GameTech/URP 渲染实战

案例 14.10完整的 Toon Shader高阶

目标:把本章的所有部件组装成一个可直接用于项目的卡通 Shader:两层阴影色或 Ramp 贴图、卡通化的接收阴影、风格化高光、受光侧边缘光、卡通化的附加光、平坦环境光,以及支持平滑法线的等宽描边。

附加光

附加光(点光、聚光)在卡通画面里通常用作氛围光:角色走进一盏红色灯笼的范围,身上出现一块边界清晰的红色光斑。做法与主光相同,只是把“N·L × 距离衰减 × 阴影”整体做一次色阶。距离衰减先乘 4 再 saturate,是为了让光斑在衰减到很弱之前就达到阈值,形成一块完整的色斑而不是一个模糊的光圈:

Assets/Cookbook/Ch14/ToonLit.shader(节选:addlight)
// 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑
half3 ToonAdditional(half3 albedo, half3 N, Light light)
{
    half atten = light.distanceAttenuation * light.shadowAttenuation;
    half x = saturate(dot(N, light.direction)) * saturate(atten * 4.0);
    return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength;
}
// 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑
vec3 ToonAdditional(vec3 albedo, vec3 N, Light light)
{
    float atten = light.distanceAttenuation * light.shadowAttenuation;
    float x = saturate(dot(N, light.direction)) * saturate(atten * 4.0);
    return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength;
}

光源循环与第 12 章相同,同样需要处理 Forward+ 的 inputData 和额外方向光。

片元函数

Assets/Cookbook/Ch14/ToonLit.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb;

    // ---------- 主光 + 阴影 ----------
    // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne)
    float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS + N * _ShadowNormalOffset);
    Light mainLight = GetMainLight(shadowCoord, i.positionWS, half4(1, 1, 1, 1));
    half NoL = dot(N, mainLight.direction);
    half3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color;

    // 高光与边缘光只出现在“真正被照亮”的区域
    half litMask = ToonBand((NoL * 0.5 + 0.5) * lerp(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness);
    half3 H = normalize(mainLight.direction + V);
    half spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask;
    color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color;
    half rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask;
    color = lerp(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength);

    // ---------- 附加光 ----------
#if defined(_ADDITIONAL_LIGHTS)
    InputData inputData = (InputData)0;         // Forward+ 的光源循环宏需要它
    inputData.positionWS = i.positionWS;
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
  #if USE_FORWARD_PLUS
    for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++)
        color += ToonAdditional(albedo, N, GetAdditionalLight(li, i.positionWS, half4(1, 1, 1, 1)));
  #endif
    uint lightCount = GetAdditionalLightsCount();
    LIGHT_LOOP_BEGIN(lightCount)
        color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, i.positionWS, half4(1, 1, 1, 1)));
    LIGHT_LOOP_END
#endif

    // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ----------
    color += SampleSH(half3(0, 0, 0)) * albedo * _AmbientStrength;

    return half4(MixFog(color, i.fogFactor), 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb;

    // ---------- 主光 + 阴影 ----------
    // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne)
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS + N * _ShadowNormalOffset);
    Light mainLight = GetMainLight(shadowCoord, positionWS, vec4(1.0));
    float NoL = dot(N, mainLight.direction);
    vec3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color;

    // 高光与边缘光只出现在“真正被照亮”的区域
    float litMask = ToonBand((NoL * 0.5 + 0.5) * mix(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness);
    vec3 H = normalize(mainLight.direction + V);
    float spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask;
    color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color;
    float rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask;
    color = mix(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength);

    // ---------- 附加光 ----------
#if defined(_ADDITIONAL_LIGHTS)
    // Forward / Forward+ 的光源循环在网页版里都是普通 for 循环
    int lightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
        color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, positionWS, vec4(1.0)));
#endif

    // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ----------
    color += SampleSH(vec3(0.0)) * albedo * _AmbientStrength;

    return vec4(MixFog(color, fogFactor), 1.0);
}

几个设计上的选择:

描边 Pass

描边部分与案例 14.5 相同,抽成了 ToonOutlinePass.hlsl,供 ToonLit、ToonHair、ToonFace 共用;外扩法线的来源用 KeywordEnum 在原始法线、切线(物体空间平滑法线)、顶点色(切线空间平滑法线)三者间切换。线宽为 0 时片元直接 clip 掉,同一个材质就能关掉描边。

