概要
UnrealEngineを使っていると、実行環境によってメモリ不足に悩まされることがあります。その時に、チェックすべき項目をまとめていきます。
この記事は、UE5.4 の内容をベースに書かれています。ただ、エンジンのバージョンに依存している内容は少ないと思うので、他のバージョンを採用している人にも参考になると思います。
Naniteの無効化
高品質なメッシュを描画するのに便利なNaniteですが、プロジェクトによってはNaniteを使用していないところもあると思います。その際、
NaniteはONのままだけど、各メッシュアセットでNaniteを使ってないから、余計なコストはかかっていない。
と思っていたんですが、そこに落とし穴がありました。
たしかに、ランタイムのCPUやGPUの計算コストはかからないのですが、初期化時に確保するメモリが24.5MBほどあり、メモリには意外と大きなサイズの無駄があります。
補足情報:確保場所のソースコード位置
Engine\Source\Runtime\Engine\Private\Rendering\NaniteStreamingManager.cpp
(Nanite::) FStreamingManager::InitRHI()
で確保しているメモリが全部で24.5MBぐらいあります。
無効化の設定は、プロジェクト設定(Project Settings)の Engine>Rendering>Nanite>Naniteのチェックボックスを外すだけです。

iniファイルに直接書く場合は、DefaultEngine.iniの [/Script/Engine.RendererSettings] セクションに、r.Nanite.ProjectEnabled=Falseを追加してください。(どちらで対応しても同じ結果になります)
エンジンプラグインの整理
デフォルトで有効になっているプラグインにも、意外とメモリを消費しているものがありました。
とあるプロジェクトではサウンドにCRI Wareを採用していたため、以下のUnrealEngine標準のサウンド系プラグインを無効化しました。
- MetaSound
- Syhthesis
- ResonanceAudio
サウンドにCRI WareやWwise、FMODなどミドルウェアを採用しているプロジェクトではいずれも不要なものです。
サイズが大きめだったこれらの3つのプラグインを無効化することで、44MBぐらいのメモリ削減効果がありました。Naniteと違って、半分以上はプログラム領域のサイズで、特にMetaSoundだけで25MBぐらいのサイズがあるようです。
プログラムサイズの問題なので、例えばMetaSoundを一切使って無くてもメモリに乗ってしまいます。これ以外にも使っていないプラグインを整理すると数MB程度は削れそうなものもありましたが、本当に使っていないかの検証が必要だったり、費用対効果が悪そうだったので対応を見送ったプラグインもあります。
上級者向け:プログラムサイズの確認方法
マップファイルを出力するとその内容からプログラムサイズをざっくり確認することができます。
まず、マップファイルを出力するには *.Target.csに bCreateMapFile = true; を追記します。
// コード例:
public class SandboxTarget : TargetRules
{
public SandboxTarget( TargetInfo Target) : base(Target)
{
Type = TargetType.Game;
DefaultBuildSettings = BuildSettingsVersion.Latest;
IncludeOrderVersion = EngineIncludeOrderVersion.Latest;
ExtraModuleNames.AddRange( new string[] { "Sandbox" } );
bCreateMapFile = true; // この行を追加!
}
}
その後、パッケージを作成すると出力フォルダ(Binaries\Win64など)の中に*.mapファイルが出力されています。
中身を見ると、よく分からない文字列がたくさん並んでいますが、UEにおいては
0001:0e65b470 IMPLEMENT_MODULE_LocationServicesBPLibrary 000000014e65c470 f Module.LocationServicesBPLibrary.cpp.obj
(中略)
0001:0e699620 IMPLEMENT_MODULE_MetasoundGraphCore 000000014e69a620 f Module.MetasoundGraphCore.cpp.obj
(中略)
0001:0e731c00 IMPLEMENT_MODULE_MetasoundFrontend 000000014e732c00 f Module.MetasoundFrontend.1.cpp.obj
のように、IMPLEMENT_MODULE_xxxを最終行として各モジュールのエリアが分かれています。一番左端の数字がアドレスになっていますので、前のIMPLEMENT_MODULE_xxxから注目しているIMPLEMENT_MODULE_xxxの間の数字差が、そのモジュールで占めているプログラムサイズになります。
例えば、IMPLEMENT_MODULE_MetasoundGraphCore は、
0x0e699620 - 0x0e65b470 = 0x3E1B0(約254KB)
のプログラムサイズを持っていることになります。
1つのプラグインで複数のモジュールに分かれている場合は、複数の区間を足す必要がありますし、プログラムサイズ以外にも静的データが容量を食っている場合もありますので、一概にこれだけで全部わかるわけではないですが、容量を食っているプラグインの見当を付けるのに使えます。
メッシュアセットのコリジョン
StaticMeshなどについているコリジョンについて、いくつか注目すべきポイントがありました。このセクションではそれらについてまとめていきます。
UE5におけるコリジョンの構成について
UE5では、SimpleCollisionとComplexCollisionが存在しています。SimpleCollisionはBoxやSphereなどのPrimitive形状とConvexメッシュによって定義された、軽量なコリジョンです。
一方、ComplexCollisionは、基本的に表示メッシュから作成されポリゴン単位で情報を持つ、その名の通り「複雑な」コリジョンになります。
そもそもコリジョンは必要?
とあるプロジェクトで見つかったのは、HLODや遠景用のメッシュなど、キャラクターの制御が動かないエリアのメッシュに付いているコリジョンです。特にHLODは頂点数が多いため、表示メッシュ由来のComplexCollisionが存在しているとかなりのメモリを消費していました。
コリジョンを削除する方法①
まず、コリジョンが不要ということで真っ先に思いつくのが、CollisionPresetの変更です。

