Spaces:
Running on Zero
Running on Zero
| /* Lightloom · frontend/js/explore3d.js | |
| * ============================================================================ | |
| * NAVIGABLE 3D — "step into your finished world". | |
| * | |
| * A self-contained Three.js viewer (NEVER touches the live stage-scroll renderer) that lifts the | |
| * finished painterly panorama into real 3D: a subdivided plane whose vertices are DISPLACED by the | |
| * Depth-Anything depth map (2D -> 2.5D -> 3D, the depth-displaced-mesh recipe from the WebGL research | |
| * doc), textured with the panorama. The user DRAGS to orbit (tilting pops the depth: near brushwork | |
| * parallaxes against the far background), wheels/pinches to dolly in, and scrubs a slider to travel the | |
| * mural. Disocclusion "rubber-sheet" triangles at depth cliffs are masked (SceneScape gradient discard), | |
| * so edges reveal honest gaps instead of smears. 100% CLIENT GPU — the Space's ZeroGPU is never touched. | |
| * | |
| * window.LL.explore3d = { open({color, depth, title}), close() } | |
| * ==========================================================================*/ | |
| ; | |
| import * as THREE from "three"; | |
| window.LL = window.LL || {}; | |
| const Explore3D = (() => { | |
| let host = null, renderer = null, scene = null, camera = null, mesh = null, mat = null, backdrop = null, raf = 0; | |
| let hintEl = null, hintTimer = 0, _onWinMove = null, _onWinUp = null; | |
| // fade the "drag to peer" hint out the first time the user interacts, or after a grace period. | |
| function fadeHint() { if (hintEl) hintEl.classList.add("is-faded"); if (hintTimer) { clearTimeout(hintTimer); hintTimer = 0; } } | |
| let target = null; // THREE.Vector3 look-point on the strip (x travels along the mural) | |
| let yaw = 0, pitch = 0, dist = 1.5, worldW = 4, halfVisU = 0.9, open = false; | |
| const YAW_MAX = 0.36, PITCH_MAX = 0.22, DIST_MIN = 0.9, DIST_MAX = 3.0; // clamps: gentle peer, never extreme angles (tearing) | |
| // Load an image URL and return a THREE.CanvasTexture downscaled to <= MAX_TEXTURE_SIZE wide (a session | |
| // panorama can be 8k-25k px; Android caps at 4096) so the upload never silently fails. Aspect preserved. | |
| function loadTex(url, sRGB) { | |
| return new Promise((resolve) => { | |
| const im = new Image(); im.crossOrigin = "anonymous"; | |
| im.onerror = () => resolve(null); | |
| im.onload = () => { | |
| try { | |
| let maxT = 4096; | |
| try { const gl = renderer.getContext(); maxT = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096, 8192); } catch (_) {} | |
| let w = im.width, h = im.height; | |
| if (w > maxT) { h = Math.round(h * (maxT / w)); w = maxT; } | |
| const cv = document.createElement("canvas"); cv.width = w; cv.height = h; | |
| cv.getContext("2d").drawImage(im, 0, 0, w, h); | |
| const tx = new THREE.CanvasTexture(cv); | |
| if (sRGB) tx.colorSpace = THREE.SRGBColorSpace; | |
| tx.minFilter = THREE.LinearFilter; tx.magFilter = THREE.LinearFilter; | |
| tx.wrapS = tx.wrapT = THREE.ClampToEdgeWrapping; tx.generateMipmaps = false; | |
| tx._w = w; tx._h = h; | |
| resolve(tx); | |
| } catch (_) { resolve(null); } | |
| }; | |
| im.src = url; | |
| }); | |
| } | |
| const VERT = ` | |
| precision highp float; | |
| uniform sampler2D uDep; uniform float uDisplace, uGamma, uGain; | |
| varying vec2 vUv; varying float vD; | |
| void main(){ | |
| vUv = uv; | |
| float d0 = texture2D(uDep, uv).r; // 0 = far, 1 = near | |
| float d = pow(clamp(d0, 0.0, 1.0), uGamma); | |
| d = clamp(0.5 + (d - 0.5) * uGain, 0.0, 1.0); | |
| vD = d; | |
| // taper displacement to 0 at the top/bottom borders so the frame rim stays flat (no torn edge) | |
| float edge = smoothstep(0.0, 0.07, uv.y) * smoothstep(1.0, 0.93, uv.y); | |
| vec3 p = position; | |
| p.z += (d - 0.5) * uDisplace * edge; // push near toward the camera, far away | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); | |
| }`; | |
| const FRAG = ` | |
| precision highp float; | |
| uniform sampler2D uImg, uDep; uniform vec2 uTexel; uniform float uEdge; | |
| varying vec2 vUv; varying float vD; | |
| 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)); } | |
| void main(){ | |
| // SceneScape stretch-mask: drop fragments on a steep depth cliff (rubber-sheet smear) -> honest gap. | |
| float dl=texture2D(uDep, vUv-vec2(uTexel.x,0.)).r, dr=texture2D(uDep, vUv+vec2(uTexel.x,0.)).r; | |
| float dn=texture2D(uDep, vUv-vec2(0.,uTexel.y)).r, dd=texture2D(uDep, vUv+vec2(0.,uTexel.y)).r; | |
| float grad = length(vec2(dr-dl, dd-dn)); | |
| if (grad > uEdge) discard; | |
| vec3 c = toSRGB(texture2D(uImg, vUv).rgb); // uImg is sRGB -> decoded to linear on sample | |
| c *= mix(0.82, 1.06, vD); // near a touch brighter (aerial depth cue) | |
| float vy = smoothstep(0.0, 0.06, vUv.y) * smoothstep(1.0, 0.94, vUv.y); | |
| c *= mix(0.86, 1.0, vy); // soft top/bottom vignette | |
| gl_FragColor = vec4(c, 1.0); | |
| }`; | |
| function buildOverlay() { | |
| host = document.createElement("section"); | |
| host.className = "explore3d"; host.setAttribute("role", "dialog"); host.setAttribute("aria-modal", "true"); | |
| host.setAttribute("aria-label", "Explore your world in 3D"); | |
| const canvas = document.createElement("canvas"); canvas.className = "explore3d__canvas"; | |
| const close = document.createElement("button"); | |
| close.className = "explore3d__close"; close.type = "button"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; | |
| close.addEventListener("click", () => Explore3D.close()); | |
| const hint = document.createElement("p"); hint.className = "explore3d__hint"; | |
| hint.textContent = (document.documentElement.dataset.lang === "es") | |
| ? "Arrastra para asomarte · rueda para acercar · desliza para recorrer" | |
| : "Drag to peer · scroll to move closer · slide to travel"; | |
| const slider = document.createElement("input"); | |
| slider.type = "range"; slider.min = "0"; slider.max = "1000"; slider.value = "500"; | |
| slider.className = "explore3d__scrub"; slider.setAttribute("aria-label", "Travel along the world"); | |
| host.append(canvas, close, hint, slider); | |
| document.body.appendChild(host); | |
| return { canvas, slider, close, hint }; | |
| } | |
| function onKey(e) { if (e.key === "Escape") Explore3D.close(); } | |
| async function openFn({ color, depth, title, focal } = {}) { | |
| if (open) Explore3D.close(); | |
| if (!color || !depth) return false; | |
| const { canvas, slider, hint } = buildOverlay(); | |
| hintEl = hint; | |
| try { | |
| renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5)); | |
| renderer.setClearColor(0x05060a, 1); | |
| } catch (_) { Explore3D.close(); return false; } | |
| scene = new THREE.Scene(); | |
| const [imgTex, depTex] = await Promise.all([loadTex(color, true), loadTex(depth, false)]); | |
| if (!imgTex || !depTex) { Explore3D.close(); return false; } | |
| worldW = (imgTex._w && imgTex._h) ? imgTex._w / imgTex._h : 4; // panorama aspect (wide) | |
| // grid resolution: enough to resolve depth edges along the wide strip, capped for mobile | |
| const mobile = Math.min(window.innerWidth, window.innerHeight) < 760; | |
| const segX = Math.max(96, Math.min(mobile ? 240 : 480, Math.round(worldW * (mobile ? 10 : 18)))); | |
| const segY = mobile ? 64 : 112; | |
| mat = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uImg: { value: imgTex }, uDep: { value: depTex }, | |
| uTexel: { value: new THREE.Vector2(1 / (depTex._w || 1024), 1 / (depTex._h || 256)) }, | |
| uDisplace: { value: 0.24 }, uGamma: { value: 0.85 }, uGain: { value: 1.2 }, uEdge: { value: 0.05 }, | |
| }, | |
| vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide, | |
| }); | |
| mesh = new THREE.Mesh(new THREE.PlaneGeometry(worldW, 1, segX, segY), mat); | |
| scene.add(mesh); | |
| // BACKDROP: a flat, dimmed copy of the panorama BEHIND the displaced mesh, so the disocclusion holes | |
| // (discarded cliff fragments + scene-seam jumps) reveal the dim scene/atmosphere instead of black void. | |
| backdrop = new THREE.Mesh( | |
| new THREE.PlaneGeometry(worldW, 1), | |
| new THREE.MeshBasicMaterial({ map: imgTex, color: new THREE.Color(0.34, 0.34, 0.4) }), | |
| ); | |
| backdrop.position.z = -0.22; | |
| scene.add(backdrop); | |
| camera = new THREE.PerspectiveCamera(42, 1, 0.01, 100); | |
| halfVisU = Math.tan((42 * Math.PI / 180) / 2) * 1.5 * (16 / 9) / worldW; // visible fraction of the mural | |
| target = new THREE.Vector3(0, 0, 0); | |
| yaw = 0; pitch = 0; dist = 1.5; open = true; | |
| resize(); | |
| // open on the region MiniCPM's Art Director judged most striking (focal_points), else the middle | |
| const fx0 = (Array.isArray(focal) && focal[0] && typeof focal[0].x === "number") ? Math.min(1, Math.max(0, focal[0].x)) : 0.5; | |
| slider.value = String(Math.round(fx0 * 1000)); | |
| slider.addEventListener("input", () => { | |
| const u = (+slider.value) / 1000; | |
| const halfVis = halfVisU * worldW; | |
| target.x = (-worldW / 2 + halfVis) + u * (worldW - 2 * halfVis); | |
| fadeHint(); | |
| }); | |
| slider.dispatchEvent(new Event("input")); | |
| bindDrag(canvas); | |
| // hint fades on first interaction (drag/scrub/wheel) or after a 4.5s grace period if untouched. | |
| hintTimer = setTimeout(fadeHint, 4500); | |
| window.addEventListener("resize", resize); | |
| document.addEventListener("keydown", onKey); | |
| loop(); | |
| return true; | |
| } | |
| function bindDrag(canvas) { | |
| let drag = false, lx = 0, ly = 0; | |
| const down = (x, y) => { drag = true; lx = x; ly = y; fadeHint(); }; | |
| const move = (x, y) => { | |
| if (!drag) return; | |
| yaw = Math.max(-YAW_MAX, Math.min(YAW_MAX, yaw - (x - lx) * 0.0045)); | |
| pitch = Math.max(-PITCH_MAX, Math.min(PITCH_MAX, pitch + (y - ly) * 0.004)); | |
| lx = x; ly = y; | |
| }; | |
| const up = () => { drag = false; }; | |
| canvas.addEventListener("mousedown", (e) => down(e.clientX, e.clientY)); | |
| _onWinMove = (e) => move(e.clientX, e.clientY); // named so closeFn can remove them (no window leak) | |
| _onWinUp = up; | |
| window.addEventListener("mousemove", _onWinMove); | |
| window.addEventListener("mouseup", _onWinUp); | |
| canvas.addEventListener("touchstart", (e) => { const t = e.touches[0]; down(t.clientX, t.clientY); }, { passive: true }); | |
| canvas.addEventListener("touchmove", (e) => { const t = e.touches[0]; move(t.clientX, t.clientY); }, { passive: true }); | |
| canvas.addEventListener("touchend", up); | |
| canvas.addEventListener("wheel", (e) => { e.preventDefault(); dist = Math.max(DIST_MIN, Math.min(DIST_MAX, dist + Math.sign(e.deltaY) * 0.12)); fadeHint(); }, { passive: false }); | |
| } | |
| function resize() { | |
| if (!renderer || !camera) return; | |
| const w = window.innerWidth, h = window.innerHeight; | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; camera.updateProjectionMatrix(); | |
| halfVisU = Math.tan((camera.fov * Math.PI / 180) / 2) * dist * camera.aspect / worldW; | |
| } | |
| function loop() { | |
| raf = requestAnimationFrame(loop); | |
| if (!renderer || !open) return; | |
| // orbit the camera around the look-target: drag yaw/pitch reveal the depth; never swing behind the sheet. | |
| const cx = target.x + dist * Math.sin(yaw) * Math.cos(pitch); | |
| const cy = target.y + dist * Math.sin(pitch); | |
| const cz = dist * Math.cos(yaw) * Math.cos(pitch); | |
| camera.position.set(cx, cy, cz); | |
| camera.lookAt(target); | |
| renderer.render(scene, camera); | |
| } | |
| function closeFn() { | |
| open = false; | |
| cancelAnimationFrame(raf); | |
| if (hintTimer) { clearTimeout(hintTimer); hintTimer = 0; } | |
| hintEl = null; | |
| window.removeEventListener("resize", resize); | |
| document.removeEventListener("keydown", onKey); | |
| if (_onWinMove) { window.removeEventListener("mousemove", _onWinMove); _onWinMove = null; } // no leak per open/close | |
| if (_onWinUp) { window.removeEventListener("mouseup", _onWinUp); _onWinUp = null; } | |
| try { if (mesh) { mesh.geometry.dispose(); } } catch (_) {} | |
| try { if (backdrop) { backdrop.geometry.dispose(); backdrop.material.dispose(); } } catch (_) {} | |
| try { if (mat) { for (const k of ["uImg", "uDep"]) { const t = mat.uniforms[k] && mat.uniforms[k].value; if (t && t.dispose) t.dispose(); } mat.dispose(); } } catch (_) {} | |
| try { if (renderer) renderer.dispose(); } catch (_) {} | |
| try { if (host && host.parentNode) host.parentNode.removeChild(host); } catch (_) {} | |
| host = renderer = scene = camera = mesh = mat = backdrop = null; | |
| const sb = document.getElementById("save-btn"); if (sb) { try { sb.focus(); } catch (_) {} } | |
| } | |
| return { open: openFn, close: closeFn, get isOpen() { return open; } }; | |
| })(); | |
| LL.explore3d = Explore3D; | |
| export default LL.explore3d; | |