Skip to content

feat: incremental (damage-region) present for an opt-in root - #7

Merged
tannevaled merged 3 commits into
mainfrom
perf/damage-present
Aug 9, 2026
Merged

feat: incremental (damage-region) present for an opt-in root#7
tannevaled merged 3 commits into
mainfrom
perf/damage-present

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes the gap between the scene layer's per-frame damage region and the window present path: a live window can now pack+present ONLY the changed rectangle(s) instead of the full surface each frame.

The seam

A root passed to Run may implement the opt-in capability

type DamageRenderer interface {
    RenderDamaged(p painter.Painter, th *toolkit.Theme) []toolkit.Rect
}

satisfied by scene.HostRoot (go-widgets/toolkit#174, v0.132.0). When the root implements it, Run draws the frame through it and packs+presents only the coalesced union of the returned rectangles via the existing small-rect paths — X11 MIT-SHM ShmPutImage over a framebuffer-mirroring segment, or a wl_shm sub-rect DamageBuffer. A plain toolkit.Widget root keeps the full-surface path unchanged. The first frame, a resize (framebuffer reallocated) and an X11 Expose present the full surface, then Run resumes incremental.

Wayland double-buffer correctness

The two wl_shm pool buffers alternate, so a re-chosen buffer is up to a frame stale outside this frame's damage. presentDamaged packs everything the buffer owes (per-buffer damage age), so every attached buffer holds the whole correct image, while surface damage marks only this frame's rectangles. The framebuffer persists across frames, so pixels outside the damage stay correct.

Verification (Xvfb + headless sway, on a Linux VM; arm64 locally, amd64 in CI)

  • Pixel-identity (critical): the framebuffer updated only by incremental render stays byte-identical (fnv1a screenshot-hash) to a fresh full repaint over a scripted change log, each frame touching ~one cell. The Wayland buffer-age invariant is asserted at the pool-buffer level (fully current after every frame).
  • Live lanes: TestLiveX11Damage / TestLiveWaylandDamage flip one quadrant via a real injected click (xdotool / sovereign virtual-pointer) and assert the clicked quadrant took the new colour while the other three are pixel-unchanged.
  • Measurement (800×600, 1200 cells, one-cell change): full 3.06 ms / 1.92 MB per frame vs incremental 0.75 ms / 1.44 KB4.1× faster, ~1330× fewer bytes packed/blitted.
  • internal/x11 + internal/wayland stay at 100% coverage (-race); damage.go helpers 100%; cross-build (windows/darwin/6-linux) + arch lanes unaffected.

Pure-Go, CGO-free.

🤖 Generated with Claude Code

tannevaled and others added 3 commits August 9, 2026 17:18
A root passed to Run may now implement the opt-in capability

    RenderDamaged(p painter.Painter, th *toolkit.Theme) []toolkit.Rect

(DamageRenderer, satisfied by scene.HostRoot). When it does, Run draws the
frame through it and packs+presents ONLY the coalesced union of the returned
damaged rectangles via the existing small-rect paths -- X11 MIT-SHM
ShmPutImage over a framebuffer-mirroring segment, or a wl_shm sub-rect
DamageBuffer -- instead of the whole surface every frame. A plain
toolkit.Widget root keeps the full-surface path unchanged. The first frame,
a resize (framebuffer reallocated) and an X11 Expose (server discarded
contents) still present the full surface, then Run resumes incremental.

Wayland double-buffer correctness: because the two wl_shm pool buffers
alternate, the chosen buffer is up to a frame stale outside this frame's
damage; presentDamaged packs everything the buffer OWES (per-buffer damage
age), so every attached buffer holds the whole correct image, while surface
damage marks only this frame's changed rectangles. The framebuffer persists
across frames, so pixels outside the damage are already correct.

Verification (Xvfb + headless-sway VM, arm64/amd64):
- pixel-identity: framebuffer updated only by incremental render stays
  byte-identical (fnv1a) to a fresh full repaint over a scripted change log,
  each frame touching ~one cell; and the Wayland buffer-age invariant is
  asserted at the pool-buffer level (fully current after every frame).
- live lanes: TestLiveX11Damage / TestLiveWaylandDamage flip one quadrant via
  a real injected click and assert the clicked quadrant took the new colour
  while the other three are pixel-unchanged.
- measurement (800x600, 1200 cells, one-cell change): full 3.06 ms / 1.92 MB
  per frame vs incremental 0.75 ms / 1.44 KB -- 4.1x faster, ~1330x fewer
  bytes packed/blitted.
- internal/x11 + internal/wayland stay at 100% coverage (-race); damage.go
  helpers 100%; all cross-build + arch lanes unaffected.

Requires go-widgets/toolkit v0.132.0 (scene.HostRoot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Headless sway configures the toplevel to the full 800x600 output, so the
four-quadrant live-damage pattern must lay its cells out relative to its
bounds (as the existing patternRoot does) instead of fixed 200x160 rects,
which left the sampled points on the background. quadBox re-flows its four
children into the quadrants on every SetBounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first xdg configure arrives during the run loop, not at bring-up, so
paintInitial ran with the surface not yet configured. presentDamaged then
no-opped (correctly), but drawIncremental had already CONSUMED the scene's
full-surface seed damage via RenderDamaged -- so once the configure arrived,
every later frame reported empty damage and the window presented nothing (a
blank, background-only surface on a real compositor). The plain path is
immune because it re-renders the whole surface each frame.

Gate the incremental path on w.configured: skip render+present until the
surface is configured, keeping the pending damage intact so the first
configure draws and presents the full seed frame. Proven on headless sway:
TestLiveWaylandDamage now flips one quadrant via an injected click and sees
the clicked quadrant change while the other three stay pixel-unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 2876704 into main Aug 9, 2026
19 checks passed
@tannevaled
tannevaled deleted the perf/damage-present branch August 9, 2026 15:41
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