1. サイトトップ
  2. ブログ
  3. C++
  4. 【C++】Mesh Shaderでモデルを描画してみる

【C++】Mesh Shaderでモデルを描画してみる

こんにちは。ロジカルビート情熱開発部プログラム課の廣江です。

今回はMesh Shaderを使って三角形を描画してみようと思います。

というのも、最近私のPC用にRTXシリーズのGPUを購入したので、この機会にMesh Shaderを触ってみよう!ということでこのテーマにしました。

今回作成したMesh Shaderについては、Microsoftのサンプルを参考に作成させていただきました。あらかじめご了承ください。

Mesh Shaderとは

近年ではGPUの性能が向上したことによりこれまでCPUで行っていた処理をGPUでやろう、という流れがあります。
そこで注目されたのがGPU駆動レンダリングです。これはカリングやLODの選択から、描画までをすべてGPUで行う技術です。
これまでは、GPU駆動レンダリングをCompute Shaderと組み合わせることで実現していました。

しかし、これには問題が残っています。頂点を処理するジオメトリパイプラインです。
従来のジオメトリパイプラインでは、頂点を処理するシェーダが増え、複雑化している問題があります。にもかかわらず、ほとんどの場面ではVS/PSしか使用されていません。私もGeometry Shader、Domain Shader、Hull Shaderなどは知識として知っているだけで実際に使用したことはありませんでした。
また、入力アセンブラが遅いことなどから、頂点シェーダ自体ががネックになっている場合もあります。

そんな中で、NVIDIAはGPU駆動レンダリングをより効率的に行うためにMesh ShaderとTask Shaderを開発しました。
これらはより効率的にLODを行う目的で開発された技術です。従来のLODシステムはCPU側で処理を行うことが多かったですが、NVIDIAが発表したMesh ShaderではこれをGPUで高効率に処理できるようにしたものです。また、それと同時にジオメトリパイプラインの早い段階でカリングを行えるようにしたことで、さらに処理する頂点を減らすこと可能になっています。

これらの技術を使用できるようにするため、MicrosoftはDirectX12 UltimateのリリースとともにMesh ShaderとAmplification Shader(Task Shaderから改名)を追加しました。

今後のMesh ShaderとAmplification Shaderについては、様々なプラットフォームが存在するので、一気に切り替わることはないと思いますが、PS5、Xbox Series Xの登場などにより少しずつ切り替わっていくと思われます。

今回はそんなMesh Shaderを用いてモデルを描画してみようと思います。

シェーダを書く

まずは今回作成したMesh Shaderのコードをみていきましょう。

#include "test_ms.hlsli"

//-------------------------------------------------------
// リソース
//-------------------------------------------------------
StructuredBuffer<MS_INPUT>      Vertices    : register(t10);
StructuredBuffer<Meshlet>       Meshlets    : register(t11);
ByteAddressBuffer               UniqueVertexIndices : register(t12);
StructuredBuffer<uint>          PrimitiveIndices    : register(t13);

//-------------------------------------------------------
// 頂点出力情報を取得
//-------------------------------------------------------
VERTEX_OUTPUT GetVertexAttribute(uint vertexIndex, uint meshletIndex)
{
    MS_INPUT v = Vertices[vertexIndex];

    VERTEX_OUTPUT vout;
    // 座標変換
    vout.pos        = mul( float4(v.pos, 1.f), cObjectBuffer.g_mWorld );
    vout.wpos       = vout.pos.xyz;
    vout.pos        = mul( vout.pos, cCameraBuf.g_mView );
    vout.pos        = mul( vout.pos, cCameraBuf.g_mProj );
    // 法線にワールド行列を適用
    vout.wnormal    = normalize(mul(v.normal, (float3x3)cObjectBuffer.g_mWorld));
    // MeshletのIndexを出力
    vout.meshletIdx = meshletIndex;

    return vout;
}

