• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

djeedai / bevy_hanabi / 28974755201

08 Jul 2026 08:50PM UTC coverage: 59.296% (+0.7%) from 58.625%
28974755201

Pull #547

github

web-flow
Merge b45017140 into cf35d53d5
Pull Request #547: Batch compatible effect instances in init/update passes

291 of 500 new or added lines in 7 files covered. (58.2%)

11 existing lines in 3 files now uncovered.

5422 of 9144 relevant lines covered (59.3%)

685.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.82
/src/render/batch.rs
1
use std::{fmt::Debug, num::NonZeroU32};
2

3
use bevy::{
4
    ecs::entity::EntityHashMap,
5
    prelude::*,
6
    render::{
7
        render_resource::{Buffer, BufferVec, CachedComputePipelineId},
8
        renderer::{RenderDevice, RenderQueue},
9
        sync_world::MainEntity,
10
    },
11
};
12
use fixedbitset::FixedBitSet;
13
use wgpu::BufferUsages;
14

15
use super::{
16
    effect_cache::EffectSlice,
17
    event::{CachedChildInfo, CachedEffectEvents},
18
    BufferBindingSource, ExtractedEffectMesh, LayoutFlags, PropertyBindGroupKey,
19
};
20
use crate::{
21
    render::{
22
        aligned_buffer_vec::AlignedBufferVec, buffer_table::BufferTableId, effect_cache::SlabId,
23
        ExtractedEffect, ExtractedSpawner, GpuBatchInfo, GpuSpawnerParams,
24
    },
25
    AlphaMode, EffectAsset, ParticleLayout, TextureLayout,
26
};
27

28
#[derive(Debug, Clone, Copy)]
29
pub(crate) enum BatchSpawnInfo {
30
    /// Spawn a number of particles uploaded from CPU each frame.
31
    CpuSpawner {
32
        /// Total number of particles to spawn for the batch. This is only used
33
        /// to calculate the number of compute workgroups to dispatch.
34
        total_spawn_count: u32,
35
    },
36

37
    /// Spawn a number of particles calculated on GPU from "spawn events", which
38
    /// generally emitted by another effect.
39
    GpuSpawner {
40
        /// Index into the init indirect dispatch buffer of the
41
        /// [`GpuDispatchIndirect`] instance for this batch.
42
        ///
43
        /// [`GpuDispatchIndirect`]: super::GpuDispatchIndirect
44
        init_indirect_dispatch_index: u32,
45
        /// Index of the [`EventBuffer`] where the GPU spawn events consumed by
46
        /// this batch are stored.
47
        ///
48
        /// [`EventBuffer`]: super::event::EventBuffer
49
        #[allow(dead_code)]
50
        event_buffer_index: u32,
51
    },
52
}
53

54
impl BatchSpawnInfo {
55
    #[inline]
56
    #[must_use]
57
    pub fn is_cpu(&self) -> bool {
3,346✔
58
        matches!(self, Self::CpuSpawner { .. })
3,346✔
59
    }
60

61
    #[inline]
62
    #[must_use]
63
    #[allow(dead_code)]
NEW
64
    pub fn cpu_spawn_count(&self) -> u32 {
×
NEW
65
        if let Self::CpuSpawner { total_spawn_count } = self {
×
NEW
66
            *total_spawn_count
×
67
        } else {
NEW
68
            u32::MAX
×
69
        }
70
    }
71
}
72

73
#[derive(Debug, Clone, Copy)]
74
pub(crate) struct BatchEffectData {
75
    /// Main [`Entity`] this effect instance was extracted from.
76
    pub entity: u32,
77
    /// Offset into the GPU buffer where the particles for this effect instance
78
    /// are located. This is uploaded to GPU as [`GpuBatchInfo::base_particle`].
79
    pub slab_offset: u32,
80
    pub draw_indirect_buffer_row_index: BufferTableId,
81
    pub metadata_table_id: BufferTableId,
82
    pub sort_fill_indirect_dispatch_index: Option<u32>,
83
    /// Offset in bytes of the [`GpuBatchInfo`] into the
84
    /// [`Batcher::batch_info_buffer`], which contains the location of the
85
    /// particles to render for this effect instance. Ready for bind group
86
    /// dynamic offset usage.
87
    pub render_batch_info_offset: u32,
88
}
89

