@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.
586 lines (508 loc) • 23.6 kB
text/typescript
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 { type NeedleXREventArgs, 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 automatically added to {@link NeedleMenu} that allows users to enter VR mode.
*/
createVRButton: boolean = true;
/**
* When enabled, a button will be automatically added to {@link NeedleMenu} that allows users to enter AR mode.
*/
createARButton: boolean = true;
/**
* When enabled, a button to send the experience to an Oculus Quest will be shown if the current device does not support VR.
* This helps direct users to compatible devices for optimal VR experiences.
*/
createSendToQuestButton: boolean = true;
/**
* When enabled, a QR code will be generated and displayed on desktop devices to allow easy opening of the experience on mobile devices.
*/
createQRCode: boolean = true;
// VR Settings
/**
* When enabled, default movement controls will be automatically added to the scene when entering VR.
* This includes teleportation and smooth locomotion options for VR controllers.
*/
useDefaultControls: boolean = true;
/**
* When enabled, 3D models representing the user's VR controllers will be automatically created and rendered in the scene.
*/
showControllerModels: boolean = true;
/**
* When enabled, 3D models representing the user's hands will be automatically created and rendered when hand tracking is available.
*/
showHandModels: boolean = true;
// AR Settings
/**
* When enabled, a reticle will be displayed to help place the scene in AR. The user must tap on a detected surface to position the scene.
*/
usePlacementReticle: boolean = true;
/**
* Optional custom 3D object to use as the AR placement reticle instead of the default one.
*/
customARPlacementReticle?: AssetReference;
/**
* When enabled, users can adjust the position, rotation, and scale of the AR scene with one or two fingers after initial placement.
*/
usePlacementAdjustment: boolean = true;
/**
* Determines the scale of the user relative to the scene in AR. Larger values make the 3D content appear smaller.
* Only applies when `usePlacementReticle` is enabled.
*/
arScale: number = 1;
/**
* When enabled, an XRAnchor will be created for the AR scene and its position will be regularly updated to match the anchor.
* This can help with spatial persistence in AR experiences.
* @experimental
*/
useXRAnchor: boolean = false;
/**
* When enabled, the scene will be automatically placed as soon as a suitable surface is detected in AR,
* without requiring the user to tap to confirm placement.
*/
autoPlace: boolean = false;
/**
* When enabled, the AR session root center will be automatically adjusted to place the center of the scene.
* This helps ensure the scene is properly aligned with detected surfaces.
*/
autoCenter: boolean = false;
/**
* When enabled, a USDZExporter component will be automatically added to the scene if none is found.
* This allows iOS and visionOS devices to view 3D content using Apple's AR QuickLook.
*/
useQuicklookExport: boolean = false;
/**
* When enabled, the 'depth-sensing' WebXR feature will be requested to provide real-time depth occlusion.
* Currently only supported on Oculus Quest devices.
* @see https://developer.mozilla.org/en-US/docs/Web/API/XRDepthInformation
* @experimental
*/
useDepthSensing: boolean = false;
/**
* When enabled, a {@link SpatialGrabRaycaster} will be added or enabled in the scene,
* allowing users to interact with objects at a distance in VR/AR.
* @default true
*/
useSpatialGrab: boolean = true;
/**
* Specifies the avatar representation that will be created when entering a WebXR session.
* Can be a reference to a 3D model or a boolean to use the default avatar.
*/
defaultAvatar?: AssetReference | boolean;
private _playerSync?: PlayerSync;
/** these components were created by the WebXR component on session start and will be cleaned up again in session end */
private readonly _createdComponentsInSession: Behaviour[] = [];
private _usdzExporter?: USDZExporter;
static activeWebXRComponent: WebXR | null = null;
/**
* Initializes the WebXR component by obtaining the XR sync object for this context.
* @internal
*/
awake() {
NeedleXRSession.getXRSync(this.context);
}
/**
* Sets up the WebXR component when it's enabled. Checks for HTTPS connection,
* sets up USDZ export if enabled, creates UI buttons, and configures avatar settings.
* @internal
*/
onEnable(): void {
// 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);
}
}
/**
* Cleans up resources when the component is disabled.
* Destroys the USDZ exporter if one was created and removes UI buttons.
* @internal
*/
onDisable(): void {
this._usdzExporter?.destroy();
this.removeButtons();
}
/**
* Checks if WebXR is supported and offers an appropriate session.
* This is used to show the WebXR session joining prompt in browsers that support it.
* @returns A Promise that resolves to true if a session was offered, false otherwise
*/
private 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(): NeedleXRSession | null {
return NeedleXRSession.active ?? null;
}
/** immersive-vr or immersive-ar */
get sessionMode(): XRSessionMode | null {
return NeedleXRSession.activeMode ?? null;;
}
/** While AR: this will return the currently active WebARSessionRoot component.
* You can also query this component in your scene with `findObjectOfType(WebARSessionRoot)`
*/
get arSessionRoot() {
return this._activeWebARSessionRoot;
}
/** Call to start an WebVR session.
*
* This is a shorthand for `NeedleXRSession.start("immersive-vr", init, this.context)`
*/
async enterVR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
return NeedleXRSession.start("immersive-vr", init, this.context);
}
/** Call to start an WebAR session
*
* This is a shorthand for `NeedleXRSession.start("immersive-ar", init, this.context)`
*/
async enterAR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
return NeedleXRSession.start("immersive-ar", init, this.context);
}
/** Call to end a WebXR (AR or VR) session.
*
* This is a shorthand for `NeedleXRSession.stop()`
*/
exitXR() {
NeedleXRSession.stop();
}
private _exitXRMenuButton?: HTMLElement;
private _previousXRState: number = 0;
private _spatialGrabRaycaster?: SpatialGrabRaycaster;
private _activeWebARSessionRoot: WebARSessionRoot | null = null;
private get isActiveWebXR() {
return !WebXR.activeWebXRComponent || WebXR.activeWebXRComponent === this;
}
/**
* Called before entering a WebXR session. Sets up optional features like depth sensing, if needed.
* @param _mode The XR session mode being requested (immersive-ar or immersive-vr)
* @param args The XRSessionInit object that will be passed to the WebXR API
* @internal
*/
onBeforeXR(_mode: XRSessionMode, args: XRSessionInit): void {
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");
}
}
/**
* Called when a WebXR session begins. Sets up the scene for XR by configuring controllers,
* AR placement, and other features based on component settings.
* @param args Event arguments containing information about the started XR session
* @internal
*/
async onEnterXR(args: NeedleXREventArgs) {
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 (args.xr.isAR) {
let sessionroot = GameObject.findObjectOfType(WebARSessionRoot);
// Only create a WebARSessionRoot if none is in the scene already
if (!sessionroot) {
if (this.usePlacementReticle) {
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);
}
else if (debug || isDevEnvironment()) {
console.warn("WebXR: No WebARSessionRoot found in scene and usePlacementReticle is disabled in WebXR component.")
}
}
this._activeWebARSessionRoot = sessionroot;
if (sessionroot) {
// sessionroot.enabled = this.usePlacementReticle; // < not sure if we want to disable the session root when placement reticle if OFF...
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,
});
}
}
/**
* Called every frame during an active WebXR session.
* Updates components that depend on the current XR state.
* @param _args Event arguments containing information about the current XR session frame
* @internal
*/
onUpdateXR(_args: NeedleXREventArgs): void {
if (!this.isActiveWebXR) return;
if (this._spatialGrabRaycaster) {
this._spatialGrabRaycaster.enabled = this.useSpatialGrab;
}
}
/**
* Called when a WebXR session ends. Restores pre-session state,
* removes temporary components, and cleans up resources.
* @param _ Event arguments containing information about the ended XR session
* @internal
*/
onLeaveXR(_: NeedleXREventArgs): void {
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._activeWebARSessionRoot = null;
this.handleOfferSession();
delayForFrames(1).then(() => WebXR.activeWebXRComponent = null);
}
/** Call to enable or disable default controller behaviour */
setDefaultMovementEnabled(enabled: boolean): XRControllerMovement | null {
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: boolean): XRControllerModel | null {
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;
}
/**
* Creates and instantiates the user's avatar representation in the WebXR session.
* @param xr The active session
*/
protected async createLocalAvatar(xr: NeedleXRSession) {
if (this._playerSync && xr.running && typeof this.defaultAvatar != "boolean") {
this._playerSync.asset = this.defaultAvatar;
await this._playerSync.getInstance();
}
}
/**
* Event handler called when a player avatar is spawned.
* Ensures the avatar has the necessary Avatar component.
* @param instance The spawned avatar 3D object
*/
private onAvatarSpawned = (instance: Object3D) => {
// 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 {@link getButtonsFactory} or directly access {@link WebXRButtonFactory.getOrCreate} */
getButtonsContainer(): WebXRButtonFactory {
return this.getButtonsFactory();
}
/**
* Returns the WebXR button factory, creating one if it doesn't exist.
* Use this to access and modify WebXR UI buttons.
* @returns The WebXRButtonFactory instance
*/
getButtonsFactory(): WebXRButtonFactory {
if (!this._buttonFactory) {
this._buttonFactory = WebXRButtonFactory.getOrCreate();
}
return this._buttonFactory;
}
/**
* Reference to the WebXR button factory used by this component.
*/
private _buttonFactory?: WebXRButtonFactory;
/**
* Creates and sets up UI elements for WebXR interaction based on component settings
* and device capabilities. Handles creating AR, VR, QuickLook buttons and utility buttons like QR codes.
*/
private 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);
}
}
}
/**
* Storage for UI buttons created by this component.
*/
private readonly _buttons: HTMLElement[] = [];
/**
* Adds a button to the UI with the specified priority.
* @param button The HTML element to add
* @param priority The button's priority value (lower numbers appear first)
*/
private addButton(button: HTMLElement, priority: number) {
this._buttons.push(button);
button.setAttribute("priority", priority.toString());
this.context.menu.appendChild(button);
}
/**
* Removes all buttons created by this component from the UI.
*/
private removeButtons() {
for (const button of this._buttons) {
button.remove();
}
this._buttons.length = 0;
}
}