feat: LOD 网格分级——meshopt 解析期简化 + 按距离/级联选级

vendored meshoptimizer 1.2(submodule 环境故障改直接 vendor)。Mesh
多 LOD:各级索引连续拼进同一 index buffer(共享顶点,LodRange 区间
选择),解析期 meshopt_simplify 生成链(LOD0=原始,每级目标减半,
相对误差 1e-2,小网格复用上级);都在 worker 线程,加载零额外卡顿。

SceneRenderer 按 (mesh, LOD) 聚合 batch(lookup key = 指针<<8|lod):
主视图按屏占比近似 radius/dist 选级(阈值 10/20 倍半径);阴影级联
按级联序降级(越远越简)。Draw 按 batch.lod 选索引区间。

A/B 对比(100 背包 10×10,RTX 5060 Laptop,vsync off):
- 纯背包:LOD 开 93-95 fps / 关 90-107 fps —— 无显著差异
- 加 SunTemple:LOD 开 52-56 / 关 51-55 —— 无显著差异
结论:当前场景瓶颈不在顶点数(5060 顶点吞吐富余),LOD 基础设施
保留(几何复杂资产/低端 GPU/更大场景生效),帧率问题转向 Tracy
GPU 时间线定位真实瓶颈。

坑:LodRange 记录"LOD 不足复用最后一级"时 first 不得继续累加
(buffer 无重复数据,累加必越界——VUID-08798 抓的)。
This commit is contained in:
2026-09-24 20:53:19 +08:00
parent 34b593f734
commit 5bdfb53566
125 changed files with 67608 additions and 29 deletions
+962
View File
@@ -0,0 +1,962 @@
/**
* clusterlod - a small "library"/example built on top of meshoptimizer to generate cluster LOD hierarchies
* This is intended to either be used as is, or as a reference for implementing similar functionality in your engine.
*
* To use this code, you need to have one source file which includes meshoptimizer.h and defines CLUSTERLOD_IMPLEMENTATION
* before including this file. Other source files in your project can just include this file and use the provided functions.
*
* Copyright (C) 2016-2026, by Arseny Kapoulkine (arseny.kapoulkine@gmail.com)
* This code is distributed under the MIT License. See notice at the end of this file.
*/
#pragma once
#include <stddef.h>
struct clodConfig
{
// configuration of each cluster; maps to meshopt_buildMeshlets* parameters
size_t max_vertices;
size_t min_triangles;
size_t max_triangles;
// partitioning setup; maps to meshopt_partitionClusters parameters (plus optional partition sorting)
// note: partition size is the target size, not maximum; actual partitions may be up to 1/3 larger (e.g. target 24 results in maximum 32)
bool partition_spatial;
bool partition_sort;
size_t partition_size;
// clusterization setup; maps to meshopt_buildMeshletsSpatial / meshopt_buildMeshletsFlex
bool cluster_spatial;
float cluster_fill_weight;
float cluster_split_factor;
// every level aims to reduce the number of triangles by ratio, and considers clusters that don't reach the threshold stuck
float simplify_ratio;
float simplify_threshold;
// to compute the error of simplified clusters, we use the formula that combines previous accumulated error as follows:
// max(previous_error * simplify_error_merge_previous, current_error) + current_error * simplify_error_merge_additive
float simplify_error_merge_previous;
float simplify_error_merge_additive;
// amplify the error of clusters that go through sloppy simplification to account for appearance degradation
float simplify_error_factor_sloppy;
// limit error by edge length, aiming to remove subpixel triangles even if the attribute error is high
float simplify_error_edge_limit;
// clamp attribute error to match position error scale and avoid overly conservative LOD selection
bool simplify_error_clamped;
// use permissive simplification instead of regular simplification (make sure to use attribute_protect_mask if this is set!)
bool simplify_permissive;
// use permissive or sloppy simplification but only if regular simplification gets stuck
bool simplify_fallback_permissive;
bool simplify_fallback_sloppy;
// use regularization during simplification to make triangle density more uniform, at some cost to overall triangle count; recommended for deformable objects
bool simplify_regularize;
// try to preserve fold lines between opposite-facing triangles during simplification, at a small performance cost
bool simplify_preserve_folds;
// dilate open borders for each cluster to try to mitigate area loss; most useful for foliage, should not be enabled everywhere
// note: this will mutate vertex_positions in place; the caller should copy positions for each cluster in the output callback as they will be overwritten by simplified clusters
bool simplify_dilate_borders;
// should clodCluster::bounds be computed based on the geometry of each cluster
bool optimize_bounds;
// should clodCluster::indices be optimized for locality; helps with rasterization performance and ray tracing performance in fast-build modes
bool optimize_clusters;
int optimize_clusters_level;
};
struct clodMesh
{
// input triangle indices
const unsigned int* indices;
size_t index_count;
// total vertex count
size_t vertex_count;
// input vertex positions; must be 3 floats per vertex
const float* vertex_positions;
size_t vertex_positions_stride;
// input vertex attributes; used for attribute-aware simplification and permissive simplification
const float* vertex_attributes;
size_t vertex_attributes_stride;
// input vertex locks; allows to preserve additional seams (when not using attribute_protect_mask) or lock vertices via meshopt_SimplifyVertex_* flags
const unsigned char* vertex_lock;
// attribute weights for attribute-aware simplification; maps to meshopt_simplifyWithAttributes parameters
const float* attribute_weights;
size_t attribute_count;
// attribute mask to flag attribute discontinuities for permissive simplification; mask (1<<K) corresponds to attribute K
unsigned int attribute_protect_mask;
};
// To compute approximate (perspective) projection error of a cluster in screen space (0..1; multiply by screen height to get pixels):
// - camera_proj is projection[1][1], or cot(fovy/2); camera_znear is *positive* near plane distance
// - for simplicity, we ignore perspective distortion and use rotationally invariant projection size estimation
// - return: bounds.error / max(distance(bounds.center, camera_position) - bounds.radius, camera_znear) * (camera_proj * 0.5f)
struct clodBounds
{
// sphere bounds, in mesh coordinate space
float center[3];
float radius;
// combined simplification error, in mesh coordinate space
float error;
};
struct clodCluster
{
// index of more refined group (with more triangles) that produced this cluster during simplification, or -1 for original geometry
int refined;
// cluster bounds; should only be used for culling, as bounds.error is not monotonic across DAG
clodBounds bounds;
// cluster indices; refer to the original mesh vertex buffer
const unsigned int* indices;
size_t index_count;
// cluster vertex count; indices[] has vertex_count unique entries
size_t vertex_count;
};
struct clodGroup
{
// DAG level the group was generated at
int depth;
// simplified group bounds (reflects error for clusters with clodCluster::refined == group id; error is FLT_MAX for terminal groups)
// cluster should be rendered if:
// 1. clodGroup::simplified for the group it's in is over error threshold
// 2. cluster.refined is -1 *or* clodGroup::simplified for groups[cluster.refined].simplified is at or under error threshold
clodBounds simplified;
};
// gets called for each group in sequence
// returned value gets saved for clusters emitted from this group (clodCluster::refined)
typedef int (*clodOutput)(void* output_context, clodGroup group, const clodCluster* clusters, size_t cluster_count);
// when using BVH to determine cluster visibility, traverse the tree into nodes while bounds is over error threshold; for leaves,
// group clusters should be rendered if cluster.refined is -1 *or* clodGroup::simplified for groups[cluster.refined].simplified is at or under error threshold
struct clodNode
{
// BVH node bounds, reflecting worst case error for all groups in the subtree
clodBounds bounds;
// leaf node: index of the group this node represents (or -1 for internal nodes)
int group;
// internal node: children are at nodes[child_offset..child_offset+child_count)
unsigned int child_offset;
unsigned int child_count;
};
#ifdef __cplusplus
extern "C"
{
#endif
// default configuration optimized for rasterization / raytracing
clodConfig clodDefaultConfig(size_t max_triangles);
clodConfig clodDefaultConfigRT(size_t max_triangles);
// build cluster LOD hierarchy, calling output callbacks as new clusters and groups are generated
void clodBuild(clodConfig config, clodMesh mesh, void* output_context, clodOutput output_callback);
// extract meshlet-local indices from cluster indices produced by clodBuild
// fills triangles[] and vertices[] such that vertices[triangles[i]] == indices[i]
// returns number of unique vertices (which will be equal to clodCluster::vertex_count)
size_t clodLocalIndices(unsigned int* vertices, unsigned char* triangles, const unsigned int* indices, size_t index_count);
// upper bound on the number of nodes clodBuildHierarchy can produce
// level_count is the number of levels in the group DAG (max(clodGroup::depth) + 1)
size_t clodBuildHierarchyBound(size_t group_count, size_t node_width, size_t level_count);
// build a spatial hierarchy over the groups produced by clodBuild for accelerating cluster visibility
// level_count is the number of levels in the group DAG (max(clodGroup::depth) + 1)
// the result is a forest with one tree per DAG level:
// - nodes 0..level_count-1 are the roots of per-level trees, each covering groups with clodGroup::depth == level
// - remaining nodes are the tree nodes; each internal node has up to node_width children, and each leaf node refers to a single group
size_t clodBuildHierarchy(clodNode* nodes, const clodGroup* groups, size_t group_count, size_t node_width, size_t level_count);
#ifdef __cplusplus
} // extern "C"
template <typename Output>
void clodBuild(clodConfig config, clodMesh mesh, Output output)
{
struct Call
{
static int output(void* output_context, clodGroup group, const clodCluster* clusters, size_t cluster_count)
{
return (*static_cast<Output*>(output_context))(group, clusters, cluster_count);
}
};
clodBuild(config, mesh, &output, &Call::output);
}
#endif
#ifdef CLUSTERLOD_IMPLEMENTATION
// For reference, see the original Nanite paper:
// Brian Karis. Nanite: A Deep Dive. 2021
#include <float.h>
#include <math.h>
#include <string.h>
#include <algorithm>
#include <vector>
namespace clod
{
struct Cluster
{
size_t vertices;
// offset into indices vector in the processing loop
size_t index_offset;
size_t index_count;
int refined;
clodBounds bounds;
};
static clodBounds boundsCompute(const clodMesh& mesh, const unsigned int* indices, size_t index_count, float error)
{
meshopt_Bounds bounds = meshopt_computeClusterBounds(indices, index_count, mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride);
return {{bounds.center[0], bounds.center[1], bounds.center[2]}, bounds.radius, error};
}
static clodBounds boundsMerge(const clodBounds* bounds, size_t count, size_t stride)
{
meshopt_Bounds merged = meshopt_computeSphereBounds(&bounds[0].center[0], count, stride, &bounds[0].radius, stride);
// merged bounds error must be conservative wrt cluster errors
float error = 0.f;
for (size_t j = 0; j < count; ++j)
error = std::max(error, reinterpret_cast<const clodBounds*>(reinterpret_cast<const char*>(bounds) + j * stride)->error);
return {{merged.center[0], merged.center[1], merged.center[2]}, merged.radius, error};
}
static void clusterize(std::vector<Cluster>& clusters, std::vector<unsigned int>& cluster_indices, const clodConfig& config, const clodMesh& mesh, const unsigned int* indices, size_t index_count, int refined = -1)
{
size_t max_meshlets = meshopt_buildMeshletsBound(index_count, config.max_vertices, config.min_triangles);
size_t buffer_size = max_meshlets * sizeof(meshopt_Meshlet) + index_count * (sizeof(unsigned int) + sizeof(unsigned char));
// use stack buffer for small mesh subsets (should be enough for group clusterization; initial clusterization uses heap)
alignas(meshopt_Meshlet) char buffer_stack[32768];
std::vector<char> buffer_heap;
char* buffer = (buffer_size <= sizeof(buffer_stack)) ? buffer_stack : (buffer_heap.resize(buffer_size), buffer_heap.data());
meshopt_Meshlet* meshlets = reinterpret_cast<meshopt_Meshlet*>(buffer);
unsigned int* meshlet_vertices = reinterpret_cast<unsigned int*>(meshlets + max_meshlets);
unsigned char* meshlet_triangles = reinterpret_cast<unsigned char*>(meshlet_vertices + index_count);
size_t meshlet_count;
if (config.cluster_spatial)
meshlet_count = meshopt_buildMeshletsSpatial(meshlets, meshlet_vertices, meshlet_triangles, indices, index_count,
mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride,
config.max_vertices, config.min_triangles, config.max_triangles, config.cluster_fill_weight);
else
meshlet_count = meshopt_buildMeshletsFlex(meshlets, meshlet_vertices, meshlet_triangles, indices, index_count,
mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride,
config.max_vertices, config.min_triangles, config.max_triangles, 0.f, config.cluster_split_factor);
size_t cluster_offset = clusters.size();
clusters.resize(cluster_offset + meshlet_count);
size_t index_offset = cluster_indices.size();
cluster_indices.resize(index_offset + index_count);
for (size_t i = 0; i < meshlet_count; ++i)
{
const meshopt_Meshlet& meshlet = meshlets[i];
Cluster& cluster = clusters[cluster_offset + i];
if (config.optimize_clusters)
meshopt_optimizeMeshletLevel(&meshlet_vertices[meshlet.vertex_offset], meshlet.vertex_count, &meshlet_triangles[meshlet.triangle_offset], meshlet.triangle_count, config.optimize_clusters_level);
cluster.vertices = meshlet.vertex_count;
// note: we discard meshlet-local indices; they can be recovered by the caller using clodLocalIndices
cluster.index_offset = index_offset;
cluster.index_count = meshlet.triangle_count * 3;
for (size_t j = 0; j < meshlet.triangle_count * 3; ++j)
cluster_indices[index_offset + j] = meshlet_vertices[meshlet.vertex_offset + meshlet_triangles[meshlet.triangle_offset + j]];
index_offset += meshlet.triangle_count * 3;
cluster.refined = refined;
}
}
static std::vector<unsigned int> partition(std::vector<Cluster>& clusters, const clodConfig& config, const clodMesh& mesh, const std::vector<unsigned int>& indices, const std::vector<unsigned int>& remap)
{
if (clusters.size() <= config.partition_size)
return {0, unsigned(clusters.size())};
// copy remapped cluster index data into a flat array for partitioning
std::vector<unsigned int> cluster_counts(clusters.size());
std::vector<unsigned int> cluster_indices;
cluster_indices.reserve(indices.size());
for (size_t i = 0; i < clusters.size(); ++i)
{
const Cluster& cluster = clusters[i];
cluster_counts[i] = unsigned(cluster.index_count);
for (size_t j = 0; j < cluster.index_count; ++j)
cluster_indices.push_back(remap[indices[cluster.index_offset + j]]);
}
// partition clusters into groups; the output is a partition id per cluster
std::vector<unsigned int> cluster_part(clusters.size());
size_t partition_count = meshopt_partitionClusters(&cluster_part[0], &cluster_indices[0], cluster_indices.size(), &cluster_counts[0], cluster_counts.size(),
config.partition_spatial ? mesh.vertex_positions : NULL, remap.size(), mesh.vertex_positions_stride, config.partition_size);
if (config.partition_sort)
{
// compute partition points for sorting; any representative point will do, we use last cluster center for simplicity
std::vector<float> partition_point(partition_count * 3);
for (size_t i = 0; i < clusters.size(); ++i)
memcpy(&partition_point[cluster_part[i] * 3], clusters[i].bounds.center, sizeof(float) * 3);
// sort partitions spatially; the output is a remap table from old index (partition id) to new index
std::vector<unsigned int> partition_remap(partition_count);
meshopt_spatialSortRemap(partition_remap.data(), partition_point.data(), partition_count, sizeof(float) * 3);
for (size_t i = 0; i < clusters.size(); ++i)
cluster_part[i] = partition_remap[cluster_part[i]];
}
// count clusters in each partition and compute offsets via prefix sum
std::vector<unsigned int> partition_offsets(partition_count + 1);
for (size_t i = 0; i < clusters.size(); ++i)
partition_offsets[cluster_part[i] + 1]++;
for (size_t i = 1; i <= partition_count; ++i)
partition_offsets[i] += partition_offsets[i - 1];
// distribute clusters into contiguous partitions
std::vector<unsigned int> write = partition_offsets;
std::vector<Cluster> result(clusters.size());
for (size_t i = 0; i < clusters.size(); ++i)
result[write[cluster_part[i]]++] = clusters[i];
clusters.swap(result);
return partition_offsets;
}
static void lockBoundary(std::vector<unsigned char>& locks, const Cluster* clusters, const unsigned int* group_offsets, size_t group_count, const std::vector<unsigned int>& cluster_indices, const std::vector<unsigned int>& remap, const unsigned char* vertex_lock)
{
// for each remapped vertex, use bit 7 as temporary storage to indicate that the vertex has been used by a different group previously
for (size_t i = 0; i < locks.size(); ++i)
locks[i] &= ~((1 << 0) | (1 << 7));
for (size_t i = 0; i < group_count; ++i)
{
const Cluster* group = clusters + group_offsets[i];
size_t group_size = group_offsets[i + 1] - group_offsets[i];
// mark all remapped vertices as locked if seen by a prior group
for (size_t j = 0; j < group_size; ++j)
{
const Cluster& cluster = group[j];
for (size_t k = 0; k < cluster.index_count; ++k)
{
unsigned int v = cluster_indices[cluster.index_offset + k];
unsigned int r = remap[v];
locks[r] |= locks[r] >> 7;
}
}
// mark all remapped vertices as seen
for (size_t j = 0; j < group_size; ++j)
{
const Cluster& cluster = group[j];
for (size_t k = 0; k < cluster.index_count; ++k)
{
unsigned int v = cluster_indices[cluster.index_offset + k];
unsigned int r = remap[v];
locks[r] |= 1 << 7;
}
}
}
for (size_t i = 0; i < locks.size(); ++i)
{
unsigned int r = remap[i];
// consistently lock all vertices with the same position; keep protect bit if set
locks[i] = (locks[r] & 1) | (locks[i] & meshopt_SimplifyVertex_Protect);
if (vertex_lock)
locks[i] |= vertex_lock[i];
}
}
struct SloppyVertex
{
float x, y, z;
unsigned int id;
};
static void simplifyFallback(std::vector<unsigned int>& lod, const clodMesh& mesh, const std::vector<unsigned int>& indices, const std::vector<unsigned char>& locks, size_t target_count, float* error)
{
std::vector<SloppyVertex> subset(indices.size());
std::vector<unsigned char> subset_locks(indices.size());
lod.resize(indices.size());
size_t positions_stride = mesh.vertex_positions_stride / sizeof(float);
// deindex the mesh subset to avoid calling simplifySloppy on the entire vertex buffer (which is prohibitively expensive without sparsity)
for (size_t i = 0; i < indices.size(); ++i)
{
unsigned int v = indices[i];
assert(v < mesh.vertex_count);
subset[i].x = mesh.vertex_positions[v * positions_stride + 0];
subset[i].y = mesh.vertex_positions[v * positions_stride + 1];
subset[i].z = mesh.vertex_positions[v * positions_stride + 2];
subset[i].id = v;
subset_locks[i] = locks[v];
lod[i] = unsigned(i);
}
lod.resize(meshopt_simplifySloppy(&lod[0], &lod[0], lod.size(), &subset[0].x, subset.size(), sizeof(SloppyVertex), subset_locks.data(), target_count, FLT_MAX, error));
// convert error to absolute
*error *= meshopt_simplifyScale(&subset[0].x, subset.size(), sizeof(SloppyVertex));
// restore original vertex indices
for (size_t i = 0; i < lod.size(); ++i)
lod[i] = subset[lod[i]].id;
}
static void simplify(std::vector<unsigned int>& lod, const clodConfig& config, const clodMesh& mesh, const std::vector<unsigned int>& indices, const std::vector<unsigned char>& locks, size_t target_count, float* error)
{
assert(target_count <= indices.size());
lod.resize(indices.size());
unsigned int options = meshopt_SimplifySparse | meshopt_SimplifyErrorAbsolute;
if (config.simplify_error_clamped)
options |= meshopt_SimplifyErrorClamped;
if (config.simplify_permissive)
options |= meshopt_SimplifyPermissive;
if (config.simplify_regularize)
options |= meshopt_SimplifyRegularize;
if (config.simplify_preserve_folds)
options |= meshopt_SimplifyPreserveFolds;
lod.resize(meshopt_simplifyWithAttributes(&lod[0], &indices[0], indices.size(),
mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride,
mesh.vertex_attributes, mesh.vertex_attributes_stride, mesh.attribute_weights, mesh.attribute_count,
&locks[0], target_count, FLT_MAX, options, error));
if (lod.size() > target_count && config.simplify_fallback_permissive && !config.simplify_permissive)
lod.resize(meshopt_simplifyWithAttributes(&lod[0], &indices[0], indices.size(),
mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride,
mesh.vertex_attributes, mesh.vertex_attributes_stride, mesh.attribute_weights, mesh.attribute_count,
&locks[0], target_count, FLT_MAX, options | meshopt_SimplifyPermissive, error));
// while it's possible to call simplifySloppy directly, it doesn't support sparsity or absolute error, so we need to do some extra work
if (lod.size() > target_count && config.simplify_fallback_sloppy)
{
simplifyFallback(lod, mesh, indices, locks, target_count, error);
*error *= config.simplify_error_factor_sloppy; // scale error up to account for appearance degradation
}
// replace degenerate outputs with the first triangle; this ensures we do not produce empty clusters that break DAG invariants
if (lod.empty() && !indices.empty())
lod.assign(indices.begin(), indices.begin() + 3);
// optionally limit error by edge length, aiming to remove subpixel triangles even if the attribute error is high
if (config.simplify_error_edge_limit > 0)
{
float max_edge_sq = 0;
for (size_t i = 0; i < indices.size(); i += 3)
{
unsigned int a = indices[i + 0], b = indices[i + 1], c = indices[i + 2];
assert(a < mesh.vertex_count && b < mesh.vertex_count && c < mesh.vertex_count);
const float* va = &mesh.vertex_positions[a * (mesh.vertex_positions_stride / sizeof(float))];
const float* vb = &mesh.vertex_positions[b * (mesh.vertex_positions_stride / sizeof(float))];
const float* vc = &mesh.vertex_positions[c * (mesh.vertex_positions_stride / sizeof(float))];
// compute squared edge lengths
float eab = (va[0] - vb[0]) * (va[0] - vb[0]) + (va[1] - vb[1]) * (va[1] - vb[1]) + (va[2] - vb[2]) * (va[2] - vb[2]);
float eac = (va[0] - vc[0]) * (va[0] - vc[0]) + (va[1] - vc[1]) * (va[1] - vc[1]) + (va[2] - vc[2]) * (va[2] - vc[2]);
float ebc = (vb[0] - vc[0]) * (vb[0] - vc[0]) + (vb[1] - vc[1]) * (vb[1] - vc[1]) + (vb[2] - vc[2]) * (vb[2] - vc[2]);
float emax = std::max(std::max(eab, eac), ebc);
float emin = std::min(std::min(eab, eac), ebc);
// we prefer using min edge length to reduce the number of triangles <1px thick, but need some stopgap for thin and long triangles like wires
max_edge_sq = std::max(max_edge_sq, std::max(emin, emax / 4));
}
// adjust the error to limit it for dense clusters based on edge lengths
*error = std::min(*error, sqrtf(max_edge_sq) * config.simplify_error_edge_limit);
}
}
static int outputGroup(const clodConfig& config, const clodMesh& mesh, const Cluster* group, size_t group_size, const std::vector<unsigned int>& cluster_indices, const clodBounds& simplified, int depth, void* output_context, clodOutput output_callback)
{
// use stack buffer for small groups; should pretty much never trigger heap allocations
clodCluster clusters_stack[32];
std::vector<clodCluster> clusters_heap;
clodCluster* group_clusters = (group_size <= sizeof(clusters_stack) / sizeof(clusters_stack[0])) ? clusters_stack : (clusters_heap.resize(group_size), clusters_heap.data());
for (size_t i = 0; i < group_size; ++i)
{
const Cluster& cluster = group[i];
clodCluster& result = group_clusters[i];
result.refined = cluster.refined;
result.bounds = (config.optimize_bounds && cluster.refined != -1) ? boundsCompute(mesh, cluster_indices.data() + cluster.index_offset, cluster.index_count, cluster.bounds.error) : cluster.bounds;
result.indices = cluster_indices.data() + cluster.index_offset;
result.index_count = cluster.index_count;
result.vertex_count = cluster.vertices;
}
return output_callback ? output_callback(output_context, {depth, simplified}, group_clusters, group_size) : -1;
}
static unsigned long long* edgeLookup(std::vector<unsigned long long>& table, unsigned long long key)
{
size_t mask = table.size() - 1;
size_t h = size_t((key * 0x9E3779B97F4A7C15ull) >> 32) & mask;
while (table[h] != ~0ull && table[h] != key)
h = (h + 1) & mask;
return &table[h];
}
static float boundaryArea(const clodMesh& mesh, const std::vector<unsigned int>& indices, const std::vector<unsigned char>& locks, const std::vector<unsigned int>& remap, std::vector<unsigned long long>& table)
{
memset(table.data(), -1, sizeof(unsigned long long) * table.size());
for (size_t i = 0; i < indices.size(); i += 3)
for (int e = 0; e < 3; ++e)
{
unsigned int a = remap[indices[i + e]], b = remap[indices[i + (e == 2 ? 0 : e + 1)]];
unsigned long long id = (unsigned long long)a << 32 | b;
*edgeLookup(table, id) = id;
}
float area = 0.f;
for (size_t i = 0; i < indices.size(); i += 3)
{
bool border = false;
for (int e = 0; e < 3; ++e)
{
unsigned int a = remap[indices[i + e]], b = remap[indices[i + (e == 2 ? 0 : e + 1)]];
unsigned long long id = (unsigned long long)b << 32 | a;
// we only track border triangles - at least one edge should be unlocked and open (the reverse edge does not exist)
border |= (locks[a] & locks[b] & 1) == 0 && *edgeLookup(table, id) == ~0ull;
}
if (!border)
continue;
const float* va = &mesh.vertex_positions[indices[i + 0] * (mesh.vertex_positions_stride / sizeof(float))];
const float* vb = &mesh.vertex_positions[indices[i + 1] * (mesh.vertex_positions_stride / sizeof(float))];
const float* vc = &mesh.vertex_positions[indices[i + 2] * (mesh.vertex_positions_stride / sizeof(float))];
float ab[3] = {vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]};
float ac[3] = {vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]};
float nt[3] = {ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]};
area += sqrtf(nt[0] * nt[0] + nt[1] * nt[1] + nt[2] * nt[2]) * 0.5f;
}
return area;
}
static void dilateBorders(const clodMesh& mesh, const std::vector<unsigned int>& merged, const std::vector<unsigned int>& simplified, const std::vector<unsigned char>& locks, const std::vector<unsigned int>& remap, clodBounds& bounds, std::vector<float>& dilate_offsets, std::vector<unsigned long long>& edge_table)
{
// hash table is sized to be able to track all edges
size_t table_size = 16;
while (table_size < merged.size() + merged.size() / 2)
table_size *= 2;
edge_table.resize(table_size);
float old_area = boundaryArea(mesh, merged, locks, remap, edge_table);
if (old_area == 0.f)
return;
// limit expansion to cases of moderate area shrinkage; it may be unsafe to dilate otherwise
float new_area = boundaryArea(mesh, simplified, locks, remap, edge_table);
if (new_area >= old_area || new_area < old_area / 4)
return;
float perimeter = 0.f;
for (size_t i = 0; i < simplified.size(); i += 3)
for (int e = 0; e < 3; ++e)
{
unsigned int a = remap[simplified[i + e]], b = remap[simplified[i + (e == 2 ? 0 : e + 1)]];
unsigned long long id = (unsigned long long)b << 32 | a;
// the border check must match the one in boundaryArea (we also rely on table being filled by boundaryArea to avoid extra work)
if ((locks[a] & locks[b] & 1) != 0 || *edgeLookup(edge_table, id) != ~0ull)
continue;
const float* va = &mesh.vertex_positions[simplified[i + e] * (mesh.vertex_positions_stride / sizeof(float))];
const float* vb = &mesh.vertex_positions[simplified[i + (e == 2 ? 0 : e + 1)] * (mesh.vertex_positions_stride / sizeof(float))];
const float* vc = &mesh.vertex_positions[simplified[i + (e + 2) % 3] * (mesh.vertex_positions_stride / sizeof(float))];
float ev[3] = {vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]};
float el = sqrtf(ev[0] * ev[0] + ev[1] * ev[1] + ev[2] * ev[2]);
// compute edge normal by projecting C onto AB
float cv[3] = {va[0] - vc[0], va[1] - vc[1], va[2] - vc[2]};
float cp = (cv[0] * ev[0] + cv[1] * ev[1] + cv[2] * ev[2]) / (el > 0.f ? el * el : 1.f);
float nv[3] = {cv[0] - ev[0] * cp, cv[1] - ev[1] * cp, cv[2] - ev[2] * cp};
float nl = sqrtf(nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]);
// accumulate edge normals weighted by edge length; we will renormalize by perimeter before applying
float ns = nl > 0.f ? el / nl : 0;
if ((locks[a] & 1) == 0)
dilate_offsets[a * 4 + 0] += nv[0] * ns, dilate_offsets[a * 4 + 1] += nv[1] * ns, dilate_offsets[a * 4 + 2] += nv[2] * ns, dilate_offsets[a * 4 + 3] += el;
if ((locks[b] & 1) == 0)
dilate_offsets[b * 4 + 0] += nv[0] * ns, dilate_offsets[b * 4 + 1] += nv[1] * ns, dilate_offsets[b * 4 + 2] += nv[2] * ns, dilate_offsets[b * 4 + 3] += el;
perimeter += el;
}
float distance = perimeter > 0.f ? (old_area - new_area) / perimeter : 0.f;
for (size_t i = 0; i < simplified.size(); ++i)
{
unsigned int r = remap[simplified[i]];
if (locks[r] & 1)
continue;
float* vi = const_cast<float*>(&mesh.vertex_positions[simplified[i] * (mesh.vertex_positions_stride / sizeof(float))]);
float* vr = const_cast<float*>(&mesh.vertex_positions[r * (mesh.vertex_positions_stride / sizeof(float))]);
float* n = &dilate_offsets[r * 4];
if (n[3] > 0.f)
{
// we average the normals and then divide by length twice: once to normalize, and once to extend the offset into a miter
float nx = n[0] / n[3], ny = n[1] / n[3], nz = n[2] / n[3];
float nn = nx * nx + ny * ny + nz * nz;
float ns = distance / (nn > 0.15f ? nn : 0.15f);
vr[0] += nx * ns, vr[1] += ny * ns, vr[2] += nz * ns;
n[0] = n[1] = n[2] = n[3] = 0.f;
// expand group bounds to accommodate the offset vertex
float dx = vr[0] - bounds.center[0], dy = vr[1] - bounds.center[1], dz = vr[2] - bounds.center[2];
bounds.radius = std::max(bounds.radius, sqrtf(dx * dx + dy * dy + dz * dz));
}
// copy dilated positions back to all referencing wedges from the canonical copy updated above; no-op for vertices that weren't dilated
vi[0] = vr[0], vi[1] = vr[1], vi[2] = vr[2];
}
}
static clodNode mergeNodes(const clodNode* nodes, size_t offset, size_t count)
{
clodNode result = {};
result.bounds = boundsMerge(&nodes[offset].bounds, count, sizeof(clodNode));
result.group = -1;
result.child_offset = unsigned(offset);
result.child_count = unsigned(count);
return result;
}
} // namespace clod
clodConfig clodDefaultConfig(size_t max_triangles)
{
assert(max_triangles >= 4 && max_triangles <= 256);
clodConfig config = {};
config.max_vertices = max_triangles;
config.min_triangles = max_triangles / 3;
config.max_triangles = max_triangles;
config.partition_spatial = true;
config.partition_size = 16;
config.cluster_spatial = false;
config.cluster_split_factor = 2.0f;
config.optimize_clusters = true;
config.optimize_clusters_level = 1;
config.simplify_ratio = 0.5f;
config.simplify_threshold = 0.85f;
config.simplify_error_merge_previous = 1.0f;
config.simplify_error_factor_sloppy = 2.0f;
config.simplify_error_clamped = true;
config.simplify_permissive = true;
config.simplify_fallback_permissive = false; // note: by default we run in permissive mode, but it's also possible to disable that and use it only as a fallback
config.simplify_fallback_sloppy = true;
return config;
}
clodConfig clodDefaultConfigRT(size_t max_triangles)
{
clodConfig config = clodDefaultConfig(max_triangles);
// for ray tracing, we may want smaller clusters when that improves BVH quality further; for maximum ray tracing performance this could be reduced even further
config.min_triangles = max_triangles / 4;
// by default, we use larger max_vertices for RT; the vertex count is not important for ray tracing performance, and this helps improve cluster utilization
config.max_vertices = std::min(size_t(256), max_triangles * 2);
config.cluster_spatial = true;
config.cluster_fill_weight = 0.5f;
return config;
}
void clodBuild(clodConfig config, clodMesh mesh, void* output_context, clodOutput output_callback)
{
using namespace clod;
assert(mesh.vertex_attributes_stride % sizeof(float) == 0);
assert(mesh.attribute_count * sizeof(float) <= mesh.vertex_attributes_stride);
assert(mesh.attribute_protect_mask < (1u << (mesh.vertex_attributes_stride / sizeof(float))));
std::vector<unsigned char> locks(mesh.vertex_count);
// for cluster connectivity, we need a position-only remap that maps vertices with the same position to the same index
std::vector<unsigned int> remap(mesh.vertex_count);
meshopt_generatePositionRemap(&remap[0], mesh.vertex_positions, mesh.vertex_count, mesh.vertex_positions_stride);
// set up protect bits on UV seams for permissive mode
if (mesh.attribute_protect_mask)
{
size_t max_attributes = mesh.vertex_attributes_stride / sizeof(float);
for (size_t i = 0; i < mesh.vertex_count; ++i)
{
unsigned int r = remap[i]; // canonical vertex with the same position
for (size_t j = 0; j < max_attributes; ++j)
if (r != i && (mesh.attribute_protect_mask & (1u << j)) && mesh.vertex_attributes[i * max_attributes + j] != mesh.vertex_attributes[r * max_attributes + j])
locks[i] |= meshopt_SimplifyVertex_Protect;
}
}
std::vector<float> dilate_offsets(config.simplify_dilate_borders ? mesh.vertex_count * 4 : 0);
std::vector<unsigned long long> dilate_table;
// initial clusterization splits the original mesh
std::vector<unsigned int> cluster_indices;
std::vector<Cluster> clusters;
clusterize(clusters, cluster_indices, config, mesh, mesh.indices, mesh.index_count);
// compute initial precise bounds; subsequent bounds will be using group-merged bounds
for (Cluster& cluster : clusters)
cluster.bounds = boundsCompute(mesh, cluster_indices.data() + cluster.index_offset, cluster.index_count, 0.f);
std::vector<Cluster> pending;
std::vector<unsigned int> pending_indices;
pending.reserve(clusters.size());
pending_indices.reserve(size_t(cluster_indices.size() * config.simplify_threshold));
std::vector<unsigned int> merged, simplified;
merged.reserve((config.partition_size + config.partition_size / 3) * config.max_triangles * 3);
int depth = 0;
// merge and simplify clusters until we can't merge anymore
while (clusters.size() > 1)
{
std::vector<unsigned int> group_offsets = partition(clusters, config, mesh, cluster_indices, remap);
pending.clear();
pending_indices.clear();
// mark boundaries between groups with a lock bit to avoid gaps in simplified result
lockBoundary(locks, clusters.data(), group_offsets.data(), group_offsets.size() - 1, cluster_indices, remap, mesh.vertex_lock);
// every group needs to be simplified now
for (size_t i = 0; i + 1 < group_offsets.size(); ++i)
{
const Cluster* group = clusters.data() + group_offsets[i];
size_t group_size = group_offsets[i + 1] - group_offsets[i];
merged.clear();
for (size_t j = 0; j < group_size; ++j)
merged.insert(merged.end(), cluster_indices.begin() + group[j].index_offset, cluster_indices.begin() + group[j].index_offset + group[j].index_count);
size_t target_size = size_t((merged.size() / 3) * config.simplify_ratio) * 3;
// enforce bounds and error monotonicity
// note: it is incorrect to use the precise bounds of the merged or simplified mesh, because this may violate monotonicity
clodBounds bounds = boundsMerge(&group[0].bounds, group_size, sizeof(Cluster));
float error = 0.f;
simplify(simplified, config, mesh, merged, locks, target_size, &error);
if (simplified.size() > merged.size() * config.simplify_threshold)
{
bounds.error = FLT_MAX; // terminal group, won't simplify further
outputGroup(config, mesh, group, group_size, cluster_indices, bounds, depth, output_context, output_callback);
continue; // simplification is stuck; abandon the merge
}
// enforce error monotonicity (with an optional hierarchical factor to separate transitions more)
bounds.error = std::max(bounds.error * config.simplify_error_merge_previous, error) + error * config.simplify_error_merge_additive;
// output the new group with all clusters; the resulting id will be recorded in new clusters as clodCluster::refined
int refined = outputGroup(config, mesh, group, group_size, cluster_indices, bounds, depth, output_context, output_callback);
// now that we've output the group with the original clusters, we need to dilate simplified clusters if requested
if (config.simplify_dilate_borders)
dilateBorders(mesh, merged, simplified, locks, remap, bounds, dilate_offsets, dilate_table);
// enqueue new clusters for further processing
size_t cluster_offset = pending.size();
clusterize(pending, pending_indices, config, mesh, simplified.data(), simplified.size(), refined);
// update cluster group bounds to the group-merged bounds; this ensures that we compute the group bounds for whatever group this cluster will be part of conservatively
for (size_t j = cluster_offset; j < pending.size(); ++j)
pending[j].bounds = bounds;
}
clusters.swap(pending);
cluster_indices.swap(pending_indices);
depth++;
}
if (clusters.size())
{
assert(clusters.size() == 1);
const Cluster& cluster = clusters[0];
clodBounds bounds = cluster.bounds;
bounds.error = FLT_MAX; // terminal group, won't simplify further
outputGroup(config, mesh, clusters.data(), clusters.size(), cluster_indices, bounds, depth, output_context, output_callback);
}
}
size_t clodLocalIndices(unsigned int* vertices, unsigned char* triangles, const unsigned int* indices, size_t index_count)
{
return meshopt_extractMeshletIndices(vertices, triangles, indices, index_count);
}
size_t clodBuildHierarchyBound(size_t group_count, size_t node_width, size_t level_count)
{
// count nodes for each tree depth; we pad by level_count at each iteration to account for unknown level distribution in the forest
size_t total = level_count;
for (size_t frontier = group_count; frontier > 1; frontier = (frontier + node_width - 1) / node_width)
total += frontier + level_count;
return total;
}
size_t clodBuildHierarchy(clodNode* nodes, const clodGroup* groups, size_t group_count, size_t node_width, size_t level_count)
{
using namespace clod;
// reserve space for per-level roots
size_t offset = level_count;
std::vector<clodNode> row(group_count);
std::vector<unsigned int> order(group_count);
for (size_t level = 0; level < level_count; ++level)
{
// start each tree hierarchy from the groups of that level as leaves
row.clear();
for (size_t i = 0; i < group_count; ++i)
if (groups[i].depth == int(level))
{
clodNode node = {};
node.bounds = groups[i].simplified;
node.group = int(i);
row.push_back(node);
}
// build the tree going up one level at a time, using spatial grouping of centers as a proxy for locality
while (row.size() > 1)
{
size_t count = row.size();
meshopt_spatialClusterPoints(order.data(), row[0].bounds.center, count, sizeof(clodNode), node_width);
for (size_t i = 0; i < count; ++i)
nodes[offset + i] = row[order[i]];
row.clear();
for (size_t i = 0; i < count; i += node_width)
{
// spatialClusterPoints guarantees that each cluster except for last one is full
size_t children = std::min(node_width, count - i);
row.push_back(mergeNodes(nodes, offset + i, children));
}
offset += count;
}
// the root of the current level goes into the fixed section at the beginning
assert(row.size() == 1);
nodes[level] = row[0];
}
assert(offset <= clodBuildHierarchyBound(group_count, node_width, level_count));
return offset;
}
#endif
/**
* Copyright (c) 2016-2026 Arseny Kapoulkine
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/