GameTech/URP 渲染实战

案例 10.1最小 Lit:自己喂数据给 UniversalFragmentPBR进阶

目标:写一个只有一百多行的 Lit Shader:自己填 SurfaceData 和 InputData,直接调用 URP 的 UniversalFragmentPBR,光照结果与 URP Lit 一致;同时提供 ShadowCaster、DepthOnly、DepthNormals Pass,并保持 SRP Batcher 兼容。

思路:三个以上的 Pass 要共享同一份 UnityPerMaterial,最稳妥的做法是把 CBUFFER 写进一个 hlsl,所有 Pass 都 include 它。阴影和深度 Pass 不必手写:URP 的 ShadowCasterPass.hlsl、DepthOnlyPass.hlsl 只依赖 _BaseMap、_BaseColor、_Cutoff 这几个名字(_ALPHATEST_ON 时才用到),先 include 我们的输入文件再 include 它们即可。

Assets/Cookbook/Ch10/MinimalLitInput.hlsl
#ifndef COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED
#define COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED

// “最小 Lit”的材质输入。三个 Pass(ForwardLit / ShadowCaster / DepthOnly)共用这一份,
// 保证 UnityPerMaterial 布局完全一致 —— 这是 SRP Batcher 兼容的前提。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// SurfaceInput.hlsl 声明了 _BaseMap / _BumpMap / _EmissionMap 以及 SampleAlbedoAlpha、SampleNormal、Alpha 等辅助函数,
// URP 的 ShadowCasterPass.hlsl、DepthOnlyPass.hlsl 也依赖这些名字。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"

CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half4  _EmissionColor;
    half   _Cutoff;        // ShadowCasterPass / DepthOnlyPass 在 _ALPHATEST_ON 时会用到
    half   _Metallic;
    half   _Smoothness;
    half   _BumpScale;
CBUFFER_END

#endif
#ifndef COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED
#define COOKBOOK_CH10_MINIMAL_LIT_INPUT_INCLUDED

// “最小 Lit”的材质输入。(GLSL 版,对应 Ch10/MinimalLitInput.hlsl)
// 三个 Pass(ForwardLit / ShadowCaster / DepthOnly)共用这一份,
// 保证 UnityPerMaterial 布局完全一致 —— 这是 SRP Batcher 兼容的前提。
// HLSL 版 include 了 SurfaceInput.hlsl(声明 _BaseMap / _BumpMap / _EmissionMap 以及 SampleAlbedoAlpha、SampleNormal、Alpha);
// GLSL 没有 TEXTURE2D_ARGS 这种“贴图 + 采样器”参数宏,下面直接写出这几个函数,采样固定的那张贴图。

uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform vec4  _EmissionColor;
uniform float _Cutoff;        // ShadowCasterPass / DepthOnlyPass 在 _ALPHATEST_ON 时会用到
uniform float _Metallic;
uniform float _Smoothness;
uniform float _BumpScale;

// ---- SurfaceInput.hlsl ----
uniform sampler2D _BaseMap;
uniform sampler2D _BumpMap;
uniform sampler2D _EmissionMap;

vec4 SampleAlbedoAlpha(vec2 uv) { return texture(_BaseMap, uv); }

float Alpha(float albedoAlpha, vec4 color, float cutoff)
{
    float alpha = albedoAlpha * color.a;
#ifdef _ALPHATEST_ON
    if (alpha - cutoff < 0.0) discard;
#endif
    return alpha;
}

vec3 SampleNormal(vec2 uv, float scale)
{
#ifdef _NORMALMAP
    return UnpackNormalScale(texture(_BumpMap, uv), scale);
#else
    return vec3(0.0, 0.0, 1.0);
#endif
}

#endif

ForwardLit Pass 的关键字从 Lit 里挑出实时光照真正需要的部分(光照贴图、Light Cookie、Rendering Layers、DBuffer、Debug Display 等省略):

Assets/Cookbook/Ch10/MinimalLit.shader(节选:keywords)
// 材质关键字:有法线贴图才开(脚本创建材质时要自己 EnableKeyword)
#pragma shader_feature_local _NORMALMAP
// 管线关键字:与 URP 14 Lit.shader 保持同名,管线会按设置自动打开
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _FORWARD_PLUS
#pragma multi_compile_fog
#pragma multi_compile_instancing
// 材质关键字:有法线贴图才开(脚本创建材质时要自己 EnableKeyword)
#pragma shader_feature_local _NORMALMAP
// 管线关键字(主光阴影、附加光、软阴影、SSAO、雾):网页版引擎始终提供,不用声明;
// 反射探针混合/盒体投影、Forward+、GPU Instancing 网页版没有

