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

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

## 接下来来介绍一下3中UI在UGUI下的实现方法
### 1.显示在画面的最上层，不会被其他游戏物体遮挡。这里就用锁定标志来举例
![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/0a63b4d9-915d-3995-af3d-7404c795d926.gif)

创建一个脚本AimMark.cs，装在实际AimMark的UI物体上
![コメント 2019-12-22 213838.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/e2a951c5-5ea5-9b5f-c362-6025bdfce9cb.png)
Unity提供了一个RectTransformUtility工具类，可以很方便的实现这个功能。
关键代码：

```csharp
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的完整代码

```csharp: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/53c0ad1e-5802-20b5-82b6-c7a6e4a2259e.gif)

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

 ![コメント 2019-12-22 220923.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/6d6ebd2f-2ab7-730c-7bdc-5c3d08b37026.png) ![コメント 2019-12-22 221258.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/30d5e061-603c-a8f1-360b-e955593862de.png)
**这里要注意的是，从Canvas的Z轴正方向看去，Canvas上显示的UI是反向的，也就是说需要让Canvas的Z轴正方向和相机的Z轴正方向一致，UI才是正的**

* 接下来就是写一个脚本，控制Canvas的Z轴正向始终保持和相机的Z轴正向一致，即Canvas的旋转值和Camera的旋转值保持一致。

关键代码：

```csharp
private void LateUpdate()
{
    // 令自身旋转值和相机的旋转值保持一致，使UI始终面向相机
    transform.rotation = MainCamera.transform.rotation;      
}
```

### 3. 和2一样会被物体遮挡，但UI在屏幕上的尺寸保持一致，不随距离而变化。
![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/4cf6cb16-da6c-7d99-0a1c-c47bb6e98226.gif)

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

原理图：
![コメント 2019-12-22 205629.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/816fe6d6-1ba0-b6c8-6a95-7968a63abceb.png)
根据三角形相似的定理，其中需要求的缩放比例 = l/L = d/D。其中d为选定的参考距离，D为实际UI距离相机的垂直距离。

具体方法如下
1. 先通过2中实现的UI，调整相机到UI的距离，取一个认为合适的UI大小，将此时相机和UI间的垂直距离作为一个参考距离，记为d。
![コメント 2019-12-22 231338.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/0a7afefa-cf56-1b63-d4b7-f94947a13e3b.png)
2. 在游戏运行中中取得D。因为UICanvas的旋转值和相机是一致的，所以UI到相机的垂直距离，就等于UI在相机坐标系内的Z值。要做的就是将UICanvas的世界坐标转换到相机坐标系下的局部坐标，然后取z值即可。Unity中也有非常方便在各种坐标系下转换的方法。

```csharp
// 计算出自身在相机坐标系内的局部坐标，此时局部坐标的Z值即为自身到相机的垂直距离
var posInCamera = MainCamera.transform.InverseTransformPoint(transform.position);
```

3。计算2中取得的z值和参考距离的比值，即为需要缩放的比例。

```csharp
// 使用当前垂直距离比上参考距离，即可得出需要缩放的比例
var rate = posInCamera.z / baseDistance;
```

4。最后将3中计算的出的缩放值乘以原本的缩放值，即可得到最终缩放值。
把1到4整合成一个计算最终缩放值的函数。

```csharp
// 根据Canvas相对于相机的垂直距离和参考距离的比，来计算出新的缩放比例
private Vector2 CalcScale()
{
    // 计算出自身在相机坐标系内的局部坐标，此时局部坐标的Z值即为自身到相机的垂直距离
    var posInCamera = MainCamera.transform.InverseTransformPoint(transform.position);

    // 使用当前垂直距离比上参考距离，即可得出需要缩放的比例
    var rate = posInCamera.z / baseDistance;

    // 用原本的缩放比例乘以需要缩放的比例，得到最终缩放比例
    return baseScale * rate;
}
```

Billboard.cs的完整代码

```csharp: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/20b257c7-aa42-a510-4046-bee22597aded.gif)
![コメント 2019-12-22 235521.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/9fce23ec-e9d9-e4b6-d3c6-c50fb3e5f70b.png)

但仅使用LateUpdate还不能保证一定脚本的执行在相机和物体后，因为相机运动脚本也可能会使用LateUpdate，例如本方案中相机使用了Cinemachine就是默认使用LateUpdate，并且还指定了其脚本执行顺序在默认之后。
![コメント 2019-12-22 235842.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/3c7c698a-d97c-13d6-7660-3dbcd6247ec2.png)

因此我们需要把AimMark和Billboard的执行顺序添加到CinemachineBrain的后面
![コメント 2019-12-22 235926.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/1c8f15cf-feb7-457a-75a8-858c8995d829.png)

保证UI的位置计算在物体和相机之后，无论如何运动也不会出现UI错位和滞后感了。
![5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265897/a945e5b8-4bd0-1230-616a-3b3c25168199.gif)

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