Unity Workflow LibraryUWLAbout

Topic 3 · Unity programming

Input, touch & pointer gating

Input System wiring and touch traps that never reproduce with a mouse.

Input System wiring plus the touch-specific traps that never reproduce on desktop, because a mouse hovers and a finger does not.

  • CinemachineOrbitalFollow (Sphere style): RadialAxis.Value is a SCALE on Radius, not meters. Camera distance = Radius × RadialAxis.Value, so code that drives the radial axis in meters (zoom 8–120 style) must set orbit.Radius = 1 — an authored Radius of 10 silently multiplies every zoom ×10 (symptom: camera hundreds of meters out, map a speck, units "missing"). Enforce Radius = 1 in the same code that pushes zoom ranges/values so any scene wiring stays correct. (Seen in a survival RTS: match camera 480 m out from zoom 48 × authored Radius 10.)

  • World-input gates (click-through, scroll-zoom, orbit-through) must be GENERAL — one EventSystem.current.IsPointerOverGameObject check, never a hand-maintained per-panel list. UI Toolkit runtime panels register PanelRaycasters with the EventSystem, so that one call covers EVERY panel, current and future, and respects pickingMode. A per-panel OR-chain of BlocksWorldClick/rect tests goes stale the day the next panel ships and the bug re-reports forever (burned repeatedly; final straw — scroll zoomed the camera through every panel added after the chain). Keep per-panel rect methods only for semantics a pick can't express (e.g. "this modal blocks the whole screen"). Caveat: call it from Update/LateUpdate and cache the bool — calling it inside an InputAction callback returns wrong results and logs a warning.

  • Discrete input is event-driven, never polled every frame; a stable handle is resolved ONCE, not re-fetched in Update. For a one-shot action — a key press, a button, a toggle — bind an InputAction and handle its performed callback (subscribe in OnEnable, -= + Dispose in OnDisable), so there is NO per-frame poll at all (often no Update at all). Do NOT sit a Keyboard.current[key].wasPressedThisFrame / someKey.wasPressedThisFrame check inside Update/LateUpdate to catch a discrete press — that polls every frame for what is an event. Continuous input (mouse position, scroll, a held button for drag/pan/zoom, an axis) DOES read per frame, but off a cached action/device and behind an early-out. More generally: any handle that doesn't change frame-to-frame — an input device (Keyboard.current/Mouse.current), a component ref, a singleton, an EntityQuery, Camera.main — is resolved once in Awake/OnEnable/OnCreate and cached; re-grabbing it inside a per-frame method is the smell even when the call looks cheap. (New Input System note: legacy UnityEngine.Input.* THROWS at runtime when Active Input Handling is "Input System Package" — it still compiles, so it only fails in Play. Seen in practice: a debug hotkey polled Keyboard.current per frame in Update for a discrete digit press; rewritten to a callback-driven InputAction with no Update.)

  • TOUCH-UI-GATE-SYNC-PICK — EventSystem.IsPointerOverGameObject(touchId) misses the touch PRESS frame; gate world input with a synchronous UITK pick. Touch has no hover — the pointer materializes exactly at press, and the UI module often has not processed it yet when gameplay polls, so on device the gate reads false for the first frame(s): a tap on a HUD button ALSO lands as a world tap (deselect → "the panel closed itself"), and camera drags start from under buttons. Desktop is immune (mouse hover pre-arms the pointer), so this ships silently and only reports from device builds. Fix shape: register each runtime panel root, then hit-test synchronously — RuntimePanelUtils.ScreenToPanel(root.panel, new Vector2(x, Screen.height - y))

  • panel.Pick(...) (respects pickingMode; input positions are bottom-left origin, panels top-left). Keep the EventSystem check only as a fallback for unregistered surfaces. (Seen in a mobile strategy game: Android: every HUD tap fell through and closed the selection panel.)

  • PINCH-REANCHOR-TOPOLOGY — a touch gesture driver must re-anchor its cached sample whenever finger topology changes, keyed by touchId. Deltas are only valid against the SAME fingers: on 2→1 (pinch end) the cached centroid is the two-finger midpoint, so the next 1-finger frame applies half a pinch-span as a pan — the camera "snaps somewhere else" after every zoom; an activeTouches order/id swap flips the twist angle up to 180° in one frame. Store the touchIds the sample was taken from; on any mismatch (count OR ids) cache fresh and skip that frame's delta instead of applying it. (Seen in a mobile strategy game: Android pinch zoom.)

  • GESTURE-DEADZONE-CUMULATIVE — a gesture deadzone is CUMULATIVE from the gesture anchor with a latch, never a per-frame delta test against a reference you re-cache every frame. The broken shape: if (abs(delta) >= deadzone) apply(delta); prev = current; — a deliberate slow twist/drag moves ~1-2°/frame, never crosses the deadzone in one frame, and the re-cached reference erases the accumulation, so the gesture is STRUCTURALLY stuck (only a violent flick registers, which reads as "rotation randomly jumps"). Correct shape: bank the delta from the gesture-start anchor; when the BANKED total crosses the deadzone, latch the gesture active for the rest of the touch and stream per-frame deltas (feed only the overshoot past the deadzone on the latch frame so it eases in instead of snapping the wind-up). Reset bank+latch at every fresh anchor (new pair, topology change — see PINCH-REANCHOR-TOPOLOGY). Also: check which quantity a pinch SINK scales — if it scales camera DISTANCE, the driver must send pow(span ratio, -gain); spread = zoom IN is the platform convention. (Seen in a mobile strategy game: mobile twist rotation "gets stuck" — 4° per-frame deadzone vs re-anchored angle; pinch also zoomed backward.)

  • TOUCH-PICK-PHYSICAL — input thresholds are physical units (dp), and world pick volumes are CAPSULES, not spheres at one anchor height. Two paired reasons "tapping things on device feels dead while FPS is fine": (1) a raw-pixel tap-slop/tolerance constant tuned on desktop (10 px) is ~0.5 mm at phone DPI — a normal finger rolls 15-40 px between press and release, so most honest taps reclassify as drags and the release picks nothing. Scale every touch threshold like Android touch slop: px = base * max(1, Screen.dpi / 160); mouse paths keep raw pixels. (2) a pick test of ray-vs-sphere at a fixed low anchor makes pickability CAMERA-ANGLE-dependent — at shallow pitch the tall visible mesh projects far above its little sphere, so taps on the body sail over (reported as "some angles are harder to select"). Test ray-vs-vertical-SEGMENT (feet→top capsule) instead — angle-independent for tall objects; and never exclude a category (structures) from the screen-space near-miss fallback. (Seen in a mobile strategy game: building selection "super unresponsive / delayed / angle-dependent" on Android.)

  • DESKTOP-HOVER-MOBILE-TAP-SELECT — every desktop hover-to-inspect surface must expose the same information as a tap-select state on touch; a second tap closes it. Touch has no hover, so a hover-only tooltip silently removes the information on phones. Keep the selected state visible until the player dismisses it; do not rely on simulated hover.

  • CM3-DEPRECATED-FREELOOK — before rewriting a Cinemachine 2 rig for Cinemachine 3, check the deprecated compat layer: CM3 still ships CinemachineFreeLook/CinemachineVirtualCameraBase with the old m_XAxis/m_YAxis AxisState fields under the Unity.Cinemachine namespace, marked [Obsolete] but fully functional. Authored scene rigs (Follow/LookAt, orbit rigs, damping) survive the package upgrade untouched, so the cheapest correct migration of driver code is often: swap using Cinemachine; → using Unity.Cinemachine;, add #pragma warning disable 0618, and keep the original body — not a rewrite to OrbitalFollow. Verify with reflection when unsure: Type.GetType("Unity.Cinemachine.CinemachineFreeLook, Unity.Cinemachine") and list its axis fields. Rewrite to the CM3-native components only when actually touching rig behaviour. (Proven a tower-defense game: developer's commented-out FreeLook driver restored verbatim after the CM2→CM3 move that had been misread as "API gone".)

  • STARTER-ASSETS-BUTTONS-LATCH — under PlayerInput SendMessages, StarterAssetsInputs button fields LATCH on press (OnJump fires on press only; the stock controllers clear _input.jump themselves as consumption). A binder that disables those controllers and reads the fields must consume the latch after reading, and an input lock must ZERO those fields before local intent is published — locking the HUD or cursor alone leaves jump held forever. (a survival game)

  • STREAMED-BOUNDS-NEVER-LATCH — a clamp pulled from streamed, frame-budgeted content must re-pull while the extents grow, and once in-game only ever WIDEN. A one-shot _boundsPulled latched on the first non-empty rect — a single corner tile — and trapped camera pan in that pocket for the whole session. Re-centre only during loading; expand afterwards. (a project docs/pitfalls/camera.md:25.)

  • INPUT-FRAME-HAS-ONE-OWNER — an armed mode (build, demolish, target) OWNS its input frames; one physical click never reaches two consumers. The right-click that exited placement was also run through the ordinary right-click chain the same frame and auto-built on the pawn it landed on. Every armed mode implements both halves: consume the frame, and the general chain checks the armed state first. (a project docs/pitfalls/building.md:62, storage-haul.md:43.)
  • CAMERA-RIG-INPUT-TRAPS. Scroll zoom is PROPORTIONAL (a fraction of the current distance per notch), never a fixed step — one unit per notch crawls at radius 60 and lurches at radius 2; an orbit cursor-hide must not use CursorLockMode.Locked when the same button also issues orders (locking breaks the pointer raycasts); and a CinemachineCamera needs a rotation-control component plus BOTH TrackingTarget and LookAtTarget or it orbits without aiming, with the drag gated by a OneModifier composite in the actions asset. (a project docs/pitfalls/camera.md:17-31.)

  • DEVICE-SIMULATOR-TAB-DISABLES-THE-MOUSE — a Device Simulator window that exists anywhere in the editor layout, even as a hidden tab behind another window, owns Screen and switches the native Mouse and Pen OFF. Its Input System plugin disables them in OnCreate and again on every Added/Reconnected device change, so the device reset that comes with each Play re-disables them; the game then accepts only touches injected from the simulator's own screen, which nobody can see. Symptom: the Game view renders, nothing responds, nothing logs, and "it worked last Play" (the simulator tab was in front then). Probe InputSystem.devices: Mouse (disabled) next to Device Simulator Touchscreen is the whole diagnosis - close the simulator tab or bring it to the front and click there, and leave the game's input code alone. A synthetic QueueStateEvent cannot stand in for the click (INJECTED-INPUT-RESPECTS-GAME-VIEW-FOCUS in the Live play testing via Unity MCP page); read the device list instead. (Seen in practice: Unity 6000.5 + Input System 1.20: a card game's Home screen dead for a whole session, blamed on the second match.)