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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 29 additions & 7 deletions arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/text/test_text_sprite.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import PIL.Image
import PIL.ImageChops
import pytest
from pyglet.text import LinearGradient

import arcade


Expand Down Expand Up @@ -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)
Loading