Skip to content

Commit d760d75

Browse files
pvcravenPaul V Cravenclaude
authored
Let draw_text reuse only labels not drawn this frame (#2925)
draw_text kept at most 8 labels per style, so a changing line would reuse its own old label instead of making a new one every frame. With 9 or more unchanged lines in one style, though, the lines took each other's labels and every line was laid out again every frame: 20 lines cost 5.6 ms per frame instead of 0.6 ms. Instead, each label records the GLOBAL_CLOCK tick it was last drawn in. On a cache miss, the style's least recently used label is reused only if it wasn't drawn this frame. Lines are drawn in the same order each frame, so a changing line reuses its own previous label, and unchanged lines keep theirs however many there are. The 256 label limit stays. Co-authored-by: Paul V Craven <paul.craven@optimizley.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ed76f45 commit d760d75

3 files changed

Lines changed: 90 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
2626
- Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check.
2727

2828
### Misc Changes
29-
- Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame).
29+
- Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame). Any number of lines can share a style. A line whose text changes reuses its own label from the previous frame, found by the clock's tick count, instead of making a new one.
3030
- Sped up `arcade.Text` updates. Setting a property to the value it already has no longer lays out the text again (for example `font_size`, `bold`, or `width` went from about 165 µs to under 1 µs), and a `with text:` block only lays out the text if something in it needs a new layout. An unchanged `arcade.TextPool` frame with 5 lines went from 760 µs to 6 µs.
3131
- Added a "Drawing Text" page to the programming guide: choosing between `draw_text`, `Text`, `TextPool`, and `create_text_sprite`, what's slow and fast to change, batches, fonts (names, font files, and the bundled Kenney and Liberation fonts), and styles. Updated the text section of the performance tips, and the `draw_text` warning and docstring, which overstated how slow it is.
3232

‎arcade/text.py‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from pyglet.text import LinearGradient
2020

2121
import arcade
22+
from arcade.clock import GLOBAL_CLOCK
2223
from arcade.exceptions import NoArcadeWindowError, PerformanceWarning, warning
2324
from arcade.resources import resolve
2425
from arcade.texture_atlas import TextureAtlasBase
@@ -370,6 +371,8 @@ def __init__(
370371
# resolved name (e.g. "arial" for ("calibri", "arial"))
371372
self._requested_font_name = _normalize_font_name(font_name)
372373
self._resolved_font_name: str | None = None
374+
# The clock tick this text was last drawn by draw_text, if it was
375+
self._draw_text_tick = -1
373376

374377
self._initialized = False
375378
try:
@@ -1172,35 +1175,35 @@ def draw_over(background: RGBA255) -> PIL.Image.Image:
11721175
)
11731176

11741177

1175-
# How many labels draw_text keeps for reuse, in total and per style
1178+
# How many labels draw_text keeps for reuse
11761179
_DRAW_TEXT_CACHE_SIZE = 256
1177-
_DRAW_TEXT_LABELS_PER_STYLE = 8
11781180

11791181

11801182
def _get_draw_text_label(label_cache, style: tuple, text: str) -> "Text | None":
11811183
"""
11821184
Find a label in draw_text's cache for drawing ``text`` in a style.
11831185
1184-
Returns the label already showing this text, or the style's least
1185-
recently used label (changed to show this text) if the style already
1186-
has the most labels it may. Returns None if a new label should be made.
1187-
Labels used every frame stay recent, so a line whose text keeps
1188-
changing reuses its own previous label rather than another line's.
1186+
Returns the label already showing this text. Otherwise, returns the
1187+
style's least recently used label, changed to show this text, if it
1188+
wasn't drawn yet this frame: it belonged to a line whose text changed,
1189+
or that isn't drawn anymore. Returns None if a new label should be made.
1190+
1191+
Lines are usually drawn in the same order every frame, so a line whose
1192+
text keeps changing reuses its own label from the previous frame, and
1193+
lines that don't change keep theirs, however many there are.
11891194
"""
11901195
key = (style, text)
11911196
label = label_cache.get(key)
11921197
if label is not None:
11931198
label_cache.move_to_end(key)
11941199
return label
11951200

1196-
count = 0
1197-
oldest = None
1198-
for cached_style, cached_text in label_cache:
1199-
if cached_style == style:
1200-
if oldest is None:
1201-
oldest = (cached_style, cached_text)
1202-
count += 1
1203-
if oldest is None or count < _DRAW_TEXT_LABELS_PER_STYLE:
1201+
oldest = next((cached for cached in label_cache if cached[0] == style), None)
1202+
if oldest is None:
1203+
return None
1204+
# Frames are counted by the clock's ticks. If the style's least recently
1205+
# used label was drawn this frame, they all were, so this is a new line.
1206+
if label_cache[oldest]._draw_text_tick == GLOBAL_CLOCK.ticks:
12041207
return None
12051208

