Fix blurry lines
- Closes https://github.com/emilk/egui/issues/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:
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):
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)
Stroke::Outside (proposed default behavior for closed paths)
Stroke::Inside (for completeness but unused at the moment)
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:
After (notice the sharper lines):
The main challenge I'm seeing at the moment is that if the rasterization of the fill doesn't exactly match the rasterization of the stroke, you'll see a gap between the two 🤔. This only happens when rounding is enabled afaict.
(zoom in)
Update: this artifact is actually already present in the current version and it’s almost imperceptible in most cases. We might be able to fix it later by adding a tiny fudge factor when shapes are filled and stroked but IMO it’s out of scope of this PR.
There is a visible gap where the line ends on the "after" screenshot (above the window cross).
There is a visible gap where the line ends on the "after" screen shot
Good catch. Fixed!
Thanks the egoat!
I modified fill_closed_path to feather the edges to the stroke color (instead of transparent) and that gets the job done.
Related:
- https://github.com/emilk/egui/issues/5164