纹理与 UI 图片:ASTC 变小不等于运行时成本变小
自动关联目录:纹理与 UI 图片:ASTC 变小不等于运行时成本变小
核心判断:图片优化至少有三笔互不等价的账:下载与包体、GPU 驻留、UI 提交。ASTC 文件更小,不代表软件解码后的纹理仍是压缩格式;图片进了 Atlas,也不代表不同层级、裁剪状态和材质一定能合成一个 Slate Batch。正确边界不是“统一转 ASTC”或“全部合图”,而是给每张图片定义从源数据、平台制品到运行时表示的完整采样契约。
一、先把三笔账分开
创作者 UI 会异步引用图片,并要求 Stretch、Fit、Crop、Tile、九宫格、染色以及屏幕和世界空间显示。与此同时,客户端还要控制下载体积、解码峰值、显存、Draw Call 和低端设备兼容性。把这些目标都归为“压缩图片”,很容易优化错对象。
| 优化动作 | 主要降低什么 | 不会自动解决什么 |
|---|---|---|
| PNG/JPEG 等传输压缩 | 下载字节、磁盘占用 | GPU 纹理驻留、Slate Batch |
| ASTC/BC 等块压缩 | 平台制品和 GPU 纹理字节 | 图片数量、Widget 层级、裁剪屏障 |
| 降分辨率或减少 Mip | 纹理字节 | 远距离稳定性、UI 清晰度 |
| UI Atlas | 纹理切换和合批机会 | 不同 Layer、Clip、Blend、Shader 的批次屏障 |
| Texture Streaming | 非当前 Mip 的驻留 | 单 Mip UI 页、首帧解码峰值 |
ASTC 每个块固定占用 16 字节,因此某一 Mip 的理论大小为:
bytes = ceil(width / blockWidth) * ceil(height / blockHeight) * 16块越大,单位像素成本越低,细线、文字、法线和 Alpha 边缘的误差通常越明显。这个公式只能算体积,不能证明视觉质量,也不能证明最终运行时仍以 ASTC 驻留。
二、早期运行时 ASTC 解决了传输,却没有形成纹理制品契约
实际代码中有一条完整的运行时 ASTC 编解码路径:从 UTextureRenderTarget2D 或 UTexture2D 读取 BGRA8,转换为 RGBA,按固定块大小补齐尺寸,在 Task Graph 上并行编码;解码同样在后台执行,回到 Game Thread 创建 UTexture2D 并 UpdateResource()。弱对象引用和 Game Thread 回调避免了后台线程直接构造 UObject,这些设计是正确的。
但它的边界只是一种文件转换器:
- 编码只读取 Mip 0,解码也只创建一个 Mip。
- 文件头保存块尺寸和宽高,没有保存 sRGB、Alpha 语义、采样方式、逻辑身份和内容版本。
- 编码接口允许直接把 Alpha 覆盖为不透明,但这个决定没有进入制品元数据。
- 解码结果固定展开成
PF_B8G8R8A8临时纹理,再上传 GPU。 - 补齐后的尺寸直接成为解码尺寸,图片原始有效区域没有独立记录。
最后一点尤其关键:假设一张图片以较大的 ASTC Block 存储很省空间,软件解码后却按 width * height * 4 驻留;若源 ASTC、解码缓冲和 UTexture2D 在一段时间内同时存在,峰值还要叠加三份数据。这个路径优化了下载和落盘,没有保留 GPU 块压缩,也没有 Texture Streaming 能力。
因此,运行时 ASTC 不是错误方案,但它只适合“需要一个跨平台中间文件,最终必须访问 RGBA 像素”的场景。若目标是降低显存,就应该发布目标平台可直接创建的原生纹理制品,而不是在客户端把 ASTC 当图片压缩包再解开。
三、BlockSize 应按最终画面选择,而不是看原图猜
后来离线工具不再固定使用一种块尺寸,而是在多个 ASTC BlockSize 和编码质量之间搜索,再把结果写回 UE 的移动端压缩设置。对普通二维图片,可以对编解码结果直接做感知误差评估;对材质中的多张纹理,则在固定光照、相机和模型下多视角渲染,比较最终画面。
这个演进解决了“单张贴图误差不等于最终画面误差”:
- BaseColor、Mask、Normal 的通道意义不同,不能共用一个像素阈值。
- 材质可能只读取某个通道,或经过颜色空间转换、强度缩放和光照后才显现误差。
- UI 图标直接暴露像素与 Alpha 边缘,通常比平滑表面更敏感。
- 同一纹理在实际屏幕上只占很小面积时,源分辨率误差可能没有可见意义。
值得保留的不是某种搜索算法,而是评价位置的改变:3D 纹理在渲染空间验收,2D 图片在实际采样和显示尺度下验收。
这里仍有一个工程风险:外部分析结果通过表格重新写入 UTexture2D 的移动压缩字段,并触发 PostEditChange() 与保存。若结果没有同时记录源内容摘要、工具版本、目标平台和评价配置,就可能把旧结论应用到已经变化的资产。更稳妥的做法是把压缩选择作为可复现的派生制品,只有输入 Fingerprint 完全匹配时才允许回写。
四、动态图集能减少纹理切换,但不能凭空消除 Slate 屏障
第一代运行时图集已经做了几件必要的事:按平台选择压缩格式,只接受与图集格式完全一致且只有一个 Mip 的源纹理;按压缩块对齐分配区域,直接复制 cooked Mip 字节到临时图集;以资源路径计数引用,释放后回收区域。另一套更激进的实现会把图片拆成若干条带,在 Slate OnPaint 阶段为条带生成顶点,从而减少空白区域占用并兼容九宫格。
这些方案证明了两个事实。
第一,压缩纹理不能按像素随便切。ASTC/BC 的最小复制单位是压缩块,区域起点、宽高、RowPitch 和上传字节数都必须按 GPixelFormats[Format] 计算;否则轻则边缘串色,重则 RHI 更新越界。
第二,共用一张纹理只是合批必要条件。真实 Slate 测试表明,同一 Atlas 上连续且渲染状态兼容的图片可以进入同一批次;不同 Layer、Scissor、材质或绘制屏障仍会拆批。为了强行合并而丢掉每个 Widget 的裁剪状态,会得到更低的 Draw Call 和错误的画面。
一次实际方案对比也说明,动态图集并非在所有维度都胜出:它降低了图集显存并改善部分帧稳定性,却增加了 CPU 打包、上传时间和进程内存;预分配的 TextureArray 加载更快、CPU 内存更可控,但会为未使用切片付出 GPU 空间。最终没有仅凭某个指标替换原方案,这是正确决策。Atlas、TextureArray 和独立纹理应该按瓶颈选择,而不是按“技术更新”排序。
五、后续方案的关键升级:发布图集是平台制品,不是运行时 RGBA 拼图
后续实现把 UI 图片分成发布期图集、运行期动态图集和原图三种表示,并规定发布图集优先于动态图集。发布流程只处理显式开启的 UI 根节点,按根节点划分 Scope,避免一个大图集把无关界面的下载和生命周期绑在一起。
发布期做了以下工作:
- 收集图片控件的逻辑资产引用,只接纳能够保真表达的缩放模式;平铺、复杂布局或无法推导的世界空间尺寸保持原图。
- 根据参考分辨率、父级尺寸和裁剪区域推导最大显示需求;存在自动布局、脚本或未知约束时不做激进降采样。
- 下载并校验不可变源图片,重新查询源版本,拒绝“分析期间资产已变化”的结果。
- 在 sRGB 转线性后做面积重采样,颜色先乘 Alpha 再累计,最后再转回 sRGB,避免透明边缘把无效 RGB 混进可见像素。
- 每张图片四周复制边缘像素,Padding 至少覆盖支持的最大压缩块;摆放位置也按块大小对齐,阻断双线性采样和 ASTC 跨边缘块造成的串色。
- 在多个合法页宽中选择“容纳图片最多、GPU 面积最小”的方案,而不是一开始就按最大页宽装箱。
- 将同一逻辑页分别 Cook 为各目标平台的原生
Texture2D制品,并在 Manifest 中绑定平台、内容摘要、尺寸、源版本和 UV。
这里最重要的不是装箱算法,而是 Manifest + 平台纹理 共同构成可验证制品。运行时加载页面前会先检查当前 RHI 是否支持目标格式,避免在不支持的设备上创建纹理时触发断言;加载后还会校验内容摘要、尺寸、sRGB、单 Mip、非 VT 以及实际 PixelFormat。RGBA8 页面会被拒绝,因为它意味着本应原生压缩的页面发生了静默膨胀。
flowchart LR
A[逻辑图片引用] --> B[源图片 + 版本摘要]
B --> C{能否保真合图}
C -->|否| D[保留独立原图]
C -->|是| E[线性空间预乘 Alpha 重采样]
E --> F[块对齐 + 边缘外扩 + Scope 装箱]
F --> G[目标平台原生纹理制品]
G --> H[Manifest: 身份 / 平台 / Hash / UV]
H --> I{运行时验证}
I -->|通过| J[发布图集]
I -->|失败| D六、运行期动态合图的正确姿势:复制 cooked Block,不做软件解码
未进入发布图集的独立图片仍可在运行时尝试合图,但资格更严格:必须是可渲染客户端、sRGB、双线性采样、非虚拟纹理、单 Mip、受支持的 PixelFormat,且尺寸和页数不超过设备档位预算。点采样、Mip 链和超大图片直接保留原图,因为把它们塞进单 Mip 图集会改变采样语义。
这条路径没有把 ASTC/BC 解码为 RGBA。它通过 BulkData 的异步 IO 接口读取 cooked Mip 字节,按相同 PixelFormat 创建图集页,再用 UpdateTexture2D 把压缩块直接上传到对应区域。页面 CPU BulkData 同步维护同一份块数据,使 UpdateResource() 后仍能恢复正确内容。
为了不把合图变成新卡顿源,实际实现还限制了:
- 每帧启动的 IO、完成的页面构建和回调数量。
- Pending、Entry、页数和图片尺寸的上限。
- 已满时在读取源 Mip 前拒绝,避免做无用 IO。
- 空闲 Entry 延迟淘汰,局部空洞优先复用;只在整页无活跃图片时释放页面,保证存活 UV 稳定。
- IO 取消可能异步完成,因此延迟持有请求和字节缓冲,但不持有 World 或 UObject。
这里的内存账必须完整计算:
peak = source resident bytes
+ atlas page bytes
+ in-flight BulkData read bytes
+ render-command upload bytes只统计图集页会低估峰值。只有在原图随后能够卸载,且页面填充率足够高时,动态图集才可能真正降低总驻留;如果源纹理因其他引用继续常驻,收益主要是减少纹理切换,而不是节省显存。
七、回退不是异常分支,而是图片表示的主流程
实际运行顺序是先让原图可显示,再异步尝试更优表示:
Apply(original);
if (PublishedPage.IsVerifiedFor(CurrentPlatform))
{
Apply(PublishedPage, PublishedUV);
}
else if (DynamicAtlasEnabled && IsVisible() && IsEligible(original))
{
RequestCompressedBlockCopy(original, generation, revision,
[=](Page, UV)
{
if (!IsCurrent(generation, revision)) return;
if (Page) Apply(Page, UV);
else Apply(original);
});
}这套优先级处理了几个真实故障:
- 图片加载回调可能在 UI Manager 已 Teardown 或进入 GC 后到达。入口必须同时检查弱引用、业务销毁态、UObject 销毁标记和 GUI Generation。
- 小尺寸请求的回调可能晚于新的大尺寸请求,不能覆盖新 UV;每个图片 Watch 需要独立 Revision。
- 运行时开关关闭后,迟到的合图回调不能再次替换原图;开关本身需要 Generation。
- 已命中发布图集的图片不能再次进入动态图集,否则会形成“图集套图集”,丢失原始 UV 与生命周期边界。
- 将
TMap::Find()返回的值引用传入一个会对同一TMap::Add()的函数,可能因容器重分配变成悬垂引用;实际修复是先复制局部值,再调用重入路径。
最后一个问题与纹理算法无关,却是 UE/C++ UI 异步链路里非常典型的崩溃机制:数据看起来仍在同一个对象内,但容器写操作已经让引用失效。仅增加 UObject 弱引用无法修复它。
八、仍然不够好的地方
后续方案已经比早期“转 ASTC + 塞 Atlas”完整很多,但表示规则仍分散在发布构建器、平台 Cook、页面缓存、动态合图、GUI Manager 和自定义 Slate Widget 中。
主要问题有三个:
第一,资格判定重复。 发布图集与动态图集对 sRGB、Filter、Mip、尺寸和 ScaleMode 的要求不同,但目前由多个入口分别维护。新增一种图片语义时,很容易只修改显示层,没有同步修改发布与回退。
第二,成本模型不是一等数据。 系统知道页面、Entry 和 Pending,却缺少统一的 source bytes / page bytes / staging bytes / occupancy / fallback reason / batch reduction 统计,很难回答某张图片为何没有优化,以及优化后到底省了哪笔账。
第三,单 Mip 是资格条件,不应变成全局规范。 普通屏幕 UI 常适合单 Mip;世界空间 UI、远近变化明显的大图或会被大幅缩小的图片需要 Mip。强制删除 Mip 虽然便于块复制,却可能把显存节省换成闪烁和带宽浪费。
九、目标架构:编译 Image Presentation,而不是直接改 Texture
更好的边界是统一生成 ImagePresentationPlan,再由不同后端执行。Plan 必须显式描述保留和放弃的语义:
struct ImageContract
{
AssetIdentity source;
ColorSpace colorSpace;
AlphaMode alphaMode;
SamplerContract sampler;
MipPolicy mipPolicy;
ImageScaleMode scaleMode;
PixelDemand maxDisplayPixels;
};
struct ImagePresentationPlan
{
Representation kind; // Original, PublishedAtlas, DynamicAtlas
PlatformArtifact artifact;
PixelFormat format;
Rect sourceRect;
Hash contentHash;
FallbackReason fallback;
};
ImagePresentationPlan CompileImage(
const ImageContract& image,
const PlatformCapabilities& platform,
const RuntimeBudget& budget);发布期和运行时都消费同一份契约:发布期决定是否降采样、BlockSize、平台格式与装箱;运行时只做能力校验、获取、激活和回退。这样可以把“为什么没进 Atlas”从日志字符串提升为可查询的稳定枚举,也可以对每次规则变更做离线重放。
十、验证:同时证明画面、内存和批次
工程中已经存在的测试方向是对的:
- Manifest 拒绝越界、重叠、错误平台、错误 Hash 和不一致的源尺寸。
- 原生页面必须是受支持的压缩格式、sRGB、单 Mip;禁止静默展开为 RGBA8。
- 动态页读取真实 BulkData,验证压缩字节不被改写,并覆盖区域复用、整页回收和活跃 UV 稳定。
- 发布图集替换动态图集;关闭动态能力只恢复动态图片,不破坏发布图集。
- 旧请求、隐藏图片、销毁节点和重置场景的迟到结果不能复活表示。
- 使用真实 Slate Renderer 比较独立纹理与 Atlas 的最终 Batch,同时验证不同 Clip 仍保留正确裁剪;CPU 批处理测试不冒充 GPU 帧时间结论。
下一步应补齐统一指标:
| 维度 | 应记录的量 |
|---|---|
| 传输 | 原始字节、平台制品字节、命中率、校验失败原因 |
| 解码 | 队列等待、CPU 时间、峰值临时内存、Game Thread 提交时间 |
| 驻留 | 源纹理字节、图集页字节、双份驻留时长、页填充率 |
| 渲染 | 最终 Slate Batch、纹理切换、Clip/Layer 屏障、上传字节/帧 |
| 质量 | 最终显示尺度下的感知误差、Alpha 边缘、远距离闪烁 |
我的反思是:早期运行时 ASTC 与动态图集分别击中了下载和纹理切换问题,后台编解码、压缩块对齐和原图回退都值得保留;真正的升级发生在把图片视为“有身份、有平台变体、有采样语义的制品”之后。最终原则应是:源图片是事实,ASTC、BC、RGBA、发布图集和动态图集只是表示;任何表示替换都必须证明颜色、Alpha、Mip、UV 和生命周期没有被悄悄改变。