Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/backend/wayland/backend/event_loop/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub(super) fn maybe_render(
if render_duration > Duration::from_millis(5) {
debug!("Render took {:?}", render_duration);
}

// Reset failure counter and record render time.
*consecutive_render_failures = 0;
*last_render_time = Some(Instant::now());
Expand Down
112 changes: 65 additions & 47 deletions src/backend/wayland/state/render/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ impl WaylandState {
phys_height: u32,
render_ui: bool,
now: Instant,
damage: &[crate::util::Rect],
damage_world: &[crate::util::Rect],
) -> Result<()> {
let zoom_transform_active = self.zoom.active;
let eraser_ctx = self.render_canvas_background(ctx, scale, phys_width, phys_height)?;
Expand All @@ -40,81 +40,99 @@ impl WaylandState {
);
let shapes = &self.input_state.canvas_set.active_frame().shapes;
let replay_ctx = eraser_ctx.replay_context();

// Manual Culling: Only render shapes that intersect with the damage regions.
// Cairo's internal clipping is efficient for rasterization, but sending
// thousands of shapes to Cairo still incurs overhead for geometry processing.
// A simple bounding box check here eliminates that overhead.


let render_drawn_shape = |drawn_shape: &crate::draw::DrawnShape| match &drawn_shape.shape {
crate::draw::Shape::EraserStroke { points, brush } => {
crate::draw::render_eraser_stroke(ctx, points, brush, &replay_ctx);
}
other => {
crate::draw::render_shape(ctx, other);
}
};

// Compute bounding box of all damage regions for fast rejection
// (Union of all dirty rects)
let damage_bounds = damage.iter().fold(None, |acc: Option<crate::util::Rect>, r| {
match acc {
// (Union of all dirty rects). These bounds are in world coordinates.
let damage_bounds = damage_world
.iter()
.fold(None, |acc: Option<crate::util::Rect>, r| match acc {
None => Some(*r),
Some(u) => {
// Manual union since Rect doesn't have it exposed directly in this scope maybe?
// Let's implement min/max logic
// Manual union to avoid extra allocations.
let min_x = u.x.min(r.x);
let min_y = u.y.min(r.y);
let max_x = (u.x + u.width).max(r.x + r.width);
let max_y = (u.y + u.height).max(r.y + r.height);
let max_x = u.x.saturating_add(u.width).max(r.x.saturating_add(r.width));
let max_y =
u.y.saturating_add(u.height)
.max(r.y.saturating_add(r.height));
Some(crate::util::Rect {
x: min_x,
y: min_y,
width: max_x - min_x,
height: max_y - min_y,
})
}
}
});
});

if let Some(bounds) = damage_bounds {
// Expand bounds slightly to account for line width/glow that might extend outside
// the logical shape bounds (though Shape::bounding_box should theoretically cover it,
// safety margin is good).
// Clamp to logical surface bounds to avoid negative coords or overflow.
let margin = 2;
let logical_width = width as i32;
let logical_height = height as i32;
let safe_x = (bounds.x - margin).max(0);
let safe_y = (bounds.y - margin).max(0);
let safe_bounds = crate::util::Rect {
x: safe_x,
y: safe_y,
width: (bounds.width + 2 * margin).min(logical_width - safe_x),
height: (bounds.height + 2 * margin).min(logical_height - safe_y),
let safe_x = bounds.x.saturating_sub(margin);
let safe_y = bounds.y.saturating_sub(margin);
let safe_width = bounds.width.saturating_add(margin * 2);
let safe_height = bounds.height.saturating_add(margin * 2);
let safe_bounds = if zoom_transform_active {
crate::util::Rect::new(safe_x, safe_y, safe_width, safe_height)
} else {
// Clamp to logical surface bounds to avoid negative coords or overflow.
let logical_width = width as i32;
let logical_height = height as i32;
let clamped_x = safe_x.max(0);
let clamped_y = safe_y.max(0);
let max_width = logical_width.saturating_sub(clamped_x);
let max_height = logical_height.saturating_sub(clamped_y);
crate::util::Rect::new(
clamped_x,
clamped_y,
safe_width.min(max_width),
safe_height.min(max_height),
)
};

for drawn_shape in shapes {
// If shape has no bounding box (e.g. empty freehand), skip it.
// If it has one, check intersection.
if let Some(bbox) = drawn_shape.shape.bounding_box() {
// Check intersection:
// !(bbox.left > safe.right || bbox.right < safe.left || ...)
let bbox_right = bbox.x + bbox.width;
let bbox_bottom = bbox.y + bbox.height;
let safe_right = safe_bounds.x + safe_bounds.width;
let safe_bottom = safe_bounds.y + safe_bounds.height;

let intersects = !(bbox.x >= safe_right || bbox_right <= safe_bounds.x ||
bbox.y >= safe_bottom || bbox_bottom <= safe_bounds.y);

if intersects {
// We must handle eraser strokes specially here, just like render_shapes does,
// because render_shape() ignores them.
match &drawn_shape.shape {
crate::draw::Shape::EraserStroke { points, brush } => {
crate::draw::render_eraser_stroke(ctx, points, brush, &replay_ctx);
}
other => {
crate::draw::render_shape(ctx, other);
}
if let Some(safe_bounds) = safe_bounds {
for drawn_shape in shapes {
// If shape has no bounding box (e.g. empty freehand), skip it.
// If it has one, check intersection.
if let Some(bbox) = drawn_shape.shape.bounding_box() {
// Check intersection:
// !(bbox.left > safe.right || bbox.right < safe.left || ...)
let bbox_right = bbox.x.saturating_add(bbox.width);
let bbox_bottom = bbox.y.saturating_add(bbox.height);
let safe_right = safe_bounds.x.saturating_add(safe_bounds.width);
let safe_bottom = safe_bounds.y.saturating_add(safe_bounds.height);

let intersects = !(bbox.x >= safe_right
|| bbox_right <= safe_bounds.x
|| bbox.y >= safe_bottom
|| bbox_bottom <= safe_bounds.y);

if intersects {
render_drawn_shape(drawn_shape);
}
}
}
}
} else {
// If damage is empty, nothing to draw.
// If we don't have damage bounds, render everything to stay correct.
for drawn_shape in shapes {
render_drawn_shape(drawn_shape);
}
}

self.render_selection_overlays(ctx);
Expand Down
51 changes: 41 additions & 10 deletions src/backend/wayland/state/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,19 @@ impl WaylandState {
// We do this BEFORE acquiring the buffer/damage so the current frame's changes
// are included in the damage for the current buffer.
let input_damage = self.input_state.take_dirty_regions();
self.buffer_damage.add_regions(input_damage);
let logical_width = width.min(i32::MAX as u32) as i32;
let logical_height = height.min(i32::MAX as u32) as i32;
let force_full_damage = self.zoom.active
|| ui_toast_active
|| preset_feedback_active
|| blocked_feedback_active;
if force_full_damage {
// Zoom uses a world transform and some UI effects don't emit damage; full damage avoids
// mismatched coordinate spaces and empty damage frames.
self.buffer_damage.mark_all_full();
} else {
self.buffer_damage.add_regions(input_damage);
}

// Get a buffer from the pool for rendering
let (buffer, canvas_ptr, pool_gen, pool_size) = {
Expand Down Expand Up @@ -80,13 +92,32 @@ impl WaylandState {
// Pool identity (generation + size) is passed to detect pool recreation/growth.
// SlotPool reuses the same memory regions for released buffers, so the
// canvas pointer serves as a stable slot identifier across buffer reuse.
let logical_damage = self.buffer_damage.take_buffer_damage(
let mut logical_damage = self.buffer_damage.take_buffer_damage(
canvas_ptr,
self.surface.width().min(i32::MAX as u32) as i32,
self.surface.height().min(i32::MAX as u32) as i32,
logical_width,
logical_height,
pool_gen,
pool_size,
);
if logical_damage.is_empty()
&& let Some(full) = crate::util::Rect::new(0, 0, logical_width, logical_height)
{
logical_damage = vec![full];
self.buffer_damage.mark_all_full();
}
let damage_screen = logical_damage;
let damage_world = if self.zoom.active {
let scale = self.zoom.scale.max(f64::MIN_POSITIVE);
let view_width = ((width as f64) / scale).ceil() as i32;
let view_height = ((height as f64) / scale).ceil() as i32;
let view_x = self.zoom.view_offset.0.floor() as i32;
let view_y = self.zoom.view_offset.1.floor() as i32;
crate::util::Rect::new(view_x, view_y, view_width, view_height)
.map(|rect| vec![rect])
.unwrap_or_default()
} else {
damage_screen.clone()
};

// SAFETY: This unsafe block creates a Cairo surface from raw memory buffer.
// Safety invariants that must be maintained:
Expand Down Expand Up @@ -117,8 +148,8 @@ impl WaylandState {
// avoiding redraws of static content (which is preserved in the back-buffer).
// Note: Cairo works in logical coordinates if we scale it, but here we are
// pre-scale (identity transform). We must scale the logical damage rects to pixels.
if !logical_damage.is_empty() {
for rect in &logical_damage {
if !damage_screen.is_empty() {
for rect in &damage_screen {
// Scale logical rect to physical pixels
let x = rect.x as f64 * scale as f64;
let y = rect.y as f64 * scale as f64;
Expand All @@ -145,7 +176,7 @@ impl WaylandState {
phys_height,
render_ui,
now,
&logical_damage,
&damage_world,
)?;
}

Expand All @@ -170,10 +201,10 @@ impl WaylandState {
wl_surface.set_buffer_scale(scale);
wl_surface.attach(Some(buffer.wl_buffer()), 0, 0);

// Damage logic moved to top of function (add_regions and take_buffer_damage)
// We now just use the computed logical_damage.
// Damage logic moved to top of function (add_regions and take_buffer_damage).
// We now use the computed screen-space damage for clipping and compositor hints.

let scaled_damage = scale_damage_regions(logical_damage.clone(), scale);
let scaled_damage = scale_damage_regions(damage_screen.clone(), scale);

if debug_damage_logging_enabled() {
debug!(
Expand Down
10 changes: 5 additions & 5 deletions src/draw/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ pub use color::Color;
pub use dirty::DirtyTracker;
pub use font::FontDescriptor;
pub use frame::{DrawnShape, Frame, ShapeId};
pub use render::{
EraserReplayContext, render_board_background, render_click_highlight,
render_freehand_borrowed, render_marker_stroke_borrowed, render_selection_halo, render_shape,
render_sticky_note, render_text,
};
#[allow(unused_imports)]
pub(crate) use render::render_eraser_stroke;
pub use render::{
EraserReplayContext, render_board_background, render_click_highlight, render_freehand_borrowed,
render_marker_stroke_borrowed, render_selection_halo, render_shape, render_sticky_note,
render_text,
};
#[allow(unused_imports)]
pub use shape::{ArrowLabel, EraserBrush, EraserKind, Shape, invalidate_text_cache};

Expand Down
2 changes: 1 addition & 1 deletion src/draw/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub use background::{fill_transparent, render_board_background};
pub use highlight::render_click_highlight;
pub use selection::render_selection_halo;
pub use shapes::render_shape;
pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed};
pub(crate) use strokes::render_eraser_stroke;
pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed};
pub use text::{render_sticky_note, render_text};
pub use types::EraserReplayContext;
1 change: 1 addition & 0 deletions src/draw/render/strokes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub fn render_freehand_borrowed(
let _ = ctx.stroke();
}

#[allow(dead_code)] // Used by the Wayland backend; the lib crate doesn't compile backend modules.
pub(crate) fn render_eraser_stroke(
ctx: &cairo::Context,
points: &[(i32, i32)],
Expand Down