lightloom / frontend /js /stage-scroll.js
Efradeca's picture
chore: deploy private lightloom build
318f094 verified
Raw
History Blame Contribute Delete
41.3 kB
/* 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 <colorspace_fragment> 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;