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 纹理占用的显存约为 。角色、场景、UI 一多,显存根本扛不住。块压缩把贴图分成 的小块,每块用固定比特数编码,解压由 GPU 硬件完成,显存和带宽一起降。这个思路自 DXT 时代延续至今,在 BC 和 ASTC 里都能看到影子。
1.1 BC 系列在 PC 和主机上的分工
Unreal 中常见的 BC 格式对应如下:
| 格式 | 每像素比特 | 适用贴图 | 特点 |
|---|---|---|---|
| BC1 | 4 bpp | 基础色、无透明 diffuse | 颜色质量一般,省一半空间 |
| BC2 | 8 bpp | 需要 4-bit alpha 的颜色贴图 | alpha 精度低,已很少用 |
| BC3 | 8 bpp | 带 alpha 的基础色、UI | alpha 用线性插值,质量较好 |
| BC4 | 4 bpp | 单通道灰度,如粗糙度、高度 | 只存一个通道 |
| BC5 | 8 bpp | 法线贴图 | 存两个通道,第三个在 shader 中重建 |
| BC6H | 8 bpp | HDR 环境光、天空盒 | 支持浮点,压缩慢 |
| BC7 | 8 bpp | 高质量基础色、UI | 画质接近原图,压缩极慢 |
那次事故里,美术把基础色全设成 BC7,法线又用了 BC5,粗糙度和金属度各自占一张 BC4。看起来每张都不大,加起来却非常可观。PC 上 BC7 确实好看,但只有在近距离观察、色差明显的资产上才值得。我们更惨痛的经历是 Cook 时间:一张 4K BC7 贴图在普通工作站上要压好几分钟,CI 打包时间直接翻倍。后来主程在构建机上加了 -compress 并行任务,又把非关键资产改回 BC1,Cook 才回到可接受范围。
1.2 移动平台的 ASTC 与 ETC2
移动芯片通常用 ASTC 或 ETC2。ASTC 是 Khronos 推出的可变块大小格式,块可以是 、、 甚至 。块越大,比特率越低:
所以 对应 , 约为 , 只有 。安卓设备普遍支持 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 ,角色和道具保持 ,UI 维持 ,这样整体内存减了接近四成,画面没有明显劣化。这个策略后来被写进了项目的美术规范,每次导入移动资源都会弹窗提醒。
对于多平台项目,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 Stream 或 Force Resident 的贴图不会被驱逐,所以这两个选项要慎用,否则池子里的常驻部分会越积越多。如果驱逐速度赶不上需求,就会打印 Texture Streaming Pool Over budget,极端情况导致崩溃。排查那起事故时,我们先把 r.Streaming.PoolSize 临时调到 2048,崩溃立刻消失,但这只是确认病因,不是治疗方案。真正的解法是把资源体积降下来,再让池子大小贴合目标硬件。
Pool 大小的设置要基于实际预算。可以用下面这个思路估算:
其中 、 是第 张贴图在最高使用分辨率下的宽高, 是压缩后的每像素比特, 是完整 Mip 链的近似系数, 是同时驻留比例,通常取 。
4 Mip 生成:为什么远处不糊不行
Mip 是贴图的一半尺寸层级链:。每往下一级面积变成四分之一,整链额外占用约三分之一。没有 Mip,远处像素会因为采样不足产生闪烁和摩尔纹。
UE 在导入时默认生成完整 Mip 链,使用盒式过滤。对法线贴图可以在纹理属性里改成 sharpen 0.5 或 1.0,让远处法线保持更多细节;对基础色通常用默认即可。Mip 层级数量由最大尺寸决定:
运行时,r.MipMap.LodBias 可以全局偏移 Mip 选择。r.Streaming.MipBias 则在流送阶段生效,适合在低端机器上快速降低整体纹理精度。我们曾在主机模式用 r.Streaming.MipBias -1 让贴图更锐利,在低端 PC 用 +1 省显存。
各向异性过滤会进一步影响 Mip 选择。开启 或 各向异性后,倾斜表面会采样更高精度的 Mip,纹理占用随之上升。如果项目对地面和墙面开了高各向异性,就要在池子预算里留足余量。
5 UDIM 与贴图图集
角色美术通常把 UV 拆到多个象限,每个象限对应一张独立贴图,这就是 UDIM。一张脸可能占 1001,身体 1002,手臂 1003。UE 的标准材质采样器不支持直接读取 UDIM 编号,所以常见做法是合并成一张大图集(Texture Atlas),或者在 shader 里用材质函数切换 UV。
合并图集时要注意边界填充。Mipmap 生成时会在 UV 块边缘插值,如果块与块之间没有留出足够像素间隔,低 Mip 会出现颜色串扰。经验做法是相邻块之间留 像素的安全边, 由最大 Mip 层级决定。例如要支持到 的最低 Mip,至少需要留出 像素的边界。
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=Truer.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 纹理:
同尺寸 BC1 只要一半,约 ;ASTC 约为 ;ASTC 则回到约 。
我们当时列了一张表,把场景里所有贴图按用途分组,分别计算 PC、主机、高端移动、低端移动四个档位的占用。最后发现仅角色 PBR 套在低端移动上就占了 180MB,远超 128MB 的纹理池目标。于是把角色基础色降到 2K ASTC ,法线保留 BC5,整体降到 75MB 左右。
预算不是一锤子买卖。每加入一批新贴图,都应该重新跑一遍显存分析。UE 的控制台命令 stat streaming 和 stat streamingoverview 可以实时看到 Pool 使用情况和每张贴图的驻留状态。配合 r.Streaming.DropMips 可以观察极端压力下的纹理降级效果。
7.1 一个具体计算例子
假设场景里有一张 2K 基础色、一张 2K 法线和一张 1K 粗糙度金属度合并贴图。PC 上分别用 BC1、BC5、BC4,移动上全部改成 ASTC 。那么 PC 端的贴图内存约为:
移动端如果用 ASTC ,基础色和法线各约 ,合并贴图约 ,合计约 。再算上 的驻留比例,实际峰值只有 左右。把这样一张表铺到整个场景,就能在立项阶段判断目标平台能不能吃得下。养成先算后加的习惯,能避免后期大规模返工。
8 性能优化:从参数到流程
那起事故之后,团队定了几条规矩:
- 基础色默认 BC1,只有主角脸部、Logo、UI 用 BC7。
- 法线用 BC5,远景或纯色法线可降到 BC3。
- 粗糙度、金属度、AO 合并到一张贴图的不同通道,避免多张 BC4。
- 移动平台默认 ASTC ,背景用 ,UI 用 。
- 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 从 全改成 ,帧率从 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 长期大于零,说明资源总量或池子大小不匹配。还要关注 Wanted 和 Required 列,前者是理想状态下想要的 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 按目标显卡分级配置。改动量不大,效果立竿见影。很多问题不需要花哨的技术,只要回到链路本身,把每一步的数据算清楚,就能避免重蹈覆辙。