From 8a02e41b21d9b8c91339bff3ee57194982790e97 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:08:16 -0600 Subject: [PATCH 1/7] Eye tracker: one thread for OpenCV and numpy Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000 times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's OpenBLAS also started 8 threads that never had work. eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins). Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s, system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from 21-36 to 57-69 a second per eye. Co-Authored-By: Claude Opus 5.5 --- gaze/README.md | 2 +- gaze/tracker/eyes_pupil.py | 7 ++++++- gaze/tracker/ft-eyes | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/gaze/README.md b/gaze/README.md index 8143f51..3346530 100644 --- a/gaze/README.md +++ b/gaze/README.md @@ -63,7 +63,7 @@ Ground rules, for anyone changing it: - **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only). - **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`. - **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs. -- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. Replays, scoring, and training go to a PC. +- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. Replays, scoring, and training go to a PC. ## Headset fit diff --git a/gaze/tracker/eyes_pupil.py b/gaze/tracker/eyes_pupil.py index 8884363..cf3672d 100644 --- a/gaze/tracker/eyes_pupil.py +++ b/gaze/tracker/eyes_pupil.py @@ -9,7 +9,12 @@ the search runs again with a smaller closing. import cv2 import numpy as np -DARK = 30 # pupil pixels are below this (the face around it is 40-180) +# One thread: OpenCV's pool of one per core costs more than it saves on a frame this small +# (a 140-240 px window while it follows the pupil). Its idle workers spun and yielded about +# 14,000 times a second each, a quarter of a core, beside SteamVR's compositor. +cv2.setNumThreads(1) + +DARK = 30 # pupil pixels are below this (the face around it is 40-180) MIN_AREA = 150 # pupil area range in pixels MAX_AREA = 20000 MIN_FILL = 0.75 # blob area / fitted-ellipse area diff --git a/gaze/tracker/ft-eyes b/gaze/tracker/ft-eyes index 5e25799..66bbff7 100755 --- a/gaze/tracker/ft-eyes +++ b/gaze/tracker/ft-eyes @@ -58,7 +58,12 @@ import time from collections import deque from pathlib import Path -import numpy as np +# One thread for numpy's BLAS and OpenMP, set before numpy loads: it would start one per core +# (8 here) for small arrays that never need them. eyes_pupil keeps OpenCV to one as well. +for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS"): + os.environ.setdefault(_var, "1") + +import numpy as np # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) import eyes_model # noqa: E402 From a11cef49bad187e1e036a228985eed6997fe4f23 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:15 -0600 Subject: [PATCH 2/7] Gaze: ft-eyes and ft-gaze below SteamVR's priority ft-eyes and ft-gaze run in the dev container through distrobox, so they live in podman's libpod scope: frametop-gaze.service's limits never reach them, and they ran at nice 0 next to vrcompositor and vrserver, also at nice 0. - ft-eyes sets itself to nice 10 and SCHED_BATCH at start, before its threads. Batch turns off wakeup preemption, so a frame ft-eyes wakes up for can wait out a running compositor's turn; a few ms late costs the gaze little. - ft-gaze sets nice 5 before its threads start, but stays SCHED_OTHER: each sample goes on to the pointer, and batch would add the same wait to every one of them. - Both only ever lower their priority (a higher nice already set wins), and a failure is logged and ignored. Checked in the dev container: nice 0 -> 10, policy 0 -> 3 (SCHED_BATCH) without any capability. Co-Authored-By: Claude Opus 5.5 --- gaze/README.md | 2 +- gaze/ft-gaze.cpp | 10 ++++++++++ gaze/tracker/ft-eyes | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/gaze/README.md b/gaze/README.md index 3346530..2f47612 100644 --- a/gaze/README.md +++ b/gaze/README.md @@ -63,7 +63,7 @@ Ground rules, for anyone changing it: - **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only). - **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`. - **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs. -- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. Replays, scoring, and training go to a PC. +- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. It also runs at nice 10 with SCHED_BATCH, and ft-gaze at nice 5 (not batch, since each sample goes on to the pointer): both run in the dev container's podman scope, out of reach of the gaze service's unit, on the cores vrcompositor and vrserver use at nice 0. Replays, scoring, and training go to a PC. ## Headset fit diff --git a/gaze/ft-gaze.cpp b/gaze/ft-gaze.cpp index 2d4a107..f11dd4d 100644 --- a/gaze/ft-gaze.cpp +++ b/gaze/ft-gaze.cpp @@ -58,6 +58,7 @@ #include #include +#include #include #include #include @@ -72,6 +73,7 @@ #include #include +#include #include #include #include @@ -430,6 +432,14 @@ int main(int argc, char **argv) { if (std::strcmp(argv[i], "-v") == 0) verbose = true; if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true; } + // Nice 5, before any thread starts (they inherit it): we run in the dev container's podman + // scope, beside vrcompositor and vrserver at nice 0, and the gaze service's unit doesn't + // reach us. Not SCHED_BATCH, as ft-eyes is: that would let each wakeup wait out another + // task's turn, and each sample goes on to the pointer. + errno = 0; + const int nice0 = getpriority(PRIO_PROCESS, 0); + if (errno == 0 && nice0 < 5 && setpriority(PRIO_PROCESS, 0, 5) != 0) + std::fprintf(stderr, "ft-gaze: nice: %s\n", std::strerror(errno)); // --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which // passes neither its signals nor a closed stdout on to us, but does pass stdin's end. std::atomic stdinClosed{false}; diff --git a/gaze/tracker/ft-eyes b/gaze/tracker/ft-eyes index 66bbff7..f716f77 100755 --- a/gaze/tracker/ft-eyes +++ b/gaze/tracker/ft-eyes @@ -377,7 +377,24 @@ def serve(sock, tracker): pass +def below_steamvr(): + """Nice 10 and SCHED_BATCH, for this thread and those it starts. ft-eyes runs in the dev + container's podman scope, where the gaze service's unit doesn't reach it, so it ran at + nice 0 on the cores vrcompositor and vrserver use. Batch also lets a waking ft-eyes wait + for the running task's turn instead of taking the core: a frame a few ms late costs the + gaze little, a late compositor frame costs a dropped frame in the headset.""" + try: + os.setpriority(os.PRIO_PROCESS, 0, max(os.getpriority(os.PRIO_PROCESS, 0), 10)) + except OSError as e: + print(f"ft-eyes: nice: {e}", file=sys.stderr, flush=True) + try: + os.sched_setscheduler(0, os.SCHED_BATCH, os.sched_param(0)) + except (OSError, AttributeError) as e: + print(f"ft-eyes: SCHED_BATCH: {e}", file=sys.stderr, flush=True) + + def main(): + below_steamvr() verbose = "-v" in sys.argv if "--watch-stdin" in sys.argv: # Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our From 7851ce90cc0082bda3a3dfc7d6eff353e8aeafed Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:15:27 -0600 Subject: [PATCH 3/7] Eye tracker: ft-eyes sleeps until the next frame is due ft-eyes looked for new frames about 1,000 times a second: each pass of its loop asked the control socket with a non-blocking recvfrom (a BlockingIOError nearly every time), read both cameras' counters, and slept 1 ms. The frames come every 11.1 ms per camera, and only as counters in ft-eyegrab's shared memory, so there's no fd to wait on. Now each pass ends in select() on the control socket, with a timeout until 2 ms before the next frame of either camera is due (from when its last one was seen), then every 1 ms until it comes. A command wakes it at once. A camera with no frame for 0.1 s (headset off, grabber idle) isn't waited for, and with both stopped it looks every 20 ms. Waiting for the frame grabber's file uses the same select, 0.2 s at a time, instead of sleeping through commands. A new frame is still seen within about 1 ms of when it lands. On a synthetic share at 90 Hz per camera, on a heavily loaded headset (load average 23, so ft-eyes rarely sat idle), its waits went from 178 to 81 a second; unloaded, the old loop's 1 ms sleeps add up to about 1,000. Co-Authored-By: Claude Opus 5.5 --- gaze/tracker/ft-eyes | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/gaze/tracker/ft-eyes b/gaze/tracker/ft-eyes index f716f77..871828e 100755 --- a/gaze/tracker/ft-eyes +++ b/gaze/tracker/ft-eyes @@ -50,6 +50,7 @@ import json import math import mmap import os +import select import socket import struct import sys @@ -87,6 +88,15 @@ GAP = 3.0 # s without frames: the headset was off, and may sit diffe CLICK_BEFORE = 0.3 # a click's frames: the 300 ms before it (like the probe's fixation) CALIB_MIN = 15 # frames an eye needs in a calibration dot's window CALIB_SPREAD = 4.0 # px: more than this and the eye moved during the dot +# Waiting for frames. They come only as a counter in shared memory, so there's nothing to block +# on: sleep until a camera's next frame is due, then look every POLL. ft-eyegrab passes each one +# on within a ms or two of when its camera starts the one after, so they arrive 11.1 ms apart, +# give or take that. +PERIOD = 1 / 90 # s between a camera's frames +EARLY = 0.002 # start looking this long before a frame is due +POLL = 0.001 # then this often until it comes +STALLED = 0.1 # s without a frame: that camera stopped (headset off), and isn't waited for +IDLE_POLL = 0.02 # how often to look while both are stopped class Cams: @@ -357,7 +367,7 @@ def wait_for_cams(sock, tracker, want): return Cams() except (OSError, ValueError, RuntimeError): serve(sock, tracker) - time.sleep(0.2) + select.select([sock], [], [], 0.2) def serve(sock, tracker): @@ -420,6 +430,7 @@ def main(): print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True) seen = [cams.count(0), cams.count(1)] report = time.monotonic() + arrived = [0.0, 0.0] # when each camera's newest frame was seen (monotonic) while True: serve(sock, tracker) want() @@ -429,6 +440,7 @@ def main(): if n == seen[c]: continue seen[c] = n + arrived[c] = time.monotonic() got = cams.frame(c, n - 1) # only the newest: never fall behind if got: eyes[c].feed(*got) @@ -447,8 +459,6 @@ def main(): shifts += [float(v) for v in e.shift.value] pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan] out.write(latest, (yaw, pitch), flags, per, shifts, pupils) - else: - time.sleep(0.001) now = time.monotonic() if now - report >= 5: if verbose: @@ -466,6 +476,12 @@ def main(): print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True) cams = wait_for_cams(sock, tracker, want) seen = [cams.count(0), cams.count(1)] + # Until the next frame is due (a command on the socket wakes us sooner). + wait = IDLE_POLL + for c in (0, 1): + if now - arrived[c] < STALLED: + wait = min(wait, max(arrived[c] + PERIOD - EARLY - now, POLL)) + select.select([sock], [], [], wait) if __name__ == "__main__": From 060b4957f84dbc0984d63757362c24ee88f8b051 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:20:00 -0600 Subject: [PATCH 4/7] Eye tracker: ft-eyegrab checks only the slot each camera writes next While copying, ft-eyegrab woke every 300 us (about 1,500 to 3,000 times a second) and fingerprinted all eight slots each time: 8 x 256 strided reads from DMA-BUF memory. - Each look now checks only the slot each camera writes next. The order is known (camera 0 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), and the next slot follows from the last two; the table starts from those orders and learns from every frame, so a SteamVR update that changes them costs a few seconds of full scans, not frames. - A camera with nothing in its expected slot 1.5 frames after its last one, or with no order yet, gets all four slots checked, as before. A frame that turns up in an unexpected slot means full scans for that camera for 2 s. - A slot's fingerprint is taken again when it stops being one of the two in use, so a later check sees only a new frame. A frame is still passed on when its camera starts the frame after next. - Between frames it sleeps until 2.5 ms before the next is due, then looks every 1 ms, with 0.5 ms of timer slack (PR_SET_TIMERSLACK, --share only). With no frames from either camera for 0.5 s (headset off) it looks every 4 ms. - --rec keeps its 0.3 ms polls (and the expected-slot checks), for its timestamps. Tested offline by building poll_frames against simulated cameras that write each frame in four bursts, the last after the next frame starts (6 s, both cameras): 1,076 frames passed on, none torn or skipped, with the known orders and with camera 1 in a different order. Wakeups 1,486/s -> 207/s, the poller's CPU 3.6% -> 0.8% of a core (in plain memory; the real DMA-BUF reads cost more), and a frame's start is seen 1.35 ms after it begins on average instead of 0.76. With a camera stalling 15 ms every 2 s, the old poller passed on 22 torn frames and the new one 8 or fewer. Built (glibc 2.38 symbols at most, the host has 2.39), not installed: it runs as root from /etc/frametop, so it takes effect only after gaze/tracker/install.sh (sudo). Co-Authored-By: Claude Opus 5.5 --- gaze/tracker/findings.md | 7 +++ gaze/tracker/ft-eyegrab.c | 89 ++++++++++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/gaze/tracker/findings.md b/gaze/tracker/findings.md index d5e36f9..3d9a932 100644 --- a/gaze/tracker/findings.md +++ b/gaze/tracker/findings.md @@ -63,6 +63,13 @@ reading only. frame when its camera starts the frame after next (no slot is rewritten sooner than three frames), ignores late writes to the frame just finished, and saves from a separate thread. fit1 may hold a few percent of torn frames. + - `--share` (2026-10-03) checks only the slot each camera writes next, from the two before + (the orders above, learned again if they change; all four slots for 2 s after a frame turns + up elsewhere), sleeps until 2.5 ms before the next frame is due, then looks every 1 ms with + 0.5 ms of timer slack. Against the 0.3 ms poll of all eight slots, on simulated cameras: + 207 wakeups a second instead of 1,486, 0.8% of a core instead of 3.6% (more on the real + DMA-BUF memory), no torn or skipped frames, and a frame's start seen 1.35 ms late on + average instead of 0.76. `--rec` still polls all slots every 0.3 ms, for its times. - Eye tracking stops when the headset is off ("HMD off, stopping eye tracking"), so recordings are empty then. - **Which camera is which eye** (capture fit1, 2026-09-29, closing one eye at a time): diff --git a/gaze/tracker/ft-eyegrab.c b/gaze/tracker/ft-eyegrab.c index 6cc31c8..a976c41 100644 --- a/gaze/tracker/ft-eyegrab.c +++ b/gaze/tracker/ft-eyegrab.c @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -288,20 +289,53 @@ static int eye_buffer(void) { // Calls done(frame, slot, time) for every complete eye-camera frame until `seconds` pass // (forever if negative), a stop signal comes, the tracker process goes away, or keep() -// (checked about every 0.25 s, when given) says to stop. +// (checked about every 0.25 s, when given) says to stop. Looks every `poll_us`. // // A frame lands over several milliseconds, in bursts, and its last bursts can come after // the camera has started its next frame. A slot isn't rewritten until at least three frames // later (camera 0 cycles 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), so a frame is passed on when // its camera starts the frame after next. Changes to the slot just finished are late bursts, // not a new frame. A frame's time is when its slot first changed. +// +// Each look checks only the slot each camera writes next: the one that followed the last two +// before (after[][], seeded with the orders above and learned as frames come). Every check +// reads 256 words spread over a frame in DMA-BUF memory, so all eight slots each time was +// most of the cost. A camera with nothing in its expected slot for 1.5 frames, or with no +// order known yet, has all its slots checked until a frame comes. When that finds a frame +// somewhere else (the order changed, or a frame was missed), all its slots are checked for +// SCAN_AFTER_MISS, as before, while after[][] learns the new order. A slot's fingerprint is +// taken again when it stops being one of the two in use, so a check later sees only a new frame. +// Between frames it sleeps until FRAME_EARLY before the next one is due (the cameras run at +// 90 fps, within a ms of each other), then looks every `poll_us`. +#define FRAME_DUE (1.5 / 90) // s: a camera that hasn't started a frame by then gets all its slots checked +#define SCAN_AFTER_MISS 2.0 // s of checking all slots after a frame came in an unexpected one +#define CAMS_IDLE 0.5 // s without a frame from either camera: look 4 times less often +#define FRAME_EARLY 0.0025 // s before a frame is due to start looking for it + +// When to start looking for the next frame: FRAME_EARLY before the first camera's is due. A +// camera already late (it lost its order, or stopped) means now. +static double next_due(const double last[2], double t) { + double due = 1e300; + for (int cam = 0; cam < 2; cam++) { + double d = last[cam] + 1.0 / 90 - FRAME_EARLY; + if (t - last[cam] > CAMS_IDLE) continue; // stopped: the other one sets the pace + if (d < due) due = d; + } + return due < 1e300 ? due : t; +} + static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8_t *, int, double), - int (*keep)(void)) { + int (*keep)(void), unsigned poll_us) { + static const int order[2][8] = {{3, 0, 1, 2, 3, 0, 1, 2}, {7, 5, 4, 6, 5, 7, 6, 4}}; + int after[EYE_SLOTS][EYE_SLOTS]; + memset(after, -1, sizeof after); + for (int c = 0; c < 2; c++) + for (int i = 0; i < 8; i++) after[order[c][i]][order[c][(i + 1) % 8]] = order[c][(i + 2) % 8]; uint64_t sig[EYE_SLOTS]; double first[EYE_SLOTS]; int cur[2] = {-1, -1}, prev[2] = {-1, -1}; for (int k = 0; k < EYE_SLOTS; k++) sig[k] = frame_sig(bufs[b].p + slot_start(k)), first[k] = 0; - double start = now(), checked = start, kept = start; + double start = now(), checked = start, kept = start, last[2] = {start, start}, scan_until[2] = {0, 0}; char proc[64]; snprintf(proc, sizeof proc, "/proc/%d", pid); while ((seconds < 0 || now() - start < seconds) && !stop_rec) { @@ -315,18 +349,36 @@ static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8 if (!keep()) return; kept = t; } - for (int k = 0; k < EYE_SLOTS; k++) { - uint64_t s = frame_sig(bufs[b].p + slot_start(k)); - if (s == sig[k]) continue; - sig[k] = s; - int cam = k >= 4; - if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst - if (prev[cam] >= 0) done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]); - prev[cam] = cur[cam]; - cur[cam] = k; - first[k] = t; + for (int cam = 0; cam < 2; cam++) { + int next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1; + int all = next < 0 || t - last[cam] > FRAME_DUE || t < scan_until[cam]; + int from = all ? cam * 4 : next, to = all ? cam * 4 + 4 : next + 1; + for (int k = from; k < to; k++) { + uint64_t s = frame_sig(bufs[b].p + slot_start(k)); + if (s == sig[k]) continue; + sig[k] = s; + if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst + if (next >= 0 && k != next) scan_until[cam] = t + SCAN_AFTER_MISS; + if (prev[cam] >= 0) { + done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]); + after[prev[cam]][cur[cam]] = k; + // Out of use from now on: later changes are a new frame. + sig[prev[cam]] = frame_sig(bufs[b].p + slot_start(prev[cam])); + } + prev[cam] = cur[cam]; + cur[cam] = k; + first[k] = t; + last[cam] = t; + next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1; + } } - usleep(300); + double wait = poll_us * 1e-6; + if (t - last[0] > CAMS_IDLE && t - last[1] > CAMS_IDLE) { + wait *= 4; + } else if (next_due(last, t) - t > wait) { + wait = next_due(last, t) - t; + } + usleep((useconds_t)(wait * 1e6)); } } @@ -356,7 +408,7 @@ static int rec(double seconds, const char *dir, int pid) { pthread_t writer; pthread_create(&writer, NULL, ring_writer, NULL); double start = now(); - poll_frames(b, seconds, pid, rec_frame, NULL); + poll_frames(b, seconds, pid, rec_frame, NULL, 300); // 0.3 ms: recordings' times pthread_mutex_lock(&ring.mu); ring.done = 1; pthread_cond_signal(&ring.cv); @@ -383,6 +435,10 @@ static int rec(double seconds, const char *dir, int pid) { #define SHARE_SLOTS 8 #define SHARE_MAGIC 0x31434546u // "FEC1" #define WANT_FRESH 3.0 // seconds a touch of the --want file lasts +// How often --share looks for frames, with a timer slack that lets the kernel group the +// wakeups: a frame reaches ft-eyes 1 to 1.5 ms after its camera starts the next, not 0.3. +#define SHARE_POLL_US 1000 +#define SHARE_SLACK_NS 500000 typedef struct { uint32_t magic, version, width, height, slots, entry_size; @@ -462,6 +518,7 @@ static int share_loop(const char *path) { .slots = SHARE_SLOTS, .entry_size = (uint32_t)esize}; signal(SIGINT, on_stop); signal(SIGTERM, on_stop); + if (prctl(PR_SET_TIMERSLACK, SHARE_SLACK_NS, 0, 0, 0) != 0) perror("PR_SET_TIMERSLACK"); fprintf(stderr, "ft-eyegrab: sharing frames in %s%s%s\n", path, want_path ? " while wanted by " : "", want_path ? want_path : ""); int pid = -1, idle = -1, missing = 0; @@ -486,7 +543,7 @@ static int share_loop(const char *path) { if (idle != 0 || missing) fprintf(stderr, "ft-eyegrab: copying frames from eyetracking %d\n", pid); idle = 0, missing = 0; h->tracker_pid = pid; - poll_frames(eye_buffer(), -1, pid, share_frame, wanted); + poll_frames(eye_buffer(), -1, pid, share_frame, wanted, SHARE_POLL_US); struct stat st; char proc[64]; snprintf(proc, sizeof proc, "/proc/%d", pid); From 0c95d1d731fc016e82bd9d75eca87bae02e3bd2c Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:23:53 -0600 Subject: [PATCH 5/7] Gaze: ft-gaze prints and reads only the sources in use ft-gaze computed and printed all six sources for every sample: about 1.3 KB of JSON a line with our tracker (practice2), 120 KB a second through podman's stdio relay for ft-gazed to json.loads 90 times a second. It also read SteamVR's gaze action for every sample outside games (UpdateActionState and GetEyeTrackingDataRelativeToNow, two calls into vrserver, 180 a second), though with our tracker ft-gazed only uses own and mmap1. - ft-gaze takes --sources LIST (action, mmap1, mmap2, left, right, own, and eye for the EYE object; all by default, so the probe and ft-eyes-session are unchanged), and with --watch-stdin a line "sources LIST" on stdin switches them. A source left out isn't read and prints as {"ok":0} ("eye" as null), so every line keeps the same keys. An older ft-gaze ignores both, and prints everything as before. - ft-gazed asks for what it reads: own,mmap1 with our tracker; left,right,mmap1 with SteamVR's eyes; the source plus mmap1 and mmap2 on the older one-source path. While a check or the calibration runs or waits to open, all of them, since checks record every source (the calibration fits the action's correction too) and the fit check reads "eye". It switches as soon as that changes, well inside the check's 0.45 s settle. - So the action is read only during checks, or with --source action. On recorded samples, a line with own and mmap1 is about 700 bytes instead of 1,300 (practice2), and one with left, right and mmap1 about 550 instead of 940 (test1). gaze/test/idle-test.py now checks that ft-gaze starts with every source for a check and is then switched to those in use, without the action or own; all its checks pass. Co-Authored-By: Claude Opus 5.5 --- gaze/README.md | 2 +- gaze/ft-gaze.cpp | 86 ++++++++++++++++++++++++++++++++++-------- gaze/ft-gazed | 41 ++++++++++++++++++-- gaze/test/idle-test.py | 32 +++++++++++++--- 4 files changed, 136 insertions(+), 25 deletions(-) diff --git a/gaze/README.md b/gaze/README.md index 2f47612..2ee6937 100644 --- a/gaze/README.md +++ b/gaze/README.md @@ -2,7 +2,7 @@ The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it. -- `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. +- `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. The gaze service asks it for only the sources it uses (`--sources`, and `sources LIST` on its stdin): our tracker and mmap set 1 with Own tracker, about 700 bytes a line instead of 1.3 KB, and every source while a check or the calibration runs. So SteamVR's gaze action, the only source that calls into vrserver (twice a sample), is read only then. The probe gets them all. - `gazecal.py` has what the probe and the gaze service share: the correction models, filters, and the reader for SteamVR's eye tracking log. - `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below. - `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments. diff --git a/gaze/ft-gaze.cpp b/gaze/ft-gaze.cpp index f11dd4d..2e23b0a 100644 --- a/gaze/ft-gaze.cpp +++ b/gaze/ft-gaze.cpp @@ -4,7 +4,14 @@ // Every eye tracker sample (90 Hz) becomes one JSON line on stdout with each gaze source // hit-tested against the Frametop screens: // -// Options: -v (log action errors), --watch-stdin (quit when stdin closes). +// Options: -v (log action errors), --watch-stdin (quit when stdin closes; until then, a line +// "sources LIST" on stdin switches the sources as --sources does), --sources LIST (comma- +// separated: action, mmap1, mmap2, left, right, own, and eye for EYE; or all, the default). +// Sources left out are read not at all and print as {"ok":0} ("eye" as null), so every line +// keeps the same keys. The gaze service asks for the ones it uses (own and mmap1 with our +// tracker), and all of them while a check or the calibration runs. Only the action costs +// SteamVR anything (two calls into vrserver per sample), and a line with every source is +// about 1.5 KB, 130 KB a second through podman's relay. // // {"t":,"age":,"n":, // "head":{"yaw":..,"pitch":..,"hit":HIT}, head forward ray (for head nudging) @@ -391,6 +398,30 @@ std::string SrcJson(const std::vector &screens, const vr::HmdMatrix34_t return buf + extra + "\"hit\":" + HitJson(screens, head, yaw, pitch) + "}"; } +// Sources (--sources, "sources LIST" on stdin): a bit each. +enum : unsigned { kAction = 1, kMmap1 = 2, kMmap2 = 4, kLeft = 8, kRight = 16, kOwn = 32, kEye = 64, kAll = 127 }; + +bool ParseSources(const std::string &list, unsigned &mask) { + static const struct { + const char *name; + unsigned bit; + } names[] = {{"action", kAction}, {"mmap1", kMmap1}, {"mmap2", kMmap2}, {"left", kLeft}, + {"right", kRight}, {"own", kOwn}, {"eye", kEye}, {"all", kAll}}; + unsigned m = 0; + size_t at = 0; + while (at <= list.size()) { + const size_t comma = std::min(list.find(',', at), list.size()); + const std::string name = list.substr(at, comma - at); + bool known = false; + for (const auto &n : names) + if (name == n.name) m |= n.bit, known = true; + if (!known) return false; + at = comma + 1; + } + mask = m; + return true; +} + // Head poses of the last half second, so a sample can use the pose at its own time. class PoseHistory { public: @@ -428,9 +459,17 @@ std::string ExeDir() { int main(int argc, char **argv) { bool verbose = false, watchStdin = false; + std::atomic sources{kAll}; for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "-v") == 0) verbose = true; if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true; + if (std::strcmp(argv[i], "--sources") == 0 && i + 1 < argc) { + unsigned m; + if (ParseSources(argv[++i], m)) + sources = m; + else + std::fprintf(stderr, "ft-gaze: --sources %s: unknown source (all used)\n", argv[i]); + } } // Nice 5, before any thread starts (they inherit it): we run in the dev container's podman // scope, beside vrcompositor and vrserver at nice 0, and the gaze service's unit doesn't @@ -442,11 +481,20 @@ int main(int argc, char **argv) { std::fprintf(stderr, "ft-gaze: nice: %s\n", std::strerror(errno)); // --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which // passes neither its signals nor a closed stdout on to us, but does pass stdin's end. + // Lines on stdin until then: "sources LIST". std::atomic stdinClosed{false}; if (watchStdin) - std::thread([&stdinClosed] { + std::thread([&stdinClosed, &sources] { char c[256]; - while (read(0, c, sizeof c) > 0) { + std::string line; + ssize_t n; + while ((n = read(0, c, sizeof c)) > 0) { + line.append(c, size_t(n)); + for (size_t nl; (nl = line.find('\n')) != std::string::npos; line.erase(0, nl + 1)) { + unsigned m; + if (line.compare(0, 8, "sources ") == 0 && ParseSources(line.substr(8, nl - 8), m)) sources = m; + } + if (line.size() > 4096) line.clear(); // no newline in sight: not ours } stdinClosed = true; }).detach(); @@ -519,6 +567,7 @@ int main(int argc, char **argv) { if (fresh && hp.bPoseIsValid) { lastEmit = now; + const unsigned want = sources; const auto list = screens.Get(); const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking; vr::HmdMatrix34_t headThen = headNow; @@ -527,7 +576,7 @@ int main(int argc, char **argv) { // SteamVR's action: a room-space origin and fixation point, turned into the head // frame so every source reports the same kind of angles. std::string action = "{\"ok\":0}"; - if (!inGame) { + if (!inGame && (want & kAction)) { vr::VRActiveActionSet_t active{}; active.ulActionSet = set; active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin; @@ -570,26 +619,33 @@ int main(int argc, char **argv) { return std::string(b); }; char extra[256]; - std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1), - s.open[0], s.open[1], lr(s.left1, s.right1)); - m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1)); - std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2)); - m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2)); - left = SrcJson(list, headThen, s.left2); - right = SrcJson(list, headThen, s.right2); + if (want & kMmap1) { + std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1), + s.open[0], s.open[1], lr(s.left1, s.right1)); + m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1)); + } + if (want & kMmap2) { + std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2)); + m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2)); + } + if (want & kLeft) left = SrcJson(list, headThen, s.left2); + if (want & kRight) right = SrcJson(list, headThen, s.right2); + // "new" compares with the last sample, so the last measurement is kept either way. const float *m = s.meas; const bool newL = m[0] != lastMeas[0] || m[1] != lastMeas[1]; const bool newR = m[2] != lastMeas[2] || m[3] != lastMeas[3]; std::memcpy(lastMeas, m, sizeof lastMeas); - std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}", - (m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR)); - eye = extra; + if (want & kEye) { + std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}", + (m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR)); + eye = extra; + } } // Our tracker: its own sample time picks the head pose, like the mmap's. std::string own = "{\"ok\":0}"; OwnSample o; - if (ownFile.Read(o) && now - o.t < 0.1) { + if ((want & kOwn) && ownFile.Read(o) && now - o.t < 0.1) { vr::HmdMatrix34_t headOwn = headNow; history.At(o.t, headOwn); auto pair = [](bool ok, float a, float b) { diff --git a/gaze/ft-gazed b/gaze/ft-gazed index f751ba6..18a768b 100755 --- a/gaze/ft-gazed +++ b/gaze/ft-gazed @@ -76,6 +76,12 @@ off. A check asked for while idle waits for the tracker to start (at most WAKE_S tracker's next click after it starts again re-seats it, as after the headset was off, so turning gaze mode on after a while opens the quick check. +ft-gaze prints only the sources this uses ("sources LIST" on its stdin, see wanted_sources): +own and mmap1 with our tracker, left, right and mmap1 with SteamVR's eyes, and every source +while a check or the calibration runs or is asked for, since those record them all. So +SteamVR's gaze action, the one source that calls into vrserver, is read only then (or with +--source action). + Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or our tracker asks for a click, and the full calibration when gaze mode comes on without one, both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too). @@ -260,6 +266,7 @@ class Service: self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own") self.checks = Checks(self, self.sel) self.proc = None + self.proc_sources = None # what ft-gaze was last told to print self.buf = b"" self.restart_at = 0.0 self.running = True @@ -459,6 +466,31 @@ class Service: # --- ft-gaze --- + def wanted_sources(self): + """The sources ft-gaze should print (its --sources): what on_sample and the checks read. + mmap1 always (blinks, lost eyes, and the headset going on, from its openness and + variances); everything while a check runs or waits, since they record every source.""" + c = self.checks + if c.active or c.pending: + return "all" + kind = self.kind + if kind == "own": + return "own,mmap1" + if kind == "eyes": + return "left,right,mmap1" + return ",".join(dict.fromkeys((self.source, "mmap1", "mmap2"))) # mmap2: each eye, for the fallback + + def sync_sources(self): + want = self.wanted_sources() + if not self.proc or want == self.proc_sources: + return + try: + self.proc.stdin.write(f"sources {want}\n".encode()) + self.proc.stdin.flush() + except (OSError, ValueError): + return # it's stopping: read_stdout notices + self.proc_sources = want + def start_helper(self): if not HELPER.exists(): log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh") @@ -469,9 +501,11 @@ class Service: subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False) distrobox = Path.home() / ".local" / "bin" / "distrobox" # ft-gaze quits when its stdin closes: the one thing distrobox passes on. - self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin"], env=env, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True) + sources = self.wanted_sources() + self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin", + "--sources", sources], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, start_new_session=True) + self.proc_sources = sources os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False) self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") @@ -876,6 +910,7 @@ class Service: elif key.data == "stderr" and self.proc: self.read_stderr() self.checks.tick() + self.sync_sources() if now >= next_periodic: self.periodic() next_periodic = now + 1.0 diff --git a/gaze/test/idle-test.py b/gaze/test/idle-test.py index a0cd240..145becb 100755 --- a/gaze/test/idle-test.py +++ b/gaze/test/idle-test.py @@ -37,14 +37,26 @@ gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0) logs = [] gazed.log = gazecheck.log = lambda msg: logs.append(msg) -# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. +# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. It logs the +# sources it was asked for: "argv LIST" (--sources), then "line LIST" for each "sources LIST". tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-") FAKE = os.path.join(tmp, "ft-gaze") +SOURCES_LOG = os.path.join(tmp, "sources.log") with open(FAKE, "w") as f: - f.write('''import json, select, sys, time + f.write('''import json, os, select, sys, time +log = open(sys.argv[1], "a", buffering=1) +log.write("argv " + (sys.argv[sys.argv.index("--sources") + 1] if "--sources" in sys.argv else "-") + "\\n") +buf = b"" while True: - if select.select([sys.stdin], [], [], 1 / 90)[0] and not sys.stdin.read(1): - break + if select.select([sys.stdin], [], [], 1 / 90)[0]: + data = os.read(0, 4096) + if not data: + break + buf += data + while b"\\n" in buf: + line, buf = buf.split(b"\\n", 1) + if line.startswith(b"sources "): + log.write("line " + line[8:].decode() + "\\n") eye = {"hy": 1.0, "hp": 2.0} print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001], "open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True) @@ -55,8 +67,9 @@ started = [] def start_helper(self): """ft-gaze, straight from here instead of the dev container.""" import selectors - self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + self.proc = subprocess.Popen([sys.executable, FAKE, SOURCES_LOG, "--sources", self.wanted_sources()], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.proc_sources = self.wanted_sources() os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False) self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") @@ -142,17 +155,24 @@ check("lease over (2 s + IDLE_AFTER): idle", wait(lambda: not svc.awake, 4.5), T time.sleep(0.5) logs.clear() +open(SOURCES_LOG, "w").close() count = len(started) check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first") check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True) check("once the tracker sends, it ran (and says why it couldn't open)", wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True) check("nothing left pending", svc.checks.pending, None) +sources = open(SOURCES_LOG).read().split("\n") +check("ft-gaze started with every source for the check", sources[0], "argv all") +in_use = svc.wanted_sources() +check("then only those in use (SteamVR's tracker: not the action, not own)", + (f"line {in_use}" in sources, in_use != "all", "action" in in_use, "own" in in_use), (True, True, False, False)) check("idle again after", wait(lambda: not svc.awake, 3), True) print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True) svc.running = False time.sleep(0.7) os.remove(FAKE) +os.remove(SOURCES_LOG) os.rmdir(tmp) os._exit(1 if failures else 0) From 95c5d056cff43606adac9a8f3ad3595875030a42 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:54 -0600 Subject: [PATCH 6/7] Gaze: ft-gaze's loop runs every 4 ms instead of 2 ft-gaze's loop slept 2 ms, so 500 times a second it read the head pose (GetDeviceToAbsoluteTrackingPose), checked the eye tracker's counter, and drained SteamVR's events, for samples that come 90 times a second. It now sleeps 4 ms. A new sample is printed within 4 ms of appearing, 2 on average (was 1), and the pose history still has a pose within 2 ms of any sample's time, which keeps the head-pose error under 0.2 degrees for a head turning 100 degrees a second. Sleeping until the next sample is due would have thinned the pose history to 11 ms. Co-Authored-By: Claude Opus 5.5 --- gaze/ft-gaze.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gaze/ft-gaze.cpp b/gaze/ft-gaze.cpp index 2e23b0a..0c9393c 100644 --- a/gaze/ft-gaze.cpp +++ b/gaze/ft-gaze.cpp @@ -690,7 +690,10 @@ int main(int argc, char **argv) { break; } if (stdinClosed) break; - std::this_thread::sleep_for(std::chrono::milliseconds(2)); + // 250 passes a second: a new sample is printed within 4 ms (2 on average) of appearing, + // and the pose history has a pose within 2 ms of any sample's time (a 0.2 degree head + // turn at 100 degrees a second). Every 2 ms read the pose and the events twice as often. + std::this_thread::sleep_for(std::chrono::milliseconds(4)); } screens.Stop(); vr::VR_Shutdown(); From e44837cee6e7b233cce8fc50595a6bca3e594aea Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:28:02 -0600 Subject: [PATCH 7/7] Gaze: the hidden panel waits for a command instead of waking every 50 ms The calibration panel runs for as long as the gaze service does, hidden nearly all the time, and it woke 20 to 30 times a second to look at its socket and SteamVR's events: about 0.9% of a core, the main cost left with gaze idle. It now waits in poll() on its command socket: up to a second while hidden, and up to 10 ms while shown, as before (it still drains SteamVR's events each pass, so a quit is acknowledged within a second while hidden). A command wakes it at once, so "show" draws sooner than before. With --watch-stdin, its stdin closing wakes it as well, so stopping it doesn't wait. Co-Authored-By: Claude Opus 5.5 --- gaze/panel/ft-gazepanel.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gaze/panel/ft-gazepanel.cpp b/gaze/panel/ft-gazepanel.cpp index f9b32dd..cbe888b 100644 --- a/gaze/panel/ft-gazepanel.cpp +++ b/gaze/panel/ft-gazepanel.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -524,7 +525,11 @@ int main(int argc, char **argv) { if (!shown) ov->ShowOverlay(h), shown = true; dirty = false; } - std::this_thread::sleep_for(std::chrono::milliseconds(visible ? 10 : 50)); + // Until a command comes, or 10 ms while shown (SteamVR's events). Hidden, it waits up to a + // second: it woke 20 to 30 times a second for nothing, the main cost left with gaze idle. + // A closed stdin (--watch-stdin) wakes it too, so quitting doesn't wait. + pollfd fds[2] = {{sock, POLLIN, 0}, {0, POLLIN, 0}}; + poll(fds, watchStdin ? 2 : 1, visible ? 10 : 1000); } ov->DestroyOverlay(h); buffers.Drop();