これは鈴鹿高専Advent Calendar 2023 7日目の記事です。
目次
はじめに
1. 概要
2. IOとDI
3. 座標系とベクトルの変換
4. 当たり判定と物理演算
5. さいごに
はじめに
この記事は仮想空間を作ろう!(クライアント編)のプレイヤー操作周りに特化したものです。
1. 概要
この記事でターゲットとするものはTPSやFPS、またはその視点でプレイする仮想空間のようなものです。具体的なプロダクト名を挙げるとApex LegendsやMinecraftなどのようなものです。
そのようなゲームを作る際には(リアルの)ユーザーが(ゲーム内の)プレイヤーを動かす処理を用意する必要があります。この記事ではその処理の作り方についてを私なりに備忘録的にまとめてみようと思います。
2. IOとDI
ユーザーがゲームをプレイする際はいかなる場合でもIOデバイスからゲームシステムに操作を伝達します。デバイスは様々な種類があり、例えばWASDやマウス、またはゲームパッドやVRコントローラーなど挙げ始めるときりがないと思います。
対応デバイスを絞る場合はIOデバイス毎に新しくクラスなどでロジックを用意してしまうのも良いかもしれませんが、もしたくさんのデバイスに対応しないといけない場合はよろしくないです。なぜなら動かすゲームオブジェクトに対してどのIOデバイスを使うかをコードの時点で書かなければならず、デバイスを変更したいときに修正箇所が増えて大変なことになるからです。
具体的な例を見せます。プレイヤーのGame Objectに操作結果を反映させるためのPlayerControlコンポーネントをアタッチし、このコンポーネントがゲームパッドのコントローラーからの入力をターゲットとしている場合を考えます。
図にするとこのようになり、PlayerControlがInputFromPadを持っているため依存しています。もしIOを違うデバイスにしたい場合、デバイスを使うコンポーネントが一つだけならそのコンポーネントを書き換える方針でも良いかもしれませんが、デバイスに依存したコンポーネントが多い場合は書き換え箇所が増えて面倒です。
ここでDIの出番です。まずそもそもPlayerControlは必ずしもIOデバイスの具体的なことを知っている必要がありません。どのようなデータが入力されるのかだけを知っていれば問題ないでしょう。そこでInterfaceを作成してPlayerControlコンポーネントはこれを持ち、実体はDIで注入してあげます。
IInputProviderはInterfaceで、これに実体を注入するのはExtenjectなどのDIフレームワーク用いることで実現できます。 もし対応デバイスを変更したい場合でもInterfaceを継承した実体クラスを定義し、DIフレームワークを使うコードを変更すれば良いだけになるのでメンテナンスが圧倒的に楽になります。
Extenjectの使い方に関しては既に詳しい記事がそこそこあるので検索してみてください。たぶん私が書くよりも分かりやすいと思います。
ちなみにDIとは依存性注入を意味するDependency Injectionの略で、依存性注入のデザインパターンと呼ばれたりしています。この例ではPlayerControlが依存するものをPadという具体的なものからInterfaceという抽象的なものへ変えることができています。DIはこの他にも単体テストしたいときなど便利な場面があるので興味のある人は調べて使ってみると面白いかもしれません。
3. 座標系とベクトルの変換
Game Objectの座標系は、ヒエラルキーの最上位層にあるものがワールド座標系で親を持つゲームオブジェクトは親を原点としたローカル座標系を使います。
出典 : https://xr-hub.com/archives/12124
ここではプレイヤーはグローバル座標系で動いている場合で考えていきます。
ではまずIOデバイスから前後左右の移動入力を受け取った場合を考えます。これは2次元ベクトルで考えると分かりやすいでしょう。
Direction | Vector |
---|---|
Forward | $(0, 1)$ |
Right | $(1, 0)$ |
Backword | $(0, -1)$ |
Left | $(-1, 0)$ |
こうなりますね。しかしこのベクトルというのはプレイヤー中心で考えた場合のもの、つまりプレイヤーを原点としたローカル座標系でのベクトルです。なのでこれをワールド座標系でのベクトルに変換する必要があります。そのためにはワールド座標系とローカル座標系がどういう関係があるのか、特にどう回転させたらローカル座標系とワールド座標系が一緒になるのかを考えます。回転を考える際、Unityは3次元のため回転軸が3つありますが、平面移動に関しては$y$軸周りの回転のみを考えれば良いです。
また、Unityは左手座標系で左ネジの法則のため、回転は軸のベクトルが進む向きに対して反時計回りが正となっています。電磁気などの右ネジの法則とは逆で大変扱いづらくなんでこんな仕様にしているのか謎すぎますが仕方ないです。またその関係でプレイヤーが平面移動する$xz$平面に関して右回りが正の回転方向となっています。
では早速ですが回転を$\phi$, ローカル座標系の移動ベクトルを$\mathbb{t}_l$, ワールド座標系の移動ベクトルを$\mathbb{t}_w$として関係式を書いてみます。
$$ \mathbb{t}_w = \begin{pmatrix} cos \phi & sin \phi \\ -sin \phi & cos \phi \end{pmatrix} \mathbb{t}_l $$
このようになりますね。回転行列の右上と左下の成分が符号が反転しているように見えますが、これは回転行列はそのままだと左回転が正だからです。奇関数である$sin$の符号を変えることで回転を逆にできます。
一般的にはこのままでも良いかもしれませんが、私は行列計算が大嫌いです。面倒なので。そのためここで複素数平面を応用して数式を楽にしてみましょう。
複素数同士の掛け算を極形式で表してみると
$$ae^{i\theta_1} \cdot be^{i\theta_2} = abe^{i(\theta_1+\theta_2)}$$
のような関係があります。つまりある複素数に対して偏角$\phi$の単位複素数を乗算することで複素数平面上で$\phi$だけ回転させることができます。ここで注意しなければならないのが、複素数平面上での回転は左向きが正ということです。そのためUnityの回転の向きに合わせるには複素共役を取ったものを乗算する必要があります。
では複素数平面で回転を$\phi$, ローカル座標系の移動ベクトルを$\mathbb{z}_l$, ワールド座標系の移動ベクトルを$\mathbb{z}_w$として関係式を書いてみると
$$ \mathbb{z}_w = \overline{e^{i\phi}} \cdot \mathbb{z}_l$$
となります。行列計算よりもスッキリしたように感じませんか?ちなみにこれが行列計算と等価であることは複素数を直交座標形式で計算してみることで証明できます。興味がある人はやってみましょう。
ちなみに
$x$成分←→実部
$z$成分←→虚部
の対応関係があることを知っておいてください。
余談
C#の複素数構造体はUnityで使うにはリッチ過ぎて重たいです。Unityのベクトルは32bitFloatですが複素数構造体は虚部と実部をそれぞれ64bitDoubleで保持しているためです。演算回数が少ないならそのままでも良いですが、毎フレーム実行するとなるとできる限り無駄を減らしたいですよね。そこで今回の開発では32bitFloatで複素数構造体を自作してしまい軽量化を図っています。自作した構造体もできる限りC#標準のものと簡単に切り替えられるようにメンバを揃えるなど工夫をしています。またUnityの$xz$平面と変換しやすいようにメンバ関数を追加で定義したりしています。
複素数構造体のコード
public readonly struct Complex: IEquatable<Complex>, IComparable<Complex>, IFormattable
{
public readonly float Real;
public readonly float Imaginary;
public Complex(float real, float imaginary)
{
Real = real;
Imaginary = imaginary;
}
public Complex(Vector3 rot, bool isDeg = false)
{
var y = rot.y;
if (isDeg)
{
y *= MathF.PI / 180.0f;
}
Real = MathF.Cos(y);
Imaginary = MathF.Sin(y);
}
private unsafe float Invsqrt()
{
var x = Real * Real +Imaginary * Imaginary;
var buf = *(long*)&x;
buf = 0x5F3759DF - (buf >> 1);
var y = *(float*)&buf;
y *= Math.Abs(1.5f - x * 0.5f * y * y);
return y;
}
public float Im => Imaginary;
public float Re => Real;
public float Magnitude => 1 / Invsqrt();
public float SqrMagnitude => Real * Real + Imaginary * Imaginary;
public float Phase => MathF.Atan2(Imaginary, Real);
public Complex Conjugate => new(Real, -Imaginary);
public Complex Normalize => this * Invsqrt();
public Vector3 ToVector3 => new(Real, 0.0f, Imaginary);
public static Complex operator +(in Complex a, in Complex b)
{
return new Complex(a.Real + b.Real, a.Imaginary + b.Imaginary);
}
public static Complex operator -(in Complex a, in Complex b)
{
return new Complex(a.Real - b.Real, a.Imaginary - b.Imaginary);
}
public static Complex operator *(in Complex a, in Complex b)
{
return new Complex(a.Real * b.Real - a.Imaginary * b.Imaginary, a.Real * b.Imaginary + a.Imaginary * b.Real);
}
public static Complex operator *(in Complex a, in float b)
{
return new Complex(a.Real * b, a.Imaginary * b);
}
public static Complex operator *(in float a, in Complex b)
{
return new Complex(a * b.Real, a * b.Imaginary);
}
public static Complex operator /(in Complex a, in float b)
{
return new Complex(a.Real / b, a.Imaginary / b);
}
public static Complex operator /(in float a, in Complex b)
{
return new Complex(a * b.Real, a * b.Imaginary);
}
public static Complex operator /(in Complex a, in Complex b)
{
return new Complex(a.Real * b.Real + a.Imaginary * b.Imaginary, a.Imaginary * b.Real - a.Real * b.Imaginary) / (b.Real * b.Real + b.Imaginary * b.Imaginary);
}
public static Complex operator -(in Complex a)
{
return new Complex(-a.Real, -a.Imaginary);
}
public static Complex operator ~(in Complex a)
{
return new Complex(a.Real, -a.Imaginary);
}
public static Complex operator ++(in Complex a)
{
return new Complex(a.Real + 1, a.Imaginary);
}
public static Complex operator --(in Complex a)
{
return new Complex(a.Real - 1, a.Imaginary);
}
public bool Equals(Complex other)
{
return Real.Equals(other.Real) && Imaginary.Equals(other.Imaginary);
}
public override bool Equals(object obj)
{
return obj is Complex other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(Real, Imaginary);
}
public override string ToString(){
return $"{Real}+{Imaginary}i";
}
public string ToString(string format, IFormatProvider formatProvider)
{
return ToString();
}
public int CompareTo(Complex other)
{
var realComparison = Real.CompareTo(other.Real);
return realComparison != 0 ? realComparison : Imaginary.CompareTo(other.Imaginary);
}
}
絶対値計算で何やら怪しいことをしているように見えますが、これは高速逆平方根のアルゴリズムを応用してパフォーマンスの向上を図っています。
4. 当たり判定と物理演算
ここまででプレイヤーを動かす準備ができましたので実際に動かしてみましょう。
Vector3 vec;
//vecに移動ベクトルを代入...
transform.position += vec;
大抵の人はこれを書くと思います。これでも良い場合はあるのですが、実はこの方法では衝突時に物体をすり抜けてしまいます。
transform.position
はGame Objectのワールド座標系での位置を参照しています。これを書き換えると位置が絶対的に変わってしまい衝突時でもそれを考慮せずに位置が確定してしまいます。テレポートする、とイメージしてもらうと分かりやすいでしょう。
衝突した、ということを検出できるので自分で当たり判定時の処理を書けば問題なくなりますが、せっかくUnityという便利なゲームエンジンを使っているので当たり判定はUnityの機能を使って楽をしたいですね。
そこでGame ObjectにRigidbodyコンポーネントを追加し、メンバ関数のAddForceを使って移動させましょう。AddForceはGame Objectに対して外力を加える関数で、この関数で移動させることによって当たり判定などの物理演算がコードを書かなくても動作するようになります。少なくとも私が開発したときはノーコードで当たり判定時にすり抜けないようにすることができました。
ただしAddForce関数にも注意点があります。この関数で外力を加えるとGame Objectの速度が変化します。関数を叩くことでステップ関数的に変化しますが、そのあとは物理演算によって徐々に減衰していくようになります。つまり入力を解除しても仮想的に慣性が働いてすぐに止まらないのです。そのためAddForce関数を叩く前に速度を0にして慣性が働かないようにする必要があります。
Rigidbody _rigidbody = GetComponent<Rigidbody>();
...
Vector3 vec;
//vecに移動ベクトルを代入...
_rigidbody.velocity = Vector3.zero;
_rigidbody.AddForce(vec, ForceMode.VelocityChange);
5. さいごに
以上までの内容を理解してもらうと、様々なデバイスからの入力を受け付けてプレイヤーを動かすことのできるコンポーネントを作成できると思います。しかし「こんな長い文章なんて読んでる暇が無い!」という限界状態の人がいるかもしれないので、実際に書いたコードを下に貼り付けておきます。ExtenjectをインポートしてプレイヤーのGame ObjectにRigitbodyなどの必要なコンポーネントをアタッチしていれば動くと思うのでコピペしてみてください。ただし動かなくても責任は取りません。
コードはこちら
using IO; //IO周りのクラスの名前空間
using UnityEngine;
using Util; //自作した複素数構造体が入っている名前空間
using Zenject; //Extenject
namespace hogehoge
{
/// <summary>
/// Class for controlling player
/// </summary>
public class PlayerControl : MonoBehaviour
{
[SerializeField] private float moveSpeed = 1.0f;
[SerializeField] private float viewPointSpeed = 10.0f;
private Vector3 _move;
[Inject] private IMoveProvider _moveProvider;
private Rigidbody _rigidbody;
[Inject] private IViewPointProvider _viewPointProvider;
private void Start()
{
_rigidbody = GetComponent<Rigidbody>();
}
private void Update()
{
var moveComplex = _moveProvider.GetMove();
var rotationComplex = new Complex(transform.eulerAngles, true);
var move = (moveComplex * rotationComplex.Conjugate).Normalize.ToVector3 * moveSpeed;
_rigidbody.velocity = Vector3.zero;
_rigidbody.AddForce(move, ForceMode.VelocityChange);
var horizontalViewPoint = _viewPointProvider.GetHorizontalViewPoint();
transform.RotateAround(transform.position, Vector3.up,
horizontalViewPoint * (viewPointSpeed * Time.deltaTime));
}
}
}
using Util;
namespace IO
{
/// <summary>
/// Interface for providing move direction
/// </summary>
public interface IMoveProvider
{
public Complex GetMove();
}
}
using UnityEngine;
using Util;
namespace IO
{
/// <summary>
/// Class for providing move direction from key
/// </summary>
public class MoveFromKey : IMoveProvider
{
// ReSharper disable Unity.PerformanceAnalysis
public Complex GetMove()
{
var moveComplex = new Complex(0, 0);
if (Input.GetKey(KeyCode.W))
{
moveComplex += new Complex(0, 1);
}
if (Input.GetKey(KeyCode.S))
{
moveComplex += new Complex(0, -1);
}
if (Input.GetKey(KeyCode.A))
{
moveComplex += new Complex(-1, 0);
}
if (Input.GetKey(KeyCode.D))
{
moveComplex += new Complex(1, 0);
}
return moveComplex;
}
}
}
namespace IO
{
/// <summary>
/// Interface for providing view point
/// </summary>
public interface IViewPointProvider
{
public float GetHorizontalViewPoint();
public float GetVerticalViewPoint();
}
}
using UnityEngine;
namespace IO
{
/// <summary>
/// Class for providing view point from mouse
/// </summary>
public class ViewPointFromMouse : IViewPointProvider
{
public float GetHorizontalViewPoint()
{
return Input.GetAxis("Mouse X");
}
public float GetVerticalViewPoint()
{
return Input.GetAxis("Mouse Y");
}
}
}
DIのためのコード
using IO;
using Zenject;
namespace DI
{
public class InputInstaller : Installer<InputInstaller>
{
public override void InstallBindings()
{
Container.Bind<IMoveProvider>().To<MoveFromKey>().AsCached();
Container.Bind<IViewPointProvider>().To<ViewPointFromMouse>().AsCached();
}
}
}
using IO;
using Zenject;
namespace DI
{
public class InputInstallerManager : MonoInstaller
{
public override void InstallBindings()
{
InputInstaller.Install(Container);
}
}
}
P.S.
2時間ほどで急いで書いたのでおかしいところがあるかもしれません。気付いた方はこっそり教えてください。