Skip to content

Scene damage and adaptive CPU rendering - #45

Closed
huacnlee wants to merge 49 commits into
mainfrom
adaptive-cpu-render
Closed

huacnlee wants to merge 49 commits into
mainfrom
adaptive-cpu-render

Conversation

@huacnlee

@huacnlee huacnlee commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds scene damage, adaptive CPU rendering on Linux, and partial GPU redraws on macOS and Windows to reduce rendering work for small changes:

  1. Scene damage (crates/gpui/src/fast/damage.rs, all platforms): each finished scene carries Scene::damage, the device-pixel rectangles where it can draw different pixels than the previous scene. The damage is exact. It diffs each draw-order group: byte-equal groups are skipped; otherwise primitives are matched by value and checked for relative order. It also covers primitive extents (shadow blur, transformed sprites, paths), dirty scroll-layer tiles, and path batches that split or join. Nearby rectangles merge (8 px). Past 16 rectangles the cheapest pair merges, and damage over half the window becomes the whole window.
  2. Adaptive CPU rendering (crates/gpui_wgpu/src/fast/{cpu,adaptive}/, Linux wgpu): a CPU rasterizer reproduces the WGSL shaders and the GPU's 8-bit blending, and is checked pixel by pixel against WgpuHeadlessRenderer. How a fragment becomes an 8-bit level depends on the GPU vendor (NVIDIA truncates to 12 bits, Intel to 16), and the rasterizer handles both. The atlas keeps a CPU mirror. A policy draws small damage on the CPU and sends large changes, expensive bursts, surfaces and the first frame to the GPU. Long bursts of small changes, such as a spinner, catch the canvas up once and then stay on the CPU.
    • Presenters (crates/gpui_linux/src/fast/cpu_present/): Wayland wl_shm on a synchronized subsurface, because the explicit-sync WSI surface forbids shm. While the subsurface shows, wp_alpha_modifier_v1 makes the window surface transparent, so translucent windows show no ghosting. Without that protocol, CPU frames go through blit. X11 uses PutImage, with MIT-SHM for large rectangles.
    • There is also a blit mode: upload the region and draw one full-screen triangle.
    • Default: native, with blit where no presenter is installed.
  3. Partial redraws (crates/gpui_apple/src/fast/partial.rs, crates/gpui_windows/src/fast/partial.rs, macOS and Windows): the GPU renderers keep the last frame in a window-sized canvas and redraw only the damage rectangles into it, scissored, then copy the canvas into the drawable / back buffer. Windows presents with Present1 and the damage as dirty rectangles. A frame with nothing to draw (same scene, or empty damage) is neither drawn nor presented. GPUI_PARTIAL_REDRAW=0 turns it off.

Upstream files get hooks only. script/check-upstream reports 0 violations. The API differences are listed in docs/upstream-sync.md. The reference doc is docs/adaptive-rendering.md and the design is in docs/superpowers/specs/2026-10-08-damage-and-adaptive-cpu-design.md.

Controls: GPUI_CPU_RENDER=0|always, GPUI_CPU_PRESENT=native|blit, GPUI_PARTIAL_REDRAW=0, GPUI_RENDER_STATS=1, GPUI_CPU_VERIFY=1.

What this means

On Linux, at the same or lower CPU cost, the application stops using the GPU for small updates. In every small-update scenario the process CPU stays flat or drops: caret 0.4% → 0.2%, quotes 1.9% → 1.4%, spinner 8.3% → 4.3%. The application's GPU time drops to 0.

  • Linux CPU does not go up in these small-update measurements. A CPU frame of a few hundred to a few thousand pixels takes tens of µs, which is less than recording and submitting a whole-window GPU frame.
  • What it buys: the GPU stays idle while the app is nearly still. A trading app spends most of its day in that state: quotes ticking, clocks, carets, hovers. Keeping the GPU idle mainly saves power and heat. The gain is largest on laptops (battery, fans) and discrete GPUs, where each wake-up jumps to a high clock state, and with many windows or apps open.
  • What it does not do:
    • It does not make the app faster. Frame rate, latency and scrolling are unchanged, and large changes still go to the GPU.
    • The compositor still composites the window, so its GPU time drops only for continuous animations (spinner 164 → 101 ms/s).
  • To be honest about magnitude: on this desktop with an integrated GPU the absolute saving is small. A few tens of ms/s of iGPU time are saved, while the idle desktop already keeps the iGPU ~35% busy. Power has not been measured yet: RAPL needs root, and the sampling code is parked for later. So "saves power" is still an inference. The convincing test is a laptop with a discrete GPU, on battery, running the real Longbridge UI.

