Unity3D Shader 顶点法线外扩实现描边效果
前言
在很多游戏中,我们都见过这样的效果:
- 被选中的角色有一圈描边
- 交互物体高亮外轮廓
- 卡通渲染风格有黑色边线
有一种非常经典、性能也不错的做法:
在顶点着色器中沿法线方向"外扩"模型,再用单色渲染。
原理解析
我们先理解一个关键点:
每个顶点都有一个法线(Normal)。
法线代表这个点"朝向哪里"。
如果我们在顶点阶段做这样一件事:
c
pos.xyz += normal * _OutlineWidth;
那么:
- 模型就会整体向外"膨胀"
- 看起来像放大了一点
如果:
- 外扩的模型用黑色渲染
- 原模型正常渲染
- 外扩模型先画
那么最终效果就是:
外层黑色边线 + 内层原始模型
实现方式(双 Pass)
我们需要两个 Pass:
第一个 Pass:绘制外扩模型(黑色)
第二个 Pass:绘制正常模型
完整 Shader 实现
c
Shader "Custom/NormalOutline"
{
Properties
{
_MainColor ("Main Color", Color) = (1,1,1,1)
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_OutlineWidth ("Outline Width", Float) = 0.05
}
SubShader
{
Tags { "RenderType"="Opaque" }
// ========================
// Pass 1:绘制描边
// ========================
Pass
{
Cull Front // 关键:剔除正面,只绘制背面
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
};
float _OutlineWidth;
fixed4 _OutlineColor;
v2f vert(appdata v)
{
v2f o;
float4 pos = v.vertex;
// 法线转世界空间
float3 normal = UnityObjectToWorldNormal(v.normal);
// 沿法线方向外扩
pos.xyz += normal * _OutlineWidth;
o.pos = UnityObjectToClipPos(pos);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _OutlineColor;
}
ENDCG
}
// ========================
// Pass 2:绘制原模型
// ========================
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
};
fixed4 _MainColor;
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
return _MainColor;
}
ENDCG
}
}
}
效果:

关键点详解
为什么要 Cull Front
c
Cull Front
意思是:
剔除正面三角形,只渲染背面。
因为:
- 外扩模型本质是"膨胀版本"
- 如果不剔除正面,会覆盖原模型
- 剔除正面后,只剩外圈
这是描边成立的关键。
为什么要先画描边 Pass
因为:
- 外扩模型比原模型稍大
- 先画黑色轮廓
- 再画原模型覆盖内部
最终只留下边缘。
OutlineWidth 如何控制
c
pos.xyz += normal * _OutlineWidth;
- 值越大 → 描边越粗
- 值太大 → 会产生穿插
通常范围:
c
0.01 ~ 0.1
进阶优化
问题一:缩放后描边会变粗
如果物体 Scale 变大,描边也会变粗。
改进方式:
使用对象空间法线,而不是世界空间:
c
pos.xyz += v.normal * _OutlineWidth;
适合不考虑世界缩放时。
问题二:远处描边太细
可以基于摄像机距离做补偿:
c
float dist = distance(mul(unity_ObjectToWorld, v.vertex).xyz, _WorldSpaceCameraPos);
pos.xyz += normal * _OutlineWidth * dist * 0.1;
实现远近一致的视觉粗细。
优缺点分析
优点
- 实现简单
- 性能较好
- 不需要后处理
- 不依赖屏幕空间
缺点
- 受模型法线影响
- 锐角处可能断裂
- 透明物体不适用
- 多 Pass 会增加 DrawCall
适用场景
- 角色选中描边
- 卡通渲染(Toon)
- 交互物体提示
- 低成本 Outline