Unity HDRPのLitシェーダーを改造してレイマーチングする(GBuffer編)

f:id:kaneta1011:20190826231443p:plain
サムネ用

なにをやったか

今回はUnityのHDRPのシェーダーを改造して、HDRPのシーン上にレイマーチングオブジェクトを表示しました!

がむさんによる作例もあります

UnityとHDRPのバージョン

  • Unity 2019.2.0f1
  • HDRP 6.9.0-preview

今回作ったプロジェクトはこちらのリポジトリで公開しています!

もしかしたらHDRPでカスタムシェーダーを作るときに何かの足しになるかもしれないです!!

github.com

仕組み

まずどうやってUnityの世界にレイマーチングオブジェクトを表示するのかの仕組みを簡単に説明します。

3Dではフォワードやディファードレンダリングという言葉をよく聞くと思います。HDRPではディファードをさらに発展させたものをハイブリットに使用してレンダリングしています。

ディファードレンダリングでは、GBufferと呼ばれるライティングに必要な情報を収集するパスと、そのGBufferを利用して実際にライティングを行うパスに分かれて世界を表現します。

今回はこのGBufferを生成するパスにレイマーチングによる情報で上書きすることで、ライティングをHDRPの強力なライティングソリューションにお任せするということをしています。

既にUnityでレイマーチングをするのは偉大な先駆者の方々がいらっしゃるので、詳しくはそちらの方々の解説を参照してください。

この記事ではHDRPでレイマーチングするにあたっての知識にフォーカスします。

i-saint.hatenablog.com

tips.hecomi.com

gam0022.net

レイマーチング用シェーダーファイルの構成

レイマーチングオブジェクトのマテリアルは、以下の6つのユーザーシェーダーで構成されます。

もし独自のレイマーチングオブジェクトをこのプロジェクトに追加したければ、 DF.hlslRaymarching.shader をコピーして、 DF.hlsldistanceFunctiongetDistanceFunctionSurfaceData を実装すればOKです。

GBufferパス

HDRPではマテリアルタイプ毎にGBufferに格納する内容が異なるっぽい(GBuffer0.aとGBuffer2が異なるみたい)ですが、今回のレイマーチングはHDRPのStandardマテリアルのみで描画しています。(今後別マテリアルにも対応したいです)

シェーダー内のコメントによると、特に追加設定の無いStandardマテリアルのGBufferの内容は以下のようになっています。(バージョンアップで変わる可能性もあります)

f:id:kaneta1011:20190817235544p:plain

最終的にこのフォーマットでレンダリングすればHDRPの強力なライティングソリューションで描画してくれます!

既存のLit.shaderで使用されているShaderPassGBuffer.hlslのGBufferパスを覗いてみると、こちらの一行でGBufferに情報を書き込んでいました。

SurfaceData surfaceData;
BuiltinData builtinData;
GetSurfaceAndBuiltinData(input, V, posInput, surfaceData, builtinData);

ENCODE_INTO_GBUFFER(surfaceData, builtinData, posInput.positionSS, outGBuffer);

このインターフェースに乗っ取って、SurfaceDataとBuiltinDataをレイマーチングで生成できれば上手くGBufferに情報を書き込むことが出来そうです。

GBufferパスでレイマーチングを行うように改造する

このプロジェクトでは3つのレイマーチングオブジェクトがありますが、その中のMengerEmitを例に解説します。

GBufferパスのフラグメントシェーダーでレイマーチングを行いたいので、該当の処理を見てみます。

HDRPのシェーダーは各処理毎にシェーダーファイルが分かれていて、includeを差し替えることでいろいろなパスに切り替えれるようになっています。

なのでGBufferのフラグメントシェーダーも実際は.shaderファイルには無くて、ShaderPassGBuffer.hlslに切り出されています。

このhlslファイルを丸々コピーしてレイマーチング用のGBufferパスにカスタマイズしていきます。

GBufferパス改造の本質と言える部分は以下の箇所です。

GBufferPass.hlslの該当箇所

float3 ray = normalize(input.positionRWS);
float3 pos = GetRayOrigin(input.positionRWS);

DistanceFunctionSurfaceData surface = Trace(pos, ray, GBUFFER_MARCHING_ITERATION);

SurfaceData surfaceData;
BuiltinData builtinData;
ToHDRPSurfaceAndBuiltinData(input, V, posInput, surface, surfaceData, builtinData);

float depth = WorldPosToDeviceDepth(surface.Position);

ENCODE_INTO_GBUFFER(surfaceData, builtinData, posInput.positionSS, outGBuffer);

上記のコードを一つずつ解説していきます。

RayOriginとRayDirectionについて

レイマーチングを行うにはレイマーチを始める点である RayOrigin と進む方向である RayDirection が必要です。

今回は球を描画する際にレイマーチングを行うので、本来ならばカメラのワールド座標をRayOriginに、カメラ位置からフラグメントの位置へのベクトルをRayDirectionにするはずです。

しかし私は今回以下のように実装しました。

// RaymarchingUtility.hlsl
float3 GetRayOrigin(float3 positionRWS) {
    float3 pos = float3(0.0, 0.0, 0.0);
    return pos;
}

float3 ray = normalize(input.positionRWS);
float3 pos = GetRayOrigin(input.positionRWS);

RayOriginが原点...?なんだこれは?と思いますよね...

実はHDRPではカメラの平行移動による座標値の精度低下を抑えるために、MVP行列のMに当たるモデル変換行列の位置成分から既にカメラ座標が差し引かれています。カメラ空間レンダリングというらしいです。

該当箇所はこちらです

#define UNITY_MATRIX_M     ApplyCameraTranslationToMatrix(GetRawUnityObjectToWorld())

float4x4 ApplyCameraTranslationToMatrix(float4x4 modelMatrix)
{
    // To handle camera relative rendering we substract the camera position in the model matrix
#if (SHADEROPTIONS_CAMERA_RELATIVE_RENDERING != 0)
    modelMatrix._m03_m13_m23 -= _WorldSpaceCameraPos;
#endif
    return modelMatrix;
}

そしてその変換行列でObject to World変換を行ったフラグメント座標が input.positionRWS に格納されています。(RWSは Relative World Space の略だと思います)

つまり、HDRPではフラグメントシェーダーの時点でカメラ位置が原点の空間に変換されているので、カメラ位置を表すRayOriginが原点になります!

RayDirectionも本来ならカメラ座標からフラグメントのワールド座標へのベクトルを求める必要がありますが、今回はカメラ位置が原点の空間なので input.positionRWS をnormalizeするだけで求めることができます。

カメラ空間レンダリングを行わないとどうなるのかと言ったことはこちらの記事で詳しく解説してくれています。(こちらはレイトレーサーの話なので少し文脈が異なりそうですが...)

pharr.org

実際にHDRPではどういう理由で採用されて、どう動いているかはこちらのHDRPのマニュアルに記載されています。

docs.unity3d.com

サーフェースデータをHDRPのデータに変換してGBufferに書き込む

DistanceFunctionSurfaceData surface = Trace(pos, ray, GBUFFER_MARCHING_ITERATION);

SurfaceData surfaceData;
BuiltinData builtinData;
ToHDRPSurfaceAndBuiltinData(input, V, posInput, surface, surfaceData, builtinData);

ENCODE_INTO_GBUFFER(surfaceData, builtinData, posInput.positionSS, outGBuffer);

座標変換等少し特殊なことをしていますが、いつものようにスフィアトレーシングをしてサーフェースデータを計算します。(該当の処理はこの辺

その後 ToHDRPSurfaceAndBuiltinData でHDRPのライティングに必要なデータに変換します。

関数の中身はこのようになっています。

void ToHDRPSurfaceAndBuiltinData(FragInputs input, float3 V, inout PositionInputs posInput, DistanceFunctionSurfaceData surface, out SurfaceData surfaceData, out BuiltinData builtinData) {
    surfaceData = (SurfaceData)0;
    surfaceData.materialFeatures = MATERIALFEATUREFLAGS_LIT_STANDARD;
    surfaceData.normalWS = surface.Normal;
    surfaceData.ambientOcclusion = surface.Occlusion;
    surfaceData.perceptualSmoothness = surface.Smoothness;
    surfaceData.specularOcclusion = GetSpecularOcclusionFromAmbientOcclusion(ClampNdotV(dot(surfaceData.normalWS, V)), surfaceData.ambientOcclusion, PerceptualSmoothnessToRoughness(surfaceData.perceptualSmoothness));
    surfaceData.baseColor = surface.Albedo;
    surfaceData.metallic = surface.Metallic;
    input.positionRWS = surface.Position;
    posInput.positionWS = surface.Position;

    float alpha = 1.0;
#if HAVE_DECALS
    if (_EnableDecals)
    {
        DecalSurfaceData decalSurfaceData = GetDecalSurfaceData(posInput, alpha);
        ApplyDecalToSurfaceData(decalSurfaceData, surfaceData);
    }
#endif

    GetBuiltinData(input, V, posInput, surfaceData, alpha, surface.BentNormal, 0.0, builtinData);
    builtinData.emissiveColor = surface.Emissive;
}

HDRPは描画の計算に SurfaceDataBuiltinData が必要で、既存のGBufferパスもこの二つを計算する処理が書かれています。

SurfaceData はレイマーチングによって簡単にデータを作ることができますが、 BuiltinData はGI等HDRPの仕組みに依存したデータが必要です。

これは GetBuiltinData というLitBuiltinData.hlslに定義されている関数で SurfaceData 等の情報を使って取得してくれるのでそちらにお任せします。

Emissiveのみ取得後に上書きする必要があるので注意です。

この関数を拡張すれば、SSSマテリアル等にも対応できるのではないかと思っているので今後検証していきたいです。

この関数で取得した SurfaceDataBuiltinDataENCODE_INTO_GBUFFER を用いてGBufferに書き込みます。

レイマーチングオブジェクトの深度で上書きする

このままだとポリゴンの形状のままの深度値で計算されてしまうので、レイマーチングによって計算された深度で上書きします。

既にGBufferパスのフラグメントシェーダーには、深度を書き込む処理が実装されていますが、プリプロセッサで分岐しているので #define _DEPTHOFFSET_ON を定義します。

GBufferPass.hlslの該当箇所

+ #define _DEPTHOFFSET_ON

void Frag(  PackedVaryingsToPS packedInput,
            OUTPUT_GBUFFER(outGBuffer)
            #ifdef _DEPTHOFFSET_ON
            , out float outputDepth : SV_Depth
            #endif
            )
{

・・・

#ifdef _DEPTHOFFSET_ON
    outputDepth = depth;
#endif

HDRPのカスタマイズの内容

今回レイマーチングをHDRPで動かすにあたってHDRP本体に二行ほどの修正を行いました。

-           RenderDBuffer(hdCamera, cmd, renderContext, cullingResults);
-           // We can call DBufferNormalPatch after RenderDBuffer as it only affect forward material and isn't affected by RenderGBuffer
-           // This reduce lifteime of stencil bit
-           DBufferNormalPatch(hdCamera, cmd, renderContext, cullingResults);
#if ENABLE_RAYTRACING
            bool validIndirectDiffuse = m_RaytracingIndirectDiffuse.ValidIndirectDiffuseState();
            cmd.SetGlobalInt(HDShaderIDs._RaytracedIndirectDiffuse, validIndirectDiffuse ? 1 : 0);
#endif

            RenderGBuffer(cullingResults, hdCamera, renderContext, cmd);
+           // This will bind the depth buffer if needed for DBuffer)
+           RenderDBuffer(hdCamera, cmd, renderContext, cullingResults);
+           // We can call DBufferNormalPatch after RenderDBuffer as it only affect forward material and isn't affected by RenderGBuffer
+           // This reduce lifteime of stencil bit
+           DBufferNormalPatch(hdCamera, cmd, renderContext, cullingResults);

具体的に言うとGBufferパスの後にD(ecal)Bufferパスを描画するようにしました。

また Lit.shader をコピーして作成した Raymarching.shader から DepthOnlyPass を消去しました。

なぜこのような変更を加えたかというと、デプステクスチャのコピーのタイミングに関係があります。

HDRenderPipeline.csを除くとデプステクスチャーがコピーされる可能性があるタイミングが以下の二か所にあります。

Decalは設定によってON/OFFを切り替えれるため、DBufferパスを実行する場合はその直前に、しなかった場合はGBufferパスの直後にデプステクスチャーがコピーされるという実装になっているようです。

このままだと何が問題かというと、Decalが有効の場合、GBufferパスの前に既にデプステクスチャがコピーされてしまっているので、レイマーチングで上書きした深度がその後のパスに反映されないという状態になります。

f:id:kaneta1011:20190826143732p:plain
カスタム無し

f:id:kaneta1011:20190826143829p:plain
カスタム有り

このように前後関係がめちゃくちゃになります。

そのため、GBufferによる深度をコピー前に上書きするために、このような変更を加えました。

この時3つの解決策を思いつきましたが、結局GとDBufferの順番を入れ替えるという方法で解決することにしました。

  • ①DBufferとGBufferを入れ替える
    • メリット
      • 入れ替えるだけで簡単に解決できる
      • 追加のコピーや描画が不要なのでコストが安い
    • デメリット
      • GBufferパスでDBufferを参照しているため、入れ替えることによって1フレーム前のDBufferが参照されてしまう
  • ➁Depth Only Passでもレイマーチングを行って正しい深度を計算する
    • メリット
      • HDRP本体に変更を加える必要がないためクリーンに解決できる
    • デメリット
      • 追加のレイマーチングが必要なため負荷が非常に高い
  • ➂DBuffer前とGBuffer後、どちらもデプスをコピーするように変更する
    • メリット
      • 追加のレイマーチングをするのに比べてテクスチャのコピーなので負荷が少ない
    • デメリット
      • HDRP側の変更が入れ替えるものより少し多い
      • 入れ替えるものに比べて少し負荷が高い

今回は①の入れ替える方法を採用しましたが、個人的には負荷や正確性を考えて➂が良いのかなと思います。

さいごに

筆が遅すぎて、このプロジェクトを作ってから結構時間が空いてしまいましたが、ここまでの情報でHDRP上でレイマーチングできます!

HDRPのカスタムシェーダーを手書きするのはあまり賢い手段ではないのですが、何かの足しになれば幸いです。

まだ影とモーションベクターのパスの解説ができていないので、来月ぐらいにはそちらも書きたいです。