diff --git a/docs/design.md b/docs/design.md index 4725296..f6f5324 100644 --- a/docs/design.md +++ b/docs/design.md @@ -193,7 +193,7 @@ Staying awake while charging uses Steam's own setting rather than a logind sleep Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service ran at full rate whether gaze mode was on or not; now it idles while the gaze isn't used (gaze/README.md), and pausing stops it outright. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are. -- A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not, so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together. +- A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not (since then, a hidden screen always gets one a second; see the frame rates in reference.md), so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together. - The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started. - The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/` instead of `/user/hand/right`), so the reader looks the controllers up again every 3 seconds. - Resuming starts remote desktop through `systemd-run --scope`: started straight from the relay, it would join the relay's cgroup and end with the next relay restart. diff --git a/docs/reference.md b/docs/reference.md index b1273b1..32de70e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -69,8 +69,11 @@ visibility always|dashboard|gesture|toggle wrist degrees gesture left|righ hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible pause on|off|state conceal N|all reveal N|all concealed cutouts on|off|state cutouts predict on|off cutouts lead ms float N mpp x y w h title unfloat N pose N matrix sub N k x y w h | sub N k off minimized N 0|1 carry N +rates focused in_view hidden rates? watch seconds phase ms ``` +Each screen draws at a frame rate for how much of it you see. KWin draws a screen only after ft-screens gives it a frame callback, and its apps wait for theirs, so the rate of callbacks is the screen's frame rate, for KWin and the apps on it alike. A screen is focused while you look at it (within 12 degrees of where your head points), while a laser or the mouse is on it or was in the last 1.5 seconds, while it's carried, and while you type on it; it gets every display frame. The rest of what you can see (within 60 degrees) gets 15 frames a second, and a hidden screen, one behind you, and everything while paused get one a second. A level goes up at once and comes down after a moment (1.5 s from focused, 0.5 s from in view). A video, or anything moving over a large part of a screen (6% or more of it, redrawn on 8 commits in a row, 10 or more a second), keeps every frame while in view. A floating window is a screen of its own here. Nothing that stands still costs anything at any rate: KWin sends a frame only when something on the screen changed. `rates F V H` sets the three rates in Hz (0: every display frame; default `0 15 1`, also `ft-screens --rates 0,15,1`), and `rates?` shows them, the display's rate, and for each screen its level, its milliseconds between frames, and whether it counts as a video. `watch S` gives every screen full rate for S seconds: remote desktop renews it while a VNC client is connected, since a viewer sees what KWin draws. The ticks (SteamVR events and the callbacks) come once per display frame, 1 ms after the vsync (`phase ms` changes that, for tuning), in step with the display rather than on a timer that drifted through the frame. + `conceal` and `reveal` hide and show one screen on its own (`ft-layout hide` and `show` send them), and `concealed` lists those screens. `pause on` (from the input relay, when Frametop pauses for a VR game) hides every screen and floating window whatever else says, and slows the desktop down; `pause off` undoes it. `cutouts` turns the hand cutouts on and off (`ft-handsctl cutouts`). The last line is ft-floatd's, for floating windows: N is a floating window's panel, numbered on from the screens, one per spare output. `float` gives the window's rectangle in its output, metres per pixel, and the title bar's height, and shows the panel; `unfloat` hides it. `pose` places it (a 3x4 matrix, standing universe), `sub` shows popup or dialog k over it, `minimized` hides it while its window is minimized, and `carry` moves it with the laser that pressed the window's own title bar. ## Input relay @@ -206,7 +209,7 @@ Paused, Frametop leaves the headset's CPU and GPU to a VR game. The input relay - The gaze service stops (`frametop-gaze`: ft-gazed, ft-gaze, our own eye tracker, the gaze panel), so nothing reads SteamVR's eye tracking. Our frame grabber, the root service `ft-eyegrab`, goes idle by itself 3 seconds after our eye tracker stops asking it for frames. - Hand tracking stops if it runs (`frametop-camd`, `frametop-hands`). -- The desktop, as the Game optimization page of Frametop Input Settings says (`pause_desktop`): hidden (the default) or closed. Hidden, ft-screens hides every screen and floating window whatever the visibility mode, the hotkey, or the dashboard says, and gives KWin a frame callback once a second instead of 90 times. KWin draws a screen only after its frame callback, and its apps wait for theirs, so the desktop hardly draws, but its windows stay open. Remote desktop stops if it runs (`session/remote-ctl.sh`). Closed, `desktops.sh stop` closes the desktop and its windows, and resuming starts it again (about 12 seconds), in its start profile if it has one. +- The desktop, as the Game optimization page of Frametop Input Settings says (`pause_desktop`): hidden (the default) or closed. Hidden, ft-screens hides every screen and floating window whatever the visibility mode, the hotkey, or the dashboard says, and gives KWin a frame callback once a second instead of every display frame. KWin draws a screen only after its frame callback, and its apps wait for theirs, so the desktop hardly draws, but its windows stay open. Remote desktop stops if it runs (`session/remote-ctl.sh`). Closed, `desktops.sh stop` closes the desktop and its windows, and resuming starts it again (about 12 seconds), in its start profile if it has one. - The relay lets go of the 3D mouse and feeds pointer devices to its virtual mouse and keyboard, as with `POINTER=0`. Typing goes to Steam. Mapped buttons and key combinations do nothing but pausing, the Steam menu, and commands; a key combination that does nothing is typed as usual. Resuming starts again only what pausing stopped, and plays a second sound. The pointer helper and ft-powerd keep running: they cost little, the helper is what says a game started, and stopping it would leave its virtual controller connected with its last pose. diff --git a/screens/build.sh b/screens/build.sh index bd3cd46..fd626f8 100755 --- a/screens/build.sh +++ b/screens/build.sh @@ -23,6 +23,6 @@ $cxx -c -o build/handcut.o handcut.cpp $cxx -c -o build/handtest.o handtest.cpp vrlibs="$(pkg-config --libs egl glesv2 gbm) -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64" g++ -o build/ft-screens build/compositor.o build/vr.o build/keyboard.o build/handcut.o \ - $(pkg-config --libs wlroots-0.20 wayland-server xkbcommon) $vrlibs + $(pkg-config --libs wlroots-0.20 wayland-server xkbcommon pixman-1) $vrlibs g++ -o build/ft-handtest build/handtest.o build/handcut.o $vrlibs echo "built build/ft-screens build/ft-handtest"' diff --git a/screens/compositor.c b/screens/compositor.c index c635d33..c582903 100644 --- a/screens/compositor.c +++ b/screens/compositor.c @@ -13,7 +13,7 @@ // scale first ("scale ", from ft-layout). // // Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... -// [--spares N] [-- COMMAND ARGS...] +// [--spares N] [--rates F,V,H] [-- COMMAND ARGS...] // --socket Wayland socket name in $XDG_RUNTIME_DIR (default ft-screens-0) // --control the control socket's abstract name (default ft_screens) // --no-vr run without SteamVR, for tests next to the running desktop: no panels, no @@ -22,6 +22,8 @@ // --screen one per screen, in KWin's order (default: 3440x1440@2.4) // --spares KWin's outputs after the screens: spares for floating windows (ft-floatd // turns them on and sizes them; see docs/floating-windows.md) +// --rates frame rates in Hz for screens you look at, the rest you see, and hidden ones +// (0: every display frame; default 0,15,1; see frame_interval) // COMMAND run with WAYLAND_DISPLAY set to our socket (e.g. the Frametop session) // Runs in the dev container (wlroots 0.20); KWin connects from the host. #define _GNU_SOURCE @@ -33,7 +35,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -61,6 +65,12 @@ #include "controller-click.h" #define MAX_SCREENS 24 // screens and spare outputs +// A screen counts as playing a video while its last VIDEO_COMMITS commits each redrew at +// least VIDEO_AREA percent of it, at VIDEO_HZ or more; for VIDEO_HOLD ms after that stops. +#define VIDEO_COMMITS 8 +#define VIDEO_AREA 6 +#define VIDEO_HZ 10 +#define VIDEO_HOLD 1500 struct config { int width, height; @@ -80,6 +90,13 @@ struct screen { int buffer_width, buffer_height; struct wlr_xdg_toplevel_decoration_v1 *decoration; // answered on the first commit struct wl_listener commit, destroy, decoration_destroy, set_title; + // Its frame rate (see tick): when its last frame callback went (ms), and what its recent + // commits looked like, to tell a video playing on it. + uint32_t frame_sent; + uint32_t commit_ms[VIDEO_COMMITS]; + unsigned commit_at; + unsigned big; // a bit per recent commit: it redrew at least VIDEO_AREA percent + uint32_t video_until; // counts as a video until then (ms) }; // Per client buffer: forget its import when it goes away. @@ -104,6 +121,14 @@ struct server { int spares; struct wl_list buffers; // tracked_buffer struct wl_event_source *tick; + int tick_fd; // timerfd, at absolute times in step with the display's vsync (see schedule) + int64_t period_ns, base_ns, synced_ns; // the display's frame time, a tick time, last sync + double phase_ms; // ticks come this long after a vsync + // Frame rates in Hz for each attention level (0: every display frame), and a remote + // viewer's lease: until then (ms) every screen gets full rate ("watch"). + int rate[3]; + uint32_t watch_until; + uint32_t typed_ms; // the last key sent to the desktop: its screen counts as focused struct screen *pointer_focus; struct ft_controller_click controller_click; pid_t child; @@ -149,6 +174,25 @@ static void track_buffer(struct server *s, struct wlr_buffer *buffer) { // ---------------------------------------------------------------- screens +// A video (or anything moving over a large area) on a screen you don't look at keeps the +// full frame rate (see frame_interval): its commits keep redrawing much of it, and keep +// coming as fast as its rate lets them. A cursor blinking or a spinner turning redraws a +// small part, and a page changing now and then doesn't keep coming. +static void note_damage(struct screen *sc, struct wlr_surface *surface, struct wlr_buffer *buffer) { + int n = 0; + const pixman_box32_t *r = pixman_region32_rectangles(&surface->buffer_damage, &n); + int64_t area = 0; + for (int i = 0; i < n; ++i) area += (int64_t)(r[i].x2 - r[i].x1) * (r[i].y2 - r[i].y1); + const bool big = area * 100 >= (int64_t)buffer->width * buffer->height * VIDEO_AREA; + const unsigned all = (1u << VIDEO_COMMITS) - 1; + sc->big = ((sc->big << 1) | big) & all; + const uint32_t t = now_ms(); + const uint32_t oldest = sc->commit_ms[sc->commit_at]; // the commit VIDEO_COMMITS ago + sc->commit_ms[sc->commit_at] = t; + sc->commit_at = (sc->commit_at + 1) % VIDEO_COMMITS; + if (sc->big == all && t - oldest <= VIDEO_COMMITS * 1000 / VIDEO_HZ) sc->video_until = t + VIDEO_HOLD; +} + static void screen_commit(struct wl_listener *l, void *data) { struct screen *sc = wl_container_of(l, sc, commit); struct wlr_xdg_surface *xdg = sc->toplevel->base; @@ -172,6 +216,7 @@ static void screen_commit(struct wl_listener *l, void *data) { if (!buffer) return; sc->frame_pending = true; ++sc->commits; + note_damage(sc, xdg->surface, buffer); sc->buffer_width = buffer->width, sc->buffer_height = buffer->height; if (buffer == sc->held) return; struct wlr_dmabuf_attributes a; @@ -381,12 +426,64 @@ static void keys_update(struct server *s) { offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1); } -// Every ~11 ms (90 Hz): SteamVR events, and frame callbacks for screens that committed. While -// Frametop is paused for a VR game (everything hidden), every 100 ms, and the frame callbacks -// once a second: KWin draws a screen only after its callback, and its apps wait for theirs, so -// the desktop hardly draws until it's resumed. -static int tick(void *data) { +// How often a screen gets its frame callback (ms; 0 every tick). KWin draws a screen only +// after its callback, and its apps wait for theirs, so this is the screen's frame rate: +// full where you look (ft_vr_screen_attention) and for a video, lower for the rest of what +// you see, and about once a second for what you don't (hidden, behind you, paused for a VR +// game). A screen nothing changes on costs nothing at any rate: KWin commits only when +// something on it moved. A remote viewer ("watch") sees every screen at full rate. +static uint32_t frame_interval(struct server *s, struct screen *sc, uint32_t t) { + if ((int32_t)(s->watch_until - t) > 0) return 0; + enum ft_attention a = ft_vr_screen_attention(sc->index); + if (a == FT_IN_VIEW && (int32_t)(sc->video_until - t) > 0) a = FT_FOCUSED; + if (a != FT_FOCUSED && t - s->typed_ms < 1500 && + s->seat->keyboard_state.focused_surface == sc->toplevel->base->surface) + a = FT_FOCUSED; // typing on it while looking elsewhere + const int hz = s->rate[a]; + return hz > 0 ? 1000 / hz : 0; +} + +static int64_t mono_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000000000LL + ts.tv_nsec; +} + +// Ticks come once per display frame, phase_ms after its vsync: KWin gets its frame callbacks +// early in the frame and has the rest of it to draw before vrcompositor takes the panels. +// The vsync is read from SteamVR once a second, and the ticks run at absolute times between +// reads, so they keep their place (a timer set again after each tick, as before, slid +// through the frame and came about 85 times a second at 90 Hz). Without SteamVR's timing +// they still come at the display's rate (90 Hz until known). While paused, every 100 ms. +static void schedule(struct server *s) { + const int64_t now = mono_ns(); + int64_t next; + if (ft_vr_paused()) { + next = now + 100000000LL; + } else { + double since, hz; + if (now - s->synced_ns >= 1000000000LL) { + s->synced_ns = now; + if (ft_vr_vsync(&since, &hz)) { + const int64_t period = (int64_t)(1e9 / hz); + if (llabs(period - s->period_ns) > 100000) wlr_log(WLR_INFO, "display at %.1f Hz", hz); + s->period_ns = period; + s->base_ns = now - (int64_t)(since * 1e9) + (int64_t)(s->phase_ms * 1e6); + while (s->base_ns > now) s->base_ns -= s->period_ns; + } + } + next = s->base_ns + ((now - s->base_ns) / s->period_ns + 1) * s->period_ns; + } + struct itimerspec its = {.it_value = {.tv_sec = next / 1000000000LL, .tv_nsec = next % 1000000000LL}}; + timerfd_settime(s->tick_fd, TFD_TIMER_ABSTIME, &its, NULL); +} + +// Each tick: SteamVR events, and frame callbacks for screens that committed and are due. +static int tick(int fd, uint32_t mask, void *data) { struct server *s = data; + uint64_t expirations; + const ssize_t got = read(fd, &expirations, sizeof expirations); // clears it; how many doesn't matter + (void)got; ft_vr_poll(handle_vr_event, s); if (++s->ticks % 9 == 0) keys_update(s); if (s->kb_close_at && s->ticks >= s->kb_close_at) { @@ -398,15 +495,17 @@ static int tick(void *data) { } struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); - const bool paused = ft_vr_paused(); - for (int i = 0; i < MAX_SCREENS && (!paused || s->ticks % 10 == 0); ++i) { + const uint32_t t = now_ms(); + // Due within half a tick counts as due, so 15 Hz is every 6th tick at 90 Hz, not 7th. + const uint32_t slack = ft_vr_paused() ? 50 : (uint32_t)(s->period_ns / 2000000); + for (int i = 0; i < MAX_SCREENS; ++i) { struct screen *sc = s->screens[i]; - if (sc && sc->frame_pending) { - sc->frame_pending = false; - wlr_surface_send_frame_done(sc->toplevel->base->surface, &now); - } + if (!sc || !sc->frame_pending || t - sc->frame_sent + slack < frame_interval(s, sc, t)) continue; + sc->frame_pending = false; + sc->frame_sent = t; + wlr_surface_send_frame_done(sc->toplevel->base->surface, &now); } - wl_event_source_timer_update(s->tick, paused ? 100 : 11); + schedule(s); return 0; } @@ -431,6 +530,7 @@ static bool key_held(const struct wlr_keyboard *kb, uint32_t code) { // One key to the focused screen, through the seat's keyboard so its xkb state and // modifiers stay right. static void send_key(struct server *s, uint32_t code, int pressed) { + s->typed_ms = now_ms(); struct wlr_keyboard_key_event ev = { .time_msec = now_ms(), .keycode = code, .update_state = true, .state = pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED}; @@ -584,6 +684,38 @@ static int control_readable(int fd, uint32_t mask, void *data) { handle_vr_event(&e, s); snprintf(reply, sizeof reply, "ok"); } + } else if (sscanf(buf, "watch %d", &value) == 1) { + // A remote viewer is watching for that many seconds (vnc-bridge.sh renews it). + s->watch_until = now_ms() + (uint32_t)(value < 0 ? 0 : value > 60 ? 60 : value) * 1000; + snprintf(reply, sizeof reply, "ok"); + } else if (sscanf(buf, "rates %d %d %d", &index, &w, &h) == 3) { + // Frame rates in Hz: focused, in view, hidden (0: every display frame). + if (index < 0 || w < 0 || h < 0 || index > 240 || w > 240 || h > 240) { + snprintf(reply, sizeof reply, "error rates (Hz, 0 full)"); + } else { + s->rate[FT_FOCUSED] = index, s->rate[FT_IN_VIEW] = w, s->rate[FT_HIDDEN] = h; + wlr_log(WLR_INFO, "frame rates: focused %d, in view %d, hidden %d (0 full)", index, w, h); + snprintf(reply, sizeof reply, "ok"); + } + } else if (sscanf(buf, "phase %lf", &scale) == 1) { + // Ticks this long after the vsync (ms), for tuning. + if (!(scale >= 0 && scale < 20)) snprintf(reply, sizeof reply, "error phase "); + else s->phase_ms = scale, s->synced_ns = 0, snprintf(reply, sizeof reply, "ok"); + } else if (strcmp(buf, "rates?") == 0) { + // "ok " and a line per + // screen: " hidden|view|focused