2023年5月18日木曜日

Outline Shader

今回はアウトラインシェーダについて

調べたところ、ここで4つの方法が紹介されていた。

1つ目の方法は背面法と呼ばれるもので、主流?の方法らしい。
ただ色々問題があって、手間を掛けないとちゃんとした枠線が描けない。

2つ目はステンシルバッファを使った方法。
まだ自分のライブラリにステンシルバッファが組み込まれていないので後回し。
3Dオブジェクトの裏をキャラクタが通った時、ステンシルのアウトラインがそのままシルエット代わりになる方法が使えそうなので、今後やってみる予定。

3つ目は深度テクスチャを使う方法。

4つ目は法線テクスチャを使う方法。

他にも調べていたところ、深度テクスチャ、法線テクスチャの両方を使ってアウトライン描画を行うここのサンプルがシンプルで分かりやすかったので参考にさせてもらい実装してみた。


深度テクスチャを使ったアウトライン描画


対象の点と付近の点(サンプルでは4点)の差分を合算する。


深度テクスチャのアウトライン描画

深度テクスチャだけを使ったアウトライン描画だと、外枠のみが描画される


法線テクスチャを使ったアウトライン描画


深度テクスチャと同じように対象の点と付近の点(サンプルでは4点)の差分を取って、xyzの成分を足し合わせて合算する。

ただこちらの方は結果が思ったようにならず、オブジェクトの正面から見ると線が描画されるけど(でも不完全)、裏に回ってみると、線が全く描画されなくなった。



表から見た場合

裏から見た場合

色々調べてみると法線がView空間になっているらしく、こちらの実装もView空間に変換したら裏側でも線が描画されるようになった。


法線テクスチャのアウトライン描画

法線テクスチャだけを使ったアウトライン描画だと、オブジェクトの中の線が描画される。


深度テクスチャと法線テクスチャを使ったアウトライン描画


両方のテクスチャの値を使って組み合わせるとお互いの結果が補完されていい感じの描画になる。

組み合わせたアウトライン描画

アウトラインシェーダ


float4 OutlineDepthNormal(
	in		float4			Col,		// ベース色
	in		float4			LineCol,	// 枠線色
	in		Texture2D		TexDepth,	// 深度テクスチャ
	in		Texture2D		TexNormal,	// 法線テクスチャ
	in		SamplerState	Smpler,		// サンプラ
	in		float2			UV,			// UV
	in		float			Depth,		// ベース深度
	in		float3			Normal,		// ベース法線
	in		float2			Screen,		// 画面サイズ
	in		float			FarZ,		// Far Z
	in		float4x4		TransV		// View変換行列
) {
	float2 sp = float2( rcp( Screen.x ), rcp( Screen.y )) ;
	float2 Offset[4] = {
		float2( UV.x		, UV.y + sp.y	), // 上
		float2( UV.x		, UV.y - sp.y	), // 下
		float2( UV.x + sp.x	, UV.y			), // 右
		float2( UV.x - sp.x	, UV.y			), // 左
	} ;

	float BaseZ = Depth * FarZ ;
	float OZ = 0.0f ;

	[unroll]
	for( uint i = 0 ; i < 4 ; i++ ) {
		float DZ ;
		DZ = TexDepth.Sample( Smpler, Offset[i]).r * FarZ ;
		OZ += BaseZ - DZ ;
	}

	OZ = saturate( OZ ) ;

	float ON = 0.0f ;
	float3 BaseN = mul( TransV, float4( Normal, 1.0f )).xyz ;

	[unroll]
	for( uint i = 0 ; i < 4 ; i++ ) {
		float3 N ;
		DeferredDecodeNomal( TexNormal.Sample( Smpler, Offset[i] ), N ) ;
		N = BaseN - mul( TransV, float4( N, 1.0f )).xyz ;
		ON += N.x + N.y + N.z ;
	}

	ON = saturate( ON ) ;

	return lerp( Col, LineCol, OZ + ON ) ;
}







2023年5月15日月曜日

Point Light

ポイントライトを実装してみることにした。

ディファードレンダリングを見つけた時、このポイントライトがきっかけだった気がする。

ライトの処理は重くなりがちで、数個の制限を駆使してシーンを作らないと行けないらしい。
でもそんな面倒なことはしたくない。
ディファードレンダリングなら今までの制限を大幅に超えてライトが自由に配置できるとのこと。

HLSLの魔導書に書かれているのをアレンジして実装してみた。

ポイントライト1000個

1000個のポイントライトをランダムに配置してみた。
まあ、普通に動きそう。

更に視錐台カリングも実装してみた。

視錐台カリング


Tile Based Rendering


問題はここから。 
Deferred Renderingで何も考えずにライトを描画出来るけど、やはり1ピクセル単位ですべてのライトを処理するのは無駄すぎる。

そこで、描画時に画面を分割して、そこに含まれるライトのみを対象とすることで高速化する。

視錐台を分割する

タイル1つ分の視錐台を用意して、そこに含まれるポイントライトを調べる。

タイル内に含まれるポイントライト

あるタイルで描画対象のライトをUAVに書き出して、Deferred Renderingでそのバッファを元に対象のライトのみを描画することにより、重なっているライト数だけ処理をすれば良くなる。


この話題になると絶対に出てくるIntelのサンプルプログラム

HLSLの魔導書でもこのプログラムの言及があり、視錐台分割部分で下記のようなコメントがある。

    // Intelのサンプルと微妙に違うのは右手系でやっているから
    // あと、Intelのサンプルは微妙に間違ってると思う

実物のソースと見比べてみても全く同じで、間違ったところが直されていると思ったがそのままだった。もしかするとIntelのソースが直されたのかと思ったがそうでもないらしい。

その根拠が、このサイト
ここによると、左面と底面の式が間違っている。

float4 c1 = float4(mtxProj._11 * tileScale.x, 0.0, tileBias.x, 0.0);
float4 c2 = float4(0.0, -mtxProj._22 * tileScale.y, tileBias.y, 0.0);
float4 c4 = float4(0.0, 0.0, 1.0, 0.0);

frustumPlanes[0] = c4 - c1; // Right
frustumPlanes[1] = c4 + c1; // Left
frustumPlanes[2] = c4 - c2; // Top
frustumPlanes[3] = c4 + c2; // Bottom

上記は間違いで、下記が正解。

float4 c1 = float4(mtxProj._11 * tileScale.x, 0.0, tileBias.x, 0.0);
float4 c2 = float4(0.0, -mtxProj._22 * tileScale.y, tileBias.y, 0.0);
float4 c4 = float4(0.0, 0.0, 1.0, 0.0);

frustumPlanes[0] = c4 - c1; // Right
frustumPlanes[1] =      c1; // Left
frustumPlanes[2] = c4 - c2; // Top
frustumPlanes[3] =      c2; // Bottom

また、このIntelのサンプルプログラムは1つのCompute Shader内で、異なる単位の処理が行われている。説明なしには理解するのは難しい。

// [ドット単位]のZを取得(dot/thread)
float2 uv = In.DTID.xy ;
float4 Pos = DepthToPos( TexDepth[uv], uv, Cam.InverseP ) ;
uint Base = ( In.GID.y * (( Cam.Screen.x + TILE_SIZE-1 ) / TILE_SIZE ) + In.GID.x ) * PLIGHT_PER_TILE ;

if( In.GI == 0 ) {
	sMinZ = asuint( Pos.z ) ;
	sMaxZ = asuint( Pos.z ) ;
	sCount = 0 ;
}

GroupMemoryBarrierWithGroupSync() ;

// [タイル(グループ)単位]の最大/最小のZを書き込む(tile/256thread)
InterlockedMin( sMinZ, asuint( Pos.z )) ;
InterlockedMax( sMaxZ, asuint( Pos.z )) ;

GroupMemoryBarrierWithGroupSync() ;

float MinZ = asfloat( sMinZ ) ;
float MaxZ = asfloat( sMaxZ ) ;

float4 Frustum[ 6 ] ;
GetTileFrustumPlane( Frustum, Cam.TransP, In.GID.xy, Cam.Screen, MinZ, MaxZ, TILE_SIZE ) ;

// [ライト単位]で視錐台に含まれるかチェック(1024lightの場合 4light/thread)
for( uint lightIndex = In.GI ; lightIndex < Cam.PLightCount ; lightIndex += TILE_SIZE * TILE_SIZE ) {
	_PointLight light = PointLight[ lightIndex ] ;
	float4 PLPos = mul( Cam.TransV, float4( light.Pos, 1.0f )) ;

	// タイルとの判定
	bool inFrustum = true ;
	[unroll]
	for( uint i = 0 ; i < 6 ; ++i ) {
		// ライトの座標と平面の法線とで内積を使って、
		// ライトと平面との距離(正負あり)を計算する
		float d = dot( Frustum[i], PLPos ) ;

		// ライトと平面の距離を使って、衝突判定を行う
		inFrustum = inFrustum && ( d >= -light.Range ) ;
	}

	// タイルと衝突している場合
	if( inFrustum ) {
		// 衝突したポイントライトの番号を影響リストに積んでいく
		uint Index ;
		InterlockedAdd( sCount, 1, Index ) ;
		if( Index >= PLIGHT_PER_TILE ) break ;	// 1タイル内の上限に達した
		Dst[ Base + Index ] = lightIndex ;
	}
}

GroupMemoryBarrierWithGroupSync() ;

if( In.GI == 0 && sCount < PLIGHT_PER_TILE ) {
	Dst[ Base + sCount ] = 0xffffffff ;
}

GroupMemoryBarrierWithGroupSync関数を呼ぶと、グループ内のスレッドがこの呼出に達するまでブロックされる。

最初のブロックでは、グループID0番だけにグループの共有メモリを初期化させる。

次のブロックでは16×16ドットのZを調べて、最小と最大深度を共有メモリに書き込む。
ここでは1ドット/1スレッド単位で処理される。

次のブロックではポイントライト情報を参照して視錐台に含まれるかをチェックする。
ポイントライトが1024個あるとしたら、4ライト/1スレッド単位で処理される。

IntelのサンプルではCompute Shader内でG-Bufferも参照してライトの計算までしていたけど、自分のプログラムではUAVにタイル別のライト配列を出力するまでとして、Defferd/Forward Rendering両方に使えるようにしてみた。

また、1タイル分のリストの大きさはライト数分持つのが普通だけど、ライト数1024として、1タイルに1024個描くような状況は殆どないはずなので、色々調整して256個にした。


Deferred Renderingでは、ライト描画を加えた処理が2~4msぐらい掛かっていたけど、TBDRだと50~100usに短縮。ただし、UAVを作るCSが35~40usかかるので、トータルでは85usから140usぐらい。




2023年5月12日金曜日

GPU Thread

今までCPU側のスレッドについて対応してきたけど、GPU側のスレッドも効率的に使いたい。

Z Prepassや影、通常のメッシュ描画については何も考えずにDrawコマンドを発行すれば並列に処理してくれる。

GPU側で並列に処理されている様子


最近までシェーダを切り替える度にコマンドリストを実行して、その処理が終わるまで待っていた。なのでコマンドリストに詰めた単位では重なって並列に処理されているけど、コマンドリストの実行自体を1フレーム内で何回も呼んで、しかもGPU側の処理が終わるまで待っていたので処理の塊の間の隙間が大きくなっていた。

CPU側のマルチスレッド対応にともなって、複数のコマンドリストを用意してやること&シェーダを跨いでまとめて実行できるようになり、GPU側の処理の並列度が上がり、処理の塊の間隔も短くなった。


ライブラリ側の描画処理はDraw関数を呼び出すと、その中でZ Prepass、影、G Buffer描画、Defferdレンダリング、Emmisiveのポストエフェクトをまとめて実行するようになっている。

サブカメラを用意して、今のシーンをメインカメラと、サブカメラからレンダリング後メインカメラの描画をワイプで画面端に描画するようにしていた。

この時、レンダリングターゲットを変えて、2回Draw関数を呼び出している。
PIXで確認すると、同じような並びの処理の塊が2回続く。

Z Prepass、影、G Buffer描画、DefferdレンダリングまではGPU側で全体を並列で処理しているような形になっている。ただその後のEmmisiveの部分で並列にはならず、その前の処理を待って次の処理が始まっている。

Drawの処理を分解して、それぞれ独立して呼べるように修正した。
そして、メインカメラ、サブカメラのZ Prepass、影、G Buffer描画、Defferdレンダリングまでを先に処理して、それぞれのEmmisiveを最後に処理するようにした。

前半の処理をまとめた結果

こうすることで、かなりの処理が並列に動いているように見えるようになった。

最後のEmissive部分も並列にできないかと、リソースを2倍にしてそれぞれ被らないようにして実行してみたけど、全く変わらなかった。
バリアを張ってしまうと、対象のリソースは関係なしに全部が止まってしまうのか?

おそらく同じ意味のバリアなら、複数同時に出来る。
例えばテクスチャコピーを2つ連続なら並列になる。
だけど、テクスチャコピーして、そのコピーが完了してミップマップを生成するとなると、全く別のリソースに対して行ってるのに、並列に行われなかった。


複数のキュー


バリアを挟む複数の操作をしてしまうと、最小単位で同じ操作をまとめていかない限り並列には処理ができなさそう。

こういった場合でも処理を並列にしたい場合は、キュー自体を複数用意すればいいのでは無いか?
例えば2人用、4人用など複数人用に別々の画面をレンダリングするような場合、1つのキューではなく複数のキューでそれぞれ処理して、最後に揃ったところで画面にレンダリングすると、何も考えずに並列処理ができそう。



2023年5月1日月曜日

DirectX12 Multithread対応の続き

以前マルチスレッド対応をしたが、今回はその続き。

マルチスレッドに対応する際、参考にしたのがマイクロソフトのサンプルだけど、このサンプルって実は特殊なのでは無いかと思った。

複数のコマンドリストを用意して、それぞれを担当するスレッドがコマンドリストにコマンドを追加していく。
これはある1シーンのためにカスタマイズされた専用のプログラムで使う分には問題ないし効果も高そう。

ただ、自分が作っている汎用的な3Dエンジンの場合、ちょっと無理があるかもしれないと思い始めた。


今までは1つのコマンドリストにシーケンシャルにコマンドを追加していた。
必要なパラメータを順番に追加していけば描画はされた。

新しくマルチスレッド対応になった場合、シングルスレッドで追加するか、マルチスレッドで追加するかを切り替えられるようにした。シングルスレッドの場合は今までと同じ使用感。
マルチスレッドにした場合、最初のコマンドリストだけで実行するコマンド、全部のコマンドリストで同じ実行が必要なコマンド、パラレルに設定できるコマンドなど呼び側でかなりいろいろ考慮が必要で、投入コマンドがかなり多くないと寧ろ遅くなる結果になっていた。


DX12 Do's And Don'ts


nVIDIAのサイトでこんなドキュメントを見つけた。
全てきちんと理解できたらかなりクオリティが高くなりそうだけど、読み取れた一部でも改良していきたい。


ExecuteCommandListsとFence


以前コマンドリストのラッパを刷新した際、それまで対応してなかったReadbackも対応した。ExecuteCommandListsを呼び出したら、その後メインスレッドorクロージャスレッドでその実行完了を待つ。Readbackデータ読み取りたい場合は、メインスレッドで待ってその後参照する。読み取り不要な場合はクロージャスレッドで終わった段階で各種コマンドの開放を行うようにしていた。この時、フェンスを立てることになるが、このフェンスがパフォーマンスに多大な影響を与えてしまう。
PIXで見るとExecuteCommandLists単位で、隙間が開いている。
これが普通なのかと思っていたけど、全くダメな作りだった。


PSOの切り替え


シェーダが同じものをまとめて描画し、ExecuteCommandListsを呼び出していた。
それしかできないと思っていた。
勘違いしていたのが、Reset関数にPSOを指定するからなんだけど、Resetのタイミングで指定するのだから切り替えにはまたResetする必要があるのだろうと。だが実は違った。
SetPipelineStateという関数があり途中から設定できる。設定できるだけでなく切り替えも出来る。
これはnVIDIAのドキュメントに書いてあるわけではないけど、読んでいるうちにそういう事が出来るのだろうと、リファレンスを調べたらあった。
SetPipelineState、Set[Graphics|Compute]RootSignatureを呼べば途中でシェーダの切り替えも出来ることを知った。


コマンドリストは15~30


コマンドリストは15~30以下と書かれていて驚いた。
これをみて今までの考え方が根本から間違っているのではないかと思った。
シェーダを切り替える度にコマンドリストも切り替えて行くといいのではないか?
同時に複数のコマンドリストを扱うのではなく、常に1つのコマンドリストに対して追加し、切り替えのタイミングで次のコマンドリストに移る。ある程度コマンドリストが溜まったらExecuteCommandListsを実行する。


ドキュメントではすべてのコマンドリストを用意してから最後にExecuteCommandListsというのもだめパターンに書かれている。5回~10回に分けて、CPUとGPUが並行処理していく状態を作るのが望ましい。


マルチスレッド対応した際、コマンドリストに投入したいデータが出揃ったタイミングで、一斉にスレッドに追加させていた。
これもマイクロソフトのマルチスレッドのサンプルを最初に見てしまった弊害。
追加する度に別スレッドで並列にコマンドリストにも追加するように作り変えた。
全部のコマンドを作っていざ実行となったときに、今まではこのタイミングから追加し始めていたのが、裏で作るのと同時にコマンドリストに追加をしているため、待ち時間が短縮できた。

描画全体の時間(デバッグ)
揃ってからスレッド実行 :0.0958秒
追加と同時にスレッド実行:0.0707秒

描画全体の時間(リリース)
揃ってからスレッド実行 :0.0642秒
追加と同時にスレッド実行:0.0398秒

ただ残念なことに、マルチスレッドにせずメインスレッドで直接コマンドリストに追加した場合は下記となり、マルチスレッドが意味ないと思わせる結果となった。
デバッグ:0.0618
リリース:0.0289
コマンドリストに追加する重たい処理が少ないケースだからか?
改良版はそこまでシングルスレッドに負けてないので、マルチスレッドが勝つケースもあると信じる。


比較


シェーダを切り替える度にExecuteCommandListsを呼び出していたのを、まとめて1回で実行したものの比較。


シェーダ切り替え毎にExecuteCommandLists実行

Z Pre-passからDeferredRenderingまでをまとめて実行

修正前は、塊の間隔が空いていて無駄に待ってしまっている。全体は343usかかっている。
修正後は、まとめた部分が並列で動くようになっており、全体は297usに短縮している。
PIXでイベント名をつける単位がExecuteCommandListsの実行単位なので、今まできれいに分類されていたのが、1まとめになってしまったけど、そんなことはどうでもいいくらいにパフォーマンスがアップした。

拡張バリアにした結果、400us位かかっていたのが、350usぐらいになって、コマンドリストの実行をまとめた結果、350usから300usぐらいになった。
後半のEmissive部分がまだまとめる余地が残っているのでもう少し削れそう。