はじめに
こんにちは、クライアントエンジニアの下野です。
今回はFMeshDescriptionを使ってShadowのマテリアルを統合してドローコールを減らす遊びをします。
これをうまくやれば最適化になる気もしますが、一旦は触って遊んでみた所感とかがメインです。
前提
エンジンのバージョンは5.6です。バージョンによってはコードが変わったりするので完全にコードを一致させても動かない場合があります。
以前やったエンジン改造で特に書いてませんでしたが、基本的にベイクされるシャドウのお話では無く、カスケードシャドウとかの動的な影のお話になります。
なぜShadowを最適化するのか
以前やったエンジン改造でもShadow系の最適化をしていますが、Shadowが負荷高くなりがちな理由はちゃんとあります。
Basepassとか、一般的にゲーム画面に描画するものはカメラの範囲まででカリングされているので、投下されるメッシュは少ないです。
カリングにもいくつか種類がありますが、本題からそれるのでまたいつか。

しかし、影というのはカメラの外にあるオブジェクトも考慮しないといけません。
ビル等の大きいオブジェクトがカメラの視界外に出た場合。いきなり影が落ちなくなったら不自然なので、通常のカメラより広い範囲から見た目を作らないといけないです。

どの程度の距離まで影を落とすかは、Directional Lightの中に設定があります。(ドキュメントの更新が無いので4.27の資料です)
デフォルト設定だと、かなり遠くまで考慮してメッシュを投下しています。
そのため、必然的に投下物が増えて重くなりがちなわけです。
本題
ただでさえ重くなりがちなShadowなので、せめてドローコールくらいは減らそうというのが今回の対応です。
ドローコールとはCPUがGPUに出す描画命令のことで、描画に必要なデータを送ったりするため投下物が多いと負荷になります。
UEの描画の基礎的なことが知りたい方は以下のスライドが丁寧なのでおススメです。
ドローコールはメッシュ単位ではなくセクション単位で発行されます。
セクションというのは↓のようなものです。該当メッシュに使われているマテリアルがセットされています。
マテリアルスロットとは別物で、LOD毎にどのマテリアルを使ってるか等をセットしています。

数が多ければ多いほど、CPUがGPUにいっぱい描画命令を送るため負荷が上がります。
Basepassの投下はゲーム上の見た目に関係してくるためマテリアルの情報が必要になります。
しかし、影はRenderDocなどで見ればわかりますが、ほとんどの場合に処理が軽いデフォルトマテリアルに置換されます。(深度さえ取れれば何でもいいため)

そのため、シャドウに関してはセクション分ける必要が無いので、マテリアルを1つにしてしまえば処理が速くなるわけです。
これを実現するために、FMeshDescriptionを使ってStaticMeshを弄ります。
注意点として、エンジンコードを見れば分かりますがマスクマテリアルでOpacityMaskが動的変更される場合は、クリップ処理入る可能性があるためデフォルトマテリアルに変換されません。
セクション合成する場合にマスクマテリアルと不透明マテリアルが混ざったメッシュの場合、どっちかの情報のみしか取れないので元の見た目と乖離する可能性があります。
余談
今回の最適化はドローコールを減らす最適化なのですが、正直DrawCallが負荷の原因になることはあまりないです。
というのも、UE5ではAuto Instancingによって同じオブジェクト、同じマテリアルなら自動的にドローコールを使いまわして描画する機能が備わっています。
100個同じメッシュとマテリアルがあっても、実際にドローコールが呼ばれるのは1回だけになったりします。(流石にMaterialInstanceDynamicとかでパラメータ変えてたら別になるけど)


