From 0fa17fe81da3e6703b8a9438cc16aaf1cee4ebfa Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Tue, 6 Oct 2026 10:46:46 -0500 Subject: [PATCH] Keep text color and alpha in create_text_sprite textures 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 --- CHANGELOG.md | 1 + arcade/text.py | 36 +++++++++--- tests/unit/text/test_text_sprite.py | 91 +++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d837b1113..718ea84df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - The sprite was placed too high. It now covers the same area as an `arcade.Text` with the same arguments at the same position. - 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. + - Text sprites were drawn fainter than the same text drawn directly. Drawing the text into a transparent texture squared its alpha and multiplied its color by the alpha, so the sprite's alpha was applied twice: anti-aliased edges came out darker and thinner, text with partial alpha (for example `(255, 255, 255, 128)`) was nearly invisible, and even with an opaque `background_color` the edges were partly transparent. The texture now has the text's own color and alpha. - 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. - Fixed `arcade.Text` style properties: diff --git a/arcade/text.py b/arcade/text.py index 452c50700..a86a4d7de 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -8,6 +8,8 @@ from typing import Any from uuid import uuid4 +import PIL.Image +import PIL.ImageChops import pyglet from pyglet.enums import Style, Weight @@ -20,7 +22,7 @@ from arcade.exceptions import NoArcadeWindowError, PerformanceWarning, warning from arcade.resources import resolve from arcade.texture_atlas import TextureAtlasBase -from arcade.types import Color, Point, RGBOrA255 +from arcade.types import RGBA255, Color, Point, RGBOrA255 from arcade.types.rect import LRBT, Rect __all__ = ["load_font", "Text", "TextPool", "create_text_sprite", "draw_text"] @@ -1066,7 +1068,8 @@ def create_text_sprite( Internally this creates a Text object and an empty texture. It then uses either the provided texture atlas, or gets the default one, and draws the Text object into the - texture atlas. + texture atlas. The texture has the same colors and transparency as the text, so the + sprite looks the same as the text drawn directly. It then creates a sprite referencing the newly created texture, and positions it accordingly, and that is final result that is returned from the function. @@ -1134,13 +1137,32 @@ def create_text_sprite( if not texture_atlas: texture_atlas = arcade.get_window().ctx.default_atlas texture_atlas.add(texture) - with texture_atlas.render_into(texture) as fbo: - fbo.clear(color=background_color or arcade.color.TRANSPARENT_BLACK) - text_object.draw() - # Keep a copy of the pixels in the texture's image. The atlas redraws + + # Drawing text over a transparent background would multiply the color + # by the text's alpha and square the alpha, since pyglet blends alpha + # like color, so the sprite would be drawn too faint. Instead, draw it + # over black and over white: over black each pixel is the color times + # the alpha, and over white it's lighter by (1 - alpha). + def draw_over(background: RGBA255) -> PIL.Image.Image: + with texture_atlas.render_into(texture) as fbo: + fbo.clear(color=background) + text_object.draw() + return texture_atlas.read_texture_image_from_atlas(texture).convert("RGB") + + over_black = draw_over(arcade.color.BLACK) + over_white = draw_over(arcade.color.WHITE) + alpha = PIL.ImageChops.invert(PIL.ImageChops.subtract(over_white, over_black).convert("L")) + # "RGBa" is premultiplied RGBA, so converting it divides out the alpha + image = PIL.Image.merge("RGBa", (*over_black.split(), alpha)).convert("RGBA") + if background_color: + background = PIL.Image.new("RGBA", size, Color.from_iterable(background_color)) + image = PIL.Image.alpha_composite(background, image) + + # Store the result in the texture's image too. The atlas redraws # textures from their images when it rebuilds itself, which would # otherwise leave the sprite blank. - texture_atlas.update_texture_image_from_atlas(texture) + texture.image_data.image = image + texture_atlas.update_texture_image(texture) # Place the sprite where the Text object was drawn return arcade.Sprite( diff --git a/tests/unit/text/test_text_sprite.py b/tests/unit/text/test_text_sprite.py index 2742df1d3..5c6b3209f 100644 --- a/tests/unit/text/test_text_sprite.py +++ b/tests/unit/text/test_text_sprite.py @@ -1,4 +1,8 @@ +import PIL.Image +import PIL.ImageChops import pytest +from pyglet.text import LinearGradient + import arcade @@ -96,3 +100,90 @@ def test_empty_text(window): assert sprite.texture.height >= 1 image = window.ctx.default_atlas.read_texture_image_from_atlas(sprite.texture) assert _ink(image) == 0 + + +BACKGROUND = (40, 80, 160, 255) + + +def _pixels(image) -> list[tuple[int, int, int, int]]: + data = image.convert("RGBA").tobytes() + return [tuple(data[i : i + 4]) for i in range(0, len(data), 4)] + + +def _direct_over_background(window, text: arcade.Text, size): + """Draw a Text directly over an opaque background, the size of its sprite""" + text.x = -text.left + text.y = -text.bottom + ctx = window.ctx + fbo = ctx.framebuffer(color_attachments=[ctx.texture(size, components=4)]) + with fbo.activate(): + fbo.clear(color=BACKGROUND) + text.draw() + image = PIL.Image.frombytes("RGBA", size, fbo.read(components=4)) + # Atlas images are stored upside down compared to framebuffer reads + return image.transpose(PIL.Image.Transpose.FLIP_TOP_BOTTOM) + + +@pytest.mark.parametrize( + "color", + [ + arcade.color.WHITE, + (255, 50, 50, 255), + (255, 255, 255, 128), + (50, 255, 50, 60), + LinearGradient((255, 0, 0, 255), (0, 0, 255, 128)), + ], +) +def test_texture_matches_text_drawn_directly(window, color): + """ + The sprite's texture, drawn over a background, looks like the text drawn + directly. Text with partial alpha used to get its alpha squared and its + color multiplied by its alpha, so sprites were drawn too faint. + """ + kwargs = dict(font_name="Liberation Sans", font_size=30, color=color) + sprite = arcade.create_text_sprite("Hello World", **kwargs) + image = sprite.texture.image + background = PIL.Image.new("RGBA", image.size, BACKGROUND) + via_sprite = PIL.Image.alpha_composite(background, image) + direct = _direct_over_background(window, arcade.Text("Hello World", 0, 0, **kwargs), image.size) + + diff = PIL.ImageChops.difference(via_sprite.convert("RGB"), direct.convert("RGB")) + assert max(high for _low, high in diff.getextrema()) <= 2 + + +def test_opaque_text_is_opaque(window): + sprite = arcade.create_text_sprite("Hello", font_name="Liberation Sans", font_size=30) + alpha = sprite.texture.image.getchannel("A") + assert alpha.getextrema() == (0, 255) + # Edge pixels keep the text's own color, not a darker one. Dividing out + # an 8 bit alpha can be off by a little. + colors = {pixel[:3] for pixel in _pixels(sprite.texture.image) if pixel[3] > 0} + assert min(min(color) for color in colors) >= 253 + + +def test_alpha_text_keeps_its_alpha(window): + sprite = arcade.create_text_sprite( + "Hello", font_name="Liberation Sans", font_size=30, color=(255, 255, 255, 128) + ) + # Used to be 64: the alpha was squared + assert sprite.texture.image.getchannel("A").getextrema()[1] in (127, 128, 129) + + +def test_partly_transparent_background(window): + sprite = arcade.create_text_sprite( + "Hello", font_name="Liberation Sans", font_size=30, background_color=(0, 0, 0, 128) + ) + image = sprite.texture.image + # Away from the text, the background is unchanged + assert image.getpixel((0, 0)) == (0, 0, 0, 128) + # Over the text it's covered by opaque white text + assert (255, 255, 255, 255) in set(_pixels(image)) + + +def test_opaque_background(window): + sprite = arcade.create_text_sprite( + "Hello", font_name="Liberation Sans", font_size=30, background_color=arcade.color.BLUE + ) + image = sprite.texture.image + assert image.getchannel("A").getextrema() == (255, 255) + assert image.getpixel((0, 0)) == (0, 0, 255, 255)