画面外のターゲットへの方向が画面端に表示されるアレ（Offscreen Indicator）を作る

# 完成デモ
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/965d0d14-0eb8-8d23-4a78-427b111ce591.gif)
# 作り方
## CanvasとUIを準備する
CanvasのRenderModeは`Screen Space - Overlay`前提です。
![img1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/a144292a-968b-1d53-4cb6-238e159751d4.png)
以下の画像を参考にUIを作成して、Canvasの子要素としてヒエラルキーに追加しておきます。
![img2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/92d4130b-daf8-934a-db4a-0be078b72442.png)
## スクリーン座標を求めるスクリプトを作成する
まず`Camera.WorldToScreenPoint()`でターゲットのワールド座標をスクリーン座標に変換して、UIをターゲット上に表示させます。

```C#:TargetIndicator.cs
using UnityEngine;

[RequireComponent(typeof(RectTransform))]
public class TargetIndicator : MonoBehaviour
{
    [SerializeField]
    private Transform target = default;

    private Camera mainCamera;
    private RectTransform rectTransform;

    private void Start() {
        mainCamera = Camera.main;
        rectTransform = GetComponent<RectTransform>();
    }

    private void LateUpdate() {
        var center = 0.5f * new Vector3(Screen.width, Screen.height);

        // （画面中心を原点(0,0)とした）ターゲットのスクリーン座標を求める
        var pos = mainCamera.WorldToScreenPoint(target.position) - center;

        rectTransform.anchoredPosition = pos;
    }
}
```
### カメラ後方にあるターゲットのUIの位置について
上のコードの段階では、意図しない場所にもUIが表示されることがあります。これは、カメラ後方にあるターゲットのUIも表示されていることが原因です。
![img3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/ad8b8094-6b5b-966d-8d5e-71f978d22278.png)
この画面をEditorのシーンビューで見てみたのが下の図です。カメラ後方の左下側にあるターゲットのワールド座標が、右上側（画面中心に対する点対称の位置）のスクリーン座標に変換されていることがわかります。
![img4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/9266ddf5-f7d6-f3b0-ad4f-8cb272b43983.png)
`Camera.WorldToScreenPoint()`の`z`値には**カメラからターゲットへの距離**が入っています。この値の正負を確認することで、ターゲットがカメラ後方にあるかどうかを判定できます。ここでは一旦、カメラ後方のターゲットのUIを画面外に移動するようにしておきましょう。

```C#
        var pos = mainCamera.WorldToScreenPoint(target.position) - center;
        // カメラ後方にあるターゲットのスクリーン座標は、画面外に移動する
        if (pos.z < 0f) {
            pos.x = Screen.width;
            pos.y = Screen.height;
        }
```
### UIとカメラの動きを合わせる
UIのスクリーン座標を計算する処理は、ターゲットやカメラの位置・向きを計算する処理**よりも後に**行わないと、ターゲットとUIの表示が（1フレーム分）ずれてしまいます。もしずれている場合は、`Edit > Project Settings > Script Execution Order`からスクリプトの実行順を制御する必要があります。
![img5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/07471804-0646-de65-f316-3a2ab0715b49.png)
## 画面外のUIの位置を画面端にする
画面内のUIの位置が正しく表示できるようになりました。次に、画面外のUIの位置を画面端に調整するための処理を追加していきます。
### カメラ後方にあるターゲットの方向を求める
カメラ後方にあるターゲットは、画面中心に対する点対称の位置のスクリーン座標になっていました。この値をもう一度点対称の位置に移動させれば、元のターゲットの方向がわかるようになります。

```C#
        var pos = mainCamera.WorldToScreenPoint(target.position) - center;
        // カメラ後方にあるターゲットのスクリーン座標は、画面中心に対する点対称の座標にする
        if (pos.z < 0f) {
            pos.x = -pos.x;
            pos.y = -pos.y;
        }
```
### 画面外のスクリーン座標の画面端の位置を求める
スクリーン座標を画面サイズの半分で割ると、以下の図のようなビューポート座標の値になります。
![viewport.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/c09b754f-9bf7-2db7-5074-07af450a0a71.png)
この値の`x`座標か`y`座標かどちらかの絶対値が1になるように計算すると、画面端のスクリーン座標を求めることができます。

