GameTech/URP 渲染实战

案例 11.3Forward Pass:顶点、InputData 与片元进阶

目标:写出 ForwardLit Pass 的顶点、片元函数,并理解为什么我们要填 URP 的 InputData 结构体。

Assets/Cookbook/Ch11/MyPBR.shader(节选:pass_forward)
Pass
{
    Name "ForwardLit"
    Tags { "LightMode"="UniversalForward" }
    ZWrite On
    Cull Back

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyPBRVert
    #pragma fragment MyPBRFrag

    // ---- 材质关键字(只编译用到的组合)----
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _DFG_LUT
    #pragma shader_feature_local_fragment _ALPHATEST_ON

    // ---- URP 管线关键字(抄自 URP 14 Lit.shader 的 ForwardLit Pass)----
    #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
    #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
    #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
    #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
    #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
    #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
    #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
    #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
    #pragma multi_compile_fragment _ _LIGHT_COOKIES
    #pragma multi_compile _ _LIGHT_LAYERS
    #pragma multi_compile _ _FORWARD_PLUS
    #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl"

    // ---- Unity 内置关键字:光照贴图、雾、调试 ----
    #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING
    #pragma multi_compile _ SHADOWS_SHADOWMASK
    #pragma multi_compile _ DIRLIGHTMAP_COMBINED
    #pragma multi_compile _ LIGHTMAP_ON
    #pragma multi_compile_fog
    #pragma multi_compile_fragment _ DEBUG_DISPLAY
    #pragma multi_compile_instancing

    #include "Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "ForwardLit"
    Tags { "LightMode"="UniversalForward" }
    ZWrite On
    Cull Back

    GLSLPROGRAM
    // ---- 材质关键字(只编译用到的组合)----
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _DFG_LUT
    #pragma shader_feature_local_fragment _ALPHATEST_ON

    // ---- URP 管线关键字:_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_SHADOWS_SOFT 等网页版引擎始终打开 ----
    // ---- 调试:网页版模拟的 Rendering Debugger ----
    #pragma multi_compile_fragment _ DEBUG_DISPLAY

    #include "Ch11/MyPBR_ForwardPass.glsl"
    #include "Ch11/DebugDisplay.glsl"

    #ifdef VERTEX
    void main() { MyPBRVert(); }
    #endif

    #ifdef FRAGMENT
    void main()
    {
    #if defined(DEBUG_DISPLAY)
        // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR:
        // 把我们的表面参数交给调试函数(HLSL 版是翻译成 URP 的 SurfaceData 再交给 CanDebugOverrideOutputColor)
        MySurface s  = MySampleSurface(uv);
        InputData id = MyInitInputData(s.normalTS);
        MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);
        vec4 debugColor;
        if (CanDebugOverrideOutputColor(s.albedo, s.alpha, m.f0, s.metallic, 1.0 - s.perceptualRoughness,
                                        s.occlusion, s.emission, id.normalWS, debugColor))
        {
            fragColor = debugColor;
            return;
        }
    #endif
        fragColor = MyPBRFrag();
    }
    #endif
    ENDGLSL
}

关键字列表对照 URP 14 的 Shaders/Lit.shader 的 ForwardLit Pass 抄写,然后删掉我们不支持的部分(视差、细节贴图、透明混合、清漆、DOTS Instancing、实时 GI 的 DYNAMICLIGHTMAP_ON)。每一行 multi_compile 都对应 URP Asset 或场景里的一个开关:主光阴影三选一、附加光逐像素/逐顶点、软阴影档位、SSAO、反射探针混合与盒体投影、Light Cookie、Light Layers、Forward+。少声明一个,对应功能在这个 Shader 上就“静默失效”;多声明一个,变体数翻倍。c29 会讲如何统计和剥离变体。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:structs)
struct Attributes
{
    float4 positionOS        : POSITION;
    float3 normalOS          : NORMAL;
    float4 tangentOS         : TANGENT;
    float2 texcoord          : TEXCOORD0;
    float2 staticLightmapUV  : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS  : SV_POSITION;
    float2 uv          : TEXCOORD0;
    float3 positionWS  : TEXCOORD1;
    half3  normalWS    : TEXCOORD2;
#if defined(_NORMALMAP)
    half4  tangentWS   : TEXCOORD3;    // xyz 切线,w 副切线符号
#endif
    half   fogFactor   : TEXCOORD4;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    half3  vertexLight : TEXCOORD5;
#endif
    DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 6);   // 有光照贴图时是 lightmap UV,否则是逐顶点 SH
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};
// WebGL 不允许结构体做 varying:HLSL 的 Varyings 拆成单独的变量(顶点输入 Attributes 由引擎固定命名:positionOS、normalOS……)
VARYING vec2  uv;
VARYING vec3  positionWS;
VARYING vec3  normalWS;
#if defined(_NORMALMAP)
VARYING vec4  tangentWS;      // xyz 切线,w 副切线符号
#endif
VARYING float fogFactor;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
VARYING vec3  vertexLight;
#endif
// 光照贴图 UV / 逐顶点 SH:网页版没有光照贴图,SH 在片元里逐像素求
Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:vert)
Varyings MyPBRVert(Attributes input)
{
    Varyings o = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs   nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    o.positionCS = pos.positionCS;
    o.positionWS = pos.positionWS;
    o.uv         = TRANSFORM_TEX(input.texcoord, _BaseMap);
    o.normalWS   = nrm.normalWS;
#if defined(_NORMALMAP)
    // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正
    o.tangentWS  = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
#endif
    o.fogFactor  = ComputeFogFactor(pos.positionCS.z);

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点
    half3 vl = 0;
    uint n = GetAdditionalLightsCount();
    for (uint i = 0u; i < n; ++i)
    {
        Light l = GetAdditionalLight(i, pos.positionWS);
        vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction));
    }
    o.vertexLight = vl;
