Unreal Engine 与着色器卡顿:PSO 预缓存解决方案
Game engines and shader stuttering: Unreal Engine’s solution to the problem
核心问题:着色器编译卡顿 (Shader Compilation Stuttering)
- 当渲染引擎在绘制物体前发现需要一个新的着色器(Shader)但该着色器尚未编译时,会暂停所有操作等待驱动程序完成编译,导致画面卡顿。
背景知识:着色器与编译过程
- 着色器 (Shaders):在 GPU 上执行的小程序,用于渲染 3D 图像的各个步骤(如变换、光照、后处理等)。
- 编译流程:
- 高级语言 (如 HLSL) -> 编译器 -> GPU 可执行的机器码。
- 与 CPU 不同,GPU 型号繁多,指令集各异,甚至同厂商不同代产品指令集也可能不同。
- 因此,游戏不直接分发 GPU 机器码,而是将高级着色器代码编译成中间表示 (Intermediate Representation) 或字节码 (Bytecode) (如 Direct3D 11 的 DXBC, Direct3D 12 的 DXIL, Vulkan 的 SPIR-V)。
- 游戏运行时,GPU 驱动程序会将字节码翻译成当前 GPU 可执行的机器码。
- 早期问题:起初游戏着色器简单量少,字节码到机器码的转换成本可忽略。随着 GPU 发展,着色器变多变复杂,驱动优化也更复杂,导致运行时编译成本显著增加,成为卡顿元凶。
现代 API 的尝试:管线状态对象 (Pipeline State Objects - PSOs)
- PSO 定义:渲染一个物体通常涉及多个着色器(如顶点着色器和像素着色器)以及大量 GPU 设置(如剔除模式、混合模式、深度模板测试模式等)。PSO 将这些配置打包成一个单元。
- 与旧 API 的区别:
- 旧 API (如 Direct3D 11, OpenGL) 允许单独、任意时间修改状态,驱动只有在发出绘制指令时才能看到完整配置,此时才可能开始编译着色器,导致卡顿。
- 现代 API (如 Direct3D 12, Vulkan) 要求开发者将所有着色器和设置打包进 PSO,并一次性设置。
- PSO 的优势:理论上,引擎可以在加载等较早阶段创建所有需要的 PSO,让编译有足够时间在渲染前完成。
理论与实践的挑战 (Unreal Engine 的情况)
- 材质系统的复杂性:UE 强大的材质系统允许艺术家创建丰富的视觉效果,一个材质可能产生多种着色器变体(如静态网格、蒙皮网格、样条网格使用不同顶点着色器),再乘以不同的管线状态组合,可能导致数百万种不同的 PSO。
- 全部预编译不可行:预编译所有可能的 PSO 在时间和内存上都不可行(加载关卡可能耗费数小时)。
- 实际使用子集:运行时实际用到的 PSO 只是一个小小子集,但仅通过分析材质无法确定该子集,且子集会因游戏会话(如更改画质设置)而变化。
- 早期 D3D12 引擎的解决方法:通过游戏测试、自动关卡漫游等方式记录遇到的 PSO,形成“捆绑 PSO 缓存 (Bundled PSO Cache)”,在游戏启动或关卡加载时创建。这是 UE 5.2 版本之前的推荐做法。
- 捆绑缓存的局限性:
- 收集过程资源密集,内容更新时需保持同步。
- 对于动态世界(如物体材质根据玩家行为改变)可能无法发现所有 PSO。
- 如果会话间差异大(如多地图、多皮肤选择),缓存会变得过大。
- 不适用于用户生成内容 (UGC),因为需要为每个体验单独收集缓存。
Unreal Engine 的 PSO 预缓存 (PSO Precaching) (UE 5.2 及以后版本)
- 核心思想:在加载时确定潜在的 PSO。
- 工作方式:
- 当对象(如模型、材质)加载时,系统检查其材质,并结合网格信息(静态 vs. 动画)和全局状态(如视频质量设置)来计算一个可能用于渲染该对象的 PSO 子集。
- 这个子集仍比实际使用的多,但远小于所有可能性,使得在加载期间编译它们变得可行。
- 示例:《堡垒之夜》大逃杀模式一场比赛编译约 30,000 个 PSO,实际使用约 10,000 个,而总组合空间有数百万个。
- 地图加载时创建的对象在加载屏幕显示时预缓存其 PSO。
- 游戏过程中流式加载或生成的对象,可以等待其 PSO 就绪后再渲染,或使用一个已编译的默认材质(通常只延迟几帧,不明显)。
- 效果:已为材质消除了 PSO 编译卡顿,并能与用户生成内容无缝协作。
PSO 预缓存的当前挑战与未来工作
- 已显示网格更换材质:不希望隐藏或用默认材质渲染,正在开发 API 允许游戏代码提前提示,以便额外预缓存 PSO,并考虑在编译新材质时继续渲染旧材质。
- 全局着色器 (Global Shaders):
- 与材质无关,用于渲染算法和效果(如动态模糊、升采样、降噪)。
- 预缓存机制已覆盖全局计算 (compute) 着色器,但截至 UE 5.5(文章发布时)尚未完全处理全局图形 (graphics) 着色器。这些 PSO 仍可能导致首次使用时罕见的单次卡顿。正在努力弥补这一覆盖空白。
- 捆绑缓存与预缓存结合:
- 可将一些通用材质包含在捆绑缓存中,在启动时编译,而非游戏过程中。
- 有助于全局图形着色器,因为发现过程会记录它们。
驱动程序缓存 (Driver Cache)
- 作用:驱动程序会将编译好的 PSO 保存到磁盘,在后续游戏会话中再次遇到时可直接加载。
- UE 的利用方式 (预缓存 Precaching):
- UE 在加载时创建 PSO,并在它们完成编译后立即丢弃(确保它们已进入驱动缓存)。
- 之后渲染需要该 PSO 时,引擎发出编译请求,驱动直接从其缓存中返回。
- 一旦 PSO 用于绘制,会保持加载直到所有使用它的图元被移除。
- 优缺点:
- 优点:不使用的 PSO 不会保留在内存中。
- 缺点:在需要时从驱动缓存中获取 PSO 仍可能花费一些时间(远快于编译),可能导致材质首次渲染时的微小卡顿 (micro-stutters)。
- 优化方向:
- 保留预缓存的 PSO 而不是丢弃,但这会增加内存使用(可能超过 1GB),只适用于内存充足的机器。正在研究减少内存影响和自动决定何时保留预缓存 PSO 的方案。
- UE 利用一些实践知识在预缓存过程中跳过某些冗余的 PSO 排列(因为某些管线状态变化不影响最终可执行代码),以减少加载时间和内存使用。
移动平台和主机平台
- 移动平台:
- 同样使用设备端着色器编译模型,UE 的预缓存系统也有效。
- 通常着色器比桌面端少,但 CPU 较慢导致 PSO 编译时间更长。
- 调整:跳过一些不常用的排列(可能导致在渲染这些罕见状态时出现卡顿);地图加载时预缓存有超时限制,以防加载屏幕过长(可能导致游戏开始时仍有编译任务,若立即需要则会卡顿,使用优先级提升系统尽量缓解)。
- 主机平台:
- 不存在此问题。主机只有单一目标 GPU,着色器直接编译成可执行代码随游戏分发。
- 顶点着色器与多像素着色器组合或管线状态变化不会导致重编译。
- 运行时组装 PSO 成本不高,因此没有 PSO 卡顿。
关于 “DirectX 11 怀旧论”
- 误解:认为 D3D11 没有这些问题是片面的。
- 事实:D3D11 时代也有卡顿,且由于 API 设计,引擎无法阻止。卡顿较少或较短主要是因为当时游戏和着色器更简单,且某些特性(如光线追踪)不存在。
- D3D12 引入 PSO 是为了在问题恶化前提早解决,但引擎需要时间来有效利用它们。
尚未完成的工作与开发者建议
- 持续改进:预缓存系统自 UE 5.2 实验性引入以来已大幅改进,但仍有覆盖缺口和局限性,正在持续优化。
- 最终目标:自动且最优地处理预缓存,使开发者无需额外操作即可避免卡顿。
- 开发者建议 (在系统完善前):
- 使用最新引擎版本:新版本通常有更好的预缓存行为。
- 分析游戏中的 PSO 卡顿:使用
r.PSOPrecache.Validation=2
等工具识别 PSO 缺失或过迟,并理解原因。 - 游戏测试前清除驱动缓存:使用命令行参数
-clearPSODriverCache
来模拟玩家首次运行游戏或更新驱动后的体验,关注此模式下的卡顿。 - 定期重复此过程:内容或代码更改可能引入新的卡顿。
- 留意其他类型的卡顿:PSO 编译不是唯一原因。定期分析游戏,追踪其他可能导致帧时间尖峰的高成本进程(如同步加载、过多生成、移动触发的场景捕获等)。