GameTech/URP 渲染实战
CHAPTER 14第 14 章

卡通渲染

卡通渲染(Toon / Cel Shading)不追求物理正确,追求的是“像画出来的”:干净的色块、可控的阴影形状、清晰的轮廓线。本章从最简单的色阶开始,一项项加上阴影色、边缘光、风格化高光、描边、天使环和面部阴影,最后组装成一个完整的 Toon Shader。

本章你会学到
色阶化的三种做法、色阶硬边的抗锯齿,以及阴影色该怎么挑
菲涅尔边缘光与屏幕空间深度边缘光;风格化(方块、分裂)高光
裁剪空间等宽的背面外扩描边,以及平滑法线烘焙工具
天使环、面部 SDF 阴影的原理与程序化生成,卡通化的接收阴影

14.1 卡通渲染在做什么

把一张赛璐珞动画的截图和一张 PBR 渲染放在一起比较,差别一目了然:动画里的明暗只有两三档,每一档内部几乎是平涂的;阴影的形状是画师“设计”过的,脸上不会出现鼻子投下的乱七八糟的影子;物体周围有一圈粗细均匀的轮廓线;高光是一块形状规整的白色色块。卡通渲染要做的,就是在三维管线里把这些特征重现出来。

从实现的角度看,卡通渲染的核心思想只有一句话:把连续的光照量“量化”,再交给美术来决定每一档长什么样。N·L 不再直接当成亮度,而是被切成几段,每段对应一种美术指定的颜色;高光、边缘光也是同理。PBR 关心“这个表面反射了多少光”,卡通渲染关心“这个像素应该涂哪种颜色”。这也意味着很多 PBR 里的规矩在这里不适用:环境光可以是一个常数,阴影颜色可以是紫色的,脸上的明暗交界线可以和法线毫无关系。

半 Lambert 主光阴影 色阶 / Ramp14.2、14.3、14.9 阴影色 1/2 高光 14.5 边缘光 14.4 附加光 / 环境光 脸 SDF / 天使环14.8、14.9 片元颜色ToonForward 第二个 Pass:背面外扩描边(14.6、14.7)
图 14-1本章 Toon Shader 的组成,括号内是对应的小节

本章代码在 Assets/Cookbook/Ch14/。公共函数(顶点结构、色阶、边缘光、风格化高光、发丝高光、描边外扩)集中在 ToonCommon.hlsl 里,每个案例的 Shader 只写自己的那部分,最后的 ToonLit.shader 把它们组装起来。各 Shader 的 ShadowCaster / DepthOnly / DepthNormals Pass 与第 12 章一样直接 include URP 自带的 Pass 文件;着色 Pass 的 LightMode 同样写成 UniversalForwardOnly,保证切换到 Deferred 渲染路径时卡通物体不会消失。

和后面章节的分工本章只讲“在材质 Shader 里能做完的事”。用 Renderer Feature 只给某一层物体描边属于第 25 章,基于深度和法线的后处理描边(Sobel)属于第 26 章;Shader Graph 版本的卡通着色在第 24 章。

案例 14.1色阶化:从 Lambert 到 Ramp入门单独打开 ↗

目标:把平滑的漫反射切成明暗两档(或多档),比较 step、smoothstep 和 Ramp 贴图三种做法。

输入:用半 Lambert 而不是 N·L

色阶的输入通常用半 Lambert(Half Lambert)x=N·L×0.5+0.5,而不是 saturate(N·L)。后者在整个背光面都等于 0,信息全丢了;半 Lambert 把 [−1, 1] 完整地映射到 [0, 1],背光面也有变化,可以在暗部再切出第二层阴影,或者交给 Ramp 贴图画出暗部的颜色过渡。它原本是 Valve 在《半条命 2》里为了让角色暗部不死黑而发明的,在卡通渲染里则主要被当作“查表坐标”使用。

三种量化方式

step:硬边屏幕上会出锯齿 阈值 smoothstep:可控软边过渡宽度 = 2 × softness Ramp 贴图:查表档数、颜色、过渡全由美术画 x = 0x = 1
图 14-2把半 Lambert x 映射为明暗的三种方式
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:band)
// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边)
half ToonBand(half x, half threshold, half softness)
{
    return smoothstep(threshold - softness, threshold + softness, x);
}
// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边)
float ToonBand(float x, float threshold, float softness)
{
    return smoothstep(threshold - softness, threshold + softness, x);
}
Assets/Cookbook/Ch14/ToonRamp.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    Light light = GetMainLight();
    half NoL = dot(N, light.direction);
    half halfLambert = NoL * 0.5 + 0.5;     // 0~1,比 saturate(NoL) 更适合当查表坐标

    half3 color;
#if defined(_RAMP_LAMBERT)
    color = _BaseColor.rgb * saturate(NoL);                      // 对照:普通 Lambert
#elif defined(_RAMP_TEXTURE)
    // Ramp 贴图:横向就是“明暗 → 颜色”的映射表,美术可以随意画色阶与色相
    half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(clamp(halfLambert, 0.01, 0.99), 0.5)).rgb;
    color = _BaseColor.rgb * ramp;
#else
  #if defined(_RAMP_STEP)
    half band = step(_Threshold, halfLambert);                   // 硬边:会有锯齿
  #else
    // 软一点的硬边:过渡宽度至少一个像素(fwidth = 这个量在相邻像素间的变化),硬边也不会出锯齿
    half band = ToonBand(halfLambert, _Threshold, max(_Softness, fwidth(halfLambert)));
  #endif
    // 亮部用固有色,暗部用美术指定的阴影色,而不是“固有色 × 0.5”
    color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band);
    // 可选的第二层阴影:在更暗的位置再切一档
    half band2 = ToonBand(halfLambert, _Shade2Threshold, _Softness);
    color = lerp(lerp(color, _ShadeColor2.rgb, _Shade2Strength), color, band2);
#endif
    color *= light.color;

    // 环境光:只取球谐的常数项(平均环境色),避免环境光把色块又“抹圆”
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * _AmbientStrength;
    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    Light light = GetMainLight();
    float NoL = dot(N, light.direction);
    float halfLambert = NoL * 0.5 + 0.5;     // 0~1,比 saturate(NoL) 更适合当查表坐标

    vec3 color;
#if defined(_RAMP_LAMBERT)
    color = _BaseColor.rgb * saturate(NoL);                      // 对照:普通 Lambert
#elif defined(_RAMP_TEXTURE)
    // Ramp 贴图:横向就是“明暗 → 颜色”的映射表,美术可以随意画色阶与色相
    vec3 ramp = texture(_RampTex, vec2(clamp(halfLambert, 0.01, 0.99), 0.5)).rgb;
    color = _BaseColor.rgb * ramp;
#else
  #if defined(_RAMP_STEP)
    float band = step(_Threshold, halfLambert);                  // 硬边:会有锯齿
  #else
    // 软一点的硬边:过渡宽度至少一个像素(fwidth = 这个量在相邻像素间的变化),硬边也不会出锯齿
    float band = ToonBand(halfLambert, _Threshold, max(_Softness, fwidth(halfLambert)));
  #endif
    // 亮部用固有色,暗部用美术指定的阴影色,而不是“固有色 × 0.5”
    color = mix(_ShadeColor.rgb, _BaseColor.rgb, band);
    // 可选的第二层阴影:在更暗的位置再切一档
    float band2 = ToonBand(halfLambert, _Shade2Threshold, _Softness);
    color = mix(mix(color, _ShadeColor2.rgb, _Shade2Strength), color, band2);
#endif
    color *= light.color;

    // 环境光:只取球谐的常数项(平均环境色),避免环境光把色块又“抹圆”
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * _AmbientStrength;
    color = MixFog(color, fogFactor);
    return vec4(color, 1.0);
}

Ramp 贴图模式下,采样坐标的 u 用 clamp(x, 0.01, 0.99) 而不是直接用 x:即使贴图的 Wrap Mode 设成了 Clamp,双线性过滤在 u = 0 和 u = 1 的最边缘处仍然会取到半个像素之外,钳一下更保险。Ramp 贴图本身要关掉 mip(贴图很窄,mip 会把色阶糊掉),本书公共贴图的导入脚本对 ramp_ 开头的文件已经这样设置。

图 14-3同一光照下的四种漫反射。从左到右:Lambert;step(硬边);smoothstep(软边,暗部使用指定的阴影色);Ramp 贴图(公共贴图 ramp_toon3,三档)

色阶硬边的锯齿