CollisionPresetを NoCollision に設定する事で、実行時にコリジョンのセットアップが行われず、メモリの節約になります。
コリジョンを削除する方法②
①の方法で終わり!と思ったら大間違いでした。まだ削れる部分があります。
次は CollisionComplexity の変更です。デフォルトでは ProjectDefault が設定されています。そこで、プロジェクト設定を確認すると、デフォルトは SimpleAndComplex になっていると思います。
そのため、ComplexCollisionをデータとして保持している状態になっています。

そこで、この設定は UseSimpleCollisionAsComplex に変更します。これは、SimpleCollisionをComplexCollisionとして使用するという設定になります。これでComplexCollisionがデータからも除外されます。
NoCollisionを指定しているのに…
NoCollisionを指定しているのに、なんでCollisionが付いてくるの?と私も最初は思いましたが、CollisionPresetは実行時に変更可能な設定です。そのため、実行時に変更した時にすぐにコリジョンをセットアップできるように、頂点データなどはメモリ上に読み込む仕様になっているようです。
コリジョンを削除する方法③
①②の設定変更でおおむね大丈夫だと思いますが、Fabなどで購入したアセットやメッシュのインポートの方法によっては、もう一つ SimpleCollision に関する確認が必要です。

SimpleCollsiionは上図の右側、 Primitives の項目に何か情報があればSimpleCollsiionの情報を持っていることになります。ComplexCollisionほどではないですが、ConvexElementsの中には意外と複雑な形状を持っているメッシュが設定されていることがあり、コリジョンが不要ならここも削除することをお勧めします。
具体的にはStaticMeshEditorなどのメニューから Collision>RemoveCollision を選択します。Primitivesの項目が全部 ”0 Array element” になれば成功です。
ComplexCollisionの詳細度
コリジョンは必要なメッシュでも削減の余地があります。前項の冒頭で「表示メッシュ由来のComplexCollision~」と書いた通り、ComplexCollisionはデフォルトでは表示メッシュから作成されます。
そもそも、ComplexCollisionは、銃弾の着弾判定やIKの処理に使われていることが多いです。ということは、それらの判定に必要な詳細度があればよく、それ以上に細かい頂点を持っている場合は無駄になりますし、処理負荷にも影響してきます。
ComplexCollision削減方法①
最初に確認するパラメータは、LOD for Collisionです。この設定で使用するComplexCollisionを制御できます。

先ほどから何度か出ていますが、ComplexCollisionは表示メッシュから作成されます。その作成元のLODを切り替える設定になります。
例えば、LOD0がかなりのハイポリメッシュで、コリジョンの精度としてはLOD1でも問題ない場合は、この設定を上図のように1に変更します。(LOD2を使うなら2に変更)
なお、

StaticMeshEditor上の Show>ComplexCollision のチェックを入れておくと、上図のようにComplexCollisionの形状が視覚化できますので、これで表示メッシュとの乖離具合などを事前に確認できます。
ComplexCollision削減方法①ー2(Naniteメッシュの場合)
Naniteメッシュの場合、LODを持たないと思いますが、デフォルトではFallbackMeshとしてLOD0は保持していて、FallbackMesh(=LOD0)をComplexCollisionとして採用しています。
これを調整するには、Naniteの設定(Fallback Targetなど)でFallbackMeshの詳細度を調整することになると思いますが、当然表示される可能性のあるFallbackMeshと連動します。
もし、品質的にコリジョンの精度と合わない場合は、別途LOD1を足してLOD for Collisionを1にすることで回避できます。一方、メモリの削減という意味では本末転倒で、LOD1分のメモリが別途必要になってしまいます。
この辺は、ユースケースに応じてメモリを取るか、品質を取るかで検討が必要になると思います。
ComplexCollision削減方法②
①で説明したLOD for Collisionの設定では、表示されるLODのいずれかを使う方法でした。しかし、コリジョンとしてちょうどいいLODがあるとは限りません。その場合、別途用意したStaticMeshAssetをComplexCollisionとして使うことができます。

