文章目录
-
- [0. 效果预览](#0. 效果预览)
- [1. 原理简述](#1. 原理简述)
- [2. 功能点](#2. 功能点)
- [3. 完整 Shader(可直接用)](#3. 完整 Shader(可直接用))
- [4. 使用方法](#4. 使用方法)
- [5. 参数说明](#5. 参数说明)
- [6. 变体与扩展](#6. 变体与扩展)
-
- [6.1 二色阶简化版(只要明/暗)](#6.1 二色阶简化版(只要明/暗))
- [6.2 Ramp 贴图替代 Step(更灵活)](#6.2 Ramp 贴图替代 Step(更灵活))
- [6.3 屏幕空间等宽描边](#6.3 屏幕空间等宽描边)
- [7. 常见问题](#7. 常见问题)
- [8. 性能建议](#8. 性能建议)
0. 效果预览

简易卡通着色(Toon / Cel Shading)是非真实感渲染(NPR)的入门效果:用 step 把连续光照硬切成 2-3 个色阶,再加一个背面扩张的描边 Pass,就能得到经典的手绘动画/赛璐珞风格。《塞尔达:旷野之息》《罪恶装备》《原神》的渲染都以此为基础。
1. 原理简述
卡通着色的本质:把 Lambert 光照的连续灰度用 step/smoothstep 量化为有限色阶(明/暗或明/中/暗),再用第二个 Pass 沿法线方向扩张背面顶点形成描边。
色阶硬切:
hlsl
float NdotL = dot(normalWS, lightDirWS);
float halfLambert = NdotL * 0.5 + 0.5;
// 二分硬切:亮面 / 暗面
float toon = step(_ShadowThreshold, halfLambert);
half3 color = lerp(_ShadowColor.rgb, _LitColor.rgb, toon);
step(_ShadowThreshold, halfLambert) 在阈值处一刀切:大于阈值 = 亮面色,小于 = 暗面色。smoothstep 可以让切割边缘有一点柔和过渡,避免锯齿。
描边原理:
hlsl
// 第二个 Pass:只渲染背面,沿法线方向扩张顶点
Cull Front // 剔除正面,只画背面
v.positionOS.xyz += v.normalOS * _OutlineWidth;
背面顶点沿法线外扩一小段距离,形成比正面大一圈的"壳",颜色设为纯黑/深色,就是描边。正面 Pass 正常渲染盖在上面,只露出背面扩张的边缘部分。
2. 功能点
- 三色阶硬切:Half-Lambert 在两个阈值处硬切为暗/中/亮三段色阶
- Smoothstep 柔化:可调边缘柔化宽度,避免硬切锯齿
- 三色独立可调:亮面/中间/暗面颜色各自控制,暗面偏冷+亮面偏暖=经典冷暖对比
- 法线外扩描边:第二个 Pass 背面扩张,渲染纯色描边
- 描边宽度可调:Inspector 实时调节描边粗细
- 主贴图支持:保留模型纹理细节
- GPU Instancing:支持多实例渲染
3. 完整 Shader(可直接用)
hlsl
Shader "Custom/SimpleToon_URP"
{
Properties
{
// 主贴图
_BaseMap ("Base Map", 2D) = "white" {}
// ===== 卡通光照参数(三色阶:暗 / 中 / 亮) =====
// 亮面颜色
_LitColor ("Lit Color", Color) = (1, 0.95, 0.9, 1)
// 中间色(半影区域)
_MidColor ("Mid Color", Color) = (0.7, 0.65, 0.7, 1)
// 暗面颜色(建议偏冷色)
_ShadowColor ("Shadow Color", Color) = (0.4, 0.35, 0.5, 1)
// 暗→中 分界阈值
_ShadowThreshold ("Shadow Threshold", Range(0, 1)) = 0.35
// 中→亮 分界阈值
_MidThreshold ("Mid Threshold", Range(0, 1)) = 0.6
// 边缘柔化宽度(0=硬切,0.05=微柔,越大越柔和)
_ShadowSmoothness ("Shadow Smoothness", Range(0, 0.1)) = 0.02
// ===== 描边参数 =====
// 描边宽度
_OutlineWidth ("Outline Width", Range(0, 0.05)) = 0.01
// 描边颜色
_OutlineColor ("Outline Color", Color) = (0.1, 0.1, 0.1, 1)
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalRenderPipeline"
"Queue" = "Geometry"
"RenderType" = "Opaque"
}
// =============================================================
// Pass 1:描边(背面外扩)
// =============================================================
Pass
{
Name "Outline"
Tags { "LightMode" = "SRPDefaultUnlit" }
Cull Front // 剔除正面,只渲染背面
ZWrite On
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float _OutlineWidth;
float4 _OutlineColor;
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings vert(Attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
// 沿法线方向外扩顶点
float3 expandedPos = v.positionOS.xyz + normalize(v.normalOS) * _OutlineWidth;
o.positionHCS = TransformObjectToHClip(expandedPos);
return o;
}
half4 frag(Varyings i) : SV_Target
{
return (half4)_OutlineColor;
}
ENDHLSL
}
// =============================================================
// Pass 2:卡通光照(正面渲染)
// =============================================================
Pass
{
Name "ToonLit"
Tags { "LightMode" = "UniversalForward" }
Cull Back
ZWrite On
Blend Off
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// 贴图声明
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
// 材质属性
float4 _BaseMap_ST;
float4 _LitColor;
float4 _MidColor;
float4 _ShadowColor;
float _ShadowThreshold;
float _MidThreshold;
float _ShadowSmoothness;
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings vert(Attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
o.normalWS = TransformObjectToWorldNormal(v.normalOS);
return o;
}
half4 frag(Varyings i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
// 1) 采样主贴图
half4 baseCol = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
// 2) 计算 Half-Lambert
Light mainLight = GetMainLight();
float3 normalWS = normalize(i.normalWS);
float NdotL = dot(normalWS, normalize(mainLight.direction));
float halfLambert = NdotL * 0.5 + 0.5;
// 3) 三色阶硬切(smoothstep 做微柔化)
// 暗→中 过渡
float shadow = smoothstep(
_ShadowThreshold - _ShadowSmoothness,
_ShadowThreshold + _ShadowSmoothness,
halfLambert
);
// 中→亮 过渡
float mid = smoothstep(
_MidThreshold - _ShadowSmoothness,
_MidThreshold + _ShadowSmoothness,
halfLambert
);
// 4) 三段插值:暗 → 中 → 亮
half3 toonColor = lerp(_ShadowColor.rgb, _MidColor.rgb, shadow);
toonColor = lerp(toonColor, _LitColor.rgb, mid);
// 5) 主贴图叠乘 + 光源颜色
half3 finalColor = baseCol.rgb * toonColor * mainLight.color;
return half4(finalColor, baseCol.a);
}
ENDHLSL
}
}
}
4. 使用方法
-
在 Unity 项目的
Assets/Shaders/下新建文件SimpleToon_URP.shader,粘贴上方完整代码。 -
新建材质(Create → Material),Shader 选择
Custom/SimpleToon_URP。 -
将材质赋给场景中的模型(角色模型效果最好,球体也能展示)。
-
在 Inspector 中调节参数:
经典冷暖对比:

Lit Color= 暖白#FFF2E6Mid Color= 灰紫#B3A6B3Shadow Color= 冷紫#665980Shadow Threshold= 0.35,Mid Threshold= 0.6Shadow Smoothness= 0.02Outline Width= 0.01,Outline Color= 深紫黑#261A33
橙红活力风格:

Lit Color= 暖杏#FFE0B2Mid Color= 橙红#FF7043Shadow Color= 深红棕#BF360COutline Color=#4E1A08
青蓝冰感风格:

Lit Color= 冰白#E0F7FAMid Color= 青蓝#26C6DAShadow Color= 深青#00695COutline Color=#0A2E26
粉紫糖果风格:

Lit Color= 浅粉#FCE4ECMid Color= 玫粉#EC407AShadow Color= 深玫红#880E4FOutline Color=#3C0522
翠绿自然风格:

Lit Color= 嫩绿白#F1F8E9Mid Color= 翠绿#66BB6AShadow Color= 深绿#2E7D32Outline Color=#1B3D1F

-
确保场景中有一个 Directional Light,调整光照方向观察明暗分界线的移动。
5. 参数说明
| 参数 | 类型 | 范围/默认值 | 说明 |
|---|---|---|---|
_BaseMap |
2D | white | 模型主贴图 |
_LitColor |
Color | (1,0.95,0.9,1) | 亮面颜色 |
_MidColor |
Color | (0.7,0.65,0.7,1) | 中间色(半影区域) |
_ShadowColor |
Color | (0.4,0.35,0.5,1) | 暗面颜色(建议偏冷色) |
_ShadowThreshold |
Range(0,1) | 0.35 | 暗→中 分界阈值 |
_MidThreshold |
Range(0,1) | 0.6 | 中→亮 分界阈值 |
_ShadowSmoothness |
Range(0,0.1) | 0.02 | 边缘柔化宽度:0=硬切,越大越柔和 |
_OutlineWidth |
Range(0,0.05) | 0.01 | 描边宽度(模型空间单位) |
_OutlineColor |
Color | (0.1,0.1,0.1,1) | 描边颜色 |
6. 变体与扩展
6.1 二色阶简化版(只要明/暗)
如果不需要中间色,去掉 _MidColor 和 _MidThreshold,只用一个 step:
hlsl
float toon = smoothstep(
_ShadowThreshold - _ShadowSmoothness,
_ShadowThreshold + _ShadowSmoothness,
halfLambert
);
half3 toonColor = lerp(_ShadowColor.rgb, _LitColor.rgb, toon);
更简洁,适合极简卡通风格或性能敏感场景。
6.2 Ramp 贴图替代 Step(更灵活)
用一张 1D 渐变贴图控制色阶过渡,和文章 24(Ramp Shading)原理相同:
hlsl
// 替换 step 色阶计算
float2 rampUV = float2(halfLambert, 0.5);
half3 toonColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb;
Ramp 贴图里画几个色阶就有几段硬切,比代码更直观、美术更可控。
6.3 屏幕空间等宽描边
当前的法线外扩描边有一个问题:离摄像机远的模型描边变细,近的变粗。如果需要屏幕空间等宽描边:
hlsl
// 在 Outline Pass 的 vert 中,改为裁剪空间偏移
float4 clipPos = TransformObjectToHClip(v.positionOS.xyz);
float3 clipNormal = mul((float3x3)UNITY_MATRIX_VP,
TransformObjectToWorldNormal(v.normalOS));
// 在裁剪空间沿法线偏移,除以 w 保证屏幕空间等宽
clipPos.xy += normalize(clipNormal.xy) * _OutlineWidth * clipPos.w * 2.0
/ _ScreenParams.xy;
o.positionHCS = clipPos;
7. 常见问题
Q: 描边在某些角度消失或断裂?
A: 法线外扩描边依赖平滑法线。如果模型有硬边(Hard Edge),法线在接缝处不连续,描边会断开。解决方案:在建模软件中烘焙平滑法线到顶点色或额外 UV 通道,Shader 中用平滑法线做外扩。
Q: 描边粗细不均匀,尤其在模型细长部分?
A: 法线外扩在曲率变化大的地方会不均匀。简单解决:降低 _OutlineWidth。高级解决:用屏幕空间等宽描边(见变体 6.3)。
Q: 明暗分界线有明显锯齿?
A: 调大 _ShadowSmoothness(如 0.03~0.05),让 smoothstep 在分界处产生一个窄的柔化过渡带。完全的 step 硬切在低分辨率下容易出锯齿。
Q: 想要暗面偏冷、亮面偏暖的色调?
A: 这正是 _LitColor 和 _ShadowColor 的用途。亮面设暖白 (1, 0.95, 0.9),暗面设冷紫 (0.4, 0.35, 0.5),冷暖对比是卡通渲染的经典技巧。
Q: 描边 Pass 在 URP 中不显示?
A: 检查描边 Pass 的 LightMode 标签。URP 默认只执行特定 LightMode 的 Pass。本文使用 SRPDefaultUnlit,如果不生效,在 URP Renderer 的 Renderer Features 中确认该 LightMode 没被过滤掉。或者改用 UsePass / Render Objects Feature 手动添加。
8. 性能建议
- 双 Pass 开销:描边需要额外一个 Pass,顶点数翻倍。对大多数场景影响不大,但大量卡通角色时注意 Draw Call。
- 描边 Pass 极轻量:描边的 frag 只输出纯色,几乎无片元开销。瓶颈在顶点处理,低模角色(< 10K 顶点)完全不需要担心。
- 色阶计算极低 :一个
smoothstep+ 一个lerp,比标准 PBR 光照省一个数量级的计算量。 - 合批注意 :双 Pass Shader 会让 SRP Batcher 合批效率降低。如果同场景大量使用,考虑把描边合并到单 Pass(在 frag 中用
fwidth检测边缘,但视觉效果不如双 Pass 干净)。 - 移动端友好:除了双 Pass 的额外 Draw Call,计算量比 PBR 低很多,移动端表现良好。