diff --git a/CHANGELOG.md b/CHANGELOG.md index f97f28fc2..fbe869b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - 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). +- 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. - 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. - 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. diff --git a/arcade/text.py b/arcade/text.py index c4c5daa88..7569eea62 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -19,6 +19,7 @@ from pyglet.text import LinearGradient import arcade +from arcade.clock import GLOBAL_CLOCK from arcade.exceptions import NoArcadeWindowError, PerformanceWarning, warning from arcade.resources import resolve from arcade.texture_atlas import TextureAtlasBase @@ -370,6 +371,8 @@ def __init__( # resolved name (e.g. "arial" for ("calibri", "arial")) self._requested_font_name = _normalize_font_name(font_name) self._resolved_font_name: str | None = None + # The clock tick this text was last drawn by draw_text, if it was + self._draw_text_tick = -1 self._initialized = False try: @@ -1172,20 +1175,22 @@ def draw_over(background: RGBA255) -> PIL.Image.Image: ) -# How many labels draw_text keeps for reuse, in total and per style +# How many labels draw_text keeps for reuse _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. + Returns the label already showing this text. Otherwise, returns the + style's least recently used label, changed to show this text, if it + wasn't drawn yet this frame: it belonged to a line whose text changed, + or that isn't drawn anymore. Returns None if a new label should be made. + + Lines are usually drawn in the same order every frame, so a line whose + text keeps changing reuses its own label from the previous frame, and + lines that don't change keep theirs, however many there are. """ key = (style, text) label = label_cache.get(key) @@ -1193,14 +1198,12 @@ def _get_draw_text_label(label_cache, style: tuple, text: str) -> "Text | 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: + oldest = next((cached for cached in label_cache if cached[0] == style), None) + if oldest is None: + return None + # Frames are counted by the clock's ticks. If the style's least recently + # used label was drawn this frame, they all were, so this is a new line. + if label_cache[oldest]._draw_text_tick == GLOBAL_CLOCK.ticks: return None label = label_cache.pop(oldest) @@ -1451,6 +1454,7 @@ def draw_text( # forever, for example when animating font_size or the text if len(label_cache) > _DRAW_TEXT_CACHE_SIZE: label_cache.popitem(last=False) + label._draw_text_tick = GLOBAL_CLOCK.ticks if label.x != x or label.y != y or label.z != z: label.position = x, y, z # type: ignore diff --git a/tests/unit/text/test_draw_text_cache.py b/tests/unit/text/test_draw_text_cache.py index 76d4e6318..b80c00374 100644 --- a/tests/unit/text/test_draw_text_cache.py +++ b/tests/unit/text/test_draw_text_cache.py @@ -4,6 +4,7 @@ import arcade from arcade import text as text_module +from arcade.clock import GLOBAL_CLOCK # These tests call draw_text on purpose, which warns that it's slow pytestmark = pytest.mark.filterwarnings("ignore::arcade.exceptions.PerformanceWarning") @@ -70,20 +71,83 @@ def _draw_hud(changing_text): arcade.draw_text(line, 10, 10 + i * 24, arcade.color.WHITE, 16) +def _next_frame(): + """The event loop ticks the clock once per frame""" + GLOBAL_CLOCK.tick(1 / 60) + + +def _label_for(label_cache, text): + return next(label for key, label in label_cache.items() if key[1] == text) + + 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()} + labels = {key[1]: label for key, label in label_cache.items()} for frame in range(1, 30): + _next_frame() _draw_hud(f"Time: {frame}") # The unchanging lines still use their original labels - for text, label in static_labels.items(): + for text, label in 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) + assert _label_for(label_cache, text) is label + # The changing line keeps reusing its own label + assert len(label_cache) == 5 + assert _label_for(label_cache, "Time: 29") is labels["Time: 0"] + + +@pytest.mark.parametrize("line_count", [9, 30]) +def test_many_same_style_lines_keep_their_labels(window, label_cache, line_count): + """There's no limit on lines per style: more than 8 used to take each other's labels""" + + def draw_lines(): + for i in range(line_count): + arcade.draw_text(f"Line {i}", 10, 10 + i * 18) + + draw_lines() + labels = dict(label_cache) + for _ in range(5): + _next_frame() + draw_lines() + assert len(label_cache) == line_count + for key, label in labels.items(): + assert label_cache[key] is label + + +def test_line_that_stops_frees_its_label(window, label_cache): + """A label not drawn in a frame is reused for a new line in the same style""" + for text in ("Apple", "Banana", "Cherry"): + arcade.draw_text(text, 10, 10) + cherry = _label_for(label_cache, "Cherry") + + _next_frame() + for text in ("Apple", "Banana", "Date"): + arcade.draw_text(text, 10, 10) + assert len(label_cache) == 3 + assert _label_for(label_cache, "Date") is cherry + + +def test_new_line_in_same_frame_gets_new_label(window, label_cache): + """A label already drawn this frame isn't taken by another line""" + arcade.draw_text("First", 10, 10) + arcade.draw_text("Second", 10, 30) + assert len(label_cache) == 2 + first, second = label_cache.values() + assert first is not second + assert first.text == "First" + + +def test_changing_text_without_frames_is_limited(window, label_cache, monkeypatch): + """ + Without the event loop ticking the clock, every label looks in use, so + changing text makes new labels, but the cache size still limits them + """ + monkeypatch.setattr(text_module, "_DRAW_TEXT_CACHE_SIZE", 5) + for i in range(20): + arcade.draw_text(f"Count {i}", 10, 10) + assert len(label_cache) == 5 + assert _label_for(label_cache, "Count 19").text == "Count 19" def test_reused_label_draws_correctly(window, label_cache):