Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
5500624
cmd key and shift key
Mar 13, 2026
7502ed6
alt
Mar 13, 2026
df67ac6
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js
Mar 13, 2026
e8c823d
merge
Mar 23, 2026
00f14d6
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js
Mar 24, 2026
2bfee09
input plan
Mar 25, 2026
81fe852
architecture plan and adopting to geocam
Mar 25, 2026
885289e
introduce arcrotate and simplify handlers api
Mar 25, 2026
744b7f1
nonpartial
Mar 25, 2026
055df6f
remove partial
Mar 25, 2026
3711be5
update doc
Mar 25, 2026
caf208a
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
Mar 25, 2026
078af0e
Task 01: Add resetInputMap() to CameraMovement base class and subclasses
Mar 31, 2026
4016fa6
Task 02: Add useMovementSystem flag to ArcRotateCamera and wire compu…
Mar 31, 2026
d5cb4fe
Task 03: Map legacy deprecated flags to inputMap modifications
Mar 31, 2026
9903026
Task 04: Add unit tests for CameraMovement base class and ArcRotateCa…
Mar 31, 2026
e32e184
merge
Apr 13, 2026
c4e41e7
add shift, separate modifiers
Apr 13, 2026
85a06f0
allow for configurable modifiers
Apr 13, 2026
cee59d0
negation confusion
Apr 13, 2026
261412f
rotate direction changes
Apr 13, 2026
b65423d
continue iterating and making improvements to api
Apr 13, 2026
2196e8c
applypandelta extracted
Apr 13, 2026
e48a14d
remove more duplication
Apr 13, 2026
4090e2e
speed calibration
Apr 14, 2026
62a6639
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
Apr 15, 2026
85ad317
inputmapper
Apr 16, 2026
ff8eb61
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
Apr 17, 2026
c129870
matching behavior 60fps
Apr 17, 2026
b8fe7d9
commit
Apr 23, 2026
6061bd8
merge
Apr 24, 2026
53888a3
latest
Apr 24, 2026
26885b3
Remove dev artifacts and revert package-lock.json
Apr 24, 2026
b5418ef
Remove specs/inputSystem dev notes
Apr 24, 2026
75918bd
Code review fixes (automated by code-review skill)
Apr 27, 2026
b59a8e1
Address PR review comments
Apr 27, 2026
31825cf
Fix tsc build: narrow getEntry/resolveInteraction return type by source
Apr 28, 2026
9480522
retrigger CI to verify viewer test stability
Apr 28, 2026
e240684
Add InputMapper.setInteraction() for targeted input remapping
Apr 28, 2026
474948e
Remove arcRotateCamera interaction tests and revert playwright config
Apr 28, 2026
1a023ef
Address PR review feedback from Kevin
Apr 29, 2026
4555093
Merge branch 'upstream-master' into inputMapperBackCompat
Apr 29, 2026
498cd6f
Improve inertial offset docstrings with decay coefficient explanation
Apr 29, 2026
f4f1ca9
Merge branch 'master' into inputMapperBackCompat
Apr 29, 2026
ee15bcb
rename
May 4, 2026
b0b00f2
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
May 4, 2026
922aef5
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
May 6, 2026
f6bee9d
optimization
May 6, 2026
e2a5540
obj allocation and getentries
May 6, 2026
5dce2e5
Address PR review comments from RaananW
May 12, 2026
bba5493
Merge branch 'master' of https://github.com/BabylonJS/Babylon.js into…
May 12, 2026
7fc2ad1
Fix TypeDoc errors flagged by tightened CI check (#18428)
May 12, 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
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,16 @@ export class ArcRotateCameraGamepadInput implements ICameraInput<ArcRotateCamera
if (rsValues.x != 0) {
const normalizedRX = rsValues.x / this.gamepadRotationSensibility;
if (normalizedRX != 0 && Math.abs(normalizedRX) > 0.005) {
camera.inertialAlphaOffset += normalizedRX;
camera.movement.activeInput = true;
camera.movement.rotationAccumulatedPixels.x += normalizedRX;
}
}

if (rsValues.y != 0) {
const normalizedRY = (rsValues.y / this.gamepadRotationSensibility) * this._yAxisScale;
if (normalizedRY != 0 && Math.abs(normalizedRY) > 0.005) {
camera.inertialBetaOffset += normalizedRY;
camera.movement.activeInput = true;
camera.movement.rotationAccumulatedPixels.y += normalizedRY;
}
}
}
Expand All @@ -114,7 +116,8 @@ export class ArcRotateCameraGamepadInput implements ICameraInput<ArcRotateCamera
if (lsValues && lsValues.y != 0) {
const normalizedLY = lsValues.y / this.gamepadMoveSensibility;
if (normalizedLY != 0 && Math.abs(normalizedLY) > 0.005) {
this.camera.inertialRadiusOffset -= normalizedLY;
camera.movement.activeInput = true;
camera.movement.zoomAccumulatedPixels -= normalizedLY;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type ICameraInput, CameraInputTypes } from "../../Cameras/cameraInputsM
import { type KeyboardInfo, KeyboardEventTypes } from "../../Events/keyboardEvents";
import { Tools } from "../../Misc/tools";
import { type AbstractEngine } from "../../Engines/abstractEngine";
import { type KeyboardConditions } from "../cameraInteractions";

/**
* Manage the keyboard inputs to control the movement of an arc rotate camera.
Expand Down Expand Up @@ -49,6 +50,20 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
@serialize()
public keysReset = [220];

/**
* Defines the list of key codes associated with the zoom in action.
* Only used when CameraMovement is active — these keys always trigger zoom regardless of modifiers.
*/
@serialize()
public keysZoomIn: number[] = [187, 107]; // 187 = +/= key, 107 = numpad +

/**
* Defines the list of key codes associated with the zoom out action.
* Only used when CameraMovement is active — these keys always trigger zoom regardless of modifiers.
*/
@serialize()
public keysZoomOut: number[] = [189, 109]; // 189 = -/_ key, 109 = numpad -

/**
* Defines the panning sensibility of the inputs.
* (How fast is the camera panning)
Expand All @@ -64,17 +79,35 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
public zoomingSensibility: number = 25.0;

/**
* Defines whether maintaining the alt key down switch the movement mode from
* orientation to zoom.
* Rotation speed of the camera
*/
@serialize()
public useAltToZoom: boolean = true;
public angularSpeed = 0.01;

private _useAltToZoom: boolean = true;

/**
* Rotation speed of the camera
* Defines whether alt+arrows/wasd triggers zoom instead of rotation/pan.
* When disabled, alt+keyboard events are ignored by the zoom inputMap entry.
* Setting this updates the corresponding inputMap entry on the camera's movement system.
*/
@serialize()
public angularSpeed = 0.01;
public get useAltToZoom(): boolean {
return this._useAltToZoom;
}

public set useAltToZoom(value: boolean) {
this._useAltToZoom = value;
if (this.camera?.movement) {
const inputMap = this.camera.movement.input.inputMap;
const idx = inputMap.findIndex((e) => e.source === "keyboard" && "modifiers" in e && e.modifiers?.alt === true && e.interaction === "zoom");
if (!value && idx !== -1) {
inputMap.splice(idx, 1);
} else if (value && idx === -1) {
this.camera.movement.input.addEntry({ source: "keyboard", modifiers: { alt: true }, interaction: "zoom" });
}
}
}

private _keys = new Array<number>();
private _ctrlPressed: boolean;
Expand All @@ -84,6 +117,9 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
private _engine: AbstractEngine;
private _scene: Scene;

/** Cached conditions object to avoid per-frame allocations in checkInputs */
private _keyboardConditions: KeyboardConditions = { modifiers: { ctrl: false, alt: false } };

/**
* Attach the input controls to a specific dom element to get the input from.
* @param noPreventDefault Defines whether event caught by the controls should call preventdefault() (https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault)
Expand Down Expand Up @@ -115,7 +151,9 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
this.keysDown.indexOf(evt.keyCode) !== -1 ||
this.keysLeft.indexOf(evt.keyCode) !== -1 ||
this.keysRight.indexOf(evt.keyCode) !== -1 ||
this.keysReset.indexOf(evt.keyCode) !== -1
this.keysReset.indexOf(evt.keyCode) !== -1 ||
this.keysZoomIn.indexOf(evt.keyCode) !== -1 ||
this.keysZoomOut.indexOf(evt.keyCode) !== -1
) {
const index = this._keys.indexOf(evt.keyCode);

Expand All @@ -135,7 +173,9 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
this.keysDown.indexOf(evt.keyCode) !== -1 ||
this.keysLeft.indexOf(evt.keyCode) !== -1 ||
this.keysRight.indexOf(evt.keyCode) !== -1 ||
this.keysReset.indexOf(evt.keyCode) !== -1
this.keysReset.indexOf(evt.keyCode) !== -1 ||
this.keysZoomIn.indexOf(evt.keyCode) !== -1 ||
this.keysZoomOut.indexOf(evt.keyCode) !== -1
) {
const index = this._keys.indexOf(evt.keyCode);

Expand Down Expand Up @@ -179,38 +219,60 @@ export class ArcRotateCameraKeyboardMoveInput implements ICameraInput<ArcRotateC
public checkInputs(): void {
if (this._onKeyboardObserver) {
const camera = this.camera;
const input = camera.movement.input;

this._keyboardConditions.modifiers!.ctrl = this._ctrlPressed;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be great if we could avoid this non-null assertion operator, maybe store the modifiers object seperately?

this._keyboardConditions.modifiers!.alt = this._altPressed;

for (let index = 0; index < this._keys.length; index++) {
const keyCode = this._keys[index];
if (this.keysLeft.indexOf(keyCode) !== -1) {
if (this._ctrlPressed && this.camera._useCtrlForPanning) {
camera.inertialPanningX -= 1 / this.panningSensibility;
} else {
camera.inertialAlphaOffset -= this.angularSpeed;
}
} else if (this.keysUp.indexOf(keyCode) !== -1) {
if (this._ctrlPressed && this.camera._useCtrlForPanning) {
camera.inertialPanningY += 1 / this.panningSensibility;
} else if (this._altPressed && this.useAltToZoom) {
camera.inertialRadiusOffset += 1 / this.zoomingSensibility;
} else {
camera.inertialBetaOffset -= this.angularSpeed;
}
} else if (this.keysRight.indexOf(keyCode) !== -1) {
if (this._ctrlPressed && this.camera._useCtrlForPanning) {
camera.inertialPanningX += 1 / this.panningSensibility;
} else {
camera.inertialAlphaOffset += this.angularSpeed;
}
} else if (this.keysDown.indexOf(keyCode) !== -1) {
if (this._ctrlPressed && this.camera._useCtrlForPanning) {
camera.inertialPanningY -= 1 / this.panningSensibility;
} else if (this._altPressed && this.useAltToZoom) {
camera.inertialRadiusOffset -= 1 / this.zoomingSensibility;
} else {
camera.inertialBetaOffset += this.angularSpeed;

this._keyboardConditions.key = keyCode;

// Skip resolveInteraction for the reset key — it has no inputMap entry of its own
// and would otherwise spuriously match the catch-all keyboard→rotate entry.
if (this.keysReset.indexOf(keyCode) === -1) {
const resolved = input.resolveInteraction("keyboard", this._keyboardConditions);

if (resolved) {
// Per-frame impulse magnitude. The inputMap entry's `sensitivity` takes precedence
// when set so consumers can tune feel declaratively (and so we can phase out the
// legacy sensibility/angularSpeed properties over time). When `sensitivity` is
// undefined, fall back to the legacy properties for backward compatibility.
if (resolved.interaction === "pan") {
const panSens = resolved.sensitivity ?? 1 / this.panningSensibility;
if (this.keysLeft.indexOf(keyCode) !== -1) {
input.handlers.pan(-panSens, 0);
} else if (this.keysRight.indexOf(keyCode) !== -1) {
input.handlers.pan(panSens, 0);
} else if (this.keysUp.indexOf(keyCode) !== -1) {
input.handlers.pan(0, panSens);
} else if (this.keysDown.indexOf(keyCode) !== -1) {
input.handlers.pan(0, -panSens);
}
} else if (resolved.interaction === "zoom") {
const zoomSens = resolved.sensitivity ?? 1 / this.zoomingSensibility;
if (this.keysUp.indexOf(keyCode) !== -1 || this.keysZoomIn.indexOf(keyCode) !== -1) {
input.handlers.zoom(zoomSens);
} else if (this.keysDown.indexOf(keyCode) !== -1 || this.keysZoomOut.indexOf(keyCode) !== -1) {
input.handlers.zoom(-zoomSens);
}
} else if (resolved.interaction === "rotate") {
const rotateSens = resolved.sensitivity ?? this.angularSpeed;
if (this.keysLeft.indexOf(keyCode) !== -1) {
input.handlers.rotate(-rotateSens, 0);
} else if (this.keysRight.indexOf(keyCode) !== -1) {
input.handlers.rotate(rotateSens, 0);
} else if (this.keysUp.indexOf(keyCode) !== -1) {
input.handlers.rotate(0, -rotateSens);
} else if (this.keysDown.indexOf(keyCode) !== -1) {
input.handlers.rotate(0, rotateSens);
}
}
}
} else if (this.keysReset.indexOf(keyCode) !== -1) {
}

if (this.keysReset.indexOf(keyCode) !== -1) {
if (camera.useInputToRestoreState) {
camera.restoreState();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam
// If zooming in, estimate the target radius and use that to compute the delta for inertia
// this will stop multiple scroll events zooming in from adding too much inertia
if (delta > 0) {
// When zoomToMouseLocation is active, the accumulating state lives in our private
// `_zoomToMouseRadiusImpulse` field. Otherwise fall back to the legacy surface.
const currentAccumulated = this.zoomToMouseLocation ? this._zoomToMouseRadiusImpulse : this.camera.inertialRadiusOffset;
let estimatedTargetRadius = this.camera.radius;
let targetInertia = this.camera.inertialRadiusOffset + delta;
let targetInertia = currentAccumulated + delta;
for (let i = 0; i < 20; i++) {
// 20 iterations should be enough to converge
if (estimatedTargetRadius <= targetInertia) {
Expand All @@ -117,7 +120,11 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam
delta = this._computeDeltaFromMouseWheelLegacyEvent(wheelDelta, estimatedTargetRadius);
}
} else {
delta = wheelDelta / (this.wheelPrecision * 40);
// The inputMap entry's `sensitivity` takes precedence so consumers can tune
// wheel zoom feel declaratively; fall back to the legacy `wheelPrecision`.
const wheelEntry = this.camera.movement.input.getEntry("wheel", "zoom");
const wheelScale = wheelEntry?.sensitivity ?? 1 / (this.wheelPrecision * 40);
delta = wheelDelta * wheelScale;
}
}

Expand All @@ -132,10 +139,9 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam

this._zoomToMouse(delta);
} else {
this.camera.inertialRadiusOffset += delta;
this.camera.movement.zoomAccumulatedPixels += delta;
}
}

if (event.preventDefault) {
if (!noPreventDefault) {
event.preventDefault();
Expand Down Expand Up @@ -171,18 +177,38 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam
}

const camera = this.camera;
const motion = 0.0 + camera.inertialAlphaOffset + camera.inertialBetaOffset + camera.inertialRadiusOffset;
// Motion check based on our private impulse state + the legacy inertialRadiusOffset
// surface (so user code that still writes to it continues to animate). Legacy also
// included alpha/beta offsets in the motion check — keep that for compatibility so
// the hit plane stays updated while the camera rotates.
const motion =
Math.abs(this._zoomToMouseRadiusImpulse) +
this._inertialPanning.lengthSquared() +
Math.abs(camera.inertialAlphaOffset) +
Math.abs(camera.inertialBetaOffset) +
Math.abs(camera.inertialRadiusOffset);
if (motion) {
// if zooming is still happening as a result of inertia, then we also need to update
// the hit plane.
this._updateHitPlane();

// Note we cannot use arcRotateCamera.inertialPlanning here because arcRotateCamera panning
// uses a different panningInertia which could cause this panning to get out of sync with
// the zooming, and for this to work they must be exactly in sync.
// Apply this frame's coupled radius + pan impulse to the camera. They must stay
// in lockstep so the cursor remains at the zoom focal point — keep pan here instead
// of routing through `arcRotateCamera.inertialPanning` which uses a different
// `panningInertia`.
camera.target.addInPlace(this._inertialPanning);
this._inertialPanning.scaleInPlace(camera.inertia);
camera.radius -= this._zoomToMouseRadiusImpulse;

// Framerate-independent decay — matches legacy `* camera.inertia` exactly at 60fps
// and preserves glide duration at any other fps.
const decay = camera.movement.getFrameIndependentDecay(camera.inertia);
this._inertialPanning.scaleInPlace(decay);
this._zoomToMouseRadiusImpulse *= decay;

this._zeroIfClose(this._inertialPanning);
if (Math.abs(this._zoomToMouseRadiusImpulse) < Epsilon) {
this._zoomToMouseRadiusImpulse = 0;
}
}
}

Expand Down Expand Up @@ -237,19 +263,30 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam

private _inertialPanning: Vector3 = Vector3.Zero();

/**
* Private impulse state for the zoomToMouseLocation path. Replaces the legacy use of
* `camera.inertialRadiusOffset` so that the coupled radius/pan impulses can be decayed
* with framerate-independent semantics (via `CameraMovement.getFrameIndependentDecay`)
* without disturbing the general inertialRadiusOffset back-compat surface.
*/
private _zoomToMouseRadiusImpulse: number = 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term impulse is confusing to me here, isn't this just an accumulation of delta? I think of impulse as the rate of change of acceleration, but that's not what this is right? Maybe more detail in the docstring would be helpful, particularly about the units of this value.


private _zoomToMouse(delta: number) {
const camera = this.camera;
const inertiaComp = 1 - camera.inertia;
// inputScale corrects per-frame impulse so that the decayed sum matches the legacy 60fps total
// at any actual framerate. At 60fps inputScale = 1 (no-op).
const inputScale = camera.movement.getFrameIndependentInputScale(camera.inertia);
if (camera.lowerRadiusLimit) {
const lowerLimit = camera.lowerRadiusLimit ?? 0;
if (camera.radius - (camera.inertialRadiusOffset + delta) / inertiaComp < lowerLimit) {
delta = (camera.radius - lowerLimit) * inertiaComp - camera.inertialRadiusOffset;
if (camera.radius - (this._zoomToMouseRadiusImpulse + delta * inputScale) / inertiaComp < lowerLimit) {
delta = ((camera.radius - lowerLimit) * inertiaComp - this._zoomToMouseRadiusImpulse) / inputScale;
}
}
if (camera.upperRadiusLimit) {
const upperLimit = camera.upperRadiusLimit ?? 0;
if (camera.radius - (camera.inertialRadiusOffset + delta) / inertiaComp > upperLimit) {
delta = (camera.radius - upperLimit) * inertiaComp - camera.inertialRadiusOffset;
if (camera.radius - (this._zoomToMouseRadiusImpulse + delta * inputScale) / inertiaComp > upperLimit) {
delta = ((camera.radius - upperLimit) * inertiaComp - this._zoomToMouseRadiusImpulse) / inputScale;
}
}

Expand All @@ -264,9 +301,11 @@ export class ArcRotateCameraMouseWheelInput implements ICameraInput<ArcRotateCam
vec.subtractToRef(camera.target, directionToZoomLocation);
directionToZoomLocation.scaleInPlace(ratio);
directionToZoomLocation.scaleInPlace(inertiaComp);

directionToZoomLocation.scaleInPlace(inputScale);
this._inertialPanning.addInPlace(directionToZoomLocation);

camera.inertialRadiusOffset += delta;
this._zoomToMouseRadiusImpulse += delta * inputScale;
}

// Sets x y or z of passed in vector to zero if less than Epsilon.
Expand Down
Loading
Loading