/* loader.js — ghost visuals. * * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group, * rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor: * Red / Yellow / Blue). Procedural wisp fallback until models are uploaded. * * Per-character overrides come from /api/characters (managed in /admin/characters.html): * { modelId, opacity, topColor, bottomColor, heightCm, scale, * faceTextureUrl, torsoTextureUrl } * * 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 * centre/size, blended over the gradient — a simple decal without needing UV-mapped models. */ import * as THREE from 'three'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; const objLoader = new OBJLoader(); const objCache = new Map(); const texLoader = new THREE.TextureLoader(); const texCache = new Map(); function loadTexture(url) { if (!url) return null; if (!texCache.has(url)) { const t = texLoader.load(url); t.colorSpace = THREE.SRGBColorSpace; texCache.set(url, t); } return texCache.get(url); } function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) { return new THREE.ShaderMaterial({ transparent: true, depthWrite: false, uniforms: { topColor: { value: new THREE.Color(top) }, bottomColor: { value: new THREE.Color(bottom) }, opacity: { value: opacity }, uMinY: { value: 0 }, uMaxY: { value: 1 }, uTime: { value: 0 }, faceMap: { value: faceTex || null }, torsoMap: { value: torsoTex || null }, hasFace: { value: faceTex ? 1 : 0 }, hasTorso: { value: torsoTex ? 1 : 0 }, // [centreY, halfHeight, halfWidth] in normalized model space faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) }, torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) }, }, vertexShader: ` varying float vY; varying vec3 vNormal; varying vec3 vLocal; uniform float uMinY, uMaxY; void main() { float h = max(uMaxY - uMinY, 0.001); vY = clamp((position.y - uMinY) / h, 0.0, 1.0); vLocal = vec3(position.x / h, vY, position.z / h); vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` varying float vY; varying vec3 vNormal; varying vec3 vLocal; uniform vec3 topColor, bottomColor; uniform float opacity, uTime; uniform sampler2D faceMap, torsoMap; uniform int hasFace, hasTorso; uniform vec3 faceBand, torsoBand; // planar-front decal: map local X/Y into the band's UV box vec4 decal(sampler2D map, vec3 band) { vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5, ((vLocal.y - band.x) / band.y) * 0.5 + 0.5); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0); return texture2D(map, uv); } void main() { vec3 c = mix(bottomColor, topColor, vY); float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0); c += rim * 0.35; // only decal the front-facing side so images don't mirror onto the back float front = smoothstep(0.0, 0.35, vNormal.z); if (hasTorso == 1) { vec4 t = decal(torsoMap, torsoBand); c = mix(c, t.rgb, t.a * front); } if (hasFace == 1) { vec4 f = decal(faceMap, faceBand); c = mix(c, f.rgb, f.a * front); } float pulse = 0.92 + 0.08 * sin(uTime * 2.2); gl_FragColor = vec4(c, opacity * pulse); }`, }); } async function loadOBJ(url) { if (objCache.has(url)) return objCache.get(url).clone(); const obj = await objLoader.loadAsync(url); objCache.set(url, obj); return obj.clone(); } function proceduralWisp() { const g = new THREE.Group(); const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16)); body.scale.set(1, 1.35, 1); body.position.y = 14; const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16)); tail.rotation.x = Math.PI; tail.position.y = 0; g.add(body, tail); return g; } /* Resolve the override chain: character-specific -> defaults -> built-in. */ function resolveOverrides(ghost, characters) { const d = (characters && characters.defaults) || {}; const c = (characters && characters.byId && characters.byId[ghost.id]) || {}; return { ...d, ...c }; } function pickModel(manifest, ov) { const models = (manifest && manifest.models) || []; if (!models.length) return null; if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0]; return models[0]; } export async function buildGhost(ghost, gradients, manifest, characters) { const ov = resolveOverrides(ghost, characters); const grad = gradients[ghost.color] || gradients.Blue; const top = ov.topColor || grad.top; const bottom = ov.bottomColor || grad.bottom; const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92; const bands = { faceY: ov.faceY != null ? Number(ov.faceY) : 0.82, faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16, torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52, torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22, }; const mat = gradientMaterial(top, bottom, opacity, loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands); let group; const model = pickModel(manifest, ov); if (model && model.parts) { group = new THREE.Group(); try { for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) { const url = model.parts[key]; if (!url) continue; group.add(await loadOBJ(url)); } if (!group.children.length) group = proceduralWisp(); } catch (e) { console.warn('ghost model load failed, using wisp fallback', e); group = proceduralWisp(); } } else { group = proceduralWisp(); } // gradient/decal mapping needs the model's Y range const box = new THREE.Box3().setFromObject(group); group.traverse(o => { if (o.isMesh) { o.material = mat; o.material.uniforms.uMinY.value = box.min.y; o.material.uniforms.uMaxY.value = box.max.y; } }); // Normalize: feet at origin, then setHeight(cm) scales any source units to world height. const outer = new THREE.Group(); const inner = new THREE.Group(); inner.position.y = -box.min.y; inner.add(group); outer.add(inner); const rawH = Math.max(box.max.y - box.min.y, 1e-6); const modelScale = (ov.scale || model?.scale || 1); outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale); outer.userData.setHeight(4); outer.userData.material = mat; outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v * opacity; }; outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; return outer; }