
1. 项目概述为什么MaterialPropertyBlock是管理海量物体着色的“利器”在Unity项目开发中尤其是涉及大量重复物体如森林中的树木、战场上的士兵、城市中的建筑群的场景里性能优化是每个开发者都必须面对的挑战。一个常见的性能瓶颈就出现在材质Material的管理上。很多开发者尤其是刚接触Unity不久的朋友会习惯性地为场景中每一个需要独立颜色的物体都创建一个新的Material实例。比如你有1000个一模一样的箱子但希望它们有1000种不同的颜色你可能会写一个脚本为每个箱子动态new Material()并设置其color属性。这种做法在物体数量少时无伤大雅但当数量膨胀到成百上千时你就会发现Draw Call绘制调用急剧飙升内存占用也水涨船高帧率自然就卡成了幻灯片。这背后的核心问题在于Unity的渲染管线在绘制物体时是以Material为基本单位进行状态切换的。每一个独立的Material实例即使它们来自同一个Shader着色器只要其属性如颜色、纹理偏移有任何不同就会被视为不同的渲染状态从而产生额外的Draw Call和内存开销。更糟糕的是频繁地创建和销毁Material实例还会引发GC垃圾回收压力导致游戏间歇性卡顿。那么有没有一种方法能让这1000个箱子共享同一个Material但又可以拥有各自独立的颜色呢答案就是MaterialPropertyBlock。你可以把它理解为一个“属性覆盖包”。它允许你在不创建新Material实例的情况下为特定的渲染器Renderer动态覆盖其材质上的某些属性。对于GPU来说它依然是在用同一个Material进行绘制只是每次绘制前我们通过MaterialPropertyBlock告诉它“嘿这次画这个物体时把_Color属性临时换成这个值。” 这样一来我们既实现了视觉上的差异化又保住了性能上的高效率。这个技巧在管理海量同模型、不同状态的物体时效果尤为显著是Unity中高级性能优化工具箱里的一件“利器”。2. 核心原理Material实例化与MaterialPropertyBlock的机制对比要理解为什么MaterialPropertyBlock更高效我们必须先深入看看传统的Material实例化到底做了什么以及GPU是如何工作的。2.1 传统Material实例化的开销分析当你从资源中加载一个材质球Material Asset并直接赋值给Renderer.material时Unity实际上会为你创建一个该材质的运行时实例。这个实例是原始材质球的一个独立拷贝。即使你只是修改了这个实例上的一个浮点数属性在渲染引擎看来它也已经是一个全新的、独一无二的材质状态。产生的开销主要包括CPU内存开销每个Material实例在CPU端都会占用一定的内存用于存储其所有的属性值颜色、浮点数、纹理引用等。1000个实例就是1000份内存。GPU资源与状态切换开销这是更关键的部分。在每次绘制调用Draw Call前GPU驱动需要为这次绘制设置好所有的渲染状态包括Shader、纹理、混合模式、以及所有的Shader属性Uniforms。如果两个物体使用不同的Material实例即使它们99%的属性相同GPU驱动也需要为第二个物体重新设置一遍所有的状态。这个过程称为“状态切换”是非常耗时的。大量的状态切换会直接导致Draw Call数量暴增。GC垃圾回收压力如果你在每帧都动态创建和销毁Material例如在Update中new Material会产生大量的托管堆内存分配。C#的垃圾回收器Garbage Collector需要频繁工作来清理这些短期对象GC操作会阻塞主线程导致游戏帧率出现明显的卡顿峰值。2.2 MaterialPropertyBlock的工作机制MaterialPropertyBlock的设计哲学完全不同。它本身不是一个材质而是一个轻量级的属性值容器。你可以把它附加到一个具体的Renderer组件如MeshRenderer、SkinnedMeshRenderer上。它的工作流程是这样的创建与配置你创建一个MaterialPropertyBlock对象然后使用SetColor,SetFloat,SetTexture等方法将你想要覆盖的Shader属性名和值设置进去。附加到渲染器通过Renderer.SetPropertyBlock(mpb)方法将这个属性块附加到指定的渲染器上。GPU渲染当Unity渲染这个物体时它会先使用其Renderer.sharedMaterial共享材质作为基础状态。然后它会检查这个渲染器上是否附加了MaterialPropertyBlock。如果有就用属性块中定义的值临时覆盖共享材质中对应的属性值。这个覆盖操作发生在GPU绘制命令提交的最后一刻对于渲染管线来说它依然认为所有使用sharedMaterial的物体都是在用同一个材质进行绘制。关键优势零Material实例所有物体共享同一个Material资产sharedMaterial没有额外的CPU端Material实例内存开销。最小化状态切换因为材质Shader、纹理等核心状态是共享的GPU驱动可以对这些物体进行更高效的批处理Batching尤其是静态合批Static Batching和动态合批Dynamic Batching更容易生效从而显著减少Draw Call。无GC压力MaterialPropertyBlock对象可以被复用。你可以在初始化时创建一批然后在物体的生命周期内反复设置其值避免了每帧分配新对象。注意MaterialPropertyBlock的覆盖是“一次性的”它不会修改原始的sharedMaterial资产。这意味着如果你修改了sharedMaterial的属性所有使用该材质且没有被MaterialPropertyBlock覆盖该属性的物体都会受到影响。这既是优点方便全局调整也可能带来意料之外的效果需要留心。2.3 性能数据对比概念性示例假设场景中有1000个相同的预制体Prefab我们需要为每个设置不同的颜色。方式Material实例数量预估Draw Call (未合批)内存开销 (示例)GC压力传统new Material()1000~1000高。1000份材质属性内存。高。频繁创建/销毁时严重。使用MaterialPropertyBlock1(共享材质)大幅降低(可合批至个位数)极低。1份材质内存 1000份轻量属性块数据。低。属性块可复用。这个对比清晰地表明在“海量物体属性各异”的场景下MaterialPropertyBlock在性能和内存上具有压倒性优势。3. 实战演练从零开始使用MaterialPropertyBlock理解了原理我们来看看具体怎么用。我们将通过一个完整的例子实现一个管理大量旋转立方体并随机赋予颜色的系统。3.1 基础场景搭建与问题复现首先我们创建一个最基础的、性能低下的版本作为对比基准。创建材质在Project窗口中右键创建 - Material命名为BaseColor。将其Shader设为Universal Render Pipeline/Lit如果你使用URP或Standard内置管线。创建预制体在场景中创建一个Cube将BaseColor材质拖给它。然后将这个Cube拖回Project窗口生成一个Prefab命名为ColoredCube。编写生成脚本低效版创建一个C#脚本SpawnerInefficient.cs。using UnityEngine; public class SpawnerInefficient : MonoBehaviour { public GameObject cubePrefab; public int gridSize 10; // 10x10x10 1000个立方体 public float spacing 2.0f; void Start() { for (int x 0; x gridSize; x) { for (int y 0; y gridSize; y) { for (int z 0; z gridSize; z) { Vector3 pos new Vector3(x, y, z) * spacing; GameObject go Instantiate(cubePrefab, pos, Quaternion.identity); // 【性能陷阱】每次访问.material都会创建新实例 Material newMat new Material(go.GetComponentRenderer().material); newMat.color new Color(Random.value, Random.value, Random.value, 1.0f); go.GetComponentRenderer().material newMat; } } } } }运行测试将脚本挂到场景空物体上赋值Prefab运行。你会瞬间生成1000个立方体。打开Stats面板Game视图右上角或Profiler窗口观察BatchesDraw Call和Used Texture Memory等指标。你会发现Batches数量极高可能接近1000帧率也会很低。3.2 使用MaterialPropertyBlock进行高效改造现在我们重写这个脚本使用MaterialPropertyBlock。修改预制体材质引用确保ColoredCube预制体上MeshRenderer的材质引用是BaseColor这是共享材质。编写生成脚本高效版创建新脚本SpawnerEfficient.cs。using UnityEngine; public class SpawnerEfficient : MonoBehaviour { public GameObject cubePrefab; public int gridSize 10; public float spacing 2.0f; // 声明一个静态的Shader属性ID这是最佳实践避免每次在字符串中查找。 private static readonly int ColorPropertyID Shader.PropertyToID(_BaseColor); // 如果你使用内置管线Standard Shader颜色属性名可能是_Color。 // private static readonly int ColorPropertyID Shader.PropertyToID(_Color); void Start() { // 预先生成一个MaterialPropertyBlock实例进行复用。 MaterialPropertyBlock mpb new MaterialPropertyBlock(); for (int x 0; x gridSize; x) { for (int y 0; y gridSize; y) { for (int z 0; z gridSize; z) { Vector3 pos new Vector3(x, y, z) * spacing; GameObject go Instantiate(cubePrefab, pos, Quaternion.identity); Renderer renderer go.GetComponentRenderer(); // 为每个立方体设置不同的随机颜色到属性块中 mpb.SetColor(ColorPropertyID, new Color(Random.value, Random.value, Random.value, 1.0f)); // 将属性块应用到该渲染器 renderer.SetPropertyBlock(mpb); } } } } }关键代码解析Shader.PropertyToID(“_BaseColor”)这是一个非常重要的优化技巧。在Shader中每个属性都有一个唯一的整数ID。直接使用字符串如“_BaseColor”去设置属性Unity内部需要做一次字符串到ID的查找这有微小的开销。在循环外部预先获取这个ID并缓存起来可以避免成百上千次的字符串查找进一步提升性能。mpb.SetColor(ColorPropertyID, color)将颜色值设置到属性块中。renderer.SetPropertyBlock(mpb)将这个属性块附加到渲染器上。注意这里传递的是mpb的引用而不是值拷贝。这意味着如果你之后修改了mpb的内容所有使用了这个mpb引用并且没有重新调用SetPropertyBlock的渲染器其属性都会被更新这通常不是我们想要的行为。因此在循环中为每个物体设置不同属性时我们复用同一个mpb对象但每次设置新值后立即应用这是安全的。运行对比使用高效版脚本运行。再次观察Stats面板。你会惊喜地发现Batches数量急剧下降如果所有立方体使用同一个Mesh且满足合批条件可能只有几十个甚至几个。帧率会有巨大提升。这就是MaterialPropertyBlock带来的合批红利。3.3 进阶应用动态更新与属性块复用在实际游戏中物体的属性如血条对应的颜色、受击闪白可能需要每帧更新。我们来看如何高效地动态更新MaterialPropertyBlock。假设我们的立方体会根据距离中心点的远近颜色在红色和蓝色之间渐变。创建管理脚本DynamicColorController.csusing UnityEngine; public class DynamicColorController : MonoBehaviour { private Renderer _renderer; private MaterialPropertyBlock _mpb; private static readonly int ColorPropertyID Shader.PropertyToID(_BaseColor); public Transform centerPoint; // 中心点 public float maxDistance 20f; // 最大影响距离 void Start() { _renderer GetComponentRenderer(); // 每个物体持有自己的MaterialPropertyBlock实例。 // 这对于需要独立、频繁更新属性的物体是合适的。 _mpb new MaterialPropertyBlock(); // 初始化先获取渲染器当前通过属性块设置的值如果有的话 _renderer.GetPropertyBlock(_mpb); } void Update() { if (centerPoint null) return; float distance Vector3.Distance(transform.position, centerPoint.position); float t Mathf.Clamp01(distance / maxDistance); // 计算比例因子 // 根据距离插值颜色 Color lerpedColor Color.Lerp(Color.red, Color.blue, t); // 更新属性块中的颜色值 _mpb.SetColor(ColorPropertyID, lerpedColor); // 将更新后的属性块重新设置回渲染器 _renderer.SetPropertyBlock(_mpb); } }应用与测试将这个脚本挂到之前生成的每个立方体预制体上或运行时添加并指定一个中心点如主摄像机。运行后你会看到立方体颜色随着距离动态变化。复用策略分析每物体一个MPB如上例所示每个需要独立动态更新的物体持有自己的MaterialPropertyBlock实例。这在属性更新频率高且各不相同时是合理的避免了每帧为大量物体新建对象。全局共享一个MPB如果大量物体需要在同一帧更新为相同的属性值例如全局环境光变化影响所有物体你可以创建一个全局的MaterialPropertyBlock在同一帧内为所有物体设置相同的值然后分别调用SetPropertyBlock。但要注意引用问题最好在设置后立即用新的值重新初始化该全局MPB或者为每个物体单独new一个。实操心得在Update中频繁调用SetPropertyBlock本身也有CPU开销。如果属性不需要每帧都变比如颜色只在特定事件时改变就应该将调用放在事件触发时而不是Update中。对于成千上万的物体即使是用MaterialPropertyBlock每帧遍历设置也是昂贵的需要考虑按需更新或使用GPU Instancing等更高级的技术。4. 深入解析合批条件、限制与最佳实践使用MaterialPropertyBlock的主要目的是为了促成合批Batching从而降低Draw Call。但并不是用了它就一定能合批它有自己的规则和限制。4.1 与合批系统的协作Unity的合批系统主要分两种动态合批Dynamic BatchingUnity运行时自动将满足条件的小网格合并绘制。静态合批Static Batching对标记为Static的物体在运行前或运行时提前合并网格。MaterialPropertyBlock对合批的影响积极影响因为它允许物体共享同一个Material实例sharedMaterial所以满足了合批最关键的一个前提条件——材质相同。这使得原本因为材质实例不同而无法合批的物体现在具备了合批的潜力。必要条件要成功合批物体还必须满足其他条件如使用相同的Mesh、处于相同的渲染队列Render Queue、具有相同的缩放尺度对于动态合批等。MaterialPropertyBlock本身不改变这些条件。4.2 MaterialPropertyBlock的使用限制与陷阱不支持所有Shader类型这是最重要的限制。MaterialPropertyBlock不能覆盖Shader中用MaterialPropertyDrawer如[Toggle],[Enum]定义的、在材质面板上显示为下拉菜单或复选框的属性。它只能覆盖那些基本的、通过SetFloat/Color/Vector/Texture设置的属性。如果你尝试覆盖一个枚举属性设置是无效的。与GPU Instancing的冲突MaterialPropertyBlock会默认禁用该渲染器的GPU Instancing。GPU Instancing是另一种用于渲染大量相同网格的、更高效的图形API级别技术。如果你的材质已经开启了Enable GPU Instancing并且你希望通过Instancing的每实例数据如UNITY_INSTANCING_BUFFER_START来传递差异化属性如颜色那么再使用MaterialPropertyBlock就会覆盖掉Instancing数据导致所有实例颜色相同或者产生意外效果。通常你需要根据情况在MaterialPropertyBlock和GPU Instancing之间做出选择。对于超大量数万、属性差异不大的物体GPU Instancing通常性能更好。属性块的生命周期与管理MaterialPropertyBlock对象本身是托管代码对象需要管理其生命周期。避免在每帧的Update中new一个新的属性块而应该复用。获取属性值通过MaterialPropertyBlock设置的值无法通过Material.GetXXX方法读取。必须通过Renderer.GetPropertyBlock来获取当前附加的属性块副本。4.3 最佳实践总结优先使用共享材质确保所有需要变体的物体都引用同一个Material资产sharedMaterial。缓存Shader属性ID始终使用Shader.PropertyToID在静态变量中缓存属性名对应的ID。复用MaterialPropertyBlock对象在循环或频繁更新中在外部创建一次MaterialPropertyBlock并复用而不是在循环内部创建。明确更新时机只在属性确实需要改变时才调用SetPropertyBlock不要放在每帧不变的Update中。注意与GPU Instancing的互斥如果项目使用了复杂的、基于Shader变体Variants的GPU Instancing方案谨慎引入MaterialPropertyBlock需充分测试渲染结果。用于“覆盖”而非“创建”牢记MaterialPropertyBlock是用于覆盖现有材质属性的。你的基础材质Shader和其默认属性仍然需要正确设置。性能分析使用Unity Profiler的Render模块和Frame Debugger工具来验证合批效果。在Frame Debugger中你可以清晰地看到每一个Draw Call以及为什么合批失败。5. 常见问题排查与实战技巧在实际使用中你可能会遇到一些意想不到的情况。下面是一些常见问题及其解决方法。5.1 问题排查速查表问题现象可能原因排查步骤与解决方案设置了MaterialPropertyBlock但物体颜色/纹理没变化。1. Shader属性名写错。2. 该属性是[Toggle]或[Enum]类型不受支持。3. 属性块未成功应用代码逻辑错误。1. 检查Shader代码确认准确的属性名。对于URP Lit主色是_BaseColor内置Standard是_Color。2. 尝试修改一个简单的_Float或_Color属性测试。3. 在SetPropertyBlock后使用Renderer.GetPropertyBlock检查是否应用成功。使用MaterialPropertyBlock后Draw Call并没有明显下降。1. 物体使用了不同的Mesh。2. 物体的缩放不一致影响动态合批。3. 物体被其他组件如Lightmap、Reflection Probe影响了合批。4. 材质本身未开启合批支持。1. 确保合批的物体使用相同的Mesh资产。2. 尝试将所有物体的缩放设为(1,1,1)。3. 在Frame Debugger中查看每个Draw Call的“Why not batched?”信息。4. 检查材质的Enable GPU Instancing是否关闭如果用了MPB。物体出现了奇怪的闪烁或渲染错误。1. 多个脚本在竞争修改同一个渲染器的属性块。2.MaterialPropertyBlock被多个物体共享引用并在一处修改。1. 确保属性块的管理权责清晰。可以考虑集中管理。2.牢记在循环中为不同物体设置不同属性时应在SetPropertyBlock前为同一个mpb对象设置新值或者为每个物体使用独立的mpb实例。在移动设备上性能提升不明显。1. 移动设备GPU的合批收益可能不如PC明显但CPU和内存收益仍在。2. 可能存在其他更大的性能瓶颈如骨骼动画、复杂光照。1. 使用Profiler分析CPU和GPU时间确认瓶颈所在。2. 即使Draw Call未减少减少Material实例化对内存和GC的优化也是有益的。5.2 实战技巧纹理数组与MaterialPropertyBlock结合有时我们不仅想改颜色还想让海量物体显示不同的纹理。一种高效的方法是使用纹理数组Texture2DArray配合MaterialPropertyBlock。创建纹理数组在脚本中或通过工具将多张纹理打包成一个Texture2DArray资源。修改Shader编写一个自定义Shader使用TEXTURE2D_ARRAY宏来采样纹理数组。需要一个float类型的属性如_TextureIndex来指定采样哪一层纹理。使用MaterialPropertyBlock设置索引private static readonly int TexArrayIndexID Shader.PropertyToID(“_TextureIndex”); ... mpb.SetFloat(TexArrayIndexID, Random.Range(0, textureArrayLayersCount)); renderer.SetPropertyBlock(mpb);这样所有物体可以共享一个材质和一个纹理数组资源仅通过属性块传递不同的索引值就能实现纹理的差异化这是管理海量差异化物体如不同种类的树木、石头的终极方案之一。5.3 与ScriptableRenderPipeline的配合在URP/HDRP中MaterialPropertyBlock的使用方式基本不变。但需要注意URP Shader的属性名可能不同例如_BaseColor代替了_Color。最好的做法是查看Shader源码或通过材质面板的“Debug”模式查看属性名。此外在SRP中你还可以在RenderPass中通过CommandBuffer.SetGlobalXXX设置全局属性或通过DrawingSettings和FilteringSettings进行更底层的渲染控制这与MaterialPropertyBlock在应用层级上的优化是互补的。最后记住一点MaterialPropertyBlock是优化工具而不是魔法。它解决了“材质实例化”这个特定问题。在着手优化前一定要用Profiler找准性能瓶颈。如果你的瓶颈在于网格数量太多、骨骼动画太复杂或光照计算太重那么优化材质管理可能收效甚微。但在它适用的场景里——即你需要成千上万个视觉上略有不同的相同物体时——正确地使用MaterialPropertyBlock将是让你的项目从“能跑”到“流畅”的关键一步。