做游戏特效和渲染表现这些年,消融效果一直是我最常用也最爱用的一类需求:怪物死亡时身体像被魔法一点点烧掉、Boss战胜利后残骸化为灰烬、场景切换时墙壁像被高温熔穿——这些在Unity里大多靠同一种技术实现,就是“动态着色 + 消融效果”。所谓动态着色,不是贴一张会流动的贴图那么简单,而是让Shader在运行时逐帧修改像素的去留和颜色,把整块物体从完整状态过渡到消失状态。这套技术做起来不难,但要让效果不廉价、性能不出问题、还能被策划自由控制进度,里面有不少细节值得聊。
这篇文章我按自己的实际项目经验来写:核心思路、Shader写法、边缘发光和烧焦处理、C#驱动逻辑,最后是常见问题和性能避坑。适用于URP管线,我用的是Unity 2021.3 LTS + URP 12,这套方案稍微改改也能放到内置渲染管线和Shader Graph里。
1. 消融效果怎么做出来的:方案选型与渲染思路
1.1 消融不是简单的透明,而是“像素级消失”
很多第一次做消融的同学,第一反应是用材质透明度做淡出。但做过一遍就会明白,单纯降低Alpha值,角色看起来不是“消失了”,而是变成一块越来越透明的玻璃,内部、边缘、穿模问题全暴露出来。尤其角色身上有多层材质、披风、头发的时候,半透明排序会让人头大:前后关系稍微一乱,画面就开始闪烁,根本不像是被烧毁或者汽化。
真正的消融,需要做到同一个三角形网格的不同部分,消失时间不一样。这个效果靠Alpha混合是做不到的,因为我不能在同一个不透明物体的内部做出“局部透明”,只要三角形还在,要么整片覆盖,要么什么都不画。
这时候就得依赖GPU的片元级裁剪机制。在Fragment Shader阶段,我可以对每个像素单独做判断:如果满足某个条件就保留这个像素,不满足就直接丢弃。丢弃的像素不会写入颜色缓冲和深度缓冲,对后续渲染也不可见。这样同一个三角形上,有的像素活着,有的像素消失,视觉上就会出现类似“被啃掉一口”的效果。
但问题是:怎么决定哪些像素先消失、哪些像素后消失?拿一个纯色物体来说,如果所有像素的丢弃条件都一样,整个物体要么全在、要么全消失,没有中间过程。要让消融变得连续自然,就需要给每个像素一张“差异化指令地图”,这就是噪声贴图的核心价值。
噪声贴图本质上是一张灰度图,不同UV位置的像素会采样到不同的灰度值。写Shader时,我用一个叫“消融进度”的浮点参数作为阈值,把这个阈值和当前像素的噪声值做比较:当进度刚起步时,只有少数区域被裁掉;进度慢慢变大,越来越多低噪声值区域消失;最后只剩高亮区域还在,直到全部消失。因为噪声灰度总是不规则的,物体消失的形状也就会不规则的剥离,就像被逐渐腐蚀掉一样。
1.2 动态着色在这里扮演什么角色
如果只是用一张噪声图做Clip,其实还算不上“动态着色”,更像是一个静态裁切动画。加了“动态着色”这个关键词以后,我理解的方案是:在消融过程中,物体表面颜色也要跟着变化,这种变化可以由进度值、噪声采样值、甚至时间一起驱动。
最早做项目时,我的需求是“敌人被命中后开始溶解,溶解边缘有高温火光,颜色从亮白过渡到暗红,最后碎成灰烬”。这个需求里,颜色变化不是固定不变的贴图,而是在Shader里实时计算的。可以说,动态着色是消融效果的上层包装,而裁切只是底层机制。
也正是因为需要“动态”持续变化,我强烈不建议用美术做一串序列帧来代替。序列帧在小范围、固定视角的特效里能用,但一旦模型复杂、镜头拉近或旋转,序列帧会出现穿帮,资源量也爆炸。动态着色方案用一个Shader加上一张噪声图,通过脚本实时改Float参数就能驱动,不同物体还能共用材质,资源上特别省。
另外,动态着色还能很好地和其他效果联动。比如我在边沿高亮的部分叠加一个Emission值,当进度变化时,发光区域会跟着阈值移动。换句话说,物体表面有一层“看不见的火焰”,火焰的位置由噪声阈值实时计算出来,和我要求的效果完全吻合。
1.3 Shader Graph 还是手写 Shader?
具体到Unity实现,不少同学第一反应是打开Shader Graph拖节点。Shader Graph好处是可视化,美术同事可以直接调参,节点图上拖一拖,在URP里创建Lit材质很快。如果你团队分工里,这个效果主要由TA负责、美术要频繁试渐变和颜色,用Shader Graph更高效。
但我个人在这个需求里,选择的是手写HLSL. 原因不是Shader Graph不能用,而是消融效果牵扯到的细节挺多:怎么处理边缘采样方向、怎么避免在阴影通道里产生错误遮挡、怎么加入MaterialPropertyBlock控制多个物体的随机性,这些在Shader Graph里拖起来反而不直观。虽然Shader Graph也有Custom Function节点,可以插入代码,但最后还是绕不开写代码。
我的建议是:无论你用哪种方式,都要理解背后的算法。Shader Graph和手写的核心逻辑是一样的:采样噪声、设阈值、裁切像素、在阈值附近算边缘强度、输出颜色。这篇文章下面我用手写为例讲原理,如果你项目里用Shader Graph,完全可以用同样的节点关系复刻出来。
2. URP下从0写一个基础消融着色器
2.1 工程准备与Shader基本结构
先假设你已经建好了一个URP工程,用的是至少Unity 2021.3。如果项目还在用旧版内置管线,代码里的引用和宏会略有差异,不过逻辑一样,主要是把HLSLPROGRAM、Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl这些都替换成对应的内置库。
我的做法是创建一个Unlit风格的消融Shader,先不看光照,把“如何消失”这件事彻底跑通,再接基础色、发光色等参数。因为真实项目里如果要求不高,消融发生在极短时间,角色本身会叠加受击特效,没有光阴也很少有人注意到。如果你需要物体被光照影响,建议基于URP的Lit改,或者用Lit风格的Shader Graph再套一层Alpha Clip。
打开Unity Asset面板,右键Create -> Shader -> Unlit Shader,创建后先删掉自带模板,从空白写起。整个Shader我习惯按区块组织:Properties放材质参数,SubShader里放Pass主逻辑,最后用Fallback做好兜底。先放基础参数:
Properties { [Header(Base)] _BaseMap ("Base Texture", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1,1,1,1) [Header(Dissolve)] _NoiseTex ("Noise Texture", 2D) = "white" {} _Progress ("Progress", Range(0, 1)) = 0 }_NoiseTex那张图我建议直接让美术出,用Substance Designer做一版Perlin噪声/Flow Noise混合,或者你在Photoshop里生成一张云纹灰度图也行。注意三点:第一,最好是灰度图,不需要Alpha通道;第二,尺寸不用太大,256x256足够,除非镜头贴得很近;第三,内容不要有硬边,分辨率低一点反而更细腻。
2.2 加入噪声采样与裁切
Shader的主逻辑在Pass里,我需要先拿到模型UV,然后在Fragment阶段采样噪声,比较阈值并裁切。核心代码如下:
Shader "Custom/Dissolve" { Properties { _BaseMap ("Base Texture", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1,1,1,1) _NoiseTex ("Noise Texture", 2D) = "white" {} _Progress ("Progress", Range(0,1)) = 0 } SubShader { Tags { "Queue"="Geometry" "RenderType"="Opaque" } Cull Off ZWrite On Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_NoiseTex); SAMPLER(sampler_NoiseTex); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; float4 _NoiseTex_ST; float _Progress; CBUFFER_END Varyings vert (Attributes input) { Varyings output; output.positionHCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = TRANSFORM_TEX(input.uv, _BaseMap); return output; } half4 frag (Varyings input) : SV_Target { float noise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, input.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw).r; float threshold = lerp(-0.02, 1.02, _Progress); clip(noise - threshold); half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; return baseColor; } ENDHLSL } } }这里有几个细节我特别想强调。
第一,为什么阈值要写成lerp(-0.02, 1.02, _Progress),而不是直接用clip(noise - _Progress)?因为很多Perlin噪声贴图的暗部并不是严格等于0,而是接近0。如果进度刚为0时直接拿噪声和0比较,画面里所有接近0的暗部都会被裁剪掉,物体一出生就自带破洞。差评极其难看。用-0.02到1.02的映射,保证进度在0时所有像素都安全保留,进度到1时所有像素都必然消失,区间完整覆盖。
第二,Queue和RenderType我特意写成了Geometry和Opaque。有同学看到“消融”可能会下意识改成Transparent,这是典型误区。我们并没有使用混合,而是直接丢弃像素。被裁掉的区域是不写入颜色的,所以本质上是OnePass的不透明渲染,不需要半透明排序。如果写了Transparent队列,性能反而变差,还会出现奇怪的遮挡问题。
2.3 材质参数怎么设,播放时要注意什么
创建材质后,把Noise Texture拖进去,可以先把Progress放在0看物体是不是完整显示。正常情况下,物体看起来和普通Unlit材质没有任何区别。手动把Progress拉到0.5,就能看到有部分区域被切开,随着进度接近1,残留部分越来越少,到1时完全消失。
这里有个实际操作经验:在Inspector里手动拉Progress做测试是第一步,但真正做表现时,这个值最好完全交给脚本,不要手动在细节面板里改。原因是动态着色很容易被反复改出不可复现的状态,美术在编辑器里调了一半切到Play模式,材质状态是混乱的,运行时计算结果可能出乎意料。我的习惯是,给初始材质默认值规定为Progress等于0,任何测试都先重置材质再跑,避免“为什么一进场景物体就缺了一块”这种乌龙。
另外一个容易踩的坑是噪声UV缩放。我上面代码的_NoiseTex_ST包含了Tiling和Offset,如果模型UV范围跨得很大,噪声图会在同一模型表面重复很多次。重复次数少,熔解区域大块大块地掉;重复次数多,像细小颗粒在飞散,更有“粒子化”感觉。建议根据模型大小来微调Noise Tiling,而不是直接用默认1:我记得做过一个2米高的角色,Tiling设成0.8视觉效果最像撕开,而小石头模型反而要设到2到4才会有碎裂感。
3. 让效果“好看”:边缘高亮与烧焦色
3.1 边缘高亮的数学原理
基础裁切做出来后,物体消失的瞬间边缘是硬生生的锯齿,说白了就是游戏里常说的“硬边”,看久了很廉价。因为我们只是在阈值处一刀切,多保留一个像素或少保留一个像素,会产生极剧烈的亮度变化,最后表现为像素级锯齿和闪烁。
为了让边缘过渡平滑,我引入了“边缘强度”的计算。思路是在裁切阈值附近设置一个过渡带宽_EdgeWidth,当当前像素的噪声值离阈值很近时,把这个像素标记为“边缘区域”,并输出一个高亮颜色;离阈值较远、已经深入物体内部的像素,则保持原本纹理颜色。
计算方式是这个:
float noise = ...; float threshold = lerp(-0.02, 1.02, _Progress); clip(noise - threshold); float edgeFactor = saturate((noise - threshold) / _EdgeWidth); edgeFactor = 1.0 - edgeFactor; // 离阈值越近越大edgeFactor在噪声值刚好等于阈值时是1,但它所在像素已经被Clip丢弃了,所以实际能看到的边缘是紧贴阈值内侧的一圈像素,它们的edgeFactor都接近1。当像素深入内部、噪声远大于阈值时,edgeFactor接近0。用这个值去混合颜色,就能制造出柔和的光晕带。
更重要的是,这条边缘带的位置不是固定的,而是跟着阈值实时移动。_Progress一变化,整圈高亮也会跟着从表面逐渐往网格深处爬,视觉上就像火焰从外往里吞。这就是动态着色带来的核心体验:消融过程是活的。
3.2 在一个着色器中同时输出高亮和烧焦
拿到edgeFactor以后,后面的一切都顺理成章了。先加几个参数:
_EdgeColor ("Edge Color", Color) = (1, 0.5, 0, 1) _EdgeWidth ("Edge Width", Range(0, 0.2)) = 0.05 _BurnColor ("Burn Color", Color) = (0.2, 0.05, 0, 1)Fragment里我这样输出颜色:
half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; float edgeFactor = 1.0 - saturate((noise - threshold) / _EdgeWidth); half3 color = baseColor.rgb; color = lerp(color, color * _BurnColor.rgb, edgeFactor * 0.8); color += _EdgeColor.rgb * edgeFactor;意思是:边缘附近先压暗烧焦,再加亮橙色高亮。边缘越近,烧焦越明显,同时高亮越亮。越靠内部越保持原样。这样消融的边缘既有高温发光的橙黄色,又紧贴着一圈暗红焦痕,观感立刻上一个档次。
关于颜色参数,我项目里通常默认_BurnColor是暗红褐色,但有些特殊怪物死亡我希望是紫色魔法瓦解,那我会把_EdgeColor直接调成紫色,烧焦部分调成深蓝紫,本质都是在边缘区域做色调偏移和发光输出。只要保留“同一边缘因子驱动两个不同颜色”的逻辑,换一种颜色就是一套新特效。
还要说一句,这里的发光是加法叠加在返回颜色上,并不一定代表真的被实时全局光照点亮。如果模型处于一个超暗场景里,这样的边缘颜色会显得突兀。此时可以给_EdgeColor加一个强度系数_EdgeIntensity,方便美术调节,或者接到Shader Graph的Emission通道。
3.3 如何把边缘做得更可控
边缘宽度_EdgeWidth这个参数对最终效果影响很大。设太大会让发光的范围覆盖接近一半物体,消融的时候像在融化而不是高温溶解。设太小又会出现一点一点闪烁。我实际用的区间一般是在0.02到0.08之间,具体根据噪声贴图的灰度分布来调,别太迷信一个固定值。
如果你希望边缘的颜色不是整圈均匀,而是有一种“随着噪声脉冲变化”的感觉,可以再引入时间变量。比如在算edgeFactor之前,把阈值加上一点_Time.y * _PulseSpeed做扰动,这样边缘会有闪烁感,某些像素高亮跳跃性更强,看起来像火焰跳动。但要注意这个扰动幅度不能太大,否则会把clip裁切边界弄乱,导致物体在还没完成消融时就有大片提前消失。
还有一点很有用:在计算噪声时,把UV乘上一个旋转矩阵或者加入第二张细节噪声,让边缘位置不是完全固定。两张噪声图叠乘后,大块噪声控制“何时消失”,小块细节噪声控制“边缘细节”,消融轮廓会显得更丰富。美术消耗其实不大,但看起来会比单张噪声高级不少。
4. 从Shader到游戏逻辑:脚本控制与多物件管理
4.1 写个简单的消融控制脚本
Shader准备好了,下一步肯定是让策划或者代码在游戏过程中控制消融进度。我用一个很简单的C#组件来演示思路,直接把材质上的_Progress从0线性推到1即可:
using System.Collections; using UnityEngine; public class DissolveEffect : MonoBehaviour { [SerializeField] private Renderer[] targetRenderers; [SerializeField] private float duration = 1f; [SerializeField] private AnimationCurve curve = AnimationCurve.EaseInOut(0,0,1,1); private MaterialPropertyBlock block; private float progress; public void Play() { if (block == null) { block = new MaterialPropertyBlock(); } StopAllCoroutines(); StartCoroutine(ProgressRoutine()); } private IEnumerator ProgressRoutine() { float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; progress = Mathf.Clamp01(elapsed / duration); progress = curve.Evaluate(progress); foreach (var r in targetRenderers) { if (r == null) continue; r.GetPropertyBlock(block); block.SetFloat("_Progress", progress); r.SetPropertyBlock(block); } yield return null; } } }这个组件里用了MaterialPropertyBlock,而不是直接给r.material赋值。原因是多个敌人可能共用同一个材质,如果直接改材质,所有敌人的消融进度会被同步控制,还会产生材质实例泄漏,非常不划算。用MaterialPropertyBlock,每个Renderer拥有自己独立的Shader属性覆盖,又不会复制材质。场景里哪怕有100个敌人同时消融,内存也扛得住。
我把进度处理成动画曲线AnimationCurve,是因为“匀速消融”并不总是最好看的。有些怪物理应先快后慢,先被撕开大半边,最后一点残渣飘一会儿。曲线自由度大,调起来也比直接改代码里的速度方便很多。
4.2 多物体同步与随机化播放
实际项目里,消融很少只作用在一个简单的Cube上。一个Boss全身可能有多个部件:外壳、内衬、武器、披风,它们分别挂在不同的Renderer上。如果每个Renderer使用不同材质或不同进度,要保证视觉上是“同一个整体”在溶解,就必须让所有部件的_Progress同步变化。
我处理这种多部件同步的方式,就是把所有需要同时消失的Renderer拖进上面targetRenderers数组里,统一执行同一个协程。但需要注意:如果不同部件用了不同的UV分布或不同贴图,单靠同步进度还不够。比如头部小、身体大,在相同阈值下,初始出现空洞的位置会不同,看起来头先没、身体后没,节奏不一致。
解决办法有两种。第一种是让所有部件共享同一套噪声图坐标方式,比如使用世界空间坐标作为噪声采样坐标,而不是各自模型的本地UV。这样不同部件的同一空间位置,噪声值一致,消融会在表面形成连续的整体撕裂线。第二种是每个部件单独设置一个“噪声采样偏移”参数,让各部件在同一条整体进度下出现细微差别,做一个“身体比头晚消失0.2秒”的先后效果,往往更真实,不会像模具翻模一样一模一样。
有时候策划还想要“尸体消失后留一点灰烬粒子”的效果。这个用Shader本身做不出来,需要配合ParticleSystem。我的做法是:在Progress超过约0.7以后,从Renderer的包围盒顶点随机生成粒子。别把逻辑混在Shader里,Shader管好视觉裁切,C#管好事件触发器,这样团队协作更清晰。
4.3 用MaterialPropertyBlock避免材质泄漏
在这里我要把一个最常见的错误单独拎出来说:有些同学在Start函数里写GetComponent<Renderer>().material.SetFloat("_Progress", ...)。第一次看没问题,但Unity文档里说明过,访问renderer.material会自动实例化一份新材质。你每次调用都可能产生一份内存拷贝,而且这份拷贝永远不会自动销毁。如果玩家在一局游戏里反复杀怪,每一次都会泄漏一份材质副本,游戏越玩越卡,内存占用越来越高,最终在手机上卡成PPT。
正确姿势就是我上面的脚本里那样,用MaterialPropertyBlock。它不会创建材质,而是为一个Renderer单独保存Shader属性覆盖。渲染时,Unity会把这个覆盖合并到材质属性里,但不会修改共享材质本体的_Progress。这样同一个材质可以被100个敌人共用,每个人通过自己的Block拥有独立的消融值。
还有个小点:如果你用了SkinnedMeshRenderer,这套逻辑同样适用,只要把targetRenderers声明成Renderer[],拖SkinnedMeshRenderer进去就行。MaterialPropertyBlock在URP下对SkinnedMeshRenderer也有效,不需要额外处理。
5. 常见问题大坑与性能调优
5.1 常见问题排查表
我把这几年遇到最多的消融效果问题整理成一个速查表,方便你对照排查:
| 现象 | 原因 | 处理建议 |
|---|---|---|
| 进度为0时物体已经有洞 | 噪声图暗部接近0,阈值起点没留余量 | 把阈值改成lerp(-0.02, 1.02, _Progress) |
| 进度到达1但画面还有零星亮点 | 噪声图存在接近1的高亮区域,阈值到1时依然能通过剪裁 | 将上限设到1.05以上,或者结束后关闭Renderer |
| 物体被裁掉的区域仍然遮挡后面物体 | 没有写入正确的Depth | 保持Pass里ZWrite On,RenderType不要随意改Transparent |
| 边缘发光出现明显锯齿 | 过渡带太窄或噪声图分辨率过低 | 调大_EdgeWidth,换512分辨率噪声 |
| 消融后阴影还是完整方块 | ShadowCaster Pass没有做同样的裁切 | 补ShadowCaster Pass,或在Shader Graph中开启Alpha Clip |
| 大批量角色消失时掉帧明显 | 每帧大量像素被Clip,GPU无法有效Early-Z | 做LOD,减少高级别网格,控制同屏数量 |
| 场景里多个敌人共用材质,一个播放全部跟着播放 | 直接改了renderer.material | 改用MaterialPropertyBlock,不要实例化材质 |
| URP打包后Shader变成粉色 | 漏了URP引用,或打包时没包含Shader变体 | 确保Shader在Always Included Shaders里,或放入Resources |
表格里最后一条打包问题很烦人。URP工程里如果Shader是动态创建的,在打包时可能被裁剪掉。最简单的办法是把Shader放到Project Settings里的Graphics Settings的Always Included Shaders列表。另一个更稳妥的方式是把它加入到某个材质里,只要那个材质被场景引用,Unity就会把依赖一起打进去。
5.2 阴影和渲染队列方面的大坑
阴影是最容易被忽略的细节。如果你的消融Shader只是把Fragment裁了,但ShadowCaster Pass没有做同样的操作,那么物体会出现“主体已经消失一半,但投在地面上的阴影还是完整一块”的穿帮。
在URP的手写Shader里,你需要额外增加一个ShadowCaster Pass,在里面同样采样噪声并且做相同的clip判断。稍微讲究一点的做法是把片元裁切逻辑抽离成一个函数,主Pass和ShadowCaster Pass共同调用,避免两处代码不一致。Shader Graph就简单很多,只要在材质设置里开启Alpha Clipping,并将Noise和Progress接进AlphaClipThreshold,URP会自动为ShadowCaster生成对应的裁切。
另外一个坑是渲染队列。我见过有人把消融Shader的Queue从Geometry改成Transparent,理由是“带裁切的材质不是透明吗”。结果一运行,物体和半透明特效、粒子系统混淆,出现各种谁先谁后的渲染错误。要知道,用clip裁掉的像素并不参与透明混合,它只是不写入结果,所以在渲染队列上它仍然属于不透明物体。把RenderType保持Opaque或TransparentCutout,把Queue保持Geometry,才是正确的。
5.3 一些性能与美术上的建议
最后说说性能。消融本身的Shader开销其实很小:一次噪声图采样、一次比较、一个边缘插值,几乎可以忽略不计。真正的性能问题来自两个地方。
第一是裁切带来的Overdraw问题。GPU对于不透明物体的渲染通常可以靠Early-Z跳过被遮挡的像素,但一旦写了clip,GPU在大多数移动端架构上就没法放心地提前剔除片元了,因为被裁掉的片元会改变结果。也就是说,一个本应被墙遮挡的NPC,只要正在消融,仍可能引发额外的片元计算。所以不建议对超大物体做持续很长的消融,更不建议一堆大型怪物同时在屏幕里消融。如果确实有这个需求,用LOD先切低模,让被裁切的三角形数尽量少。
第二是噪声贴图的采样频率。尽可能使用256或者512的灰度图,不要上来就是4K。因为噪声贴图是低频信息,高分辨率带来的细节增益几乎看不出来,只会拖低帧率。并且建议把噪声贴图的压缩格式设为RGB Compressed或R8,不需要RGBA,也不需要HDR。
根据我的项目经验,一套消融效果真正能拉开差距的往往不是Shader代码本身,而是细节:有没有为噪声图做Tiling匹配、有没有给阴影通道做同步、有没有把脚本和材质管理干净。先把基础Clip版本做出来,再一步步加边缘光、烧焦色、脚本控制,你就能在一个Unity项目里既拥有不错的视觉冲击力,又不用担心后期优化追债。尤其是MaterialPropertyBlock带来的多对象管理方案,强烈建议一开始就用上,别等写了几十份材质再回头改。