Vela Games では、CircleCI を使って新作のマルチプレイヤーオンライン協力プレイ (MOCO) ゲーム『Project-V』を開発しています。これは、多人数参加型オンライン (MMO) 型のダンジョン探索ゲームに、マルチプレイヤーオンラインバトルアリーナ (MOBA) ゲームならではのチームプレイとスキルを融合させたものです。

この記事ではチュートリアル形式で、Unreal Engine (UE) の BuildGraph テクノロジーと CircleCI のダイナミックコンフィグおよびセルフホストランナーを組み合わせてビルドステップを並列実行し、Unreal Engine ゲームのビルド時間を大幅に短縮する方法について説明します。

本記事で説明しない事項:

  1. Unreal Engine 5 のソースコード入りのカスタム Amazon マシンイメージ (AMI) を作成する方法
  2. 未処理のタスク数に応じてセルフホストランナーを自動スケーリングする方法(英語)

本チュートリアルで示すコードはすべて、こちらの GitHub リポジトリから入手できます。

背景

Vela Games では、これまで Jenkins の継続的インテグレーション (CI) インフラストラクチャを利用していましたが、2022 年に『Project-V』の開発規模が急速に拡大し、チームのメンバーも増えたために、コミット量とパイプライン構造の面でボトルネックが生じ始めました。あらゆるステップ (ビルド、クック、パッケージ化、ゲームサーバーのデプロイ) を順次実行していたものの、所要時間が最長で 2.5 時間にも及ぶようになったのです。

Vela Games プロジェクト V キャラクター

そのうえ、当社では複数のテクノロジー分野にわたるゲームサポートプロジェクトの開発も進めています。こうしたプロジェクトの中に、Jenkins では自動化のハードルが想像以上に高いものがあったので、エンジニアの作業を簡素化し、プロセスの自動化ではなくプロダクトの開発に注力できるようなソリューションが必要でした。

そこで、これらの問題を解決するため、Jenkins パイプラインを CircleCI に移行しようと決めました。まず注目したのは、『Project-V』のビルドプロセスです。各ステージを並列化してさまざまなエージェントに分散させ、ゲームのビルド時間をできるだけ短縮することを目標に設定しました。

そして検討の結果、Unreal Engine の BuildGraph テクノロジーと、CircleCI のダイナミックコンフィグおよびセルフホストランナーを組み合わせることにしました。この組み合わせのおかげで、ビルド時間を最高で 85% も短縮できました。

本記事でご紹介するチュートリアルは、主にこうした作業の経験をベースとしています。このチュートリアルを完了することで、ダイナミックコンフィグセルフホストランナーを併用し、さらに BuildGraph により複数のランナーにプロセスを分散させることで、Unreal Engine 5 プロジェクトのビルド、クック、パッケージ化の各ステージのスピードを高める方法を身につけられます。

前提条件

このチュートリアルを進めるには、以下の準備が必要です。

  1. CircleCI アカウントを用意する
  2. AWS アカウントを作成し、EC2 インスタンスのデプロイ方法を身につける
  3. Unreal Engine 5 のソースコード入りの Linux と Windows の AWS AMI を作成する
  4. 全セルフホストランナー間でデータを共有するための高速共有ストレージを用意する (Vela Games では AWS FSx for OpenZFS を使用)
  5. Unreal Engine 5 プロジェクトを作成する (本記事では Unreal Engine 付属の FPS サンプルを使用)

BuildGraph について

BuildGraph は、Unreal Engine に付属するスクリプトベースのビルド自動化システムであり、ビルディングブロックには UE プロジェクトと同じくグラフを採用しています。

BuildGraph のスクリプトは XML で記述し、ノードおよびノードどうしの依存関係を定義します。

各ノードは順次実行型のタスクで構成されており、タスクは (設定に応じて) 入力を受け取り出力を生成します。これらの入力と出力は、#MyTag という形式のタグで定義します。

入力の仕様を定義すると、BuildGraph で各ノード上での設定済みタスクの実行に必要な依存関係を特定するための依存関係グラフが作成されます。それぞれのノードは異なる物理ノード上で実行される可能性があるので、この依存関係は共有ストレージを通じてジョブ間で共有されます。

BuildGraph スクリプトは、以下の要素を使用して記述します。

  • タスク (Task): ビルドプロセス中に実行するアクションです (コンパイル、クックなど)。
  • ノード (Node): 出力の生成のために実行するタスクを順序付きでまとめたシーケンスです。依存関係によっては、あるノードを実行するために別のノードのタスクを先に実行する必要があります。
  • エージェント (Agent): 同じマシン上で実行するノードのグループです (ビルドシステムの一部として実行する場合)。ローカルでビルドを実行する場合、エージェントの効果はありません。
  • トリガー (Trigger): 手動での介入後にのみ実行可能なグループをまとめるコンテナです。
  • アグリゲート (Aggregate): ノードと名前付きの出力を、1 つの名前で参照できるように集約するグループです。

このチュートリアルでは、タスク、ノード、エージェントのみを使用します。また、BuildGraph の ForEach のようなフロー制御ノードや条件文も使用することで、スクリプトの動的性を高め、入力に応じて処理を変えてみます。

BuildGraph は UnrealBuildTool、AutomationTool、およびエディターと緊密に連携しているので、複数のプラットフォームにわたってゲームのコンパイル、クック、パッケージ化を自動で制御できます。

本記事の執筆時点では、BuildGraph の使用には以下の注意事項があります。

  • 現在、XML スクリプトを配置できるのは Unreal Engine ディレクトリ内のみに限られる
  • 生成されたアーティファクトが実際には変更されていなくても、変更されたとみなされ BuildGraph が失敗するバグがある

こうした制限があるため、当社では、社内リポジトリ内の XML スクリプトを使用しファイルの “変更” を許容するパッチを BuildGraph にあてています。Redpoint Studios の June Rhodes 氏がこうした問題を発見し、パッチを作成してくれました。

このパッチはこちらの GitHub リポジトリにあります。

ダイナミックコンフィグ

CircleCI を既に使っていても、ワークフローの定義は YAML だけで行っており、ダイナミックコンフィグのことはよく知らないという方もいるでしょう。

ダイナミックコンフィグは、setup というワークフローを先に実行し、このワークフロー内で独自のワークフロー全体を定義できる機能です。出力が CircleCI の YAML 設定ファイルとして認められているものになるのであれば、どのプログラミング言語でも使用できます。

生成された YAML 設定ファイルを CircleCI API の /api/v2/pipeline/continue エンドポイントに渡すと、ダイナミックワークフローが生成されます。この手順では、プロセスを大幅に簡略化できる continuation Orb を使用します。

