Building on the core

Integrate Klipp with your own renderer or engine, or use the data and functions underneath.

The core is the whole camera system. An integration only adapts the two places where it meets your world: where the shot goes, and where the targets come from. Klipp's own integrations are built exactly this way.

Your own integration

Put the shot on your camera

Read klipp.shot after update. To update your camera only when the shot changes, extend Klipp and override write instead:

EngineKlipp.ts
import { Klipp, type CameraState } from '@pmndrs/klipp';

class EngineKlipp extends Klipp {
  protected override write(shot: CameraState, transformChanged: boolean, lensChanged: boolean) {
    if (transformChanged) engine.camera.setTransform(shot.position, shot.quaternion);
    if (lensChanged) engine.camera.setFov(shot.fov);
  }
}

Read your targets

Every piece with a target reads it through readTarget. By default the target is a TargetPose you fill in yourself. To follow your engine's objects directly, extend the piece and fill the pose from them:

EngineFollow.ts
import { quat, vec3 } from 'math';
import { FollowBody, targetPose, type TargetPose } from '@pmndrs/klipp';

class EngineFollow extends FollowBody<EngineObject | null> {
  private readonly pose = targetPose.create();

  protected override readTarget(): TargetPose | null {
    if (!this.target) return null;
    vec3.copy(this.pose.position, this.target.worldPosition);
    quat.copy(this.pose.rotation, this.target.worldRotation);
    this.pose.hasRotation = true;
    return this.pose;
  }
}

camera.body = new EngineFollow(player, { offset: [0, 3, 8] });

The same works for HardLockToTargetBody, OrbitFollowBody, PositionComposerBody, HardLookAtAim, RotateWithFollowTargetAim, RotationComposerAim and PanTiltAim. FollowZoomExtension reads its target the same way, ConfinerExtension its bounds through readBounds and GroupFramingExtension its members through readMembers.

Name your classes after the core one with a suffix of your own, like FollowBodyThree, which is FollowBody reading an Object3D.

For logic of your own, write a custom piece.

Data and functions

Under its classes, every piece is plain data and functions on math, in a namespace named after it, with the same three parts. For example, FollowBody has:

  • FollowParams, and follow.createParams(settings) for the defaults: its settings (offset, damping, ...).
  • FollowState and follow.createState(): the memory it keeps between frames, like damping velocity.
  • follow.update(out, state, params, ...): advances one frame and writes into a CameraState.

A class is a thin wrapper that passes itself as params. Calling the functions directly gives you full control, for example from an ECS or a simulation:

import { cameraState, follow, targetPose, hardLookAt } from '@pmndrs/klipp';

const camera = cameraState.create();
const state = follow.createState();
const params = follow.createParams({ offset: [0, 3, 8], damping: 0.5 });
const player = targetPose.create();

function frame(dt: number, justActivated: boolean) {
  follow.update(camera, state, params, player, dt, justActivated);
  hardLookAt.update(camera, player.position);
}

justActivated is true on a camera's first frame, and again when it turns back on, so smoothing starts from the right place. Virtual cameras pass it for you.

Picking the camera and blending works the same way. klippState.tick returns the shot, and pushes transitions to state.events as data:

import { klippState, cameraState } from '@pmndrs/klipp';

const klipp = klippState.create();
const klippParams = klippState.createParams();
klippState.register(klipp, { name: 'follow', priority: 10, state: camera });

function frame(dt: number) {
  const shot = klippState.tick(klipp, klippParams, dt);
  klipp.events.length = 0;
  // write shot to your camera, for example only when cameraState.transformEquals says it moved
}

With an ECS

Because state is plain data, it can live in components. A sketch with Koota:

import { createWorld, trait } from 'koota';
import { cameraState, follow, targetPose } from '@pmndrs/klipp';

const Camera = trait(() => cameraState.create());
const Target = trait(() => targetPose.create());
const Follow = trait(() => ({ state: follow.createState(), params: follow.createParams({ offset: [0, 3, 8] }) }));

const world = createWorld();
world.spawn(Camera, Target, Follow);

function frame(dt: number) {
  world.query(Camera, Target, Follow).updateEach(([camera, target, { state, params }]) => {
    follow.update(camera, state, params, target, dt, false);
  });
}

What's available

Each piece is a namespace with the same names: update runs one frame, createState() makes its memory and createParams() its settings. The other namespaces are the parts they share.

AreaNamespaces
Bodyfollow, hardLockToTarget, orbitFollow, positionComposer
AimhardLookAt, panTilt, rotateWithFollowTarget, rotationComposer
ExtensioncameraOffset, confiner, followZoom, groupFraming, lens, recomposer
NoisebasicMultiChannelPerlin, impulseListener, impulses
Following a targettracker (shared by follow and orbitFollow), threeRing (the threeRing orbit style)
TargetstargetExtent, targetPose
InputconsumedInput, inputAxis, inputMapping
Choosing the cameraklippState
Blendingblend, cameraState.lerp
Dampingdamping, predictor
DebuggingdebugZones
OutputcameraState.lensEquals, cameraState.transformEquals