UE5.8 材质函数与参数集:把可复用材质逻辑做成全局控制器

📑 目录

一、从一片会呼吸的森林说起

美术小林负责新项目里一片占地四平方公里的森林场景。策划的要求不算复杂:春天叶子翠绿,夏天浓荫深绿,秋天满屏金黄,冬天覆盖白雪;下雨时地表变湿反光,夜晚火把靠近时树干要有暖色辉光。听起来只是换换颜色和粗糙度,真动手之后小林才发现麻烦才刚刚开始。

森林里有一千多棵树、三百多块岩石、两百片草地,还有无数碎石和倒木。每一种资源都来自不同外包团队,材质球数量超过八十。小林最初的做法是把基础母材质复制一份,改一改 Base Color 和 Roughness,再赋给对应模型。秋天到了,策划说树叶颜色要再偏橙一点,小林只能打开八十多个材质球逐个调整。改到第三十个时,他意识到这种重复劳动会把整个项目拖死。

更深层的问题在于,这些材质之间其实共享着大量逻辑:苔藓混合、法线混合、 wetness 叠加、风动画、季节调色。它们本应该被抽象成可复用单元,而不是复制粘贴的节点堆。UE5.8 提供的 Material Function、Material Parameter Collection 和 Dynamic Material Instance,正是为了把这套逻辑从杂乱无章的材质球里解放出来。

二、Material Function:把重复节点打包成积木

Material Function 本质上是一段可以被多个材质引用的子图。它有自己的输入输出节点,内部可以包含任意材质表达式。当你在某个材质里调用它时,就像调用一个函数:传入参数,得到结果。

小林做的第一个函数叫 MF_FoliageWind。树叶随风摆动需要基于顶点位置做正弦偏移,传统做法是在每个植被材质里复制一套 World Position Offset 节点。现在他把这套逻辑封装进函数:输入顶点位置、风向、风速、摆动强度,输出偏移后的位置。

flowchart TD
    A["植被母材质 M_FoliageMaster"] -->|"调用"| B[MF_FoliageWind]
    C["草地材质 M_Grass"] -->|"调用"| B
    D["灌木材质 M_Bush"] -->|"调用"| B
    E["树材质 M_Tree"] -->|"调用"| B
    B --> F["World Position Offset 输出"]
    F --> G["所有植被模型同步摆动"]

函数内部可以用 Scalar、Vector、Texture 输入节点暴露可调参数。小林在 MF_FoliageWind 里放了一个 Speed 标量输入和一个 Direction 向量输入,这样不同植被可以共用同一套风算法,却拥有各自的风速和风向。策划突然要求加大暴风天气的摆动幅度,小林只需要改函数内部的一个乘法系数,所有调用该函数的植被会同步更新。

函数复用带来的好处不只是省时间。它把材质图的复杂度压缩到人可以理解的层级。母材质 M_FoliageMaster 里不再是一团纠缠的节点,而是几个清晰的函数调用:MF_FoliageWind、MF_SeasonTint、MF_Wetness、MF_NormalBlend。每个函数内部依然复杂,但对外只暴露几个输入输出,阅读和维护难度直线下降。

函数还可以嵌套。小林写了一个 MF_Porosity,根据粗糙度和曲率计算孔隙度;再写一个 MF_Wetness,内部调用 MF_Porosity 来决定湿润区域。这种分层让材质逻辑接近普通代码的函数调用关系,而不是平铺的节点海洋。

三、Material Parameter Collection:全局参数的中央仓库

风的问题解决了,季节颜色怎么办?森林里有八十多个材质,每个材质都需要知道当前是春天还是冬天。如果每个材质单独设置季节参数,切换季节时依然要改八十多处。小林需要的是一个全局存储:所有材质都能读取,运行时修改一次就能影响全场景。

