☰
UE5模型点击选中高亮:Custom Depth与Stencil后处理描边实现指南
2026/9/28 15:43:51 网站建设 项目流程

简介:面向UE4/UE5开发者,提供模型鼠标点击选中后边缘高亮的完整UE5工程。工程基于第一人称模板,内含高亮材质、射线碰撞检测蓝图以及选中状态管理逻辑。包内共485个文件,以401个uasset资源、10个umap关卡文件为主,配合ini配置与log日志等,压缩包大小约673.69MB,可视为可直接打开运行的完整项目。已有4749人学习/下载,适合希望快速实现交互高亮效果并理解其实现思路的中级开发者。通过该工程,可学习并复用材质发光参数控制、射线检测、Actor接口调用等关键技巧,也可在原基础上扩展动画效果或多选模式。

1. 先从点击选中的刚需讲起:为什么这套描边方案在 UE5 工程里最好改

做 UE5 工程时,被问得最多的一类需求就是“模型鼠标点击选中边缘高亮”。数字孪生、展厅交互、关卡编辑器工具,甚至简单的产品选型演示,都逃不开这一步:鼠标点到模型,模型轮廓亮一圈,再点空白处取消。这背后的实现路径其实非常收敛——Custom Depth / Stencil 通道加后处理描边材质,是 UE5 里最稳、最通用的做法,UE4 从 4.26 之后也基本通用。这个方案的好处是不需要给每个模型单独改材质,不破坏原有贴图和光照,高亮样式统一由后期材质控制,换颜色、调宽度都在一个资产里完成。本文适合两类读者:一类是刚接触 UE5 交互开发,想给现有场景快速加选中高亮;另一类是已经把射线点击跑通,但描边效果总是不对、想找参数和坑位的人。整套东西拆开看只有三块:鼠标点击命中、Stencil 标记、后处理描边,下面按落地顺序逐个讲。

2. 点击选中这块地基:射线命中与组件封装

2.1 鼠标位置转 World 射线:两种写法与坐标换算

UE5 里把鼠标点击变成一条世界射线,最常用的入口是 PlayerController 的DeprojectMousePositionToWorld。它做的事就是把屏幕坐标反投影到世界空间,得到一条从相机出发的射线,然后交给碰撞检测。下面这段是我在工程里常用的写法:

// 在 APlayerController 子类或自定义交互组件中 bool UInteractionComponent::TraceFromMouse(FHitResult& OutHit) { APlayerController* PC = GetPlayerController(); if (!PC) return false; FVector WorldOrigin; FVector WorldDirection; if (!PC->DeprojectMousePositionToWorld(WorldOrigin, WorldDirection)) return false; FCollisionQueryParams Params; Params.bTraceComplex = false; // 用简化碰撞体,便宜且够用 Params.AddIgnoredActor(GetOwner()); // 忽略自己,避免射线被自身挡住 return GetWorld()->LineTraceSingleByChannel( OutHit, WorldOrigin, WorldOrigin + WorldDirection * HitDistance, ECC_Visibility, Params ); }

这段代码的逻辑是:先从鼠标位置生成射线起点和方向,再用LineTraceSingleByChannel做单次命中检测。通道用ECC_Visibility是经验之谈,因为大多数场景里可见的静态网格和动态 Actor 默认都在 Visibility 通道有碰撞,射线不容易穿模;如果项目里自己建了交互通道,也可以把这里的通道换成项目自定义的 Trace 通道,设置方法后面避坑章节会说到。

要注意的是DeprojectMousePositionToWorld依赖“Enable Input”已经生效。在纯 C++ 工程里,如果 PlayerController 没有启用输入,鼠标位置拿不到;在蓝图里就是Get Hit Result Under Cursor节点,本质也是走这条路径。另一个更容易忽略的点是 UI 遮挡:当鼠标停在 Slate 或 UMG 控件上时,射线仍然会穿到模型,所以需要在调用前先判断当前鼠标是否在 UI 上,常见做法是FSlateApplication::Get().GetMouseCaptor或者直接检测IsMouseOverAnyWidget,这一点在坑位章节里专门展开。

