次の方法で共有


頂点シェーダによるトゥイーニングの最適化、または頂点シェーダの使い方、Part 3

Philip Taylor
Microsoft Corporation

May 24, 2001

この記事のソースコードをダウンロードする。

Driving DirectX へようこそ。先月は三種類のトゥイーニングの手法を紹介し、頂点シェーダによるトゥイーニングの解説をしました。今月は、頂点シェーダによるトゥイーニングの別の側面について説明し、シェーダの基本的な最適化にハイライトを当ててみたいと思います。

先月のコラムのトゥイーニング シェーダ サンプルで使用したシェーダは、DirectX 8.0 SDK に組み込まれているシェーダと同じものです。まずは明瞭さを目的とし、次にパフォーマンス最適化を目的として書かれています。少し明瞭さを犠牲にしてシェーダ命令の使用方法を最適化すれば、サイクルを節約でき、より最適化されたシェーダを作成できます。その上、アプリケーションによるシェーダ定数の処理、レンダーステートの変更の削減、レンダリングの順序などを多少変更すると、パフォーマンスをさらに強化できます。最終的には、最適化されたメッシュを利用してさらに高いパフォーマンスを実現できます。

オリジナル DolphinVS サンプルは、NV20 プロトタイプ ボードを組み込んだ AMD 1GHz マシンで約 550 FPS で動作します。このサンプルを以下の図 1 に示します。

Original DolphinVS

図 1. オリジナル DolphinVS

シェーダとレンダリングの最適化により、図 2 のように FPS が 120 FPS、フレーム レートで 22% 向上しました。

Shader- and rendering-optimized DolphinVS

図 2. シェーダとレンダリングを最適化した DolphinVS

最後は、D3DX メソッド OptimizeInplace でメッシュ最適化を行います。この構成のシェーダを使用するメッシュでは、さらに 100 FPS パフォーマンスが向上します。すなわち、フレーム レートでさらに 18% 向上します。これを図 3 に示します。

Mesh-, shader-, and rendering-optimized DolphinVS

図 3. メッシュ、シェーダ、レンダリングを最適化した DolphinVS

この記事で紹介する上記の方法を利用すると、このサンプルでは合計 ~220 FPS、パフォーマンスでいえば ~40% を強化できます。これは少々のパフォーマンス強化ではありません。パフォーマンスの 40% 向上は非常に重要です。最適化は、次の 3 つの部分に分かれています。

  • シェーダの最適化

  • レンダリングの最適化

  • メッシュの最適化

処理速度が良くなった原因の大半は、シェーダとメッシュの最適化によるものですが、レンダリング コードの最適化も有意義であり、有効です。変更内容を簡略に説明し、(できるだけ) 最適化の内容を理解していただくために、以下の資料を使用します。

  • オリジナルのシェーダ ファイルとアップデートしたシェーダ ファイル

  • 3 バージョンのアプリケーション ソース ファイル (オリジナルの dolphinvs.cpp、シェーダのみを最適化した dolphinvs-shaderoptimizations.cpp、シェーダとレンダリング コードの両方を最適化した dolphinvs-bothoptimizations.cpp)

  • メッシュ最適化の方法を示す、オリジナルの d3dfile.cpp とアップデートした d3dfile.cpp。

ではコードの内容を見ながら、以上の最適化の内容を検討しましょう。まずシェーダの最適化、次にレンダリング コードの最適化、最後にメッシュ最適化の方法について説明します。

頂点シェーダの最適化

最初に確認しておきます。DolphinVS サンプル (前回のコラムの TweenVS) では、DolphinTween.vsh と DolphinTween2.vsh という 2 組の頂点シェーダを使用して、トゥイーニングしたイルカをレンダリングしています。海底のレンダリングには SeaFloor.vsh と SeaFloor2.vsh を使用しています。イルカと海底のいずれもシェーダを 2 つ使用しています。これは、DolphinVS では 2 パス レンダリングで、海中のコースティックや光の波紋を作成しているからです。DolphinTween2.vsh では、別の方法でコースティックに必要なテクスチャ座標を生成します。また SeaFloor2.vsh では、さらに別の方法でコースティック テクスチャのテクスチャ座標を作成します。

イルカ シェーダと海底シェーダのどちらにも、頂点トランスフォーム、ライティングの計算、テクスチャ座標、フォグという 4 つのセクションがあります。シェーダ最適化の検討は、まずイルカ シェーダから始めて 4 つのセクションを解説し、海底シェーダに移り、最後は各セクションを順に調べましょう。

イルカ シェーダ トランスフォーム セクション

このトランスフォーム セクションには、2 つのコード部分があります。1 つは頂点位置のトゥイーニングを行い、もう 1 つは得られた頂点位置をトランスフォームします。これらのコード コード部分について個別に調べて、適用する最適化について検討します。

イルカ シェーダの最適化前のオリジナルのトゥイーニング コード部分の頂点 トランスフォーム セクションをリスト 1 に示します。DolphinTween.vsh と DolphinTween2.vsh のいずれも同じ内容です。ここで、各キーフレームの位置ベクトル (v0, v1, v2) に、FrameMove() で算出した各メッシュのトゥイーニングの重みをおさめた定数 C2 を乗算します。得られた位置ベクトル (r0, r1, r2) を、すべて r3 に加えます。

; 3 つの位置 (v0、v1、v2) を 1 つの位置にトゥイーニング
mul r0, v0, c2.x
mul r1, v1, c2.y
mul r2, v2, c2.z
add r3, r0, r1
add r3, r3, r2

リスト 1. 最適化前のトランスフォーム シェーダのイルカ トゥイーニング コード部分

最適化後のトランスフォーム シェーダ コード トゥイーニング コード部分をリスト 2 に示します。DolphinTween.vsh と DolphinTween2.vsh のいずれも同じ内容です。ここで、3 つの mul (乗算) 命令と 2 つの add (加算) 命令を 1 つの mul 命令と 2 つの mad (乗算と加算) 命令に統合します。

; 3 つの位置 (v0、v1、v2) を 1 つの位置にトゥイーニングし
; mad 命令を使用。r1 と r2 は使用せず
mul r0, v0, c2.x
mad r0, v1, c2.y, r0
mad r3, v2, c2.z, r0

リスト 2. 最適化後のトランスフォーム シェーダのイルカ トゥイーニング コード部分

これらの演算を 3 つの命令だけの場合と 5 つのオリジナル シェーダによる場合とで比較しても、乗算と加算の合計値は同じです。いずれもスロットが 1 つで、実行サイクルが 1 つの単純な命令です (『DirectX 8.0 のプログラマブル シェーダ』の表 2 参照)。最適化前のトゥイーニング コード部分では命令が 5 つですが、最適化後のトゥイーニング コード部分では 3 つになります。2 つの命令を節約したことで、最適化後のシェーダ セクションが 40% 改善されます。また、パフォーマンスの改善という意味ではどの程度貢献したか判断は困難ですが、2 つのレジスタ (r1, r2) の書き込みとそれらからの読み取りがなくなったことで、少なくとも悪影響はありません。

頂点のトランスフォーム コードは、DolphinTween.vsh と DolphinTween2.vsh で少し異なっています。いずれの場合も、1) 出力で位置をクリッピング空間にトランスフォームし、2) 後からシェーダで使用できるよう位置をカメラ空間に トランスフォーム するという 2 つの動作が行われます。最初の動作は、どちらのシェーダとも同じです。2 番めのコード部分はそれぞれのシェーダで異なっています。

下のリスト 3 は、両方の動作に対応するコードであり、最適化前のオリジナル コードです。オリジナル シェーダでは、2 つの m4x4 (4x4 行列乗算) で 2 つの動作をトランスフォームします。この方法はシンプルでわかりやすいようですが、非効率的な部分が隠れています。

m4x4 は、標準命令またはシンプルな命令に展開できるマクロであり、シェーダの命令数からみれば複雑な命令です (『DirectX 8.0 のプログラマブル シェーダ』の表 3 参照)。ベクトルに実行される m4x4 命令は、ベクトルの成分に実行される 4 つの dp4 命令と機能的に同じです。したがって代数的には置換が可能です。つまり、一見単純に見えるコードが実際にシェーダの命令スロットの 8 つを使用し、8 サイクルで実行されるわけです。

最適化を検討するとき、シェーダのすべての途中結果を後で使用するわけではないことに注意してください。つまり、必要な dp4 命令だけを置換すれば、命令数とサイクル数を削減できます。この方法は、m4x4 で得られる値をすべて使用するのでなければ最適化として有意義です。ただし、頂点シェーダ エンジンによっては、シェーダ命令の合計数が 4 つのままでも、m4x4 が直接評価され、単純に 4 つの dp4 命令を置換するよりも効率的なものもあります。

; 位置をクリッピング空間にトランスフォーム 
m4x4 oPos, r3, c4

; 位置をカメラ空間にトランスフォーム 
m4x4 r9, r3, c8

リスト 3. 最適化前のトランスフォーム シェーダのイルカの最終位置コード部分

DolphinTween.vsh において最適化後の最終位置コード部分のトランスフォーム シェーダ コードをリスト 4 に示します。ここで、最初の m4x4 命令で最終位置を計算し、出力位置レジスタ oPos に書き込みます。つまり、m4x4 命令を展開して置換するには、4 つすべての dp4 命令が必要である、あるいはそうしないと頂点位置が正しく計算されないということになります。ここでは、まだその意義がわかりません。一部のシェーダ マシンでは、m4x4 の内部最適化が行われるため、結果の値がすべて必要でない限り、この処理をする必要はなく、したがって命令数が節約できます。

2 番めの m4x4 は別の内容です。この命令では、シェーダが後でフォグに使用する結果を生成します。フォグ コードで何が使用されるかを調べると、必要なのはカメラ空間の z 成分だけです。そこで、 m4x4 命令を 4 つの dp4 命令に展開し、dp4 命令を 1 つだけ残して使用しない 3 つを削除します。

; 位置をクリッピング空間にトランスフォーム 
; 合成した行列を利用して 1 ステップで射影を実行
; 結果を oPos に直接書き込む
m4x4 oPos、r3、c4
;以下の 4 つの dp4 命令と等価だが
;シェーダ マシンによっては m4x4 に内部最適化を実行できるものがあるので、
;命令の節約が必要な場合にだけ置換すること
;dp4 oPos.x, r3, c4
;dp4 oPos.y, r3, c5
;dp4 oPos.z, r3, c6
;dp4 oPos.w, r3, c7

; マクロを使用すると 4 つの命令を使用しているという事実が隠れてしまう
; もっと悪いのは、見かけ上使用する要素が 1 つになってしまうこと
; 実際に必要な命令は 1 つだけである
; 位置をカメラ空間にトランスフォーム 
dp4 r9.z, r3, c10   ; ビュー空間 z 成分を計算

リスト 4. 最適化後の トランスフォーム シェーダの DolphinTween.vsh の最終位置コード部分

DolphinTween.vsh と DolphinTween2.vsh の唯一の違いは、2 番めの m4x4 の展開方法です。DolphinTween2.vsh には、カメラ空間に 2 つの位置成分が必要です。それは、DolphinTween.vsh と同じくフォグ用の z 成分と、コースティックのテクスチャ座標を生成するための x 成分と z 成分です。テクスチャ座標で x 成分と z 成分が必要だということは、ここで少し異なるコードが必要だということになります。リスト 5 には、x 成分を生成するための新しい dp4 命令と、すでに紹介済みの z 成分を生成するための dp4 命令という 2 つの dp4 命令が必要です。

; マクロを使用すると 4 つの命令を使用しているという事実が隠れてしまう
; もっと悪いのは、見かけ上使用する成分が 2 つになること
; 実際に必要な命令は 2 つだけである
; 位置をカメラ空間に トランスフォーム  
dp4 r9.x, r3, c8    ; ビュー空間 x 成分の計算
dp4 r9.z, r3, c10   ; ビュー空間 z 成分の計算

リスト 5. 最適化後のトランスフォーム シェーダの DolphinTween2.vsh 最終位置コード部分

DolphinTween.vsh で最適化後の最終位置コード部分が使用する命令は 8 つから 5 つになりました。これで 3 つの命令または 37% を節約しました。DolphinTween2.vsh で最適化後の最終位置コード部分で使用する命令は 8 つから 6 つになりました。これで 2 つの命令または 25% を節約しました。

Dolphin シェーダ ライティング計算セクション

次は、ライティングの計算シェーダ コードについて調べてみましょう。最適化前のシェーダ コードをリスト 6 に示します。このコードでは、1) ライティングの法線をトゥイーニングし、2) トゥイーニングした法線でライティングを実行するという 2 つの動作が行われます。

まず、法線のトゥイーニング コードについて解説します。法線のトゥイーニング コードは、位置トゥイーニング コードとほとんど同じです。これは当然であり、頂点セットに同じ演算が実行されるからですが、今回は異なる頂点セットです。ここで、 FrameMove() で計算したトゥイーニングの重みをおさめる定数 C2 を、各キーフレームの法線ベクトル (v3, v4, v5) に乗算します。これらの mul (r0, r1, r2) から得られた 3 本の法線ベクトルを、2 つの add で加算し r3 に加えます。

次に、実際のライティング演算を実行します。ライティングには同次の w 成分が不要なため、dp4 ではなく dp3 で内積を計算します。結果がゼロ未満の場合、内積をゼロに設定します。結果 [0, value] にディフューズを乗算し、アンビエントを追加し、最終結果をクランプし、最初の出力カラー レジスタ oDO に書き込みます。

; 3 本の法線 (v3、v4、v5) を 1 本の法線にトゥイーニング
mul r0, v3, c2.x
mul r1, v4, c2.y
mul r2, v5, c2.z
add r3, r0, r1
add r3, r3, r2; 

;ライティングの計算を実行
dp3 r1.x, r3, c20    ; r1 = 法線とライトの内積
max r1.x, r1.x, c0.x ; 結果が <0 の場合、結果 = 0
mul r0, r1.x, c21    ; ディフューズを乗算
add r0, r0, c22      ; アンビエントを加算
min oD0, r0, c1.x    ; > 1 の場合クランプ

リスト 6. 最適化前のイルカ ライティング計算のシェーダ コード

最適化後のシェーダ コードをリスト 7 に示します。このコードでは、1) 法線をライティング用にトゥイーニングし、2) トゥイーニングした法線を利用してライティングを実行するという同じ 2 つの動作を実行します。

ここでも、トゥイーニング コード部分に対し、3 つの mul と 2 つの add を使用し、それらを 1 つの mul と 2 つの mad に統合します。これも 2 つの命令の節約になり、2 つのレジスタで読み書きを行います。

次はライティング コードです。ここでも dp3 命令で内積を計算します (ライティングには同次の w 成分が不要なため)。ここでは、max 命令ではなく lit 命令を使用します。定数レジスタを使用せずにすみ (読み取りを節約)、後からスペキュラ ライティングを追加する場合に好都合だからです。ディフューズに mul を実行し、アンビエントに add を実行します。オリジナル シェーダのように min で 1.0 にクランプしません。ピクセル補完に使用する数値は [0.0,1.0] に自動的にクランプされるという頂点シェーダの仕様を思い出してください。最初の出力カラー レジスタ oDO はピクセル補完に使用する値であり、自動的にクランプされるので、クランプのための命令は省略できます。すなわち addoDO を直接書き込み、レジスタによる読み書きを省略します。

; 3 本の法線 (v3、v4、v5) を 1 本の法線にトゥイーニング
; mad 命令を使用し、r1、r2 は使用しない
mul r0, v3, c2.x
mad r0, v4, c2.y, r0
mad r3, v5, c2.z, r0 

; ライティングの計算を実行
dp3 r1.x, r3, c20       ; r1 = 法線とライトの内積
;  スペキュラを追加する場合は (max ではなく) lit のほうが良い
;  定数レジスタを使用する必要はない
;  結果は y 成分に保存されるので、それに応じて
;  以下の命令を変更する必要がある
lit r1.y, r1.x          ; <0 の場合、結果 = 0
mul r0, r1.y, c21       ; ディフューズを乗算
add oD0, r0, c22        ; アンビエントを加算
; oD0 は暗黙的に 1 にクランプ
; これを明示的に行う必要はない
; min oD0, r0, c1.x     ; > 1 の場合クランプ

リスト 7. 最適化後のイルカ ライティングの計算シェーダ コード

最適化前のライティングの計算シェーダ コード セクションには命令が計 10 あり、各命令に 1 つの命令スロットと、1 つの実行サイクルがあります。最適化後のライティング計算のシェーダ コード セクションには命令が計 7 つあり、各命令に 1 つの命令スロットと、1 つの実行サイクルがあります。全体で 10 のうち、3 つの命令を節約でき、レジスタの読み書きに対する節約を無視しても、このセクションでは 30% の節約になります。

イルカ シェーダ テクスチャ座標セクション

シェーダ コードのテクスチャ座標セクションは、DolphinTween.vsh と DolphinTween2.vsh の両方に対する命令ですでに最適化済みです。この全コードをリスト 8 とリスト 9 に示します。DolphinTween2.vsh テクスチャ座標シェーダ コード セクションで r9.xz を使用していることに注意してください。これが、トランスフォーム シェーダ コードの最終位置コード部分の 2 番めの m4x4 を展開したときの 2 番めの dp4 の理由です。

; テクスチャ座標のコピー
mov oT0.xy, v6

リスト 8. オリジナルの DolphinTween.vsh テクスチャ座標シェーダ コード

; 頂点 xz 位置からテクスチャ座標を生成
mul oT0.xy, c1.y, r9.xz

リスト 9. オリジナルの DolphinTween2.vsh テクスチャ座標シェーダ コード

イルカ シェーダ フォグ セクション

シェーダ コードのフォグ セクションでも最適化は可能です。そのためには、1) フォグ係数式を新しい定数によって整理できること、2) 整理したフォグ係数式を 1 つの mad で実装でき、3) フォグがピクセル補完に使用する値であることを理解する必要があります。

リスト 10 はオリジナル フォグ コード セクションです。addmul により、フォグ係数を算出し、maxmin で値を [0.0,1.0] にクランプします。

; フォグ係数の計算 f = (fog_end - dist)*(1/(fog_end-fog_start))
add r0.x, -r9.z, c23.y
mul r0.x, r0.x, c23.z
max r0.x, r0.x, c0.x       ;  > 0.0 にフォグをクランプ
min oFog.x, r0.x, c1.x     ;  < 1.0 にフォグをクランプ

リスト 10. 最適化前のイルカ フォグ シェーダ コード

リスト 11 は最適化後のフォグ コード セクションです。命令は、新しい定数を使用する mad 1 つだけです。これで addmul の代用になります。add+mul のペアのパターンに注意してください。2 番めの命令は最初の命令の結果を使用します。このパターンは mad による置き換えが可能です。

; フォグ係数を計算 f = (fog_end - dist)*(1/(fog_end-fog_start))
; 上式を書き換えて == to: (fog_end/(fog_end-fog_start) - dist/(fog_end-fog_start)
; これは 1 つの mad 命令に書き換え可能
mad oFog.x, -r9.z, c36.y, c36.x 

; oFog.x は暗黙的に [0,1] にクランプされる: 明示的に実行する必要はない
; max r0.x, r0.x, c0.x       ; フォグを > 0.0 にクランプ
; min oFog.x, r0.x, c1.x     ; フォグを < 1.0 にクランプ

リスト 11. オリジナル フォグ係数式を最適化したイルカ フォグ セクション シェーダ コード:

新しい定数は、以下の式の因数分解です。

f = (fog_end - dist)*(1/(fog_end-fog_start))
上の式は次のように因数分解できる :
f = (fog_end/(fog_end-fog_start) - dist/(fog_end-fog_start)

oFog はピクセル補完に使用する値なのでクランプは不要です。0.0 と 1.0 へのクランプには本来 2 つの命令が必要です。

オリジナルのフォグ セクション シェーダ コードは、4 つの簡単な命令からなります。最適化後のフォグ セクション シェーダ コードは mad 命令 1 つで構成され、75% の節約になります。このコードでは r0 の 3 回の書き込みと読み取りも省略できます。

このセクションで、イルカ シェーダの 4 つのセクションは完了です。最適化の結果をまとめてみましょう。表 1 は、イルカ シェーダの命令数です。また、DolphinTween.vsh シェーダが 50% 高速で (36 命令に対して 18 命令)、DolphinTween2.vsh シェーダは 47% 高速で (36 命令に対して 19 命令)、はるかにパフォーマンスがよくなります。

セクション オリジナル Dolphintween Dolphintween2 節約できた読み書き
トランスフォーム 21 9 10 2
ライティング 10 7 7 3.5
テクスチャ 1 1 1 0
フォグ 4 1 1 3
合計 36 18 19 8.5

表 1. イルカ シェーダの命令数

海底シェーダ トランスフォーム セクション

トランスフォーム セクションには 2 つのコード コード部分があります。1 つは頂点位置をトゥイーニングするコードであり、もう 1 つは得られた頂点位置をトランスフォームするコードです。次は、これらの各コードについて、どのような最適化が行われるか検討しましょう。

イルカ シェーダで頂点トランスフォーム セクションのオリジナルの最適化前のトゥイーニング コード部分をリスト 12 に示します。SeaFloor.vsh と SeaFloor2.vsh のどちらも同じ内容です。ここでは、m4x4 で位置をビュー空間に トランスフォーム しています。もう 1 つの m4x4 で射影空間にトランスフォームし、mov で結果を出力位置レジスタ oPos に保存します。m4x4 は 4 つの命令に展開されるため、このセクションには 9 つの命令スロットと 9 つのサイクルが必要です。r9 における最初の m4x4 の結果のすべて、あるいはその一部はシェーダで再利用されます。それを以下に示します。

; ビュー空間に トランスフォーム  (ワールド行列は単位行列)
m4x4 r9, v0, c12

; 射影空間にトランスフォーム 
m4x4 r10, r9, c28

; 出力位置を保存
mov oPos, r10

リスト 12. 最適化前の海底トランスフォーム シェーダ コード

最適化後の海底トランスフォーム シェーダ コードをリスト 13 に示します。m4x4 マクロは、テクスチャ座標生成用の x 成分と z 成分だけに展開できます。すなわち、m4x4 のかわりに必要な dp4 命令は 2 つだけということです。2 番めの m4x4 は、出力位置の書き込みに使用するので、この m4x4 を書き換えるには、ベクトル 成分 (xyzw) ごとに 1 つ、すなわち 4 つの dp4 命令に展開する必要があります。さらに、レジスタ読み書き節約を追加して、一時レジスタに対する読み書きも 1 回ずつ節約できます。

; マクロを使用すると 4 つの命令を使用しているという事実が隠れてしまう
; もっと悪いのは、見かけ上使用するのが 2 つの成分になること。実際に必要な命令は 2 つだけである
; ビュー空間に トランスフォーム  (ワールド行列は単位行列)
dp4 r9.x, v0, c12   ; ビュー空間 x 成分の計算
dp4 r9.z, v0, c14   ; ビュー空間 z 成分の計算

; 射影空間に トランスフォーム 
; 合成した行列を利用して 1 ステップで射影を実行
; 結果を oPos に直接書き込む
dp4 oPos.x, v0, c32
dp4 oPos.y, v0, c33
dp4 oPos.z, v0, c34
dp4 oPos.w, v0, c35

; mov 命令は、a0 へのロードを除き、99% の確率で、より便利な命令に置き換え可能
; 出力位置を保存
; mov oPos, r10

リスト 13. 最適化後の海底トランスフォーム シェーダ コード

オリジナルの最適化前の海底シェーダでは、トランスフォーム セクションで 9 つの命令を使用していました。最適化後の海底シェーダではトランスフォーム セクションで使用する命令が 6 つになり、3 つの命令を節約できます。いいかえると 33% パフォーマンスが強化された他、レジスタによる読み書きが節約されます。

海底シェーダ ライティング計算セクション

次は、ライティングの計算シェーダ コードについて検討しましょう。最適化前のシェーダ コードをリスト 14 に示します。まず、内積を計算します。結果がゼロ未満の場合、内積はゼロに設定されます。結果 [0, value] をディフューズに乗算し、アンビエントを追加します。最終結果をクランプし、最初の出力カラー レジスタ oDO に書き込みます。


dp3 r1.x, v3, c20    ; r1 = 法線とライトの内積
max r1, r1.x, c0.x   ; 結果 <0 の場合、結果 = 0
mul r0, r1.x, c21    ; ディフューズを乗算
add r0, r0, c22      ; アンビエントを加算
min oD0, r0, c1.x    ; > 1 の場合クランプ

リスト 14. 最適化前の海底ライティング シェーダ コード

最適化後のシェーダ コードをリスト 15 に示します。ライティングには同次 w 成分が不要なため、ここでも dp3 命令で再び内積を計算します。また max 命令ではなく lit 命令を使用します。定数レジスタを使用せずにすみ (読み取りを節約)、後からスペキュラを追加する場合に好都合だからです (拡張性)。ディフューズに mul を実行し、アンビエントに add を実行します。oDO がピクセル補完に使用する値であり、自動的にクランプされる規則を利用します。同じくクランプの命令を省略できます。つまり、add によって oDO に直接ロードされるので、今回もレジスタの読み書きを省略します。


dp3 r1.x, v3, c20    ; r1 = 法線とライトの内積
;  スペキュラを追加する場合は (max ではなく) lit のほうが良い
;  定数レジスタを使用する必要はない
;  結果は y 成分に保存されるので、それに応じて
;  以下の命令を変更する必要がある
lit r1.y, r1.x       ; ドット <0 の場合、ドット = 0
mul r0, r1.y, c21    ; ディフューズを乗算
add oD0, r0, c22     ; アンビエントを加算
; oD0 の場合暗黙的に 1 でクランプ: これを明示的に行う必要はない
; min oD0, r0, c1.x  ; > 1 の場合クランプ

リスト 15. 最適化後の海底ライティングの計算シェーダ コード

最適化前のライティングの計算シェーダ コード セクションには命令が計 5 つあり、各命令に 1 つの命令スロットと、1 つの実行サイクルがあります。最適化後のライティングの計算シェーダ コード セクションには命令が計 4 つあり、各命令に 1 つの命令スロットと、1 つの実行サイクルがあります。全体で 5 つのうち、1 つの命令を節約でき、このセクションでは 20% の節約になります。またレジスタによる読み書きを 1 回節約できます。

海底シェーダテクスチャ座標セクション

SeaFloor.vsh シェーダ コードのテクスチャ座標セクションは、ステージ 0 で oTO 出力テクスチャ座標レジスタを読み取る 1 命令に最適化済みです。その全コードをリスト 16 に示します。

; テクスチャ座標のコピー
mov oT0.xy, v6

リスト 16. オリジナルの SeaFloor.vsh テクスチャ座標シェーダ コード

SeaFloor2.vsh シェーダ コードのテクスチャ座標セクションは内容が変更されています。これをリスト 17 に示します。これは、最適化パターンの 1 つです。すなわち、mul の後にその結果を使用する add が配置されています。このパターンは mad に置き換え可能です。リスト 18 は、このシェーダ コード セクションを最適化したバージョンです。ここでは、トランスフォーム済みの r9.xz を使用することに注意してください。

; 頂点 xz 位置からテクスチャ座標を生成
mul r0.xy, c24.x, r9.xz
add oT0.xy, r0.xy, c24.zw

リスト 17. オリジナルの SeaFloor2.vsh テクスチャ座標シェーダ コード


; 頂点 xz 位置からテクスチャ座標を生成
; mul と add を mad 命令に統合
mad oT0.xy, c24.x, r9.xz, c24.zw

リスト 18. 最適化後の SeaFloor2.vsh テクスチャ座標 シェーダ コード

SeaFloor.vsh は、すでに出力レジスタに書き込む 1 つの命令なので、改善の余地はありません。SeaFloor2.vsh の場合は、2 つの命令のうちの 1 つを節約できます。これで 50% のパフォーマンス強化になります。

海底シェーダ フォグ セクション

海底シェーダ コードのフォグ セクションは、イルカ トゥイーニング フォグ シェーダ コードと同様の処理をします。ここでは 2 つの係数が関係します。1) 新しい定数を使用して式の順序を変更し、2) 順序を変更した式を 1 つの mad で実装します。リスト 19 は、オリジナルのフォグ コード セクションです。addmul でフォグ係数を計算し、maxmin で値を [0.0,1.0] にクランプします。

; フォグ係数を計算 f = (fog_end - dist)*(1/(fog_end-fog_start))
add r0.x, -r9.z, c23.y
mul r0.x, r0.x, c23.z
max r0.x, r0.x, c0.x       ; フォグを > 0.0 にクランプ
min oFog.x, r0.x, c1.x     ; フォグを < 1.0 にクランプ

リスト 19. 最適化前の海底フォグ シェーダ コード

リスト 20 は最適化後のフォグ コード セクションです。使用する命令は mad 1 つです。mad は新しい定数で、イルカ セクションにあった add と mul に置き換えられます。ここでも、oFog はピクセル補完に使用する値なのでクランプは不要です。0.0 と 1.0 へのクランプには本来 2 つの命令が必要です。

; フォグ係数を計算 f = (fog_end - dist)*(1/(fog_end-fog_start))
; 上式を書き換えて == to: (fog_end/(fog_end-fog_start) - dist/(fog_end-fog_start)
; これは 1 つの mad 命令に書き換え可能
mad oFog.x, -r9.z, c36.y, c36.x 

; oFog.x は暗黙的に [0,1] にクランプされる: 明示的に実行する必要はない
; max r0.x, r0.x, c0.x       ; フォグを > 0.0 にクランプ
; min oFog.x, r0.x, c1.x     ; フォグを < 1.0 にクランプ

リスト 20. 最適化後の海底フォグ セクション シェーダ コード

オリジナルのフォグ セクション シェーダ コードには 4 つの単純命令があります。最適化後のフォグ セクション シェーダ コードでは mad 命令 1 つだけになり、75% の節約になります。また、r0 への読み書きも 3 回ずつ節約できます。このセクションで海底シェーダの 4 つのセクションが終了です。ここまでに、最適化でどのような結果が得られたかまとめてみましょう。

表 2 は、海底シェーダの命令数です。SeaFloor.vsh シェーダが 45% 高速であることがわかります (20 命令に対して 11 命令)。また SeaFloor2.vsh シェーダが 43% 高速であることがわかります (20 命令に対して 12 命令)。イルカ シェーダの最適化ほどではないにしても結構なパフォーマンス強化になりました。

セクション オリジナル Seafloor Seafloor2 節約できた読み書き
トランスフォーム 9 6 6 1
ライティング 5 3 3 1.5
テクスチャ 1/2 1 1 0
フォグ 4 1 1 3
合計 20/21 11 12 5.5

表 2. 海底シェーダ命令数

レンダリングの最適化

レンダリング コードの 3 つの領域を変更できます。1) 定数の更新の合理化、2 ) レンダーステートの更新の合理化、3) デバイスにセット済みのジオメトリを利用した描画順序の逆転

定数の合理化

FrameMove() をよく見ると、その定数値の多くはすべてのフレームでロードされるにも関らず変更されないことがわかります。リスト 21 に FrameMove() の関連コードを示します。

// ライティング ベクトル (正規化されている) とマテリアル カラー
// (非頂点シェーダ ケースとの差を出すため赤いライト使用)
FLOAT fLight[]    = { 0.0f, 1.0f, 0.0f, 0.0f };
FLOAT fDiffuse[]  = { 1.00f, 1.00f, 1.00f, 1.00f };
FLOAT fAmbient[]  = { 0.25f, 0.25f, 0.25f, 0.25f };
FLOAT fFog[]      = { 0.5f, 50.0f, 1.0f/(50.0f-1.0f), 0.0f };
FLOAT fCaustics[] = { 0.05f, 0.05f, sinf(m_fTime)/8, cosf(m_fTime)/10 };

// 頂点シェーダ演算では、転置行列を使用
D3DXMATRIX mat, matCamera, matTranspose, matCameraTranspose;
D3DXMATRIX matViewTranspose, matProjTranspose;
D3DXMatrixMultiply(&matCamera, &matDolphin, &m_matView);
D3DXMatrixMultiply(&mat, &matCamera, &m_matProj);
D3DXMatrixTranspose(&matTranspose, &mat);
D3DXMatrixTranspose(&matCameraTranspose, &matCamera);
D3DXMatrixTranspose(&matViewTranspose, &m_matView);
D3DXMatrixTranspose(&matProjTranspose, &m_matProj);

// 頂点シェーダ定数を設定
m_pd3dDevice->SetVertexShaderConstant( 0, &vZero,     1 );
m_pd3dDevice->SetVertexShaderConstant( 1, &vOne,      1 );
m_pd3dDevice->SetVertexShaderConstant( 2, &vWeight,   1 );
m_pd3dDevice->SetVertexShaderConstant( 4, &matTranspose, 4 );   
m_pd3dDevice->SetVertexShaderConstant( 8, &matCameraTranspose,  4 );
m_pd3dDevice->SetVertexShaderConstant(12, &matViewTranspose,  4 );
m_pd3dDevice->SetVertexShaderConstant(20, &fLight,    1 );
m_pd3dDevice->SetVertexShaderConstant 21, &fDiffuse,  1 );
m_pd3dDevice->SetVertexShaderConstant(22, &fAmbient,  1 );
m_pd3dDevice->SetVertexShaderConstant 23, &fFog,      1 );
m_pd3dDevice->SetVertexShaderConstant 24, &fCaustics, 1 );
m_pd3dDevice->SetVertexShaderConstant(28, &matProjTranspose,  4 );
m_pd3dDevice->SetVertexShaderConstant(32, &matWorldViewProjTranspose, 4 ); 
m_pd3dDevice->SetVertexShaderConstant(36, &fFog2,      1 );

リスト 21. オリジナルの
FrameMove() 定数ロード コード

レジスタ 0、1、20、21、22 の定数が変更されず、レジスタ 23 のフォグ値が使用されていないのがお分かりでしょうか。これらをグローバル定数にすることができます。他の定数はフレーム単位で変化します。ロードにたいした手間がかかるわけではありませんが、負荷ゼロで行われる処理はありません。簡単に対応できるのであれば、改善しましょう。すなわち、リスト 22 のコードを RestoreDeviceObjects() メソッドに移植します。これで、デバイスを復元する時にこれらの値がデバイスに設定されます。

// 定数頂点シェーダ定数を設定
D3DXVECTOR4 vZero( 0.0f, 0.0f, 0.0f, 0.0f );
D3DXVECTOR4 vOne( 1.0f, 0.5f, 0.2f, 0.05f );

m_pd3dDevice->SetVertexShaderConstant(  0, &vZero,     1 );
m_pd3dDevice->SetVertexShaderConstant(  1, &vOne,      1 );

FLOAT fLight[]    = { 0.0f, 1.0f, 0.0f, 0.0f };
FLOAT fDiffuse[]  = { 1.00f, 1.00f, 1.00f, 1.00f };
FLOAT fAmbient[]  = { 0.25f, 0.25f, 0.25f, 0.25f };

m_pd3dDevice->SetVertexShaderConstant( 20, &fLight,    1 );
m_pd3dDevice->SetVertexShaderConstant( 21, &fDiffuse,  1 );
m_pd3dDevice->SetVertexShaderConstant( 22, &fAmbient,  1 );
      
FLOAT fFog2[] = { 50.0f/(50.0f - 1.0f), 1.0f/(50.0f - 1.0f), 0.0f, 0.0f };

m_pd3dDevice->SetVertexShaderConstant( 36, &fFog2,      1 );

リスト 22. RestoreDeviceObjects() グローバル定数の新しい ロード コード

フレーム単位の定数はそのままです。また FrameMove() に残るコードをリスト 23 に示します。

// フレーム単位頂点シェーダ定数の設定
m_pd3dDevice->SetVertexShaderConstant(2, &vWeight,   1 );
m_pd3dDevice->SetVertexShaderConstant(4, &matTranspose, 4 );
m_pd3dDevice->SetVertexShaderConstant(8, &matCameraTranspose,  4 );
m_pd3dDevice->SetVertexShaderConstant(12,&matViewTranspose,  4 ); 

m_pd3dDevice->SetVertexShaderConstant(24, &fCaustics, 1 );
m_pd3dDevice->SetVertexShaderConstant(32, matWorldViewProjTranspose, 4 ); 

リスト 23. FrameMove() フレーム単位定数の新しいロード コード

レンダーステートの合理化

一般には、無駄なレンダーステートの変更を避けるためにはレンダーステートを合理化すべきです。ただし、リセットしなくてすむレンダーステートは実際には小さいセットが 1 つあるだけです。リスト 24 にアルファ ブレンド レンダーステートを示します。

// アルファ ブレンドを起動
m_pd3dDevice->SetRenderState( D3DRS_ALPHABLENDENABLE, TRUE );
m_pd3dDevice->SetRenderState( D3DRS_SRCBLEND,  D3DBLEND_SRCCOLOR );
m_pd3dDevice->SetRenderState( D3DRS_DESTBLEND, D3DBLEND_ONE );

リスト 24. オリジナルの Render() フレーム単位レンダーステート更新コード

D3DRS_SRCBLEND 状態と D3DRS_DESTBLEND 状態は変化せず、フレームごとに設定する必要はありません。後は、必要に応じて D3DRS_ALPHABLENDENABLE の有効、無効を切り替えるだけです。それをリスト 25 とリスト 26 に示します。ここでは、RestoreDeviceObjects() に移動したコードと、FrameMove() に残ったコードを示します。

m_pd3dDevice->SetRenderState( D3DRS_ALPHABLENDENABLE, TRUE );

リスト 25. Render() の新しいフレーム単位レンダーステートの更新コード


          
          m_pd3dDevice->SetRenderState( D3DRS_SRCBLEND,  D3DBLEND_SRCCOLOR );
m_pd3dDevice->SetRenderState( D3DRS_DESTBLEND, D3DBLEND_ONE);

リスト 26. RestoreDeviceobjects() **の新しい変更されないレンダーステート更新コード

第 2 パスのレンダリング順序の入れ替え

レンダリング コードの最適化はここで終了です。原則として、ステート変更は負荷が高いので避けるべきです。そして、最も負荷が高いのがテクスチャの変更であり、頂点バッファの変更です。FVF コードの変更をともなう頂点バッファの変更は、さらに手間がかかります。リスト 27 は、Render() メソッドの第 2 パスです。ここで重要なのは、ここにはピックアップ可能な共通項があるということです。つまり、第 2 パスのレンダリングの順序を変更すれば、デバイスにセット済みのジオメトリを利用できるということです。これによって、各フレームで 3 つの SetStreamSource() 呼び出しと 1 つの SetIndices() 呼び出しを節約できます。

// イルカをレンダリング
m_pd3dDevice->SetTexture( 0, m_pDolphinTexture );
m_pd3dDevice->SetVertexShader( m_dwDolphinVertexShader );
m_pd3dDevice->SetStreamSource( 0, m_pDolphinVB1, sizeof(D3DVERTEX) );
m_pd3dDevice->SetStreamSource( 1, m_pDolphinVB2, sizeof(D3DVERTEX) );
m_pd3dDevice->SetStreamSource( 2, m_pDolphinVB3, sizeof(D3DVERTEX) );
m_pd3dDevice->SetIndices( m_pDolphinIB, 0 );
m_pd3dDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST,
                                            0, m_dwNumDolphinVertices,
                                            0, m_dwNumDolphinFaces ); 
[コード省略]

