やりたいこと
ゲームで3D空間上に表示するUIで、下記条件を満たすUIを作りたかったので、試行錯誤しました。
仕様
- 特定の3Dオブジェクトに関するUIを、そのオブジェクトより必ず前面に表示されるようにしたい
- カメラとの距離・角度に関係なく常に同じサイズ・角度でUIを表示したい
つまりどういうことかというと、
こんな感じの、特定の3Dオブジェクトに追従するUIに使うのに必要でした。
問題点
仕様2を満たすだけなら、Camera.WorldToScreenPointを使えばすぐですが・・・
当然ですがCanvasに普通にUIを配置すると、こんな感じに表示類が必ず前面に出てしまうため、カメラワークやオブジェクトの配置によっては、
というような問題が出てしまいます。
※逆にこういうのが問題にならないゲームなら普通にWorldToScreenPointを使えばOKです。
↓みんな大好きテラシュールブログに詳しく使い方載ってます
http://tsubakit1.hateblo.jp/entry/2016/03/01/020510
#結果的にどうなるか
この記事で紹介する方法で、「指定したCollider群、またはRenderer群より必ず前面にオブジェクトが表示される」ようにできます。
前述の例で言うと、
こんな感じです。
「理屈はどうでもいいから早く使わせてくれ!」って人はこちら
https://github.com/fluncle/WorldUI
※Demoシーンを見れば大体の使い方がわかると思います
#対応の方針
・UIオブジェクトは、1つのまとまり単位でWorldSpaceのCanvasを作る
→ HPゲージ1つにCanvas1つ、照準1つにCanvas1つ、という具合
・設定するCollider/Rendererの表面に、かつカメラに対して水平にUIをオフセットする
位置関係としては、下図のような感じになるようにします。
#3D空間にCanvasオブジェクト配置
WorldSpaceのCanvasを用意。
#常にカメラと並行にする
よくあるビルボード表示。
オブジェクトが必ずカメラと同じ角度になるようにします。
using UnityEngine;
/// <summary>
/// 常にカメラの方を向くオブジェクト回転をカメラに固定
/// </summary>
public class Billboard : MonoBehaviour {
void LateUpdate() {
// 回転をカメラと同期させる
transform.rotation = Camera.main.transform.rotation;
}
}
#カメラとの距離を変えてもスクリーン上は等倍にする
距離が離れればその分大きくなり、近づけば小さくすることで、
スクリーン上の見た目の大きさを変えないようにするコンポーネントです。
/// <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
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"メソッドを使うことに注意。
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メソッド。
- GetClosestPointを使って、カメラに最も近い表面座標を取得
- CalcIntersectPointでカメラに最も近い表面座標の平面上座標を取得
- 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で表面を計算しているため、余計なオフセットが出やすい
このあたり、「こうすれば解決するぞ!」というのがあればぜひご教授ください!