核心是两个“填表”函数。SurfaceData 按金属度工作流填写,specular 留 0:

Assets/Cookbook/Ch10/MinimalLit.shader(节选:surface)
SurfaceData BuildSurfaceData(float2 uv)
{
    SurfaceData s = (SurfaceData)0;
    half4 albedoAlpha = SampleAlbedoAlpha(uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
    s.albedo     = albedoAlpha.rgb * _BaseColor.rgb;
    s.alpha      = 1.0;                      // 不透明
    s.metallic   = _Metallic;                // 金属度工作流:specular 留 0
    s.specular   = 0;
    s.smoothness = _Smoothness;
    s.normalTS   = SampleNormal(uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
    s.occlusion  = 1.0;
    s.emission   = _EmissionColor.rgb;
    s.clearCoatMask = 0;
    s.clearCoatSmoothness = 0;
    return s;
}
SurfaceData BuildSurfaceData(vec2 uv)
{
    SurfaceData s = InitSurfaceData();
    vec4 albedoAlpha = SampleAlbedoAlpha(uv);
    s.albedo     = albedoAlpha.rgb * _BaseColor.rgb;
    s.alpha      = 1.0;                      // 不透明
    s.metallic   = _Metallic;                // 金属度工作流:specular 留 0
    s.specular   = vec3(0.0);
    s.smoothness = _Smoothness;
    s.normalTS   = SampleNormal(uv, _BumpScale);
    s.occlusion  = 1.0;
    s.emission   = _EmissionColor.rgb;
    s.clearCoatMask = 0.0;
    s.clearCoatSmoothness = 0.0;
    return s;
}

InputData 的写法与 InitializeInputData 一致,有三处简化:阴影坐标总是逐像素计算(省掉一个插值器,代价是每像素一次矩阵乘法);环境漫反射直接逐像素 SampleSH;ShadowMask 设为全 1(没有光照贴图):

Assets/Cookbook/Ch10/MinimalLit.shader(节选:inputdata)
InputData BuildInputData(Varyings i, half3 normalTS)
{
    InputData d = (InputData)0;
    d.positionWS = i.positionWS;
    d.positionCS = i.positionCS;

#if defined(_NORMALMAP)
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    d.tangentToWorld = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    d.normalWS = TransformTangentToWorld(normalTS, d.tangentToWorld);
#else
    d.normalWS = i.normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);

    d.shadowCoord = TransformWorldToShadowCoord(i.positionWS);   // 逐像素算级联,省一个插值器
    d.fogCoord = i.fogFactor;
    d.vertexLighting = 0;
    d.bakedGI = SampleSH(d.normalWS);                            // 无光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    d.shadowMask = half4(1, 1, 1, 1);
    return d;
}
InputData BuildInputData(vec3 normalTS)
{
    InputData d = InitInputData();
    d.positionWS = positionWS;
    d.positionCS = gl_FragCoord;

#if defined(_NORMALMAP)
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    d.tangentToWorld = mat3(tangentWS.xyz, bitangent, normalWS);   // GLSL 的 mat3 按列构造:列 = T、B、N
    d.normalWS = TransformTangentToWorld(normalTS, d.tangentToWorld);
#else
    d.normalWS = normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);

    d.shadowCoord = TransformWorldToShadowCoord(positionWS);   // 逐像素算级联,省一个插值器
    d.fogCoord = fogFactor;
    d.vertexLighting = vec3(0.0);
    d.bakedGI = SampleSH(d.normalWS);                          // 无光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    d.shadowMask = vec4(1.0);
    return d;
}
Assets/Cookbook/Ch10/MinimalLit.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    SurfaceData surfaceData = BuildSurfaceData(i.uv);
    InputData inputData = BuildInputData(i, surfaceData.normalTS);

    half4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    return color;
}
void main()
{
    SurfaceData surfaceData = BuildSurfaceData(uv);
    InputData inputData = BuildInputData(surfaceData.normalTS);

    vec4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);
    fragColor = color;
}

注意变量名必须叫 inputData——原因见上一节 Forward+ 的 LIGHT_LOOP_BEGIN。最后是三个辅助 Pass:

