Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 3 additions & 103 deletions apps/overlay/src/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
}
});
}
Expand Down Expand Up @@ -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();
Expand Down
84 changes: 46 additions & 38 deletions packages/core/src/drivers/steam-controller-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/visualizer/src/controller-profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down