atagen

monolith

public
Star0
Watch0

Commit f887aaaeea0d

tarball

atagen <boss@atagen.co>2026-10-16 11:23 UTC

1 file changed+86-2
monolith: sample animations at the predicted nested presentation time

A frame is seen at the host vblank after it is committed, not when it is rendered, so sampling at render time put every frame up to a refresh behind and made the lag vary with render time. The host refresh from wp_presentation also replaces the hard-coded 60 Hz on the nested output.
Modified file: crates/monolith/src/nested.rs+86 additions−2 deletions
@@ -21,6 +21,7 @@ use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
2121 use wayland_protocols::wp::linux_dmabuf::zv1::client::{
2222 zwp_linux_buffer_params_v1, zwp_linux_dmabuf_v1,
2323 };
24+use wayland_protocols::wp::presentation_time::client::{wp_presentation, wp_presentation_feedback};
2425 use wayland_protocols::xdg::decoration::zv1::client::{
2526 zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
2627 };
@@ -28,6 +29,7 @@ use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_ba
2829
2930 const CLEAR: [f32; 4] = [0.07, 0.075, 0.09, 1.0];
3031 const DEFAULT_SIZE: (i32, i32) = (1280, 800);
32+const CLOCK_MONOTONIC: u32 = 1;
3133
3234 pub struct Host {
3335 pub size: Size<i32, Logical>,
@@ -46,6 +48,24 @@ pub struct Host {
4648 configured: bool,
4749 frame_pending: bool,
4850 pending_size: Option<Size<i32, Logical>>,
51+ presentation: Option<wp_presentation::WpPresentation>,
52+ /// The host clock is CLOCK_MONOTONIC, so its timestamps share our timeline.
53+ monotonic: bool,
54+ last_present_ns: Option<u64>,
55+ refresh_ns: u64,
56+}
57+
58+impl Host {
59+ /// When a frame committed now will reach the screen, the next host
60+ /// vblank after `now`.
61+ pub fn predict(&self, now: u64) -> u64 {
62+ match self.last_present_ns.filter(|_| self.monotonic) {
63+ Some(last) if self.refresh_ns > 0 && last <= now => {
64+ last + ((now - last) / self.refresh_ns + 1) * self.refresh_ns
65+ }
66+ _ => now,
67+ }
68+ }
4969 }
5070
5171 /// Destroys the host-side buffer when its swapchain slot goes away.
@@ -67,6 +87,9 @@ pub fn init(
6787 let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 4..=6, ())?;
6888 let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
6989 let dmabuf: zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1 = globals.bind(&qh, 3..=3, ())?;
90+ let presentation = globals
91+ .bind::<wp_presentation::WpPresentation, _, _>(&qh, 1..=2, ())
92+ .ok();
7093 // Dropping a proxy handle does not destroy it, so seat events keep reaching input.rs.
7194 let _ = globals.bind::<wayland_client::protocol::wl_seat::WlSeat, _, _>(&qh, 1..=7, ());
7295
@@ -131,6 +154,10 @@ pub fn init(
131154 configured: false,
132155 frame_pending: false,
133156 pending_size: None,
157+ presentation,
158+ monotonic: false,
159+ last_present_ns: None,
160+ refresh_ns: 16_666_667,
134161 });
135162 queue.roundtrip(state)?;
136163 queue.roundtrip(state)?;
@@ -159,7 +186,7 @@ pub fn render(state: &mut State) {
159186 host.size = size;
160187 let mode = Mode {
161188 size: (size.w, size.h).into(),
162- refresh: 60_000,
189+ refresh: (1_000_000_000_000 / host.refresh_ns) as i32,
163190 };
164191 host.output
165192 .change_current_state(Some(mode), None, None, None);
@@ -168,6 +195,7 @@ pub fn render(state: &mut State) {
168195 sc.resize(size.w as u32, size.h as u32);
169196 }
170197 }
198+ let now = host.predict(crate::state::now_ns());
171199 if host.swapchain.is_none() {
172200 host.swapchain = make_swapchain(renderer, &host.formats, host.size);
173201 }
@@ -182,7 +210,6 @@ pub fn render(state: &mut State) {
182210 return;
183211 }
184212 };
185- let now = crate::state::now_ns();
186213 let mut dmabuf = (*slot).clone();
187214 let damage = {
188215 let mut fb = match renderer.bind(&mut dmabuf) {
@@ -240,6 +267,9 @@ pub fn render(state: &mut State) {
240267 .damage_buffer(r.loc.x, r.loc.y, r.size.w, r.size.h);
241268 }
242269 host.surface.frame(&host.qh, ());
270+ if let Some(p) = &host.presentation {
271+ p.feedback(&host.surface, &host.qh, ());
272+ }
243273 host.surface.commit();
244274 swapchain.submitted(&slot);
245275 host.in_flight.push((buffer, slot));
@@ -441,3 +471,57 @@ delegate_noop!(State: ignore wl_surface::WlSurface);
441471 delegate_noop!(State: ignore zwp_linux_buffer_params_v1::ZwpLinuxBufferParamsV1);
442472 delegate_noop!(State: zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
443473 delegate_noop!(State: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1);
474+
475+impl Dispatch<wp_presentation::WpPresentation, ()> for State {
476+ fn event(
477+ state: &mut Self,
478+ _: &wp_presentation::WpPresentation,
479+ event: wp_presentation::Event,
480+ _: &(),
481+ _: &Connection,
482+ _: &QueueHandle<Self>,
483+ ) {
484+ if let wp_presentation::Event::ClockId { clk_id } = event
485+ && let Some(host) = state.host.as_mut()
486+ {
487+ host.monotonic = clk_id == CLOCK_MONOTONIC;
488+ }
489+ }
490+}
491+
492+impl Dispatch<wp_presentation_feedback::WpPresentationFeedback, ()> for State {
493+ fn event(
494+ state: &mut Self,
495+ _: &wp_presentation_feedback::WpPresentationFeedback,
496+ event: wp_presentation_feedback::Event,
497+ _: &(),
498+ _: &Connection,
499+ _: &QueueHandle<Self>,
500+ ) {
501+ let wp_presentation_feedback::Event::Presented {
502+ tv_sec_hi,
503+ tv_sec_lo,
504+ tv_nsec,
505+ refresh,
506+ ..
507+ } = event
508+ else {
509+ return;
510+ };
511+ let Some(host) = state.host.as_mut() else {
512+ return;
513+ };
514+ let secs = (u64::from(tv_sec_hi) << 32) | u64::from(tv_sec_lo);
515+ host.last_present_ns = Some(secs * 1_000_000_000 + u64::from(tv_nsec));
516+ if refresh > 0 && u64::from(refresh) != host.refresh_ns {
517+ host.refresh_ns = u64::from(refresh);
518+ let mode = Mode {
519+ size: (host.size.w, host.size.h).into(),
520+ refresh: (1_000_000_000_000 / u64::from(refresh)) as i32,
521+ };
522+ host.output
523+ .change_current_state(Some(mode), None, None, None);
524+ host.output.set_preferred(mode);
525+ }
526+ }
527+}