【Unity】放物線状にRaycastしたい!

導入

VRゲームをプレイしていると、テレポート先やジャンプ先を決めるときなどに、図のようなUIをよく見かけます。手から放物線のような曲線を放ち、それがヒットした位置に移動する、というものです。

VRでテレポート先を決めるUI

高い位置にある床面を選択することができたり、過度に遠すぎる地点は選択できないなどの特徴があり、実に素晴らしいUIだと思います。 しかし、弧を描くように判定を飛ばして、その衝突位置を取得する、という機能は、Unityには標準ではないように思います。 最も近いのは、直線的にRayを飛ばして、衝突地点の情報を得るPhysics.Raycast()ですよね。 そこで、放物線をサンプリングした点列をもとに、手前から順に短くRaycast()を繰り返すことで、放物線状にRaycast()を行ってみました。

曲線を線分で近似してRaycastを繰り返す
その記録とコードを紹介します。

実装

放物線状のRaycastを行うために、以下のCurveRaycasterクラスを作成しました。

using UnityEngine;

public class CurveRaycaster : MonoBehaviour
{
    //引数のVector3配列の手前から2点ずつ取り出し、Raycastを繰り返し、ヒットしたかどうかを返す関数
    //ヒットした場合、ヒット時の始点のindexをhitStartIndex、ヒット情報をhitで返す
    //ヒットしなかった場合、hitStartIndexに-1を入れ、hitには適当なものが入るので注意
    public static bool ContinuesRaycast(Vector3[] points, out int hitStartIndex, out RaycastHit hit)
    {
        for (int i = 0; i < points.Length - 1; i++)
        {
            Ray ray = new Ray(points[i], points[i + 1] - points[i]);

            if (Physics.Raycast(ray, out hit, Vector3.Distance(points[i], points[i+1])))
            {
                //hit位置までの線を1フレーム表示(エディタ上のみ)
#if UNITY_EDITOR
                Debug.DrawLine(points[i], hit.point, Color.red, Time.deltaTime);
#endif
                hitStartIndex = i;
                return true;
            }
            else
            {
                //2点間を結ぶ線を1フレーム表示(エディタ上のみ)
#if UNITY_EDITOR
                Debug.DrawLine(points[i], points[i+1], Color.red, Time.deltaTime);
#endif
            }
        }
        hitStartIndex = -1;
        //hitには適当なものを入れる(よくない?)
        hit = new RaycastHit();
        return false;
    }

    //初期位置と初速度と時刻から、放物線上の点を求める。
    public static Vector3 CalculateParabolaPosByTime(Vector3 initialPosition, Vector3 initialVelocity, float t)
    {
        Vector3 gravity = Physics.gravity;

        //t秒後の座標
        Vector3 pos_t = initialPosition + initialVelocity * t + (gravity * t * t) / 2.0f;

        return pos_t;
    }

    //初期位置、初期速度から求まる放物線上の、interval秒ごとの位置をnum個並べた座標列を返す
    public static Vector3[] CalculateParabolaPosArray(Vector3 initialPosition, Vector3 initialVelocity, int num, float interval)
    {
        Vector3[] posArray = new Vector3[num];
        for (int i = 0; i < num; i++)
        {
            posArray[i] = CalculateParabolaPosByTime(initialPosition, initialVelocity, i * interval);
        }
        return posArray;
    }
}

重要なのはContinuesRaycast()ですね。Vector3配列を引数で受け取り、「配列の頭から2点ずつ取り出し、その2点間でRaycastを行う」を繰り返し行います、 最初にhitした時点で、trueを返し、そのインデックスとhit情報をoutパラメータで返します。 今回は放物線を用いますが、任意の点列を入力にできます。

そして、放物線上の点を求めるCalculateParabolaPosByTime()と、その点列を取得するCalculateParabolaPosArray()を実装しました。

動作確認

今回は動作していることが分かりやすいように、図のような矢印状のオブジェクトを、ヒット地点に表示するようにしてみました。

使用したUIオブジェクト
動作確認用に作成したスクリプトは以下です。

using UnityEngine;

public class Test : MonoBehaviour
{
    [SerializeField] float initialSpeed;
    float interval = 0.05f;
    int num = 100;
    int lastHitStartIndex = -1;
    RaycastHit lastHit;

    //ヒット地点に表示したいUIオブジェクト
    [SerializeField] GameObject arrowUI;

    void Update()
    {
        //放物線の点列を生成
        Vector3[] points = CurveRaycaster.CalculateParabolaPosArray(transform.position, transform.forward * initialSpeed, num, interval);
        //生成した点列についてContinuesRaycast()
        if(CurveRaycaster.ContinuesRaycast(points, out lastHitStartIndex, out lastHit))
        {
            Debug.Log($"index:{lastHitStartIndex}, hit:{lastHit.collider.name}");

            //UIオブジェクトをアクティブにして、ヒット位置に移動させ、ヒット位置の法線方向を向かせる
            arrowUI.SetActive(true);
            arrowUI.transform.position = lastHit.point;
            arrowUI.transform.rotation = Quaternion.LookRotation(lastHit.normal);
        }
        else
        {
            //ヒットしてないときはUIを非アクティブに
            arrowUI.SetActive(false);
        }
    }
}

以下のように、正しく動作していますね。

動作確認

最後に

今回作成した内容では、
・LayerMaskを指定できない
・LineRendererではなくDebug.DrawRayを使っているので、Game内では線が見えない
という問題があるので、実際にゲームに実装する場合は、もう少し修正が必要ですね。 また、Spline機能をうまく使えたら良かったのですが、今回は考慮していません。 Spline側に、Vector3[]に変換する機能があるだろうと思うので、組み合わせることは可能だとは思いますが…

あまりこの内容を書いてる人がいなかったように思ったので執筆しました。
参考になれば幸いです。