#endif

    OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, o.staticLightmapUV);
    OUTPUT_SH(o.normalWS, o.vertexSH);
    return o;
}
#ifdef VERTEX
void MyPBRVert()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    VertexNormalInputs   nrm = GetVertexNormalInputs(normalOS, tangentOS);

    gl_Position = pos.positionCS;
    positionWS  = pos.positionWS;
    uv          = TransformTex(texcoord0.xy, _BaseMap_ST);
    normalWS    = nrm.normalWS;
#if defined(_NORMALMAP)
    // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正
    tangentWS   = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
#endif
    fogFactor   = ComputeFogFactor(pos.positionCS.z);

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点
    vec3 vl = vec3(0.0);
    int n = GetAdditionalLightsCount();
    for (int i = 0; i < n; ++i)
    {
        Light l = GetAdditionalLight(i, pos.positionWS);
        vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction));
    }
    vertexLight = vl;
#endif
}
#endif

顶点函数用 GetVertexPositionInputs / GetVertexNormalInputs 一次拿到各个空间的位置和 TBN。DECLARE_LIGHTMAP_OR_SH 是 URP 的宏:开了 LIGHTMAP_ON 时声明一个 lightmap UV,否则声明一个逐顶点 SH 插值器;配套的 OUTPUT_LIGHTMAP_UV / OUTPUT_SH 负责填值,在“SH 逐像素”模式下 OUTPUT_SH 什么都不做。逐顶点附加光(URP Asset 中 Additional Lights 设为 Per Vertex 时)我们自己写了一个 Lambert 循环——它只累加 color × 衰减 × NoL,到片元里再乘漫反射色,π 恰好与 1/π 抵消。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:input_data)
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器,
// 填好它,阴影、SSAO、Forward+ 的分簇循环、Rendering Debugger 都能直接用
InputData MyInitInputData(Varyings i, half3 normalTS)
{
    InputData d = (InputData)0;
    d.positionWS = i.positionWS;
    d.positionCS = i.positionCS;
#if defined(_NORMALMAP)
    float  sgn = i.tangentWS.w;
    float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz);
    half3x3 tbn = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    d.tangentToWorld = tbn;
    d.normalWS = TransformTangentToWorld(normalTS, tbn);
#else
    d.normalWS = i.normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);   // 插值后长度不为 1,必须重新归一化
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    d.shadowCoord = TransformWorldToShadowCoord(i.positionWS); // 逐像素算级联索引,级联/屏幕空间阴影都适用
    d.fogCoord = InitializeInputDataFog(float4(i.positionWS, 1.0), i.fogFactor);
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    d.vertexLighting = i.vertexLight;
#endif
    d.bakedGI = SAMPLE_GI(i.staticLightmapUV, i.vertexSH, d.normalWS);   // 光照贴图或 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    d.shadowMask = SAMPLE_SHADOWMASK(i.staticLightmapUV);
#if defined(DEBUG_DISPLAY)
    #if defined(LIGHTMAP_ON)
    d.staticLightmapUV = i.staticLightmapUV;
    #else
    d.vertexSH = i.vertexSH;
    #endif
#endif
    return d;
}
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器,
// 填好它,阴影、SSAO、附加光循环都能直接用
InputData MyInitInputData(vec3 normalTS)
{
    InputData d = InitInputData();
    d.positionWS = positionWS;
    d.positionCS = gl_FragCoord;
#if defined(_NORMALMAP)
    float sgn = tangentWS.w;
    vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz);
    mat3 tbn = mat3(tangentWS.xyz, bitangent, normalWS);   // GLSL 的 mat3 按列构造:列就是 T/B/N
    d.tangentToWorld = tbn;
    d.normalWS = TransformTangentToWorld(normalTS, tbn);