UE5.8 的 Material Parameter Collection(简称 MPC)就是这个全局仓库。它在 Content Browser 里是一个独立资产,内部可以定义 Scalar Parameter 和 Vector Parameter。任何材质都可以通过 Collection Parameter 节点按名称读取这些值。运行时通过 C++ 或蓝图调用 Set Scalar Parameter Value 和 Set Vector Parameter Value,就能实时改变所有引用该 MPC 的材质。

小林建了一个 MPC_GlobalWeather,里面放了这些参数:

  • SeasonLerp:00 表示春天,11 表示夏天,22 表示秋天,33 表示冬天
  • Wetness:0011,控制地表和物体湿润程度
  • SnowCoverage:0011,控制积雪覆盖强度
  • WindSpeed:全局风速
  • TorchWarmth:火把靠近时额外暖色强度
flowchart TD
    subgraph mpc ["MPC_GlobalWeather"]
        season["SeasonLerp"]
        wet["Wetness"]
        snow["SnowCoverage"]
        wind["WindSpeed"]
        torch["TorchWarmth"]
    end
    subgraph materials ["引用材质"]
        tree["M_Tree"]
        grass["M_Grass"]
        rock["M_Rock"]
        ground["M_Ground"]
    end
    season --> tree
    season --> grass
    season --> rock
    season --> ground
    wet --> tree
    wet --> grass
    wet --> rock
    wet --> ground
    snow --> tree
    snow --> grass
    snow --> rock
    snow --> ground
    wind --> tree
    wind --> grass
    torch --> tree
    torch --> rock

在材质函数 MF_SeasonTint 内部,小林用 Collection Parameter 节点读取 SeasonLerp,再用 Lerp 在春、夏、秋、冬四张颜色查找纹理之间插值。因为所有植被都调用 MF_SeasonTint,所以只要 MPC 里的 SeasonLerp 一变,整片森林都会跟着变色。

MPC 的强大之处在于它跨越了材质实例层级。Dynamic Material Instance、普通 Material Instance、甚至母材质本身,都可以读取同一个 MPC。这意味着游戏逻辑只需要和 MPC 打交道,不需要关心具体有多少个材质实例。

四、Dynamic Material Instance:运行时单独调参

全局参数解决了共性问题,但游戏里还有很多需要单独控制的材质。比如玩家点燃的火把,只有靠近树干时才应该让树皮变暖。这个效果不能通过 MPC 一刀切,因为不同树干与火把的距离不同。

Dynamic Material Instance(DMI)就是针对这种单对象运行时调参的方案。它在运行时为某个网格体创建一个可写材质实例,然后可以调用 Set Scalar Parameter Value、Set Vector Parameter Value、Set Texture Parameter Value 修改该实例的参数。这些修改只影响这个实例,不影响同材质的其他对象。

小林在树皮母材质里放了一个 Scalar Parameter 叫 TorchDistance,默认值为 10001000。当玩家举着火把移动时,他在 Tick 里遍历附近树干,为每个树干创建 DMI,并把 TorchDistance 设为树干到火把的距离。材质内部根据 TorchDistance 做衰减:

Warmth=TorchWarmthMPC×max(0,1TorchDistanceInfluenceRadius)Warmth = TorchWarmth_{MPC} \times \max\left(0, 1 - \frac{TorchDistance}{InfluenceRadius}\right)

这里 TorchWarmthMPCTorchWarmth_{MPC} 来自全局 MPC,InfluenceRadiusInfluenceRadius 是材质内部定义的常量,TorchDistanceTorchDistance 是每个树干独有的 DMI 参数。两者相乘得到最终暖色强度。

sequenceDiagram
    participant Player as "玩家"
    participant GM as GameMode
    participant MPC as MPC_GlobalWeather
    participant Tree as "树干 Actor"
    participant DMI as "Dynamic Material Instance"
    participant Mat as M_Bark

    Player->>GM: 举着火把移动
    GM->>MPC: Set Scalar Parameter(WindSpeed, 2.5)
    GM->>Tree: 更新附近可互动对象列表
    Tree->>DMI: Create Dynamic Material Instance(M_Bark)
    Tree->>DMI: Set Scalar Parameter(TorchDistance, 120)
    DMI->>Mat: 读取 MPC 全局参数
    Mat->>Mat: 混合暖色与基础树皮颜色
    Mat-->>Tree: 更新渲染

