はじめに

株式会社WinTicketでUnity&XRエンジニアをしています。加田です。私たちスポーツ映像テック事業部では、新たなスポーツ映像の創出を目指し、日々開発をしています。現在、データの可視化にはゲームエンジンのUnityを用いて、リアルタイムCG合成をしています。以前の記事では、オンプレミスで運用していた競輪オリジナル映像WINLIVEの描画システムについて解説しました。

そこから1年が経ち、複数のスポーツとクラウドへの対応を進めてきました。本記事では、1つのUnityプロジェクトで複数のスポーツ、および各スポーツ内の複数のプロダクトをサポートし、かつオンプレミスとクラウドに対応するために実践した設計戦略について共有します。

この記事で学べること

  • マルチプラットフォーム・マルチプロダクトのUnityプロジェクト運用方法
  • VContainerを使ったDI設計パターン
  • GitHub ActionsとEC2 Image Builderを使った自動デプロイの構築方法

想定読者

本記事は、以下のような読者を想定しています。

  • Unityで複数のプロダクトやプラットフォームを扱う必要があるエンジニア
  • DIコンテナを活用した設計に興味があるUnity開発者
  • CI/CDパイプラインの構築や自動デプロイの実装を検討しているエンジニア
  • モノレポ開発や大規模Unityプロジェクトの運用方法を学びたい方

目次

複数プロダクトの共存

開発初期はプロダクトが競輪のHP可視化映像だけでした。そこから、同じ競輪というスポーツ内で風可視化という別のプロダクトが増え、さらに他スポーツが増え、今後も様々なスポーツに対応することが予想されました。
プロダクトごとにリポジトリを分けて開発する方法や共通部分だけをSubmoduleに切り出し開発する手法も考えましたが、
複数のプロダクトを1つのリポジトリ(Unityプロジェクト)で管理するモノレポ開発として進めることにしました。
管理するリポジトリの減少によりメンテナンスコストが下がる点を大きな理由としています。
また、Unityで複数のプロジェクトを扱う場合、アセットが増えるとバイナリサイズが大きくなりやすいという問題があります。
そこに対しては、各プロダクトのアセットはSceneに紐づいているため、不必要なSceneをビルドに含めないことで回避しました。

映像を出力する環境には以下の2つがあります。

環境 OS 映像出力方法
オンプレミス Windows UltraStudio HD Mini/SDI
クラウド Ubuntu NDI

Platform

オンプレミスでは出力デバイスにUltraStudio HD Miniを利用し、SDI出力をしています。有料となりますがUnity組み込み用のプラグインがAsset Storeに用意されています。
クラウドではNDIを利用し、ネットワーク経由で映像出力をしています。また、NDIの出力プラグインにはKlakNDIを利用しています。

競輪は全国に40以上の競輪場があり、毎日約10場で開催が行われています。これをすべてオンプレミスでカバーするには大量のハードウェアを保有・運用する必要が発生してしまいます。その課題を解決するために、競輪に関連するプロダクトではクラウドで映像制作をしています。また、競輪ではテロップの出し入れは自動化されており、トラブル時以外は操作が不要なつくりになっています。
一方、スポーツによっては状況に応じて素早くテロップを出し入れする必要がありました。そのようなスポーツの場合は手動によるオペレーションレスポンスを重視してオンプレミスによるシステム構築をしました。

なお、クラウドへのシステム移行についてはAWS Summit 2025でチームメンバーが講演をしております。併せて見ていただくことで、本記事の理解が深まる内容となっております。

Assembly Definition Filesによるモジュール分離

複数のスポーツ、および各スポーツ内の複数のプロダクトを1つのUnityプロジェクトで管理するため、Assembly Definition Files(.asmdef)を使ってモジュールを分離しました。

Assets/Project/
├── 01Keirin/          # 競輪モジュール
│   └── Scripts/
│       └── Keirin.asmdef
├── 02OtherSport/      # その他スポーツモジュール
│   └── Scripts/
│       └── OtherSport.asmdef
└── Scripts/           # 共通コード
    └── Common.asmdef

この設計により、以下のメリットが得られました。

  • ビルド時間の短縮:変更の影響範囲を限定できるため、ビルド時間が短縮
  • 名前空間の明確化:各モジュールの責務が明確になり、コードの可読性が向上
  • 依存関係の可視化.asmdefファイルで依存関係が明確になり、循環参照を防止

VContainerを使ったDI設計

以前の記事のアーキテクチャの章で紹介した通り、全体の設計にはMVVM(Model-View-ViewModel)+Repositoryパターンを利用しています。これまでは独自のシステムでDIを構築していましたが、今回、VContainerを利用してDIの実装をしました。

実装例

VContainerではLifetimeScopeを継承したクラスを作り、ここにInjectしたいクラスを登録していくのが基本的な使い方になります。本プロジェクトではUnityのSceneとLifetimeScopeの関係を下記のように構築しました。競輪の例を示します。

LifetimeScope

EntrySceneという各プロダクトのエントリーポイントとなるSceneを用意します。共通でInjectしたいクラスを登録するためのKeirinLifetimeScopeをシーン内のGameObjectにアサインしておきます。KeirinLifetimeScopeDon't Destroy属性にしておくことでアプリが立ち上がっている間は生存し続けます。つまり、シーン遷移してもKeirinLifetimeScopeに常にアクセスできます。また、KeirinLifetimeScopeでは主にRepositoryクラスをRegisterしています。
遷移先のシーンにもLifetimeScopeを用意しておき、Parentに先ほどのKeirinLifetimeScopeを指定します。こうすることでシーン遷移してもRepositoryにアクセスができます。

下図のように各シーンのLifetimeScopeにはViewModelが登録されています。シーン上のMonoBehaviourを継承して作られたUIやエフェクトはViewModelをInjectし、ViewModel経由でRepositoryのデータを受け取ります。

MainScene

LifetimeScope/ViewModel/UIのサンプルコードは下記のようになります。

using VContainer;
using VContainer.Unity;

//Don't Destroyのアプリ共通LifetimeScope
public class KeirinLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        //Singletonで登録することでRepositoryのインスタンスを1つに固定
        builder.Register<PlayerDataRepository>(Lifetime.Singleton);
        builder.Register<EffectRepository>(Lifetime.Singleton);

        //LifetimeScopeはMonoBehaviourの継承なのでDon't Destroy属性にできる
        DontDestroyOnLoad(this.gameObject);
    }
}
using VContainer;
using VContainer.Unity;

//シーン上に配置するLifetimeScope
public class MainLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        //ViewModelはLifetime.Transientを設定して、複数インスタンス生成する
        builder.Register<PlayerEffectViewModel>(Lifetime.Transient);
        builder.Register<TimeControlViewModel>(Lifetime.Transient);

        //destroyCancellationTokenをWithParameter経由で渡せる
        var dct = this.destroyCancellationToken;
        builder.Register<RaceInfoViewModel>(Lifetime.Transient).WithParameter(dct);
    }
}
using UnityEngine;
using R3;

//ViewとRepositoryをつなぐViewModel
public class PlayerEffectViewModel
{
    readonly PlayerDataRepository playerDataRepository;
    readonly EffectRepository effectRepository;

    //コンストラクタインジェクションで参照を持たせる
    public PlayerEffectViewModel(PlayerDataRepository playerDataRepository, EffectRepository effectRepository)
    {
        this.playerDataRepository = playerDataRepository;
        this.effectRepository = effectRepository;
    }

    //UIから使うためのObservableを公開する
    public Observable<string> PlayerName => this.playerDataRepository.PlayerName;

    public Observable<bool> VisibleEffect => this.effectRepository.Visible;
}
using UnityEngine;
using UnityEngine.UI;
using R3;
using TMPro;

//エフェクト(見た目)を制御するクラス
public class PlayerEffect : MonoBehaviour
{
    //Inject属性を付けておくと、自動的に参照を解決してくれる
    [Inject] PlayerEffectViewModel viewModel;

    [SerializeField] TextMeshProUGUI playerNameText;
    [SerializeField] CanvasGroup playerEffectRootCanvasGroup;

    void Start()
    {
       this.viewModel.PlayerName.Subscribe(x =>
       {
            this.playerNameText.text = x;
       }).AddTo(this);

       this.viewModel.VisibleEffect.Subscribe(visible =>
       {
            this.playerEffectRootCanvasGroup.alpha = visible ? 1f : 0f;
       }).AddTo(this);
    }
}

VContainerを使うと[Inject]属性さえ書けば自動的に参照を解決してくれるので、SerializeFieldで参照させたり、シングルトンが乱立するようなことを防げます。また、ViewModelからRepositoryへの参照はコンストラクタに書いておくと、参照を解決してくれます(コンストラクタインジェクション)。このような設計にすることでViewModelやRepositoryの単体テストがしやすくなり、プロダクトの品質を高めることができました。

ResolveAccesserパターン

本プロジェクトではEditor拡張を多数利用してテストをしています。しかし、エディター拡張ではMonoBehaviourのDIが使えないため、ResolveAccesserを導入しました。これにより、エディター拡張でもDIを活用できます。

public class ResolveAccesser : MonoBehaviour
{
    [Inject] IObjectResolver objectResolver;

    public void Inject(object target)
    {
        this.objectResolver.Inject(target);
    }

    public static ResolveAccesser Instance { get; private set; }

    private void Awake()
    {
        Instance = this;
    }
}

下図のようにエントリーポイントのLifetimeScopeと同じGameObjectにアタッチして使います。

ResolveAccesser

下記のように、エディター拡張からResolveAccesser経由でRepositoryへアクセスが可能になりました。

public class RaceEventSimulator : EditorWindow
{
    [Inject] TelopRepository telopRepository = null;
    [Inject] PlayerEffectRepository effectRepository = null;

    void OnGUI()
    {
        if (this.effectRepository == null)
        {
            var accesser = ResolveAccesser.Instance;
            if (accesser == null) return;
            accesser.Inject(this);
        }
        // Repositoryを使用
    }
}

メリット

VContainerの導入により、以下のメリットが得られました。

  • 依存関係の明確化:DIコンテナで依存関係を一元管理
  • テスト容易性の向上:モック注入が容易になり、単体テストの作成が効率的に
  • エディター拡張でもDIを活用可能ResolveAccesserパターンにより、エディター拡張でもDIを活用

CI/CDによる自動デプロイ

本プロジェクトではWindowsのオンプレミス運用・Ubuntuのクラウド環境を使い分けています。GitHub Actionsを構築し、メインブランチにマージした際、ビルドとデプロイをしています。
デプロイフローは下記のようになっています。

デプロイフロー

共通処理・Unityバイナリビルド

Windows・Ubuntuどちらのバイナリを作る場合もGameCIを利用して描画用バイナリのビルドをしています。
プロダクトごとに分かれたGitHub ActionsのJobとUnityのビルドスクリプトで構築されています。

例えば風可視化のビルドスクリプトは下記のようになっています。
Editor開発時は開発しやすいように全てのシーンがBuild ProfilesのScene Listに入っています。
これをそのままビルドしてしまうと、プロダクトによっては不必要なシーンもビルドに含まれてしまいます。
これを防ぐために、ビルド前に必要なシーンだけ含まれるように調整をしています。

using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
using System.Linq;

/// <summary>
/// WindVisualizeをビルドするためのクラス(GitHub Actionsから呼ばれる)
/// </summary>
public class WindVisualizeBuilder
{
    [MenuItem("Tools/Build/WindVisualize")]
    public static void Build()
    {
        PlayerSettings.productName = "WindVisualize";
        //必要なシーンだけsceneListに含める
        var sceneList = new string[]
        {
            "Assets/Project/01Keirin/Scenes/0_KeirinEntry.unity",
            "Assets/Project/01Keirin/Scenes/WebAPIConnect.unity",
            "Assets/Project/01Keirin/Scenes/InputJson.unity",
            "Assets/Project/01Keirin/Scenes/Empty.unity",
            "Assets/Project/01Keirin/Scenes/WindVisualize.unity"
        };

        var option = new BuildPlayerOptions();
        option.scenes = sceneList;
#if UNITY_STANDALONE_OSX
        option.target = BuildTarget.StandaloneOSX;
        option.locationPathName = "build/StandaloneOSX/WindVisualize.app";
#elif UNITY_STANDALONE_LINUX
        option.target = BuildTarget.StandaloneLinux64;
        option.locationPathName = "build/StandaloneLinux64/WindVisualize";
#else
        option.target = BuildTarget.StandaloneWindows64;
        option.locationPathName = "build/StandaloneWindows64/WindVisualize.exe";
#endif
        var result = BuildPipeline.BuildPlayer(option);
        if(result.summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded)
        {
            Debug.Log("BUILD SUCCEED");
            Debug.Log(result.summary.outputPath);
            Debug.Log($"{result.summary.totalSize:N}bytes");
            Debug.Log($"{result.summary.totalTime.TotalSeconds}seconds");
        }
        else
        {
            Debug.LogError("BUILD FAILED");
            foreach (var step in result.steps)
            {
                Debug.LogError(step);
            }
        }

        EditorApplication.Exit(result.summary.result == BuildResult.Succeeded ? 0 : 1);
    }
}

オンプレミス用Windowsバイナリ

オンプレミス用Windowsバイナリの場合、ビルドしたバイナリをZIPに固めてSlackにアップロードしています。
これはオペレーターがGitHubへログインすることなくバイナリを使えるようにするためです。

クラウド用Ubuntuバイナリ

クラウド用のビルドの最終アウトプットはAMI(Amazon Machine Image)を作ることです。
このAMIはGUI付きUbuntuのEC2インスタンスで稼働するように構成されています。
このインスタンスの中にビルドした描画用バイナリをコピーし、インスタンス立ち上げ時に自動起動するよう設定しています。
また、リモートデスクトップアプリであるAmazon DCVをインストール済みです。
Amazon DCVを利用することでローカルマシンから、稼働しているインスタンスのデスクトップを確認できます。
見るだけでなく、ローカルマシンからマウスやキーボード操作も可能なので、クラウド上で稼働している描画アプリの操作も実現しています。

EC2構成

上記の構成をコードベースで管理するためにEC2 Image Builderを利用してAMIの構築をしています。
EC2 Image Builderは、ベースイメージに対して設定手順を定義し、AMIを自動作成するサービスです。
パッケージのインストールや設定コマンドなどをまとめたコンポーネントがあり、
ベースイメージとコンポーネントを組み合わせて レシピ を作成します。
レシピは「どのイメージに、どの設定を適用するか」をまとめた設計図にあたります。さらに、どのインスタンスタイプを使うかという設定をインフラストラクチャ設定が持っています。
最後に、そのレシピを使って Image Builder を実行すると、定義どおりの設定が反映された AMI が作成されます。
なお、今回のユースケースでは、EC2 Image BuilderにおいてAWS Marketplaceで提供されているAMIをベースイメージとして使用できないという制約がありました。
そこでUbuntuにAmazon DCVを自前でインストールしたAMIを作成しておき、そちらをベースイメージとして利用しました。
インストールスクリプトはこちらのページで紹介されています。AWS公式ドキュメントとしてはこちらのページになります。
本プロジェクトではコンポーネント内で下記の処理をしています。

  • ビルドした描画用バイナリをS3からダウンロードし、インスタンス内に設置
  • OS起動時に描画用バイナリが自動的に起動するように設定
  • その他必要なソフトウェアのインストール
  • OS設定の書き換え(スクリーンセーバーオフ)など

下記のYAMLはコンポーネントの例です。S3からダウンロードして展開、タイムゾーンの設定をしています。

name: BuildComponent
description: AMI Builder
schemaVersion: 1.0

parameters:
  - S3Path:
      type: string
      default: "Linux.zip"

phases:
  - name: build
    steps:
      - name: ContentsDeploy
        action: S3Download
        maxAttempts: 3
        inputs:
          - source: s3://storage-shared/{{ S3Path }}
            destination: /home/ubuntu/Build.zip
            overwrite: true
      - name: ContentCheck
        action: ExecuteBash
        inputs:
          commands:
            - echo "Content Check"
            - cd /home/ubuntu/
            - unzip Build.zip
            - timedatectl set-timezone Asia/Tokyo

これでImage Builderの準備ができました。
Github Actionsからaws imagebuilder create-imageコマンドでレシピとインフラストラクチャ設定を指定し、AMIを作成しています。
AMIの作成には数十分ほどかかるため、ポーリングで状況を確認しています。

- name: Create AMI
    id: ami
    run: |
        CREATE_IMAGE=$(aws imagebuilder create-image \
        --image-recipe-arn arn:aws:imagebuilder:ap-northeast-1:123456789:image-recipe/build-recipe/1.0.18 \
        --infrastructure-configuration-arn arn:aws:imagebuilder:ap-northeast-1:123456789:infrastructure-configuration/ami-pipeline \
        --region ap-northeast-1 \
        --tags Hash=${{ github.sha }})
        echo $CREATE_IMAGE
        ImageBuildVersion=$(echo $CREATE_IMAGE | jq '.imageBuildVersionArn' )
        echo [$(date)] $ImageBuildVersion
        ImageVersion="${ImageBuildVersion//\"/}"
        echo "The value of imageBuildVersionArn is: $ImageVersion"

        #Polling
        while true; do
        # API を呼び出すコマンド
        response=$(aws imagebuilder get-image --image-build-version-arn $ImageVersion --region ap-northeast-1)
        echo $response > response.txt
        status=$(jq '.image.state.status' response.txt)
        echo [$(date)] $status

        # レスポンスを確認し、条件を満たしたらループを抜ける
        if [[ "$status" == *"AVAILABLE"* ]]; then
            echo "API returned success!"
            ami_id=$(jq '.image.outputResources.amis[0].image' response.txt)
            ami_id_fixed="${ami_id//\"/}"
            echo $ami_id_fixed
            # AMI IDを環境変数に保存
            echo "image_id=$ami_id_fixed" >> $GITHUB_OUTPUT
            break
        fi

        # 一定時間待機
        sleep 60
        done

ビルド完了後、AMI にタグを付与してバージョニングを行っています。
その後、AMI IDをParameter Storeに登録することで、他のAWSサービスから参照できるようにしています。
なお、複数のAWSアカウントからAMI IDを参照することを想定しているため、Parameter StoreではAdvanced tierを利用しています。

# AMIにタグ付け
aws ec2 create-tags --resources ${{ steps.ami.outputs.image_id }} \
--tags \
Key=Hash,Value=${{ github.sha }} \
Key=BuildNumber,Value="${{ github.event.workflow_run.run_number }}" \
--region ap-northeast-1

#Parameter StoreにAMI IDを登録
aws ssm put-parameter \
--name "/ami_id/latest" \
--type "String" \
--value ${{ steps.ami.outputs.image_id }} \
--data-type "aws:ec2:image" --tier "Advanced" --overwrite \
--region ap-northeast-1

このようにUbuntu用バイナリのビルドからAMIの作成まで一貫して自動で作成しています。人の手が入らないことで安定したAMIの作成が実現できました。
また、AMI IDをParameter Storeに登録することで運用しているAMIが明確になり、不具合対応やアップデート対応に役立てています。

まとめ

本記事では、1つのUnityプロジェクトで複数のスポーツ、および各スポーツ内の複数のプロダクトをサポートし、かつオンプレミスとクラウドに対応する設計戦略について共有しました。

主なポイントは以下の通りです。

  1. Assembly Definition Filesによるモジュール分離:複数プロダクトを1つのリポジトリで管理しつつ、モジュールを分離することで保守性を向上
  2. VContainerを使ったDI設計:依存関係の管理を一元化し、テスト容易性とエディター拡張でのDI活用を実現
  3. CI/CDによる自動デプロイ:Image Builderを使った自動AMI作成により、デプロイプロセスを自動化

また、通信プロトコルについては、Protocol Buffersを使った通信設計として別記事で詳しく解説します。

これらの改善により、保守性と拡張性が向上し、新規スポーツの追加や環境の変更に対応しやすくなりました。今後も改善を続けていきます。

参考リンク

アバター画像
株式会社WinTicket所属 Unity&XRエンジニア。現在は競輪中継映像『WINLIVE』の描画パートを担当