【Unreal Fest 2026】系统性的着色器削减:如何把《堡垒之夜》的着色器砍掉 68%

【Unreal Fest 2026】系统性的着色器削减:如何把《堡垒之夜》的着色器砍掉 68%

2026, Aug 07    

来源:https://zhuanlan.zhihu.com/p/2065857760663704678 原文标题:系统性的着色器削减:如何把《堡垒之夜》的着色器砍掉 68% - Unreal Fest Chicago 2026 作者:洛桑(知乎)| 发布于 2026-07-29 摘录时间:2026-08-07


摘要(Key Takeaways)

  • 主题:这是一篇对 Unreal Fest Chicago 2026 演讲《Systemic Shader Reduction: How We Cut Fortnite’s Shaders by 68%》的中文整理笔记。讲述 Epic 如何在不到半年内,把《堡垒之夜》PC 端的着色器从 190 万个 / 14 GB 降到 62 万个 / 4 GB(着色器数量 -68%、体积近 -70%),由工程侧(Jason Nadro)与内容/技术美术侧(John Loff)分别讲述。

  • 核心心智模型:着色器总量 = 材质 × 顶点工厂类型 × 着色器类型(再乘质量级别 / Shader Model)。这个排列网格是稀疏的,ShouldCompilePermutation 控制哪些格子真正激活,是剔除排列的最大杠杆。使用标记(usage flag)对应顶点工厂类型,父材质开一个标记会给它每一个子实例都加一整组着色器——乘法爆炸的根源。

  • 工程侧关键优化(UE5.8,多数默认启用):① 顶点着色器去重(VS/PS 独立常量缓冲区,索引稳定后跨材质共享)省约 1826 MB;② 只编译 Skin Cache、扔掉 GPU Skin 顶点工厂着色器,省 2500 MB(最大单项,需先启用 Skin Cache);③ ISM 统一用 local 顶点工厂省约 400 MB;④ 先翻译材质再决定编译哪些着色器(修掉”空材质凭空加 100 个着色器”的 bug);⑤ 阴影深度、Slate、半透明 base pass 等改动态分支。

  • 内容侧治理经验:历史包袱是父材质累积 282+ 静态开关、使用标记随复制材质到处传播(Niagara/Nanite/布料等)。治理靠 Python + 引用查看器:递归找出所有实例、按网格用途逐个判断每个标记是否必要、输出 CSV、在实例级覆盖标记。分支策略:release 分支做”覆盖 true”(零风险),main 分支做”父材质关标记”(给 QA 时间)。成果:净削减 20 万+ 排列、省 2 GB+ 内存。

  • 面向未来的最佳实践:便利有编译成本(父材质少放标记、复制材质后回收标记);静态参数悠着用,简单逻辑优先if + 枚举(enum);最重要——尽早把使用标记下放到材质实例级,父材质只保留绝对必要的最少标记。

  • 适用场景 / 为什么重要:对使用 UE5 且材质/外观内容体量大的项目(尤其 live service 游戏)极有参考价值,涉及 PSO 卡顿、包体、cook 时间、着色器内存等痛点的系统化治理方法与 UE5.8 新特性 / CVar 清单。


1. 问题:190 万着色器的代价

这是一场“二合一”演讲:Jason 讲工程侧,John 讲资产内容侧。开场就抛出问题——《堡垒之夜》在 PC 上有 190 万个着色器、14 GB 着色器字节码。这直接影响:

  • cook 时间 / 迭代时间变长;
  • 玩家侧:安装包更大、补丁更大、每次更新下载量大;
  • 运行时着色器内存与 PSO 卡顿(PSO hitch)。 在不到六个月里,团队把它降到 62 万个着色器、4 GB 字节码——着色器减少 68%,体积缩小近 70%。这是工程与美术深度协作的成果。

2. 心智模型:为着色器排列问题进行建模

一个蓝色方块 = 一份独立着色器字节码。着色器总量是几个维度相乘得到的:

① 着色器类型(shader type):渲染工程师为特定渲染功能/pass(Lumen、速度、阴影、深度、base pass 等)编写,引擎里有 155 种。是否激活取决于平台、CVar 和项目设置。

② 顶点工厂类型(vertex factory type):对应引擎里不同图元(静态网格、骨骼网格、地形、ISM…),负责蒙皮或访问网格顶点数据,引擎里有 30 种。它与着色器类型是乘法关系。

③ 材质资产(U material):美术制作的材质图,数量无上限。每个材质都是一层 3D 切片,加一个材质就是加一大堆着色器。

