add boundary falloff

This commit is contained in:
Andrew Sink
2026-03-21 10:20:31 -04:00
parent 87ad3bcecf
commit 27306ed596
5 changed files with 454 additions and 3 deletions
+106 -1
View File
@@ -172,6 +172,110 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
n[0] /= len; n[1] /= len; n[2] /= len;
});
// ── Boundary falloff distance field ──────────────────────────────────────────
// When boundaryFalloff > 0, identify boundary positions (vertices adjacent to
// both masked and unmasked faces, or on the user-exclusion seam) and compute
// the Euclidean distance from every fully-textured vertex to its nearest
// boundary position. The result is a falloffMap: posKey → [0, 1] where 0 means
// "at the boundary" and 1 means "at or beyond the falloff distance".
const boundaryFalloff = settings.boundaryFalloff ?? 0;
let falloffMap = null;
if (boundaryFalloff > 0) {
const boundaryPositions = []; // [[x, y, z], ...]
// Collect boundary positions: vertices where maskedFrac is between 0 and 1,
// or that sit on the user-exclusion seam.
const posFromKey = new Map(); // posKey → [x, y, z]
for (let i = 0; i < count; i++) {
tmpPos.fromBufferAttribute(posAttr, i);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (!posFromKey.has(k)) posFromKey.set(k, [tmpPos.x, tmpPos.y, tmpPos.z]);
}
for (const [k, pos] of posFromKey) {
const mf = maskedFracMap.get(k);
const maskedFrac = mf && mf[1] > 0 ? mf[0] / mf[1] : 0;
const isOnExclBoundary = excludedPosSet && excludedPosSet.has(k);
if (isOnExclBoundary || (maskedFrac > 0 && maskedFrac < 1)) {
boundaryPositions.push(pos);
}
}
if (boundaryPositions.length > 0) {
// Build a spatial grid of boundary positions for fast nearest-neighbor lookup
let gMinX = Infinity, gMinY = Infinity, gMinZ = Infinity;
let gMaxX = -Infinity, gMaxY = -Infinity, gMaxZ = -Infinity;
for (const bp of boundaryPositions) {
if (bp[0] < gMinX) gMinX = bp[0]; if (bp[0] > gMaxX) gMaxX = bp[0];
if (bp[1] < gMinY) gMinY = bp[1]; if (bp[1] > gMaxY) gMaxY = bp[1];
if (bp[2] < gMinZ) gMinZ = bp[2]; if (bp[2] > gMaxZ) gMaxZ = bp[2];
}
const gPad = boundaryFalloff + 1e-3;
gMinX -= gPad; gMinY -= gPad; gMinZ -= gPad;
gMaxX += gPad; gMaxY += gPad; gMaxZ += gPad;
const gRes = Math.max(4, Math.min(128, Math.ceil(Math.cbrt(boundaryPositions.length) * 2)));
const gDx = (gMaxX - gMinX) / gRes || 1;
const gDy = (gMaxY - gMinY) / gRes || 1;
const gDz = (gMaxZ - gMinZ) / gRes || 1;
const bGrid = new Map();
const bCellKey = (ix, iy, iz) => (ix * gRes + iy) * gRes + iz;
for (const bp of boundaryPositions) {
const ix = Math.max(0, Math.min(gRes - 1, Math.floor((bp[0] - gMinX) / gDx)));
const iy = Math.max(0, Math.min(gRes - 1, Math.floor((bp[1] - gMinY) / gDy)));
const iz = Math.max(0, Math.min(gRes - 1, Math.floor((bp[2] - gMinZ) / gDz)));
const ck = bCellKey(ix, iy, iz);
const cell = bGrid.get(ck);
if (cell) cell.push(bp); else bGrid.set(ck, [bp]);
}
// How many grid cells to search in each direction to cover boundaryFalloff distance
const searchX = Math.ceil(boundaryFalloff / gDx);
const searchY = Math.ceil(boundaryFalloff / gDy);
const searchZ = Math.ceil(boundaryFalloff / gDz);
falloffMap = new Map();
for (const [k, pos] of posFromKey) {
const mf = maskedFracMap.get(k);
const maskedFrac = mf && mf[1] > 0 ? mf[0] / mf[1] : 0;
const isOnExclBoundary = excludedPosSet && excludedPosSet.has(k);
// Only compute falloff for fully-textured, non-boundary positions
if (maskedFrac > 0 || isOnExclBoundary) continue;
const px = pos[0], py = pos[1], pz = pos[2];
const cix = Math.max(0, Math.min(gRes - 1, Math.floor((px - gMinX) / gDx)));
const ciy = Math.max(0, Math.min(gRes - 1, Math.floor((py - gMinY) / gDy)));
const ciz = Math.max(0, Math.min(gRes - 1, Math.floor((pz - gMinZ) / gDz)));
let minDist2 = boundaryFalloff * boundaryFalloff;
for (let dix = -searchX; dix <= searchX; dix++) {
const nix = cix + dix;
if (nix < 0 || nix >= gRes) continue;
for (let diy = -searchY; diy <= searchY; diy++) {
const niy = ciy + diy;
if (niy < 0 || niy >= gRes) continue;
for (let diz = -searchZ; diz <= searchZ; diz++) {
const niz = ciz + diz;
if (niz < 0 || niz >= gRes) continue;
const cell = bGrid.get(bCellKey(nix, niy, niz));
if (!cell) continue;
for (const bp of cell) {
const dx = px - bp[0], dy = py - bp[1], dz = pz - bp[2];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 < minDist2) minDist2 = d2;
}
}
}
}
const dist = Math.sqrt(minDist2);
const factor = Math.min(1, dist / boundaryFalloff);
if (factor < 1) falloffMap.set(k, factor);
}
}
}
// ── Pass 2: sample displacement texture once per unique position ──────────
const dispCache = new Map(); // posKey → grey [0, 1]
@@ -258,7 +362,8 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const mf = maskedFracMap.get(k) || [0, 1];
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : (1 - maskedFrac) * centeredGrey * settings.amplitude;
const falloffFactor = (falloffMap && falloffMap.has(k)) ? falloffMap.get(k) : 1.0;
const disp = (isFaceExcluded || isSealedBoundary) ? 0 : falloffFactor * (1 - maskedFrac) * centeredGrey * settings.amplitude;
const newX = tmpPos.x + sn[0] * disp;
const newY = tmpPos.y + sn[1] * disp;