DirectX の構成要素

ピクセル シェーダーと光の反射

Charles Petzold

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

Charles Petzold光子 (少なくとも一部の光子) を見ることができるとしましょう。光子とは、電磁放射線を構成する素粒子です。目は、可視光の範囲内の波長の光子を感じ取ります。

しかし、あらゆる場所を光子が飛び回っているにもかかわらず、興味深いことに、光子を見ることはできません。光子は、物体を通り抜けることも、吸収されることも、反射することもあります。通常は、これらすべての作用が組み合わさってさまざまな現象が生じています。物体に反射した光子の一部が、最終的に目に達することで、各物体固有の色や質感が認識されます。

非常に高品質の 3D グラフィックスでは、レイ トレーシングと呼ばれる手法を使用して、無数の光子の軌道をシミュレーションしたものを実際にプロットして、反射や影の効果を模倣します。しかし、一般的なニーズであれば、もっと単純な手法で満たすことができます。これは、Direct3D を使用する場合や、(個人的にですが) 3D を利用する Direct2D のカスタム効果を記述する場合によく当てはまります。

効果の再利用

このコラムでこれまで説明してきたように、Direct2D 効果とは基本的には GPU で実行するコードのラッパーです。そのようなコードをシェーダーと呼びますが、中でも、頂点シェーダーとピクセル シェーダーが最も重要です。各シェーダーのコードは、ディスプレイのビデオのリフレッシュ レートで呼び出されます。頂点シェーダーは、効果によって表示されるグラフィカル オブジェクトを構成する各三角形の 3 つの頂点それぞれに対して呼び出されます。一方、ピクセル シェーダーは、三角形に含まれるピクセルすべてに対して呼び出されます。

言うまでもなく、ピクセル シェーダーは頂点シェーダーよりもはるかに頻繁に呼び出されます。そのため、ピクセル シェーダーよりも頂点シェーダーでできるだけ多くの処理を実行する方が合理的です。しかし、必ずしもこれが可能なわけではありません。これらのシェーダーを使用して光の反射をシミュレーションする場合にシェーディングの精巧さと柔軟性を左右するのは、通常、この 2 つのシェーダーのバランスと相互作用です。

9 月号のコラムで、RotatingTriangleEffect という Direct2D 効果を紹介しました。この効果によって、点と色で構成される頂点バッファーを構築し、頂点に標準モデルとカメラの変換を適用しました。この効果により、3 つの三角形が回転します。三角形のデータは多くありません。3 つの三角形の頂点の総数はわずか 9 つです。また、9 月号で説明したように、同じ効果をこれよりも大きな頂点バッファーの処理に使用できます。

では、ShadedCircularText という今回のコラムのコード サンプル (msdn.microsoft.com/magazine/msdnmag1014) を試してみましょう。このサンプルでは、RotatingTriangleEffect をまったく変更しないで使用しています。

ShadedCircularText プログラムは、今年の初めに取り組み始めた、テセレーションされた 2D テキストを 3D で表示するという問題に立ち返ったものです。ShadedCircularTextRenderer クラスのコンストラクターは、フォント ファイルを読み込んでフォント フェイスを作成した後、GetGlyphRunOutline を呼び出して文字の輪郭のパス ジオメトリを取得します。このパス ジオメトリは、実際の三角形を蓄積するために作成した InterrogableTessellationSink というクラスを使用してテセレーションされます。

RotatingTriangleEffect の登録後、ShadedCircularText­Renderer では、この効果に基づいて ID2D1Effect オブジェクトが作成されます。その後、テセレーションされたテキストの三角形が、球体の面上の頂点に変換されます。テキストは、基本的に、球体の赤道に沿って巻き付き、極に向かって曲げられた形になります。各頂点の色は、元のテキスト ジオメトリの X 座標から得られる色合いに基づきます。このようにして、虹のような効果が生み出されます。結果を図 1 に示します。

ShadedCircularText による虹色の 3D テキスト
図 1 ShadedCircularText による虹色の 3D テキスト

ご覧のように、上部に簡単なメニューを表示します。このプログラムには、他の伝統的なシェーディング モデルを実装する Direct2D 効果を 3 つ追加してあります。これらの効果はすべて同じ点、同じ変換、および同じアニメーションを使用するため、効果を切り替えて違いを確認できます。違いは、三角形の色のシェーディングだけです。

