Repository navigation
Add gamepad navigation, remappable profiles and Sequencer controls - #3855
Conversation
MrNeRF
left a comment
There was a problem hiding this comment.
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
-
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_NSinSDL_events.c). Our idleSDL_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.
-
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 checksenabled(input_controller.cpp:2738-2761).syncGamepadIndicator()looks up the indicator element on every overlay render (rml_viewport_overlay.cpp:1550, called at:1605and:1660).
Please check
enabledfirst in all three, so someone who never turns the feature on pays nothing for it.
Should fix
-
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
setGamepadProfilesdeliberately refuses to replace.
Please run the navigation reset anyway and report the gamepad error afterwards.
-
A short trigger press bound to a command can get lost. Only
SDL_EVENT_GAMEPAD_BUTTON_DOWNis buffered (window_manager.cpp:966), and triggers are read from the current axis value (gamepad_service.cpp:229-233). The short-press path ingamepad_navigation.hpp:252only 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:236and:238now listlfs_sequencertwice in the visualizer test link list. Master's #3851 already added it.docs/docs/features/gamepad.md:88names two console brands. Please describe the layouts by button position instead; we keep third-party names out of what we ship.
|
Thanks for the detailed review. I have implemented and validated the fixes locally in commit 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.
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
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.
|
The local fixes described in my previous update are now pushed, through Commits:
Validation
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
left a comment
There was a problem hiding this comment.
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:
-
Trigger commands have no hysteresis.
gamepadCommandTriggerDown(gamepad_service.hpp:20) uses> 0.5for both press and release, and since 020fbd3 every upward crossing inside a pump setstrigger_pressed(gamepad_service.cpp:180-187). The edge ingamepad_navigation.hpp:255-265isshort_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 thoughheldnever 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 andheld? -
Reset on the Gamepad page drops the selected navigation controller.
preferences_panel.py:1836callsdeactivate()with the defaultrelease_discovery=Truebeforereset(). With navigation off that shuts the service down, soconfigure(selected=...)fromgamepad_preferences.py:198asks 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 diagnosticsample.trigger_pressedis set and cleared but never read,labelsandhas_buttonsinget_gamepad_test_state(py_ui.cpp:5547,:5556) have no reader (andlabelscosts fourSDL_GetGamepadButtonLabelcalls per sample), andpreferences.rmldoesn't use thegamepad_test_axes/gamepad_test_buttonsbindings. Either use them or drop them. tests/CMakeLists.txt:255-256still swapslfs_ioandlfs_sequencercompared to master. Keeping master's order keeps those lines out of the diff.
|
The second-round fixes are now pushed through
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:
This validation uses virtual controllers and routing tests; physical controller/driver testing remains separate. Fresh GitHub CI will validate the pushed head. |
MrNeRF
left a comment
There was a problem hiding this comment.
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.
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
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.
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.
Repeated capture and selection
Remapping and profiles
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.
Device lifetime, routing and efficiency
Validation
Validated locally on Windows, using the existing Fast Compile + sccache build and dependencies, after merging upstream master through
811f63c98. Final compiled head:a0b5acc96.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.