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

Fix rounding errors with font scaling #2490

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

parasyte
Copy link
Contributor

@parasyte parasyte commented Dec 20, 2022

I am working on something like a terminal emulator. It needs to have a fixed width font that renders identically on all platforms. One of the problems I am facing right now is that with the pixels_per_point value at 2.0 on macOS and 1.0 on Windows, scaling the fonts ends up rendering slightly differently in these environments.

Here are some screenshots with a grid of characters rendered on Windows and macOS. I initially developed this on macOS, so it is showing what I "expected" to see on Windows:

macOS:

prosaic-macos

Windows:

prosaic-windows

They both have exactly 800x600 LogicalSize inner window size, e.g. it's 800x600 physical pixels on Windows, and 1600x1200 physical pixels on macOS. But clearly the font size is ever so slightly different enough that the cursor isn't clipped in the corner of the window on Windows, as it is on macOS.

This PR attempts to address the bug.

Reveal outdated patch description

First, we notice that Font::round_to_pixel() is called with a point size that is already scaled by pixels_per_point, and it internally resizes by dividing after rounding. This leads to rounding errors. For example, suppose the point size is 16.27 and pixels_per_point is 2.0:

(16.27 * 2.0).round() == 33.0

33.0 / 2.0 == 16.5

Compare this when pixels_per_point is 1.0:

(16.27 * 1.0).round() == 16.0

16.0 / 1.0 == 16.0

If instead we floor the value, then we get the same result, regardless of the value of pixels_per_point:

(16.27 * 2.0).floor() == 32.0

32.0 / 2.0 == 16.0

Secondly, the same issue occurs with the horizontal advance, so we change that round to floor also.


Description of the new patch:

We defer the scaling of the font to as late as possible (e.g. when drawing glyphs). The fonts themselves now only store sizes in points (unscaled). Rounding of positions is still done, but it uses the font points coordinate space instead of physical pixels. This accounts for the kerning differences on high DPI displays as shown in screenshots below.

The result is that the rasterization looks identical on Windows where pixels_per_point == 1.0, but the rasterization on macOS (pixels_per_point == 2.0) now more closely resembles what is seen on Windows:

prosaic-macos-fixed


I'll leave this as a draft PR to open discussion. I could be wrong, but I believe this is the correct way to address the problem. I'll leave a list of items that I know need to be done before this can be accepted.

TODO:

  • Add changelog entry
  • Update comments and function names to reference flooring instead of rounding

@parasyte
Copy link
Contributor Author

parasyte commented Dec 20, 2022

I don't know for sure, but this might also fix #2164 and #1790?

@parasyte
Copy link
Contributor Author

parasyte commented Dec 21, 2022

edit: This has been fixed.


This patch introduces some issues with font rendering, even with pixels_per_point == 1.0.

Reveal outdated comparison screenshots

Before:

before

After:

after

Kerning is badly impacted, much like it is in #2164.

Perhaps it is worth attempting to address the problem by performing the scaling only once, as late as possible?

@parasyte
Copy link
Contributor Author

Ok, the latest commit does a much better job at fixing the rounding errors when pixels_per_point != 1.0. The before and after screenshots with pixels_per_point == 1.0 look pixel-perfect identical, AFAICT.

Kerning is absolutely different with this PR on high DPI displays with pixels_per_point > 1.0. Overall difference is minor (1 physical pixel here and there) but it's worth pointing out. For instance, here are some comparison shots on macOS with pixels_per_point == 2.0:

Before:

macOS-before

After:

macOS-after

And here are some detail shots that show the kerning differences. The changes may be better, but I don't necessarily think it is any worse than before. Open these each in a new tab and switch between them. Or put each into a separate layer in photoshop and toggle layer visibility.

Detail 1

Before:

detail-1-before

After:

detail-1-after

Detail 2

Before:

detail-2-before

After:

detail-2-after

@parasyte parasyte force-pushed the fix/font-scaling-rounding-errors branch 2 times, most recently from b9ef406 to 507d516 Compare December 21, 2022 19:58
@parasyte parasyte marked this pull request as ready for review December 21, 2022 20:27
Copy link
Owner

@emilk emilk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code is designed to produce crisp text and ok kerning for any pixels_per_point scale. The new code fails pretty badly on non-integral pixels_per_point (which are common).

There may be some kerning improvements to be made, but if it comes at the cost of crisp text it should be made optional imho.

crates/epaint/src/text/text_layout.rs Outdated Show resolved Hide resolved
@parasyte
Copy link
Contributor Author

It is not the keening that I am trying to address. That was just a side effect. The situation is that the code which rounds to pixels is causing the fonts to appear at the wrong scale when pixels_per_point is not 1.0. See the new tests in the PR.

@emilk
Copy link
Owner

emilk commented Jan 23, 2023

I appreciate that, but the point is that your PR breaks something else: crisp text.

Getting both crisp text and kerning right is difficult, and I suspect the current code may be doing the best job that could be done, but I'd be happy to be proven wrong.

@parasyte
Copy link
Contributor Author

parasyte commented Jan 25, 2023

I guess I'll have to look more closely, but on initial inspection I do not see any additional blurriness in the text when rendered with non-integer scale factors. What I do see, and what this PR wants to achieve, is correctly scaling the text proportionally to the scale factor.

The following screens show the difference. (The window size was not changed, only the scale value.)

Before

With pixels_per_point == 1.0:

before-1.0

With pixels_per_point == 0.9:

before-0.9

With pixels_per_point == 0.75:

before-0.75

Notice that the words "feature" and "syntect" are laid out on different lines depending on the value of pixels_per_point. This is because the character size is literally different proportionally to the UI when the scale factor is changed. And this is unfortunately caused by rounding in the pixel coordinate system before the point coordinate system has been fully "materialized".

After

With pixels_per_point == 1.0:

after-1.0

With pixels_per_point == 0.9:

after-0.9

With pixels_per_point == 0.75:

after-0.75

Now notice that the text layout looks proportionately identical across the board. Also notice that that semicolon on the println! line horizontally aligns with the n in "highlighting" on the top line in all three screenshots. (You can draw a vertical line from the semicolon and it always hits roughly the same spot in every word all the way to the top of the window.) While in the "before" set the horizontal alignment is all over the place!


I still can't make out the blurriness you mentioned, even at 0.9. Here is a comparison from both the before and after magnified by 10x:

Before

before-0.9-detail

After

after-0.9-detail

Neither looks blurrier to me. The only difference is in kerning (as mentioned in the OP) and even then, I don't think either is objectively worse. For instance, in the "before" there is 1px of space between the "u" and "r" that doesn't exist in the "after". But also in the "after" there is 1px of space between "{" and "s" that doesn't exist in the "before". So, it appears to be a net neutral change. No better, no worse, just different.

Ok, so that's the proportional font, what about the monospace font?

Before

before-0.9-detail-2

After

after-0.9-detail-2

Now it is obvious that the font sizes are literally different. In the "after" image, the "H" and the "d" are more clearly defined as a result of it just being larger. The double quotes and "w" are less clearly defined, also as a result of the larger font. And kerning may be worse. (Particularly the extra space between the two "l"s and the extra space between the "o" and "r".)

