材质与着色器编译
自动关联目录:材质与着色器编译
官方依据:Shader Development(UE 5.8 Documentation)。本页覆盖该页全部 17 个条目。PSO 相关的另一半(首帧卡顿的直接成因)见 PSO 预缓存。
1. 定位
本页回答"一个材质是怎么变成 GPU 上的着色器的",以及"为什么编译这么慢"。
一句话定位:UE 的编译是异步流式的——材质加载时把编译请求入队,编译结果一可用就套上去,不阻塞引擎。这解释了几乎所有相关的现象:为什么编辑器里物体一开始是默认材质、为什么打包后第一次启动会卡。
2. 结构:三层着色器
| 类型 | 定义 | 编译份数 | 例子 |
|---|---|---|---|
| Global Shader | 操作固定几何(如全屏四边形),不与材质打交道 | 任何给定类型在内存里只有一份 | 阴影过滤、后处理 |
| FMaterialShaderType | Pass 专用,需要材质属性但不需要网格属性 | 每个材质一份 | Light Function pass |
| FMeshMaterialShaderType | Pass 专用,既依赖材质属性又依赖网格类型 | 每个材质 × 每个 Vertex Factory 一份 | TBasePassVS / TBasePassPS |
Vertex Factory
Vertex Factory 是"材质能应用到不同网格类型"的机制:FVertexFactoryType 代表一种网格类型,FVertexFactory 实例持有该类型所需的每实例数据(如 FGPUSkinVertexFactory 持有骨骼矩阵与顶点缓冲引用)。
它的着色器代码是被各 pass 着色器用来抽象网格类型差异的隐式接口。关键函数:
| 函数 | 作用 |
|---|---|
FVertexFactoryInput | 顶点着色器需要什么输入(必须与 C++ 侧的顶点声明匹配) |
FVertexFactoryIntermediates | 缓存多个 VF 函数共用的中间数据(如 TangentToLocal 矩阵) |
FVertexFactoryInterpolantsVSToPS | VS 传给 PS 的数据 |
VertexFactoryGetWorldPosition | 取世界空间位置:静态网格只做 LocalToWorld 变换;GPU 蒙皮网格先蒙皮再变换 |
VertexFactoryGetInterpolantsVSToPS | 转成插值量 |
GetMaterialPixelParameters | 在 PS 里把 VF 特有的插值量转成 FMaterialPixelParameters |
FMaterialShaderMap:变体是从哪来的
FMaterialShaderMap
├── FLightFunctionPixelShader (FMaterialShaderType)
├── FLocalVertexFactory (FVertexFactoryType)
│ ├── TDepthOnlyPS (FMeshMaterialShaderType)
│ ├── TDepthOnlyVS
│ ├── TBasePassPS
│ └── TBasePassVS
└── FGPUSkinVertexFactory
└── (同样一套)这是一个稀疏矩阵:Vertex Factory 依据自身的 ShouldCache(取决于材质用途,如 bUsedWithSkeletalMesh 为真就纳入 GPU skin VF)加入;FMeshMaterialShaderType 依据自己的 ShouldCache(取决于材质与 VF 属性)加入。
官方的说明很坦白:
这种稀疏矩阵方式很快就会累积出大量的着色器,占用内存并增加编译时间。相比"存一份真正需要用到的着色器列表"的做法,它的主要优势是不需要生成那份列表——因此在主机上,运行时需要的着色器总是已经提前编译好了。
UE 用两个办法缓解:
- 内存问题 → 着色器压缩
- 编译时间问题 → 多核着色器编译
3. 编译机制
异步流式编译
材质加载(没有已缓存的 shader map)
→ 编译请求入队
→ Shader Compile Worker 进程编译
→ 结果可用时套用(不阻塞引擎)为什么用独立的 Worker 进程:平台的着色器编译函数(如 D3DCompile)内部常含临界区,在单个进程里无法多核扩展。所以真正的编译工作放在 Shader Compile Workers 辅助进程里做。
缓存与 Cook
| 阶段 | 行为 |
|---|---|
| 编译完成 | 存入 DDC |
| 缓存 key | 包含着色器源文件在内的所有编译输入的哈希 → 改了 .usf 会自动被识别 |
| Cook | 材质着色器内联进材质的包;全局着色器单独存一份全局着色器文件,以便引擎启动早期加载 |
改 FShader 的 Serialize 函数不需要处理向后兼容——只要在被引用的着色器文件里加个空格即可(key 变了就重编)。
4. 参数与控制台变量
| CVar / 设置 | 作用 | 备注 |
|---|---|---|
r.ShaderDevelopmentMode | 设为 1:开启出错重试与着色器开发相关日志/警告 | 建议写进 ConsoleVariables.ini,每次启动都生效 |
recompileshaders changed / Ctrl+Shift+. | 重编译改动过的着色器 | 改了 .usf 并保存后执行 |
r.DumpShaderDebugInfo | =1 把所有被编译的着色器导出到磁盘 | 输出到 GameName/Saved/ShaderDebugInfo,含源文件/includes、预处理版本、可复现编译的 bat。一直开着会塞满硬盘 |
bAllowCompilingThroughWorkers([DevOptions.Shaders]) | 是否启动 SCW 调编译器 DLL | 关掉 → 单核编译 |
bAllowAsynchronousShaderCompiling | 是否在 UE 内的另一线程编译 | 关掉 → 编译阻塞 |
想直接单步进编译器 DLL(如
CompileD3D11Shader),把上面两个都设为 false,并确保其它着色器都已缓存(编译会非常慢)。
特殊引擎材质
UMaterial::bUsedAsSpecialEngineMaterial:允许材质用于任何 vertex factory 类型 → 所有 VF 都会与该材质一起编译,集合非常大。适用于:
| 用途 | 例子 |
|---|---|
| 视图模式用材质 | lighting only 等 |
| 编译出错时的回退材质 | DefaultDecalMaterial、DefaultMaterial |
| 用来减少需要缓存的着色器数量 | 不透明材质的 depth-only 输出与 DefaultMaterial 相同 → 直接用 DefaultMaterial 的着色器,该材质跳过 depth-only 着色器的缓存 |
5. 迭代与调试
| 场景 | 最快的方式 |
|---|---|
| 改 global shader | recompileshaders changed / Ctrl+Shift+.**;编译太慢就在 ModifyCompilationEnvironment 里指定 CFLAG_StandardOptimization |
改 material shader(如 BasePassPixelShader.usf) | 在单个材质上迭代更快——材质编辑器点 Apply 会重新读盘并只重编该材质 |
改了被大量着色器 include 的文件(如 common.usf) | 会很久,要有心理准备 |
| 调试着色器 | 改着色器输出中间量,再用 VisualizeTexture 看。例如验证世界坐标:OutColor = frac(WorldPosition / 1000); |
输出中间值的方式对构建复杂数据结构的着色器扩展性不好——那种要靠
r.DumpShaderDebugInfo拿预处理产物。
编译出错重试
开了 r.ShaderDevelopmentMode 后:
- Debug + 调试器附加 → 命中断点,错误输出在 VS 输出窗口,双击错误日志直接跳到出错行
- 否则弹 Yes/No 对话框
对 global shader 尤其重要——它们编译失败是 fatal error。
HLSL 交叉编译器
HLSL Cross Compiler 在离线着色器编译阶段把 HLSL 自动转成 GLSL(OpenGL 平台),让着色器只写一次;同时做一些 OpenGL 驱动常缺失的优化。
AsyncCompute
部分 API / GPU 支持的硬件特性,让任务交错执行以更好利用 GPU 硬件单元。
注意:异步计算开着时 GPU 计时不可靠——分析时要 r.RDG.AsyncCompute 0(仅用于分析,发布版绝不能关,见 虚拟阴影贴图)。
6. 代价与权衡
| 设计 | 收益 | 代价 | 什么时候不该用 |
|---|---|---|---|
| 稀疏矩阵缓存 | 主机上无需生成列表,运行时要的都已编译好 | 着色器数量膨胀 → 内存 + 编译时间 | 内存极紧的平台要压缩与裁剪 |
| 异步编译 | 加载不被阻塞 | 物体先以默认材质/跳过绘制出现 | 需要"一定立刻正确"的场景要配 PSO 预缓存等待 |
| SCW 多进程 | 绕开编译器 DLL 的临界区,能多核 | 额外进程与内存 | 调试时反而要关掉 |
| 特殊引擎材质 | 减少缓存数量 | 该材质变体爆炸式增长 | 只用于官方列出的三类场景 |
| DDC key 含源码哈希 | 改源码自动失效 | 任何源码改动都触发重编 | 大项目必须配共享 DDC |
7. 踩坑与排查
| 坑 | 现象 | 怎么验证 |
|---|---|---|
改了 .usf 没重编 | 行为没变 | 是否执行了 recompileshaders changed |
改了 common.usf | 编译巨慢 | 该文件被大量着色器 include,正常 |
一直开 r.DumpShaderDebugInfo | 硬盘被塞满几万个小文件 | 检查 Saved/ShaderDebugInfo |
| 编译出错直接崩 | global shader 编译失败是 fatal | 开 r.ShaderDevelopmentMode 拿重试与日志 |
| 内存占用高 | 着色器数量膨胀 | 检查是否误设 bUsedAsSpecialEngineMaterial;用着色器压缩 |
| 每次换机器都重编 | DDC 未共享 | 配共享 DDC(见 Cook 与打包流程) |
| 首帧/切场景卡顿 | PSO 未预缓存 | 走 PSO 预缓存 |
| 编辑器里物体先是默认材质 | 异步编译未就绪 | 设计如此;发布版靠 PSO 预缓存 + 加载屏等待 |
| GPU 计时忽高忽低 | 异步计算开着 | r.RDG.AsyncCompute 0 再测(只用于分析) |
8. 排查顺序
1. 卡在哪?编译慢 → 共享 DDC;运行时卡 → PSO 预缓存
2. 编译错误 → r.ShaderDevelopmentMode = 1,看重试与日志
3. 着色器结果不对 → 输出中间量 + VisualizeTexture
4. 内存大 → 查变体数量与特殊引擎材质
5. 首次启动体验 → -clearPSODriverCache 复现(见 PSO 篇)