Assets/Cookbook/Ch14/ToonOutlinePass.hlsl
// 描边 Pass 的通用实现(ToonLit / ToonHair / ToonFace 共用)。
// 使用方需要在 UnityPerMaterial 里声明 _OutlineColor、_OutlineWidth,
// 并用 shader_feature_local_vertex 声明 _OUTLINENORMAL_MESH/_TANGENT/_VERTEXCOLOR 之一(不声明时用原始法线)。
#ifndef COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED
#define COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED

#include "Assets/Cookbook/Ch14/ToonCommon.hlsl"

struct OutlineVaryings
{
    float4 positionCS : SV_POSITION;
    float  fogFactor  : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

OutlineVaryings OutlineVert(ToonAttributes v)
{
    OutlineVaryings o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_TRANSFER_INSTANCE_ID(v, o);
    float3 n = v.normalOS;
#if defined(_OUTLINENORMAL_TANGENT)
    n = v.tangentOS.xyz;                                   // 物体空间平滑法线
#elif defined(_OUTLINENORMAL_VERTEXCOLOR)
    float3 nTS = v.color.rgb * 2.0 - 1.0;                  // 切线空间平滑法线
    float3 bOS = cross(v.normalOS, v.tangentOS.xyz) * v.tangentOS.w;
    n = normalize(nTS.x * v.tangentOS.xyz + nTS.y * bOS + nTS.z * v.normalOS);
#endif
    o.positionCS = OutlinePositionCS(v.positionOS.xyz, n, _OutlineWidth);
    o.fogFactor = ComputeFogFactor(o.positionCS.z);
    return o;
}

half4 OutlineFrag(OutlineVaryings i) : SV_Target
{
    clip(_OutlineWidth - 0.01);   // 线宽为 0 时不输出(方便在同一个材质上关掉描边)
    return half4(MixFog(_OutlineColor.rgb, i.fogFactor), 1);
}

#endif
// 描边 Pass 的通用实现(ToonLit / ToonHair / ToonFace 共用)。(GLSL 版,对应 Ch14/ToonOutlinePass.hlsl)
// 使用方需要声明 uniform _OutlineColor、_OutlineWidth,
// 并用 shader_feature_local_vertex 声明 _OUTLINENORMAL_MESH/_TANGENT/_VERTEXCOLOR 之一(不声明时用原始法线)。
#ifndef COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED
#define COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED

#include "Ch14/ToonCommon.glsl"

VARYING float fogFactor;

#ifdef VERTEX
void OutlineVert()
{
    vec3 n = normalOS;
#if defined(_OUTLINENORMAL_TANGENT)
    n = tangentOS.xyz;                                   // 物体空间平滑法线
#elif defined(_OUTLINENORMAL_VERTEXCOLOR)
    vec3 nTS = color.rgb * 2.0 - 1.0;                    // 切线空间平滑法线
    vec3 bOS = cross(normalOS, tangentOS.xyz) * tangentOS.w;
    n = normalize(nTS.x * tangentOS.xyz + nTS.y * bOS + nTS.z * normalOS);
#endif
    gl_Position = OutlinePositionCS(positionOS.xyz, n, _OutlineWidth);
    fogFactor = ComputeFogFactor(gl_Position.z);
}
void main() { OutlineVert(); }
#endif

#ifdef FRAGMENT
vec4 OutlineFrag()
{
    if (_OutlineWidth - 0.01 < 0.0) discard;   // 线宽为 0 时不输出(方便在同一个材质上关掉描边)
    return vec4(MixFog(_OutlineColor.rgb, fogFactor), 1.0);
}
void main() { fragColor = OutlineFrag(); }
#endif

#endif

完整代码

Assets/Cookbook/Ch14/ToonLit.shader
// 本章的完整卡通 Shader:色阶(两层阴影色 / 可选 Ramp 贴图)+ 卡通化接收阴影 + 风格化高光 + 边缘光
// + 附加光 + 平坦环境光 + 背面外扩描边(裁剪空间等宽,可选平滑法线来源)。
Shader "Cookbook/Ch14/ToonLit"
{
    Properties
    {
        [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
        [MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
        [Header(Shading)]
        _ShadeColor ("1st Shade Color", Color) = (0.7, 0.6, 0.75, 1)
        _ShadeColor2 ("2nd Shade Color", Color) = (0.45, 0.38, 0.55, 1)
        _Threshold ("1st Shade Threshold", Range(0, 1)) = 0.5
        _Shade2Threshold ("2nd Shade Threshold", Range(0, 1)) = 0.2
        _Shade2Strength ("2nd Shade Strength", Range(0, 1)) = 1
        _Softness ("Edge Softness", Range(0, 0.3)) = 0.02
        [Toggle(_RAMPMAP)] _UseRamp ("Use Ramp Map (overrides shade colors)", Float) = 0
        [NoScaleOffset] _RampTex ("Ramp Map", 2D) = "white" {}
        [Header(Shadows)]
        _ReceiveShadow ("Receive Shadow", Range(0, 1)) = 1
        _ShadowSoftness ("Shadow Edge Softness", Range(0, 0.5)) = 0.05
        _ShadowNormalOffset ("Shadow Receiver Normal Offset (m)", Range(0, 0.1)) = 0.03
        [Header(Specular)]
        [HDR] _SpecColor ("Specular Color", Color) = (1, 1, 1, 1)
        _SpecThreshold ("Specular Threshold", Range(0.5, 1)) = 0.97
        _SpecStrength ("Specular Strength", Range(0, 1)) = 1
        [Header(Rim)]
        [HDR] _RimColor ("Rim Color", Color) = (1, 0.95, 0.9, 1)
        _RimWidth ("Rim Width", Range(0, 1)) = 0.25
        _RimStrength ("Rim Strength", Range(0, 1)) = 0.6
        [Header(Ambient and Extra Lights)]
        _AmbientStrength ("Ambient Strength", Range(0, 1)) = 0.35
        _AddLightStrength ("Additional Lights Strength", Range(0, 2)) = 1
        [Header(Outline)]
        _OutlineColor ("Outline Color", Color) = (0.15, 0.1, 0.12, 1)
        _OutlineWidth ("Outline Width (px)", Range(0, 10)) = 2.5
        [KeywordEnum(Mesh, Tangent, VertexColor)] _OutlineNormal ("Outline Normal Source", Float) = 0
    }

    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)
            float4 _BaseMap_ST;
            half4 _BaseColor, _ShadeColor, _ShadeColor2, _SpecColor, _RimColor, _OutlineColor;
            half  _Threshold, _Shade2Threshold, _Shade2Strength, _Softness;
            half  _ReceiveShadow, _ShadowSoftness, _ShadowNormalOffset;
            half  _SpecThreshold, _SpecStrength, _RimWidth, _RimStrength;
            half  _AmbientStrength, _AddLightStrength, _OutlineWidth;
        CBUFFER_END
        TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
        TEXTURE2D(_RampTex); SAMPLER(sampler_RampTex);
        ENDHLSL

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

            HLSLPROGRAM
            #pragma target 3.5
            #pragma vertex ToonVert
            #pragma fragment frag
            #pragma shader_feature_local_fragment _ _RAMPMAP
            #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 _ _FORWARD_PLUS
            #pragma multi_compile_fog
            #pragma multi_compile_instancing
            #include "Assets/Cookbook/Ch14/ToonCommon.hlsl"

            // 主光的色阶:输入是“半 Lambert × 卡通化后的阴影”,输出是最终的漫反射颜色
            half3 ToonDiffuse(half3 albedo, half halfLambert, half shadow)
            {
                // 接收阴影卡通化:把 PCF 的软阴影值也切成硬边,再“乘进”明暗,而不是在最后乘一次
                half toonShadow = lerp(1.0, ToonBand(shadow, 0.5, _ShadowSoftness), _ReceiveShadow);
                // 投影区域只压到“第一层阴影”的位置,而不是压到 0(那会掉进第二层阴影,比背光面还暗)
                half shadowedX = min(halfLambert, (_Threshold + _Shade2Threshold) * 0.5);
                half x = lerp(shadowedX, halfLambert, toonShadow);
            #if defined(_RAMPMAP)
                return albedo * SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(clamp(x, 0.01, 0.99), 0.5)).rgb;
            #else
                half band1 = ToonBand(x, _Threshold, _Softness);
                half band2 = ToonBand(x, _Shade2Threshold, _Softness);
                half3 shade = lerp(albedo * _ShadeColor2.rgb, albedo * _ShadeColor.rgb, lerp(1.0, band2, _Shade2Strength));
                return lerp(shade, albedo, band1);
            #endif
            }

            // 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑
            half3 ToonAdditional(half3 albedo, half3 N, Light light)
            {
                half atten = light.distanceAttenuation * light.shadowAttenuation;
                half x = saturate(dot(N, light.direction)) * saturate(atten * 4.0);
                return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength;
            }

            half4 frag(ToonVaryings i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i);
                half3 N = normalize(i.normalWS);
                half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb;

                // ---------- 主光 + 阴影 ----------
                // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne)
                float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS + N * _ShadowNormalOffset);
                Light mainLight = GetMainLight(shadowCoord, i.positionWS, half4(1, 1, 1, 1));
                half NoL = dot(N, mainLight.direction);
                half3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color;

