mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 03:00:13 +02:00
The controller bridge reads the poses with setTimeout instead of a fixed 11 ms interval: the next read after (distance - 10 cm) / 2.5 m/s, 11-250 ms, the distance being the nearest tip's to the keyboard's rect plus a margin, so a hand up to 2.5 m/s is read within 10 cm before it reaches the surface (the tracker's crossing and 8 cm jump guard see 11 ms steps as before). Full rate during a demand or with a pulled trigger. Without touch typing (controllers.continuous, set by vr-keyboard-touch) no reads while the keyboard is shown until a swipe demands them; a demand starting reads at once. Bridge VERSION 6. Tests with a fake clock: bridge.test.mjs.
215 lines
11 KiB
JavaScript
215 lines
11 KiB
JavaScript
// The controller bridge (vr-keyboard-controllers.nix; patch name
|
|
// "vr-keyboard-controllers"), injected into SteamVR's systemui page (8087):
|
|
// both controllers relative to Steam's VR keyboard, streamed to Steam's
|
|
// SharedJSContext (relay.mjs -> hub.js). mkPatch convention plus one more
|
|
// argument: geometry.js.
|
|
//
|
|
// While SteamVR shows the keyboard (systemui mounts its overlay, mountedId
|
|
// "...gamepadui.keyboard", only then, also without the dashboard, in a
|
|
// transform scaled to its size; the overlay hangs from its top edge, 1
|
|
// overlay width = 1 unit there):
|
|
// - its pose: an empty vsg-transform of ours (id PROBE) in that transform,
|
|
// read with SteamVR's own SGQueryService.requestSGTransform every POLL_MS;
|
|
// for MOVE_QUIET_MS after it moved (drag, dashboard re-latch) frames say
|
|
// moving: true;
|
|
// - per tick both controllers: VRHTML.GetPose(hand, Standing) times the
|
|
// render model's "tip" component (SteamVR's laser origin) -> the tip and
|
|
// the laser's hit on the keyboard (geometry.js), the trigger from the
|
|
// render model's animated "trigger" component (null without one).
|
|
// Ticks (setTimeout, one at a time): with opts.continuous (touch typing)
|
|
// the next after (reach - NEAR) / VMAX, clamped to TICK_MS..SLOW_MS, reach
|
|
// = the nearest tip's distance to the keyboard's rect plus a margin (none:
|
|
// SLOW_MS). A tip at up to VMAX is thus sampled within NEAR before it can
|
|
// reach the surface, at TICK_MS from there (contacts: tracker.js). TICK_MS
|
|
// while Steam asks for it (S.demand(ms), hub.js demand: a laser press) or a
|
|
// trigger is pulled. Without opts.continuous (only the two-handed swipe):
|
|
// ticks only during a demand; a demand starting one ticks at once.
|
|
// Frames go out through the CDP binding __sfuiCtlOut(json) per tick while
|
|
// a hand is relevant (reach < NEAR or trigger pulled) or demanded, else
|
|
// every IDLE_MS; one { keyboard: null } frame when the keyboard goes. While
|
|
// it is hidden: no frames and no ticks, one DOM lookup per POLL_MS.
|
|
// Frame (hub.js adds page px):
|
|
// { seq, t, moving, keyboard: { width } | null,
|
|
// hands: { left, right: null | { tip: { u, v, d }, ray: { u, v, dist } | null,
|
|
// trigger: 0..1 | null } } }
|
|
// Never touches SteamVR's input or the lasers. Debugging: __sfuiCtl.log,
|
|
// __sfuiCtl.last (the last frame), __sfuiCtl.delay (the next tick's ms;
|
|
// null: none), __sfuiCtl.ticks (count).
|
|
((find, sigs, opts, hooks, GEO) => {
|
|
const VERSION = 6;
|
|
const G = window;
|
|
const TICK_MS = 11, SLOW_MS = 250, IDLE_MS = 1000, POLL_MS = 250, MOVE_QUIET_MS = 300, TIP_CACHE_MS = 5000;
|
|
const NEAR = 0.1; // m
|
|
const VMAX = 2.5; // m/s, a fast hand
|
|
const RECT = { u0: -0.2, u1: 1.2, v0: -0.2, v1: 0.7 }; // keyboard widths: the keyboard (v 0..~0.33) plus a margin
|
|
const TRIGGER_DEG = 12.5; // frame controller: trigger component's full travel
|
|
const PROBE = 'sfui-ctl-keyboard';
|
|
const HANDS = ['left', 'right'];
|
|
const STANDING = 1; // ETrackingUniverseOrigin, as SteamVR's dashboard reads poses
|
|
const stamp = `${VERSION}:${GEO.VERSION} ${JSON.stringify(opts)}`;
|
|
if (G.__sfuiCtl?.stamp === stamp) return 'unchanged';
|
|
const V = G.VRHTML;
|
|
const missing = [
|
|
typeof V?.GetPose !== 'function' && 'VRHTML.GetPose',
|
|
typeof V?.VRRenderModels?.GetComponentStateForDevicePath !== 'function' && 'VRHTML.VRRenderModels.GetComponentStateForDevicePath',
|
|
typeof V?.VRProperties?.GetStringProperty !== 'function' && 'VRHTML.VRProperties.GetStringProperty',
|
|
typeof V?.NextSGID !== 'function' && 'VRHTML.NextSGID',
|
|
typeof V?.VROverlay?.ThisOverlayKey !== 'function' && 'VRHTML.VROverlay.ThisOverlayKey',
|
|
typeof G.SGQueryService?.requestSGTransform !== 'function' && 'SGQueryService.requestSGTransform',
|
|
typeof G.forceLayoutUpdate !== 'function' && 'forceLayoutUpdate',
|
|
].filter(Boolean);
|
|
if (missing.length) return `SteamVR internals changed, no controller bridge: missing ${missing.join(', ')}`;
|
|
try { G.__sfuiCtl?.dispose?.(); } catch { /* gone */ }
|
|
|
|
const S = G.__sfuiCtl = { stamp, log: [], kb: null, last: null, delay: null, ticks: 0 };
|
|
const log = (...a) => { S.log.push([Math.round(performance.now()), ...a]); if (S.log.length > 100) S.log.shift(); };
|
|
let seq = 0, sentAt = -Infinity, wasRelevant = false;
|
|
const send = (f) => {
|
|
S.last = f;
|
|
sentAt = f.t;
|
|
try { G.__sfuiCtlOut?.(JSON.stringify(f)); } catch (e) { log('send', String(e)); }
|
|
};
|
|
|
|
// ---- the keyboard -----------------------------------------------------------
|
|
const fiberProps = (e) => {
|
|
const k = Object.keys(e).find((x) => x.startsWith('__reactFiber$'));
|
|
let f = k && e[k];
|
|
for (let i = 0; f && i < 6; f = f.return, i++) if (f.memoizedProps?.mountedId) return f.memoizedProps;
|
|
return null;
|
|
};
|
|
let scaled = null, probe = null;
|
|
function findScaled() {
|
|
if (scaled?.isConnected) return scaled;
|
|
scaled = null;
|
|
for (const e of document.querySelectorAll('[vsg-type="mountedscenegraph"]')) {
|
|
if (String(fiberProps(e)?.mountedId ?? '').endsWith('gamepadui.keyboard') && e.parentElement?.tagName === 'VSG-TRANSFORM') {
|
|
scaled = e.parentElement;
|
|
break;
|
|
}
|
|
}
|
|
return scaled;
|
|
}
|
|
function ensureProbe(parent) {
|
|
if (probe?.parentElement === parent) return;
|
|
probe?.remove();
|
|
probe = document.createElement('vsg-transform');
|
|
for (const [k, v] of Object.entries({ translation: '0 0 0', rotation: '1 0 0 0', scale: '1 1 1', sgid: V.NextSGID() }))
|
|
probe.setAttribute(k, String(v));
|
|
probe.id = PROBE;
|
|
parent.appendChild(probe);
|
|
G.forceLayoutUpdate();
|
|
}
|
|
const removeProbe = () => { if (probe) { probe.remove(); probe = null; G.forceLayoutUpdate(); } };
|
|
|
|
let pending = false, movedAt = -Infinity;
|
|
async function pollKeyboard() {
|
|
pending = true;
|
|
try {
|
|
let timeout;
|
|
const xf = await Promise.race([
|
|
G.SGQueryService.requestSGTransform(`${V.VROverlay.ThisOverlayKey()}::${PROBE}`),
|
|
new Promise((_, rej) => { timeout = setTimeout(() => rej(new Error('timeout')), 1000); }),
|
|
]).finally(() => clearTimeout(timeout));
|
|
if (!probe) return;
|
|
let ow = 1;
|
|
try { ow = V.VROverlay.GetWidthInMeters(V.VROverlay.FindOverlay('valve.steam.gamepadui.keyboard')) || 1; } catch { /* 1 */ }
|
|
const kb = { translation: xf.translation, rotation: xf.rotation, width: xf.scale.x * ow };
|
|
const o = S.kb;
|
|
if (!o || Math.hypot(kb.translation.x - o.translation.x, kb.translation.y - o.translation.y, kb.translation.z - o.translation.z) > 0.002 ||
|
|
Math.abs(kb.rotation.w * o.rotation.w + kb.rotation.x * o.rotation.x + kb.rotation.y * o.rotation.y + kb.rotation.z * o.rotation.z) < 0.99998 ||
|
|
Math.abs(kb.width - o.width) > 0.002) movedAt = performance.now();
|
|
S.kb = kb;
|
|
if (!o && shown && timer === null && (opts.continuous || performance.now() < fastUntil)) tick();
|
|
} catch (e) { log('keyboard pose', String(e?.message ?? e)); } finally { pending = false; }
|
|
}
|
|
|
|
// ---- the controllers ------------------------------------------------------------
|
|
const models = {}; // hand -> { at, rm, tip }
|
|
function model(hand, now) {
|
|
const c = models[hand];
|
|
if (c && now - c.at < TIP_CACHE_MS) return c;
|
|
const path = `/user/hand/${hand}`;
|
|
let rm = null, tip = null;
|
|
try {
|
|
rm = V.VRProperties.GetStringProperty(path, 1003) || null; // Prop_RenderModelName_String
|
|
tip = rm ? V.VRRenderModels.GetComponentStateForDevicePath(rm, 'tip', path)?.xfTrackingToComponentLocal ?? null : null;
|
|
} catch { /* the device pose */ }
|
|
return (models[hand] = { at: now, rm, tip });
|
|
}
|
|
function trigger(hand, rm) {
|
|
try {
|
|
const q = rm && V.VRRenderModels.GetComponentStateForDevicePath(rm, 'trigger', `/user/hand/${hand}`)?.xfTrackingToComponentRenderModel?.rotation;
|
|
return q ? Math.min(1, GEO.rotationDegrees(q) / TRIGGER_DEG) : null;
|
|
} catch { return null; }
|
|
}
|
|
function handFrame(hand, now) {
|
|
const [p] = V.GetPose(`/user/hand/${hand}`, STANDING) || [];
|
|
if (!p?.bPoseIsValid) return null;
|
|
const m = model(hand, now);
|
|
const pose = GEO.tipPose(p.xfDeviceToAbsoluteTracking, m.tip);
|
|
return { tip: GEO.toKeyboard(pose.translation, S.kb), ray: GEO.rayHit(pose, S.kb), trigger: trigger(hand, m.rm) };
|
|
}
|
|
// The tip's distance (m) to RECT.
|
|
const reach = (h) => {
|
|
const { u, v, d } = h.tip, w = S.kb.width;
|
|
return Math.hypot(Math.max(RECT.u0 - u, 0, u - RECT.u1) * w, Math.max(RECT.v0 - v, 0, v - RECT.v1) * w, d);
|
|
};
|
|
const pulled = (h) => !!h && h.trigger >= 0.5;
|
|
const relevant = (h) => !!h && (reach(h) < NEAR || pulled(h));
|
|
|
|
// ---- loops ---------------------------------------------------------------------
|
|
// shown: the keyboard is (POLL_MS loop); timer/dueAt: the next tick.
|
|
let shown = false, timer = null, dueAt = Infinity, fastUntil = -Infinity;
|
|
function schedule(ms) {
|
|
clearTimeout(timer);
|
|
S.delay = ms;
|
|
if (ms === null) { timer = null; dueAt = Infinity; return; }
|
|
dueAt = performance.now() + ms;
|
|
timer = setTimeout(tick, ms);
|
|
}
|
|
// The next tick's delay (ms) after hands at now; null: none.
|
|
function delay(hands, now) {
|
|
if (now < fastUntil || HANDS.some((h) => pulled(hands[h]))) return TICK_MS;
|
|
if (!opts.continuous) return null;
|
|
const r = Math.min(...HANDS.map((h) => (hands[h] ? reach(hands[h]) : Infinity)));
|
|
if (!(r < Infinity)) return SLOW_MS;
|
|
return Math.min(SLOW_MS, Math.max(TICK_MS, ((r - NEAR) / VMAX) * 1000));
|
|
}
|
|
S.demand = (ms) => {
|
|
const now = performance.now();
|
|
fastUntil = now + Math.max(0, Math.min(5000, +ms || 0));
|
|
if (shown && now < fastUntil && dueAt - now > TICK_MS) tick();
|
|
};
|
|
function tick() {
|
|
clearTimeout(timer); timer = null; dueAt = Infinity;
|
|
if (!S.kb) { S.delay = null; return; } // pollKeyboard starts it
|
|
S.ticks++;
|
|
const now = performance.now(), hands = {};
|
|
for (const h of HANDS) {
|
|
try { hands[h] = handFrame(h, now); } catch (e) { hands[h] = null; log('pose', String(e)); }
|
|
}
|
|
const rel = now < fastUntil || HANDS.some((h) => relevant(hands[h]));
|
|
if (rel || wasRelevant || now - sentAt >= IDLE_MS) {
|
|
send({ seq: ++seq, t: now, moving: now - movedAt < MOVE_QUIET_MS, keyboard: { width: S.kb.width }, hands });
|
|
}
|
|
wasRelevant = rel;
|
|
schedule(delay(hands, now));
|
|
}
|
|
function deactivate() {
|
|
if (!shown) return;
|
|
shown = false; schedule(null);
|
|
send({ seq: ++seq, t: performance.now(), moving: false, keyboard: null, hands: { left: null, right: null } });
|
|
log('inactive');
|
|
}
|
|
const slow = setInterval(() => {
|
|
const parent = findScaled();
|
|
if (!parent) { deactivate(); S.kb = null; removeProbe(); return; }
|
|
ensureProbe(parent);
|
|
if (!shown) { shown = true; log('active'); if (opts.continuous || performance.now() < fastUntil) tick(); }
|
|
if (!pending) pollKeyboard();
|
|
}, POLL_MS);
|
|
|
|
S.dispose = () => { clearInterval(slow); deactivate(); removeProbe(); };
|
|
return 'patched';
|
|
})
|