DirectX の構成要素

2D の入り口から 3D 分野をのぞく

Charles Petzold

コード サンプルのダウンロード

Charles Petzold2D グラフィックスに精通している方は、3D は次元が 1 つ増えるだけで後は同じだと考えるかもしれませんが、そんなことはありません。3D グラフィックス プログラミングに手を出したことのある方なら、それがどれほど難しいかご存じでしょう。3D グラフィックス プログラミングでは、従来の 2D 分野のどの概念よりも新しくて異質な概念を習得する必要があります。ちょっとした 3D を画面に表示するだけでも多くの前提条件が必要となり、ささいな計算ミスを犯しただけで表示されなくなることもあります。そのため、グラフィックス プログラミングの学習で非常に重要になる視覚的フィードバックは、プログラミングの要素がすべて揃い、調和して機能するようになるまで得られません。

DirectX には、2D と 3D のグラフィックス プログラミングの大きな違いが反映されており、Direct2D と Direct3D に分かれています。同じ出力デバイスに 2D コンテンツと 3D コンテンツを混在させることは可能ですが、2 つはまったく異なるプログラミング インターフェイスであり、その中間はありません。DirectX では、2 つの世界に片足ずつ突っ込むようなことは不可能です。

それとも、可能でしょうか。

興味深いことに、Direct2D には 3D プログラミングの世界で生まれた概念と機能がいくつか含まれています。複雑なジオメトリを三角形に分解するジオメトリ テセレーション、グラフィックス処理ユニット (GPU) で実行する特殊なコードで構成されたシェーダーによる 2D 効果などの機能を使えば、Direct2D のコンテキストにとどまったまま、強力な 3D の概念を利用できます。

さらに、このような 3D の概念は段階的に導入して学ぶことができるうえ、画面上の結果を実際に見ることで達成感を得られます。手始めに Direct2D で 3D に触れておいてから後で Direct3D プログラミングにスムーズに移行することも可能です。

Direct2D に 3D 機能がいくつか組み込まれていることは、それほど驚くべきことではないでしょう。設計上、Direct2D は Direct3D を基盤としているため、Direct2D では GPU のハードウェア アクセラレータも利用できます。Direct2D と Direct3D のこのような関係は、Direct2D の深層を探るとさらにはっきりします。

まずは、3D 座標と座標系の復習からこの探究を始めましょう。

外界への大きな跳躍

ここ数か月間のこのコラムを読んでいる方はご存じでしょうが、IDWriteFontFace インターフェイスを実装するオブジェクトの GetGlyphRunOutline メソッドを呼び出すと、直線とベジエ曲線に基づいてテキスト文字列の輪郭を描画する ID2D1PathGeometry インスタンスを取得できます。さらに、これらの直線と曲線の座標を操作すると、さまざまな方法でテキスト文字列を変形できます。

パス ジオメトリの 2D 座標を 3D 座標に変換し、3D 座標を操作してから、再度 2D に変換して通常どおりパス ジオメトリを表示することもできます。おもしろそうですね。

2 次元空間の座標は数値のペア (X, Y) で表され、画面上の位置に対応しています。3D 座標は (X, Y, Z) の形式を取り、概念上、Z 軸は画面に直交しています。ホログラフィック ディスプレイや 3D プリンターを使用していない限り、Z 座標は、X 座標と Y 座標と比べてまったく現実的な存在ではありません。

2D 座標系と 3D 座標系の違いは他にもあります。慣習的に、2D の原点である点 (0, 0) は、ディスプレイ デバイスの左上隅にあります。X 座標の値は右方向へ増加し、Y 座標の値は下方向へ増加します。3D では、原点はほぼ必ず画面の中央に位置し、標準的なデカルト座標系に近くなっています。X 座標の値は 2D と同様に右方向へ増加しますが、Y 座標の値は上方向へ増加し、各軸には負の座標も存在します (当然ながら、3 軸の原点、倍率、および方向は行列変換で変更でき、実際に変更することもよくあります)。