                // 高光与边缘光只出现在“真正被照亮”的区域
                half litMask = ToonBand((NoL * 0.5 + 0.5) * lerp(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness);
                half3 H = normalize(mainLight.direction + V);
                half spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask;
                color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color;
                half rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask;
                color = lerp(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength);

                // ---------- 附加光 ----------
            #if defined(_ADDITIONAL_LIGHTS)
                InputData inputData = (InputData)0;         // Forward+ 的光源循环宏需要它
                inputData.positionWS = i.positionWS;
                inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
              #if USE_FORWARD_PLUS
                for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++)
                    color += ToonAdditional(albedo, N, GetAdditionalLight(li, i.positionWS, half4(1, 1, 1, 1)));
              #endif
                uint lightCount = GetAdditionalLightsCount();
                LIGHT_LOOP_BEGIN(lightCount)
                    color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, i.positionWS, half4(1, 1, 1, 1)));
                LIGHT_LOOP_END
            #endif

                // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ----------
                color += SampleSH(half3(0, 0, 0)) * albedo * _AmbientStrength;

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

        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            ZWrite On
            HLSLPROGRAM
            #pragma vertex OutlineVert
            #pragma fragment OutlineFrag
            #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
            #pragma multi_compile_fog
            #pragma multi_compile_instancing
            #include "Assets/Cookbook/Ch14/ToonOutlinePass.hlsl"
            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
        }
    }
}
// 本章的完整卡通 Shader:色阶(两层阴影色 / 可选 Ramp 贴图)+ 卡通化接收阴影 + 风格化高光 + 边缘光
// + 附加光 + 平坦环境光 + 背面外扩描边(裁剪空间等宽,可选平滑法线来源)。(GLSL 版,对应 Ch14/ToonLit.shader)
Shader "Cookbook/Ch14/ToonLit"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseMap_ST;
        uniform vec4 _BaseColor, _ShadeColor, _ShadeColor2, _SpecColor, _RimColor, _OutlineColor;
        uniform float _Threshold, _Shade2Threshold, _Shade2Strength, _Softness;
        uniform float _ReceiveShadow, _ShadowSoftness, _ShadowNormalOffset;
        uniform float _SpecThreshold, _SpecStrength, _RimWidth, _RimStrength;
        uniform float _AmbientStrength, _AddLightStrength, _OutlineWidth;
        uniform sampler2D _BaseMap;
        uniform sampler2D _RampTex;
        ENDGLSL

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

            GLSLPROGRAM
            #pragma shader_feature_local_fragment _ _RAMPMAP
            #define TOON_VARYINGS
            #include "Ch14/ToonCommon.glsl"

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

            #ifdef FRAGMENT
            // 主光的色阶:输入是“半 Lambert × 卡通化后的阴影”,输出是最终的漫反射颜色
            vec3 ToonDiffuse(vec3 albedo, float halfLambert, float shadow)
            {
                // 接收阴影卡通化:把 PCF 的软阴影值也切成硬边,再“乘进”明暗,而不是在最后乘一次
                float toonShadow = mix(1.0, ToonBand(shadow, 0.5, _ShadowSoftness), _ReceiveShadow);
                // 投影区域只压到“第一层阴影”的位置,而不是压到 0(那会掉进第二层阴影,比背光面还暗)
                float shadowedX = min(halfLambert, (_Threshold + _Shade2Threshold) * 0.5);
                float x = mix(shadowedX, halfLambert, toonShadow);
            #if defined(_RAMPMAP)
                return albedo * texture(_RampTex, vec2(clamp(x, 0.01, 0.99), 0.5)).rgb;
            #else
                float band1 = ToonBand(x, _Threshold, _Softness);
                float band2 = ToonBand(x, _Shade2Threshold, _Softness);
                vec3 shade = mix(albedo * _ShadeColor2.rgb, albedo * _ShadeColor.rgb, mix(1.0, band2, _Shade2Strength));
                return mix(shade, albedo, band1);
            #endif
            }

            // 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑
            vec3 ToonAdditional(vec3 albedo, vec3 N, Light light)
            {
                float atten = light.distanceAttenuation * light.shadowAttenuation;
                float x = saturate(dot(N, light.direction)) * saturate(atten * 4.0);
                return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength;
            }

            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb;

                // ---------- 主光 + 阴影 ----------
                // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne)
                vec4 shadowCoord = TransformWorldToShadowCoord(positionWS + N * _ShadowNormalOffset);
                Light mainLight = GetMainLight(shadowCoord, positionWS, vec4(1.0));
                float NoL = dot(N, mainLight.direction);
                vec3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color;

                // 高光与边缘光只出现在“真正被照亮”的区域
                float litMask = ToonBand((NoL * 0.5 + 0.5) * mix(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness);
                vec3 H = normalize(mainLight.direction + V);
                float spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask;
                color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color;
                float rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask;
                color = mix(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength);

                // ---------- 附加光 ----------
            #if defined(_ADDITIONAL_LIGHTS)
                // Forward / Forward+ 的光源循环在网页版里都是普通 for 循环
                int lightCount = GetAdditionalLightsCount();
                for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
                    color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, positionWS, vec4(1.0)));
            #endif

                // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ----------
                color += SampleSH(vec3(0.0)) * albedo * _AmbientStrength;

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

        Pass
        {
            Name "Outline"
            Tags { "LightMode"="SRPDefaultUnlit" }
            Cull Front
            ZWrite On
            GLSLPROGRAM
            #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
            #include "Ch14/ToonOutlinePass.glsl"
            ENDGLSL
        }

        // URP 自带的阴影/深度 Pass 与材质无关,网页版用公共的同名 Pass
        UsePass "Cookbook/Common/SHADOWCASTER"
        UsePass "Cookbook/Common/DEPTHONLY"
        UsePass "Cookbook/Common/DEPTHNORMALS"
    }
}
图 14-19完整效果:身体、四肢和道具使用 ToonLit(描边用烘焙进顶点色的平滑法线),脸部使用 SDF 阴影,头发带天使环,地面接收卡通化的投影;左右两盏彩色点光在地面和物体上留下边界清晰的光斑
图 14-20逐步叠加。从左到右:① 只有一层色阶,脸部按法线着色;② 加接收阴影、第二层阴影色,脸部换成 SDF 阴影;③ 加高光、边缘光与天使环;④ 加描边