90
/// Batch of effects dispatched and rendered together.
91
#[derive(Debug, Clone)]
92
pub(crate) struct EffectBatch {
93
    /// ID of the [`GpuBatchInfo`] in the global shared array for this batch.
94
    pub batch_info_id: u32,
95
    /// Handle of the underlying effect asset describing the effect.
96
    pub handle: Handle<EffectAsset>,
97
    /// ID of the particle slab in the [`EffectBuffer`] where all the batched
98
    /// effects are stored.
99
    ///
100
    /// [`EffectBuffer`]: super::effect_cache::EffectBuffer
101
    pub slab_id: SlabId,
102
    /// Spawn info for this batch
103
    pub spawn_info: BatchSpawnInfo,
104
    /// Specialized init and update compute pipelines.
105
    pub init_and_update_pipeline_ids: InitAndUpdatePipelineIds,
106
    /// Configured shader used for the particle rendering of this group.
107
    /// Note that we don't need to keep the init/update shaders alive because
108
    /// their pipeline specialization is doing it via the specialization key.
109
    pub render_shader: Handle<Shader>,
110
    /// ID of the particle slab where the parent effect is stored, if any. If a
111
    /// parent exists, its particle buffer is made available (read-only) for
112
    /// a child effect to read its attributes.
113
    #[allow(dead_code)]
114
    pub parent_slab_id: Option<SlabId>,
115
    pub parent_min_binding_size: Option<NonZeroU32>,
116
    pub parent_binding_source: Option<BufferBindingSource>,
117
    /// Event buffers of child effects, if any.
118
    pub child_event_buffers: Vec<(Entity, BufferBindingSource)>,
119
    /// Index of the property buffer, if any.
120
    pub property_key: Option<PropertyBindGroupKey>,
121
    /// Index of the first [`GpuSpawnerParams`] entry of the effects in the
122
    /// batch. Subsequent batched effects have their entries following linearly
123
    /// after that one.
124
    ///
125
    /// [`GpuSpawnerParams`]: super::GpuSpawnerParams
126
    pub spawner_base: u32,
127
    /// Per-effect metadata used for draw and sorting. The number of elements
128
    /// equals the number of effects batched together inside this batch.
129
    pub effect_data: Vec<BatchEffectData>,
130
    /// Particle layout shared by all batched effects and groups.
131
    pub particle_layout: ParticleLayout,
132
    /// Flags describing the render layout.
133
    pub layout_flags: LayoutFlags,
134
    /// Asset ID of the effect mesh to draw.
135
    pub mesh: AssetId<Mesh>,
136
    /// Texture layout.
137
    pub texture_layout: TextureLayout,
138
    /// Textures.
139
    pub textures: Vec<Handle<Image>>,
140
    /// Alpha mode.
141
    pub alpha_mode: AlphaMode,
142
    pub cached_effect_events: Option<CachedEffectEvents>,
143
}
144

145
impl EffectBatch {
146
    /// Try to merge another batch into this one.
147
    ///
148
    /// # Returns
149
    ///
150
    /// Returns `Ok(())` if merged successfully. Returns `Err(input)` if the
151
    /// input couldn't be merged.
152
    #[allow(clippy::result_large_err)]
153
    pub fn try_merge(&mut self, input: EffectBatch) -> Result<(), EffectBatch> {
1,673✔
154
        // Keep merging conservative; parent/child/event-linked effects require
155
        // additional per-effect bindings and aren't safe to merge yet.
156
        if self.handle != input.handle
1,673✔
157
            || self.slab_id != input.slab_id
1,673✔
158
            || self.init_and_update_pipeline_ids != input.init_and_update_pipeline_ids
1,673✔
159
            || self.mesh != input.mesh
1,673✔
160
            || self.alpha_mode != input.alpha_mode
1,673✔
161
            || self.texture_layout != input.texture_layout
1,673✔
162
            || self.textures != input.textures
1,673✔
163
            || self.property_key != input.property_key
1,673✔
164
            || self.parent_slab_id != input.parent_slab_id
1,673✔
165
            || self.parent_binding_source != input.parent_binding_source
1,673✔
166
            || self.child_event_buffers != input.child_event_buffers
1,673✔
167
            || self.cached_effect_events.is_some()
1,673✔
168
            || input.cached_effect_events.is_some()
1,673✔
169
            || !self.spawn_info.is_cpu()
1,673✔
170
            || !input.spawn_info.is_cpu()
1,673✔
171
        {
NEW
172
            return Err(input);
×
173
        }
174

175
        self.effect_data.extend(input.effect_data);
5,019✔
176
        if let (
177
            BatchSpawnInfo::CpuSpawner {
178
                total_spawn_count: self_count,
1,673✔
179
            },
180
            BatchSpawnInfo::CpuSpawner {
NEW
181
                total_spawn_count: input_count,
×
182
            },
183
        ) = (&mut self.spawn_info, input.spawn_info)
3,346✔
184
        {
NEW
185
            *self_count += input_count;
×
186
        }
187
        Ok(())
1,673✔
188
    }
189
}
190

