diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js index 240a5d3..da7fa14 100644 --- a/public/js/ghosts/loader.js +++ b/public/js/ghosts/loader.js @@ -20,10 +20,20 @@ const objCache = new Map(); const texLoader = new THREE.TextureLoader(); const texCache = new Map(); +/* A sampler2D uniform must ALWAYS be bound to a real texture: leaving it null makes + * the shader program fail to link on many GPUs, which would break every untextured + * ghost. This 1x1 fully-transparent pixel is the safe no-op binding. */ +const EMPTY_TEX = (() => { + const t = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat); + t.needsUpdate = true; + return t; +})(); + function loadTexture(url) { if (!url) return null; if (!texCache.has(url)) { - const t = texLoader.load(url); + const t = texLoader.load(url, undefined, undefined, + () => console.warn('texture failed to load:', url)); // keeps the ghost alive t.colorSpace = THREE.SRGBColorSpace; texCache.set(url, t); } @@ -41,8 +51,8 @@ function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) { uMinY: { value: 0 }, uMaxY: { value: 1 }, uTime: { value: 0 }, - faceMap: { value: faceTex || null }, - torsoMap: { value: torsoTex || null }, + faceMap: { value: faceTex || EMPTY_TEX }, + torsoMap: { value: torsoTex || EMPTY_TEX }, hasFace: { value: faceTex ? 1 : 0 }, hasTorso: { value: torsoTex ? 1 : 0 }, // [centreY, halfHeight, halfWidth] in normalized model space @@ -136,7 +146,8 @@ function pickModel(manifest, ov) { export async function buildGhost(ghost, gradients, manifest, characters) { const ov = resolveOverrides(ghost, characters); - const grad = gradients[ghost.color] || gradients.Blue; + const grad = (gradients && (gradients[ghost.color] || gradients.Blue)) + || { top: '#51eaf1', bottom: '#529eff' }; // survive a missing/!oddly-shaped gradient payload const top = ov.topColor || grad.top; const bottom = ov.bottomColor || grad.bottom; const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92;