// 海底のコースティック エフェクトをレンダリング
m_pd3dDevice->SetVertexShader(m_dwSeaFloorVertexShader2 );
m_pd3dDevice->SetStreamSource(0, m_pSeaFloorVB, sizeof(D3DVERTEX) );
m_pd3dDevice->SetIndices( m_pSeaFloorIB, 0 );
m_pd3dDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST,
                                    0, m_dwNumSeaFloorVertices,
                                    0, m_dwNumSeaFloorFaces );

// 最後にイルカのコースティック エフェクトをレンダリング
m_pd3dDevice->SetVertexShader(m_dwDolphinVertexShader2 );
m_pd3dDevice->SetStreamSource(0, m_pDolphinVB1, sizeof(D3DVERTEX) );
m_pd3dDevice->SetStreamSource(1, m_pDolphinVB2, sizeof(D3DVERTEX) );
m_pd3dDevice->SetStreamSource(2, m_pDolphinVB3, sizeof(D3DVERTEX) );
m_pd3dDevice->SetIndices( m_pDolphinIB, 0 );
m_pd3dDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST,
                              0, m_dwNumDolphinVertices,
                              0, m_dwNumDolphinFaces );

リスト 27. オリジナル 第 2 パスの Render() コード

これは、小さな変更ではありません。またこの方法はテクスチャにも利用できるので覚えておきましょう。結果をリスト 28 に示します。これは現在デバイスにセットされているジオメトリを利用できるようレンダリングの順序を改めたものです。

// イルカのコースティック エフェクトをレンダリング
m_pd3dDevice->SetVertexShader( m_dwDolphinVertexShader2 ); 
m_pd3dDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST,
                                    0, m_dwNumDolphinVertices,
                              0, m_dwNumDolphinFaces );

// 海底のコースティック エフェクトをレンダリング
m_pd3dDevice->SetVertexShader( m_dwSeaFloorVertexShader2 );
m_pd3dDevice->SetStreamSource(0, m_pSeaFloorVB, sizeof(D3DVERTEX) );
m_pd3dDevice->SetIndices( m_pSeaFloorIB, 0 );
m_pd3dDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST,
                                    0, m_dwNumSeaFloorVertices,
                                    0, m_dwNumSeaFloorFaces );

リスト 28. 新しい第 2 パス Render() コード

メッシュ最適化

最後はイルカ メッシュの最適化です。ここでは、CD3DMesh クラスが、D3DX でメッシュに用意されている比較的シンプルな最適化、すなわちメッシュ メソッド OptimizeInplace を使用していないことがわかります。

このメソッドの通常の用途を示します。

  • 未使用の面や頂点データを削除

  • 属性を整理

  • 頂点キャッシュに適するよう頂点を整理

じつは、この処置が命令を最も節約できます。CD3DMesh::Create を変更して CD3DMesh クラスにこの最適化を適用すると、100 FPS のパフォーマンス強化が得られます。以下のリスト 29 は CD3DMesh::Create に対する必要な変更内容です。プロジェクトのオリジナルと変更後、両方の d3dfile.cpp を示します。時間があるときに比較してみてください。

    
…[コード省略]
LPD3DXBUFFER pAdjacencyBuffer = NULL;
…[コード省略]
// メッシュをロード
if( FAILED( hr = D3DXLoadMeshFromX(strPathANSI, D3DXMESH_SYSTEMMEM, 
                                   pd3dDevice, 
                                   &pAdjacencyBuffer, &pMtrlBuffer, 
                                   &m_dwNumMaterials, &m_pSysMemMesh ) ) )
{
     return hr;
}
// パフォーマンスのためにメッシュを最適化
if( FAILED( hr = m_pSysMemMesh->OptimizeInplace( D3DXMESHOPT_COMPACT | 
                         D3DXMESHOPT_ATTRSORT | 
D3DXMESHOPT_VERTEXCACHE,
                        (DWORD*)pAdjacencyBuffer->GetBufferPointer(), 
                         NULL, NULL, NULL ) ) )
{
        SAFE_RELEASE( pAdjacencyBuffer );
        SAFE_RELEASE( pMtrlBuffer );
        return hr;

}

リスト 29. CD3DMesh::Create() における新しいメッシュのロードと最適化

D3DX メッシュ ルーチンを使用しているなら、このメソッドを使用してください。イルカ メッシュは、頂点キャッシュの局所性という点ではそれほど悪くはありません。しかし、頂点キャッシュの最適化によってパフォーマンスが 3 倍になるメッシュもあります。

ついに、図 3 のように、この最適化を利用して、~770 FPS を実現しました。これで、3 つの領域の最適化により、アプリケーション全体で 40% のパフォーマンス改善を達成したのです。

最後に

最後はトゥイーニングを目的とした頂点シェーダの最適化で締めくくりました。トゥイーニング シェーダは、その処理速度がほぼ 50% 高速になり、海底シェーダは約 40% 高速化されました。また、順序の整理により、実行時の処理量を削減できました。これらの最適化で、フレーム レート単位でパフォーマンス 22% が得られました。最後に、D3DX でメッシュの最適化を実現し、フレーム レート単位でさらに 18% の改善を得ました。最終的なフレーム単位の改善値は 40%。すなわち 550 FPS から 770 FPS のパフォーマンス強化です。今度は、読者自身のシェーダとシェーダ アプリケーションを最適化してみませんか。ご健闘を祈ります。

このコラムの執筆に際して、Matthias Wloka (nVidia)Iouri TarassovMike AndersonMike Burrows (Microsoft) 各氏のご協力に感謝します。

Microsoftでは、同じような方向性を持った開発者が情報を共有できるようなフォーラムとして、活発なメーリング リストを運営しています。

Philip Taylor は DirectX のプログラム マネージャの一人です。DirectX の最初のパブリック ベータの段階から手を染め、かつては実際に DirectX 2 でゲームを開発したこともありました。時間に余裕があるときは、さまざまな3-Dグラフィックス プログラミング メーリング リストで彼に出会うことがあるかもしれません。

Driving DirectX コラム アーカイブ