Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 19 additions & 15 deletions arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1172,35 +1175,35 @@ 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)
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:
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)
Expand Down Expand Up @@ -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
Expand Down
76 changes: 70 additions & 6 deletions tests/unit/text/test_draw_text_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
Loading