191
#[derive(Debug, Clone, Copy)]
192
pub(crate) struct EffectBatchIndex(pub u32);
193

194
#[derive(Resource)]
195
pub(crate) struct Batcher {
196
    /// Effect batches in the order they were inserted by [`push()`], indexed by
197
    /// the returned [`EffectBatchIndex`].
198
    ///
199
    /// [`push()`]: Self::push
200
    batches: Vec<EffectBatch>,
201
    /// Index of the dispatch queue used for indirect fill dispatch and
202
    /// submitted to [`GpuBufferOperations`].
203
    pub(super) dispatch_queue_index: Option<u32>,
204
    /// Global shared GPU buffer storing the various `BatchInfo` structs for the
205
    /// active batches. This is dynamically updated each frame based on current
206
    /// batching, with one entry per batch (= one entry per dispatch/draw).
207
    batch_info_buffer: AlignedBufferVec<GpuBatchInfo>,
208
    /// Debug: was begin_batch() called without end_batch()?
209
    is_batch_open: bool,
210
    /// Buffer containing the prefix sums for all batches.
211
    prefix_sum_buffer: BufferVec<u32>,
212
    /// Current running prefix sum counter of CPU values passed to
213
    /// [`Self::push()`], and used to initialize the prefix sum of each batch
214
    /// for the next init pass.
215
    cpu_prefix_sum_value: u32,
216
}
217

218
impl FromWorld for Batcher {
219
    fn from_world(world: &mut World) -> Self {
4✔
220
        let device = world.resource::<RenderDevice>();
12✔
221
        let item_align =
4✔
222
            NonZeroU32::new(device.limits().min_storage_buffer_offset_alignment).unwrap();
12✔
223
        let batch_info_buffer = AlignedBufferVec::new(
224
            BufferUsages::STORAGE,
225
            Some(item_align.into()),
4✔
226
            Some("hanabi:buffer:batch_info".to_string()),
4✔
227
        );
228
        let mut prefix_sum_buffer = BufferVec::new(BufferUsages::STORAGE);
8✔
229
        prefix_sum_buffer.set_label(Some("prefix_sum_buffer"));
12✔
230
        Self {
231
            batches: vec![],
8✔
232
            dispatch_queue_index: None,
233
            batch_info_buffer,
234
            is_batch_open: false,
235
            prefix_sum_buffer,
236
            cpu_prefix_sum_value: 0,
237
        }
238
    }
239
}
240

