Skip to content

perf(rendering): cache per-frame bind groups, mask buffers and the resolved wallpaper path - #2871

Merged
richiemcilroy merged 3 commits into
mainfrom
perf/rendering-per-frame-caches
Oct 23, 2026
Merged

richiemcilroy merged 3 commits into
mainfrom
perf/rendering-per-frame-caches

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Per frame, the renderer rebuilt GPU bindings and touched the disk even when nothing they depend on had changed:

  • Cursor: created a new texture view and bind group every frame.
  • Wallpaper / image background: cloned the background source, ran three String::replace calls and a Path::exists stat every frame.
  • Background colour grade, background blur, 3D camera warp and blur: each pass created its bind group every frame.
  • Masks: each mask pass created a new uniform buffer (create_buffer_init) and bind group every frame.

This PR builds each of these only when its inputs change. The output is byte-identical.

  • Cursor: the bind group is cached together with the texture it binds. A new cursor texture (a cursor id switch, type change or SVG toggle) rebuilds it.
  • Wallpaper: the source path is resolved and checked on disk once, then again only when the path in the config changes. The decoded texture cache was already keyed by path, so a file replaced at the same path behaves as before.
  • Full-frame passes: a small SourceBindGroups cache keys each bind group by the uniform buffer and the session texture view it samples. That is at most two per buffer, one for each ping-pong texture. Entries for views the session no longer owns are dropped at the start of each render, so a resized session's old textures are released.
  • Masks: each pass in a frame gets its own persistent uniform buffer, written only when its values change. Bind groups come from the same cache. Every renderer submits between RendererLayers::render calls (the transition path submits the outgoing frame first), which the cursor and display uniforms already rely on.

Measurements

Scratch A/B on Blacksmith (ci/rendering-caches-ab, never merge), base d16fd199b vs this branch, in one job per OS. Fixture: cap_verify::fixture 10 s 1080p30 display recording, extended with:

  • two recorded cursor images that switch every 1.5 s, 60 Hz moves and clicks;
  • a 2560x1440 wallpaper.

The "effects" variant also adds a colour grade, blur and pixelate masks, and a 3D camera segment with radial blur. Each bench renders 300 frames at 1920x1080 in a release-style build; the numbers are medians of 7 interleaved rounds. Bind groups and buffers are counted at wgpu-hal create calls.

macOS 26 (blacksmith-6vcpu-macos-26, Metal)

base head
cursor + wallpaper: prepare CPU excl. display upload 31.3 µs 13.9 µs
cursor + wallpaper: layer render CPU 19.9 µs 24.3 µs
effects: prepare CPU excl. display upload 59.4 µs 48.2 µs
effects: layer render CPU 103.7 µs 67.9 µs
background prepare 11.7 to 13.5 µs 0.4 µs
cursor prepare 15.7 to 17.0 µs 10.2 to 10.5 µs
bind groups created per frame (cursor + wallpaper / effects) 1.00 / 8.00 0.02 / 0.02 (cursor id switches)
buffers created per frame, effects 10 4
wallpaper stat calls over 310 frames 310 (1 per frame) 1
export, 10 s 1080p30 (cursor + wallpaper / effects) 1.25 s / 1.22 s 1.18 s / 1.26 s

Linux (blacksmith-8vcpu-ubuntu-2404, lavapipe)

base head
cursor + wallpaper: prepare CPU excl. display upload 175.2 µs 25.3 µs
cursor + wallpaper: layer render CPU 71.0 µs 73.5 µs
effects: prepare CPU excl. display upload 196.8 µs 59.8 µs
effects: layer render CPU 176.6 µs 137.7 µs
background prepare 26.8 to 27.3 µs 2.0 to 2.1 µs
cursor prepare 123.7 to 131.7 µs 9.5 to 9.9 µs
bind groups created per frame 1.00 / 8.00 0.02 / 0.02
wallpaper stat calls over 310 frames (strace) 310 1
export, 10 s 1080p30 (cursor + wallpaper / effects) 4.90 s / 10.87 s 4.71 s / 10.88 s

Per frame, the CPU spent in prepare (excluding the display upload) plus layer render drops:

  • cursor + wallpaper: 51 to 38 µs on macOS, 246 to 99 µs on Linux;
  • effects: 163 to 116 µs on macOS, 373 to 198 µs on Linux.

Layer render on the cursor-only scene is a few µs higher on both runners, but the totals above already include that. Frame wall time is dominated by the GPU and readback, and is unchanged within run-to-run noise. Export is encoder- or GPU-bound, so its time moves only slightly (macOS cursor + wallpaper -6%, Linux -4%; effects is flat).

Identity:

  • All 300 rendered frames hash identically between base and head for both projects on both OSes.
  • Export framemd5 is identical on Linux for both projects.
  • The new per_frame_caches integration test also passes on base.

Tests

  • tests/per_frame_caches.rs renders frames with reused layers and compares them byte for byte with layers built fresh for each frame. The scenarios are recorded cursor images that switch ids, a circle cursor, a wallpaper, a colour grade, blur/pixelate/highlight masks, 3D camera gaussian and bokeh blur, and an animated gradient with background blur. They include a session resize. Windows WARP renders the mask scene differently on identical fresh renders (frame 0 differed between two fresh layer stacks), so that one scene is skipped on WARP. It runs on Metal and lavapipe.
  • per_frame_cache_tests (lib) checks that the cursor bind group stays the same across frames. It deletes the wallpaper after the first frame and checks that the background never falls back. It also checks that mask buffers and colour grade/mask bind groups stop at the expected counts.
  • image_path_is_checked_on_disk_only_when_the_source_changes (background).

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable issues were found.

Summary

Caches GPU bindings, reuses mask buffers, and remembers resolved background paths to reduce work between frames.

  • Full-frame effects and mask passes reuse GPU resources across frames.
  • Cursor bind groups stay paired with the texture they use.
  • Wallpaper paths are checked again only when their source changes.

Reviews (1) · Last reviewed commit: "test(rendering): skip the mask scene on ..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 23, 2026 5:15pm UTC

@blacksmith-sh

This comment has been minimized.

@blacksmith-sh

This comment has been minimized.

@blacksmith-sh

This comment has been minimized.

@blacksmith-sh

This comment has been minimized.

@richiemcilroy
richiemcilroy marked this pull request as ready for review October 22, 2026 06:23
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

View 1 other finding in Capy.

Open in Capy

}

let resolved = match &self.0 {
Some((cached, resolved)) if cached == path => resolved.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing background paths remain cached after the file is restored

Medium Severity

If a project's image is temporarily unavailable on its first render, clean_background_path returns None and this cache retains that result. Restoring the file or reconnecting its drive never reaches BackgroundTextureCache::ensure: subsequent frames keep producing the white fallback. Switching to a color and reselecting the same image also fails because the non-image branches leave this entry intact. Previously, path existence was checked again and the image recovered. Avoid indefinitely caching failed lookups, or provide invalidation and retry when the source is reselected.

Open in Capy

@blacksmith-sh

This comment has been minimized.

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit b314053 into main Oct 23, 2026
71 of 73 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — 2cc7ca56 Deployed Oct 23, 2026 by vercel[bot]
Preview – cap-status — 2cc7ca56 Deployed Oct 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant