@ferret/xr
VR through WebXR: a session service (detect, enter, leave, pause while the headset system menu is up), loaded only when a game asks for it.
Classes
BabylonXrDriver class
class BabylonXrDriver implements XrDriverThe default driver: Babylon's WebXR experience helper on the game's scene.
constructor(private scene: BABYLON.Scene, private nav: { xr?: { isSessionSupported(m: string): Promise<boolean> } } = navigator as never) | |
async isSupported(): Promise<boolean> | immersive-vr is available (navigator.xr.isSessionSupported) |
async enter(opts: XrEnterOptions, onVisibility: (v: XrVisibility) => void, onEnd: () => void): Promise<XrSessionInfo> | enter the session; resolves when the first frame can be rendered |
async exit(): Promise<void> | |
headPose(): HeadPose | null | the head in the room (local-floor space) for this moment; null while untracked |
controllers(): Partial<Record<'left' | 'right', XrControllerState>> | The xr-standard gamepad of Quest Touch / Touch Plus: 0 trigger, 1 squeeze, 3 thumbstick, 4 A or X, 5 B or Y; axes 2, 3 the stick (down-positive). |
setFoveation(level: number): boolean | fixed foveation 0..1 (false when the browser has none) |
supportedFrameRates(): number[] | the display rates the headset offers, Hz |
async setFrameRate(hz: number): Promise<boolean> | |
setFramebufferScale(scale: number): boolean | change the framebuffer scale of a running session (false when this browser cannot) |
pulse(hand: 'left' | 'right', strength: number, ms: number): void | vibrate a controller (strength 0..1) |
dispose() |
packages/xr/src/driver.ts:64
FramebufferScaler class
class FramebufferScalerMoves the framebuffer scale between min and max from the measured frame times: down when frames are late, up when there is headroom. It only says what it wants (wanted); the caller applies it when nobody would see the resolution change: between levels or behind a fade.
current: number | the scale the session runs at now |
constructor(readonly min = 0.8, readonly max = 1.2, start = 1, readonly step = 0.05, readonly window = 90) | |
sample(frameMs: number, periodMs: number, dt: number) | one rendered frame's time; periodMs is the display's frame period (13.9 at 72 Hz, 11.1 at 90 Hz) |
get wanted(): number | the scale it would like, one step at a time: down after 0.5 s of late frames, up after 3 s of headroom |
applied(scale: number) | the caller changed the framebuffer: start measuring again |
packages/xr/src/tier.ts:15
LazyFollow class
class LazyFollowWhere the HUD hangs: in front of the head, catching up lazily when the head turns past angle radians.
yaw: number | |
constructor(public angle = 30 * Math.PI / 180, public rate = 6) | |
update(headYaw: number, dt: number, first = false): number | returns the yaw the panel faces for a head yaw; call once per frame |
packages/xr/src/panel.ts:59
XrBody class
class XrBodyArms by IK: the character's hands follow the controllers through the animation layer IkRig.limb, the legs keep the locomotion animation. A hand that is not tracked leaves its arm as animated. Only the hand's position is driven; its turning is the animation's.
constructor(readonly o: XrBodyOptions, hands: () => Partial<Record<Hand, HandPose>> | undefined) | |
dispose() |
packages/xr/src/body.ts:20
XrEffects class
class XrEffectsEffects that move the view, made safe for a headset. The headset owns the head, so in a session: - a shake becomes a short controller vibration (camera:shake { strength, seconds }, or shake()); - a scripted camera move is a fade out, the move, a fade in (camera:move { apply }, or scriptedMove()); - a cutscene camera plays on a virtual screen in front of the player (camera:cutscene { camera, seconds? }, or cutscene()). Camera blends of CameraRig do nothing while the headset has the view (the rig is not placed). A game can handle the events itself first and mark them handled.
constructor(private xr: XrService, private world: World) | |
shake(strength: number, seconds: number) | Both controllers buzz for seconds, as strong as strength (0..1). |
scriptedMove(apply: () => void) | Fade out, apply the move while the view is black, fade in. |
cutscene(camera: BABYLON.Camera, seconds?: number, width = 2.4) | Show what camera sees on a screen width metres wide, 2.2 m in front of the player (fixed in the world), for seconds (until stopCutscene when omitted). |
stopCutscene() | |
get cutsceneActive(): boolean | |
update(dt: number) | Per frame: the cutscene's timer. |
dispose() |
packages/xr/src/effects.ts:11
XrHands class
class XrHandsThe player's hands as focus sources and grabbers. Each hand points a ray (the laser) and can touch: both feed the player's Interactor as focus sources (the trigger uses what the ray points at; touching uses it too), and the squeeze takes the Grabbable the hand touches or points at. Interactable does not change: interact(actor) is called by whatever source focused it.
readonly poses: Partial<Record<Hand, HandPose>> | |
readonly aimed: Partial<Record<Hand, { target: Entity; point: BABYLON.Vector3 }>> | what each hand's ray points at now, for drawing the laser |
reach: number | how far a hand reaches with its ray, metres, and how close counts as touching |
touchPad: number | |
grabPad: number | |
showModels: boolean | draw a simple procedural controller and a laser while a ray points at something (the Quest Touch glTF models are not shipped: no asset to ship offline yet) |
constructor(readonly world: World, readonly rig: XrRig, readonly actor: Entity, public dominant: () => Hand = () => 'right') | |
update(cs: Partial<Record<Hand, XrControllerState>> | undefined) | One rendered frame: hand poses in the world, the trigger and squeeze edges. |
dispose() |
packages/xr/src/hands.ts:31
XrLocomotion class
class XrLocomotionTurn, teleport and the comfort effects of a VR player. Moving is the game's move action, read by the player controller relative to the rig's forward; this class adds the stick turn (snap or smooth), the vignette and the teleport with its blink.
vignette: number | 0..1 strength of the vignette this frame |
blink: number | 0..1 black-out of a jump of the view (teleport, respawn) |
aiming: Arc | null | |
constructor(readonly rig: XrRig, public settings: () => XrSettings, readonly blinkTime = 0.12) | |
turn(x: number, dt: number): number | The stick turn for one frame: x in -1..1. Returns the yaw turned. |
update(dt: number, turnX: number, moveSpeed01: number) | Per frame: stick turn, vignette and the blink of a teleport. moveSpeed01 is the player's speed as a fraction of the walking speed. |
aim(physics: PhysicsService, hand: { origin: BABYLON.Vector3; dir: BABYLON.Vector3 } | null, held: boolean, canStand: (p: BABYLON.Vector3) => boolean = () => true) | Aim with the hand: call each frame; held is the teleport action. Releasing on a valid target teleports. |
teleportTo(p: BABYLON.Vector3) | Blink out, move the capsule, blink back in: the view never slides. |
jump(change: () => void) | Any jump of the view (teleport, respawn, a scripted camera move): black out, run change while nothing is seen, black back in. |
get blackout(): number | What the black-out sphere shows: the larger of the rig's wall fade and the blink. |
packages/xr/src/locomotion.ts:28
XrPanel class
class XrPanelOne panel in the world: a plane with a texture the painter draws. Headless (no document) it has no texture but lays out and hit-tests the same.
readonly mesh: BABYLON.Mesh | |
width: number | |
height: number | |
yaw: number | |
constructor(scene: BABYLON.Scene, readonly name: string) | |
get shown() | |
get center() | |
place(pos: BABYLON.Vector3, yaw: number) | |
show(pxW: number, pxH: number, signature: string, paint: (ctx: CanvasRenderingContext2D, w: number, h: number) => void) | Show with a layout in texture pixels; signature changes when the content does, and only then is the texture painted again. |
hide() | |
dispose() |
packages/xr/src/panel.ts:74
XrRemoteAvatar class
class XrRemoteAvatar extends ComponentThe look of a remote VR player: a head and two hands, moved smoothly between the updates (about 10 per second). Hidden when the owner leaves VR.
head: BABYLON.Mesh | |
hands: Record<'left' | 'right', BABYLON.Mesh> | |
follow: number | how fast the pieces follow the latest pose, 1/s |
onAttach() | |
receive(p: XrPoses | undefined) | Newest poses from the owner; undefined when the owner is no longer in VR. |
get inVr(): boolean | |
onUpdate(dt: number) | |
onDetach() |
packages/xr/src/presence.ts:45
XrRig class
class XrRigThe head and body of a VR player. The character's capsule is the player: the rig's root sits at the capsule's feet and turns with the character's yaw, and the headset adds its own offset and rotation under it. When the player walks in the room the capsule is moved toward the head's floor position through CharacterMovement (with collisions), so the character cannot be walked through a wall; if the head gets into geometry the view fades to black until it comes out. The rig never pushes the view back: the head owns the head.
readonly root: BABYLON.TransformNode | the node the XR camera is parented to: at the feet, turned by the rig's yaw |
yaw: number | |
seated: boolean | |
forward: 'head' | 'left' | |
fade: number | 0..1, how much of the view is faded to black because the head is in geometry |
head: HeadPose | |
leftYaw: number | the left controller's yaw in the room, when the game follows it |
constructor(scene: BABYLON.Scene, readonly movement: CharacterMovement, opts: XrRigOptions = {}) | |
take(player: PlayerController) | Take the view from the player controller (and give it back with release). |
release() | |
forwardYaw(): number | The yaw "move forward" means in the world. |
recenter() | Put the rig under the capsule so the head is over it, now. |
turnBy(dyaw: number) | A turn of the view around the head (snap or smooth): the head stays where it is in the world. |
step(head: HeadPose) | One fixed step with the head pose sampled for it: locomotion carries the root, then the capsule follows the head. |
dispose() |
packages/xr/src/rig.ts:25
XrService class
class XrService implements ServiceVR through WebXR. The game turns it on with createGame({ xr: { enabled: true } }); the service detects immersive-vr, enters and leaves a session at run time, and pauses the world while the headset's system menu is up. Events: xr:enter, xr:exit, xr:state.
readonly name: "xr" | |
supported: boolean | |
state: XrState | |
session?: XrSessionInfo | |
rig?: XrRig | the head and body of the player while in a session (needs a character with a PlayerController) |
settings: XrSettings | |
loco?: XrLocomotion | |
ui?: XrUi | the UI model drawn in the world, when the game has a ui service |
hands?: XrHands | the hands: ray and touch focus for the Interactor, grabbing |
bodyArms?: XrBody | the character's arms, when body: 'arms' |
effects?: XrEffects | shake, scripted camera moves and cutscene cameras, as 4.3 says |
scaler?: FramebufferScaler | the framebuffer scale that follows the frame time (tier xr) |
tier: { foveation: boolean; frameRate: number; framebufferScale: number; } | what the tier asked of the browser and what it gave: for xr.status |
constructor(readonly opts: XrOptions = {}, scene?: BABYLON.Scene) | |
get active(): boolean | |
init(world: World) | |
setSettings(p: Partial<XrSettings>) | |
settingsItem() | The "In VR" page for the game's pause menu: items.push(xr.settingsItem()). |
fixedUpdate() | Called before the components' fixed update, at the fixed rate. |
update(dt: number) | Per rendered frame: controllers into the input service, then the stick turn, the vignette, the teleport and the black-out. |
checkSupport(): Promise<boolean> | Ask the browser once whether immersive-vr can start. |
status(): XrStatus | |
async enter(opts: XrEnterOptions = {}): Promise<boolean> | |
async exit(): Promise<void> | |
pulse(hand: 'left' | 'right', strength: number, ms: number) | Vibrate a controller. No-op outside a session. |
dispose() |
packages/xr/src/service.ts:54
XrUi class
class XrUiThe UI model drawn in the world: the menu at 1.5 m when opened, then fixed in the world; the HUD and the prompt on a small panel that follows the head lazily; the loading bar as a panel. The menu is chosen by the laser and the trigger, or by the stick and A / B as on the screen.
readonly menu: XrPanel | |
readonly hud: XrPanel | |
readonly loading: XrPanel | |
hover: number | the row the laser points at, -1 for none |
laser: { from: BABYLON.Vector3; to: BABYLON.Vector3 } | null | |
constructor(scene: BABYLON.Scene, readonly ui: UIService, readonly distance = 1.5) | |
update(dt: number, head: Head, input: UiInput) | |
dispose() |
packages/xr/src/ui.ts:19
Functions
addXr function
function addXr(world: World, opts: XrOptions | undefined, nav?: unknown): XrService | undefinedAdd the service where it can work: xr set and WebXR in the browser. Anywhere else (OS Beroun) nothing is added.
packages/xr/src/service.ts:340
arcTarget function
function arcTarget(physics: PhysicsService, origin: BABYLON.Vector3, dir: BABYLON.Vector3, opts: { speed?: number; gravity?: number; steps?: number; minUp?: number } = {}): ArcWhere the hand's arc lands: a ballistic arc from the hand along its pointing direction, stopped by the first surface.
packages/xr/src/locomotion.ts:8
chooseFrameRate function
function chooseFrameRate(supported: number[], frameMs: number, preferred = 90): numberThe highest display rate the frame time allows (frames must fit 85% of the period), never below the lowest the headset offers.
packages/xr/src/tier.ts:4
clampSettings function
function clampSettings(s: XrSettings, limits: XrLimits = {}): XrSettingspackages/xr/src/settings.ts:35
findPlayer function
function findPlayer(world: { entitiesWith<C extends PlayerController>(c: new (...a: any[]) => C): Entity[] }): { entity: Entity; player: PlayerController } | undefinedThe entity of the first player controller in a world, if any.
packages/xr/src/rig.ts:104
hasWebXr function
function hasWebXr(nav: unknown = typeof navigator === 'undefined' ? undefined : navigator): booleanTrue where the browser has WebXR at all: the only check made on a page that does not use VR (OS Beroun has none).
packages/xr/src/driver.ts:59
hitMenu function
function hitMenu(l: MenuLayout, px: number, py: number): numberThe row under a point of the panel (texture pixels), or -1.
packages/xr/src/panel.ts:21
hudLayout function
function hudLayout(ui: Pick<UIService, 'hud' | 'promptText' | 'toasts'>): HudLayout | nullpackages/xr/src/panel.ts:30
loadingLayout function
function loadingLayout(ui: Pick<UIService, 'loading'>): LoadingLayout | nullpackages/xr/src/panel.ts:39
localStore function
function localStore(key: string): SettingsStoreBrowser storage under the game's key.
packages/xr/src/settings.ts:24
rayOnPanel function
function rayOnPanel(origin: BABYLON.Vector3, dir: BABYLON.Vector3, center: BABYLON.Vector3, yaw: number): { u: number; v: number; distance: number } | nullA point on a panel (the plane through center facing yaw) hit by a ray: metres across and up from the panel's centre, or null.
packages/xr/src/panel.ts:44
settingsItem function
function settingsItem(get: () => XrSettings, set: (p: Partial<XrSettings>) => void, limits: XrLimits = {}): MenuItemThe "In VR" page a game adds to its pause menu: items.push(xr.settingsItem())
packages/xr/src/settings.ts:48
toPixels function
function toPixels: (u: number, v: number, w: number, h: number) => { px: number; py: number; }The panel in texture pixels from metres across and up.
packages/xr/src/panel.ts:56
xrPoseField function
function xrPoseField(xr: () => XrService | undefined): ReplicatedFieldThe replicated field of a VR player: add it to the avatar's Replicated fields ({ xr: xrPoseField(() => game.xr) }). A player on a screen (no session) sends nothing new, so the field is absent for them; the others see a VR player's head and hands move.
packages/xr/src/presence.ts:19
Interfaces
HandPose interface
interface HandPoseA hand in the world: where it is, which way it points, and its pointing ray.
position: BABYLON.Vector3 | |
rotation: BABYLON.Quaternion | |
dir: BABYLON.Vector3 |
packages/xr/src/hands.ts:9
Head interface
interface Headposition: BABYLON.Vector3 | |
yaw: number |
packages/xr/src/ui.ts:13
HeadPose interface
interface HeadPoseThe head in the room (the local-floor reference space): metres from the room's origin on the floor, and the head's yaw.
x: number | |
y: number | |
z: number | |
yaw: number |
packages/xr/src/rig.ts:5
HudLayout interface
interface HudLayoutlines: string[] | |
prompt: string | |
toasts: string[] | |
width: number | |
height: number |
packages/xr/src/panel.ts:26
LoadingLayout interface
interface LoadingLayouttitle: string | |
progress: number | |
width: number | |
height: number |
packages/xr/src/panel.ts:38
MenuLayout interface
interface MenuLayoutwidth: number | |
height: number | |
title: string | |
message?: string | |
items: Array<{ id: string; y: number; h: number; label: string; value: string; disabled: boolean; selected: boolean }> |
packages/xr/src/panel.ts:6
SettingsStore interface
interface SettingsStoreload(): Partial<XrSettings> | null | |
save(s: XrSettings): void |
packages/xr/src/settings.ts:21
UiInput interface
interface UiInputray: { origin: BABYLON.Vector3; dir: BABYLON.Vector3 } | null | world ray of the pointing hand, or null |
accept: boolean | edges this frame: trigger/A = accept, B = back, stick up/down = move |
back: boolean | |
move: number | |
select?: boolean | the trigger: chooses the row the laser points at (nothing when it points elsewhere) |
packages/xr/src/ui.ts:4
XrBodyOptions interface
interface XrBodyOptionsik: IkRig | the character's IK rig (built before any clip played) |
arms: { left: [string, string, string]; right: [string, string, string] } | [shoulder, elbow, hand] bone names per arm |
show?: BABYLON.AbstractMesh[] | meshes of the body to show in the headset (arms, torso) and to hide (the head the camera sits in) |
hide?: BABYLON.AbstractMesh[] | |
elbow?: [number, number, number] | the elbow bends toward this point relative to the hand, in the rig's frame: behind and below by default |
packages/xr/src/body.ts:4
XrControllerState interface
interface XrControllerStatebuttons: Partial<Record<'trigger' | 'squeeze' | 'thumbstick' | 'a' | 'b' | 'x' | 'y' | 'menu', boolean>> | |
stick: { x: number; y: number } | |
pinch?: boolean | hand tracking: thumb to index finger |
pose?: XrPose |
packages/xr/src/driver.ts:48
XrDriver interface
interface XrDriverisSupported(): Promise<boolean> | immersive-vr is available (navigator.xr.isSessionSupported) |
enter(opts: XrEnterOptions, onVisibility: (v: XrVisibility) => void, onEnd: () => void): Promise<XrSessionInfo> | enter the session; resolves when the first frame can be rendered |
exit(): Promise<void> | |
pulse?(hand: 'left' | 'right', strength: number, ms: number): void | vibrate a controller (strength 0..1) |
headPose?(): HeadPose | null | the head in the room (local-floor space) for this moment; null while untracked |
controllers?(): Partial<Record<'left' | 'right', XrControllerState>> | both controllers for this frame: buttons, the stick (up-positive) and the grip pose in the room |
setFoveation?(level: number): boolean | fixed foveation 0..1 (false when the browser has none) |
supportedFrameRates?(): number[] | the display rates the headset offers, Hz |
setFrameRate?(hz: number): Promise<boolean> | |
setFramebufferScale?(scale: number): boolean | change the framebuffer scale of a running session (false when this browser cannot) |
dispose?(): void |
packages/xr/src/driver.ts:26
XrEnterOptions interface
interface XrEnterOptionsfeatures?: string[] | WebXR features asked for as optional (default: hand-tracking, layers, local-floor, bounded-floor) |
framebufferScale?: number | the framebuffer scale (1 = the headset's recommended size) |
packages/xr/src/driver.ts:8
XrLimits interface
interface XrLimitsLimits a game puts on the settings: teleport: false = a game with no teleport.
teleport?: boolean | |
seated?: boolean | |
smoothTurn?: boolean |
packages/xr/src/settings.ts:33
XrOptions interface
interface XrOptions extends XrEnterOptionsenabled?: boolean | |
rig?: XrRigOptions | the head-and-body rig: seated play, which hand "forward" follows |
frameRate?: number | the display rate the game prefers, Hz (default 90: the highest the headset offers up to it) |
body?: 'hands' | 'arms' | hands (default): controllers or tracked hands only, the character's body hidden; arms: the character's arms follow the controllers by two-bone IK (needs bodyRig) |
bodyRig?: XrBodyOptions | |
storageKey?: string | the game's key for saving the player's VR settings (default "game") |
store?: SettingsStore | |
limits?: XrLimits | |
actions?: { turn?: string; teleport?: string } | the game's actions the VR locomotion reads (default turn = "turn", teleport = "teleport") |
driver?: XrDriver | a driver other than Babylon's (tests) |
packages/xr/src/service.ts:20
XrPoses interface
interface XrPosesHead and hands of a VR player relative to the avatar's feet, in world axes, millimetres rounded: what travels.
h: [number, number, number, number] | |
l?: [number, number, number, number, number, number, number] | |
r?: [number, number, number, number, number, number, number] |
packages/xr/src/presence.ts:6
XrRigOptions interface
interface XrRigOptionsseated?: boolean | seated play: the rig is raised so the eyes are at the character's eye height |
forward?: 'head' | 'left' | "move forward" follows the head's yaw (default) or the left controller's |
fadeStart?: number | horizontal distance between the head and the capsule (blocked by a wall) where the view starts to fade, and where it is black |
fadeFull?: number |
packages/xr/src/rig.ts:7
XrSessionInfo interface
interface XrSessionInfofeatures: string[] | the features the session actually got |
frameRate: number | the headset's display rate in Hz (0 = unknown) |
multiview?: boolean | one draw for both eyes is on (reported by the browser's capabilities; only the headset run shows whether it helps) |
camera?: BABYLON.Camera | the camera of the player's view in the session |
packages/xr/src/driver.ts:15
XrSettings interface
interface XrSettingsComfort and control settings of the player; a game may limit them, never remove the snap turn and the vignette.
turn: 'snap' | 'smooth' | |
snapAngle: 30 | 45 | 90 | degrees per snap |
turnSpeed: number | rad/s at full stick |
vignette: number | 0..1, the vignette while moving or turning smoothly |
seated: boolean | |
forward: 'head' | 'left' | |
dominantHand: 'left' | 'right' | |
teleport: boolean | teleport (hold the teleport action, release to go) in addition to smooth move |
packages/xr/src/settings.ts:4
XrStatus interface
interface XrStatussupported: boolean | |
state: XrState | |
active: boolean | |
features: string[] | |
frameRate: number | |
tier?: { foveation: boolean; frameRate: number; framebufferScale: number; multiview: boolean } | what the headset tier got from the browser, while a session runs |
packages/xr/src/service.ts:39
Types
Hand type
type Hand = 'left' | 'right'packages/xr/src/hands.ts:6
XrState type
type XrState = 'off' | 'starting' | 'visible' | 'blurred'blurred: the headset's system menu is up or the headset is off the head.
packages/xr/src/service.ts:18
XrVisibility type
type XrVisibility = 'visible' | 'visible-blurred' | 'hidden'What the session service needs from a WebXR implementation. Babylon's is the default; tests pass a fake.
packages/xr/src/driver.ts:6
Constants
DEFAULT_FEATURES const
const DEFAULT_FEATURES: string[]packages/xr/src/driver.ts:56
DEFAULT_SETTINGS const
const DEFAULT_SETTINGS: XrSettingspackages/xr/src/settings.ts:19
PPM const
const PPM: 512Pixels per metre on a panel's texture
packages/xr/src/panel.ts:4