游戏引擎中的着色器卡顿问题与 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 问题是如何恶化的
- 早期: Shader 数量少、逻辑简单,字节码→机器码的翻译 代价可忽略 。
- GPU 性能提升后:
- Shader 代码量 急剧膨胀 。
- 驱动为了生成更高效的机器码,引入了 复杂的优化变换 。
- 运行时编译开销变得 不可忽视 。
- 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 候选集 :
- 材质信息(Materials) —— 该物体使用了哪些材质
- 网格属性(Mesh Info) —— 静态网格 vs. 骨骼动画网格等
- 全局状态(Global State) —— 当前的画质设置、开启的渲染特性等
3.2 预缓存的效率
推断出的子集仍然 大于最终实际使用量 ,但远小于全排列空间,使得 加载期间编译变得可行 :
Fortnite Battle Royale 实测数据:
- 全组合空间:数百万 种 PSO
- 预缓存编译量:约 30,000 种
- 实际使用量:约 10,000 种
预缓存精确到将编译量压缩到了全空间的 极小比例 ,同时保证覆盖了所有运行时需求。
3.3 不同场景的处理策略
| 场景 | 处理方式 |
|---|---|
| 地图加载阶段创建的物体 | 在 加载画面 显示期间完成 PSO 编译,玩家无感知 |
| 运行时流式加载/动态生成的物体 | 策略 A: 等待 PSO 就绪后再渲染(通常只延迟 几帧 ,不易察觉) 策略 B: 使用已编译好的 默认材质(Default Material) 暂时替代 |
| 已可见物体切换材质 | 这是 最困难的情况 ——不能隐藏物体,也不应回退到默认材质 |
3.4 已可见物体材质切换的应对(进行中)
对于已经在屏幕上显示的物体需要更换材质的场景,UE 团队正在推进两项改进:
- 提示式 API(Hint API): 允许游戏代码和 蓝图(Blueprints) 提前告知预缓存系统"这个物体可能会切换到某种材质",从而 提前编译对应 PSO 。
- 旧材质持续渲染: 在新材质的 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) 流程如下:
- 加载阶段: 引擎创建 PSO 并提交给驱动编译
- 编译完成后: 引擎 立即丢弃 这些 PSO 对象(不持有在内存中)
- 渲染阶段需要某 PSO 时: 引擎再次向驱动发起编译请求
- 驱动从缓存命中: 因为预缓存阶段已经编译过,驱动直接 从磁盘缓存返回 ,速度极快
- 生命周期管理: 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 编译。