Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1351262
feat: add InputAxis.setValue
kvvasuu Oct 7, 2026
cd2d66a
refactor: move InputAxisOwner type to core
kvvasuu Oct 7, 2026
7d963d4
refactor!: name input classes by layer (InputController, InputControl…
kvvasuu Oct 7, 2026
d2b42e6
feat: expose a virtual camera's input axes
kvvasuu Oct 7, 2026
9157a26
feat: let InputController drive the camera's own axes
kvvasuu Oct 7, 2026
b23086f
feat: add buttonInput source state
kvvasuu Oct 7, 2026
eb90954
feat: add suppressNativeGestures with style restore
kvvasuu Oct 7, 2026
d7b8c28
feat: add wheel source with deltaMode normalization
kvvasuu Oct 7, 2026
be408c9
feat: add mouse source
kvvasuu Oct 7, 2026
892b346
refactor: read mouse and wheel through sources in InputSystem
kvvasuu Oct 7, 2026
d7cdb7c
feat: add touch source
kvvasuu Oct 7, 2026
c691f25
refactor: read touch through a source in InputSystem
kvvasuu Oct 7, 2026
c24b650
feat: add Safari gesture source
kvvasuu Oct 7, 2026
7def3ae
refactor: make InputSystem a facade over the input sources
kvvasuu Oct 7, 2026
bd0bbc3
fix: wake demand rendering from every input source
kvvasuu Oct 7, 2026
fe3d6fd
feat: map wheel and pinch onto single axes, ignore iOS touch pinches …
kvvasuu Oct 7, 2026
108a523
docs: add input from data page
kvvasuu Oct 7, 2026
0faec86
docs: add input from data example
kvvasuu Oct 7, 2026
0b1e638
refactor: tidy input sources and docs after review
kvvasuu Oct 7, 2026
8b3a9fe
fix: leave page scroll and zoom alone unless wheel or trackpad pinch …
kvvasuu Oct 7, 2026
d1f95c9
refactor: move input sources into their own folder
kvvasuu Oct 7, 2026
c3425ff
fix: skip clearing empty button sets, which allocated every frame
kvvasuu Oct 7, 2026
d8563c5
test: rewrite input benches around a fake DOM
kvvasuu Oct 7, 2026
61bea99
test: warm up every bench before labs decides on batching
kvvasuu Oct 7, 2026
cd82622
feat: let input configs list only the sources they use
kvvasuu Oct 7, 2026
503bac6
feat!: merge touch and trackpad pinch into one pinch source
kvvasuu Oct 7, 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
54 changes: 52 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"camera-controls",
"**/three/**",
"**/react/**",
"**/dom/**"
"**/dom/**",
"**/input-sources/**"
],
"message": "core imports only math."
}
Expand All @@ -50,14 +51,38 @@
{
"patterns": [
{
"group": ["react", "react/*", "react-dom", "@react-three/*", "**/react/**", "**/dom/**"],
"group": [
"react",
"react/*",
"react-dom",
"@react-three/*",
"**/react/**",
"**/dom/**",
"**/input-sources/**"
],
"message": "three must not depend on React or the DOM layer."
}
]
}
]
}
},
{
"files": ["src/react/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**/input-sources/**"],
"message": "React reads input through the dom layer."
}
]
}
]
}
},
{
"files": ["src/dom/**"],
"rules": {
Expand All @@ -83,6 +108,31 @@
]
}
},
{
"files": ["src/input-sources/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"../**",
"three",
"three/*",
"react",
"react/*",
"react-dom",
"@react-three/*",
"camera-controls"
],
"message": "input-sources import only each other and math."
}
]
}
]
}
},
{
"files": ["test/**"],
"rules": {
Expand Down
7 changes: 5 additions & 2 deletions benches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ The folders follow `src`:

- `core/`: one piece or core step at a time (`@body`, `@aim`, `@noise`, `@impulse`, `@controller`, `@core`, `@blend`)
- `three/`: target reads through the registry (`@targets`)
- `dom/`: input event handlers (`@input`)
- `input-sources/`: one input source at a time, an event and the frame after it (`@input`)
- `dom/`: a whole `InputControllerDom` frame, from events to axes (`@input`)
- `scenes/`: whole frames of `KlippThree` with 1 to 50 cameras and a blend (`@scene`)

The scene benches take long enough per iteration to stay above the noise of a busy machine. Use them to spot a regression, then the smaller benches to find where it comes from.
Expand All @@ -33,4 +34,6 @@ Results are saved to `.labs/` (gitignored). A comparison only reports a change w

## Writing a bench

Code before `yield` is setup, and the yielded function is measured. Return a value from the measured function that depends on the work, so it isn't optimized away. Shared moving targets live in `targets.ts`.
Code before `yield` is setup, and the yielded function is measured. Return a value from the measured function that depends on the work, so it isn't optimized away. Shared moving targets live in `targets.ts`, and a fake DOM for input benches in `fakeDom.ts`.

labs times the first calls to decide whether to batch. When they run through a lot of cold code, it times every call alone and mostly measures its own timer, which shows up as microseconds and kilobytes for sub-microsecond work. Wrap such a function in `warm` from `warm.ts` before yielding it.
17 changes: 9 additions & 8 deletions benches/core/aim.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ import { HardLookAtAimThree } from '../../src/three/aim/HardLookAtAimThree';
import { RotationComposerAimThree } from '../../src/three/aim/RotationComposerAimThree';

import { makeMovingTarget } from '../targets';
import { warm } from '../warm';

group('Aim.update @aim', () => {
bench('HardLookAt', function* () {
const { object, step } = makeMovingTarget();
const aim = new HardLookAtAimThree(object);
const out = cameraState.create();
vec3.set(out.position, 0, 2, 15);
yield () => {
yield warm(() => {
step();
aim.update(out);
return out.quaternion[0];
};
});
});

bench('RotationComposer (deadZone + hardLimit)', function* () {
Expand All @@ -33,11 +34,11 @@ group('Aim.update @aim', () => {
});
const out = cameraState.create();
vec3.set(out.position, 0, 2, 15);
yield () => {
yield warm(() => {
step();
aim.update(out, 0.016, false);
return out.quaternion[0];
};
});
});

bench('RotationComposer (deadZone + hardLimit + lookahead)', function* () {
Expand All @@ -54,11 +55,11 @@ group('Aim.update @aim', () => {
});
const out = cameraState.create();
vec3.set(out.position, 0, 2, 15);
yield () => {
yield warm(() => {
step();
aim.update(out, 0.016, false);
return out.quaternion[0];
};
});
});

// Where a live camera spends most frames: both dampers return early.
Expand All @@ -72,9 +73,9 @@ group('Aim.update @aim', () => {
const out = cameraState.create();
vec3.set(out.position, 0, 2, 15);
aim.update(out, 0.016, true);
yield () => {
yield warm(() => {
aim.update(out, 0.016, false);
return out.quaternion[0];
};
});
});
});
8 changes: 5 additions & 3 deletions benches/core/blend.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Matrix4, Quaternion, Vector3 } from 'three';
import * as cameraState from '../../src/core/CameraState';
import { BlendHints } from '../../src/core/blend/BlendHints';

import { warm } from '../warm';

/** Runs every frame of a blend, and never once it settles. */
group('cameraState.lerp @blend', () => {
function makeOrbitingState(position: Vector3, lookAtTarget: Vector3) {
Expand All @@ -27,20 +29,20 @@ group('cameraState.lerp @blend', () => {
vec3.set(b.position, 0, 0, 5);
new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2).toArray(b.quaternion);
const out = cameraState.create();
yield () => cameraState.lerp(out, a, b, 0.5).position[0];
yield warm(() => cameraState.lerp(out, a, b, 0.5).position[0]);
});

bench('lookAtTarget-driven rotation', function* () {
const a = makeOrbitingState(new Vector3(5, 5, 5), new Vector3(0, 0, 0));
const b = makeOrbitingState(new Vector3(0, 0, 5), new Vector3(0, 0, 0));
const out = cameraState.create();
yield () => cameraState.lerp(out, a, b, 0.5).position[0];
yield warm(() => cameraState.lerp(out, a, b, 0.5).position[0]);
});

bench('lookAtTarget-driven rotation + sphericalPosition hint', function* () {
const a = makeOrbitingState(new Vector3(5, 5, 5), new Vector3(0, 0, 0));
const b = makeOrbitingState(new Vector3(0, 0, 5), new Vector3(0, 0, 0));
const out = cameraState.create();
yield () => cameraState.lerp(out, a, b, 0.5, BlendHints.sphericalPosition).position[0];
yield warm(() => cameraState.lerp(out, a, b, 0.5, BlendHints.sphericalPosition).position[0]);
});
});
29 changes: 15 additions & 14 deletions benches/core/body.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,29 @@ import { HardLockToTargetBodyThree } from '../../src/three/body/HardLockToTarget
import { PositionComposerBodyThree } from '../../src/three/body/PositionComposerBodyThree';

import { makeMovingMeshTarget, makeMovingTarget } from '../targets';
import { warm } from '../warm';

group('Body.update @body', () => {
bench('HardLockToTarget', function* () {
const { object, step } = makeMovingTarget();
const body = new HardLockToTargetBodyThree(object, { damping: 0.5 });
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('Follow (lockToTarget binding)', function* () {
const { object, step } = makeMovingTarget();
const body = new FollowBodyThree(object, { offset: [0, 3, 8], damping: 0.5 });
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('PositionComposer (deadZone + hardLimit)', function* () {
Expand All @@ -42,11 +43,11 @@ group('Body.update @body', () => {
hardLimit: [0.4, 0.4],
});
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('PositionComposer (deadZone + hardLimit + lookahead)', function* () {
Expand All @@ -63,11 +64,11 @@ group('Body.update @body', () => {
lookaheadSmoothing: 1,
});
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('PositionComposer (radius extent)', function* () {
Expand All @@ -82,11 +83,11 @@ group('Body.update @body', () => {
radius: 1.5,
});
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('PositionComposer (explicit size extent, rotating box)', function* () {
Expand All @@ -101,11 +102,11 @@ group('Body.update @body', () => {
size: [2, 2, 2],
});
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});

bench('PositionComposer (auto-detected Mesh size extent, rotating box)', function* () {
Expand All @@ -119,10 +120,10 @@ group('Body.update @body', () => {
hardLimit: [0.4, 0.4],
});
const out = cameraState.create();
yield () => {
yield warm(() => {
step();
body.update(out, 0.016, false);
return out.position[0];
};
});
});
});
26 changes: 14 additions & 12 deletions benches/core/impulse.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { ImpulseField } from '../../src/core/impulse/ImpulseField';
import { ImpulseListenerNoise } from '../../src/core/impulse/ImpulseListenerNoise';
import { BasicMultiChannelPerlinNoise } from '../../src/core/noise/BasicMultiChannelPerlinNoise';

import { warm } from '../warm';

const always = () => 1;

group('ImpulseField.sampleAt @impulse', () => {
Expand All @@ -22,35 +24,35 @@ group('ImpulseField.sampleAt @impulse', () => {
const out: Vec3 = [0, 0, 0];
const samplePosition: Vec3 = [0, 0, 0];
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
field.sampleAt(out, samplePosition, 1, 1, now);
return out[0];
};
});
});

bench('10 concurrent events', function* () {
const field = makeFieldWithEvents(10);
const out: Vec3 = [0, 0, 0];
const samplePosition: Vec3 = [0, 0, 0];
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
field.sampleAt(out, samplePosition, 1, 1, now);
return out[0];
};
});
});

bench('50 concurrent events', function* () {
const field = makeFieldWithEvents(50);
const out: Vec3 = [0, 0, 0];
const samplePosition: Vec3 = [0, 0, 0];
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
field.sampleAt(out, samplePosition, 1, 1, now);
return out[0];
};
});
});
});

Expand All @@ -61,11 +63,11 @@ group('ImpulseListenerNoise.update @impulse', () => {
const listener = new ImpulseListenerNoise({ field });
const out = cameraState.create();
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
listener.update(out, 0.016, false, now);
return out.position[0];
};
});
});

bench('kick + shake', function* () {
Expand All @@ -78,11 +80,11 @@ group('ImpulseListenerNoise.update @impulse', () => {
const listener = new ImpulseListenerNoise({ field, channelMask: 1, gain: 1, shake });
const out = cameraState.create();
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
listener.update(out, 0.016, false, now);
return out.position[0];
};
});
});

bench('kick + cameraSpace', function* () {
Expand All @@ -91,10 +93,10 @@ group('ImpulseListenerNoise.update @impulse', () => {
const listener = new ImpulseListenerNoise({ field, channelMask: 1, gain: 1, cameraSpace: true });
const out = cameraState.create();
let now = 0;
yield () => {
yield warm(() => {
now += 0.016;
listener.update(out, 0.016, false, now);
return out.position[0];
};
});
});
});
Loading
Loading