于是总量 = 材质 × 顶点工厂类型 × 着色器类型,再乘上质量级别、Shader Model等额外乘数。注意这个网格实际上是稀疏的——引擎的 ShouldCompilePermutation 函数控制哪些格子真正激活,这也正是剔除排列所能使用的最大的杠杆

3. 使用标记与父子材质的乘法爆炸

使用标记(usage flag) 对应特定的顶点工厂类型(如“用于实例化静态网格 / 骨骼网格 / Niagara”)。默认只启用“用于静态网格”时材质有 10 个着色器;全部打开则暴涨到 186 个。每点开一个标记 = 加进一整组着色器。

更糟的是父子关系:给父材质打开一个使用标记,不是加一行,而是给它的每一个子实例都加一行。

4. 多维度进攻计划

  • 工程侧:移除着色器类型(压缩 X 维)、移除顶点工厂类型(压缩 Y 维)、把部分静态编译排列改为动态分支。
  • 内容侧:美术管理材质上的使用标记(同样压缩 Y 维、去掉不需要的顶点工厂类型)。

5. 工程侧优化(UE5.8)

顶点着色器去重:很多材质开了 WPO(世界位置偏移),会额外编译顶点着色器。分析发现来自不同材质的两个顶点着色器逻辑等价,只是查常量缓冲区的索引不同(如材质 A 用 13、B 用 12)——因为顶点着色器和像素着色器共用一个参数缓冲区,多一个像素着色器参数就会移动偏移、改变索引。

修复:给顶点着色器和像素着色器各自独立的 uniform / 常量缓冲区,索引稳定后即可识别为同一着色器、跨材质共享字节码(默认启用)。

通过这一步,削减了331575个Shader,节省了1826M的shader字节码

顶点工厂维度削减:

  • 蒙皮路径:引擎有 GPU Skin(顶点着色器内联蒙皮)和 Skin Cache(compute 蒙皮一次写缓冲)。《堡垒之夜》全用 Skin Cache,新增 CVar )r.SkinCache.CompileShaders)可只编译其一 → 扔掉 GPU Skin 顶点工厂着色器,省 2500 MB(本次最大单项)。

  • ISM 统一:默认把 ISM 与静态网格统一用 local 顶点工厂,ISM 从 GPU Scene 读实例数据 → 省约 400 MB。

“最爱的 bug”:一个什么都没做的材质(把 0 接进 PDO),却因为接线判断问题让渲染器以为需要 PDO,凭空加了 100 个着色器;用静态开关关 WPO 当时也没生效。

原因:对着色器数量的保守猜测发在翻译材质之前,即在确定知道材质使用哪些属性之前

修复:先翻译材质,判断哪些属性真正有非零输出,再决定编译哪些着色器(默认行为,可通过r.Material.UseShaderCompilationParameters来控制)。

着色器类型维度削减(改动态分支):

  • 阴影深度顶点着色器(方向光/聚光/点光 3 种排列)→ 1 个动态分支,省约 200 MB;
  • Slate(UI)着色器 8 种排列 → CVar(slate.MaterialShaderMode) 控制(全动态着色器 / 混合模式);
  • 半透明材质的 base pass 像素着色器(带/不带天光 2 个)→ 复用已有动态分支,按场景是否有天光分支,零性能代价。

要点:绝大多数优化升级即生效、无需干预。只有两处需要你权衡性能后手动决策:Slate 着色器(CVar 控制)和内联蒙皮着色器(需先启用 Skin Cache)。

总结:大部分是5.8开始默认启用的,其他有些需要手动开启。

6. 工具与工作流改进

  • 材质实例使用标记覆盖(呼声最高的功能):不必在父材质开“用于骨骼网格”,可在具体材质实例上覆盖标记——对庞大材质层级是巨大节省。

  • 材质校验数据库插件:维护项目所有父材质/子实例清单,追踪每个材质给构建加了多少着色器,能像内容规则一样做预算和强制约束;编辑器里改静态开关引入新排列时会弹警告;experiments 面板能给出“移除某静态开关可省 6% 着色器”之类的建议。

  • 材质编辑器新增:可关闭“用于静态网格”(UI/Niagara 材质应关)、实时显示着色器数量、按顶点工厂/着色器类型列出并可看编译后源码、支持属性矩阵批量编辑、内容浏览器一键创建正确域的 UI 材质。

  • 视口中显示着色器数量,选中不同的usage flag后会实时更新,这个估计数量是一个上界,具体的去重会在cook的时候执行导致实际数量可能会减少。

  • 材质所拥有的着色器列表:完整展示该材质所使用的所有着色器,按照顶点工厂类型进行排序,点开能看到具体的代码。

  • 批量编辑材质:现在可以通过Property Materix功能批量编辑多个材质资产

  • UI材质:现在可以通过内容浏览器创建最少usage flag的UI材质,其使用静态网格的flag是关掉的。推荐用这个方式创建,其仅包含最少数量的shader,别再用创建材质然后挑选UI domain的方式

  • 着色器审计插件(规划中):从cook 产物检视所有着色器、按类型/域细分大小、diff 两个构建catch 回退、size map 视图下钻内存占用。

