// First-person walkthrough of a reconstructed scene.glb. // Octree + capsule collision (so you don't fall through walls/floor), // desktop pointer-lock + WASD, mobile joystick + drag-look. // All movement params derive from the scene's bounding box, so it feels // the same regardless of the (arbitrary, up-to-scale) COLMAP units. import * as THREE from 'three'; import { GLTFLoader } from './vendor/jsm/loaders/GLTFLoader.js'; import { Octree } from './vendor/jsm/math/Octree.js'; import { Capsule } from './vendor/jsm/math/Capsule.js'; let renderer, scene, camera, raf, container, clock; let octree, collider, startPoint, params; const velocity = new THREE.Vector3(); const dir = new THREE.Vector3(); let onFloor = false; const keys = {}; const joy = { x: 0, y: 0 }; // -1..1 strafe / forward from the mobile stick let looking = null; // active drag-look pointer id const STEPS = 5; function disposeScene(obj) { obj.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => m.dispose()); }); } export function stopViewer() { if (raf) cancelAnimationFrame(raf); raf = null; window.removeEventListener('resize', onResize); document.removeEventListener('keydown', onKey); document.removeEventListener('keyup', onKey); if (renderer) { renderer.domElement.remove(); renderer.dispose(); renderer = null; } if (scene) { disposeScene(scene); scene = null; } } export async function startViewer(glbUrl) { stopViewer(); container = document.getElementById('viewer-canvas'); clock = new THREE.Clock(); scene = new THREE.Scene(); scene.background = new THREE.Color(0x10141b); camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.01, 5000); camera.rotation.order = 'YXZ'; scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.1)); scene.add(new THREE.AmbientLight(0xffffff, 0.35)); const sun = new THREE.DirectionalLight(0xffffff, 1.4); sun.position.set(1, 2, 1); scene.add(sun); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement); const gltf = await new GLTFLoader().loadAsync(glbUrl); const model = gltf.scene; scene.add(model); // --- derive scale-aware params from the bounding box --- const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const diag = size.length() || 1; // Scaled to the scene diagonal so the "time to cross the room" feel is the // same at any (arbitrary, up-to-scale) reconstruction scale. params = { radius: diag * 0.01, eye: diag * 0.03, accelGround: diag * 0.5, // terminal walk speed ~= accelGround/4 accelAir: diag * 0.16, gravity: diag * 0.6, jump: diag * 0.32, fallY: box.min.y - diag * 0.6, }; camera.far = diag * 20; camera.updateProjectionMatrix(); octree = new Octree(); octree.fromGraphNode(model); // Spawn STANDING on the floor: raycast straight down through the scene centre. // (Dropping from the ceiling tunnels through thin reconstructed floors.) const ray = new THREE.Raycaster( new THREE.Vector3(center.x, box.max.y + diag * 0.1, center.z), new THREE.Vector3(0, -1, 0), ); const hit = ray.intersectObject(model, true)[0]; const floorY = hit ? hit.point.y : box.min.y; startPoint = new THREE.Vector3(center.x, floorY + params.eye, center.z); window.__params = Object.assign({ diag, floorY }, params); resetPlayer(); window.addEventListener('resize', onResize); document.addEventListener('keydown', onKey); document.addEventListener('keyup', onKey); setupPointer(); setupTouch(); animate(); } function resetPlayer() { collider = new Capsule( startPoint.clone().add(new THREE.Vector3(0, params.radius, 0)), startPoint.clone().add(new THREE.Vector3(0, params.eye, 0)), params.radius, ); velocity.set(0, 0, 0); camera.position.copy(collider.end); } export function recenter() { if (collider) resetPlayer(); } function onResize() { if (!renderer) return; camera.aspect = container.clientWidth / container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); } function onKey(e) { keys[e.code] = e.type === 'keydown'; } // --- look: pointer lock (desktop) + drag (touch/mouse) --- function setupPointer() { const el = renderer.domElement; el.addEventListener('click', () => { if (!isTouch()) el.requestPointerLock(); }); document.addEventListener('mousemove', (e) => { if (document.pointerLockElement === el) applyLook(e.movementX, e.movementY); }); } function setupTouch() { const el = renderer.domElement; let last = null; el.addEventListener('touchstart', (e) => { const t = e.changedTouches[0]; if (t.clientX < window.innerWidth * 0.4) return; // left zone = joystick looking = t.identifier; last = { x: t.clientX, y: t.clientY }; }, { passive: true }); el.addEventListener('touchmove', (e) => { for (const t of e.changedTouches) { if (t.identifier === looking && last) { applyLook(t.clientX - last.x, t.clientY - last.y); last = { x: t.clientX, y: t.clientY }; } } }, { passive: true }); el.addEventListener('touchend', (e) => { for (const t of e.changedTouches) if (t.identifier === looking) { looking = null; last = null; } }, { passive: true }); } function applyLook(dx, dy) { camera.rotation.y -= dx / 500; camera.rotation.x -= dy / 500; camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x)); } function isTouch() { return matchMedia('(pointer: coarse)').matches; } // --- physics (adapted from three.js games_fps example) --- function collide() { const r = octree.capsuleIntersect(collider); onFloor = false; if (r) { onFloor = r.normal.y > 0; collider.translate(r.normal.multiplyScalar(r.depth)); } } function forward() { camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); return dir; } function side() { camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); dir.cross(camera.up); return dir; } function step(dt) { let damp = Math.exp(-4 * dt) - 1; if (!onFloor) { velocity.y -= params.gravity * dt; damp *= 0.1; } velocity.addScaledVector(velocity, damp); const sd = dt * (onFloor ? params.accelGround : params.accelAir); let f = 0, s = 0; if (keys['KeyW'] || keys['ArrowUp']) f += 1; if (keys['KeyS'] || keys['ArrowDown']) f -= 1; if (keys['KeyD'] || keys['ArrowRight']) s += 1; if (keys['KeyA'] || keys['ArrowLeft']) s -= 1; f += joy.y; s += joy.x; if (f) velocity.add(forward().multiplyScalar(sd * f)); if (s) velocity.add(side().multiplyScalar(sd * s)); if (onFloor && keys['Space']) velocity.y = params.jump; collider.translate(velocity.clone().multiplyScalar(dt)); collide(); camera.position.copy(collider.end); if (camera.position.y < params.fallY) resetPlayer(); } function animate() { raf = requestAnimationFrame(animate); const dt = Math.min(0.05, clock.getDelta()) / STEPS; for (let i = 0; i < STEPS; i++) step(dt); renderer.render(scene, camera); window.__genreconTris = renderer.info.render.triangles; // test/debug readiness signal window.__genreconCam = [camera.position.x, camera.position.y, camera.position.z]; window.__dbg = { onFloor, vel: [velocity.x, velocity.y, velocity.z], keys: Object.keys(keys).filter((k) => keys[k]) }; window.__genreconReady = true; } // mobile joystick hook (called from app.js) export function setJoystick(x, y) { joy.x = x; joy.y = y; }