1. サイトトップ
  2. ブログ
  3. C++
  4. 【UE5】ランタイムでStaticMesh同士を結合してドローコールを削減しよう

【UE5】ランタイムでStaticMesh同士を結合してドローコールを削減しよう

新年あけましておめでとうございます。
情熱開発部プログラム2課の今井です。

2024年あっという間に過ぎてしまいましたね。昨年を振り返ると様々な技術的な挑戦ができた年になったように感じます。今年もできないことに挑戦する1年にしたいですね!

さて、そんな新年最初のブログとして紹介するのはドローコールの最適化についてです!
中でも今回はUE5のランタイムで利用できるStaticMeshのドローコール最適化手法を紹介します。

※使用バージョン
Unreal Engine:5.4.4
Visual Studio 2022:17.11.2

一般的なStaticMeshのドローコール数を確認してみる

まず一般的にStaticMeshを配置するとどのくらいのドローコールが呼ばれるのか気になりますよね。初めにその点を把握してみましょう!

今回、確認に利用するモデルデータはUnreal Engineのスターターコンテンツに含まれる以下の2つのモデルになります。

  • Content/StarterContent/Props/SM_Lamp_Ceiling.uasset
  • Content/StarterContent/Props/SM_Lamp_Wall.uasset

実際に上の2つのモデルを実際にレベルに配置していきます!

では配置したStaticMeshのドローコールを確認してみましょう!
今回ドローコールを確認するために以下のコンソールコマンドを利用します!

stat scenerendering

stat scenerendering」を利用することによってレンダリングに関する全般的な統計情報を確認することができます。中でも今回は「Mesh draw calls」という項目の値を見ていきます。
実際にCmdに「stat scenerendering」を入力して実行してみましょう!

実行してみると上の画像のように「Mesh draw calls」という項目の「Average」に約”13“という値が表示されます。
次にレベルに配置した2つのモデルを削除してみます。

すると今度は四捨五入で約”7”という値が表示されます。
ということはSM_Lamp_CeilingとSM_Lamp_Wallをシーンに配置しカメラに映した際の2つのモデルのメッシュのドローコール数は『約13-約7』で約”6”ということになりますね。

モデルを2つ配置するだけでドローコール数が約6回増えた!?」と驚きましたでしょうか。では実際にどのような仕組みでドローコール数が増えていくのか次の項目でより詳しくみていきましょう。

UE内部のStaticMeshのドローコール処理について

さて、ここからドローコールを削減していく訳ですがドローコールを削減するには「Unreal Engineでどのようなドローコール処理が行われているか」を知る必要があります。
StaticMeshのドローコール命令は主にエンジン内部の下のソースファイル内のDrawMesh関数が何回呼ばれるかによってドローコール数がおおよそ決まります。

Engine\Source\Runtime\Engine\Private\StaticMeshRender.cpp

上記ソースファイル内にDrawMesh関数が呼ばれる箇所がいくつかありますが大雑把にまとめると以下のようにDrawMesh関数が処理されるようになっていると思います。

for () // LODのループ
{
	for () // セクションのループ
	{
		PDI->DrawMesh();
	}
}

つまりStaticMeshのドローコール処理はセクション単位で呼び出されるようになっています。(例外部分あり)

LODのセクションとはエディタ画面でいうところの[LOD0]->[セクション]部分に当たります。

(SM_Lamp_Ceiling.uassetにおける例)

Unreal Engineのモデルは同じマテリアルなら同じセクションにまとめられることが多く、自然と

1マテリアル:1セクション

の関係になっている場合が多いです。

今回実際にレベルに配置した2つのStaticMeshのマテリアルは同じものを利用していますが、その場合はセクションが同じかどうか以前にモデル自体が異なるため別々のDrawMeshが呼ばれてしまいます。

<SM_Lamp_Ceiling.uassetのマテリアル>

<SM_Lamp_Wall.uassetのマテリアル>

これまでの情報からこの別々のStaticMeshがもし1つStaticMeshだった場合、同じマテリアルは同じDrawMeshとしてまとめて呼ばれるようになるはずですよね。
実際に同じStaticMeshにまとめる方法としてどのような方法があるかについて次の項目で紹介します。

FMeshDescriptionの紹介

今回別々のStaticMeshを1つのStaticMeshにまとめる方法としてFMeshDescriptionを利用します。
FMeshDescriptionとはメッシュの構造を頂点やエッジ、ポリゴンなどの要素で抽象化した構造体です。メッシュの生成、変換、編集、一時的な保存のために使用されます。このFMeshDescriptionを利用する大きな利点として、エディタのみの機能ではなくランタイム上でも利用することができる点があります。

定義ソースコード:Engine\Source\Runtime\MeshDescription\Public\MeshDescription.h

利用するにはプロジェクトのビルド設定ファイル([プロジェクト名].Build.cs)に「MeshDescription」と「StaticMeshDescription」を含める必要があります。

