diff --git a/arcade/perf_graph.py b/arcade/perf_graph.py index 440ac5c01..2d8b10a70 100644 --- a/arcade/perf_graph.py +++ b/arcade/perf_graph.py @@ -224,10 +224,13 @@ def update_graph(self, delta_time: float): return sprite_list = self.sprite_lists[0] + atlas = sprite_list.atlas # Clear and return if timings are disabled if not arcade.timings_enabled(): - with sprite_list.atlas.render_into(self.minimap_texture, projection=self.proj) as fbo: + # Please forgive the ugly spacing. It makes type checking work. + with atlas.render_into( # type: ignore + self.minimap_texture, projection=self.proj) as fbo: fbo.clear(color=(0, 0, 0, 255)) return @@ -284,7 +287,9 @@ def update_graph(self, delta_time: float): text_object.text = f"{int(index * view_y_legend_increment)}" # Render to the internal texture - with sprite_list.atlas.render_into(self.minimap_texture, projection=self.proj) as fbo: + # This ugly spacing is intentional to make type checking work. + with atlas.render_into( # type: ignore + self.minimap_texture, projection=self.proj) as fbo: # Set the background color fbo.clear(self.background_color) diff --git a/arcade/sprite_list/sprite_list.py b/arcade/sprite_list/sprite_list.py index b8de470e4..903927166 100644 --- a/arcade/sprite_list/sprite_list.py +++ b/arcade/sprite_list/sprite_list.py @@ -33,6 +33,7 @@ get_window, gl, ) +from arcade.gl import Texture2D from arcade.types import Color, RGBA255 from arcade.gl.types import OpenGlFilter, BlendFunction, PyGLenum from arcade.gl.buffer import Buffer @@ -86,11 +87,10 @@ class SpriteList(Generic[SpriteType]): :param capacity: (Advanced) The initial capacity of the internal buffer. It's a suggestion for the maximum amount of sprites this list can hold. Can normally be left with default value. - :param lazy: (Advanced) Enabling lazy spritelists ensures no internal OpenGL - resources are created until the first draw call or ``initialize()`` - is called. This can be useful when making spritelists in threads - because only the main thread is allowed to interact with - OpenGL. + :param lazy: (Advanced) ``True`` delays creating OpenGL resources + for the sprite list until either its :py:meth:`~SpriteList.draw` + or :py:meth:`~SpriteList.initialize` method is called. See + :ref:`pg_spritelist_advanced_lazy_spritelists` to learn more. :param visible: Setting this to False will cause the SpriteList to not be drawn. When draw is called, the method will just return without drawing. """ @@ -105,8 +105,7 @@ def __init__( visible: bool = True, ): self.program = None - if atlas: - self._atlas: TextureAtlas = atlas + self._atlas: Optional[TextureAtlas] = atlas self._initialized = False self._lazy = lazy self._visible = visible @@ -190,9 +189,8 @@ def _init_deferred(self): self.ctx = get_window().ctx self.program = self.ctx.sprite_list_program_cull - self._atlas: TextureAtlas = ( - getattr(self, "_atlas", None) or self.ctx.default_atlas - ) + if not self._atlas: + self._atlas = self.ctx.default_atlas # Buffers for each sprite attribute (read by shader) with initial capacity self._sprite_pos_buf = self.ctx.buffer(reserve=self._buf_capacity * 12) # 3 x 32 bit floats @@ -371,7 +369,7 @@ def alpha_normalized(self, value: float): self._color = self._color[0], self._color[1], self._color[2], value @property - def atlas(self) -> "TextureAtlas": + def atlas(self) -> Optional["TextureAtlas"]: """Get the texture atlas for this sprite list""" return self._atlas @@ -624,7 +622,7 @@ def append(self, sprite: SpriteType): if self._initialized: if sprite.texture is None: raise ValueError("Sprite must have a texture when added to a SpriteList") - self._atlas.add(sprite.texture) + self._atlas.add(sprite.texture) # type: ignore def swap(self, index_1: int, index_2: int): """ @@ -883,7 +881,9 @@ def preload_textures(self, texture_list: List["Texture"]) -> None: raise ValueError("Cannot preload textures before the window is created") for texture in texture_list: - self._atlas.add(texture) + # Ugly spacing is a fast workaround for None type checking issues + self._atlas.add( # type: ignore + texture) def write_sprite_buffers_to_gpu(self) -> None: @@ -945,16 +945,19 @@ def _write_sprite_buffers_to_gpu(self): self._sprite_index_buf.write(self._sprite_index_data) self._sprite_index_changed = False - def initialize(self): + def initialize(self) -> None: """ - Create the internal OpenGL resources. - This can be done if the sprite list is lazy or was created before the window / context. - The initialization will happen on the first draw if this method is not called. - This is acceptable for most people, but this method gives you the ability to pre-initialize - to potentially void initial stalls during rendering. + Request immediate creation of OpenGL resources for this list. + + Calling this method is optional. It only has an effect for lists + created with ``lazy=True``. If this method is not called, + uninitialized sprite lists will automatically initialize OpenGL + resources on their first :py:meth:`~SpriteList.draw` call instead. - Calling this otherwise will have no effect. Calling this method in another thread - will result in an OpenGL error. + This method is useful for performance optimization, advanced + techniques, and writing tests. Do not call it across thread + boundaries. See :ref:`pg_spritelist_advanced_lazy_spritelists` + to learn more. """ self._init_deferred() @@ -968,6 +971,16 @@ def draw( """ Draw this list of sprites. + Uninitialized sprite lists will first create OpenGL resources + before drawing. This may cause a performance stutter when the + following are true: + + 1. You created the sprite list with ``lazy=True`` + 2. You did not call :py:meth:`~SpriteList.initialize` before drawing + 3. You are initializing many sprites and/or lists at once + + See :ref:`pg_spritelist_advanced_lazy_spritelists` to learn more. + :param filter: Optional parameter to set OpenGL filter, such as `gl.GL_NEAREST` to avoid smoothing. :param pixelated: ``True`` for pixelated and ``False`` for smooth interpolation. @@ -988,30 +1001,34 @@ def draw( else: self.ctx.blend_func = self.ctx.BLEND_DEFAULT + # Workarounds for Optional[TextureAtlas] + slow . lookup speed + atlas: TextureAtlas = self.atlas # type: ignore + atlas_texture: Texture2D = atlas.texture + # Set custom filter or reset to default if filter: if hasattr(filter, '__len__', ): # assume it's a collection if len(cast(Sized, filter)) != 2: raise ValueError("Can't use sequence of length != 2") - self.atlas.texture.filter = tuple(filter) # type: ignore + atlas_texture.filter = tuple(filter) # type: ignore else: # assume it's an int - self.atlas.texture.filter = cast(OpenGlFilter, (filter, filter)) + atlas_texture.filter = cast(OpenGlFilter, (filter, filter)) else: - self.atlas.texture.filter = self.ctx.LINEAR, self.ctx.LINEAR + atlas_texture.filter = self.ctx.LINEAR, self.ctx.LINEAR # Handle the pixelated shortcut if pixelated: - self.atlas.texture.filter = self.ctx.NEAREST, self.ctx.NEAREST + atlas_texture.filter = self.ctx.NEAREST, self.ctx.NEAREST else: - self.atlas.texture.filter = self.ctx.LINEAR, self.ctx.LINEAR + atlas_texture.filter = self.ctx.LINEAR, self.ctx.LINEAR if not self.program: raise ValueError("Attempting to render without 'program' field being set.") self.program["spritelist_color"] = self._color - self._atlas.texture.use(0) - self._atlas.use_uv_texture(1) + atlas_texture.use(0) + atlas.use_uv_texture(1) if not self._geometry: raise ValueError("Attempting to render without '_geometry' field being set.") self._geometry.render( @@ -1143,7 +1160,9 @@ def _update_all(self, sprite: SpriteType): if not sprite._texture: return - tex_slot, _ = self._atlas.add(sprite._texture) + # Ugly syntax makes type checking pass without perf hit from cast + tex_slot: int = self._atlas.add( # type: ignore + sprite._texture)[0] slot = self.sprite_slot[sprite] self._sprite_texture_data[slot] = tex_slot @@ -1159,8 +1178,10 @@ def _update_texture(self, sprite) -> None: if not sprite._texture: return - - tex_slot, _ = self._atlas.add(sprite._texture) + atlas = self._atlas + # Ugly spacing makes type checking work with specificity + tex_slot: int = atlas.add( # type: ignore + sprite._texture)[0] slot = self.sprite_slot[sprite] self._sprite_texture_data[slot] = tex_slot diff --git a/tests/unit/spritelist/test_spritelist_lazy.py b/tests/unit/spritelist/test_spritelist_lazy.py index 8696ba2aa..c7b46959a 100644 --- a/tests/unit/spritelist/test_spritelist_lazy.py +++ b/tests/unit/spritelist/test_spritelist_lazy.py @@ -1,12 +1,18 @@ import pytest import arcade +from arcade import TextureAtlas -def test_create(): +def test_create_lazy_equals_true(): """Test lazy creation of spritelist""" spritelist = arcade.SpriteList(lazy=True, use_spatial_hash=True) + + # Make sure OpenGL abstractions are not created assert spritelist._sprite_pos_buf == None assert spritelist._geometry == None + assert spritelist.atlas is None + + # Make sure CPU-only behavior still works correctly for x in range(100): spritelist.append( arcade.Sprite(":resources:images/items/coinGold.png", center_x=x * 64) @@ -15,19 +21,29 @@ def test_create(): assert spritelist.spatial_hash is not None assert spritelist._initialized is False + # Verify that initialization will fail without a window arcade.set_window(None) with pytest.raises(RuntimeError): spritelist.initialize() -def test_create_2(window): +def test_manual_initialization_after_lazy_equals_true(window): + """Test manual initialization of lazy sprite lists.""" spritelist = arcade.SpriteList(lazy=True) + + # CPU-only actions which shouldn't affect initializing OpenGL resources sprite = arcade.SpriteSolidColor(10, 10, color=(255, 255, 255, 255)) spritelist.append(sprite) spritelist.remove(sprite) + # Make sure initialization still worked correctly. spritelist.initialize() assert spritelist._initialized assert spritelist._sprite_pos_buf assert spritelist._geometry + assert isinstance(spritelist.atlas, TextureAtlas) + + # Uncomment the next line and set a breakpoint on it to + # spot-check the number of sprites drawn (it should be zero). spritelist.draw() + # pass