纹理与 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,避免一个大图集把无关界面的下载和生命周期绑在一起。

发布期做了以下工作:

  1. 收集图片控件的逻辑资产引用,只接纳能够保真表达的缩放模式;平铺、复杂布局或无法推导的世界空间尺寸保持原图。
  2. 根据参考分辨率、父级尺寸和裁剪区域推导最大显示需求;存在自动布局、脚本或未知约束时不做激进降采样。
  3. 下载并校验不可变源图片,重新查询源版本,拒绝“分析期间资产已变化”的结果。
  4. 在 sRGB 转线性后做面积重采样,颜色先乘 Alpha 再累计,最后再转回 sRGB,避免透明边缘把无效 RGB 混进可见像素。
  5. 每张图片四周复制边缘像素,Padding 至少覆盖支持的最大压缩块;摆放位置也按块大小对齐,阻断双线性采样和 ASTC 跨边缘块造成的串色。
  6. 在多个合法页宽中选择“容纳图片最多、GPU 面积最小”的方案,而不是一开始就按最大页宽装箱。
  7. 将同一逻辑页分别 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 和生命周期没有被悄悄改变。