Unity深度+法线屏幕后处理描边:告别乱描边,实现干净轮廓
1. 项目概述:从“乱描边”到“干净描边”的进化
在Unity里做屏幕后处理描边,估计是很多开发者都尝试过但又容易踩坑的功能。最经典的基于颜色差值的描边,实现简单,但效果一言难尽——物体内部纹理复杂的地方会莫名其妙地多出一圈“描边”,背景颜色和前景接近时描边又会消失,更别提处理半透明物体了,效果非常不稳定。这就是我们常说的“乱描边”现象。它本质上是因为基于屏幕空间颜色信息的算法,无法准确区分“这是物体的边缘”还是“这只是颜色发生了剧烈变化”。
所以,当我们需要更专业、更稳定的描边效果时,比如用于角色高亮、场景交互提示或者风格化渲染,就必须寻求更底层的几何信息。深度纹理(Depth Texture)和法线纹理(Normal Texture)就是解决这个问题的两把钥匙。深度信息告诉我们像素在三维空间中的前后关系,法线信息则描述了物体表面的朝向。结合这两者,我们就能在屏幕空间里,更精准地判断哪里才是真正的“几何边缘”,而不是“纹理边缘”。这个项目要做的,就是彻底告别那种基于颜色、噪点满满的描边,利用Unity内置的深度和法线纹理,实现一套干净、可控、性能也相对友好的屏幕后处理描边方案。无论你是想做游戏的角色选中效果,还是为你的独立游戏增添一抹独特的视觉风格,这套思路都能给你一个扎实的起点。
2. 核心原理:为什么深度+法线能解决“乱描边”问题
要理解新方案的优势,得先看看老方案为什么不行。传统的Sobel或Roberts算子等边缘检测方法,直接在最终的屏幕颜色图像上操作。它们计算相邻像素之间的颜色或亮度差异,当差异超过某个阈值时,就认为是边缘。这会导致几个问题:
- 纹理干扰:一张砖墙贴图有丰富的颜色变化,算法会把这些纹理接缝也误判为边缘,导致描边“内溢”。
- 光照干扰:物体表面的高光、阴影边界也会产生强烈的颜色对比,被错误地识别为几何边缘。
- 深度不敏感:两个颜色不同但处于同一深度的物体紧挨着,传统方法能检测出边缘;但两个颜色相同、一个在前一个在后的物体,由于颜色没变化,反而检测不出边缘,这是致命的。
而深度和法线纹理直接从渲染管线更早期的阶段获取信息,避开了颜色和光照的“干扰”。
深度纹理存储了每个像素距离摄像机的深度值(通常是线性深度或Eye深度)。在物体的实际几何轮廓处,相邻像素的深度值会发生突变(从前景物体的深度跳到背景或另一个物体的深度)。这个突变是物理空间位置不连续的直接表现,是真正的几何边缘。
法线纹理存储了每个像素对应的世界空间或视图空间法线向量。在物体的硬边(比如立方体的棱角)或者曲面变化剧烈的地方,相邻像素的法线方向会差异很大(点积结果接近0或为负)。这标识了物体表面朝向的不连续,同样是重要的几何边缘特征,尤其是对于同一个深度层面上的复杂形状。
注意:Unity中获取的深度纹理和法线纹理是屏幕空间的。深度纹理可能来自延迟渲染路径(G-Buffer)或深度预通道,法线纹理通常来自延迟渲染的G-Buffer。在正向渲染路径下,需要额外的设置(
Camera.depthTextureMode |= DepthTextureMode.DepthNormals;)来同时生成深度和法线信息。
因此,我们的新算法核心就变成了:同时检测屏幕空间中相邻像素的深度差和法线差,当两者之一超过设定的阈值时,就将该像素判定为边缘像素并进行描边绘制。这样,只有真正的几何不连续(深度突变或法线突变)才会被识别,完美避开了颜色和纹理的干扰。这就是实现“干净描边”的理论基础。
3. 实现前的准备:理解Unity中的深度与法线纹理
在动手写Shader之前,必须搞清楚Unity提供给我们的深度和法线数据到底是什么格式、如何解码。这一步理解不到位,后面全是坑。
3.1 深度纹理的获取与解码
在Unity中,我们可以通过声明sampler2D _CameraDepthTexture来访问深度纹理。但直接采样得到的值(通常在0-1范围)并不是直接可用的线性深度。
解码线性深度是关键一步。常见的解码过程如下:
// 在Fragment Shader中 float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); float linearDepth = LinearEyeDepth(depth, _ZBufferParams);这里,LinearEyeDepth函数(或自己实现对应计算)负责将非线性化的深度缓冲值转换回摄像机空间下的线性深度值(即距离摄像机的实际距离)。这个linearDepth才是我们进行差值计算需要的。
为什么需要线性深度?因为深度缓冲本身为了优化精度分布是非线性的(近处精度高,远处精度低)。直接对非线性深度求差,在远处会由于精度不足导致边缘检测失效或出现噪点。
3.2 法线纹理的获取与解码
对于延迟渲染路径,法线信息可以直接从_CameraNormalsTexture中获取。对于正向渲染,当我们设置了DepthTextureMode.DepthNormals后,Unity会将深度和法线编码到同一张纹理(_CameraDepthNormalsTexture)中。
解码编码的法线需要特定的函数:
// 如果是 _CameraNormalsTexture,解码相对简单 float3 worldNormal = tex2D(_CameraNormalsTexture, uv).rgb * 2 - 1; // 如果是 _CameraDepthNormalsTexture,需要使用DecodeDepthNormal float4 cdn = tex2D(_CameraDepthNormalsTexture, uv); float depth; float3 normal; DecodeDepthNormal(cdn, depth, normal); // 此时得到的normal是视图空间(View Space)下的法线DecodeDepthNormal是Unity CG/HLSL内置函数,能正确地从编码的纹理中分离出深度和视图空间法线。视图空间法线是以摄像机为原点的坐标系下的方向向量,非常适合在屏幕后处理中进行计算,因为所有像素都在同一个视图空间下。
实操心得:我强烈建议在开发初期,写一个简单的Shader将解码后的深度(可视化为灰度)和法线(可视化为颜色)单独渲染到屏幕上。这能帮你直观地确认数据是否正确获取,也是调试边缘检测阈值的最佳方式。你可以看到深度图在物体轮廓处的明显阶梯,以及法线图在棱角处的颜色突变。
4. 描边算法核心实现细节
有了正确解码的数据,我们就可以设计具体的边缘检测算法了。我们采用业界常用的“中心差分”方法,结合深度和法线信息。
4.1 边缘检测算子与采样
我们不在整个屏幕进行复杂的卷积,而是对当前像素的上下左右(或包括对角线)进行采样,分别计算深度差和法线差。这里以经典的Roberts交叉算子为例,它计算对角线像素的差异,计算量小且对边缘敏感。
// 定义采样偏移(基于一个像素的纹理坐标偏移) float2 uv = i.uv; float2 uv1 = uv + _MainTex_TexelSize.xy * float2(1, 1); // 右上对角线 float2 uv2 = uv + _MainTex_TexelSize.xy * float2(1, -1); // 右下对角线 // 采样并解码深度、法线 float depthCenter = GetLinearDepth(uv); float depth1 = GetLinearDepth(uv1); float depth2 = GetLinearDepth(uv2); float3 normalCenter = GetViewSpaceNormal(uv); float3 normal1 = GetViewSpaceNormal(uv1); float3 normal2 = GetViewSpaceNormal(uv2);_MainTex_TexelSize是Unity提供的变量,包含了纹理像素的大小,用于确保我们的偏移是精确的一个像素。
4.2 深度边缘与法线边缘的判定
接下来分别计算深度和法线的差异:
// 1. 深度边缘:计算对角线深度差的平方和 float depthEdge = abs(depth1 - depthCenter) + abs(depth2 - depthCenter); // 更鲁棒的做法:使用平方差 // float depthEdge = (depth1 - depthCenter) * (depth1 - depthCenter) + (depth2 - depthCenter) * (depth2 - depthCenter); // 2. 法线边缘:计算法线向量的点积。点积接近1说明方向相似,接近0或负说明方向差异大。 float normalEdge = dot(normal1, normalCenter) + dot(normal2, normalCenter); // 因为normal是单位向量,点积即cos(θ)。我们想要的是“不相似度”,所以用1减去。 normalEdge = 2.0 - normalEdge; // 此时值越大,说明法线差异越大。这里有一个关键点:深度差是标量,直接相减;法线差是向量,用点积衡量夹角。
4.3 阈值融合与边缘输出
得到两个边缘强度值后,我们需要用阈值来判断是否超过,并融合两者:
// 应用阈值,将连续值二值化(或平滑过渡) float depthThreshold = _DepthSensitivity; // 例如 0.05 float normalThreshold = _NormalSensitivity; // 例如 0.5 float edgeDepth = step(depthThreshold, depthEdge); // 大于阈值则为1,否则为0 float edgeNormal = step(normalThreshold, normalEdge); // 融合策略:深度或法线任一检测到边缘,即认为是边缘 float edge = max(edgeDepth, edgeNormal); // 或者使用平滑的smoothstep,让边缘有抗锯齿的过渡 // float edgeDepth = smoothstep(depthThreshold, depthThreshold + 0.01, depthEdge); // float edgeNormal = smoothstep(normalThreshold, normalThreshold + 0.01, normalEdge); // float edge = saturate(edgeDepth + edgeNormal);_DepthSensitivity和_NormalSensitivity是需要暴露给美术或开发者调节的参数。深度敏感度通常很小(如0.01-0.1),因为线性深度值本身可能很大;法线敏感度通常在0.1-1之间。
最终,edge因子为1的地方,就是我们要绘制描边的位置。在片段着色器中,我们通常用这个因子在原图颜色和描边颜色之间进行混合:
float4 originalColor = tex2D(_MainTex, uv); float4 outlineColor = _OutlineColor; float4 finalColor = lerp(originalColor, outlineColor, edge); return finalColor;5. 完整Shader代码与后处理脚本集成
理论讲完,是时候上干货了。下面是一个简化但完整的Unity后处理描边Shader示例,以及配套的C#脚本。
5.1 边缘检测Shader (OutlineEffect.shader)
Shader "Hidden/OutlineEffect" { Properties { _MainTex ("Texture", 2D) = "white" {} _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1) _DepthSensitivity ("Depth Sensitivity", Range(0, 0.1)) = 0.01 _NormalSensitivity ("Normal Sensitivity", Range(0, 2)) = 0.5 _OutlineThickness ("Outline Thickness", Int) = 1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } sampler2D _MainTex; sampler2D _CameraDepthNormalsTexture; // 使用编码的深度法线纹理 float4 _MainTex_TexelSize; float4 _OutlineColor; float _DepthSensitivity; float _NormalSensitivity; int _OutlineThickness; // 解码深度和法线 inline void DecodeDepthNormal(float4 enc, out float depth, out float3 normal) { depth = DecodeFloatRG(enc.zw); normal = DecodeViewNormalStereo(enc); } float GetLinearDepth(float2 uv) { float4 cdn = tex2D(_CameraDepthNormalsTexture, uv); float depth; float3 normal; DecodeDepthNormal(cdn, depth, normal); return LinearEyeDepth(depth); } float3 GetViewSpaceNormal(float2 uv) { float4 cdn = tex2D(_CameraDepthNormalsTexture, uv); float depth; float3 normal; DecodeDepthNormal(cdn, depth, normal); return normal; } float4 frag (v2f i) : SV_Target { float2 uv = i.uv; float edge = 0; // 根据描边厚度进行多次采样(简化示例,仅作横向纵向扩展) for(int x = -_OutlineThickness; x <= _OutlineThickness; x++) { for(int y = -_OutlineThickness; y <= _OutlineThickness; y++) { if(x == 0 && y == 0) continue; // 跳过中心点 float2 offset = float2(x, y) * _MainTex_TexelSize.xy; float2 sampleUV = uv + offset; float depthCenter = GetLinearDepth(uv); float depthSample = GetLinearDepth(sampleUV); float3 normalCenter = GetViewSpaceNormal(uv); float3 normalSample = GetViewSpaceNormal(sampleUV); // 计算差异 float depthDiff = abs(depthSample - depthCenter); float normalDiff = 1.0 - dot(normalSample, normalCenter); // 点积越小,差异越大 // 阈值判断 if(depthDiff > _DepthSensitivity || normalDiff > _NormalSensitivity) { edge = 1; break; // 已检测到边缘,可提前跳出 } } if(edge > 0) break; } float4 originalColor = tex2D(_MainTex, uv); float4 finalColor = lerp(originalColor, _OutlineColor, edge); return finalColor; } ENDCG } } }5.2 C#后处理脚本 (OutlinePostProcessing.cs)
using UnityEngine; [ExecuteInEditMode] public class OutlinePostProcessing : MonoBehaviour { public Color outlineColor = Color.black; [Range(0, 0.1)] public float depthSensitivity = 0.01f; [Range(0, 2)] public float normalSensitivity = 0.5f; [Range(1, 5)] public int outlineThickness = 1; private Material _outlineMaterial; void OnEnable() { // 确保摄像机生成深度法线纹理 Camera.main.depthTextureMode |= DepthTextureMode.DepthNormals; // 创建材质 Shader outlineShader = Shader.Find("Hidden/OutlineEffect"); if (outlineShader == null) { Debug.LogError("Outline Shader not found!"); enabled = false; return; } _outlineMaterial = new Material(outlineShader); _outlineMaterial.hideFlags = HideFlags.HideAndDontSave; } void OnDisable() { if (_outlineMaterial != null) { DestroyImmediate(_outlineMaterial); } } // 在图像渲染完成后调用 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_outlineMaterial == null) { Graphics.Blit(source, destination); return; } // 传递参数到Shader _outlineMaterial.SetColor("_OutlineColor", outlineColor); _outlineMaterial.SetFloat("_DepthSensitivity", depthSensitivity); _outlineMaterial.SetFloat("_NormalSensitivity", normalSensitivity); _outlineMaterial.SetInt("_OutlineThickness", outlineThickness); // 执行后处理 Graphics.Blit(source, destination, _outlineMaterial); } }将这个脚本挂到主摄像机上,调整参数,你就能立刻看到效果。脚本中的OnEnable方法确保了摄像机会渲染深度法线纹理,这是正向渲染路径下获取数据的必要条件。
6. 参数调优与视觉风格化
实现基础功能只是第一步,要让描边效果真正融入项目美术风格,精细的参数调优和功能扩展必不可少。
6.1 核心参数详解与调节指南
_DepthSensitivity (深度敏感度):
- 作用:控制多深的“台阶”会被识别为边缘。值越小,对深度变化越敏感,越细微的深度差异(如地面上的小石子)也会产生描边;值越大,只有较大的深度断层(如角色站在地面上)才会被识别。
- 调节技巧:从0.005开始尝试。对于需要清晰轮廓的卡通风格,可以调低(如0.002);对于写实风格,避免远景噪点,可以调高(如0.02)。注意,这个值依赖于场景的尺度(Scene Scale),如果整个场景放大了一倍,深度差值也会变大,可能需要相应调高敏感度。
_NormalSensitivity (法线敏感度):
- 作用:控制表面朝向变化多大时被识别为边缘。值越小,对法线变化越敏感,曲面上的细微褶皱也会出现描边;值越大,只有硬边(如立方体棱角)才会产生描边。
- 调节技巧:从0.5开始尝试。对于光滑的有机模型(如角色),可以调高(如0.8-1.2)以避免模型表面出现不必要的内部描边。对于硬表面机械模型,可以调低(如0.3-0.6)以确保所有硬边都被捕捉。
_OutlineThickness (描边厚度):
- 作用:控制描边的像素宽度。在Shader中,我们通过增加采样点范围来实现。
- 调节技巧:厚度为1通常对应1像素宽。增加厚度会让描边更粗,但也会增加采样次数(性能开销)并可能使边缘变“糊”。一个优化技巧是:不要简单地进行NxN的全方位采样,而是分别进行水平方向和垂直方向的扩张采样,然后合并结果,性能更好。
_OutlineColor (描边颜色):
- 作用:不言自明。但不仅仅是颜色,还可以考虑使用纹理或根据深度进行颜色渐变。
- 进阶技巧:可以让描边颜色受深度或屏幕位置影响。例如,让远处的描边变淡或变蓝(大气透视),或者让描边具有噪点纹理来增加手绘感。
6.2 常见视觉问题与优化方案
问题:描边在物体内部闪烁或抖动(“泳池效应”)
- 原因:深度或法线信息在相邻帧间因微小差异(如摄像机抖动、物体微动)而波动,导致边缘检测结果不稳定。
- 解决方案:
- 阈值平滑:使用
smoothstep代替step,在阈值附近创建一个平滑过渡带,而不是非0即1的硬切换。 - ** temporal filtering (时间滤波)**:将当前帧的边缘检测结果与上一帧的结果进行混合(如
currentEdge = lerp(previousEdge, currentEdge, 0.1)),可以极大程度地平滑闪烁,但会带来轻微的拖影。需要权衡。
- 阈值平滑:使用
问题:远景物体描边断裂或消失
- 原因:深度缓冲在远处精度不足,相邻像素的深度值可能没有差异,导致深度边缘检测失效。
- 解决方案:
- 依赖法线边缘:在远景,更多地依靠法线信息。可以编写Shader,根据线性深度值动态调整
_DepthSensitivity(远处增大)和_NormalSensitivity(远处减小)。 - 使用对数深度:更高级的方案是使用对数深度缓冲(Logarithmic Depth Buffer),但这需要修改整个渲染管线的深度计算,比较复杂。
- 依赖法线边缘:在远景,更多地依靠法线信息。可以编写Shader,根据线性深度值动态调整
问题:描边在屏幕边缘被切断
- 原因:采样偏移到了屏幕纹理坐标范围(0,1)之外。
- 解决方案:在Shader中进行UV坐标钳制或使用纹理的Wrap Mode。
sampler2D默认是Clamp模式,采样越界会取边缘值,这可能导致屏幕边缘误检测。更安全的方式是在采样前判断UV是否在有效范围内,或者直接舍弃屏幕边缘几个像素的描边效果。
7. 性能考量与进阶优化思路
屏幕后处理是每帧对全屏幕像素进行的操作,性能至关重要。我们的基础实现已经比一些基于模糊的描边方案高效,但仍有优化空间。
7.1 性能瓶颈分析
- 纹理采样次数:这是最大的开销。我们简单的NxN采样循环,采样次数是
(2*Thickness+1)^2。厚度为2时,每个像素最多要采样25次深度法线纹理,代价高昂。 - 复杂的解码计算:
DecodeDepthNormal和LinearEyeDepth涉及一些数学运算,但相对于纹理采样,这部分开销较小。 - 分辨率:后处理在屏幕分辨率下进行。4K分辨率下的像素数是1080p的4倍,计算量也剧增。
7.2 实战优化策略
降采样(Downsampling):
- 思路:先在半分辨率(甚至四分之一分辨率)的RenderTexture上进行边缘检测。因为描边本身是低频信息(变化不剧烈),降采样后质量损失不大。
- 实现:使用
Graphics.Blit先将原图渲染到一个小尺寸的RT,在这个小RT上执行边缘检测Shader,得到一张“边缘图”。然后再将这张边缘图上采样(简单双线性过滤即可)到全分辨率,与原图混合。这能直接减少3/4的像素运算量。
分离采样(Separable Sampling):
- 思路:将二维的NxN采样,拆解成先进行水平方向的1xN采样,再进行垂直方向的Nx1采样,然后将两次结果叠加。这能将采样复杂度从O(N²)降低到O(2N)。
- 实现:编写两个Pass的Shader。第一个Pass只进行水平方向的边缘检测,输出一张水平边缘图。第二个Pass对水平边缘图进行垂直方向的边缘检测(或直接与原图信息结合),得到最终边缘。这需要额外的RT,但性能提升显著。
基于距离场的优化(Signed Distance Field, SDF):
- 思路:这是一个更高级、效果也更好的方案。不实时检测边缘,而是为需要描边的关键物体(如角色)预生成其屏幕空间的有向距离场。在后期处理时,只需要对每个像素采样SDF纹理,根据距离值就能快速、平滑地绘制出描边,且描边粗细、渐变效果控制极为灵活。
- 代价:需要额外的预计算或每帧更新SDF纹理,实现复杂度高,适用于固定或少量需要高质量描边的物体。
实操心得:对于移动平台或性能敏感的项目,我强烈推荐“降采样 + 基础采样”的组合。首先将深度法线纹理和颜色纹理降采样到一半,然后在这个低分辨率缓冲区进行简单的上下左右4方向采样检测。最后将得到的低分辨率边缘图放大,并与原图混合。在绝大多数情况下,视觉损失微乎其微,但性能提升立竿见影。在我的一个中型移动项目中,采用此方案后,后处理描边的耗时从每帧约2ms降低到了0.5ms以下。
8. 与其他描边方案的对比与选型建议
深度+法线方案并非银弹,了解它的优缺点,才能做出正确的技术选型。
| 描边方案 | 核心原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 基于颜色/亮度 | 检测屏幕相邻像素颜色/亮度差 | 实现最简单,性能开销极低 | 受纹理、光照干扰严重,“乱描边” | 快速原型,对效果要求极低的场合 |
| 基于深度 | 检测相邻像素深度差 | 能准确捕捉物体与背景的轮廓,不受颜色影响 | 无法处理同一深度层面的物体内部边缘(如立方体正面) | 需要突出物体整体轮廓,如场景交互高亮 |
| 基于法线 | 检测相邻像素法线方向差 | 能准确捕捉物体表面的硬边和褶皱 | 对平滑曲面不敏感,无法处理共面物体的轮廓 | 硬表面机械、建筑等棱角分明的模型 |
| 深度+法线(本文) | 结合深度差和法线差 | 兼顾轮廓与硬边,效果干净、准确 | 需要生成深度法线纹理,有一定性能开销 | 通用性最强,角色高亮、风格化渲染、交互提示 |
| 基于几何着色器 | 在模型几何阶段膨胀背面 | 描边质量极高,宽度均匀,可物体单独控制 | 性能开销大,对复杂模型不友好,实现复杂 | 需要高质量、独立控制的卡通渲染描边 |
| 基于图像边缘检测(如Sobel) | 对颜色/深度图进行卷积运算 | 边缘可能更“柔和”,有特定风格 | 计算量相对较大,阈值难调,可能包含噪声 | 需要图像处理风格的特殊效果 |
选型建议:
- 快速验证想法:用基于颜色的,最快。
- 移动端轻度使用:如果只想要物体轮廓,纯深度方案是性能和效果的平衡点。
- 绝大多数3D项目需要描边:深度+法线方案是你的首选。它提供了最好的效果与复杂度平衡。
- 风格化/卡通渲染项目:如果资源允许,几何着色器膨胀法是质量标杆。如果追求性能,深度+法线方案调好参数(如使用纯色、加大阈值)也能得到不错的卡通描边。
- 需要边缘发光等复杂效果:可以考虑在深度+法线检测出边缘后,对边缘图进行高斯模糊,再与原图进行叠加(Additive)混合,实现发光效果。这比直接对原图模糊高效得多。
实现过程中,调试是必不可少的。我常用的方法是创建一个调试面板,可以实时开关深度边缘、法线边缘的显示,并单独调节它们的阈值和颜色。这样能直观地看到是深度信息还是法线信息主导了某处的边缘检测,帮助快速定位问题和调整参数。例如,如果发现角色手臂弯曲处的内部出现了不该有的描边,很可能是法线阈值太低,调高即可消除。这套深度+法线的屏幕后处理描边方案,从原理到实现,从调优到对比,其核心价值在于它提供了一种稳定、可靠且足够高效的“干净描边”生产流水线。它可能不是效果最华丽的,但绝对是项目中最让人省心的那一类技术方案。当你不再需要为闪烁的、破碎的、或总是出现在奇怪地方的描边而烦恼时,你会觉得这些底层原理的学习和调试都是值得的。
