perf: replace string-key maps with numeric flat arrays in hot paths

Displacement, subdivision and exclusion all used template-string keys
for vertex dedup and edge lookup maps. Replaced with a single numeric
dedup pass + flat typed arrays (Float64Array / Uint8Array), cutting
displacement time by ~2.5x and subdivision by ~2.4x on a 68k tri STL.

- displacement.js: vertex dedup → flat arrays for smooth normals,
  zone areas, masked fractions, displacement cache, excluded set
- subdivision.js: numeric edge keys (a*maxV+b) instead of template strings
- exclusion.js: numeric edge keys, BFS queue.shift() → index pointer,
  adjacency Map → Array, TypedArray.set() for overlay copy
- mapping.js: Math.pow(x,4) → x²·x², cos/sin cached per computeUV call,
  applyTransform signature changed to accept precomputed cos/sin
This commit is contained in:
Avatarsia
2026-04-06 02:38:13 +02:00
parent d99c97fb24
commit 51873fd5fc
4 changed files with 142 additions and 135 deletions
+76 -77
View File
@@ -45,8 +45,6 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
const QUANT = 1e4;
const posKey = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
// ── WHY GAPS HAPPEN ───────────────────────────────────────────────────────
// The mesh is non-indexed (unrolled): every triangle has its own copy of
@@ -64,33 +62,47 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// underlying geometry is still faceted (the subdivision didn't change it),
// so printed edges remain sharp.
// ── Vertex dedup pass: position → numeric ID via one-time string-map pass ─
const _dedupMap = new Map();
let _nextId = 0;
const vertexId = new Uint32Array(count);
for (let i = 0; i < count; 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)}`;
let id = _dedupMap.get(key);
if (id === undefined) { id = _nextId++; _dedupMap.set(key, id); }
vertexId[i] = id;
}
const uniqueCount = _nextId;
// ── Pass 1: accumulate area-weighted smooth normals per unique position ───
// Map: posKey → [nx, ny, nz] (unnormalised sum)
const smoothNrmMap = new Map();
// zoneAreaMap: posKey → [xArea, yArea, zArea] (cubic mapping only)
// Tracks the total adjacent face area in each cubic projection zone (X/Y/Z dominant).
// Seam-edge vertices that border two zones get a blend proportional to face area,
// eliminating the mixed-projection artefact on seam-crossing triangles.
const zoneAreaMap = new Map();
// maskedFracMap: posKey → [maskedArea, totalArea]
// Tracks the fraction of surrounding face area that is masked so boundary
// vertices get a smooth displacement blend instead of a hard on/off cutoff.
const maskedFracMap = new Map();
// Flat arrays indexed by vertex dedup ID (replaces Map<string, ...>)
const smoothNrmX = new Float64Array(uniqueCount);
const smoothNrmY = new Float64Array(uniqueCount);
const smoothNrmZ = new Float64Array(uniqueCount);
// zoneArea: per-axis face area for cubic mapping (replaces zoneAreaMap)
const zoneAreaX = new Float64Array(uniqueCount);
const zoneAreaY = new Float64Array(uniqueCount);
const zoneAreaZ = new Float64Array(uniqueCount);
// maskedFrac: [maskedArea, totalArea] per unique vertex (replaces maskedFracMap)
const maskedFracMasked = new Float64Array(uniqueCount);
const maskedFracTotal = new Float64Array(uniqueCount);
// Optional per-vertex exclusion weights threaded through by subdivision.js.
// A face's user-exclusion flag = average of its 3 vertex weights > 0.99.
const ewAttr = geometry.attributes.excludeWeight || null;
// Per-face user-exclusion flag: stored separately from maskedFracMap so that
// Per-face user-exclusion flag: stored separately from maskedFrac so that
// user-excluded faces do NOT bleed reduced displacement into adjacent faces
// via shared vertices (maskedFracMap is only for angle-based blending).
// via shared vertices (maskedFrac is only for angle-based blending).
const userExcludedFaces = ewAttr ? new Uint8Array(count / 3) : null;
// Positions that belong to at least one user-excluded face. Any included-face
// vertex whose original position is in this set sits on the seam boundary; we
// pin it to zero displacement so both sides of the seam end up at the same
// final position. Without this the mesh has an open crack at the mask
// boundary, which causes the QEM decimator to treat the excluded patch as an
// isolated open-mesh island and collapse it to nothing (missing triangles).
const excludedPosSet = ewAttr ? new Set() : null;
// Positions that belong to at least one user-excluded face (replaces excludedPosSet).
const excludedPos = ewAttr ? new Uint8Array(uniqueCount) : null;
// Displacement cache: one sample per unique vertex (replaces dispCache Map)
const dispCacheVal = new Float64Array(uniqueCount);
const dispCacheSet = new Uint8Array(uniqueCount);
for (let t = 0; t < count; t += 3) {
vA.fromBufferAttribute(posAttr, t);
@@ -141,9 +153,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
}
for (let v = 0; v < 3; v++) {
tmpPos.fromBufferAttribute(posAttr, t + v);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
const vid = vertexId[t + v];
if (userExcluded && excludedPos) excludedPos[vid] = 1;
// Use the buffer normal (from subdivision) weighted by face area.
// The subdivision pipeline splits indexed vertices at sharp dihedral
// edges (>30°), so the interpolated buffer normals are smooth across
@@ -151,44 +162,33 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// This eliminates visible faceting steps on round surfaces while still
// preserving hard edges.
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
const existing = smoothNrmMap.get(k);
if (existing) {
existing[0] += tmpNrm.x * faceArea;
existing[1] += tmpNrm.y * faceArea;
existing[2] += tmpNrm.z * faceArea;
} else {
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
}
smoothNrmX[vid] += tmpNrm.x * faceArea;
smoothNrmY[vid] += tmpNrm.y * faceArea;
smoothNrmZ[vid] += tmpNrm.z * faceArea;
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
const za = zoneAreaMap.get(k);
if (za) { za[0] += czX; za[1] += czY; za[2] += czZ; }
else { zoneAreaMap.set(k, [czX, czY, czZ]); }
}
const mf = maskedFracMap.get(k);
if (mf) {
if (faceMasked) mf[0] += faceArea;
mf[1] += faceArea;
} else {
maskedFracMap.set(k, [faceMasked ? faceArea : 0, faceArea]);
zoneAreaX[vid] += czX;
zoneAreaY[vid] += czY;
zoneAreaZ[vid] += czZ;
}
if (faceMasked) maskedFracMasked[vid] += faceArea;
maskedFracTotal[vid] += faceArea;
}
}
// Normalise each accumulated normal
smoothNrmMap.forEach((n) => {
const len = Math.sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]) || 1;
n[0] /= len; n[1] /= len; n[2] /= len;
});
for (let id = 0; id < uniqueCount; id++) {
const len = Math.sqrt(smoothNrmX[id]*smoothNrmX[id] + smoothNrmY[id]*smoothNrmY[id] + smoothNrmZ[id]*smoothNrmZ[id]) || 1;
smoothNrmX[id] /= len; smoothNrmY[id] /= len; smoothNrmZ[id] /= len;
}
// ── Pass 2: sample displacement texture once per unique position ──────────
const dispCache = new Map(); // posKey → grey [0, 1]
for (let i = 0; i < count; i++) {
tmpPos.fromBufferAttribute(posAttr, i);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (dispCache.has(k)) continue;
const vid = vertexId[i];
if (dispCacheSet[vid]) continue;
dispCacheSet[vid] = 1;
const sn = smoothNrmMap.get(k);
tmpPos.fromBufferAttribute(posAttr, i);
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
// Non-seam vertices use their single zone purely; seam-edge vertices that
@@ -201,39 +201,39 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// top (0,0,1) and bottom (0,0,-1) face normals cancel at shared edge vertices,
// leaving a horizontal smooth normal. computeUV would then pick the wrong
// cubic projection axis, making those faces appear untextured. The face-
// normal-based zoneAreaMap is immune to this because it classifies faces by
// their geometric cross-product normal, not the averaged vertex normal.
// normal-based zoneArea arrays are immune to this because they classify faces
// by their geometric cross-product normal, not the averaged vertex normal.
if (settings.mappingMode === 6 /* MODE_CUBIC */) {
const za = zoneAreaMap.get(k);
const total = za ? za[0] + za[1] + za[2] : 0;
const zaX = zoneAreaX[vid], zaY = zoneAreaY[vid], zaZ = zoneAreaZ[vid];
const total = zaX + zaY + zaZ;
if (total > 0) {
const md = Math.max(bounds.size.x, bounds.size.y, bounds.size.z, 1e-6);
const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
let grey = 0;
if (za[0] > 0) { // X-dominant zone → YZ projection
if (zaX > 0) { // X-dominant zone → YZ projection
let rawU = (tmpPos.y-bounds.min.y)/md;
if (sn[0] < 0) rawU = -rawU; // flip U for -X faces
if (smoothNrmX[vid] < 0) rawU = -rawU; // flip U for -X faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[0]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaX/total);
}
if (za[1] > 0) { // Y-dominant zone → XZ projection
if (zaY > 0) { // Y-dominant zone → XZ projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[1] > 0) rawU = -rawU; // flip U for +Y faces
if (smoothNrmY[vid] > 0) rawU = -rawU; // flip U for +Y faces
const uv = _cubicUV(rawU, (tmpPos.z-bounds.min.z)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[1]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaY/total);
}
if (za[2] > 0) { // Z-dominant zone → XY projection
if (zaZ > 0) { // Z-dominant zone → XY projection
let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[2] < 0) rawU = -rawU; // flip U for -Z faces
if (smoothNrmZ[vid] < 0) rawU = -rawU; // flip U for -Z faces
const uv = _cubicUV(rawU, (tmpPos.y-bounds.min.y)/md, settings, rotRad, aspectU, aspectV);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (za[2]/total);
grey += sampleBilinear(imageData.data, imgWidth, imgHeight, uv.u, uv.v) * (zaZ/total);
}
dispCache.set(k, grey);
dispCacheVal[vid] = grey;
continue;
}
}
tmpNrm.set(sn[0], sn[1], sn[2]);
tmpNrm.set(smoothNrmX[vid], smoothNrmY[vid], smoothNrmZ[vid]);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds);
let grey;
@@ -245,7 +245,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
} else {
grey = sampleBilinear(imageData.data, imgWidth, imgHeight, uvResult.u, uvResult.v);
}
dispCache.set(k, grey);
dispCacheVal[vid] = grey;
}
// ── Pass 3: displace every vertex copy by the same vector ─────────────────
@@ -258,9 +258,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpPos.fromBufferAttribute(posAttr, i);
tmpNrm.fromBufferAttribute(nrmAttr, i);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
const sn = smoothNrmMap.get(k);
const grey = dispCache.get(k);
const vid = vertexId[i];
const grey = dispCacheVal[vid];
// User-excluded faces get zero displacement; only angle-based masking uses
// the smooth per-vertex blend so neighbours are never unintentionally dimmed.
@@ -268,15 +267,15 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// Pin included-face vertices that share a position with an excluded face.
// This seals the open crack at the mask boundary so the mesh stays watertight
// and the decimator cannot collapse the excluded patch to zero faces.
const isSealedBoundary = !isFaceExcluded && excludedPosSet && excludedPosSet.has(k);
const mf = maskedFracMap.get(k) || [0, 1];
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
const isSealedBoundary = !isFaceExcluded && excludedPos && excludedPos[vid] === 1;
const mfTotal = maskedFracTotal[vid];
const maskedFrac = mfTotal > 0 ? maskedFracMasked[vid] / mfTotal : 0;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + sn[0] * disp;
const newY = tmpPos.y + sn[1] * disp;
let newZ = tmpPos.z + sn[2] * disp;
const newX = tmpPos.x + smoothNrmX[vid] * disp;
const newY = tmpPos.y + smoothNrmY[vid] * disp;
let newZ = tmpPos.z + smoothNrmZ[vid] * disp;
// Prevent boundary vertices from poking through the masked surface in Z.
// Only triggers for vertices that are partly masked (maskedFrac > 0) and