UE5.8 内存分析与优化:Texture Pool 爆炸的那个下午
项目进入主机认证前两周,QA 甩过来一份崩溃日志:测试机在连续游玩三十七分钟后直接退出,系统提示内存不足。策划说是刷怪波次太多,美术说是不是模型面数爆炸,程序组的老张把 crash dump 拖到 WinDbg 里看了一眼,地址空间已经冲到 5.8 GB,而这款游戏的目标主机预算只有 4.5 GB。
那天下午我们复现了三遍。每次崩溃前,控制台都会刷出一行红字:RHIOutOfMemory,后面跟着 Texture Streaming Pool Over Budget。老张打开 Memory Profiler 2,把时间轴拖到最后一刻,Texture Streaming Pool 已经吃到 2.1 GB,而 r.Streaming.PoolSize 只给了 1.4 GB。池子早就满了,引擎还在拼命往里塞高分辨率 mipmap,最后把整机内存拖垮。
这篇文章就从这次事故说起,聊 UE5.8 里怎么分析内存、怎么控制纹理池、怎么管理资产和 Pak、怎么理解 UObject 与 GC,以及那些一不留神就会漏掉的内存泄漏。
老张后来回忆说,那天最大的教训不是技术本身,而是团队一直把内存当成背景噪音。帧率、加载时间、画质优先级都排在前面,等到主机认证时才发现内存是最硬的墙。这个问题其实可以避免,只要在项目早期就建立内存基线。
1. 内存地图:先知道钱花在哪
UE5.8 把进程内存大致分成几块。不知道每一块占多少,优化就是盲人摸象。大致可以这样划分:
是引擎本身开销,包括 UObject 元数据、反射表、物理场景、导航网格、子系统这些常驻对象。 是加载到内存的资产,纹理、网格、动画、音效、材质实例都算。 主要是 RHI 资源,贴图 GPU 内存、顶点缓冲、索引缓冲、RT、深度缓冲。 是解码后的音频缓冲。 是平台强制留出的安全余量,主机上尤其严格。
flowchart TD
subgraph process ["UE5.8 进程"]
game["Game Thread"]
render["Render Thread"]
rhi["RHI Thread"]
end
subgraph memory ["内存管理"]
gm["GMalloc"]
gc["Garbage Collector"]
stream["Texture Streaming"]
end
subgraph storage ["存储层"]
pak["Pak 文件"]
chunk["Chunk 0..N"]
end
game --> gm
render --> gm
rhi --> stream
stream --> gm
gm --> gc
pak --> chunk
chunk --> gm这张图的含义很简单:所有游戏线程和渲染线程的分配都经过 GMalloc;RHI 资源走 Texture Streaming 做预算控制;Pak 和 Chunk 决定哪些资产能被加载到内存。任何一条线失控,都会把 顶破天花板。
做预算时我习惯把每一块都用一张表记下来,随版本更新。第一次填表的时候,美术以为纹理只会占几百兆,结果一统计,光是角色和武器就超过 1.8 GB。数字不会骗人,这也是内存分析最残酷的地方。
2. Memory Profiler 工具链
UE5.8 提供了好几套内存分析工具,各有侧重。最常用的是 stat memory 控制台命令,它能在运行时打印一份即时快照,包括物理内存、虚拟内存、纹理池、渲染目标、音频等大类占用。QA 在测试机上最先看到的往往就是这行命令的输出。
# 运行时查看内存概览
stat memory
# 输出详细报告到 Saved/Logs 目录
memreport -full
# 启动 LLM 低层级内存追踪(Low Level Memory Tracker)
-statnamedevents -llmmemreport -full 会把当前所有加载的 ULevel、纹理、声音、粒子系统列出来,并给出每类资产的数量和占用。这份报告特别适合在关卡切换前后各抓一次,做差值分析。
Memory Profiler 2 是带时间轴的可视化工具。它的用法是给启动参数加上 -trace=memory 或者 -trace=default,memory,然后用 Unreal Insights 打开生成的 .utrace 文件。时间轴上能看到每帧的内存曲线, spikes 往往对应关卡流送、大规模刷怪、或者过场动画播放。
老张那次先在崩溃前抓了一段 trace,切到 Memory Insights 视图,发现内存曲线在最后两分钟以大约每秒 15 MB 的速度匀速上涨。正常游戏内存应该随关卡内容波动,而不是单边向上。这个形状基本就是在告诉我们:有东西在泄漏。
Memory Profiler 2 最强大的地方不是看总量,而是下钻。点击时间轴上某个 spike,右侧会列出这一帧里新增或 retained 的 UObject 类别。如果某类对象数量在持续增长,而你觉得它不该增长,就点进列表看实例名,再用 obj refs 查引用链。这样能把“内存涨了”变成“哪个对象被谁拉住了”。
3. Texture Streaming Pool
Texture Streaming Pool 是 UE 用来限制纹理 GPU 内存占用的核心机制。所有流送纹理都会向这个池子申请空间,池子大小由 r.Streaming.PoolSize 控制,单位是 MB。池子满了之后,引擎会尝试卸载远处或不重要的 mipmap,给新的高分辨率贴图腾地方。
一个纹理加上完整 mipmap 链占用的显存大致可以估算:
和 是最高分辨率, 是每像素比特数, 是 mipmap 链的级数和。一张 2048x2048 的 DXT5 贴图,原始大小约 5.33 MB,加上 mip 链后约 7.11 MB。如果场景里同时有几百张这种贴图,池子很容易就爆了。
flowchart TD
A["请求加载 2048 纹理"] --> B{Pool 是否有足够空间}
B -->|"有"| C["加载最高分辨率 mip"]
B -->|"无"| D["按优先级排序已加载纹理"]
D --> E["丢弃低优先级 mips"]
E --> F["腾出空间"]
F --> C
C --> G["更新 GPU 资源"]
G --> H{Pool 仍超预算}
H -->|"是"| I["控制台输出 Pool Over Budget"]
H -->|"否"| J["正常渲染"]我们项目的 Texture Streaming Pool 在 PC 上设了 2 GB,在目标主机上只给了 1.4 GB。问题是美术为了展示细节,给大量道具贴了 2K 甚至 4K 纹理,而很多小物件在屏幕上只占几十个像素。池子一满,引擎开始降级远处贴图,但近景又需要高清,于是纹理在不断升级和降级之间反复横跳,CPU 和 GPU 都跟着受罪。
解决方式分三步:先把所有纹理的最大分辨率按屏幕占比重新分级,远景和微小物体降到 512;再把 r.Streaming.PoolSize 按平台拆成不同的 DefaultEngine.ini 配置;最后用 r.Streaming.MaxTempMemoryAllowed 限制流送过程中的临时内存峰值。
[/Script/Engine.RendererSettings]
r.Streaming.PoolSize=1400
r.Streaming.LimitPoolSizeToVRAM=1
r.Streaming.MaxTempMemoryAllowed=80
r.Streaming.FramesForFullUpdate=1修改后重新跑测试,Texture Streaming Pool 稳定在 1.2 GB 左右,主机再没出现 OOM。
4. 资产内存占用
纹理只是资产的一部分。Static Mesh、Skeletal Mesh、动画序列、音效、材质实例、Niagara 系统都会占内存。资产管理的核心原则是:只加载当前需要的,加载进来的要尽快释放。
静态网格的内存主要由 LOD0 顶点数、UV 通道、顶点色、碰撞体决定。一个 10 万面带 4 个 UV 通道和顶点色的角色模型,可能轻松超过 50 MB。如果同屏同时加载十几个这种角色,内存压力会非常大。LOD 和 Nanite 能缓解这个问题,但 Nanite 本身也有开销,不能无脑开。
动画资产通常是内存大户之一。动画序列按骨骼数量和帧率采样,数据量是:
是每条骨骼曲线每个采样的字节数。对于 100 根骨骼、每秒 30 帧、持续 3 秒的动画,压缩前能到几十 MB。UE 的 Additive 动画和 BlendSpace 还会额外生成采样表。做动画压缩、减少关键帧、使用 Anim Compression 插件,是降低这块占用的常规手段。
音效方面,Voices 和 Sound Waves 解码后都会进入内存。长循环背景音乐如果直接加载为 Sound Wave,会长期占用大量空间。改成 Streaming Sound Wave,按流式播放,可以把常驻内存降到几百 KB。
材质和 Niagara 也不能忽视。材质实例如果大量动态生成,会占用 UObject 和渲染状态双重开销。Niagara 系统里的 GPU 粒子模拟数据、绑定到骨骼的粒子、以及大量同时播放的特效,都可能在战斗高潮时把显存顶上去。优化思路是统一使用 Material Instance Dynamic 的池化,减少运行时创建;对 Niagara 的 emitters 做 LOD 和最大粒子数限制,并确保特效结束后正确销毁组件。
FStreamableManager 是控制资产加载释放的关键工具。不要在构造函数里直接 LoadObject 然后永久持有,应该按关卡或按玩法阶段请求和释放。内存峰值的差异,往往就来自这里。
// 不推荐:永久把资产钉在内存
static ConstructorHelpers::FObjectFinder<UTexture2D> MyTex(TEXT("/Game/Textures/T_Rare.T_Rare"));
// 推荐:按阶段异步加载,用完后释放句柄
TSharedPtr<FStreamableHandle> Handle = Streamable.RequestAsyncLoad(AssetPath);
// ... 使用资产 ...
Handle->ReleaseHandle();5. Pak 文件与 Chunk
现代游戏很少把所有资产打进一个 Pak。UE 的 Cooked Content 会按 Chunk 切分,Patch 和 DLC 也依赖这套机制。Pak 文件过大,不仅会增加下载和安装时间,还会在挂载和加载时一次性占用大量内存。
Pak 加载的基本流程是:启动时挂载 Pak 文件,建立文件索引;请求资产时,根据路径找到对应 Chunk,把数据读入内存;解压后再提交给资产系统。如果 Pak 里的单个文件特别大,或者一个 Chunk 包含了太多无关资产,解压和反序列化的瞬时内存就会冲高。
graph LR
A["游戏启动"] --> B["挂载 Pak 索引"]
B --> C{请求资产}
C --> D["定位 Chunk"]
D --> E["磁盘读取"]
E --> F["解压"]
F --> G["反序列化为 UObject"]
G --> H["提交到内存"]
H --> I{资产不再使用}
I -->|"是"| J["GC 回收"]
I -->|"否"| C我们项目最初把 0 号 Chunk 做成了通用包,里面塞了所有基础 UI 纹理、角色、武器和大量通用材质。结果每次启动 0 号 Chunk 就要占掉 1.1 GB 内存,而当前关卡其实只需要其中一小部分。后来把 Chunk 按关卡和玩法模式重新拆分:0 号只放引擎和 UI 必需资源,1 号放主城,2 号放战斗关卡,3 号放过场动画。拆分后启动内存降到 600 MB 以下,关卡切换时的内存波动也小了很多。
UE5.8 默认使用 IoStore,它比传统 Pak 更细粒度,可以按文件做依赖分析和 Chunk 分配。如果是老项目迁移,建议用 AssetManager 的 ChunkAssignment 功能重新规划。DLC 和补丁也更容易控制。
6. UObject 生命周期与 GC
UE 的 UObject 生命周期由引用链决定。一个对象只要还位于根集 RootSet,或者能被 RootSet 中的对象通过 UPROPERTY 引用链到达,GC 就不会回收它。理解这一点,比记住 NewObject 和 SpawnActor 的签名更重要。
sequenceDiagram
participant A as "Gameplay 代码"
participant O as UObject
participant R as RootSet
participant G as "Garbage Collector"
A->>O: NewObject / SpawnActor
A->>R: AddToRoot
Note over O: 被 RootSet 强引用
A->>O: 通过 UPROPERTY 建立引用
A->>R: RemoveFromRoot
Note over O: 仍可通过 UPROPERTY 到达
A->>O: 清除 UPROPERTY 引用
G->>G: Mark Phase 标记可达对象
G->>O: 不可达,标记为待回收
G->>G: Sweep Phase 回收内存AddToRoot 会把对象钉在根集,常用于工具类对象或者全局单例。如果不配对调用 RemoveFromRoot,这个对象会永生,直到进程结束。这在编辑器插件和测试代码里特别常见。
UPROPERTY() 宏标记的指针、TArray、TMap 都会成为 GC 引用链的一部分。反过来,原始指针、TWeakObjectPtr、TSoftObjectPtr 不会阻止回收。如果用一个裸指针缓存了 UObject,而真正的引用被清掉,这个裸指针就会变成悬空指针,访问时直接崩溃。
GC 默认是增量式的,由 GEngine->GcTimeBetweenPurgesPendingKillObjects 控制频率。频繁创建和销毁 UObject 会带来 GC 压力,表现为周期性的卡顿。对象池能缓解这个问题,但对象池本身也要小心别把对象一直钉在 RootSet 里。
7. 常见内存泄漏
那次主机 OOM 查到最后,真正的元凶不是纹理池,也不是 Pak 拆分,而是一个漏注销的委托。战斗系统在每个 AI 死亡时注册了一个 OnActorDestroyed 委托,用来统计击杀数。但代码路径里有一个提前 return 分支没有解绑,导致死去的 AI 一直留在委托的引用链里。每个 AI 连带它的 Mesh、材质、动画实例都无法被 GC,三十分钟积累下来,内存就这样一点一点被吃光。
常见的 UObject 泄漏根因可以归为几类:
- 未解绑的委托和事件。Timer、Tick、Subsystem 事件订阅后没有 Remove。
- 被 RootSet 永久钉住的对象。
AddToRoot后没有RemoveFromRoot。 - 静态容器或全局单例持有 UObject。比如一个静态 TMap 缓存了所有加载过的 Widget。
- 循环引用。两个 Actor 互相持有对方的
UPROPERTY引用,即使外部引用断了,GC 也会认为它们互相可达。 - World Context 泄漏。在切换关卡或 Streaming Level 卸载后,某些逻辑还在引用旧 World 里的对象。
排查泄漏的一个有效方法是 obj list 控制台命令。它按类列出当前所有 UObject 实例数量和内存占用。对比关卡切换前后的输出,数量只增不减的类就很可疑。
# 列出所有 UObject,按类统计
obj list
# 只列出纹理
obj list class=Texture2D
# 查看某个类的实例引用链
obj refs name=MyLeakingActor_C_0obj refs 会输出一个对象的完整引用链,顺着链往上找,通常能定位到是哪一个 RootSet 对象或者静态容器在把它拉住。
8. 内存预算与平台差异
不同平台的内存预算天差地别。PC 可能 16 GB 起步,高端主机 16 GB 共享内存,但系统要扣掉 OS、后台服务、渲染保留,留给游戏的往往只有 4 GB 到 8 GB。移动端更紧,一款中档手机留给游戏的可能不到 2 GB。
制定预算时,不要把整机内存当可用内存。公式可以写成:
是显示缓冲和渲染保留, 是平台认证要求的安全余量。主机厂商通常会给出明确的 值,比如 200 MB 或 300 MB,超过就过不了认证。
UE5.8 里可以用 FPlatformMemory::GetStats 拿到当前平台内存状态,也可以在项目设置里按平台配置 MemoryBudget。更细粒度的控制可以通过自定义 FGenericPlatformMemory 的派生或者 LLM 标签来实现。
平台差异还体现在分配器行为上。Windows 使用 FMallocBinned2,主机和移动平台通常使用平台专用分配器。它们的对齐要求、碎片策略、大块分配阈值都不一样。在 PC 上测出来没问题,到主机上可能因为碎片导致无法分配连续大块内存而崩溃。
我们的做法是给每个主要模块设定上限:纹理池 1.4 GB、动画和蒙皮网格 600 MB、音频 200 MB、物理和导航 300 MB、引擎和子系统 400 MB,余量 200 MB。每次打包主机版本前,QA 会用固定场景跑内存曲线,任何一类超过预算都会自动标红。
具体操作上,PC 平台我们放宽到 8 GB 总预算,允许纹理池 2 GB,因为这样能支撑 4K 分辨率的显示输出。主机平台则严格按照厂商文档留余量,总预算压到 4.5 GB。移动平台更极端,会把高分辨率贴图在打包阶段直接 cook 成平台专用尺寸,纹理池控制在 600 MB 以内。同一个项目,三套 ini 配置和三套 cook 规则,分别对应三种完全不同的内存策略。
9. 优化策略
把前面的内容串起来,UE5.8 的内存优化可以概括为几个 actionable 方向。
纹理方面:按平台限制最大分辨率,用 Texture Streaming 而不是全部加载最高 mip,关闭不必要贴图的 mip 链,合并小贴图到 atlas,严控 UI 全屏大图。
资产方面:用 FStreamableManager 按阶段异步加载,关卡切换时主动释放句柄,避免在构造函数里永久 LoadObject,对动画启用压缩并减少采样率,长音效改成流式。
Pak 与 Chunk:把通用资源和高频关卡资源分开,按玩法模式拆 Chunk,利用 IoStore 做细粒度管理,避免 0 号 Chunk 过大,补丁和 DLC 单独成包。
GC 与 UObject:配对使用 AddToRoot 和 RemoveFromRoot,所有 UObject 引用都用 UPROPERTY 或智能指针,委托订阅后必须有解绑路径,避免静态容器长期持有对象,定期检查 obj list。
工具流:把 stat memory、memreport -full、Memory Profiler 2 纳入日常测试,每个大版本发布前跑一次固定场景的内存曲线, spike 必须解释清楚。
内存优化不是一次性任务。每次新增大系统、新关卡、新资产,都要重新跑基线。我们曾经在一个版本里加了全局天气系统,看起来只是多了几张噪声纹理,结果把整个纹理池推高了 300 MB。后来改成按区域流送,才压回预算。
那次 Texture Pool 爆炸的事故最后成了我们内存规范的起点。两个月后,同一套主机版本在相同场景下连续运行两小时,内存曲线稳定在 3.6 GB 左右,峰值 3.9 GB,距离 4.5 GB 的硬顶还有一段安全距离。老张在群里只发了一句话:今晚不用加班了。