做日式卡通渲染的人,第一眼看到动画电影的分镜稿,往往会愣一下:明明只是一张干净到近乎单调的画面,角色脸上清清楚楚只有两三块颜色,可看起来就是比一堆物理材质堆出来的模型要生动。问题出在哪?不是模型精度,不是贴图信息量,而是着色思路里的“分区意识”。传统渲染追逐连续的光照过渡,卡通渲染恰好相反,它把世界简化成一小块一小块明确的明暗区域,再用颜色本身去讲故事。这也是为什么我一直觉得,Unity美术向的卡通渲染入门,第一课不该是描边,而是着色。
这篇文章就是“着色篇”的第一篇,目标是把日式卡通渲染中最关键的光影分层思路和Shader实现讲透。我会从赛璐璐的明暗分区逻辑出发,给出可以直接抄进Unity工程的基础Toon着色Shader,然后逐个拆解漫反射色阶、卡通高光、边缘光和阴影Ramp的实现细节。文章定位是给有一定Shader基础、想认真做动漫风格角色的开发者和技术美术看,也欢迎零基础但愿意边抄边学的新手对照着试。
为了保证节奏清晰,这篇只讲“光打在模型上之后怎么变成色块”,描边、头发丝材质、URP下的完整配置,留给后面的文章。
1. 画师的分层思维,才是卡通渲染的核心
1.1 赛璐璐着色法中的明暗分区
你随便找一张赛璐璐动画的截图,放大角色脸部,会发现颜色区块少得惊人:额头、颧骨、鼻梁是一整块亮色,下颌线到耳朵下面是一整块暗色,刘海投下来的投影又是一块更深的颜色,中间几乎没有渐变。这种把体积感压缩成“两到三块平面颜色”的做法,就是赛璐璐着色法。
这里有个容易被忽略的细节:动画师并不是简单地把模型“调暗”,而是在暗部做了明确的色相偏移。比如受光面是偏暖的肤色,暗面就会偏灰紫、偏冷;白衬衫的亮部是白色,暗部会带一点天蓝的倾向。这种颜色选择本质上是在用色彩关系暗示空间关系,而不是用亮度递减模拟物理光照。
所以做卡通Shader时,第一原则就是:放弃“亮度连续变化”的执念,把光照结果当成一个开关,要么亮、要么暗、要么中间过渡,然后让你能自由控制每一块的颜色。理解了这个,后面所有参数就都有了解释。
1.2 传统光照为什么不能直接搬
很多新手做卡通渲染,第一反应是拿Standard Shader调低光滑度、加一张黑白贴图,觉得“看起来有点平”就够了。结果往往是一张灰扑扑、油腻腻的脸,既没有动画感,又丢了体积。问题就出在PBR光照模型的底层逻辑和卡通风格是冲突的。
PBR模型的输入是法线、粗糙度、金属度,输出是一路连续变化的亮度值,它追求的是“光怎么反射就怎么呈现”。而卡通画面需要的是大色块、明确的明暗交界线、可控的高光形状,背后的语言体系更像平面设计,不追求物理正确,追求视觉秩序。强行靠降低粗糙度去逼近,只会得到“脏”而不是“干净”。
这也是为什么成熟项目的卡通角色几乎不用内置Standard着色器,而是写自定义光照模型。自定义光照模型的自由度,决定了你能不能在漫反射、高光、边缘光三块里分别用上step、smoothstep这类阶梯函数,把连续光切成色块。
2. 明暗阶数的选择:三阶体系还是四阶体系
2.1 色阶数由什么决定
第二个要想清楚的问题是:角色身上到底要分几层色块?早期像素风或复古风格游戏喜欢用“一阶硬切”,也就是只有亮和暗两个颜色,干净但信息量低;现代手游角色大多数用三阶或四阶,脸部、头发、衣服各取所需。
具体怎么选,可以从三个维度判断。第一是美术风格:如果参考动画是那种很现代的、吉卜力或新海诚式的细腻光影,三阶起步比较稳妥;如果是复古赛璐璐或者偏Q版,二阶就够。第二是材质类型:皮肤一般需要一到两个过渡层模拟颧骨、鼻梁的柔和感,衣服大面积布料反而适合更少的色阶,避免显得“碎”。第三是场景光照:场景里多光源多的时候,色阶太多会导致画面抢戏,单主光时则可以加一层暗部补光。
我比较推荐的起步方案是“三阶基础 + 一个可选的闭塞层”。也就是说,亮部、中间调(或阴影色)、以及贴地/关节处的更深投影。这个结构既能Hold住绝大部分角色,又不会让Shader复杂到难调。
2.2 阈值点与过渡带参数的经验值
确定阶数之后,最关键的就是阈值点放在哪。这里没有任何物理公式可以套,完全靠肉眼和审美,但还是有几个经验值可以当作起点。
假设NdotL的取值范围是-1到1,且你已经把它映射到0到1区间,那么第一阈值(亮部和中间调的分界)通常放在0.35到0.45之间,第二阈值(中间调和暗部的分界)放在0.15到0.25之间。阈值越高,暗部面积越大,整体就越“阴”;阈值越低,画面越亮,但容易丢失体积感。
过渡带宽度,也就是smoothstep的平滑范围,一般控制在0.02到0.08之间。这个值决定明暗交界线是“一刀切”还是“有一点点斜向过渡”。纯step的硬切远处看很好看,近看全是锯齿和闪烁,所以我会永远用smoothstep,但把过渡带压到很小,视觉上依然像硬边,边缘却稳得多。
3. 阶梯化漫反射Shader实现:从Lambert到可用的卡通色阶
3.1 自定义光照模型比内置Lambert更贴近卡通需求
Surface Shader里写自定义光照模型,是Unity内置渲染管线中最适合做卡通着色的方式。它的好处是:光照函数可以逐光源执行,能拿到主光源方向、视角方向、阴影衰减atten,并且天然兼容Unity的阴影贴图系统,不需要自己处理阴影矩阵。
核心思路是写一个Lighting函数,替换掉默认的Lambert。内置Lambert做的事情很简单:NdotL乘以光源颜色。我们要做的,就是在它后面加一道工序:把连续乘积结果送进smoothstep,变成阶梯化的值,再用这个值去混合亮部和暗部颜色。
这里要注意Lighting函数的签名。如果想在光照模型里同时使用视角方向做高光和边缘光,必须用带viewDir参数的版本:
half4 LightingToon(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten)要是只写漫反射也可以省掉viewDir,但为了后面扩展高光,我建议一次到位。
3.2 smoothstep代替step,硬切里藏一点点柔和
直接给NdotL套step确实能出色块,但实际运行时你会看到边界处严重锯齿,而且角色稍微动一下,边界会一帧帧地跳,观感非常差。原因是屏幕上的硬边很难被抗锯齿处理,尤其是偏高光反射区域,边缘更是闪得厉害。
smoothstep的作用就是把“开/关”变成一个极窄的渐变带。视觉上体感依然是硬切,但边缘像素有了过渡,就不会高频闪烁。这个渐变带宽度就是我在上一章说的“过渡带”,由两个阈值决定:_DiffuseThreshold - _DiffuseSmooth 和 _DiffuseThreshold + _DiffuseSmooth。
顺带说一句,不要把smoothstep的过渡带调太大,否则角色会变成“软软的塑料”,失去赛璐璐的干脆感。一般0.05左右是安全线。
3.3 完整的基础着色Shader
下面是我用于起步的一套OnionShading基础Shader,完整可跑,内置渲染管线,Unity 2019及以上版本都支持。把它丢进工程,建一个材质,挂到任意模型上就能看到色阶化的效果。
Shader "Custom/ToonShadingBasic" { Properties { _BaseColor ("Base Color", Color) = (1,1,1,1) _ShadowColor ("Shadow Color", Color) = (0.7,0.7,0.85,1) _DiffuseThreshold ("Diffuse Threshold", Range(0,1)) = 0.4 _DiffuseSmooth ("Diffuse Smoothness", Range(0,0.2)) = 0.05 _SpecularColor ("Specular Color", Color) = (1,1,1,1) _SpecularPower ("Specular Power", Range(1,128)) = 24 _SpecularThreshold ("Specular Threshold", Range(0,1)) = 0.5 _SpecularSmooth ("Specular Smoothness", Range(0,0.2)) = 0.05 _RimColor ("Rim Color", Color) = (1,0.7,0.5,1) _RimPower ("Rim Power", Range(0,10)) = 4 _RimIntensity ("Rim Intensity", Range(0,3)) = 1 } SubShader { Tags { "RenderType"="Opaque" } CGPROGRAM #pragma surface surf Toon fullforwardshadows #pragma target 3.0 half4 _BaseColor; half4 _ShadowColor; half _DiffuseThreshold; half _DiffuseSmooth; half4 _SpecularColor; half _SpecularPower; half _SpecularThreshold; half _SpecularSmooth; half4 _RimColor; half _RimPower; half _RimIntensity; struct Input { float2 uv_MainTex; }; half4 LightingToon(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { half NdotL = dot(s.Normal, lightDir); half diffStep = smoothstep( _DiffuseThreshold - _DiffuseSmooth, _DiffuseThreshold + _DiffuseSmooth, NdotL ); half3 lightColor = _LightColor0.rgb * s.Albedo.rgb; half3 shadowColor = _ShadowColor.rgb * s.Albedo.rgb; half3 diffuse = lerp(shadowColor, lightColor, diffStep); half3 H = normalize(lightDir + viewDir); half NdotH = saturate(dot(s.Normal, H)); half spec = pow(NdotH, _SpecularPower); half specStep = smoothstep( _SpecularThreshold - _SpecularSmooth, _SpecularThreshold + _SpecularSmooth, spec ); specStep *= diffStep; half rim = 1.0 - saturate(dot(s.Normal, viewDir)); rim = pow(rim, _RimPower) * _RimIntensity; half4 final; final.rgb = diffuse * atten + _SpecularColor.rgb * specStep + _RimColor.rgb * rim; final.a = s.Alpha; return final; } void surf(Input IN, inout SurfaceOutput o) { o.Albedo = _BaseColor.rgb; o.Alpha = _BaseColor.a; } ENDCG } FallBack "Diffuse" }这个版本看起来简单,但已经把“亮部、暗部、高光、边缘光”四层都搭好了。实际使用的时候,你可以把_BaseColor换成一张角色贴图,然后在surf函数里采样,阴影色也可以单独拆成贴图通道。等这套跑通后,再谈Ramp贴图和多层材质的进阶玩法。
4. 卡通高光:从Blinn-Phong到形状可控的带状高光
4.1 高光为什么要单独做一阶
如果你把PBR材质里的高光直接套在卡通角色上,会得到一个很不协调的结果:漫反射已经被压成大色块了,高光却还在那边连续渐变,像一块油渍。卡通风格的高光应该是“装饰性”的,它不需要精确模拟反射,只需要在正确的位置出现一个合适的形状。
所以卡通高光的基本做法是:先算Blinn-Phong高光,得到一个0到1的连续值,然后再对这个值做一次smoothstep或step,把它变成“要么有、要么没有”的色块。阈值越高,高光面积越小;Power值控制高光在表面上的相对集中程度,Power越大,高光越锐利、越小。
这里有个需要注意的细节:暗部的高光要不要保留?大部分情况下高光应该只出现在受光面,因为赛璐璐里阴影里的高光通常是另一层动画层,一般只在头发、眼睛上出现。所以在代码里我把specStep乘了diffStep,让暗部默认没有高光,如果你需要暗部点缀,可以单独加参数放开。
4.2 参数化高光的核心代码
上面的基础Shader里已经包含简单的高光实现。如果你要微调,核心参数就两个:_SpecularPower和_SpecularThreshold。前者控制高光在模型表面的紧凑程度,后者控制“多亮才算高光”,本质上是在切割pow之后的连续结果。
我给一个常见的皮肤高光参数组合作为参照:_SpecularPower = 24,_SpecularThreshold = 0.45,_SpecularSmooth = 0.05。这个组合在脸颊、鼻梁、肩膀这些位置会出现圆润的小块高光,不会过爆,也不会完全消失。
如果是金属铠甲、武器这类硬表面,可以把Power拉到64甚至128,阈值适当提高到0.6以上,得到的高光会接近“刀切般”的锐利亮片,很有日系游戏武器质感。一句话总结:Power管集中度,Threshold管亮片大小。
4.3 头发带状高光的做法
日式卡通里最标志性的高光,其实是头发上那条像丝带一样的带状高光。用刚才的Blinn-Phong直接算,高光会是一团散开的圆斑,完全不对味。正确的做法是让高光只在一个方向上扩散,另一个方向被压扁成带子。
实现思路是利用发丝的切线(Tangent)方向。你可以把切线方向传进Shader,用视角和光源在切线方向上的投影值,替代原本的NdotH。简化版本是这样:
half hairSpec = dot(viewDir, tangent); half3 lightTangent = dot(lightDir, tangent); half wrap = saturate(hairSpec + lightTangent); half band = pow(wrap, _SpecularPower); half hairBand = smoothstep(_HairSpecThreshold - _HairSpecSmooth, _HairSpecThreshold + _HairSpecSmooth, band);实际项目中我建议在模型里把头发单独一组材质,头发模型本身要有合理的切线方向,否则带子位置会乱跑。这个属于比较进阶的玩法,一文讲不清,后面单独开篇再往细里说。
5. 边缘光:让角色从背景里“跳”出来
5.1 菲涅尔的简化使用:不要依赖物理精确
边缘光几乎是所有二次元风格角色的标配。它的原理来自菲涅尔现象:视线与表面法线夹角越大,反射越强,所以边缘会发光。在卡通Shader里我们不需要精确的物理公式,只需要简单地用1减去法线和视线点积,取pow,再乘上颜色和强度。
代码里我用的是:
half rim = 1.0 - saturate(dot(s.Normal, viewDir)); rim = pow(rim, _RimPower) * _RimIntensity;_RimPower越大,边缘光越窄,越贴合轮廓;_RimIntensity控制亮度。经验值方面,皮肤和头发的边缘光Power一般取3到5,强度控制在0.8到1.2之间,太强会像“塑料发光”。机械角色的边缘光可以更强,甚至从边缘往内部渗入一点,营造“能量感”。
5.2 与描边、高光叠加时的遮蔽关系
如果你后面加了描边,边缘光和描边会存在明显的遮挡冲突。最常见的做法是背面放大法描边:把角色法线翻转,把背面顶点沿法线向外挤出,用纯色渲染。此时视线边缘会先被描边覆盖,边缘光其实被描边盖住了一部分,看起来会像描边发光,反而不干净。
我的做法是给边缘光加一个“朝向约束”,也就是让边缘光只出现在法线朝向摄像机的一侧,并且用深度偏移或描边宽度参数去错开两者。更简单的方法:先不开描边调边缘光,再开描边后手动把边缘光Power调小一点,让两者分层。
还有个容易忽略的问题:边缘光在暗部会特别抢眼。比如角色背对主光源时,整个身体是暗的,结果发丝边上一圈橙光,非常突兀。所以我会把边缘光乘以漫反射的结果,至少在纯阴影区域减弱它。具体乘不乘,取决于你想要的风格,游戏里“夜店风”角色反而要刻意保留阴影里的边缘光。
6. 阴影Ramp:用一张贴图替代纯数学色阶
6.1 为什么成熟项目最终都会走向Ramp贴图
数学公式再灵活,也画不出美术想要的微妙渐变。比如脸颊上“从亮到暗稍微带一点粉紫色过渡”、布料褶皱里“暗部偏蓝一点点再转深灰”,用smoothstep一层层堆,堆到后面你根本调不动。Ramp贴图就是专为这种情况准备的:把NdotL当作UV的U坐标,在一张横向渐变的贴图里取颜色,想要什么过渡,美术直接画在贴图里。
这也是为什么吉卜力风格的游戏或个人项目,最终几乎都会改成Ramp方案。它把“调光照”这件事从写代码/拖参数,变成了“画一张图”。这个转换对美术极其友好,对程序员来说,Shader反而变简单了。
6.2 Ramp采样代码与主光源阴影的合并
用Ramp替换smoothstep的代码非常直接。先把NdotL从-1到1映射到0到1,作为U坐标,再指定一行Ramp行,用tex2D取色。你可以把不同材质的状态放到不同行,材质球上用一个_RampRow参数切换,一张图管全身。
half rampUV = saturate(NdotL) * 0.5 + 0.5; half3 rampColor = tex2D(_RampTex, float2(rampUV, _RampRow)).rgb; half3 diffuse = rampColor * _BaseColor.rgb;此时主光源阴影衰减atten还是要乘到结果里。要注意一个问题:如果角色站在建筑的阴影里,atten会变得很小,diffuse会整个变黑,画面立刻失去卡通感。所以我会把衰减结果做一个clamp,保留下限:
atten = saturate(atten + _MinIndirectLight);_MinIndirectLight一般给0.2到0.4,保证角色始终有一层底色。阴影交界处还是会出来,只是不会死黑。这个参数在Ramp方案里特别重要,很多新人第一次接阴影后角色“一片黑”,就是忽略了它。
7. 五个高频调试问题与我的处理顺序
7.1 问题表格与快速定位
在实际操作中,Shader写完只是开始,调试才是大头。我把最常踩的五个问题整理成一张表,方便你对照排查。
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 明暗交界线锯齿闪烁 | step硬切,过渡带太小 | 把_DiffuseSmooth提到0.03以上,必要时开MSAA |
| 角色全身发黑 | 阴影衰减没有下限 | 用atten + _MinIndirectLight保底 |
| 阴影和暗部混成一片,缺乏层次 | 阴影色饱和度过低 | 阴影色适当偏色,别直接乘灰度 |
| 高光边缘像狗啃 | 高光阈值smooth不够 | 增大_SpecularSmooth,并确保MSAA开启 |
| URP下材质变紫 | Surface Shader不兼容URP | 改为HLSL Shader或Shader Graph实现 |
表格里列的都算“症状明显的案例”,下面再展开两个我反复被问的问题。
7.2 阴影色为什么会发灰
很多人的阴影色喜欢设置成灰黑色,然后模型一进游戏就“脏”。原因很简单:现实里的暗部并不是把固有色变暗,而是受到环境光和漫反射影响后的另一种颜色。皮肤暗部偏紫红,白衣服暗部偏蓝,树叶暗部偏绿灰。你直接给整数灰度值,等于把所有色相的差异都抹平了。
我的习惯是:阴影色 = 固有色乘以一个偏色的倍率,再叠加一个环境色倾向。具体到Shader里,可以把_ShadowColor拆成两个参数:一个是倍率(0.6到0.8),一个是叠加色(冷色或暖色)。这样调整起来既直观又不容易发灰。
7.3 我平时调卡通的先后顺序
每次接到新角色,我都不是上来就拉参数,而是按固定顺序来:第一步,把角色模型放在纯灰色环境,只保留主方向光,关掉其他光,确保只有一盏平行光在起作用。第二步,先调漫反射阈值和阴影色,得到基本明暗结构。第三步,开高光,调整位置和大小,确保高光和阴影不打架。第四步,边缘光放上去,微调Power和强度。第五步,才打开场景里的环境光、Light Probe和描边,做整体调和。
这个顺序能最大程度减少变量,让你清楚每一步改动到底影响了什么。遇到问题也别急着改材质球,先检查灯光方向和阴影设置,很多时候卡通感出不来,恰恰是场景光照太“物理”了。把默认平行光强度和Skybox强度改成适合卡通的软光环境,着色器本身的压力会小很多。
如果按照这套流程跑通了,下一篇我打算把描边、Ramp贴图和多层材质串起来讲——那是让角色从“还能看”到“有动画感”的关键一步。着色篇(一)就先到这里,你可以把文章里的Shader丢进Unity里试试,有问题欢迎在评论区带上截图聊,我看到都会回。