[Unity]3D空間上に表示する距離・角度の影響を受けないUI

# やりたいこと

ゲームで3D空間上に表示するUIで、下記条件を満たすUIを作りたかったので、試行錯誤しました。

## 仕様
1. 特定の3Dオブジェクトに関するUIを、そのオブジェクトより必ず前面に表示されるようにしたい
2. カメラとの距離・角度に関係なく常に同じサイズ・角度でUIを表示したい

つまりどういうことかというと、

![cursorview_demo3.gif](https://qiita-image-store.s3.amazonaws.com/0/184294/aed7589a-8663-ac63-c9fe-2f2301298874.gif)

こんな感じの、特定の3Dオブジェクトに追従するUIに使うのに必要でした。

## 問題点

仕様2を満たすだけなら、[Camera.WorldToScreenPoint](https://docs.unity3d.com/ja/540/ScriptReference/Camera.WorldToScreenPoint.html)を使えばすぐですが・・・

当然ですがCanvasに普通にUIを配置すると、こんな感じに表示類が必ず前面に出てしまうため、カメラワークやオブジェクトの配置によっては、

▼敵への照準なのにプレイヤーより前に出てしまう
![cursor_bad.png](https://qiita-image-store.s3.amazonaws.com/0/184294/71ad4545-80f9-1b8c-f9eb-c9a8cea14392.png)

▼壁の向こうにいる敵が事前に分かってしまう
![hp_gauge_bad.gif](https://qiita-image-store.s3.amazonaws.com/0/184294/ce7ddb5e-c33f-0e76-cc42-b4295f62cfaa.gif)

というような問題が出てしまいます。
※逆にこういうのが問題にならないゲームなら普通にWorldToScreenPointを使えばOKです。
↓みんな大好きテラシュールブログに詳しく使い方載ってます
http://tsubakit1.hateblo.jp/entry/2016/03/01/020510

#結果的にどうなるか

この記事で紹介する方法で、「指定したCollider群、またはRenderer群より必ず前面にオブジェクトが表示される」ようにできます。

前述の例で言うと、

▼プレイヤーが前にいるときはきちんと奥に表示
![cursor_good.png](https://qiita-image-store.s3.amazonaws.com/0/184294/5a6cc087-75ef-3da3-e03d-3a3c6ebf6043.png)

▼壁の向こう側の敵の情報は隠れる
![hp_gauge_good.gif](https://qiita-image-store.s3.amazonaws.com/0/184294/ce049f79-2dc7-6472-c25b-f95ca873cdf0.gif)

こんな感じです。

「理屈はどうでもいいから早く使わせてくれ！」って人はこちら
https://github.com/fluncle/WorldUI
※Demoシーンを見れば大体の使い方がわかると思います

#対応の方針

・UIオブジェクトは、1つのまとまり単位でWorldSpaceのCanvasを作る
　 → HPゲージ1つにCanvas1つ、照準1つにCanvas1つ、という具合
・設定するCollider/Rendererの表面に、かつカメラに対して水平にUIをオフセットする

位置関係としては、下図のような感じになるようにします。

![image.jpg](https://qiita-image-store.s3.amazonaws.com/0/184294/db7e8373-566d-36a0-c8e6-d4df0a801890.jpeg)


#3D空間にCanvasオブジェクト配置

WorldSpaceのCanvasを用意。

![canvas.jpg](https://qiita-image-store.s3.amazonaws.com/0/184294/5dfef1b8-7429-0059-30dc-62e620abd372.jpeg)



#常にカメラと並行にする

よくあるビルボード表示。
オブジェクトが必ずカメラと同じ角度になるようにします。

```csharp:Billboard.cs
using UnityEngine;

/// <summary>
/// 常にカメラの方を向くオブジェクト回転をカメラに固定
/// </summary>
public class Billboard : MonoBehaviour {
	void LateUpdate() {
		// 回転をカメラと同期させる
		transform.rotation = Camera.main.transform.rotation;
	}
}
```

#カメラとの距離を変えてもスクリーン上は等倍にする

距離が離れればその分大きくなり、近づけば小さくすることで、
スクリーン上の見た目の大きさを変えないようにするコンポーネントです。

```csharp:FixedScreenScaleObject.cs
/// <summary>
/// カメラからの距離を取得
/// </summary>
private float GetDistance() {
	return (transform.position - Camera.main.transform.position).magnitude;
}

private void LateUpdate() {
	transform.localScale = Vector3.one * _baseScale * GetDistance();
}
```

_baseScaleは基準スケールで、サンプルスクリプトではStartメソッド時のスケールを使って、カメラからの距離が1のときのスケール値を算出するようにしていますが、キャンバスに表示したときと同じような大きさに見えるように「画角によって一意にベーススケールが決まる」ようなロジックを作る必要がありそうです。
また、毎フレームmagnitude（距離）を取るのは負荷が高いので、sqrMgnitudeをそれっぽく使ったりなどの工夫が必要。
それぞれ、要改修。

#カメラと並行に表面位置までオフセットする

今回のキモの部分です。
この図の、「表面までオフセット」の処理を受け持ちます。

↓こちらの記事を参考にさせてもらいました。
[Unity] 任意の無限遠の平面とベクトルとの交点を求める
http://qiita.com/edo_m18/items/c8808f318f5abfa8af1e

```csharp:FrontBoundsObject.cs
private void LateUpdate () {
	if (_renderers == null && _colliders == null) {
		// オブジェクト表面として、レンダラーかコライダーが設定されていなければ何もしない
		return;
	}

	// 毎フレーム位置の補正をする前に、本来の位置（_baseLocalPosition）にリセットする
	transform.localPosition = _baseLocalPosition;

	UpdateClosestPoint();

	// 表面の形状やUI次第ではUIの一部がオブジェクトにめり込むことがあるため
	// 算出した表面から更に_marginDistance分オフセットする
	transform.position -= _cameraT.forward * _marginDistance;
}
```

まず、基本のUpdateの流れはこちらの通り。
表面の座標は"UpdateClosestPoint"メソッドで行っている。
その前に、_baseLocalPositionで毎回本来表示すべき位置に移動させている。
UIの表示位置を変えたい場合は、"SetPosition"か、"SetLocalPosition"メソッドを使うことに注意。
　

```csharp:FrontBoundsObject.cs
private void UpdateClosestPoint() {
	Vector3 mostClosestPoint;
	// 指定されているレンダラーorコライダーで、カメラに最も近い表面座標を取得
	if (_renderers != null && _renderers.Length > 0) {
		mostClosestPoint = GetClosestPoint(_renderers, _cameraT.position);
	} else {
		mostClosestPoint = GetClosestPoint(_colliders, _cameraT.position);
	}

	// UIからカメラへの方向
	Quaternion quaternion = Quaternion.LookRotation(transform.position - _cameraT.position);
	// UIの位置からまっすぐ進んで、カメラに最も近い表面座標の平面上にぶつかる位置を取得する
	Vector3 intersectPoint = CalcIntersectPoint(
		normal: quaternion * Vector3.back,
		planePosition: mostClosestPoint,
		startPosition: transform.position,
		startForward: quaternion * Vector3.forward
	);
	transform.position = intersectPoint;
}
```
UpdateClosestPointメソッド。
1. GetClosestPointを使って、カメラに最も近い表面座標を取得
2. CalcIntersectPointでカメラに最も近い表面座標の平面上座標を取得
3. UIのトランスフォームに座標を設定
という流れです。CalcIntersectPointの中身については、前述の
http://qiita.com/edo_m18/items/c8808f318f5abfa8af1e
を参考にしました。詳細はこちらをご覧ください！

GetClosestPointで表面座標を取る際、レンダラーやコライダーのBoundsを使っているため、
実際には厳密な表面座標が取れていません。
要改修。

#まとめ
・常にカメラと並行にする（Billboard.cs）
・カメラとの距離を変えてもスクリーン上は等倍にする（FixedScreenScaleObject.cs）
・カメラと並行に表面位置までオフセットする（FrontBoundsObject.cs）

この3つのコンポーネントで、「3D空間上に表示する距離・角度の影響を受けないUI」を実現しました。
実際には、ステンシルバッファやカメラを分けて描画順で解決できるように、ゲーム仕様で解決するほうが軽いかもしれないです。
が、いちいちカメラを分けたりシェーダーを書いたりするのが面倒くさい（例えばモック開発やハッカソンなど）では役に立つ・・・といいなと思ってます。

▼FixedScreenScaleObjectの課題
・画角によって一意にベーススケールが決まるロジックが必要
・magnitudeを使わない、負荷の低い方法で距離やスケールを計算するべき

▼ FrontBoundsObjectの課題
・凹凸が激しいモデルや大きなUIを扱うとモデルにめり込みやすい
・Boundsで表面を計算しているため、余計なオフセットが出やすい

このあたり、「こうすれば解決するぞ！」というのがあればぜひご教授ください！