Skip to content

Commit 49ed371

Browse files
Paul V Cravenclaude
andcommitted
Draw sharp text on scaled (HiDPI) displays
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>
1 parent 275bdc2 commit 49ed371

3 files changed

Lines changed: 219 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
66
## Unreleased
77

88
### Fixes
9+
- Fixed blocky, uneven text on scaled (HiDPI) displays, such as Windows set to 125% or 150%. Arcade's windows use pyglet's `"stretch"` scaling, so text was rasterized at the window's size and then stretched to fill the larger framebuffer, giving strokes of uneven width. It looked worse after the default font changed to Liberation Sans, which shows the stretching more than Calibri did. `Text`, `draw_text` and GUI text now lay their glyphs out at the framebuffer's resolution and draw them back at the same size, so they're sharp. Nothing changes at a scale of 100%.
10+
- Positions and sizes on `Text`, such as `x`, `content_width` and `left`, are still in window units. The pyglet label in `Text.label` uses framebuffer pixels on a scaled display.
11+
- Text can come out a few percent wider, because the font is hinted for the larger size.
12+
- `create_text_sprite` is unchanged; its texture is the size of the text in window units.
913
- Fixed `DefaultTextureAtlas` losing track of textures that share an image, such as every `SpriteSolidColor` size, which share one white image:
1014
- Once the first texture with a given image was freed while another was still in use, resizing the atlas, which happens when it fills up, raised `RuntimeError: Empty set in unique textures` and left the atlas half-resized.
1115
- If the last texture's finalizer ran before Python had cleared it from the atlas's records, the atlas kept the texture's name after freeing its slot. Adding another texture with that image then raised `Texture '...' not found in UVData`. This showed up as an occasional CI failure.

‎arcade/text.py‎

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# Pyright can't figure out the dynamic backend imports in pyglet.graphics
1717
# right now. Maybe can fix in future Pyglet version
1818
from pyglet.graphics import Batch, Group # type: ignore
19+
from pyglet.graphics.state import State
20+
from pyglet.math import Mat4, Vec3
1921
from pyglet.text import LinearGradient
2022

2123
import arcade
@@ -174,6 +176,42 @@ def _attempt_font_name_resolution(font_name: FontNameOrNames) -> str:
174176
return pyglet.font.load(names).name
175177

176178

