游戏引擎中的着色器卡顿问题与 Unreal Engine 的解决方案

Game engines and shader stuttering: Unreal Engine's solution to the problem


一、着色器编译卡顿的本质

1.1 什么是着色器编译卡顿(Shader Compilation Stuttering)

  • 核心现象: 当渲染引擎在绘制某个物体 前一刻 才发现需要编译一个新的 Shader,整个渲染流程就会 阻塞等待 驱动程序完成编译,导致帧时间剧烈飙升,玩家感知为 卡顿(Hitching/Stuttering)
  • 这不是偶发 bug,而是由 GPU 程序的编译机制 从根本上决定的结构性问题

1.2 GPU 编译与 CPU 编译的关键差异

对比维度CPU 程序GPU 程序(Shader)
指令集数量每个平台通常只有 1~2 种(x64、ARM 等)不同厂商、不同代次的 GPU 指令集 各不相同
二进制兼容性极强(10 年前编译的 x64 程序今天仍能运行)极弱 ——AMD 编译的二进制无法在 NVIDIA 上运行,同厂商不同代次也可能不兼容
分发方式直接分发 机器码可执行文件分发 中间表示/字节码 ,运行时再由驱动翻译为本机 GPU 机器码
  • 中间表示(Intermediate Representation / Bytecode): 高级着色器语言(如 HLSL )先被编译为一套由 3D API 定义的抽象指令集字节码,而非直接生成 GPU 机器码。
    • DXBC → Direct3D 11
    • DXIL → Direct3D 12
    • SPIR-V → Vulkan
  • 这种模式类似于 Java 字节码 ——一次编译,由各平台的 JVM/驱动在运行时翻译执行。
  • 游戏发行时只需要携带 一份 Shader 字节码库 ,而不需要为每种 GPU 架构各生成一份。

1.3 问题是如何恶化的

  1. 早期: Shader 数量少、逻辑简单,字节码→机器码的翻译 代价可忽略
  2. GPU 性能提升后:
    • Shader 代码量 急剧膨胀
    • 驱动为了生成更高效的机器码,引入了 复杂的优化变换
    • 运行时编译开销变得 不可忽视
  3. Direct3D 11 时代达到临界点 ——卡顿问题严重到必须在 API 层面寻找解决方案。

二、管线状态对象(Pipeline State Object, PSO)

2.1 GPU 管线状态的组成

渲染一个物体不仅仅需要 Shader,还需要一系列 GPU 配置共同定义 管线状态(Pipeline State)

  • 着色器组合: 顶点着色器(Vertex Shader)、像素着色器(Pixel Shader)等协同工作
  • 固定功能状态:
    • 剔除模式(Culling Mode)
    • 混合模式(Blend Mode)
    • 深度/模板比较模式(Depth & Stencil Comparison Modes)
    • 等等

2.2 旧 API 的致命缺陷(D3D11 / OpenGL)

  • 允许 逐项、任意时机 地修改管线状态的各个部分。
  • 后果: 驱动只有在游戏真正 发出 Draw Call 的那一刻 ,才能看到完整的管线配置。
  • 某些固定功能设置 会影响最终的可执行 Shader 代码 (例如不同的混合模式可能导致 Shader 变体不同)。
  • 因此驱动 可能不得不在处理 Draw Call 时才开始编译 Shader
  • 单个 Draw Call 的编译延迟可达 数十毫秒甚至更多 ,造成首次使用某 Shader 时的严重卡顿。

2.3 现代 API 的解决思路(D3D12 / Vulkan)

  • 引入 Pipeline State Object(PSO) 概念。
  • 核心设计: 开发者必须将一次 Draw Call 所需的 全部 Shader + 全部状态设置 打包到一个 PSO 中,以 整体 形式提交。
  • 关键优势:PSO 可以在任意时刻创建 ——不必等到绘制前。
    • 理论上引擎可以在 加载阶段 就提前创建所有需要的 PSO。
    • 驱动拿到完整状态信息后即可开始编译,在渲染开始前完成所有编译工作
  • 这就是 PSO Precaching(PSO 预缓存) 策略的理论基础。