该插件还能够允许你在不同的构建之间进行diff,以查看不同构建有哪些新增/减少的着色器

最终是着色器内存大小占用的可视化,其有个选项,可以剔除掉父材质的大小占用,仅保留该材质自身的大小占用。

7. 内容侧:排列是怎么产生的

(John 接手)内容里有两个主要功能会产生排列:

  • 使用标记:提供“材质该如何编译”的上下文(如给带布料的骨骼网格用,需同时开骨骼网格+布料标记,否则只得到灰色 fallback)。
  • 静态参数:静态开关 / 静态布尔(等价)、静态分量遮罩。误解澄清:不是加一个静态参数就产生排列,而是所有材质实例里“唯一的静态参数组合”才产生排列。
  • 新的使用标记覆盖(材质实例级):把上下文下放到实例级,是清理内容的关键。 乘法演示:

2 个使用标记 × 3 个唯一材质实例 = 6 个排列:

父材质再开 1 个标记 = +3(每实例一个):

加 1 个新唯一实例 = +3。两步就能轻松翻倍。

8. 《堡垒之夜》中外观皮肤的历史包袱

2017 年大逃杀上线时只有一小撮角色、共用一个标准父材质。如今有成千上万个外观,风格各异(tune 角色、响应式外观、动画像素特效、kicks、companions)。多年来的做法是:新独特外观 → 写个材质函数塞进父材质图 → 用默认 false 的静态开关门控 → 只在该实例启用。

结果材质函数越来越多、越来越角色专用(甚至用角色代号命名)。banana 函数就是经典例子:角色 Peely(代号 Banana)首创“翻书式面部动画”本以为是一次性的,结果这种风格成了常态,之后每个用它的角色都得开 use banana 标记。

最终父材质的一个 section 就积累了 282+ 个静态开关,材质大到打不开、一开就卡死电脑。

解法:用 Python Unreal API遍历所有外观材质实例、统计为 true 的静态开关,选出 top 10 材质函数,清理后放进新材质,并规定“不再加新函数”

真正独特的定制外观则整份复制材质再加自定义逻辑,这样的好处是能保持父材质整洁,坏处就是父材质的usage flag会被传播,而且这个代价会扩散到3000多个独特的材质

9. Usage Flag的大范围传播

复制材质时,把父材质累积的使用标记也一并复制了——新外观一开始就带着 Niagara/Nanite 等它不需要的标记。蔓延途径:

  • emote 的粒子系统 → 需要 Niagara 标记;地图区域配环境资产 → Nanite 标记;角色有布料 → 布料标记;这些在所有实例和副本间成倍放大。所有从父材质继承后定制的材质都会累加这些影响,最终造成乘数级别的影响。
  • 项目设置里的自动标记:把材质拖到缺标记的网格上时会自动加标记——建议关掉,否则很容易随手弄脏父材质。

结果:旧父材质派生 8600+ 实例、新父材质 4600+、另有 3000+ 定制材质,几乎都带着不需要的标记,排列成了真正的问题。

10. 用 Python + 引用查看器批量治理

能直接删掉这些flag吗?不能,否则会让依赖它的已发布外观变成灰色 fallback。

正确思路:逐个材质实例、根据它被应用到的网格,判断每个标记到底需不需要。

用引用查看器手动总结出每个标记的规则,再转成 Python 函数:

  • Niagara:往下看 2 层引用(Niagara→网格→外观材质);
  • Nanite:找对静态网格的直接引用且 nanite=true;
  • 布料:骨骼网格的 mesh clothing 数组有条目。

工作流:用 AssetRegistryHelpers(Python 版引用查看器)递归找出父材质的所有实例 → 收集引用存进容器类 → 每个usage flag都有个自定义的python函数去决定其是否在该材质实例上是必须的 → 输出大 CSV(实例路径 + 所需标记)。另一流程用 Python 在实例级覆盖为 true(用 UnrealMaterialEditingLibrary.set_base_material_usage,已扩展到支持材质实例)。

Python相关类的简单示例如下,存储其父材质的材质实例以及所有引用该材质的资产(引用者,referencer)

下面的示例就是关于检测有没有带布料模拟的衣服资产使用了该材质

分支策略:覆盖 true 的工作在 release 分支做(离发布近、只是覆盖已为 true 的状态、零风险);父材质关标记在 main 分支做(离发布远、给 QA 足够测试时间)。

成果:两个主父材质关掉约 6 个标记、3000+ 定制材质关掉大量标记;虽然要在实例级覆盖约 1000 个标记,仍净削减 20 万+ 排列、省 2 GB+ 内存。

规划中的技术美术工具(带 UI,两个模式):

  • 战术视图:加载父材质→列出所有实例,颜色编码显示各实例标记状态(继承/覆盖/是否必要),大量过滤器 + 批量编辑;

  • 战略视图:加载所有材质,按“不必要标记数”降序排,给出优先优化的路线图。

11. 静态参数的新规则与枚举

已发布内容的静态参数难以回改(易弄坏材质)。根因是一开始对静态开关太随意(连选 UV 通道、基础 tint 这种零 GPU 成本的东西都用静态开关)。

新规则——只在两种情况用静态开关:

  • 材质函数含纹理采样(低端硬件采样器数量有限);
  • 数学很重、不会在所有实例用到、会成为 GPU 负担。其余一律用普通 if 语句(最快)。 例:旧做法把两组 UV 塞进静态开关;新做法用 if(参数接 A 引脚、B 引脚设常量 0.5)。注意别接 == 引脚(有额外阈值成本),用> 即可(代码里按≥ 求值)。

枚举(enum)是大赢家:材质/材质实例新增枚举支持,比静态开关界面更好——下拉列表把美术选择的索引返回给标量参数。已建通用枚举(true/false、on/off、UV 通道、blend mode 等)。

  • 静态分量遮罩参数:已无理由再用——改用动态的通道遮罩参数;需要多通道合并时,把 RGB 纹理与 float3 参数(要的通道填 1)做点积 + saturate即可。

12. 总结要点

  • 便利有编译成本:在父材质上放使用标记,若它有大量实例,就会为一堆不需要它的实例编译该标记;复制材质时同理——记得回头收拾使用标记,只留必要的。
  • 静态参数悠着用:虽方便但有代价。简单逻辑优先用 if 语句 + 枚举(性能与界面都更好)。
  • 最重要:项目早期、或开发将有大量实例且内容会增长的材质时,把使用标记下放到材质实例级,父材质只保留绝对必要的最少标记。

13. 关键概念 / CVar 速查

概念 / 设置 说明
shader permutation(排列) 材质 × 顶点工厂类型 × 着色器类型 ×(质量级别 / Shader Model)相乘产生的独立着色器
usage flag(使用标记) 对应顶点工厂类型(静态网格/骨骼网格/ISM/Niagara/Nanite/布料…);默认静态网格=10 shader,全开=186
static switch / static param 静态开关/布尔/分量遮罩;唯一组合才产生排列。新规则:优先用 if + 枚举
material instance usage override 5.8 新增:在实例级覆盖使用标记,避免父材质波及所有子实例
r.SkinCache.CompileShaders(Skin Cache vs GPU Skin VF) 全用 Skin Cache 后可编译掉 GPU Skin 顶点工厂着色器(需先启用 Skin Cache)→ 本例省 2500MB
ISM 统一 ISM 默认改用 local 顶点工厂、从 GPU Scene 读实例数据 → 省 ~400MB
slate.MaterialShaderMode 控制 8 种 Slate 排列编译多少(全动态 / 全变体 / 混合),需权衡性能
ShouldCompilePermutation 引擎函数,决定排列网格里哪些格子真正激活(网格本就稀疏)
UnrealMaterialEditingLibrary.set_base_material_usage Python 批量设置/覆盖使用标记(已扩展支持材质实例)
材质校验数据库 / 着色器审计插件 追踪、预算、告警、diff 构建、size map 下钻着色器内存
channel mask parameter(动态) 取代静态分量遮罩;多通道合并用 RGB · float3 点积 + saturate

说明:本文为知乎作者「洛桑」对Unreal Fest Chicago 2026 演讲的中文整理笔记,配图为视频关键帧截图(1920×1080)。相关功能以 UE5.8 及 Epic 官方文档为准。