<<追加例>>

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "MeshDescription" , "StaticMeshDescription" });

StaticMesh同士を結合する処理の作成

では実際にFMeshDescriptionを活用してStaticMesh同士を結合する処理を作成してみましょう。

※今回は結合前のモデルがセクション数=1,LOD数=1を前提としてセクション0を結合する処理を作成しています。

#include "MeshDescription.h"
#include "StaticMeshAttributes.h"

FMeshDescription MergeStaticMeshToMeshDescription(const UStaticMesh* meshA, const UStaticMesh* meshB)
{
    // LOD 0 のリソースを取得
    const FStaticMeshLODResources& LODResourceA = meshA->GetRenderData()->LODResources[0];
    const FStaticMeshLODResources& LODResourceB = meshB->GetRenderData()->LODResources[0];
    const FStaticMeshSection& SectionA = LODResourceA.Sections[0];
    const FStaticMeshSection& SectionB = LODResourceB.Sections[0];

    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();

    const int32 NumSections = 1;
    int32 VertexCount = LODResourceA.VertexBuffers.PositionVertexBuffer.GetNumVertices() + LODResourceB.VertexBuffers.PositionVertexBuffer.GetNumVertices();
    int32 VertexInstanceCount = SectionA.NumTriangles * 3 + SectionB.NumTriangles * 3;
    int32 PolygonCount = SectionA.NumTriangles + SectionB.NumTriangles;

    TArray<FPolygonGroupID> PolygonGroupForSection;
    PolygonGroupForSection.Reserve(NumSections);

    // 頂点やインスタンスの合計数を計算
    MeshDescription.ReserveNewVertices(VertexCount);
    MeshDescription.ReserveNewVertexInstances(VertexInstanceCount);
    MeshDescription.ReserveNewPolygons(PolygonCount);
    MeshDescription.ReserveNewEdges(PolygonCount * 2);
    UVs.SetNumChannels(1);

    // 新しいポリゴングループを作成
    FPolygonGroupID newPolygonGroupID = MeshDescription.CreatePolygonGroup();
    PolygonGroupNames[newPolygonGroupID] = "";
    PolygonGroupForSection.Add(newPolygonGroupID);

    FPolygonGroupID PolygonGroupID = PolygonGroupForSection[0];

    // 頂点を作成
    int32 NumVertex = VertexCount;
    TMap<int32, FVertexID> VertexIndexToVertexID;
    VertexIndexToVertexID.Reserve(NumVertex);
    for (uint32 i = 0; i < LODResourceA.VertexBuffers.PositionVertexBuffer.GetNumVertices(); ++i)
    {
        const FVertexID VertexID = MeshDescription.CreateVertex();
        VertexPositions[VertexID] = FVector3f(LODResourceA.VertexBuffers.PositionVertexBuffer.VertexPosition(i));
        VertexIndexToVertexID.Add(i, VertexID);
    }
    for (uint32 i = 0; i < LODResourceB.VertexBuffers.PositionVertexBuffer.GetNumVertices(); ++i)
    {
        const FVertexID VertexID = MeshDescription.CreateVertex();
        VertexPositions[VertexID] = FVector3f(LODResourceB.VertexBuffers.PositionVertexBuffer.VertexPosition(i));
        VertexIndexToVertexID.Add(i + LODResourceA.VertexBuffers.PositionVertexBuffer.GetNumVertices(), VertexID);
    }

    // インスタンスを作成
    int32 NumIndices = VertexInstanceCount;
    int32 NumTri = PolygonCount;
    TMap<int32, FVertexInstanceID> IndiceIndexToVertexInstanceID;
    IndiceIndexToVertexInstanceID.Reserve(NumIndices);
    for (uint32 i = 0; i < SectionA.NumTriangles * 3; i++)
    {
        const int32 VertexIndex = LODResourceA.IndexBuffer.GetIndex(i);
        const FVertexID VertexID = VertexIndexToVertexID[VertexIndex];
        const FVertexInstanceID VertexInstanceID = MeshDescription.CreateVertexInstance(VertexID);
        IndiceIndexToVertexInstanceID.Add(i, VertexInstanceID);

        Normals[VertexInstanceID] = (FVector3f)LODResourceA.VertexBuffers.StaticMeshVertexBuffer.VertexTangentZ(VertexIndex);
        BinormalSigns[VertexInstanceID] = 1.f;
        Colors[VertexInstanceID] = FLinearColor(1, 1, 1, 1);
        UVs.Set(VertexInstanceID, 0, FVector2f(LODResourceA.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(VertexIndex, 0)));
    }
    for (uint32 i = 0; i < SectionB.NumTriangles * 3; i++)
    {
        const int32 VertexIndex = LODResourceB.IndexBuffer.GetIndex(i);
        const FVertexID VertexID = VertexIndexToVertexID[VertexIndex + LODResourceA.VertexBuffers.PositionVertexBuffer.GetNumVertices()];
        const FVertexInstanceID VertexInstanceID = MeshDescription.CreateVertexInstance(VertexID);
        IndiceIndexToVertexInstanceID.Add(i + SectionA.NumTriangles * 3, VertexInstanceID);

        Normals[VertexInstanceID] = (FVector3f)LODResourceB.VertexBuffers.StaticMeshVertexBuffer.VertexTangentZ(VertexIndex);
        BinormalSigns[VertexInstanceID] = 1.f;
        Colors[VertexInstanceID] = FLinearColor(1, 1, 1, 1);
        UVs.Set(VertexInstanceID, 0, FVector2f(LODResourceB.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(VertexIndex, 0)));
    }

    // ポリゴンを作成
    for (int32 TriIdx = 0; TriIdx < PolygonCount; TriIdx++)
    {
        TArray<FVertexInstanceID> VertexInstanceIDs;
        VertexInstanceIDs.SetNum(3);

        VertexInstanceIDs[0] = IndiceIndexToVertexInstanceID[(TriIdx * 3)];
        VertexInstanceIDs[1] = IndiceIndexToVertexInstanceID[(TriIdx * 3) + 1];
        VertexInstanceIDs[2] = IndiceIndexToVertexInstanceID[(TriIdx * 3) + 2];

        MeshDescription.CreatePolygon(PolygonGroupID, VertexInstanceIDs);
    }
    return MeshDescription;
}