241
impl Batcher {
242
    #[inline]
243
    pub fn batch_info_buffer(&self) -> Option<&Buffer> {
551✔
244
        self.batch_info_buffer.buffer()
1,102✔
245
    }
246

247
    #[inline]
248
    pub fn batch_info_buffer_aligned_size(&self) -> u32 {
1,656✔
249
        self.batch_info_buffer.aligned_size() as u32
1,656✔
250
    }
251

252
    #[inline]
253
    pub fn prefix_sum_buffer(&self) -> Option<&Buffer> {
551✔
254
        self.prefix_sum_buffer.buffer()
1,102✔
255
    }
256

257
    pub fn clear(&mut self) {
570✔
258
        self.batches.clear();
1,140✔
259
        self.dispatch_queue_index = None;
570✔
260
        self.prefix_sum_buffer.clear();
1,140✔
261
        self.batch_info_buffer.clear();
1,140✔
262
    }
263

264
    /// Begin a new batch of effects.
265
    fn begin_batch(&mut self, base_particle: u32, spawner_base: u32) -> u32 {
2,775✔
266
        assert!(!self.is_batch_open, "Duplicate call to begin_batch()");
5,550✔
267

268
        let prefix_sum_offset = self.prefix_sum_buffer.len() as u32;
5,550✔
269

270
        let batch_info_base = self.batch_info_buffer.len() as u32;
5,550✔
271
        let batch_info = GpuBatchInfo {
272
            total_spawn_count: 0,
273
            total_update_count: 0,
274
            spawner_base,
275
            base_particle,
276
            prefix_sum_offset,
277
            prefix_sum_count: u32::MAX, // invalid; set in end_batch()
278
        };
279
        trace!("batch info = {:?}", batch_info);
2,775✔
280
        self.batch_info_buffer.push(batch_info);
8,325✔
281
        self.is_batch_open = true;
2,775✔
282

283
        batch_info_base
2,775✔
284
    }
285

286
    /// Add a single effect instance entry to the current batch prefix array.
287
    fn add_effect_to_batch(&mut self, prefix_value: u32) {
4,448✔
288
        assert!(
4,448✔
289
            self.is_batch_open,
4,448✔
290
            "Cannot add effect before calling begin_batch()"
291
        );
292
        self.prefix_sum_buffer.push(prefix_value);
13,344✔
293
    }
294

295
    /// Try to end the current batch, if any. Does nothing if no batch is
296
    /// pending.
297
    ///
298
    /// # Returns
299
    ///
300
    /// Returns `true` if a batch was closed, or `false` otherwise.
301
    pub fn try_end_batch(&mut self) -> bool {
1,121✔
302
        if self.is_batch_open {
1,121✔
303
            self.end_batch();
1,102✔
304
            true
551✔
305
        } else {
306
            false
570✔
307
        }
308
    }
309

310
    /// End the current batch.
311
    ///
312
    /// # Panics
313
    ///
314
    /// Panics if no batch is pending.
315
    fn end_batch(&mut self) {
2,775✔
316
        assert!(
2,775✔
317
            self.is_batch_open,
2,775✔
318
            "Call to end_batch() without begin_batch()"
319
        );
320

321
        let batch = self
5,550✔
322
            .batch_info_buffer
2,775✔
323
            .last_mut()
324
            .expect("No open batch. Missing begin_batch() call?");
325
        let end = self.prefix_sum_buffer.len() as u32;
5,550✔
326
        assert!(end >= batch.prefix_sum_offset);
5,550✔
327
        batch.prefix_sum_count = end - batch.prefix_sum_offset;
2,775✔
328

329
        self.is_batch_open = false;
2,775✔
330
    }
331

332
    /// Insert a new batch into the collection.
333
    ///
334
    /// Try to merge the `effect_batch` into the last pushed batch, or create a
335
    /// new standalone batch if not possible (incompatible effects, or first
336
    /// one). The `instance_spawn_count` is the number of particles to spawn
337
    /// from CPU, and is used to initialize the prefix sum buffer, for use by
338
    /// the init pass (CPU spawn). The update pass' prefix sum is recomputed
339
    /// inside the same buffer between the init and update passes, directly on
340
    /// GPU.
341
    ///
342
    /// # Returns
343
    ///
344
    /// This returns the index of the new batch if the inserted one couldn't be
345
    /// merged with a previous batch. Otherwise the input batch was merged with
346
    /// an existing one, and therefore share its index; in that case `None` is
347
    /// returned.
348
    pub fn push(
2,224✔
349
        &mut self,
350
        effect_batch: EffectBatch,
351
        instance_spawn_count: u32,
352
    ) -> Option<EffectBatchIndex> {
353
        assert!(effect_batch.effect_data.len() == 1);
4,448✔
354

355
        let effect_batch = if let Some(batch) = self.batches.last_mut() {
4,448✔
NEW
356
            let Err(effect_batch) = batch.try_merge(effect_batch) else {
×
357
                // Successfully batched
358
                self.add_effect_to_batch(self.cpu_prefix_sum_value);
5,019✔
359
                self.cpu_prefix_sum_value += instance_spawn_count;
1,673✔
360
                return None;
1,673✔
361
            };
362
            // Failed to merge incompatible batches
NEW
363
            effect_batch
×
364
        } else {
365
            // No prior batch to merge with
366
            effect_batch
551✔
367
        };
368

369
        // Close the previous batch if any
NEW
370
        self.try_end_batch();
×
371

372
        // Start a new batch
UNCOV
373
        let index = self.batches.len() as u32;
×
NEW
374
        let base_particle = effect_batch.effect_data[0].slab_offset;
×
UNCOV
375
        self.batches.push(effect_batch);
×
376

377
        // Begin a new batch with this new effect instance
NEW
378
        let batch_info_id = self.begin_batch(base_particle, self.last().unwrap().spawner_base);
×
NEW
379
        self.last_mut().unwrap().batch_info_id = batch_info_id;
×
NEW
380
        self.cpu_prefix_sum_value = 0;
×
381

NEW
382
        self.add_effect_to_batch(self.cpu_prefix_sum_value);
×
NEW
383
        self.cpu_prefix_sum_value += instance_spawn_count;
×
384

NEW
385
        Some(EffectBatchIndex(index))
×
386
    }
387

388
    #[inline]
389
    pub fn last(&self) -> Option<&EffectBatch> {
551✔
390
        self.batches.last()
551✔
391
    }
392

393
    #[inline]
394
    pub fn last_mut(&mut self) -> Option<&mut EffectBatch> {
551✔
395
        self.batches.last_mut()
551✔
396
    }
397

398
    pub fn len(&self) -> usize {
1,102✔
399
        self.batches.len()
2,204✔
400
    }
401

402
    pub fn is_empty(&self) -> bool {
1,140✔
403
        self.batches.is_empty()
2,280✔
404
    }
405

406
    /// Get an iterator over the sorted sequence of effect batches.
407
    #[inline]
408
    pub fn iter(&self) -> &[EffectBatch] {
1,653✔
409
        &self.batches
1,653✔
410
    }
411

412
    pub fn get(&self, index: EffectBatchIndex) -> Option<&EffectBatch> {
2,198✔
413
        if index.0 < self.batches.len() as u32 {
4,396✔
414
            Some(&self.batches[index.0 as usize])
2,198✔
415
        } else {
416
            None
×
417
        }
418
    }
419

420
    /// Allocate the render batches.
421
    ///
422
    /// Allocate one render batch info entry per effect instance after compute
423
    /// batching is finalized, to avoid nesting begin_batch()/end_batch() calls.
424
    /// Currently there's no rendering batching, so we allocate one GpuBatchInfo
425
    /// per effect instance.
426
    pub fn allocate_render_batches(&mut self) {
570✔
427
        let batch_info_aligned_size = self.batch_info_buffer_aligned_size();
1,710✔
428
        let num_batches = self.batches.len();
1,710✔
429
        for batch_index in 0..num_batches {
1,121✔
NEW
430
            let num_effects = self.batches[batch_index].effect_data.len();
×
431
            for effect_index in 0..num_effects {
2,224✔
NEW
432
                let effect_batch = &self.batches[batch_index];
×
NEW
433
                let effect_data = &effect_batch.effect_data[effect_index];
×
NEW
434
                if effect_data.render_batch_info_offset != u32::MAX {
×
NEW
435
                    continue;
×
436
                }
NEW
437
                let spawner_index = effect_batch.spawner_base + effect_index as u32;
×
438

NEW
439
                let base_particle = effect_data.slab_offset;
×
NEW
440
                let render_batch_info_id = self.begin_batch(base_particle, spawner_index);
×
441
                // Render-only batch infos are not processed by vfx_prefix_sum; keep
442
                // a relative offset of 0 for the single effect in that batch.
NEW
443
                self.add_effect_to_batch(0);
×
NEW
444
                self.end_batch();
×
NEW
445
                let render_batch_info_offset = render_batch_info_id
×
NEW
446
                    .checked_mul(batch_info_aligned_size)
×
447
                    .unwrap();
448

NEW
449
                self.batches[batch_index].effect_data[effect_index].render_batch_info_offset =
×
NEW
450
                    render_batch_info_offset;
×
451
            }
452
        }
453
    }
454

455
    #[inline]
456
    pub fn write_batch_info_buffer(&mut self, device: &RenderDevice, queue: &RenderQueue) -> bool {
570✔
457
        self.batch_info_buffer.write_buffer(device, queue)
2,280✔
458
    }
459

460
    pub fn write_prefix_sum_buffer(&mut self, device: &RenderDevice, queue: &RenderQueue) -> bool {
570✔
461
        let mut reallocated = false;
1,140✔
462
        let cpu_len = self.prefix_sum_buffer.len();
1,710✔
463
        if cpu_len > 0 {
570✔
464
            let gpu_capacity = self.prefix_sum_buffer.capacity();
1,653✔
465
            if cpu_len > gpu_capacity {
554✔
466
                self.prefix_sum_buffer.reserve(cpu_len, device);
12✔
467
                reallocated = true;
3✔
468
            }
469
            assert!(self.prefix_sum_buffer.buffer().is_some());
1,653✔
470
            self.prefix_sum_buffer.write_buffer(device, queue);
2,204✔
471
        }
472
        reallocated
570✔
473
    }
474
}
475