概念上、正の Z 軸は画面の手前を向く場合も画面の奥を向く場合もあります。これらの 2 つの表現方法は、両者を区別する手法を引き合いに、"右手" と "左手" の座標系と呼ばれます。右手の座標系なら、右手の人差し指を正の X 軸方向へ向け、中指を正の Y 軸の方向へ向けると、親指は正の Z 軸を指します。また、右手の 4 本の指を正の X 軸から正の Y 軸へ曲げて握ると、親指は正の Z 軸を指します。左手の座標系についても、左手を使う点以外は同様です。

今回の目的は、短いテキスト文字列の 2D パス ジオメトリを取得して原点を中心に曲げ、図 1 に示す図のように文字列の先頭が末尾につながった、3D リングを作ることです。2D 座標を 3D 座標に変換してから 2D に戻すので、ここでは Y 座標の値が 2D と同様に下方向へ増加する 3D 座標系を使用することにしました。正の Z 軸は画面の手前を向いていますが、実際には左手の座標系です。

The Coordinate System Used for the Programs in This Article
図 1 この記事のプログラムで使用する座標系

この作業全体をできる限り間単にするために、プログラム リソースとして保存したフォント ファイルを使用し、IDWriteFontFace オブジェクトの取得用に IDWriteFontFile オブジェクトを作成しました。別の方法として、遠回りにはなりますが、システム フォント コレクションを使って IDWriteFontFace を取得することもできます。

続いて GetGlyphRunOutline メソッドから生成した ID2D1PathGeometry オブジェクトを D2D1_GEOMETRY_SIMPLIFICATION_OPTION_LINES 引数を使って Simplify メソッドに渡し、すべてのベジエ曲線を短い直線の連続に単純化します。この簡略化したジオメトリを FlattenedGeometrySink という名前のカスタム ID2D1GeometrySink 実装に渡して、すべての直線をさらに短い直線に分解します。その結果、直線だけで構成されたきわめて柔軟なジオメトリができあがります。

これらの座標を操作しやすいよう、FlattenedGeometrySink では Polygon オブジェクトのコレクションを生成します。図 2 は、Polygon 構造体の定義を示しています。基本的に、Polygon 構造体は接続された 2D 点の集合にすぎません。各 Polygon オブジェクトは、パス ジオメトリの閉じた図に対応しています。パス ジオメトリのすべての図が閉じているわけではありませんが、テキスト グリフは常に閉じているため、この構造体は目的にかなっています。文字によっては、単一の Polygon のみで表せる文字 (C、E、X など)、内側と外側の 2 つの Polygon オブジェクトで構成される文字 (A、D、および O)、3 つの Polygon で構成される文字 (B など) があります。記号文字の中にはさらに多くの Polygon オブジェクトで構成される文字もあるでしょう。

図 2 閉じたパス図を格納する Polygon クラス

 

struct Polygon
{
  // Constructors
  Polygon()
  {
  }
  Polygon(size_t pointCount)
  {
    Points = std::vector<D2D1_POINT_2F>(pointCount);
  }
  // Move constructor
  Polygon(Polygon && other) : Points(std::move(other.Points))
  {
  }
  std::vector<D2D1_POINT_2F> Points;
  static HRESULT CreateGeometry(ID2D1Factory* factory,
                                const std::vector<Polygon>& polygons,
                                ID2D1PathGeometry** pathGeometry);
};
HRESULT Polygon::CreateGeometry(ID2D1Factory* factory,
                                const std::vector<Polygon>& polygons,
                                ID2D1PathGeometry** pathGeometry)
{
  HRESULT hr;
  if (FAILED(hr = factory->CreatePathGeometry(pathGeometry)))
    return hr;
  Microsoft::WRL::ComPtr<ID2D1GeometrySink> geometrySink;
  if (FAILED(hr = (*pathGeometry)->Open(&geometrySink)))
    return hr;
  for (const Polygon& polygon : polygons)
  {
    if (polygon.Points.size() > 0)
    {
      geometrySink->BeginFigure(polygon.Points[0],
                                D2D1_FIGURE_BEGIN_FILLED);
      if (polygon.Points.size() > 1)
      {
        geometrySink->AddLines(polygon.Points.data() + 1,
                               polygon.Points.size() - 1);
      }
      geometrySink->EndFigure(D2D1_FIGURE_END_CLOSED);
    }
  }
  return geometrySink->Close();
}

