UE4 ComputeShaderでMeshの頂点バッファをリアルタイム書き換えする (GPGPU)

エンジンバージョン:4.22, 4.23
諸事情によりサンプルプロジェクト無し

UE4のProceduralMeshのようなことをComputeShaderでできたら、大量の頂点をリアルタイムに操作できて面白いのではないかという思い付き。
今回はたくさんの草をリアルタイムに生成する目的で実装してある程度うまくいったのでメモ。
この方法を応用すれば頂点カラーとかにInstanceID的なものやそのほか特殊な情報を埋め込んでおいてマテリアルBPで利用するといったこともできる。

この記事はC++とGlobalShaderを利用したもので、且つエンジン改造はしない。
また、自前で用意した頂点バッファをComputeShaderで書き換えてUE4のメッシュ描画に流す方法がメインであるため、実装したComputeShader自体については深く説明しない。

www.youtube.com
↑プレイヤーからの影響を草一本一本が受ける例。
草の位置や基準方向などを構造化バッファで保持しているのでそれらをComputeShaderで更新すればこのようなことも可能。
www.youtube.com
↑さらにマテリアルBP側で頂点オフセットによる風揺れをつけた例。
描画自体はUE4のメッシュ描画に乗っかっているのでStaticMesh等と同じようにマテリアルが使えるのが強みの一つ。

基本の考え

自分で作成した頂点バッファやインデックスバッファをUE4のメッシュ描画に流し込む方法は意外と簡単で、FPrimitiveSceneProxy派生クラスでGetDynamicMeshElements()関数をoverrideして適切にバッファ情報を渡せば可能になる。
そこでこれらのバッファをUnorderedAccess可能な設定で作成しておき、毎フレームComputeShaderで書き換えるようにすることで、形状をリアルタイムに変更しつつマテリアル等を含めた描画部分はUE4の仕組みをそのまま利用できると考えた。

f:id:nagakagachi:20190922201851p:plain
フローのイメージ

必要な要素の概要

  • 自作ComputeShader用GlobalShader
    • 自作MeshProxyが生成した頂点バッファUAVやインデックスバッファUAVへ読み書きして草メッシュを作る
    • 草メッシュの位置や向きは別途StructuredBufferで与える
  • 自作MeshComponentクラス
    • 対になる自作MeshProxyを生成してシステムに登録し、ゲーム側からの情報を通知する役割
  • 自作MeshProxyクラス
    • 自作MeshComponentの描画スレッド側における分身のような存在
    • UnorderedAccess可能な各種頂点バッファとインデックスバッファおよびそれらのUAVを作成する
    • UE4メッシュ描画システムのメッシュ情報収集に適切に頂点バッファなどを設定する
    • ComputeShaderのDispatchコマンド発行もする

自作ComputeShader用GlobalShader

自作MeshProxyの各バッファのUAVを設定して書き換えるComputeShaderのGlobalShader。
GlobalShader自体については公式のドキュメントを参考。
https://docs.unrealengine.com/ja/Programming/Rendering/ShaderInPlugin/QuickStart/index.html
ComputeShaderをGlobalShaderとして作成してUAV他パラメータを設定する流れについては以下のブログが参考になる。
[UE4][ComputeShader][HLSL][C++]Unreal Engine 4で(RW)StructuredBufferを用いたComputeShaderを利用する(その2:C++側シェーダクラス) – サイアメント技術メモ

今回はComputeShaderのスレッド一つが草一本を担当することとし、スレッドIDと草一つに割り当てられた頂点数からスレッドが書き込む頂点バッファ内の位置を決めている。あまり特殊なことはしていないが、接線法線頂点バッファ等がPackedフォーマットになっている場合はUAVから読み取った値をUnpackしたり書き戻す際に再度Packしたりといった処理が必要な点に注意。

f:id:nagakagachi:20190922220711p:plain
ComputeShaderの処理イメージ

自作MeshComponentクラス

UPrimitiveComponentを継承
コンポーネントとしてゲームスレッド側の情報を管理する。UProceduralMeshComponentを参考にした。
コンポーネントの描画スレッド側の情報を管理するカスタムMeshProxyクラスを生成して保持し、適切に情報を渡したりするのが主な役目。
適切なタイミングで自作MeshProxyへComputeShaderのDispatchコマンド生成をリクエストをする。

virtual void UComputeMeshComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction) override
{
    // SendRenderDynamicData_Concurrent の呼び出しをリクエスト
    MarkRenderDynamicDataDirty();
}
virtual void UComputeMeshComponent::SendRenderDynamicData_Concurrent() override
{
    // カスタムMeshProxyにComputeShaderのDispatchコマンド生成を要求
    カスタムMeshProxy->EnqueueDispatchComputeShader();
}

自作MeshProxyクラス

FPrimitiveSceneProxyを継承
今回の主役。FProceduralMeshSceneProxyを参考にした。

バッファ生成時の設定

ComputeShaderで読み書きをするバッファはUnorderedAccessView(UAV)として使用可能な設定で生成する必要がある。
そのため各種頂点バッファおよびインデックスバッファの生成時のusageフラグに BUF_UnorderedAccess を追加する。

FRHIResourceCreateInfo CreateInfo;
uint32 usage = BUF_Static | BUF_ShaderResource;
    if (need_uav_)
        usage |= BUF_UnorderedAccess;
VertexBufferRHI = RHICreateVertexBuffer( 頂点要素サイズ * 頂点数, usage, CreateInfo);

そのうえで RHICreateUnorderedAccessView() 関数でUAVを生成する。このUAVに対してComputeShaderで読み書きすることで頂点位置などを書き換えることができる。

if (need_uav_)
    uav_ = RHICreateUnorderedAccessView(VertexBufferRHI, PF_R32_FLOAT);

今回の草は1本につき6頂点、4トライアングルで構成されるものとして生成時に最大草数分のサイズを確保している。
各種頂点バッファの生成とVertexFactoryへのBindは以下のソースコードを参考に。
Engine/Source/Runtime/Engine/Public/Rendering/PositionVertexBuffer.h
Engine/Source/Runtime/Engine/Public/Rendering/ColorVertexBuffer.h
Engine/Source/Runtime/Engine/Public/Rendering/StaticMeshVertexBuffer.h
インデックスバッファについては以下を参考に。
Engine/Source/Runtime/Engine/Public/DynamicMeshBuilder.h

今回は頂点バッファのフォーマットとして

Position : Float3
Color : FColor
TangentNormal :PackedNormal ( R8G8B8A8_SNORMにパックしたフォーマット )
Texcoord : Float2

を採用した。TangentNormalとTexcoordをHalfFloatとすることもできたが、うまくComputeShaderで読み書きができなかったので要調査。

描画メッシュとして登録

上記のように作成した頂点バッファやインデックスバッファを実際にメッシュ描画に登録するために GetDynamicMeshElements() 関数をoverrideする。
具体的な設定はProceduralMeshComponent等のエンジン側のソースコードが参考になる。

// 描画メッシュ収集時に呼ばれる関数
virtual void FComputeMeshProxy::GetDynamicMeshElements(const TArray<const FSceneView*>& Views, const FSceneViewFamily& ViewFamily, uint32 VisibilityMap, FMeshElementCollector& Collector) const override
{
    // TODO
    // Collector に描画したい頂点バッファ群を設定したVertexFactoryやインデックスバッファを登録する
    // ProceduralMeshComponent.cpp 等を参考
}

最後にComputeShaderをDispatchして頂点バッファを書き換える処理をRHICmdListに積み込む必要がある。RHICmdListへの操作はRenderThread側で実行する必要があるので、ENQUEUE_RENDER_COMMANDマクロを使って描画スレッドで実行されるようにリクエストする。

void FComputeMeshProxy::EnqueueDispatchComputeShader()
{
    // RenderThreadで実行してほしい処理をマクロで登録
    ERHIFeatureLevel::Type FeatureLevel = component_->GetWorld()->Scene->GetFeatureLevel();
    ENQUEUE_RENDER_COMMAND(CaptureCommand)(
        [this, FeatureLevel](FRHICommandListImmediate& RHICmdList)
    {
        // -- この中がRenderThreadで実行される --
        // ComputeShaderのDispatch処理をRHICmdListに積み込む関数
        DispatchComputeShader_RenderThread(
            RHICmdList,
            this,
            FeatureLevel);
    }
    );
}

DispatchComputeShader_RenderThread()ではComputeShaderのGlobalShaderに対して書き込み対象の頂点バッファUAVを設定してDispatchコマンドを発行する。頂点バッファはComputeShaderで読み書きされた後にメッシュ描画で頂点バッファとして利用されるので、TransitionResourceによるトランジションコマンドも発行する必要があると思われる。

static void DispatchComputeShader_RenderThread(FRHICommandListImmediate& RHICmdList,FNglComputeMeshProxy* proxy,ERHIFeatureLevel::Type FeatureLevel)
{
    // 頂点バッファを書き換える自作ComputeShaderのGlobalShaderを取得
    TShaderMap<FGlobalShaderType>* GlobalShaderMap = GetGlobalShaderMap(FeatureLevel);
    TShaderMapRef<FComputeMeshShaderCS> cs(GlobalShaderMap);
    
    // Position頂点バッファリソースをGfxからComputeへトランジション
    RHICmdList.TransitionResource(EResourceTransitionAccess::ERWBarrier, EResourceTransitionPipeline::EGfxToCompute, Position頂点バッファUAV);
    // TODO ほかのバッファも同様に

    {
        // Position頂点バッファUAVをRWリソースとして設定
        SetUAVParameter(RHICmdList, cs->GetComputeShader(), cs->vtx_pos_buffer_, Position頂点バッファUAV);
        // TODO ほかのバッファも同様に

        // ComputeShaderのDispatch
        DispatchComputeShader(RHICmdList, *cs, ディスパッチグループ数, 1, 1);

        // Position頂点バッファUAVをRWリソースから外す
        SetUAVParameter(RHICmdList, cs->GetComputeShader(), cs->vtx_pos_buffer_, nullptr);
        // TODO ほかのバッファも同様に
    }

    // Position頂点バッファリソースをComputeからGfxへトランジション
    RHICmdList.TransitionResource(EResourceTransitionAccess::EReadable, EResourceTransitionPipeline::EComputeToGfx, Position頂点バッファUAV);
    // TODO ほかのバッファも同様に
}

ここでの注意点としては、RWリソースとして設定したバッファを使用後に外しておかないと、メッシュ描画時のバッファ利用方法と競合して正常に描画されなくなる点。

future work

インデックスバッファに同一インデックスを書き込むことでトライアングルを潰すことができるので、最大限のバッファサイズを確保しておいて必要に応じてトライアングルを増減させてTessellationのようなことができるかもしれない。
以下はプレイヤーの近く以外の草を単純にスケールで縮退させてみた例.


Shell方式の毛皮表現のように多数のポリゴンを重ねる表現などでも、カメラからの距離時応じて重ねる数を動的に変更したりすることでパフォーマンスを稼ぐことができるかもしれない。
メッシュを作っているだけなのでGPUパーティクル的なこともできそう。
最初にも書いたがカラーやUVなどの独自の情報を埋め込んでマテリアルBPで利用することでもいろいろなことができる気がする。
そのほかアイデア募集中。