# Scenery Scripting -- Guide (v0.2.0) Concepts, how-to, and worked examples for scripting Scenery AR experiences. Sibling bundle (complete API signatures): https://scenery.app/docs/scripting/api/llms-full.txt Heavier reference pages are fetchable directly: worked examples at https://scenery.app/docs/scripting/guide/examples.md, changelog at https://scenery.app/docs/scripting/guide/changelog.md, migration at https://scenery.app/docs/scripting/guide/migration.md. Any docs page is also available as raw markdown by appending `.md` -- e.g. https://scenery.app/docs/scripting/api/mesh.md, https://scenery.app/docs/scripting/guide/examples.md. --- # Getting Started Scenery is a visual editor for spatial experiences. You design scenes, place objects, configure materials, and wire up behaviors - all without code. Scripts handle what the visual editor doesn't: state, logic, external data, real-time connections, procedural behavior. ## How scripts work Scripts attach to events. When something happens - a tap, an object appearing, a frame rendering - Scenery can run your code. To add a script: 1. Create an event (on an object or the scene) 2. Add a "Run Script" action 3. Write your code When the event fires, your script runs with event data in `scriptContext.sourceEvent`: ```javascript // Tap event - scale up the tapped object scriptContext.sourceEvent.objectEntity.animateTo( { scale: Vector3(1.2, 1.2, 1.2) }, 0.2 ); ``` Different events provide different data - position, deltaTime, distance, etc. Use `console.log(scriptContext.sourceEvent)` to inspect what data is available for any event. **Action order matters**: Actions run sequentially by default. If your script uses `scene.findEntity` to find an object, that object must already be in the scene - so place the "Add to Scene" action **before** "Run Script": **Add to Scene** (MyBox) → **Run Script** (finds MyBox) If the script runs first, the object won't exist yet and `findEntity` returns `null`. Alternatively, attach your script to the object's **On Will Appear** or **On Did Appear** event - then the object is guaranteed to be available. ## Example: Continuous rotation Attach to "On Render": ```javascript // Replace with your object's ID or use { name: "..." } var cube = scene.findEntity("YOUR_OBJECT_ID"); cube.representation.rotation = cube.representation.rotation.multiply( Rotation(0, scriptContext.sourceEvent.deltaTime, 0) ); ``` ## Finding objects ```javascript // By name (set in Identity panel) var box = scene.findEntity({ name: "MyBox" }); // By ID var box = scene.findEntity("ABC123"); // Access the representation (visual content) box.representation.position = Vector3(0, 1, 0); box.representation.opacity = 0.5; ``` To get an ID: right-click an object or representation in the editor → **Copy Development ID**. **Entity vs Representation**: An **entity** is the top-level object (the anchor container you see in the scene list). A **representation** is the visual content inside it (model, shape, image, audio, etc.). Use `entity` for finding and referencing objects, use `entity.representation` for visual changes (position, rotation, scale, opacity). `scene.findEntity` finds top-level objects only - not nested representations. If your object contains multiple representations (e.g. a model and an audio source), find the parent entity first, then navigate: ```javascript var entity = scene.findEntity({ name: "MyObject" }); var audio = entity.findRepresentation({ name: "BGMusic" }); audio.isEnabled = true; ``` See [Examples → Entity hierarchy](/scripting/guide/examples#entity-hierarchy-and-lookup) for the full pattern. ## Rotations All rotation values are in **radians**. Use `Math.toRadians()` and `Math.toDegrees()` to convert: ```javascript Rotation(0, Math.PI / 2, 0); // 90° around Y axis Rotation(Math.toRadians(45), 0, 0); // 45° around X axis var degrees = Math.toDegrees(Math.PI); // 180 ``` Other math helpers: `Math.lerp(a, b, t)`, `Math.clamp(value, min, max)`, `Math.map(value, inMin, inMax, outMin, outMax)`, `Math.smoothstep(edge0, edge1, x)`. For working with directions, distances, dot/cross products, smooth motion, and converting between world and local space, see [Vectors & Transforms](/scripting/guide/vectors-transforms). ## Error handling Script errors are logged to the debug console but don't crash the scene - other scripts and actions continue to run. Use `try/catch` for operations that may fail: ```javascript try { var response = await http.get("https://api.example.com/data"); } catch (error) { console.error("Request failed: " + error); } ``` **Using await**: You can use `await` directly at the top level of your script - the runtime wraps async code for you, so there's no need for an `(async function(){...})()` wrapper or a helper function. ```javascript var box = await scene.createEntity(createBox(0.3, 0.3, 0.3)); ``` ## Configuring entities - use the descriptor builder `scene.createEntity(...)` takes **one** descriptor argument. Configure anchor, name, traits, and other settings via the descriptor's chainable builder methods, not as a second options argument. ```javascript // ✅ Correct - chain builder methods on the descriptor const box = createBox(0.3, 0.3, 0.3) .anchor(Anchor.position(0, 0, -1)) .name("hero-box"); const entity = await scene.createEntity(box); // ❌ Wrong - second argument throws at call time await scene.createEntity(box, { anchor: Anchor.position(0, 0, -1) }); ``` Same rule for `scene.runAction(action)` - single argument. Configure inside the action data, not via a second arg. The same builder also adjusts objects you already authored in the editor: `scene.getObjectDescriptor({ name: "..." })` returns an authored object's descriptor, so you can change its traits, material, or model - or add an interaction - *before* it's shown, then write it back with `scene.setObjectDescriptor(...)`. Where `findEntity` gives you the live object, `getObjectDescriptor` gives you its recipe. See [Examples → Adjust an authored object](/scripting/guide/examples#adjust-an-authored-object-before-it-appears). ## Debugging Tap the terminal button in the bottom-right corner to open the **debug console**. It shows `console.log` output and errors from your scripts. You can also type and run scripts directly in the console input field. ```javascript console.log("score:", experience.getVariable("score")); console.error("something went wrong"); ``` ## Next steps - [Events](/scripting/guide/events) - Triggers, event data, the render loop - [API Reference](/scripting/api/) - Full documentation # Events Events trigger scripts. Scenery has two kinds: **object events** that fire for a specific object, and **scene events** that fire globally. ## Object events Attach to individual objects. The triggering object is available in `scriptContext.sourceEvent`. | Event | Fires when | |-------|------------| | On Will Appear | Object is about to be added to scene | | On Did Appear | Object has appeared | | On Removal | Object is removed from scene | | On Tap Gesture | Object is tapped | | On Gesture | Object is dragged, rotated, or resized (needs a gesture enabled) | | On Camera Collision | Camera enters/exits object bounds | | On Object Collision | Physics collision with another object | | Distance Field | Camera or target crosses distance threshold | | Look Direction | User looks at/away from object (or object looks at target) | | Media Playback | Video/audio begins, reaches timestamp, or ends | ```javascript // On Tap Gesture - scale up the tapped object scriptContext.sourceEvent.objectEntity.animateTo( { scale: Vector3(1.2, 1.2, 1.2) }, 0.2 ); ``` ## Scene events Fire regardless of which object is involved. | Event | Fires when | |-------|------------| | On Experience Start | Experience begins | | On Experience Load | Experience finishes loading | | On Screen Tap | User taps anywhere on screen | | On Render | Every frame | | On Schedule | After a delay or at specific time | | On Variable Change | A variable is modified | | On Audio Analysis | Audio levels update (ambient or microphone) | ```javascript // On Render - rotate every frame var cube = scene.findEntity("YOUR_OBJECT_ID"); cube.representation.rotation = cube.representation.rotation.multiply( Rotation(0, scriptContext.sourceEvent.deltaTime, 0) ); ``` ## Subscribing in code You can also subscribe to events programmatically: ```javascript var cube = scene.findEntity("YOUR_OBJECT_ID"); // Object event cube.on('tap', function(e) { e.objectEntity.animateTo({ scale: Vector3(1.2, 1.2, 1.2) }, 0.2); }); // Scene event scene.on('render', function(e) { cube.representation.rotation = cube.representation.rotation.multiply( Rotation(0, e.deltaTime, 0) ); }); // One-time listener scene.once('start', function() { console.log('Experience started'); }); ``` ### Throttling For expensive operations, throttle the render loop: ```javascript scene.on('render', { throttle: 0.1 }, function(e) { // Runs every 100ms instead of every frame }); ``` ### Unsubscribing ```javascript var eventId = cube.on('tap', function() {}); // Later... cube.off(eventId); ``` **objectEntity vs representationEntity**: Tap and collision events give you both `objectEntity` (the top-level container) and `representationEntity` (the specific visual that was interacted with). Use `objectEntity` when you need to find sibling representations like audio sources. ## Gesture and screen events Beyond taps, you can react to live manipulation, multi-tap gestures, and raw screen gestures. ### Manipulating an object If an object has a gesture enabled (Drag, Rotate or Resize), `on('gesture')` fires continuously while the user manipulates it, so you can react to the movement itself. Read `e.phase` (`'began'` / `'changed'` / `'ended'`) and, by gesture kind, `e.translation` / `e.rotation` / `e.scale` (world-space): ```javascript box.on('gesture', function(e) { if (e.phase === 'ended') { console.log('let go'); } }); ``` `doubleTap` and `longPress` are discrete object gestures - these are available in code (not as editor triggers): ```javascript box.on('doubleTap', function() { /* ... */ }); box.on('longPress', { minimumDuration: 0.5 }, function() { /* ... */ }); ``` ### Screen gestures `pan`, `pinch` and `rotate` fire on any such gesture anywhere on screen, independent of scene objects - the raw input for mechanics like flick-to-launch. Read `e.position` (normalized) and, by kind, `translation` / `scale` / `rotation` plus `velocity`: ```javascript // Flick up in the bottom of the screen to launch the ball scene.on('pan', { area: [0, 0.6, 1, 0.4], phase: 'ended' }, function(e) { // e.velocity is in screen points/sec - scale it to a world-space push (tune to taste) var power = Math.max(0, -e.velocity.y) * 0.002; ball.applyImpulse(new Vector3(0, power, -power * 2), { space: 'world' }); }); ``` Outside AR (the desktop preview, the Mac app), a one-finger pan **also orbits the camera** by default. So if your handler reacts to a pan, that one gesture does two things at once - your mechanic runs *and* the camera swings. `consume` prevents that: return `true` from its predicate and the gesture is handled by your script alone, so the camera ignores it. The predicate runs as the gesture begins, so you can claim only the pans that start on your object and leave the rest for the camera: ```javascript // Claim the pan only when it starts on the ball; every other pan still orbits the camera scene.on('pan', { consume: function(e) { return e.hits(ball); } }, function(e) { /* ... */ }); ``` Omit `consume` for the smart default - a gesture is claimed automatically when it begins on a scene object. See [scene.on](/scripting/api/scene) for the full payload and `consume` options. **Screen gestures are for touch screens**: `pan`, `pinch` and `rotate` fire on iPhone, iPad and Mac. On Apple Vision Pro input is spatial (you look and pinch), so screen gestures aren't delivered - drive interaction from UI panels and buttons there instead. Gate a portable script with `environment.features.has('screenGestures')` and provide a fallback. ## Event data How you access event data depends on how your script runs: **Run Script action** - use `scriptContext.sourceEvent`: ```javascript var entity = scriptContext.sourceEvent.objectEntity; var dt = scriptContext.sourceEvent.deltaTime; ``` **Event subscriptions** - data is passed to your callback: ```javascript scene.on('render', function(e) { var dt = e.deltaTime; }); cube.on('tap', function(e) { var tapped = e.objectEntity; }); ``` **Properties by event type:** | Event | Properties | |-------|------------| | `render` | `deltaTime`, `time` | | `tap` | `objectEntity`, `representationEntity`, `screenPosition` | | `gesture` | `objectEntity`, `representationEntity`, `phase`, `translation`/`rotation`/`scale` | | `doubleTap`, `longPress` | `objectEntity`, `representationEntity` | | `pan`, `pinch`, `rotate` | `position`, `beganPosition`, `phase`, `translation`/`scale`/`rotation`, `velocity` | | `screenTap` | `position` | | `distance` | `objectEntity`, `distance`, `crossed` | | `lookAt` | `objectEntity`, `isLooking` | | `cameraCollision` | `objectEntity`, `representationEntity` | | `physicsCollision` | `objectEntity`, `representationEntity`, `position`, `impulse` | | `variableChange` | `variableId`, `value` | | `mediaPlayback` | `objectEntity`, `status`, `time`, `duration` | | `audioAnalysis` | `kind`, `loudness`, `bass`, `lowMid`, `mid`, `upperMid`, `presence`, `brilliance`, `air`, `treble` | | `add`, `appear`, `remove` | `objectEntity`, `representationEntity` | Use `console.log(scriptContext.sourceEvent)` or `console.log(e)` to inspect all available properties. # Vectors & Transforms Positions, directions, and rotations are the raw material of anything spatial - aiming an object at the viewer, checking how far away something is, moving smoothly, turning a hand position into an object's own space. This page is a tour of the `Vector3`, `Rotation`, and `Transform` helpers, focused less on the maths and more on what each one is *for*. ## Points and directions A `Vector3` is just three numbers, but it plays two roles: - a **point** - a position in space (an object's `position`, a hand joint, a raycast hit) - a **direction** - which way something points, usually with a length of 1 The direction from one point to another is their difference: ```javascript var toTarget = target.subtract(myPosition); // points from me toward the target var distance = toTarget.length(); // how far away the target is var heading = toTarget.normalize(); // same direction, length 1 ``` `a.distanceTo(b)` is a shortcut for `b.subtract(a).length()` when you only need the distance. **What `normalize()` is for:** it scales a vector to length 1 while keeping its direction, so you're left with pure "which way" and no "how far". You want it whenever direction is what matters but magnitude shouldn't leak in - moving at a constant speed, aiming, or feeding a direction into a dot product (below), which only reads as an angle when its inputs are unit length. Normalize a direction before you use it to move or aim. A raw direction whose length happens to be, say, 3 would move an object 3× too fast - speed would depend on how far the target is, which is almost never what you want. Move an object a fixed speed toward a target each frame: ```javascript // Attach to: On Render var dt = scriptContext.sourceEvent.deltaTime; // seconds since last frame var step = target.subtract(obj.position).normalize().multiply(2.0 * dt); // 2 m/s obj.position = obj.position.add(step); ``` ## Dot product - how aligned are two directions? `a.dot(b)` measures how much two directions point the same way. For unit vectors it is the cosine of the angle between them: `1` means the same direction, `0` perpendicular, `-1` opposite. That makes it the go-to test for "is this in front of me?" and "is the viewer looking at it?": ```javascript // Attach to: On Render var cam = scene.cameraTransform; var forward = cam.transformVector(Vector3(0, 0, -1)); // where the camera looks var toObj = obj.representation.worldPosition.subtract(cam.position).normalize(); if (forward.dot(toObj) > 0.97) { // 0.97 ≈ within ~14° of dead-on; a smaller number = wider cone // the viewer is looking almost straight at the object } ``` Need the actual angle instead of a threshold? `a.angleTo(b)` returns it in radians. ## Cross product - give me a perpendicular `a.cross(b)` returns a vector at right angles to both `a` and `b`. You reach for it when you need an axis you don't already have: - a **surface normal** - the direction a surface faces - from two of its edges: `edge1.cross(edge2)`. Normals are what lighting uses to shade a surface, so this comes up when you build or deform a mesh. - a **"right" direction** from a forward and an up vector: `forward.cross(up)` - useful for placing something beside the viewer or moving sideways. This one is more advanced - if you're mostly moving and aiming objects, you may not need it yet. ## Smooth motion - lerp and slerp Snapping straight to a target reads as robotic; easing toward it feels alive. `lerp` blends between two values by a fraction `t` (0 = start, 1 = end): ```javascript // Attach to: On Render -- smooth follow obj.position = obj.position.lerp(target, 0.1); // moves 10% of the remaining distance each frame ``` - `Vector3.lerp` for positions, `Math.lerp(a, b, t)` for plain numbers. - Rotations use `rotation.slerp(other, t)` - the shortest smooth turn between two orientations. ## Rotations A `Rotation` orients something. Build one from angles (in **radians**), then combine, undo, or blend: ```javascript var quarterTurn = Rotation(0, Math.toRadians(90), 0); // 90° around the up (Y) axis var combined = rotA.multiply(rotB); // apply B, then A var undo = rot.inverse(); // the opposite rotation var eased = current.slerp(target, 0.2); // smooth turn toward another orientation ``` Use `Math.toRadians()` / `Math.toDegrees()` to convert. To turn an object to *face* a point (a "look-at"), see the billboard example in [Examples](/scripting/guide/examples). ## Coordinate spaces The same point can be described from different frames of reference, and mixing them up is a common source of "why is it in the wrong place" bugs: - **World space** - the one frame shared by everything, so any two things can be compared in it. The values you *read* are almost always world-space: hand joints (`scene.tracking.hands`), raycast hits, an object's `worldPosition`, the camera's position. - **Local space** - each object has its *own*, relative to its transform. A mesh's vertices, and an object's `position`, live in that object's local frame. (The camera has its own local frame too - "camera space".) So "local" isn't one place - it's per-object. Moving a point from one frame into another is exactly what a **Transform** does. ## Transforms A `Transform` bundles **position, rotation, and scale** - it is how one object sits in space, i.e. where its local frame lands in the world. Every entity has one: - `entity.transform` - relative to its parent (its **local** transform) - `entity.worldTransform` - its final place in the scene, with all parents applied Use it to move points and directions between the world and an object's local space: ```javascript var inv = Transform.inverse(entity.worldTransform); var local = Transform.transformPoint(inv, worldPoint); // world → this entity's local frame var world = entity.worldTransform.transformPoint(localPoint); // local → world ``` - **`transformPoint`** moves a position (translation included). - **`transformVector`** moves a direction (translation *ignored*, so it stays a direction) - this is why the dot-product example built the camera's forward with `transformVector`. - **`multiply`** chains two transforms; **`inverse`** reverses one. A hand joint feeding a mesh you built is the classic case: the joint is world-space, the mesh's vertices are the entity's local space - convert the joint into the entity's local frame first. In practice you rarely convert by hand, because entities hand you both frames directly: a representation's `position` is its **local** position, its `worldPosition` is the **world** one, and you can read *or set* either. Manual `transformPoint` conversion is for a loose point that isn't already an entity property - a hand joint, a raycast hit, a mesh vertex. An object at the scene's origin has local space equal to world space, so no conversion is needed. It starts to matter once the object - or the experience origin it sits under - is moved or rotated. # Gestures & Interactions Gestures come in two layers: - **Object gestures** - drag, rotate, or resize a *specific* object. Enable them on the object, then optionally react in a script. - **Screen gestures** - raw `pan` / `pinch` / `rotate` anywhere on screen, independent of any object. The building block for custom mechanics like flick-to-launch. Screen gestures fire on iPhone, iPad and Mac - not Apple Vision Pro, where input is spatial. Gate a portable script with `environment.features.has('screenGestures')` and fall back to UI controls. See [Feature Detection](/scripting/guide/feature-detection). ## Object gestures Enable a gesture on the descriptor (or in the editor), and the user can manipulate the object directly: ```javascript var crate = await scene.createEntity( createBox(0.2, 0.2, 0.2) .name('crate') .gestures({ drag: true, rotate: true }) ); ``` React to the live manipulation with `on('gesture')` - it fires continuously while the user moves the object. Read `e.phase` (`'began'` / `'changed'` / `'ended'`) and, by kind, `e.translation` / `e.rotation` / `e.scale`: ```javascript crate.on('gesture', function(e) { if (e.phase === 'ended') { console.log('moved by', e.translation.toString()); } }); ``` `doubleTap` and `longPress` are discrete object gestures, available in code: ```javascript crate.on('doubleTap', function() { /* ... */ }); crate.on('longPress', { minimumDuration: 0.5 }, function() { /* ... */ }); ``` ### Release behaviour When a dragged object is let go, `releaseBehavior` decides what happens next: ```javascript createSphere(0.04) .physics({ mode: 'dynamic', mass: 0.2 }) .gestures({ drag: true, releaseBehavior: 'momentum' }); ``` | `releaseBehavior` | On release | | --- | --- | | `'stay'` (default) | Stays where it was let go, then falls under gravity if it has a dynamic body. | | `'reset'` | Animates back to where the gesture started. | | `'momentum'` | Keeps the motion the gesture gave it - a flick throws it, a slow release lets it drift. Tune with `momentumScale` / `angularMomentumScale`. | `'momentum'` gives you a throw with no manual velocity maths - the engine measures the release motion for you. Reach for the scripted approach below only when you need finer control. ## Screen gestures `scene.on('pan' | 'pinch' | 'rotate')` fires for any such gesture anywhere on screen. Read `e.position` (normalized), `e.phase`, and by kind `translation` / `scale` / `rotation` plus `velocity`: ```javascript // Flick strength from a swipe in the bottom of the screen. scene.on('pan', { area: [0, 0.6, 1, 0.4], phase: 'ended' }, function(e) { console.log('flick speed', e.velocity.length()); }); ``` Outside AR (the desktop preview, the Mac app), a one-finger pan **also orbits the camera** by default - so a pan your script reacts to would move your object *and* swing the camera at once. `consume` claims the gesture for your script alone: return `true` from its predicate (evaluated as the gesture begins) and the camera ignores that gesture. Omit `consume` for the smart default, which claims a gesture automatically when it begins on a scene object: ```javascript // Claim the pan only when it starts on the ball; every other pan still orbits the camera. scene.on('pan', { consume: function(e) { return e.hits(ball); } }, function(e) { /* ... */ }); ``` See [scene.on](/scripting/api/scene) for the full payload and `consume` options. ## Composing a custom mechanic Specific mechanics - pickup-and-throw, drag-to-aim, swipe-to-navigate - aren't engine features; you compose them in a script from the primitives above plus physics. A flick-to-launch, in miniature: ```javascript var ball = scene.findEntity({ name: 'ball' }); // Claim a pan that starts on the ball; on release, convert its screen velocity to an impulse. scene.on('pan', { consume: function(e) { return e.hits(ball); } }, function(e) { if (e.phase !== 'ended') { return; } var launchScale = 0.002; // screen points/sec -> world impulse; tune for the feel you want var lift = 0.4; // upward component so the throw arcs ball.applyImpulse(new Vector3(e.velocity.x * launchScale, lift, -e.velocity.y * launchScale)); }); ``` For a full worked example (velocity smoothing over the tail of the drag, scoring, reset), the same idea scales up - measure the release velocity, apply it as an impulse, and let physics carry the object. # User Interface Build 2D panels - labels, sliders, buttons, stacks - from a script, place them in the scene or pin them to the screen, and drive them from your logic. For text that tracks a variable live, see [Live labels](#live-labels) below. ## Build and place a panel Compose a view tree with the `UI.*` builders, wrap it with `createPanel(...)`, and instantiate it with `scene.createEntity`: ```javascript var panel = createPanel( UI.vStack({ spacing: 12, children: [ UI.label({ text: 'Volume' }), UI.slider({ name: 'vol', value: 0.5, range: [0, 1], sizing: { width: 'fill' } }) ] }), { panelSize: [320, 160], material: 'system' } ).name('controls'); var entity = await scene.createEntity( panel.anchor(Anchor.screenSpace({ alignment: 'bottom', insets: 24 })) ); ``` Builders: `UI.label`, `UI.button`, `UI.slider`, `UI.toggle`, `UI.textField`, `UI.progressView`, `UI.spacer`, and the layout stacks `UI.vStack` / `UI.hStack` / `UI.zStack`. Every builder takes a `name` (for lookup), `padding`, and `sizing` (`'auto'` / `'fill'` / a number / a `'50%'` string). `Anchor.screenSpace(...)` pins the panel to a screen edge or corner; anchor it in the world instead (e.g. `Anchor.position(...)`) for a panel that lives in the scene. ## Find and update views Name a view, then reach it later through the object's representation to read or change it: ```javascript var view = entity.representation.findView({ name: 'vol' }); view.setProperty('value', 0.8); ``` `setProperty` / `getProperty` accept keys appropriate to the view kind (a label's `text`, a slider's `value`, a button's `title`, ...). Setting a key the kind doesn't support is a no-op. ## React to input Subscribe to a view's events: ```javascript entity.representation.findView({ name: 'vol' }).on('valueChanged', function(e) { console.log('slider is now', e.uiValue); }); entity.representation.findView({ name: 'save' }).on('tap', function() { scene.setVariable('saved', true); }); ``` ## Bind a control to a variable `bind` connects a view's value to a variable in both directions - the control writes the variable as the user drags, and the view updates if the variable changes elsewhere: ```javascript entity.representation.findView({ name: 'vol' }).bind('value', 'volume'); ``` Pair a bound control with a live label (below) to show its value. ## Live labels Label text supports inline variable references that resolve **live** - the label re-renders automatically whenever the referenced variable changes. No `scene.on('variableChange', ...)` wiring needed. ```javascript UI.label({ text: 'vol = ${vol}' }) ``` `${vol}` is read from the current segment's variable storage. If `vol` is unset, the label renders `vol = UNDEFINED`. ### Scopes | Form | Reads from | |------|-----------| | `${name}` | Segment-scoped variable (default) | | `${g:name}` | Experience-scoped (global) variable | Set scope on the variable side via `scene.setVariable(name, value, { scope: 'experience' })`. ### Filters Pipe the value through a formatter with `|filter` or `|filter(arg)`: | Filter | Use | Example output (input `0.6789`) | |--------|-----|--------------------------------| | `\|fixed(N)` | Trim a float to N decimals (default 2) | `0.68` | | `\|percent(N)` | Multiply ×100, append `%` (default 0 decimals) | `68%` | | `\|time` | Seconds → `m:ss` (or `h:mm:ss` ≥ 1h) | (for `185`) `3:05` | | `\|int` | Truncate toward zero | `0` | | `\|signed(N)` | Always-signed, N decimals (default 0) | `+1` | Filters are numeric - applied to a non-numeric variable, they pass the raw value through unchanged. Unknown filter names log a console warning and do the same. ### Example: bound slider with live readout The slider drives the variable; the label reads from it - no subscription code, the label re-renders on every change automatically: ```javascript // Attach to: On Experience Start scene.setVariable('vol', 0.5); var panel = createPanel( UI.vStack({ spacing: 12, padding: 16, children: [ UI.label({ text: 'Volume: ${vol|fixed(2)}' }), UI.slider({ name: 'volume', value: 0.5 }) ] }), { panelSize: [400, 160] } ); var entity = await scene.createEntity(panel); entity.representation.findView({ name: 'volume' }).bind('value', 'vol'); ``` A single label can mix any number of references and plain text; each reference subscribes independently, so any one variable changing re-renders with the current values of all of them: ```javascript UI.label({ text: 'Score: ${g:score|int} , Timer: ${countdown|time}' }) ``` ### Sizing panels with dynamic labels A label re-measures itself when its value changes, so its own frame grows to fit wider text. **But an auto-sized panel does not** - it measures its outer bounds once, from the content present when it is first built. If you seed a label empty (or short) and fill it in later with a wider value, the panel stays at its original small size and the text truncates to `...`. For a panel whose interpolated content changes width at runtime - a live readout, a counter that gains digits, a place name - give the panel a **fixed size** rather than relying on auto-sizing: ```javascript createPanel(content, { panelSize: [340, 200] }); ``` Fixed panels are unaffected: the labels have room to grow within them. Auto sizing is best kept for panels whose content width is known up front and does not change. # Materials A `Material` is what a surface looks like - color, texture, reflectivity, shader. Use the `Material` builder to construct one inline, reference an existing library material by id, or clone a library material and tweak it. ## Kinds Six factories. Pick the one that matches the look you need. | Factory | Use for | |---------|---------| | `Material.unlit({...})` | Flat-shaded surfaces - UI, billboards, vertex-colored point clouds. Renders the same regardless of scene lighting. | | `Material.pbr({...})` | Realistic surfaces - metal, plastic, fabric. Responds to scene lighting. | | `Material.occlusion({...})` | Invisible itself but hides geometry behind it - holdouts, portals. | | `Material.customShader({...})` | Author-supplied Metal shader functions from a compiled `.metal` library. | | `Material.materialX({...})` | ShaderGraph material authored in Reality Composer Pro / `.usda`. | | `Material.video({...})` | Video texture playback. | ## Inline construction Factory + options object reads naturally: ```javascript // Unlit - single flat color const red = Material.unlit({ color: '#FF0000' }); // PBR - color + metalness + roughness, no textures const gold = Material.pbr({ color: '#FFD700', metalness: 1.0, roughness: 0.2 }); // PBR - textured const brick = Material.pbr({ color: '#FFFFFF', // tints the texture map: 'brick-albedo.png', normalMap: 'brick-normal.png', roughnessMap: 'brick-rough.png' }); ``` Same chained, if you prefer: ```javascript const gold = new Material('pbr') .color('#FFD700') .metalness(1.0) .roughness(0.2); ``` ## Input shapes **Colors** - `color`, `emissive`, `sheenColor`, `specularColor`: - `'#RRGGBB'` or `'#RRGGBBAA'` - `{ r, g, b, a }` with values in 0..1 - `{ colorSpace: 'p3', hex: 'FF6600' }` **Textures** - `map`, `emissiveMap`, `normalMap`, etc.: - URL string: `'https://.../texture.png'` - Asset id string: `'libraryTextureId'` - With a scale: `{ texture: 'urlOrId', scale: 0.5 }` **Scalars** - `roughness`, `metalness`, `clearcoat`, `opacity`, `emissiveIntensity`: plain numbers. ## Library reference Materials authored in the experience editor are referenced by id directly - no inline construction needed: ```javascript // Apply a library material to a mesh await scene.createEntity(createMesh({ ..., materials: ['myLibraryMaterialId'] })); ``` Or fetch one with `scene.getMaterial(id)` to inspect: ```javascript const mat = scene.getMaterial('myLibraryMaterialId'); console.log(mat); // → Material(pbr 'My Material' id=..., color=#FFAA00, roughness=0.3) ``` ## Clone → tweak → apply To override a library material for a single entity (without mutating the library entry), clone it first: ```javascript const base = scene.getMaterial('redMatId'); const brighter = base.clone() .emissive('#FF0000') .emissiveIntensity(2); await entity.representation.setMaterial(brighter); ``` `.clone()` deep-copies the material with a fresh `id`. The original library material is untouched. ## ShaderGraph (MaterialX) parameters ShaderGraph materials authored in Reality Composer Pro expose promoted inputs. Set constant values via `setParameter(name, value, typeHint?)`: ```javascript const mat = scene.getMaterial('myShaderGraphMatId').clone() .setParameter('intensity', 0.6) .setParameter('tintColor', '#FF00FF', { type: 'color' }) .setParameter('offset', [0.1, 0.2, 0], { type: 'vector3' }); ``` The same `setParameter` works on `customShader` materials too - the Material auto-routes to the right options blob based on its kind. **Type hints** override inferred types: `{ type: "float" | "int" | "vector2" | "vector3" | "vector4" | "color" | "boolean" | "string" }` For runtime variable bindings (slider drives a parameter), use `entity.representation.bindMaterialParameter(parameterName, variableId)` - different path, lives on the entity, ties into the variable system. ## Runtime swap Replace an entity's material at runtime: ```javascript // Cycle materials every 2 seconds const palette = [ Material.unlit({ color: '#4287F5' }), Material.pbr({ color: '#42F578', metalness: 0.5, roughness: 0.3 }), Material.unlit({ color: '#F54242' }) ]; let i = 0; scene.on('schedule', { interval: 2 }, async function() { i = (i + 1) % palette.length; await entity.representation.setMaterial(palette[i]); }); ``` `setMaterial` accepts a `Material` instance, a library ref-id string, or `{ id, target? }`. `setMaterials([...])` for multiple at once. ## Introspection ```javascript console.log(material); // → Material(pbr id=..., color=#FF6600, roughness=0.3) console.log(JSON.stringify(material, null, 2)); // → full material JSON material.kind; // → 'pbr' material.id; // → 'AB12CD34' material.baseColor; // → { color: { colorSpace: 'p3', hex: 'FF6600' } } material.materialXOptions?.parameters; // → { intensity: { ... } } for materialX ``` For setter names that conflict with property names (`roughness`, `opacity`, `clearcoat`, `emissiveIntensity`), read via `material.data.` - e.g. `material.data.roughness?.scale`. ## Target By default a material applies to all models on an entity. Override for multi-model entities: ```javascript Material.pbr({ color: '#FF0000' }) .target({ type: 'firstModel' }); Material.pbr({ color: '#0000FF' }) .target({ type: 'selectedModels', models: ['windshield'], materialSlots: [0] }); ``` # Mesh Instancing Instancing renders many copies of one object in a single draw - thousands of them stay cheap, because the GPU draws the shared mesh once and stamps it at each transform. Use it for fields, crowds, particles, and any procedural scatter. ## Scatter copies Give one object the set of transforms to render it at with `setInstances(Instances.transforms([...]))`. Each transform is a `Vector3` position (or a full transform): ```javascript var base = await scene.createEntity( createBox(0.03, 0.03, 0.03).anchor(Anchor.position(0, 0, -1.5)) ); var spots = []; var count = 1000; var goldenAngle = Math.PI * (3 - Math.sqrt(5)); for (var i = 0; i < count; i++) { var r = 0.4 * Math.sqrt(i / count); var a = i * goldenAngle; spots.push(new Vector3(r * Math.cos(a), 0, r * Math.sin(a))); } base.setInstances(Instances.transforms(spots)); ``` Call `setInstances` again with a new set to re-scatter - for example, from an `On Render` handler or a slider's `valueChanged`. ## Instancing vs cloning Instances are **rendering-only**: they share the base object's mesh and material and are not individual entities. You can't tap one, script one, or give one its own physics body. When you need copies that behave independently - each collidable, tappable, or separately animated - use [`entity.clone()`](/scripting/api/objectentity) instead. Rule of thumb: dozens of interactive copies → clone; hundreds or thousands of visual copies → instance. **Shadows on dense fields**: A large instanced field can alias the shared shadow map into streaks. Turn the projected shadow off for the base object so the field stays clean: ```javascript createBox(0.03, 0.03, 0.03).shadow({ directional: false }); ``` Instancing needs iOS 26 or later. Gate a portable script with `environment.features.has('instancing')` - see [Feature Detection](/scripting/guide/feature-detection). # Paths & Splines Build a smooth curve through a set of control points and drive an object along it. Paths are a runtime construct - `scene.createPath(...)` returns a handle you query and hand to `entity.followPath(...)`; they aren't saved in the experience. ## Create a path Pass at least two control points. The curve is a smooth spline through them: ```javascript var path = scene.createPath({ points: [ new Vector3(-0.6, 0, -1.2), new Vector3(-0.2, 0.4, -1.2), new Vector3(0.2, -0.2, -1.4), new Vector3(0.6, 0.3, -1.2) ] }); ``` `createPath` returns `null` if you pass fewer than two valid points. ## Query the curve | Member | Result | | --- | --- | | `path.length` | Total arc length, in metres. | | `path.point(t)` | Position at parameter `t` (0...1 across the whole path). | | `path.pointAtDistance(d)` | Position at arc-length distance `d` (0...`length`) - constant-speed spacing. | | `path.tangent(t)` | Unit direction of travel at parameter `t`. | ```javascript console.log('length', path.length.toFixed(2), 'm'); // Drop a marker every fifth of the way along, evenly spaced by distance. for (var i = 0; i <= 5; i++) { var p = path.pointAtDistance((i / 5) * path.length); await scene.createEntity(createSphere(0.01).anchor(Anchor.position(p.x, p.y, p.z))); } ``` ## Follow a path `entity.followPath(path, options)` animates an object along the curve: ```javascript var box = await scene.createEntity( createBox(0.05, 0.05, 0.05).anchor(Anchor.position(0, 0, 0)) ); box.followPath(path, { duration: 4, constantSpeed: true, // even speed regardless of control-point spacing (default true) alignsToTangent: true, // nose points along the direction of travel (default false) timing: 'easeInOut', // linear | easeIn | easeOut | easeInOut repeat: true // true to loop, or a number of cycles }); ``` **Anchor anywhere**: `followPath` positions the object in world space, so the anchor you give the object only sets its starting reference - the path drives it from there. # Location Read the device's geographic location from a script through `environment.location`. Coarse reads ride the base location grant; precise accuracy is a separate, explicit opt-in. ## Request access and read a fix Ask for access once, then read the current location. Reads never prompt on their own - call `requestAccess()` first. ```javascript await environment.location.requestAccess(); var here = await environment.location.current(); console.log(here.latitude, here.longitude, '±' + here.horizontalAccuracy + 'm'); ``` `current()` resolves a `GeoCoordinate`. On a live fix it also carries `horizontalAccuracy` (metres) and `timestamp`; those are `null` on a coordinate you construct yourself. ## Coordinates: distance and bearing `GeoCoordinate` carries the great-circle helpers, so you can measure against a point of interest without any external maths: ```javascript var target = new GeoCoordinate(48.1372, 11.5756); // latitude, longitude console.log('distance', Math.round(here.distanceTo(target)), 'm'); // metres console.log('bearing', Math.round(here.bearingTo(target)) + '°'); // 0-360 from north ``` ## Watch position Observe location continuously - for example, reveal something when the user gets close: ```javascript var target = new GeoCoordinate(48.1372, 11.5756); var sub = environment.location.watch(function(here) { if (here.distanceTo(target) < 50) { sub.stop(); scene.setVariable('arrived', true); } }); ``` Call `sub.stop()` to end the subscription. ## Precise accuracy The base grant may be coarse. When your experience genuinely needs precise positioning, ask for it explicitly - the user sees a confirmation carrying your `reason`, then the system accuracy prompt: ```javascript var precise = await environment.location.requestPreciseAccess({ reason: 'to guide you to nearby points of interest' }); if (precise) { var fix = await environment.location.current(); } ``` **Ask only when needed**: Request precise accuracy at the moment the feature needs it, not on load - the prompt reads better in context, and coarse location is enough for most distance/region checks. # Tracking `scene.tracking` reads live tracking data from script - articulated hand joints, and face blendshapes when the scene runs in a face context. It is the author-facing entry point for both; you never talk to the underlying provider directly. Two things it is useful for: reading hand or face data **without** planting an anchored object, and controlling when the hand-tracking provider is running. ## Hands Hand tracking is **currently available on Apple Vision Pro only**, and it is opt-in - nothing is tracked until a script asks for it. Gate portable scripts on the feature flag: ```javascript // Attach to: On Experience Start if (environment.features.has("handTracking")) { await scene.tracking.hands.enable(); // resolves when tracking is live } ``` `enable()` rejects if the device cannot track hands or the person declines permission, so handle the rejection rather than assuming it succeeded: ```javascript // Attach to: On Experience Start try { await scene.tracking.hands.enable(); } catch (e) { console.log("Hand tracking unavailable: " + e.message); } ``` Once tracking is live, read the hands each frame: ```javascript // Attach to: On Render var right = scene.tracking.hands.right; // null when that hand isn't tracked if (right) { var tip = right.joints.indexFingerTip.worldTransform.position; console.log("Right index tip: " + tip); } ``` ### Hands reference | Member | Type | Description | |---|---|---| | `enable()` | `Promise` | Opt into hand tracking. Idempotent. Resolves when the provider is running and authorized; rejects if denied or unsupported. | | `disable()` | - | Drop this script's request. Fire-and-forget. | | `isSupported` | `boolean` | Whether this device can track hands at all. | | `active` | `boolean` | Whether the provider is authorized and running. | | `enabled` | `boolean` | Whether this script currently requests hand tracking. | | `left` | [`AnchorData`](/scripting/api/anchordata) \| `null` | Left hand, or `null` when it isn't tracked. | | `right` | [`AnchorData`](/scripting/api/anchordata) \| `null` | Right hand, or `null` when it isn't tracked. | | `anchors` | `Array<`[`AnchorData`](/scripting/api/anchordata)`>` | All currently tracked hands. | **active vs enabled vs a hand being visible**: These are three different questions - `enabled` is what your script asked for, `active` is whether tracking is actually running, and a `left` / `right` of `null` means that particular hand isn't in view right now. Poll `active` to notice permission being revoked or tracking being lost after a successful `enable()`. ## Face Face data needs no opt-in - it is already flowing whenever the scene runs in a face tracking context, so there is nothing to enable. ```javascript // Attach to: On Render var face = scene.tracking.face.current; if (face) { console.log("Face at: " + face.worldTransform.position); } ``` ### Face reference | Member | Type | Description | |---|---|---| | `isSupported` | `boolean` | Whether the scene can deliver face tracking data. | | `active` | `boolean` | Whether a face is currently tracked. | | `current` | [`AnchorData`](/scripting/api/anchordata) \| `null` | The current face anchor - blendshapes and eye transforms - or `null`. | ## Coordinate spaces Joint and face positions are in **world space**. To use them against an object you created, convert into that object's local frame first - see [Coordinate spaces](/scripting/guide/vectors-transforms#coordinate-spaces). ## The lower-level equivalent `scene.tracking` is a convenience layer over `scene.getAnchors(...)`, which stays available if you would rather filter the anchor list yourself: ```javascript // Attach to: On Render var anchors = scene.getAnchors("hand"); for (var i = 0; i < anchors.length; i++) { if (anchors[i].chirality === "right") { console.log("Right hand at: " + anchors[i].worldTransform.position); } } ``` The difference is lifecycle: only `scene.tracking.hands` can turn the hand-tracking provider on and off. `getAnchors("hand")` returns an empty list until something has enabled it. ## Related - [Anchors](/editor/anchors) - anchoring an object to a hand joint instead of reading joints yourself - [Feature Detection](/scripting/guide/feature-detection) - the full capability matrix - [Examples](/scripting/guide/examples) - worked hand-tracking snippets # Compute Kernels GPU compute lets scripts run a function across thousands of threads in parallel - typical uses are heightmap textures, particle simulations, vertex displacement on a dynamic mesh, or "how many triangles did marching cubes emit this frame" atomic counters. **Apple GPUs only, for now**: Compute kernels are **currently available on Apple GPUs only** - web / WebXR support is planned but **not yet available**. Today you author the kernel directly in **Metal Shading Language (MSL)**, which makes this the one corner of the scripting API that isn't fully engine-agnostic. Always gate compute behind `environment.features.has('compute')` ([Feature Detection](/scripting/guide/feature-detection)) so a script degrades cleanly on devices - and platforms - that don't support it yet. ```javascript // Attach to: onStart if (!environment.features.has('compute')) { console.warn('compute not supported - bailing'); return; } const heightMap = await Texture.compute({ pixelFormat: 'rgba8Unorm', width: 256, height: 256 }); const kernel = await Kernel.fromSource({ source: ` #include using namespace metal; struct Uniforms { float time; }; kernel void wave(constant Uniforms& U [[buffer(0)]], texture2d outTex [[texture(0)]], uint2 gid [[thread_position_in_grid]]) { float2 uv = float2(gid) / float2(outTex.get_width(), outTex.get_height()); float r = 0.5 + 0.5 * sin(U.time + uv.x * 6.28); outTex.write(float4(r, uv.y, 0.5, 1.0), gid); }`, functionName: 'wave' }); const uniforms = new Float32Array(1); scene.on('render', function () { uniforms[0] = scene.time; kernel.run({ uniforms: uniforms, outputTextures: { outTex: heightMap.write }, threadGroups: [16, 16, 1] }); }); ``` ## Dispatch entry points Two ways to dispatch a kernel: - **`mesh.runCompute(kernel, options)`** - when the kernel writes mesh attribute buffers (`storage: "compute"` attributes) or the mesh's index buffer. The mesh sits in `buffer(1...)` slots. - **`kernel.run(options)`** - standalone compute. No mesh involvement. Use for texture generation, particle simulation, atomic counters, anything that doesn't displace mesh geometry. Both paths share the same options shape; `kernel.run` simply skips the mesh-related slots. ## MSL slot ordering - strict and positional Author-side bindings map to MSL `[[buffer(N)]]` / `[[texture(N)]]` indices in a fixed order: | Slot | Source | Notes | |---|---|---| | `buffer(0)` | `uniforms: Float32Array` | Always slot 0, packed as raw bytes | | `buffer(1..A)` | `outputs[]` (mesh attributes) | Only for `mesh.runCompute`. Order matches the array - `outputs: ['position', 'normal']` binds `position` at 1, `normal` at 2 | | `buffer(A+1)` | mesh index buffer | Only when `outputIndices: true` and the mesh has `indexCapacity > 0` | | `buffer(A+2..)` | `inputBuffers` then `outputBuffers` | Both maps merged into one continuous block, inputs first | | `texture(0..)` | `inputTextures` then `outputTextures` | Separate slot space from buffers, inputs first | Match your MSL signature to this order. Slot indices the kernel declares (`[[buffer(N)]]`) must match what the runtime binds. ## Common pitfalls ### "No outputs declared" warning ``` kernel.run: no outputs declared (outputs / outputIndices / outputBuffers / outputTextures all empty). Kernel will run but nothing it writes is observable - likely a bug. ``` A dispatch without any write target runs the kernel but has no observable effect - the GPU reads inputs, computes values, then throws them away. Almost always a missed wiring. Add the destination map you meant to (`outputBuffers: { triCount: counter }`, `outputTextures: { outTex: heightMap.write }`, etc.). ### Slot ordering off by one MSL kernels declare slot indices by hand (`constant Uniforms& U [[buffer(0)]]`). If you change the JS dispatch options - add an attribute to `outputs`, toggle `outputIndices`, add an `inputBuffer` - every later slot index shifts. Always re-check the MSL signature when you change the dispatch options. ### Texture token swap (`.read` vs `.write`) A texture handle has two binding tokens: `.read` (sample inside the kernel) and `.write` (write to inside the kernel). They route to different Metal APIs under the hood. Passing `.read` where you meant `.write` (or vice versa) means the kernel binds the texture in the wrong access mode - usually a silent no-op for writes, or undefined sampling for reads. Token name = what the kernel does with it. ### Uniforms must be a `Float32Array` `uniforms` is packed as raw bytes. The runtime only accepts a `Float32Array`. Plain JS arrays and other typed arrays trigger a warn and skip. To pack mixed types (a float and an integer index, say), reinterpret memory inside MSL or use an `inputBuffers` entry instead. ### `outputs` only valid with a mesh `outputs: [...]` and `outputIndices: true` are mesh-attribute writes. They mean nothing for `kernel.run(...)` (no mesh) and are silently ignored there. If you want raw output, use `outputBuffers: { ... }` with a `Buffer` handle. ### Forgetting `await` on factory promises `Texture.compute(...)`, `Kernel.fromSource(...)`, and `Kernel.fromAsset(...)` all return Promises. Without `await`, downstream code touches a Promise object - `kernel.run(...)` fails, `texture.write` is undefined. The `_promisify` wrapper logs nothing helpful here; just remember the `await`. ### Buffer size mismatches on `writeSync` ``` Buffer.writeSync: undersize write - source has 2 elements, buffer holds 4. Copying 2 elements; tail of buffer UNTOUCHED. ``` The warn is loud on purpose. Seeding partial data is usually a bug - match `src.length` to the buffer's declared element count. ## Capability gate Always gate compute calls with `environment.features.has('compute')` - devices below the Apple 7 GPU family or below iOS 18 / visionOS 2 / macOS 15 return `false`. The Swift bridge throws cleanly if you slip past, but the JS-side gate keeps your script structure honest. # Feature detection Not every Scenery feature is available on every platform or every runtime version. Compute shaders are gated behind a future runtime; hand tracking needs a visionOS device; SharePlay is stripped out of App Clip builds. Authors branch on capability through a single string-keyed registry on `environment.features`: ```javascript if (environment.features.has('compute')) { mesh.runCompute('advect', { uniforms }); } else { stepOnCPU(); } ``` Unknown feature names return `false` rather than throwing - this matches WebGPU's `Set.has` semantic and lets forward-looking scripts query features that may land in a future runtime version without crashing on today's runtime. ## Listing what's available ```javascript console.log(environment.features.list()); // → ['sharedActivities'] ``` `list()` returns every feature string the current runtime + device actually supports. Use it when debugging ("why isn't my branch firing?") or when reflecting capability state into telemetry. ## Current feature strings | Feature | Today | Notes | |---------|-------|-------| | `compute` | Apple-family 7+ devices on iOS 18 / visionOS 2 / macOS 15+ | GPU compute-kernel dispatch. `Kernel.fromSource(...)` / `Kernel.fromAsset(...)` compile a kernel; `mesh.runCompute(kernel, options)` dispatches it against `storage: "compute"` attribute buffers and / or compute textures. | | `sharedActivities` | platform-dependent | Multi-user / SharePlay support. `false` in App Clip and any build without the shared-activities trait. | | `cameraFeed` | iOS / iPadOS | Live camera-feed texture (via `Texture.cameraFeed()`). Uses the AR camera capture; not available on Mac Catalyst (no AR camera) or visionOS (different passthrough surface). | | `screenGestures` | iPhone / iPad / Mac | Screen-space `scene.on('pan'\|'pinch'\|'rotate')` and the `consume` camera claim. Not delivered on visionOS, where input is spatial (eyes + pinch) - use UI panels / buttons there instead. | | `instancing` | iOS 26 / visionOS 26 / macOS 26+ | GPU mesh instancing via `rep.setInstances(Instances.transforms([...]))` - render hundreds of copies of a mesh cheaply. Rendering-only (no physics, collision, or per-instance identity); use `entity.clone()` for objects the user interacts with. | More feature strings land here as their backends wire up. A name not in the table above always returns `false` from `has()` - never throws. ## Pattern Feature detection is the right tool for runtime branching across platforms and capability classes. Use it for: - Hardware capabilities the device may or may not have (compute, future: `handTracking`, `planeDetection`, `sceneReconstruction`). - Build-trait features that strip out of compact targets (`sharedActivities`, future: `gamification`). - Runtime-version features (anything reserved for a later API bump). For pure platform identity - "am I on visionOS?" - read `environment.hostingPlatform` directly: ```javascript if (environment.hostingPlatform === 'visionOS') { enableHandTrackingUI(); } ``` When you care about the **interaction model** rather than the exact OS, `environment.deviceCategory` is usually the cleaner branch - `'handheld'` (iPhone, iPad), `'spatial'` (Apple Vision Pro and other headsets), or `'desktop'` (Mac, desktop web). It collapses several hosts into the axis that actually drives your decision: ```javascript if (environment.deviceCategory === 'spatial') { showControlPanel(); // spatial input - use UI, not screen gestures } ``` Platform identity is single-valued (you're on exactly one host, in one category) and so stays a string property; capabilities form a set and live in the registry. Reach for a capability flag like `environment.features.has('screenGestures')` when you want the precise "does this specific API work here" answer rather than a form-factor guess.