尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity悬停改层级全方案:Sprite、UGUI到3D的排序与状态恢复

Unity悬停改层级全方案:Sprite、UGUI到3D的排序与状态恢复 鼠标悬停改层级这个需求我最早是在做一款卡牌对战Demo时遇到的。手牌一排摆在屏幕底部鼠标划过去的时候不仅要放大、上移还必须让当前这张牌盖过相邻的手牌否则放大之后会被旁边的牌遮挡整个交互瞬间就废了。当时第一反应是改SpriteRenderer的SortingOrder结果跑起来发现UI部分又出了新问题——UGUI的Canvas层级和Sprite的排序是两套体系单纯改SortingOrder根本影响不到UI。这篇文章就把我从2D Sprite做到UGUI、再做到3D深度控制这一路的完整方案和踩坑记录整理出来适合正在做卡牌、道具栏、地图编辑器这类“悬停高亮”交互的Unity开发者参考。1. 悬停检测方案对比选错后面全是坑先解决“怎么知道鼠标已经悬停在物体上”这个前置问题。Unity里做悬停检测常见的有四条路子组件回调、物理射线、UGUI事件接口、以及EventSystem全局监听。它们各自适用的对象类型完全不同一开始选错后面整个架构都得跟着改。1.1 组件回调OnMouseEnter的局限Unity的MonoBehaviour里自带OnMouseEnter、OnMouseExit、OnMouseOver这三个消息回调用法非常直观private void OnMouseEnter() { // 鼠标进入物体时触发 Debug.Log(Enter); } private void OnMouseExit() { // 鼠标离开时触发 Debug.Log(Exit); }但凡是走过实际项目的基本都知道这套回调有几个硬伤。第一物体必须挂Collider而且这个Collider还要能被鼠标“碰到”——本质是Unity内部帮你做了相机到鼠标点的射线检测。第二它只对3D Collider和特定条件的2D Collider生效遇上UGUI的Image、Text这类UI元素完全不支持。第三回调依赖EventSystem的处理如果你的场景里没有Camera设置成“Main Camera”或者Collider所在的GameObject在非激活层上消息根本不会派发。我自己的体会是这个方案只适合纯3D的原型验证或者物体数量极少、交互逻辑非常简单的场景正经项目里不要拿它当主力方案。1.2 物理射线OverlapPoint的灵活度另一种常见方案是自己发射射线检测2D场景一般用Physics2D.OverlapPoint3D场景用Physics.Raycast。以2D为例每帧拿鼠标的世界坐标去扫一遍Vector2 mouseWorldPos Camera.main.ScreenToWorldPoint(Input.mousePosition); Collider2D hit Physics2D.OverlapPoint(mouseWorldPos);这种方式的优势是主动权完全在你手里。可以指定检测的LayerMask只让特定层的物体响应悬停可以控制检测半径留出一点“误触容错”也可以一次检测多个叠在一起的物体再统一做优先级处理。代价是需要自己管理“上一帧悬停的是谁”“当前这帧悬停的是谁”这两个状态处理不好就会出现悬停离开不生效、或者连续帧闪烁的问题。1.3 UGUI事件接口IPointerEnterHandler与IPointerExitHandler如果悬停目标是UGUI元素那最省心的就是实现IPointerEnterHandler和IPointerExitHandler接口配合EventSystem的StandaloneInputModule一起工作using UnityEngine; using UnityEngine.EventSystems; public class HoverLayerChanger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler { public void OnPointerEnter(PointerEventData eventData) { // 进入时置顶 } public void OnPointerExit(PointerEventData eventData) { // 离开时恢复 } }这套接口是UGUI的标准事件派发机制挂在Canvas下的任何UI元素上都能用。它不关心Collider不关心相机只要物体上有Graphic组件且Raycast Target是勾选状态事件就能正确触发。我在后面章节里讲的所有层级切换逻辑基本都是配合这套接口实现的。它最大的优点是跟UGUI的生命周期完全契合缺点则是只适用于UI不能用来检测场景里的Sprite或3D模型。1.4 我这边的选型逻辑过了几个项目之后我的判断是场景里只有纯UI直接用IPointerEnterHandler场景里只有Sprite比如2D卡牌摆放在世界坐标优先用Physics2D射线场景里UI和Sprite混杂比如手牌是Sprite但技能描述是UI弹窗老老实实做两套检测逻辑再统一汇总到一个“当前悬停物体”的管理器里。不要指望一套方案通吃所有场景Unity的渲染体系决定了这两类对象本来就不在同一套坐标系里。2. 2D Sprite的层级真相Sorting Layer和Sorting Order是两个维度对2D游戏来说Sprite的绘制顺序是由两个参数共同决定的Sorting Layer和Sorting Order。这两个概念经常被混在一起实际它们的职责完全不同。2.1 先分清“图层”和“序号”Sorting Layer可以理解成“大的分组”是把物体按类别区分开的宏观顺序。默认情况下所有Sprite都在Default这一层里你可以在Project Settings里新建层并调整它们的前后关系比如背景层在最低、角色层往上一档、特效层再往上。不同Sorting Layer之间的先后关系一旦确定看的是Layer的排列顺序而不是Order值。Sorting Order则是同一个Sorting Layer内部的具体序号。Order越大Sprite越靠后渲染也就显示在最前面。两个Sprite如果不在同一层哪怕A的Order是999、B的Layer排得更靠前最终显示上B还是会盖住A。记住这个结论跨层看Layer排序同层内看Order数值。2.2 悬停提升层级的核心代码知道了原理实现就很简单了。悬停时把SortingOrder调到当前场景中同层Sprite的最大值再加一个偏移量离开时恢复成原来的值using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class SpriteHoverLayer : MonoBehaviour { private SpriteRenderer spriteRenderer; private int originalSortingOrder; private void Awake() { spriteRenderer GetComponentSpriteRenderer(); originalSortingOrder spriteRenderer.sortingOrder; } public void HoverToTop() { // 这一步是补齐层级的关键 spriteRenderer.sortingOrder GetMaxSortingOrderInLayer() 1; } public void RestoreOriginalOrder() { spriteRenderer.sortingOrder originalSortingOrder; } private int GetMaxSortingOrderInLayer() { SpriteRenderer[] allRenderers FindObjectsOfTypeSpriteRenderer(); int max 0; foreach (var renderer in allRenderers) { if (!renderer.isActiveAndEnabled) continue; if (renderer.sortingLayerName spriteRenderer.sortingLayerName renderer.sortingOrder max) { max renderer.sortingOrder; } } return max; } }这段代码有两个容易忽略的细节。第一如果场景里同一层有多个Sprite定了很高的Order值而你只是粗暴地改成某个固定大数比如9999很可能一次悬停就把所有物体的相对顺序打乱之后任何悬停都失效了。所以我习惯每帧动态获取当前层最大值再加1而不是写死一个值。第二originalSortingOrder必须在Awake里缓存千万不能在悬停后才临时读——那时候读到的已经是改完的值恢复的时候就出错了。2.3 为什么不能直接改Sorting Layer有人在社区问既然悬停要置顶那把Sorting Layer改成最高的层不就行了这个方法表面可行但它破坏了Layer分组的意义。假如你的设计里“UI层”在最上、“角色层”在其下悬停一张卡牌时把它的Layer改成UI层那它渲染顺序上会和UI混在一起后续如果UI要额外弹提示、或者角色要播放受击特效排序全乱套。应尽量保持Sorting Layer不变只通过Order在组内调整优先级。这也符合代码设计的“单一职责”原则Layer管类别归属Order管内部顺序。2.4 阴影、描边等子物体的同步问题卡牌类游戏通常不会只挂一个SpriteRenderer卡牌底图下面往往还有投影Sprite、边框Sprite、甚至牌面信息Sprite。悬停改层级时如果只改了根物体的Order子物体没跟着变结果就是卡牌底图拿到最前面了阴影却还留在原来的层级里视觉上极其割裂。稳妥做法是递归处理悬停时让所有子物体的SpriteRenderer的相对Order差保持原样整体平移。public void HoverToTopWithChildren(int topOrder) { // 先记录当前根物体的Order算出差值然后每个子物体都加同一个差值 int offset topOrder - spriteRenderer.sortingOrder; SpriteRenderer[] all GetComponentsInChildrenSpriteRenderer(); foreach (var renderer in all) { renderer.sortingOrder offset; } }这个思路比把每个子物体都改到同一个大数要合理它保留了子物体之间的原相对顺序比如阴影在底图后面、高光在底图前面不会因为悬停被彻底打乱。3. UGUI场景下的层级调整SetSiblingIndex与Canvas嵌套到了UGUI这一层“层级”的概念又变了。UI元素的渲染顺序不是看SortingOrder——虽然UGUI底层也有sorting order的概念但大多数情况下由Hierarchy里的兄弟节点顺序决定后出现的兄弟节点绘制在上方。所以处理UI悬停置顶核心动作就是调整SiblingIndex。3.1 标准方案SetAsLastSibling代码最简单一行搞定public class UIHoverLayer : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler { private int originalSiblingIndex; public void OnPointerEnter(PointerEventData eventData) { // 进入时记录原index再置顶 originalSiblingIndex transform.GetSiblingIndex(); transform.SetAsLastSibling(); } public void OnPointerExit(PointerEventData eventData) { // 离开时恢复原index注意index可能已变化 transform.SetSiblingIndex(Mathf.Min(originalSiblingIndex, transform.parent.childCount - 1)); } }思路非常清晰进入时缓存原始SiblingIndex执行SetAsLastSibling离开时用SetSiblingIndex还原。这里有个容易踩的坑如果悬停期间父节点下新增或移除了其他子节点缓存的那个index可能已经越界或者指向了别的物体。所以恢复时要做一次Mathf.Clamp确保index在合法范围内。另外多物体同时悬停时两个物体都执行SetAsLastSibling后触发者会覆盖先触发者这在下文状态管理里会展开讲。3.2 Canvas渲染顺序是由Hierarchy整体决定的很多初学者以为Canvas里的渲染顺序只由Canvas的SortingOrder决定这是一个误解。同一Canvas内的元素完全按Hierarchy的兄弟顺序渲染多个Canvas之间才轮到Canvas的SortingOrder起作用。所以我调整SiblingIndex时只影响当前Canvas内的顺序不会跨Canvas改变整体前后关系。如果你的UI弹窗、提示框分布在不同的Canvas里需要额外处理的是Canvas组件的SortingOrder而不是元素本身的SiblingIndex。3.3 嵌套Canvas时SetSiblingIndex失效的问题这是我在实际项目里卡过很久的一个问题。在一个商品卡片对象里如果子物体上有独立的Canvas组件比如为了做特效独立控制透明度那么直接对卡片本身调用SetAsLastSibling往往发现层级没变化。原因是嵌套Canvas会生成独立的渲染节点当子Canvas存在于目标元素内部时Unity会以这个子Canvas为主体决定渲染顺序而不是其父节点。解决方案有两个一是把要排序的节点放在外层统一由外层Control排序二是用Canvas的sortingOrder做手动调整而非SetSiblingIndex。我自己更倾向于方案一因为内嵌Canvas通常是设计上的历史遗留可以从结构上规范化掉。3.4 UI卡牌悬停放大后的层级动效说回文章开头的卡牌场景。UI手牌的悬停通常不只是改层级还伴随缩放和位移。我的建议是先改层级再做动画。原因是UI的缩放动画一般用DOTween这类工具播放如果动画和层级修改同时进行在一瞬间原index已经被覆盖恢复的时候取到的值可能已经被SiblingIndex调整影响。实际流程应该是OnPointerEnter先缓存index、SetAsLastSibling再启动一个Scale到1.1的Tween动画OnPointerExit先执行恢复index的Tween等动画结束后再SetSiblingIndex还原。顺序反了或者合并执行就会出现“已还原但层级还飘在最前”的bug。4. 3D物体的深度处理与渲染顺序边界2D和UI都是直观的“前后排序”3D场景里情况稍有不同。你可能遇到的问题是鼠标悬停在一个3D模型上时想让它从其他物体中间“浮”出来置顶显示。但3D的渲染顺序不完全是你能改的物体属性决定的它还牵扯相机深度、深度缓冲Z-Buffer以及渲染队列。4.1 修改Z轴坐标最直接但不总是有效最简单粗暴的办法是悬停时把物体的Z坐标往相机方向拉近一段距离。只要这个距离足够大物体就会出现在原本它前方的物体之前。优点是生效快、逻辑直白。缺点是Z轴的改变会带来两个副作用一是Transform的position变化会连带动画、物理系统等联动二是如果物体原本就在一个很小的3D空间里前面还有地形、墙壁等固定遮挡物那你需要把Z轴拉得非常近才有效这时候镜头透视变形就非常明显了。4.2 渲染队列概念透明物体的排序顺序3D物体的渲染顺序还要考虑材质里的Render Queue。这个队列决定了同一相机下各物体的绘制先后。队列值小的先画队列值大的后画不透明物体的Renderer还受深度缓冲影响后面的物体会被前面已经写入深度的物体挡住。如果物体是半透明的比如卡牌的投影、光束特效它往往走Transparent队列。透明物体默认不写深度渲染顺序就变成严格按距离排序从后往前画。这时候光改Z轴未必有效因为透明物体排序依据的是模型到相机的距离而不是某个单独属性。更稳的方案是悬停时临时替换材质的RenderQueue值把它的渲染队列提升到所有透明物体之上。这个方案在URPUniversal Render Pipeline里同样奏效只是URP的渲染机制更讲究不建议在生产项目里频繁切换材质实例容易生成大量DrawCall。4.3 相机堆叠单独用一个Overlay相机渲染悬停物我后来做3D项目时发现与其绞尽脑汁改动物体的排序和深度不如单独设置一个Overlay相机只渲染被悬停的物体。具体做法是把要悬停的对象放到一个特定Layer上比如叫HoverLayer主相机不渲染这一层Overlay相机只渲染这一层且它的Depth比主相机高画面就会盖在上面。这个方案的好处是不改变物体在场景中的任何属性不需要改Z、不需要改材质、不需要改队列看着就像物体自动“穿越”到了最前面。缺点是需要管理好Layer的分配一个物体被悬停时要从原来的Layer切到HoverLayer离开时再切回去。我自己的实测感受是如果只是几个简单物体改Z轴足够如果涉及半透明材质或者是建筑透视类效果强烈推荐单体Overlay相机的方案稳定性高很多代码量反而少。5. 状态恢复、多物体冲突与生命周期安全悬停改层级的难点从来不在“改上去”而在“改回来”。项目里bug高发区基本全集中在状态恢复环节。下面把这些边界情况一条一条说清楚。5.1 多物体同时悬停时的优先级管理在比较复杂的界面里即便使用EventSystem也可能出现两个物体同时处于“高亮”状态的情况——因为鼠标移动速度很快时OnPointerExit还没触发新的OnPointerEnter已经进来了。如果两物体的逻辑都改了SiblingIndex最终置顶的是后一个但前一个也已经把原来的index改掉了。我处理这类冲突的做法是用一个静态管理器来保存“当前唯一悬停物体”。public class HoverLayerManager { private static MonoBehaviour currentHover; public static void RegisterHover(MonoBehaviour target) { if (currentHover ! null currentHover ! target) { // 让上一个悬停物体赶紧恢复原状 currentHover.SendMessage(RestoreOrder, SendMessageOptions.DontRequireReceiver); } currentHover target; } }由管理器负责调度的好处是同一时刻只存在一个“提升层级状态”的物体不会出现两个物体同时停留在置顶状态、鼠标一挪开互相踩线的局面。这个管理器对2D Sprite层级的全局Order最大值计算同样有用。5.2 物体被销毁或失活时别留下脏状态如果鼠标悬停在物体A上A的层级已经置顶但这时A被外部逻辑销毁了或者被SetActive(false)了那么它的OnPointerExit不一定执行。结果就是场景里可能残留一个“悬停状态标志位”等你下一次再创建设备时旧状态被误继承。稳妥做法是结合OnDisable或OnDestroy做清理private void OnDisable() { RestoreOriginalOrder(); }这样即使物体被禁用它的渲染顺序也会立刻回到原位不会污染场景状态。5.3 恢复index时被其他逻辑挤占UI元素的SiblingIndex不是稳定的。如果悬停期间父节点下插入了新UI比如一个飘字的伤害提示原本缓存的那个index可能已经指向了别的元素。所以在OnPointerExit节里我没有用原始index直接赋值而是先Mathf.Clamp到当前合法区间。如果发现Clamp后index和原值不一致说明期间有别的节点插队这时候按Clamp后的值恢复是更合理的策略——至少不会因为越界报异常。5.4 Raycast Target误触与穿透问题UGUI的IPointerEnterHandler是靠射线检测Graphic的Raycast Target来工作的。很多时候层级确实改了但悬停判定还是没触发多半是某个透明的Image把射线挡住了。排错套路是打开EventSystem的Debug窗口或者直接把Graphic的RaycastTarget临时全部关掉再观察。常见误区是只检查了目标物体的RaycastTarget忽略了上层覆盖的透明底板。这里的经验是做层级变换之前先把同级UI的射线阻隔关系理一遍否则检测链路会一直有问题。6. 实测中遇到的几个典型bug和解决记录把我在两个项目里实际踩过、修过的几个和自己总结的排查思路列出来给同路人参考。6.1 第一起2D卡牌悬停后阴影残留现象是卡牌置顶后底下的阴影残留了一块。排查发现我的阴影是用一个额外的SpriteRenderer做的位于卡牌根物体的子节点。我只改了根物体的SortingOrder阴影没跟着变于是阴影留在低层级、卡牌跑到了顶层看起来就像影子悬空。修复方法在2.4节里已经给了整体遍历子物体保持相对差值同步平移Order。6.2 第二起UGUI悬停置顶时把弹窗也顶到前面UI店铺界面里道具卡片悬停置顶了没想到连着弹窗一起被顶到最前。看了Hierarchy才发现弹窗竟然是道具卡片的子节点——历史遗留的结构问题。SetAsLastSibling是把整个子节点树都挪到末尾而弹窗在树的内部所以跟着卡片一起被顶上去。最终处理是从结构上拆掉这个父子关系把弹窗独立成Canvas下的一个兄弟节点用专门的CanvasSortingOrder控制弹窗优先级。代码改动不小但结构干净之后这类bug再也没出现。6.3 第三起3D透明物体的悬停深度效果不稳定场景里的技能效果是半透明的粒子材质用改Z轴方式做悬停穿透结果因为粒子的排序是距离驱动Z轴改了但粒子的排序顺序还是飘忽不定最后不稳定。最终按4.3的方案单独加了一个Overlay相机专门渲染悬停物一次性搞定。这个方案额外好处是悬停物还可以顺便加描边、发光等特效不影响主场景画面渲染压力也没增加多少。6.4 第四起鼠标快速划过时闪烁快速从一张卡划到另一张时卡牌层级会来回闪烁。根因是OnPointerEnter先提升了A鼠标随即划到BA的OnPointerExit执行恢复B的OnPointerEnter立刻提升B。理论上两帧内完成但实际A的恢复和B的提升都发生在同一帧视觉上会看到A先回原位再让B上来产生一波闪烁。解决办法是在同一帧内延迟到Update末尾统一处理“上一张恢复下一张提升”两条消息合并成一次操作。我在HoverLayerManager里就是这样调度的。这套“悬停改层级”的方案体系我前前后后迭代了三轮才稳定下来。从最早的OnMouseEnter加固定Order到后来按需选型射线、UGUI接口、Overlay相机核心心得就一句话层级不是单一变量永远是“检测方式 渲染顺序 状态恢复”三件事一起设计。如果你现在正被类似问题卡住先从自己的场景类型出发判断用哪套检测方案UI走事件接口、Sprite世界坐标走射线、3D半透明物体优先考虑相机层再按我上面的恢复逻辑把边界情况补上基本就不会再有“悬停置顶成功但恢复失败”“阴影残留”“UI被连带置顶”这类问题了。最后再多提一句写完代码之后务必拿快速来回滑动鼠标的场景测试几轮看有没有闪烁这个动作能帮你提前发现90%的层级状态管理问题。
返回列表