LoginSignup
1
1

More than 3 years have passed since last update.

【Unity学习笔记】跟随物体移动的UI的三种实现方案

Posted at

游戏中跟随物体移动的的UI,大致可以分为以下三种

  1. 显示在画面的最上层,不会被其他游戏物体遮挡。这样的UI一般是属于系统的,例如HUD(抬头显示)中的锁定标志等。
  2. 参与游戏物体的远近关系,会被其他游戏物体所遮挡。这样的UI一般是属于跟随主的,可以称为Billboard,例如血条,名字等。
  3. 同2一样会被其他物体遮挡,但不同是的UI尺寸不会因为远近而变化,始终保持一致。

接下来来介绍一下3中UI在UGUI下的实现方法

1.显示在画面的最上层,不会被其他游戏物体遮挡。这里就用锁定标志来举例

1.gif

创建一个脚本AimMark.cs,装在实际AimMark的UI物体上
コメント 2019-12-22 213838.png
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的完整代码

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.参与游戏物体的远近关系,会被其他游戏物体所遮挡。这里用血条和名字作为例子

2.gif

  • 在跟随主物体下建立一个Canvas,RanderMode设置为WolrdSpace

コメント 2019-12-22 220923.png コメント 2019-12-22 221258.png
这里要注意的是,从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在屏幕上的尺寸保持一致,不随距离而变化。

3.gif

实现方法前半和2相同,需要增加的是根据UI距离相机的垂直距离,来缩放UI,实现UI在屏幕上显示的尺寸保持不变。

原理图:
コメント 2019-12-22 205629.png
根据三角形相似的定理,其中需要求的缩放比例 = l/L = d/D。其中d为选定的参考距离,D为实际UI距离相机的垂直距离。

具体方法如下
1. 先通过2中实现的UI,调整相机到UI的距离,取一个认为合适的UI大小,将此时相机和UI间的垂直距离作为一个参考距离,记为d。
コメント 2019-12-22 231338.png
2. 在游戏运行中中取得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的完整代码

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错位和滞后感,在快速运动时极为明显。
4.gif
コメント 2019-12-22 235521.png

但仅使用LateUpdate还不能保证一定脚本的执行在相机和物体后,因为相机运动脚本也可能会使用LateUpdate,例如本方案中相机使用了Cinemachine就是默认使用LateUpdate,并且还指定了其脚本执行顺序在默认之后。
コメント 2019-12-22 235842.png

因此我们需要把AimMark和Billboard的执行顺序添加到CinemachineBrain的后面
コメント 2019-12-22 235926.png

保证UI的位置计算在物体和相机之后,无论如何运动也不会出现UI错位和滞后感了。
5.gif

总结

  1. 有一个公共的Canvas,将跟随主的世界坐标转换成需要该Canvas下的RectTransform坐标系下的局部坐标赋值给需要显示的UI局部坐标。
  2. 在跟随主物体下建立一个Canvas,把UI放入其中。在游戏执行中令Canvas的旋转值和Camera的保持一致。
  3. 在2的基础上,选定一个参考距离,计算出Canvas在相机坐标系下的局部坐标取得当前距离,利用相似三角形定理计算出缩放值。
  4. 需要保证UI的位置和缩放计算在物体和相机的位置计算之后。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1