//-------------------------------------------------------
// エントリーポイント
//-------------------------------------------------------
[RootSignature(ROOT_SIG_MS)]
[numthreads(128, 1, 1)]
[outputtopology("triangle")] 
void main
(
    uint gtid : SV_GroupThreadID,
    uint gid : SV_GroupID,
    out vertices VERTEX_OUTPUT verts[256],
    out indices uint3 tris[256]
)
{
    Meshlet m = Meshlets[gid];      // Meshlet取得

    SetMeshOutputCounts(m.VertCount, m.PrimCount);

    if (gtid < m.PrimCount)
    {
        tris[gtid] = GetPrimitive(m, gtid);         // 頂点インデックスを設定
    }

    if (gtid < m.VertCount)
    {
        uint vertexIndex = GetVertexIndex(m, gtid);
        verts[gtid] = GetVertexAttribute(vertexIndex, gid);   // 頂点を出力
    }
}

どうでしょう、かなりCompute Shaderっぽいですね(笑)
Mesh ShaderはCompute Shaderと似たつくりになっており、numthreadsやSV_GroupThreadIDなど似たような記述があります。

ではまず関数属性から見ていきます。

[RootSignature(ROOT_SIG_MS)]
[numthreads(128, 1, 1)]
[outputtopology("triangle")] 

Mesh ShaderではCompute Shader同様にnumthreadsを指定する必要があります。 スレッドの最大数は128です。
outputtopologyの指定も必要です。こちらは”triangle”または”line”を指定できます。
RootSignatureは他シェーダと同様ですので説明は省きます。

次はMesh Shaderの引数についてです。

    uint gtid : SV_GroupThreadID,
    uint gid : SV_GroupID,
    out vertices VERTEX_OUTPUT verts[256],
    out indices uint3 tris[256]

gtid 、gidについてはCompute Shaderと同様ですので説明は省きます。

verticesは頂点出力用の属性です。
VERTEX_OUTPUT構造体は頂点シェーダの出力用構造体と同じで、SV_Positionなどのセマンティクスの指定が必要です。

indicesはインデックス出力用の属性です。
outputtopologyで”line”を指定した場合はuint2、”triangle”を指定した場合はuint3の配列にする必要があります。

vertices属性 、indices属性ともに配列の最大数は256です。

SetMeshOutputCounts(m.VertCount, m.PrimCount);

こちらは、出力する頂点数とプリミティブ数を設定する関数です。
この関数はシェーダごとに1回呼び出すことができ、呼び出れない場合はメッシュは出力されません。

※今回はMeshletの作成にDirectXMeshライブラリを使用しているため、GetPrimitive関数やGetVertexIndex関数はMicrosoftのサンプルを参考にさせていただいております。

PSOについて

ここからはcpp側の処理について説明します。
まずはPSOの作成からです。

通常VS/PSグラフィックスパイプラインの場合、PSO作成にD3D12_GRAPHICS_PIPELINE_STATE_DESCを使用しますが、Mesh Shaderを使用する場合は少し異なります。

	D3DX12_MESH_SHADER_PIPELINE_STATE_DESC desc = {};
	desc.MS                        = msData;
	desc.PS                        = psData;
	desc.pRootSignature            = pRS;
	desc.BlendState                = bsDesc;
	desc.RasterizerState           = rsDesc;
	desc.DepthStencilState         = dssDesc;
	desc.PrimitiveTopologyType     = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;

	CD3DX12_PIPELINE_MESH_STATE_STREAM psoStream = CD3DX12_PIPELINE_MESH_STATE_STREAM(desc);

	D3D12_PIPELINE_STATE_STREAM_DESC streamDesc  = {};
	streamDesc.pPipelineStateSubobjectStream     = &psoStream;
	streamDesc.SizeInBytes                       = sizeof(psoStream);

	// PSO作成
	hr = m_pDevice->CreatePipelineState(&streamDesc, IID_PPV_ARGS(&pPSO));
	if (FAILED(hr))return false;

どうでしょう。処理的には少し増えますがやっていることは大差ないです。

D3DX12_MESH_SHADER_PIPELINE_STATE_DESCの作成ですが、こちらは D3D12_GRAPHICS_PIPELINE_STATE_DESC とほぼ同じで、VSの代わりにMSを設定する、頂点入力レイアウトは不要ぐらいしか変わりません。

