这篇主要写一下关于日式卡通渲染(也叫二次元渲染)的 Shader。这里的 Shader 会在 URP 下进行。
二次元渲染基本可以分成两个部分:第一个部分是描边(把轮廓线画出来),第二个部分是着色。这两部分互相独立,可以分开调试。
描边
我们实现二次元描边的基本原理是把顶点沿法线方向向外扩充一圈,再只保留背面。这种做法一般叫反向外扩壳(inverted hull)。整个过程大概会经历以下几个步骤:
拓展法线 → 剔除正面 → 调整渲染顺序优化性能 → 调整视距变化 → 修复描边断裂 → 控制描边粗细
拓展法线与剔除正面
描边是一个额外的 Pass。在这个 Pass 里,我们把每个顶点沿法线推出去一点,然后 Cull Front 只画背面。因为壳被推大了,背面会从正面模型的边缘露出来,露出的那一圈就是描边。
HLSLPass
{
Name "Outline"
Tags { "LightMode" = "SRPDefaultUnlit" }
Cull Front // 只保留背面,正面交给主 Pass
ZWrite On
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _OutlineColor;
float _OutlineWidth;
CBUFFER_END
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 color : COLOR; };
struct Varyings { float4 positionCS : SV_POSITION; };
Varyings vert(Attributes IN)
{
Varyings OUT;
float3 posWS = TransformObjectToWorld(IN.positionOS.xyz);
float3 normalWS = TransformObjectToWorldNormal(IN.normalOS);
posWS += normalWS * _OutlineWidth * 0.01 * IN.color.a;
OUT.positionCS = TransformWorldToHClip(posWS);
return OUT;
}
half4 frag(Varyings IN) : SV_Target { return _OutlineColor; }
ENDHLSL
}
注意这个 Pass 的 LightMode 用的是 SRPDefaultUnlit。URP 会把没有匹配到已知 LightMode 的 Pass 丢掉,所以这一步不能省。
调整渲染顺序
描边 Pass 写深度但完全不需要光照,越早画完越好。把它放在主 Pass 之前,后面被遮住的像素可以直接被 Early-Z 剔掉,省下主 Pass 的计算。反过来先画主 Pass,描边就会在已经着色完的像素上再跑一遍。
调整视距变化
如果直接在世界空间外扩,模型离相机越远,描边在屏幕上就越细,最后会消失。想要屏幕空间恒定粗细,需要把外扩量按距离补偿。最简单的做法是在裁剪空间里外扩,并乘上 positionCS.w:
HLSLfloat3 normalCS = TransformWorldToHClipDir(normalWS);
OUT.positionCS = TransformWorldToHClip(posWS);
OUT.positionCS.xy += normalCS.xy * _OutlineWidth * 0.01 * OUT.positionCS.w;
实际项目里通常取一个折中:随距离缩放,但夹在一个最小/最大宽度之间,避免特写时描边粗得像轮胎。
修复描边断裂
这是这套方案最常见的坑。硬边处(UV 接缝、法线被拆开的地方)同一个位置有多个法线不同的顶点,外扩之后它们分开了,描边就会裂开。
解决办法不是改 Shader,而是改数据:把一份平滑后的法线烘焙到顶点色或某个 UV 通道里,描边 Pass 用这份平滑法线外扩,主 Pass 仍然用原始法线着色。这样几何上的硬边保住了,描边的壳却是连续的。
HLSL// 平滑法线预先烘到 uv2 中(切线空间压缩后的 xy)
float3 smoothNormalOS = DecodeSmoothNormal(IN.uv2);
float3 normalWS = TransformObjectToWorldNormal(smoothNormalOS);
控制描边粗细
角色描边不应该是一个常数。脸部通常要比躯干细得多,手指这类小结构也需要单独收窄。把粗细系数画进顶点色的 alpha 通道,美术就能在 DCC 里直接刷出粗细变化,Shader 里只要乘上去即可(上面代码里的 IN.color.a)。
着色
二次元着色的核心思路是:放弃连续的光照过渡,改成少数几个色阶。
从 Lambert 到色阶
先算 half-Lambert,把 NdotL 映射到 [0, 1],再用一张 ramp 贴图查色,或者直接用 smoothstep 卡一个阈值:
HLSLLight mainLight = GetMainLight(shadowCoord);
half NdotL = dot(normalWS, mainLight.direction);
half halfLambert = NdotL * 0.5 + 0.5;
// 方案 A:ramp 贴图,色阶完全交给美术控制
half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(halfLambert, 0.5)).rgb;
// 方案 B:解析阈值,可以调软硬
half shadowMask = smoothstep(_Threshold - _Softness, _Threshold + _Softness, halfLambert);
half3 shaded = lerp(_ShadowColor.rgb * baseColor, baseColor, shadowMask);
方案 A 灵活,美术改一张图就能换风格;方案 B 参数少、好动画化。实际项目里两者经常混用:ramp 定大关系,阈值做动态调整。
一个容易忽略的细节:阴影色不要用基础色直接乘一个灰度。日式画法里暗部通常会往紫/蓝偏,饱和度反而更高。把阴影色作为独立参数暴露出来,效果会好很多。
高光与边缘光
二次元高光同样是硬边的:算出 Blinn-Phong 的 NdotH,然后 step 掉。
HLSLhalf3 halfDir = normalize(mainLight.direction + viewDirWS);
half NdotH = saturate(dot(normalWS, halfDir));
half spec = step(1 - _SpecSize, pow(NdotH, _SpecPower));
color += spec * _SpecColor.rgb * _SpecIntensity;
边缘光(rim)可以直接用菲涅尔,做法和《Unity Shader #5 边缘光》里完全一样,但这里通常要再乘一个 NdotL,否则背光面也会亮起来,很出戏。
关于脸部
脸是这套方案最容易翻车的地方。鼻梁和眼窝的法线会在脸上投出一片破碎的暗块,无论 ramp 怎么调都不好看。业界通行的做法是给脸单独做一张 SDF 光照贴图:用一张记录了"光线从各个水平角度射来时明暗分界在哪里"的贴图,把光照方向投影到脸的水平面上去查表,而不是用真实法线。这样脸部的明暗分界始终是一条干净的曲线。
这部分内容我打算单独写一篇,这里先留个记号。
小结
日式卡通渲染看起来是风格问题,做下来会发现大部分工作量在数据和边界情况上:平滑法线的烘焙、描边粗细的绘制、脸部的特殊处理。Shader 本身反而是最简单的一环。