
做材质做了不少年最常被问的一句话是“这个效果怎么做得这么干净”其实答案往往不在美术资源里而在材质编辑器更深一层的地方。UE的Material Editor初学时大家喜欢纯连节点觉得可视化直观真到想要性能、想全局联动、想做特殊光照和风格化效果的时候纯节点反而成了瓶颈。这篇继续我们41系列的进阶话题自定义表达式、Custom HLSL、MPCMaterial Parameter Collection材质参数集合和渲染管线集成。它们解决的简单说就是“材质如何从静态参数变成动态代码再融入整个渲染流程”的问题。适合已经有材质基础、但被节点图绕得头疼或者想从TA视角理解渲染链路的人参考。1. 内容整体设计与思路拆解1.1 为什么要在材质里写代码之前聊材质节点的设计哲学时我常打一个比方材质编辑器像乐高节点就是各种标准积木拼装快乐但拼到一定复杂度你会发现积木的形状并不完全匹配你想造的东西。这时候要么强行拼要么开始切割积木——也就是写自定义代码。UE的Material Editor里任何一张材质图最终都会编译成一份着色器代码节点图本身只是这份HLSL的“可视化前端”。所以理论上你在节点里能做的代码里全能做节点里不能轻易做的循环、分支判断、编译期推导、自定义数学函数、读取一些内部变量代码里反而能更直接地实现。很多人对写代码进材质有恐惧但实际体验是一旦跨过“代码会编译错误”这道坎效率反而比拖节点高一截而且改起来更直观。为什么把这个和MPC放一起讲因为写代码解决的是“单个材质内部的表达能力”MPC解决的是“多个材质之间、以及代码和材质之间的数据共享”。单写代码每次改效果还要进材质编辑器配合MPC你可以在运行时从蓝图或C里改参数所有引用了同一个参数集合的材质一起响应。再往深一层如果你还要让材质跟自定义Pass、全局Shader变量联动那理解“参数怎么从MPC流向渲染”就非常关键。三者合起来才是完整的一套进阶能力。1.2 整体设计思路自下而上分三层理解我习惯把这类工作拆成三层来规划避免一头扎进代码里把思路写乱。第一层是“材质内部表达层”。这一层负责把视觉目标变成一段可执行的着色器逻辑。通常是Custom Expression或Custom节点也可能是全套节点图。重点在于“表达式”本身能正确产出颜色、粗糙度、法线等输出。第二层是“参数控制层”。这一层把材质里需要经常调整的量——比如发光强度、噪声缩放、颜色偏移——提升为MPC参数或材质参数。这样做的价值不在于省几个常量而在于把“美术逐个材质去改”变成“运行时统一控制”。它让材质从“静态资源”变成了“可编程的视觉单元”。第三层是“渲染集成层”。这一层考虑的是你的材质参数在什么阶段进入渲染队列能否被其他Pass读取能否被项目级配置覆盖。很多项目里材质本身写得没问题问题出在参数没有正确接入渲染管线导致不同平台表现不一致或性能不达标。这种分层法帮我解决过不少实际麻烦。比如之前做一个多角色技能特效核心材质写得很复杂但全靠节点连一堆宏和嵌套分支最后编译器报指令数超限。后来我把关键计算移到Custom节点里再用MPC暴露了几个关键参数编译后指令数直接降了接近三分之一而且美术调效果不用再求我改材质直接在蓝图里改vector参数就行。这就是进阶方案组合使用的收益。2. 工具选型解析为什么需要 Custom HLSL2.1 纯节点图的天花板很多人会问单纯连节点到底哪里不够用我说几个我实际踩过的坑。第一个坑是逻辑表达。材质不止是算颜色还经常要算“什么时候用什么逻辑”。节点图里虽然有If、Switch、Lerp这类流程工具但真正复杂的分支一多图上全是线后面想维护的人直接崩溃。而HLSL里一行if或者三目运算符就能讲清楚可读性远超一片连线。第二个坑是算法表达。像方向光偏移、环绕光照、菲涅尔变体、程序化噪声这些用数学公式描述最清晰。节点图里虽然有现成的节点但组合起来又臭又长计算精度和中间值处理也不好控制。写代码你可以把公式原样放进去哪一步想优化也一目了然。第三个坑是性能控制。材质图太复杂编译器为了把任意节点图编译成目标平台的着色器往往需要做很多保守推导生成的代码可能包含大量冗余计算。而用Custom HLSL你可以明确控制每一步计算避免编译器生成不必要的中间量。实际上我做过对比同样的效果用Custom节点手写推导后指令数经常能少两位数百分比。2.2 Custom Expression 与 Custom HLSL 的定位差异这是很多刚接触的人容易混淆的地方。在UE里Custom Expression是一个节点它里面可以放一小段HLSL代码用来代替一部分节点逻辑。因为太常用很多人就直接把它等同于“在材质里写代码”。但更准确地说UE材质图表中还有更底层的“自定义节点”Custom和着色器代码集成方式。Custom Expression本质上是把你写的代码片段插入到材质生成的着色器代码中的某个位置它能访问节点输入、也能访问当前像素的UV、坐标等基础数据但它不会给你完整的Pass控制权也不能定义整个Shader的入口。Custom HLSL的说法通常指你在Custom节点里写更大段、更完整的着色器代码甚至可以通过定义自己的函数来处理更独立的算法模块然后暴露给材质图表调用。实际选型时我会这样判断如果只是算一个颜色偏移或者写一小段循环用Custom Expression就够了轻量直接。如果你要做一套独立的着色器算法模块——比如一个自定义的光照模型、一套程序化纹理生成器、一个从网格数据读取并处理的高阶函数——那值得用Custom节点写完整函数再在图表里调用它。两者不冲突共同点是它们写的内容都是HLSL语言只是作用范围和使用深度不同。2.3 集成时的性能预算与平台考虑写Custom HLSL最容易兴奋过头把桌面端能跑的代码一股脑塞进去。等放到移动端一看帧率掉了十几帧甚至直接闪屏。原因主要包括精度问题。移动端很多GPU对half精度的运算更友好float虽然通用但开销更高。Custom代码里如果大量使用float并且不做正确的精度标注移动端会明显吃亏。特性问题。比如纹理采样里的SampleCmp、偏导数计算里的ddx/ddy不同平台支持程度不一样。写之前要查一遍目标平台着色器语言规范别在没验证的情况下写进去。分支问题。GPU对动态分支的执行模型和CPU不同并非“if越少越好”只要分支条件统一且概率高分支本身没问题。怕的是条件依赖像素采样且分叉严重这会让GPU产生极大的性能损耗。这时候宁可把结果计算出来再lerp。我一般会给自己定几条规矩所有可公开调节的量尽量走MPC参数复杂函数拆成小函数每个函数职责单一在代码注释里标注哪些平台可用、哪些不可用新写法先放到PC上做指令数统计再放到目标真机上验证。这条流程虽然繁琐但能省去后期在性能问题里大海捞针的时间。3. 核心细节解析与实操要点3.1 自定义表达式的基本构造打开Material Editor在图表里右键输入Custom Expression你会看到一个带白色输入框的节点。它可以定义多个输入Inputs每个输入可以指定类型、默认值节点主体写代码。最外层需要一个返回值返回值类型通过输出类型来指定。一个最简单的例子做颜色增益float3 result BaseColor * Intensity AddColor; return result;这里BaseColor、Intensity、AddColor都是你在节点输入里定义的引脚。默认类型可以设为float3、float等。我把这类能直接在节点里跑通的小函数叫“表达式级”代码它们是进入Custom世界的第一步。不过要提醒一点Custom Expression节点里的代码UE会尝试把它内联进材质的主着色器代码中。这意味着它不能定义自己的Pubilc输入布局也只能返回一个方向的值。如果你需要同时输出多个结果比如既能当BaseColor又想输出到EmissiveColor通常的做法是写多个独立的Custom Expression节点各自计算结果或者用一个大Custom节点输出到需要的地方。材质图表的节点结构可以自由分支但代码函数本身是单返回值的这个思维要转过来。3.2 写第一段实用 HLSL以边缘发光为例我建议初学者从边缘光开始练手。它计算量小、反馈直观、且涉及向量运算和视角方向很适合理解Custom代码怎么和材质输入协同工作。先建一个Custom节点输入只需要一个float3 NormalWS世界空间法线。float3 viewDir normalize(CameraVector); float fresnel pow(1.0 - saturate(dot(NormalWS, viewDir)), 3.0); return fresnel;这段代码利用法线和视线方向点积计算菲涅尔强度。拿到这个值后在图上把Custom节点的输出连到EmissiveColor乘上一个发光色就能得到边缘发光效果。你会立刻体会到这个算法在节点图里可以用Fresnel节点实现但代码写法让你能快速改指数、改算法比如加一个扭曲噪声源自由度完全不一样。实际操作中我常把这类计算封装成一个函数方便多个材质复用。在自定义节点里这样写float3 MyFresnelGlow(float3 NormalWS, float3 ViewDir, float Power, float Intensity) { float fresnel pow(1.0 - saturate(dot(NormalWS, ViewDir)), Power); return fresnel * Intensity; }然后在主体里直接调用。这种“函数封装”习惯是项目变大后维护成本降低的关键否则每个材质复制一段相同逻辑后面改一个地方要改几十张材质图够你受的。3.3 节点内常见陷阱坐标空间、导数与编译器优化写HLSL进材质最经典的问题就是坐标空间错乱。材质计算中代码里访问的向量、法线、位置并不总是处于同一个坐标系。常见的是世界空间与切线空间混用。你在Custom节点里取到的CameraVector、PixelPosition等很多是引擎帮你摆好的值依赖节点上下文。如果自己写了一个函数传入了来自贴图的法线通常在切线空间却没有先变换到世界空间点积结果就会完全错误。解决办法是养成习惯进入Custom节点前把需要的空间信息通过材质输入引脚明确传进来。不要指望代码里自己重建因为不同引擎版本、不同Pass里可用的内置变量名并不是完全一致的。把输入引脚定义清楚让引擎替你处理空间转换代码里只做纯数学计算这样最稳。另一个坑是导数计算。ddx、ddy这类指令只能用于屏幕空间连续的量对“基于纹理坐标的采样”效果比较好但对“经过大量自定义计算后再采样”的结果用导数经常需要显式使用ComputeScreenPos或UV的技巧来引导。如果你在一个材质里做了多次不同的UV变换再想用代码计算mip等级建议直接使用CalcLevelOfDetail这类内置函数而不是自己用偏导数推。最后是编译器优化问题。Custom代码写太长、逻辑太绕编译器优化起来会很痛苦还可能出现“看似等价但结果不同”的浮点精度差异。我的经验是能让材质节点处理的比如常规贴图采样、混合不要在代码里重复造轮子代码只负责真正的数学逻辑和算法逻辑。节点与代码各司其职编译效率和可读性才会都好。4. MPC 的实战用法4.1 为什么从普通参数升级为 MPC先明确一下这里说的MPC是Material Parameter Collection材质参数集合是UE里一种资源类型用来存放一组标量Scalar和向量Vector参数可以被任意一张材质引用。它和普通材质参数最大的区别是普通材质参数是“每张材质独立一份”MPC是“一份参数全局共享引用它的所有材质共同使用同一份运行时数据”。这个区别在项目里非常关键。举个例子你想做一个全场景受击闪红效果。如果用普通材质参数你得把场景里几十种材质都暴露一个“闪红强度”参数然后写蓝图去逐个设置这个方案听上去就头大。如果用MPC你只需要在所有需要闪红的材质里引用同一个MPC的“HitFlash”参数然后蓝图里只Set一次所有材质瞬间一起亮起来。并行修改、全局反馈这是MPC存在的最本质理由。因此决策逻辑很清楚如果一个参数只影响一张材质且不需要全局联动用普通材质参数就够了。如果一个参数要被多张材质、多个Mesh Instance共用或者需要在运行期从代码/蓝图里统一修改那应该放进MPC。用错层级的后果不严重但会浪费维护时间和沟通成本。4.2 创建 MPC 并在材质中引用创建MPC很简单在Content Browser里右键选择“Material Textures”分类下的“Material Parameter Collection”即可。新建后双击打开你会看到它可以添加两类参数Scalar Parameter浮点数和Vector Parameter四维向量可用于颜色或坐标。每个参数都有默认值项目运行时所有材质一开始读取的就是默认值。建议参数命名有统一规范。团队里如果几个人都在维护MPC起名混乱会非常痛苦。我的习惯是前缀_用途_描述比如Global_Flash_Intensity、Char_Outline_Color。因为MPC可以被很多人引用名字一旦铺开后面改名的成本比改普通参数大得多所以前期规范一定要定好。在材质图里引用MPC只需要右键搜索“Parameter Collection”节点然后指定你创建的MPC资源并从下拉框里选参数名。这样这个材质就和参数绑定了每次渲染时引擎会读取MPC的最新值。要注意的是MPC参数被读取后其值在材质内部的传递是“每帧更新的”。你可以把这个节点连到任何一个材质输入引脚效果和普通参数完全一样。4.3 蓝图与 C 中的运行时修改MPC真正发挥威力是在运行时修改。蓝图里可以直接用Set Scalar Parameter Value in Collection和Set Vector Parameter Value in Collection节点指定MPC资源、参数名、目标值即可。C里方式也直白。先通过引用的方式拿到MPC资源UmaterialParameterCollection* MPC LoadObjectUMaterialParameterCollection(nullptr, TEXT(/Game/GlobalParams.GlobalParams));拿到资源后调用以下接口设置参数if (MPC) { UMaterialParameterCollectionInstance* Instance GetWorld()-GetParameterCollectionInstance(MPC); if (Instance) { Instance-SetScalarParameterValue(FName(Global_Flash_Intensity), 1.5f); Instance-SetVectorParameterValue(FName(Global_Outline_Color), FLinearColor(1, 0.2, 0, 1)); } }这里有一个关键点GetParameterCollectionInstance拿到的是当前世界World中的实例。MPC资源本身是共享的但每个世界有自己独立的运行时实例所以跨关卡切换时要注意重新设置否则参数可能停留在旧关卡的值。很多“参数明明改了进新场景却没效果”的问题根源就在这。4.4 多材质共享与全局管理的设计模式项目大了以后MPC的数量会快速增长。我的建议是不要做一个“超级大集合”把所有参数塞一起也不要每个效果单独建一个MPC。正确做法是按领域分组全局时间/环境参数例如天气、昼夜、全局颜色放一个Env_Global里角色相关例如血量反馈、受击闪红、Outline状态放一个Char_Common里技能特效相关参数放一个FX_Global里这样划分的理由是美术和策划在运行时只需要关注某几个集合不用同一时刻维护上百个无关参数。而且在代码里Set参数时也能通过集合的命名空间快速找到对应参数减少误操作。补充一个性能相关的小知识MPC参数本质上会作为Uniform/Shader参数传入材质对应的着色器。一个材质引用的MPC数量不建议无限增加因为引擎需要为这些参数分配常缓冲空间。虽然单个参数开销很小但如果你在一张材质里引用了10个不同的MPC而每个MPC里你只用到其中一个参数这种用法在编译时会产生大量无用的常缓冲绑定里外里反而比把参数合并到一个MPC里更浪费。所以MPC的粒度把握直接影响着渲染效率。5. 渲染管线集成5.1 参数如何从 MPC 流向渲染管线把MPC参数接进材质后很多人会好奇它到底怎么跑到GPU上的简化的链路是这样的项目运行时GetParameterCollectionInstance里的值发生改变引擎会把变化的参数打包进一个统一的常缓冲Constant Buffer材质在编译时那些引用MPC参数的节点会被替换成对应的cbuffer读取指令每一次Draw Call前引擎把MPC实例的当前值绑定到相应Shader里。换句话说MPC参数不是“每帧重新读取贴图”的那种数据而是“每Draw Call前从CPU拷贝到GPU”的Uniform数据。这个结构决定了你可以很安全地在运行时高频快速修改MPC值比如每一帧都根据角色血量改闪光强度不必担心带宽爆炸。但如果你想用MPC传递整个纹理或超大数据集思路就不对了那是纹理采样和资源绑定的范畴。理解这点后你在设计渲染特性时会更清楚MPC适合做什么适合做那些全局性、小体量、实时切换的数据。不适合做什么不适合做需要逐像素高频采样的大规模数据。项目里这种误用发生过不少次比如有人试图用MPC传粒子位置数组结果要么传不过去要么性能崩盘。5.2 与全局着色器变量协同的实践在更底层的渲染管线中你可能还需要让自己的自定义Pass读取材质相关的数据。比如你想写一个描边Pass在另一张RenderTarget上标记出哪些像素属于角色轮廓。这时候MPC可以用来在Shader之间传递“当前角色颜色阈值”之类的配置但要真正在自定义Pass与材质Shader之间共享数据通常不止MPC这一步。一个常见做法是在材质层用MPC控制效果参数同时在管线的自定义Pass里通过引擎的ParameterCollection接口读取同一个MPC实例。因为MPC是有运行时实例的它天然适合作为“CPU到GPU的数据管道”让不同Pass共享同一份配置。举个例子我给一个项目做过自适应地形颜色采样。地表的Albedo材质里用MPC暴露了Terrain_ColorShift和Terrain_BlendRange。同时在地形烘焙Pass里我利用这两个参数来确定着色的边缘柔和度从而生成一张供地表材质混合使用的权重图。材质和自定义Pass读的是同一个MPC这样策划在场景里拖一个杆子地表颜色过渡和权重图生成效果会同步变化不会出现“材质变了但辅助数据没变”的魔幻问题。这类集成要注意的点是Pass的渲染顺序和依赖关系。如果自定义Pass需要在材质前完成那Pass依赖的参数更新时机就要提前不能等到材质阶段再Set否则自定义Pass读到的还是上一帧的值。处理办法是把MPC参数更新放在该Pass的渲染线程前必要时在OnPrePass这类节点钩子里做。5.3 移动端、主机与PC的兼容与优化渲染管线集成中最让人头疼的往往不是逻辑写不出来而是条件差异。其实HP阶段构建各种特殊Pass时我强烈建议先在ShaderPlatform层面写清楚平台分支避免同一段代码在所有平台无差别运行。比如某些特效只在高端机启用那就在Custom节点代码里通过预编译宏或者材质质量级别开关来控制#if MOBILE // 移动端简化算法 result sampleTexture * 0.8; #else // 桌面端完整算法 result fullComplexCalc(inputUV); #endifUE的材质编辑器默认会为不同质量级别生成不同的着色器变体如果材质里带有QualitySwitch节点或类似控制这部分逻辑就能在编译期自动分流。Custom代码里也可以使用引擎定义的宏来判断当前编译目标平台。另外对移动端MPC参数尽量使用float4而不是拆成多个标量参数分别传输因为GPU常缓冲的对齐规则对向量更友好。多个标量参数分散在不同的偏移位置上会造成填充浪费和额外的缓冲存储。这个细节在你为移动端做内存优化时会体现得比较明显。6. 常见问题与排查技巧实录6.1 Custom HLSL 编译失败看懂报错的关键这个问题几乎每个人都会遇到。刚开始写Custom代码时编译报错信息往往特别具有误导性常见的有“undeclared identifier”和“syntax error in expression”之类。我总结了一套排查流程先检查输入引脚名是否和代码里引用的名字完全一致。Custom Expression节点里代码使用的变量名必须和输入引脚名一模一样。多了个空格、大小写不一致都会导致编译失败。我曾花半小时找一个错最后发现是引脚名多打了一个下划线。再检查返回值类型和节点输出类型是否匹配。如果节点输出类型是float3你却在代码里return 1;报错可能不够明确但本质就是类型不匹配。养成习惯返回前先搞清楚节点输出类型必要时把结果显式转换一下比如return float3(result, 0, 0);。最后是平台特性问题。sin、cos这类数学函数没问题但如果你写sampletextureLOD或者用到SAMPLE_TEXTURE_2D_ARRAY这种纹理采样宏就必须确保当前材质所在的Target Platform支持这些用法。移动端和PC的差异特别显著。如果你不确定可以先关闭预编译平台选项只在当前平台编译测试然后再逐步展开。6.2 MPC 参数改了没反应症状与对策“我在蓝图里Set了MPC参数但材质看起来完全没变化”是出现频率极高的问题。排查这张表症状可能原因解决方式参数完全不变材质没有引用这个MPC或引用错了参数名检查材质图里的ParameterCollection节点确认资源与参数名只在编辑器里有效打包后无效MPC资源没被打包或者代码里用了软引用却未加载在资源包设置里强制包含该MPC或先用蓝图层硬引用改了参数但画面没变化该参数虽然连附材质但被后续节点覆盖检查材质图表中后面的操作是否重新赋了值新关卡加载后参数重置未在新关卡的世界实例中重新Set监听关卡加载事件重新发送参数某些平台正常某些平台无效果精度或编译宏分支导致逻辑被跳过检查Custom代码中的平台分支和精度声明其中“材质没引用这个MPC”最常见但新手往往会先怀疑蓝图逻辑。我建议排查顺序是材质图引用 → 参数名称匹配 → 蓝图/C有没有拿到正确的世界实例 → 是否有关卡切换未重置。按这个顺序走一遍大部分问题都能解决。6.3 性能排查当材质感觉“用力过猛”自定义代码越来越多之后你会碰到一个隐含问题单帧Draw Call数量没变但整体帧耗时却上去了。这通常是着色器复杂度导致的也就是材质计算本身变重了。排查方法并不神秘用ProfileGPU和Stats工具看每个Pass耗时同时留意Shader编译后的指令数。如果你发现某个材质指令数暴增优先检查是否有不必要的循环尤其是带有branch的动态循环。GPU上动态循环如果编译成“无界循环”编译器必须生成更保守的代码性能损耗极大。能展开成有限次数的循环尽量显式写次数或者干脆用多个步骤代替。还有个容易被忽视的点过度依赖Custom节点会影响材质编辑器对某些优化的识别。材质编辑器有时会基于节点图做常量折叠和dead code elimination但你用自定义代码把逻辑“封装”起来后引擎无法看到内部结构一些可以优化的死代码会被保留。这就是为什么我不建议把所有节点全部改成Custom代码的原因代码化并非越多越好而是应该只在节点图难以表达的部分使用。写完后多看一眼反汇编或指令统计能避免“看着清爽跑着卡顿”的尴尬。最后一笔经验回顾这一套进阶玩法我最想强调的是“平衡”Custom代码给你自由度MPC给你全局联动渲染管线集成让这些落实到具体Pass中但每一步都伴随着平台、性能和可维护性的权衡。我自己早期容易走极端要么觉得“什么都能用代码写”然后疯狂堆HLSL要么觉得“MPC万能”把大量关卡参数塞进一个集合里。后来吃了教训才慢慢摸出边界该由数学函数处理的交给代码该由团队共享的统一走MPC该为平台做区分的老老实实留分支。这套思路搭起来之后材质就不再只是一张贴图参数组合而是真正变成了渲染流程里的一个有生命的代码模块。希望这篇能帮你在自己的项目里少走几步弯路早点把这些能力用起来。