在Unity项目里做角色渲染,我经常听到一句话:“想做卡通风格,那就给模型加个描边。”这句话只对了一半。描边确实是卡通渲染最有辨识度的特征,但真正让画面“卡”起来的,是光照和颜色被强制压成了色块。这也就是卡通风格渲染和PBR渲染之间最本质的分歧:一个在模拟真实光线的连续变化,一个在模拟动画师手工上色时那种不加过渡的干脆感。
这篇文章源于我整理Shader笔记时反复验证过的一套流程,覆盖《Unity Shader》第14.1节的核心内容:把漫反射量化成色带、做轮廓描边、让高光也变成硬朗的色块,再补一层边缘光,最后把所有Pass整合进一个可用的卡通材质里。适合正在照着教程写Shader、但总觉得效果“不够卡”的技术美术,也适合打算做风格化项目的Unity开发者。
1. 卡通感的本质:从改光照模型入手,而不是给画面叠滤镜
1.1 真实感渲染的“连续渐变”,恰恰是卡通画面最不该出现的东西
我先说一个经常被误解的点:卡通渲染不是“把饱和度拉高、加个描边”就能做出来的,关键在光照结果从连续值变成离散值。在Unity的Standard Shader甚至URP的Lit Shader里,漫反射光是一个连续变化量,光源方向、法线方向之间的夹角不同,反射能量会平滑衰减。这个衰减在物理上是正确的,但在卡通画面里会带来一种“软乎乎”的体积感。
相反,卡通风格借鉴的是传统赛璐璐动画的上色逻辑。动画里一个球体的受光面通常只有两三层颜色,没有一堆中间过渡色。美术直接画出了“亮部色”“暗部色”两块区域,两块之间是明确的边界。Shader要做的事情,就是模拟这个人工判断的过程,把连续的光照结果按阈值切成若干段。既然是分段,边界就必须足够干净,不能是模糊的半透明过渡。否则看起来仍然是“真实感渲染饱和度调低了”,而不是“这就是个卡通角色”。
所以第一步不是去研究描边,而是重新审视漫反射模型本身。常见做法是把NdotL(法线方向点乘光源方向)的结果拿来做阶梯量化。简单说,当NdotL大于某个阈值,走亮部色;小于阈值,走暗部色。这是整个卡通渲染的起点,几乎所有风格的进阶玩法都从这里延展。
1.2 光照与法线:请不要省略“向量方向统一”这一步
在Shader里做卡通风格,永远要记住一个前提:你需要明确的亮部朝向和暗部朝向,然后在同一个坐标空间里做运算。内置管线的Cg/HLSL里,最常见的是在世界空间计算。先获取世界空间法线,再拿世界空间的光照方向,点积得到的点乘结果就是一个从1到-1的连续标量。
不过很多人在这一步会踩一个坑:直接把dot(normalWS, lightDirWS)的结果用saturate缩到0到1,然后拿去分色带。这样在光源从正面转到背面的过程中,明暗分界线在0附近会显得特别硬,暗部直接掉到全黑。我不建议在写第一个版本时就这样做。可以先用半兰伯特(Half Lambert)把点积范围从[-1,1]映射到[0,1],让暗部至少保留一些底色的可见性。后续如果美术觉得暗部还不够黑,再在暗部颜色里调整,而不是靠这个映射把整个暗部裁掉。
在做Shader实验室时,我会把光照方向、法线方向用Debug视图输出出来,确认模型在场景里每一个角度的明暗关系都是可控的。很多卡通风格一旦出现不可控黑影,90%不是算法问题,而是把点光源当成平行光用了,或者法线方向在模型空间和世界空间没有正确变换。先把这些数学基础做对,效果才有可能稳定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 漫反射不只一刀切:色带分割、Ramp贴图和暗部节奏
2.1 用Ramp贴图代替大量分支判断,让美术可以“画”光照
上一节说了阈值切割的思路,但真正做角色Shader时,我不建议在代码里堆一堆if和smoothstep来判断三个四五个色带。为什么?因为用代码硬写色带虽然方便,但只要美术想调整暗部偏冷还是偏暖、阴影边界要不要带一点柔和渐变,你就得去改Shader参数,沟通成本极高。
更工程化的做法是使用Ramp贴图,也就是渐变纹理。我们不做复杂的纹理采样,而是把NdotL结果当成这张纹理的UV坐标的横轴。这样场景里的光照方向变化,最终会反映成在Ramp贴图上从左到右的采样位置。贴图最左端是暗部,最右端是亮部。贴图里可以画两段式,也可以画三段式、四段式,甚至可以画成带一点过渡的窄渐变。
以我常用的Cg写法举例:
hlsl复制inline fixed3 ToonDiffuse(fixed3 albedo, fixed3 normalWS, fixed3 lightDirWS)
{
half ndl = dot(normalWS, lightDirWS);
// 半兰伯特,把[-1,1]映射到[0,1],避免暗部完全掉色
half halfLambert = ndl * 0.5 + 0.5;
half rampPos = saturate(halfLambert);
fixed3 ramp = tex2D(_RampTex, float2(rampPos, 0.5)).rgb;
return albedo * ramp;
}
这里把UV的V方向固定在了0.5,所以Ramp贴图理论上只需要一行有效高度就够了。但美术经常习惯画一横条,我一般