右下隅にパフォーマンスを FPS (1 秒あたりのフレーム数) で表示していますが、別の処理を実行していない限り、このプログラムの処理によって 60 FPS 以下になることはないでしょう。

グーロー シェーディング

光子は私たちの周りを飛び交っており、頻繁に空気中の窒素分子や酸素分子に当たって跳ね返っています。直接日光がささない曇りの日でも、相当な量の周辺光が存在します。周辺光は、物体を均一に照らします。

RGB 値が (0, 0.5, 1.0) の緑がかった青色の物体があるとします。周辺光が最大明度の白の 1/4 の場合、RGB 値 (0.25, 0.25, 0.25) の光になります。この周辺光による物体の知覚色は、RGB 値の赤成分、緑成分、青成分の積である (0, 0.125, 0.25) です。緑がかった青色であることに変わりはありませんが、はるかに暗い色になります。

しかし、単純な 3D シーンを照らすのに周辺光だけでは不十分です。現実には、通常、物体の面にさまざまな色の違いがあります。そのため、均一に光が当たっていても、物体の質感を認識することができます。しかし、単純な 3D シーンでは、周辺光のみに照らされた緑がかった青色の物体は、均一な色をした区別のつかない厚板のように見えます。

そのため、単純な 3D シーンでは、指向性光が非常に有効です。指向性光は、(太陽のような) 遠距離からの光であり、光の方向は単一方向ベクトルとしてシーン全体に適用されると考えるとわかりやすいでしょう。光源が 1 つだけの場合、通常、観測者の左肩越しに光がさしていると想定します。そのベクトルは、右手座標系で (1, -1, -1) です。この指向性光には色 (たとえば (0.75, 0.75, 0.75)) もあり、(0.25, 0.25, 0.25) の周辺光と組み合わさって最大の照度となることもあります。

面に反射する指向性光の量は、面と光の角度に左右されます (この概念については 2014 年 5 月号の「DirectX の構成要素」コラムで説明しました)。指向性光が面に垂直に当たる場合に反射は最大となり、光が面に接するか面の背後から当たる場合に反射光の量は 0 になります。

ランベルトの余弦法則 (ドイツの数学者兼物理学者での Johann Heinrich Lambert (1728–1777) に由来) によれば、面に反射する光の割合は、光の方向と面に垂直なベクトル (面法線) の方向との角の負の余弦になります。この 2 つのベクトルを正規化する (絶対値を 1 とする) と、2 つのベクトル間の角の余弦は、ベクトルのドット積に等しくなります。

たとえば、光が特定の面に 45 度の角度で当たる場合、余弦はおよそ 0.7 です。この値に対して指向性光の色 (0.75, 0.75, 0.75) と物体の色 (0, 0.5, 1.0) を乗算すると、指向性光が当たった物体の色 (0, 0.26, 0.53) が求められます。この値を、周辺光が当たった色に加えます。

ただし、3D シーンの曲面物体は実際には曲がっていないことに注意してください。シーン内にあるものはすべて、平らな三角形で構成されます。各三角形の照度がその三角形に垂直な面法線に基づいている場合、各三角形は異なった均一色になります。2014 年 5 月のコラムで説明したような正多面体はこれで問題ありませんが、曲面には適切ではありません。曲面では、三角形の色を互いに混ぜ合わせることを考えます。

つまり、各三角形の色を均一色ではなく、グラデーションにする必要があります。指向性光が当たった色を、三角形の面法線に基づいて表現することはできません。代わりに、三角形の各頂点に、その頂点の面法線に基づいて、異なる色を設定します。これで、これらの頂点の色を三角形のすべてのピクセルに補間できます。その後、隣接する三角形の色を互いに混ぜ合わせて、曲面のようにします。

このようなシェーディングは、フランスのコンピューター科学者の Henri Gouraud (1944 年誕生) が 1971 年に発明して論文で発表したため、グーロー シェーディングと呼ばれます。

グーロー シェーディングは ShadedCircularText プログラムに実装した 2 つ目のオプションです。この効果は GouraudShadingEffect という名前で、若干多くのデータを格納する頂点バッファーが必要です。

struct PositionNormalColorVertex
{
  DirectX::XMFLOAT3 position;
  DirectX::XMFLOAT3 normal;
  DirectX::XMFLOAT3 color;
  DirectX::XMFLOAT3 backColor;
};

