のりかつおの備忘録ブログ

Unity, AI, ゲームに関心がある鰹です。 ぴちぴち跳ねて精進します。

【Unity】PhysicMaterialを使わずに反射する弾をつくってみた

1. はじめに

今回の記事では、PhysicMaterialを使わずに反射する弾の挙動をつくってみた内容を紹介します。

Unityでは既に用意されているPhysicMaterialを使うことで反射する弾を実装することができますが、PhysicMaterialの動きでは満足できなかったので自分でつくることにしました。

PhysicMaterialを使わずに反射挙動を実装することは一応できたものの、PhysicMaterialを完全に置き換えるような上位互換を実装することはできず、自分の欲しい挙動に合わせて限定的に実装した形になります。

今回 作成した反射する弾(以下、反射弾と呼びます)の仕様は以下のとおりです。

  • (1) コライダーのIsTriggerはTrueにして使う
  • (2) Rigidbodyの重力は使わない
  • (3) 衝突対象は静的なオブジェクトを対象とする
  • (4) X-Y軸で動かす想定の場合はRIgidbodyのConstrainstsのFreezePositionのZに☑する

(1), (2) ,(3), (4) について、どういうことか個別に説明します。

(1) コライダーのIsTriggerはTrueにして使う

一番の理由はまだ実装してないだけ…というのが本音ではありますが…。
今後の実装予定として、動体同士の衝突処理に対応できるようにしたいと考えていて、そのときにRigidbodyのCollisionDetectionのContinuousDynamicsモードが使いたいというのがちゃんとした理由です。

IsTriggerのオン/オフによってOnCollisionEnterとOnTriggerEnterの呼び出し判定が微妙に異なるので、実装を分ける必要があることも理由の一つです。
CollisionではDefaultContactOffsetだけ衝突判定が拡大されているので、ほぼ接触していればOnCollisionEnterが呼び出されるのに対して、Colliderではゼロ距離あるいは完全に埋まっている場合にOnTriggerEnterが呼ばれるので、そのあたりまだ手を付けられていないです。

さっさと取り組んで記事の続き書きます!

(2) Rigidbodyの重力は使わない

実装に取り掛かった当初は重力込みで進めていたのですが、毎フレーム速度が変化するというのが思った以上に扱いづらく、重力に対応するために他の場所でバグを相次いで生み出すという結果がループしたので一度保留にしています。

気が向いたらリベンジするかもしれませんが…、重力使いたいならPhysicMaterialを使えばいいじゃないの!?と感じているので、重力に関してPhysicMaterialの挙動で満足できない点が今後出てこなければ未対応でいいかなと思っています。

重力ありの場合にはPhysicMaterialでよいと考える理由については次章で取り上げるのでそちらを参照してください。

(3) 衝突対象は静的なオブジェクトを対象とする

動体同士による物理演算を実装していないのが理由です。
動体を扱えないということは「①反射弾同士の反射ができない」と「②反射弾でオブジェクトを吹っ飛ばすようなことできない」ことを意味します。

①に関しては、完全に扱えないわけではなく、一部の動作が不完全です。
1つ目の反射弾に対して2つ目の反射弾が横向きに衝突した際に、2つ目は正しく反射しますが1つ目は何事もなかったように直進していってしまうのが不完全な挙動です(動かしてみたら案外それっぽかった)。
この反射弾を複数使ったブロック崩しゲームのようなものが作りたいときは、Layerを設定して弾同士が衝突しないようして使ってください。

②に関しては、実装したい内容によっては致命的ですが…今後何とかなる予定です。

①、②に共通する点は、衝突先に力を加えられない問題です。
衝突対象にAddForceで力を加えてもいいのですが、物理に精通しているわけではないので自分で実装するよりUnityの物理挙動に倣ったほうがいいだろうと結論づけました。

ではどうするかですが…
TODOタスクとして、PhysicMaterialとの併用による反射弾の実装を掲げています。
PhysicMaterialには衝突後の物理挙動を任せ、私の反射弾には正しい反射だけ任せようという作戦です。
頑張って完成させて記事の続き書きます!

