Skip to content

cocoa: convert only the damaged rows, so reporting damage does something - #117

Merged
tannevaled merged 1 commit into
mainfrom
cocoa-damage-scaled-conversion
Sep 11, 2026
Merged

tannevaled merged 1 commit into
mainfrom
cocoa-damage-scaled-conversion

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The defect

Incremental present is wired end to end on macOS — Surface.Damage
RenderDamagedpresentRectssetNeedsDisplayRect — and it saved
nothing. Measured on a window presenting at 60 Hz, same binary, one bool
changed:

cost
whole-surface present 26.2% of a core
damage reporting 26.8%

Sampling the process said why. -drawRect: wrapped the whole framebuffer
in a fresh NSBitmapImageRep and drew it once, relying on AppKit's clip to
limit what reached the screen — which it does. What it does not limit is the
conversion:

-[NSView displayIfNeeded]
  -> drawRect:
     -> -[NSImageRep drawInRect:fromRect:...]
        -> -[NSBitmapImageRep CGImage]

A rep has no backing CGImage until it is drawn, so every present converted
every row whatever the invalid region said.

The change

A run of rows is contiguous in the framebuffer, so it can be wrapped as a
bitmap of its own. presentRects hands its rectangles to -drawRect: through
the window, DrawBands reduces them to merged row runs, and each run is drawn
as its own rep into its own destination rect. The conversion is then
proportional to what changed.

An AppKit-initiated draw — an expose, a resize, the first display — finds no
pending rectangles and falls back to one band covering the buffer, which is
exactly the draw this replaces.

What it is worth

Measured rather than implied: a window repainting a small region every frame
at 60 Hz costs 22.2% of a core before and 19.1% after, two runs each,
consistent in direction. That is ~14% off the frame cost — real and
reproducible, and well short of what the profile suggested. The conversion was
about a seventh of the cost, not the bulk of it; the rest sits below this layer
and is not addressed here.

Tests

The row arithmetic is in mapping.go, the OS-independent half the Linux
coverage lane measures, and is covered by construction:

  • bands merge when they touch as well as when they overlap — converting a
    seam twice costs more than the row it saves;
  • they clamp to the buffer and drop what falls outside;
  • every row a caller asks for ends up inside a band, whatever the rectangles —
    a row left out is a row the screen keeps stale;
  • BandDest places a band where the whole-buffer draw would have put those
    same rows, and adjacent bands meet exactly, since a gap between two of
    them is a stale line across the window.

internal/cocoa mapping.go stays at 100% statement coverage. The live
darwin repaint proof passes.

🤖 Generated with Claude Code

Incremental present was wired end to end and saved nothing. Measured on a
window presenting at 60 Hz, same binary, one bool changed: 26.2% of a core
with whole-surface present, 26.8% with damage reporting. Slightly worse, not
better.

Sampling the process said why. -drawRect: wrapped the WHOLE framebuffer in a
fresh NSBitmapImageRep and drew it once, relying on AppKit's clip to limit
what reached the screen — which it does. What it does not limit is the
conversion:

    -[NSView displayIfNeeded]
      -> drawRect:
         -> -[NSImageRep drawInRect:fromRect:...]
            -> -[NSBitmapImageRep CGImage]

A rep has no backing CGImage until it is drawn, so every present converted
every row whatever the invalid region said. The clip narrowed the blit and
left the conversion where it was.

A run of rows is contiguous in the framebuffer, so it can be wrapped as a
bitmap of its own. presentRects now hands its rectangles to -drawRect:
through the window, DrawBands reduces them to merged row runs, and each run
is drawn as its own rep into its own destination rect, so the conversion is
proportional to what changed.

WHAT IT IS WORTH, measured rather than implied: a window repainting a small
region every frame at 60 Hz costs 22.2% of a core before and 19.1% after,
two runs each, consistent in direction. That is ~14% off the frame cost —
real and reproducible, and well short of what the profile suggested. The
conversion was about a seventh of the cost, not the bulk of it; the rest
sits below this layer and is not addressed here.

An AppKit-initiated draw — an expose, a resize, the first display — finds no
pending rectangles (they are consumed by the draw that reads them, and
presentFull clears them) and falls back to one band covering the buffer,
which is exactly the draw this replaces.

The row arithmetic lives in mapping.go, the OS-independent half the Linux
lane measures, and is covered by construction: bands merge when they touch
as well as when they overlap, clamp to the buffer, drop what falls outside,
and BandDest places each band where the whole-buffer draw would have put
those same rows — with adjacent bands meeting exactly, since a gap between
two of them is a stale line across the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 6eeb07e into main Sep 11, 2026
28 checks passed
@tannevaled
tannevaled deleted the cocoa-damage-scaled-conversion branch September 11, 2026 16:43
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