display: a ~60 Hz frame clock — presents are scheduled, not immediate

Client 'present' requests and cursor pokes no longer repaint on the spot:
they accumulate damage and arm a one-shot 16 ms timer, and the tick
composites everything pending as one frame. A fast mouse previously
turned every input event into a full present (100+/s); now any number of
draws and moves inside one interval coalesce into a single repaint.

No backend has a real vblank to pace by (docs/display-v2.md, 'Fenced is
not vsync'), so this is the software stand-in — the same strategy Linux
uses atop virtio-gpu. Bring-up paths that need pixels synchronously
(initialise, self-checks) still present directly.

onNotification now handles the message and timer badge bits
independently: one coalesced badge can carry both, and the old
either/or dispatch would have dropped a tick.

All six display QEMU cases pass.
This commit is contained in:
Daniel Samson
2026-07-21 11:26:49 +01:00
parent 16618d2cdc
commit 4f02f75602
2 changed files with 59 additions and 22 deletions
+50 -21
View File
@@ -10,7 +10,9 @@
//! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`,
//! `blit_tile`), mark `damage`, and ask for a `present`; the compositor repaints only the
//! damaged region — clear it, paint the visible layers bottom-to-top into the backend's
//! surface, then `backend.present(damage)`. Shared-memory client surfaces are later
//! surface, then `backend.present(damage)`. Presents are paced by a ~60 Hz **frame clock**
//! (see `schedulePresent`), so any number of client presents and cursor moves inside one
//! interval coalesce into a single frame. Shared-memory client surfaces are later
//! (docs/display-v2.md).
const std = @import("std");
@@ -78,6 +80,37 @@ const damage_mode: DamageMode = .grid;
var damage_list: compositor.DamageList = .{};
var damage_grid: compositor.TileGrid = .{};
/// The **frame clock**: client `present` requests and cursor motion don't repaint
/// immediately — they accumulate damage and arm a one-shot timer, and the tick composites
/// everything pending as one frame. That paces presents to ~60 Hz no matter how fast
/// clients draw or the mouse moves (previously every mouse event became a full present).
/// No backend has a real vblank to pace by (docs/display-v2.md, "Fenced is not vsync");
/// this is the software stand-in, the same strategy Linux uses atop virtio-gpu. Bring-up
/// paths that need pixels on screen *now* (initialise, the self-checks) still call
/// `present()` directly.
const frame_interval_milliseconds = 16;
var frame_timer_armed = false;
/// Arm the frame clock unless a tick is already pending: any number of requests inside
/// one interval coalesce into that single tick's present.
fn schedulePresent() void {
if (frame_timer_armed) return;
frame_timer_armed = true;
_ = system.timerOnce(service_endpoint, frame_interval_milliseconds);
}
/// A timer landing — the frame clock, or the deferred first native present armed by
/// `attach_scanout`: present the accumulated damage, then run the one-shot mode-set
/// self-check if the native upgrade queued it.
fn frameTick() void {
frame_timer_armed = false;
present();
if (pending_modeset_check) {
pending_modeset_check = false;
modesetSelfCheck();
}
}
// --- geometry helpers -------------------------------------------------------
fn screenRect() Rect {
@@ -474,15 +507,15 @@ fn mouseListener(width: u32, height: u32) void {
}
}
/// Consume the latest cursor position from the channel and repaint the cursor layer at
/// it. Runs on the main loop (the compositor owner) in response to a listener poke.
/// `configureLayer` damages both the old and new footprints, so a plain `present`
/// repaints exactly the two rectangles that changed.
/// Consume the latest cursor position from the channel and move the cursor layer to it.
/// Runs on the main loop (the compositor owner) in response to a listener poke.
/// `configureLayer` damages both the old and new footprints; the frame clock presents
/// them at the next tick, so a fast mouse coalesces to at most ~60 repaints a second.
fn renderCursor() void {
const snapshot = cursor_channel.take() orelse return;
const id = cursor_layer orelse return;
_ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true);
present();
schedulePresent();
if (!cursor_tracking_reported and
@abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and
@abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold)
@@ -592,7 +625,9 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
return ok(reply);
},
@intFromEnum(protocol.Operation.present) => {
present();
// Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return ok(reply);
},
@intFromEnum(protocol.Operation.attach_scanout) => {
@@ -622,21 +657,15 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
}
}
/// Two notification sources reach the compositor. A **message-notification** is a poke
/// from the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`):
/// repaint the cursor at its latest channel position. Anything else is the post-attach
/// present **timer**: repaint into the freshly attached native surface, verify the frame
/// landed, then run the one-shot mode-set self-check (V5).
/// Two notification sources reach the compositor, and one coalesced badge can carry
/// both, so each bit is handled independently. A **message-notification** is a poke from
/// the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): fold the
/// newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the frame
/// clock — or the deferred first native present after `attach_scanout` — either way,
/// present the accumulated damage.
fn onNotification(badge: u64) void {
if (badge & ipc.notify_message_bit != 0) {
renderCursor();
return;
}
present(); // native present + verify (first timer fire after the upgrade)
if (pending_modeset_check) {
pending_modeset_check = false;
modesetSelfCheck();
}
if (badge & ipc.notify_message_bit != 0) renderCursor();
if (badge & ipc.notify_timer_bit != 0) frameTick();
}
pub fn main() void {