feat: update normal handling to use area-weighted buffer normals for improved surface rendering

This commit is contained in:
CNCKitchen
2026-03-20 23:30:56 +01:00
parent b35140cd78
commit a5cb0e5671
3 changed files with 122 additions and 54 deletions
+11 -11
View File
@@ -136,20 +136,20 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpPos.fromBufferAttribute(posAttr, t + v); tmpPos.fromBufferAttribute(posAttr, t + v);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z); const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (userExcluded && excludedPosSet) excludedPosSet.add(k); if (userExcluded && excludedPosSet) excludedPosSet.add(k);
// Use the geometric face normal (faceNrm = cross product, length ∝ 2×area) // Use the buffer normal (from subdivision) weighted by face area.
// instead of the buffer normal. The subdivision pipeline interpolates // The subdivision pipeline splits indexed vertices at sharp dihedral
// smooth normals at midpoints, which propagates the 45° edge tilt deep // edges (>30°), so the interpolated buffer normals are smooth across
// into the face interior across iterations. Using the true face normal // soft edges (cylinder, sphere) but sharp across hard edges (cube).
// ensures interior vertices on flat faces get a perfectly perpendicular // This eliminates visible faceting steps on round surfaces while still
// smooth normal, limiting the angled displacement to the single outermost // preserving hard edges.
// vertex row at each hard edge — matching addSmoothNormals() in main.js. tmpNrm.fromBufferAttribute(nrmAttr, t + v);
const existing = smoothNrmMap.get(k); const existing = smoothNrmMap.get(k);
if (existing) { if (existing) {
existing[0] += faceNrm.x; existing[0] += tmpNrm.x * faceArea;
existing[1] += faceNrm.y; existing[1] += tmpNrm.y * faceArea;
existing[2] += faceNrm.z; existing[2] += tmpNrm.z * faceArea;
} else { } else {
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]); smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
} }
if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) { if (czX > 1e-12 || czY > 1e-12 || czZ > 1e-12) {
const za = zoneAreaMap.get(k); const za = zoneAreaMap.get(k);
+16 -9
View File
@@ -1132,8 +1132,14 @@ function addSmoothNormals(geometry) {
const key = (x, y, z) => const key = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`; `${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
// Accumulate area-weighted face normals per unique position // Accumulate area-weighted buffer normals per unique position.
// The subdivision pipeline splits indexed vertices at sharp dihedral edges
// (>30°) so the interpolated buffer normals are smooth across soft edges
// (cylinder, sphere) but sharp across hard edges (cube). Using these buffer
// normals instead of geometric face normals eliminates visible faceting steps
// on round surfaces while still preserving hard edges.
const nrmMap = new Map(); const nrmMap = new Map();
const nrm = geometry.attributes.normal.array;
const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3(); const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3(); const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), fn = new THREE.Vector3();
@@ -1143,19 +1149,20 @@ function addSmoothNormals(geometry) {
vC.set(pos[(i + 2) * 3], pos[(i + 2) * 3 + 1], pos[(i + 2) * 3 + 2]); vC.set(pos[(i + 2) * 3], pos[(i + 2) * 3 + 1], pos[(i + 2) * 3 + 2]);
e1.subVectors(vB, vA); e1.subVectors(vB, vA);
e2.subVectors(vC, vA); e2.subVectors(vC, vA);
fn.crossVectors(e1, e2); // length = 2 × triangle area fn.crossVectors(e1, e2);
const area = fn.length(); const area = fn.length();
if (area < 1e-12) continue; if (area < 1e-12) continue;
fn.divideScalar(area); // unit face normal for (let v = 0; v < 3; v++) {
for (const v of [vA, vB, vC]) { const vi = i + v;
const k = key(v.x, v.y, v.z); const nx = nrm[vi * 3], ny = nrm[vi * 3 + 1], nz = nrm[vi * 3 + 2];
const k = key(pos[vi * 3], pos[vi * 3 + 1], pos[vi * 3 + 2]);
const prev = nrmMap.get(k); const prev = nrmMap.get(k);
if (prev) { if (prev) {
prev[0] += fn.x * area; prev[0] += nx * area;
prev[1] += fn.y * area; prev[1] += ny * area;
prev[2] += fn.z * area; prev[2] += nz * area;
} else { } else {
nrmMap.set(k, [fn.x * area, fn.y * area, fn.z * area]); nrmMap.set(k, [nx * area, ny * area, nz * area]);
} }
} }
} }
+95 -34
View File
@@ -88,6 +88,17 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
const maxSq = maxEdgeLength * maxEdgeLength; const maxSq = maxEdgeLength * maxEdgeLength;
const midCache = new Map(); const midCache = new Map();
// Position-based edge key for split detection. toIndexed() splits indexed
// vertices at sharp dihedral edges (>30°), so two faces sharing a geometric
// edge may reference different index pairs. Using quantised positions as
// the key guarantees both sides see the same split decision, preventing
// T-junctions at the boundary between textured and angle-masked faces.
const _posEdgeKey = (a, b) => {
const ka = `${Math.round(positions[a*3]*QUANTISE)}_${Math.round(positions[a*3+1]*QUANTISE)}_${Math.round(positions[a*3+2]*QUANTISE)}`;
const kb = `${Math.round(positions[b*3]*QUANTISE)}_${Math.round(positions[b*3+1]*QUANTISE)}_${Math.round(positions[b*3+2]*QUANTISE)}`;
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
// ── Step 1: globally mark edges that need splitting ───────────────────── // ── Step 1: globally mark edges that need splitting ─────────────────────
// Excluded triangles do NOT proactively mark their own edges their // Excluded triangles do NOT proactively mark their own edges their
// interior edges will never be split, saving triangles on untextured // interior edges will never be split, saving triangles on untextured
@@ -97,9 +108,9 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
for (let t = 0; t < indices.length; t += 3) { for (let t = 0; t < indices.length; t += 3) {
if (faceExcluded && faceExcluded[t / 3]) continue; // skip excluded faces if (faceExcluded && faceExcluded[t / 3]) continue; // skip excluded faces
const a = indices[t], b = indices[t + 1], c = indices[t + 2]; const a = indices[t], b = indices[t + 1], c = indices[t + 2];
if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(edgeKey(a, b)); if (edgeLenSq(positions, a, b) > maxSq) splitEdges.add(_posEdgeKey(a, b));
if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(edgeKey(b, c)); if (edgeLenSq(positions, b, c) > maxSq) splitEdges.add(_posEdgeKey(b, c));
if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(edgeKey(c, a)); if (edgeLenSq(positions, c, a) > maxSq) splitEdges.add(_posEdgeKey(c, a));
} }
if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false }; if (splitEdges.size === 0) return { newIndices: indices, newFaceExcluded: faceExcluded, changed: false };
@@ -122,9 +133,9 @@ function subdividePass(positions, normals, weights, indices, maxEdgeLength, safe
const a = indices[t], b = indices[t + 1], c = indices[t + 2]; const a = indices[t], b = indices[t + 1], c = indices[t + 2];
const fIdx = t / 3; const fIdx = t / 3;
const excl = faceExcluded ? faceExcluded[fIdx] : 0; const excl = faceExcluded ? faceExcluded[fIdx] : 0;
const sAB = splitEdges.has(edgeKey(a, b)); const sAB = splitEdges.has(_posEdgeKey(a, b));
const sBC = splitEdges.has(edgeKey(b, c)); const sBC = splitEdges.has(_posEdgeKey(b, c));
const sCA = splitEdges.has(edgeKey(c, a)); const sCA = splitEdges.has(_posEdgeKey(c, a));
const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0); const n = (sAB ? 1 : 0) + (sBC ? 1 : 0) + (sCA ? 1 : 0);
if (n === 0) { if (n === 0) {
@@ -258,43 +269,93 @@ function getMidpoint(positions, normals, weights, cache, a, b) {
// weight wins (conservative: any excluded face marks the shared vertex). // weight wins (conservative: any excluded face marks the shared vertex).
function toIndexed(geometry, nonIndexedWeights = null) { function toIndexed(geometry, nonIndexedWeights = null) {
const posAttr = geometry.attributes.position; const posAttr = geometry.attributes.position;
const nrmAttr = geometry.attributes.normal;
const positions = [];
const normals = [];
const normalSums = [];
const weights = nonIndexedWeights ? [] : null;
const indices = [];
const vertMap = new Map();
const n = posAttr.count; const n = posAttr.count;
// ── Pre-compute per-face normals (unit + raw cross product) ──────────────
const faceNrmUnit = new Float32Array(n * 3);
const faceNrmRaw = new Float32Array(n * 3);
for (let t = 0; t < n; t += 3) {
const ax = posAttr.getX(t), ay = posAttr.getY(t), az = posAttr.getZ(t);
const bx = posAttr.getX(t+1), by = posAttr.getY(t+1), bz = posAttr.getZ(t+1);
const cx = posAttr.getX(t+2), cy = posAttr.getY(t+2), cz = posAttr.getZ(t+2);
const e1x = bx-ax, e1y = by-ay, e1z = bz-az;
const e2x = cx-ax, e2y = cy-ay, e2z = cz-az;
const rx = e1y*e2z - e1z*e2y;
const ry = e1z*e2x - e1x*e2z;
const rz = e1x*e2y - e1y*e2x;
const len = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
const ux = rx/len, uy = ry/len, uz = rz/len;
for (let v = 0; v < 3; v++) {
faceNrmUnit[(t+v)*3] = ux;
faceNrmUnit[(t+v)*3+1] = uy;
faceNrmUnit[(t+v)*3+2] = uz;
faceNrmRaw[(t+v)*3] = rx;
faceNrmRaw[(t+v)*3+1] = ry;
faceNrmRaw[(t+v)*3+2] = rz;
}
}
// ── Merge vertices, splitting at sharp dihedral edges ───────────────────
// Two vertices at the same position merge into one indexed vertex only when
// their face normals are within SHARP_ANGLE of each other. This keeps
// smooth-surface normals averaged across facet boundaries (cylinder, sphere)
// while preventing the 45° edge-normal tilt from propagating into flat-face
// interiors during subdivision (cube, box).
const SHARP_COS = Math.cos(30 * Math.PI / 180);
const positions = [];
const normals = [];
const normalSums = [];
const weights = nonIndexedWeights ? [] : null;
const indices = [];
const vertMap = new Map(); // posKey → [{idx, fnU: [x,y,z]}]
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const px = posAttr.getX(i); const px = posAttr.getX(i);
const py = posAttr.getY(i); const py = posAttr.getY(i);
const pz = posAttr.getZ(i); const pz = posAttr.getZ(i);
const nx_ = nrmAttr ? nrmAttr.getX(i) : 0; const fnUx = faceNrmUnit[i*3], fnUy = faceNrmUnit[i*3+1], fnUz = faceNrmUnit[i*3+2];
const ny_ = nrmAttr ? nrmAttr.getY(i) : 0; const fnRx = faceNrmRaw[i*3], fnRy = faceNrmRaw[i*3+1], fnRz = faceNrmRaw[i*3+2];
const nz_ = nrmAttr ? nrmAttr.getZ(i) : 1;
const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`; const key = `${Math.round(px * QUANTISE)}_${Math.round(py * QUANTISE)}_${Math.round(pz * QUANTISE)}`;
let idx = vertMap.get(key); const clusters = vertMap.get(key);
if (idx === undefined) { if (clusters) {
idx = positions.length / 3; let matched = false;
positions.push(px, py, pz); for (const cl of clusters) {
normals.push(nx_, ny_, nz_); const dot = cl.fnU[0]*fnUx + cl.fnU[1]*fnUy + cl.fnU[2]*fnUz;
normalSums.push(nx_, ny_, nz_); if (dot >= SHARP_COS) {
if (weights) weights.push(nonIndexedWeights[i]); // Same smooth group accumulate area-weighted face normal
vertMap.set(key, idx); const idx = cl.idx;
} else { normalSums[idx*3] += fnRx;
normalSums[idx * 3] += nx_; normalSums[idx*3+1] += fnRy;
normalSums[idx * 3 + 1] += ny_; normalSums[idx*3+2] += fnRz;
normalSums[idx * 3 + 2] += nz_; if (weights && nonIndexedWeights[i] > weights[idx]) {
if (weights && nonIndexedWeights[i] > weights[idx]) { weights[idx] = nonIndexedWeights[i];
// MAX: if any incident original face was excluded, the shared vertex is excluded }
weights[idx] = nonIndexedWeights[i]; indices.push(idx);
matched = true;
break;
}
} }
if (!matched) {
// New cluster at this position (sharp-edge split)
const idx = positions.length / 3;
positions.push(px, py, pz);
normals.push(fnRx, fnRy, fnRz);
normalSums.push(fnRx, fnRy, fnRz);
if (weights) weights.push(nonIndexedWeights[i]);
clusters.push({idx, fnU: [fnUx, fnUy, fnUz]});
indices.push(idx);
}
} else {
const idx = positions.length / 3;
positions.push(px, py, pz);
normals.push(fnRx, fnRy, fnRz);
normalSums.push(fnRx, fnRy, fnRz);
if (weights) weights.push(nonIndexedWeights[i]);
vertMap.set(key, [{idx, fnU: [fnUx, fnUy, fnUz]}]);
indices.push(idx);
} }
indices.push(idx);
} }
for (let i = 0; i < positions.length / 3; i++) { for (let i = 0; i < positions.length / 3; i++) {