Mesh ShaderでMeshletによる描画 API編
さて、そしたら実際に描画を書いていく.
meshletを作る方法だけど、これはmeshlet生成のパフォーマンスを各手法で行ったもの1があり、こちらが参考になる.
この論文では以下の5つを実装している.
- DirectX Mesh
- MeshOptimizer
- Greedy
- Bounding Sphere
- k-medoids
この中で上2つがAPIを使う方法で、下の3つが自前で実装するものとなっている.
k-medoids以外をすべて実装していくのが目標.k-medoidsもやりたいけど、力尽きました.簡単な資料があればそのうち書くかもだけど、未来の自分にご期待ください.
さて、ということは4つを実装するわけだけど、今回は一番簡単なAPIを使う方法であるDirectX MeshとMeshOptimizerの二つに絞ってやっていく.
まずはMeshOptimizerの方からやっていく.
MeshOptimizer2はGithubにあるので、そちらを参照するのが速いかと思います.とても便利なライブラリ.
使い方に関しては中級グラフィックス入門3の方を参考にするとよいです、結局これが一番詳しい!
また今回はMesh Shading Part 1: Rendering Meshlets4の記事の方を結構参考にしてます.これも非常に分かりやすいし、step by stepなので読みやすくて好き.
さて、そしたら実際にコードを見ていこう.
まずはMeshletの定義、これはOffsetとCountによる定義であった.
なので、こんな感じにすればOK.
struct ResourceMeshlet
{
// Offset within meshlet vertices and meshlet triangles.
// Count means number of vertices and triangles used in the meshlet.
uint32_t m_vertexOffset;
uint32_t m_vertexCount;
uint32_t m_primitiveOffset;
uint32_t m_primitiveCount;
};
uint32_tで表されている.
次にMeshletが管理する最大頂点数とポリゴン数.
private:
// Max num of vertex and triangle
static constexpr size_t MAX_VERTICES = 64;
static constexpr size_t MAX_TRIANGLES = 124;
けど、ぎっちり詰めた方がinactive laneが少なくなってよいはずだとは思うけどね.
Meshletの構築で一番最初にやるのはMeshletの最大数を求めること.
meshopt_buildMeshletsBoundに投げると、実際にmeshletを構築するしたとき、最大でもどれだけのmeshletが必要なのかが分かる.
// Calculate max num of meshlet
const size_t maxMeshlets =
meshopt_buildMeshletsBound(
mesh->GetIndicesNum(),
MAX_VERTICES,
MAX_TRIANGLES);
つまり、これを使って最大数をあらかじめ確保することができる!!
確保に関しては以下のようにやればOK.
// alloc max size
std::vector<meshopt_Meshlet> meshlets; meshlets.resize(maxMeshlets);
mesh->m_uniqueVertexIndices.resize(maxMeshlets * MAX_VERTICES);
std::vector<uint8_t> meshletTriangles;
meshletTriangles.resize(maxMeshlets * MAX_TRIANGLES * 3);
meshopt_Meshletという型で各meshletを表すので、この分確保しておく.また、meshletは頂点を分割したものなので、全meshletと最大数を掛け合わせたもので大体の頂点数が分かるため、これもあらかじめ確保.
更に更に、meshletは最大ポリゴン数も分かる.
最大ポリゴン数が分かれば、Mesuletの数にポリゴンを構成する頂点数である3を掛ければ、全indicesの数も推測可能なため、こちらも計算しておく.
必要な分が分かれば、後はmeshopt_buildMeshletsを呼び出すだけで構築完了!!APIは楽でいいですね.
size_t meshletCount = meshopt_buildMeshlets
(
meshlets.data(), // Output: meshopt_Meshlet
mesh->m_uniqueVertexIndices.data(), // Output: meshlet to mesh index mapping
meshletTriangles.data(), // Output: triangle indices
mesh->m_indices.data(), // Input: pointer indices
mesh->GetIndicesNum(), // Input: number of indices
reinterpret_cast<const float*>(mesh->m_positions.data()), // Input: pointer vertices
mesh->GetVerticesNum(), // Input: number of vertices
sizeof(XMFLOAT3), // Input: stride of vertices
MAX_VERTICES, // Input: maximum number of vertices per meshlet
MAX_TRIANGLES, // Input: maximum number of triangles per mehslet
CONE_WEIGHT // Input: cone weight
);
meshlets.data(), // Output: meshopt_Meshlet
mesh->m_uniqueVertexIndices.data(), // Output: meshlet to mesh index mapping
meshletTriangles.data(), // Output: triangle indices
要は勝手にMeshletのOffsetとCountの指定とリソースの用意をやってくれるわけである.
そして、分割するということは分割する前のデータが必要なのでそれを渡す.
最初はインデックスバッファに相当するデータを渡す.
mesh->m_indices.data(), // Input: pointer indices
mesh->GetIndicesNum(), // Input: number of indices
今回はpositionのみなので、
XMFLOAT3にしてる. reinterpret_cast<const float*>(mesh->m_positions.data()), // Input: pointer vertices
mesh->GetVerticesNum(), // Input: number of vertices
sizeof(XMFLOAT3), // Input: stride of vertices
先ほど設定した最大頂点数と最大ポリゴン数だね.
これを設定することで、上手くmeshletを最大値を超えないように分割してくれるという感じ.
CONE_WEIGHTは公式のREADME2を読むと、カリング用で0ならCone Cullingしませんよ~という設定らしい、まあ今回は使わないので0にしてます.これだけ渡せば計算完了だけど、この関数は返り値で結局何個に分割したのかを返してくれてるので、これを保持しておく.
さて、次はデータをshrinkする.
最初にリソースを最大数で確保してたけど、最大まで使ってない場合もあるので、データを縮めてしまう訳.
まずは全体のコード.
// shrink vector size using meshlet size.
// triangle align to equal (x mod 4 = 0).
meshopt_Meshlet& last = meshlets[meshletCount - 1];
mesh->m_uniqueVertexIndices.resize(last.vertex_offset + last.vertex_count);
meshletTriangles.resize(last.triangle_offset + ((last.triangle_count * 3 + 3) & ~3));
meshlets.resize(meshletCount);
まず最後尾のデータを参照する.
そして、最後尾のデータのoffsetとcountを使ってVertexIndicesのデータをresizeしてしまう.
meshopt_Meshlet& last = meshlets[meshletCount - 1];
mesh->m_uniqueVertexIndices.resize(last.vertex_offset + last.vertex_count);
そのため、meshletの最後尾を参照するというのは、下の図のようにリソースの最後尾と一致することになる.

