2025年のCEDECで発表した内容のうち、今回はメッシュレットの詳細度制御のサンプルプログラムを公開しようかと思います。
注意事項として,この世に存在するすべてのメッシュデータにチェックした訳ではないので,特定のメッシュデータなどでうまくLOD生成されないなどの問題がある場合がありますので,予めご注意ください。
今回はCEDECで発表でした内容のうち,多段階LODシステムについて取り上げます。
ゲームでは処理負荷を抑えるために,詳細度制御(LOD : Level of Details)が使用されます。メッシュのLODなんかはどのゲームも普通に使っていると思います。LODを使う際に問題となるのは,ポッピング(Poping)と呼ばれる現象です。LODを切り替えた瞬間にその変化が唐突に見えてしまう現象のことを指します。特にLODを切り替える際に,メッシュのシルエットが大きく変わってしまうと,ポッピングが非常に目立つことになります。逆言えば,ポッピングを低減するためには,シルエットをできるだけ保つようにすればよいとも言えます。
では、できるだけシルエットを保つようにするには,どうすればいいでしょうか?これには,境界エッジを固定し,なるべくシルエットを壊さずにポリゴン簡略化を行えばよいです。
この多段階LODシステムはどのように実装すればよいでしょうか?
これには以下の図のような手順に従って,LODメッシュレットを生成していく処理を実装していけばよいです。
まず、メッシュからメッシュレットを生成します。次に,メッシュレット間の接続関係を求めます。接続関係に基づいてメッシュレットをグループ化します。グループ化したらグループごとにインデックスバッファをマージして,ポリゴンリダクションを実行します。ポリゴンリダクションを行ったメッシュを再びメッシュレットに分割します。以降は,終了条件に達していれば終了し,達していなかったら手順2.の処理に戻ります。終了条件については,例えば指定されたポリゴン数に達したかどうか,ポリゴンリダクションできなくなったかなどの判定を行うことになります。
これはすでに過去記事で方法を紹介済みです。実装だけ再掲しておきます。
//-----------------------------------------------------------------------------
// メッシュレット生成を行います.
//-----------------------------------------------------------------------------
bool CreateMeshlets(const char* path, ResMeshlets& result)
{
MeshOBJ mesh;
if (!mesh.Load(path))
{
ELOGA("Error : MeshOBJ::Load() Filed. path = %s", path);
return false;
}
auto& indices = mesh.GetIndices ();
auto& positions = mesh.GetPositions();
auto& normals = mesh.GetNormals ();
auto& texcoords = mesh.GetTexCoords();
auto& tangents = mesh.GetTangents ();
const size_t kMaxVertices = 256;
const size_t kMaxTriangles = 256;
const float kConeWeight = 0.0f;
uint64_t meshletOffset = 0;
for(const auto& subset : mesh.GetSubsets())
{
auto maxMeshlets = meshopt_buildMeshletsBound(subset.Count, kMaxVertices, kMaxTriangles);
std::vector<meshopt_Meshlet> meshlets (maxMeshlets);
std::vector<uint32_t> meshletVertices (maxMeshlets * kMaxVertices);
std::vector<uint8_t> meshletTriangles(maxMeshlets * kMaxTriangles * 3);
auto meshletCount = meshopt_buildMeshlets(
meshlets.data(),
meshletVertices.data(),
meshletTriangles.data(),
&indices[subset.Offset],
subset.Count,
&positions[0].x,
positions.size(),
sizeof(asdx::Vector3),
kMaxVertices,
kMaxTriangles,
kConeWeight
);
meshlets.resize(meshletCount);
for(auto& meshlet : meshlets)
{
meshopt_optimizeMeshlet(
&meshletVertices [meshlet.vertex_offset],
&meshletTriangles[meshlet.triangle_offset],
meshlet.triangle_count,
meshlet.vertex_count);
auto primOffset = uint32_t(result.Primitives.size());
auto vertOffset = uint32_t(result.VertexIndices.size());
for(auto i=0u; i<meshlet.vertex_count; ++i)
{
auto vertIndex = meshletVertices[i + meshlet.vertex_offset];
result.VertexIndices.emplace_back(vertIndex);
}
for(auto i=0u; i<meshlet.triangle_count * 3; i+= 3)
{
uint8_t3 tris = {};
tris.x = meshletTriangles[i + 0 + meshlet.triangle_offset];
tris.y = meshletTriangles[i + 1 + meshlet.triangle_offset];
tris.z = meshletTriangles[i + 2 + meshlet.triangle_offset];
result.Primitives.emplace_back(tris);
}
MeshletInfo m = {};
m.VertexOffset = vertOffset;//meshlet.vertex_offset;
m.VertexCount = meshlet.vertex_count;
m.PrimitiveOffset = primOffset;//meshlet.triangle_offset;
m.PrimitiveCount = meshlet.triangle_count;
auto bounds = meshopt_computeMeshletBounds(
&meshletVertices[meshlet.vertex_offset],
&meshletTriangles[meshlet.triangle_offset],
meshlet.triangle_count,
&positions[0].x,
positions.size(),
sizeof(asdx::Vector3));
auto normalCone = asdx::Vector4(
asdx::Saturate(bounds.cone_axis[0] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_axis[1] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_axis[2] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_cutoff * 0.5f + 0.5f));
m.NormalCone.x = uint8_t(normalCone.x * 255.0f);
m.NormalCone.y = uint8_t(normalCone.y * 255.0f);
m.NormalCone.z = uint8_t(normalCone.z * 255.0f);
m.NormalCone.w = uint8_t(normalCone.w * 255.0f);
m.BoundingSphere.x = bounds.center[0];
m.BoundingSphere.y = bounds.center[1];
m.BoundingSphere.z = bounds.center[2];
m.BoundingSphere.w = bounds.radius;
result.Meshlets.emplace_back(m);
}
ResSubset subsets = {};
subsets.MaterialId = subset.MaterialId;
subsets.MeshletOffset = meshletOffset;
subsets.MeshletCount = meshletCount;
result.Subsets.emplace_back(subsets);
meshletOffset += meshletCount;
}
result.Positions = positions;
result.Normals = normals;
result.Tangents = tangents;
result.TexCoords = texcoords;
result.VertexIndices.shrink_to_fit();
result.Primitives .shrink_to_fit();
result.Meshlets .shrink_to_fit();
result.Subsets .shrink_to_fit();
{
auto bounds = meshopt_computeSphereBounds(
&result.Positions[0].x,
result.Positions.size(),
sizeof(asdx::Vector3),
nullptr,
0);
result.BoundingSphere = asdx::Vector4(
bounds.center[0],
bounds.center[1],
bounds.center[2],
bounds.radius);
}
return true;
}やりたいことはグループ分けして,グループごとにポリゴンリダクションを行うことしたいです。このグループ分けのために,どことどこのメッシュレットが隣接しているかなどの接続関係を調べる必要があります。また先程も述べたように,シルエット部分は動かさずに残しておきたいので,境界エッジを判定する必要があります。
まずは,グループ分けから。今回グループ分けについては,UE5でも使用されている METIS() というオープンソースのライブラリを使用して,行うことにします。METISにあるMETIS_PartGraphKway()という関数を使ってグループ分けを実行します。グループ分けには,グラフの隣接構造や,グラフを分割するパーツ数などを引数として渡す必要があります。最初に,これらの引数として渡す必要があるデータの作成を行いましょう。BuildMeshletConectivity()という関数内で,接続関係を構築して,このデータをもとにMETISに渡すデータを作成することにします。BuildMeshletConnectivity()という関数の実装は下記のように行いました。
using Edge = std::pair<uint32_t, uint32_t>; // エッジデータ - (頂点0 - 頂点1).
///////////////////////////////////////////////////////////////////////////////
// EdgeHash structure
///////////////////////////////////////////////////////////////////////////////
struct EdgeHash
{
size_t operator() (const Edge& value) const
{
const auto hasher = std::hash<uint32_t>{};
return hasher(value.first) ^ hasher(value.second);
}
};
using EdgeToMeshletMap = std::unordered_map<Edge, std::vector<size_t>, EdgeHash>; // エッジからメッシュレットへの辞書.
using MeshletToEdgeMap = std::unordered_map<size_t, std::vector<Edge>>; // メッシュレットからエッジへの辞書.
//-----------------------------------------------------------------------------
// ボーダーエッジマップを構築します.
//-----------------------------------------------------------------------------
void BuildMeshletConectivity
(
const std::vector<LodMeshletInfo>& meshlets,
EdgeToMeshletMap& edge2Meshlet,
MeshletToEdgeMap& meshlet2Edge
)
{
// エッジリストを構築.
for(size_t mId = 0; mId < meshlets.size(); ++mId)
{
const auto& meshlet = meshlets[mId];
for(size_t pId = 0; pId < meshlet.Primitives.size(); ++pId)
{
const auto& prim = meshlet.Primitives[pId];
for(size_t j=0; j<3; ++j)
{
const auto& e0 = prim.v[j];
const auto& e1 = prim.v[(j + 1) % 3];
if (e0 == e1)
continue;
Edge edge = { std::min(e0, e1), std::max(e0, e1) };
edge2Meshlet[edge].push_back(mId);
meshlet2Edge[mId].emplace_back(edge);
}
}
}
// メッシュレットが1つでないものは複数接続されていてボーダーではないので,削除する.
{
remove_if(edge2Meshlet, [&](const auto& pair) {
return pair.second.size() != 1;
});
}
}まず,メッシュレットの各ポリゴンを構成するエッジとメッシュレットをペアにした辞書をstd::unordered_mapとして構築します。エッジ → メッシュ を引けるようにするのが,edge2Meshletで,メッシュレット → エッジ を参照できるようにするのが,meshlet2Edgeとなっています。これただ単にコンテナに入れていくだけなので問題ないでしょう。エッジリストが構築されたら,多段階LOD生成で欲しいのは,シルエットを保つための固定するべき境界エッジなので,これを求めます。境界エッジはどのように求めればよいでしょうか?求めるには,メッシュレットが1つでないものは対象外となるので,これedge2Meshletからremove_if()を使って削除すればよいです。「どういうこと?」って思われる方もおられると思うので,図示して説明します。ここでは,簡潔な説明のため4つのポリゴンで構成されるメッシュレットを例とします。最初に境界エッジではないケースですが,下記のように複数のメッシュレットと接するエッジになります。
続いて,境界エッジになるケースですが,下記のように必ず接するメッシュレットが1つになります。
そんなわけで,エッジが属するメッシュレットが1つのケースのみを残して,それ以外のケースは削除してしまえよいので…
// メッシュレットが1つでないものは複数接続されていてボーダーではないので,削除する.
{
remove_if(edge2Meshlet, [&](const auto& pair) {
return pair.second.size() != 1;
});
}…というコードにすればよいわけです。
境界エッジ → メッシュレット,メッシュレット → エッジの辞書が完成したので、これらのデータを使ってMETISに渡す引数データを作成します。各メッシュレットをグラフのノードと見立てて,グラフ構造のグループ化を行います。まずはグラフノードのデータが必要です。これにはメッシュレットの番号をデータとして使用します。そのため,メッシュレット数でループさせて,push_back()すればデータが出来上がるので,このデータをxAjacencyとします。これで1つできました。次には最後の引数として渡すpartitionですが,どのグループに移動したかが格納されます。そのため,メッシュレット数分のメモリを確保しておけばよいでしょう。これで2つ揃いました。残り2つは,エッジデータとその重みです。エッジデータをedgeAdjacency, エッジの重みをedgeWeightsとします。エッジの重みにはエッジを共有しているグラフの数を利用します。つまり,共有しているメッシュレット数になります。これには,先程説明した境界エッジの辞書データを利用します。境界エッジの辞書データから,メッシュレット番号を取得します。次に,edgeAdjancencyに既に登録済みかどうかチェックして,未登録であれば新たにedgeAdjacencyに追加,登録済みであれば共有しているとし,重みをカウントアップします。実際の実装コードは下記のようになります。
EdgeToMeshletMap e2m;
MeshletToEdgeMap m2e;
// 接続性を構築.
BuildMeshletConectivity(meshlets, e2m, m2e);
// … 中略 …
idx_t count = idx_t(meshletCount);
// idx_t はMETISで定義されている.
std::vector<idx_t> partition;
std::vector<idx_t> xAdjacency;
std::vector<idx_t> edgeAdjacency;
std::vector<idx_t> edgeWeights;
partition .resize (count);
xAdjacency.reserve(count + 1);
for(size_t i=0; i<meshletCount; ++i)
{
auto offsetEdgeAdjacency = idx_t(edgeAdjacency.size());
for(const auto& edge : m2e[i])
{
// 境界エッジからメッシュレットを探す.
auto itr = e2m.find(edge);
if (itr == e2m.end())
continue;
// 接続されているメッシュレットについて処理.
const auto& connections = itr->second;
for(const auto& connectedMeshlet : connections)
{
// 自分自身ならスキップ.
if (connectedMeshlet == i)
continue;
// 隣接エッジリストに登録されているかチェック.
auto edgeItr = std::find(
edgeAdjacency.begin() + offsetEdgeAdjacency,
edgeAdjacency.end(),
connectedMeshlet);
// 未登録.
if (edgeItr == edgeAdjacency.end())
{
edgeAdjacency.emplace_back(idx_t(connectedMeshlet));
edgeWeights .emplace_back(1);
}
// 登録済み.
else
{
auto d = std::distance(edgeAdjacency.begin(), edgeItr);
assert(d >= 0);
edgeWeights[d]++;
}
}
}
// メッシュレット開始番号を登録.
xAdjacency.push_back(offsetEdgeAdjacency);
}
xAdjacency.push_back(idx_t(edgeAdjacency.size()));ここまでで,METISに渡す引数が準備できたので,あとはMETIS_PartGraphKway()関数を使ってグループ分けを実行します。次のような感じです。
idx_t constrainCount = 1;
idx_t partsCount = count / kMinGroups;
idx_t options[METIS_NOPTIONS] = {};
METIS_SetDefaultOptions(options);
options[METIS_OPTION_OBJTYPE] = METIS_OBJTYPE_CUT;
options[METIS_OPTION_CCORDER] = 1; // Identifies the connected components.
options[METIS_OPTION_NUMBERING] = 0; // C-Style.
idx_t edgeCut; // final cost of the cut found by METIS.
// METISを使ってグループ分けする.
auto ret = METIS_PartGraphKway(
&count,
&constrainCount,
xAdjacency.data(),
edgeAdjacency.data(),
nullptr, // vertex weights.
nullptr, // vertex size
edgeWeights.data(),
&partsCount,
nullptr,
nullptr,
options,
&edgeCut,
partition.data());paritionにどのグループに移動したか?が格納されるので,ポリゴン簡略化処理で利用するので,下記のようにグループ分けした番号を記録しておきます。
// グループ番号を記録する.
std::vector<MeshletGroup> groups(partsCount);
for(auto i=0u; i<meshletCount; ++i)
{
auto groupId = partition[i];
groups[groupId].MeshletIds.push_back(i);
}これでグループ分け処理が完了です。
グループ分けされた複数のメッシュレットをグループごとに1つのメッシュに統合して,ポリゴンリダクションを実行します。
ポリゴンリダクション処理にはmeshoptimizerを利用します。CEDECの発表時には,meshopt_simplify()を使っていたのですが,meshopt_simplifyWithAttributes()を使った方が法線ベクトルやテクスチャ座標ができるだけおかしくならないようにポリゴンリダクションしてくれるので,後者の関数を利用した方が良いかと思います。今回のサンプルでは後者の関数を使うように実装変更しています。ポリゴン簡略化処理は,SimplifyGroup()というメソッドで行っており,次のような実装にしました。
///////////////////////////////////////////////////////////////////////////////
// MeshletGroup structure
///////////////////////////////////////////////////////////////////////////////
struct MeshletGroup
{
std::vector<size_t> MeshletIds;
};
///////////////////////////////////////////////////////////////////////////////
// MergeInfo structure
///////////////////////////////////////////////////////////////////////////////
struct MergeInfo
{
std::vector<uint32_t> Indices;
float Error;
bool IsMerged;
asdx::Vector4 BoundingSphere;
};
//-----------------------------------------------------------------------------
// グループ化されたメッシュレットをマージし,ポリゴン削減を行います.
//-----------------------------------------------------------------------------
MergeInfo SimplifyGroup
(
const MeshletGroup& group,
const std::vector<LodMeshletInfo>& meshlets,
const std::vector<asdx::Vector3>& positions,
const std::vector<asdx::Vector3>& normals,
const std::vector<asdx::Vector2>& texcoords,
const std::vector<uint32_t>& vertIndices,
uint32_t lodIndex
)
{
MergeInfo result;
std::unordered_map<uint32_t, uint32_t> dict; // mergeIndex <---> verteIndex の辞書.
// グループごとにマージする.
std::vector<uint32_t> mergedIdx;
std::vector<MergeVertex> mergedPos;
// 最大数でメモリ確保.
mergedIdx.reserve(group.MeshletIds.size() * 256 * 3);
mergedPos.reserve(group.MeshletIds.size() * 256);
for(size_t i=0; i<group.MeshletIds.size(); ++i)
{
const auto& meshletId = group.MeshletIds[i];
const auto& meshlet = meshlets[meshletId];
for(size_t j=0u; j<meshlet.Primitives.size(); ++j)
{
const auto& tris = meshlet.Primitives[j];
// マージで壊れたやつは取り除く.
if (tris.x == tris.y && tris.x == tris.z)
continue;
for(auto k=0; k<3; ++k)
{
auto vertId = meshlet.VertIndices[tris.v[k]];
auto index = uint32_t(mergedIdx.size());
MergeVertex vtx = {};
vtx.Pos = positions[vertId];
vtx.Normal = normals[vertId];
vtx.TexCoord = texcoords[vertId];
mergedIdx.emplace_back(index);
mergedPos.emplace_back(vtx);
dict.try_emplace(index, vertId);
}
}
}
mergedIdx.shrink_to_fit();
mergedPos.shrink_to_fit();
// 重複データを削る.
{
std::vector<uint32_t> remap(mergedIdx.size());
auto vertexCount = meshopt_generateVertexRemap(
remap.data(), mergedIdx.data(), mergedIdx.size(), mergedPos.data(), mergedPos.size(), sizeof(mergedPos[0]));
// 位置座標をリマップ.
{
std::vector<MergeVertex> temp(vertexCount);
meshopt_remapVertexBuffer(temp.data(), &mergedPos[0], mergedPos.size(), sizeof(mergedPos[0]), remap.data());
mergedPos = std::move(temp);
}
// 辞書をリマップ.
{
std::unordered_map<uint32_t, uint32_t> remapDict;
for(const auto& pair : dict)
{
auto newIdx = pair.first;
auto vertId = pair.second;
newIdx = remap[newIdx];
remapDict.try_emplace(newIdx, vertId);
}
dict = std::move(remapDict);
}
// 頂点インデックスをリマップ.
mergedIdx = std::move(remap);
}
// ポリゴンを半分ずつ減らす.
size_t targetIndexCount = mergedIdx.size() / 2;
// ポリゴン削減を行う.
if (targetIndexCount > 0)
{
uint32_t options = meshopt_SimplifyLockBorder | meshopt_SimplifyErrorAbsolute;
float clusterError = 0.0f; // QEM (Quadratic Error Metrics) の格納先.
float permissiveError = 0.03f; // 許容誤差 (3%未満とする).
assert(targetIndexCount > 0);
std::vector<uint32_t> indices(mergedIdx.size());
const float kWeight = 1.0f;
const float kAttrWeights[] = {
kWeight, // Normal.x
kWeight, // Normal.y
kWeight, // Normal.z
kWeight, // TexCoord.x
kWeight // TexCoord.y
};
// 法線ベクトルとテクスチャ座標を考慮して簡略化.
auto indexCount = meshopt_simplifyWithAttributes(
indices.data(),
mergedIdx.data(),
mergedIdx.size(),
&mergedPos[0].Pos.x,
mergedPos.size(),
sizeof(MergeVertex),
&mergedPos[0].Normal.x,
sizeof(MergeVertex),
kAttrWeights,
_countof(kAttrWeights),
nullptr,
targetIndexCount,
permissiveError,
options,
&clusterError
);
indices.resize(indexCount);
// 簡略化後は不要なので解放.
mergedIdx.clear();
mergedIdx.shrink_to_fit();
// バウンディングスフィアを求める.
result.BoundingSphere = ComputeBoundingSphere(indices, mergedPos);
mergedPos.clear();
mergedPos.shrink_to_fit();
// エラー値を設定.
result.Error = clusterError;
// 元の頂点インデックス番号を復元する.
result.Indices.reserve(indices.size());
for(const auto& index : indices)
{
auto vertId = dict[index];
result.Indices.emplace_back(vertId);
}
// エラーが 0 なければ,マージされて変形した.
result.IsMerged = (clusterError > 0.0f);
}
// ポリゴン削減を行わない.
else
{
result.Error = 0.0f; // 頂点をそのまま使うので...
// 元の頂点インデックス番号を復元する.
result.Indices.reserve(mergedIdx.size());
for(const auto& index : mergedIdx)
{
auto vertId = dict[index];
result.Indices.emplace_back(vertId);
}
// マージせず.
result.IsMerged = false;
auto bounds = meshopt_computeSphereBounds(&mergedPos[0].Pos.x, mergedPos.size(), sizeof(mergedPos[0]), nullptr, 0);
result.BoundingSphere.x = bounds.center[0];
result.BoundingSphere.y = bounds.center[1];
result.BoundingSphere.z = bounds.center[2];
result.BoundingSphere.w = bounds.radius;
}
return result;
}リターンで返却されるMergeInfoにマージ結果が格納されるます。ここまでで手順.3までの処理が終わりました。あとはマージされた結果を使って手順.4の処理を実行します。
続いて,手順.4です。前工程でグループごとにポリゴンリダクションされた1メッシュになっていますので,これらをメッシュレットに分割します。メッシュレットの分割はほぼ手順.1と同じで,ポリゴンリダクションされた結果を入力とする箇所が違うのみです。実装は下記のような感じです。
//-----------------------------------------------------------------------------
// ポリゴン削減されたメッシュから,新しくメッシュレットを生成します.
//-----------------------------------------------------------------------------
std::vector<LodMeshletInfo> BuildMeshlets
(
const MergeInfo& mergeInfo,
const std::vector<asdx::Vector3>& positions,
uint32_t lodIndex,
uint32_t materialId,
float parentError
)
{
const size_t kMaxVertices = 256;
const size_t kMaxTriangles = 256;
const float kConeWeight = 0.0f;
// メッシュレットサイズを算出.
auto maxMeshlets = meshopt_buildMeshletsBound(mergeInfo.Indices.size(), kMaxVertices, kMaxTriangles);
std::vector<meshopt_Meshlet> meshlets (maxMeshlets);
std::vector<uint32_t> meshletVertices (maxMeshlets * kMaxVertices);
std::vector<uint8_t> meshletTriangles(maxMeshlets * kMaxTriangles * 3);
// メッシュレットを生成.
auto meshletCount = meshopt_buildMeshlets(
meshlets.data(),
meshletVertices.data(),
meshletTriangles.data(),
mergeInfo.Indices.data(),
mergeInfo.Indices.size(),
&positions[0].x,
positions.size(),
sizeof(asdx::Vector3),
kMaxVertices,
kMaxTriangles,
kConeWeight
);
std::vector<LodMeshletInfo> result(meshletCount);
for(auto i=0; i<meshletCount; ++i)
{
const auto& src = meshlets[i];
auto& dst = result[i];
meshopt_optimizeMeshlet(
&meshletVertices [src.vertex_offset],
&meshletTriangles[src.triangle_offset],
src.triangle_count,
src.vertex_count);
dst.VertIndices.resize(src.vertex_count);
for(auto i=0u; i<src.vertex_count; ++i)
{
dst.VertIndices[i] = meshletVertices[src.vertex_offset + i];
}
dst.Primitives.resize(src.triangle_count);
for(auto i=0u, j=0u; i<src.triangle_count * 3; i+= 3, ++j)
{
uint8_t3 tris = {};
tris.x = meshletTriangles[i + 0 + src.triangle_offset];
tris.y = meshletTriangles[i + 1 + src.triangle_offset];
tris.z = meshletTriangles[i + 2 + src.triangle_offset];
dst.Primitives[j] = tris;
}
// カリング用情報を生成.
auto bounds = meshopt_computeMeshletBounds(
&meshletVertices[src.vertex_offset],
&meshletTriangles[src.triangle_offset],
src.triangle_count,
&positions[0].x,
positions.size(),
sizeof(asdx::Vector3));
auto normalCone = asdx::Vector4(
asdx::Saturate(bounds.cone_axis[0] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_axis[1] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_axis[2] * 0.5f + 0.5f),
asdx::Saturate(bounds.cone_cutoff * 0.5f + 0.5f));
dst.NormalCone.x = uint8_t(normalCone.x * 255.0f);
dst.NormalCone.y = uint8_t(normalCone.y * 255.0f);
dst.NormalCone.z = uint8_t(normalCone.z * 255.0f);
dst.NormalCone.w = uint8_t(normalCone.w * 255.0f);
dst.BoundingSphere.x = bounds.center[0];
dst.BoundingSphere.y = bounds.center[1];
dst.BoundingSphere.z = bounds.center[2];
dst.BoundingSphere.w = bounds.radius;
dst.Lod = lodIndex;
dst.MaterialId = materialId;
dst.GroupError = mergeInfo.Error + parentError;
dst.GroupBounds = mergeInfo.BoundingSphere;
dst.ParentError = std::numeric_limits<float>::infinity();
dst.ParentBounds = asdx::Vector4(0.0f, 0.0f, 0.0f, 0.0f);
}
return result;
}ここまでで,1LODを作成することができる状態になったので,あとはこれらの処理をループ分で回し,終了条件に達するまで繰り返せばよいです。今回のサンプルでは終了条件を最大LODに達するか,入力メッシュレットが1つになったら終了としています。実装はCreateLodMeshlets()という関数にまとめました。下記のような感じです。
//-----------------------------------------------------------------------------
// LODメッシュレットを生成します.
//-----------------------------------------------------------------------------
bool CreateLodMeshlets(const ResMeshlets& meshlets, ResLodMeshlets& lodMesh)
{
// サブセットごとにLODメッシュレットに変換.
std::vector<SubsetMeshlets> subsets;
Conversion(meshlets, subsets);
// サブセットのメモリを確保.
lodMesh.Subsets.resize(subsets.size());
uint32_t maxLodLevel = 0;
// マテリアルごとに処理.
for(size_t i=0; i<subsets.size(); ++i)
{
// 入力データ.
std::vector<LodMeshletInfo> input = std::move(subsets[i].Meshlets);
// LOD範囲.
ResLodRange range = {};
range.Offset = uint32_t(lodMesh.Meshlets.size());
range.Count = uint32_t(input.size());
// マテリアルごとの全LODを含むメッシュレット総数のカウンター.
uint32_t totalMeshletCount = uint32_t(input.size());
lodMesh.Subsets[i].MaterialId = subsets[i].MaterialId;
lodMesh.Subsets[i].MeshletOffset = range.Offset;
lodMesh.Subsets[i].LodRangeOffset = uint32_t(lodMesh.LodRanges.size());
lodMesh.LodRanges.emplace_back(range);
// メッシュレットを追加.
add_range(lodMesh.Meshlets, input);
// LODレベル.
uint32_t lodIndex = 1;
// 指定数に達するまでループ.
while(input.size() > 1 && lodIndex < (kMaxLodLevels - 1))
{
// 接続性に基づいてメッシュレットをグループ化.
auto groups = GroupMeshlets(input);
bool isMerged = false;
std::vector<LodMeshletInfo> simplifies;
for(const auto& group : groups)
{
// グループ化したものを1つのメッシュにマージして,ポリゴン削減する.
auto mergedInfo = SimplifyGroup(group, input, meshlets.Positions, meshlets.Normals, meshlets.TexCoords, meshlets.VertexIndices, lodIndex);
// マージされていなければ以降の処理はスキップ.
if (!mergedInfo.IsMerged)
continue;
float parentError = 0;
for(auto id : group.MeshletIds)
{
const auto& meshlet = input[id];
parentError = asdx::Max(parentError, meshlet.GroupError);
}
// ポリゴン削減されたメッシュを,新しくメッシュレットに分割.
auto newOnes = BuildMeshlets(mergedInfo, meshlets.Positions, lodIndex, subsets[i].MaterialId, parentError);
const auto groupError = mergedInfo.Error + parentError;
for(auto& id : group.MeshletIds)
{
// 1つ前のLOD(=入力データinput)が今新しく作ったメッシュレットの親になる.
const auto offset = lodMesh.LodRanges.back().Offset;
auto& parent = lodMesh.Meshlets[offset + id];
parent.ParentError = groupError;
parent.ParentBounds = mergedInfo.BoundingSphere;
}
// 新しいメッシュレットを追加.
add_range(simplifies, newOnes);
// マージした.
isMerged = true;
}
// 1回もマージされなければおしまい.
if (!isMerged)
break;
// 新しいメッシュレットに差し替える.
input = std::move(simplifies);
// LOD範囲を設定.
range.Offset = uint32_t(lodMesh.Meshlets.size());
range.Count = uint32_t(input.size());
lodMesh.LodRanges.emplace_back(range);
totalMeshletCount += range.Count;
// LODレベルをカウントアップ.
lodIndex++;
add_range(lodMesh.Meshlets, input);
}
// 総数を記録.
lodMesh.Subsets[i].MeshletCount = totalMeshletCount;
lodMesh.Subsets[i].LodRangeCount = lodIndex;
maxLodLevel = asdx::Max(maxLodLevel, lodIndex);
}
lodMesh.Positions = meshlets.Positions;
lodMesh.Normals = meshlets.Normals;
lodMesh.Tangents = meshlets.Tangents;
lodMesh.TexCoords = meshlets.TexCoords;
lodMesh.BoundingSphere = meshlets.BoundingSphere;
lodMesh.MaxLodLevel = maxLodLevel;
lodMesh.LodRanges.shrink_to_fit();
// 正常終了.
return true;
}今回のサンプル実装では,サブセット(サブメッシュ)ごとに処理をして,マテリアルデータが変わらないように対処しています。meshopt_simplifyWithAttributes()でマテリアルIDも渡すようにすれば,マテリアルデータが変わらないようにええ感じにしてくれるのかもしれませんが、今回はそこまで検証できなかったので、やりたいひとはやってみてください(そして、そのノウハウを共有していただけるとみんなが嬉しいです)。
これで,LODデータが完成したので,あとはランタイムでどのLODを選択するかを決定すればよいです。今回のサンプルでは,QEMを用いてメッシュレット単位でLODを決定することにします。
まず,QEMってなんぞ?って話から。QEMとはQuadric Error Metricsというもので,ポリゴン簡略化処理でよく目にする尺度です。日本語で直訳するなら2次誤差尺度となります。
QEMは全頂点に対する頂点 vs 平面の2乗距離の総和を表します。QEMの値が小さいほど,元のメッシュに近くなり,値が大きいほど粗くなることを表すことができます。距離の総和なので,パラメータとしては最終的に1つのfloat値になります。そして,このQEMの値をmeshoptimizerから取得することができます。
このQEMを使えば,どのぐらい粗くなったのかが表現できるので,この値を使ってLOD判定を行います。ポリゴンリダクション前とリダクション後のQEMを比べて指定値が収まっていれば,そのLODで描画すると判定することができるようになります。
先程述べたように,QEMは距離の総和を表します。つまり距離として解釈します。今,画面上でできるだけLODの切り替わりでポッピングが目立たないようにしたいので,画面上の尺度でQEMを評価したいです。そのためには,スクリーン空間上にQEMとして表されている距離を射影して,どのぐらいの距離であるかを比較すればよいことになります。では,どのように画面上にQEMを射影すればよいでしょうか?いつかの方法が考えられますが,今回のサンプルでは上図のようにバウンディングスフィアを用いて,画面上にQEMを射影することにします。そのため,スクリーン空間上でのバウンディングスフィアの半径の求め方を知っておく必要があります。幸いにも [stack overflow 2017] に算出方法の詳細が記載されていますので,こちらの方法をそのまま利用することにします。
この手法を用いると,画面空間上でのQEMは下図のように求めることができます。
画面空間上でのQEMが求まりましたので,上図にあるIsVisibleLod()関数を用いて,LODを決定すればよいことになります。今回のサンプルプログラムでは,上記の判定処理を全メッシュレットにたいしてbrute forceに行うかなり力任せな実装になっています。今回はあくまでもサンプルプログラムであり,仕組みを理解するのが先であるという都合上であまり効率の良い実装ではありません。UE5など製品レベルの実装を行う際はPersistent Threadモデルを使った実装にした方が良いかと思います。これについて,別記事で実装紹介を行おうと思います。
さて、ここまでの内容を踏まえて,増幅シェーダは次のような感じで実装すればよいです。
static const float kMinContribution = 1e-4f;
///////////////////////////////////////////////////////////////////////////////
// MeshletInfo structure
///////////////////////////////////////////////////////////////////////////////
struct MeshletInfo
{
uint VertexOffset;
uint VertexCount;
uint PrimitiveOffset;
uint PrimitiveCount;
uint NormalCone;
float4 BoundingSphere;
uint MaterialId;
uint Lod;
float GroupError;
float ParentError;
float4 GroupBoundingSphere;
float4 ParentBoundingSphere;
};
///////////////////////////////////////////////////////////////////////////////
// Constants structure
///////////////////////////////////////////////////////////////////////////////
struct Constants
{
uint MeshletOffset;
uint MeshletCount;
uint InstanceId;
uint Flags;
};
///////////////////////////////////////////////////////////////////////////////
// TransParam structure
///////////////////////////////////////////////////////////////////////////////
struct TransParam
{
column_major float4x4 View;
column_major float4x4 Proj;
column_major float4x4 ViewProj;
float3 CameraPos;
float FieldOfView;
float4 Planes[6];
float4 RenderTargetSize;
column_major float4x4 DebugView;
column_major float4x4 DebugProj;
column_major float4x4 DebugViewProj;
float3 DebugCameraPos;
float DebugFiledOfView;
float4 DebugPlanes[6];
};
///////////////////////////////////////////////////////////////////////////////
// MeshInstanceParam structure
///////////////////////////////////////////////////////////////////////////////
struct MeshInstanceParam
{
column_major float4x4 CurrWorld;
column_major float4x4 PrevWorld;
};
///////////////////////////////////////////////////////////////////////////////
// LodRange structure
///////////////////////////////////////////////////////////////////////////////
struct LodRange
{
uint Offset;
uint Count;
};
///////////////////////////////////////////////////////////////////////////////
// Payload structure
///////////////////////////////////////////////////////////////////////////////
struct Payload
{
uint MeshletIndices[32];
};
struct DebugWrite
{
float ProjClusterError;
float ProjParentError;
float4 groupBounds;
float4 parentBounds;
column_major float4x4 localToView;
};
//-----------------------------------------------------------------------------
// Resources
//-----------------------------------------------------------------------------
groupshared Payload g_Payload;
ConstantBuffer<Constants> g_Constants : register(b0);
ConstantBuffer<TransParam> g_TransParam : register(b1);
StructuredBuffer<MeshletInfo> g_Meshlets : register(t5);
StructuredBuffer<MeshInstanceParam> g_MeshInstances : register(t6);
StructuredBuffer<LodRange> g_LodRanges : register(t7);
RWStructuredBuffer<DebugWrite> g_DebugWrite : register(u0);
//-----------------------------------------------------------------------------
// Unormをfloatに変換します.
//-----------------------------------------------------------------------------
float4 UnpackUnorm(uint value)
{
float4 v;
v.x = float((value >> 0) & 0xFF);
v.y = float((value >> 8) & 0xFF);
v.z = float((value >> 16) & 0xFF);
v.w = float((value >> 24) & 0xFF);
v = v / 255.0;
return v;
}
//-----------------------------------------------------------------------------
// バウンディングスフィアを変換します.
//-----------------------------------------------------------------------------
float4 TransformSphere(float4 sphere, float4x4 world)
{
// 中心をワールド変換.
float3 center = mul((float3x3)world, sphere.xyz);
// 各軸の長さの2乗値を求める.
float sx = dot(world._11_12_13, world._11_12_13);
float sy = dot(world._21_22_23, world._21_22_23);
float sz = dot(world._31_32_33, world._31_32_33);
// 最も大きいものをスケールとして採用.
float scale = sqrt(max(sx, max(sy, sz)));
return float4(center, sphere.w * scale);
}
//-----------------------------------------------------------------------------
// スクリーン上の矩形を求めます.
//-----------------------------------------------------------------------------
float4 SphereScreenExtents(float4 sphere, float4x4 viewProj)
{
// https://gist.github.com/JarkkoPFC/1186bc8a861dae3c8339b0cda4e6cdb3
float4 result;
float r2 = sphere.w * sphere.w;
float d = sphere.z * sphere.w;
float hv = sqrt(sphere.x * sphere.x + sphere.z * sphere.z - r2);
float ha = sphere.x * hv;
float hb = sphere.x * sphere.w;
float hc = sphere.z * hv;
result.x = (ha - d) * viewProj._11 / (hc + hb); // left
result.z = (ha + d) * viewProj._11 / (hc - hb); // right
float vv = sqrt(sphere.y * sphere.y + sphere.z * sphere.z - r2);
float va = sphere.y * vv;
float vb = sphere.y * sphere.w;
float vc = sphere.z * vv;
result.y = (va - d) * viewProj._22 / (vc + vb); // bottom
result.w = (va + d) * viewProj._22 / (vc - vb); // top.
return result;
}
//-----------------------------------------------------------------------------
// 視錐台の中にスフィアが含まれるかどうかチェックします.
//-----------------------------------------------------------------------------
bool Contains(float4 planes[6], float4 sphere)
{
// sphereは事前に位置座標がワールド変換済み,半径もスケール適用済みとします.
float4 center = float4(sphere.xyz, 1.0f);
for(int i=0; i<6; ++i)
{
if (dot(center, planes[i]) < -sphere.w)
return false; // カリングする.
}
// カリングしない.
return true;
}
//-----------------------------------------------------------------------------
// 寄与カリングを行います.
//-----------------------------------------------------------------------------
bool ContributionCulling(float4 sphere, float4x4 viewProj)
{
float4 LBRT = SphereScreenExtents(sphere, viewProj);
float w = abs(LBRT.z - LBRT.x); // (left - right).
float h = abs(LBRT.w - LBRT.y); // (top - bottom).
return max(w, h) < kMinContribution; // カリングする.
}
//-----------------------------------------------------------------------------
// 法錐カリングを行います.
//-----------------------------------------------------------------------------
bool NormalConeCulling(float4 normalCone, float3 viewDir)
{
// normalConeはワールド変換済みとします.
return dot(normalCone.xyz, -viewDir) > normalCone.w; // カリングする.
}
//-----------------------------------------------------------------------------
// 誤差を投影します.
//-----------------------------------------------------------------------------
float ProjectError(float3 center, float radius, float screenScaleY)
{
if (isinf(radius))
return radius;
// screenScaleY = height * 0.5f * (1.0f / tan(fov * 0.5f)) とします.
float d2 = dot(center, center);
return screenScaleY * radius / sqrt(d2 - radius * radius);
}
//----------------------------------------------------------------------------
// LODが視認できるかチェックします.
//----------------------------------------------------------------------------
bool IsVisibleLod(MeshletInfo meshlet, float pixelErrorThreshold, float4x4 localToView, float screenScaleY)
{
float4 projGroupSphere = mul(localToView, float4(meshlet.GroupBoundingSphere.xyz, 1.0f));
float projGroupError = ProjectError(projGroupSphere.xyz, max(meshlet.GroupError, 1e-9f), screenScaleY);
float4 projParentSphere = mul(localToView, float4(meshlet.ParentBoundingSphere.xyz, 1.0f));
float projParentError = ProjectError(projParentSphere.xyz, max(meshlet.ParentError, 1e-9f), screenScaleY);
return (projGroupError <= pixelErrorThreshold && pixelErrorThreshold < projParentError);
}
//-----------------------------------------------------------------------------
// 視認できるかどうかチェックします.
//-----------------------------------------------------------------------------
bool IsVisible(MeshletInfo info, float3 cameraPos, float4 planes[6], float4x4 world, float4x4 viewProj)
{
// [-1, 1] に展開.
float4 normalCone = UnpackUnorm(info.NormalCone) * 2.0f - 1.0f;
// 角度がゼロかどうか判定.
if (normalCone.w <= 1e-6f)
return false;
// ワールド空間に変換.
float4 sphere = TransformSphere(info.BoundingSphere, world);
float3 axis = normalize(mul((float3x3)world, normalCone.xyz));
// 視錐台カリング.
if (!Contains(planes, sphere))
return false;
// 寄与カリング.
if (ContributionCulling(sphere, viewProj))
return false;
// 視線ベクトルを求める.
float3 viewDir = normalize(sphere.xyz - cameraPos);
// 法錐カリング.
if (NormalConeCulling(float4(axis, normalCone.w), viewDir))
return false;
// カリングしない.
return true;
}
//-----------------------------------------------------------------------------
// メインエントリーポイントです.
//-----------------------------------------------------------------------------
[numthreads(32, 1, 1)]
void main(uint dispatchId : SV_DispatchThreadID)
{
bool visible = false;
if (dispatchId < g_Constants.MeshletCount)
{
MeshletInfo info = g_Meshlets[dispatchId + g_Constants.MeshletOffset];
MeshInstanceParam instance = g_MeshInstances[g_Constants.InstanceId];
// 常にメインカメラを使用する.
visible = IsVisible(info, g_TransParam.CameraPos, g_TransParam.Planes, instance.CurrWorld, g_TransParam.ViewProj);
// LOD判定.
if (visible)
{
float4x4 localToView = mul(instance.CurrWorld, g_TransParam.View);
float screenScaleY = g_TransParam.RenderTargetSize.y * 0.5f * (1.0f / tan(g_TransParam.FieldOfView * 0.5f));
visible = IsVisibleLod(info, 1.0f, localToView, screenScaleY);
}
}
if (visible)
{
uint index = WavePrefixCountBits(visible);
g_Payload.MeshletIndices[index] = dispatchId + g_Constants.MeshletOffset;
}
uint visibleCount = WaveActiveCountBits(visible);
DispatchMesh(visibleCount, 1, 1, g_Payload);
}今回はCEDEC 2025で発表した内容のうち,多段階LODシステムのサンプルプログラムを紹介しました。CEDECでも言いましたが,Persistent Threadモデルについては,宿題となっていたままだったので,別記事で実装紹介をしようと思います。
本ソースコードおよびプログラムはMIT Licenseに準じます。プログラムの作成にはMicrosoft Visual Studio Community 2026を用いています。