「建物1個1個が全部違うメッシュ、違うマテリアルとかで10000個あります!!」とかなら流石に問題になりますが、そんなこと滅多に起こらないでしょう。
そのため、ドローコール起因の負荷なのかをちゃんとプロファイルして確認してから最適化に移った方が時間を無駄にしなくて良きです。
余談はここまでになります。
FMeshDescriptionとは
FMeshDescriptionとは、頂点やポリジョン、UV等のメッシュの構造をまとめているクラスです。
ランタイム上でのメッシュ生成や変更、おまけに複数メッシュの統合なども出来るため、非常に便利なものです。
使用するためにはBuild.csのPrivateDependencyModuleNamesにMeshDescriptionとStaticMeshDescription の2つを含める必要があるので別途対応してください。
それでは実装に移ります。
実装
ファイル分けるの面倒だったのでActorにコードべた書きですが、本当はStaticMeshComponentとか継承して書いた方がいいです。
最適化系は後から実装しがちなので、アクター置き直しよりコンポーネント置き直しの方が被害が少ないからです。
ヘッダー
#pragma once #include "ShadowOptimizeMeshActor.generated.h" UCLASS(BlueprintType) class AShadowOptimizeMeshActor : public AActor { GENERATED_BODY() public: AShadowOptimizeMeshActor(); // こっちは描画用 UPROPERTY(EditAnywhere) TObjectPtr<UStaticMeshComponent> StaticMesh; // こっちは影だけ表示用(変にメッシュ弄られても困るので公開はしない方が良い) TObjectPtr<UStaticMeshComponent> ShadowStaticMesh; protected: virtual void BeginPlay() override; private: UStaticMesh* CreateShadowStaticMesh(const UStaticMesh* Mesh, const int32 LODIndex = 0); FMeshDescription CreateMeshDescription(const UStaticMesh* Mesh, const int32 LODIndex = 0); };
#include "ShadowOptimizeMeshActor.h" #include "StaticMeshAttributes.h" AShadowOptimizeMeshActor::AShadowOptimizeMeshActor() { RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("SceneComponent")); StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMeshComponent")); ShadowStaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ShadowStaticMeshComponent")); StaticMesh->SetupAttachment(RootComponent); ShadowStaticMesh->SetupAttachment(StaticMesh); // StaticMeshの影なので、追従先はStaticMeshであるべき } void AShadowOptimizeMeshActor::BeginPlay() { if (!::IsValid(StaticMesh) && !::IsValid(StaticMesh->GetStaticMesh()) && !::IsValid(ShadowStaticMesh)) { return; } // LOD指定があるかもしれない // int32 MinLOD = StaticMesh->MinLOD; UStaticMesh* ShadowMesh = CreateShadowStaticMesh(StaticMesh->GetStaticMesh()); if (::IsValid(ShadowMesh)) { // メッシュセットして影以外投下しない設定する ShadowStaticMesh->SetStaticMesh(ShadowMesh); ShadowStaticMesh->SetHiddenInGame(true); // Basepass投下しない設定 ShadowStaticMesh->SetCastHiddenShadow(true); // HiddenInGameでも影を落とす設定 // BaseのCastShadowはオフ StaticMesh->SetCastShadow(false); } } UStaticMesh* AShadowOptimizeMeshActor::CreateShadowStaticMesh(const UStaticMesh* Mesh, const int32 LODIndex) { if (!::IsValid(Mesh)) { return nullptr; } FMeshDescription Desc = CreateMeshDescription(Mesh); // ビルド設定(今回はコリジョン無し) UStaticMesh::FBuildMeshDescriptionsParams Params; Params.bUseHashAsGuid = false; Params.bMarkPackageDirty = true; Params.bBuildSimpleCollision = false; Params.bCommitMeshDescription = true; Params.bAllowCpuAccess = true; // 非Editorビルドでは必須、デフォルトはfalseなので注意 #ifで分けてもいい Params.bFastBuild = true; TArray<const FMeshDescription*> MeshDescs; MeshDescs.Emplace(&Desc); UStaticMesh* ShadowMesh = NewObject<UStaticMesh>(); // FMeshDescriptionからStaticMeshへビルド ShadowMesh->BuildFromMeshDescriptions(MeshDescs, Params); // 適当なマテリアルを突っ込む(どうせデフォルトに置換されるので) ShadowMesh->GetStaticMaterials().Add(FStaticMaterial(Mesh->GetMaterial(0))); ShadowMesh->GetStaticMaterials()[0].UVChannelData.bInitialized = true; // BeginPlayで呼び出すと初期化されないので、明示的に return ShadowMesh; } FMeshDescription AShadowOptimizeMeshActor::CreateMeshDescription(const UStaticMesh* Mesh, const int32 LODIndex) { // LOD存在しない場合エラー出るので確認大事 int32 Index = FMath::Min(Mesh->GetRenderData()->LODResources.Num(), LODIndex); const FStaticMeshLODResources& LODResource = Mesh->GetRenderData()->LODResources[Index]; FMeshDescription MeshDescription; FStaticMeshAttributes AttributeGetter(MeshDescription); AttributeGetter.Register(); // 余談だけど、ブログにコード貼り付けるとインデントがスペースに代わるので、コピペする人は直してあげて下さい。 TPolygonGroupAttributesRef<FName> PolygonGroupNames = AttributeGetter.GetPolygonGroupMaterialSlotNames(); TVertexAttributesRef<FVector3f> VertexPositions = AttributeGetter.GetVertexPositions(); TVertexInstanceAttributesRef<float> BinormalSigns = AttributeGetter.GetVertexInstanceBinormalSigns(); TVertexInstanceAttributesRef<FVector3f> Normals = AttributeGetter.GetVertexInstanceNormals(); TVertexInstanceAttributesRef<FVector4f> Colors = AttributeGetter.GetVertexInstanceColors(); TVertexInstanceAttributesRef<FVector2f> UVs = AttributeGetter.GetVertexInstanceUVs(); // セクション数は1個に強制する const int32 NumSections = 1; int32 NumVertices = LODResource.VertexBuffers.PositionVertexBuffer.GetNumVertices(); int32 NumVertexInstances = 0; int32 NumPolygons = 0; for (const FStaticMeshSection& Section : LODResource.Sections) { NumVertexInstances += Section.NumTriangles * 3; NumPolygons += Section.NumTriangles; } TArray<FPolygonGroupID> PolygonGroupForSection; PolygonGroupForSection.Reserve(NumSections); // 頂点やインスタンスの合計数を計算 MeshDescription.ReserveNewVertices(NumVertices); MeshDescription.ReserveNewVertexInstances(NumVertexInstances); MeshDescription.ReserveNewPolygons(NumPolygons); MeshDescription.ReserveNewEdges(NumPolygons * 2); UVs.SetNumChannels(1); // 新しいポリゴングループを作成 FPolygonGroupID NewPolygonGroupID = MeshDescription.CreatePolygonGroup(); PolygonGroupNames[NewPolygonGroupID] = ""; PolygonGroupForSection.Add(NewPolygonGroupID); FPolygonGroupID PolygonGroupID = PolygonGroupForSection[0]; // 頂点を作成 int32 NumVertex = NumVertices; TMap<int32, FVertexID> VertexIDs; VertexIDs.Reserve(NumVertex); for (uint32 i = 0; i < LODResource.VertexBuffers.PositionVertexBuffer.GetNumVertices(); ++i) { const FVertexID VertexID = MeshDescription.CreateVertex(); VertexPositions[VertexID] = FVector3f(LODResource.VertexBuffers.PositionVertexBuffer.VertexPosition(i)); VertexIDs.Add(i, VertexID); } // インスタンスを作成 int32 SectionsIndex = 0; TMap<int32, FVertexInstanceID> VertexIdMap; VertexIdMap.Reserve(NumVertexInstances); for (int i = 0; i < LODResource.Sections.Num(); i++) { const FStaticMeshSection& Section = LODResource.Sections[i]; // 頂点カラーだのUVだのを突っ込むけど、影作るうえであんまり重要じゃないので適当に for (uint32 j = 0; j < Section.NumTriangles * 3; j++) { const int32 VertexIndex = LODResource.IndexBuffer.GetIndex(j); const FVertexID VertexID = VertexIDs[VertexIndex]; const FVertexInstanceID VertexInstanceID = MeshDescription.CreateVertexInstance(VertexID); int32 MapIndex = j + SectionsIndex; // TMapだと同じキー重なるので、いい感じにずらす。 VertexIdMap.Add(MapIndex, VertexInstanceID); Normals[VertexInstanceID] = (FVector3f)LODResource.VertexBuffers.StaticMeshVertexBuffer.VertexTangentZ(VertexIndex); BinormalSigns[VertexInstanceID] = 1.0f; Colors[VertexInstanceID] = FLinearColor::White; UVs.Set(VertexInstanceID, 0, FVector2f(LODResource.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(VertexIndex, 0))); } SectionsIndex += Section.NumTriangles * 3; } // ポリゴンを作成 for (int32 TriIdx = 0; TriIdx < NumPolygons; TriIdx++) { TArray<FVertexInstanceID> VertexInstanceIDs; VertexInstanceIDs.SetNum(3); VertexInstanceIDs[0] = VertexIdMap[(TriIdx * 3) + 0]; VertexInstanceIDs[1] = VertexIdMap[(TriIdx * 3) + 1]; VertexInstanceIDs[2] = VertexIdMap[(TriIdx * 3) + 2]; MeshDescription.CreatePolygon(PolygonGroupID, VertexInstanceIDs); } return MeshDescription; }
解説
ヘッダーについては特に解説必要な物も無い気がします。
強いて言えば、描画用のメッシュと影用のメッシュでコンポーネントを分けないといけない位でしょうか。
Descriptionの生成
新しいStaticMeshを作るためには、FMeshDescriptionが必要です。CreateMeshDescriptionで作成していきます。
最初にLODの指定です。LODが複数ある場合やMinLODでLOD強制している場合は別途対応してあげて下さい。
LODを決めたら、メッシュにある頂点情報とかUV情報とかを新しく作成したFMeshDescriptionに書き込んでいきます。
ポリゴングループとかを書き込む際は、生の変数がprivateでアクセス出来ないので、FStaticMeshAttributesを宣言して書いてあげて下さい。
セクション数は問答無用で1にします。
そのため、セクションごとに分かれている頂点を全て1つにまとめるために、for文回してMap内に格納しています。
int32 MapIndex = j + SectionsIndex;の部分はマップ配列はキー重複したら上書き入りバグるので、セクションごとに分けるためにやっています。インデックス重複あったら削除したり色々メッシュに最適化を入れるとGPUの短縮にもなる気がします。
メッシュとマテリアルの生成
FMeshDescriptionが完成したら、BuildFromMeshDescriptionsでメッシュを生成します。
bFastBuildが非エディタビルドでは必須なので注意してください。
メッシュの生成が終わったらマテリアルをセットします。
UStaticMeshのマテリアルはFStaticMaterialになるらしいです。
別に変える必要もないので表示用メッシュのマテリアルを引っ張っておきます。
コンポーネントの設定
ここまで終わってnullptrが返ってきていないなら生成が正しく終わっているので、コンポーネントの設定をします。
影用メッシュコンポーネントに作成したメッシュをセットして、HiddenInGameとCastHiddenShadowのフラグをTrueにします。
HiddenInGameがゲーム中に表示しない設定で、CastHiddenShadowがHiddenInGame中でも影を落とすための設定です。
最後に、描画用メッシュのCastShadowをFalseにしたら完成です。
今回はBeginePlayで生成していますが、ランタイム以外でメッシュ作成も出来るので、エディターで事前に作る方が多分良いです。
デバック
今回はFabにあった線路アセットが、ちょうどよく複数セクションあったのでこれを使用します。