#else
    d.normalWS = normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);   // 插值后长度不为 1,必须重新归一化
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    d.shadowCoord = TransformWorldToShadowCoord(positionWS);
    d.fogCoord = fogFactor;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    d.vertexLighting = vertexLight;
#endif
    d.bakedGI = SampleSH(d.normalWS);                     // 网页版没有光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    d.shadowMask = vec4(1.0);
    return d;
}

InputData 是 URP 定义的“像素几何与环境数据”的容器,里面没有任何 BRDF 内容。填好它有三个直接收益:GetMainLight(inputData, shadowMask, aoFactor) 会自动处理阴影、Light Cookie 和 SSAO 的直接光部分;Forward+ 的 LIGHT_LOOP_BEGIN 宏内部会读 inputData.normalizedScreenSpaceUV 和 inputData.positionWS;Rendering Debugger 的调试函数也以它为输入。

阴影坐标我们逐像素调用 TransformWorldToShadowCoord。URP Lit 在“单级联”时会在顶点里算好再插值以省一点 ALU,但级联阴影必须逐像素选级联,屏幕空间阴影则需要屏幕坐标——TransformWorldToShadowCoord 内部已经按 _MAIN_LIGHT_SHADOWS_CASCADE、_MAIN_LIGHT_SHADOWS_SCREEN 分好了支,逐像素调用在所有模式下都正确,代价是一次矩阵乘法。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:frag)
half4 MyPBRFrag(Varyings i
#if defined(_WRITE_RENDERING_LAYERS)
    , out float4 outRenderingLayers : SV_Target1
#endif
    ) : SV_Target0
{
    UNITY_SETUP_INSTANCE_ID(i);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);

    MySurface s  = MySampleSurface(i.uv);
    InputData id = MyInitInputData(i, s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

#if defined(DEBUG_DISPLAY)
    // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR:
    // 把我们的表面参数翻译成 URP 的 SurfaceData,交给它的调试函数
    SurfaceData sd = (SurfaceData)0;
    sd.albedo = s.albedo;  sd.metallic = s.metallic;  sd.specular = m.f0;
    sd.smoothness = 1.0h - s.perceptualRoughness;     sd.normalTS = s.normalTS;
    sd.emission = s.emission;  sd.occlusion = s.occlusion;  sd.alpha = s.alpha;
    id.brdfDiffuse = m.diffuseColor;  id.brdfSpecular = m.f0;
    half4 debugColor;
    if (CanDebugOverrideOutputColor(id, sd, debugColor))
        return debugColor;
#endif

    MyLightingTerms t = MyComputeLighting(s, m, id);
    float3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission;
    color = MixFog(color, id.fogCoord);

#if defined(_WRITE_RENDERING_LAYERS)
    outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0);
#endif
    return half4(color, 1.0);
}
vec4 MyPBRFrag()
{
    MySurface s  = MySampleSurface(uv);
    InputData id = MyInitInputData(s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

    // HLSL 版在 DEBUG_DISPLAY 下把表面参数交给 Rendering Debugger;网页版的调试视图见 Ch11/MyPBRDebug.glsl
    MyLightingTerms t = MyComputeLighting(s, m, id);
    vec3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission;
    color = MixFog(color, id.fogCoord);
    return vec4(color, 1.0);
}

片元函数的主干只有四行:读表面 → 填 InputData → 推导 BRDF 参数 → 计算光照。DEBUG_DISPLAY 分支把我们的 MySurface 翻译成 URP 的 SurfaceData,交给 CanDebugOverrideOutputColor(摘自 URP 14 ShaderLibrary/Debug/Debugging3D.hlsl,我们用的是不需要 BRDFData 的三参数版本)。有了这几行,Window › Analysis › Rendering Debugger 里的 Albedo、Smoothness、法线等材质视图和材质校验对 MyPBR 同样有效——c13 会大量用到这个功能。_WRITE_RENDERING_LAYERS 来自 RenderingLayers.hlsl 的 pragma,开启 Decal Layers / Light Layers 的某些组合时,URP 要求不透明物体额外输出一个 Rendering Layer 目标。

图 11-5Rendering Debugger 作用于 MyPBR。左:正常着色;中:Material Override = Albedo;右:Material Override = Smoothness(刷漆环面上漆面与金属的光滑度差异一目了然)。若没有 DEBUG_DISPLAY 分支,中、右两格里的 MyPBR 物体会照常着色。
提示检查“平时不开”的变体:unity_check 和 Inspector 的编译按钮默认只编译当前关键字组合,光照贴图、调试视图、逐顶点光这类“平时不开”的分支即使有语法错误也发现不了。配套工程的 Editor/MyPBRVariantCheck.cs(菜单 Cookbook › Ch11 › Check MyPBR Variants)用六组典型关键字组合强制编译每个 Pass,并把编译错误打印出来;本章配图时它报告的错误数为 0。