step 的边是“着色锯齿”:它来自片元着色器的输出本身不连续,而 MSAA 只对三角形边缘做多重采样、每个像素仍然只执行一次片元着色,所以 MSAA 救不了它。解决办法是让过渡宽度至少有一个像素:fwidth(x) 返回 x 在相邻像素之间的变化量(|∂x/∂u|+|∂x/∂v|),用它作为 smoothstep 的半宽,就得到一条恰好一两个像素宽的抗锯齿边,无论物体远近都一样细。这就是 ToonRamp.shader 里 max(_Softness, fwidth(halfLambert)) 的用意:美术想要更软的边时用 _Softness,否则至少保证不出锯齿。

图 14-4明暗交界的特写(低分辨率渲染后放大,关闭 MSAA)。左:step,像素台阶清晰可见;右:以 fwidth 为半宽的 smoothstep,边缘仍然锐利,但台阶被一两个像素的过渡抹平
提示fwidth、ddx、ddy 在 2×2 像素块上用差分实现,只能在片元着色器里用,而且在动态分支(依赖于不同像素的 if)内部结果未定义。把它放在分支外面先算好再用,是一个好习惯。

案例 14.2阴影色:不要只是“变暗”入门单独打开 ↗

目标:理解为什么卡通渲染的暗部要用单独指定的颜色,而不是“固有色 × 系数”,并加上第二层阴影。

新手最常见的写法是 lerp(baseColor * 0.5, baseColor, band):暗部就是亮部乘个系数。结果是暗部又灰又脏——乘一个小于 1 的数,等于在 HSV 里同时降低明度和(相对)饱和度,色相不变。可是画师上阴影的时候从来不这么做:受光面偏暖,背光面偏冷,阴影往往是色相偏移(向蓝、紫方向)而饱和度不降甚至略升的颜色。这个规律来自真实世界——暗部主要接收的是天空的蓝色散射光——但被动画和插画大大夸张了。

所以 ToonRamp.shader 的暗部使用 _ShadeColor,由美术直接挑颜色;再用第二个阈值切出更暗的 _ShadeColor2,用于背光最深的区域(例如下巴底下、手臂内侧)。两层阴影在日式卡通渲染里很常见,有的项目叫“1st Shade / 2nd Shade”。

图 14-5左:暗部 = 固有色 × 0.45,发灰发脏;中:手挑的阴影色(偏紫、饱和度不降);右:再加第二层阴影,背光最深处多出一档

挑阴影色的几条经验

① 先定整个场景的“环境色调”(例如偏蓝的天光),所有材质的阴影色都往这个方向偏移,画面才统一。② 皮肤的阴影往红、粉方向偏(模仿次表面散射),金属和布料往冷色偏。③ 阴影色的明度大约是亮部的 60%~75%,比 PBR 的暗部亮得多——卡通画面是靠色相区分明暗,而不是靠明度。④ 用 Ramp 贴图时,可以把这几条直接画进贴图里;用纯色参数时,最好在材质面板上做一个“从固有色自动推算阴影色”的按钮,减少美术的重复劳动。

案例 14.3边缘光:菲涅尔与屏幕空间深度进阶单独打开 ↗

目标:实现三种边缘光——纯菲涅尔、只在受光侧出现的菲涅尔、屏幕空间等宽的深度边缘光,并比较它们的特点。

菲涅尔边缘光

最简单的边缘光就是 1−N·V:法线越垂直于视线,越靠近轮廓,值越大。对它做一次色阶,就得到一条贴着轮廓的亮线:

Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:rim)
// 菲涅尔边缘光:越靠近轮廓(N·V → 0)越亮,再做一次色阶得到“一条线”
half FresnelRim(half3 N, half3 V, half width, half softness)
{
    half f = 1.0 - saturate(dot(N, V));
    return ToonBand(f, 1.0 - width, softness);
}
// 菲涅尔边缘光:越靠近轮廓(N·V → 0)越亮,再做一次色阶得到“一条线”
float FresnelRim(vec3 N, vec3 V, float width, float softness)
{
    float f = 1.0 - saturate(dot(N, V));
    return ToonBand(f, 1.0 - width, softness);
}

纯菲涅尔边缘光会均匀地出现在整个轮廓上,看起来像物体自己在发光,和场景光照毫无关系。卡通渲染里更常见的是让它只出现在受光的一侧(乘一个 N·L 的色阶),像是主光从侧后方“勾”出来的轮廓。

屏幕空间深度边缘光

菲涅尔边缘光有两个缺点:宽度随着曲率变化(平面几乎没有边缘光,球体边缘很宽),而且在物体内部的转折处也会出现。很多二次元风格的游戏改用屏幕空间的方法:沿着法线在屏幕上的投影方向,偏移固定的几个像素去读深度图,如果那里的深度比自己深很多,说明自己在轮廓上,就点亮。这样边缘光在屏幕上永远是固定的像素宽度,而且只出现在真正的轮廓处。

偏移 N 个像素后读深度:是背景(很远)→ 自己在轮廓上,点亮 内部的点:偏移后仍落在自己身上,深度差很小 → 不亮
图 14-6深度边缘光:沿法线的屏幕投影方向偏移若干像素,比较深度
Assets/Cookbook/Ch14/ToonRimSpec.shader(节选:depthrim)
// 屏幕空间深度边缘光:沿法线在屏幕上的投影方向偏移若干像素读深度,
// 如果那里的深度比自己“深很多”,说明自己处在轮廓边上
half DepthRim(ToonVaryings i, half3 N)
{
    float3 normalCS = TransformWorldToHClipDir(N);
    float2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
#if UNITY_UV_STARTS_AT_TOP
    dirPx.y = -dirPx.y;   // 这些平台的像素坐标 y 朝下,与裁剪空间 y 相反
#endif
    int2 p = int2(i.positionCS.xy + dirPx * _RimWidthPx);
    p = clamp(p, int2(0, 0), int2(_ScreenParams.xy) - 1);
    float sceneDepth = LinearEyeDepth(LoadSceneDepth(p), _ZBufferParams);
    float selfDepth = LinearEyeDepth(i.positionCS.z, _ZBufferParams);
    return step(_RimDepthThreshold, sceneDepth - selfDepth);
}
// 屏幕空间深度边缘光:沿法线在屏幕上的投影方向偏移若干像素读深度,
// 如果那里的深度比自己“深很多”,说明自己处在轮廓边上
float DepthRim(vec3 N)
{
    vec3 normalCS = mat3(unity_MatrixVP) * N;     // TransformWorldToHClipDir
    vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // WebGL 的像素坐标 y 朝上,与裁剪空间一致,不需要翻转(HLSL 版的 UNITY_UV_STARTS_AT_TOP 分支)
    ivec2 p = ivec2(gl_FragCoord.xy + dirPx * _RimWidthPx);
    p = clamp(p, ivec2(0), ivec2(_ScreenParams.xy) - 1);
    float sceneDepth = LinearEyeDepth(texelFetch(_CameraDepthTexture, p, 0).r, _ZBufferParams);   // LoadSceneDepth
    float selfDepth = LinearEyeDepth(gl_FragCoord.z, _ZBufferParams);
    return step(_RimDepthThreshold, sceneDepth - selfDepth);
}

这段代码有几个细节:

  • 偏移方向用裁剪空间的法线 TransformWorldToHClipDir(N),并像描边那样乘屏幕尺寸后再归一化,保证横竖方向的像素宽度一致。
  • 直接用 SV_Position.xy(像素坐标)加偏移,再用 LoadSceneDepth 按整数坐标读深度,避开了 UV 翻转的麻烦。只有一点要处理:在 D3D、Metal、Vulkan 这类“像素坐标 y 朝下”的平台(UNITY_UV_STARTS_AT_TOP 为 1)上,裁剪空间的 y 与像素坐标的 y 方向相反,偏移的 y 要取反。
  • 比较的是线性深度(LinearEyeDepth),阈值才有“米”的意义。
注意:深度图什么时候可用不透明物体着色时,_CameraDepthTexture 不一定已经有内容。URP 14 默认会尽量“在不透明物体画完之后拷贝深度”(Copy Depth Mode = After Opaques),这时在不透明 Shader 里读到的是上一帧或空的深度。要让本节的效果成立,需要在 Universal Renderer Data 上把 Depth Texture Mode 设为 Force Prepass(脚本里是 copyDepthMode = CopyDepthMode.ForcePrepass),这样 URP 会在不透明之前先用各物体的 DepthOnly Pass 画一遍深度——这也是为什么我们的 Shader 一定要带 DepthOnly Pass。代价是多一遍几何绘制。
Assets/Cookbook/Ch14/ToonRimSpec.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    half3 T = normalize(i.tangentWS.xyz);
    half3 B = cross(N, T) * i.tangentWS.w;
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    Light light = GetMainLight();
    half3 L = light.direction;
    half NoL = dot(N, L);
    half band = ToonBand(NoL * 0.5 + 0.5, _Threshold, _Softness);
    half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.3;

    // ---------- 高光 ----------
    half3 H = normalize(L + V);
    half spec = 0;