上図のComplexCollisionMeshの設定がそれで、ここで指定したStaticMeshがそのままComplexCollisionとして採用されます。この場合もNaniteメッシュの場合と同じく、コリジョン専用のメッシュを別途持つことになりますので、メモリ削減の観点からだと本末転倒になりそうです。
もう一つ、この設定で注意が必要なのは、別アセットのため表示用のStaticMeshAssetと連動しないという点です。例えば、設定するStaticMeshAssetの形状が変わった場合、ComplexCollisionMeshに設定したStaticMeshAssetも別途修正を行う必要があります。
一方、LOD for Collisionを使用した場合は、基本的にLODは自動的に再生成されますのでこの心配はありません。どちらの方法を使うかはワークフロー次第でもあると思うので、ユースケースに応じて検討してください。
無駄なUVを削除
次もメッシュアセットに関連する話題です。

上図のように、StaticMeshEditorの左上に出ている情報(赤枠内)で UV Channelsの情報が確認できます。これが無駄に多くないかチェックしてください。
たいていの場合は、UVは1~2個(LightMapUVがあれば+1個)ぐらいだと思いますので、さすがに8個はおかしいです。本当に使っているかどうか、使用しているマテリアルを精査したり、StaticMeshEditor上でもUVレイアウトを表示できますので、確認してみてください。(緑枠内のメニュー参照)
過去に見たデータでは、UV2以降に同じデータが入っていることが多く、明らかに不要であることが分かる変なUVもありました。
なお、UVChannelの削除もStaticMeshEditor上から可能です。削除対象のUV Channelを選択し、UV>RemoveSelectedで削除できます。ただ、インポート元のFBXなどに問題がある場合は、再インポート時に復活してしまいますので、念のためインポート元のデータ(DCCのシーンデータ含む)も確認をお勧めします。
UV Channelsもそれ単体では大したサイズは有りませんが、HLODなど頂点数が数万頂点を超えるレベルになってくると無視できないサイズになります。Naniteなど頂点数の多いアセットを扱う機会は今後増えていくと思いますので、積極的に確認していく事をお勧めします。
対象の検索方法
StaticMeshの場合
StaticMeshに限って言えば、コンテンツブラウザでソートして対象を検索することが可能です。

フィルタでStaticMeshに限定して、Settings>Columnsを選択するとUVChannnelsの列が出てきます。そこでソートすれば、UVChannnelsが多いメッシュをあぶり出せます。
SkeletalMeshの場合
SkeletalMeshについては、残念ながらデフォルトでは対応しておらず、エンジン改造が必要になります。上級者向けになりますが、ついでに触れておきます。
Engine/Source/Runtime/Engine/Private/SkeletalMesh.cppに定義されているUSkeletalMesh::GetAssetRegistryTags()関数で、Columns表示する際に出す項目を収集しています。
void USkeletalMesh::GetAssetRegistryTags(FAssetRegistryTagsContext Context) const
{
(中略)
int32 NumTriangles = 0;
int32 NumVertices = 0;
int32 NumUVChannels = 0; // 追加
FSkeletalMeshRenderData* SkelMeshRenderData = GetResourceForRendering();
if (SkelMeshRenderData && SkelMeshRenderData->LODRenderData.Num() > 0)
{
const FSkeletalMeshLODRenderData& LODData = SkelMeshRenderData->LODRenderData[0];
NumTriangles = LODData.GetTotalFaces();
NumVertices = LODData.GetNumVertices();
NumUVChannels = LODData.GetNumTexCoords(); //追加
}
(中略)
Context.AddTag(FAssetRegistryTag("Vertices", FString::FromInt(NumVertices), FAssetRegistryTag::TT_Numerical));
Context.AddTag(FAssetRegistryTag("Triangles", FString::FromInt(NumTriangles), FAssetRegistryTag::TT_Numerical));
Context.AddTag(FAssetRegistryTag("UVChannels", FString::FromInt(NumUVChannels), FAssetRegistryTag::TT_Numerical) ); // 追加
Context.AddTag(FAssetRegistryTag("LODs", FString::FromInt(NumLODs), FAssetRegistryTag::TT_Numerical));
(以下略)
という感じで、UVChannlesの情報を追加してあげれば、StaticMeshと同じようにコンテンツブラウザ上にも出てくるようになります。
まとめ
メモリ不足が問題になった際、最初は無駄なテクスチャのロードが無いかを徹底的に洗い出していました。テクスチャはアセットの中でもサイズが大きくなりがちなので、真っ先に目を付けられる種類なんですが、意外とそれよりStaticMeshアセットの設定変更(コリジョンとUV)の方が稼いだメモリは多かったようです。
この辺は、Validate Assetsなどの機能を拡張するとか、コマンドレットで検査するツールを作りCIに組み込むとかしても良さそうです。
書いている順番とは逆ですが、メッシュアセットの精査をした後にプログラムから確保しているメモリを見ていって、こちらも意外と掘り出し物がありました。特にプラグインの類は、デフォルトでONだけど使ってない機能が多いんじゃないかと思います。
1つ当たりは小さいもがほとんどですが、ちりも積もれば山となるで、余裕のあるうちに精査して減らしておくといいかもしれません。(忘れなければ埋蔵金的に取っておくという手もありますが…)