Assets/Cookbook/Ch10/MinimalLit.shader(节选:shadowpass)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex ShadowPassVertex
    #pragma fragment ShadowPassFragment
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // 先 include 自己的输入(CBUFFER 与 ForwardLit 相同),再直接复用 URP 的阴影投射 Pass
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
    ENDHLSL
}

Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex DepthOnlyVertex
    #pragma fragment DepthOnlyFragment
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
    ENDHLSL
}

Pass
{
    // 开了 SSAO(Depth Normals 模式)或其他需要 _CameraNormalsTexture 的功能时会用到
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex DepthNormalsVertex
    #pragma fragment DepthNormalsFragment
    #pragma shader_feature_local _NORMALMAP
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch10/MinimalLitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0

    GLSLPROGRAM
    // 先 include 自己的输入(uniform 与 ForwardLit 相同),再复用 URP 的阴影投射 Pass(ShadowCasterPass.hlsl 的 GLSL 写法)
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()   // ShadowPassVertex
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        gl_Position = GetShadowPositionHClip(positionOS.xyz, normalOS);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // ShadowPassFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}

Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R

    GLSLPROGRAM
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()   // DepthOnlyVertex
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        gl_Position = TransformObjectToHClip(positionOS.xyz);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // DepthOnlyFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
        fragColor = vec4(gl_FragCoord.z);
    }
    #endif
    ENDGLSL
}

Pass
{
    // 开了 SSAO(Depth Normals 模式)或其他需要 _CameraNormalsTexture 的功能时会用到
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On

    GLSLPROGRAM
    #pragma shader_feature_local _NORMALMAP
    #include "Ch10/MinimalLitInput.glsl"
    VARYING vec2 uv;
    VARYING vec3 normalWS;
    VARYING vec4 tangentWS;
    #ifdef VERTEX
    void main()   // DepthNormalsVertex(LitDepthNormalsPass.hlsl)
    {
        uv = TransformTex(texcoord0.xy, _BaseMap_ST);
        VertexNormalInputs n = GetVertexNormalInputs(normalOS, tangentOS);
        normalWS = n.normalWS;
        tangentWS = vec4(n.tangentWS, tangentOS.w * GetOddNegativeScale());
        gl_Position = TransformObjectToHClip(positionOS.xyz);
    }
    #endif
    #ifdef FRAGMENT
    void main()   // DepthNormalsFragment
    {
        Alpha(SampleAlbedoAlpha(uv).a, _BaseColor, _Cutoff);
    #ifdef _NORMALMAP
        vec3 nTS = SampleNormal(uv, _BumpScale);
        vec3 b = tangentWS.w * cross(normalWS, tangentWS.xyz);
        vec3 n = normalize(mat3(tangentWS.xyz, b, normalWS) * nTS);
    #else
        vec3 n = normalize(normalWS);
    #endif
        fragColor = vec4(n, 0.0);
    }
    #endif
    ENDGLSL
}
图 10-3左:URP Lit;右:本案例的 MinimalLit。同一场景(方向光 + 橙色点光 + 程序化天空),金属球、塑料球、带法线贴图的砖块和地面都换成了对应 Shader,结果几乎逐像素一致,阴影也正确投射与接收。
扩展方向想加 Mask Map,就在 BuildSurfaceData 里采样一张贴图,把 R、G、A 分别填进 metallic、occlusion、smoothness;想支持光照贴图,就照 Lit 加上 LIGHTMAP_ON 等关键字、DECLARE_LIGHTMAP_OR_SH 插值器和 SAMPLE_GI,再补一个 Meta Pass。Shader Graph 的 Lit Target 生成的代码,本质上也是这个结构。
常见坑① 不要用 UsePass "Universal Render Pipeline/Lit/ShadowCaster" 偷懒:被引用 Pass 的 CBUFFER 是 Lit 的布局,与你的 ForwardLit 不同,整个 Shader 会失去 SRP Batcher 兼容性(材质 Inspector 顶部会提示原因)。② 漏掉 _FORWARD_PLUS,Forward+ 渲染器下附加光不亮。③ 漏掉 _MAIN_LIGHT_SHADOWS_SCREEN 不会报错,但开启屏幕空间阴影(Screen Space Shadows Renderer Feature)后会选到“无阴影”变体——本案例为简洁省略了它,需要时照 Lit 补上,并像 GetShadowCoord 那样为它计算屏幕坐标。