Add AR engine: WebXR session with camera+gyro fallback

This commit is contained in:
2026-06-02 16:15:01 +10:00
parent cf59d76655
commit 7c4d481d55
+160
View File
@@ -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;
}
}