リモートデスクトップでゲームパッドを使う方法

Share

こんにちわ。
Pocolです。

たまには生活お役立ち情報としてテレワークに役立つリモートデスクトップでゲームパッドを使う方法を紹介します。
役立つ場面としては,リモートデスクトップで自宅PCから会社PCに接続し,ゲームタイトルを開発する状況などを想定しています。

皆さん,ご存知のようにWindows 10 Proであれば,ゲームパッドはリモートデスクトップでも問題なく使えると思います。なので,Proの場合は適切に設定すればOK。

困るのがWindows 10 Homeとかの場合です。デフォルトだとリモートデスクトップでゲームパッド入力が受け付けない状態です。自分もこれに当てはまります。

ちょっと前にMicrosoftがリモートデスクトップでXBox GamePadを使える様にするプラグインをGithub上で公開しました(https://github.com/microsoft/RdpGamepad)
これを使えば,Windows 10 Homeでもゲームパッドが使えるようになります。
順に説明していきます。

まずは,Githubページの上にあるReleaseタブをクリックしましょう。

あとはリリースページに書いてある手順に従ってexeをインストールしていくだけです。
リモートワークをする前提なので,転送元PCを「自宅PC」,転送先PCを「会社PC」と想定します。

転送元PCの設定

まずクライアント側(転送元)の設定です。
(1) リリースページから,「dpGamepadClientInstall-1.0.0.exe」をダウンロードし,インストールします。
(2) インストールが終わったら,リモートデスクトップセッションを切断して,転送元のPCを差起動します。
(3) 起動後に転送元PCにXboxコントローラーを指します。
以上で,転送元PCの設定は完了です。

転送先PCの設定

(1) https://github.com/ViGEm/ViGEmBus/releasesのページに飛び,

  • Xbox 360 Accessories Software 1.2
  • Microsoft Security Advisory 3033929 Update
  • ViGEmBus_Setup_1.16.116.exe

の3つを順に転送先PCにインストールします。
(2) 3つインストールしたら,ページを戻り「RdpGamepadReceiverInstall-1.0.0.exe」を転送先PCにインストールします。
(3) インストールが完了したら,「Microsoft Remote Desktop Gamepad Receiver」が起動していることを確認します。転送先PCの画面右下から「隠れているインジケータ」を表示すると確認できるはずです。

これで転送先PCの設定は終了です。
会社さんによってはインストールできるソフトウェアに制限があると思うので,情報管理部門の方とかに許可取るのを忘れないようにしてください。

実際に動かす

あとは普通に転送元のPCに刺さっているXboxコントローラーを使えば,リモート先で動くようになるはずです。

そんなわけで,リモートデスクトップでゲームパッドを使えるようにする方法を紹介してみました。是非テレワークでの開発にお役立てください。
ではでは~

中級グラフィックス入門
~シャドウマップについての補足~

Share

お断り

はじめに

この記事は Graphics Advent Calender 2017(https://qiita.com/advent-calendar/2017/graphics) の9日目の記事です。

さて,昨年作った資料(https://speakerdeck.com/projectasura/zhong-ji-gurahuitukusuru-men-siyadoumatupinguzong-matome) の反響が良かったので,シャドウについて捕捉を書いてみようと思いました。特に資料後半で適当に書いてしまったPCSSと,最適化について話が抜けているので加筆してみます。

不要なオブジェクトを書かない

現在所属するプロジェクトでもそうなのですが,シャドウマップは処理が重く最適化をしないとどうしようもありません。そこで通常は,シャドウマップ用の軽量メッシュを用意する,不要なオブジェクトを書かないカリング処理を行うといった措置を取ります。軽量メッシュはアーティストに作成をお願いしなくてはいけませんが,カリング処理はエンジニアのみで対応可能ですのでカリング処理について説明してみます。

通常の描画であれば,視錘台カリングを使っておけば良いでしょう。

しかしシャドウマップの場合,単なる視錘台カリングだとまずい場合があります。
例えば,視錘台の外にいるオブジェクトが視錘台の中に影を落とす場合です。

この場合に,単なる視錘台カリングを適用してしまうと,本来影が落ちて欲しいオブジェクトが錘台の外にいるために,カリングで消えてしまって影が出ないという見た目的におかしい状態が発生してしまいます。

そこで,シャドウマップを描画する際はカリングにはある程度工夫が必要となります。

昔やっていた方法(1)

シャドウマップ部分に移る余白部分が多いほど無駄になります。前述した資料にあるようにシャドウマップに移る領域を出来るだけタイトにして,無駄な領域をなくしてシャドウマップに割り当てられるピクセル数を増やすことによってシャドウマップの解像度は変えずにシャドウマップの品質を上げることが可能です。この品質をあげるために使われるテクニックが単位キューブクリッピングというテクニックです。

昔やっていた方法では,視錘台に引っかかるオブジェクトだけちゃんと描画するという割り切った最適化をやっていました。具体的には視錘台と交差するものを含めて単位キューブクリッピングに使用するAABBを作成するという方法です。当時やっていたプロジェクトでは,結構大きめのバウンディングボックス等があったので,単純に視錘台に引っかかるものだけを入れるという方法使うと,無駄な領域が増えてしまいシャドウマップの品質がかなり落ちるという状態になってしまいました。そこで,さらに判定を厳密化し,視錘台と交差するオブジェクトではなく,視錘台と交差する点のみを使ってタイトなAABBを作るという方法をやっていました。

実装としては,視錘台の8角から構成されるワイヤー錘台を作って,ワイヤーとオブジェクトの交差判定処理を行い,交差点をAABBを作成する点群に追加するといった感じです。

技術デモを作成した当時は,大抵のシーンでおおむね問題がありませんでしたが,やはり割り切った実装であるため,いくつかのシーンではシャドウが消えたり,急にシャドウが書かれたり”パカパカする”フリッカリングが発生してしまいました。

昔やっていた方法(2)

技術デモは品質が命ということで,このままではダメということになりました。
が,既にそのプロジェクトからは外れてしまっていたため,後任者の方に実装をお願いすることにしました。
当時思いついた方法は,視錘台のAABBを先に作っておき,判定対象のオブジェクト中心からレイを飛ばして,AABBにヒットしたら描画対象として加えるという方法です。

後任者の方がちゃんと実装したかどうかは定かではありませんが,最近になって自分で実装してみました。
レイを飛ばす方法のオブジェクトのサイズが適度に小さい場合は,これで大丈夫そうでした。
…が,高層ビルのような長いオブジェクトがあると誤判定されるケースが多くやっぱり駄目だということになりました。まぁ,当たり前っちゃ~当たり前ですよね…。
で,ダメだったので,もう少しだけ頑張って実装してみました。疑似コードは次のような感じです。

AABBの中心からレイを飛ばす
if (カメラ錘台にヒット)
{
   描画対象に追加。
   return;
}

AABBの8角の点を取得。
for(auto i=0; i<8; ++i)
{
    点iからレイを飛ばす
    if (カメラ錘台にヒット)
    {
       描画対象に追加。
    return;
    }
}

これで,多少マシになりました。ただレイを9本飛ばすので,若干処理負荷が増えます。

最近使っている方法

つい最近実装した方法は,まじめに判定をやる方法です。

視錘台の8角からAABBを作り,これをライトの照射方向で作成されるライト空間にAABBを変換して,ライト空間上でのAABBのサイズを求めます。
次に,求めたサイズとライトの照射方向ベクトルからOBBを作成して,OBB内にオブジェクトが含まれるかどうか判定処理に持ち込みます。OBB内にオブジェクトが含まれていたら描画リストに追加するという方法です。とりあえず実験でためした実装では視錘台のAABBを使っていますが,視錘台のAABBではなく視錘台を8角を使った方がよりタイトになると思います。
DirectXCollisionが使える場合は,BoundingOrientedBoxを使うことで簡単に実装できます。BoundingOrientedBoxを作成する際のOrientationとなる四元数はライト空間の基底行列から求めることが出来ます。この基底行列はサイズを求める計算で作っているので,XMQuaternionRotationMatrix()メソッドの引数として渡せば求まります。
しかし,図をみると分かるようにこの方法は完璧でなく,視錘台の外側に不要な領域が生まれてしまいます。

たぶん一番良い方法

一番良い方法としては,視錘台とライトの照射方向ベクトルから構成される厳密な凸包を作成して,GJKアルゴリズムなどによりオブジェクトが凸包に含まれるかどうかの判定を行う方法です。

この方法まだ試してみていないです。判定としては一番厳密になるのですが,処理負荷的に大丈夫かどうかが不安材料で,実用に耐えられるかどうかという所でしょうか。だれか試したことがある方がいらっしゃったら,結果がどういう感じか教えて頂けると有難いです。あるいは,もっといい方法あれば教えてください…。

→ @holeさんに,”Sample Distribution Shadow Map”がいいよと教えていただきました。
確かに,おっしゃる通りですね。これに勝るものはありません。レンダリングバジェットがある程度確保できているのであれば,SDSMを使うのが良いでしょう。
(ただ,今仕事でやっているやつはSDSM実装できるだけのレンダリングバジェットが無いんですよね…。またシャドウの領域はバッチリとフィットするのですが,GPU上で錘台計算している実装などではCPUにデータをリードバックさせるとパフォーマンスが落ちます。そのため,GPU上で錘台カリングするような実装になると思うのですが,この場合ドローコールを減らすためのカリングを行うなど工夫していないとドローコールが増えるので,ドローコールや頂点シェーダがボトルネックである場合はパフォーマンスが出ません。)

Percentage-Closer Soft Shadow

さて,話は変わってPCSSの話です。
FarCryやアサシンズクリードなど,最近のAAAタイトルではPCSSを使った半影表現が行われています。
より現実的なシャドウを出したいというフェーズにシフトしたと考えても良いでしょう。これに遅れるわけにはいきません。
そんなわけでPCSSについて少し勉強をしておこうと思いました。


※図は,[Fernando 2005]より引用。

PCSS[Fernando 2005]は半影表現を行うアルゴリズムの1つです。オブジェクトとの距離が近いほどハードシャドウ気味になり,距離が遠いほどソフトシャドウ気味になります。
このアルゴリズムの基となるアイデアは,PCFフィルタのカーネルサイズを制御しようというものです。

※図は, [Fernando 2005]より引用。

PCFフィルタのカーネルサイズの推定には次の情報が必要となります。

  • ブロッカーの深度(\(d_{Blocker}\))
  • レシーバーの深度(\(d_{Receiver}\))
  • ライトサイズ(\(w_{Light}\))

これらの情報を元に次式によって,カーネルサイズを決定します。

\begin{eqnarray}
W_{Penumbra} = \frac{d_{Receiver} – d_{Blocker}}{d_{Blocker}} w_{Light} \tag{1}
\end{eqnarray}

\(W_{Penumbra}\)は推定によって得られるPCFのカーネルサイズです。

ライトのサイズは,シーン側で分かるはずなのでシェーダ側に定数バッファとして渡します。レシーバーの深度は,ライト空間に変換してZ値を求めればよいので,計算により求まります。必要な3つの情報のうち,2つがあっさりと計算により求まります。残る1つはブロッカーをシャドウマップ上で探し出して,ブロッカーの深度を求めます。

実装

NVIDIAによる実装がGithubに上がっているので,これをもとに実装方法を勉強してみます。
リポジトリは下記です。
https://github.com/NVIDIAGameWorks/D3DSamples/tree/master/samples/SoftShadows

mediaフォルダ内にシェーダがあります。
SoftShadows.fxにピクセルシェーダのメインエントリーポイントがあり,下記のように実装されています。

float4 EyeRender_PS (uniform int shadowTechnique, Geometry_VSOut IN) : SV_Target
{
    float2 uv = IN.LightPos.xy / IN.LightPos.w;
    float  z  = IN.LightPos.z  / IN.LightPos.w;

    // Compute gradient using ddx/ddy before any branching
    float2 dz_duv = DepthGradient(uv, z);
    float4 color = Shade(IN.WorldPos, IN.Normal);
    if (IsBlack(color.rgb)) return color;

    // Eye-space z from the light's point of view
    float zEye = mul(IN.WorldPos, g_lightView).z;
    float shadow = 1.0f;
    switch (shadowTechnique)
    {
        case 1:
            shadow = PCSS_Shadow(uv, z, dz_duv, zEye);
            break;

        case 2:
            shadow = PCF_Shadow(uv, z, dz_duv, zEye);
            break;
    }
    return color * shadow;	
}

PCSS_Shadow()という部分が肝心な部分です。このメソッドはPercentageCloserSoftShadows.fxhというファイルに実装されています。実装は下記のようになっています。

float PCSS_Shadow(float2 uv, float z, float2 dz_duv, float zEye)
{
	// ------------------------
	// STEP 1: blocker search
	// ------------------------
	float accumBlockerDepth = 0;
	float numBlockers = 0;
	float2 searchRegionRadiusUV = SearchRegionRadiusUV(zEye);
	FindBlocker(accumBlockerDepth, numBlockers, g_shadowMap, uv, z, dz_duv, searchRegionRadiusUV);

	// Early out if not in the penumbra
	if (numBlockers == 0)
		return 1.0;
	else if (numBlockers == BLOCKER_SEARCH_COUNT)
		return 0.0;

	// ------------------------
	// STEP 2: penumbra size
	// ------------------------
	float  avgBlockerDepth      = accumBlockerDepth / numBlockers;
	float  avgBlockerDepthWorld = ZClipToZEye(avgBlockerDepth);
	float2 penumbraRadiusUV     = PenumbraRadiusUV(zEye, avgBlockerDepthWorld);
	float2 filterRadiusUV       = ProjectToLightUV(penumbraRadiusUV, zEye);

	// ------------------------
	// STEP 3: filtering
	// ------------------------
	return PCF_Filter(uv, z, dz_duv, filterRadiusUV);
}

ブロッカーを探索(STEP 1)して,その結果からカーネルサイズを決定して(STEP 2),PCFフィルタを適用する(STEP 3)という実装です。探索した結果,ブロッカーがゼロである場合は,影ではないので処理を打ち切ります。また,全てのピクセルがブロッカーだった場合は,半影になる可能性はなくなるので,影と判定して半影処理はスキップして終わりにしています。探索領域は上記の実装からみると,SearchRegionRadiusUV()というメソッドで決定しているようです。このメソッドの実装は次のようになっています。

// Using similar triangles from the surface point to the area light
float2 SearchRegionRadiusUV(float zWorld)
{
	return g_lightRadiusUV * (zWorld - g_lightZNear) / zWorld;
}

上記で出てくるg_lightRadiusUVとg_lightZNearは,SoftShadowRenderer.cppのSoftShadowsRenderer::updateLightCamera()メソッドのあたりで設定が行われています。
実装を見ると,g_lightRadiusUVはワールド空間でのライト半径をライト錘台で見たときのUV座標の大きさに変換を掛けているようです。g_lightZNearはライト錘台のニアクリップ平面までの距離を設定しているようです。
続いて,FindBlocker()メソッドを追ってみましょう。

// Returns accumulated blocker depth in the search region, as well as the number of found blockers.
// Blockers are defined as shadow-map samples between the surface point and the light.
void FindBlocker
(
    out float        accumBlockerDepth, 
    out float        numBlockers,
    Texture2D<float> g_shadowMap,
    float2           uv,
    float            z0,
    float2           dz_duv,
    float2           searchRegionRadiusUV
)
{
	accumBlockerDepth = 0;
	numBlockers = 0;

    #ifdef USE_POISSON
	for (int i = 0; i < SEARCH_POISSON_COUNT; ++i)
	{
		float2 offset = SEARCH_POISSON[i] * searchRegionRadiusUV;
		float shadowMapDepth = g_shadowMap.SampleLevel(PointSampler, uv + offset, 0);
		float z = BiasedZ(z0, dz_duv, offset);
		if (shadowMapDepth < z)
		{
			accumBlockerDepth += shadowMapDepth;
			numBlockers++;
		}
	}
    #else
	float2 stepUV = searchRegionRadiusUV / BLOCKER_SEARCH_STEP_COUNT;
	for(float x = -BLOCKER_SEARCH_STEP_COUNT; x <= BLOCKER_SEARCH_STEP_COUNT; ++x)
		for(float y = -BLOCKER_SEARCH_STEP_COUNT; y <= BLOCKER_SEARCH_STEP_COUNT; ++y)
		{
			float2 offset = float2(x, y) * stepUV;
			float shadowMapDepth = g_shadowMap.SampleLevel(PointSampler, uv + offset, 0);
			float z = BiasedZ(z0, dz_duv, offset);
			if (shadowMapDepth < z)
			{
				accumBlockerDepth += shadowMapDepth;
				numBlockers++;
			}
		}
    #endif
}

やっていることとしては,探索半径から探索範囲の各テクセルをなめてシャドウであるかどうかの判定を行い,シャドウと判定されたら,その時のシャドウマップの深度とヒット数をカウントアップするということをやっているようです。結構素直な処理ですが,見ると分かるように各テクセルを調べる際にテクスチャフェッチが必要になるため,それなりに重たい処理です。
これで,ブロッカー深度を求めることができるので,必要な情報が揃います。後はそろった情報を使ってPCFフィルタを適用すれば終わりです。PCFフィルタについては見ればわかると思うので,説明は省略します。

Screen-Space Percentage-Closer Soft Shadows

[Fernando 2005]はライト空間(シャドウマップ空間)で計算を行うアルゴリズムでした。これをライト空間ではなくスクリーン空間で行うものが,”Screen-Space Percentage-Closer Soft Shadows”[MohammadBagher 2010]です。


※図は[MohammadBagher 2010]より引用

アルゴリズムは以下の手順で行われます。

  • Perparation
  • [MohammadBagher 2010]のアルゴリズムでは4つのバッファを使用する様です。

     * Scene depth map : シーンの深度マップ
     * Shadow map : 普通のシャドウマップ
     * Hard shadow map : スクリーン空間においてシャドウであるかを示す点群
     * Projected shadow map : スクリーン空間上の各点に対して最も近いブロッカー距離が格納される

  • Blocker search
  • スクリーン空間上の各ピクセルに対してProjected Shadow mapをスキャンし,光源をブロックするオブジェクトの距離平均を求めます。
    ブロッカーが無い点については,計算は省略します。
    このステップでブロッカーの深度の平均が求まることになります。

  • Penumbra estimation
  •  前のステップで計算されたブロッカーの平均深度を用いて,各ピクセルに対する半影サイズを推定します。
     スクリーン空間上での半影サイズは式(2)により計算します。

  • Shadow filtering
  •  前ステップで求めた半影サイズの直径から,Hard shadow mapを可変サイズフィルタでフィルタリングします。

さて,上記で出てきた式(2)は以下のようになります。

\begin{eqnarray}
W_{screen\,penumbra} \equiv \frac{W_{penumbra} d_{screen}}{d_{eye}} \tag{2}
\end{eqnarray}

ただし,上式における\(d_{screen}\)と\(W_{penumbra}\)は,

\begin{eqnarray}
d_{screen} & \equiv & \frac{1}{2 \tan \frac{fov}{2}} \\
w_{penumbra} & \equiv & \frac{d_{receiver} – d_{blocker}}{d_{blocker}} W_{light}
\end{eqnarray}

とします。

[Fernando 2005]とは違って,一旦シャドウマップを射影してしまって,スクリーン空間上で処理を行うというのがミソです。この手法はスクリーン空間で処理する際にエッジ情報が失われるため,クロスバイラテラルフィルタなどを使っているようです。

Screen Space Anisotropic Blurred Soft Shadows

SSPCSSを改良した手法というのが提案されています。
その手法というのが,”Screen Space Anisotropic Blurred Soft Shadows”[Zheng 2011]です。


※図は[Zheng 2011]より引用。

SSABSSは,ビューイング角度を考慮して半影を出すようです。
アルゴリズムの詳細が[Zheng 2014]に載っています。


※図は[Zheng 2014]より引用。  

SSPCSSと違う所は,法線を考慮したAnisotropic Gaussian Filterを適用するという所のようです。
[Zheng 2014]はちゃんと読んでいないのですが,[Zheng 2011]を見た感じだと,通常のガウスブラーを適用する際に円の半径の使う代わりに,次式によって算出される楕円半径を用いてガウスブラー処理を行えば良い様です。

\begin{eqnarray}
A_{minor} & = & {\rm{normalize}}(n_x, n_y, 0) \\
A_{major} & = & A_{minor} \times N_v \\
r_{minor} & = & N_v \cdot N_s \\
r_{major} & = & 1
\end{eqnarray}

上式における\(N_v = (n_x, n_y, n_z)\)はサーフェイスの法線ベクトルとし,\(N_s = (0, 0, 1)\)はスクリーンの法線ベクトルとします。

最後に

シャドウマップについて資料ですっ飛ばしたところをいくつか補足説明してみました。
論文の特許等については調べていないので,会社で実装する際は各社法務とご相談ください。
“Adaptive Depth Bias for Shadow Maps”はまだきちんと読めていないので,後日また書きます。
(※追記:http://project-asura.com/blog/archives/4271 に記事を書きました。)

参考文献

  • [Fernando 2005] Randima Fernando, “Percentage-Closer Soft Shadows”, SIGGRAPH 2005 Sketches, http://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf, https://http.download.nvidia.com/developer/presentations/2005/SIGGRAPH/Percentage_Closer_Soft_Shadows.pdf.
  • [MohammadBagher 2010] Mahdi MohammadBagher, Jan Kautz, Nicolas Holzschuch, Cyril Soler, “Screen-Space Percentage-Closer Soft Shadows”, ACM SIGGRAPH 2010 Posters, https://hal.inria.fr/inria-00536256/file/paper.pdf.
  • [Zheng 2011] Zhongxiang Zheng, Suguru Saito, “Screen Space Anisotropic Blurred Soft Shadows”, SIGGRAPH 2011 Posters, https://pdfs.semanticscholar.org/d320/4f7153bb23a8a7a56bbcb50a346013e8f524.pdf.
  • [Zheng 2014] Zhongxiang Zheng, Suguru Saito, “Efficient Screen Space Anisotropic Blurred Soft Shadows”, IEICE TRANSACTIONS on Information and Systems, Vol.E97-D No.8, pp.2038-2045.

夏休みの自由研究ネタ

Share

こんにちわ,Pocolです。
コロナウィルスで大変ですね。

さて…
たまには心穏やかに過ごしたいなとおもったので,心穏やかなうちに
甥っ子から夏休みの自由研究を相談された時のためのネタを今のうちに考えておこうと思いました。

まず,エンジニアをやっているオジサン(自分)は凄いと思わせるところを最初の目標としたいと思います。

まだ小学1年なので,デジタルな所に興味を頂かせるよりも物理的に動くものを見せる方が興味を持たせるきっかけとして良いかなと思いました。
Twitterみてたら,大分でホバークラフトが復活するとか見たので,
とりあえず最初はホーバークラフトを簡易に作ってみようかと思いました。
基本的な作り方がYouTubeに上がっていました。

…見ると分かるのですが,動きはするのですが制御が出来ていない様子です。
このあたりの改善案等をまとめて,研究とすればとりあえず乗り切れるのではないかと思いました。

あとこのようなネタを毎年考えないといけなくなる可能性があるので,
ネタが思いつかなかったとき用に今のところ考えているのは…

・AMラジオの各放送局は等差数列になっているという噂は本当か?
・花火の魅惑。炎色反応を実際に試してみよう!
・モーターを自作してみよう!
・なぜSuicaは電池無しで改札機と通信できるのか?交通系ICカードの仕組みを理解しよう!
・身近な物で電池を作ってみよう!
・ゲームに出る武器の名前はどの文化が由来?歴史を調べてみよう!

…とか。
化学系に手を出してみようかなと思ったけども
廃液の処理方法とか知らんし,一般人が買えない薬品とかは手に入れられないからそもそもがアウトだなと思った。
あとは,統計取って可視化するとかもありかなと思いました。

ちなみに,元上司はどんなことやっていたんだろう?って参考にブログ見てみたら

「太陽光で水をどれだけ熱くできるかを調べてみたい」
「夏休みの自由研究、超高火力練炭で身近な金属を溶かしてみよう!」
「Minecraft中毒の息子がゲームに登場する塊鉄炉(鉄鋼炉)を使った製鉄を実際にやってみたいとしつこく言ってくるので、黙らせるために何となくそれらしいことをしてみました。」

…さ、さっ,さすがと思うようなことをやっておられるようです。
ちょっと流石にそこまでロックなことは出来ないですね。
もし何か物理系を起点として工学系に誘導していくみたいな,良い夏休みの子供の自由研究ネタあれば教えて欲しいです。
そしてそれがゲーム開発とかゲームに興味を持つものであれば,尚更歓迎です。

よろしくお願い致します。

本の執筆状況。

Share

こんにちわ,Pocolです。
気になっている方もいるかと思うので,共有しておきましょう。

本の執筆状況ですが,昨年年始頃に仮脱稿を終えまして,出版に向け色々と調整を行っている状況です。
転職の兼ね合いもありまして,副業にあたる・あたらないとかの周りで少々問題がありましたが,それもクリア出来まして
皆様にきちんとしたものをお届けできるようクオリティアップの作業の真っ最中でございます。

来るべき時が来たら「本年度中に」アナウンス致しますので,もうしばらくお待ちいただければと思います。
豪華なレビューアによるきちんとした和書になると思いますので,是非ご期待頂ければと思います。

サンプルプログラムも公開予定ですが,こちらはVisual Studio 2019対応済みです。
公開方法は詳しく決まっておりませんが,サンプルプログラムと合わせて読むことで本が完結する作りになっておりますので,
ご購入前にはご検討頂ければと思います。
※プログラム行数がかなり多いため,書籍のページの都合上要所部分だけを抜粋して載せる仕組みになっていますので予めご注意してください。

また大事なことなのですが,自分は本の著者として実績が無いのと,専門書は部数が出ない。
…という背景事情がありまして,初版の冊数はそんなに多くありません。

そのため…
万一冊数が出てしまった場合は品薄のため買えないという状況が起こりえるのと,
逆に販売数が振るわなかった場合は,すぐに絶版になり二度と手に入れることが出来なくなる可能性がありえます。

特に前者よりも後者の絶版になる可能性の方が高いと思います。
確実に手に入れていただくためには,予約購入して頂くのが今のところ確実かと思います。
来るべきアナウンスがありましたら,早めにご決断して頂き,予約購入して頂くことをお勧めいたします。

ジオメトリシェーダでカリング

Share

Frostbiteの”Optimizing the Graphics Pipeline with Compute”を今まで見ていなかったので,資料見たら感銘を受けました。ただ,実際に実装するの怠いな~と思っていたのですが,ふと思い出して,ジオメトリシェーダでカリングを実装してみました。
下記のような感じ。

///////////////////////////////////////////////////////////////////////////////
// CbCulling constant buffer.
///////////////////////////////////////////////////////////////////////////////
cbuffer CbCulling : register(b0)
{
    float4 Viewport : packoffset(c0);  // xy:(width, height), zw:(topLeftX, topLeftY).
};

//-----------------------------------------------------------------------------
//      メインエントリーポイントです.
//------------------------------------------------------------------
[maxvertexcount(3)]
void main
(
    triangle VSOutput                   input[3], 
    inout  TriangleStream< VSOutput >   output
)
{
    // Orientation Culling.
    // ※ラスタライザーステートで時計回りを正とするため,マイナス倍になっていることに注意!
    float det = -determinant(float3x3(
        input[0].Position.xyw,
        input[1].Position.xyw,
        input[2].Position.xyw));
    if (det <= 0.0f)
    { return; }

    // 正規化デバイス座標系(NDC)に変換.
    float3 pos0 = input[0].Position.xyz / input[0].Position.w;
    float3 pos1 = input[1].Position.xyz / input[1].Position.w;
    float3 pos2 = input[2].Position.xyz / input[2].Position.w;

    // Frustum Culling.
    float3 mini = min(pos0, min(pos1, pos2));
    float3 maxi = max(pos0, max(pos1, pos2));
    if (any(maxi.xy < -1.0f) || any(mini.xy > 1.0f) || maxi.z < 0.0f || mini.z > 1.0f)
    { return; }

    // Small Primitive Culling.
    float2 vmin = mini.xy * Viewport.xy + Viewport.zw;
    float2 vmax = maxi.xy * Viewport.xy + Viewport.zw;
    if (any(round(vmin) == round(vmax)))
    { return; }

    // カリングを通過したものだけラスタライザーに流す.
    [unroll]
    for (uint i=0; i<3; i++)
    { output.Append(input[i]); }
}

RenderDocでキャプチャして,ジオメトリシェーダの出力をチェックしてみました。
正面から見た図。

回り込んで横から見た図。

一応ちゃんとカリングされているようです。
コンピュートシェーダで実装するのが怠い人は,プラットホームによっては全然違いますが,ジオメトリシェーダでパフォーマンス向上するモードがなにかしらあると思うので,そちらで動作させると幸せになれるかもしれません。

※ちなみに手元の環境で計測もしてみたんですが,あまり高速化は見られず処理が格段に重くなりました。環境やシーンにもよると思いますが、暴力的な数のポリゴン数が投入されるシーンでは使わない方が良さそうだなと実感しました。手元のシーンでやってみた感じだと,GSカリングを入れた処理が+3msほど重くなっていて,シーン全体で+12ms程度負荷が増えました。

ラスタライザーの効率性を測るシェーダ

Share

“Optimizing the Graphics Pipeline with Compute”を見ていたら,ラスタライザーの効率性を表示するピクセルシェーダが載っていたので,忘れないようにメモしておこうかと思います。

float3 main() : SV_TARGET0
{
    bool inside = false;
    float2 barycentric = fbGetBarycentricLinearCenter(); //__XB_GetBarycentricCoords_Linear_Center();

    if (barycentric.x >= 0 && barycentric.y >= 0 && barycentric.x + barycentric.y <= 1)
        inside = true;

    uint2 insideBallot = fbBallot(inside); //__XB_Ballot64();
    uint  insideCount  = countbits(insideBallot.x) + countbits(insideBallot.y);
    float insidePrecent = insideCount * (1.0 / 64.0);
    return float3(1 - insidePercent, insidePercent, 0);
}

合っているどうか全くわからないけども,Shader Model 6.0以降で書くと次のような感じ???
SM6.0全然弄ってないから分からん。

float3 main(linear float3 barycentric : SV_Barycentrics) : SV_TARGET0
{
    bool inside = false;
    if (baryenctric.x >= 0 && barycentric.y >= 0 && barycentric.x + barycentric.y <= 1)
        inside = true;

    uint  insideCount  = WaveActiveCountBits(inside);
    float insidePercent = insideCount * (1.0 / 64.0);
    return float3(1 - insidePercent, insidePercent, 0);
}

たぶん,間違っていると思うので誰か正しいコード教えてください。

ビジビリティカリング メモ(3)

Share

こんちわ、Pocolです。
前回までで,ビジビリティカリングの説明が終わりました。
こんなに素晴らしいカリング手法があるのなら,積極的に使いたいと思いました。
で,ふと思いついたのが,ビジビリティカリングをシャドウキャスターのカリングに使おうという単純なアイデアです。

参考文献

[3] Oliver Mattausch, Jiri Bittner, Ari Silvennoinen, Daniel Scherzer, and Micheal Wimmer, “Efficient Online Visibility for Shadow Maps”, GPU Pro 3, pp.233-242, CRC Express, 2012.
[4] Jiri Bittner, Oliver Mattausch, Airi Silvennoinen, Micheal Wimmer, “Shadow Caster Culling for Efficient Shadow Mapping”, I3D’11: Symposium on Interactive 3D Graphics and Games, February 2011, pp.81-88.

実装アイデア

実装アイデアはいたって単純です。
[3]の文献で使用されているハードウェアオクルージョンカリングの代わりにカリング手法をビジビリティカリングに置き換えるだけのものです。

まず,以下を前提とします。
・カメラビューからみた深度バッファがある。
・シャドウマップ行列がすでに求まっている。

[3]の手法をかなり雑に説明すると,
欲しい情報は「カメラビューから見た時のピクセルにシャドウが落ちるかどうか?」であるので,カメラビューのピクセルにシャドウを落とさないものは,そもそも寄与しないからカリングしてよくね?というアイデアです。

実装のためのメモ

ここからは自分なりの実装前の実装するためのメモです。
[3]の手法を実装するために,マスクが必要なります。

まずは,Pre-Zなりで作成されたカメラビューから見た深度バッファを用いて,現在フレームのワールド空間位置座標に変換し点群を求めます。
次に,この求まったワールド空間位置座標にシャドウマップ行列を適用し,ライトビュー空間位置座標に変換します。
これでライトビュー空間からみたときのラスタライズ位置が求まるはずです。
あとはUAV等に,このラスタイズ位置に「カメラから見える」としてフラグを書き込みます。
これでマスクが完成です。

次に,False Negative Passと同じ要領でライトビューからOBBを描画し,マスクバッファ上のフラグが立っている場所にシャドウをキャストするものだけをビジビリティバッファ上に「見える」としてマークします。
これで「カメラビューに寄与するシャドウキャスターのみ」のビジビリティバッファが完成します。

あとはインダイレクトドロー情報を生成して,ExecuteIndirect()で描画すればOKのはず…。

オプションとして,Mainパスに当たる部分のように前フレームのシャドウマップバッファを用いて,粗くカリングしておくという手も考えられます。…あんまり高速化期待できないかもしれませんが。

ビジビリティカリング メモ(2)

Share

前回の続きです。
今回はFalse Negative Passの説明。

False Negative Pass

Main Passでは前フレームの深度バッファをダウンサンプルして,現在フレームの深度バッファをでっちあげカリング処理を行いました。
当然ながら,適当にでっちあげた深度バッファだと誤判定される可能性があります。
そこで,False Negative Passで誤判定されたものを取りこぼしが無いようにちゃんと判定しようというわけです。

このパスでは,フル解像度の深度バッファを用いて描画を行います。
文献[1]には,理論的にはコンサバティブラスタライゼーションを用いれば,この第2オクルージョンパスでも1/4解像度の深度バッファが使えると書いてあるのですが,いくつかの問題があったためフル解像度を使っているようです。

ビジビリティバッファのクリア

False Negative Passの最初の処理はビジビリティバッファのクリアです。
これはメインパスと同じようにゼロクリアすれば良いようです。

ビジビリティバッファを埋める

メインオクル―ジョンパスのように,Early-DepthStencilテストを通過したピクセルをビジビリティバッファ中にVisibleとしてピクセルシェーダで各インスタンスをマークします。問題のあるオブジェクトすべては最初のパスでどっちみち描画されるので,カメラのニア平面背後にあるバウンディングボックスに対してクランプするコードの実行は不要になります。

インダイレクトドロー情報を生成する

インダイレクトドロー情報はメインパス中で生成されます。
このときには可視できるインスタンスドロー情報のみが生成されているので,遮蔽されたインスタンスに対する処理は不要になります。

インダイレクトにFlase Negativeを描画する

メインパスと同じように描画します。

ここまでは,文献[2]でも説明されているので,特に新しいことはありません。
ここから先はアイデア段階のものです。
…それは次回に説明します。

ビジビリティカリング メモ(1)

Share

新年明けましておめでとうございます。
本年もProjectAsuraをよろしくお願い致します。

さて,今日はビジビリティバッファを用いたカリングを行うための自分用の実装メモを残しておこうと思います。
あくまでも自分用なので間違っているかもしれません。また実装結果ではなく,これから実装するためのメモなので全然当てにならない可能性もあるので注意してください。

参考文献

[1] Hawar Doghramachi and Jean-Normand Bucchi, “Deferred+: Next-Gen Culling and Rendering for Dawn Engine”, GPU Pro, pp.77-104, Black Cat Publishing 2017.
[2] 三嶋 仁, “最新タイトルのグラフィックス最適化事例”, https://cedil.cesa.or.jp/cedil_sessions/view/1897, CEDEC 2017.

概要

ここでは,[1]の文献をベースに纏めて置きます。
基本的に2パスで処理を行います。まず1パス目がMain Passと呼ばれるものです。
前フレームの深度バッファを1/4解像度にダウンサンプリングして,現フレームのビュー射影行列を用いて,現在フレームにおおまかに一致する深度バッファを作成しインダイレクトドローを用いて可視と判定されたメッシュのみを描画します。
2パス目はFalse Negative Passと呼ばれるもので,フル解像度でMain PassでOccludeと判定されたOBBを描画し,インダイレクトドローを用いて,可視と判定されたメッシュのみを描画します。

Main Passの処理概要は次です。

  • ① 深度バッファのダウンサンプリングとリプロジェクション
  • ② ビジビリティバッファのクリア
  • ③ ビジビリティバッファを埋める
  • ④ インダイレクトドロー情報を生成する
  • ⑤ 可視メッシュをインダイレクトに描画する

False Negative Passの処理概要は次になります。

  • ① ビジビリティバッファのクリア
  • ② ビジビリティバッファを埋める
  • ③ インダイレクトドロー情報を生成する
  • ④ 可視メッシュをインダイレクトに描画する

以下のデータが多分必要。

  • 前フレームの深度バッファ
  • 雑な現フレームの深度バッファ(Computeから深度書き込みできないらしいので,Color → Depthの変換が必要らしい…)
  • ビジビリティバッファ(uint32_tのUAV) 
  • インスタンス変換用のバッファ(たぶん,ワールド行列。これをつかってGPU上でOBBにする)
  • メッシュ情報
  • インダイレクトドロー引数用のバッファ(可視メッシュ描画用)
  • 可視インスタンスのインデックスバッファ
  • Occludeされたインスタンスのインデックスバッファ

上記のうち,[1]によると…
<CPU更新>

  • メッシュ情報
  • インスタンス変換用のバッファ

<GPU更新>

  • ビジビリティバッファ
  • 可視インスタンスのインデックスバッファ
  • Occludeされたインスタンスのインデックスバッファ
  • インダイレクトドロー引数用のバッファの

とのこと。

実装詳細

Main Pass

深度バッファのダウンサンプリングとリプロジェクション

前フレームの深度バッファを用いて4×4ピクセルの最大値をコンサバティブにとることにより1/4解像度にダウンサンプリングし,前のフレームのワールド位置座標を復元します。
復元した,ワールド位置座標に現フレームのビュー射影行列を掛けて,w除算することにより現フレームの雑な深度情報を生成しておきます。大きな深度値を避けるためにカメラよりも後ろの深度値を出力することを抑制します。
疑似コードは次のようになります。

// 4要素のうちの最大値を求める.
float Max4(float4 value)
{ return max( max( max(value.x, value.y), value.z ), value.w ); }

[numthreads(REPROJECT_THREAD_GROUP_SIZE, REPROJECT_THREAD_GROUP_SIZEk, 1)]
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
     if ((dispatchThreadID.x < (uint(SCREEN_WIDTH)/4)) 
      && (dispatchThreadID.y < (uint(SCREEN_HEIGHT)/4)))
    {
        const float2 screenSize = float2(SCREEN_WIDTH/4.0f, SCREEN_HEIGHT/4.0f);
        float2 texCoords = (float2(dispatchThreadID.xy) + float2(0.5f, 0.5f)) / screenSize;

        const float offsetX = 1.0f/SCREEN_WIDTH;
        const float offsetY = 1.0f/SCREEN_HEIGHT;

        // 深度のダウンサンプル
        float4 depthValues00 = depthMap.GatherRed(depthMapSampler, texCoords + float2(-offsetX, -offsetY));
        float depth = Max4(depthValues00);

        float4 depthValues10 = depthMap.GatherRed(depthMapSampler, texCoords + float2(offsetX, -offsetY));
        depth = max( Max4(depthValues10), depth);

        float4 depthValues01 = depthMap.GatherRed(depthMapSampler, texCoords + float2(-offsetX, offsetY));
        depth = max( Max4(depthValues01), depth);

        float4 depthvalues11 = depthMap.GatherRed(depthMapSampler, texCoords + flaot2(offsetX, offsetY));
        depth = max( Max4(depthValues11), depth);

        // ダウンサンプルした深度から前フレームのワールド空間位置座標を再構築する.
        float4 lastProjPosition = float4(texCoord, depth, 1.0f);
        lastProjPosition.xy = (lastProjPosition.xy * 2.0f) - 1.0f;
        lastProjPosition.y = -lastProjPosition.y;
        float4 position = mul(cameraCB.lastInvViewProjMatrix, lastProjPosition);
        position /= position.w;

        // 現フレームの射影空間位置座標を計算する.
        float4 projPosition = mul(cameraCB.viewProjMatrix, position);
        projPosition.xyz /= projPosition.w;
        projPosition.y = -projPosition.y;
        projPosition.xy = (porjPosition.xy * 0.5f) + 0.5f;
        int2 outputPos = int2(saturate(projPosition.xy) * screenSize);

        // カメラ背後の大きな深度値を避ける.
        float depthF = (projPosition.w < 0.0f) ? depth : projPosition.z;

        // atomic max操作のための深度変換.
        // バインドされたカラーバッファはゼロで初期化されるため,最初に深度を反転し,atomic max操作を行い,
        // 最終的な深度バッファにコピーする際に深度を戻す.
        uint invDepth = asuint(saturate(1.0f - depthF));

        // 新しい位置にリプロジェクションされた深度を書き込む.
        InterlockedMax(depthTexture[outputPos], invDepth);

        // リプロジェクションによる穴あきを処理するために現在位置にリプロジェクションされた深度を書き込む.
        InterlockedMax(depthTexture[dispatchThreaID.xy], invDepth);
    }
}

ビジビリティバッファのクリア

ゼロクリアする。
DirectX12のUAVクリアAPIか,コンピュートシェーダでゼロクリアする。

ビジビリティバッファを埋める

前フレームの深度バッファから作成された現フレームの深度バッファをバインドして,ピクセルシェーダで[earlydepthstencil]付きでOBBを描画し,可視メッシュのフラグを立てる。
疑似コードは次のようになります。

// 頂点シェーダ
VS_Output main(uint vertexId : SV_VertexID, uint instanceID : SV_InstanceID)
{
    VS_Output output;

    output.occludedID = instanceID;

    // 単位キューブの位置を生成する.
    float3 position = float3(((vertexID && 0x4) == 0) ? -1.0f : 1.0f,
                             ((vertexID && 0x2) == 0) ? -1.0f : 1.0f,
                             ((vertexID && 0x1) == 0) ? -1.0f : 1.0f);

    matrix instanceMatrix = instanceBuffer[output.occludedID];
    float4 positionWS = mul(instanceMatrix, float4(position, 1.0f));
    output.position   = mul(cameraCB.viewProjMatrix, positionWS);

    // カメラがバウンディングボックスの中の場合,オブジェクト自身が可視であっても完全に遮蔽される.
    // そのため,バウンディングボックスの頂点がニア平面の後ろであるものは,そのようなオブジェクトカリングを避けるために
    // ニア平面の前にクランプされる.
    output.position = (output.position.w < 0.0f) 
                      ? float4(clamp(output.position.xy, float(-0.999f).xx, float(0.999f).xx), 0.0001f, 1.0f)
                      : output.position;

    return output;
}

// ピクセルシェーダ
[earlydepthstencil]
void main(VS_Output input)
{
    visBuffer[input.occludedID] = 1;
}

インダイレクトドロー引数の生成

コンピュートシェーダ上でインダイレクトドロー引数を生成する。
疑似コードは次のようになります。

#define THREAD_GROUP_SIZE 64

groupshared uint visibileInstanceIndexCounter;

[numthread(THREAD_GROUP_SIZE, 1, 1)]
void main
(
    uint3 groupID          : SV_GroupID,
    uint  groupIndex       : SV_GroupIndex,
    uint3 dispatchThreadID : SV_DispatchThreadID
)
{
    if (groupIndex == 0)
    {
        visibleInstanceIndexCounter = 0;
    }
    GroupMemoryBarrierWithGroupSync();

    MeshInfo meshInfo = meshInfoBuffer[groupID.x];
    for(uint i=0; i<meshInfo.numInstances; i+=THREAD_GROUP_SIZE)
    {
        uint elementIndex = groupIndex + i;
        if (elementIndex < meshInfo.numInstances)
        {
            uint instanceIndex = mesh.instanceOffset + elementIndex;

            // 可視の場合.
            if (visBuffer[instanceIndex] > 0)
            {
                uint index;
                InterlockedAdd(visibleInstanceIndexCounter, 1, index);
                visibleInstanceIndexBuffer[meshInfo.instanceOffset + indexs + NUM_FILL_PASS_TYPE] = instanceIndex;
            }
            else
            {
                uint index;
                InterlockedAdd(drawIndirectBuffer[0].instanceCount, 1, index);
                occludedInstanceIndexBuffer[index] = instanceIndex;
            }
        }
    }
    GroupMemoryBarrierWithGroupSync();

    if (groupIndex == 0)
    {
        if (visibleInstanceIndexCount > 0)
        {
            // 可視メッシュのカウンターをインクリメントする.
            uint cmdIndex;
            InterlockedAdd(visibleInstanceIndexBuffer[meshInfo.meshType], 1, cmdIndex);
            cmdIndex += meshInfo.meshTypeOffset + 1;

            // G-Bufferに可視メッシュを描画する.
            DrawIndirectCmd cmd;
            cmd.instanceOffset        = meshInfo.instanceOffset;
            cmd.MaterialId            = meshInfo.materialId;
            cmd.indexCountPerInstance = meshInfo.numIndices;
            cmd.instanceCount         = visibleMeshInstanceIndexCounter;
            cmd.startIndexLocation    = meshInfo.firstIndex;
            cmd.baseVertexLocation    = 0;
            cmd.startInstanceLocation = 0;
            drawIndirectBuffer[cmdIndex] = cmd;
        }
    }
}

可視メッシュを描画する

メッシュタイプごとにExecuteIndirectコマンドを発行して,描画します。
疑似コードはつぎのような感じ。

VS_Output main(VS_Input input, uint instanceID : SV_InstanceID)
{
    VS_Output output;
    uint instanceIndex = instanceInfoCB.instanceOffset + instanceID+

    instanceIndex = visibleInstanceIndexBuffer[instanceIndex + NUM_MESH_TYPES];

    matrix transformMatrix = instanceBuffer[instanceIndex].transformMatrix;

    ...
}

長くなったので,続きのFalse Negative Passは次回書きます。

Pre-Integrated SkinのLUTを作ってみた。

Share

こんにちわ。
Pocolです。

会社でキャラの肌が調整しずらいからどうにかしてくれ!
…と,言われてしまったので,とりあえずPre-Integrated Skinを調べてみようと思いました。

LUTテーブル作るところまでは実装してみました。
出来上がったLUTのテクスチャはこちら。

で,これを作るソースコードは以下のような感じ。


#define STBI_MSC_SECURE_CRT
#define STB_IMAGE_WRITE_IMPLEMENTATION

//-----------------------------------------------------------------------------
// Includes
//-----------------------------------------------------------------------------
#include <cstdio>
#include <stb_image_write.h>
#include <vector>
#include <asdxMath.h>

//-----------------------------------------------------------------------------
//      リニアからSRGBに変換.
//-----------------------------------------------------------------------------
inline float ToSRGB(float value)
{
    return (value <= 0.0031308) 
        ? 12.92f * value 
        : (1.0f + 0.055f) * pow(abs(value), 1.0f / 2.4f) - 0.055f;
}

//-----------------------------------------------------------------------------
//      ガウス分布.
//-----------------------------------------------------------------------------
inline float Gaussian(float v, float r)
{ return 1.0f / sqrtf(2.0f * asdx::F_PI * v) * exp(-(r * r) / (2.0f * v)); }

//-----------------------------------------------------------------------------
//      散乱計算
//-----------------------------------------------------------------------------
inline asdx::Vector3 Scatter(float r)
{
    // GPU Pro 360 Guide to Rendering, "5. Pre-Integrated Skin Shading", Appendix A.
    return 
          Gaussian(0.0064f * 1.414f, r) * asdx::Vector3(0.233f, 0.455f, 0.649f)
        + Gaussian(0.0484f * 1.414f, r) * asdx::Vector3(0.100f, 0.336f, 0.344f)
        + Gaussian(0.1870f * 1.414f, r) * asdx::Vector3(0.118f, 0.198f, 0.000f)
        + Gaussian(0.5670f * 1.414f, r) * asdx::Vector3(0.113f, 0.007f, 0.007f)
        + Gaussian(1.9900f * 1.414f, r) * asdx::Vector3(0.358f, 0.004f, 0.00001f)
        + Gaussian(7.4100f * 1.414f, r) * asdx::Vector3(0.078f, 0.00001f, 0.00001f);
}

//-------------------------------------------------------------------------------
//      Diffusionプロファイルを求める.
//-------------------------------------------------------------------------------
asdx::Vector3 IntegrateDiffuseScatterOnRing(float cosTheta, float skinRadius, int sampleCount)
{
    auto theta = acosf(cosTheta);
    asdx::Vector3 totalWeight(0.0f, 0.0f, 0.0f);
    asdx::Vector3 totalLight (0.0f, 0.0f, 0.0f);

    const auto inc = asdx::F_PI / float(sampleCount);
    auto a = -asdx::F_PIDIV2;

    while(a <= asdx::F_PIDIV2)
    {
        auto sampleAngle = theta + a;
        auto diffuse     = asdx::Saturate(cosf(sampleAngle));
        auto sampleDist  = abs(2.0f * skinRadius * sinf(a * 0.5f));
        auto weights     = Scatter(sampleDist);

        totalWeight += weights;
        totalLight  += weights * diffuse;
        a += inc;
    }

    return asdx::Vector3(
        totalLight.x / totalWeight.x,
        totalLight.y / totalWeight.y,
        totalLight.z / totalWeight.z);
}

//-----------------------------------------------------------------------------
//      ルックアップテーブル書き出し.
//-----------------------------------------------------------------------------
bool WriteLUT(const char* path, int w, int h, int s)
{
    std::vector<uint8_t> pixels;
    pixels.resize(w * h * 3);

    float stepR = 1.0f / float(h);
    float stepT = 2.0f / float(w);

    for(auto j=0; j<h; ++j)
    {
        for(auto i=0; i<w; ++i)
        {
            auto radius    = float(j + 0.5f) * stepR;
            auto curvature = 1.0f / radius;
            auto cosTheta  = -1.0f + float(i) * stepT;
            auto val       = IntegrateDiffuseScatterOnRing(cosTheta, curvature, s);

            // 書き出しを考慮して, 上下逆にして正しく出力されるようにする.
            auto idx = ((h - 1 - j) * w * 3) + (i * 3);

            pixels[idx + 0] = static_cast<uint8_t>(ToSRGB(val.x) * 255.0f + 0.5f);
            pixels[idx + 1] = static_cast<uint8_t>(ToSRGB(val.y) * 255.0f + 0.5f);
            pixels[idx + 2] = static_cast<uint8_t>(ToSRGB(val.z) * 255.0f + 0.5f);
        }
    }

    auto ret = stbi_write_tga(path, w, h, 3, pixels.data()) != 0;
    pixels.clear();

    return ret;
}

//-----------------------------------------------------------------------------
//      メインエントリーポイントです.
//-----------------------------------------------------------------------------
int main(int argc, char** argv)
{
    std::string path = "preintegrated_skin_lut.tga";
    int w = 256;
    int h = 256;
    int s = 4096;

    for(auto i=0; i<argc; ++i)
    {
        if (_stricmp(argv[i], "-w") == 0)
        {
            i++;
            auto iw = atoi(argv[i]);
            w = asdx::Clamp(iw, 1, 8192);
        }
        else if (_stricmp(argv[i], "-h") == 0)
        {
            i++;
            auto ih = atoi(argv[i]);
            h = asdx::Clamp(ih, 1, 8192);
        }
        else if (_stricmp(argv[i], "-s") == 0)
        {
            i++;
            auto is = atoi(argv[i]);
            s = asdx::Max(is, 1);
        }
    }

    return WriteLUT(path.c_str(), w, h, s) ? 0 : -1;
}

ちょっとググって調べた感じだと,https://j1jeong.wordpress.com/2018/06/20/pre-intergrated-skin-shading-in-colorspace/というBlog記事をみて,検証もせずにとりあえず鵜呑みで,sRGBで書き出してみました。
シェーダで使う場合は補正入れてリニアになるようにしてから使ってください。