From bb2f22bd0a35ec37eb93c6e0b4cec125c717f3df Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 29 May 2026 23:12:18 -0700 Subject: [PATCH] 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 --- raylib_for_desktop_applications.md | 143 +++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 raylib_for_desktop_applications.md diff --git a/raylib_for_desktop_applications.md b/raylib_for_desktop_applications.md new file mode 100644 index 0000000..9032552 --- /dev/null +++ b/raylib_for_desktop_applications.md @@ -0,0 +1,143 @@ +# 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.