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但这需要修改整个渲染管线的深度计算比较复杂。问题描边在屏幕边缘被切断原因采样偏移到了屏幕纹理坐标范围0,1之外。解决方案在Shader中进行UV坐标钳制或使用纹理的Wrap Mode。sampler2D默认是Clamp模式采样越界会取边缘值这可能导致屏幕边缘误检测。更安全的方式是在采样前判断UV是否在有效范围内或者直接舍弃屏幕边缘几个像素的描边效果。7. 性能考量与进阶优化思路屏幕后处理是每帧对全屏幕像素进行的操作性能至关重要。我们的基础实现已经比一些基于模糊的描边方案高效但仍有优化空间。7.1 性能瓶颈分析纹理采样次数这是最大的开销。我们简单的NxN采样循环采样次数是(2*Thickness1)^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混合实现发光效果。这比直接对原图模糊高效得多。实现过程中调试是必不可少的。我常用的方法是创建一个调试面板可以实时开关深度边缘、法线边缘的显示并单独调节它们的阈值和颜色。这样能直观地看到是深度信息还是法线信息主导了某处的边缘检测帮助快速定位问题和调整参数。例如如果发现角色手臂弯曲处的内部出现了不该有的描边很可能是法线阈值太低调高即可消除。这套深度法线的屏幕后处理描边方案从原理到实现从调优到对比其核心价值在于它提供了一种稳定、可靠且足够高效的“干净描边”生产流水线。它可能不是效果最华丽的但绝对是项目中最让人省心的那一类技术方案。当你不再需要为闪烁的、破碎的、或总是出现在奇怪地方的描边而烦恼时你会觉得这些底层原理的学习和调试都是值得的。