前回Editor-Onlyなゲームエンジンを紹介しました。 Editor-Onlyという体で説明してましたが、「実装例」として結局エンジンランタイムを一揃いリポジトリ内に用意しちゃってます。 今回はこのランタイム実装例のおおまかな構造を紹介します。
全体像
いくつものモジュールに分割されています。
graph BT
Serialization --> Storage
World --> Serialization
World --> ObjectModel
Graphics --> ObjectModel
Mesh --> Graphics
Text --> Graphics
WebGpu --> Mesh
WebGpu --> Graphics
PlayerLoop --> ObjectModel
Audio --> ObjectModel
Collision --> ObjectModel
Windowing
EmptyEngine.ObjectModelでUnityライクなオブジェクト/コンポーネントによるシーン表現を定義しています。
後述のプレイヤーループを分離した都合からデータ指向との両立は難しくなりました。
ゲームの組みやすさも含めて今回はコンポーネントベースとしています。
そのEmptyEngine.ObjectModelに依存するモジュールが複数あり、各種コンポーネント等を提供している作りです。
全部のモジュールを使ってもいいし、要件に合わなければ別の実装に入れ替えてもいいです。
シリアライザの分離
殆どのゲームエンジンではシリアライズ形式の変更はできませんし、変更したくなっても後戻りできない…!みたいな状況になりがちです。
これを避けたくて、今回のエンジンでは各機能モジュールからシリアライザに依存しないようにしました。 代償として、機能モジュールからの要求が暗黙的なものになってます。 逆に言えばこれらの要件さえ満たせば、後からシリアライザを別物に変更できます。
- 公開フィールド/プロパティをシリアライズしてほしい
- プリミティブ型と、それらから成る構造体/配列をシリアライズしてほしい
- コンストラクタインジェクションしてほしい
- etc...
EmptyEngine.Serializationでは上記を満たす型ごとのシリアライザをSourceGeneratorに書かせ、MessagePackに詰めています。
コンポーネント/アセットへのDI
このエンジンの特徴として、デシリアライザがDIを行う点が挙げられます。 コンポーネント/アセットはコンストラクタ注入で依存サービスを受けられます。
public sealed partial class PlatformerObject(SceneWorld world, RenderWorld renderWorld, DefaultUpdateRegistry registry)
: UpdatableComponent(registry)
{
...
}
デシリアライザが引数付きコンストラクタにも対応している場合、多くの実装ではシリアライズされたデータから引数を決定します。 一方で、EmptyEngine.Serializationではコンストラクタに引数があれば対象型のインスタンスをコンテナから引いて渡すことにしています。 後付けのDIと違い、コンポーネント/アセット側はコンテナやシリアライザへの依存を一切持たずにDIの恩恵を受けられる状態です。
全部コンテナに入れよう
各モジュールは、必要があればサービスインスタンスをコンテナに登録する想定で作っています。
例えば、シーン状態を抱えるSceneWorldオブジェクトはグローバルなものではなく、インスタンス1つをSingletonスコープで登録しています。
GameObjectの生成など、シーンへの操作を行うコンポーネントでは必ずそのSceneWorldをDIで受けて使う形です。
この作りで嬉しい点は、全ての状態の破棄がコンテナの破棄で済むところです。 UnityではFast Enter Play Modeへの移行に伴ってstaticフィールドの初期化等を漏れなく行う必要が出ちゃいましたが… 今回の実装ではPlay/Editの切り替えに伴ってコンテナの破棄/再生成で状態をリセットできます。
(これはstaticを使わないで済む仕組みを用意したという話であり、もちろん各モジュール側でstatic状態を持たないような実装が必要です)
Updateは後付け
デフォルトのプレイヤーループを用意していません。 各プロジェクトが自由に組めたほうがいいと考えています。
ObjectModel/Serializationはコンポーネントのライフサイクルのみを知っていて、Updateの概念を知りません。
各コンポーネントが「私はUpdate対象ですよ」を自己申告してまとめて呼び出せるようにしています。
上記コード例のPlatformerObjectのように、登録先もコンストラクタで受けます。
「Update対象の登録先」はインスタンス毎の管理ができます、「常に描画フレームレートで呼ぶUpdate」「ポーズ中は呼ばれないUpdate」等を定義して各コンポーネントがどの種別に入るか自己申告できます。 今のところ上記シリアライザがKeyed DIに対応していないので、種別ごとに継承しただけの別の型を用意してサービスとして登録しています。
graph TB
MainLoop --> |ポーズ中じゃなければ呼び出し|DefaultUpdateRegistry
MainLoop --> |常に呼び出し|IgnorePauseUpdateRegistry
DefaultUpdateRegistry --- |登録|PlatformerObject["PlatformerObject<br>Component"]
IgnorePauseUpdateRegistry --- |登録|TweenUI["TweenUI<br>Component"]
この後付けUpdateの利点がプレイヤーループの自由度であり、代償がデータ指向との相性の悪さと捉えています。 また、レンダラーコンポーネントなどの必ず描画フレームに紐づく処理は別枠で各モジュールの中に閉じ込められています。
.NET 10で実装
ランタイムとして.NET 10を使い、エンジンの殆どがC#実装です。 コンポーネント等のゲーム側のロジックもC#実装であり、単純にエンジン実装パッケージ群を参照したゲームコードがdotnetツールチェインでビルドされています。
Windows/Mac向けにはnet10.0で、iOSビルドはTargetFrameworkの変更で動くようにしています。 Web向けにはWebAssembly Browser AppのSDKを利用してビルドしています。 上記のシリアライザ含め各モジュールはAOTコンパイルでも動作するようにしています。
LinuxやAndroidは、同様に対応できるでしょ…と思いつつ試してません…
マルチプラットフォーム対応
ランタイム部分のマルチプラットフォーム対応はdotnetに丸投げでしたが、 その他の機能部分のマルチプラットフォーム対応もあまり自前では行わないようにしています。
描画はwebgpu.h相当のインターフェイスをプラットフォーム共通で使い、Webビルドではemscripten組み込み実装、それ以外ではDawnを利用しています。
テクスチャのエンコーディングにはBasis Universalを利用しています。 ビルドターゲット毎の再エンコード等は不要で、Web環境など末端のGPU機能が未知の場合にも便利です。
音声再生はNVorbisでVorbisのデコードを行い、WebAudio APIまたはOpenALでPCMを流す形です。
Windowingは3種類のバックエンドが存在し、仕方なく小さな抽象化を挟んでいます。
- DesktopではSilk
- iOSではdotnetのiOS向けworkloadの機能
- Webでは自前実装
Program.csでやること
ゲームエンジンと言いながらデフォルトのエントリポイントを用意していません。 上記の構成を踏まえ、ゲームプログラムがやるべきことを示すと次のような感じです。
- コンテナを作って各種サービスを登録
- Windowを作ってハンドルを描画モジュールに教える
- 任意のタイミングのループでゲームロジックを更新
- Play/Edit切り替え時にコンテナを作り直し
実際の例はここにあります。
おしまい
やはり一番思想が強いのは前回紹介したエディタの部分ですが… ランタイム実装例としても次のような特徴を持たせています。
- モジュール毎に捨てて別実装に入れ替えられる
- DIコンテナの利用を前提とした構成
- シリアライザに依存しない機能実装
このエンジンを使った成果物としてGenso Flood Frontがあります。 名前もついてない「EmptyEngine実装例エンジン」ですが、既存エンジンに頼らずにゲームを作れるようになりました。 やったね。













