feat: add texture aspect correction for non-square textures in UV mapping

This commit is contained in:
CNCKitchen
2026-04-02 17:23:24 +02:00
parent ccf77c988a
commit 7289c2cabc
4 changed files with 89 additions and 23 deletions
+24 -7
View File
@@ -36,6 +36,14 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const edge2 = new THREE.Vector3(); const edge2 = new THREE.Vector3();
const faceNrm = new THREE.Vector3(); const faceNrm = new THREE.Vector3();
// Texture aspect correction so non-square textures keep their proportions.
// The shorter axis gets aspect > 1 so it tiles faster, making each tile
// proportionally shorter in world-space to match the texture's content.
const tmax = Math.max(imgWidth, imgHeight, 1);
const aspectU = tmax / Math.max(imgWidth, 1);
const aspectV = tmax / Math.max(imgHeight, 1);
const settingsWithAspect = { ...settings, textureAspectU: aspectU, textureAspectV: aspectV };
const QUANT = 1e4; const QUANT = 1e4;
const posKey = (x, y, z) => const posKey = (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)}`;
@@ -203,15 +211,21 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
let grey = 0; let grey = 0;
if (za[0] > 0) { // X-dominant zone → YZ projection if (za[0] > 0) { // X-dominant zone → YZ projection
const uv = _cubicUV((tmpPos.y-bounds.min.y)/md, (tmpPos.z-bounds.min.z)/md, settings, rotRad); let rawU = (tmpPos.y-bounds.min.y)/md;
if (sn[0] < 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) * (za[0]/total);
} }
if (za[1] > 0) { // Y-dominant zone → XZ projection if (za[1] > 0) { // Y-dominant zone → XZ projection
const uv = _cubicUV((tmpPos.x-bounds.min.x)/md, (tmpPos.z-bounds.min.z)/md, settings, rotRad); let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[1] > 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) * (za[1]/total);
} }
if (za[2] > 0) { // Z-dominant zone → XY projection if (za[2] > 0) { // Z-dominant zone → XY projection
const uv = _cubicUV((tmpPos.x-bounds.min.x)/md, (tmpPos.y-bounds.min.y)/md, settings, rotRad); let rawU = (tmpPos.x-bounds.min.x)/md;
if (sn[2] < 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) * (za[2]/total);
} }
dispCache.set(k, grey); dispCache.set(k, grey);
@@ -221,7 +235,7 @@ export function applyDisplacement(geometry, imageData, imgWidth, imgHeight, sett
tmpNrm.set(sn[0], sn[1], sn[2]); tmpNrm.set(sn[0], sn[1], sn[2]);
const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settings, bounds); const uvResult = computeUV(tmpPos, tmpNrm, settings.mappingMode, settingsWithAspect, bounds);
let grey; let grey;
if (uvResult.triplanar) { if (uvResult.triplanar) {
grey = 0; grey = 0;
@@ -321,6 +335,9 @@ function sampleBilinear(data, w, h, u, v) {
// Ensure [0,1) — guard against floating-point edge cases // Ensure [0,1) — guard against floating-point edge cases
u = ((u % 1) + 1) % 1; u = ((u % 1) + 1) % 1;
v = ((v % 1) + 1) % 1; v = ((v % 1) + 1) % 1;
// Flip V to match WebGL/Three.js texture convention (flipY=true means
// v=0 is the bottom of the image, but ImageData row 0 is the top).
v = 1 - v;
const fx = u * (w - 1); const fx = u * (w - 1);
const fy = v * (h - 1); const fy = v * (h - 1);
@@ -345,9 +362,9 @@ function sampleBilinear(data, w, h, u, v) {
/** Apply scale/offset/rotation to raw UV for cubic projection. /** Apply scale/offset/rotation to raw UV for cubic projection.
* Mirrors the private applyTransform helper in mapping.js. */ * Mirrors the private applyTransform helper in mapping.js. */
function _cubicUV(rawU, rawV, settings, rotRad) { function _cubicUV(rawU, rawV, settings, rotRad, aspectU, aspectV) {
let u = rawU / settings.scaleU + settings.offsetU; let u = (rawU * aspectU) / settings.scaleU + settings.offsetU;
let v = rawV / settings.scaleV + settings.offsetV; let v = (rawV * aspectV) / settings.scaleV + settings.offsetV;
if (rotRad !== 0) { if (rotRad !== 0) {
const c = Math.cos(rotRad), s = Math.sin(rotRad); const c = Math.cos(rotRad), s = Math.sin(rotRad);
u -= 0.5; v -= 0.5; u -= 0.5; v -= 0.5;
+12 -1
View File
@@ -1423,7 +1423,18 @@ function getEffectiveMapEntry() {
function updatePreview() { function updatePreview() {
if (!currentGeometry || !currentBounds) return; if (!currentGeometry || !currentBounds) return;
const fullSettings = { ...settings, bounds: currentBounds }; // Texture aspect correction so non-square textures keep their proportions.
// A 512×279 texture needs aspectV = 512/279 ≈ 1.84 so V tiles faster (more
// repetitions), making each tile shorter in world-space to match the texture's
// wider-than-tall content. The wider axis gets aspect = 1 (unchanged).
const tw = activeMapEntry?.width ?? 1, th = activeMapEntry?.height ?? 1;
const tmax = Math.max(tw, th, 1);
const fullSettings = {
...settings,
bounds: currentBounds,
textureAspectU: tmax / Math.max(tw, 1),
textureAspectV: tmax / Math.max(th, 1),
};
if (!activeMapEntry) { if (!activeMapEntry) {
// No map yet — plain material // No map yet — plain material
+28 -7
View File
@@ -104,7 +104,13 @@ export function getCubicBlendWeights(normal, blend, seamBandWidth = 0.35) {
*/ */
export function computeUV(pos, normal, mode, settings, bounds) { export function computeUV(pos, normal, mode, settings, bounds) {
const { min, size, center } = bounds; const { min, size, center } = bounds;
const { scaleU, scaleV, offsetU, offsetV } = settings; // Compensate for non-square textures: divide scale by aspect correction
// so equal world-space distances produce equal physical texture distances.
const aU = settings.textureAspectU ?? 1;
const aV = settings.textureAspectV ?? 1;
const scaleU = (settings.scaleU) / aU;
const scaleV = (settings.scaleV) / aV;
const { offsetU, offsetV } = settings;
const rotRad = (settings.rotation ?? 0) * Math.PI / 180; const rotRad = (settings.rotation ?? 0) * Math.PI / 180;
const maxDim = Math.max(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z);
const md = Math.max(maxDim, 1e-6); const md = Math.max(maxDim, 1e-6);
@@ -230,9 +236,17 @@ export function computeUV(pos, normal, mode, settings, bounds) {
case MODE_CUBIC: { case MODE_CUBIC: {
const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0, settings.seamBandWidth ?? 0.35); const weights = getCubicBlendWeights(normal, settings.mappingBlend ?? 0.0, settings.seamBandWidth ?? 0.35);
const tYZ = applyTransform((pos.y - min.y) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); // Flip U based on normal sign so opposite faces show correct (non-mirrored) text.
const tXZ = applyTransform((pos.x - min.x) / md, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad); // Derived from camera right = view_dir × up_dir for each face orientation (Z-up).
const tXY = applyTransform((pos.x - min.x) / md, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad); let yzU = (pos.y - min.y) / md;
if (normal.x < 0) yzU = -yzU;
let xzU = (pos.x - min.x) / md;
if (normal.y > 0) xzU = -xzU;
let xyU = (pos.x - min.x) / md;
if (normal.z < 0) xyU = -xyU;
const tYZ = applyTransform(yzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXZ = applyTransform(xzU, (pos.z - min.z) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
const tXY = applyTransform(xyU, (pos.y - min.y) / md, scaleU, scaleV, offsetU, offsetV, rotRad);
if (weights.x > 0.999) return tYZ; if (weights.x > 0.999) return tYZ;
if (weights.y > 0.999) return tXZ; if (weights.y > 0.999) return tXZ;
@@ -263,18 +277,25 @@ export function computeUV(pos, normal, mode, settings, bounds) {
const wy = by / sum; const wy = by / sum;
const wz = bz / sum; const wz = bz / sum;
// Flip U based on normal sign so opposite faces show correct (non-mirrored) text.
let yzU = (pos.y - min.y) / md;
if (normal.x < 0) yzU = -yzU;
let xzU = (pos.x - min.x) / md;
if (normal.y > 0) xzU = -xzU;
let xyU = (pos.x - min.x) / md;
if (normal.z < 0) xyU = -xyU;
const uvXY = { const uvXY = {
u: (pos.x - min.x) / md, u: xyU,
v: (pos.y - min.y) / md, v: (pos.y - min.y) / md,
w: wz, w: wz,
}; };
const uvXZ = { const uvXZ = {
u: (pos.x - min.x) / md, u: xzU,
v: (pos.z - min.z) / md, v: (pos.z - min.z) / md,
w: wy, w: wy,
}; };
const uvYZ = { const uvYZ = {
u: (pos.y - min.y) / md, u: yzU,
v: (pos.z - min.z) / md, v: (pos.z - min.z) / md,
w: wx, w: wx,
}; };
+25 -8
View File
@@ -38,6 +38,7 @@ const sharedGLSL = /* glsl */`
uniform float capAngle; uniform float capAngle;
uniform int symmetricDisplacement; uniform int symmetricDisplacement;
uniform int useDisplacement; uniform int useDisplacement;
uniform vec2 textureAspect;
const float PI = 3.14159265358979; const float PI = 3.14159265358979;
const float TWO_PI = 6.28318530717959; const float TWO_PI = 6.28318530717959;
@@ -81,9 +82,9 @@ const sharedGLSL = /* glsl */`
return blendedWeights / (dot(blendedWeights, vec3(1.0)) + 1e-6); return blendedWeights / (dot(blendedWeights, vec3(1.0)) + 1e-6);
} }
// Sample after applying scale + tiling // Sample after applying scale + tiling (aspect-corrected)
float sampleMap(vec2 rawUV) { float sampleMap(vec2 rawUV) {
vec2 uv = rawUV / scaleUV + offsetUV; vec2 uv = (rawUV * textureAspect) / scaleUV + offsetUV;
float c = cos(rotation); float s = sin(rotation); float c = cos(rotation); float s = sin(rotation);
uv -= 0.5; uv -= 0.5;
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
@@ -159,15 +160,29 @@ const sharedGLSL = /* glsl */`
vec3 blend = abs(projN); vec3 blend = abs(projN);
blend = pow(blend, vec3(4.0)); blend = pow(blend, vec3(4.0));
blend /= dot(blend, vec3(1.0)) + 1e-4; blend /= dot(blend, vec3(1.0)) + 1e-4;
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); // Flip U based on normal sign so opposite faces show correct (non-mirrored) text.
float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); float yzU = (pos.y - boundsMin.y) / md;
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); if (projN.x < 0.0) yzU = -yzU;
float xzU = (pos.x - boundsMin.x) / md;
if (projN.y > 0.0) xzU = -xzU;
float xyU = (pos.x - boundsMin.x) / md;
if (projN.z < 0.0) xyU = -xyU;
float hXY = sampleMap(vec2(xyU, (pos.y - boundsMin.y) / md));
float hXZ = sampleMap(vec2(xzU, (pos.z - boundsMin.z) / md));
float hYZ = sampleMap(vec2(yzU, (pos.z - boundsMin.z) / md));
return hXY * blend.z + hXZ * blend.y + hYZ * blend.x; return hXY * blend.z + hXZ * blend.y + hYZ * blend.x;
} else { } else {
float hYZ = sampleMap(vec2((pos.y - boundsMin.y) / md, (pos.z - boundsMin.z) / md)); // Flip U based on normal sign so opposite faces show correct (non-mirrored) text.
float hXZ = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.z - boundsMin.z) / md)); float yzU = (pos.y - boundsMin.y) / md;
float hXY = sampleMap(vec2((pos.x - boundsMin.x) / md, (pos.y - boundsMin.y) / md)); if (projN.x < 0.0) yzU = -yzU;
float xzU = (pos.x - boundsMin.x) / md;
if (projN.y > 0.0) xzU = -xzU;
float xyU = (pos.x - boundsMin.x) / md;
if (projN.z < 0.0) xyU = -xyU;
float hYZ = sampleMap(vec2(yzU, (pos.z - boundsMin.z) / md));
float hXZ = sampleMap(vec2(xzU, (pos.z - boundsMin.z) / md));
float hXY = sampleMap(vec2(xyU, (pos.y - boundsMin.y) / md));
vec3 bN = blendN; vec3 bN = blendN;
vec3 absFaceN = abs(projN); vec3 absFaceN = abs(projN);
float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z)); float facePrimary = max(absFaceN.x, max(absFaceN.y, absFaceN.z));
@@ -355,6 +370,7 @@ export function updateMaterial(material, displacementTexture, settings) {
u.capAngle.value = settings.capAngle ?? 20.0; u.capAngle.value = settings.capAngle ?? 20.0;
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.textureAspect.value.set(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1);
} }
// ── Internal ────────────────────────────────────────────────────────────────── // ── Internal ──────────────────────────────────────────────────────────────────
@@ -382,6 +398,7 @@ function buildUniforms(tex, settings) {
capAngle: { value: settings.capAngle ?? 20.0 }, capAngle: { value: settings.capAngle ?? 20.0 },
symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 }, symmetricDisplacement: { value: settings.symmetricDisplacement ? 1 : 0 },
useDisplacement: { value: settings.useDisplacement ? 1 : 0 }, useDisplacement: { value: settings.useDisplacement ? 1 : 0 },
textureAspect: { value: new THREE.Vector2(settings.textureAspectU ?? 1, settings.textureAspectV ?? 1) },
}; };
} }