476
/// Information that the [`EffectSorter`] maintains in order to sort each
477
/// effect into the proper order.
478
pub(crate) struct EffectToBeSorted {
479
    /// The render-world entity of the effect.
480
    pub(crate) entity: Entity,
481
    /// The index of the buffer that the indirect indices for this effect are
482
    /// stored in.
483
    pub(crate) slab_id: SlabId,
484
    /// The offset within the buffer described above at which the indirect
485
    /// indices for this effect start.
486
    ///
487
    /// This is in elements, not bytes.
488
    pub(crate) base_instance: u32,
489
}
490

491
/// The key that we sort effects by for optimum batching.
492
#[derive(Clone, Copy, PartialEq, PartialOrd, Eq, Ord)]
493
struct EffectSortKey {
494
    /// The level in the dependency graph.
495
    ///
496
    /// Parents always have lower levels than their children.
497
    level: u32,
498
    /// The index of the buffer that the indirect indices for this effect are
499
    /// stored in.
500
    slab_id: SlabId,
501
    /// The offset within the buffer described above at which the indirect
502
    /// indices for this effect start.
503
    base_instance: u32,
504
}
505

506
/// Sorts effects into the proper order for batching.
507
///
508
/// This places parents before children and also tries to place effects in the
509
/// same slab together.
510
pub(crate) struct EffectSorter {
511
    /// Information that we keep about each effect.
512
    pub effects: Vec<EffectToBeSorted>,
513
    /// A mapping from a child to its parent, if it has one.
514
    pub child_to_parent: EntityHashMap<Entity>,
515
}
516