このチュートリアルでは、ダイナミックコンフィグを活用し、XML スクリプト内の定義に従って BuildGraph で実行が必要と判断される処理に基づきワークフロー全体を定義します。

: この機能を使用するには、[Project Settings (プロジェクト設定)] > [Advanced (詳細設定)] > [Enable dynamic config using setup workflows (セットアップワークフローによるダイナミックコンフィグを有効にする)] を有効にします。

ダイナミックコンフィグ ワークフロー

セルフホストランナー

今回重要になるもう一つの機能が、セルフホストランナーです。これは、ユーザー自身のインフラストラクチャ (今回は AWS の EC2 インスタンス) 上で CircleCI ジョブを実行できる機能です。今回のビルドプロセスでは、クラウド版 CircleCI プラットフォームでは提供されていないきわめて大規模なリソースタイプが必要になります。そこで、目的の独自のコンピューティングリソースにアクセスするために、セルフホストランナーを使用します。

セルフホストランナーを使用する場合、ワークフローの実行に必要なソフトウェアを用意してインフラストラクチャを構築することは、ユーザー自身の担当になります。今回のシナリオでは、これらのランナーのデプロイに使用する AMI 内に、Unreal Engine ソースコードをパッケージ化する必要があります。

ただし、本チュートリアルでは、UE コード入りの AMI の作成方法については扱いません。AMI を作成する場合は Packer を使用することをおすすめします。UE のソースコードは Epic Games の Web サイトで入手できます。UE エンジンの構築方法については、Unreal Engine の GitHub リポジトリにあるドキュメントをご覧ください。

はじめよう

このチュートリアルでは、Unreal Engine 付属の FPS サンプルを使用します。

はじめに、Windows と Linux でプロジェクトのコンパイル、クック、パッケージ化を行う BuildGraph スクリプトを作成します。次に、BuildGraph スクリプトを CircleCI のダイナミックコンフィグに変換する Python スクリプトを作成します。

この BuildGraph スクリプトですべての主要なパイプラインロジックを管理し、実行する処理と実行場所を決定します。BuildGraph の実行は、依存関係グラフのみを BuildGraph から JSON として出力する特別なフラグを使用して、ダイナミックコンフィグの setup ステージで行います。出力された JSON をパラメーターとして Python 変換レイヤーに渡し、YAML 形式の最終版ワークフローを出力します。

最後に、ゲームバイナリが含まれる ZIP ファイルを生成し、このバイナリを CircleCI にアーティファクトとしてアップロードします。

セルフホストランナーをデプロイする

このセクションでは、ワークフローの実行環境となるセルフホストランナーを設定し、デプロイします。これらのセルフホストランナーで、サンプルゲームのビルド、クック、パッケージ化を行います。

まず、Linux インスタンス用および Windows インスタンス用のリソースクラスを作成します。これは CircleCI CLIセルフホストランナーの Web UI で行えますが、今回は CLI を使用します。

$ circleci runner resource-class create vela-games/linux-runner-test "For CircleCI Tutorial" --generate-token

 api:
     auth_token: f5dfdc41d7973864e9f625a897b755fea5ac17ecdf519732b81b87a309467bf20698fbdbc04a8b94
 +------------------------------+-----------------------+
 |        RESOURCE CLASS        |      DESCRIPTION      |
 +------------------------------+-----------------------+
 | vela-games/linux-runner-test | For CircleCI Tutorial |
 +------------------------------+-----------------------+

どちらのツールを使用する場合でも、リソースクラスを作成すると auth_token が提供されます。このトークンは、インスタンスにエージェントを設定する際に必要になります。

次に、リソースクラスと auth_token の設定を行うインスタンスをデプロイします。

Vela Games では、Infrastructure-As-Code (IaC) と GitOps 型ワークフロー、そして変更のピアレビューの組み合わせが最良であると考えています。この考えには Terraform がぴったりなので、セルフホストランナーのデプロイにはこれを使っています。

当社における Terraform を使ったセルフホストランナーのデプロイ方法については、サンプルをオープンソースとして公開しています。このサンプルの大部分は、Vela Games 社内の使用ツールに基づいています。 こちらの GitHub リポジトリで公開していますので、ぜひフォークしてお使いください。

この Terraform モジュールでは、ランナーのリソースクラスごとに Auto Scaling グループを使用しています。このグループでローンチ設定を定義すれば、必要な EC2 インスタンスすべてで再利用できます。そのため、インスタンスのスケールインとスケールアウトを必要なタイミングできわめて効率的に行えます。また、今後ジョブキューに応じてランナーを自動スケーリングするための土台にもなります。

BuildGraph を使用するには、実行するジョブ間でアーティファクトを共有するための共有ストレージが必要です。当社では、以下のメリットを踏まえて AWS FSx for OpenZFS を採用しています。

  • NFS プロトコル経由でファイルシステムをマウント可能なので、どの OS でも利用できる。当社では Windows マシンと Linux マシンの両方でビルドを行っているので、この点は重要です。
  • ファイルアクセスのレイテンシが非常に小さい。Unreal Engine でビルド用にプルすべきファイルをチェックするメタデータ処理をほぼ瞬時に実行できるので、このメリットはとても大きいものです。

サンプルの Terraform モジュールを適用すると、次の処理が行われます。

  • 設定したリソースクラスごとに Auto Scaling グループを作成する。
  • デフォルト設定の Auto Scaling グループに、cron 式に基づきスケールイン/スケールアウトを定期実行するアクションを設定してデプロイする。この処理は、パイプラインの実行が想定されていない時にはインスタンスの実行を止めて、費用を節約するために定義しています。
  • FSx for OpenZFS ファイルシステムをデプロイする。このファイルシステムは、共有 DDC をセットアップし、実行するジョブ間でアーティファクトを共有するために使用します。
  • CircleCI エージェントのインストールと FSx のマウント設定を行うユーザーデータスクリプトで各 EC2 インスタンスをデプロイする。Windows の場合、このスクリプトはパイプラインの実行時に FSx をマウントするための PowerShell スクリプトを作成します。Linux の場合は、ユーザーデータの実行の一環として FSx をマウントします。

この Terraform モジュールを使用するには、tfvars ファイルを作成し、このファイルでデプロイ用の subnet_idsvpc_id を設定してください。

各リソースクラス用の auth_tokens は、circleci_auth_tokens マップでマップとして設定する必要があります。この値は次の形式で設定します。

{
   "namespace/resource-class": "your-token"
 }

トークンは機密値として扱ってください。Vela Games では Terraform Cloud を使用し、ワークスペースで auth_token マップを機密値として設定しています。

