材质与特效: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 自由度;最需要改进的地方,是把这种限制散落成了多份手写规则。后续缓存、批量应用和分帧队列降低了成本,但真正的架构升级应是统一材质契约:发布期决定可以编译什么,运行时只决定使用哪个已编译变体、写入哪些动态参数,并在不改变通知和生命周期语义的前提下提交。