feat(menu): FPS counter levels: frame time, a graph and where the time goes #2181

Merged
hckrman101 merged 3 commits from feat/fps-counter-levels into main 2026-11-11 10:59:07 +00:00
hckrman101 commented 2026-11-11 06:24:32 +00:00 (Migrated from github.com)

The FPS counter shows one number. It tells you the frame rate dropped, but not why, and not whether it was one long frame (a stutter) or a slow stretch. This turns the row into levels that add the frame time, a graph of the last few seconds and where each frame's time goes.

What changed

The Gameplay tab's FPS counter row now has five values, and a new FPS counter position row picks its corner. An options file from before keeps working: its fps_counter=1 (On) is FPS, the counter as it was.

Level Shows
Off nothing
FPS the number alone, exactly as before: classic green, outlined (#1957); with frame generation on, the game's own rate too
Frame time + the game's frame time over the last half second and the 1% low (the rate of the slowest 1% of frames over the last 10 s). From here on, the number and the frame time are green at the target rate, amber under it, red under two thirds of it. The target is the Game frame rate cap's rate, or the display's refresh with V-Sync On when that is slower. With Uncapped and V-Sync not On there is no target: no line, and the colours are against 60
Graph + the last 4 s of frame times: a thin line over a soft fill, each point the slowest frame of its span (so no stutter falls between two), a faint dashed line at the cap's frame time. The scale follows the usual frame, so a spike is drawn at the top and marked red instead of flattening everything else
Breakdown + where the time goes, as a bar and as numbers: Game, Renderer, GPU wait, Pacing; the GPU time per frame; Shaders built and Draws left out over the 4 s, with red marks on the graph where they happened

Like the counter, it has no plate: the same Dear ImGui foreground draw list, text with a soft shadow, tabular digits so the numbers don't jitter, text refreshed 4 times a second, sized with the Menu size and the window (checked at 720p, 900p and 4K). While the menu is open, the counter stays at the top right under the banner with the number and frame time only, as before, so it doesn't cover the menu.

Where the numbers come from

The parts are those of the game's presenting thread, present to present, and they add up to the frame time:

  • Renderer: time inside the renderer's entries on that thread (submit, present, nvn wait), which replay and record there (sync replay, the default), minus the waits inside them (gpu/vk/vk_fps.cpp FpsRender).
  • GPU wait: the replay's waits for a recording slot (st_wait_ns, already measured), the semaphore waits of nvnSyncWait and finish, the swapchain frame's fence, and OoT's own present poll for its frame's GPU work (its SleepThread at 0x80590520, timed by the host).
  • Pacing: the window acquire's frame-pacing sleep (host, on whichever thread does it: OoT's main thread), the presenting thread's other guest sleeps, the swapchain acquire and vkQueuePresentKHR (vsync), and the Reflex / Anti-Lag 2 wait.
  • Game: the rest: the game's own code on that thread, and its waits for its main thread.
  • GPU time: the timestamps the replay already writes at the start and end of each recording (gpu_time_collect). They are read when the recording's slot comes round again, so nothing waits for them and the number trails by a few frames.
  • Shaders built / draws left out: the renderer's existing counters (st_pipelines, st_pending_skips).

The host gives the back end its sleeps through a new MkwVkHost.slept_ns, timed only while Breakdown shows (MkwVkHost.fps_timing). That makes the API version 15 (main is 14). The control bridge #926 also takes 15 with other fields: whichever of #926 and this PR merges second needs one more bump. The ring of frames, averages, 1% low, graph columns and scale are plain functions in gpu/vk/fps_stats.h.

Cost

Off and FPS measure nothing new: a relaxed load per present and per renderer entry, and a load of a flag per guest sleep in the host. Frame time and Graph read a clock once per present. Breakdown adds a TSC read at each renderer entry and around its waits, and the host times its sleeps. The graph is rebuilt from the frames at most 60 times a second, and the text is drawn in runs, not glyph by glyph, so the cost stays small even with the cap at Uncapped.

Measured on an RTX 3090 (driver 620.70), Linux, 2560x1440 window, Kokiri Forest standing still, frame rate cap 240 so the game is not paced, each run a fresh start, 45 s from the renderer's frame log (RX_VK_FRAME_LOG), with nothing else on the GPU:

run 1 run 2 run 3 mean
Off 47.3 fps 47.1 fps 48.0 fps 47.4 fps
Breakdown 43.5 fps 48.8 fps 52.9 fps 48.4 fps

(Measured before the self-review below, whose changes make the counter cheaper: fewer text draws, the graph rebuilt at most
60 times a second, the host's timers off below Breakdown.)

No measurable loss: runs of the same setting differ by more (other work on the PC) than the two settings do.

Screenshots

The renderer's own frames (headless runs with RX_VK_SHOT_MENU=1), 1600x900 unless noted. Bright scene: Desert Colossus. Dark scene: Bottom of the Well.

Bright Dark
FPS
Frame time
Graph
Breakdown

Breakdown at 1280x720 and at 3840x2160, and in the bottom-left corner:



(The frame rates in the shots are from a busy shared PC, and a frame that takes a screenshot is itself slow.)

Self-review

  • Uncapped (#2122): the cap's seventh value was read as 240 (a 4.2 ms target). The target now follows the host's rules, including HOS_FPS_MAX over the row. Uncapped has no target line and grades colours against 60, and its graph scale starts at 4 ms.
  • V-Sync On with a display slower than the cap now targets the display's refresh.
  • Off: the host no longer times guest sleeps while Breakdown is off (a flag set by the back end).
  • High frame rates:
    • the ring holds 16384 frames, so the 1% low covers its full 10 s up to 1600 fps;
    • the graph is rebuilt at most 60 times a second;
    • the text is drawn in runs, not ten times per glyph.
  • The first Breakdown frame after switching level is kept in the graph.
  • Checked: thread safety (the ring's lock, per-thread timers, atomic host counters), level changes at runtime, 0 fps and a paused game, the menu open, ini compatibility both ways.

Tests

  • tests/test_fps_stats.py / tests/test_fps_stats.cpp: the levels and the old value's mapping, the corners, the split into parts, the ring, the 1% low, the summaries, the graph's columns and marks, the scale (a spike doesn't set it), the colour grades and the text. It also checks the wiring: the rows, a frame stamped at each present, the timers gated on Breakdown, and the host's sleeps.
  • python -I -B tests/run_all.py on Linux, after merging main: 357 passed. The 4 failures need packages not installed on this PC (zstandard for two of them, tkinter), or are the libdecor hint check, which fails on main here too.
  • In game (Linux, RTX 3090): every level over a bright and a dark scene, three window sizes, two corners, and the timing above.
The FPS counter shows one number. It tells you the frame rate dropped, but not why, and not whether it was one long frame (a stutter) or a slow stretch. This turns the row into levels that add the frame time, a graph of the last few seconds and where each frame's time goes. ## What changed The Gameplay tab's **FPS counter** row now has five values, and a new **FPS counter position** row picks its corner. An options file from before keeps working: its `fps_counter=1` (On) is **FPS**, the counter as it was. | Level | Shows | |---|---| | Off | nothing | | FPS | the number alone, exactly as before: classic green, outlined (#1957); with frame generation on, the game's own rate too | | Frame time | + the game's frame time over the last half second and the 1% low (the rate of the slowest 1% of frames over the last 10 s). From here on, the number and the frame time are green at the target rate, amber under it, red under two thirds of it. The target is the Game frame rate cap's rate, or the display's refresh with V-Sync On when that is slower. With Uncapped and V-Sync not On there is no target: no line, and the colours are against 60 | | Graph | + the last 4 s of frame times: a thin line over a soft fill, each point the slowest frame of its span (so no stutter falls between two), a faint dashed line at the cap's frame time. The scale follows the usual frame, so a spike is drawn at the top and marked red instead of flattening everything else | | Breakdown | + where the time goes, as a bar and as numbers: **Game**, **Renderer**, **GPU wait**, **Pacing**; the **GPU time** per frame; **Shaders built** and **Draws left out** over the 4 s, with red marks on the graph where they happened | Like the counter, it has no plate: the same Dear ImGui foreground draw list, text with a soft shadow, tabular digits so the numbers don't jitter, text refreshed 4 times a second, sized with the Menu size and the window (checked at 720p, 900p and 4K). While the menu is open, the counter stays at the top right under the banner with the number and frame time only, as before, so it doesn't cover the menu. ### Where the numbers come from The parts are those of the game's presenting thread, present to present, and they add up to the frame time: - **Renderer**: time inside the renderer's entries on that thread (submit, present, nvn wait), which replay and record there (sync replay, the default), minus the waits inside them (`gpu/vk/vk_fps.cpp` `FpsRender`). - **GPU wait**: the replay's waits for a recording slot (`st_wait_ns`, already measured), the semaphore waits of `nvnSyncWait` and finish, the swapchain frame's fence, and OoT's own present poll for its frame's GPU work (its `SleepThread` at 0x80590520, timed by the host). - **Pacing**: the window acquire's frame-pacing sleep (host, on whichever thread does it: OoT's main thread), the presenting thread's other guest sleeps, the swapchain acquire and `vkQueuePresentKHR` (vsync), and the Reflex / Anti-Lag 2 wait. - **Game**: the rest: the game's own code on that thread, and its waits for its main thread. - **GPU time**: the timestamps the replay already writes at the start and end of each recording (`gpu_time_collect`). They are read when the recording's slot comes round again, so nothing waits for them and the number trails by a few frames. - **Shaders built / draws left out**: the renderer's existing counters (`st_pipelines`, `st_pending_skips`). The host gives the back end its sleeps through a new `MkwVkHost.slept_ns`, timed only while Breakdown shows (`MkwVkHost.fps_timing`). That makes the API version 15 (main is 14). The control bridge #926 also takes 15 with other fields: whichever of #926 and this PR merges second needs one more bump. The ring of frames, averages, 1% low, graph columns and scale are plain functions in `gpu/vk/fps_stats.h`. ### Cost Off and FPS measure nothing new: a relaxed load per present and per renderer entry, and a load of a flag per guest sleep in the host. Frame time and Graph read a clock once per present. Breakdown adds a TSC read at each renderer entry and around its waits, and the host times its sleeps. The graph is rebuilt from the frames at most 60 times a second, and the text is drawn in runs, not glyph by glyph, so the cost stays small even with the cap at Uncapped. Measured on an RTX 3090 (driver 620.70), Linux, 2560x1440 window, Kokiri Forest standing still, frame rate cap 240 so the game is not paced, each run a fresh start, 45 s from the renderer's frame log (`RX_VK_FRAME_LOG`), with nothing else on the GPU: | | run 1 | run 2 | run 3 | mean | |---|---|---|---|---| | Off | 47.3 fps | 47.1 fps | 48.0 fps | 47.4 fps | | Breakdown | 43.5 fps | 48.8 fps | 52.9 fps | 48.4 fps | (Measured before the self-review below, whose changes make the counter cheaper: fewer text draws, the graph rebuilt at most 60 times a second, the host's timers off below Breakdown.) No measurable loss: runs of the same setting differ by more (other work on the PC) than the two settings do. ## Screenshots The renderer's own frames (headless runs with `RX_VK_SHOT_MENU=1`), 1600x900 unless noted. Bright scene: Desert Colossus. Dark scene: Bottom of the Well. | | Bright | Dark | |---|---|---| | FPS | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/fps-bright.png) | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/fps-dark.png) | | Frame time | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/frametime-bright.png) | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/frametime-dark.png) | | Graph | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/graph-bright.png) | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/graph-dark.png) | | Breakdown | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/breakdown-bright.png) | ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/breakdown-dark.png) | Breakdown at 1280x720 and at 3840x2160, and in the bottom-left corner: ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/breakdown-720p.png) ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/breakdown-4k.png) ![](https://github.com/hckrman101/Hookshot/raw/pr-assets/fps-counter/breakdown-bottomleft.png) (The frame rates in the shots are from a busy shared PC, and a frame that takes a screenshot is itself slow.) ## Self-review - **Uncapped (#2122):** the cap's seventh value was read as 240 (a 4.2 ms target). The target now follows the host's rules, including `HOS_FPS_MAX` over the row. Uncapped has no target line and grades colours against 60, and its graph scale starts at 4 ms. - **V-Sync On** with a display slower than the cap now targets the display's refresh. - **Off:** the host no longer times guest sleeps while Breakdown is off (a flag set by the back end). - **High frame rates:** - the ring holds 16384 frames, so the 1% low covers its full 10 s up to 1600 fps; - the graph is rebuilt at most 60 times a second; - the text is drawn in runs, not ten times per glyph. - **The first Breakdown frame** after switching level is kept in the graph. - **Checked:** thread safety (the ring's lock, per-thread timers, atomic host counters), level changes at runtime, 0 fps and a paused game, the menu open, ini compatibility both ways. ## Tests - `tests/test_fps_stats.py` / `tests/test_fps_stats.cpp`: the levels and the old value's mapping, the corners, the split into parts, the ring, the 1% low, the summaries, the graph's columns and marks, the scale (a spike doesn't set it), the colour grades and the text. It also checks the wiring: the rows, a frame stamped at each present, the timers gated on Breakdown, and the host's sleeps. - `python -I -B tests/run_all.py` on Linux, after merging main: 357 passed. The 4 failures need packages not installed on this PC (`zstandard` for two of them, `tkinter`), or are the libdecor hint check, which fails on main here too. - In game (Linux, RTX 3090): every level over a bright and a dark scene, three window sizes, two corners, and the timing above.
Commenting is not possible because the repository is archived.
No description provided.