卡通渲染
卡通渲染(Toon / Cel Shading)不追求物理正确,追求的是“像画出来的”:干净的色块、可控的阴影形状、清晰的轮廓线。本章从最简单的色阶开始,一项项加上阴影色、边缘光、风格化高光、描边、天使环和面部阴影,最后组装成一个完整的 Toon Shader。
14.1 卡通渲染在做什么
把一张赛璐珞动画的截图和一张 PBR 渲染放在一起比较,差别一目了然:动画里的明暗只有两三档,每一档内部几乎是平涂的;阴影的形状是画师“设计”过的,脸上不会出现鼻子投下的乱七八糟的影子;物体周围有一圈粗细均匀的轮廓线;高光是一块形状规整的白色色块。卡通渲染要做的,就是在三维管线里把这些特征重现出来。
从实现的角度看,卡通渲染的核心思想只有一句话:把连续的光照量“量化”,再交给美术来决定每一档长什么样。N·L 不再直接当成亮度,而是被切成几段,每段对应一种美术指定的颜色;高光、边缘光也是同理。PBR 关心“这个表面反射了多少光”,卡通渲染关心“这个像素应该涂哪种颜色”。这也意味着很多 PBR 里的规矩在这里不适用:环境光可以是一个常数,阴影颜色可以是紫色的,脸上的明暗交界线可以和法线毫无关系。
本章代码在 Assets/Cookbook/Ch14/。公共函数(顶点结构、色阶、边缘光、风格化高光、发丝高光、描边外扩)集中在 ToonCommon.hlsl 里,每个案例的 Shader 只写自己的那部分,最后的 ToonLit.shader 把它们组装起来。各 Shader 的 ShadowCaster / DepthOnly / DepthNormals Pass 与第 12 章一样直接 include URP 自带的 Pass 文件;着色 Pass 的 LightMode 同样写成 UniversalForwardOnly,保证切换到 Deferred 渲染路径时卡通物体不会消失。
案例 14.1色阶化:从 Lambert 到 Ramp入门单独打开 ↗
目标:把平滑的漫反射切成明暗两档(或多档),比较 step、smoothstep 和 Ramp 贴图三种做法。
输入:用半 Lambert 而不是 N·L
色阶的输入通常用半 Lambert(Half Lambert),而不是 。后者在整个背光面都等于 0,信息全丢了;半 Lambert 把 [−1, 1] 完整地映射到 [0, 1],背光面也有变化,可以在暗部再切出第二层阴影,或者交给 Ramp 贴图画出暗部的颜色过渡。它原本是 Valve 在《半条命 2》里为了让角色暗部不死黑而发明的,在卡通渲染里则主要被当作“查表坐标”使用。
三种量化方式
// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边) half ToonBand(half x, half threshold, half softness) { return smoothstep(threshold - softness, threshold + softness, x); }
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_ 开头的文件已经这样设置。

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

fwidth、ddx、ddy 在 2×2 像素块上用差分实现,只能在片元着色器里用,而且在动态分支(依赖于不同像素的 if)内部结果未定义。把它放在分支外面先算好再用,是一个好习惯。案例 14.2阴影色:不要只是“变暗”入门单独打开 ↗
目标:理解为什么卡通渲染的暗部要用单独指定的颜色,而不是“固有色 × 系数”,并加上第二层阴影。
新手最常见的写法是 lerp(baseColor * 0.5, baseColor, band):暗部就是亮部乘个系数。结果是暗部又灰又脏——乘一个小于 1 的数,等于在 HSV 里同时降低明度和(相对)饱和度,色相不变。可是画师上阴影的时候从来不这么做:受光面偏暖,背光面偏冷,阴影往往是色相偏移(向蓝、紫方向)而饱和度不降甚至略升的颜色。这个规律来自真实世界——暗部主要接收的是天空的蓝色散射光——但被动画和插画大大夸张了。
所以 ToonRamp.shader 的暗部使用 _ShadeColor,由美术直接挑颜色;再用第二个阈值切出更暗的 _ShadeColor2,用于背光最深的区域(例如下巴底下、手臂内侧)。两层阴影在日式卡通渲染里很常见,有的项目叫“1st Shade / 2nd Shade”。

挑阴影色的几条经验
① 先定整个场景的“环境色调”(例如偏蓝的天光),所有材质的阴影色都往这个方向偏移,画面才统一。② 皮肤的阴影往红、粉方向偏(模仿次表面散射),金属和布料往冷色偏。③ 阴影色的明度大约是亮部的 60%~75%,比 PBR 的暗部亮得多——卡通画面是靠色相区分明暗,而不是靠明度。④ 用 Ramp 贴图时,可以把这几条直接画进贴图里;用纯色参数时,最好在材质面板上做一个“从固有色自动推算阴影色”的按钮,减少美术的重复劳动。
案例 14.3边缘光:菲涅尔与屏幕空间深度进阶单独打开 ↗
目标:实现三种边缘光——纯菲涅尔、只在受光侧出现的菲涅尔、屏幕空间等宽的深度边缘光,并比较它们的特点。
菲涅尔边缘光
最简单的边缘光就是 :法线越垂直于视线,越靠近轮廓,值越大。对它做一次色阶,就得到一条贴着轮廓的亮线:
// 菲涅尔边缘光:越靠近轮廓(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·L 的色阶),像是主光从侧后方“勾”出来的轮廓。
屏幕空间深度边缘光
菲涅尔边缘光有两个缺点:宽度随着曲率变化(平面几乎没有边缘光,球体边缘很宽),而且在物体内部的转折处也会出现。很多二次元风格的游戏改用屏幕空间的方法:沿着法线在屏幕上的投影方向,偏移固定的几个像素去读深度图,如果那里的深度比自己深很多,说明自己在轮廓上,就点亮。这样边缘光在屏幕上永远是固定的像素宽度,而且只出现在真正的轮廓处。
// 屏幕空间深度边缘光:沿法线在屏幕上的投影方向偏移若干像素读深度, // 如果那里的深度比自己“深很多”,说明自己处在轮廓边上 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 上把 设为 Force Prepass(脚本里是 copyDepthMode = CopyDepthMode.ForcePrepass),这样 URP 会在不透明之前先用各物体的 DepthOnly Pass 画一遍深度——这也是为什么我们的 Shader 一定要带 DepthOnly Pass。代价是多一遍几何绘制。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.4风格化高光:圆斑、色块、方块进阶单独打开 ↗
目标:把高光做成一块硬边色块,并通过平移、缩放、分裂、方形化来控制它的形状。
最基本的卡通高光就是对 Blinn-Phong 的 N·H 做色阶:ToonBand(N·H, 0.97, ...),得到一块硬边的圆形。但卡通美术常常想要更有设计感的形状:拉长的、偏到一边的、方形的(像窗户的反光)、分成两三块的。Anjyo 和 Hiramitsu 在 2003 年的论文《Stylized Highlights for Cartoon Rendering》里给出了一套方法:既然高光的形状由“H 离 N 有多近”决定,那就在切线空间里直接对 H 动手脚。
- 平移:,高光整体挪动。
- 方向缩放:,削弱 H 在 T 方向的分量,高光在 T 方向被拉长。
- 分裂:,T 方向的两侧各往外推,一个高光分成两块(T、B 同时分裂则成四块)。
- 方形化:H 在切平面上的分量 (x, y) = (H·T, H·B) 的长度就是 ,“N·H ≥ 阈值”等价于“这个长度 ≤ sin(阈值角)”。把长度(L2 范数)换成 Lp 范数 ,p = 2 是圆,p 越大越接近正方形。原论文的方形化用的是另一个基于角度的公式,在阈值较高时形状控制不够直接,这里换成了等价思路下更好调的 Lp 范数写法。
// 风格化高光(思路来自 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)); }

