Skip to content

Commit

Permalink
Fix blurry lines (#4943)
Browse files Browse the repository at this point in the history
<!--
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 <#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 #4019 and #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)
  • Loading branch information
juancampa authored Aug 30, 2024
1 parent 3777b8d commit f2815b4
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 89 deletions.
28 changes: 20 additions & 8 deletions crates/egui/src/containers/panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ impl SidePanel {
ui.ctx().set_cursor_icon(cursor_icon);
}

// Keep this rect snapped so that panel content can be pixel-perfect
let rect = ui.painter().round_rect_to_pixels(rect);

PanelState { rect }.store(ui.ctx(), id);

{
Expand All @@ -343,10 +346,14 @@ impl SidePanel {
Stroke::NONE
};
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
// In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel
// (hence the shrink).
let resize_x = side.opposite().side_x(rect.shrink(1.0));
let resize_x = ui.painter().round_to_pixel(resize_x);
let resize_x = side.opposite().side_x(rect);

// This makes it pixel-perfect for odd-sized strokes (width=1.0, width=3.0, etc)
let resize_x = ui.painter().round_to_pixel_center(resize_x);

// We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for
// left-side panels
let resize_x = resize_x - if side == Side::Left { 1.0 } else { 0.0 };
ui.painter().vline(resize_x, panel_rect.y_range(), stroke);
}

Expand Down Expand Up @@ -817,6 +824,9 @@ impl TopBottomPanel {
ui.ctx().set_cursor_icon(cursor_icon);
}

// Keep this rect snapped so that panel content can be pixel-perfect
let rect = ui.painter().round_rect_to_pixels(rect);

PanelState { rect }.store(ui.ctx(), id);

{
Expand All @@ -831,10 +841,12 @@ impl TopBottomPanel {
Stroke::NONE
};
// TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done
// In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel
// (hence the shrink).
let resize_y = side.opposite().side_y(rect.shrink(1.0));
let resize_y = ui.painter().round_to_pixel(resize_y);
let resize_y = side.opposite().side_y(rect);
let resize_y = ui.painter().round_to_pixel_center(resize_y);

// We want the line exactly on the last pixel but rust rounds away from zero so we bring it back a bit for
// top-side panels
let resize_y = resize_y - if side == TopBottomSide::Top { 1.0 } else { 0.0 };
ui.painter().hline(panel_rect.x_range(), resize_y, stroke);
}

Expand Down
26 changes: 8 additions & 18 deletions crates/egui/src/containers/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,9 +439,6 @@ impl<'open> Window<'open> {
let mut window_frame = frame.unwrap_or_else(|| Frame::window(&ctx.style()));
// Keep the original inner margin for later use
let window_margin = window_frame.inner_margin;
let border_padding = window_frame.stroke.width / 2.0;
// Add border padding to the inner margin to prevent it from covering the contents
window_frame.inner_margin += border_padding;

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

if let Some(title_bar) = title_bar {
let mut title_rect = Rect::from_min_size(
outer_rect.min + vec2(border_padding, border_padding),
outer_rect.min,
Vec2 {
x: outer_rect.size().x - border_padding * 2.0,
x: outer_rect.size().x,
y: title_bar_height,
},
);
Expand All @@ -587,9 +584,6 @@ impl<'open> Window<'open> {
if on_top && area_content_ui.visuals().window_highlight_topmost {
let mut round = window_frame.rounding;

// Eliminate the rounding gap between the title bar and the window frame
round -= border_padding;

if !is_collapsed {
round.se = 0.0;
round.sw = 0.0;
Expand All @@ -603,7 +597,7 @@ impl<'open> Window<'open> {

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

title_bar.ui(
Expand Down Expand Up @@ -667,14 +661,10 @@ fn paint_resize_corner(
}
};

// Adjust the corner offset to accommodate the stroke width and window rounding
let offset = if radius <= 2.0 && stroke.width < 2.0 {
2.0
} else {
// The corner offset is calculated to make the corner appear to be in the correct position
(2.0_f32.sqrt() * (1.0 + radius + stroke.width / 2.0) - radius)
* 45.0_f32.to_radians().cos()
};
// Adjust the corner offset to accommodate for window rounding
let offset =
((2.0_f32.sqrt() * (1.0 + radius) - radius) * 45.0_f32.to_radians().cos()).max(2.0);

let corner_size = Vec2::splat(ui.visuals().resize_corner_size);
let corner_rect = corner.align_size_within_rect(corner_size, outer_rect);
let corner_rect = corner_rect.translate(-offset * corner.to_sign()); // move away from corner
Expand Down Expand Up @@ -1136,7 +1126,6 @@ impl TitleBar {
let text_pos =
emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top();
let text_pos = text_pos - self.title_galley.rect.min.to_vec2();
let text_pos = text_pos - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better)
ui.painter().galley(
text_pos,
self.title_galley.clone(),
Expand All @@ -1150,6 +1139,7 @@ impl TitleBar {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
// Workaround: To prevent border infringement,
// the 0.1 value should ideally be calculated using TessellationOptions::feathering_size_in_pixels
// or we could support selectively disabling feathering on line caps
let x_range = outer_rect.x_range().shrink(0.1);
ui.painter().hline(x_range, y, stroke);
}
Expand Down
24 changes: 20 additions & 4 deletions crates/egui/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1717,26 +1717,42 @@ impl Context {
});
}

/// Useful for pixel-perfect rendering
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
#[inline]
pub(crate) fn round_to_pixel_center(&self, point: f32) -> f32 {
let pixels_per_point = self.pixels_per_point();
((point * pixels_per_point - 0.5).round() + 0.5) / pixels_per_point
}

/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
#[inline]
pub(crate) fn round_pos_to_pixel_center(&self, point: Pos2) -> Pos2 {
pos2(
self.round_to_pixel_center(point.x),
self.round_to_pixel_center(point.y),
)
}

/// Useful for pixel-perfect rendering of filled shapes
#[inline]
pub(crate) fn round_to_pixel(&self, point: f32) -> f32 {
let pixels_per_point = self.pixels_per_point();
(point * pixels_per_point).round() / pixels_per_point
}

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

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

/// Useful for pixel-perfect rendering
/// Useful for pixel-perfect rendering of filled shapes
#[inline]
pub(crate) fn round_rect_to_pixels(&self, rect: Rect) -> Rect {
Rect {
Expand Down
14 changes: 13 additions & 1 deletion crates/egui/src/painter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,19 @@ impl Painter {
self.clip_rect = clip_rect;
}

/// Useful for pixel-perfect rendering.
/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
#[inline]
pub fn round_to_pixel_center(&self, point: f32) -> f32 {
self.ctx().round_to_pixel_center(point)
}

/// Useful for pixel-perfect rendering of lines that are one pixel wide (or any odd number of pixels).
#[inline]
pub fn round_pos_to_pixel_center(&self, pos: Pos2) -> Pos2 {
self.ctx().round_pos_to_pixel_center(pos)
}

/// Useful for pixel-perfect rendering of filled shapes.
#[inline]
pub fn round_to_pixel(&self, point: f32) -> f32 {
self.ctx().round_to_pixel(point)
Expand Down
8 changes: 6 additions & 2 deletions crates/egui/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2477,8 +2477,12 @@ impl Widget for &mut Stroke {

// stroke preview:
let (_id, stroke_rect) = ui.allocate_space(ui.spacing().interact_size);
let left = stroke_rect.left_center();
let right = stroke_rect.right_center();
let left = ui
.painter()
.round_pos_to_pixel_center(stroke_rect.left_center());
let right = ui
.painter()
.round_pos_to_pixel_center(stroke_rect.right_center());
ui.painter().line_segment([left, right], (*width, *color));
})
.response
Expand Down
4 changes: 2 additions & 2 deletions crates/egui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2215,9 +2215,9 @@ impl Ui {

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

if left_vline {
// draw a faint line on the left to mark the indented section
Expand Down
4 changes: 2 additions & 2 deletions crates/egui/src/widgets/separator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ impl Widget for Separator {
if is_horizontal_line {
painter.hline(
(rect.left() - grow)..=(rect.right() + grow),
painter.round_to_pixel(rect.center().y),
painter.round_to_pixel_center(rect.center().y),
stroke,
);
} else {
painter.vline(
painter.round_to_pixel(rect.center().x),
painter.round_to_pixel_center(rect.center().x),
(rect.top() - grow)..=(rect.bottom() + grow),
stroke,
);
Expand Down
13 changes: 8 additions & 5 deletions crates/egui_demo_app/src/wrap_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,14 @@ impl eframe::App for WrapApp {
}

let mut cmd = Command::Nothing;
egui::TopBottomPanel::top("wrap_app_top_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.visuals_mut().button_frame = false;
self.bar_contents(ui, frame, &mut cmd);
egui::TopBottomPanel::top("wrap_app_top_bar")
.frame(egui::Frame::none().inner_margin(4.0))
.show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.visuals_mut().button_frame = false;
self.bar_contents(ui, frame, &mut cmd);
});
});
});

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

Expand Down Expand Up @@ -324,6 +326,7 @@ impl WrapApp {
egui::SidePanel::left("backend_panel")
.resizable(false)
.show_animated(ctx, is_open, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("💻 Backend");
});
Expand Down
1 change: 1 addition & 0 deletions crates/egui_demo_lib/src/demo/demo_app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ impl DemoWindows {
.resizable(false)
.default_width(150.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("✒ egui demos");
});
Expand Down
43 changes: 43 additions & 0 deletions crates/egui_demo_lib/src/rendering_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,49 @@ pub fn pixel_test(ui: &mut Ui) {
ui.add_space(4.0);

pixel_test_squares(ui);

ui.add_space(4.0);

pixel_test_strokes(ui);
}

fn pixel_test_strokes(ui: &mut Ui) {
ui.label("The strokes should align to the physical pixel grid.");
let color = if ui.style().visuals.dark_mode {
egui::Color32::WHITE
} else {
egui::Color32::BLACK
};

let pixels_per_point = ui.ctx().pixels_per_point();

for thickness_pixels in 1..=3 {
let thickness_pixels = thickness_pixels as f32;
let thickness_points = thickness_pixels / pixels_per_point;
let num_squares = (pixels_per_point * 10.0).round().max(10.0) as u32;
let size_pixels = vec2(
ui.available_width(),
num_squares as f32 + thickness_pixels * 2.0,
);
let size_points = size_pixels / pixels_per_point + Vec2::splat(2.0);
let (response, painter) = ui.allocate_painter(size_points, Sense::hover());

let mut cursor_pixel = Pos2::new(
response.rect.min.x * pixels_per_point + thickness_pixels,
response.rect.min.y * pixels_per_point + thickness_pixels,
)
.ceil();

let stroke = Stroke::new(thickness_points, color);
for size in 1..=num_squares {
let rect_points = Rect::from_min_size(
Pos2::new(cursor_pixel.x, cursor_pixel.y),
Vec2::splat(size as f32),
);
painter.rect_stroke(rect_points / pixels_per_point, 0.0, stroke);
cursor_pixel.x += (1 + size) as f32 + thickness_pixels * 2.0;
}
}
}

fn pixel_test_squares(ui: &mut Ui) {
Expand Down
Loading

0 comments on commit f2815b4

Please sign in to comment.