SPARK CREATIVE Tech Blog

https://spark-group.jp/

【UE5】ディザを嗜む(ついでに最適化)

はじめに


こんにちは、クライアントエンジニアの下野です。

今回は初心に立ち返り、ディザ抜きのマテリアルを作っていこうと思います。

エンジンのバージョンは5.6を使用しています。

ディザとは


ディザは、透明度を扱えないマテリアル設定(Masked)で、アンチエイリアス機能を利用して「擬似的な半透明」を表現する手法です。

実体と透過を網掛けのように表示して、TAAなどのアンチエイリアシングでぼかす事で半透明の見た目を再現してます。

よく使われるのは、プレイヤーとカメラの間にオブジェクトが入り込んだ場合に透過処理する場合です。

↓のような見た目になります。ノイズっぽい見た目で透明化するのが特徴です。

主なメリットは下の3つです。

  • 描画負荷の軽減: 半透明(Translucent)は重なり合うと描画負荷(オーバードロー)が急増しますが、ディザ抜きは不透明に近い処理のため軽量です。
  • ソート順問題の回避: 半透明マテリアルで発生しがちな「描画順序が狂って奥のものが手前に見える」といった不具合が発生しません。
  • 影の描画: 通常の半透明では難しい「影を落とす」処理も、Masked設定であれば容易に行えます。

ディザは網掛け上にピクセルを抜く仕様から、中途半端に背景物の深度とってきてDOFで見た目変になったり、TAA使う都合でゴースティング気になったり等、半透明にした方が良い案件が度々出てきます。それでも負荷の面で圧倒的に半透明より早いのが利点です。

上記のメリットがあるため、まだしばらく使い分けが続くと思います。

前置きはここまでにして、さっそく始めていきます。

実装


円形に抜くとか他のアクターを考慮するとかディザパターン変えるとかを考えなければ、UEには既にディザ用のノードがあるためとても簡単です。

DitherTemporalAAノードは、Alpha Thresholdに透明度を入れたら、入れた値の透明度になるように0, 1を返してくれます。

カメラと近いほどディザ度合を強くするマテリアルを作成します。

この程度の処理ならマテリアル内で完結しますが、後述のためにパラメータを出しています。

マテリアルを直接セットしてしまうと、同じ処理でも別のテクスチャで使用したい等の場合に面倒になるため、BPなどにセットするのはマテリアルインスタンスを使用しましょう

C++のコードでMaterialInstanceDynamic(MID)を作成し、値をTickでセットしてあげれば完成です。

DitherActor::BeginePlay()
{
    // Meshとか各変数は各々で実装お願いします
    TArray<UMaterialInterface*> MeshMaterials = Mesh->GetMaterials();
    for (int i = 0; UMaterialInterface* MI : MeshMaterials)
    {
        // MIDにしないと値の変更が出来ません
        UMaterialInstanceDynamic* DitherMaterial = UMaterialInstanceDynamic::Create(MI, Mesh);

        DitherMaterials.Add(DitherMaterial);
        MeshComponent->SetMaterial(i++, DitherMaterial);
    }
}

DitherActor::Tick(float DeltaTime)
{
    FVector TargetLocation = FVector::ZeroVector;
    FVector ActorLocation = GetActorLocation();
    
    float Distance = FVector::Dist(TargetLocation, ActorLocation);
    
    float Rate = FMath::GetMappedRangeValueClamped(
        FVector2D(300.0f, 2000.0f),
        FVector2D(0.0f, 1.0f),
        Distance
    );

    for (UMaterialInstanceDynamic* MID : DitherMaterials)
    {
        MID->SetScalarParameterValue(TEXT("DitheringRate"), Rate);
    }
}

今回は原点(矢印の位置)に近いほど透明化して見えます。TargetLocationにプレイヤーの座標を入れたりしたらゲームでもつかえます。

最適化を考えよう


最初に負荷をとります。

1個では誤差レベルなので、100個ほど置いて計測しました。

ShadowPassやVelocityパスなど、メッシュが投下される全てのパスを見るべきではありますが、減ってることが分かればいいので、今回は単純にBasepassだけを見ます。

ここから負荷を減らしていきます。

常駐の負荷を減らす


常駐がMaskedだとOpaqueに比べて負荷高いです。

Opaueと違いMaskedはOpacityの値を見て描画クリップするかの分岐処理があったりしますが、今回の処理ではカメラから離れたら絶対にディザをしないので分岐処理が無駄になります。

常駐はOpaueで必要なタイミングでMaskedにする処理を組みます。

やることは簡単で、Opaqueなマテリアルインスタンスを作成し、不透明になった際にマテリアルを切り替えるだけです。

通常マテリアルとディザマテリアルを区別するために、ディザの方のマテリアルの名前に_Masked を付けることを統一しておくとC++の処理が楽です。

StaticMeshのマテリアルをOpaqueの物に変更して、

C++でMIDを作成する際に、Masked のマテリアルをロードして、SetMaterialで切り替えると終わりです。

今回は値が1ならOpaqueマテリアルにし、それ以外はMaskedで置き換えるようにしています。

DitherActor::BeginePlay()
{
    // 元に戻すためにキャッシュする
    MeshMaterials = Mesh->GetMaterials();
    for (UMaterialInterface* MI : MeshMaterials)
    {
        // ファイル名と拡張子を分割(_Maskedを名前に付けるため)
        FString LeftStr, RightStr;
        MI->GetPathName().Split(TEXT("."), &LeftStr, &RightStr);
        FString DitherMaterialPath = LeftStr + TEXT("_Masked.") + RightStr + TEXT("_Masked");
        
        // 元の名前に_Maskedを追加してファイルをロード
        if(UMaterialInstance* DitherMI = Cast<UMaterialInstance>(StaticLoadObject(UMaterialInstace::StaticClass(), nullptr, *DitherMaterialPath)))
        {
            UMaterialInstanceDynamic* DitherMaterial = UMaterialInstanceDynamic::Create(DitherMI, Mesh);
    
            DitherMaterials.Add(DitherMaterial);
        }
    }
}

DitherActor::Tick(float DeltaTime)
{
    // 同じだから省略
    float Rate = ;

    if (Rate < 1.0)
    {
        for (int i = 0; UMaterialInstanceDynamic* MID : DitherMaterials)
        {
            Mesh->SetMaterial(i++, MID);
            MID->SetScalarParameterValue(TEXT("DitheringRate"), Rate);
        }
    }
    else
    {
        for (int i = 0; UMaterialInterface* MI : MeshMaterials)
        {
            Mesh->SetMaterial(i++, MI);
        }
    }
}

完全に実体な物はOpaqueになります。(違いを分かりやすくするため、Opaqueな物は赤色にしています)

Basepassは0.02msほど減りました。

Cubeでこれなので、メッシュが高ポリになるほど最適化効果は高くなります。

StaticLoadだと読み込み中メインスレッドが止まってしまうので非同期読み込みにしたり、そもBPからセットしたりなどすれば楽ですが、BeginPlayは常駐じゃないので今回は諸々見送ります。

一応非同期にするだけでメインスレッドが止まらなくなり、ある程度負荷変わりますの例だけ置いておきます。

同期読み込み

非同期読み込み

余談(MaterialInstanceDynamicを減らす)


UEでは同じメッシュ、同じマテリアルならドローコールをまとめるAuto Insatncingが走り、ドローコールを減らした効率の良い描画をしてくれます。

しかし、メッシュごとに異なる値(今回の場合はディザ度合い)を入れようとMIDを作ると、同じメッシュでもドローコールをまとめられないため、インスタンシングが剝がれてしまいます。

これを解消する場合はCustom Primitive Data(CPD) を使うのがいいです。

インスタンス毎に値を変えれて、マテリアルからの参照も楽で、インスタンシング描画も崩れないとかなり便利な代物です。

DitherActor::Tick(float DeltaTime)
{
    // 同じだから省略
    float Rate = ;
    if (Rate < 1.0)
    {
        // MID作成しないのでInterface取るように変数も変更する
        for (int i = 0; UMaterialInterface* MI : DitherList)
        {
            Mesh->SetMaterial(i++, MI);
        }
    }
    else
    {
        for (int i = 0; UMaterialInterface* MI : MeshMaterials)
        {
            Mesh->SetMaterial(i++, MI);
        }
    }

    // Material側のIDと合わせる、パラメータ出した方がデバック楽
    int32 DitheringRateIndex = 0;
    Mesh()->SetCustomPrimitiveDataFloat(DitheringRateIndex, Rate);
}

メッシュ毎に個別の値が入り、インスタンシングも崩れないためドローコールが減ります。

値違くてもまとまる

一応Opaque置換も出来る

じゃあMID使わず、全てをCPDにすればいいかと言われるとそうでもありません。

CPDは値を保持できますがTextureの保持ができません。Textureの変更をしたい場合はMIDを使うしかないです。

それだけでなく、MIDはパラメータを名前で管理できますが、CPDはID管理なためデバックの楽さが違います。

一応配置したアクターを選択したらIDと名前は出るのですが、私の環境ではDefaults以外にパラメータが無くPIE実行中の値を見ることができなかったので、MIDの方が管理が楽でした。

明らかに半透明なのに、値は1.0となっている

色々書きましたが、プロジェクトややりたい事次第なので、いろいろ動かしながら試すのがいいです。

雑にGeminiに使い分けを出してもらった奴

終わり


いかがでしたか?

Maskedなマテリアルを使うことがある際は、ぜひ参考にしてみて下さい。

マテリアルの置き換えも工夫1つでファイルのロードが減り、ゲームの起動時間が変わるので大事です。

ディザマテリアルの作成はEditorUtilityBlueprintなどで行えば効率化できるので、次回か空いたタイミングでブログにします。

負荷最適化を通して、UEの様々な仕様とか設計とかが見えてくるので、興味が出てきたら色々試してみて、最終的にエンジン改造まで手が出せるようになると幅が広がると思います。

©2025 SPARKCREATIVE Inc.