T.TAO
返回博客
/8 min read/Technical Art

Unity Shader #7 日式卡通渲染

#Unity#Shader#TechnicalArt

这篇主要写一下关于日式卡通渲染(也叫二次元渲染)的 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 本身反而是最简单的一环。

本系列文章

Unity Shader
  1. 01Unity Shader #0 数学基础
  2. 02Unity Shader #1 渲染管线
  3. 03Unity Shader #2 代码基础
  4. 04Unity Shader #2.1 基础指令
  5. 05Unity Shader #3 升级到 URP
  6. 06Unity Shader #4 在 UV 上画图形
  7. 07Unity Shader #5 边缘光
  8. 08Unity Shader #6 扫描与全息
  9. 09Unity Shader #7 日式卡通渲染
  10. 10Unity Shader #8 溶解
  11. 11Unity Shader #9 镜头上的雨滴
  12. 12Unity Shader #13 屏幕后处理
  13. 13URP #1 Universal Lit 与 URP ShaderLab