(4) X-Y軸で動かす想定の場合はRigidbodyのConstrainstsのFreezePositionのZに☑する

もしかしたら、今は☑しなくても問題なく動くようになってるかもしれません(検証不足)。 以前はX-Y軸(つまり二次元空間)で動かしていた際に計算過程なのか接触時のわずかなズレなのか断定できませんが、小数点以下の誤差が発生してしまい、いずれZ軸方向に飛んで行ってしまう不具合がありました。

2. PhysicMaterialの問題点

PhysicMaterialには、オブジェクトとオブジェクトの境目に衝突したときに反射方向が不完全になる不具合があるようです。
挙動の説明としてこちらのリンク(2d simple bounce issue )を挙げます。
私も現象としては同じことが発生してしまったため自力実装に取り組むことになりました。反射角が目で見ている想定する角度とは異なってしまう現象です。

根本的な原因はブラックボックスの中ですが、理由としてはBoxColliderの角の法線をもとに反射角を計算していることだと思われます。
弾が2つのBoxColliderに衝突した際に、片方では想定通りの法線を取得し、もう片方では角の法線(いわゆるSmoothNormalというやつでしょうか)を取得してしまい、2つの法線の合計から反射角を計算するためにおかしくなります。

逆に、厳密な反射挙動さえ要求されない用途であればPhysicMaterialで十分な気がします(実装コスト面を考えると)。 重力を使う場合は挙動が予測しにくくなるので(多少変な反射角でもわからない)、PhysicMaterialで十分だと思います。

3. スクリプト

とりあえず先に全スクリプトです。

■ 使い方
(1) このスクリプトSphereにアタッチします。
(2) エディタを実行して、スペースキーを押すと設定されているspeed値で動き始めます。(キーボードの"↑"と"↓"で射出角度を変更できます。詳しくはUpdateメソッドを参照。)

using System.Collections;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Norikatuo.ReboundShot {
    [RequireComponent(typeof(Rigidbody))]
    [RequireComponent(typeof(SphereCollider))]
    public class SelfReboundBehaviour : MonoBehaviour {
        [Tooltip("反発係数")]
        [Range(0, 1)]
        [SerializeField] private float bounciness = 1.0f;
        [Tooltip("弾の速度")]
        public float speed = 10.0f;
        [Tooltip("ONならDefaultContactOffsetの値を衝突検知に使用する")]
        [SerializeField] private bool useContactOffset = true;

        private Rigidbody rigidbody;
        private SphereCollider sphereCollider;
        private float defaultContactOffset;
        private const float sphereCastMargin = 0.01f; // SphereCast用のマージン
        private Vector3? reboundVelocity; // 反射速度
        private Vector3 lastDirection;
        private bool canKeepSpeed;

        private void Awake() {
            rigidbody = GetComponent<Rigidbody>();
            sphereCollider = GetComponent<SphereCollider>();
        }

        private void Start() {
            Init();
            StartCoroutine(StartWaitForFixedUpdate());
        }

        private void Init() {
            // isTrigger=false で使用する場合はContinuous Dynamicsに設定
            if (!sphereCollider.isTrigger && rigidbody.collisionDetectionMode != CollisionDetectionMode.ContinuousDynamic) {
                rigidbody.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
            }

            // 重力の使用は禁止
            if (rigidbody.useGravity) {
                rigidbody.useGravity = false;
            }

            defaultContactOffset = Physics.defaultContactOffset;
            canKeepSpeed = true;
        }

        private IEnumerator StartWaitForFixedUpdate() {
            while (true) {
                yield return new WaitForFixedUpdate();

                WaitForFixedUpdate();
            }
        }

        private void WaitForFixedUpdate() {
            KeepConstantSpeed();
        }

        /// <summary>
        /// 速度を一定に保つ
        /// 衝突や引っかかりによる減速を上書きする役目
        /// </summary>
        private void KeepConstantSpeed() {
            if (!canKeepSpeed)return;

            var velocity = rigidbody.velocity;
            var nowSqrSpeed = velocity.sqrMagnitude;
            var sqrSpeed = speed * speed;

            if (!Mathf.Approximately(nowSqrSpeed, sqrSpeed)) {
                var dir = velocity != Vector3.zero ? velocity.normalized : lastDirection;
                rigidbody.velocity = dir * speed;
            }
        }

        private void FixedUpdate() {
            // 重なりを解消
            OverlapDetection();

            // 前フレームで反射していたら反射後速度を反映
            ApplyReboundVelocity();

            // 進行方向に衝突対象があるかどうか確認
            ProcessForwardDetection();

            // 1フレーム前の進行方向を保存
            UpdateLastDirection();

        }

        /// <summary>
        /// オブジェクトとの重なりを検知して解消するように位置補正する
        /// 主にTransform.positionで移動してきた外部オブジェクトを回避するのに使う
        /// </summary>
        private void OverlapDetection() {
            // Overlap
            var colliders = Physics.OverlapSphere(sphereCollider.transform.position, sphereCollider.radius);
            var isOverlap = 1 < colliders.Length;
            if (isOverlap) {
                var pushVec = Vector3.zero;
                var pushDistance = 0f;
                var totalPushPos = Vector3.zero;
                var pushCount = 0;

                foreach (var targetCollider in colliders) {
                    // 自身のコライダーなら無視する
                    if (targetCollider == sphereCollider)continue;

                    var isCollision = Physics.ComputePenetration(
                        sphereCollider, sphereCollider.transform.position, sphereCollider.transform.rotation,
                        targetCollider, targetCollider.transform.position, targetCollider.transform.rotation,
                        out pushVec, out pushDistance);

                    if (isCollision && pushDistance != 0) {
                        totalPushPos += pushDistance * pushVec;
                        pushCount++;
                    }
                }

                if (pushCount != 0) {
                    var pos = transform.position;
                    pos += totalPushPos / pushCount;
                    transform.position = pos;
                }
            }
        }

        /// <summary>
        /// 計算した反射ベクトルを反映する
        /// </summary>
        private void ApplyReboundVelocity() {
            if (reboundVelocity == null)return;

            rigidbody.velocity = reboundVelocity.Value;
            speed *= bounciness;
            reboundVelocity = null;
            canKeepSpeed = true;
        }

        /// <summary>
        /// 前方方向を監視して1フレーム後に衝突している場合は反射ベクトルを計算する
        /// </summary>
        private void ProcessForwardDetection() {
            var velocity = rigidbody.velocity;
            var direction = velocity.normalized;

            var offset = useContactOffset ? defaultContactOffset * 2 : 0;
            var origin = transform.position - direction * (sphereCastMargin + offset);
            var colliderRadius = sphereCollider.radius + offset;
            var isHit = Physics.SphereCast(origin, colliderRadius, direction, out var hitInfo);
            if (isHit) {
                var distance = hitInfo.distance - sphereCastMargin;
                var nextMoveDistance = velocity.magnitude * Time.fixedDeltaTime;
                if (distance <= nextMoveDistance) {
                    // 次フレームに使う反射速度を計算
                    var normal = hitInfo.normal;
                    var inVecDir = direction;
                    var outVecDir = Vector3.Reflect(inVecDir, normal);
                    reboundVelocity = outVecDir * speed * bounciness;

                    // 衝突予定先に接するように速度を調整
                    var adjustVelocity = distance / Time.fixedDeltaTime * direction;
                    rigidbody.velocity = adjustVelocity;
                    canKeepSpeed = false;
                }
            }
        }

        private void UpdateLastDirection() {
            var velocity = rigidbody.velocity;
            if (velocity != Vector3.zero) {
                lastDirection = velocity.normalized;
            }
        }

#region Debug
        private float debugAngle;
        private Vector3 debugDirection;
        private void Update() {
            if (Input.GetKey(KeyCode.DownArrow)) {
                debugAngle -= 1f;
            } else if (Input.GetKey(KeyCode.UpArrow)) {
                debugAngle += 1f;
            }
            if (Input.GetKeyDown(KeyCode.Space)) {
                debugDirection = new Vector3(Mathf.Cos(debugAngle * Mathf.Deg2Rad), Mathf.Sin(debugAngle * Mathf.Deg2Rad), 0f);
                rigidbody.velocity = debugDirection * speed;
            }
        }

#if UNITY_EDITOR
        void OnDrawGizmos() {
            if (EditorApplication.isPlaying && rigidbody.velocity == Vector3.zero) {
                Gizmos.color = Color.green;
                debugDirection = new Vector3(Mathf.Cos(debugAngle * Mathf.Deg2Rad), Mathf.Sin(debugAngle * Mathf.Deg2Rad), 0f);
                var colliderRadius = sphereCollider.radius + (useContactOffset ? Physics.defaultContactOffset : 0);
                var isHit = Physics.SphereCast(transform.position, colliderRadius, debugDirection * 10, out var hit);
                if (isHit) {
                    Gizmos.DrawRay(transform.position, debugDirection * hit.distance);
                    Gizmos.DrawWireSphere(transform.position + debugDirection * hit.distance, colliderRadius);
                } else {
                    Gizmos.DrawRay(transform.position, transform.position + debugDirection * 10);
                }
            }
        }
    }
#endif
#endregion

}

4. 動画

分かりやすいように、衝突時に赤色に変化させてます。

f:id:norikatuo:20210205024837g:plain

5. 解説

近日 追記予定

6. TODO

  1. PhysicMaterialとの併用による動体との反射挙動実装
  2. Surface Normalを利用した反射挙動(今はSmooth Normal)
  3. IsTrigger=True での対応

【Unity】子オブジェクトにRigidbodyをアタッチして親オブジェクトを移動させたときの動作

子にRigidbodyをアタッチしたときの挙動についていくらか混乱してしまったので備忘録としてまとめます。
こうしたらこうなったという知見的なものになりますが参考になれば幸いです。

動作状況

図のように親(ヒエラルキー上のParent)と子(ヒエラルキー上のChild)として、それぞれCubeオブジェクト(白色のCube)を用意します。
下側にある大きいほうのCubeが親で、上側にある小さいほうのCubeが子です。
OnTriggerEnterの発火用にもう一つCubeを追加して、区別がつきやすいよう赤色にして置いておきます(ヒエラルキー上のArea)。

子だけにBoxColliderをアタッチしてIsTrigger=Trueに設定します。
親にはあらかじめRIgidbodyをアタッチしておきます。

OnTriggerEnterが呼ばれたことが分かりやすいように、呼ばれたタイミングでオブジェクトを赤色に染めています。

以下の2通りで親オブジェクトを赤色のAreaに向けて移動させた結果をまとめました。
(1) rigidbody.velocityを変更して速度を与える方法
(2) transform.positionで位置を直接変更する方法
f:id:norikatuo:20210126002949p:plain

1. 子にRigidBodyがアタッチされていない場合

子は親の移動(rigidbodyによる移動, transform.positionによる移動)に追従する。
子のColliderの接触時には親と子の両方のOnTriggerEnterが呼ばれる。
f:id:norikatuo:20210126011542g:plain

2. 子にRigidBodyがアタッチされている場合

  • 子のRIgidbodyがIskinemati=Falseの場合

    子は親の移動(transform.positionによる移動)に追従する。
    子のColliderの接触時には子のOnTriggerEnterのみが呼ばれる。

Rigidbodyによって親に速度を与えた場合

f:id:norikatuo:20210126013203g:plain

Transform.positionを変更して親を動かした場合

f:id:norikatuo:20210126012845g:plain

  • 子のRIgidbodyがIskinemati=Trueの場合

    子は親の移動(rigidbodyによる移動, transform.positionによる移動)に追従する。
    子のColliderの接触時には子のOnTriggerEnterのみが呼ばれる。

f:id:norikatuo:20210126013925g:plain

3. まとめ

子のRigidbody 子のIsKinematic 親のRigidbodyによる移動に追従 親のtransformによる移動に追従 子のColliderが衝突時
なし - 親子共にOnTriggerEnter
あり False × 子のOnTriggerEnter
あり True 子のOnTriggerEnter

※検証の簡易化のためIsTrigger=Trueの結果です。