Every simulated frame gets one render-time sample. A rolling window of the last 180 samples is re-sorted on every update so the percentiles are exact, not approximated:
frame_budget_ms = 1000 / target_fps
jank(frame) = frame_time_ms > frame_budget_ms
jank_rate = janky_frames / window_size
P50/P90/P99 = sorted(window)[ceil(p/100 * N) - 1]
avg_fps = 1000 / mean(recent frame times)
The frame-time stream is synthetic but built the way a real device's is: a base render cost (layout + paint + composite) plus Gaussian jitter from scheduling noise, plus rare long-tail spikes (GC pauses, thermal throttling, a dropped compositor frame) whose frequency and size you control directly. The strip chart scrolls one bar per simulated frame, colored green/amber/red by how close it came to the budget; the histogram bins the same window's distribution so you can see the long right tail that a single "average FPS" number erases. The comparison panel computes a mean-based average FPS and the sorted-window P99/jank-rate from the identical samples, side by side — a workload with a small, frequent spike rate can post a fine-looking average (e.g. ~58 fps) while its P99 and jank rate show real, visible stutter.
- Target refresh rate — sets the budget line: 60 FPS gives every frame 16.7 ms to finish.
- Base render cost / jitter — the mean and spread of the simulated per-frame UI-thread work.
- Jank-spike frequency / magnitude — how often a long-tail stall happens and how large it is, the classic signature of GC pauses or thermal throttling.