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

djeedai / bevy_hanabi / 30177227450

25 Jul 2026 10:15PM UTC coverage: 58.445% (-0.7%) from 59.154%
30177227450

push

github

web-flow
Pack `EffectMetadata` and bind as array (#553)

Bind the `EffectMetadata` struct describing the per-effect-instance data
as an array. This allows compacting the struct and avoiding
device-mandated padding. This also greatly simplifies the `vfx_indirect`
shader which know can use the typed struct instead of manipulating
type-erased `u32`/`f32` values.

Fix missing GPU thread capping in CPU-based init and in update passes,
to early out when the thread has no particle to process, instead of
doing a useless binary search. GPU-based init continues to do the binary
search, which is anyway currently operating on a single-element array
(no GPU event batching).

Update the `instancing` example to use properties per effect, to prove
property batching works.

139 of 320 new or added lines in 7 files covered. (43.44%)

41 existing lines in 2 files now uncovered.

5426 of 9284 relevant lines covered (58.44%)

678.76 hits per line

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

79.71
/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
/// Info about particle spawning for an entire batch of effects.
29
#[derive(Debug, Clone, Copy)]
30
pub(crate) enum BatchSpawnInfo {
31
    /// Spawn a number of particles uploaded from CPU each frame.
32
    CpuSpawner {
33
        /// Total number of particles to spawn for the batch. This is only used
34
        /// to calculate the number of compute workgroups to dispatch. This is
35
        /// the sum of all spawn counts for all effects in the batch.
36
        total_spawn_count: u32,
37
    },
38

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

56
impl BatchSpawnInfo {
57
    /// Check if this batch uses CPU-based spawning.
58
    ///
59
    /// # Returns
60
    ///
61
    /// Returns `true` if this instance is a `BatchSpawnInfo::CpuSpawner`.
62
    #[inline]
63
    #[must_use]
64
    pub fn is_cpu(&self) -> bool {
3,346✔
65
        matches!(self, Self::CpuSpawner { .. })
3,346✔
66
    }
67

68
    /// Check if this batch uses GPU-based spawning.
69
    ///
70
    /// # Returns
71
    ///
72
    /// Returns `true` if this instance is a `BatchSpawnInfo::GpuSpawner`.
73
    #[inline]
74
    #[must_use]
75
    #[allow(dead_code)]
NEW
76
    pub fn is_gpu(&self) -> bool {
×
NEW
77
        matches!(self, Self::GpuSpawner { .. })
×
78
    }
79

80
    /// Retrieve the CPU spawn count, if this batch is CPU-based.
81
    ///
82
    /// # Returns
83
    ///
84
    /// Returns `Some(count)` with the total spawn count if this instance is a
85
    /// `BatchSpawnInfo::CpuSpawner`. Otherwise returns `None`.
86
    #[inline]
87
    #[must_use]
88
    #[allow(dead_code)]
NEW
89
    pub fn as_cpu(&self) -> Option<&u32> {
×
NEW
90
        if let Self::CpuSpawner { total_spawn_count } = self {
×
NEW
91
            Some(total_spawn_count)
×
92
        } else {
NEW
93
            None
×
94
        }
95
    }
96

97
    /// Retrieve the CPU spawn count or a default value.
98
    ///
99
    /// This variant is used as a shortcut to
100
    /// `.as_cpu().unwrap_or(<unspecified>)` when filling out GPU buffers,
101
    /// where we need a value whatever the case, but will ignore it if GPU
102
    /// based.
103
    ///
104
    /// # Returns
105
    ///
106
    /// Returns the total spawn count if this instance is a
107
    /// `BatchSpawnInfo::CpuSpawner`. Otherwise returns an unspecified value,
108
    /// which should be ignored.
109
    #[inline]
110
    #[must_use]
111
    #[allow(unused)]
112
    pub fn cpu_spawn_count(&self) -> u32 {
×
113
        if let Self::CpuSpawner { total_spawn_count } = self {
×
114
            *total_spawn_count
×
115
        } else {
116
            u32::MAX
×
117
        }
118
    }
119
}
120

121
#[derive(Debug, Clone, Copy)]
122
pub(crate) struct BatchEffectData {
123
    /// Main [`Entity`] this effect instance was extracted from.
124
    pub entity: u32,
125
    /// Offset into the GPU buffer where the particles for this effect instance
126
    /// are located. This is uploaded to GPU as [`GpuBatchInfo::base_particle`].
127
    pub slab_offset: u32,
128
    pub draw_indirect_buffer_row_index: BufferTableId,
129
    pub metadata_table_id: BufferTableId,
130
    pub sort_fill_indirect_dispatch_index: Option<u32>,
131
    /// Offset in bytes of the [`GpuBatchInfo`] into the
132
    /// [`Batcher::batch_info_buffer`], which contains the location of the
133
    /// particles to render for this effect instance. Ready for bind group
134
    /// dynamic offset usage.
135
    pub render_batch_info_offset: u32,
136
}
137

138
/// Batch of effects dispatched and rendered together.
139
#[derive(Debug, Clone)]
140
pub(crate) struct EffectBatch {
141
    /// ID of the [`GpuBatchInfo`] in the global shared array for this batch.
142
    pub batch_info_id: u32,
143
    /// Handle of the underlying effect asset describing the effect. The batch
144
    /// only contains effect instances of the same asset.
145
    pub handle: Handle<EffectAsset>,
146
    /// ID of the particle slab in the [`EffectBuffer`] where all the batched
147
    /// effects are stored.
148
    ///
149
    /// [`EffectBuffer`]: super::effect_cache::EffectBuffer
150
    pub slab_id: SlabId,
151
    /// Spawn info for this batch
152
    pub spawn_info: BatchSpawnInfo,
153
    /// Specialized init and update compute pipelines.
154
    pub init_and_update_pipeline_ids: InitAndUpdatePipelineIds,
155
    /// Configured shader used for the particle rendering of this group.
156
    /// Note that we don't need to keep the init/update shaders alive because
157
    /// their pipeline specialization is doing it via the specialization key.
158
    pub render_shader: Handle<Shader>,
159
    /// ID of the particle slab where the parent effect is stored, if any. If a
160
    /// parent exists, its particle buffer is made available (read-only) for
161
    /// a child effect to read its attributes.
162
    #[allow(dead_code)]
163
    pub parent_slab_id: Option<SlabId>,
164
    pub parent_min_binding_size: Option<NonZeroU32>,
165
    pub parent_binding_source: Option<BufferBindingSource>,
166
    /// Event buffers of child effects, if any.
167
    pub child_event_buffers: Vec<(Entity, BufferBindingSource)>,
168
    /// Index of the property buffer, if any.
169
    pub property_key: Option<PropertyBindGroupKey>,
170
    /// Index of the first [`GpuSpawnerParams`] entry of the effects in the
171
    /// batch. Subsequent batched effects have their entries following linearly
172
    /// after that one.
173
    ///
174
    /// [`GpuSpawnerParams`]: super::GpuSpawnerParams
175
    pub spawner_base: u32,
176
    /// Per-effect metadata used for draw and sorting. The number of elements
177
    /// equals the number of effects batched together inside this batch.
178
    pub effect_data: Vec<BatchEffectData>,
179
    /// Particle layout shared by all batched effects and groups.
180
    pub particle_layout: ParticleLayout,
181
    /// Flags describing the render layout.
182
    pub layout_flags: LayoutFlags,
183
    /// Asset ID of the effect mesh to draw.
184
    pub mesh: AssetId<Mesh>,
185
    /// Texture layout.
186
    pub texture_layout: TextureLayout,
187
    /// Textures.
188
    pub textures: Vec<Handle<Image>>,
189
    /// Alpha mode.
190
    pub alpha_mode: AlphaMode,
191
    pub cached_effect_events: Option<CachedEffectEvents>,
192
}
193

194
impl EffectBatch {
195
    /// Try to merge another batch into this one.
196
    ///
197
    /// # Returns
198
    ///
199
    /// Returns `Ok(())` if merged successfully. Returns `Err(input)` if the
200
    /// input couldn't be merged.
201
    #[allow(clippy::result_large_err)]
202
    pub fn try_merge(&mut self, input: EffectBatch) -> Result<(), EffectBatch> {
1,673✔
203
        // Keep merging conservative; parent/child/event-linked effects require
204
        // additional per-effect bindings and aren't safe to merge yet.
205
        if self.handle != input.handle
1,673✔
206
            || self.slab_id != input.slab_id
1,673✔
207
            || self.init_and_update_pipeline_ids != input.init_and_update_pipeline_ids
1,673✔
208
            || self.mesh != input.mesh
1,673✔
209
            || self.alpha_mode != input.alpha_mode
1,673✔
210
            || self.texture_layout != input.texture_layout
1,673✔
211
            || self.textures != input.textures
1,673✔
212
            || self.property_key != input.property_key
1,673✔
213
            || self.parent_slab_id != input.parent_slab_id
1,673✔
214
            || self.parent_binding_source != input.parent_binding_source
1,673✔
215
            || self.child_event_buffers != input.child_event_buffers
1,673✔
216
            || self.cached_effect_events.is_some()
1,673✔
217
            || input.cached_effect_events.is_some()
1,673✔
218
            || !self.spawn_info.is_cpu()
1,673✔
219
            || !input.spawn_info.is_cpu()
1,673✔
220
        {
221
            return Err(input);
×
222
        }
223

224
        self.effect_data.extend(input.effect_data);
5,019✔
225
        if let (
226
            BatchSpawnInfo::CpuSpawner {
227
                total_spawn_count: self_count,
1,673✔
228
            },
229
            BatchSpawnInfo::CpuSpawner {
230
                total_spawn_count: input_count,
×
231
            },
232
        ) = (&mut self.spawn_info, input.spawn_info)
3,346✔
233
        {
234
            *self_count += input_count;
×
235
        }
236
        Ok(())
1,673✔
237
    }
238
}
239

240
#[derive(Debug, Clone, Copy)]
241
pub(crate) struct EffectBatchIndex(pub u32);
242

243
#[derive(Resource)]
244
pub(crate) struct Batcher {
245
    /// Effect batches in the order they were inserted by [`push()`], indexed by
246
    /// the returned [`EffectBatchIndex`].
247
    ///
248
    /// [`push()`]: Self::push
249
    batches: Vec<EffectBatch>,
250
    /// Index of the dispatch queue used for indirect fill dispatch and
251
    /// submitted to [`GpuBufferOperations`].
252
    pub(super) dispatch_queue_index: Option<u32>,
253
    /// Index of the queue which copies post-update alive counts into the prefix
254
    /// sum buffer before the ribbon sort prefix sum pass.
255
    pub(super) sort_fill_prefix_sum_queue_index: Option<u32>,
256
    /// Global shared GPU buffer storing the various `BatchInfo` structs for the
257
    /// active batches. This is dynamically updated each frame based on current
258
    /// batching, with one entry per batch (= one entry per dispatch/draw).
259
    batch_info_buffer: AlignedBufferVec<GpuBatchInfo>,
260
    /// Debug: was begin_batch() called without end_batch()?
261
    is_batch_open: bool,
262
    /// Buffer containing the prefix sums for all batches.
263
    prefix_sum_buffer: BufferVec<u32>,
264
    /// Current running prefix sum counter of CPU values passed to
265
    /// [`Self::push()`], and used to initialize the prefix sum of each batch
266
    /// for the next init pass.
267
    cpu_prefix_sum_value: u32,
268
}
269

270
impl FromWorld for Batcher {
271
    fn from_world(world: &mut World) -> Self {
4✔
272
        let device = world.resource::<RenderDevice>();
12✔
273
        let item_align =
4✔
274
            NonZeroU32::new(device.limits().min_storage_buffer_offset_alignment).unwrap();
12✔
275
        let batch_info_buffer = AlignedBufferVec::new(
276
            BufferUsages::STORAGE,
277
            Some(item_align.into()),
4✔
278
            Some("hanabi:buffer:batch_info".to_string()),
4✔
279
        );
280
        let mut prefix_sum_buffer = BufferVec::new(BufferUsages::STORAGE);
8✔
281
        prefix_sum_buffer.set_label(Some("prefix_sum_buffer"));
12✔
282
        Self {
283
            batches: vec![],
8✔
284
            dispatch_queue_index: None,
285
            sort_fill_prefix_sum_queue_index: None,
286
            batch_info_buffer,
287
            is_batch_open: false,
288
            prefix_sum_buffer,
289
            cpu_prefix_sum_value: 0,
290
        }
291
    }
292
}
293

294
impl Batcher {
295
    #[inline]
296
    pub fn batch_info_buffer(&self) -> Option<&Buffer> {
551✔
297
        self.batch_info_buffer.buffer()
1,102✔
298
    }
299

300
    #[inline]
301
    pub fn batch_info_buffer_aligned_size(&self) -> u32 {
1,655✔
302
        self.batch_info_buffer.aligned_size() as u32
1,655✔
303
    }
304

305
    #[inline]
306
    pub fn prefix_sum_buffer(&self) -> Option<&Buffer> {
551✔
307
        self.prefix_sum_buffer.buffer()
1,102✔
308
    }
309

310
    pub fn clear(&mut self) {
570✔
311
        self.batches.clear();
1,140✔
312
        self.dispatch_queue_index = None;
570✔
313
        self.sort_fill_prefix_sum_queue_index = None;
570✔
314
        self.prefix_sum_buffer.clear();
1,140✔
315
        self.batch_info_buffer.clear();
1,140✔
316
    }
317

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

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

324
        let batch_info_base = self.batch_info_buffer.len() as u32;
5,550✔
325
        let batch_info = GpuBatchInfo {
326
            total_spawn_count: 0,  // set in end_batch()
327
            total_update_count: 0, // calculated on GPU
328
            spawner_base,
329
            base_particle,
330
            prefix_sum_offset,
331
            prefix_sum_count: u32::MAX, // invalid; set in end_batch()
332
        };
333
        trace!("batch info = {:?}", batch_info);
2,775✔
334
        self.batch_info_buffer.push(batch_info);
8,325✔
335
        self.is_batch_open = true;
2,775✔
336

337
        batch_info_base
2,775✔
338
    }
339

340
    /// Add a single effect instance entry to the current batch prefix array.
341
    fn add_effect_to_batch(&mut self, prefix_value: u32) {
4,448✔
342
        assert!(
4,448✔
343
            self.is_batch_open,
4,448✔
344
            "Cannot add effect before calling begin_batch()"
345
        );
346
        self.prefix_sum_buffer.push(prefix_value);
13,344✔
347
    }
348

349
    /// Try to end the current batch, if any. Does nothing if no batch is
350
    /// pending.
351
    ///
352
    /// # Returns
353
    ///
354
    /// Returns `true` if a batch was closed, or `false` otherwise.
355
    pub fn try_end_batch(&mut self) -> bool {
1,121✔
356
        if self.is_batch_open {
1,121✔
357
            self.end_batch();
1,102✔
358
            true
551✔
359
        } else {
360
            false
570✔
361
        }
362
    }
363

364
    /// End the current batch.
365
    ///
366
    /// # Panics
367
    ///
368
    /// Panics if no batch is pending.
369
    fn end_batch(&mut self) {
2,775✔
370
        assert!(
2,775✔
371
            self.is_batch_open,
2,775✔
372
            "Call to end_batch() without begin_batch()"
373
        );
374

375
        // Get the open batch
376
        let batch = self
5,550✔
377
            .batch_info_buffer
2,775✔
378
            .last_mut()
379
            .expect("No open batch. Missing begin_batch() call?");
380

381
        // Record prefix sum
382
        let end = self.prefix_sum_buffer.len() as u32;
5,550✔
383
        assert!(end >= batch.prefix_sum_offset);
5,550✔
384
        batch.prefix_sum_count = end - batch.prefix_sum_offset;
2,775✔
385

386
        // Record total number of CPU spawn, to clamp the number of GPU threads
387
        batch.total_spawn_count = self.cpu_prefix_sum_value;
2,775✔
388

389
        self.is_batch_open = false;
2,775✔
390
    }
391

392
    /// Insert a new batch into the collection.
393
    ///
394
    /// Try to merge the `effect_batch` into the last pushed batch, or create a
395
    /// new standalone batch if not possible (incompatible effects, or first
396
    /// one). The `instance_spawn_count` is the number of particles to spawn
397
    /// from CPU, and is used to initialize the prefix sum buffer, for use by
398
    /// the init pass (CPU spawn). The update pass' prefix sum is recomputed
399
    /// inside the same buffer between the init and update passes, directly on
400
    /// GPU.
401
    ///
402
    /// # Returns
403
    ///
404
    /// This returns the index of the new batch if the inserted one couldn't be
405
    /// merged with a previous batch. Otherwise the input batch was merged with
406
    /// an existing one, and therefore share its index; in that case `None` is
407
    /// returned.
408
    ///
409
    /// Also returns the index of that effect batch in the prefix sum buffer.
410
    pub fn push(
2,224✔
411
        &mut self,
412
        effect_batch: EffectBatch,
413
        instance_spawn_count: u32,
414
    ) -> (Option<EffectBatchIndex>, u32) {
415
        assert!(effect_batch.effect_data.len() == 1);
4,448✔
416

417
        let prefix_sum_index = self.prefix_sum_buffer.len() as u32;
4,448✔
418

419
        let effect_batch = if let Some(batch) = self.batches.last_mut() {
4,448✔
420
            let Err(effect_batch) = batch.try_merge(effect_batch) else {
×
421
                // Successfully batched
422
                self.add_effect_to_batch(self.cpu_prefix_sum_value);
5,019✔
423
                self.cpu_prefix_sum_value += instance_spawn_count;
1,673✔
424
                return (None, prefix_sum_index);
1,673✔
425
            };
426
            // Failed to merge incompatible batches
427
            effect_batch
×
428
        } else {
429
            // No prior batch to merge with
430
            effect_batch
551✔
431
        };
432

433
        // Close the previous batch if any
434
        self.try_end_batch();
×
435

436
        // Start a new batch
437
        let index = self.batches.len() as u32;
×
438
        let base_particle = effect_batch.effect_data[0].slab_offset;
×
439
        self.batches.push(effect_batch);
×
440

441
        // Begin a new batch with this new effect instance
442
        let batch_info_id = self.begin_batch(base_particle, self.last().unwrap().spawner_base);
×
443
        self.last_mut().unwrap().batch_info_id = batch_info_id;
×
444
        self.cpu_prefix_sum_value = 0;
×
445

446
        self.add_effect_to_batch(self.cpu_prefix_sum_value);
×
447
        self.cpu_prefix_sum_value += instance_spawn_count;
×
448

NEW
449
        (Some(EffectBatchIndex(index)), prefix_sum_index)
×
450
    }
451

452
    #[inline]
453
    #[must_use]
454
    pub fn last(&self) -> Option<&EffectBatch> {
551✔
455
        self.batches.last()
551✔
456
    }
457

458
    #[inline]
459
    #[must_use]
460
    pub fn last_mut(&mut self) -> Option<&mut EffectBatch> {
551✔
461
        self.batches.last_mut()
551✔
462
    }
463

464
    pub fn len(&self) -> usize {
1,102✔
465
        self.batches.len()
2,204✔
466
    }
467

468
    pub fn is_empty(&self) -> bool {
1,140✔
469
        self.batches.is_empty()
2,280✔
470
    }
471

472
    /// Get an iterator over the sorted sequence of effect batches.
473
    #[inline]
474
    pub fn iter(&self) -> &[EffectBatch] {
1,653✔
475
        &self.batches
1,653✔
476
    }
477

478
    pub fn get(&self, index: EffectBatchIndex) -> Option<&EffectBatch> {
2,198✔
479
        if index.0 < self.batches.len() as u32 {
4,396✔
480
            Some(&self.batches[index.0 as usize])
2,198✔
481
        } else {
482
            None
×
483
        }
484
    }
485

486
    /// Allocate the render batches.
487
    ///
488
    /// Allocate one render batch info entry per effect instance after compute
489
    /// batching is finalized, to avoid nesting begin_batch()/end_batch() calls.
490
    /// Currently there's no rendering batching, so we allocate one GpuBatchInfo
491
    /// per effect instance.
492
    pub fn allocate_render_batches(&mut self) {
570✔
493
        let batch_info_aligned_size = self.batch_info_buffer_aligned_size();
1,710✔
494
        let num_batches = self.batches.len();
1,710✔
495
        for batch_index in 0..num_batches {
1,121✔
496
            let num_effects = self.batches[batch_index].effect_data.len();
×
497
            for effect_index in 0..num_effects {
2,224✔
498
                let effect_batch = &self.batches[batch_index];
×
499
                let effect_data = &effect_batch.effect_data[effect_index];
×
500
                if effect_data.render_batch_info_offset != u32::MAX {
×
501
                    continue;
×
502
                }
503
                let spawner_index = effect_batch.spawner_base + effect_index as u32;
×
504

505
                let base_particle = effect_data.slab_offset;
×
506
                let render_batch_info_id = self.begin_batch(base_particle, spawner_index);
×
507
                // Render-only batch infos are not processed by vfx_prefix_sum; keep
508
                // a relative offset of 0 for the single effect in that batch.
509
                self.add_effect_to_batch(0);
×
510
                self.end_batch();
×
511
                let render_batch_info_offset = render_batch_info_id
×
512
                    .checked_mul(batch_info_aligned_size)
×
513
                    .unwrap();
514

515
                self.batches[batch_index].effect_data[effect_index].render_batch_info_offset =
×
516
                    render_batch_info_offset;
×
517
            }
518
        }
519
    }
520

521
    #[inline]
522
    pub fn write_batch_info_buffer(&mut self, device: &RenderDevice, queue: &RenderQueue) -> bool {
570✔
523
        self.batch_info_buffer.write_buffer(device, queue)
2,280✔
524
    }
525

526
    pub fn write_prefix_sum_buffer(&mut self, device: &RenderDevice, queue: &RenderQueue) -> bool {
570✔
527
        let mut reallocated = false;
1,140✔
528
        let cpu_len = self.prefix_sum_buffer.len();
1,710✔
529
        if cpu_len > 0 {
570✔
530
            let gpu_capacity = self.prefix_sum_buffer.capacity();
1,653✔
531
            if cpu_len > gpu_capacity {
554✔
532
                self.prefix_sum_buffer.reserve(cpu_len, device);
12✔
533
                reallocated = true;
3✔
534
            }
535
            assert!(self.prefix_sum_buffer.buffer().is_some());
1,653✔
536
            self.prefix_sum_buffer.write_buffer(device, queue);
2,204✔
537
        }
538
        reallocated
570✔
539
    }
540
}
541

542
/// Information that the [`EffectSorter`] maintains in order to sort each
543
/// effect into the proper order.
544
pub(crate) struct EffectToBeSorted {
545
    /// The render-world entity of the effect.
546
    pub(crate) entity: Entity,
547
    /// The index of the buffer that the indirect indices for this effect are
548
    /// stored in.
549
    pub(crate) slab_id: SlabId,
550
    /// The offset within the buffer described above at which the indirect
551
    /// indices for this effect start.
552
    ///
553
    /// This is in elements, not bytes.
554
    pub(crate) base_instance: u32,
555
}
556

557
/// The key that we sort effects by for optimum batching.
558
#[derive(Clone, Copy, PartialEq, PartialOrd, Eq, Ord)]
559
struct EffectSortKey {
560
    /// The level in the dependency graph.
561
    ///
562
    /// Parents always have lower levels than their children.
563
    level: u32,
564
    /// The index of the buffer that the indirect indices for this effect are
565
    /// stored in.
566
    slab_id: SlabId,
567
    /// The offset within the buffer described above at which the indirect
568
    /// indices for this effect start.
569
    base_instance: u32,
570
}
571

572
/// Sorts effects into the proper order for batching.
573
///
574
/// This places parents before children and also tries to place effects in the
575
/// same slab together.
576
pub(crate) struct EffectSorter {
577
    /// Information that we keep about each effect.
578
    pub effects: Vec<EffectToBeSorted>,
579
    /// A mapping from a child to its parent, if it has one.
580
    pub child_to_parent: EntityHashMap<Entity>,
581
}
582

583
impl EffectSorter {
584
    /// Creates a new [`EffectSorter`].
585
    pub fn new() -> EffectSorter {
571✔
586
        EffectSorter {
587
            effects: vec![],
571✔
588
            child_to_parent: default(),
571✔
589
        }
590
    }
591

592
    /// Insert an effect to be sorted.
593
    pub fn insert(
2,224✔
594
        &mut self,
595
        entity: Entity,
596
        slab_id: SlabId,
597
        base_instance: u32,
598
        parent: Option<Entity>,
599
    ) {
600
        self.effects.push(EffectToBeSorted {
6,672✔
601
            entity,
4,448✔
602
            slab_id,
2,224✔
603
            base_instance,
2,224✔
604
        });
605
        if let Some(parent) = parent {
2,224✔
606
            self.child_to_parent.insert(entity, parent);
×
607
        }
608
    }
609

610
    /// Sorts all the effects into the optimal order for batching.
611
    pub fn sort(&mut self) {
572✔
612
        // trace!("Sorting {} effects...", self.effects.len());
613
        // for effect in &self.effects {
614
        //     trace!(
615
        //         "+ {}: slab={:?} base_instance={:?}",
616
        //         effect.entity,
617
        //         effect.slab_id,
618
        //         effect.base_instance
619
        //     );
620
        // }
621
        // trace!("child->parent:");
622
        // for (k, v) in &self.child_to_parent {
623
        //     trace!("+ c[{k}] -> p[{v}]");
624
        // }
625

626
        // First, create a map of entity to index.
627
        let mut entity_to_index = EntityHashMap::default();
1,144✔
628
        for (index, effect) in self.effects.iter().enumerate() {
3,373✔
629
            entity_to_index.insert(effect.entity, index);
630
        }
631

632
        // Next, create a map of children to their parents.
633
        let mut children_to_parent: Vec<_> = (0..self.effects.len()).map(|_| vec![]).collect();
5,089✔
634
        for (kid, parent) in self.child_to_parent.iter() {
575✔
635
            let (parent_index, kid_index) = (entity_to_index[parent], entity_to_index[kid]);
636
            children_to_parent[kid_index].push(parent_index);
637
        }
638

639
        // Now topologically sort the graph. Create an ordering that places
640
        // children before parents.
641
        // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
642
        let mut ordering = vec![0; self.effects.len()];
2,288✔
643
        let mut visiting = FixedBitSet::with_capacity(self.effects.len());
2,288✔
644
        let mut visited = FixedBitSet::with_capacity(self.effects.len());
2,288✔
645
        while let Some(effect_index) = visited.zeroes().next() {
5,028✔
646
            visit(
647
                &mut ordering,
648
                &mut visiting,
649
                &mut visited,
650
                &children_to_parent,
651
                effect_index,
652
            );
653
        }
654

655
        // Compute levels.
656
        let mut levels = vec![0; self.effects.len()];
2,288✔
657
        for effect_index in ordering.into_iter().rev() {
6,174✔
658
            let level = levels[effect_index];
659
            for &parent in &children_to_parent[effect_index] {
6✔
660
                levels[parent] = levels[parent].max(level + 1);
661
            }
662
        }
663

664
        // Now sort the result.
665
        self.effects.sort_unstable_by_key(|effect| EffectSortKey {
1,144✔
666
            level: levels[entity_to_index[&effect.entity]],
6,708✔
667
            slab_id: effect.slab_id,
3,354✔
668
            base_instance: effect.base_instance,
3,354✔
669
        });
670

671
        // Helper function for topologically sorting the effect dependency graph
672
        fn visit(
2,231✔
673
            ordering: &mut Vec<usize>,
674
            visiting: &mut FixedBitSet,
675
            visited: &mut FixedBitSet,
676
            children_to_parent: &[Vec<usize>],
677
            effect_index: usize,
678
        ) {
679
            if visited.contains(effect_index) {
6,693✔
680
                return;
2✔
681
            }
682
            debug_assert!(
683
                !visiting.contains(effect_index),
684
                "Parent-child effect relation contains a cycle"
685
            );
686

687
            visiting.insert(effect_index);
6,687✔
688

689
            for &parent in &children_to_parent[effect_index] {
2,232✔
690
                visit(ordering, visiting, visited, children_to_parent, parent);
691
            }
692

693
            visited.insert(effect_index);
6,687✔
694
            ordering.push(effect_index);
6,687✔
695
        }
696
    }
697

698
    /// Iterate over the effects. This only iterates in sorted order if
699
    /// [`Self::sort()`] was called beforehand.
700
    #[inline]
701
    pub fn iter(&self) -> impl Iterator<Item = Entity> + use<'_> {
570✔
702
        self.effects.iter().map(|e| e.entity)
1,140✔
703
    }
704
}
705

