diff --git a/benches/core/body.bench.ts b/benches/core/body.bench.ts
index 0266813..a55466c 100644
--- a/benches/core/body.bench.ts
+++ b/benches/core/body.bench.ts
@@ -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';
@@ -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, {
diff --git a/docs/advanced/custom-components.mdx b/docs/advanced/custom-components.mdx
index e2956c6..2bbcda4 100644
--- a/docs/advanced/custom-components.mdx
+++ b/docs/advanced/custom-components.mdx
@@ -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)`.
diff --git a/docs/body/camera-controls.mdx b/docs/body/camera-controls.mdx
index 1335173..9fdc38d 100644
--- a/docs/body/camera-controls.mdx
+++ b/docs/body/camera-controls.mdx
@@ -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.
diff --git a/docs/body/follow.mdx b/docs/body/follow.mdx
index 543afb6..f3de3af 100644
--- a/docs/body/follow.mdx
+++ b/docs/body/follow.mdx
@@ -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` |
@@ -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.
diff --git a/docs/body/introduction.mdx b/docs/body/introduction.mdx
index 21ac584..41b5dcc 100644
--- a/docs/body/introduction.mdx
+++ b/docs/body/introduction.mdx
@@ -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. |
diff --git a/docs/body/orbit-follow.mdx b/docs/body/orbit-follow.mdx
new file mode 100644
index 0000000..f43220b
--- /dev/null
+++ b/docs/body/orbit-follow.mdx
@@ -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
+
+
+
+ Core
+ three.js
+ React Three Fiber
+
+
+
+```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;
+```
+
+
+
+
+```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.
+
+
+
+
+```tsx
+import { Aim, Body, InputController, VirtualCamera } from '@kvvasuu/klipp/react';
+
+const orbit = { axes: { x: 'horizontal', y: 'vertical' }, gain: 0.3 };
+
+
+
+
+
+
+
+```
+
+`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 };
+
+
+```
+
+
+
diff --git a/docs/body/position-composer.mdx b/docs/body/position-composer.mdx
index 8fdb902..3ad127f 100644
--- a/docs/body/position-composer.mdx
+++ b/docs/body/position-composer.mdx
@@ -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.
diff --git a/docs/debugging/introduction.mdx b/docs/debugging/introduction.mdx
index 861693b..b4163b9 100644
--- a/docs/debugging/introduction.mdx
+++ b/docs/debugging/introduction.mdx
@@ -1,6 +1,6 @@
---
title: Debugging
-description: Visualize framing zones and camera frustums.
+description: Visualize framing zones, camera frustums and orbits.
nav: 80
---
@@ -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
@@ -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.
+
@@ -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.
+
@@ -116,5 +138,13 @@ import { CameraFrustumHelper } from '@kvvasuu/klipp/react';
`ref` gives you the `THREE.CameraHelper`.
+**Orbits**
+
+Add the `debug` prop:
+
+```tsx
+
+```
+
diff --git a/docs/getting-started/building-on-the-core.mdx b/docs/getting-started/building-on-the-core.mdx
index f9d08cb..af0a64c 100644
--- a/docs/getting-started/building-on-the-core.mdx
+++ b/docs/getting-started/building-on-the-core.mdx
@@ -48,7 +48,7 @@ class EngineFollow extends FollowBody {
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`.
diff --git a/docs/getting-started/core-concepts.mdx b/docs/getting-started/core-concepts.mdx
index cf09fc3..f7f358c 100644
--- a/docs/getting-started/core-concepts.mdx
+++ b/docs/getting-started/core-concepts.mdx
@@ -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` |
diff --git a/docs/input/introduction.mdx b/docs/input/introduction.mdx
index 061d0c5..1135b3a 100644
--- a/docs/input/introduction.mdx
+++ b/docs/input/introduction.mdx
@@ -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).
diff --git a/examples/src/index.css b/examples/src/index.css
index b8b6552..c5f4ed2 100644
--- a/examples/src/index.css
+++ b/examples/src/index.css
@@ -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%;
diff --git a/examples/src/registry/examples.ts b/examples/src/registry/examples.ts
index 682d8ef..6a18c72 100644
--- a/examples/src/registry/examples.ts
+++ b/examples/src/registry/examples.ts
@@ -13,6 +13,12 @@ import { BlendHints } from '../scenes/blending/BlendHints';
import { CustomBlends } from '../scenes/blending/CustomBlends';
import { Follow } from '../scenes/body/Follow';
import { HardLockToTarget } from '../scenes/body/HardLockToTarget';
+import { OrbitFollow } from '../scenes/body/OrbitFollow';
+import { OrbitFollowBindingMode } from '../scenes/body/OrbitFollowBindingMode';
+import { OrbitFollowDampedAim } from '../scenes/body/OrbitFollowDampedAim';
+import { OrbitFollowGroupFraming } from '../scenes/body/OrbitFollowGroupFraming';
+import { OrbitFollowRecentering } from '../scenes/body/OrbitFollowRecentering';
+import { OrbitFollowThreeRing } from '../scenes/body/OrbitFollowThreeRing';
import { PositionComposer } from '../scenes/body/PositionComposer';
import { PositionComposerDolly } from '../scenes/body/PositionComposerDolly';
import { PositionComposerLookahead } from '../scenes/body/PositionComposerLookahead';
@@ -20,6 +26,9 @@ import { GroupFraming } from '../scenes/extension/GroupFraming';
import { Lens } from '../scenes/extension/Lens';
import { Impulse } from '../scenes/impulse/Impulse';
import { InputFromData } from '../scenes/input/InputFromData';
+import { InputRawInput } from '../scenes/input/InputRawInput';
+import { InputTouchAndAreas } from '../scenes/input/InputTouchAndAreas';
+import { InputWheelAndPinch } from '../scenes/input/InputWheelAndPinch';
import { BasicMultiChannelPerlin } from '../scenes/noise/BasicMultiChannelPerlin';
import { Placeholder } from '../scenes/Placeholder';
import { StandbyUpdate } from '../scenes/virtual-camera/StandbyUpdate';
@@ -50,6 +59,72 @@ export const categories: ExampleCategory[] = [
spectatorTarget: [0, 3, 0],
ready: true,
},
+ {
+ slug: 'orbit-follow',
+ title: 'OrbitFollow',
+ group: 'orbit-follow',
+ Scene: OrbitFollow,
+ description:
+ 'Drag to move the camera around the shape, scroll or pinch to zoom. The orange circle and blue arc show where dragging takes the camera. Turn on `lockPointer` and left-click to orbit with the mouse alone.',
+ spectatorPosition: [12, 10, 14],
+ spectatorTarget: [0, 1.5, 0],
+ ready: true,
+ },
+ {
+ slug: 'orbit-follow-binding-mode',
+ title: 'OrbitFollow: Binding Mode',
+ group: 'orbit-follow',
+ Scene: OrbitFollowBindingMode,
+ description:
+ '`bindingMode` decides whether the orbit turns with the plane: with `lockToTargetWithWorldUp` the camera stays behind it, with `worldSpace` it keeps its heading. With `damping` the camera lags behind the plane, but dragging around the orbit is never delayed.',
+ spectatorPosition: [0, 14, 18],
+ spectatorTarget: [0, 3, 0],
+ ready: true,
+ },
+ {
+ slug: 'orbit-follow-three-ring',
+ title: 'OrbitFollow: Three Rings',
+ group: 'orbit-follow',
+ Scene: OrbitFollowThreeRing,
+ description:
+ 'With `orbitStyle: threeRing` the camera moves on a surface through three rings instead of a sphere. Change the rings and `splineCurvature`, and drag up and down to follow the blue curve.',
+ spectatorPosition: [14, 10, 14],
+ spectatorTarget: [0, 2, 0],
+ ready: true,
+ },
+ {
+ slug: 'orbit-follow-recentering',
+ title: 'OrbitFollow: Recentering',
+ group: 'orbit-follow',
+ Scene: OrbitFollowRecentering,
+ description:
+ 'Drag and let go. After `wait` seconds the camera eases back: behind the plane with `recenteringTarget: trackingTarget`, or to the axis center with `axisCenter`.',
+ spectatorPosition: [0, 14, 18],
+ spectatorTarget: [0, 3, 0],
+ ready: true,
+ },
+ {
+ slug: 'orbit-follow-damped-aim',
+ title: 'OrbitFollow: Damped Aim',
+ group: 'orbit-follow',
+ Scene: OrbitFollowDampedAim,
+ description:
+ "The camera lags behind the plane with `damping`, and a `RotationComposer` with `aimDamping` turns toward it slowly, so the plane drifts on screen. Dragging around the orbit still keeps it in place: turns made by the Body skip the Aim's damping.",
+ spectatorPosition: [0, 14, 18],
+ spectatorTarget: [0, 3, 0],
+ ready: true,
+ },
+ {
+ slug: 'orbit-follow-group-framing',
+ title: 'OrbitFollow: Group Framing',
+ group: 'orbit-follow',
+ Scene: OrbitFollowGroupFraming,
+ description:
+ 'Orbit around a group of balls while `GroupFraming` moves the camera in and out along the view, so every ball stays in the frame from any side. With `fitMode: ceiling` it only ever moves out from the orbit, with `rigid` it also moves in.',
+ spectatorPosition: [0, 40, 50],
+ spectatorTarget: [0, 0, 0],
+ ready: true,
+ },
{
slug: 'position-composer',
title: 'PositionComposer',
@@ -197,11 +272,41 @@ export const categories: ExampleCategory[] = [
title: 'Input from data',
Scene: InputFromData,
description:
- 'One camera, three sources of input. Pick a `source` and only its settings show up. With the mouse you can record what you do, `scripted` lets code look around with `applyDelta`, and `replay` plays the recording back without any mouse.',
+ 'One camera, three sources of input. Pick a `source` and only its settings show up. With the mouse you can record what you do, `scripted` lets code orbit with `applyDelta`, and `replay` plays the recording back without any mouse.',
spectatorPosition: [8, 5, 8],
spectatorTarget: [0, 2, 0],
ready: true,
},
+ {
+ slug: 'input-wheel-and-pinch',
+ title: 'Wheel and Pinch',
+ Scene: InputWheelAndPinch,
+ description:
+ 'Scroll or pinch to zoom. The wheel moves in pixels and needs its own `gain`, while a pinch on a trackpad or a touch screen zooms exactly as much as the fingers spread. The zoom stops at `closest` and `farthest`.',
+ spectatorPosition: [12, 10, 14],
+ spectatorTarget: [0, 1.5, 0],
+ ready: true,
+ },
+ {
+ slug: 'input-touch-and-areas',
+ title: 'Touch and Areas',
+ Scene: InputTouchAndAreas,
+ description:
+ 'Input only starts inside the green `area`, so the rest of the page stays usable. Pinch to zoom and twist to turn the box: with `lockTouchAxis` a two-finger gesture does one or the other, never both, which is handy when twisting is handled by hand from `input`. Turn off `suppressContextMenu` to get the browser menu back on right-click.',
+ spectatorPosition: [12, 10, 14],
+ spectatorTarget: [0, 1.5, 0],
+ ready: true,
+ },
+ {
+ slug: 'input-raw-input',
+ title: 'Raw Input',
+ Scene: InputRawInput,
+ description:
+ 'Everything the controller read this frame, as plain numbers in `input`. Use it for gestures no axis maps: here a right-drag spins the shape.',
+ spectatorPosition: [12, 10, 14],
+ spectatorTarget: [0, 1.5, 0],
+ ready: true,
+ },
],
},
{
diff --git a/examples/src/scene/OrbitingGroup.tsx b/examples/src/scene/OrbitingGroup.tsx
new file mode 100644
index 0000000..dd2d9d6
--- /dev/null
+++ b/examples/src/scene/OrbitingGroup.tsx
@@ -0,0 +1,43 @@
+import { useFrame } from '@react-three/fiber';
+import type { RefObject } from 'react';
+import type { Group } from 'three';
+
+import { memberOrbits, memberRadius, type GroupMember } from './groupMembers';
+
+/** Balls circling the origin at different radii and speeds, with `anchorRef` kept at their average position. */
+export function OrbitingGroup({ members, anchorRef }: { members: GroupMember[]; anchorRef: RefObject }) {
+ useFrame(({ clock }) => {
+ const t = clock.elapsedTime;
+ let sumX = 0;
+ let sumY = 0;
+ let sumZ = 0;
+ let resolved = 0;
+
+ memberOrbits.forEach((orbit, i) => {
+ const mesh = members[i].target.current;
+ if (!mesh) return;
+ const angle = t * orbit.speed + orbit.phase;
+ mesh.position.set(Math.cos(angle) * orbit.radius, orbit.height, Math.sin(angle) * orbit.radius);
+ sumX += mesh.position.x;
+ sumY += mesh.position.y;
+ sumZ += mesh.position.z;
+ resolved++;
+ });
+
+ // plain mean, matching `positionMode: 'groupAverage'`, so Aim looks at the point GroupFraming frames
+ const anchor = anchorRef.current;
+ if (anchor && resolved > 0) anchor.position.set(sumX / resolved, sumY / resolved, sumZ / resolved);
+ });
+
+ return (
+ <>
+ {memberOrbits.map((orbit, i) => (
+
+
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/examples/src/scene/clutterLayouts.ts b/examples/src/scene/clutterLayouts.ts
index dee886c..1a60375 100644
--- a/examples/src/scene/clutterLayouts.ts
+++ b/examples/src/scene/clutterLayouts.ts
@@ -132,6 +132,23 @@ export const clutterLayouts = {
{ x: 3.5, z: -9, width: 1.2, height: 1.6, depth: 1.2 },
{ x: 4.5, z: -15, width: 1.4, height: 1.8, depth: 1.4, color: '#c7c7cf' },
],
+ // Only between and beyond the orbits of OrbitingGroup, which pass at radii 4, 6.5, 9 and 18.
+ groupFraming: [
+ { x: 12.9, z: 4, width: 1.4, height: 0.3, depth: 1.4 },
+ { x: 6.3, z: 11.9, width: 1.2, height: 0.2, depth: 1.2, color: '#9a9aa8' },
+ { x: -4, z: 12.9, width: 1.6, height: 0.4, depth: 1.6, color: '#c7c7cf' },
+ { x: -11.9, z: 6.3, width: 1.3, height: 0.25, depth: 1.3 },
+ { x: -12.9, z: -4, width: 1.5, height: 0.35, depth: 1.5, color: '#9a9aa8' },
+ { x: -6.3, z: -11.9, width: 1.2, height: 0.2, depth: 1.2 },
+ { x: 4, z: -12.9, width: 1.6, height: 0.3, depth: 1.6, color: '#c7c7cf' },
+ { x: 11.9, z: -6.3, width: 1.3, height: 0.45, depth: 1.3 },
+ { x: 16, z: 16.5, width: 2, height: 0.5, depth: 2, color: '#9a9aa8' },
+ { x: -6.3, z: 22.1, width: 1.8, height: 0.35, depth: 1.8 },
+ { x: -22.3, z: 5.6, width: 2.2, height: 0.6, depth: 2.2, color: '#c7c7cf' },
+ { x: -16, z: -16.5, width: 1.8, height: 0.4, depth: 1.8 },
+ { x: 6.3, z: -22.1, width: 2, height: 0.3, depth: 2, color: '#9a9aa8' },
+ { x: 22.3, z: -5.6, width: 1.9, height: 0.5, depth: 1.9 },
+ ],
} satisfies Record;
export type ClutterLayout = keyof typeof clutterLayouts;
diff --git a/examples/src/scene/groupMembers.ts b/examples/src/scene/groupMembers.ts
new file mode 100644
index 0000000..2e48efb
--- /dev/null
+++ b/examples/src/scene/groupMembers.ts
@@ -0,0 +1,17 @@
+import { createRef, type RefObject } from 'react';
+import type { Mesh } from 'three';
+
+export const memberRadius = 1;
+
+export const memberOrbits = [
+ { radius: 4, speed: 0.3, phase: 0, height: 0, color: '#21a9e0' },
+ { radius: 9, speed: 0.45, phase: 1.3, height: 1.2, color: '#ff6b4a' },
+ { radius: 6.5, speed: -0.35, phase: 2.6, height: -0.8, color: '#7ed957' },
+ { radius: 18, speed: 0.22, phase: 4.2, height: 0.6, color: '#ffd23f' },
+] as const;
+
+export type GroupMember = { target: RefObject; radius: number };
+
+/** One member per orbit of `OrbitingGroup`. */
+export const createGroupMembers = (): GroupMember[] =>
+ memberOrbits.map(() => ({ target: createRef(), radius: memberRadius }));
diff --git a/examples/src/scenes/body/Follow.tsx b/examples/src/scenes/body/Follow.tsx
index 1c70685..71b4d8d 100644
--- a/examples/src/scenes/body/Follow.tsx
+++ b/examples/src/scenes/body/Follow.tsx
@@ -11,9 +11,10 @@ import { SpectatorFrustum } from '../../scene/SpectatorFrustum';
export function Follow() {
const planeRef = useRef(null);
- const { offset, damping, bindingMode } = useControls('Follow', {
+ const { offset, damping, rotationDamping, bindingMode } = useControls('Follow', {
offset: { x: 0, y: 0.5, z: 5 },
damping: { value: 0, min: 0, max: 2, step: 0.05 },
+ rotationDamping: { value: 0, min: 0, max: 2, step: 0.05 },
bindingMode: { value: BindingModes.lockToTarget as BindingMode, options: Object.values(BindingModes) },
});
@@ -28,6 +29,7 @@ export function Follow() {
target={planeRef}
offset={[offset.x, offset.y, offset.z]}
damping={damping}
+ rotationDamping={rotationDamping}
bindingMode={bindingMode}
/>
diff --git a/examples/src/scenes/body/OrbitFollow.tsx b/examples/src/scenes/body/OrbitFollow.tsx
new file mode 100644
index 0000000..12e06aa
--- /dev/null
+++ b/examples/src/scenes/body/OrbitFollow.tsx
@@ -0,0 +1,76 @@
+import type { InputControllerDom } from '@kvvasuu/klipp/dom';
+import { Aim, Body, Klipp, VirtualCamera } from '@kvvasuu/klipp/react';
+import type { OrbitFollowBodyThree } from '@kvvasuu/klipp/three';
+import { useFrame } from '@react-three/fiber';
+import { useControls } from 'leva';
+import { useRef } from 'react';
+import type { Mesh } from 'three';
+
+import { CanvasOverlay } from '../../scene/CanvasOverlay';
+import { Crosshair } from '../../scene/Crosshair';
+import { GroundClutter } from '../../scene/GroundClutter';
+import { SpectatorFrustum } from '../../scene/SpectatorFrustum';
+import { SpinningSubject } from '../../scene/SpinningSubject';
+import { usePointerLock } from '../../scene/usePointerLock';
+
+import { OrbitInput, orbitHint } from './OrbitInput';
+
+export function OrbitFollow() {
+ const subjectRef = useRef(null);
+ const bodyRef = useRef(null);
+ const controllerRef = useRef(null);
+ const valuesRef = useRef(null);
+
+ const { radius, axisDamping, zoomDamping, autoNormalize, debug, lockPointer } = useControls('OrbitFollow', {
+ radius: { value: 6, min: 1, max: 20, step: 0.5 },
+ axisDamping: { value: 0.1, min: 0, max: 1, step: 0.05 },
+ zoomDamping: { value: 0.15, min: 0, max: 1, step: 0.05 },
+ autoNormalize: true,
+ debug: true,
+ lockPointer: false,
+ });
+
+ const locked = usePointerLock(controllerRef, lockPointer);
+
+ useFrame(() => {
+ const body = bodyRef.current;
+ if (!valuesRef.current || !body) return;
+ const { horizontal, vertical, radial } = body;
+ valuesRef.current.textContent =
+ `horizontal: ${horizontal.value.toFixed(1)}° vertical: ${vertical.value.toFixed(1)}° ` +
+ `zoom: ${Math.exp(radial.value).toFixed(2)}`;
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {locked && }
+