From 4f02f75602b7ebb8b3d435a16d5ae794026d7b95 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:26:49 +0100 Subject: [PATCH] =?UTF-8?q?display:=20a=20~60=20Hz=20frame=20clock=20?= =?UTF-8?q?=E2=80=94=20presents=20are=20scheduled,=20not=20immediate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/display.md | 10 +++- system/services/display/display.zig | 71 ++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/docs/display.md b/docs/display.md index a8e436b..826f0b7 100644 --- a/docs/display.md +++ b/docs/display.md @@ -196,13 +196,21 @@ shell, a terminal, a cursor, and a wallpaper: | `fill_rect` | fill a rectangle of a layer with a colour | | `blit_tile` | copy a small client-supplied pixel tile into a layer (inline) | | `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 (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 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` Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig), diff --git a/system/services/display/display.zig b/system/services/display/display.zig index 65a7c1d..be8beae 100644 --- a/system/services/display/display.zig +++ b/system/services/display/display.zig @@ -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 {