feat: enhance QEM mesh decimation with performance improvements and numeric key optimizations

This commit is contained in:
CNCKitchen
2026-03-17 22:11:41 +01:00
parent 2eb52a0bf2
commit 08eb15a2ab
+193 -96
View File
@@ -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(01)
@@ -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;
}
}