Skip to content

Commit

Permalink
Be smarter when rounding rectangles to the pixel grid (#5656)
Browse files Browse the repository at this point in the history
  • Loading branch information
emilk authored Jan 30, 2025
1 parent 4b9da5f commit 50294b5
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 58 deletions.
4 changes: 2 additions & 2 deletions crates/egui_demo_lib/tests/snapshots/demos/Scene.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 2 additions & 4 deletions crates/epaint/src/shapes/rect_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
Expand Down Expand Up @@ -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]
Expand Down
138 changes: 86 additions & 52 deletions crates/epaint/src/tessellator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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));
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 => {
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down

0 comments on commit 50294b5

Please sign in to comment.