Damage rendering
Why an idle BUI window uses no CPU, and why a hover redraws one widget instead of the whole window. The trick, step by step.
Leave a BUI window open and walk away: it uses no CPU at all. Hover a button, and BUI redraws that button, not the window. This is damage rendering, and it’s the main reason BUI is easy on laptop batteries.
The idea is simple: keep track of which parts of the screen are “damaged” (out of date), and redraw only those. Here’s how BUI does it, from the widget all the way to the screen.
The short version
- A change marks only its widget’s rectangle as dirty.
- If nothing is dirty, BUI doesn’t render. The event loop sleeps until the next input or timer.
- When something is dirty, BUI redraws only those rectangles.
- The renderer then finds which pixels really changed, and only those reach the screen.
On the full components demo (520×900, Ryzen 7 5800U laptop), that’s the difference between about 0.7 ms for a complete frame and about 0.05 ms for a hover or number-scrub frame.
Frame cost
Python and C, the full component demo (520×900)
Lower is better
0.05 msfor a hover or scrub frame
- Complete frameeverything redrawn~0.7 ms
- Input to screennative window~0.06 ms
- Hover or scrubdamage only~0.05 ms
Open benchClose bench
| Frame | Time |
|---|---|
| Complete frame (everything redrawn) | ~0.7 ms |
| Hover or number-scrub frame (damage only) | ~0.05 ms |
| Native window: input event → on screen | ~0.06 ms |
Dear ImGui's demo window with every section open (27k triangles, 1280×800) is a full software redraw of about 5–8 ms.
Source: README.md#performance · Ryzen 7 5800U laptop, Windows 11, on AC power
Step by step
-
A widget marks itself dirty.
When a value, text or state changes, the kit marks that node’s rectangle as dirty, plus a few pixels of margin. Popups get a wider margin, to cover their shadow. Nothing is drawn yet.
-
Dirty rectangles merge.
The kit keeps a short list of dirty rectangles per window: up to 24. A new rectangle that overlaps one already in the list is merged into it. If the list is full, everything merges into one rectangle.
-
The loop decides whether to render.
On the next turn of the event loop, the kit checks for dirty rectangles. If there are none, and nothing needs a new layout, it returns without drawing anything. This is why an idle window costs nothing: there’s no timer redrawing it “just in case”.
-
Only the dirty regions are drawn.
For each dirty rectangle, the kit draws the widgets that touch it, clipped to the rectangle. Everything outside keeps last frame’s pixels.
If the layout changed (the window resized, or a widget was added), positions may have moved everywhere, so the whole window is redrawn once.
-
The renderer finds what actually changed.
The software raster compares the new frame with the previous one in tiles of 64×32 pixels, but only the tiles under the redrawn area. Runs of changed tiles become the damage list. A redraw that produced the same pixels (a hover that didn’t change a color) reports nothing.
-
Only damaged tiles reach the screen.
The window host presents just the damage list:
- Windows: the damaged regions are copied straight to the window.
- Linux (X11): each damaged rectangle is sent with
XPutImage. - macOS: each damaged rectangle is invalidated on the view, which redraws it from the frame.
- GPU renderer: frames render into a persistent target, so only damaged regions are drawn on the GPU. On Windows, the flip-model swap chain gets dirty rectangles, so the system compositor only recomposes what changed.
Animation and frame pacing
Some things do need frames over time: a panel collapsing, a CSS transition, a React app calling requestAnimationFrame. BUI renders frames only while something animates, and stops as soon as it’s done.
- Paced to the display. On Windows, animation frames wait for the display’s next refresh (
DwmFlush), at its refresh rate. On Linux and macOS, the event loop paces them with a timer. - Late frames don’t wait. A frame that’s already late starts right away instead of waiting for another refresh.
- One rebuild per frame. Rust and JavaScript rebuild the scene right before the render (
BK_window_request_framein C), so changes made in an animation callback join that same frame. - Damage still applies. If one cell of a grid animates, only that cell is redrawn each frame.
Headless windows (tests and screenshots) run on a virtual clock that tests advance by hand, so animations step exactly and tests don’t wait.
What it adds up to
From the start-time benchmark, measured on a Ryzen 7 5800U laptop: the CPU each app used while its window sat idle, per minute.
Idle CPU, every way in
CPU time per minute while the settings window sits idle, CPU renderer
Lower is better
0 mswhile nothing changes
- RustHTML + CSS0 ms
- Blender componentsPython0 ms
- Qt syntaxPython0 ms
- tkinter syntaxPython0.2 ms
- JavaScriptReact, Node.js event loop68 ms
Open benchClose bench
| App | Renderer | Start time | CPU used to start | Memory (idle) | Idle CPU per minute |
|---|---|---|---|---|---|
| Rust (HTML + CSS) | CPU | 38 ms | 27 ms | 13 MB | 0 ms |
| GPU | 42 ms | 61 ms | 31 MB | 1.8 ms | |
| JavaScript (React, Node.js) | CPU | 94 ms | 100 ms | 48 MB | 68 ms |
| GPU | 99 ms | 131 ms | 60 MB | 71 ms | |
| Blender components (Python) | CPU | 112 ms | 86 ms | 23 MB | 0 ms |
| GPU | 116 ms | 125 ms | 32 MB | 1.7 ms | |
| tkinter syntax (Python) | CPU | 139 ms | 112 ms | 25 MB | 0.2 ms |
| GPU | 159 ms | 157 ms | 43 MB | 2.4 ms | |
| Qt syntax (Python) | CPU | 156 ms | 123 ms | 27 MB | 0 ms |
| GPU | 161 ms | 160 ms | 36 MB | 1.2 ms | |
| Same gpui code: blenderui | CPU | 40 ms | 27 ms | 15 MB | 0 ms |
| blenderui | GPU | 43 ms | 60 ms | 31 MB | 2.1 ms |
| gpui | GPU | 231 ms | 226 ms | 29 MB | 743 ms |
| gpui-fast | GPU | 226 ms | 217 ms | 31 MB | 2.5 ms |
Python 3.14 and Node 22. The last rows are one gpui-syntax source file (bindings/rust/bench/common/settings.rs) compiled unchanged against blenderui, gpui and gpui-fast; gpui has no form widgets, so there the controls are styled divs. Battery cost is the CPU time an app causes; memory doesn't count GPU memory.
Source: README.md#performance · Ryzen 7 5800U laptop, Windows 11, on AC power
Idle CPU, same gpui code
CPU time per minute while the window sits idle
Lower is better
0 msidle CPU on the CPU renderer: it sleeps
- BUI
- Others
- blenderuiCPU renderer0 ms
- blenderuiGPU renderer2.1 ms
- gpui-fastGPU2.5 ms1.2× more
- gpuiZed's, GPU743 ms354× more
Open benchClose bench
| Same gpui code | Renderer | Start time | CPU used to start | Memory (idle) | Idle CPU per minute |
|---|---|---|---|---|---|
| blenderui | CPU | 40 ms | 27 ms | 15 MB | 0 ms |
| blenderui | GPU | 43 ms | 60 ms | 31 MB | 2.1 ms |
| gpui | GPU | 231 ms | 226 ms | 29 MB | 743 ms |
| gpui-fast | GPU | 226 ms | 217 ms | 31 MB | 2.5 ms |
gpui has no form widgets, so there the controls are styled divs. Memory doesn't count GPU memory.
Source: README.md#performance · Ryzen 7 5800U laptop, Windows 11, on AC power
Node’s own event loop accounts for the JavaScript idle figure. On the same gpui source, gpui itself keeps working while idle. The full table is on the Benchmarks page.
Keeping your app cheap
Damage rendering works without any effort from you. A few habits keep it working well:
- Change values, not layouts. Setting a value redraws one widget. Adding or removing widgets triggers a layout pass and a full redraw. Both are fast, but the first is nearly free.
- Don’t poll. A timer in your code that sets the same value every few milliseconds keeps the event loop awake. Prefer callbacks.
- Keep big lists on signals (JavaScript). A signal updates one node; re-rendering a 3,000-item list re-renders everything. See Profiling.
- Measure.
BUI_PROFILE=1prints per-phase frame times, so you can see whether frames are full or partial.
Next steps
Last updated