GameTech/URP 渲染实战

案例 15.1Gooch 冷暖着色入门

目标:在技术插图风格里,让背光面依然清楚可读——不是变黑,而是变“冷”。

原理:1998 年 Gooch 等人在研究技术插图时发现,插画师很少用纯黑表现背光面,因为黑色会和轮廓线、阴影混在一起,形体反而看不清。他们的做法是用色相变化表达朝向:受光面偏暖(黄、橙),背光面偏冷(蓝、紫)。人眼对冷暖的感知天然带有空间感——暖色前进、冷色后退——所以只靠色相也能读出体积。

模型本身非常简单。先构造冷暖两个端点颜色:纯冷色 b(蓝)加上一部分物体本色 kd,纯暖色 y(黄)加上一部分本色:

kcool=b+αkd,kwarm=y+βkd

再用重映射到 0..1 的 N·L 在两者之间插值:

t=1+N·L2,I=tkwarm+(1−t)kcool

这里的 (1 + N·L)/2 和 Half-Lambert 长得一样,但目的不同:Half-Lambert 是为了让暗部不死黑,Gooch 是为了让整个 −1..1 的范围都有意义——完全背光(N·L = −1)的点取纯冷色,完全受光的点取纯暖色,中间是一条连续的色相过渡。这也是为什么 GetNprLight 不截断 N·L:一旦 saturate,背光面全部变成 0,冷色端就丢了一半信息。

Lambert Gooch N·L = −1N·L = 0N·L = 1 Lambert:左半段全是 0(黑)
图 15-2同一个 N·L 范围,Lambert 只用了一半(背光面全黑),Gooch 用满了整个区间,暗部变成冷色

完整代码:

Assets/Cookbook/Ch15/Gooch.shader
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向,
// 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。
Shader "Cookbook/Ch15/Gooch"
{
    Properties
    {
        _BaseColor ("Base Color (k_d)", Color) = (0.8, 0.25, 0.2, 1)
        _CoolColor ("Cool Color (b)", Color) = (0.0, 0.05, 0.45, 1)
        _WarmColor ("Warm Color (y)", Color) = (0.45, 0.4, 0.0, 1)
        _Alpha ("Alpha (cool mix)", Range(0, 1)) = 0.25
        _Beta ("Beta (warm mix)", Range(0, 1)) = 0.55
        _SpecPower ("Specular Power", Range(4, 256)) = 48
        _SpecStrength ("Specular Strength", Range(0, 1)) = 0.8
        _ShadowCool ("Shadow pushes to cool", Range(0, 1)) = 0.6
        _OutlineWidth ("Outline Width", Range(0, 0.05)) = 0.012
        _OutlineColor ("Outline Color", Color) = (0.05, 0.05, 0.08, 1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor;
            half  _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool;
            float _OutlineWidth;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex NprVert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #pragma multi_compile_fog
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
                NprLight L = GetNprLight(i.positionWS, N);

                // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
                half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
                half3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

                // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
                half t = (1.0h + L.ndotl) * 0.5h;
                // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
                t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow);
                half3 col = lerp(kCool, kWarm, t);

                // 3) 白色高光:Blinn-Phong,只在受光面出现
                float3 H = normalize(L.direction + V);
                half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl);
                col += spec;

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

        // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线)
        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            float4 vert(A v) : SV_POSITION
            {
                float4 positionCS = TransformObjectToHClip(v.positionOS.xyz);
                float3 normalCS = mul((float3x3)UNITY_MATRIX_VP, TransformObjectToWorldNormal(v.normalOS));
                // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关
                float2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w;
                offset.x *= _ScreenParams.y / _ScreenParams.x;   // 纠正宽高比
                positionCS.xy += offset;
                return positionCS;
            }
            half4 frag() : SV_Target { return _OutlineColor; }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex NprShadowVert
            #pragma fragment NprNullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex NprDepthVert
            #pragma fragment NprNullFrag
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }
    }
}
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向,(GLSL 版,对应 Ch15/Gooch.shader)
// 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。
Shader "Cookbook/Ch15/Gooch"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor;
        uniform float _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool;
        uniform float _OutlineWidth;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #define NPR_VARYINGS
            #include "Ch15/NPRCommon.glsl"

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

            #ifdef FRAGMENT
            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                NprLight L = GetNprLight(positionWS, N);

                // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
                vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
                vec3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

                // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
                float t = (1.0 + L.ndotl) * 0.5;
                // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
                t *= mix(1.0 - _ShadowCool, 1.0, L.shadow);
                vec3 col = mix(kCool, kWarm, t);

                // 3) 白色高光:Blinn-Phong,只在受光面出现
                vec3 H = normalize(L.direction + V);
                float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl);
                col += spec;

                col = MixFog(col, fogFactor);
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线)
        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            GLSLPROGRAM
            #ifdef VERTEX
            void main()
            {
                vec4 positionCS = TransformObjectToHClip(positionOS.xyz);
                vec3 normalCS = mat3(UNITY_MATRIX_VP) * TransformObjectToWorldNormal(normalOS);
                // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关
                vec2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w;
                offset.x *= _ScreenParams.y / _ScreenParams.x;   // 纠正宽高比
                positionCS.xy += offset;
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = _OutlineColor; }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #define NPR_SHADOW_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define NPR_DEPTH_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }
    }
}

关键代码讲解:

Assets/Cookbook/Ch15/Gooch.shader(节选:frag)
half4 frag(NprVaryings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    NprLight L = GetNprLight(i.positionWS, N);

    // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
    half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
    half3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

    // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
    half t = (1.0h + L.ndotl) * 0.5h;
    // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
    t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow);
    half3 col = lerp(kCool, kWarm, t);

    // 3) 白色高光:Blinn-Phong,只在受光面出现
    float3 H = normalize(L.direction + V);
    half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl);
    col += spec;

    col = MixFog(col, i.fogFactor);
    return half4(col, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    NprLight L = GetNprLight(positionWS, N);

    // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色
    vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb;
    vec3 kWarm = _WarmColor.rgb + _Beta  * _BaseColor.rgb;

    // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷”
    float t = (1.0 + L.ndotl) * 0.5;
    // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充)
    t *= mix(1.0 - _ShadowCool, 1.0, L.shadow);
    vec3 col = mix(kCool, kWarm, t);

    // 3) 白色高光:Blinn-Phong,只在受光面出现
    vec3 H = normalize(L.direction + V);
    float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl);
    col += spec;

    col = MixFog(col, fogFactor);
    return vec4(col, 1.0);
}
图 15-3左:URP Lit(相同本色);中:Gooch 冷暖着色,无高光无描边;右:Gooch + 白色高光 + 黑色轮廓线。注意中、右两图的背光面没有发黑,纽结内侧的走向依然清晰
调参建议冷暖端点不必是蓝和黄。想要“傍晚”的感觉可以用紫—橙,想要清冷的科技感可以用青—白。α、β 越大,本色越占主导,冷暖感越弱;两者都很小时,所有物体会变成同一种冷暖渐变,本色几乎消失。实践中 α 取 0.2~0.4、β 取 0.4~0.6 比较平衡。
常见坑立方体的描边在棱角处会断开(图 15-3 右图的方块)。原因是立方体每个面的顶点各有一套法线,同一个角上的三个顶点沿不同方向外扩,就裂开了。第 14 章的“平滑法线烘焙”就是为了解决这个问题。