Files
rspektrum/raylib_for_desktop_applications.md
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

6.7 KiB
Raw Permalink Blame History

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 UISetTargetFPS(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.

// 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):

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:

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:

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.