feat: enhance cubic mapping with smooth normals and blending weights for improved texture transitions

This commit is contained in:
CNCKitchen
2026-03-19 13:36:21 +01:00
parent 32eddcad37
commit 32cc538bfb
4 changed files with 152 additions and 32 deletions
+8 -7
View File
@@ -55,7 +55,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
// underlying geometry is still faceted (the subdivision didn't change it),
// so printed edges remain sharp.
// ── Pass 1: accumulate area-weighted face normals per unique position ─────
// ── 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)
@@ -137,13 +137,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpPos.fromBufferAttribute(posAttr, t + v);
const k = posKey(tmpPos.x, tmpPos.y, tmpPos.z);
if (userExcluded && excludedPosSet) excludedPosSet.add(k);
tmpNrm.fromBufferAttribute(nrmAttr, t + v);
const existing = smoothNrmMap.get(k);
if (existing) {
existing[0] += faceNrm.x;
existing[1] += faceNrm.y;
existing[2] += faceNrm.z;
existing[0] += tmpNrm.x * faceArea;
existing[1] += tmpNrm.y * faceArea;
existing[2] += tmpNrm.z * faceArea;
} else {
smoothNrmMap.set(k, [faceNrm.x, faceNrm.y, faceNrm.z]);
smoothNrmMap.set(k, [tmpNrm.x * faceArea, tmpNrm.y * faceArea, tmpNrm.z * faceArea]);
}
if (czX > 0 || czY > 0 || czZ > 0) {
const za = zoneAreaMap.get(k);
@@ -176,12 +177,12 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const sn = smoothNrmMap.get(k);
// Cubic: zone-area-weighted sampling with a stable per-face dominant axis.
// Cubic at sharp seams: zone-area-weighted sampling with a stable per-face dominant axis.
// Non-seam vertices use their single zone purely; seam-edge vertices that
// adjoin two zones get a face-area-proportional blend. This guarantees all
// three vertices of every triangle receive consistent displacement, making
// the mesh watertight with no mixed-projection artefact rows at the seam.
if (settings.mappingMode === 6 /* MODE_CUBIC */) {
if (settings.mappingMode === 6 /* MODE_CUBIC */ && (settings.mappingBlend ?? 0) < 0.001) {
const za = zoneAreaMap.get(k);
const total = za ? za[0] + za[1] + za[2] : 0;
if (total > 0) {
+82 -16
View File
@@ -27,6 +27,71 @@ export function getDominantCubicAxis(normal) {
return 'z';
}
export function isAmbiguousCubicNormal(normal) {
const ax = Math.abs(normal.x);
const ay = Math.abs(normal.y);
const az = Math.abs(normal.z);
const axis = getDominantCubicAxis(normal);
const primary = axis === 'x' ? ax : axis === 'y' ? ay : az;
const secondary = axis === 'x' ? Math.max(ay, az) : axis === 'y' ? Math.max(ax, az) : Math.max(ax, ay);
return primary - secondary <= CUBIC_AXIS_EPSILON;
}
export function getCubicBlendWeights(normal, blend) {
const axis = getDominantCubicAxis(normal);
const ax = Math.abs(normal.x);
const ay = Math.abs(normal.y);
const az = Math.abs(normal.z);
const primary = axis === 'x' ? ax : axis === 'y' ? ay : az;
const secondary = axis === 'x' ? Math.max(ay, az) : axis === 'y' ? Math.max(ax, az) : Math.max(ax, ay);
if (blend <= 0.001 || isAmbiguousCubicNormal(normal)) {
return {
x: axis === 'x' ? 1 : 0,
y: axis === 'y' ? 1 : 0,
z: axis === 'z' ? 1 : 0,
};
}
const oneHot = {
x: axis === 'x' ? 1 : 0,
y: axis === 'y' ? 1 : 0,
z: axis === 'z' ? 1 : 0,
};
// Only blend inside a seam band around the cube-face boundary. This keeps
// strongly dominant faces fully textured even when the slider is barely on.
const seamWidth = Math.max(blend * 0.35, CUBIC_AXIS_EPSILON * 2);
const seamMixRaw = 1 - Math.min(1, Math.max(0, (primary - secondary) / seamWidth));
const seamMix = blend * seamMixRaw * seamMixRaw * (3 - 2 * seamMixRaw);
if (seamMix <= 0.001) return oneHot;
// blend=1 should produce a genuinely soft triplanar-style transition.
// Lower blend values progressively sharpen the weights back toward a single
// dominant axis without snapping until the slider reaches zero.
const power = 1 + (1 - seamMix) * 11;
const sx = Math.pow(ax, power);
const sy = Math.pow(ay, power);
const sz = Math.pow(az, power);
const smoothSum = sx + sy + sz + 1e-6;
const smooth = {
x: sx / smoothSum,
y: sy / smoothSum,
z: sz / smoothSum,
};
const mx = oneHot.x * (1 - seamMix) + smooth.x * seamMix;
const my = oneHot.y * (1 - seamMix) + smooth.y * seamMix;
const mz = oneHot.z * (1 - seamMix) + smooth.z * seamMix;
const sum = mx + my + mz;
return {
x: mx / sum,
y: my / sum,
z: mz / sum,
};
}
/**
* Compute normalised UV coordinates [0, 1) (tiling) for a vertex.
*
@@ -118,22 +183,23 @@ export function computeUV(pos, normal, mode, settings, bounds) {
}
case MODE_CUBIC: {
let uRaw, vRaw;
switch (getDominantCubicAxis(normal)) {
case 'x':
uRaw = (pos.y - min.y) / md;
vRaw = (pos.z - min.z) / md;
break;
case 'y':
uRaw = (pos.x - min.x) / md;
vRaw = (pos.z - min.z) / md;
break;
default:
uRaw = (pos.x - min.x) / md;
vRaw = (pos.y - min.y) / md;
break;
}
return applyTransform(uRaw, vRaw, scaleU, scaleV, offsetU, offsetV, rotRad);
const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0);
const tYZ = applyTransform((pos.y - min.y) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXZ = applyTransform((pos.x - min.x) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXY = applyTransform((pos.x - min.x) / md, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
if (weights.x > 0.999) return tYZ;
if (weights.y > 0.999) return tXZ;
if (weights.z > 0.999) return tXY;
return {
triplanar: true,
samples: [
{ u: tXY.u, v: tXY.v, w: weights.z },
{ u: tXZ.u, v: tXZ.v, w: weights.y },
{ u: tYZ.u, v: tYZ.v, w: weights.x },
],
};
}
case MODE_TRIPLANAR:
+42 -6
View File
@@ -54,7 +54,7 @@ const fragmentShader = /* glsl */`
uniform vec3 boundsCenter;
uniform float bottomAngleLimit; // degrees from horizontal; 0 = disabled
uniform float topAngleLimit; // degrees from horizontal; 0 = disabled
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended (cylindrical)
uniform float mappingBlend; // 0 = sharp seams, 1 = fully blended
varying vec3 vModelPos;
varying vec3 vModelNormal;
@@ -72,6 +72,37 @@ const fragmentShader = /* glsl */`
return 2;
}
vec3 cubicBlendWeights(vec3 n) {
vec3 absN = abs(n);
int axis = dominantCubicAxis(n);
float primary = axis == 0 ? absN.x : axis == 1 ? absN.y : absN.z;
float secondary = axis == 0 ? max(absN.y, absN.z)
: axis == 1 ? max(absN.x, absN.z)
: max(absN.x, absN.y);
if (mappingBlend < 0.001 || primary - secondary <= CUBIC_AXIS_EPSILON) {
if (axis == 0) return vec3(1.0, 0.0, 0.0);
if (axis == 1) return vec3(0.0, 1.0, 0.0);
return vec3(0.0, 0.0, 1.0);
}
vec3 oneHot = axis == 0 ? vec3(1.0, 0.0, 0.0)
: axis == 1 ? vec3(0.0, 1.0, 0.0)
: vec3(0.0, 0.0, 1.0);
float seamWidth = max(mappingBlend * 0.35, CUBIC_AXIS_EPSILON * 2.0);
float seamMixRaw = 1.0 - clamp((primary - secondary) / seamWidth, 0.0, 1.0);
float seamMix = mappingBlend * seamMixRaw * seamMixRaw * (3.0 - 2.0 * seamMixRaw);
if (seamMix <= 0.001) return oneHot;
float power = 1.0 + (1.0 - seamMix) * 11.0;
vec3 softWeights = pow(absN, vec3(power));
softWeights /= dot(softWeights, vec3(1.0)) + 1e-6;
vec3 blendedWeights = mix(oneHot, softWeights, seamMix);
return blendedWeights / (dot(blendedWeights, vec3(1.0)) + 1e-6);
}
// Sample after applying scale + tiling
float sampleMap(vec2 rawUV) {
vec2 uv = rawUV / scaleUV + offsetUV;
@@ -142,14 +173,19 @@ const fragmentShader = /* glsl */`
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
} else {
// Cubic (box) always pick exactly one projection per triangle.
// Cubic (box) use smooth normals for blend weights so high blend values
// can hide seams, but fall back to the face-stable triangle normal when
// the triangle sits on an ambiguous near-45° tie.
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md));
float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md));
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md));
int axis = dominantCubicAxis(PN);
if (axis == 0) return hYZ;
if (axis == 1) return hXZ;
return hXY;
vec3 blendN = vModelNormal;
vec3 absFaceN = abs(PN);
float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z));
float faceSecondary = absFaceN.x + absFaceN.y + absFaceN.z - facePrimary - min(absFaceN.x, min(absFaceN.y, absFaceN.z));
if (facePrimary - faceSecondary <= CUBIC_AXIS_EPSILON) blendN = PN;
vec3 wts = cubicBlendWeights(blendN);
return hYZ * wts.x + hXZ * wts.y + hXY * wts.z;
}
}
+20 -3
View File
@@ -262,6 +262,7 @@ function toIndexed(geometry, nonIndexedWeights = null) {
const positions = [];
const normals = [];
const normalSums = [];
const weights = nonIndexedWeights ? [] : null;
const indices = [];
const vertMap = new Map();
@@ -281,15 +282,31 @@ function toIndexed(geometry, nonIndexedWeights = null) {
idx = positions.length / 3;
positions.push(px, py, pz);
normals.push(nx_, ny_, nz_);
normalSums.push(nx_, ny_, nz_);
if (weights) weights.push(nonIndexedWeights[i]);
vertMap.set(key, idx);
} else if (weights && nonIndexedWeights[i] > weights[idx]) {
// MAX: if any incident original face was excluded, the shared vertex is excluded
weights[idx] = nonIndexedWeights[i];
} else {
normalSums[idx * 3] += nx_;
normalSums[idx * 3 + 1] += ny_;
normalSums[idx * 3 + 2] += nz_;
if (weights && nonIndexedWeights[i] > weights[idx]) {
// MAX: if any incident original face was excluded, the shared vertex is excluded
weights[idx] = nonIndexedWeights[i];
}
}
indices.push(idx);
}
for (let i = 0; i < positions.length / 3; i++) {
const nx = normalSums[i * 3];
const ny = normalSums[i * 3 + 1];
const nz = normalSums[i * 3 + 2];
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
normals[i * 3] = nx / len;
normals[i * 3 + 1] = ny / len;
normals[i * 3 + 2] = nz / len;
}
return { positions, normals, weights, indices };
}