Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
771bce0
feat: add tracker that damps the target point in the camera offset frame
kvvasuu Oct 8, 2026
a8eebbd
feat: place the camera on a sphere around the target in orbitFollow
kvvasuu Oct 8, 2026
2e61ec5
feat: add OrbitFollowBody
kvvasuu Oct 8, 2026
96b1c3d
feat: add OrbitFollowBodyThree
kvvasuu Oct 8, 2026
cc5df50
feat: ease radius changes and orbit up to the poles in orbitFollow
kvvasuu Oct 8, 2026
ab32f65
feat: add the OrbitFollow component
kvvasuu Oct 8, 2026
d1ade1b
docs: add OrbitFollow example
kvvasuu Oct 8, 2026
02e07de
fix: capture lockToTargetOnAssign again on mode change and activation
kvvasuu Oct 8, 2026
4b378f8
fix: keep look rotations valid when looking along up
kvvasuu Oct 8, 2026
30449f2
test: add golden trajectories for OrbitFollow
kvvasuu Oct 8, 2026
0cb2cae
feat: prime OrbitFollow axes from a position and restart recentering …
kvvasuu Oct 8, 2026
b10c4af
feat: sync OrbitFollow recentering and recenter behind the target
kvvasuu Oct 8, 2026
9505a79
docs: add recentering to the OrbitFollow example
kvvasuu Oct 8, 2026
bdf8eee
feat: let Bodies bypass Aim rotation damping for their own turns
kvvasuu Oct 8, 2026
9eac1b4
docs: add a damped Aim to the OrbitFollow example
kvvasuu Oct 8, 2026
2b247d5
feat: start the OrbitFollow vertical axis level with the target
kvvasuu Oct 8, 2026
ff7a50f
docs: add OrbitFollow
kvvasuu Oct 8, 2026
dad0992
fix: clear the OrbitFollow rotation damping bypass without a target
kvvasuu Oct 8, 2026
2867e7f
test: drop vertical resets made redundant by the level default
kvvasuu Oct 8, 2026
d21d3ca
docs: list OrbitFollow in the getting started pages
kvvasuu Oct 8, 2026
62f5480
feat: add the threeRing orbit style to OrbitFollow
kvvasuu Oct 8, 2026
974ffa5
docs: add threeRing to the OrbitFollow docs and example
kvvasuu Oct 8, 2026
a2c0562
feat: draw the OrbitFollow orbit with a debug prop
kvvasuu Oct 8, 2026
ef306e9
docs: document the orbit debug drawing and show it in the example
kvvasuu Oct 8, 2026
4a82f00
docs: add pointer lock to the OrbitFollow example
kvvasuu Oct 8, 2026
cd31808
docs: split the OrbitFollow example into scenes
kvvasuu Oct 8, 2026
dc5d0b8
feat!: track Follow targets with the shared tracker and add rotationD…
kvvasuu Oct 8, 2026
bd5ee28
test: re-record golden trajectories for the Follow tracker
kvvasuu Oct 8, 2026
53a1dc9
docs: document rotationDamping and add it to the examples
kvvasuu Oct 8, 2026
15c93a5
docs: add focused input examples on OrbitFollow
kvvasuu Oct 9, 2026
e3d1c98
feat: add the autoNormalize prop to OrbitFollow
kvvasuu Oct 9, 2026
740d144
feat: add setFromPosition and setFromRotation to OrbitFollow
kvvasuu Oct 9, 2026
c062c19
docs: add an OrbitFollow with GroupFraming example
kvvasuu Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions benches/core/body.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as cameraState from '../../src/core/CameraState';

import { FollowBodyThree } from '../../src/three/body/FollowBodyThree';
import { HardLockToTargetBodyThree } from '../../src/three/body/HardLockToTargetBodyThree';
import { OrbitFollowBodyThree } from '../../src/three/body/OrbitFollowBodyThree';
import { PositionComposerBodyThree } from '../../src/three/body/PositionComposerBodyThree';

import { makeMovingMeshTarget, makeMovingTarget } from '../targets';
Expand Down Expand Up @@ -32,6 +33,31 @@ group('Body.update @body', () => {
});
});

bench('OrbitFollow (damping, axis moving)', function* () {
const { object, step } = makeMovingTarget();
const body = new OrbitFollowBodyThree(object, { damping: 0.5 });
const out = cameraState.create();
yield warm(() => {
step();
body.horizontal.applyDelta(0.5);
body.update(out, 0.016, false);
return out.position[0];
});
});

bench('OrbitFollow (threeRing, damping, axis moving)', function* () {
const { object, step } = makeMovingTarget();
const body = new OrbitFollowBodyThree(object, { orbitStyle: 'threeRing', damping: 0.5 });
const out = cameraState.create();
yield warm(() => {
step();
body.horizontal.applyDelta(0.5);
body.vertical.applyDelta(Math.sin(body.horizontal.value) * 0.5);
body.update(out, 0.016, false);
return out.position[0];
});
});

bench('PositionComposer (deadZone + hardLimit)', function* () {
const { object, step } = makeMovingTarget();
const body = new PositionComposerBodyThree(object, {
Expand Down
1 change: 1 addition & 0 deletions docs/advanced/custom-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function pointerDrift(pointer: { x: number; y: number }, strength = 0.1,
| `target`, `hasTarget` | `Vec3`, `boolean` | The point the Body follows, if any. Used by [blend hints](/blending/introduction#blend-hints). |
| `lookAtTarget`, `hasLookAtTarget` | `Vec3`, `boolean` | The point the Aim looks at, if any. |
| `referenceUp` | `Vec3` | Which way is up for Aims. Defaults to `[0, 1, 0]`. |
| `rotationDampingBypass` | `Quat` | How far a Body turned the camera this frame, for example around an orbit. Aims with damping follow this turn right away. Reset to identity every frame. |

Vectors are plain arrays (`[x, y, z]`, quaternions `[x, y, z, w]`) from [`math`](https://github.com/pmndrs/math), which klipp depends on. Use its `vec3` and `quat` functions to work with them, or copy to three.js with `vector.fromArray(out.position)`.

Expand Down
2 changes: 1 addition & 1 deletion docs/body/camera-controls.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: CameraControls
description: Lets the user orbit, pan and zoom with the mouse or touch.
nav: 24
nav: 25
---

A user-controlled orbit camera, built on [`camera-controls`](https://github.com/yomotsu/camera-controls). It still takes part in priority and blending like any other camera. Unlike other Bodies, it controls both position and rotation, so don't add an Aim next to it.
Expand Down
5 changes: 3 additions & 2 deletions docs/body/follow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Keeps the camera at `offset` from the target. By default the offset turns with t
| ------- | ----------- | ------- |
| `target` | What to follow. See [Targets](/getting-started/core-concepts#targets). | |
| `offset` | Where the camera sits relative to the target. | `[0, 0, 10]` |
| `damping` | Seconds to catch up with the target. `0` is instant. See [Damping](/getting-started/core-concepts#damping). | `0` |
| `damping` | Seconds to catch up with the target's position. `0` is instant. See [Damping](/getting-started/core-concepts#damping). | `0` |
| `rotationDamping` | Seconds to turn with the target, for every `bindingMode` but `worldSpace`. `0` is instant. | `0` |
| `maxSpeed` | Maximum catch-up speed, in units per second. | `Infinity` |
| `bindingMode` | How the offset turns with the target. See below. | `BindingModes.lockToTarget` |

Expand All @@ -23,7 +24,7 @@ Keeps the camera at `offset` from the target. By default the offset turns with t
| `lockToTarget` | full rotation, including roll. |
| `lockToTargetNoRoll` | yaw and pitch, but never rolls. |
| `lockToTargetWithWorldUp` | yaw only. Good for flying or jumping targets. |
| `lockToTargetOnAssign` | rotation at the moment `target` is set, then stays fixed. |
| `lockToTargetOnAssign` | rotation at the moment `target` is set, the camera goes live or this mode is chosen, then stays fixed. |
| `worldSpace` | nothing. The offset is always in world space. |

A target without rotation, like a fixed point, always uses world space.
Expand Down
1 change: 1 addition & 0 deletions docs/body/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A Body decides where the camera is. Where it looks is up to the [Aim](/aim/intro
| ---- | ---------- |
| [`HardLockToTarget`](/body/hard-lock-to-target) | Placing the camera exactly on a target: first person, a camera mounted on something, a fixed spot. |
| [`Follow`](/body/follow) | Keeping a fixed offset from a target: chase cameras, third person. |
| [`OrbitFollow`](/body/orbit-follow) | Letting the user orbit and zoom around a target: third person, inspecting an object. |
| [`PositionComposer`](/body/position-composer) | Keeping a target at a spot on screen: top-down, side-scrollers, anything with a fixed rotation. |
| [`CameraControls`](/body/camera-controls) | Letting the user orbit, pan and zoom. three.js and React only. |

Expand Down
148 changes: 148 additions & 0 deletions docs/body/orbit-follow.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
title: OrbitFollow
description: Lets the user orbit and zoom around a target.
nav: 23
---

Puts the camera on a sphere around the target, or on a surface through [three rings](#three-rings). Three [`InputAxis`](/input/input-axis) pick the spot: `horizontal` goes around the target and `vertical` up and down, both in degrees, and `radial` zooms. They can be driven by the mouse, touch, the wheel and pinches through an [input controller](/input/input-controller), or from code. Pair it with an Aim that looks at the same target, like [`HardLookAt`](/aim/hard-look-at), for an orbit or third person camera.

The camera sits `radius × exp(radial)` away from the target. `radial` is the natural log of the zoom: `0` is `radius`, `ln 2` twice as far, `ln 0.5` half as far. That way each step of the wheel zooms by the same ratio near and far, and a pinch zooms exactly as much as the fingers spread.

To start from a specific spot, pass a `position` to the camera's [`initialState`](/api/virtual-camera#initialstate). `horizontal` and `vertical` are set from its direction around the target, `radial` keeps its value. Later on, `setFromPosition(position)` and `setFromRotation(rotation)` on the body ease the camera there with the axes' damping.

## Settings

| Setting | Description | Default |
| ------- | ----------- | ------- |
| `target` | What to orbit. See [Targets](/getting-started/core-concepts#targets). | |
| `orbitStyle` | `'sphere'`, or `'threeRing'` for a surface through three rings. | `'sphere'` |
| `radius` | Distance from the target while `radial` is `0`, for `'sphere'`. Changes ease in with the `radial` axis' `damping`. | `10` |
| `orbits` | The `top`, `center` and `bottom` ring for `'threeRing'`, each a `radius` and a `height` above the target. | see [Three rings](#three-rings) |
| `splineCurvature` | How strongly the `'threeRing'` surface curves between the rings, from `0` to `1`. | `0` |
| `targetOffset` | Center of the orbit relative to the target, turned by `bindingMode`. | `[0, 0, 0]` |
| `bindingMode` | How the orbit turns with the target. See [Follow](/body/follow#bindingmode). | `BindingModes.worldSpace` |
| `damping` | Seconds to catch up with the target. See [Damping](#damping). | `0` |
| `rotationDamping` | Seconds to turn with the target, for every `bindingMode` but `worldSpace`. | `0` |
| `maxSpeed` | Maximum catch-up speed, in units per second. | `Infinity` |
| `recenteringTarget` | Where `horizontal` recenters to: `'trackingTarget'` brings the camera back behind the target, `'axisCenter'` to the axis' `center`. | `'trackingTarget'` |
| `horizontal`, `vertical`, `radial` | React only. Settings for each axis, like `range`, `damping` or `recentering`. On the core and in three.js, set them on `body.horizontal` and the others. | see [Axes](#axes) |
| `autoNormalize` | React only. Keep `horizontal` inside its range after each drag. `autoNormalize` on the axis elsewhere. | `false` |
| `debug` | React only. Draws the orbit in the scene. See [Debugging](/debugging/introduction#orbits). | `false` |

## Axes

| Axis | Range | Center | Wraps |
| ---- | ----- | ------ | ----- |
| `horizontal` | `[-180, 180]` | `0`, the camera on the target's `+Z` side | yes |
| `vertical` | `[-90, 90]` | `0`, level with the target | no |
| `radial` | `[ln 0.5, ln 2]` | `0` | no |

Every axis starts at its `center`. Dragging right with a positive `gain` turns the view right, the same way as [`PanTilt`](/aim/pan-tilt).

Axes with the same `recentering.time` [recenter](/input/input-axis#recentering) together: input on one of them restarts the wait on the others. With `recenteringTarget: 'trackingTarget'`, the default, `horizontal` recenters behind the target's forward, `-Z`, whatever the `bindingMode`.

## Three rings

With `orbitStyle: 'threeRing'`, `vertical` runs from the `bottom` ring at the start of its range, through `center`, to the `top` ring at its end, and `radial` scales the whole surface. The rings let the camera come closer when it looks from above or below.

By default all three rings have a `radius` of `10`, at heights `-10`, `0` and `10`: a plain cylinder whose center ring is where the sphere starts.

## Damping

`damping` makes the camera lag behind a moving target. Going around the orbit is never delayed by it, the camera turns around the target right away. To smooth the input itself, set `damping` on the axes.

An Aim with damping, like [`RotationComposer`](/aim/rotation-composer), also follows the turns of the orbit without its damping, so the target stays in place on screen while the user drags.

## Usage

<Tabs defaultValue="core" syncKey="integration">
<TabsList>
<TabsTrigger value="core">Core</TabsTrigger>
<TabsTrigger value="three">three.js</TabsTrigger>
<TabsTrigger value="r3f">React Three Fiber</TabsTrigger>
</TabsList>
<TabsContent value="core">

```ts
import { HardLookAtAim, OrbitFollowBody, targetPose } from '@kvvasuu/klipp';
import { InputControllerDom } from '@kvvasuu/klipp/dom';

const player = targetPose.create(); // keep player.position up to date

const body = new OrbitFollowBody(player, { radius: 8, damping: 0.3 });
body.radial.damping = 0.15;
camera.body = body;
camera.aim = new HardLookAtAim(player);

const orbit = { axes: { x: body.horizontal, y: body.vertical }, gain: 0.3 };
const controller = new InputControllerDom({
mouseButtons: { left: orbit },
touches: { one: orbit },
wheel: { axis: body.radial, gain: 0.001, invert: true },
pinch: { axis: body.radial, invert: true },
});
klipp.whileOnScreen(camera, () => {
controller.connect(canvas);
return () => controller.disconnect();
});
klipp.registerUpdate(() => controller.update());
```

Scrolling up and spreading a pinch are positive, while zooming in makes `radial` smaller, hence `invert`.

**Recentering**

```ts
const recentering = { enabled: true, wait: 1, time: 0.5 };
body.horizontal.recentering = recentering;
body.vertical.recentering = recentering;
```

</TabsContent>
<TabsContent value="three">

```ts
import { Vector3 } from 'three';
import { HardLookAtAimThree, OrbitFollowBodyThree } from '@kvvasuu/klipp/three';

const body = new OrbitFollowBodyThree(player, { radius: 8, targetOffset: new Vector3(0, 1.5, 0), damping: 0.3 });
body.radial.damping = 0.15;
camera.body = body;
camera.aim = new HardLookAtAimThree(player);
```

Map the input as on the core.

</TabsContent>
<TabsContent value="r3f">

```tsx
import { Aim, Body, InputController, VirtualCamera } from '@kvvasuu/klipp/react';

const orbit = { axes: { x: 'horizontal', y: 'vertical' }, gain: 0.3 };

<VirtualCamera name="orbit" priority={10}>
<Body.OrbitFollow target={playerRef} radius={8} damping={0.3} radial={{ damping: 0.15 }}>
<InputController
mouseButtons={{ left: orbit }}
touches={{ one: orbit }}
wheel={{ axis: 'radial', gain: 0.001, invert: true }}
pinch={{ axis: 'radial', invert: true }}
/>
</Body.OrbitFollow>
<Aim.HardLookAt target={playerRef} />
</VirtualCamera>
```

`ref` gives you the `OrbitFollowBodyThree`. Removing an axis setting brings back its default without moving the axis.

**Recentering**

```tsx
const recentering = { enabled: true, wait: 1, time: 0.5 };

<Body.OrbitFollow target={playerRef} horizontal={{ recentering }} vertical={{ recentering }} />
```

</TabsContent>
</Tabs>
2 changes: 1 addition & 1 deletion docs/body/position-composer.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: PositionComposer
description: Moves the camera to keep a target at a spot on screen.
nav: 23
nav: 24
---

Moves the camera, without rotating it, so the target stays at `screenPosition` and `cameraDistance` away. With a `deadZone` the target can move freely inside it before the camera reacts.
Expand Down
32 changes: 31 additions & 1 deletion docs/debugging/introduction.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Debugging
description: Visualize framing zones and camera frustums.
description: Visualize framing zones, camera frustums and orbits.
nav: 80
---

Expand All @@ -25,6 +25,10 @@ Each element has a CSS class you can restyle: `klipp-debug-deadzone`, `klipp-deb

A frustum helper draws a camera in the scene, so you can see where an inactive camera is and what it's looking at. Every camera's `state` holds what it needs: `position`, `quaternion`, `fov`, `near` and `far`. Off screen, a camera updates only now and then by default, so its frustum moves in steps. Give it [`standbyUpdate: 'always'`](/api/virtual-camera#standbyupdate) to see it move smoothly.

## Orbits

An orbit helper draws where an [`OrbitFollow`](/body/orbit-follow) camera can move, around the point it tracks: the circle `horizontal` moves it along in orange, the arc `vertical` moves it along in blue, and the three rings of `'threeRing'` in gray.

## Usage

<Tabs defaultValue="core" syncKey="integration">
Expand All @@ -50,6 +54,10 @@ overlay.render(debugZones.composer(body.screenPosition, body.deadZone, body.hard

`debugZones.composer` turns a composer's settings into the same boxes the React components draw. Call `render` again whenever the zones change, and `disconnect()` to remove the overlay. For group framing, `debugZones.groupFramingPaddingBox` gives the size of the padding boundary.

**Orbits**

`orbitFollow.point(out, body.state, body, horizontal, vertical)` gives where the camera would be at any axis values, to draw with your own renderer.

</TabsContent>
<TabsContent value="three">

Expand Down Expand Up @@ -83,6 +91,20 @@ scene.add(helper);
helper.sync(securityCam.state, innerWidth / innerHeight);
```

**Orbits**

```ts
import { OrbitFollowHelperThree } from '@kvvasuu/klipp/three';

const helper = new OrbitFollowHelperThree();
scene.add(helper);

// every frame, after klipp.update
helper.sync(body);
```

`helper.colors` holds the `horizontal`, `vertical` and `rings` colors.

</TabsContent>
<TabsContent value="r3f">

Expand Down Expand Up @@ -116,5 +138,13 @@ import { CameraFrustumHelper } from '@kvvasuu/klipp/react';

`ref` gives you the `THREE.CameraHelper`.

**Orbits**

Add the `debug` prop:

```tsx
<Body.OrbitFollow target={playerRef} debug />
```

</TabsContent>
</Tabs>
2 changes: 1 addition & 1 deletion docs/getting-started/building-on-the-core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class EngineFollow extends FollowBody<EngineObject | null> {
camera.body = new EngineFollow(player, { offset: [0, 3, 8] });
```

The same works for `HardLockToTargetBody`, `PositionComposerBody`, `HardLookAtAim`, `RotateWithFollowTargetAim`, `RotationComposerAim` and `PanTiltAim`. `GroupFramingExtension` reads its members through `readMembers`.
The same works for `HardLockToTargetBody`, `OrbitFollowBody`, `PositionComposerBody`, `HardLookAtAim`, `RotateWithFollowTargetAim`, `RotationComposerAim` and `PanTiltAim`. `GroupFramingExtension` reads its members through `readMembers`.

</Steps>

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Each camera is made of up to four kinds of pieces, which run in this order every

| Piece | What it does | How many | Examples |
| ----- | ------------ | -------- | -------- |
| [Body](/body/introduction) | Moves the camera | one | `Follow`, `PositionComposer` |
| [Body](/body/introduction) | Moves the camera | one | `Follow`, `OrbitFollow`, `PositionComposer` |
| [Aim](/aim/introduction) | Rotates the camera | one | `HardLookAt`, `RotationComposer` |
| [Extension](/extension/introduction) | Adjusts the finished shot | any | `GroupFraming`, `Lens` |
| [Noise](/noise/introduction) | Adds shake on top | any | `BasicMultiChannelPerlin`, `ImpulseListener` |
Expand Down
2 changes: 1 addition & 1 deletion docs/input/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Mouse, touch, wheel and pinch input for cameras, or your own data.
nav: 60
---

Some pieces can be controlled by the user, like [`PanTilt`](/aim/pan-tilt). They expose **axes** (`pan`, `tilt`), and an [input controller](/input/input-controller) maps mouse and touch drags, the wheel and pinches onto those axes.
Some pieces can be controlled by the user, like [`PanTilt`](/aim/pan-tilt) or [`OrbitFollow`](/body/orbit-follow). They expose **axes** (`pan` and `tilt`, or `horizontal`, `vertical` and `radial`), and an [input controller](/input/input-controller) maps mouse and touch drags, the wheel and pinches onto those axes.

Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, damping and optional recentering. You can also drive an axis from your own code, for example in a cutscene, see [Input from data](/input/input-from-data).

Expand Down
8 changes: 8 additions & 0 deletions examples/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ body {
text-align: center;
}

.input-area {
position: absolute;
box-sizing: border-box;
border: 2px dashed #7ed957;
background: rgb(126 217 87 / 0.08);
pointer-events: none;
}

.pan-tilt-crosshair {
position: absolute;
top: 50%;
Expand Down
Loading
Loading