次の方法で共有


DirectX の構成要素

3D 空間で三角形を操作する

Charles Petzold

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

Charles Petzoldオランダ人のグラフィック アーティスト、M. C. Escher は、いつもプログラマ、エンジニアなどの技術分野で働く人に好まれています。不可能な構造を題材にし、機知に富んだ作風は、見た目の情報から法則を見いだそうとする抗いようのない意識を欺き、数学から着想を得たメッシュ パターンの使い方は、ソフトウェア再帰技法との親和性を示しているかのようです。

Escher の作品の中で私が特に気に入っているのは、1948 年の『描く手』に代表される、2 次元と 3 次元の画像を融合させた作品です。『描く手』では、2D の絵から一組の 3D の手が浮き出しており、その 2D の絵自体が 3D の手によって描かれています (図 1 参照)。しかし、この 2D 画像と 3D 画像の並置によって強調されているのは、3D の手はディテールとシェーディングの産物として立体的に見えているだけということです。この絵のすべてが平面の紙に描かれていることは明らかです。

M.C. Escher’s “Drawing Hands”
図 1 M.C. Escher の『描く手』

今回は、これと似たことを行います。2D のグラフィカル オブジェクトに奥行きと立体感を与え、画面から浮き出して 3D 空間を漂い、再び平面の画面に戻るように見せます。

ただし、ここで作成するグラフィカル オブジェクトは人間の手の肖像ではなく、おそらく最も単純な 3D オブジェクトである 5 つの正多面体を使います。正多面体とは、すべての面が同一の正多角形で構成され、かつ頂点で接する面の数がすべて等しい凸多面体を指します。正多面体には、四面体 (4 個の三角形)、八面体 (8 個の三角形)、二十面体 (20 個の三角形)、立方体 (6 個の四角形)、および十二面体 (12 個の五角形) があります。

正多面体は、一般に定義および組み立てが容易なことから、初歩的な 3D グラフィックス プログラミングでよく用いられます。頂点を求める公式は、Wikipedia などで紹介されています。

今回の演習をできるだけわかりやすくするため、ここでは Direct3D ではなく Direct2D を使用します。ただし、Direct3D との関連でよく使用するいくつかの概念、データ型、関数、および構造体に慣れておく必要があります。

ここでは、これらの立体オブジェクトを三角形を使って 3D 空間で定義し、次に 3D 変換を適用して回転させようと考えています。次に、変換した三角形の座標の Z 座標を無視することで 2D 空間に平坦化し、そこから ID2D1Mesh オブジェクトを作成したうえで、ID2D1DeviceContext オブジェクトの FillMesh メソッドを使用してレンダリングします。

後ほど説明しますが、3D オブジェクトの座標を定義するだけでは不十分です。光の反射を模倣するためにシェーディングを適用して初めて、オブジェクトは平面から浮き出して見えます。

3D の点と変換

今回の演習では、3D の点に 3D 行列変換を施して、空間でオブジェクトを回転させます。そのために、最適なデータ型と関数は何でしょう。

Direct2D の D2D1 名前空間には 3D 変換行列を表すのに適した D2D1_MATRIX_4X4_F 構造体と Matrix4x4F クラスがあります。ただし、4 月号の実装で実演したように、これらのデータ型は ID2D1DeviceContext で定義した DrawBitmap メソッドで使用するためにのみ設計されています。特に、Matrix4x4F には 3D の点に変換するために適用できる Transform という名前のメソッドすらありません。この行列乗算は自分でコードを記述して実装する必要があります。

3D データ型を探すのは、Direct3D プログラムでも使用する DirectX Math ライブラリが適当です。このライブラリには 500 以上の関数 (すべて XM という文字で始まる関数) といくつかデータ型が定義されています。これらはすべて DirectXMath.h ヘッダー ファイルで宣言されており、DirectX の名前空間に関連付けられています。

DirectX Math ライブラリのそれぞれの関数は、4 つの数値のコレクションである XMVECTOR というデータ型を使用しています。XMVECTOR は、2D または 3D の点 (W 座標を使用する場合を含む) または色 (アルファ チャネルを使用する場合を含む) を表すのに適しています。以下は、XMVECTOR 型オブジェクトの定義方法です。

XMVECTOR vector;

XMVECTOR は、「4 つの浮動小数点値」でも「4 つの整数値」でもなく、「4 つの数値」のコレクションだと説明しました。XMVECTOR オブジェクトの 4 つの数値の実際の形式はハードウェアに依存するため、これ以上具体的な言い方はできません。

XMVECTOR は、通常のデータ型とは異なります。実際にはプロセッサ チップの 4 つのハードウェア レジスタ、具体的には SIMD (Single Instruction/Multiple Data) レジスタのプロキシで、並列処理を実装するストリーミング SIMD 拡張 (SSE) と一緒に使用します。x86 ハードウェアでは、これらのレジスタは実際には単精度浮動小数点値ですが、ARM プロセッサ (Windows RT デバイスで使用) では、小数部分を含むように定義された整数値です。

このため、XMVECTOR オブジェクトのフィールドに直接アクセスするのは適切ではありません (明確な意図がある場合は除きます)。代わりに、DirectX Math ライブラリには整数値または浮動小数点値をこのオブジェクトのフィールドに設定する多数の関数があります。以下のような使い方が一般的です。

XMVECTOR vector = XMVectorSet(x, y, z, w);

個々のフィールド値を取得する関数もあります。

float x = XMVectorGetX(vector);

このデータ型は、ハードウェア レジスタのプロキシなので、使用には一定の制約があります。XMVECTOR 型の構造体メンバーを定義し、XMVECTOR 引数を関数に渡す方法の詳細については、オンラインの「XNA Math ライブラリ プログラミング ガイド」(https://msdn.microsoft.com/ja-jp/library/windows/desktop/ee415571(v=vs.85).aspx) を参照してください。

ただし、一般には XMVECTOR は主にメソッド ローカルなコードで使用することになります。3D の点とベクトルの汎用の格納場所として、DirectX Math ライブラリでは XMFLOAT3 (x、y、および z という float 型の 3 つのデータ メンバーを含む)、XMFLOAT4 (w を加え 4 つのデータ メンバーを含む) など、シンプルな通常の構造体のデータ型も定義されています。具体的には、点の配列を格納する場合 XMFLOAT3 または XMFLOAT4 を使用します。

XMVECTOR と XMFLOAT3 や XMFLOAT4 との間の変換は簡単です。3D の点の格納に XMFLOAT3 を使用しているとします。

XMFLOAT3 point;

XMVECTOR を必要とする DirectX Math 関数の 1 つを使用するときは、XMLoadFloat3 関数を使って XMVECTOR に値を読み込みます。

XMVECTOR vector = XMLoadFloat3(&point);

XMVECTOR の w 値は 0 に初期化されます。こうすれば、さまざまな DirectX Math 関数で XMVECTOR オブジェクトを使用できます。XMVECTOR 値を再び XMFLOAT3 オブジェクトに格納するには、以下のように呼び出します。

XMStoreFloat3(&point, vector);

同様に、XMLoadFloat4 と XMStoreFloat4 は、XMVECTOR オブジェクトと XMFLOAT4 オブジェクトとの間で値を変換します。こちらは W 座標が重要な場合に使用します。

一般には、同じコード ブロックで複数の XMVECTOR オブジェクトを操作します。操作するオブジェクトの中には基盤となる XMFLOAT3 や XMFLOAT4 に対応するオブジェクトもあれば、単に一時的なオブジェクトもあります。この後例を見ることにします。

少し前に、DirectX Math ライブラリのすべての関数が XMVECTOR に関係すると説明しました。ライブラリを調べてみると、実際には XMVECTOR を必要とせず、XMMATRIX 型のオブジェクトを使用する関数があることがわかります。

XMMATRIX データ型は 3D 変換に適した 4×4 の行列ですが、実際にはそれぞれが行に相当する 4 つの XMVECTOR オブジェクトです。

struct XMMATRIX
{
  XMVECTOR r[4];
};

このように、XMMATRIX オブジェクトを必要とするすべての DirectX Math 関数は実際には XMVECTOR オブジェクトも使用し、XMMATRIX には XMVECTOR と同じ制約があるため、上記のような説明にしました。

XMFLOAT4 が XMVECTOR オブジェクトとの間で値を変換するのに使用できる標準構造体であるのと同様に、MFLOAT4X4 という標準構造体を使って 4×4 行列を格納でき、XMLoadFloat4x4 関数と XMStoreFloat4x4 関数を使って XMMATRIX との間で変換できます。

3D の点を XMVECTOR オブジェクト (たとえば、vector というオブジェクト) に読み込み、変換行列を matrix という XMMATRIX オブジェクトに読み込んでいる場合、以下のように点を変換できます。

XMVECTOR result = XMVector3Transform(vector, matrix);

あるいは、以下のようにも変換できます。

XMVECTOR result = XMVector4Transform(vector, matrix);

唯一の違いは、XMVector4Transform は XMVECTOR の実際の w 値を使用するのに対し、XMVector3Transform は値を 3D 移動の実装に適切な w=1 と仮定することです。

ただし、XMFLOAT3 値または XMFLOAT4 値の配列があり、配列全体に変換を適用する場合は、もっと良い方法があります。XMVector3TransformStream 関数と XMVector4TransformStream 関数は、XMMATRIX を値の配列に適用し、(入力型に関わらず) 結果を XMFLOAT4 値の配列に格納します。

さらに、XMMATRIX は実際には SSE を実装する CPU 上の SIMD レジスタなので、CPU は並列処理を使ってこの変換を点の配列に適用し、3D レンダリングの最大のボトルネックの 1 つを高速化できます。

正多面体の定義

コラム付属のダウンロード コードは、PlatonicSolids という名前の 1 つの Windows 8.1 プロジェクトです。このプログラムは、Direct2D を使って 5 つの正多面体の 3D 画像をレンダリングします。

すべての 3D 図形と同様、これらの正多面体は 3D 空間における三角形の集合として表すことができます。XMVector3TransformStream または XMVector4TransformStream を使って 3D 三角形の配列を変換することと、これら 2 つの関数の出力配列が常に XMFLOAT4 オブジェクトの配列であることがわかっているため、入力配列にも XMFLOAT4 を使用します。そのため、以下のように 3D 三角形の構造体を定義します。

struct Triangle3D
{
  DirectX::XMFLOAT4 point1;
  DirectX::XMFLOAT4 point2;
  DirectX::XMFLOAT4 point3;
};

図 2 は、3D 図形の記述とレンダリングに必要な情報を格納する追加のプライベート データ構造体を示しています。この構造体は、 PlatonicSolidsRenderer.h で定義しています。5 つの正多面体はそれぞれ FigureInfo 型のオブジェクトです。srcTriangles コレクションと dstTriangles コレクションは、倍率変換と回転変換を適用した後、元の "ソース" 三角形と "ターゲット" 三角形を格納します。どちらのコレクションのサイズも、faceCount と trianglesPerFace の積に等しくなります。srcTriangles.data と dstTriangles.data は、実際には XMFLOAT4 構造体へのポインターなので、XMVector4TransformStream 関数への引数として指定できます。これは PlatonicSolidRenderer クラスの Update メソッドで行います。

図 2 3D 図形の格納に使用するデータ構造体

struct RenderInfo
{
  Microsoft::WRL::ComPtr<ID2D1Mesh> mesh;
  Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> brush;
};
struct FigureInfo
{
  // Constructor
  FigureInfo()
  {
  }
  // Move constructor
  FigureInfo(FigureInfo && other) :
    srcTriangles(std::move(other.srcTriangles)),
    dstTriangles(std::move(other.dstTriangles)),
    renderInfo(std::move(other.renderInfo))
  {
  }
  int faceCount;
  int trianglesPerFace;
  std::vector<Triangle3D> srcTriangles;
  std::vector<Triangle3D> dstTriangles;
  D2D1_COLOR_F color;
  std::vector<RenderInfo> renderInfo;
};
std::vector<FigureInfo> m_figureInfos;

renderInfo フィールドは RenderInfo オブジェクトのコレクションで、各オブジェクトが図形の各面に対応します。この構造体の 2 つのメンバーも Update メソッドで決定し、Render メソッドで単純に ID2D1DeviceContext オブジェクトの FillMesh メソッドに渡します。

PlatonicSolidsRenderer クラスのコンストラクターでは、5 つの FigureInfo オブジェクトをそれぞれ初期化します。図 3 に、5 つのうち最もシンプルな四面体の処理を示します。

図 3 四面体の定義

FigureInfo tetrahedron;
tetrahedron.faceCount = 4;
tetrahedron.trianglesPerFace = 1;
tetrahedron.srcTriangles =
{
  Triangle3D { XMFLOAT4(-1,  1, -1, 1),
               XMFLOAT4(-1, -1,  1, 1),
               XMFLOAT4( 1,  1,  1, 1) },
  Triangle3D { XMFLOAT4( 1, -1, -1, 1),
               XMFLOAT4( 1,  1,  1, 1),
               XMFLOAT4(-1, -1,  1, 1) },
  Triangle3D { XMFLOAT4( 1,  1,  1, 1),
               XMFLOAT4( 1, -1, -1, 1),
               XMFLOAT4(-1,  1, -1, 1) },
  Triangle3D { XMFLOAT4(-1, -1,  1, 1),
               XMFLOAT4(-1,  1, -1, 1),
               XMFLOAT4( 1, -1, -1, 1) }
};
tetrahedron.srcTriangles.shrink_to_fit();
tetrahedron.dstTriangles.resize(tetrahedron.srcTriangles.size());
tetrahedron.color = ColorF(ColorF::Magenta);
tetrahedron.renderInfo.resize(tetrahedron.faceCount);
m_figureInfos.at(0) = tetrahedron;

八面体と二十面体の初期化も同様です。これら 3 つの正多面体は、各面が 1 つの三角形で構成されます。ピクセルに関しては、座標は非常に小さいものの、プログラム後半のコードで適切なサイズに拡大します。

しかし、立方体と十二面体は異なります。立方体は 6 つの面から成りますが、各面は四角形で、十二面体は 12 個の五角形で構成されます。これら 2 つの図形には、別のデータ構造体を使ってそれぞれの面の頂点を格納したうえ、各面を三角形に変換する一般的な方法を使用しました。つまり、立方体の各面を三角形 2 つに、十二面体の各面を三角形 3 つに変換します。

3D 座標を 2D 座標に容易に変換できるように、正の X 座標が右方向へ増加し、正の Y 座標が下方向へ増加する座標系を基準に図形を処理します (3D プログラミングでは正の Y 座標は上方向へ増加するのが一般的です)。また、正の Z 座標は画面の手前に向けて増加するものとします。つまり、左手座標系になります。左手の人差し指を正の X 方向へ向け、中指を正の Y 方向へ向けると、親指が正の Z 方向を向きます。

コンピューター画面に向かって座ると、正の Z 軸上の点から原点方向を見ていることになります。

3D での回転

PlatonicSolidsRenderer の Update メソッドは、複数のセクションから成るアニメーションを実行します。プログラムの実行を開始すると、5 つの正多面体が表示されますが、まだ平面のようにしか見えません (図 4 参照)。

The PlatonicSolids Program As It Begins Running
図 4 実行開始時の PlatonicSolids プログラム

これらを 3D オブジェクトだと言うのは、いくらなんでも無理でしょう。

2.5 秒を経過したあたりから、オブジェクトが回転を始めます。Update メソッドは、画面サイズに基づいて、回転角度と倍率を計算してから、DirectX Math 関数を使用します。XMMatrixRotationX などの関数は、X 軸を中心とした回転を表す XMMATRIX オブジェクトを計算します。XMMATRIX は、行列乗算演算子も定義するため、これらの関数の結果を乗算できます。

図 5 は、行列変換の合計を計算し、それを各図形内の Triangle3D オブジェクトの配列に適用する方法を示しています。

図 5 図形の回転

// Calculate total matrix
XMMATRIX matrix = XMMatrixScaling(scale, scale, scale) *
                  XMMatrixRotationX(xAngle) *
                  XMMatrixRotationY(yAngle) *
                  XMMatrixRotationZ(zAngle);
// Transform source triangles to destination triangles
for (FigureInfo& figureInfo : m_figureInfos)
{
  XMVector4TransformStream(
    (XMFLOAT4 *) figureInfo.dstTriangles.data(),
    sizeof(XMFLOAT4),
    (XMFLOAT4 *) figureInfo.srcTriangles.data(),
    sizeof(XMFLOAT4),
    3 * figureInfo.srcTriangles.size(),
    matrix);
}

しかし、図形が回転を開始しても、形状は変化するものの、まだ平面の多角形のように見えます。

遮蔽と隠れた面

3D グラフィックス プログラミングの重要な側面の 1 つは、手前のオブジェクトに視界が遮られた場合に奥のオブジェクトが見えないようにすることです (遮蔽効果)。複雑なシーンでは、これはなかなか厄介な問題で、一般にはグラフィックス ハードウェアでピクセル単位に実行する必要があります。

ただし、凸多面体の場合は比較的シンプルです。立方体について考えてみます。立方体が空間で回転すると、ほとんどの場合は 3 つの面が視界に入り、時折 1 つか 2 つの面しか見えなくなります。4 つまたは 5 つの面や、6 つすべての面が見えることはありません。

回転する立方体の特定の面について、どの面が見えていて、どの面が見えていないかをどのように判断すればよいでしょう。立方体の各面に垂直で、立方体の外側方向を指すベクトル (多くの場合、特定の方向を持った矢印として表示) について考えます。このようなベクトルを、"面法線" ベクトルと呼びます。

面法線ベクトルに正の Z 要素が含まれる場合のみ、その面は正の Z 軸上の点からオブジェクトを見ている人の視界に入ります。

数学的には、三角形の面法線を計算するのは簡単です。三角形の 3 つの頂点から 2 つのベクトルを定義し、3D 空間の 2 つのベクトル (V1 と V2) で平面を定義し、その平面に対する法線をベクトルのクロス積から求めます (図 6 参照)。

The Vector Cross Product
図 6 ベクトルのクロス積

このベクトルの実際の方向は、その座標系が右手座標系か左手座標系かによって決まります。たとえば、右手座標系では、右手の親指を除く 4 本の指を V1 から V2 に向けて曲げることで V1×V2 クロス積の方向を判断できます。親指が指しているのがクロス積の方向です。左手座標系では、同じことを左手を使って行います。

これらの図形を構成する任意の特定の三角形で、まず 3 つの頂点を XMVECTOR オブジェクトに読み込みます。

XMVECTOR point1 = XMLoadFloat4(&triangle3D.point1);
XMVECTOR point2 = XMLoadFloat4(&triangle3D.point2);
XMVECTOR point3 = XMLoadFloat4(&triangle3D.point3);

次に、三角形の 2 辺を表す 2 つのベクトルは、便利な DirectX Math 関数を使って point1 から point2 と point3 を減算することで計算できます。

XMVECTOR v1 = XMVectorSubtract(point2, point1);
XMVECTOR v2 = XMVectorSubtract(point3, point1);

このプログラムの正多面体は、すべて図形の外側から三角形を見たときに、point1、point2、point3 の順に時計回りに配置された 3 つの点を頂点とする三角形で定義します。図形の外側を指す面法線は、クロス積を求める DirectX Math 関数を使って計算できます。

XMVECTOR normal = XMVector3Cross(v1, v2);

これらの図形を表示するプログラムでは、0 または負の Z 要素が含まれる面法線を持った三角形は、単純に表示しないという方法も選択できます。PlatonicSolids プログラムでは、こうした三角形も引き続き表示しますが、透明色で表示します。

シェーディングこそすべて

現実世界で物体を見ることができるのは、光を反射しているためです。光がなければ、何も見えません。多くの現実環境で、光は他の面を跳ね返ったり、空気中で拡散したりするため、さまざまな方向から飛来します。

3D グラフィックス プログラミングでは、これを "周辺" 光と呼びますが、周辺光だけでは不十分です。立方体が 3D 空間を漂っており、同じ周辺光が 6 つすべての面に当たっているとしたら、6 つの面はすべて同様に着色され、3D の立方体にはまるで見えません。

そのため、3D のシーンでは、通常 1 つまたは複数の方向から当てられる指向性光が必要です。シンプルな 3D シーンでよく用いられるのは、指向性光の光源を、見る人の左肩の背後から伸びてくるベクトルとして定義する方法です。

XMVECTOR lightVector = XMVectorSet(2, 3, -1, 0);

見る人の視点からは、右下を指し、負の Z 軸方向へ離れていく、多数のベクトルの 1 つです。

次の作業の準備として、面法線ベクトルとこの指向性光ベクトルをどちらも正規化します。

normal = XMVector3Normalize(normal);
lightVector = XMVector3Normalize(lightVector);

XMVector3Normalize 関数は、ピタゴラスの定理の 3D 形式を使用してベクトルの絶対値を計算し、その絶対値で 3 つの座標を除算します。その結果、ベクトルの絶対値は 1 になります。

法線ベクトルが lightVector の負の値と等しくなった場合、これは光が三角形の面に垂直に当てられており、指向性光が提供できる最大の照度であることを意味します。指向性光が、三角形の面に対して十分に垂直でなければ照度が落ちます。

数学的には、指向性光の光源からの面の照度は、指向性光ベクトルと負の面法線の間の角度の余弦と等しくなります。これら 2 つのベクトルの絶対値が 1 の場合、この重要な数値は 2 つのベクトルのドット積で求められます。

XMVECTOR dot = XMVector3Dot(normal, -lightVector);

ドット積はベクトルではなくスカラー (1 つの数値) なので、この関数で返される XMVECTOR オブジェクトのすべてのフィールドが同じ値を保持します。

回転する正多面体に 3D の奥行きを与え、魔法のように平面から浮き出すようにするため、PlatonicSolids プログラムは lightIntensity 値を 0 から 1 へ、そして 1 から 0 へ徐々に変化させます。値が 0 だと、指向性光のシェーディングと 3D 効果は生まれず、値が 1 のときに最大の 3D 効果が生まれます。この lightIntensity 値は、ドット積と共に使用し、総光量の要素を計算します。

float totalLight = 0.5f +
  lightIntensity * 0.5f * XMVectorGetX(dot);

この式の最初の 0.5 は周辺光を表し、2 つ目の 0.5 はドット積の値に基づいて totalLight を 0 から 1 の範囲で算出します (理論的には、これは正確ではありません。ドット積が負の値の場合、総光量の方が周辺光よりも低くなってしまうため、ドット積を 0 に設定すべきです)。

次に、この totalLight を使って、各面の色とブラシを計算します。

renderColor = ColorF(totalLight * baseColor.r,
                     totalLight * baseColor.g,
                     totalLight * baseColor.b);

図 7 が 3D 効果を最大限に施した結果です。

The PlatonicSolids Program with Maximum 3D
図 7 3D 効果を最大限に施した PlatonicSolids プログラム

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

この記事のレビューに協力してくれた技術スタッフの Doug Erickson に心より感謝いたします。