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
+5
View File
@@ -160,6 +160,11 @@
<div id="amplitude-warning" class="amplitude-warning hidden" data-i18n="warnings.amplitudeOverlap"> <div id="amplitude-warning" class="amplitude-warning hidden" data-i18n="warnings.amplitudeOverlap">
⚠ Amplitude exceeds 10% of the smallest model dimension — geometry overlaps may occur in the exported STL. ⚠ Amplitude exceeds 10% of the smallest model dimension — geometry overlaps may occur in the exported STL.
</div> </div>
<div class="form-row slider-row">
<label for="boundary-falloff" data-i18n="labels.boundaryFalloff" data-i18n-title="tooltips.boundaryFalloff" title="Gradually reduces displacement to zero near masked boundaries, preventing triangle overlap where textured and non-textured regions meet.">Boundary Falloff ⓘ</label>
<input type="range" id="boundary-falloff" min="0" max="10" step="0.1" value="1" />
<input type="number" class="val" id="boundary-falloff-val" value="1" min="0" max="10" step="0.1" />
</div>
<div class="form-row"> <div class="form-row">
<label class="checkbox-label" for="symmetric-displacement" <label class="checkbox-label" for="symmetric-displacement"
data-i18n-title="tooltips.symmetricDisplacement" data-i18n-title="tooltips.symmetricDisplacement"
+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; 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 ────────── // ── Pass 2: sample displacement texture once per unique position ──────────
const dispCache = new Map(); // posKey → grey [0, 1] 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 mf = maskedFracMap.get(k) || [0, 1];
const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0; const maskedFrac = mf[1] > 0 ? mf[0] / mf[1] : 0;
const centeredGrey = settings.symmetricDisplacement ? (grey - 0.5) : grey; 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 newX = tmpPos.x + sn[0] * disp;
const newY = tmpPos.y + sn[1] * disp; const newY = tmpPos.y + sn[1] * disp;
+8
View File
@@ -91,6 +91,10 @@ export const TRANSLATIONS = {
'excl.hintExclude': 'Masked surfaces appear orange and will not receive displacement during export.', 'excl.hintExclude': 'Masked surfaces appear orange and will not receive displacement during export.',
'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.', 'excl.hintInclude': 'Selected surfaces appear green and will be the only ones to receive displacement during export.',
// Boundary falloff
'labels.boundaryFalloff': 'Boundary Falloff \u24d8',
'tooltips.boundaryFalloff': 'Gradually reduces displacement to zero near masked boundaries, preventing triangle overlap where textured and non-textured regions meet.',
// Symmetric displacement // Symmetric displacement
'labels.symmetricDisplacement': 'Symmetric displacement \u24d8', 'labels.symmetricDisplacement': 'Symmetric displacement \u24d8',
'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.', 'tooltips.symmetricDisplacement':'When on, 50% grey = no displacement; white pushes out, black pushes in. Keeps part volume roughly constant.',
@@ -239,6 +243,10 @@ export const TRANSLATIONS = {
'excl.hintExclude': 'Maskierte Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.', 'excl.hintExclude': 'Maskierte Fl\u00e4chen erscheinen orange und erhalten beim Export keine Verschiebung.',
'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.', 'excl.hintInclude': 'Ausgew\u00e4hlte Fl\u00e4chen erscheinen gr\u00fcn und sind die einzigen, die beim Export eine Verschiebung erhalten.',
// Boundary falloff
'labels.boundaryFalloff': 'Rand\u00fcbergang \u24d8',
'tooltips.boundaryFalloff': 'Reduziert die Verschiebung schrittweise auf Null nahe maskierter Grenzen, um Dreiecks\u00fcberschneidungen an \u00dcberg\u00e4ngen zu vermeiden.',
// Symmetric displacement // Symmetric displacement
'labels.symmetricDisplacement': 'Symmetrische Verschiebung \u24d8', 'labels.symmetricDisplacement': 'Symmetrische Verschiebung \u24d8',
'tooltips.symmetricDisplacement':'Wenn aktiv: 50% Grau = keine Verschiebung; Weiß nach außen, Schwarz nach innen. H\u00e4lt das Volumen des Teils in etwa konstant.', 'tooltips.symmetricDisplacement':'Wenn aktiv: 50% Grau = keine Verschiebung; Weiß nach außen, Schwarz nach innen. H\u00e4lt das Volumen des Teils in etwa konstant.',
+292
View File
@@ -23,6 +23,10 @@ let previewMaterial = null;
let isExporting = false; let isExporting = false;
let previewDebounce = null; let previewDebounce = null;
// Boundary edge data texture for per-fragment falloff in bump-only preview
let _boundaryEdgeTex = null;
let _boundaryEdgeCount = 0;
// ── Exclusion state ─────────────────────────────────────────────────────────── // ── Exclusion state ───────────────────────────────────────────────────────────
let excludedFaces = new Set(); // triangle indices in currentGeometry let excludedFaces = new Set(); // triangle indices in currentGeometry
let triangleAdjacency = null; // Map from buildAdjacency let triangleAdjacency = null; // Map from buildAdjacency
@@ -53,6 +57,7 @@ const settings = {
topAngleLimit: 0, topAngleLimit: 0,
mappingBlend: 1, mappingBlend: 1,
seamBandWidth: 0.5, seamBandWidth: 0.5,
boundaryFalloff: 1,
symmetricDisplacement: false, symmetricDisplacement: false,
useDisplacement: false, useDisplacement: false,
}; };
@@ -111,6 +116,8 @@ const seamBlendSlider = document.getElementById('seam-blend');
const seamBlendVal = document.getElementById('seam-blend-val'); const seamBlendVal = document.getElementById('seam-blend-val');
const seamBandWidthSlider = document.getElementById('seam-band-width'); const seamBandWidthSlider = document.getElementById('seam-band-width');
const seamBandWidthVal = document.getElementById('seam-band-width-val'); const seamBandWidthVal = document.getElementById('seam-band-width-val');
const boundaryFalloffSlider = document.getElementById('boundary-falloff');
const boundaryFalloffVal = document.getElementById('boundary-falloff-val');
const symmetricDispToggle = document.getElementById('symmetric-displacement'); const symmetricDispToggle = document.getElementById('symmetric-displacement');
const dispPreviewToggle = document.getElementById('displacement-preview'); const dispPreviewToggle = document.getElementById('displacement-preview');
@@ -324,6 +331,7 @@ function wireEvents() {
linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); }); linkSlider(rotationSlider, rotationVal, v => { settings.rotation = v; return Math.round(v); });
linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); }); linkSlider(amplitudeSlider, amplitudeVal, v => { settings.amplitude = v; checkAmplitudeWarning(); return v.toFixed(2); });
amplitudeVal.addEventListener('change', checkAmplitudeWarning); amplitudeVal.addEventListener('change', checkAmplitudeWarning);
linkSlider(boundaryFalloffSlider, boundaryFalloffVal, v => { settings.boundaryFalloff = v; return v.toFixed(1); });
linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(2); }, false); linkSlider(refineLenSlider, refineLenVal, v => { settings.refineLength = v; return v.toFixed(2); }, false);
linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false); linkSlider(maxTriSlider, maxTriVal, v => { settings.maxTriangles = v; return formatM(v); }, false);
linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; }); linkSlider(bottomAngleLimitSlider, bottomAngleLimitVal, v => { settings.bottomAngleLimit = v; return v; });
@@ -1137,6 +1145,289 @@ function updateFaceMask(geometry) {
if (!geometry.attributes.faceNormal) { if (!geometry.attributes.faceNormal) {
addFaceNormals(geometry); addFaceNormals(geometry);
} }
computeBoundaryFalloffAttr(geometry, maskArr);
computeBoundaryEdges(geometry, maskArr);
syncBoundaryEdgeUniforms();
}
/**
* Compute a per-vertex `boundaryFalloffAttr` float attribute on the geometry.
* Vertices near the boundary between masked and non-masked regions get values
* ramping from 0 (at boundary) to 1 (at or beyond boundaryFalloff distance).
* The shader multiplies displacement/bump by this attribute.
*
* @param {THREE.BufferGeometry} geometry
* @param {Float32Array} userMaskArr per-vertex user-exclusion mask from updateFaceMask
*/
function computeBoundaryFalloffAttr(geometry, userMaskArr) {
const posAttr = geometry.attributes.position;
const posCount = posAttr.count;
const triCount = posCount / 3;
const falloff = settings.boundaryFalloff ?? 0;
const falloffArr = new Float32Array(posCount);
falloffArr.fill(1.0);
if (falloff <= 0) {
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
return;
}
// Compute per-face combined mask (angle masking + user exclusion).
// Mirrors the vertex shader logic so the preview boundary matches export.
const faceNrmAttr = geometry.attributes.faceNormal;
const faceMask = new Float32Array(triCount); // 0 = masked, 1 = textured
for (let t = 0; t < triCount; t++) {
const userVal = userMaskArr[t * 3]; // same for all 3 verts of this face
if (userVal < 0.5) { faceMask[t] = 0; continue; }
let angleMask = 1.0;
if (faceNrmAttr) {
const fnz = faceNrmAttr.getZ(t * 3);
const fnx = faceNrmAttr.getX(t * 3);
const fny = faceNrmAttr.getY(t * 3);
const len = Math.sqrt(fnx * fnx + fny * fny + fnz * fnz);
const nz = len > 1e-6 ? fnz / len : 0;
const surfaceAngle = Math.acos(Math.min(1, Math.abs(nz))) * (180 / Math.PI);
if (nz < 0 && settings.bottomAngleLimit >= 1)
angleMask = surfaceAngle > settings.bottomAngleLimit ? 1.0 : 0.0;
if (nz >= 0 && settings.topAngleLimit >= 1)
angleMask = Math.min(angleMask, surfaceAngle > settings.topAngleLimit ? 1.0 : 0.0);
}
faceMask[t] = angleMask;
}
// Build per-unique-position map and identify boundary positions.
const QUANT = 1e4;
const posKey = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
const posFromKey = new Map(); // posKey → [x, y, z]
// Per-position: [maskedArea, totalArea] to find boundary vertices
const maskFracMap = new Map();
const tmpV = 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();
for (let t = 0; t < triCount; t++) {
vA.fromBufferAttribute(posAttr, t * 3);
vB.fromBufferAttribute(posAttr, t * 3 + 1);
vC.fromBufferAttribute(posAttr, t * 3 + 2);
e1.subVectors(vB, vA);
e2.subVectors(vC, vA);
fn.crossVectors(e1, e2);
const area = fn.length();
const masked = faceMask[t] < 0.5;
for (let v = 0; v < 3; v++) {
tmpV.fromBufferAttribute(posAttr, t * 3 + v);
const k = posKey(tmpV.x, tmpV.y, tmpV.z);
if (!posFromKey.has(k)) posFromKey.set(k, [tmpV.x, tmpV.y, tmpV.z]);
const mf = maskFracMap.get(k);
if (mf) {
if (masked) mf[0] += area;
mf[1] += area;
} else {
maskFracMap.set(k, [masked ? area : 0, area]);
}
}
}
// Boundary positions: shared between masked and non-masked faces
const boundaryPositions = [];
for (const [k, pos] of posFromKey) {
const mf = maskFracMap.get(k);
const frac = mf[1] > 0 ? mf[0] / mf[1] : 0;
if (frac > 0 && frac < 1) boundaryPositions.push(pos);
}
if (boundaryPositions.length === 0) {
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
return;
}
// Spatial grid of boundary positions for fast nearest-neighbor search
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 = falloff + 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]);
}
const searchX = Math.ceil(falloff / gDx);
const searchY = Math.ceil(falloff / gDy);
const searchZ = Math.ceil(falloff / gDz);
// Compute per-unique-position falloff factor
const falloffCache = new Map(); // posKey → factor [0,1]
for (const [k, pos] of posFromKey) {
const mf = maskFracMap.get(k);
const frac = mf[1] > 0 ? mf[0] / mf[1] : 0;
if (frac > 0) continue; // masked or boundary vertex — keep 1.0 (mask handles it)
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 = falloff * falloff;
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 / falloff);
if (factor < 1) falloffCache.set(k, factor);
}
// Write per-vertex attribute
for (let i = 0; i < posCount; i++) {
tmpV.fromBufferAttribute(posAttr, i);
const k = posKey(tmpV.x, tmpV.y, tmpV.z);
if (falloffCache.has(k)) falloffArr[i] = falloffCache.get(k);
}
geometry.setAttribute('boundaryFalloffAttr', new THREE.Float32BufferAttribute(falloffArr, 1));
}
/**
* Compute boundary edge segments between masked and non-masked faces and
* pack them into a DataTexture for per-fragment distance queries in the
* bump-only preview shader. Each edge is stored as two RGBA texels
* (endpoint A xyz, endpoint B xyz).
*/
function computeBoundaryEdges(geometry, userMaskArr) {
const posAttr = geometry.attributes.position;
const posCount = posAttr.count;
const triCount = posCount / 3;
const falloff = settings.boundaryFalloff ?? 0;
if (_boundaryEdgeTex) { _boundaryEdgeTex.dispose(); _boundaryEdgeTex = null; }
_boundaryEdgeCount = 0;
if (falloff <= 0) return;
const faceNrmAttr = geometry.attributes.faceNormal;
const faceMaskBool = new Uint8Array(triCount);
for (let t = 0; t < triCount; t++) {
if (userMaskArr[t * 3] < 0.5) { faceMaskBool[t] = 0; continue; }
let angleMask = 1.0;
if (faceNrmAttr) {
const fnx = faceNrmAttr.getX(t * 3);
const fny = faceNrmAttr.getY(t * 3);
const fnz = faceNrmAttr.getZ(t * 3);
const len = Math.sqrt(fnx * fnx + fny * fny + fnz * fnz);
const nz = len > 1e-6 ? fnz / len : 0;
const surfAngle = Math.acos(Math.min(1, Math.abs(nz))) * (180 / Math.PI);
if (nz < 0 && settings.bottomAngleLimit >= 1)
angleMask = surfAngle > settings.bottomAngleLimit ? 1.0 : 0.0;
if (nz >= 0 && settings.topAngleLimit >= 1)
angleMask = Math.min(angleMask, surfAngle > settings.topAngleLimit ? 1.0 : 0.0);
}
faceMaskBool[t] = angleMask > 0.5 ? 1 : 0;
}
const QUANT = 1e4;
const pk = (x, y, z) =>
`${Math.round(x * QUANT)}_${Math.round(y * QUANT)}_${Math.round(z * QUANT)}`;
const ek = (k1, k2) => k1 < k2 ? k1 + '|' + k2 : k2 + '|' + k1;
const tmpV = new THREE.Vector3();
const edgeFaces = new Map();
const edgePos = new Map();
for (let t = 0; t < triCount; t++) {
const keys = [], pts = [];
for (let v = 0; v < 3; v++) {
tmpV.fromBufferAttribute(posAttr, t * 3 + v);
keys.push(pk(tmpV.x, tmpV.y, tmpV.z));
pts.push([tmpV.x, tmpV.y, tmpV.z]);
}
for (let e = 0; e < 3; e++) {
const edgeKey = ek(keys[e], keys[(e + 1) % 3]);
const list = edgeFaces.get(edgeKey);
if (list) list.push(t);
else {
edgeFaces.set(edgeKey, [t]);
edgePos.set(edgeKey, [pts[e], pts[(e + 1) % 3]]);
}
}
}
const MAX_EDGES = 512;
const edges = [];
for (const [key, faces] of edgeFaces) {
if (edges.length >= MAX_EDGES) break;
let hasMasked = false, hasTextured = false;
for (const f of faces) {
if (faceMaskBool[f] === 0) hasMasked = true;
else hasTextured = true;
if (hasMasked && hasTextured) break;
}
if (hasMasked && hasTextured) edges.push(edgePos.get(key));
}
if (edges.length === 0) return;
const texWidth = edges.length * 2;
const data = new Float32Array(texWidth * 4);
for (let i = 0; i < edges.length; i++) {
const [a, b] = edges[i];
const off = i * 8;
data[off] = a[0]; data[off + 1] = a[1]; data[off + 2] = a[2]; data[off + 3] = 0;
data[off + 4] = b[0]; data[off + 5] = b[1]; data[off + 6] = b[2]; data[off + 7] = 0;
}
_boundaryEdgeTex = new THREE.DataTexture(data, texWidth, 1, THREE.RGBAFormat, THREE.FloatType);
_boundaryEdgeTex.minFilter = THREE.NearestFilter;
_boundaryEdgeTex.magFilter = THREE.NearestFilter;
_boundaryEdgeTex.needsUpdate = true;
_boundaryEdgeCount = edges.length;
}
function syncBoundaryEdgeUniforms() {
if (!previewMaterial || !previewMaterial.uniforms.boundaryEdgeTex) return;
const u = previewMaterial.uniforms;
if (_boundaryEdgeTex) {
u.boundaryEdgeTex.value = _boundaryEdgeTex;
u.boundaryEdgeTexWidth.value = _boundaryEdgeTex.image.width;
}
u.boundaryEdgeCount.value = _boundaryEdgeCount;
u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0;
} }
/** /**
@@ -1299,6 +1590,7 @@ function updatePreview() {
updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings); updateMaterial(previewMaterial, activeMapEntry.texture, fullSettings);
} }
syncBoundaryEdgeUniforms();
exportBtn.disabled = false; exportBtn.disabled = false;
} }
+43 -2
View File
@@ -156,12 +156,13 @@ const vertexShader = /* glsl */`
attribute vec3 smoothNormal; attribute vec3 smoothNormal;
attribute vec3 faceNormal; attribute vec3 faceNormal;
attribute float faceMask; attribute float faceMask;
attribute float boundaryFalloffAttr;
varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment varying vec3 vModelPos; // ORIGINAL model-space position → UV computation in fragment
varying vec3 vModelNormal; // model-space face normal → stable UV blending varying vec3 vModelNormal; // model-space face normal → stable UV blending
varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular varying vec3 vViewPos; // view-space position (possibly displaced) → TBN & specular
varying vec3 vNormal; // view-space normal → lighting varying vec3 vNormal; // view-space normal → lighting
varying float vFaceMask; // combined mask (angle + user exclusion) varying float vFaceMask; // combined mask (angle + user exclusion + boundary falloff)
void main() { void main() {
vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0); vec3 safeN = length(normal) > 1e-6 ? normalize(normal) : vec3(0.0, 0.0, 1.0);
@@ -177,7 +178,7 @@ const vertexShader = /* glsl */`
angleMask = min(angleMask, surfaceAngle > bottomAngleLimit ? 1.0 : 0.0); angleMask = min(angleMask, surfaceAngle > bottomAngleLimit ? 1.0 : 0.0);
if (fN.z >= 0.0 && topAngleLimit >= 1.0) if (fN.z >= 0.0 && topAngleLimit >= 1.0)
angleMask = min(angleMask, surfaceAngle > topAngleLimit ? 1.0 : 0.0); angleMask = min(angleMask, surfaceAngle > topAngleLimit ? 1.0 : 0.0);
float totalMask = angleMask * faceMask; float totalMask = angleMask * faceMask * boundaryFalloffAttr;
vFaceMask = totalMask; vFaceMask = totalMask;
if (useDisplacement == 1) { if (useDisplacement == 1) {
@@ -205,6 +206,11 @@ const fragmentShader = /* glsl */`
precision highp float; precision highp float;
${sharedGLSL} ${sharedGLSL}
uniform sampler2D boundaryEdgeTex;
uniform int boundaryEdgeCount;
uniform float boundaryEdgeTexWidth;
uniform float boundaryFalloffDist;
varying vec3 vModelPos; varying vec3 vModelPos;
varying vec3 vModelNormal; varying vec3 vModelNormal;
varying vec3 vViewPos; varying vec3 vViewPos;
@@ -236,6 +242,27 @@ const fragmentShader = /* glsl */`
// ── Combined mask (angle + user exclusion) from vertex shader ──────── // ── Combined mask (angle + user exclusion) from vertex shader ────────
float maskBlend = vFaceMask; float maskBlend = vFaceMask;
// Per-fragment boundary falloff for bump-only mode. On coarse meshes the
// vertex attribute cannot produce a gradient (too few vertices), so we
// compute the distance from each pixel to the nearest boundary edge.
if (useDisplacement == 0 && boundaryFalloffDist > 0.001 && boundaryEdgeCount > 0) {
float minDist = boundaryFalloffDist;
for (int i = 0; i < 512; i++) {
if (i >= boundaryEdgeCount) break;
float uA = (float(i * 2) + 0.5) / boundaryEdgeTexWidth;
float uB = (float(i * 2 + 1) + 0.5) / boundaryEdgeTexWidth;
vec3 ea = texture2D(boundaryEdgeTex, vec2(uA, 0.5)).xyz;
vec3 eb = texture2D(boundaryEdgeTex, vec2(uB, 0.5)).xyz;
vec3 ab = eb - ea;
float abLen2 = dot(ab, ab);
float t = clamp(dot(vModelPos - ea, ab) / max(abLen2, 1e-10), 0.0, 1.0);
float d = length(vModelPos - (ea + t * ab));
minDist = min(minDist, d);
}
maskBlend *= clamp(minDist / boundaryFalloffDist, 0.0, 1.0);
}
h *= maskBlend; h *= maskBlend;
dhx *= maskBlend; dhx *= maskBlend;
dhy *= maskBlend; dhy *= maskBlend;
@@ -323,6 +350,7 @@ export function updateMaterial(material, displacementTexture, settings) {
u.seamBandWidth.value = settings.seamBandWidth ?? 0.35; u.seamBandWidth.value = settings.seamBandWidth ?? 0.35;
u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0; u.symmetricDisplacement.value = settings.symmetricDisplacement ? 1 : 0;
u.useDisplacement.value = settings.useDisplacement ? 1 : 0; u.useDisplacement.value = settings.useDisplacement ? 1 : 0;
u.boundaryFalloffDist.value = settings.boundaryFalloff ?? 0.0;
} }
// ── Internal ────────────────────────────────────────────────────────────────── // ── Internal ──────────────────────────────────────────────────────────────────
@@ -349,6 +377,10 @@ function buildUniforms(tex, settings) {
seamBandWidth: { value: settings.seamBandWidth ?? 0.35 }, seamBandWidth: { value: settings.seamBandWidth ?? 0.35 },
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 }, symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
useDisplacement: { value: settings.useDisplacement ? 1 : 0 }, useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
boundaryEdgeTex: { value: createFallbackDataTexture() },
boundaryEdgeCount: { value: 0 },
boundaryEdgeTexWidth: { value: 1.0 },
boundaryFalloffDist: { value: settings.boundaryFalloff ?? 0.0 },
}; };
} }
@@ -362,3 +394,12 @@ function createFallbackTexture() {
t.wrapS = t.wrapT = THREE.RepeatWrapping; t.wrapS = t.wrapT = THREE.RepeatWrapping;
return t; return t;
} }
function createFallbackDataTexture() {
const data = new Float32Array(4);
const t = new THREE.DataTexture(data, 1, 1, THREE.RGBAFormat, THREE.FloatType);
t.minFilter = THREE.NearestFilter;
t.magFilter = THREE.NearestFilter;
t.needsUpdate = true;
return t;
}