/* Lightloom · frontend/js/stage-scroll.js * ============================================================================ * THE LIVING SCROLL — Voice-Scroll in 2.5D. * * The world is ONE continuous horizontal panorama that keeps EXTENDING to the * right as you speak (each section is outpainted to continue the last, seamlessly) * and SCROLLS LEFT continuously, so it flows like a living mural. Real 2.5D comes * from a per-fragment STEEP-PARALLAX RAYMARCH (DepthFlow relief recipe): the * DepthAnything map is a heightfield, and each pixel marches the view ray to the * FIRST surface hit — so it is occlusion-correct (no tear), the eyepoint shifts with * SCROLL VELOCITY (no time term → no vibration; perfectly flat at rest), and it reads * over ONE continuous strip (no seams). A depth aerial-perspective pass (haze + * desaturation toward far) makes depth read even in a still frame. * * Sections stream in via addSection({imageUrl, depthUrl}); they are composited onto * a growing strip canvas (so the texture is ONE continuous panorama, no quad seams). * * window.LL.scroll = { init(canvas), addSection({imageUrl,depthUrl,meta}), reset(), * setReducedMotion(flag), set onFocus, get ready, get pendingAhead } * ==========================================================================*/ "use strict"; import * as THREE from "three"; window.LL = window.LL || {}; const Scroll = (() => { let renderer = null, scene = null, camera = null, canvas = null, mesh = null, mat = null; let raf = 0, t0 = 0, lastT = 0, ok = false, reduced = false, aspect = 16 / 9; // FX MODE (depth-replacement experiments, OPT-IN — base = 0 leaves the renderer IDENTICAL): // 0 base raymarch · 1 living painting · 2 diorama (2-layer) · 3 combo (living + a touch of diorama) // Chosen from the ?fx= URL param (fx=living|diorama|combo) or LL.scroll.setFx(name) at runtime. let FX_MODE = 0; const FX_NAME = { living: 1, diorama: 2, combo: 3 }; try { FX_MODE = FX_NAME[new URLSearchParams(location.search).get("fx")] || 0; } catch (_) {} // per-mode parallax budget: diorama pops the planes harder; combo a touch above base; base = PARALLAX function _fxParallax() { return FX_MODE === 2 ? 0.105 : FX_MODE === 3 ? 0.072 : PARALLAX; } // backing strip canvases (one continuous panorama + its depth), grown to the right const STRIP_H = 768, STRIP_W = 8192; // strip texture height raised 512->768 so a 576x768 section is // drawn 1:1 (no vertical downscale) and the view window upscales ~1.4x to the viewport instead of // ~2.1x -> much sharper on screen. 8192x768 is within the WebGL MAX_TEXTURE_SIZE>=8192 guarantee; // ~14 sections fit before recycle. All strip math derives from STRIP_H/STRIP_W so it adapts. // 2.5D via a per-fragment STEEP-PARALLAX RAYMARCH (DepthFlow): depth is a heightfield; each pixel // marches the view ray to the FIRST hit (occlusion-correct -> no tear), the eyepoint shifts with // SCROLL VELOCITY (no time term -> no vibration; flat at rest), one continuous strip -> no seams. const CAM_FOV = 38; // degrees const CAM_DIST = 2.0; // camera distance from the plane center (the camera is STATIC) const DEPTH_GAIN = 1.45; // contrast stretch for coarse, low-contrast DepthAnything-Small depth const DEPTH_GAMMA = 0.85; // <1 lifts mid-depths so near content separates from far const OVERSCAN = 1.0; // plane fills the frustum exactly so vUv maps 1:1 to the visible frame const MESH_SEG = 1; // pure full-screen quad; relief is per-fragment now const PARALLAX = 0.0; // FLAT by default: the 2.5D depth-parallax warped the painting over an // unreliable painterly depth map without reading as real depth (user: "distorts the image, no real // depth"). 0 => the strip renders undistorted. (The diorama FX mode sets its own non-zero budget.) const MARCH_LIN = 8; // coarse linear forward steps (first-hit bracket). 13->8 for FPS; with BIN=5 const MARCH_BIN = 5; // the hit resolution is 1/256, still ~3x finer than a depth texel (verified) const SWAY_PERIOD = 540; // px of scroll per full eyepoint sway -> CONTINUOUS gentle parallax motion const SWAY_AMT = 0.6; // fraction of the eyepoint budget the slow positional sway uses const VEL_TAU = 0.18; // s — exponential smoothing time-const for scroll velocity const VEL_REF = 220.0; // px/s mapped to full +/-1 parallax (scroll ~110px/s => ~0.5 budget) const SCROLL_SPEED = 110; // px/s cruise speed when the buffer is healthy const SCROLL_ACCEL_TAU = 0.45; // s — how fast scrollVel eases toward its adaptive target. Soft enough // that resuming from a lull RAMPS up (no 0->110 slam) yet quick enough to feel responsive. const HEADROOM_EASE = 280; // px of painted-ahead buffer over which the cruise speed ramps 0->full. // The scroll DECELERATES as it nears the frontier and ACCELERATES as content arrives, so it glides to // a stop and back instead of the old binary stop/go at maxScroll => no "micro parada". 280px ~= 2.5s // of runway at cruise: a phrase that lands within that window is absorbed with no visible deceleration. const AERIAL_DESAT = 0.04; // whisper far-plane desaturation (depth cue). Verified: at 0.04 far content // keeps ~85-96% chroma; the near-brightness mix below also reads as depth. const AERIAL_HAZE = 0.0; // OFF. This was THE grey-blue fog: HAZE_TINT below is a LINEAR color that // DISPLAYS at [206,215,229]/255 (bright grey-blue) after toSRGB, and mixing // it in LINEAR space lifts dark/shadow pixels ~4x (sRGB-16 -> ~53), so big // "far" regions milked out. DepthAnything reads large painterly areas as far, // spreading it everywhere. Lowering it (0.45->0.15->0.05) kept firing; ZERO is // the qualitative fix (line below becomes a no-op). Verified twice: removes the // cast completely, does NOT darken (haze/desat are pre-toSRGB; brightness term // untouched), parallax geometry untouched. const AERIAL_BLUR = 0.35; // texel-scale of the far-plane 4-tap micro-blur — spatial only, no desat const HAZE_TINT = [0.62, 0.68, 0.78]; // cool sky/haze tint let imgCanvas = null, depCanvas = null, imgCtx = null, depCtx = null; let imgTex = null, depTex = null; let writeX = 0; // where the next section is pasted (px on the strip) let scrollX = 0; // current left edge of the view window (px on the strip) let prevScrollX = 0; // scrollX last frame, for d(scrollX)/dt let scrollVel = 0; // px/s ACTUAL scroll speed, eased toward an adaptive target (no stop-start) let velSmooth = 0; // smoothed px/s scroll velocity (drives the parallax eyepoint) let swayPhase = 0; // accumulated from scroll DELTA -> a slow continuous eyepoint sway (depth) let _focusCache = 0.5, _focusFrame = 0; // CPU-pinned view-centre median depth (throttled) let osReduced = false; const queue = []; // sections {img, dep, meta, x} in arrival order, for focus/captions let onFocus = null, focusX = -1; // FLUIDITY: per-section work used to allocate ~8MB of offscreen canvases + re-upload the WHOLE // 8192xSTRIP_H texture (~50MB) -> a frame hitch each time a section streamed in. Pool the scratch // buffers (no GC churn) and a cosine LUT (kills ~107K Math.cos/section in featherDraw). let _scC = null, _scX = null, _gBuf = null; // shared feather/depth offscreen + Gaussian buffer let _upC = null, _upX = null; // crop canvas for the partial GL upload let _didRecycle = false; // recycle shifts the whole canvas -> needs a full upload const _COS = new Float32Array(257); // cosine ease-in-out LUT for (let _i = 0; _i <= 256; _i++) _COS[_i] = 0.5 - 0.5 * Math.cos((_i / 256) * Math.PI); function _scratch(w) { // shared per-section offscreen, GROWN not reallocated (kills the per-append GC) if (!_scC) { _scC = document.createElement("canvas"); _scC.height = STRIP_H; _scX = _scC.getContext("2d", { willReadFrequently: true }); } if (_scC.width < w) _scC.width = w; // grow only (a resize clears it, fine before drawImage) _scX.clearRect(0, 0, w, STRIP_H); return _scX; } // PARTIAL GL upload: push ONLY the changed column to the GPU instead of re-uploading the whole texture // every section (~50MB -> ~2.7MB). Crops the dirty column into a small canvas then texSubImage2D's it, // matching three's CanvasTexture flipY=true so the orientation is IDENTICAL to a full upload. Falls // back to a safe full needsUpdate on any failure or before the first (three-managed) upload. function _subUpload(tex, srcCanvas, dx, dw) { try { const props = renderer && renderer.properties.get(tex); if (!props || !props.__webglTexture) { tex.needsUpdate = true; return; } if (!_upC) { _upC = document.createElement("canvas"); _upC.height = STRIP_H; _upX = _upC.getContext("2d"); } if (_upC.width !== dw) _upC.width = dw; // exact width so the upload covers exactly [dx, dx+dw) _upX.clearRect(0, 0, dw, STRIP_H); _upX.drawImage(srcCanvas, dx, 0, dw, STRIP_H, 0, 0, dw, STRIP_H); const gl = renderer.getContext(); const prev = gl.getParameter(gl.TEXTURE_BINDING_2D); gl.bindTexture(gl.TEXTURE_2D, props.__webglTexture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // match three CanvasTexture flipY -> same orientation gl.texSubImage2D(gl.TEXTURE_2D, 0, dx, 0, gl.RGBA, gl.UNSIGNED_BYTE, _upC); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.bindTexture(gl.TEXTURE_2D, prev); } catch (_) { tex.needsUpdate = true; } } const loader = new THREE.TextureLoader(); loader.crossOrigin = "anonymous"; // VERT: trivial pass-through full-screen quad (all the depth work is per-fragment now). const VERT = ` precision highp float; varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; // FRAG: a steep-parallax (relief) raymarch over the strip-window. The depth strip is a heightfield // (global [0,1], 0=far 1=near); for each pixel we march the view ray to the FIRST below-surface // crossing (occlusion-correct -> no tear) and sample the painted strip there. The eyepoint offset // P comes from smoothed scroll velocity (uHParallax). + aerial perspective from the hit's depth. const FRAG = ` precision highp float; varying vec2 vUv; uniform sampler2D uImg; // continuous painted strip uniform sampler2D uDep; // continuous depth strip, global [0,1], 0=far 1=near uniform float uU0; // window left edge, normalized on the strip uniform float uViewU; // window width, normalized uniform float uTexel; // 1.0 / STRIP_W uniform float uTexelV; // 1.0 / STRIP_H uniform float uFocus; // CPU-pinned view-centre median depth (aerial pivot + swim guard) uniform float uGain; // depth contrast gain uniform float uGamma; // depth gamma (<1 lifts mids) uniform float uHParallax; // signed eyepoint offset in [-1,1] from smoothed scroll velocity uniform float uParallax; // budget (fraction of view width) uniform float uAerialDesat; uniform float uAerialHaze; uniform float uAerialBlur; uniform vec3 uHazeTint; uniform float uTime; // seconds since start (ONLY read when uFx>0 -> base mode has no time term) uniform float uFx; // 0 base · 1 living painting · 2 diorama · 3 combo const int LIN = ${MARCH_LIN}; const int BIN = ${MARCH_BIN}; // window-uv (vUv) -> strip-uv, clamped into the painted canvas vec2 toStrip(vec2 w){ return vec2(clamp(uU0 + w.x * uViewU, 0.0, 1.0), clamp(w.y, 0.001, 0.999)); } // HEIGHTFIELD height in FULL [0,1]: contrast-stretched depth used directly as surface height so // the ray can intersect anywhere along [1->0]. 0 = far (deep), 1 = near (tall). float heightAt(vec2 w){ float d0 = texture2D(uDep, toStrip(w)).r; float d = pow(clamp(d0, 0.0, 1.0), uGamma); return clamp(uFocus + (d - uFocus) * uGain, 0.0, 1.0); } // Linear -> sRGB OETF. REQUIRED: the strip CanvasTexture has colorSpace=SRGB, which in three r160 // uploads it as SRGB8_ALPHA8 (WebGLTextures.getInternalFormat, three.module.js:24171), so the GPU // DECODES sRGB->linear on every texture2D(uImg) sample -> c is LINEAR here. This custom shader has // no chunk, so the renderer adds no output encode. We must encode linear->sRGB // ourselves or the framebuffer (outputColorSpace=SRGB) shows the picture ~2.2-gamma too dark. vec3 toSRGB(vec3 c){ c = max(c, vec3(0.0)); vec3 lo = c * 12.92; vec3 hi = 1.055 * pow(c, vec3(1.0/2.4)) - 0.055; return mix(lo, hi, step(vec3(0.0031308), c)); } // ---- LIVING PAINTING helpers (compiled always, EXECUTED only when uFx>0) ---- float hash21(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float vnoise(vec2 p){ // value noise, smooth vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); float a = hash21(i), b = hash21(i + vec2(1.0, 0.0)); float cc = hash21(i + vec2(0.0, 1.0)), d = hash21(i + vec2(1.0, 1.0)); return mix(mix(a, b, f.x), mix(cc, d, f.x), f.y); } // sparse luminous dust motes that drift slowly UP and twinkle (catches the light) float motes(vec2 uv, float tm){ float acc = 0.0; for (int L = 0; L < 2; L++){ float sc = 22.0 + float(L) * 18.0; vec2 p = uv * vec2(sc * 1.8, sc); p.y += tm * (0.22 + float(L) * 0.14); // float upward p.x += tm * 0.04; // gentle lateral drift vec2 ip = floor(p), fp = fract(p) - 0.5; float h = hash21(ip + float(L) * 7.3); float on = step(0.90, h); // ~10% of cells hold a mote float tw = 0.55 + 0.45 * sin(tm * 2.0 + h * 31.0); // twinkle acc += on * tw * smoothstep(0.36, 0.0, length(fp)); } return acc; } void main(){ // view ray (parallax-occlusion): eyepoint shifts horizontally by P across the ray. ray param // t in [0,1]; ray height descends 1 -> 0; surface = heightAt(baseUv - P*t). First t where // rayH < surfH is the FIRST hit => occlusion-correct (no smear). float P = uHParallax * uParallax; // signed, |P| <= uParallax vec2 baseUv = vUv; vec2 hitUv = baseUv; // FLAT FAST-PATH (default): when the parallax budget is ~0 the painted strip is shown UNDISTORTED. // The relief raymarch over DepthAnything's noisy painterly depth WARPED subjects (the user's "the // depth distorts the image, e.g. the aliens") without reading as real depth, so the base render is // now flat; skipping the 13-tap march here also removes its per-pixel cost (fluidity). The diorama // FX mode still sets a non-zero uParallax and runs the march below. if (abs(P) > 0.001) { float prevT = 0.0; bool hit = false; float hitT = 1.0; for (int i = 1; i <= LIN; i++){ float t = float(i) / float(LIN); float rayH = 1.0 - t; float surfH = heightAt(baseUv - vec2(P * t, 0.0)); if (rayH < surfH){ hitT = t; hit = true; break; } prevT = t; } float tA = prevT, tB = hit ? hitT : 1.0; for (int j = 0; j < BIN; j++){ float tm = 0.5 * (tA + tB); float rayH = 1.0 - tm; float surfH = heightAt(baseUv - vec2(P * tm, 0.0)); if (rayH < surfH) tB = tm; else tA = tm; } float tHit = 0.5 * (tA + tB); hitUv = baseUv - vec2(P * tHit, 0.0); } vec2 sStrip = toStrip(hitUv); vec3 c = texture2D(uImg, sStrip).rgb; // AERIAL PERSPECTIVE: cue from the hit's height (0 far -> 1 near) float dHit = texture2D(uDep, sStrip).r; dHit = pow(clamp(dHit, 0.0, 1.0), uGamma); dHit = clamp(uFocus + (dHit - uFocus) * uGain, 0.0, 1.0); float farness = 1.0 - dHit; if (uAerialBlur > 0.0 && farness > 0.06){ // gate raised 0.01->0.06: near pixels skip the 4 blur taps float r = uAerialBlur * farness; vec3 b = texture2D(uImg, sStrip + vec2( uTexel*r, 0.0)).rgb + texture2D(uImg, sStrip + vec2(-uTexel*r, 0.0)).rgb + texture2D(uImg, sStrip + vec2(0.0, uTexelV*r)).rgb + texture2D(uImg, sStrip + vec2(0.0, -uTexelV*r)).rgb; c = mix(c, b * 0.25, farness * 0.6); } float luma = dot(max(c, vec3(0.0)), vec3(0.299, 0.587, 0.114)); c = mix(c, vec3(luma), uAerialDesat * farness); c = mix(c, uHazeTint, uAerialHaze * farness * farness); c = toSRGB(c); // linear -> sRGB for the framebuffer (see note above) c *= mix(0.92, 1.05, dHit); // near a touch brighter (aerial cue) float vy = smoothstep(0.0, 0.12, vUv.y) * smoothstep(1.0, 0.88, vUv.y); c *= mix(0.82, 1.0, vy); // ===== LIVING PAINTING (uFx 1 or 3): SLOW, low-frequency light life — NOT per-pixel jitter. // A still painting gently breathes: atmospheric shimmer, faint drifting god-ray shafts in the // bright/sky regions, and luminous dust motes floating up. All time-driven but low-freq + smooth // so it reads as living air, never as vibration. c is in display space here (post toSRGB). ===== if (uFx == 1.0 || uFx == 3.0){ float tm = uTime; float amt = (uFx == 3.0) ? 0.62 : 1.0; // combo dials the life back to a touch // 1) atmospheric shimmer: large-scale, very slow luminance undulation (air/heat/water) float shim = vnoise(vUv * vec2(2.2, 1.6) + vec2(tm * 0.05, tm * 0.03)); c *= mix(1.0, mix(0.965, 1.035, shim), amt); // 2) god-ray shafts: soft diagonal streaks, only where the painting is bright (sky/light), // strongest toward the top, sliding slowly float skyGate = smoothstep(0.42, 0.92, luma); float shaft = vnoise(vec2((vUv.x + vUv.y * 0.6) * 5.0 + tm * 0.10, 0.0)); shaft = smoothstep(0.50, 0.95, shaft); float upper = smoothstep(0.05, 0.7, 1.0 - vUv.y); c += vec3(0.060, 0.057, 0.048) * shaft * skyGate * upper * amt; // 3) luminous dust motes drifting up — the clearest "alive" cue, so a touch brighter/warmer c += vec3(0.95, 0.90, 0.72) * motes(vUv, tm) * 0.17 * amt; } gl_FragColor = vec4(c, 1.0); }`; function init(cv) { if (ok) return; try { canvas = cv; renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5)); // cap 2->1.5: the raymarch is per-pixel, 2x retina ~doubles cost for little gain (fluidity) // WebGL context can be LOST (GPU reset/OOM, or the OS reclaiming a backgrounded tab's context // during the 30-40s warm-up). preventDefault() lets the browser restore it; without these the // canvas stays black forever with no error. On restore, re-init the renderer (the strip is // rebuilt as new sections stream in). This is the recover path; a lost context never self-heals. canvas.addEventListener("webglcontextlost", (e) => { e.preventDefault(); cancelAnimationFrame(raf); ok = false; }, false); canvas.addEventListener("webglcontextrestored", () => { ok = false; try { init(canvas); } catch (_) {} }, false); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(CAM_FOV, aspect, 0.05, 100); camera.position.set(0, 0, CAM_DIST); camera.lookAt(0, 0, 0); imgCanvas = document.createElement("canvas"); imgCanvas.width = STRIP_W; imgCanvas.height = STRIP_H; depCanvas = document.createElement("canvas"); depCanvas.width = STRIP_W; depCanvas.height = STRIP_H; imgCtx = imgCanvas.getContext("2d"); depCtx = depCanvas.getContext("2d", { willReadFrequently: true }); imgCtx.fillStyle = "#0b0d12"; imgCtx.fillRect(0, 0, STRIP_W, STRIP_H); depCtx.fillStyle = "#808080"; depCtx.fillRect(0, 0, STRIP_W, STRIP_H); imgTex = new THREE.CanvasTexture(imgCanvas); imgTex.colorSpace = THREE.SRGBColorSpace; depTex = new THREE.CanvasTexture(depCanvas); for (const tx of [imgTex, depTex]) { tx.minFilter = THREE.LinearFilter; tx.magFilter = THREE.LinearFilter; tx.wrapS = THREE.ClampToEdgeWrapping; } imgTex.generateMipmaps = false; // strip is always MAGNIFIED (upscaled), never minified -> mips depTex.generateMipmaps = false; depTex.wrapT = THREE.ClampToEdgeWrapping; // are wasted work + VRAM try { renderer.initTexture(imgTex); renderer.initTexture(depTex); } catch (_) {} // create __webglTexture now so _subUpload can patch columns mat = new THREE.ShaderMaterial({ uniforms: { uImg: { value: imgTex }, uDep: { value: depTex }, uU0: { value: 0 }, uViewU: { value: 0.25 }, uTexel: { value: 1 / STRIP_W }, uTexelV: { value: 1 / STRIP_H }, uFocus: { value: 0.5 }, uGain: { value: DEPTH_GAIN }, uGamma: { value: DEPTH_GAMMA }, uHParallax: { value: 0 }, // driven by smoothed scroll velocity in loop() uParallax: { value: _fxParallax() }, uAerialDesat: { value: AERIAL_DESAT }, uAerialHaze: { value: AERIAL_HAZE }, uAerialBlur: { value: AERIAL_BLUR }, uHazeTint: { value: new THREE.Vector3(HAZE_TINT[0], HAZE_TINT[1], HAZE_TINT[2]) }, uTime: { value: 0 }, uFx: { value: FX_MODE }, }, vertexShader: VERT, fragmentShader: FRAG, depthWrite: false, depthTest: false, }); mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, MESH_SEG, MESH_SEG), mat); // pure quad scene.add(mesh); fitMeshToFrustum(); // with OVERSCAN=1.0 it fits exactly -> vUv maps 1:1 to the frame resize(); window.addEventListener("resize", resize); // TESTING TOGGLE: Shift+F cycles base -> living -> diorama -> combo on the live world, with a brief // on-screen label, so the depth-replacement modes can be A/B'd in one session. Base = the shipped // renderer; the modes are otherwise reachable via the ?fx= URL param. Ignored while typing. window.addEventListener("keydown", (e) => { if ((e.key === "F" || e.key === "f") && e.shiftKey && !/^(input|textarea)$/i.test((e.target && e.target.tagName) || "")) { const next = setFx(((FX_MODE + 1) % 4)); _fxToast(["base", "living painting", "diorama", "combo (living + diorama)"][next]); } }); const mq = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)"); if (mq) { osReduced = mq.matches; const f = (e) => (osReduced = e.matches); (mq.addEventListener ? mq.addEventListener("change", f) : mq.addListener && mq.addListener(f)); } ok = true; t0 = performance.now(); loop(); } catch (e) { ok = false; } } // size the plane to fill the frustum at z=0 (OVERSCAN=1.0 -> exact fit, vUv 1:1 with the frame). function fitMeshToFrustum() { if (!camera || !mesh || !camera.isPerspectiveCamera) return; const halfH = Math.tan((camera.fov * Math.PI / 180) / 2) * CAM_DIST; const sy = halfH * OVERSCAN; // plane half-height in world units const sx = sy * aspect; // plane half-width (PlaneGeometry(2,2) is [-1,1]) mesh.scale.set(sx, sy, 1); } function resize() { if (!renderer || !canvas) return; const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 1280; const h = canvas.clientHeight || canvas.parentElement?.clientHeight || 720; renderer.setSize(w, h, false); aspect = w / h; if (camera && camera.isPerspectiveCamera) { camera.aspect = aspect; camera.updateProjectionMatrix(); fitMeshToFrustum(); } } function reducedNow() { return reduced || osReduced || (document.body && document.body.classList.contains("reduced-motion")); } // Pin the focus/aerial plane to the view-centre median-ish depth (CPU, cheap, throttled 1-in-6), // so the picture doesn't swim and aerial perspective tracks the current scene. function focusDepthTarget() { if ((_focusFrame++ % 6) !== 0) return _focusCache; try { const viewU = Math.min(1, (aspect * STRIP_H) / STRIP_W); const cx = Math.round(scrollX + viewU * STRIP_W * 0.5); // centre column on the strip (px) if (cx < 0 || cx >= STRIP_W) return _focusCache; const col = depCtx.getImageData(cx, Math.round(STRIP_H * 0.25), 1, Math.round(STRIP_H * 0.5)).data; let s = 0, n = 0; for (let i = 0; i < col.length; i += 4) { s += col[i]; n++; } _focusCache = n ? (s / n) / 255 : 0.5; } catch (_) { /* tainted/blocked -> keep last */ } return _focusCache; } function loadImg(url) { return new Promise((resolve) => { if (!url) return resolve(null); const im = new Image(); im.crossOrigin = "anonymous"; im.onload = () => resolve(im); im.onerror = () => resolve(null); im.src = url; }); } const FEATHER = 140; // px the new section's left edge ORGANICALLY melts into the previous (wider -> softer seams) // Paint `img` onto the strip at `x`, blending its left `blend` px into what's already // there — but ARTISTICALLY: a wide, smoothly-eased cross-fade whose boundary WOBBLES // organically per row (multi-frequency sine), so adjacent scenes mix like drifting mist // instead of meeting at a straight vertical seam. Per-pixel via ImageData (a gradient + // destination-in offscreen composite is silently dropped on software/headless GL, which // would leave the section unpainted). Same-origin images only, so getImageData never // taints; falls back to a hard draw if pixel access is blocked. function featherDraw(ctx, img, x, w, h, blend) { if (blend <= 0 || x <= 0) { ctx.drawImage(img, x, 0, w, h); return; } const tc = _scratch(w); // pooled offscreen (no per-section allocation) tc.drawImage(img, 0, 0, w, h); try { const band = tc.getImageData(0, 0, blend, h); // left `blend` columns const d = band.data; for (let row = 0; row < h; row++) { // organic vertical wobble of the blend boundary (3 octaves -> mist-like, not a line) const wob = Math.sin(row * 0.045) * 0.5 + Math.sin(row * 0.017 + 1.7) * 0.32 + Math.sin(row * 0.091 + 0.6) * 0.18; const shift = wob * blend * 0.45; const base = row * blend * 4; for (let col = 0; col < blend; col++) { let t = (col - shift) / blend; t = t < 0 ? 0 : t > 1 ? 1 : t; const a = _COS[(t * 256) | 0]; // LUT (was 0.5-0.5*Math.cos(t*PI)) -> ~107K fewer Math.cos/section d[base + col * 4 + 3] = (d[base + col * 4 + 3] * a) | 0; } } tc.putImageData(band, 0, 0); ctx.drawImage(_scC, 0, 0, w, h, x - blend, 0, w, h); // crop [0,w] (scratch may be wider) over the prev section } catch (_) { ctx.drawImage(img, x - blend, 0, w, h); // pixel access blocked -> hard draw (no seam fade) } } // Pre-smooth the DEPTH STRIP ONLY (never the picture): a separable 5-tap Gaussian with // edge-clamp, run once per appended section, kills DepthAnything-Small's speckle and softens // hard depth steps into gentle ramps BEFORE the raymarch reads them. Touches only depCanvas; // imgCanvas is never blurred. Same-origin -> no taint; falls back to a raw draw if blocked. function smoothDepthToStrip(dep, dx, w, h, blend = 0) { const oc = _scratch(w); // pooled offscreen (no per-section allocation) oc.drawImage(dep, 0, 0, w, h); try { const src = oc.getImageData(0, 0, w, h); const a = src.data, out = src.data, n = w * h; // SEAM AFFINE MATCH: pin this section's depth onto the EXISTING strip's depth scale over the // FEATHER overlap so there is no DC/contrast cliff at the boundary (kills the strip-seam shear). if (blend > 1 && dx > 0) { try { const bw = Math.min(blend, w); const old = depCtx.getImageData(dx, 0, bw, h).data; // existing neighbour, same x-range const S = w >= 64 ? 4 : 1; // stride the scalar stats (1/16 the samples; mean/var unchanged) let mO = 0, mN = 0, cnt = 0; for (let y = 0; y < h; y += S) for (let x = 0; x < bw; x += S) { mO += old[(y * bw + x) * 4]; mN += a[(y * w + x) * 4]; cnt++; } mO /= cnt; mN /= cnt; let vO = 0, vN = 0; for (let y = 0; y < h; y += S) for (let x = 0; x < bw; x += S) { const o = old[(y * bw + x) * 4] - mO, nn = a[(y * w + x) * 4] - mN; vO += o * o; vN += nn * nn; } const g = Math.sqrt(vO / cnt) / (Math.sqrt(vN / cnt) + 1e-3), bb = mO - mN * g; // d' = g*d + b for (let i = 0; i < n; i++) { let v = a[i * 4] * g + bb; v = v < 0 ? 0 : v > 255 ? 255 : v; a[i * 4] = a[i * 4 + 1] = a[i * 4 + 2] = v; a[i * 4 + 3] = 255; } } catch (_) { /* tainted/blocked -> skip match; the Gaussian below still softens the seam */ } } const K = [0.25, 0.5, 0.25], KR = 1; // 3-tap (was 5-tap): depth is coarse + shader-magnified; ~halves cost if (!_gBuf || _gBuf.length < n) _gBuf = new Float32Array(n); // pooled, grown not reallocated const tmp = _gBuf; for (let y = 0; y < h; y++) { // horizontal pass (depth in R; grayscale) const row = y * w; for (let x = 0; x < w; x++) { let s = 0; for (let k = -KR; k <= KR; k++) { let xx = x + k; xx = xx < 0 ? 0 : xx >= w ? w - 1 : xx; s += a[(row + xx) * 4] * K[k + KR]; } tmp[row + x] = s; } } for (let x = 0; x < w; x++) { // vertical pass, write back to RGBA for (let y = 0; y < h; y++) { let s = 0; for (let k = -KR; k <= KR; k++) { let yy = y + k; yy = yy < 0 ? 0 : yy >= h ? h - 1 : yy; s += tmp[yy * w + x] * K[k + KR]; } const v = s < 0 ? 0 : s > 255 ? 255 : s | 0; const i = (y * w + x) * 4; out[i] = out[i + 1] = out[i + 2] = v; out[i + 3] = 255; } } oc.putImageData(src, 0, 0); depCtx.drawImage(_scC, 0, 0, w, STRIP_H, dx, 0, w, STRIP_H); // crop [0,w] (pooled scratch may be wider) } catch (_) { depCtx.drawImage(dep, dx, 0, w, STRIP_H); // pixel access blocked -> raw draw } } // Serialize section appends: each addSection awaits image loads (async), so without a // chain a later section whose image loads faster could paint BEFORE an earlier one and // chain its overlap onto the wrong neighbour. The chain guarantees strict spoken order. let addChain = Promise.resolve(); function addSection(args) { addChain = addChain.then(() => doAddSection(args || {})).catch(() => null); return addChain; } async function doAddSection({ imageUrl, depthUrl, meta } = {}) { if (!ok) return null; const [img, dep] = await Promise.all([loadImg(imageUrl), loadImg(depthUrl)]); if (!img) return null; const w = Math.round((img.width / img.height) * STRIP_H); // scale to strip height // clamp the feather to the room available so x = writeX - blend never goes negative // (writeX can land in (0, FEATHER) right after a recycle()) let blend = Math.min(writeX > 0 ? FEATHER : 0, writeX); _didRecycle = false; if (writeX - blend + w > STRIP_W) { recycle(w); _didRecycle = true; blend = Math.min(writeX > 0 ? FEATHER : 0, writeX); } const x = writeX - blend; featherDraw(imgCtx, img, writeX, w, STRIP_H, blend); // SEAM-MATCH (re-enabled — QUALITY_100_PLAN #1): pin each section's depth onto the existing strip's // depth scale over the overlap (affine match) + a light smooth, so there is NO DC/contrast cliff in the // DEPTH channel at a scene boundary — the discontinuity that makes the aerial cue + the join read as a // hard edge. This does NOT add parallax (PARALLAX stays 0 — no depth motion); it only smooths the depth // CHANNEL. If it reintroduces a per-section frame hitch, revert to `depCtx.drawImage(dep, x, 0, w, STRIP_H)`. if (dep) smoothDepthToStrip(dep, x, w, STRIP_H, blend); else { depCtx.fillStyle = "#808080"; depCtx.fillRect(x, 0, w, STRIP_H); } // FLUIDITY: patch ONLY the new column to the GPU (the per-section ~50MB full re-upload was the main // micro-stutter). recycle() shifts the whole canvas, so that one append still needs a full upload. if (_didRecycle) { imgTex.needsUpdate = true; depTex.needsUpdate = true; } else { _subUpload(imgTex, imgCanvas, x, w); _subUpload(depTex, depCanvas, x, w); } queue.push({ x, w, meta: meta || null }); writeX = x + w; return { x, w }; } // when the strip canvas is full, shift everything left to make room (keeps flowing) function recycle(need) { const shift = Math.max(need, STRIP_W * 0.4); imgCtx.globalCompositeOperation = "copy"; imgCtx.drawImage(imgCanvas, -shift, 0); imgCtx.globalCompositeOperation = "source-over"; depCtx.globalCompositeOperation = "copy"; depCtx.drawImage(depCanvas, -shift, 0); depCtx.globalCompositeOperation = "source-over"; writeX = Math.max(0, writeX - shift); scrollX = Math.max(0, scrollX - shift); prevScrollX = Math.max(0, prevScrollX - shift); // so next-frame velocity isn't a spike for (const s of queue) s.x -= shift; } function loop() { raf = requestAnimationFrame(loop); if (!renderer || !scene || !camera) return; const now = performance.now(); const dt = Math.min(0.05, (now - (lastT || now)) / 1000); lastT = now; const rm = reducedNow(); const viewU = Math.min(1, (aspect * STRIP_H) / STRIP_W); // window width (normalized) mat.uniforms.uViewU.value = viewU; // continuous leftward scroll: advance the window toward the freshly written edge, but never past // the available content. The OLD code clamped scrollX hard at maxScroll, so when generation lagged // (strips are now ~1/phrase ~= 4s of runway, often slower than the spoken cadence) the scroll // SLAMMED to a stop at the edge and SLAMMED back to 110px/s when the next strip landed -> a visible // stop-start "micro parada", and the velocity-driven sway froze with it. Instead we drive an EASED // velocity toward an ADAPTIVE target speed that tapers to 0 as the view nears the painted frontier: // the scroll glides to a stop with runway to spare and glides back up as content arrives. No jump. const maxScroll = Math.max(0, writeX - viewU * STRIP_W - 8); const headroom = maxScroll - scrollX; // px of painted world still ahead of the view // cruise tapers 0->full across HEADROOM_EASE px of remaining runway (smoothstep -> gentle in/out) let hr = Math.max(0, Math.min(1, headroom / HEADROOM_EASE)); hr = hr * hr * (3 - 2 * hr); // smoothstep so the taper has no velocity kink const targetVel = rm ? 0 : SCROLL_SPEED * hr; const aS = 1 - Math.exp(-dt / SCROLL_ACCEL_TAU); // exponential ease (no overshoot/ring) scrollVel += (targetVel - scrollVel) * aS; scrollX = Math.min(maxScroll, scrollX + scrollVel * dt); // clamp is now a no-op in practice (safety) mat.uniforms.uU0.value = scrollX / STRIP_W; // 2.5D parallax: the eyepoint offset is driven by SCROLL VELOCITY (NOT time -> no vibration; // NOT cumulative position -> no divergence). A cascade of one-pole filters can only monotonically // approach its target, so it never rings; at rest it eases to 0 -> a clean still flat image. const dScroll = scrollX - prevScrollX; // per-frame scroll delta (recycle-safe) const instVel = dt > 1e-4 ? dScroll / dt : 0; // px/s this frame prevScrollX = scrollX; // CONTINUOUS parallax: accumulate a slow eyepoint sway from the scroll DELTA (not absolute scrollX, // and not velocity). A velocity-driven eyepoint is CONSTANT at steady scroll -> the relief looks // flat/static (the user's "no depth"). Sweeping the eyepoint as the world scrolls makes near and // far move at different rates continuously -> real motion-parallax, smooth (tied to the smooth // scroll) and low-frequency (no vibration). dScroll is ~0 across a recycle so swayPhase never jumps. if (Math.abs(dScroll) < STRIP_W * 0.2) swayPhase += dScroll / SWAY_PERIOD; const aV = 1 - Math.exp(-dt / VEL_TAU); velSmooth += (instVel - velSmooth) * aV; const velComp = Math.max(-1, Math.min(1, velSmooth / VEL_REF)); // a little velocity flavour let hTarget = rm ? 0 : Math.max(-1, Math.min(1, SWAY_AMT * Math.sin(swayPhase) + (1 - SWAY_AMT) * 0.5 * velComp)); // DIORAMA/COMBO: a gentle continuous eyepoint ORBIT (slow 8 s sine) so the multi-plane depth POPS // even at rest — the user's "depth no aporta mucho" was partly that parallax only showed while // scrolling. Low-freq + smooth = a living diorama, not vibration. Diorama full, combo a touch. if (!rm && (FX_MODE === 2 || FX_MODE === 3)) { const orbAmt = FX_MODE === 2 ? 0.40 : 0.22; hTarget = Math.max(-1, Math.min(1, hTarget + orbAmt * Math.sin((now - t0) * (2 * Math.PI / 8000)))); } mat.uniforms.uHParallax.value += (hTarget - mat.uniforms.uHParallax.value) * Math.min(1, dt * 6); if (FX_MODE > 0) mat.uniforms.uTime.value = (now - t0) / 1000; // pin the FOCUS/aerial plane to the view-centre median depth (slow ease, throttled probe) mat.uniforms.uFocus.value += (focusDepthTarget() - mat.uniforms.uFocus.value) * Math.min(1, dt * 1.5); // focus = the section under the centre of the view -> caption const centerX = scrollX + viewU * STRIP_W * 0.5; let f = null; for (const s of queue) if (centerX >= s.x && centerX < s.x + s.w) f = s; if (f && f.x !== focusX) { focusX = f.x; if (onFocus && f.meta) { try { onFocus(f.meta); } catch (_) {} } } renderer.render(scene, camera); } function reset() { if (!ok) return; imgCtx.fillStyle = "#0b0d12"; imgCtx.fillRect(0, 0, STRIP_W, STRIP_H); depCtx.fillStyle = "#808080"; depCtx.fillRect(0, 0, STRIP_W, STRIP_H); imgTex.needsUpdate = depTex.needsUpdate = true; writeX = 0; scrollX = 0; queue.length = 0; focusX = -1; prevScrollX = 0; scrollVel = 0; velSmooth = 0; swayPhase = 0; _focusCache = 0.5; _focusFrame = 0; if (mat) { mat.uniforms.uHParallax.value = 0; mat.uniforms.uFocus.value = 0.5; } } function setReducedMotion(f) { reduced = !!f; } let _toastEl = null, _toastTimer = 0; function _fxToast(label) { // minimal self-styled toast (no CSS dependency) for the Shift+F mode switch try { if (!_toastEl) { _toastEl = document.createElement("div"); _toastEl.style.cssText = "position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:99;" + "padding:7px 16px;border-radius:999px;background:rgba(8,10,16,.78);color:#eef;font:600 13px/1 system-ui," + "sans-serif;letter-spacing:.04em;pointer-events:none;backdrop-filter:blur(6px);transition:opacity .25s"; document.body.appendChild(_toastEl); } _toastEl.textContent = "FX · " + label; _toastEl.style.opacity = "1"; clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { if (_toastEl) _toastEl.style.opacity = "0"; }, 1400); } catch (_) {} } // switch the depth-replacement FX mode at runtime (base|living|diorama|combo). Base restores the // exact original renderer. Safe to call before or after init. function setFx(name) { FX_MODE = FX_NAME[name] || (name === "base" || !name ? 0 : FX_MODE); if (typeof name === "number") FX_MODE = name; if (mat) { mat.uniforms.uFx.value = FX_MODE; mat.uniforms.uParallax.value = _fxParallax(); } return FX_MODE; } function dispose() { cancelAnimationFrame(raf); window.removeEventListener("resize", resize); if (renderer) renderer.dispose(); ok = false; } return { init, addSection, reset, dispose, setReducedMotion, setFx, get fx() { return FX_MODE; }, set onFocus(fn) { onFocus = typeof fn === "function" ? fn : null; }, get ready() { return ok; }, // how much generated content is still ahead of the view (px) — lets the loop pace generation get pendingAhead() { return Math.max(0, writeX - scrollX - (aspect * STRIP_H)); }, // diagnostics (harmless, never called in normal flow): strip cursor + geometry, and a // single-pixel probe — used by the local visual-flow harness to verify the strip paints. get debug() { return { writeX: Math.round(writeX), scrollX: Math.round(scrollX), viewPx: Math.round(aspect * STRIP_H), STRIP_W, qn: queue.length, q: queue.map((s) => ({ x: Math.round(s.x), w: s.w })) }; }, pixelAt(x) { try { const d = imgCtx.getImageData(Math.max(0, Math.round(x)), 256, 1, 1).data; return [d[0], d[1], d[2], d[3]]; } catch (_) { return null; } }, }; })(); LL.scroll = Scroll; export default LL.scroll;