viewer.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. // First-person walkthrough of a reconstructed scene.glb.
  2. // Octree + capsule collision (so you don't fall through walls/floor),
  3. // desktop pointer-lock + WASD, mobile joystick + drag-look.
  4. // All movement params derive from the scene's bounding box, so it feels
  5. // the same regardless of the (arbitrary, up-to-scale) COLMAP units.
  6. import * as THREE from 'three';
  7. import { GLTFLoader } from './vendor/jsm/loaders/GLTFLoader.js';
  8. import { Octree } from './vendor/jsm/math/Octree.js';
  9. import { Capsule } from './vendor/jsm/math/Capsule.js';
  10. let renderer, scene, camera, raf, container, clock;
  11. let octree, collider, startPoint, params;
  12. let startYaw = 0, startPitch = 0;
  13. const velocity = new THREE.Vector3();
  14. const dir = new THREE.Vector3();
  15. let onFloor = false;
  16. const keys = {};
  17. const joy = { x: 0, y: 0 }; // -1..1 strafe / forward from the mobile stick
  18. let looking = null; // active drag-look pointer id
  19. const STEPS = 5;
  20. function disposeScene(obj) {
  21. obj.traverse((o) => {
  22. if (o.geometry) o.geometry.dispose();
  23. if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => m.dispose());
  24. });
  25. }
  26. export function stopViewer() {
  27. if (raf) cancelAnimationFrame(raf);
  28. raf = null;
  29. window.removeEventListener('resize', onResize);
  30. document.removeEventListener('keydown', onKey);
  31. document.removeEventListener('keyup', onKey);
  32. if (renderer) {
  33. renderer.domElement.remove();
  34. renderer.dispose();
  35. renderer = null;
  36. }
  37. if (scene) { disposeScene(scene); scene = null; }
  38. }
  39. export async function startViewer(glbUrl) {
  40. stopViewer();
  41. container = document.getElementById('viewer-canvas');
  42. clock = new THREE.Clock();
  43. scene = new THREE.Scene();
  44. scene.background = new THREE.Color(0x10141b);
  45. camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.01, 5000);
  46. camera.rotation.order = 'YXZ';
  47. scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.1));
  48. scene.add(new THREE.AmbientLight(0xffffff, 0.35));
  49. const sun = new THREE.DirectionalLight(0xffffff, 1.4);
  50. sun.position.set(1, 2, 1);
  51. scene.add(sun);
  52. renderer = new THREE.WebGLRenderer({ antialias: true });
  53. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  54. renderer.setSize(container.clientWidth, container.clientHeight);
  55. renderer.outputColorSpace = THREE.SRGBColorSpace;
  56. container.appendChild(renderer.domElement);
  57. const gltf = await new GLTFLoader().loadAsync(glbUrl);
  58. const model = gltf.scene;
  59. scene.add(model);
  60. // --- derive scale-aware params from the bounding box ---
  61. const box = new THREE.Box3().setFromObject(model);
  62. const size = box.getSize(new THREE.Vector3());
  63. const center = box.getCenter(new THREE.Vector3());
  64. const diag = size.length() || 1;
  65. // Scaled to the scene diagonal so the "time to cross the room" feel is the
  66. // same at any (arbitrary, up-to-scale) reconstruction scale.
  67. params = {
  68. radius: diag * 0.01,
  69. eye: diag * 0.03,
  70. accelGround: diag * 0.5, // terminal walk speed ~= accelGround/4
  71. accelAir: diag * 0.16,
  72. gravity: diag * 0.6,
  73. jump: diag * 0.32,
  74. fallY: box.min.y - diag * 0.6,
  75. };
  76. camera.far = diag * 20;
  77. camera.updateProjectionMatrix();
  78. octree = new Octree();
  79. octree.fromGraphNode(model);
  80. // Spawn STANDING on the floor at the most OPEN spot, facing the scene. Blindly
  81. // spawning at the horizontal centre buries the camera inside geometry for
  82. // object-centric captures (a bonsai, a statue…) → a dark close-up. Sample the
  83. // floor and pick the standing position with the most horizontal clearance.
  84. const spawn = chooseSpawn(model, box, size, center, diag, params.eye);
  85. startPoint = spawn.point;
  86. startYaw = spawn.yaw;
  87. startPitch = -0.12; // a gentle downward tilt to frame the scene
  88. window.__params = Object.assign(
  89. { diag, floorY: spawn.floorY, clearance: Math.round(spawn.clr * 1000) / 1000 }, params);
  90. resetPlayer();
  91. window.addEventListener('resize', onResize);
  92. document.addEventListener('keydown', onKey);
  93. document.addEventListener('keyup', onKey);
  94. setupPointer();
  95. setupTouch();
  96. animate();
  97. }
  98. // Find a good first-person spawn: sample floor points across the scene, keep the
  99. // one with the most open horizontal clearance (so the camera isn't wedged in a
  100. // wall), and face it toward the scene's centre of geometry. Falls back to the
  101. // scene centre if nothing samples cleanly.
  102. function chooseSpawn(model, box, size, center, diag, eye) {
  103. const ray = new THREE.Raycaster();
  104. const down = new THREE.Vector3(0, -1, 0);
  105. const dirs = [];
  106. 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))); }
  107. const N = 4, inset = 0.18;
  108. const cands = [];
  109. const floorAt = (x, z) => {
  110. ray.set(new THREE.Vector3(x, box.max.y + diag * 0.1, z), down);
  111. const h = ray.intersectObject(model, true)[0];
  112. return h ? h.point.y : null;
  113. };
  114. const sample = (x, z) => {
  115. const fy = floorAt(x, z);
  116. if (fy === null) return;
  117. const p = new THREE.Vector3(x, fy + eye, z);
  118. let clr = diag;
  119. for (const d of dirs) { ray.set(p, d); const h = ray.intersectObject(model, true)[0]; if (h) clr = Math.min(clr, h.distance); }
  120. cands.push({ point: p, floorY: fy, clr });
  121. };
  122. for (let ix = 0; ix <= N; ix++) for (let iz = 0; iz <= N; iz++) {
  123. sample(box.min.x + size.x * (inset + (1 - 2 * inset) * ix / N),
  124. box.min.z + size.z * (inset + (1 - 2 * inset) * iz / N));
  125. }
  126. sample(center.x, center.z);
  127. if (!cands.length) {
  128. const fy = floorAt(center.x, center.z);
  129. return { point: new THREE.Vector3(center.x, (fy ?? box.min.y) + eye, center.z), yaw: 0, floorY: fy ?? box.min.y, clr: 0 };
  130. }
  131. cands.sort((a, b) => b.clr - a.clr);
  132. const best = cands[0];
  133. const dx = center.x - best.point.x, dz = center.z - best.point.z;
  134. best.yaw = (Math.abs(dx) + Math.abs(dz) < diag * 1e-3) ? 0 : Math.atan2(-dx, -dz);
  135. return best;
  136. }
  137. function resetPlayer() {
  138. collider = new Capsule(
  139. startPoint.clone().add(new THREE.Vector3(0, params.radius, 0)),
  140. startPoint.clone().add(new THREE.Vector3(0, params.eye, 0)),
  141. params.radius,
  142. );
  143. velocity.set(0, 0, 0);
  144. camera.position.copy(collider.end);
  145. camera.rotation.set(startPitch, startYaw, 0); // face the scene, not a wall
  146. }
  147. export function recenter() { if (collider) resetPlayer(); }
  148. function onResize() {
  149. if (!renderer) return;
  150. camera.aspect = container.clientWidth / container.clientHeight;
  151. camera.updateProjectionMatrix();
  152. renderer.setSize(container.clientWidth, container.clientHeight);
  153. }
  154. function onKey(e) { keys[e.code] = e.type === 'keydown'; }
  155. // --- look: pointer lock (desktop) + drag (touch/mouse) ---
  156. function setupPointer() {
  157. const el = renderer.domElement;
  158. el.addEventListener('click', () => { if (!isTouch()) el.requestPointerLock(); });
  159. document.addEventListener('mousemove', (e) => {
  160. if (document.pointerLockElement === el) applyLook(e.movementX, e.movementY);
  161. });
  162. }
  163. function setupTouch() {
  164. const el = renderer.domElement;
  165. let last = null;
  166. el.addEventListener('touchstart', (e) => {
  167. const t = e.changedTouches[0];
  168. if (t.clientX < window.innerWidth * 0.4) return; // left zone = joystick
  169. looking = t.identifier; last = { x: t.clientX, y: t.clientY };
  170. }, { passive: true });
  171. el.addEventListener('touchmove', (e) => {
  172. for (const t of e.changedTouches) {
  173. if (t.identifier === looking && last) {
  174. applyLook(t.clientX - last.x, t.clientY - last.y);
  175. last = { x: t.clientX, y: t.clientY };
  176. }
  177. }
  178. }, { passive: true });
  179. el.addEventListener('touchend', (e) => {
  180. for (const t of e.changedTouches) if (t.identifier === looking) { looking = null; last = null; }
  181. }, { passive: true });
  182. }
  183. function applyLook(dx, dy) {
  184. camera.rotation.y -= dx / 500;
  185. camera.rotation.x -= dy / 500;
  186. camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x));
  187. }
  188. function isTouch() { return matchMedia('(pointer: coarse)').matches; }
  189. // --- physics (adapted from three.js games_fps example) ---
  190. function collide() {
  191. const r = octree.capsuleIntersect(collider);
  192. onFloor = false;
  193. if (r) {
  194. onFloor = r.normal.y > 0;
  195. collider.translate(r.normal.multiplyScalar(r.depth));
  196. }
  197. }
  198. function forward() {
  199. camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); return dir;
  200. }
  201. function side() {
  202. camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); dir.cross(camera.up); return dir;
  203. }
  204. function step(dt) {
  205. let damp = Math.exp(-4 * dt) - 1;
  206. if (!onFloor) { velocity.y -= params.gravity * dt; damp *= 0.1; }
  207. velocity.addScaledVector(velocity, damp);
  208. const sd = dt * (onFloor ? params.accelGround : params.accelAir);
  209. let f = 0, s = 0;
  210. if (keys['KeyW'] || keys['ArrowUp']) f += 1;
  211. if (keys['KeyS'] || keys['ArrowDown']) f -= 1;
  212. if (keys['KeyD'] || keys['ArrowRight']) s += 1;
  213. if (keys['KeyA'] || keys['ArrowLeft']) s -= 1;
  214. f += joy.y; s += joy.x;
  215. if (f) velocity.add(forward().multiplyScalar(sd * f));
  216. if (s) velocity.add(side().multiplyScalar(sd * s));
  217. if (onFloor && keys['Space']) velocity.y = params.jump;
  218. collider.translate(velocity.clone().multiplyScalar(dt));
  219. collide();
  220. camera.position.copy(collider.end);
  221. if (camera.position.y < params.fallY) resetPlayer();
  222. }
  223. function animate() {
  224. raf = requestAnimationFrame(animate);
  225. const dt = Math.min(0.05, clock.getDelta()) / STEPS;
  226. for (let i = 0; i < STEPS; i++) step(dt);
  227. renderer.render(scene, camera);
  228. window.__genreconTris = renderer.info.render.triangles; // test/debug readiness signal
  229. window.__genreconCam = [camera.position.x, camera.position.y, camera.position.z];
  230. window.__dbg = { onFloor, vel: [velocity.x, velocity.y, velocity.z], keys: Object.keys(keys).filter((k) => keys[k]) };
  231. window.__genreconReady = true;
  232. }
  233. // mobile joystick hook (called from app.js)
  234. export function setJoystick(x, y) { joy.x = x; joy.y = y; }