デプロイするランナーの一覧は、runners.auto.tfvars ファイル内で設定します。以下に、このファイルの使用例を示します。

runners = [{
   name = "namespace/linux-resource-class"
   instance_type = "c6a.8xlarge"
   os = "windows"
   ami = "ami-id"
   root_volume_size = 2000
   spot_instance = true
   asg = {
     min_size = 0
     max_size = 10
     desired_capacity = 0
   }
   key_name = "key-name"
   scale_out_recurrence = "0 6 * * MON-FRI"
   scale_in_recurrence = "0 20 * * MON-FRI"
 },
 {
   name = "namespace/windows-resource-class"
   instance_type = "c6a.8xlarge"
   os = "linux"
   root_volume_size = 2000
   spot_instance = true
   ami = "ami-id"
   asg = {
     min_size = 0
     max_size = 10
     desired_capacity = 0
   }
   key_name = "key-name"
   scale_out_recurrence = "0 6 * * MON-FRI"
   scale_in_recurrence = "0 20 * * MON-FRI"
 }]

name はマップから auth_token を取得するためのものなので、値には先ほど作成したリソースクラス名を設定してください。

TF モジュールを使わずにエージェントのデプロイを行う場合は、セルフホストランナーのインストール方法に関するこちらのドキュメントをご覧ください。また、すべてのランナーを連携しジョブ間で中間ファイルを共有できるように、高速の共有ストレージソリューションを用意してください。

BuildGraph スクリプトを作成する

BuildGraph スクリプトの作成方法に興味がない場合は、次のセクションまで飛ばしてかまいません。完成版 BuildGraph スクリプトはこちらにあります。

前述のとおり、今回は Unreal Engine 5.0 付属の FPS サンプルを使用します。UE エディターで新規プロジェクトをゼロから作成するか、または GitHub にあるサンプルを使用してください。

前セクションでセルフホストランナーのインフラストラクチャの運用準備が完了したので、次はパイプライン処理のすべてを扱う BuildGraph スクリプトを作成しましょう。

このスクリプトの目的は次のとおりです。

  • エディターをクック用にコンパイルする
  • エディターのバイナリファイルを使用してクックを行う
  • ゲームをコンパイルする
  • ゲームをパッケージ化する

これらの処理を、Linux と Windows の両方について行います。そのため、BuildGraph に渡すパラメーターも作成します。

プロジェクトで Tools という名前のフォルダーを新しく作成し、その中に BuildGraph.xml ファイルを作成してください。

top ノードを作成する

はじめに、root/top ノードを以下のように定義します。

<?xml version='1.0' ?>
<BuildGraph xmlns="https://urldefense.com/v3/__http://www.epicgames.com/BuildGraph__;!!Nhk69END!aBSNnLIYx2O0jwdVeKzousD7gSfvOOTn-WAKDf2Na2S7ASXwRArUoWi96Oz43LxeIvRXbHHznq8eRFDg2SxYgjI$  " xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.epicgames.com/BuildGraph ./Schema.xsd" >
<BuildGraph/>

<BuildGraph> の内側に、すべてのスクリプトを記述していきます。

入力オプションを作成する

まずは、<Option> ノードを追加します。このノードは CLI から渡し、スクリプト内のさまざまな処理に使用します。

<Option> ノードの値は正規表現で規定でき、デフォルト値を設定することも可能です。また、値をセミコロン区切りのリストにすれば、<ForEach> ノード内でリストを使用して新しいノードを動的に作成できます。

<?xml version='1.0' ?>
<BuildGraph xmlns="https://urldefense.com/v3/__http://www.epicgames.com/BuildGraph__;!!Nhk69END!aBSNnLIYx2O0jwdVeKzousD7gSfvOOTn-WAKDf2Na2S7ASXwRArUoWi96Oz43LxeIvRXbHHznq8eRFDg2SxYgjI$  " xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.epicgames.com/BuildGraph ./Schema.xsd" >

  <Option Name="ProjectRoot" Restrict=".*" DefaultValue="" Description="Path to the directory that contains the .uproject" />

  <Option Name="UProjectPath" Restrict=".*" DefaultValue="" Description="Path to the .uproject file" />

  <Option Name="ExecuteBuild" Restrict="true|false" DefaultValue="true" Description="Whether the build steps should be executed" />

  <Option Name="EditorTarget" Restrict="[^ ]+" DefaultValue="FirstPersonGameEditor" Description="Name of the editor target to be built, to be used for cooking" />
  <Option Name="GameTargets" Restrict="[^ ]*" DefaultValue="FirstPersonGame" Description="List of game targets to build, e.g. UE4Game" />

  <Option Name="GameTargetPlatformsCookedOnWin" Restrict="[^ ]*" DefaultValue="Win64" Description="List of the game target platforms to cook on win64 for, separated by semicolons, eg. Win64;Win32;Android"/>
  <Option Name="GameTargetPlatformsCookedOnLinux" Restrict="[^ ]*" DefaultValue="Linux" Description="List of the game target platforms to cook on linux for, separated by semicolons, eg. Win64;Win32;Android"/>
  <Option Name="GameTargetPlatformsBuiltOnWin" Restrict="[^ ]*" DefaultValue="Win64" Description="List of the game target platforms to build on win64 for, separated by semicolons, eg. Win64;Win32;Android"/>
  <Option Name="GameTargetPlatformsBuiltOnLinux" Restrict="[^ ]*" DefaultValue="Linux" Description="List of the game target platforms to build on linux for, separated by semicolons, eg. Win64;Win32;Android"/>

  <Option Name="GameConfigurations" Restrict="[^ ]*" DefaultValue="Development" Description="List of configurations to build the game targets for, e.g. Development;Shipping" />

  <Option Name="StageDirectory" Restrict=".+" DefaultValue="dist" Description="The path under which to place all of the staged builds" />

<BuildGraph/>

上記コードでは、オプションのコンテキストが把握できるように、それぞれに Description を追加しています。

また、各 <Option> ノードを見るとわかるように、今回のサンプルでは、プラットフォームごとにコンパイル・クック・パッケージ化のプロセスを分けています。UE では一部プラットフォーム向けにクロスコンパイルがサポートされていますが、今回はセルフホストランナーの OS ごとにジョブを実行する当社のアプローチをご紹介します。

プロパティを作成する

次は <Property> ノードを追加します。このノードは変数のようなものであり、スクリプトのさまざまなステージで読み取りおよび書き込みを行えます。今回は、先ほど作成した <Option> ノードの一部について <ForEach> でイテレーションを行うので、作成したタグすべてをこの <Property> ノードを使って集約します。

