前回は自作DIを作ったお話をしましたが、今回はそれを踏まえて適切なアーキテクチャやパターンを考えてみました。
結論 万能な設計方針は存在しない
当たり前ですが、シルバーバレッドと言われる「すべての問題を解決できる名案」というのは存在しません。これは実開発でみなさん経験していると思います。
それを踏まえたうえで、Unityで採用すべき技術的パターン(アーキテクチャやデザインパターン)を考えてみました。
アーキテクチャ
アーキテクチャとは最も深い部分の設計方針です。SwitchとPS5には互換性がないように、同じゲームであっても動作させるにはそのアーキテクチャに依存しなくてはなりません。
今回のアーキテクチャという用語は、設計やデザインパターンも含めた広域的な呼び方とさせてください。
目的って?
適当にコードを書いてもゲームは作れます。ではなぜ、アーキテクチャを考えるのか。
それは 「保守管理を楽にすることです。
もっと砕いて言えば、「コストがかからないコード」を作ろうということですね。
私達は知らないうちに疎結合(クラスとクラスが依存しない)や、レイヤー(機能ごとに区切る)を求めがちです。でもそれが帰って重荷になったりする場合もあります。
特にUnityはインゲーム(毎フレームの速度が求められる処理)があり、Webやデスクトップのようなアウトゲーム(データを表示、操作するだけ)だけとは限りません。
MonoBehaviorはnewしてはいけない。そんな変わった仕様もある環境の中でアーキテクチャを考える必要があります。
どのレベルを対象にするか
アーキテクチャを考えるうえで最も大事なのは、開発者がどのような規模で保守管理するのかということです。
インディーズや同人レベルが、AAA(トリプルエーお金かけた超大作ゲーム)のアーキテクチャを真似ても、かえって複雑になるだけで、何年かけても開発が進みません。
逆にAAAがインディーズレベルのコーディングをすれば、数行の仕様変更で動作しなくなることでしょう。
私が提供したいレベルは自作DIでお話したように、「途中から導入したり、無理せず使いたい。強制されたくないライブラリ」です。
小規模〜中規模が対象になると考えています。
クリーンアーキテクチャ
直近で一番新しいのが、このクリーンアーキテクチャです。
有名IPゲームのプロトタイプにクリーンアーキテクチャを取り入れたことがありました。しかしながら、クリーンと言いながらも、待ち構えていたのは学習コストと分離しすぎたロジックでした。
というのも当時は「Clean Architecture 達人に学ぶソフトウェアの構造と設計」
という本が発売されたときで、一通り読んだものの、実装例が少なく場当たり的に組んでいましたし、DIライブラリはZenjectを使っており、お世辞にも快適に開発ができていたわけではありませんでした。

クリーンアーキテクチャは下請構造にそっくりです。
「うちが提案したゲームを作ってね! 設計? それは下請けのおたくらが考えてよね」
「第二下請けさん。悪いけど、DBとUIの設計してくれる? 仕様? うちのプログラムが動けばそれでいいから」
聞こえは悪いですが、実際には疎結合でテストが容易な設計です。
しかしながら、完璧な設計とはこの世にはなくて、必ずしわ寄せは起きます。
反復性
クリーンアーキテクチャが輝くのは閉ざされたレイヤーで反復開発ができることです。Presenterの人はひたすら集中してできるわけです。
ゲームは仕様(エンティティ)がすぐ変わります。柔軟に対応するには依存性をなくし、柔軟な対応が求められるわけです。
クリーンアーキテクチャは必然的に層の密度をInterfaceを利用して再現します。「完全な疎結合ではなく、レイヤーの制御」をテーマにしていると考えてください。
依存性の逆転の原則 (DIP)
レイヤーの制御とは、DIのことです。つまり、クリーンアーキテクチャはインターフェイスを大量に作って、DIを利用して疎結合を意識しながら、エンティティ(ゲームの面白いところ)を反復開発しながらやっていこうじゃないかと。そういうものですね。(間違ってたらごめんなさい)
そうはいったものの、これは理想論であって、現実はそう上手く行きません。
さきほど紹介した本も概念や理論は述べていますが、実装コードはかなり少ないのです。これは、理想論では簡単だけれども、それを実現・普及するには上級プログラマが必要であり、各種ライブラリも配備する必要があるということになります。
つまり、効率化と技術力はトレードオフになるのです。このことからも、アーキテクチャの目的がコストの掛からないコードという側面からみると、小規模に無理してクリーンアーキテクチャを推奨することは、必ずしも正しいとは言えないということになります。
大量のインターフェイス問題
インターフェイスが大量に作られるのはいいことです。なぜならロジックが分割され責務の所在がわかりやすくなるから。それにDI(後述)すればとても便利ですよね。
弊害もあります。コードを読み解く際に、クラスをクリックして実装を見に行くことはありますよね? もしインターフェイスだと処理が追えなくなります。
また、インターフェイス独自の問題がC#にはたくさんあります。
[MessagePack.Union(0, typeof(NyanClass))]
[MessagePack.Union(1, typeof(WanClass))]
public interface IUnion
{
}
[MessagePackObject]
public class NyanClass : IUnion
{
[Key(0)]
public int Zipcode { get; set; }
}
これはMessagePackというライブラリを使った例ですが、インターフェイスを使った場合、Unionという機能を使って「インターフェイスの具象クラスを指定」する必要があります。粒子が小さいと、それがどんなものかわからなくなっていきます。
[SerializeField] private ICat _cat;
このようなインスペクター表示の処理でさえ使用できなくなります。
ここで大事な教訓は、Unityに近い処理は密結合を許容するということです。
UnityにおけるDIの位置づけについて
アーキテクチャの話をすると、必ずDIが出てきます。
UnityのDIと、Asp.NETやMAUIのDIは本質的に異なります。
「DIはテストを楽にできるし、疎結合になるから導入するんだ」と思う人は、一歩引いて遠目から観察してほしいのです。
UnityのDIは本物のDIではなく、おまけです。テストが楽になる? これは副産物であって、狙いに行くものではありません。と、私は思います。
[Inject]
Unityには[Inject] というアトリビュートがあり、MonoVehaviorではできなかったDIが楽になりました。
でも、おかしなことになります。そもそも、DIってコンストラクタを経由するものであって、メソッドコンストラクタインジェクションを経由するのはおかしいのです。
依存関係の明確化
[MemoryTable("person"), MessagePackObject(true)]
public record Person : ITable
{
[PrimaryKey]
public int Id { get; init; }
public int Age { get; init; }
}
これはMasterMemoryを使ったコードです。MessagePackを扱いながらrecordを使っていますね。
この場合はrecordを使うのでコンストラクタの所在(生成コードに含まれる)initによる不変性が担保されます。
「どのインターフェイスが、どこの具象クラスに、どのコンスタラクタを通して、同期・非同期のどれを使って、どのタイミングで終わったのか」
これが一番知りたいことじゃないでしょうか。
[Inject]はこのうち、どのインターフェイスが同期的に使われたことしかわかりません。また、潜在的にはAwakeやStartより前に注入されるため、やはり特殊な処理といえます。
ライフタイム
結果的にどのタイミングで初期化されるのかという問題が残るため、VContainerあるいはZenjectを使う場合は、そのライフタイムに従う必要があります。
私はライブラリの一部にSceneFlowというものがあり、これがUnity内の少し違ったライフタイムを管理しています。
これはシーン移動やGameObjectの初期化など広範囲のサイクルを管理するもので、効率的に扱うにはオリジナルのDIが必要でした。
クリーンアーキテクチャといえども、Unityのコアに近づくと密結合が必ず必要になるということですね。
またVContainerは最速ですが、さきほどの[Inject]を始めとして、やはりチューニング幅には限界があります。割り切って通常のクラスを使う場面もあるでしょう。
私のDIは[Inject]を使わず、注入タイミングも独自タイミングなので、AwakeでもStart、await NextFrame()でも自由です。統一性はなくなりますが、途中から利用するDIという名目上、必須でした。
原則の話
アーキテクチャを語るためにはSOLIDの原則を守る必要があります。これはプログラマにおける聖書のようなもので、全員が守るべき原則です(一般常識なので詳細は省きます)
OOPの言語を扱う以上、避けては通れませんね。
結局、クリーンアーキテクチャやそれ以外の設計を取り入れたとしても、SOLIDを破れば、簡単に破綻します。しかしながら、破綻しないとできない効率性もまたあります。
聖書と表現したのはまさにここで、SOLID自体が抽象性の高い提案なので技術面の観点からイレギュラーを受け入れる事例もあるということになります。
Unity自体がちゃんとデザインパターンを提供してくれている
Unityは大原則としてコンポーネントパターンを全面に押し出したシステムです。
さっきからDIの話が出ていますが、実際はGetComponentだけでも、しっかりとした責務分離を果たしているのです。
私達はエコシステムにDIを利用したいからInjectを用意してもらっただけで、本来はコンポーネントパターンをうまく使うべきなのです。(特にADVライブラリのUtageは上手にComponentを扱っている)
シングルトンは悪じゃない
私が学生の頃、担任の先生に「シングルトンはバグの元だからできる限り使うなよ」と言われました。C++でメモリーリークに繋がった経験もしました。
やはりシングルトンはだめなのか。
Unityを触っているとシングルトン予備軍がたくさん実装されていることに気づきました。
たとえばDontDestroyOnload。これはシーンをまたいでも消えないGameObjectです。シーン切り替えUIをここに配置する人は多いでしょう。
UnityのPub/SubライブラリであるMessagePipeにはMessagePipeDiagnosticsInfoがありすべてのSubscriberを監視して解放忘れを予防しています。中央管理という側面からみると予備軍でしょう。
拡張メソッドはどうでしょうか?
UniRxやR3のAddToやRegisterToはどこからでも使えるメソッドです。Staticなアクセスだからこそ便利ですよね。
アーキテクチャを考えるときに、シングルトンがあってもいいと思うんです。
DDD
これはクリーンアーキテクチャのベースになった考えで、ドメイン駆動と言われるものです。要は、「ビジネスロジック・エンティティ・コア・ゲームの面白い部分」というようなど真ん中にあるものと、UI、データベース、動作という副産物を分けて考えようぜというものです。そのためには層のように分ける必要があり、レイヤードアーキテクチャの概念が生まれます。オニオンアーキテクチャなど色々あるわけですが、クリーンアーキテクチャもこの一つで、
「結局はさ、部品が一つ壊れたからって爆発するなって話だろ? 依存絶対ダメ」 という一言に尽きます。(言い方は違えど、本の著者も言っていた気がします)
でも、それは理想論です。ロケットの部品が一つ壊れたら爆発するでしょう。カーナビが壊れたらバック駐車できずにぶつけて大破するかもしれません。
どんなに責務を分離したところで、「利用する側が困ったら依存は必ず発生する」 という本質は変わりません。
MVP
DDDを利用するとMVP(もしくは多少依存があるMVC)が必要になります。
データと見た目はお互いを知らない。プレゼンターを介して操作されるべきだ。
シンプルです。ですが、裏方仕事をこなすプレゼンターは忙しすぎて困ります。
「お互いの面倒を見るのが辛い」
すると、依存したくなり、MVVMを考えるようになります。
MVVM
ここでUniRxやR3が活躍し、Observer パターンを利用したViewModelが使われます。
やっていることはシンプルで、プレゼンターは手紙だけ受取り、それを手渡すだけになります。つまり、データを直接代入するのではなくて、イベントをそのまま相手に送るだけでよくなりました。
MVPやMVVMもUIに関連することですが、これはもっと複雑になります。データはどこから来て、誰が教えてくれるのか。この問題は解決していないのです。
ReduxとFlux
Unityと外れますが、Web業界はカオスな世界です。TypeScriptが動的言語であるため、やりたい放題なコードができます。その結果、Fluxパターンが誕生しました。
-
ViewからActionが発行される(例:ユーザーがボタンをクリック)
-
ActionがDispatcherに送られる
-
Dispatcherが、Actionを関連するStore全てに配送する
-
StoreがActionを受け取り、自身の状態を更新する
-
Storeが状態の変更をViewに通知する
-
Viewが通知を受け取り、自身を更新(再描画)する
このように1番から6番まで順番にフロー制御するべきだと考えたのです。(単一フロー)
そして最大の特徴がグローバルステートであること。シングルトンです。
| ステート | シングルトンか |
|---|---|
| Action | x |
| Dispatcher | ◯ |
| Store | ◯ |
| View | x |
なぜStoreはシングルトンがいいのか。それは単一のデータがそこにあるという保証があるという事実です。セーブデータやキャラクター情報など、考えてみれば絶対に必要なデータはイジってもらえるフローを制御することで、原因を特定できるようになります。
例として、プレイヤーのHPを減らすのは誰が行うのか問題を考えます。
1.GameManager
2.敵
3.プレイヤー自身
さあ、どれが正解だと思いますか?
答えは4番のActionです。インチキですね。しかしながら、HPを減らすActionを定義すればデバッグは楽になります。1万行のクラスファイルがあったとしても、HPActionを修正したり、利用者を特定すればいいのです。
また、この考え方は身近にあり、サーバー/クライアントモデルにも通じます。オンラインゲームを考えてみましょう。FPSで打ち合いをしていても、結局はサーバーのデータを受信しているに過ぎません。いくら自分が不正をしても、サーバー側のHPこそが唯一の本物だからです。またHPを減らす処理もユーザーが指示を出すのではなく、サーバーの減らす処理(Action)が行っています。
さきほどのクイズを分解すると面白いことがわかります。GameManagerがやっていることはDispatcherの部分です。
また、プレイヤーがHPを減らすということは自己申告でありイベントとは言えません。
正解は敵が「プレイヤーにダメージを与えた」というActionを発行するのが正しいといえます。
データベースからみる
このようなFluxパターンは1:1という考えの大事さを教えてくれます。それはデータベースにも同じことが言えます。
ActiveRecordパターン
RubyのWeb開発ライブラリであるRuby on RailsにはActiveRecordがありますが、正確にはActiveRecordパターンを実装した機能を指します。
これはドメインとデータベースを密結合することで、迅速に処理が行えることになります。つまり、クラス名=データベースのように割り切ることで覚えることが減り、簡単に処理ができます。
簡単とは
簡単という言葉がでましたが、これは大事なポイントです。
簡単というのは糖衣構文のように回り道をショートカットしているのに過ぎません。つまり、本質的な解決は行っていないといえます。
対義語としてシンプルあるいは単純という言葉があります。
これはデータやフローを整理してわかりやすくすること。Fluxのように考えられた上で処理されます。しかしながら、シンプルゆえに妥協すべきことは多くボイラーテンプレートなどコード量が増えることも当然ながら発生します。
Repositoryパターン
さきほどのデータベースでは、密結合でした。対して疎結合にするにはRepositoryパターンを利用する必要があります。
これはDDDやクリーンアーキテクチャを意識した設計で処理をします。
LaravelやC#のEntity Frameworkで扱えます。
しかしながら、これも賛否両論で過剰な抽象化では?とよく討論されます。私はRails派でしたので、リポジトリは好みではありません。
過剰な抽象化
抽象化というのはC#でも言えることで、どこまでクラスをインターフェイスにして、Abstractにすべきかという問題もあります。
Rust言語のように継承ではなくトレイトを扱い、実質インターフェイスオンリーに振り切るのもアリですが、OOPの知見が通じず苦労したのを覚えています。
つまり、すべてをDDD・クリーンアーキテクチャに振り切ってはいけないということです。これは冒頭の結論にあるように全てが解決する答えはないという点につながります。
Zustand
FluxパターンのつながりでZustandを紹介します。とはいっても、これはFluxを継承したRedux。それをもっと使いやすくしたZustandなので実質、Fluxパターンです。
Jotai
同じ作者が作ったJotaiはAtomパターンを利用します。これは実装レベルを細かくしていき、ほどよいレベルで結晶化させるイメージです。たとえば、
playerAtom = Hp
catAtom = Tail
catHuman = (playerAtom, catAtom)
のようにすることで、無限につくれていきます。Atom自体がインターフェイスのように働き、別途Actionを定義することで具象クラスのような動作になります。
これは動的言語だからできる素敵なテクニックですが、Unityでもインターフェイスを細かくしていけば似たようなことは可能です。
問題としては単位が小さすぎたり、どこまで細分化するのか基準がなければ変数一つのインターフェイスもありえるので、チーム開発にはルールがいるということです。
データドリブン
少し話をずらして、任天堂のゼルダの伝説ティアキンをのぞいてみましょう。
ティアキンは紆余曲折あってデータドリブンになっています。
SoundManagerやGameManagerが指示を出してデータを渡すというよりは、データ自体が主導権を握っており依存関係を逆転して命令を出していることになります。
この辺はMessagePipeやMesageBrokerのPublish/Subscribeパターンといえます。正確にはObserverと同系列のMediatorパターンです。
データドリブンはその主体がデータなので、Fluxパターンと相性がよくなります。
1.View・ゲームイベント:トリガーに、Action(データ)を発行
2.Action:HealActionなど、何が起きたかのデータを保持
3.MessagePipe (Dispatcher):ActionをStoreへ配信。またはイベントを配信
4.Store:Actionを受け取り、R3のReactiveProperty(State)を更新
5.R3 (Subscribe):ViewがStateの変化を購読し、UIを再描画
6.イベントを受信したSoundManagerがBGMの音量を上げて盛り上げる
このようにすることで、データドリブンかつFluxパターンを維持したクリーンアーキテクチャを保守できます。
なぜStoreが必要なのか
私がStoreを気に入っている点は、Storeのデータは重要なものであり、保存される可能性が高いということです。また、MessagePipeでActionとStoreをCommandパターンとして利用すれば、ゲームリプレイなどの動作も可能になります。
Storeは単一のデータでありDIを使って初期化できます。
中身はクローズドになっており、変数へのアクセスはReactivePropertyやObservable、Subscriberなどに限られます。
ではStoreの中身はなにかというと、Packという単位のクラスになります。これはJotaiのAtomに近いでしょう。
またPackはシリアライズとMessagePackObjectを最低限のルールとして保証しています。
このようなコードがStoreで利用されます。
MessagePackObjectなのでサーバーとのやり取りや、シリアライズしてセーブデータにもできます。さらにSerializeFieldにすることでインスペクターに表示されるため、ゲームプレイ中にデータを改造することもできます。
こうすることで、何がゲームで特に重要であり、保存されるべきか、利用されるべきかがわかります。シーンに配置されたパンの枚数を保存したいという変わり者はいないでしょう。でも、プログラマから見えれば、それは保存可能であり、プロデューサーなりリードプログラマに仕様を聞かないとわかりません。逆に言えば、Packにパンの枚数が定義されていないのであれば、絶対にこのプロジェクトでは保存されるべきではないし、Streamを使ってサーバーから上書きされることもなければ、イベントの引数にもならないということが、はっきりと明確になるのです。
私なりのベストな設計
SAMA (Store-And-Messaging Adaptive Architecture)
ストア&メッセージング適応型アーキテクチャを提唱します。
Adaptiveという部分がミソで、無理やりストアやメッセージングを使うのではなく、状況に応じて使用しましょうということになります。これはUnityに近いコードは密結合をするべきという意味も含まれます。
ストアも単一で不変データを示すものではなく、任意の構成にするべきです。もっとも、時代はイミュータブルですので、できる限り担保してあげるのがよろしいかと。
メッセージングは実装に依存します。MessagePipeはOnNextのみ発行して、エラーは関知しません。これは相性がいいのですが、一方でエラー処理が必要なものやSubscriber自体の優先順位、await待ちによるデッドロックなど沼る要素があります。コアなゲームロジックのみメッセージングで行うべきで、それ以外はManagerに委譲すべきタイミングが多いでしょう。この見極めも難しいところです。
クリーンアーキテクチャはAdaptiveであるべき
全面的にクリーンアーキテクチャを支持しますが、それは規模によって部分的であるべきです。とくにUnityに近い部分は積極的に密結合で実装すべきであり、トレードオフとして諦めるべきです。
どうしてもUnityはクリーンアーキテクチャに適応できません。UnityはComponentパターンを使えばいい。無理してクリーンアーキテクチャを守る必要はないです。
しかし、C#レベルでのクリーンアーキテクチャは当然可能なので、しっかりと実装する。(Fluxやデータドリブンが例)MonoBehavior周りはクッションを置く。(自作DIなり、VContainerのInjectアトリビュートなりの妥協)
つぎにFluxパターンのように「大事な情報とはなにか」を明確にします。ゲームにおいてデータは一番重要です。なので、ティアキンがデータドリブンを選んだように、もう少し優遇してあげるべきです。どうしても私達はGameManagerやSoundMangerをボスとして扱い、データはその言うことを聞くしかありませんでした。そうではなくて、イベントやデータが来た場合にManagerとしてできる範囲で処理するのが望ましいと思います。
しかしながら、すべてのデータとはいいつつも、単一のデータというのは扱いが難しくなります。例えばPlayerStoreがあった場合、L4Dというゾンビゲームのようにプレイヤーは人になったり、ゾンビに変わったりする場合があります。すると、イベントの引数によって処理が分岐されることになり、かえってデメリットが増してしまいます。
データドリブンとはいいつつも、その適応範囲を最小限にしながら開発を行い、Dispatcherの一部をManagerに移譲することも大事になるでしょう。
シングルトンは悪くありません。責任の分離という観点からすればこれ以上とない分け方です。ActiveRecordを見ればわかるように、1:1のデメリットを考慮しても、それが開発者にウケてここまでRubyやRailsが普及したわけです。Rubyの黒魔術や動的言語の柔軟性を知っていれば、疎結合やきれいなコードだけが絶対に正しいということにはなりません。
自分やプロジェクトにあったレベルまで、パターンを落とす
これこそが、私が一番言いたいことです。
無理してDIを入れなくてもいいし、クリーンアーキテクチャを取り入れなくていい。
データドリブンが合うゲームもあれば、ADVのようにExcelデータで十分なものもある。
ただ、重要なデータに関してはActionを定義して、「だれからアクセスされて、どのデータを変更したのか」を明確にすること。これはSOLIDの原則にも当てはまります。
こういったことさえ守れば、アーキテクチャとして十分だと思います。
というのも、クリーンアーキテクチャのクリーンとは、システムの変更容易性とテスト容易性のこと。
ドメインやらPresenterなど小難しいことは理解できなくても、この2つを求める限り、それはクリーンアーキテクチャ(レイヤードアーキテクチャ)になるのです。
ZustandやJotaiの作者さんも、必要があれば別のステートライブラリを作るという記事も拝見しました。
任天堂のゲームタイトルでさえも、依存するツールはなく、プロジェクトに応じてツールを選択し、その都度、アーキテクチャを変えています。
しかしながら、クリーンアーキテクチャは平成自体のテクニックを総まとめにしたものであり、Metaが提唱したFluxもこれをベースにしていることから見ても、確実に参考にすべき(土台)です。
もっともな話を言えば、最初に掲載した円状のイラストも一つのアーキテクチャ例であり、必ずしもあのレイヤーを守るべきではないですし、そもそもゲームでは通用しないでしょう。
昔のゲームなどはスレッドこそが一番大きなレイヤーでした。きれいなロジック層ではなく、並列スレッドを使いこなして最高のゲームを提供する。どちらも間違いではありません。
最後に
アーキテクチャとUnityを仲介する、クッションのお話が出ました。
私なりの答えとして、Navigateパターン、Procedureパターン、Actorパターン、AnchorEventパターンなど作りました。
こんな感じでオレオレパターンを作って、オリジナルのアーキテクチャを作るのが正解だし、楽しいということですね。

