TRS keyframe animation with createPoseTrack. Four { pos, rot } keyframes, cubic Hermite interpolation of position with auto-Catmull-Rom tangents, and slerp on rotations. A transport panel scrubs the track; a second panel toggles the trackPath overlay bits — PATH, CONTROLS, TANGENTS_IN, TANGENTS_OUT — and switches interpolation modes live. Unlike createCameraTrack, which applies its result to a camera automatically, PoseTrack produces an interpolated pose you fold into the transform stack yourself with applyPose(track.eval(out)).


Four keyframes, one object

createPoseTrack returns a PoseTrack — a renderer-agnostic state machine for { pos, rot, scl } keyframes. track.add(spec) appends one; adjacent duplicates are skipped by default:

const track = createPoseTrack()
track.add({ pos: [-220, -40,  0], rot: { axis: [0, 1, 0], angle:  0         } })
track.add({ pos: [ -70, -40, 80], rot: { axis: [0, 1, 0], angle:  Math.PI/3 } })

rot accepts every sensible form — raw quaternion [x,y,z,w], axis-angle, look direction, intrinsic Euler angles, shortest-arc from/to, or a rotation matrix. All get normalised to a unit quaternion internally, so you pick whichever form is cheapest to author for the keyframe at hand. Position uses cubic Hermite with centripetal Catmull-Rom tangents when none are supplied, so four waypoints produce a smooth curve without any hand-set control vectors.


Applying the pose

A PoseTrack does not bind to anything — it just interpolates. Each frame, track.eval(out) writes the current TRS into a pre-allocated buffer, and applyPose folds it into the current matrix stack:

const out = { pos: [0,0,0], rot: [0,0,0,1], scl: [1,1,1] }

function draw() {
  track.eval(out)
  push()
  applyPose(out)
  box(42)
  pop()
}

This is the core contrast with createCameraTrack, whose predraw hook calls cam.applyPose(...) on the bound camera automatically. PoseTrack is explicit on purpose — the same track can drive any number of objects, each with its own local transform layered above applyPose, and the object’s orientation can be composed with the scene’s own push/pop discipline without the track fighting for control of the view matrix.

For a zero-allocation direct-to-mat4 path, use track.mat4Model(mat) instead — it writes a column-major 4×4 into the provided Float32Array(16), ready for applyMatrix(...mat). Same math, no intermediate TRS object.


Interpolation modes

Position and rotation have independent interpolation modes, toggled live from the right-hand panel:

track.posInterp = 'hermite'   // 'hermite' | 'linear' | 'step'
track.rotInterp = 'slerp'     // 'slerp'   | 'nlerp'  | 'step'

hermite uses auto-Catmull-Rom tangents when keyframes carry none; linear lerps between adjacent keyframes, producing a straight polyline that coincides with the control polygon; step snaps to k0 until the next boundary — handy for game-state transitions or tile-based motion. On rotations, slerp is Ken Shoemake’s constant-angular-velocity interpolation, nlerp is a cheaper normalised lerp with slightly non-constant speed, and step snaps to k0’s quaternion. Modes compose — smooth position with stepped rotation is a valid, visually distinct combination, and switching mid-playback doesn’t break the transport: the cursor keeps its (seg, t) and the sampler just produces different output next frame.


Visualising the path with trackPath

trackPath is the gizmo for any track — PoseTrack or CameraTrack. Four bits cover the pose-track case:

const { PATH, CONTROLS, TANGENTS_IN, TANGENTS_OUT } = p5.Tree
stroke('#e8ecf1'); trackPath(track, { bits: PATH,         marker: null })
stroke('#4a5566'); trackPath(track, { bits: CONTROLS,     marker: null })
stroke('#5cd0ff'); trackPath(track, { bits: TANGENTS_IN,  marker: null })
stroke('#ff6ec7'); trackPath(track, { bits: TANGENTS_OUT, marker: null })

PATH samples the interpolated position polyline. CONTROLS is the straight control polygon joining adjacent keyframes — the chord that hermite and linear agree on at the endpoints but diverge between. TANGENTS_IN / TANGENTS_OUT draw arrows at each keyframe showing the effective incoming and outgoing tangents, either the stored tanIn/tanOut or the auto-CR fallback, which makes it easy to see why a curve bulges the way it does.

There is no colour argument. Every bit inherits the ambient stroke(...), so composing a multi-coloured path is just multiple calls — the same convention axes and viewFrustum follow. Passing marker: null suppresses the per-keyframe marker so overlay calls don’t stack redundant gizmos on top of each other.

A final pass with bits: 0 draws only the default marker — a six-axis cross oriented by each keyframe’s pose:

stroke(180)
trackPath(track, { bits: 0 })

Custom markers are a callback (kf, index, track, ctx) => { ... } called once per keyframe — useful for drawing a wireframe proxy, a thumbnail model, or a labelled index at each keyframe.


Two panels, two modes

createPanel has two call shapes. Passing a track yields a transport panel — play/stop, seek slider, rate, loop, bounce, and a + button that captures the current camera pose as a new keyframe:

uiTrack = createPanel(track, {
  x: 10, y: 10, width: 130,
  title: 'PoseTrack', info: true,
  reset: false, camera: null,    // suppress reset + '+' buttons
  color: 'white',
})

Passing a schema object yields a parameter panel — checkboxes, sliders, dropdowns, colour pickers — with values read either through a target sink or polled manually via panel.<key>.value():

uiViz = createPanel({
  path:      { value: true },                     // inferred as checkbox
  posInterp: { type: 'select', value: 'hermite',
               options: [{ label: 'hermite', value: 'hermite' },
                         { label: 'linear',  value: 'linear'  },
                         { label: 'step',    value: 'step'    }] },
}, { x: 460, y: 10, width: 130, labels: true, title: 'Path viz', color: 'white' })

// in draw():
track.posInterp = uiViz.posInterp.value()
if (uiViz.path.value()) trackPath(track, { bits: p5.Tree.PATH, marker: null })

Both panels self-tick — no panel.tick() needed in the draw loop. The transport panel polls track.playing / track.loop / track.bounce live, so poking the track from keyboard shortcuts or external code stays in sync with the UI, and the parameter panel’s dirty-flag model collapses rapid slider drags into a single push per frame.


References

  1. p5.tree READMEPoseTrack, CameraTrack, trackPath, createPanel
  2. Centripetal Catmull–Rom spline — the tangent-free Hermite default
  3. Cubic Hermite spline — the math behind tanIn / tanOut
  4. Slerp — Animating rotation with quaternions — Ken Shoemake’s constant-speed rotational interpolation
  5. Understanding Slerp, Then Not Using It — Jonathan Blow on when nlerp is the right call