ARScene
Augmented reality: a Scene drawn over the device camera's picture, its camera moved
as the phone moves, so 3D models stay on a real floor, table or printed picture. A camera preview
in the UI, a photo or a QR code is CameraView instead.
- place a 3D model on a real table or floor in AR
- tap to place an object on the floor or a table
- let the user drag, pinch and twist a placed model
- show a video or a model on a printed picture (image targets)
- find the floors, tables and walls the camera sees
- light the model like the room around it
- take a photo of the AR view
Platforms: iOS, Android, the le.codes viewer app; on the web (Web Lite, lecodes web) plain AR only — the camera picture under the scene, turned by the device's sensors, no surfaces, no image targets, https needed; the web player opens the session but shows no camera picture; not on desktop or in headless tests — check ARScene.isSupported.
At a glance
const main = async () => {
const scene = new ARScene()
const chair = await Model.load(asset('./chair.glb'))
scene.root.add(chair) // on the floor, once the session finds it
scene.root.addEventListener('track', () => toast('Drag to move · pinch to scale'))
scene.addControls(chair) // drag / pinch / twist to place it
try {
await scene.open() // camera permission → warm render → session → on screen, drawn
} catch (e) {
toast('AR is not available here') // denied, no ARKit / ARCore, or superseded
}
}
main()Add AR to an app: the ar plugin, ARScene.isSupported
ARScene is a plugin: list it in app.json — "plugins": { "ar": "*" } (one list for every
platform). iOS runs ARKit, Android ARCore (the app installs on a phone without it; the first
open() may ask to install Google Play Services for AR). ARScene.isSupported is false on a host
without the plugin — check it before showing an AR button. It says the plugin is there, not that
the device can track: open() still rejects unavailable on a phone without ARKit / ARCore.
Everything from Scene (options, add/remove, events, overlays, capture()) applies. A
session of the plugin drives the scene every frame: it moves scene.camera as the device moves,
gives it the camera's lens and draws the camera image behind everything.
Open the camera and start AR: new ARScene, open()
new ARScene(options?: SceneOptions & {
mode?: 'world' | 'images' // 'world' (default): planes, hit tests, anchors, images
}) // 'images': image targets only — lighter; no planes, no hit test, no root
ARScene.isSupported // static: this host has the "ar" plugin
scene.mode // the mode it was created with
scene.useWarmRender = true // open() warm-renders the scene first (default true)
scene.open(options?: {
transition?: Transition
planes?: 'horizontal' | 'vertical' | 'both' | 'none' // what to look for; default 'horizontal'
lightEstimation?: boolean // 'light' events; default false
followLight?: boolean // the scene's ambient follows the light
worldAlignment?: 'gravity' | 'gravityAndHeading' // -Z north (iOS, with location access)
autoFocus?: boolean // default true
}): Promise<void>
scene.close(options?): void // stops the session (the camera is let go) and leaves the screenopen() asks for the camera, warm-renders, starts the session and waits for its first camera
frame before the scene becomes current. The current destination (typically a loading screen) stays
visible the whole time: the swap — and the transition — happens only once everything is ready.
It resolves at the first frame drawn of the presented scene: after await scene.open() the
scene is on screen, scene.camera.displaySize is its view's size, and hitTest() and
captureFrame() work. It rejects — and leaves the screen as it was — when the camera is denied
(denied), the device has no ARKit / ARCore or the user declined installing it (unavailable), the
session failed, or another navigation happened meanwhile; a close() or another navigation before
that first frame — while it prepares, or after the swap — rejects it as superseded. Router.push(arScene) /
Router.replace(arScene) wait the same way — to the first frame — with the options of the last
open().
Tracking quality and session errors: trackingState
scene.trackingState // 'notAvailable' | 'limited' | 'normal'
scene.trackingReason // with 'limited': 'initializing' | 'excessiveMotion' |
// 'insufficientFeatures' | 'relocalizing' — else null
scene.addEventListener('tracking', (state, reason) => {}) // the session's first event, then on change
scene.addEventListener('error', ({ code, message }) => {}) // the session failed and is overThe camera is the session's: read it (scene.camera.worldPosition, getRay), never move it — the
session overwrites it every frame.
Keep a model on the floor or at a place in the room: root, anchor()
scene.root: Anchor // the floor (not in 'images')
scene.anchor(pose: { position, rotation }): Anchor // a place in the room (a hit test's pose)
anchor.tracked // the session holds it now
anchor.addEventListener('track', () => {})
anchor.addEventListener('untrack', () => {})
anchor.remove() // the session lets it go; it leaves the sceneAn Anchor is a Node the session moves: put content under it (it joins the scene with
it). root is pinned on the first horizontal plane the session finds — it fires track then;
until that it sits at the world's origin, where the session began. scene.anchor(pose) keeps a
place where it is in the room as the session's map of it improves (iOS reports untrack / track
around a relocalization). Every tracked anchor fires untrack when the session ends (close() or
an error).
Show a video or a model on a printed picture: createAnchor
scene.createAnchor(source: FetchResponse | File, physicalWidth = 0.2): ImageAnchor // an image target
imageAnchor.extent // [width, height] of the image, metrescreateAnchor follows a picture — a fetched image, physicalWidth its printed width in
metres — and returns an ImageAnchor: a rigid pose in metres, like every anchor (no
scale): its centre, Y out of the image, the image lying in X (its width) and Z (towards its
bottom). How big the image is, is extent — [width, height] in metres, the session's estimate,
refined while it tracks the image (ARKit's scale estimate, ARCore's measured extent). Before the
image is first found it is [physicalWidth, 0]: the height comes with the image's proportions,
which the session reads, at the first track. An image anchor is hidden while not tracked.
Image targets can be added (and removed, anchor.remove()) before open() or at any time: on a
running scene the image set is updated in place — tracking, planes and anchors stay. On Android an
image being tracked is looked for again when the set changes (ARCore starts the new set afresh): an
untrack, then a track once the camera sees it again.
A video on a poster — a plane in the image, sized to it:
const main = async () => {
const scene = new ARScene({ mode: 'images' })
const poster = scene.createAnchor(await fetch(asset('./poster.jpg')), 0.42) // printed 42 cm wide
const player = new VideoPlayer(asset('./clip.mp4'))
const screen = Mesh.plane({ material: Material.video(player.texture), eulerAngles: [-90, 0, 0] }) // XY → the image's XZ
poster.add(screen)
poster.addEventListener('track', () => {
screen.scale = [poster.extent[0], poster.extent[1], 1]
player.play()
})
await scene.open()
}
main()Find floors, tables and walls: scene.planes
scene.planes: ARPlane[] // what the session holds now
scene.addEventListener('plane', ({ plane, change, mergedInto }) => {})
// change: 'added' | 'updated' | 'removed'; mergedInto: the plane that absorbed this one (Android)
scene.showPlanes = true // a grid drawn on every plane
plane.type // 'horizontal' | 'vertical'
plane.extent // [width, depth], metres, in the plane's X and Z
plane.polygon // its outline: x, z pairs in the plane's space
plane.center // its centre in the world
plane.planeId // what a hit test namesAn ARPlane is an anchor at the plane's centre with Y along its normal; it moves and grows as the
session learns the surface (updates come at most ten times a second, for a change over a
centimetre). Walls come only with open({ planes: 'vertical' }) or 'both':
const scene = new ARScene()
scene.showPlanes = true // a grid on every surface found
scene.addEventListener('plane', ({ plane, change }) => {
if (change === 'added' && plane.type === 'vertical') toast(`A wall, ${plane.extent[0].toFixed(1)} m wide`)
})
scene.open({ planes: 'both' })Tap to place an object on the floor or a table in AR: hitTest
A tap's point on the screen becomes a pose on the real surface under it; anchor the object there.
scene.hitTest(x, y, target?: 'planes' | 'estimated' | 'any'): Promise<Array<{
pose: { position: Vec3, rotation: Quat }
type: 'plane' | 'estimated' // on a found plane, or on a point / surface the session estimates
plane: ARPlane | null
distance: number // metres from the camera
}>>What is under a point of the scene's view, nearest first — the point as the scene's touches give it
(clientX / clientY of a click or a touchstart on the scene, relative to its view; the session
fits the camera to that view, not to the window); target defaults to 'any'. Empty when nothing
is. Place things with it:
const scene = new ARScene()
scene.addEventListener('click', async (ev) => {
const [hit] = await scene.hitTest(ev.clientX, ev.clientY, 'planes')
if (hit) scene.anchor(hit.pose).add(await Model.load(asset('./lamp.glb')))
})
scene.open()Light the model like the room: lightEstimate, followLight
scene.lightEstimate // { intensity, temperature } | null — 1000 = neutral, kelvin
scene.addEventListener('light', ({ intensity, temperature }) => {}) // with lightEstimation, ≤ 10/sopen({ followLight: true }) turns the estimate on and scales the scene's
environmentIntensity by it (back to what it was when the session ends).
const main = async () => {
const scene = new ARScene()
scene.root.add(await Model.load(asset('./lamp.glb')))
scene.addEventListener('light', ({ intensity, temperature }) => console.log(intensity, temperature))
await scene.open({ followLight: true }) // the scene's light dims and brightens with the room
}
main()Take a photo of the AR view: capture, captureFrame
scene.capture(): Promise<File> // what the scene shows: the camera image and the 3D, a PNG
scene.captureFrame(): Promise<File> // the camera's image alone, a JPEGcaptureFrame() waits for a readable camera image when the session has none at that instant (the
first frames after a start or a resume) — up to about half a second, then it rejects failed; a
session stopped meanwhile rejects notRunning.
const scene = new ARScene()
scene.open()
scene.addEventListener('click', async () => {
const photo = await scene.capture() // the camera picture with the 3D on it, a PNG
share(photo)
})Let the user move, scale and rotate a model: addControls
scene.addControls(target: Node, options?: {
minScale?: number // smallest uniform scale the pinch allows; default 0.08
maxScale?: number // largest; default 2
pan?: boolean // one-finger drag; default true
pinch?: boolean // two-finger scale; default true
twist?: boolean // two-finger rotate; default true
}): { remove(): void }- One finger drags the target over what the session finds under the finger (a plane, through
hitTest); where it finds nothing — the web, or before a surface is known — across the ground, relative to where the camera looks, at the target's height. - Two fingers pinch to scale (uniform, clamped by
minScale/maxScale— the effective range is[minScale × 0.4, maxScale × 1.5]) and twist to rotate the target around its Y axis.
AR on the web: the camera picture turned by the sensors
The plugin's web half (Web Lite) is the plain kind: the camera's picture under the scene, the
scene's camera turned as the device turns (the gyroscope and the accelerometer, or the browser's
orientation where there is no gyroscope; Safari asks for the sensors inside a tap — open the scene
from one). The camera stays at the world's origin and the session starts looking at −Z.
It finds nothing in the picture: tracking is 'normal' from the first frame, no plane /
light event comes, image anchors never track, hitTest resolves [], root stays at the origin
(it never fires track), anchor() places an anchor that stays where it is. The origin is the
camera's own position, so content under root sits at the viewer's eye — move it away from the
origin. captureFrame() and capture() work. A page needs https (or localhost) for the camera.
The web player runs the same half, but its runtime module has neither the camera picture nor the
device's turn: the session opens and reports 'normal', and nothing of the camera shows.
See also
- Scene — everything inherited: options, add/remove, overlays,
capture, events - Node — hierarchy, transforms, events
- Model — loading the GLB you're placing
- Pointer events & gestures — the touch surface
addControlsis built on - Permissions — the camera, asked by
open()