Add root .gitignore for Unity Library/Temp/Logs, IDE folders, and node_modules. Include Assets, Needle TypeScript (MenuController, asset picker, WebXR), and project configuration. Made-with: Cursor
404 lines
16 KiB
Markdown
404 lines
16 KiB
Markdown
# Needle Engine — WebXR Reference
|
||
|
||
## Table of Contents
|
||
- [Overview](#overview)
|
||
- [Starting XR Sessions](#starting-xr-sessions)
|
||
- [XRRig and Movement](#xrrig-and-movement)
|
||
- [Component XR Lifecycle](#component-xr-lifecycle)
|
||
- [NeedleXRController](#needlexrcontroller)
|
||
- [Pointer Events in XR](#pointer-events-in-xr)
|
||
- [XR + Networking (Avatars)](#xr--networking-avatars)
|
||
- [Image Tracking](#image-tracking)
|
||
- [Depth Sensing](#depth-sensing)
|
||
- [DOM Overlay (HTML in AR)](#dom-overlay-html-in-ar)
|
||
- [iOS AR (USDZ + App Clip)](#ios-ar-usdz--app-clip)
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
Needle Engine supports WebXR for both VR and AR experiences. XR works across:
|
||
- **VR headsets** (Meta Quest, etc.) — `immersive-vr` mode
|
||
- **AR on Android** (Chrome) — `immersive-ar` mode via WebXR
|
||
- **AR on iOS** — via USDZ Quick Look export, or via the Needle App Clip (which provides real WebXR AR on iOS)
|
||
|
||
The `WebXR` component (added in Unity/Blender) handles the AR/VR buttons and session setup automatically. From code, use `NeedleXRSession.start()`.
|
||
|
||
---
|
||
|
||
## Starting XR Sessions
|
||
|
||
```ts
|
||
import { NeedleXRSession } from "@needle-tools/engine";
|
||
|
||
// Start VR
|
||
await NeedleXRSession.start("immersive-vr");
|
||
|
||
// Start AR via WebXR (Android, Quest, etc.)
|
||
await NeedleXRSession.start("immersive-ar");
|
||
|
||
// Shorthand "ar" — WebXR AR on supported devices, USDZ Quick Look on iOS
|
||
await NeedleXRSession.start("ar");
|
||
|
||
// With custom session init (e.g. request additional features)
|
||
await NeedleXRSession.start("immersive-ar", {
|
||
optionalFeatures: ["camera-access", "plane-detection", "mesh-detection"]
|
||
});
|
||
|
||
// Check XR state anytime
|
||
this.context.xr?.isInXR // boolean
|
||
this.context.xr?.session // XRSession
|
||
this.context.xr?.mode // "immersive-vr" | "immersive-ar"
|
||
this.context.xr?.controllers // NeedleXRController[]
|
||
```
|
||
|
||
### Default features requested by Needle Engine
|
||
|
||
These are requested automatically — you don't need to add them:
|
||
|
||
**AR (`immersive-ar`):** `anchors`, `local-floor`, `layers`, `dom-overlay`, `hit-test`, `unbounded`, `hand-tracking` (except on visionOS)
|
||
|
||
**VR (`immersive-vr`):** `local-floor`, `bounded-floor`, `high-fixed-foveation-level`, `layers`, `hand-tracking` (except on visionOS)
|
||
|
||
**Not requested by default** — add these via `onBeforeXR` or the session init if you need them:
|
||
- `camera-access` — needed for AR screenshots/camera feed compositing (add `ARCameraBackground` component or request manually)
|
||
- `depth-sensing` — depth-based occlusion
|
||
- `plane-detection` — detect real-world planes
|
||
- `mesh-detection` — detect room mesh geometry
|
||
- `image-tracking` — track reference images (added automatically by `WebXRImageTracking` component)
|
||
|
||
```ts
|
||
// Add extra features via component lifecycle:
|
||
onBeforeXR(mode: XRSessionMode, init: XRSessionInit) {
|
||
if (mode === "immersive-ar") {
|
||
init.optionalFeatures ??= [];
|
||
init.optionalFeatures.push("camera-access", "plane-detection");
|
||
}
|
||
}
|
||
|
||
// Or via static event (global scope, outside components):
|
||
NeedleXRSession.onSessionRequestStart(evt => {
|
||
evt.init.optionalFeatures?.push("mesh-detection");
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## XRRig and Movement
|
||
|
||
The `XRRig` component defines the player's position and scale in XR. It's the parent transform for the headset and controllers — moving/rotating the XRRig moves the player in the scene. If no XRRig exists, one is created automatically.
|
||
|
||
```ts
|
||
import { XRRig } from "@needle-tools/engine";
|
||
// Add to an object in Unity/Blender, or create from code
|
||
// The rig's world position = the player's feet position
|
||
// The rig's scale controls the player's size relative to the scene
|
||
// In AR: a larger rig scale makes the scene appear smaller (you're "bigger" relative to it)
|
||
```
|
||
|
||
### XRControllerMovement
|
||
Built-in locomotion: thumbstick movement + snap/smooth turn + teleport.
|
||
```ts
|
||
import { XRControllerMovement } from "@needle-tools/engine";
|
||
// Add to an object in the scene — works automatically with XRRig
|
||
// movementSpeed: 1.5 (m/s), rotationType: snap or smooth, teleport: enabled by default
|
||
```
|
||
|
||
### TeleportTarget
|
||
Mark surfaces as valid teleport destinations.
|
||
```ts
|
||
import { TeleportTarget } from "@needle-tools/engine";
|
||
// Add to floor/ground objects — XRControllerMovement uses these as valid teleport targets
|
||
```
|
||
|
||
You can create custom XR movement by implementing `onUpdateXR` on your own component, or by extending the built-in XR components:
|
||
|
||
```ts
|
||
import { Behaviour, NeedleXREventArgs, NeedleXRController, registerType } from "@needle-tools/engine";
|
||
import { Vector3 } from "three";
|
||
|
||
@registerType
|
||
export class MyXRMovement extends Behaviour {
|
||
speed = 2;
|
||
|
||
onUpdateXR(args: NeedleXREventArgs) {
|
||
const rig = args.xr.rig;
|
||
if (!rig) return;
|
||
|
||
// Move with left thumbstick
|
||
for (const ctrl of args.xr.controllers) {
|
||
const stick = ctrl.getStick("xr-standard-thumbstick");
|
||
if (stick && (Math.abs(stick.x) > 0.1 || Math.abs(stick.y) > 0.1)) {
|
||
// Get forward/right from the controller ray direction
|
||
const forward = new Vector3(0, 0, -1).applyQuaternion(ctrl.rayWorldQuaternion);
|
||
forward.y = 0;
|
||
forward.normalize();
|
||
const right = new Vector3(1, 0, 0).applyQuaternion(ctrl.rayWorldQuaternion);
|
||
right.y = 0;
|
||
right.normalize();
|
||
|
||
const dt = this.context.time.deltaTime;
|
||
rig.gameObject.position.add(forward.multiplyScalar(-stick.y * this.speed * dt));
|
||
rig.gameObject.position.add(right.multiplyScalar(stick.x * this.speed * dt));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Component XR Lifecycle
|
||
|
||
Implement these optional methods on any component extending `Behaviour`:
|
||
|
||
```ts
|
||
import { Behaviour, NeedleXREventArgs, NeedleXRControllerEventArgs, registerType } from "@needle-tools/engine";
|
||
|
||
@registerType
|
||
export class MyXRComponent extends Behaviour {
|
||
|
||
// Filter which XR modes this component handles
|
||
supportsXR(mode: XRSessionMode): boolean { return true; }
|
||
|
||
// Modify session init params before the session starts
|
||
onBeforeXR(mode: XRSessionMode, args: XRSessionInit) {
|
||
args.optionalFeatures?.push("hand-tracking");
|
||
}
|
||
|
||
onEnterXR(args: NeedleXREventArgs) {
|
||
console.log("Entered XR, mode:", args.xr.mode);
|
||
// args.xr is the NeedleXRSession
|
||
}
|
||
|
||
onUpdateXR(args: NeedleXREventArgs) {
|
||
// Per-frame during XR — access controllers here
|
||
for (const ctrl of args.xr.controllers) {
|
||
const pos = ctrl.gripWorldPosition;
|
||
const rot = ctrl.gripWorldQuaternion;
|
||
}
|
||
}
|
||
|
||
onLeaveXR(args: NeedleXREventArgs) {
|
||
console.log("Left XR");
|
||
}
|
||
|
||
onXRControllerAdded(args: NeedleXRControllerEventArgs) {
|
||
console.log("Controller added:", args.controller.index, args.controller.isHand ? "hand" : "controller");
|
||
}
|
||
|
||
onXRControllerRemoved(args: NeedleXRControllerEventArgs) {
|
||
console.log("Controller removed");
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## NeedleXRController
|
||
|
||
Wraps an `XRInputSource` — either a physical controller or a hand. Controller inputs are also emitted as pointer events, so `onPointerDown`/`onPointerClick` on components work with controllers too.
|
||
|
||
```ts
|
||
// Access in onUpdateXR or via context:
|
||
const controllers = this.context.xr?.controllers ?? [];
|
||
|
||
for (const ctrl of controllers) {
|
||
// Identity
|
||
ctrl.index // 0 = left, 1 = right (typically)
|
||
ctrl.isHand // true if hand tracking, false if controller
|
||
ctrl.hand // XRHand (if hand tracking)
|
||
ctrl.profiles // input source profiles
|
||
ctrl.connected // still connected?
|
||
|
||
// Spatial data (rig space)
|
||
ctrl.gripPosition // Vector3 — grip position in rig space
|
||
ctrl.gripQuaternion // Quaternion — grip rotation in rig space
|
||
ctrl.rayPosition // Vector3 — ray origin in rig space
|
||
ctrl.rayQuaternion // Quaternion — ray direction in rig space
|
||
|
||
// Spatial data (world space)
|
||
ctrl.gripWorldPosition // Vector3
|
||
ctrl.gripWorldQuaternion // Quaternion
|
||
ctrl.rayWorldPosition // Vector3
|
||
ctrl.rayWorldQuaternion // Quaternion
|
||
|
||
// Buttons and sticks (named access)
|
||
ctrl.getButton("trigger") // { value, pressed, touched }
|
||
ctrl.getButton("squeeze")
|
||
ctrl.getButton("primary-button") // A/X button
|
||
ctrl.getStick("xr-standard-thumbstick") // { x, y }
|
||
|
||
// Raw gamepad
|
||
ctrl.gamepad // Gamepad object
|
||
|
||
// Hit testing
|
||
ctrl.raycastHit // current raycast result (if any)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Pointer Events in XR
|
||
|
||
XR controllers and hands emit pointer events through the same system as mouse/touch. Your components' `onPointerDown`, `onPointerClick`, etc. work automatically with XR input.
|
||
|
||
### PointerEventData
|
||
|
||
The `PointerEventData` passed to pointer callbacks contains:
|
||
|
||
```ts
|
||
onPointerClick(args: PointerEventData) {
|
||
// Source identification
|
||
args.event // NEPointerEvent — the original event
|
||
args.event.mode // "screen" (mouse/touch), "tracked-pointer" (controller), "gaze", "transient-pointer" (hand)
|
||
args.deviceIndex // 0 for mouse/touch, controller index for XR
|
||
args.pointerId // unique pointer+button combo ID
|
||
args.button // 0=left, 1=middle, 2=right (mouse); button index (controller)
|
||
args.buttonName // "LeftButton", "trigger", "squeeze", etc.
|
||
args.pressure // 0–1 pressure value
|
||
|
||
// Hit information
|
||
args.object // Object3D that was hit
|
||
args.point // Vector3 — world position of the hit
|
||
args.normal // Vector3 — surface normal at hit point
|
||
args.distance // distance from origin to hit
|
||
args.face // triangle face that was hit
|
||
|
||
// State
|
||
args.isDown // true on pointer down frame
|
||
args.isUp // true on pointer up frame
|
||
args.isPressed // true while held
|
||
args.isClick // true on click
|
||
args.isDoubleClick // true on double click
|
||
|
||
// Control
|
||
args.use() // mark as consumed (other handlers won't receive it)
|
||
args.used // true if already consumed
|
||
args.setPointerCapture() // receive move events even when pointer leaves this object
|
||
args.releasePointerCapture()
|
||
args.stopPropagation() // stop event from reaching other handlers
|
||
}
|
||
```
|
||
|
||
**Screen coordinates:** `args.event.clientX` / `args.event.clientY` give the screen position of the pointer (for mouse/touch). For world-to-screen projection, use Three.js standard: `worldPos.clone().project(camera)` then convert to pixels.
|
||
|
||
Use `args.event.mode` to distinguish between mouse, touch, and XR controllers:
|
||
- `"screen"` — mouse or touch
|
||
- `"tracked-pointer"` — XR controller ray
|
||
- `"gaze"` — gaze-based input
|
||
- `"transient-pointer"` — XR hand pinch
|
||
|
||
---
|
||
|
||
## XR + Networking (Avatars)
|
||
|
||
The `WebXR` component takes a reference to an avatar prefab — when a user enters XR, their avatar is spawned and synced to other users via `PlayerSync`.
|
||
|
||
Typical XR multiplayer setup:
|
||
1. Add `SyncedRoom` for room management
|
||
2. Add `WebXR` component and assign an avatar prefab (the prefab must have `PlayerState`)
|
||
3. The avatar prefab should have `SyncedTransform` on the root and any tracked parts (head, hands)
|
||
4. Use `PlayerState.isLocalPlayer` to distinguish between local and remote players (e.g. hide the local player's head mesh to avoid seeing it from inside)
|
||
|
||
The XRRig position is synced via the avatar's `SyncedTransform`. Controller/hand positions are synced as child objects of the avatar. See the [networking reference](networking.md) for full details on PlayerSync and PlayerState.
|
||
|
||
---
|
||
|
||
## Depth Sensing
|
||
|
||
WebXR depth sensing provides per-pixel depth information from the device's depth sensor. This enables realistic occlusion where real-world objects appear in front of virtual ones.
|
||
|
||
Enable via the `WebXR` component's depth sensing toggle, or request manually:
|
||
```ts
|
||
await NeedleXRSession.start("immersive-ar", {
|
||
optionalFeatures: ["depth-sensing"]
|
||
});
|
||
```
|
||
|
||
Needle Engine uses the depth data automatically for occlusion when available — no additional code needed in most cases.
|
||
|
||
---
|
||
|
||
## Image Tracking
|
||
|
||
Track real-world images (markers) in AR sessions. Each tracked image maps a reference image to a 3D object that gets placed at the detected position. The `image-tracking` feature is automatically requested when `WebXRImageTracking` is in the scene.
|
||
|
||
```ts
|
||
import { WebXRImageTracking, WebXRImageTrackingModel } from "@needle-tools/engine";
|
||
|
||
// Set up image tracking from code:
|
||
const tracker = myObject.addComponent(WebXRImageTracking);
|
||
tracker.trackedImages = [
|
||
new WebXRImageTrackingModel({
|
||
url: "assets/my-marker.png", // reference image URL
|
||
widthInMeters: 0.09, // physical size of the printed marker (9cm)
|
||
object: my3DContent, // Object3D or AssetReference to show at the marker
|
||
imageDoesNotMove: false, // true for wall/floor markers (more stable)
|
||
hideWhenTrackingIsLost: true, // hide when marker is no longer visible
|
||
})
|
||
];
|
||
|
||
// Listen for tracking updates:
|
||
tracker.onTrackedImage = (images) => {
|
||
for (const img of images) {
|
||
console.log(img.url, img.state); // "tracked" or "emulated"
|
||
img.applyToObject(myObj); // apply position/rotation to an object
|
||
img.applyToObject(myObj, 0.5); // with smoothing (0–1)
|
||
}
|
||
};
|
||
```
|
||
|
||
Tips for marker images:
|
||
- Use high-contrast images with distinct features
|
||
- Avoid repetitive patterns or solid colors
|
||
- `widthInMeters` must match the actual printed size — mismatched sizes cause floating/sinking
|
||
|
||
---
|
||
|
||
## DOM Overlay (HTML in AR)
|
||
|
||
WebXR DOM Overlay allows HTML elements to be displayed on top of the AR camera feed. Needle Engine handles this automatically — `dom-overlay` is requested by default for AR sessions.
|
||
|
||
During an AR session, HTML elements inside the `<needle-engine>` element are reparented into the AR overlay container so they remain visible. You can place buttons, UI, or any HTML content alongside your 3D scene, and it will appear as a 2D overlay in AR.
|
||
|
||
```html
|
||
<needle-engine src="assets/scene.glb">
|
||
<!-- These elements will be visible as overlay during AR -->
|
||
<div style="position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);">
|
||
<button onclick="doSomething()">My AR Button</button>
|
||
</div>
|
||
</needle-engine>
|
||
```
|
||
|
||
Access the overlay container from code:
|
||
```ts
|
||
// During an AR session:
|
||
this.context.arOverlayElement // the DOM overlay container element
|
||
```
|
||
|
||
On Quest, DOM overlay is excluded (it interferes with `sessiongranted`). On Mozilla WebXR (e.g. Firefox Reality) and Needle App Clip, elements are automatically reparented to ensure visibility.
|
||
|
||
---
|
||
|
||
## iOS AR (USDZ + App Clip)
|
||
|
||
iOS Safari doesn't support WebXR natively. Needle Engine provides two paths:
|
||
|
||
**USDZ Quick Look** — Exports the scene as an interactive `.usdz` file that opens in Apple's AR viewer. Supports animations, audio, and basic interactions ("Everywhere Actions"). Configure via `USDZExporter` component or the `WebXR` component's USDZ settings.
|
||
|
||
**Needle App Clip (Needle Go)** — A native iOS app clip that provides real WebXR AR on iOS with full feature support (image tracking, plane detection, hand tracking). Starts automatically when the `WebXR` component is present and an iOS user taps the AR button. No extra setup needed — the App Clip loads the same web URL in a WebXR-capable native container.
|
||
|
||
```ts
|
||
// Use "ar" to automatically pick the best AR path per platform:
|
||
// Android/Quest → immersive-ar (WebXR), iOS → USDZ Quick Look or App Clip
|
||
await NeedleXRSession.start("ar");
|
||
|
||
// Force USDZ Quick Look specifically:
|
||
await NeedleXRSession.start("quicklook");
|
||
|
||
// immersive-ar is standard WebXR — works on Android, Quest, visionOS
|
||
// On iOS, Needle Engine automatically launches the Needle App Clip (Needle Go) to provide WebXR support
|
||
await NeedleXRSession.start("immersive-ar");
|
||
```
|