
1. 项目概述当动态材质成为性能瓶颈在Unity项目开发的中后期尤其是涉及大量同模型但不同外观比如不同颜色的士兵、不同贴图的建筑、不同光泽的武器时很多开发者会本能地想到一个“简单粗暴”的解决方案为每个需要不同颜色的物体创建一个新的材质实例Material Instance。这个操作在编辑器里点几下就完成了看似方便但它正是移动端和大型场景中一个隐形的性能杀手——材质实例化Material Instancing。我经历过一个项目场景里有上千个相同的路灯模型美术希望每个路灯的灯罩颜色能根据时间动态变化。最初的做法就是每个路灯GetComponentRenderer().material然后改颜色。在编辑器里跑起来似乎没问题但一到真机特别是低端设备上帧率直接掉到不忍直视。一查性能分析器SetPass calls高得吓人Draw Call暴涨内存也在悄悄增长。这就是材质实例化带来的典型问题它破坏了Unity的静态/动态合批Batching机会并产生了大量的材质资源副本。而MaterialPropertyBlock就是Unity提供给我们的一把“手术刀”让我们能在不创建新材质实例的前提下精准地修改物体渲染的某些属性如颜色、纹理偏移、浮点参数等。它直接操作于渲染命令层面绕过了材质资产本身从而完美避免了实例化。理解并掌握它是从“功能实现”到“性能优化”进阶的关键一步。这篇文章我就结合踩过的坑和实战代码带你彻底搞懂MaterialPropertyBlock让你的项目渲染效率提升一个档次。2. MaterialPropertyBlock 核心原理与优势解析2.1 传统材质修改的代价实例化要理解MaterialPropertyBlock为什么高效必须先明白默认的.material属性做了什么。当你通过renderer.material注意不是sharedMaterial去获取或修改一个材质时Unity在背后执行了一个“保护性”复制操作。它会为这个特定的渲染器Renderer创建一个该材质的独立副本。这个副本是一个全新的Material对象存在于内存中拥有自己的属性集。你后续对这个material的所有修改都只影响这个副本而不会影响原始的材质资产Material Asset或其他使用该原始材质的物体。带来的问题破坏合批Unity的GPU Instancing和动态合批Dynamic Batching有一个核心前提被合批的物体必须使用同一个材质实例。一旦你通过.material为每个物体创建了独立的副本它们就不再是“同一个”材质了合批机制立即失效。这意味着每个物体都可能产生独立的Draw Call。内存开销每个材质实例都是一个独立的Unity引擎对象会占用内存。一个简单的材质可能不大但成百上千个累积起来就非常可观了。更重要的是这些实例化的材质会绕过Unity的资源管理如Addressables的引用计数容易造成内存泄漏或卸载困难。实例化开销创建材质实例本身也有CPU开销尤其是在同一帧内大量创建时。2.2 MaterialPropertyBlock 的工作机制MaterialPropertyBlock后文简称MPB采用了完全不同的思路。你可以把它想象成一个“属性覆盖层”或“参数补丁包”。它不是材质MPB本身不是一个Material对象而是一个轻量级的、用于存储属性键值对如_Color,_MainTex_ST的数据容器。作用于渲染命令在提交渲染命令时即在Renderer组件被渲染时Unity会检查这个Renderer是否附加了一个MaterialPropertyBlock。如果有它会将MPB中存储的属性值“覆盖”到当前使用的材质无论是sharedMaterial还是实例化后的material的对应属性上。不改变材质资产这个覆盖操作发生在渲染管线中是临时的、每帧的。它不会修改底层的材质资产.sharedMaterial本身也不会创建任何新的材质实例。渲染器仍然引用着原始的材质资源。带来的优势保持合批因为所有物体仍然使用同一个材质资产sharedMaterial只是通过不同的MPB提供了不同的参数所以GPU Instancing可以正常工作。这是它最大的性能优势。内存友好MPB对象本身非常轻量且可以复用。大量物体使用MPB带来的内存增长远低于实例化等量的材质。灵活高效可以每帧动态修改MPB中的属性实现动态效果如颜色闪烁、纹理滚动而无需担心实例化开销。注意这里有一个关键点MPB保持合批的前提是使用GPU Instancing。对于不支持Instancing的Shader或者物体因顶点数过多等原因无法动态合批时即使使用MPB和相同材质Draw Call也可能不会合并。但即便如此它依然避免了材质实例化的内存开销。2.3 性能对比数据参考为了量化差异我做过一个简单的测试在Unity 2021 LTS下场景1000个相同的Cube。操作每帧随机修改其颜色。方案A传统使用renderer.material.color newColor。方案BMPB使用MaterialPropertyBlock设置_Color属性。结果对比表指标方案A (传统.material)方案B (MaterialPropertyBlock)说明Draw Calls (静态)~1000~1 (Instanced)MPB方案触发了GPU Instancing千个物体合并为1个Draw Call。传统方案每个Cube独立Draw Call。SetPass Calls~1000~1与Draw Call对应MPB大幅降低渲染状态切换。帧率 (中端手机)15-20 FPS55-60 FPS渲染开销减少直接体现在帧率上。内存增量 (Profiler) ~3.8 MB ~0.1 MB传统方案产生了1000个材质实例的内存开销。MPB的内存开销主要来自1000个MPB对象可优化为复用。每帧CPU耗时较高极低传统方案需要管理1000个材质对象的生命周期。这个对比清晰地展示了在需要为大量相同网格物体赋予不同属性时MPB带来的巨大性能红利。3. 核心细节解析与实操要点3.1 何时使用 MaterialPropertyBlockMPB并非万能明确其适用场景能避免误用。强烈推荐使用的情况大量重复物体的差异化渲染这是MPB的“主场”。如策略游戏中的单位颜色区分阵营、开放世界中的植被颜色/风力参数微调、抽卡游戏中的角色立绘边框颜色/亮度变化。需要频繁修改的材质属性例如角色的血条材质_FillAmount、技能特效的_Dissolve阈值、场景物体的闪烁_EmissionColor。使用MPB修改可以避免每帧都new Material或操作.material。配合GPU Instancing当你为自定义Shader编写了Instancing支持后MPB是传递每个实例独有数据如颜色、矩阵的标准方式。需要谨慎或避免使用的情况修改Shader关键词Keywords或Render QueueMPB主要用于修改MaterialProperty属性如float,Color,Vector,Texture。它不能直接启用/禁用#pragma multi_compile或#pragma shader_feature定义的关键词。修改渲染队列renderQueue也应通过材质而非MPB。需要修改材质全局状态如果你希望一个修改应用到所有使用该材质的物体上那么你应该直接修改材质资产sharedMaterial但这会影响所有物体需特别注意。属性名拼写错误这是最常见的坑。MPB通过字符串名称如_MainTex来设置属性。如果名称拼写错误或与Shader中声明的属性名不一致设置会静默失败。务必检查Shader中的属性声明。3.2 Shader 侧的配合支持 GPU Instancing要让MPB发挥最大功效即实现合批你的Shader必须支持GPU Instancing。在Unity URP/HDRP或自定义Shader中这通常很简单。在Shader中添加Instancing支持// 在Properties块中可以声明每实例属性但非必须 Properties { _Color (Color, Color) (1,1,1,1) _MainTex (Albedo (RGB), 2D) white {} // ... 其他属性 } // 在CGPROGRAM或HLSLPROGRAM中添加以下指令 #pragma multi_compile_instancing // 支持多编译变体 // 如果希望_Color支持每实例数据可以这样处理 UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _Color) // 将_Color定义为每实例属性 UNITY_INSTANCING_BUFFER_END(Props) // 在片元着色器中使用宏来访问属性 fixed4 c tex2D(_MainTex, IN.uv_MainTex) * UNITY_ACCESS_INSTANCED_PROP(Props, _Color);对于URP Lit Shader等内置Shader它们通常已经内置了Instancing支持你无需修改Shader只需确保材质球上勾选了“Enable GPU Instancing”选项。3.3 MaterialPropertyBlock 的创建与复用策略创建和更新MPB也有最佳实践不当使用会抵消其性能优势。1. 避免每帧new最差的写法是在Update中void Update() { MaterialPropertyBlock mpb new MaterialPropertyBlock(); // 错误每帧都在分配新内存 mpb.SetColor(_Color, Random.ColorHSV()); GetComponentRenderer().SetPropertyBlock(mpb); }这会导致大量的GC垃圾回收开销。正确做法是在初始化时如Awake或Start创建一次然后复用。2. 推荐的单物体复用模式private Renderer _renderer; private MaterialPropertyBlock _mpb; void Awake() { _renderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); // 只创建一次 } void UpdateColor(Color newColor) { // 先获取现有的属性块如果有避免覆盖其他可能已设置的属性 _renderer.GetPropertyBlock(_mpb); _mpb.SetColor(_Color, newColor); _renderer.SetPropertyBlock(_mpb); }这里GetPropertyBlock是关键它确保了我们在修改属性时不会丢失之前通过MPB设置的其他属性。3. 大量物体时的优化对象池化MPB对于成百上千的物体每个物体都持有一个MPB对象在内存上仍有开销。一个更极致的优化是使用一个全局或池化的MPB在需要设置属性时临时使用设置完毕后就不持有引用。但这要求你的设置逻辑是集中的比如在管理器中统一循环设置。public class UnitManager : MonoBehaviour { private MaterialPropertyBlock _sharedMPB new MaterialPropertyBlock(); void UpdateAllUnitColors() { foreach (var unit in _allUnits) { _sharedMPB.Clear(); // 清空上次设置 _sharedMPB.SetColor(_Color, unit.teamColor); unit.renderer.SetPropertyBlock(_sharedMPB); } } }这种方式内存开销最小但要注意Clear()会清空所有属性适合批量设置单一或一组相关属性的场景。如果每个物体需要设置的属性组合不同且复杂还是每个物体缓存自己的MPB更清晰。4. 实操过程与核心环节实现下面通过几个从简单到复杂的代码示例展示MPB的具体用法。我们假设有一个支持Instancing的Shader其中包含_Color和_EmissionColor属性。4.1 基础示例修改物体颜色这是最常见的需求。我们创建一个脚本让物体在运行时改变颜色。using UnityEngine; [RequireComponent(typeof(Renderer))] public class ColorChangerBasic : MonoBehaviour { public Color startColor Color.white; public Color endColor Color.red; public float changeSpeed 1.0f; private Renderer _renderer; private MaterialPropertyBlock _mpb; private float _timer; void Start() { _renderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); // 初始化颜色 UpdateColor(startColor); } void Update() { // 简单的颜色插值 _timer Time.deltaTime * changeSpeed; Color currentColor Color.Lerp(startColor, endColor, Mathf.PingPong(_timer, 1.0f)); UpdateColor(currentColor); } void UpdateColor(Color color) { // 关键步骤获取现有属性块避免覆盖 _renderer.GetPropertyBlock(_mpb); // 设置颜色属性。属性名“_BaseColor”是URP Lit Shader的主颜色属性名。 // 如果是自定义Shader请使用你定义的属性名如“_Color”。 _mpb.SetColor(_BaseColor, color); // 应用属性块到渲染器 _renderer.SetPropertyBlock(_mpb); } }关键点解析RequireComponent(typeof(Renderer))确保脚本挂载的物体上有Renderer组件。_renderer.GetPropertyBlock(_mpb)这是良好习惯。它确保了如果你或别的脚本之前已经通过MPB设置了其他属性比如纹理偏移这次修改颜色不会把它们冲掉。如果你确定只设置颜色且没有其他属性也可以直接new一个或Clear()后设置。SetColor方法的第一个参数是属性名必须与Shader中定义的属性名完全一致大小写敏感。使用Shader.PropertyToID进行哈希ID缓存是更高效的做法见下文。4.2 高效实践使用Shader.PropertyToID在性能关键的代码中使用字符串查找属性名是有开销的。Unity提供了Shader.PropertyToID方法可以将属性名转换为一个整数ID这个ID在游戏运行期间是恒定的查找速度极快。using UnityEngine; public class ColorChangerEfficient : MonoBehaviour { private static readonly int BaseColorId Shader.PropertyToID(_BaseColor); private static readonly int EmissionColorId Shader.PropertyToID(_EmissionColor); private Renderer _renderer; private MaterialPropertyBlock _mpb; void Start() { _renderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); } public void SetColorAndEmission(Color baseColor, Color emissionColor, float emissionIntensity 1.0f) { _renderer.GetPropertyBlock(_mpb); // 使用缓存的Property ID而不是字符串 _mpb.SetColor(BaseColorId, baseColor); // 注意EmissionColor通常需要乘以一个强度或者使用HDR颜色。 // URP中可以通过SetColor设置_EmissionColor并通过SetFloat设置_EmissionStrength如果存在。 _mpb.SetColor(EmissionColorId, emissionColor * emissionIntensity); _renderer.SetPropertyBlock(_mpb); } }为什么这样做Shader.PropertyToID在静态构造函数或初始化时被调用一次生成一个全局唯一的整数ID。之后在所有物体的MaterialPropertyBlock.SetX方法中使用这个整数ID避免了运行时每次调用时的字符串哈希计算和查找对大量物体频繁更新的场景性能提升明显。4.3 复杂示例动态修改纹理偏移与自定义参数MPB不仅可以设置颜色和纹理还能设置向量、浮点数、矩阵等。例如实现一个滚动河流或瀑布的材质效果。using UnityEngine; public class ScrollingTexture : MonoBehaviour { public Vector2 scrollSpeed new Vector2(0.1f, 0f); // X, Y方向的滚动速度 public string mainTexPropertyName _MainTex_ST; // 纹理的缩放偏移属性 private Renderer _renderer; private MaterialPropertyBlock _mpb; private int _mainTexSTId; private Vector2 _currentOffset Vector2.zero; void Start() { _renderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); _mainTexSTId Shader.PropertyToID(mainTexPropertyName); // 初始化获取材质原始的缩放偏移值 _renderer.GetPropertyBlock(_mpb); // 如果之前没有设置过GetPropertyBlock后_mpb可能是空的。 // 我们需要先拿到材质本身的_ST值。一个更稳妥的方式是从材质资产获取初始值。 // 这里我们假设初始缩放为(1,1)偏移为(0,0) Vector4 initialST new Vector4(1, 1, 0, 0); // scaleX, scaleY, offsetX, offsetY // 但我们更应从sharedMaterial获取真实值注意这不会实例化材质 if (_renderer.sharedMaterial.HasProperty(_mainTexSTId)) { initialST _renderer.sharedMaterial.GetVector(_mainTexSTId); } _mpb.SetVector(_mainTexSTId, initialST); _renderer.SetPropertyBlock(_mpb); } void Update() { _currentOffset scrollSpeed * Time.deltaTime; _currentOffset.x Mathf.Repeat(_currentOffset.x, 1.0f); _currentOffset.y Mathf.Repeat(_currentOffset.y, 1.0f); UpdateTextureOffset(_currentOffset); } void UpdateTextureOffset(Vector2 offset) { _renderer.GetPropertyBlock(_mpb); // 获取当前的缩放值只修改偏移部分 Vector4 currentST _mpb.GetVector(_mainTexSTId); // 如果GetVector失败例如第一次则使用默认缩放(1,1) if (currentST Vector4.zero _renderer.sharedMaterial.HasProperty(_mainTexSTId)) { currentST _renderer.sharedMaterial.GetVector(_mainTexSTId); } // 构建新的ST向量缩放保持不变偏移更新 Vector4 newST new Vector4(currentST.x, currentST.y, offset.x, offset.y); _mpb.SetVector(_mainTexSTId, newST); _renderer.SetPropertyBlock(_mpb); } }关键点与避坑纹理属性名在Shader中_MainTex的缩放和偏移通常通过一个float4 _MainTex_ST变量访问ST代表Scale和Translation。在MPB中设置时属性名就是_MainTex_ST。获取初始值直接new Vector4(1,1,0,0)可能不对因为材质资产可能设置了不同的缩放或初始偏移。通过sharedMaterial.GetVector可以安全地获取原始值且不会实例化材质。GetVector可能返回Vector4.zero如果MPB中尚未设置过该属性GetVector会返回(0,0,0,0)。因此需要做判断并回退到材质原始值。数学重复使用Mathf.Repeat处理偏移值使其保持在[0, 1)范围内避免精度问题。4.4 实战批量处理大量物体最后我们看一个管理器脚本它高效地管理上百个物体的颜色并演示如何与对象池结合。using System.Collections.Generic; using UnityEngine; public class MassUnitColorManager : MonoBehaviour { public GameObject unitPrefab; public int unitCount 500; public float radius 10f; private ListRenderer _unitRenderers new ListRenderer(); private ListMaterialPropertyBlock _unitMpbs new ListMaterialPropertyBlock(); private static readonly int BaseColorId Shader.PropertyToID(_BaseColor); void Start() { // 初始化单位 for (int i 0; i unitCount; i) { Vector3 pos Random.insideUnitSphere * radius; pos.y 0; var unit Instantiate(unitPrefab, pos, Quaternion.identity, transform); var rend unit.GetComponentRenderer(); _unitRenderers.Add(rend); // 每个单位拥有自己的MPB便于独立控制 var mpb new MaterialPropertyBlock(); _unitMpbs.Add(mpb); // 设置初始随机颜色 SetUnitColor(i, Random.ColorHSV()); } } void Update() { // 示例每帧随机更新一部分单位的颜色 for (int i 0; i unitCount; i 10) // 每帧更新10% { if (Random.value 0.5f) { Color newColor Random.ColorHSV(0f, 1f, 0.5f, 1f, 0.8f, 1f); // 更鲜艳的颜色 SetUnitColor(i, newColor); } } } void SetUnitColor(int index, Color color) { if (index 0 || index _unitRenderers.Count) return; var mpb _unitMpbs[index]; var rend _unitRenderers[index]; // 因为我们为每个单位缓存了独立的mpb且只用来设置颜色 // 所以可以直接Set不需要先GetPropertyBlock除非有其他脚本干扰。 // 但为了代码健壮性保留Get操作是好习惯。 rend.GetPropertyBlock(mpb); mpb.SetColor(BaseColorId, color); rend.SetPropertyBlock(mpb); } // 优化版本使用单个共享MPB进行批量设置适用于所有单位同时更新相同属性 public void SetAllUnitsColor(Color color) { MaterialPropertyBlock sharedMpb new MaterialPropertyBlock(); sharedMpb.SetColor(BaseColorId, color); foreach (var rend in _unitRenderers) { // 注意这里直接SetPropertyBlock会覆盖该渲染器上之前通过MPB设置的所有其他属性。 // 仅适用于“重置”或“统一设置”的场景。 rend.SetPropertyBlock(sharedMpb); } // 清空列表中的个人MPB缓存因为已被覆盖 foreach (var mpb in _unitMpbs) { mpb.Clear(); } } }设计选择讨论这个示例给出了两种模式每个物体独立MPB缓存_unitMpbs列表。优点是每个物体的属性状态独立管理清晰可以随时修改任意物体的任意属性而不影响他人。缺点是内存占用随物体数量线性增长。共享MPB批量设置SetAllUnitsColor方法。使用一个临时的MPB对象循环设置所有物体。这会覆盖每个物体之前通过MPB设置的所有属性。仅适用于将所有物体“重置”到同一状态或者你确定只关心这个单一属性的场景。在实际项目中你需要根据游戏逻辑的复杂程度来权衡。对于RTS游戏中成百上千的单位每个单位可能有独立的颜色、血条百分比、选中高亮等多种需要通过MPB控制的属性那么为每个单位缓存一个MPB对象是更合理的设计。5. 常见问题与排查技巧实录即使理解了原理在实际使用MPB时还是会遇到各种问题。下面是我总结的常见“坑”和解决方法。5.1 问题一设置了MPB但物体颜色/纹理没变化这是最高频的问题排查思路如下检查属性名99%的问题源于属性名拼写错误或大小写不对。首先打开你的Shader文件找到Properties块和CG/HLSL程序中实际使用的变量名。例如在Properties中声明了_MyColor(Color, Color) (1,1,1,1)但在Shader代码中可能使用uniform float4 _MyColor;。MPB设置的名称必须与Shader中用于渲染的变量名一致。对于纹理的缩放偏移通常是_MainTex_ST。检查Shader是否支持该属性使用Material.HasProperty或Shader.PropertyToID不为零来判断。可以在代码中调试int propId Shader.PropertyToID(_MyColor); if (propId 0) { Debug.LogError(Shader does not have property _MyColor!); } // 或者 if (!renderer.sharedMaterial.HasProperty(_MyColor)) { Debug.LogError(Material does not have property _MyColor!); }检查渲染器Renderer是否正确获取确保你的脚本是挂在有MeshRenderer或SkinnedMeshRenderer的物体上并且通过GetComponentRenderer()获取到了有效的引用。检查MPB是否成功应用在SetPropertyBlock后你可以通过GetPropertyBlock读回来验证。MaterialPropertyBlock testMpb new MaterialPropertyBlock(); renderer.GetPropertyBlock(testMpb); if (testMpb.isEmpty) { Debug.Log(No PropertyBlock is set.); } else { Color setColor testMpb.GetColor(_MyColor); Debug.Log($Color read from MPB: {setColor}); }检查材质是否启用了GPU Instancing在材质Inspector面板查看。如果未启用即使使用MPB合批也可能不会发生但属性修改应该依然生效。5.2 问题二使用MPB后Draw Call为什么没有下降MPB的主要优势之一是保持合批但合批成功需要多个条件材质相同所有物体必须使用同一个材质资产sharedMaterial。如果你通过.material修改过它们就已经不同了。Shader支持Instancing材质必须勾选“Enable GPU Instancing”并且Shader本身支持。渲染器设置相同物体的Renderer组件的其他设置如Lightmap索引、Light Probe用法、Reflection Probe用法等需要相同。你可以在Frame Debugger中查看合批中断的原因。物体距离不能过远动态合批有顶点数限制通常300顶点且要求物体位置、缩放等在一定范围内。GPU Instancing要求更宽松但也不是无限的。使用Frame Debugger这是Unity最强大的调试工具。Window - Analysis - Frame Debugger。运行游戏在Frame Debugger中点击“Enable”然后逐帧查看每个Draw Call。你可以看到每个被渲染的物体以及它们为什么没有被合批例如“Different materials”, “Different shadow casters”等。5.3 问题三MPB和Material.CopyPropertiesFromMaterial有什么关系Material.CopyPropertiesFromMaterial是用于在运行时从一个材质复制属性到另一个材质。它和MPB是不同维度的工具MPB覆盖属性用于物体级别的差异化不创建新材质。CopyPropertiesFromMaterial复制属性用于创建新的材质实例。当你确实需要一个新的材质对象又想继承另一个材质的属性时使用。它会实例化材质因此会破坏合批。不要为了修改几个属性而去复制整个材质这比直接使用.material实例化好不了多少。MPB是更轻量、更高效的解决方案。5.4 问题四如何在SRPURP/HDRP中正确使用MPB在可编程渲染管线中原理完全一样。但需要注意属性名的变化URP Lit Shader主颜色属性名通常是_BaseColor而不是旧版Standard Shader的_Color。自发光颜色是_EmissionColor。纹理属性设置纹理使用SetTexture方法属性名如_BaseMap。始终以Shader为准最可靠的方法是写一个简单的测试脚本遍历材质的所有属性ID和名称或者直接查看URP的Shader源码。5.5 性能陷阱过度使用GetPropertyBlockGetPropertyBlock操作本身也有微小的CPU开销。如果你在每帧Update中为成百上千的物体调用GetPropertyBlock只为了修改一个你知道之前只有你自己设置的属性这可能是不必要的。优化建议状态缓存如果你是自己控制所有MPB的写入并且知道初始状态可以在第一次设置后在本地缓存一个bool _isColorDirty这样的标志避免每帧读取。批量获取在管理器中如果逻辑允许可以在一个循环中先为所有需要更新的物体计算好新的属性值然后再统一进行Get和Set操作减少逻辑分散。权衡对于大多数情况GetPropertyBlock的开销可以忽略不计。代码的清晰性和健壮性避免属性被意外覆盖更重要。只有在Profiler明确显示这里成为瓶颈时才需要进行这类极致的优化。最后记住一点任何性能优化都需要用数据说话。在引入MaterialPropertyBlock前后务必使用Unity Profiler和Frame Debugger进行对比测试验证它在你的具体场景中是否带来了预期的性能提升以及是否引入了新的问题。