アヒルのある日

株式会社AHIRUの社員ブログです。毎週更新!社員が自由に思いついたことを書きます。

メッシュシェーダーのハイブリッド実装①

こんにちは、みにくい社長です。

DirectX12及びシェーダーモデル6.5から使用できる機能として欠かせないのがメッシュシェーダーですが、実際に使われている場面は少ないのが現状です。 なぜあまり使われていないかというと、
・プラットフォームによって、もしくはスペックが高いPCでしか使えない
・主に高速化が目的で、表現力が上がる機能ではない
といった要因があると思われます。
確かに主な目的は高速化ですが、今後の主流となるであろうGPU駆動レンダリングに移行するには必須の機能なので、できれば実装しておきたいところです。 メッシュシェーダーで描画するだけであれば既に多くの参考記事があると思いますので、ここでは既存の描画フローからどのように移行すればよいか、ハイブリッド実装について説明します。
高速化する為にメッシュシェーダーに移行したはずが、下手な実装だとむしろ重くなってしまいます。高速化を意識した実装を心がけましょう。

まず、PCがメッシュシェーダーに対応しているかを判定するには次のようにします。

bool isEnableMeshShader(){
    _shaderModel.HighestShaderModel = D3D_HIGHEST_SHADER_MODEL;
    _pDevice->CheckFeatureSupport(D3D12_FEATURE_SHADER_MODEL, &_shaderModel, sizeof(_shaderModel));
    _pDevice->CheckFeatureSupport(D3D12_FEATURE_D3D12_OPTIONS7, &_featureLevelOptions7, sizeof(D3D12_FEATURE_DATA_D3D12_OPTIONS7));

    return _shaderModel.HighestShaderModel >= D3D_SHADER_MODEL_6_5 && _featureLevelOptions7.MeshShaderTier >= D3D12_MESH_SHADER_TIER_1
}

次にやることを整理します。
従来の描画フロー(テッセレーションを使用しない場合)では
VS(頂点シェーダー)→PS(ピクセルシェーダー)
だったところを
AS(増幅シェーダー)→MS(メッシュシェーダー)→PS
に変更することになります。
ASではメッシュレット単位でカリングを行い、MSを呼び出すかどうかを判断します。
MSでは描画することになったメッシュレットを描画します。この時、MS内で頂点処理を行うのでVSの関数を呼び出すことができます。つまり、VSの前にメッシュレットの処理が入るだけであまり変更箇所は多くありません。
やることとしては、
①メッシュリソースをメッシュレットに事前に分割する
②MSでメッシュレットを描画する
③ASでメッシュレットをカリングする
の順です。ASは実装しなくても描画することは可能で、高速化の為に後で追加すれば良いです。

①メッシュリソースをメッシュレットに事前に分割する

メッシュレットに分割するオープンソースのライブラリはいくつかありますが、軽量で簡単に使えるMeshOptimizerを使って説明します。
https://github.com/zeux/meshoptimizer

メッシュをメッシュレット単位に分割する前に、どれぐらいのバッファが必要かを知る必要がありますので、meshopt_buildMeshletsBoundでメッシュレットの数を確認します。
バッファを用意したらmeshopt_buildMeshletsで分割します。

constexpr u32 VERTEX_MAX = 128;
constexpr u32 TRIANGLE_MAX = 128;
const u32 meshletMax = static_cast<u32>(meshopt_buildMeshletsBound(pMesh->_indexCount, VERTEX_MAX, TRIANGLE_MAX));
const u32 remapIndexMax = meshletMax * VERTEX_MAX;
// バッファを用意
meshletInfo._pMeshletBuffer = static_cast<meshopt_Meshlet*>(malloc(sizeof(meshopt_Meshlet) * meshletMax));
meshletInfo._pRemapIndex = static_cast<u32*>(malloc(sizeof(u32) * remapIndexMax));
meshletInfo._pTriangleBuffer = static_cast<u8*>(malloc(meshletMax * TRIANGLE_MAX * 3));
// メッシュレット分割
pMesh->_meshletCount = static_cast<u32>(meshopt_buildMeshlets(pMeshletInfo[m]._pMeshletBuffer, meshletInfo._pRemapIndex, meshletInfo._pTriangleBuffer, pIndexBufferNow, pMesh->_indexCount, static_cast<f32*>(pVertexBuffer), pMesh->_vertexBufferSize / pMesh->_vertexStride, pMesh->_vertexStride, VERTEX_MAX, TRIANGLE_MAX, 1.0f));

分割といっても、頂点の並びはそのままです。分割結果として新たにリマップインデックスバッファとトライアングルバッファが得られます。
・頂点バッファ(従来のもの)
・リマップインデックスバッファ(頂点バッファを指すインデックス)
・トライアングルバッファ(リマップインデックスバッファを指すインデックスで三角形を構成する)
この時、トライアングルバッファが従来のインデックスバッファの役割になりますので、VSのフローで描画したい場合は、トライアングルバッファが指すインデックスを頂点バッファを指すインデックスに書き換えてしまえば、頂点バッファとトライアングルバッファ(従来のインデックスバッファ)で描画が可能です。 なので、リソースとしてはこの分割後の状態で保存し、読み込んだ時にMSを使用するかを判断し、VSで描画する場合はトライアングルバッファのインデックスを書き換えて描画するとハイブリッド実装になります。
また、meshopt_computeMeshletBoundsでメッシュレットのバウンディングスフィアと法線をまとめた円錐の情報が取得できます。こちらはカリング処理で必要になりますので、併せて保存しておきましょう。

for (u32 ml = 0; ml < meshletCount; ml++){
    const auto bounds = meshopt_computeMeshletBounds(meshletInfo._pRemapIndex + meshletInfo._pMeshletBuffer[ml].vertex_offset, meshletInfo._pTriangleBuffer + meshletInfo._pMeshletBuffer[ml].triangle_offset, meshletInfo._pMeshletBuffer[ml].triangle_count, static_cast<const float*>(pVertexBuffer), _pMesh->_vertexBufferSize / _pMesh->_vertexStride, _pMesh->_vertexStride);
}

次はメッシュレットの描画になりますが、長くなるのでまた次回。
では、またねー。