On the font size with the monospace font, the text is more accurately proportional to the width of the text input widget (i.e., the raison d'être for this PR):

Before

Scale Widget width Width of "println" line_width / widget_width
1.0 342 px 178 px 0.52046783625730994152046
0.9 307 px 145 px 0.47231270358306188925081
0.75 256 px 143 px 0.55859375

After

Scale Widget width Width of "println" line_width / widget_width
1.0 342 px 174 px 0.50877192982456140350877
0.9 307 px 159 px 0.51791530944625407166123
0.75 257 px 135 px 0.52529182879377431906614

The proportionality of the monospace font in this test varies by about 8% before this PR, but only about 2% after. It can never be perfect because it rounds each character to the pixel grid (for #382), but I'll take 2% over 8% any day!


I'm happy to address any feedback that can demonstrate blurring, but I honestly don't see it, here.

For the kerning issues in the monospace font ... Yeah, that's not ideal. But it accomplishes the goal of retaining proportionality (and character legibility), which is far more important in a monospace font than perfect kerning on a pixel grid. (See OP for rationale.)

Getting both crisp text and kerning right is difficult

Ain't it the truth! I think I've shown that the crispness has not been affected at all by this PR. Kerning is still problematic, but also, I believe the real solution to that is probably subpixel rendering or another antialiasing technology (hinting, et al). But that is very much outside the scope of this PR.

@emilk
Copy link
Owner

emilk commented Jan 26, 2023

I was wrong, the text seems crisp, sorry about that.

But the kerning for non-integer scales is pretty bad.

Try 1.1 pixels_per_point and then enter iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii in a text field and you will see a very uneven lettering:

Screen Shot 2023-01-26 at 11 40 18

compared to master:

Screen Shot 2023-01-26 at 11 42 35

@parasyte
Copy link
Contributor Author

Yes, that is intentional, unfortunately. It is the only way to both maintain the correct text bounding box regardless of scale, and also align each glyph to the pixel grid independently.

In one opinion it is unacceptable to have uneven kerning, and in another opinion, it is unacceptable to change the size of text bounding boxes when moving the window between different displays.

Since we are at an impasse, I propose a compromise with a new opt-in feature flag in epaint to disable the per-pixel rounding during glyph layout. That way, you get today's kerning behavior by default at the expense of fixed width fonts not actually having a fixed width. Or you can opt-in to better fixed width font behavior at the expense of poor kerning.

The real fix is of course better antialiasing (subpixel rendering, hinting, super sampling, etc. that does not rely on rounding positions to the pixel grid) for which I will create a separate ticket for tracking. Meanwhile this PR can potentially go forward to fix rounding errors introduced by forcing glyph kerning to the pixel grid.

Does this sound reasonable to you? I have tried the proposed feature flag locally and it is doing what I need it to.

- Store the font size in _points_ instead of _pixels_.
- Scale only for drawing.
- Add a new feature `proportional_font_scaling`.
  - Maintains the correct kerning and fixes relative font sizes for any
    `pixels_per_point` with monospace fonts.
  - Enabling this feature will cause some unavoidable kerning issues
    with proportional fonts.
- Add tests for font scaling with `pixels_per_point`.
- Add note to `epaint` CHANGELOG.
@parasyte parasyte force-pushed the fix/font-scaling-rounding-errors branch from 2f3055a to 75fa886 Compare January 28, 2023 18:01
@emilk
Copy link
Owner

emilk commented Feb 4, 2023

Yes, having flags for all these options would be great!

I thin we can give full control if we have flags for:

  • Rounding font sizes to even pixels on/off (FontImplCache::font_impl)
  • Rounding glyphs to even pixels on/off (crates/epaint/src/text/text_layout.rs)

true, true: stepwise scaling, crisp text, correct kerning
false, true: fluid scaling, crisp text, bad kerning
false, false: fluid scaling, blurry text, correct kerning
true, false: stepwise scaling, blurry text, correct kerning (no real sense in this I think)

It would be extra great if these were runtime flags. For instance, for high-resolution displays it could make sense to turn off both types of rounding to get fluid scaling and perfect kerning at the cost of slightly blurry text (which makes much less difference on high resolution displays)

@parasyte
Copy link
Contributor Author

parasyte commented Feb 8, 2023

In terms of design, should the new flag state be attached to the egui::Context and automatically inherited by fonts as they are added? I'll see what it takes to make it a runtime config.

@emilk
Copy link
Owner

emilk commented Mar 29, 2023

In terms of design, should the new flag state be attached to the egui::Context and automatically inherited by fonts as they are added? I'll see what it takes to make it a runtime config.

That sounds good to me! Perhaps add a struct FontOptions and add it to struct Options

@emilk emilk marked this pull request as draft January 7, 2024 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants