bb2f22bd0a
Captures the idle-CPU findings and patterns from the perf work: event-driven idle, raylib's constant ~5% busy-wait, lazy audio device, headless render recipe, and the warnings that bit us. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
6.7 KiB
Markdown
144 lines
6.7 KiB
Markdown
# raylib lessons learned — desktop (non-game) applications
|
||
|
||
Notes accumulated while building rspektrum (a spectrogram viewer) on top of
|
||
raylib. raylib is built for games — a fixed-step loop that redraws every frame —
|
||
so most of these are about making it behave like a *desktop tool* that sits
|
||
idle, respects the OS, and doesn't cook the laptop. Concrete, learned-the-hard-
|
||
way, not the docs.
|
||
|
||
## Idle CPU: the three things that secretly burn a core
|
||
|
||
A "doing nothing" raylib window can easily hold 30–40% of a core. There are
|
||
three independent culprits; fixing one without the others gets you nowhere.
|
||
|
||
### 1. You are redrawing 60×/sec for no reason
|
||
The default loop calls `BeginDrawing()`/`EndDrawing()` every frame even when
|
||
nothing changed. For a UI, draw only when something actually changed.
|
||
|
||
- Lower the active frame rate. **30 fps is plenty for a UI** — `SetTargetFPS(30)`
|
||
halves per-frame cost vs. 60 with no perceptible difference for a non-game.
|
||
- Gate "is anything happening" behind a single predicate (mouse moved, a key is
|
||
down, window resized, an animation/playback/background job is running, a drag
|
||
is in progress, a transient message timer is alive). If none are true, you're
|
||
idle.
|
||
|
||
### 2. raylib's frame limiter busy-waits ~5% of a core *regardless of FPS*
|
||
This is the big surprise. raylib is built with `SUPPORT_PARTIALBUSY_WAIT_LOOP=1`,
|
||
so `EndDrawing()`'s frame limiter spins in a partial busy-wait for the tail of
|
||
each frame interval. **Lowering `SetTargetFPS` from 60 → 10 → 5 does almost
|
||
nothing to idle CPU** because the busy-wait is a fraction of *each* frame, not a
|
||
fixed amount of work — fewer, longer frames still each end in a spin.
|
||
|
||
The fix is to stop running the frame loop at all when idle: switch to
|
||
**event-driven waiting**.
|
||
|
||
```c
|
||
// raylib exposes glfwWaitEvents() through these:
|
||
EnableEventWaiting(); // PollInputEvents() now blocks in glfwWaitEvents()
|
||
DisableEventWaiting(); // back to glfwPollEvents() (non-blocking)
|
||
```
|
||
|
||
With `EnableEventWaiting()` active and `SetTargetFPS(0)`, the loop blocks in the
|
||
OS until an input event arrives — true ~0% idle, and it wakes instantly on mouse
|
||
move / key / focus. Pattern that worked well (track state so you only toggle on
|
||
transitions, not every frame):
|
||
|
||
```c
|
||
static double lastActive = -1000.0;
|
||
static int waiting = -1; // -1 unknown, 0 active, 1 waiting
|
||
bool focused = IsWindowFocused();
|
||
if (focused && AppIsDoingSomething()) lastActive = GetTime();
|
||
bool active = focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS);
|
||
|
||
if (active) {
|
||
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(30); waiting = 0; }
|
||
} else {
|
||
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
|
||
// ... release expensive resources here (see audio, below) ...
|
||
}
|
||
```
|
||
|
||
Keep a small **grace window** (~0.5 s) after the last activity before dropping
|
||
into the blocking wait, so a quick pause mid-interaction doesn't feel laggy.
|
||
|
||
`WindowShouldClose()` still works under event-waiting — it returns a cached flag
|
||
refreshed by `PollInputEvents()`, which `glfwWaitEvents()` drives.
|
||
|
||
### 3. The audio device runs a mixing thread forever
|
||
`InitAudioDevice()` starts miniaudio's device thread (`ma_device_start`) and it
|
||
keeps running — costing a steady ~1–2% of a core — until `CloseAudioDevice()`.
|
||
**Don't open the audio device at startup.** Open it lazily on first playback and
|
||
release it when idle:
|
||
|
||
```c
|
||
void EnsureAudioDevice(void){ if (!IsAudioDeviceReady()) InitAudioDevice(); }
|
||
void ReleaseAudioDevice(void){
|
||
if (!IsAudioDeviceReady()) return;
|
||
if (sound.frameCount) { UnloadSound(sound); sound = (Sound){0}; }
|
||
CloseAudioDevice();
|
||
}
|
||
```
|
||
|
||
Caveat: raylib only exposes `InitAudioDevice` / `CloseAudioDevice` /
|
||
`IsAudioDeviceReady`. There is **no pause/stop** for the device thread —
|
||
`ma_device_uninit` (inside Close) joins the thread. So to "stop" it you must
|
||
fully close it. Guard the release so you don't close mid-playback: only release
|
||
when `!IsSoundPlaying(...)`. (We release in the idle branch above, gated on "not
|
||
currently playing.")
|
||
|
||
## Window focus
|
||
|
||
- `IsWindowFocused()` lets you pause rendering entirely when backgrounded. A
|
||
hidden/unfocused window doesn't need to draw at all — combine with event-
|
||
waiting and `continue` past `BeginDrawing()` when unfocused. You still want to
|
||
`PollInputEvents()` so you notice when focus returns.
|
||
- Combined, "unfocused → stop drawing + release audio + block on events" got a
|
||
backgrounded window to ~0%.
|
||
|
||
## Headless rendering (no visible window)
|
||
|
||
You can render to an offscreen framebuffer and export a PNG without a visible
|
||
window — useful for CLI/batch image generation:
|
||
|
||
```c
|
||
SetConfigFlags(FLAG_WINDOW_HIDDEN); // BEFORE InitWindow
|
||
InitWindow(w, h, "title");
|
||
// ... do your drawing in one BeginDrawing/EndDrawing ...
|
||
Image img = LoadImageFromScreen(); // grabs the framebuffer
|
||
ImageCrop(&img, (Rectangle){...}); // optional: crop to a sub-pane
|
||
ExportImage(img, "out.png");
|
||
```
|
||
|
||
Gotchas:
|
||
- You still need a GL context, so a display is still required unless you run
|
||
under a virtual one (Xvfb + llvmpipe software GL works; it's just slow).
|
||
- `LoadImageFromScreen()` reads the *current* framebuffer — call it after
|
||
`EndDrawing()` of the frame you want, before the next clear.
|
||
- Do any "background"/incremental work **synchronously** before the capture
|
||
frame. A worker that normally finishes over many frames won't have run yet on
|
||
the single headless frame — force it to completion (e.g. `skipFactor=1`, run
|
||
the incremental compute to 100%, build textures) before you draw.
|
||
- Under software GL (Xvfb/llvmpipe) idle-CPU measurements are **worthless** —
|
||
software rasterization pegs a core and background-thread contention changes
|
||
behavior. Measure idle CPU on real hardware, not in CI/Xvfb.
|
||
|
||
## Toolchain / build warnings worth heeding
|
||
|
||
These bit us specifically; the compiler was right both times.
|
||
|
||
- `-Wshadow`: raylib idioms encourage short names (`L`, `m`, `c`). It's easy to
|
||
shadow a loop variable with a local of the same name in a nested capture
|
||
block — rename (we used `capL` for a second `Layout`).
|
||
- `-Wformat-truncation` (only fires at `-O2`, so a debug build looks clean and
|
||
the release build fails): `snprintf` into a too-small fixed buffer for a
|
||
worst-case `"%d/%d"` etc. Size buffers for the worst case, not the typical
|
||
one. **Build release before declaring victory** — it enables optimizations
|
||
that turn on additional warnings.
|
||
|
||
## General mindset
|
||
|
||
raylib defaults are tuned for a game that *wants* to run flat-out. For a desktop
|
||
tool, you are constantly opting *out* of that: lower FPS, event-driven idle,
|
||
lazy resource acquisition, draw-on-change, pause-when-unfocused. None of it is
|
||
hard, but none of it is the default — you have to ask for all of it explicitly.
|