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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
## Unreleased

### Fixes
- Fixed `DefaultTextureAtlas` losing track of textures that share an image, such as every `SpriteSolidColor` size, which share one white image:
- Once the first texture with a given image was freed while another was still in use, resizing the atlas, which happens when it fills up, raised `RuntimeError: Empty set in unique textures` and left the atlas half-resized.
- If the last texture's finalizer ran before Python had cleared it from the atlas's records, the atlas kept the texture's name after freeing its slot. Adding another texture with that image then raised `Texture '...' not found in UVData`. This showed up as an occasional CI failure.
- Fixed `Sprite.remove_from_sprite_lists()` raising `AttributeError` for a sprite registered with `register_physics_engine()` on an engine without a `remove_sprite()` method, such as `PhysicsEngineSimple` or `PhysicsEnginePlatformer`. It now only calls `remove_sprite()` on engines that have it.
- Fixed a window sized with `arcade.get_display_size()` being bigger than the screen with display scaling, such as Windows set to 125% or 150%. Arcade scales windows up by the display scale, but `get_display_size()` returned the screen's size in pixels, so the window was scaled up twice. It now returns the size in window units on Windows and Linux: at 125%, a 1920x1200 screen gives 1536x960, which fills it exactly. On macOS and the web it's unchanged, as they don't scale windows ([#2791](https://github.com/pythonarcade/arcade/issues/2791)).
- Fixed `arcade.future.background.ParallaxGroup` raising `ZeroDivisionError` the first time its offset changed after a layer was given a depth of 0. Setting a depth of 0 now raises a `ValueError` right away, which says to use `float("inf")` for a layer that doesn't scroll ([#1551](https://github.com/pythonarcade/arcade/issues/1551)).
Expand Down
22 changes: 14 additions & 8 deletions arcade/texture_atlas/atlas_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,13 @@ def unique_textures(self) -> list[Texture]:
# Entry was GCed during iteration
if tex_set is None:
continue
# Corrupt data
if len(tex_set) == 0:
raise RuntimeError("Empty set in unique textures")
# The last texture with this name is dying and its finalizer
# hasn't removed the name yet
texture = next(iter(tex_set), None)
if texture is None:
continue

textures.append(next(iter(tex_set)))
textures.append(texture)

return textures

Expand Down Expand Up @@ -298,6 +300,9 @@ def _add(self, texture: Texture, create_finalizer=True) -> tuple[int, AtlasRegio
# Add add references to the duplicate texture
if not self.has_texture(texture):
self._add_texture_ref(texture, create_finalizer=create_finalizer)
# Track every texture sharing this name, so the set is only
# empty when none of them are alive
self._unique_textures[texture.atlas_name].add(texture)
slot = self._texture_uvs.get_slot_or_raise(texture.atlas_name)
region = self.get_texture_region_info(texture.atlas_name)
return slot, region
Expand Down Expand Up @@ -550,10 +555,11 @@ def _remove_texture_by_identifiers(self, atlas_name: str, hash: str):

# Remove the unique texture if ref counter reaches 0
if self._unique_texture_ref_count.dec_ref_by_atlas_name(atlas_name) == 0:
# Remove the unique texture key to signal we don't have any more
refs = self._unique_textures[atlas_name]
if len(refs) == 0:
del self._unique_textures[atlas_name]
# No texture with this name is left. The set may still hold the
# dying texture if this finalizer ran before the set's own weak
# reference callback, so remove the name regardless. Otherwise a
# later texture with the same name would find it with no slot.
self._unique_textures.pop(atlas_name, None)

# Reclaim region and uv slot
del self._texture_regions[atlas_name]
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/atlas/test_shared_textures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
Separate textures can share an image and atlas name, such as every
SpriteSolidColor size. The atlas has to keep track of all of them.
"""

import gc

import PIL.Image

from arcade import DefaultTextureAtlas, Texture
from arcade.texture import ImageData


def _shared_textures(count):
data = ImageData(PIL.Image.new("RGBA", (8, 8), (255, 255, 255, 255)))
textures = [Texture(data) for _ in range(count)]
assert len({texture.atlas_name for texture in textures}) == 1
return textures


def test_resize_after_first_shared_texture_dies(ctx):
# Used to raise "Empty set in unique textures"
first, second = _shared_textures(2)
atlas = DefaultTextureAtlas((32, 32))
atlas.add(first)
atlas.add(second)
name = second.atlas_name
del first
gc.collect()

atlas.resize((64, 64))

# The surviving texture's coordinates follow its image to the new position
texture_region = atlas.get_texture_region_info(name)
image_region = atlas.get_image_region_info(second.image_data.hash)
assert texture_region.texture_coordinates == image_region.texture_coordinates


def test_name_removed_when_last_shared_texture_dies(ctx):
textures = _shared_textures(3)
atlas = DefaultTextureAtlas((32, 32))
for texture in textures:
atlas.add(texture)
del texture
name = textures[0].atlas_name

textures.pop(0)
gc.collect()
assert atlas.has_unique_texture(textures[0])
textures.clear()
gc.collect()
assert name not in atlas._unique_textures


def test_name_removed_even_if_set_still_holds_texture(ctx):
# A finalizer can run before the set's own weak reference callback removes
# the dying texture. Simulate that with a texture the atlas doesn't count.
(texture,) = _shared_textures(1)
(not_counted,) = _shared_textures(1)
atlas = DefaultTextureAtlas((32, 32))
atlas.add(texture)
name = texture.atlas_name
atlas._unique_textures[name].add(not_counted)

del texture
gc.collect()
assert name not in atlas._unique_textures

# Used to raise "Texture '...' not found in UVData"
(later,) = _shared_textures(1)
atlas.add(later)
assert atlas.get_texture_id(later) is not None
Loading