【Unity】衝突・通過した時間を正確に計算する関数

# 概要

以下の gif は、床にボールが衝突したら名前と時間をログに出力する検証動画です。

![gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307985/2ce210f4-f180-760f-51cf-e74da1f73525.gif)

青いボールが先に衝突していますが、赤いボールのログが先に表示されています。また、どちらのボールも同時刻に衝突した事になっています。
これは、OnCollisionEnter, OnTriggerEnter が距離に関係なく処理順で呼ばれているためです。

大抵の場合は問題ありませんが、レースゲームのゴール等がこれでは困ります。
そこで、衝突・通過した時間を正確に計算できる関数を作成しました。

修正点や改良案などがありましたら、コメントをいただけると助かります。

# コード

お手持ちの Vector3Util クラスに追加してご利用ください。

``` Vector3Util.cs
using System;
using UnityEngine;

namespace Utility
{
    public static class Vector3Util
    {
        public static Vector3 PerpendicularFootPoint(Vector3 a, Vector3 b, Vector3 p)
        {
            Vector3 ab = (b - a).normalized;
            float k = Vector3.Dot(p - a, ab);
            return a + k * ab;
        }
        public static float InverseLerp(Vector3 from, Vector3 to, Vector3 t)
            => InverseLerpUnclamped(from, to, PerpendicularFootPoint(from, to, t));
        public static float InverseLerpUnclamped(Vector3 from, Vector3 to, Vector3 t)
        {
            var fromDistance = (from - t).magnitude;
            if (fromDistance == 0f) return 0f;
            var toDistance = (to - t).magnitude;
            if (toDistance == 0f) return 1f;
            return fromDistance / (fromDistance + toDistance);
        }

        public static double CollisionTime(ContactPoint contact, Vector3 pos, Vector3 prevPos, Vector3 prevPrevPos, double? time = null, float? deltaTime = null)
        {
            deltaTime ??= Time.fixedDeltaTime;
            var virtualPos = prevPos - contact.impulse * deltaTime.Value;
            var contactOffset = contact.thisCollider.ClosestPoint(contact.point + pos - prevPrevPos) - pos;
            var beforePoint = prevPrevPos + contactOffset;
            var afterPoint = virtualPos + contactOffset;
            var deltaRatio = InverseLerp(afterPoint, beforePoint, contact.point);
            return (time ?? Time.fixedTimeAsDouble) - deltaTime.Value * deltaRatio;
        }
        public static double ClosestTime(Collider thisCollider, Collider otherCollider, Vector3 pos, Vector3 prevPrevPos, double? time = null, float? deltaTime = null)
        {
            var closestPoint = otherCollider.ClosestPoint(prevPrevPos);
            var contactOffset = thisCollider.ClosestPoint(closestPoint + pos - prevPrevPos) - pos;
            var beforePoint = prevPrevPos + contactOffset;
            var afterPoint = pos + contactOffset;
            var deltaRatio = InverseLerp(afterPoint, beforePoint, closestPoint);
            return (time ?? Time.fixedTimeAsDouble) - (deltaTime ?? Time.fixedDeltaTime) * deltaRatio;
        }
    }
}
```

# 使い方

OnCollisionEnter では```CollisionTime()```を使用します。
引数にはContactPoint, 現在の座標、1フレーム前と2フレーム前の座標を入れてください。
ContactPoint がよく分からなければ、とりあえず```collision.GetContact(0)```で大丈夫です。

OnTriggerEnter では```ClosestTime()```を使用します。
引数には自分と相手のコライダー、現在の座標、2フレーム前の座標を入れてください。

2フレーム前の座標が必要な理由は、接点が衝突・通過する前の座標を利用するためです。
OnCollisionEnter, OnTriggerEnter は衝突・通過した次のフレームで呼ばれるので、1フレーム前の座標だけでは足りません。

# 仕組み

通過直前から通過直後の長さに対して、通過地点から通過直後の長さの割合を計算します。
現在の時間から割合の分だけ時間を引けば、通過時の時間が求められます。
衝突時間を求める際は、通過直後の座標を予想して利用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307985/33187f34-5d8a-309e-78ea-4d14347c4e98.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307985/bebbbf63-b70e-f061-758c-9b9a349726b5.png)

# サンプル

検証に使ったコードを改良して、正確な衝突時間を表示するサンプルを作りました。

![gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307985/ca233f36-fd29-e7d7-fb14-8322031dd76a.gif)

0.000001m の差でも正確な時間が出せます。
OnCollisionEnter, OnTriggerEnter のどちらでも同じ値が出せます（要検証）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307985/526d55b8-d1b0-dd7b-14b7-eb4a3b78cb19.png)

## コード

``` C#
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class NewBehaviourScript : MonoBehaviour
{
    private static List<(string name, double time)> enterList = new();

    /// <summary>
    /// 値が変動し続けるので、キャッシュして使用
    /// </summary>
    private static double fixedTimeAsDouble;

    Rigidbody rb;
    Collider thisCollider;
    Vector3 prevPos, prevPrevPos;

    private void Start()
    {
        rb = GetComponent<Rigidbody>();
        thisCollider = GetComponent<Collider>();
    }

    private void FixedUpdate()
    {
        if (enterList.Count > 0)
        {
            foreach (var item in enterList.OrderBy(e => e.time)) Debug.Log($"{item.name} : {item.time}");
            enterList.Clear();
        }

        prevPrevPos = prevPos;
        prevPos = rb.position;
        fixedTimeAsDouble = Time.fixedTimeAsDouble;
    }

    private void OnCollisionEnter(Collision collision)
    {
        enterList.Add((name, Vector3Util.CollisionTime(collision.GetContact(0), rb.position, prevPos, prevPrevPos, fixedTimeAsDouble)));
    }

    private void OnTriggerEnter(Collider other)
    {
        enterList.Add((name, Vector3Util.ClosestTime(thisCollider, other, rb.position, prevPrevPos, fixedTimeAsDouble)));
    }
}
```

# 参考文献

https://sleepygamersmemo.blogspot.com/2019/03/perpendicular-foot-point.html