From 072877d2cf125923e1cc55aafbfd2dfccd70c17b Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Mon, 5 Oct 2026 16:53:14 -0500 Subject: [PATCH] Fix and speed up the draw_text label cache draw_text reuses arcade.Text labels from ctx.label_cache: - The cache key ignored multiline, so a multiline call and an otherwise equal single-line call shared a label and one drew wrong. The key is now a tuple (a concatenated string could also collide, e.g. font size 1 with font "2arial" and size 12 with "arial") and includes multiline. - rotation was in the key although it's updated on reused labels, so animating it added a label every frame, forever. It's no longer in the key. - The cache had no limit. It's now an OrderedDict of at most 256 labels, forgetting the least recently used. - Remove ctx.flush() after every call. Its comment said it stopped a reused label's buffers changing while an earlier draw used them, but glFlush doesn't wait for anything, and pyglet's default buffers are updated with glBufferSubData, which OpenGL keeps in order. Drawing one reused label six times a frame with different text still matches separate Text objects pixel for pixel. Unchanged calls: 76 -> 15 us. - Labels are now found by style and text, keeping up to 8 per style, so several lines in the same style each keep their own label instead of one label being re-laid out for every call. When a style has 8, the least recently used is reused, so a line whose text keeps changing reuses its own old label. 5 static HUD lines: 1.24 -> 0.08 ms/frame. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + arcade/context.py | 9 +- arcade/text.py | 79 ++++++++++++++---- tests/unit/text/test_draw_text_cache.py | 105 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 tests/unit/text/test_draw_text_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9990e3083..0f9d9b839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - `anchor_x="center"` cut off the left half of the text, and `anchor_x="right"` made an empty sprite. - The sprite was placed too high. It now covers the same area as an `arcade.Text` with the same arguments at the same position. - An empty string raised `ValueError`. It now makes a transparent sprite, as documented. +- Fixed `arcade.draw_text()` cache problems: a multiline call and an otherwise equal single-line call shared one label and drew wrong, and animating `rotation` or `font_size` added a cached label every frame, forever. The cache now holds at most 256 labels, forgetting the least recently used. ### New Features - 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. +### Misc Changes +- 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). + ## 4.0.0.dev8 ### Breaking Changes diff --git a/arcade/context.py b/arcade/context.py index 5d8c53d60..c088fb5a6 100644 --- a/arcade/context.py +++ b/arcade/context.py @@ -4,7 +4,8 @@ """ from array import array -from collections.abc import Iterable, Sequence +from collections import OrderedDict +from collections.abc import Hashable, Iterable, Sequence from pathlib import Path from typing import Any @@ -323,9 +324,9 @@ def __init__( self.geometry_empty: Geometry = self.geometry() self._atlas: TextureAtlasBase | None = None - # Global labels we modify in `arcade.draw_text`. - # These multiple labels with different configurations are stored - self.label_cache: dict[str, arcade.Text] = {} + # Labels reused by `arcade.draw_text`, keyed by the settings that are + # expensive to change and the text, least recently used first + self.label_cache: OrderedDict[Hashable, arcade.Text] = OrderedDict() # self.active_program = None if gl_api != "webgl": diff --git a/arcade/text.py b/arcade/text.py index 88b083c6f..3f0813f71 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -948,6 +948,43 @@ def create_text_sprite( ) +# How many labels draw_text keeps for reuse, in total and per style +_DRAW_TEXT_CACHE_SIZE = 256 +_DRAW_TEXT_LABELS_PER_STYLE = 8 + + +def _get_draw_text_label(label_cache, style: tuple, text: str) -> "Text | None": + """ + Find a label in draw_text's cache for drawing ``text`` in a style. + + Returns the label already showing this text, or the style's least + recently used label (changed to show this text) if the style already + has the most labels it may. Returns None if a new label should be made. + Labels used every frame stay recent, so a line whose text keeps + changing reuses its own previous label rather than another line's. + """ + key = (style, text) + label = label_cache.get(key) + if label is not None: + label_cache.move_to_end(key) + return label + + count = 0 + oldest = None + for cached_style, cached_text in label_cache: + if cached_style == style: + if oldest is None: + oldest = (cached_style, cached_text) + count += 1 + if oldest is None or count < _DRAW_TEXT_LABELS_PER_STYLE: + return None + + label = label_cache.pop(oldest) + label.text = text + label_cache[key] = label + return label + + @warning( message=( "draw_text is an extremely slow function for displaying text. " @@ -1135,12 +1172,6 @@ def draw_text( """ # See : https://github.com/pyglet/pyglet/blob/ff30eadc2942553c9de96d6ce564ad1bc3128fb4/pyglet/text/__init__.py#L401 - color = Color.from_iterable(color) - # Cache the states that are expensive to change - key = f"{font_size}{font_name}{bold}{italic}{anchor_x}{anchor_y}{align}{width}{rotation}" - ctx = arcade.get_window().ctx - label = ctx.label_cache.get(key) - if align not in ("left", "center", "right"): raise ValueError("The 'align' parameter must be equal to 'left', 'right', or 'center'.") @@ -1150,11 +1181,30 @@ def draw_text( f"but got {width!r}." ) - if not label: + color = Color.from_iterable(color) + text = str(text) + # Reuse labels, keyed by the settings that are expensive to change and + # the text. Position, color, and rotation are cheap to update. + style = ( + font_size, + tuple(font_name) if isinstance(font_name, list) else font_name, + bold, + italic, + anchor_x, + anchor_y, + align, + width, + multiline, + ) + ctx = arcade.get_window().ctx + label_cache = ctx.label_cache + label = _get_draw_text_label(label_cache, style, text) + + if label is None: adjusted_font = _attempt_font_name_resolution(font_name) label = arcade.Text( - text=str(text), + text=text, x=x, y=y, z=z, @@ -1170,11 +1220,12 @@ def draw_text( multiline=multiline, rotation=rotation, ) - ctx.label_cache[key] = label + label_cache[style, text] = label + # Forget the least recently used label, so the cache can't grow + # forever, for example when animating font_size or the text + if len(label_cache) > _DRAW_TEXT_CACHE_SIZE: + label_cache.popitem(last=False) - # These updates are quite expensive - if label.text != text: - label.text = str(text) if label.x != x or label.y != y or label.z != z: label.position = x, y, z # type: ignore if label.color != color: @@ -1183,7 +1234,3 @@ def draw_text( label.rotation = rotation label.draw() - # This is absolutely necessary to prevent the vertex buffers - # to be altered while another one is drawing. If the same cached - # label is used multiple times in a single frame it's a disaster. - ctx.flush() diff --git a/tests/unit/text/test_draw_text_cache.py b/tests/unit/text/test_draw_text_cache.py new file mode 100644 index 000000000..76d4e6318 --- /dev/null +++ b/tests/unit/text/test_draw_text_cache.py @@ -0,0 +1,105 @@ +"""Tests for the labels arcade.draw_text keeps and reuses.""" + +import pytest + +import arcade +from arcade import text as text_module + +# These tests call draw_text on purpose, which warns that it's slow +pytestmark = pytest.mark.filterwarnings("ignore::arcade.exceptions.PerformanceWarning") + + +@pytest.fixture +def label_cache(window): + cache = window.ctx.label_cache + cache.clear() + yield cache + cache.clear() + + +def _render(window, draw): + """Draw into an offscreen framebuffer and return its pixels""" + ctx = window.ctx + fbo = ctx.framebuffer(color_attachments=[ctx.texture((400, 300), components=4)]) + with fbo.activate(): + window.default_camera.use() + fbo.clear(color=(0, 0, 0, 0)) + draw() + return fbo.read(components=4) + + +def test_multiline_has_its_own_label(window, label_cache): + """A multiline call and an otherwise equal single line call don't share a label""" + text = "A long line of text that wraps when multiline" + arcade.draw_text(text, 10, 10, width=200, multiline=True) + arcade.draw_text(text, 10, 10, width=200, multiline=False) + assert len(label_cache) == 2 + assert {label.multiline for label in label_cache.values()} == {True, False} + + +def test_rotation_reuses_label(window, label_cache): + """Animating rotation updates one label instead of adding one per angle""" + for frame in range(100): + arcade.draw_text("Spin", 100, 100, rotation=frame * 0.7) + assert len(label_cache) == 1 + (label,) = label_cache.values() + assert label.rotation == pytest.approx(99 * 0.7) + + +def test_cache_size_is_limited(window, label_cache, monkeypatch): + """The cache forgets the least recently used labels instead of growing forever""" + # A small limit, since each new font size is slow to load + limit = 5 + monkeypatch.setattr(text_module, "_DRAW_TEXT_CACHE_SIZE", limit) + for frame in range(limit + 3): + arcade.draw_text("Pulse", 100, 100, font_size=10 + frame) + assert len(label_cache) == limit + # The newest are kept and the oldest forgotten + font_sizes = sorted(label.font_size for label in label_cache.values()) + assert font_sizes == [13, 14, 15, 16, 17] + + # Using a label makes it the most recent, so it's kept + arcade.draw_text("Pulse", 100, 100, font_size=13) + arcade.draw_text("Pulse", 100, 100, font_size=18) + assert sorted(label.font_size for label in label_cache.values()) == [13, 15, 16, 17, 18] + + +def _draw_hud(changing_text): + lines = ["Lives: 3", "Level: 7", changing_text, "Coins: 42", "Best: 9001"] + for i, line in enumerate(lines): + arcade.draw_text(line, 10, 10 + i * 24, arcade.color.WHITE, 16) + + +def test_same_style_lines_keep_their_labels(window, label_cache): + """Several lines in the same style each keep a label, even if one keeps changing""" + _draw_hud("Time: 0") + static_labels = {key[1]: label for key, label in label_cache.items()} + for frame in range(1, 30): + _draw_hud(f"Time: {frame}") + + # The unchanging lines still use their original labels + for text, label in static_labels.items(): + if not text.startswith("Time"): + assert label_cache[next(key for key in label_cache if key[1] == text)] is label + # The changing line doesn't add a label every frame + assert len(label_cache) <= text_module._DRAW_TEXT_LABELS_PER_STYLE + assert any(key[1] == "Time: 29" for key in label_cache) + + +def test_reused_label_draws_correctly(window, label_cache): + """Drawing many texts in one style matches drawing separate Text objects""" + lines = [(f"Line {i}: {'abcdefghij'[i:]}", 20 + i * 7, 20 + i * 40) for i in range(10)] + texts = [arcade.Text(text, x, y, arcade.color.WHITE, 16) for text, x, y in lines] + + def via_draw_text(): + for text, x, y in lines: + arcade.draw_text(text, x, y, arcade.color.WHITE, 16) + + def via_text_objects(): + for text in texts: + text.draw() + + for _ in range(3): + drawn = _render(window, via_draw_text) + assert drawn == _render(window, via_text_objects) + assert any(drawn)