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 bench
Python and C, the full component demo (520×900)
FrameTime
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

  1. 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.

  2. 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.

  3. 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”.

  4. 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.

  5. 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.

  6. 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_frame in 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 bench
Start time and battery: the same small settings window, median of 10 launches, process start to first frame
AppRendererStart timeCPU used to startMemory (idle)Idle CPU per minute
Rust (HTML + CSS)CPU38 ms27 ms13 MB0 ms
GPU42 ms61 ms31 MB1.8 ms
JavaScript (React, Node.js)CPU94 ms100 ms48 MB68 ms
GPU99 ms131 ms60 MB71 ms
Blender components (Python)CPU112 ms86 ms23 MB0 ms
GPU116 ms125 ms32 MB1.7 ms
tkinter syntax (Python)CPU139 ms112 ms25 MB0.2 ms
GPU159 ms157 ms43 MB2.4 ms
Qt syntax (Python)CPU156 ms123 ms27 MB0 ms
GPU161 ms160 ms36 MB1.2 ms
Same gpui code: blenderuiCPU40 ms27 ms15 MB0 ms
blenderuiGPU43 ms60 ms31 MB2.1 ms
gpuiGPU231 ms226 ms29 MB743 ms
gpui-fastGPU226 ms217 ms31 MB2.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 bench
Start time, the same gpui-syntax file (bindings/rust/bench/common/settings.rs) compiled against each library: median of 10 launches, process start to first frame
Same gpui codeRendererStart timeCPU used to startMemory (idle)Idle CPU per minute
blenderuiCPU40 ms27 ms15 MB0 ms
blenderuiGPU43 ms60 ms31 MB2.1 ms
gpuiGPU231 ms226 ms29 MB743 ms
gpui-fastGPU226 ms217 ms31 MB2.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=1 prints per-phase frame times, so you can see whether frames are full or partial.

Next steps

Edit this page on GitHub

Last updated