最初に、CPU上で頂点情報などにアクセスするため、StaticMeshのAllow CPU Accessの項目にチェックを入れます。

見た目
C++で作成したクラスを継承してBPを作成し、StaticMeshの枠に線路を突っ込み、レベルに配置しPIEを起動すると…

見た目では分かりませんね。
影用メッシュをエディターで触れるようにUPROPERTY設定してみると↓の様になっています。
ShadowStaticMesh_1なのは名前を特に決めてないからで、指定があれば入れてあげて下さい。

このStaticMeshを開くと、↓のように新規で作成したメッシュが見れます。
ちゃんとエレメントも1個になっていて仕様通りです。

負荷
RenderDocで見てみます。
SM_Rails_Sunk_01が該当のメッシュで、Basepassでは↓のように4つのマテリアルが投下されていました。

ShadowDepthパスでは下のようにすべてが統合された1個が投下されています。
想定通りですね。

次に、statで確認してみます。
stat SceneRenderingを入力するとDrawCall数とか色々見れます。
通常ではAVG 39でした。

今回実装したものはAVG 30でした。1個しか置いてないのに結構変わりますね。

stat InitViewsを入力するとプリミティブの数が見れます。
デフォルトだと 8.00でした。

今回の実装では、見た目用と影用でメッシュが分かれているので、プリミティブは1個増えて9.00となってしまいます。