UStaticMesh* MergeStaticMesh(const UStaticMesh* meshA, const UStaticMesh* meshB)
{
	if (meshA == nullptr || meshB == nullptr) {
		return nullptr;
	}

    FMeshDescription meshDesc = MergeStaticMeshToMeshDescription(meshA, meshB);

    // ビルド設定
    UStaticMesh::FBuildMeshDescriptionsParams mdParams;
    mdParams.bUseHashAsGuid = false;
    mdParams.bMarkPackageDirty = true;
    mdParams.bBuildSimpleCollision = false;
    mdParams.bCommitMeshDescription = true;
    mdParams.bAllowCpuAccess = true;

    // 非Editorビルドでは必須
    mdParams.bFastBuild = true;

    TArray<const FMeshDescription*> MeshDescs;
    MeshDescs.Emplace(&meshDesc);
    UStaticMesh* MainStaticMesh = NewObject<UStaticMesh>();

    // FMeshDescriptionからStaticMeshへビルド
    MainStaticMesh->BuildFromMeshDescriptions(MeshDescs, mdParams);

    // マテリアルの設定(両StaticMeshともに同じマテリアルなので片方のみ利用して設定)
    MainStaticMesh->GetStaticMaterials().Add(FStaticMaterial(meshA->GetMaterial(0)));

	return MainStaticMesh;
}

まず関数が2つ確認できると思います。MergeStaticMesh関数では入力された2つStaticMeshから新しい結合されたStaticMeshを作成するための入口となる役割をしています。実際に結合処理を記述しているのはMergeStaticMeshToMeshDescription関数になります。

MergeStaticMeshToMeshDescription関数内では、引数で渡された2つのStaticMeshから頂点の情報やUV、インデックスの情報を取得しFMeshDescriptionに交互に設定しています。
その後、MergeStaticMesh関数内でUStaticMeshのBuildFromMeshDescriptions関数を利用してFMeshDescriptionからUStaticMeshに変換しています。

上記関数処理コードは結合させたStaticMeshComponentを所持したいAActorクラスやマネージャークラスに記述するとよいと思います。

処理を実行して確認してみる

実行する前に1点準備することがあります。
今回C++(CPU)上でStaticMeshの頂点情報などを利用したいため、利用するStaticMeshの「Allow CPU Access」の項目にチェックを入れる必要があります。

これで結合させたいStaticMeshの頂点などを取得する準備が整いました!
関数の引数に渡す2つのStaticMeshは最初に紹介したレベルに配置してある「SM_Lamp_Ceiling」と「SM_Lamp_Wall」を設定します。

それでは実際に先ほどのコードを実行してみましょう!

画像右側に表示されているモデルが結合後のモデルです。見た目は変わりませんがMesh Draw callsのAverageを確認すると約”9”と表示されています!
最初結合する前は約”13”だったので4ドローコール分ほど少なくなりました!

次に新しく結合して生成されたStaticMeshがちゃんとできているか見てみましょう。

生成したStaticMeshを確認すると赤枠と青枠の結合前の2つのモデルが同じビュー画面に表示され、緑枠の通り一つのマテリアルセクションになっています。これらから結合された一つのStaticMeshになっていることがわかりますね。

まとめ

今回紹介した方法を行うには以下の条件があります。

  • 同じマテリアルが割り当てられたセクションがあること
  • 結合後に結合前の別々のモデル単位で編集しないこと

難しい条件にも感じますが、ランタイム上で利用でき柔軟に対応することが可能だと思いますので是非利用してみてください!


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。