游戏中跟随物体移动的的UI,大致可以分为以下三种
- 显示在画面的最上层,不会被其他游戏物体遮挡。这样的UI一般是属于系统的,例如HUD(抬头显示)中的锁定标志等。
- 参与游戏物体的远近关系,会被其他游戏物体所遮挡。这样的UI一般是属于跟随主的,可以称为Billboard,例如血条,名字等。
- 同2一样会被其他物体遮挡,但不同是的UI尺寸不会因为远近而变化,始终保持一致。
接下来来介绍一下3中UI在UGUI下的实现方法
1.显示在画面的最上层,不会被其他游戏物体遮挡。这里就用锁定标志来举例
创建一个脚本AimMark.cs,装在实际AimMark的UI物体上
Unity提供了一个RectTransformUtility工具类,可以很方便的实现这个功能。
关键代码:
private void LateUpdate()
{
// 将Target的世界坐标转先转换到屏幕坐标,再将其转换到父RectTransform内的局部坐标
var screenPoint = RectTransformUtility.WorldToScreenPoint(MainCamera, Target.position);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentRectTransform, screenPoint, CanvasCamera, out var vector2))
{
transform.localPosition = vector2;
}
}
-
使用RectTransformUtility.WorldToScreenPoint把世界坐标转换到屏幕坐标,但这还不够,因为UI是隶属于Canvas下的RectTranform的,最终决定UI位置的其实是父RectTransform下的局部坐标。
-
使用RectTransformUtility.ScreenPointToLocalPointInRectangle,将屏幕坐标转换为指定的父RectTranform下的局部坐标。其中CanvasCamera是隶属于的Canvas的RanderCamera,如果Canvas的渲染模式是Overlay,则这个值应为null,否则位置计算会出错。
AimMark.cs的完整代码
using UnityEngine;
// 锁定标志的控制类
public class AimMark : MonoBehaviour
{
[SerializeField]
private Animator animator;
private Canvas ParentCanvas { get; set; } // 隶属于的Canvas
private Camera CanvasCamera { get; set; } // Canvas的渲染相机
private RectTransform ParentRectTransform { get; set; } // 父节点的RectTransform
private Transform Target { get; set; } // 锁定的目标
private Camera mainCamera; // 游戏画面的主相机
private Camera MainCamera
{
get
{
if (mainCamera == null)
{
mainCamera = Camera.main;
}
return mainCamera;
}
}
// 指定一个显示锁定标志的屏幕范围
private Rect ViewProtRect { get; } = new Rect(0f, 0f, 1f, 1f);
// 记录上一帧是否在范围内的状态,用于和当前状态对比得到转换的瞬间
private bool IsInScreen { get; set; }
public void Setup(Canvas rootCanvas, RectTransform rootRectTransform, Transform target)
{
ParentCanvas = rootCanvas;
ParentRectTransform = rootRectTransform;
CanvasCamera = ParentCanvas.worldCamera;
SetAimTarget(target);
}
public void SetAimTarget(Transform target)
{
Target = target;
IsInScreen = false;
}
public void PlayLockAnimation(bool isLock)
{
if (isLock)
animator.SetTrigger("Lock");
else
animator.SetTrigger("UnLock");
}
private void LateUpdate()
{
if(Target == null)
{
Destroy(gameObject);
return;
}
if (IsTargetInScreen())
{
if (!IsInScreen)
{
IsInScreen = true;
PlayLockAnimation(true);
}
// 将Target的世界坐标转先转换到屏幕坐标,再将其转换到父RectTransform内的局部坐标
var screenPoint = RectTransformUtility.WorldToScreenPoint(MainCamera, Target.position);
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentRectTransform, screenPoint, CanvasCamera, out var vector2))
{
transform.localPosition = vector2;
}
}
else
{
if (IsInScreen)
{
IsInScreen = false;
PlayLockAnimation(false);
}
}
}
// 判断锁定对象是否处于可视范围内
private bool IsTargetInScreen()
{
// 转换目标的世界坐标至屏幕坐标
var point = MainCamera.WorldToViewportPoint(Target.position);
// 屏幕坐标的z值处于相机的进截面和远截面之间,并且处于屏幕范围内,即表示锁定对象在可视范围内
var isInScreen = point.z > MainCamera.nearClipPlane && point.z < MainCamera.farClipPlane && ViewProtRect.Contains(point);
return isInScreen;
}
}
2.参与游戏物体的远近关系,会被其他游戏物体所遮挡。这里用血条和名字作为例子
- 在跟随主物体下建立一个Canvas,RanderMode设置为WolrdSpace
这里要注意的是,从Canvas的Z轴正方向看去,Canvas上显示的UI是反向的,也就是说需要让Canvas的Z轴正方向和相机的Z轴正方向一致,UI才是正的
- 接下来就是写一个脚本,控制Canvas的Z轴正向始终保持和相机的Z轴正向一致,即Canvas的旋转值和Camera的旋转值保持一致。
关键代码:
private void LateUpdate()
{
// 令自身旋转值和相机的旋转值保持一致,使UI始终面向相机
transform.rotation = MainCamera.transform.rotation;
}
3. 和2一样会被物体遮挡,但UI在屏幕上的尺寸保持一致,不随距离而变化。
实现方法前半和2相同,需要增加的是根据UI距离相机的垂直距离,来缩放UI,实现UI在屏幕上显示的尺寸保持不变。
原理图:
根据三角形相似的定理,其中需要求的缩放比例 = l/L = d/D。其中d为选定的参考距离,D为实际UI距离相机的垂直距离。
具体方法如下
- 先通过2中实现的UI,调整相机到UI的距离,取一个认为合适的UI大小,将此时相机和UI间的垂直距离作为一个参考距离,记为d。
- 在游戏运行中中取得D。因为UICanvas的旋转值和相机是一致的,所以UI到相机的垂直距离,就等于UI在相机坐标系内的Z值。要做的就是将UICanvas的世界坐标转换到相机坐标系下的局部坐标,然后取z值即可。Unity中也有非常方便在各种坐标系下转换的方法。
// 计算出自身在相机坐标系内的局部坐标,此时局部坐标的Z值即为自身到相机的垂直距离
var posInCamera = MainCamera.transform.InverseTransformPoint(transform.position);
3。计算2中取得的z值和参考距离的比值,即为需要缩放的比例。
// 使用当前垂直距离比上参考距离,即可得出需要缩放的比例
var rate = posInCamera.z / baseDistance;
4。最后将3中计算的出的缩放值乘以原本的缩放值,即可得到最终缩放值。
把1到4整合成一个计算最终缩放值的函数。
// 根据Canvas相对于相机的垂直距离和参考距离的比,来计算出新的缩放比例
private Vector2 CalcScale()
{
// 计算出自身在相机坐标系内的局部坐标,此时局部坐标的Z值即为自身到相机的垂直距离
var posInCamera = MainCamera.transform.InverseTransformPoint(transform.position);
// 使用当前垂直距离比上参考距离,即可得出需要缩放的比例
var rate = posInCamera.z / baseDistance;
// 用原本的缩放比例乘以需要缩放的比例,得到最终缩放比例
return baseScale * rate;
}
Billboard.cs的完整代码
using UnityEngine;
public class Billboard : MonoBehaviour
{
[SerializeField]
private bool isScaleSize; // 是否根据距离来缩放大小
[SerializeField]
private float baseDistance = 10f; // 给定距离相机的参考距离。在该距离下的UI大小是我们想要的
private Camera mainCamera; // 游戏主相机
private Camera MainCamera
{
get
{
if (mainCamera == null)
{
mainCamera = Camera.main;
}
return mainCamera;
}
}
private Vector2 baseScale; // 原本的缩放比例
private void Start()
{
baseScale = transform.localScale;
}
private void LateUpdate()
{
// 令自身旋转值和相机的旋转值保持一致,使UI始终面向相机
transform.rotation = MainCamera.transform.rotation;
if (isScaleSize)
{
var scale = CalcScale();
transform.localScale = new Vector3(scale.x, scale.y, 1);
}
}
// 根据Canvas相对于相机的垂直距离和参考距离的比,来计算出新的缩放比例
private Vector2 CalcScale()
{
// 计算出自身在相机坐标系内的局部坐标,此时局部坐标的Z值即为自身到相机的垂直距离
var posInCamera = MainCamera.transform.InverseTransformPoint(transform.position);
// 使用当前垂直距离比上参考距离,即可得出需要缩放的比例
var rate = posInCamera.z / baseDistance;
// 用原本的缩放比例乘以需要缩放的比例,得到最终缩放比例
return baseScale * rate;
}
}
4.注意事项
可以看到,上述的位置和缩放的操作均在LateUpdate中进行,其目的是为了保证UI的位置和缩放的计算在相机和物体的位置计算之后进行。如果UI的位置和缩放计算先于相机和物体,就会发生UI渲染在上一帧时物体的位置上而导致UI错位和滞后感,在快速运动时极为明显。
但仅使用LateUpdate还不能保证一定脚本的执行在相机和物体后,因为相机运动脚本也可能会使用LateUpdate,例如本方案中相机使用了Cinemachine就是默认使用LateUpdate,并且还指定了其脚本执行顺序在默认之后。
因此我们需要把AimMark和Billboard的执行顺序添加到CinemachineBrain的后面
保证UI的位置计算在物体和相机之后,无论如何运动也不会出现UI错位和滞后感了。
总结
- 有一个公共的Canvas,将跟随主的世界坐标转换成需要该Canvas下的RectTransform坐标系下的局部坐标赋值给需要显示的UI局部坐标。
- 在跟随主物体下建立一个Canvas,把UI放入其中。在游戏执行中令Canvas的旋转值和Camera的保持一致。
- 在2的基础上,选定一个参考距离,计算出Canvas在相机坐标系下的局部坐标取得当前距离,利用相似三角形定理计算出缩放值。
- 需要保证UI的位置和缩放计算在物体和相机的位置计算之后。