Unity日式卡通渲染着色实战:从色阶化漫反射到面部阴影处理
2026/9/18 11:04:19 网站建设 项目流程

做了几年Unity下的风格化渲染,经常有新入行的朋友问我:想要日式卡通渲染的效果,是不是把Standard Shader的阴影调硬一点就行了?每次听到这个我都想把对方按在椅子上看一遍渲染结果:看着是“一块阴影”,实际上日式卡通着色的逻辑跟PBR完全是两个思路,硬调参数只会得到一张脏兮兮、灰蒙蒙的脸。

这篇是“着色篇”的第一篇,重点解决日式卡通渲染里最核心的三块内容:漫反射的色阶化、高光的风格化、面部阴影的处理。我会从原理讲到完整Shader,再给实际的参数调节经验,适合刚接触Unity NPR的开发者,也适合想弄清楚材质参数背后逻辑的美术同学。代码用Built-in管线写,URP下稍微改一下引用就能用,文中会提差异点。

1. 日式卡通着色的底层逻辑:先搞清楚“硬阴影”为什么不够

1.1 卡通渲染的目标是信息压缩,而不是降低画质

PBR渲染的目标是“物理正确”,光线怎么反射、能量怎么衰减,一切都有现实依据。但日式卡通渲染不一样,它做的是信息压缩:把复杂的光影关系简化成几档明确的明暗层级,让观众在一眼扫过去的时候,能快速读取模型的体积、结构和材质倾向。

这个区别非常关键。PBR里物体暗部来自光照衰减、环境遮挡、间接光反弹,颜色随光源变化有丰富的中间过渡。而日式卡通里,阴影往往是一个“设计过的形状”,它不追求真实,而是为了分割画面、表达体积,甚至承载情感。这也是为什么“把PBR阴影调硬”行不通,因为你保留了PBR的明暗算法,只是把过渡压窄了,结果该脏的地方还是脏,该碎的地方还是碎,只是变得更难看而已。

提示:判断一个NPR着色器是否合格,不要看它在纯色球上好不好看,要看它在角色脸上、衣服褶皱上、头发上是不是还干净利落。

1.2 从PBR到Toon的思维切换:光照不再是一堆连续值

用一句话概括PBR到Toon的切换:PBR把光照计算成连续的灰度或颜色,Toon把光照量化成离散的档位。

这个想法看起来简单,实际操作里牵扯到一个核心变量:NdotL,也就是法线方向与光照方向的点积。PBR里NdotL直接参与漫反射计算出连续亮度;Toon里NdotL通常需要先做一次映射,再经过阈值判断或者Ramp贴图采样,变成两到三档的颜色。

为了避免模型背光面完全黑死,我们一般会把NdotL映射成Half Lambert:

half halfLambert = dot(normalWS, lightDirWS) * 0.5 + 0.5;

Half Lambert的好处是把原本范围在-1到1的点积映射到0到1,暗部的信息也被保留下来,方便后续用阈值去切分。这也是很多日式卡通Shader的地基,后面所有漫反射分级都建立在它上面。

注意:阈值判断和Ramp采样,本质都是把连续量变成离散量,这一步就是“卡通感”的来源。如果这一步做不好,后面加多少描边和高光都救不回来。

2. 漫反射色阶化:Ramp贴图与阈值控制的完整实现

2.1 Step、Ramp贴图、半兰伯特怎么选

很多教程会直接给你看Step硬切的效果:

half diffuse = step(0.5, halfLambert);

这确实能产生赛璐璐式的大色块,但在真实项目里我几乎不用纯Step,原因有两个。第一,模型上的法线是连续的,但屏幕像素是离散的,纯Step会在明暗交界处产生严重的锯齿或者闪烁。第二,纯Step只产生两档颜色,遇到需要三档、四档明暗的材质,比如衣服有固有色、阴影色、还有环境反光色,Step写起来会非常痛苦。

