这几个月在项目里啃了不少材质上的硬骨头,从最初只会拖节点连蓝图,到后面被性能和多平台适配逼着去写HLSL,一路踩坑一路填。今天这篇”41”就是想把这些进阶阶段的实操经验梳理一遍。不是给你念引擎文档,而是站在美术和TA的角度,说说自定义表达式、Custom HLSL、MPC(Material Parameter Collection)以及渲染管线集成,这些东西在真实项目里到底怎么用、为什么用、以及用的时候有哪些坑。
先说几个典型的场景,你感受下是不是正中痛点:
- 想在材质里用一个循环,结果节点图绕了半天,最后堆了几十个节点还乱成一团;
- 想实现一个效果,查了一圈发现官方节点根本不存在,只能眼巴巴看着;
- 战斗中用蓝图动态改材质参数,结果每帧Update一下卡得要死;
- 或者干脆没有这种优化意识,等UI突然卡顿才知道出了问题。
如果你也遇到这些情况,那这篇文章基本就是给你写的。下文涉及的这套方案,我在移动端中低端机上验证过,也在PC高画质模式下测过,稳定性和性能都过关,可以直接拿来当范本参考。
1. 内容整体设计与思路拆解
1.1 为什么节点拼图会有天花板
UE的材质编辑器底层就是Shader编译器的可视化外壳。材质节点最终会被翻译成HLSL代码,然后交给RHI层编译成对应平台的着色器。理论上节点图能表达任何Shader逻辑,但实际操作中事情没那么理想:
- 循环和分支结构在蓝图模式下表达极其笨拙。每次想做一个迭代效果,得手动展开多次操作,图表复杂度成倍增加。
- 连续嵌套的数学运算,会在节点图中生成大量中间变量,编译器优化如果不够激进,很可能产生多余的GPU指令。
- 复杂的后处理逻辑(比如自定义模糊核、多次采样计算)用节点连接会覆盖整个屏幕空间,输出结构非常难维护。
- 跨平台移植时,节点编译出的代码可控性低,不同驱动和移动设备上的表现差异明显。
1.2 什么时候必须上自定义代码
搞清楚节点图的边界,就可以判断什么场景该写Shader代码了:
- 需要循环或递归逻辑,例如UI上的扫描线效果、波纹扩散、颗粒动画。
- 需要自定义的噪声算法、分形逻辑,而这些算法在节点图上实现会有极高的性能开销。
- 需要精确控制寄存器数量、指令数,保证在低端设备上同样能稳定60帧。
- 需要在材质里使用Buffer或结构化数据,例如读取骨骼位置、物理场景的代理数据。
- 需要在材质里和渲染管线深度交互,比如拿深度Buffer做透视计算、定制平面裁剪逻辑等。
1.3 本文的整体架构
全文会这么展开:先从自定义表达式讲起,说清楚它能做什么、不能做什么,然后过渡到Custom HLSL模块,讲代码层面的注意事项;接着解释MPC的架构和用法,最后聊材质怎么跟渲染管线协作。每章都以我自己项目里的真实案例为落地场景,附上对应的代码片段、参数配置和排查思路。为了方便你按需翻阅,每一节都可以独立阅读。
这里有个基础但重要的知识点先放前面:UE在材质编辑器中写的所有自定义代码都会被拼接到最终的Shader中。引擎支持两种入口,一种是在Custom节点里嵌入一小段代码(其实也是HLSL片段),另一种是使用独立的HLSL文件,然后通过Include方式引入。后者更接近传统图形学开发,工程性更强。
2. 核心细节解析与实操要点
2.1 Custom Expression:材质编辑器里的“轻量脚本”
Custom Expression是UE材质中最基础的“写代码”入口。使用方法很直接:连一个Custom节点,然后在代码框里输入表达式。
但要意识到,Custom节点的代码块不是完整的一个函数,它相当于一个内联的表达式片段。引擎会把你在编辑器里写的代码嵌入到生成的Shader代码上下文中。这意味着:
- 代码块里可以直接使用名为
Parameters的结构体,里面包含了CurrentWorldPosition、CameraWorldPosition等全局信息。 - 代码块里也可以引用你自己定义的局部变量,但外层的变量不会自动可用。
- 代码块里不允许定义函数,不能声明局部结构体或类。
- 代码块里不支持预处理指令(比如
#if),至少不能完整支持,可能破坏编译流程。
正因为有这些限制,把Custom节点当成快速验算工具最合适。比如做视差映射、自定义噪声、向量运算组合,这些短小精悍但节点展开繁琐的逻辑,用Custom节点几行代码就搞定。我自己在项目里常用它做自定义Noise,比如一个3D Voronoi的简化版,如果用节点拼图,至少得五六十个节点,而Custom Expression 15行代码就可以输出可用的噪声值。
来看一个简单的示例,假设我们要做一个基于世界坐标动态天空混合的效果(伪代码,展示思路):
float HeightFactor = saturate((WorldPosition.z - 500.0) / 1000.0); float3 SkyColor = lerp(float3(0.75, 0.78, 0.85), float3(0.25, 0.35, 0.55), HeightFactor); return SkyColor;这段代码其实就是替代了Lerp和Clamp那一堆节点。你要清楚,Custom Expression里输出的数据类型由节点引脚决定,比如输出引脚是float3,那你代码块返回的最终结果是float3。如果你在代码块里返回一个float4,但引脚类型是float3,UE会尝试做隐式转换,不过更稳妥的做法是显式控制类型。
写Custom Expression的第一个坑就是输出精度。引擎默认很多材质计算是half精度,尤其是移动端上。当你在Custom代码块里写float运算时,很可能被引擎优化成half,这就会导致高精度数据截断。最经典的例子是做UV偏移时遇到精度问题,物体放大后UV会出现抖动。解决思路是在Custom节点里声明需要高精度的中间变量时,加上float类型显式修饰,并开启Use GPU Compiler's Full Precision。在材质细节面板的属性里,就能找到这个选项,将Shading Model的Full Precision打开。
2.2 Custom Expression 的输入参数和使用技巧
Custom节点底部可以添加输入引脚。这些引脚有三种类型:
- 普通常量或向量:在编辑器里连各种材质节点,运行时作为变量传入。
- 连接Texture节点:此时引擎会传入采样坐标,但代码块里得自己声明TextureObject和SamplerState。
- 连接向量表达式:在代码里直接使用这个输入变量的名字。
输入引脚的名字可以自定义,但名字不能与HLSL关键字冲突。比如你取名为sample,就不会通过编译,因为sample在HLSL中已经有定义。同样的,避免使用pos、size、color这类可能被引擎宏展开覆盖的名字,最好统一使用带前缀的命名,例如in_Color、in_UV。
有一个使用技巧是传入UV坐标时不要用Texture节点连接的自动采样,而是手动连接一个TexCoord节点并将其转为线性。原因在于Custom Expression节点内的采样没有做常规的MipMap计算和Clamp,如果你的代码块里直接执行了Texture2DSample(Tex, Sampler, UV),引擎可能不会给你加上自动的Mip等级计算,导致远处出现严重闪烁。这里你可以手动在Custom代码里算导数,或者让传入的UV经过TexCoord节点的MipMapLevel调节,保证远距离画面稳定。
我自己的项目里经常在Custom Expression里做水面的波动偏移。一个典型的Versatile Water代码段是:
float2 SampleUV = in_WaterUV + float2(Time * 0.05, 0.0); float WaveA = sin(SampleUV.x * 3.0 + Time * 2.0) * 0.01; float WaveB = sin(SampleUV.y * 5.0 + Time * 1.5) * 0.01; return float2(WaveA, WaveB);这里的Time可以通过Custom节点输入引脚连接一个Time节点(注意引擎的时间值类型是float)。做法就是把Time节点连接到一个输入引脚上,然后在代码块里使用该变量。这样做的优势是,如果你想用蓝图控制时间流速,只需要把引脚的连接关系在运行时改为一个动态标量就行。
2.3 Custom HLSL:摆脱代码片段限制的正规军
Custom HLSL比Custom Expression工程化得多,适合真正的Shader功能模块。它的做法是通过内容浏览器创建一个.ush或.hlsl文件,放在项目的Shaders目录下。然后在材质编辑器中添加Custom节点,把文件作为Include引入。
文件写好后,在Custom节点的Code区域只需要写函数调用或者片段代码:
#include "/Project/Shaders/MyCustomShader.ush" return MyCustomFunction(Parameters, UV);也可以更干净地使用CustomExpression节点配合Include模块。但要注意,Include路径的有效范围有讲究。直接写包含项目名的绝对路径最稳定,例如/Game/MyProject/Shaders/MyFile.ush在引擎里是内容资产路径,但在Shader代码Include里更推荐用相对项目根路径的方式。
在Include文件里可以定义函数、结构体、常量等,自由度远高于Custom Expression节点。下面是一个简单的自定义色阶化函数示例(放在.ush里):
float3 MyPosterize(float3 Color, float Steps) { return floor(Color * Steps) / Steps; }然后在Custom节点里调用:
return MyPosterize(InColor, InSteps);这种做法最大的优势在于复用。一个文件里可以写几十个函数,供不同材质共用。比如我把噪声、变换、卡通描边、发光扰动等函数全部放在一个MyCustomLibrary.ush里,每隔项目直接引用即可。团队协作时,专门有一套Shader库,统一命名规范、统一风格,后接手的人一目了然。
Custom HLSL在代码层面还有一些进阶玩法:
- 可以声明全局变量,这些变量由引擎以Uniform形式传入,材质代码里可以直接引用,但值的来源却不一定是材质本身的参数。例如可以声明
float4x4 CustomMatrix;,然后在C++侧通过控制台命令或者RHI去更新它,这在做后处理时很常见。 - 可以访问引擎内部的各种Buffer,比如
SceneColor、SceneDepth、CustomStencil,而前者需要材质域是后处理域,或者使用后处理材质作为Blendable。 - 可以控制顶点着色器和像素着色器之间的数据传递,当你把材质域设为
Volume或User Defined时,输出的结构体会被引擎包装成对应域的结构,从而改变材质的光照流程。
需要注意的是,Custom HLSL文件里的代码不能使用#include自身相对路径的方式,比如编辑器里的Include路径会做字符串拼接,路径写错通常不会被编译器识别,最好的办法是把文件放在/Shaders/目录或Content/下并保持一致的大小写。一旦出现无法定位Include的错误,先检查路径大小写和文件名拼写,这是我踩过最多次的坑。
2.4 自定义表达式和Custom HLSL的选型逻辑
很多新人纠结用Custom Expression还是Custom HLSL。我的选择标准很简单:
- 如果只是想在节点图里少拉几条线,代码量在10行以内,用Custom Expression。
- 如果代码超过20行、需要定义函数、需要结构体辅助,或者要在多个材质里共用一套逻辑,立刻上Custom HLSL。遇到复杂逻辑,不要硬塞进Custom节点的代码框,否则代码可读性非常差,排查编译问题也困难。
这份选择逻辑背后还有一个性能考量。Custom Expression节点里的代码跟Inline一样,会导致引擎无法为你做太多抽象和优化。如果你写了一个很复杂的循环结构,循环体内藏了一个大数组操作,Shader编译器虽然能做展开和优化,但代码体积可能爆炸。而Custom HLSL用函数封装后,至少在编译层有机会被复用或剔除。当然,插件或引擎自身优化也起很大作用,但作为开发者,我们得主动为编译器提供足够的“优化空间”。
还有一个细节:在材质编辑器里选中Custom节点,右侧细节面板会有一堆属性选项。其中有两个非常关键:
Output Type:决定返回值的类型和通道数量。Description:用于在节点图上显示注释,便于快速识别。Pure Node:表示无状态节点,只做计算,不采样纹理。Reentrant:标记为允许递归调用的节点。
实际操作中,我常常忘记设置Pure Node,结果发现节点没法连到某些要求纯计算的输入上。这虽然不是Shader逻辑错误,但会挡住你连接节点图的路。
3. 实操过程与核心环节实现
3.1 用Custom HLSL实现一个完整的溶解效果
好,我拿项目里做过的一个溶解材质作为完整案例拆解。这个案例既用到了Custom HLSL,也涉及了MPC的动态参数驱动,最终效果是:角色被火焰吞噬时,材质从脚部向上溶解,表面散布火星,同时边缘有灼烧发光的效果。
第一步,写Shader库文件。在项目Content/Shaders/下建一个DissolveLibrary.ush:
float3 DissolveEffect(float2 UV, float DissolveAmount, float3 BaseColor, float3 EdgeColor) { float NoiseValue = (sin(UV.x * 50.0) * cos(UV.y * 40.0)) * 0.5 + 0.5; float EdgeWidth = 0.05; float EdgeMask = smoothstep(DissolveAmount, DissolveAmount + EdgeWidth, NoiseValue); float CoreMask = step(DissolveAmount, NoiseValue); float3 FinalColor = BaseColor * CoreMask; FinalColor += EdgeColor * (EdgeMask - CoreMask) * 2.0; return FinalColor; }这里用了一个自带的正弦噪声来模拟随机感,真实项目里你可以换成Perlin噪声或Voronoi噪声贴图采样,效果会更细腻。
第二步,在材质编辑器中创建Custom节点,然后把它连接到BaseColor / Emissive等引脚。在Custom节点代码框里写上:
#include "/Content/Shaders/DissolveLibrary.ush" return DissolveEffect(UV, DissolveAmount, BaseColor, EdgeColor);把输入的四个引脚分别连到TexCoord、MPC参数(后面细说)、基础色纹理采样和边火颜色常量上。
第三步,调整好材质属性。如果你想让溶解模型呈现出物体渐隐的效果,需要打开材质细节面板里的Opacity Mask输出,并接一个带阈值的Mask输出。实际项目里常用MaskOpacity引脚,选择Masked混合模式后,把Opacity Mask引脚连接到一个计算出的Cutout值上,只有被溶解到的部分才会隐藏。这里把CoreMask稍微变换一下,用(NoiseValue > DissolveAmount) ? 1.0 : 0.0生成新的Mask值。
第四步,在蓝图中处理参数。DissolveAmount来自MPC,后面会讲。
3.2 MPC:给材质参数留一个全场景可见的“后门”
MPC的全名是Material Parameter Collection,它是材质参数的一个集合容器,可以在任意材质中引用。与动态材质实例不同,MPC不依赖某个Mesh或Material实例,它在项目全局只有一份数据。材质通过Collection Parameter节点从MPC里取值,蓝图/C++通过Set Vector Parameter或Set Scalar Parameter来修改值,所有引用该MPC的材质会立即响应。
MPC最核心的价值是:你在任何地方更新一个参数,数百个材质、上百个物体可以同时更新。这在做全局特效控制时简直太方便了。比如刚才的溶解案例,我可以把DissolveAmount存到名为Global_FX_Scalar的MPC中,然后所有使用溶解材质的敌人、物块、UI特效都引用了同一个MPC参数。在战斗系统里,我只需要在C++或蓝图中调用一次:
UKismetMaterialLibrary::SetScalarParameterValue(GetWorld(), MPC_Global_FX, FName("DissolveAmount"), NewValue);所有表面同时推进溶解进度。
创建MPC的步骤非常简单:
- 在内容浏览器里右键 -> Materials & Textures -> Material Parameter Collection,起个名。
- 双击打开,添加标量或向量参数,注意参数名唯一性。
- 在材质编辑器中右键搜索
Collection Parameter节点,下拉列表选刚才的MPC,然后选择参数通道。
一个MPC里可以塞多个标量/向量参数,但不要塞一张大贴图。MPC本质上是一个全局Uniform Buffer,贴图应该走纹理资源,用Parameter节点单独引。
有个性能细节特别值得注意:同一个MPC里参数过多,会导致所有引用它的材质都被强制绑定这个Uniform Buffer,如果你只是改其中一个标量,其他材质也要跟着状态切换,这会影响渲染管线状态的管理效率。在实际项目里,我通常把MPC拆分成几个小集合,例如Global_Global(全局时间、昼夜、季节)、Global_VFX(通用特效参数)、Global_Character(角色通用颜色控制),避免耦合。
C++侧的更新我已经给了示例。蓝图侧有一点不一样,需要先拿到MPC对象再调用Set Scalar Parameter。如果你不想在蓝图里存对象引用,也可以直接用控制台或者Sequencer的Track来实现动画化参数更新。
MPC还有一个隐藏技巧:它支持在材质编辑器里直接拖拽引用MPC的某个向量分量。比如Collection Parameter节点连到一个输入引脚后,你可以在代码块里访问其分量,同时蓝图修改的分量值是float,材质侧的接收也是float。不过MPC不支持数组,如果你想传一个float数组,就没办法了。这种情况下得转向动态材质实例或者自定义Uniform。
3.3 渲染管线集成:材质如何跟Custom Depth、Scene Texture和全局Shader变量互动
写材质写多了就会发现,材质并不是只跟自己玩,它需要跟渲染管线的各个阶段协作。集成一般分几个层次:
第一层:材质域和混合模式的配置
材质编辑器的三个核心参数决定集成方式:Material Domain(材质域)、Blend Mode(混合模式)、Shading Model(着色模型)。
- Domain为Surface的材质用于场景物体表面。
- Domain为Post Process的材质挂在后期处理链上,可以读取场景各种Buffer。
- Domain为Volume的材质可以在Local Volume里做自定义渐变,常用于雾效和体积效果。
- Domain为Light Function的材质可以控制体积光的强度分布。
这些域的选择决定了可访问的内置变量。你只有在Post Process域中才能方便访问SceneTexture节点的内容,比如SceneColor、CustomStencil、WorldNormal等。Surface域通常访问不到后处理Buffer(除非用自定义节点加代码访问SceneTextures,但那种做法不推荐,既绕又容易出兼容性问题)。
实际操作中,我常用Post Process材质来做全屏描边、Scanline效果、或基于深度的边缘检测。后处理材质的优势在于可以把复杂的深度信息和颜色信息组合,整个屏幕输出一个材质节点树的运算结果。
第二层:Custom Stencil / Custom Depth 的配合
在场景里选中需要特殊处理的物体,把Render CustomDepth Pass勾上。这样物体的深度信息会被额外写入CustomStencil缓冲区,然后Post Process材质里通过SceneTexture节点的CustomStencil通道就能读取。
这个方案我在项目里做过“地面透视标记物”效果:一个人物被墙壁挡住,但墙上会显示他的轮廓。实现方式就是先把人物模板单独渲染到CustomStencil,Post Process材质里用深度对比判定遮挡区域,然后叠加颜色。
float CustomStencil = SceneTextureCustomStencilLoad; // 伪代码示例,实际节点存在SceneTexture:CustomStencil float SceneDepth = SceneTextureDepthLoad; float PixelDepth = GetScreenDepth(); float ObjectDepthMask = (CustomStencil > 0.0) ? 1.0 : 0.0; float OccludedMask = (SceneDepth > PixelDepth) ? 1.0 : 0.0; return float3(RedColor, 0.0, 0.0) * ObjectDepthMask * OccludedMask;这套逻辑在PC上没问题,但在移动端要小心:移动端很多GPU不推荐频繁开启CustomDepth,性能开销大,而且老机型对Stencil的支持有限。上这套系统前一定要先在目标低端机上做Profile,否则卡成PPT。
第三层:全局Shader变量和引擎常量缓冲区的访问
Custom Expression和Custom HLSL可以访问引擎的常量缓冲区,比如View、Frame、Globals中定义的一系列内置量。具体能访问什么,取决于平台和渲染后端。
要做全局变量通信,可以用两种方式,一种就是MPC(引擎帮你管理Uniform Buffer),另一种是直接用RHIUpdateShaderResourceView或通过CVar传值。后者更底层,但跨平台稳定性需要自己保证。在需要高频更新的全局数据(比如逐帧时间、相机视锥参数)时,MPC已经足够用,没有必要走底层通道。
第四层:与Render Target的联动
当你要做复杂的后处理算法时,往往会先在RTT(Render Target)上做一些中间计算,再把RTT作为纹理输入到材质里。这个场景下,材质只是最终展示层。集成步骤是:
- 创建一个Render Target资产,在C++或蓝图中逐渐绘制。
- 在材质编辑器里用
Texture节点直接引用该RT资产(或运行时获取)。 - 将RT纹理采样结果结合场景其他Buffer,实现自定义效果。
这套方案我做UI动效比较多。比如游戏里武器有能量条充能效果,能量条的流动条纹可以先渲染到RT,再在材质里混合UI的平滑颜色和高光。RT更新的频率可以由蓝图控制,不强制每帧更新,能显著降低开销。
4. 常见问题与排查技巧实录
材质和Shader的问题往往神出鬼没,编译器报错信息有时候也不是很友好。这里记录我踩过的坑和排查办法。
4.1 编译报错怎么快速定位
错误信息大致分三类:语法错误、类型错误、作用域错误。
- 语法错误:最常见,经常是某个函数名拼错、括号没闭合、漏了分号。编译器会报错到莫名奇妙的行号,因为你的代码是内联进一个巨大Shader文件中的。这时候建议的第一件事就是把你的代码段复制出来,单独放到一个干净的小文件里,用Visual Studio或Notepad++做语法高亮,凭肉眼找低级错误。
- 类型错误:比如把
float3传给float2参数,或者对向量做length()方式不对。引擎的报错一般能告诉你哪个变量类型不对,但有时候是隐式转换失败。在代码里显式写明类型能有效缓解。 - 作用域错误:Custom Expression内定义了一个变量,但你尝试在另一个Custom节点里访问它,这不可能。Custom节点之间是平行的,数据交换只能通过引脚、全局Uniform或者MPC。
排查的常规流程:看编译日志(Output Log)找到第一个红色Error,双击跳转到对应行。如果日志显示引擎内部Shader文件中的某一行(比如MaterialTemplate.ush),不要慌,那不是你代码的问题,而是你代码在展开后触碰了某个引擎内部逻辑分支。这往往是“你调用了引擎不支持的函数”或“变量声明与引擎内部冲突”的信号。
4.2 Custom HLSL没生效的几种原因
这类问题特别隐蔽。明明写了代码,连了引脚,但最终效果没变化。
- 第一种原因:Shader代码根本没被编译进去。检查Include路径是否正确,路径大小写敏感性会不会导致文件找不到。这时可以在Material编辑器右上角点击
Compile后打开Shader Code窗口(工具栏中的Code按钮),搜索你的函数名,看它是否出现在最终代码里。 - 第二种原因:被材质节点的输入覆盖了。假如你连了一个
Constant3Vector到Custom节点的输入,但代码块里根本没引用这个输入,那自然看不到效果。检查你的函数调用,是否真的把输入参数用上了。 - 第三种原因:你在Shader代码里开启了复杂的循环,但输入的某个值为0,循环根本没执行。可在代码里临时给输入赋值一个固定常量测试,看是否能出效果。
- 第四种原因:混合模式设置问题。比如你在Post Process材质里写了一个Draw做全屏覆盖,但材质混合模式是Opaque且没有启用
Post Process材质里的Apply Before Tonemapping,可能画面被转成线性空间后色彩表现不符合预期。要检查材质域、混合模式、后处理优先级。
4.3 MPC参数更新不及时或错误
这是最让我头疼的问题之一,参数在蓝图里设了,但材质就是不响应。
- MPMC参数必须确保命名唯一。同一个MPC里有两个同名的标量,后添加的会覆盖前面但旧引用会指向同一个名字,不会报错,但语义混乱。解决办法:设计阶段强制命名规范,比如带前缀
Global_FX、Global_Time。 - 蓝图里调用
SetScalarParameterValue时,传入的MPC对象引用必须正确。常见错误是Get了A集合,改了B集合的参数名,结果自然无效。 - MPC参数更新是逐帧生效的,不会瞬间同步到所有材质。实际观察值会在下一帧渲染时更新,如果你的材质同时使用多个MPC,更新顺序会有差异,可能造成短暂的不一致。解决办法:需要同步更新的参数放同一个MPC。
- 如果你在材质编辑器里看到参数值已变化,但视口里材质没有变化,先确认材质是否引用了这个参数。右键材质节点 ->
Find Ref可以查看该MPC的引用情况。另一种可能是Use Material Attributes模式下有个别的自定义节点屏蔽了MPC的输入。
4.4 编译时间过长和Shader变体爆炸
这个问题虽然在性能测试里才暴露,但根治需要从材质代码层面解决。
材质里用了大量的分支语句(比如多个if条件),Shader编译器会为每个分支生成独立指令集,变体数量呈指数级增长。这里不是让你完全不写分支,而是要有意识地用lerp、saturate、step这类数学运算替代完全分支的写法。早期移动端GPU对分支支持不好,哪怕现在很多桌面GPU支持动态分支,材质图内部也会为不同材质域生成不同的Shader变体,每个平台和渲染特性组合都会增加编译时间。
我一般遵守的规则是:
- 分支条件只依赖于常量或少量Vector参数,不要让Pervasiveness不同的输出走完全不同的算法路径。
- 可以用
select或lerp处理的,不用if。 - 必须用
if时,把循环放在函数内,尽量少用嵌套。 - 多用宏化常量,让编译器有机会做常量折叠和分支消除。
另外,每个Custom节点虽然代码块只有一小段,但如果每个节点里的代码都用到了#include引用完整Shader库,等于每个材质都把整个库拖进来了。你的Shader库文件大小会直接影响编译时间和最终包体。最好的做法是拆分库文件,让每个材质只引用自己需要的部分。
4.5 性能与兼容性的动态平衡
最后讲一个实操层面的原则:高性能材质不是靠某一个惊天优化实现的,而是靠大量小细节的堆叠。
在材质编辑器里打开Stats标签页,能看到指令数、纹理采样数、寄存器使用情况。指令数在移动端最好控制在200条以内(不同GPU要求不同)。如果超了,优先检查是否每个材质都真的需要那么多纹理采样,某些采样结果能否在VS阶段算好再传给PS,或者用共享的RenderTarget代替多次采样。
移动端的采样器数量是很有限的,材质里每多一个Texture Sample节点,都会占用一个采样器插槽。如果你的Custom HLSL里直接用代码采样,要记得声明采样器状态以复用引擎已生成的采样器。纯代码采样的写法是:
Texture2D MyTex; SamplerState MySampler; float4 Color = MyTex.Sample(MySampler, UV);这里MyTex和MySampler实际上需要在U材质界面里设定,而不是直接裸用。一旦跨平台(比如切换到Metal后端),采样器绑定方式会有微妙差异。我在安卓和iOS上就吃过亏,同样的代码在PC上正常,在手机上会出现明显颜色偏移或采样失败。后面我统一改成材质编辑器里加TextureSample节点,把UV输出传给Custom节点内部采样,效果稳定得多。
说到这里,你可以看出来,材质从节点到代码的进化,其实是一次思维转换。节点图更强调“数据的连接”,而代码方式更强调“计算的结构”。在复杂项目里,代码的掌控力还是要强得多。
我个人在实际项目里最看重的,不是某一招多炫,而是整套材质系统的可维护性。不管是用Custom HLSL还是MPC,都要先想清楚半年后别人接手这段工程时,能不能看懂这个模块在干什么。我的习惯是每个大功能模块都必须配套一份设计文档,说明参数的意义、材质引用关系、Shader库的职责边界,并在Shader文件顶部写清楚版本和修改人。这种细节,平时不起眼,但项目一旦进入上线冲刺阶段,能帮你省下几天排查时间。