From 03b29575b8cbcbef0ee44edae3ab1610427f73bc Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Thu, 13 Jun 2024 21:26:12 -0700 Subject: [PATCH 01/13] demo: Implement a basic example of cluster simplification This is an important building block for Nanite, as it requires a repeated application of buildMeshlets => simplify (with additional algorithms that meshoptimizer currently doesn't support, like grouping meshlets based on proximity). This can be used to refine future improvements to simplification like sparsity, as well as serve as a basic example. For simplicity we use LockBorders flag instead of locking boundary vertices manually via meshopt_simplifyWithAttributes; from production perspective both options are viable. --- demo/main.cpp | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/demo/main.cpp b/demo/main.cpp index f3ac62d7..e3969ec9 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -641,6 +641,52 @@ void simplifyComplete(const Mesh& mesh) } } +void simplifyClusters(const Mesh& mesh, float threshold = 0.2f) +{ + // note: we use clusters that are larger than normal to give simplifier room to work; in practice you'd use cluster groups merged from smaller clusters and build a cluster DAG + const size_t max_vertices = 255; + const size_t max_triangles = 512; + + double start = timestamp(); + + size_t max_meshlets = meshopt_buildMeshletsBound(mesh.indices.size(), max_vertices, max_triangles); + std::vector meshlets(max_meshlets); + std::vector meshlet_vertices(max_meshlets * max_vertices); + std::vector meshlet_triangles(max_meshlets * max_triangles * 3); + + meshlets.resize(meshopt_buildMeshlets(&meshlets[0], &meshlet_vertices[0], &meshlet_triangles[0], &mesh.indices[0], mesh.indices.size(), &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), max_vertices, max_triangles, 0.f)); + + double middle = timestamp(); + + std::vector lod; + lod.reserve(mesh.indices.size()); + + for (size_t i = 0; i < meshlets.size(); ++i) + { + const meshopt_Meshlet& m = meshlets[i]; + + size_t cluster_offset = lod.size(); + + for (size_t j = 0; j < m.triangle_count * 3; ++j) + lod.push_back(meshlet_vertices[m.vertex_offset + meshlet_triangles[m.triangle_offset + j]]); + + unsigned int options = meshopt_SimplifyLockBorder; + + size_t cluster_target = size_t(float(m.triangle_count) * threshold) * 3; + size_t cluster_size = meshopt_simplify(&lod[cluster_offset], &lod[cluster_offset], m.triangle_count * 3, &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), cluster_target, threshold, options, 0); + + // simplified cluster is available in lod[cluster_offset..cluster_offset + cluster_size] + lod.resize(cluster_offset + cluster_size); + } + + double end = timestamp(); + + printf("%-9s: %d triangles => %d triangles in %.2f msec, clusterized in %.2f msec\n", + "SimplifyN", // N for Nanite + int(mesh.indices.size() / 3), int(lod.size() / 3), + (end - middle) * 1000, (middle - start) * 1000); +} + void optimize(const Mesh& mesh, const char* name, void (*optf)(Mesh& mesh)) { Mesh copy = mesh; @@ -1245,7 +1291,7 @@ void processDev(const char* path) if (!loadMesh(mesh, path)) return; - simplify(mesh); + simplifyClusters(mesh); } int main(int argc, char** argv) From e42f3d3f1141cd94659ad53debe05a5c5d222d52 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Thu, 13 Jun 2024 22:21:59 -0700 Subject: [PATCH 02/13] simplify: Implement initial support for sparse simplification When simplifying a small subset of the larger mesh, all computations that go over the entire vertex buffer become expensive; this adds up even when done once, and especially when done for every pass. This change introduces a sparse simplification mode that instructs the simplifier to optimize based on the assumption that the subset of the mesh that is being simplified is small. In that case it's worth spending extra time to convert indices into a small 0..U subrange, do all internal processing assuming we are working with a small vertex/index buffer, and remap the indices at the end. While this processing could be done externally, that is less efficient as it requires constant copying of position/attribute data; in constrast, we can do it fairly cheaply. We need to take sparse_remap into account for any code that indexes input position/attribute data; buildPositionRemap needs this as well as it currently works off of original (unscaled) data. buildSparseRemap does need to perform O(dense) work if we want to avoid using hash maps for deduplication/filtering; this change uses a small zeroed array and a large sparsely-initialized array to reduce the cost. This is subject to future improvements (although is fairly performant as it is when simplifying 500-triangle subsets of a 800K triangle mesh). --- demo/main.cpp | 2 +- src/meshoptimizer.h | 2 + src/simplifier.cpp | 93 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/demo/main.cpp b/demo/main.cpp index e3969ec9..665951aa 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -670,7 +670,7 @@ void simplifyClusters(const Mesh& mesh, float threshold = 0.2f) for (size_t j = 0; j < m.triangle_count * 3; ++j) lod.push_back(meshlet_vertices[m.vertex_offset + meshlet_triangles[m.triangle_offset + j]]); - unsigned int options = meshopt_SimplifyLockBorder; + unsigned int options = meshopt_SimplifyLockBorder | meshopt_SimplifySparse; size_t cluster_target = size_t(float(m.triangle_count) * threshold) * 3; size_t cluster_size = meshopt_simplify(&lod[cluster_offset], &lod[cluster_offset], m.triangle_count * 3, &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), cluster_target, threshold, options, 0); diff --git a/src/meshoptimizer.h b/src/meshoptimizer.h index c377eebb..a01e6cf4 100644 --- a/src/meshoptimizer.h +++ b/src/meshoptimizer.h @@ -330,6 +330,8 @@ enum { /* Do not move vertices that are located on the topological border (vertices on triangle edges that don't have a paired triangle). Useful for simplifying portions of the larger mesh. */ meshopt_SimplifyLockBorder = 1 << 0, + /* Improve simplification performance assuming input indices are a sparse subset of the mesh. Note that error becomes relative to subset extents. */ + meshopt_SimplifySparse = 1 << 1, }; /** diff --git a/src/simplifier.cpp b/src/simplifier.cpp index a84b3327..9d3d5bac 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -111,10 +111,12 @@ struct PositionHasher { const float* vertex_positions; size_t vertex_stride_float; + const unsigned int* sparse_remap; size_t hash(unsigned int index) const { - const unsigned int* key = reinterpret_cast(vertex_positions + index * vertex_stride_float); + unsigned int ri = sparse_remap ? sparse_remap[index] : index; + const unsigned int* key = reinterpret_cast(vertex_positions + ri * vertex_stride_float); // scramble bits to make sure that integer coordinates have entropy in lower bits unsigned int x = key[0] ^ (key[0] >> 17); @@ -127,7 +129,10 @@ struct PositionHasher bool equal(unsigned int lhs, unsigned int rhs) const { - return memcmp(vertex_positions + lhs * vertex_stride_float, vertex_positions + rhs * vertex_stride_float, sizeof(float) * 3) == 0; + unsigned int li = sparse_remap ? sparse_remap[lhs] : lhs; + unsigned int ri = sparse_remap ? sparse_remap[rhs] : rhs; + + return memcmp(vertex_positions + li * vertex_stride_float, vertex_positions + ri * vertex_stride_float, sizeof(float) * 3) == 0; } }; @@ -167,9 +172,9 @@ static T* hashLookup2(T* table, size_t buckets, const Hash& hash, const T& key, return NULL; } -static void buildPositionRemap(unsigned int* remap, unsigned int* wedge, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, meshopt_Allocator& allocator) +static void buildPositionRemap(unsigned int* remap, unsigned int* wedge, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const unsigned int* sparse_remap, meshopt_Allocator& allocator) { - PositionHasher hasher = {vertex_positions_data, vertex_positions_stride / sizeof(float)}; + PositionHasher hasher = {vertex_positions_data, vertex_positions_stride / sizeof(float), sparse_remap}; size_t table_size = hashBuckets2(vertex_count); unsigned int* table = allocator.allocate(table_size); @@ -205,6 +210,43 @@ static void buildPositionRemap(unsigned int* remap, unsigned int* wedge, const f allocator.deallocate(table); } +static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, size_t vertex_count, size_t* out_vertex_count, meshopt_Allocator& allocator) +{ + unsigned char* filter = allocator.allocate(vertex_count); + memset(filter, 0, vertex_count); + + size_t unique = 0; + for (size_t i = 0; i < index_count; ++i) + { + assert(indices[i] < vertex_count); + unique += filter[indices[i]] == 0; + filter[indices[i]] = 1; + } + + unsigned int* revremap = allocator.allocate(vertex_count); + + unsigned int* remap = allocator.allocate(unique); + size_t offset = 0; + for (size_t i = 0; i < index_count; ++i) + { + if (filter[indices[i]] == 1) + { + remap[offset] = indices[i]; + filter[indices[i]] = 0; + revremap[indices[i]] = unsigned(offset); + indices[i] = unsigned(offset); + offset++; + } + else + { + indices[i] = revremap[indices[i]]; + } + } + + *out_vertex_count = unique; + return remap; +} + enum VertexKind { Kind_Manifold, // not on an attribute seam, not on any boundary @@ -383,7 +425,7 @@ struct Vector3 float x, y, z; }; -static float rescalePositions(Vector3* result, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride) +static float rescalePositions(Vector3* result, const float* vertex_positions_data, size_t vertex_count, size_t vertex_positions_stride, const unsigned int* sparse_remap = NULL) { size_t vertex_stride_float = vertex_positions_stride / sizeof(float); @@ -392,7 +434,8 @@ static float rescalePositions(Vector3* result, const float* vertex_positions_dat for (size_t i = 0; i < vertex_count; ++i) { - const float* v = vertex_positions_data + i * vertex_stride_float; + unsigned int ri = sparse_remap ? sparse_remap[i] : i; + const float* v = vertex_positions_data + ri * vertex_stride_float; if (result) { @@ -431,15 +474,17 @@ static float rescalePositions(Vector3* result, const float* vertex_positions_dat return extent; } -static void rescaleAttributes(float* result, const float* vertex_attributes_data, size_t vertex_count, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count) +static void rescaleAttributes(float* result, const float* vertex_attributes_data, size_t vertex_count, size_t vertex_attributes_stride, const float* attribute_weights, size_t attribute_count, const unsigned int* sparse_remap) { size_t vertex_attributes_stride_float = vertex_attributes_stride / sizeof(float); for (size_t i = 0; i < vertex_count; ++i) { + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); + for (size_t k = 0; k < attribute_count; ++k) { - float a = vertex_attributes_data[i * vertex_attributes_stride_float + k]; + float a = vertex_attributes_data[ri * vertex_attributes_stride_float + k]; result[i * attribute_count + k] = a * attribute_weights[k]; } @@ -1481,7 +1526,7 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); assert(vertex_positions_stride % sizeof(float) == 0); assert(target_index_count <= index_count); - assert((options & ~(meshopt_SimplifyLockBorder)) == 0); + assert((options & ~(meshopt_SimplifyLockBorder | meshopt_SimplifySparse)) == 0); assert(vertex_attributes_stride >= attribute_count * sizeof(float) && vertex_attributes_stride <= 256); assert(vertex_attributes_stride % sizeof(float) == 0); assert(attribute_count <= kMaxAttributes); @@ -1489,16 +1534,24 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic meshopt_Allocator allocator; unsigned int* result = destination; + if (result != indices) + memcpy(result, indices, index_count * sizeof(unsigned int)); + + // build an index remap and update indices/vertex_count to minimize the subsequent work + // note: as a consequence, errors will be computed relative to the subset extent + unsigned int* sparse_remap = NULL; + if (options & meshopt_SimplifySparse) + sparse_remap = buildSparseRemap(result, index_count, vertex_count, &vertex_count, allocator); // build adjacency information EdgeAdjacency adjacency = {}; prepareEdgeAdjacency(adjacency, index_count, vertex_count, allocator); - updateEdgeAdjacency(adjacency, indices, index_count, vertex_count, NULL); + updateEdgeAdjacency(adjacency, result, index_count, vertex_count, NULL); // build position remap that maps each vertex to the one with identical position unsigned int* remap = allocator.allocate(vertex_count); unsigned int* wedge = allocator.allocate(vertex_count); - buildPositionRemap(remap, wedge, vertex_positions_data, vertex_count, vertex_positions_stride, allocator); + buildPositionRemap(remap, wedge, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap, allocator); // classify vertices; vertex kind determines collapse rules, see kCanCollapse unsigned char* vertex_kind = allocator.allocate(vertex_count); @@ -1522,14 +1575,14 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic #endif Vector3* vertex_positions = allocator.allocate(vertex_count); - rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride); + rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap); float* vertex_attributes = NULL; if (attribute_count) { vertex_attributes = allocator.allocate(vertex_count * attribute_count); - rescaleAttributes(vertex_attributes, vertex_attributes_data, vertex_count, vertex_attributes_stride, attribute_weights, attribute_count); + rescaleAttributes(vertex_attributes, vertex_attributes_data, vertex_count, vertex_attributes_stride, attribute_weights, attribute_count, sparse_remap); } Quadric* vertex_quadrics = allocator.allocate(vertex_count); @@ -1547,14 +1600,11 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic memset(attribute_gradients, 0, vertex_count * attribute_count * sizeof(QuadricGrad)); } - fillFaceQuadrics(vertex_quadrics, indices, index_count, vertex_positions, remap); - fillEdgeQuadrics(vertex_quadrics, indices, index_count, vertex_positions, remap, vertex_kind, loop, loopback); + fillFaceQuadrics(vertex_quadrics, result, index_count, vertex_positions, remap); + fillEdgeQuadrics(vertex_quadrics, result, index_count, vertex_positions, remap, vertex_kind, loop, loopback); if (attribute_count) - fillAttributeQuadrics(attribute_quadrics, attribute_gradients, indices, index_count, vertex_positions, vertex_attributes, attribute_count, remap); - - if (result != indices) - memcpy(result, indices, index_count * sizeof(unsigned int)); + fillAttributeQuadrics(attribute_quadrics, attribute_gradients, result, index_count, vertex_positions, vertex_attributes, attribute_count, remap); #if TRACE size_t pass_count = 0; @@ -1630,6 +1680,11 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic memcpy(meshopt_simplifyDebugLoopBack, loopback, vertex_count * sizeof(unsigned int)); #endif + // convert resulting indices back into the dense space of the larger mesh + if (sparse_remap) + for (size_t i = 0; i < result_count; ++i) + result[i] = sparse_remap[result[i]]; + // result_error is quadratic; we need to remap it back to linear if (out_result_error) *out_result_error = sqrtf(result_error); From 69e41190c43dbe5ad7697bdbbf8db5c81b981936 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Thu, 13 Jun 2024 23:13:45 -0700 Subject: [PATCH 03/13] simplify: Deallocate reverse remap when buildSparseRemap is done This reduces the high watermark and may allow reusing the deallocated space for the actual simplifier state. Ideally we would reduce the space consumed here further, but that requires a hash map so may not be ideal. --- src/simplifier.cpp | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index 9d3d5bac..6c016f7b 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -223,27 +223,35 @@ static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, filter[indices[i]] = 1; } - unsigned int* revremap = allocator.allocate(vertex_count); - unsigned int* remap = allocator.allocate(unique); size_t offset = 0; + + // temporary map dense => sparse; we allocate it last so that we can deallocate it + unsigned int* revremap = allocator.allocate(vertex_count); + for (size_t i = 0; i < index_count; ++i) { - if (filter[indices[i]] == 1) + unsigned int index = indices[i]; + + if (filter[index] == 1) { remap[offset] = indices[i]; - filter[indices[i]] = 0; - revremap[indices[i]] = unsigned(offset); + revremap[index] = unsigned(offset); indices[i] = unsigned(offset); + filter[index] = 0; offset++; } else { - indices[i] = revremap[indices[i]]; + indices[i] = revremap[index]; } } + allocator.deallocate(revremap); + + assert(offset == unique); *out_vertex_count = unique; + return remap; } From 0f8a780f9cab1e3b65df0ac4673340bcf1a631c1 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Thu, 13 Jun 2024 23:41:20 -0700 Subject: [PATCH 04/13] simplify: Fix MSVC warning --- src/simplifier.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index 6c016f7b..cb6f3a7d 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -442,7 +442,7 @@ static float rescalePositions(Vector3* result, const float* vertex_positions_dat for (size_t i = 0; i < vertex_count; ++i) { - unsigned int ri = sparse_remap ? sparse_remap[i] : i; + unsigned int ri = sparse_remap ? sparse_remap[i] : unsigned(i); const float* v = vertex_positions_data + ri * vertex_stride_float; if (result) From 99d8bb4749555a0094eaf690ca98f60d4c238c97 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 08:51:21 -0700 Subject: [PATCH 05/13] simplify: Use a bit set for sparse remap computation We have two parts of buildSparseRemap that are still dependent on the number of vertices wrt complexity, filter clearing and revremap allocation. We could replace revremap with a hash map if a large allocation proves to be problematic, but it also might work fine in practice - whereas filter[] clearing is a cost we must pay and for small subsets of large meshes this can be 20% of the entire simplification. To fix this we now use a bit set which is 8x cheaper to clear (the actual addressing gets more expensive but it's a fraction of the cost due to sparsity assumption). --- src/simplifier.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index cb6f3a7d..fcb0296f 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -212,15 +212,18 @@ static void buildPositionRemap(unsigned int* remap, unsigned int* wedge, const f static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, size_t vertex_count, size_t* out_vertex_count, meshopt_Allocator& allocator) { - unsigned char* filter = allocator.allocate(vertex_count); - memset(filter, 0, vertex_count); + // use a bit set to compute the precise number of unique vertices + unsigned char* filter = allocator.allocate((vertex_count + 7) / 8); + memset(filter, 0, (vertex_count + 7) / 8); size_t unique = 0; for (size_t i = 0; i < index_count; ++i) { - assert(indices[i] < vertex_count); - unique += filter[indices[i]] == 0; - filter[indices[i]] = 1; + unsigned int index = indices[i]; + assert(index < vertex_count); + + unique += (filter[index / 8] & (1 << (index % 8))) == 0; + filter[index / 8] |= 1 << (index % 8); } unsigned int* remap = allocator.allocate(unique); @@ -229,16 +232,18 @@ static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, // temporary map dense => sparse; we allocate it last so that we can deallocate it unsigned int* revremap = allocator.allocate(vertex_count); + // fill remap, using revremap as a helper, and rewrite indices in the same pass + // note: we use filter[] here to avoid initializing revremap for performance for (size_t i = 0; i < index_count; ++i) { unsigned int index = indices[i]; - if (filter[index] == 1) + if ((filter[index / 8] & (1 << (index % 8))) != 0) { remap[offset] = indices[i]; revremap[index] = unsigned(offset); indices[i] = unsigned(offset); - filter[index] = 0; + filter[index / 8] &= ~(1 << (index % 8)); offset++; } else From ff26e25c107582f0d133141af2848b4aabb8cc0e Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 08:54:32 -0700 Subject: [PATCH 06/13] simplify: Fix vertex_lock access when sparse simplification is used classifyVertices references the input vertex_lock[] which is indexed using dense vertex indices so it needs to remap the access index on the fly as well. --- src/simplifier.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index fcb0296f..28788307 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -307,7 +307,7 @@ static bool hasEdge(const EdgeAdjacency& adjacency, unsigned int a, unsigned int return false; } -static void classifyVertices(unsigned char* result, unsigned int* loop, unsigned int* loopback, size_t vertex_count, const EdgeAdjacency& adjacency, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_lock, unsigned int options) +static void classifyVertices(unsigned char* result, unsigned int* loop, unsigned int* loopback, size_t vertex_count, const EdgeAdjacency& adjacency, const unsigned int* remap, const unsigned int* wedge, const unsigned char* vertex_lock, const unsigned int* sparse_remap, unsigned int options) { memset(loop, -1, vertex_count * sizeof(unsigned int)); memset(loopback, -1, vertex_count * sizeof(unsigned int)); @@ -353,7 +353,7 @@ static void classifyVertices(unsigned char* result, unsigned int* loop, unsigned { if (remap[i] == i) { - if (vertex_lock && vertex_lock[i]) + if (vertex_lock && vertex_lock[sparse_remap ? sparse_remap[i] : i]) { // vertex is explicitly locked result[i] = Kind_Locked; @@ -1570,7 +1570,7 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic unsigned char* vertex_kind = allocator.allocate(vertex_count); unsigned int* loop = allocator.allocate(vertex_count); unsigned int* loopback = allocator.allocate(vertex_count); - classifyVertices(vertex_kind, loop, loopback, vertex_count, adjacency, remap, wedge, vertex_lock, options); + classifyVertices(vertex_kind, loop, loopback, vertex_count, adjacency, remap, wedge, vertex_lock, sparse_remap, options); #if TRACE size_t unique_positions = 0; From 1e498e2107f870f8779c29f6caf38373a98b204f Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 11:28:50 -0700 Subject: [PATCH 07/13] simplify: Add SimplifyErrorAbsolute flag to treat error as absolute When using sparse simplification, the error is treated as relative to the mesh subset. This is a performance requirement as computing the full mesh extents is too expensive when the subset is small relative to the mesh, but it means that it can be difficult to rely on exact error metrics. There are also cases in general, when not using sparse simplification, when an absolute error is more convenient. These can be achieved right now via meshopt_simplifyScale but that is an extra step that is not always necessary. With this change, when meshopt_SimplifyErrorAbsolute flag is used, we treat the error limit and the output error as an absolute distance (in mesh coordinates), and convert it to/from relative using the internal scale factor. --- src/meshoptimizer.h | 2 ++ src/simplifier.cpp | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/meshoptimizer.h b/src/meshoptimizer.h index a01e6cf4..bb78cf28 100644 --- a/src/meshoptimizer.h +++ b/src/meshoptimizer.h @@ -332,6 +332,8 @@ enum meshopt_SimplifyLockBorder = 1 << 0, /* Improve simplification performance assuming input indices are a sparse subset of the mesh. Note that error becomes relative to subset extents. */ meshopt_SimplifySparse = 1 << 1, + /* Treat error limit and resulting error as absolute instead of relative to mesh extents. */ + meshopt_SimplifyErrorAbsolute = 1 << 2, }; /** diff --git a/src/simplifier.cpp b/src/simplifier.cpp index 28788307..79ad69f9 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -1539,7 +1539,7 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic assert(vertex_positions_stride >= 12 && vertex_positions_stride <= 256); assert(vertex_positions_stride % sizeof(float) == 0); assert(target_index_count <= index_count); - assert((options & ~(meshopt_SimplifyLockBorder | meshopt_SimplifySparse)) == 0); + assert((options & ~(meshopt_SimplifyLockBorder | meshopt_SimplifySparse | meshopt_SimplifyErrorAbsolute)) == 0); assert(vertex_attributes_stride >= attribute_count * sizeof(float) && vertex_attributes_stride <= 256); assert(vertex_attributes_stride % sizeof(float) == 0); assert(attribute_count <= kMaxAttributes); @@ -1588,7 +1588,7 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic #endif Vector3* vertex_positions = allocator.allocate(vertex_count); - rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap); + float vertex_scale = rescalePositions(vertex_positions, vertex_positions_data, vertex_count, vertex_positions_stride, sparse_remap); float* vertex_attributes = NULL; @@ -1634,7 +1634,8 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic float result_error = 0; // target_error input is linear; we need to adjust it to match quadricError units - float error_limit = target_error * target_error; + float error_scale = (options & meshopt_SimplifyErrorAbsolute) ? vertex_scale : 1.f; + float error_limit = (target_error * target_error) / (error_scale * error_scale); while (result_count > target_index_count) { @@ -1700,7 +1701,7 @@ size_t meshopt_simplifyEdge(unsigned int* destination, const unsigned int* indic // result_error is quadratic; we need to remap it back to linear if (out_result_error) - *out_result_error = sqrtf(result_error); + *out_result_error = sqrtf(result_error) * error_scale; return result_count; } From 684238c55b2f0261ae0e7f26d5297daedbf35de4 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 11:45:26 -0700 Subject: [PATCH 08/13] demo: Use meshopt_SimplifyErrorAbsolute in simplifyClusters Previously we couldn't really guarantee a sensible error bound or display the resulting deviation, but meshopt_SimplifyErrorAbsolute together with meshopt_simplifyScale makes it easy, so we incorporate that into simplifyClusters. --- demo/main.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/demo/main.cpp b/demo/main.cpp index 665951aa..e923d2fe 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -658,9 +658,13 @@ void simplifyClusters(const Mesh& mesh, float threshold = 0.2f) double middle = timestamp(); + float scale = meshopt_simplifyScale(&mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex)); + std::vector lod; lod.reserve(mesh.indices.size()); + float error = 0.f; + for (size_t i = 0; i < meshlets.size(); ++i) { const meshopt_Meshlet& m = meshlets[i]; @@ -670,10 +674,14 @@ void simplifyClusters(const Mesh& mesh, float threshold = 0.2f) for (size_t j = 0; j < m.triangle_count * 3; ++j) lod.push_back(meshlet_vertices[m.vertex_offset + meshlet_triangles[m.triangle_offset + j]]); - unsigned int options = meshopt_SimplifyLockBorder | meshopt_SimplifySparse; + unsigned int options = meshopt_SimplifyLockBorder | meshopt_SimplifySparse | meshopt_SimplifyErrorAbsolute; + float cluster_target_error = 1e-2f * scale; size_t cluster_target = size_t(float(m.triangle_count) * threshold) * 3; - size_t cluster_size = meshopt_simplify(&lod[cluster_offset], &lod[cluster_offset], m.triangle_count * 3, &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), cluster_target, threshold, options, 0); + float cluster_error = 0.f; + size_t cluster_size = meshopt_simplify(&lod[cluster_offset], &lod[cluster_offset], m.triangle_count * 3, &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), cluster_target, cluster_target_error, options, &cluster_error); + + error = cluster_error > error ? cluster_error : error; // simplified cluster is available in lod[cluster_offset..cluster_offset + cluster_size] lod.resize(cluster_offset + cluster_size); @@ -681,9 +689,10 @@ void simplifyClusters(const Mesh& mesh, float threshold = 0.2f) double end = timestamp(); - printf("%-9s: %d triangles => %d triangles in %.2f msec, clusterized in %.2f msec\n", + printf("%-9s: %d triangles => %d triangles (%.2f%% deviation) in %.2f msec, clusterized in %.2f msec\n", "SimplifyN", // N for Nanite int(mesh.indices.size() / 3), int(lod.size() / 3), + error / scale * 100, (end - middle) * 1000, (middle - start) * 1000); } @@ -1291,6 +1300,7 @@ void processDev(const char* path) if (!loadMesh(mesh, path)) return; + simplify(mesh); simplifyClusters(mesh); } From db26310289b6475888215998112657ab00a2c336 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 11:55:35 -0700 Subject: [PATCH 09/13] demo: Add a test for meshopt_SimplifyErrorAbsolute We collapse a center vertex which introduces an error and check that the error is close to what we would expect. Note that the distance the vertex travels here is 1.0f, not 0.85f, but the errors are evaluated as distances to triangles which makes it smaller. --- demo/tests.cpp | 53 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/demo/tests.cpp b/demo/tests.cpp index 7c94ba93..53a9e6f2 100644 --- a/demo/tests.cpp +++ b/demo/tests.cpp @@ -1188,15 +1188,15 @@ static void simplifyAttr() static void simplifyLockFlags() { float vb[] = { - 0.000000f, 0.000000f, 0.000000f, - 0.000000f, 1.000000f, 0.000000f, - 0.000000f, 2.000000f, 0.000000f, - 1.000000f, 0.000000f, 0.000000f, - 1.000000f, 1.000000f, 0.000000f, - 1.000000f, 2.000000f, 0.000000f, - 2.000000f, 0.000000f, 0.000000f, - 2.000000f, 1.000000f, 0.000000f, - 2.000000f, 2.000000f, 0.000000f, // clang-format :-/ + 0, 0, 0, + 0, 1, 0, + 0, 2, 0, + 1, 0, 0, + 1, 1, 0, + 1, 2, 0, + 2, 0, 0, + 2, 1, 0, + 2, 2, 0, // clang-format :-/ }; unsigned char lock[9] = { @@ -1233,6 +1233,40 @@ static void simplifyLockFlags() assert(memcmp(ib, expected, sizeof(expected)) == 0); } +static void simplifyErrorAbsolute() +{ + float vb[] = { + 0, 0, 0, + 0, 1, 0, + 0, 2, 0, + 1, 0, 0, + 1, 1, 1, + 1, 2, 0, + 2, 0, 0, + 2, 1, 0, + 2, 2, 0, // clang-format :-/ + }; + + // 0 1 2 + // 3 4 5 + // 6 7 8 + + unsigned int ib[] = { + 0, 1, 3, + 3, 1, 4, + 1, 2, 4, + 4, 2, 5, + 3, 4, 6, + 6, 4, 7, + 4, 5, 7, + 7, 5, 8, // clang-format :-/ + }; + + float error = 0.f; + assert(meshopt_simplify(ib, ib, 24, vb, 9, 12, 18, 2.f, meshopt_SimplifyLockBorder | meshopt_SimplifyErrorAbsolute, &error) == 18); + assert(fabsf(error - 0.85f) < 0.01f); +} + static void adjacency() { // 0 1/4 @@ -1448,6 +1482,7 @@ void runTests() simplifyLockBorder(); simplifyAttr(); simplifyLockFlags(); + simplifyErrorAbsolute(); adjacency(); tessellation(); From df42d10c3a20dfb00af4eafc8b117fcf8a248b50 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 14:08:49 -0700 Subject: [PATCH 10/13] js: Expose new simplification options Note that this change doesn't rebuild the Wasm bundle; the options should just work once that is done. --- js/meshopt_simplifier.js | 2 ++ js/meshopt_simplifier.module.d.ts | 2 +- js/meshopt_simplifier.module.js | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/js/meshopt_simplifier.js b/js/meshopt_simplifier.js index 3daeb380..73590683 100644 --- a/js/meshopt_simplifier.js +++ b/js/meshopt_simplifier.js @@ -155,6 +155,8 @@ var MeshoptSimplifier = (function() { var simplifyOptions = { LockBorder: 1, + Sparse: 2, + ErrorAbsolute: 4, }; return { diff --git a/js/meshopt_simplifier.module.d.ts b/js/meshopt_simplifier.module.d.ts index 7e95ad83..b0131756 100644 --- a/js/meshopt_simplifier.module.d.ts +++ b/js/meshopt_simplifier.module.d.ts @@ -1,6 +1,6 @@ // This file is part of meshoptimizer library and is distributed under the terms of MIT License. // Copyright (C) 2016-2024, by Arseny Kapoulkine (arseny.kapoulkine@gmail.com) -export type Flags = "LockBorder"; +export type Flags = "LockBorder" | "Sparse" | "ErrorAbsolute"; export const MeshoptSimplifier: { supported: boolean; diff --git a/js/meshopt_simplifier.module.js b/js/meshopt_simplifier.module.js index a4ff5f1f..5b540a4a 100644 --- a/js/meshopt_simplifier.module.js +++ b/js/meshopt_simplifier.module.js @@ -154,6 +154,8 @@ var MeshoptSimplifier = (function() { var simplifyOptions = { LockBorder: 1, + Sparse: 2, + ErrorAbsolute: 4, }; return { From 71fd33eb1fe712f444f5d1b08a2111e120faf55c Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 14:26:02 -0700 Subject: [PATCH 11/13] demo: Add a test for sparse simplification The test needs to check that positions, attributes and lock flags are all addressed using proper indexing. We do assume that collapses are going in a specific direction (the input data technically permits two collapse directions for each test), if this becomes a problem due to fp instabilities we can tweak the input data then. --- demo/tests.cpp | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/demo/tests.cpp b/demo/tests.cpp index 53a9e6f2..83e2b25a 100644 --- a/demo/tests.cpp +++ b/demo/tests.cpp @@ -1233,6 +1233,77 @@ static void simplifyLockFlags() assert(memcmp(ib, expected, sizeof(expected)) == 0); } +static void simplifySparse() +{ + float vb[] = { + 0, 0, 100, + 0, 1, 0, + 0, 2, 100, + 1, 0, 0.1f, + 1, 1, 0.1f, + 1, 2, 0.1f, + 2, 0, 100, + 2, 1, 0, + 2, 2, 100, // clang-format :-/ + }; + + float vba[] = { + 100, + 0.5f, + 100, + 0.5f, + 0.5f, + 0, + 100, + 0.5f, + 100, // clang-format :-/ + }; + + float aw[] = { + 0.2f}; + + unsigned char lock[9] = { + 8, 1, 8, + 1, 0, 1, + 8, 1, 8, // clang-format :-/ + }; + + // 1 + // 3 4 5 + // 7 + + unsigned int ib[] = { + 3, 1, 4, + 1, 5, 4, + 3, 4, 7, + 4, 5, 7, // clang-format :-/ + }; + + unsigned int res[12]; + + // vertices 3-4-5 are slightly elevated along Z which guides the collapses when only using geometry + unsigned int expected[] = { + 1, 5, 3, + 3, 5, 7, // clang-format :-/ + }; + + assert(meshopt_simplify(res, ib, 12, vb, 9, 12, 6, 1e-3f, meshopt_SimplifySparse) == 6); + assert(memcmp(res, expected, sizeof(expected)) == 0); + + // vertices 1-4-7 have a crease in the attribute value which guides the collapses the opposite way when weighing attributes sufficiently + unsigned int expecteda[] = { + 3, 1, 7, + 1, 5, 7, // clang-format :-/ + }; + + assert(meshopt_simplifyWithAttributes(res, ib, 12, vb, 9, 12, vba, sizeof(float), aw, 1, lock, 6, 1e-1f, meshopt_SimplifySparse) == 6); + assert(memcmp(res, expecteda, sizeof(expecteda)) == 0); + + // a final test validates that destination can alias when using sparsity + assert(meshopt_simplify(ib, ib, 12, vb, 9, 12, 6, 1e-3f, meshopt_SimplifySparse) == 6); + assert(memcmp(ib, expected, sizeof(expected)) == 0); +} + static void simplifyErrorAbsolute() { float vb[] = { @@ -1482,6 +1553,7 @@ void runTests() simplifyLockBorder(); simplifyAttr(); simplifyLockFlags(); + simplifySparse(); simplifyErrorAbsolute(); adjacency(); From 1046faff0884966f1ceb31c7f4d5ca7bdc66055d Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Fri, 14 Jun 2024 20:17:07 -0700 Subject: [PATCH 12/13] demo: Add simplifyClusters to the default set of pipelines This helps to test sparse simplification on a large variety of meshes. Drive-by: fix simplifyPoints under address sanitizer when the number of points was below the threshold so &indices[0] would be out of bounds. --- demo/main.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/demo/main.cpp b/demo/main.cpp index e923d2fe..f93bf34e 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -522,6 +522,8 @@ void simplifyPoints(const Mesh& mesh, float threshold = 0.2f) double start = timestamp(); size_t target_vertex_count = size_t(mesh.vertices.size() * threshold); + if (target_vertex_count == 0) + return; std::vector indices(target_vertex_count); indices.resize(meshopt_simplifyPoints(&indices[0], &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), NULL, 0, 0, target_vertex_count)); @@ -1286,6 +1288,7 @@ void process(const char* path) simplifySloppy(mesh); simplifyComplete(mesh); simplifyPoints(mesh); + simplifyClusters(mesh); spatialSort(mesh); spatialSortTriangles(mesh); From a2cdd757c91233939fbb7ad0f5d86f611de2616e Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Sat, 15 Jun 2024 09:17:35 -0700 Subject: [PATCH 13/13] simplify: Use a hash map to generate sparse remap Instead of using a large uninitialized allocation, we now use a hash table. Under the assumption that a mesh subset is much smaller, we were relying on the efficiency of repeatedly reallocating a large uninitialized segment of memory, which worked well on some systems (Linux) but not as much on others (Windows). Instead, we now minimize the allocation size. This ends up a little slower when the sparsity assumption does not hold, as we no longer use direct indexing. To minimize the impact, we use a hash function that reduces the amount of avalanche so that sequential indices have fewer hash conflicts; it might also make sense to use an identity function here, or a small multiplicative constant. To minimize the impact further, we don't store pairs in the map, and just store the new index; when doing a lookup, due to the unique construction of the hasher we can lookup the dense index despite the fact that the map only stores sparse indices. --- src/simplifier.cpp | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index 79ad69f9..1f6f8ab1 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -136,6 +136,21 @@ struct PositionHasher } }; +struct RemapHasher +{ + unsigned int* remap; + + size_t hash(unsigned int id) const + { + return id * 0x5bd1e995; + } + + bool equal(unsigned int lhs, unsigned int rhs) const + { + return remap[lhs] == rhs; + } +}; + static size_t hashBuckets2(size_t count) { size_t buckets = 1; @@ -230,26 +245,27 @@ static unsigned int* buildSparseRemap(unsigned int* indices, size_t index_count, size_t offset = 0; // temporary map dense => sparse; we allocate it last so that we can deallocate it - unsigned int* revremap = allocator.allocate(vertex_count); + size_t revremap_size = hashBuckets2(unique); + unsigned int* revremap = allocator.allocate(revremap_size); + memset(revremap, -1, revremap_size * sizeof(unsigned int)); // fill remap, using revremap as a helper, and rewrite indices in the same pass - // note: we use filter[] here to avoid initializing revremap for performance + RemapHasher hasher = {remap}; + for (size_t i = 0; i < index_count; ++i) { unsigned int index = indices[i]; - if ((filter[index / 8] & (1 << (index % 8))) != 0) + unsigned int* entry = hashLookup2(revremap, revremap_size, hasher, index, ~0u); + + if (*entry == ~0u) { - remap[offset] = indices[i]; - revremap[index] = unsigned(offset); - indices[i] = unsigned(offset); - filter[index / 8] &= ~(1 << (index % 8)); + remap[offset] = index; + *entry = unsigned(offset); offset++; } - else - { - indices[i] = revremap[index]; - } + + indices[i] = *entry; } allocator.deallocate(revremap);