/* 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;