@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in
419 lines • 18.8 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Object3D } from "three";
import { isDevEnvironment, showBalloonWarning } from "../../engine/debug/index.js";
import { AssetReference } from "../../engine/engine_addressables.js";
import { findObjectOfType } from "../../engine/engine_components.js";
import { serializable } from "../../engine/engine_serialization.js";
import { delayForFrames, DeviceUtilities, getParam } from "../../engine/engine_utils.js";
import { NeedleXRSession } from "../../engine/engine_xr.js";
import { ButtonsFactory } from "../../engine/webcomponents/buttons.js";
import { WebXRButtonFactory } from "../../engine/webcomponents/WebXRButtons.js";
import { PlayerSync } from "../../engine-components-experimental/networking/PlayerSync.js";
import { Behaviour, GameObject } from "../Component.js";
import { USDZExporter } from "../export/usdz/USDZExporter.js";
import { NeedleMenu } from "../NeedleMenu.js";
import { SpatialGrabRaycaster } from "../ui/Raycaster.js";
import { Avatar } from "./Avatar.js";
import { XRControllerModel } from "./controllers/XRControllerModel.js";
import { XRControllerMovement } from "./controllers/XRControllerMovement.js";
import { WebARSessionRoot } from "./WebARSessionRoot.js";
import { XRState, XRStateFlag } from "./XRFlag.js";
const debug = getParam("debugwebxr");
const debugQuicklook = getParam("debugusdz");
/**
* WebXR component to enable VR, AR and Quicklook on iOS in your scene.
* It provides a simple wrapper around the {@link NeedleXRSession} API and adds some additional features like creating buttons or enabling default movement behaviour.
* @category XR
* @group Components
*/
export class WebXR extends Behaviour {
// UI
/** When enabled a button will be added to the UI to enter VR */
createVRButton = true;
/** When enabled a button will be added to the UI to enter AR */
createARButton = true;
/** When enabled a send to quest button will be shown if the device does not support VR */
createSendToQuestButton = true;
/** When enabled a QRCode will be created to open the website on a mobile device */
createQRCode = true;
// VR Settings
/** When enabled default movement behaviour will be added */
useDefaultControls = true;
/** When enabled controller models will automatically be created and updated when you are using controllers in WebXR */
showControllerModels = true;
/** When enabled hand models will automatically be created and updated when you are using hands in WebXR */
showHandModels = true;
// AR Settings
/** When enabled the scene must be placed in AR */
usePlacementReticle = true;
/** When assigned this object will be used as the AR placement reticle */
customARPlacementReticle;
/** When enabled you can position, rotate or scale your AR scene with one or two fingers */
usePlacementAdjustment = true;
/** Used when `usePlacementReticle` is enabled. This is the scale of the user in the scene in AR. Larger values make the 3D content appear smaller */
arScale = 1;
/** Experimental: When enabled an XRAnchor will be created for the AR scene and the position will be updated to the anchor position every few frames */
useXRAnchor = false;
/**
* When enabled the scene will be placed automatically when a point in the real world is found
*/
autoPlace = true;
/** When enabled the AR session root center will be automatically adjusted to place the center of the scene */
autoCenter = false;
/** When enabled a USDZExporter component will be added to the scene (if none is found) */
useQuicklookExport = false;
/** Preview feature enabling occlusion (when available: https://github.com/cabanier/three.js/commit/b6ee92bcd8f20718c186120b7f19a3b68a1d4e47)
* Enables the 'depth-sensing' WebXR feature to provide realtime depth occlusion. Only supported on Oculus Quest right now.
*/
useDepthSensing = false;
/**
* When enabled the spatial grab raycaster will be added or enabled in the scene
* @default true
*/
useSpatialGrab = true;
/** This avatar representation will be spawned when you enter a webxr session */
defaultAvatar;
_playerSync;
/** these components were created by the WebXR component on session start and will be cleaned up again in session end */
_createdComponentsInSession = [];
_usdzExporter;
static activeWebXRComponent = null;
awake() {
NeedleXRSession.getXRSync(this.context);
}
onEnable() {
// check if we're on a secure connection:
if (window.location.protocol !== "https:") {
showBalloonWarning("<a href=\"https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API\" target=\"_blank\">WebXR</a> only works on secure connections (https).");
}
if (this.useQuicklookExport) {
const existingUSDZExporter = GameObject.findObjectOfType(USDZExporter);
if (!existingUSDZExporter) {
// if no USDZ Exporter is found we add one and assign the scene to be exported
if (debug)
console.log("WebXR: Adding USDZExporter");
this._usdzExporter = GameObject.addComponent(this.gameObject, USDZExporter);
this._usdzExporter.objectToExport = this.context.scene;
this._usdzExporter.autoExportAnimations = true;
this._usdzExporter.autoExportAudioSources = true;
}
}
this.handleCreatingHTML();
this.handleOfferSession();
// If the avatar is undefined (meaning not set and also not null) we will use the fallback default avatar
if (this.defaultAvatar === true) {
if (debug)
console.warn("WebXR: No default avatar set, using static default avatar");
this.defaultAvatar = new AssetReference("https://cdn.needle.tools/static/avatars/DefaultAvatar.glb");
}
if (this.defaultAvatar) {
this._playerSync = this.gameObject.getOrAddComponent(PlayerSync);
this._playerSync.autoSync = false;
}
if (this._playerSync && typeof this.defaultAvatar != "boolean") {
this._playerSync.asset = this.defaultAvatar;
this._playerSync.onPlayerSpawned?.removeEventListener(this.onAvatarSpawned);
this._playerSync.onPlayerSpawned?.addEventListener(this.onAvatarSpawned);
}
}
onDisable() {
this._usdzExporter?.destroy();
this.removeButtons();
}
async handleOfferSession() {
if (this.createVRButton) {
const hasVRSupport = await NeedleXRSession.isVRSupported();
if (hasVRSupport && this.createVRButton) {
return NeedleXRSession.offerSession("immersive-vr", "default", this.context);
}
}
if (this.createARButton) {
const hasARSupport = await NeedleXRSession.isARSupported();
if (hasARSupport && this.createARButton) {
return NeedleXRSession.offerSession("immersive-ar", "default", this.context);
}
}
return false;
}
/** the currently active webxr input session */
get session() {
return NeedleXRSession.active ?? null;
}
/** immersive-vr or immersive-ar */
get sessionMode() {
return NeedleXRSession.activeMode ?? null;
;
}
/** Call to start an WebVR session */
async enterVR(init) {
return NeedleXRSession.start("immersive-vr", init, this.context);
}
/** Call to start an WebAR session */
async enterAR(init) {
return NeedleXRSession.start("immersive-ar", init, this.context);
}
/** Call to end a WebXR (AR or VR) session */
exitXR() {
NeedleXRSession.stop();
}
_exitXRMenuButton;
_previousXRState = 0;
_spatialGrabRaycaster;
get isActiveWebXR() {
return !WebXR.activeWebXRComponent || WebXR.activeWebXRComponent === this;
}
onBeforeXR(_mode, args) {
if (!this.isActiveWebXR) {
console.warn(`WebXR: another WebXR component is already active (${WebXR.activeWebXRComponent?.name}). This is ignored: ${this.name}`);
return;
}
WebXR.activeWebXRComponent = this;
if (_mode == "immersive-ar" && this.useDepthSensing) {
args.optionalFeatures = args.optionalFeatures || [];
args.optionalFeatures.push("depth-sensing");
}
}
async onEnterXR(args) {
if (!this.isActiveWebXR)
return;
if (debug)
console.log("WebXR onEnterXR");
// set XR flags
this._previousXRState = XRState.Global.Mask;
const isVR = args.xr.isVR;
XRState.Global.Set(isVR ? XRStateFlag.VR : XRStateFlag.AR);
// Handle AR session root
if (this.usePlacementReticle && args.xr.isAR) {
let sessionroot = GameObject.findObjectOfType(WebARSessionRoot);
if (!sessionroot) {
const implicitSessionRoot = new Object3D();
for (const ch of this.context.scene.children)
implicitSessionRoot.add(ch);
this.context.scene.add(implicitSessionRoot);
sessionroot = GameObject.addComponent(implicitSessionRoot, WebARSessionRoot);
this._createdComponentsInSession.push(sessionroot);
}
sessionroot.customReticle = this.customARPlacementReticle;
sessionroot.arScale = this.arScale;
sessionroot.arTouchTransform = this.usePlacementAdjustment;
sessionroot.autoPlace = this.autoPlace;
sessionroot.autoCenter = this.autoCenter;
sessionroot.useXRAnchor = this.useXRAnchor;
}
// handle VR controls
if (this.useDefaultControls) {
this.setDefaultMovementEnabled(true);
}
if (this.showControllerModels || this.showHandModels) {
this.setDefaultControllerRenderingEnabled(true);
}
// ensure we have a spatial grab raycaster for close grabs
if (this.useSpatialGrab) {
this._spatialGrabRaycaster = GameObject.findObjectOfType(SpatialGrabRaycaster) ?? undefined;
if (!this._spatialGrabRaycaster) {
this._spatialGrabRaycaster = this.gameObject.addComponent(SpatialGrabRaycaster);
}
}
this.createLocalAvatar(args.xr);
// for mobile screen AR we have the "X" button to exit and don't need to add an extra menu button to close
// https://linear.app/needle/issue/NE-5716
if (args.xr.isScreenBasedAR) {
}
else {
this._exitXRMenuButton = this.context.menu.appendChild({
label: "Quit XR",
onClick: () => this.exitXR(),
icon: "exit_to_app",
priority: 20_000,
});
}
}
onUpdateXR(_args) {
if (!this.isActiveWebXR)
return;
if (this._spatialGrabRaycaster) {
this._spatialGrabRaycaster.enabled = this.useSpatialGrab;
}
}
onLeaveXR(_) {
this._exitXRMenuButton?.remove();
if (!this.isActiveWebXR)
return;
// revert XR flags
XRState.Global.Set(this._previousXRState);
this._playerSync?.destroyInstance();
for (const comp of this._createdComponentsInSession) {
comp.destroy();
}
this._createdComponentsInSession.length = 0;
this.handleOfferSession();
delayForFrames(1).then(() => WebXR.activeWebXRComponent = null);
}
/** Call to enable or disable default controller behaviour */
setDefaultMovementEnabled(enabled) {
let movement = this.gameObject.getComponent(XRControllerMovement);
if (!movement && enabled) {
movement = this.gameObject.addComponent(XRControllerMovement);
this._createdComponentsInSession.push(movement);
}
if (movement)
movement.enabled = enabled;
return movement;
}
/** Call to enable or disable default controller rendering */
setDefaultControllerRenderingEnabled(enabled) {
let models = this.gameObject.getComponent(XRControllerModel);
if (!models && enabled) {
models = this.gameObject.addComponent(XRControllerModel);
this._createdComponentsInSession.push(models);
models.createControllerModel = this.showControllerModels;
models.createHandModel == this.showHandModels;
}
if (models)
models.enabled = enabled;
return models;
}
async createLocalAvatar(xr) {
if (this._playerSync && xr.running && typeof this.defaultAvatar != "boolean") {
this._playerSync.asset = this.defaultAvatar;
await this._playerSync.getInstance();
}
}
onAvatarSpawned = (instance) => {
// spawned webxr avatars must have a avatar component
if (debug)
console.log("WebXR.onAvatarSpawned", instance);
let avatar = GameObject.getComponentInChildren(instance, Avatar);
avatar ??= GameObject.addComponent(instance, Avatar);
};
// HTML UI
/** @deprecated use `getButtonsFactory()` or access `WebXRButtonFactory.getOrCreate()` directory */
getButtonsContainer() {
return this.getButtonsFactory();
}
/** Calling this function will get the Needle WebXR button factory (it will be created if it doesnt exist yet)
* @returns the Needle WebXR button factory */
getButtonsFactory() {
if (!this._buttonFactory) {
this._buttonFactory = WebXRButtonFactory.getOrCreate();
}
return this._buttonFactory;
}
_buttonFactory;
handleCreatingHTML() {
const xrButtonsPriority = 50;
if (this.createARButton || this.createVRButton || this.useQuicklookExport) {
// Quicklook / iOS
if ((DeviceUtilities.isiOS() && DeviceUtilities.isSafari()) || debugQuicklook) {
if (this.useQuicklookExport) {
const usdzExporter = GameObject.findObjectOfType(USDZExporter);
if (!usdzExporter || (usdzExporter && usdzExporter.allowCreateQuicklookButton)) {
const button = this.getButtonsFactory().createQuicklookButton();
this.addButton(button, xrButtonsPriority);
}
}
}
// WebXR
if (this.createARButton) {
const arbutton = this.getButtonsFactory().createARButton();
this.addButton(arbutton, xrButtonsPriority);
}
if (this.createVRButton) {
const vrbutton = this.getButtonsFactory().createVRButton();
this.addButton(vrbutton, xrButtonsPriority);
}
}
if (this.createSendToQuestButton && !DeviceUtilities.isQuest()) {
NeedleXRSession.isVRSupported().then(supported => {
if (!supported) {
const button = this.getButtonsFactory().createSendToQuestButton();
this.addButton(button, xrButtonsPriority);
}
});
}
if (this.createQRCode) {
const menu = findObjectOfType(NeedleMenu);
if (menu && menu.createQRCodeButton === false) {
// If the menu exists and the QRCode option is disabled we dont create it (NE-4919)
if (isDevEnvironment())
console.warn("WebXR: QRCode button is disabled in the Needle Menu component");
}
else if (!DeviceUtilities.isMobileDevice()) {
const qrCode = ButtonsFactory.getOrCreate().createQRCode();
this.addButton(qrCode, xrButtonsPriority);
}
}
}
_buttons = [];
addButton(button, priority) {
this._buttons.push(button);
button.setAttribute("priority", priority.toString());
this.context.menu.appendChild(button);
}
removeButtons() {
for (const button of this._buttons) {
button.remove();
}
this._buttons.length = 0;
}
}
__decorate([
serializable()
], WebXR.prototype, "createVRButton", void 0);
__decorate([
serializable()
], WebXR.prototype, "createARButton", void 0);
__decorate([
serializable()
], WebXR.prototype, "createSendToQuestButton", void 0);
__decorate([
serializable()
], WebXR.prototype, "createQRCode", void 0);
__decorate([
serializable()
], WebXR.prototype, "useDefaultControls", void 0);
__decorate([
serializable()
], WebXR.prototype, "showControllerModels", void 0);
__decorate([
serializable()
], WebXR.prototype, "showHandModels", void 0);
__decorate([
serializable()
], WebXR.prototype, "usePlacementReticle", void 0);
__decorate([
serializable(AssetReference)
], WebXR.prototype, "customARPlacementReticle", void 0);
__decorate([
serializable()
], WebXR.prototype, "usePlacementAdjustment", void 0);
__decorate([
serializable()
], WebXR.prototype, "arScale", void 0);
__decorate([
serializable()
], WebXR.prototype, "useXRAnchor", void 0);
__decorate([
serializable()
], WebXR.prototype, "autoPlace", void 0);
__decorate([
serializable()
], WebXR.prototype, "autoCenter", void 0);
__decorate([
serializable()
], WebXR.prototype, "useQuicklookExport", void 0);
__decorate([
serializable()
], WebXR.prototype, "useDepthSensing", void 0);
__decorate([
serializable()
], WebXR.prototype, "useSpatialGrab", void 0);
__decorate([
serializable(AssetReference)
], WebXR.prototype, "defaultAvatar", void 0);
//# sourceMappingURL=WebXR.js.map