playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
441 lines (440 loc) • 13.7 kB
JavaScript
import { EventHandler } from "../../core/event-handler.js";
import { platform } from "../../core/platform.js";
import { warnInsecureContext } from "../../core/secure-context-warning.js";
import { Mat4 } from "../../core/math/mat4.js";
import { Quat } from "../../core/math/quat.js";
import { Vec2 } from "../../core/math/vec2.js";
import { Vec3 } from "../../core/math/vec3.js";
import { XRTYPE_INLINE, XRTYPE_VR, XRTYPE_AR, XRDEPTHSENSINGUSAGE_CPU, XRDEPTHSENSINGUSAGE_GPU, XRDEPTHSENSINGFORMAT_L8A8, XRDEPTHSENSINGFORMAT_R16U, XRDEPTHSENSINGFORMAT_F32 } from "./constants.js";
import { XrDomOverlay } from "./xr-dom-overlay.js";
import { XrHitTest } from "./xr-hit-test.js";
import { XrImageTracking } from "./xr-image-tracking.js";
import { XrInput } from "./xr-input.js";
import { XrLightEstimation } from "./xr-light-estimation.js";
import { XrPlaneDetection } from "./xr-plane-detection.js";
import { XrAnchors } from "./xr-anchors.js";
import { XrMeshDetection } from "./xr-mesh-detection.js";
import { XrViews } from "./xr-views.js";
import { XrBridge } from "../../platform/graphics/xr-bridge.js";
class XrManager extends EventHandler {
static EVENT_AVAILABLE = "available";
static EVENT_START = "start";
static EVENT_END = "end";
static EVENT_UPDATE = "update";
static EVENT_ERROR = "error";
app;
_supported = platform.browser && !!navigator.xr;
_available = {};
_type = null;
_spaceType = null;
_session = null;
xrBridge = null;
get graphicsBinding() {
return this.xrBridge?.graphicsBinding ?? null;
}
_referenceSpace = null;
domOverlay;
hitTest;
imageTracking;
planeDetection;
meshDetection;
input;
lightEstimation;
views;
anchors;
_camera = null;
_localPosition = new Vec3();
_localRotation = new Quat();
_depthNear = 0.1;
_depthFar = 1e3;
_supportedFrameRates = null;
_width = 0;
_height = 0;
_framebufferSize = new Vec2();
_framebufferScaleFactor = 1;
constructor(app) {
super();
this.app = app;
this._available[XRTYPE_INLINE] = false;
this._available[XRTYPE_VR] = false;
this._available[XRTYPE_AR] = false;
this.domOverlay = new XrDomOverlay(this);
this.hitTest = new XrHitTest(this);
this.imageTracking = new XrImageTracking(this);
this.planeDetection = new XrPlaneDetection(this);
this.meshDetection = new XrMeshDetection(this);
this.input = new XrInput(this);
this.lightEstimation = new XrLightEstimation(this);
this.anchors = new XrAnchors(this);
this.views = new XrViews(this);
if (this._supported) {
navigator.xr.addEventListener("devicechange", () => {
this._deviceAvailabilityCheck();
});
this._deviceAvailabilityCheck();
}
}
destroy() {
if (this.xrBridge) {
this.xrBridge.destroy();
this.xrBridge = null;
}
}
start(camera, type, spaceType, options) {
let callback = options;
if (typeof options === "object") {
callback = options.callback;
}
if (!this._available[type]) {
warnInsecureContext("WebXR");
if (callback) callback(new Error("XR is not available"));
return;
}
if (this._session) {
if (callback) callback(new Error("XR session is already started"));
return;
}
this._camera = camera;
this._camera.camera.xr = this;
this._type = type;
this._spaceType = spaceType;
this._framebufferScaleFactor = options?.framebufferScaleFactor ?? 1;
this._setClipPlanes(camera.nearClip, camera.farClip);
const opts = {
requiredFeatures: [spaceType],
optionalFeatures: []
};
const device = this.app.graphicsDevice;
if (device?.isWebGPU) {
opts.requiredFeatures.push("webgpu");
}
if (type === XRTYPE_AR) {
opts.optionalFeatures.push("light-estimation");
opts.optionalFeatures.push("hit-test");
if (options) {
if (options.imageTracking && this.imageTracking.supported) {
opts.optionalFeatures.push("image-tracking");
}
if (options.planeDetection) {
opts.optionalFeatures.push("plane-detection");
}
if (options.meshDetection) {
opts.optionalFeatures.push("mesh-detection");
}
}
if (this.domOverlay.supported && this.domOverlay.root) {
opts.optionalFeatures.push("dom-overlay");
opts.domOverlay = { root: this.domOverlay.root };
}
if (options && options.anchors && this.anchors.supported) {
opts.optionalFeatures.push("anchors");
}
if (options && options.depthSensing && this.views.supportedDepth) {
opts.optionalFeatures.push("depth-sensing");
const usagePreference = [];
const dataFormatPreference = [];
usagePreference.push(XRDEPTHSENSINGUSAGE_GPU, XRDEPTHSENSINGUSAGE_CPU);
dataFormatPreference.push(XRDEPTHSENSINGFORMAT_F32, XRDEPTHSENSINGFORMAT_L8A8, XRDEPTHSENSINGFORMAT_R16U);
if (options.depthSensing.usagePreference) {
const ind = usagePreference.indexOf(options.depthSensing.usagePreference);
if (ind !== -1) usagePreference.splice(ind, 1);
usagePreference.unshift(options.depthSensing.usagePreference);
}
if (options.depthSensing.dataFormatPreference) {
const ind = dataFormatPreference.indexOf(options.depthSensing.dataFormatPreference);
if (ind !== -1) dataFormatPreference.splice(ind, 1);
dataFormatPreference.unshift(options.depthSensing.dataFormatPreference);
}
opts.depthSensing = {
usagePreference,
dataFormatPreference
};
}
if (options && options.cameraColor && this.views.supportedColor) {
opts.optionalFeatures.push("camera-access");
}
}
opts.optionalFeatures.push("hand-tracking");
if (options && options.optionalFeatures) {
opts.optionalFeatures = opts.optionalFeatures.concat(options.optionalFeatures);
}
if (this.imageTracking.supported && this.imageTracking.images.length) {
this.imageTracking.prepareImages((err, trackedImages) => {
if (err) {
if (callback) callback(err);
this.fire("error", err);
return;
}
if (trackedImages !== null) {
opts.trackedImages = trackedImages;
}
this._onStartOptionsReady(type, spaceType, opts, callback);
});
} else {
this._onStartOptionsReady(type, spaceType, opts, callback);
}
}
_onStartOptionsReady(type, spaceType, options, callback) {
navigator.xr.requestSession(type, options).then((session) => {
this._onSessionStart(session, spaceType, callback);
}).catch((ex) => {
this._camera.camera.xr = null;
this._camera = null;
this._type = null;
this._spaceType = null;
if (callback) callback(ex);
this.fire("error", ex);
});
}
end(callback) {
if (!this._session) {
if (callback) callback(new Error("XR Session is not initialized"));
return;
}
this.xrBridge?.releasePresentation();
if (callback) this.once("end", callback);
this._session.end();
}
isAvailable(type) {
return this._available[type];
}
_deviceAvailabilityCheck() {
for (const key in this._available) {
this._sessionSupportCheck(key);
}
}
initiateRoomCapture(callback) {
if (!this._session) {
callback(new Error("Session is not active"));
return;
}
if (!this._session.initiateRoomCapture) {
callback(new Error("Session does not support manual room capture"));
return;
}
this._session.initiateRoomCapture().then(() => {
if (callback) callback(null);
}).catch((err) => {
if (callback) callback(err);
});
}
updateTargetFrameRate(frameRate, callback) {
if (!this._session?.updateTargetFrameRate) {
callback?.(new Error("unable to update frameRate"));
return;
}
this._session.updateTargetFrameRate(frameRate).then(() => {
callback?.();
}).catch((err) => {
callback?.(err);
});
}
_sessionSupportCheck(type) {
navigator.xr.isSessionSupported(type).then((available) => {
if (this._available[type] === available) {
return;
}
this._available[type] = available;
this.fire("available", type, available);
this.fire(`available:${type}`, available);
}).catch((ex) => {
this.fire("error", ex);
});
}
_onSessionStart(session, spaceType, callback) {
let failed = false;
this._session = session;
this.xrBridge = new XrBridge(this.app.graphicsDevice, this);
const onVisibilityChange = () => {
this.fire("visibility:change", session.visibilityState);
};
const onClipPlanesChange = () => {
this._setClipPlanes(this._camera.nearClip, this._camera.farClip);
};
const onFrameRateChange = () => {
this.fire("frameratechange", this._session?.frameRate);
};
const onEnd = () => {
if (this._camera) {
this._camera.off("set_nearClip", onClipPlanesChange);
this._camera.off("set_farClip", onClipPlanesChange);
this._camera.camera.xr = null;
this._camera = null;
}
session.removeEventListener("end", onEnd);
session.removeEventListener("visibilitychange", onVisibilityChange);
session.removeEventListener("frameratechange", onFrameRateChange);
if (!failed) this.fire("end");
if (this.xrBridge) {
this.xrBridge.destroy();
this.xrBridge = null;
}
this._session = null;
this._referenceSpace = null;
this._width = 0;
this._height = 0;
this._type = null;
this._spaceType = null;
if (this.app.systems) {
this.app.requestAnimationFrame();
}
};
session.addEventListener("end", onEnd);
session.addEventListener("visibilitychange", onVisibilityChange);
this._camera.on("set_nearClip", onClipPlanesChange);
this._camera.on("set_farClip", onClipPlanesChange);
const gd = this.app.graphicsDevice;
const framebufferScaleFactor = gd.maxPixelRatio / window.devicePixelRatio * this._framebufferScaleFactor;
this.xrBridge.attachPresentation(this._session, {
framebufferScaleFactor,
depthNear: this._depthNear,
depthFar: this._depthFar,
onBindingError: (ex) => {
this.fire("error", ex);
}
});
if (this.session.supportedFrameRates) {
this._supportedFrameRates = Array.from(this.session.supportedFrameRates);
} else {
this._supportedFrameRates = null;
}
this._session.addEventListener("frameratechange", onFrameRateChange);
session.requestReferenceSpace(spaceType).then((referenceSpace) => {
this._referenceSpace = referenceSpace;
this.app.requestAnimationFrame();
if (callback) callback(null);
this.fire("start");
}).catch((ex) => {
failed = true;
session.end();
if (callback) callback(ex);
this.fire("error", ex);
});
}
_setClipPlanes(near, far) {
if (this._depthNear === near && this._depthFar === far) {
return;
}
this._depthNear = near;
this._depthFar = far;
if (!this._session) {
return;
}
this._session.updateRenderState({
depthNear: this._depthNear,
depthFar: this._depthFar
});
}
update(frame) {
if (!this._session) return false;
this.xrBridge.getFramebufferSize(frame, this._framebufferSize);
const width = this._framebufferSize.x;
const height = this._framebufferSize.y;
if (this._width !== width || this._height !== height) {
this._width = width;
this._height = height;
this.app.graphicsDevice.setResolution(width, height);
}
const pose = frame.getViewerPose(this._referenceSpace);
if (!pose) return false;
const lengthOld = this.views.list.length;
this.views.update(frame, pose.views);
const posePosition = pose.transform.position;
const poseOrientation = pose.transform.orientation;
this._localPosition.set(posePosition.x, posePosition.y, posePosition.z);
this._localRotation.set(poseOrientation.x, poseOrientation.y, poseOrientation.z, poseOrientation.w);
if (lengthOld === 0 && this.views.list.length > 0) {
const viewProjMat = new Mat4();
const view = this.views.list[0];
viewProjMat.copy(view.projMat);
const data = viewProjMat.data;
const fov = 2 * Math.atan(1 / data[5]) * 180 / Math.PI;
const aspectRatio = data[5] / data[0];
const farClip = data[14] / (data[10] + 1);
const nearClip = data[14] / (data[10] - 1);
const horizontalFov = false;
const camera = this._camera.camera;
camera.setXrProperties({
aspectRatio,
farClip,
fov,
horizontalFov,
nearClip
});
}
this._camera.camera._node.setLocalPosition(this._localPosition);
this._camera.camera._node.setLocalRotation(this._localRotation);
this.input.update(frame);
if (this._type === XRTYPE_AR) {
if (this.hitTest.supported) {
this.hitTest.update(frame);
}
if (this.lightEstimation.supported) {
this.lightEstimation.update(frame);
}
if (this.imageTracking.supported) {
this.imageTracking.update(frame);
}
if (this.anchors.supported) {
this.anchors.update(frame);
}
if (this.planeDetection.supported) {
this.planeDetection.update(frame);
}
if (this.meshDetection.supported) {
this.meshDetection.update(frame);
}
}
this.fire("update", frame);
this.xrBridge.beginFrame(frame, this._referenceSpace);
return true;
}
get supported() {
return this._supported;
}
get active() {
return !!this._session;
}
get type() {
return this._type;
}
get spaceType() {
return this._spaceType;
}
get session() {
return this._session;
}
get frameRate() {
return this._session?.frameRate ?? null;
}
get supportedFrameRates() {
return this._supportedFrameRates;
}
get framebufferScaleFactor() {
return this._framebufferScaleFactor;
}
set fixedFoveation(value) {
const layer = this.xrBridge?.presentationLayer;
if ((layer?.fixedFoveation ?? null) !== null) {
if (this.app.graphicsDevice.samples > 1) {
}
layer.fixedFoveation = value;
}
}
get fixedFoveation() {
const layer = this.xrBridge?.presentationLayer;
return layer?.fixedFoveation ?? null;
}
get camera() {
return this._camera ? this._camera.entity : null;
}
get visibilityState() {
if (!this._session) {
return null;
}
return this._session.visibilityState;
}
}
export {
XrManager
};