2.2 从命中结果里拿到模型与网格体:Component 才是操作对象

很多新手拿到FHitResult后第一个反应是拿GetActor(),但真正需要做 Stencil 标记的是网格组件,也就是UPrimitiveComponent及其子类。静态网格、骨骼网格、程序化网格,甚至 Landscap 的每个 Section,在底层都是UPrimitiveComponent的实例。所以在封装里我会同时保存命中的 Actor 和 Component,描边操作只认 Component:

void UInteractionComponent::HandleHit(const FHitResult& Hit) { UPrimitiveComponent* Comp = Hit.Component.Get(); if (!Comp) return; // 如果是同一个组件,视为重复点击,走取消逻辑 if (Comp == CurrentSelectedComponent) { ClearSelection(); return; } // 先把上一次选中的清掉,再标记新的 if (CurrentSelectedComponent) { CurrentSelectedComponent->SetRenderCustomDepth(false); CurrentSelectedComponent->SetCustomDepthStencilValue(0); } CurrentSelectedComponent = Comp; Comp->SetRenderCustomDepth(true); Comp->SetCustomDepthStencilValue(StencilMaskValue); }

这里把“选中状态”完全挂在UPrimitiveComponent上,好处是无论场景里挂的是 StaticMesh、SkeletalMesh 还是 InstancedMesh,接口都一致,不用为每种模型单独写分支。需要额外处理的只有两种情况:一是UInstancedStaticMeshComponent,它的每个实例有不同的 Transform,但 Stencil 是组件级的,想做单实例高亮需要额外用 Custom Primitive Data 或拆组件;二是带透明材质的模型,半透明物体在 Stencil 通道里的表现比较特殊,第 5 章会说。

2.3 把选中逻辑封装成一个组件:单例与多选模式都要考虑

实际工程里我不建议直接在 Character 或 Pawn 里写这些逻辑,而是封装成一个 ActorComponent 或一个独立的交互管理器。这样关卡里的任何 Pawn 都可以复用,换项目时拖着组件走就行。上面两段代码已经基本构成一个最小组件,但还需要考虑两种交互模式:单选模式和多选模式。单选模式下,每次点击先清空上次选中;多选模式下,把新命中的组件加入一个数组,Stencil 值按分组叠加。下面是一个简化的多选实现:

void UInteractionComponent::ToggleSelect(UPrimitiveComponent* Comp) { if (SelectedComponents.Contains(Comp)) { // 取消选中:减去这一组 Stencil 位,而不是直接清零 int32 CurrentStencil = Comp->GetCustomDepthStencilValue(); Comp->SetCustomDepthStencilValue(CurrentStencil & ~StencilMaskValue); if (CurrentStencil == StencilMaskValue) Comp->SetRenderCustomDepth(false); SelectedComponents.Remove(Comp); return; } // 新选中:把当前组件已有的 Stencil 值叠加新标记 Comp->SetRenderCustomDepth(true); int32 NewStencil = Comp->GetCustomDepthStencilValue() | StencilMaskValue; Comp->SetCustomDepthStencilValue(NewStencil); SelectedComponents.Add(Comp); }

代码里的~StencilMaskValue是位运算的按位取反,用意是只清除当前选中组对应的 Stencil 位,保留其他系统可能写入的标记。直接赋 0 很危险,因为场景里可能有别的系统已经在用 Stencil 做效果,整体清零会造成未知冲突。这个习惯从 UE4 时代一直延续下来,在 UE5 工程里更要注意,因为 Lumen、Nanite 这些新特性对渲染通道的管理更敏感。

蓝图侧的使用方式也很直接:把组件挂到 PlayerController 上,在Left Mouse Button事件里调用一个自定义事件,内部走Get Hit Result Under Cursor,然后把 Hit 里的 Component 传给组件。不想写 C++ 的团队可以把上面逻辑完全用蓝图节点复刻一遍,唯一要小心的就是蓝图里对UPrimitiveComponent的 Cast,Mesh 组件类型不对时容易 Cast 失败。

3. 让模型真正亮起来:Custom Depth / Stencil 标记与描边材质

3.1 为什么不用改模型材质,也能画一圈边:两种描边路线对比

UE5 里做选中高亮的路线大致分两种。第一种是给每个模型单独做一套“选中材质”,在材质里用 Fresnel 节点做边缘光,或者用顶点膨胀法线做轮廓线。这种方案画面效果直接,但维护成本很高:场景里几十上百个模型,每个都要预留一套材质开关,换贴图、换 PBR 参数时都要同步改,而且 Fresnel 做出来的“边缘”在相机角度变化时粗细不一致。第二种是本文采用的方案——模型本身材质不变,只在渲染管线里多写一个 Stencil 标记,由后处理材质统一把标记区域边缘画出来。两种方案对比如下:

对比项模型材质内做边缘高亮CustomDepthStencil + 后处理描边
是否改造原材质必须额外加材质层完全不用动
边缘粗细一致性随视角和模型曲率变化由后处理参数固定控制
支持多模型分组需要每个模型区分做法用 Stencil 值天然分组
性能代价选中物体多渲染一遍多一个后处理 Pass,模型额外跑 CustomDepth
工程侵入高,美术资产耦合重低,纯逻辑控制

实际项目里两者不冲突:有些团队用模型材质内边缘光做“悬停预览”,用后处理描边做“选中确认”,两层效果叠加。但如果只做一个方案,优先做后处理描边,理由很简单:它是全局的,不是针对单个资产的,改需求时只改一个后处理材质。

3.2 先把 Custom Depth-Stencil Pass 打开,再给模型写标记值

后处理描边的前提是场景里有一层“额外深度模板信息”能被后处理材质读取。UE5 里这对应一个项目设置项:Project Settings → Rendering → Post Processing → Custom Depth-Stencil Pass,选项要设为Enabled with Stencil。只有深度却没有模板值时做不了按模型分组,所以必须选带 Stencil 的版本。这一步漏掉的话,后处理材质里读到的 CustomStencil 永远是 0,后面一切节点连接都白搭。

然后是给模型写标记。UE5 里每个UPrimitiveComponent都有两个相关属性:Render CustomDepth Pass表示这个模型要不要额外渲染进 CustomDepth 缓冲区,Custom Depth Stencil Value表示写入的具体模板值。两者都要设置,缺一不可。在蓝图上对应Set Render Custom Depth和Set Custom Depth Stencil Value两个节点,C++ 侧就是第 2 章代码里的SetRenderCustomDepth与SetCustomDepthStencilValue。

这里有个细节值得单独说:Stencil 值取值范围是 0~255,但不要随手填 255。因为后处理描边时常用位运算做分组,255 占满了所有二进制位,后续叠加状态就不好做。我一般预留 250 以上给纯选中效果,项目里如果有 AI 标记、区域提示等其他系统,给他们单独分配 1~240 的位段。多组状态叠加时用|=按位写入,取消时用&~清除,这样同一模型同时被选中和受 AI 标记时,两种描边可以同时存在,互不覆盖。

3.3 后处理描边材质:核心节点怎么连

后处理材质是整个方案的灵魂。它的逻辑一句话概括:读取每个像素的 CustomStencil 值,如果当前像素没有标记,但周围像素有标记,就判定为边缘,用高亮颜色覆盖原画面。判断“周围像素”需要偏移采样,这里用材质编辑器原生节点就能实现,不需要写 HLSL,节点连线如下:

SceneTexture: CustomStencil → A(当前像素模板值) SceneTexture: CustomStencil 偏移(+1,0) → B1 SceneTexture: CustomStencil 偏移(-1,0) → B2 SceneTexture: CustomStencil 偏移(0,+1) → B3 SceneTexture: CustomStencil 偏移(0,-1) → B4 SceneTexture: CustomStencil 偏移(+1,+1) → B5 SceneTexture: CustomStencil 偏移(-1,-1) → B6 SceneTexture: CustomStencil 偏移(+1,-1) → B7 SceneTexture: CustomStencil 偏移(-1,+1) → B8 Max(Max(B1..B8), A) → 周围最大模板值 (周围最大模板值 - A) → 边缘标记信号 Step(0.01, 边缘标记信号) → 边缘 Mask Lerp(PostProcessInput0, 高亮颜色, 边缘 Mask) → 最终输出

偏移量不是直接写死坐标,而是用 SceneTexture 节点自带的 TexelSize 输出,把偏移坐标和 TexelSize 相乘。TexelSize表示当前纹理一个像素占的 UV 大小,这样无论最终渲染分辨率怎么变,偏移量都对应当前屏幕像素,不会出现 4K 和 1080P 下描边粗细不一致的问题。

上面连线里SceneTexture: CustomStencil读到的值是 0 或 Stencil 标记值(比如 250),是一个很大的整数。为什么要用 Max 减当前值而不是直接用 If 判断相等?因为 250 在 Step 函数里会被归一化成接近 1 的数,而 0 保持 0,用 Max 减法能在不做等于判断的情况下捕获“周围有标记但当前没有”的边界像素,语义更清晰,节点数也更少。实际调时如果把 250 改成 2,阈值 Step 也要跟着调,这就是为什么推荐 Stencil 值用高位的大数。

材质本身的 Blendable Location 放在After Tonemapping还是Before Tonemapping会影响颜色表现。我一般放在After Tonemapping,因为这里的 PostProcessInput0 已经是 tonemapping 后的画面,高亮颜色是“所见即所得”,不会因为色调映射产生色偏;代价是高亮色不会参与 Bloom,想要发光感的话需要额外加 Bloom。如果放在 Before Tonemapping,高亮颜色会被映射一次,颜色会偏淡偏灰,调试时不好预估。选哪一种没有绝对对错,但同一个工程里最好固定一种,并把它写进项目规范。

4. 让描边从“能亮”到“好看”:四个必调参数与分组策略

4.1 后处理描边参数表:宽度、颜色、阈值、 Mask 的配合

描边材质做出来后,真正决定效果的是几个参数的配合。我把常用参数整理成一张表,在工程里作为默认配置,新项目直接复制:

参数推荐值作用调节注意
边缘偏移半径1~3 像素边缘厚度半径越大描边越宽,但超过 3 像素锯齿感明显,需要配合模糊
Step 阈值0.01边缘判定灵敏度抬高到 0.1 会让细碎噪点消失,但细模型边缘会断
边缘 Mask 模糊1.0边缘平滑度用 Gaussian 模糊一次再乘颜色,能消除像素颗粒感
高亮颜色自定义描边颜色建议用 2~4 倍亮度值,tonemapping 后视觉更明显
Stencil Mask 值250分组标记各分组按二进制位分配,不要直接用 255

这里最容易翻车的组合是“大半径 + 高阈值”。半径拉到 3 像素后,模型边缘往往有 2~3 个像素的过渡带,阈值太高会把过渡带砍掉一半,描边看起来一边宽一边窄。我通常的做法是先用半径 1 保证基线效果,再在材质里加一个 1 像素的高斯模糊,最后用 Threshold 收边缘毛刺。如果还有锯齿,不要继续加半径,改成在后处理链路上再接一个 Blur 节点,效果差别很大。

4.2 多模型选中与取消选中时,Stencil 值怎么交接

单模型高亮是最简单的场景,但多数实际需求是“允许点选多个模型”或者“一个模型同时有选中态和警告态”。这时候 Stencil 不能当成单一 0/1 开关用,要当成一个位掩码表。项目里可以维护一张分组表,比如:

Bit 0 (值 1) → 保留 Bit 1 (值 2) → 悬停预览 Bit 2 (值 4) → 选中态 Bit 3 (值 8) → 警告态 Bit 4~7 → 业务自定义

写入时用或运算叠加:选中一个模型就在原 Stencil 值上|= 4;取消选中时&= ~4。后处理材质里要区分“边缘来源”时,把读取到的 Stencil 值和每个 bit 做 AND 判断,分别输出不同颜色。这样同一模型选中又警告时,描边可以不同侧不同色,或者叠加第二种描边样式。这种做法在纯蓝图工程里也能做,节点多几个,逻辑不复杂。

4.3 边缘发虚和像素颗粒:后处理链路顺序与颜色空间

描边最丑的两个毛病是“发虚”和“颗粒感”。发虚的原因通常是边缘 Mask 数值太软,比如 Step 阈值给的是 0.5 而不是 0.01,边缘区域被线性插值成了半透明过渡带;颗粒感则多发生在暗色场景,因为后处理材质的计算精度在 tonemapping 前是 16 位浮点,暗部噪声被描边放大后特别明显。我一般把描边材质放在After Tonemapping,此时画面是 8 位整数范围,颗粒感会轻很多,代价是边缘没法再被 Bloom 照亮,发光效果弱一截。这个取舍没有标准答案,展厅项目我选 After Tonemapping,追求“亮边光晕”视觉的选 Before Tonemapping,两种都试一遍再定。

5. 高亮选中避坑:五个反复踩到的常见问题与排查思路

5.1 选中后模型整体变黑或完全看不到:Custom Depth Pass 没开

现象:点击模型后,模型变成黑色剪影,或者边缘高亮完全没出现,但点击逻辑本身是通的。

原因:项目设置里的 Custom Depth-Stencil Pass 处于 Disabled,模型虽然执行了SetRenderCustomDepth(true),但没有写入任何通道数据;后处理材质读到的 Stencil 全是 0,描边信号算不出来。变黑的情况是后处理材质把“无标记像素”错误输出成了黑色,说明材质逻辑也有问题。

解决:先在 Project Settings → Rendering → Post Processing 里确认 Custom Depth-Stencil Pass 是Enabled with Stencil,不是Enabled without Stencil;然后在材质编辑器里临时输出一个纯SceneTexture: CustomStencil节点用来调试。如果输出画面能看到模型区域是白色、其他区域是黑色,说明数据和后处理材质都正常,问题在描边判定逻辑;如果全黑,说明数据通道根本没写入。

5.2 鼠标点击穿透模型:碰撞响应通道不匹配

现象:地面、墙壁都能正常点击,但某些模型点不中;或者模型能点中,但射线会穿透玻璃、粒子等特殊物体。

原因:LineTraceSingleByChannel用的是 Visibility 通道,而有些模型的碰撞预设没有勾选 Visibility 响应。常见于美术从外部导入的模型,碰撞预设往往是BlockAll或者自定义的WorldStatic,Visibility 通道是 Ignore,射线直接穿过去了。

解决:两个方向。第一个方向是检查模型碰撞预设,把 Visibility 响应改为 Block,这是最简单的做法;第二个方向是改用 Object Type 射线,遍历WorldDynamic和WorldStatic两类对象。我不建议把所有物体都改成 Block Visibility,因为项目里总有不想被点击挡住的透明装饰物,更合理的做法是把射线通道统一到项目自定义的Interaction通道,在碰撞预设里按需勾选,这样和 UI 点击检测互不干扰。

5.3 被 UMG 界面遮挡时,点击穿透到模型

现象:鼠标停在按钮、面板上时点击,背后的模型也被选中了。

原因:射线检测只做了物理碰撞,不会自动感知 Slate/UMG 的控件命中。Get Hit Result Under Cursor底层只做几何射线,控件层接收鼠标事件后,这个射线仍然会打到模型上。

解决:在射线检测前加一个 UI 命中判断。蓝图里可以在 PlayerController 的事件分发处先调用Get Mouse Position和Get Widget Under Mouse,如果在控件上直接返回,不执行射线。C++ 里用FSlateApplication::Get().GetWidgetUnderMouse()判断控件类型,如果是 UMG 里的 Panel 或交互控件就跳过。这个坑只影响带 UI 的工程,但十个项目里有八个带 UI,所以每次新工程我都会第一时间补上。

5.4 描边只在高亮模型内部出现,外部轮廓不完整:半透明材质写入 Stencil 的问题

现象:用半透明材质做的模型(玻璃、显示屏、粒子面片),选中后描边出现但只显示在模型内部,外部轮廓断断续续,甚至完全看不到。

原因:半透明物体在渲染时不会写入 Custom Depth Stencil,或者说需要额外的bAllowCustomDepthStencil开关与半透明渲染兼容设置。UE5 里半透明模型的 Stencil 默认是无效的,后处理描边做边缘判定时,半透明区域像素贡献的 Stencil 为零,边缘信号算不出正确结果。

解决:给半透明材质开启Render CustomDepth Pass的组件设置,并把材质的Translucency Pass调整为After DOF,然后测试半透明物体在 CustomDepth 缓冲区的表现是否正常。如果试完仍然不稳定,会在工程规范里把“选中高亮能力”限制在不透明模型上,半透明物体用另一种高亮方式(比如模型中心点发一个点光源)。这样最省心。

5.5 场景里同时选中十几个模型后帧率掉一半:CustomDepth 渲染开销

现象:小场景没事,一旦多选模型数量上来,帧率明显下滑,尤其是场景里模型面数复杂时。

原因:每个勾选Render CustomDepth Pass的组件都会在 CustomDepth 阶段被重新渲染一次,等于多了一整个渲染 Pass。如果所有模型常驻勾选,Draw Call 数量直接翻倍,后面还有后处理材质的全屏采样开销。

解决:严格做到“选中才开,取消就关”。这已经写入第 2 章的组件逻辑里:选中时SetRenderCustomDepth(true),取消时无论SetCustomDepthStencilValue(0)还是清除位掩码,都要同步调用SetRenderCustomDepth(false)。另外,不要把所有场景模型都设成可选中,给可交互模型单独加一个 Tag,在射线命中后先判断 Tag 再决定是否开启描边,可以省下大量无效的 CustomDepth 渲染。

6. 交付前验证与手感优化:一个测试关卡和三个细节

6.1 用一个小场景快速验证描边效果是否达标

新工程里我一般会搭一个专门测高亮的“样板关卡”,里面放四组测试物:一个高面数雕像、一个低面数盒子、一个半透明玻璃球、一个多材质组合的汽车模型。点击每个物体,检查四件事:描边是否完整、内外轮廓是否一致、颜色是否被 tonemapping 改变、取消选中是否彻底干净。这个关卡不建议塞太多装饰物,否则画面里其他光效容易干扰判断。测完后把材质参数记录成一份默认值,后续项目直接引用,省去反复调颜色的时间。

6.2 让选中高亮交互更顺手的三个细节

第一个细节是悬停和点击分开。悬停预览用淡蓝色、点击选中用亮橙色,两者用不同的 Stencil bit 位写入,交互层次清晰。很多项目只做点击高亮,鼠标划过时没有任何反馈,操作感很生硬。

第二个细节是重复点击同一模型要能取消选中。这在第 2 章代码里已经做了处理,但蓝图工程里很容易漏掉,因为蓝图的Get Hit Result Under Cursor每次都是独立事件流,忘了保存上一次命中的组件就没有取消逻辑。

第三个细节是描边状态跟随相机可见性。模型被遮挡时不显示描边、镜头转过来再出现,这是通过 CustomDepth 本身对深度关系的处理,但在项目里要确认后处理材质里用的是 Stencil 计算出的边缘,而不是单纯在模型像素上叠加颜色,否则遮挡部分会被“看穿”。这个差别在移动端上特别明显,我自己的习惯是写好描边材质后专门用一个遮挡场景验收一次,确认没有穿帮再交付。

这整套方案从射线检测、组件封装到后处理描边参数,我已经在同一类 UE5 工程上验证过多次,踩过的坑基本都收敛在这篇文章里。新项目再接手类似需求时,我会先打开项目设置确认 Custom Depth-Stencil Pass,再把描边材质烘焙成一个公共资产,剩下的只是参数微调。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询