GameTech/URP 渲染实战

案例 12.1本章的材质框架与基准 GGX进阶

目标:写一个最小但完整的金属度工作流 Shader 作为“基准材质”,并把光源循环、顶点处理等公共代码拆成可复用的 include。它要和 URP Lit 放在一起对比,确认数值基本一致——后面所有进阶材质都以它为起点,如果基准就偏了,后面的对比都没有意义。

文件组织

本章代码放在 Assets/Cookbook/Ch12/,分四层:

材质 Shader struct MaterialCtx ShadeOneLight() frag():填 ctx、加间接光 LightLoop.hlsl AccumulateDirectLighting() 主光+阴影 / 附加光 / Forward+ AdvBRDF.hlsl D / V / F、KK、Charlie 薄膜、环境 BRDF 近似 URP ShaderLibrary GetMainLight GetAdditionalLight LIGHT_LOOP_BEGIN SampleSH GlossyEnvironment-Reflection 调用回调
图 12-1本章代码的分层:材质 Shader 只写“单个光源怎么着色”,光源循环与 BRDF 数学分别放在两个公共 include 里

“模板式 include”:让光源循环回调材质函数

HLSL 没有函数指针,也没有虚函数,怎么让一份光源循环代码去调用每个材质各自的着色函数?办法很朴素:HLSL 的 include 是纯文本展开,只要在 #include "LightLoop.hlsl" 之前把 MaterialCtx 结构体和 ShadeOneLight 函数定义好,展开后的循环代码就能直接引用它们。这和 C++ 模板“按名字查找”的效果一样,URP 自己也大量使用这种写法(例如各个 Pass 的 hlsl 都假定调用方已经声明了 _BaseMap)。

Assets/Cookbook/Ch12/LightLoop.hlsl(节选:loop)
half3 AccumulateDirectLighting(MaterialCtx m, float3 positionWS, float2 screenUV)
{
    half4 shadowMask = half4(1, 1, 1, 1);   // 本章不涉及 Shadowmask 烘焙,全 1 即可
    float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);
    half3 color = ShadeOneLight(m, mainLight);

#if defined(_ADDITIONAL_LIGHTS)
    // Forward+ 的 LIGHT_LOOP_BEGIN 宏内部直接引用名为 inputData 的变量
    InputData inputData = (InputData)0;
    inputData.positionWS = positionWS;
    inputData.normalizedScreenSpaceUV = screenUV;

  #if USE_FORWARD_PLUS
    // Forward+ 把“额外的方向光”单独放在列表最前面,不参与分簇
    for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++)
    {
        color += ShadeOneLight(m, GetAdditionalLight(li, positionWS, shadowMask));
    }
  #endif

    uint pixelLightCount = GetAdditionalLightsCount();
    LIGHT_LOOP_BEGIN(pixelLightCount)
        color += ShadeOneLight(m, GetAdditionalLight(lightIndex, positionWS, shadowMask));
    LIGHT_LOOP_END
#endif
    return color;
}
vec3 AccumulateDirectLighting(MaterialCtx m, vec3 positionWS, vec2 screenUV)
{
    vec4 shadowMask = vec4(1.0);   // 本章不涉及 Shadowmask 烘焙,全 1 即可
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);
    vec3 color = ShadeOneLight(m, mainLight);

#if defined(_ADDITIONAL_LIGHTS)
    int pixelLightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < pixelLightCount; lightIndex++)
    {
        color += ShadeOneLight(m, GetAdditionalLight(lightIndex, positionWS, shadowMask));
    }
#endif
    return color;
}

这段循环有三个容易踩的坑:

公共顶点着色器

Assets/Cookbook/Ch12/Ch12Common.hlsl(节选:vertex)
struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS   : NORMAL;
    float4 tangentOS  : TANGENT;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    float3 positionWS : TEXCOORD1;
    float3 normalWS   : TEXCOORD2;
    float4 tangentWS  : TEXCOORD3;   // w = 副切线符号(镜像 UV 时为 -1)
    float  fogFactor  : TEXCOORD4;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings Ch12Vert(Attributes input)
{
    Varyings o = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);
    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    o.positionCS = pos.positionCS;
    o.positionWS = pos.positionWS;
    o.normalWS = nrm.normalWS;
    o.tangentWS = float4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
    o.uv = input.uv;
    o.fogFactor = ComputeFogFactor(pos.positionCS.z);
    return o;
}

