UE的“破坏”与“修复”:工程师的问题解决指南
Breaking (and Fixing) Unreal: An Engineer’s Guide to Problem-Solving | Unreal Fest Bali 2025
演讲背景与核心主题
- 演讲者 Thomas ,来自 Prismatic Studios (位于新西兰奥克兰的 Unreal Engine 技术咨询与协作开发工作室),担任工作室总监兼 Unreal 软件工程师
- 该工作室是 Epic 金牌服务合作伙伴 (Gold Service Partner),与 Epic Pro Support 紧密合作,涉及领域包括游戏、工具工程、模拟仿真、实时动画、虚拟制片等
- 演讲核心:不仅仅是 "修 Bug",而是建立一套 系统性的问题排查方法论 ——从识别问题、用数据验证假设、评估风险,到在真实生产压力(交付期限、多平台、客户项目影响)下实施调试策略
四大问题类型分类
将 Unreal 中遇到的几乎所有技术问题归纳为 四个大类 ,识别类型能帮助你找到 正确的解决路径 和 需要协作的人群 。
1. 实现与性能问题(Implementation & Performance Issues)
- 经典的 Bug 或效率低下 问题
- 典型表现:帧率下降( Frame Drops )、 内存泄漏 (Memory Leaks)、逻辑错误等
- 特点:系统本身构建方式有问题——要么不工作,要么工作得很差
- 解决方式:通常与其他工程师直接协作,阅读代码、查日志、做 Profiling 、单步调试引擎源码
2. 引擎与系统集成问题(Engine & System Integration)
- 发生在 不同系统之间的交互边界
- 典型触发场景:
- 引擎版本升级
- 平台特定的边界情况 (platform-specific edge cases)
- 第三方 SDK 或插件 引入的问题
- 特点:往往涉及 Unreal 内部实现 或你自身代码库之外的外部因素,排查难度较高
3. 技术债务与架构问题(Technical Debt & Architecture Issues)
- 长期积累的痛点
- 表现:曾经能用,但现在变得 脆弱、难以调试、无法扩展
- 特点:缓慢积累,直到在压力下 集中爆发
- 解决挑战:不仅要技术上修复,还需要 说服制作人、负责人、利益相关者 投入时间进行重构——你需要能清晰阐述 长期成本 (long-term cost)
4. 范围蔓延(Scope Creep)
- 问题往往 不是代码本身坏了 ,而是 需求不断变化
- 特性不断膨胀 → 复杂度螺旋上升 → 在所有战线上疲于奔命
- 同样需要与产品、管理层沟通,可能涉及 重新定义范围
为什么分类如此重要?
| 问题类型 | 主要协作对象 | 核心挑战 |
|---|---|---|
| 实现与性能 / 引擎集成 | 工程师团队 | 技术层面的诊断与修复 |
| 技术债务 / 范围蔓延 | 制作人、负责人、利益相关者 | 说服他人认可问题的存在并争取修复资源 |
关键原则 :尽早识别问题类型,不仅在技术上走对路,还能在 团队沟通和争取支持 方面走对路。
面对令人崩溃的问题:拆解思维
当遇到让人感到无从下手的问题时(无法复现、别人的代码导致、只偶尔出现),核心原则是:
不要试图一次性解决整个问题。
具体做法:
- 拆解问题 (Break it down)
- 厘清已知与未知 ——明确我们知道什么、不知道什么
- 缩小调查范围 (Narrow the scope)
- 尽可能排除变量 (Eliminate variables)
目标:从 "完全不知道怎么回事" 推进到 "我知道问题是什么,也知道怎么修"。
准确识别与界定问题
为什么"准确识别"是第一步?
- 在 Unreal 这样的复杂系统中,误诊 非常容易发生
- 如果一开始就修错方向,不仅浪费时间,还可能 引入新的 Bug
界定问题的关键提问清单
当你怀疑存在问题时,应 退后一步 ,用结构化的问题来定义其范围:
- 能否在内部可靠地复现? (Can we reliably reproduce it?)
- 是否为局部 / 特定平台问题? (Local or platform-specific?)
- 是否仅在 Cooked Build 中出现? (Cooked builds only?)
- 是否仅限主机平台? (Console only?)
- 是否依赖网络延迟? (Latency-dependent?)
- ⭐ 系统最近发生了什么变化? (What changed recently?)—— 新代码、配置更改、引擎升级等
演讲者强调,最后一个问题 "最近改了什么" 往往是最有用的线索入口。
案例分析:排查不可复现的关机崩溃
这是一个来自真实项目的案例,展示了完整的问题排查流程。
问题描述
- 一款 已发行 的游戏在 应用关机(Shutdown) 时崩溃
- 崩溃发生在 后台 Direct3D 线程 上
- 不影响实际游戏玩法 ,但影响 崩溃统计数据 和应用的 感知稳定性
核心难点
| 难点 | 具体情况 |
|---|---|
| 不可复现 | 崩溃非常难以稳定重现 |
| 堆栈不完整 | 调用栈指向 FD3D12DynamicRHI 系统(资源清理阶段),但核心堆栈 部分未符号化 (partially unsymbolicated) |
| 疑似时序问题 | 可能是 游戏线程关机 与 后台 D3D 线程 之间的 竞态条件 (Race Condition) |
排查步骤一:收集定向信息
在分析了提供的 dump 文件 和 崩溃调用栈 后,通过关键提问收集更多数据:
-
"什么时候开始的?"
- 首次出现于 2024 年 11 月 ,恰好在 升级到 Unreal Engine 5.3.4 之后
-
"频率如何?"
- 间歇性发生,通常 每天 0~2 次
- 在一次高强度内部 Playtest 期间飙升至 每天 16 次
-
"是否与特定硬件相关?"
- 排除了硬件原因 ——崩溃发生在从 RTX 2060 到 4080、独立显卡到集成显卡的 广泛硬件配置 上
-
将崩溃频率与升级时间线做映射
- 确认行为始于 5.3.4 更新后 ,并 持续到 5.3.5
- 搜索范围缩窄至 该时间窗口内的引擎变更
排查步骤二:压力测试与验证关机路径
由于无法轻松复现,目标转变为 对关机路径(Shutdown Path)进行压力测试和验证 ,聚焦三个方面:
1. 内存安全(Memory Safety)
- 崩溃指向
RtlFreeHeap,这通常暗示内存误用,如:- Double Free (重复释放)
- Use-After-Free (释放后使用)
- 建议措施 :审计关机相关系统(特别是 D3D12 和 RHI )中的 内存分配与销毁模式
- 推荐工具 :
- 内存性能分析工具(Memory Profiling Tools)
- ASAN (Address Sanitizer,地址消毒器)—— 捕获常规手段不可见的内存问题
- Malloc Stomp —— Unreal 内置的内存调试分配器,可选启用
2. 线程生命周期(Thread Lifecycle)
- 审查后台线程(尤其是渲染相关线程)的 关机方式
- 确保这些线程在引擎或堆内存被拆除(torn down)之前已被 正确 Join 或终止
- 建议措施 :引入 线程守卫 (Thread Guards)或 调试检查 (Debug Checks),捕获任何 延迟的或不安全的内存访问
3. 工具支撑(Tooling)
- 即使无法复现崩溃,仍可在关机流程中 植入验证逻辑 ,让问题在发生时被捕获
- 确保没有后台线程在资源被销毁后仍在访问它们
最终结果
通过以上额外的调试手段,团队成功识别出:
在关机序列中确实存在一次 对无效内存地址的读取操作 (attempt to read an invalid memory address)
问题得以修复。✅
本节方法论总结
本节展示的 系统性排查流程 可提炼为:
怀疑问题 → 结构化提问界定范围 → 收集定向数据(时间线/频率/硬件)
→ 缩窄搜索范围 → 聚焦验证方向(内存/线程/工具)
→ 植入检测机制 → 定位根因 → 修复
核心心法 :
- 先理解,再动手修 —— 不要急于写修复代码,先确保你理解了问题
- 数据驱动 —— 用 Profiling 数据和时间线映射来验证假设,而非凭直觉
- 即使不能复现,也要主动创造捕获条件 —— 通过 ASAN、Thread Guards 等工具让隐藏问题自己暴露出来
验证假设与性能分析(Unreal Insights 实战)
核心原则:用数据验证,而非凭直觉猜测
- 经验不足的团队最常犯的错误之一是 "猜测式开发" ——根据症状而非证据跳到结论
- 面对棘手 Bug 时,必须做到:
- 验证你对问题的假设 (Validate your assumptions)
- 确认根因 (Confirm root causes)
- 使用 Profiling 和调试工具获取硬证据
- 目标:不是修 看起来 有问题的东西,而是修 真正 有问题的东西
真实案例背景
- 一个 多平台项目 ,不同平台分别需要达到 90 / 60 / 30 FPS
- 必须对 Profiling 策略 非常有针对性 ——结合 Unreal 自带工具 和 平台特定分析器 ,精确定位哪些系统在拖慢性能,以及 为什么
- 没有数据支撑,可能花好几天优化错误的方向,或完全漏掉关键问题
Unreal Insights 概述
定位
- 几乎所有 Unreal 性能分析的 起点
- 一个 独立的 Profiling 系统 ,与 Unreal 引擎集成,负责 采集、分析、可视化 引擎发射的数据
- 核心用途:识别性能瓶颈
可扩展性:自定义 Trace 事件
- Unreal Insights 不仅覆盖引擎现有系统,还允许你 添加自己的 Profiling 数据
- 通过 Scoped Trace Events (作用域追踪事件),可以深入到具体函数内部,理解其开销
- 实现方式:只需 几个宏 (macros),即可标记引擎代码和 Gameplay 代码
- 这些事件 无缝集成 到 Unreal Insights 的 Trace 系统中
基本 Profiling 工作流
- 第一步:识别慢在哪里
- 使用控制台命令
stat unit或stat fps快速定位问题区域
- 使用控制台命令
- 第二步:深入分析
- 对问题区域进行更深入的 Profiling,理解时间消耗是否合理
-
关键思维 :一个功能 "贵" 并不一定是问题——只要它 值得 那个开销就行
- 第三步:建立基线
- 用 Insights 对游戏各阶段建立 基线数据 (baseline),作为对比参照
实战:识别四大瓶颈
通过 Insights 建立基线后,团队识别出以下 四个主要瓶颈 :
| 瓶颈领域 | 具体问题 |
|---|---|
| 预算计算逻辑 (Budgeting Calculations) | 用于功能优先级排序的计算本身占用了可观的帧时间 |
| AI 控制器 (AI Controller) | AI 决策逻辑开销显著 |
| 动画蓝图 + Control Rig + 角色移动代码 | 动画与角色运动系统消耗了大量帧时间 |
| 物理 Tick | 仅物理更新就占用了近 4 毫秒 |
没有"银弹"的困境
- 好消息:项目已做过许多正确的优化——Tick 设置已优化,不需要 Tick 的 Actor 已停止 Tick
- 坏消息:没有单一的"大问题" 可以一修就好
- 需要在 许多不同领域各削减一点点时间 ,而非指望单次大优化
- 要达到 90 FPS 目标,仍需回收 约 2 毫秒 的帧时间——在已经很精简的项目上,这是一个巨大的挑战
优化策略一:Blueprint 节点的隐性开销
问题:Get Component by Class 的重复拷贝
- 这个 Blueprint 节点 表面上很方便
- 但底层实现是:每次调用都创建新数组,并拷贝组件指针
- 如果在 Tick 中频繁调用 → 大量不必要的 堆内存分配 和 数据拷贝
修复方案
- 将逻辑 迁移到 C++
- 只获取一次 组件引用
- 使用
TInlineComponentArray——在 栈上 分配内存,避免堆分配开销 - 对数组使用 预分配 (Pre-reserve)和
TInlineAllocator,尽量把内存保持在栈上
问题:HasMatchingGameplayTag 的重复查询
- 在 Blueprint 中多次调用
HasMatchingGameplayTag(通过IGameplayTagAssetInterface) - 每次调用都会 拷贝所拥有的 Tags 容器 或执行复杂逻辑
修复方案
- 一次性获取 所有 Tags,缓存 起来
- 后续查询都针对 缓存 进行
- 结果:显著的速度提升
关键教训
即使在 Blueprint 中,也不能忽略内存访问与使用方式。
- Unreal Insights 能帮你定位函数开销,但你仍需 深入 Blueprint 节点内部 ,理解底层 Helper 函数到底做了什么
- 通过以上优化,部分函数的开销降低了 80%
- 每一个小优化单独看微不足道,但 累积效果 在严苛的目标帧率下至关重要
优化策略二:内存竞争(Memory Contention)
问题:CPU-GPU 共享内存带来的延迟
- 在某些平台上,CPU 和 GPU 共享内存
- 访问 非 CPU 本地的内存 会带来显著的延迟开销
- 即使很小的内存相关优化,也能产生 远超预期的性能提升
Insights 中观察到的关键现象
- 同一函数、同一代码路径、同一工作量
- 当 GPU 线程活跃时,耗时从 10 纳秒 → 100 纳秒 ,增加了 10 倍
- 原因纯粹是 共享资源的竞争 (contention over shared resources)
修复方案
- 缓存频繁访问的值 :结构体、组件数组等
- 构建数据捆绑结构体 :在函数间传递时,把相关数据打包在一起,避免每次都从各个类中分散获取
-
追逐 1-2 毫秒的帧时间时,每一次 Cache Miss 都很重要
优化策略三:物理 Tick 与线程空闲
问题:Game Thread 等待物理完成
- 虽然许多 Actor 已被移到 During Physics Tick 阶段执行
- 但 Game Thread 仍有 1.6 毫秒的空闲时间 ,等待开始 Post Physics 工作
两个探索方向
| 方向 | 策略 |
|---|---|
| 填充空闲 | 将更多工作移入 During Physics Tick 阶段,利用那 1.6ms 空闲 |
| 缩短物理 Tick | 减少物理 Tick 本身的耗时 |
使用 stat chaos 和 stat chaos counters 的意外发现
- 一个 物理体更多 的关卡:物理 Tick 耗时 2.66 ms
- 一个 物理体更少 的关卡:物理 Tick 耗时 4 ms
- 直觉上完全相反!
深挖根因
通过 Unreal 的 Physics Views ,发现以下问题:
- 复杂碰撞体 (Complex Collisions)——碰撞几何体过于复杂
- 大包围盒物体 (Objects with Large Bounds)
- 静态几何体被标记为 Movable ——本应是静态的物体被错误设置
排查工具与命令
- 内容浏览器搜索 :
CollisionPrims >= 500→ 快速找到碰撞面数过多的资产 - 控制台命令 :
collision.ListObjectsWithComplexCollision+ Collision Complexity 设为UseComplexAsSimple→ 找出使用高复杂度碰撞数据的资产
结果
清理这些 错误配置的资产 后,物理 Tick 时间降至预算范围内。
总结:微优化的累积威力
- Unreal Insights 帮助团队发现并修复了 数十个微观问题
- 这些问题 没有一个 从外部观察是显而易见的
- 核心方法论的三大支柱:
| 支柱 | 具体内容 |
|---|---|
| 智能 Profiling | 用 Insights 建立基线、定位热点、添加自定义 Trace |
| Blueprint 意识 | 理解 Blueprint 节点底层的真实开销,避免隐性拷贝和分配 |
| C++ 优化 + 内存管理 | 栈分配优先、缓存数据、减少 Cache Miss、关注内存竞争 |
最终,团队通过 每次 100 纳秒的积累 ,回收了所需的全部性能——没有魔法,只有系统性的工程实践。
内存分析与诊断:MemReport
MemReport 概述
基本定位
- Unreal 提供的 内存诊断工具 ,用于在运行时捕获项目 内存使用快照
- 展示某一时刻内存中存在的所有内容:资产、引擎子系统、流式关卡、纹理、网格、音频 等
- 按 类别 和 单个对象类型 进行分类统计
使用方式
- 控制台输入
memreport即可触发 - 适用于 PIE 、 Standalone 、 打包构建 (Packaged Build)
- 强烈建议 加上
-full参数,获取更详细的分类报告(包括资产流式信息等):memreport -full
与 Unreal Insights 的区别
- MemReport 不是 可视化 Profiler——本质上是一系列 控制台命令的输出聚合 ,生成的是一份 静态报告
- Epic 已表示正在将更多内存报告功能整合到 Unreal Insights 中,MemReport 的部分功能 不再被积极维护
- 但它 依然非常有用 ,特别适合以下场景:
典型应用场景
| 场景 | 说明 |
|---|---|
| 跨构建内存对比 | 比较不同版本之间的内存消耗变化 |
| 平台内存预算检查 | 验证各平台是否在内存限额以内 |
| 特定关卡加载后的内存分析 | 排查加载后是否出现 内存尖峰 或 内存泄漏 |
| GC 卡顿排查 | 当出现 Garbage Collection Hitches 时,定位内存压力来源 |
常见发现模式
- 经常会发现某个单一类(如
UTexture2D或USkeletalMesh)占用了 远超预期的内存 - MemReport 会给出 每个实例的具体列表 ,让你精确定位贡献最大的对象
实战案例:主机平台内存崩溃排查
问题表现
- 主机游戏在游玩过程中出现 内存相关崩溃
- 经典的缓慢积累模式 :开始一切正常 → 逐渐累积 → 最终崩溃
- 没有一致的复现路径 (inconsistent repro)
- 调用栈(Call Stack)中 没有可操作的信息
排查过程
- 使用 MemReport 构建内存全景图
- 重点关注 Allocation Bins (分配区间):引擎认为自己分配了多少内存、分配在哪里
- 扩大开发模式内存限制
- 主机内存 非常紧张且分段 ,开启 Larger Memory Modes 以获取崩溃前的有效内存报告
- 关键发现:过度分配
- MemReport 显示系统分配了 14~15 GB 内存 ,已经 超出该主机的内存上限
- 明确判定为 内存过度分配 (Overallocation)
根因追踪
- 追溯到一个当时处于 实验阶段的引擎功能 —— Geometry Caches (几何缓存)
- 该功能在项目中 严重泄漏内存 ,每个实例泄漏接近 500 MB
- 一旦确认问题出在大块分配上,后续追踪变得容易许多
额外收获:引擎级 Bug 修复
- 在排查过程中,还发现了 引擎自身 在 RHI Pool Allocator 中的一个 内存泄漏
- 该 Bug 一直存在到 UE 5.6 Preview 版本
- 团队向 Epic 提交报告并贡献了修复代码 → 被合入 5.6 正式版
-
这不仅是项目级的胜利,更是 MemReport 帮助 改善引擎本身 的典型案例
渲染调试与性能调优:Microsoft PIX
PIX 概述
基本定位
- Microsoft 出品的 性能调优与调试套件 ,面向 Windows 和 Xbox 上使用 DirectX 12 的游戏开发者
- 即使 Unreal 对 DX12 做了大量抽象封装,PIX 仍能让你看到 内容如何到达硬件 的底层细节
核心优势
| 特点 | 说明 |
|---|---|
| 厂商无关 (Vendor Agnostic) | 适用于所有支持 DX12 的 GPU |
| UE 5.6 原生集成 | 编辑器内提供 Capture 按钮 ,可直接启动 PIX 捕获会话 |
| GPU/CPU 协同分析 | 深入理解 GPU 和 CPU 工作负载之间的交互 |
| Shader 字节码检查 | 可直接检查着色器内部指令 |
实际用法倾向
- 不仅用于 "调试 GPU 问题",更常用于 排除 GPU 侧问题 (rule out GPU-side issues)——即确认问题 不在 GPU 侧
实战案例:验证 Nanite 导数操作(Derivative Ops)性能问题
背景知识:Nanite 与导数运算
- 传统光栅化 中, DDX / DDY 指令 (屏幕空间导数)由 GPU 硬件自动处理
- Nanite 不同——它尽可能使用 解析导数 (Analytic Derivatives)
- 当解析导数 不可用 时,Nanite 必须退回到 2×2 Quad 渲染 来计算 DDX/DDY
- 这会 影响可变速率着色 ( Variable Rate Shading, VRS ),降低 VRS 的性能收益
问题发现
- 在项目优化过程中,启用 Nanite 的
No Derivative Ops高级可视化模式后发现:- 场景中 几乎所有物体 都显示为 红色 (意味着使用了导数操作)
- 仅极少数网格显示正常
- 虽然离帧率目标已很接近,不是致命问题,但这意味着:
- 要么引擎的 检测系统有误
- 要么材质确实 导致 Nanite 对所有绘制使用了 Quad 渲染 ,完全抹杀了 VRS 的性能提升
Unreal 可视化 vs PIX 深度分析
核心方法论 :Unreal 的可视化模式擅长回答 "What" (发生了什么),PIX 擅长回答 "Why" (为什么发生)
| 层次 | 工具 | 能回答的问题 |
|---|---|---|
| What (症状) | Unreal No Derivative Ops View Mode | 哪些绘制调用使用了导数操作 |
| Why (根因) | Microsoft PIX | 导数指令是如何被引入 Shader 的、来自哪个材质图 |
PIX 捕获流程
- 启动 打包的 Development 版本
- 在捕获帧之前,设置控制台变量:
这会导出 标记和名称 ,方便在 PIX 中追踪对应的 Draw Callr.RHI.SetGPUCaptureOptions 1 - 捕获帧后,在 PIX 中选择具体的 Draw Call,查看绑定的 Shader 字节码
关键发现:采样函数的差异
问题的根源在于材质中 纹理采样方式 的不同:
| 采样函数 | 导数处理方式 | Nanite 行为 | 可视化颜色 |
|---|---|---|---|
Texture2DSampleGrad (Sample Gradient) | 显式传入导数 (Explicit Derivatives) | 使用 解析导数 ,无需 Quad 渲染 | 蓝色 ✅ |
Texture2DSampleBias (Sample Bias) | 隐式计算导数 (Implicit Derivatives) | 必须退回 2×2 Quad 渲染 | 红色 ❌ |
场景中三个测试物体的对比
| 物体位置 | 使用的采样方式 | 结果 |
|---|---|---|
| 左侧 | Texture2DSampleBias (如三面投影材质) | 红色——触发 Quad 渲染 |
| 中间 | Texture2DSampleGrad + 解析导数 | 蓝色——正常使用 VRS |
| 右侧 | 两个材质混用 Bias 和 Gradient | 部分红色 |
技术细节补充
- Nanite Base Pass 运行在 Compute Queue 上(基于 Compute Shader),因此在 PIX 中需要在 Compute 管线下检查绑定的 Shader
- 在 Shader 字节码中可以直接看到
CalculatePixelMaterialInputs_AnalyticDerivatives函数内部调用的是SampleBias还是SampleGrad
排查后的结论
通过这次分析确认了:
- 为什么 导数操作存在? → 材质使用了
SampleBias而非SampleGrad - 是否有视觉用途? → 需要评估
- 如何引入 Shader 的? → 通过材质图中的特定采样节点
- 替代方案是什么? → 替换为支持显式导数的采样方式
最重要的结论 :问题出在 材质设置 ,而非引擎 Bug 或误报。这同时验证了 What 和 Why ,给了团队 信心 继续进行针对性的优化。
工具选择总结
| 工具 | 类型 | 擅长领域 | 局限 |
|---|---|---|---|
| Unreal Insights | 实时可视化 Profiler | CPU/GPU 帧时间分析、自定义 Trace | 内存报告功能仍在完善中 |
| MemReport | 静态内存快照 | 内存预算检查、泄漏定位、跨构建对比 | 非可视化、部分功能不再积极维护 |
| Microsoft PIX | 底层 GPU/CPU 调试器 | DX12 Shader 字节码检查、Draw Call 级分析、排除 GPU 侧问题 | 仅限 DX12 平台(Windows/Xbox) |
GPU 崩溃调试:NVIDIA Nsight Aftermath
工具定位
- NVIDIA 出品的 GPU 崩溃调试工具 ,面向使用 GeForce GPU 的 Windows 开发者
- 核心目的:追踪 GPU 崩溃发生那一刻 GPU 究竟在做什么
- 以 轻量级 C++ 库 的形式集成,Unreal Engine 原生支持
- 开销极低,甚至 可以随最终发行版一起发布
与其他 Profiling 工具的关键区别
| 常规 Profiling 工具 | Nsight Aftermath |
|---|---|
| 分析游戏 正常运行时 的表现 | 专注于 事后诊断 (Postmortem Debugging) |
| 帧率、耗时、瓶颈 | 黑屏、Device Removed 错误、GPU 挂起(Hang)、TDR |
TDR (Timeout Detection and Recovery):Windows 检测到 GPU 长时间无响应后强制重置,是出了名的 难以复现、难以排查 的问题类型。
核心能力
- 捕获 详细的 GPU 崩溃转储 (GPU Crash Dump)
- 支持在渲染管线中 插入自定义标记 (Markers),为排查 GPU 挂起提供 面包屑线索
- 将崩溃映射回 具体的 Shader 代码
使用流程
1. 启用 GPU 崩溃调试
两种方式任选其一:
- 命令行参数 :
-gpucrashdebugging - 项目配置 :
r.GPUCrashDebugging=1
2. 加载崩溃转储到 Nsight(GPU Crash Dump Inspector)
第一步:查看异常摘要(Exception Summary)
- 在 Dump Info 选项卡下查看 高层概览
- 确定崩溃类型:
- GPU 挂起 (Hung)
- Page Fault (页错误)
- Shader 相关崩溃
第二步:分析页错误(如果是内存故障)
- 在 Crash Info 选项卡下的 Page Fault 部分
- 关键信息:
- Fault Type :错误类型
- Access Type :访问类型(如 "读操作时虚拟地址转换失败")
- Resource History :资源详情,包括大小、 是否已被标记为 Destroyed
第三步:检查 GPU 状态
- GPU State 部分显示崩溃时 GPU 正在执行的操作
- 可以揭示 管线中哪个阶段处于活跃状态 (如纹理采样)
第四步:追踪到具体 Shader
- Aftermath 通过 代码哈希 (Code Hash)而非文件名来标识 Shader
- 需要配置 Shader 二进制文件的搜索路径
- Unreal 项目中,Shader 调试信息通常位于:
Saved/ShaderDebugInfo/<TargetPlatform>/ - 包含 HLSL 文件 和元数据,用于将崩溃映射回具体 Shader 代码
实战案例:编辑器中的随机 GPU 崩溃
问题表现
- 使用编辑器时出现 看似随机的 GPU 崩溃
- 触发场景极其多样:空闲状态、移动摄像机、卸载 World Partition、编辑器撤销操作
- 没有明确的复现路径
排查过程
- 启用
-gpucrashdebugging生成 GPU 崩溃转储 - 使用 Nsight Aftermath 加载转储,检查 GPU 内存寄存器 和相关数据
根因
- 一个 Fragment Shader 试图访问 已被销毁的资源 → 触发 Page Fault Error
- 典型的 资源生命周期管理问题 :资源在 GPU 仍在引用时被 CPU 端释放
排查方法论总结
最小化复现环境
- 面对神秘或间歇性问题时,应 剥离到仅包含被测系统 ——尽可能 移除变量
- 如果能在 干净的最小测试地图 或 隔离模块 中复现,理解问题的可能性大幅提升
- 额外好处:让 队友或 Epic 支持 更容易介入帮助
可验证性原则
如果问题不可复现、不可测量,你怎么知道自己真的修好了?
- 无法验证的修复 不算真正的修复
- 正确流程:
- 建立基线 (Baseline)
- 应用修改
- 重新检查数据 ,确认问题确实被解决
- 然后才能 有信心地部署更新
修复问题与评估风险:爆炸半径(Blast Radius)
核心理念
- 找到根因只是 战斗的一半 ,交付一个 可靠的修复 才是最终目标
- 每一个修复都有 "爆炸半径" (Blast Radius)——即该变更对其他系统可能产生的 潜在影响或波及范围
- 即使出发点最好的修复,也可能 意外破坏代码库的其他部分
风险评估的四个关键维度
| 维度 | 核心问题 | 风险逻辑 |
|---|---|---|
| 可见性(Visibility) | 用户/客户是否会直接看到这个问题或修复? | 越可见 → 出错后果越严重 |
| 复杂度(Complexity) | 被修改的代码/系统与其他系统的 耦合程度 如何? | 耦合越紧密 → 小改动也可能引发连锁反应 |
| 理解深度(Understanding) | 我们是否 完全理解 这个系统的工作方式? | 理解不充分 → 意外副作用 的概率上升 |
| 时间节点(Timing) | 是否临近 里程碑或重大发布 ? | 周期末尾的变更风险更高——测试和修复新问题的时间更少 |
按风险等级选择修复策略
低风险(Low Risk)
- 特征 :影响范围小,对修复方案有信心
- 策略 :
- 直接修改
- 自行充分测试 ,确认行为符合预期、无副作用
中风险(Medium Risk)
- 特征 :涉及较复杂或用户可见的系统
- 策略 :
- 进行 充分的测试
- 让 其他团队成员 Code Review
- 尽早 引入 QA 团队
- 记录文档 ——便于后续追踪变更历史
高风险(High Risk)
- 特征 :爆炸半径大,或涉及关键系统
- 策略 :
- 向上升级 :将修复方案提交给 Tech Lead 或高级工程师 审查,利用经验发现潜在陷阱
- 使用 Feature Flag / 配置开关 (Configuration Toggles):
- 控制修复的灰度发布
- 无需完整发版即可 快速启用或禁用 变更
- 在生产环境中 管理风险 的利器
案例分析:发售后修改玩家存档系统
背景与风险定位
- 有些引擎变更相对温和,但 玩家存档数据 属于 影响最大、风险最高 的类别
- 发售后修改存档结构,一旦出错可能 严重损害用户体验 ,甚至导致 所有存档数据丢失
本案例的三项变更
| 变更编号 | 内容 | 风险等级 |
|---|---|---|
| ① | 从 字符串序列化 转为 自定义二进制序列化 + 版本控制 | 中 |
| ② | 为存档文件添加 LZ4 压缩 | 中 |
| ③ | 添加代码路径,在启动时 自动重新保存所有用户存档 以启用压缩 | 极高(灾难级) |
第三项变更的 "爆炸半径" (Blast Radius)最大——如果失败,可能导致 每个平台上每个用户的每个存档全部损坏 。
变更一:字符串序列化 → 自定义二进制序列化
问题根源
- Unreal 默认使用
FObjectAndNameAsStringProxyArchive进行存档序列化,将数据以 类似字符串的结构 写入磁盘 - 这会导致 显著的磁盘空间浪费 ,存档文件膨胀
- 问题在于: Day Zero 版本已经以这种格式上线 → 无法保证所有用户存档都不是旧格式
解决方案
- 利用 Unreal 的 Archive 版本控制系统 (Archive Versioning System),让存档系统 同时兼容新旧两种格式
- 加载时根据版本号判断格式,分别走不同的反序列化路径
效果
- 存档文件大小缩减至原始大小的 83.75% (减少 16.25% )
- 部分数据 故意未转换 ——因为该数据不是每次加载都会被序列化,转换可能在后续引入新问题
- 变更在发布前 数周提交给 QA 团队 进行严格测试
- 作为 Day Zero Patch 的早期变更上线 → 大多数后续存档会是新版本格式
变更二:添加 LZ4 压缩
问题触发
- 发售后发现,某些平台的 云存储空间有限 ,当前存档大小成为实际问题
- 不想将整个存档转为完全自定义格式 → 选择 探索压缩方案
首选方案:LZ4
- LZ4 以 压缩速度快 著称,团队的首要关注点是 存档时的卡顿 (Save Hitching)
踩坑:Unreal 内置 LZ4 的陷阱
- Unreal 原生提供的 LZ4 实际上是 LZ4 HC(High Compression) ,且使用 最大压缩等级
- 结果:
| 指标 | LZ4 HC(最大压缩) | 标准 LZ4 |
|---|---|---|
| 压缩比 | 14:1 (非常优秀) | 7.5:1 (约为一半) |
| 压缩耗时 | 高达 3000 毫秒 (3 秒卡顿) | 低于 50 毫秒 |
- 在某些平台上,如果能放到 后台线程 执行,3 秒可能勉强可接受
- 但部分目标平台 物理核心数不足 ,无法将这项工作分流 → 导致 3 秒游戏线程阻塞 ,完全不可接受
最终方案
- 使用标准 LZ4 替代 LZ4 HC
- LZ4 的源文件 已经存在于引擎中 ,获取非 HC 版本很简单
- 7.5:1 的压缩比 + 50 毫秒以下的耗时 ——团队认为这是一个可接受的折中
变更三:自动重新保存所有用户存档(最高风险)
为什么需要这个变更?
- 压缩已实现 → 用户 下次手动保存时 会自动升级为压缩格式
- 但是 :云存储问题在用户手动重新保存每个存档之前都不会解决
- 因此决定:每次启动游戏时(补丁后),自动对所有旧存档执行重新保存
风险评估
- 这是三项变更中 爆炸半径最大的 ——如果出错且未在测试中捕获:
每个平台上的每个用户的每个存档都可能被破坏
防御策略一:识别故障点 + 强化测试
- 识别所有可能产生损坏数据的位置 → 这些位置成为 测试时间的重点投向
- 错误注入 (Error Injection):模拟重新保存过程中的各种 潜在故障模式
- 强制每次启动都执行重新保存 (测试期间):最大化这些代码路径的 被执行次数
防御策略二:安全的重新保存流程设计
核心原则: 任何错误都必须尽可能可恢复 ,包括假设应用程序可能在过程中 崩溃或断电 。
具体流程:
1. 加载原始存档文件
2. 为原始文件生成 Hash
3. 反序列化原始存档
4. 重新序列化到 **临时位置** (生成 Checksum)
5. 加载临时文件 → 反序列化
6. 将临时文件的反序列化结果与原始存档的反序列化结果 **逐一比对**
7. 如果完全一致 → 用临时文件 **覆盖原始存档**
8. 清理临时数据
原始文件 ─→ [反序列化] ─→ [重新序列化] ─→ 临时文件
│ │
└────── 比对(Deserialized) ───┘
│
匹配? ─→ 是 → 覆盖原始 → 清理临时
└──→ 否 → 保留原始不动(不破坏数据)
该模式能抵御的故障类型
| 故障类型 | 恢复能力 |
|---|---|
| 断电 / 应用崩溃 | ✅ 原始文件未被修改(写入的是临时位置),重启后重试 |
| 数据损坏 | ✅ Hash / Checksum 校验失败 → 放弃写入,保留原始存档 |
| 序列化逻辑 Bug | ⚠️ 游戏可能无法正常启动,但 存档数据不会丢失 |
回顾反思:可以做得更好的地方
1. 分阶段灰度发布(Staggered Rollout)
- 即使是 内部构建 ,也应先对 一小部分用户 验证,而非一次性推送给所有人
- 限制爆炸半径 :问题出现时影响面可控
2. 更健壮的集成测试
- 构建能模拟 完整 Save/Load 全流程 的自动化集成测试
- 帮助 更早发现 破坏性问题
3. 更充裕的测试时间
- 不仅是功能测试,还需要针对 平台特定行为 (Platform-Specific Quirks)的测试
- 尤其是围绕 文件 I/O 的边界情况
- 但现实是游戏已经上线,时间永远不够
关键经验总结
存档系统的发售后修改是最危险的变更之一 ——必须以 "假设一切都会出错" 的心态来设计防御机制:不可变原始数据、临时写入、校验比对、错误注入测试,缺一不可。
重构、技术债与代码规范
核心理念:最聪明的修复不一定是再打一个补丁
- 如果一个系统 不稳定、过度复杂、频繁出错 ,再次打补丁可能只是 推迟了不可避免的崩溃
- 这是系统需要被 重构或重写 的信号,而不仅仅是"再修一下"
- 快速修复 看似高效,但可能持续增加 长期技术债务 (Technical Debt),特别是在缺乏充分测试的情况下
何时该停止打补丁?
当 维护一个系统的成本 超过了 替换它的成本 时,就该停止贴创可贴、开始解决根因了。
现实约束下的折中策略
- 不是每个项目都负担得起完整重构
- 即便如此,团队应坚持 "摸到就顺手改" 原则( Fix it as you touch it ):
- 无法重写整个系统?那至少 在你接触它时做些清理
- 哪怕只是 补充注释 ,让下一个阅读这段代码的人日子好过一点
文档与提交规范
为什么文档如此重要?
- 你写的代码和提交记录 不仅是给自己看的 ,是给 整个团队 看的
- 更关键的是:给 几年后的开发团队 看的——那时原始上下文早已消失
- 后来的工程师可能完全不知道:
- 为什么要加这个检查?
- 最初是什么 Bug 导致了这个修改?
好的 Commit Message vs. 坏的 Commit Message
| 类型 | 示例 | 问题 |
|---|---|---|
| ❌ 坏 | "Fixed issue" / "Minor tweaks" | 没有解释、没有可追溯性、无法判断修复是否有效 |
| ✅ 好 | 包含 为什么 改、改了什么、影响范围 | 有上下文、有追溯能力 |
核心原则
记录"为什么"(Why),而不仅仅是"改了什么"(What)。 未来的你和你的队友会感谢你。
- 对 变更列表 (Change Lists / Changelists)要 刻意认真 对待
- 避免把无关变更混入同一个提交(如修崩溃的提交中混入了关卡内容修改,让人无法判断关卡变更是否是预期行为)
修改虚幻引擎源码的权衡与规范
核心问题:引擎源码修改的代价
修改 Unreal Engine 源码 有时是正确的选择 ,但必须清醒认识 代价 :
| 代价 | 说明 |
|---|---|
| 版本升级的合并冲突 | 每个新引擎版本都可能引发冲突,可能需要 数小时甚至数天 的手动合并工作 |
| 团队协作摩擦 | 定制引擎增加了所有人的工作复杂度 |
| 新人上手困难 | 新工程师入职后需要理解自定义修改的上下文 |
何时修改引擎源码是合理的?
以下四种情况可以考虑:
- 关键 Bug 修复 :上游尚未合入,且该 Bug 阻塞了你的开发进度
- 核心引擎限制 :引擎本身的设计限制 阻挡了游戏的核心功能 实现
- 经过验证的优化 :能带来 实际的、经过测量确认的性能提升
- 长期引擎分支维护 :如 主机定制分支 或 内部技术平台 ,团队承诺长期维护自定义版本
最佳实践
优先级策略(从优先到最后手段)
- 先尝试用 Plugin 或 Engine Subsystem 解决 ——尽量不碰基础引擎文件
- 只在确实必要时 才修改 Base Engine 文件
- 如果必须修改,尽量隔离变更 ——放入 新文件 或 专用类 中,不要在代码库中 随处散布 修改
文档与维护
- 记录每一处引擎修改 :维护 Change Log ,在代码注释中说明 为什么必须修改引擎
- 维护一个干净的引擎 Release 版本镜像分支 (Clean Mirror Branch):
- 用于 对比 Diff
- 极大简化版本升级时的 合并工作
存档系统案例的补充说明
- 演讲者的存档系统改造(LZ4 压缩等) 没有修改引擎源码
- 做法是:发现引擎中存在所需功能但被锁定时 → 将其复制到自己的 Plugin 中 → 基于此二次开发
-
推荐策略 :如果引擎中已有你需要的功能但无法直接使用,复制到自己的 Plugin 中,基于引擎已有系统构建解决方案
版本控制工具建议
- 强烈推荐 Perforce :Unreal 几乎所有源码控制相关工具和工作流都是 以 Perforce 为核心设计的
- Git 可以用,但在 Unreal 源码级工作中 管理起来会困难得多
Q&A 环节要点整理
Q1:如何在时间压力下排查问题?(Time Boxing 策略)
核心方法:时间盒(Time Box)
- 在 接手任务之前 就分配好你愿意花在排查上的最长时间
- 可以直接写在 Jira 卡片 或项目管理系统上
- 如果到达时间上限仍未解决 → 升级问题 (Escalate):
- 交给 Lead / Senior 工程师
- 交给 制作人
- 某些情况下甚至 联系 Epic 官方支持
为什么要这么做?
- 如果问题简单,你大概率会在预算时间内解决 → 超时本身就是"这个问题比预想复杂"的 信号
- 不要无限期地对着一个问题硬磕 ——这最终会损害 生产路线图 和团队进度
- 随着经验积累,你对"问题可能需要多长时间"的 直觉判断 会越来越准
Q2:存档压缩是否修改了引擎源码?
- 没有修改引擎源码
- 做法:将引擎中已有的 LZ4 功能 复制到自己的系统 / Plugin 中 ,在此基础上进行定制
- 再次强调推荐策略:先尝试不修改引擎源码 ,如果引擎有你需要的功能但"锁着",就复制到自己的 Plugin 中使用
Q3:关于工具知识的团队分享文化
Prismatic Studios 的做法
- 没有特别正式的培训体系,主要通过 深入的 Standup 会议
- 某个工程师遇到复杂问题并使用了不常用工具时 → 在 Standup 中分享
- 如果团队对某工具有足够兴趣 → 安排更正式的 工具展示会 (Tool Showcase)
工具分享的关键
- 分享时最重要的不仅是 怎么用 (How),而是 为什么选择这个工具 (Why)
- 理解 "Why" 能让其他工程师在遇到类似场景时 有信心自主拿出该工具
- 例如:知道 MemReport 能打印静态内存报告是一回事;理解 "在什么情况下静态内存快照特别有价值" 才是真正让你将其纳入工具箱的关键
Q4:Xbox 上的 GPU 崩溃调试
- 涉及 平台 NDA ,无法在公开演讲中分享
- 需在确认双方 NDA 都到位后才能讨论
Q5:NVIDIA Aftermath 能否在非 NVIDIA 硬件上运行?
- 演讲者 不确定 ,需要进一步确认
- (注:根据 NVIDIA 官方文档,Aftermath SDK 的 GPU 崩溃转储功能 需要 NVIDIA GPU ;但部分功能如标记插入在其他硬件上可能可用但无实际效果)