2.4 PSO 的核心价值总结

旧模型(D3D11):  状态零散设置 → Draw Call 时才拼装完整 → 驱动被迫即时编译 → 卡顿
新模型(D3D12/Vulkan):  所有状态预打包为 PSO → 可提前创建 → 编译提前完成 → 无卡顿

一句话理解: PSO 将"驱动什么时候能看到完整管线信息"这个时间点,从 Draw Call 发出时 提前到了 开发者可以自由控制的任意早期时刻 ,从而把编译工作从渲染热路径中移出。

理论与实践的鸿沟:PSO 组合爆炸与 UE 的预缓存方案


一、为什么"提前编译所有 PSO"在实践中行不通

1.1 PSO 组合爆炸问题

Unreal Engine 拥有强大的 材质编辑系统(Material Authoring System) ,美术可以创建数千种材质。每种材质又会因以下因素产生大量 Shader 变体

  • 网格类型差异: 同一材质渲染在 静态网格(Static Mesh)蒙皮网格(Skinned Mesh)样条网格(Spline Mesh) 上时,需要 不同的顶点着色器
  • Shader 自由组合: 同一个顶点着色器可以搭配 多种不同的像素着色器
  • 管线状态排列组合: 以上每种组合再乘以不同的 管线状态设置 (混合模式、深度模式等)。

结果: 一个大型游戏的 PSO 全排列可达 数百万种 。如果在加载阶段全部编译,加载时间将长达 数小时 ,内存占用也完全不可接受。

1.2 运行时实际使用量远小于全集

  • 实际运行中只会使用到这个巨大组合空间中 极小的子集
  • 但仅凭 孤立地分析单个材质 ,无法确定哪些 PSO 会被用到。
  • 这个子集还会 随会话变化:
    • 玩家修改 画质设置 → 开启/关闭某些渲染特性 → 不同的 Shader 或管线状态被启用
    • 不同关卡、不同角色皮肤、不同玩家行为都会改变实际用到的 PSO 集合

二、旧方案——捆绑 PSO 缓存(Bundled PSO Cache)

2.1 工作原理

早期的 D3D12 引擎实现依赖以下 离线发现手段 来收集运行时会遇到的 PSO:

  • 人工 Playtest (手动测试游戏流程)
  • 自动关卡飞行遍历(Automated Level Fly-throughs)
  • 其他类似的录制/发现方法

收集到的 PSO 数据随游戏一起打包分发,引擎在 启动或关卡加载阶段 将这些已知 PSO 提前编译好。Unreal Engine 称之为 "Bundled PSO Cache" ,在 UE 5.2 之前 一直是官方推荐的最佳实践。

2.2 捆绑缓存的局限性

局限说明
采集成本高需要大量人力/自动化资源来遍历游戏内容
维护负担重内容变更后缓存 必须重新采集更新
动态世界覆盖不全若物体根据 玩家行为动态切换材质 ,录制流程可能 遗漏 相关 PSO
缓存膨胀若会话间差异大(多地图、多皮肤),缓存会包含 远超单次游玩所需 的 PSO
UGC 不友好用户生成内容(User-Generated Content)需要 每个体验单独采集缓存 ,将负担转嫁给内容创作者

典型反面案例—— Fortnite: 作为拥有大量地图、角色皮肤和 UGC 的游戏,Fortnite 几乎 命中了捆绑缓存的所有缺陷


三、新方案—— PSO 预缓存(PSO Precaching,UE 5.2+)

3.1 核心思路

目标: 支持大型、多样化的游戏世界和用户生成内容,无需依赖离线录制。

关键机制:加载时(Load Time)动态推断 可能用到的 PSO 子集。

当一个物体被加载时,系统自动检查以下信息来 计算 PSO 候选集

  1. 材质信息(Materials) —— 该物体使用了哪些材质
  2. 网格属性(Mesh Info) —— 静态网格 vs. 骨骼动画网格等
  3. 全局状态(Global State) —— 当前的画质设置、开启的渲染特性等

3.2 预缓存的效率

推断出的子集仍然 大于最终实际使用量 ,但远小于全排列空间,使得 加载期间编译变得可行

Fortnite Battle Royale 实测数据:

  • 全组合空间:数百万 种 PSO
  • 预缓存编译量:约 30,000
  • 实际使用量:约 10,000

预缓存精确到将编译量压缩到了全空间的 极小比例 ,同时保证覆盖了所有运行时需求。

3.3 不同场景的处理策略

场景处理方式
地图加载阶段创建的物体加载画面 显示期间完成 PSO 编译,玩家无感知
运行时流式加载/动态生成的物体策略 A: 等待 PSO 就绪后再渲染(通常只延迟 几帧 ,不易察觉)
策略 B: 使用已编译好的 默认材质(Default Material) 暂时替代
已可见物体切换材质这是 最困难的情况 ——不能隐藏物体,也不应回退到默认材质

3.4 已可见物体材质切换的应对(进行中)

对于已经在屏幕上显示的物体需要更换材质的场景,UE 团队正在推进两项改进:

  1. 提示式 API(Hint API): 允许游戏代码和 蓝图(Blueprints) 提前告知预缓存系统"这个物体可能会切换到某种材质",从而 提前编译对应 PSO
  2. 旧材质持续渲染: 在新材质的 PSO 编译完成之前, 继续使用之前的材质渲染物体 ,避免视觉跳变。

3.5 系统有效解决了材质相关的卡顿

PSO 预缓存已经消除了与材质相关的 Shader 编译卡顿 ,并且 天然兼容用户生成内容 ——无需内容创作者手动采集缓存。


四、尚未完全覆盖的区域——全局着色器(Global Shaders)

4.1 什么是全局着色器

Unreal Engine 中有一类 不与材质关联 的着色器,称为 全局着色器(Global Shaders) 。它们用于渲染器自身的各种算法和后处理效果:

  • 运动模糊(Motion Blur)
  • 上采样(Upscaling)
  • 降噪(Denoising)
  • 等等

4.2 当前覆盖状态(截至 UE 5.5)

全局着色器类型预缓存支持状态
全局计算着色器(Global Compute Shaders)✅ 已被预缓存机制覆盖
全局图形着色器(Global Graphics Shaders)❌ 尚未覆盖——首次使用时仍可能产生 罕见的一次性卡顿

UE 团队正在积极开发,目标是 彻底消除预缓存覆盖的盲区


五、新旧方案的协同使用

捆绑缓存(Bundled PSO Cache) 并未被废弃,可以与 PSO 预缓存 配合使用,对特定游戏仍有价值:

  • 常用材质 可放入捆绑缓存,在 启动阶段 就完成编译,而非留到游戏中加载
  • 全局图形着色器 可通过离线发现流程录入捆绑缓存,弥补当前预缓存系统的盲区
  • 整体思路是 预缓存为主、捆绑缓存为补充 ,最大限度减少运行时编译卡顿

驱动缓存机制与跨平台着色器编译策略


一、驱动缓存(Driver Cache)的工作原理

1.1 基本机制

  • GPU 驱动会将 已编译的 PSO 保存到磁盘 ,当后续游戏会话再次遇到相同 PSO 时,可以 直接从磁盘加载 而无需重新编译。
  • 这一机制 对所有游戏引擎和所有 PSO 编译策略都有效 ,是驱动层面的通用优化。
  • 实际效果: 对于使用 PSO 预缓存的 UE 游戏,第二次启动时加载画面会 明显更短
    • Fortnite 实测: 驱动缓存为空时,加载一场大逃杀比赛要多花 20–30 秒
  • 缓存失效时机: 安装新驱动时缓存会被 清空 ,因此驱动更新后首次运行游戏看到更长的加载时间是 正常现象

1.2 UE 如何利用驱动缓存——"预缓存"名称的由来

UE 的 预缓存(Precaching) 流程如下:

  1. 加载阶段: 引擎创建 PSO 并提交给驱动编译
  2. 编译完成后: 引擎 立即丢弃 这些 PSO 对象(不持有在内存中)
  3. 渲染阶段需要某 PSO 时: 引擎再次向驱动发起编译请求
  4. 驱动从缓存命中: 因为预缓存阶段已经编译过,驱动直接 从磁盘缓存返回 ,速度极快
  5. 生命周期管理: PSO 被实际用于绘制后,会 保持加载状态 ,直到所有使用它的图元(Primitive)从场景中移除——不会每帧都重复请求

这就是为什么叫 "预缓存" ——引擎的目的不是持有 PSO,而是 "预热"驱动缓存

1.3 预缓存策略的权衡

策略优点缺点
编译后丢弃(默认)未使用的 PSO 不占内存渲染时从驱动缓存取回仍有少量耗时,首次渲染某材质可能出现 微卡顿(Micro-stutter)
编译后保留渲染时 零延迟 ,无微卡顿内存占用可增加 超过 1 GB ,仅适用于 RAM 充足的机器
  • Epic 正在研究 降低内存开销 的方案,以及 自动判断何时可以保留预缓存 PSO 的决策机制。

二、状态冗余与剪枝优化

2.1 并非所有状态都影响可执行代码

  • 创建两个拥有 相同 Shader 但不同管线状态设置 的 PSO 时,如果这些不同的状态 不影响最终生成的机器码 ,则 只有第一个 需要走完整编译流程,第二个会被驱动 直接从缓存返回
  • 换言之,驱动缓存天然具有一定的 去重能力

2.2 "哪些状态影响编译"是不确定的

  • 不同 GPU 架构 中影响代码生成的状态集合 各不相同
  • 甚至 同一 GPU 的不同驱动版本 之间也可能改变。
  • UE 基于 实践经验(Practical Knowledge) 在预缓存阶段 跳过部分排列组合 ,执行 剪枝(Pruning)

2.3 剪枝的收益

即使冗余请求因驱动缓存而 编译时间很短 ,引擎仍需花费工作来 生成这些请求 ,积少成多。剪枝可以同时减少:

  • 加载时间
  • 内存占用

三、移动平台与主机平台

3.1 移动平台(Mobile)

移动平台同样采用 设备端运行时着色器编译模型 ,UE 的预缓存系统在移动端 同样有效

移动端的特殊挑战:

  • 渲染器使用的 Shader 数量较少 ,但移动 CPU 性能较弱 ,导致单次 PSO 编译 耗时更长

针对性调整措施:

措施说明代价
跳过罕用排列组合预缓存集 不再是保守的完全覆盖 ,变为"大部分覆盖"少数不常见状态被渲染时 仍可能卡顿
加载超时机制为地图加载期间的预缓存设定 时间上限 ,防止加载界面过长游戏可能在 仍有未完成编译任务 时就开始运行,若这些 PSO 立刻被需要则会卡顿
优先级提升系统(Priority Boost)当某个 PSO 被渲染需要时,将其编译任务 移到队列最前面尽量 缩短 卡顿持续时间,但无法完全消除

3.2 主机平台(Consoles)

主机平台 完全不需要解决这个问题 ,原因如下:

  • 每台主机只有 单一目标 GPU
  • Shader 在构建阶段就直接编译为 可执行的 GPU 机器码 ,随游戏一起分发。
  • 不存在组合爆炸:
    • 同一顶点着色器搭配不同像素着色器 不会导致重新编译
    • 管线状态变化 也不会导致重新编译
  • Shader 与状态可以在运行时 以极低开销组装成 PSO

结论: 主机平台上 不存在 PSO 卡顿问题


四、关于"回到 Direct3D 11"的误解

4.1 常见误解

"D3D11 没有这些问题,我们应该回到旧 API。"

这是一个 部分性的误解(Partial Misconception)

4.2 D3D11 时代的真实情况

  • D3D11 时代 同样存在着色器编译卡顿
  • 由于 API 设计(允许逐项修改状态,驱动在 Draw Call 时才看到完整配置),引擎 没有任何手段预防卡顿
  • 卡顿 看起来不那么严重 的原因:
    • 游戏的 Shader 更简单、数量更少
    • 光线追踪等特性尚不存在
    • 驱动做了大量 "魔法式" 的优化来最小化卡顿(但无法完全避免)

4.3 D3D12 / Vulkan 的设计意图与演进

  • D3D12 引入 PSO 是为了 在问题恶化之前提供解决手段
  • 引擎 花了较长时间 才有效利用 PSO,原因包括:
    • 改造现有材质系统的难度大
    • API 本身的不足 在游戏复杂度提升后才逐渐暴露
  • UE 作为 通用引擎 ,用例广泛、历史内容和工作流丰富,问题尤其 难以解决

4.4 当前进展

  • UE 终于达到了拥有 可行解决方案 的阶段(PSO Precaching)。
  • API 层面也在推进改进,例如 Vulkan 的 Graphics Pipeline Library 扩展 ——允许将管线的各个部分(顶点输入、Shader 阶段、片段输出等)分别预编译为库,在运行时快速组装,进一步缓解组合爆炸问题。

PSO 预缓存系统的现状、局限与最佳实践


一、系统现状与未来方向

1.1 演进历程

  • PSO 预缓存系统自 UE 5.2 实验性引入 以来已经历了 大量迭代改进
  • 当前版本已能 防止大部分类型的着色器编译卡顿 ,但仍存在 覆盖缺口(Coverage Gaps) 和其他局限性。

1.2 正在进行的工作

方向说明
引擎侧持续优化缩小预缓存的覆盖缺口,提升准确性和效率
与硬件/软件厂商合作推动 GPU 驱动和图形 API 适配游戏实际使用模式 ,从生态层面改善问题

1.3 终极目标

全自动、最优化的预缓存 ——游戏开发者 无需做任何额外工作 即可获得无卡顿体验。


二、当前阶段的最佳实践(面向 UE 授权用户)

在系统尚未完全成熟之前,Epic 给出了以下 实操建议

2.1 使用最新引擎版本

  • 预缓存仍处于 持续开发阶段(Work in Progress) ,新版本的行为 一定优于旧版本
  • 若无法整体升级引擎,建议将预缓存相关改进 回移植(Backport) 到自定义引擎版本中。

2.2 分析并定位 PSO 卡顿

  • 使用控制台变量:

    r.PSOPrecache.Validation=2
    
  • 该模式会帮助识别:

    • Miss(未命中) ——运行时需要的 PSO 从未被预缓存
    • Late PSO(迟到) ——PSO 虽然在预缓存队列中,但在被需要时 尚未编译完成
  • 分析原因后可有针对性地解决问题。

2.3 清空驱动缓存后再进行测试

  • 使用命令行参数:

    -clearPSODriverCache
    
  • 目的: 模拟玩家 首次运行游戏驱动更新后 的体验(此时驱动缓存为空,是卡顿最严重的场景)。

  • 在此模式下仔细观察卡顿,并利用上述分析工具定位问题。

2.4 定期重复检测流程

  • 内容变更游戏代码修改 都可能引入 新的卡顿 或暴露预缓存系统的 bug。
  • 强烈建议 将 PSO 统计数据监控纳入 自动化测试流程(Automated Testing Pipeline) ,持续回归。

2.5 关注其他类型的遍历卡顿(Traversal Stuttering)

PSO 编译 并非导致游戏运行中卡顿的唯一原因 。没有合适的性能分析工具(Instrumentation),很难判断卡顿的 真正根因

其他常见的 帧时间尖刺(Frame Time Spikes) 来源:

卡顿类型说明
同步加载(Synchronous Loading)在主线程上阻塞式加载资源,直接冻结渲染
过量生成/流入(Excessive Spawning / Stream-in)短时间内大量对象进入场景,导致 CPU 或 GPU 过载
移动触发的场景捕获(Scene Captures Triggered by Movement)角色移动时触发实时渲染到纹理操作,相当于额外渲染一次场景
其他昂贵的运行时操作物理模拟突增、大规模 AI 更新等

关键建议: 在开发和测试过程中 定期进行性能分析(Profiling) ,系统性地追踪所有可能造成帧时间异常的来源,而不仅仅聚焦于 Shader 编译。