後は特に説明することもないですが、PSOを作成する関数が変わってますのでその点注意してください。

Meshletを作成する

Mesh Shaderでは基本的にMeshlet(メッシュを細かく分割したポリゴングループ)単位でメッシュを処理します。

では実際にMeshletを作成してみようと思います。
といっても、自前でMeshletを一から作るのは大変ですので、今回はMicrosoftがGitHubへ公開しているDirectXMeshライブラリのMeshlet計算機能を使用してみます。

それではコードを紹介していきます。

// ※indices, positionsの作成処理は省きます
std::vector<uint32_t>          indices;      // インデックス配列
std::vector<DirectX::XMFLOAT3> positions;    // 頂点座標配列

// 出力用
std::vector<DirectX::Meshlet>          meshlets;
std::vector<uint8_t>                   uniqueVertexIB;
std::vector<DirectX::MeshletTriangle>  primitiveIndices

// Meshlet計算
HRESULT hr = DirectX::ComputeMeshlets(
			indices.data(), indices.size() / 3,
			positions.data(), positions.size(), 
			nullptr, 
			meshlets, 
			uniqueVertexIB,
			primitiveIndices );
if ( FAILED( hr ) ){
      // エラー処理
}

Meshletの計算を行うためにDirectX::ComputeMeshlets関数が用意されています。
第2引数はインデックスカウントではなく、三角形の面の数を指定します。
第5引数では隣接情報を指定できますが、nullptrを指定した場合は関数内部で自動的に生成されます。
第6から第8引数は出力です。こちらの3つの出力されたバッファをMesh Shaderへ渡して描画に使用します。

DirectXMeshはオープンソースのライブラリとなっているので、Meshletの計算処理に興味がある方はソースコードをご覧ください。

モデルを描画する

それではいよいよMesh Shaderを使ってモデルを描画してみようと思います。

次のコードはコマンドリストへコマンドを積む処理を一部抜粋したものです。

pCommandList->SetGraphicsRootShaderResourceView(0, pVertices->GetGPUVirtualAddress());
pCommandList->SetGraphicsRootShaderResourceView(1, pMeshlets->GetGPUVirtualAddress());
pCommandList->SetGraphicsRootShaderResourceView(2, pUniqueVertexIndices->GetGPUVirtualAddress());
pCommandList->SetGraphicsRootShaderResourceView(3, pPrimIndices->GetGPUVirtualAddress());

pCommandList->DispatchMesh(MeshletCount, 1, 1);

リソースの設定処理はMesh Shader固有のものではないので説明は省かせていただきます。

Mesh Shaderの実行はDispatchMesh関数で行います。引数ではCompute Shader同様、スレッドグループ数を指定します。
今回はスレッドグループにMeshletの数を指定するように作成しております。

描画結果

Meshletごとに色分けして描画

まとめ

今回はMesh Shaderでモデルを描画してみました。Meshletの作成は少し面倒ですが、それ以外は特に難しい処理はなく比較的簡単にモデル描画まで出来ました。
基本的な内容のみなので、興味がある方は是非お試しください。
また、次回機会があればAmplification Shaderと組み合わせたMeshlet単位でのカリングやLODなどを紹介できればいいなと思っております。

参考資料

DirectXの資料 (https://microsoft.github.io/DirectX-Specs/d3d/MeshShader.html)
DirectXのサンプル(https://github.com/microsoft/DirectX-Graphics-Samples)
DirectXMeshライブラリ (https://www.findbestopensource.com/product/microsoft-directxmesh)
もんしょの巣穴 DirectXの話 第171回 (https://sites.google.com/site/monshonosuana/directxno-hanashi-1/directx-171)
Project ASURA – 初めてのメッシュシェーダ(http://www.project-asura.com/program/d3d12/d3d12_008.html
スタンフォードの3Dモデル (http://graphics.stanford.edu/data/3Dscanrep/)


【免責事項】

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