mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
VR keyboard bridge: adaptive sampling, on demand only without touch typing
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.
This commit is contained in:
1 parent
1612c162fb
commit
57ed00e29c
6 files changed
+341
-28
No files matched your search
+11
-4
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
''
|
||||
@@ -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 <bridge-patch.js> <geometry.js> <tracker.js>
|
||||
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`);
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user