Unity物体高亮闪烁:MaterialPropertyBlock高效实现与性能优化
1. 项目概述与核心价值在游戏开发里给物体加个高亮闪烁效果这事儿听起来简单但真做起来新手和老手做出来的东西体验上能差出十万八千里。你可能见过那种生硬的、像霓虹灯故障一样的闪烁也可能见过《魔兽世界》里任务目标那种柔和、醒目又自然的呼吸式高亮。今天要聊的就是怎么在Unity里用5分钟左右的时间实现一个后者那种级别的、能直接用到项目里的物体闪烁高亮效果。这个需求太常见了玩家接了新任务你得把关键NPC或物品“标亮”解谜游戏里提示玩家下一个可互动的机关战斗游戏中标记出BOSS的弱点部位。核心就一点在不干扰玩家正常游戏视野的前提下清晰、优雅地引导其注意力。很多朋友的第一反应是去改材质球加个自发光或者换Shader这当然可以但不够灵活尤其是在需要动态控制、多个物体、不同闪烁频率和颜色的场景下用代码驱动才是王道。我这次分享的方案是一个即插即用的HighlightFlash脚本。它不依赖复杂的Shader编写核心是利用Unity内置的MaterialPropertyBlock来高效修改材质属性从而实现单个物体甚至单个MeshRenderer上特定材质的颜色闪烁。我会把完整代码贴出来并掰开揉碎了讲清楚每一行是干嘛的为什么这么写以及实际项目中容易踩的坑。无论你是刚接触Unity不久想找个能直接用的轮子还是已经有一定基础想深入了解性能优化的细节这篇文章都能给你带来实实在在的收获。2. 核心思路与方案选型为什么不用简单粗暴的Material直接改在动手写代码之前我们先得把路子选对。实现高亮市面上大概有几种常见思路我们来逐一分析看看优劣。2.1 常见方案对比与陷阱方案一直接创建并替换高亮材质这是最直觉的做法准备一个高亮版本的材质比如HighlightMat当需要闪烁时把物体Renderer上的材质替换成它。// 伪代码示例问题方案 public Material highlightMat; private Material originalMat; private Renderer rend; void Start() { rend GetComponentRenderer(); originalMat rend.material; // 注意这里 } void EnableHighlight() { rend.material highlightMat; // 这里直接替换了材质实例 }致命陷阱rend.material这个属性Unity会在你第一次获取时自动为你创建一个该材质的独立实例Instance。如果你有100个相同的敌人用了同一个材质球EnemyMat用这种方式你会瞬间创建出100个材质实例。在移动端或需要渲染大量单位的项目里这会急剧增加Draw Call和内存占用是性能杀手。而且替换材质本身也是一个开销较大的操作。方案二修改共享材质的属性那不用实例我直接改共享的材质球sharedMaterial的属性总行了吧public Color flashColor Color.yellow; private Material sharedMat; void Start() { sharedMat GetComponentRenderer().sharedMaterial; } void Update() { float lerp Mathf.PingPong(Time.time, 1.0f); sharedMat.SetColor(_Color, Color.Lerp(Color.white, flashColor, lerp)); }另一个大坑sharedMaterial是引用的项目资源中那个原始的材质球。你修改它的属性场景中所有使用这个材质球的物体都会跟着一起变想象一下你只想让一个敌人高亮结果屏幕上所有用这个材质的敌人一起开始“蹦迪”这显然不是我们想要的。方案三使用MaterialPropertyBlock本次选用方案MaterialPropertyBlock材质属性块是Unity提供的一个神器。它允许你为每个Renderer单独覆盖一组材质属性而不需要创建新的材质实例。它修改的是GPU渲染时使用的属性值不影响原始的材质资产。这完美解决了上述两个问题既实现了每个物体的独立控制又保持了材质的批处理优化Batch可能性性能开销极小。private Renderer rend; private MaterialPropertyBlock mpb; void Start() { rend GetComponentRenderer(); mpb new MaterialPropertyBlock(); rend.GetPropertyBlock(mpb); // 获取当前属性如果有 } void UpdateHighlight() { mpb.SetColor(_Color, targetColor); rend.SetPropertyBlock(mpb); // 应用属性块 }我们的闪烁效果就将基于这个方案来构建。核心动画逻辑则通过协程Coroutine来控制这样我们可以方便地管理闪烁的周期、缓动效果和启停。2.2 方案延伸与Shader的配合我们的脚本需要材质有一个可供调节的颜色属性。最简单的情况材质使用Unity的标准着色器Standard Shader或通用渲染管线URP的Lit Shader它们通常有_BaseColor或_Color属性。如果你的项目使用了自定义Shader你需要知道其中控制主颜色的属性名是什么例如_MainColor,_TintColor等并在脚本中对应修改。为了通用性我们的脚本将允许你自定义这个属性名。同时为了实现“外发光”式的高亮而不仅仅是改变物体本色更高级的做法是配合一个简单的边缘发光Shader。但考虑到“5分钟搞定”和普适性本文的核心脚本将专注于通过修改基础颜色来实现闪烁并在最后会简要提一下如何与自定义Shader结合实现更炫酷的效果。3. 完整代码拆解与逐行精讲下面就是HighlightFlash脚本的完整代码。我会把它分成几个逻辑块并详细解释每一部分的作用和设计考量。using UnityEngine; using System.Collections; [DisallowMultipleComponent] [RequireComponent(typeof(Renderer))] public class HighlightFlash : MonoBehaviour { [Header(闪烁核心参数)] [Tooltip(闪烁的目标颜色)] public Color flashColor Color.yellow; [Tooltip(一次闪烁周期的时长秒)] public float flashDuration 1.0f; [Tooltip(材质中用于控制颜色的属性名称。标准Shader通常是 _Color 或 _BaseColor)] public string colorPropertyName _Color; [Header(高级控制)] [Tooltip(是否在脚本启用时自动开始闪烁)] public bool flashOnEnable false; [Tooltip(闪烁的缓动类型)] public EaseType easeType EaseType.SineInOut; public enum EaseType { Linear, SineInOut, QuadInOut } // 私有变量 private Renderer targetRenderer; private MaterialPropertyBlock materialPropertyBlock; private Coroutine flashCoroutine; private Color originalColor; private int colorPropertyID; // 使用属性ID比字符串更高效 void Awake() { // 确保有Renderer组件[RequireComponent]已保证这里获取引用 targetRenderer GetComponentRenderer(); if (targetRenderer null) { Debug.LogError(HighlightFlash 需要附加在带有 Renderer 组件的物体上, this); enabled false; return; } // 初始化 MaterialPropertyBlock materialPropertyBlock new MaterialPropertyBlock(); // 获取当前渲染器上的属性块以保留可能已设置的其他属性 targetRenderer.GetPropertyBlock(materialPropertyBlock); // 将颜色属性名转换为Shader的整数ID提升运行时效率 colorPropertyID Shader.PropertyToID(colorPropertyName); // 记录物体原本的颜色 if (materialPropertyBlock.isEmpty || !materialPropertyBlock.HasProperty(colorPropertyID)) { // 如果属性块为空或没有目标属性则从材质的sharedMaterial获取默认值 originalColor targetRenderer.sharedMaterial.GetColor(colorPropertyID); } else { originalColor materialPropertyBlock.GetColor(colorPropertyID); } } void OnEnable() { if (flashOnEnable) { StartFlashing(); } } void OnDisable() { StopFlashing(); // 可选禁用时恢复原色 // ResetToOriginalColor(); } /// summary /// 开始闪烁效果 /// /summary public void StartFlashing() { // 如果已经有闪烁协程在运行先停止它 if (flashCoroutine ! null) { StopCoroutine(flashCoroutine); } // 启动新的闪烁协程 flashCoroutine StartCoroutine(FlashRoutine()); } /// summary /// 停止闪烁效果并恢复原始颜色 /// /summary public void StopFlashing() { if (flashCoroutine ! null) { StopCoroutine(flashCoroutine); flashCoroutine null; } ResetToOriginalColor(); } /// summary /// 立即将颜色重置为原始颜色 /// /summary public void ResetToOriginalColor() { materialPropertyBlock.SetColor(colorPropertyID, originalColor); targetRenderer.SetPropertyBlock(materialPropertyBlock); } /// summary /// 闪烁动画的核心协程 /// /summary private IEnumerator FlashRoutine() { // 计算半周期用于PingPong计算 float halfDuration flashDuration / 2f; float elapsedTime 0f; while (true) { // 计算当前周期内的归一化时间0 - 1 - 0 float pingPongValue Mathf.PingPong(elapsedTime, halfDuration * 2) / (halfDuration * 2); // 应用缓动函数 float easedT ApplyEase(pingPongValue, easeType); // 根据缓动后的值插值颜色 Color currentColor Color.Lerp(originalColor, flashColor, easedT); // 通过MaterialPropertyBlock设置颜色 materialPropertyBlock.SetColor(colorPropertyID, currentColor); targetRenderer.SetPropertyBlock(materialPropertyBlock); // 等待一帧并累加时间 elapsedTime Time.deltaTime; yield return null; } } /// summary /// 应用缓动函数 /// /summary private float ApplyEase(float t, EaseType type) { switch (type) { case EaseType.Linear: return t; case EaseType.SineInOut: // 平滑的Sine缓动效果类似呼吸灯 return Mathf.Sin(t * Mathf.PI) * 0.5f 0.5f; case EaseType.QuadInOut: // 二次缓动有轻微的加速和减速感 t * 2f; if (t 1f) return 0.5f * t * t; t - 1f; return -0.5f * (t * (t - 2f) - 1f); default: return t; } } // 在编辑器模式下可以实时预览颜色变化可选 #if UNITY_EDITOR void OnValidate() { // 确保属性名不为空 if (string.IsNullOrEmpty(colorPropertyName)) { colorPropertyName _Color; } // 如果脚本未运行但我们在编辑器修改了参数可以预览效果 if (!Application.isPlaying targetRenderer ! null) { // 注意这里为了预览会临时修改属性块进入运行模式后会重置 // 更安全的做法是只在特定条件下预览这里简化处理 } } #endif }3.1 关键代码段深度解析1. 属性定义与[Header]、[Tooltip]的运用[Header(闪烁核心参数)] [Tooltip(闪烁的目标颜色)] public Color flashColor Color.yellow;这里不仅仅是定义变量。[Header]在Unity Inspector中创建分组标题让参数排列更清晰。[Tooltip]是给策划和美术同事的福音鼠标悬停时显示提示避免了他们来问你“这个参数是干嘛的”。将colorPropertyName暴露出来提高了脚本的灵活性使其能适配各种Shader。2. 性能关键Shader.PropertyToIDcolorPropertyID Shader.PropertyToID(colorPropertyName);在Awake中我们将颜色属性名称字符串转换成一个整数ID。在Update或协程这种每帧执行的方法里使用mpb.SetColor(colorPropertyID, color)比mpb.SetColor(“_Color”, color)效率高得多。因为整数比较比字符串查找要快这是一个重要的性能优化习惯。3. 正确获取“原始颜色”if (materialPropertyBlock.isEmpty || !materialPropertyBlock.HasProperty(colorPropertyID)) { originalColor targetRenderer.sharedMaterial.GetColor(colorPropertyID); } else { originalColor materialPropertyBlock.GetColor(colorPropertyID); }这是脚本健壮性的核心。我们不能直接假设物体当前的颜色就是材质球上的默认颜色。因为其他脚本可能已经通过MaterialPropertyBlock修改过它。所以我们先检查当前的materialPropertyBlock是否为空或者是否包含目标属性。如果有就从中获取这才是物体当前实际显示的颜色如果没有才从共享材质sharedMaterial中获取默认值。这保证了无论物体之前处于什么状态我们都能正确记录并最终恢复其“原始”外观。4. 闪烁协程的逻辑精髓float pingPongValue Mathf.PingPong(elapsedTime, halfDuration * 2) / (halfDuration * 2);Mathf.PingPong(elapsedTime, length)会让一个值在0到length之间来回往复。假设flashDuration是2秒halfDuration是1秒那么PingPong的范围就是0到2。再除以总长度2我们就得到了一个在0到1之间循环的值pingPongValue。这个值从0增长到1再减少到0完美对应了颜色从originalColor渐变到flashColor再渐变回来的过程。5. 缓动函数Easing Function的加入直接使用pingPongValue进行Color.Lerp是线性变化效果比较机械。我们加入了ApplyEase函数提供了几种缓动选项。以SineInOut为例它的曲线是正弦波的一部分让颜色的变化在开始和结束时更平滑产生一种自然的“呼吸感”视觉上高级很多。这是区分业余和专业效果的细节之一。4. 实战应用从拖入场景到复杂控制现在我们有了代码来看看怎么把它用起来并解决一些实际项目中的问题。4.1 基础使用三步走创建脚本在Unity项目中创建一个C#脚本命名为HighlightFlash将上面的代码完整复制进去。挂载脚本将脚本拖拽到需要高亮的游戏物体上该物体必须有MeshRenderer或SkinnedMeshRenderer等渲染组件。Inspector面板会自动出现可配置的参数。配置与触发调整Flash Color选择你想要的提示颜色如醒目的黄色、红色或蓝色。调整Flash Duration控制闪烁快慢。如果希望物体一出现就闪烁勾选Flash On Enable。或者在其他脚本中通过GetComponentHighlightFlash().StartFlashing()和StopFlashing()来动态控制。4.2 处理复杂物体与多材质我们的基础脚本是针对物体上第一个材质的_Color属性进行修改。但一个模型可能有多个子网格SubMesh和多个材质。比如一个角色身体、衣服、武器可能是不同的材质。解决方案一扩展脚本支持多材质索引我们可以修改脚本增加一个materialIndex参数让用户指定修改第几个材质槽Material Slot的颜色。[Tooltip(要修改的材质索引从0开始。如果物体有多个材质请指定。)] public int materialIndex 0; // 在FlashRoutine中应用属性块时 targetRenderer.SetPropertyBlock(materialPropertyBlock, materialIndex);这样你可以精确控制角色是全身高亮还是只有武器高亮。解决方案二使用多个HighlightFlash组件一个更简单粗暴但有效的办法是为模型每个需要独立高亮的部分比如一个单独的武器子物体分别挂载HighlightFlash组件。这样控制粒度更细但组件数量会增多。4.3 与UI系统的联动提示很多时候高亮物体需要配合UI提示比如屏幕上出现一个箭头或图标。我们可以在HighlightFlash脚本中增加事件回调。// 在类定义中添加事件 public UnityEvent OnFlashStarted; public UnityEvent OnFlashStopped; // 在StartFlashing方法中触发 public void StartFlashing() { // ... 原有逻辑 OnFlashStarted?.Invoke(); } // 在StopFlashing方法中触发 public void StopFlashing() { // ... 原有逻辑 OnFlashStopped?.Invoke(); }这样设计师就可以在Inspector面板里将高亮物体的OnFlashStarted事件和UI动画的播放函数关联起来实现“物体一闪UI箭头也跟着出现”的协同效果。4.4 实现“受击高亮”Hit Flash变种受击高亮通常需要更快速、更强烈的反馈往往是一次性的白色或红色闪烁而不是持续的循环。我们可以基于现有脚本轻松扩展。/// summary /// 触发一次受击高亮 /// /summary /// param namehitColor受击颜色通常为白色或红色/param /// param namespeed闪烁速度倍率/param public void TriggerHitFlash(Color hitColor, float speed 2.0f) { if (flashCoroutine ! null) { StopCoroutine(flashCoroutine); } flashCoroutine StartCoroutine(HitFlashRoutine(hitColor, speed)); } private IEnumerator HitFlashRoutine(Color hitColor, float speed) { float duration flashDuration / speed; // 更快 float halfDur duration / 2f; float elapsedTime 0f; while (elapsedTime duration) // 只执行一个完整周期 { float t Mathf.PingPong(elapsedTime, halfDur * 2) / (halfDur * 2); Color currentColor Color.Lerp(originalColor, hitColor, t); materialPropertyBlock.SetColor(colorPropertyID, currentColor); targetRenderer.SetPropertyBlock(materialPropertyBlock); elapsedTime Time.deltaTime; yield return null; } // 结束后恢复原色 ResetToOriginalColor(); flashCoroutine null; }在角色受到攻击时调用TriggerHitFlash(Color.white, 3.0f)就能实现一个快速的白色闪烁给玩家清晰的受击反馈。5. 性能优化与高级技巧任何游戏功能在考虑效果之后必须考虑性能。我们的方案虽然已经使用了高效的MaterialPropertyBlock但在大规模应用时仍有优化空间。5.1 对象池与批量管理如果你的游戏中有大量需要频繁触发高亮的物体比如RTS游戏中选中的单位为每个物体都运行一个独立的协程和每帧的SetPropertyBlock调用虽然比创建材质实例好但协程调度本身也有开销。优化思路使用一个中心化的管理器。创建一个HighlightManager单例它用一个ListHighlightFlash或DictionaryRenderer, HighlightData来管理所有需要高亮的物体。在管理器的Update中统一计算颜色并应用SetPropertyBlock。这样可以将N个协程的更新合并为一次遍历和计算尤其当这些物体的闪烁参数颜色、周期相同时计算可以复用。// 简化版管理器核心思路 public class HighlightManager : MonoBehaviour { public static HighlightManager Instance; private ListHighlightFlash activeHighlights new ListHighlightFlash(); void Awake() { Instance this; } void Update() { float time Time.time; foreach (var hf in activeHighlights) { // 统一计算该物体的当前颜色 float t Mathf.PingPong(time * hf.speed, 1.0f); Color c Color.Lerp(hf.originalColor, hf.flashColor, t); // 应用颜色 hf.mpb.SetColor(hf.colorPropertyID, c); hf.renderer.SetPropertyBlock(hf.mpb); } } public void Register(HighlightFlash hf) { /*...*/ } public void Unregister(HighlightFlash hf) { /*...*/ } }然后修改HighlightFlash脚本在StartFlashing时向管理器注册自己在StopFlashing时注销自身的协程就可以去掉了。这对于移动端或VR项目尤其重要。5.2 与URP/HDRP的适配在通用渲染管线URP或高清渲染管线HDRP中标准材质的主颜色属性名可能不再是_Color而是_BaseColor。我们的脚本通过公开colorPropertyName已经解决了这个问题。你只需要在Inspector中将属性名改为_BaseColor即可。此外URP/HDRP提供了更强大的后处理Post Processing方案来实现全屏范围的高亮如Outline效果。我们的物体闪烁方案是局部的、基于材质的两者可以结合使用。例如用后处理勾勒物体轮廓同时用我们的脚本让物体表面颜色闪烁效果叠加引导性会更强。5.3 实现更炫酷的Shader驱动高亮如果想实现边缘发光、溶解等高亮就需要配合自定义Shader。我们的脚本框架依然适用只需稍作修改。编写一个高亮Shader这个Shader除了常规渲染还包含一个可调节的“高亮强度”_HighlightIntensity属性。修改脚本将脚本中控制的属性从_Color改为_HighlightIntensity颜色插值改为强度插值如从0到1。材质设置为需要高亮的物体创建一个材质实例是的这里需要实例化使用这个自定义Shader。因为Shader是自定义的实例化一个的开销是可接受的并且我们可以通过脚本控制一个浮点数参数性能依然很好。// 在自定义Shader中 Properties { _MainTex (Texture, 2D) white {} _HighlightIntensity (Highlight Intensity, Range(0, 1)) 0.0 _HighlightColor (Highlight Color, Color) (1,1,0,1) } // 在片元着色器中根据_HighlightIntensity混合_MainTex颜色和_HighlightColor // 在C#脚本中 materialPropertyBlock.SetFloat(_HighlightIntensity, currentIntensity);这种方式可以实现比单纯改颜色丰富得多的视觉效果。6. 常见问题排查与调试心得在实际使用中你可能会遇到一些“诡异”的情况。下面是我踩过的一些坑和解决方法。6.1 问题速查表问题现象可能原因解决方案物体没有任何变化1.colorPropertyName填写错误。2. 材质球使用的Shader没有该属性。3. 脚本没有获取到Renderer组件。1. 检查Inspector中属性名标准Shader试试_ColorURP试试_BaseColor。2. 在材质球面板查看其属性列表找到正确的颜色属性名。3. 确保物体上有MeshRenderer等组件并检查脚本Awake中的错误日志。闪烁一下后恢复但不再继续闪烁FlashRoutine协程可能因为异常或逻辑错误提前退出了。在FlashRoutine的while循环内加Debug.Log或检查是否有其他代码调用了StopFlashing。确保协程是无限循环或由外部控制停止。所有使用相同材质的物体一起闪烁错误地修改了sharedMaterial的属性而不是使用MaterialPropertyBlock。确保脚本中没有出现renderer.material或renderer.sharedMaterial.color xxx这样的代码。所有颜色设置必须通过MaterialPropertyBlock的SetColor和SetPropertyBlock完成。高亮颜色非常暗或不明显flashColor的亮度或饱和度太低或者物体原本的originalColor就很暗插值后效果不强。1. 选择更鲜艳、更亮的颜色作为flashColor如Color.yellow。2. 尝试使用Color.LerpUnclamped并给flashColor一个大于1的HDR值如果项目支持。3. 考虑改用修改_EmissionColor自发光属性并确保材质启用了自发光。在编辑器里预览正常打包后无效可能使用了#if UNITY_EDITOR下的调试代码或者Shader属性在打包时被优化掉了。1. 检查OnValidate等编辑器专用代码是否影响了运行时逻辑。2. 确保Shader中使用的属性名与脚本中完全一致并且Shader没有将该属性#ifdef掉。性能开销感觉很大1. 对大量物体每帧调用SetPropertyBlock。2. 每个物体都有一个独立的协程。1. 参考5.1 对象池与批量管理使用管理器统一更新。2. 考虑降低闪烁更新的频率比如每两帧更新一次yield return new WaitForSeconds(0.033f)对于快速闪烁人眼不易察觉。6.2 调试技巧可视化调试与Editor扩展在开发复杂交互时光看代码不够直观。我习惯给这种工具脚本加上简单的调试视图。在Scene视图绘制Gizmos可以在OnDrawGizmosSelected里当物体被选中时在其位置绘制一个线框球或图标表示它正在高亮状态。void OnDrawGizmosSelected() { if (flashCoroutine ! null) { Gizmos.color flashColor; Gizmos.DrawWireSphere(transform.position, 1.0f); } }创建自定义Editor脚本为HighlightFlash编写一个Editor类可以在Inspector面板增加一个测试按钮点击后直接在编辑器里播放闪烁效果而无需进入Play模式。这对于关卡设计师配置触发器非常友好。6.3 关于材质实例化的最后提醒虽然我们极力避免但有些特殊Shader或效果可能迫使你不得不实例化材质。如果必须这么做请务必做好管理在Awake或Start中实例化一次myMaterialInstance new Material(renderer.sharedMaterial);在OnDestroy中销毁它Destroy(myMaterialInstance);防止内存泄漏。明确知晓性能代价这会打断动态批处理增加内存和Draw Call。仅在对视觉效果有严格要求时使用。这个HighlightFlash脚本的妙处就在于它在90%的情况下都能在不实例化材质的前提下给你一个效果不错、性能优良的高亮方案。剩下的10%就需要你根据项目的具体美术需求和性能预算去权衡和升级了。希望这份详细的拆解能让你不仅“抄”到了代码更理解了背后的原理和工程化的思考方式。下次再遇到需要突出显示的游戏物体你就能自信地甩出这个五分钟搞定的方案了。