<Property Name="GameBinaries" Value="" />
<Property Name="GameCookedContent" Value="" />
<Property Name="GameStaged" Value="" />
<Property Name="GamePatched" Value="" />

Agent ノードを作成する

<Agent> ノードでは、指定したタイプのインスタンス (Linux または Windows) 上で実行するノード一式を定義します。

ノードの定義は次のように行います。

<Agent Name="Windows Build" Type="UEWindowsRunner">
<Agent/>

NameType には任意の値を指定できます。今回、Name には、ノード内で実行する処理がわかりやすくなる名前を設定しています。このノードで最も重要なのは Type です。この値に基づいて、<Agent> 内のすべてのジョブの実行環境となるセルフホストランナーのリソースクラスを決定するからです。この処理は、Python 変換レイヤーで行います。

Type には次の 2 つの値のみを設定します。

  • UEWindowsRunner - 実行環境に Windows を使用する
  • UELinuxRunner - 実行環境に Linux を使用する

<Agent> ノードは、以下のとおり合計 7 つ作成します。

  • Linux ビルド用ノード x 3 (Linux 上でのコンパイル、クック、パッケージ化)
  • Windows ビルド用ノード x 3 (Windows 上でのコンパイル、クック、パッケージ化)
  • BuildGraph にすべてのジョブを実行させるアグリゲートノード x 1

Windows エージェントを作成する (ビルド、クック、パッケージ化)

このセクションでは、まず、エディターをコンパイルし生成出力を #EditorBinaries としてタグ付けするノードを定義します。タグ付けの目的は、後で出力を使用してアセットのクックを行うためです。

次に、<ForEach> を使用してイテレーションを行います。イテレーションは、Windows ノード上でのビルドを許可したすべてのターゲットプラットフォームについて、ゲームの各ビルド設定 (Development や Shipping) で実行します。

これらの処理により (<Option> の入力に応じて) 複数のノードが作成されます。これらのノードは、Windows 上で複数の設定およびプラットフォームについてゲームのコンパイルを並列に実行し、依存関係にある後続ジョブとの共有用に生成出力をタグ付けします。

<Agent> 内のノードはいずれもお互いに独立しているので、すべて並列に実行されます。

<!-- Targets that we will execute on a Windows machine. -->
<Agent Name="Windows Build" Type="UEWindowsRunner">

  <!-- Compile the editor for Windows (necessary for cook later) -->
  <Node Name="Compile $(EditorTarget) Win64" Produces="#EditorBinaries">
    <Compile Target="$(EditorTarget)" Platform="Win64" Configuration="Development" Tag="#EditorBinaries" Arguments="-Project=&quot;$(UProjectPath)&quot;"/>
  </Node>

  <!-- Compile the game (targeting the Game target, not Client) -->
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsBuiltOnWin)">
    <ForEach Name="TargetConfiguration" Values="$(GameConfigurations)">
      <Node Name="Compile $(GameTargets) $(TargetPlatform) $(TargetConfiguration)" Produces="#GameBinaries_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration)">
        <Compile Target="$(GameTargets)" Platform="$(TargetPlatform)" Configuration="$(TargetConfiguration)" Tag="#GameBinaries_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration)" Arguments="-Project=&quot;$(UProjectPath)&quot;"/>
        <Tag Files="#GameBinaries_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration)" Filter="*.target" With="#GameReceipts_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration)"/>
        <SanitizeReceipt Files="#GameReceipts_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration)" />
      </Node>
      <Property Name="GameBinaries" Value="$(GameBinaries)#GameBinaries_$(GameTargets)_$(TargetPlatform)_$(TargetConfiguration);"/>
    </ForEach>
  </ForEach>
</Agent>

ここでのポイントは、ノードのプロパティ内での変数補間を許可していることです。

次に、Windows 上でアセットをクックする 2 つ目のエージェントを定義します。

<!-- Targets that we will execute on a Windows machine. -->
<Agent Name="Windows Cook" Type="UEWindowsRunner">
  <!-- Cook for game platforms (targeting the Game target, not Client) -->
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsCookedOnWin)">
    <Node Name="Cook Game $(TargetPlatform) Win64" Requires="#EditorBinaries" Produces="#GameCookedContent_$(TargetPlatform)">
      <Property Name="CookPlatform" Value="$(TargetPlatform)" />
      <Property Name="CookPlatform" Value="Windows" If="'$(CookPlatform)' == 'Win64'" />
      <Property Name="CookPlatform" Value="$(CookPlatform)" If="(('$(CookPlatform)' == 'Windows') or ('$(CookPlatform)' == 'Mac') or ('$(CookPlatform)' == 'Linux'))" />
      <Cook Project="$(UProjectPath)" Platform="$(CookPlatform)" Arguments="-Compressed" Tag="#GameCookedContent_$(TargetPlatform)" />
    </Node>
    <Property Name="GameCookedContent" Value="$(GameCookedContent)#GameCookedContent_$(TargetPlatform);"/>
  </ForEach>
</Agent>

ここでは、Windows 上でのクックを許可したプラットフォームごとにイテレーションを行っています。さらに、Requires="#EditorBinaries" と指定することで、はじめにコンパイルする Windows 用エディターとこのノードとの依存関係を設定しています。

イテレーションのたびに、<Cook> タスクを使用してアセットをクックし、後のパッケージ化用に生成出力をタグ付けしています。

最後に、Windows 上でパッケージ化を行う 3 つ目のエージェントを定義します。

<!-- Targets that we will execute on a Windows machine. -->
<Agent Name="Windows Pak and Stage" Type="UEWindowsRunner">
  <!-- Pak and stage the game (targeting the Game target, not Client) -->
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsBuiltOnWin)">
    <ForEach Name="TargetConfiguration" Values="$(GameConfigurations)">
      <Node Name="Pak and Stage $(GameTarget) $(TargetPlatform) $(TargetConfiguration)" Requires="#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration);#GameCookedContent_$(TargetPlatform)" Produces="#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" >
        <Property Name="StagePlatform" Value="$(TargetPlatform)" />
        <Property Name="StagePlatform" Value="Windows" If="'$(StagePlatform)' == 'Win64'" />
        <Property Name="DisableCodeSign" Value="" />
        <Property Name="DisableCodeSign" Value="-NoCodeSign" If="('$(TargetPlatform)' == 'Win64') or ('$(TargetPlatform)' == 'Mac') or ('$(TargetPlatform)' == 'Linux')" />
        <Spawn Exe="c:\UnrealEngine\Engine\Build\BatchFiles\RunUAT.bat" Arguments="BuildCookRun -project=$(UProjectPath) -nop4 $(DisableCodeSign) -platform=$(TargetPlatform) -clientconfig=$(TargetConfiguration) -SkipCook -cook -pak -stage -stagingdirectory=$(StageDirectory) -compressed -unattended -stdlog" />
        <Zip FromDir="$(StageDirectory)\$(StagePlatform)" ZipFile="$(ProjectRoot)\dist_win64.zip" />
        <Tag BaseDir="$(StageDirectory)\$(StagePlatform)" Files="..." With="#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" />
      </Node>
      <Property Name="GameStaged" Value="$(GameStaged)#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration);"  />
    </ForEach>
  </ForEach>
</Agent>

コンパイルステージと同様に、ここでも Windows でのパッケージ化を強化したプラットフォームごとにイテレーションを行います。ただし、今回はコンパイルの設定ごとにもイテレーションを行い、コンパイル済みのゲームを必須条件として、クック済みのアセットを配布用にパッケージ化します。

本記事の執筆時点では、ゲームをパッケージ化できる BuildGraph ネイティブのタスクはありません。そのため、従来の RunUATBuildCookRun コマンドを呼び出しています。

その後、<Zip> タスクを使用して最終出力を圧縮しています。これが、アーティファクトとして保存するファイルになります。

この処理のポイントは、以下のように条件文に応じて <Property> で値を操作していることです。

<Property Name="StagePlatform" Value="$(TargetPlatform)" />
<Property Name="StagePlatform" Value="Windows" If="'$(StagePlatform)' == 'Win64'" />

今回のシナリオでは、Win64 という値を、UE でサポートされる Windows に変換しています。

Linux エージェントを作成する (ビルド、クック、パッケージ化)

Linux ビルドについても、ステップの定義プロセスは Windows とほぼ同じです。ただし、Tag の名前、AgentType の値、および Linux エージェント上の Unreal Engine の配置パスを変更します。

<!-- Targets that we will execute on a Linux machine. -->
<Agent Name="Linux Build" Type="UELinuxRunner">

  <!-- Compile the editor for Linux (necessary for cook later) -->
  <Node Name="Compile $(EditorTarget) Linux" Produces="#LinuxEditorBinaries">
    <Compile Target="$(EditorTarget)" Platform="Linux" Configuration="Development" Tag="#LinuxEditorBinaries" Arguments="-Project=&quot;$(UProjectPath)&quot;"/>
  </Node>

  <!-- Compile the game (targeting the Game target, not Client) -->
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsBuiltOnLinux)">
    <ForEach Name="TargetConfiguration" Values="$(GameConfigurations)">
      <Node Name="Compile $(GameTarget) $(TargetPlatform) $(TargetConfiguration)" Produces="#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)">
        <Compile Target="$(GameTarget)" Platform="$(TargetPlatform)" Configuration="$(TargetConfiguration)" Tag="#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" Arguments="-Project=&quot;$(UProjectPath)&quot;"/>
        <Tag Files="#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" Filter="*.target" With="#GameReceipts_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)"/>
        <SanitizeReceipt Files="#GameReceipts_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" />
      </Node>
      <Property Name="GameBinaries" Value="$(GameBinaries)#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration);"/>
    </ForEach>
  </ForEach>
</Agent>

<Agent Name="Linux Cook" Type="UELinuxRunner">
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsCookedOnLinux)">
    <Node Name="Cook Game $(TargetPlatform) Linux" Requires="#LinuxEditorBinaries" Produces="#GameCookedContent_$(TargetPlatform)">
      <Property Name="CookPlatform" Value="$(TargetPlatform)" />
      <Property Name="CookPlatform" Value="Windows" If="'$(CookPlatform)' == 'Win64'" />
      <Property Name="CookPlatform" Value="$(CookPlatform)" If="(('$(CookPlatform)' == 'Windows') or ('$(CookPlatform)' == 'Mac') or ('$(CookPlatform)' == 'Linux'))" />
      <Cook Project="$(UProjectPath)" Platform="$(CookPlatform)" Arguments="-Compressed" Tag="#GameCookedContent_$(TargetPlatform)" />
    </Node>
    <Property Name="GameCookedContent" Value="$(GameCookedContent)#GameCookedContent_$(TargetPlatform);"/>
  </ForEach>
</Agent>

<Agent Name="Linux Pak and Stage" Type="UELinuxRunner">
  <!-- Pak and stage the dedicated server -->
  <ForEach Name="TargetPlatform" Values="$(GameTargetPlatformsBuiltOnLinux)">
    <ForEach Name="TargetConfiguration" Values="$(GameConfigurations)">
      <Node Name="Pak and Stage $(GameTarget) $(TargetPlatform) $(TargetConfiguration)" Requires="#GameBinaries_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration);#GameCookedContent_$(TargetPlatform)"  Produces="#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)">
        <Property Name="StagePlatform" Value="$(TargetPlatform)"/>
        <Property Name="DisableCodeSign" Value="" />
        <Property Name="DisableCodeSign" Value="-NoCodeSign" If="('$(TargetPlatform)' == 'Win64') or ('$(TargetPlatform)' == 'Mac') or ('$(TargetPlatform)' == 'Linux')" />
        <Spawn Exe="/home/ubuntu/UnrealEngine/Engine/Build/BatchFiles/RunUAT.sh" Arguments="BuildCookRun -project=$(UProjectPath) -nop4 $(DisableCodeSign) -platform=$(TargetPlatform) -clientconfig=$(TargetConfiguration) -SkipCook -cook -pak -stage -stagingdirectory=$(StageDirectory) -compressed -unattended -stdlog" />
        <Zip FromDir="$(StageDirectory)/$(StagePlatform)" ZipFile="$(ProjectRoot)/dist_linux.zip" />
        <Tag BaseDir="$(StageDirectory)/$(StagePlatform)" Files="..." With="#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration)" />
      </Node>
      <Property Name="GameStaged" Value="$(GameStaged)#GameStaged_$(GameTarget)_$(TargetPlatform)_$(TargetConfiguration);" />
    </ForEach>
  </ForEach>
</Agent>

ご覧のとおり、主な変更点は、AgentType の値を UELinuxRunner にしたことと、すべてのパスを Linux 用にしたことです。

集約エージェント

最後に、これまでに定義したすべてのタスクを集約するダミーエージェントを作成します。このエージェントは、setup ワークフロー内で、全ノードの実行を BuildGraph に指示するために使用します。

<Agent Name="All" Type="UEWindowsRunner">
  <!-- Node that we just use to easily execute all required nodes -->
  <Node Name="End" Requires="$(GameStaged)">
  </Node>
</Agent>

完成版の BuildGraph スクリプト

