| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "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; |
| |
| |
| |
| 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 (_) {} |
| |
| function _fxParallax() { return FX_MODE === 2 ? 0.105 : FX_MODE === 3 ? 0.072 : PARALLAX; } |
| |
| const STRIP_H = 768, STRIP_W = 8192; |
| |
| |
| |
| |
| |
| |
| const CAM_FOV = 38; |
| const CAM_DIST = 2.0; |
| const DEPTH_GAIN = 1.45; |
| const DEPTH_GAMMA = 0.85; |
| const OVERSCAN = 1.0; |
| const MESH_SEG = 1; |
| const PARALLAX = 0.0; |
| |
| |
| const MARCH_LIN = 8; |
| const MARCH_BIN = 5; |
| const SWAY_PERIOD = 540; |
| const SWAY_AMT = 0.6; |
| const VEL_TAU = 0.18; |
| const VEL_REF = 220.0; |
| const SCROLL_SPEED = 110; |
| const SCROLL_ACCEL_TAU = 0.45; |
| |
| const HEADROOM_EASE = 280; |
| |
| |
| |
| const AERIAL_DESAT = 0.04; |
| |
| const AERIAL_HAZE = 0.0; |
| |
| |
| |
| |
| |
| |
| |
| const AERIAL_BLUR = 0.35; |
| const HAZE_TINT = [0.62, 0.68, 0.78]; |
| let imgCanvas = null, depCanvas = null, imgCtx = null, depCtx = null; |
| let imgTex = null, depTex = null; |
| let writeX = 0; |
| let scrollX = 0; |
| let prevScrollX = 0; |
| let scrollVel = 0; |
| let velSmooth = 0; |
| let swayPhase = 0; |
| let _focusCache = 0.5, _focusFrame = 0; |
| let osReduced = false; |
| const queue = []; |
| let onFocus = null, focusX = -1; |
| |
| |
| |
| let _scC = null, _scX = null, _gBuf = null; |
| let _upC = null, _upX = null; |
| let _didRecycle = false; |
| const _COS = new Float32Array(257); |
| for (let _i = 0; _i <= 256; _i++) _COS[_i] = 0.5 - 0.5 * Math.cos((_i / 256) * Math.PI); |
| function _scratch(w) { |
| if (!_scC) { _scC = document.createElement("canvas"); _scC.height = STRIP_H; _scX = _scC.getContext("2d", { willReadFrequently: true }); } |
| if (_scC.width < w) _scC.width = w; |
| _scX.clearRect(0, 0, w, STRIP_H); |
| return _scX; |
| } |
| |
| |
| |
| |
| 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; |
| _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); |
| 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"; |
|
|
| |
| const VERT = ` |
| precision highp float; |
| varying vec2 vUv; |
| void main(){ |
| vUv = uv; |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); |
| }`; |
| |
| |
| |
| |
| 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)); |
| |
| |
| |
| |
| 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; |
| depTex.generateMipmaps = false; depTex.wrapT = THREE.ClampToEdgeWrapping; |
| try { renderer.initTexture(imgTex); renderer.initTexture(depTex); } catch (_) {} |
| 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 }, |
| 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); |
| scene.add(mesh); |
| fitMeshToFrustum(); |
| resize(); |
| window.addEventListener("resize", resize); |
| |
| |
| |
| 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; } |
| } |
|
|
| |
| function fitMeshToFrustum() { |
| if (!camera || !mesh || !camera.isPerspectiveCamera) return; |
| const halfH = Math.tan((camera.fov * Math.PI / 180) / 2) * CAM_DIST; |
| const sy = halfH * OVERSCAN; |
| const sx = sy * aspect; |
| 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")); } |
|
|
| |
| |
| 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); |
| 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 (_) { } |
| 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; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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); |
| tc.drawImage(img, 0, 0, w, h); |
| try { |
| const band = tc.getImageData(0, 0, blend, h); |
| const d = band.data; |
| for (let row = 0; row < h; row++) { |
| |
| 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]; |
| 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); |
| } catch (_) { |
| ctx.drawImage(img, x - blend, 0, w, h); |
| } |
| } |
|
|
| |
| |
| |
| |
| function smoothDepthToStrip(dep, dx, w, h, blend = 0) { |
| const oc = _scratch(w); |
| 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; |
| |
| |
| if (blend > 1 && dx > 0) { |
| try { |
| const bw = Math.min(blend, w); |
| const old = depCtx.getImageData(dx, 0, bw, h).data; |
| const S = w >= 64 ? 4 : 1; |
| 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; |
| 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 (_) { } |
| } |
| const K = [0.25, 0.5, 0.25], KR = 1; |
| if (!_gBuf || _gBuf.length < n) _gBuf = new Float32Array(n); |
| const tmp = _gBuf; |
| for (let y = 0; y < h; y++) { |
| 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++) { |
| 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); |
| } catch (_) { |
| depCtx.drawImage(dep, dx, 0, w, STRIP_H); |
| } |
| } |
|
|
| |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| |
| |
| |
| if (dep) smoothDepthToStrip(dep, x, w, STRIP_H, blend); |
| else { depCtx.fillStyle = "#808080"; depCtx.fillRect(x, 0, w, STRIP_H); } |
| |
| |
| 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 }; |
| } |
|
|
| |
| 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); |
| 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); |
| mat.uniforms.uViewU.value = viewU; |
| |
| |
| |
| |
| |
| |
| |
| const maxScroll = Math.max(0, writeX - viewU * STRIP_W - 8); |
| const headroom = maxScroll - scrollX; |
| |
| let hr = Math.max(0, Math.min(1, headroom / HEADROOM_EASE)); |
| hr = hr * hr * (3 - 2 * hr); |
| const targetVel = rm ? 0 : SCROLL_SPEED * hr; |
| const aS = 1 - Math.exp(-dt / SCROLL_ACCEL_TAU); |
| scrollVel += (targetVel - scrollVel) * aS; |
| scrollX = Math.min(maxScroll, scrollX + scrollVel * dt); |
| mat.uniforms.uU0.value = scrollX / STRIP_W; |
| |
| |
| |
| const dScroll = scrollX - prevScrollX; |
| const instVel = dt > 1e-4 ? dScroll / dt : 0; |
| prevScrollX = scrollX; |
| |
| |
| |
| |
| |
| 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)); |
| let hTarget = rm ? 0 : Math.max(-1, Math.min(1, SWAY_AMT * Math.sin(swayPhase) + (1 - SWAY_AMT) * 0.5 * velComp)); |
| |
| |
| |
| 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; |
| |
| mat.uniforms.uFocus.value += (focusDepthTarget() - mat.uniforms.uFocus.value) * Math.min(1, dt * 1.5); |
| |
| 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) { |
| 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 (_) {} |
| } |
| |
| |
| 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; }, |
| |
| get pendingAhead() { return Math.max(0, writeX - scrollX - (aspect * STRIP_H)); }, |
| |
| |
| 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; |
|
|