517
impl EffectSorter {
518
    /// Creates a new [`EffectSorter`].
519
    pub fn new() -> EffectSorter {
571✔
520
        EffectSorter {
521
            effects: vec![],
571✔
522
            child_to_parent: default(),
571✔
523
        }
524
    }
525

526
    /// Insert an effect to be sorted.
527
    pub fn insert(
2,224✔
528
        &mut self,
529
        entity: Entity,
530
        slab_id: SlabId,
531
        base_instance: u32,
532
        parent: Option<Entity>,
533
    ) {
534
        self.effects.push(EffectToBeSorted {
6,672✔
535
            entity,
4,448✔
536
            slab_id,
2,224✔
537
            base_instance,
2,224✔
538
        });
539
        if let Some(parent) = parent {
2,224✔
540
            self.child_to_parent.insert(entity, parent);
×
541
        }
542
    }
543

544
    /// Sorts all the effects into the optimal order for batching.
545
    pub fn sort(&mut self) {
572✔
546
        // trace!("Sorting {} effects...", self.effects.len());
547
        // for effect in &self.effects {
548
        //     trace!(
549
        //         "+ {}: slab={:?} base_instance={:?}",
550
        //         effect.entity,
551
        //         effect.slab_id,
552
        //         effect.base_instance
553
        //     );
554
        // }
555
        // trace!("child->parent:");
556
        // for (k, v) in &self.child_to_parent {
557
        //     trace!("+ c[{k}] -> p[{v}]");
558
        // }
559

560
        // First, create a map of entity to index.
561
        let mut entity_to_index = EntityHashMap::default();
1,144✔
562
        for (index, effect) in self.effects.iter().enumerate() {
3,373✔
563
            entity_to_index.insert(effect.entity, index);
564
        }
565

566
        // Next, create a map of children to their parents.
567
        let mut children_to_parent: Vec<_> = (0..self.effects.len()).map(|_| vec![]).collect();
5,089✔
568
        for (kid, parent) in self.child_to_parent.iter() {
575✔
569
            let (parent_index, kid_index) = (entity_to_index[parent], entity_to_index[kid]);
570
            children_to_parent[kid_index].push(parent_index);
571
        }
572

573
        // Now topologically sort the graph. Create an ordering that places
574
        // children before parents.
575
        // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
576
        let mut ordering = vec![0; self.effects.len()];
2,288✔
577
        let mut visiting = FixedBitSet::with_capacity(self.effects.len());
2,288✔
578
        let mut visited = FixedBitSet::with_capacity(self.effects.len());
2,288✔
579
        while let Some(effect_index) = visited.zeroes().next() {
5,028✔
580
            visit(
581
                &mut ordering,
582
                &mut visiting,
583
                &mut visited,
584
                &children_to_parent,
585
                effect_index,
586
            );
587
        }
588

589
        // Compute levels.
590
        let mut levels = vec![0; self.effects.len()];
2,288✔
591
        for effect_index in ordering.into_iter().rev() {
6,174✔
592
            let level = levels[effect_index];
593
            for &parent in &children_to_parent[effect_index] {
6✔
594
                levels[parent] = levels[parent].max(level + 1);
595
            }
596
        }
597

598
        // Now sort the result.
599
        self.effects.sort_unstable_by_key(|effect| EffectSortKey {
1,144✔
600
            level: levels[entity_to_index[&effect.entity]],
6,708✔
601
            slab_id: effect.slab_id,
3,354✔
602
            base_instance: effect.base_instance,
3,354✔
603
        });
604

605
        // Helper function for topologically sorting the effect dependency graph
606
        fn visit(
2,231✔
607
            ordering: &mut Vec<usize>,
608
            visiting: &mut FixedBitSet,
609
            visited: &mut FixedBitSet,
610
            children_to_parent: &[Vec<usize>],
611
            effect_index: usize,
612
        ) {
613
            if visited.contains(effect_index) {
6,693✔
614
                return;
2✔
615
            }
616
            debug_assert!(
617
                !visiting.contains(effect_index),
618
                "Parent-child effect relation contains a cycle"
619
            );
620

621
            visiting.insert(effect_index);
6,687✔
622

623
            for &parent in &children_to_parent[effect_index] {
2,232✔
624
                visit(ordering, visiting, visited, children_to_parent, parent);
625
            }
626

627
            visited.insert(effect_index);
6,687✔
628
            ordering.push(effect_index);
6,687✔
629
        }
630
    }
631

632
    /// Iterate over the effects. This only iterates in sorted order if
633
    /// [`Self::sort()`] was called beforehand.
634
    #[inline]
635
    pub fn iter(&self) -> impl Iterator<Item = Entity> + use<'_> {
570✔
636
        self.effects.iter().map(|e| e.entity)
1,140✔
637
    }
638
}
639

640
/// Single effect batch to drive rendering.
641
///
642
/// This component is spawned into the render world during the prepare phase
643
/// ([`prepare_effects()`]), once per effect batch per group. In turns it
644
/// references an [`EffectBatch`] component containing all the shared data for
645
/// all the groups of the effect.
646
#[derive(Debug, Component)]
647
pub(crate) struct EffectDrawBatch {
648
    /// Index of the [`EffectBatch`] in the [`SortedEffectBatches`] this draw
649
    /// batch is part of.
650
    ///
651
    /// Note: currently there's a 1:1 mapping between effect batch and draw
652
    /// batch.
653
    pub effect_batch_index: EffectBatchIndex,
654
    /// Position of the emitter so we can compute distance to camera.
655
    pub translation: Vec3,
656
    /// The main-world entity that contains this effect.
657
    #[allow(dead_code)]
658
    pub main_entity: MainEntity,
659
}
660

661
impl EffectBatch {
662
    /// Create a new batch from a single input.
663
    pub fn from_input(
2,224✔
664
        main_entity: Entity,
665
        extracted_effect: &ExtractedEffect,
666
        extracted_spawner: &ExtractedSpawner,
667
        cached_mesh: &ExtractedEffectMesh,
668
        cached_effect_events: Option<&CachedEffectEvents>,
669
        cached_child_info: Option<&CachedChildInfo>,
670
        spawner_index: u32,
671
        input: &mut BatchInput,
672
        draw_indirect_buffer_row_index: BufferTableId,
673
        metadata_table_id: BufferTableId,
674
        property_key: Option<PropertyBindGroupKey>,
675
    ) -> EffectBatch {
676
        assert_eq!(
2,224✔
677
            input.event_buffer_index.is_some(),
4,448✔
678
            input.init_indirect_dispatch_index.is_some()
4,448✔
679
        );
680

681
        let spawn_info = if let Some(event_buffer_index) = input.event_buffer_index {
4,448✔
682
            BatchSpawnInfo::GpuSpawner {
683
                init_indirect_dispatch_index: input.init_indirect_dispatch_index.unwrap(),
×
684
                event_buffer_index,
685
            }
686
        } else {
687
            BatchSpawnInfo::CpuSpawner {
688
                total_spawn_count: extracted_spawner.spawn_count,
2,224✔
689
            }
690
        };
691

692
        EffectBatch {
693
            batch_info_id: u32::MAX, // allocated later once the batch is completed
694
            handle: extracted_effect.handle.clone(),
4,448✔
695
            slab_id: input.effect_slice.slab_id,
2,224✔
696
            spawn_info,
697
            init_and_update_pipeline_ids: input.init_and_update_pipeline_ids,
2,224✔
698
            render_shader: extracted_effect.effect_shaders.render.clone(),
4,448✔
699
            parent_slab_id: input.parent_slab_id,
2,224✔
700
            parent_min_binding_size: cached_child_info
2,224✔
701
                .map(|cci| cci.parent_particle_layout.min_binding_size32()),
702
            parent_binding_source: cached_child_info
2,224✔
703
                .map(|cci| cci.parent_buffer_binding_source.clone()),
704
            child_event_buffers: input.child_effects.clone(),
4,448✔
705
            property_key,
706
            spawner_base: spawner_index,
707
            effect_data: vec![BatchEffectData {
4,448✔
708
                entity: main_entity.index_u32(),
709
                slab_offset: input.effect_slice.slice.start,
710
                draw_indirect_buffer_row_index,
711
                metadata_table_id,
712
                sort_fill_indirect_dispatch_index: None,
713
                render_batch_info_offset: u32::MAX,
714
            }],
715
            particle_layout: input.effect_slice.particle_layout.clone(),
4,448✔
716
            layout_flags: extracted_effect.layout_flags,
2,224✔
717
            mesh: cached_mesh.mesh,
2,224✔
718
            texture_layout: extracted_effect.texture_layout.clone(),
4,448✔
719
            textures: extracted_effect.textures.clone(),
4,448✔
720
            alpha_mode: extracted_effect.alpha_mode,
2,224✔
721
            cached_effect_events: cached_effect_events.cloned(),
4,448✔
722
        }
723
    }
724
}
725

