Skip to content

Add gamepad navigation, remappable profiles and Sequencer controls - #3855

Merged
MrNeRF merged 33 commits into
MrNeRF:masterfrom
francescofugazzi:feat/analog-camera-input
Oct 27, 2026
Merged

MrNeRF merged 33 commits into
MrNeRF:masterfrom
francescofugazzi:feat/analog-camera-input

Conversation

@francescofugazzi

Copy link
Copy Markdown
Collaborator

Summary

Add SDL3 gamepad navigation, configurable gamepad profiles and a Sequencer editing context. Controls act on the assigned viewport and follow its active navigation mode, so a controller can be used to explore a scene and capture a camera path without emulating keyboard events.

The PR also reorganizes Input preferences into focused pages, adds controller diagnostics and remapping, and addresses input ownership, hot-plugging and idle-update behavior. Gamepad profiles remain independent of keyboard keymaps.

Getting started

  1. Open Preferences → Input → Gamepad and enable gamepad navigation.
  2. Connect a controller. The sole usable controller is selected automatically; choose explicitly when several are connected.
  3. Click the intended viewport once, then briefly release all controls to arm navigation.
  4. Use the sticks and triggers to navigate. Interaction with other application panels preserves the viewport assignment.
  5. Open the Sequencer to use the keyframe commands below.

Enablement is persistent. A saved profile or device association does not grant permission to move a viewport: reconnecting, changing the selected controller, or changing the scene/split layout requires viewport activation again.

Default navigation controls

Button names below use SDL's logical positions. Xbox and PlayStation labels are examples; another vendor may print different letters or symbols.

Control Default action
Left stick Pan in Orbit / Free Orbit; horizontal movement in Fly, Drone and Walk
Right stick Orbit in Orbit / Free Orbit; look in Fly, Drone and Walk
RT / R2 Dolly toward the scene in Orbit / Free Orbit; move upward in Fly / Drone
LT / L2 Dolly away in Orbit / Free Orbit; move downward in Fly / Drone
LB / L1 Cycle to the next available navigation mode
RB / R1 Cycle rendering: splats → points → rings → center markers
B / Circle (right face button) Exit Walk, when Walk is active
Navigation mode Behavior
Orbit Pan, orbit around the pivot, and dolly
Free Orbit / Trackball Pan, freely orbit around the pivot, and dolly
Fly Move and look using the existing flight navigation
Drone Move and turn using the existing drone navigation
Walk Move and look using the existing collision and ground-support system

In orthographic Orbit / Free Orbit, triggers adjust zoom instead of perspective dolly. Mode cycling skips unsupported modes, including Walk when no usable collision volume/spawn is available. Walk does not bind vertical movement or the fast modifier.

Rotation speed, movement multiplier and stick dead zone are adjustable. Precision and faster movement are available as remappable actions but are unassigned by default; the shoulder buttons are reserved for mode cycling in the default layout. This PR uses the existing Walk physics and settings; it does not introduce jumping or a new gravity/fall-recovery system.

Default Sequencer controls

The Sequencer context is active while the Sequencer is open. Its assigned controls take precedence over camera bindings using the same physical input.

Control Default action Result
X / Square (left face button) Capture keyframe Capture the assigned viewport at the playhead, deselect, then advance the playhead by 0.5 seconds without moving the camera
Y / Triangle (top face button) Delete selected keyframe Delete only an actual selected keyframe, including the first or only real keyframe
D-pad left Previous keyframe Select the previous keyframe and move the assigned viewport to its pose
D-pad right Next keyframe Select the next keyframe and move the assigned viewport to its pose
Unassigned Deselect keyframe Explicit deselection, available for remapping
Unassigned Update selected keyframe Replace only the selected pose without advancing the playhead

Repeated capture and selection

  • Repeated X presses create a sequence of poses. If a capture time is already occupied, acquisition searches forward in 0.5-second steps instead of overwriting an existing pose.
  • The clip extends when needed. Capture and clip extension are recorded in the same undo step.
  • Capture leaves camera Follow mode so the camera can be repositioned for the next shot.
  • Going beyond either end with the D-pad deselects without moving the camera. Further outward presses remain deselected; an inward press selects that same endpoint again.
  • Y does nothing without a real selection. Synthetic loop keyframes remain protected.
  • Captured keyframes can subsequently be dragged in the timeline.
  • The ordinary explicit-time add/replace and selected-pose update paths remain available; automatic advancement belongs to the gamepad acquisition command.
  • First-key deletion is made consistent across gamepad, timeline/context-menu, Python and MCP paths.
  • Keyframe navigation, capture and updates target the same assigned viewport, including independent split views.
  • Dragging timeline keys, the playhead or Reveal handles blocks competing gamepad keyframe commands. Camera navigation remains available while dragging keys or Reveal handles. Playback/playhead scrubbing retain camera control; an active Reveal effect alone does not suspend navigation.
  • Camera-key edits and undo preserve the Reveal track.

