Skip to content

Camera input mapping system with backward-compatible legacy flag support#18379

Open
georginahalpern wants to merge 52 commits into
BabylonJS:masterfrom
georginahalpern:inputMapperBackCompat
Open

Camera input mapping system with backward-compatible legacy flag support#18379
georginahalpern wants to merge 52 commits into
BabylonJS:masterfrom
georginahalpern:inputMapperBackCompat

Conversation

@georginahalpern
Copy link
Copy Markdown
Contributor

@georginahalpern georginahalpern commented Apr 24, 2026

🤖 This PR was created by the create-pr skill.

Summary

Replaces the scattered, ad-hoc input handling on ArcRotateCamera and GeospatialCamera with a declarative two-layer input mapping system, while preserving the behavior of all existing legacy boolean flags.

Motivation

Camera input configuration today is spread across ad-hoc boolean flags (useCtrlForPanning, multiTouchPanAndZoom, panningMouseButton, useAltToZoom, …) and hardcoded button-to-action logic in each input class. Adding new configurations requires another flag, another conditional, and often cross-input snooping. This doesn't scale.

The new system replaces that with a declarative inputMap resolved by resolveInteraction() that produces typed handlers (orbit/zoom/pan, etc.). Per-frame deltas are accumulated and applied via computeCurrentFrameDeltas() for framerate-independent inertia.

Playground demos

Demo Link
Legacy inertia 0.9 alpha glide #C7KCTE#0
Legacy inertia 0.5 alpha glide #OPHBRC#0
Legacy inertia 0 alpha snap #HF2DLH#0
Legacy panningInertia glide #8S8H0S#0
Legacy inertialRadiusOffset glide #98108I#0
Legacy beta limits clamp glide #FBLW1J#0
Legacy radius limits clamp glide #QWTJH9#0
Legacy combined axis glide #SJJRY4#0
setInteraction() — remap ctrl+drag #A1CDLY#0

What's in this PR

Core (packages/dev/core/src/Cameras)

  • Extends the existing CameraMovement base class with input-map resolution, typed handlers, and framerate-independent accumulators / inertia.
  • New ArcRotateCameraMovement subclass and refreshed GeospatialCameraMovement subclass, each with default input maps.
  • New cameraInteractions.ts with shared interaction descriptors and InputMapper class.
  • InputMapper.setInteraction(source, conditions, interaction) — targeted remapping of individual input entries without rebuilding the full inputMap (e.g. swap ctrl+left-drag from pan to rotate).
  • InputMapper.getEntry(source, interaction) — find an entry to tweak properties like sensitivity.
  • ArcRotateCamera and GeospatialCamera now always route through the movement system; _checkInputs() calls computeCurrentFrameDeltas() and applies the resulting rotation / zoom / pan deltas. The legacy useMovementSystem getter/setter is kept as a deprecated no-op for source compatibility.
  • Camera.inertia converted from a plain property to a get/set accessor so ArcRotateCamera can override it to sync with the movement system.
  • Legacy back-compat: _syncLegacyFlagsToInputMap() maps _useCtrlForPanning, _panningMouseButton, and useAltToZoom onto the inputMap so existing apps that toggle these flags keep behaving the same.
  • All input classes migrated to movement system: pointer, keyboard, mouse wheel, and gamepad (arcRotateCameraGamepadInput.ts) now feed movement.rotationAccumulatedPixels / panAccumulatedPixels / zoomAccumulatedPixels instead of legacy inertial offsets.
  • resetInputMap() on the base class and subclasses to restore default mappings after user customization.

Tests

  • Unit tests: cameraMovement.test.ts, arcRotateCameraMovement.test.ts, geospatialCameraMovement.test.ts, arcRotateCameraFramerateIndependence.test.ts.
  • Playwright interaction tests: arcRotateCameraInteraction.interaction.test.ts — 7 tests covering left-drag rotation, vertical drag, right-drag pan, ctrl+left-drag pan, mouse wheel zoom, arrow key rotation, and ctrl+arrow key panning.
  • Visualization tests with reference images covering legacy beta/radius limits and panning/radius/combined inertia and glide (8 tests).

Backward compatibility

  • Default end-user behavior is unchanged for existing apps: the same camera interactions (orbit, pan, zoom) produce equivalent motion.
  • Existing legacy boolean flags are honored via _syncLegacyFlagsToInputMap(), so apps that set e.g. _useCtrlForPanning = false or change _panningMouseButton still see the expected mapping changes.
  • The useMovementSystem property remains as a deprecated no-op getter/setter so any code referencing it still compiles.
  • Gamepad input migrated to movement system — steady-state behavior matches legacy at 60fps, with improved framerate independence at other refresh rates.

Out of scope / follow-ups

  • FreeCamera, FollowCamera, FlyCamera, VR / touch-joystick input sources — to be handled in subsequent PRs.

Georgina Halpern and others added 30 commits March 13, 2026 13:02
Add a public resetInputMap() method to the CameraMovement base class (resets
to empty array) and override it in ArcRotateCameraMovement and
GeospatialCameraMovement to restore their camera-type default inputMap
configurations. Extract default inputMap construction into private
_createDefaultInputMap() methods to avoid duplication between constructors
and resetInputMap().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…teCurrentFrameDeltas

