UE5で細か過ぎるDLCを作る

 案の定細か過ぎて伝わらない、の巻

この記事はUnreal Engine (UE) Advent Calendar 2023 のシリーズ3、12月11日分に向けて書いたものです。

第 19 回 UE5 ぷちコンにて DlcRockShoot というゲームで応募させて頂きました。

何の変哲もない360度トップビュー STG ですが、初期状態では武器がなく周囲から迫ってくる青いキューブを避けることしかできません。 緑色のアイテムを取ると武器が追加されて青キューブを破壊できるようになります。

実装上はゲームの実行ファイル自体には自機が弾を発射する機能は存在していません。 アイテム取得時に Pak ファイルをダウンロードして Mount し、追加された ActorComponent と Niagara が自機にアタッチされる、という仕組みです。 (このPakファイルは200KBytesほどのごく小さいものになります) つまり、この「ラインタイムにダウンロードされる細か過ぎる DLC」、というのがコンセプト(ツッコミ所)でした。

ありがたい事にぷちコン審査結果発表会の中で取り上げて頂きました。挙動が細か過ぎるせいで、わざわざコマ送りまでして解説して頂きほんとすみませんでした…(汗)。

(28:55 のあたり)

Pak ファイルのダウンロードと Mount 処理は Blueprint ノードがないため自力で C++ のコードを書くかプラグインの力を借りる必要があります。 マーケットプレースのPakLoader プラグインを使用したので、 本稿ではその使い方を解説します。

方針

Blueprint クラス構成はこんな感じになります。

自機BP_Playerがアイテムを取得した時に、BP_GameInstance のダウンロード処理を呼び出します。 DLC プラグイン(FastBullet)の Pak ファイルがダウンロードされると Mount し、Pak 内の ActorComponent と NiagaraSystem が使用できるようになります。 それらをBP_Playerにアタッチすることで、弾発射ができるようになります。

DLC の一覧は JSON ファイルに記載しておき、ゲームの起動時に読み込んでおきます。 Pak ファイル名とファイルサイズ(ファイル破損チェック用)、Mount 処理に必要なプラグインのパス、アタッチする Component 名を書いています。 (この JSON ファイルを自動生成する Python スクリプトを最後の方に掲載しています)

{
  "URLBase": "http://localhost:8090/",
  "DLC": [
    {
      "Name": "FastBullet",
      "PakFileName": "FastBulletDlcRockShoot-Windows.pak",
      "PakFileSize": 2725340,
      "PakPluginPath": "../../../DlcRockShoot/Plugins/FastBullet",
      "ComponentToAttach": "/FastBullet/AC_FastBullet"
    },
    {
      "Name": "SlowBullet",
      "PakFileName": "SlowBulletDlcRockShoot-Windows.pak",
      "PakFileSize": 2725702,
      "PakPluginPath": "../../../DlcRockShoot/Plugins/SlowBullet",
      "ComponentToAttach": "/SlowBullet/AC_SlowBullet"
    }
  ]
}

プレイヤー側の処理

自機の BP_Player が武器アイテムと接触すると、BP_GameInstance のダウンロード処理を呼び出し、ダウンロードのプログレスバー UI を表示します。 ダウンロード中の進捗率の更新はイベントディスパッチャが呼ばれるため、プログレスバー UI を更新します。 ダウンロード完了後にもイベントディスパッチャが呼ばれるため、古い ActorComponent を削除した後、DLC 情報から取得したクラス名を使って ActorComponent を生成、 BP_Player にアタッチしています。 その後、プログレスバー UI が 100%まで到達した状態を表示しないとすっきりしないため、いったん 100%表示にして Delay 経過後に非表示にする、という演出を入れています。

自機の弾の発射は、自身にアタッチされてるコンポーネントのうち BPI_PlayerShot インターフェースを持つものを取り出し、OnPressShot/OnReleaseShot を呼び出しています。 アタッチされているコンポーネントを直接参照せず、インターフェース経由で呼び出しているため、DLC 側の ActorComponent に依存しないようにしています。 依存しないということは、DLC 側のプラグインが存在しなくても Blueprint のコンパイルが通るようになります。

ダウンロード・Mount 処理の実装

BP_GameInstance に実際のダウンロード処理を実装しています。ダウンロード処理の実体はDownloadPakノードですが、 エディタで実行している時、ローカルに Pak ファイルがダウンロード済みの時、Pak ファイルが破損している時、等の条件が多岐に渡るため実行フローが多少複雑になっています。

PakLoader プラグインを使う上で一番苦労したのが Mount 時のパスの処理でした。

MountPakFile に渡すパラメータ:
  PakFileName: (ProjectPersistentDownloadDir)/FastBulletDlcRockShoot-Windows.pak
  MountPath: (空欄)

RegisterMountPoint に渡すパラメータ:
  RootPath: /FastBullet/
  ContentPath: ../../../DlcRockShoot/Plugins/FastBullet/Content/

LoadPakAssetRegistryFile に渡すパラメータ:
  AssetRegistryFile: ../../../DlcRockShoot/Plugins/FastBullet/AssetRegistry.bin

PakLoader の各関数に上記のようなパスを渡す必要があります。 ../../../ とあるのは、ProjectPersistentDownloadDir から UE プロジェクトの Root ディレクトリ(ゲーム内の / に相当)への相対パスを表しています。

DLC.json に記載
  PakFileName: FastBulletDlcRockShoot-Windows.pak
  PakPluginPath: ../../../DlcRockShoot/Plugins/FastBullet

上記のうち、DLC プラグイン固有のパスを切り出して JSON ファイルに記載し、そこから各ノードに渡すパラメータを生成しています。 この辺りを含めたMount処理はマクロでまとめています。

DLC コンテンツの作り方

DLC 側については、まず ContentOnly プラグインを作成します。 下記例(FastBullet)では、弾を発射する NiagaraSystem と、ActorComponent の 2 つのアセットが存在します。

ActorComponent では BeginPlay 時に NiagaraSystem コンポーネントを自機にアタッチし、EndPlay 時に削除しています。

ActorComponent は BPI_PlayerShot インターフェースを継承しており、OnPressShot/OnReleaseShot を実装しておきます。それぞれ、NiagaraSystem コンポーネントに Activate/Deactivate を呼ぶだけです。

Niagara パーティクルの弾が的に当たった時の処理も記載していますが割愛します。 こちらの とかさん の Youtube 動画を参考に実装しています。とてもわかりやすいチュートリアル動画を多数公開されていますので、是非ご覧下さい。

(03:14のあたり、Niagara コリジョンの所を見て下さい)

パッケージング

Project Launcher の設定

パッケージングは Project Launcher を使って行います。メインのゲームパッケージと DLC とで設定項目は大体同じですが一部異なるので、それぞれカスタムプロファイルを作成しておきます。

メインゲームのカスタムプロファイル

メインゲームのプロファイルはこのようになります。デフォルト状態から変更のある箇所をオレンジ枠で囲っています。

DLC のカスタムプロファイル

メインゲームとほぼ一緒ですが、異なる箇所は緑枠で囲っています。

パッケージング

メインのゲームをパッケージングする場合は、あらかじめ DLC のプラグインを無効化してから Project Launcher からパッケージングを実行します。 (DLC プラグインの無効化を忘れるとメインのゲームに DLC が含まれた状態でパッケージが作成されてしまうのでご注意下さい)

パッケージができた後は、UnrealPak.exe -List を使ってメインゲーム側に DLC が入っていないか確認しておいて下さい。 下のように grep して DLC プラグイン側のアセットが含まれていると失敗です。残念!

$ /e/Program\ Files/Epic\ Games/UE_5.3/Engine/Binaries/Win64/UnrealPak.exe -List Windows/DlcRockShoot/Content/Paks/DlcRockShoot-Windows.pak  | grep Bullet
LogPakFile: Display: "DlcRockShoot/Plugins/FastBullet/Content/AC_FastBullet.uasset" offset: 165165056, size: 2848 bytes, sha1: 1E38C7F4A4DC3A0902DE6E55389CDF122C43942A, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/FastBullet/Content/AC_FastBullet.uexp" offset: 165167977, size: 1541 bytes, sha1: 437D5AA7071A4F68EA21E50F6FCD893256A2A9EF, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/FastBullet/Content/NS_FastBullet.uasset" offset: 165171200, size: 4294 bytes, sha1: F09F6EBEB1B9EF5F1BCC188EEE5809E59E128C84, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/FastBullet/Content/NS_FastBullet.uexp" offset: 165175567, size: 11550 bytes, sha1: 0A59D0C127E42CE1C7C58C83D7CF497E8B802365, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/FastBullet/FastBullet.uplugin" offset: 165187584, size: 352 bytes, sha1: 2C1A7A354B87F692B974C1855A8EDD9E5B3FFF9F, compression: None.
LogPakFile: Display: "DlcRockShoot/Plugins/SlowBullet/Content/AC_SlowBullet.uasset" offset: 166514688, size: 2854 bytes, sha1: C163A3A8B6E3DAFB9F43C5C6E13A3969E904F58C, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/SlowBullet/Content/AC_SlowBullet.uexp" offset: 166517615, size: 1535 bytes, sha1: DA18495306D92BC7A534263AFE8877BA23765027, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/SlowBullet/Content/NS_Bullet.uasset" offset: 166520832, size: 4299 bytes, sha1: E2A4CC72525994D0BA2C768516095CF6376E1CD1, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/SlowBullet/Content/NS_Bullet.uexp" offset: 166525204, size: 11548 bytes, sha1: A6D835E21763C34E2BEFFBD63A4DD0DC074A07DD, compression: Oodle.
LogPakFile: Display: "DlcRockShoot/Plugins/SlowBullet/SlowBullet.uplugin" offset: 166537216, size: 352 bytes, sha1: 52066EFFB076C96612A7705C671D6CB20601C874, compression: None.

パッケージ完了後、DLC のプラグインを有効にして DLC プラグインのパッケージングを行います。

パッケージングが完了すると、ProjectLauncher で指定したフォルダの下、かなり深い階層に pak ファイルが生成されます。

DlcFastBullet/Windows/DlcRockShoot/Plugins/FastBullet/Content/Paks/Windows/FastBulletDlcRockShoot-Windows.pak

実行

DLC ファイルを集めて DLC.json を作成

DLC の pak ファイルを同じフォルダに置いて DLC 情報のファイル DLC.json を作成します。 当初手作業を書いてましたが、だんだん辛くなってきたので下記のような簡単な Python スクリプト generate-dlc-json.py を書きました。 DLCの置き場所はlocalhostを想定していますが、外部のサーバーの置く場合はurl_baseを書き換えて下さい。

import json
import os
import typing
from pathlib import Path

url_base = "http://localhost:8090/"
project_name = "DlcRockShoot"
output_file = "DLC.json"

def get_dlcs(file_list: list[Path]) -> list[dict]:
    dlcs:list[dict] = []
    for file in file_list:
        index = file.name.index(project_name)
        dlc_name = file.name[0:index]
        print("dlc_name: {0}".format(dlc_name))
        dlc:dict = {
            "Name": dlc_name,
                        "PakFileName": file.name,
                        "PakFileSize": file.stat().st_size,
                        "PakPluginPath": "../../../{project_name}/Plugins/{dlc_name}".format(project_name=project_name, dlc_name=dlc_name),
                        "ComponentToAttach": "/{dlc_name}/AC_{dlc_name}".format(dlc_name=dlc_name)
        }
        dlcs.append(dlc)
    return dlcs

def main():
    scan_path = Path(os.path.dirname(__file__))
    file_list = scan_path.glob("*.pak")
    dlcs:list[dict] = get_dlcs(file_list)
    dlc_info: dict = {
        "URLBase": url_base
    }
    dlc_info["DLC"] = dlcs
    j = json.dumps(dlc_info, indent=4)
    print("json: {0}".format(j))
    with open(scan_path.joinpath(Path(output_file)), "wt") as f:
        json.dump(dlc_info, f, indent=4)


if __name__ == "__main__":
    main()

DLC の Pak ファイルを置いたフォルダで以下のように実行すると、DLC.json ファイルが生成されます。

$ python generate-dlc-json.py

HTTP サーバー起動

DLC を設置するサーバーを立てます。(ポート番号8090DLC.jsonと一致しておく必要があります)

$ python -m http.server 8090

ゲーム起動

DLC.jsonをゲームパッケージの Windows/DlcRockShoot フォルダにコピーした後、Windows/DlcRockShoot.exe を実行します。

アイテムを取得した後に自機から弾が出たら成功です!

弾が出ない時は、ログファイル Windows/DlcRockShoot/Saved/Logs/DlcRockShoot.log (Shipping パッケージなら C:\Users\[ユーザー名]\AppData\Local\DlcRockShoot\Saved\Logs\DlcRockShoot.log) に何かエラーが出てないか確認して下さい。

注意点

いくつか注意すべき所がありましたので、書いておきます。

プロジェクト設定

  • 「Use Io Store」のチェックを外す

    PakLoader プラグインのドキュメントにも記載されていますが、IoStore を有効にしたパッケージでは Mount できないためチェックを外しておく必要があります。 (個人的にはこれが何故なのか調査したい所です…)

  • Share Material Shader Code のチェックを外す

    ぷちコン応募動画 をよく見るとわかりますが、自機の弾のマテリアルが非常に見辛くなっています。 実は応募時点で解決できなかった問題で、DLC 側の Niagara 弾のマテリアルが剥がれてデフォルトになっていましたが、 Share Material Shader Code のチェックを外すことで解消しました。(当時の投稿)

プロジェクトにコードプラグインを追加した時は、ゲームパッケージと DLC パッケージを更新する

この記事を書いている最中に Blueprint のスクショが撮影したくなり GraphPrinter プラグインを導入したのですが、 導入後にゲーム側のパッケージングを行った所、導入前の DLC パッケージの Mount に失敗するようになりました。 出来上がったパッケージそのものからマウント可能かどうかを確認する方法が不明なので、ゲームと DLC の再パッケージは同時に行うよう注意する必要があります。 (これ、第三者から提供された DLC パッケージを Mount しようにも、本体側のプラグイン構成が変わったらできなくなるので、UGC 的な観点からは問題になりますね…)

まとめ

ということで、ゲームのごく一部の機能をPakファイルとしてゲーム本体とは別にパッケージングし、ゲーム中にダウンロード・機能追加する方法を書きました。 細か過ぎるDLCに一体何の意味があるのか…というツッコミは来そうですが、ただひたすらに「やってみたかっただけ」になります。 マーケットプレースのプラグインを使うだけ…かと思いきやそこそこ面倒でしたので、忘れないうちに記録しておきます。

蛇足: GameFeature は未使用

UE5 の目玉機能の一つに、GameFeature と ModularGameplay というものがありました。 古代の谷のデモでランタイムで機能を追加したり切り換える様子が印象的でした。 本記事でもその機能を使っているんでは…?と思われた方もおられると思いますが、実は全く使っていません(汗)。

GameFeature プラグイン化したバージョンの開発も進めていたのですが、ランタイムで Mount した GameFeature プラグインを UE に認識させる方法がわからない(LoadAndActivateGameFeaturePlugin が失敗する)ため、頓挫していました。 色々模索する中で、GameFeatureSubsystem の機能を Blueprint ノード化したプラグインUEGameFeatureUtilPluginも 作ったりしました。(UE5.1.1 止まりなんで更新しないと…)

GameFeatureSybsystem のソースを読むと、InstallBundle というキーワードが頻繁に出てきます。 DefaultInstallBundleManager なるプラグインも存在し、ダウンロードや Mount 処理、パッチ適用などを行うようなのですが どうやって使うのか情報を見つけることができませんでした。

もやもやした気持を抱えつつ数ヶ月が経過した頃、こちらのどんぶつさんのツイートで気になる情報を見かけました。

(05:37:30 のあたり)

10 月にニューオリンズで開催された Unreal Fest の講演で、将来的な機能として Modular Game Builds なるものが紹介されていました。 パッケージが数十 GB に肥大化しビルド時間も長期化している問題への対策としてこんな構想が語られていました。

  • GameFeature プラグイン化した個々の機能を並列にビルドする
  • ゲーム実行時、一部機能を先に実行しておき、残りの機能は CDN からダウンロードして追加する

InstallBundle なるものもこの中で使われる…のでしょうか? 個人的にとても気になるので続報を待ちたいと思います。

文責:ともたこ/Tomotaka Ogino Twitter/github/Qiita