1、你是不是也见过这个场景?
玩某些 Unreal 游戏的时候,你一定经历过:
更新完游戏,兴奋地双击图标。然后屏幕上弹出一个窗口——「Compiling Shaders: 1 / 23847」。进度条缓慢蠕动,预计剩余时间:20 分钟。
你的表情:😐
为什么每次更新都要编译 Shader?为什么编译 Shader 这么慢?有没有办法不让玩家等?
这篇文章,就是来回答这三个问题的。
2、为什么 Shader 要编译?它不是「代码」吗?
Shader 确实是一种代码。你写在材质蓝图节点里的逻辑,最终会被 Unreal 翻译成 HLSL(DirectX)或 GLSL(OpenGL)或 MSL(Metal)代码。
这个翻译过程叫 Shader 编译。它发生在两个时机:
- 构建时:Cook 阶段,Unreal 尽可能多地预编译 Shader
- 运行时:游戏运行时,如果遇到 Cook 时没覆盖到的 Shader Variant,会触发运行时编译
那么问题来了:既然 Cook 时已经编译了,为什么运行时还要编译?
这就涉及 Unreal Shader 系统最大的特点——也是最大的麻烦——Shader 排列组合。
3、排列组合爆炸:为什么是「2 万个」而不是「50 个」
Unreal 的材质系统非常灵活。一个材质可以有十几个可切换的特性:
- 是否接受动态光照?
- 是否投射阴影?
- 是否有法线贴图?
- 是否有自发光?
- 是否启用半透明?
- 是否使用 Instance Stereo(VR)?
- 目标平台是 DirectX 还是 Vulkan 还是 Metal?
- 质量等级是 Low / Medium / High / Epic?
每个选项都是一个「开关」。这些开关组合起来,可能产生成千上万个不同的 Shader Variant。
但问题是,这么多 Variant,你的项目可能只用到了其中的一部分。
举例来说:
- 你的项目在场景 A 用了「动态光照 + 阴影 + 法线贴图」组合
- 在场景 B 用了「无动态光照 + 无阴影」组合
- 但项目里没有一个材质用了「VR Instanced Stereo + 半透明 + 自发光」这个组合
Cook 阶段,Unreal 会根据引用分析(Cook by the book)编译被引用到的那些 Variant。Cook 不会编译那些「你的项目没用到的」组合。
所以,理论上 Cook 阶段不可能覆盖所有 Variant。如果玩家在某个特定情况下触发了一个没被编译过的 Variant,引擎就得现场编译。这被称为 Shader 编译卡顿(Shader Compilation Stutter)。
4、PSO Cache:解决运行时编译的答案
PSO 的全称是 Pipeline State Object(管线状态对象)。
简单理解,PSO 就是把「Shader 代码 + 渲染状态(混合模式、深度测试、光栅化状态等)」打包在一起的一个对象。GPU 在执行渲染之前,需要把这个 PSO 准备好。
PSO Cache 就是提前把常用的 PSO 编译好并存下来,让玩家在游戏里遇到对应渲染场景时,不用现场编译,直接加载缓存好的 PSO。
PSO Cache 的生成时机有两种:
- 在构建时预生成:开发者在出包前跑一遍收集工具,把常用的 PSO 提前编译好,随包下发
- 在玩家本地生成:玩家第一次玩游戏时,后台偷偷编译,存在本地 PSO Cache 里。下次再玩就快了
理想情况是用第一种——构建时预生成。这样玩家拿到包就能流畅玩。但这也意味着你的 CI/CD 除了 Cook 和 Pak,还要多一步 PSO 采集和缓存生成。
而采集 PSO 需要「真正渲染一遍」——你需要在编辑器或专门的工具里跑遍所有关卡、覆盖尽可能多的渲染场景,让引擎记录下用到了哪些 PSO,然后编译它们并写入缓存。
5、DDC:让 Cook 不重复的缓存系统
上一篇文章我们简单提过 DDC。在 Shader 编译这个话题里,DDC 的重要性要再强调一次。
Shader 编译是 Cook 过程中最慢的部分。而 DDC 的存在,让「已经编译过的 Shader 不需要重新编译」成为可能。
正确配置了共享 DDC 的团队,Shader 编译的体验是这样的:
- 同事 A:Cook 了整个项目,编译了所有的 Shader,消耗 40 分钟。编译结果写入了共享 DDC。
- 同事 B:拉取代码后 Cook,发现大部分 Shader 在共享 DDC 里有缓存命中了,只需编译那些 A 没碰到过的 Shader。耗时 5 分钟。
- CI 机器:配置了团队的共享 DDC 作为只读源,每次 Cook 前先同步一遍 DDC,编译时间从 40 分钟降到 10 分钟。
所以 DDC 不只是「缓存」,它几乎是 Unreal 项目构建的必需基础设施。一个没有正确配置 DDC 的 Unreal 项目,每个人每次 Cook 都要全量编译 Shader——这在团队规模大了以后是不可接受的。
6、怎么让玩家不等?
回到最初的问题:能不能不让玩家在游戏启动时等二十分钟编译 Shader?
几条路:
方案一:构建时预编译所有 Shader
在出包时,用完整的 Cook by the book 把所有关卡、所有可能的 Shader Variant 全部编译一遍。优点是不留遗漏,缺点是 Cook 时间极长(可能好几小时),产物体积也会更大。
方案二:构建时预生成 PSO Cache,随包下发
这是 Epic 推荐的做法。出包时用 PSO 采集流程收集所有关卡用到的 PSO,编译好,打包进安装包或热更新包。玩家下载完成就能直接玩,启动时不用编译任何 Shader。
方案三:后台预编译
安装包不包含完整的 PSO Cache,但游戏在首次启动或空闲时,在后台静默编译一些常用的 PSO。等玩家真正玩到复杂场景时,大部分 Shader 已经就绪了。
方案四:驱动层缓存
现代显卡驱动(NVIDIA、AMD)通常自带了 Shader 缓存机制。即使游戏没有显式的 PSO Cache,驱动也会在第一次编译 Shader 后把结果存在本地。下次启动同一游戏,同一个 Shader 就不用重新编译了。但驱动缓存的颗粒度不如 PSO Cache 那么细,也不跨平台。
实践中,效果最好的做法是方案一 + 方案二组合:Cook 时尽量多编译 Shader,同时生成 PSO Cache 随包下发。玩家第一次启动,引擎用 PSO Cache 快速就绪,无需现场编译。
7、小结
Shader 编译之所以慢,根源在于 Unreal 材质的灵活性导致了排列组合爆炸——一个材质可能对应成千上万个 Shader Variant,不是所有 Variant 都能在构建时覆盖到。
解决这个问题的核心思路是:
- Cook 阶段:用 DDC 缓存编译结果,减少重复编译
- 出包阶段:预生成 PSO Cache 随包下发
- 运行时:用 PSO Cache 替代现场编译,消除启动等待
这其实和 IL2CPP 的思路殊途同归——把运行时的动态编译提前到构建时完成。用户拿到的是一个「烤好了」的包,不需要再等烤箱预热。
写在最后
到这里,Unreal 构建四篇也完结了。加上之前的一篇开篇文章和 Unity 构建五篇,我们一共写了十篇文章,走完了从「互联网构建 vs 游戏构建」的认知转变,到 Unity 和 Unreal 两套构建体系的深度拆解。
回顾一下这趟旅程:
| 篇目 | 内容 |
|---|---|
| 序章 | 从 Java 到游戏行业,重新理解「构建」 |
| Unity(一) | Build 按钮背后的八大步骤 |
| Unity(二) | AssetBundle 接口演进——从第 0 代到第 1 代两条路线 |
| Unity(三) | SBP——用最小改动换最大缓存提升 |
| Unity(四) | Addressables——改动大得多的资源管理方案 |
| Unity(五) | IL2CPP 怎么做到跨平台 |
| Unreal(一) | UBT 和 UAT 的角色与构建流程 |
| Unreal(二) | Cook——资源为什么需要「做菜」 |
| Unreal(三) | Pak 文件的打包与热更新 |
| Unreal(四) | Shader 编译——为什么这么慢,怎么让玩家不等 |
如果你一路读到了这里,说明你和我一样,对「构建系统到底在做什么」这件事有浓厚的兴趣。希望这十篇文章,能帮你在面对 Unity 或 Unreal 的构建问题时,不再是一头雾水,而是能清楚地知道——「哦,原来是卡在这一步了。」
从 CPU 到 GPU,从 Java 到 C#/C++,从互联网到游戏——职业生涯的这一次转向,值得好好记录。也希望这些记录,能帮到和我一样的后来者。
每天前进一小步,就是一个新的高度!