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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- An empty string raised `ValueError`. It now makes a transparent sprite, as documented.
- Text sprites went blank when the texture atlas rebuilt itself, which happens when it fills up after some textures were freed. The atlas redraws textures from their images, and a text sprite's image was empty. `sprite.texture.image` now also contains the text.
- Fixed an `AttributeError` being printed as "Exception ignored in `__del__`" when creating an OpenGL texture, framebuffer, buffer, program, compute shader, texture array, or vertex array failed, for example with an invalid number of texture components.
- 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
Expand Down
9 changes: 5 additions & 4 deletions arcade/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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":
Expand Down
79 changes: 63 additions & 16 deletions arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,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. "
Expand Down Expand Up @@ -1139,12 +1176,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'.")

Expand All @@ -1154,11 +1185,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,
Expand All @@ -1174,11 +1224,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:
Expand All @@ -1187,7 +1238,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()
105 changes: 105 additions & 0 deletions tests/unit/text/test_draw_text_cache.py
Original file line number Diff line number Diff line change
@@ -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)
Loading