
做Unity项目做到中后期几乎绕不开一个需求让玩家能清楚地看到自己选中了哪个物体。不管是RTS里的单位框选、建造类游戏里的建筑放置预览还是工业仿真里的设备点选物体高亮选中效果都是交互反馈里最基础也最容易被低估的一环。早期项目里我用过改材质自发光、加描边Shader、甚至直接换一套高亮材质球这些土办法简单场景下能跑一旦场景里材质种类多起来、后处理堆上去各种穿帮和性能问题就全冒出来了。后来把渲染管线切到HDRP开始认真用Custom Pass来做高亮才算把这件事做成了一个可复用、可控、性能也说得过去的方案。这篇内容就围绕Unity HDRP下用Custom Pass实现物体高亮选中效果展开从渲染管线的执行时机讲起把原理、方案选型、完整实操步骤、参数计算和踩坑经验都摊开说。适合已经上手HDRP、想做出稳定高亮效果的中级开发者也适合刚接触Custom Pass、想找一个完整落地案例的朋友。读完之后你应该能独立搭出一套支持多物体选中、描边粗细可控、能跟后处理共存的高亮系统并且知道哪些地方容易翻车。1. 先想清楚为什么高亮这件事在HDRP里不能随便糊弄1.1 高亮效果的本质是“渲染时序”问题很多人第一次做高亮直觉反应是给物体换个亮一点的材质或者挂个自发光。这个思路在内置管线里勉强能用但在HDRP里很快就会撞墙。原因在于HDRP的渲染流程是分阶段、多Pass的不透明物体、透明物体、后处理、光照计算各走各的队列你如果只是在材质层面动手脚很难保证高亮效果稳定地出现在最终画面上更别说跟Bloom、色调映射这些后处理和平共处了。高亮选中效果的本质其实是在正确的渲染时机往正确的颜色缓冲里写入正确的信息。拆开来看就三件事第一你得知道当前哪些物体被选中了第二你得在物体正常渲染之外额外画一层“高亮标记”第三这层标记要能跟场景里其他东西正确混合不能把后处理搞乱。Custom Pass之所以适合干这个就是因为它允许你在HDRP渲染流程的指定注入点插入自己的渲染逻辑你能精确控制“什么时候画、画到哪、怎么混合”。我见过不少项目用OnRenderImage或者自己挂Camera回调来做高亮在内置管线时代这是常规操作但HDRP下这套基本失效因为HDRP不走那套回调。Custom Pass是HDRP官方给出的扩展入口用它做高亮属于“顺着管线设计走”稳定性和兼容性都比野路子强得多。1.2 常见土办法的翻车现场在正式讲Custom Pass方案之前先把几种常见土办法的问题摆出来这样你更能理解为什么值得花时间搭一套正规方案。改材质自发光是最常见的做法。问题在于自发光会参与光照和Bloom计算物体亮起来的同时会把周围环境也照亮晚上场景里选个物体跟点了盏灯似的视觉上非常假。而且自发光强度调高了会过曝调低了又看不清很难找到一个通用参数。换高亮材质球的问题更直接你得为每种材质准备一套高亮版本材质种类一多维护成本爆炸而且换材质会打断SRP Batcher的合批性能直接掉一截。场景里几百个物体同时选中帧率能给你表演一个自由落体。屏幕空间描边基于深度或法线的后处理描边看起来高级但它描的是整个屏幕的轮廓你没法精确控制只描选中的物体除非再额外做一张遮罩图流程反而更复杂。而且屏幕空间描边在物体互相遮挡时容易出现描边断裂或者描到背景上的问题。这些办法的共同毛病是它们都在“物体渲染”这个层面打转没有跳出来从“渲染管线扩展”的角度想问题。Custom Pass的价值就在于它给了你一个管线级别的钩子让你能在物体正常渲染之外干净地叠加一层高亮信息。1.3 Custom Pass在HDRP渲染流程中的位置要理解Custom Pass得先对HDRP的渲染流程有个大致概念。HDRP一帧的渲染大致经过不透明物体渲染、透明物体渲染、后处理、最终合成这几个大阶段。Custom Pass允许你选择注入点Injection Point常见的有Before Rendering、After Opaque、Before Transparent、After Transparent、After Post Process等。做物体高亮最常用的注入点是After Opaque或者Before Transparent。选After Opaque的理由是此时不透明物体已经画完深度缓冲可用你可以在深度测试的基础上画高亮层被遮挡的部分自然被剔除同时这个时机在后处理之前高亮层能正常参与Bloom等后处理视觉上更融合。选Before Transparent则适合你想让高亮层被透明物体正确遮挡的场景。注入点的选择直接决定了高亮效果跟场景的融合程度这是整个方案里第一个需要认真想清楚的设计决策后面实操部分我会结合具体参数再展开。2. 方案设计与核心思路拆解2.1 整体架构遮罩渲染加合成两段式我最终采用的方案是两段式第一段用Custom Pass把选中物体的“遮罩”渲染到一张临时RT上第二段再基于这张遮罩做描边或者填充高亮最后合成回主画面。这个思路借鉴了屏幕空间效果的一般套路但关键区别在于遮罩的生成是由Custom Pass在管线内部完成的而不是靠额外的相机或者手动CommandBuffer。具体来说第一段Custom Pass在After Opaque注入点执行用一个只写特定颜色比如纯白的材质把选中物体的轮廓渲染到一张与屏幕同分辨率的RT上同时做深度测试保证被遮挡的部分不写入。第二段可以是一个全屏的Blit Pass读取这张遮罩RT做边缘检测生成描边或者直接做半透明填充然后叠加到相机颜色缓冲上。这个架构的好处是职责清晰遮罩生成只关心“哪些像素属于选中物体”合成阶段只关心“怎么把高亮画好看”。两段解耦之后你想换描边算法、想加填充、想改颜色都只动第二段第一段完全不用碰。而且遮罩RT可以复用多个选中物体一次性渲染进去天然支持多选。2.2 为什么不用DrawRenderer而用Custom PassUnity里其实有DrawRenderer这个API可以在CommandBuffer里手动画指定Renderer理论上也能实现类似效果。但我最终选Custom Pass主要基于几点考虑。第一是生命周期管理。Custom Pass由HDRP统一调度注入点、RT分配、释放这些事管线帮你管了你只需要关心Pass内部的逻辑。DrawRenderer需要你自己管理CommandBuffer的创建、执行时机、RT的申请释放稍不注意就是内存泄漏或者渲染顺序错乱。第二是跟HDRP特性的兼容性。Custom Pass天然支持HDRP的相机设置、分辨率变化、VR多眼渲染这些场景DrawRenderer在这些边缘情况下需要额外处理。项目一旦要支持多平台或者动态分辨率Custom Pass省的心不是一点半点。第三是可维护性。Custom Pass可以做成ScriptableObject资产在项目里配置、复用、版本管理都方便团队里其他人接手也容易看懂。DrawRenderer的逻辑往往散落在代码里时间一长就没人记得当初为什么这么写了。当然Custom Pass也不是没有代价它对HDRP版本有要求老版本HDRP可能没有完整的Custom Pass支持这点在项目初期选型时就要确认清楚。2.3 遮罩RT的格式与精度选择遮罩RT的格式选择是个容易被忽略但影响很大的细节。我一开始图省事用了默认的ARGB32结果发现描边边缘有锯齿尤其是物体轮廓是斜线的时候特别明显。后来换成R8格式单通道8位配合适当的采样和边缘检测边缘质量明显改善而且内存占用还降了。这里的关键认知是遮罩RT只需要存“是不是选中物体”这一个信息单通道就够了用RGBA是浪费。R8格式每个像素1字节1080p下大约2MB完全可以接受。如果你的高亮方案需要区分不同选中状态比如主选中和次选中用不同颜色可以升级到R8G8或者ARGB32但大多数场景R8足够。精度方面8位对于遮罩来说够用因为遮罩本质是二值的选中或不选中8位有256级灰度做抗锯齿边缘检测绰绰有余。如果你要做非常精细的软边缘可以考虑R16但实测下来收益不明显反而增加带宽开销不推荐。2.4 描边算法选型Sobel还是形态学第二段合成阶段的核心是描边算法。常见的有Sobel算子边缘检测和形态学膨胀两种思路。Sobel算子的原理是对遮罩图做卷积计算梯度幅值梯度大的地方就是边缘。优点是实现简单、边缘定位准缺点是抗噪能力一般遮罩如果有零星噪点会被放大成假边缘。形态学膨胀则是把遮罩往外扩几个像素再跟原图做差得到一圈边缘优点是边缘粗细均匀可控缺点是对遮罩质量要求高遮罩有缺口时膨胀结果会跟着缺口走。我最终选了Sobel原因是遮罩是我们自己渲染的质量可控噪点问题可以通过在遮罩渲染阶段做适当的抗锯齿来解决。而且Sobel的边缘检测对描边粗细的控制更直观——调整采样步长就能改粗细形态学膨胀要改结构元素大小调起来没那么顺手。不过这里有个细节标准Sobel是3x3卷积核采样范围固定想要更粗的描边需要手动扩大采样步长这时候要配合双线性采样避免出现块状伪影。这个参数后面实操部分会给具体数值。3. 完整实操从零搭一套高亮系统3.1 环境准备与版本确认动手之前先把环境确认清楚这一步别省。我用的组合是Unity 2021.3 LTS配HDRP 12.x这个组合下Custom Pass的API比较稳定。如果你用的是更新的Unity版本HDRP的Custom Pass API可能有细微变化尤其是CustomPassVolume的API和注入点枚举建议先查一下对应版本的文档。项目设置里要确认几件事HDRP Asset已经正确配置Frame Settings里Custom Pass相关的选项是开启的默认是开的但有些项目模板会关掉相机的Frame Settings里也要允许Custom Pass执行。这几项任何一项没开你的Custom Pass都不会跑而且不会有明显报错排查起来很费时间。提示新建一个空场景先验证Custom Pass能不能正常执行再往复杂场景里搬。我吃过这个亏在一个材质和光照都很复杂的场景里调Custom Pass结果半天没意识到是Frame Settings的问题。3.2 创建遮罩渲染用的Custom Pass第一步是写遮罩渲染的Custom Pass。核心逻辑是继承CustomPass重写Execute方法在里面遍历选中物体列表用指定的材质把它们画到目标RT上。using UnityEngine; using UnityEngine.Rendering.HighDefinition; public class HighlightMaskPass : CustomPass { public LayerMask selectionLayer; public Material maskMaterial; public string maskBufferName _HighlightMask; protected override void Execute(CustomPassContext ctx) { if (maskMaterial null) return; // 申请或复用遮罩RT var maskRT ctx.renderGraphBuilder.CreateTexture( ctx.cameraColorBuffer.descriptor, maskBufferName); // 清空为黑色 ctx.cmd.SetRenderTarget(maskRT); ctx.cmd.ClearRenderTarget(true, true, Color.black); // 遍历选中层物体绘制 foreach (var renderer in SelectionManager.SelectedRenderers) { if (renderer null) continue; ctx.cmd.DrawRenderer(renderer, maskMaterial, 0, 0); } // 把RT暴露给后续Pass ctx.cmd.SetGlobalTexture(maskBufferName, maskRT); } }这段代码有几个关键点要说明。第一RT的创建用的是renderGraphBuilder这是HDRP较新版本推荐的RT管理方式能自动处理生命周期比手动new RenderTexture靠谱。第二清屏用黑色而不是透明色因为后面Sobel检测的是亮度梯度黑色背景配白色遮罩对比度最高。第三DrawRenderer的第三个参数是submesh index第四个是pass index如果你的模型有多个submesh需要遍历所有submesh分别绘制否则会漏掉部分轮廓。maskMaterial是一个极简的Unlit材质颜色固定为白色关闭所有光照和阴影计算。这个材质不需要任何贴图越简单越好因为它的唯一任务就是往遮罩上写白色。3.3 描边合成Pass的实现第二段Pass负责读遮罩、做Sobel、合成到画面。这段逻辑可以放在同一个Custom Pass的后续阶段也可以单独写一个全屏Pass。我倾向于单独写职责更清晰。protected override void Execute(CustomPassContext ctx) { var mask ctx.cmd.GetGlobalTexture(maskBufferName); if (mask null) return; // 设置描边参数 outlineMaterial.SetTexture(_MaskTex, mask); outlineMaterial.SetFloat(_OutlineWidth, outlineWidth); outlineMaterial.SetColor(_OutlineColor, outlineColor); // 全屏Blit HDUtils.BlitCameraTexture(ctx.cmd, ctx.cameraColorBuffer, ctx.cameraColorBuffer, outlineMaterial); }描边Shader的核心是Sobel卷积。这里给一个简化版的思路对当前像素周围3x3区域的遮罩亮度做加权求和水平和垂直方向分别算梯度取模长作为边缘强度超过阈值就输出描边颜色。float sobel(float2 uv, float2 texelSize, float width) { float2 offset texelSize * width; float gx 0; float gy 0; // 水平梯度 gx SampleMask(uv float2(-offset.x, -offset.y)) * -1; gx SampleMask(uv float2(-offset.x, 0)) * -2; gx SampleMask(uv float2(-offset.x, offset.y)) * -1; gx SampleMask(uv float2(offset.x, -offset.y)) * 1; gx SampleMask(uv float2(offset.x, 0)) * 2; gx SampleMask(uv float2(offset.x, offset.y)) * 1; // 垂直梯度同理 // ... return sqrt(gx * gx gy * gy); }_OutlineWidth这个参数控制采样步长值越大描边越粗。实测下来1.0到2.0之间比较合适超过3.0边缘会开始出现明显的块状伪影因为采样点间距太大双线性插值也救不回来。如果确实需要很粗的描边建议改用多次迭代的小步长采样而不是一次大范围采样。3.4 选中管理与多物体支持高亮系统要真正好用选中管理这块得设计好。我的做法是维护一个SelectedRenderers列表选中状态变化时更新这个列表Custom Pass每帧遍历它。这里有几个细节值得说。第一列表里存Renderer而不是GameObject因为DrawRenderer需要的是Renderer存GameObject每次还要GetComponent没必要。第二物体被销毁时要及时从列表里移除否则DrawRenderer会报空引用。我是在OnDestroy里做移除配合一个弱引用或者定期清理防止漏网。第三多选时所有选中物体的遮罩一次性渲染到同一张RT上天然支持多物体高亮不需要为每个物体单独开RT。选中逻辑本身可以很简单射线检测或者框选都行这部分不属于高亮系统的核心按项目需求实现即可。关键是选中状态变化时要同步更新SelectedRenderers列表并且触发一次遮罩重绘其实每帧都在重绘所以只要列表对就行。3.5 参数调优描边宽度、颜色与透明度参数调优这块没有标准答案得看项目美术风格。但我可以给一组实测下来比较通用的起点值。描边宽度方面1080p分辨率下_OutlineWidth设1.5左右视觉上比较舒服4K下要相应放大到2.5到3.0因为像素密度高了同样的采样步长对应的物理宽度变小了。这里有个换算关系描边的物理宽度大致等于_OutlineWidth乘以屏幕高度的倒数再乘以一个系数所以分辨率翻倍参数也要翻倍才能保持视觉一致。描边颜色建议用高饱和度的亮色比如亮青或者亮黄跟场景主色调拉开对比。透明度不要设成完全不透明0.7到0.85之间比较合适太实了会显得生硬太淡了又看不清。如果场景本身很亮描边颜色要往深色方向调反之亦然这个得根据实际场景试。注意描边颜色如果参与HDR的Bloom计算强度过高会产生光晕视觉上反而糊。建议描边颜色的亮度控制在1.0到2.0之间不要飙太高。4. 踩坑实录与问题排查4.1 遮罩RT分辨率与相机不匹配这个问题我遇到过一次现象是高亮描边位置整体偏移物体在画面左边描边却出现在右边。排查了半天发现是遮罩RT的分辨率跟相机实际渲染分辨率不一致原因是项目开了动态分辨率相机渲染分辨率每帧在变但我的RT是按固定分辨率创建的。解决办法是用ctx.cameraColorBuffer.descriptor来创建RT这样RT的分辨率会自动跟随相机颜色缓冲动态分辨率下也不会错位。这个细节在文档里不太显眼但实际项目里只要开了动态分辨率就一定会碰到。4.2 透明物体遮挡关系错误默认在After Opaque注入点渲染遮罩透明物体还没画所以透明物体不会遮挡高亮。如果你的场景里有玻璃、水面这类透明物体选中物体在透明物体后面时高亮会“穿透”透明物体显示出来视觉上很怪。解决办法有两个一是把注入点改到Before Transparent让遮罩在透明物体之前渲染这样透明物体能正常遮挡高亮二是保持After Opaque但在合成阶段额外做一次深度比较用相机深度图剔除被透明物体遮挡的像素。前者简单后者更精确但开销大。我一般选前者除非项目对透明遮挡有极高要求。4.3 后处理导致描边颜色失真HDRP的后处理链里色调映射和颜色分级会改变最终画面颜色包括你的描边。如果描边是在后处理之前合成的颜色会被后处理“洗”一遍可能变得跟预期不一样。这个问题没有完美解法只能接受或者调整。我的做法是把描边颜色的预期值往后处理的反方向调比如预期最终是亮青色就在合成时用更饱和的青色让后处理洗完之后刚好落在预期值附近。这需要试几轮但一旦调好就稳定了。另一个思路是把高亮合成放到After Post Process注入点这样描边不参与后处理颜色完全可控代价是描边不参与Bloom融合感稍差。两种方案各有取舍看项目更看重哪头。4.4 性能问题DrawRenderer的合批断裂DrawRenderer是逐个物体绘制的不走SRP Batcher选中物体多的时候draw call会线性增长。几十个物体选中问题不大几百个同时选中就要注意了。优化思路有几个一是遮罩材质尽量简单减少状态切换二是如果选中物体数量极大可以考虑用Graphics.DrawMeshInstanced批量绘制但需要自己管理矩阵复杂度上升三是限制同时高亮的物体数量比如只高亮屏幕内可见的、或者只高亮最近的N个。实测下来同屏高亮物体控制在100个以内性能基本无感超过这个数就要做取舍了。4.5 常见问题速查表现象可能原因排查方向高亮完全不显示Custom Pass未执行检查Frame Settings和相机设置描边位置偏移RT分辨率与相机不匹配用cameraColorBuffer的descriptor创建RT描边穿透透明物体注入点选择不当改到Before Transparent或加深度比较描边颜色偏淡后处理色调映射影响调整合成颜色或改注入点边缘有锯齿遮罩RT精度不足换R8格式并加抗锯齿多选时部分物体无高亮submesh未遍历遍历所有submesh绘制帧率下降明显DrawRenderer数量过多限制高亮数量或改实例化绘制这张表是我自己踩坑过程中攒下来的实际排查时按现象对号入座能省不少时间。尤其是前两条新手最容易卡住因为不报错只能靠观察。5. 进阶扩展让高亮系统更贴合项目需求5.1 支持不同选中状态的多色高亮很多项目需要区分“主选中”和“次选中”比如RTS里当前操作的队伍和待命队伍用不同颜色。实现思路是在遮罩阶段用不同通道存不同状态主选中写红色通道次选中写绿色通道合成阶段分别检测两个通道的梯度输出不同颜色。这个扩展的关键是遮罩RT要升级到至少R8G8格式并且maskMaterial要能根据物体状态输出不同通道值。实现上可以给每个物体挂一个状态组件绘制前根据状态切换材质的输出通道。复杂度比单色高一些但架构上完全兼容不需要推翻重来。5.2 高亮与描边分离的视觉层次有些项目希望选中物体既有描边又有半透明填充形成更强的视觉提示。这个可以在合成阶段做Sobel检测到边缘画描边遮罩内部区域做半透明填充。填充颜色和描边颜色可以不同形成层次感。实现上就是在合成Shader里多一个分支根据Sobel结果和遮罩原始值决定输出。注意填充的透明度要控制好太高会盖住物体本身的细节太低又没效果一般0.15到0.3之间比较合适。5.3 性能优化按需重绘与RT复用前面提到每帧都在重绘遮罩其实如果选中状态没变、相机没动遮罩是可以复用的。优化思路是记录上一帧的选中列表和相机矩阵如果都没变就跳过遮罩重绘直接复用上一帧的RT。这个优化在静态场景里效果明显能省掉DrawRenderer的开销。实现上要注意RT的持久化不能每帧创建销毁得用一个常驻的RT在需要重绘时才清空重画。这个优化有一定复杂度建议先把基础版本跑通再考虑不要一上来就做过度优化。5.4 与UI系统的联动高亮系统最终是要服务于交互的跟UI的联动不能忽视。比如选中物体后UI上要显示物体信息面板这时候高亮状态和UI状态要保持同步。我的做法是把选中状态收敛到一个统一的SelectionManager里UI和高亮系统都订阅这个Manager的事件避免两边状态不一致。这个设计看起来跟高亮效果本身没关系但实际项目里状态不同步导致的高亮残留、UI显示错误是很常见的bug来源。把状态管理这块做扎实后面省的心比优化渲染还多。6. 一些实操心得整套方案跑下来我最深的体会是高亮效果好不好七分靠遮罩质量三分靠描边算法。很多人一上来就研究各种高级描边算法结果遮罩本身有锯齿、有缺口再好的算法也救不回来。所以如果你的高亮效果不理想先回头检查遮罩RT的格式、分辨率和抗锯齿这几项调好了Sobel这种基础算法出来的效果就已经很能打了。另一个心得是关于注入点的选择不要迷信某个“最佳实践”得根据项目实际场景试。透明物体多的项目往Before Transparent靠后处理重的项目考虑After Post Process没有放之四海皆准的答案。我一般会花半天时间把几个注入点都试一遍截图对比选视觉上最协调的那个这个时间花得值。最后说个容易被忽略的点高亮系统的调试可视化。开发阶段建议加一个调试模式把遮罩RT直接显示到屏幕上这样遮罩有没有问题一眼就能看出来比对着最终画面猜要高效得多。这个调试开关上线前记得关掉或者做成仅编辑器可见。这套方案我在几个项目里复用下来稳定性没问题扩展性也够。如果你的项目也在HDRP下做高亮希望这些内容能帮你少走点弯路。