706
/// Single effect batch to drive rendering.
707
///
708
/// This component is spawned into the render world during the prepare phase
709
/// ([`prepare_effects()`]), once per effect batch per group. In turns it
710
/// references an [`EffectBatch`] component containing all the shared data for
711
/// all the groups of the effect.
712
#[derive(Debug, Component)]
713
pub(crate) struct EffectDrawBatch {
714
    /// Index of the [`EffectBatch`] in the [`SortedEffectBatches`] this draw
715
    /// batch is part of.
716
    ///
717
    /// Note: currently there's a 1:1 mapping between effect batch and draw
718
    /// batch.
719
    pub effect_batch_index: EffectBatchIndex,
720
    /// Position of the emitter so we can compute distance to camera.
721
    pub translation: Vec3,
722
    /// The main-world entity that contains this effect.
723
    #[allow(dead_code)]
724
    pub main_entity: MainEntity,
725
}
726

727
impl EffectBatch {
728
    /// Create a new batch from a single input.
729
    pub fn from_input(
2,224✔
730
        main_entity: Entity,
731
        extracted_effect: &ExtractedEffect,
732
        extracted_spawner: &ExtractedSpawner,
733
        cached_mesh: &ExtractedEffectMesh,
734
        cached_effect_events: Option<&CachedEffectEvents>,
735
        cached_child_info: Option<&CachedChildInfo>,
736
        spawner_index: u32,
737
        input: &mut BatchInput,
738
        draw_indirect_buffer_row_index: BufferTableId,
739
        metadata_table_id: BufferTableId,
740
        property_key: Option<PropertyBindGroupKey>,
741
    ) -> EffectBatch {
742
        assert_eq!(
2,224✔
743
            input.event_buffer_index.is_some(),
4,448✔
744
            input.init_indirect_dispatch_index.is_some()
4,448✔
745
        );
746

747
        let spawn_info = if let Some(event_buffer_index) = input.event_buffer_index {
4,448✔
748
            BatchSpawnInfo::GpuSpawner {
749
                init_indirect_dispatch_index: input.init_indirect_dispatch_index.unwrap(),
×
750
                event_buffer_index,
751
            }
752
        } else {
753
            BatchSpawnInfo::CpuSpawner {
754
                total_spawn_count: extracted_spawner.spawn_count,
2,224✔
755
            }
756
        };
757

758
        EffectBatch {
759
            batch_info_id: u32::MAX, // allocated later once the batch is completed
760
            handle: extracted_effect.handle.clone(),
4,448✔
761
            slab_id: input.effect_slice.slab_id,
2,224✔
762
            spawn_info,
763
            init_and_update_pipeline_ids: input.init_and_update_pipeline_ids,
2,224✔
764
            render_shader: extracted_effect.effect_shaders.render.clone(),
4,448✔
765
            parent_slab_id: input.parent_slab_id,
2,224✔
766
            parent_min_binding_size: cached_child_info
2,224✔
767
                .map(|cci| cci.parent_particle_layout.min_binding_size32()),
768
            parent_binding_source: cached_child_info
2,224✔
769
                .map(|cci| cci.parent_buffer_binding_source.clone()),
770
            child_event_buffers: input.child_effects.clone(),
4,448✔
771
            property_key,
772
            spawner_base: spawner_index,
773
            effect_data: vec![BatchEffectData {
4,448✔
774
                entity: main_entity.index_u32(),
775
                slab_offset: input.effect_slice.slice.start,
776
                draw_indirect_buffer_row_index,
777
                metadata_table_id,
778
                sort_fill_indirect_dispatch_index: None,
779
                render_batch_info_offset: u32::MAX,
780
            }],
781
            particle_layout: input.effect_slice.particle_layout.clone(),
4,448✔
782
            layout_flags: extracted_effect.layout_flags,
2,224✔
783
            mesh: cached_mesh.mesh,
2,224✔
784
            texture_layout: extracted_effect.texture_layout.clone(),
4,448✔
785
            textures: extracted_effect.textures.clone(),
4,448✔
786
            alpha_mode: extracted_effect.alpha_mode,
2,224✔
787
            cached_effect_events: cached_effect_events.cloned(),
4,448✔
788
        }
789
    }
790
}
791

