diff --git a/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl b/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl index fe6a852118..8ca5210ddd 100644 --- a/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl +++ b/arcade/resources/system/shaders/sprites/sprite_list_simple_vs.glsl @@ -17,6 +17,8 @@ uniform sampler2D size_data; uniform sampler2D color_data; uniform sampler2D texture_id_data; uniform isampler2D index_data; +// First logical sprite index for a partial draw. +uniform int sprite_start; // How much half-pixel offset to apply to the UVs. // 0.0 is no offset, 1.0 is half a pixel offset @@ -34,7 +36,7 @@ out vec4 v_color; void main() { // Reading per-instance data from textures. // First we need take the index texture into account to get the correct rendering order. - int index = getInstanceIndex(index_data, gl_InstanceID); + int index = getInstanceIndex(index_data, gl_InstanceID + sprite_start); vec4 pos_rot = getInstancePosRot(pos_data, index); vec2 size = getInstanceSize(size_data, index); vec4 color = getInstanceColor(color_data, index); diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index 9a94447962..8f840b9975 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -132,6 +132,35 @@ def draw( """ ... + @abstractmethod + def draw_range( + self, + start: int = 0, + stop: int | None = None, + *, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + ) -> None: + """Draw the sprites selected by ``sprite_list[start:stop]``. + + The bounds follow Python slicing: ``stop`` is exclusive, negative + indices count from the end, and out-of-range bounds are clipped. + The sprites keep their list order, without creating a temporary list. + + Args: + start: Index of the first sprite to draw. + stop: Exclusive end index. ``None`` draws to the end of the list. + filter: Optional OpenGL texture filter, as in :meth:`draw`. + pixelated: Whether to use pixelated rendering, as in :meth:`draw`. + blend_function: Optional OpenGL blend function, as in :meth:`draw`. + + Raises: + ValueError: A custom texture-storage shader does not support the + ``sprite_start`` uniform required for a nonzero start index. + """ + ... + @abstractmethod def draw_hit_boxes( self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 @@ -1072,6 +1101,33 @@ def draw( blend=self._blend, ) + def draw_range( + self, + start: int = 0, + stop: int | None = None, + *, + filter: PyGLenum | OpenGlFilter | None = None, + pixelated: bool | None = None, + blend_function: BlendFunction | None = None, + ) -> None: + start, stop, _ = slice(start, stop).indices(len(self.sprite_list)) + if stop <= start or not self._visible or self.alpha_normalized == 0.0: + return + + self._init_deferred() + self._write_sprite_buffers_to_gpu() + self.data.render( + atlas=self._atlas, # type: ignore + count=stop - start, + first=start, + color=self._color, + default_texture_filter=self.DEFAULT_TEXTURE_FILTER, + filter=filter, + pixelated=pixelated, + blend_function=blend_function, + blend=self._blend, + ) + def draw_hit_boxes( self, color: RGBOrA255 = (0, 0, 0, 255), line_thickness: float = 1.0 ) -> None: @@ -1498,6 +1554,7 @@ def render( count: int, color: tuple[float, float, float, float], default_texture_filter: OpenGlFilter, + first: int = 0, filter: PyGLenum | OpenGlFilter | None = None, pixelated: bool | None = None, blend_function: BlendFunction | None = None, @@ -1746,6 +1803,7 @@ def render( count: int, color: tuple[float, float, float, float], default_texture_filter: OpenGlFilter, + first: int = 0, filter: PyGLenum | OpenGlFilter | None = None, pixelated: bool | None = None, blend_function: BlendFunction | None = None, @@ -1809,6 +1867,7 @@ def render( self.program, mode=self.ctx.POINTS, vertices=count, + first=first, ) # Leave global states to default @@ -1954,6 +2013,7 @@ def render( count: int, color: tuple[float, float, float, float], default_texture_filter: OpenGlFilter, + first: int = 0, filter: PyGLenum | OpenGlFilter | None = None, pixelated: bool | None = None, blend_function: BlendFunction | None = None, @@ -1963,6 +2023,14 @@ def render( if not self.program: raise ValueError("Attempting to render without shader program.") + if first: + try: + self.program["sprite_start"] + except KeyError: + raise ValueError( + "Range drawing requires a sprite_start uniform in the shader." + ) from None + prev_blend_func = self.ctx.blend_func if blend: self.ctx.enable(self.ctx.BLEND) @@ -2016,10 +2084,14 @@ def render( self._storage_texture_id.use(5) self._storage_index.use(6) - self._geometry.render( - self.program, - instances=count, - ) + if first: + self.program["sprite_start"] = first + try: + self._geometry.render(self.program, instances=count) + finally: + if first: + # The program is shared with other lists and ordinary draw calls. + self.program["sprite_start"] = 0 # Leave global states to default if blend: diff --git a/doc/programming_guide/sprites/spritelists.rst b/doc/programming_guide/sprites/spritelists.rst index cbef241341..dfb5feef5b 100644 --- a/doc/programming_guide/sprites/spritelists.rst +++ b/doc/programming_guide/sprites/spritelists.rst @@ -72,6 +72,22 @@ Here's a minimal example: :linenos: +Drawing a Range of Sprites +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use :py:meth:`~arcade.SpriteList.draw_range` to draw part of a list without +creating another sprite list. The bounds work like a Python slice: the end +index is exclusive, negative indices count from the end, and bounds are +clipped to the list length. Sprites retain their existing drawing order. + +.. code-block:: python + + sprites.draw_range(10, 20) # Draw sprites at indices 10 through 19. + sprites.draw_range(-5) # Draw the last five sprites. + +The method accepts the same texture filtering and blending options as +:py:meth:`~arcade.SpriteList.draw`. + Using Images with Sprites ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/unit/spritelist/test_spritelist_draw_range.py b/tests/unit/spritelist/test_spritelist_draw_range.py new file mode 100644 index 0000000000..9b0c83cfab --- /dev/null +++ b/tests/unit/spritelist/test_spritelist_draw_range.py @@ -0,0 +1,190 @@ +import pytest + +import arcade +from arcade.resources import resolve +from arcade.sprite_list import sprite_list as sprite_list_module + + +@pytest.fixture(params=["buffer", "texture"]) +def storage_backend(request, monkeypatch): + if request.param == "texture": + monkeypatch.setattr( + sprite_list_module, "SpriteListBufferData", sprite_list_module.SpriteListTextureData + ) + + +COLORS = [arcade.color.RED, arcade.color.GREEN, arcade.color.BLUE, arcade.color.YELLOW] + + +def make_sprites(*, lazy=False): + sprites = arcade.SpriteList(lazy=lazy) + for i, color in enumerate(COLORS): + sprites.append( + arcade.SpriteSolidColor(4, 4, center_x=10 + 10 * i, center_y=10, color=color) + ) + return sprites + + +def test_render_control(offscreen, storage_backend): + sprites = make_sprites() + sprites.draw() + for i, color in enumerate(COLORS): + assert offscreen.read_pixel(10 + 10 * i, 10) == color[:3] + + +@pytest.mark.parametrize( + "start, stop, expected", + [ + (1, 3, [1, 2]), + (0, None, [0, 1, 2, 3]), + (-3, -1, [1, 2]), + (-20, 2, [0, 1]), + (2, 20, [2, 3]), + (3, 1, []), + (10, None, []), + ], +) +def test_slice_bounds(offscreen, storage_backend, start, stop, expected): + sprites: arcade.SpriteSequence[arcade.Sprite] = make_sprites(lazy=True) + sprites.draw_range(start, stop) + for i, color in enumerate(COLORS): + assert offscreen.read_pixel(10 + 10 * i, 10) == (color[:3] if i in expected else (0, 0, 0)) + + +def test_logical_order_after_slot_reuse(offscreen, storage_backend): + sprites = make_sprites() + removed = sprites[1] + sprites.remove(removed) + sprites.reverse() + sprites.append(removed) + assert [sprites.sprite_slot[sprite] for sprite in sprites] != list(range(len(sprites))) + sprites.draw_range(1, 3) + for sprite in sprites: + expected = sprite.color[:3] if sprite in sprites[1:3] else (0, 0, 0) + assert offscreen.read_pixel(int(sprite.center_x), 10) == expected + + +def test_range_preserves_painter_order(offscreen, storage_backend): + sprites = make_sprites() + for sprite in sprites: + sprite.center_x = 10 + sprites.reverse() + sprites.draw_range(1, 3) + assert offscreen.read_pixel(10, 10) == sprites[2].color[:3] + + +def test_offset_does_not_leak_to_whole_draw(offscreen, storage_backend): + sprites = make_sprites() + other = make_sprites() + for current in (sprites, other, sprites): + offscreen.clear() + sprites.draw_range(2, 3) + assert offscreen.read_pixel(10, 10) == (0, 0, 0) + offscreen.clear() + current.draw() + for i, color in enumerate(COLORS): + assert offscreen.read_pixel(10 + 10 * i, 10) == color[:3] + + +def test_crosses_texture_row_boundary(offscreen, storage_backend): + sprites = arcade.SpriteList(capacity=1000) + for i in range(300): + sprites.append(arcade.SpriteSolidColor(1, 1, center_x=i + 0.5, center_y=0.5)) + sprites.draw_range(254, 258) + for i in range(300): + assert offscreen.read_pixel(i, 0) == ((255, 255, 255) if 254 <= i < 258 else (0, 0, 0)) + + +@pytest.mark.parametrize("state", ["empty", "range", "invisible", "transparent"]) +def test_noop_keeps_lazy_list_uninitialized(ctx, state): + sprites = make_sprites(lazy=True) + if state == "empty": + sprites.clear() + elif state == "invisible": + sprites.visible = False + elif state == "transparent": + sprites.alpha = 0 + sprites.draw_range(2, 2 if state == "range" else 3) + assert not sprites._initialized + + +def test_render_options(offscreen, storage_backend): + sprites = make_sprites() + sprites.color = (128, 255, 255) + offscreen.fbo.clear(color=(0, 0, 64)) + sprites.draw_range(0, 1, filter=arcade.gl.NEAREST, blend_function=offscreen.ctx.BLEND_ADDITIVE) + assert offscreen.read_pixel(10, 10) == (128, 0, 64) + assert offscreen.read_pixel(20, 10) == (0, 0, 64) + assert sprites.atlas.texture.filter == (arcade.gl.NEAREST, arcade.gl.NEAREST) + + +def test_window_view_draw(window, storage_backend): + sprites = make_sprites() + + class RangeView(arcade.View): + def on_draw(self): + self.clear() + sprites.draw_range(1, 3) + assert arcade.get_pixel(10, 10) == (0, 0, 0) + assert arcade.get_pixel(20, 10) == COLORS[1][:3] + assert arcade.get_pixel(30, 10) == COLORS[2][:3] + assert arcade.get_pixel(40, 10) == (0, 0, 0) + + window.background_color = arcade.color.BLACK + window.show_view(RangeView()) + window.test(2) + + +def test_legacy_texture_shader(offscreen, monkeypatch, tmp_path): + monkeypatch.setattr( + sprite_list_module, "SpriteListBufferData", sprite_list_module.SpriteListTextureData + ) + sprites = make_sprites() + source = resolve(":system:shaders/sprites/sprite_list_simple_vs.glsl").read_text() + source = source.replace("uniform int sprite_start;", "").replace( + "gl_InstanceID + sprite_start", "gl_InstanceID" + ) + shader = tmp_path / "legacy_vertex.glsl" + shader.write_text(source) + program = offscreen.ctx.load_program( + vertex_shader=shader, fragment_shader=":system:shaders/sprites/sprite_list_simple_fs.glsl" + ) + for unit, name in enumerate( + [ + "sprite_texture", + "uv_texture", + "pos_data", + "size_data", + "color_data", + "texture_id_data", + "index_data", + ] + ): + program[name] = unit + sprites.data.program = program + sprites.draw_range(0, 1) + assert offscreen.read_pixel(10, 10) == COLORS[0][:3] + offscreen.clear() + previous_blend = offscreen.ctx.blend_func + with pytest.raises(ValueError, match="sprite_start"): + sprites.draw_range(1, 3, blend_function=offscreen.ctx.BLEND_ADDITIVE) + assert offscreen.ctx.blend_func == previous_blend + sprites.draw() + for i, color in enumerate(COLORS): + assert offscreen.read_pixel(10 + 10 * i, 10) == color[:3] + + +def test_texture_offset_restored_after_render_error(offscreen, monkeypatch): + monkeypatch.setattr( + sprite_list_module, "SpriteListBufferData", sprite_list_module.SpriteListTextureData + ) + sprites = make_sprites() + + def fail_render(*args, **kwargs): + assert sprites.data.program["sprite_start"] == 2 + raise RuntimeError("Injected rendering failure") + + monkeypatch.setattr(arcade.gl.Geometry, "render", fail_render) + with pytest.raises(RuntimeError, match="Injected"): + sprites.draw_range(2, 3) + assert sprites.data.program["sprite_start"] == 0