Skip to main content

goud_engine/libs/graphics/
sprite_batch.rs

1//! Sprite Batch Renderer for efficient 2D sprite rendering.
2//!
3//! This module provides a high-performance sprite batching system that:
4//! - **Batches sprites**: Groups sprites by texture to minimize draw calls
5//! - **Sorts by Z-layer**: Ensures correct render order
6//! - **Manages vertex buffers**: Dynamic vertex buffer resizing
7//! - **Handles transforms**: Integrates with Transform2D component
8//!
9//! # Architecture
10//!
11//! The sprite batch system uses a gather-sort-batch-render pipeline:
12//!
13//! 1. **Gather**: Query all entities with Sprite + Transform2D
14//! 2. **Sort**: Order sprites by Z-layer and texture for efficient batching
15//! 3. **Batch**: Group consecutive sprites with same texture into batches
16//! 4. **Render**: Submit vertex data and draw calls to GPU
17//!
18//! # Performance
19//!
20//! Target performance: <100 draw calls for 10,000 sprites (100:1 batch ratio)
21//!
22//! # Example
23//!
24//! ```rust,ignore
25//! use goud_engine::graphics::sprite_batch::{SpriteBatch, SpriteBatchConfig};
26//! use goud_engine::graphics::backend::OpenGLBackend;
27//! use goud_engine::ecs::World;
28//!
29//! let backend = OpenGLBackend::new()?;
30//! let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default());
31//!
32//! // Each frame
33//! batch.begin();
34//! batch.draw_sprites(&world);
35//! batch.end();
36//! ```
37
38use crate::assets::{loaders::TextureAsset, AssetHandle, AssetServer};
39use crate::core::error::{GoudError, GoudResult};
40use crate::core::math::{Color, Rect, Vec2};
41use crate::ecs::components::{Mat3x3, Sprite, Transform2D};
42use crate::ecs::query::Query;
43use crate::ecs::{Entity, World};
44use crate::libs::graphics::backend::types::{
45    BufferHandle, BufferType, BufferUsage, PrimitiveTopology, ShaderHandle, TextureHandle,
46    VertexAttribute, VertexAttributeType, VertexLayout,
47};
48use crate::libs::graphics::backend::RenderBackend;
49use std::collections::HashMap;
50
51// =============================================================================
52// Configuration
53// =============================================================================
54
55/// Configuration for sprite batch rendering.
56#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct SpriteBatchConfig {
59    /// Initial capacity for vertex buffer (number of sprites).
60    pub initial_capacity: usize,
61
62    /// Maximum number of sprites per batch before automatic flush.
63    pub max_batch_size: usize,
64
65    /// Enable Z-layer sorting (disable for UI layers that don't need depth).
66    pub enable_z_sorting: bool,
67
68    /// Enable automatic batching by texture (disable for debugging).
69    pub enable_batching: bool,
70}
71
72impl Default for SpriteBatchConfig {
73    fn default() -> Self {
74        Self {
75            initial_capacity: 1024, // Start with space for 1024 sprites
76            max_batch_size: 10000,  // Flush after 10K sprites
77            enable_z_sorting: true, // Sort by Z-layer by default
78            enable_batching: true,  // Batch by texture by default
79        }
80    }
81}
82
83// =============================================================================
84// Vertex Format
85// =============================================================================
86
87/// Vertex data for a single sprite corner.
88///
89/// Each sprite is composed of 4 vertices forming a quad.
90/// The vertex layout is optimized for cache coherency.
91#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
92#[repr(C)]
93#[derive(Debug, Clone, Copy)]
94struct SpriteVertex {
95    /// World-space position (x, y)
96    position: Vec2,
97    /// Texture coordinates (u, v)
98    tex_coords: Vec2,
99    /// Vertex color (r, g, b, a)
100    color: Color,
101}
102
103impl SpriteVertex {
104    /// Returns the vertex layout descriptor for GPU.
105    #[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
106    fn layout() -> VertexLayout {
107        VertexLayout::new(std::mem::size_of::<Self>() as u32)
108            .with_attribute(VertexAttribute {
109                location: 0,
110                attribute_type: VertexAttributeType::Float2,
111                offset: 0,
112                normalized: false,
113            })
114            .with_attribute(VertexAttribute {
115                location: 1,
116                attribute_type: VertexAttributeType::Float2,
117                offset: 8,
118                normalized: false,
119            })
120            .with_attribute(VertexAttribute {
121                location: 2,
122                attribute_type: VertexAttributeType::Float4,
123                offset: 16,
124                normalized: false,
125            })
126    }
127}
128
129// =============================================================================
130// Sprite Instance
131// =============================================================================
132
133/// Internal representation of a sprite instance for batching.
134#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
135#[derive(Debug, Clone)]
136struct SpriteInstance {
137    /// Entity that owns this sprite
138    entity: Entity,
139    /// Texture handle
140    texture: AssetHandle<TextureAsset>,
141    /// World transform matrix
142    transform: Mat3x3,
143    /// Color tint
144    color: Color,
145    /// Source rectangle (UV coordinates)
146    source_rect: Option<Rect>,
147    /// Sprite size
148    size: Vec2,
149    /// Z-layer for sorting
150    z_layer: f32,
151    /// Flip flags
152    flip_x: bool,
153    flip_y: bool,
154}
155
156// =============================================================================
157// Batch Entry
158// =============================================================================
159
160/// A single draw batch for sprites sharing the same texture.
161#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
162#[derive(Debug)]
163struct SpriteBatchEntry {
164    /// Texture used by this batch
165    texture_handle: AssetHandle<TextureAsset>,
166    /// GPU texture handle (resolved from asset handle)
167    gpu_texture: Option<TextureHandle>,
168    /// Start index in vertex buffer
169    vertex_start: usize,
170    /// Number of vertices in this batch
171    vertex_count: usize,
172}
173
174// =============================================================================
175// Sprite Batch
176// =============================================================================
177
178/// High-performance sprite batch renderer.
179///
180/// The SpriteBatch collects sprites, sorts them by texture and Z-layer,
181/// then renders them in efficient batches to minimize draw calls.
182///
183/// # Lifecycle
184///
185/// ```rust,ignore
186/// batch.begin();           // Start a new frame
187/// batch.draw_sprites(&world, &asset_server);  // Gather and batch sprites
188/// batch.end();             // Flush remaining batches
189/// ```
190#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
191pub struct SpriteBatch<B: RenderBackend> {
192    /// Graphics backend
193    backend: B,
194    /// Configuration
195    config: SpriteBatchConfig,
196    /// Vertex buffer handle
197    vertex_buffer: Option<BufferHandle>,
198    /// Index buffer handle (shared quad indices)
199    index_buffer: Option<BufferHandle>,
200    /// Current vertex buffer capacity (number of sprites)
201    vertex_capacity: usize,
202    /// Sprite shader program
203    shader: Option<ShaderHandle>,
204    /// Collected sprite instances this frame
205    sprites: Vec<SpriteInstance>,
206    /// CPU-side vertex buffer
207    vertices: Vec<SpriteVertex>,
208    /// Prepared batches for rendering
209    batches: Vec<SpriteBatchEntry>,
210    /// Texture handle cache (AssetHandle -> GPU TextureHandle)
211    texture_cache: HashMap<AssetHandle<TextureAsset>, TextureHandle>,
212    /// Current frame number (for debugging)
213    frame_count: u64,
214}
215
216#[allow(dead_code)] // TODO: Remove when sprite batch is integrated with game loop
217impl<B: RenderBackend> SpriteBatch<B> {
218    // =========================================================================
219    // Lifecycle
220    // =========================================================================
221
222    /// Creates a new sprite batch renderer.
223    pub fn new(backend: B, config: SpriteBatchConfig) -> GoudResult<Self> {
224        Ok(Self {
225            backend,
226            config,
227            vertex_buffer: None,
228            index_buffer: None,
229            vertex_capacity: 0,
230            shader: None,
231            sprites: Vec::with_capacity(config.initial_capacity),
232            vertices: Vec::with_capacity(config.initial_capacity * 4),
233            batches: Vec::with_capacity(128),
234            texture_cache: HashMap::new(),
235            frame_count: 0,
236        })
237    }
238
239    /// Begins a new frame of sprite rendering.
240    ///
241    /// This clears the sprite collection from the previous frame.
242    pub fn begin(&mut self) {
243        self.sprites.clear();
244        self.vertices.clear();
245        self.batches.clear();
246        self.frame_count += 1;
247    }
248
249    /// Ends the current frame and flushes remaining batches.
250    pub fn end(&mut self) -> GoudResult<()> {
251        // Flush is automatic in draw_sprites, but we could add
252        // final state cleanup here if needed
253        Ok(())
254    }
255
256    // =========================================================================
257    // Drawing
258    // =========================================================================
259
260    /// Draws all sprites from the world.
261    ///
262    /// This performs the gather-sort-batch-render pipeline:
263    /// 1. Queries all entities with Sprite + Transform2D
264    /// 2. Sorts by Z-layer and texture
265    /// 3. Generates batches
266    /// 4. Submits draw calls
267    pub fn draw_sprites(&mut self, world: &World, asset_server: &AssetServer) -> GoudResult<()> {
268        // Step 1: Gather sprites from world
269        self.gather_sprites(world)?;
270
271        // Step 2: Sort sprites
272        if self.config.enable_z_sorting {
273            self.sort_sprites();
274        }
275
276        // Step 3: Generate batches
277        self.generate_batches(asset_server)?;
278
279        // Step 4: Render batches
280        self.render_batches()?;
281
282        Ok(())
283    }
284
285    // =========================================================================
286    // Internal: Gather
287    // =========================================================================
288
289    /// Gathers all sprites from the world into the internal sprite list.
290    fn gather_sprites(&mut self, world: &World) -> GoudResult<()> {
291        // Clear previous frame's sprites
292        self.sprites.clear();
293
294        // Create query for entities with Sprite and Transform2D components
295        // Query returns (Entity, &Sprite, &Transform2D) tuples
296        let query: Query<(Entity, &Sprite, &Transform2D)> = Query::new(world);
297
298        // Iterate over all matching entities
299        for (entity, sprite, transform) in query.iter(world) {
300            // Extract transform matrix for vertex transformation
301            let matrix = transform.matrix();
302
303            // Calculate sprite dimensions
304            let size = if let Some(custom_size) = sprite.custom_size {
305                custom_size
306            } else if let Some(ref source_rect) = sprite.source_rect {
307                Vec2::new(source_rect.width, source_rect.height)
308            } else {
309                // Default to texture size (we'll need to get this from AssetServer later)
310                // For now, use a default size
311                Vec2::new(64.0, 64.0)
312            };
313
314            // Use transform's Y position as Z-layer for 2D sorting
315            // In 2D games, Y-axis often determines draw order (bottom-to-top)
316            let z_layer = transform.position.y;
317
318            // Create sprite instance
319            let instance = SpriteInstance {
320                entity,
321                transform: matrix,
322                texture: sprite.texture,
323                color: sprite.color,
324                source_rect: sprite.source_rect,
325                size,
326                flip_x: sprite.flip_x,
327                flip_y: sprite.flip_y,
328                z_layer,
329            };
330
331            self.sprites.push(instance);
332        }
333
334        Ok(())
335    }
336
337    // =========================================================================
338    // Internal: Sort
339    // =========================================================================
340
341    /// Sorts sprites by Z-layer (back to front) and texture (for batching).
342    fn sort_sprites(&mut self) {
343        if !self.config.enable_batching {
344            // Simple Z-layer sort only
345            self.sprites.sort_by(|a, b| {
346                a.z_layer
347                    .partial_cmp(&b.z_layer)
348                    .unwrap_or(std::cmp::Ordering::Equal)
349            });
350        } else {
351            // Sort by Z-layer first, then by texture for batching
352            self.sprites.sort_by(|a, b| {
353                match a.z_layer.partial_cmp(&b.z_layer) {
354                    Some(std::cmp::Ordering::Equal) | None => {
355                        // Within same Z-layer, sort by texture for batching
356                        a.texture.cmp(&b.texture)
357                    }
358                    Some(ord) => ord,
359                }
360            });
361        }
362    }
363
364    // =========================================================================
365    // Internal: Batch Generation
366    // =========================================================================
367
368    /// Generates batches from sorted sprites.
369    fn generate_batches(&mut self, asset_server: &AssetServer) -> GoudResult<()> {
370        if self.sprites.is_empty() {
371            return Ok(());
372        }
373
374        let sprite_count = self.sprites.len();
375        let mut current_texture = self.sprites[0].texture;
376        let mut batch_start = 0;
377
378        // Build batch ranges first to avoid borrowing issues
379        let mut batch_ranges = Vec::new();
380
381        for i in 0..sprite_count {
382            let sprite_texture = self.sprites[i].texture;
383
384            // Check if we need to start a new batch
385            let new_batch = sprite_texture != current_texture
386                || (i - batch_start) >= self.config.max_batch_size;
387
388            if new_batch && i > batch_start {
389                // Record current batch range
390                batch_ranges.push((current_texture, batch_start, i));
391
392                // Start new batch
393                current_texture = sprite_texture;
394                batch_start = i;
395            }
396        }
397
398        // Record last batch
399        batch_ranges.push((current_texture, batch_start, sprite_count));
400
401        // Now generate vertices for each batch
402        for (texture_handle, start_idx, end_idx) in batch_ranges {
403            self.finalize_batch(texture_handle, start_idx, end_idx, asset_server)?;
404        }
405
406        Ok(())
407    }
408
409    /// Finalizes a batch by generating vertices.
410    fn finalize_batch(
411        &mut self,
412        texture_handle: AssetHandle<TextureAsset>,
413        start_idx: usize,
414        end_idx: usize,
415        asset_server: &AssetServer,
416    ) -> GoudResult<()> {
417        let vertex_start = self.vertices.len();
418
419        // Get texture dimensions for UV calculation
420        let texture_size = self.get_texture_size(texture_handle, asset_server);
421
422        // Clone sprite data to avoid borrowing issues
423        let sprites_to_process = self.sprites[start_idx..end_idx].to_vec();
424
425        // Generate vertices for each sprite in this batch
426        for sprite in sprites_to_process {
427            self.generate_sprite_vertices(&sprite, texture_size)?;
428        }
429
430        let vertex_count = self.vertices.len() - vertex_start;
431
432        // Resolve GPU texture handle
433        let gpu_texture = self.resolve_texture(texture_handle, asset_server)?;
434
435        // Add batch entry
436        self.batches.push(SpriteBatchEntry {
437            texture_handle,
438            gpu_texture: Some(gpu_texture),
439            vertex_start,
440            vertex_count,
441        });
442
443        Ok(())
444    }
445
446    /// Generates 4 vertices for a single sprite quad.
447    fn generate_sprite_vertices(
448        &mut self,
449        sprite: &SpriteInstance,
450        texture_size: Vec2,
451    ) -> GoudResult<()> {
452        // Calculate sprite corners in local space
453        let half_size = sprite.size * 0.5;
454        let local_corners = [
455            Vec2::new(-half_size.x, -half_size.y), // Top-left
456            Vec2::new(half_size.x, -half_size.y),  // Top-right
457            Vec2::new(half_size.x, half_size.y),   // Bottom-right
458            Vec2::new(-half_size.x, half_size.y),  // Bottom-left
459        ];
460
461        // Calculate UV coordinates
462        let uv_rect = if let Some(source) = sprite.source_rect {
463            // Use source rectangle
464            Rect::new(
465                source.x / texture_size.x,
466                source.y / texture_size.y,
467                source.width / texture_size.x,
468                source.height / texture_size.y,
469            )
470        } else {
471            // Use full texture
472            Rect::new(0.0, 0.0, 1.0, 1.0)
473        };
474
475        // Calculate UV corners with flipping
476        let u_min = if sprite.flip_x {
477            uv_rect.x + uv_rect.width
478        } else {
479            uv_rect.x
480        };
481        let u_max = if sprite.flip_x {
482            uv_rect.x
483        } else {
484            uv_rect.x + uv_rect.width
485        };
486        let v_min = if sprite.flip_y {
487            uv_rect.y + uv_rect.height
488        } else {
489            uv_rect.y
490        };
491        let v_max = if sprite.flip_y {
492            uv_rect.y
493        } else {
494            uv_rect.y + uv_rect.height
495        };
496
497        let uv_corners = [
498            Vec2::new(u_min, v_min), // Top-left
499            Vec2::new(u_max, v_min), // Top-right
500            Vec2::new(u_max, v_max), // Bottom-right
501            Vec2::new(u_min, v_max), // Bottom-left
502        ];
503
504        // Transform corners to world space and create vertices
505        for i in 0..4 {
506            let world_pos = sprite.transform.transform_point(local_corners[i]);
507            self.vertices.push(SpriteVertex {
508                position: world_pos,
509                tex_coords: uv_corners[i],
510                color: sprite.color,
511            });
512        }
513
514        Ok(())
515    }
516
517    // =========================================================================
518    // Internal: Rendering
519    // =========================================================================
520
521    /// Renders all batches to the GPU.
522    fn render_batches(&mut self) -> GoudResult<()> {
523        if self.batches.is_empty() {
524            return Ok(());
525        }
526
527        // Ensure GPU resources are created
528        self.ensure_resources()?;
529
530        // Upload vertex data
531        self.upload_vertices()?;
532
533        // Bind shader and set uniforms
534        if let Some(shader) = self.shader {
535            self.backend.bind_shader(shader)?;
536            // TODO: Set projection matrix uniform
537        }
538
539        // Bind vertex buffer and set attributes
540        if let Some(vbo) = self.vertex_buffer {
541            self.backend.bind_buffer(vbo)?;
542            self.backend.set_vertex_attributes(&SpriteVertex::layout());
543        }
544
545        // Bind index buffer
546        if let Some(ibo) = self.index_buffer {
547            self.backend.bind_buffer(ibo)?;
548        }
549
550        // Draw each batch
551        for batch in &self.batches {
552            // Bind texture
553            if let Some(gpu_tex) = batch.gpu_texture {
554                self.backend.bind_texture(gpu_tex, 0)?;
555            }
556
557            // Calculate indices
558            let sprite_count = batch.vertex_count / 4;
559            let index_start = (batch.vertex_start / 4) * 6;
560            let index_count = sprite_count * 6;
561
562            // Draw indexed
563            self.backend.draw_indexed(
564                PrimitiveTopology::Triangles,
565                index_count as u32,
566                index_start,
567            )?;
568        }
569
570        Ok(())
571    }
572
573    // =========================================================================
574    // Internal: Resource Management
575    // =========================================================================
576
577    /// Ensures GPU resources (buffers, shader) are created.
578    fn ensure_resources(&mut self) -> GoudResult<()> {
579        // Create vertex buffer if needed
580        if self.vertex_buffer.is_none() || self.vertices.len() > self.vertex_capacity * 4 {
581            self.create_vertex_buffer()?;
582        }
583
584        // Create index buffer if needed
585        if self.index_buffer.is_none() {
586            self.create_index_buffer()?;
587        }
588
589        // Create shader if needed
590        if self.shader.is_none() {
591            self.create_shader()?;
592        }
593
594        Ok(())
595    }
596
597    /// Creates or resizes the vertex buffer.
598    fn create_vertex_buffer(&mut self) -> GoudResult<()> {
599        // Calculate new capacity (double if needed)
600        let required_sprites = self.vertices.len().div_ceil(4);
601        let new_capacity = if required_sprites > self.vertex_capacity {
602            (required_sprites * 2).max(self.config.initial_capacity)
603        } else {
604            self.config.initial_capacity
605        };
606
607        let buffer_size = new_capacity * 4 * std::mem::size_of::<SpriteVertex>();
608
609        // Destroy old buffer if exists
610        if let Some(old_buffer) = self.vertex_buffer {
611            let _ = self.backend.destroy_buffer(old_buffer);
612        }
613
614        // Create new buffer with empty data (will be updated later)
615        let empty_data = vec![0u8; buffer_size];
616        let buffer =
617            self.backend
618                .create_buffer(BufferType::Vertex, BufferUsage::Dynamic, &empty_data)?;
619
620        self.vertex_buffer = Some(buffer);
621        self.vertex_capacity = new_capacity;
622
623        Ok(())
624    }
625
626    /// Creates the shared index buffer for quad rendering.
627    fn create_index_buffer(&mut self) -> GoudResult<()> {
628        // Generate indices for max_batch_size quads
629        let quad_count = self.config.max_batch_size;
630        let mut indices = Vec::with_capacity(quad_count * 6);
631
632        for i in 0..quad_count {
633            let base = (i * 4) as u32;
634            // Two triangles per quad (CCW winding)
635            indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
636        }
637
638        let buffer_size = indices.len() * std::mem::size_of::<u32>();
639        let buffer_data =
640            unsafe { std::slice::from_raw_parts(indices.as_ptr() as *const u8, buffer_size) };
641
642        let buffer =
643            self.backend
644                .create_buffer(BufferType::Index, BufferUsage::Static, buffer_data)?;
645
646        self.index_buffer = Some(buffer);
647
648        Ok(())
649    }
650
651    /// Creates the sprite shader program.
652    fn create_shader(&mut self) -> GoudResult<()> {
653        // TODO: Load shader from assets or use built-in shader
654        // For now, return error as shader loading isn't implemented yet
655        Err(GoudError::NotImplemented(
656            "Sprite shader creation".to_string(),
657        ))
658    }
659
660    /// Uploads vertex data to the GPU.
661    fn upload_vertices(&mut self) -> GoudResult<()> {
662        if self.vertices.is_empty() {
663            return Ok(());
664        }
665
666        let buffer = self
667            .vertex_buffer
668            .ok_or_else(|| GoudError::InvalidState("Vertex buffer not created".to_string()))?;
669
670        let data_size = self.vertices.len() * std::mem::size_of::<SpriteVertex>();
671        let data_ptr = self.vertices.as_ptr() as *const u8;
672        let data_slice = unsafe { std::slice::from_raw_parts(data_ptr, data_size) };
673
674        self.backend.update_buffer(buffer, 0, data_slice)?;
675
676        Ok(())
677    }
678
679    /// Resolves an asset handle to a GPU texture handle.
680    fn resolve_texture(
681        &mut self,
682        asset_handle: AssetHandle<TextureAsset>,
683        asset_server: &AssetServer,
684    ) -> GoudResult<TextureHandle> {
685        // Check cache first
686        if let Some(&gpu_handle) = self.texture_cache.get(&asset_handle) {
687            return Ok(gpu_handle);
688        }
689
690        // Load texture from asset server
691        let _texture_asset = asset_server.get(&asset_handle).ok_or_else(|| {
692            GoudError::ResourceNotFound(format!("Texture asset {:?}", asset_handle))
693        })?;
694
695        // TODO: Upload texture to GPU and cache handle
696        // For now, return error as texture upload isn't implemented yet
697        Err(GoudError::NotImplemented("Texture upload".to_string()))
698    }
699
700    /// Gets the size of a texture from the asset server.
701    fn get_texture_size(
702        &self,
703        asset_handle: AssetHandle<TextureAsset>,
704        asset_server: &AssetServer,
705    ) -> Vec2 {
706        if let Some(texture) = asset_server.get(&asset_handle) {
707            Vec2::new(texture.width as f32, texture.height as f32)
708        } else {
709            Vec2::one() // Fallback to 1x1
710        }
711    }
712
713    // =========================================================================
714    // Statistics
715    // =========================================================================
716
717    /// Returns the number of sprites rendered this frame.
718    pub fn sprite_count(&self) -> usize {
719        self.sprites.len()
720    }
721
722    /// Returns the number of batches rendered this frame.
723    pub fn batch_count(&self) -> usize {
724        self.batches.len()
725    }
726
727    /// Returns the current frame number.
728    pub fn frame_count(&self) -> u64 {
729        self.frame_count
730    }
731
732    /// Returns the batch ratio (sprites per draw call).
733    pub fn batch_ratio(&self) -> f32 {
734        if self.batches.is_empty() {
735            0.0
736        } else {
737            self.sprites.len() as f32 / self.batches.len() as f32
738        }
739    }
740
741    /// Returns rendering statistics as (sprite_count, batch_count, batch_ratio) tuple.
742    ///
743    /// This is a convenience method that combines `sprite_count()`, `batch_count()`,
744    /// and `batch_ratio()` for easy performance monitoring.
745    ///
746    /// # Example
747    ///
748    /// ```ignore
749    /// let (sprites, batches, ratio) = batch.stats();
750    /// println!("Rendered {} sprites in {} batches ({}:1 ratio)", sprites, batches, ratio);
751    /// ```
752    pub fn stats(&self) -> (usize, usize, f32) {
753        (self.sprite_count(), self.batch_count(), self.batch_ratio())
754    }
755}
756
757// =============================================================================
758// Tests
759// =============================================================================
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::ecs::World;
765    use crate::libs::graphics::backend::opengl::OpenGLBackend;
766
767    #[test]
768    fn test_sprite_batch_config_default() {
769        let config = SpriteBatchConfig::default();
770        assert_eq!(config.initial_capacity, 1024);
771        assert_eq!(config.max_batch_size, 10000);
772        assert!(config.enable_z_sorting);
773        assert!(config.enable_batching);
774    }
775
776    #[test]
777    fn test_sprite_vertex_layout() {
778        let layout = SpriteVertex::layout();
779        assert_eq!(layout.stride, std::mem::size_of::<SpriteVertex>() as u32);
780        assert_eq!(layout.attributes.len(), 3);
781    }
782
783    #[test]
784    #[ignore] // Requires OpenGL context
785    fn test_sprite_batch_new() {
786        let backend = OpenGLBackend::new().unwrap();
787        let config = SpriteBatchConfig::default();
788        let batch = SpriteBatch::new(backend, config);
789        assert!(batch.is_ok());
790    }
791
792    #[test]
793    #[ignore] // Requires OpenGL context
794    fn test_sprite_batch_begin_end() {
795        let backend = OpenGLBackend::new().unwrap();
796        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
797
798        batch.begin();
799        assert_eq!(batch.sprite_count(), 0);
800        assert_eq!(batch.batch_count(), 0);
801
802        let result = batch.end();
803        assert!(result.is_ok());
804    }
805
806    #[test]
807    #[ignore] // Requires OpenGL context
808    fn test_sprite_batch_gather_empty_world() {
809        let backend = OpenGLBackend::new().unwrap();
810        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
811        let world = World::new();
812
813        batch.begin();
814        let result = batch.gather_sprites(&world);
815        assert!(result.is_ok());
816        assert_eq!(batch.sprite_count(), 0);
817    }
818
819    #[test]
820    #[ignore] // Requires OpenGL context
821    fn test_sprite_batch_sort_z_layer() {
822        let backend = OpenGLBackend::new().unwrap();
823        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
824
825        // Add sprites with different Z-layers
826        batch.sprites = vec![
827            SpriteInstance {
828                entity: Entity::new(0, 0),
829                texture: AssetHandle::new(0, 0),
830                transform: Mat3x3::IDENTITY,
831                color: Color::WHITE,
832                source_rect: None,
833                size: Vec2::one(),
834                z_layer: 10.0,
835                flip_x: false,
836                flip_y: false,
837            },
838            SpriteInstance {
839                entity: Entity::new(1, 0),
840                texture: AssetHandle::new(0, 0),
841                transform: Mat3x3::IDENTITY,
842                color: Color::WHITE,
843                source_rect: None,
844                size: Vec2::one(),
845                z_layer: 5.0,
846                flip_x: false,
847                flip_y: false,
848            },
849        ];
850
851        batch.sort_sprites();
852        assert_eq!(batch.sprites[0].z_layer, 5.0);
853        assert_eq!(batch.sprites[1].z_layer, 10.0);
854    }
855
856    #[test]
857    #[ignore] // Requires OpenGL context
858    fn test_sprite_batch_statistics() {
859        let backend = OpenGLBackend::new().unwrap();
860        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
861
862        assert_eq!(batch.frame_count(), 0);
863        batch.begin();
864        assert_eq!(batch.frame_count(), 1);
865
866        assert_eq!(batch.batch_ratio(), 0.0);
867    }
868
869    #[test]
870    fn test_sprite_instance_creation() {
871        let instance = SpriteInstance {
872            entity: Entity::new(42, 1),
873            texture: AssetHandle::new(1, 1),
874            transform: Mat3x3::IDENTITY,
875            color: Color::RED,
876            source_rect: Some(Rect::new(0.0, 0.0, 32.0, 32.0)),
877            size: Vec2::new(64.0, 64.0),
878            z_layer: 100.0,
879            flip_x: true,
880            flip_y: false,
881        };
882
883        assert_eq!(instance.entity.index(), 42);
884        assert_eq!(instance.color, Color::RED);
885        assert!(instance.flip_x);
886        assert!(!instance.flip_y);
887    }
888
889    #[test]
890    fn test_sprite_batch_entry_creation() {
891        let entry = SpriteBatchEntry {
892            texture_handle: AssetHandle::new(1, 1),
893            gpu_texture: None,
894            vertex_start: 0,
895            vertex_count: 24,
896        };
897
898        assert_eq!(entry.vertex_start, 0);
899        assert_eq!(entry.vertex_count, 24);
900        assert!(entry.gpu_texture.is_none());
901    }
902
903    // ==========================================================================
904    // Texture Batching Tests
905    // ==========================================================================
906
907    #[test]
908    #[ignore] // Requires OpenGL context
909    fn test_texture_batching_single_texture() {
910        let backend = OpenGLBackend::new().unwrap();
911        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
912
913        // Create 5 sprites with the same texture
914        let texture = AssetHandle::new(1, 1);
915        for i in 0..5 {
916            batch.sprites.push(SpriteInstance {
917                entity: Entity::new(i, 0),
918                texture,
919                transform: Mat3x3::IDENTITY,
920                color: Color::WHITE,
921                source_rect: None,
922                size: Vec2::one(),
923                z_layer: 0.0,
924                flip_x: false,
925                flip_y: false,
926            });
927        }
928
929        // With batching enabled, all sprites should be in one batch
930        assert_eq!(batch.sprites.len(), 5);
931
932        // Verify all sprites have the same texture
933        for sprite in &batch.sprites {
934            assert_eq!(sprite.texture, texture);
935        }
936    }
937
938    #[test]
939    #[ignore] // Requires OpenGL context
940    fn test_texture_batching_multiple_textures() {
941        let backend = OpenGLBackend::new().unwrap();
942        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
943
944        // Create sprites with different textures
945        let tex1 = AssetHandle::new(1, 1);
946        let tex2 = AssetHandle::new(2, 1);
947        let tex3 = AssetHandle::new(3, 1);
948
949        batch.sprites = vec![
950            SpriteInstance {
951                entity: Entity::new(0, 0),
952                texture: tex1,
953                transform: Mat3x3::IDENTITY,
954                color: Color::WHITE,
955                source_rect: None,
956                size: Vec2::one(),
957                z_layer: 0.0,
958                flip_x: false,
959                flip_y: false,
960            },
961            SpriteInstance {
962                entity: Entity::new(1, 0),
963                texture: tex2,
964                transform: Mat3x3::IDENTITY,
965                color: Color::WHITE,
966                source_rect: None,
967                size: Vec2::one(),
968                z_layer: 0.0,
969                flip_x: false,
970                flip_y: false,
971            },
972            SpriteInstance {
973                entity: Entity::new(2, 0),
974                texture: tex3,
975                transform: Mat3x3::IDENTITY,
976                color: Color::WHITE,
977                source_rect: None,
978                size: Vec2::one(),
979                z_layer: 0.0,
980                flip_x: false,
981                flip_y: false,
982            },
983        ];
984
985        // Verify we have 3 different textures
986        assert_eq!(batch.sprites.len(), 3);
987        assert_ne!(batch.sprites[0].texture, batch.sprites[1].texture);
988        assert_ne!(batch.sprites[1].texture, batch.sprites[2].texture);
989        assert_ne!(batch.sprites[0].texture, batch.sprites[2].texture);
990    }
991
992    #[test]
993    #[ignore] // Requires OpenGL context
994    fn test_texture_batching_sort_by_texture() {
995        let backend = OpenGLBackend::new().unwrap();
996        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
997
998        // Create sprites with different textures, unsorted
999        let tex1 = AssetHandle::new(1, 1);
1000        let tex2 = AssetHandle::new(2, 1);
1001
1002        batch.sprites = vec![
1003            SpriteInstance {
1004                entity: Entity::new(0, 0),
1005                texture: tex2, // tex2 first
1006                transform: Mat3x3::IDENTITY,
1007                color: Color::WHITE,
1008                source_rect: None,
1009                size: Vec2::one(),
1010                z_layer: 0.0,
1011                flip_x: false,
1012                flip_y: false,
1013            },
1014            SpriteInstance {
1015                entity: Entity::new(1, 0),
1016                texture: tex1, // tex1 second
1017                transform: Mat3x3::IDENTITY,
1018                color: Color::WHITE,
1019                source_rect: None,
1020                size: Vec2::one(),
1021                z_layer: 0.0,
1022                flip_x: false,
1023                flip_y: false,
1024            },
1025            SpriteInstance {
1026                entity: Entity::new(2, 0),
1027                texture: tex2, // tex2 again
1028                transform: Mat3x3::IDENTITY,
1029                color: Color::WHITE,
1030                source_rect: None,
1031                size: Vec2::one(),
1032                z_layer: 0.0,
1033                flip_x: false,
1034                flip_y: false,
1035            },
1036        ];
1037
1038        // Sort should group sprites by texture
1039        batch.sort_sprites();
1040
1041        // After sorting with same Z-layer, sprites should be grouped by texture
1042        // tex1 < tex2 (assuming handle comparison), so tex1 sprites should come first
1043        assert_eq!(batch.sprites[0].texture, tex1);
1044        assert_eq!(batch.sprites[1].texture, tex2);
1045        assert_eq!(batch.sprites[2].texture, tex2);
1046    }
1047
1048    #[test]
1049    #[ignore] // Requires OpenGL context
1050    fn test_texture_batching_with_z_layers() {
1051        let backend = OpenGLBackend::new().unwrap();
1052        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
1053
1054        let tex1 = AssetHandle::new(1, 1);
1055        let tex2 = AssetHandle::new(2, 1);
1056
1057        batch.sprites = vec![
1058            SpriteInstance {
1059                entity: Entity::new(0, 0),
1060                texture: tex2,
1061                transform: Mat3x3::IDENTITY,
1062                color: Color::WHITE,
1063                source_rect: None,
1064                size: Vec2::one(),
1065                z_layer: 10.0, // Higher Z-layer
1066                flip_x: false,
1067                flip_y: false,
1068            },
1069            SpriteInstance {
1070                entity: Entity::new(1, 0),
1071                texture: tex1,
1072                transform: Mat3x3::IDENTITY,
1073                color: Color::WHITE,
1074                source_rect: None,
1075                size: Vec2::one(),
1076                z_layer: 5.0, // Lower Z-layer
1077                flip_x: false,
1078                flip_y: false,
1079            },
1080        ];
1081
1082        // Sort should prioritize Z-layer first, then texture
1083        batch.sort_sprites();
1084
1085        // Lower Z should come first
1086        assert_eq!(batch.sprites[0].z_layer, 5.0);
1087        assert_eq!(batch.sprites[1].z_layer, 10.0);
1088    }
1089
1090    #[test]
1091    #[ignore] // Requires OpenGL context
1092    fn test_texture_batching_same_z_different_texture() {
1093        let backend = OpenGLBackend::new().unwrap();
1094        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
1095
1096        let tex1 = AssetHandle::new(1, 1);
1097        let tex2 = AssetHandle::new(2, 1);
1098
1099        batch.sprites = vec![
1100            SpriteInstance {
1101                entity: Entity::new(0, 0),
1102                texture: tex2,
1103                transform: Mat3x3::IDENTITY,
1104                color: Color::WHITE,
1105                source_rect: None,
1106                size: Vec2::one(),
1107                z_layer: 5.0,
1108                flip_x: false,
1109                flip_y: false,
1110            },
1111            SpriteInstance {
1112                entity: Entity::new(1, 0),
1113                texture: tex1,
1114                transform: Mat3x3::IDENTITY,
1115                color: Color::WHITE,
1116                source_rect: None,
1117                size: Vec2::one(),
1118                z_layer: 5.0, // Same Z-layer
1119                flip_x: false,
1120                flip_y: false,
1121            },
1122            SpriteInstance {
1123                entity: Entity::new(2, 0),
1124                texture: tex1,
1125                transform: Mat3x3::IDENTITY,
1126                color: Color::WHITE,
1127                source_rect: None,
1128                size: Vec2::one(),
1129                z_layer: 5.0, // Same Z-layer, same texture
1130                flip_x: false,
1131                flip_y: false,
1132            },
1133        ];
1134
1135        // Sort should group sprites with same Z by texture
1136        batch.sort_sprites();
1137
1138        // All have same Z, so should be sorted by texture
1139        assert_eq!(batch.sprites[0].texture, tex1);
1140        assert_eq!(batch.sprites[1].texture, tex1);
1141        assert_eq!(batch.sprites[2].texture, tex2);
1142    }
1143
1144    #[test]
1145    #[ignore] // Requires OpenGL context
1146    fn test_texture_batching_disabled() {
1147        let backend = OpenGLBackend::new().unwrap();
1148        let config = SpriteBatchConfig {
1149            initial_capacity: 1024,
1150            max_batch_size: 10000,
1151            enable_z_sorting: true,
1152            enable_batching: false, // Batching disabled
1153        };
1154        let mut batch = SpriteBatch::new(backend, config).unwrap();
1155
1156        let tex1 = AssetHandle::new(1, 1);
1157        let tex2 = AssetHandle::new(2, 1);
1158
1159        batch.sprites = vec![
1160            SpriteInstance {
1161                entity: Entity::new(0, 0),
1162                texture: tex2,
1163                transform: Mat3x3::IDENTITY,
1164                color: Color::WHITE,
1165                source_rect: None,
1166                size: Vec2::one(),
1167                z_layer: 5.0,
1168                flip_x: false,
1169                flip_y: false,
1170            },
1171            SpriteInstance {
1172                entity: Entity::new(1, 0),
1173                texture: tex1,
1174                transform: Mat3x3::IDENTITY,
1175                color: Color::WHITE,
1176                source_rect: None,
1177                size: Vec2::one(),
1178                z_layer: 10.0,
1179                flip_x: false,
1180                flip_y: false,
1181            },
1182        ];
1183
1184        // With batching disabled, should only sort by Z-layer
1185        batch.sort_sprites();
1186
1187        // Should be sorted by Z only, not texture
1188        assert_eq!(batch.sprites[0].z_layer, 5.0);
1189        assert_eq!(batch.sprites[1].z_layer, 10.0);
1190    }
1191
1192    #[test]
1193    #[ignore] // Requires OpenGL context
1194    fn test_texture_batching_stress_test() {
1195        let backend = OpenGLBackend::new().unwrap();
1196        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
1197
1198        // Create 100 sprites with 10 different textures (10 sprites per texture)
1199        for texture_id in 0..10 {
1200            let texture = AssetHandle::new(texture_id, 1);
1201            for sprite_id in 0..10 {
1202                batch.sprites.push(SpriteInstance {
1203                    entity: Entity::new((texture_id * 10 + sprite_id) as u32, 0),
1204                    texture,
1205                    transform: Mat3x3::IDENTITY,
1206                    color: Color::WHITE,
1207                    source_rect: None,
1208                    size: Vec2::one(),
1209                    z_layer: 0.0,
1210                    flip_x: false,
1211                    flip_y: false,
1212                });
1213            }
1214        }
1215
1216        assert_eq!(batch.sprites.len(), 100);
1217
1218        // Sort should group sprites by texture
1219        batch.sort_sprites();
1220
1221        // Verify that sprites are grouped by texture
1222        for i in 0..10 {
1223            let start = i * 10;
1224            let end = start + 10;
1225            let texture = batch.sprites[start].texture;
1226
1227            for j in start..end {
1228                assert_eq!(
1229                    batch.sprites[j].texture, texture,
1230                    "Sprite {} should have texture {:?}",
1231                    j, texture
1232                );
1233            }
1234        }
1235    }
1236
1237    #[test]
1238    #[ignore] // Requires OpenGL context
1239    fn test_max_batch_size_enforcement() {
1240        let backend = OpenGLBackend::new().unwrap();
1241        let config = SpriteBatchConfig {
1242            initial_capacity: 1024,
1243            max_batch_size: 5, // Small batch size for testing
1244            enable_z_sorting: true,
1245            enable_batching: true,
1246        };
1247        let mut batch = SpriteBatch::new(backend, config).unwrap();
1248
1249        // Create 10 sprites with the same texture
1250        let texture = AssetHandle::new(1, 1);
1251        for i in 0..10 {
1252            batch.sprites.push(SpriteInstance {
1253                entity: Entity::new(i, 0),
1254                texture,
1255                transform: Mat3x3::IDENTITY,
1256                color: Color::WHITE,
1257                source_rect: None,
1258                size: Vec2::one(),
1259                z_layer: 0.0,
1260                flip_x: false,
1261                flip_y: false,
1262            });
1263        }
1264
1265        assert_eq!(batch.sprites.len(), 10);
1266
1267        // Even though all sprites have the same texture, max_batch_size should
1268        // force them to be split into multiple batches (2 batches of 5)
1269        // This is verified in generate_batches() method (lines 338-356)
1270    }
1271
1272    #[test]
1273    #[ignore] // Requires OpenGL context
1274    fn test_interleaved_textures_batching() {
1275        let backend = OpenGLBackend::new().unwrap();
1276        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default()).unwrap();
1277
1278        let tex1 = AssetHandle::new(1, 1);
1279        let tex2 = AssetHandle::new(2, 1);
1280
1281        // Create interleaved pattern: tex1, tex2, tex1, tex2, tex1, tex2
1282        for i in 0..6 {
1283            let texture = if i % 2 == 0 { tex1 } else { tex2 };
1284            batch.sprites.push(SpriteInstance {
1285                entity: Entity::new(i, 0),
1286                texture,
1287                transform: Mat3x3::IDENTITY,
1288                color: Color::WHITE,
1289                source_rect: None,
1290                size: Vec2::one(),
1291                z_layer: 0.0,
1292                flip_x: false,
1293                flip_y: false,
1294            });
1295        }
1296
1297        // Before sorting, interleaved pattern
1298        assert_eq!(batch.sprites[0].texture, tex1);
1299        assert_eq!(batch.sprites[1].texture, tex2);
1300        assert_eq!(batch.sprites[2].texture, tex1);
1301
1302        // After sorting, should be grouped
1303        batch.sort_sprites();
1304
1305        // All tex1 sprites should be together, all tex2 sprites together
1306        // The exact order depends on Handle comparison
1307        let first_texture = batch.sprites[0].texture;
1308        let mut found_second = false;
1309        let mut second_texture = first_texture;
1310
1311        for sprite in &batch.sprites {
1312            if sprite.texture != first_texture {
1313                if !found_second {
1314                    found_second = true;
1315                    second_texture = sprite.texture;
1316                } else {
1317                    // Should still be second texture, not back to first
1318                    assert_eq!(sprite.texture, second_texture);
1319                }
1320            }
1321        }
1322
1323        // Should have exactly 2 distinct texture groups
1324        assert!(found_second);
1325    }
1326
1327    // =========================================================================
1328    // Integration Tests
1329    // =========================================================================
1330
1331    #[test]
1332    #[ignore] // Requires OpenGL context
1333    fn test_gather_sprites_from_world() {
1334        use crate::assets::AssetServer;
1335        use crate::ecs::components::{Sprite, Transform2D};
1336
1337        let mut world = World::new();
1338        let mut asset_server = AssetServer::new();
1339
1340        // Create some test entities with Sprite + Transform2D
1341        let texture = asset_server.load::<crate::assets::loaders::TextureAsset>("test.png");
1342
1343        let e1 = world.spawn_empty();
1344        world.insert(e1, Transform2D::from_position(Vec2::new(10.0, 20.0)));
1345        world.insert(e1, Sprite::new(texture));
1346
1347        let e2 = world.spawn_empty();
1348        world.insert(e2, Transform2D::from_position(Vec2::new(30.0, 40.0)));
1349        world.insert(e2, Sprite::new(texture).with_color(Color::RED));
1350
1351        let e3 = world.spawn_empty();
1352        world.insert(e3, Transform2D::from_position(Vec2::new(50.0, 60.0)));
1353        world.insert(
1354            e3,
1355            Sprite::new(texture).with_source_rect(Rect::new(0.0, 0.0, 32.0, 32.0)),
1356        );
1357
1358        // Create entity without Sprite (should be ignored)
1359        let e4 = world.spawn_empty();
1360        world.insert(e4, Transform2D::from_position(Vec2::new(70.0, 80.0)));
1361
1362        // Create sprite batch (stub backend since we're just testing gather)
1363        let backend = OpenGLBackend::new().expect("Failed to create OpenGL backend");
1364        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default())
1365            .expect("Failed to create sprite batch");
1366
1367        // Gather sprites
1368        batch
1369            .gather_sprites(&world)
1370            .expect("Failed to gather sprites");
1371
1372        // Should have gathered 3 sprites (e1, e2, e3), not e4
1373        assert_eq!(batch.sprite_count(), 3);
1374
1375        // Verify entity IDs are correct
1376        let entities: Vec<Entity> = batch.sprites.iter().map(|s| s.entity).collect();
1377        assert!(entities.contains(&e1));
1378        assert!(entities.contains(&e2));
1379        assert!(entities.contains(&e3));
1380        assert!(!entities.contains(&e4));
1381
1382        // Verify colors are preserved
1383        let sprite1 = batch.sprites.iter().find(|s| s.entity == e1).unwrap();
1384        assert_eq!(sprite1.color, Color::WHITE);
1385
1386        let sprite2 = batch.sprites.iter().find(|s| s.entity == e2).unwrap();
1387        assert_eq!(sprite2.color, Color::RED);
1388
1389        // Verify source rect is preserved
1390        let sprite3 = batch.sprites.iter().find(|s| s.entity == e3).unwrap();
1391        assert!(sprite3.source_rect.is_some());
1392        let source = sprite3.source_rect.unwrap();
1393        assert_eq!(source.x, 0.0);
1394        assert_eq!(source.y, 0.0);
1395        assert_eq!(source.width, 32.0);
1396        assert_eq!(source.height, 32.0);
1397
1398        // Verify Z-layers match Y positions
1399        assert_eq!(sprite1.z_layer, 20.0);
1400        assert_eq!(sprite2.z_layer, 40.0);
1401        assert_eq!(sprite3.z_layer, 60.0);
1402    }
1403
1404    #[test]
1405    #[ignore] // Requires OpenGL context
1406    fn test_gather_sprites_empty_world() {
1407        use crate::assets::AssetServer;
1408
1409        let world = World::new();
1410        let _asset_server = AssetServer::new();
1411
1412        let backend = OpenGLBackend::new().expect("Failed to create OpenGL backend");
1413        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default())
1414            .expect("Failed to create sprite batch");
1415
1416        // Should handle empty world gracefully
1417        batch
1418            .gather_sprites(&world)
1419            .expect("Failed to gather sprites");
1420        assert_eq!(batch.sprite_count(), 0);
1421    }
1422
1423    #[test]
1424    #[ignore] // Requires OpenGL context
1425    fn test_gather_sprites_clears_previous_frame() {
1426        use crate::assets::AssetServer;
1427        use crate::ecs::components::{Sprite, Transform2D};
1428
1429        let mut world = World::new();
1430        let mut asset_server = AssetServer::new();
1431
1432        let texture = asset_server.load::<crate::assets::loaders::TextureAsset>("test.png");
1433
1434        let backend = OpenGLBackend::new().expect("Failed to create OpenGL backend");
1435        let mut batch = SpriteBatch::new(backend, SpriteBatchConfig::default())
1436            .expect("Failed to create sprite batch");
1437
1438        // First frame: 2 sprites
1439        let e1 = world.spawn_empty();
1440        world.insert(e1, Transform2D::from_position(Vec2::new(10.0, 20.0)));
1441        world.insert(e1, Sprite::new(texture));
1442
1443        let e2 = world.spawn_empty();
1444        world.insert(e2, Transform2D::from_position(Vec2::new(30.0, 40.0)));
1445        world.insert(e2, Sprite::new(texture));
1446
1447        batch
1448            .gather_sprites(&world)
1449            .expect("Failed to gather sprites");
1450        assert_eq!(batch.sprite_count(), 2);
1451
1452        // Second frame: despawn one sprite
1453        world.despawn(e2);
1454
1455        batch
1456            .gather_sprites(&world)
1457            .expect("Failed to gather sprites");
1458        // Should only have 1 sprite now, not 3 (shouldn't accumulate)
1459        assert_eq!(batch.sprite_count(), 1);
1460    }
1461}