Spaces:
Running on Zero
Running on Zero
| /* 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. | |
| * ==========================================================================*/ | |
| ; | |
| 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; | |