Building on the core
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:
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:
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, andfollow.createParams(settings)for the defaults: its settings (offset,damping, ...).FollowStateandfollow.createState(): the memory it keeps between frames, like damping velocity.follow.update(out, state, params, ...): advances one frame and writes into aCameraState.
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.