From fa0f478e7f0913515c6fa244d1d4d0ac42a4eb9b Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Sat, 29 Nov 2025 08:39:25 -0800 Subject: [PATCH 1/4] demo: Update edge visualization to distinguish complex edges on loops We switch the current (cyan) color to only show complex edges with loop metadata, similarly to other collapse kinds. For complex-complex edges that aren't tagged as loops, we instead use a darker magenta. Both can be collapsed but the non-loop edges usually have a higher error and will use a slightly different logic in the next commit. --- demo/simplify.html | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/demo/simplify.html b/demo/simplify.html index b2fcdb8c0..faea97a93 100644 --- a/demo/simplify.html +++ b/demo/simplify.html @@ -610,7 +610,7 @@ var mask = (1 << 28) - 1; - for (var kind = 1; kind <= 5; ++kind) { + for (var kind = 1; kind <= 6; ++kind) { var offset = dind.length; for (var i = 0; i < dlen; i += 3) { @@ -620,8 +620,8 @@ var ak = (a >> 28) & 7, bk = (b >> 28) & 7; - if ((a >> 31 != 0 || kind == 3) && (ak == kind || bk == kind) && (ak == kind || ak == 4) && (bk == kind || bk == 4)) { - // edge of current kind (allow one of the vertices to be locked) + if (a >> 31 != 0 && (ak == kind || bk == kind) && (ak == kind || ak == 4) && (bk == kind || bk == 4)) { + // loop edge of current kind (allow one of the vertices to be locked) // note: complex edges ignore loop metadata dind.push(a & mask); dind.push(a & mask); @@ -631,8 +631,13 @@ dind.push(a & mask); dind.push(a & mask); dind.push(b & mask); - } else if (kind == 4 && ak == kind && bk == kind) { - // locked edge (not be marked as a loop) + } else if (a >> 31 == 0 && kind == 4 && ak == kind && bk == kind) { + // locked edge (not marked as a loop) + dind.push(a & mask); + dind.push(a & mask); + dind.push(b & mask); + } else if (a >> 31 == 0 && kind == 6 && ak == 3 && bk == 3) { + // complex edge (not marked as a loop) dind.push(a & mask); dind.push(a & mask); dind.push(b & mask); @@ -793,6 +798,7 @@ new THREE.MeshBasicMaterial({ color: 0x009f9f, wireframe: true }), // complex new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true }), // locked new THREE.MeshBasicMaterial({ color: 0xff9f00, wireframe: true }), // mixed edge + new THREE.MeshBasicMaterial({ color: 0x9f5f9f, wireframe: true }), // complex, no loop ]; } From eaaf861849951c7625aac597800a1a97ae52eaf9 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Sat, 29 Nov 2025 08:47:40 -0800 Subject: [PATCH 2/4] simplifier: Use parallel collapses for complex vertices based on loops Collapsing all wedges into a single wedge for complex-complex collapses is simple but results in high-error collapses alongside attribute discontinuities. This is a problem because it either results in poor attribute quality, or in poor geometric quality elsewhere as the simplifier is forced to look for lower error collapses. This change instead adjusts complex collapses to use parallel collapses if possible: wedges are collapsed individually towards different wedges along the original edges. This makes complex collapses work similarly to seam collapses if possible. To implement this without extra performance issues, we use loop metadata. Because loops are really tracking a single incoming/outgoing half-edge in the original (unwelded) topology, a typical attribute discontinuity will have X wedges (seams only have 2), and each of the wedges will have their own loop/loopback that correctly trace the adjoining triangle boundary. Thus we can use these to guide the collapses. In some complex topological situations this may not always find the correct wedge target, but fundamentally complex-complex collapses may *not* have a correct wedge target in all cases (if a discontinuity has 3 wedges then any collapse will have to collapse one of them onto a wedge that was not connected to the original). In the future we could use error or position patching to resolve these cases but that requires invasive changes with potentially problematic consequences, whereas using loop metadata should be cheap and safe. This has no effect on performance on most meshes (and no effect unless permissive mode is used); on meshes where almost every vertex is complex, this costs under 10% performance but results in much better quality. In general, after this "soft" protection like normal creases is mostly unnecessary because the simplifier will do the right thing while it's possible. Protecting UV edges may still be important to avoid UV distortion, although even without UV protection the quality can remain quite high if the UVs are weighted properly. --- src/simplifier.cpp | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/simplifier.cpp b/src/simplifier.cpp index efbdf91a6..14d4d42fe 100644 --- a/src/simplifier.cpp +++ b/src/simplifier.cpp @@ -367,8 +367,8 @@ static void classifyVertices(unsigned char* result, unsigned int* loop, unsigned memset(loopback, -1, vertex_count * sizeof(unsigned int)); // incoming & outgoing open edges: ~0u if no open edges, i if there are more than 1 - // note that this is the same data as required in loop[] arrays; loop[] data is only valid for border/seam - // but here it's okay to fill the data out for other types of vertices as well + // note that this is the same data as required in loop[] arrays; loop[] data is only used for border/seam by default + // in permissive mode we also use it to guide complex-complex collapses, so we fill it for all vertices unsigned int* openinc = loopback; unsigned int* openout = loop; @@ -1271,6 +1271,20 @@ static float getNeighborhoodRadius(const EdgeAdjacency& adjacency, const Vector3 return sqrtf(result); } +static unsigned int getComplexTarget(unsigned int v, unsigned int target, const unsigned int* remap, const unsigned int* loop, const unsigned int* loopback) +{ + unsigned int r = remap[target]; + + // use loop metadata to guide complex collapses towards the correct wedge + // this works for edges on attribute discontinuities because loop/loopback track the single half-edge without a pair, similar to seams + if (loop[v] != ~0u && remap[loop[v]] == r) + return loop[v]; + else if (loopback[v] != ~0u && remap[loopback[v]] == r) + return loopback[v]; + else + return target; +} + static size_t boundEdgeCollapses(const EdgeAdjacency& adjacency, size_t vertex_count, size_t index_count, unsigned char* vertex_kind) { size_t dual_count = 0; @@ -1393,15 +1407,22 @@ static void rankEdgeCollapses(Collapse* collapses, size_t collapse_count, const } else { - // complex edges can have multiple wedges, so we need to aggregate errors for all wedges - // this is different from seams (where we aggregate pairwise) because all wedges collapse onto the same target + // complex edges can have multiple wedges, so we need to aggregate errors for all wedges based on the selected target if (vertex_kind[i0] == Kind_Complex) for (unsigned int v = wedge[i0]; v != i0; v = wedge[v]) - ei += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[i1], &vertex_attributes[i1 * attribute_count]); + { + unsigned int t = getComplexTarget(v, i1, remap, loop, loopback); + + ei += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[t], &vertex_attributes[t * attribute_count]); + } if (vertex_kind[i1] == Kind_Complex && bidi) for (unsigned int v = wedge[i1]; v != i1; v = wedge[v]) - ej += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[i0], &vertex_attributes[i0 * attribute_count]); + { + unsigned int t = getComplexTarget(v, i0, remap, loop, loopback); + + ej += quadricError(attribute_quadrics[v], &attribute_gradients[v * attribute_count], attribute_count, vertex_positions[t], &vertex_attributes[t * attribute_count]); + } } } @@ -1553,7 +1574,9 @@ static size_t performEdgeCollapses(unsigned int* collapse_remap, unsigned char* do { - collapse_remap[v] = i1; + unsigned int t = getComplexTarget(v, i1, remap, loop, loopback); + + collapse_remap[v] = t; v = wedge[v]; } while (v != i0); } From 4fb35646bb791a17ae18752c3a1e7850d97336f8 Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Sat, 29 Nov 2025 08:48:30 -0800 Subject: [PATCH 3/4] demo: Disable normal crease protection by default In permissive mode we no longer need this as the simplifier will correctly trace the hard edges by itself. We still keep the option for testing just in case, but it's no longer preferred and isn't the default. --- demo/simplify.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/simplify.html b/demo/simplify.html index faea97a93..192eef4f9 100644 --- a/demo/simplify.html +++ b/demo/simplify.html @@ -155,7 +155,7 @@ debugOverlay: false, sloppy: false, permissive: false, - permissiveProtection: 3, + permissiveProtection: 2, // uvs lockBorder: false, preprune: 0.0, prune: true, @@ -207,7 +207,7 @@ guiSimplify.add(settings, 'ratio', 0, 1, 0.01).onChange(simplify); guiSimplify.add(settings, 'sloppy').onChange(simplify); guiSimplify.add(settings, 'permissive').onChange(simplify); - guiSimplify.add(settings, 'permissiveProtection', { none: 0, normals: 1, uvs: 2, all: 3 }).onChange(simplify); + guiSimplify.add(settings, 'permissiveProtection', { none: 0, uvs: 2, normals: 1, all: 3 }).onChange(simplify); guiSimplify.add(settings, 'lockBorder').onChange(simplify); guiSimplify.add(settings, 'prune').onChange(simplify); guiSimplify.add(settings, 'regularize').onChange(simplify); From 9d7330501bbe790e23a89a216e1faad7c21ff47e Mon Sep 17 00:00:00 2001 From: Arseny Kapoulkine Date: Sat, 29 Nov 2025 08:49:09 -0800 Subject: [PATCH 4/4] Update README.md Remove normal crease protection from permissive mode example. With parallel collapses, this should be mostly unnecessary so we should no longer recommend it in the documentation. --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a08aa4d8..afecb604f 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ lod.resize(meshopt_simplifyWithAttributes(&lod[0], indices, index_count, &vertic target_index_count, target_error, /* options= */ meshopt_SimplifyPermissive, &lod_error)); ``` -To maintain appearance, it's highly recommended to use this option together with attribute-aware simplification, as shown above, as it allows the simplifier to maintain attribute appearance. In this mode, it is often desirable to selectively preserve certain attribute seams, such as UV seams or sharp creases. This can be achieved by using the `vertex_lock` array with flag `meshopt_SimplifyVertex_Protect` set for individual vertices to protect specific discontinuities. To fill this array, use `meshopt_generatePositionRemap` to create a mapping table for vertices with identical positions, and then compare each vertex to the remapped vertex to determine which attributes are different: +To maintain appearance, it's highly recommended to use this option together with attribute-aware simplification, as shown above, as it allows the simplifier to maintain attribute quality. In this mode, it is often desirable to selectively preserve certain attribute seams, such as UV seams or sharp creases. This can be achieved by using the `vertex_lock` array with flag `meshopt_SimplifyVertex_Protect` set for individual vertices to protect specific discontinuities. To fill this array, use `meshopt_generatePositionRemap` to create a mapping table for vertices with identical positions, and then compare each vertex to the remapped vertex to determine which attributes are different: ```c++ std::vector remap(vertices.size()); @@ -515,9 +515,6 @@ for (size_t i = 0; i < vertices.size(); ++i) { if (r != i && (vertices[r].tx != vertices[i].tx || vertices[r].ty != vertices[i].ty)) locks[i] |= meshopt_SimplifyVertex_Protect; // protect UV seams - - if (r != i && (vertices[r].nx * vertices[i].nx + vertices[r].ny * vertices[i].ny + vertices[r].nz * vertices[i].nz < 0.25f)) - locks[i] |= meshopt_SimplifyVertex_Protect; // protect sharp normal creases } ```