perf: event-driven idle + lazy audio device for near-zero idle CPU

raylib's frame limiter partial-busy-waits ~5% of every frame interval, so
capping the FPS couldn't get idle CPU below ~5% of a core, and the audio
output device's mixing thread spun continuously even when nothing played.

- Idle render: go fully event-driven when idle. While active (focused +
  something animating, or within a 0.5s grace window) run at ACTIVE_FPS;
  otherwise EnableEventWaiting() so EndDrawing's PollInputEvents blocks on
  glfwWaitEvents until the next input/window event. Unfocused windows skip
  drawing entirely and block. Headless render is exempt (its hidden window
  gets no events). Replaces the earlier fixed-FPS idle throttle.
- ACTIVE_FPS lowered 60 -> 30: ample for a non-game UI, halves active cost.
- Lazy audio device: open it on demand at first playback (EnsureAudioDevice)
  and release it once idle and not playing (ReleaseAudioDevice), reopened on
  the next play. Never closes mid-playback (gated on !isPlaying), so audio
  always finishes first. Shutdown is guarded with IsAudioDeviceReady().

Idle/backgrounded window now drops to ~0% CPU (no render busy-wait, no audio
thread); snaps back instantly on input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:02:14 -07:00
parent 2befb42d71
commit f89e1b7eab
3 changed files with 69 additions and 29 deletions
+45 -29
View File
@@ -70,12 +70,11 @@ static bool IsUserInteracting(void)
return false;
}
// Idle FPS throttle. raylib re-renders the whole scene every frame regardless
// of whether anything changed, so an idle window still pins the GPU (and, on a
// software GL stack, the CPU) at 60 fps. We drop to IDLE_FPS whenever nothing
// needs animating, and snap back to ACTIVE_FPS the moment activity resumes.
#define ACTIVE_FPS 60
#define IDLE_FPS 10
// Idle power management. raylib re-renders the whole scene every frame, so an
// idle window otherwise pins the GPU (and, on software GL, the CPU) at the
// target rate. We run at ACTIVE_FPS while something needs animating, then go
// fully event-driven (block until input) when idle — see the loop below.
#define ACTIVE_FPS 30 // plenty for a non-game UI; halves active-frame cost
#define IDLE_GRACE_SECONDS 0.5 // stay at full rate briefly after the last activity
/**
@@ -621,9 +620,10 @@ int main(int argc, char* argv[])
}
#endif
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTargetFPS(ACTIVE_FPS);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice();
// Audio device is opened lazily on first playback (see EnsureAudioDevice)
// and released while idle, so an idle/backgrounded window holds no device.
SetExitKey(KEY_NULL); // ESC should not close the window
// Save original working directory so command-line args resolve correctly
@@ -782,26 +782,42 @@ int main(int argc, char* argv[])
// Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow();
#else
// When the window isn't focused there's nothing for the user to see, so
// skip the entire frame: pump window events (so focus regain and the
// close button still register) and idle, with zero drawing. The
// headless render uses a hidden window that never reports focus, so it
// is exempt — otherwise it would never draw its single capture frame.
if (!headless && !IsWindowFocused()) {
PollInputEvents();
WaitTime(0.1); // ~10 Hz event poll, no rendering
continue;
}
// Idle FPS throttle (desktop only; the browser drives RAF/vsync itself).
// Keep full rate while active or within a short grace window after the
// last activity, then fall back to a low idle rate.
{
// Desktop power management. raylib's frame limiter partial-busy-waits
// ~5% of every frame interval, so capping the FPS can't get idle CPU
// below ~5% of a core. Instead we go fully event-driven when idle:
// block in PollInputEvents() (glfwWaitEvents) until an input/window
// event arrives, so an idle/backgrounded window costs ~0% CPU and only
// redraws on demand. Headless render (hidden window, single frame) opts
// out — its window never receives events, so waiting would deadlock.
if (!headless) {
static double lastActive = -1000.0;
static int curFps = ACTIVE_FPS;
if (IsAppActive()) lastActive = GetTime();
int wantFps = (GetTime() - lastActive < IDLE_GRACE_SECONDS) ? ACTIVE_FPS : IDLE_FPS;
if (wantFps != curFps) { SetTargetFPS(wantFps); curFps = wantFps; }
static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait)
bool focused = IsWindowFocused();
if (focused && IsAppActive()) lastActive = GetTime();
// Active = focused AND something needs animating (or just did, within
// the grace window). Anything else is a static frame we can sleep on.
bool active = focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS);
if (active) {
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
} else {
// Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
// Release the output device while idle — but never mid-playback
// (isPlaying gates it, so audio always finishes first). Reopened
// on the next play. Safe on the unfocused path too: only closes
// when nothing is playing.
if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
if (!focused) {
// Unfocused: nothing to show. Block on events (refocus/close)
// without drawing at all.
PollInputEvents();
continue;
}
// Focused but idle: fall through to draw this one static frame,
// then EndDrawing blocks until the next event.
}
}
#endif
@@ -1682,7 +1698,7 @@ int main(int argc, char* argv[])
TraceLog(LOG_INFO, "Shutting down...");
if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
if (IsAudioDeviceReady() && AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); }
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
@@ -1693,7 +1709,7 @@ int main(int argc, char* argv[])
free(app.reassignBuffer);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
CloseAudioDevice();
if (IsAudioDeviceReady()) CloseAudioDevice();
CloseWindow();
return headlessRc;
}