DMI 的创建最好集中在对象初始化阶段或第一次需要变化时。小林在树干 BeginPlay 里先创建好 DMI 并缓存到变量,之后每帧只改参数,不重复创建。频繁 Create Dynamic Material Instance 会造成不必要的内存分配和渲染状态构建。

五、参数类型:Scalar、Vector、Texture 该怎么选

材质参数有三种主要类型。小林在搭建系统时踩过不少坑,后来总结出一套选择标准。

Scalar 是单浮点数,适合表示强度、比例、距离、时间、开关。例如 Wetness、SnowCoverage、TorchDistance、WindSpeed 都是 Scalar。Scalar 在 GPU 上占一个 float,开销最低。

Vector 是四维向量,通常表示颜色或方向。MF_SeasonTint 里用到的季节颜色虽然来自纹理,但中间过渡颜色可以直接用 Vector Parameter 调试。TorchWarmth 在 MPC 里存为 Vector,RGB 通道分别控制暖色偏移。Vector 占四个 float,适合打包多个相关标量,比如把风向的三个分量 xyz 和风速 w 存在一个 Vector 里,一次更新就能同步四个相关值。

Texture 参数用于切换贴图。小林最初想为每个季节准备一张 Base Color 贴图,通过 Texture Parameter 在运行时切换。后来发现直接用一张四季调色纹理配合 SeasonLerp 做 Lerp 更省内存,也更容易做平滑过渡。Texture 参数更适合那些确实需要完全不同贴图的情况,比如建筑外墙从砖切换到涂鸦,或者角色装备从不同皮肤贴图之间切换。

graph LR
    A["运行时需求"] --> B{需要逐对象控制?}
    B -->|"是"| C["Dynamic Material Instance"]
    B -->|"否"| D{需要全局统一?}
    D -->|"是"| E["Material Parameter Collection"]
    D -->|"否"| F["Material Instance 静态参数"]
    C --> G["Scalar / Vector / Texture"]
    E --> G
    F --> G

Static Switch Parameter 是另一种常见参数,但它在实例化时确定,运行时无法修改。小林把是否启用风动画、是否启用雪覆盖做成 Static Switch,美术在创建实例时勾选。这样 Shader Variant 在编译期就确定,不会增加运行时的分支开销。

六、全局控制:季节与天气怎样影响整片森林

策划的季节需求拆解后,小林发现真正需要全局控制的只有几个维度:颜色、覆盖层、湿度、风速。他把这些维度映射到 MPC_GlobalWeather 的参数里,再由各个材质函数读取。

季节颜色由 MF_SeasonTint 处理。函数内部用 SeasonLerp 在四张颜色 LUT 之间采样,再与原 Albedo 相乘。春天亮度略高,夏天饱和度最高,秋天偏橙黄,冬天整体去饱和并加蓝灰。因为函数被所有植被调用,整片森林会一起变色,不会有某棵树还绿着、旁边树已经黄了的割裂感。

湿润度 Wetness 影响两部分:粗糙度和法线细节。下雨时 Wetness 从 00 升到 11,材质函数 MF_Wetness 把 Roughness 从干燥值向湿润值 Lerp,同时在法线上叠加一层雨滴微表面细节。地面材质还会把 Specular 稍微提高,模拟积水反光。所有材质读取同一个 Wetness,所以场景干湿状态一致。

积雪由 SnowCoverage 控制。小林没有简单地在顶部盖一张白贴图,而是写了一个 MF_SnowAccumulation:根据世界法线的 z 分量判断朝上表面,按曲率和遮挡关系混合雪色。雪覆盖强度由 MPC 控制,冬天一键铺满,春天一键消退。岩石、树干、草地都调用这个函数,所以积雪会自然出现在所有朝上的表面。

