【Unity】既存の物理エンジン禁止でシンプルな物理演算を実装する

この記事はUnityゲーム開発者ギルド Advent Calendar 2020最終日の記事です。

adventar.org

こんにちは。 最近はMC Eternalに無限に時間を吸われているnotargsです。

Unityにはとても良くできた物理エンジンの「PhysX」が入っていますが、それを使わずに実装できると便利なときがごくまれににあります。 この記事では、「Position Based Dynamics」というシンプルなアルゴリズムを使って、自前で必要最低限な物理演算を実装する方法について解説します。

f:id:notargs:20201217235836g:plain

Prefabをつくる

まずはPrefabを作り、ランダムに配置していきます。 PrefabにくっつけるScriptをSampleRigidBody、Prefabを生成するScriptをSampleRigidBodyManagerと名付けました。

using UnityEngine;

public sealed class SampleRigidBody : MonoBehaviour
{
}
using System.Collections.Generic;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }
}

SampleRigidBodyはSphereにアタッチしてPrefabにしておきます。 f:id:notargs:20201218012353p:plain

SampleRigidBodyは空のGameObjectにアタッチし、SampleRigidBodyのついたPrefabを設定してSceneに置いておきましょう。 f:id:notargs:20201218012438p:plain

これを実行すると、ランダムにSphereが配置されました。 f:id:notargs:20201218012544p:plain

球同士の衝突判定を行う

続いて、球同士の衝突判定を行い、衝突していたら反発させる処理を書きます。

全ての球について、他の全ての球に対して衝突判定を行うと、球の数 * 球の数 O(n^2) の処理が必要になり、球の数が増えるほど、指数関数的に重くなってしまいます。 そのため、一般的には、次の2つのフェーズに分けて衝突判定を行います。 - ブロードフェーズ: シンプルな形状で大まかな衝突判定を行って枝刈りを行うフェーズ - ナローフェーズ: 詳細な形状に合わせて正確に衝突判定を行うフェーズ

が、ここではめんどくさいためブロードフェーズは省略します。 興味がある人はブロードフェーズ スイープ&プルーン Bounding Volume Hierarchyなどのキーワードで検索してみてください。

球の半径を0.5としたとき、球同士の距離が1以下なら衝突しているため、それを用いて衝突を判定します。

f:id:notargs:20201218013443p:plain

衝突していたら、球同士がちょうどぶつからない場所までそれぞれの位置をずらしてあげます。

f:id:notargs:20201218013714p:plain

物理挙動はUpdateで計算を行うと不安定になってしまいがちなので、FixedUpdate内で行います。

using System.Collections.Generic;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }

    private void FixedUpdate()
    {
        for (var i = 0; i < _rigidBodies.Count - 1; ++i)
        {
            for (var j = i; j < _rigidBodies.Count; ++j)
            {
                var rigidBody1 = _rigidBodies[i];
                var rigidBody2 = _rigidBodies[j];
                if (rigidBody1 == rigidBody2)
                {
                    continue;
                }
                
                var a = rigidBody1.transform.position;
                var b = rigidBody2.transform.position;
                
                var ab = b - a;
                
                var abMagnitude = ab.magnitude;
                var abDirection = ab.normalized;
                if (abDirection == Vector3.zero) abDirection = Vector3.up;
                
                if (abMagnitude < 1)
                {
                    a -= (1 - abMagnitude) * abDirection / 2;
                    b += (1 - abMagnitude ) * abDirection / 2;
                }
                
                rigidBody1.transform.position = a;
                rigidBody2.transform.position = b;
            }
        }
    }
}

球同士が衝突し、反発するようになりました。 f:id:notargs:20201218014703g:plain

壁を作る

球がどこまでも飛んでいってしまわないように、壁を作ります。 とはいってもやることは座標を制限するだけなので、とても簡単です。

a = math.clamp(a, -2, 2);
b = math.clamp(b, -2, 2);

この2行をFixedUpdateの最後に追記してあげましょう。

using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }

    private void FixedUpdate()
    {
        for (var i = 0; i < _rigidBodies.Count - 1; ++i)
        {
            for (var j = i; j < _rigidBodies.Count; ++j)
            {
                var rigidBody1 = _rigidBodies[i];
                var rigidBody2 = _rigidBodies[j];
                if (rigidBody1 == rigidBody2)
                {
                    continue;
                }
                
                var a = rigidBody1.transform.position;
                var b = rigidBody2.transform.position;
                
                var ab = b - a;
                
                var abMagnitude = ab.magnitude;
                var abDirection = ab.normalized;
                if (abDirection == Vector3.zero) abDirection = Vector3.up;
                
                if (abMagnitude < 1)
                {
                    a -= (1 - abMagnitude) * abDirection / 2;
                    b += (1 - abMagnitude ) * abDirection / 2;
                }

                a = math.clamp(a, -2, 2);
                b = math.clamp(b, -2, 2);
                
                rigidBody1.transform.position = a;
                rigidBody2.transform.position = b;
            }
        }
    }
}

位置の制限が実装できました! Gizmoを使って強制的に座標を上書きしたためにぷるぷるしていますが、ここでは気にしないこととします。

f:id:notargs:20201218015220g:plain

速度の概念を作る

続いて、「速度」を実装していきます。 SampleRigidBody.csに「1フレーム前の自身の位置」を保存しておき、速度が必要になったタイミングで都度計算するアプローチで実装していきます。 「速度」をそのまま各RigidBodyに持たせることもできますが、反発したときに速度の再計算などが必要になってくるため、より楽な方を選びました。

using UnityEngine;

public sealed class SampleRigidBody : MonoBehaviour
{
    public Vector3 PrevPosition { get; set; }
}

SampleRigidBodyManager.csのFixedUpdate内に、速度の計算、位置の更新、前の位置の更新処理を追記します。

foreach (var rigidbody in _rigidBodies)
{
    var position = rigidbody.transform.position;
    var velocity = (position - rigidbody.PrevPosition) / Time.deltaTime;

    rigidbody.PrevPosition = position;
    position += velocity * Time.deltaTime;
    
    rigidbody.transform.position = position;
}

SampleRigidBodyManager.csの全文は次のようになりました。

using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }
    
    private void FixedUpdate()
    {
        foreach (var rigidbody in _rigidBodies)
        {
            var position = rigidbody.transform.position;
            var velocity = (position - rigidbody.PrevPosition) / Time.deltaTime;

            rigidbody.PrevPosition = position;
            position += velocity * Time.deltaTime;
            
            rigidbody.transform.position = position;
        }

        for (var i = 0; i < _rigidBodies.Count - 1; ++i)
        {
            for (var j = i; j < _rigidBodies.Count; ++j)
            {
                var rigidBody1 = _rigidBodies[i];
                var rigidBody2 = _rigidBodies[j];
                if (rigidBody1 == rigidBody2)
                {
                    continue;
                }
                
                var a = rigidBody1.transform.position;
                var b = rigidBody2.transform.position;
                
                var ab = b - a;
                
                var abMagnitude = ab.magnitude;
                var abDirection = ab.normalized;
                if (abDirection == Vector3.zero) abDirection = Vector3.up;
                
                if (abMagnitude < 1)
                {
                    a -= (1 - abMagnitude) * abDirection / 2;
                    b += (1 - abMagnitude ) * abDirection / 2;
                }

                a = math.clamp(a, -2, 2);
                b = math.clamp(b, -2, 2);
                
                rigidBody1.transform.position = a;
                rigidBody2.transform.position = b;
            }
        }
    }
}

これを動かしてみると、フレームをまたいで速度が維持されていることがわかります。 f:id:notargs:20201218020541g:plain

だいぶ物理っぽくなってきました。

重力を加える

velocity += Physics.gravity * Time.deltaTime;

の1行を加えることで、重力を実装してみます。

using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }

    private void FixedUpdate()
    {
        foreach (var rigidbody in _rigidBodies)
        {
            var position = rigidbody.transform.position;
            var velocity = (position - rigidbody.PrevPosition) / Time.deltaTime;

            velocity += Physics.gravity * Time.deltaTime;

            rigidbody.PrevPosition = position;
            position += velocity * Time.deltaTime;
            
            rigidbody.transform.position = position;
        }

        for (var i = 0; i < _rigidBodies.Count - 1; ++i)
        {
            for (var j = i; j < _rigidBodies.Count; ++j)
            {
                var rigidBody1 = _rigidBodies[i];
                var rigidBody2 = _rigidBodies[j];
                if (rigidBody1 == rigidBody2)
                {
                    continue;
                }
                
                var a = rigidBody1.transform.position;
                var b = rigidBody2.transform.position;
                
                var ab = b - a;
                
                var abMagnitude = ab.magnitude;
                var abDirection = ab.normalized;
                if (abDirection == Vector3.zero) abDirection = Vector3.up;
                
                if (abMagnitude < 1)
                {
                    a -= (1 - abMagnitude) * abDirection / 2;
                    b += (1 - abMagnitude ) * abDirection / 2;
                }

                a = math.clamp(a, -2, 2);
                b = math.clamp(b, -2, 2);
                
                rigidBody1.transform.position = a;
                rigidBody2.transform.position = b;
            }
        }
    }
}

物体が重力に沿って落下するようになりました。 f:id:notargs:20201218021503g:plain

台パンを実装する

せっかくなので、台を揺らすようなイメージで、物体を跳ねさせる仕組みを実装してみましょう。 SampleRigidBodyManager.csに、「Zキーが押されたときに球をランダムな方向に移動させる」処理を書きこみます。

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Z))
    {
        foreach (var rigidbody in _rigidBodies)
        {
            rigidbody.transform.position += (Vector3)_random.NextFloat3Direction() * 0.1f;
        }
    }
}

全文はこのようになりました。

using System.Collections.Generic;
using Unity.Mathematics;
using UnityEngine;
using Random = Unity.Mathematics.Random;

public sealed class SampleRigidBodyManager : MonoBehaviour
{
    [SerializeField] private SampleRigidBody rigidBodyPrefab;
    
    private readonly List<SampleRigidBody> _rigidBodies = new List<SampleRigidBody>();

    private Random _random;
    
    private void Start()
    {
        _random = new Random(1);
        for (var i = 0; i < 20; ++i)
        {
            var rigidBody = Instantiate(rigidBodyPrefab);
            rigidBody.transform.position = _random.NextFloat3(-1.5f, 1.5f);
            _rigidBodies.Add(rigidBody);
        }
    }

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Z))
    {
        foreach (var rigidbody in _rigidBodies)
        {
            rigidbody.transform.position += (Vector3)_random.NextFloat3Direction() * 0.1f;
        }
    }
}

    private void FixedUpdate()
    {
        foreach (var rigidbody in _rigidBodies)
        {
            var position = rigidbody.transform.position;
            var velocity = (position - rigidbody.PrevPosition) / Time.deltaTime;

            velocity += Physics.gravity * Time.deltaTime;

            rigidbody.PrevPosition = position;
            position += velocity * Time.deltaTime;
            
            rigidbody.transform.position = position;
        }

        for (var i = 0; i < _rigidBodies.Count - 1; ++i)
        {
            for (var j = i; j < _rigidBodies.Count; ++j)
            {
                var rigidBody1 = _rigidBodies[i];
                var rigidBody2 = _rigidBodies[j];
                if (rigidBody1 == rigidBody2)
                {
                    continue;
                }
                
                var a = rigidBody1.transform.position;
                var b = rigidBody2.transform.position;
                
                var ab = b - a;
                
                var abMagnitude = ab.magnitude;
                var abDirection = ab.normalized;
                if (abDirection == Vector3.zero) abDirection = Vector3.up;
                
                if (abMagnitude < 1)
                {
                    a -= (1 - abMagnitude) * abDirection / 2;
                    b += (1 - abMagnitude ) * abDirection / 2;
                }

                a = math.clamp(a, -2, 2);
                b = math.clamp(b, -2, 2);
                
                rigidBody1.transform.position = a;
                rigidBody2.transform.position = b;
            }
        }
    }
}

まとめ

ここまで、シンプルな仕組みで物理エンジンのようなものを自前実装してみました。 DynamicBoneVRMSpringBoneUnityちゃんSpringBoneなどの「揺れもの」はこの仕組みベースに、さらに次のような要素を組み込むことで実現されています。

  • 物体同士を紐のようなものでつなげる
  • 繋がっている物体同士の距離を一定距離に制限
  • 繋がっている物体が一定の位置関係を保つように力を加える

ここまで解説した内容がしっかりと理解できていれば、これらのソースコードもそれほど苦戦することなく読み解くことができると思います。 ぜひともチャレンジしてみてください。