Reference
VR and AR in code
Opting a piece into VR and AR, and what it gets: controllers, hands, surfaces and hit tests.
A WebGL 3D piece goes into VR, AR or both with one line: an xr export. The runtime does the rest. It shows the Enter VR and Enter AR buttons where the device can do them, starts the session, puts the camera on the viewer's head, draws both eyes, and hands your code the controllers, the hands and, in AR, the room. This page is for writing that code by hand. For what a viewer sees, see VR pieces and AR pieces.
Opting in
Add the export at the top level of the module, beside the default export:
export const xr = 'vr';
| Value | What the piece offers |
|---|---|
'vr' | Enter VR: a world to step into, on a headset |
'ar' | Enter AR: the piece in the room, on a headset or an Android phone |
['vr', 'ar'] | Both, with a button for each |
Only the WebGL 3D engine can go into VR or AR. Any other value is ignored, with a warning in the console.
When the piece starts, the runtime asks the browser which of the declared modes it can enter, and puts a button for each at the bottom of the frame: Enter VR, Enter AR. On a device that can do neither, no button shows and the piece plays on the screen as usual, so it must still work there with mouse, touch and keys. The buttons show in the studio, on share links, in embeds and on the VR and AR page; the embed code already allows them. They don't show in thumbnails or video renders.
What the runtime does for you
- Starts the session from the button, with the floor at y = 0 where the device knows where the floor is.
- Moves the camera into
xr.rigfor the session. The headset moves the camera; your code moves the rig. - Draws
scenewithcamerafor each eye. Arender()your sketch returns, and with it any post-processing, is used on the screen only. - Reads every controller's buttons and sticks once a frame, before
update(). - Puts the camera back as it was, and the renderer at the page's size, when the viewer leaves.
So never add VRButton, ARButton or XRButton, call navigator.xr or renderer.xr.setSession, or start an animation loop of your own.
Units and the viewer
Build in metres, with the floor at y = 0. The viewer stands in xr.rig, eyes about 1.6 m above it, looking down -z.
- Place the rig where they start:
xr.rig.position.set(0, 0, 3). - Move or turn the rig to move them: walking with a stick, teleporting, riding something.
- Never set the camera's position or rotation for VR. Read where the viewer is and where they look with
camera.getWorldPosition(v)andcamera.getWorldDirection(v). - Set the screen camera up as usual, near the same viewpoint (
camera.position.set(0, 1.6, 3)). It's restored when they leave.
On a phone in AR there may be no floor to stand on: the space then starts where the phone is, so stand things on a surface it finds rather than at fixed heights.
The xr object
| Member | What it is |
|---|---|
supported | Whether this device can take the piece into VR or AR here |
modes | What it can enter: 'vr', 'ar' or both |
mode | 'vr' or 'ar' while a session runs, else null |
presenting | Whether a session is running right now |
rig | The group the viewer, the controllers and the hands are in |
controllers | The two controllers (or hands), connected or not |
left, right | The connected controller in that hand, else null |
on(type, fn) | Listens for an event; returns a function that stops listening |
exit() | Ends the session |
rays | Whether the pointer line from each controller is drawn (true to start) |
models | Whether the controller and hand models are drawn (true to start) |
backdrop, room, surfaces, surface(), gaze, pin(), unpin(), occlusion | The room, in AR; see In the room |
Controllers
Each of xr.controllers has:
| Field | What it is |
|---|---|
ray | An object at the controller, aiming down its -z |
grip | Where it's held: add a sword, a wand or a torch to it |
hand | The tracked hand's joints |
connected, handedness, isHand | Whether it's there, 'left' or 'right', and whether it's a hand rather than a controller |
trigger, squeeze | From 0 to 1, read each frame |
stick | { x, y } from -1 to 1, with y at 1 pushed forward |
a, b | Whether A or X, and B or Y, are held |
targetRayMode | 'tracked-pointer' for a controller or hand, 'screen' for a finger on a phone in AR |
intersect(objects) | What its ray points at, nearest first, as Raycaster.intersectObjects gives it. It looks past the models the runtime draws |
pulse(strength, ms) | A buzz, strength from 0 to 1 |
surface | In AR, where its ray meets a real surface this frame, else null |
grip, ray and the rig are already in the scene, so add things to them directly.
Events
const stop = xr.on('select', ({ controller, mode }) => { … });
stop(); // no longer listening
| Event | When |
|---|---|
selectstart, select, selectend | The trigger is pulled and let go, or a pinch with tracked hands, or a tap on a phone in AR |
squeezestart, squeeze, squeezeend | The grip |
connected, disconnected | A controller or hand comes and goes |
enter, exit | The session starts and ends; mode says which |
surfaces | In AR, the surfaces found have changed |
Every listener gets { type, controller, mode }, with controller null for the events that aren't about one. enter fires on the first frame in the session, when the camera is already where the viewer's head is.
A VR piece
Orbs to pop with the trigger in VR, or with a click or tap on a screen, and the left stick to walk.
import * as THREE from 'three';
export const xr = 'vr';
export default function sketch({ scene, camera, canvas, size, xr }) {
scene.background = new THREE.Color('#05060a');
scene.add(new THREE.HemisphereLight('#bcd4ff', '#101018', 1.2));
// The viewer starts 3 m back from the middle, and so does the screen camera.
xr.rig.position.set(0, 0, 3);
camera.position.set(0, 1.6, 3);
camera.lookAt(0, 1.4, 0);
const orbs = new THREE.Group();
scene.add(orbs);
for (let i = 0; i < 9; i++) {
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 24, 16),
new THREE.MeshStandardMaterial({ color: '#ffb86b', emissive: '#ff7a1a', emissiveIntensity: 0.8 }),
);
orb.position.set((i - 4) * 0.45, 1.2 + (i % 3) * 0.3, 0);
orbs.add(orb);
}
const pop = (hit, controller) => {
if (!hit) return;
orbs.remove(hit.object);
controller?.pulse(0.6, 40);
};
// In VR: the trigger, or a pinch.
xr.on('select', ({ controller }) => pop(controller.intersect(orbs)[0], controller));
// On a screen: a click or a tap.
const ray = new THREE.Raycaster();
canvas.addEventListener('pointerdown', (e) => {
const at = new THREE.Vector2((e.clientX / size.width) * 2 - 1, -(e.clientY / size.height) * 2 + 1);
ray.setFromCamera(at, camera);
pop(ray.intersectObjects(orbs.children)[0]);
});
return {
update({ delta }) {
// Walk with the left stick, at walking pace.
const left = xr.left;
if (left) {
xr.rig.position.x += left.stick.x * delta * 1.5;
xr.rig.position.z -= left.stick.y * delta * 1.5;
}
},
};
}
In the room
AR adds the real room. Everything above holds in AR too.
The backdrop
In AR the runtime leaves out scene.background and the fog, and hides xr.backdrop, a group already in the scene. Put whatever stands in for a room on a screen and in VR (a floor, walls, a table top, a sky) in xr.backdrop, so the piece looks complete there and the real room shows in AR. Keep what the viewer plays with out of it.
Additive blending adds light over the room, as it does on a screen, so glows, sparks and trails work as they are.
Surfaces
xr.surfaces lists what the headset or phone has found of the room. Each surface has:
| Field | What it is |
|---|---|
label | 'floor', 'wall', 'ceiling', 'table', 'couch', 'door', 'window', 'screen', 'plant', 'wall art', 'global mesh' for the whole scanned room, or 'other' |
kind | 'plane' or 'mesh' |
orientation | 'horizontal' or 'vertical' for a plane, null for a mesh |
center | Its middle, in the world |
normal | The way a plane faces, in the world (a wall's faces into the room); null for a mesh |
size | Its extents; x and z across a plane |
mesh | Its mesh, in xr.room |
xr.surface('table') returns the largest surface with that label, and xr.surface((s) => …) the largest that passes a test, else null. A phone's planes are often all 'other', so tell them apart by orientation and height.
Surfaces arrive a moment after the session starts, change as the viewer looks round, and may never come: no room set up, permission refused, or a phone that only finds flat planes. Start with a sensible placement in front of the viewer on enter, listen for surfaces, and move onto one when it turns up. Never wait for one before showing anything.
Rays against the room
xr.room is the group of the surfaces' meshes, in the scene and hidden. Rays still find them: controller.intersect(xr.room) gives the real wall or table a controller points at, with its surface in hit.object.userData.surface, and a Raycaster against xr.room lets things bounce, roll and land on the real room. Set xr.room.visible = true only to show what was found.
controller.surface is where that controller's ray, or a finger on a phone, meets a real surface this frame, as { point, normal } in the world. xr.gaze is the same for the middle of the view, a phone's crosshair. Both change every frame, so clone() what you keep.
Pinning
xr.pin(object) keeps an object where it is in the real room while AR runs, even as the device re-tracks. Call it after placing the object, and xr.unpin(object) before moving it again. It returns false outside AR or on a device without anchors. Pins are dropped when the session ends.
Occlusion
Set xr.occlusion = true before the viewer goes in, and real hands, people and furniture hide what's behind them, on headsets that can (Quest 3). It uses the headset's depth where it has it, and the scanned room otherwise. Leave it off for things that sit on or in a wall or table, which it would cut into.
On a phone
There are no controllers on a phone. A tap on the screen is a select from a controller whose targetRayMode is 'screen', with its ray running from the phone through the finger, so the same xr.on('select', …) handles taps, triggers and pinches.
An AR piece
A lamp that starts in front of the viewer, moves onto a table when one is found, and goes wherever they point and pull the trigger, or tap.
import * as THREE from 'three';
export const xr = ['vr', 'ar'];
export default function sketch({ scene, camera, xr }) {
scene.add(new THREE.HemisphereLight('#ffffff', '#404050', 1.5));
// A floor for screens and VR; hidden in AR, where the real floor shows.
const floor = new THREE.Mesh(new THREE.CircleGeometry(4, 48), new THREE.MeshStandardMaterial({ color: '#1a1c24' }));
floor.rotation.x = -Math.PI / 2;
xr.backdrop.add(floor);
const lamp = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.1, 2),
new THREE.MeshStandardMaterial({ color: '#77ccff', emissive: '#2299ff' }),
);
const home = new THREE.Vector3(0, 1, -1);
lamp.position.copy(home);
scene.add(lamp);
let moved = false;
const placeOn = (point) => {
xr.unpin(lamp);
lamp.position.copy(point);
lamp.position.y += 0.1;
xr.pin(lamp);
};
// Start in front of the viewer, below eye height.
const head = new THREE.Vector3();
const look = new THREE.Vector3();
xr.on('enter', ({ mode }) => {
moved = false;
if (mode !== 'ar') return;
camera.getWorldPosition(head);
camera.getWorldDirection(look).setY(0).normalize();
placeOn(head.clone().addScaledVector(look, 0.8).setY(head.y - 0.5));
});
// Onto a table, when one turns up.
xr.on('surfaces', () => {
const table = xr.surface('table');
if (xr.mode === 'ar' && table && !moved) placeOn(table.center);
});
// Point at a flat surface and pull the trigger, or tap it on a phone.
xr.on('select', ({ controller }) => {
const hit = controller.surface;
if (xr.mode === 'ar' && hit && hit.normal.y > 0.8) {
moved = true;
placeOn(hit.point);
}
});
xr.on('exit', () => lamp.position.copy(home));
}
What doesn't reach the headset
- The page's overlays: a timeline's text and image layers, the pointer and the gui panel don't show in VR or AR. Put scores, names and menus on planes in the world, such as a
CanvasTexturepanel 1 to 2 m away, or on a controller's grip. - Post-processing: an
EffectComposerand its bloom show on the screen only. Make the look stand on its own with emissive materials, additive blending and fog. - Lighting for the runtime's models: the controller and hand models are lit like the rest of the scene, so keep a light in it, even a
HemisphereLight, or they show black.
Performance and comfort
Headsets draw two eyes at 72 to 90 frames a second on a mobile chip. Keep draw calls low with instancing and merged geometry, particles in the low thousands, at most one light casting shadows, simple MeshStandardMaterial or MeshBasicMaterial, and light fragment shaders. Rendering into render targets in update() (GPUComputationRenderer, Reflector, CubeCamera) tends to break in VR.
Only the headset should turn the view. Never roll or shake the rig, keep the horizon level, move at walking pace, and turn the rig in 30° snaps on the right stick. Put things to reach between 0.5 and 3 m away, between chest and eye height; in AR, within 2 m, and a tabletop piece 0.3 to 0.8 m across.
Related
Still stuck, or something here out of date? Tell us