#if defined(_SPEC_BLINN)
    spec = pow(saturate(dot(N, H)), 64.0);                                // 对照:普通 Blinn-Phong
#elif defined(_SPEC_TOON)
    spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005);          // 色阶化:一个硬边色块
#elif defined(_SPEC_STYLIZED)
    spec = StylizedSpecular(N, T, B, H, _SpecShiftScale, _SpecSplit.xy, _SpecSquare, _SpecThreshold, 0.005);
#endif
    color += spec * band * _SpecColor.rgb * light.color;                  // 只在亮部出现

    // ---------- 边缘光 ----------
    half rim = 0;
#if defined(_RIM_FRESNEL)
    rim = FresnelRim(N, V, _RimWidth, 0.02);
#elif defined(_RIM_LIGHTMASKED)
    // 只保留朝光一侧的边缘:边缘光“像是被主光勾出来的”,背光侧不会到处发亮
    rim = FresnelRim(N, V, _RimWidth, 0.02) * ToonBand(NoL, 0.0, 0.1);
#elif defined(_RIM_DEPTH)
    rim = DepthRim(i, N) * ToonBand(NoL, -0.2, 0.1);
#endif
    color = lerp(color, _RimColor.rgb * light.color, rim);

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 T = normalize(tangentWS.xyz);
    vec3 B = cross(N, T) * tangentWS.w;
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light light = GetMainLight();
    vec3 L = light.direction;
    float NoL = dot(N, L);
    float band = ToonBand(NoL * 0.5 + 0.5, _Threshold, _Softness);
    vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.3;

    // ---------- 高光 ----------
    vec3 H = normalize(L + V);
    float spec = 0.0;
#if defined(_SPEC_BLINN)
    spec = pow(saturate(dot(N, H)), 64.0);                                // 对照:普通 Blinn-Phong
#elif defined(_SPEC_TOON)
    spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005);          // 色阶化:一个硬边色块
#elif defined(_SPEC_STYLIZED)
    spec = StylizedSpecular(N, T, B, H, _SpecShiftScale, _SpecSplit.xy, _SpecSquare, _SpecThreshold, 0.005);
#endif
    color += spec * band * _SpecColor.rgb * light.color;                  // 只在亮部出现

    // ---------- 边缘光 ----------
    float rim = 0.0;
#if defined(_RIM_FRESNEL)
    rim = FresnelRim(N, V, _RimWidth, 0.02);
#elif defined(_RIM_LIGHTMASKED)
    // 只保留朝光一侧的边缘:边缘光“像是被主光勾出来的”,背光侧不会到处发亮
    rim = FresnelRim(N, V, _RimWidth, 0.02) * ToonBand(NoL, 0.0, 0.1);
#elif defined(_RIM_DEPTH)
    rim = DepthRim(N) * ToonBand(NoL, -0.2, 0.1);
#endif
    color = mix(color, _RimColor.rgb * light.color, rim);

    color = MixFog(color, fogFactor);
    return vec4(color, 1.0);
}
图 14-7主光从左后方照来。从左到右:无边缘光;纯菲涅尔,整圈轮廓都亮;只在受光侧的菲涅尔;屏幕空间深度边缘光,宽度固定、只出现在左侧轮廓,但在头与身体相接处出现了几处碎点

图中最右一格头与身体交界处的碎点,是深度边缘光的典型问题:头部下缘偏移后读到的是身体的深度,深度差超过了阈值。可以按物体的大小调大阈值、只在法线朝向侧面时启用(乘 1−|Nview.z|),或者对角色单独渲染一张深度来比较。

案例 14.4风格化高光:圆斑、色块、方块进阶单独打开 ↗

目标:把高光做成一块硬边色块,并通过平移、缩放、分裂、方形化来控制它的形状。

最基本的卡通高光就是对 Blinn-Phong 的 N·H 做色阶:ToonBand(N·H, 0.97, ...),得到一块硬边的圆形。但卡通美术常常想要更有设计感的形状:拉长的、偏到一边的、方形的(像窗户的反光)、分成两三块的。Anjyo 和 Hiramitsu 在 2003 年的论文《Stylized Highlights for Cartoon Rendering》里给出了一套方法:既然高光的形状由“H 离 N 有多近”决定,那就在切线空间里直接对 H 动手脚。

  • 平移:H←normalize(H+αT+βB),高光整体挪动。
  • 方向缩放:H←normalize(H−σ(H·T)T),削弱 H 在 T 方向的分量,高光在 T 方向被拉长。
  • 分裂:H←normalize(H−γsign(H·T)T),T 方向的两侧各往外推,一个高光分成两块(T、B 同时分裂则成四块)。
  • 方形化:H 在切平面上的分量 (x, y) = (H·T, H·B) 的长度就是 sin∠(N,H),“N·H ≥ 阈值”等价于“这个长度 ≤ sin(阈值角)”。把长度(L2 范数)换成 Lp 范数 (|x|p+|y|p)1/p,p = 2 是圆,p 越大越接近正方形。原论文的方形化用的是另一个基于角度的公式,在阈值较高时形状控制不够直接,这里换成了等价思路下更好调的 Lp 范数写法。
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:spec)
// 风格化高光(思路来自 Anjyo & Hiramitsu 2003):在切线空间里对半角向量 H 做平移、缩放、分裂,
// 最后用“高光区域的形状”切出硬边。T/B 是表面的切线与副切线。
half StylizedSpecular(half3 N, half3 T, half3 B, half3 H, half4 shiftScale, half2 split, half square, half threshold, half softness)
{
    H = normalize(H + shiftScale.x * T + shiftScale.y * B);                 // 平移:整体挪动高光
    H = normalize(H - shiftScale.z * dot(H, T) * T - shiftScale.w * dot(H, B) * B);   // 方向缩放:拉长/压扁
    H = normalize(H - split.x * sign(dot(H, T)) * T - split.y * sign(dot(H, B)) * B); // 分裂:一分为二/四
    // 方形化:H 在切平面上的分量 (x, y) 的长度就是 sin(N,H)。“N·H ≥ 阈值”等价于“长度 ≤ sin(阈值角)”。
    // 把长度换成 Lp 范数,p = 2 是圆,p 越大越接近正方形
    half2 xy = abs(half2(dot(H, T), dot(H, B)));
    half p = lerp(2.0, 10.0, square);
    half d = pow(pow(xy.x, p) + pow(xy.y, p), 1.0 / p);
    half sinTh = sqrt(saturate(1.0 - threshold * threshold));
    return smoothstep(-softness, softness, sinTh - d) * step(0.0, dot(N, H));
}
// 风格化高光(思路来自 Anjyo & Hiramitsu 2003):在切线空间里对半角向量 H 做平移、缩放、分裂,
// 最后用“高光区域的形状”切出硬边。T/B 是表面的切线与副切线。
float StylizedSpecular(vec3 N, vec3 T, vec3 B, vec3 H, vec4 shiftScale, vec2 split, float square, float threshold, float softness)
{
    H = normalize(H + shiftScale.x * T + shiftScale.y * B);                 // 平移:整体挪动高光
    H = normalize(H - shiftScale.z * dot(H, T) * T - shiftScale.w * dot(H, B) * B);   // 方向缩放:拉长/压扁
    H = normalize(H - split.x * sign(dot(H, T)) * T - split.y * sign(dot(H, B)) * B); // 分裂:一分为二/四
    // 方形化:H 在切平面上的分量 (x, y) 的长度就是 sin(N,H)。“N·H ≥ 阈值”等价于“长度 ≤ sin(阈值角)”。
    // 把长度换成 Lp 范数,p = 2 是圆,p 越大越接近正方形
    vec2 xy = abs(vec2(dot(H, T), dot(H, B)));
    float p = mix(2.0, 10.0, square);
    float d = pow(pow(xy.x, p) + pow(xy.y, p), 1.0 / p);
    float sinTh = sqrt(saturate(1.0 - threshold * threshold));
    return smoothstep(-softness, softness, sinTh - d) * step(0.0, dot(N, H));
}
图 14-8从左到右:Blinn-Phong 原始高光;色阶化的硬边圆斑;平移 + 沿切线方向缩放,变成横向的长条;分裂 + 方形化,变成“田”字形的窗户反光

这些变换都依赖切线 T、B 的方向,所以和第 12 章的各向异性一样,需要模型有合理的切线。对角色来说,通常在头发和眼睛上用得最多(眼睛的高光几乎都是贴图画的,但头发、金属饰品、头盔上的风格化高光很适合用这种方法)。