792
/// Effect batching input, obtained from extracted effects.
793
#[derive(Debug, Component)]
794
pub(crate) struct BatchInput {
795
    /// Effect slices.
796
    pub effect_slice: EffectSlice,
797
    /// Compute pipeline IDs of the specialized and cached pipelines.
798
    pub init_and_update_pipeline_ids: InitAndUpdatePipelineIds,
799
    /// ID of the particle slab of the parent effect, if any.
800
    pub parent_slab_id: Option<SlabId>,
801
    /// Index of the event buffer, if this effect consumes GPU spawn events.
802
    pub event_buffer_index: Option<u32>,
803
    /// Child effects, if any.
804
    pub child_effects: Vec<(Entity, BufferBindingSource)>,
805
    /// [`GpuSpawnerParams`] for this instance.
806
    pub gpu_spawner_params: GpuSpawnerParams,
807
    /// Index of the init indirect dispatch struct, if any.
808
    // FIXME - Contains a single effect's data; should handle multiple ones.
809
    pub init_indirect_dispatch_index: Option<u32>,
810
}
811

812
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
813
pub(crate) struct InitAndUpdatePipelineIds {
814
    pub init: CachedComputePipelineId,
815
    pub update: CachedComputePipelineId,
816
}
817

818
#[cfg(test)]
819
mod tests {
820
    use bevy::ecs::entity::Entity;
821

822
    use super::*;
823

824
    fn insert_entry(
825
        sorter: &mut EffectSorter,
826
        entity: Entity,
827
        slab_id: SlabId,
828
        base_instance: u32,
829
        parent: Option<Entity>,
830
    ) {
831
        sorter.effects.push(EffectToBeSorted {
832
            entity,
833
            base_instance,
834
            slab_id,
835
        });
836
        if let Some(parent) = parent {
837
            sorter.child_to_parent.insert(entity, parent);
838
        }
839
    }
840

841
    #[test]
842
    fn toposort_batches() {
843
        let mut sorter = EffectSorter::new();
844

845
        // Some "parent" effect
846
        let e1 = Entity::from_raw_u32(1).unwrap();
847
        insert_entry(&mut sorter, e1, SlabId::new(42), 0, None);
848
        assert_eq!(sorter.effects.len(), 1);
849
        assert_eq!(sorter.effects[0].entity, e1);
850
        assert!(sorter.child_to_parent.is_empty());
851

852
        // Some "child" effect in a different buffer
853
        let e2 = Entity::from_raw_u32(2).unwrap();
854
        insert_entry(&mut sorter, e2, SlabId::new(5), 30, Some(e1));
855
        assert_eq!(sorter.effects.len(), 2);
856
        assert_eq!(sorter.effects[0].entity, e1);
857
        assert_eq!(sorter.effects[1].entity, e2);
858
        assert_eq!(sorter.child_to_parent.len(), 1);
859
        assert_eq!(sorter.child_to_parent[&e2], e1);
860

861
        sorter.sort();
862
        assert_eq!(sorter.effects.len(), 2);
863
        assert_eq!(sorter.effects[0].entity, e2); // child first
864
        assert_eq!(sorter.effects[1].entity, e1); // parent after
865
        assert_eq!(sorter.child_to_parent.len(), 1); // unchanged
866
        assert_eq!(sorter.child_to_parent[&e2], e1); // unchanged
867

868
        // Some "child" effect in the same buffer as its parent
869
        let e3 = Entity::from_raw_u32(3).unwrap();
870
        insert_entry(&mut sorter, e3, SlabId::new(42), 20, Some(e1));
871
        assert_eq!(sorter.effects.len(), 3);
872
        assert_eq!(sorter.effects[0].entity, e2); // from previous sort
873
        assert_eq!(sorter.effects[1].entity, e1); // from previous sort
874
        assert_eq!(sorter.effects[2].entity, e3); // simply appended
875
        assert_eq!(sorter.child_to_parent.len(), 2);
876
        assert_eq!(sorter.child_to_parent[&e2], e1);
877
        assert_eq!(sorter.child_to_parent[&e3], e1);
878

879
        sorter.sort();
880
        assert_eq!(sorter.effects.len(), 3);
881
        assert_eq!(sorter.effects[0].entity, e2); // child first
882
        assert_eq!(sorter.effects[1].entity, e3); // other child next (in same buffer as parent)
883
        assert_eq!(sorter.effects[2].entity, e1); // finally, parent
884
        assert_eq!(sorter.child_to_parent.len(), 2);
885
        assert_eq!(sorter.child_to_parent[&e2], e1);
886
        assert_eq!(sorter.child_to_parent[&e3], e1);
887
    }
888
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc