はじめに
以前、Lightweight Render Pipeline(LWRP)と呼ばれていたパイプラインが 2019.3(SRP 7.0.0)から、Universal Render Pipeline(UniversalRP / URP)と改名されました。
私は LWRP 含む SRP をあまり調べていなかったのですが、配布しているライブラリの LWRP 対応要望もちらほら聞こえてきましたので、詳しく調べてみることにしました。が、いきなり体系的にまとめるのは自分も分かっていない点が多くかなり大変...なので、プロジェクトを作成し、以前のビルトインのパイプラインから変わったところ等を適当な順番で色々見ていくことにしました。本記事ではレンダリング回り(パイプラインやシェーダの変更点)について調べます(シェーダグラフは本記事では触れません)。URP そのものの使い方などについては余り触れません。
Scriptable Render Pipeline(SRP)についてのおさらいは(ちょっと古いですが)以下もご参照いただけると嬉しいです。
環境
- Untiy 2019.3.0b6
- URP 7.1.2
事前勉強
以下 UTJ 公式スライドを見ておくと理解が深まると思います。
またより詳細については公式ブログポストを見ておきましょう。
セットアップ
プロジェクトの作成
Unity 2019.3 では Universal RP のテンプレートが用意されているので、Unity Hub から選択してプロジェクトを作成します。すると以下のようなデフォルトのシーンが表示されます。

URP アセット
SRP ではパイプラインの設定はアセット化されています。これを Project Settings > Graphics でセットして使う流れです。

デフォルトでは UniversalRP-HighQuality が選択されているようなので、これを見てみましょう。

なにやら色々と設定項目がありますが...、これらは以下のマニュアルにまとまっています。
LWRP 時代と概ね同じようですが、私は以前のものをあまり見てないので順に気になったところを見ていきたいと思います。
General
Renderer List
レンダラを指定することが出来ます。現状は Forward Renderer と 2D Renderer しかないようですが、公式ブログによるとディファードも追加される見込みのようです。もちろん自分で作成して追加も可能だと思います。

レンダラでどういうことが出来るかは、応用した記事を読むとより理解が深まると思います:
DepthTexture / OpaqueTexture
以前は、Camera.depthTextureMode を適当なコンポーネントでセットしないと使えなかった _CameraDepthTexture はチェックボックスになっています。また、屈折などを表現するために必要だったレンダリング後の画は、従来はシェーダ内で GrabPass を設けてキャプチャしていましたが、こちらもチェックボックスで _CameraOpaqueTexture という形でとってこれるようになっています(半透明描画前の画になり、これを参照したシェーダによる表現を半透明のパスで利用できるという形です)。
HDR / MSAA / Render Scale
これらは従来と同じです。HDR をオンにすると明るいピクセルが 1.0 を超えて出力され、Bloom でいい感じに光が強いことを表現できます。MSAA を 2x、4x、8x と上げていくとアンチエイリアスのかかりがきれいになっていきます(負荷も増えていきます)。Render Scale はレンダーターゲットの解像度を動的に変更することが出来ます。ただ、UI は影響を受けずネイティブの解像度でレンダリングされるようです。ランタイムでポチポチ切り替えれば違いが分かるので試してみてください。
Lighting
組み込みの Forward ではライティングは決め打ちの挙動をしていました。
具体的にはライトは影響度の大きい順に 4 個までピクセル単位で(かつメイン以外は追加の Pass で)ライティングが行われ、残りは頂点単位または球面調和関数による近似でライティングが行われていました。しかし、URP では後で見ていきたいと思いますが、1 Pass でライトの計算を行います。その関係でこのあたりの設定が大きく変わっています。まず、Main Light はメインのディレクショナルライトを指します。メインのディレクショナルライトは、Lighting ウィンドウの Sun Source によって指定します。

設定しない場合は最も強いディレクショナルライトが採用される仕様のようです。オフにすることもできます(Per Pixel か Disabled が指定可能)。
追加のライトに関しては、Per Pixel、Disabled に加え、Per Vertex で頂点単位のライティングも選択可能です。また、以前は決め打ちだった数も 0 ~ 8 個の間で選択することが出来ます(繰り返しですが、数に依らず 1 Pass で計算されます)。
また、メイン・追加ライトともに影の設定があり、それぞれ異なるシャドウマップの解像度を指定できます。
Shadows
影に関しての設定は以前とそれほど変わりません。距離、カスケード、各バイアス(デプスと法線)、ソフトシャドウの設定がある形です。挙動の詳細はドキュメントに書いてあります。
Post-processing
カラーグレーディング用の微調整が可能で、計算を HDR / LDR どちらで行うかと、ルックアップテーブルの解像度の指定が可能です。そのうち他のポストプロセスの調整のパラメタも追加されるかもしれません。
Advanced
SRP Batcher はデフォルトで ON になっています。同一のシェーダを利用するマテリアルが大量にある場合に高速化が望めます。詳細はこちら:
Dynamic Batching は同一マテリアルのオブジェクトをバッチ処理するものですが、GPU インスタンシングが使える場合は必要ないので、デフォルトでは OFF になっています。Mixed Lighting はデフォルトで ON です。この他に、レンダリングパイプラインが生成するログレベルをセットする Debug Level と、シェーダバリアントのログレベルをセットする Shader Variant Log Level もここに含まれています。
シェーダ(前半)
さて、設定は分かったので、さっそくシェーダを見てみたいと思います。取り敢えずサーフェスシェーダを変換して見てみるか~、とサーフェスシェーダを適用したマテリアルを適用すると以下のようになります。

残念ながらサーフェスシェーダは URP では使えません。。URP 専用(対応)のシェーダが必要になります。組み込みのものは以下の Packages > Universal RP から参照することが出来ます。

まずはこの中にある Unlit シェーダを元にしたコードで解説していきたいと思います。
アウトラインの変更
まずアウトラインを見てみましょう。
Shader "Universal Render Pipeline/Unlit" { Properties { ... } SubShader { Tags { "RenderType" = "Opaque" "IgnoreProjector" = "True" "RenderPipeline" = "UniversalPipeline" } LOD 100 Blend [_SrcBlend][_DstBlend] ZWrite [_ZWrite] Cull [_Cull] Pass { Name "Unlit" HLSLPROGRAM ... ENDHLSL } Pass { Tags { "LightMode" = "DepthOnly" } ... } Pass { Name "Meta" Tags {"LightMode" = "Meta" } ... } } FallBack "Hidden/InternalErrorShader" }
基本的な構造は同じで、ShaderLab の文法に則っています。CGPROGRAM は HLSLPROGRAM に変更されているようです(Cg は NVIDIA 開発のシェーディング言語ですが、2012 年にサポート終了していて、現在は MS 開発の HLSL ベースなのでこれを機にリネーム*1したのでしょうか)。
SubShader ブロックで RenderPipeline を UniversalPipeline と指定しています。URP ではこの指定をされたブロックを利用して描画をするようです。URP が指定されておらず従来の組み込みのパイプラインを使う際にも動作させたい場合は、指定なしの SubShader ブロックを追加するか、従来の Standard シェーダへの Fallback の指定を行う、などの対応が必要なようです。
Pass ブロックはこのシェーダでは 3 つあり、それぞれカラーを出力するパス(LightMode は名無し)、デプスのみを出力する DepthOnly パス、ライトマップに情報を渡す Meta パス、となっています。
Unlit
では Unlit のパスを詳しく見ていきましょう。
Pass
{
Name "Unlit"
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma vertex vert
#pragma fragment frag
#pragma shader_feature _ALPHATEST_ON
#pragma shader_feature _ALPHAPREMULTIPLY_ON
#pragma multi_compile_fog
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/Shaders/UnlitInput.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float2 uv : TEXCOORD0;
float fogCoord : TEXCOORD1;
float4 vertex : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings vert(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);
output.vertex = vertexInput.positionCS;
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
output.fogCoord = ComputeFogFactor(vertexInput.positionCS.z);
return output;
}
half4 frag(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
half2 uv = input.uv;
half4 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);
half3 color = texColor.rgb * _BaseColor.rgb;
half alpha = texColor.a * _BaseColor.a;
AlphaDiscard(alpha, _Cutoff);
#ifdef _ALPHAPREMULTIPLY_ON
color *= alpha;
#endif
color = MixFog(color, input.fogCoord);
return half4(color, alpha);
}
ENDHLSL
}
いくつか変数・関数の名前、それらの使い方が変わっているところがあるので見ていきましょう。
pragma 文
フォグやインスタンシングなど、以前と同じ形です。
ひとつだけ今回の内容(URP)とは関係ないですが、見たことのなかった prefer_hlslcc gles というものがあるので調べてみました。Unity は HLSL を GLSL に変換するトランスパイラに数年前までは hlsl2glslfork を使っていましたが、DX9 の HLSL しか変換できないようで、現在は HLSLcc が使われているようです。ただ、GLES 2.0 に関してはどちらを使うかが選択できるようで、それがこの pragma 文のようです。
- Unity - Manual: Shading Language used in Unity
- https://forum.unity.com/threads/shader-compile-pipe-in-lightweight-rendepipeline.541809/
また、DX9 はサポートされていないので #pragma exclude_renderers されています。
変数・関数の違い
名前も色々と変わっています。appdata や v2f だった構造体も、Attributes や Varyings となっています。また、UnityObjectToClipPos() だったものは、GetVertexPositionInputs() に置き換わっています。これは ShaderLibrary の Core.hlsl で次のように定義されています。
VertexPositionInputs GetVertexPositionInputs(float3 positionOS)
{
VertexPositionInputs input;
input.positionWS = TransformObjectToWorld(positionOS);
input.positionVS = TransformWorldToView(input.positionWS);
input.positionCS = TransformWorldToHClip(input.positionWS);
float4 ndc = input.positionCS * 0.5f;
input.positionNDC.xy = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
input.positionNDC.zw = input.positionCS.zw;
return input;
}
色々な位置の変数がありますが...、これは座標系変換を考えてもらうとわかりやすいと思います。positionOS はオブジェクト空間(Object Space)、positionWS はワールド空間(World Space)、positionVS はビュー空間(View Space)、positionCS はクリップ空間(Clip Space)、そして positionNDC はデバイス正規化座標(Normalized Device Coordinates)の位置だと思います。
TransformObjectToWorld() 等の関数は、コア側(Core RP Library)の SpaceTransform.hlsl で次のように定義されています。
float4x4 GetObjectToWorldMatrix()
{
return UNITY_MATRIX_M;
}
float3 TransformObjectToWorld(float3 positionOS)
{
return mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)).xyz;
}
float4x4 GetWorldToViewMatrix()
{
return UNITY_MATRIX_V;
}
float3 TransformWorldToView(float3 positionWS)
{
return mul(GetWorldToViewMatrix(), float4(positionWS, 1.0)).xyz;
}
float4x4 GetWorldToHClipMatrix()
{
return UNITY_MATRIX_VP;
}
float4 TransformWorldToHClip(float3 positionWS)
{
return mul(GetWorldToHClipMatrix(), float4(positionWS, 1.0));
}
中身は以前と同じ UNITY_MATRIX_* の掛け算になっています。ここでは使われていませんが、従来の UnityObjectToClipPos() と同じ役割をする TransformObjectToHClip() もあります。以前、VR 向けのステレオインスタンシングの説明でもしたことがあるのですが、この UNITY_MATRIX_* にステレオインスタンシングの仕組みが仕込まれているので、UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO などの VR 向けの対応もそのまま出来ている形になります。
tex2D は SAMPLE_TEXTURE2D に置き換わっています。コア側の ShaderLibrary/API/*.hlsl に定義されていて、プラットフォームごとに場合分けされるようになっています。
// DX11 #define SAMPLE_TEXTURE2D(textureName, samplerName, coord2) textureName.Sample(samplerName, coord2) // GLES 2.0 #define SAMPLE_TEXTURE2D(textureName, samplerName, coord2) tex2D(textureName, coord2)
フォグは、UNITY_TRANSFER_FOG() と UNITY_APPLY_FOG() の組み合わせから、ComputeFogFactor() と MixFog() に変更されています。UNITY_APPLY_FOG は組み込みのフォワード用の処理(ベースパスか加算パスか、フォグタイプがリニアなのか exp なのか...など)の場合分けがあったので変更になっているようです。以前のフォグについてはこちら:
Lit シェーダ
さて、簡単なので(ちょい改変)Unlit なシェーダを見てきましたが、次に Lit シェーダを見ていきましょう。
アウトライン
Shader "Universal Render Pipeline/Lit" { Properties { ... } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "IgnoreProjector" = "True" } LOD 300 Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } ... } Pass { Name "ShadowCaster" Tags { "LightMode" = "ShadowCaster" } ... } Pass { Name "DepthOnly" Tags { "LightMode" = "DepthOnly" } ... } Pass { Name "Meta" Tags { "LightMode" = "Meta" } ... } Pass { Name "Universal2D" Tags { "LightMode" = "Universal2D" } ... } } FallBack "Hidden/InternalErrorShader" CustomEditor "UnityEditor.Rendering.Universal.ShaderGUI.LitShader" }
Lit シェーダでは、UniversalForward なパスと ShadowCaster パス、Universal2D なパスが追加されています。順に見ていきましょう。
UniversalForward
LightMode
SRP のおさらいですが、LightMode は SRP では自由に決めることが出来ます。UniversalForward は、C# 側から以下のようにセットされています(簡略化したコードです)。
public override void Execute( ScriptableRenderContext context, ref RenderingData renderingData) { ... var id = new ShaderTagId("UniversalForward"); var drawSettings = new DrawingSettings(id, sortSettings); context.DrawRenderers( renderingData.cullResults, ref drawSettings, ref m_FilteringSettings, ref m_RenderStateBlock); ... }
このように SRP では独自のパスを簡単にシェーダ側で用意して使えるのでした。
シェーダ外観
さて、この UniversalForward が URP のキモで、1 パスでライティングを行う部分になります。シェーダのコードを見てみます。
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
Blend [_SrcBlend][_DstBlend]
ZWrite [_ZWrite]
Cull [_Cull]
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma target 2.0
// マテリアルのキーワード
#pragma shader_feature _NORMALMAP
#pragma shader_feature _ALPHATEST_ON
#pragma shader_feature _ALPHAPREMULTIPLY_ON
#pragma shader_feature _EMISSION
#pragma shader_feature _METALLICSPECGLOSSMAP
#pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
#pragma shader_feature _OCCLUSIONMAP
#pragma shader_feature _SPECULARHIGHLIGHTS_OFF
#pragma shader_feature _ENVIRONMENTREFLECTIONS_OFF
#pragma shader_feature _SPECULAR_SETUP
#pragma shader_feature _RECEIVE_SHADOWS_OFF
// URP のキーワード
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile _ _SHADOWS_SOFT
#pragma multi_compile _ _MIXED_LIGHTING_SUBTRACTIVE
// Unity のキーワード
#pragma multi_compile _ DIRLIGHTMAP_COMBINED
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile_fog
// インスタンシング
#pragma multi_compile_instancing
#pragma vertex LitPassVertex
#pragma fragment LitPassFragment
#include "LitInput.hlsl"
#include "LitForwardPass.hlsl"
ENDHLSL
}
たくさんのフラグのセットがありますが、おおまかに以下のように分かれています。
- マテリアルのキーワード
- インスペクタから設定できるマテリアルに関するキーワード(下の画像参照)
- URP のキーワード
- 先程見た URP アセットでの設定に関連するキーワード
- Unity のキーワード
- Unity の設定で切り替えるキーワード(ライトマップとフォグ)
- インスタンシング
- インスタンシングの ON / OFF
実際のインスペクタを参照しながら見てみるとわかりやすいと思います。

なんとなくキーワードとの対応付は先程の URP アセットとこのマテリアルのインスペクタを見るとわかるような...気がしますが、では具体的にどこでこれらのキーワードをセットしているかというと、CustomEditor で指定されている LitShader を継承した BaseShaderGUI の中で次のように行われています。
public static void SetMaterialKeywords( Material material, Action<Material> shadingModelFunc = null, Action<Material> shaderFunc = null) { ... if (material.HasProperty("_BumpMap")) { CoreUtils.SetKeyword( material, "_NORMALMAP", material.GetTexture("_BumpMap")); } ... }
入力・出力構造体
さて、キーワードは外観が掴めたので、本丸の頂点・フラグメントシェーダを見ていきます。これらは LitForwardPass.hlsl に記述されていて、それを #include する形になっています。ちょっと長くなるのでまずは構造体から見ていきましょう:
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; float2 lightmapUV : TEXCOORD1; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float2 uv : TEXCOORD0; DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 1); #ifdef _ADDITIONAL_LIGHTS float3 positionWS : TEXCOORD2; #endif #ifdef _NORMALMAP float4 normalWS : TEXCOORD3; // xyz: normal, w: viewDir.x float4 tangentWS : TEXCOORD4; // xyz: tangent, w: viewDir.y float4 bitangentWS : TEXCOORD5; // xyz: bitangent, w: viewDir.z #else float3 normalWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; #endif half4 fogFactorAndVertexLight : TEXCOORD6; // x: fogFactor, yzw: vertex light #ifdef _MAIN_LIGHT_SHADOWS float4 shadowCoord : TEXCOORD7; #endif float4 positionCS : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO };
先程のキーワードに応じて場合分けするところがありますが、後にライティングに必要な変数が詰まっている感じで、従来のパイプラインと然程変わりありません。
頂点シェーダ
次に頂点シェーダを見ていきましょう。
Varyings LitPassVertex(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);
half3 viewDirWS = GetCameraPositionWS() - vertexInput.positionWS;
half3 vertexLight = VertexLighting(vertexInput.positionWS, normalInput.normalWS);
half fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
#ifdef _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 = NormalizeNormalPerVertex(normalInput.normalWS);
output.viewDirWS = viewDirWS;
#endif
OUTPUT_LIGHTMAP_UV(input.lightmapUV, unity_LightmapST, output.lightmapUV);
OUTPUT_SH(output.normalWS.xyz, output.vertexSH);
output.fogFactorAndVertexLight = half4(fogFactor, vertexLight);
#ifdef _ADDITIONAL_LIGHTS
output.positionWS = vertexInput.positionWS;
#endif
#if defined(_MAIN_LIGHT_SHADOWS) && !defined(_RECEIVE_SHADOWS_OFF)
output.shadowCoord = GetShadowCoord(vertexInput);
#endif
output.positionCS = vertexInput.positionCS;
return output;
}
以前と比べるとキレイに読みやすい関数に分かれています。使う関数は刷新されてますが、名前から何を処理して何を出力するかが分かりやすくなっています。以前と比べて大きな変更があるのは VertexLighting の部分です。これは Lighting.hlsl に次のように記述されています。
half3 VertexLighting(float3 positionWS, half3 normalWS)
{
half3 vertexLightColor = half3(0.0, 0.0, 0.0);
#ifdef _ADDITIONAL_LIGHTS_VERTEX
uint lightsCount = GetAdditionalLightsCount();
for (uint lightIndex = 0u; lightIndex < lightsCount; ++lightIndex)
{
Light light = GetAdditionalLight(lightIndex, positionWS);
half3 lightColor = light.color * light.distanceAttenuation;
vertexLightColor += LightingLambert(lightColor, light.direction, normalWS);
}
#endif
return vertexLightColor;
}
最初の方で見た URP のアセットの Lighting > Additional Lights > Main Light で Per Vertex を選んだ場合に for 文が回り、ライトの個数文だけランバートによるライティングが頂点単位で行われます。GetAdditionalLightsCount() は同アセットで指定した Per Object Limit の値が入ってきます*2。
GetAdditionalLight() は Light 構造体に情報を詰めてライティングの結果を返してくれます。少し長いですが、以下のような処理になっています:
struct Light { half3 direction; half3 color; half distanceAttenuation; half shadowAttenuation; }; Light GetAdditionalLight(uint i, float3 positionWS) { int perObjectLightIndex = GetPerObjectLightIndex(i); return GetAdditionalPerObjectLight(perObjectLightIndex, positionWS); } int GetPerObjectLightIndex(uint index) { uint offset = unity_LightData.x; return _AdditionalLightsIndices[offset + index]; } Light GetAdditionalPerObjectLight(int perObjectLightIndex, float3 positionWS) { // Structured Buffer(_AdditionalLightsBuffer)からライト情報を取り出す float4 lightPositionWS = _AdditionalLightsBuffer[perObjectLightIndex].position; half3 color = _AdditionalLightsBuffer[perObjectLightIndex].color.rgb; half4 distanceAndSpotAttenuation = _AdditionalLightsBuffer[perObjectLightIndex].attenuation; half4 spotDirection = _AdditionalLightsBuffer[perObjectLightIndex].spotDirection; half4 lightOcclusionProbeInfo = _AdditionalLightsBuffer[perObjectLightIndex].occlusionProbeChannels; // 距離と角度による減衰を計算 float3 lightVector = lightPositionWS.xyz - positionWS * lightPositionWS.w; float distanceSqr = max(dot(lightVector, lightVector), HALF_MIN); half3 lightDirection = half3(lightVector * rsqrt(distanceSqr)); half attenuation = DistanceAttenuation(distanceSqr, distanceAndSpotAttenuation.xy) * AngleAttenuation(spotDirection.xyz, lightDirection, distanceAndSpotAttenuation.zw); // 結果を Light 構造体に格納 Light light; light.direction = lightDirection; light.distanceAttenuation = attenuation; light.shadowAttenuation = AdditionalLightRealtimeShadow(perObjectLightIndex, positionWS); light.color = color; // ライトマップを反映 #if defined(LIGHTMAP_ON) || defined(_MIXED_LIGHTING_SUBTRACTIVE) int probeChannel = lightOcclusionProbeInfo.x; half lightProbeContribution = lightOcclusionProbeInfo.y; half probeOcclusionValue = unity_ProbesOcclusion[probeChannel]; light.distanceAttenuation *= max(probeOcclusionValue, lightProbeContribution); #endif return light; }
実際は StructuredBuffer が使えるプラットフォームとそうでないプラットフォームでの場合分けのコードがあるのでもう少し長いです。ちなみに _AdditionalLightsIndices や _AdditionalLightsBuffer は ForwardLight.cs でセットされています。
追記(2019/10/28)
@hecomi URPでの、ライトデータのStructuredBuffer使用ですが、現状 うまくハード切り分けできずに、全プラットフォームでCBuffer使用にフォールバックしていると思います。レイトレの邪魔にならないと良いのですが…。https://t.co/5JK0TVJlj2
— Katsuhiko Omori (@ekakiya) 2019年10月27日
Twitter で教えていただいたのですが、現状 Structured Buffer はいくつかの問題点から off になっているようで、次のコードが走るみたいです。
float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsColor[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsAttenuation[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsSpotDir[MAX_VISIBLE_LIGHTS];
half4 _AdditionalLightsOcclusionProbes[MAX_VISIBLE_LIGHTS];
Light GetAdditionalPerObjectLight(int perObjectLightIndex, float3 positionWS)
{
float4 lightPositionWS = _AdditionalLightsPosition[perObjectLightIndex];
half3 color = _AdditionalLightsColor[perObjectLightIndex].rgb;
half4 distanceAndSpotAttenuation = _AdditionalLightsAttenuation[perObjectLightIndex];
half4 spotDirection = _AdditionalLightsSpotDir[perObjectLightIndex];
half4 lightOcclusionProbeInfo = _AdditionalLightsOcclusionProbes[perObjectLightIndex];
...
}
フラグメントシェーダ
フラグメントシェーダを見てみましょう:
half4 LitPassFragment(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
SurfaceData surfaceData;
InitializeStandardLitSurfaceData(input.uv, surfaceData);
InputData inputData;
InitializeInputData(input, surfaceData.normalTS, inputData);
half4 color = UniversalFragmentPBR(
inputData,
surfaceData.albedo,
surfaceData.metallic,
surfaceData.specular,
surfaceData.smoothness,
surfaceData.occlusion,
surfaceData.emission,
surfaceData.alpha);
color.rgb = MixFog(color.rgb, inputData.fogCoord);
return color;
}
こちらはとてもシンプルです。ロジックとしては SurfaceData と InputData を組み立てて UniversalFragmentPBR に渡し、MixFog でフォグを処理している形です。
InitializeStandardLitSurfaceData は次のようにインスペクタでセットされた値を組み立てる関数です。
struct SurfaceData { half3 albedo; half3 specular; half metallic; half smoothness; half3 normalTS; half3 emission; half occlusion; half alpha; }; inline void InitializeStandardLitSurfaceData(float2 uv, out SurfaceData outSurfaceData) { half4 albedoAlpha = SampleAlbedoAlpha( uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap)); outSurfaceData.alpha = Alpha(albedoAlpha.a, _BaseColor, _Cutoff); half4 specGloss = SampleMetallicSpecGloss(uv, albedoAlpha.a); outSurfaceData.albedo = albedoAlpha.rgb * _BaseColor.rgb; #if _SPECULAR_SETUP outSurfaceData.metallic = 1.0h; outSurfaceData.specular = specGloss.rgb; #else outSurfaceData.metallic = specGloss.r; outSurfaceData.specular = half3(0.0h, 0.0h, 0.0h); #endif outSurfaceData.smoothness = specGloss.a; outSurfaceData.normalTS = SampleNormal( uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale); outSurfaceData.occlusion = SampleOcclusion(uv); outSurfaceData.emission = SampleEmission( uv, _EmissionColor.rgb, TEXTURE2D_ARGS(_EmissionMap, sampler_EmissionMap)); }
InitializeInputData の方はライティングに必要な InputData 構造体を、頂点シェーダから渡ってきた Varyings 構造体の変数から抽出して組み立てる関数です。
struct InputData { float3 positionWS; half3 normalWS; half3 viewDirectionWS; float4 shadowCoord; half fogCoord; half3 vertexLighting; half3 bakedGI; }; void InitializeInputData(Varyings input, half3 normalTS, out InputData inputData) { inputData = (InputData)0; #ifdef _ADDITIONAL_LIGHTS inputData.positionWS = input.positionWS; #endif #ifdef _NORMALMAP half3 viewDirWS = half3(input.normalWS.w, input.tangentWS.w, input.bitangentWS.w); inputData.normalWS = TransformTangentToWorld(normalTS, half3x3(input.tangentWS.xyz, input.bitangentWS.xyz, input.normalWS.xyz)); #else half3 viewDirWS = input.viewDirWS; inputData.normalWS = input.normalWS; #endif inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS); viewDirWS = SafeNormalize(viewDirWS); inputData.viewDirectionWS = viewDirWS; #if defined(_MAIN_LIGHT_SHADOWS) && !defined(_RECEIVE_SHADOWS_OFF) inputData.shadowCoord = input.shadowCoord; #else inputData.shadowCoord = float4(0, 0, 0, 0); #endif inputData.fogCoord = input.fogFactorAndVertexLight.x; inputData.vertexLighting = input.fogFactorAndVertexLight.yzw; inputData.bakedGI = SAMPLE_GI(input.lightmapUV, input.vertexSH, inputData.normalWS); }
そしてこれらの構造体を受け取ってライティングを行う本体が UniversalFragmentPBR() です。
half4 UniversalFragmentPBR(
InputData inputData,
half3 albedo,
half metallic,
half3 specular,
half smoothness,
half occlusion,
half3 emission,
half alpha)
{
BRDFData brdfData;
InitializeBRDFData(albedo, metallic, specular, smoothness, alpha, brdfData);
Light mainLight = GetMainLight(inputData.shadowCoord);
MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI, half4(0, 0, 0, 0));
half3 color = GlobalIllumination(brdfData, inputData.bakedGI, occlusion, inputData.normalWS, inputData.viewDirectionWS);
color += LightingPhysicallyBased(brdfData, mainLight, inputData.normalWS, inputData.viewDirectionWS);
#ifdef _ADDITIONAL_LIGHTS
uint pixelLightCount = GetAdditionalLightsCount();
for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex)
{
Light light = GetAdditionalLight(lightIndex, inputData.positionWS);
color += LightingPhysicallyBased(brdfData, light, inputData.normalWS, inputData.viewDirectionWS);
}
#endif
#ifdef _ADDITIONAL_LIGHTS_VERTEX
color += inputData.vertexLighting * brdfData.diffuse;
#endif
color += emission;
return half4(color, alpha);
}
PBR 部分の詳細は難しいのでスキップしますが、URP の PBR の計算の概要はマニュアルに書いてあります:
以前との差分として眺めてみると、組み込みのフォワードではメインのライトをここで処理して、加算パスで追加のライトによるライティングを処理していました。
URP では、まず GetMainLight() でメインのライトを取得し、これを使って LightingPhysicallyBased() を行います。そして Lighting > Additional Lights > Main Light で Per Pixel を選んだ場合(= _ADDITIONAL_LIGHTS が定義されている場合)は同一パス内で for 文が回り、追加のライト分だけ加えて LightingPhysicallyBased() が行われます。Per Vertex を選んでいた場合(= _ADDITIONAL_LIGHTS_VERTEX が定義されている場合)は、先程頂点シェーダの解説で見た計算結果を足し合わせる形になっています。
これでようやく「URP では追加のライト 8 個まで 1 パスで処理される」という全容が把握できました。
レンダリング全体像
シェーダの概要が把握できましたので、レンダリングパイプラインの方を見ていきましょう。
パイプラインの流れ
レンダリングの順序は ForwardRenderer.cs で次のように書かれています。
namespace UnityEngine.Rendering.Universal { public sealed class ForwardRenderer : ScriptableRenderer { public ForwardRenderer(ForwardRendererData data) : base(data) { ... m_MainLightShadowCasterPass = new MainLightShadowCasterPass(RenderPassEvent.BeforeRenderingShadows); m_AdditionalLightsShadowCasterPass = new AdditionalLightsShadowCasterPass(RenderPassEvent.BeforeRenderingShadows); m_DepthPrepass = new DepthOnlyPass(RenderPassEvent.BeforeRenderingPrepasses, RenderQueueRange.opaque, data.opaqueLayerMask); m_ScreenSpaceShadowResolvePass = new ScreenSpaceShadowResolvePass(RenderPassEvent.BeforeRenderingPrepasses, screenspaceShadowsMaterial); m_ColorGradingLutPass = new ColorGradingLutPass(RenderPassEvent.BeforeRenderingOpaques, data.postProcessData); m_RenderOpaqueForwardPass = new DrawObjectsPass("Render Opaques", true, RenderPassEvent.BeforeRenderingOpaques, RenderQueueRange.opaque, data.opaqueLayerMask, m_DefaultStencilState, stencilData.stencilReference); m_CopyDepthPass = new CopyDepthPass(RenderPassEvent.BeforeRenderingOpaques, copyDepthMaterial); m_DrawSkyboxPass = new DrawSkyboxPass(RenderPassEvent.BeforeRenderingSkybox); m_CopyColorPass = new CopyColorPass(RenderPassEvent.BeforeRenderingTransparents, samplingMaterial); m_RenderTransparentForwardPass = new DrawObjectsPass("Render Transparents", false, RenderPassEvent.BeforeRenderingTransparents, RenderQueueRange.transparent, data.transparentLayerMask, m_DefaultStencilState, stencilData.stencilReference); m_OnRenderObjectCallbackPass = new InvokeOnRenderObjectCallbackPass(RenderPassEvent.BeforeRenderingPostProcessing); m_PostProcessPass = new PostProcessPass(RenderPassEvent.BeforeRenderingPostProcessing, data.postProcessData); m_FinalPostProcessPass = new PostProcessPass(RenderPassEvent.AfterRenderingPostProcessing, data.postProcessData); m_CapturePass = new CapturePass(RenderPassEvent.AfterRendering); m_FinalBlitPass = new FinalBlitPass(RenderPassEvent.AfterRendering, blitMaterial); ... } ... public override void Setup(ScriptableRenderContext context, ref RenderingData renderingData) { ... if (mainLightShadows) EnqueuePass(m_MainLightShadowCasterPass); if (additionalLightShadows) EnqueuePass(m_AdditionalLightsShadowCasterPass); if (requiresDepthPrepass) { ... EnqueuePass(m_DepthPrepass); } ... } ... } ... }
小分けにクラス単位でパスが区切られていて、とてもカスタマイズしやすい感じになっています。Setup 内でこれら ScriptableRenderPass 継承で作成されたパスが EnqueuePass されて、レンダラの基底クラスのキューに積まれていく形になっています。
この流れを Frame Debugger で見てみると、次のような形になります:

シャドウマップ
まず URP アセットの設定に従ってメインライトのシャドウマップ(Render Main Shadowmap)と追加ライトのシャドウマップ(Render Additional Shadows)を作成します。先程見たとおり、シャドウは UniversalForward の頂点シェーダやフラグメントシェーダで使われるため事前に計算しておく必要があります。解像度などの設定は URP の設定によって変化します。
デプスプリパス
次にデプスプリパス(Zプリパス)のために DepthPrepass が走っています。これは不要なフラグメントシェーダの起動を避けるために良く使われるテクニックで、事前にデプスのみ求めた上で、最終的に画に影響の与えるフラグメントシェーダのみ起動することで、ライティングの計算など不要な計算負荷を避けられるものです。
スクリーンスペースのシャドウのテクスチャ作成
Resolve Shadows で、先程のシャドウマップを参照してスクリーンスペースでの影を表現するテクスチャを作成します。
LUT の作成
カラーグレーディング用の LUT(ルックアップテーブル)を作成するパスが差し込まれています。後のポストプロセス処理で参照されます。ポストプロセスの後でまた詳しく見ていきます。
不透明オブジェクトの描画
Render Opaques では DrawObjectsPass というクラスを使って不透明なオブジェクトの描画が行われます。このクラスは半透明オブジェクト描画と共通になります。ちょっとクラスを覗いてみましょう:
using System.Collections.Generic; namespace UnityEngine.Rendering.Universal.Internal { public class DrawObjectsPass : ScriptableRenderPass { FilteringSettings m_FilteringSettings; RenderStateBlock m_RenderStateBlock; List<ShaderTagId> m_ShaderTagIdList = new List<ShaderTagId>(); string m_ProfilerTag; bool m_IsOpaque; public DrawObjectsPass( string profilerTag, bool opaque, RenderPassEvent evt, RenderQueueRange renderQueueRange, LayerMask layerMask, StencilState stencilState, int stencilReference) { m_ProfilerTag = profilerTag; m_ShaderTagIdList.Add(new ShaderTagId("UniversalForward")); m_ShaderTagIdList.Add(new ShaderTagId("LightweightForward")); m_ShaderTagIdList.Add(new ShaderTagId("SRPDefaultUnlit")); renderPassEvent = evt; m_FilteringSettings = new FilteringSettings(renderQueueRange, layerMask); m_RenderStateBlock = new RenderStateBlock(RenderStateMask.Nothing); m_IsOpaque = opaque; if (stencilState.enabled) { m_RenderStateBlock.stencilReference = stencilReference; m_RenderStateBlock.mask = RenderStateMask.Stencil; m_RenderStateBlock.stencilState = stencilState; } } public override void Execute( ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(m_ProfilerTag); using (new ProfilingSample(cmd, m_ProfilerTag)) { context.ExecuteCommandBuffer(cmd); cmd.Clear(); // 対象のカメラ var camera = renderingData.cameraData.camera; // ソートフラグ(半透明 / 不透明で順番を変える) var sortFlags = (m_IsOpaque) ? renderingData.cameraData.defaultOpaqueSortFlags : SortingCriteria.CommonTransparent; // 描画対象(UniversalForward タグなど)やメインライト、 // バッチング、インスタンシグなどの設定を作成 var drawSettings = CreateDrawingSettings( m_ShaderTagIdList, ref renderingData, sortFlags); // 不透明オブジェクトの描画 // 描画対象は m_FilteringSettings でキューの範囲とレイヤで決定 // Unity 自体の機能(ScriptableRenderContext)を生でたたいている context.DrawRenderers( renderingData.cullResults, ref drawSettings, ref m_FilteringSettings, ref m_RenderStateBlock); // レガシーなパスしか持たないオブジェクトの描画 RenderingUtils.RenderObjectsWithError( context, ref renderingData.cullResults, camera, m_FilteringSettings, SortingCriteria.None); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } }
なにか手を入れたい場合はここらをいじれば良さそうです。
スカイボックスの描画
Camera.RenderSkybox でスカイボックスが描画されます。スカイボックスはデフォルトシーンでは Skybox/Procedural を使って描画されていて、パラメトリックに空をコントロールできるようになっています。

Camera の Background Type が Skybox のときのみ実行されます。
半透明オブジェクトの描画
Render Transparents では半透明オブジェクトの描画が行われます。先程も述べたように、不透明オブジェクトと同じクラスを使っています。ソーティング方法を変えたい場合などは sortFlags を書き換えたり外から与えられるようにしたりすれば良さそうです。
ポストプロセス
ポストプロセスは従来の外付けの Post-processeing Stack とは異なり、レンダリングパイプラインに結合された形になっています。本項冒頭のコードでも PostProcessPass をセットアップしていましたね。ポストプロセスについて詳しく書くと長くなってしまいそうなので、ここでは短く概要だけまとめます。
マニュアルはこちら:
URP のポストプロセスはボリューム(コライダ)ベースで複数配置することが出来、プライオリティやブレンド距離を指定してブレンドすることが出来ます。

このブレンド処理は VolumeManager というクラスが担当していて、すべてのボリュームを見てカメラごとにブレンドを行います。この結果を、まずは先程の LUT の構築の段で参照して、シェーダ側に教えて、必要な LUT を作成します。
そして、このポストプロセスの段では様々な設定を参照して各エフェクト(ブルームや DoF やカラー調整など)を実行する流れになっています。どのエフェクトが現在実行されているかは Frame Debugger から参照できます。各種エフェクトに関するマニュアルはこちら:
ファイナルポストプロセス
Frame Debugger を見ると最後に Render Final PostProcessing Pass というものがあります。ここは FinalPost.shader で処理されるところで、FXAA、グレイン、リニア -> SRGB 変換、ディザなどの処理が入っています。GIF でここを選択すると明るくなるのはリニアからの変換が入っているからです。
以上がレンダリングパイプラインの全体像となります。
シェーダ(後半)
レンダリングパイプライン全体像が理解できたかと思いますので、触れていなかった残りの 2 つのパスである ShadowCaster と DepthOnly を見ていきましょう。頂点シェーダで変形させたりするエフェクトを作りたい場合はこれらを書き換える必要があります。
ShadowCaster パス
... struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float2 uv : TEXCOORD0; float4 positionCS : SV_POSITION; }; float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) { float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS)); float scale = invNdotL * _ShadowBias.y; positionWS = lightDirection * _ShadowBias.xxx + positionWS; positionWS = normalWS * scale.xxx + positionWS; return positionWS; } float4 GetShadowPositionHClip(Attributes input) { float3 positionWS = TransformObjectToWorld(input.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(input.normalOS); float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, _LightDirection)); #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, positionCS.w * UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, positionCS.w * UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } Varyings ShadowPassVertex(Attributes input) { Varyings output; UNITY_SETUP_INSTANCE_ID(input); output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); output.positionCS = GetShadowPositionHClip(input); return output; } half4 ShadowPassFragment(Varyings input) : SV_TARGET { Alpha( SampleAlbedoAlpha( input.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap) ).a, _BaseColor, _Cutoff); return 0; } ...
頂点シェーダではバイアスをかけるために GetShadowPositionHClip() を定義して位置を調整してます。フラグメントシェーダではアルファテスト用に Alpha() をかましています。当たり前かもですが、関数とかは変わっていますが処理は以前と同じです。
DepthOnly パス
DepthOnly パスもとてもシンプルです。
... struct Attributes { float4 position : POSITION; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float2 uv : TEXCOORD0; float4 positionCS : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; Varyings DepthOnlyVertex(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.position.xyz); return output; } half4 DepthOnlyFragment(Varyings input) : SV_TARGET { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); Alpha( SampleAlbedoAlpha( input.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap) ).a, _BaseColor, _Cutoff); return 0; } ...
同じくアルファテスト用にフラグメントシェーダのコードは同じですが、VR 対応用にマクロが 1 行はさまっています。頂点シェーダは単に TransformObjectToHClip() でクリップ空間の座標を出力しているだけですね。
これですべてのパスを見ることが出来ました。
その他
Universal2D については 2D ゲーム周りを今後触る機会が出てきたら調べようと思います(ないかも...)。
おわりに
今回は URP のレンダリングやシェーダの全体像をつかむために記事を書きました。レンダリングパイプラインも理解しやすいものになり、シェーダも変更点さえ押さえればむしろシンプルに記述できる気がします。これから uRaymarching の URP サポートに取り掛かるので、別途また気づいた点があれば追記していきます。
参考
*1:https://forum.unity.com/threads/about-hlslprogram-vs-cgprogram.566620/
*2:正確には、unity_LightData と _AdditionalLightsCount の小さい方が使われ、前者はエンジン内部で、後者は ForwardLight.cs から与えられます