技术博客

材质编译与 Shader Permutation:FMaterialShaderMap 的生成链路

第 40 / 100 篇 · 2026-03-15 · ue5 · 材质Shader编译源码分析

"材质改一下编译半天"是 UE 开发者共同的痛。痛感的来源不是编译器慢,而是着色程序的组合数量——同一个材质要产出几十上百份变体(permutation)。这篇讲清三件事:一份材质的 HLSL 从哪来、变体数量怎么膨胀的、编译结果怎么缓存复用。代码在 Engine/Source/Runtime/Engine/Private/Materials/MaterialShader.cpp、MaterialShared.cpp(UE5.3)。

从节点图到 HLSL:翻译不是编译

材质编辑器里的节点图本身不是着色器。翻译分两层:

  1. 节点求值序列化。每个材质节点(UMaterialExpression)实现 Compile(),输出不是颜色值而是一个表达式索引——把"我依赖哪些输入、怎么算"追加到 FMaterialCompiler 的中间表示(一棵扁平的指令列表)。整个节点图编译完得到一份平台无关的指令序列;
  2. 代码生成。FHLSLMaterialTranslator 把这棵指令树遍历一遍,生成真正的 HLSL 源码字符串。这一步的输出(加上引擎侧公共的 shader 框架代码)就是 FMaterialShaderMap 的输入。

关键认知:节点图 → HLSL 是近乎即时的纯文本生成,通常不到 100ms。慢的是下一步——把这份 HLSL 喂给各平台的离线编译器(DXC、dxc->SPIRV、Metal 的编译器等),产出真正的 GPU 二进制。每一组合法的环境组合都要走一遍这个离线编译。

Permutation:变体从哪来

HLSL 源码里充斥着 #if 宏分支——顶点工厂类型(静态网格/骨骼网格/粒子/地形…)、光照模型、GBuffer 布局、各种 quality 开关。同一份 HLSL 源码,每种宏组合的展开都是一次独立的离线编译,产出一个 permutation。

数量估算:一个材质的理论变体数是各维度选项数的乘积。顶点工厂 ~10+ 种、quality 档 ~5 档、各色 feature 开关(雾、Decal、VT…)十几个——全排列轻松上千。UE 用两类手段压住这个组合爆炸:

  • ShouldCompilePermutation 过滤。每个 shader 类实现 ShouldCompilePermutation,在编译前判定"这个宏组合对这个材质是否合法/是否用得上"。比如一个只用静态网格的材质,骨骼网格工厂的所有 permutation 直接剪枝。MaterialShader.cpp 里能看到这条过滤链;
  • 依赖哈希去重。每个 permutation 的编译输入做哈希(shader 源码 + 宏组合 + 编译器版本),同哈希的结果只编一次。两个材质如果节点图不同但生成的 HLSL 相同(比如只改了不输出的参数),它们的 permutation 哈希相同,第二个材质直接复用第一个的编译结果。

实测数据感受下量级:改一个常用基础材质的参数,全变体重编在 32 核编译机上能到几分钟;但 DDC 命中时(下面讲)同样的操作秒级完成。

FMaterialShaderMap:编译结果的容器

编译产物组织成 FMaterialShaderMap——按 shader 类型(FMaterialShaderType:比如 TBasePassPS、TShadowDepthPS)分桶,每桶存该类型所有合法 permutation 的二进制。这个 map 就是材质在渲染时的"可执行文件",渲染线程查 GetShader<> 拿到的就是它。

材质和 shader map 之间有一层间接:材质存的是对 map 的指针 + 编译完成的序列号(FMaterial::GetRenderingShaderMap)。材质可以换 shader map(重新编译后),旧 map 的释放走延迟——正在用它渲染的帧排空后才回收。这是 UE 全引擎贯穿的"渲染资源延迟释放"纪律在 shader 上的体现。

材质编译链路:节点图 → HLSL → Permutation 展开 → 平台二进制 材质节点图 UMaterialExpression 表达式指令序列 FMaterialCompiler HLSL 源码 FHLSLMaterialTranslator Permutation 展开(带剪枝) 工厂 × 质量档 × feature 开关 ShouldCompilePermutation 过滤 依赖哈希去重 DDC 查缓存 命中:直接载入 秒级完成 Miss:DXC/平台编译器 分钟级,可并行

DDC:为什么共享缓存能救命

派生数据缓存(DDC)在资产管线那篇会展开,这里只说它在 shader 上的作用:FMaterialShaderMap 的序列化结果就是派生数据,键是(材质 GUID + shader 平台 + 编译器版本 + 所有影响生成结果的引擎配置)的哈希。命中 DDC = 不用跑 DXC。

这解释了团队开发的几个现象:

  • 新同事同步完项目第一次打开编辑器要编很久——他本地 DDC 是空的,但团队共享 DDC(DerivedDataBackend 配 SharedDDC 指向服务器/NAS)后这个时间趋近于零;
  • 升级引擎版本后第一次启动全量重编——哈希键里的"编译器版本/引擎配置"变了,旧缓存全部失效。这是设计使然:宁可全编也不冒险用过期的二进制;
  • CI 机器上跑 cook 慢——检查 CI 环境的 DDC 配置是否指向共享缓存,CI 的编译机核数多但单任务编译速度不如缓存命中。

静态开关与材质实例:另一个爆炸源

除了编译期 permutation,材质还有运行时静态开关(StaticSwitch)——开关的每种组合也是独立编译的。材质实例(Material Instance)改静态开关参数会触发和改材质节点图一样的重编译流程(生成新的 HLSL → 重新走 permutation)。大项目里"材质实例改个开关卡 30 秒"的根源在这里。

实践经验:静态开关留给真正需要切换大段逻辑的场合(比如"是否支持曲面细分"),颜色/标量这类连续参数用 regular 参数(走 uniform buffer,不触发重编译)。判断标准很简单——这个值变了,生成的 HLSL 字符串会不会变。

源码入口

  • 节点编译入口:MaterialExpression.cpp 各 Compile(),聚合点在 HLSLMaterialTranslator.cpp;
  • Permutation 展开:MaterialShader.cpp 的 FMaterialShaderMap::Compile,看 ShouldCompilePermutation 的调用时机;
  • 编译任务调度:ShaderCompiler.cpp,重点看 FShaderCompileThreadRunnable 的任务队列和 worker 进程管理(编译跑在独立进程 ShaderCompileWorker,崩了不拖死编辑器)。

"改材质卡半天"归根到底是组合数学问题,UE 的答案是剪枝 + 哈希去重 + 分布式缓存三层防御。排查编译慢时按这条链从上游往下查:是不是生成了预期外的 HLSL(节点图冗余)、是不是剪枝条件没生效(自定义顶点工厂忘了实现 ShouldCompilePermutation)、还是纯粹 DDC 没命中。