从分解图可以看出每个部件的贡献:色阶决定了画面的基调;阴影让物体“落地”,并建立起空间关系;高光和边缘光提供材质感和立体感;描边则一下子把画面拉到了“动画”的感觉上——它往往是对卡通感贡献最大的单一部件。

移动端与性能

这个 Toon Shader 的片元开销比 URP Lit 低(没有 GGX、没有环境反射探针采样),主要的额外开销来自描边 Pass:每个描边物体多一次 Draw Call 和一遍顶点处理,背面的片元绝大多数会被深度测试剔除,填充率开销不大。角色多的场景可以考虑:远处的角色关掉描边(LOD);用 shader_feature_local 把高光、边缘光等做成可剔除的变体;把 Ramp 贴图、阈值图做成很小的单通道贴图。具体收益请在目标设备上用 Frame Debugger 与 Profiler 确认。

✓ 推荐
阴影色、Ramp 交给美术定义;投影折进色阶;高光、边缘光只在受光区出现;描边用平滑法线 + 裁剪空间等宽;脸部用 SDF 阴影。
✗ 避免
暗部 = 固有色 × 系数;最后再乘一次软阴影;纯白的菲涅尔边缘光;用原始硬法线外扩描边;用法线色阶算脸部阴影。
和 Built-in 管线的差异Built-in 管线下,描边 Pass 通常不写 LightMode 或写 "LightMode"="Always",多 Pass Shader 会被依次绘制。URP 只认识特定的 LightMode:没有 LightMode 的 Pass 被当作 SRPDefaultUnlit,而 Built-in 的 ForwardBase、ForwardAdd 在 URP 里根本不会被绘制。迁移老的卡通 Shader 时,最常见的现象就是“描边还在,但主体着色不见了”(主 Pass 还写着 ForwardBase)。