Skip to main content

Placement scripts

Placement scripts keep an object in a useful spatial relationship after it is in the scene. They can keep a card near the viewer, make a label follow a moving object, face content toward the camera, move an object in an orbit, or animate visibility.

They are different from one-time room placement. Use xb.world.placeOnHorizontalSurface() to find a physical surface once. Use a placement script when XR Blocks must update the object's transform over time.

How they work

A placement script is a Script child that changes its parent object. Add the script to the object that must move. Add the object itself to the XR Blocks scene.

XR Blocks scene
└─ statusCard <- the object that moves
├─ UIText
├─ FollowHead <- changes statusCard.position
└─ FaceCamera <- changes statusCard.quaternion
import * as THREE from 'three';
import * as xb from 'xrblocks';

const statusCard = new xb.UICard({
size: {width: 0.42, height: 0.16},
children: [new xb.UIText({text: 'Ready'})],
});

statusCard.add(
new xb.FollowHead({
offset: new THREE.Vector3(0, -0.12, -0.8),
smoothing: 0.1,
}),
new xb.FaceCamera({mode: 'spherical', smoothing: 0.1})
);

xb.add(statusCard);
xb.init(new xb.Options());

XR Blocks finds the child scripts and calls their init() and update() methods. Do not call those lifecycle methods yourself.

Placement scripts do not need an Options.enable*() call. They use the camera and frame timer that XR Blocks creates during normal initialization.

All built-in placement scripts support objects under transformed parents. They read and write the correct world or local transform as required.

Choose a script

ScriptTransform or state that it controlsTypical use
FollowHeadPositionA view-relative HUD or tool palette
FollowObjectPosition, rotation, or bothA label, companion, or proxy that tracks another object
FaceCameraRotationReadable world-space UI and labels
OrbitPositionSatellites, indicators, and animated displays around a focus
VisibilityTransitionVisibility and scaleShow, hide, or toggle content without an abrupt change

These scripts do not create visible content. They control the parent that owns them.

Keep content near the viewer

FollowHead treats offset as a camera-space offset in meters. Negative Z is in front of the viewer. The script converts this offset to the coordinate space of the object's parent each frame.

const follow = new xb.FollowHead({
offset: new THREE.Vector3(0.3, -0.2, -0.9),
smoothing: 0.08,
});

card.add(follow);

smoothing controls how quickly the object reaches the target position. A higher value responds faster. The default is 0.1.

Combine FollowHead with FaceCamera when the object must follow the viewer and remain readable. The scripts control different transform parts, so they do not compete.

card.add(
new xb.FollowHead({
offset: new THREE.Vector3(0, -0.15, -0.75),
}),
new xb.FaceCamera({mode: 'spherical'})
);

Use UIOverlay instead when the content must be fixed to the 2D viewport. FollowHead is for a real world-space object that moves relative to the head.

Follow another object

FollowObject copies the target's world position, world rotation, or full pose. positionOffset uses world axes. rotationOffset is applied after the target's world rotation, so it keeps a relative orientation to the target.

const label = new xb.UICard({
size: {width: 0.28, height: 0.1},
children: [new xb.UIText({text: 'Robot A'})],
});

label.add(
new xb.FollowObject({
target: robot,
mode: 'position',
positionOffset: new THREE.Vector3(0, 0.35, 0),
}),
new xb.FaceCamera({mode: 'spherical'})
);

xb.add(label);

Choose the mode based on which transform parts the follower must copy:

ModeResult
positionCopies world position and adds positionOffset
rotationCopies world rotation and applies rotationOffset
poseCopies both position and rotation

The default mode is position. Use it with FaceCamera for a label: the first script controls position and the second controls rotation. Do not use mode: 'pose' with FaceCamera, because both scripts would write the follower's rotation each frame.

Use a quaternion for a rotation offset:

const quarterTurn = new THREE.Quaternion().setFromEuler(
new THREE.Euler(0, Math.PI / 2, 0)
);

model.add(
new xb.FollowObject({
target: controllerModel,
mode: 'pose',
rotationOffset: quarterTurn,
})
);

Face content toward the camera

FaceCamera rotates its parent toward the active camera.

card.add(
new xb.FaceCamera({
mode: 'capsule',
capsuleHalfHeight: 0.25,
smoothing: 0.1,
})
);
ModeBehaviorTypical use
capsuleStays upright near eye height, then tilts toward the viewerPanels that can move vertically
cylindricalTurns around the vertical axis and always stays uprightPanels and signs at about eye height
sphericalTurns vertically and horizontally toward the cameraLabels above, below, or around the viewer

The default mode is capsule. Its upright region extends capsuleHalfHeight above and below the camera. The default half-height is 0.25 meters. A higher smoothing value responds faster. The default is 0.1.

UICard can accept a TransformScript as a direct child. Nested UI elements such as UIPanel accept UI children, not placement scripts. Attach the script to the card root that must move.

Orbit around a target

Orbit moves its parent around a target object. The parent and target must be separate scene branches. Neither object can be the other object's ancestor or descendant.

const focus = new THREE.Mesh(
new THREE.SphereGeometry(0.08),
new THREE.MeshStandardMaterial({color: 0xfbbc04})
);
focus.position.set(0, 1.2, -1);

const satellite = new THREE.Mesh(
new THREE.SphereGeometry(0.04),
new THREE.MeshStandardMaterial({color: 0xea4335})
);
satellite.add(
new xb.Orbit({
target: focus,
radius: 0.35,
period: 6,
path: 'elliptical',
eccentricity: 0.3,
inclination: Math.PI / 5,
precessionPeriod: 12,
direction: 'counterclockwise',
clearance: 0.02,
})
);

xb.add(focus, satellite);

The main options are:

OptionMeaningDefault
targetObject at the focus of the orbitRequired
radiusSemi-major radius in meters0.5
periodSeconds for one orbit20
pathcircular or ellipticalcircular
frameworld, target, or view reference frameworld
eccentricityEllipse shape in the range 0 to less than 10.2 for an ellipse, otherwise 0
inclinationInitial orbital-plane tilt in radians0
precessionPeriodSeconds for one rotation of the orbital planeNo precession
directionclockwise or counterclockwisecounterclockwise
clearanceExtra minimum gap between object bounds in meters0

For frame: 'world', the orbital plane stays aligned with the world. For frame: 'target', it follows the target rotation. For frame: 'view', it is oriented relative to the active camera.

Orbit measures the world bounds of the target and the orbiting object. It increases the effective radius when needed to prevent the captured bounds from overlapping. Call orbit.resume() after geometry or scale changes so the script captures the new bounds.

const orbit = new xb.Orbit({target: focus, radius: 0.2});
satellite.add(orbit);

satellite.scale.setScalar(2);
orbit.resume(); // Refresh bounds and restart from the current position.

Animate show and hide

VisibilityTransition changes its parent's scale and visible state. Keep a reference to the script so application code can call show(), hide(), or toggle().

const transition = new xb.VisibilityTransition({duration: 0.3});
detailsCard.add(transition);

const toggleButton = new xb.UIButton({
label: 'Details',
onClick: () => transition.toggle(),
});

duration is in seconds. When hiding starts, the script captures the parent's current scale. It restores that scale when the object is shown again. Do not replace the scale from another per-frame script while the transition runs.

Use placement scripts with manipulation

Placement scripts work with automatic object manipulation. XR Blocks suspends the direct TransformScript children while the user manipulates their parent. It resumes them when the manipulation ends or is canceled.

const card = new xb.UICard({
size: {width: 0.4, height: 0.2},
manipulation: true,
});

const follow = new xb.FollowHead({
offset: new THREE.Vector3(0, 0, -0.8),
});
card.add(follow);

On resume, scripts that track a spatial relationship use the manipulated pose as their new baseline:

  • FollowHead captures a new camera-space offset.
  • FollowObject captures new world position and rotation offsets.
  • Orbit restarts from the manipulated position and refreshes overlap bounds.
  • FaceCamera resumes facing the camera.
  • VisibilityTransition continues to use its visibility transition state.

You can use the same behavior for a transform that your application controls. Suspend the script, move the object, and then resume it.

follow.suspend();
card.position.set(0.4, 1.4, -1);
follow.resume(); // Preserve this pose as the new follow relationship.

Combine scripts safely

One object can have more than one placement script when each script controls a different part of the object state.

CombinationResult
FollowHead + FaceCameraView-relative world-space content that faces the viewer
FollowObject in position mode + FaceCameraA readable label that tracks an object
Orbit + FaceCameraOrbiting content that stays oriented toward the viewer
A position script + VisibilityTransitionMoving content that can animate in and out

Avoid combinations in which two scripts write the same transform part. For example, do not combine FollowHead with Orbit, because both write position. Do not combine FollowObject in rotation or pose mode with FaceCamera, because both write rotation.

Place once, then keep updating

One-time surface placement and continuous placement scripts can be used together. Place an object on a detected surface first, then attach behavior that must continue.

class SurfaceLabel extends xb.Script {
async init() {
const card = new xb.UICard({
size: {width: 0.3, height: 0.12},
children: [new xb.UIText({text: 'Placed'})],
});
card.visible = false;
this.add(card);

const placed = await xb.world.placeOnHorizontalSurface(card, {
seconds: 15,
});
card.visible = placed;

if (placed) {
card.add(new xb.FaceCamera({mode: 'cylindrical'}));
}
}
}

const options = new xb.Options();
options.enableDepth();
options.enablePlaneDetection();

xb.add(new SurfaceLabel());
xb.init(options);

See templates/03_spatial_placement for the one-time surface placement flow. See demos/interaction_playground/main.js for all placement scripts together with UI and automatic manipulation.