Files
rspektrum/raylib_for_desktop_applications.md
T
tyler bb2f22bd0a docs: raylib lessons learned for desktop (non-game) apps
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>
2026-05-29 23:12:18 -07:00

144 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 3040% 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 ~12% 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.