ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity头顶血条UI实现:World Space Canvas、Billboard与性能优化教程

Unity头顶血条UI实现:World Space Canvas、Billboard与性能优化教程

Unity里做血条、做头顶名称,几乎是做角色类游戏绕不过去的一步。我刚接触这个需求的时候,第一反应就是“往角色头顶丢一个UI,让它跟着走”,结果真做起来才发现,这里面牵扯到Canvas模式、坐标体系、朝向计算、遮挡关系、性能开销一堆问题。我最早做过一个角色扮演Demo,里面同时有二十多个AI敌人,每个都需要显示血条和名字,最初用很天真的方式写,一上线测试就出现名字乱飘、血条忽大忽小、点击角色却点到血条上之类的毛病。这篇博文把我踩过的坑和最终沉淀下来的方案都写出来,适合刚入门Unity、想给角色加头顶UI的开发者参考,也适合已经被各种“屏幕坐标转换”教程绕晕的人换个思路。

1. 先弄清“头顶UI”的本质:为什么血条能浮在3D角色上方

1.1 两种常见做法的路线对比

做头顶UI,Unity社区里基本分成两派。一派是沿用常规UI思路,用Screen Space - Overlay模式,然后每一帧把角色的世界坐标通过Camera.WorldToScreenPoint转成屏幕坐标,再去设置UI元素位置。另一派是把Canvas切到World Space模式,直接把UI当成一个三维世界里的“面板”,放在角色头顶,让它跟着角色移动、朝向相机。

两条路我都试过,最终长期项目里全部换成了World Space。不是Overlay不能用,而是它坑太深。核心问题在于:Overlay Canvas渲染在最上层,跟世界里的模型、特效没有深度关系,你永远无法做到“墙挡住血条就隐藏”这种很自然的交互。而且只要相机FOV、分辨率、画布缩放比任一变化,屏幕坐标转换的结果就会变,还得单独处理出屏检测、UI边缘裁剪,代码量蹭蹭往上涨。

World Space的思路则是反过来的:血条本来就在世界坐标里,位置、缩放、被遮挡与否都跟其他3D物体一视同仁。你不需要“转换坐标”,只需要保证它正对相机。这背后牵涉到几个基础原理,下面拆开说。

1.2 世界空间UI的坐标与缩放原理

World Space Canvas跟普通UI最大区别在于,它的RectTransform在世界上是有真实尺寸的。默认一个Canvas的宽高如果是1600x900,那它在世界空间里就是1600个单位宽、900个单位高——这个尺寸大得离谱,所以必须改Scale。

我习惯的做法是把Canvas的Scale设成0.01,RectTransform宽度设成1920。这样原本用像素思考的UI布局,放到世界空间里就成了“1920 * 0.01 = 19.2个世界单位宽”。你摆子物体的时候,脑子里还能继续用像素坐标去想,比如一个120像素宽的血条底图,放入后就是1.2个世界单位宽,数值上完全直觉。

很多教程没解释这个缩放比例为什么是0.01,导致有人随便填了个0.1或者0.0001,最后血条要么巨大要么看不见。你可以反推:UGUI内部布局逻辑是按像素算的,世界空间Canvas只是加了一层缩放矩阵。0.01这个值只是让“像素->世界单位”的换算变成简单的除以100,方便手工校准。项目里如果基础单位不同,可以自己改,但建议全场统一,别一个角色一个缩放值。

1.3 朝向相机的Billboard数学逻辑

血条浮在头顶之后,如果你不处理朝向,它就会像一片纸板一样侧着甚至背对相机。所谓Billboard(公告板)技术,简单说就是让一个面永远正对相机。

Unity里最直白的写法是:

Vector3 dir = Camera.main.transform.position - transform.position; dir.y = 0f; // 只绕Y轴旋转,保持UI竖直 transform.rotation = Quaternion.LookRotation(dir);

为什么要把dir.y清零?如果直接用完整方向做LookRotation,当相机从头顶俯视时,UI会跟着向下倾斜,看起来像贴在地面上,阅读起来很别扭。去掉Y分量后,UI只会绕世界Y轴旋转,无论相机俯仰角度多大,血条始终保持竖直,只是朝向机位所在方向。

这里还有个细节:这类代码最好放在LateUpdate而不是Update里。因为相机的旋转和位置更新通常在Update阶段完成,如果你在Update里先读取了相机旧位置去做朝向,画面就会产生一帧的滞后,表现为血条轻微抖动。放在LateUpdate,能确保使用的是本帧最终的相机数据。

2. 最推荐的基础方案:一个角色一个WorldSpace画布

2.1 为什么不用ScreenSpace Overlay加坐标换算

我见过很多新手(包括当年的我)一头扎进Overlay方案,写出来大概是这样:先拿到角色的世界坐标,再用Camera.main.WorldToScreenPoint转成屏幕坐标,然后把UI的anchoredPosition设置成那个值。单看一个角色确实能跑,但是:

  • 你必须在屏幕坐标和Canvas坐标之间做二次换算,因为Overlay模式下Canvas的anchoredPosition基准取决于CanvasScaler设置,不是纯屏幕像素。
  • 角色转到镜头背后时,WorldToScreenPoint返回的点会跑到屏幕外面,还要自己写“是否在相机前方”的判断。
  • 任何UI元素只要动了位置,整个Canvas的脏矩形区域都要重新计算,多个怪物频繁移动会导致CPU开销上涨。

World Space方案里这些都天然消失。UI在世界里的位置就是角色的位置,不用转换。相机掉头了,血条自然转出视野,那就是正常的空间关系。

2.2 搭建步骤1:创建World Space Canvas并配置缩放

具体操作流程:

  1. 在场景里右键 -> UI -> Canvas,创建新Canvas。
  2. 把Canvas的Render Mode切到World Space。
  3. 把RectTransform的Width设为1920,Height可以设1080(高度一般用不到,因为血条总是横着放的)。
  4. 把Scale全部设成0.01。
  5. 设置Event Camera为主相机。这一步经常被忽略,如果Canvas里想放按钮或者需要接收事件,不指定EventCamera,UI交互就完全不生效。
  6. 把这个Canvas拖成预制体,运行时动态生成。

我给这套预制体起名叫HUDCanvas。注意不要把Canvas直接放在角色模型下面,除非你希望它跟着角色骨骼旋转——那样会出大问题。应该让Canvas作为世界空间里的独立物体存在,只是位置跟随角色头部。

2.3 搭建步骤2:添加UI元素并挂跟随脚本

Canvas建好之后,在它下面创建Slider(血条)或者TextMeshPro文本(名字)。调整UI相对位置时,记住你所在的坐标系是“像素等比例缩放”过的世界坐标:想在角色头顶上方2米处,就把Canvas的Y轴偏移设为200(因为2 / 0.01 = 200)。

然后给Canvas挂一个跟随目标转换的脚本,核心逻辑很简单:

using UnityEngine; public class HUDAnchor : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector3 offset = new Vector3(0f, 220f, 0f); // 这里的offset是“像素单位”,实际偏移 = offset * canvasScale(0.01) private Camera mainCamera; private void Start() { mainCamera = Camera.main; } private void LateUpdate() { if (target == null) { Destroy(gameObject); return; } // 换算成世界单位 Vector3 worldOffset = offset * 0.01f; transform.position = target.position + worldOffset; Vector3 dir = mainCamera.transform.position - transform.position; dir.y = 0f; if (dir.sqrMagnitude > 0.0001f) { transform.rotation = Quaternion.LookRotation(dir); } } }

这个脚本挂在Canvas根节点上,target引用角色模型头顶的一个空物体或者角色Transform本身。

2.4 血条跟随脚本与Billboard脚本的完整实现

上面那份代码其实把“跟随”和“朝向”一起做了,所以不需要额外Billboard脚本。但如果你的UI结构更复杂,比如Canvas下面有多层子物体,最好独立一个HUDLookAtCamera组件只负责旋转,另一个HUDFollowTarget组件只负责位置。职责分拆,后续调试方便。

我给一套更加工程化的完整版:

using UnityEngine; public class HUDLookAtCamera : MonoBehaviour { [SerializeField] private bool keepVertical = true; private Camera targetCamera; private void Start() { targetCamera = Camera.main; } private void LateUpdate() { if (targetCamera == null) return; Vector3 dir = targetCamera.transform.position - transform.position; if (keepVertical) { dir.y = 0f; if (dir.sqrMagnitude < 0.0001f) return; transform.rotation = Quaternion.LookRotation(dir); } else { transform.rotation = Quaternion.LookRotation(dir); } } }
using UnityEngine; public class HUDFollowTarget : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector3 offsetPixels = new Vector3(0f, 220f, 0f); [SerializeField] private float pixelToWorld = 0.01f; public void Attach(Transform newTarget, Vector3 newOffsetPixels) { target = newTarget; offsetPixels = newOffsetPixels; } private void LateUpdate() { if (target == null) { Destroy(gameObject); return; } transform.position = target.position + offsetPixels * pixelToWorld; } }

