材质与特效:MID 能改参数,不能替你编译 Shader
自动关联目录:材质与特效:MID 能改参数,不能替你编译 Shader
核心判断:创作者看到的是“换材质、调颜色、挂特效”,UE 执行的却是“选择已 Cook 的 Shader 变体,再向实例写动态参数”。因此,材质数据不能只是
MaterialPath + Map<string, value>;它至少要同时表达稳定槽位、预编译父材质、类型化覆盖、依赖资产、异步代际和失败回退。特效也不是一个粒子文件,而是一张包含材质、纹理和网格的依赖图。
一、先划清边界:哪些可在 MID 上改,哪些必须提前编译
UMaterialInstanceDynamic 适合修改父材质已经暴露的 Scalar、Vector 和 Texture 参数。它不能在发布包里重新编译材质图,也不能把静态开关、Blend Mode、Two Sided、Shading Model、深度行为或 WPO 能力动态变成另一套可靠的 Shader。
| 变化 | 运行时 MID | 对渲染表示的影响 |
|---|---|---|
| 颜色、强度、UV、已声明纹理参数 | 可写 | 通常复用同一编译变体 |
| 静态开关、Blend Mode、Two Sided | 不应伪装成动态参数 | 需要另一父材质或 Cook 变体 |
| WPO、特殊深度和粒子专用能力 | 需要预声明能力 | 可能改变顶点工厂、Pass 或合批资格 |
| 网格材质槽 | 可换 UMaterialInterface | 可能改变 Section、PSO 与 Draw Call |
实际代码曾尝试在运行时构造材质表达式并触发编辑器式刷新,这条路最终被删除:打包客户端和 DS 没有可依赖的材质编译环境。正确做法是准备有限的父材质族,运行时只选择变体并创建 MID;父材质缺失时退回能力更弱但确定可用的效果,例如从复杂裁剪退回矩形裁剪。
这也解释了一个容易误判的性能问题:把多个网格都改成“同名材质”不等于它们能合批。父材质变体、Blend Mode、Two Sided、WPO 和 Section 布局中任一项不兼容,都必须保留独立渲染批次。
二、早期固定材质目录解决了失控,也制造了耦合
早期方案把可用材质做成固定配置表,以数字 ID 选择父材质;粒子编辑器再维护一份可调参数清单,声明参数名、范围和控件类型。属性修改后,UI 直接写组件、刷新预览并登记撤销。
这套方案并不落后,它有两个重要优点:
- 创作者只能选择经过验证和 Cook 的父材质,Shader 变体、移动端兼容和包体成本可控。
- 参数范围和控件被策划化,不会把完整材质图能力直接暴露给运行时。
问题在于,同一语义被分别写在配置表、属性面板、组件字段、预览刷新、撤销记录和运行时应用里。实际故障包括首次打开面板颜色显示为白色、普通与高级面板状态不同步、快速拖动颜色或渐变参数时明显卡顿,甚至因密集刷新进入不稳定路径;自定义参数还需要单独补撤销逻辑。
根因不是某个 UI Bug,而是缺少一份可生成各层代码的材质 Schema。增加参数时,开发者必须手工保证“编辑控件、序列化、运行时白名单、MID 参数名和回读”同时变化,任何遗漏都会形成静默分叉。
三、覆盖值必须分层,槽位也必须有身份
后续方案把材质状态拆成三层,这是值得保留的设计:
| 层 | 含义 | 是否持久化 |
|---|---|---|
| L1 | 对象级透明度、反射等通用属性 | 是 |
| L2 | 资产描述中的父材质默认值 | 否,由资产版本决定 |
| L3 | 用户明确修改的参数覆盖 | 是 |
最终值是 L3 > L2 > 父材质默认值。L2 异步到达时不能把场景标脏,也不能覆盖 L3;但它仍要通知 Actor 和 DMI 刷新。工程里曾出现服务端 L1 默认值复制到客户端后压过 L2 外观、切换材质仍残留旧发光参数、清除 L3 后数据已正确但视口没有重新应用 L2 等问题,说明“值存对了”和“运行时表示已提交”是两份状态。
类型也不能被字符串 Map 抹平。空字符串曾同时承担“没有填”“继承默认值”和“清空纹理”,最终必须拆成明确操作:
using MaterialValue = variant<Scalar, Vector, TextureRef>;
struct ParameterOp
{
ParameterName name;
ParameterType type;
enum { Set, ResetToDefault, ClearTexture } operation;
optional<MaterialValue> value;
};同名 Scalar 和 Texture 参数也应是不同键。UE 的材质参数最终使用 FName 查找,大小写或空格错误通常只会让 MID 写入无效而不报错;发布验证必须确认参数名和类型确实存在于目标父材质,不能把检查留给运行时。
材质槽同样不能只存数组下标。网格替换、预设重建、组合与拆分都可能改变 Section 顺序,实际也出现过动态重建后首槽材质丢失,以及槽位状态未进入复制快照的问题。持久化键应是:
MeshIdentity + StableSlotKey + ParentVariant + TypedOverrides加载新网格时用稳定槽位键重映射;无法匹配时记录明确回退,不能让 slot[0] 猜测原来的业务含义。DMI 只是运行时投影,绝不能反向充当存档源。
四、一次“换材质”为何会变成主线程尖峰
实际加载链并非一次 SetMaterial:
读取描述 -> 解码 -> 解析默认值和覆盖 -> 解析父材质
-> 获取依赖纹理 -> 创建 DMI -> 逐项写参数
-> 逐项广播属性变化 -> 刷新编辑器和场景状态大量对象在同一帧完成异步请求时,重复解码、重复整理相同参数、反复重建 DMI 和动态多播会同时集中到 Game Thread。早期对每个属性沿用完整命令路径,语义最稳妥,但一次材质完成会触发多次应用和通知,拖动参数时还会把未完成的旧结果继续送回来。
后续优化分了三层,而不是用一个“大缓存”解决所有问题。
第一层是纯解码缓存。 以原始字节的 CRC 缩小候选,再做完整字节相等校验;只缓存成功结果,非法输入不缓存,超预算的大输入正常解码但不进入缓存。缓存随场景绑定和格式版本失效,返回值独立持有,不保存 UObject、回调或 DMI。
第二层是不可变准备结果。 把已解析描述转换为类型化参数计划,包括 Scalar、Vector、Texture、Reset 和 ClearTexture。相同内容和版本可以共享这份纯数据,具体对象仍拥有独立 DMI、纹理请求和 Generation。
第三层才是应用事务。 仅当父材质已经驻留、Actor 类型受支持、所有通知接收者都经过审计时,原生路径才一次重建 DMI、写入最终值,然后按原顺序重放通知;任何条件不满足,都在修改对象之前整体退回旧路径。
bool TryApplyPreparedMaterial(Target& target, const PreparedPlan& plan)
{
Admission admission = Preflight(target, plan);
if (!admission.allObserversAudited || !admission.parentAlreadyResident)
return false; // 尚未修改,调用方可完整执行旧路径
RuntimeMaterial next = BuildMIDOnce(admission.parent);
ApplyFinalValues(next, plan.orderedOps);
Commit(target, move(next));
ReplayOriginalNotifications(target, plan.notifications);
return true;
}“父材质已经驻留”是关键限制。批量快路径不能在准入阶段同步加载资产,否则 LoadObject 可能泵消息、触发回调和重入,事务还没决定是否接管就已经改变了观察顺序。
五、通知不是附带成本,而是行为契约
属性变化通知不仅更新 UI,还可能触发资源失效、捕获取消、脏标记和生命周期逻辑。把一串通知合并成“材质已更新”虽然快,却会改变订阅者看到的中间状态和重入行为。
工程里的原生快路径因此采用了严格准入:先复制动态委托快照,确认每个接收者都是已审计的原生类和函数;只要出现 Blueprint、未知函数、网络函数或不安全生命周期,就让整条通知走原始动态多播。快路径仍保持原通知顺序、同步时机、弱引用快照和订阅列表在回调中变化的语义。
这比给所有通知统一加 C++ 缓存更谨慎,也更正确。它的代价是新增接收者默认失去优化资格,需要显式审计。我的判断是这个代价值得:优化可以选择不生效,但不能在不知情时改掉可观察行为。
六、分帧应用只能削峰,不能伪装成总耗时下降
材质完成回调随后进入场景级 FIFO,以“每帧最多启动多少个完整对象事务”限流。一个对象的解码结果、DMI 提交和通知不可拆开;回调真正执行时重新检查对象、材质引用和 Generation,旧结果不会因为排队而复活。关闭限流时先排空旧队列,再允许新任务直达,避免新请求超车。
这解决的是同帧集中完成造成的尖峰,代价是可见就绪延迟。现有实现仍有明确局限:它按对象数量而非耗时预算,同样一个名额可能是简单颜色,也可能包含多纹理和大量通知;它只覆盖这条材质加载入口,不代表全局材质工作都受控。
更好的调度应同时记录 ApplyCpuTime、等待年龄和依赖状态,在保持单对象事务与 FIFO 基本顺序的前提下使用“数量上限 + 时间软预算 + 防饥饿年龄”。不能为了帧稳而把已经可见的近处对象长期排在后台装饰之后。
七、Shader 变体必须在发布期收口
运行时导出材质时,工程最终采用了很清晰的边界:
- 持久化的
UMaterial或UMaterialInstanceConstant原样保留,它代表一个已经编译的变体。 - 临时 MID/MIC 链只展平动态 Scalar、Vector 和 Texture,子层覆盖父层。
- 遇到第一个持久化父实例就停止展平,保留它的静态语义。
- 临时层若修改静态参数、基础属性或不支持的运行时字段,发布直接失败,不做“看起来差不多”的降级。
这是 UE 特性决定的。MID 的基础属性读取会委托父材质,运行时 Setter 也不能重建静态排列。若创作者有多个独立静态开关,理论变体数近似为各开关状态数的乘积;把所有能力塞进一个万能父材质,只会把复杂度转移到 Shader 编译时间、Shader Code、PSO 首次使用和低端设备指令成本。
目标不是完全禁止变化,而是把变化分为两类:
VariantKey = 需要 Cook 的有限能力组合
Overrides = Variant 已声明的运行时参数新增 Variant 必须给出实际内容需求、目标平台、Shader/PSO 成本和回退策略。未发布的 Variant 在运行时不能临时编译;应回退到兼容父材质并记录原因,开发与验证环境则把这种回退视为待修复错误。
八、特效不是一个资产,而是可验证的依赖闭包
特效编辑数据包含发射器、模块和参数,真正播放还依赖父材质、纹理、网格及平台制品。只把 JSON 交给运行时重建,虽然易编辑,但解析和对象构造成本高,也难以证明发布包已包含全部依赖;只保留二进制,又会让旧播放格式失效时无法迁移源数据。
后续实现将两者分离:版本化容器同时保存可编辑源数据和独立版本的播放记录,资源索引引用逻辑资产。加载时优先使用通过版本、尺寸、校验和依赖索引验证的二进制;播放版本过旧或二进制不可用时,仍可从源数据重建。未知粒子模块、非法参数或缺失依赖在导出阶段失败,而不是静默丢掉一部分效果。
材质父级在特效链上也必须保持一致:持久化父材质实例必须原样记录;临时动态层只允许展平动态参数,显式空纹理仍是有效覆盖。组件级覆盖和父材质默认值即使引用同一资源,也属于不同实例状态,不能为了复用 DMI 而混为一份。
运行时还必须处理 UE 异步加载边界:加载器处于暂停区间时,不得用同步 TryLoad 强行拉起父材质;只能使用已驻留对象或延迟应用。纹理完成回调需要同时校验发射器、参数名、资源引用和 Generation,再更新 DMI 与 Render State。编辑预览可以分帧提交发射器材质,正式播放则应使用已准备好的依赖,不能把编辑器削峰策略带成播放延迟。
DS 不创建视觉组件,但仍需读取逻辑特效定义中与玩法有关的对象和时序信息;这也是源数据与平台播放制品不能混成一个 uasset 路径的原因。
九、仍然不够好的地方
现有演进已经解决了大量真实问题,但还有四个结构性缺陷。
Schema 仍未完全统一。 脚本字段表扩展后,原生批量白名单曾未同步,新参数因此静默退回或不生效。参数定义、类型、默认值、编辑控件、序列化和原生派发应由同一 Schema 生成,并在 Cook 时与父材质反射结果交叉校验。
缓存层级分散。 脚本桥、原生解码、材质子系统和不同对象入口各自做过复用,局部都合理,但缺少统一的命中、失效和版本观测。缓存正确性不能只靠“当前内容相同”,还必须包含格式版本、目标平台和 World 生命周期。
预算还不是统一调度。 材质回调、纹理依赖和编辑态特效分别排队,局部削峰可能在同一帧汇合。尤其进程级编辑预览队列在多 World/PIE 场景下难以表达公平性;更合理的是 World 级展示工作调度器,按任务阶段和可见重要度统一预算。
回退提高了可用性,也可能掩盖错误。 JSON 重建、旧路径和简化父材质能避免内容完全不可见,但若没有结构化原因码和验证环境告警,发布缺少变体或依赖会长期以低质量模式运行。
十、目标架构:编译材质契约,再提交运行时事务
struct MaterialContract
{
AssetIdentity mesh;
StableSlotKey slot;
VariantKey parentVariant;
vector<ParameterOp> authoredOverrides;
vector<TypedAssetRef> dependencies;
SchemaVersion schema;
};
struct PreparedMaterialPlan
{
ResolvedParent parent;
vector<ParameterOp> finalOrderedOps;
DependencyClosure closure;
FallbackPolicy fallback;
};
PreparedMaterialPlan Prepare(
const MaterialContract& contract,
const PlatformTarget& target); // 纯函数,可按内容和版本复用
ApplyResult Commit(
RuntimeTarget& object,
const PreparedMaterialPlan& plan,
Generation expected); // Game Thread 上的单对象事务完整状态机应是:
Resolve identity -> Verify artifact -> Prepare immutable plan
-> Await dependencies -> Budgeted apply queue
-> Recheck generation -> Commit DMI once
-> Replay notifications -> Ready
\-> Explicit fallback + reason特效复用同一套材质契约,但在外层增加版本化播放图和依赖闭包。这样,材质槽、粒子 Renderer 和 UI 材质不再各自发明一套参数语义;它们只负责把准备结果提交给不同 UE 组件。
十一、怎样证明方案真的更好
| 阶段 | 必须观测的量 |
|---|---|
| 发布 | 变体数、Shader Code、Cook/编译时间、依赖闭包失败、回退原因 |
| 准备 | 解码/Prepare 时间、缓存命中、无效与超预算输入、重复工作量 |
| 应用 | 每帧事务数和 CPU 时间、队列年龄、DMI 创建数、通知耗时 |
| 渲染 | Section/Draw Call、PSO 首次命中、材质驻留、纹理依赖就绪 |
| 正确性 | 槽位重映射、L2/L3 继承、迟到回调、编辑/运行/DS 一致性 |
回归测试也应紧贴这些契约:精确字节缓存不能把 CRC 碰撞当命中;非法结果不得污染缓存;World 和格式版本必须隔离;通知快路径要覆盖顺序、重入和未知 Blueprint 回退;队列要覆盖 FIFO、取消、Generation 和开关切换;槽位要覆盖网格替换与组合/拆分;特效要覆盖容器损坏、播放版本不兼容、依赖缺失、持久化父实例和显式空纹理。
我的反思是:早期最正确的决定不是“用了固定材质 ID”,而是主动限制了 Shader 自由度;最需要改进的地方,是把这种限制散落成了多份手写规则。后续缓存、批量应用和分帧队列降低了成本,但真正的架构升级应是统一材质契约:发布期决定可以编译什么,运行时只决定使用哪个已编译变体、写入哪些动态参数,并在不改变通知和生命周期语义的前提下提交。