はじめに
Unity向けアーキテクチャフレームワーク「Lighthouse」を作ったので、
どういう経緯で作ったとか、何を狙って作ったんだとか、自分の考えを整理するために書きました。
長ったらしい紹介文を読み始める前に、まずはWebGLで動くデモを触ってみましょう!
GitHub
Lighthouseとは
Unity向けアーキテクチャフレームワークです。
Unityである程度の規模以上のゲームを開発する際に必ず必要となるシーン基盤と、
そのシーン基盤を前提としたミドルレイヤーの機能を提供します。
予め必要な機能が提供されているため、
あなたのチームは煩わしい基盤開発に時間も人材も使わず、
コンテンツを作るところからゲーム開発を始めることが出来ます。
自分用のオレオレフレームワークですが、
「ゲーム会社のプロジェクトを跨いだ共通機能として提供出来るレベルのもの」
と自負しています。
せっかく作って公開したので、他の人に使ってもらうためにpros/consぐらい書こうと思います。
導入に対する不安
- 学習コストが高そう
- いざ導入した後にバグがあったらどうしよう
- 基盤側の融通が効かなさそう
このページでは、
① これらの不安を解消
② Lighthouseがどのようにして開発効率を上げるのか
の2つをお伝えしようと思います。
①-1: 学習コストは高い?
はい、学習コストは高いです。
Lighthouseのベースとなる「クリーンアーキテクチャ」と「DI」は、
ただこの2つだけでも理解に時間がかかります。
初めてコードを見た人からすると意図のわからないインターフェースを大量に作っているように見えます。
しかしこういった一見不可解な構造は、機能同士の依存関係を制御・分離するために行っていることで、この依存関係を綺麗に制御出来ていると 「新しい機能を実装するために既存のコードを作り直さないといけない」 となった際の修正範囲を局所化出来ます。
このリスク回避はゲームが巨大であれば巨大であるほど、運用が長ければ長いほど、「実装工数が減る」という形で優位に働きます。
簡単に言うと技術的負債が生まれにくい構造ということです。
そして
Lighthouseはサンプルとして、この概念を理解する手助けになります。
本来この手の概念は抽象的な表現が大量に出てきて考え方を理解するのは難しいのですが、
Lighthouseでは既に基礎実装が組まれていて、シーンやダイアログなどをテンプレートから自動生成する仕組みがあるため、
極端な話、利用していれば身体で覚えられます。
学習コストが高いからと忌避するのではなく、上記の概念を学習するきっかけとしてLighthouseを利用してみてはいかがでしょうか。
「高そう」じゃなくて「高い」という確信に変わったと思うので、
不安は解消されたと言ってよいでしょう。
おすすめ記事:
①-2: 導入した後にバグがあったら?
細かいバグは、認識していないだけでおそらく結構あります。すいません。早めに直します。
①-3: 基盤側はプロジェクト側で拡張・修正出来る?
LighthouseやLighthouse.Extendsは全てDIで結合しているため、
プロジェクト側のLifetimeScopeで組み立てる時に任意のクラスに差し替える事が出来ます。
ここはDIの良い所で、もし仮に致命的なバグがLighthouseにあっても、
プロジェクト側で修正したクラスをInjectionすることで回避できるということです。
Lighthouseはソースコードの改変も可能なライセンスなので、
「とりあえず導入してみて問題がありそうなところをチームのエンジニアにどんどん差し替えてもらう」
という戦略も出来るのではないでしょうか。
② Lighthouseがどのようにして開発効率を上げるのか
「Lighthouse」はUnityのシーン基盤と、それに乗った形で実装されたミドルレイヤーの機能を提供するアーキテクチャフレームワークです。
ゲーム開発にほぼ必ず必須となるダイアログ、入力制御、アニメーション制御、ローカライズ基盤が予め実装されているため、プロジェクトの要件に合致すればそのまま導入出来るでしょう。
そしてこれらはゲーム開発プロジェクトの黎明期にシニアエンジニアが全力で開発しなければならない機能と合致します。
おそらく日本のゲームの開発のほとんどは、企画段階からプロトタイプ開発を経て、 「このゲームのメインの遊びはこれだ!」と定めてからゲームサイクルを構築して実装を始めるでしょう。
しかし大抵の場合、このスケジュールの中に「エンジニアがゲームのコンテンツ拡張・量産化に耐えうるシステムを構築する」という予定は存在しません。
やらなければいけないこといくらでもあるのに、人材の投入とリリース予定時期が先に決まり、デザイナーから「こんな画面を実装したいが可能か」という問い合わせに答え、企画から「この画面は3Dにしてシームレスに遷移出来るようにしてほしい」という要望が届き、新しく参画するエンジニアの作業が浮かないようにあらかじめ画面や機能が作れる状態にプロジェクトを整える必要があります。
そんな状態で実装されるシーン基盤が最初から本調子で回るわけもなく、シニアエンジニアはゲーム開発が進む中必死に基盤のメンテナンスと拡張をし続け、なんとかそのプロジェクトがうまくいっても次のプロジェクトに持ち越せる汎用的な実装は驚くほど少なく再び負の連鎖は続くわけです。これは私個人の想像であって、経験に基づく体験談ではありません。
「Lighthouse」はそんな架空の不幸を現実にさせないために、
「あらかじめ汎用的なシーン基盤を作りおきしてしまおう」 という目的の元作られました。
もしこの戦略が正しく作用するのであれば、シニアエンジニアはLighthouseが標準でカバーしきれない部分を塞ぐという作業のみに対応すれば良く、浮いたリソースをコンテンツ開発に注ぐことで、実現出来るゲームの表現や遊びの幅はぐっと広がることになります。
「開発効率を上げる」とは単にプログラミングの速度を早めるだけでなく、
プロジェクト全体の工程効率 を上げることを目的としています。
そこであなたが次に気になるのは「じゃあLighthouseはそんなパワーを本当に備えているのか?」だと思います。
Lighthouse SceneManager
Lighthouseがどんなものか、どれだけ能弁に語っても文章だけでは「導入しよう」とはならないでしょう。
ここはエンジニアらしくLighthouseのScene基盤を簡単に解説します。
シーン構成:MainScene と ModuleScene
シーンは MainScene と ModuleScene の 2 種類に分けています。
| 種別 | 役割 |
|---|---|
| MainScene | 画面の主役。1 遷移に必ず 1 つが「現在のシーン」になる |
| ModuleScene | MainScene に付随して動作するサブシーン。ヘッダー・フッターのような共通 UI など |
これらは SceneGroup としてまとめて扱います。
var group = new SceneGroup(new Dictionary<MainSceneId, ModuleSceneId[]>
{
{ SceneIds.Home, new[] { SceneIds.Header, SceneIds.Footer } },
{ SceneIds.Detail, new[] { SceneIds.Header } },
});
同じ SceneGroup 内の遷移ではシーンのアンロード・ロードが発生しないので、グループの設計がパフォーマンスに直結します。
ゲームの作り方にもよりますが、最近のゲームの演出としてよくある
「ユーザーがUIを操作すると3Dキャラクターがポーズを切り替える」みたいな表現も、
この仕組みで再現出来ます。
MainScene or ModuleSceneの選択は機能の規模の大小ではなく役割で選ぶ必要があります。
シーンの遷移方法
遷移時に 3 種類の TransitionType を指定できます。
| 種別 | 動作 |
|---|---|
Exclusive |
アウト → ロード → イン の順で処理する標準的な遷移 |
Cross |
次シーンを先にロードしてからクロスフェードする |
Auto |
遷移先が同じ SceneGroup 内なら Cross、それ以外は Exclusive を自動選択 |
基本的に Auto を使っておけば SceneGroup の設計に合わせた遷移が自動で選ばれます。
await sceneManager.TransitionScene(new DetailTransitionData(), TransitionType.Auto);
Phase / Step パイプライン
遷移処理は Phase(フェーズ) と Step(ステップ) の 2 層構造になっています。
Phase
└── Step[] ─ WhenAll で並列実行
Phase は遷移の大きなまとまりで、CanTransitionIntercept フラグを持ちます。処理中に別の遷移が割り込めるかどうかをフェーズ単位で制御できます。
Step は実際の非同期処理の単位で、ISceneTransitionContext を通じてマネージャーや差分情報にアクセスします。
独自のシーケンスが必要な場合は ISceneTransitionSequenceProvider を差し替えるだけで対応できます。
これがDIのいいところです。
シーンの基底クラス
シーンは用途に応じた基底クラスを継承します。
SceneBase(MonoBehaviour)
├── MainSceneBase
│ ├── MainSceneBase<TTransitionData> ← 型付き遷移データを受け取る
│ └── CanvasMainSceneBase<T> ← Canvas + CanvasGroup を持つメインシーン
└── ModuleSceneBase
└── CanvasModuleSceneBase ← Canvas + CanvasGroup を持つモジュールシーン
ライフサイクル
OnLoad() ← アディティブロード直後(シーンがアクティブになる前)
OnSetup() ← 初回 Enter 前に 1 度だけ
OnEnter(data, ctx) ← 毎回の Enter 時(Back で戻ってきたときも含む)
OnLeave(ctx) ← Leave 時
OnUnload() ← アンロード直前
OnSceneTransitionFinished(diff) ← 遷移の全フェーズ完了後
MainSceneBase<TTransitionData> を継承すると遷移データを型安全に受け取れます。
public class HomeScene : MainSceneBase<HomeTransitionData>
{
protected override async UniTask OnEnter(
HomeTransitionData data,
ISceneTransitionContext context,
CancellationToken ct)
{
// data に遷移時のパラメータが入っている
}
}
CameraStack管理
URP の Camera Stack(Base / Overlay)は SceneCameraManager が遷移ごとに再構築します。各シーンは SceneCamera コンポーネントを持ち、SceneCameraType(3D / 2D / UI)と深度に基づいてスタックの順序が自動で決まります。
CanvasMainSceneBase / CanvasModuleSceneBase を継承していれば、ResolveCameraStep のタイミングで UI カメラが Canvas に自動で注入されます。
シーンスタックによるBackScene処理と履歴管理
SceneManager は遷移履歴を Stack<TransitionDataBase> で持っています。BackScene() を呼ぶと、スタックを遡って CanTransition && CanBackTransition が両方 true の最初のエントリへ遷移します。
public class ModalTransitionData : TransitionDataBase
{
public override MainSceneId MainSceneId => SceneIds.Modal;
// CanBackTransition = false にすると BackScene() のスキップ対象になる
}
TransitionScene に backMainSceneId を渡すと、指定シーンより古い履歴をスタックからまとめて削除できます。タブ切り替えのように「A → B へ遷移して Back したら A に戻る」ケースに使います。
Lighthouse.Extendsで提供しているInputLayerと組み合わせると、スマホアプリのバックキー対応はそれだけで解決出来ます。
整理すると
| 概念 | 役割 |
|---|---|
| SceneGroup | MainScene と ModuleScene の構成を定義する |
| TransitionType | 遷移演出の種別を指定する(Auto で自動判定) |
| Phase / Step | 遷移処理を宣言的・並列的に組み立てる |
| SceneBase | ライフサイクルフックを提供するシーンの基底クラス |
| SceneCameraManager | URP Camera Stack を遷移ごとに再構築する |
| SceneManager | 履歴スタックを管理して Back 遷移を解決する |
Phase / Step による宣言的なパイプラインと、SceneGroup の差分ベースのロード管理がこのシーンシステムの核になっています。
紹介一覧
▼ 概要の紹介記事です