```C#

        float d = Mathf.Max(
            Mathf.Abs(pos.x / center.x),
            Mathf.Abs(pos.y / center.y)
        );

        // ターゲットのスクリーン座標が画面外なら、画面端になるよう調整する
        bool isOffscreen = (pos.z < 0f || d > 1f);
        if (isOffscreen) {
            pos.x /= d;
            pos.y /= d;
        }
        rectTransform.anchoredPosition = pos;
```
## いろいろ調整する
画面外のターゲットのUIを画面端に表示できたので、ここから細かく調整してクオリティを上げていきます。
### カメラと水平なターゲットのUIの表示を補正する
カメラと水平な位置にあるターゲットのUIは、中央左端か中央右端の位置に偏ります。このターゲットがカメラ後方になった時には、スクリーン座標に補正をかけることでUIの動きをなめらかにすることができます。
またこの補正は、カメラ後方のターゲットのスクリーン座標が画面中心になった場合に、画面端の位置の計算で（ゼロ除算）エラーが発生する問題を回避する効果もあります。

```C#
        var pos = mainCamera.WorldToScreenPoint(target.position) - center;
        if (pos.z < 0f) {
            pos.x = -pos.x;
            pos.y = -pos.y;

            // カメラと水平なターゲットのスクリーン座標を補正する
            if (Mathf.Approximately(pos.y, 0f)) {
                pos.y = -center.y;
            }
        }
```
### 画面端の表示位置を調整する
画面端の表示位置をUIのサイズの半分だけ画面中心側に寄せて、画面端のUIが見切れないようにします。

```C#
        var halfSize = 0.5f * rectTransform.sizeDelta;
        float d = Mathf.Max(
            Mathf.Abs(pos.x / (center.x - halfSize.x)),
            Mathf.Abs(pos.y / (center.y - halfSize.y))
        );
```
### 矢印を表示する
UIに矢印を追加します。矢印の角度は`Mathf.Atan2()`にスクリーン座標を渡すだけで求めることができます。
![img9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/36e8d840-2a63-f794-ea0d-b255c96d849f.png)

```C#
    [SerializeField]
    private Image arrow = default;
```
```C#
        // ターゲットのスクリーン座標が画面外なら、ターゲットの方向を指す矢印を表示する
        arrow.enabled = isOffscreen;
        if (isOffscreen) {
            arrow.rectTransform.eulerAngles = new Vector3(
                0f, 0f,
                Mathf.Atan2(pos.y, pos.x) * Mathf.Rad2Deg
            );
        }
```
## CanvasScalerに対応する
最後にCanvasのスケールを調整しても表示が崩れないようにします。
Canvasのスケール値は、Canvasの`Transform.localScale`から取得できるので、この値を使って適切な座標系に変換して計算するようにします。
![img10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495108/fae36ce2-b0c8-7ce9-fb7e-c79709e1f6b8.png)

```C#
        // ルート（Canvas）のスケール値を取得する
        float canvasScale = transform.root.localScale.z;
```
```C#

        // UI座標系の値をスクリーン座標系の値に変換する
        var halfSize = 0.5f * canvasScale * rectTransform.sizeDelta;
```
```C#
        // スクリーン座標系の値をUI座標系の値に変換する
        rectTransform.anchoredPosition = pos / canvasScale;
```
# 完成スクリプト
```C#:TargetIndicator.cs
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RectTransform))]
public class TargetIndicator : MonoBehaviour
{
    [SerializeField]
    private Transform target = default;
    [SerializeField]
    private Image arrow = default;

    private Camera mainCamera;
    private RectTransform rectTransform;

    private void Start() {
        mainCamera = Camera.main;
        rectTransform = GetComponent<RectTransform>();
    }

    private void LateUpdate() {
        float canvasScale = transform.root.localScale.z;
        var center = 0.5f * new Vector3(Screen.width, Screen.height);

        var pos = mainCamera.WorldToScreenPoint(target.position) - center;
        if (pos.z < 0f) {
            pos.x = -pos.x;
            pos.y = -pos.y;

            if (Mathf.Approximately(pos.y, 0f)) {
                pos.y = -center.y;
            }
        }

        var halfSize = 0.5f * canvasScale * rectTransform.sizeDelta;
        float d = Mathf.Max(
            Mathf.Abs(pos.x / (center.x - halfSize.x)),
            Mathf.Abs(pos.y / (center.y - halfSize.y))
        );

        bool isOffscreen = (pos.z < 0f || d > 1f);
        if (isOffscreen) {
            pos.x /= d;
            pos.y /= d;
        }
        rectTransform.anchoredPosition = pos / canvasScale;

        arrow.enabled = isOffscreen;
        if (isOffscreen) {
            arrow.rectTransform.eulerAngles = new Vector3(
                0f, 0f,
                Mathf.Atan2(pos.y, pos.x) * Mathf.Rad2Deg
            );
        }
    }
}
```
# 参考記事
- [【Unity】【uGUI】Screenの座標とWorld（3D）座標の変換について](http://tsubakit1.hateblo.jp/entry/2016/03/01/020510)