From 50294b5d9f51d4c599c243669c464f826ac51728 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 30 Jan 2025 21:04:36 +0100 Subject: [PATCH] Be smarter when rounding rectangles to the pixel grid (#5656) --- .../tests/snapshots/demos/Scene.png | 4 +- crates/epaint/src/shapes/rect_shape.rs | 6 +- crates/epaint/src/tessellator.rs | 138 +++++++++++------- 3 files changed, 90 insertions(+), 58 deletions(-) diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png index ae1d6acf728..78f69749035 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93f2bf32b0607b56a9eebefdc07b55d66988251eeb0ccf7799c2836281d5d5fb -size 35573 +oid sha256:9fee66bab841602862c301a7df3add365ea93fde0ac9b71ca7f9b74a709a68d2 +size 35576 diff --git a/crates/epaint/src/shapes/rect_shape.rs b/crates/epaint/src/shapes/rect_shape.rs index ca7ab01f161..f691234b143 100644 --- a/crates/epaint/src/shapes/rect_shape.rs +++ b/crates/epaint/src/shapes/rect_shape.rs @@ -21,13 +21,13 @@ pub struct RectShape { pub stroke: Stroke, /// Is the stroke on the inside, outside, or centered on the rectangle? + /// + /// If you want to perfectly tile rectangles, use [`StrokeKind::Inside`]. pub stroke_kind: StrokeKind, /// Snap the rectangle to pixels? /// /// Rounding produces sharper rectangles. - /// It is the outside of the fill (=inside of the stroke) - /// that will be rounded to the physical pixel grid. /// /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. pub round_to_pixels: Option, @@ -117,8 +117,6 @@ impl RectShape { /// Snap the rectangle to pixels? /// /// Rounding produces sharper rectangles. - /// It is the outside of the fill (=inside of the stroke) - /// that will be rounded to the physical pixel grid. /// /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. #[inline] diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index f05d9b47a75..45919759a00 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -8,14 +8,12 @@ use emath::{pos2, remap, vec2, GuiRounding as _, NumExt, Pos2, Rect, Rot2, Vec2}; use crate::{ - color, emath, stroke, texture_atlas::PreparedDisc, CircleShape, ClippedPrimitive, ClippedShape, - Color32, CubicBezierShape, EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, - RectShape, Rounding, Shape, Stroke, StrokeKind, TextShape, TextureId, Vertex, WHITE_UV, + color::ColorMode, emath, stroke::PathStroke, texture_atlas::PreparedDisc, CircleShape, + ClippedPrimitive, ClippedShape, Color32, CubicBezierShape, EllipseShape, Mesh, PathShape, + Primitive, QuadraticBezierShape, RectShape, Rounding, Shape, Stroke, StrokeKind, TextShape, + TextureId, Vertex, WHITE_UV, }; -use self::color::ColorMode; -use self::stroke::PathStroke; - // ---------------------------------------------------------------------------- #[allow(clippy::approx_constant)] @@ -920,13 +918,13 @@ fn fill_closed_path_with_uv( #[inline(always)] fn translate_stroke_point(p: &mut PathPoint, stroke: &PathStroke) { match stroke.kind { - stroke::StrokeKind::Middle => { /* Nothing to do */ } - stroke::StrokeKind::Outside => { - p.pos += p.normal * stroke.width * 0.5; - } - stroke::StrokeKind::Inside => { + StrokeKind::Inside => { p.pos -= p.normal * stroke.width * 0.5; } + StrokeKind::Middle => { /* Nothing to do */ } + StrokeKind::Outside => { + p.pos += p.normal * stroke.width * 0.5; + } } } @@ -947,7 +945,7 @@ fn stroke_path( let idx = out.vertices.len() as u32; // Translate the points along their normals if the stroke is outside or inside - if stroke.kind != stroke::StrokeKind::Middle { + if stroke.kind != StrokeKind::Middle { path.iter_mut() .for_each(|p| translate_stroke_point(p, stroke)); } @@ -1672,12 +1670,18 @@ impl Tessellator { /// * `rect`: the rectangle to tessellate. /// * `out`: triangles are appended to this. pub fn tessellate_rect(&mut self, rect_shape: &RectShape, out: &mut Mesh) { + if self.options.coarse_tessellation_culling + && !rect_shape.visual_bounding_rect().intersects(self.clip_rect) + { + return; + } + let brush = rect_shape.brush.as_ref(); let RectShape { mut rect, mut rounding, fill, - stroke, + mut stroke, stroke_kind, round_to_pixels, mut blur_width, @@ -1686,9 +1690,56 @@ impl Tessellator { let round_to_pixels = round_to_pixels.unwrap_or(self.options.round_rects_to_pixels); - // Modify `rect` so that it represents the filled region, with the stroke on the outside: + // Important: round to pixels BEFORE applying stroke_kind + if round_to_pixels { + // The rounding is aware of the stroke kind. + // It is designed to be clever in trying to divine the intentions of the user. + match stroke_kind { + StrokeKind::Inside => { + // The stroke is inside the rect, so the rect defines the _outside_ of the stroke. + // We round the outside of the stroke on a pixel boundary. + // This will make the outside of the stroke crisp. + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + // + // This is so that a user can tile rectangles with `StrokeKind::Inside`, + // and get no pixel overlap between them. + rect = rect.round_to_pixels(self.pixels_per_point); + } + StrokeKind::Middle => { + // On this path we optimize for crisp and symmetric strokes. + // We put odd-width strokes in the center of pixels. + // To understand why, see `fn round_line_segment`. + if stroke.width <= self.feathering + || is_nearest_integer_odd(self.pixels_per_point * stroke.width) + { + rect = rect.round_to_pixel_center(self.pixels_per_point); + } else { + rect = rect.round_to_pixels(self.pixels_per_point); + } + } + StrokeKind::Outside => { + // Put the inside of the stroke on a pixel boundary. + // Makes the inside of the stroke and the filled rect crisp, + // but the outside of the stroke may become feathered (blurry). + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + rect = rect.round_to_pixels(self.pixels_per_point); + } + } + } + + // Modify `rect` so that it represents the filled region, with the stroke on the outside. + // Important: do this AFTER rounding to pixels match stroke_kind { StrokeKind::Inside => { + // Shrink the stroke so it fits inside the rect: + stroke.width = stroke.width.at_most(rect.size().min_elem() / 2.0); + rect = rect.shrink(stroke.width); } StrokeKind::Middle => { @@ -1699,28 +1750,6 @@ impl Tessellator { } } - if self.options.coarse_tessellation_culling - && !rect.expand(stroke.width).intersects(self.clip_rect) - { - return; - } - - if round_to_pixels { - // Since the stroke extends outside of the rectangle, - // we can round the rectangle sides to the physical pixel edges, - // and the filled rect will appear crisp, as will the inside of the stroke. - let Stroke { width, .. } = stroke; // Make sure we remember to update this if we change `stroke` to `PathStroke` - if width <= self.feathering && !stroke.is_empty() { - // If the stroke is thin, make sure its center is in the center of the pixel: - rect = rect - .expand(width / 2.0) - .round_to_pixel_center(self.pixels_per_point) - .shrink(width / 2.0); - } else { - rect = rect.round_to_pixels(self.pixels_per_point); - } - } - // It is common to (sometimes accidentally) create an infinitely sized rectangle. // Make sure we can handle that: rect.min = rect.min.at_least(pos2(-1e7, -1e7)); @@ -1751,6 +1780,7 @@ impl Tessellator { if rect.width() < 0.5 * self.feathering { // Very thin - approximate by a vertical line-segment: + // There is room for improvement here, but it is not critical. let line = [rect.center_top(), rect.center_bottom()]; if 0.0 < rect.width() && fill != Color32::TRANSPARENT { self.tessellate_line_segment(line, Stroke::new(rect.width(), fill), out); @@ -1761,6 +1791,7 @@ impl Tessellator { } } else if rect.height() < 0.5 * self.feathering { // Very thin - approximate by a horizontal line-segment: + // There is room for improvement here, but it is not critical. let line = [rect.left_center(), rect.right_center()]; if 0.0 < rect.height() && fill != Color32::TRANSPARENT { self.tessellate_line_segment(line, Stroke::new(rect.height(), fill), out); @@ -1776,22 +1807,25 @@ impl Tessellator { path.add_line_loop(&self.scratchpad_points); let path_stroke = PathStroke::from(stroke).outside(); - if let Some(brush) = brush { - let crate::Brush { - fill_texture_id, - uv, - } = **brush; - // Textured - let uv_from_pos = |p: Pos2| { - pos2( - remap(p.x, rect.x_range(), uv.x_range()), - remap(p.y, rect.y_range(), uv.y_range()), - ) - }; - path.fill_with_uv(self.feathering, fill, fill_texture_id, uv_from_pos, out); - } else { - // Untextured - path.fill(self.feathering, fill, &path_stroke, out); + if rect.is_positive() { + // Fill + if let Some(brush) = brush { + // Textured + let crate::Brush { + fill_texture_id, + uv, + } = **brush; + let uv_from_pos = |p: Pos2| { + pos2( + remap(p.x, rect.x_range(), uv.x_range()), + remap(p.y, rect.y_range(), uv.y_range()), + ) + }; + path.fill_with_uv(self.feathering, fill, fill_texture_id, uv_from_pos, out); + } else { + // Untextured + path.fill(self.feathering, fill, &path_stroke, out); + } } path.stroke_closed(self.feathering, &path_stroke, out);