很多人遇到过这个现象:导出包里一切正常,玩家第一次进某个场景时却莫名卡半秒,之后再进就丝般顺滑。罪魁祸首几乎都是同一个: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 顶替期间的帧时间照样难看。根治办法不是调参数,是预热。
预热的正确姿势
目标是让玩家第一次进图时 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 渲染器路径上的变体行为另行讨论。