UE的“破坏”与“修复”:工程师的问题解决指南


Breaking (and Fixing) Unreal: An Engineer’s Guide to Problem-Solving | Unreal Fest Bali 2025

核心思想:建立系统性的问题解决思维

本讲座的目标不是修复某个特定Bug,而是分享一种真实世界的工程思维。这套思维模式用于在面临压力(如上线DDL、多平台约束)时,系统性地识别问题、用数据验证、评估风险应用调试策略

核心是从“我完全没头绪”转变为“我知道问题在哪,也知道如何修复”。


第一部分:理解技术问题的本质

1. 什么是技术问题解决?

这不仅仅是“修复Bug”。它是理解、诊断和解决复杂问题的过程,尤其是当症状不明确或具有误导性时。你需要学会使用正确的工具、提出正确的问题,并与引擎协同工作,而不是对抗它。

2. 技术问题的四大分类

几乎所有UE中的技术问题都可以归为以下四类。正确分类至关重要,因为它决定了你需要和谁一起解决问题

  1. 实现与性能问题 (Implementation & Performance)

    • 描述: 经典Bug或低效实现。例如:功能不工作、帧率下降、内存泄漏、逻辑错误。

    • 解决方案: 通常是工程师之间的工作(读代码、查日志、性能分析)。

  2. 引擎与系统集成 (Engine & System Integration)

    • 描述: 系统之间的“裂缝”导致的问题。例如:版本升级、特定平台(Console-only)的边缘情况、第三方SDK或插件冲突。

    • 解决方案: 棘手,通常涉及UE引擎源码或外部因素。同样主要由工程师解决。

  3. 技术债与架构 (Technical Debt & Architecture)

    • 描述: 长期积累的“阵痛”。代码曾经能用,但现在变得脆弱、难以调试、无法扩展

    • 解决方案: 不仅需要技术,还需要沟通。你必须去说服制作人、主管或利益相关者,告诉他们为什么需要花时间重构,以及不还债的长期成本。

  4. 范围蔓延 (Scope Creep)

    • 描述: 问题不是来自“坏掉的代码”,而是来自“不断变化的需求”。功能持续膨胀,复杂性螺旋上升,导致系统疲于奔命。

    • 解决方案: 同样需要制作人或主管介入,重新思考功能范围。

3. 如何克服“无从下手”的窘境

当遇到一个没有明确复现路径、或者发生在别人代码里、或者“时好时坏”的Bug时,人会很容易感到无助。

  • 核心原则: 不要试图一次性解决整个问题

  • 行动步骤:

    1. 分解问题 (Break it down)。

    2. 明确已知和未知 (Get clarity)。

    3. 缩小调查范围 (Narrow the scope)。

    4. 尽可能消除变量 (Eliminate variables)。


第二部分:问题解决的实战步骤与案例

步骤一:准确识别问题(定义问题)

错误诊断会浪费时间,甚至引入新Bug。在动手修复前,先通过结构化提问来**“框定”问题**:

  • 我们能稳定复现(reliably reproduce)吗?

  • 这是本地问题还是特定平台问题?(例如:只在Cooked包中出现?只在主机上出现?)

  • 它是否依赖延迟(latency)?

  • 最有用的问题: “最近有什么变动?”(代码、配置、引擎升级等)。

案例分析一:追踪“关闭时崩溃” (Shutdown Crash)

  • 问题: 游戏在关闭应用时发生崩溃。

  • 症状:

    • 发生在一个后台D3D线程上。

    • 极难复现

    • 堆栈指向 FD3D12DynamicRHI(资源清理)。

    • 堆栈信息不完整(partially unsymbolicated)。

    • 怀疑是竞态条件(Race Condition):游戏线程关闭 vs D3D后台线程关闭。

  • 数据收集与调查:

    1. 时间点? 崩溃在升级到UE 5.3.4后开始出现。

    2. 频率? 偶发,但在“高强度内部测试”时崩溃激增

    3. 硬件? 排除了硬件特定原因(RTX 2060到4080都崩)。

    4. 结论: 问题锁定在 5.3.4 版本的引擎改动窗口期。

  • 解决方案(验证关闭路径):

    1. 内存安全: 崩溃指向 RtlFreeHeap,强烈暗示二次释放 (double free)释放后使用 (use after free)。团队审计了关闭时的内存分配与销毁,特别是D3D12和RHI部分。

    2. 线程生命周期: 检查后台渲染线程如何被关闭,确保它们在引擎堆内存被撕碎前被正确Join或Terminate

    3. 工具介入: 推荐使用内存分析工具,如 ASAN (Address Sanitizer)M-Alex Stomp 来捕获这些难以察觉的内存问题。

    4. 添加验证: 引入线程守卫 (thread guards) 或调试检查,来捕获任何在资源销毁后仍试图访问它的“迟到”线程。

  • 最终成果: 团队通过这些调试手段,定位到在关闭序列中存在一次无效的内存地址读取,并成功修复。


