← Index · Prev · Next →

12 — Input: actions, bindings, @inputMap

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:

Gamepad bindings (W3C standard mapping)

Gamepad controls use labeled pickers that match the browser Gamepad API standard mapping — not raw index integers.

Control kindPicker examplesManifest
ButtonA / Cross, LB / L1, D-pad Up, L3, …{ "device": "GAMEPAD", "control": "button", "index": N }
AxisLeft Stick X/Y, Right Stick X/Y, LT / L2, RT / R2{ "device": "GAMEPAD", "control": "axis", "index": N }
StickLeft Stick, Right Stick (2D vector){ "device": "GAMEPAD", "control": "stick", "index": 0|1 }

Triggers (LT/RT) are authored as axis bindings, not face buttons:

Stick axes use indices 03 (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:

KindPartsResultTypical use
+ BindingOne controlJump on Space, Move on left stick
+ 1D AxisNegative, Positivepositive − negative scalar −1..1W/S throttle, opposing keys
+ 2D VectorUp, Down, Left, RightNormalized { 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 HalfEffect
FullEntire axis −1..1 (default for direct bindings)
+ HalfOnly positive direction (stick up / right)
− HalfOnly 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:

FieldValuesMeaning
TypeButton / Value / Pass-ThroughCallback semantics (started/performed/canceled)
Control TypeButton / Axis / Vector 2Shape 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 needControl TypeGamepadKeyboard
Throttle or brake only (one analog axis)Axis+ 1D Axis on stick Y with + Half / − HalfOpposing keys in a 1D composite, or direct keys
Move / drive and steer (2D)Vector 2+ BindingStick → Left Stick+ 2D Vector → WASD (or arrows)
One digital direction per actionButtonDirect Axis row with + Half or − HalfDirect 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.

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.tsInjectInputMaps):

@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.