| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- // 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;
- let startYaw = 0, startPitch = 0;
- 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 at the most OPEN spot, facing the scene. Blindly
- // spawning at the horizontal centre buries the camera inside geometry for
- // object-centric captures (a bonsai, a statue…) → a dark close-up. Sample the
- // floor and pick the standing position with the most horizontal clearance.
- const spawn = chooseSpawn(model, box, size, center, diag, params.eye);
- startPoint = spawn.point;
- startYaw = spawn.yaw;
- startPitch = -0.12; // a gentle downward tilt to frame the scene
- window.__params = Object.assign(
- { diag, floorY: spawn.floorY, clearance: Math.round(spawn.clr * 1000) / 1000 }, params);
- resetPlayer();
- window.addEventListener('resize', onResize);
- document.addEventListener('keydown', onKey);
- document.addEventListener('keyup', onKey);
- setupPointer();
- setupTouch();
- animate();
- }
- // Find a good first-person spawn: sample floor points across the scene, keep the
- // one with the most open horizontal clearance (so the camera isn't wedged in a
- // wall), and face it toward the scene's centre of geometry. Falls back to the
- // scene centre if nothing samples cleanly.
- function chooseSpawn(model, box, size, center, diag, eye) {
- const ray = new THREE.Raycaster();
- const down = new THREE.Vector3(0, -1, 0);
- const dirs = [];
- for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; dirs.push(new THREE.Vector3(Math.cos(a), 0, Math.sin(a))); }
- const N = 4, inset = 0.18;
- const cands = [];
- const floorAt = (x, z) => {
- ray.set(new THREE.Vector3(x, box.max.y + diag * 0.1, z), down);
- const h = ray.intersectObject(model, true)[0];
- return h ? h.point.y : null;
- };
- const sample = (x, z) => {
- const fy = floorAt(x, z);
- if (fy === null) return;
- const p = new THREE.Vector3(x, fy + eye, z);
- let clr = diag;
- for (const d of dirs) { ray.set(p, d); const h = ray.intersectObject(model, true)[0]; if (h) clr = Math.min(clr, h.distance); }
- cands.push({ point: p, floorY: fy, clr });
- };
- for (let ix = 0; ix <= N; ix++) for (let iz = 0; iz <= N; iz++) {
- sample(box.min.x + size.x * (inset + (1 - 2 * inset) * ix / N),
- box.min.z + size.z * (inset + (1 - 2 * inset) * iz / N));
- }
- sample(center.x, center.z);
- if (!cands.length) {
- const fy = floorAt(center.x, center.z);
- return { point: new THREE.Vector3(center.x, (fy ?? box.min.y) + eye, center.z), yaw: 0, floorY: fy ?? box.min.y, clr: 0 };
- }
- cands.sort((a, b) => b.clr - a.clr);
- const best = cands[0];
- const dx = center.x - best.point.x, dz = center.z - best.point.z;
- best.yaw = (Math.abs(dx) + Math.abs(dz) < diag * 1e-3) ? 0 : Math.atan2(-dx, -dz);
- return best;
- }
- 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);
- camera.rotation.set(startPitch, startYaw, 0); // face the scene, not a wall
- }
- 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; }
|