/* loader.js — ghost visuals. * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group, * every part rendered with the recovered Hidden Side gradient shader (top->bottom tint * by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback * is used so the exhibit always has something to show. * * Model manifest (data/models.json, editable via /api/models): * { "models": [ { "id": "classic", "scale": 1.0, * "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] } */ import * as THREE from 'three'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; const objLoader = new OBJLoader(); const objCache = new Map(); function gradientMaterial(top, bottom, opacity = 0.92) { 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 }, }, vertexShader: ` varying float vY; varying vec3 vNormal; uniform float uMinY, uMaxY; void main() { vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0); vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` varying float vY; varying vec3 vNormal; uniform vec3 topColor, bottomColor; uniform float opacity, uTime; 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; 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(0.09, 20, 16)); body.scale.set(1, 1.35, 1); body.position.y = 0.14; const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16)); tail.rotation.x = Math.PI; tail.position.y = 0.0; g.add(body, tail); return g; } export async function buildGhost(ghost, gradients, manifest) { const grad = gradients[ghost.color] || gradients.Blue; const mat = gradientMaterial(grad.top, grad.bottom); let group; const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later 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 (model.scale) group.scale.setScalar(model.scale); if (!group.children.length) group = proceduralWisp(); } catch (e) { console.warn('ghost model load failed, using wisp fallback', e); group = proceduralWisp(); } } else { group = proceduralWisp(); } // apply gradient material to every mesh; compute Y-range for gradient mapping 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; } }); group.userData.material = mat; group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; }; group.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; return group; }