これに関しては頑張れば以前やったエンジン改造の奴みたいに、プリミティブ1個に減らせる気がしますが、今回はFMeshDescriptionで遊ぶのが目的なので特に行いません。
やり方は色々考えただけで特に試してないので、出来そうという感想だけ残しておきます。
終わり
いかがでしたか?
実際に使う場合は同じインデックス参照してる場合はポリゴン統合したり、以前やったエンジン改造みたいに、プリミティブ1つでやりくりした方が効率が良いですが、こだわり過ぎると一生ブログ更新できなくなるので、どこまでやるかは個々人の裁量に任せます。でもBeginePlayでメッシュ生成するのだけは変えた方が良いです。
ブログ内でも話しましたが、UEはインスタンシング描画とかドローコール減らす処理がすごく頑張っているので、ドローコールが負荷の問題になることはあんまり無い気がします。
ですが、いざ困った時に対策を何も知らないと無駄に時間が必要になるので、解決策は何かしら持っておいても良いのではというのが私の感想です。
最後に余談ですが、ゲームしていて某ローポリブドウみたいなのとかビルボードとかを見かけると「頑張って削ってるんだなぁ」とほっこりします。
逆に某リヴァイアサンおにぎり的な物も、こだわりは感じるのでそれはそれで良いなぁとも思います。
ゲームは奥深いですね。