Yuyeyyy · Graphics

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。这是唯一需要特殊坐标的地方,后面单独说。

图集设置上有两条硬性要求:

  1. 每格留 2–4 px padding。图集开了 mip 之后,边缘像素会在低 mip 上和相邻格混色,UI 上表现为数字边缘"长毛"。
  2. 采样类型设 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 变换函数——这个函数写对一次,后面就都是配参数了。

评论