GameTech/URP 渲染实战

案例 12.2金属漆:清漆 + 程序化金属片进阶

目标:实现带能量衰减、能修正底层的清漆材质,底层加一层程序化的金属片(flake)让车漆“闪”起来;用白炉测试验证能量,并与 URP Complex Lit 对比。

材质上下文多了几项:底层用的扰动法线、两层各自的粗糙度、清漆遮罩和视线方向的清漆菲涅尔:

Assets/Cookbook/Ch12/ClearCoat.shader(节选:ctx)
struct MaterialCtx
{
    float3 N, V;
    float3 flakeN;      // 带金属片扰动的底层法线
    float3 diffuse, f0;
    float  aBase;       // 底层 α(可能已被清漆修正)
    float  aCoat;       // 清漆层 α
    float  coatMask;
    float  coatFv;      // 清漆在视线方向的菲涅尔,用来衰减底层
};
struct MaterialCtx
{
    vec3 N, V;
    vec3 flakeN;      // 带金属片扰动的底层法线
    vec3 diffuse, f0;
    float aBase;      // 底层 α(可能已被清漆修正)
    float aCoat;      // 清漆层 α
    float coatMask;
    float coatFv;     // 清漆在视线方向的菲涅尔,用来衰减底层
};

单个光源的着色,和公式一一对应。底层用扰动后的金属片法线算 GGX,清漆层用宏观法线(漆面是平的):

Assets/Cookbook/Ch12/ClearCoat.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    // 底层:漫反射 + 金属片法线下的 GGX
    float3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

    // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
    float  Fc = AdvF_Schlick(0.04, 1.0, VoH);
    float  coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

    // 能量衰减:被清漆反射掉的那部分光不会再到达底层
    float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    float3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    vec3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    // 底层:漫反射 + 金属片法线下的 GGX
    vec3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

    // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
    float Fc = AdvF_Schlick(0.04, 1.0, VoH);
    float coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

    // 能量衰减:被清漆反射掉的那部分光不会再到达底层
    float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    vec3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}

金属片的做法很简单:把 UV 切成很小的格子,每格用哈希生成一个随机方向,把法线朝这个方向歪一点。哈希函数用的是常见的 frac 系列,不需要任何贴图:

Assets/Cookbook/Ch12/ClearCoat.shader(节选:flake)
// 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
float3 Hash33(float3 p)
{
    p = frac(p * float3(0.1031, 0.1030, 0.0973));
    p += dot(p, p.yxz + 33.33);
    return frac((p.xxy + p.yxx) * p.zyx);
}
float3 FlakeNormal(float2 uv, float3 T, float3 B, float3 N)
{
    float2 cell = floor(uv * _FlakeDensity);
    float3 r = Hash33(float3(cell, 7.0)) * 2.0 - 1.0;
    return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
}
// 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
vec3 Hash33(vec3 p)
{
    p = fract(p * vec3(0.1031, 0.1030, 0.0973));
    p += dot(p, p.yxz + 33.33);
    return fract((p.xxy + p.yxx) * p.zyx);
}
vec3 FlakeNormal(vec2 uv, vec3 T, vec3 B, vec3 N)
{
    vec2 cell = floor(uv * _FlakeDensity);
    vec3 r = Hash33(vec3(cell, 7.0)) * 2.0 - 1.0;
    return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
}

片元函数里先按开关修正底层的粗糙度和 F0,然后环境光部分各采一次反射探针——清漆用自己的粗糙度和宏观法线,底层用修正后的粗糙度和金属片法线:

Assets/Cookbook/Ch12/ClearCoat.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);
    float NoV = saturate(dot(N, V));

    MaterialCtx m;
    m.N = N; m.V = V;
    m.flakeN = FlakeNormal(i.uv, T, B, N);
    m.coatMask = _CoatMask;
    m.f0 = lerp(0.04, _BaseColor.rgb, _Metallic);
    m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

    float prBase = 1.0 - _Smoothness;
    float aBase = max(prBase * prBase, 0.002);
    if (_CoatAffectBase > 0.5)
    {
        // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
        float ieta = lerp(1.0, 1.0 / 1.5, m.coatMask);
        float n = 2.0 / (aBase * aBase) - 2.0;
        aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
        // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
        float3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
        m.f0 = lerp(m.f0, f0Coat, m.coatMask);
    }
    m.aBase = aBase;
    float prCoat = 1.0 - _CoatSmoothness;
    m.aCoat = max(prCoat * prCoat, 0.002);
    m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

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

    // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
    float prBaseEff = sqrt(m.aBase);
    float2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
    float3 baseIndirect = SampleSH(N) * m.diffuse
        + GlossyEnvironmentReflection(reflect(-V, m.flakeN), i.positionWS, prBaseEff, 1.0, screenUV) * (m.f0 * AB.x + AB.y);
    float2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
    float3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), i.positionWS, prCoat, 1.0, screenUV) * (0.04 * ABc.x + ABc.y);

    float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    color += baseIndirect * attenuation + coatIndirect * m.coatMask;

    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);
    float NoV = saturate(dot(N, V));

    MaterialCtx m;
    m.N = N; m.V = V;
    m.flakeN = FlakeNormal(uv, T, B, N);
    m.coatMask = _CoatMask;
    m.f0 = mix(vec3(0.04), _BaseColor.rgb, _Metallic);
    m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

    float prBase = 1.0 - _Smoothness;
    float aBase = max(prBase * prBase, 0.002);
    if (_CoatAffectBase > 0.5)
    {
        // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
        float ieta = mix(1.0, 1.0 / 1.5, m.coatMask);
        float n = 2.0 / (aBase * aBase) - 2.0;
        aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
        // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
        vec3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
        m.f0 = mix(m.f0, f0Coat, m.coatMask);
    }
    m.aBase = aBase;
    float prCoat = 1.0 - _CoatSmoothness;
    m.aCoat = max(prCoat * prCoat, 0.002);
    m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

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

    // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
    float prBaseEff = sqrt(m.aBase);
    vec2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
    vec3 baseIndirect = SampleSH(N) * m.diffuse
        + GlossyEnvironmentReflection(reflect(-V, m.flakeN), positionWS, prBaseEff, 1.0) * (m.f0 * AB.x + AB.y);
    vec2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
    vec3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), positionWS, prCoat, 1.0) * (0.04 * ABc.x + ABc.y);

    float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    color += baseIndirect * attenuation + coatIndirect * m.coatMask;

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

完整 Shader 如下(Pass 部分与基准材质相同):

Assets/Cookbook/Ch12/ClearCoat.shader
// 清漆(Clear Coat):在基础 GGX 材质上再盖一层透明的电介质 GGX。
// 两层之间的能量分配、折射对底层的影响,是本案例要讲清的重点。
Shader "Cookbook/Ch12/ClearCoat"
{
    Properties
    {
        [MainColor] _BaseColor ("Base Color", Color) = (0.6, 0.04, 0.03, 1)
        _Metallic ("Metallic", Range(0, 1)) = 0.6
        _Smoothness ("Base Smoothness", Range(0, 1)) = 0.45
        [Header(Flakes)]
        _FlakeDensity ("Flake Density (cells per UV)", Float) = 600
        _FlakeStrength ("Flake Normal Strength", Range(0, 1)) = 0.35
        [Header(Clear Coat)]
        _CoatMask ("Coat Mask", Range(0, 1)) = 1
        _CoatSmoothness ("Coat Smoothness", Range(0, 1)) = 0.95
        [Toggle] _CoatEnergy ("Energy Attenuation (1 - Fc)", Float) = 1
        [Toggle] _CoatAffectBase ("Coat Modifies Base (IOR 1.5)", Float) = 1
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half  _Metallic, _Smoothness;
            float _FlakeDensity;
            half  _FlakeStrength;
            half  _CoatMask, _CoatSmoothness, _CoatEnergy, _CoatAffectBase;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }

            HLSLPROGRAM
            #pragma target 3.5
            #pragma vertex Ch12Vert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #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 _ _LIGHT_COOKIES
            #pragma multi_compile _ _FORWARD_PLUS
            #pragma multi_compile_fog
            #pragma multi_compile_instancing

            #include "Assets/Cookbook/Ch12/Ch12Common.hlsl"

            struct MaterialCtx
            {
                float3 N, V;
                float3 flakeN;      // 带金属片扰动的底层法线
                float3 diffuse, f0;
                float  aBase;       // 底层 α(可能已被清漆修正)
                float  aCoat;       // 清漆层 α
                float  coatMask;
                float  coatFv;      // 清漆在视线方向的菲涅尔,用来衰减底层
            };

            half3 ShadeOneLight(MaterialCtx m, Light light)
            {
                float3 L = light.direction;
                float3 H = normalize(m.V + L);
                float NoL = saturate(dot(m.N, L));
                float NoV = max(dot(m.N, m.V), 1e-4);
                float NoH = saturate(dot(m.N, H));
                float VoH = saturate(dot(m.V, H));

                // 底层:漫反射 + 金属片法线下的 GGX
                float3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

                // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
                float  Fc = AdvF_Schlick(0.04, 1.0, VoH);
                float  coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

                // 能量衰减:被清漆反射掉的那部分光不会再到达底层
                float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                float3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

                return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
            }

            #include "Assets/Cookbook/Ch12/LightLoop.hlsl"

            // 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
            float3 Hash33(float3 p)
            {
                p = frac(p * float3(0.1031, 0.1030, 0.0973));
                p += dot(p, p.yxz + 33.33);
                return frac((p.xxy + p.yxx) * p.zyx);
            }
            float3 FlakeNormal(float2 uv, float3 T, float3 B, float3 N)
            {
                float2 cell = floor(uv * _FlakeDensity);
                float3 r = Hash33(float3(cell, 7.0)) * 2.0 - 1.0;
                return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
            }

            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);
                float NoV = saturate(dot(N, V));

                MaterialCtx m;
                m.N = N; m.V = V;
                m.flakeN = FlakeNormal(i.uv, T, B, N);
                m.coatMask = _CoatMask;
                m.f0 = lerp(0.04, _BaseColor.rgb, _Metallic);
                m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

                float prBase = 1.0 - _Smoothness;
                float aBase = max(prBase * prBase, 0.002);
                if (_CoatAffectBase > 0.5)
                {
                    // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
                    float ieta = lerp(1.0, 1.0 / 1.5, m.coatMask);
                    float n = 2.0 / (aBase * aBase) - 2.0;
                    aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
                    // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
                    float3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
                    m.f0 = lerp(m.f0, f0Coat, m.coatMask);
                }
                m.aBase = aBase;
                float prCoat = 1.0 - _CoatSmoothness;
                m.aCoat = max(prCoat * prCoat, 0.002);
                m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

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

                // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
                float prBaseEff = sqrt(m.aBase);
                float2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
                float3 baseIndirect = SampleSH(N) * m.diffuse
                    + GlossyEnvironmentReflection(reflect(-V, m.flakeN), i.positionWS, prBaseEff, 1.0, screenUV) * (m.f0 * AB.x + AB.y);
                float2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
                float3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), i.positionWS, prCoat, 1.0, screenUV) * (0.04 * ABc.x + ABc.y);

                float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                color += baseIndirect * attenuation + coatIndirect * m.coatMask;

                color = MixFog(color, i.fogFactor);
                return half4(color, 1);
            }
            ENDHLSL
        }

        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
        }
    }
}
// 清漆(Clear Coat):在基础 GGX 材质上再盖一层透明的电介质 GGX。
// 两层之间的能量分配、折射对底层的影响,是本案例要讲清的重点。
// (GLSL 版,对应 Ch12/ClearCoat.shader)
Shader "Cookbook/Ch12/ClearCoat"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseColor;
        uniform float _Metallic, _Smoothness;
        uniform float _FlakeDensity;
        uniform float _FlakeStrength;
        uniform float _CoatMask, _CoatSmoothness, _CoatEnergy, _CoatAffectBase;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }

            GLSLPROGRAM
            #include "Ch12/Ch12Common.glsl"

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

            #ifdef FRAGMENT
            struct MaterialCtx
            {
                vec3 N, V;
                vec3 flakeN;      // 带金属片扰动的底层法线
                vec3 diffuse, f0;
                float aBase;      // 底层 α(可能已被清漆修正)
                float aCoat;      // 清漆层 α
                float coatMask;
                float coatFv;     // 清漆在视线方向的菲涅尔,用来衰减底层
            };

            vec3 ShadeOneLight(MaterialCtx m, Light light)
            {
                vec3 L = light.direction;
                vec3 H = normalize(m.V + L);
                float NoL = saturate(dot(m.N, L));
                float NoV = max(dot(m.N, m.V), 1e-4);
                float NoH = saturate(dot(m.N, H));
                float VoH = saturate(dot(m.V, H));

                // 底层:漫反射 + 金属片法线下的 GGX
                vec3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

                // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
                float Fc = AdvF_Schlick(0.04, 1.0, VoH);
                float coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

                // 能量衰减:被清漆反射掉的那部分光不会再到达底层
                float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                vec3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

                return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
            }

            #include "Ch12/LightLoop.glsl"

            // 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
            vec3 Hash33(vec3 p)
            {
                p = fract(p * vec3(0.1031, 0.1030, 0.0973));
                p += dot(p, p.yxz + 33.33);
                return fract((p.xxy + p.yxx) * p.zyx);
            }
            vec3 FlakeNormal(vec2 uv, vec3 T, vec3 B, vec3 N)
            {
                vec2 cell = floor(uv * _FlakeDensity);
                vec3 r = Hash33(vec3(cell, 7.0)) * 2.0 - 1.0;
                return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
            }

            void main()
            {
                vec3 T, B, N; Ch12BuildFrame(T, B, N);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
                float NoV = saturate(dot(N, V));

                MaterialCtx m;
                m.N = N; m.V = V;
                m.flakeN = FlakeNormal(uv, T, B, N);
                m.coatMask = _CoatMask;
                m.f0 = mix(vec3(0.04), _BaseColor.rgb, _Metallic);
                m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

                float prBase = 1.0 - _Smoothness;
                float aBase = max(prBase * prBase, 0.002);
                if (_CoatAffectBase > 0.5)
                {
                    // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
                    float ieta = mix(1.0, 1.0 / 1.5, m.coatMask);
                    float n = 2.0 / (aBase * aBase) - 2.0;
                    aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
                    // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
                    vec3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
                    m.f0 = mix(m.f0, f0Coat, m.coatMask);
                }
                m.aBase = aBase;
                float prCoat = 1.0 - _CoatSmoothness;
                m.aCoat = max(prCoat * prCoat, 0.002);
                m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

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

                // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
                float prBaseEff = sqrt(m.aBase);
                vec2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
                vec3 baseIndirect = SampleSH(N) * m.diffuse
                    + GlossyEnvironmentReflection(reflect(-V, m.flakeN), positionWS, prBaseEff, 1.0) * (m.f0 * AB.x + AB.y);
                vec2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
                vec3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), positionWS, prCoat, 1.0) * (0.04 * ABc.x + ABc.y);

                float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                color += baseIndirect * attenuation + coatIndirect * m.coatMask;

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

        UsePass "Cookbook/Common/SHADOWCASTER"
        UsePass "Cookbook/Common/DEPTHONLY"
        UsePass "Cookbook/Common/DEPTHNORMALS"
    }
}
图 12-4金属漆。左:只有底层(金属度 0.6、光滑度 0.45、带金属片);中:加清漆(光滑度 0.95),开启能量衰减;右:加清漆但关闭能量衰减

左图只有底层时,金属片在粗糙高光里闪烁,整体像磨砂的金属;中图盖上清漆后多出一层锐利的高光和清晰的天空反射,底层的颜色和闪点仍然透出来,这就是车漆该有的“深度感”。右图乍看和中图差不多,只是边缘更亮一些——能量问题在普通光照下往往不显眼,需要专门的测试才能看清。

图 12-5清漆遮罩从左到右 0 → 1(底层光滑度 0.3)。遮罩可以用贴图控制,做出局部磨损、只在部分区域上漆的效果

白炉测试:一眼看出能量是否守恒

白炉测试(Furnace Test)是检验 BRDF 能量的经典方法:把物体放进一个各方向亮度完全相同的环境里,关掉所有直接光。一个反照率为 1、不吸收能量的材质,反射出去的光应该刚好等于照进来的光——它会和背景“融为一体”,看不见轮廓。如果某处比背景亮,说明 BRDF 在那里凭空产生了能量;比背景暗,说明能量丢了(单次散射的微表面模型在粗糙时本来就会损失一些,这就是第 11 章提到的多次散射补偿要解决的问题)。

在拍摄脚本里构造这样的环境很简单:一张所有像素都是 0.5 的立方体贴图作为反射源,球谐只保留常数项 0.5,背景色设为线性 0.5(注意相机背景色按 sRGB 解释,要用 Color.gamma 换算):

Assets/Cookbook/Ch12/Editor/Ch12Shots.cs(节选:furnace)
const float L = 0.5f;
var cube = new Cubemap(16, TextureFormat.RGBAHalf, true) { hideFlags = HideFlags.DontSave };
var px = new Color[16 * 16];
for (int k = 0; k < px.Length; k++) px[k] = new Color(L, L, L, 1);
for (int f = 0; f < 6; f++) cube.SetPixels(px, (CubemapFace)f);
cube.Apply(true);
s.Temp.Add(cube);
RenderSettings.customReflectionTexture = cube;
var sh = new UnityEngine.Rendering.SphericalHarmonicsL2();
sh.AddAmbientLight(new Color(L, L, L));
RenderSettings.ambientProbe = sh;
s.Background(new Color(L, L, L).gamma);   // 背景色按 sRGB 解释,要换算成线性 0.5
s.Sun.intensity = 0;
图 12-6白炉测试(白色底层,背景与环境都是线性 0.5)。左:无清漆;中:清漆 + 能量衰减;右:清漆但不做衰减,边缘明显比背景亮

左、中两图里球几乎消失,只剩下边缘一圈淡淡的暗环(粗糙底层在掠射角的能量损失);右图边缘发亮,因为在掠射角清漆的反射率接近 1,再加上一个完整的底层,总反射率超过了 1。普通场景里这种错误会表现为“物体边缘发白、看起来像蒙了一层雾”,很容易被误当成环境光太亮。

与 URP Complex Lit 对照

图 12-7左:URP Complex Lit(Clear Coat 开启);右:本案例的 ClearCoat。底层相同(蓝色、金属度 0.3、光滑度 0.4),清漆光滑度从左到右 0.6、0.8、0.95

两者整体一致。可见的差别是:URP 的清漆高光略亮、略大——它的清漆镜面项用的是 URP 自己的 DirectBRDFSpecular(一种把 D 和 V 合并的简化 GGX)乘常数 0.04,而我们用的是完整的 D·V·F,F 在正对视线时比 0.04 还小一点。这类差别属于“近似方法不同”,不是谁对谁错。

注意Complex Lit 在 URP 14 里始终走 Forward 渲染:即使渲染路径设成 Deferred,它也会在 Forward 阶段单独绘制,因为 G-Buffer 里没有位置存清漆参数。自写的清漆 Shader 同理——不写 UniversalGBuffer Pass,并把着色 Pass 的 LightMode 设为 UniversalForwardOnly(见 12.1 的说明)。

性能与调参建议

清漆的直接光要多算一次 D·V·F,环境光要多采一次反射探针(不同的 mip 等级),总开销大约是基准材质的 1.5 倍左右,具体以目标平台实测为准。移动端可以把底层的环境反射省掉(底层粗糙,环境反射本来就很模糊,用球谐近似即可),只保留清漆那一次采样。清漆光滑度一般取 0.85~1.0;做旧、磨砂清漆可以降到 0.6 左右,但此时两层高光会混在一起,清漆的意义就不大了。金属片的密度要与屏幕分辨率匹配:格子小于一个像素时会严重闪烁,远处应当按距离或 mip 淡出金属片强度。