UE5.8 Decal 与 Runtime Virtual Texturing:弹孔、血迹、轮胎印与大地形细节
一、小林的关卡像是被人泼了油漆
小林负责的战术射击关卡进入性能测试那天,QA 给他截了张图。混凝土墙面上叠着七八层弹孔贴花,水泥地面上一大滩血迹还在泛光,泥地里横七竖八的轮胎印一直延伸到视野尽头。帧率 28。GPU 时间飙到 14 毫秒,其中 BasePass 和 Translucency 占了大头。
他有点懵。这些细节在编辑器单张截图里看着很爽:弹孔有边缘剥落,血迹带法线凹陷,轮胎印会根据地面湿度变深浅。可真跑起来,几百个 Deferred Decal 叠在一起,PS4 测试机直接报警。策划还嫌地面细节不够丰富,要在 4 km × 4 km 的地图上加更多脚印、车辙和泥泞痕迹。
主程走过来拍了拍他肩膀:Decal 不是不能堆,你得知道它每一帧在干嘛。大地形细节也别想着靠贴花铺,去试试 Runtime Virtual Texturing。
这篇文章把 UE5.8 里 Decal 与 RVT 这两条管线拆开聊。你会看到弹孔和血迹是怎么贴到墙上的,轮胎印是怎么写进一张虚拟纹理的,以及为什么大地形细节这件事,交给 RVT 比堆 Decal 划算得多。
二、Deferred Decal 到底在画什么
UE5.8 的 Decal 本质上是一个 Box 投影器。你在场景里拖一个 Deferred Decal Actor,引擎会在该 Box 覆盖的像素上,重新跑一遍 Decal 材质,把结果按某种混合方式写进 GBuffer。
Box 的三条轴分别对应贴花的 U、V、W。材质里的 WorldPosition 会被变换到 Decal 的局部空间,再映射到 UV。只有落在 Box 内部的像素才会被处理。这套逻辑放在延迟管线上非常自然:GBuffer 已经摆在那里,Decal 只需要像一次延迟光照一样,读取 BaseColor、Normal、Roughness,再输出修改后的值。
投影的数学很简单。设 Box 中心为 ,局部法线为 ,表面上一点为 ,则点到 Box 中平面的距离为:
UE 会根据这个距离和 Box 的 half-size 决定像素是否在有效范围内,顺便做一点软边衰减。软边宽度可以在 Decal 组件上调,也可以在材质里用 SmoothStep 自己控制。
创建一个 Decal 材质时,记得把 Material Domain 改成 Deferred Decal。Domain 不对的话,材质不会被识别为贴花,渲染结果会成一团粉色。Decal 材质常用的 Blend Mode 有 Translucent、Modulate、Stain 几种。Translucent 适合做弹孔、弹痕,能保留原有法线并叠加新细节;Stain 更适合血迹、油污,会按颜色去浸染 BaseColor;Modulate 做旧化、脏迹比较省。
flowchart TD
subgraph scene ["场景"]
decal["Deferred Decal Actor"]
mesh["Static Mesh"]
landscape["Landscape"]
end
subgraph gbuffer ["GBuffer"]
base["BaseColor"]
normal["Normal"]
rough["Roughness"]
metal["Metallic"]
end
subgraph decal_mat ["Decal 材质"]
proj["Box 投影 UV"]
blend["混合模式"]
out["Decal 输出"]
end
decal --> gbuffer
gbuffer --> decal_mat
decal_mat --> gbuffer这张图把 Decal 的工作方式画得很清楚。它不是真的在模型表面再贴一层几何,而是借 GBuffer 的现有数据,在 Box 范围内重新着色一次像素。
三、弹孔与血迹:混合细节决定成败
小林最早做的弹孔直接用了默认 Translucent 混合,结果子弹打到金属板上,弹孔中心发亮,边缘却和墙面颜色糊成一团。后来他换了一种做法:用一张 Alpha 通道控制弹孔形状,再用另一张法线贴图给边缘做破损起伏,BaseColor 只做暗化不做发光。混合公式可以写成:
这里 是 Decal 材质算出的颜色, 是 GBuffer 里原来的 BaseColor, 是贴花 Alpha。看起来就是普通 Alpha 混合,但 Decal 真正的麻烦在于 Normal 和 Roughness 也要一起混。
Normal 混合不能直接用线性插值。两个法线向量直接 Lerp 会损失长度,导致光照计算出错。UE5.8 的 Decal 材质里,Normal 默认会走 Reoriented Normal Blending,也就是先把切线空间法线转回世界空间,再做扰动混合,最后归一化写回 GBuffer。你在材质里连上 Normal 节点,引擎会自动处理这件事。
血迹是另一种思路。小林不要弹孔那种硬边凹凸,他想要液体浸进水泥缝隙的感觉。他把 Blend Mode 改成 Stain,BaseColor 用暗红色,Roughness 拉到 0.1 让表面看起来像湿的,Opacity 用一张噪波图控制边缘渗透。Stain 模式不会对 Normal 做剧烈扰动,而是更像一层染色层叠在表面。近处看很真实,远处也不会因为法线高频变化而闪烁。
轮胎印又不一样。车辙是长条形,跨过很多不同材质:柏油、泥土、草地、碎石。如果每个轮胎印都放一个 Decal,Box 会拉得很长,覆盖大量无关像素,overdraw 爆炸。小林最初 200 米赛道丢了四十多个长条 Decal,帧率掉了 8 帧。后来他只在短直道放单个 Decal,弯道处用几个短 Decal 拼接,并且把 Decal 的 DrawInGame 距离调小,远距离直接剔除。
flowchart TD
A["玩家开枪/车辆驶过"] --> B{需要贴花?}
B -->|"弹孔/弹痕"| C["创建短 Box Decal"]
B -->|"血迹/油污"| D["创建中等 Box Decal"]
B -->|"轮胎印/长车辙"| E{长度 < 5m?}
E -->|"是"| F["单个 Decal"]
E -->|"否"| G["分段 Decal 或换 RVT"]
C --> H["设置混合模式与法线"]
D --> H
F --> H
G --> I["写入 RVT Cache"]
H --> J["提交渲染"]
I --> J这张图展示了不同贴花场景的选择逻辑。短而集中的细节交给 Decal,长而蔓延的痕迹要考虑 RVT。
四、Decal 的性能账要这么算
Deferred Decal 的代价主要来自两部分:像素覆盖面积和材质复杂度。每一个 Decal 都会向 GPU 提交一个 Box 几何体,光栅化后覆盖的每个像素都要跑一遍 Decal Pixel Shader。如果 Box 很大、很厚,或者多个 Decal 叠在同一区域,同一块屏幕像素会被重复着色多次,这就是 overdraw。
小林用 RenderDoc 抓了一帧,发现一堵墙角落里有 6 层弹孔叠在一起。每个弹孔 Alpha 只在中心很小一块区域非零,但 Box 覆盖了整个墙面。GPU 不得不在整面墙上跑 6 次 Pixel Shader,哪怕大部分像素最终 Alpha 都是 0。解决方法有两个:缩小 Box 尺寸,或者用 DBuffer 做 Mesh Decal。
DBuffer Decal 会把 Decal 结果写入一个与 GBuffer 并行的预 pass 缓冲,支持在 opaque 物体之前混合。它的优势是可以处理金属度和高光,缺点是增加一个渲染 pass,对带宽有要求。UE5.8 默认的 Deferred Decal 对大多数场景够用了,DBuffer 更适合需要修改 Specular 或 Metallic 的高级贴花。
材质复杂度方面,Decal 材质里能省则省。不要做复杂的世界位置偏移,不要用太多纹理采样,尤其是移动端。一个在 PC 上很漂亮的血迹材质,到了手机上可能因为纹理采样数超限而被编译失败。UE5.8 的材质编辑器会在移动端显示采样器数量,超过 16 个就要小心。
还有一个常被忽略的点:Decal 的排序。多个 Decal 叠在一起时,渲染顺序会影响最终结果。UE 默认按创建顺序或 Actor 排序渲染,但这不是绝对可靠。如果你的血迹必须盖在弹孔上面,最好在材质里用 Priority 或在项目设置里调整 decal sorting 规则。
五、Runtime Virtual Texturing 是什么
前面说的轮胎印问题,本质上是:我们想在很大一片表面上留下持久的、细节丰富的痕迹,但又不希望每一帧都为这些痕迹重新投影和混合。Runtime Virtual Texturing 就是为此而生的。
传统 Virtual Texturing 是把一张超大纹理切成很多小 tile,运行时只加载看得见的 tile。Streaming Virtual Texturing 常用于电影级贴图流送,而 Runtime Virtual Texturing 更进一步:这些 tile 不是从磁盘流进来的,而是由 GPU 在运行时画出来的。
你可以把它理解成一块动态画布。RVT 会维护一张虚拟纹理,逻辑上覆盖整个大世界,比如 4 km × 4 km。物理上它只存当前可见区域的一组 tile,通常放在一张 2D 纹理图集里。当相机移动时,新的 tile 会被分配、渲染、缓存;旧的 tile 如果离开视野太久,会被回收。
对于大地形来说,RVT 最大的价值是把 per-pixel 的复杂计算从每帧一次变成按需一次。比如地面有泥土、草地、石头三层材质,每层都要做 triplanar 映射、湿度混合、脚印混合。如果没有 RVT,每个像素每帧都要算一遍。有了 RVT,引擎只在 tile 更新时把这些材质画进虚拟纹理,之后直接从 RVT 采样已经混合好的结果。
sequenceDiagram
participant Cam as "相机"
participant VT as "RVT 系统"
participant Vol as "RVT Volume"
participant Mat as "写入材质"
participant Tex as "物理纹理图集"
Cam->>VT: 视图变化,请求新 tile
VT->>Vol: 查询覆盖的 RVT Volume
Vol->>Mat: 分配虚拟纹理页
Mat->>Mat: 渲染该 tile 的材质输出
Mat->>Tex: 写入 BaseColor/Normal/Roughness
VT->>Tex: 返回虚拟 UV 给采样材质
Cam->>Tex: 最终地表材质采样 RVT这张时序图画出了 RVT 一帧里的主要交互。相机驱动 tile 需求,RVT Volume 决定哪些区域参与写入,材质把结果画进物理图集,最终地表材质从图集里读。
六、RVT Volume 与材质写入
在 UE5.8 里启用 RVT 需要三步:创建 Runtime Virtual Texture Asset、在场景里放 RVT Volume、给需要写入的地表或物体设置写入材质。
Runtime Virtual Texture Asset 定义了虚拟纹理的分辨率、tile size、mip 数量和内容类型。内容类型通常选 BaseColor_Normal_Specular_Roughness,如果你的地形只需要颜色也可以只选 BaseColor。分辨率要和世界尺寸匹配。4 km 地图如果虚拟纹理尺寸只有 2048,那每个世界米只分到半像素,轮胎印会糊成一条粗线。小林试了几版,最后 8k 虚拟纹理配合 128 的 tile size,在视野中心能给出足够清晰的脚印。
RVT Volume 是一个 Actor,划定哪些区域会被写入 RVT。它像 Decal 的 Box 一样有边界,但作用不是投影,而是告诉引擎:这个范围内的地表材质,请把结果写进虚拟纹理。Volume 可以多个叠加,也可以一个覆盖全地图。小林在 4 km 地图中央放了一个大 Volume 覆盖主战场,又在几个频繁交火的村落单独放了小 Volume,提高这些区域的分辨率。
写入材质和普通地表材质的区别在于最后要加一个 Runtime Virtual Texture Output 节点。这个节点把材质网络算出的 BaseColor、Normal、Roughness 写进 RVT。写法很简单:
// 伪代码:RVT 写入材质的核心节点连接
// 来源:UE5.8 Material Editor 中 Runtime Virtual Texture Output 用法
BaseColor = LayerBlend(Grass, Mud, Rock, WetnessMask)
Normal = ReorientedNormalBlend(DetailNormal, LayerNormal)
Roughness = Lerp(0.9, 0.2, WetnessMask)
RuntimeVirtualTextureOutput(
VirtualTexture = RVT_MudTrack,
Output = { BaseColor, Normal, Roughness }
)实际在蓝图材质里不需要手写这些,关键是理解 BaseColor、Normal、Roughness 三个输出都要连到 Runtime Virtual Texture Output 上。如果只连 BaseColor,地表法线信息就丢失了,RVT 采样出来的地面会看起来很平。
Mip chain 也需要关注。RVT 会自动生成 mip,但写入材质里的细节噪声如果频率太高,远端 mip 会闪烁。小林在写入材质里加了一层距离缩放,让高频细节在近处出现,远处逐渐淡出。淡出权重可以用相机距离或虚拟纹理的 mip 级别驱动。
七、把轮胎印写进大地形
小林想要的效果是:载具驶过泥地后,留下两条逐渐变浅的车辙,过几分钟被雨水冲淡。用 Decal 做这件事,每个车辙都是一个 Box,两百米赛道下来要上百个。用 RVT 做,只需要在载具轮胎位置向 RVT 画一个小的 stamp,车辙会持久地留在虚拟纹理上,直到 tile 被回收或显式清理。
实现方式有两种。一种是把轮胎印当成地表材质的一部分,用一张世界空间 mask 去控制泥土混合。这种方法不需要每帧画 RVT,但 mask 精度受地形权重图限制,边缘很硬。另一种是真正的运行时绘制:在轮胎接触点生成一个小的 RVT 写入代理,每次车轮压到地面就向 RVT 输出一个轮胎纹理。
UE5.8 没有直接提供轮胎印组件,但可以用 Blueprint 配合 Runtime Virtual Texture Volume 的 Invalidate 接口实现。基本思路是:
- 在载具 Blueprint 里记录左右轮胎的世界坐标。
- 每帧检查轮胎是否接触地面,且地面材质属于泥土。
- 如果接触,在轮胎位置生成一个小的 RVT 写入 Mesh 或 Decal,把它画进 RVT。
- RVT 写入 Mesh 使用一张轮胎印 Alpha 图,混合模式类似 Stain,但输出目标是 RVT。
车轮离开后,印记已经存在 RVT 里,不需要再保留任何 Actor。除非该区域 tile 失效或被回收,否则印记会一直可见。这比维持几百个 Decal Actor 轻量得多。
graph LR
A["轮胎触地"] --> B["计算轮胎世界坐标"]
B --> C["生成 RVT 写入 Mesh"]
C --> D["采样轮胎印 Alpha"]
D --> E["写入 RVT Tile"]
E --> F["移除写入 Mesh"]
F --> G["地表材质采样 RVT"]
G --> H["车辙持续显示"]
H --> I{Tile 失效?}
I -->|"是"| J["重新渲染或清除"]
I -->|"否"| H这个 graph 展示了轮胎印进入 RVT 的完整生命周期。触地、写入、移除代理、持续显示、失效处理,形成一个完整的循环。
RVT 在大地形里的另一个应用场景是脚印。小林给玩家角色加了一个简单的足迹系统:角色双脚着地时根据地面类型选择 footprint 纹理,向 RVT 画一个小 stamp。草地和泥地的 footprint 会保留,石板地则跳过。因为 RVT 是按 tile 缓存的,脚印不会随着角色走远就突然消失,也不会因为角色回头再看而重新计算。
八、Decal 与 RVT 怎么配合
Decal 和 RVT 不是互相替代,而是互补。Decal 适合局部、短暂、需要精确投影的细节;RVT 适合大范围、持久、需要重复采样的痕迹。一个合理的分工是:
- 弹孔、弹痕、爆炸焦痕:Decal,生命周期短,结束后销毁。
- 血迹、油渍:短时间用 Decal,如果策划要求长时间保留,可以淡入 RVT。
- 轮胎印、脚印、车辙:RVT,持续存在,覆盖面积大。
- 大地表混合细节:RVT,把多层材质预先混合成一张。
小林最后采用的方案是:事件发生的瞬间用 Decal 做即时反馈,几秒后把需要的痕迹 bake 到 RVT,然后销毁 Decal。这样玩家既能看到即时的命中效果,地面又不会堆满 Actor。
转移过程要注意时序。Decal 还在显示时,RVT 写入必须发生在同一位置,并且混合方式要和 Decal 一致,否则玩家会看到痕迹跳变。小林的做法是让 Decal 在几秒内淡出,同时 RVT 印记以同样速度淡入。两个 Alpha 曲线相加恒为 1,视觉上看就是无缝过渡。
还有一个容易踩的坑:RVT 写入和 Decal 写入的坐标空间不同。Decal 用 Box 局部 UV,RVT 用世界空间 UV 或虚拟纹理 UV。把 Decal 纹理 bake 到 RVT 时,必须做正确的坐标变换,否则纹理会拉伸或错位。小林用了一张 world-space 投影的 stamp 纹理,保证不同角度驶过的轮胎印方向正确。
九、平台差异不能忽略
UE5.8 的 RVT 在 PC 和主机上支持得比较完整,但移动端要谨慎。RVT 需要额外的物理纹理内存、虚拟纹理页表更新、以及写入 pass。低端手机可能连一张 4k 的 RVT 图集都放不下,帧率还会因为 tile 更新而波动。
PC 上小林用 8k 虚拟纹理、RGBA8 格式、带 Normal。主机上他降到 4k,Normal 用 RG16F 压缩。移动端他直接关闭 RVT 轮胎印,改成在地表材质里用一张 world-space 噪波图假装有车辙痕迹。远处玩家看不出来,近处虽然缺少真实 stamp,但帧率稳住了。
Shader Model 也有差异。RVT 写入材质在 SM5 和 SM6 上都能跑,但某些节点在 Vulkan 或 Metal 上行为可能不同。比如 Reoriented Normal Blending 在移动端某些驱动上会得到反法线,需要手动翻转 Y 通道。小林在材质里加了一个平台判断宏,移动端走简化版 Normal 混合。
内存方面,RVT 物理图集的大小可以用 r.VT.MaxAnisotropy 和 r.VT.PoolSize 控制。默认值在大型地图上可能不够,调太大又容易爆显存。小林的建议是:先在目标平台上跑一遍,看 RVT Pool 有没有频繁 evict,再逐步调整。eviction 太频繁说明 tile 存不住,要么减小世界覆盖范围,要么增大 pool。
还有一个细节是 RVT 与 Nanite 的交互。UE5.8 里 Nanite mesh 默认支持 RVT 采样,但 RVT 写入到 Nanite 表面需要额外确认 mesh 的 virtual texture support 选项。小林第一次把轮胎印写到一块 Nanite 碎石上没效果,查了半天才发现碎石 Static Mesh 没有勾选 Support Virtual Texturing。
十、写在最后
小林那个关卡最后跑稳了 60 帧。弹孔用短 Box Decal,命中后 5 秒淡出;血迹保留 30 秒,fade 到 RVT;轮胎印和脚印直接写 RVT,主战场 8k 虚拟纹理,移动端降级成 world-space 噪波。大地形本身的三层材质混合也搬进了 RVT,BasePass 轻了很多。
下面是几个他会反复提醒自己的点:
- Decal 是按 Box 像素收费的,Box 越大、叠得越多越贵。
- 长条形痕迹不要硬堆 Decal,RVT 是更自然的归宿。
- RVT 写入材质要连 BaseColor、Normal、Roughness,不能只写颜色。
- 移动端开启 RVT 前先做内存和 tile eviction 测试。
- Decal 做即时反馈,RVT 做持久痕迹,两者 fade 过渡最干净。
贴花和虚拟纹理都是让画面更丰富的工具,但它们的成本结构完全不同。把细节放在正确的管线上,画面和帧率才能同时站得住。