Performance

Measured with script/measure-adaptive --modes gpu,adaptive,blit --reps 2, 20 s per run. Hardware: Intel Arrow Lake iGPU, 4K at scale 1.5, Hyprland. The GPU columns are the GPU time of the application's and the compositor's own DRM clients, in ms per second.

Wayland Process CPU gpu → adaptive App GPU gpu → adaptive → blit Compositor GPU gpu → adaptive → blit
CaretBlink 0.4% → 0.2% 24.6 → 0 → 1.6 45.8 → 46.7 → 44.8
Hover 1.3% → 1.1% 52.1 → 0 → 3.7 36.8 → 36.4 → 39.6
Quotes 1.9% → 1.4% 64.7 → 0 → 5.1 32.0 → 35.4 → 31.8
Spinner 8.3% → 4.3% 356 → 0 → 38.7 164 → 101 → 162
Scroll (stays on GPU) 16.1% → 16.4% 377 → 368 → 331 173 → 176 → 173

X11 (XWayland) shows the same pattern. For example, Spinner: 8.3% → 4.3% CPU, 320 → 0 ms/s app GPU, and 237 → 138 ms/s compositor GPU. Full tables are in the doc.

Native beats blit on every measure. Blit keeps 5–10% of the GPU time. It also makes the compositor recomposite the whole window, while native recomposites only the damage.

Re-measured after the review fixes (same command, Wayland and X11): every mode keeps 60 fps and the gpu vs adaptive picture is unchanged. Wayland: caret 0.4% → 0.3%, hover 1.4% → 1.1%, quotes 1.9% → 1.6%, spinner 9.1% → 5.0% CPU, and app GPU time 19–358 ms/s → 0 for every small update. X11: spinner 6.9% → 3.5%. The desktop was not in the same state as in the morning run (the idle compositor drew 110 ms/s instead of 28 on Wayland, and nothing on X11, likely a blanked screen), so compare modes within a run, not absolute numbers across runs.

Per-frame CPU cost of this PR (headless gpui_perf --headless --frames 300, retention on, 1440×900; the PR against its merge base edbd764, median of 3 alternating runs, run-to-run spread mostly 1–4%). Headless frames are not drawn, so this shows the cost only, not the rendering savings. Almost all of it is the damage diff:

Scenarios Frame CPU, base → PR Damage diff
Forms, settings, scroll lists +1–6% (mostly within noise) 0.001–0.03 ms
Workspace (trading UI) +5–7%, ~2.2 → 2.4 ms 0.09–0.10 ms
table-ticks-many +7.8%, 7.31 → 7.88 ms 0.11 ms
list-select +13.6%, 0.82 → 0.93 ms 0.095 ms
list-variable-scroll (worst) +31.7%, 0.275 → 0.362 ms 0.079 ms (~2400 changed primitives a frame)

At most ~0.1 ms a frame, about 0.6% of a core at 60 fps. In real windows it is more than paid back on small updates (the CPU column above drops), but frames that are redrawn whole anyway (scrolling, GPUI_PARTIAL_REDRAW=0, headless) pay it for nothing. A follow-up can stop diffing once the damage covers most of the window: 60–91% of the frames in workspace-scroll and list-select end up whole.

macOS partial redraws, re-measured after the empty-frame fix (2026-10-08, commit 907863e, MacBook Pro M3 / 24 GB, 1512×836 pt window at 2×). Same release build, gpui_perf --idle --duration 15 --warmup 2, GPUI_RENDER_STATS=1, three rounds alternating whole and partial redraws (full/partial, partial/full, full/partial). Whole frames (GPUI_PARTIAL_REDRAW=0) → partial (GPUI_PARTIAL_REDRAW=1); each value is the median of three runs. Process CPU is a percentage of one core. GPU time is the change in each process's ioreg accumulatedGPUTime, divided by the measured duration, excluding startup and warmup.

Scenario Process CPU App GPU ms/s WindowServer GPU ms/s Update frames/s
CaretBlink 1.84% → 1.86% 8.20 → 5.53 (-32.5%) 77.16 → 76.08 2.00 → 2.00
Clock 1.46% → 1.40% 4.20 → 2.51 (-40.2%) 78.03 → 70.83 1.00 → 1.00
Hover 3.62% → 3.69% 15.71 → 10.46 (-33.4%) 75.07 → 77.40 4.00 → 4.00
Quotes 5.23% → 5.21% 20.19 → 14.59 (-27.7%) 90.07 → 90.73 5.00 → 5.00
Spinner 34.89% → 42.67% 369.49 → 245.30 (-33.6%) 282.16 → 348.85 120.00 → 120.00
Scroll 40.45% → 45.57% 402.53 → 304.08 (-24.5%) 244.07 → 302.68 120.00 → 119.98
Idle 0.95% → 1.02% 0.00 → 0.00 71.60 → 69.38 0.00 → 0.00

The application's GPU time drops 24–40%, with update frequency unchanged. Small-update CPU is approximately flat, but continuous animations have a measurable cost: Spinner's CPU median rises from 34.89% to 42.67%, and Scroll's from 40.45% to 45.57%. CPU varies across runs (Spinner: whole 34.18–43.14%, partial 39.58–43.34%; Scroll: whole 40.30–44.14%, partial 41.90–46.39%), so these results do not support a claim of no CPU regression on macOS.

WindowServer GPU time also rises for both continuous animations in every paired round. Its counters include the whole desktop, with about 70 ms/s of background activity even in Idle, so they cannot all be attributed to this application. Lower application GPU time is not evidence of lower total power consumption here; power and battery life were not measured. Apple's partial path still loads/stores the canvas and copies the whole canvas to the drawable; Core Animation composites each presented drawable whole.

Scroll includes expected whole-frame fallbacks after glyph atlas writes. Across each complete partial-mode run (including warmup and scenario transitions), there were 43 whole frames: one initial canvas frame and 42 atlas-write fallbacks. No frame was skipped in these measurements: Quotes had visible changes at this wider viewport, while Idle already requested no ongoing draws in either mode. The empty-frame fix's correctness is covered by the Metal pixel tests, but its performance benefit in a narrower window has not been measured. These M3 results replace the earlier M4 table; hardware and viewport differ, so absolute values should not be compared across the two runs. Windows has not been measured on real hardware.

CPU rasterizer (this machine): 1920×1080 whole frame in 7.7 ms on 1 thread and 2.3 ms on 8 threads. A 200×40 text region takes 55 µs and a caret 14 µs.

Correctness checks

  • GPUI_CPU_VERIFY=1 redraws each CPU frame's scene whole and compares it pixel by pixel with the incrementally drawn canvas, so it catches any missed damage, stale region or atlas write. It was run on every gpui_perf --idle scenario: tiled and 1600×1000 windows, Wayland and X11, native and blit, ~2600 CPU frames. Result: verify_bad=0 everywhere.
    • It found one real bug, now fixed: the raster panicked on path batches beside a narrow region.
    • It also settles the old Quotes TODO: frames with empty damage are genuinely unchanged. In narrower windows the price columns are clipped.
  • CPU vs GPU frames, real window: the Idle workspace was drawn whole on the GPU and on the CPU (native and blit) and screenshotted in place. Out of 3.5 M pixels, 42–64 differ by more than 3 levels, on Wayland and on X11. The differences are the clock (a different second), path antialiasing edges (≤5 levels), and twice a single path MSAA sample.
  • Opacity ghosting: at window opacity 0.5, native and blit frames now differ in 36 pixels. Without wp_alpha_modifier_v1 they differed in 54,095.
  • Partial redraws: CI draws scenes partially and whole on the runners' real Metal and Direct3D devices and compares them byte for byte (paths, glyphs, images, shadows, transparent windows, several rectangles, overlapping damage, consecutive partial frames, skipped frames).