12061209
label = label_cache.pop(oldest)
@@ -1451,6 +1454,7 @@ def draw_text(
14511454
# forever, for example when animating font_size or the text
14521455
if len(label_cache) > _DRAW_TEXT_CACHE_SIZE:
14531456
label_cache.popitem(last=False)
1457+
label._draw_text_tick = GLOBAL_CLOCK.ticks
14541458

14551459
if label.x != x or label.y != y or label.z != z:
14561460
label.position = x, y, z # type: ignore

‎tests/unit/text/test_draw_text_cache.py‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import arcade
66
from arcade import text as text_module
7+
from arcade.clock import GLOBAL_CLOCK
78

89
# These tests call draw_text on purpose, which warns that it's slow
910
pytestmark = pytest.mark.filterwarnings("ignore::arcade.exceptions.PerformanceWarning")
@@ -70,20 +71,83 @@ def _draw_hud(changing_text):
7071
arcade.draw_text(line, 10, 10 + i * 24, arcade.color.WHITE, 16)
7172

7273

74+
def _next_frame():
75+
"""The event loop ticks the clock once per frame"""
76+
GLOBAL_CLOCK.tick(1 / 60)
77+
78+
79+
def _label_for(label_cache, text):
80+
return next(label for key, label in label_cache.items() if key[1] == text)
81+
82+
7383
def test_same_style_lines_keep_their_labels(window, label_cache):
7484
"""Several lines in the same style each keep a label, even if one keeps changing"""
7585
_draw_hud("Time: 0")
76-
static_labels = {key[1]: label for key, label in label_cache.items()}
86+
labels = {key[1]: label for key, label in label_cache.items()}
7787
for frame in range(1, 30):
88+
_next_frame()
7889
_draw_hud(f"Time: {frame}")
7990

8091
# The unchanging lines still use their original labels
81-
for text, label in static_labels.items():
92+
for text, label in labels.items():
8293
if not text.startswith("Time"):
83-
assert label_cache[next(key for key in label_cache if key[1] == text)] is label
84-
# The changing line doesn't add a label every frame
85-
assert len(label_cache) <= text_module._DRAW_TEXT_LABELS_PER_STYLE
86-
assert any(key[1] == "Time: 29" for key in label_cache)
94+
assert _label_for(label_cache, text) is label
95+
# The changing line keeps reusing its own label
96+
assert len(label_cache) == 5
97+
assert _label_for(label_cache, "Time: 29") is labels["Time: 0"]
98+
99+
100+
@pytest.mark.parametrize("line_count", [9, 30])
101+
def test_many_same_style_lines_keep_their_labels(window, label_cache, line_count):
102+
"""There's no limit on lines per style: more than 8 used to take each other's labels"""
103+
104+
def draw_lines():
105+
for i in range(line_count):
106+
arcade.draw_text(f"Line {i}", 10, 10 + i * 18)
107+
108+
draw_lines()
109+
labels = dict(label_cache)
110+
for _ in range(5):
111+
_next_frame()
112+
draw_lines()
113+
assert len(label_cache) == line_count
114+
for key, label in labels.items():
115+
assert label_cache[key] is label
116+
117+
118+
def test_line_that_stops_frees_its_label(window, label_cache):
119+
"""A label not drawn in a frame is reused for a new line in the same style"""
120+
for text in ("Apple", "Banana", "Cherry"):
121+
arcade.draw_text(text, 10, 10)
122+
cherry = _label_for(label_cache, "Cherry")
123+
124+
_next_frame()
125+
for text in ("Apple", "Banana", "Date"):
126+
arcade.draw_text(text, 10, 10)
127+
assert len(label_cache) == 3
128+
assert _label_for(label_cache, "Date") is cherry
129+
130+
131+
def test_new_line_in_same_frame_gets_new_label(window, label_cache):
132+
"""A label already drawn this frame isn't taken by another line"""
133+
arcade.draw_text("First", 10, 10)
134+
arcade.draw_text("Second", 10, 30)
135+
assert len(label_cache) == 2
136+
first, second = label_cache.values()
137+
assert first is not second
138+
assert first.text == "First"
139+
140+
141+
def test_changing_text_without_frames_is_limited(window, label_cache, monkeypatch):
142+
"""
143+
Without the event loop ticking the clock, every label looks in use, so
144+
changing text makes new labels, but the cache size still limits them
145+
"""
146+
monkeypatch.setattr(text_module, "_DRAW_TEXT_CACHE_SIZE", 5)
147+
for i in range(20):
148+
arcade.draw_text(f"Count {i}", 10, 10)
149+
assert len(label_cache) == 5
150+
assert _label_for(label_cache, "Count 19").text == "Count 19"
87151

88152

89153
def test_reused_label_draws_correctly(window, label_cache):

0 commit comments

Comments
 (0)