完成デモ
作り方
CanvasとUIを準備する
CanvasのRenderModeはScreen Space - Overlay
前提です。
以下の画像を参考にUIを作成して、Canvasの子要素としてヒエラルキーに追加しておきます。
スクリーン座標を求めるスクリプトを作成する
まずCamera.WorldToScreenPoint()
でターゲットのワールド座標をスクリーン座標に変換して、UIをターゲット上に表示させます。
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も表示されていることが原因です。
この画面をEditorのシーンビューで見てみたのが下の図です。カメラ後方の左下側にあるターゲットのワールド座標が、右上側(画面中心に対する点対称の位置)のスクリーン座標に変換されていることがわかります。
Camera.WorldToScreenPoint()
のz
値にはカメラからターゲットへの距離が入っています。この値の正負を確認することで、ターゲットがカメラ後方にあるかどうかを判定できます。ここでは一旦、カメラ後方のターゲットのUIを画面外に移動するようにしておきましょう。
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
からスクリプトの実行順を制御する必要があります。
画面外のUIの位置を画面端にする
画面内のUIの位置が正しく表示できるようになりました。次に、画面外のUIの位置を画面端に調整するための処理を追加していきます。
カメラ後方にあるターゲットの方向を求める
カメラ後方にあるターゲットは、画面中心に対する点対称の位置のスクリーン座標になっていました。この値をもう一度点対称の位置に移動させれば、元のターゲットの方向がわかるようになります。
var pos = mainCamera.WorldToScreenPoint(target.position) - center;
// カメラ後方にあるターゲットのスクリーン座標は、画面中心に対する点対称の座標にする
if (pos.z < 0f) {
pos.x = -pos.x;
pos.y = -pos.y;
}
画面外のスクリーン座標の画面端の位置を求める
スクリーン座標を画面サイズの半分で割ると、以下の図のようなビューポート座標の値になります。
この値のx
座標かy
座標かどちらかの絶対値が1になるように計算すると、画面端のスクリーン座標を求めることができます。
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の動きをなめらかにすることができます。
またこの補正は、カメラ後方のターゲットのスクリーン座標が画面中心になった場合に、画面端の位置の計算で(ゼロ除算)エラーが発生する問題を回避する効果もあります。
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が見切れないようにします。
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()
にスクリーン座標を渡すだけで求めることができます。
[SerializeField]
private Image arrow = default;
// ターゲットのスクリーン座標が画面外なら、ターゲットの方向を指す矢印を表示する
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
から取得できるので、この値を使って適切な座標系に変換して計算するようにします。
// ルート(Canvas)のスケール値を取得する
float canvasScale = transform.root.localScale.z;
// UI座標系の値をスクリーン座標系の値に変換する
var halfSize = 0.5f * canvasScale * rectTransform.sizeDelta;
// スクリーン座標系の値をUI座標系の値に変換する
rectTransform.anchoredPosition = pos / canvasScale;
完成スクリプト
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
);
}
}
}