From 54d5cf5366c107eb78e038e59f2a5a92123a49a0 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Wed, 7 Oct 2026 12:28:35 -0500 Subject: [PATCH] Create WebGL sprite data textures in rows of 256 On WebGL, a SpriteList stores its sprite data in textures, which the shader reads as rows of 256 sprites and the grow methods resize to (256, rows). SpriteListTextureData created them as one row as wide as the capacity, so with a capacity over 256, sprites past the first 256 were read from rows that didn't exist and weren't drawn: SpriteList(capacity= 1000), clear() on a list that had grown past 256, or a lazy list with more than 256 sprites before its first draw. Create them as (256, capacity // 256). Uploads are row-major whole texture writes, so they don't change. Fixes #2909 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/sprite_list/sprite_list.py | 24 +++--- .../test_spritelist_texture_storage.py | 74 +++++++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 tests/unit/spritelist/test_spritelist_texture_storage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bba1cca3..8d7fa468e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed fast sprites passing through thin walls and platforms in `PhysicsEngineSimple` and `PhysicsEnginePlatformer`. A sprite that moved farther than its own size plus the wall's thickness in one frame jumped over it, for example a player falling fast onto a thin platform. A move longer than the sprite is now checked in steps shorter than the sprite, so it stops at the first wall in the way. Moves shorter than the sprite work exactly as before. - Fixed collision checks with `CollisionMethod.GPU` (also chosen automatically for lists of over 1500 sprites without a spatial hash) missing hit boxes that reach beyond a sprite's drawn size, such as a melee attack's reach. The GPU only knows the sprites' drawn sizes, so it now also uses the checking sprite's hit box, and each `SpriteList` keeps track of how far its sprites' hit boxes reach beyond their sizes. - Fixed drawing inside `TextureAtlas.render_into()` when the atlas had to rebuild to make room, for example to add a new texture being drawn. A full atlas with freed space rebuilds instead of growing, which moved the texture being drawn into, so the drawing landed on other textures and what was drawn before was lost. While rendering into it, the atlas now grows instead if it can, and a rebuild keeps what was drawn and moves the drawing area with the texture. +- Fixed a `SpriteList` on WebGL only drawing its first 256 sprites when its storage was created with a capacity over 256: for example `SpriteList(capacity=1000)`, `clear()` on a list that had grown past 256 sprites, or a lazy list with more than 256 sprites before it was first drawn ([#2909](https://github.com/pythonarcade/arcade/issues/2909)). ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 33da7632e..835e3adeb 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -1865,21 +1865,15 @@ def __init__(self, ctx: ArcadeContext, capacity: int, atlas: TextureAtlasBase) - self._geometry = self.ctx.spritelist_geometry_simple # Texture buffers for per-sprite data. These are looked up using gl_InstanceID - self._storage_pos_angle: Texture2D = self.ctx.texture( - size=(capacity, 1), components=4, dtype="f4" - ) - self._storage_size: Texture2D = self.ctx.texture( - size=(capacity, 1), components=2, dtype="f4" - ) - self._storage_color: Texture2D = self.ctx.texture( - size=(capacity, 1), components=4, dtype="f1" - ) - self._storage_texture_id: Texture2D = self.ctx.texture( - size=(capacity, 1), components=1, dtype="f4" - ) - self._storage_index: Texture2D = self.ctx.texture( - size=(capacity, 1), components=1, dtype="i4" - ) + # The shader reads the data as rows of 256 sprites (see lib/sprite.glsl), + # and the grow methods keep that layout, so create the textures in it. + # A single row wider than 256 would only draw the first 256 sprites. + size = (256, _align_capacity(capacity) // 256) + self._storage_pos_angle: Texture2D = self.ctx.texture(size=size, components=4, dtype="f4") + self._storage_size: Texture2D = self.ctx.texture(size=size, components=2, dtype="f4") + self._storage_color: Texture2D = self.ctx.texture(size=size, components=4, dtype="f1") + self._storage_texture_id: Texture2D = self.ctx.texture(size=size, components=1, dtype="f4") + self._storage_index: Texture2D = self.ctx.texture(size=size, components=1, dtype="i4") def write_sprite_buffers_to_gpu( self, diff --git a/tests/unit/spritelist/test_spritelist_texture_storage.py b/tests/unit/spritelist/test_spritelist_texture_storage.py new file mode 100644 index 000000000..d3511e49c --- /dev/null +++ b/tests/unit/spritelist/test_spritelist_texture_storage.py @@ -0,0 +1,74 @@ +""" +On WebGL, a SpriteList keeps its sprite data in textures instead of buffers. +These tests use that storage on desktop OpenGL, so they run in CI. + +The shader reads the textures as rows of 256 sprites. They used to be +created as a single row as wide as the capacity, so a list whose storage +was created with a capacity over 256 only drew its first 256 sprites. +""" + +import pytest + +import arcade +from arcade.sprite_list import sprite_list as sprite_list_module + + +@pytest.fixture +def texture_storage(monkeypatch): + """New SpriteLists use the WebGL storage, in textures""" + monkeypatch.setattr( + sprite_list_module, "SpriteListBufferData", sprite_list_module.SpriteListTextureData + ) + + +def _add_pixels(sprites, count): + for i in range(count): + x, y = (i % 30) * 3 + 1.5, (i // 30) * 3 + 1.5 + sprites.append(arcade.SpriteSolidColor(1, 1, center_x=x, center_y=y)) + + +def _drawn(ctx, sprites): + """How many pixels the sprites cover, one per sprite""" + fbo = ctx.framebuffer(color_attachments=[ctx.texture((100, 100), components=4)]) + with fbo.activate(): + fbo.clear() + sprites.draw() + data = fbo.read(components=4) + return sum(1 for i in range(3, len(data), 4) if data[i] > 0) + + +@pytest.mark.parametrize("capacity", [100, 256, 1000, 1024, 3000]) +def test_created_with_capacity(ctx, texture_storage, capacity): + sprites = arcade.SpriteList(capacity=capacity) + assert isinstance(sprites._data, sprite_list_module.SpriteListTextureData) + _add_pixels(sprites, 600) + assert _drawn(ctx, sprites) == 600 + + +def test_clear_after_growing(ctx, texture_storage): + """clear() keeps the grown capacity and creates the storage again""" + sprites = arcade.SpriteList() + _add_pixels(sprites, 300) + sprites.clear() + _add_pixels(sprites, 400) + assert _drawn(ctx, sprites) == 400 + + +def test_lazy_list_grown_before_first_draw(ctx, texture_storage): + """A lazy list creates its storage at the capacity it has grown to""" + sprites = arcade.SpriteList(lazy=True) + _add_pixels(sprites, 400) + assert _drawn(ctx, sprites) == 400 + + +def test_storage_is_rows_of_256(ctx, texture_storage): + sprites = arcade.SpriteList(capacity=1024) + data = sprites._data + for texture in ( + data._storage_pos_angle, + data._storage_size, + data._storage_color, + data._storage_texture_id, + data._storage_index, + ): + assert texture.size == (256, 4)