Unity HDRPのLitシェーダーを改造してレイマーチングする(GBuffer編)
なにをやったか
今回はUnityのHDRPのシェーダーを改造して、HDRPのシーン上にレイマーチングオブジェクトを表示しました!
Unity HDRP + Raymarchingのプロジェクト公開しました!
— かねた (@kanetaaaaa) 2019年8月11日
興味があれば動かしてみてください!!https://t.co/hcBJKSWzC9
コードの整理ができたらブログに情報を残します#unity3d #raymarching pic.twitter.com/6Jc0HeeOAB
がむさんによる作例もあります
Unity HDRP + Raymarching by @kanetaaaaa を試してみました!
— がむ (@gam0022) 2019年8月20日
カッコいいシーンが無限に作れてしまう😍
これは凄いです🙏#unity3d #raymarchinghttps://t.co/EK6JsHpTBZ pic.twitter.com/ZueP2hfzet
UnityとHDRPのバージョン
- Unity 2019.2.0f1
- HDRP 6.9.0-preview
今回作ったプロジェクトはこちらのリポジトリで公開しています!
もしかしたらHDRPでカスタムシェーダーを作るときに何かの足しになるかもしれないです!!
仕組み
まずどうやってUnityの世界にレイマーチングオブジェクトを表示するのかの仕組みを簡単に説明します。
3Dではフォワードやディファードレンダリングという言葉をよく聞くと思います。HDRPではディファードをさらに発展させたものをハイブリットに使用してレンダリングしています。
ディファードレンダリングでは、GBufferと呼ばれるライティングに必要な情報を収集するパスと、そのGBufferを利用して実際にライティングを行うパスに分かれて世界を表現します。
今回はこのGBufferを生成するパスにレイマーチングによる情報で上書きすることで、ライティングをHDRPの強力なライティングソリューションにお任せするということをしています。
既にUnityでレイマーチングをするのは偉大な先駆者の方々がいらっしゃるので、詳しくはそちらの方々の解説を参照してください。
この記事ではHDRPでレイマーチングするにあたっての知識にフォーカスします。
レイマーチング用シェーダーファイルの構成
レイマーチングオブジェクトのマテリアルは、以下の6つのユーザーシェーダーで構成されます。
- 全てのレイマーチングオブジェクト共通の RaymarchingUtility.hlsl
- 距離関数やマテリアル情報を記載する DF.hlsl
- HDRPの
Lit.shader
をレイマーチング用に改造した Raymarching.shader
もし独自のレイマーチングオブジェクトをこのプロジェクトに追加したければ、 DF.hlsl
と Raymarching.shader
をコピーして、 DF.hlsl
の distanceFunction
と getDistanceFunctionSurfaceData
を実装すればOKです。
GBufferパス
HDRPではマテリアルタイプ毎にGBufferに格納する内容が異なるっぽい(GBuffer0.aとGBuffer2が異なるみたい)ですが、今回のレイマーチングはHDRPのStandardマテリアルのみで描画しています。(今後別マテリアルにも対応したいです)
シェーダー内のコメントによると、特に追加設定の無いStandardマテリアルのGBufferの内容は以下のようになっています。(バージョンアップで変わる可能性もあります)
最終的にこのフォーマットでレンダリングすれば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パス改造の本質と言える部分は以下の箇所です。
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するだけで求めることができます。
カメラ空間レンダリングを行わないとどうなるのかと言ったことはこちらの記事で詳しく解説してくれています。(こちらはレイトレーサーの話なので少し文脈が異なりそうですが...)
実際にHDRPではどういう理由で採用されて、どう動いているかはこちらのHDRPのマニュアルに記載されています。
サーフェースデータを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は描画の計算に SurfaceData
と BuiltinData
が必要で、既存のGBufferパスもこの二つを計算する処理が書かれています。
SurfaceData
はレイマーチングによって簡単にデータを作ることができますが、 BuiltinData
はGI等HDRPの仕組みに依存したデータが必要です。
これは GetBuiltinData
というLitBuiltinData.hlslに定義されている関数で SurfaceData
等の情報を使って取得してくれるのでそちらにお任せします。
Emissiveのみ取得後に上書きする必要があるので注意です。
この関数を拡張すれば、SSSマテリアル等にも対応できるのではないかと思っているので今後検証していきたいです。
この関数で取得した SurfaceData
と BuiltinData
を ENCODE_INTO_GBUFFER
を用いてGBufferに書き込みます。
レイマーチングオブジェクトの深度で上書きする
このままだとポリゴンの形状のままの深度値で計算されてしまうので、レイマーチングによって計算された深度で上書きします。
既にGBufferパスのフラグメントシェーダーには、深度を書き込む処理が実装されていますが、プリプロセッサで分岐しているので #define _DEPTHOFFSET_ON
を定義します。
+ #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パスの前に既にデプステクスチャがコピーされてしまっているので、レイマーチングで上書きした深度がその後のパスに反映されないという状態になります。
このように前後関係がめちゃくちゃになります。
そのため、GBufferによる深度をコピー前に上書きするために、このような変更を加えました。
この時3つの解決策を思いつきましたが、結局GとDBufferの順番を入れ替えるという方法で解決することにしました。
- ①DBufferとGBufferを入れ替える
- メリット
- 入れ替えるだけで簡単に解決できる
- 追加のコピーや描画が不要なのでコストが安い
- デメリット
- GBufferパスでDBufferを参照しているため、入れ替えることによって1フレーム前のDBufferが参照されてしまう
- メリット
- ➁Depth Only Passでもレイマーチングを行って正しい深度を計算する
- メリット
- HDRP本体に変更を加える必要がないためクリーンに解決できる
- デメリット
- 追加のレイマーチングが必要なため負荷が非常に高い
- メリット
- ➂DBuffer前とGBuffer後、どちらもデプスをコピーするように変更する
- メリット
- 追加のレイマーチングをするのに比べてテクスチャのコピーなので負荷が少ない
- デメリット
- HDRP側の変更が入れ替えるものより少し多い
- 入れ替えるものに比べて少し負荷が高い
- メリット
今回は①の入れ替える方法を採用しましたが、個人的には負荷や正確性を考えて➂が良いのかなと思います。
さいごに
筆が遅すぎて、このプロジェクトを作ってから結構時間が空いてしまいましたが、ここまでの情報でHDRP上でレイマーチングできます!
HDRPのカスタムシェーダーを手書きするのはあまり賢い手段ではないのですが、何かの足しになれば幸いです。
まだ影とモーションベクターのパスの解説ができていないので、来月ぐらいにはそちらも書きたいです。