diff --git a/docs/keyboard.md b/docs/keyboard.md index 5722f53..c10ad41 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -258,7 +258,11 @@ Delete repeat and the swipe patch's gestures react to the laser only. Depends on while a hand's tip is within 10 cm of the keyboard, its trigger is pulled or Steam asks for it (`__sfuiControllers.demand(ms)`, back through the relay: the swipe patch during a press), else every second; nothing - while the keyboard is hidden. The + while the keyboard is hidden. The poses are read (~0.15 ms each) only as + often as needed: with touch typing the next read comes after + (distance − 10 cm) / 2.5 m/s, 11–250 ms (4 per second with the hands + far), so even a fast hand is read within 10 cm before it can reach the + surface; with only `swipe.twoHanded` only during a demand. The laser hit matches SteamVR's laser to a few px. - **Touch typing** (patch `vr-keyboard-touch`, `SharedJSContext`): per hand `tracker.js` turns the tip's path into press and release, then calls the @@ -268,9 +272,12 @@ Delete repeat and the swipe patch's gestures react to the laser only. Depends on `vr-keyboard-controllers`). **Tests:** `nix flake check` (checks `vr-keyboard-controllers`: tip and -laser relative to the keyboard; `vr-keyboard-touch`: contact, hysteresis, -two hands, the touch list Steam's highlight and long press read). +laser relative to the keyboard, the bridge's read rate and demand with a +fake clock, a fast tap through the contact tracker; `vr-keyboard-touch`: +contact, hysteresis, two hands, the touch list Steam's highlight and long +press read). **Debugging:** `window.__sfuiTouchTypeLog` (`SharedJSContext`: presses, misses), `__sfuiControllers.last` (the last frame), and in `systemui` -`__sfuiCtl.log` and `__sfuiCtl.last`. +`__sfuiCtl.log`, `__sfuiCtl.last` and `__sfuiCtl.delay` (ms to the next +read; null: none). diff --git a/modules/vr-keyboard-controllers.nix b/modules/vr-keyboard-controllers.nix index 6449bd1..84199e9 100644 --- a/modules/vr-keyboard-controllers.nix +++ b/modules/vr-keyboard-controllers.nix @@ -5,7 +5,9 @@ # - vr-keyboard-controllers/bridge-patch.js (SteamVR systemui, 8087; patch # "vr-keyboard-controllers"): the keyboard's pose, per hand the tip (where # the laser starts), the laser's hit and the trigger (geometry.js), ~90 Hz -# while relevant or asked for (demand); CDP binding __sfuiCtlOut. +# near the keyboard or asked for (demand), slower with distance +# (`continuous`, set by touch typing), else only on demand; CDP binding +# __sfuiCtlOut. # - vr-keyboard-controllers/relay.mjs (user service # vr-keyboard-controllers-relay): to Steam's SharedJSContext; demand back # (CDP binding __sfuiCtlIn there). @@ -28,6 +30,12 @@ in { internal = true; description = "The controller bridge (set by the VR keyboard features that use it)."; }; + continuous = lib.mkOption { + type = lib.types.bool; + default = false; + internal = true; + description = "Sample the controllers all the time the keyboard is shown (touch typing), not only on demand (the two-handed swipe)."; + }; files = lib.mkOption { type = lib.types.attrsOf lib.types.path; readOnly = true; @@ -48,6 +56,7 @@ in { cp ${uiLib.mkPatch { name = "vr-keyboard-controllers"; src = ./vr-keyboard-controllers/bridge-patch.js; + opts = { inherit (cfg) continuous; }; extraArgs = [ ./vr-keyboard-controllers/geometry.js ]; }} $out node --check $out diff --git a/modules/vr-keyboard-controllers/bridge-patch.js b/modules/vr-keyboard-controllers/bridge-patch.js index e62b5c5..c6b1fa6 100644 --- a/modules/vr-keyboard-controllers/bridge-patch.js +++ b/modules/vr-keyboard-controllers/bridge-patch.js @@ -12,26 +12,36 @@ // 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; -// - every TICK_MS both controllers: VRHTML.GetPose(hand, Standing) times the +// - 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). -// Frames go out through the CDP binding __sfuiCtlOut(json) at TICK_MS while -// a hand is relevant (tip within NEAR m of the keyboard or trigger pulled) or -// Steam asked for it (S.demand(ms), hub.js demand: a laser press), else every -// IDLE_MS; one { keyboard: null } frame when the keyboard goes. While it is -// hidden: no frames and no TICK_MS loop, one DOM lookup per POLL_MS. +// 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.last (the last frame), __sfuiCtl.delay (the next tick's ms; +// null: none), __sfuiCtl.ticks (count). ((find, sigs, opts, hooks, GEO) => { - const VERSION = 5; + const VERSION = 6; const G = window; - const TICK_MS = 11, IDLE_MS = 1000, POLL_MS = 250, MOVE_QUIET_MS = 300, TIP_CACHE_MS = 5000; + 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']; @@ -51,7 +61,7 @@ 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 }; + 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) => { @@ -95,11 +105,11 @@ async function pollKeyboard() { pending = true; try { - let timer; + let timeout; const xf = await Promise.race([ G.SGQueryService.requestSGTransform(`${V.VROverlay.ThisOverlayKey()}::${PROBE}`), - new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timeout')), 1000); }), - ]).finally(() => clearTimeout(timer)); + 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 */ } @@ -109,6 +119,7 @@ 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; } } @@ -138,14 +149,41 @@ 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) }; } - const relevant = (h) => !!h && ((Math.abs(h.tip.d) < NEAR && h.tip.u > -0.2 && h.tip.u < 1.2 && h.tip.v > -0.2 && h.tip.v < 0.7) || - h.trigger >= 0.5); + // 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 --------------------------------------------------------------------- - let fast = null, fastUntil = -Infinity; // fast: the TICK_MS loop, only while the keyboard is shown - S.demand = (ms) => { fastUntil = performance.now() + Math.max(0, Math.min(5000, +ms || 0)); }; + // 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() { - if (!S.kb) return; + 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)); } @@ -155,10 +193,11 @@ 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 (fast === null) return; - clearInterval(fast); fast = null; + if (!shown) return; + shown = false; schedule(null); send({ seq: ++seq, t: performance.now(), moving: false, keyboard: null, hands: { left: null, right: null } }); log('inactive'); } @@ -166,7 +205,7 @@ const parent = findScaled(); if (!parent) { deactivate(); S.kb = null; removeProbe(); return; } ensureProbe(parent); - if (fast === null) { fast = setInterval(tick, TICK_MS); log('active'); } + if (!shown) { shown = true; log('active'); if (opts.continuous || performance.now() < fastUntil) tick(); } if (!pending) pollKeyboard(); }, POLL_MS); diff --git a/modules/vr-keyboard-controllers/check.nix b/modules/vr-keyboard-controllers/check.nix index 8971254..09b811e 100644 --- a/modules/vr-keyboard-controllers/check.nix +++ b/modules/vr-keyboard-controllers/check.nix @@ -1,7 +1,11 @@ # Tests of the controller bridge (flake check `vr-keyboard-controllers`): # tip and laser relative to the keyboard, the hub's page px -# (tests/geometry.test.mjs). +# (tests/geometry.test.mjs); the bridge's adaptive tick, demand and +# touch-off mode with a fake clock, a fast approach through +# vr-keyboard-touch's tracker.js (tests/bridge.test.mjs). { pkgs }: pkgs.runCommand "vr-keyboard-controllers-checks" { nativeBuildInputs = [ pkgs.nodejs ]; } '' + set -o pipefail node ${./tests/geometry.test.mjs} ${./geometry.js} ${./hub.js} | tee $out + node ${./tests/bridge.test.mjs} ${./bridge-patch.js} ${./geometry.js} ${../vr-keyboard-touch/tracker.js} | tee -a $out '' diff --git a/modules/vr-keyboard-controllers/tests/bridge.test.mjs b/modules/vr-keyboard-controllers/tests/bridge.test.mjs new file mode 100644 index 0000000..9bb48be --- /dev/null +++ b/modules/vr-keyboard-controllers/tests/bridge.test.mjs @@ -0,0 +1,253 @@ +// bridge-patch.js's tick loop in a fake systemui page (VRHTML poses, the +// keyboard's mount, SGQueryService) with a fake clock: the distance-adaptive +// tick, demand, opts.continuous, the hidden keyboard; a fast approach fed +// through vr-keyboard-touch's tracker.js. +// usage: node bridge.test.mjs +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; + +const SRC = readFileSync(process.argv[2], 'utf8'); +const GEO = (0, eval)(readFileSync(process.argv[3], 'utf8')); +const TR = (0, eval)(readFileSync(process.argv[4], 'utf8')); +let passed = 0; +const test = async (name, f) => { await f(); passed++; console.log(`ok ${name}`); }; + +// The keyboard: identity pose at the origin, 0.738 m wide (+z toward the viewer). +const KB = { translation: { x: 0, y: 0, z: 0 }, rotation: { w: 1, x: 0, y: 0, z: 0 }, width: 0.738 }; +const at = (u, v, front) => ({ x: (u - 0.5) * KB.width, y: -v * KB.width, z: front }); // tip in tracking space + +// A systemui page: tips by hand (fn of time -> point | null), the keyboard +// shown or not, a fake clock (timers run in time order by advance()). +function page({ continuous, tips = {}, shown = true }) { + let now = 1000, nextId = 1; + const timers = new Map(); // id -> { at, fn, every } + const env = { shown, tips, trigger: {}, frames: [], poses: 0 }; + const scaled = { tagName: 'VSG-TRANSFORM', appendChild(c) { c.parentElement = scaled; } }; + const mount = { __reactFiber$x: { memoizedProps: { mountedId: 'valve.steam.gamepadui.keyboard' } }, parentElement: scaled }; + Object.defineProperty(scaled, 'isConnected', { get: () => env.shown }); + const w = { + performance: { now: () => now }, + setTimeout: (fn, ms) => { timers.set(nextId, { at: now + Math.max(0, +ms || 0), fn }); return nextId++; }, + setInterval: (fn, ms) => { timers.set(nextId, { at: now + ms, fn, every: ms }); return nextId++; }, + clearTimeout: (id) => timers.delete(id), + clearInterval: (id) => timers.delete(id), + document: { + querySelectorAll: () => (env.shown ? [mount] : []), + createElement: () => ({ setAttribute() {}, remove() { this.parentElement = null; } }), + }, + forceLayoutUpdate() {}, + SGQueryService: { requestSGTransform: async () => ({ translation: KB.translation, rotation: KB.rotation, scale: { x: KB.width } }) }, + VRHTML: { + GetPose(path) { + env.poses++; + const p = env.tips[path.split('/').pop()]?.(now); + return [p ? { bPoseIsValid: true, xfDeviceToAbsoluteTracking: { translation: p, rotation: { w: 1, x: 0, y: 0, z: 0 } } } : { bPoseIsValid: false }]; + }, + NextSGID: () => 7, + VROverlay: { ThisOverlayKey: () => 'systemui', GetWidthInMeters: () => 1, FindOverlay: () => 1 }, + VRProperties: { GetStringProperty: () => 'frame' }, + VRRenderModels: { // no tip component; the trigger at env.trigger[hand] degrees + GetComponentStateForDevicePath(rm, c, path) { + const deg = env.trigger[path.split('/').pop()]; + if (c !== 'trigger' || deg === undefined) return null; + const h = (deg * Math.PI) / 360; + return { xfTrackingToComponentRenderModel: { rotation: { w: Math.cos(h), x: Math.sin(h), y: 0, z: 0 } } }; + }, + }, + }, + __sfuiCtlOut: (s) => env.frames.push(JSON.parse(s)), + }; + w.window = w; + vm.createContext(w); + const status = vm.runInContext(SRC, w)(null, {}, { continuous }, null, GEO); + assert.equal(status, 'patched'); + env.S = w.__sfuiCtl; + env.now = () => now; + // Runs timers due until now + ms (and the promises they start). + env.advance = async (ms) => { + const end = now + ms; + for (;;) { + await new Promise((r) => setImmediate(r)); + let next = null; + for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t]; + if (!next) break; + const [id, t] = next; + now = t.at; + if (t.every) t.at += t.every; else timers.delete(id); + t.fn(); + } + now = end; + await new Promise((r) => setImmediate(r)); + }; + // Tick times (ms) in the next ms. + env.ticksIn = async (ms) => { + const t0 = env.S.ticks, times = []; + const end = now + ms; + while (now < end) { + const before = env.S.ticks; + await env.advance(1); + if (env.S.ticks > before) times.push(now); + } + assert.ok(env.S.ticks - t0 >= times.length); + return times; + }; + return env; +} +const gaps = (ts) => ts.slice(1).map((t, i) => t - ts[i]); + +await test('the tick interval shrinks as a tip approaches; full rate within NEAR', async () => { + let front = 1; + const p = page({ continuous: true, tips: { left: () => at(0.5, 0.15, front), right: () => null } }); + await p.advance(600); // shown, keyboard pose read + const intervals = []; + for (front of [1, 0.6, 0.35, 0.2, 0.12, 0.08, 0.02]) { + await p.advance(300); + intervals.push(p.S.delay); + } + assert.deepEqual(intervals.map(Math.round), [250, 200, 100, 40, 11, 11, 11]); + for (let i = 1; i < intervals.length; i++) assert.ok(intervals[i] <= intervals[i - 1]); + // beside the keyboard: the distance to its rect (plus margin), not the plane + front = 0; + p.tips.left = () => at(1.2 + 0.5 / KB.width, 0.15, 0); // 0.5 m right of the margin + await p.advance(300); + assert.equal(Math.round(p.S.delay), 160); +}); + +await test('both hands untracked or far: SLOW_MS, frames every second', async () => { + const p = page({ continuous: true, tips: { left: () => null, right: () => at(0.5, 0.2, 2) } }); + await p.advance(500); + const ts = await p.ticksIn(2000); + assert.ok(gaps(ts).every((g) => g === 250), gaps(ts).join()); + const fs = p.frames.filter((f) => f.keyboard); + assert.ok(fs.length >= 2 && fs.length <= 3, `${fs.length} frames`); +}); + +await test('a fast approach (1 m at 2.5 m/s) is sampled within NEAR before contact and presses the key', async () => { + for (const offset of [0, 37, 101, 180]) { // phases of the slow loop + let start = Infinity; + // From 1 m in front at 2.5 m/s straight through the keyboard (key at u 0.42, v 0.14). + const p = page({ continuous: true, tips: { right: (t) => at(0.42, 0.14, t < start ? 1 : 1 - (t - start) * 0.0025), left: () => null } }); + await p.advance(500 + offset); + start = p.now(); + const tr = TR.createTracker(); + let down = null; + const contact = start + 400; // 1 m / 2.5 m/s + while (p.now() < contact + 40) { + const n = p.frames.length; + await p.advance(1); + for (const f of p.frames.slice(n)) { + if (!f.keyboard) continue; + const ev = tr.update(f.hands.right?.tip ?? null, f.t); + if (ev?.phase === 'down') down = { ...ev, t: f.t }; + } + } + const before = p.frames.filter((f) => f.keyboard && f.t < contact).map((f) => -f.hands.right.tip.d); + assert.ok(before.some((d) => d > 0 && d <= 0.1), `offset ${offset}: sampled in front within NEAR: ${before.map((d) => d.toFixed(3))}`); + assert.ok(Math.min(...before.filter((d) => d > 0)) <= 0.0275 + 1e-9, `offset ${offset}: last sample before contact within one tick`); + assert.ok(down, `offset ${offset}: pressed`); + assert.ok(Math.abs(down.u - 0.42) < 1e-6 && Math.abs(down.v - 0.14) < 1e-6); + assert.ok(down.t - contact <= 11 + 1e-9, `offset ${offset}: pressed ${down.t - contact} ms after contact`); + } +}); + +await test('demand: full rate regardless of distance, then back to adaptive', async () => { + const p = page({ continuous: true, tips: { left: () => at(0.5, 0.15, 1.5), right: () => null } }); + await p.advance(1000); + const t0 = p.S.ticks; + p.S.demand(300); + assert.equal(p.S.ticks, t0 + 1, 'ticks at once'); + const ts = await p.ticksIn(290); + assert.ok(gaps(ts).every((g) => g === 11), gaps(ts).join()); + const sent = p.frames.filter((f) => f.keyboard && f.t >= p.now() - 290); + assert.ok(sent.length >= 25, `frames at full rate: ${sent.length}`); + await p.advance(300); + assert.equal(p.S.delay, 250); + // demand(0) ends a lease + p.S.demand(2000); + await p.advance(50); + p.S.demand(0); + await p.advance(20); + assert.equal(p.S.delay, 250); +}); + +await test('a pulled trigger: full rate', async () => { + const p = page({ continuous: true, tips: { left: () => at(0.5, 0.15, 1.5), right: () => null } }); + await p.advance(600); + assert.equal(p.S.delay, 250); + p.trigger.left = 10; // 0.8 of its travel + await p.advance(300); + assert.equal(p.S.delay, 11); + assert.ok(p.now() - p.frames.at(-1).t < 11); + p.trigger.left = 2; + await p.advance(300); + assert.equal(p.S.delay, 250); +}); + +await test('touch typing off: no ticks while shown until a demand, the first at once', async () => { + const p = page({ continuous: false, tips: { left: () => at(0.5, 0.15, 0.02), right: () => at(0.5, 0.15, 1) } }); + await p.advance(3000); + assert.equal(p.S.ticks, 0); + assert.equal(p.poses, 0); + assert.equal(p.S.delay, null); + p.S.demand(800); + assert.equal(p.S.ticks, 1, 'the first demand ticks at once'); + assert.equal(p.frames.length, 1); + const ts = await p.ticksIn(780); + assert.ok(gaps(ts).every((g) => g === 11), gaps(ts).join()); + p.S.demand(800); // renewed: no extra tick + const n = p.S.ticks; + p.S.demand(800); + assert.equal(p.S.ticks, n); + p.S.demand(0); + await p.advance(1000); + const end = p.S.ticks; + await p.advance(3000); + assert.equal(p.S.ticks, end, 'stopped after the lease'); + assert.equal(p.S.delay, null); +}); + +await test('touch typing off: a demand before the keyboard pose is known ticks once it is', async () => { + const p = page({ continuous: false, tips: { left: () => at(0.5, 0.15, 0.3), right: () => null } }); + await p.advance(240); // not found yet (the first DOM poll at 250 ms) + p.S.demand(800); + assert.equal(p.S.ticks, 0); + await p.advance(15); // first poll -> pose -> tick + assert.ok(p.S.ticks >= 1); + assert.equal(p.frames[0].hands.left.tip.d, -0.3); +}); + +await test('hidden keyboard: no loop, no frames; one keyboard: null frame when it goes', async () => { + const p = page({ continuous: true, shown: false, tips: { left: () => at(0.5, 0.15, 0.02), right: () => null } }); + await p.advance(3000); + p.S.demand(800); + await p.advance(500); + assert.equal(p.S.ticks, 0); + assert.equal(p.poses, 0); + assert.equal(p.frames.length, 0); + p.shown = true; + await p.advance(600); + assert.ok(p.S.ticks > 20, 'shown, tip near: full rate'); + p.shown = false; + await p.advance(300); + const n = p.S.ticks; + assert.equal(p.frames.at(-1).keyboard, null); + await p.advance(3000); + assert.equal(p.S.ticks, n); + assert.equal(p.S.delay, null); +}); + +// Simulated cost: ticks per second (each ~0.15 ms in-page) in typical states. +{ + const rate = async (continuous, front) => { + const p = page({ continuous, tips: { left: () => at(0.5, 0.15, front), right: () => at(0.6, 0.15, front + 0.1) } }); + await p.advance(1000); + const t0 = p.S.ticks; + await p.advance(10000); + return (p.S.ticks - t0) / 10; + }; + console.log(`ticks/s (was 91 while shown): hands far ${await rate(true, 1)}, 30 cm ${await rate(true, 0.3)},` + + ` 5 cm ${await rate(true, 0.05)}; touch typing off ${await rate(false, 0.05)}`); +} + +console.log(`${passed} tests passed`); diff --git a/modules/vr-keyboard-touch.nix b/modules/vr-keyboard-touch.nix index e01c496..a38eb45 100644 --- a/modules/vr-keyboard-touch.nix +++ b/modules/vr-keyboard-touch.nix @@ -2,7 +2,7 @@ # key is pressed when a controller's tip (where SteamVR's laser starts) # passes through it. Independent of keyboard.vr.enable / extraKeys. # - The tips relative to the keyboard come from the controller bridge -# (vr-keyboard-controllers.nix, turned on here). +# (vr-keyboard-controllers.nix, turned on here, sampling continuously). # - vr-keyboard-touch/keyboard-patch.js (Steam UI, 8080; patch # "vr-keyboard-touch"): tracker.js per hand (contact, hysteresis); presses # the key with the keyboard's own touch handlers, like a laser press. @@ -36,6 +36,7 @@ in { config = lib.mkIf cfg.enable { steamFrame.keyboard.vr.controllers.enable = true; + steamFrame.keyboard.vr.controllers.continuous = true; steamFrame.uiPatches.patches = [ { name = "vr-keyboard-touch"; target.title = "SharedJSContext";