Add AR engine: WebXR session with camera+gyro fallback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user