Remapping and profiles

Feature Behavior
Common assignments Shared defaults for camera navigation
Per-mode overrides Separate overrides for Orbit, Free Orbit, Fly, Drone and Walk
Sequencer assignments Separate editing context; it is not another camera mode
Inheritance Inherit the common binding, override it, or explicitly leave an action unbound
Binding capture Capture buttons/axes with validation and conflict handling
Named profiles Select, create, duplicate, rename and delete controller layouts independently of keyboard profiles
Import / export Transfer one validated layout, including tuning and assignments
Remember for this model Optionally restore a profile using SDL's model GUID; identical controllers share the model association

Profiles do not contain connection-specific device IDs, global enablement or viewport activation. Explicit profile selection/creation/import updates an enabled model association. Removing that association is available even when a different profile is active.

Binding edits use revision checks so a delayed capture or conflict dialog cannot overwrite a newer device/profile/configuration change or restore stale enablement.

Preferences and diagnostics

Input becomes an expandable sidebar group with Navigation, Shortcuts and Gamepad pages. This replaces the long combined page and avoids a horizontal tab strip that overlaps in narrow windows.

  • Navigation settings, searchable keyboard shortcuts and gamepad controls have focused pages.
  • Gamepad assignments expand inline beneath its settings, using grouped contexts.
  • Controller tests show separate left-stick, right-stick, trigger and button readouts.
  • Navigation-device and diagnostic-device selection are independent. The test view never moves the camera.
  • Section reset acts on the displayed Input page. The Gamepad page resets the active layout; the general reset additionally clears named profiles/model associations and disables gamepad navigation.
  • Safe-mode persistence restrictions and the existing general reset behavior are preserved.
  • Help text is moved into concise labels/tooltips where appropriate. The viewport HUD reports controller/navigation status compactly.
  • Added/changed strings are reviewed across all ten supported locales, with consistent navigation-mode names, positional button terminology and corrected default-control descriptions.

Device lifetime, routing and efficiency

  • Reuse SDL3 semantic gamepad mapping; no additional vcpkg package, vendor driver, controller artwork or external mapping database is added.
  • Enumerate multiple controllers and handle connection/disconnection. Only the selected controller drives navigation; raw unmapped joysticks remain available for diagnostics.
  • SDL handles stay on the viewer thread and consumers use value snapshots.
  • Viewport ownership survives ordinary panel interactions. Modals, native dialogs, Preferences, diagnostics and relevant competing input temporarily suspend control; neutral rearming prevents jumps on resumption.
  • Walk keeps an explicit viewport owner across neutral input and temporary interruptions.
  • Short button presses remain buffered until their actual consumer processes them; diagnostic reads do not consume navigation commands.
  • Dead-zone-filtered input determines motion wakeups, reducing idle redraws caused by stick noise.
  • Preferences avoid unconditional binding-row/model dirty updates. Slider persistence is debounced and flushed on page changes/close, with unfinished edits discarded after conflicting device/profile changes.

Validation

Validated locally on Windows, using the existing Fast Compile + sccache build and dependencies, after merging upstream master through 811f63c98. Final compiled head: a0b5acc96.

Check Result
Native input/navigation/preferences/Sequencer regression selection 286 passed
Python preferences, bindings, profiles, localization, UI and Windows/Reveal/shader source contracts 473 passed, 1 skipped
Python first-key deletion boundary against the compiled extension 1 passed
Locale completeness and localization contracts Passed; 10 supported locales
Hardcoded UI text check Passed
Error-debt census against the existing baseline Passed; no baseline relaxation
I/O discipline and CUDA/log-format checks Passed
Windows path-boundary and source build preflight checks Passed
clang-format Passed on 41 changed C/C++ files
clang-tidy 23 configured translation units analyzed, no frontend errors; 19 reporting-only warnings remain

The local clang-tidy run required analysis-only normalization of Clang 19's Windows target/C++23 language macro reporting; no production compiler flags or source checks were weakened. The full MCP test translation unit is outside the configured lightweight native-test target and is not included in that test count.

Regression coverage includes virtual SDL devices/hot-plugging, neutral rearming, split-viewport ownership, buffered presses, binding/profile conflicts, idle filtering, repeated capture, occupied times, end-of-path deselection, first/only-key deletion, undo/redo, clip extension and Reveal preservation.

Remaining hardware validation: physical vendor/driver behavior, USB/Bluetooth reconnection, two identical physical controllers and real stick calibration. Virtual tests do not replace those checks.

Related work / credit

Thanks to @MrNeRF for #3846 — Navigate the viewport with a 3Dconnexion SpaceMouse. Reviewing its input-session and idle-behavior approach informed the review of effective-input wakeups and discrete-input consumption here. This is credit for that specific inspiration: no code was copied from #3846, and this PR does not include its SpaceMouse implementation.

User-facing documentation is in Gamepad navigation.

Copilot AI balanced review requested due to automatic review settings October 27, 2026 11:30

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks, this is a big feature and most of it is solid. Commands go through the profile bindings: I found no button or trigger that bypasses them, and "B exits Walk" is just a default binding. Keymaps, preferences and .licht files keep their format. The locale changes are additions only, 169 keys in each of the ten files.

Must fix

  1. With a controller plugged in, the idle app wakes up about 1000 times a second, even when gamepad navigation is off.

    • WindowManager::setInputController() (window_manager.cpp:726) starts the gamepad service whenever there is a window, whatever the setting.
    • GamepadService::Impl::add() then opens every mapped device (gamepad_service.cpp:52).
    • In SDL 3.4.2, an open joystick drops the event pump's polling interval to 1 ms (EVENT_POLL_INTERVAL_NS in SDL_events.c). Our idle SDL_WaitEventTimeout (window_manager.cpp:1070) then no longer sleeps.

    I measured it with the same SDL build, using a hidden window waiting for 3 s:

    Setup Wakeups CPU
    Without the gamepad subsystem 11 1.4 ms
    Subsystem started, no device 13 1.2 ms
    One open virtual gamepad 2844 45 ms

    Most users will never enable this, and plenty keep a controller connected. Please start the subsystem and open devices only while gamepad navigation is enabled or the diagnostics page is open, and close them again when neither needs them. Discovery and hot-plug only matter while one of the two is active.

  2. The disabled path still does work every frame. With the feature off:

    • flushGamepadEvents() samples the auto-selected device on every pump (gamepad_service.cpp:193-197).
    • updateGamepadNavigation() parses the gamepad profile library on a device change, and may switch the active profile, before it checks enabled (input_controller.cpp:2738-2761).
    • syncGamepadIndicator() looks up the indicator element on every overlay render (rml_viewport_overlay.cpp:1550, called at :1605 and :1660).

    Please check enabled first in all three, so someone who never turns the feature on pays nothing for it.

Should fix

  1. Reset All skips the navigation defaults when the gamepad reset fails. In preferences_panel.py:1831-1833, an error from the gamepad reset returns before the navigation mode, speeds, camera repeat and view snap are reset. This happens:

    • in safe mode, where preferences are read-only;
    • when the stored profiles are unreadable or from a newer version, which setGamepadProfiles deliberately refuses to replace.

    Please run the navigation reset anyway and report the gamepad error afterwards.

  2. A short trigger press bound to a command can get lost. Only SDL_EVENT_GAMEPAD_BUTTON_DOWN is buffered (window_manager.cpp:966), and triggers are read from the current axis value (gamepad_service.cpp:229-233). The short-press path in gamepad_navigation.hpp:252 only covers buttons. So if the trigger is pressed and released while a frame is busy, the command never fires, while a face button bound to the same command does. Bindings allow triggers for commands, so the threshold crossing should be buffered like a button press.

Small things

  • tests/CMakeLists.txt:236 and :238 now list lfs_sequencer twice in the visualizer test link list. Master's #3851 already added it.
  • docs/docs/features/gamepad.md:88 names two console brands. Please describe the layouts by button position instead; we keep third-party names out of what we ship.

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed review. I have implemented and validated the fixes locally in commit 020fbd353.

Status: this commit has not been pushed yet, so the PR diff still shows the previous implementation. This is a progress report, not a request to re-review the current remote head.

Review point Local change
Idle SDL polling with navigation disabled Acquire the gamepad subsystem and device handles only while navigation is enabled or the Gamepad preferences page needs discovery. Release them when neither does. Closing Preferences also releases discovery when its document remains mounted. Switching between Gamepad settings and diagnostics keeps the same discovery request.
Work in the disabled path Check enablement before navigation sampling, device-profile restoration and indicator DOM lookup. Diagnostics can sample independently without reading the navigation device each pump.
Reset All interrupted by a gamepad error Continue resetting navigation defaults, speeds, camera repeat and view snap, then report the gamepad error. Other reset sections still run.
Lost short trigger commands Buffer trigger activation-threshold crossings from SDL axis events, independently for navigation and diagnostics. A press/release between processed frames is retained; held inputs do not repeatedly fire without another crossing.
Duplicate test dependency Remove the second lfs_sequencer entry from the visualizer test link list.
Hardware terminology in shipped documentation Describe the hardware test coverage by positional button layouts instead of console brands.

Handle ownership changes are deferred until SDL's event pump/watch has returned. For Preferences closing without unmounting, the existing native panel visibility notification is now forwarded to retained Python panels, including lazy proxies, so the page can release its transient resources on hide.

Local validation

  • 295 native tests passed, including handle release/re-enumeration, diagnostics-only sampling, trigger buffering through the window event pump, and deferred ownership changes during an event watch.
  • 443 Python tests passed, 1 skipped, including reset failures in normal/safe mode, discovery lifetime, hidden Preferences and lazy-panel visibility forwarding.
  • Locale completeness, hardcoded UI text, error-debt baseline, I/O discipline, log formatting, Windows path/preflight and clang-format checks passed.
  • clang-tidy completed on the 10 changed configured translation units without frontend errors; reporting-only warnings remain on unchanged code paths.
  • Rebuilt the application with the existing Fast Compile/sccache setup.

Physical controller/driver validation remains separate from the virtual-device regressions above.

Preserve gamepad and new scale-tool translations in all locales. Read AprilTag locale fixtures as UTF-8 on Windows.
Use full dead zones for armed idle wakeups and retain neutral hysteresis only during rearming. Cover progressive squeezes and profile operations with two controllers.

Copy link
Copy Markdown
Collaborator Author

The local fixes described in my previous update are now pushed, through a7f04c920. The PR diff is ready for another review.

Commits:

  • 020fbd353: address the review's SDL lifetime, disabled-path work, Reset All, short-trigger buffering, duplicate CMake dependency and documentation points.
  • 536193cfa: merge upstream master through 95a54dec6 using a regular merge, preserving both sides of all ten locale conflicts. Also fix the new AprilTag locale test to explicitly read UTF-8 on Windows.
  • a7f04c920: resolve three additional issues found in the follow-up local review.
Additional finding Fix
A progressive trigger squeeze could generate two commands SDL event buffering and command held-state detection now share the same raw trigger threshold. Movement conditioning remains separate. Regression cases cover both triggers, keyframe capture/render cycling, holding and releasing.
Profile operations could lose the selected controller while navigation was disabled Profile edits cancel capture without releasing the visible page's discovery request. Tests exercise selection, creation, duplication, renaming, deletion, model association and cancelled import/export with two controllers, with navigation both enabled and disabled. Closing the page still releases its request.
Stick noise inside the configured dead zone could request redraws after arming Armed sessions use the full movement dead zone for wake filtering. The narrower neutral threshold is used only during rearming. Tests also verify that returning to neutral still wakes and rearms an interrupted session without another viewport click.

Validation

  • Latest head: 348 native input/viewport/Sequencer/scale regression tests passed.
  • Latest head: 532 Python tests passed, 1 skipped, plus 1 passing Python boundary test against the compiled extension.
  • After the master merge: Windows format tests now link successfully (129 passed, 4 skipped, 1 disabled); AprilTag scaling (13 passed) and batch-process tests (15 passed) also passed. Those unchanged standalone targets were not rerun for the final input-only fix.
  • Locale completeness, hardcoded UI text, I/O discipline, log formatting, Windows source/path preflight and clang-format checks passed.
  • Targeted clang-tidy on the final changed C++ translation units completed with no warnings or frontend errors.
  • The repository's canonical error-debt baseline is behind the newly merged master. A comparison against an independently scanned, unmodified 95a54dec6 source snapshot reports no additional debt from this branch. The repository baseline has not been relaxed or regenerated.
  • Application and regression binaries rebuilt using the existing Fast Compile/sccache setup.

Physical controller/driver testing remains separate from the virtual-device and routing coverage above. New GitHub CI runs will validate the pushed head independently.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Built a7f04c9 and ran it. All the round-1 points are fixed. With navigation off the gamepad subsystem never starts: under gdb there is no SDL_InitSubSystem(SDL_INIT_GAMEPAD) at startup or while idle, opening the Gamepad page starts it, leaving the page quits it, and turning navigation on and off does the same. The disabled frame path, Reset All and the short trigger press look right too. The visualizer tests (1035 pass, 2 skipped), the gamepad and MCP sequencer tests (236, and 20 repeats), your Python test files (305) and the fast Python tier all pass.

Two things I'd still like fixed:

  1. Trigger commands have no hysteresis. gamepadCommandTriggerDown (gamepad_service.hpp:20) uses > 0.5 for both press and release, and since 020fbd3 every upward crossing inside a pump sets trigger_pressed (gamepad_service.cpp:180-187). The edge in gamepad_navigation.hpp:255-265 is short_press || (held && !action_down_), so a trigger resting right at the threshold that dips to 0.498 and comes back within one frame fires again even though held never dropped. 8-bit triggers sit at 127/128 there, so one step of noise is enough: bind Add keyframe to RT, squeeze slowly, and one squeeze can add two keys. Could you latch the trigger per device with separate press and release levels (for example press above 0.5, release below 0.35) and use that one state for both the event edge and held?

  2. Reset on the Gamepad page drops the selected navigation controller. preferences_panel.py:1836 calls deactivate() with the default release_discovery=True before reset(). With navigation off that shuts the service down, so configure(selected=...) from gamepad_preferences.py:198 asks an inactive service to select the device, which fails and falls back to 0 (input_controller.cpp:2683-2688). With two controllers and the second one selected, Reset leaves the selector on None and closes and reopens every device. deactivate(release_discovery=False) there should be enough, since leaving the page still releases discovery.

Smaller things:

  • A few pieces have no user: GamepadService::beginFrame() is only called from tests, the diagnostic sample.trigger_pressed is set and cleared but never read, labels and has_buttons in get_gamepad_test_state (py_ui.cpp:5547, :5556) have no reader (and labels costs four SDL_GetGamepadButtonLabel calls per sample), and preferences.rml doesn't use the gamepad_test_axes / gamepad_test_buttons bindings. Either use them or drop them.
  • tests/CMakeLists.txt:255-256 still swaps lfs_io and lfs_sequencer compared to master. Keeping master's order keeps those lines out of the diff.

Copy link
Copy Markdown
Collaborator Author

The second-round fixes are now pushed through d0eb73743, in three separate commits.

Review point Change
Trigger commands retrigger near the activation threshold 3747c083b: keep a per-device latch for each trigger. Press above 0.5; release below 0.35. SDL event edges and sampled held state use that same latch, and the command resolver consumes the latched held state. Analog movement conditioning remains independent.
Reset drops the selected navigation controller 95f2b424f: reset cancels capture with deactivate(release_discovery=False), preserving the visible page's discovery lease. Added a two-controller regression matrix covering section/general Input reset with navigation initially on/off; selection stays on the second controller, and hiding Preferences still releases discovery.
Unused diagnostic/frame plumbing and CMake noise d0eb73743: remove beginFrame(), unused diagnostic trigger-edge writes/clears, sampled button labels and their four SDL label queries, the unused labels/has_buttons Python outputs, and the unused text bindings/helpers. Diagnostics tests now assert the actual rendered row data and that diagnostic reads leave navigation trigger edges intact. Restore master's lfs_io / lfs_sequencer link order.

Trigger regressions cover threshold jitter across samples and multiple crossings in one pump, partial release, rearming after a real release, independent devices/selections, diagnostic reads, and progressive squeezes for both triggers with keyframe/render commands. The two focused trigger tests also passed 20 consecutive repetitions.

Validation on the final build:

  • 568 native CPU tests passed using the visualizer CTest filter used by CI; two existing tests remain disabled.
  • 349 targeted native input/viewport/Sequencer/preferences tests passed (overlapping coverage with the CPU suite).
  • 536 Python tests passed, one skipped, including all four new reset cases.
  • Compiled C++/Python gamepad diagnostics boundary check passed.
  • Locale completeness, UI text, I/O discipline, log format, Windows path/source preflight, clang-format and git diff --check passed.
  • Clang-tidy completed without errors. The changed gamepad/service/navigation/dispatcher test code has no reported warnings; six warnings remain in unchanged portions of py_ui.cpp.
  • No added error debt against the independently synchronized master baseline from Restore the error-debt gate by syncing the master baseline #3862. This branch does not modify the baseline.
  • Application and tests rebuilt with the existing Fast Compile/sccache build; the local bundle is aligned to d0eb73743.

This validation uses virtual controllers and routing tests; physical controller/driver testing remains separate. Fresh GitHub CI will validate the pushed head.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Built d0eb737 and ran it. The trigger latch, the Reset fix and the cleanup all look right, and the link order matches master again. The visualizer tests (1036 pass, 2 skipped), the gamepad and MCP sequencer tests (237, and 20 repeats), your Python test files (309) and the fast Python tier all pass.

@MrNeRF
MrNeRF merged commit 90c63ac into MrNeRF:master Oct 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants