viewer.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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. const velocity = new THREE.Vector3();
  13. const dir = new THREE.Vector3();
  14. let onFloor = false;
  15. const keys = {};
  16. const joy = { x: 0, y: 0 }; // -1..1 strafe / forward from the mobile stick
  17. let looking = null; // active drag-look pointer id
  18. const STEPS = 5;
  19. function disposeScene(obj) {
  20. obj.traverse((o) => {
  21. if (o.geometry) o.geometry.dispose();
  22. if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach((m) => m.dispose());
  23. });
  24. }
  25. export function stopViewer() {
  26. if (raf) cancelAnimationFrame(raf);
  27. raf = null;
  28. window.removeEventListener('resize', onResize);
  29. document.removeEventListener('keydown', onKey);
  30. document.removeEventListener('keyup', onKey);
  31. if (renderer) {
  32. renderer.domElement.remove();
  33. renderer.dispose();
  34. renderer = null;
  35. }
  36. if (scene) { disposeScene(scene); scene = null; }
  37. }
  38. export async function startViewer(glbUrl) {
  39. stopViewer();
  40. container = document.getElementById('viewer-canvas');
  41. clock = new THREE.Clock();
  42. scene = new THREE.Scene();
  43. scene.background = new THREE.Color(0x10141b);
  44. camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.01, 5000);
  45. camera.rotation.order = 'YXZ';
  46. scene.add(new THREE.HemisphereLight(0xffffff, 0x444455, 1.1));
  47. scene.add(new THREE.AmbientLight(0xffffff, 0.35));
  48. const sun = new THREE.DirectionalLight(0xffffff, 1.4);
  49. sun.position.set(1, 2, 1);
  50. scene.add(sun);
  51. renderer = new THREE.WebGLRenderer({ antialias: true });
  52. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  53. renderer.setSize(container.clientWidth, container.clientHeight);
  54. renderer.outputColorSpace = THREE.SRGBColorSpace;
  55. container.appendChild(renderer.domElement);
  56. const gltf = await new GLTFLoader().loadAsync(glbUrl);
  57. const model = gltf.scene;
  58. scene.add(model);
  59. // --- derive scale-aware params from the bounding box ---
  60. const box = new THREE.Box3().setFromObject(model);
  61. const size = box.getSize(new THREE.Vector3());
  62. const center = box.getCenter(new THREE.Vector3());
  63. const diag = size.length() || 1;
  64. // Scaled to the scene diagonal so the "time to cross the room" feel is the
  65. // same at any (arbitrary, up-to-scale) reconstruction scale.
  66. params = {
  67. radius: diag * 0.01,
  68. eye: diag * 0.03,
  69. accelGround: diag * 0.5, // terminal walk speed ~= accelGround/4
  70. accelAir: diag * 0.16,
  71. gravity: diag * 0.6,
  72. jump: diag * 0.32,
  73. fallY: box.min.y - diag * 0.6,
  74. };
  75. camera.far = diag * 20;
  76. camera.updateProjectionMatrix();
  77. octree = new Octree();
  78. octree.fromGraphNode(model);
  79. // Spawn STANDING on the floor: raycast straight down through the scene centre.
  80. // (Dropping from the ceiling tunnels through thin reconstructed floors.)
  81. const ray = new THREE.Raycaster(
  82. new THREE.Vector3(center.x, box.max.y + diag * 0.1, center.z),
  83. new THREE.Vector3(0, -1, 0),
  84. );
  85. const hit = ray.intersectObject(model, true)[0];
  86. const floorY = hit ? hit.point.y : box.min.y;
  87. startPoint = new THREE.Vector3(center.x, floorY + params.eye, center.z);
  88. window.__params = Object.assign({ diag, floorY }, params);
  89. resetPlayer();
  90. window.addEventListener('resize', onResize);
  91. document.addEventListener('keydown', onKey);
  92. document.addEventListener('keyup', onKey);
  93. setupPointer();
  94. setupTouch();
  95. animate();
  96. }
  97. function resetPlayer() {
  98. collider = new Capsule(
  99. startPoint.clone().add(new THREE.Vector3(0, params.radius, 0)),
  100. startPoint.clone().add(new THREE.Vector3(0, params.eye, 0)),
  101. params.radius,
  102. );
  103. velocity.set(0, 0, 0);
  104. camera.position.copy(collider.end);
  105. }
  106. export function recenter() { if (collider) resetPlayer(); }
  107. function onResize() {
  108. if (!renderer) return;
  109. camera.aspect = container.clientWidth / container.clientHeight;
  110. camera.updateProjectionMatrix();
  111. renderer.setSize(container.clientWidth, container.clientHeight);
  112. }
  113. function onKey(e) { keys[e.code] = e.type === 'keydown'; }
  114. // --- look: pointer lock (desktop) + drag (touch/mouse) ---
  115. function setupPointer() {
  116. const el = renderer.domElement;
  117. el.addEventListener('click', () => { if (!isTouch()) el.requestPointerLock(); });
  118. document.addEventListener('mousemove', (e) => {
  119. if (document.pointerLockElement === el) applyLook(e.movementX, e.movementY);
  120. });
  121. }
  122. function setupTouch() {
  123. const el = renderer.domElement;
  124. let last = null;
  125. el.addEventListener('touchstart', (e) => {
  126. const t = e.changedTouches[0];
  127. if (t.clientX < window.innerWidth * 0.4) return; // left zone = joystick
  128. looking = t.identifier; last = { x: t.clientX, y: t.clientY };
  129. }, { passive: true });
  130. el.addEventListener('touchmove', (e) => {
  131. for (const t of e.changedTouches) {
  132. if (t.identifier === looking && last) {
  133. applyLook(t.clientX - last.x, t.clientY - last.y);
  134. last = { x: t.clientX, y: t.clientY };
  135. }
  136. }
  137. }, { passive: true });
  138. el.addEventListener('touchend', (e) => {
  139. for (const t of e.changedTouches) if (t.identifier === looking) { looking = null; last = null; }
  140. }, { passive: true });
  141. }
  142. function applyLook(dx, dy) {
  143. camera.rotation.y -= dx / 500;
  144. camera.rotation.x -= dy / 500;
  145. camera.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, camera.rotation.x));
  146. }
  147. function isTouch() { return matchMedia('(pointer: coarse)').matches; }
  148. // --- physics (adapted from three.js games_fps example) ---
  149. function collide() {
  150. const r = octree.capsuleIntersect(collider);
  151. onFloor = false;
  152. if (r) {
  153. onFloor = r.normal.y > 0;
  154. collider.translate(r.normal.multiplyScalar(r.depth));
  155. }
  156. }
  157. function forward() {
  158. camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); return dir;
  159. }
  160. function side() {
  161. camera.getWorldDirection(dir); dir.y = 0; dir.normalize(); dir.cross(camera.up); return dir;
  162. }
  163. function step(dt) {
  164. let damp = Math.exp(-4 * dt) - 1;
  165. if (!onFloor) { velocity.y -= params.gravity * dt; damp *= 0.1; }
  166. velocity.addScaledVector(velocity, damp);
  167. const sd = dt * (onFloor ? params.accelGround : params.accelAir);
  168. let f = 0, s = 0;
  169. if (keys['KeyW'] || keys['ArrowUp']) f += 1;
  170. if (keys['KeyS'] || keys['ArrowDown']) f -= 1;
  171. if (keys['KeyD'] || keys['ArrowRight']) s += 1;
  172. if (keys['KeyA'] || keys['ArrowLeft']) s -= 1;
  173. f += joy.y; s += joy.x;
  174. if (f) velocity.add(forward().multiplyScalar(sd * f));
  175. if (s) velocity.add(side().multiplyScalar(sd * s));
  176. if (onFloor && keys['Space']) velocity.y = params.jump;
  177. collider.translate(velocity.clone().multiplyScalar(dt));
  178. collide();
  179. camera.position.copy(collider.end);
  180. if (camera.position.y < params.fallY) resetPlayer();
  181. }
  182. function animate() {
  183. raf = requestAnimationFrame(animate);
  184. const dt = Math.min(0.05, clock.getDelta()) / STEPS;
  185. for (let i = 0; i < STEPS; i++) step(dt);
  186. renderer.render(scene, camera);
  187. window.__genreconTris = renderer.info.render.triangles; // test/debug readiness signal
  188. window.__genreconCam = [camera.position.x, camera.position.y, camera.position.z];
  189. window.__dbg = { onFloor, vel: [velocity.x, velocity.y, velocity.z], keys: Object.keys(keys).filter((k) => keys[k]) };
  190. window.__genreconReady = true;
  191. }
  192. // mobile joystick hook (called from app.js)
  193. export function setJoystick(x, y) { joy.x = x; joy.y = y; }