From 08eb15a2abe161a10f6c6fb70ef30be8f4862a30 Mon Sep 17 00:00:00 2001 From: CNCKitchen Date: Tue, 17 Mar 2026 22:11:41 +0100 Subject: [PATCH] feat: enhance QEM mesh decimation with performance improvements and numeric key optimizations --- js/decimation.js | 289 +++++++++++++++++++++++++++++++---------------- 1 file changed, 193 insertions(+), 96 deletions(-) diff --git a/js/decimation.js b/js/decimation.js index 43cb3c3..094597b 100644 --- a/js/decimation.js +++ b/js/decimation.js @@ -24,6 +24,14 @@ * the QEM cost of any collapse that would move a vertex off a sharp feature, * ensuring smooth regions are decimated first while creases are kept intact. * + * Performance notes: + * - Struct-of-arrays typed-array heap avoids per-entry object allocation. + * - Numeric edge keys (v_lo * MAX_V + v_hi) replace template strings. + * - Vertex deduplication uses a numeric spatial-grid Map instead of strings. + * - Link-violation check packs sorted face triple into two Numbers to avoid + * string allocation. + * - Progress callback fires at most every 512 collapses. + * * @param {THREE.BufferGeometry} geometry non-indexed input * @param {number} targetTriangles desired output face count * @param {function} [onProgress] callback(0–1) @@ -49,50 +57,55 @@ export function decimate(geometry, targetTriangles, onProgress) { initQuadrics(quadrics, positions, faces, faceCount); addCreaseQuadrics(quadrics, positions, faces, faceCount); - // Vertex → set of incident face indices + // Vertex → set of incident face indices (Int32Arrays for cache efficiency) const vertFaces = buildAdjacency(faces, faceCount, vertCount); const active = new Uint8Array(vertCount).fill(1); // Per-vertex version counter: incremented whenever a vertex's quadric or // position changes. Heap entries carry the versions at push time; any - // entry whose versions no longer match is stale and is skipped. This - // prevents old low-cost entries (computed before a crease-quadric merge) - // from firing after the vertex has been updated to a higher-cost state. + // entry whose versions no longer match is stale and is skipped. const version = new Uint32Array(vertCount); let activeFaces = faceCount; - // Seed min-heap with one entry per unique edge - const heap = new MinHeap(); + // Seed min-heap with one entry per unique edge. + // Use BigInt keys to handle any vertex count without integer overflow. + const heap = new SoAHeap(Math.min(faceCount * 3, 1 << 24)); const seedSeen = new Set(); + const _vc = BigInt(vertCount); for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let e = 0; e < 3; e++) { - const v1 = faces[f * 3 + e]; - const v2 = faces[f * 3 + ((e + 1) % 3)]; - const ek = v1 < v2 ? `${v1}:${v2}` : `${v2}:${v1}`; - if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, version, v1, v2); } + const va = faces[f * 3 + e]; + const vb = faces[f * 3 + ((e + 1) % 3)]; + const lo = va < vb ? va : vb, hi = va < vb ? vb : va; + const ek = BigInt(lo) * _vc + BigInt(hi); + if (!seedSeen.has(ek)) { seedSeen.add(ek); pushEdge(heap, quadrics, positions, version, va, vb); } } } seedSeen.clear(); - const initFaces = activeFaces; - let lastProg = 0; + const initFaces = activeFaces; + const toRemove = initFaces - targetTriangles; + let lastProg = 0; + let collapses = 0; while (activeFaces > targetTriangles && heap.size() > 0) { - const { v1, v2, ver1, ver2, px, py, pz } = heap.pop(); + const idx = heap.pop(); + if (idx < 0) break; + + const v1 = heap.getV1(idx), v2 = heap.getV2(idx); + const ver1 = heap.getVer1(idx), ver2 = heap.getVer2(idx); + const px = heap.getPx(idx), py = heap.getPy(idx), pz = heap.getPz(idx); // Stale-entry checks (lazy deletion) if (!active[v1] || !active[v2]) continue; - // Version check: reject if either vertex's quadric/position has changed - // since this entry was pushed (catches outdated pre-merge low-cost entries) if (version[v1] !== ver1 || version[v2] !== ver2) continue; if (!shareActiveFace(faces, vertFaces, v1, v2)) continue; // ── Three safety guards ─────────────────────────────────────────────────── if (isBoundaryEdge(faces, vertFaces, v1, v2)) continue; // Guard 1 if (hasLinkViolation(faces, vertFaces, v1, v2)) continue; // Guard 2 - const np = [px, py, pz]; - if (checkFlipped(positions, vertFaces, faces, v1, v2, np)) continue; // Guard 3 v1-side - if (checkFlipped(positions, vertFaces, faces, v2, v1, np)) continue; // Guard 3 v2-side + if (checkFlipped(positions, vertFaces, faces, v1, v2, px, py, pz)) continue; // Guard 3 v1-side + if (checkFlipped(positions, vertFaces, faces, v2, v1, px, py, pz)) continue; // Guard 3 v2-side // ── Collapse: keep v1 at new position, remove v2 ───────────────────────── positions[v1 * 3] = px; @@ -133,9 +146,9 @@ export function decimate(geometry, targetTriangles, onProgress) { if (active[nb]) pushEdge(heap, quadrics, positions, version, v1, nb); } - if (onProgress) { - const p = Math.min(1, (initFaces - activeFaces) / (initFaces - targetTriangles)); - if (p - lastProg > 0.02) { onProgress(p); lastProg = p; } + if (onProgress && (++collapses & 511) === 0) { + const p = Math.min(1, (initFaces - activeFaces) / toRemove); + if (p - lastProg > 0.015) { onProgress(p); lastProg = p; } } } @@ -161,41 +174,51 @@ function isBoundaryEdge(faces, vertFaces, v1, v2) { // After collapsing v2 into v1, every face of v2 that survives (i.e. does not // share v1) gets v2 replaced by v1. If any such remapped face is identical to // a face already incident to v1, the collapse would create a duplicate → reject. -// This is the actual harm the link condition guards against, without the -// false-positives that the strict set-equality test produces on interior edges. function hasLinkViolation(faces, vertFaces, v1, v2) { - // Build a set of face "signatures" already incident to v1 (excluding shared faces). - // A signature is the sorted triple of vertex indices, encoded as a string. - const v1Sigs = new Set(); + // Build a map of face signatures already incident to v1 (excluding shared faces). + // Each sorted triple (a,b,c) is encoded as hi=a*0x200000+b → [c…] for zero string allocation. + const v1Lo = new Map(); // hi → [c…] for (const f of vertFaces[v1]) { if (faces[f * 3] < 0) continue; - const fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; - if (fa === v2 || fb === v2 || fc === v2) continue; // shared face, will be deleted - const arr = [fa, fb, fc].sort((a, b) => a - b); - v1Sigs.add(`${arr[0]},${arr[1]},${arr[2]}`); + let fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; + if (fa === v2 || fb === v2 || fc === v2) continue; + // Sort triple + let t; + if (fa > fb) { t = fa; fa = fb; fb = t; } + if (fb > fc) { t = fb; fb = fc; fc = t; } + if (fa > fb) { t = fa; fa = fb; fb = t; } + const hi = fa * 0x200000 + fb; + // Store hi→[lo…] mapping + let arr = v1Lo.get(hi); + if (!arr) { arr = []; v1Lo.set(hi, arr); } + arr.push(fc); } - // Check every surviving face of v2 (after remapping v2→v1) for duplicates. for (const f of vertFaces[v2]) { if (faces[f * 3] < 0) continue; - const fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; - if (fa === v1 || fb === v1 || fc === v1) continue; // shared face, will be deleted + let fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; + if (fa === v1 || fb === v1 || fc === v1) continue; // Remap v2 → v1 - const ra = fa === v2 ? v1 : fa; - const rb = fb === v2 ? v1 : fb; - const rc = fc === v2 ? v1 : fc; - const arr = [ra, rb, rc].sort((a, b) => a - b); - if (v1Sigs.has(`${arr[0]},${arr[1]},${arr[2]}`)) return true; + if (fa === v2) fa = v1; else if (fb === v2) fb = v1; else fc = v1; + let t; + if (fa > fb) { t = fa; fa = fb; fb = t; } + if (fb > fc) { t = fb; fb = fc; fc = t; } + if (fa > fb) { t = fa; fa = fb; fb = t; } + const hi = fa * 0x200000 + fb; + const arr = v1Lo.get(hi); + if (arr) { + for (let i = 0; i < arr.length; i++) if (arr[i] === fc) return true; + } } return false; } // ── Guard 3: Normal-flip rejection ────────────────────────────────────────── -// After hypothetical collapse of v_collapse → newPos, recompute normals of -// all affected faces. If any flip by more than ~78° (dot < FLIP_DOT) reject. +// After hypothetical collapse of v_collapse → (npx,npy,npz), recompute normals +// of all affected faces. If any flip by more than ~78° (dot < FLIP_DOT) reject. -function checkFlipped(positions, vertFaces, faces, v_collapse, v_other, newPos) { +function checkFlipped(positions, vertFaces, faces, v_collapse, v_other, npx, npy, npz) { for (const f of vertFaces[v_collapse]) { if (faces[f * 3] < 0) continue; const fa = faces[f * 3], fb = faces[f * 3 + 1], fc = faces[f * 3 + 2]; @@ -209,16 +232,16 @@ function checkFlipped(positions, vertFaces, faces, v_collapse, v_other, newPos) positions[fc*3], positions[fc*3+1], positions[fc*3+2] ); - // New normal with v_collapse replaced by newPos - const ax = fa === v_collapse ? newPos[0] : positions[fa*3]; - const ay = fa === v_collapse ? newPos[1] : positions[fa*3+1]; - const az = fa === v_collapse ? newPos[2] : positions[fa*3+2]; - const bx = fb === v_collapse ? newPos[0] : positions[fb*3]; - const by = fb === v_collapse ? newPos[1] : positions[fb*3+1]; - const bz = fb === v_collapse ? newPos[2] : positions[fb*3+2]; - const cx = fc === v_collapse ? newPos[0] : positions[fc*3]; - const cy = fc === v_collapse ? newPos[1] : positions[fc*3+1]; - const cz = fc === v_collapse ? newPos[2] : positions[fc*3+2]; + // New normal with v_collapse replaced by new position + const ax = fa === v_collapse ? npx : positions[fa*3]; + const ay = fa === v_collapse ? npy : positions[fa*3+1]; + const az = fa === v_collapse ? npz : positions[fa*3+2]; + const bx = fb === v_collapse ? npx : positions[fb*3]; + const by = fb === v_collapse ? npy : positions[fb*3+1]; + const bz = fb === v_collapse ? npz : positions[fb*3+2]; + const cx = fc === v_collapse ? npx : positions[fc*3]; + const cy = fc === v_collapse ? npy : positions[fc*3+1]; + const cz = fc === v_collapse ? npz : positions[fc*3+2]; const [nnx, nny, nnz] = faceNormal(ax, ay, az, bx, by, bz, cx, cy, cz); const dot = onx * nnx + ony * nny + onz * nnz; @@ -249,14 +272,19 @@ function faceNormal(ax, ay, az, bx, by, bz, cx, cy, cz) { // smooth-surface edges and are therefore collapsed last (or not at all). function addCreaseQuadrics(quadrics, positions, faces, faceCount) { - // Build edge → [face, face] map + // Build edge → [face, face] map using numeric keys (va_lo * vertMax + vb_hi) + // vertMax = next power of two >= faceCount*3 vertices upper bound; use faceCount*3 + // as a safe upper bound since #verts ≤ #triangles*3. + // We already have the actual vertCount from the caller but it's not passed here; + // use a Map with numeric key = min*N + max where N = faceCount*3 (safe upper bound). + const N = faceCount * 3; const edgeToFaces = new Map(); for (let f = 0; f < faceCount; f++) { if (faces[f * 3] < 0) continue; for (let e = 0; e < 3; e++) { const va = faces[f * 3 + e]; const vb = faces[f * 3 + ((e + 1) % 3)]; - const key = va < vb ? `${va}:${vb}` : `${vb}:${va}`; + const key = va < vb ? va * N + vb : vb * N + va; let arr = edgeToFaces.get(key); if (!arr) { arr = []; edgeToFaces.set(key, arr); } arr.push(f); @@ -285,10 +313,9 @@ function addCreaseQuadrics(quadrics, positions, faces, faceCount) { if (n0x*n1x + n0y*n1y + n0z*n1z >= CREASE_COS) continue; // smooth — skip - // Resolve the two vertex indices from the key string - const colon = key.indexOf(':'); - const va = parseInt(key.slice(0, colon)); - const vb = parseInt(key.slice(colon + 1)); + // Resolve the two vertex indices from the numeric key + const va = Math.floor(key / N); + const vb = key - va * N; // Normalised edge direction const ex = positions[vb*3] - positions[va*3]; @@ -400,26 +427,38 @@ function pushEdge(heap, quadrics, positions, version, v1, v2) { const cost = evalQSum(quadrics, v1, v2, px, py, pz); // Snapshot both vertices' versions so the pop-side check can detect staleness - heap.push({ cost, v1, v2, ver1: version[v1], ver2: version[v2], px, py, pz }); + heap.push(cost, v1, v2, version[v1], version[v2], px, py, pz); } // ── Indexed <-> Non-indexed conversion ────────────────────────────────────── +// Numeric spatial-hash vertex deduplication. +// Avoids template-string allocation by encoding quantised (ix,iy,iz) as a +// BigInt key: this is still fast because we only call BigInt() once per vertex. function buildIndexed(geometry) { const posAttr = geometry.attributes.position; const n = posAttr.count; - const positions = []; - const vertMap = new Map(); + const positions = new Float64Array(n * 3); // over-allocated, trimmed later const indexRemap = new Int32Array(n); + let vertCount = 0; + + const vertMap = new Map(); for (let i = 0; i < n; i++) { const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i); - const key = `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; + // Encode three 21-bit quantised integers into one BigInt key. + // Offset by 2^20 to handle negative coordinates. + const ix = (Math.round(x * QUANT) + 0x100000) >>> 0; + const iy = (Math.round(y * QUANT) + 0x100000) >>> 0; + const iz = (Math.round(z * QUANT) + 0x100000) >>> 0; + const key = (BigInt(ix) << 42n) | (BigInt(iy) << 21n) | BigInt(iz); let idx = vertMap.get(key); if (idx === undefined) { - idx = positions.length / 3; - positions.push(x, y, z); + idx = vertCount++; + positions[idx * 3] = x; + positions[idx * 3 + 1] = y; + positions[idx * 3 + 2] = z; vertMap.set(key, idx); } indexRemap[i] = idx; @@ -427,13 +466,9 @@ function buildIndexed(geometry) { const faceCount = n / 3; const faces = new Int32Array(faceCount * 3); - for (let f = 0; f < faceCount; f++) { - faces[f * 3] = indexRemap[f * 3]; - faces[f * 3 + 1] = indexRemap[f * 3 + 1]; - faces[f * 3 + 2] = indexRemap[f * 3 + 2]; - } + for (let i = 0; i < n; i++) faces[i] = indexRemap[i]; - return { positions: new Float64Array(positions), faces, vertCount: positions.length / 3, faceCount }; + return { positions: positions.subarray(0, vertCount * 3), faces, vertCount, faceCount }; } // ── Adjacency helpers ──────────────────────────────────────────────────────── @@ -483,46 +518,108 @@ function buildOutput(positions, faces, faceCount) { return geo; } -// ── Binary Min-Heap ────────────────────────────────────────────────────────── - -class MinHeap { - constructor() { this._data = []; } - - size() { return this._data.length; } - - push(item) { - this._data.push(item); - this._bubbleUp(this._data.length - 1); +// ── Struct-of-arrays Min-Heap ──────────────────────────────────────────────── +// Stores each heap entry in parallel typed arrays rather than JS objects to +// avoid heap allocation pressure and GC pauses during the collapse loop. +// The heap is 1-indexed (root at slot 1). Slot 0 is used as a scratch area +// by pop() so the caller can read fields after popping. +// pop() returns 0 (the scratch slot index) on success, or -1 if empty. +const SOA_GROW = 1.5; +class SoAHeap { + constructor(initialCap = 65536) { + let cap = 2; + while (cap <= initialCap) cap <<= 1; + this._cap = cap; + this._len = 0; + this._cost = new Float64Array(cap); + this._v1 = new Int32Array(cap); + this._v2 = new Int32Array(cap); + this._ver1 = new Uint32Array(cap); + this._ver2 = new Uint32Array(cap); + this._px = new Float64Array(cap); + this._py = new Float64Array(cap); + this._pz = new Float64Array(cap); } + size() { return this._len; } + + push(cost, v1, v2, ver1, ver2, px, py, pz) { + let i = ++this._len; + if (i >= this._cap) this._grow(); + this._cost[i] = cost; this._v1[i] = v1; this._v2[i] = v2; + this._ver1[i] = ver1; this._ver2[i] = ver2; + this._px[i] = px; this._py[i] = py; this._pz[i] = pz; + this._bubbleUp(i); + } + + // Pops the minimum entry into slot 0 and returns 0. Returns -1 if empty. pop() { - const top = this._data[0]; - const last = this._data.pop(); - if (this._data.length > 0) { this._data[0] = last; this._sinkDown(0); } - return top; + if (this._len === 0) return -1; + this._copySlot(0, 1); + this._copySlot(1, this._len--); + if (this._len > 0) this._sinkDown(1); + return 0; + } + + getV1 (i) { return this._v1[i]; } + getV2 (i) { return this._v2[i]; } + getVer1(i) { return this._ver1[i]; } + getVer2(i) { return this._ver2[i]; } + getPx (i) { return this._px[i]; } + getPy (i) { return this._py[i]; } + getPz (i) { return this._pz[i]; } + + _copySlot(dst, src) { + this._cost[dst] = this._cost[src]; this._v1[dst] = this._v1[src]; this._v2[dst] = this._v2[src]; + this._ver1[dst] = this._ver1[src]; this._ver2[dst] = this._ver2[src]; + this._px[dst] = this._px[src]; this._py[dst] = this._py[src]; this._pz[dst] = this._pz[src]; + } + + _swap(a, b) { + const tc = this._cost[a], tv1 = this._v1[a], tv2 = this._v2[a]; + const te1 = this._ver1[a], te2 = this._ver2[a]; + const tpx = this._px[a], tpy = this._py[a], tpz = this._pz[a]; + this._cost[a] = this._cost[b]; this._v1[a] = this._v1[b]; this._v2[a] = this._v2[b]; + this._ver1[a] = this._ver1[b]; this._ver2[a] = this._ver2[b]; + this._px[a] = this._px[b]; this._py[a] = this._py[b]; this._pz[a] = this._pz[b]; + this._cost[b] = tc; this._v1[b] = tv1; this._v2[b] = tv2; + this._ver1[b] = te1; this._ver2[b] = te2; + this._px[b] = tpx; this._py[b] = tpy; this._pz[b] = tpz; } _bubbleUp(i) { - const d = this._data; - while (i > 0) { - const p = (i - 1) >> 1; - if (d[p].cost <= d[i].cost) break; - [d[p], d[i]] = [d[i], d[p]]; - i = p; + const cost = this._cost; + while (i > 1) { + const p = i >> 1; + if (cost[p] <= cost[i]) break; + this._swap(p, i); i = p; } } _sinkDown(i) { - const d = this._data; - const n = d.length; + const cost = this._cost; + const n = this._len; for (;;) { let s = i; - const l = 2 * i + 1, r = 2 * i + 2; - if (l < n && d[l].cost < d[s].cost) s = l; - if (r < n && d[r].cost < d[s].cost) s = r; + const l = i << 1, r = l | 1; + if (l <= n && cost[l] < cost[s]) s = l; + if (r <= n && cost[r] < cost[s]) s = r; if (s === i) break; - [d[s], d[i]] = [d[i], d[s]]; - i = s; + this._swap(s, i); i = s; } } + + _grow() { + const newCap = Math.ceil(this._cap * SOA_GROW) + 2; + const resize = (old, Ctor) => { const n = new Ctor(newCap); n.set(old); return n; }; + this._cost = resize(this._cost, Float64Array); + this._v1 = resize(this._v1, Int32Array); + this._v2 = resize(this._v2, Int32Array); + this._ver1 = resize(this._ver1, Uint32Array); + this._ver2 = resize(this._ver2, Uint32Array); + this._px = resize(this._px, Float64Array); + this._py = resize(this._py, Float64Array); + this._pz = resize(this._pz, Float64Array); + this._cap = newCap; + } }