Skip to content

Skip unneeded Text layouts for unchanged values and with blocks - #2921

Merged
pvcraven merged 1 commit into
developmentfrom
perf/text-setters
Oct 6, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
perf/text-setters

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

PR 4 of the text improvements: arcade.Text no longer lays out its text again when nothing changed.

The problem

  1. Setters didn't check for unchanged values. Only text, x, y and z did. Setting font_size, font_name, bold, italic, width, height, align, multiline or tracking to the value it already had ran a full pyglet layout, about 165–195 µs each. Code that sets properties every frame, like TextPool or a UI refresh, paid that cost even when nothing changed.
  2. with text: always laid out the text. pyglet's end_update() always runs a full layout, so even an empty block cost 165 µs. TextPool.get() wraps every call in with cached_text:, so each line in a pool was laid out again every frame.

The fix

  • Every setter returns early if the value is unchanged. font_name compares with the last requested name, because the label holds the resolved name (e.g. "arial" for ("calibri", "arial")). It also checks that the label's font wasn't changed directly, so setting the name again still works in that case.
  • with text: begins a pyglet update only when needed. That happens at the first change in the block that needs a new layout: text, font, size, weight, style, width, height, align, multiline, tracking, group, or a gradient color. Position, solid color, rotation, anchors and visibility apply right away, as they already did in pyglet. An empty or unchanged block now costs nothing, and several changes are still laid out once. Nested blocks work, and an exception inside a block still ends the update.
  • Italic workaround: its comment now links pyglet issue Label.italic, Label.font setter, and IncrementalTextLayout still use the old "italic" style key, so italic changes don't render pyglet/pyglet#1508.

Measurements

µs, pyglet 3.0.dev11, best of 5:

Case Before After
font_size, width, align, multiline set to the same value 163–169 0.1–0.4
bold, italic set to the same value 195 0.4
font_name set to the same value 172 3.5
color set to the same value 10.8 0.3
position set to the same value 9.1 0.2
empty with text: block 165 0.0
TextPool, 5 unchanged lines, per frame 764 6
TextPool, 4 unchanged lines + 1 changing, per frame 949 165

A real change costs the same as before: one layout.

UILabel.update_font() also uses with label:, so it benefits too.

Tests

New tests/unit/text/test_text_updates.py (30 tests). It counts the label's full layouts by wrapping its _update and counting only the calls that actually lay out.

  • Unchanged values: setting each of 19 properties to its current value does no layout. This also holds for an equal gradient and for a font name given as a list instead of a tuple.
  • Font name: setting the name again works after the label's font was changed directly.
  • Empty blocks: an empty with text: block, or one that only sets unchanged values, does no layout.
  • Cheap changes: position, color and rotation in a block apply without a layout.
  • One layout per block: six layout changes in one block give exactly one layout, and nested blocks also give one.
  • Exceptions: an exception inside a block still ends the update.
  • Outside a block: changes still lay out right away.
  • Pixels: text changed in a block draws pixel-identical to a new Text with the same settings.
  • TextPool: unchanged get() calls do no layout, and a changed one does one.

On development, 16 of the new tests fail. The other 14 check behavior that already worked (correct drawing, nesting, exceptions, cheap setters) and guard it.

Full suite on pyglet 3.0.dev11: 1543 passed, no warnings, plus the same 3 render tests that fail only on my machine. Ruff is clean, and mypy has no new errors.

One thing I noticed: pyglet replaces a label's text by deleting it and then inserting the new text, so a text change runs two layouts. The first one is of empty text, and wrapping both in one update measured the same (about 205 µs), so I left it alone.

The changelog has an entry under Unreleased → Misc Changes.

🤖 Generated with Claude Code

- Every Text setter now returns early when the value is unchanged.
  Setting font_size, bold, italic, width, align, and similar to their
  current value used to run a full pyglet layout (about 165 us).
- `with text:` only begins a pyglet update when a change inside it
  needs a new layout. pyglet's end_update() always lays out the text
  again, so an empty or unchanged block cost a full layout, and
  TextPool.get() did this for every line, every frame.
- font_name compares with the last requested name, since the label
  holds the resolved one.
- Link the italic workaround to pyglet issue #1508.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit a4daef5 into development Oct 6, 2026
7 checks passed
@pvcraven
pvcraven deleted the perf/text-setters branch October 6, 2026 15:29
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