興味深いことに、テキストは事実上、点 (0, 0, 0) を中心とする球体に巻き付いているため、各頂点の面法線は頂点の位置と等しく、絶対値 1 に正規化されています。この効果は各頂点に固有の色を設定できますが、このプログラムではすべての頂点に同じ色 (0, 0.5, 1) と同じ backColor (0.5, 0.5, 0.5) を割り当てています。backColor は面の裏が見える場合に使う色です。

GouraudShadingEffect には効果のプロパティがもう少し必要です。周辺光の色、指向性光の色、および指向性光のベクトル方向を設定できる必要があります。GouraudShadingEffect では、これらすべての値を頂点シェーダーの大きな定数バッファーに送信します。この頂点シェーダーを図 2 に示します。

図 2 グーロー シェーディングの頂点シェーダー

// Per-vertex data input to the vertex shader
struct VertexShaderInput
{
  float3 position : MESH_POSITION;
  float3 normal : NORMAL;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Per-vertex data output from the vertex shader
struct VertexShaderOutput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 color : COLOR0;
};
// Constant buffer provided by effect.
cbuffer VertexShaderConstantBuffer : register(b1)
{
  float4x4 modelMatrix;
  float4x4 viewMatrix;
  float4x4 projectionMatrix;
  float4 ambientLight;
  float4 directionalLight;
  float4 lightDirection;
};
// Called for each vertex.
VertexShaderOutput main(VertexShaderInput input)
{
  // Output structure
  VertexShaderOutput output;
  // Get the input vertex, and include a W coordinate
  float4 pos = float4(input.position.xyz, 1.0f);
  // Pass through the resultant scene space output value
  //  (not necessary -- can be removed from both shaders)
  output.sceneSpaceOutput = pos;
  // Apply transforms to that vertex
  pos = mul(pos, modelMatrix);
  pos = mul(pos, viewMatrix);
  pos = mul(pos, projectionMatrix);
  // The result is clip space output
  output.clipSpaceOutput = pos;
  // Apply model transform to normal
  float4 normal = float4(input.normal, 0);
  normal = mul(normal, modelMatrix);
  // Find angle between light and normal
  float3 lightDir = normalize(lightDirection.xyz);
  float cosine = -dot(normal.xyz, lightDir);
  cosine = max(cosine, 0);
  // Apply view transform to normal
  normal = mul(normal, viewMatrix);
  // Check if normal pointing at viewer
  if (normal.z > 0)
  {
    output.color = (ambientLight.xyz + cosine *
                    directionalLight.xyz) * input.color;
  }
  else
  {
    output.color = input.backColor;
  }
  return output;
}

ピクセル シェーダーは RotatingTriangleEffect と同じで、図 3 に示すとおりです。三角形全体の頂点色の補間は、頂点シェーダーとピクセル シェーダーの間でバックグラウンド処理されるため、ピクセル シェーダーは表示する色を渡すだけです。

図 3 グーロー シェーディングのピクセル シェーダー

// Per-pixel data input to the pixel shader
struct PixelShaderInput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 color : COLOR0;
};
// Called for each pixel
float4 main(PixelShaderInput input) : SV_TARGET
{
  // Simply return color with opacity of 1
  return float4(input.color, 1);
}

結果は図 4 に示すとおりです。今回は、Windows 8.1 ではなく Windows Phone 8.1 で実行しています。ShadedCircularText ソリューションは、Visual Studio で新しいユニバーサル アプリ テンプレート使用して作成したため、どちらのプラットフォームに対してもコンパイルできます。すべてのコードは、App クラスと DirectXPage クラスを除き、2 つのプラットフォームで共用します。2 つのプログラムのレイアウトの違いから、プログラムの機能が根本的には同じ場合でも、異なるページ定義を用意することが推奨される理由がわかります。

グーロー シェーディング モデルの表示
図 4 グーロー シェーディング モデルの表示

ご覧のように、左上の領域の色が薄くなって指向性光の効果がはっきりと現れ、面が丸く見える錯覚効果が高まっています。

フォンによる改良

グーロー シェーディングは長く使われている手法ですが、根本的な欠陥があります。グーロー シェーディングにおいて、三角形の中心で反射する指向性光の量は、頂点で反射する光の補間値です。頂点で反射する光は、光の方向とその頂点の面法線との角の余弦に基づいています。

しかし、三角形の中心で反射する光は、本当は、その位置の面法線に基づいたものであるべきです。つまり、色を三角形に補間するのではなく、面法線を三角形の面に補間して、その法線に基づいて各ピクセルの反射光を計算すべきです。

ここで、ベトナムのコンピューター科学者である Bui Tuong Phong (1942-1975、白血病のため 32 歳で死去) の登場です。Phong は、1973 年、博士論文で少し変わったシェーディング アルゴリズムを発表しました。そのアルゴリズムは、頂点色を三角形に補間するのではなく、頂点の法線を三角形に補間して、そこから反射光を計算するというものです。

実際には、フォン シェーディングでは、反射光の計算を、そのジョブ専用の定数バッファーのセクションと一緒に頂点シェーダーからピクセル シェーダーに移す必要があります。これにより、ピクセルごとの処理量が大幅に増えますが、さいわい、処理は期待どおり GPU で行われ、大きな差が生じることはありません。

フォン シェーディング モデルの頂点シェーダーは図 5 に示すとおりです。入力データの一部 (色や背面色など) を単純にピクセル シェーダーに渡します。しかし、ここですべての変換を適用するのも有効です。ワールド変換と両方のカメラの変換を位置に適用する必要がある一方、2 つの法線も計算する必要があります (1 つは反射光のモデル変換のみで計算し、もう 1 つはビュー変換で計算して、面が観測者の方を向いているか逆を向いているかを判断します)。

図 5 フォン シェーディング モデルの頂点シェーダー

// Per-vertex data input to the vertex shader
struct VertexShaderInput
{
  float3 position : MESH_POSITION;
  float3 normal : NORMAL;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Per-vertex data output from the vertex shader
struct VertexShaderOutput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 normalModel : NORMAL0;
  float3 normalView : NORMAL1;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Constant buffer provided by effect
cbuffer VertexShaderConstantBuffer : register(b1)
{
  float4x4 modelMatrix;
  float4x4 viewMatrix;
  float4x4 projectionMatrix;
};
// Called for each vertex
VertexShaderOutput main(VertexShaderInput input)
{
  // Output structure
  VertexShaderOutput output;
  // Get the input vertex, and include a W coordinate
  float4 pos = float4(input.position.xyz, 1.0f);
  // Pass through the resultant scene space output value
  // (not necessary — can be removed from both shaders)
  output.sceneSpaceOutput = pos;
  // Apply transforms to that vertex
  pos = mul(pos, modelMatrix);
  pos = mul(pos, viewMatrix);
  pos = mul(pos, projectionMatrix);
  // The result is clip space output
  output.clipSpaceOutput = pos;
  // Apply model transform to normal
  float4 normal = float4(input.normal, 0);
  normal = mul(normal, modelMatrix);
  output.normalModel = normal.xyz;
  // Apply view transform to normal
  normal = mul(normal, viewMatrix);
  output.normalView = normal.xyz;
  // Transfer colors
  output.color = input.color;
  output.backColor = input.backColor;
  return output;
}

頂点シェーダーの出力は、ピクセル シェーダーの入力になります。これらの法線が三角形の面に補間されます。その後、ピクセル シェーダーによって反射光が計算されてジョブは終了です (図 6 参照)。

図 6 フォン シェーディング モデルのピクセル シェーダー

// Per-pixel data input to the pixel shader
struct PixelShaderInput
{
  float4 clipSpaceOutput : SV_POSITION;
  float4 sceneSpaceOutput : SCENE_POSITION;
  float3 normalModel : NORMAL0;
  float3 normalView : NORMAL1;
  float3 color : COLOR0;
  float3 backColor : COLOR1;
};
// Constant buffer provided by effect
cbuffer PixelShaderConstantBuffer : register(b0)
{
  float4 ambientLight;
  float4 directionalLight;
  float4 lightDirection;
};
// Called for each pixel
float4 main(PixelShaderInput input) : SV_TARGET
{
  // Find angle between light and normal
  float3 lightDir = normalize(lightDirection.xyz);
  float cosine = -dot(input.normalModel, lightDir);
  cosine = max(cosine, 0);
  float3 color;
  // Check if normal pointing at viewer
  if (input.normalView.z > 0)
  {
    color = (ambientLight.xyz + cosine *
      directionalLight.xyz) * input.color;
  }
  else
  {
    color = input.backColor;
  }
  // Return color with opacity of 1
  return float4(color, 1);
}

この結果のスクリーンショットはお見せしません。グーロー シェーディングとほとんど見た目が同じだからです。グーロー シェーディングは妥当な近似であるといえるでしょう。

鏡面ハイライト

フォン シェーディングの真の重要性は、このシェーディングによって、より正確な面法線を利用する他の機能を実現できるようになることです。

ここまでの説明では、拡散表面に適したシェーディングを確認しました (拡散表面とは、ざらざらな質感で鈍い色をしており、反射した光を拡散させる傾向のある面のことです)。

光沢のある面では、少し異なった光の反射が起こります。面が傾いている場合、指向性光が跳ね返ってまっすぐ観測者の目に入ります。これは、鏡面ハイライトと呼ばれ、通常まぶしい白色光として感じられます。この効果を誇張すると図 7 のようになります。図の湾曲が急になるほど、白色光は局所的になります。

鏡面ハイライトの表示
図 7 鏡面ハイライトの表示

最初は、この効果を実現するには複雑な計算が必要なように思われますが、ピクセル シェーダーの数行のコードで処理しています。このすばらしい手法を開発したのは、NASA に勤めるグラフィックスの達人 Jim Blinn (1949 年誕生) です。

最初に、3D シーンの観測者が見ている方向を示すベクトルを求めます。ビュー カメラの変換によって観測者が Z 軸を真下に見るようにすべての座標を調節しているため、これは非常に簡単です。

float3 viewVector = float3(0, 0, -1);

次に、ビュー ベクトルと光の方向の中間のベクトルを計算します。

float3 halfway = -normalize(viewVector + lightDirection.xyz);

マイナス記号に注目してください。これによって、光源と観測者の中間でベクトルが逆方向を指すようになります。

特定の三角形にこの中間ベクトルに正確に一致する面法線が含まれる場合、光は面に反射して観測者の目に直接入ります。このとき、鏡面ハイライトが最大になります。

中間ベクトルと面法線の角度が 0 ではない場合、ハイライトは弱くなります。次に示すのも、2 つのベクトル間の余弦は正規化された 2 つのベクトルのドット積に等しいことを利用した応用です。

float dotProduct = max(0.0f, dot(input.normalView, halfway));

この dotProduct の値は 1 (鏡面ハイライトが最大。2 つのベクトル間の角度が 0 の場合) から 0 (鏡面ハイライトなし。2 つのベクトルが垂直の場合) の値になります。

ただし、鏡面ハイライトは 0 ~ 90 度のすべての角度から見えるようにはしないで、局所的にし、2 つのベクトルの非常に小さい角度に対してのみ存在するようにします。1 のドット積には影響しないで、1 未満の値をさらに小さくするような関数が必要です。それを実現するのが pow 関数です。

float specularLuminance = pow(dotProduct, 20);

この pow 関数では、ドット積を 20 乗します。ドット積が 1 の場合、pow 関数から 1 が返されます。ドット積が 0.7 (2 つのベクトルの角が 45 度) の場合、pow 関数からは 0.0008 が返されます。この値は、照明に関して言えば実質的に 0 です。高い指数値を使用すると、効果がさらに局所的になります。

後は、指向性光の色にこの因数を乗算して、その結果を周辺光と指向性光を基に既に計算した色に加えるだけです。

color += specularLuminance * directionalLight.xyz;

これで、アニメーションによる図の回転に合わせて、白い光のハイライトが作られます。

別れの言葉

今回で、「DirectX の構成要素」のコラムは終了です。DirectX への取り組みは、私のキャリアの中で最も困難な仕事の 1 つでしたが、結果的に最もやりがいのある仕事の 1 つにもなりました。いつの日か、この強力なテクノロジに戻ってきたいと思っています。


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

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

今回は、Charles Petzold にとって、MSDN マガジンのレギュラー コラムニストとしての最後の記事になります。彼は今後、Microsoft .NET Framework を活用するクロスプラットフォーム ツールの大手プロバイダーである Xamarin のチームに参加します。彼は長年 MSDN マガジンに携わり、「基礎」、「UI 最前線」、「DirectX の構成要素」など、多数のレギュラー コラムを執筆しました。Charles の新しい場所での活躍を期待しています。