はじめに
先日 Unity 2021 LTS が発表されましたね。
Unity 2021 には数多くの機能追加がありますが、このうちの一つが URP への Deferred Rendering の正式サポートです(実験的に以前から追加はされていたようです)。
ドキュメントはこちらです。
本ブログではレンダリングパイプラインを読んでいこう企画を何度かやってまして、URP も Unlit / Lit (Forward) については以下の記事を書きました。
今回は続きで、Deferred を始めとして、前回執筆時から更新があり追加された DepthNormals パスやデカールなどについて書いていこうと思います。ただし、今回は、マクロの分岐なども含めてものすごい深堀りするところまではやらずに、全体の流れを見るにとどめます(たまに細かく見ます)。なお、体系的に URP の更新履歴を追いたい方には、以下の lil さんによるまとめが大変素晴らしくまとまっています。
環境
- Unity 2021.3.0f1
- Universal RP 12.1.6
Lit シェーダのパスの外観
Lit
シェーダの方は色んな技法に対応しており追うのが大変なので、シンプルな SimpleLit
シェーダを見ていきます。まずは ShaderLab の全体構造をざっくり見てみます。
Shader "Universal Render Pipeline/Simple Lit" { Properties { ... } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "UniversalMaterialType" = "SimpleLit" "IgnoreProjector" = "True" "ShaderModel"="4.5" } LOD 300 Pass { Name "ForwardLit" Tags {"LightMode" = "UniversalForward"} .... } Pass { Name "ShadowCaster" Tags {"LightMode" = "ShadowCaster"} ... } Pass { Name "GBuffer" Tags {"LightMode" = "UniversalGBuffer"} } Pass { Name "DepthOnly" Tags {"LightMode" = "DepthOnly"} ... } Pass { Name "DepthNormals" Tags {"LightMode" = "DepthNormals"} } Pass { Name "Meta" Tags {"LightMode" = "Meta"} ... } Pass { Name "Universal2D" Tags { "LightMode" = "Universal2D" } ... } } SubShader { Tags { ... "ShaderModel"="2.0" } ... } FallBack "Hidden/Universal Render Pipeline/FallbackError" CustomEditor "UnityEditor.Rendering.Universal.ShaderGUI.LitShader" }
この UniversalGBuffer
なパスが今回見ていく Deferred のコアとなるパスになります。ただ、前回の記事と比べて DepthNormals
やデカールなども増えているのでこちらも合わせて見ていきたいと思います。
DepthNormals パス
Depth Normals パスとは
URP 10.0.x より追加されたパスです。_CameraNormalsTexture
というテクスチャに出力が書き込まれ、ポストプロセスなどで利用されます。
例えば SSAO の Render Feature では Source
で Depth
か Depth Normals
かを選択できます。
Depth
と Depth Normals
のどちらを使うかによってプラットフォーム次第でパフォーマンス差(ほとんどのケースであまり変わらない)やビジュアルの差(一般的に Depth Normals
のほうがキレイ)が生じます。詳しくは以下のドキュメントに記述されています。
例えば執筆時点では、uRaymarching は Depth Normals
パスに対応していないので、Depth Normals
が指定されたときは対応するパスがないので法線情報が出力されず、SSAO が適用されないという状態になっています。
コード
コードを詳細に見てみましょう。
Pass { Name "DepthNormals" Tags {"LightMode" = "DepthNormals"} ZWrite On Cull[_Cull] HLSLPROGRAM #pragma exclude_renderers gles gles3 glcore #pragma target 4.5 #pragma vertex DepthNormalsVertex #pragma fragment DepthNormalsFragment #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma shader_feature_local_fragment _GLOSSINESS_FROM_BASE_ALPHA #pragma multi_compile_instancing #pragma multi_compile _ DOTS_INSTANCING_ON #include "Packages/com.unity.render-pipelines.universal/Shaders/SimpleLitInput.hlsl" #include "Packages/com.unity.render-pipelines.universal/Shaders/SimpleLitDepthNormalsPass.hlsl" }
パス本体は SimpleLitDepthNormalsPass.hlsl に記述されています。説明のために簡略化したコードを見てみます。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; float3 normal : NORMAL; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD1; #ifdef _NORMALMAP half4 normalWS : TEXCOORD2; // xyz: normal, w: viewDir.x half4 tangentWS : TEXCOORD3; // xyz: tangent, w: viewDir.y half4 bitangentWS : TEXCOORD4; // xyz: bitangent, w: viewDir.z #else half3 normalWS : TEXCOORD2; half3 viewDir : TEXCOORD3; #endif UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; Varyings DepthNormalsVertex(Attributes input) { Varyings output = (Varyings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); output.positionCS = TransformObjectToHClip(input.positionOS.xyz); VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput = GetVertexNormalInputs(input.normal, input.tangentOS); half3 viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); #if defined(_NORMALMAP) output.normalWS = half4(normalInput.normalWS, viewDirWS.x); output.tangentWS = half4(normalInput.tangentWS, viewDirWS.y); output.bitangentWS = half4(normalInput.bitangentWS, viewDirWS.z); #else output.normalWS = half3(NormalizeNormalPerVertex(normalInput.normalWS)); #endif return output; } half4 DepthNormalsFragment(Varyings input) : SV_TARGET { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); Alpha( SampleAlbedoAlpha( input.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap)).a, _BaseColor, _Cutoff); ... float2 uv = input.uv; #if defined(_NORMALMAP) half3 normalTS = SampleNormal( uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap)); half3 normalWS = TransformTangentToWorld( normalTS, half3x3( input.tangentWS.xyz, input.bitangentWS.xyz, input.normalWS.xyz)); #else half3 normalWS = input.normalWS; #endif normalWS = NormalizeNormalPerPixel(normalWS); return half4(normalWS, 0.0); }
ちょっとコードが多く見えるかもしれませんが、ここで重要なのはフラグメントシェーダの出力の return half4(normalWS, 0.0)
です。要は法線を計算して SV_Target
として値を書き出しているところですね。計算に関しては他の Pass と変わらない(むしろ色の計算が無い)ので分かりやすいと思います。このパスを追加することで法線情報が出力される様になり、SSAO など法線情報を必要とするポストプロセスがうまく動くようになります。
デプスの上書きをしたい場合
レイマーチングの場合など、Depth の値をポリゴンの値(頂点シェーダの SV_Position
で出力された値)から変更したい場合は、次のように出力構造体を用意すれば可能です。
... struct FragOutput { float4 normal : SV_Target; float depth : SV_Depth; }; ... FragOutput Frag(Varyings input) { ... float3 normalWS = ...; ... FragOutput o; o.normal = float4(normalWS, 0.0); o.depth = ray.depth; return o; }
こうして任意のデプス出力でも SSAO が効くようになります。
なお、SSAO を実際に反映するには、ShaderLab の ForwardLit
パスに次の行を追加しておく必要があります。
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
SSAO レンダリングの流れ
では、この法線出力とそれを利用するポストプロセス(正確には URP では Render Feature) である SSAO の流れを見てみましょう。
まず各オブジェクトの Depth Normals
パスが実行され、_CameraDepthTexture
および _CameraNormalsTexture
が生成されます。この 2 つを入力として受け取り、SSAO の計算パスが走ります(ブラーのため複数パスが実行されます)。その出力が _SSAO_OcclusionTexture
となります。そして後段のオブジェクトごとのレンダリングステージ(DrawOpaqueObjects
)で、それぞれのオブジェクトのシェーダで _SCREEN_SPACE_OCCLUSION
キーワードが ON の時にこの _SSAO_OcclusionTexture
を参照して出力色へ反映される流れになっています。
ポストプロセスで行うのではなく、マテリアル単位で SSAO 適用の選択ができるので、ステンシルマスクなど使わずに必要なものだけ適応できるのが良いですね(例えばキャラの顔は除外、など)。
デカール
URP デカールについて
こちらの Unity Japan さん公式動画でも言及されていますが、URP 12 より HDRP で先行搭載されていたデカールシステムが追加されました。
ドキュメントはこちら:
追加の仕方はこちらの記事を見ると良くわかります:
コードの対応
シェーダ側でこれに対応するのは簡単で、次のように _DBUFFER
部を追加するだけです。
float4 Frag(Varyings input) : SV_TARGET { ... #ifdef _DBUFFER ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData); #endif ... }
なお、デカールを有効にするには以下のキーワードを有効にする必要があります。
#pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3
レンダリングの流れ
デカールの手法は 2 種類、D-Buffer を使う方法と Screen Space で後処理でやる方法があります。D-Buffer を使う方法を見てみると、次のように DBuffer Render ステージで _CameraDepthTexture
を参照しながら Decal Projector の画を投影したバッファ(_DBufferTexture0
など、色や法線など)を生成します。これを使って通常のレンダーステージでこのバッファを参照しながら画を出力します。
もうひとつの手法の Screen Space の方では、オブジェクトごとのレンダリングまでは通常通り行い、その後にポストプロセス的に投影する形になります。
D-Buffer 形式ではフラグメントシェーダ内でパラメタとして入ってくるので、どのように投影するか好き勝手に改変できる形になります(適応しないマスクを用意したり、部分的にアルファで薄くしたり、UV をずらしたりなど)。一方で、_DepthNormals
テクスチャを要求したりすることからパフォーマンス観点では少し劣ります。Screen Space の方はパフォーマンスが稼げる代わりに一括処理になるので細かい調整ができなかったりクオリティ面で少し劣るのがデメリットですね。
Depth Prepass
(2022/05/14 追記)
はじめに
色を決定するフラグメントシェーダの計算は PBR などを思い浮かべるとそれなりのコストが必要です。Depth Prepass は、この計算をする前にまず深度だけ計算して最終的にどのオブジェクトが最前面に描画されるかを求め、そのオブジェクトに対してのみカラーの計算を行う手法です。URP 12 から追加されました。詳しくは公式の keijiro さんによる解説をご参照ください。
ON の仕方
Universal Rendering Data アセットで Rendering > Rendering Path > Depth Priming Mode の選択で Depth Prepass を使用するかが指定できます。
- Disabled
- Depth Prepass を使用しない
- Forced
- Depth Prepass を常に使用
- Auto
- 既に事前パスがあるようなケース(例えば SSAO のように DepthNormals 計算が先駆けて必要なケース)のみ Depth Prepass を使用
レンダリングの流れ
このように事前にデプス生成のパスが差し込まれます(法線も必要なときは DepthOnly
ではなく DepthNormals
パスが代わりに使われます)。よく見てみると、DrawOpaqueObjects では ZWrite Off
および ZTest Equal
になっています。事前に作成したデプステクスチャを参照してデプスはそれをそのまま使い、デプスが一致するピクセルシェーダのみが起動されて計算が行われる、という流れになっているのが分かりますね。
G-Buffer パス
概要
Deferred レンダリングが追加されました。URP は Legacy パイプラインと比べて(制限個数付き)複数ライトを 1 パスで使えるメリットがありますが、それでも大きい空間で動的なライトをたくさん描画するようなゲームだと厳しいです。そういったゲームへ向けても選択肢が増えるのは良いですね。Deferred レンダリングそのものについては Wikipedia がとても分かり易いのでそちらをご参照ください。
コード
では Deferred レンダリングのための G-Buffer を出力するパスを見てみましょう。
Pass { Name "GBuffer" Tags {"LightMode" = "UniversalGBuffer" } ZWrite [_ZWrite] ZTest LEqual Cull [_Cull] HLSLPROGRAM #pragma exclude_renderers gles gles3 glcore #pragma target 4.5 #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma shader_feature_local_fragment _ _SPECGLOSSMAP _SPECULAR_COLOR #pragma shader_feature_local_fragment _GLOSSINESS_FROM_BASE_ALPHA #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local _RECEIVE_SHADOWS_OFF #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT #pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3 #pragma multi_compile_fragment _ _LIGHT_LAYERS #pragma multi_compile _ DIRLIGHTMAP_COMBINED #pragma multi_compile _ LIGHTMAP_ON #pragma multi_compile _ DYNAMICLIGHTMAP_ON #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING #pragma multi_compile _ SHADOWS_SHADOWMASK #pragma multi_compile_fragment _ _GBUFFER_NORMALS_OCT #pragma multi_compile_fragment _ _RENDER_PASS_ENABLED #pragma multi_compile_instancing #pragma instancing_options renderinglayer #pragma multi_compile _ DOTS_INSTANCING_ON #pragma vertex LitPassVertexSimple #pragma fragment LitPassFragmentSimple #define BUMP_SCALE_NOT_SUPPORTED 1 #include "Packages/com.unity.render-pipelines.universal/Shaders/SimpleLitInput.hlsl" #include "Packages/com.unity.render-pipelines.universal/Shaders/SimpleLitGBufferPass.hlsl" ENDHLSL }
頂点・フラグメントシェーダは SimpleLitGBufferPass.hlsl に書かれているのでそちらを見てみます。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/UnityGBuffer.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; float2 staticLightmapUV : TEXCOORD1; float2 dynamicLightmapUV : TEXCOORD2; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float2 uv : TEXCOORD0; float3 posWS : TEXCOORD1; // xyz: posWS #ifdef _NORMALMAP half4 normal : TEXCOORD2; // xyz: normal, w: viewDir.x half4 tangent : TEXCOORD3; // xyz: tangent, w: viewDir.y half4 bitangent : TEXCOORD4; // xyz: bitangent, w: viewDir.z #else half3 normal : TEXCOORD2; #endif #ifdef _ADDITIONAL_LIGHTS_VERTEX half3 vertexLighting : TEXCOORD5; // xyz: vertex light #endif #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) float4 shadowCoord : TEXCOORD6; #endif DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 7); #ifdef DYNAMICLIGHTMAP_ON float2 dynamicLightmapUV : TEXCOORD8; // Dynamic lightmap UVs #endif float4 positionCS : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; void InitializeInputData( Varyings input, half3 normalTS, out InputData inputData) { inputData = (InputData)0; inputData.positionWS = input.posWS; inputData.positionCS = input.positionCS; #ifdef _NORMALMAP half3 viewDirWS = half3( input.normal.w, input.tangent.w, input.bitangent.w); inputData.normalWS = TransformTangentToWorld( normalTS, half3x3( input.tangent.xyz, input.bitangent.xyz, input.normal.xyz)); #else half3 viewDirWS = GetWorldSpaceNormalizeViewDir(inputData.positionWS); inputData.normalWS = input.normal; #endif inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS); viewDirWS = SafeNormalize(viewDirWS); inputData.viewDirectionWS = viewDirWS; #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) inputData.shadowCoord = input.shadowCoord; #elif defined(MAIN_LIGHT_CALCULATE_SHADOWS) inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS); #else inputData.shadowCoord = float4(0, 0, 0, 0); #endif #ifdef _ADDITIONAL_LIGHTS_VERTEX inputData.vertexLighting = input.vertexLighting.xyz; #else inputData.vertexLighting = half3(0, 0, 0); #endif inputData.fogCoord = 0; // we don't apply fog in the gbuffer pass #if defined(DYNAMICLIGHTMAP_ON) inputData.bakedGI = SAMPLE_GI( input.staticLightmapUV, input.dynamicLightmapUV, input.vertexSH, inputData.normalWS); #else inputData.bakedGI = SAMPLE_GI( input.staticLightmapUV, input.vertexSH, inputData.normalWS); #endif inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); inputData.shadowMask = SAMPLE_SHADOWMASK(input.staticLightmapUV); #if defined(DEBUG_DISPLAY) #if defined(DYNAMICLIGHTMAP_ON) inputData.dynamicLightmapUV = input.dynamicLightmapUV; #endif #if defined(LIGHTMAP_ON) inputData.staticLightmapUV = input.staticLightmapUV; #else inputData.vertexSH = input.vertexSH; #endif #endif } Varyings LitPassVertexSimple(Attributes input) { Varyings output = (Varyings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS); output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); output.posWS.xyz = vertexInput.positionWS; output.positionCS = vertexInput.positionCS; #ifdef _NORMALMAP half3 viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); output.normal = half4(normalInput.normalWS, viewDirWS.x); output.tangent = half4(normalInput.tangentWS, viewDirWS.y); output.bitangent = half4(normalInput.bitangentWS, viewDirWS.z); #else output.normal = NormalizeNormalPerVertex(normalInput.normalWS); #endif OUTPUT_LIGHTMAP_UV( input.staticLightmapUV, unity_LightmapST, output.staticLightmapUV); #ifdef DYNAMICLIGHTMAP_ON output.dynamicLightmapUV = input.dynamicLightmapUV.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw; #endif OUTPUT_SH(output.normal.xyz, output.vertexSH); #ifdef _ADDITIONAL_LIGHTS_VERTEX half3 vertexLight = VertexLighting( vertexInput.positionWS, normalInput.normalWS); output.vertexLighting = vertexLight; #endif #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) output.shadowCoord = GetShadowCoord(vertexInput); #endif return output; } FragmentOutput LitPassFragmentSimple(Varyings input) { UNITY_SETUP_INSTANCE_ID(input); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); SurfaceData surfaceData; InitializeSimpleLitSurfaceData(input.uv, surfaceData); InputData inputData; InitializeInputData(input, surfaceData.normalTS, inputData); SETUP_DEBUG_TEXTURE_DATA(inputData, input.uv, _BaseMap); #ifdef _DBUFFER ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData); #endif Light mainLight = GetMainLight( inputData.shadowCoord, inputData.positionWS, inputData.shadowMask); MixRealtimeAndBakedGI( mainLight, inputData.normalWS, inputData.bakedGI, inputData.shadowMask); half4 color = half4( inputData.bakedGI * surfaceData.albedo + surfaceData.emission, surfaceData.alpha); return SurfaceDataToGbuffer( surfaceData, inputData, color.rgb, kLightingSimpleLit); }
ちょっと長いので迷子になりますが...、G-Buffer パスの見どころはフラグメントシェーダです。LitPassFragmentSimple
を見てみると、FragmentOutput
が出力構造体として指定されています。これは以下のようになっています。
struct FragmentOutput { half4 GBuffer0 : SV_Target0; half4 GBuffer1 : SV_Target1; half4 GBuffer2 : SV_Target2; half4 GBuffer3 : SV_Target3; #ifdef GBUFFER_OPTIONAL_SLOT_1 GBUFFER_OPTIONAL_SLOT_1_TYPE GBuffer4 : SV_Target4; #endif #ifdef GBUFFER_OPTIONAL_SLOT_2 half4 GBuffer5 : SV_Target5; #endif #ifdef GBUFFER_OPTIONAL_SLOT_3 half4 GBuffer6 : SV_Target6; #endif };
基本 4 つ、最大 7 個の G-Buffer の出力ができるようになっています。ここに対して SurfaceDataToGbuffer()
で出力するための情報を色々かき集めている、というのが全体像です。最初にこの出力部である SurfaceDataToGbuffer()
を見てみましょう。
FragmentOutput SurfaceDataToGbuffer( SurfaceData surfaceData, InputData inputData, half3 globalIllumination, int lightingMode) { half3 packedNormalWS = PackNormal(inputData.normalWS); uint materialFlags = 0; #ifdef _RECEIVE_SHADOWS_OFF materialFlags |= kMaterialFlagReceiveShadowsOff; #endif #if defined(LIGHTMAP_ON) && defined(_MIXED_LIGHTING_SUBTRACTIVE) materialFlags |= kMaterialFlagSubtractiveMixedLighting; #endif // albedo albedo albedo flags // specular specular specular occlusion // normal normal normal smoothness // GI GI GI [optional] FragmentOutput output; output.GBuffer0 = half4(surfaceData.albedo.rgb, PackMaterialFlags(materialFlags)); output.GBuffer1 = half4(surfaceData.specular.rgb, surfaceData.occlusion); output.GBuffer2 = half4(packedNormalWS, surfaceData.smoothness); output.GBuffer3 = half4(globalIllumination, 1); #if _RENDER_PASS_ENABLED output.GBuffer4 = inputData.positionCS.z; #endif #if OUTPUT_SHADOWMASK // will have unity_ProbesOcclusion value if subtractive lighting is used (baked) output.GBUFFER_SHADOWMASK = inputData.shadowMask; #endif #ifdef _LIGHT_LAYERS uint renderingLayers = GetMeshRenderingLightLayer(); output.GBUFFER_LIGHT_LAYERS = float4( (renderingLayers & 0x000000FF) / 255.0, 0.0, 0.0, 0.0); #endif return output; }
G-Buffer のレイアウトとしては、Albedo / Specular / Normal / GI が RGB チャンネルに、マテリアルのフラグ / Occlusion / Smoothness がアルファチャネルに書き込まれています。あとはレンダリングオプションに応じて、5 ~ 7 チャンネルに追加の情報が書き込まれる形式ですね(+ デプス・ステンシル用の出力があります)。より詳細な説明はドキュメントに記載されています。
順番に見ていきましょう。
GBuffer 0
ここには Albedo とマテリアルのフラグが書き込まれます。まずは Albedo から見ましょう。ここには SurfaceData.albedo
が書き込まれます。SurfaceData
は InitializeSimpleLitSurfaceData()
によって初期化されます。
inline void InitializeSimpleLitSurfaceData(float2 uv, out SurfaceData outSurfaceData) { outSurfaceData = (SurfaceData)0; half4 albedoAlpha = SampleAlbedoAlpha( uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap)); outSurfaceData.alpha = albedoAlpha.a * _BaseColor.a; AlphaDiscard(outSurfaceData.alpha, _Cutoff); outSurfaceData.albedo = albedoAlpha.rgb * _BaseColor.rgb; outSurfaceData.albedo = AlphaModulate( outSurfaceData.albedo, outSurfaceData.alpha); half4 specularSmoothness = SampleSpecularSmoothness( uv, outSurfaceData.alpha, _SpecColor, TEXTURE2D_ARGS(_SpecGlossMap, sampler_SpecGlossMap)); outSurfaceData.metallic = 0.0; outSurfaceData.specular = specularSmoothness.rgb; outSurfaceData.smoothness = specularSmoothness.a; outSurfaceData.normalTS = SampleNormal( uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap)); outSurfaceData.occlusion = 1.0; outSurfaceData.emission = SampleEmission( uv, _EmissionColor.rgb, TEXTURE2D_ARGS(_EmissionMap, sampler_EmissionMap)); }
基本的には _BaseColor
を設定しています。_ALPHAMODULATE_ON
による AlphaModulate()
は乗算ブレンドの関係なので飛ばします。
マテリアルのフラグは以下のように定義されています。
#define kMaterialFlagReceiveShadowsOff 1 // Does not receive dynamic shadows #define kMaterialFlagSpecularHighlightsOff 2 // Does not receivce specular #define kMaterialFlagSubtractiveMixedLighting 4 // The geometry uses subtractive mixed lighting #define kMaterialFlagSpecularSetup 8 // Lit material use specular setup instead of metallic setup float PackMaterialFlags(uint materialFlags) { return materialFlags * (1.0h / 255.0h); } uint UnpackMaterialFlags(float packedMaterialFlags) { return uint((packedMaterialFlags * 255.0h) + 0.5h); }
レンダリングのオプションをビットでフラグを立てておき、float に変換・逆変換して G-Buffer に乗せておきます。これで Deferred の後段のシェーディングのタイミングでこのフラグを参照して、ピクセル単位で異なるレンダリングが出来るわけですね。
GBuffer 1
GBuffer 1 には Specular と Occlusion が格納されています。こちらも同様に SurfaceData
から渡ってくるマテリアルごとの情報になってます。
Simple Lit シェーダでは Spcular の値が書き込まれるだけですが、Lit シェーダでは Specular ワークフローか Metallic ワークフローかで書き込まれる値が変わります(Metallic の場合は R のみ使われ、GB は使われません)。
Occlusion に関しては、テクスチャとして焼き込まれた値と SSAO による値が統合されて出力されるようです。
GBuffer 2
GBuffer 2 には法線と Smoothness が格納されています。Smoothness の方は同様に SurfaceData
から渡ってくるものですが、法線の方は少しオプションがあるので見てみましょう。
法線は InputData
に格納されています。法線は法線マップがあることも考慮しながら頂点シェーダから渡ってくる情報も使ってピクセル単位で算出します。法線が G-Buffer へと送られる過程を見てみましょう。
Varyings LitPassVertexSimple(Attributes input) { ... VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS); ... #ifdef _NORMALMAP half3 viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); output.normal = half4(normalInput.normalWS, viewDirWS.x); output.tangent = half4(normalInput.tangentWS, viewDirWS.y); output.bitangent = half4(normalInput.bitangentWS, viewDirWS.z); #else output.normal = NormalizeNormalPerVertex(normalInput.normalWS); #endif ... } FragmentOutput LitPassFragmentSimple(Varyings input) { ... InputData inputData; InitializeInputData(input, surfaceData.normalTS, inputData); ... return SurfaceDataToGbuffer(..., inputData, ...); }; void InitializeInputData( Varyings input, half3 normalTS, out InputData inputData) { ... #ifdef _NORMALMAP half3 viewDirWS = half3( input.normal.w, input.tangent.w, input.bitangent.w); inputData.normalWS = TransformTangentToWorld( normalTS, half3x3( input.tangent.xyz, input.bitangent.xyz, input.normal.xyz)); #else half3 viewDirWS = GetWorldSpaceNormalizeViewDir(inputData.positionWS); inputData.normalWS = input.normal; #endif inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS); ... } FragmentOutput SurfaceDataToGbuffer(..., InputData inputData, ...) { half3 packedNormalWS = PackNormal(inputData.normalWS); ... output.GBuffer2 = half4(packedNormalWS, ...); }
NormalizeNormalPerVertex
も NormalizeNormalPerPixel()
も今のところは内部では normalize()
しているだけです。法線マップが必要なときは tangent
と bitangent
が必要な点などは通常と同じですが、おもしろポイントは SurfaceDataToGbuffer()
の中で行っている PackNormal()
です。これの実装を見てみましょう。
#ifdef _GBUFFER_NORMALS_OCT half3 PackNormal(half3 n) { float2 octNormalWS = PackNormalOctQuadEncode(n); float2 remappedOctNormalWS = saturate(octNormalWS * 0.5 + 0.5); return half3(PackFloat2To888(remappedOctNormalWS)); } half3 UnpackNormal(half3 pn) { half2 remappedOctNormalWS = half2(Unpack888ToFloat2(pn)); half2 octNormalWS = remappedOctNormalWS.xy * half(2.0) - half(1.0); return half3(UnpackNormalOctQuadEncode(octNormalWS)); } #else half3 PackNormal(half3 n) { return n; } half3 UnpackNormal(half3 pn) { return pn; } #endif
_GBUFFER_NORMALS_OCT
というマクロが設定されている時に、PackNormalOctQuadEncode()
というコードを経由しています。これは G-Buffer 上の法線の格納領域は 8 bit x 3 = 24 bit しかないのですが、八面体に射影した 2 次元ベクトル(float2
)にパックしたものを half3
に変換、実際の使用時(ライティング時)に逆変換することで精度向上を行うための仕組みです。Universal Rendering Data で Accurate G-Buffer normals
というチェックをオンにすることでマクロが ON になります。
ON / OFF の違いはドキュメントを御覧ください。
論文はこちら:
パックに関連したコードはこちら:
float2 PackNormalOctQuadEncode(float3 n) { n *= rcp(max(dot(abs(n), 1.0), 1e-6)); float t = saturate(-n.z); return n.xy + (n.xy >= 0.0 ? t : -t); } float3 UnpackNormalOctQuadEncode(float2 f) { float3 n = float3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y)); float t = max(-n.z, 0.0); n.xy += n.xy >= 0.0 ? -t.xx : t.xx; return normalize(n); } uint3 PackFloat2To888UInt(float2 f) { uint2 i = (uint2)(f * 4095.5); uint2 hi = i >> 8; uint2 lo = i & 255; uint3 cb = uint3(lo, hi.x | (hi.y << 4)); return cb; } float3 PackFloat2To888(float2 f) { return PackFloat2To888UInt(f) / 255.0; } float2 Unpack888UIntToFloat2(uint3 x) { uint hi = x.z >> 4; uint lo = x.z & 15; uint2 cb = x.xy | uint2(lo << 8, hi << 8); return cb / 4095.0; } float2 Unpack888ToFloat2(float3 x) { uint3 i = (uint3)(x * 255.5); return Unpack888UIntToFloat2(i); }
面白いですね。計算負荷が少し上がる(デスクトップアプリだとほとんど影響がないくらい)代わりに法線精度が上がりライティングがきれいになるので、リリースプラットフォームに応じて検討すると良さそうです。なお、ON にすると法線の出力が面白い感じになります。
GBuffer 3
GBuffer 3 には Emissive やベイクしたライティングに関する情報が格納されます。関連コードのみ抜き出して見てみます。
Varyings LitPassVertexSimple(Attributes input) { ... OUTPUT_LIGHTMAP_UV( input.staticLightmapUV, unity_LightmapST, output.staticLightmapUV); #ifdef DYNAMICLIGHTMAP_ON output.dynamicLightmapUV = input.dynamicLightmapUV.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw; #endif OUTPUT_SH(output.normal.xyz, output.vertexSH); #ifdef _ADDITIONAL_LIGHTS_VERTEX half3 vertexLight = VertexLighting(vertexInput.positionWS, normalInput.normalWS); output.vertexLighting = vertexLight; #endif #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) output.shadowCoord = GetShadowCoord(vertexInput); #endif ... } FragmentOutput LitPassFragmentSimple(Varyings input) { SurfaceData surfaceData; InitializeSimpleLitSurfaceData(input.uv, surfaceData); InputData inputData; InitializeInputData(input, surfaceData.normalTS, inputData); SETUP_DEBUG_TEXTURE_DATA(inputData, input.uv, _BaseMap); #ifdef _DBUFFER ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData); #endif Light mainLight = GetMainLight( inputData.shadowCoord, inputData.positionWS, inputData.shadowMask); MixRealtimeAndBakedGI( mainLight, inputData.normalWS, inputData.bakedGI, inputData.shadowMask); half4 color = half4( inputData.bakedGI * surfaceData.albedo + surfaceData.emission, surfaceData.alpha); return SurfaceDataToGbuffer(..., color.rgb, ...); }; void InitializeInputData(Varyings input, half3 normalTS, out InputData inputData) { ... #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) inputData.shadowCoord = input.shadowCoord; #elif defined(MAIN_LIGHT_CALCULATE_SHADOWS) inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS); #else inputData.shadowCoord = float4(0, 0, 0, 0); #endif #ifdef _ADDITIONAL_LIGHTS_VERTEX inputData.vertexLighting = input.vertexLighting.xyz; #else inputData.vertexLighting = half3(0, 0, 0); #endif ... #if defined(DYNAMICLIGHTMAP_ON) inputData.bakedGI = SAMPLE_GI( input.staticLightmapUV, input.dynamicLightmapUV, input.vertexSH, inputData.normalWS); #else inputData.bakedGI = SAMPLE_GI( input.staticLightmapUV, input.vertexSH, inputData.normalWS); #endif inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); inputData.shadowMask = SAMPLE_SHADOWMASK(input.staticLightmapUV); ... }
おおまかな流れとしては、InputData.bakedGI
にライトマップや球面調和ライティングを書き込み、それを albedo
とかけて得られた値を emissive
と一緒に書き込んでいる形です。しかしながら途中で MixRealtimeAndBakedGI()
というものを経由しています。これは以下のような関数です。
// 後方互換性のため void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI, half4 shadowMask) { MixRealtimeAndBakedGI(light, normalWS, bakedGI); } void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI) { #if defined(LIGHTMAP_ON) && defined(_MIXED_LIGHTING_SUBTRACTIVE) bakedGI = SubtractDirectMainLightFromLightmap(light, normalWS, bakedGI); #endif }
LIGHTMAP_ON
かつ _MIXED_LIGHTING_SUBTRACTIVE
の時にのみ SubtractDirectMainLightFromLightmap()
というのが実行されてますね。これは静的オブジェクト向けで、Mixed Lighting > Lighting Mode が Subtractive の時にベイク影とリアルタイム影をマージする処理を行うところです。このモードのときには Realtime Shadow Color で指定した色で影が制御できるようになります。土屋つかささんの解説を読むと分かりやすいです。
自分は GI 周りはあまり深く追ったことが無いのでいずれ調べたいです。
GBuffer 4
Lighting Mode が Subtractive か Shadow Mask に設定されている際にこの G-Buffer が追加されます。具体的にどう使われるかは追えていません。。なお、Deferred レンダリングでは、パフォーマンスの観点からこの 2 つの Lighting Mode は推奨されておらず、Baked Indirect を使うことが推奨されています。
GBuffer 5
これは Light Layer 用の G-Buffer になります。URP アセットで Advanced > Light Layers を ON にすると使える、オブジェクト単位にライトが影響するかしないかを決められる機能です。詳細はドキュメントを御覧ください:
GBuffer 6
Vulkan を使ったデバイス向けに用意された G-Buffer のようです。
Deferred レンダリング
Deferred Pass
さて、こうして出力した G-Buffer は後段のレンダリングパス(Deferred Pass)で実際に画として出力されます。
レンダリングは com.unity.render-pipelines.universal/Shaders/Utils/StencilDeferred.shader に記述されているシェーダで行います。外観としてはこんな感じです。
Shader "Hidden/Universal Render Pipeline/StencilDeferred" { ... SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" } Pass { Name "Stencil Volume" ... } Pass { Name "Deferred Punctual Light (Lit)" ... } Pass { Name "Deferred Punctual Light (SimpleLit)" ... } Pass { Name "Deferred Directional Light (Lit)" ... } Pass { Name "Deferred Directional Light (SimpleLit)" ... } Pass { Name "Fog" ... } Pass { Name "ClearStencilPartial" ... } Pass { Name "SSAOOnly" ... } } FallBack "Hidden/Universal Render Pipeline/FallbackError" }
いくつかのパスがありますね。実はマテリアル毎にレンダリング時にステンシルが書き込まれており、それに応じたパスが実行される仕組みのようです。例えば Lit
シェーダなら 32(0b100000)、SimpleLit
シェーダなら 64(0b1000000)といった具合です。そのステンシルに対応した Deferred のパスが使われてライティングが行われます。パフォーマンスのためかバッファがクリアされないのでちょっと見づらいですが、Frame Debugger で見ると、ステンシル値の書き込みや参照している様子を確認できます。
しかしながら Lit
や SimpleLit
の ShaderLab を見てみてもステンシルの書き込み部分はありません。。これは ShaderLab 内の Tags の UniversalMaterialType
を参照して、スクリプト側の Packages/com.unity.render-pipelines.universal/Runtime/Passes/GBufferPass.cs 内でステンシルが上書きされているようです。Lit
と SimpleLit
の ShaderLab の該当部分を少し見てみましょう。
Simple Lit
Shader "Universal Render Pipeline/Simple Lit" { ... SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "UniversalMaterialType" = "SimpleLit" "IgnoreProjector" = "True" "ShaderModel"="5.5" } ... } ... }
Lit
Shader "Universal Render Pipeline/Lit" { ... SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "UniversalMaterialType" = "Lit" "IgnoreProjector" = "True" "ShaderModel"="4.5" } } ... }
こんな感じで分かれています。あとは出てきていないタイプとしては Unlit
があります。なので自作のシェーダを作る際は出力するステンシル値を切り替えるために、この UniversalMaterialType
に何を指定するか注意する必要があります。
一方 Deferred Pass では次のように普通に ShaderLab 内の Stencil
ブロックで扱うシェーダを切り分けています。
Pass { Name "Deferred Directional Light (Lit)" ZTest NotEqual ZWrite Off Cull Off Blend One SrcAlpha, Zero One BlendOp Add, Add Stencil { Ref [_LitDirStencilRef] ReadMask [_LitDirStencilReadMask] WriteMask [_LitDirStencilWriteMask] Comp Equal Pass Keep Fail Keep ZFail Keep } HLSLPROGRAM #pragma exclude_renderers gles gles3 glcore #pragma target 4.5 #pragma multi_compile_fragment _DEFERRED_STENCIL #pragma multi_compile _DIRECTIONAL #pragma multi_compile_fragment _LIT #pragma multi_compile_fragment _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _DEFERRED_MAIN_LIGHT ... #pragma vertex Vertex #pragma fragment DeferredShading ENDHLSL } Pass { Name "Deferred Directional Light (SimpleLit)" ZTest NotEqual ZWrite Off Cull Off Blend One SrcAlpha, Zero One BlendOp Add, Add Stencil { Ref [_SimpleLitDirStencilRef] ReadMask [_SimpleLitDirStencilReadMask] WriteMask [_SimpleLitDirStencilWriteMask] Comp Equal Pass Keep Fail Keep ZFail Keep } HLSLPROGRAM #pragma exclude_renderers gles gles3 glcore #pragma target 4.5 #pragma multi_compile_fragment _DEFERRED_STENCIL #pragma multi_compile _DIRECTIONAL #pragma multi_compile_fragment _SIMPLELIT #pragma multi_compile_fragment _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _DEFERRED_MAIN_LIGHT ... #pragma vertex Vertex #pragma fragment DeferredShading ENDHLSL }
では、Lit
/ SimpleLit
で共通で使われているフラグメントシェーダ実装の DeferredShading
を見てみましょう。
half4 DeferredShading(Varyings input) : SV_Target { ... half4 gbuffer0 = SAMPLE_TEXTURE2D_X_LOD(_GBuffer0, ...); half4 gbuffer1 = SAMPLE_TEXTURE2D_X_LOD(_GBuffer1, ...); half4 gbuffer2 = SAMPLE_TEXTURE2D_X_LOD(_GBuffer2, ...); ... half3 color = 0.0.xxx; half alpha = 1.0; ... InputData inputData = InputDataFromGbufferAndWorldPosition(gbuffer2, posWS.xyz); ... #if defined(_LIT) BRDFData brdfData = BRDFDataFromGbuffer( gbuffer0, gbuffer1, gbuffer2); color = LightingPhysicallyBased( brdfData, unityLight, inputData.normalWS, inputData.viewDirectionWS, materialSpecularHighlightsOff); #elif defined(_SIMPLELIT) SurfaceData surfaceData = SurfaceDataFromGbuffer( gbuffer0, gbuffer1, gbuffer2, kLightingSimpleLit); half3 attenuatedLightColor = ...; half3 diffuseColor = ...; half smoothness = ...; half3 specularColor = ...; color = diffuseColor * surfaceData.albedo + specularColor; #endif return half4(color, alpha); }
こんな形で SimpleLit
側は単純な足し合わせの計算をしているだけのものになります。一方、Lit
の方は物理シェーディングを行っています。先程の GIF 画像では Directional ライトのみでしたが、Deferred の真骨頂はたくさんのライトがあるケースなのでそれも見てみます。
Deferred Punctual Light パスが実行されるようになりました。こちらも Lit
と Simple Lit
のパスが存在しています。これでなんとなく全体の流れが分かりましたね。
GBuffer3?
なお、GBuffer3 が見当たらない?と思うかもしれませんが、こちらは Legacy パスと同じで出力用のレンダーターゲットと共有されています。
ライトの計算時には Blend One One
で加算ブレンドとなっており、各ライトの影響が足されていくのが分かります。
このレンダーターゲットは Skybox 描画時も出力として指定され、最終的には Post Process 時に _SourceTex
として与えられ、最終的な画が作られる流れです。
Lit シェーダについて(追記: 2022/05/01)
主に SimpleLit
について見てきましたが、Lit
も見てみましたので少しだけですが追記です。
SimpleLit
でも Lit
でも入力パラメタから SurfaceData
を初期化するのは同じです。ただ詰める情報量が違っていまして次のような感じです。
// SimpleLit CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecColor; half4 _EmissionColor; half _Cutoff; half _Surface; CBUFFER_END // Lit CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; float4 _DetailAlbedoMap_ST; half4 _BaseColor; half4 _SpecColor; half4 _EmissionColor; half _Cutoff; half _Smoothness; half _Metallic; half _BumpScale; half _Parallax; half _OcclusionStrength; half _ClearCoatMask; half _ClearCoatSmoothness; half _DetailAlbedoMapScale; half _DetailNormalMapScale; half _Surface; CBUFFER_END
Lit
の方は金属やスムースネス、視差・ディテールマップやクリアコートなど色々な機能を変数としてサポートしていますね(クリアコートの適用は ComplexLit シェーダでのサポートですが)。この関係で SimpleLit
は InitializeSimpleLitSurfaceData()
で初期化していたところが Lit
では InitializeStandardLitSurfaceData()
になります。これは Forward レンダリングと共通化されています。
フラグメントシェーダを概観するとこんな感じになります。
FragmentOutput LitGBufferPassFragment(Varyings input) { ... SurfaceData surfaceData; InitializeStandardLitSurfaceData(input.uv, surfaceData); ... BRDFData brdfData; InitializeBRDFData( surfaceData.albedo, surfaceData.metallic, surfaceData.specular, surfaceData.smoothness, surfaceData.alpha, brdfData); Light mainLight = GetMainLight(...); MixRealtimeAndBakedGI(...); half3 color = GlobalIllumination( brdfData, inputData.bakedGI, surfaceData.occlusion, inputData.positionWS, inputData.normalWS, inputData.viewDirectionWS); return BRDFDataToGbuffer( brdfData, inputData, surfaceData.smoothness, surfaceData.emission + color, surfaceData.occlusion); }
シンプルなアルベドとスペキュラだけの計算部が BRDF によるものに置き換えられています。ちなみに GlobalIllumination()
も Forward レンダリングと共通の関数になっています。内部では URP は関数が小分けにされているのでわかりやすくて良いですね。
おわりに
端折って見ていった感じがありますが、全体像は薄っすらと見えてきましたね。Legacy の Deferred と比べてもだいぶ違うものになっているのが分かりました。今後、実際にどのようなゲームで使われていくのかも見ていきたいです。
次はここで学習したことをベースに uRaymarching の Deferred 周りのアップデートを行おうと思います。