Skip to content

Keep text color and alpha in create_text_sprite textures - #2923

Merged
pvcraven merged 1 commit into
developmentfrom
fix/text-blend
Oct 6, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
fix/text-blend

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

PR 6 of the text improvements: create_text_sprite sprites were drawn fainter than the same text drawn directly.

The problem

pyglet draws text with glBlendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA), which uses the same factors for color and alpha. Drawn into a transparent texture, that gives each pixel:

  • color = text color × alpha (pre-multiplied)
  • alpha = alpha × alpha (squared)

The sprite is then blended by that alpha again when drawn. Measured on the texture of a 30pt "Hello":

Before After
Edge pixel with 24% coverage, white text color 61, alpha 15 color 255, alpha 61
Text with alpha 128: highest alpha in the texture 64 128
Opaque background_color: edge pixels partly transparent (a² + (1 − a)) opaque

Drawn over a background, the sprite version of opaque white text had about 22% less "ink" than the same text drawn directly (how far its pixels move from the background). Text with alpha 128 had about 85% less, so it was nearly invisible.

pyglet's text groups set their own blend function on every draw, so arcade can't set a separate alpha blend from outside.

The fix

The text is drawn twice, over opaque black and over opaque white:

  • over black, each pixel is the pre-multiplied color P;
  • over white, it's P + (1 − A);
  • so A = 1 − (white − black), and the straight color is P / A.

This is exact for any number of layers (strokes, shadows, gradients, decorations), because the composite is linear in the background. The math uses Pillow: subtract, then an RGBa → RGBA conversion, which divides out the alpha. A background_color is composited under the result with Image.alpha_composite, so partly transparent backgrounds work too. The result is stored as the texture's image and written to the atlas, so it still survives atlas rebuilds.

Directly drawn text (Text.draw(), draw_text()) isn't affected; it was already correct.

Cost

create_text_sprite is called once per sprite, not every frame. Per call, alternating old and new:

Before After
"Score: 100", 16pt 1.7–2.0 ms 3.1 ms
a long 40pt title 2.6–2.7 ms 4.5–5.0 ms

Most of the extra time is the second GPU readback (about 1.1 ms). Drawing both passes into one temporary framebuffer and reading it once could save about 1 ms, but it adds complexity for a one-time cost, so I left it out.

Tests

New tests in tests/unit/text/test_text_sprite.py:

  • test_texture_matches_text_drawn_directly: the texture composited over a background matches the text drawn directly over the same background within 2. It runs for white, red, alpha 128, alpha 60, and a gradient ending at alpha 128.
  • test_opaque_text_is_opaque: alpha reaches 255, and edge pixels keep the text's own color (within 2 from 8-bit rounding).
  • test_alpha_text_keeps_its_alpha: alpha-128 text tops out at 128, not 64.
  • test_partly_transparent_background and test_opaque_background.

On development, 8 of them fail, including the opaque background one. The tests compare texture contents rather than drawn sprites. My display runs at 125% scaling, and I found that there sprites are drawn scaled by the pixel ratio even in test mode, while text isn't. That's a separate HiDPI issue, and it's probably why the same 3 render tests fail only on my machine.

Full suite on pyglet 3.0.dev11: 1566 passed, no warnings, plus those 3 local-only failures. Ruff is clean, and mypy has no new errors.

The changelog has an entry with the other create_text_sprite fixes under Unreleased → Fixes.

🤖 Generated with Claude Code

pyglet draws text with glBlendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA) for
color and alpha alike. Over a transparent texture that squares the
alpha and multiplies the color by it, so the sprite's alpha was
applied twice when drawn: edges came out darker and thinner, text with
partial alpha was nearly invisible, and edges were partly transparent
even with an opaque background_color.

Draw the text over black and over white instead. Over black each pixel
is the color times the alpha, and over white it's lighter by
(1 - alpha), which gives the exact alpha and color for any number of
layers. The background color is composited under the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit e3645ae into development Oct 6, 2026
7 checks passed
@pvcraven
pvcraven deleted the fix/text-blend branch October 6, 2026 16:20
pvcraven pushed a commit that referenced this pull request Oct 8, 2026
- Python 3.9 -> 3.10 in the install, SBC and contributing guides, and a
  release checklist note listing everywhere the minimum appears (#1302).
- ShapeElementList: link pyglet.shapes, "in a back" -> "in a batch"
  (#2608).
- gui/1_layouts example: use the bundled Liberation Sans instead of a
  CSS-style list of fonts most systems don't have (#2743).
- create_text_sprite: drop the stale warning that the sprite draws as a
  black box with another texture atlas; text sprites keep their image
  since #2923 (#1883).
- performance tips: "due to since" typo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pvcraven pushed a commit that referenced this pull request Oct 8, 2026
- Python 3.9 -> 3.10 in the install, SBC and contributing guides, and a
  release checklist note listing everywhere the minimum appears (#1302).
- ShapeElementList: link pyglet.shapes, "in a back" -> "in a batch"
  (#2608).
- gui/1_layouts example: use the bundled Liberation Sans instead of a
  CSS-style list of fonts most systems don't have (#2743).
- create_text_sprite: drop the stale warning that the sprite draws as a
  black box with another texture atlas; text sprites keep their image
  since #2923 (#1883).
- performance tips: "due to since" typo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pvcraven pushed a commit that referenced this pull request Oct 8, 2026
- Python 3.9 -> 3.10 in the install, SBC and contributing guides, and a
  release checklist note listing everywhere the minimum appears (#1302).
- ShapeElementList: link pyglet.shapes, "in a back" -> "in a batch"
  (#2608).
- gui/1_layouts example: use the bundled Liberation Sans instead of a
  CSS-style list of fonts most systems don't have (#2743).
- create_text_sprite: drop the stale warning that the sprite draws as a
  black box with another texture atlas; text sprites keep their image
  since #2923 (#1883).
- performance tips: "due to since" typo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pvcraven pushed a commit that referenced this pull request Oct 8, 2026
- Python 3.9 -> 3.10 in the install, SBC and contributing guides, and a
  release checklist note listing everywhere the minimum appears (#1302).
- ShapeElementList: link pyglet.shapes, "in a back" -> "in a batch"
  (#2608).
- gui/1_layouts example: use the bundled Liberation Sans instead of a
  CSS-style list of fonts most systems don't have (#2743).
- create_text_sprite: drop the stale warning that the sprite draws as a
  black box with another texture atlas; text sprites keep their image
  since #2923 (#1883).
- performance tips: "due to since" typo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pvcraven added a commit that referenced this pull request Oct 8, 2026
- Python 3.9 -> 3.10 in the install, SBC and contributing guides, and a
  release checklist note listing everywhere the minimum appears (#1302).
- ShapeElementList: link pyglet.shapes, "in a back" -> "in a batch"
  (#2608).
- gui/1_layouts example: use the bundled Liberation Sans instead of a
  CSS-style list of fonts most systems don't have (#2743).
- create_text_sprite: drop the stale warning that the sprite draws as a
  black box with another texture atlas; text sprites keep their image
  since #2923 (#1883).
- performance tips: "due to since" typo.

Co-authored-by: Paul V Craven <paul.craven@optimizley.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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