这里有一个很重要的思想:整个Canvas根节点移动,等于改变的是Canvas的世界坐标,不会让子节点UI的RectTransform产生脏标记。Canvas整体位置的改变不要求Unity重排所有子UI布局,性能开销比移动单个Image小得多。很多性能问题都能通过“动根不动子”来缓解。

3. 头顶名称的进阶玩法:遮挡裁剪、绕背隐藏与距离自适应

3.1 名称被遮挡时该不该显示:两套方案对比

基础功能做出来之后,真正提升体验的是细节。其中最容易被提上需求的是“名字应该被墙挡住”。做这个功能前先要明确:你要的到底是“物体挡住了UI,UI就不可见”,还是“UI不挡物体、但物理遮挡物太近时隐藏UI”。

纯Shader方案通常是在UI材质上开启深度写入(ZWrite On)或调整ZTest模式。UGUI默认材质为了渲染透明UI,ZWrite是关闭的,导致UI永远不会因为被物体挡到而消失。理论上你写一个支持深度测试的UI Shader,把TMP或Image替换上去,就能得到比较自然的遮挡效果。但实际操作时这个方案有点折磨人:UI Mask、CanvasGroup、TMP的动态合成都可能跟自定义Shader打架,调试起来费时间。

更工程化的做法是用射线检测做“可见性判断”。每隔一段时间检测相机到目标点之间有没有阻挡物,有则把CanvasGroup的Alpha设为0,没有则设为1。这个方案直观、可控,不碰Shader,绝大多数俯视角和第三人称项目够用。

public class HUDVisibilityByRaycast : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private LayerMask obstacleMask; [SerializeField] private float checkInterval = 0.1f; [SerializeField] private CanvasGroup canvasGroup; private float lastCheckTime; private void LateUpdate() { if (Time.time - lastCheckTime < checkInterval) return; lastCheckTime = Time.time; if (target == null) return; Vector3 origin = Camera.main.transform.position; Vector3 destination = target.position; Vector3 direction = destination - origin; float distance = direction.magnitude; bool visible = !Physics.Raycast(origin, direction, distance, obstacleMask); if (visible) { canvasGroup.alpha = 1f; } else { canvasGroup.alpha = 0f; } } }

为什么不每帧检测?一方面物理射线本身有开销,另一方面血条名字这种信息不需要精细到每帧;0.1秒的延迟人眼根本感觉不出来。比如某个项目里有40个敌人同时活动,每帧打40条射线也不是不能跑,但完全没有必要。

3.2 绕背隐藏:用一个点积判断解决

“敌人转身朝向你时显示名字,背对你时自动隐藏”是很多动作游戏和MMO的标配。这个判断用的不是复杂算法,就是向量点积。

原理很简单:相机有一个前方向量cameraForward,角色头顶到相机有一个方向向量toCamera。当角色面向相机时,这两个方向大致相同,点积接近1;背对相机时点积接近-1。设定一个阈值,比如大于0.1才显示名字,否则淡出。

using UnityEngine; public class HUDShowWhenFacing : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private CanvasGroup canvasGroup; [SerializeField] private float minDot = 0.2f; private Camera mainCamera; private void Start() { mainCamera = Camera.main; } private void LateUpdate() { if (target == null || canvasGroup == null) return; Vector3 toCamera = (mainCamera.transform.position - target.position).normalized; float dot = Vector3.Dot(mainCamera.transform.forward, toCamera); float alpha = Mathf.InverseLerp(minDot, minDot + 0.15f, dot); canvasGroup.alpha = alpha; } }

这段代码里我用了InverseLerp而不是简单的if判断,好处是名字不是在某一帧突然消失,而是有0.15的过渡区间,视觉上会柔和得多。这个区间值你可以调整,太小就硬切,太大就出现“还没完全转身名字就开始变淡”的情况。

3.3 距离自适应缩放与隐藏阈值

