Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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);