Skip to content

Commit 002ae25

Browse files
juancampahacknus
authored andcommitted
Fix blurry lines (emilk#4943)
<!-- Please read the "Making a PR" section of [`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md) before opening a Pull Request! * Keep your PR:s small and focused. * The PR title is what ends up in the changelog, so make it descriptive! * If applicable, add a screenshot or gif. * If it is a non-trivial addition, consider adding a demo for it to `egui_demo_lib`, or a new example. * Do NOT open PR:s from your `master` branch, as that makes it hard for maintainers to test and add commits to your PR. * Remember to run `cargo fmt` and `cargo clippy`. * Open the PR as a draft until you have self-reviewed it and run `./scripts/check.sh`. * When you have addressed a PR comment, mark it as resolved. Please be patient! I will review your PR, but my time is limited! --> * Closes <emilk#4776> * [x] I have followed the instructions in the PR template I've been meaning to look into this for a while but finally bit the bullet this week. Contrary to what I initially thought, the problem of blurry lines is unrelated to feathering because it also happens with feathering disabled. The root cause is that lines tend to land on pixel boundaries, and because of that, frequently used strokes (e.g. 1pt), end up partially covering pixels. This is especially noticeable on 1ppp displays. There were a couple of things to fix, namely: individual lines like separators and indents but also shape strokes (e.g. Frame). Lines were easy, I just made sure we round them to the nearest pixel _center_, instead of the nearest pixel boundary. Strokes were a little more complicated. To illustrate why, here’s an example: if we're rendering a 5x5 rect (black fill, red stroke), we would expect to see something like this: ![Screenshot 2024-08-11 at 15 01 41](https://github.com/user-attachments/assets/5a5d4434-0814-451b-8179-2864dc73c6a6) The fill and the stroke to cover entire pixels. Instead, egui was painting the stroke partially inside and partially outside, centered around the shape’s path (blue line): ![Screenshot 2024-08-11 at 15 00 57](https://github.com/user-attachments/assets/4284dc91-5b6e-4422-994a-17d527a6f13b) Both methods are valid for different use-cases but the first one is what we’d typically want for UIs to feel crisp and pixel perfect. It's also how CSS borders work (related to emilk#4019 and emilk#3284). Luckily, we can use the normal computed for each `PathPoint` to adjust the location of the stroke to be outside, inside, or in the middle. These also are the 3 types of strokes available in tools like Photoshop. This PR introduces an enum `StrokeKind` which determines if a `PathStroke` should be tessellated outside, inside, or _on_ the path itself. Where "outside" is defined by the directions normals point to. Tessellator will now use `StrokeKind::Outside` for closed shapes like rect, ellipse, etc. And `StrokeKind::Middle` for the rest since there's no meaningful "outside" concept for open paths. This PR doesn't expose `StrokeKind` to user-land, but we can implement that later so that users can render shapes and decide where to place the stroke. ### Strokes test (blue lines represent the size of the rect being rendered) `Stroke::Middle` (current behavior, 1px and 3px are blurry) ![Screenshot 2024-08-09 at 23 55 48](https://github.com/user-attachments/assets/dabeaa9e-2010-4eb6-bd7e-b9cb3660542e) `Stroke::Outside` (proposed default behavior for closed paths) ![Screenshot 2024-08-09 at 23 51 55](https://github.com/user-attachments/assets/509c261f-0ae1-46a0-b9b8-08de31c3bd85) `Stroke::Inside` (for completeness but unused at the moment) ![Screenshot 2024-08-09 at 23 54 49](https://github.com/user-attachments/assets/c011b1c1-60ab-4577-baa9-14c36267438a) ### Demo App The best way to review this PR is to run the demo on a 1ppp display, especially to test hover effects. Everything should look crisper. Also run it in a higher dpi screen to test that nothing broke 🙏. Before: ![egui_old](https://github.com/user-attachments/assets/cd6e9032-d44f-4cb0-bb41-f9eb4c3ae810) After (notice the sharper lines): ![egui_new](https://github.com/user-attachments/assets/3365fc96-6eb2-4e7d-a2f5-b4712625a702)
1 parent 70fa32d commit 002ae25

File tree

12 files changed

+278
-89
lines changed

12 files changed

+278
-89
lines changed

crates/egui/src/containers/panel.rs

+20-8
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,9 @@ impl SidePanel {
329329
ui.ctx().set_cursor_icon(cursor_icon);
330330
}
331331

332+
// Keep this rect snapped so that panel content can be pixel-perfect
333+
let rect = ui.painter().round_rect_to_pixels(rect);
334+
332335
PanelState { rect }.store(ui.ctx(), id);
333336

334337
{
@@ -343,10 +346,14 @@ impl SidePanel {
343346
Stroke::NONE
344347
};
345348
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
346-
// In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel
347-
// (hence the shrink).
348-
let resize_x = side.opposite().side_x(rect.shrink(1.0));
349-
let resize_x = ui.painter().round_to_pixel(resize_x);
349+
let resize_x = side.opposite().side_x(rect);
350+
351+
// This makes it pixel-perfect for odd-sized strokes (width=1.0, width=3.0, etc)
352+
let resize_x = ui.painter().round_to_pixel_center(resize_x);
353+
354+
// We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for
355+
// left-side panels
356+
let resize_x = resize_x - if side == Side::Left { 1.0 } else { 0.0 };
350357
ui.painter().vline(resize_x, panel_rect.y_range(), stroke);
351358
}
352359

@@ -817,6 +824,9 @@ impl TopBottomPanel {
817824
ui.ctx().set_cursor_icon(cursor_icon);
818825
}
819826

827+
// Keep this rect snapped so that panel content can be pixel-perfect
828+
let rect = ui.painter().round_rect_to_pixels(rect);
829+
820830
PanelState { rect }.store(ui.ctx(), id);
821831

822832
{
@@ -831,10 +841,12 @@ impl TopBottomPanel {
831841
Stroke::NONE
832842
};
833843
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
834-
// In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel
835-
// (hence the shrink).
836-
let resize_y = side.opposite().side_y(rect.shrink(1.0));
837-
let resize_y = ui.painter().round_to_pixel(resize_y);
844+
let resize_y = side.opposite().side_y(rect);
845+
let resize_y = ui.painter().round_to_pixel_center(resize_y);
846+
847+
// We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for
848+
// top-side panels
849+
let resize_y = resize_y - if side == TopBottomSide::Top { 1.0 } else { 0.0 };
838850
ui.painter().hline(panel_rect.x_range(), resize_y, stroke);
839851
}
840852

crates/egui/src/containers/window.rs

+8-18
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,6 @@ impl<'open> Window<'open> {
439439
let mut window_frame = frame.unwrap_or_else(|| Frame::window(&ctx.style()));
440440
// Keep the original inner margin for later use
441441
let window_margin = window_frame.inner_margin;
442-
let border_padding = window_frame.stroke.width / 2.0;
443-
// Add border padding to the inner margin to prevent it from covering the contents
444-
window_frame.inner_margin += border_padding;
445442

446443
let is_explicitly_closed = matches!(open, Some(false));
447444
let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible());
@@ -575,9 +572,9 @@ impl<'open> Window<'open> {
575572

576573
if let Some(title_bar) = title_bar {
577574
let mut title_rect = Rect::from_min_size(
578-
outer_rect.min + vec2(border_padding, border_padding),
575+
outer_rect.min,
579576
Vec2 {
580-
x: outer_rect.size().x - border_padding * 2.0,
577+
x: outer_rect.size().x,
581578
y: title_bar_height,
582579
},
583580
);
@@ -587,9 +584,6 @@ impl<'open> Window<'open> {
587584
if on_top && area_content_ui.visuals().window_highlight_topmost {
588585
let mut round = window_frame.rounding;
589586

590-
// Eliminate the rounding gap between the title bar and the window frame
591-
round -= border_padding;
592-
593587
if !is_collapsed {
594588
round.se = 0.0;
595589
round.sw = 0.0;
@@ -603,7 +597,7 @@ impl<'open> Window<'open> {
603597

604598
// Fix title bar separator line position
605599
if let Some(response) = &mut content_response {
606-
response.rect.min.y = outer_rect.min.y + title_bar_height + border_padding;
600+
response.rect.min.y = outer_rect.min.y + title_bar_height;
607601
}
608602

609603
title_bar.ui(
@@ -667,14 +661,10 @@ fn paint_resize_corner(
667661
}
668662
};
669663

670-
// Adjust the corner offset to accommodate the stroke width and window rounding
671-
let offset = if radius <= 2.0 && stroke.width < 2.0 {
672-
2.0
673-
} else {
674-
// The corner offset is calculated to make the corner appear to be in the correct position
675-
(2.0_f32.sqrt() * (1.0 + radius + stroke.width / 2.0) - radius)
676-
* 45.0_f32.to_radians().cos()
677-
};
664+
// Adjust the corner offset to accommodate for window rounding
665+
let offset =
666+
((2.0_f32.sqrt() * (1.0 + radius) - radius) * 45.0_f32.to_radians().cos()).max(2.0);
667+
678668
let corner_size = Vec2::splat(ui.visuals().resize_corner_size);
679669
let corner_rect = corner.align_size_within_rect(corner_size, outer_rect);
680670
let corner_rect = corner_rect.translate(-offset * corner.to_sign()); // move away from corner
@@ -1136,7 +1126,6 @@ impl TitleBar {
11361126
let text_pos =
11371127
emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top();
11381128
let text_pos = text_pos - self.title_galley.rect.min.to_vec2();
1139-
let text_pos = text_pos - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better)
11401129
ui.painter().galley(
11411130
text_pos,
11421131
self.title_galley.clone(),
@@ -1150,6 +1139,7 @@ impl TitleBar {
11501139
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
11511140
// Workaround: To prevent border infringement,
11521141
// the 0.1 value should ideally be calculated using TessellationOptions::feathering_size_in_pixels
1142+
// or we could support selectively disabling feathering on line caps
11531143
let x_range = outer_rect.x_range().shrink(0.1);
11541144
ui.painter().hline(x_range, y, stroke);
11551145
}

crates/egui/src/context.rs

+20-4
Original file line numberDiff line numberDiff line change
@@ -1717,26 +1717,42 @@ impl Context {
17171717
});
17181718
}
17191719

1720-
/// Useful for pixel-perfect rendering
1720+
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
1721+
#[inline]
1722+
pub(crate) fn round_to_pixel_center(&self, point: f32) -> f32 {
1723+
let pixels_per_point = self.pixels_per_point();
1724+
((point * pixels_per_point - 0.5).round() + 0.5) / pixels_per_point
1725+
}
1726+
1727+
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
1728+
#[inline]
1729+
pub(crate) fn round_pos_to_pixel_center(&self, point: Pos2) -> Pos2 {
1730+
pos2(
1731+
self.round_to_pixel_center(point.x),
1732+
self.round_to_pixel_center(point.y),
1733+
)
1734+
}
1735+
1736+
/// Useful for pixel-perfect rendering of filled shapes
17211737
#[inline]
17221738
pub(crate) fn round_to_pixel(&self, point: f32) -> f32 {
17231739
let pixels_per_point = self.pixels_per_point();
17241740
(point * pixels_per_point).round() / pixels_per_point
17251741
}
17261742

1727-
/// Useful for pixel-perfect rendering
1743+
/// Useful for pixel-perfect rendering of filled shapes
17281744
#[inline]
17291745
pub(crate) fn round_pos_to_pixels(&self, pos: Pos2) -> Pos2 {
17301746
pos2(self.round_to_pixel(pos.x), self.round_to_pixel(pos.y))
17311747
}
17321748

1733-
/// Useful for pixel-perfect rendering
1749+
/// Useful for pixel-perfect rendering of filled shapes
17341750
#[inline]
17351751
pub(crate) fn round_vec_to_pixels(&self, vec: Vec2) -> Vec2 {
17361752
vec2(self.round_to_pixel(vec.x), self.round_to_pixel(vec.y))
17371753
}
17381754

1739-
/// Useful for pixel-perfect rendering
1755+
/// Useful for pixel-perfect rendering of filled shapes
17401756
#[inline]
17411757
pub(crate) fn round_rect_to_pixels(&self, rect: Rect) -> Rect {
17421758
Rect {

crates/egui/src/painter.rs

+13-1
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,19 @@ impl Painter {
158158
self.clip_rect = clip_rect;
159159
}
160160

161-
/// Useful for pixel-perfect rendering.
161+
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
162+
#[inline]
163+
pub fn round_to_pixel_center(&self, point: f32) -> f32 {
164+
self.ctx().round_to_pixel_center(point)
165+
}
166+
167+
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
168+
#[inline]
169+
pub fn round_pos_to_pixel_center(&self, pos: Pos2) -> Pos2 {
170+
self.ctx().round_pos_to_pixel_center(pos)
171+
}
172+
173+
/// Useful for pixel-perfect rendering of filled shapes.
162174
#[inline]
163175
pub fn round_to_pixel(&self, point: f32) -> f32 {
164176
self.ctx().round_to_pixel(point)

crates/egui/src/style.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -2477,8 +2477,12 @@ impl Widget for &mut Stroke {
24772477

24782478
// stroke preview:
24792479
let (_id, stroke_rect) = ui.allocate_space(ui.spacing().interact_size);
2480-
let left = stroke_rect.left_center();
2481-
let right = stroke_rect.right_center();
2480+
let left = ui
2481+
.painter()
2482+
.round_pos_to_pixel_center(stroke_rect.left_center());
2483+
let right = ui
2484+
.painter()
2485+
.round_pos_to_pixel_center(stroke_rect.right_center());
24822486
ui.painter().line_segment([left, right], (*width, *color));
24832487
})
24842488
.response

crates/egui/src/ui.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -2215,9 +2215,9 @@ impl Ui {
22152215

22162216
let stroke = self.visuals().widgets.noninteractive.bg_stroke;
22172217
let left_top = child_rect.min - 0.5 * indent * Vec2::X;
2218-
let left_top = self.painter().round_pos_to_pixels(left_top);
2218+
let left_top = self.painter().round_pos_to_pixel_center(left_top);
22192219
let left_bottom = pos2(left_top.x, child_ui.min_rect().bottom() - 2.0);
2220-
let left_bottom = self.painter().round_pos_to_pixels(left_bottom);
2220+
let left_bottom = self.painter().round_pos_to_pixel_center(left_bottom);
22212221

22222222
if left_vline {
22232223
// draw a faint line on the left to mark the indented section

crates/egui/src/widgets/separator.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ impl Widget for Separator {
116116
if is_horizontal_line {
117117
painter.hline(
118118
(rect.left() - grow)..=(rect.right() + grow),
119-
painter.round_to_pixel(rect.center().y),
119+
painter.round_to_pixel_center(rect.center().y),
120120
stroke,
121121
);
122122
} else {
123123
painter.vline(
124-
painter.round_to_pixel(rect.center().x),
124+
painter.round_to_pixel_center(rect.center().x),
125125
(rect.top() - grow)..=(rect.bottom() + grow),
126126
stroke,
127127
);

crates/egui_demo_app/src/wrap_app.rs

+8-5
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,14 @@ impl eframe::App for WrapApp {
277277
}
278278

279279
let mut cmd = Command::Nothing;
280-
egui::TopBottomPanel::top("wrap_app_top_bar").show(ctx, |ui| {
281-
ui.horizontal_wrapped(|ui| {
282-
ui.visuals_mut().button_frame = false;
283-
self.bar_contents(ui, frame, &mut cmd);
280+
egui::TopBottomPanel::top("wrap_app_top_bar")
281+
.frame(egui::Frame::none().inner_margin(4.0))
282+
.show(ctx, |ui| {
283+
ui.horizontal_wrapped(|ui| {
284+
ui.visuals_mut().button_frame = false;
285+
self.bar_contents(ui, frame, &mut cmd);
286+
});
284287
});
285-
});
286288

287289
self.state.backend_panel.update(ctx, frame);
288290

@@ -324,6 +326,7 @@ impl WrapApp {
324326
egui::SidePanel::left("backend_panel")
325327
.resizable(false)
326328
.show_animated(ctx, is_open, |ui| {
329+
ui.add_space(4.0);
327330
ui.vertical_centered(|ui| {
328331
ui.heading("💻 Backend");
329332
});

crates/egui_demo_lib/src/demo/demo_app_windows.rs

+1
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ impl DemoWindows {
260260
.resizable(false)
261261
.default_width(150.0)
262262
.show(ctx, |ui| {
263+
ui.add_space(4.0);
263264
ui.vertical_centered(|ui| {
264265
ui.heading("✒ egui demos");
265266
});

crates/egui_demo_lib/src/rendering_test.rs

+43
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,49 @@ pub fn pixel_test(ui: &mut Ui) {
415415
ui.add_space(4.0);
416416

417417
pixel_test_squares(ui);
418+
419+
ui.add_space(4.0);
420+
421+
pixel_test_strokes(ui);
422+
}
423+
424+
fn pixel_test_strokes(ui: &mut Ui) {
425+
ui.label("The strokes should align to the physical pixel grid.");
426+
let color = if ui.style().visuals.dark_mode {
427+
egui::Color32::WHITE
428+
} else {
429+
egui::Color32::BLACK
430+
};
431+
432+
let pixels_per_point = ui.ctx().pixels_per_point();
433+
434+
for thickness_pixels in 1..=3 {
435+
let thickness_pixels = thickness_pixels as f32;
436+
let thickness_points = thickness_pixels / pixels_per_point;
437+
let num_squares = (pixels_per_point * 10.0).round().max(10.0) as u32;
438+
let size_pixels = vec2(
439+
ui.available_width(),
440+
num_squares as f32 + thickness_pixels * 2.0,
441+
);
442+
let size_points = size_pixels / pixels_per_point + Vec2::splat(2.0);
443+
let (response, painter) = ui.allocate_painter(size_points, Sense::hover());
444+
445+
let mut cursor_pixel = Pos2::new(
446+
response.rect.min.x * pixels_per_point + thickness_pixels,
447+
response.rect.min.y * pixels_per_point + thickness_pixels,
448+
)
449+
.ceil();
450+
451+
let stroke = Stroke::new(thickness_points, color);
452+
for size in 1..=num_squares {
453+
let rect_points = Rect::from_min_size(
454+
Pos2::new(cursor_pixel.x, cursor_pixel.y),
455+
Vec2::splat(size as f32),
456+
);
457+
painter.rect_stroke(rect_points / pixels_per_point, 0.0, stroke);
458+
cursor_pixel.x += (1 + size) as f32 + thickness_pixels * 2.0;
459+
}
460+
}
418461
}
419462

420463
fn pixel_test_squares(ui: &mut Ui) {

0 commit comments

Comments
 (0)