1. 项目概述一个看似简单的选择背后是性能的“隐形杀手”如果你在Unity里做过3D渲染尤其是涉及到动态修改材质属性、换肤或者特效触发那你一定对MeshRenderer组件里的materials和sharedMaterials这两个属性不陌生。乍一看它们好像都能拿到或设置材质新手很容易混用甚至觉得用哪个都行。但我要告诉你这个选择绝不是随意的它直接关系到你项目的内存占用、GC垃圾回收频率最终决定了你的游戏是丝滑流畅还是卡顿掉帧。很多团队在项目后期进行性能剖析时会发现一个巨大的GC峰值追根溯源往往就是因为在某个高频更新的脚本里错误地使用了meshRenderer.materials。今天我们就来彻底拆解这对“孪生兄弟”把它们的区别、底层原理、使用场景和避坑指南讲透让你在性能优化路上先避开这个最常见的“大坑”。简单来说sharedMaterials是“共享的”它指向的是MeshRenderer所使用的材质资源本身的引用。而materials是“实例化的”当你获取或设置它时Unity可能会在背后为你创建一份该材质的独立副本。这个“可能创建副本”的行为就是所有性能问题的根源。理解并正确使用它们是Unity开发者从“功能实现”迈向“性能敏感”开发的关键一步。2. 核心原理深度拆解引用、副本与内存的故事要理解区别我们必须深入到Unity的资产管理和渲染管线层面去看。2.1 sharedMaterials指向资源文件的“快捷方式”sharedMaterials是一个Material数组它存储的是对项目中材质资源Asset的直接引用。你可以把它想象成一个“指针”或“快捷方式”它本身不包含材质的具体数据如颜色、纹理、Shader参数只是告诉渲染系统“去用Assets/Materials/MyMat.mat这个文件来渲染我”。关键特性共享性多个MeshRenderer的sharedMaterials可以指向同一个材质资源文件。当你通过一个渲染器修改sharedMaterial的属性如sharedMaterial.color时所有使用该材质资源的渲染器都会立即生效因为你们修改的是同一个“源文件”。零开销理论上获取或设置sharedMaterials属性通常不会创建新的材质实例也不会产生GC Alloc垃圾回收分配。它只是一个引用赋值操作。持久化对sharedMaterial属性的修改在编辑器模式下会直接保存到材质资源文件中如果允许的话在运行时会持续生效直到场景重置或资源被重新加载。// 示例修改sharedMaterial影响所有使用该材质的对象 MeshRenderer rendererA, rendererB; // 假设rendererA和rendererB的sharedMaterials都指向了同一个材质资源“RedMat” rendererA.sharedMaterial.color Color.blue; // 直接修改资源 // 此时rendererB渲染的颜色也会立刻变成蓝色因为它们共享同一个材质源。2.2 materials可能触发的“克隆工厂”materials属性则要“狡猾”得多。它的行为分为“获取”getter和“设置”setter两种情况而陷阱主要藏在“获取”操作里。获取GetmeshRenderer.materials 这是最需要警惕的操作。当你调用var mats renderer.materials;时Unity的底层逻辑是这样的检查该MeshRenderer当前是否已经拥有独立的材质实例即之前是否通过materials属性设置过或调用过MaterialPropertyBlock。如果没有独立实例Unity会自动为sharedMaterials数组中的每一个材质资源创建一个全新的Material实例Clone然后将这个新实例数组返回给你。这个新创建的Material实例是运行时的对象它独立于原始的材质资源文件。修改它不会影响其他使用相同材质资源的渲染器。这个“自动创建实例”的过程就是一次内存分配New Material并且返回的数组也是一个新分配的数组。这两者都会产生GC Alloc。设置SetmeshRenderer.materials newMats; 这个操作相对直接。你将一个Material数组赋值给materials属性。Unity会直接用这个新数组作为该渲染器的材质列表。如果数组里的材质是实例那就用实例如果是共享资源那就用共享资源。这个赋值操作本身通常不会触发额外的克隆。// 示例获取materials可能触发克隆 MeshRenderer renderer; // 第一次获取materials触发克隆产生GC Alloc。 Material[] mats1 renderer.materials; // 注意这里可能已经创建了实例 mats1[0].color Color.green; // 只修改这个渲染器的实例不影响其他。 // 第二次获取materials因为已有实例直接返回实例引用可能仍有数组分配。 Material[] mats2 renderer.materials; // 可能不克隆材质了但数组仍是新的。 // 直接设置materials Material newMat new Material(shader); // 手动创建新实例 renderer.materials new Material[] { newMat }; // 直接赋值无额外克隆。2.3 性能陷阱对比表为了让区别更直观我们用一个表格来对比关键差异特性维度sharedMaterialsmaterials(Get操作)说明与影响本质对材质资源(Asset)的引用数组。获取时返回材质实例数组可能自动克隆。sharedMaterials是“源文件列表”materials是“当前使用的复印件列表”。内存分配通常无。仅为引用操作。高概率有。可能分配新Material实例和新数组。频繁获取materials会导致大量小内存对象产生加剧GC压力。GC Alloc极少或没有。几乎每次Get都有至少分配新数组。在Update、FixedUpdate等高频循环中获取materials是性能灾难。修改影响范围全局性。修改sharedMaterial.color会影响所有使用该材质的物体。局部性。修改返回的实例材质只影响当前渲染器。需要改变单个物体外观时用materials获取实例来改是对的但方式有讲究。使用场景1. 初始化时赋值材质资源。2. 需要全局改变一类物体外观如环境光变色。3. 只读取材质信息绝不修改。1. 需要独立修改某个物体的材质属性且不影响他人。2.但应避免在循环中直接调用Getter理解场景是关键滥用sharedMaterials改属性会导致意外联动滥用materials获取会导致性能崩盘。核心心法你可以把sharedMaterials看作仓库里的原件图纸把materialsGet看作发给某个生产车间MeshRenderer的复印件。每次问车间“你现在用的图纸是哪份”(get_materials)如果车间没有复印件仓库就会立刻复印一份(Clone)给你看。频繁地问仓库就频繁地复印产生了大量废纸GC垃圾。3. 实战场景分析与最佳实践选择明白了原理我们来看具体在什么情况下该用谁以及如何用得高效。3.1 场景一初始化或批量替换材质资源当你需要在游戏启动时、或根据配置批量设置一批物体的材质时应使用sharedMaterials。// 推荐做法使用sharedMaterials进行初始化或批量设置 public Material wallMaterial; // 在Inspector中拖入预设的材质资源 void Start() { MeshRenderer[] allWalls FindObjectsOfTypeMeshRenderer(); foreach (var renderer in allWalls) { if (renderer.CompareTag(Wall)) { // 直接赋值共享材质资源高效无分配 renderer.sharedMaterial wallMaterial; // 如果是多个子材质使用 sharedMaterials // renderer.sharedMaterials new Material[] { wallMaterial, otherMat }; } } }为什么这么做这里我们的目的是让所有墙使用同一个材质“资源”不需要每个墙有独立属性。使用sharedMaterials直接赋值引用零内存分配效率最高。修改wallMaterial的资源属性所有墙会一起变这通常也是符合预期的。3.2 场景二需要独立修改单个渲染器的材质属性这是最容易出错的地方。需求是让一个角色受伤时变红但其他同款角色不变。错误做法性能杀手void Update() { if (isDamaged) { // 在Update里直接获取materials每帧都产生GC Alloc Material[] mats GetComponentMeshRenderer().materials; mats[0].color Color.red; // 注意即使你只是读取了数组克隆也可能已经发生 // 并且你还需要将修改后的数组赋值回去吗实际上不需要因为mats[0]已经是实例的引用。 // 但更糟的是下一帧Update又会调用get_materials再次分配 } }正确做法一缓存材质实例最常用private MeshRenderer _renderer; private Material _instanceMaterial; // 缓存实例化后的材质 void Start() { _renderer GetComponentMeshRenderer(); // 在Start时主动获取一次触发克隆并缓存起来。以后永远不再调用 Getter。 _instanceMaterial _renderer.material; // 注意这是单材质版本同理会克隆 // 如果是多材质使用 _renderer.materials[0] 并缓存整个数组或所需实例 } void Update() { if (isDamaged) { // 直接使用缓存的实例进行修改零GC _instanceMaterial.color Color.red; } else { _instanceMaterial.color Color.white; } } void OnDestroy() { // 重要手动创建的Material实例不会自动销毁必须手动管理否则内存泄漏。 if (_instanceMaterial ! null) { Destroy(_instanceMaterial); } }为什么这么做我们在初始化时Start支付一次创建实例的成本一次GC Alloc然后将这个实例的引用缓存起来。在后续每帧的更新中我们直接操作这个缓存实例完全避免了持续的内存分配和GC压力。这是解决动态修改单个物体属性的标准模式。正确做法二使用MaterialPropertyBlock更高级、更高效当你只需要修改一些Shader属性如_Color,_MainTex_ST而不需要切换不同的Shader或材质资源时MaterialPropertyBlock是终极解决方案。private MeshRenderer _renderer; private MaterialPropertyBlock _propertyBlock; void Start() { _renderer GetComponentMeshRenderer(); _propertyBlock new MaterialPropertyBlock(); // 创建一次可复用 } void Update() { if (isDamaged) { // 1. 获取当前渲染器已有的PropertyBlock如果有 _renderer.GetPropertyBlock(_propertyBlock); // 2. 设置属性 _propertyBlock.SetColor(_Color, Color.red); // 3. 应用回渲染器 _renderer.SetPropertyBlock(_propertyBlock); } else { // 清除或设置回默认属性 _renderer.SetPropertyBlock(null); } }为什么这么做MaterialPropertyBlock允许你覆盖某个特定渲染器的Shader属性而完全不需要创建任何材质实例。它是在GPU渲染命令层面进行的覆盖性能开销极低且不影响其他共享同一材质的物体。这是Unity官方推荐的对大量物体进行差异化渲染如大量草、树木、士兵的颜色变化的首选方案。它唯一的“缺点”是只能修改属性不能更换Shader或材质资源本身。3.3 场景三只读访问材质信息如果你只是想读取材质的某个属性例如判断材质使用了哪个Shader那么你应该使用sharedMaterials来避免不必要的实例化。bool IsUsingStandardShader(MeshRenderer renderer) { // 使用sharedMaterial读取信息避免创建实例 return renderer.sharedMaterial.shader.name Standard; }4. 性能问题排查与优化实战记录在实际项目中如何发现并定位由materials误用引起的性能问题呢4.1 使用Profiler抓出“真凶”Unity Profiler是你的第一道防线。打开Profiler窗口切换到CPU使用率模块。观察GC Alloc列在游戏运行时特别关注那些每帧都出现的高频GC Alloc。如果一帧内产生了数十甚至上百B的“小额”分配这很可疑。深入调用堆栈点击那一帧的GC Alloc数据条在下方详情窗口会显示具体的分配来源。寻找诸如Material.CreateWithMaterial、Material..ctor构造函数或MeshRenderer.get_materials这样的调用。一旦看到它们出现在高频Update函数中基本就可以定罪了。内存Profiler辅助切换到Memory Profiler查看Material对象的数量。如果运行时Material实例数量远远超过你项目中材质资源文件的数量并且持续增长那说明有地方在不停创建新的材质实例而没有销毁存在内存泄漏。4.2 常见问题排查清单当你怀疑或确认存在相关性能问题时可以按此清单排查现象可能原因排查与解决方案游戏间歇性卡顿频繁的GC Alloc触发垃圾回收导致CPU峰值。使用Profiler确认卡顿帧是否伴随GC.Collect。追溯GC Alloc来源查找高频的get_materials或new Material调用。材质修改“传染”本想只改一个物体颜色结果同材质的所有物体都变了。检查代码你是否错误地使用了renderer.sharedMaterial.color进行修改应改为使用实例化材质或MaterialPropertyBlock。运行时材质数量暴涨Memory Profiler中Material实例数异常多。1. 检查是否有脚本在循环中不断调用GetComponentRenderer().material(s)且未缓存。2. 检查动态创建的物体其材质是否是临时创建的且未复用。3. 确认手动创建的材质实例new Material(...)在物体销毁时OnDestroy是否被Destroy。移动设备发热耗电快不必要的每帧材质操作导致GPU状态频繁切换和CPU计算。优化更新频率。例如颜色变化用MaterialPropertyBlock替代修改材质实例将每帧修改改为在状态改变时如受伤、升级才修改。4.3 一个真实的优化案例技能特效的材质控制我曾优化过一个ARPG项目的技能系统。原先的技能命中特效为了表现击中后的颜色渐变在Update中这样写void Update() { float lerpValue Mathf.PingPong(Time.time, 1.0f); // 每帧都获取materials产生GC Material[] mats particleRenderer.materials; mats[0].SetColor(_TintColor, Color.Lerp(startColor, endColor, lerpValue)); }问题一个特效可能持续2秒60帧就是120次GC Alloc。屏幕上同时出现10个特效就是1200次/秒的分配GC压力巨大。优化方案缓存在Start或OnEnable中_instanceMat particleRenderer.material;缓存实例。改用PropertyBlock因为只修改颜色属性使用MaterialPropertyBlock是更优解。降低频率颜色变化不需要每帧更新可以每2-3帧更新一次或者使用Shader Graph通过Time节点在GPU端实现彻底解放CPU。最终采用方案2重构后private MaterialPropertyBlock _propBlock; void Start() { _propBlock new MaterialPropertyBlock(); particleRenderer.GetPropertyBlock(_propBlock); } void Update() { float lerpValue Mathf.PingPong(Time.time, 1.0f); _propBlock.SetColor(_TintColor, Color.Lerp(startColor, endColor, lerpValue)); particleRenderer.SetPropertyBlock(_propBlock); // 几乎无开销 }优化后该特效的CPU开销和GC Alloc降为近乎零移动端帧率显著提升。5. 高级话题与扩展思考掌握了基础用法和优化后我们再看一些更深层或相关的知识点。5.1 MaterialPropertyBlock的局限性与其适用边界虽然MaterialPropertyBlockMPB很强大但它并非万能。不能更换Shader或材质资源MPB只能覆盖现有材质上的属性。如果你需要从一个Standard材质切换到一个Unlit材质MPB做不到你必须更换材质实例或sharedMaterial。批处理影响使用MPB会打断动态合批Dynamic Batching。因为每个物体的属性不同了Unity无法将它们合并成一个Draw Call。但对于静态合批Static Batching或GPU Instancing如果Shader支持MPB可以与之协同工作通过EnableInstancing和_PerRendererData。属性名称与IDSetColor(“_Color”, color)中的“_Color”是属性名每次调用都会计算一次字符串Hash到Shader Property ID。更高效的做法是在Start中缓存这个IDint colorID Shader.PropertyToID(“_Color”);然后使用SetColor(colorID, color)。5.2 多子材质Sub-Meshes的处理一个Mesh可能有多个子网格对应materials数组的多个元素。上述所有原则同样适用只是操作对象变成了数组。缓存整个数组如果你需要修改多个子材质最好缓存整个Material[]实例数组。针对性修改如果只修改其中一个可以只缓存那一个实例Material。MPB对多材质SetPropertyBlock默认将属性块应用到所有子材质。如果你需要为不同子材质设置不同属性需要使用SetPropertyBlock(_propBlock, materialIndex)指定子材质索引。5.3 资源管理与内存泄漏防范这是使用实例化材质时必须牢记的准则谁创建谁销毁。Unity AssetsharedMaterial引用的.mat文件由Unity资源管理系统自动管理。运行时通过new Material(...)或renderer.materialgetter创建的Material实例是C#托管对象但其底层对应着GPU资源。它们不会在渲染器销毁或场景卸载时自动销毁你必须手动调用Destroy(materialInstance)来释放。通常放在OnDestroy()方法中。一个良好的实践是在创建实例的同一脚本中用一个私有字段引用它并在OnDestroy中检查并销毁它。这能有效避免内存泄漏。5.4 编辑器脚本中的注意事项在编写Editor工具时你修改的是场景资源的状态。此时直接修改sharedMaterial通常是安全的因为你的操作目标就是改变资产本身或其在场景中的引用。但在编辑器脚本中频繁调用materials的Getter同样可能导致编辑器卡顿原理是类似的。6. 总结与最终行动指南回到我们最初的问题materials和sharedMaterials到底怎么选答案已经清晰当你需要“读取”材质信息或进行“一次性”的材质资源分配时- 使用sharedMaterials。安全、高效。当你需要“独立修改”某个渲染器的材质属性且修改会持续发生如每帧时-优先使用MaterialPropertyBlock。这是性能最优解。当MaterialPropertyBlock无法满足需求如需要切换不同Shader且仍需独立修改时-使用materials/material的Getter但务必在初始化时如Start仅调用一次并缓存结果后续永远操作缓存实例并在物体销毁时手动Destroy该实例。最后的黄金法则在你的代码编辑器中把GetComponentRenderer().material(s)的Getter调用视为一个“危险信号”。每当写下这行代码时停下来问自己“我是不是在循环或高频更新中我是否需要缓存这个结果有没有可能用MaterialPropertyBlock替代” 养成这个习惯你的项目就离性能瓶颈远了一大步。性能优化往往就藏在这些看似微小的选择里避开了这个坑你的Unity之路会顺畅很多。
Unity性能优化:MeshRenderer.materials与sharedMaterials深度解析与实战避坑
1. 项目概述一个看似简单的选择背后是性能的“隐形杀手”如果你在Unity里做过3D渲染尤其是涉及到动态修改材质属性、换肤或者特效触发那你一定对MeshRenderer组件里的materials和sharedMaterials这两个属性不陌生。乍一看它们好像都能拿到或设置材质新手很容易混用甚至觉得用哪个都行。但我要告诉你这个选择绝不是随意的它直接关系到你项目的内存占用、GC垃圾回收频率最终决定了你的游戏是丝滑流畅还是卡顿掉帧。很多团队在项目后期进行性能剖析时会发现一个巨大的GC峰值追根溯源往往就是因为在某个高频更新的脚本里错误地使用了meshRenderer.materials。今天我们就来彻底拆解这对“孪生兄弟”把它们的区别、底层原理、使用场景和避坑指南讲透让你在性能优化路上先避开这个最常见的“大坑”。简单来说sharedMaterials是“共享的”它指向的是MeshRenderer所使用的材质资源本身的引用。而materials是“实例化的”当你获取或设置它时Unity可能会在背后为你创建一份该材质的独立副本。这个“可能创建副本”的行为就是所有性能问题的根源。理解并正确使用它们是Unity开发者从“功能实现”迈向“性能敏感”开发的关键一步。2. 核心原理深度拆解引用、副本与内存的故事要理解区别我们必须深入到Unity的资产管理和渲染管线层面去看。2.1 sharedMaterials指向资源文件的“快捷方式”sharedMaterials是一个Material数组它存储的是对项目中材质资源Asset的直接引用。你可以把它想象成一个“指针”或“快捷方式”它本身不包含材质的具体数据如颜色、纹理、Shader参数只是告诉渲染系统“去用Assets/Materials/MyMat.mat这个文件来渲染我”。关键特性共享性多个MeshRenderer的sharedMaterials可以指向同一个材质资源文件。当你通过一个渲染器修改sharedMaterial的属性如sharedMaterial.color时所有使用该材质资源的渲染器都会立即生效因为你们修改的是同一个“源文件”。零开销理论上获取或设置sharedMaterials属性通常不会创建新的材质实例也不会产生GC Alloc垃圾回收分配。它只是一个引用赋值操作。持久化对sharedMaterial属性的修改在编辑器模式下会直接保存到材质资源文件中如果允许的话在运行时会持续生效直到场景重置或资源被重新加载。// 示例修改sharedMaterial影响所有使用该材质的对象 MeshRenderer rendererA, rendererB; // 假设rendererA和rendererB的sharedMaterials都指向了同一个材质资源“RedMat” rendererA.sharedMaterial.color Color.blue; // 直接修改资源 // 此时rendererB渲染的颜色也会立刻变成蓝色因为它们共享同一个材质源。2.2 materials可能触发的“克隆工厂”materials属性则要“狡猾”得多。它的行为分为“获取”getter和“设置”setter两种情况而陷阱主要藏在“获取”操作里。获取GetmeshRenderer.materials 这是最需要警惕的操作。当你调用var mats renderer.materials;时Unity的底层逻辑是这样的检查该MeshRenderer当前是否已经拥有独立的材质实例即之前是否通过materials属性设置过或调用过MaterialPropertyBlock。如果没有独立实例Unity会自动为sharedMaterials数组中的每一个材质资源创建一个全新的Material实例Clone然后将这个新实例数组返回给你。这个新创建的Material实例是运行时的对象它独立于原始的材质资源文件。修改它不会影响其他使用相同材质资源的渲染器。这个“自动创建实例”的过程就是一次内存分配New Material并且返回的数组也是一个新分配的数组。这两者都会产生GC Alloc。设置SetmeshRenderer.materials newMats; 这个操作相对直接。你将一个Material数组赋值给materials属性。Unity会直接用这个新数组作为该渲染器的材质列表。如果数组里的材质是实例那就用实例如果是共享资源那就用共享资源。这个赋值操作本身通常不会触发额外的克隆。// 示例获取materials可能触发克隆 MeshRenderer renderer; // 第一次获取materials触发克隆产生GC Alloc。 Material[] mats1 renderer.materials; // 注意这里可能已经创建了实例 mats1[0].color Color.green; // 只修改这个渲染器的实例不影响其他。 // 第二次获取materials因为已有实例直接返回实例引用可能仍有数组分配。 Material[] mats2 renderer.materials; // 可能不克隆材质了但数组仍是新的。 // 直接设置materials Material newMat new Material(shader); // 手动创建新实例 renderer.materials new Material[] { newMat }; // 直接赋值无额外克隆。2.3 性能陷阱对比表为了让区别更直观我们用一个表格来对比关键差异特性维度sharedMaterialsmaterials(Get操作)说明与影响本质对材质资源(Asset)的引用数组。获取时返回材质实例数组可能自动克隆。sharedMaterials是“源文件列表”materials是“当前使用的复印件列表”。内存分配通常无。仅为引用操作。高概率有。可能分配新Material实例和新数组。频繁获取materials会导致大量小内存对象产生加剧GC压力。GC Alloc极少或没有。几乎每次Get都有至少分配新数组。在Update、FixedUpdate等高频循环中获取materials是性能灾难。修改影响范围全局性。修改sharedMaterial.color会影响所有使用该材质的物体。局部性。修改返回的实例材质只影响当前渲染器。需要改变单个物体外观时用materials获取实例来改是对的但方式有讲究。使用场景1. 初始化时赋值材质资源。2. 需要全局改变一类物体外观如环境光变色。3. 只读取材质信息绝不修改。1. 需要独立修改某个物体的材质属性且不影响他人。2.但应避免在循环中直接调用Getter理解场景是关键滥用sharedMaterials改属性会导致意外联动滥用materials获取会导致性能崩盘。核心心法你可以把sharedMaterials看作仓库里的原件图纸把materialsGet看作发给某个生产车间MeshRenderer的复印件。每次问车间“你现在用的图纸是哪份”(get_materials)如果车间没有复印件仓库就会立刻复印一份(Clone)给你看。频繁地问仓库就频繁地复印产生了大量废纸GC垃圾。3. 实战场景分析与最佳实践选择明白了原理我们来看具体在什么情况下该用谁以及如何用得高效。3.1 场景一初始化或批量替换材质资源当你需要在游戏启动时、或根据配置批量设置一批物体的材质时应使用sharedMaterials。// 推荐做法使用sharedMaterials进行初始化或批量设置 public Material wallMaterial; // 在Inspector中拖入预设的材质资源 void Start() { MeshRenderer[] allWalls FindObjectsOfTypeMeshRenderer(); foreach (var renderer in allWalls) { if (renderer.CompareTag(Wall)) { // 直接赋值共享材质资源高效无分配 renderer.sharedMaterial wallMaterial; // 如果是多个子材质使用 sharedMaterials // renderer.sharedMaterials new Material[] { wallMaterial, otherMat }; } } }为什么这么做这里我们的目的是让所有墙使用同一个材质“资源”不需要每个墙有独立属性。使用sharedMaterials直接赋值引用零内存分配效率最高。修改wallMaterial的资源属性所有墙会一起变这通常也是符合预期的。3.2 场景二需要独立修改单个渲染器的材质属性这是最容易出错的地方。需求是让一个角色受伤时变红但其他同款角色不变。错误做法性能杀手void Update() { if (isDamaged) { // 在Update里直接获取materials每帧都产生GC Alloc Material[] mats GetComponentMeshRenderer().materials; mats[0].color Color.red; // 注意即使你只是读取了数组克隆也可能已经发生 // 并且你还需要将修改后的数组赋值回去吗实际上不需要因为mats[0]已经是实例的引用。 // 但更糟的是下一帧Update又会调用get_materials再次分配 } }正确做法一缓存材质实例最常用private MeshRenderer _renderer; private Material _instanceMaterial; // 缓存实例化后的材质 void Start() { _renderer GetComponentMeshRenderer(); // 在Start时主动获取一次触发克隆并缓存起来。以后永远不再调用 Getter。 _instanceMaterial _renderer.material; // 注意这是单材质版本同理会克隆 // 如果是多材质使用 _renderer.materials[0] 并缓存整个数组或所需实例 } void Update() { if (isDamaged) { // 直接使用缓存的实例进行修改零GC _instanceMaterial.color Color.red; } else { _instanceMaterial.color Color.white; } } void OnDestroy() { // 重要手动创建的Material实例不会自动销毁必须手动管理否则内存泄漏。 if (_instanceMaterial ! null) { Destroy(_instanceMaterial); } }为什么这么做我们在初始化时Start支付一次创建实例的成本一次GC Alloc然后将这个实例的引用缓存起来。在后续每帧的更新中我们直接操作这个缓存实例完全避免了持续的内存分配和GC压力。这是解决动态修改单个物体属性的标准模式。正确做法二使用MaterialPropertyBlock更高级、更高效当你只需要修改一些Shader属性如_Color,_MainTex_ST而不需要切换不同的Shader或材质资源时MaterialPropertyBlock是终极解决方案。private MeshRenderer _renderer; private MaterialPropertyBlock _propertyBlock; void Start() { _renderer GetComponentMeshRenderer(); _propertyBlock new MaterialPropertyBlock(); // 创建一次可复用 } void Update() { if (isDamaged) { // 1. 获取当前渲染器已有的PropertyBlock如果有 _renderer.GetPropertyBlock(_propertyBlock); // 2. 设置属性 _propertyBlock.SetColor(_Color, Color.red); // 3. 应用回渲染器 _renderer.SetPropertyBlock(_propertyBlock); } else { // 清除或设置回默认属性 _renderer.SetPropertyBlock(null); } }为什么这么做MaterialPropertyBlock允许你覆盖某个特定渲染器的Shader属性而完全不需要创建任何材质实例。它是在GPU渲染命令层面进行的覆盖性能开销极低且不影响其他共享同一材质的物体。这是Unity官方推荐的对大量物体进行差异化渲染如大量草、树木、士兵的颜色变化的首选方案。它唯一的“缺点”是只能修改属性不能更换Shader或材质资源本身。3.3 场景三只读访问材质信息如果你只是想读取材质的某个属性例如判断材质使用了哪个Shader那么你应该使用sharedMaterials来避免不必要的实例化。bool IsUsingStandardShader(MeshRenderer renderer) { // 使用sharedMaterial读取信息避免创建实例 return renderer.sharedMaterial.shader.name Standard; }4. 性能问题排查与优化实战记录在实际项目中如何发现并定位由materials误用引起的性能问题呢4.1 使用Profiler抓出“真凶”Unity Profiler是你的第一道防线。打开Profiler窗口切换到CPU使用率模块。观察GC Alloc列在游戏运行时特别关注那些每帧都出现的高频GC Alloc。如果一帧内产生了数十甚至上百B的“小额”分配这很可疑。深入调用堆栈点击那一帧的GC Alloc数据条在下方详情窗口会显示具体的分配来源。寻找诸如Material.CreateWithMaterial、Material..ctor构造函数或MeshRenderer.get_materials这样的调用。一旦看到它们出现在高频Update函数中基本就可以定罪了。内存Profiler辅助切换到Memory Profiler查看Material对象的数量。如果运行时Material实例数量远远超过你项目中材质资源文件的数量并且持续增长那说明有地方在不停创建新的材质实例而没有销毁存在内存泄漏。4.2 常见问题排查清单当你怀疑或确认存在相关性能问题时可以按此清单排查现象可能原因排查与解决方案游戏间歇性卡顿频繁的GC Alloc触发垃圾回收导致CPU峰值。使用Profiler确认卡顿帧是否伴随GC.Collect。追溯GC Alloc来源查找高频的get_materials或new Material调用。材质修改“传染”本想只改一个物体颜色结果同材质的所有物体都变了。检查代码你是否错误地使用了renderer.sharedMaterial.color进行修改应改为使用实例化材质或MaterialPropertyBlock。运行时材质数量暴涨Memory Profiler中Material实例数异常多。1. 检查是否有脚本在循环中不断调用GetComponentRenderer().material(s)且未缓存。2. 检查动态创建的物体其材质是否是临时创建的且未复用。3. 确认手动创建的材质实例new Material(...)在物体销毁时OnDestroy是否被Destroy。移动设备发热耗电快不必要的每帧材质操作导致GPU状态频繁切换和CPU计算。优化更新频率。例如颜色变化用MaterialPropertyBlock替代修改材质实例将每帧修改改为在状态改变时如受伤、升级才修改。4.3 一个真实的优化案例技能特效的材质控制我曾优化过一个ARPG项目的技能系统。原先的技能命中特效为了表现击中后的颜色渐变在Update中这样写void Update() { float lerpValue Mathf.PingPong(Time.time, 1.0f); // 每帧都获取materials产生GC Material[] mats particleRenderer.materials; mats[0].SetColor(_TintColor, Color.Lerp(startColor, endColor, lerpValue)); }问题一个特效可能持续2秒60帧就是120次GC Alloc。屏幕上同时出现10个特效就是1200次/秒的分配GC压力巨大。优化方案缓存在Start或OnEnable中_instanceMat particleRenderer.material;缓存实例。改用PropertyBlock因为只修改颜色属性使用MaterialPropertyBlock是更优解。降低频率颜色变化不需要每帧更新可以每2-3帧更新一次或者使用Shader Graph通过Time节点在GPU端实现彻底解放CPU。最终采用方案2重构后private MaterialPropertyBlock _propBlock; void Start() { _propBlock new MaterialPropertyBlock(); particleRenderer.GetPropertyBlock(_propBlock); } void Update() { float lerpValue Mathf.PingPong(Time.time, 1.0f); _propBlock.SetColor(_TintColor, Color.Lerp(startColor, endColor, lerpValue)); particleRenderer.SetPropertyBlock(_propBlock); // 几乎无开销 }优化后该特效的CPU开销和GC Alloc降为近乎零移动端帧率显著提升。5. 高级话题与扩展思考掌握了基础用法和优化后我们再看一些更深层或相关的知识点。5.1 MaterialPropertyBlock的局限性与其适用边界虽然MaterialPropertyBlockMPB很强大但它并非万能。不能更换Shader或材质资源MPB只能覆盖现有材质上的属性。如果你需要从一个Standard材质切换到一个Unlit材质MPB做不到你必须更换材质实例或sharedMaterial。批处理影响使用MPB会打断动态合批Dynamic Batching。因为每个物体的属性不同了Unity无法将它们合并成一个Draw Call。但对于静态合批Static Batching或GPU Instancing如果Shader支持MPB可以与之协同工作通过EnableInstancing和_PerRendererData。属性名称与IDSetColor(“_Color”, color)中的“_Color”是属性名每次调用都会计算一次字符串Hash到Shader Property ID。更高效的做法是在Start中缓存这个IDint colorID Shader.PropertyToID(“_Color”);然后使用SetColor(colorID, color)。5.2 多子材质Sub-Meshes的处理一个Mesh可能有多个子网格对应materials数组的多个元素。上述所有原则同样适用只是操作对象变成了数组。缓存整个数组如果你需要修改多个子材质最好缓存整个Material[]实例数组。针对性修改如果只修改其中一个可以只缓存那一个实例Material。MPB对多材质SetPropertyBlock默认将属性块应用到所有子材质。如果你需要为不同子材质设置不同属性需要使用SetPropertyBlock(_propBlock, materialIndex)指定子材质索引。5.3 资源管理与内存泄漏防范这是使用实例化材质时必须牢记的准则谁创建谁销毁。Unity AssetsharedMaterial引用的.mat文件由Unity资源管理系统自动管理。运行时通过new Material(...)或renderer.materialgetter创建的Material实例是C#托管对象但其底层对应着GPU资源。它们不会在渲染器销毁或场景卸载时自动销毁你必须手动调用Destroy(materialInstance)来释放。通常放在OnDestroy()方法中。一个良好的实践是在创建实例的同一脚本中用一个私有字段引用它并在OnDestroy中检查并销毁它。这能有效避免内存泄漏。5.4 编辑器脚本中的注意事项在编写Editor工具时你修改的是场景资源的状态。此时直接修改sharedMaterial通常是安全的因为你的操作目标就是改变资产本身或其在场景中的引用。但在编辑器脚本中频繁调用materials的Getter同样可能导致编辑器卡顿原理是类似的。6. 总结与最终行动指南回到我们最初的问题materials和sharedMaterials到底怎么选答案已经清晰当你需要“读取”材质信息或进行“一次性”的材质资源分配时- 使用sharedMaterials。安全、高效。当你需要“独立修改”某个渲染器的材质属性且修改会持续发生如每帧时-优先使用MaterialPropertyBlock。这是性能最优解。当MaterialPropertyBlock无法满足需求如需要切换不同Shader且仍需独立修改时-使用materials/material的Getter但务必在初始化时如Start仅调用一次并缓存结果后续永远操作缓存实例并在物体销毁时手动Destroy该实例。最后的黄金法则在你的代码编辑器中把GetComponentRenderer().material(s)的Getter调用视为一个“危险信号”。每当写下这行代码时停下来问自己“我是不是在循环或高频更新中我是否需要缓存这个结果有没有可能用MaterialPropertyBlock替代” 养成这个习惯你的项目就离性能瓶颈远了一大步。性能优化往往就藏在这些看似微小的选择里避开了这个坑你的Unity之路会顺畅很多。