FERRETGame engine over Babylon.js
Ferret

@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.

52 exports · source in packages/xr/src

Classes

BabylonXrDriver class

class BabylonXrDriver implements XrDriver

The 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 | nullthe 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): booleanfixed 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): booleanchange the framebuffer scale of a running session (false when this browser cannot)
pulse(hand: 'left' | 'right', strength: number, ms: number): voidvibrate a controller (strength 0..1)
dispose()

packages/xr/src/driver.ts:64

FramebufferScaler class

class FramebufferScaler

Moves 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: numberthe 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(): numberthe 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 LazyFollow

Where 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): numberreturns the yaw the panel faces for a head yaw; call once per frame

packages/xr/src/panel.ts:59

XrBody class

class XrBody

Arms 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 XrEffects

Effects 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 XrHands

The 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: numberhow far a hand reaches with its ray, metres, and how close counts as touching
touchPad: number
grabPad: number
showModels: booleandraw 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 XrLocomotion

Turn, 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: number0..1 strength of the vignette this frame
blink: number0..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): numberThe 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(): numberWhat 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 XrPanel

One 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 Component

The 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: numberhow 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 XrRig

The 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.TransformNodethe node the XR camera is parented to: at the feet, turned by the rig's yaw
yaw: number
seated: boolean
forward: 'head' | 'left'
fade: number0..1, how much of the view is faded to black because the head is in geometry
head: HeadPose
leftYaw: numberthe 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(): numberThe 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 Service

VR 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?: XrRigthe head and body of the player while in a session (needs a character with a PlayerController)
settings: XrSettings
loco?: XrLocomotion
ui?: XrUithe UI model drawn in the world, when the game has a ui service
hands?: XrHandsthe hands: ray and touch focus for the Interactor, grabbing
bodyArms?: XrBodythe character's arms, when body: 'arms'
effects?: XrEffectsshake, scripted camera moves and cutscene cameras, as 4.3 says
scaler?: FramebufferScalerthe 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 XrUi

The 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: numberthe 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 | undefined

Add 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 } = {}): Arc

Where 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): number

The 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 = {}): XrSettings

packages/xr/src/settings.ts:35

findPlayer function

function findPlayer(world: { entitiesWith<C extends PlayerController>(c: new (...a: any[]) => C): Entity[] }): { entity: Entity; player: PlayerController } | undefined

The 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): boolean

True 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): number

The 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 | null

packages/xr/src/panel.ts:30

loadingLayout function

function loadingLayout(ui: Pick<UIService, 'loading'>): LoadingLayout | null

packages/xr/src/panel.ts:39

localStore function

function localStore(key: string): SettingsStore

Browser 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 } | null

A 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 = {}): MenuItem

The "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): ReplicatedField

The 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 HandPose

A 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

HeadPose interface

interface HeadPose

The 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 HudLayout
lines: string[]
prompt: string
toasts: string[]
width: number
height: number

packages/xr/src/panel.ts:26

LoadingLayout interface

interface LoadingLayout
title: string
progress: number
width: number
height: number

packages/xr/src/panel.ts:38

SettingsStore interface

interface SettingsStore
load(): Partial<XrSettings> | null
save(s: XrSettings): void

packages/xr/src/settings.ts:21

UiInput interface

interface UiInput
ray: { origin: BABYLON.Vector3; dir: BABYLON.Vector3 } | nullworld ray of the pointing hand, or null
accept: booleanedges this frame: trigger/A = accept, B = back, stick up/down = move
back: boolean
move: number
select?: booleanthe trigger: chooses the row the laser points at (nothing when it points elsewhere)

packages/xr/src/ui.ts:4

XrBodyOptions interface

interface XrBodyOptions
ik: IkRigthe 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 XrControllerState
buttons: Partial<Record<'trigger' | 'squeeze' | 'thumbstick' | 'a' | 'b' | 'x' | 'y' | 'menu', boolean>>
stick: { x: number; y: number }
pinch?: booleanhand tracking: thumb to index finger
pose?: XrPose

packages/xr/src/driver.ts:48

XrDriver interface

interface XrDriver
isSupported(): 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): voidvibrate a controller (strength 0..1)
headPose?(): HeadPose | nullthe 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): booleanfixed 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): booleanchange the framebuffer scale of a running session (false when this browser cannot)
dispose?(): void

packages/xr/src/driver.ts:26

XrEnterOptions interface

interface XrEnterOptions
features?: string[]WebXR features asked for as optional (default: hand-tracking, layers, local-floor, bounded-floor)
framebufferScale?: numberthe framebuffer scale (1 = the headset's recommended size)

packages/xr/src/driver.ts:8

XrLimits interface

interface XrLimits

Limits 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 XrEnterOptions
enabled?: boolean
rig?: XrRigOptionsthe head-and-body rig: seated play, which hand "forward" follows
frameRate?: numberthe 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?: stringthe 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?: XrDrivera driver other than Babylon's (tests)

packages/xr/src/service.ts:20

XrPoses interface

interface XrPoses

Head 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 XrRigOptions
seated?: booleanseated 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?: numberhorizontal 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 XrSessionInfo
features: string[]the features the session actually got
frameRate: numberthe headset's display rate in Hz (0 = unknown)
multiview?: booleanone draw for both eyes is on (reported by the browser's capabilities; only the headset run shows whether it helps)
camera?: BABYLON.Camerathe camera of the player's view in the session

packages/xr/src/driver.ts:15

XrSettings interface

interface XrSettings

Comfort 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 | 90degrees per snap
turnSpeed: numberrad/s at full stick
vignette: number0..1, the vignette while moving or turning smoothly
seated: boolean
forward: 'head' | 'left'
dominantHand: 'left' | 'right'
teleport: booleanteleport (hold the teleport action, release to go) in addition to smooth move

packages/xr/src/settings.ts:4

XrStatus interface

interface XrStatus
supported: 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: XrSettings

packages/xr/src/settings.ts:19

PPM const

const PPM: 512

Pixels per metre on a panel's texture

packages/xr/src/panel.ts:4