今回のコラムのダウンロード可能なコードには、CircularText という名前の Windows ストア プログラムがあります。このプログラムでは、"Text in an Infinite Circle of" というテキストに基づいて、最後の文字が最初の文字に接続してリング形になるように Polygon オブジェクトのコレクションを作成します。実際には、テキスト文字列をプログラムで "ext in an Infinite Circle of T" と指定しています。これは、グリフからパス ジオメトリを生成すると抜け落ちる場合がある、先頭や末尾のスペースを回避するためです。

CircularText プロジェクトの CircularTextRenderer クラスには、m_srcPolygons (パス ジオメトリから生成した元の Polygon オブジェクト) と m_dstPolygons (レンダリングされるパス ジオメトリの生成に使用する Polygon オブジェクト) という 2 つの std::vector オブジェクトが含まれています。図 3 は、画面サイズに基づいて変換元のポリゴンを変換先のポリゴンに変換する CreateWindowSizeDependentResources メソッドを示しています。

図 3 CircularText プログラムでの 2D - 3D - 2D 変換

void CircularTextRenderer::CreateWindowSizeDependentResources()
{
  // Get window size and geometry size
  Windows::Foundation::Size logicalSize = m_deviceResources->GetLogicalSize();
  float geometryWidth = m_geometryBounds.right - m_geometryBounds.left;
  float geometryHeight = m_geometryBounds.bottom - m_geometryBounds.top;
  // Calculate a few factors for converting 2D to 3D
  float radius = logicalSize.Width / 2 - 50;
  float circumference = 2 * 3.14159f * radius;
  float scale = circumference / geometryWidth;
  float height = scale * geometryHeight;
  for (size_t polygonIndex = 0; polygonIndex < m_srcPolygons.size(); polygonIndex++)
  {
    const Polygon& srcPolygon = m_srcPolygons.at(polygonIndex);
    Polygon& dstPolygon = m_dstPolygons.at(polygonIndex);
    for (size_t pointIndex = 0; pointIndex < srcPolygon.Points.size(); pointIndex++)
    {
      const D2D1_POINT_2F pt = srcPolygon.Points.at(pointIndex);
      float radians = 2 * 3.14159f * (pt.x - m_geometryBounds.left) / geometryWidth;
      float x = radius * sin(radians);
      float z = radius * cos(radians);
      float y = height * ((pt.y - m_geometryBounds.top) / geometryHeight - 0.5f);
      dstPolygon.Points.at(pointIndex) = Point2F(x, y);
    }
  }
  // Create path geometry from Polygon collection
  DX::ThrowIfFailed(
    Polygon::CreateGeometry(m_deviceResources->GetD2DFactory(),
                            m_dstPolygons,
                            &m_pathGeometry)
    );
}

内側のループを見ると、x、y、および z の値を計算していることがわかります。これは 3D 座標ですが、保存すらしません。その代わりに、z 値を無視することですぐに 2D に分解して戻します。これらの 3D 座標を計算するために、コードでは、まず元のパス ジオメトリの水平位置を 0 から 2π までのラジアンの角度に変換します。sin 関数と cos 関数は、XZ 平面の単位円上の位置を計算しています。y 値は、元のパス ジオメトリの垂直座標をさらに直接的に変換した値です。

CreateWindowSizeDependentResources メソッドでは、最後に、変換先の Polygon コレクションから新しい ID2D1PathGeometry オブジェクトを取得します。次に、Render メソッドで行列変換を設定して画面中央に原点を配置し、このパス ジオメトリの塗りつぶしと輪郭描画の両方を実行します。その結果は、図 4 に示すとおりです。

