git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit259d73f7a9bcf1d9b05e84ad10f6d966ef68684e
parent43b40804fb
authorLucas Galante <[email protected]>
date2026-09-15 22:05
feat(camera): sub-pixel desk placement at HiDPI (device-pixel pans)

Scene nodes sit on integer layout px, so at output scale 2 every pan
moved the desk in two-device-pixel steps: a slow pan alternated 0 and
2 px frames, the visible judder of HiDPI panning. Now the pan handed
to placement is floored to a layout pixel at the current zoom
(`WindowManager::layout_camera`) and the remainder — each in (-1, 0]
layout px — is a desk sub-pixel offset the vendored scenefx applies at
render time: every box and region of a node under one of the scene's
desk trees (windows, borders, popups, X11 menus, the grid) is shifted
by the offset rounded to that output's device px, so at scale 2 the
desk moves one device pixel per frame and at scale 1 nothing changes.
Layer shells, fullscreen and the lock screen are not desk trees and
stay put. Hit-testing and damage stay on the unshifted positions;
`scene_output_damage` widens damage by the shift so a client repaint
under a shifted desk still covers what it drew, and a shifted desk
buffer is never direct-scanned-out. The camera's motion quantum for
repaints follows: a move counts once it crosses a device pixel of the
largest enabled output.

The scenefx additions are appended at the END of `struct wlr_scene`
(a desk-tree list rather than a per-tree flag): an earlier cut added a
field to `wlr_scene_tree`, which heads `wlr_scene`, and the compositor
segfaulted in the colour manager the moment a surface entered an
output.

Measured headless at --scale 2: four successive half-layout-pixel pans
moved a window's edge 199 → 198 → 197 → 196 device px with no vertical
drift, no seam at the output's right or bottom edge, and the overview
at a fractional pan renders clean.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 scenefx/include/scenefx/types/wlr_scene.h | 16 +++++++
 scenefx/types/scene/wlr_scene.c           | 72 +++++++++++++++++++++++++++++++
 src/server/output.rs                      | 10 ++++-
 src/server/scene.rs                       | 10 +++++
 src/server/window.rs                      | 12 +++---
 src/server/window_manager.rs              | 58 ++++++++++++++++++++++---
 src/server/wlroots_log_wrapper.c          | 34 +++++++++++++++
 src/server/xdg_toplevel.rs                |  5 ++-
 wrapper.h                                 |  2 +
 9 files changed, 203 insertions(+), 16 deletions(-)

diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index a570801..ca392ef 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -143,6 +143,22 @@ struct wlr_scene {
 
 		struct blur_data blur_data;
 	} WLR_PRIVATE;
+
+	/**
+	 * Desk sub-pixel rendering (appended last: nothing else in this
+	 * struct, nor wlr_scene_tree, moves). desk_trees lists the subtrees
+	 * that render shifted by desk_sub_x/y — the camera pan's remainder
+	 * below one layout pixel, each in (-1, 0] layout px. Node positions
+	 * stay integer layout px; each output rounds the shift to its own
+	 * device pixels at render time (0 or -1 at scale 2), so at output
+	 * scale 2 a desk moves in single device pixels instead of two at a
+	 * time. Hit-testing and damage stay on the unshifted positions;
+	 * damage is widened by the shift so a client repaint under a shifted
+	 * desk still covers what it drew.
+	 */
+#define WLR_SCENE_DESK_TREES 8
+	struct wlr_scene_tree *desk_trees[WLR_SCENE_DESK_TREES];
+	double desk_sub_x, desk_sub_y;
 };
 
 /** A scene-graph node displaying a single surface. */
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 167e782..1aa2e85 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1,4 +1,5 @@
 #include <assert.h>
+#include <math.h>
 #include <pixman.h>
 #include <stdint.h>
 #include <stdio.h>
@@ -510,6 +511,33 @@ static void transform_output_box(struct wlr_box *box, const struct render_data *
 	wlr_box_transform(box, box, transform, data->trans_width, data->trans_height);
 }
 