距离越远名字越“糊”,超过一定距离可以直接隐藏。这里有两种思路,一种是调节Alpha,一种是动态缩放Canvas大小。

调Alpha简单直接:

float distance = Vector3.Distance(mainCamera.transform.position, target.position); if (distance > 40f) { canvasGroup.alpha = 0f; } else if (distance > 25f) { canvasGroup.alpha = Mathf.InverseLerp(40f, 25f, distance); } else { canvasGroup.alpha = 1f; }

如果你追求的是“无论远近血条看起来尺寸大致相同”,那就得动态改Canvas的Scale。原理是根据当前距离和期望显示尺寸反推缩放值。但这里有个坑:不要直接写transform.localScale = Vector3.one * (distance * k),因为你是在覆盖之前设置的0.01基础缩放,会把整个UI的像素尺寸语义打乱。正确做法是维护一个“基础缩放值”,动态Scale是基础缩放乘上距离系数:

float targetScale = 0.01f * Mathf.Clamp(distance / 10f, 1f, 3f); transform.localScale = Vector3.one * targetScale;

我个人经验是:头顶血条这种信息,玩家其实更希望它保持相对稳定的屏幕尺寸,而不是远小近大;但这个名字需求并没有绝对标准,你应该先跟策划或者自己的体验目标对齐,再去选方案。

4. 让血条有“手感”:平滑掉血、延迟白条与颜色反馈

4.1 基础血条制作:Slider还是两张Image

血条本身在UGUI里可以用Slider,也可以用Image的fillAmount。我的建议是:除非你需要拖动滑块,否则不要用Slider。Slider自带一大坨子物体和状态逻辑,对纯粹展示用的血条来说是多余负担。

更推荐的做法是:Canvas下建一个Background Image(深色底图),再建一个Fill Image(血量颜色图)。把Fill Image的Image Type设为Filled,Fill Method选Horizontal,然后通过修改fillAmount控制血量比例。Filled模式利用UV裁剪,性能优秀,不需要额外脚本同步宽度。

还有更轻量的做法是用两张普通的Image做遮罩裁剪,但那个会在每帧修改RectTransform尺寸时触发Canvas布局计算。相比之下,fillAmount只改材质属性,性能好得多。

4.2 延迟血量条的经典设计与实现

如果你玩过一些ARPG,会发现掉血时有“两段条”:前半段是当前血量瞬间掉到目标值,后面还拖着一条白条或者黄条慢慢收缩。这个被叫做“幽灵血条”或“延迟血条”,设计目的是让玩家看清“刚才这一下到底扣了多少血”。

实现并不复杂,核心就是两个fillAmount,一个跟随得快,一个跟随得慢。但写的时候有几个细节容易错:

using UnityEngine; using UnityEngine.UI; public class HealthBar : MonoBehaviour { [SerializeField] private Image instantFill; [SerializeField] private Image delayedFill; [SerializeField] private float delayedSpeed = 1.2f; private float currentSharp; private float currentDelayed; public void SetHealth(float ratio) { // 血量上升时,白条不应该慢慢涨,应该立刻跟上 if (ratio > currentDelayed) { currentDelayed = ratio; } currentSharp = ratio; } private void Update() { // 血量下降时,白条每秒向当前值追一点 currentDelayed = Mathf.MoveTowards( currentDelayed, currentSharp, delayedSpeed * Time.deltaTime ); // 防止白条超过即时条 currentDelayed = Mathf.Max(currentDelayed, currentSharp); instantFill.fillAmount = currentSharp; delayedFill.fillAmount = currentDelayed; } }

这里用了MoveTowards而不是Lerp,原因很重要:Lerp是按比例插值,越接近目标变化越慢,容易出现白条永远差一点追不上血条的“拖尾”感;MoveTowards是匀速逼近,视觉上更利落。速度值delayedSpeed表示每秒最多变化多少比例的进度,0.5意味着白条需要2秒走完50%的血量差,你可以根据手感调。

如果想让白条从满血到空血这个过程在不同血量段有不同速度,可以再把速度乘上一个跟当前血量相关的系数,比如血量低时白条更慢,制造“濒死挣扎”的感觉。这块属于表现层细节,看项目需求自己掂量。

4.3 血量颜色动态变化与多段血条

血量颜色从绿到黄再到红,是另一个提升反馈感的手段。不要用if硬切,而是用Color.Lerp做渐变色过渡。参考实现:

public static Color GetHealthColor(float ratio) { if (ratio > 0.6f) { return Color.Lerp(Color.yellow, Color.green, (ratio - 0.6f) / 0.4f); } if (ratio > 0.25f) { return Color.Lerp(Color.red, Color.yellow, (ratio - 0.25f) / 0.35f); } return new Color(0.45f, 0f, 0f); }

这个函数返回的颜色范围是:大于60%血量时从黄色渐变到绿色,25%到60%时从红色渐变到黄色,低于25%直接是暗红色。实际用的时候,把fillAmount和颜色绑定更新即可。

至于多段血条,比如Boss战那种“一格一格”扣血的条,本质是在血条底图上叠一层间隔纹理,或者通过每段血条单独控制。我做过一个简化方案:每段用一个Image,循环设置每段的fillAmount为Mathf.Clamp01(剩余血量 - 段索引)。这样段与段之间的边界天然清晰,比用纹理遮罩更可控。

5. 血条数量一多就卡?性能优化与对象池方案

5.1 头顶UI卡顿的根源:Canvas重建与DrawCall

不少人的项目刚加上血条的时候没事,等场景里几十个角色同时存在,帧率立刻跳水。大部分情况下不是模型问题,而是UI系统开销失控。UGUI的每个Canvas维护自己的网格数据,任何UI元素的位置、尺寸、文本内容、颜色改变,都可能触发该Canvas的“脏标记”重建网格。

反过来,移动Canvas根节点本身不会触发子元素重建。这个特性前面提过,是优化头顶UI的第一抓手。所以怪物的血条在跟随角色移动时,应该移动Canvas的Transform,而不是逐个移动里面的Image或Text。如果你没有把Canvas挂在角色子节点而是单独跟随,这个天然就做到了。

5.2 单画布多子节点与合批策略

如果场景里怪物数量很多,比如20个以上,每个怪物一个Canvas意味着20个渲染队列,DrawCall自然往上涨。更合理的方式是复用一个大Canvas,所有怪物的血条都作为子物体挂进去,从视觉上看是多个“头顶UI”,但在渲染体系里它们处于同一个Canvas内。

Unity的UGUI合批要求“同一个Canvas内、相邻排序、共用材质和贴图”。血条底图、填充图如果用同一张小纹理,合批效果会很好。文本组件用TMP,合批条件略有不同,但整体思路一样。

单Canvas方案的代价是管理成本变高。你得自己维护“哪些血条属于哪个怪物”“怪物死了血条怎么回收”。这正好过渡到对象池。

5.3 不依赖UGUI的轻量血条方案

如果项目规模特别大,比如同屏几十上百个带血条的单位,UGUI这套再优化也吃力。轻量替代方案是直接用SpriteRenderer做血条:底图、填充图各是一张Sprite,填充图的填充比例用修改SpriteRenderer的Scale或者材质参数实现。SpriteRenderer的渲染路径比UGUI简单得多,也不存在Canvas重建的概念,GPU开销可控。

具体做法:将填充Sprite的锚点设在最左边,修改transform.localScale.x即可控制显示宽度。注意如果填充图本身有圆角或者渐变纹理,直接拉伸Scale会失真,所以得配合使用Shader里的裁剪变量,或者把图做成九宫格样式。这里是我踩过的一个坑点:拉伸Sprite来做填充会导致边缘变形,看起来极不专业。如果只是简单纯色块,无所谓;一旦有描边或圆角,还是老实做Shader裁剪。

5.4 对象池的核心实现思路

对象池对头顶UI来说是标配,因为角色死亡是高频事件,直接Destroy血条再Instantiate会产生GC尖峰。我通常这样组织:

  • 预创建一定数量的HUD预制体放进队列。
  • 角色出现时从队列取出一个,调用Attach将target指向角色,设置初始血量,把CanvasGroup的alpha恢复为1。
  • 角色死亡时,先把血条从角色解绑(把target置空),再把Canvas根节点禁用,重新归还队列。
  • 复用前必须重置所有状态:fillAmount、文本内容、颜色、位置、朝向、CanvasGroup的alpha和blocksRaycast。

用SetActive(false)还是CanvasGroup.alpha=0保存,我推荐回收时用SetActive,因为禁用整个Canvas后Unity不会对它做任何网格重建,CPU开销几乎为零。只是要注意:不要频繁切换SetActive,每帧切就是灾难,只在“出生/死亡”两个时刻切是最合适的。

6. 实测中那些容易踩的坑:排查链路与解决记录

6.1 UI排序完全混乱:SortingOrder与Layer的关系

症状:血条有时候显示在角色前面,有时候又跑到地板下面,甚至在粒子特效后面若隐若现。

排查过程:先看Canvas的SortingOrder,跟普通3D物体的渲染队列不是同一套体系。默认World Space Canvas的SortingOrder是0,而粒子、模型等按各自的SortingOrder和队列渲染。当UI穿插在粒子之间时,排序不稳定很正常。

解决:给头顶UI Canvas一个明确的SortingOrder,比如10,保证它高于场景里的普通特效,但低于全屏过场、对话框这类UI。如果同一个Canvas下有多个“小UI组”,可以通过子物体的顺序控制彼此遮挡。至少我实测下来,把SortingOrder固定而不是默认0,画面就稳定多了。

6.2 点击血条导致角色选中不了:射线穿透问题

症状:玩家点击角色身体没反应,点击血条却选中了角色——或者反过来,血条把角色点击完全挡住。

排查链路:

  1. 先检查每个UI Image上的RaycastTarget是否关闭。这个选项默认开着,只要开着,UGUI的GraphicRaycaster就会把它视为可点击目标。纯展示用途的血条名称,直接全部关闭RaycastTarget。
  2. 如果确认关掉了还有问题,检查CanvasGroup是否勾选了Blocks Raycasts。
  3. 最后查EventSystem里是否挂了多个GraphicRaycaster导致重复响应。

我的习惯是一开始建立HUD预制体的时候,就写一个编辑器工具把所有Image和Text的RaycastTarget统一设为false。避免人工逐个去关。

6.3 血条尺寸忽大忽小:Scale设置与距离换算

症状:角色走近时血条大得夸张,走远时又小到看不见,而且不是正常的透视变化,是那种“跳变”。

原因多半是你在多个地方改了Canvas的localScale,比如某个脚本用了transform.localScale = Vector3.one * (distance * 0.001f),把之前设定的0.01基缩放直接覆盖了。

排查时先把所有修改Canvas scale的脚本列出来,算出各自期望的scale值。正确的做法是确定一个“标准距离基准”:比如10米时,血条宽度应为200像素,那么Canvas的世界宽度为200 * 0.01 = 2个世界单位,canvasScale = 2 / 1920 ≈ 0.00104。 这个数值跟基础0.01差距就很大,所以一定要统一用基缩放乘距离系数,而不是直接赋值。

6.4 UI错位类问题:世界坐标转本地坐标的正确姿势

如果你确实在某些特殊功能里需要把世界坐标点转成UI的本地坐标,比如自定义头像跟随,一定要用RectTransformUtility.ScreenPointToLocalPointInRectangle,而不是自己手写“屏幕坐标减Canvas中心”。

RectTransform canvasRect = canvas.GetComponent<RectTransform>(); Vector2 screenPoint = camera.WorldToScreenPoint(worldPosition); Vector2 localPoint; RectTransformUtility.ScreenPointToLocalPointInRectangle( canvasRect, screenPoint, camera, out localPoint );

这个方法会同时考虑Canvas的缩放、相机的Viewport设置,比自己手算稳得多。而且记得传入的camera要跟Canvas的eventCamera一致,否则算出来的坐标会有偏移。我之前有个项目因为主相机和UI相机是两个相机,这里传错了相机,头像定位总是偏一个固定距离,找了半天才发现。

最后分享一个我觉得很值得做的细节:把血条锚点尽量绑到角色骨骼而不是Transform上。我在某角色扮演Demo里就把血条挂在角色的头部骨骼上,角色点头、摇头、弯腰时血条跟着自然摆动,比死死钉在头顶上生动很多,观感也真实许多。做绑定的时候别忘了骨骼Transform会在动画驱动下每一帧变化,跟随脚本用LateUpdate是必须的,否则你会在动画更新后拿到旧位置,出现跳动。如果后续你想做更复杂的同屏大量单位头顶UI,也可以考虑用GPU Instancing配合自定义Shader方案做全屏公告板,但那是另一个量级的工程了,多数项目做到对象池加单Canvas合批就已经很能打了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进