所以实践中更推荐Ramp贴图。Ramp本质上是一张1D渐变图,横轴是Half Lambert值,纵轴是最终亮度或者颜色,Shader在运行时根据halfLambert去采样这张图。美术可以直接在Photoshop里画过渡,想要硬边就画一个台阶,想要柔和就画一段斜坡,完全不需要改代码。

提示:Unity Shader里用tex1D采样有时会遇到平台兼容性问题,最稳妥的做法是把Ramp图做成宽N、高1的2D纹理,采样时固定y坐标为0.5。

2.2 一个可运行的Toon漫反射Shader

这里给出一个最精简但完整的Shader结构,包含ShadowCaster和ForwardBase两个Pass,可以放进Unity里直接用:

Shader "Custom/ToonDiffuse" { Properties { _BaseMap ("Base Texture", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1, 1, 1, 1) _RampTex ("Ramp Texture", 2D) = "white" {} _ShadowColor ("Shadow Color", Color) = (0.6, 0.65, 0.7, 1) _ToonThreshold ("Toon Threshold", Range(0, 1)) = 0.5 _ToonSoftness ("Toon Softness", Range(0, 0.2)) = 0.05 _ShadowIntensity ("Shadow Atten Intensity", Range(0, 1)) = 0.6 } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include "UnityCG.cginc" struct v2f { V2F_SHADOW_CASTER; }; v2f vert(appdata_base v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } float4 frag(v2f i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG } Pass { Name "ForwardBase" Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; }; sampler2D _BaseMap; float4 _BaseMap_ST; fixed4 _BaseColor; sampler2D _RampTex; fixed4 _ShadowColor; float _ToonThreshold; float _ToonSoftness; float _ShadowIntensity; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 albedo = tex2D(_BaseMap, i.uv) * _BaseColor; float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 normalWS = normalize(i.worldNormal); half halfLambert = dot(normalWS, lightDir) * 0.5 + 0.5; // 通过阈值和柔边构造色阶化漫反射 half diffuse = smoothstep( _ToonThreshold - _ToonSoftness * 0.5, _ToonThreshold + _ToonSoftness * 0.5, halfLambert); // 采样Ramp贴图,这里用diffuse作为横坐标 half rampCoord = saturate(diffuse); fixed3 rampColor = tex2D(_RampTex, float2(rampCoord, 0.5)).rgb; // 阴影色:在亮部和暗部之间做渐变 fixed3 shadowColor = _ShadowColor.rgb * albedo.rgb; fixed3 diffuseColor = lerp(shadowColor, albedo.rgb, rampColor); // 主光源阴影衰减统一成一个宽度可控的软化层 fixed shadowAtten = LIGHT_ATTENUATION(i); shadowAtten = lerp(_ShadowIntensity, 1.0, saturate(shadowAtten)); fixed3 finalColor = diffuseColor * _LightColor0.rgb * shadowAtten; finalColor += UNITY_LIGHTMODEL_AMBIENT * albedo.rgb * 0.3; return fixed4(finalColor, 1.0); } ENDCG } } Fallback "VertexLit" }

这里有几个重点要解释清楚。

第一,_ToonThreshold是明暗分界线在Half Lambert空间的位置。默认0.5意味着光线与法线夹角约60度时切换阴影,这时阴影面积通常比较好看。想要阴影面积更大就提高阈值,比如0.6,想要更亮就降低阈值。

第二,_ToonSoftness不是“模糊阴影”,它只是控制threshold两边的过渡带宽度,这个宽度决定了明暗交界线是锐利还是柔和。0.05到0.1之间是比较稳的,太小容易锯齿,太大就没有卡通味了。

第三,Ramp图的横坐标我直接用了diffuse而非halfLambert,这样Ramp图实际控制的是“明暗分级后的渐变”,Artists画图时更直观:左边是阴影色,右边是固有色,中间画几个台阶就是几档过渡。

2.3 阴影色选择与Unity阴影系统的衔接

日式卡通的阴影色,千万不要用“固有色乘以0.6”这么偷懒的做法。因为乘法只能降低亮度,会让阴影发灰、发闷,动画里看起来非常“脏”。正确的日式阴影,往往选择比固有色更深但饱和度更高一点的偏色。比如红色衣服的阴影色,可以往紫红偏移;白色衣服的阴影色,可以往冷灰偏移。

上面的Shader里_ShadowColor给了独立的颜色,并直接与albedo相乘,美术在调材质时就可以针对每个部位配不同的阴影色,这一条对最终观感的影响比很多人想象的大。

Unity自带的阴影系统,也就是Shadow Map那套,在NPR里也必须处理。我这里没有直接拿shadowAtten去乘diffuse,而是先做了一次lerp:当shadowAtten为0时,光照最多衰减到_ShadowIntensity,而不是全黑。这是非常关键的一步,避免阴影和阴影叠加后出现脏斑。如果整个暗部被压成纯黑,那日式卡通里最常说的“通透感”就没了。

注意:ShadowCaster Pass一定要写。很多新手偷懒不写ShadowCaster,结果角色无法投射阴影,整个画面立刻失去立体感。另外,如果阴影边缘有严重的锯齿或者痤疮,优先检查这个Pass是否用了正常的偏移,其次再调Shadow Near Plane和Bias。

3. 高光与质感:让材质“活”起来

3.1 卡通高光的两种常见做法

漫反射解决的是大形和明暗关系,高光解决的是材质质感。同样一块头发,有高光和没高光的观感差距很大。日式卡通里常见的高光做法有两类。

第一类是基于Blinn-Phong的高光再阈值化,这也是最容易理解、最容易控制的方案。先算出高光能量,再用smoothstep切出锐利边缘,这样能模拟出动画赛璐璐质感里的“塑料高光”和“头发高光”。

第二类是程序化高光,比如头发的片状高光沿U方向做幂运算,或者眼睛高光用独立的贴图和UV控制。这类高光通常占据特定区域,效果更细腻,但需要额外贴图和美术资源。这节先讲第一类,第二类放到后面的材质篇继续展开。

3.2 高光形状与边缘光的控制参数

把Blinn-Phong高光改造成卡通高光,核心代码很短:

float3 halfDir = normalize(lightDir + viewDir); half spec = pow(saturate(dot(normalWS, halfDir)), _Shininess); half toonSpec = smoothstep( _SpecThreshold - _SpecSmooth * 0.5, _SpecThreshold + _SpecSmooth * 0.5, spec); fixed3 specColor = toonSpec * _SpecColor.rgb; finalColor += specColor;

参数含义如下。_Shininess控制高光面积,数值越大高光越集中,日式头发高光一般给到100到500之间,皮肤和衣服给到30到100之间。_SpecThreshold控制高光能量阈值,太高会让高光消失,太低会让高光糊成一片。我的经验值是0.5到0.7之间,具体根据环境光强弱微调。_SpecSmooth控制高光边缘软硬,0.02到0.08之间比较合适。

有一点需要特别提醒:卡通高光不要做得太亮、太白。日式动画里的高光更多是一种“形状提示”,不是真实的镜面反射。很多新手把_SpecColor调成纯白,还加一个很大的强度,结果角色看起来像涂了一层油。更日式的做法是让高光颜色接近固有色,只是在明度上明显高出一档。

边缘光在NHPR里也很常用,用来给暗部增加透气感。一个轻量实现:

half fresnel = pow(1.0 - saturate(dot(normalWS, viewDir)), _FresnelPower); finalColor += fresnel * _FresnelColor * _FresnelIntensity;

注意边缘光不能太强,否则会盖过漫反射的主体明暗关系。一般_FresnelPower取3到5,_FresnelIntensity取0.2到0.4,颜色可以微微偏暖或者偏冷,用来帮角色从背景里“跳”出来。

4. 面部阴影:日式卡通最难也最关键的细节

4.1 通用NPR阴影在脸上失效的原因

如果直接把上面的漫反射Shader套用到角色头部,你会看到特别惊悚的一幕:鼻翼两侧、眼窝、下巴这些地方出现细碎的阴影,甚至半边脸像被人揍了一样。道理很简单,头部模型在五官附近法线变化剧烈,通用Toon计算是根据每个像素的法线方向来判定明暗的,局部细微的法线波动会被放大成一块块小阴影。

日式卡通角色脸部阴影的设计逻辑是“整体的大阴影块”,刘海挡住眼睛时,阴影从刘海的形状往外切,而不是顺着颧骨和眼窝乱跑。所以脸部阴影必须限定在极少数可控的边界上,不能交给通用算法自由发挥。

第一版最快速的修法,是把脸部分离成单独材质,然后关掉法线对阴影的过强影响,用固定阈值或者一张脸颊阴影贴图去控制。这也是为什么很多日式项目里角色的脸和身体分开用两套材质的原因。

4.2 基于法线与遮罩的脸部阴影实现

我们先从可接受的第一版做法说起:脸部仍然参与NdotL计算,但阴影边界只在Half Lambert空间里取一个很小的范围,然后通过一张脸颊阴影遮罩图,把鼻子、眼睛、嘴巴这些需要保护的区域的阴影盖掉。

核心思路是准备一张_FaceShadowMap,R通道记录“脸部允许出现阴影”的遮罩,越黑代表越保护。在Shader中:

half faceHalfLambert = dot(normalWS, lightDirWS) * 0.5 + 0.5; half faceLight = smoothstep( _FaceShadowThreshold - _FaceShadowSoftness * 0.5, _FaceShadowThreshold + _FaceShadowSoftness * 0.5, faceHalfLambert); // faceMask黑色区域强制保持亮部 half faceMask = tex2D(_FaceShadowMap, i.uv).r; half shadowEnabled = 1.0 - faceMask; faceLight = lerp(faceLight, 1.0, shadowEnabled * _FaceShadowInfluence);

这里需要注意,_FaceShadowThreshold建议直接取0.5左右,并且脸部的smoothness要比身体更大一些,否则转脸时阴影会突然跳动。_FaceShadowInfluence用来控制遮罩的保护力度,0表示不保护,1表示完全保护,一般给0.5到0.8,保留一点点细微阴影会更有立体感。

当然,真正的日式脸阴影还需要处理“阴影随角色转脸平滑移动”的问题,这通常靠SDF脸阴影数据来实现。但SDF需要额外烘焙工具,并不适合所有项目。作为着色篇的一部分,先用“阈值遮罩”方案把脸部控干净,等之后写数据篇再展开SDF烘焙的完整流程。

提示:脸部法线在建模软件里尽量做平滑,不要在旁边面保留硬边法线。如果导入Unity后发现脸部阴影还是碎的,先检查模型的法线平滑组,别急着改Shader。

4.3 眼睛、眉毛的单独保护

眼睛和眉毛也需要区别对待。瞳孔通常要始终高亮,眉毛要清晰显示形状,如果它们也参与统一脸部阴影,很容易视觉上“糊成一片”。

完整项目里常见做法是:脸部贴图提供一张脸的细节遮罩,G通道保护眼睛,B通道保护眉毛。在上面的Shader基础上前向增加一段:

half protectEye = tex2D(_FaceShadowMap, i.uv).g; faceLight = lerp(faceLight, 1.0, protectEye * _EyeProtectStrength); half protectBrow = tex2D(_FaceShadowMap, i.uv).b; faceLight = lerp(faceLight, 1.0, protectBrow * _BrowProtectStrength);

这样保护强度都可以在材质面板上微调,不用专门为眼睛和眉毛写两套Pass,性能和复杂度都可控。后续如果还要给眼睛加高光,可以直接用眼睛UV区域采样一张Mask,叠加在发光区域上。

5. 实操调优与常见问题排查

5.1 材质参数初始值参考

不同的模型、不同的光照环境,参数肯定不一样,但可以先给一组实测下来比较稳的初始值:

参数推荐范围说明
Toon Threshold0.45 ~ 0.55控制阴影面积,越大阴影越多
Toon Softness0.03 ~ 0.08控制明暗交界线锐利度
Shadow Intensity0.5 ~ 0.8控制Shadow Map对明暗的压暗程度
Shininess30 ~ 300高光越集中数值越大
Spec Threshold0.5 ~ 0.7高光形状主控参数
Spec Smooth0.02 ~ 0.08高光边缘软硬
Fresnel Power3 ~ 5边缘光衰减速度
Fresnel Intensity0.1 ~ 0.4边缘光强度
Face Shadow Threshold0.48 ~ 0.55脸部阴影边界
Face Shadow Softness0.08 ~ 0.15脸部过渡需要更柔和

这套参数适用于大多数日式角色,配合Directional Light为主光的场景。天光或环境光的强度如果太高,需要同步调整_ShadowColor和_BaseColor的关系,避免角色被环境光“漂白”。

5.2 用Frame Debugger和可视化方式排查

遇到效果不对,我一般按这个顺序排查。

先在Scene视图里把灯光方向旋转,观察模型表面的明暗分界是否随灯光稳定移动。如果分界跳动、闪烁,问题多半在Shadow Map参数或者smoothness设置上。再用Frame Debugger确认Shader实际执行了几个Pass,有没有意外多一个额外光源的Pass导致明暗叠加异常。最后把材质里的Ramp图临时换成纯黑白台阶,确认色阶化结果是否符合预期。

法线问题也是重灾区。做法是在材质的Debug模式下把normalWS输出成颜色,观察面部和身体是否平滑。如果看到硬接缝,就回到建模软件重做法线平滑,通常Blender里的“平滑着色”加上“自动光滑”就能解决大部分问题。

5.3 常见问题速查表

现象常见原因处理办法
阴影边缘锯齿严重Toon Softness过小适当提高Softness,或在后处理开MSAA
背光面死黑Shadow Color和环境光不够调高_ShadowColor明度、增加环境光系数
脸部阴影破碎模型法线不平滑修正平滑组,或加强脸部遮罩保护
高光像油腻塑料感SpecColor过白或Shininess过低降低高光颜色明度,提高Shininess
明暗分界随镜头闪烁ShadowMap开启硬阴影且Bias过小调整阴影Bias,或用柔和阴影
与同屏其他PBR角色不协调明暗层级差异太大用Ramp图的三档过渡兼容PBR亮度感知

5.4 光照与渲染管线的几点经验

如果是URP项目,上面的Shader需要把CGPROGRAM改成HLSLPROGRAM,并显式引用Lighting.hlsl和Shadow.hlsl,光照函数名也要换成URP对应的接口。核心的阈值和Ramp思路不变,只是底层函数不同。另外URP下启用SRP Batcher时,要注意Shader里的Properties数量不要太多,有些参数可以合并到纹理通道里,否则会打断合批。

多光源场景要注意:很多Toon Shader只考虑主方向光,一旦来了多个实时光,每个光源都走一次光照后,明暗档位就会被破坏。最稳妥的方案是只在ForwardBase里计算主光,其他光源一律用SH或者不参与。这一点在移动端尤其重要,毕竟是每像素的多Pass光照,性能开销也不小。

说到性能,卡通渲染其实比PBR更容易优化,因为光照计算简单。但对于移动端VR和微信小游戏这类环境,仍然要控制贴图大小和Overdraw,尤其Ramp贴图和脸部遮罩图建议压缩成ETC2或者ASTC格式,采样精度要求不高,可以适当压一压。

最后说两句个人体会

我在实际项目里踩过最大的坑,就是在视网膜上追求“赛璐璐感”却忽略了整体色调统一。漫反射、高光、边缘光、脸影遮罩,每一块单独看都没问题,合在一起却容易变成“花脸”。后来我把所有材质参数全部暴露成美术可控的Property,然后用一组统一的预设让全角色保持同样的Threshold和Softness基准,这才慢慢找到协调感。

着色只是日式卡通渲染的第一步,描边、阴影偏移、头发片状高光、眼睛高光这些内容在每一帧画面里都是半壁江山。下一篇会专门讲描边和轮廓线的实现,以及它在不同风格下的取舍,到时候我会把“内描边”和“外描边”两种方案放在一起对比,方便你直接按项目需求选型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询