３Dゲームで壁抜けしないコリジョンを実装してみた

グレンジ  [Advent Calendar 2017](https://qiita.com/advent-calendar/2017/grenge "Advent Calendar 2017")　1８日目担当の タテイシ です。
株式会社グレンジで、クライアントエンジニアをやっています。
## はじめに
unityでゲーム開発をしているとコライダーを使ったコリジョンに満足できない場面が時々あります。
特に速度の速いキャラクターやオブジェクトを既存のコリジョンでは満足する結果を得られないことが多く、FixedUpdateの頻度も増やしたくない場合が多いため、今回は３Dゲームで壁など動かないようなコライダーに対して移動するキャラクターが抜けないような処理を書いてみた。

## 使ったメソッド
・球体衝突検出、当たったコライダーを配列で取得
Physics.OverlapSphere
https://docs.unity3d.com/ja/540/ScriptReference/Physics.OverlapSphere.html

・コライダー同士のめり込みから押し出し方向と、めり込んでいる長さを取得
Physics.ComputePenetration
https://docs.unity3d.com/ScriptReference/Physics.ComputePenetration.html

・球移動部分の衝突判定時に使用
Physics.SphereCast
https://docs.unity3d.com/ja/540/ScriptReference/Physics.OverlapSphere.html

## 処理の順番
①Physics.OverlapSphereを使い球の衝突判定をする。

```

//オブジェクトのスケールを掛けて衝突計算で使用するする半径を_colliderのサイズとあわせる
float simulationRadius = _collider.radius * _collider.transform.localScale.x;
//指定した半径の球に当たるコライダー
Collider[] colliders = Physics.OverlapSphere (_collider.transform.position, simulationRadius, CollisionLayerMask);

```

②衝突していたらめり込みから押し戻すために必要なベクトルと、押し戻す距離をPhysics.ComputePenetrationを使って取得

```

//押し出す方向と長さを取得
Vector3 pushBackVector;
float pushBackDistance;
bool isCollision = 
	Physics.ComputePenetration (
		_collider,
		_collider.transform.position,
		_collider.transform.rotation,
		targetCollider,
		targetCollider.transform.position,
		targetCollider.transform.rotation,
		out pushBackVector,
		out pushBackDistance
	);

```

③「①、②」で衝突していなければPhysics.SphereCastを使って移動部分の衝突判定

```

RaycastHit hitInfo;
//押し出し処理のあと同じ半径で衝突判定をすると貫通していたため、少し小さめの半径で判定(0.99f)
if (Physics.SphereCast (prevPosition, simulationRadius * 0.99f, moveDir, out hitInfo, moveDistance, CollisionLayerMask)) {

```

④衝突していたら、RaycastHitを使って衝突した座標まで球体を移動させる。
（壁を滑らせるような動作を入れたかったので、次回の移動方向を衝突した面に対して滑るような方向に変更）

```

resultMoveDistance = hitInfo.distance;

//当たった位置まで座標を動かす
Vector3 moveVector = moveDir * resultMoveDistance;
Vector3 pos = prevPosition + moveVector;
_collider.transform.position = pos;
//壁を滑らせるような動きをさせるため、次回移動方向を壁に沿った方向に変更
nextMoveDir = Vector3.ProjectOnPlane (moveDir, hitInfo.normal);

```

⑤上の①～④の処理をキャラの移動量の分進める。

## 書いてみたコード
```

public class CollisionSimulation : MonoBehaviour {
	//衝突判定用コリジョンマスク
	const int CollisionLayerMask = 1;

	/// <summary>
	/// 移動速度
	/// </summary>
	[SerializeField]
	float _speed = 5f;

	/// <summary>
	/// 移動方向
	/// </summary>
	[SerializeField]
	Vector3 _moveDir = Vector3.zero;

	/// <summary>
	/// 判定用コライダー
	/// </summary>
	[SerializeField]
	SphereCollider _collider;


	// Use this for initialization
	void Start () {

	}

	// Update is called once per frame
	void Update () {
		float moveDistance =  _speed * Time.deltaTime;
		_moveDir = _moveDir.normalized;
		MoveSimulation (_moveDir,moveDistance);
	}

	/// <summary>
	/// 移動時の衝突シミュレーションなるべく移動量分動かすように実装
	/// </summary>
	/// <param name="moveDir">ノーマライズ済み移動方向</param>
	/// <param name="moveDistance">移動距離</param>
	/// <param name="simulationMax">計算回数</param>
	void MoveSimulation (Vector3 moveDir, float moveDistance,int simulationMax = 10) {
		//移動量がなくなるかsimulationMax分計算した場合は終了
        for(int i = 0 ; i<simulationMax ;i++) {
			Vector3 nextMoveDir;
			float lastMoveDistance = MoveCollisionCalc (moveDir, moveDistance,out nextMoveDir);
			//移動していない場合、移動方向がない場合
			if (lastMoveDistance <= 0f||nextMoveDir.sqrMagnitude <= float.Epsilon) {
				return;
			} else {
				//移動ベクトルを設定
				moveDir = nextMoveDir;
				//移動分の距離を引く
				moveDistance = Mathf.Max (0f, moveDistance - lastMoveDistance);
			}
		}

	}

    /// <summary>
    /// 移動時の衝突計算
    /// </summary>
    /// <param name="moveDir">ノーマライズ済み移動方向</param>
    /// <param name="moveDistance">移動距離</param>
    /// <param name="nextMoveDir">ノーマライズ済み次回移動方向</param>
    /// <returns></returns>
	float MoveCollisionCalc (Vector3 moveDir, float moveDistance,out Vector3 nextMoveDir) {
		float resultMoveDistance = moveDistance;
		nextMoveDir = moveDir;
		Vector3 prevPosition = _collider.transform.position;
		//オブジェクトのスケールを掛けて衝突計算で使用するする半径を_colliderのサイズとあわせる
		float simulationRadius = _collider.radius * _collider.transform.localScale.x;
		//指定した半径の球に当たるコライダー
		Collider[] colliders = Physics.OverlapSphere (_collider.transform.position, simulationRadius, CollisionLayerMask);
		bool isCollisionSphere = false;
		if (colliders.Length > 0) {

			//押し出し処理で戻された距離
			float pushBackMoveDistance = 0f;
			Vector3 pushBackVectorAll = Vector3.zero;
			for (int i = 0; i < colliders.Length; i++) {
				Collider targetCollider = colliders [i];
				if (_collider == targetCollider)
					continue;
                
				//押し出す方向と長さを取得
				Vector3 pushBackVector;
				float pushBackDistance;
				bool isCollision = 
					Physics.ComputePenetration (
						_collider,
						_collider.transform.position,
						_collider.transform.rotation,
						targetCollider,
						targetCollider.transform.position,
						targetCollider.transform.rotation,
						out pushBackVector,
						out pushBackDistance
					);

				//めり込んでいた場合
				if (isCollision) {
					isCollisionSphere = true;
					//押し出した分を移動したことにしている
					_collider.transform.position += pushBackVector * pushBackDistance;
					pushBackMoveDistance += pushBackDistance;
				}
			}

			//押し出していた場合
			if(isCollisionSphere) {
				//押し出した量を返す
				resultMoveDistance = pushBackMoveDistance;
				//押し出しの移動分がない場合
				if (pushBackMoveDistance <= float.Epsilon) {
					isCollisionSphere = false;
				}
			}

		}

        //押し出し処理をしていない場合はSphereCastで球の移動分の衝突判定
		if(!isCollisionSphere){
			RaycastHit hitInfo;
            //押し出し処理のあと同じ半径で衝突判定をすると貫通していたため、少し小さめの半径で判定(0.99f)
            if (Physics.SphereCast (prevPosition, simulationRadius * 0.99f, moveDir, out hitInfo, moveDistance, CollisionLayerMask)) {
                resultMoveDistance = hitInfo.distance;
				//当たった位置まで座標を動かす
				Vector3 moveVector = moveDir * resultMoveDistance;
				Vector3 pos = prevPosition + moveVector;
				_collider.transform.position = pos;
				//壁を滑らせるような動きをさせるため、次回移動方向を壁に沿った方向に変更
				nextMoveDir = Vector3.ProjectOnPlane (moveDir, hitInfo.normal);

				//もともとの移動方向と逆向きに進もうとしている場合
				if (nextMoveDir.sqrMagnitude > float.Epsilon && Vector3.Dot (nextMoveDir, _moveDir) < 0f) {
					//次回移動方向にもともとの移動方向を設定
					nextMoveDir = _moveDir;
				} else {
					nextMoveDir = Vector3.Normalize (nextMoveDir);
				}
			} else {
				//衝突していない場合は通常の移動計算
				_collider.transform.position = prevPosition + moveDir * moveDistance;
			}
		}
		return resultMoveDistance;
	}
}

```
メッシュコライダーに当たっても問題なさそう…


## 実装してみて気が付いたこと
・「処理の順番」②の押し出し処理の箇所でPhysics.ComputePenetrationから取得したベクトル、距離をもとに押し出し処理をすると、「処理の順番」③のPhysics.SphereCastでの判定がうまくいかず、押し出し処理に「0.01」のオフセットなどを足すなど工夫が必要で、コライダーとめり込みをなくしてからPhysics.SphereCastをする必要があった。
・Physics.ComputePenetrationの引数にColliderを設定するが、GameObjectのスケールも見ているようなので、それにあわせてColliderを持っているGameObjectのスケールとColliderのサイズを掛けたサイズを、Physics.OverlapSphere、Physics.SphereCastの引数にも渡す必要がある。

## おわりに
Physics.ComputePenetrationがGameObjectのスケールを見ていると思っていなかったので、ちょっとバグにつまずきましたが、Physicsのメソッドを使うことで思ったより簡単に実装できました。
また、壁に当たった際の速度計算は、特にこだわった計算をしていないので、そこらへんはゲームに適したコードに改良してみてもいいかもしれません。