update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user