Review fixes

A five-way review (damage, adaptive policy, CPU raster, Linux presenters, macOS/Windows) found no missed damage in the core diff. Fixed:

  • X11: a CPU frame's PutImage could land before a queued GPU PresentPixmap and be overwritten at vblank. The presenter now selects PresentCompleteNotify and re-uploads what CPU frames drew since the GPU's present. Expose rectangles are uploaded too (no compositing manager: uncovered areas stayed black).
  • Device recovery dropped the platform presenter (CPU frames fell back to blit for good); it is now carried over.
  • Atlas writes taken by a frame that was not presented were lost; they are kept until a frame presents them.
  • can_draw now checks paths and surfaces inside the scroll-layer tiles it draws.
  • The fragment-bits flag is no longer process-global (flaky tests when two vendors' settings ran in parallel); it is passed through the draw.
  • Windows: a background appearance change now draws the frame whole; path batches are rasterized once per frame, not once per damage rectangle.

Tests

  • gpui: damage tests and property tests over random scenes.
  • gpui_wgpu: CPU vs GPU pixel comparisons on NVIDIA and Intel, the policy (including catch-up), atlas writes across unpresented frames, device recovery, blit, and verify.
  • gpui_linux: presenters, including the X11 re-upload bookkeeping.
  • gpui_apple / gpui_windows: partial redraw pixel tests, run in CI on macOS and Windows. Local M3 validation at 907863e: cargo test -p gpui_apple --lib 23 passed, including 8 partial-redraw tests on the real Metal device; cargo test -p gpui --features test-support --lib fast::tests::damage 15 passed; script/check-upstream 0 violations. All six performance runs completed successfully with no panic, ERROR or WARN logs. Manual occlusion, window resizing and cross-display checks were not performed.
  • Clippy (-D warnings) and rustfmt are clean; the Windows and macOS crates are also cross-checked from Linux.
  • wasm32, x11-only and wayland-only builds succeed.

Notes

  • The vendor's fragment bits are a const generic chosen once per span, so the raster keeps a constant-shift path for both (whole 1080p frame 7.6–7.9 ms on 1 thread, 2.1–2.3 ms on 8).
  • On Intel there are two rounding edges, both allowed for in the tests there: a rotated sprite's filtering can differ by 3 levels, and a checkerboard cell edge that falls exactly on a pixel center can move one pixel. Neither shows within a frame, because the canvas is redrawn whole when the frame switches between CPU and GPU.
  • Not tested on AMD: there the fragment conversion is assumed to be 16-bit.

🤖 Generated with Claude Code

huacnlee and others added 30 commits October 8, 2026 00:29
…erfaces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e CPU

The atlas mirror keeps a CPU copy of every atlas texture once a renderer
with a CPU presenter enables it, logs the rectangles written (capped, then
unioned per texture, then "everything"), and serves sprites to the CPU
rasterizer.

The adaptive policy draws a frame on the CPU, inside damage, stale
rectangles left by GPU frames and sprites over written atlas tiles, unless
the frame changes more than a sixteenth of the window in a burst, would
redraw the canvas whole in a burst, exceeds 8 Mpx, or the burst's CPU frames
cost more than a quarter of its time after 250 ms. The canvas is released
after a second of GPU frames; presenter errors turn the CPU path off.
GPUI_CPU_RENDER=0/always and GPUI_RENDER_STATS=1 control and report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n X11

Wayland: CPU frames are shown on a synchronized subsurface covering the
window surface, because the Vulkan WSI gives the window surface explicit
sync (wp_linux_drm_syncobj_surface_v1) and committing a wl_shm buffer to
it is a protocol error. Each frame copies into a free wl_shm buffer (up to
four, one memfd each, tracked by wl_buffer.release) the regions the buffer
misses since it was last attached, attaches it to the subsurface, damages
the frame's damage and commits the window surface, which carries the frame
callback. gpu_presented() removes the subsurface's buffer so the GPU
frame's commit hides it. The subsurface is scaled like the window surface
from the size and scale composition's resize hook reports.

X11: damage rectangles go into the window with ShmPutImage through an
MIT-SHM segment (memfd, ShmAttachFd) when large, else PutImage split under
the maximum request length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CPU frames can now be shown in two ways: with the platform's presenter
(native), or by uploading the changed rectangles to a texture that a tiny
full-screen pipeline copies to the swapchain image (blit), which needs no
presenter and works on every wgpu window. GPUI_CPU_PRESENT=native|blit
selects it; unset, default_present_mode uses the presenter where one is
installed and blit otherwise. Atlases mirror from their first upload when
CPU frames are possible; renderers resolving to none turn the mirror off.

The presenter is told the GPU presents (gpu_presented) from gpu_drew, right
before the swapchain present, including for the first frame, which now
always draws on the GPU. A refused CPU frame draws on the GPU; the CPU path
turns off only after MAX_PRESENT_FAILURES refusals in a row.
GPUI_RENDER_STATS breaks CPU frames down by presentation mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`gpui_perf --idle` shows the trading workspace in a 1600x1000 window and
runs small-update scenarios for a fixed time each (CaretBlink, Clock,
Hover, Spinner, Quotes, Scroll, Idle), with the application's own timers
off so that only the scenario changes the window. Each reports the frames
drawn, the main thread's CPU per frame (mean, p50, p95) and the process's
CPU over the run, prints begin/end markers, and writes JSON with --json.
With GPUI_RENDER_STATS set, the renderer's `render stats` log records are
printed on stderr.

`script/measure-adaptive` builds gpui_perf once and runs each scenario
with GPUI_CPU_RENDER=0, the default and optionally `always`, alternating
the order, sampling /proc CPU and RSS and nvidia-smi power, utilization,
P-state, clock and memory, against an idle-desktop baseline. On Hyprland
it floats the window at 1600x1000; --x11 runs through XWayland. It writes
results.json with every sample and a Markdown summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
raster::draw draws a scene into a canvas inside regions only, batch by
batch in the GPU renderer's order, porting each fragment shader in f32:
quads (every background, rounded corners, solid and dashed borders),
shadows, underlines, monochrome, subpixel and polychrome sprites (with
transformations and bilinear sampling), paths (4x multisampled into an
intermediate, then composited through the batch's sprites) and scroll
layer tiles (their content drawn over the layer's background).

Blending reproduces how the GPU writes an 8-bit target: each fragment
channel truncated to 12 bits of fixed point, rounded to a level, and
levels blended as integers; texture filtering uses 8-bit fractions and
weights. Pixel tests against the GPU renderer, of every primitive and
shader branch and of seeded random scenes, in opaque and premultiplied
modes, find no channel more than 2 levels apart (3 on one path edge
pixel). Large regions are drawn in bands on several threads, exactly as
on one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GPU unless sampled 4x

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`fast::damage::finish_frame` fills `Scene.damage` when a frame is
finished: the device-pixel rectangles where the new scene can draw
different pixels than the previous one, exactly.

Each primitive kind's two vectors, both in drawing order, are compared
one draw order at a time. Groups equal byte for byte damage nothing.
Otherwise the two are walked side by side, looking up to 8 primitives
ahead to agree again; failing that, or for small groups, primitives are
matched by value (an open-addressing hash table, exact equality), and
matched primitives outside a longest increasing run are damaged only if
they overlap a primitive they changed places with. Visible extents
follow the shaders: drop shadows by three blur radii, inset shadows by
their element, transformed sprites by their transformed bounds, paths
padded by a pixel. Surfaces are always damaged, scroll layer tiles by
their layer's dirty tiles and generation. Rectangles within 8 px merge,
past 16 all merge, and damage over half the window covers it. The first
scene and one after a resize have `since == 0`.

LayoutStats gains damage counters and time, and gpui_perf's headless
report shows the damaged share of the window, small and full damage
frames, changed primitives and damage time per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tead of all

Changes spread over the window (quotes ticking in several panels) stay
apart: workspace-quotes damage drops from 66% to 20% of the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch of paths that share an order is composited through one sprite per
path. Clipped to a band or region beside some of them but level with them,
their rectangles came out inverted, and copying a row of the intermediate
through one indexed past it: a thread panicked. `GPUI_CPU_VERIFY=1` found it
in the trading workspace's charts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The raster reproduced NVIDIA's conversion of a fragment to the 8-bit
target: truncated to 12 bits of fixed point, then rounded to a level. Intel
(Mesa, measured on Arrow Lake) truncates to 16 bits, reproduced exactly on
every level and hundredth of a level. `RasterParams::fragment_bits` comes
from the adapter's vendor: 12 on NVIDIA, 16 elsewhere.

On Intel two rounding edges remain, which the tests now allow there: a
rotated sprite's filtering can land 3 levels apart, and a checkerboard
cell's edge exactly on a pixel center can move a pixel (its division comes
out just below the whole number), so random scenes draw checkerboards solid
on GPUs other than NVIDIA's. `adapter_info` is `pub(crate)` for the vendor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a burst, a frame that would redraw the canvas whole goes to the GPU. An
animation that never pauses, such as a 24 px spinner, so stayed on the GPU
for good once a large change had left the canvas stale. Once a burst's
frames have each changed at most a sixteenth of the window for 250 ms
(`CATCH_UP_AFTER`), the CPU redraws the canvas whole once, and the frames
after draw only what they change. The Spinner scenario now draws every
frame on the CPU, at about 0.035 ms each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CPU_VERIFY=1

A CPU frame redraws the canvas only inside its region. With
`GPUI_CPU_VERIFY=1` each CPU frame's scene is also drawn whole into a second
canvas and compared pixel by pixel; the raster's pixels do not depend on how
a region is split, so a difference is a change the region missed: missed
damage, stale pixels or atlas writes. Mismatches are logged with their
bounds and the frame's damage and region, and counted in the render stats
(`verify_n`, `verify_bad`, `verify_bad_px`). It costs a whole frame per
frame: for checking real applications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… blit mode

Without nvidia-smi the script sampled no GPU. It now also reads an Intel
GPU's utilization (time out of RC6) and clock from i915's sysfs, and on any
DRM driver that reports it the GPU time of the application and of the
compositor (`drm-engine-*` in /proc/<pid>/fdinfo), which other programs on
the desktop do not disturb. The `blit` mode runs adaptively with
`GPUI_CPU_PRESENT=blit`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…frame

CPU frames show on a subsurface over the window surface, which still holds
the last GPU frame. Where the compositor makes the window translucent (a
window-opacity rule, which Omarchy sets on every window), that frame showed
through the CPU frames, and the window came out more opaque than the rule.
A CPU frame now sets the window surface's alpha multiplier to 0 with
`wp_alpha_modifier_v1`, in the commit that shows the subsurface, and the
next GPU frame sets it back in its own commit. At window opacity 0.5 in the
Hover scenario, native CPU frames now match blit ones (36 pixels apart by
more than 4 levels, against 54,095 before).

Without the protocol no presenter is installed, so CPU frames are shown by
blit, which has no such effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the opacity fix

Wayland and X11 measurements of gpu, adaptive (native) and blit on an Intel
integrated GPU, with per-process GPU time; the CPU/GPU screenshot
comparison; GPUI_CPU_VERIFY; per-vendor fragment conversion; the catch-up
rule for long bursts; native presenting as the default now that the window
surface is made transparent under Wayland CPU frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same or lower CPU, no application GPU time for small updates; the point is
an idle GPU (power and heat, mostly on laptops and discrete GPUs), not
speed, and power is not measured yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e rendering, Scene damage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dirty rectangles

The renderer draws every frame into a canvas it keeps, copies the canvas
into the back buffer and presents. A frame whose damage is relative to the
scene the canvas holds clears and draws only its damage rectangles,
scissored, and presents with Present1 and the damage as dirty rectangles, so
that DWM recomposes only those. Frames are drawn whole without a canvas of
the window's size, when the damage is not relative to the canvas, after an
atlas write, with surfaces, with damage over half the window, or while a
graphics debugger captures. GPUI_PARTIAL_REDRAW=0 keeps the old path;
GPUI_RENDER_STATS=1 prints partial and whole frames per second.

Hooks: DirectXRenderer::draw forwards to fast::partial::draw, a field, a
release on a lost device, an atlas-write call, and pub(crate) on font_info
and three DirectXResources fields. Tests draw a scene of every primitive
kind, paths included, into the damage of a canvas holding the scene before
and compare it with the scene drawn whole, on a composition swap chain's
resources (no window); CI runs them on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Metal renderer now keeps the last frame in a canvas texture of its own
and draws each window frame into it: whole, as before, or, when the canvas
holds the scene the damage compares with, only inside the damage. For each
damage rectangle a pass loads the canvas, scissors every main encoder to
the rectangle, fills it with the clear color and draws the scene's batches;
paths are rasterized into their intermediate whole and composited through
the scissor (`fast::paths::encode_pass`). The canvas is then copied into the
drawable by a full-screen triangle that reads it texel for texel (drawables
are framebuffer-only, so a blit cannot write them), and presented as before.

Frames are drawn whole when the canvas is missing, resized or drawn for the
other alpha mode, the scene's damage does not compare with the canvas (not
numbered, a dropped frame, composed windows' replayed scenes), the atlas
was written since the last frame (counted per atlas in `fast_writes`), the
scene has surfaces, or the damage covers over half the window. A scene drawn
again shows the canvas as it is. Only `draw` goes through the canvas; the
headless and image paths draw as upstream does.

`GPUI_PARTIAL_REDRAW=0` draws as upstream does. `GPUI_RENDER_STATS=1`
prints `partial_frames`, `full_frames`, `partial_px` and `why_<reason>`
every second. Pixel tests compare partial frames with the same scene drawn
whole, byte for byte, and run in CI on macOS.

Hooks: `MetalRenderer::fast_partial` and the forwarding call in `draw`;
`MetalAtlasTextures::fast_writes`, noted in `insert`; `render_frame`,
`read_texture_to_image`, `MetalAtlas.0` and `MetalAtlasTextures` made
`pub(crate)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee and others added 19 commits October 8, 2026 12:07
The canvas each renderer keeps, when a frame is partial and when whole on
each platform, how Metal and Direct3D 11 draw the damage and present it
(Present1 dirty rectangles on Windows, a copy pass into the drawable on
macOS), the tests that check them, GPUI_PARTIAL_REDRAW and the render stats,
and what they save and cost, unmeasured on real hardware yet. The README
lists partial redraws among the features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A partial frame encoded a render pass over the canvas, and its path
rasterization, for each damage rectangle. On Apple's GPUs, which render in
tiles, every pass loads and stores the whole canvas, so each rectangle cost
that again. Now the scene's batches are cut into segments at the path groups
rasterized between main passes, as in a whole frame, and each main pass
scissors to every rectangle in turn, filling it in the first pass and drawing
the segment's batches through it in the scene's order. Overlapping damage
rectangles are merged first, so that no pixel is drawn twice.

Tests: four separate rectangles drawn in one main pass and equal to the
scene drawn whole; overlapping rectangles merged and drawn once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g them

Each rectangle draws the whole scene into itself, scissored, so a pixel two
rectangles share would have its translucent primitives blended twice.
Overlapping rectangles become their union until none overlap, as on Metal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, and walk the batches once for all damage rectangles

The canvas was drawn with the previous appearance's clear color, which no
scene damage covers: a frame now draws whole (why_appearance) when the
clear color differs from the one the canvas was drawn with.

Partial frames drew the whole scene once per damage rectangle, so each
path batch cleared and resolved the full-window 4x MSAA intermediate once
per rectangle. fast::frame::draw_scene_in walks the batches once, drawing
each through every rectangle's scissor and rasterizing each path batch
once, scissored to the rectangles' union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Concurrent draws with different fragment bits (tests on NVIDIA and Intel
settings run in parallel) overwrote each other's process-global setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…faces

can_draw looked only at the window scene's paths and surfaces, so paths in
scroll layer tiles were drawn with 4x coverage at other sample counts, and
surfaces in tiles were skipped. It now builds the tiles the frame draws,
checks their content too, and hands them to the draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The writes were taken from the atlas at the start of every attempt but
only folded into the policy when the attempt presented. A frame that was
not presented, or a GPU frame that failed, dropped them, and a scene
presented again then left the sprites over them stale on the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WgpuRenderer::recover replaced the renderer whole, dropping the presenter
the platform installs only with the window: CPU frames fell back to blit
for good, and on Wayland dropping a shown presenter destroyed its
subsurface before a GPU frame replaced it. The presenter is now taken out
before the renderer is recreated, its memory released, and installed in
the new renderer, whose first GPU frame tells it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osures

The swapchain presents in FIFO mode, so a GPU frame's PresentPixmap can
land in the window after CPU frames were uploaded over it, and later CPU
frames upload only their own damage. The presenter now selects
CompleteNotify on the window with its own event context; a GPU present
completing after CPU frames were shown refreshes the window, and that
frame uploads again everything the CPU frames since the GPU presented
uploaded.

Without a compositing manager, Expose re-presents the same scene as a CPU
frame without damage: the exposed rectangles are now uploaded with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anvas

Measured on an M4, a partial frame still costs 75-80% of a whole frame's
GPU time (loading, storing and copying the canvas), and the window
server composites every presented drawable whole. A frame whose scene
was drawn already, or whose damage relative to the canvas is empty, now
draws and presents nothing on Metal (decided before taking a drawable)
and on Direct3D (no Present1 with a 1x1 rectangle). Layer tiles still
rasterize for it. The render stats count these as skipped_frames.

Records the M4 measurements in docs/adaptive-rendering.md and README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only the sprites atlas writes change

Measured on an RTX 4080 at 3840x2040, a 3,788-pixel spinner frame and a
1.2 M-pixel scroll frame cost about the same GPU time: every frame copied
the whole 31 MB canvas into the back buffer. A flip-sequential swap chain
hands its buffers out in turn, so the back buffer holds the frame presented
BUFFER_COUNT presents ago; now only the damage of the frames since is
copied (CopySubresourceRegion), and the whole canvas whenever that cannot
be known: until every buffer holds one of our frames, after a present
elsewhere (GetLastPresentCount), a present that did not return S_OK, a
whole frame, or a new canvas. A test on a real swap chain checks that the
back buffer comes out as the canvas frame after frame.

Atlas writes drew the next frame of every window whole (4 of 5 whole
frames in Quotes, 36 in Scroll). Writes are now logged with their tile
bounds, and the sprites whose tile overlaps one join the damage; past 256
writes the frame is drawn whole.

Records the Windows measurements in docs/adaptive-rendering.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main thread's and the process's CPU time came out as zero on
Windows; read them from GetThreadTimes and GetProcessTimes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Partial frames still uploaded and rasterized every scene path before applying the main-pass scissor. Omit paths that cannot reach the damage while preserving batching, draw order, and a conservative filtering margin.

Add Metal regression tests for omitted paths and mixed batches. All 25 Metal tests, clippy, and the upstream check pass. Three alternating M3 runs show application GPU time falling 70% for Spinner and 52% for Scroll against the previous partial implementation, at 120 fps.
@huacnlee huacnlee closed this Oct 8, 2026
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