このセクションでは、以下のステップを生成する BuildGraph スクリプトを作成しました。

  • UE エディターをクック用にコンパイルする
  • ゲームアセットをクックする
  • ゲームをコンパイルする
  • アセットとバイナリを 1 つの配布ファイルにパッケージ化する

また、これらすべてのステップを、Windows と Linux の両プラットフォーム用に定義しました。

完成版の BuildGraph スクリプトは、こちらのリポジトリでご覧いただけます。

変換レイヤー

本セクションについて、チュートリアル抜きで実際の変換レイヤーのコードが見たいという方は、こちらをご覧ください。

BuildGraph スクリプトが完成したので、次はこの XML スクリプトを CircleCI の YAML 形式のワークフローに変換する方法を考えなくてはなりません。

今回は、そのために Python 変換レイヤーを作成します。このレイヤー内では、以下のクラスを作成します。

  1. 基底クラス Runner: OS 共通のコードが設定されたすべてのパイプラインステップを処理します。今回は BuildGraph を Linux と Windows の両方で実行するので、OSごとに使用するシェルが異なり、一部のステップ (環境変数の設定や読み取りなど) も異なることを考慮する必要があります。
  2. UEWindowsRunner クラス: Runner を継承し、すべての Windows 固有コードを含みます。
  3. UELinuxRunner クラス: Runner を継承し、すべての Linux 固有コードを含みます。

お気づきの方もいると思いますが、各クラス名は、BuildGraph スクリプトで定義した AgentType と同じです。これは偶然ではありません。リフレクションを使用してクラスをインスタンス化するためです。

それでは、Tools 内に buildgraph-to-circleci ディレクトリを作成して、この中に以下のものを作成していきましょう。

  • buildgraph-to-circleci.py スクリプト
  • common.py スクリプト
  • runners ディレクトリ。このディレクトリ内には以下を作成します。
    • __init__.py - runners ディレクトリ内のクラスを公開する
    • runner.py - 基底クラスであり、CircleCI のワークフローロジックすべてを含む
    • ue_linux_runner.py - OS 固有のコードを記述した Linux クラス
    • ue_windows_runner.py - OS 固有のコードを記述した Windows クラス

まずは、common.py で以下の共通関数を作成します。

# common.py

# This will add support for multiline string in PyYAML
def yaml_multiline_string_pipe(dumper, data):
  text_list = [line.lstrip().rstrip() for line in data.splitlines()]
  fixed_data = "\n".join(text_list)
  if len(text_list) > 1:
      return dumper.represent_scalar('tag:yaml.org,2002:str', fixed_data, style="|")
  return dumper.represent_scalar('tag:yaml.org,2002:str', fixed_data)

# Will use this to sanitize job names, removing spaces with hyphens.
def sanitize_job_name(name):
  return name.replace(' ', '-').lower()

上記コードでは、他の Python ファイルで使用する関数を 2 つ定義しています。1 つ目は、最終版の CircleCI ワークフロー用に YAML ファイルの複数行の文字列をサポートする関数であり、2つ目はスペースをハイフンに置き換えることでジョブ名のサニタイジングを行う関数です。

setup ワークフローで実行するメインのスクリプトは buildgraph-to-circleci.py です。このスクリプトでは、BuildGraph からエクスポートされた JSON ファイルを受け取り、リフレクションにより適切なランナークラスをインスタンス化して、インスタンス化したランナーで build-game ワークフローのステップを生成します。

#!/usr/bin/env python3

import json
import yaml
import argparse
import os
import sys
import importlib

from common import yaml_multiline_string_pipe, sanitize_job_name

# We create some arguments that we need to execute the script.
parser = argparse.ArgumentParser()
parser.add_argument("--json-graph", required=True, help="Path to Graph in JSON format")
parser.add_argument("--git-branch", default=os.getenv("CIRCLE_BRANCH", ""), help="Branch that triggered the pipeline")
parser.add_argument("--git-commit", default=os.getenv("CIRCLE_SHA1", ""), help="Commit that triggered the pipeline")
args = parser.parse_args()

if not args.git_branch or not args.git_commit:
  print("--git-branch and --git-commit are required. Or CIRCLE_BRANCH and CIRCLE_SHA1 variables should be defined")
  parser.print_help(sys.stderr)
  parser.exit(1)

graph = None

# Create an empty boilerplate object that we will fill with jobs for CircleCI
# We create a workflow named build-game
circleci_manifest = {
  'version': 2.1,
  'parameters': {
  },
  'jobs': {
  },
  'workflows': {
    'build-game': {
      'jobs': [
      ]
    }
  }
}

# Load the Exported JSON BuildGraph
with open(args.json_graph, "r") as stream:
    try:
        graph = json.load(stream)
    except Exception as exc:
        print(exc)

## Loop over the generated jobs
for group in graph["Groups"]:
  for node in group["Nodes"]:

    # Sanitize the node name BuildGraph gives us. Replacing spaces with hyphens and lowercase.
    sanitized_name = sanitize_job_name(node['Name'])

    # The End node is an additional node that buildgraph creates that depends on all nodes being executing, there's no actual logic here to execute
    # so we check if we should skip.
    if "end" in sanitized_name:
      continue

    # Using reflection load the runner class defined in the 'Agent Type' field on BuildGraph
    Class = getattr(importlib.import_module("runners"), group['Agent Types'][0])
    # The git branch is the only required parameter for our constructor.
    runner = Class(args.git_branch)

    # Generate the job for this Node
    steps = runner.generate_steps(node['Name'])

    # Add the job to our manifest
    circleci_manifest['jobs'][sanitized_name] = {
      'shell': runner.shell,
      'machine': True,
      'working_directory': runner.working_directory,
      'resource_class': runner.resource_class,
      'environment': runner.environment,
      'steps': steps
    }

    job = {
      sanitized_name: {
      }
    }

    # Set the dependencies for each job
    if node["DependsOn"] != "":
      if not 'requires' in job[sanitized_name]:
        job[sanitized_name]['requires'] = []

      # The depdencies are in a semicolon-separated list.
      depends_on = [sanitize_job_name(d) for d in node["DependsOn"].split(";")]
      job[sanitized_name]['requires'].extend(depends_on)

    # Add the job to the build-game workflow
    circleci_manifest['workflows']['build-game']['jobs'].append(job)

## Print the final YAML
yaml.add_representer(str, yaml_multiline_string_pipe)
yaml.representer.SafeRepresenter.add_representer(str, yaml_multiline_string_pipe)
print(yaml.dump(circleci_manifest))

上記のメインスクリプトでは、BuildGraph から JSON 形式でエクスポートされたグラフへのパスを受け取り、このパスについてイテレーションを行っています。グラフの各ノードには、ジョブを実行する AgentType を設定します。Type と同じ名前の Python クラスをインスタンス化してから、generate_steps メソッドを使用して、ワークフローで実行するジョブ用のステップを取得します。

最後に、ワークフローにジョブ間の依存関係を設定します。

次は、runners/runner.py 内に Runner 基底クラスを作成します。

from common import sanitize_job_name

class Runner:
  def __init__(self):
    # This will determine the resource class the runner will use
    self.resource_class = ''
    # The location of the RunUAT script
    self.run_uat_subpath = ''
    # The prefix to read an environment variable in the OS
    self.env_prefix = ''
    # The prefix to assign a value to an environment variable
    self.env_assignment_prefix = ''
    # The shell the runner will be using
    self.shell = ''
    # Any environment variables we want to include in our job execution
    self.environment = {}
    # The path to unreal engine
    self.ue_path = ''
    # The working directory for the jobs
    self.working_directory = ''
    # The shared storage BuildGraph will use
    self.shared_storage_volume = ''
    # The name of the branch we are running
    self.branch_name = ''

  # These methods have to be overloaded/overriden by the classes that inherit Runner
  # they have to return the OS-specific way to execute these actions.
  def mount_shared_storage(self):
    raise NotImplementedError("Runners have to implement this")

  def create_dir(self, directory):
    raise NotImplementedError("Runners have to implement this")

  def pre_cleanup(self):
    raise NotImplementedError("Runners have to implement this")

  def self_patch_buildgraph(self):
    return f"git -C {self.ue_path} apply {self.env_prefix}CIRCLE_WORKING_DIRECTORY/Tools/BuildGraph.patch"

  def patch_buildgraph(self):
    self.self_patch_buildgraph()

  def generate_steps(self, job_name):
    return self.generate_buildgraph_steps(job_name)

  # These return all the steps to run for the job
  def generate_buildgraph_steps(self, job_name):
    sanitized_job_name = sanitize_job_name(job_name)
    filesafe_branch_name = self.branch_name.replace("/", "_")

    steps = [
      # Checkout our code
      "checkout",
      # Mount our shared storage
      {
        'run': {
          'name': 'Mount Shared Storage (FSx)',
          'command': self.mount_shared_storage()
        }
      },
      # Create a directory within our shared storage specific for our branch/commit combination.
      {
        'run': {
          'name': "Create shared directory for workflow",
          'command': f"""{self.create_dir(f"{self.shared_storage_volume}{filesafe_branch_name}/{self.env_prefix}CIRCLE_SHA1")}
          """
        }
      },
      # We do some cleanup previous to running BuildGraph
      {
        'run': {
          'name': "Cleanup old build",
          'command': self.pre_cleanup()
        }
      },
      # We patch BuildGraph
      {
        'run': {
          'name': "Apply BuildGraph patch",
          'command': self.patch_buildgraph()
        }
      },
      # Here we run our current BuildGraph node.
      # Environment variables used:
      # BUILD_GRAPH_ALLOW_MUTATION - To allow for file mutations
      # uebp_UATMutexNoWait - Allows UE5 to execute multiple instances of RunUAT
      # uebp_LOCAL_ROOT - The location of our Unreal Engine Build
      # BUILD_GRAPH_PROJECT_ROOT - The working location of our project
      #
      # We always run the same BuildGraph command calling the specific Node we have to run for the step. Then BuildGraph takes care of the rest.
      {
        'run': {
          'name': job_name,
          'command': f"""
            {self.env_assignment_prefix}BUILD_GRAPH_ALLOW_MUTATION=\"true\"
            {self.env_assignment_prefix}uebp_UATMutexNoWait=\"1\"
            {self.env_assignment_prefix}uebp_LOCAL_ROOT=\"{self.ue_path}\"
            {self.env_assignment_prefix}BUILD_GRAPH_PROJECT_ROOT=\"{self.env_prefix}CIRCLE_WORKING_DIRECTORY\"

            {self.ue_path}{self.run_uat_subpath} BuildGraph -Script=\"{self.env_prefix}CIRCLE_WORKING_DIRECTORY/Tools/BuildGraph.xml\" -SingleNode=\"{job_name}\" -set:ProjectRoot=\"{self.env_prefix}CIRCLE_WORKING_DIRECTORY\" -set:UProjectPath=\"{self.env_prefix}CIRCLE_WORKING_DIRECTORY/FirstPersonGame.uproject\" -set:StageDirectory=\"{self.env_prefix}CIRCLE_WORKING_DIRECTORY/dist\" -SharedStorageDir=\"{self.shared_storage_volume}{filesafe_branch_name}/{self.env_prefix}CIRCLE_SHA1\" -NoP4 -WriteToSharedStorage -BuildMachine
          """
        }
      }
    ]

    pak_steps = []

    # We check if we have the word 'pak' on our job name (this will come from our buildgraph script)
    # To know if we have to upload an artifact after running BuildGraph
    if "pak" in sanitized_job_name:
      suffix = ""
      if "win64" in sanitized_job_name:
        suffix = "_win64"
      elif "linux" in sanitized_job_name:
        suffix = "_linux"

      # We upload the produced artifact
      pak_steps.extend([
        {
          'store_artifacts': {
            'path': f'dist{suffix}.zip'
          }
        }
      ])

    steps.extend(pak_steps)
    return steps

この基底クラスでは、Unreal Engine ディレクトリの場所や環境変数の割り当て方法など、渡す必要のある属性すべてを定義しています。また、ディレクトリの作成などのために継承するクラスによりオーバーライドするメソッドも定義しています。コマンドは、Linux と Windows で若干異なります。最後に、generate_buildgraph_steps メソッドで、対応する CircleCI ワークフローを出力しています。このワークフローは OS によらず同じものです。

OS 共通のコードが完成したので、次は runners/ue_linux_runner.py 内に Linux クラスを、runners/ue_windows_runner.py 内に Windows クラスを作成しましょう。

# runners/ue_linux_runner.py
from .runner import Runner

class UELinuxRunner(Runner):
  def __init__(self, branch_name):
    Runner.__init__(self)
    self.branch_name = branch_name
    self.env_prefix = '$'
    self.env_assignment_prefix = 'export '
    self.environment = {
      'UE_SharedDataCachePath': '/data_fsx/SharedDDCUE5Test'
    }
    self.resource_class = 'vela-games/linux-runner-ue5'
    self.run_uat_subpath = '/Engine/Build/BatchFiles/RunUAT.sh'
    self.shell = '/usr/bin/env bash'
    self.shared_storage_volume = '/data_fsx/'
    self.ue_path = '/home/ubuntu/UnrealEngine'
    self.working_directory = f'/home/ubuntu/workspace/{branch_name.replace("/","-").replace("_", "-").lower()}'

  def patch_buildgraph(self):
    return f'{self.self_patch_buildgraph()} || true'

  def mount_shared_storage(self):
    return 'echo "Linux already mounted"'

  def create_dir(self, directory):
    return f"mkdir -p {directory}"

  def pre_cleanup(self):
    return """rm -rf *.zip
    rm -rf /home/ubuntu/UnrealEngine/Engine/Saved/BuildGraph/
    rm -rf $CIRCLE_WORKING_DIRECTORY/Engine/Saved/*
    rm -rf $CIRCLE_WORKING_DIRECTORY/dist
    """

# runners/ue_windows_runner.py
from .runner import Runner

class UEWindowsRunner(Runner):
  def __init__(self, branch_name):
    Runner.__init__(self)
    self.branch_name = branch_name
    self.env_prefix = '$Env:'
    self.env_assignment_prefix = '$Env:'
    self.environment = {
      'UE-SharedDataCachePath': 'Z:\\SharedDDCUE5Test'
    }
    self.resource_class = 'vela-games/windows-runner-ue5'
    self.run_uat_subpath = '\\Engine\\Build\\BatchFiles\\RunUAT.bat'
    self.shell = 'powershell.exe'
    self.shared_storage_volume = 'Z:\\'
    self.ue_path = 'C:\\UnrealEngine'
    self.working_directory = f'C:\\workspace\\{branch_name.replace("/","-").replace("_", "-").lower()}'

  def patch_buildgraph(self):
    return f"""{self.self_patch_buildgraph()}
    [Environment]::Exit(0)
    """

  # This script we are creating in the user-data on our TF module. See above in the tutorial
  def mount_shared_storage(self):
    return "C:\\mount_fsx.ps1"

  def create_dir(self, directory):
    return f"New-Item -ItemType 'directory' -Path \"{directory}\" -Force -ErrorAction SilentlyContinue"

  def pre_cleanup(self):
    return """Remove-Item -Force *.zip -ErrorAction SilentlyContinue
    Remove-Item -Force -Recurse \"C:\\UnrealEngine\\Engine\\Saved\\BuildGraph\\\" -ErrorAction SilentlyContinue
    Remove-Item -Force -Recurse \"$Env:CIRCLE_WORKING_DIRECTORY\\Engine\\Saved\\*\" -ErrorAction SilentlyContinue
    Remove-Item -Force -Recurse \"$Env:CIRCLE_WORKING_DIRECTORY\\dist\\\" -ErrorAction SilentlyContinue
    [Environment]::Exit(0)
    """

上記のとおり、どちらのクラスについても、OS 固有の操作を設定したクラス属性とメソッドを定義しているだけです。メインの処理は、Runner 基底クラスで行います。

また、runners/__init__.py に、以下のコードを追加します。

# runners/__init__.py
from .ue_linux_runner import UELinuxRunner
from .ue_windows_runner import UEWindowsRunner

これは、メインスクリプトでクラスをインポートするために必要です。

セットアップ ワークフロー

変換レイヤーの準備ができたので、いよいよパイプライン実行用の CircleCI 設定ファイルを作成しましょう。

プロジェクト内に .circleci/config.yml ファイルを作成し、次のコードを追加します。

version: 2.1
setup: true
orbs:
  continuation: circleci/continuation@0.1.2

jobs:
  setup:
    machine: true
    resource_class: vela-games/your-linux-class
    steps:
      - checkout
      # Here we run BuildGraph only to "Compile" our BuildGraph script and export the JSON Graph
      # We then pass it down to our translation layer and redirect the output to a yml file that the continuation orb will send to CircleCI's API
      - run: |
          /home/ubuntu/UnrealEngine/Engine/Build/BatchFiles/RunUAT.sh BuildGraph -Target=All -Script=$PWD/Tools/BuildGraph.xml -Export=$PWD/ExportedGraph.json
          ./Tools/buildgraph-to-circleci/buildgraph-to-circleci.py --json-graph $PWD/ExportedGraph.json > /tmp/generated_config.yml
      - continuation/continue:
          configuration_path: /tmp/generated_config.yml

workflows:
  setup:
    jobs:
      - setup

上記コードは、Linux のセルフホストランナー上で setup ジョブを実行します。具体的には、以下の処理を行っています。

  1. アグリゲートノードをターゲットとし、生成されたグラフを JSON ファイルとして出力するよう指示する -Export パラメーターを付けて BuildGraph を実行する
  2. エクスポートされたグラフを buildgraph-to-circleci.py 変換スクリプトに渡し、出力を一時ファイルにリダイレクトする
  3. continuation Orb を使用して、生成された YAML ワークフローを渡す

build-game pipeline

ワークフローの実行が完了すると、pak ジョブの [Artifacts (アーティファクト)] タブからビルド済みのゲームをダウンロードできるようになります。

pak-and-stage

まとめ

このチュートリアルでは、Unreal Engine の BuildGraph 自動化システムの JSON グラフを CircleCI の YAML 設定ファイルに変換するレイヤーを構築することで、BuildGraph と CircleCI を統合する方法をご紹介しました。また、CircleCI でジョブの実行環境として提供されているセルフホストランナーの使い方も示しました。

冒頭に述べたとおり、このテクニックでは、一般的な BuildCookRun スクリプトに比べて並列ビルドを高速化できます。

本チュートリアルで実際に行った処理は次のとおりです。

  1. Terraform を使用して、AWS 上にセルフホストランナーと FSx (共有ストレージソリューション) をデプロイした
  2. Windows と Linux の各プラットフォーム上でゲームのコンパイル、クック、パッケージ化を行う BuildGraph スクリプトを作成した
  3. BuildGraph の JSON グラフを CircleCI のワークフロー設定ファイルに変換する Python 変換レイヤーを作成した
  4. ダイナミックコンフィグを使用してワークフローを動的に構築した

このテクニックにより、Vela Games ではビルド時間を最大 85% 短縮できました。みなさんも、次の Unreal Engine プロジェクトのビルドを自動化する基盤として、また既存プロジェクトの開発のスピードアップ法として、ぜひこのテクニックをご活用ください。

Vela Games について

Vela Games は独立系のエンターテイメントソフトウェア開発スタジオであり、オリジナル IP、およびプレイヤー第一主義の魅力的な協力型ゲームの制作を行っています。本社はダブリンにあり、世界レベルの人材から成る国際チームを結成しています。現在は第一作目として、地球上のあらゆる人が楽しめる、まったく新しいジャンルのマルチプレイヤーゲームの開発を進めています。