UE5 / Material
UE5 武器 HUD:一张图集 + UV 变换,干掉成堆的材质实例
在 UE5 射击预研项目里做武器 HUD 时碰到一个很典型的坑:弹药数字、icon 启用/禁用、进度条这些动效,按常规 UI 思路做,材质实例数量会随状态数线性爆炸,最后维护不动。最后落地的方案是纯材质驱动——一张图集 + 一个 UV 变换材质函数,把显示状态全部变成参数。这篇记一下方案结构和踩过的坑。
问题:状态数 × 材质实例
武器 HUD 上要显示的东西拆开看都不难:
| 元素 | 状态数 | 常规做法 | 问题 |
|---|---|---|---|
| 弹药数字 | 0–999,三位数 | 每个数字一张贴图 / 每套组合一个 MI | 3 位数字就是 10×10×10 的组合空间 |
| icon 启用 | 亮 / 灰 / 禁用 3 态 | 三套贴图或三个 MI | 武器种类一多就翻倍 |
| 进度条 | 连续 0–1 | 每档一个 MI 或靠 UMG 拉伸 | 拉伸会糊,档位又不够细 |
真正的痛点不是"某个效果做不出来",而是资产关系失控:美术改一个数字样式要动十几个材质实例,改完还得逐个对一遍武器。
方案总览:三段语义分层
整套方案只有三层语义,每一层各管一件事,互不干扰:
| 层 | 参数 | 含义 | 谁改 |
|---|---|---|---|
| slot | 图集里的 UV 区域 | 这个 UI 元素"画的是什么" | layout.json,美术不碰 |
| layer offset | ox, oy |
这一层在图集格子内的偏移 | 母材质参数 |
| layer scale | sc |
这一层的缩放 | 母材质参数 |
好处是:图集重新排布时,只需要改 layout.json,材质和蓝图一行不用动。之前改一次图集要重连一堆 UV 节点,现在是个配置文件的事。
图集布局
主角是一张 T_Weapon_HUD_Atlas,1024×1024,按 10×10 网格组织,每格 102.4 px。
格坐标 (Row, Col),0 起算
┌────┬────┬────┬────┬────┐
│ 0,0│ 0,1│ 0,2│ ...│ 0,9│
├────┼────┼────┼────┼────┤
│ 1,0│ 1,1│ ...│ │ │ 数字 0-9 占一行
├────┼────┼────┼────┼────┤
│ ...│ │ │ │ │ icon 各占一格
└────┴────┴────┴────┴────┘
两类元素摆放规则不同:
- 数字 / icon:一格一个,
StartRC == EndRC,用格子索引直接算 UV。 - 进度条:
bar_a/bar_b是跨多格的长条,必须显式给 Start/End RowCol。这是唯一需要特殊坐标的地方,后面单独说。
图集设置上有两条硬性要求:
- 每格留 2–4 px padding。图集开了 mip 之后,边缘像素会在低 mip 上和相邻格混色,UI 上表现为数字边缘"长毛"。
- 采样类型设 UserInterface,压缩用 UI 专用格式(BC7 / ASTC 视平台),不要走默认的 Color 采样链路——否则线性空间转换会把 UI 的锐利边缘糊掉。
核心:图集 UV 变换材质函数
这是整个方案的关键。做成一个材质函数 MF_AtlasUVTransform,内部是一个 Custom 节点。
输入口位:
| 输入 | 类型 | 默认值 | 作用 |
|---|---|---|---|
UV |
float2 | — | 本地 0–1 UV |
AtlasSize |
float2 | (10,10) | 图集网格数 |
StartRC |
float2 | — | 起始格 (Row, Col) |
EndRC |
float2 | — | 结束格 (Row, Col) |
Translation |
float2 | (0,0) | 层偏移 ox, oy |
Scale |
float | 1 | 层缩放 sc |
Rotation |
float | 0 | 旋转角度(度) |
ClipX |
float | 0 | 溢出裁切开关 |
Custom 节点里的 HLSL:
// MF_AtlasUVTransform —— 图集 UV 变换
// 关键认知:UV 采样是"反向"的。想让图案在视觉上 rotate → translate → scale,
// 对采样坐标就要按 scale → translate → rotate 的逆序施加。
// 顺序写反的表现是:缩放和旋转互相对不上,旋转中心偏移。
float2 uv = UV;
// ---- 1) Scale:缩放采样框(视觉上是图案被缩放)----
uv = (uv - 0.5) / max(Scale, 1e-5) + 0.5;
// ---- 2) Translate:层偏移 ox, oy ----
uv -= Translation;
// ---- 3) Rotate:绕格中心旋转 ----
float rad = radians(Rotation);
float s = sin(rad), c = cos(rad);
uv = mul(float2x2(c, -s, s, c), uv - 0.5) + 0.5;
// ---- 4) 本地 UV → 图集 UV ----
float2 cellSize = 1.0 / AtlasSize; // (0.1, 0.1)
float2 cellStart = StartRC * cellSize;
float2 cellSpan = (EndRC - StartRC + 1.0) * cellSize; // 跨格 span
float2 atlasUV = cellStart + uv * cellSpan;
// ---- 5) 溢出裁切:防止采到相邻格 ----
float inside = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
if (ClipX > 0.5) inside = 0.0;
return float3(atlasUV, inside); // xy = 采样 UV,z = 有效遮罩
返回值用 float3 是为了把"这一片有没有采到东西"一起带出来——透明区域直接靠 inside 抠掉,不用再单独做一次遮罩采样。
5 秒验证:把
Scale从 1 拉到 2,图案应该变大(因为采样框缩小了)。如果变小,说明第 1 步写反了。
多位数显:拆位 + 高位消零
三位弹药数字不是三张贴图,而是同一个材质函数调三次,每次取一位:
// 取 value 在 place 位上的数字,place = 1 / 10 / 100
float DigitAt(float value, float place)
{
return fmod(floor(abs(value) / place), 10.0);
}
// 高位消零:value < place 时该位不显示
float Visible = step(place, abs(value) + 1e-5);
数字 → 格子索引:
// 数字 0-9 存在第 R 行,列即数字本身
float2 RC = float2(DigitRow, digit);
然后 RC 同时喂给 StartRC 和 EndRC(单格)。三位数字就是三个 MF_AtlasUVTransform 实例横排,各自拿 DigitAt(Ammo, 100)、DigitAt(Ammo, 10)、DigitAt(Ammo, 1)。
Visible 乘到最终 alpha 上,弹药 7 就只显示 "7",不会显示 "007"。
进度条:跨格逐格点亮
bar_a / bar_b 跨多格,这里最容易踩的坑是直接对跨格 span 做线性插值——结果就是条被拉伸、格子交界处对不上。
正确做法是逐格点亮,最后一格做部分填充:
// 进度条:Progress 0..1,跨 cells 格
float cells = (EndRC.x - StartRC.x + 1.0);
float filled = Progress * cells; // 已填充的格数(可含小数)
float idx = floor(uv.x * cells); // 当前像素落在第几格
float below = step(idx + 1.0, filled); // 完全点亮
float isCur = step(idx, filled) * step(filled, idx + 1.0); // 正在填充的那一格
float partial = saturate(filled - idx);
float lit = max(below, isCur * partial);
这样条是按格子一格一格长出来的,末格内部平滑,不会有拉伸感,格子接缝也永远对得齐。
icon 启用 / 禁用
icon 不换贴图,只换格子 + 调色:
- 启用:正常格 + 原色
- 禁用:切到"灰态"格(图集里预留),或者复用同一格 +
Desaturate到 0.85,再压Opacity到 0.4
我倾向于后者——图集里少存一套灰态,内存和制作成本都省了,视觉上和手绘灰态几乎没差别。
结果
| 指标 | 常规做法 | 图集 + UV 变换 |
|---|---|---|
| 母材质数 | 每种元素一套 | 1 套 |
| 材质实例 | 随状态数增长 | 按武器种类,个位数 |
| 改一次数字样式 | 动十几个 MI | 改 layout.json + 图集 |
| 新增一种 icon | 新建材质 | 图集加一格 + 配置加一行 |
真正的收益不是"少了几 MB",而是显示逻辑和资产解耦:美术调样式不用碰材质图,程序调数值不用等美术出图。
踩坑清单
| 现象 | 根因 | 处理 |
|---|---|---|
| 数字边缘"长毛"、发虚 | 图集开了 mip,低 mip 上跨格混色 | 每格留 padding;UI 图集直接关 mip |
| 图案颜色发灰/发亮 | 贴图 sRGB 设置错 | UI 图集按 UI 专用采样类型,不要走 Color 链路 |
| 缩放时图案往角落跑 | Scale 的锚点没减 0.5 | 所有绕中心变换前先 -0.5,做完再 +0.5 |
| 旋转和缩放对不上 | UV 变换顺序写反 | 严格 scale → translate → rotate(视觉上是反过来的) |
| 进度条格子交界错位 | 对跨格 span 直接线性插值 | 改成逐格点亮 + 末格部分填充 |
| 相邻格内容串进来 | 采样溢出到隔壁 | 用返回值的 inside 抠掉,或开 ClipX |
| 半透明排序乱 | UI 元素互相穿插 | 同一 HUD 内的材质统一 Translucency Sort Priority |
小结
这套东西技术含量不算高,但它解决的是 TA 日常最烦的那种问题:不是做不出来,是做完之后没人维护得动。把"显示什么"和"怎么显示"拆成两层,中间的接缝就是那个 UV 变换函数——这个函数写对一次,后面就都是配参数了。