lightloom / frontend /js /explore3d.js
Efradeca's picture
chore: deploy private lightloom build
3e796be verified
Raw
History Blame Contribute Delete
12.7 kB
/* 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() }
* ==========================================================================*/
"use strict";
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;