lightloom / frontend /js /stage3d.js
Efradeca's picture
chore: deploy private lightloom build
8422b0e verified
Raw
History Blame Contribute Delete
32.1 kB
/* Lightloom · frontend/js/stage3d.js
* ============================================================================
* "La Sala" — TRUE 3D cinema renderer (Three.js).
*
* This REPLACES the 2.5D UV-parallax shader in stage-gl.js with GENUINE 3D
* geometry: each beat is a high-resolution PlaneGeometry whose vertices are
* physically displaced along Z by the depth map (white = near = popped toward
* the lens), textured with the painted frame. We then fly a REAL perspective
* camera through that relief. There is no UV trick here — the silhouette of a
* foreground subject genuinely occludes the background as the camera translates,
* because the foreground vertices are actually closer to the camera in world
* space.
*
* It exposes the EXACT SAME interface as stage-gl.js so the rest of the app is
* none the wiser:
*
* LL.stage.init(canvas)
* LL.stage.revealBeat({ imageUrl, depthUrl, cameraMove, transition, reducedMotion })
* -> Promise that resolves after the ~700ms reveal + transition.
* LL.stage.setReducedMotion(flag)
* LL.stage.dispose()
*
* Load contract: this module loads AFTER stage-gl.js. If — and only if —
* Three.js imports cleanly AND a WebGL2 context can be created AND init()
* succeeds, we OVERWRITE window.LL.stage with this true-3D version. If anything
* fails (no Three, no WebGL2, a throw during init), we leave the existing
* stage-gl.js LL.stage untouched as the graceful fallback. Nothing here ever
* hangs a Promise: a failed texture load still resolves the reveal.
*
* Vanilla ES module. Three.js comes from the index.html import map; no build,
* no npm. Shader math (the vertex displacement) is commented inline.
* ==========================================================================*/
"use strict";
import * as THREE from "three";
window.LL = window.LL || {};
/* ---------------------------------------------------------------------------
* Tunables. Kept here, not magic-numbered through the code. The plane is built
* in world units sized to roughly fill the camera frustum at z=0; depth pushes
* vertices toward/away from the lens by DEPTH_K world units about the mid-plane.
* ------------------------------------------------------------------------- */
const CFG = {
PLANE_W: 4.78, // plane width in world units (2.39 * 2, the film aspect)
PLANE_H: 2.0, // plane height in world units
SEG_X: 256, // horizontal mesh segments (true geometry, not a quad)
SEG_Y: 144, // vertical mesh segments — 256x144 ~ a 16:9-ish grid
DEPTH_K: 0.34, // Z displacement gain in world units (near pops by ~K/2)
CAM_Z: 2.35, // resting camera distance from the plane (frames 2.39:1)
CAM_FOV: 38, // resting vertical field of view (deg)
REVEAL_MS: 700, // frame reveal duration (the contract window)
TRANSITION_MS: 700, // beat-to-beat transition duration
ABERRATION_MS: 420, // chromatic-aberration flutter only this long
MOVE_AMP: 0.42, // base camera translation amplitude (world units)
DOLLY_AMP: 0.85, // dolly push/pull distance (world units)
FOV_PUSH: 4.0, // extra FOV degrees a dolly_in adds (subtle "vertigo")
TILT_AMP: 0.06, // camera pitch amplitude for tilt/crane (radians)
DRIFT_AMP: 0.05, // brownian idle drift amplitude (world units) for "static"
IDLE_BREATH: 0.018, // post-reveal live idle sway so a held frame breathes
GRAIN: 0.05, // film-grain opacity in the post pass
VIGNETTE: 0.34, // vignette darkening at the corners
ABERR_MAX: 0.0035, // peak chromatic-aberration UV split during a transition
};
/* The Director's camera_move enum. Each entry is resolved into an actual camera
* pose delta (translate / dolly / pitch / fov) that is eased in across the
* reveal, then settles into a gentle live idle. */
const CAMERA_MOVES = {
static: { tx: 0, ty: 0, dz: 0, pitch: 0, fov: 0, drift: true },
dolly_in: { tx: 0, ty: 0, dz: -CFG.DOLLY_AMP, pitch: 0, fov: CFG.FOV_PUSH, drift: false },
dolly_out: { tx: 0, ty: 0, dz: CFG.DOLLY_AMP, pitch: 0, fov: 0, drift: false },
pan_left: { tx: -CFG.MOVE_AMP, ty: 0, dz: 0, pitch: 0, fov: 0, drift: false },
pan_right: { tx: CFG.MOVE_AMP, ty: 0, dz: 0, pitch: 0, fov: 0, drift: false },
tilt_up: { tx: 0, ty: CFG.MOVE_AMP * 0.6, dz: 0, pitch: CFG.TILT_AMP, fov: 0, drift: false },
crane_down: { tx: 0, ty: -CFG.MOVE_AMP * 0.7, dz: -CFG.DOLLY_AMP * 0.4, pitch: -CFG.TILT_AMP * 0.6, fov: 0, drift: false },
};
/* Director transition enum -> internal id. */
const TRANS = { hard_cut: "hard_cut", crossfade: "crossfade", wipe_left: "wipe_left", iris: "iris" };
/* ---------------------------------------------------------------------------
* Small helpers.
* ------------------------------------------------------------------------- */
/* classic cubic smoothstep ease, clamped to [0,1]. */
function smoothstep01(x) {
const t = Math.max(0, Math.min(1, x));
return t * t * (3 - 2 * t);
}
/* Are we in reduced-motion? Any of: explicit arg, body class, OS preference. */
function isReducedMotion(explicit) {
if (explicit) return true;
if (document.body && document.body.classList.contains("reduced-motion")) return true;
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
/* ===========================================================================
* MESH MATERIAL — the genuine depth-displacement shader.
* ---------------------------------------------------------------------------
* Vertex shader: every vertex of the plane carries a UV. We sample the depth
* texture at that UV and move the vertex along the plane's local +Z (toward the
* camera) by:
*
* z = DEPTH_K * (depth - 0.5)
*
* The depth map convention (backend-normalized) is WHITE = 1.0 = NEAR. So a
* white pixel (depth 1) pushes the vertex by +DEPTH_K*0.5 toward the lens, and a
* black pixel (depth 0) recedes by -DEPTH_K*0.5. The mid-plane (depth 0.5) stays
* put — that's the pivot the camera frames. Because this is REAL geometry, when
* the camera translates sideways the near vertices sweep across the far ones and
* genuinely occlude them (true parallax + disocclusion, not a UV smear).
*
* Fragment shader: just sample the color frame, modulated by u_opacity (used for
* crossfade) and a wipe/iris reveal mask (u_wipe / u_iris) so transitions are
* per-fragment on the actual 3D surface.
* ===========================================================================*/
const MESH_VERT = /* glsl */ `
precision highp float;
uniform sampler2D u_depth; // grayscale depth, white(1) = near
uniform float u_depthK; // displacement gain in world units (0 in reduced motion)
uniform float u_hasDepth; // 1.0 if a real depth map is bound, else 0 (flat)
varying vec2 v_uv;
void main() {
v_uv = uv;
// Sample depth (red channel). Clamp the UV so edge taps never wrap.
vec2 duv = clamp(uv, 0.0, 1.0);
float depth = texture2D(u_depth, duv).r;
// White = near. Displace along local +Z toward the camera about the 0.5
// mid-plane. u_hasDepth gates this to a flat plane when no depth is supplied.
float dz = u_depthK * (depth - 0.5) * u_hasDepth;
vec3 displaced = position + vec3(0.0, 0.0, dz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
`;
const MESH_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D u_image; // painted color frame
uniform float u_opacity; // 0..1 (crossfade)
uniform int u_revealMode; // 0 none, 1 wipe_left, 2 iris
uniform float u_reveal; // 0..1 reveal progress for wipe/iris
uniform float u_aspect; // film aspect for a round iris
varying vec2 v_uv;
void main() {
vec2 uv = clamp(v_uv, 0.0, 1.0);
vec4 c = texture2D(u_image, uv);
float mask = 1.0;
if (u_revealMode == 1) {
// wipe_left: the revealed region grows from the left edge. At u_reveal=0
// nothing of the new beat shows; at u_reveal=1 it fully covers. A 0.06-wide
// smoothstep band softens the seam so it reads as a film wipe, not a cut.
// mask = 1 where uv.x < edge (already revealed), fading to 0 just past it.
float edge = u_reveal;
mask = smoothstep(edge + 0.06, edge - 0.06, uv.x);
} else if (u_revealMode == 2) {
// iris: a circular aperture opens from center, aspect-corrected to a true
// circle on the 2.39:1 letterbox.
// iris: a circular aperture opens from the center. Inside the aperture
// (r < radius) the new beat is visible (mask=1); outside it's hidden
// (mask=0). The radius grows with u_reveal until it covers the corners.
vec2 p = uv - 0.5;
p.x *= u_aspect;
float r = length(p);
float maxR = 0.5 * sqrt(u_aspect * u_aspect + 1.0) + 0.05;
float radius = u_reveal * maxR;
// Reversed edges => 1.0 inside the aperture, 0.0 outside, soft 0.03 rim.
mask = smoothstep(radius + 0.03, radius - 0.03, r);
}
float a = c.a * u_opacity * mask;
if (a <= 0.001) discard; // let the recessed backdrop show through gaps
gl_FragColor = vec4(c.rgb, a);
}
`;
/* ===========================================================================
* POST-FX — full-screen pass: animated grain, vignette, transition-only
* chromatic aberration. Runs every frame so the image is never frozen.
* ===========================================================================*/
const POST_VERT = /* glsl */ `
precision highp float;
varying vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const POST_FRAG = /* glsl */ `
precision highp float;
uniform sampler2D u_tex; // rendered scene
uniform vec2 u_res; // canvas px
uniform float u_time; // seconds (animated grain)
uniform float u_grain; // grain opacity
uniform float u_vignette; // vignette strength
uniform float u_aberr; // chromatic aberration amount (0 except in transition)
uniform float u_static; // 1 => freeze grain (reduced motion)
varying vec2 v_uv;
// Cheap sin-dot hash for grain — no noise texture needed.
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
void main() {
vec2 uv = v_uv;
vec2 dir = uv - 0.5;
// --- Chromatic aberration (transition-only) --------------------------
// Split RGB radially outward; corners shimmer more than the focal center.
float ca = u_aberr;
vec3 col;
col.r = texture2D(u_tex, uv + dir * ca).r;
col.g = texture2D(u_tex, uv).g;
col.b = texture2D(u_tex, uv - dir * ca).b;
// --- Animated film grain --------------------------------------------
// Seed advances at ~24fps unless frozen (reduced motion). Centered +/-0.5.
float tSeed = u_static > 0.5 ? 0.0 : floor(u_time * 24.0);
float g = hash(uv * u_res + tSeed) - 0.5;
col += g * u_grain;
// --- Radial vignette -------------------------------------------------
float d = length(dir) * 1.414; // center 0, corner ~1
float vig = 1.0 - u_vignette * smoothstep(0.45, 1.0, d);
col *= vig;
gl_FragColor = vec4(col, 1.0);
}
`;
/* ===========================================================================
* THE RENDERER (true 3D).
* ===========================================================================*/
const Stage = (() => {
let renderer = null;
let canvas = null;
let scene = null;
let camera = null;
// Offscreen render target + post-FX full-screen pass.
let rt = null;
let postScene = null;
let postCamera = null;
let postMat = null;
let backdrop = null; // recessed dark plane that hides disocclusion gaps
let loader = null; // THREE.TextureLoader
// Beat meshes. `cur` is the live beat; `prev` is the outgoing beat during a
// transition (disposed when the transition completes).
let cur = null; // { mesh, mat, imgTex, depthTex, move, revealAt }
let prev = null;
let haveFirstBeat = false;
let reducedFlag = false; // explicit setReducedMotion() flag
// Camera home pose (the resting frame) + the live target deltas.
const camHome = { x: 0, y: 0, z: CFG.CAM_Z, pitch: 0, fov: CFG.CAM_FOV };
// Brownian drift accumulator for "static" shots.
const drift = { x: 0, y: 0, tx: 0, ty: 0, nextRetarget: 0 };
// In-flight transition state.
const trans = {
active: false,
mode: TRANS.crossfade,
t0: 0,
durMs: CFG.TRANSITION_MS,
aberrMs: CFG.ABERRATION_MS,
resolve: null,
};
let rafId = 0;
let startTime = 0;
let disposed = false;
/* --- init --------------------------------------------------------------- */
function init(cv) {
canvas = cv;
// Acquire the WebGL2 context ourselves first so we can bail cleanly (and
// leave stage-gl.js in place) before Three.js touches the canvas or prints
// to the console. We then hand this SAME context to the renderer via the
// `context` option — in r160 the renderer uses a supplied context directly
// instead of calling canvas.getContext() again (which would fail, since a
// canvas only ever yields one GL context).
const ctx = cv.getContext("webgl2", { antialias: false });
if (!ctx) throw new Error("WebGL2 unavailable");
renderer = new THREE.WebGLRenderer({
canvas: cv,
context: ctx,
antialias: true,
alpha: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
});
renderer.setClearColor(0x0a0a0c, 1); // --bg #0A0A0C, the dark projection room
renderer.outputColorSpace = THREE.SRGBColorSpace;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0c);
camera = new THREE.PerspectiveCamera(CFG.CAM_FOV, 2.39, 0.05, 100);
camera.position.set(0, 0, CFG.CAM_Z);
camera.lookAt(0, 0, 0);
loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
// Recessed dark backdrop a little behind the relief. When the camera
// translates and a foreground silhouette disoccludes the background, the
// stretched edge fragments are discarded (alpha) and this near-black plane
// shows through instead of a bright gutter.
const bgGeo = new THREE.PlaneGeometry(CFG.PLANE_W * 1.6, CFG.PLANE_H * 1.6);
const bgMat = new THREE.MeshBasicMaterial({ color: 0x06060a });
backdrop = new THREE.Mesh(bgGeo, bgMat);
backdrop.position.z = -(CFG.DEPTH_K * 0.5 + 0.25);
scene.add(backdrop);
setupPost();
resize();
window.addEventListener("resize", resize, { passive: true });
startTime = performance.now() / 1000;
loop();
}
/* Full-screen post pass: render the scene into an offscreen RT, then draw a
* unit quad textured with that RT through the grain/vignette/aberration shader
* to the default framebuffer. */
function setupPost() {
const size = new THREE.Vector2();
renderer.getDrawingBufferSize(size);
rt = new THREE.WebGLRenderTarget(Math.max(2, size.x), Math.max(2, size.y), {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: true,
stencilBuffer: false,
});
postScene = new THREE.Scene();
postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
postMat = new THREE.ShaderMaterial({
vertexShader: POST_VERT,
fragmentShader: POST_FRAG,
uniforms: {
u_tex: { value: rt.texture },
u_res: { value: new THREE.Vector2(size.x, size.y) },
u_time: { value: 0 },
u_grain: { value: CFG.GRAIN },
u_vignette: { value: CFG.VIGNETTE },
u_aberr: { value: 0 },
u_static: { value: 0 },
},
depthTest: false,
depthWrite: false,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), postMat);
postScene.add(quad);
}
function resize() {
if (!renderer || !canvas) return;
const w = Math.max(2, canvas.clientWidth || canvas.width || 2);
const h = Math.max(2, canvas.clientHeight || canvas.height || 2);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
const size = new THREE.Vector2();
renderer.getDrawingBufferSize(size);
if (rt) rt.setSize(Math.max(2, size.x), Math.max(2, size.y));
if (postMat) postMat.uniforms.u_res.value.set(size.x, size.y);
}
/* --- build a mesh for a freshly loaded beat ---------------------------- */
function buildBeatMesh(imgTex, depthTex, hasDepth) {
const geo = new THREE.PlaneGeometry(CFG.PLANE_W, CFG.PLANE_H, CFG.SEG_X, CFG.SEG_Y);
const mat = new THREE.ShaderMaterial({
vertexShader: MESH_VERT,
fragmentShader: MESH_FRAG,
uniforms: {
u_image: { value: imgTex },
u_depth: { value: depthTex },
u_depthK: { value: reducedFlag || isReducedMotion(false) ? 0.0 : CFG.DEPTH_K },
u_hasDepth: { value: hasDepth ? 1.0 : 0.0 },
u_opacity: { value: 0.0 }, // fades up over the reveal
u_revealMode: { value: 0 }, // 0 none / 1 wipe / 2 iris
u_reveal: { value: 1.0 },
u_aspect: { value: 2.39 },
},
transparent: true,
depthTest: true,
depthWrite: true,
});
const mesh = new THREE.Mesh(geo, mat);
return { mesh, mat };
}
/* --- live camera animation --------------------------------------------- */
function updateCamera(now, reduced) {
// Base/home pose.
let x = camHome.x, y = camHome.y, z = camHome.z, pitch = camHome.pitch, fov = camHome.fov;
if (cur && !reduced) {
const spec = CAMERA_MOVES[cur.move] || CAMERA_MOVES.static;
const since = now - (cur.revealAt || now);
// Travel from 0 -> full across the reveal window with smoothstep easing,
// then a gentle continuing idle sway so a held frame keeps breathing.
const revealP = smoothstep01(since / (CFG.REVEAL_MS / 1000));
const breath = Math.sin(since * 0.5) * CFG.IDLE_BREATH;
x += spec.tx * revealP;
y += spec.ty * revealP;
z += spec.dz * revealP;
pitch += spec.pitch * revealP;
fov += spec.fov * revealP;
// Live idle sway (tiny) layered on every shot so nothing is ever frozen.
x += Math.sin(since * 0.35) * breath;
y += Math.cos(since * 0.27) * breath;
if (spec.drift) {
// Brownian drift for "static": a smoothed random walk retargeted every
// few seconds, eased toward the target (wander, not jitter).
if (now >= drift.nextRetarget) {
drift.tx = (Math.random() * 2 - 1) * CFG.DRIFT_AMP;
drift.ty = (Math.random() * 2 - 1) * CFG.DRIFT_AMP;
drift.nextRetarget = now + 2.5 + Math.random() * 2.0;
}
drift.x += (drift.tx - drift.x) * 0.01;
drift.y += (drift.ty - drift.y) * 0.01;
x += drift.x;
y += drift.y;
}
}
camera.position.set(x, y, z);
camera.lookAt(0, 0, 0); // always frame the relief's center
if (pitch) camera.rotateX(pitch); // tilt/crane add a small pitch on top
if (Math.abs(camera.fov - fov) > 1e-3) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
}
/* --- per-frame transition bookkeeping ---------------------------------- */
function updateTransition(now) {
if (!trans.active) return;
const p = (now * 1000 - trans.t0) / trans.durMs;
const pc = Math.max(0, Math.min(1, p));
// The new beat is `cur`; the outgoing beat is `prev`.
if (cur && cur.mat) {
const u = cur.mat.uniforms;
if (trans.mode === TRANS.hard_cut) {
u.u_opacity.value = pc < 0.5 ? 0.0 : 1.0;
u.u_revealMode.value = 0;
} else if (trans.mode === TRANS.wipe_left) {
u.u_opacity.value = 1.0;
u.u_revealMode.value = 1;
u.u_reveal.value = pc;
} else if (trans.mode === TRANS.iris) {
u.u_opacity.value = 1.0;
u.u_revealMode.value = 2;
u.u_reveal.value = pc;
} else {
// crossfade (default)
u.u_opacity.value = pc;
u.u_revealMode.value = 0;
}
}
if (prev && prev.mat) {
const u = prev.mat.uniforms;
// Outgoing beat fades out for crossfade; for wipe/iris/hard_cut it stays
// fully opaque underneath while the incoming beat reveals on top.
if (trans.mode === TRANS.crossfade) u.u_opacity.value = 1.0 - pc;
else if (trans.mode === TRANS.hard_cut) u.u_opacity.value = pc < 0.5 ? 1.0 : 0.0;
else u.u_opacity.value = 1.0;
}
if (p >= 1.0) finishTransition();
}
function finishTransition() {
// Lock the new beat fully visible, drop any reveal mask.
if (cur && cur.mat) {
cur.mat.uniforms.u_opacity.value = 1.0;
cur.mat.uniforms.u_revealMode.value = 0;
}
// Dispose the outgoing beat — it's done its job.
if (prev) {
disposeBeat(prev);
prev = null;
}
const resolve = trans.resolve;
trans.active = false;
trans.resolve = null;
if (resolve) resolve();
}
/* --- the rAF loop ------------------------------------------------------- */
function loop() {
if (disposed) return;
rafId = requestAnimationFrame(loop);
if (!renderer) return;
const now = performance.now() / 1000;
const reduced = reducedFlag || isReducedMotion(false);
updateTransition(now);
updateCamera(now, reduced);
// Render the 3D scene into the offscreen RT...
renderer.setRenderTarget(rt);
renderer.clear();
renderer.render(scene, camera);
// ...then the post-FX pass to the visible canvas.
const tt = now - startTime;
postMat.uniforms.u_time.value = tt;
postMat.uniforms.u_static.value = reduced ? 1.0 : 0.0;
postMat.uniforms.u_grain.value = reduced ? CFG.GRAIN * 0.6 : CFG.GRAIN;
// Chromatic aberration ONLY during a transition, ramped 0->1->0 across the
// ~0.42s window for a flutter rather than a step. Zeroed in reduced motion.
let aberr = 0.0;
if (trans.active && !reduced) {
const a = (now * 1000 - trans.t0) / trans.aberrMs;
if (a < 1.0) aberr = Math.sin(Math.max(0, Math.min(1, a)) * Math.PI) * CFG.ABERR_MAX;
}
postMat.uniforms.u_aberr.value = aberr;
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, postCamera);
}
/* --- texture loading (Promise, never rejects fatally) ------------------- */
function loadTexture(url, isColor) {
return new Promise((resolve) => {
if (!url) { resolve(null); return; }
try {
loader.load(
url,
(tex) => {
try {
tex.colorSpace = isColor ? THREE.SRGBColorSpace : THREE.NoColorSpace;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
} catch (_e) { /* non-fatal */ }
resolve(tex);
},
undefined,
() => resolve(null) // load error => resolve null, NEVER hang
);
} catch (_e) {
resolve(null);
}
});
}
/* 1x1 mid-gray depth fallback => (depth - 0.5) == 0 => flat plane. */
let _flatDepth = null;
function flatDepthTexture() {
if (_flatDepth) return _flatDepth;
const data = new Uint8Array([128, 128, 128, 255]);
const tex = new THREE.DataTexture(data, 1, 1, THREE.RGBAFormat);
tex.needsUpdate = true;
_flatDepth = tex;
return tex;
}
/* --- public: revealBeat ------------------------------------------------- */
function revealBeat(opts) {
const {
imageUrl,
depthUrl,
cameraMove = "static",
transition = "crossfade",
reducedMotion = false,
} = opts || {};
const reduced = isReducedMotion(reducedMotion) || reducedFlag;
return new Promise((resolve) => {
// Guard EVERYTHING: a throw here must still resolve so the reveal chain in
// stage.js never stalls.
let settled = false;
const done = () => { if (!settled) { settled = true; resolve(); } };
Promise.all([
loadTexture(imageUrl, true),
depthUrl ? loadTexture(depthUrl, false) : Promise.resolve(null),
])
.then(([imgTex, depthTex]) => {
try {
if (!imgTex) {
// Couldn't load the painted frame: don't build a blank mesh, just
// resolve so the session keeps rolling (controller shows soft state).
done();
return;
}
const hasDepth = !!depthTex;
const beat = buildBeatMesh(imgTex, hasDepth ? depthTex : flatDepthTexture(), hasDepth);
beat.imgTex = imgTex;
beat.depthTex = hasDepth ? depthTex : null; // flat depth is shared, not owned
beat.move = CAMERA_MOVES[cameraMove] ? cameraMove : "static";
beat.revealAt = performance.now() / 1000;
scene.add(beat.mesh);
// Promote the existing current beat to outgoing.
if (prev) disposeBeat(prev); // safety: shouldn't normally happen
prev = cur;
cur = beat;
// Choose transition. Reduced motion forces crossfade. The first beat
// has no "from", so it crossfades up from the dark room regardless.
let mode = TRANS[transition] || TRANS.crossfade;
if (reduced) mode = TRANS.crossfade;
if (!haveFirstBeat) { mode = TRANS.crossfade; haveFirstBeat = true; }
// Reset depth gain on the live beat per current reduced-motion state.
cur.mat.uniforms.u_depthK.value = reduced ? 0.0 : CFG.DEPTH_K;
cur.mat.uniforms.u_aspect.value = camera.aspect || 2.39;
// Start fully transparent; the loop ramps it via updateTransition.
cur.mat.uniforms.u_opacity.value = 0.0;
if (!prev) {
// No outgoing beat to fade against — but still run the timed
// crossfade so we honor the ~700ms reveal contract.
}
// If a transition was somehow still active, finish it cleanly first.
if (trans.active && trans.resolve) {
const r = trans.resolve;
trans.active = false;
trans.resolve = null;
r();
}
trans.active = true;
trans.mode = mode;
trans.t0 = performance.now();
trans.durMs = CFG.TRANSITION_MS;
trans.aberrMs = reduced ? 0 : CFG.ABERRATION_MS;
trans.resolve = done;
} catch (_e) {
// Any failure building/animating the beat: resolve, never hang.
done();
}
})
.catch(() => done());
// Absolute safety net: even if the rAF loop were starved, resolve after a
// generous window so the controller's reveal chain can never deadlock.
setTimeout(done, CFG.REVEAL_MS + CFG.TRANSITION_MS + 600);
});
}
/* --- dispose a single beat's GL resources ------------------------------ */
function disposeBeat(beat) {
if (!beat) return;
try {
if (beat.mesh) {
scene.remove(beat.mesh);
if (beat.mesh.geometry) beat.mesh.geometry.dispose();
}
if (beat.mat) beat.mat.dispose();
if (beat.imgTex) beat.imgTex.dispose();
if (beat.depthTex) beat.depthTex.dispose(); // shared flat depth was set null
} catch (_e) { /* non-fatal */ }
}
/* --- public: setReducedMotion ------------------------------------------ */
function setReducedMotion(flag) {
reducedFlag = !!flag;
// Mirror to the body class so isReducedMotion() agrees everywhere (and the
// CSS overlays can respond too) — matches stage-gl.js semantics.
if (document.body) document.body.classList.toggle("reduced-motion", reducedFlag);
// Zero / restore the live parallax displacement immediately.
const k = reducedFlag ? 0.0 : CFG.DEPTH_K;
if (cur && cur.mat) cur.mat.uniforms.u_depthK.value = k;
if (prev && prev.mat) prev.mat.uniforms.u_depthK.value = k;
}
/* --- public: dispose --------------------------------------------------- */
function dispose() {
disposed = true;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
try { window.removeEventListener("resize", resize); } catch (_e) {}
disposeBeat(cur); cur = null;
disposeBeat(prev); prev = null;
try {
if (backdrop) {
scene.remove(backdrop);
backdrop.geometry.dispose();
backdrop.material.dispose();
}
if (_flatDepth) _flatDepth.dispose();
if (rt) rt.dispose();
if (postMat) postMat.dispose();
if (renderer) renderer.dispose();
} catch (_e) { /* non-fatal */ }
}
return { init, revealBeat, setReducedMotion, dispose };
})();
/* ===========================================================================
* INSTALL — only overwrite LL.stage if Three.js + WebGL2 actually work.
* ---------------------------------------------------------------------------
* We do a real init against the live canvas. If it succeeds we publish this
* true-3D renderer (already initialized — its loop is running). If ANYTHING
* throws, we restore whatever LL.stage was there before (stage-gl.js) so the
* app keeps its graceful fallback, none the wiser.
*
* stage.js (the controller) looks up LL.stage at call time and calls
* LL.stage.init(canvas). To keep that contract working whether or not we've
* pre-initialized, the published init() is idempotent: if we already
* initialized the same canvas here, a second init() call from the controller is
* a no-op; if for some reason the controller passes a different canvas, we honor
* it.
* ===========================================================================*/
(function install() {
const previousStage = window.LL.stage; // the stage-gl.js renderer (fallback)
let initialized = false;
let initCanvas = null;
const canvas = document.getElementById("stage-canvas");
if (!canvas) {
// No canvas yet — leave the fallback in place. (The controller inits the
// renderer it finds at boot; stage-gl.js already handles that path.)
return;
}
try {
Stage.init(canvas); // throws if WebGL2/Three unavailable or shader fails
initialized = true;
initCanvas = canvas;
} catch (err) {
// Keep the existing fallback renderer; do NOT touch LL.stage.
console.warn("[LL.stage3d] true-3D init failed, keeping stage-gl fallback:", err);
window.LL.stage = previousStage;
return;
}
// Success: OVERWRITE LL.stage with the true-3D version (same interface).
window.LL.stage = {
init: (cv) => {
// Idempotent: the controller calls init(canvas) at boot. If we already
// initialized this canvas, it's a no-op (our loop is already running). If a
// different canvas is supplied, (re)initialize against it.
if (initialized && cv === initCanvas) return;
try {
Stage.init(cv);
initialized = true;
initCanvas = cv;
} catch (err) {
// Late failure: fall back to the previous renderer for this canvas.
console.warn("[LL.stage3d] late init failed, using fallback:", err);
window.LL.stage = previousStage;
if (previousStage && typeof previousStage.init === "function") {
try { previousStage.init(cv); } catch (_e) {}
}
}
},
revealBeat: (opts) => Stage.revealBeat(opts),
setReducedMotion: (flag) => Stage.setReducedMotion(flag),
dispose: () => Stage.dispose(),
};
})();
export default window.LL.stage;