The CircularText Display
図 4 CircularText の表示

プログラムは機能しているのでしょうか。これではよくわかりません。よく見ると、中央の文字の幅が広くて左右の文字の幅が狭いことを確認できます。しかし、大きな問題は、交わる線のないパス ジオメトリから処理を開始したことです。このため、ジオメトリの一部がその背後に表示され、重なった領域が塗りつぶされなくなっています。この効果はジオメトリの特性であり、Polygon 構造体で作成したパス ジオメトリの塗りつぶしモードが交互でも全域でも発生します。

遠近感を生み出す

3 次元グラフィックス プログラミングは、座標点だけが重要なのではありません。ユーザーが 2D 画面のイメージを 3D 空間のオブジェクトとして解釈するには、視覚的な手掛かりが必要です。現実世界では、ある一定の視点でオブジェクトを見ることはめったにありません。図 4 の 3D テキストを少し傾けて図 1 のリングのようにすれば、表示を改善できます。

3 次元テキストの遠近感を生み出すには、空間内で座標を回転する必要があります。ご存じのように、Direct2D は、2D 変換の定義に使用できる D2D1_MATRIX_3x2_F という行列変換構造体をサポートしています。最初に ID2D1RenderTarget の SetTransform メソッドを呼び出しておけば、2D 変換を 2D グラフィックス出力に適用できます。

そのための最も一般的な方法は、D2D1 名前空間の Matrix3x2F というクラスを使用することでしょう。このクラスは D2D1_MATRIX_3x2F_F から派生し、移動、拡大縮小、回転、および傾斜に関するさまざまな種類の標準を定義するメソッドを提供します。

Matrix3x2F クラスでは、個々の D2D1_POINT_2F オブジェクトに "手動で" 変換を適用できるようにする、TransformPoint という名前のメソッドも定義しています。このメソッドは、レンダリング前に点を操作する場合に役立ちます。

表示されるテキストを傾けるには 3D 回転行列が必要だとお思いでしょうか。3D 行列変換についてはもちろん今後のコラムで説明する予定ですが、今回は 2D 回転で間に合います。自分が図 1 の負の X 軸のどこかに立っていて、原点を向いていると想像してみてください。正の Z 軸と Y 軸は従来の 2D 座標系の X 軸と Y 軸と同じ位置関係にあるので、2D 回転行列を Z 値と Y 値に適用すれば、3 次元 X 軸を中心にすべての座標を回転できそうです。

この操作は、CircularText プログラムで試すことができます。CreateWindowSizeDependentResources メソッド内の、Polygon 座標を操作する前の任意の時点で、2D 回転行例を作成します。

Matrix3x2F tiltMatrix = Matrix3x2F::Rotation(-8);

このコードは -8 度の回転を表し、マイナス記号は反時計回りに回転することを示しています。内側のループで、x、y、および z を計算したら、x 値と y 値に適用する場合と同様にこの変換を z 値と y 値に適用します。

 

 

D2D1_POINT_2F tiltedPoint =
     tiltMatrix.TransformPoint(Point2F(z, y));
z = tiltedPoint.x;
y = tiltedPoint.y;

図 5 に結果を示します。

The Tilted CircularText Display
図 5 傾けた CircularText の表示

かなり良くなりましたが、まだ問題が残っています。ジオメトリが重なり合う箇所が適切に表示されていないうえ、ジオメトリのどの部分が手前にあってどの部分が奥にあるのかがまるでわかりません。じっと見ていると、違う部分が手前に見えてくるでしょう。

しかし、このオブジェクトに 3D 変換を適用できることを考えれば、Y 軸を中心にオブジェクトを回転するのも簡単そうに思えます。実際、これはそのとおりです。正の Y 軸から原点を見ているとすれば、X 軸と Z 軸は 2D 座標系の X 軸と Y 軸と同じ向きに見えるはずです。

