卡通渲染·描边篇:Back Facing 描边法的原理与优化实战

卡通渲染·描边篇

摘要:本文记录了卡通渲染中描边技术的完整实现路径,从 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-1:Back Facing 描边法原理:Pass 1 正常渲染,Pass 2 Cull Front 背面顶点外扩露出轮廓环
图 2-1:Back Facing 描边法原理:Pass 1 正常渲染,Pass 2 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-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-2:左 = NDC 空间外扩;右 = 加入宽高比修正后 16:9 视口描边均匀
图 3-2:左 = NDC 空间外扩;右 = 加入宽高比修正后 16:9 视口描边均匀

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-1:左 = 纯法线外扩,立方体棱角断裂;右 = 切线外扩,描边完整连续
图 4-1:左 = 纯法线外扩,立方体棱角断裂;右 = 切线外扩,描边完整连续

4.4 效果对比

模型类型 纯法线外扩 切线外扩
光滑球体 完整 完整
立方体 断边 完整无断
角色模型 轻微断边 完整
圆柱体 边缘不均 均匀

5. 顶点色的使用(高级扩展模块)

5.1 设计思路

基础描边法中,所有顶点共享同一个 _OutlineWidth_OutlineColor。实际项目中,不同部位的描边需求不同:

  • 脸部:细描边,避免五官被掩盖
  • 头发轮廓:粗描边,强化边缘
  • 配饰/武器:独立颜色描边

我们通过顶点色的四个通道来存储这些属性:

图 5-1:顶点色 RGBA 通道映射:R/G/B 控制颜色,A 控制粗细;各部位独立设置
图 5-1:顶点色 RGBA 通道映射:R/G/B 控制颜色,A 控制粗细;各部位独立设置
通道 用途
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 最终效果展示

图 5-2:最终效果对比——立方体优化流程(上排)+ 相机距离适应性(下排)
图 5-2:最终效果对比——立方体优化流程(上排)+ 相机距离适应性(下排)
部位 顶点色设置 描边效果
脸部 A=0.3(细) 细描边,不遮五官
头发 A=1.0(粗) 粗描边,轮廓清晰
武器 RGB=(80,80,200) 蓝黑描边,区分主次
身体 A=0.6(默认) 中等描边

关键效果指标:

  • 摄像机拉近/拉远,描边宽度恒定
  • 立方体棱角无断边
  • 顶点色控制区域清晰独立
  • 彩色描边无杂色溢出

总结

本文从 Back Facing 描边法的原理出发,逐步解决了三个实战问题:

优化项 核心改动 收益
摄像机距离适应 NDC 空间 + 宽高比修正 远近一致,宽屏均匀
棱角断边问题 切线数据外扩 全模型无断边
精细描边控制 顶点色 RGBA 复用 美术自由度大幅提升

Back Facing 描边法的本质是用一次额外的 Draw Call 换描边精度与稳定性,在现代 GPU 上额外开销极低,是卡通渲染描边方案的性价比首选。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部