技术博客

Shader 缓存与变体编译:首次运行卡顿的来源与预热

第 109 / 149 篇 · 2026-02-19 · godot · 渲染管线资源管线性能优化

很多人遇到过这个现象:导出包里一切正常,玩家第一次进某个场景时却莫名卡半秒,之后再进就丝般顺滑。罪魁祸首几乎都是同一个:shader 变体首次编译。Godot 的 shader 加载和 GPU pipeline 创建是两回事——前者只是解析文本生成 SPIR-V,后者才是真正的编译,而且被刻意推迟到第一次使用时才做。这篇把延迟编译的原因、变体数量的组合数学、磁盘缓存的命中条件讲清楚。

为什么编译被推迟到运行时

引擎在编译 shader 时拿到的信息是不完整的。一个 Godot shading language 文件编译成 SPIR-V 只需要源码本身,这步在加载时就能做完。但 GPU 真正执行的对象不是 SPIR-V,而是 pipeline:它把 shader 代码和一堆渲染状态绑在一起——blend 模式、cull 模式、深度测试、顶点格式、渲染 pass 的格式(有几张附件、什么精度)。同样的 SPIR-V,画不透明物体是一个 pipeline,画透明物体是另一个。

问题是这些状态在游戏运行前并不全知道。材质什么时候第一次被画、画进哪个 pass、blend 状态是什么,取决于场景内容而不是 shader 文件本身。所以引擎干脆不猜:SPIR-V 先备好,pipeline 等第一次 draw call 真的需要它时再创建。代价就是你替玩家承担了这次编译——Vulkan 上创建 graphics pipeline 是几十到几百毫秒的阻塞操作,正好是"卡顿半秒"的量级。

变体的组合数学

延迟编译本身不至于造成灾难,灾难来自变体数量。shader 源码里每个 if 依赖的编译期分支、每个 render mode 组合、每种 blend/cull 状态,都会生成不同的 pipeline。设一个 shader 有 $k$ 个二值变体分支(常见来源:阴影质量档位、雾开关、软阴影采样数),渲染状态有 $m$ 种合法组合,那么最坏情况下的 pipeline 数是

$$ N_{pipeline} = 2^k \times m $$

以 4.x 内置天空 shader 为例,质量相关分支好几个,乘上 Forward+ 的多 pass(主 pass、反射 probe pass、半透明 pass……),内置 shader 的全量变体数轻松上千。$k$ 增加 1,编译量翻倍——这是指数,不是线性。引擎自己的应对是把不用的分支用 shader_type/render mode 在 SPIR-V 生成期就裁掉一部分,但用户 shader 和游戏场景引出的状态组合是引擎裁不完的。

这也是为什么"我在编辑器里跑得好好的,玩家第一次进图卡成 PPT"是大概率事件:编辑器里你提前看过了所有场景,pipeline 早就编译完;玩家是冷启动。

磁盘缓存:第二次启动为什么不卡

Godot 4 把创建过的 pipeline 二进制结果缓存在用户目录(Linux 下 ~/.cache/godot/shader_cache,Windows 在 %APPDATA%\Godot\shader_cache 附近,按驱动和项目分目录)。这是 Vulkan 驱动提供的 VkPipelineCache 机制:驱动把编译产物序列化成驱动相关的 blob,下次同 GPU、同驱动、同 shader 内容时直接加载,跳过编译。

注意三个失效条件,任何一个命中缓存就报废:

  • 驱动更新:blob 是驱动版本相关的,驱动升级后旧缓存作废;
  • GPU/后端切换:缓存按渲染驱动(Vulkan/D3D12)和 GPU 型号分目录;
  • 项目设置变更:影响 pipeline 状态的设置(渲染器、色调映射、阴影质量)变了,变体集合本身变了。

缓存只解决"同一台机器第二次玩",不解决"玩家第一次玩"。项目设置里的 rendering/shader_compiler/shader_cache/enabled(默认开)和 compress 控制的就是这层磁盘缓存。

异步编译与 ubershader

4.x 的默认答案藏在 rendering/driver/pipeline_compilation/ 这组设置里。pipeline_compilation_mode 有三个档位(4.2 起):

  • 同步:第一次用到就当场编译,卡顿原样暴露;
  • 异步:缺的 pipeline 提交给后台线程编译,编译完成前用一个预先编译好的"全能版"顶替;
  • 异步 + 缓存:在异步基础上把编译过的 pipeline 存盘(就是上面那层缓存)。

"全能版"就是社区俗称的 ubershader:一个把所有变体分支都打开的超集 shader,同步编译好备用。画面用 ubershader 画出来可能是功能正确但偏慢(多算了关闭的分支),等特化 pipeline 就绪后无缝切换。max_simultaneous_compilations(默认 2)限制后台并行编译数,机器好可以调大。

这套机制有个已知短板:第一次进图那批异步编译本身也是负载 spike。如果一屏之内需要上百个新 pipeline,后台编译线程会抢 CPU,ubershader 顶替期间的帧时间照样难看。根治办法不是调参数,是预热。

shader 从文本到 GPU pipeline 的三段旅程与两个缓存点 .gdshader 文本 加载时解析 编译 SPIR-V(内存) 变体分支已展开 首次 draw Pipeline 创建 代码+渲染状态绑定 提交 GPU 执行 之后复用 缓存点 1:磁盘 pipeline 缓存 VkPipelineCache blob,按驱动/GPU/项目分目录 同机第二次启动命中;驱动升级、换 GPU、改设置即失效 缓存点 2:ubershader 顶替 全能变体同步备好,特化 pipeline 后台编译 编译期间功能正确但偏慢,就绪后无缝切换 变体数量:N = 2^k × m k = shader 内二值分支数(阴影质量、雾开关等),m = blend/cull/pass 状态组合数 k 加 1 编译量翻倍:指数增长是"首卡"的根本原因,调参数只能缓解

预热的正确姿势

目标是让玩家第一次进图时 pipeline 已经编译完。可用的手段按工程成本排:

  • 隐藏加载场景过场:进正式场景前先进一个加载界面,把所有要用的材质用 RenderingServer 预热渲染一遍(一个相机看一个摆满材质的预热场景,渲染若干帧)。这是社区验证过的笨办法,有效且跨平台;
  • 缩短异步编译暴露面:把 max_simultaneous_compilations 调到 CPU 核数的一半左右,减少首次进图的后台编译争抢;
  • 减少变体:别在 shader 里堆用不到的 if 分支;项目设置里关掉用不到的特性(比如不用 VoxelGI 就别留它的 shader 分支),直接砍掉 $k$;
  • 慎用同步模式:pipeline_compilation_mode 改同步看起来"省掉"了 ubershader 的偏慢,实际是把成本从"可感知的偶发卡顿"换成"必现的卡顿",得不偿失。

排查这类问题时别猜:开 Vulkan validation layer 跑一次,卡顿帧附近能看到 vkCreateGraphicsPipelines 的耗时;或者直接在怀疑的材质上强制预热,对比帧时间曲线就能定位。

本文涉及的设置为 Godot 4.2–4.4 的行为;pipeline_compilation_mode 三档在 4.2 引入,更早版本只有同步编译 + 磁盘缓存,移动端 Compatibility 渲染器路径上的变体行为另行讨论。