loader: per-part placement transforms + per-ghost partOverrides; fix gradient/decal banding across assembled multi-part figures (per-mesh group-local matrix)
This commit is contained in:
+87
-14
@@ -6,7 +6,14 @@
|
|||||||
*
|
*
|
||||||
* Per-character overrides come from /api/characters (managed in /admin/characters.html):
|
* Per-character overrides come from /api/characters (managed in /admin/characters.html):
|
||||||
* { modelId, opacity, topColor, bottomColor, heightCm, scale,
|
* { modelId, opacity, topColor, bottomColor, heightCm, scale,
|
||||||
* faceTextureUrl, torsoTextureUrl }
|
* faceTextureUrl, torsoTextureUrl,
|
||||||
|
* partOverrides: { <partKey>: { offset:[x,y,z], rotation:[x,y,z], scale } } }
|
||||||
|
*
|
||||||
|
* Part placement: each part in a model may be a bare URL string, or an object
|
||||||
|
* { url, offset:[x,y,z], rotation:[x,y,z](degrees), scale }
|
||||||
|
* The model manifest holds the shared default placement; a character's
|
||||||
|
* partOverrides[key] is merged on top so an individual ghost can be nudged
|
||||||
|
* without disturbing every other ghost that shares the same model.
|
||||||
*
|
*
|
||||||
* Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
|
* Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
|
||||||
* so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
|
* so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
|
||||||
@@ -15,6 +22,9 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
|
|
||||||
|
/* Canonical part order (also the assembly/z-order). Shared with the editor. */
|
||||||
|
export const PART_KEYS = ['legs', 'wisp', 'torso', 'head', 'headpiece'];
|
||||||
|
|
||||||
const objLoader = new OBJLoader();
|
const objLoader = new OBJLoader();
|
||||||
const objCache = new Map();
|
const objCache = new Map();
|
||||||
const texLoader = new THREE.TextureLoader();
|
const texLoader = new THREE.TextureLoader();
|
||||||
@@ -50,6 +60,7 @@ function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
|
|||||||
opacity: { value: opacity },
|
opacity: { value: opacity },
|
||||||
uMinY: { value: 0 },
|
uMinY: { value: 0 },
|
||||||
uMaxY: { value: 1 },
|
uMaxY: { value: 1 },
|
||||||
|
uToGroup: { value: new THREE.Matrix4() }, // mesh-local -> group-local (bakes part transform)
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
faceMap: { value: faceTex || EMPTY_TEX },
|
faceMap: { value: faceTex || EMPTY_TEX },
|
||||||
torsoMap: { value: torsoTex || EMPTY_TEX },
|
torsoMap: { value: torsoTex || EMPTY_TEX },
|
||||||
@@ -64,10 +75,14 @@ function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
|
|||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
varying vec3 vLocal;
|
varying vec3 vLocal;
|
||||||
uniform float uMinY, uMaxY;
|
uniform float uMinY, uMaxY;
|
||||||
|
uniform mat4 uToGroup;
|
||||||
void main() {
|
void main() {
|
||||||
|
// Position within the assembled group (accounts for per-part offset/rotation/scale),
|
||||||
|
// so the gradient + decal bands span the whole figure, not each part's raw origin.
|
||||||
|
vec3 gp = (uToGroup * vec4(position, 1.0)).xyz;
|
||||||
float h = max(uMaxY - uMinY, 0.001);
|
float h = max(uMaxY - uMinY, 0.001);
|
||||||
vY = clamp((position.y - uMinY) / h, 0.0, 1.0);
|
vY = clamp((gp.y - uMinY) / h, 0.0, 1.0);
|
||||||
vLocal = vec3(position.x / h, vY, position.z / h);
|
vLocal = vec3(gp.x / h, vY, gp.z / h);
|
||||||
vNormal = normalize(normalMatrix * normal);
|
vNormal = normalize(normalMatrix * normal);
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
}`,
|
}`,
|
||||||
@@ -118,6 +133,50 @@ async function loadOBJ(url) {
|
|||||||
return obj.clone();
|
return obj.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
/* A part entry may be a bare URL string or { url, offset, rotation, scale }.
|
||||||
|
* Return a normalized { url, offset:[x,y,z], rotation:[x,y,z], scale } or null. */
|
||||||
|
export function normalizePart(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
return { url: entry, offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1 };
|
||||||
|
}
|
||||||
|
if (!entry.url) return null;
|
||||||
|
const o = Array.isArray(entry.offset) ? entry.offset : [0, 0, 0];
|
||||||
|
const r = Array.isArray(entry.rotation) ? entry.rotation : [0, 0, 0];
|
||||||
|
return {
|
||||||
|
url: entry.url,
|
||||||
|
offset: [+o[0] || 0, +o[1] || 0, +o[2] || 0],
|
||||||
|
rotation: [+r[0] || 0, +r[1] || 0, +r[2] || 0],
|
||||||
|
scale: entry.scale != null ? (+entry.scale || 1) : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Merge a per-ghost override on top of the model's default placement.
|
||||||
|
* Only fields present in the override replace the base; url always wins from base. */
|
||||||
|
export function mergePart(base, override) {
|
||||||
|
const b = normalizePart(base);
|
||||||
|
if (!b) return null;
|
||||||
|
if (!override) return b;
|
||||||
|
const ov = override;
|
||||||
|
return {
|
||||||
|
url: b.url,
|
||||||
|
offset: Array.isArray(ov.offset) ? ov.offset.map((v, i) => (v != null ? +v : b.offset[i])) : b.offset,
|
||||||
|
rotation: Array.isArray(ov.rotation) ? ov.rotation.map((v, i) => (v != null ? +v : b.rotation[i])) : b.rotation,
|
||||||
|
scale: ov.scale != null ? +ov.scale : b.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply a normalized transform to a freshly-loaded part group. */
|
||||||
|
function applyPartTransform(obj, p) {
|
||||||
|
obj.position.set(p.offset[0], p.offset[1], p.offset[2]);
|
||||||
|
obj.rotation.set(p.rotation[0] * DEG, p.rotation[1] * DEG, p.rotation[2] * DEG);
|
||||||
|
obj.scale.setScalar(p.scale);
|
||||||
|
obj.userData.partKey = p.key;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
function proceduralWisp() {
|
function proceduralWisp() {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16));
|
const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16));
|
||||||
@@ -162,13 +221,17 @@ export async function buildGhost(ghost, gradients, manifest, characters) {
|
|||||||
|
|
||||||
let group;
|
let group;
|
||||||
const model = pickModel(manifest, ov);
|
const model = pickModel(manifest, ov);
|
||||||
|
const partOv = ov.partOverrides || {};
|
||||||
if (model && model.parts) {
|
if (model && model.parts) {
|
||||||
group = new THREE.Group();
|
group = new THREE.Group();
|
||||||
try {
|
try {
|
||||||
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
|
for (const key of PART_KEYS) {
|
||||||
const url = model.parts[key];
|
const merged = mergePart(model.parts[key], partOv[key]);
|
||||||
if (!url) continue;
|
if (!merged) continue;
|
||||||
group.add(await loadOBJ(url));
|
merged.key = key;
|
||||||
|
const partObj = await loadOBJ(merged.url);
|
||||||
|
applyPartTransform(partObj, merged);
|
||||||
|
group.add(partObj);
|
||||||
}
|
}
|
||||||
if (!group.children.length) group = proceduralWisp();
|
if (!group.children.length) group = proceduralWisp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -179,15 +242,24 @@ export async function buildGhost(ghost, gradients, manifest, characters) {
|
|||||||
group = proceduralWisp();
|
group = proceduralWisp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// gradient/decal mapping needs the model's Y range
|
// gradient/decal mapping needs the model's Y range (group-local, post part-transform)
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
const box = new THREE.Box3().setFromObject(group);
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
|
const groupInv = new THREE.Matrix4().copy(group.matrixWorld).invert();
|
||||||
|
const mats = [];
|
||||||
group.traverse(o => {
|
group.traverse(o => {
|
||||||
if (o.isMesh) {
|
if (o.isMesh) {
|
||||||
o.material = mat;
|
// Each mesh needs its own material instance to carry a per-mesh group-local matrix,
|
||||||
o.material.uniforms.uMinY.value = box.min.y;
|
// but they all share the same tunable uniforms via the tick/opacity setters below.
|
||||||
o.material.uniforms.uMaxY.value = box.max.y;
|
const m = mat.clone();
|
||||||
|
m.uniforms.uMinY.value = box.min.y;
|
||||||
|
m.uniforms.uMaxY.value = box.max.y;
|
||||||
|
m.uniforms.uToGroup.value = new THREE.Matrix4().multiplyMatrices(groupInv, o.matrixWorld);
|
||||||
|
o.material = m;
|
||||||
|
mats.push(m);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!mats.length) mats.push(mat);
|
||||||
|
|
||||||
// Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
|
// Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
|
||||||
const outer = new THREE.Group();
|
const outer = new THREE.Group();
|
||||||
@@ -200,8 +272,9 @@ export async function buildGhost(ghost, gradients, manifest, characters) {
|
|||||||
outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
|
outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
|
||||||
outer.userData.setHeight(4);
|
outer.userData.setHeight(4);
|
||||||
|
|
||||||
outer.userData.material = mat;
|
outer.userData.material = mats[0];
|
||||||
outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v * opacity; };
|
outer.userData.materials = mats;
|
||||||
outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
|
outer.userData.setOpacity = (v) => { for (const m of mats) m.uniforms.opacity.value = v * opacity; };
|
||||||
|
outer.userData.tick = (t) => { for (const m of mats) m.uniforms.uTime.value = t; };
|
||||||
return outer;
|
return outer;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user