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)

  • 问题往往 不是代码本身坏了 ,而是 需求不断变化
  • 特性不断膨胀 → 复杂度螺旋上升 → 在所有战线上疲于奔命
  • 同样需要与产品、管理层沟通,可能涉及 重新定义范围

为什么分类如此重要?

问题类型主要协作对象核心挑战
实现与性能 / 引擎集成工程师团队技术层面的诊断与修复
技术债务 / 范围蔓延制作人、负责人、利益相关者说服他人认可问题的存在并争取修复资源

关键原则 :尽早识别问题类型,不仅在技术上走对路,还能在 团队沟通和争取支持 方面走对路。


面对令人崩溃的问题:拆解思维

当遇到让人感到无从下手的问题时(无法复现、别人的代码导致、只偶尔出现),核心原则是:

不要试图一次性解决整个问题。

具体做法:

  1. 拆解问题 (Break it down)
  2. 厘清已知与未知 ——明确我们知道什么、不知道什么
  3. 缩小调查范围 (Narrow the scope)
  4. 尽可能排除变量 (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 文件崩溃调用栈 后,通过关键提问收集更多数据:

  1. "什么时候开始的?"

    • 首次出现于 2024 年 11 月 ,恰好在 升级到 Unreal Engine 5.3.4 之后
  2. "频率如何?"

    • 间歇性发生,通常 每天 0~2 次
    • 在一次高强度内部 Playtest 期间飙升至 每天 16 次
  3. "是否与特定硬件相关?"

    • 排除了硬件原因 ——崩溃发生在从 RTX 2060 到 4080、独立显卡到集成显卡的 广泛硬件配置
  4. 将崩溃频率与升级时间线做映射

    • 确认行为始于 5.3.4 更新后 ,并 持续到 5.3.5
    • 搜索范围缩窄至 该时间窗口内的引擎变更

排查步骤二:压力测试与验证关机路径

由于无法轻松复现,目标转变为 对关机路径(Shutdown Path)进行压力测试和验证 ,聚焦三个方面:

1. 内存安全(Memory Safety)

  • 崩溃指向 RtlFreeHeap ,这通常暗示内存误用,如:
    • Double Free (重复释放)
    • Use-After-Free (释放后使用)
  • 建议措施 :审计关机相关系统(特别是 D3D12RHI )中的 内存分配与销毁模式
  • 推荐工具
    • 内存性能分析工具(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 工作流

  1. 第一步:识别慢在哪里
    • 使用控制台命令 stat unitstat fps 快速定位问题区域
  2. 第二步:深入分析
    • 对问题区域进行更深入的 Profiling,理解时间消耗是否合理
    • 关键思维 :一个功能 "贵" 并不一定是问题——只要它 值得 那个开销就行

  3. 第三步:建立基线
    • 用 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 chaosstat chaos counters 的意外发现

  • 一个 物理体更多 的关卡:物理 Tick 耗时 2.66 ms
  • 一个 物理体更少 的关卡:物理 Tick 耗时 4 ms
  • 直觉上完全相反!

深挖根因

通过 Unreal 的 Physics Views ,发现以下问题:

  1. 复杂碰撞体 (Complex Collisions)——碰撞几何体过于复杂
  2. 大包围盒物体 (Objects with Large Bounds)
  3. 静态几何体被标记为 Movable ——本应是静态的物体被错误设置

排查工具与命令

  • 内容浏览器搜索CollisionPrims >= 500 → 快速找到碰撞面数过多的资产
  • 控制台命令collision.ListObjectsWithComplexCollision + Collision Complexity 设为 UseComplexAsSimple → 找出使用高复杂度碰撞数据的资产

结果

清理这些 错误配置的资产 后,物理 Tick 时间降至预算范围内。


总结:微优化的累积威力

  • Unreal Insights 帮助团队发现并修复了 数十个微观问题
  • 这些问题 没有一个 从外部观察是显而易见的
  • 核心方法论的三大支柱:
支柱具体内容
智能 Profiling用 Insights 建立基线、定位热点、添加自定义 Trace
Blueprint 意识理解 Blueprint 节点底层的真实开销,避免隐性拷贝和分配
C++ 优化 + 内存管理栈分配优先、缓存数据、减少 Cache Miss、关注内存竞争

最终,团队通过 每次 100 纳秒的积累 ,回收了所需的全部性能——没有魔法,只有系统性的工程实践。

内存分析与诊断:MemReport


MemReport 概述

基本定位

  • Unreal 提供的 内存诊断工具 ,用于在运行时捕获项目 内存使用快照
  • 展示某一时刻内存中存在的所有内容:资产、引擎子系统、流式关卡、纹理、网格、音频
  • 类别单个对象类型 进行分类统计

使用方式

  • 控制台输入 memreport 即可触发
  • 适用于 PIEStandalone打包构建 (Packaged Build)
  • 强烈建议 加上 -full 参数,获取更详细的分类报告(包括资产流式信息等):
    memreport -full
    

与 Unreal Insights 的区别

  • MemReport 不是 可视化 Profiler——本质上是一系列 控制台命令的输出聚合 ,生成的是一份 静态报告
  • Epic 已表示正在将更多内存报告功能整合到 Unreal Insights 中,MemReport 的部分功能 不再被积极维护
  • 但它 依然非常有用 ,特别适合以下场景:

典型应用场景

场景说明
跨构建内存对比比较不同版本之间的内存消耗变化
平台内存预算检查验证各平台是否在内存限额以内
特定关卡加载后的内存分析排查加载后是否出现 内存尖峰内存泄漏
GC 卡顿排查当出现 Garbage Collection Hitches 时,定位内存压力来源

常见发现模式

  • 经常会发现某个单一类(如 UTexture2DUSkeletalMesh )占用了 远超预期的内存
  • MemReport 会给出 每个实例的具体列表 ,让你精确定位贡献最大的对象

实战案例:主机平台内存崩溃排查

问题表现

  • 主机游戏在游玩过程中出现 内存相关崩溃
  • 经典的缓慢积累模式 :开始一切正常 → 逐渐累积 → 最终崩溃
  • 没有一致的复现路径 (inconsistent repro)
  • 调用栈(Call Stack)中 没有可操作的信息

排查过程

  1. 使用 MemReport 构建内存全景图
    • 重点关注 Allocation Bins (分配区间):引擎认为自己分配了多少内存、分配在哪里
  2. 扩大开发模式内存限制
    • 主机内存 非常紧张且分段 ,开启 Larger Memory Modes 以获取崩溃前的有效内存报告
  3. 关键发现:过度分配
    • 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 捕获流程

  1. 启动 打包的 Development 版本
  2. 在捕获帧之前,设置控制台变量:
    r.RHI.SetGPUCaptureOptions 1
    
    这会导出 标记和名称 ,方便在 PIX 中追踪对应的 Draw Call
  3. 捕获帧后,在 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

排查后的结论

通过这次分析确认了:

  1. 为什么 导数操作存在? → 材质使用了 SampleBias 而非 SampleGrad
  2. 是否有视觉用途? → 需要评估
  3. 如何引入 Shader 的? → 通过材质图中的特定采样节点
  4. 替代方案是什么? → 替换为支持显式导数的采样方式

最重要的结论 :问题出在 材质设置 ,而非引擎 Bug 或误报。这同时验证了 WhatWhy ,给了团队 信心 继续进行针对性的优化。


工具选择总结

工具类型擅长领域局限
Unreal Insights实时可视化 ProfilerCPU/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、编辑器撤销操作
  • 没有明确的复现路径

排查过程

  1. 启用 -gpucrashdebugging 生成 GPU 崩溃转储
  2. 使用 Nsight Aftermath 加载转储,检查 GPU 内存寄存器 和相关数据

根因

  • 一个 Fragment Shader 试图访问 已被销毁的资源 → 触发 Page Fault Error
  • 典型的 资源生命周期管理问题 :资源在 GPU 仍在引用时被 CPU 端释放

排查方法论总结

最小化复现环境

  • 面对神秘或间歇性问题时,应 剥离到仅包含被测系统 ——尽可能 移除变量
  • 如果能在 干净的最小测试地图隔离模块 中复现,理解问题的可能性大幅提升
  • 额外好处:让 队友或 Epic 支持 更容易介入帮助

可验证性原则

如果问题不可复现、不可测量,你怎么知道自己真的修好了?

  • 无法验证的修复 不算真正的修复
  • 正确流程:
    1. 建立基线 (Baseline)
    2. 应用修改
    3. 重新检查数据 ,确认问题确实被解决
    4. 然后才能 有信心地部署更新

修复问题与评估风险:爆炸半径(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 毫秒以下的耗时 ——团队认为这是一个可接受的折中

变更三:自动重新保存所有用户存档(最高风险)

为什么需要这个变更?

  • 压缩已实现 → 用户 下次手动保存时 会自动升级为压缩格式
  • 但是 :云存储问题在用户手动重新保存每个存档之前都不会解决
  • 因此决定:每次启动游戏时(补丁后),自动对所有旧存档执行重新保存

风险评估

  • 这是三项变更中 爆炸半径最大的 ——如果出错且未在测试中捕获:

    每个平台上的每个用户的每个存档都可能被破坏


防御策略一:识别故障点 + 强化测试

  1. 识别所有可能产生损坏数据的位置 → 这些位置成为 测试时间的重点投向
  2. 错误注入 (Error Injection):模拟重新保存过程中的各种 潜在故障模式
  3. 强制每次启动都执行重新保存 (测试期间):最大化这些代码路径的 被执行次数

防御策略二:安全的重新保存流程设计

核心原则: 任何错误都必须尽可能可恢复 ,包括假设应用程序可能在过程中 崩溃或断电

具体流程:

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 源码 有时是正确的选择 ,但必须清醒认识 代价

代价说明
版本升级的合并冲突每个新引擎版本都可能引发冲突,可能需要 数小时甚至数天 的手动合并工作
团队协作摩擦定制引擎增加了所有人的工作复杂度
新人上手困难新工程师入职后需要理解自定义修改的上下文

何时修改引擎源码是合理的?

以下四种情况可以考虑:

  1. 关键 Bug 修复 :上游尚未合入,且该 Bug 阻塞了你的开发进度
  2. 核心引擎限制 :引擎本身的设计限制 阻挡了游戏的核心功能 实现
  3. 经过验证的优化 :能带来 实际的、经过测量确认的性能提升
  4. 长期引擎分支维护 :如 主机定制分支内部技术平台 ,团队承诺长期维护自定义版本

最佳实践

优先级策略(从优先到最后手段)

  1. 先尝试用 Plugin 或 Engine Subsystem 解决 ——尽量不碰基础引擎文件
  2. 只在确实必要时 才修改 Base Engine 文件
  3. 如果必须修改,尽量隔离变更 ——放入 新文件专用类 中,不要在代码库中 随处散布 修改

文档与维护

  • 记录每一处引擎修改 :维护 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 ;但部分功能如标记插入在其他硬件上可能可用但无实际效果)