こちらは鈴鹿高専Advent Calendar 2023 5日目の記事です。
サーバーサイド編は6日に投稿予定です。
目次
はじめに
1. 企画
2. 仕様策定
3. 設計-実装
4. まとめ
はじめに
鈴鹿高専のカリキュラムの一部の創造工学で仮想空間を開発したので、この記事ではどのように考え設計・実装をしたのかを書いていきます。そのためコピペして実際に"動く"コードは全ては書きませんのでご了承ください。
Tech Stack
- Unity 2021.3.14f
- UniTask
- Extenject
- Jetbrains Rider ( Code Editor )
1. 企画
仮想空間と一口に言っても様々なパターンが考えられます。現在すでにあるソリューションでもVRCやReality, Minecraftなどが挙げられ、それぞれで他には無い良さを持った仮想空間を展開しています。
重要視したポイント
今回私達が仮想空間を作る上で重要だと考えたことは「コミュニケーション」です。
コミュニケーションにも音声や映像など様々な種類がありますが、今回は実装が比較的楽な文字ベースのコミュニケーション、すなわち"チャット"を実装しました。Minecraftのチャットを想像してもらえると良いです。
また、アバターを自分で好きなようにカスタムできることも重要と考えました。服装を変えることはもちろん、顔や体型など容姿すらも変えられるのは仮想空間だからこそできるためです。しかしアバター作成を自分たちで実装するのはとてもできるようなものではないため、PixivのVRoidをSDKを提供してもらいアバターとして使えるようにしました。
規模感
仮想空間の規模は大きくても良かったのですが少ない人数でも難なくコミュニケーションができたほうが良いと考えました。これは発表するときに空間内で誰ともエンカウントしないなどの事態を防ぐためでもあります。そのため30人前後までの空間を想定し、最終的にはそのような仕様にしています。なおルームの概念を取り入れたため同時プレイ人数は4000人弱となっています。
対応プラットフォーム
対応プラットフォームはUnityを使っていることもありPCだけでなくスマホやVRデバイス(Oculusなど)も視野に幅広く考えました。なお時間的な都合で最終的にはPCのみとなっています。
ここまでである程度のソリューション的な仕様が決まりました。続いてはシステムの仕様を決定していきます。
2. 仕様策定
Unityで仮想空間を作ることはプレイヤーが1人ならそこまで難しくありませんが、複数人でかつリアルタイム同期が必要な場合は一気に難しくなります。
通信周り
リアルタイム同期が必要、つまり通信は必須ですね。通信にも様々な手法が考えられます。例えばプロトコルです。具体的にはgRPCやwebSocketなどです。Magic Onionなどのライブラリを使うことも考えられるでしょう。
またクライアント同士の関係も、サーバーを挟むものとクライアント同士でコネクションを張るP2Pのようなものの2つが思いつきます。
多くても30人前後ならシグナリングサーバーを用意してP2Pのメッシュ型ネットワークでも問題ないと少し思いましたが、アップストリームが多くなってしまうのはいかがなものか、ということでサーバーを介した通信に決定しました。
またプロトコルに関しては
- 送信するデータ量を減らしたい
- 学習コストが低いほうが良い
- C#で触りやすい
- 自分たちで拡張できたほうが便利そう
ということで、UDPをベースとしてプロトコルを自作しました。このように書くと「すごい」と思うかもしれませんがTCP/IPのアプリケーション層のプロトコルを自作しただけで何も難しいことはしてません。ビット演算とTCP/IPの基礎を理解していれば誰でもできることです。
アバター管理
アバターはPixivのVRoid(以下VRoid)をSDKから読み込んで使うことになりました。VRoid SDKについてはあまり喋ってはいけないため雰囲気のみのはなしになります。
まず始めにクライアントは自分と他のクライアントのアバター情報を持つ必要があります。そのためこの情報を管理するためのModel Managerを作成して使っていこうとなりました。アバターの読み込みなどもこのManagerを使うことにしました。
I/O(Input/Output)
IOはまずはPCを考えたのでキーボード&マウスを想定しましたが、マルチプラットフォーム対応やPADなどのデバイスも考え、各デバイスから得られた値を上手く処理をして利用する、というようにしました。ちなみにキーボード&マウスのみであればUnityに実装されている機能を使えばすぐに良い感じのことができます。
3. 設計-実装
ここまできたら設計です。今回はチーム開発かつUnity初心者、それどころかシステム開発が初めてという人しかいなかったので、任せるところはできる限り簡単になるように頑張りました(それでも私がほとんどを...)。
アーキテクチャ
システムアーキテクチャには色々な思想がありどれがベストプラクティスなのか、こればっかりは学生の間の開発経験だけでは分からないものがあります。今回はシステムを横に分割した後にそれぞれを縦に分割しました。
具体的には横向きとして
- IO
- Network
- VRoid
- Scene
です。
縦向きの分割でNetworkを例にすると
- Socket
- Protocol
です。更にこのProtocolを分割して小さなモジュールにし、最終的に大きな機能を実現しています。
つまり分割統合法です。
依存関係
C#などのオブジェクト指向プログラミングでは時にオブジェクト同士の関係性、依存性で頭を悩ませることがあります。また、マルチプラットフォーム対応を考えると必然的に同じ機能を持ったオブジェクトが増えてさらに頭を抱えることがあるでしょう。ここではIOデバイスを例にどう設計したかを説明していきます。
先の仕様策定の節で軽く触れましたが、IOデバイスにも様々な種類があり、それぞれで取ってこれるデータの形が違います。そのため例えばオブジェクトを動かしたなどといった単一の目的においても、上手に設計しないと目的に関する責任を複数のクラスが持っているというとてつもない事態に陥ってしまいます。
今回の開発ではIOデバイスから得られる出力値の型およびデバイスから取るべき値(位置移動, 視点移動)を得るための関数の情報、つまり内部実装を持たないインターフェース(抽象的なIOオブジェクト)を定義しました。
IOからの値で動作させたいオブジェクトに関してはインターフェースのみを持ち、実体はビルド時にターゲット毎に変える、というようにしています。
コードはこちら
namespace IO
{
/// <summary>
/// Interface for providing move direction
/// </summary>
public interface IMoveProvider
{
public Complex GetMove();
}
/// <summary>
/// Interface for providing view point
/// </summary>
public interface IViewPointProvider
{
public float GetHorizontalViewPoint();
public float GetVerticalViewPoint();
}
/// <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;
}
}
/// <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");
}
}
}
ビルド時にターゲット毎に変えるというのは「条件付きコンパイル」というものを使えば簡単に行なえますがIOを使うオブジェクトがたくさんあると辛くなりがちです。そのためDIフレームワークのExtenjectを使いDIでいい感じにしました。
座標関係
IOから値を取得できたは良いものの、今度はオブジェクトをどう動かすかを考える必要があります。まずUnityの座標系の話です。
座標系とオブジェクトの親子関係
Unityのオブジェクトは一番上(ヒエラルキーの最上位層)にあるものはワールド(グローバル)座標系、親を持つものはその親を原点としたローカル座標系で空間中を動きます。
出典 : https://xr-hub.com/archives/12124
今回アバターはワールド座標系で動くようにしました。
移動と複素数平面
先に書いたIOのコードではアバターの回転方向を考慮せずに移動ベクトルを決定します。つまりWを押せばいかなる向きでもワールド座標系の前方向に進むのです。これではプレイができないので回転を考慮する必要があります。
今IOからはアバターを中心としたローカル座標系での移動ベクトル(複素数)が取得できています。これをワールド座標系でのベクトルに変更する必要があります。
一般的にはワールド座標系上での回転角と回転行列を用いて変換しますが、(個人的に)行列計算を使ったプログラムを書きたくなかったので今回は複素数を活用しています。詳しいことは省きますが、偏角を回転角として絶対値が1の複素数をローカル座標系の移動ベクトルを表現する複素数に乗算してあげることで計算できます。
C#標準の複素数構造体は実部と虚部をdouble型で持っていて主にfloatで計算するUnityには不向きなので、演算などで互換性を持ったfloatを利用した複素数構造体を今回の開発で自作しています。また自作ついでに絶対値の計算に高速逆平方根のアルゴリズムを利用しています。
通信周り
今回のシステムのために設計したアプリケーション層のプロトコルでは{userID, roomId, Flag}
が入った2byteのヘッダを作り、その後ろにFlagに対応したデータを格納しています。また先に書いたようにUDPベースのため再送処理などは一切していません。これは再送処理している間に新しいフレームでの情報を送信しないといけない可能性があり、リアルタイムでの位置同期には再送処理は邪魔と判断したためです。また今回は秒間30回データを送信しているために再送処理のためにTCPを使うとトランスポート層のヘッダの大きさが無視できなくなります。これらの理由からUDPを採用しています。
UDPを使うということはC#のUdpSocket
クラスを使っているわけですが、サーバーのエンドポイントを毎回意識して送信するのは面倒くさいしメンテナンス性も悪くなります。そのためこれをwrapして"送信"に特化したクラスを作り、更には各Flag毎にヘッダを勝手に作ってデータを良い感じに整形してくれるApiクラスを作成しました。そのためゲームシステム側のプログラムでは"何を送信するか"のみを意識して関数一つを叩くだけで済むようにしています。
また通信は非同期ですが、Unityではメインスレッドでのみゲームオブジェクトを移動させたりできる、という制約があります。そのためデータを格納するだけのDictionary<string, Vector3[]>
型オブジェクトをメンバに持ったBufferクラスをシングルトンデザインパターンを適用して作り、MonoBehaviour
を継承している各クラスからはこのクラスからデータを取得しています。もちろんデータ受信時にはこのBufferクラスに対してデータを格納しています。本当はもう少し良いやり方があるのでしょうが、サクッと書けるかつ管理が簡単なのはこの手法と考え採用しました。
4. まとめ
Unityでオンラインマルチプレイシステムを開発する初めての機会かつ、PM兼テックリード兼チームリーダーというとんでもない中央集権の過酷な環境でしたが、(設計・実装については)今までのアプリ開発の経験がそこそこ活かせたかなと思っています。開発フローの初期段階でしっかりと設計をしたおかげで実装に関しては私はそこまで悩むことなくでき、個人としての開発体験はとても良かったかなと思います。やはり設計は実装よりも重要ですね!