卡通渲染·描边篇
摘要:本文记录了卡通渲染中描边技术的完整实现路径,从 Back Facing 描边法的原理出发,逐步解决摄像机距离适应、棱角断裂、顶点色控制等实战问题,最终形成一套稳定可靠的描边方案。
1. 序言
1.1 写作动机
在项目开发中,我们需要为 3D 角色实现一套干净、利落的卡通描边效果。真实感渲染追求”欺骗眼睛”,而非真实感渲染(Non-Photorealistic Rendering,NPR)追求的则是风格化表达——让画面”好看”而不是”真实”。描边作为卡通渲染的标志性元素,承担着分离前景与背景、勾勒形体轮廓、强化风格化三大职责。
1.2 理论铺垫
卡通渲染(Cel Shading / Toon Shading)是 NPR 最重要的分支之一,其核心特征:
- 色块化:光照层级离散化(2~4 级),拒绝渐变
- 描边:勾勒轮廓线,强化卡通感
- 高对比:黑白对比强烈,色彩饱和
日本动画中的”赛璐璐风格”(Cel Style)即为卡通渲染的对标对象,经典案例如《GUILTY GEAR Xrd》《崩坏 3》《原神》等。
1.3 行业背景
| 游戏/作品 | 描边技术 | 特点 |
|---|---|---|
| GUILTY GEAR Xrd | 多次渲染 + 轮廓检测 | 精度高,性能开销大 |
| 崩坏 3 | 后处理描边 | 适合静态场景 |
| 原神 | 混合方案 | 移动端优化极佳 |
1.4 核心选择
经过调研与对比,我们最终选择:
Back Facing 两次绘制描边法(Two-Pass Back-Face Outline)
放弃方案:后处理描边(边缘毛刺、参数敏感、依赖深度图精度)
选择原因:
- 边缘由 Mesh 几何精度保证,不会断线
- 参数独立可调,不受分辨率影响
- 可结合顶点色实现粗细控制
- 与卡通着色器解耦,可独立开关
2. Back Facing 描边法(基础实现)
2.1 核心原理
Back Facing 描边的思路极为精巧:
正常渲染 Pass → 渲染模型正面(颜色)
描边 Pass → 渲染模型背面(Cull Front)
背面顶点沿法线方向外扩 → 露出轮廓环
正常渲染时,由于背面不可见,模型内部被填充;描边 Pass 时剔除正面(Cull Front),只渲染背面,背面顶点沿法线外扩后,轮廓环刚好出现在边缘处。

2.2 完整 Unity Shader 代码
// ToonOutlineBasic.shader
Shader "Custom/ToonOutlineBasic"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_OutlineWidth ("Outline Width", Float) = 0.02
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
}
SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry" }
// ================== Pass 1: 正常渲染 ==================
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
sampler2D _MainTex;
float4 _MainTex_ST;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
};
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
// 简化卡通色块光照
float NdotL = dot(i.worldNormal, float3(0, 1, 0));
float toon = NdotL > 0.5 ? 1.0 : 0.6;
fixed4 col = tex2D(_MainTex, i.uv);
return fixed4(col.rgb * toon, 1);
}
ENDCG
}
// ================== Pass 2: 描边(背面剔除 + 法线外扩) ==================
Pass
{
Tags { "LightMode"="ForwardBase" }
Cull Front // 剔除正面,只渲染背面
CGPROGRAM
#pragma vertex vert_outline
#pragma fragment frag_outline
float _OutlineWidth;
fixed4 _OutlineColor;
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
};
v2f vert_outline(appdata v)
{
v2f o;
// 沿法线方向外扩顶点(世界空间)
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
worldPos += worldNormal * _OutlineWidth;
o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1));
return o;
}
fixed4 frag_outline(v2f i) : SV_Target
{
return _OutlineColor;
}
ENDCG
}
}
}
2.3 效果验证
测试场景:Unity 内置 Sphere 球体模型
| 参数 | 值 |
|---|---|
| _OutlineWidth | 0.02 |
| _OutlineColor | (0, 0, 0, 1) |
预期效果:
- 球体表面:正常卡通着色(色块化光照)
- 球体边缘:黑色描边环绕,宽度均匀
- 描边与模型表面衔接自然,无明显接缝
3. 修正摄像机距离问题(优化模块 1)
3.1 问题描述
当摄像机拉近模型时,描边宽度会明显变粗;推远时,描边又变细。这个现象在相机快速推进/拉远时尤为明显,影响观感。

3.2 问题根因
基础实现的法线外扩是在世界空间中直接乘以 _OutlineWidth,这意味着外扩距离是一个固定的世界单位。
但透视投影下同样一个世界单位,在近裁剪面处占的 NDC 空间大,在远裁剪面处占的 NDC 空间小——于是相机拉近时描边变粗,拉远时描边变细。
3.3 解决方案 1:NDC 空间法线外扩
v2f vert_outline_ndc(appdata v)
{
v2f o;
float4 clipPos = UnityObjectToClipPos(v.vertex);
float3 clipNormal = mul((float3x3)UNITY_MATRIX_MVP, v.normal);
// NDC 空间外扩:clip 坐标 + clip 法线方向
clipPos.xy += normalize(clipNormal.xy) * _OutlineWidth * clipPos.w;
o.pos = clipPos;
return o;
}
clipPos.w 是顶点的深度因子,除以它可以把世界空间距离归一化到齐次裁剪空间。
3.4 新问题:宽屏分辨率描边不均
当视口为 16:9 时,水平方向一个单位对应屏幕比例大;竖直方向则被压缩。于是水平边缘描边粗、竖直边缘描边细,不均匀。
3.5 解决方案 2:加入屏幕宽高比修正
float aspect = _ScreenParams.x / _ScreenParams.y;
clipPos.xy += normalize(clipNormal.xy)
* float2(1.0, aspect) // 竖直方向补偿 aspect 倍
* _OutlineWidth
* clipPos.w;

3.6 效果对比
| 相机距离 / 视口 | 基础实现(世界空间) | NDC + 宽高比修正 |
|---|---|---|
| 拉近 | 描边粗 | 宽度稳定 |
| 拉远 | 描边细 | 宽度稳定 |
| 16:9 宽屏 | 水平粗竖直细 | 均匀 |
4. 修正不光滑物体断边问题(优化模块 2)
4.1 问题描述
对立方体、立方 Sphere(光滑度低)等棱角分明的模型,描边在转角处出现断裂,无法勾勒出完整轮廓。
4.2 问题根因
立方体的顶点法线是垂直于各自平面的。考虑立方体顶角处三个面相交于一个顶点,每个顶点只能存储一个法线方向。沿法线外扩时:
- 两侧的外扩方向”背向而行”
- 在棱角处无法平滑过渡,导致”断边”
4.3 解决方案:平均法线 + 切线数据
方案 A:顶点平均法线(无切线数据)
对于静态模型,可以在建模时预先计算每个顶点的平均法线(相邻所有面的法线向量求和再归一化)。
方案 B:切线数据(Tangent)外扩(推荐,支持骨骼动画)
卡通描边的最佳实践是沿切线方向外扩,而非法线方向。切线(Tangent)描述的是模型表面的 U 方向(纹理横向),在外扩时天然更贴近轮廓边缘,且不受顶点法线不连续的影响。
Unity 编辑器工具:自动计算切线数据
// AutoCalculateTangents.cs
using UnityEngine;
[RequireComponent(typeof(MeshFilter))]
public class AutoCalculateTangents : MonoBehaviour
{
void Start()
{
Mesh mesh = GetComponent().mesh;
Vector4[] tangents = new Vector4[mesh.vertices.Length];
Vector3[] tan1 = new Vector3[mesh.vertices.Length];
Vector3[] tan2 = new Vector3[mesh.vertices.Length];
int[] triangles = mesh.triangles;
Vector2[] uv = mesh.uv;
for (int i = 0; i < triangles.Length; i += 3)
{
int i1 = triangles[i], i2 = triangles[i+1], i3 = triangles[i+2];
Vector3 v1 = mesh.vertices[i1], v2 = mesh.vertices[i2], v3 = mesh.vertices[i3];
Vector2 w1 = uv[i1], w2 = uv[i2], w3 = uv[i3];
float x1=v2.x-v1.x, x2=v3.x-v1.x, y1=v2.y-v1.y, y2=v3.y-v1.y;
float z1=v2.z-v1.z, z2=v3.z-v1.z;
float s1=w2.x-w1.x, s2=w3.x-w1.x, t1=w2.y-w1.y, t2=w3.y-w1.y;
float div = s1*t2 - s2*t1;
float r = div == 0 ? 0 : 1.0f/div;
Vector3 sdir = new Vector3((t2*x1-t1*x2)*r,(t2*y1-t1*y2)*r,(t2*z1-t1*z2)*r);
Vector3 tdir = new Vector3((s1*x2-s2*x1)*r,(s1*y2-s2*y1)*r,(s1*z2-s2*z1)*r);
tan1[i1]+=sdir; tan1[i2]+=sdir; tan1[i3]+=sdir;
tan2[i1]+=tdir; tan2[i2]+=tdir; tan2[i3]+=tdir;
}
for (int i = 0; i < mesh.vertices.Length; i++)
{
Vector3 t = tan1[i], n = mesh.normals[i];
Vector3 tangent = (t - n * Vector3.Dot(n, t)).normalized;
float w = Vector3.Dot(Vector3.Cross(n, t), tan2[i]) < 0 ? -1 : 1;
tangents[i] = new Vector4(tangent.x, tangent.y, tangent.z, w);
}
mesh.tangents = tangents;
Debug.Log("[AutoCalculateTangents] Tangents calculated for " + name);
}
}
优化后完整 Shader:切线外扩
// ToonOutlineTangent.shader
// (完整代码与 ToonOutlineBasic 结构相同,描边 Pass 改为:)
v2f vert_outline(appdata v)
{
v2f o;
// 提取切线方向
float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
// Gram-Schmidt 正交化
worldTangent = worldTangent - dot(worldTangent, worldNormal) * worldNormal;
worldTangent = normalize(worldTangent);
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
// 切线 + 法线共同外扩
float3 outlineDir = worldTangent + worldNormal * 0.3;
outlineDir = normalize(outlineDir);
worldPos.xyz += outlineDir * _OutlineWidth;
o.pos = mul(UNITY_MATRIX_VP, worldPos);
return o;
}

4.4 效果对比
| 模型类型 | 纯法线外扩 | 切线外扩 |
|---|---|---|
| 光滑球体 | 完整 | 完整 |
| 立方体 | 断边 | 完整无断 |
| 角色模型 | 轻微断边 | 完整 |
| 圆柱体 | 边缘不均 | 均匀 |
5. 顶点色的使用(高级扩展模块)
5.1 设计思路
基础描边法中,所有顶点共享同一个 _OutlineWidth 和 _OutlineColor。实际项目中,不同部位的描边需求不同:
- 脸部:细描边,避免五官被掩盖
- 头发轮廓:粗描边,强化边缘
- 配饰/武器:独立颜色描边
我们通过顶点色的四个通道来存储这些属性:

| 通道 | 用途 |
|---|---|
| R | 描边颜色的 R 分量 |
| G | 描边颜色的 G 分量 |
| B | 描边颜色的 B 分量 |
| A | 描边粗细乘数(0~1) |
5.2 实现:顶点色控制描边
// 描边 Pass 关键代码:
float widthMultiplier = v.vertexColor.a > 0.001 ? v.vertexColor.a : 1.0;
// RGB 通道控制描边颜色(全 0 → 使用基础色)
float hasColor = step(0.001, i.vertexColor.r + i.vertexColor.g + i.vertexColor.b);
float3 outlineCol = lerp(_BaseOutlineColor.rgb, i.vertexColor.rgb, hasColor);
5.3 完整优化后 Shader(含全部优化点)
// ToonOutlineFinal.shader
// 整合 NDC 宽高比修正 + 切线外扩 + 顶点色控制的完整版本
v2f vert_outline(appdata v)
{
v2f o;
float widthMultiplier = v.vertexColor.a > 0.001 ? v.vertexColor.a : 1.0;
// NDC 空间外扩 + 宽高比修正
float4 clipPos = UnityObjectToClipPos(v.vertex);
float3 clipNormal = mul((float3x3)UNITY_MATRIX_MVP, v.normal);
float aspect = _ScreenParams.x / _ScreenParams.y;
float2 offset = normalize(clipNormal.xy) * float2(1.0, aspect)
* _BaseOutlineWidth * widthMultiplier * clipPos.w;
// 切线方向混合
float3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
worldTangent = normalize(worldTangent - dot(worldTangent, worldNormal) * worldNormal);
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 outlineDir = normalize(worldTangent + worldNormal * 0.3);
worldPos.xyz += outlineDir * _BaseOutlineWidth * widthMultiplier * 0.5;
// 结合两种偏移
clipPos.xy += offset;
o.pos = clipPos;
o.vertexColor = v.vertexColor;
return o;
}
5.4 最终效果展示

| 部位 | 顶点色设置 | 描边效果 |
|---|---|---|
| 脸部 | A=0.3(细) | 细描边,不遮五官 |
| 头发 | A=1.0(粗) | 粗描边,轮廓清晰 |
| 武器 | RGB=(80,80,200) | 蓝黑描边,区分主次 |
| 身体 | A=0.6(默认) | 中等描边 |
关键效果指标:
- 摄像机拉近/拉远,描边宽度恒定
- 立方体棱角无断边
- 顶点色控制区域清晰独立
- 彩色描边无杂色溢出
总结
本文从 Back Facing 描边法的原理出发,逐步解决了三个实战问题:
| 优化项 | 核心改动 | 收益 |
|---|---|---|
| 摄像机距离适应 | NDC 空间 + 宽高比修正 | 远近一致,宽屏均匀 |
| 棱角断边问题 | 切线数据外扩 | 全模型无断边 |
| 精细描边控制 | 顶点色 RGBA 复用 | 美术自由度大幅提升 |
Back Facing 描边法的本质是用一次额外的 Draw Call 换描边精度与稳定性,在现代 GPU 上额外开销极低,是卡通渲染描边方案的性价比首选。