726
/// Effect batching input, obtained from extracted effects.
727
#[derive(Debug, Component)]
728
pub(crate) struct BatchInput {
729
    /// Effect slices.
730
    pub effect_slice: EffectSlice,
731
    /// Compute pipeline IDs of the specialized and cached pipelines.
732
    pub init_and_update_pipeline_ids: InitAndUpdatePipelineIds,
733
    /// ID of the particle slab of the parent effect, if any.
734
    pub parent_slab_id: Option<SlabId>,
735
    /// Index of the event buffer, if this effect consumes GPU spawn events.
736
    pub event_buffer_index: Option<u32>,
737
    /// Child effects, if any.
738
    pub child_effects: Vec<(Entity, BufferBindingSource)>,
739
    /// [`GpuSpawnerParams`] for this instance.
740
    pub gpu_spawner_params: GpuSpawnerParams,
741
    /// Index of the init indirect dispatch struct, if any.
742
    // FIXME - Contains a single effect's data; should handle multiple ones.
743
    pub init_indirect_dispatch_index: Option<u32>,
744
}
745

746
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
747
pub(crate) struct InitAndUpdatePipelineIds {
748
    pub init: CachedComputePipelineId,
749
    pub update: CachedComputePipelineId,
750
}
751

752
#[cfg(test)]
753
mod tests {
754
    use bevy::ecs::entity::Entity;
755

756
    use super::*;
757

758
    fn insert_entry(
759
        sorter: &mut EffectSorter,
760
        entity: Entity,
761
        slab_id: SlabId,
762
        base_instance: u32,
763
        parent: Option<Entity>,
764
    ) {
765
        sorter.effects.push(EffectToBeSorted {
766
            entity,
767
            base_instance,
768
            slab_id,
769
        });
770
        if let Some(parent) = parent {
771
            sorter.child_to_parent.insert(entity, parent);
772
        }
773
    }
774

775
    #[test]
776
    fn toposort_batches() {
777
        let mut sorter = EffectSorter::new();
778

779
        // Some "parent" effect
780
        let e1 = Entity::from_raw_u32(1).unwrap();
781
        insert_entry(&mut sorter, e1, SlabId::new(42), 0, None);
782
        assert_eq!(sorter.effects.len(), 1);
783
        assert_eq!(sorter.effects[0].entity, e1);
784
        assert!(sorter.child_to_parent.is_empty());
785

786
        // Some "child" effect in a different buffer
787
        let e2 = Entity::from_raw_u32(2).unwrap();
788
        insert_entry(&mut sorter, e2, SlabId::new(5), 30, Some(e1));
789
        assert_eq!(sorter.effects.len(), 2);
790
        assert_eq!(sorter.effects[0].entity, e1);
791
        assert_eq!(sorter.effects[1].entity, e2);
792
        assert_eq!(sorter.child_to_parent.len(), 1);
793
        assert_eq!(sorter.child_to_parent[&e2], e1);
794

795
        sorter.sort();
796
        assert_eq!(sorter.effects.len(), 2);
797
        assert_eq!(sorter.effects[0].entity, e2); // child first
798
        assert_eq!(sorter.effects[1].entity, e1); // parent after
799
        assert_eq!(sorter.child_to_parent.len(), 1); // unchanged
800
        assert_eq!(sorter.child_to_parent[&e2], e1); // unchanged
801

802
        // Some "child" effect in the same buffer as its parent
803
        let e3 = Entity::from_raw_u32(3).unwrap();
804
        insert_entry(&mut sorter, e3, SlabId::new(42), 20, Some(e1));
805
        assert_eq!(sorter.effects.len(), 3);
806
        assert_eq!(sorter.effects[0].entity, e2); // from previous sort
807
        assert_eq!(sorter.effects[1].entity, e1); // from previous sort
808
        assert_eq!(sorter.effects[2].entity, e3); // simply appended
809
        assert_eq!(sorter.child_to_parent.len(), 2);
810
        assert_eq!(sorter.child_to_parent[&e2], e1);
811
        assert_eq!(sorter.child_to_parent[&e3], e1);
812

813
        sorter.sort();
814
        assert_eq!(sorter.effects.len(), 3);
815
        assert_eq!(sorter.effects[0].entity, e2); // child first
816
        assert_eq!(sorter.effects[1].entity, e3); // other child next (in same buffer as parent)
817
        assert_eq!(sorter.effects[2].entity, e1); // finally, parent
818
        assert_eq!(sorter.child_to_parent.len(), 2);
819
        assert_eq!(sorter.child_to_parent[&e2], e1);
820
        assert_eq!(sorter.child_to_parent[&e3], e1);
821
    }
822
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc