VR keyboard touch typing, on a controller bridge from the SteamVR dashboard

keyboard.vr.touchTyping (off by default): a key is pressed when a
controller's tip, where SteamVR's laser starts (/pose/tip: the device pose
times the render model's "tip" component), passes through it from the
front; released when pulled back 1 cm, re-armed 5 mm in front. Both hands,
also at once. Keys go through the keyboard's own HandleTouchStart /
HandleTouchEnd, like a laser press (Backspace repeats while held). Options
depth (cm) and haptics.

The geometry lives in a reusable controller bridge (vr-keyboard-controllers,
internal option): a systemui patch reads the keyboard's pose with SteamVR's
SGQueryService (an empty transform of ours in the keyboard's mount) and both
controllers' poses, and streams per hand the tip, the laser's hit on the
keyboard and the trigger (~90 Hz while relevant) through the
vr-keyboard-controllers-relay service to __sfuiControllers in Steam's
SharedJSContext. Tests: flake checks vr-keyboard-controllers and
vr-keyboard-touch.
This commit is contained in:
Pierre Kisters committed 2026-10-01 03:30:01 +02:00
1 parent 7d050b8c44
commit 224715d926
19 files changed
+1042 -8

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
// relay.mjs (user service vr-keyboard-controllers-relay): carries the
// controller frames from SteamVR's systemui page (bridge-patch.js, 8087; CDP
// binding __sfuiCtlOut) to Steam's SharedJSContext (hub.js, 8080:
// __sfuiControllers.frame(frame)). Frames are rebuilt from their numbers
// (nothing else passes). If the systemui page goes away: lost().
const SIDES = {
steam: { url: 'http://127.0.0.1:8080/json/list', title: 'SharedJSContext' },
vr: { url: 'http://127.0.0.1:8087/json/list', title: 'systemui', binding: '__sfuiCtlOut' },
};
const live = {}; // side -> evaluate(expression)
const num = (x) => (Number.isFinite(x) ? +x : null);
const point = (p, keys) => {
if (!p || typeof p !== 'object') return null;
const o = {};
for (const k of keys) { o[k] = num(p[k]); if (o[k] === null) return null; }
return o;
};
const hand = (h) => (h && typeof h === 'object'
? { tip: point(h.tip, ['u', 'v', 'd']), ray: point(h.ray, ['u', 'v', 'dist']), trigger: num(h.trigger) }
: null);
function clean(m) {
if (!m || typeof m !== 'object' || !Number.isFinite(m.seq) || !Number.isFinite(m.t)) return null;
const f = { seq: m.seq, t: m.t, moving: !!m.moving, keyboard: point(m.keyboard, ['width']), hands: { left: hand(m.hands?.left), right: hand(m.hands?.right) } };
for (const h of ['left', 'right']) if (f.hands[h] && !f.hands[h].tip) f.hands[h] = null;
return f;
}
function forward(payload) {
let m;
try { m = clean(JSON.parse(payload)); } catch { return; }
if (m) live.steam?.(`window.__sfuiControllers?.frame(${JSON.stringify(m)})`);
}
async function connect(side) {
const cfg = SIDES[side];
const list = await (await fetch(cfg.url, { signal: AbortSignal.timeout(3000) })).json();
const t = list.find((x) => x.title === cfg.title && x.webSocketDebuggerUrl);
if (!t) throw new Error(`${cfg.title} not found`);
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
let id = 0;
const send = (method, params = {}) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: ++id, method, params })); };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.method === 'Runtime.bindingCalled' && m.params.name === cfg.binding) forward(m.params.payload);
};
const closed = new Promise((res) => { ws.onclose = res; });
if (cfg.binding) { send('Runtime.enable'); send('Runtime.addBinding', { name: cfg.binding }); }
live[side] = (expression) => send('Runtime.evaluate', { expression });
console.log(`${side}: connected`);
await closed;
delete live[side];
if (side === 'vr') live.steam?.('window.__sfuiControllers?.lost()');
console.log(`${side}: disconnected`);
}
for (const side of Object.keys(SIDES)) {
(async () => {
for (let failed = false; ; await new Promise((r) => setTimeout(r, 3000))) {
try { await connect(side); failed = false; } catch (e) { if (!failed) console.error(`${side}: ${e.message}`); failed = true; }
}
})();
}