tiffany-mares/opengl-renderer

Hitting a frame deadline on an OS that doesn't want you to.

A threaded OpenGL renderer's frame pacer, measured: zero missed deadlines out of 9,500 at 144 Hz where a naive sleep loop misses half — and what that costs.

0 fps
0.00 ms/frame

The real renderer, compiled to WebAssembly (WebGL2). Click the cube for keyboard focus: arrows yaw/pitch, SPACE pauses. This build is single-threaded and paced by requestAnimationFrame on purpose — neither high-resolution sleep nor a second GL thread exists on a browser main thread, so the pacer and the threaded render deliberately do not port. Every number below comes from the native build.

Backstory

This is the writeup of a C++20 OpenGL renderer whose real subject was never the cube — it was three systems problems: a graphics context that must live on one thread, a lock-free input handoff between two, and hitting a frame deadline on a general-purpose OS. The cube is the demo. The pacer is the point.

A render loop is simple on paper: do the work, wait for the next deadline, repeat. The "wait" is where the OS gets a say. A request to sleep for a few milliseconds is honored at the scheduler's convenience — on a stock Windows CI runner that means waking on the 15.6 ms timer tick, and even on a desktop with a high-resolution timer request the wake lands 2–3 ms late often enough to eat the frame. The OS is optimized for throughput and battery, not sub-millisecond wake-up precision.

Everything below is measured, not asserted: 10,000 frames per configuration (first 500 discarded as warmup), per-frame CSV logging with no file IO inside a timed frame, and a synthetic ~100 µs CPU workload per frame so the numbers characterize the pacer rather than the GPU. Three platforms: the desktop run of record (Windows 11, Intel Arc) and two weekly CI runners (windows-latest and ubuntu-latest, both Mesa llvmpipe software GL — honestly a different measurement class, labeled as such).

How it works

The pacer schedules absolute deadlines: next += period, never now + period. When a deadline is missed, the miss is counted and the schedule re-anchored at the current time — the debt is dropped, never repaid with a burst of short frames. (The repo carries a --resched=relative flag purely to measure the classic drift bug: a schedule restarted from "now" leaks every frame's work time into the timeline and cannot observe itself being late.)

The wait is split in two. First the thread sleeps to the deadline minus a margin, using each platform's sharpest timer — CreateWaitableTimerExW with the high-resolution flag on Windows, clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME) on Linux. Then it spins the remainder on the monotonic clock. The margin is not a constant: it is estimated online from the measured overshoot of every sleep — Welford mean + 3σ, clamped to half the period — because the quantity it must cover is machine- and power-plan-dependent. A constant tuned on one machine is wrong on the next, in either direction.

Every frame is measured against the same monotonic clock: deadline-to-deadline frame time, requested vs actual sleep, and end-to-end input latency (consume time minus the publish timestamp carried in the input snapshot). The per-run CSV is preallocated and written only at exit — file IO never happens inside a timed frame.

  1. 01Main thread polls keys and publishes an input snapshot at ~1 kHz — it never touches GL.
  2. 02Render thread consumes the latest snapshot and submits the frame's GL work.
  3. 03Swap buffers (vsync off — glfwSwapInterval(0); the pacer owns the frame clock).
  4. 04Sleep to the deadline minus the Welford-estimated margin, then spin the last stretch.
  5. 05On a miss: count it, re-anchor the schedule, keep the frame period honest.

Why this is different from a standard OpenGL renderer

Most OpenGL renderers stop at vsync: they hand the frame cadence to the driver and live with whatever comes back. This project treats frame delivery as a measured systems problem.

  • Explicit frame pacer instead of swap interval

    The render loop owns its own deadline and measures its own interval. VSync is off at context creation; the pacer decides when the next frame starts, and reports every miss instead of hiding it in the driver.

  • An adaptive spin margin, measured per machine

    The sleep-short margin is a Welford mean + 3σ estimate of this machine's actual wake overshoot, updated every frame. The tables below show what it buys: the bare high-res timer hands its wake jitter straight to frame starts; timer + spin absorbs it for a measured CPU cost.

  • Input decoupled from the frame rate

    Input is published at ~1 kHz from the main thread through a swappable handoff (mutex, atomic bitmask, or seqlock) and consumed by the render thread — so a frame cap no longer sets input latency. Measured end-to-end: p50 ≈ 0.64 ms against a 6.9 ms frame period.

  • Misses are counted, never chased

    Absolute deadlines with re-anchoring on a miss. No catch-up bursts, no silent drift — the missed-deadline column in every table is the pacer grading itself.

  • Instrumentation with no observer cost

    Per-frame records go into a preallocated buffer, flushed only at exit. Render-thread CPU time comes from cycle-accurate counters (QueryThreadCycleTime calibrated against the pacer clock; CLOCK_THREAD_CPUTIME_ID on POSIX), not tick-sampled APIs.

p99 frame time · 144 Hz

6.944ms

naive sleep: 9.792 ms

missed deadlines

0.00%

naive sleep: 50.0 %

render thread CPU

12.0%

naive sleep: 6.3 % — the spin costs this

Intel(R) Core(TM) Ultra 7 155H; Intel(R) Arc(TM) Graphics; Windows 11 Home; AC power, idle · 2026-07-27 · bench/results/2026-07-27-pacing-matrix.md (commit 45f87ee)

Frame interval distribution — Windows 11 · Arc, 144 Hz

log y · 50 µs data, 0.25 ms display bins · 9,500 frames per cell
1101001k10ktarget 6.94 ms02468101214161820frame interval, start-to-start (milliseconds)
naive sleep (--pace=sleep)tuned pacer (--pace=timer_spin)target interval (6.94 ms)

Results — tuned pacer (--pace=timer_spin)

platformHzp50p95p99maxmissed %CPU %
Windows 11 · Arc6016.66716.66716.667214.3520.037.8
Windows 11 · Arc1446.9446.9446.9446.9440.0012.0
Windows 11 · Arc2404.1674.1674.1674.1670.0016.1
CI · Windows6016.66716.66716.66716.6670.007.6
CI · Windows1446.9446.9446.9446.9440.0017.8
CI · Windows2404.1674.1674.1674.1670.0030.1
CI · Ubuntu6016.66716.66716.66716.6670.002.0
CI · Ubuntu1446.9446.9446.9446.9440.004.4
CI · Ubuntu2404.1674.1674.1674.1670.007.5

Baseline — naive sleep loop (--pace=sleep)

platformHzp50p95p99maxmissed %CPU %
Windows 11 · Arc6016.66716.66717.29118.2272.653.2
Windows 11 · Arc1447.1539.5229.79211.04850.006.3
Windows 11 · Arc2408.28712.31312.56214.88550.006.2
CI · Windows6016.66716.66717.76530.4583.195.0
CI · Windows1446.94416.41117.31018.60530.029.6
CI · Windows2404.16712.21114.89344.41436.5512.7
CI · Ubuntu6016.66716.66716.66716.6670.001.8
CI · Ubuntu1446.9446.9446.9446.9440.004.1
CI · Ubuntu2404.1674.1674.1674.1670.006.7

Frame time is deadline-to-deadline, in milliseconds — a zero-miss cell sits exactly at the period by construction, and the wake jitter each strategy absorbs is what the histogram above shows (start-to-start). Missed is the share of the 9,500 measured frames whose deadline was missed. The desktop tuned 60 Hz max of 214.352 ms is a single OS/driver stall caught mid-run (3 misses of 9,500), left in the data.

Input handoff: mutex vs lock-free

The second system: input crosses from the main thread (publishing at ~1 kHz) to the render thread through one of three interchangeable backends — a std::mutex around a small POD (the default), an atomic key bitmask, and a seqlock carrying the full payload with its publish timestamp. Selected at startup with --input=mutex|bitmask|seqlock.

This section is rate-independent — its x-axis IS the publish rate, so the Hz filter above doesn't apply here.

Uncontended per-op cost — Windows 11 · Arc

05101516.615.9mutex8.81.5bitmask2.32.1seqlockns/op
publish read

Amortized throughput from 1 M-iteration batches — comparable across backends, not "what one isolated call costs."

Contended sweep — Windows 11 · Arc

101001k1k10k100k1M10M≈100 k/s — the crossoverachieved publishes per second (log)reader p99 (ns, log)
mutexbitmaskseqlock

Reader p99 vs achieved publish rate. Points sit at each run's achieved rate; the unthrottled cells land where each backend actually reached. Low-rate read tails sit at each machine's clock measurement floor — 100 ns on the Windows machines, ≈30–50 ns on the Ubuntu runner.

In-app input latency — desktop run of record

backendpoll targetachieved Hzp50 latencyp99 latencyretries/s
mutex1,000970.70.639 ms2.754 ms0.0
mutex10,0003096.30.343 ms2.041 ms0.0
bitmask1,000968.10.0
bitmask10,0003364.20.0
seqlock1,000967.90.529 ms3.356 ms0.0
seqlock10,0003068.30.333 ms5.106 ms1266.2

In-app end-to-end latency (consume time − publish timestamp), desktop run of record. The bitmask cannot carry the timestamp — 32 bits of keys is all it holds — so its latency is unmeasurable by construction: the Phase-4 asymmetry made visible. App cells are a desktop protocol; CI runs measure only the micro-benchmark.

Decision log

Render on a worker thread, input on main

The main thread owns the window and event queue only — it polls keys and publishes a snapshot at ~1 kHz and never touches GL; a dedicated render thread makes the context current and owns every GL object. The decoupled rates are the payoff: ~970 Hz achieved input publishing against a 144 Hz frame consumer, with measured end-to-end input latency of p50 ≈ 0.64 ms against a 6.9 ms frame period. The price was paid once, in shutdown ordering: signal stop → render thread deletes its GL objects and detaches → join → destroy the window on main.

would change my mind: a windowing layer without the main-thread event constraint, or a target where second-thread contexts don't exist — the browser build already collapses to single-threaded for exactly that reason.

Absolute deadlines, never relative

The pacer schedules next += period, never now + period. Two otherwise-identical 60-second runs differ only in this rule: absolute ends 0.345 ms from ideal; relative leaks every frame's work time and wake overshoot into the schedule permanently — +0.664 ms per frame, 5.7 s behind after a minute — while reporting zero missed deadlines the whole way, because a schedule restarted from now cannot observe itself being late.

would change my mind: a workload whose frames routinely exceed the period — but an absolute schedule fails that loudly (counted misses), and the fix is lowering the target rate, not a policy that hides the same failure as silent drift.

An adaptive spin margin, not a constant

The margin the sleep must undershoot by is machine- and power-plan-dependent: this desktop's naive sleep wakes 2–3 ms late, while a stock Windows CI runner sleeps in full 15.6 ms scheduler ticks — a constant tuned on either machine is wrong on the other, in either direction. So the pacer estimates it online from every sleep's measured overshoot: Welford mean + 3σ, clamped to half the period, with a 1.5 ms bootstrap until 16 samples.

would change my mind: an overshoot distribution heavy-tailed enough that mean + 3σ under-covers — it would show up as rising missed-deadline counts — would argue for a quantile tracker; a hard-real-time platform with bounded overshoot would argue for a small constant.

Mutex by default, lock-free behind a flag

A std::mutex around a small POD is the shipping input handoff; the lock-free backends exist, are tested, and are not the default. At this app's real rates — 1,000 publishes/s against 144 reads/s — the expected number of reads that ever meet a held lock is ≈0.002 per second, and the measured zeros agree: five of six app cells logged exactly zero reader retries. The contended sweep puts the crossover where the seqlock first measurably wins at ≈100,000 publishes/s — two orders of magnitude above this app. Below that, choosing the seqlock is a design statement, not a performance win.

would change my mind: publish rates approaching the measured crossover, a writer that must never block (an audio callback), or multiple readers — none of which this app has.

Hand-rolled mat4, no glm

The project's subject is threads, handoff, and pacing; the matrix code exists to put a cube on screen and to be read. One small column-major header with exactly the four operations the demo needs, a dependency-free test suite including a 16-element perspective reference check, and its two sharp edges documented where they cut (transpose=GL_FALSE, and zNear/zFar because Windows headers #define near and far).

would change my mind: the first feature needing quaternions, SIMD, or more than a handful of ops — the moment the matrix code stops being trivially reviewable, glm goes in.

Known limitations

  • The seqlock's payload read is formally a data raceundefined behavior under the C++ memory model. The retry loop discards every torn copy on real hardware (torn=0 in all 15 contended-sweep rows) and the construction is the standard practical one, but it is still bending a rule of the abstract machine — naming that is the point.
  • macOS timer precision is untuned: the mach_wait_until path compiles but real precision there wants THREAD_TIME_CONSTRAINT_POLICY, which is left unset. Numbers here are Windows and Linux only; the macOS path is compiled, not measured.
  • No GPU-side timing. Every number is CPU-side by design (the ~100 µs synthetic workload characterizes the pacer, not the GPU), so driver behavior is visible only by its side effects — on battery this machine's Arc driver frame-limits GL inside SwapBuffers, which is why every run of record demands AC power.
  • The CI platforms run Mesa llvmpipe software rasterization on shared virtualized runners with no AC/idle control — a different measurement class. Compare CI platforms to each other and across weeks, not to the desktop tables.
  • The browser build on this page is not the pacer: it is single-threaded and paced by requestAnimationFrame, because neither high-resolution sleep nor a second GL thread exists on a browser main thread.

Build

git clone https://github.com/tiffany-mares/OpenGL-Renderer
cd OpenGL-Renderer
cmake -B build            # Linux: add -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure

# run it (Windows: build\Release\cube.exe, Linux: build/cube)
cube --fps 144                          # paced, timer_spin default
cube --fps 144 --pace=sleep             # feel the naive baseline
cube --fps 144 --bench-frames 10000 --log out.csv
python bench/run_matrix.py              # the full 13-cell matrix