这些变换都依赖切线 T、B 的方向,所以和第 12 章的各向异性一样,需要模型有合理的切线。对角色来说,通常在头发和眼睛上用得最多(眼睛的高光几乎都是贴图画的,但头发、金属饰品、头盔上的风格化高光很适合用这种方法)。
14.5 描边:背面外扩法
描边是卡通渲染最有辨识度的特征。实时渲染里有三大类做法:
- 背面外扩(Inverted Hull):把模型的背面沿法线推出去一点,用纯色画出来,露在正面外面的那一圈就是轮廓线。只需要在材质里多加一个 Pass,线宽可控、可以按顶点调粗细,是角色描边最常用的方法,也是本节的内容。
- 后处理边缘检测:在屏幕空间对深度、法线(甚至物体 ID)做 Sobel、Roberts 等边缘检测,一次处理整个画面。适合场景物件、建筑,详见第 26 章。
- 几何边线:在 CPU 或几何着色器里找出轮廓边、折痕边,生成线条网格。质量最高,但成本和复杂度也最高,游戏里较少见。
案例 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,场景里描边物体很多时要注意。
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 变换后得到 ,之后 GPU 会做透视除法 。NDC 的 [−1, 1] 对应整个屏幕,所以一个像素在 NDC 里的大小是 。我们想在 NDC 里偏移 (d 为像素数),那么在除法之前就要偏移这个量乘以 w:
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。 // 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); }
方向 来自法线在裁剪空间里的 xy 分量。这里有个常被忽略的细节:NDC 的 x 和 y 方向“一个单位等于多少像素”不同(宽高比),如果直接 normalize(normalCS.xy),宽屏上横向的线会比纵向的粗。正确的做法是先乘屏幕尺寸换到像素空间再归一化,最后再除回去。另外,法线是方向向量,变换到裁剪空间只用 VP 矩阵的 3×3 部分,对应 URP 的 TransformWorldToHClipDir。

clamp(distance / 参考距离, 0.5, 1.5)),或者按 FOV 修正。顶点色的 A 通道常用来做“线宽遮罩”:在鼻尖、指尖、衣服褶皱内侧把线宽刷成 0,避免内部出现多余的线。描边颜色也不一定是黑色,用固有色压暗、偏色后的颜色,画面会柔和很多。案例 14.6平滑法线烘焙工具进阶单独打开 ↗
目标:解决硬边模型的描边断裂问题——写一个编辑器工具,把平滑后的法线烘焙进切线或顶点色;并在拍摄脚本里对程序化网格调用同一个烘焙函数,对比效果。
为什么硬边会断
立方体的一个角在网格里其实是三个独立的顶点:位置相同,但各自带着三个面的法线(否则每个面就不会是平的)。描边 Pass 把它们分别沿三个不同的方向推出去,原本连在一起的角就裂开了,露出一道缝,线条在棱角处断开。凡是有硬边(光滑组断开、UV 接缝处法线不同)的模型都会有这个问题。
解决办法是给描边单独准备一套“平滑法线”:对每个空间位置,把所有共享这个位置的顶点所属面的法线加起来求平均。着色仍然用原来的硬法线,描边用平滑法线。平滑法线需要存在某个顶点属性里,常见的选择有两个:
| 存放位置 | 存什么 | 优点 | 缺点 |
|---|---|---|---|
| 切线 tangent.xyz | 物体空间的平滑法线 | 最简单,Shader 里直接用 | 切线被占用,法线贴图、各向异性都不能用了;蒙皮时 Unity 会按切线变换它,虽然方向仍正确,但语义被混用 |
| 顶点色 color.rgb | 切线空间的平滑法线(×0.5+0.5) | 不占切线;存在切线空间里,骨骼动画、形变后依然正确 | 顶点色被占用;精度只有 8 位(一般够用);Shader 里要多一次 TBN 变换 |
也有项目存在 UV2~UV7 里,原理相同。下面的工具同时支持前两种:
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 通道保留原来的值,留给线宽遮罩。
[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.7头发天使环进阶单独打开 ↗
目标:做出二次元角色头顶那圈亮环(天使环,Angel Ring),比较写实的连续发丝高光与色阶化的天使环。
天使环其实就是第 12 章 Kajiya-Kay 发丝高光的卡通版:发丝沿着头型从头顶往下垂,所有发丝的“T 垂直于 H”的位置连起来,正好是环绕头部的一圈。卡通化的处理有三步:用 KK 算出连续的高光,用阈值切成硬边色块,再用每缕头发不同的偏移量把边缘切成锯齿(像画师画的一束一束的高光)。
// 发丝高光的核心(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); }
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.8 面部 SDF 阴影
用法线算明暗,在脸上效果往往很糟:鼻子、眼窝、嘴唇的起伏会让明暗交界线碎成一块块,随着头部转动不停跳动。动画里的脸部阴影是画师设计好的几种形状:正面光时脸上几乎没有阴影;侧光时阴影从脸颊一侧推进,在鼻子处形成一个干净的三角形鼻影;光从更侧后方照来时,只剩脸的一条边被照亮。
解决方案是让脸部的明暗与法线无关,只取决于“光从哪个水平角度照来”。具体做法是预先制作一张灰度图,每个像素存一个阈值:当光源从正面转到侧面、转过的角度(归一化到 0~1)超过这个阈值时,这个像素就进入阴影。运行时只要算出当前的光照角度 φ,和贴图里的阈值比较一下即可。这张图在社区里通常被叫作“脸部 SDF 图”,因为它的制作过程用到了有符号距离场。
阈值图是怎么做出来的
制作流程分三步:
- 美术针对若干个光照角度(例如 0°、22.5°、45°……180°,共 9 张)分别画出“这个角度下脸上哪里亮”的二值图。要求是单调的:角度越大,亮区只能缩小,不能在别处冒出新的亮区。
- 对每张二值图计算有符号距离场(SDF):每个像素到明暗边界的距离,亮区为正、暗区为负。
- 对每个像素,找到它“从亮变暗”的那两张相邻图 k 和 k+1:在第 k 张里 SDF ≥ 0,第 k+1 张里 SDF < 0。两张图的 SDF 在这个像素上线性插值,过零点的位置 就是“在两个角度之间的哪个位置变暗”,阈值 = (k + t) / (N − 1)。
为什么要用 SDF 插值,而不是直接把 9 张二值图平均?因为平均只能得到 10 级灰度,相邻两个角度之间的阴影边界会“跳”过去;SDF 插值让边界在两张图之间平滑地移动,哪怕只有寥寥几张关键图,运行时的阴影边界也能连续变化。这和字体渲染里用 SDF 让字形边缘在任意缩放下平滑是同一个道理。
案例 14.8程序生成脸部阈值图与 SDF 面部阴影高阶单独打开 ↗
目标:在 C# 里程序生成一套“二值图 → SDF → 阈值图”的完整流程(用一条带鼻子凸起的解析曲线代替美术手绘),在 Shader 里按光照角度查表,对比法线色阶与 SDF 阴影。
第一步,生成二值图。真实项目里这是美术画的;这里用一条解析曲线描述“光从右侧、角度为 φ 时,第 y 行的明暗交界线在哪”:基础形状是 (正面光时交界线在最左边,90° 时在正中,背光时在最右边),再在鼻子的高度加一个凸起、在眼窝和下巴处加一点起伏。凸起量乘了 sin φ 且系数小于 1,保证交界线随 φ 单调移动:
// 光从画面右侧、与脸正前方成 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°)两张图没有边界,把交界线视为在图像的左右边缘:
// 有符号距离场:亮区为正、暗区为负,单位是像素。 // 这里用最直白的做法:先找出所有边界像素,再对每个像素求到它们的最近距离。 // 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; }
第三步,合成阈值图:
// 合成阈值图:对每个像素,找到它“从亮变暗”的那两张相邻图 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; }

运行时的着色很短。先把光方向投影到头部的水平面上,求出与脸正前方的夹角 φ;阈值图按“光从右边来”制作,光从左边来时把 U 镜像;最后用 smoothstep 比较 φ 和阈值:
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.9卡通化的接收阴影进阶单独打开 ↗
目标:让物体接收的投影也符合卡通风格:硬边、使用阴影色,而且与自身的明暗交界协调。
URP 的阴影采样返回的是 0~1 之间的软阴影值(PCF 滤波的结果)。最省事的做法是把它乘到最终颜色上,但在卡通画面里这会出现三个问题:阴影边缘是一条模糊的灰带,和硬边的色阶格格不入;阴影里的颜色是“固有色变暗”而不是指定的阴影色;投影区域和自身背光区域是两种不同的暗,画面里出现第三种颜色。
正确的做法是把阴影“折”进色阶的输入里:先对阴影值做一次色阶变成硬边,再用它去压低半 Lambert,然后统一走色阶 / Ramp 流程。这样投影和背光用的是同一套阴影色。还有一个细节:被投影的区域只应该压到“第一层阴影”的位置,而不是压到 0——压到 0 会掉进第二层阴影,比背光面还暗,看起来像是“阴影里的阴影”。
// 主光的色阶:输入是“半 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 }

卡通化之后,阴影图的走样会变得更显眼:原本被 PCF 模糊掉的锯齿,被色阶重新“锐化”了出来,在明暗交界线附近还可能出现自阴影的瑕疵(shadow acne)。常用的缓解办法有:
- 在接收端把采样位置沿法线挪一点(
_ShadowNormalOffset),专门对付交界线附近的自阴影瑕疵。这与 URP 在投射端做的 Normal Bias 是互补的。 - 适当保留一点
_ShadowSoftness,让色阶边缘有一两个像素的过渡,而不是完全的 step。 - 对角色使用更高的阴影分辨率、更短的阴影距离或单独的角色阴影(第 23 章会讨论阴影参数的取舍)。
- 对于脸部这种对阴影形状极度敏感的地方,干脆不接收自阴影,只用 SDF 阴影。
案例 14.10完整的 Toon Shader高阶单独打开 ↗
目标:把本章的所有部件组装成一个可直接用于项目的卡通 Shader:两层阴影色或 Ramp 贴图、卡通化的接收阴影、风格化高光、受光侧边缘光、卡通化的附加光、平坦环境光,以及支持平滑法线的等宽描边。
附加光
附加光(点光、聚光)在卡通画面里通常用作氛围光:角色走进一盏红色灯笼的范围,身上出现一块边界清晰的红色光斑。做法与主光相同,只是把“N·L × 距离衰减 × 阴影”整体做一次色阶。距离衰减先乘 4 再 saturate,是为了让光斑在衰减到很弱之前就达到阈值,形成一块完整的色斑而不是一个模糊的光圈:
// 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑 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 和额外方向光。
片元函数
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 掉,同一个材质就能关掉描边。
// 描边 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
完整代码
// 本章的完整卡通 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" } }


从分解图可以看出每个部件的贡献:色阶决定了画面的基调;阴影让物体“落地”,并建立起空间关系;高光和边缘光提供材质感和立体感;描边则一下子把画面拉到了“动画”的感觉上——它往往是对卡通感贡献最大的单一部件。
移动端与性能
这个 Toon Shader 的片元开销比 URP Lit 低(没有 GGX、没有环境反射探针采样),主要的额外开销来自描边 Pass:每个描边物体多一次 Draw Call 和一遍顶点处理,背面的片元绝大多数会被深度测试剔除,填充率开销不大。角色多的场景可以考虑:远处的角色关掉描边(LOD);用 shader_feature_local 把高光、边缘光等做成可剔除的变体;把 Ramp 贴图、阈值图做成很小的单通道贴图。具体收益请在目标设备上用 Frame Debugger 与 Profiler 确认。
✓ 推荐
阴影色、Ramp 交给美术定义;投影折进色阶;高光、边缘光只在受光区出现;描边用平滑法线 + 裁剪空间等宽;脸部用 SDF 阴影。✗ 避免
暗部 = 固有色 × 系数;最后再乘一次软阴影;纯白的菲涅尔边缘光;用原始硬法线外扩描边;用法线色阶算脸部阴影。"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,且只压到第一层阴影的位置。
练习
- 给 ToonLit 加一个“线宽遮罩”:读取顶点色的 A 通道乘到描边宽度上(注意与“顶点色存平滑法线”的模式共存),并按相机距离做线宽的钳制缩放。
- 写一个
AssetPostprocessor:文件名以_toon结尾的模型在导入时自动调用SmoothNormalBaker.Bake,烘焙到顶点色。 - 把 FaceSdfGenerator 的距离计算换成 8SSEDT(两遍扫描传播最近种子点),比较 512×512 时的耗时。
- 让 ToonFace 也接收普通投影(例如帽檐),与 SDF 阴影取较暗者,注意只在投影里使用卡通化后的阴影值。
- 给 ToonRimSpec 的深度边缘光增加“只在法线朝向侧面时启用”的遮罩,消除头与身体交界处的碎点。
- 用 Ramp 贴图的 V 坐标做“多套色阶”:一张 Ramp 贴图里竖向排列白天、黄昏、夜晚三条色阶,用一个全局参数在它们之间切换。