Skip to content

Fix and speed up the draw_text label cache - #2919

Open
pvcraven wants to merge 1 commit into
developmentfrom
perf/draw-text-cache
Open

pvcraven wants to merge 1 commit into
developmentfrom
perf/draw-text-cache

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

PR 2 of the text improvements: fixes and speeds up the label cache behind arcade.draw_text(), which reuses arcade.Text objects from ctx.label_cache.

Bugs

Problem Before After
The cache key ignored multiline. A multiline call and an otherwise equal single-line call shared a label, so one drew wrong Single-line call after a multiline one drew wrapped text (bbox (10,477,258,511) instead of (10,497,325,511)) Separate labels
rotation was in the key, even though it's updated on reused labels anyway 500 frames of animated rotation: 500 cached labels, never freed 1 label
No limit on the cache Animating font_size grew the cache forever At most 256 labels, forgetting the least recently used

The key is now a tuple. The old concatenated string could also collide, for example font size 1 with font "2arial" and size 12 with "arial".

Speed

1. Remove ctx.flush() after every call. Its comment said it stops a reused label's vertex buffers from changing while an earlier draw of that label is still using them. But:

  • glFlush only submits commands; it doesn't wait for anything, so it can't prevent that.
  • pyglet's default vertex buffers (GLAttributeBufferObject) are updated with glBufferSubData, which OpenGL applies in command order.
  • pyglet's optional persistent mapped buffers (pyglet.options.opengl_persistent_buffers) are off by default, and wouldn't be protected by glFlush either.

I tested exactly the case the comment describes: one cached label drawn six times in a frame with different text and positions. It matches six separate Text objects pixel for pixel, with and without the flush. There's also a test for this.

2. One label per line, per style. Labels are now found by style and text, with up to 8 per style. So several draw_text calls in the same style each keep their own label, instead of one label being re-laid out for every call, every frame. When a style already has 8 labels, the least recently used one is reused. Lines that don't change are used every frame and stay recent, so a line whose text keeps changing (a timer, say) reuses its own old label, not another line's.

µs per call or per frame, pyglet debug_api off:

Case Before After
Unchanged draw_text call 76 15
HUD: 5 static lines, same style 1,240 / frame 77 / frame
HUD: 5 lines, one changing every frame 1,250 / frame ~320 / frame
1 line whose text changes every frame 239–250 246–259 (same, within noise)

The single changing line is about the same: it still re-lays out every frame, and finding the label to reuse is cheap.

Trade-offs

  • Labels per style: a style can now hold up to 8 labels instead of 1. The total is still capped at 256.
  • Cycling font sizes: cycling repeatedly through more than 256 exact font sizes would keep creating labels, where the unlimited old cache would eventually have reused them. Animating with floats gives new values every frame, so the old cache never reused labels there either; it just grew.

Tests

New tests/unit/text/test_draw_text_cache.py:

  • test_multiline_has_its_own_label
  • test_rotation_reuses_label: 100 rotations, 1 label, with the latest angle.
  • test_cache_size_is_limited: with the limit set to 5 for speed, since each new font size costs about 30 ms to load in pyglet. The oldest labels are forgotten, and using a label keeps it.
  • test_same_style_lines_keep_their_labels: the static HUD lines keep the same label objects over 30 frames, and a changing line doesn't add a label every frame.
  • test_reused_label_draws_correctly: ten texts drawn with draw_text match ten Text objects pixel for pixel.

On development, the first four fail and the pixel test passes; the old code drew correctly, it was just slow.

Full suite on pyglet 3.0.dev11: 1489 passed, with the same 3 render tests failing on my machine only. Ruff is clean. mypy reports one error at text.py:461, the LinearGradient color getter, which is the same on development and will be fixed in the next text PR.

The changelog has entries under Unreleased: the bugs in Fixes, the speed-ups in Misc Changes.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant