UE的“破坏”与“修复”:工程师的问题解决指南
Breaking (and Fixing) Unreal: An Engineer’s Guide to Problem-Solving | Unreal Fest Bali 2025
核心思想:建立系统性的问题解决思维
本讲座的目标不是修复某个特定Bug,而是分享一种真实世界的工程思维。这套思维模式用于在面临压力(如上线DDL、多平台约束)时,系统性地识别问题、用数据验证、评估风险并应用调试策略。
核心是从“我完全没头绪”转变为“我知道问题在哪,也知道如何修复”。
第一部分:理解技术问题的本质
1. 什么是技术问题解决?
这不仅仅是“修复Bug”。它是理解、诊断和解决复杂问题的过程,尤其是当症状不明确或具有误导性时。你需要学会使用正确的工具、提出正确的问题,并与引擎协同工作,而不是对抗它。
2. 技术问题的四大分类
几乎所有UE中的技术问题都可以归为以下四类。正确分类至关重要,因为它决定了你需要和谁一起解决问题。
-
实现与性能问题 (Implementation & Performance)
-
描述: 经典Bug或低效实现。例如:功能不工作、帧率下降、内存泄漏、逻辑错误。
-
解决方案: 通常是工程师之间的工作(读代码、查日志、性能分析)。
-
-
引擎与系统集成 (Engine & System Integration)
-
描述: 系统之间的“裂缝”导致的问题。例如:版本升级、特定平台(Console-only)的边缘情况、第三方SDK或插件冲突。
-
解决方案: 棘手,通常涉及UE引擎源码或外部因素。同样主要由工程师解决。
-
-
技术债与架构 (Technical Debt & Architecture)
-
描述: 长期积累的“阵痛”。代码曾经能用,但现在变得脆弱、难以调试、无法扩展。
-
解决方案: 不仅需要技术,还需要沟通。你必须去说服制作人、主管或利益相关者,告诉他们为什么需要花时间重构,以及不还债的长期成本。
-
-
范围蔓延 (Scope Creep)
-
描述: 问题不是来自“坏掉的代码”,而是来自“不断变化的需求”。功能持续膨胀,复杂性螺旋上升,导致系统疲于奔命。
-
解决方案: 同样需要制作人或主管介入,重新思考功能范围。
-
3. 如何克服“无从下手”的窘境
当遇到一个没有明确复现路径、或者发生在别人代码里、或者“时好时坏”的Bug时,人会很容易感到无助。
-
核心原则: 不要试图一次性解决整个问题。
-
行动步骤:
-
分解问题 (Break it down)。
-
明确已知和未知 (Get clarity)。
-
缩小调查范围 (Narrow the scope)。
-
尽可能消除变量 (Eliminate variables)。
-
第二部分:问题解决的实战步骤与案例
步骤一:准确识别问题(定义问题)
错误诊断会浪费时间,甚至引入新Bug。在动手修复前,先通过结构化提问来**“框定”问题**:
-
我们能稳定复现(reliably reproduce)吗?
-
这是本地问题还是特定平台问题?(例如:只在Cooked包中出现?只在主机上出现?)
-
它是否依赖延迟(latency)?
-
最有用的问题: “最近有什么变动?”(代码、配置、引擎升级等)。
案例分析一:追踪“关闭时崩溃” (Shutdown Crash)
-
问题: 游戏在关闭应用时发生崩溃。
-
症状:
-
发生在一个后台D3D线程上。
-
极难复现。
-
堆栈指向
FD3D12DynamicRHI(资源清理)。 -
堆栈信息不完整(partially unsymbolicated)。
-
怀疑是竞态条件(Race Condition):游戏线程关闭 vs D3D后台线程关闭。
-
-
数据收集与调查:
-
时间点? 崩溃在升级到UE 5.3.4后开始出现。
-
频率? 偶发,但在“高强度内部测试”时崩溃激增。
-
硬件? 排除了硬件特定原因(RTX 2060到4080都崩)。
-
结论: 问题锁定在 5.3.4 版本的引擎改动窗口期。
-
-
解决方案(验证关闭路径):
-
内存安全: 崩溃指向
RtlFreeHeap,强烈暗示二次释放 (double free) 或 释放后使用 (use after free)。团队审计了关闭时的内存分配与销毁,特别是D3D12和RHI部分。 -
线程生命周期: 检查后台渲染线程如何被关闭,确保它们在引擎堆内存被撕碎前被正确Join或Terminate。
-
工具介入: 推荐使用内存分析工具,如 ASAN (Address Sanitizer) 和 M-Alex Stomp 来捕获这些难以察觉的内存问题。
-
添加验证: 引入线程守卫 (thread guards) 或调试检查,来捕获任何在资源销毁后仍试图访问它的“迟到”线程。
-
-
最终成果: 团队通过这些调试手段,定位到在关闭序列中存在一次无效的内存地址读取,并成功修复。
步骤二:验证假设(不要猜测!)
-
常见错误: “猜测”。根据症状(symptoms)而非证据(evidence)草率下结论。
-
正确做法: 用数据验证你的假设。使用分析和调试工具获取硬证据。你必须修复真正错误的东西,而不是看起来错误的东西。
-
核心工具:Unreal Insights
-
一个独立的分析系统,用于收集、分析和可视化引擎发出的数据。
-
可以通过添加自定义的 Scoped Trace Events(使用
TRACE_CPUPROFILER_EVENT_SCOPE宏)来轻松扩展,以深入分析你自己的函数。
-
案例分析二:极限性能优化 (90 FPS)
-
问题: 游戏已经很精简,但为了达到90 FPS,仍需从每帧中“抠出” 2毫秒。
-
初步分析:
-
使用
stat unit或stat fps找到性能瓶颈区域。 -
使用 Unreal Insights 深入分析。
-
挑战: 没有单一的“罪魁祸首”。必须从许多不同领域中节省微小的时间。
-
-
优化点 1:蓝图节点的内存陷阱
-
问题: 滥用蓝图中的
GetComponentByClass节点。这个节点虽然方便,但它在每次被调用时都会创建一个新数组并将组件指针复制进去(涉及堆分配)。 -
修复:
-
将逻辑移到 C++。
-
在初始化时获取一次组件。
-
使用
TInlineComponentArray(一个栈上数组)来避免堆分配。 -
对其他数组使用内存预留 (pre-reserving memory) 和
TInlineAllocators。
-
-
同类问题:
HasMatchingGameplayTag节点也会在每次调用时复制标签容器。 -
修复: 缓存标签容器,然后对缓存进行查询。
-
教训: “仅仅因为你在用蓝图,不代表你可以忽视内存是如何被访问和使用的。”
-
结果: 某些函数开销降低了 80%。
-
-
优化点 2:内存竞争 (Memory Contention)
-
问题: 在某些平台(CPU和GPU共享内存),访问非CPU本地内存的延迟极高。
-
证据 (Insights): 同一个函数,在GPU线程活跃时,执行时间长了10倍(10ns → 100ns)!这纯粹是由于共享资源竞争。
-
教训: 当你追求极限性能时,“每一次缓存未命中(cache miss)都很重要”。
-
修复: 缓存所有频繁访问的值(结构体、组件数组等)。构建结构体在函数间传递,而不是每次都从各个类中重新获取。
-
-
优化点 3:物理优化 (Physics Tick)
-
问题: 游戏线程 (Game Thread) 在等待物理后工作 (Post Physics Work) 时,空闲了1.6毫秒。
-
思路: 1. 塞更多工作到“物理中”阶段;2. 缩短物理Tick本身的时间。
-
调查工具:
stat chaos和stat chaos counters。 -
意外发现: 一个物理体_更少_的关卡,物理Tick(4ms)反而比物理体_更多_的关卡(2.66ms)更慢。
-
定位元凶: 发现了大量复杂的碰撞体、巨大包围盒的物体,以及被错误标记为Movable的静态几何体。
-
搜寻工具(控制台命令):
-
内容浏览器搜索:
Collision Prims >= 500(找到碰撞面片过多的模型) -
控制台命令:
collision.listobjectsithcomplexcollision -
控制台命令:
collision.complexity.UseComplexAsSimple
-
-
修复: 清理这些配置错误的资产和不必要的碰撞体。
-
结果: 物理Tick时间成功降低,达到性能预算。
-
第三部分:深入剖析:高级分析与调试工具
当 Unreal Insights 帮我们定位到“宏观”瓶颈后,我们常常需要更专门的工具来解答特定的“微观”问题,尤其是内存和GPU层面。
1. MemReport (内存快照报告)
-
核心功能: 一个控制台命令,用于在运行时抓取一份静态的内存使用快照。
-
用途: 告诉你“在这一刻,_什么_在内存里?”。它会按类别(如
UTexture2D,USkeletalMesh)和单个对象实例细分内存占用。 -
关键命令:
-
memreport:生成基础报告。 -
memreport -full:(推荐) 生成包含资产流送(asset streaming)信息在内的高精度、详细报告。
-
-
注意事项:
-
它是一个静态文本报告,不是像Insights那样的可视化、随时间变化的分析器。
-
Epic正在将更多内存分析功能迁移到
Unreal Insights中,所以memreport的某些部分可能不再积极维护,但它在特定场景下依然极其有用。
-
-
最佳应用场景:
-
对比不同Build版本之间的内存使用差异。
-
检查主机平台(Console)的严格内存预算。
-
定位特定关卡加载后出现的内存尖峰或泄漏。
-
找出“为什么某个
UTexture2D占了这么多内存?”并列出所有相关实例。
-
-
实战案例:追踪主机内存崩溃
-
问题: 游戏在主机上运行一段时间后,内存缓慢增长并最终崩溃,没有稳定复现(repro)路径。
-
分析: 在开发中启用更大的内存模式,在即将崩溃前执行
memreport -full。 -
发现:
-
内存过量分配(OOM):报告显示分配了14-15GB内存,远超主机限制。
-
定位元凶: 追踪到是UE一个当时的实验性功能 Geometry Caches 导致的,每个实例泄漏近500MB。
-
意外收获: 挖掘过程中还发现了引擎本身的 RHI Pool Allocator 中存在内存泄漏,团队将修复贡献给了Epic,并合并到了5.6版本中。
-
-
2. Microsoft Pix (DX12 渲染调试器)
-
核心功能: 微软为 DirectX12 (Windows 和 Xbox) 提供的官方性能调优和调试套件。
-
用途: 深度渲染调优。它能展示你的内容最终是如何提交给硬件的,以及 CPU 和 GPU 工作负载如何交互。它不受GPU厂商限制(Vendor Agnostic)。
-
UE集成: UE 5.6起,编辑器内有“Capture”按钮可直接启动Pix。
-
实战案例:验证 Nanite 的性能问题
-
问题: 在Nanite的“无导数操作”(
No Derivative Ops)高级视图中,场景里几乎所有物体都是红色的。 -
背景知识(关键):
-
Nanite为了实现可变速率着色 (VRS),依赖分析导数 (analytic derivatives)。
-
如果材质无法提供分析导数(例如使用了某些特定的纹理采样节点),Nanite必须回退到渲染一个 2x2 的四边形 (quad) 来手动计算导数,这会完全抵消VRS带来的性能提升。
-
-
“What” vs. “Why”:
-
Unreal (提供了 What): "No Derivative Ops" 视图告诉我们 “发生了什么” —— 几乎所有东西都在用Quad渲染。
-
Pix (提供了 Why): 我们需要Pix来检查 Shader字节码,搞清楚 “为什么会发生”,以及这些导数操作是怎么来的。
-
-
调查步骤:
-
在控制台设置
r.RHI.SetGPUCaptureOptions=1(以便Pix能抓取到标记和命名)。 -
使用Pix抓取一帧。
-
-
定位根源 (Root Cause):
-
通过检查Pix中的Shader字节码,发现材质中使用了
Texture2D_SampleBias节点。 -
Texture2D_SampleBias(坏): 这个节点需要GPU隐式计算导数,迫使Nanite使用 2x2 Quad 回退。 -
Texture2D_SampleGradient(好): 这个节点允许你显式传入导数(即分析导数),Nanite可以高效处理。
-
-
结论: Pix 100% 证实了问题出在我们的材质设置上,而不是引擎Bug。
-
3. Nvidia Nsight Aftermath (GPU 崩溃验尸)
-
核心功能: 专为Nvidia GPU设计的事后调试 (Postmortem Debugging) 工具。
-
用途: 回答“在GPU崩溃的那一刻,它到底在干什么?”。它非常轻量,甚至可以随最终版本发布。
-
最佳应用场景: 诊断那些最令人头疼、难以复现的 GPU挂起 (Hangs)、TDRs (超时检测与恢复)、黑屏或**“设备已移除” (Device Removed)** 错误。
-
启用方式:
-
启动项添加
-GPUCrashDebugging。 -
或 CVar 设置
r.GPUCrashDebugging=1。
-
-
调试流程:
-
在Aftermath中打开
.gpudump崩溃转储文件。 -
检查“Exception Summary”: 查看高级别错误(是GPU挂起?还是页面错误Page Fault?)。
-
检查“Page Fault”: 如果是内存错误,这里会显示是越界访问 (Out of Bounds) 还是 释放后使用 (Use After Free)。
-
检查“GPU State”: 查看崩溃时GPU正在执行哪个管线阶段(如纹理采样)。
-
定位Shader: Aftermath通过哈希值 (Hash) 而非文件名来识别Shader。你需要将其指向项目的Shader调试信息路径(通常在
Saved/ShaderDebugInfo)。
-
-
实战案例:追踪编辑器随机GPU崩溃
-
问题: 在编辑器中进行各种操作(移动相机、Undo、卸载World Partition)时,GPU会随机崩溃。
-
分析: 使用Aftermath加载启用的GPU Crash Dump。
-
发现: 崩溃的根源是一个片元着色器 (Fragment Shader) 试图访问一个已经被销毁 (Destroyed) 的资源,导致了 Page Fault。
-
第四部分:从诊断到解决方案
1. 隔离问题的艺术
-
核心原则: “剥洋葱”——将问题精简到只剩你正在测试的系统。尽可能移除所有变量。
-
黄金标准: 你能在一个干净、最小化的测试关卡 (Test Map) 中复现这个Bug吗?
-
为什么这至关重要?
-
易于理解: 帮助你真正看清问题的本质。
-
易于协作: 方便你的队友或Epic官方支持人员提供帮助。
-
验证修复: “如果一个问题无法复现或无法测量,你如何知道你已经修复了它?” 你必须能够验证修复是有效的。
-
2. “爆炸半径”:评估修复的风险
-
核心概念: 每一个修复都有一个**“爆炸半径” (Blast Radius)**——它可能对其他系统产生的潜在、非预期的负面影响。
-
风险评估三要素:
-
可见性 (Visibility): 这个改动会被用户或客户直接看到吗?(可见性越高,风险越高)。
-
复杂性 (Complexity): 这段代码和多少个系统紧密耦合 (Tightly Coupled)?我们真的_完全_理解它吗?
-
时机 (Timing): 我们是否临近里程碑 (Milestone) 或版本发布?(越临近发布,风险越高,因为测试时间更少)。
-
3. 针对不同风险的应用策略
-
低风险问题 (Low Risk)
-
策略: 直接修复。
-
行动: 提交更改,自己做好充分测试,确认行为符合预期。
-
-
中风险问题 (Medium Risk)
-
策略: 谨慎行事。
-
行动:
-
进行彻底的代码审查 (Code Review),让其他组员参与。
-
尽早让QA介入测试。
-
写好变更文档。
-
-
-
高风险问题 (High Risk)
-
策略: 应用缓解策略 (Mitigation Strategies)。
-
行动:
-
上报 (Escalate) 给组长或资深工程师,利用他们的经验来发现你可能遗漏的陷阱。
-
(关键技巧)使用“功能开关” (Feature Flags) 或配置开关 (Config Toggles)!
-
这允许你在生产环境中快速启用或禁用你的修复,而无需重新部署整个包,这是管理高风险变更的黄金法则。
-
-
第五部分:高风险实战:发布后修改玩家存档 (Save Game)
修改玩家存档是风险最高的操作之一,因为任何失败都可能永久性地丢失所有用户数据。
- 初始状态(“原罪”): 游戏“Day Zero”版本上线时,使用的是UE默认的基于字符串的序列化 (
FObjectNameAndStringProxyArchive)。这导致存档文件体积巨大(磁盘开销和云备份空间问题)。
变更一:字符串 → 自定义二进制 + 版本控制
-
目标: 减小文件体积。
-
实现: 切换到自定义二进制序列化,并加入了存档版本控制系统(以便系统能同时处理新旧两种格式)。
-
结果: 存档大小减少了 16.25%。
-
风险评估: 中风险。此功能在Day Zero补丁中就推送了,大部分玩家会使用新版本。QA在发布前几周进行了严格测试。
变更二:添加 LZ4 压缩(发布后)
-
目标: 进一步压缩,解决某些平台云备份空间有限的问题。
-
核心问题: 保存时的卡顿 (Hitching) 成了主要担忧。
-
陷阱:
-
UE原生的LZ4压缩默认是 LZ4 HC(高压缩率模式)。
-
这导致了**高达 3000 毫秒(3秒)**的游戏线程卡顿,无法接受。
-
尽管它实现了惊人的 14:1 压缩比。
-
-
解决方案:
-
切换到标准的 LZ4 压缩(非HC)。
-
幸运的是,
LZ4的文件已经存在于引擎中,所以实现起来很简单(见Q&A,他们没有改引擎,而是复制了这些代码到自己的插件中)。 -
权衡 (Trade-off): 压缩比从 14:1 降至 7.5:1,但卡顿时间控制在 50ms 以下。这是一个巨大的胜利。
-
变更三:自动重写(Resave)所有老存档(“巨型”风险)
-
目标: 解决云存档问题,必须让已存在的老存档也享受到压缩。
-
实现: 在玩家每次启动游戏时,自动在后台重写和压缩所有旧存档。
-
风险评估: 极高(Gargantuan)。如果这个过程出错,会摧毁所有平台、所有用户的全部存档。
-
核心缓解策略(“保险库”设计):
-
为测试服务:
-
在测试Build中添加了 “错误注入” (Error Injection) 来模拟各种失败。
-
添加了强制开关,在每次启动时都执行重写(以最大化测试覆盖率)。
-
-
防崩溃/防掉电的重写逻辑:
-
步骤 1: 加载原始存档,反序列化它,并生成一个哈希值。
-
步骤 2: 将反序列化的数据,重新序列化(压缩)到一个临时文件 (Temporary File) 中,并为这个临时文件生成一个校验和 (Checksum)。
-
步骤 3: 加载这个临时文件,并反序列化它。
-
步骤 4(关键验证): 对比 “来自原始存档的数据” 和 “来自临时文件的数据” 是否完全一致。
-
步骤 5: 仅在验证匹配后,才用临时文件去覆盖原始存档,然后删除临时文件。
-
-
-
结果: 这个流程可以抵御电源丢失、数据损坏、序列化逻辑错误。即使最坏的情况(逻辑Bug)发生,游戏可能无法启动,但玩家的原始存档数据不会丢失。
案例反思 (Postmortem)
-
本可以实现分阶段部署 (Staggered Rollout),即使是在内测中,先只推送给一小部分用户,限制“爆炸半径”。
-
本可以构建更健壮的集成测试 (Integration Testing) 来模拟完整的存/读路径。
第六部分:修复文化:创可贴 vs. 彻底重构
-
核心观点: 有时,最明智的修复不是再打一个“创可贴”,而是彻底的重新思考。
-
警惕: 快速修复会累积长期的技术债。如果一个系统频繁出问题、过度复杂,就应该考虑重构它。
-
决策点: 当维护一个系统的成本 > 替换它的成本时,就该停止打补丁,解决根本问题了。
-
务实的建议 (Pragmatic Advice): “边做边修 (Fix it as you touch it)”。
-
即使你没有时间重构整个系统,当你因为某个Bug在看这段代码时,顺手清理它。
-
哪怕只是添加更清晰的注释,也是在帮助下一个(可能是未来的你)看懂它的人。
-
第七部分:工程师的最佳实践
1. 文档:写好你的提交信息 (Commit Message)
-
黄金法则: 解释**“为什么 (Why)”,而不仅仅是“做了什么 (What)”**。
-
反面教材:
“修复了问题”、“小调整”。——这些信息是无效的。 -
正面例子:
“修复了XX崩溃,原因是玩家在A和B之间切换时,C指针变为空。通过添加D检查来解决。” -
你的提交信息是写给几年后早已忘记所有背景的团队(和你自己)看的。
2. 修改UE引擎源码(高昂的代价)
-
风险与成本:
-
合并冲突: 每次UE版本更新,你都可能要花数小时甚至数天来手动合并你的修改。
-
团队阻力: 新人上手难度增加,整个团队的工作流都变复杂了。
-
-
什么时候才值得修改引擎?(4个理由)
-
关键Bug: 你遇到了一个阻碍性 (Blocking) Bug,而Epic官方还未合并修复。
-
核心限制: 引擎的某个核心限制阻碍了你游戏最基本的功能。
-
已验证的优化: 你有数据证明这是一个真实的、能带来巨大收益的性能优化。
-
长期分支 (Fork): 你决定维护一个长期的引擎分支(例如主机平台定制版、内部技术平台)。
-
-
如果你必须修改(最佳实践):
-
首选插件/子系统: 永远优先尝试用插件 (Plugin) 或 引擎子系统 (Engine Subsystem) 来实现,而不是直接改BaseEngine。
-
隔离: 将你的修改隔离到新文件/新类中,不要把它们_随机散布_在引擎代码各处。
-
注释: 在代码中清晰地注释你的每一处修改,并说明**“为什么”**你必须这么做。
-
分支: 维护一个干净的官方Release分支作为镜像,方便你对比差异 (diff) 和合并。
-
用Perforce: UE几乎所有的源码控制工具链都是为Perforce设计的,它能让这个工作流更易于管理。
-
第八部分:现场 Q&A
-
Q:如何管理(Time-box)解决问题的时间?
- A: 在领任务时(例如在Jira上)就预先设定一个时间盒(Time-box)。努力遵守它,这能锻炼你的估时能力。如果时间到了还没解决,必须上报 (Escalate) 给主管或资深工程师。这不代表你失败了,而是证明了这个问题比预想的更难,不要一个人死磕。
-
Q:存档压缩是修改引擎实现的吗?
- A: 没有修改引擎。 标准LZ4的代码文件引擎里就有,他们把这些代码复制到了自己的插件/系统中,并在此基础上构建。永远优先尝试在不修改引擎源码的前提下解决问题。
-
Q:你们的知识共享文化是怎样的?
- A: 通过深入的站会 (Standups)。当有工程师解决了一个复杂问题,并用到了某个“压箱底”的工具时,他们会分享出来。如果团队对此感兴趣,就会安排一次正式的分享(比如这次讲座)。
-
Q:能在非Nvidia硬件上运行Nvidia Aftermath吗?
- A: (提问者问了,讲师表示要去查一下)——这强烈暗示Aftermath是Nvidia硬件专用的。
-
Q:有Xbox开发机GPU崩溃的调试技巧吗?
- A: 不能分享,这涉及平台保密协议 (NDA)。