// 片元里重建正交的 T/B/N(插值后会失去正交,Gram-Schmidt 修一下)
void Ch12BuildFrame(Varyings i, out float3 T, out float3 B, out float3 N)
{
    N = normalize(i.normalWS);
    T = normalize(i.tangentWS.xyz - N * dot(i.tangentWS.xyz, N));
    B = cross(N, T) * i.tangentWS.w;
}
VARYING vec2  uv;
VARYING vec3  positionWS;
VARYING vec3  normalWS;
VARYING vec4  tangentWS;   // w = 副切线符号(镜像 UV 时为 -1)
VARYING float fogFactor;

#ifdef VERTEX
void Ch12Vert()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
    gl_Position = pos.positionCS;
    positionWS = pos.positionWS;
    normalWS = nrm.normalWS;
    tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
    uv = texcoord0.xy;
    fogFactor = ComputeFogFactor(pos.positionCS.z);
}
#endif

// 片元里重建正交的 T/B/N(插值后会失去正交,Gram-Schmidt 修一下)
void Ch12BuildFrame(out vec3 T, out vec3 B, out vec3 N)
{
    N = normalize(normalWS);
    T = normalize(tangentWS.xyz - N * dot(tangentWS.xyz, N));
    B = cross(N, T) * tangentWS.w;
}

各向异性和头发都依赖切线,所以顶点里把 tangentOS.w(副切线方向的符号,镜像 UV 时为 −1)也传下来,并乘上 GetOddNegativeScale():物体缩放为负时,副切线要再翻一次。片元里插值后的 T、N 已经不再正交,用一次 Gram-Schmidt 修正再叉乘出 B,这一步对拉丝金属这种“高光形状完全由 T 决定”的材质尤其重要。

基准材质

先看 BRDF 本身。下面这几行就是第 9 章公式的直接翻译,AdvV_SmithGGX 是高度相关的 Smith 可见性项,已经约掉了 4N·LN·V 的分母:

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:iso)
// ---------- 各向同性 GGX ----------
// D:Trowbridge-Reitz / GGX 法线分布
float AdvD_GGX(float NoH, float a)
{
    float a2 = a * a;
    float d = NoH * NoH * (a2 - 1.0) + 1.0;
    return a2 / (ADV_PI * d * d + 1e-7);
}

// V:高度相关 Smith 可见性项,V = G / (4 NoL NoV),分母已经约掉
float AdvV_SmithGGX(float NoV, float NoL, float a)
{
    float a2 = a * a;
    float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// F:Schlick 近似
float3 AdvF_Schlick(float3 f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}
float AdvF_Schlick(float f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}

// 标准金属度工作流的镜面项:D·V·F(返回值还要乘 NoL 和光强)
float3 AdvSpecularGGX(float3 N, float3 V, float3 L, float a, float3 f0)
{
    float3 H = normalize(V + L);
    float NoV = max(dot(N, V), 1e-4);
    float NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float VoH = saturate(dot(V, H));
    return AdvD_GGX(NoH, a) * AdvV_SmithGGX(NoV, NoL, a) * AdvF_Schlick(f0, 1.0, VoH);
}
// ---------- 各向同性 GGX ----------
// D:Trowbridge-Reitz / GGX 法线分布
float AdvD_GGX(float NoH, float a)
{
    float a2 = a * a;
    float d = NoH * NoH * (a2 - 1.0) + 1.0;
    return a2 / (ADV_PI * d * d + 1e-7);
}

// V:高度相关 Smith 可见性项,V = G / (4 NoL NoV),分母已经约掉
float AdvV_SmithGGX(float NoV, float NoL, float a)
{
    float a2 = a * a;
    float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// F:Schlick 近似
vec3 AdvF_Schlick(vec3 f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}
float AdvF_Schlick(float f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}

// 标准金属度工作流的镜面项:D·V·F(返回值还要乘 NoL 和光强)
vec3 AdvSpecularGGX(vec3 N, vec3 V, vec3 L, float a, vec3 f0)
{
    vec3 H = normalize(V + L);
    float NoV = max(dot(N, V), 1e-4);
    float NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float VoH = saturate(dot(V, H));
    return AdvD_GGX(NoH, a) * AdvV_SmithGGX(NoV, NoL, a) * AdvF_Schlick(f0, 1.0, VoH);
}
// HLSL 里标量 f0 会自动扩展成 float3,GLSL 要显式重载
vec3 AdvSpecularGGX(vec3 N, vec3 V, vec3 L, float a, float f0) { return AdvSpecularGGX(N, V, L, a, vec3(f0)); }

材质 Shader 的着色函数:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:shade)
struct MaterialCtx
{
    float3 N, V;
    float3 diffuse;   // 漫反射颜色(已扣掉金属部分)
    float3 f0;        // 法向入射反射率
    float  a;         // α = 感知粗糙度²
};

// URP 的灯光约定:color 已经是“除过 π 的辐照度”,所以 BRDF 要整体乘 π
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float NoL = saturate(dot(m.N, light.direction));
    float3 brdf = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, light.direction, m.a, m.f0);
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}
struct MaterialCtx
{
    vec3 N, V;
    vec3 diffuse;   // 漫反射颜色(已扣掉金属部分)
    vec3 f0;        // 法向入射反射率
    float a;        // α = 感知粗糙度²
};

// URP 的灯光约定:color 已经是“除过 π 的辐照度”,所以 BRDF 要整体乘 π
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    float NoL = saturate(dot(m.N, light.direction));
    vec3 brdf = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, light.direction, m.a, m.f0);
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}

注意最后乘的那个 PI。物理上,点光源或方向光产生的出射辐射率是 Lo=f·E⊥·N·L,其中 E⊥ 是垂直入射时的辐照度。Unity 的约定是:灯光的“颜色 × 强度”等于一块白色 Lambert 表面正对光源时的输出值,也就是 E⊥/π。URP 的 LightingPhysicallyBased 把这个 π 预先吸收进了 BRDF(漫反射项直接用 brdfData.diffuse,没有除 π;镜面项的 normalizationTerm 也相应放大了 π 倍)。我们的 BRDF 是严格按物理写的(Lambert 带 1/π),所以要把灯光值乘回 π,两边才能对上。

片元函数负责填写 MaterialCtx、调用光源循环,再加上间接光:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    float3 T, B, N; Ch12BuildFrame(i, T, B, N);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)) * _BaseColor;
    float perceptualRoughness = 1.0 - _Smoothness;

    MaterialCtx m;
    m.N = N; m.V = V;
    m.f0 = lerp(0.04, albedo.rgb, _Metallic);
    m.diffuse = albedo.rgb * (1.0 - _Metallic) * 0.96;
    m.a = max(perceptualRoughness * perceptualRoughness, 0.002);

    half3 color = AccumulateDirectLighting(m, i.positionWS, screenUV);

    // 间接光:漫反射用球谐,镜面用反射探针 + 环境 BRDF 近似
    float NoV = saturate(dot(N, V));
    float3 R = reflect(-V, N);
    float2 AB = AdvEnvBRDFApprox(perceptualRoughness, NoV);
    color += SampleSH(N) * m.diffuse;
    color += GlossyEnvironmentReflection(R, i.positionWS, perceptualRoughness, 1.0, screenUV) * (m.f0 * AB.x + AB.y);

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
void main()
{
    vec3 T, B, N; Ch12BuildFrame(T, B, N);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    vec4 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)) * _BaseColor;
    float perceptualRoughness = 1.0 - _Smoothness;

    MaterialCtx m;
    m.N = N; m.V = V;
    m.f0 = mix(vec3(0.04), albedo.rgb, _Metallic);
    m.diffuse = albedo.rgb * (1.0 - _Metallic) * 0.96;
    m.a = max(perceptualRoughness * perceptualRoughness, 0.002);

    vec3 color = AccumulateDirectLighting(m, positionWS, screenUV);

    // 间接光:漫反射用球谐,镜面用反射探针 + 环境 BRDF 近似
    float NoV = saturate(dot(N, V));
    vec3 R = reflect(-V, N);
    vec2 AB = AdvEnvBRDFApprox(perceptualRoughness, NoV);
    color += SampleSH(N) * m.diffuse;
    color += GlossyEnvironmentReflection(R, positionWS, perceptualRoughness, 1.0) * (m.f0 * AB.x + AB.y);

    color = MixFog(color, fogFactor);
    fragColor = vec4(color, 1.0);
}

间接光的镜面部分用的是 split-sum 近似:GlossyEnvironmentReflection 按粗糙度从反射探针的 mip 链里取预滤波的环境光(第 8 章),再乘环境 BRDF 的积分值 F0A+B。A、B 本来要查一张预积分 LUT,这里用 Karis 在 2014 年给出的移动端解析拟合,几条乘加就够:

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:envbrdf)
// 环境 BRDF 的解析近似(Karis 2014,移动端常用):返回 F0*A + B 里的 (A, B)
// 用感知粗糙度做输入,拟合的是 split-sum 第二项的预积分 LUT
float2 AdvEnvBRDFApprox(float perceptualRoughness, float NoV)
{
    const float4 c0 = float4(-1.0, -0.0275, -0.572, 0.022);
    const float4 c1 = float4( 1.0,  0.0425,  1.040, -0.040);
    float4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return float2(-1.04, 1.04) * a004 + r.zw;
}
// 环境 BRDF 的解析近似(Karis 2014,移动端常用):返回 F0*A + B 里的 (A, B)
// 用感知粗糙度做输入,拟合的是 split-sum 第二项的预积分 LUT
vec2 AdvEnvBRDFApprox(float perceptualRoughness, float NoV)
{
    const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
    const vec4 c1 = vec4( 1.0,  0.0425,  1.040, -0.040);
    vec4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return vec2(-1.04, 1.04) * a004 + r.zw;
}

Pass 部分(ShadowCaster、DepthOnly、DepthNormals)直接 include URP 自带的 hlsl。这几个文件只在 _ALPHATEST_ON 打开时才会读材质的 _BaseMap,其余与材质无关,可以放心复用。有一个小坑:URP 的 Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,Lit 是经由 LitInput.hlsl 间接包含的;我们自己的 Shader 没有包含它,ShadowCaster 和 DepthNormals 两个 Pass 会报“undeclared identifier 'LerpWhiteTo'”。解决办法是在 HLSLINCLUDE 里补一行 include:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:passes)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Back
    HLSLPROGRAM
    #pragma vertex ShadowPassVertex
    #pragma fragment ShadowPassFragment
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // URP 自带的阴影 Pass 只在 _ALPHATEST_ON 时才读 _BaseMap,其余部分与材质无关,直接复用
    #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R Cull Back
    HLSLPROGRAM
    #pragma vertex DepthOnlyVertex
    #pragma fragment DepthOnlyFragment
    #pragma multi_compile_instancing
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On Cull Back
    HLSLPROGRAM
    #pragma vertex DepthNormalsVertex
    #pragma fragment DepthNormalsFragment
    #pragma multi_compile_instancing
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthNormalsPass.hlsl"
    ENDHLSL
}
// 投影 / 深度 / 深度法线:HLSL 版直接复用 URP 自带的 Pass,网页版用公共的同功能 Pass
UsePass "Cookbook/Common/SHADOWCASTER"
UsePass "Cookbook/Common/DEPTHONLY"
UsePass "Cookbook/Common/DEPTHNORMALS"
图 12-2基准材质与 URP Lit 对照。左:URP Lit;右:本章的 BaseGGX。上排金属(金),下排电介质(红),光滑度从左到右 0.1 → 0.95

两边的直接光高光大小、形状一致;差别主要在粗糙金属的边缘:URP 的 EnvironmentBRDFSpecular 用的是另一套更简化的近似(surfaceReduction 加 grazingTerm),在粗糙表面的掠射角给出的反射更亮。两种近似都不是“标准答案”,只要知道差异来自哪里即可。到这一步,框架就可以用了。

LightMode 用 UniversalForwardOnly本章所有着色 Pass 的 LightMode 都写成 UniversalForwardOnly 而不是 UniversalForward。两者在 Forward / Forward+ 下完全一样;区别在 Deferred 渲染路径:URP 会认为“有 UniversalForward 的 Shader 应该同时提供 UniversalGBuffer Pass”,只有前者而没有 GBuffer Pass 的不透明物体在 Deferred 下根本不会被绘制。我们的自定义 BRDF 无法写进 URP 的 G-Buffer,所以明确声明“只走 Forward”,URP 会在 Deferred 的 G-Buffer 阶段之后单独用 Forward 方式画它们(Complex Lit 也是这么做的)。
提示本章所有 Shader 的 Forward Pass 都使用同一组关键字(主光阴影、附加光、软阴影、反射探针混合、Forward+、雾、实例化),列表照抄自 URP 14 的 Lit.shader,只删掉了本章用不到的 Lightmap、Decal、SSAO 等项。真要上线时,按项目实际开启的功能把它们补回来。