FalcorのPathTraceサンプルをNSightGraphicsを使って確認して、最適化してみる

NVIDIAの軽量グラフィックスフレームワークのFalcorに付属する、PathTraceサンプルをNSightを使って観察してみたいと思います。ついでに、簡単な最適化を施してみたいとおもいます。

参照

Falcor 3.2.1
https://github.com/NVIDIAGameWorks/Falcor

NSight 2019.2
https://developer.nvidia.com/gameworksdownload

レンダリングパスの確認

FalcorのリポジトリをCloneしてビルドするとPathTracerというサンプルがビルドされるので、NSightのFrame Debuggerをアタッチして実行します。
NSightのFrame Debuggerメニューより、Capture for Live Analysis を実行して、フレームキャプチャを実行します。それでは、レンダリングパスの概要を確認したいと思います。

GbufferのRendering
まず初めにGBufferを描画しています。8枚のFP32のRender TargetがBindされているのがわかります。さすがサンプルコードです。

Acceleration StructureのBuild
次にASのBuildを行っています。NSightのAcceleration Structure Viewを使うと、視覚的にASの内容が確認できます。また、各種フラグ(OpaqueやCCWなど)でマスクして確認することができるので、非常に便利です。

DispatchRay
次にこのサンプルのレンダリング時間の半分以上を占有しているDispatchRayがの処理があります。これに関しては次項で詳しく見てみたいを思います。

Pixel Accumulation
このサンプルコードは、1pixel当たり、1つのIndirect Pathをたどりつつ、NEEで光源をサンプリングしています。したがって、1フレーム単体のレンダリングはNoisyなものとなるので、Pixel Accumulationを行っています。

Tonemappnig
このサンプルコードはHDRでレンダリングしているので、最後にTonemappingを適用しています。

大まかなレンダリングの流れは以上となります。

DispatchRayをNSightを通じて見てみる

まず、ScrubberViewで一番処理時間の長いEventを選択します。

すると、API Inspector ViewでDispatchRayのEventが確認できます。まず、GBufferの解像度のDimensionでDispatchが呼ばれているのが見て取れます。

次にRay Generationタブで、RayGenerationシェーダーが確認できます。クリックすると、Document Viewでシェーダーのソースコードが確認できます。ソースコードはslangをHLSLにコンパイルしているため、多少冗長になっていますが、可読性は問題ないです。早速RGシェーダーの処理を確認します。

[shader(“raygeneration”)]void GGXGlobalIllumRayGen()

  • スレッドの該当ピクセルの、GBufferを読み出し。
  • GBufferに有効なジオメトリがレンダリングされているかを確認。
  • GBufferのピクセル位置を起点に、光源のリストの中からランダムに一つ光源を選択し、ShadowRayを飛ばす。
    (GBufferの、直接光LightingとShadowingに相当)
  • GBufferのピクセル位置を起点にIndirectRayを飛ばす。
    (間接光のパストレースに相当)
  • 結果を格納

大まかにはこのような流れになっています。非常にシンプルです。TraceRay()を呼び出している個所は、直接光と間接光で計2か所あります。それでは、直接光の処理の関数であるggxDirect_0()を少し詳しく見てみます。

ggxDirect_0()

  • 乱数を引いて、シーンに配置された光源をランダムに選択し、光源情報を読み出す。
  • 読みだした光源を用いてマテリアルを評価。(シェーディング)
  • 光源に向かってshootShadowRay_0()を呼び出し、結果をシェーディング結果に乗算。

次にggxDirect_0()より呼ばれている、shootShadowRay_0()を確認します。

shootShadowRay_0()

  • ShadowRay用のTraceRay()呼び出し。

shootShadowRay_0()はTraceRay()呼び出す際に、RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH_0 | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER_0を設定しているので、このRayのClosestHitシェーダーはnullと思われます。そのため、MissShaderがPayloadを更新すると思われます。
TraceRayの引数の、MultiplierForGeometryContributionToShaderIndexにHitprogramCountという値を設定しているのが読み取れます。このことより、BLASのシステム定義Index*Multiplierが、BLASのIndexの実効Strideとなるので、使用しているShader Tableは、以下のような並び方をしているのが予想できます。HitProgramCountは、シーン全体のHit Groupの数ではなく、Rayの種類に相当する値と思われます。


[HitGroup of BLAS index:0 for shadow ray][HitGopup of BLAS index:0 for other kinds of a ray]...
[HitGroup of BLAS index:1 for shadow ray][HitGopup of BLAS index:1 for other kinds of a ray]...
...
...

それでは、NSightのAPI Inspector Viewで、HitTableのIndex:0に設定されているShaderを確認してみます。Index:0はShadowRayなので、何もしてないと思われたのですが、AnyHitが設定されています。中を見たら、AlphaTestの判定をしています。アルファ付きのテクスチャを参照していそうなオブジェクトは、シーン内に無いよう見えたのですが、とにかく設定されているようです。

続けて、MissTableのIndex:0を確認します。すると、ShadowMissという名前のShaderが確認できます。

[shader(“miss”)]void ShadowMiss(inout ShadowRayPayload_0 rayData_0)

  • PayloadのvisFactorを1.0に更新。

つまり、RayがMissすれば、光源までRayが届いたことを意味するので、光源とSurfaceはVisibleとなります。

以上が直接光の処理となっています。一言でいえば、GBufferからNEEで光源にShadowRayを飛ばしているわけです。
次に間接光の処理であるggxIndirect_0()を少し詳しく見てみます。

ggxIndirect_0()

  • diffuseとspecularのパラメータをチェックして、DiffuseRayを飛ばすかSpecularRayを飛ばすかを乱数で選択。
  • DiffuseRayはCosineWeightedなDiffuseRayを乱数で選択。
  • shootIndirectRay_0()を呼び出す。
  • Cosは乗算されているので、そのままマテリアルのDiffuseと乗算して、Diffuse/Specular選択の確率の逆数を乗算。
  • SpecularRayは、GGXのNDFをマテリアルのパラメーターに基づいて、乱数で選択した後、ViewVectorをReflectionして、Rayの方向を決定。
  • shootIndirectRay_0()を呼び出す。
  • GGX(Smith)を評価してIndirectRayをシェーディング。NDFの確率と、Diffuse/SpecularRay選択確率の逆数を乗算。

次に、ggxIndirect_0()より呼び出されている、shootIndirectRay_0()を見てみます。

shootIndirectRay_0()

  • IndirectRay用のTraceRay()呼び出し。

IndirectRayのPayloadを確認すると、以下のようになっています。

struct IndirectRayPayload_0
{
vector color_1;
uint rndSeed_1;
uint rayDepth_0;
};

Payloadに乱数シードをストアしてるので、ShadowRayと異なり、何か処理をする可能性が伺えます。またRayDepthもストアしているので、TraceRay()の再帰呼び出しがある可能性が高いです。IndirectRayでは、TraceRay呼び出しのHitGroupOffsetは1が設定されているので、ShadowRayとは異なるHitGroupを使用します。

それは、API Inspector Viewで、HitTableのIndex:1に設定されているシェーダーを確認します。こちらはAnyHitとClosestHitが設定されています。AnyHitはShadowRayと同様のAlphaTestです。ClosestHitはIndirectClosestHitという名前で定義されています。シェーダーコードを確認したいと思います。

[shader(“closesthit”)]void IndirectClosestHit(inout IndirectRayPayload_0 rayData_0, BuiltInTriangleIntersectionAttributes attribs_1)

  • PrimitiveIndexとAttribute(barycentrics)から、補完されたVertexAttributeを計算。
  • ShaderTableに設定されたLocalRootTableより、マテリアルとテクスチャを参照して、HitしたSurfaceのマテリアル情報を集める。
  • RayGenシェーダーがGbufferに対して行ったものと同様の直接光処理(ggxDirect_0())を呼び出す。
    -> ShadowRayのTraceRay()が呼び出される
  • PayloadのrayDepth_0が、MaxDepth以下ならば、RayGenシェーダーがGbufferに対して行ったものと同様の間接光処理(ggxIndirect_0())を呼び出す。
    -> IndirectRayのTraceRay()が呼び出される

このような処理になっています。直接光の評価でTraceRay()を再帰呼び出ししています。その処理の後、間接光パスの評価のため、再びTraceRay()を再帰呼び出ししています。直接光の処理は、その中でTraceRay()の再帰呼び出しがありませんが、間接光パスは、RayDepthがMaxDepthに到達せずに、RayがHitし続ける限り再帰的に呼び出されるようになっています。

最後に、MissTableのIndex:1をチェックします。これは、IndirectRayのMissShaderです。

[shader(“miss”)]void IndirectMiss(inout IndirectRayPayload_0 rayData_0)

  • EnvMapをRayのEquirectangularで参照して、Payloadに格納

EnvMapはMissShaderのLocalRootSignatureに定義されているかと思いましたが、実際は、RootSignatureに設定されていました。このような確認もAPI Inspector ViewのRootParametersで行うことができます。

これで、DispatchRayによる、シェーダーの呼び出しの構造が確認できました。Shaderの実装は、かなりStraightforwardな実装だと思います。このサンプルではMaxDepthは2に設定されているので、RayがHitし続けた場合、Gbuff NEE, GBuffer Indirect, B1 NEE, B1 Indirect, B2 NEEと、
1Pixel当たり5回のTraceRay()呼び出しが行われることになると思います。

最適化について

せっかくシェーダーを確認したので、最適化について考えてみたいと思います。
まず、ggxIndirect()の処理で、DiffuseRayとSpecularRayの分岐があり、どちらか一方を確率的に選択して、別々にTraceRay()を呼び出しているところがあるのですが、Diffuse,Specularどちらを選択した場合も、IndirectRayをキャストして、評価したマテリアルと選択確率の逆数を乗算しているので、このTraceRay()は1箇所にまとめることができます。
具体的には以下の通りです。

オリジナル
// If we randomly selected to sample our diffuse lobe
if (chooseDiffuse)
{
// Shoot a randomly selected cosine-sampled diffuse ray.
float3 L = getCosHemisphereSample(randVal, sd.N, getPerpendicularStark(sd.N));
float3 bounceColor = shootIndirectRay(sd.posW, L, gMinT, 0, rndSeed, rayDepth);

// Check to make sure our randomly selected, normal mapped diffuse ray didn't go below the surface.
if (dot(geomN, L) <= 0.0f) bounceColor = float3(0, 0, 0);

// Accumulate the color: (NdotL * incomingLight * diff / pi)
// Probability of sampling: (NdotL / pi) * probDiffuse
return bounceColor * sd.diffuse / probDiffuse;
}
// Otherwise we randomly selected to sample our GGX lobe
else
{
// Randomly sample the NDF to get a microfacet in our BRDF to reflect off of
float3 H = getGGXMicrofacet(randVal, sd.N, sd.roughness);

// Compute the outgoing direction based on this (perfectly reflective) microfacet
float3 L = reflect(-sd.V, H);

// Compute our color by tracing a ray in this direction
float3 bounceColor = shootIndirectRay(sd.posW, L, gMinT, 0, rndSeed, rayDepth);

// Check to make sure our randomly selected, normal mapped diffuse ray didn't go below the surface.
if (dot(geomN, L) <= 0.0f) bounceColor = float3(0, 0, 0);

// Compute some dot products needed for shading
float NdotL = saturate(dot(sd.N, L));
float NdotH = saturate(dot(sd.N, H));
float LdotH = saturate(dot(L, H));

// Cannot use evalSpecularBrdf() because we need the D term below
float D = evalGGX(sd.roughness, NdotH) * M_INV_PI; // Our GGX function does not include division by PI
float G = evalSmithGGX(NdotL, NdotV, sd.roughness); // Includes division by 4 * NdotL * NdotV
float3 F = fresnelSchlick(sd.specular, 1, max(0, LdotH));
float3 brdf = D * G * F;

// Probability of sampling vector H from getGGXMicrofacet()
float ggxProb = D * NdotH / (4 * LdotH);

// Accumulate the color: ggx-BRDF * incomingLight * NdotL / probability-of-sampling
return NdotL * bounceColor * brdf / (ggxProb * (1.0f - probDiffuse));
}

変更後
float3 L, contrib;

// If we randomly selected to sample our diffuse lobe
if (chooseDiffuse)
{
// Shoot a randomly selected cosine-sampled diffuse ray.
L = getCosHemisphereSample(randVal, sd.N, getPerpendicularStark(sd.N));

// Accumulate the color: (NdotL * incomingLight * diff / pi)
// Probability of sampling: (NdotL / pi) * probDiffuse
contrib = sd.diffuse / probDiffuse;
}
// Otherwise we randomly selected to sample our GGX lobe
else
{
// Randomly sample the NDF to get a microfacet in our BRDF to reflect off of
float3 H = getGGXMicrofacet(randVal, sd.N, sd.roughness);

// Compute the outgoing direction based on this (perfectly reflective) microfacet
L = reflect(-sd.V, H);

// Compute some dot products needed for shading
float NdotL = saturate(dot(sd.N, L));
float NdotH = saturate(dot(sd.N, H));
float LdotH = saturate(dot(L, H));

// Cannot use evalSpecularBrdf() because we need the D term below
float D = evalGGX(sd.roughness, NdotH) * M_INV_PI; // Our GGX function does not include division by PI
float G = evalSmithGGX(NdotL, NdotV, sd.roughness); // Includes division by 4 * NdotL * NdotV
float3 F = fresnelSchlick(sd.specular, 1, max(0, LdotH));
float3 brdf = D * G * F;

// Probability of sampling vector H from getGGXMicrofacet()
float ggxProb = D * NdotH / (4 * LdotH);

// Accumulate the color: ggx-BRDF * incomingLight * NdotL / probability-of-sampling
contrib = NdotL * brdf / (ggxProb * (1.0f - probDiffuse));
}

// Check to make sure our randomly selected, normal mapped ray didn't go below the surface.
if (dot(geomN, L) <= 0.0f)
return float3(0.0);

float3 bounceColor = shootIndirectRay(sd.posW, L, gMinT, 0, rndSeed, rayDepth);
return contrib * bounceColor;

この変更を適用して、私のマシン上でFrameTimeを比較したところ、11.5ms -> 10.7 msと、10%弱速くなりました。

 次に、DispatchRay()から呼び出されるさまざまなShaderの中で、どれが一番処理時間に影響があるかを調べます。Gbufferからの直接光シェーディングの処理は、0.3ms程度の処理時間であるのに対して、間接光処理は、RayのMaxDepthを1にしても、4.4ms程度の処理負荷となっていました。IndirectRayはClosestHitでShadowRayの再帰呼び出しをしていますが、その分を差し引いたとしても、IndirectRay自身の処理負荷が高いことがわかります。IndirectRayのClosestHitには、prepareShadingData()という、ShaderTableに設定されたマテリアルとテクスチャの情報を読み出す部分がるのですが、この処理をコメントアウトすると、4.4msから0.8msまで、処理負荷が下がります。ShadowRayの分を差し引けば、0.5msとなります。つまり、一番処理時間がかかっている部分は、ShaderTableの参照と、Textureの参照ということがわかります。

 この先は、レンダリング結果が変わるので、最適化というよりは、処理の省略を行います。IndirectRayをTraceしている時の、マテリアル参照と評価を単純化したいと思います。具体的には、2次バウンス以降でNormalMapのサンプリングをせずに、GeometryNormalを使用するようにします。たった一つのTextureサンプリングと些末な計算の省略ですが、先ほどと同じ設定で、FrameTimeは10.7msから7.4msになりました。Rayの本数は変更せずに、当初の64%程度のレンダリング時間になりました。
 ClosestHitでShaderTableを参照して行われる処理は、GPUの各Threadが異なる処理をしている可能性が高く、たとえ同じマテリアルに複数のRayがHitしたとしても、2次RayがHitする場所は空間的な連続性が期待できません。つまりテクスチャのキャッシュヒット率は非常に低いといえます。そのため、このように大きな処理時間の違いとなるようです。最後に、オリジナルと、変更を施したもののスクリーンショットを示します。

差異は確認できますが、もともと、Rayの本数が少ないので、このシーンでは、2次バウンスのNormalMapの影響は軽微といえると思いますが、これが必ずすべてのコンテンツに当てはまるというわけではありません。