Skip to content

Draw sharp text on scaled (HiDPI) displays - #2979

Merged
pvcraven merged 1 commit into
developmentfrom
crisp-hidpi-text
Oct 9, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
crisp-hidpi-text

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 9, 2026

Copy link
Copy Markdown
Member

Text looked blocky on scaled displays, such as Windows at 125% or 150%. This makes Text, draw_text and GUI text sharp there, at the same size and position.

Why it was blocky

Arcade sets pyglet.options.dpi_scaling = "stretch" (since #2361). pyglet's docs for that mode say content is drawn at the window's size and stretched to fill the larger framebuffer, "at the cost of blurry content". At 125%, a 640×320 window has an 800×400 framebuffer, so every glyph rasterized at 96 DPI was scaled 1.25× on the GPU. That gives strokes 2 or 3 pixels wide in the same word, and stair-stepped diagonals.

It got more noticeable two weeks ago. I rendered the same scene from five checkouts: September 12 (pyglet dev8), October 1 (pyglet dev11), before #2926, before #2957, and today. They look alike until #2957 switched the default font from Calibri to Liberation Sans. Calibri is heavily hinted for small screen sizes and hides the stretching. Liberation Sans, like Arial, shows it. In "platform" mode, which isn't stretched, all three fonts are smooth.

The change (arcade/text.py)

On a display whose pixel ratio isn't 1, Text creates its pyglet label:

  • with dpi = 96 × ratio (or the user's own dpi × ratio), so glyphs are rasterized at the screen's resolution;
  • with x, y, width and height multiplied by the ratio;
  • in a Group (parented to the user's group, if any) holding a _ScaleViewState. That pyglet 3 State multiplies ctx.view_matrix by scale(1/ratio) while the label draws and restores it afterwards.

The state belongs to the label's group, so it also applies when the label is drawn through the user's Batch. Equal states compare equal, so a batch can draw labels with the same scale together.

  • Window units stay window units: Text's x, y, position, width, height, size, content_width, content_height, content_size, left, right, top and bottom convert back to window units. Only Text.label, the pyglet object, uses framebuffer pixels.
  • At a ratio of 1 (100% displays, CI, pixel_perfect=True) nothing changes: no extra group, and the same DPI.
  • create_text_sprite keeps laying text out in window units through a small _UnscaledText subclass. Its texture is the text's size in window units, so a higher resolution would just be scaled back down, which looked worse in testing. Sharp text sprites would need a higher-resolution texture and a sprite scale, which I left for a separate decision.
  • Text can come out a few percent wider (349 → 360 px for one 14 pt line), because the font is hinted for the larger size.

Results at 125% (Windows)

Rendered with Arcade's default "stretch" scaling. draw_text at 10, 14 and 24 pt, arcade.Text, a UILabel and a UIFlatButton all have even strokes and smooth curves, at the same positions. create_text_sprite output is pixel-identical to development.

Tests

tests/unit/text/test_text_hidpi.py patches the scale Text lays out at to 2, since CI runs at 1:

  • the label is rasterized at 192 DPI;
  • positions and sizes read and write in window units;
  • content size is within 10% of unscaled text;
  • drawn text, directly or through a Batch, lands in the same place and at the same size as unscaled text. With the state turned off, these two fail;
  • create_text_sprite sizes are unchanged;
  • at a ratio of 1 the label is unchanged.

Also run:

  • the full test suite (1858), ruff, mypy, pyright and make.py docs-full;
  • 14 text and GUI examples, run with "stretch" scaling at 125%, without errors.

Not tested: macOS Retina, where the ratio is 2. The same code path applies there, but I couldn't check it.

🤖 Generated with Claude Code

Arcade's windows use pyglet's "stretch" scaling, so text was rasterized
at the window's size and stretched to fill the larger framebuffer,
which made strokes uneven and blocky. The switch to Liberation Sans made
it more noticeable than Calibri did.

On a scaled display, Text now lays its pyglet label out at the
framebuffer's resolution (dpi and positions multiplied by the pixel
ratio) and draws it back at the same size with a pyglet State on the
label's group that scales the view matrix. The state travels with the
group, so text in a user's Batch is scaled too. Text's positions and
sizes are still in window units. At a ratio of 1 nothing changes.
create_text_sprite keeps laying text out in window units, since its
texture is that size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit bfc1cd7 into development Oct 9, 2026
7 checks passed
@pvcraven
pvcraven deleted the crisp-hidpi-text branch October 9, 2026 22:55
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.

1 participant