Add useMovementSystem getter/setter that creates ArcRotateCameraMovement
on demand. Modify _checkInputs() to branch on this.movement: when enabled,
call computeCurrentFrameDeltas() and apply rotation/zoom/pan deltas to
alpha/beta/radius/target; when disabled (default), use the unchanged legacy
inertial offset path. The panning logic mirrors the legacy path (view matrix
inverse, panningAxis, mapPanning, panningDistanceLimit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add _syncLegacyFlagsToInputMap() to ArcRotateCamera that applies legacy flag
values (_useCtrlForPanning, _panningMouseButton, useAltToZoom) to the
movement system's inputMap. Called when useMovementSystem is enabled and
when attachControl is called with legacy flag arguments. This ensures
backward compatibility: setting _useCtrlForPanning=false before enabling
the movement system correctly removes the ctrl+keyboard→pan entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…meraMovement

Add cameraMovement.test.ts with tests for:
- resolveInteraction: source matching, first-match-wins, modifier matching
  (exact/absent/partial), button matching, touch count, 'none' fallback,
  empty inputMap
- computeCurrentFrameDeltas: accumulator reset, speed multipliers,
  per-axis rotation speeds, inertia decay, zero inertia, zero-delta

Add arcRotateCameraMovement.test.ts with tests for:
- Default inputMap (6 entries with correct interaction mappings)
- Default handlers (accumulate into base class accumulators)
- resetInputMap (restores defaults after modification)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 29, 2026

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 6, 2026

🟢 Memory Leak Test Results

13 passed, 0 leaked out of 13 scenarios

🟢 All memory leak tests passed — no leaks detected.

Passed Scenarios (13)
Scenario Package
Core Feature Stack @babylonjs/core
Core Rendering Materials Shadows Stack @babylonjs/core
Core Textures Render Targets PostProcess Stack @babylonjs/core
GUI Fullscreen UI Controls @babylonjs/gui
GUI Mesh ADT Controls @babylonjs/gui
Loaders Boombox Import @babylonjs/loaders
Loaders OBJ Direct Load @babylonjs/loaders
Loaders STL Direct Load @babylonjs/loaders
Materials Library Stack @babylonjs/materials
Serializers glTF Export @babylonjs/serializers
Serializers GLB Export @babylonjs/serializers
PostProcesses Digital Rain Stack @babylonjs/post-processes
Procedural Textures Stack @babylonjs/procedural-textures

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 6, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 6, 2026

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 6, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 6, 2026

Copy link
Copy Markdown
Member

@RaananW RaananW left a comment

Choose a reason for hiding this comment

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

This is really big :)
A wonderful idea. I'll be honest and say I couldn't find any breaking changes, so it LGTM.
Would be great to combine a few of the visualization tests, as they take time and resources to render, if possible.

Comment thread packages/dev/core/src/Cameras/Inputs/geospatialCameraKeyboardInput.ts Outdated
Comment thread packages/dev/core/src/Cameras/inputMapper.ts
Comment thread packages/dev/core/src/Cameras/Inputs/arcRotateCameraKeyboardMoveInput.ts Outdated
Georgina and others added 2 commits May 12, 2026 13:59
- arcRotateCameraKeyboardMoveInput: extract _applyUseAltToZoomToInputMap helper
  and call from both the setter and attachControl so a value cached before the
  camera is bound is flushed to the inputMap once the camera becomes available.
- geospatialCameraKeyboardInput / geospatialCameraPointersInput: guard the
  deprecated sensitivity getter/setter shims with this.camera?. and ?? [] so
  they no longer crash when read or written before the input is attached to a
  camera.
- inputMapper: add setInteractions (plural) that updates every matching entry
  and returns the count, with setInteraction JSDoc updated to point users at
  it (and getEntries) for multi-match scenarios.
- Tests: regression test for the useAltToZoom flush-on-attach path, plus three
  setInteractions tests covering all-match update, no-match return, and
  wildcard condition matching.
- Visualization: consolidate the three ArcRotate legacy inertia tests into a
  single side-by-side comparison test (#UAPORV#0) using vertical viewports
  with 2x hardware scaling for AA.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

🟢 Memory Leak Test Results

13 passed, 0 leaked out of 13 scenarios

🟢 All memory leak tests passed — no leaks detected.

Passed Scenarios (13)
Scenario Package
Core Feature Stack @babylonjs/core
Core Rendering Materials Shadows Stack @babylonjs/core
Core Textures Render Targets PostProcess Stack @babylonjs/core
GUI Fullscreen UI Controls @babylonjs/gui
GUI Mesh ADT Controls @babylonjs/gui
Loaders Boombox Import @babylonjs/loaders
Loaders OBJ Direct Load @babylonjs/loaders
Loaders STL Direct Load @babylonjs/loaders
Materials Library Stack @babylonjs/materials
Serializers glTF Export @babylonjs/serializers
Serializers GLB Export @babylonjs/serializers
PostProcesses Digital Rain Stack @babylonjs/post-processes
Procedural Textures Stack @babylonjs/procedural-textures

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

⚡ Performance Test Results

❌ Failed Tests (1)

Test Error
Default rendering pipeline [#5XB8YT#2] (webgpu) Error: page.evaluate: Execution context was destroyed, most likely because of a navigation.

Master's recent BabylonJS#18428 made missing-doc warnings fail the TypeDoc CI on
files changed by the PR. Add real descriptions to previously-empty @param
tags and JSDoc comments to previously-undocumented members so the check
passes again.

- geospatialCamera.ts: describe @param tags on updateFlyToDestination
  and flyToAsync (targetYaw/Pitch/Radius/Center, flightDurationMs,
  easingFunction).
- geospatialCameraMovement.ts: add doc comment on the constructor's
  parameter property limits, and add a real description on the
  computeCurrentFrameDeltas override.
- geospatialCameraPointersInput.ts: add JSDoc + @param descriptions on
  the onButtonDown and onMultiTouch overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

🟢 Memory Leak Test Results

13 passed, 0 leaked out of 13 scenarios

🟢 All memory leak tests passed — no leaks detected.

Passed Scenarios (13)
Scenario Package
Core Feature Stack @babylonjs/core
Core Rendering Materials Shadows Stack @babylonjs/core
Core Textures Render Targets PostProcess Stack @babylonjs/core
GUI Fullscreen UI Controls @babylonjs/gui
GUI Mesh ADT Controls @babylonjs/gui
Loaders Boombox Import @babylonjs/loaders
Loaders OBJ Direct Load @babylonjs/loaders
Loaders STL Direct Load @babylonjs/loaders
Materials Library Stack @babylonjs/materials
Serializers glTF Export @babylonjs/serializers
Serializers GLB Export @babylonjs/serializers
PostProcesses Digital Rain Stack @babylonjs/post-processes
Procedural Textures Stack @babylonjs/procedural-textures

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented May 12, 2026

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

@georginahalpern
Copy link
Copy Markdown
Contributor Author

georginahalpern commented May 12, 2026

Thanks for the review @RaananW! Pushed fixes for all three inline comments in 5dce2e5:

  • 1 (geospatial null-safety): Added this.camera?. guards (and ?? [] for setter loops) on all five deprecated sensitivity shims across the keyboard and pointers inputs.
  • 2 (setInteraction only updates first match): Added a plural setInteractions(source, conditions, interaction) that updates every match and returns the count. Updated setInteraction's JSDoc to point users at it (and at getEntries for picking out an individual non-first entry).
  • 3 (useAltToZoom setter dropped pre-bind): Extracted _applyUseAltToZoomToInputMap() and call it from both the setter and attachControl() so a value cached before the camera is bound is flushed when the camera becomes available. Backed by a regression test.

For the viz-test consolidation suggestion: collapsed the three ArcRotate legacy inertia 0.x tests into a single side-by-side comparison test (#UAPORV#0) using vertical viewports with 2x hardware scaling for AA, going from 3 entries + 3 baselines down to 1 each. Left the other 5 ArcRotate-legacy tests alone since each tests a distinct property.

Also pushed 7fc2ad1 to address the new TypeDoc CI failures from your #18428 — added missing param descriptions and JSDoc on a few previously-undocumented members in the geospatial files.

@georginahalpern georginahalpern enabled auto-merge (squash) May 12, 2026 22:12
@sebavan sebavan removed their request for review May 12, 2026 22:17
@georginahalpern georginahalpern disabled auto-merge May 12, 2026 22:20
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?

* 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 _isPanClick: boolean = false;
/** Cached resolved inputMap entry for the current pointer gesture */
private _activeEntry: PointerInputMapEntry | null = null;
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.

Nit: should we be using Nullable<> for consistency?

public override onButtonDown(evt: IPointerEvent): void {
this._isPanClick = evt.button === this.camera._panningMouseButton;
this._pointerConditions.button = evt.button;
this._pointerConditions.modifiers!.ctrl = evt.ctrlKey;
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 to avoid the non-null assertion operator - maybe also store modifiers seperately?

const input = camera.movement.input;

// Update cached modifier state
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 to avoid the ! here too - maybe store separately?

break;

this._pointerConditions.button = evt.button;
this._pointerConditions.modifiers!.ctrl = evt.ctrlKey;
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.

Could we avoid ! by storing modifiers seperately?

* Used when calculating inertial decay. Default to 60fps
*/
private _prevFrameTimeMs: number = FrameDurationAt60FPS;
private _prevFrameTimeMs: number = 1000 / DefaultReferenceFrameRate;
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.

Won't this get stale if the user sets referenceFrameRate later?

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.

Maybe we need a test to confirm that changing referenceFrameRate has the desired effect?


switch (entry.source) {
case "pointer":
if ("button" in conditions && entry.button !== conditions.button) {
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.

Curious about the use of "in" to check if a property is defined - is this preferrable to saying if (conditions.button !== undefined && ...)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants