UE5.8 贴图压缩与流送:从一次纹理池溢出说起

📑 目录

UE5.8 贴图压缩与流送:从一次纹理池溢出说起

去年冬天,项目进入量产阶段。灯光组为了给新场景提画质,把一组 PBR 材质全换成了 4K 贴图:基础色、法线、粗糙度、金属度、环境光遮蔽,再加一张细节法线。十二张贴图塞进场景,两天后被 QA 在 GTX 1660 机器上直接撞崩溃。日志里躺着一行红字:

Texture Streaming Pool Over budget by 324.5 MB

那天下午,我们几个围在测试机旁边复现。走进场景大门,帧率还稳;转过弯,镜头对准堆满箱子的货架,画面突然定格,然后闪退。回来看日志,显存被贴图吃得一干二净,连 UI 字体都开始出现方块。那次事故成了团队里关于贴图压缩与流送最生动的教材。这篇文章就是把当时的排查思路整理出来,讲讲 UE5.8 里贴图在导入、压缩、生成 Mip 和进入 Streaming Pool 这几个阶段里的完整链路。

1 压缩格式:DXT、BC、ASTC 到底怎么选

贴图不压缩时,一张 4K RGBA 纹理占用的显存约为 4096×4096×4 bytes=64 MB4096 \times 4096 \times 4\ \text{bytes} = 64\ \text{MB}。角色、场景、UI 一多,显存根本扛不住。块压缩把贴图分成 4×44 \times 4 的小块,每块用固定比特数编码,解压由 GPU 硬件完成,显存和带宽一起降。这个思路自 DXT 时代延续至今,在 BC 和 ASTC 里都能看到影子。

1.1 BC 系列在 PC 和主机上的分工

Unreal 中常见的 BC 格式对应如下:

格式每像素比特适用贴图特点
BC14 bpp基础色、无透明 diffuse颜色质量一般,省一半空间
BC28 bpp需要 4-bit alpha 的颜色贴图alpha 精度低,已很少用
BC38 bpp带 alpha 的基础色、UIalpha 用线性插值,质量较好
BC44 bpp单通道灰度,如粗糙度、高度只存一个通道
BC58 bpp法线贴图存两个通道,第三个在 shader 中重建
BC6H8 bppHDR 环境光、天空盒支持浮点,压缩慢
BC78 bpp高质量基础色、UI画质接近原图,压缩极慢

那次事故里,美术把基础色全设成 BC7,法线又用了 BC5,粗糙度和金属度各自占一张 BC4。看起来每张都不大,加起来却非常可观。PC 上 BC7 确实好看,但只有在近距离观察、色差明显的资产上才值得。我们更惨痛的经历是 Cook 时间:一张 4K BC7 贴图在普通工作站上要压好几分钟,CI 打包时间直接翻倍。后来主程在构建机上加了 -compress 并行任务,又把非关键资产改回 BC1,Cook 才回到可接受范围。

1.2 移动平台的 ASTC 与 ETC2

移动芯片通常用 ASTC 或 ETC2。ASTC 是 Khronos 推出的可变块大小格式,块可以是 4×44 \times 46×66 \times 68×88 \times 8 甚至 12×1212 \times 12。块越大,比特率越低:

bppASTC=128wblock×hblockbpp_{ASTC} = \frac{128}{w_{block} \times h_{block}}

所以 4×44 \times 4 对应 8 bpp8\ \text{bpp}6×66 \times 6 约为 3.56 bpp3.56\ \text{bpp}8×88 \times 8 只有 2 bpp2\ \text{bpp}。安卓设备普遍支持 ASTC,但低端机可能只到 ETC2;iOS 从 A8 开始全系支持 ASTC。UE5.8 在项目设置里按平台覆盖压缩格式时,通常把默认移动格式设成 ASTC,再为 Mali 旧芯片 fallback 到 ETC2。

ETC2 的质量比 ASTC 差一截,但兼容性更好。我们项目最低档的移动包会额外打一份 ETC2 fallback,通过设备配置文件在运行时选择。这样同一份资源在不同芯片上走不同格式,内存和画质都能各取所需。

下面是选择压缩格式的决策流:

flowchart TD
    A["导入贴图"] --> B{"是否需要透明度"}
    B -->|"需要"| C["BC7 或 DXT5"]
    B -->|"不需要"| D{"是否是法线或灰度"}
    D -->|"法线"| E["BC5 或 BC3"]
    D -->|"灰度"| F["BC4"]
    D -->|"普通颜色"| G["BC1"]
    C --> H{"目标平台"}
    E --> H
    F --> H
    G --> H
    H -->|"PC/主机"| I["保留 BC 格式"]
    H -->|"移动"| J["ASTC 或 ETC2"]

这条流程不是教条。项目中期我们曾经把法线从 BC5 改成 BC3,内存下来不少,远处看差别很小;但近景特写角色脸上出现了轻微噪点,又调了回去。压缩格式的选择永远是在画质、内存、构建时间之间找平衡。

2 各平台压缩格式的差异

同一张贴图在不同平台最终的显存占用可能差几倍。PC 用 DirectX 时 BC 格式直接可用;Vulkan 上同样支持 BC,但某些 Android 设备驱动只暴露 ETC2/ASTC。主机平台有各自的私有格式,UE 在打包时会自动转码,开发者主要关心 Compression Settings 和 LODGroup。

PlayStation 和 Xbox 有自己的压缩路径,Unreal 在 Derived Data Cache 里会为它们生成专用 cooked 数据。普通开发者不需要手动选格式,只要保证源贴图分辨率足够、压缩设置正确即可。Switch 这类显存紧张的机器则要特别注意,BC1 和 BC4 的组合往往是性价比最高的。

移动平台还要考虑 tile-based GPU 的带宽。ASTC 的块越小,纹理缓存命中率越高;块越大,解压出来的像素颜色过渡越生硬。我们在低端安卓上把背景贴图设成 ASTC 8×88 \times 8,角色和道具保持 6×66 \times 6,UI 维持 4×44 \times 4,这样整体内存减了接近四成,画面没有明显劣化。这个策略后来被写进了项目的美术规范,每次导入移动资源都会弹窗提醒。

对于多平台项目,DDC 会按平台拆分缓存。PC、PS、Xbox、Android、iOS 各自存一份 cooked 数据,构建机硬盘要留足空间。我们曾经因为 DDC 盘满了导致 Cook 回退到原始贴图,包体瞬间涨了几百兆。后来给构建机加了 SSD,并定时清理过期 DDC,才稳定下来。

3 Texture Streaming Pool:贴图的蓄水池

UE 的贴图流送系统维护了一块显存池,叫 Texture Streaming Pool。运行时在 r.Streaming.PoolSize 里配置,单位是 MB。池子里只放当前可见、需要的 Mip 层级,而不是整张 4K 纹理的所有 Mip。

Texture Streaming 的架构可以用下面这张图概括:

flowchart TD
    disk["原始贴图资源"]
    ddc["Derived Data Cache"]
    pool["Texture Streaming Pool"]
    gpu["显存/GPU"]

原始贴图经过 Cook 生成带 Mip 的压缩数据,存进 DDC;运行时 TextureStreamer 根据相机距离、LOD、屏幕占比决定加载哪一级 Mip;真正提交给 GPU 的只是当前需要的那一小块。下面是流送一次贴图时的交互:

sequenceDiagram
    participant Cam as Camera
    participant TS as TextureStreamer
    participant DDC as DerivedDataCache
    participant VR as VideoMemory
    Cam->>TS: 提交视锥与 LOD 信息
    TS->>TS: 计算所需 Mip 层级
    TS->>DDC: 请求压缩后的 Mip 数据
    DDC-->>TS: 返回 Cooked 数据
    TS->>VR: 上传 GPU 资源
    VR-->>TS: 确认 resident
    TS->>Cam: 通知可用

当池子不够用时,UE 会按照优先级驱逐不用的 Mip,远处物体先变模糊。优先级的计算会参考屏幕占比、距离相机的远近、上一次使用时间和纹理本身的 LODGroup。被标记为 Never StreamForce Resident 的贴图不会被驱逐,所以这两个选项要慎用,否则池子里的常驻部分会越积越多。如果驱逐速度赶不上需求,就会打印 Texture Streaming Pool Over budget,极端情况导致崩溃。排查那起事故时,我们先把 r.Streaming.PoolSize 临时调到 2048,崩溃立刻消失,但这只是确认病因,不是治疗方案。真正的解法是把资源体积降下来,再让池子大小贴合目标硬件。

Pool 大小的设置要基于实际预算。可以用下面这个思路估算:

Ptarget=iwi×hi×bppi8×43×fresidentP_{target} = \sum_{i} w_i \times h_i \times \frac{bpp_i}{8} \times \frac{4}{3} \times f_{resident}

其中 wiw_ihih_i 是第 ii 张贴图在最高使用分辨率下的宽高,bppibpp_i 是压缩后的每像素比特,43\frac{4}{3} 是完整 Mip 链的近似系数,fresidentf_{resident} 是同时驻留比例,通常取 0.30.60.3 \sim 0.6

4 Mip 生成:为什么远处不糊不行

Mip 是贴图的一半尺寸层级链:4K2K1K5124K \to 2K \to 1K \to 512 \to \cdots。每往下一级面积变成四分之一,整链额外占用约三分之一。没有 Mip,远处像素会因为采样不足产生闪烁和摩尔纹。

UE 在导入时默认生成完整 Mip 链,使用盒式过滤。对法线贴图可以在纹理属性里改成 sharpen 0.5 或 1.0,让远处法线保持更多细节;对基础色通常用默认即可。Mip 层级数量由最大尺寸决定:

Nmip=log2(max(w,h))+1N_{mip} = \lfloor \log_2(\max(w, h)) \rfloor + 1

运行时,r.MipMap.LodBias 可以全局偏移 Mip 选择。r.Streaming.MipBias 则在流送阶段生效,适合在低端机器上快速降低整体纹理精度。我们曾在主机模式用 r.Streaming.MipBias -1 让贴图更锐利,在低端 PC 用 +1 省显存。

各向异性过滤会进一步影响 Mip 选择。开启 8x8x16x16x 各向异性后,倾斜表面会采样更高精度的 Mip,纹理占用随之上升。如果项目对地面和墙面开了高各向异性,就要在池子预算里留足余量。

5 UDIM 与贴图图集

角色美术通常把 UV 拆到多个象限,每个象限对应一张独立贴图,这就是 UDIM。一张脸可能占 1001,身体 1002,手臂 1003。UE 的标准材质采样器不支持直接读取 UDIM 编号,所以常见做法是合并成一张大图集(Texture Atlas),或者在 shader 里用材质函数切换 UV。

合并图集时要注意边界填充。Mipmap 生成时会在 UV 块边缘插值,如果块与块之间没有留出足够像素间隔,低 Mip 会出现颜色串扰。经验做法是相邻块之间留 2n2^n 像素的安全边,nn 由最大 Mip 层级决定。例如要支持到 64×6464 \times 64 的最低 Mip,至少需要留出 3232 像素的边界。

graph LR
    A["角色 UV 拆分"] --> B{"面数/材质分组"}
    B -->|"多组材质"| C["UDIM 多象限贴图"]
    B -->|"单材质低面"| D["单张贴图"]
    C --> E["导入 UE
合并为 Texture Atlas"] D --> E E --> F["材质采样"] F --> G["渲染"]

我们项目里的主角用了 10 张 UDIM。最初美术把每张都做成 4K,合并成图集后变成一张 8K 或两张 4K,显存反而爆炸。后来改成只把脸部和手部用 2K,躯干四肢用 1K,再拼成图集,整体降了一半多。这个案例提醒我们:UDIM 解决了 UV 空间问题,却不代表可以无脑堆高分辨率。

6 贴图导入设置

UE5.8 的纹理编辑器里, Compression Settings 决定格式,LOD Group 决定默认尺寸和池子预算,Max Texture Size 限制最大边长。对不需要 Mip 的 UI 贴图,可以取消 Mip Gen Settings 里的 FromTextureGroup,避免生成多余层级。

基础色贴图通常要勾上 sRGB,这样颜色空间才正确;法线、粗糙度、金属度这类线性数据必须取消 sRGB,否则光照计算会出错。一个小失误就可能让整张贴图在 GPU 上被错误解码。

推荐在 DefaultEngine.ini 里按平台显存配置 Pool Size:

[/Script/Engine.TextureStreaming]
r.Streaming.PoolSize=2048
r.Streaming.LimitPoolSizeToVRAM=1
r.Streaming.MaxTempMemoryAllowed=512
r.Streaming.FramesForFullUpdate=1
r.Streaming.HLODStrategy=2

[/Script/Engine.RendererSettings]
r.Streaming.UseFixedPoolSize=True

r.Streaming.LimitPoolSizeToVRAM=1 会让 UE 自动把 Pool 上限限制在显卡可用显存以内,避免在 6GB 显卡上请求 8GB 池子。r.Streaming.HLODStrategy=2 会让 HLOD 纹理优先使用更低 Mip,进一步降低远处开销。

不同设备还要用 DefaultDeviceProfiles.ini 覆盖 TextureLODGroups:

[Android_Mali DeviceProfile]
+TextureLODGroups=(Group=TEXTUREGROUP_World,MinLODSize=1,MaxLODSize=1024,LODBias=1,MinMagFilter=aniso,MipFilter=point,MipGenSettings=TMGS_SimpleAverage)

[Windows DeviceProfile]
+TextureLODGroups=(Group=TEXTUREGROUP_World,MinLODSize=1,MaxLODSize=2048,LODBias=0,MinMagFilter=aniso,MipFilter=point,MipGenSettings=TMGS_SimpleAverage)

这样同一张世界贴图在 Windows 上最高到 2K,在 Mali 安卓上最高只到 1K 且自带 1 级 bias。

7 内存预算:别等崩溃再算账

做预算不能只算贴图张数,要算每张贴图在每个平台占用多少字节。一张 4K BC7 纹理:

MBC7=4096×4096×88×4328 MBM_{BC7} = 4096 \times 4096 \times \frac{8}{8} \times \frac{4}{3} \approx 28\ \text{MB}

同尺寸 BC1 只要一半,约 14 MB14\ \text{MB};ASTC 6×66 \times 6 约为 12.4 MB12.4\ \text{MB};ASTC 4×44 \times 4 则回到约 28 MB28\ \text{MB}

我们当时列了一张表,把场景里所有贴图按用途分组,分别计算 PC、主机、高端移动、低端移动四个档位的占用。最后发现仅角色 PBR 套在低端移动上就占了 180MB,远超 128MB 的纹理池目标。于是把角色基础色降到 2K ASTC 6×66 \times 6,法线保留 BC5,整体降到 75MB 左右。

预算不是一锤子买卖。每加入一批新贴图,都应该重新跑一遍显存分析。UE 的控制台命令 stat streamingstat streamingoverview 可以实时看到 Pool 使用情况和每张贴图的驻留状态。配合 r.Streaming.DropMips 可以观察极端压力下的纹理降级效果。

7.1 一个具体计算例子

假设场景里有一张 2K 基础色、一张 2K 法线和一张 1K 粗糙度金属度合并贴图。PC 上分别用 BC1、BC5、BC4,移动上全部改成 ASTC 6×66 \times 6。那么 PC 端的贴图内存约为:

MPC=20482×48×43+20482×88×43+10242×48×4322.8 MBM_{PC} = 2048^2 \times \frac{4}{8} \times \frac{4}{3} + 2048^2 \times \frac{8}{8} \times \frac{4}{3} + 1024^2 \times \frac{4}{8} \times \frac{4}{3} \approx 22.8\ \text{MB}

移动端如果用 ASTC 6×66 \times 6,基础色和法线各约 6.2 MB6.2\ \text{MB},合并贴图约 1.55 MB1.55\ \text{MB},合计约 14 MB14\ \text{MB}。再算上 0.50.5 的驻留比例,实际峰值只有 7 MB7\ \text{MB} 左右。把这样一张表铺到整个场景,就能在立项阶段判断目标平台能不能吃得下。养成先算后加的习惯,能避免后期大规模返工。

8 性能优化:从参数到流程

那起事故之后,团队定了几条规矩:

  • 基础色默认 BC1,只有主角脸部、Logo、UI 用 BC7。
  • 法线用 BC5,远景或纯色法线可降到 BC3。
  • 粗糙度、金属度、AO 合并到一张贴图的不同通道,避免多张 BC4。
  • 移动平台默认 ASTC 6×66 \times 6,背景用 8×88 \times 8,UI 用 4×44 \times 4
  • 4K 贴图必须经过主程和美术主管双签。
  • 每个平台在 DefaultDeviceProfiles.ini 里单独设置 TextureLODGroups,限制默认最大尺寸。

除了格式和尺寸,流送参数也影响体验。r.Streaming.FramesForFullUpdate 设得太低会让每帧都重新计算流送优先级,CPU 开销变大;设得太高则切镜头时贴图跟不上。我们项目里 PC 设 1,主机设 2,移动设 4,作为折中。

还有一次移动端的内存紧张:测试机只有 4GB RAM,GPU 共享内存。纹理池默认 1000MB 直接导致系统杀进程。我们把 r.Streaming.PoolSize 压到 384,r.Streaming.MipBias 提到 1,同时把 ASTC 从 4×44 \times 4 全改成 6×66 \times 6,帧率从 22fps 回到 30fps,崩溃也消失了。美术一开始担心画质,但在手机小屏幕上几乎看不出区别。把这些规矩写进美术规范后,后续新资源的超标率明显下降,项目整体稳定不少。

8.1 打包前的检查清单

每次发版本前,我会让负责资源的同学跑一遍检查。重点看这几点:是否有 4K 贴图没有 LOD Bias;基础色是否误设成 BC7;法线是否用了 BC5 以外的格式;移动资源是否没有改成 ASTC;UI 贴图是否生成了不需要的 Mip。这些问题单看都不严重,叠加起来就会让池子爆炸。把检查流程写进 CI,每次提交自动扫描,比人工抽查靠谱得多。

另外,建议把 r.Streaming.PoolSize 的默认值写进每个目标平台的配置文件,而不是让 UE 自己猜。自动推断的池子在中低端显卡上经常偏大,反而更容易触发 Over Budget。

9 调试工具与常用命令

排查贴图问题不能靠猜。UE 提供了一整套控制台命令,下面是事故排查时我们用得最多的几条。建议把这些命令做成编辑器蓝图按钮,方便美术同学一键调用:

stat streaming          # 显示 Texture Streaming Pool 使用情况
stat streamingoverview  # 更详细的贴图列表与 Mip 状态
r.Streaming.PoolSize 2048   # 临时扩大池子,验证是否内存导致崩溃
r.Streaming.MipBias 1       # 全局降低一级 Mip,快速测试低端效果
r.MipMap.LodBias 1          # 渲染时偏移 Mip 选择

stat streaming 面板里最重要的两个数字是 Pool Size 和 Over Budget。如果 Over Budget 长期大于零,说明资源总量或池子大小不匹配。还要关注 WantedRequired 列,前者是理想状态下想要的 Mip,后者是预算内实际能留住的 Mip,两者差距越大,画面越糊。除此之外,stat streamingoverview 会把每张纹理的当前尺寸、目标尺寸、是否常驻都列出来,适合定位某张异常膨胀的贴图。

编辑器里还有一个被低估的工具:Window -> Developer Tools -> Texture Streaming Accuracy。它会用颜色覆盖画面,红色表示纹理分辨率过高、蓝色表示过低。美术可以直接在视口里看到哪些资产分辨率超标,省去了反复对比的麻烦。

另外,Texture Streaming Build 工具可以在编辑器里跑一遍静态分析,提前发现哪些贴图会被频繁请求、哪些 Mip 会被裁掉。把这份报告发给美术,比口头劝说有效得多。

10 把这件事串起来

贴图压缩与流送从来不是单点优化。导入时选对格式,决定了显存占用和画质上限;生成 Mip 让不同距离都有合适精度;Texture Streaming Pool 把显存变成一个动态水库;UDIM 与图集管理着复杂角色的 UV 与贴图组织;内存预算则是所有决策的约束条件。

那起 GTX 1660 崩溃事故最后收敛到三个改动:把基础色从 BC7 改回 BC1、把不需要 4K 的资产降到 2K、把 Pool Size 按目标显卡分级配置。改动量不大,效果立竿见影。很多问题不需要花哨的技术,只要回到链路本身,把每一步的数据算清楚,就能避免重蹈覆辙。