perf(acrylic): release blur render targets while the effect is skipped

Turning blur off (blur 0), switching to low-spec mode or enabling
reduced transparency after the acrylic targets existed left the capture
FBO and both blur ping-pong targets allocated for the rest of the
session, although nothing draws with them while the effect is skipped.

They are now released at a frame boundary and rebuilt on demand:
- ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(),
  before NewFrame(), when the previous frame's draw data has been
  rendered, and once in the resize-watcher frame (GL and DX11). While
  the effect is skipped it frees the targets; when the effect is wanted
  again it reallocates them once, with no per-frame retry if that fails;
- resize() while skipped only records that a reallocation is needed;
- the release checks the GL names (and each DX11 texture) directly, so a
  half-allocated ping-pong pair is freed too; DX11 also resets the
  ping-pong index;
- the capture paths and applyBlur() return early without a capture
  buffer, which would otherwise blit the default framebuffer onto
  itself;
- each release logs "released targets (effect skipped)".

With blur on, the path and the output are unchanged (screenshot sweeps
within noise). After re-enabling, the first frame draws the tinted
fallback until a fresh capture exists.

Measured with software GL, toggling every 30 frames for 1200 frames: the
targets were released exactly one frame after each switch-off (22 of 22)
and rebuilt on each switch-on (21 of 21), with no GL or framebuffer
errors. While off at 1280x720, with a fixed malloc mmap threshold, the
process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180
RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With
glibc's default threshold the freed memory stays in the heap and RssAnon
does not visibly drop.

Known issue: every off/on pair now frees and reallocates those targets.
Under software GL (llvmpipe) with the default glibc allocator the freed
texture memory is not reused: toggling every 2 frames grew RssAnon from
49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every
30 frames it levels off near 120 MB. It is not a GL object leak (with a
fixed mmap threshold the mapped bytes stay flat), and on a hardware
driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the
2-frame churn (94.3 MB without the release). Users reach fast toggling
by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the
blur slider back and forth across 0. A short delay before releasing
would avoid it if software-rendered users matter.

Not verifiable here: the DX11 release and reallocation at runtime, and
macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:07 -05:00
co-authored by Claude Opus 5.5
parent f970cae193
commit 261883ca21
5 changed files with 95 additions and 5 deletions
+4
View File
@@ -1538,6 +1538,8 @@ int main(int argc, char* argv[])
SDL_GL_MakeCurrent(ctx->window, ctx->gl_context);
ImGui_ImplOpenGL3_NewFrame();
#endif
// Free/rebuild acrylic targets at the frame boundary (after MakeCurrent on GL: FBOs are per-context).
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
@@ -1951,6 +1953,8 @@ int main(int argc, char* argv[])
// Pre-frame: font atlas rebuilds and schema hot-reload must
// happen BEFORE NewFrame() because NewFrame() caches font ptrs.
app.preFrame();
// Acrylic targets are freed/rebuilt here, between frames: nothing recorded can reference them.
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
// Smooth font-scale: compensate visual font size via FontScaleMain
// when the atlas hasn't been rebuilt yet (during slider drag).
+69 -5
View File
@@ -260,6 +260,7 @@ void AcrylicMaterial::resize(int width, int height)
viewportWidth_ = width;
viewportHeight_ = height;
blurCacheValid_ = false; // Invalidate cache on resize
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
// Determine blur buffer size based on quality
int blurWidth = width;
@@ -301,16 +302,46 @@ void AcrylicMaterial::resize(int width, int height)
DEBUG_LOGF("AcrylicMaterial: Resized to %dx%d (blur: %dx%d)\n",
width, height, blurWidth, blurHeight);
needsRealloc_ = false;
dirtyFrames_ = 2; // recapture after resize
}
void AcrylicMaterial::releaseBuffers()
{
captureBuffer_.destroy();
blurBuffers_.destroy();
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
blurCacheValid_ = false;
needsRealloc_ = true;
DEBUG_LOGF("AcrylicMaterial: released targets (effect skipped)\n");
}
void AcrylicMaterial::syncBufferLifetime()
{
if (!initialized_) return;
if (shouldSkipEffect()) {
// Test the names, not isValid(): a half-allocated ping-pong pair must be freed too.
if (captureBuffer_.getFbo() || blurBuffers_.getSourceTexture() || blurBuffers_.getDestTexture())
releaseBuffers();
return;
}
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
int w = viewportWidth_, h = viewportHeight_;
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
resize(w, h);
hasValidCapture_ = false;
markBackgroundDirty();
}
}
void AcrylicMaterial::captureBackground()
{
if (!initialized_ || shouldSkipEffect()) {
return;
}
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
return;
}
@@ -332,7 +363,7 @@ void AcrylicMaterial::captureBackgroundDirect()
return;
}
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
return;
}
@@ -362,7 +393,7 @@ void AcrylicMaterial::captureLiveFramebuffer()
// inserted at the overlay window's draw-list start), so FB 0 already holds the app UI drawn
// below the overlay. A paired ImDrawCallback_ResetRenderState restores ImGui's GL state.
if (!initialized_ || shouldSkipEffect()) return;
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) return;
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) return;
glDisable(GL_SCISSOR_TEST);
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
hasValidCapture_ = true;
@@ -390,7 +421,7 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{
if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
if (!blurBuffers_.isValid() || !blurShader_.isValid() || !captureBuffer_.isValid()) {
return;
}
@@ -1188,7 +1219,7 @@ void AcrylicMaterial::resize(int width, int height)
viewportHeight_ = height;
blurCacheValid_ = false;
if (settings_.quality == AcrylicQuality::Off) return;
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
// (Re)create capture buffer at full resolution — must be B8G8R8A8 to match swap chain for CopyResource
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
@@ -1215,10 +1246,43 @@ void AcrylicMaterial::resize(int width, int height)
dx_blurWidth_ = bw;
dx_blurHeight_ = bh;
dx_blurCurrent_ = 0;
needsRealloc_ = false;
dirtyFrames_ = 2;
VERBOSE_LOGF("[Acrylic DX11] Resized %dx%d (blur %dx%d)\n", width, height, bw, bh);
}
void AcrylicMaterial::releaseBuffers()
{
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
for (int i = 0; i < 2; ++i)
dx_releaseRenderTarget(dx_blurTex_[i], dx_blurSRV_[i], dx_blurRTV_[i]);
dx_blurWidth_ = dx_blurHeight_ = 0;
dx_blurCurrent_ = 0;
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
blurCacheValid_ = false;
needsRealloc_ = true;
DEBUG_LOGF("[Acrylic DX11] released targets (effect skipped)\n");
}
void AcrylicMaterial::syncBufferLifetime()
{
if (!initialized_) return;
if (shouldSkipEffect()) {
// Per texture: each dx_createRenderTarget can fail on its own.
if (dx_captureTex_ || dx_blurTex_[0] || dx_blurTex_[1])
releaseBuffers();
return;
}
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
int w = viewportWidth_, h = viewportHeight_;
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
resize(w, h);
hasValidCapture_ = false;
markBackgroundDirty();
}
}
void AcrylicMaterial::captureBackground()
{
// Unused on DX11 — capture is done via captureBackgroundDirect()
+6
View File
@@ -194,6 +194,10 @@ public:
/// has actually been rendered (theme switch happens mid-frame, before the
/// BackgroundDrawList is rebuilt with new colors/images).
void markBackgroundDirty() { dirtyFrames_ = 2; }
/// Frame-boundary maintenance. Frees the capture/blur targets while the effect is skipped
/// (Off / disabled / reduced transparency) and rebuilds them when wanted again.
void syncBufferLifetime();
/**
* @brief Render an acrylic-filled rectangle
@@ -386,6 +390,8 @@ private:
bool blurCacheValid_ = false;
int dirtyFrames_ = 2; // >0 means recapture needed; counts down each frame
bool hasValidCapture_ = false; // true once a clean BG capture exists
void releaseBuffers();
bool needsRealloc_ = false; // targets were freed or left stale while the effect was skipped
// Capture and blur framebuffers
Framebuffer captureBuffer_;
+6
View File
@@ -221,6 +221,11 @@ void InvalidateCapture()
getAcrylicMaterial().markBackgroundDirty();
}
void ReleaseIdleBuffers()
{
if (s_initialized) getAcrylicMaterial().syncBufferLifetime();
}
// Draw callback: captures framebuffer 0 (which at this point during
// RenderDrawData() contains only the rasterized BackgroundDrawList —
// no UI windows yet) into the acrylic capture buffer.
@@ -667,6 +672,7 @@ bool IsAvailable() { return false; }
void BeginFrame(int, int) {}
void CaptureBackground() {}
void InvalidateCapture() {}
void ReleaseIdleBuffers() {}
ImDrawCallback GetBackgroundCaptureCallback() { return nullptr; }
ImDrawCallback GetLiveCaptureCallback() { return nullptr; }
+10
View File
@@ -78,6 +78,16 @@ void CaptureBackground();
*/
void InvalidateCapture();
/**
* @brief Frame-boundary maintenance: free the acrylic capture/blur targets
* while the effect is skipped (blur off, low-spec, reduced
* transparency) and rebuild them when it is wanted again.
*
* Call once per frame right after App::preFrame(), before NewFrame(), so
* nothing recorded in a draw list can still reference a freed target.
*/
void ReleaseIdleBuffers();
/**
* @brief Get a draw callback for background capture.
*