+/* Whether a node renders with the scene's desk sub-pixel offset: any
+ * ancestor is one of the scene's desk trees. */
+static bool scene_node_has_desk_offset(struct wlr_scene *scene, struct wlr_scene_node *node) {
+	for (struct wlr_scene_tree *t = node->parent; t != NULL; t = t->node.parent) {
+		for (int i = 0; i < WLR_SCENE_DESK_TREES; i++) {
+			if (scene->desk_trees[i] == t) {
+				return true;
+			}
+		}
+	}
+	return false;
+}
+
+/* The desk offset in an output's device px (each 0 or negative). Only a
+ * NORMAL transform is shifted: on a rotated output the vector would need
+ * rotating too, and no cce output is rotated. */
+static void scene_desk_offset_px(struct wlr_scene *scene, float scale,
+		enum wl_output_transform transform, int *dx, int *dy) {
+	*dx = 0;
+	*dy = 0;
+	if (transform != WL_OUTPUT_TRANSFORM_NORMAL) {
+		return;
+	}
+	*dx = (int)lround(scene->desk_sub_x * scale);
+	*dy = (int)lround(scene->desk_sub_y * scale);
+}
+
 static void scene_output_damage(struct wlr_scene_output *scene_output,
 		const pixman_region32_t *damage) {
 	struct wlr_output *output = scene_output->output;
@@ -518,6 +546,23 @@ static void scene_output_damage(struct wlr_scene_output *scene_output,
 	pixman_region32_init(&clipped);
 	pixman_region32_intersect_rect(&clipped, damage, 0, 0, output->width, output->height);
 
+	// A desk shifted by the sub-pixel offset draws its nodes up to one
+	// device px away from where their layout boxes say; widen the damage
+	// by that shift so the drawn pixels are always inside it.
+	{
+		int dx, dy;
+		scene_desk_offset_px(scene_output->scene, output->scale, output->transform, &dx, &dy);
+		if (dx != 0 || dy != 0) {
+			pixman_region32_t shifted;
+			pixman_region32_init(&shifted);
+			pixman_region32_copy(&shifted, &clipped);
+			pixman_region32_translate(&shifted, dx, dy);
+			pixman_region32_union(&clipped, &clipped, &shifted);
+			pixman_region32_intersect_rect(&clipped, &clipped, 0, 0, output->width, output->height);
+			pixman_region32_fini(&shifted);
+		}
+	}
+
 	if (!pixman_region32_empty(&clipped)) {
 		wlr_output_schedule_frame(scene_output->output);
 		wlr_damage_ring_add(&scene_output->damage_ring, &clipped);
@@ -2638,11 +2683,21 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 	struct wlr_scene_node *node = entry->node;
 	struct fx_gles_render_pass *fx_pass = fx_get_render_pass(data->render_pass);
 
+	// Desk nodes draw shifted by the sub-pixel offset (device px); every
+	// box and region derived from the node's layout position below gets
+	// the same shift, so the node stays self-consistent and only its
+	// placement against unshifted layers (bars, fullscreen) is off by it.
+	int desk_dx = 0, desk_dy = 0;
+	if (scene_node_has_desk_offset(data->output->scene, node)) {
+		scene_desk_offset_px(data->output->scene, data->scale, data->transform, &desk_dx, &desk_dy);
+	}
+
 	pixman_region32_t render_region;
 	pixman_region32_init(&render_region);
 	pixman_region32_copy(&render_region, &node->visible);
 	pixman_region32_translate(&render_region, -data->logical.x, -data->logical.y);
 	logical_to_buffer_coords(&render_region, data, true);
+	pixman_region32_translate(&render_region, desk_dx, desk_dy);
 	pixman_region32_intersect(&render_region, &render_region, &data->damage);
 	if (pixman_region32_empty(&render_region)) {
 		pixman_region32_fini(&render_region);
@@ -2669,11 +2724,14 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 	};
 	scene_node_get_size(node, &dst_box.width, &dst_box.height);
 	transform_output_box(&dst_box, data);
+	dst_box.x += desk_dx;
+	dst_box.y += desk_dy;
 
 	pixman_region32_t opaque;
 	pixman_region32_init(&opaque);
 	scene_node_opaque_region(node, x, y, &opaque);
 	logical_to_buffer_coords(&opaque, data, false);
+	pixman_region32_translate(&opaque, desk_dx, desk_dy);
 	pixman_region32_subtract(&opaque, &render_region, &opaque);
 
 	enum wl_output_transform node_transform =
@@ -2698,6 +2756,8 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		rect_clipped_region_box.y += y;
 
 		transform_output_box(&rect_clipped_region_box, data);
+		rect_clipped_region_box.x += desk_dx;
+		rect_clipped_region_box.y += desk_dy;
 		fx_corner_radii_transform(node_transform, &rect_clipped_corners);
 
 		struct fx_render_rect_options rect_options = {
@@ -2839,6 +2899,8 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		shadow_clipped_region_box.y += y;
 
 		transform_output_box(&shadow_clipped_region_box, data);
+		shadow_clipped_region_box.x += desk_dx;
+		shadow_clipped_region_box.y += desk_dy;
 		fx_corner_radii_transform(node_transform, &shadow_clipped_corners);
 
 		struct fx_render_box_shadow_options shadow_options = {
@@ -3618,6 +3680,16 @@ static enum scene_direct_scanout_result scene_entry_try_direct_scanout(
 		return SCANOUT_INELIGIBLE;
 	}
 
+	// A desk node drawn with a sub-pixel shift is not where its buffer
+	// would be scanned out.
+	{
+		int dx, dy;
+		scene_desk_offset_px(scene_output->scene, data->scale, data->transform, &dx, &dy);
+		if ((dx != 0 || dy != 0) && scene_node_has_desk_offset(scene_output->scene, node)) {
+			return SCANOUT_INELIGIBLE;
+		}
+	}
+
 	if (state->committed & (WLR_OUTPUT_STATE_MODE |
 			WLR_OUTPUT_STATE_ENABLED |
 			WLR_OUTPUT_STATE_RENDER_FORMAT)) {
diff --git a/src/server/output.rs b/src/server/output.rs
index c22d5d3..02f954d 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -669,7 +669,8 @@ impl Output {
             // Quantized to screen pixels: a sub-pixel pan moves no node
             // (see `update_viewport_local`), so it is not a reason to paint.
             let wm = &(*self.server).wm;
-            let cam = ((wm.desk_pan_x * wm.desk_zoom).round(), (wm.desk_pan_y * wm.desk_zoom).round(), wm.desk_zoom);
+            let q = wm.desk_zoom * self.current.scale as f64;
+            let cam = ((wm.desk_pan_x * q).round(), (wm.desk_pan_y * q).round(), wm.desk_zoom);
             if cam != (self.last_rendered_pan_x, self.last_rendered_pan_y, self.last_rendered_zoom) {
                 self.last_rendered_pan_x = cam.0;
                 self.last_rendered_pan_y = cam.1;
@@ -1284,6 +1285,7 @@ impl Output {
         // colour and keeps the cell lattice.
         if self.grid_backdrop_tree.is_null() {
             self.grid_backdrop_tree = ffi::wlr_scene_tree_create((*self.server).scene.layers.background);
+            ffi::river_scene_tree_set_desk_offset(self.grid_backdrop_tree, true);
             if self.grid_backdrop_tree.is_null() {
                 return;
             }
@@ -1358,6 +1360,10 @@ impl Output {
         ffi::wlr_scene_node_raise_to_top(self.grid_bevel_tree as *mut ffi::wlr_scene_node);
 
         let grid_tree = self.grid_tree;
+        if !grid_tree.is_null() {
+            // Desk content: rendered with the camera's sub-pixel offset.
+            ffi::river_scene_tree_set_desk_offset(grid_tree, true);
+        }
         let pool = &mut self.grid_rect_pool;
         let mut pool_idx = 0;
 
@@ -1440,7 +1446,7 @@ impl Output {
             crate::policy::api::BackgroundSpec::Grid(grid) => {
                 let frame = crate::policy::background::grid_frame(
                     grid,
-                    wm.camera(),
+                    wm.layout_camera().0,
                     viewport_w,
                     viewport_h,
                     self.sent.x,
diff --git a/src/server/scene.rs b/src/server/scene.rs
index 54b450c..83b8a37 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -121,6 +121,16 @@ impl Scene {
         self.layers.popups = ffi::wlr_scene_tree_create(normal_tree);
         self.layers.override_redirect = ffi::wlr_scene_tree_create(normal_tree);
 
+        // Desk content renders with the camera's sub-pixel offset
+        // (`WindowManager::layout_camera`): windows, their borders, their
+        // popups and X11 menus, and the grid (flagged where it is built).
+        // Layer shells, fullscreen and the lock screen stay put.
+        for tree in [self.layers.wm, self.layers.border_overlay, self.layers.popups, self.layers.override_redirect] {
+            if !tree.is_null() {
+                ffi::river_scene_tree_set_desk_offset(tree, true);
+            }
+        }
+
         if self.layers.border_overlay.is_null()
             || self.layers.background.is_null()
             || self.layers.background_clients.is_null()
diff --git a/src/server/window.rs b/src/server/window.rs
index fdc1116..44a70d2 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1285,10 +1285,11 @@ impl Window {
     /// and a one-pixel hop on grab and release.
     pub unsafe fn virtual_to_screen(&self, vx: f64, vy: f64) -> (i32, i32) {
         let wm = &(*self.server).wm;
+        let (cam, _, _) = wm.layout_camera();
         let (out_x, out_y, _, _) = self.first_enabled_output_box();
         (
-            out_x as i32 + ((vx - wm.desk_pan_x) * wm.desk_zoom).round() as i32,
-            out_y as i32 + ((vy - wm.desk_pan_y) * wm.desk_zoom).round() as i32,
+            out_x as i32 + ((vx - cam.pan_x) * cam.zoom).round() as i32,
+            out_y as i32 + ((vy - cam.pan_y) * cam.zoom).round() as i32,
         )
     }
 
@@ -1299,11 +1300,12 @@ impl Window {
     /// until the next transaction and is then recomputed away.
     pub unsafe fn screen_to_virtual(&self, sx: i32, sy: i32) -> (f64, f64) {
         let wm = &(*self.server).wm;
-        let zoom = wm.desk_zoom.max(0.01);
+        let (cam, _, _) = wm.layout_camera();
+        let zoom = cam.zoom.max(0.01);
         let (out_x, out_y, _, _) = self.first_enabled_output_box();
         (
-            wm.desk_pan_x + (sx as f64 - out_x) / zoom,
-            wm.desk_pan_y + (sy as f64 - out_y) / zoom,
+            cam.pan_x + (sx as f64 - out_x) / zoom,
+            cam.pan_y + (sy as f64 - out_y) / zoom,
         )
     }
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index fd6ca4d..715b1e4 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1637,6 +1637,44 @@ impl WindowManager {
     }
 
     /// The current camera as the policy crate's plain-data snapshot.
+    /// The camera as the LAYOUT sees it, plus the desk's sub-pixel render
+    /// offset. Scene nodes sit on integer layout px, so the pan handed to
+    /// placement is floored to a layout pixel (at the current zoom) and the
+    /// remainder — each in (-1, 0] layout px — goes to scenefx, which
+    /// shifts the desk trees by it in device px at render time. At output
+    /// scale 2 that is what lets a slow pan move one device pixel per
+    /// frame instead of two, the visible judder of HiDPI panning.
+    pub fn layout_camera(&self) -> (crate::policy::camera::Camera, f64, f64) {
+        let zoom = self.desk_zoom.max(1e-6);
+        let split = |pan: f64| {
+            let s = pan * zoom;
+            let f = s.floor();
+            (f / zoom, -(s - f))
+        };
+        let (pan_x, sub_x) = split(self.desk_pan_x);
+        let (pan_y, sub_y) = split(self.desk_pan_y);
+        (crate::policy::camera::Camera { pan_x, pan_y, zoom: self.desk_zoom }, sub_x, sub_y)
+    }
+
+    /// The largest enabled output scale: the device-pixel resolution a
+    /// camera move is quantized at (see `update_viewport_local`).
+    pub unsafe fn max_output_scale(&self) -> f64 {
+        let mut best = 1.0f64;
+        if self.server.is_null() {
+            return best;
+        }
+        let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr = (*outputs_list).next;
+        while curr != outputs_list {
+            let output = crate::container_of!(curr, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                best = best.max((*output).current.scale as f64);
+            }
+            curr = (*curr).next;
+        }
+        best
+    }
+
     pub fn camera(&self) -> crate::policy::camera::Camera {
         crate::policy::camera::Camera {
             pan_x: self.desk_pan_x,
@@ -3325,6 +3363,10 @@ impl WindowManager {
             win_ptrs.push(win_ptr);
         }
 
+        // Placement sees the pan floored to a layout pixel; the remainder
+        // shifts the desk trees at render time (`layout_camera`).
+        let (layout_cam, sub_x, sub_y) = self.layout_camera();
+        ffi::river_scene_set_desk_subpixel((*self.server).scene.wlr_scene, sub_x, sub_y);
         let params = crate::policy::arrange::ArrangeParams {
             bar_height: self.layout.bar_height,
             status_hide_mode: self.status_hide_mode,
@@ -3356,8 +3398,8 @@ impl WindowManager {
                 desktop_gap_width: self.layout.desktop_gap_width as f64,
                 desktop_cell_inset: self.layout.desktop_cell_fade_inset as f64,
             },
-            pan_x: self.desk_pan_x,
-            pan_y: self.desk_pan_y,
+            pan_x: layout_cam.pan_x,
+            pan_y: layout_cam.pan_y,
             zoom: self.desk_zoom,
         };
 
@@ -3518,12 +3560,14 @@ impl WindowManager {
 
     pub unsafe fn update_viewport_local(&mut self) {
         let zoom_changed = self.desk_zoom != self.last_viewport_zoom;
-        // A pan counts as motion only once it moves a screen pixel: nodes
-        // sit on integer logical px (`virtual_to_screen` rounds), so the
-        // sub-pixel tail of an eased pan changes nothing on screen, and
-        // repainting the whole output for it was pure cost.
+        // A pan counts as motion only once it moves a DEVICE pixel: the
+        // desk renders on integer layout px plus a device-px sub-pixel
+        // shift (`layout_camera`), so the tail of an eased pan below that
+        // changes nothing on screen, and repainting the whole output for
+        // it was pure cost.
         let zoom = self.desk_zoom;
-        let px = |pan: f64| (pan * zoom).round();
+        let scale = self.max_output_scale();
+        let px = |pan: f64| (pan * zoom * scale).round();
         let pan_changed = px(self.desk_pan_x) != px(self.last_viewport_pan_x)
             || px(self.desk_pan_y) != px(self.last_viewport_pan_y);
         let moved = zoom_changed || pan_changed;
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 10582fb..5f09ef3 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1155,3 +1155,37 @@ void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene) {
 void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen) {
 	scene->blur_frozen = frozen;
 }
+
+/* See wlr_scene.desk_trees: this subtree renders with the desk's
+ * sub-pixel offset. Idempotent; silently ignores a ninth tree. */
+struct wlr_scene *scene_node_get_root(struct wlr_scene_node *node);
+
+void river_scene_tree_set_desk_offset(struct wlr_scene_tree *tree, bool on) {
+	struct wlr_scene *scene = scene_node_get_root(&tree->node);
+	if (scene == NULL) {
+		return;
+	}
+	int free_slot = -1;
+	for (int i = 0; i < WLR_SCENE_DESK_TREES; i++) {
+		if (scene->desk_trees[i] == tree) {
+			if (!on) {
+				scene->desk_trees[i] = NULL;
+			}
+			return;
+		}
+		if (scene->desk_trees[i] == NULL && free_slot < 0) {
+			free_slot = i;
+		}
+	}
+	if (on && free_slot >= 0) {
+		scene->desk_trees[free_slot] = tree;
+	}
+}
+
+/* See wlr_scene.desk_sub_x/y: the camera pan's remainder below one
+ * layout pixel, each in (-1, 0]. Changing it moves every desk node on
+ * screen, which the caller damages (it is a camera move). */
+void river_scene_set_desk_subpixel(struct wlr_scene *scene, double sub_x, double sub_y) {
+	scene->desk_sub_x = sub_x;
+	scene->desk_sub_y = sub_y;
+}
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 5b55c05..dbeabdf 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -616,8 +616,9 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
                 // Rounded like every other virtual->screen placement (the
                 // arrange pass and the fallback lattice); truncation here put
                 // the latched patch a pixel off the lattice it replaces.
-                let sx = ox + ((patch.x - wm.desk_pan_x) * zoom).round() as i32;
-                let sy = oy + ((patch.y - wm.desk_pan_y) * zoom).round() as i32;
+                let (lcam, _, _) = wm.layout_camera();
+                let sx = ox + ((patch.x - lcam.pan_x) * zoom).round() as i32;
+                let sy = oy + ((patch.y - lcam.pan_y) * zoom).round() as i32;
                 (*window).rendering_requested.x = sx;
                 (*window).rendering_requested.y = sy;
                 (*window).scale = zoom / patch.scale;
diff --git a/wrapper.h b/wrapper.h
index 57d4a61..677d61a 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -272,6 +272,8 @@ void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, boo
 
 void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene);
 void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen);
+void river_scene_tree_set_desk_offset(struct wlr_scene_tree *tree, bool on);
+void river_scene_set_desk_subpixel(struct wlr_scene *scene, double sub_x, double sub_y);
 
 void river_scene_node_set_opacity(struct wlr_scene_node *node, float opacity);