
1. 项目概述从“乱描边”到“干净描边”的进化在Unity里做屏幕后处理描边很多开发者尤其是刚接触Shader编程的朋友可能都经历过一个阶段用Sobel或者Prewitt算子对着屏幕颜色一顿操作结果描出来的边要么断断续续要么把纹理细节也当成了轮廓画面看起来脏兮兮的这就是典型的“乱描边”。我刚开始做卡通渲染项目时也在这个坑里挣扎了很久。后来发现问题的根源在于我们选错了“原材料”。单纯依赖屏幕颜色RGB信息来做边缘检测太容易受到纹理、光照变化甚至噪点的干扰了。这个项目要解决的就是如何告别这种“乱描边”实现更干净、更准确的屏幕空间轮廓线。核心思路是放弃传统的颜色边缘检测转而使用深度Depth和法线Normal信息。深度信息记录了每个像素到摄像机的距离法线信息记录了每个像素所在表面的朝向。物体的轮廓本质上就是深度或法线发生剧烈突变的地方。基于这个原理我们使用Roberts交叉差分算子来检测这些突变从而生成轮廓线。相比于更常见的Sobel算子Roberts算子计算量更小对对角线方向的边缘响应更敏感在特定风格化渲染中能产生更“硬朗”、更“干净”的线条感非常适合卡通渲染、场景高亮等需要清晰轮廓的应用。这篇文章我会从一个踩过坑的开发者角度详细拆解如何从零构建一个基于深度法线的Roberts算子描边后处理效果。无论你是想为自己的独立游戏增添风格化笔触还是想深入理解屏幕后处理的边缘检测原理这篇内容都能提供一套可直接复现的“实战手册”。2. 核心原理为什么深度法线Roberts是绝配要理解这个方案为什么有效我们需要拆开来看三个关键部分深度/法线信息的优势、Roberts算子的特性以及它们组合起来产生的化学反应。2.1 深度与法线更稳定的边缘信号源屏幕后处理是在渲染完所有不透明物体之后对最终生成的图像即帧缓冲区进行的二次加工。传统的颜色描边Color-based Edge Detection直接对屏幕的RGB颜色值进行卷积运算比如Sobel。这带来几个问题纹理干扰一张带有复杂花纹的墙面其颜色变化可能比实际物体的轮廓还要剧烈导致描边算法把花纹也识别为边缘。光照干扰阴影边界、高光区域会造成颜色明暗的剧烈变化这些并非几何轮廓但会被错误检测。抗锯齿AA影响MSAA或FXAA等抗锯齿技术会混合边缘像素的颜色使得颜色过渡平滑这反而会“抹去”边缘检测所需的剧烈变化信号。深度和法线信息则完美避开了这些坑深度信息存储在每个像素的深度缓冲区Depth Buffer中表示该像素对应的场景点到摄像机的距离。在物体的轮廓Silhouette处前后景的深度值会有阶跃式的跳变。这个跳变非常尖锐不受纹理和光照影响。法线信息存储在每个像素的法线缓冲区Normal Buffer通常由GBuffer提供。在物体的折痕Crease处比如两个平面相交的棱角相邻像素的法线方向会突然改变。这能捕捉到颜色和深度都无法很好表现的内部结构线。注意深度和法线信息通常不是默认提供的。在Unity的URP通用渲染管线或Built-in管线中你需要通过脚本命令CommandBuffer或Render Feature显式地将深度和法线渲染到一张或多张纹理Render Texture中供后处理Shader采样。这是实现本方案的第一步也是关键的准备步骤。2.2 Roberts算子轻量且敏锐的边缘探测器边缘检测算子的本质是一个卷积核Kernel它通过在图像上滑动计算中心像素与周围像素的差异梯度来定位边缘。常见的算子有Sobel、Prewitt、Roberts等。Roberts交叉差分算子由两个2x2的卷积核组成Gx [1, 0] Gy [0, 1] [0, -1] [-1, 0]它的计算方式是对角线方向的差分。对于图像中某个像素点(x, y)其梯度近似计算为gradient_x I(x, y) - I(x1, y1) gradient_y I(x1, y) - I(x, y1)最终的梯度幅值即边缘强度为G sqrt(gradient_x^2 gradient_y^2)。为什么选择Roberts而不是更流行的Sobel计算效率Roberts是2x2核Sobel是3x3核。在Shader中每多采样一个纹理像素texel都有开销。Roberts算子只需要采样中心像素及其右方、下方、右下方三个相邻像素共计4次纹理采样。而Sobel算子需要采样3x3共9个像素。在移动平台或需要高性能的场景下这个优势很明显。边缘响应Roberts算子对对角线方向的边缘更敏感产生的线条更“细”更“锐利”。Sobel算子对水平和垂直边缘响应更好线条相对“粗”一些。在风格化渲染中我们有时恰恰需要这种锐利、干净的线条感。当然这也意味着Roberts对噪声更敏感但幸运的是我们使用的是相对稳定的深度/法线数据而非充满噪声的颜色数据。2.3 组合策略深度与法线的权重融合单独使用深度或法线都有其局限性仅深度能完美捕捉物体与背景的轮廓但对于两个深度值相近的物体重叠比如放在桌上的书其接触边缘可能无法检测。也无法检测物体内部的法线折痕。仅法线能很好地捕捉物体内部的折痕和曲面细节但对于一个光滑的球体其轮廓 silhouette处法线变化是连续的可能无法产生强烈的边缘信号。因此最佳实践是同时计算深度边缘强度和法线边缘强度然后按需混合。我们可以为两者分别设置一个阈值Threshold和灵敏度Sensitivity然后取两者的最大值或加权和作为最终的边缘图。这样既能保证外部轮廓清晰又能保留重要的内部结构线。3. 实战构建一步步实现深度法线Roberts描边理论讲完了我们进入实战环节。这里以Unity URP管线为例进行说明Built-in管线思路类似但API有所不同。3.1 第一步获取深度与法线纹理在URP中我们可以通过Render Features来轻松获取深度和法线。创建Render Feature在URP Asset的Renderer列表中找到你的Renderer Data添加一个Render ObjectsFeature。配置Render FeatureEvent设置为AfterRenderingOpaques。确保在不透明物体渲染完毕后执行。FiltersLayer Mask可以设置为Everything或你需要的特定层级。Override Material这里我们需要一个特殊的Shader来将深度和法线写入一张RT。但实际上URP内置了CameraDepthTexture和CameraNormalsTexture。更简单的方法是直接通过脚本或Shader Graph启用它们。启用相机纹理在你的后处理Volume组件挂载的脚本中或者直接在后处理Shader的C#调用代码里确保相机设置了生成深度和法线纹理。// 在设置后处理材质的代码中 var cameraData camera.GetUniversalAdditionalCameraData(); cameraData.requiresDepthTexture true; cameraData.requiresNormalTexture true;这样在Shader中我们就可以通过_CameraDepthTexture和_CameraNormalsTexture这两个全局变量来采样深度和法线了。实操心得在移动平台上全屏生成法线纹理requiresNormalTexture true可能会有一定的性能开销因为它意味着所有不透明物体都需要被额外渲染一次到法线GBuffer。如果项目性能吃紧可以考虑只使用深度纹理或者通过自定义的Render Feature只对特定层级的物体渲染法线。3.2 第二步编写Roberts边缘检测Shader这是核心部分。我们创建一个Unlit Shader用于后处理全屏Blit。1. 定义属性和变量Shader Hidden/RobertsDepthNormalOutline { Properties { _MainTex (Texture, 2D) white {} _OutlineColor (Outline Color, Color) (0,0,0,1) _OutlineWidth (Outline Width, Range(0, 5)) 1 _DepthThreshold (Depth Threshold, Range(0, 1)) 0.01 _NormalThreshold (Normal Threshold, Range(0, 1)) 0.5 _DepthNormalWeight (Depth/Normal Weight, Range(0, 1)) 0.5 _DepthSensitivity (Depth Sensitivity, Float) 1 _NormalSensitivity (Normal Sensitivity, Float) 1 } SubShader { // ... Pass 定义 ... } }_OutlineWidth控制边缘检测的采样距离值越大描边越粗原理是增大纹理采样的偏移_TexelSize * _OutlineWidth。_DepthThreshold/_NormalThreshold判断一个梯度值是否算作“边缘”的阈值。低于此值则忽略。_DepthNormalWeight用于混合深度边缘和法线边缘的权重。0表示只用法线1表示只用深度。_DepthSensitivity/_NormalSensitivity灵敏度系数用于放大或缩小原始梯度值方便微调效果强弱。2. 顶点着色器标准的全屏三角形渲染计算UV即可。3. 片段着色器关键float4 frag (v2f i) : SV_Target { // 计算基于宽度的纹理采样偏移 float2 texelSize _MainTex_TexelSize.xy * _OutlineWidth; // Roberts算子采样点当前像素(p0)右方(p1)下方(p2)右下方(p3) float2 uv i.uv; float2 uv1 uv float2(texelSize.x, 0); float2 uv2 uv float2(0, texelSize.y); float2 uv3 uv float2(texelSize.x, texelSize.y); // 1. 深度边缘检测 float d0 LinearEyeDepth(SampleDepth(uv), _ZBufferParams); float d1 LinearEyeDepth(SampleDepth(uv1), _ZBufferParams); float d2 LinearEyeDepth(SampleDepth(uv2), _ZBufferParams); float d3 LinearEyeDepth(SampleDepth(uv3), _ZBufferParams); float depthGradX d0 - d3; // Roberts Gx float depthGradY d1 - d2; // Roberts Gy float depthEdge sqrt(depthGradX * depthGradX depthGradY * depthGradY) * _DepthSensitivity; depthEdge saturate(depthEdge / _DepthThreshold); // 归一化并应用阈值 // 2. 法线边缘检测 float3 n0 SampleNormal(uv); float3 n1 SampleNormal(uv1); float3 n2 SampleNormal(uv2); float3 n3 SampleNormal(uv3); // 法线是方向向量我们计算其差异的“长度” float3 normalGradX n0 - n3; float3 normalGradY n1 - n2; // 使用点积来衡量差异的强度 float normalEdgeX dot(normalGradX, normalGradX); float normalEdgeY dot(normalGradY, normalGradY); float normalEdge sqrt(normalEdgeX normalEdgeY) * _NormalSensitivity; normalEdge saturate(normalEdge / _NormalThreshold); // 3. 混合深度和法线边缘 float edge lerp(normalEdge, depthEdge, _DepthNormalWeight); // 或者使用更激进的最大值混合: float edge max(depthEdge, normalEdge); edge step(0.1, edge); // 二值化大于0.1的视为边缘白色否则不是黑色 // 4. 与原始屏幕颜色混合 float4 originalColor tex2D(_MainTex, uv); float4 outlineColor _OutlineColor; float4 finalColor lerp(originalColor, outlineColor, edge); return finalColor; }代码关键点解析LinearEyeDepth将深度纹理采样得到的非线性深度值通常是0-1转换回线性的眼睛空间深度即距离摄像机的实际单位距离。这一步至关重要因为深度缓冲区的值是非线性分布的为了更好的精度分布直接用它做差分计算会得到错误的结果导致在近处物体边缘过强远处几乎没效果。转换后深度差才具有实际的物理距离意义。SampleDepth/SampleNormal是自定义的采样函数用于处理不同平台如DX/OpenGL的UV朝向差异并正确解码法线法线纹理通常被编码到0-1范围需要解码回-1到1。法线差异计算法线是三维向量不能像标量深度那样直接相减。这里计算两个法线向量的差向量然后求其长度的平方通过点积来衡量它们的方向差异程度。这是处理法线边缘的常用方法。step函数用于将连续的边缘强度图edge二值化生成非黑即白的硬边缘。如果你想要柔和的、有粗细变化的描边可以去掉这一步直接用edge值作为混合系数alpha。3.3 第三步集成到Unity后处理流程创建后处理材质将上面写好的Shader拖拽创建一个Material。使用Volume框架推荐在URP中标准做法是创建Volume并添加Full Screen Pass Render Feature对应的Override例如FullScreenPass然后将你的后处理材质赋给它。这样可以方便地控制后处理效果的启用、混合和堆叠。旧式OnRenderImage方法Built-in管线如果是在Built-in管线你需要编写一个C#脚本挂载到相机上在OnRenderImage函数中调用Graphics.Blit(source, destination, material)。参数调优建议表参数作用调优方向与技巧_OutlineWidth描边粗细通常设为1-2。值太大会导致采样点距离过远可能跨过了真正的边缘导致描边“错位”或断开。移动端建议从1开始。_DepthThreshold深度边缘阈值这是最重要的参数之一。它决定了多深的“裂缝”才会被识别为边缘。对于场景尺度大的游戏开放世界这个值需要设得大一些如0.1否则远处细微的深度变化也会产生杂边。对于小场景或固定视角可以设小如0.001以获得更精细的边缘。_NormalThreshold法线边缘阈值控制对表面朝向变化的敏感度。值越小对细微的法线变化越敏感能捕捉更多细节折痕但也可能产生噪声。通常设置在0.3-0.7之间。_DepthNormalWeight深度/法线权重0.5表示两者均衡。如果你只想要物体外轮廓可以调到1.0纯深度。如果你想要更多卡通感的内部结构线如衣服褶皱可以调到0.2偏法线。_Depth/Normal Sensitivity灵敏度在应用阈值前先放大或缩小原始梯度信号。如果你觉得边缘强度整体太弱或太强优先调整这个而不是阈值。4. 进阶优化与问题深度排查实现基础功能后我们会遇到一些实际问题和优化点。4.1 处理屏幕边缘与深度不连续区域在屏幕边缘Roberts算子需要采样屏幕外的像素这会导致错误。标准的解决方案是钳制Clamp或边框Border采样模式。在Shader中我们可以将纹理的Wrap Mode设置为Clamp或者在采样前手动钳制UV坐标。// 在采样前钳制UV uv clamp(uv, float2(0, 0), float2(1, 1));更优雅的做法是在计算边缘时判断如果采样点越界则不计入梯度计算或赋予一个默认值如背景深度。另一个常见问题是深度不连续区域例如物体与纯天空盒无限远的交界处。这里的深度值从有限值跳变到无限大或一个非常大的值会导致计算出的深度梯度depthGradX/Y异常巨大产生一条非常粗且难看的边缘线。解决方案是引入深度差异检查float depthDelta abs(d0 - d3); // 或计算四个采样点的最大深度差 if (depthDelta _MaxDepthDelta) { depthEdge 0; // 忽略这个像素的边缘计算 }_MaxDepthDelta是一个可调参数例如设置为摄像机远裁剪平面距离的0.5倍。这样可以过滤掉因前景物体与天空盒或远处背景混合而产生的异常边缘。4.2 性能优化策略降低采样次数我们的Shader目前对深度和法线各采样4次共8次。可以尝试优化为共享采样点。深度和法线纹理的采样UV偏移是相同的理论上可以只采样4个位置然后分别从深度和法线纹理中读取数据。这需要将深度和法线打包到同一张纹理的不同通道如RGBA两个通道存法线另一个RT存深度或者使用URP的_CameraDepthNormalsTexture一张纹理同时编码了深度和法线但需要解码。使用Half精度在支持half精度的平台大部分现代GPU将Shader中的浮点数变量如float改为half可以提升运算速度减少寄存器压力。对于颜色、UV、阈值参数等half精度通常足够。调整渲染分辨率全屏后处理是对每个像素进行计算开销与屏幕分辨率成正比。对于非核心视觉效果可以考虑将描边效果渲染到一张半分辨率Half-Res的Render Texture上然后再上采样Upsample到全屏。这能显著提升性能虽然会损失一些边缘的锐度但对于风格化渲染有时是可以接受的。基于距离的淡化Distance Fade对于大型开放世界远处的物体不需要精细的描边。可以根据像素的深度线性眼空间深度来逐渐减弱_OutlineWidth或edge强度直至为0。这既能节省远处像素的计算通过分支或lerp也能符合视觉景深规律。4.3 与抗锯齿TAA/MSAA的兼容性现代游戏常用TAA时间性抗锯齿来消除锯齿。TAA依赖于历史帧数据而深度/法线信息如果每帧变化剧烈比如摄像机快速移动会导致TAA重影Ghosting。解决方案使用抖动Jitter的摄像机矩阵在渲染深度/法线G-Buffer时使用与主颜色通道相同的、经过TAA抖动的摄像机投影矩阵。这能保证深度/法线信息与颜色信息的采样位置对齐减少不匹配导致的闪烁或重影。在URP中这通常意味着你需要自定义一个Render Feature来渲染深度/法线并手动设置投影矩阵。对边缘图进行模糊或形态学操作在生成二值化的边缘图后进行一次轻微的模糊如3x3高斯模糊或膨胀Dilation操作。这可以让边缘线更稳定减少因TAA采样位置微动导致的边缘闪烁。虽然这会损失一点边缘的锐度但能极大提升视觉稳定性。5. 效果对比与风格化扩展实现基础效果后我们可以将其与传统的颜色Sobel描边进行直观对比。深度法线Roberts描边的优势干净不受纹理和光照影响轮廓准确。可控能通过权重分别控制外部轮廓和内部结构线。性能潜力Roberts算子采样次数少且深度/法线信息稳定易于优化。可能的不足无法检测纯色或渐变区域的边缘如果两个物体颜色不同但深度和法线完全一致比如贴在同一墙面上的两张海报本方法无法检测。这种情况下需要结合颜色信息或ID Buffer。需要额外的渲染开销生成法线纹理需要额外的Pass对性能有影响。风格化扩展思路多级描边Multi-level Outline使用不同的_DepthThreshold和_NormalThreshold生成两套边缘图一套用于粗轮廓一套用于细折痕然后用不同颜色或粗细叠加营造手绘多层线条的感觉。边缘抖动与笔触感对边缘图的UV进行基于时间或屏幕空间的噪声扰动让描边线产生手绘般的轻微抖动增强艺术感。颜色溢出Color Bleeding在判定为边缘的像素处不仅绘制轮廓色还采样其内侧相邻像素的颜色进行一定程度的混合模拟卡通片中色块边缘的颜色扩散效果。结合物体ID除了深度法线还可以渲染一个物体ID缓冲区每个物体一种颜色。在边缘检测时如果相邻像素的ID不同则强制标记为边缘。这可以绝对确保不同物体间的分离但需要额外的Draw Call来渲染ID图。调试这个过程最实用的工具就是Frame Debugger和RenderDoc。用Frame Debugger查看你的后处理Pass是否正确插入渲染目标是否正确。用RenderDoc捕获一帧检查你生成的深度纹理、法线纹理以及中间每一步的Render Texture如边缘图直观地看到每个像素的计算结果这对于排查阈值设置不当、采样越界、深度解码错误等问题有奇效。