12 — Input: actions, bindings, @inputMap
Subsystem diagram: input.html · Code traces: trace-input (runtime) · Blender trace-input · Feature chain: 10 — Feature Traces
Overview (src/input/)
A Unity Input System clone: InputManager owns the device state (keyboard + first gamepad, deadzoned) and the project-wide InputActionAsset of Action Maps > Actions > Bindings (direct key/button/axis/stick reads, plus 1DAXIS/2DVECTOR composites). Actions have a type (BUTTON/VALUE/PASSTHROUGH) and fire Unity-style started/performed/canceled callbacks; polling: ReadValue(), ReadVector2(), IsPressed(), WasPressedThisFrame(), WasReleasedThisFrame(), WasPerformedThisFrame().
Blender authoring
The asset and Scene Default map are authored in Blender's Input Actions panel (the input_actions/ package: properties.py / serialize.py / operators.py, drawn by ui/input_panel.py) and exported as scene.inputActions + scene.defaultInputMap. If the panel is empty at export, the built-in "Player" map is serialized anyway.
Panel tools:
- Load Default Asset — seeds the built-in Player map (Move/Look/Jump/…)
- Save / Load Asset (.json) — share
.inputactions.jsonacross scenes; feednpm run input:genfor TypeScript constants - Create Maps Used by Scripts — scans
@inputMap("Name")in behavior sources and creates missing maps - Capture ( record icon) — keyboard capture on any key row; gamepad capture on gamepad rows (Linux
/dev/input/js*, Xbox-style remapping)
Gamepad bindings (W3C standard mapping)
Gamepad controls use labeled pickers that match the browser Gamepad API standard mapping — not raw index integers.
| Control kind | Picker examples | Manifest |
|---|---|---|
| Button | A / Cross, LB / L1, D-pad Up, L3, … | { "device": "GAMEPAD", "control": "button", "index": N } |
| Axis | Left Stick X/Y, Right Stick X/Y, LT / L2, RT / R2 | { "device": "GAMEPAD", "control": "axis", "index": N } |
| Stick | Left Stick, Right Stick (2D vector) | { "device": "GAMEPAD", "control": "stick", "index": 0|1 } |
Triggers (LT/RT) are authored as axis bindings, not face buttons:
- LT / L2 → axis index
4(runtime reads W3C gamepad button 6 as analog 0..1) - RT / R2 → axis index
5(W3C button 7)
Stick axes use indices 0–3 (LX, LY, RX, RY). Use Value action type for analog throttle/brake; Button if you only need a digital threshold (~half pull).
Stick Y orientation: the browser Gamepad API reports stick up as −1 on Y axes (indices 1 and 3). The runtime flips those in OrientGamepadAxis() (Devices.ts) so authored labels match Unity: Left Stick Y + Half = push stick up, − Half = push stick down. Stick bindings (control: "stick") use the same orientation.
Composite bindings
Add composites from the binding toolbar on a selected action:
| Kind | Parts | Result | Typical use |
|---|---|---|---|
| + Binding | — | One control | Jump on Space, Move on left stick |
| + 1D Axis | Negative, Positive | positive − negative scalar −1..1 | W/S throttle, opposing keys |
| + 2D Vector | Up, Down, Left, Right | Normalized { x, y } | WASD move (keys); combine with a stick binding on the same action |
Default Move uses a 2D Vector (WASD + arrows) plus a direct Left Stick binding. The runtime picks the most-actuated binding when several compete on one action.
Axis half (split one stick axis by direction)
Binding the same stick axis to both composite parts without filtering cancels out (both parts read the full signed −1..1 value). Unity avoids this with separate stick-up / stick-down controls; here use Axis Half on composite part rows when the control is a gamepad axis:
| Axis Half | Effect |
|---|---|
| Full | Entire axis −1..1 (default for direct bindings) |
| + Half | Only positive direction (stick up / right) |
| − Half | Only negative direction (stick down / left), as a positive magnitude for the composite formula |
New 1D Axis composite parts default to the matching half (Positive/Up/Right → + Half; Negative/Down/Left → − Half). Manifest field: axisHalf: "POSITIVE" | "NEGATIVE" (omitted = full axis). Export writes uppercase; the runtime accepts any case.
Example — one stick Y, forward and back on one action:
Action: Throttle (Value, Axis)
1D Axis
Positive → Gamepad / Axis / Left Stick Y / + Half
Negative → Gamepad / Axis / Left Stick Y / − Half
Stick up → +0.8; stick down → −0.8; center → 0.
Example — separate Forward / Backward actions (legacy pattern — separate IsPressed() per direction): use direct bindings, not a composite — one action per direction:
Forward (Button): W + Gamepad / Axis / Left Stick Y / + Half
Backward (Button): S + Gamepad / Axis / Left Stick Y / − Half
Do not put the same stick axis on both composite parts without axis half — values cancel. Do not bind Left Stick X when you mean forward/back on Y.
Keyboard keys in composites ignore axis half (keys are already one-sided). Triggers (LT/RT) are 0..1 only — axis half does not apply.
Action type vs control type
Two dropdowns above the bindings list — both matter:
| Field | Values | Meaning |
|---|---|---|
| Type | Button / Value / Pass-Through | Callback semantics (started/performed/canceled) |
| Control Type | Button / Axis / Vector 2 | Shape of the stored value |
Critical: a 2D binding (WASD composite or stick) on an action whose Control Type is still Button is flattened to a scalar magnitude at runtime. ReadVector2() then puts that number on x only (y is always 0) — forward/back and steer break.
For ReadVector2() (move, look, vehicle control): set Type = Value and Control Type = Vector 2. Use Axis only for true 1D scalars (ReadValue()).
Choosing a binding: Stick vs 1D Axis vs 2D Vector
Pick the control type first, then the binding shape:
| You need | Control Type | Gamepad | Keyboard |
|---|---|---|---|
| Throttle or brake only (one analog axis) | Axis | + 1D Axis on stick Y with + Half / − Half | Opposing keys in a 1D composite, or direct keys |
| Move / drive and steer (2D) | Vector 2 | + Binding → Stick → Left Stick | + 2D Vector → WASD (or arrows) |
| One digital direction per action | Button | Direct Axis row with + Half or − Half | Direct key |
Prefer Stick for 2D gamepad control. Do not rebuild a stick with a 2D Vector of four axis-half rows (Left Stick X/Y ± Half) — it is redundant, easy to misconfigure, and harder to maintain than one Left Stick binding.
Do not use two 1D Axis composites on one Vector 2 action — 1D is for scalars. For two analog dimensions together, use Vector 2 + Stick (or keyboard 2D Vector).
Example — vehicle Main Control (CarController)
CarController uses @inputMap("Vehicle") and polls one Vector 2 action:
Action: Main Control (Value, Vector 2)
+ 2D Vector → WASD keys
+ Binding → Gamepad / Stick / Left Stick
const control = this.vehicle.FindAction("Main Control")?.ReadVector2() ?? { x: 0, y: 0 };
const throttle = control.y; // forward / back
const steer = control.x; // left / right
Manifest excerpt:
{
"name": "Main Control",
"type": "VALUE",
"controlType": "VECTOR2",
"bindings": [
{
"composite": "2DVECTOR",
"parts": {
"up": { "device": "KEYBOARD", "control": "w" },
"down": { "device": "KEYBOARD", "control": "s" },
"left": { "device": "KEYBOARD", "control": "a" },
"right": { "device": "KEYBOARD", "control": "d" }
}
},
{ "device": "GAMEPAD", "control": "stick", "index": 0 }
]
}
Add a Reset button action on the same map if the script needs it. Regenerate constants: npm run input:gen -- --app playground.
Multiple bindings on one action
When an action has several bindings (e.g. WASD + left stick on Move), the runtime picks the most-actuated binding — the whole vector or scalar with the largest magnitude. It does not merge axes across bindings.
- Use one device at a time for a given action: stick for both throttle and steer, or WASD for both — not keyboard W plus horizontal stick steer on the same action.
- Ties go to whichever binding appears first in the list.
Default Move order: keyboard composites first, then stick — keyboard wins on ties when both are idle or equally actuated.
Axis half examples (1D / per-direction)
See examples under Axis half above (throttle 1D composite, separate Forward/Backward buttons). Axis half applies to gamepad axis rows in composites — not to Stick bindings.
Load-time injection
LevelLoader calls InputManager.LoadAsset before behaviors are built. Map injection (core/loader/scripts.ts → InjectInputMaps):
@inputMap("Name")on a field → that map;@inputMap()or@inputMap("")→ scene default;- no
@inputMapfields →behavior.inputreceives the scene default.
@inputMap stays lowercase like @exposed (Blender scans the literal token). See also 05 — Scripting for behavior lifecycle and decorator contracts.
Frame lifecycle
Automatic: Level.Begin attaches and enables every map; each scene.render() drives RunFrame on onBeforeRenderObservable. Inside RunFrame: InputManager.Process runs before all behavior OnUpdate calls; InputManager.EndFrame runs after them so WasPressedThisFrame / WasReleasedThisFrame edges last exactly one frame. Dispose detaches input and removes observers.
The keyboard observable (scene.onKeyboardObservable) is separate — it fires on key events and is used by Babylon's native camera key schemes, not by the Input Actions pipeline. Full app → BJS timeline: 02 — Runtime Basics · trace-runtime-loop. Babylon camera key schemes intentionally stay native (cameras consume keycode arrays).
Manifest binding shape (reference)
{
"composite": "1DAXIS",
"parts": {
"positive": {
"device": "GAMEPAD",
"control": "axis",
"index": 1,
"axisHalf": "POSITIVE"
},
"negative": {
"device": "GAMEPAD",
"control": "axis",
"index": 1,
"axisHalf": "NEGATIVE"
}
}
}
Direct trigger example: { "device": "GAMEPAD", "control": "axis", "index": 5 } for RT. Separate forward/back buttons:
{ "device": "GAMEPAD", "control": "axis", "index": 1, "axisHalf": "POSITIVE" }
Stick binding (2D): { "device": "GAMEPAD", "control": "stick", "index": 0 }
Resolution: Devices.OrientGamepadAxis (Y flip), InputBinding.ApplyAxisHalf, ReadGamepadAxis, ResolveStick, Trigger for indices 4/5.