diff --git a/apps/overlay/src/js/app.js b/apps/overlay/src/js/app.js index 66a200d..a1f0162 100644 --- a/apps/overlay/src/js/app.js +++ b/apps/overlay/src/js/app.js @@ -138,61 +138,6 @@ let syntheticGamepad = null; // Keeps orientation, gravity tracking, stillness & sensor-fusion bias // calibration, and all related scratch vectors internal. See #224. const gyroFusion = new SensorFusion(); - -// ── Quaternion-direct driver state (Steam Controller) ── -// Scratch THREE.Quaternions for the quaternion fast-path. The reference -// is captured on first valid orientation report (or whenever recalibrate -// fires) and used to express subsequent reports as relative rotations. -const _steamRawQuat = new THREE.Quaternion(); -const _steamDelta = new THREE.Quaternion(); -const _steamRefQuatInverse = new THREE.Quaternion(); -let _steamRefQuat = null; -function recalibrateSteamReference() { _steamRefQuat = null; } - -// Runtime-switchable axis transform from the IMU's body frame to the -// visualizer's world frame. Different IMUs use different conventions -// (X-forward vs Y-forward, Z-up vs Z-down, handedness). Each transform -// permutes / negates the quaternion (x, y, z) components — W is the -// scalar and stays put. Switch via DevTools: -// setSteamQuatTransform('swap-yz') -// after calibrating with L3+R3 if results look stuck. -const STEAM_QUAT_TRANSFORMS = { - 'identity': (x, y, z) => [x, y, z], - 'swap-yz': (x, y, z) => [x, z, -y], // rotate -90° about X (IMU forward→up swap) - 'swap-yz-pos': (x, y, z) => [x, -z, y], // rotate +90° about X — roll axis correct, pitch goes to yaw - 'swap-xy': (x, y, z) => [y, x, z], // swap X↔Y - 'swap-xz': (x, y, z) => [z, y, x], // swap X↔Z - 'switch-pro-like':(x, y, z) => [-z, y, -x], // Switch Pro's gyro-frame remap - 'neg-x': (x, y, z) => [-x, y, z], - 'neg-y': (x, y, z) => [x, -y, z], - 'neg-z': (x, y, z) => [x, y, -z], - 'neg-xz': (x, y, z) => [-x, y, -z], // 180° about Y - // Compositions building on swap-yz-pos (which got roll right) and - // adding an X↔Y swap to move pitch off the yaw axis onto the pitch axis. - 'yzp-xy': (x, y, z) => [-z, x, y], // swap-yz-pos + swap-xy - 'yzp-xy-pos': (x, y, z) => [-z, -x, y], // ...with X sign flipped - 'yzp-xy-alt': (x, y, z) => [z, x, y], - 'yzp-xy-alt-neg': (x, y, z) => [z, -x, y], - // Cyclic permutations - 'cycle-zxy': (x, y, z) => [z, x, y], - 'cycle-yzx': (x, y, z) => [y, z, x], -}; -// Default: 'yzp-xy-alt' = (z, x, y). Field-tested as best-of-options -// for the 2026 Steam Controller — produces correct pitch + roll with -// the driver reading bytes 31-38 as a 4-component quaternion. Yaw -// signal is weak (~10× smaller than pitch/roll per Test Report -// variance analysis) and is not visually convincing. Call -// setSteamQuatTransform('mode-name') in DevTools to try alternates. -let steamQuatTransform = 'yzp-xy-alt'; -window.setSteamQuatTransform = function(mode) { - if (!STEAM_QUAT_TRANSFORMS[mode]) { - console.warn(`Unknown transform '${mode}'. Available: ${Object.keys(STEAM_QUAT_TRANSFORMS).join(', ')}`); - return; - } - steamQuatTransform = mode; - recalibrateSteamReference(); - console.log(`Steam quat transform: ${mode} (reference recaptured)`); -}; // App-layer calibration still owns variance-check + retry UX — on success // it pushes the captured bias into gyroFusion.bias. let calibrating = false; @@ -928,13 +873,7 @@ async function connectControllerGyro() { if (isPuckDevice(device)) onPuckConnected(); else onPuckDisconnected(); - // Skip calibration for drivers that don't emit raw gyro rates (e.g. - // Steam Controller, whose IMU is a quaternion that the rate-based - // calibration pipeline can't consume — leaving calibrating=true would - // hang the "Calibrating…" hint forever waiting for samples). - if (controllerDriver.constructor.emitsRawGyro !== false) { - startCalibration(); - } + startCalibration(); // The driver's init() has run an IMU-layout probe (PlayStation family // only, for now) and set _detectedImuFamily if a wire-level signature @@ -1009,7 +948,6 @@ async function disconnectGyro() { gyroPermitted = false; syntheticGamepad = null; _firstReportLogged = false; - recalibrateSteamReference(); // Shared SensorFusion owns orientation + all intermediate state. gyroFusion.reset(); gyroFusion.resetBias(); @@ -1048,18 +986,8 @@ function loop() { checkCombo(gamepad, 'gyroToggle', toggleGyro); checkCombo(gamepad, 'calibrate', () => { if (gyroActive) { - // Quaternion-direct drivers (Steam Controller) use the - // reference-capture model: clearing _steamRefQuat causes the - // next parsed.orientation to become the new "rest." Rate-based - // drivers go through the SensorFusion bias-estimation path. - if (controllerDriver?.constructor?.emitsRawGyro === false) { - recalibrateSteamReference(); - showCalibHint('Recalibrated', 2000); - console.log('Quaternion reference re-captured'); - } else { - startCalibration(); - console.log('Gyro recalibrating'); - } + startCalibration(); + console.log('Gyro recalibrating'); } }); } @@ -1514,34 +1442,6 @@ function handleInputReport(event) { overlay.updateTouchpad(parsed.touchpad, parsed.touchpadButton); } - // Quaternion fast-path: drivers that emit orientation directly (Steam - // Controller — IMU is a quaternion at offsets 31-38 of the STATE report) - // bypass the rate-based SensorFusion pipeline. - // - // The raw quaternion is in the controller's body frame, which doesn't - // align with the visualizer's identity orientation — e.g. the Steam - // Controller emits ~(−0.58, 0, −0.09, 0.49) when flat on a desk, not - // identity. We capture the FIRST valid quaternion as a reference and - // emit each subsequent one as a delta against it: delta = current · - // ref⁻¹. This makes "wherever the controller was when you started" - // the on-screen rest position, regardless of physical orientation. - // The calibrate combo (L3+R3) re-captures the reference, same way the - // rate-based gyro calibration recenters drift. - if (parsed.orientation && gyroActive) { - const p = parsed.orientation; - const [tx, ty, tz] = STEAM_QUAT_TRANSFORMS[steamQuatTransform](p.x, p.y, p.z); - _steamRawQuat.set(tx, ty, tz, p.w).normalize(); - if (!_steamRefQuat) { - _steamRefQuat = _steamRawQuat.clone(); - } - // delta = current * ref⁻¹ (post-multiply = rotation since reference) - _steamDelta.copy(_steamRawQuat).multiply( - _steamRefQuatInverse.copy(_steamRefQuat).invert() - ); - gyroFusion.orientation.copy(_steamDelta); - return; - } - if (!gyroActive || !parsed.gyro) return; const now = performance.now(); diff --git a/packages/core/src/drivers/steam-controller-driver.js b/packages/core/src/drivers/steam-controller-driver.js index 8ba17a0..0d44b6d 100644 --- a/packages/core/src/drivers/steam-controller-driver.js +++ b/packages/core/src/drivers/steam-controller-driver.js @@ -53,14 +53,11 @@ const FEATURE_REPORT_ID_FALLBACK = 0x02; export class SteamControllerDriver extends ControllerDriver { - // Steam Controller emits a quaternion (parsed.orientation), not raw - // gyro rates. The app's rate-based calibration UX should skip itself - // for this driver — checks this flag and avoids the "Calibrating…" - // hint that would otherwise hang forever waiting for raw-rate samples - // that never come. The visualizer body still rotates: app.js's fast - // path writes parsed.orientation directly into gyroFusion.orientation, - // bypassing the rate-based integrate-and-correct loop entirely. - static emitsRawGyro = false; + // Steam Controller emits raw 3-axis gyro (bytes 39-44, ±2000 dps) + // and 3-axis accel (bytes 33-38, ±2g) — same rate-based encoding as + // DualSense, so it flows through the standard SensorFusion pipeline. + // emitsRawGyro=true (the default in the base class) opts back into + // the calibration UX, which is correct for this driver. // Valve's HID interfaces are vendor-defined (usage page 0xFF0x), not // the standard gamepad usage. Filter on vid:pid only or the picker @@ -371,35 +368,47 @@ export class SteamControllerDriver extends ControllerDriver { }, ]; - // IMU encoding (2026 firmware) — STILL UNDER INVESTIGATION. + // IMU encoding (2026 firmware) — determined by per-axis variance + // analysis of a real Test Report capture (see issue #8). The + // 53-byte STATE report's IMU layout is: // - // SteamlessController docs say quaternion at WebHID data[31-38] - // (4× int16 LE), but our captures show data[29-32] is a uint32 LE - // timestamp on this firmware. Test Report variance analysis showed - // bytes 33-38 carry the actual IMU signal (pitch primarily in - // bytes 35-36, roll primarily in bytes 33-34), but neither the - // compressed-quaternion (X,Y,Z + derived W) interpretation nor any - // axis transform matched physical motion correctly. + // bytes 29-32 = uint32 LE timestamp (~1 MHz clock tick) + // bytes 33-38 = 3-axis accelerometer (int16 LE per axis, ±2g full scale) + // bytes 39-44 = 3-axis gyroscope (int16 LE per axis, ±2000 dps) + // bytes 45+ = always-zero padding // - // Reverting to the previously field-tested layout: read bytes - // 31-38 as 4 int16 LE and feed all four into Three.js's quaternion - // as (x, y, z, w). One component is timestamp garbage, but - // Three.js's normalize() flattens it out enough that pitch + roll - // visually track physical motion when combined with the - // 'yzp-xy-alt' transform in app.js. Yaw remains weak on this - // path (~10× smaller signal than pitch/roll per Test Report). + // This differs from SteamlessController's documented layout (which + // claims a 4-int16 quaternion at data[31-38]) — that overlaps the + // timestamp on 2026 firmware. The rate-based encoding is identical + // to DualSense's, so parsed.gyro + parsed.accel flow through the + // standard SensorFusion pipeline; orientation/calibration/drift- + // correction all work the same way as on Sony controllers, with no + // need for a quaternion fast-path. // - // Better encoding TBD — possibly Euler angles in radians, or a - // different scale/sign convention. Re-investigate when there's - // time. - const QUAT_SCALE = 1 / 0x7FFF; - const orientation = { - x: r(data, 31) * QUAT_SCALE, - y: r(data, 33) * QUAT_SCALE, - z: r(data, 35) * QUAT_SCALE, - w: r(data, 37) * QUAT_SCALE, + // At-rest verification on real hardware: + // gyro all axes: 0 ± 0.2 dps (perfect zero-bias) + // accel Z: ≈ 0.5 normalized = 1.0g at ±2g scale (gravity vector) + // + // Body-to-visualizer frame remap: swap Y↔Z on both gyro AND accel. + // The Steam Controller's IMU body frame has +Z pointing up (gravity + // reads as +1g on body-Z when flat face-up). The visualizer's + // SensorFusion expects gravity along world +Y (Three.js "Y up" + // convention, same as DualSense). Swapping Y and Z on accel moves + // gravity to body-Y so fusion converges to identity at rest instead + // of slerping for ~5 seconds. The matching swap on gyro keeps + // rotations consistent: physical roll (rotation about body Y, which + // becomes body Z after swap) maps to visualizer roll axis; physical + // yaw (body Z → body Y after swap) maps to visualizer yaw axis. + const gyro = { + x: r(data, 39), + y: r(data, 43), + z: -r(data, 41), + }; + const accel = { + x: r(data, 33), + y: r(data, 37), + z: -r(data, 35), }; - return { sticks, @@ -408,11 +417,10 @@ export class SteamControllerDriver extends ControllerDriver { paddles, touchpad, touchpadButton: false, - gyro: null, - accel: null, - orientation, - gyroScale: 2000.0 / 32768.0, - accelScale: 1.0 / 8192.0, + gyro, + accel, + gyroScale: 2000.0 / 32768.0, // ±2000 dps, 16-bit + accelScale: 1.0 / 16384.0, // ±2g, 16-bit (gravity ≈ 16384) }; } diff --git a/packages/visualizer/src/controller-profiles.js b/packages/visualizer/src/controller-profiles.js index 056a7aa..17983f1 100644 --- a/packages/visualizer/src/controller-profiles.js +++ b/packages/visualizer/src/controller-profiles.js @@ -262,6 +262,11 @@ export const PROFILES = { triggerMaxAngle: 0.52, stickMaxTilt: 0.26, hasGyro: true, + // Axis remap is applied inside the driver itself (Y↔Z swap on both + // gyro and accel — see steam-controller-driver.js parseReport). + // gyroTransform here is informational; the field isn't actively + // consumed anywhere in the pipeline, but the identity transform + // documents that no further visualizer-side rotation is needed. gyroTransform: (gx, gy, gz) => [gx, gy, gz], hasTouchpad: false, // touchpads exist on hardware but not as sub-meshes bodyMeshes: ['node_0'],