関連ブログ
- [UE5] 元の位置に戻るカメラの実装 2024.12.18UE
- [UE5]難易度変更に対応したシューティングゲームを作ってみよう 2024.12.11UE
- [UE5] インタラクト可能なモノの量産に役立つBPを作ってみよう 2024.12.04UE
CATEGORY
2024.12.25UE5UE/ C++
執筆バージョン: Unreal Engine 5.4.1
|
■「チャンクダウンローダー」とは
本ブログにおけるチャンクダウンローダーとは、パッケージ済みのゲームから、インターネット経由で配布しているファイルのダウンロードを可能にするためのUneralEngineに搭載されているプラグイン及び機能を指します。
主な使用例としては、「スマホアプリのストア側の容量制限の回避」や「ゲームソフト本体のアップデートを伴わない要素の拡張」です。
スマホアプリはストアの規約によりアプリの容量に制限があり、ゲームの全要素を含めると制限を超えることが多々あります。
その際にゲームのプログラムやタイトル画面などの一部のリソースのみをパッケージ化した上で、残りの必要なリソースデータをチャンクダウンローダーを利用してストアを経由せずに配布する手段が使われます。
ゲーム本体のアップデートを伴わない要素拡張については。「テキストデータの修正」や「衣装データの追加や変更」などのゲーム性に変更を加えない要素をチャンクダウンローダーの対象にすることでアップデートをする際の変更箇所が絞られるためデバッグコストを減らすことや、アップデート時のユーザー側の更新時間などを減らすことに繋がります。
なお本ブログでは主に後者である「ゲーム本体のアップデートを伴わない要素の拡張」の対応で生じた問題箇所の共有と弊社における対応方法の紹介を行います。
■プロジェクトへの導入
下記公式ドキュメントを参考にすることで導入することができます。
概念の理解などが必要になるので、まずは見様見真似で試しつつ知見を深めるとよいでしょう。
公式ドキュメント:チャンク用のアセットを準備する
公式ドキュメント:クック処理とチャンク化
公式ドキュメント:パッチのための ChunkDownloader の使用
■マニフェストファイルについて
チャンクダウンローダーにおけるマニフェストファイルとは、ダウンロード予定のファイルの情報一覧を記述したファイルです。
公式ドキュメントの「ChunkDownloader のマニフェストとアセットをホスティングする」に詳しく説明されていますが、記述例について少しだけ罠がありまして、
「2.マニフェスト ファイルを作成する」の最後に書かれているBuildManifest-Windows.txtの中身について、「スニペット全体をコピー」をすると2行目以降の先頭にタブが挿入されています。
正しいフォーマットでは各行の先頭にタブは不要ですので消してください。
このマニフェストファイルについては配布するチャンクを更新する度に作成する必要がありますが、ファイルのサイズ(バイト数)を記述するのが特に手間です。
マニフェストファイル自動生成用のバッチのコードを書きましたので是非参考にしてください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
@echo off set CONST_BUILD_ID=1_0_0 set CONST_FILE_VERSION=Ver100 REM メイン処理 setlocal enabledelayedexpansion for /f "usebackq" %%D in (`dir "*" /a:d /b`) do ( CALL :WriteManifestAtPlatform %%D ) endlocal pause exit /b REM サブルーチン:プラットフォーム事のマニフェストファイル書き出し REM 第一引数:プラットフォーム名 :WriteManifestAtPlatform echo %1 cd %1 REM 対象プラットフォームの取得 set PLATFORM=%1 echo Platform=%PLATFORM% REM 書き込み対象のマニフェストファイルの取得 set MANIFEST_FILE=../BuildManifest-%PLATFORM%.txt echo MANIFEST_FILE = %MANIFEST_FILE% REM 配布対象のファイル数を取得&書き込み for /f "usebackq" %%A in (`dir /a-d /b "pakchunk*"^| find /c /v ""`) do ( set /a FILE_COUNT=%%A ) echo ContentsNum = %FILE_COUNT% REM マニフェストファイル1行目 配布コンテンツ数を記述 echo # $NUM_ENTRIES=%FILE_COUNT% echo $NUM_ENTRIES = !FILE_COUNT!>%MANIFEST_FILE% REM マニフェストファイル2行目 配布IDを記述 echo # $BUILD_ID=%CONST_BUILD_ID% echo $BUILD_ID = %CONST_BUILD_ID%>>%MANIFEST_FILE% REM マニフェストファイル3行目以降 ファイル詳細を記述 setlocal enabledelayedexpansion for /f "usebackq" %%A in (`dir /b "pakchunk*"`) do ( REM ファイル名 set FILE_NAME=%%A REM ファイルサイズ for %%I in (!FILE_NAME!) do ( set /a FILE_SIZE = %%~ZI ) REM チャンクインデックス set CHUNK_INDEX=%%A set SUFFIX=-!CHUNK_INDEX:*-=! set CHUNK_INDEX=!CHUNK_INDEX:*pakchunk=! call set CHUNK_INDEX=%%CHUNK_INDEX:!SUFFIX!=%% REM ファイルパス set FILE_PATH=/!PLATFORM!/!FILE_NAME! echo # !FILE_NAME! !FILE_SIZE! !CONST_FILE_VERSION! !CHUNK_INDEX! !FILE_PATH! echo !FILE_NAME! !FILE_SIZE! !CONST_FILE_VERSION! !CHUNK_INDEX! !FILE_PATH!>>%MANIFEST_FILE% ) endlocal cd .. exit /b |
使用方法は下記階層を参考にチャンクファイルを配置して、batファイルを実行してください。
NewFolder
└AutoWriteManifest.bat
└Windows
└pakchunk1000-Windows.pak
└pakchunk1001-Windows.pak
長期的な運用を考えるとPython等を用いて、バージョン管理やマニフェストに含めるかのフィルター機能などに対応させたプロジェクト専用のツールを作るとよいでしょう。
■アセットのチャンクID割り当てについて
チャンクを作るにあたって、どのアセットをどのチャンクIDに割り当てるかが重要になります。
アセットの配信などを想像してIDの割り振りを想像したうえで、実際にデータを追加するアーティスト等に設計を共有することが大切です。
弊社の事例としては以下のことに気を付けながらアセットのフォルダ配置や衣装の追加などをしました。
・PrimaryAssetLabelのApplyRecursively(参照アセットをまとめて同じチャンクで扱う設定)はTrueで運用する。
・汎用エフェクトやデフォルトの衣装、共通マテリアルなどは、PrimaryAssetLabelのChunkIDを0にしてPriorityを高い数値にすることで、他のチャンクに取り込まれないようにする。
・チャンクID=0に設定するアセットと、チャンクダウンローダーで配布予定のアセットが同フォルダ内で混在しないように、なるべく浅い階層でフォルダを分ける。
・チャンクダウンローダーで衣装追加する時は、チャンクID=0にアセットを追加して参照することをNGとする。
・定期的にAuditツールでチャンクの重複が無いかチェックする。
・リソースが複数アセットから参照される場合は、「共通アセットとしてチャンク0で用意しておく」か「アセット別にユニークなリソースになるようにコピーして使いまわさないようにする」かどちらかの対応を行い、複数チャンクをまたがるアセットは存在させない。
またPrimaryAssetLabelの「LabelAssetsInMyDirectry(同ディレクトリ内をチャンクに含める対象にする設定)」と「ExplicitAssets(明示的に含めるアセット)」は状況に応じて使い分けています。
大まかに使用パターンとしては以下の2通りになります。
・チャンクID=0に指定する際は「LabelAssetsInMyDirectry」で丸ごとフォルダ以下を対象にする。
・エクセル等で衣装の一覧を管理したうえで、DataAssetに情報を変換する際に自動でPrimaryAssetLabelの「ExplicitAssets」にアセットのパスを追加するように自動化。
衣装データに限らず、弊社では様々なデータを最終的にDataAssetにしたうえでゲーム内で扱っています。
チャンクダウンローダーでの更新対象にするかに関わらず、すべてのDataAssetをPrimaryAssetLabelを継承した上で、用途に合わせて拡張して使用し、
必要に応じてチャンクIDを割り当てることで、DataAssetとPrimaryAssetLabelの煩雑性を下げることができます。
■チャンクの「本体に含める」か「チャンクダウンローダーの対象にする」かについて
前述の項目までは「チャンクID=0の時は本体に含める」「チャンクID=1以上であればチャンクダウンローダーの対象」とも読み取れるように書きましたが、
実際はチャンクID=1以上については「本体に含める/含めない」を開発側の意志で決めることができます。
作成したパッケージのチャンクデータは「{ProjectName}/Content/Paks」の中に配置されます。
ゲームの配信ストアに提出する際に、このPaksフォルダ内にチャンクデータがあれば「ゲーム本体に含める」と言えますし、
逆にPaksフォルダ内になければ「ゲーム本体に含まない≒チャンクダウンローダーの対象にする」と言えます。
Windowsであればストアにアップロードする前に手動でPaksから切り取ってしまえば「本体に含まない」ことができます。
一方でほかのプラットフォームでは、内部的なフォルダ構成はWindowsと変わりませんが、それらをまとめた独自フォーマットの1つのファイルとして圧縮されます。
そのためUnrealEngineのパッケージ作成の最中にシステム的にContent/Paksから任意のチャンクを除外する必要があります。
弊社事例では、とあるプラットフォーム用のAutomation.csに記述された「FilterRules」に着目し、その機能の利用及び他プラットフォームへの導入をしました。
「{ProjectDir}/Platforms/{PlatformName}/FilterRules-Shipping.txt」に「Content/Paks/pakchunk1001-」のように行毎に記述をすることで、
ゲーム本体のパッケージから記述したチャンクが除外されるようになります。
Windows以外のプラットフォームでは、チャンクダウンローダーに使用するためのファイルをパッケージから持ってくることができません。
ではどこから持ってくれば良いかというと「{ProjectDir}/Saved/StagedBuilds/{PlatformName}/{ProjectName}/Content/Paks」にチャンクが生成されています。
Automation.csによって除外される前段階で生成されているので、本体に含む/含まないに関わらずフォルダ内にチャンクが存在する為、適宜必要なものだけをコピーしてください。
■チャンクのマウントについて
ゲームを実行した時のチャンクダウンローダーは次の3ステップの動作を行います。
・マニフェストファイルのダウンロード :マニフェストファイルのダウンロードと、ローカルのマニフェストとのバージョン比較
・チャンクファイルのダウンロード :チャンクのバージョンを比較したうえで、必要に応じてチャンクファイルをダウンロードする。
・チャンクのマウント :ダウンロードしたpakファイルの読み込み
この中で弊社では運用にあたってマウントの際に起きた問題点が複数存在するので、その解決方法を紹介します。
—IoStoreを使用したときのマウント問題—
IoStoreとはUnrealEngine側で用意されている機能で、IoStoreが無効の時は「.pak」オンリーだったものが、有効にすると「.pak .ucas .utoc」の3種のファイルに分割されます。
データの持ち方が分割&整理されたおかげで、アセットのロードが高速化する機能となります。
チャンクダウンローダー用に配布するチャンクのファイルとしては、1チャンク毎に「.pak .ucas .utoc」の3ファイルが対象となります。
「.pak .ucas .utoc」については3種で1セットとなるため、「.pak」のみをマウント指示すればよいのですが、
UE5.4.1時点のチャンクダウンローダーではマウント済みフラグの判定時に「.ucas .utoc」について考慮されていませんでした。
エンジン改造とはなりますが、ChunkDownloader.cppの「FChunkDownloader::MountChunkInternal」の処理に変更を加え、
ファイル名の末尾が「.ucas」または「.utoc」だった場合にPakFile->bIsMountedとPakFile->bIsCachedをtrueにする例外処理を加えています。
また同様に「FChunkDownloader::UnmountPakFile」の処理についても変更を加え、「.pak」でない場合には何もせずreturnするようにしています。
—本体に含んだチャンクを、ダウンロードしたもので上書きする—
本体に含んだチャンクというのは、ゲームを起動した際に自動でマウントされます。(実行時のLogファイルで確認することができます)
マウントされたチャンクはマウント済みと扱われるため、ゲーム起動が済んだタイミングで実行されるチャンクダウンローダーによって
ダウンロードしてきたチャンクをマウントする際に、すでに本体側のチャンクがマウント済みであるため、落としてきたチャンクをマウントできません。
チャンクの配布想定の段階で、「あとから更新する想定であれば本体から除外する」ことを徹底すればこの対応は不要となりますが、
実際に運用する際に「ユーザーへの配布データを最小限にしたい」や「発売段階では本体に含むが修正が必要な時に、修正可能な余地が欲しい」といった理由によって
本体に含んだチャンクを上書きする機能が必要となりました。
対応方法としては「FChunkDownloader::MountChunks」で読込予定のチャンク一覧から末尾が「.pak」のファイル名のみを配列化し、
それらのファイルが「Content/Paks」にファイルが存在するかチェックし、存在する場合は明示的にアンマウントする処理を加えました。
変更箇所の前後のソースコードを添付したので参考にしてください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
void FChunkDownloader::MountChunks(const TArray& ChunkIds, const FCallback& Callback) { // convert to chunk references TArray<TSharedRef> ChunksToMount; for (int32 ChunkId : ChunkIds) { TSharedRef* ChunkPtr = Chunks.Find(ChunkId); if (ChunkPtr != nullptr) { TSharedRef& ChunkRef = *ChunkPtr; if (ChunkRef->PakFiles.Num() > 0) { if (!ChunkRef->bIsMounted) { ChunksToMount.Add(ChunkRef); } continue; } } UE_LOG(LogChunkDownloader, Warning, TEXT("Ignoring mount request for chunk %d (no mapped pak files)."), ChunkId); } //+ @historia TArray EmbeddedCheckFiles; //チャンクダウンローダーで落としたpakファイル名をキャッシュする for(int32 ChunkId : ChunkIds) { TSharedRef* ChunkPtr = Chunks.Find(ChunkId); if (ChunkPtr != nullptr) { TSharedRef& ChunkRef = *ChunkPtr; if (ChunkRef->PakFiles.Num() > 0) { for(auto PakFile : ChunkRef->PakFiles) { if(PakFile->Entry.FileName.EndsWith(TEXT(".pak"))) { EmbeddedCheckFiles.Add(PakFile->Entry.FileName); } } } } } // 本体にpakが存在しているかチェックしたうえでマウント解除する IFileManager& FileManager = IFileManager::Get(); for(auto CheckFile : EmbeddedCheckFiles) { FString EmbeddedPakFilePath = FPaths::ProjectContentDir() / TEXT("Paks/") / CheckFile; if(FileManager.FileExists(*EmbeddedPakFilePath)) { if (FCoreDelegates::OnUnmountPak.IsBound()) { // 本体に含まれるpakをアンロードする if (FCoreDelegates::OnUnmountPak.Execute(EmbeddedPakFilePath)) { UE_LOG(LogChunkDownloader, Log, TEXT("unmount %s"), *EmbeddedPakFilePath); } else { UE_LOG(LogChunkDownloader, Error, TEXT("Unable to unmount %s"), *EmbeddedPakFilePath); } } } } //- @historia // make sure there are some chunks to mount (saves a frame) if (ChunksToMount.Num() <= 0) { // trivial success ExecuteNextTick(Callback, true); return; } ~~~~~~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~~ } |
■AssetRegistryについて
ここまでゲーム本体をアップデートせずにアセットを更新する方法としてチャンクダウンローダーを紹介してきましたが、
ゲーム本体のパッケージを作成したときには存在しないアセットを、チャンクダウンローダーで落としてきた後に参照するには少し工夫が必要となります。
UnrealEngineではFAssetRegistryModuleを使うことで、パッケージに含まれているアセットの一覧を取得することでき、
弊社事例ではチャンクダウンローダー完了後に、FAssetRegistryModuleで衣装用のDataAssetクラスを持つアセットをすべて検索し、明示的にロードをしています。
この時FAssetRegistryModuleはパッケージに含まれている「AssetRegistry.bin」を読み取りアセットの一覧を把握するのですが、
デフォルトだとこのAssetRegistry.binが本体にしか含まれておらず、後から追加したアセットはAssetRegistry.binに存在していないので、
結果として追加した衣装用DataAssetが読み込めないという事態に陥りました。
対策としてはUAssetManagerというアセット参照関連を制御しているクラスのGetUniqueAssetRegistryName関数を適切に実装することで、
チャンク毎にAssetRegistry〇〇.binが生成され、該当チャンクファイルに含まれるようになります。
この生成されたAssetRegistryをチャンクダウンローダーの処理直後に明示的にロードをすることで、
FAssetRegistryModuleで取得できるアセット一覧が更新されるので新規追加されたDataAssetを見つけることができます。
このGetUniqueAssetRegistryNameを実装する際に気を付けるべきなのはパッケージ作成時に必要なチャンク以外は、ユニークな名前を返さないようにしておくことです。
この関数の引数でInChunkIndexがあるのですが、常に「AssetRegistry_{InChunkIndex}.bin」のようなユニーク名にすると、
チャンクファイルが存在しないチャンクIDまでAssetRegistryが生成されることになり、それらがゲーム本体にふくまれてしまいます。
その状況に陥ると後から追加したチャンクファイルにAssetRegistryが含まれていても本体のものが優先されて、結果後から追加したDataAssetをみつけられなくなります。
弊社の事例では、csvで使用するチャンクIDを管理し、エディタ時(パッケージする時)のみGetUniqueAssetRegistryNameの実装内部でcsvを解析し、
必要な場合のみユニークなAssetRegistry名を返し、それ以外はデフォルト名を返すように実装しています。
これらの「GetUniqueAssetRegistryNameの実装」と「AssetRegistryを明示的にロードする」処理についてコードを記載しますので参考にしてください。
GetUniqueAssetRegistryNameの実装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
#if WITH_EDITOR FName UMyAssetManager::GetUniqueAssetRegistryName(int32 InChunkIndex) const { //チャンク0以下はユニークなAssetRegistryにしない if(InChunkIndex <= 0) { return Super::GetUniqueAssetRegistryName(InChunkIndex); } //エディタ時はChunkVersionを記述しているチャンク以外はAssetRegistryを個別に生成しない FString ChunkVersionCsvData; IFileManager& FileManager = IFileManager::Get(); FString FileDir = FPaths::ProjectDir() / TEXT("SourceData") / TEXT("out") / TEXT("DT_ChunkVersion.csv"); if (!FileManager.FileExists(*FileDir)) { //csvが無ければUniqueな名前を返さない return Super::GetUniqueAssetRegistryName(InChunkIndex); } static int32 CachedChunkVersionFileSize; static TArray ChunkVersionRowData; int32 CurrentFileSize = FileManager.FileSize(*FileDir); if(CachedChunkVersionFileSize != CurrentFileSize) { //キャッシュ済みのファイルと異なる場合は再読み込みする CachedChunkVersionFileSize = CurrentFileSize; FFileHelper::LoadFileToString(ChunkVersionCsvData, *FileDir); //行別に分解 ChunkVersionRowData.Empty(); ChunkVersionCsvData.ParseIntoArrayLines(ChunkVersionRowData); } for(auto RowData : ChunkVersionRowData) { if(RowData.Contains(FString::FromInt(InChunkIndex))) { //該当行におけるチャンクバージョンに関するデータ // チャンクID, チャンク配布想定フラグ TArray ChunkVersionData; RowData.ParseIntoArray(ChunkVersionData, TEXT(",")); if(ChunkVersionData.IsValidIndex(1)) { if(ChunkVersionData[0].Equals(TEXT("\"") + FString::FromInt(InChunkIndex) + TEXT("\""))) { return FName(FString::Printf(TEXT("_Chunk%d"), InChunkIndex)); } } } } return Super::GetUniqueAssetRegistryName(InChunkIndex); } #endif |
AssetRegistryを明示的にロードする
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void UMyAssetManager::LoadChunkAssetRegistry() { TArray FoundFiles; IFileManager::Get().FindFiles(FoundFiles, *FPaths::ProjectDir(), TEXT("*.bin")); for(auto FoundFile : FoundFiles) { FString AssetRegistryPath = FPaths::ProjectDir() / MoveTemp(FoundFile); FAssetRegistryState PluginAssetRegistryState; if (FAssetRegistryState::LoadFromDisk(*AssetRegistryPath, FAssetRegistryLoadOptions(), PluginAssetRegistryState)) { IAssetRegistry& AssetRegistry = UAssetManager::Get().GetAssetRegistry(); AssetRegistry.AppendState(PluginAssetRegistryState); } } } |
■さいごに
以上、弊社におけるチャンクダウンローダーの利用と問題に対するワークアラウンドの事例紹介でした。