179+
class _ScaleViewState(State):
180+
"""Scale the view matrix while a label draws.
181+
182+
On a scaled (HiDPI) display, :py:class:`Text` lays its label out at the
183+
framebuffer's resolution, so the glyphs aren't stretched and blurred.
184+
This scales the label back down to window coordinates.
185+
"""
186+
187+
sets_state = True
188+
unsets_state = True
189+
190+
def __init__(self, ctx: Any, scale: float) -> None:
191+
self.ctx = ctx
192+
self.scale = scale
193+
self._saved_view: Mat4 | None = None
194+
195+
def set_state(self, draw_ctx: Any) -> None:
196+
self._saved_view = view = self.ctx.view_matrix
197+
self.ctx.view_matrix = view @ Mat4.from_scale(Vec3(self.scale, self.scale, 1.0))
198+
199+
def unset_state(self, draw_ctx: Any) -> None:
200+
if self._saved_view is not None:
201+
self.ctx.view_matrix = self._saved_view
202+
203+
# Equal states let a batch draw labels with the same scale together
204+
def __eq__(self, other: object) -> bool:
205+
return (
206+
isinstance(other, _ScaleViewState)
207+
and other.ctx is self.ctx
208+
and other.scale == self.scale
209+
)
210+
211+
def __hash__(self) -> int:
212+
return hash((id(self.ctx), self.scale))
213+
214+
177215
def _draw_pyglet_label(label: pyglet.text.Label) -> None:
178216
"""
179217
Helper for drawing pyglet labels with rotation within arcade.
@@ -395,6 +433,10 @@ def __init__(
395433
self._resolved_font_name: str | None = None
396434
# The clock tick this text was last drawn by draw_text, if it was
397435
self._draw_text_tick = -1
436+
# Framebuffer pixels per window unit. On a scaled (HiDPI) display
437+
# the label is laid out at this scale, so its glyphs are drawn at
438+
# the screen's resolution instead of stretched.
439+
self._scale = 1.0
398440

399441
self._initialized = False
400442
try:
@@ -403,6 +445,10 @@ def __init__(
403445
# No window yet, so create the label when it's first used
404446
pass
405447

448+
def _render_scale(self, window: Any) -> float:
449+
"""How many framebuffer pixels to lay the label out per window unit."""
450+
return window.get_pixel_ratio()
451+
406452
@property
407453
def label(self) -> pyglet.text.Label:
408454
"""
@@ -426,10 +472,25 @@ def _init_deferred(self):
426472
Deferred initialization when lazy loaded
427473
"""
428474
# NOTE: Give the user a clear error message stating that the window is not created yet
429-
arcade.get_window()
475+
window = arcade.get_window()
430476

431477
self._arguments["font_name"] = _attempt_font_name_resolution(self._arguments["font_name"]) # type: ignore
432-
self._label = pyglet.text.Label(**self._arguments) # type: ignore
478+
arguments: dict[str, Any] = self._arguments
479+
self._scale = scale = self._render_scale(window)
480+
if scale != 1:
481+
# Lay the label out in framebuffer pixels, then scale it back
482+
# down to window units while it draws
483+
arguments = dict(arguments)
484+
arguments["x"] = arguments["x"] * scale
485+
arguments["y"] = arguments["y"] * scale
486+
for key in ("width", "height"):
487+
if arguments.get(key):
488+
arguments[key] = round(arguments[key] * scale)
489+
arguments["dpi"] = (arguments.get("dpi") or 96) * scale
490+
group = Group(parent=arguments.get("group"))
491+
group.set_state(_ScaleViewState(window.ctx, 1 / scale))
492+
arguments["group"] = group
493+
self._label = pyglet.text.Label(**arguments) # type: ignore
433494
self._resolved_font_name = self._label.font_name
434495
self._initialized = True
435496

@@ -532,21 +593,23 @@ def text(self, value: Any):
532593
@property
533594
def x(self) -> float:
534595
"""Get or set the x position of the label."""
535-
return self.label.x
596+
return self.label.x / self._scale
536597

537598
@x.setter
538599
def x(self, x: float) -> None:
600+
x = x * self._scale
539601
if self.label.x == x:
540602
return
541603
self.label.x = x
542604

543605
@property
544606
def y(self) -> float:
545607
"""Get or set the y position of the label."""
546-
return self.label.y
608+
return self.label.y / self._scale
547609

548610
@y.setter
549611
def y(self, y: float):
612+
y = y * self._scale
550613
if self.label.y == y:
551614
return
552615
self.label.y = y
@@ -669,13 +732,15 @@ def width(self) -> int | None:
669732
If you are looking for the physical size if the text, see
670733
:py:attr:`~arcade.Text.content_width`
671734
"""
672-
return self.label.width
735+
width = self.label.width
736+
return None if width is None else round(width / self._scale)
673737

674738
@width.setter
675739
def width(self, width: int):
676-
if self.label.width == width:
740+
scaled = None if width is None else round(width * self._scale)
741+
if self.label.width == scaled:
677742
return
678-
self._layout_label().width = width
743+
self._layout_label().width = scaled
679744

680745
@property
681746
def height(self) -> int | None:
@@ -686,48 +751,50 @@ def height(self) -> int | None:
686751
If you are looking for the physical size if the text, see
687752
:py:attr:`~arcade.Text.content_height`
688753
"""
689-
return self.label.height
754+
height = self.label.height
755+
return None if height is None else round(height / self._scale)
690756

691757
@height.setter
692758
def height(self, value: int):
693-
if self.label.height == value:
759+
scaled = None if value is None else round(value * self._scale)
760+
if self.label.height == scaled:
694761
return
695-
self._layout_label().height = value
762+
self._layout_label().height = scaled
696763

697764
@property
698765
def size(self):
699766
"""Get the size of the label."""
700-
return self.label.width, self.label.height
767+
return self.width, self.height
701768

702769
@property
703770
def content_width(self) -> int:
704771
"""Get the pixel width of the text contents."""
705-
return self.label.content_width
772+
return round(self.label.content_width / self._scale)
706773

707774
@property
708775
def content_height(self) -> int:
709776
"""Get the pixel height of the text content."""
710-
return self.label.content_height
777+
return round(self.label.content_height / self._scale)
711778

712779
@property
713780
def left(self) -> float:
714781
"""Pixel location of the left content border."""
715-
return self.label.left
782+
return self.label.left / self._scale
716783

717784
@property
718785
def right(self) -> float:
719786
"""Pixel location of the right content border."""
720-
return self.label.right
787+
return self.label.right / self._scale
721788

722789
@property
723790
def top(self) -> float:
724791
"""Pixel location of the top content border."""
725-
return self.label.top
792+
return self.label.top / self._scale
726793

727794
@property
728795
def bottom(self) -> float:
729796
"""Pixel location of the bottom content border."""
730-
return self.label.bottom
797+
return self.label.bottom / self._scale
731798

732799
@property
733800
def rect(self) -> Rect:
@@ -744,7 +811,7 @@ def rect(self) -> Rect:
744811
@property
745812
def content_size(self) -> tuple[int, int]:
746813
"""Get the pixel width and height of the text contents."""
747-
return self.label.content_width, self.label.content_height
814+
return self.content_width, self.content_height
748815

749816
@property
750817
def align(self) -> str:
@@ -888,14 +955,16 @@ def position(self) -> Point:
888955
This is faster than setting x and y position separately
889956
because the underlying geometry only needs to change position once.
890957
"""
891-
return self.label.x, self.label.y
958+
label = self.label
959+
return label.x / self._scale, label.y / self._scale
892960

893961
@position.setter
894962
def position(self, point: Point):
895963
# Starting with Pyglet 2.0b2 label positions take a z parameter.
896964
x, y, *z = point
897965
label = self.label
898-
position = (x, y, z[0] if z else label.z)
966+
scale = self._scale
967+
position = (x * scale, y * scale, z[0] if z else label.z)
899968
if label.position == position:
900969
return
901970
label.position = position
@@ -1074,6 +1143,18 @@ def remove(self, key: str) -> None:
10741143
del self._cache[key]
10751144

10761145

