做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 Pass
Unity里其实有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下做高亮,希望这些内容能帮你少走点弯路。