From 09f157e2a499ffecd68b081e9beadafaee3816be Mon Sep 17 00:00:00 2001 From: jessikitty Date: Wed, 3 Jun 2026 09:58:59 +1000 Subject: [PATCH] Add AR engine: WebXR session with camera+gyro fallback --- public/js/ar-engine.js | 160 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 public/js/ar-engine.js diff --git a/public/js/ar-engine.js b/public/js/ar-engine.js new file mode 100644 index 0000000..c7fc84f --- /dev/null +++ b/public/js/ar-engine.js @@ -0,0 +1,160 @@ +import * as THREE from "three"; + +/** + * AREngine abstracts two run modes: + * - "xr": real WebXR immersive-ar session (Android Chrome etc.) + * - "fallback": getUserMedia camera feed + DeviceOrientation gyro + * Both expose the same scene/camera/renderer + a per-frame callback. + */ +export class AREngine { + constructor(canvas, video) { + this.canvas = canvas; + this.video = video; + this.mode = null; + this.onFrame = null; + this.scene = new THREE.Scene(); + this.clock = new THREE.Clock(); + this._orientationEnabled = false; + this._deviceQuat = new THREE.Quaternion(); + this._screenTransform = new THREE.Quaternion(); + this._worldAlign = new THREE.Quaternion(); + } + + /** Detect best available mode without starting it. */ + static async detect() { + if (navigator.xr && (await navigator.xr.isSessionSupported?.("immersive-ar").catch(() => false))) { + return "xr"; + } + if (navigator.mediaDevices?.getUserMedia) return "fallback"; + return "none"; + } + + async start(mode) { + this.mode = mode; + this.renderer = new THREE.WebGLRenderer({ + canvas: this.canvas, alpha: true, antialias: true, preserveDrawingBuffer: false, + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setSize(window.innerWidth, window.innerHeight); + + if (mode === "xr") { + await this._startXR(); + } else { + await this._startFallback(); + } + } + + /* ---------------- WebXR PATH ---------------- */ + async _startXR() { + this.renderer.xr.enabled = true; + this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.01, 50); + + this.session = await navigator.xr.requestSession("immersive-ar", { + requiredFeatures: ["local-floor"], + optionalFeatures: ["hit-test", "dom-overlay"], + domOverlay: { root: document.getElementById("hunt-screen") }, + }); + await this.renderer.xr.setSession(this.session); + + this.renderer.setAnimationLoop((t, frame) => { + const dt = this.clock.getDelta(); + this.camera = this.renderer.xr.getCamera(); + if (this.onFrame) this.onFrame(dt, this.camera, frame); + this.renderer.render(this.scene, this.camera); + }); + + this.session.addEventListener("end", () => { + if (this._onEnd) this._onEnd(); + }); + } + + /* ---------------- FALLBACK PATH ---------------- */ + async _startFallback() { + this.video.classList.add("active"); + this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 50); + this.camera.position.set(0, 0, 0); + + // Camera feed + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + this.video.srcObject = stream; + this.stream = stream; + await this.video.play().catch(() => {}); + } catch (e) { + console.warn("Camera unavailable, running on plain background", e); + } + + // Device orientation for look-around + await this._enableOrientation(); + + window.addEventListener("resize", () => this._resize()); + this._resize(); + + const loop = () => { + this._raf = requestAnimationFrame(loop); + const dt = this.clock.getDelta(); + this._applyOrientation(); + if (this.onFrame) this.onFrame(dt, this.camera, null); + this.renderer.render(this.scene, this.camera); + }; + loop(); + } + + async _enableOrientation() { + const DOE = window.DeviceOrientationEvent; + if (DOE && typeof DOE.requestPermission === "function") { + try { + const res = await DOE.requestPermission(); + if (res !== "granted") return; + } catch { return; } + } + this._orientationEnabled = true; + window.addEventListener("deviceorientation", (e) => this._onOrientation(e), true); + } + + _onOrientation(e) { + if (e.alpha == null) return; + const deg = THREE.MathUtils.degToRad; + const alpha = deg(e.alpha); + const beta = deg(e.beta); + const gamma = deg(e.gamma); + const orient = deg(window.orientation || 0); + + // Standard device-orientation -> quaternion (Three.js convention) + const euler = new THREE.Euler(beta, alpha, -gamma, "YXZ"); + this._deviceQuat.setFromEuler(euler); + // Tilt the world so screen looks "forward" + const q1 = new THREE.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5)); // -90deg X + this._deviceQuat.multiply(q1); + // Account for screen rotation + this._screenTransform.setFromAxisAngle(new THREE.Vector3(0, 0, 1), -orient); + this._deviceQuat.multiply(this._screenTransform); + this._hasOrientation = true; + } + + _applyOrientation() { + if (this._hasOrientation) { + this.camera.quaternion.copy(this._deviceQuat); + } + } + + _resize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + + onEnd(cb) { this._onEnd = cb; } + + stop() { + if (this.session) this.session.end().catch(() => {}); + if (this._raf) cancelAnimationFrame(this._raf); + if (this.renderer) this.renderer.setAnimationLoop(null); + if (this.stream) this.stream.getTracks().forEach((t) => t.stop()); + this.video.classList.remove("active"); + this.video.srcObject = null; + } +}