1146+
class _UnscaledText(Text):
1147+
"""A Text laid out in window units even on a scaled (HiDPI) display.
1148+
1149+
:py:func:`create_text_sprite` draws text into a texture the size of the
1150+
text in window units, so laying it out at a higher resolution would only
1151+
be scaled back down.
1152+
"""
1153+
1154+
def _render_scale(self, window: Any) -> float:
1155+
return 1.0
1156+
1157+
10771158
def create_text_sprite(
10781159
text: str,
10791160
color: RGBOrA255 | LinearGradient = arcade.color.WHITE,
@@ -1123,7 +1204,7 @@ def create_text_sprite(
11231204
texture_atlas: The texture atlas to use for the
11241205
newly created texture. The default global atlas will be used if this is None.
11251206
"""
1126-
text_object = Text(
1207+
text_object = _UnscaledText(
11271208
text,
11281209
x=0,
11291210
y=0,

‎tests/unit/text/test_text_hidpi.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""On a scaled (HiDPI) display, Text lays out at the screen's resolution.
2+
3+
The tests run with a pixel ratio of 1, so they pretend the display is scaled
4+
by patching the scale Text lays out at. Patching the window's
5+
get_pixel_ratio would also change the viewport.
6+
"""
7+
8+
import pyglet
9+
import pytest
10+
11+
import arcade
12+
from arcade.text import _UnscaledText
13+
14+
RATIO = 2.0
15+
16+
17+
def pretend_scaled(monkeypatch):
18+
monkeypatch.setattr(arcade.Text, "_render_scale", lambda self, window: RATIO)
19+
# create_text_sprite opts out, like on a real scaled display
20+
monkeypatch.setattr(_UnscaledText, "_render_scale", lambda self, window: 1.0)
21+
22+
23+
@pytest.fixture
24+
def scaled(window, monkeypatch):
25+
pretend_scaled(monkeypatch)
26+
27+
28+
def text(**kwargs):
29+
return arcade.Text("The quick brown fox", 100, 50, arcade.color.BLACK, 14, **kwargs)
30+
31+
32+
def dark_bounds():
33+
"""The left, bottom, right and top of the dark pixels on screen."""
34+
image = arcade.get_image().convert("L")
35+
box = image.point([255 if value < 128 else 0 for value in range(256)]).getbbox()
36+
assert box, "Nothing was drawn"
37+
left, top, right, bottom = box
38+
height = image.height
39+
return left, height - bottom, right, height - top
40+
41+
42+
@pytest.mark.usefixtures("scaled")
43+
def test_glyphs_are_rasterized_at_the_screen_resolution():
44+
assert text().label.dpi == 96 * RATIO
45+
46+
47+
@pytest.mark.usefixtures("scaled")
48+
def test_positions_and_sizes_are_in_window_units():
49+
plain_scale_text = text()
50+
assert plain_scale_text.x == 100
51+
assert plain_scale_text.y == 50
52+
assert plain_scale_text.position == (100, 50)
53+
assert plain_scale_text.left == pytest.approx(100)
54+
55+
plain_scale_text.position = 20, 30
56+
assert plain_scale_text.position == (20, 30)
57+
plain_scale_text.x = 40
58+
assert plain_scale_text.x == 40
59+
60+
wrapped = text(width=150, multiline=True)
61+
assert wrapped.width == 150
62+
wrapped.width = 200
63+
assert wrapped.width == 200
64+
65+
66+
def test_content_size_matches_unscaled_text(window, monkeypatch):
67+
unscaled = text()
68+
pretend_scaled(monkeypatch)
69+
scaled_text = text()
70+
# Fonts are hinted at each size, so the widths differ a little
71+
assert scaled_text.content_width == pytest.approx(unscaled.content_width, rel=0.1)
72+
assert scaled_text.content_height == pytest.approx(unscaled.content_height, rel=0.1)
73+
74+
75+
@pytest.mark.parametrize("use_batch", [False, True], ids=["draw", "batch"])
76+
def test_drawn_where_unscaled_text_is(window, monkeypatch, use_batch):
77+
window.background_color = arcade.color.WHITE
78+
79+
window.clear()
80+
text().draw()
81+
expected = dark_bounds()
82+
83+
pretend_scaled(monkeypatch)
84+
window.clear()
85+
if use_batch:
86+
batch = pyglet.graphics.Batch()
87+
scaled_text = text(batch=batch)
88+
batch.draw()
89+
else:
90+
scaled_text = text()
91+
scaled_text.draw()
92+
actual = dark_bounds()
93+
94+
# Same place and about the same size, not twice as big
95+
assert actual[0] == pytest.approx(expected[0], abs=3)
96+
assert actual[1] == pytest.approx(expected[1], abs=3)
97+
assert actual[2] - actual[0] == pytest.approx(expected[2] - expected[0], rel=0.1)
98+
assert actual[3] - actual[1] == pytest.approx(expected[3] - expected[1], rel=0.15)
99+
assert scaled_text.label.dpi == 96 * RATIO
100+
101+
102+
def test_text_sprites_are_unchanged(window, monkeypatch):
103+
unscaled = arcade.create_text_sprite("The quick brown fox", arcade.color.BLACK, 14)
104+
pretend_scaled(monkeypatch)
105+
scaled_sprite = arcade.create_text_sprite("The quick brown fox", arcade.color.BLACK, 14)
106+
assert scaled_sprite.size == unscaled.size
107+
108+
109+
def test_unscaled_display_is_unchanged(window):
110+
assert window.get_pixel_ratio() == 1
111+
plain = text()
112+
assert plain.label.dpi == 96
113+
assert plain.label.x == 100

0 commit comments

Comments
 (0)