Scenario aspects — ready-made behaviors

Five ready-made aspects that make a 3D scene DO something without writing code: travel to a point, run a waypoint route, spin, face a target, start a GLB animation. Built for the visual scene editor (attach from the Aspects menu, pick targets in the viewport), they are ordinary aspects from code too (node.aspect(MoveTo, { target })); for a behaviour of your own write an Aspect.

  • move an object to a point, or back and forth between two
  • drive a car or a bus along a waypoint route
  • spin a fan, a coin or a pickup continuously
  • make a turret or a camera keep facing a target
  • start a model's animation clip when the scene opens
  • move a camera along a path while it looks at the hero

At a glance

TypeScript
const level = defineScene({
  nodes: {
    crate: {
      mesh: { kind: 'box', size: 1 },
      position: [0, 0.5, 0],
      aspects: [use(MoveTo, { target: ref('dropPoint'), duration: 3, mode: 'pingpong' })],
    },
    dropPoint: { position: [4, 0.5, -2] },                   // an Empty: no source block
    fan: { mesh: { kind: 'box', size: [2, 0.1, 0.2] }, position: [0, 3, 0], aspects: [use(Spin, { speed: 180 })] },
    turret: { mesh: { kind: 'box', size: 0.5 }, position: [-3, 0.25, 0], aspects: [use(LookAt, { target: ref('crate'), smoothing: 0.2 })] },
    bus: {
      model: asset('./bus.glb'),
      aspects: [use(FollowPath, { path: ref('route'), duration: 12, forward: 'z' }), use(PlayAnimation, { clip: 'Drive' })],
    },
    route: {
      children: {
        stop1: { position: [0, 0, 0] },
        stop2: { position: [8, 0, 2] },
        stop3: { position: [5, 0, 8] },
      },
    },
  },
})

const main = async () => {
  const { nodes } = await level.open()
  nodes.crate.moveTo.duration = 1.5                          // every field is live
  nodes.turret.facing.target = nodes.bus                     // LookAt's accessor is `facing`
}
main()

Targets and waypoints are Empty nodes

The usual recipe pairs these aspects with Empty nodes (plain group nodes — see scene-files.md): place an Empty where something should go, then point the aspect at it. On a camera node, the same aspects become camera moves.

Motion runs per frame in play mode (update(dt)); times are in seconds, angles in degrees. Each one's fields are live properties on its accessor — node.moveTo, node.followPath, node.spin, node.facing, node.playAnimation — so code can change a duration or a target while it runs.

Note

while a scene is being edited, the aspects are inert, but the movement ones draw their paths as editor gizmos (blue for movement, amber for look-at; overlay lines, not scene content), so a route can be seen and tuned without running the scene.

Move a node to a point: MoveTo

Travel from the node's starting position to a target node.

Field Default Meaning
target null Destination node (ref('path') in scene files — see scene-files.md for the scoped-upward resolution rules)
duration 2 Seconds for the full trip
delay 0 Seconds to wait before starting
mode 'once' 'once' stop there · 'loop' restart from the start point · 'pingpong' back and forth
easing 'smooth' 'smooth' ease-in-out · 'linear' constant speed

The start is where the node is when the trip begins (its first frame with a target). The target is tracked live — if it moves, the trip bends toward its new position (and once keeps the node pinned to it after arriving).

TypeScript
crate: {
  mesh: { kind: 'box', size: [1, 1, 1] },
  position: [0, 0.5, 0],
  aspects: [use(MoveTo, { target: ref('dropPoint'), duration: 3, mode: 'pingpong' })],
},
dropPoint: { position: [4, 0.5, -2] },   // an Empty

Drive along a waypoint route: FollowPath

Travel through the children of a path node, in scene-file order — add an Empty per waypoint.

Field Default Meaning
path null The node whose children are the waypoints
duration 6 Seconds for one full pass
delay 0 Seconds to wait before starting
mode 'loop' 'loop' closed circuit (last → first) · 'once' stop at the last point · 'pingpong' back and forth along the open path
orient true Turn to face the direction of travel
forward '-z' Which local axis leads when orienting

Speed is constant along the whole path (segment lengths are measured live, so dragging a waypoint retimes the run correctly). GLB models usually face +z — flip forward if your model drives backwards.

TypeScript
bus: {
  model: asset('./bus.glb'),
  aspects: [use(FollowPath, { path: ref('route'), duration: 12, forward: 'z' })],
},
route: {
  position: [0, 0, 0],
  children: {
    stop1: { position: [0, 0, 0] },
    stop2: { position: [8, 0, 2] },
    stop3: { position: [5, 0, 8] },
  },
},

Spin continuously: Spin

Continuous rotation about a local axis — speed degrees per second (default 45; negative spins the other way), axis 'x' | 'y' | 'z' (default 'y'). Composes in quaternion space, so it's correct on pre-rotated nodes.

TypeScript
const scene = new Scene()
const fan = Mesh.box({ size: [2, 0.1, 0.2], position: [0, 3, 0] })
fan.aspect(Spin, { speed: 180 })                 // 180°/s about its local Y
scene.add(Light.sun(), fan)
scene.open()

Keep facing a target: LookAt {#lookat}

Keep the node facing a target node each frame.

Field Default Meaning
target null The node to face
forward '-z' Which local axis points at the target
smoothing 0 0 snaps instantly; otherwise seconds of turn lag (bigger = slower)

A turret tracking the player, a signpost, or — on a camera node — a camera that keeps a moving hero framed while FollowPath drives the crane.

Note

the accessor is node.facing, not node.lookAt — that name is the node's own lookAt() method. A bone that should look at something is IK.LookAt.

Play a model's animation on start: PlayAnimation

Start a GLB clip when the scene runs. Attach to a model: node.

Field Default Meaning
clip '' Clip name; empty = the model's first clip
loop true true = the model's loop (model.anim.playLoop); false = played once, holding its last frame
speed 1 Playback rate
TypeScript
const scene = defineScene({
  nodes: {
    hero: { model: asset('./hero.glb'), aspects: [use(PlayAnimation, { clip: 'Idle' })] },  // loops
    door: { model: asset('./door.glb'), aspects: [use(PlayAnimation, { clip: 'Open', loop: false })] },
  },
})
scene.open()

In the editor its inspector card lists the model's clips in a dropdown and can Preview the clip live while editing (and Stop a non-looping preview). For runtime control (switching clips from your own code, seek, events) use model.anim directly — this aspect only presses play when it attaches.

See also

  • Scene files — defineScene, use, ref, Empties, camera nodes
  • Aspects — writing your own behaviours
  • Animator — clip playback from code