Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Be smarter when rounding rectangles to the pixel grid #5656

Merged
merged 5 commits into from
Jan 30, 2025
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
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