风速 WindSpeed 影响 MF_FoliageWind。小林把风速分成了两层:MPC 里的全局风速控制整体强度,植被材质实例里的 WindIntensity 控制单种植被的响应倍率。这样强风天气所有植被都会摆动,但芦苇摆得比橡树更剧烈,符合直觉。

火把暖色 TorchWarmth 虽然放在 MPC 里,但它更像一个全局开关。只有当玩家举着火把时才设为非零,材质内部再根据各对象自己的 TorchDistance 计算局部强度。

七、运行时修改材质:C++ 与蓝图两条路

UE5.8 里修改 MPC 和 DMI 都有蓝图节点和 C++ API。小林的团队在 GameMode 里用 C++ 驱动全局天气,在需要特殊交互的 Actor 里用蓝图驱动局部参数。

更新 MPC 的 C++ 代码大致如下:

// 在 GameMode 或天气管理器里
UMaterialParameterCollection* MPC = LoadObject<UMaterialParameterCollection>(
    nullptr, TEXT("/Game/Materials/MPC_GlobalWeather.MPC_GlobalWeather"));

if (UKismetMaterialLibrary::GetMaterialParameterCollectionKey(MPC).IsValid())
{
    UKismetMaterialLibrary::SetScalarParameterValue(
        GetWorld(), MPC, FName("SeasonLerp"), 2.0f);
    UKismetMaterialLibrary::SetVectorParameterValue(
        GetWorld(), MPC, FName("TorchWarmth"), FLinearColor(1.0f, 0.4f, 0.1f, 0.0f));
}

蓝图中只需要找到 Set Scalar Parameter Value 节点,选择 MPC 和参数名即可。参数名是字符串,拼写错误不会报错,但材质不会更新,所以小林建议把参数名做成蓝图变量或 C++ 常量,避免硬编码。

DMI 的参数修改通常在 Actor 里:

// 在树干 Actor 里缓存 DMI
void ATreeActor::BeginPlay()
{
    Super::BeginPlay();
    UMaterialInstanceDynamic* DMI = Mesh->CreateDynamicMaterialInstance(0, Mesh->GetMaterial(0));
    CachedDMI = DMI;
}

void ATreeActor::UpdateTorchEffect(const FVector& TorchLocation)
{
    if (!CachedDMI) return;

    const float Distance = FVector::Dist(GetActorLocation(), TorchLocation);
    CachedDMI->SetScalarParameterValue(FName("TorchDistance"), Distance);
}

这段代码只有不到 50 行,却完成了运行时创建、缓存和更新。小林在 Tick 里只调用 UpdateTorchEffect,不重复创建 DMI。

八、材质实例层级:母材质、实例、DMI 的关系

理解材质实例层级对性能很重要。最底层是 Material Asset,也就是母材质,包含完整的节点图。中间是 Material Instance(常数或静态参数),它继承母材质,可以覆盖部分参数但不改变节点结构。最上层是 Dynamic Material Instance,可以在运行时修改参数。

编译时,母材质和它的静态实例会共享同一个 Shader。DMI 也继承同一套 Shader,只是 Uniform Buffer 里的参数值不同。因此创建 DMI 的 GPU 开销主要来自设置新的 Uniform 和 Render State,而不是重新编译 Shader。

小林把尽可能多的事情放在 Material Instance 静态层级。季节、天气这类全局值用 MPC;固定属性如树皮类型、草的种类用普通实例参数;只有确实需要运行时变化的属性才用 DMI。这样 Shader 变体数量可控,运行时也不会被大量 DMI 创建拖垮。

graph BT
    A["Material Asset 母材质
完整节点图"] -->|"继承节点结构"| B["Material Instance 静态实例
覆盖静态参数"] A -->|"继承节点结构"| C["Dynamic Material Instance
运行时覆盖参数"] B --> D["植被实例 A"] B --> E["植被实例 B"] C --> F["火把影响中的树干"] C --> G["被玩家破坏的墙体"] H["Material Parameter Collection"] --> A H --> B H --> C

九、性能与调试:别让全局参数变成全局灾难

Material Parameter Collection 虽然方便,但也不是越多越好。每个 Collection Parameter 节点都会从全局 Uniform Buffer 读取值。如果材质里引用了大量不相关的 MPC 参数,Uniform Buffer 占用会增加,虽然现代 GPU 上通常不是瓶颈,但在移动端或复杂场景里仍然需要注意。

小林的做法是按主题拆分 MPC。他建了 MPC_GlobalWeather、MPC_Lighting、MPC_Gameplay 三个集合。天气相关材质只读取 MPC_GlobalWeather,UI 后处理材质只读取 MPC_Gameplay,互不污染。这样也降低了参数名冲突的概率。

DMI 的数量需要监控。小林在开发控制台加了条命令 r.MaterialInstanceDynamic.List,虽然 UE 原生没有这条命令,但他自己写了一个 Stat 收集器,每帧统计当前活跃的 DMI 数量。如果某个关卡 DMI 数量异常飙升,多半是某个 Actor 在 Tick 里重复创建实例。

材质函数的复杂度也要控制。函数内部节点虽然会被展开到调用处,但过度嵌套会让材质图编译变慢,调试窗口里展开函数也会卡。小林建议单个函数不要超过两百个节点,嵌套层级控制在三层以内。

调试时可以用 Material Instance Editor 的 Override 功能快速看参数效果。小林经常在编辑器里拖动 Wetness 滑块,看岩石干燥状态和雨水中浸泡状态之间的过渡是否符合预期。对于 MPC,可以在 Content Browser 里双击 MPC 资产,直接改默认值,场景会实时更新,比运行游戏更快验证。

还有一个常见坑是参数命名。MPC 和 DMI 都按字符串查找参数,如果材质里引用了一个不存在的参数名,材质编辑器会给出警告但还能编译。小林养成了在团队 Wiki 里维护参数表的习惯,新增参数前先查表,避免重复命名和拼写错误。

十、小林现在的材质工作流

现在小林再接到季节或天气需求时,流程已经变得清晰。他先判断这个需求是全局还是局部:全局就放进 MPC,局部就看是否需要运行时变化。运行时变化用 DMI,固定差异用普通 Material Instance 参数。共享逻辑抽成 Material Function,母材质保持简洁。

火把靠近树干的暖色、雨水打湿地表、暴风摇动植被、四季切换森林颜色,这些曾经需要改动八十多个材质球的需求,现在大部分只需要改几个 MPC 参数或几个材质函数。小林把省下来的时间用在了更有价值的事情上,比如优化 MF_Wetness 里的雨滴微表面细节,让石头在雨中看起来真的像被水泡过一样。

材质系统的可维护性不是一蹴而就的。小林也是从复制粘贴开始的,直到被重复修改逼到墙角,才回头去抽象。Material Function、Material Parameter Collection 和 Dynamic Material Instance 这三件套,本质上是在回答同一个问题:哪些东西应该共享,哪些东西应该全局控制,哪些东西应该单独控制。回答清楚了,材质图就不再是一团乱麻,而是一套有层次、可扩展、可调试的系统。

flowchart LR
    A["需求: 季节/天气/交互"] --> B{是否共享逻辑}
    B -->|"是"| C["封装为 Material Function"]
    B -->|"否"| D["保留在母材质内"]
    C --> E{是否全局统一}
    E -->|"是"| F["写入 Material Parameter Collection"]
    E -->|"否"| G{是否运行时变化}
    G -->|"是"| H["使用 Dynamic Material Instance"]
    G -->|"否"| I["使用 Material Instance 静态参数"]
    F --> J["渲染管线统一读取"]
    H --> J
    I --> J
    D --> J

UE5.8 在材质系统上的改进让这套抽象更加稳定。函数引用、参数集合和动态实例之间的边界非常明确,开发团队可以各自负责不同层级:TA 写函数和母材质,美术调实例参数,程序驱动全局和运行时数值。只要每个人都守好自己的边界,森林就会按策划期望的方式呼吸、变色、淋湿、燃烧。