なので、
Offset+Countでresizeを行っているわけである.原理は簡単.次にTriangleのresize,これはちょっと複雑.
なんか不思議な計算をしてる.
先ほどと同じで
offset+countの計算であるが、countのみが謎の計算をしてる.これはcount=1,2,3,4の場合の計算をすると分かりやすい.
これは4で割り切れる数になってる.
GPUのリソースとしては4byteでalignされてると嬉しいので、こうして調整してるわけだね.
meshlet自体のresizeは簡単、先ほどの関数で数は分かったのでそれを使ってあげればよい.
さて、次にやるのはIndex Bufferの圧縮.
今までのVS-PS方式のインデックスバッファは頂点バッファを直接参照する.
そのため、32bitとか大きい値を表現できないといけなかった.
MeshletはMAX_VERTICESで最大数が一気に小さくなっている!
これによって8bit、つまり高々で表すことができるので、圧縮が可能!!
中級グラフィックス入門2ではもっと賢く圧縮してるけど、今回は以下のような感じで圧縮を行う.

現状は32bitに全部別れてるけど、32bitの中に8bitを3つ埋め込んでしまうのである!!
実際はこれでも8bitの無駄があるけども、96bitから32bitへの変更は大きいね.
更に8bitを削る方法に関してはまた今度やる予定だけど、今回に関しては単純な圧縮でやってみよう.
圧縮の手順は以下の感じ.
// repack triangles from uint8 to uint32
for (meshopt_Meshlet& meshlet : meshlets)
{
// Current triangle offset for current meshlet
uint32_t triangleOffset = static_cast<uint32_t>(mesh->m_primitiveIndices.size());
// per triangle
for (uint32_t i = 0; i < meshlet.triangle_count; ++i)
{
// pick up offset
const uint32_t index = i * 3;
uint32_t i0 = index + meshlet.triangle_offset;
uint32_t i1 = index + 1 + meshlet.triangle_offset;
uint32_t i2 = index + 2 + meshlet.triangle_offset;
uint8_t vi0 = meshletTriangles[i0];
uint8_t vi1 = meshletTriangles[i1];
uint8_t vi2 = meshletTriangles[i2];
// repack!!
uint32_t packed =
((static_cast<uint32_t>(vi0) & 0xFF) << 0) |
((static_cast<uint32_t>(vi1) & 0xFF) << 8) |
((static_cast<uint32_t>(vi2) & 0xFF) << 16);
mesh->m_primitiveIndices.push_back(packed);
}
// adjust offset
meshlet.triangle_offset = triangleOffset;
}
forはmeshlet毎に行う.
まずtriangleのoffsetを取得.これは配列のサイズを起点として決める.
for (meshopt_Meshlet& meshlet : meshlets)
{
// Current triangle offset for current meshlet
uint32_t triangleOffset = static_cast<uint32_t>(mesh->m_primitiveIndices.size());
// ...
}
indexは0から2まで増えるように調整.ポリゴンの持つ頂点は3つなので、これは正しそうね.
ポリゴンのindexはoffsetの位置からずらして計算すればOK.
後はこれを使って頂点を取り出せばよい.
// per triangle
for (uint32_t i = 0; i < meshlet.triangle_count; ++i)
{
// pick up offset
const uint32_t index = i * 3;
uint32_t i0 = index + meshlet.triangle_offset;
uint32_t i1 = index + 1 + meshlet.triangle_offset;
uint32_t i2 = index + 2 + meshlet.triangle_offset;
uint8_t vi0 = meshletTriangles[i0];
uint8_t vi1 = meshletTriangles[i1];
uint8_t vi2 = meshletTriangles[i2];
現在の値を255以上の数値の部分は0で埋めて、右シフトで詰めていくだけ.
それができればあとはindexの部分にデータを溜めればOK.
// repack!!
uint32_t packed =
((static_cast<uint32_t>(vi0) & 0xFF) << 0) |
((static_cast<uint32_t>(vi1) & 0xFF) << 8) |
((static_cast<uint32_t>(vi2) & 0xFF) << 16);
mesh->m_primitiveIndices.push_back(packed);
countに関してはポリゴン数に関しては全く変わってないため変更しなくてOK.
これで圧縮も完了.
最後にMeshletのデータをこちら側が想定してるものに設定すれば終わり.
実際これをやらないでmeshopt_Meshlet前提でもいいんだけど、今回はとりあえず分ける想定にしてみた.
// set meshlet
for (const meshopt_Meshlet& meshlet : meshlets)
{
ResourceMeshlet meshletResource;
meshletResource.m_vertexOffset = meshlet.vertex_offset;
meshletResource.m_vertexCount = meshlet.vertex_count;
meshletResource.m_primitiveOffset = meshlet.triangle_offset;
meshletResource.m_primitiveCount = meshlet.triangle_count;
mesh->m_meshlets.push_back(meshletResource);
}
さて、そしたら次はshader側を見てみる.
meshletのリソースを用意する.
前回とは違いMeshletsというMeshletのリソースが追加された.
そして、前回のIndicesは分割された結果VertexIndicesとTriangleIndicesに変わった.
struct Meshlet
{
uint VertexOffset;
uint VertexCount;
uint TriangleOffset;
uint TriangleCount;
};
StructuredBuffer<VertexInput> Vertices : register(t0);
StructuredBuffer<Meshlet> Meshlets : register(t1);
StructuredBuffer<uint> VertexIndices : register(t2);
StructuredBuffer<uint> TriangleIndices : register(t3);
meshletは前回説明した通り、
groupIndexで参照ができるのだった!なので、これを使って参照を行う.
そしてMSが出力する頂点数とポリゴン数は事前に呼び出す必要があるため、
SetMeshOutputCountsで設定をしておく. // get meshlet from index
Meshlet meshlet = Meshlets[groupIndex];
// set output count
SetMeshOutputCounts(meshlet.VertexCount, meshlet.TriangleCount);
現在のLaneは
groupThreadIndexを使うことで特定が可能.そのため、これを使って現在のTriangleの位置を特定.
そして範囲内なら
offset+indexでアクセスする.Indexに関してはCPU側で右シフトで詰めたので、左シフトした後0xFFとアンド演算を行い8bitのみ取り出すようにする.
取り出した3つのindexが三角形となるため、これを出力すればOK.
// triangle index
if (groupThreadIndex < meshlet.TriangleCount)
{
uint packed = TriangleIndices[meshlet.TriangleOffset + groupThreadIndex];
uint vIndex0 = (packed >> 0) & 0xFF;
uint vIndex1 = (packed >> 8) & 0xFF;
uint vIndex2 = (packed >> 16) & 0xFF;
triangles[groupThreadIndex] = uint3(vIndex0, vIndex1, vIndex2);
}
アクセスの仕方はtriangle側とほぼ同じ.
アクセスした後は頂点を座標変換して、試しに
groupIndexを使って色を決定する.groupIndexはmeshlet毎なので、meshlet単位で同じ色になるはず!! // vertex transform
if (groupThreadIndex < meshlet.VertexCount)
{
uint vertexIndex = meshlet.VertexOffset + groupThreadIndex;
vertexIndex = VertexIndices[vertexIndex];
VertexOutput vout;
vout.Position = mul(Camera.MVP, float4(Vertices[vertexIndex].Position, 1.0f));
vout.Color = float3(float(groupIndex & 1), float(groupIndex & 3) / 4, float(groupIndex & 7) / 8);
vertices[groupThreadIndex] = vout;
}
// get meshlet from index
Meshlet meshlet = Meshlets[groupIndex];
// set output count
SetMeshOutputCounts(meshlet.VertexCount, meshlet.TriangleCount);
// triangle index
if (groupThreadIndex < meshlet.TriangleCount)
{
uint packed = TriangleIndices[meshlet.TriangleOffset + groupThreadIndex];
uint vIndex0 = (packed >> 0) & 0xFF;
uint vIndex1 = (packed >> 8) & 0xFF;
uint vIndex2 = (packed >> 16) & 0xFF;
triangles[groupThreadIndex] = uint3(vIndex0, vIndex1, vIndex2);
}
// vertex transform
if (groupThreadIndex < meshlet.VertexCount)
{
uint vertexIndex = meshlet.VertexOffset + groupThreadIndex;
vertexIndex = VertexIndices[vertexIndex];
VertexOutput vout;
vout.Position = mul(Camera.MVP, float4(Vertices[vertexIndex].Position, 1.0f));
vout.Color = float3(float(groupIndex & 1), float(groupIndex & 3) / 4, float(groupIndex & 7) / 8);
vertices[groupThreadIndex] = vout;
}
最後にコマンド呼び出しをCPU側で行う.
これは前も行ったDispatchMeshを呼び出せばOK.
DispatchMeshはWaveの数だけ呼び出す必要があり、各Waveは各Meshletの処理を行う.
つまり、Meshlet数で呼び出せばOK!!
ここまで来れば何とか描画できる.
結果は以下のような感じ.

今回はstanford bunnyを選択、いい感じに描画できてる!!
次はDirectX Meshをやっていこう.
これはロジカルビートさんの記事5でやってくれてるので、これが分かりやすいと思う.
変える部分はCPU側のみで良いので、構築だけ見てみよう.
構築、これはComputeMeshletsという関数を呼び出すだけ.
設定するものはほぼ同じ.
個人的にはMeshOptimizerの方がstride設定できて好きだなぁとは思うけど、まあこれでも問題は特にはないね.
// construct meshlet
HRESULT hr = DirectX::ComputeMeshlets
(
mesh->m_indices.data(), // Input: pointer indices
mesh->GetIndicesNum() / 3, // Input: number of triangles
mesh->m_positions.data(), // Input: pointer vertex positions
mesh->m_positions.size(), // Input: number of vertex
nullptr, // Input: adjacency
meshlets, // Output: DirectX::Meshlet
uniqueVertexIB, // Output: meshlet to mesh index mapping
meshletTriangles, // Output: DirectX::MeshletTriangle
MAX_VERTICES, // Input: maximum number of vertices per meshlet
MAX_TRIANGLES // Input: maximum number of triangles per meshlet
);
あとはちょっと変数名が違う部分があるので、そこを変更して書くだけ.
圧縮のところで一例を示しておく.
// shrink vector size using meshlet size.
// triangle align to equal (x mod 4 = 0).
DirectX::Meshlet& last = meshlets[meshletCount - 1]; //DirectX::Meshletなのに注意
mesh->m_uniqueVertexIndices.resize(last.VertOffset + last.VertCount); // VertOffset, VertCountとなってる
meshletTriangles.resize(last.PrimOffset + ((last.PrimCount * 3 + 3) & ~3)); // PrimOffset, PrimCountとなってる
meshlets.resize(meshletCount);

うん、いい感じ!
これで今回はAPI編が終わった.
次回からは自前でMeshletを組んでみるターン.
正直やらなくてもいいんだけど、こういうのは自分でやって理解したくなっちゃうのよね.
ということで次はGreedyから始めようと思います.