14.5 描边:背面外扩法

描边是卡通渲染最有辨识度的特征。实时渲染里有三大类做法:

  1. 背面外扩(Inverted Hull):把模型的背面沿法线推出去一点,用纯色画出来,露在正面外面的那一圈就是轮廓线。只需要在材质里多加一个 Pass,线宽可控、可以按顶点调粗细,是角色描边最常用的方法,也是本节的内容。
  2. 后处理边缘检测:在屏幕空间对深度、法线(甚至物体 ID)做 Sobel、Roberts 等边缘检测,一次处理整个画面。适合场景物件、建筑,详见第 26 章。
  3. 几何边线:在 CPU 或几何着色器里找出轮廓边、折痕边,生成线条网格。质量最高,但成本和复杂度也最高,游戏里较少见。
第一个 Pass:正常着色(剔除背面) 第二个 Pass:Cull Front 只画背面, 每个顶点沿法线外推,输出描边色 → 被推大的背面从正面边缘露出一圈 裁剪空间等宽: dir = normalize(NCS.xy × 屏幕尺寸) posCS.xy += dir × 宽度px × 2 / 屏幕尺寸 × posCS.w
图 14-9背面外扩描边的原理,以及“屏幕上等宽”的外扩公式

案例 14.5裁剪空间等宽描边进阶单独打开 ↗

目标:实现背面外扩描边,比较物体空间外扩与裁剪空间外扩的线宽差异,并理解为什么一个 Pass 用 SRPDefaultUnlit 就能被 URP 自动绘制。

描边 Pass 放在哪

URP 的不透明绘制(DrawObjectsPass)会收集三种 LightMode 的 Pass:UniversalForward、UniversalForwardOnly 和 SRPDefaultUnlit(没有写 LightMode 的 Pass 也视为后者)。所以只要把描边 Pass 的 LightMode 设为 SRPDefaultUnlit,它就会和着色 Pass 一起被画出来,不需要任何 Renderer Feature。限制是:一个 Shader 里同一个 LightMode 只会被画一次,所以只能有一个描边 Pass;如果需要更多 Pass(例如额外的发光层),就得用 Renderer Feature 的 RenderObjects 或自定义 ShaderTagId(第 25 章)。

顺便说一下这种多 Pass 写法与 SRP Batcher 的关系:两个 Pass 用同一个 UnityPerMaterial 常量缓冲区(描边颜色、线宽也放在里面),SRP Batcher 依然兼容;但每个物体会多一次 Draw Call,场景里描边物体很多时要注意。

Assets/Cookbook/Ch14/ToonOutline.shader(节选:outlinepass)
Pass
{
    Name "Outline"
    // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode,
    // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature
    Tags { "LightMode"="SRPDefaultUnlit" }
    Cull Front      // 只画背面
    ZWrite On

    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP
    #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
    #pragma multi_compile_fog
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch14/ToonCommon.hlsl"

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

    // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线
    float3 GetOutlineNormalOS(ToonAttributes v)
    {
    #if defined(_OUTLINENORMAL_TANGENT)
        return v.tangentOS.xyz;                               // 物体空间平滑法线
    #elif defined(_OUTLINENORMAL_VERTEXCOLOR)
        float3 nTS = v.color.rgb * 2.0 - 1.0;                  // 切线空间平滑法线
        float3 bitangentOS = cross(v.normalOS, v.tangentOS.xyz) * v.tangentOS.w;
        return normalize(nTS.x * v.tangentOS.xyz + nTS.y * bitangentOS + nTS.z * v.normalOS);
    #else
        return v.normalOS;
    #endif
    }

    OutlineVaryings vert(ToonAttributes v)
    {
        OutlineVaryings o;
        UNITY_SETUP_INSTANCE_ID(v);
        UNITY_TRANSFER_INSTANCE_ID(v, o);
        float3 n = GetOutlineNormalOS(v);
    #if defined(_OUTLINESPACE_OBJECT)
        o.positionCS = OutlinePositionCS_ObjectSpace(v.positionOS.xyz, n, _OutlineWidthOS);
    #else
        o.positionCS = OutlinePositionCS(v.positionOS.xyz, n, _OutlineWidth);
    #endif
        o.fogFactor = ComputeFogFactor(o.positionCS.z);
        return o;
    }

    half4 frag(OutlineVaryings i) : SV_Target
    {
        return half4(MixFog(_OutlineColor.rgb, i.fogFactor), 1);
    }
    ENDHLSL
}
Pass
{
    Name "Outline"
    // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode,
    // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature
    Tags { "LightMode"="SRPDefaultUnlit" }
    Cull Front      // 只画背面
    ZWrite On

    GLSLPROGRAM
    #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP
    #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR
    #include "Ch14/ToonCommon.glsl"

    VARYING float fogFactor;

    #ifdef VERTEX
    // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线
    vec3 GetOutlineNormalOS()
    {
    #if defined(_OUTLINENORMAL_TANGENT)
        return tangentOS.xyz;                               // 物体空间平滑法线
    #elif defined(_OUTLINENORMAL_VERTEXCOLOR)
        vec3 nTS = color.rgb * 2.0 - 1.0;                    // 切线空间平滑法线
        vec3 bitangentOS = cross(normalOS, tangentOS.xyz) * tangentOS.w;
        return normalize(nTS.x * tangentOS.xyz + nTS.y * bitangentOS + nTS.z * normalOS);
    #else
        return normalOS;
    #endif
    }

    void main()
    {
        vec3 n = GetOutlineNormalOS();
    #if defined(_OUTLINESPACE_OBJECT)
        gl_Position = OutlinePositionCS_ObjectSpace(positionOS.xyz, n, _OutlineWidthOS);
    #else
        gl_Position = OutlinePositionCS(positionOS.xyz, n, _OutlineWidth);
    #endif
        fogFactor = ComputeFogFactor(gl_Position.z);
    }
    #endif

    #ifdef FRAGMENT
    void main() { fragColor = vec4(MixFog(_OutlineColor.rgb, fogFactor), 1.0); }
    #endif
    ENDGLSL
}

外扩的数学

最直观的写法是在物体空间里推:positionOS + normalOS * width。问题是这个“宽度”是世界单位,经过透视投影后,近处的线粗、远处的线细,物体缩放后线宽也跟着变——这有时是想要的(近大远小更有空间感),但大多数卡通项目希望线宽在屏幕上保持恒定。

恒定像素宽度的做法是在裁剪空间里推。顶点经过 MVP 变换后得到 pCS,之后 GPU 会做透视除法 pNDC=pCS.xy/pCS.w。NDC 的 [−1, 1] 对应整个屏幕,所以一个像素在 NDC 里的大小是 2/屏幕尺寸。我们想在 NDC 里偏移 d·2/屏幕尺寸(d 为像素数),那么在除法之前就要偏移这个量乘以 w:

pCS.xy+=n^·d·2屏幕尺寸·pCS.w
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:outline)
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。
//   normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素)
float4 OutlinePositionCS(float3 positionOS, float3 normalOS, float widthPx)
{
    float4 positionCS = TransformObjectToHClip(positionOS);
    float3 normalWS = TransformObjectToWorldNormal(normalOS);
    float3 normalCS = TransformWorldToHClipDir(normalWS, true);   // 方向向量只受 VP 的 3×3 部分影响
    // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比),
    // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致
    float2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法
    float2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy;
    positionCS.xy += offset * positionCS.w;
    return positionCS;
}

// 对照组:物体空间外扩(线宽随距离变化,近粗远细)
float4 OutlinePositionCS_ObjectSpace(float3 positionOS, float3 normalOS, float width)
{
    return TransformObjectToHClip(positionOS + normalize(normalOS) * width);
}
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。
//   normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素)
vec4 OutlinePositionCS(vec3 posOS, vec3 nrmOS, float widthPx)
{
    vec4 positionCS = TransformObjectToHClip(posOS);
    vec3 nWS = TransformObjectToWorldNormal(nrmOS);
    vec3 normalCS = mat3(unity_MatrixVP) * nWS;   // 方向向量只受 VP 的 3×3 部分影响(= TransformWorldToHClipDir)
    // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比),
    // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致
    vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法
    vec2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy;
    positionCS.xy += offset * positionCS.w;
    return positionCS;
}

// 对照组:物体空间外扩(线宽随距离变化,近粗远细)
vec4 OutlinePositionCS_ObjectSpace(vec3 posOS, vec3 nrmOS, float width)
{
    return TransformObjectToHClip(posOS + normalize(nrmOS) * width);
}

方向 n^ 来自法线在裁剪空间里的 xy 分量。这里有个常被忽略的细节:NDC 的 x 和 y 方向“一个单位等于多少像素”不同(宽高比),如果直接 normalize(normalCS.xy),宽屏上横向的线会比纵向的粗。正确的做法是先乘屏幕尺寸换到像素空间再归一化,最后再除回去。另外,法线是方向向量,变换到裁剪空间只用 VP 矩阵的 3×3 部分,对应 URP 的 TransformWorldToHClipDir。

图 14-10由近及远的五个胶囊。左:物体空间外扩,近处的线很粗、远处几乎看不见;右:裁剪空间外扩,所有胶囊的线宽都是 2.5 像素
调参建议完全等宽在近景特写时会显得线太细、远景时又显得太粗,很多项目会在两者之间折中:按相机距离把线宽乘一个钳制过的系数(例如 clamp(distance / 参考距离, 0.5, 1.5)),或者按 FOV 修正。顶点色的 A 通道常用来做“线宽遮罩”:在鼻尖、指尖、衣服褶皱内侧把线宽刷成 0,避免内部出现多余的线。描边颜色也不一定是黑色,用固有色压暗、偏色后的颜色,画面会柔和很多。

案例 14.6平滑法线烘焙工具进阶单独打开 ↗

目标:解决硬边模型的描边断裂问题——写一个编辑器工具,把平滑后的法线烘焙进切线或顶点色;并在拍摄脚本里对程序化网格调用同一个烘焙函数,对比效果。

为什么硬边会断

立方体的一个角在网格里其实是三个独立的顶点:位置相同,但各自带着三个面的法线(否则每个面就不会是平的)。描边 Pass 把它们分别沿三个不同的方向推出去,原本连在一起的角就裂开了,露出一道缝,线条在棱角处断开。凡是有硬边(光滑组断开、UV 接缝处法线不同)的模型都会有这个问题。

原始法线:同一个角被推向两个方向,出现缺口 平均后的平滑法线:同一位置只朝一个方向推
图 14-11硬边处描边断裂的原因(俯视示意):着色要用分开的法线,描边要用平均后的法线

解决办法是给描边单独准备一套“平滑法线”:对每个空间位置,把所有共享这个位置的顶点所属面的法线加起来求平均。着色仍然用原来的硬法线,描边用平滑法线。平滑法线需要存在某个顶点属性里,常见的选择有两个:

存放位置存什么优点缺点
切线 tangent.xyz物体空间的平滑法线最简单,Shader 里直接用切线被占用,法线贴图、各向异性都不能用了;蒙皮时 Unity 会按切线变换它,虽然方向仍正确,但语义被混用
顶点色 color.rgb切线空间的平滑法线(×0.5+0.5)不占切线;存在切线空间里,骨骼动画、形变后依然正确顶点色被占用;精度只有 8 位(一般够用);Shader 里要多一次 TBN 变换

也有项目存在 UV2~UV7 里,原理相同。下面的工具同时支持前两种:

Assets/Cookbook/Ch14/Editor/SmoothNormalBaker.cs(节选:bake)
public static void Bake(Mesh mesh, Target target)
{
    var verts = mesh.vertices;
    var tris = mesh.triangles;
    var normals = mesh.normals;
    var tangents = mesh.tangents;
    if (tangents == null || tangents.Length != verts.Length)
    {
        mesh.RecalculateTangents();
        tangents = mesh.tangents;
    }

    // 1) 按位置分组:硬边处同一个位置有好几个顶点(各自带不同法线)
    var groups = new Dictionary<Vector3Int, Vector3>();
    Vector3Int Key(Vector3 p) => new Vector3Int(Mathf.RoundToInt(p.x * 1e4f), Mathf.RoundToInt(p.y * 1e4f), Mathf.RoundToInt(p.z * 1e4f));

    // 2) 累加每个三角形的面法线,用“顶点处的夹角”加权(比按面积加权更不受三角剖分影响)
    for (int t = 0; t < tris.Length; t += 3)
    {
        Vector3 p0 = verts[tris[t]], p1 = verts[tris[t + 1]], p2 = verts[tris[t + 2]];
        Vector3 fn = Vector3.Cross(p1 - p0, p2 - p0).normalized;
        for (int k = 0; k < 3; k++)
        {
            Vector3 a = verts[tris[t + k]], b = verts[tris[t + (k + 1) % 3]], c = verts[tris[t + (k + 2) % 3]];
            float angle = Vector3.Angle(b - a, c - a) * Mathf.Deg2Rad;
            var key = Key(a);
            groups.TryGetValue(key, out var sum);
            groups[key] = sum + fn * angle;
        }
    }

    // 3) 写回
    var outTangents = new Vector4[verts.Length];
    var outColors = new Color[verts.Length];
    var oldColors = mesh.colors;
    for (int i = 0; i < verts.Length; i++)
    {
        Vector3 smooth = groups[Key(verts[i])].normalized;
        if (target == Target.Tangent)
        {
            outTangents[i] = new Vector4(smooth.x, smooth.y, smooth.z, 1);
        }
        else
        {
            // 物体空间 → 该顶点自己的切线空间(T, B, N)
            Vector3 n = normals[i];
            Vector3 tg = tangents[i];
            Vector3 bt = Vector3.Cross(n, tg) * tangents[i].w;
            var ts = new Vector3(Vector3.Dot(smooth, tg), Vector3.Dot(smooth, bt), Vector3.Dot(smooth, n));
            float alpha = oldColors != null && oldColors.Length == verts.Length ? oldColors[i].a : 1f;  // A 通道留给线宽遮罩
            outColors[i] = new Color(ts.x * 0.5f + 0.5f, ts.y * 0.5f + 0.5f, ts.z * 0.5f + 0.5f, alpha);
        }
    }
    if (target == Target.Tangent) mesh.tangents = outTangents;
    else mesh.colors = outColors;
}

几个实现上的考虑:

  • 按位置分组时,坐标先乘 10⁴ 再取整作为字典的键,容忍浮点误差;如果模型的“同一位置”误差更大(比如导出时精度损失),可以把系数调小。
  • 按夹角加权平均面法线,而不是简单相加或按面积加权:一个四边形面被三角化成两个三角形时,按夹角加权不会让这个面的法线被算两次,结果与三角剖分方式无关。
  • 存进顶点色时,把物体空间的平滑法线投影到该顶点自己的 TBN 上。注意同一个位置的几个顶点切线空间各不相同,存进去的颜色也不同,但在 Shader 里各自变换回物体空间后会得到同一个方向。A 通道保留原来的值,留给线宽遮罩。
Assets/Cookbook/Ch14/Editor/SmoothNormalBaker.cs(节选:menu)
[MenuItem("Cookbook/Ch14/Bake Smooth Normals (Tangent)")]
static void BakeToTangent() => BakeSelection(Target.Tangent);

[MenuItem("Cookbook/Ch14/Bake Smooth Normals (Vertex Color)")]
static void BakeToColor() => BakeSelection(Target.VertexColor);

static void BakeSelection(Target target)
{
    foreach (var obj in Selection.objects)
    {
        string path = AssetDatabase.GetAssetPath(obj);
        var meshes = new List<Mesh>();
        if (obj is Mesh m) meshes.Add(m);
        else foreach (var a in AssetDatabase.LoadAllAssetsAtPath(path)) if (a is Mesh mm) meshes.Add(mm);

        foreach (var src in meshes)
        {
            var copy = Object.Instantiate(src);   // 不改原始模型,另存一份
            copy.name = src.name + "_SmoothNormals";
            Bake(copy, target);
            string dir = System.IO.Path.GetDirectoryName(path);
            string outPath = AssetDatabase.GenerateUniqueAssetPath($"{dir}/{copy.name}.asset");
            AssetDatabase.CreateAsset(copy, outPath);
            Debug.Log($"[Ch14] 平滑法线已烘焙到 {target}:{outPath}");
        }
    }
    AssetDatabase.SaveAssets();
}

[MenuItem("Cookbook/Ch14/Bake Smooth Normals (Tangent)", true)]
[MenuItem("Cookbook/Ch14/Bake Smooth Normals (Vertex Color)", true)]
static bool Validate() => Selection.objects.Length > 0;

菜单在 Project 窗口选中模型后使用:它会复制一份网格、烘焙后另存为 xxx_SmoothNormals.asset,不修改原始模型。正式项目里更好的做法是写一个 AssetPostprocessor,在模型导入时自动烘焙(OnPostprocessModel 里调用同一个 Bake),美术重新导出模型也不会丢。

拍摄脚本对程序化生成的硬边网格(复制的内置立方体、六棱柱、低多边形宝石)直接调用同一个 SmoothNormalBaker.Bake:

图 14-12三个硬边网格(立方体、六棱柱、低多边形宝石),线宽 5 像素。左:用原始法线外扩,所有棱角处的描边都断开了;右:用烘焙进切线的平滑法线外扩,轮廓连续
常见坑① 模型导入设置里 Normals 如果选了 Calculate 并设了平滑角度,导入时 Unity 会重新拆分顶点,烘焙要在导入之后做。② 选“存进切线”时,导入设置的 Tangents 不能是 Calculate(否则每次重新导入切线都会被覆盖),要么用另存的 .asset,要么在后处理器里写入。③ 平滑法线只解决“断开”,解决不了“极薄的部位描边穿插到另一侧”的问题(例如很薄的裙摆、刘海),那需要配合顶点色线宽遮罩,或者把描边顶点沿观察方向往后推一点(Z Offset)。

案例 14.7头发天使环进阶单独打开 ↗

目标:做出二次元角色头顶那圈亮环(天使环,Angel Ring),比较写实的连续发丝高光与色阶化的天使环。

天使环其实就是第 12 章 Kajiya-Kay 发丝高光的卡通版:发丝沿着头型从头顶往下垂,所有发丝的“T 垂直于 H”的位置连起来,正好是环绕头部的一圈。卡通化的处理有三步:用 KK 算出连续的高光,用阈值切成硬边色块,再用每缕头发不同的偏移量把边缘切成锯齿(像画师画的一束一束的高光)。

Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:strand)
// 发丝高光的核心(Kajiya-Kay):沿发丝方向 T 的“圆柱”高光,sin(T,H) 越接近 1 越亮
half StrandSpecular(half3 T, half3 N, half3 H, half shift, half exponent)
{
    T = normalize(T + shift * N);              // 沿法线挪动切线 = 让高光带上下移动
    half ToH = dot(T, H);
    half sinTH = sqrt(saturate(1.0 - ToH * ToH));
    return smoothstep(-1.0, 0.0, ToH) * pow(sinTH, exponent);
}
// 发丝高光的核心(Kajiya-Kay):沿发丝方向 T 的“圆柱”高光,sin(T,H) 越接近 1 越亮
float StrandSpecular(vec3 T, vec3 N, vec3 H, float shift, float exponent)
{
    T = normalize(T + shift * N);              // 沿法线挪动切线 = 让高光带上下移动
    float ToH = dot(T, H);
    float sinTH = sqrt(saturate(1.0 - ToH * ToH));
    return smoothstep(-1.0, 0.0, ToH) * pow(sinTH, exponent);
}
Assets/Cookbook/Ch14/ToonHair.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    half3 Tm = normalize(i.tangentWS.xyz);
    half3 Bm = cross(N, Tm) * i.tangentWS.w;
    half3 T = _StrandAlongV > 0.5 ? Bm : Tm;          // 发丝方向
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    Light light = GetMainLight();

    half band = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.02);
    half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.3;

    // 天使环的“光源”:可以跟主光走,也可以固定在相机上方——后者让环始终出现在头顶,
    // 这是很多二次元项目的选择(美术要的是稳定的造型,而不是物理正确)
    half3 viewUp = normalize(UNITY_MATRIX_I_V._m01_m11_m21);
    half3 Lring = normalize(lerp(light.direction, normalize(V + viewUp * 0.6), _RingFollowView));
    half3 H = normalize(Lring + V);

    half noise = SAMPLE_TEXTURE2D(_ShiftTex, sampler_ShiftTex, TRANSFORM_TEX(i.uv, _ShiftTex)).r;
    half shift = _RingShift + (noise - 0.5) * _RingNoise;   // 每缕头发偏移不同 → 锯齿边
    half s = StrandSpecular(T, N, H, shift, _RingExponent);

    half ring = 0;
#if defined(_RING_SMOOTH)
    ring = s;                                               // 写实版:连续的发丝高光
#elif defined(_RING_TOON)
    ring = ToonBand(s, _RingThreshold, 0.02);               // 卡通版:切成硬边色块
#endif
    color = lerp(color, _RingColor.rgb * lerp(0.6, 1.0, band), ring * 0.9);

    return half4(MixFog(color, i.fogFactor), 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 Tm = normalize(tangentWS.xyz);
    vec3 Bm = cross(N, Tm) * tangentWS.w;
    vec3 T = _StrandAlongV > 0.5 ? Bm : Tm;          // 发丝方向
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light light = GetMainLight();

    float band = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.02);
    vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.3;

    // 天使环的“光源”:可以跟主光走,也可以固定在相机上方——后者让环始终出现在头顶,
    // 这是很多二次元项目的选择(美术要的是稳定的造型,而不是物理正确)
    vec3 viewUp = normalize(UNITY_MATRIX_I_V[1].xyz);   // 逆观察矩阵的第 2 列 = 相机的“上”(HLSL 的 _m01_m11_m21)
    vec3 Lring = normalize(mix(light.direction, normalize(V + viewUp * 0.6), _RingFollowView));
    vec3 H = normalize(Lring + V);

    float noise = texture(_ShiftTex, TransformTex(uv, _ShiftTex_ST)).r;
    float shift = _RingShift + (noise - 0.5) * _RingNoise;   // 每缕头发偏移不同 → 锯齿边
    float s = StrandSpecular(T, N, H, shift, _RingExponent);

    float ring = 0.0;
#if defined(_RING_SMOOTH)
    ring = s;                                               // 写实版:连续的发丝高光
#elif defined(_RING_TOON)
    ring = ToonBand(s, _RingThreshold, 0.02);               // 卡通版:切成硬边色块
#endif
    color = mix(color, _RingColor.rgb * mix(0.6, 1.0, band), ring * 0.9);

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

值得注意的是“天使环跟着谁走”。物理上发丝高光由光源方向决定,主光一转,环就跑到头的侧面甚至消失了。很多二次元项目希望天使环稳定地出现在头顶附近——它是角色造型的一部分,而不是光照的结果。代码里的 _RingFollowView 让高光的“光源”在主光和“相机上方”之间插值:等于 1 时,假想的光源永远位于相机略上方,天使环就始终出现在画面中头部的上半部分。这是卡通渲染“以美术意图优先”的一个典型例子。

图 14-13从左到右:无高光;连续的 Kajiya-Kay 发丝高光(写实);色阶化的天使环,边缘按发束噪声切成锯齿
提示很多项目的天使环直接画在贴图里(或者用一张 Matcap),并用 UV 动画让它随视角滑动。程序化做法的好处是自动适配任意发型和视角,坏处是需要合理的发丝方向(切线或 UV),对于由大量发片组成的发型,发片 UV 的方向一定要统一。

14.8 面部 SDF 阴影

用法线算明暗,在脸上效果往往很糟:鼻子、眼窝、嘴唇的起伏会让明暗交界线碎成一块块,随着头部转动不停跳动。动画里的脸部阴影是画师设计好的几种形状:正面光时脸上几乎没有阴影;侧光时阴影从脸颊一侧推进,在鼻子处形成一个干净的三角形鼻影;光从更侧后方照来时,只剩脸的一条边被照亮。

解决方案是让脸部的明暗与法线无关,只取决于“光从哪个水平角度照来”。具体做法是预先制作一张灰度图,每个像素存一个阈值:当光源从正面转到侧面、转过的角度(归一化到 0~1)超过这个阈值时,这个像素就进入阴影。运行时只要算出当前的光照角度 φ,和贴图里的阈值比较一下即可。这张图在社区里通常被叫作“脸部 SDF 图”,因为它的制作过程用到了有符号距离场。

F:脸朝向 L(投影到水平面) φ 俯视:φ = acos(F·L)/π,0 = 正面光,1 = 背光 φ=22° φ=67° φ=112° ① 每个角度一张二值图(白 = 亮) 阈值图 ② 每张图算 SDF,在相邻两张之间找过零点 ③ 过零的“角度”就是该像素的阈值 → 合成一张灰度图 运行时:threshold > φ 则亮;光从另一侧来时把 U 镜像
图 14-14面部阈值图的原理:多张不同光照角度下的明暗图,合成一张“开始变暗的角度”图

阈值图是怎么做出来的

制作流程分三步:

  1. 美术针对若干个光照角度(例如 0°、22.5°、45°……180°,共 9 张)分别画出“这个角度下脸上哪里亮”的二值图。要求是单调的:角度越大,亮区只能缩小,不能在别处冒出新的亮区。
  2. 对每张二值图计算有符号距离场(SDF):每个像素到明暗边界的距离,亮区为正、暗区为负。
  3. 对每个像素,找到它“从亮变暗”的那两张相邻图 k 和 k+1:在第 k 张里 SDF ≥ 0,第 k+1 张里 SDF < 0。两张图的 SDF 在这个像素上线性插值,过零点的位置 t=sk/(sk−sk+1) 就是“在两个角度之间的哪个位置变暗”,阈值 = (k + t) / (N − 1)。

为什么要用 SDF 插值,而不是直接把 9 张二值图平均?因为平均只能得到 10 级灰度,相邻两个角度之间的阴影边界会“跳”过去;SDF 插值让边界在两张图之间平滑地移动,哪怕只有寥寥几张关键图,运行时的阴影边界也能连续变化。这和字体渲染里用 SDF 让字形边缘在任意缩放下平滑是同一个道理。

案例 14.8程序生成脸部阈值图与 SDF 面部阴影高阶单独打开 ↗

目标:在 C# 里程序生成一套“二值图 → SDF → 阈值图”的完整流程(用一条带鼻子凸起的解析曲线代替美术手绘),在 Shader 里按光照角度查表,对比法线色阶与 SDF 阴影。

第一步,生成二值图。真实项目里这是美术画的;这里用一条解析曲线描述“光从右侧、角度为 φ 时,第 y 行的明暗交界线在哪”:基础形状是 x=−cosφ(正面光时交界线在最左边,90° 时在正中,背光时在最右边),再在鼻子的高度加一个凸起、在眼窝和下巴处加一点起伏。凸起量乘了 sin φ 且系数小于 1,保证交界线随 φ 单调移动:

Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:mask)
// 光从画面右侧、与脸正前方成 phi(弧度,0~π)时,第 y 行的明暗交界线在哪(X ∈ [-1, 1])
static float Boundary(float phi, float y)
{
    // 鼻子:交界线在鼻子高度处向暗侧“凸出”一块(鼻子挡光,留下鼻影)
    float nose = Mathf.Exp(-Mathf.Pow((y - 0.47f) / 0.07f, 2));
    // 眼窝与下巴:上下两端各稍微内收,交界线不再是一条竖直线
    float shape = 0.18f * Mathf.Exp(-Mathf.Pow((y - 0.6f) / 0.12f, 2)) - 0.25f * Mathf.Pow(Mathf.Max(0, 0.35f - y) / 0.35f, 2);
    // 把起伏乘上 sin(phi):正面光和背光时没有影子形状可言;系数 < 1 保证随 phi 单调
    float p = phi + (0.55f * nose + shape) * Mathf.Sin(phi);
    return -Mathf.Cos(Mathf.Clamp(p, 0, Mathf.PI));
}

// 第 k 张二值图:1 = 亮,0 = 暗
public static bool[] Mask(int size, float phi)
{
    var m = new bool[size * size];
    for (int y = 0; y < size; y++)
    {
        float b = Boundary(phi, (y + 0.5f) / size);
        for (int x = 0; x < size; x++)
            m[y * size + x] = ((x + 0.5f) / size * 2f - 1f) > b;
    }
    return m;
}

第二步,逐张计算 SDF。这里用最直白的方法:先找出所有边界像素,再对每个像素求到它们的最近距离。256×256 的图、每张几百个边界像素,C# 里不到一秒就算完;图更大时应改用 8SSEDT 或 GPU 上的 Jump Flooding 算法。全亮(0°)和全暗(180°)两张图没有边界,把交界线视为在图像的左右边缘:

Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:sdf)
// 有符号距离场:亮区为正、暗区为负,单位是像素。
// 这里用最直白的做法:先找出所有边界像素,再对每个像素求到它们的最近距离。
// 256² 的图足够快;更大的图请用 8SSEDT 或 Jump Flooding。
public static float[] SignedDistance(bool[] mask, int size)
{
    var edge = new List<Vector2>();
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            bool v = mask[y * size + x];
            bool diff = (x + 1 < size && mask[y * size + x + 1] != v) || (y + 1 < size && mask[(y + 1) * size + x] != v);
            if (diff) edge.Add(new Vector2(x + 0.5f, y + 0.5f));
        }
    var d = new float[size * size];
    if (edge.Count == 0)
    {
        // 全亮(phi = 0)或全暗(phi = π)的图没有边界:把交界线视为在图的左缘 / 右缘
        for (int y = 0; y < size; y++)
            for (int x = 0; x < size; x++)
                d[y * size + x] = mask[y * size + x] ? x + 0.5f : -(size - x - 0.5f);
        return d;
    }
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            float best = float.MaxValue;
            foreach (var e in edge)
            {
                float dx = e.x - x, dy = e.y - y;
                float dd = dx * dx + dy * dy;
                if (dd < best) best = dd;
            }
            float dist = Mathf.Sqrt(best);
            d[y * size + x] = mask[y * size + x] ? dist : -dist;
        }
    return d;
}

第三步,合成阈值图:

Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:blend)
// 合成阈值图:对每个像素,找到它“从亮变暗”的那两张相邻图 k、k+1,
// 用两张图的 SDF 线性插值求出过零点的位置 t,阈值 = (k + t) / (N - 1)
public static Texture2D Generate(int size, int count, out Texture2D maskViz, out Texture2D sdfViz, int vizIndex = 3)
{
    var masks = new bool[count][];
    var sdfs = new float[count][];
    for (int k = 0; k < count; k++)
    {
        float phi = Mathf.PI * k / (count - 1);
        masks[k] = Mask(size, phi);
        sdfs[k] = SignedDistance(masks[k], size);
    }

    var outPx = new Color[size * size];
    for (int i = 0; i < size * size; i++)
    {
        float th = 0;
        for (int k = 0; k < count - 1; k++)
        {
            float a = sdfs[k][i], b = sdfs[k + 1][i];
            if (a >= 0 && b < 0)
            {
                float t = a / (a - b);
                th = (k + t) / (count - 1);
                break;
            }
            if (a >= 0 && b >= 0) th = (k + 1f) / (count - 1);   // 这一段一直是亮的
        }
        outPx[i] = new Color(th, th, th, 1);
    }

    maskViz = ToTex(size, i => masks[vizIndex][i] ? 1f : 0f);
    sdfViz = ToTex(size, i => Mathf.Clamp01(0.5f + sdfs[vizIndex][i] / (size * 0.5f)));
    var tex = new Texture2D(size, size, TextureFormat.RGBAHalf, false, true)
    { name = "FaceThreshold", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear };
    tex.SetPixels(outPx); tex.Apply();
    return tex;
}
图 14-15左:φ = 67.5° 时的二值图(白 = 亮,鼻子处的暗区凸起就是鼻影);中:这张图的有符号距离场(灰 = 边界,越白越在亮区深处);右:9 张图合成的阈值图,越亮的像素越晚进入阴影

运行时的着色很短。先把光方向投影到头部的水平面上,求出与脸正前方的夹角 φ;阈值图按“光从右边来”制作,光从左边来时把 U 镜像;最后用 smoothstep 比较 φ 和阈值:

Assets/Cookbook/Ch14/ToonFace.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    Light light = GetMainLight();
    float2 faceUV = i.positionOS.xy + 0.5;           // 正面投影:物体空间 xy → 0~1
    half lit;

#if defined(_FACESHADOW_NORMAL)
    // 对照组:按法线做色阶,鼻子、眼窝的起伏会让阴影边缘碎成一块一块
    half3 N = normalize(i.normalWS);
    lit = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.01);
#else
    // 1) 头部的朝向(物体空间 -Z 是脸的正前方,+X 是画面右侧)
    float3 F = normalize(TransformObjectToWorldDir(float3(0, 0, -1)));
    float3 R = normalize(TransformObjectToWorldDir(float3(1, 0, 0)));
    float3 U = normalize(TransformObjectToWorldDir(float3(0, 1, 0)));
    // 2) 光方向投影到头部的水平面,只关心“从哪一侧、转了多少度”
    float3 Lh = light.direction - U * dot(light.direction, U);
    Lh = normalize(Lh + 1e-5);
    float phi = acos(clamp(dot(F, Lh), -1.0, 1.0)) / PI;   // 0 = 正面光,1 = 正背光
    // 3) 阈值图是按“光从右边来”画的,光从左边来就把 U 镜像
    float2 uv = faceUV;
    if (dot(R, Lh) < 0) uv.x = 1.0 - uv.x;
    half threshold = SAMPLE_TEXTURE2D(_FaceSdf, sampler_FaceSdf, uv).r;
    // 4) 当前角度小于该像素的阈值 → 亮
    lit = smoothstep(phi - _SdfSoftness, phi + _SdfSoftness, threshold);
#endif

    half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, lit) * light.color;
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.25;
    if (_DrawFeatures > 0.5)
        color = DrawFeatures(color, faceUV, step(i.positionOS.z, 0.0));
    return half4(MixFog(color, i.fogFactor), 1);
}
vec4 frag()
{
    Light light = GetMainLight();
    vec2 faceUV = objectPositionOS.xy + 0.5;          // 正面投影:物体空间 xy → 0~1
    float lit;

#if defined(_FACESHADOW_NORMAL)
    // 对照组:按法线做色阶,鼻子、眼窝的起伏会让阴影边缘碎成一块一块
    vec3 N = normalize(normalWS);
    lit = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.01);
#else
    // 1) 头部的朝向(物体空间 -Z 是脸的正前方,+X 是画面右侧)
    vec3 F = normalize(TransformObjectToWorldDir(vec3(0, 0, -1)));
    vec3 R = normalize(TransformObjectToWorldDir(vec3(1, 0, 0)));
    vec3 U = normalize(TransformObjectToWorldDir(vec3(0, 1, 0)));
    // 2) 光方向投影到头部的水平面,只关心“从哪一侧、转了多少度”
    vec3 Lh = light.direction - U * dot(light.direction, U);
    Lh = normalize(Lh + 1e-5);
    float phi = acos(clamp(dot(F, Lh), -1.0, 1.0)) / PI;   // 0 = 正面光,1 = 正背光
    // 3) 阈值图是按“光从右边来”画的,光从左边来就把 U 镜像
    vec2 uv = faceUV;
    if (dot(R, Lh) < 0.0) uv.x = 1.0 - uv.x;
    float threshold = texture(_FaceSdf, uv).r;
    // 4) 当前角度小于该像素的阈值 → 亮
    lit = smoothstep(phi - _SdfSoftness, phi + _SdfSoftness, threshold);
#endif

    vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, lit) * light.color;
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.25;
    if (_DrawFeatures > 0.5)
        color = DrawFeatures(color, faceUV, step(objectPositionOS.z, 0.0));
    return vec4(MixFog(color, fogFactor), 1.0);
}

脸的 UV 直接用物体空间坐标做正面投影(本例的头是半径 0.5 的球,positionOS.xy + 0.5 正好落在 0~1),这样阈值图不受模型 UV 展开方式的影响。真实项目里脸部通常有专门的一套 UV,阈值图按那套 UV 绘制。头部的“正前方”和“右方”由物体的变换矩阵给出,所以头骨骼必须保持“局部 −Z 朝前、+X 朝右”的约定(或者用脚本把头部的朝向向量传给材质),否则阴影会和头的朝向错位。

图 14-16同一个带鼻子、眼窝起伏的头部模型,光从右前方 60° 照来。左:按法线做色阶,鼻子和脸颊的起伏让阴影边缘碎成锯齿状;右:SDF 阈值图,阴影边缘干净,在鼻子处形成设计好的三角形鼻影
图 14-17光从正面向右侧、再向背后旋转:φ = 20°、60°、100°、140°。阴影边界随角度平滑推进,形状始终保持设计好的样子
常见坑① 阈值图必须是线性(非 sRGB)、未压缩或用高质量的单通道压缩格式(BC4/EAC R11),否则阈值被 sRGB 解码或压缩误差扭曲,阴影边界会抖动。② 光源在头顶正上方时,水平投影后的方向是零向量,φ 没有意义;代码里加了一个极小的偏移防止 NaN,项目里通常在这种情况下按“正面光”处理。③ SDF 阴影只解决明暗交界的形状,脸部依然需要接收别的物体投下的阴影(例如帽檐),那部分仍用普通的阴影图,与本节的结果取较暗者。

案例 14.9卡通化的接收阴影进阶单独打开 ↗

目标:让物体接收的投影也符合卡通风格:硬边、使用阴影色,而且与自身的明暗交界协调。

URP 的阴影采样返回的是 0~1 之间的软阴影值(PCF 滤波的结果)。最省事的做法是把它乘到最终颜色上,但在卡通画面里这会出现三个问题:阴影边缘是一条模糊的灰带,和硬边的色阶格格不入;阴影里的颜色是“固有色变暗”而不是指定的阴影色;投影区域和自身背光区域是两种不同的暗,画面里出现第三种颜色。

正确的做法是把阴影“折”进色阶的输入里:先对阴影值做一次色阶变成硬边,再用它去压低半 Lambert,然后统一走色阶 / Ramp 流程。这样投影和背光用的是同一套阴影色。还有一个细节:被投影的区域只应该压到“第一层阴影”的位置,而不是压到 0——压到 0 会掉进第二层阴影,比背光面还暗,看起来像是“阴影里的阴影”。

Assets/Cookbook/Ch14/ToonLit.shader(节选:diffuse)
// 主光的色阶:输入是“半 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
}
// 主光的色阶:输入是“半 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
}
图 14-18柱子的影子投在胶囊上(为了看清阴影边缘,阴影图分辨率特意降到 512)。左:不接收阴影;中:接收阴影但不卡通化,影子边缘是一条模糊的过渡带;右:卡通化后,影子与自身的背光面使用同一种阴影色,边缘硬朗

卡通化之后,阴影图的走样会变得更显眼:原本被 PCF 模糊掉的锯齿,被色阶重新“锐化”了出来,在明暗交界线附近还可能出现自阴影的瑕疵(shadow acne)。常用的缓解办法有:

  • 在接收端把采样位置沿法线挪一点(_ShadowNormalOffset),专门对付交界线附近的自阴影瑕疵。这与 URP 在投射端做的 Normal Bias 是互补的。
  • 适当保留一点 _ShadowSoftness,让色阶边缘有一两个像素的过渡,而不是完全的 step。
  • 对角色使用更高的阴影分辨率、更短的阴影距离或单独的角色阴影(第 23 章会讨论阴影参数的取舍)。
  • 对于脸部这种对阴影形状极度敏感的地方,干脆不接收自阴影,只用 SDF 阴影。

案例 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);
}

几个设计上的选择:

  • 高光和边缘光都乘了 litMask:只在“真正被照亮”(既朝光、又不在投影里)的区域出现。站在阴影里的角色身上冒出高光和边缘光,是卡通渲染里很常见的穿帮。
  • 环境光只取球谐的常数项 SampleSH(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)。

本章小结

  • 卡通渲染的核心是“量化 + 美术定义”:半 Lambert 作为输入,用 step / smoothstep / Ramp 贴图切成色阶;用 fwidth 保证硬边至少一像素宽,避免着色锯齿。
  • 暗部使用美术挑选的阴影色(偏冷、饱和度不降),可以再切出第二层阴影。
  • 菲涅尔边缘光宽度随曲率变化,乘 N·L 色阶后只出现在受光侧;屏幕空间深度边缘光宽度固定,需要 Force Prepass 才能在不透明阶段读到深度。
  • 风格化高光在切线空间里对 H 做平移、缩放、分裂,用 Lp 范数把圆斑变成方块。
  • 背面外扩描边:第二个 Pass 用 SRPDefaultUnlit、Cull Front;在裁剪空间按“像素 × 2 / 屏幕尺寸 × w”外扩,方向要先换到像素空间再归一化。
  • 硬边模型的描边断裂用平滑法线解决,可烘焙进切线(物体空间)或顶点色(切线空间,兼容蒙皮)。
  • 天使环是色阶化的 Kajiya-Kay 发丝高光,可以让它跟随相机而不是主光。
  • 面部 SDF 阴影:多张角度二值图 → 逐张 SDF → 相邻插值得到阈值图;运行时比较水平光照角度与阈值,光从另一侧来时镜像 U。
  • 接收阴影要先色阶化,再折进半 Lambert,且只压到第一层阴影的位置。

练习

  1. 给 ToonLit 加一个“线宽遮罩”:读取顶点色的 A 通道乘到描边宽度上(注意与“顶点色存平滑法线”的模式共存),并按相机距离做线宽的钳制缩放。
  2. 写一个 AssetPostprocessor:文件名以 _toon 结尾的模型在导入时自动调用 SmoothNormalBaker.Bake,烘焙到顶点色。
  3. 把 FaceSdfGenerator 的距离计算换成 8SSEDT(两遍扫描传播最近种子点),比较 512×512 时的耗时。
  4. 让 ToonFace 也接收普通投影(例如帽檐),与 SDF 阴影取较暗者,注意只在投影里使用卡通化后的阴影值。
  5. 给 ToonRimSpec 的深度边缘光增加“只在法线朝向侧面时启用”的遮罩,消除头与身体交界处的碎点。
  6. 用 Ramp 贴图的 V 坐标做“多套色阶”:一张 Ramp 贴图里竖向排列白天、黄昏、夜晚三条色阶,用一个全局参数在它们之间切换。