Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
80 changes: 76 additions & 4 deletions arcade/sprite_list/sprite_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1809,6 +1867,7 @@ def render(
self.program,
mode=self.ctx.POINTS,
vertices=count,
first=first,
)

# Leave global states to default
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions doc/programming_guide/sprites/spritelists.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
190 changes: 190 additions & 0 deletions tests/unit/spritelist/test_spritelist_draw_range.py
Original file line number Diff line number Diff line change
@@ -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
Loading