display: damage-rect list + tile-grid trackers, vectorizable pixel loops, wide WC stores

Tearing mitigation for the GOP floor, attacking the copy window from three sides:

- Damage is no longer one bounding box. Two trackers, A/B-switchable at
  compile time (display.zig damage_mode): DamageList (free-form dirty rects,
  overlap-merged) and TileGrid (fixed 64-px tiles, exact O(1) marking, runs
  coalesced back into rects). Far-apart changes — the cursor here, an
  animating layer there — no longer unite into one huge repaint.
- fillRect/composite/blitTile now work in row spans (@memset/@memcpy), so
  the compiler vectorizes them and ReleaseSafe bounds checks drop to per-row.
- The back->front present streams 8-byte volatile stores (presentSpan);
  Backend.present takes the rect list, so each present copies only what
  changed, faster.

Host tests cover both trackers; the display QEMU cases all pass.
This commit is contained in:
Daniel Samson
2026-07-21 11:22:00 +01:00
parent 981a4af7e0
commit 23f915c593
3 changed files with 370 additions and 53 deletions
+43 -13
View File
@@ -50,8 +50,10 @@ var pending_modeset_check: bool = false;
var background: u32 = 0;
/// The layer stack. A fixed table (a compositor has few top-level surfaces during
/// bring-up); each used slot owns an mmap'd surface. `damage` accumulates the dirty
/// screen region since the last `present`, so a present touches only what changed.
/// bring-up); each used slot owns an mmap'd surface. `damage_list` accumulates the dirty
/// screen rectangles since the last `present`, so a present touches only what changed —
/// and keeps far-apart changes (the cursor here, an animating layer there) as *separate*
/// small copies rather than one huge bounding box (see compositor.DamageList).
const maximum_layers = 16;
const Layer = struct {
@@ -65,7 +67,16 @@ const Layer = struct {
};
var layers: [maximum_layers]Layer = [_]Layer{.{}} ** maximum_layers;
var damage: Rect = Rect.empty;
/// Which damage tracker drives `present` — a compile-time A/B switch (both are in
/// compositor.zig with the trade-off discussion):
/// .list — free-form dirty rectangles (tight bounds, heuristic merging)
/// .grid — a fixed 64-px tile grid (exact O(1) merging, tile-quantized repaints)
const DamageMode = enum { list, grid };
const damage_mode: DamageMode = .grid;
var damage_list: compositor.DamageList = .{};
var damage_grid: compositor.TileGrid = .{};
// --- geometry helpers -------------------------------------------------------
@@ -78,9 +89,20 @@ fn layerScreenRect(l: *const Layer) Rect {
return .{ .x = l.x, .y = l.y, .w = @intCast(l.surface.width), .h = @intCast(l.surface.height) };
}
/// Add `r` (screen coordinates) to the pending damage, clipped to the screen.
/// Add `r` (screen coordinates) to the pending damage, clipped to the screen. In grid
/// mode the grid re-sizes itself lazily when the screen geometry changes — every
/// geometry-changing path (`attach_scanout`, `set_mode`) damages the whole new screen
/// right after, so damage pending from the old geometry is safely superseded.
fn addDamage(r: Rect) void {
damage = damage.unite(r.intersect(screenRect()));
const clipped = r.intersect(screenRect());
switch (damage_mode) {
.list => damage_list.add(clipped),
.grid => {
const mode = backend.info();
if (!damage_grid.matches(mode.width, mode.height)) damage_grid.reset(mode.width, mode.height);
damage_grid.add(clipped);
},
}
}
// --- layer operations (called from onMessage and the self-check) ------------
@@ -183,21 +205,29 @@ fn compositeInto(clip: Rect) void {
}
}
/// Composite the accumulated damage into the backend's surface, hand it to the backend to
/// put on screen, then clear the damage. A no-op when nothing is dirty. The frame counter
/// advances regardless, so callers can name frames.
/// Composite each accumulated damage rectangle into the backend's surface, hand the list
/// to the backend to put on screen, then clear the damage. A no-op when nothing is dirty.
/// The frame counter advances regardless, so callers can name frames.
fn present() void {
const dirty = damage.intersect(screenRect());
if (!dirty.isEmpty()) {
compositeInto(dirty);
var scratch: [compositor.TileGrid.maximum_rects]Rect = undefined;
const dirty: []const Rect = switch (damage_mode) {
.list => damage_list.slice(),
.grid => damage_grid.collect(&scratch),
};
const had_damage = dirty.len != 0;
if (had_damage) {
for (dirty) |region| compositeInto(region);
backend.present(dirty);
}
damage = Rect.empty;
switch (damage_mode) {
.list => damage_list.clear(),
.grid => damage_grid.clear(),
}
frames += 1;
// The first present after a native upgrade confirms the composited frame actually reached
// the shared scanout surface (the automated stand-in for "it's on screen").
if (pending_native_verify and !dirty.isEmpty()) {
if (pending_native_verify and had_damage) {
pending_native_verify = false;
verifyNativePresent();
}