步骤二:验证假设(不要猜测!)

  • 常见错误: “猜测”。根据症状(symptoms)而非证据(evidence)草率下结论。

  • 正确做法: 用数据验证你的假设。使用分析和调试工具获取硬证据。你必须修复真正错误的东西,而不是看起来错误的东西。

  • 核心工具:Unreal Insights

    • 一个独立的分析系统,用于收集、分析和可视化引擎发出的数据。

    • 可以通过添加自定义的 Scoped Trace Events(使用 TRACE_CPUPROFILER_EVENT_SCOPE 宏)来轻松扩展,以深入分析你自己的函数。

案例分析二:极限性能优化 (90 FPS)

  • 问题: 游戏已经很精简,但为了达到90 FPS,仍需从每帧中“抠出” 2毫秒

  • 初步分析:

    • 使用 stat unitstat fps 找到性能瓶颈区域。

    • 使用 Unreal Insights 深入分析。

    • 挑战: 没有单一的“罪魁祸首”。必须从许多不同领域中节省微小的时间。

  • 优化点 1:蓝图节点的内存陷阱

    • 问题: 滥用蓝图中的 GetComponentByClass 节点。这个节点虽然方便,但它在每次被调用时都会创建一个新数组并将组件指针复制进去(涉及堆分配)。

    • 修复:

      1. 将逻辑移到 C++

      2. 在初始化时获取一次组件。

      3. 使用 TInlineComponentArray(一个栈上数组)来避免堆分配。

      4. 对其他数组使用内存预留 (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 chaosstat chaos counters

    • 意外发现: 一个物理体_更少_的关卡,物理Tick(4ms)反而比物理体_更多_的关卡(2.66ms)更慢

    • 定位元凶: 发现了大量复杂的碰撞体巨大包围盒的物体,以及被错误标记为Movable的静态几何体

    • 搜寻工具(控制台命令):

      1. 内容浏览器搜索:Collision Prims >= 500 (找到碰撞面片过多的模型)

      2. 控制台命令:

        collision.listobjectsithcomplexcollision
        
      3. 控制台命令:

        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

    • 发现:

      1. 内存过量分配(OOM):报告显示分配了14-15GB内存,远超主机限制。

      2. 定位元凶: 追踪到是UE一个当时的实验性功能 Geometry Caches 导致的,每个实例泄漏近500MB

      3. 意外收获: 挖掘过程中还发现了引擎本身的 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字节码,搞清楚 “为什么会发生”,以及这些导数操作是怎么来的。

    • 调查步骤:

      1. 在控制台设置 r.RHI.SetGPUCaptureOptions=1 (以便Pix能抓取到标记和命名)。

      2. 使用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

  • 调试流程:

    1. 在Aftermath中打开 .gpudump 崩溃转储文件。

    2. 检查“Exception Summary”: 查看高级别错误(是GPU挂起?还是页面错误Page Fault?)。

    3. 检查“Page Fault”: 如果是内存错误,这里会显示是越界访问 (Out of Bounds) 还是 释放后使用 (Use After Free)

    4. 检查“GPU State”: 查看崩溃时GPU正在执行哪个管线阶段(如纹理采样)。

    5. 定位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)**——它可能对其他系统产生的潜在、非预期的负面影响。

  • 风险评估三要素:

    1. 可见性 (Visibility): 这个改动会被用户或客户直接看到吗?(可见性越高,风险越高)。

    2. 复杂性 (Complexity): 这段代码和多少个系统紧密耦合 (Tightly Coupled)?我们真的_完全_理解它吗?

    3. 时机 (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)。如果这个过程出错,会摧毁所有平台、所有用户的全部存档

  • 核心缓解策略(“保险库”设计):

    1. 为测试服务:

      • 在测试Build中添加了 “错误注入” (Error Injection) 来模拟各种失败。

      • 添加了强制开关,在每次启动时都执行重写(以最大化测试覆盖率)。

    2. 防崩溃/防掉电的重写逻辑:

      • 步骤 1: 加载原始存档,反序列化它,并生成一个哈希值。

      • 步骤 2: 将反序列化的数据,重新序列化(压缩)到一个临时文件 (Temporary File) 中,并为这个临时文件生成一个校验和 (Checksum)。

      • 步骤 3: 加载这个临时文件,并反序列化它。

      • 步骤 4(关键验证): 对比 “来自原始存档的数据” 和 “来自临时文件的数据” 是否完全一致。

      • 步骤 5: 仅在验证匹配后,才用临时文件覆盖原始存档,然后删除临时文件。

  • 结果: 这个流程可以抵御电源丢失、数据损坏、序列化逻辑错误。即使最坏的情况(逻辑Bug)发生,游戏可能无法启动,但玩家的原始存档数据不会丢失

案例反思 (Postmortem)

  1. 本可以实现分阶段部署 (Staggered Rollout),即使是在内测中,先只推送给一小部分用户,限制“爆炸半径”。

  2. 本可以构建更健壮的集成测试 (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个理由)

    1. 关键Bug: 你遇到了一个阻碍性 (Blocking) Bug,而Epic官方还未合并修复。

    2. 核心限制: 引擎的某个核心限制阻碍了你游戏最基本的功能

    3. 已验证的优化: 你有数据证明这是一个真实的、能带来巨大收益的性能优化。

    4. 长期分支 (Fork): 你决定维护一个长期的引擎分支(例如主机平台定制版、内部技术平台)。

  • 如果你必须修改(最佳实践):

    1. 首选插件/子系统: 永远优先尝试用插件 (Plugin)引擎子系统 (Engine Subsystem) 来实现,而不是直接改BaseEngine。

    2. 隔离: 将你的修改隔离到新文件/新类中,不要把它们_随机散布_在引擎代码各处。

    3. 注释: 在代码中清晰地注释你的每一处修改,并说明**“为什么”**你必须这么做。

    4. 分支: 维护一个干净的官方Release分支作为镜像,方便你对比差异 (diff) 和合并。

    5. 用Perforce: UE几乎所有的源码控制工具链都是为Perforce设计的,它能让这个工作流更易于管理。


第八部分:现场 Q&A

  1. Q:如何管理(Time-box)解决问题的时间?

    • A: 在领任务时(例如在Jira上)就预先设定一个时间盒(Time-box)。努力遵守它,这能锻炼你的估时能力。如果时间到了还没解决,必须上报 (Escalate) 给主管或资深工程师。这不代表你失败了,而是证明了这个问题比预想的更难,不要一个人死磕
  2. Q:存档压缩是修改引擎实现的吗?

    • A: 没有修改引擎。 标准LZ4的代码文件引擎里就有,他们把这些代码复制到了自己的插件/系统中,并在此基础上构建。永远优先尝试在不修改引擎源码的前提下解决问题。
  3. Q:你们的知识共享文化是怎样的?

    • A: 通过深入的站会 (Standups)。当有工程师解决了一个复杂问题,并用到了某个“压箱底”的工具时,他们会分享出来。如果团队对此感兴趣,就会安排一次正式的分享(比如这次讲座)。
  4. Q:能在非Nvidia硬件上运行Nvidia Aftermath吗?

    • A: (提问者问了,讲师表示要去查一下)——这强烈暗示Aftermath是Nvidia硬件专用的。
  5. Q:有Xbox开发机GPU崩溃的调试技巧吗?

    • A: 不能分享,这涉及平台保密协议 (NDA)