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
+9 -1
View File
@@ -196,13 +196,21 @@ shell, a terminal, a cursor, and a wallpaper:
| `fill_rect` | fill a rectangle of a layer with a colour | | `fill_rect` | fill a rectangle of a layer with a colour |
| `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) | | `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) |
| `damage` | mark a region of a layer dirty | | `damage` | mark a region of a layer dirty |
| `present` | composite dirty layers and flush to the screen | | `present` | request a repaint: composited at the next frame-clock tick |
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
(the [PSF font](../system/kernel/font.psf) path the console already uses can move into a (the [PSF font](../system/kernel/font.psf) path the console already uses can move into a
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
policy in the client. policy in the client.
`present` is a *request*, not an immediate flush: the compositor runs a ~60 Hz **frame
clock** (a one-shot kernel timer re-armed on demand), and each tick composites all the
damage accumulated since the last one. Any number of client presents and cursor moves
inside one interval coalesce into a single repaint — the software stand-in for vblank
pacing on backends that have none (all of them today; see
[display-v2.md](display-v2.md), "Fenced is not vsync"). Bring-up paths that must put
pixels on screen synchronously (initialisation, the self-checks) bypass the clock.
## `runtime.display` ## `runtime.display`
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig), Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
+50 -21
View File
@@ -10,7 +10,9 @@
//! z-order, and visibility. Clients create layers, draw into them by command (`fill_rect`, //! 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 //! `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 //! 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). //! (docs/display-v2.md).
const std = @import("std"); const std = @import("std");
@@ -78,6 +80,37 @@ const damage_mode: DamageMode = .grid;
var damage_list: compositor.DamageList = .{}; var damage_list: compositor.DamageList = .{};
var damage_grid: compositor.TileGrid = .{}; 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 ------------------------------------------------------- // --- geometry helpers -------------------------------------------------------
fn screenRect() Rect { 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 /// Consume the latest cursor position from the channel and move the cursor layer to it.
/// it. Runs on the main loop (the compositor owner) in response to a listener poke. /// 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` /// `configureLayer` damages both the old and new footprints; the frame clock presents
/// repaints exactly the two rectangles that changed. /// them at the next tick, so a fast mouse coalesces to at most ~60 repaints a second.
fn renderCursor() void { fn renderCursor() void {
const snapshot = cursor_channel.take() orelse return; const snapshot = cursor_channel.take() orelse return;
const id = cursor_layer orelse return; const id = cursor_layer orelse return;
_ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true); _ = configureLayer(id, snapshot.x, snapshot.y, cursor_z, true);
present(); schedulePresent();
if (!cursor_tracking_reported and if (!cursor_tracking_reported and
@abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and @abs(snapshot.x - cursor_origin_x) >= cursor_report_threshold and
@abs(snapshot.y - cursor_origin_y) >= cursor_report_threshold) @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); return ok(reply);
}, },
@intFromEnum(protocol.Operation.present) => { @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); return ok(reply);
}, },
@intFromEnum(protocol.Operation.attach_scanout) => { @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 /// Two notification sources reach the compositor, and one coalesced badge can carry
/// from the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): /// both, so each bit is handled independently. A **message-notification** is a poke from
/// repaint the cursor at its latest channel position. Anything else is the post-attach /// the mouse-listener thread (a buffered self-`ipc.send`, `notify_message_bit`): fold the
/// present **timer**: repaint into the freshly attached native surface, verify the frame /// newest cursor position into the scene. A **timer** (`notify_timer_bit`) is the frame
/// landed, then run the one-shot mode-set self-check (V5). /// clock — or the deferred first native present after `attach_scanout` — either way,
/// present the accumulated damage.
fn onNotification(badge: u64) void { fn onNotification(badge: u64) void {
if (badge & ipc.notify_message_bit != 0) { if (badge & ipc.notify_message_bit != 0) renderCursor();
renderCursor(); if (badge & ipc.notify_timer_bit != 0) frameTick();
return;
}
present(); // native present + verify (first timer fire after the upgrade)
if (pending_modeset_check) {
pending_modeset_check = false;
modesetSelfCheck();
}
} }
pub fn main() void { pub fn main() void {