SpinningCircularText プロジェクトは、2 つの回転変換を実装してテキストを回転し、傾けます。これまで CreateWindowSizeDependentResources に配置していたすべての計算ロジックは、Update メソッドに移動しています。3D 点の回転は 2 回あります。1 回は経過時間に基づいて X 軸を中心に回転し、もう 1 回はユーザーが画面を上下にスイープする操作に基づいて Y 軸を中心に回転します。この Update メソッドを図 6 に示します。

図 6 SpinningCircularText プロジェクトの Update メソッド

 

void SpinningCircularTextRenderer::Update(DX::StepTimer const& timer)
{
  // Get window size and geometry size
  Windows::Foundation::Size logicalSize = m_deviceResources->GetLogicalSize();
  float geometryWidth = m_geometryBounds.right - m_geometryBounds.left;
  float geometryHeight = m_geometryBounds.bottom - m_geometryBounds.top;
  // Calculate a few factors for converting 2D to 3D
  float radius = logicalSize.Width / 2 - 50;
  float circumference = 2 * 3.14159f * radius;
  float scale = circumference / geometryWidth;
  float height = scale * geometryHeight;
  // Calculate rotation matrix
  float rotateAngle = -360 * float(fmod(timer.GetTotalSeconds(), 10)) / 10;
  Matrix3x2F rotateMatrix = Matrix3x2F::Rotation(rotateAngle);
  // Calculate tilt matrix
  Matrix3x2F tiltMatrix = Matrix3x2F::Rotation(m_tiltAngle);
  for (size_t polygonIndex = 0; polygonIndex < m_srcPolygons.size(); polygonIndex++)
  {
    const Polygon& srcPolygon = m_srcPolygons.at(polygonIndex);
    Polygon& dstPolygon = m_dstPolygons.at(polygonIndex);
    for (size_t pointIndex = 0; pointIndex < srcPolygon.Points.size(); pointIndex++)
    {
      const D2D1_POINT_2F pt = srcPolygon.Points.at(pointIndex);
      float radians = 2 * 3.14159f * (pt.x - m_geometryBounds.left) / geometryWidth;
      float x = radius * sin(radians);
      float z = radius * cos(radians);
      float y = height * ((pt.y - m_geometryBounds.top) / geometryHeight - 0.5f);
      // Apply rotation to X and Z
      D2D1_POINT_2F rotatedPoint = rotateMatrix.TransformPoint(Point2F(x, z));
      x = rotatedPoint.x;
      z = rotatedPoint.y;
      // Apply tilt to Y and Z
      D2D1_POINT_2F tiltedPoint = tiltMatrix.TransformPoint(Point2F(y, z));
      y = tiltedPoint.x;
      z = tiltedPoint.y;
      dstPolygon.Points.at(pointIndex) = Point2F(x, y);
    }
  }
  // Create path geometry from Polygon collection
  DX::ThrowIfFailed(
    Polygon::CreateGeometry(m_deviceResources->GetD2DFactory(),
    m_dstPolygons,
    &m_pathGeometry)
    );
    // Update FPS display text
    uint32 fps = timer.GetFramesPerSecond();
    m_text = (fps > 0) ? std::to_wstring(fps) + L" FPS" : L" - FPS";
}

複合行列変換が行列乗算と等しいことは有名ですが、行列乗算には可換性がないため、複合変換にもありません。傾斜変換と回転変換の適用を切り替えて、効果を変更してみてください (こちらの方がお気に召すかもしれません)。

SpinningCircularText プログラムの作成時には、Visual Studio テンプレートで作成した SampleFpsTextRenderer クラスを編集して SpinningCircularTextRenderer クラスを作成しましたが、レンダリング レートの表示は残しました。このため、パフォーマンスの高さを確認できます。手元の Surface Pro の場合、デバッグ モードでは 25 フレーム/秒 (FPS) です。これはコードがビデオ ディスプレイのリフレッシュ レートに対応できていないことを示しています。

パフォーマンスが気に入らない方には申し訳ありませんが、これからさらに低くしていきます。

前景と背景を分割する

3D 向けのパス ジオメトリ手法に関する最大の問題は、重なり合っている領域の影響です。こうした重なり合いを避けることは可能でしょうか。このプログラムで描画しようとしているイメージは、それほど複雑なものではありません。テキストには表向きに表示される部分と残りの裏向きに表示される部分が必ずあり、表向きのテキストは常に裏向きのテキストの上に表示されます。パス ジオメトリを前景用と背景用の 2 つのパス ジオメトリに分割できるとすれば、分割したパス ジオメトリを別々の FillGeometry 呼び出しでレンダリングして、前景を背景の上に表示できるでしょう。さらに、これら 2 つのパス ジオメトリをさまざまなブラシでレンダリングすることもできるはずです。

GetGlyphRunOutline メソッドで作成した元のパス ジオメトリについて考えてみましょう。これは、四角形の領域を占めている単なる平面 2D パス ジオメトリです。最終的に、このジオメトリの半分は前景に表示され、残りの半分は背景に表示されます。しかし、Polygon オブジェクトの取得時点では、簡単な計算で分割するには手遅れです。

代わりに、Polygon オブジェクトの取得前に元のパス ジオメトリを半分に分割する必要があります。この分割は回転角に依存しているため、大量のロジックを Update メソッドに移動する必要があります。

元のパス ジオメトリを半分に分割するには、CombineWithGeometry メソッドを 2 回呼び出します。このメソッドは、2 つのジオメトリをさまざまな方法で組み合わせて 3 つ目のジオメトリを作成します。組み合わせる 2 つのジオメトリは、テキストの輪郭を描画する元のパス ジオメトリと、パス ジオメトリのサブセットを定義する四角形のジオメトリです。このサブセットを、回転角に基づいて前景または背景に表示します。

たとえば、回転角が 0 の場合、四角形のジオメトリはテキスト輪郭のパス ジオメトリの中央半分を占める必要があります。これは、元のジオメトリのうち前景に表示される部分です。D2D1_COMBINE_MODE_INTERSECT モードで CombineWithGeometry を呼び出すと、この中央領域のみで構成されたパス ジオメトリが返されます。一方、D2D1_COMBINE_MODE_EXCLUDE モードで CombineWithGeometry を呼び出すと、残る左右部分のパス ジオメトリが返されます。これら 2 つのパス ジオメトリは、別々に Polygon オブジェクトに変換して座標を操作してから、レンダリングのために別々のパス ジオメトリに再変換できます。

このロジックは OccludedCircularText というプロジェクトに配置しています。OccludedCircularText プロジェクトでは、2 つのジオメトリを異なるブラシで塗りつぶすことで Render メソッドを実装します (図 7 参照)。

The OccludedCircularText Display
図 7 OccludedCircularText の表示

これで、前景にある部分と背景にある部分が、随分わかりやすくなりました。ただし、あまりに多くの計算を Update メソッドに移動したので、パフォーマンスが大きく低下しました。

従来の 2D プログラミング環境で利用できる 2D プログラミング手段はすべて使い果たしたことになりますが、このひどいパフォーマンスに陥っています。ただし、Direct2D には、ロジックを簡略化してパフォーマンスを大幅に高める別のジオメトリ レンダリング方法があります。この解決策では、3D プログラミングでも大きな役割を果たす最も基本的な 2D ポリゴンを利用します。

もちろん、これは普通の三角形のことです。

Charles Petzold は MSDN マガジンの記事を長期にわたって担当しており、Windows 8 向けのアプリケーション開発についての書籍『Programming Windows, 6th edition』 (O'Reilly Media、2012 年) の著者でもあります。彼の Web サイトは charlespetzold.com (英語) です。

この記事のレビューに協力してくれた技術スタッフの Jim Galasyn (マイクロソフト) に心より感謝いたします。