Unity HDRP高质量物体描边方案:基于自定义渲染通道的ID图实现

📅 2026/7/18 7:17:20
Unity HDRP高质量物体描边方案:基于自定义渲染通道的ID图实现
1. 项目概述为什么我们需要一个HDRP专属的描边方案在Unity的高清渲染管线HDRP里实现一个高质量的物体描边Outline效果这几乎是每个从内置渲染管线Built-in或通用渲染管线URP迁移过来的开发者都会遇到的“坎”。你可能试过用内置管线里那些经典的方案比如基于法线外扩的Shader、基于后处理的Sobel边缘检测甚至是自己写一个CommandBuffer来渲染第二个Pass。但把这些方法直接搬到HDRP里往往会发现要么效果不对要么性能开销大到离谱要么干脆就不工作。这背后的核心原因在于HDRP是一套完全重构的、基于物理的、延迟渲染为主的现代渲染架构。它不再有内置管线里那些“全局”的后处理事件渲染顺序、光照计算、深度/法线缓冲区的访问方式都发生了根本性的变化。一个简单的例子在延迟渲染路径下你没法像前向渲染那样在物体的Shader里轻松拿到整个场景的深度图来做边缘检测。HDRP的渲染流程被拆分成多个独立的、可配置的通道Pass每个通道都有严格的输入输出限制。所以“从零到一”打造一个HDRP Outline插件绝不仅仅是把旧代码移植过来那么简单。它要求我们深入理解HDRP的渲染流程特别是它的全屏后处理FullScreen Pass和自定义渲染通道Custom Render Pass机制。我们需要思考描边信息从哪里获取是在物体渲染时生成一个ID图还是在后处理阶段分析深度和法线如何高效地筛选需要描边的物体如何让描边效果与HDRP复杂的光照、抗锯齿如TAA、后期特效如Bloom正确兼容最后也是最重要的如何控制性能开销确保在移动端或VR等高要求场景下依然流畅这个插件项目本质上是一次对HDRP渲染引擎的深度探索。通过它我们不仅能得到一个实用的工具更能透彻掌握HDRP扩展渲染效果的核心方法论。接下来我将拆解整个实现过程从设计思路到代码细节再到性能调优的每一个坑点。2. 核心方案选型深度/法线、ID图与屏幕空间方案的抉择实现描边主流技术路线有三条每条路在HDRP下的可行性和代价截然不同。2.1 基于深度和法线的屏幕空间后处理这是最“经典”的后处理描边思路。在渲染完整个场景后我们拿到当前帧的深度缓冲区Depth Buffer和法线缓冲区Normal Buffer。通过对比相邻像素间的深度值和法线方向差异来判断哪里是物体的边缘。在HDRP中的实现挑战缓冲区访问HDRP的深度和法线纹理是内部管理的你需要通过RTHandle系统正确申请和绑定。并且由于HDRP可能使用多摄像机渲染如反射探针你需要确保处理的是主摄像机的缓冲区。抗锯齿干扰如果开启了TAA时间抗锯齿深度和法线缓冲区是经过Jitter的直接进行邻域采样会导致边缘检测结果闪烁和抖动。通常需要先对深度/法线进行重投影Reprojection或使用当前帧与历史帧混合的复杂逻辑来稳定边缘。半透明物体深度缓冲区通常不包含半透明物体的信息因此这种方法无法为半透明物体生成描边。性能需要对全屏纹理进行多次采样至少需要采样中心点和上下左右四个点计算量不小尤其是法线比较涉及向量点乘。适用场景适合需要为场景中几乎所有不透明物体快速添加统一风格描边的情况对美术资源无侵入性。但边缘精度受深度缓冲精度和抗锯齿影响且无法对特定物体进行差异化处理如不同颜色、宽度的描边。2.2 基于物体ID或颜色的渲染方案这个方案分为两步渲染ID图在正式渲染场景之前先增加一个额外的渲染通道。在这个通道里只渲染我们想要描边的物体并且每个物体或每组物体使用一个唯一的纯色或ID值来绘制到一张单独的纹理我们称为ID Map或Stencil Map上。后处理描边在后处理阶段对这张ID图进行边缘检测例如检测颜色不连续的区域。因为ID图是自定义渲染的没有抗锯齿干扰边缘清晰锐利。在HDRP中的实现优势高精度与可控性边缘完全由几何体决定不受深度精度或法线平滑影响线条非常干净。可以轻松实现不同物体不同颜色的描边。完美避抗锯齿问题ID图渲染通常关闭抗锯齿边缘检测结果稳定无闪烁。选择性描边只有被渲染到ID图中的物体才会产生描边性能开销与描边物体数量成正比而非全屏像素。核心挑战需要实现一个自定义渲染通道Custom Render Pass并将其插入到HDRP的渲染流程Render Graph的正确位置。这是HDRP插件开发的核心技能。2.3 基于几何体扩边的顶点着色器方案在内置管线中很常见即在物体的Shader中增加一个Pass在这个Pass里将顶点沿法线方向挤出Outline Width并用纯色渲染。这个方案效果稳定但缺点也很明显HDRP兼容性差HDRP的Lit Shader架构复杂直接复制一个Pass并修改会破坏光照模型和渲染状态需要完全重写一个符合HDRP Master Stack的Unlit Shader。深度冲突Z-fighting扩大的模型可能与原模型或其他物体表面产生深度冲突导致描边闪烁或断裂。无法处理平滑组对于硬边模型效果尚可但对于平滑 shading 的模型顶点法线是平滑插值的会导致描边在曲面区域“塌陷”。加倍Draw Call每个描边物体至少增加一个额外的渲染Pass对批次合并不友好。结论对于追求高质量、高可控性且需要与HDRP深度集成的插件基于物体ID的自定义渲染通道方案是更优选择。它虽然实现复杂度较高但提供了最好的效果、灵活性和性能平衡。我们的插件也将采用这条路线。3. HDRP自定义渲染通道Custom Render Pass深度解析这是整个插件的引擎。HDRP使用可编程渲染管线SRP和渲染图Render Graph来管理渲染流程。我们需要创建一个ScriptableRenderPass的子类。3.1 渲染通道的生命周期与资源管理一个自定义渲染通道的核心方法包括Configure在渲染图执行前调用用于声明本Pass需要读写哪些纹理资源RTHandle。Execute实际的渲染逻辑在这里执行。你可以调用CommandBuffer的指令进行绘制。FrameCleanup可选在帧结束后释放临时资源。关键点RTHandle系统HDRP使用RTHandle来管理渲染纹理它比传统的RenderTexture更智能能自动处理动态分辨率缩放DLSS/FSR和不同摄像机尺寸。创建ID图时必须使用这个系统。// 在类中声明 private RTHandle _idRenderTarget; // 在Configure中创建和声明 void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) { // 创建一个描述符通常使用主摄像机的颜色缓冲区格式如R8G8B8A8_UNorm RenderTextureDescriptor desc cameraTextureDescriptor; desc.colorFormat RenderTextureFormat.ARGB32; // 使用ARGB32存储物体ID颜色 desc.depthBufferBits 0; // ID图通常不需要深度 desc.msaaSamples 1; // 关闭MSAA保证边缘锐利 desc.sRGB false; // 使用线性空间颜色计算更准确 // 分配RTHandle RenderingUtils.ReAllocateIfNeeded(ref _idRenderTarget, desc, FilterMode.Point, TextureWrapMode.Clamp, name: _OutlineIDTexture); // 将_idRenderTarget作为本Pass的颜色输出目标进行配置 ConfigureTarget(_idRenderTarget); // 如果不需要深度可以ConfigureClear为ClearFlag.Color ConfigureClear(ClearFlag.Color, Color.black); // 用黑色ID0清除表示无物体 }注意FilterMode设置为Point非常重要。因为我们存储的是物体ID离散的颜色值使用Bilinear过滤会在物体边缘混合ID值导致后处理阶段边缘检测出错描边变粗或出现杂点。3.2 将通道插入HDRP渲染流程创建了通道还需要把它放到正确的位置。我们需要创建一个CustomPassVolume组件或者更底层地通过实现IRenderPipelineGraphicsSettings接口来全局注入。对于插件使用CustomPassVolume更灵活允许用户在场景中控制何时何地启用描边。但为了更底层的控制和性能我们通常选择创建一个继承自ScriptableRendererFeature的类。这个Feature可以添加到HDRP资产HDRP Asset的Renderer列表中它负责在运行时创建和管理我们的ScriptableRenderPass。[UnityEngine.Rendering.Universal.ExcludeFromPreset] // 避免被包含在预设中 public class OutlineRendererFeature : ScriptableRendererFeature { class OutlineRenderPass : ScriptableRenderPass { ... } // 上面定义的Pass private OutlineRenderPass _scriptablePass; public override void Create() { _scriptablePass new OutlineRenderPass(); // 配置Pass的渲染事件时机。 // 在HDRP中我们通常选择在“After Post-processing”之后这样描边能盖在所有后期效果之上。 // 或者选择在“Before Post-processing”之前让Bloom等特效也能影响描边。这是一个设计选择。 _scriptablePass.renderPassEvent RenderPassEvent.AfterRenderingPostProcessing; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { // 判断是否为主摄像机避免在反射探头等摄像机上也执行 if (renderingData.cameraData.cameraType CameraType.Game) { renderer.EnqueuePass(_scriptablePass); } } }将这个OutlineRendererFeature添加到你的HDRP Asset的Renderer List中它就会在每帧激活。3.3 在ID通道中渲染物体FilteringSettings与DrawingSettings在Execute方法中我们需要渲染所有需要描边的物体到ID图。这里的关键是使用正确的FilteringSettings和DrawingSettings。// 在Execute中 var cmd CommandBufferPool.Get(Outline ID Pass); // 设置渲染目标 CoreUtils.SetRenderTarget(cmd, _idRenderTarget, ClearFlag.Color, Color.black); // 1. 创建FilteringSettings决定渲染哪些物体 FilteringSettings filterSettings new FilteringSettings(RenderQueueRange.opaque) // 通常只渲染不透明物体 { layerMask _outlineLayerMask, // 可以通过Layer来筛选物体 renderingLayerMask (uint)_outlineRenderingLayerMask, // 或者使用更灵活的Rendering Layer Mask excludeMotionVectorObjects false, }; // 2. 创建DrawingSettings决定如何渲染这些物体 // 我们需要一个特殊的Shader来输出物体ID颜色 ShaderTagId shaderTag new ShaderTagId(OutlineID); // 对应Shader中的LightMode Tag DrawingSettings drawSettings new DrawingSettings(shaderTag, new SortingSettings(camera)) { enableDynamicBatching true, enableInstancing true, perObjectData PerObjectData.None, }; // 如果物体没有“OutlineID”这个SubShader可以设置一个Fallback的Shader // drawSettings.fallbackShader ...; // 3. 执行绘制 context.ExecuteCommandBuffer(cmd); cmd.Clear(); context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filterSettings); CommandBufferPool.Release(cmd);这里最大的挑战是如何让物体使用我们指定的“ID输出Shader”来渲染你有两种策略策略A材质替换。为所有需要描边的物体动态创建一个使用ID输出Shader的材质并替换其渲染。这需要维护材质实例对动态物体如角色管理复杂。策略BShader变体。为你的标准HDRP Lit Shader或其他Shader添加一个OutlineID的Pass。在这个Pass里不进行复杂的光照计算只根据物体的唯一ID输出一个固定颜色。然后通过DrawingSettings的shaderTagId来调用这个Pass。这是更优雅、性能更好的方案但需要你拥有或能修改物体的Shader。4. 物体ID的生成、传递与后处理边缘检测4.1 生成与传递唯一ID我们需要为每个描边物体分配一个唯一的标识并最终转换成一个颜色值写入ID图。方法一使用MaterialPropertyBlock传递颜色这是动态性最好的方法。你可以在运行时通过脚本为每个Renderer设置一个MaterialPropertyBlock其中包含一个_OutlineColorID属性。// 在C#脚本中 MaterialPropertyBlock mpb new MaterialPropertyBlock(); renderer.GetPropertyBlock(mpb); // 将物体索引或自定义ID转换成一个Color Color idColor IndexToColor(objectIndex); mpb.SetColor(_OutlineColorID, idColor); renderer.SetPropertyBlock(mpb);在ID输出Shader的Pass中直接读取这个_OutlineColorID并输出。方法二使用物体在世界空间或屏幕空间的唯一特征如果不想动态设置可以在Shader中利用物体固有的、且相邻物体差异足够大的信息来生成ID。例如使用物体世界坐标的哈希值或者渲染器实例ID的哈希值。但这种方法生成的ID可能不够“稳定”同一物体在不同帧颜色可能微变对边缘检测的稳定性要求更高。// 在Shader中示例稳定性较差仅作思路参考 uint hash(uint x) { x (x ^ 61) ^ (x 16); x x (x 3); x x ^ (x 4); x x * 0x27d4eb2d; x x ^ (x 15); return x; } float3 ObjectIndexToColor(uint objectId) { uint h hash(objectId); return float3(float((h 16) 0xFF) / 255.0, float((h 8) 0xFF) / 255.0, float(h 0xFF) / 255.0); }4.2 后处理全屏Shader从ID图到描边现在我们有了_OutlineIDTexture。接下来需要一个全屏后处理Shader来生成描边。这个Shader需要作为一个FullScreen Pass集成到HDRP中。边缘检测算法以Roberts交叉算子为例Roberts算子计算量小适合实时渲染。它在对角线方向检测边缘。// 在片元着色器中 float4 idTex SAMPLE_TEXTURE2D(_OutlineIDTexture, sampler_OutlineIDTexture, uv); // 采样相邻四个像素由于是Point过滤采样就是精确的ID值 float4 idTex_R SAMPLE_TEXTURE2D(_OutlineIDTexture, sampler_OutlineIDTexture, uv float2(_TexelSize.x, 0)); float4 idTex_T SAMPLE_TEXTURE2D(_OutlineIDTexture, sampler_OutlineIDTexture, uv float2(0, _TexelSize.y)); float4 idTex_RT SAMPLE_TEXTURE2D(_OutlineIDTexture, sampler_OutlineIDTexture, uv _TexelSize.xy); // 计算对角线差异 float diag1Diff distance(idTex.rgb, idTex_RT.rgb); float diag2Diff distance(idTex_R.rgb, idTex_T.rgb); float edgeStrength sqrt(diag1Diff * diag1Diff diag2Diff * diag2Diff); // 阈值化 edgeStrength step(_OutlineThreshold, edgeStrength); // 平滑边缘可选使用smoothstep // edgeStrength smoothstep(_OutlineThreshold, _OutlineThreshold _OutlineSoftness, edgeStrength); // 输出描边颜色 float3 outlineColor _OutlineColor.rgb * edgeStrength; // 与原始场景颜色混合这里使用Alpha混合描边在底层 return float4(lerp(sceneColor.rgb, outlineColor.rgb, edgeStrength * _OutlineColor.a), sceneColor.a);与HDRP的FullScreen Pass集成你需要创建一个继承自FullScreenCustomPass的类并指定这个Shader。在Execute方法中它会自动为你处理全屏绘制。你需要将_OutlineIDTexture和相关的参数如阈值、颜色、宽度传递给这个Shader。描边的“宽度”可以通过调整_TexelSize的倍数来控制。例如采样更远的像素uv float2(_TexelSize.x * 2, 0)可以得到更粗的描边但计算量也会增加。更高效的做法是先对ID图进行少量模糊如一个3x3的高斯模糊然后再进行边缘检测这样得到的边缘会更“肥”视觉上宽度增加且更平滑。5. 性能优化实战从理论到帧率提升一个基础的描边插件做出来不难难的是让它高效运行特别是在移动端或复杂场景下。以下是几个关键的优化方向。5.1 渲染目标RT分辨率与格式优化ID图不需要和屏幕分辨率一样大。降低渲染分辨率是提升性能最有效的手段之一。// 在创建RTHandle时可以指定缩放比例 float renderScale 0.5f; // 使用一半分辨率 Vector2 scale new Vector2(renderScale, renderScale); RenderingUtils.ReAllocateIfNeeded(ref _idRenderTarget, desc, scale, FilterMode.Point, TextureWrapMode.Clamp, name: _OutlineIDTexture);一半的分辨率意味着像素数减少到1/4渲染ID通道的填充率和后处理Shader的采样计算量都大幅下降。由于ID图颜色边界分明适当降采样后再经过后处理的边缘检测最终描边效果可能只有轻微锯齿在多数情况下是可以接受的。可以提供一个参数让用户根据质量/性能需求调节。纹理格式优化存储物体ID真的需要ARGB328位每通道吗如果我们用Layer或有限的组来管理可能只需要很少的颜色数。可以尝试使用R8单通道8位格式这能减少一半的带宽占用。在Shader中将float的ID映射到颜色即可。5.2 基于视锥体剔除与距离剔除不是所有标记了“可描边”的物体都需要每帧渲染到ID图。我们应该在渲染ID通道前进行高效的剔除。视锥体剔除Frustum Culling这是自动的。context.DrawRenderers会利用renderingData.cullResults只渲染在摄像机视野内的物体。距离剔除在收集需要渲染的物体列表时计算物体包围球中心到摄像机的距离如果超过设定的_maxOutlineDistance则跳过。这能有效减少远处小物体的渲染开销。遮挡剔除Occlusion Culling对于被完全遮挡的物体渲染其描边也无意义。但HDRP的Custom Pass默认不参与标准的遮挡剔除系统。一个折中方案是在后处理阶段结合深度图如果描边像素对应的深度值表示该位置被前景物体遮挡则淡化或取消该处的描边。但这增加了Shader复杂度。5.3 批处理Batching优化ID通道的渲染应尽可能利用Unity的批处理来减少Draw Call。确保ID输出Shader支持GPU Instancing在Shader中声明#pragma multi_compile_instancing并使用UNITY_INSTANCING_BUFFER_START等宏来传递每个实例的ID颜色。使用MaterialPropertyBlock时的注意事项SetPropertyBlock会打断动态合批。对于大量使用相同材质且需要不同描边色的静态物体考虑为它们生成单独的材质实例虽然会增加内存或者探索使用Graphics.DrawMeshInstanced配合材质属性数组进行手动实例化渲染这在HDRP中需要更底层的CommandBuffer操作。5.4 后处理Shader优化全屏Shader是性能敏感区。减少纹理采样次数上面提到的Roberts算子需要采样4次。也可以使用Sobel算子采样周围8个点但计算更重。根据质量需求选择最简单的算子。利用双线性过滤模拟宽度与其采样多个远距离点不如先对ID图进行一次Bilinear上采样渲染到一个稍大的RT然后再进行边缘检测。这样一次采样就能覆盖更广的区域有时比多次采样更高效。使用半分辨率处理既然ID图可能是半分辨率的后处理也可以在半分辨率下进行。最后将生成的描边Mask上采样后再与全分辨率场景混合。上采样可以使用简单的双线性过滤因为描边Mask是二值或平滑过渡的锯齿不明显。5.5 异步计算与多帧采样对于性能极端敏感的场景可以考虑隔帧渲染ID通道和后处理可以每两帧执行一次。对于移动缓慢的场景描边略有延迟可能不易察觉。这能直接节省出约一帧的GPU时间。分块渲染Tiled Rendering将屏幕分成多个区域Tile只对可能包含边缘的区域通过ID图的Mipmap或粗略检测判断进行全精度的后处理。实现复杂但能极大减少像素着色器调用。6. 高级功能与兼容性处理6.1 描边样式与动画基础的纯色描边满足大部分需求但我们可以扩展更多样式渐变描边在后处理Shader中根据边缘强度edgeStrength插值两种或多种颜色。纹理化描边将描边Mask作为UV采样一个噪声或图案纹理实现手绘、虚线等风格。动态描边Pulsing在Shader中引入时间变量_Time让描边宽度或颜色随时间正弦变化用于提示或高亮交互物体。6.2 与HDRP后期特效的兼容性抗锯齿TAA/MSAA我们的方案避开了抗锯齿对ID图的直接影响但最终描边需要与经过TAA抗锯齿的场景颜色混合。确保描边Pass在TAA之后执行或者处理好混合时的颜色空间线性空间。Bloom如果希望描边也能产生辉光效果需要将描边结果输出到Bloom能够处理的缓冲区通常是HDR颜色缓冲区并确保描边颜色值可能超过1.0。这通常意味着需要将描边Pass插入到Bloom之前的某个节点。动态分辨率DLSS/FSR得益于RTHandle系统只要我们的ID图和后处理Pass正确声明了缩放依赖就能自动适配动态分辨率。但要注意在极端缩放下低分辨率的ID图可能无法为细小物体生成连贯的描边。6.3 处理复杂材质与透明物体复杂材质如皮肤、毛发这些物体的Shader可能有多个渲染通道Pass。在渲染ID图时需要确保只渲染它们的主几何体Pass避免将阴影、深度等额外Pass也画到ID图上导致描边错误。这需要通过正确的ShaderTagId和LightMode来精确控制。透明物体为透明物体添加描边更具挑战性。因为它们的渲染顺序依赖Alpha混合且可能相互重叠。一种方法是在渲染ID图时也使用Alpha混合并为每个透明物体分配一个带Alpha的ID颜色。在后处理中边缘检测也需要考虑Alpha通道。另一种更简单但效果受限的方法是为透明物体单独使用几何扩边法。7. 实战调试与常见问题排查在开发过程中你肯定会遇到各种奇怪的问题。这里记录一些典型的坑和排查思路。7.1 问题描边闪烁或抖动原因AID图渲染使用了错误的过滤模式。确认ID图的FilterMode是否为Point。如果是Bilinear在物体边缘会混合背景的黑色ID导致边缘检测时该像素的ID值处于中间态时而被判为边缘时而不判。原因B抗锯齿干扰。确认ID图渲染时msaaSamples 1。如果场景开启了MSAA但ID图没有要确保摄像机的渲染目标与ID图匹配避免不必要的Resolve操作。原因C物体ID值每帧变化。如果你使用基于位置或实例ID的哈希算法且物体每帧有微小的移动或动画哈希值可能变化导致ID颜色变化边缘检测结果不稳定。应使用稳定的ID如手动分配或基于物体唯一标识符。7.2 问题描边缺失或不全原因ALayer或Rendering Layer Mask设置错误。检查ID通道的FilteringSettings中的layerMask和renderingLayerMask是否包含了你想描边的物体。原因B物体Shader不支持ID渲染Pass。确保物体的材质所使用的Shader包含一个LightMode为OutlineID的Pass并且该Pass能正确输出颜色。可以在Frame Debugger中查看ID通道的渲染结果如果物体是黑色的就是这个问题。原因C后处理Shader阈值过高。调低_OutlineThreshold参数。也可以在后处理Shader中将边缘检测的中间结果如edgeStrength可视化输出看看边缘信号是否真的产生了。7.3 问题性能开销过大排查工具使用Unity ProfilerGPU模块和Frame Debugger。在Frame Debugger中找到你的Outline ID Pass和Outline FullScreen Pass查看它们的耗时。ID Pass的耗时与渲染的物体数量、三角形数量正相关。FullScreen Pass的耗时与屏幕分辨率正相关。优化步骤首先尝试降低ID图分辨率如0.5倍。在Profiler中查看Draw Call数量如果ID通道的Draw Call异常高检查是否因为MaterialPropertyBlock打断了批处理。尝试将使用相同ID颜色或可归类的静态物体合并批次。检查后处理Shader的ALU算术逻辑单元指令数。简化边缘检测算法或减少不必要的计算。7.4 问题描边与场景元素叠加顺序错误现象描边可能被UI挡住或者在半透明物体后面。解决方案这由渲染通道的renderPassEvent决定。RenderPassEvent.AfterRenderingPostProcessing几乎在所有东西之后渲染描边会盖在UI之上。RenderPassEvent.BeforeRenderingPostProcessing则在后期特效之前Bloom等效果不会影响描边。你需要根据项目需求调整这个时机。对于复杂的UI覆盖关系可能需要在UI渲染的特定阶段如AfterRenderingUi插入描边Pass但这需要更精细的HDRP渲染流程控制。开发这样一个插件的过程实际上是一个不断与渲染管线细节搏斗、在效果和性能之间寻找平衡的过程。最终的插件应该提供清晰的参数面板如描边颜色、宽度、阈值、最大距离、分辨率缩放并处理好不同HDRP版本之间的API差异。当你看到自己实现的干净、稳定、高效的描边效果完美地集成在HDRP项目中时那种对引擎底层机制豁然开朗的成就感远非使用一个现成Asset所能比拟。这不仅仅是实现了一个功能更是获得了一把打开HDRP高级定制化大门的钥匙。