197 lines
7.0 KiB
JavaScript
197 lines
7.0 KiB
JavaScript
/* 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;
|
|
}
|