161 lines
5.2 KiB
JavaScript
161 lines
5.2 KiB
JavaScript
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;
|
|
}
|
|
}
|