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

djeedai / bevy_hanabi / 18243240671

04 Oct 2025 10:36AM UTC coverage: 66.58% (+0.1%) from 66.455%
18243240671

push

github

web-flow
Split extraction and render into unit systems (#499)

Reorganize most of the extraction and render systems into smaller,
unit-like systems with limited (ideally, a single) responsibility. Split
most of the data into separate, smaller components too. This not only
enable better multithreading, but also greatly simplify maintenance by
clarifying the logic and responsibility of each system and component.

As part of this change, add a "ready state" to the effect, which is read
back from the render world and informs the main world about whether an
effect is ready for simulation and rendering. This includes:

- All GPU resources being allocated, and in particular the PSOs
  (pipelines) which in Bevy are compiled asynchronously and can be very
  slow (many frames of delay).
- The ready state of all descendant effects, recursively. This ensures a
  child is ready to _e.g._ receive GPU spawn events before its parent,
  which emits those events, starts simulating.

This new ready state is accessed via
`CompiledParticleEffect::is_ready()`. Note that the state is updated
during the extract phase with the information collected from the
previous render frame, so by the time `is_ready()` returns `true`,
already one frame of simulation and rendering generally occurred.

Remove the outdated `copyless` dependency.

594 of 896 new or added lines in 12 files covered. (66.29%)

21 existing lines in 3 files now uncovered.

5116 of 7684 relevant lines covered (66.58%)

416.91 hits per line

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

78.83
/src/render/effect_cache.rs
1
use std::{
2
    cmp::Ordering,
3
    num::{NonZeroU32, NonZeroU64},
4
    ops::Range,
5
};
6

7
use bevy::{
8
    asset::Handle,
9
    ecs::{component::Component, resource::Resource},
10
    log::{trace, warn},
11
    platform::collections::HashMap,
12
    render::{mesh::allocator::MeshBufferSlice, render_resource::*, renderer::RenderDevice},
13
    utils::default,
14
};
15
use bytemuck::cast_slice_mut;
16

17
use super::{buffer_table::BufferTableId, BufferBindingSource};
18
use crate::{
19
    asset::EffectAsset,
20
    render::{
21
        calc_hash, event::GpuChildInfo, GpuDrawIndexedIndirectArgs, GpuDrawIndirectArgs,
22
        GpuEffectMetadata, GpuSpawnerParams, StorageType as _, INDIRECT_INDEX_SIZE,
23
    },
24
    ParticleLayout,
25
};
26

27
/// Describes all particle slices of particles in the particle buffer
28
/// for a single effect.
29
#[derive(Debug, Clone, PartialEq, Eq)]
30
pub struct EffectSlice {
31
    /// Slice into the underlying [`BufferVec`].
32
    ///
33
    /// This is measured in items, not bytes.
34
    pub slice: Range<u32>,
35
    /// ID of the particle slab in the [`EffectCache`].
36
    pub slab_id: SlabId,
37
    /// Particle layout of the effect.
38
    pub particle_layout: ParticleLayout,
39
}
40

41
impl Ord for EffectSlice {
42
    fn cmp(&self, other: &Self) -> Ordering {
8✔
43
        match self.slab_id.cmp(&other.slab_id) {
16✔
44
            Ordering::Equal => self.slice.start.cmp(&other.slice.start),
4✔
45
            ord => ord,
8✔
46
        }
47
    }
48
}
49

50
impl PartialOrd for EffectSlice {
51
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
8✔
52
        Some(self.cmp(other))
16✔
53
    }
54
}
55

56
/// A reference to a slice allocated inside an [`ParticleSlab`].
57
#[derive(Debug, Default, Clone, PartialEq, Eq)]
58
pub struct SlabSliceRef {
59
    /// Range into an [`ParticleSlab`], in item count.
60
    range: Range<u32>,
61
    pub(crate) particle_layout: ParticleLayout,
62
}
63

64
impl SlabSliceRef {
65
    /// The length of the slice, in number of items.
66
    #[allow(dead_code)]
67
    pub fn len(&self) -> u32 {
14✔
68
        self.range.end - self.range.start
14✔
69
    }
70

71
    /// The size in bytes of the slice.
72
    #[allow(dead_code)]
73
    pub fn byte_size(&self) -> usize {
4✔
74
        (self.len() as usize) * (self.particle_layout.min_binding_size().get() as usize)
12✔
75
    }
76

77
    pub fn range(&self) -> Range<u32> {
1,012✔
78
        self.range.clone()
2,024✔
79
    }
80
}
81

82
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
83
struct SimBindGroupKey {
84
    buffer: Option<BufferId>,
85
    offset: u32,
86
    size: u32,
87
}
88

89
impl SimBindGroupKey {
90
    /// Invalid key, often used as placeholder.
91
    pub const INVALID: Self = Self {
92
        buffer: None,
93
        offset: u32::MAX,
94
        size: 0,
95
    };
96
}
97

98
impl From<&BufferBindingSource> for SimBindGroupKey {
99
    fn from(value: &BufferBindingSource) -> Self {
×
100
        Self {
101
            buffer: Some(value.buffer.id()),
×
102
            offset: value.offset,
×
103
            size: value.size.get(),
×
104
        }
105
    }
106
}
107

108
impl From<Option<&BufferBindingSource>> for SimBindGroupKey {
109
    fn from(value: Option<&BufferBindingSource>) -> Self {
1,012✔
110
        if let Some(bbs) = value {
1,012✔
111
            Self {
112
                buffer: Some(bbs.buffer.id()),
113
                offset: bbs.offset,
114
                size: bbs.size.get(),
115
            }
116
        } else {
117
            Self::INVALID
1,012✔
118
        }
119
    }
120
}
121

122
/// State of a [`ParticleSlab`] after an insertion or removal operation.
123
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124
pub enum SlabState {
125
    /// The slab is in use, with allocated resources.
126
    Used,
127
    /// Like `Used`, but the slab was resized, so any bind group is
128
    /// nonetheless invalid.
129
    Resized,
130
    /// The slab is free (its resources were deallocated).
131
    Free,
132
}
133

134
/// ID of a [`ParticleSlab`] inside an [`EffectCache`].
135
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
136
pub struct SlabId(u32);
137

138
impl SlabId {
139
    /// An invalid value, often used as placeholder.
140
    pub const INVALID: SlabId = SlabId(u32::MAX);
141

142
    /// Create a new slab ID from its underlying index.
143
    pub const fn new(index: u32) -> Self {
1,026✔
144
        assert!(index != u32::MAX);
2,052✔
145
        Self(index)
1,026✔
146
    }
147

148
    /// Check if the current ID is valid, that is, is different from
149
    /// [`INVALID`].
150
    ///
151
    /// [`INVALID`]: Self::INVALID
152
    #[inline]
NEW
153
    pub const fn is_valid(&self) -> bool {
×
NEW
154
        self.0 != Self::INVALID.0
×
155
    }
156

157
    /// Get the raw underlying index.
158
    ///
159
    /// This is mostly used for debugging / logging.
160
    #[inline]
161
    pub const fn index(&self) -> u32 {
6,079✔
162
        self.0
6,079✔
163
    }
164
}
165

166
impl Default for SlabId {
NEW
167
    fn default() -> Self {
×
NEW
168
        Self::INVALID
×
169
    }
170
}
171

172
/// Storage for the per-particle data of effects sharing compatible layouts.
173
///
174
/// Currently only accepts a single unique particle layout, fixed at creation.
175
/// If an effect has a different particle layout, it needs to be stored in a
176
/// different slab.
177
///
178
/// Also currently only accepts instances of a unique effect asset, although
179
/// this restriction is purely for convenience and may be relaxed in the future
180
/// to improve batching.
181
#[derive(Debug)]
182
pub struct ParticleSlab {
183
    /// GPU buffer storing all particles for the entire slab of effects.
184
    ///
185
    /// Each particle is a collection of attributes arranged according to
186
    /// [`Self::particle_layout`]. The buffer contains storage for exactly
187
    /// [`Self::capacity`] particles.
188
    particle_buffer: Buffer,
189
    /// GPU buffer storing the indirection indices for the entire slab of
190
    /// effects.
191
    ///
192
    /// Each indirection item contains 3 values:
193
    /// - the ping-pong alive particles and render indirect indices at offsets 0
194
    ///   and 1
195
    /// - the dead particle indices at offset 2
196
    ///
197
    /// The buffer contains storage for exactly [`Self::capacity`] items.
198
    indirect_index_buffer: Buffer,
199
    /// Layout of particles.
200
    particle_layout: ParticleLayout,
201
    /// Total slab capacity, in number of particles.
202
    capacity: u32,
203
    /// Used slab size, in number of particles, either from allocated slices
204
    /// or from slices in the free list.
205
    used_size: u32,
206
    /// Array of free slices for new allocations, sorted in increasing order
207
    /// inside the slab buffers.
208
    free_slices: Vec<Range<u32>>,
209

210
    /// Handle of all effects common in this slab. TODO - replace with
211
    /// compatible layout.
212
    asset: Handle<EffectAsset>,
213
    /// Layout of the particle@1 bind group for the render pass.
214
    // TODO - move; this only depends on the particle and spawner layouts, can be shared across
215
    // slabs
216
    render_particles_buffer_layout: BindGroupLayout,
217
    /// Bind group particle@1 of the simulation passes (init and udpate).
218
    sim_bind_group: Option<BindGroup>,
219
    /// Key the `sim_bind_group` was created from.
220
    sim_bind_group_key: SimBindGroupKey,
221
}
222

223
impl ParticleSlab {
224
    /// Minimum buffer capacity to allocate, in number of particles.
225
    // FIXME - Batching is broken due to binding a single GpuSpawnerParam instead of
226
    // N, and inability for a particle index to tell which Spawner it should
227
    // use. Setting this to 1 effectively ensures that all new buffers just fit
228
    // the effect, so batching never occurs.
229
    pub const MIN_CAPACITY: u32 = 1; // 65536; // at least 64k particles
230

231
    /// Create a new slab and the GPU resources to back it up.
232
    ///
233
    /// The slab cannot contain less than [`MIN_CAPACITY`] particles. If the
234
    /// input `capacity` is smaller, it's rounded up to [`MIN_CAPACITY`].
235
    ///
236
    /// [`MIN_CAPACITY`]: Self::MIN_CAPACITY
237
    pub fn new(
8✔
238
        slab_id: SlabId,
239
        asset: Handle<EffectAsset>,
240
        capacity: u32,
241
        particle_layout: ParticleLayout,
242
        render_device: &RenderDevice,
243
    ) -> Self {
244
        trace!(
8✔
245
            "ParticleSlab::new(slab_id={}, capacity={}, particle_layout={:?}, item_size={}B)",
3✔
246
            slab_id.0,
247
            capacity,
248
            particle_layout,
249
            particle_layout.min_binding_size().get(),
9✔
250
        );
251

252
        // Calculate the clamped capacity of the group, in number of particles.
253
        let capacity = capacity.max(Self::MIN_CAPACITY);
24✔
254
        debug_assert!(
8✔
255
            capacity > 0,
8✔
256
            "Attempted to create a zero-sized effect buffer."
×
257
        );
258

259
        // Allocate the particle buffer itself, containing the attributes of each
260
        // particle.
261
        let particle_capacity_bytes: BufferAddress =
16✔
262
            capacity as u64 * particle_layout.min_binding_size().get();
24✔
263
        let particle_label = format!("hanabi:buffer:slab{}:particle", slab_id.0);
24✔
264
        let particle_buffer = render_device.create_buffer(&BufferDescriptor {
32✔
265
            label: Some(&particle_label),
16✔
266
            size: particle_capacity_bytes,
8✔
267
            usage: BufferUsages::COPY_DST | BufferUsages::STORAGE,
8✔
268
            mapped_at_creation: false,
8✔
269
        });
270

271
        // Each indirect buffer stores 3 arrays of u32, of length the number of
272
        // particles.
273
        let capacity_bytes: BufferAddress = capacity as u64 * 4 * 3;
24✔
274

275
        let indirect_label = format!("hanabi:buffer:slab{}:indirect", slab_id.0);
24✔
276
        let indirect_index_buffer = render_device.create_buffer(&BufferDescriptor {
32✔
277
            label: Some(&indirect_label),
16✔
278
            size: capacity_bytes,
8✔
279
            usage: BufferUsages::COPY_DST | BufferUsages::STORAGE,
8✔
280
            mapped_at_creation: true,
8✔
281
        });
282
        // Set content
283
        {
284
            // Scope get_mapped_range_mut() to force a drop before unmap()
285
            {
286
                let slice: &mut [u8] = &mut indirect_index_buffer
32✔
287
                    .slice(..capacity_bytes)
8✔
288
                    .get_mapped_range_mut();
8✔
289
                let slice: &mut [u32] = cast_slice_mut(slice);
24✔
290
                for index in 0..capacity {
6,427✔
291
                    slice[3 * index as usize + 2] = capacity - 1 - index;
292
                }
293
            }
294
            indirect_index_buffer.unmap();
16✔
295
        }
296

297
        // Create the render layout.
298
        // TODO - move; this only depends on the particle and spawner layouts, can be
299
        // shared across slabs
300
        let spawner_params_size = GpuSpawnerParams::aligned_size(
301
            render_device.limits().min_storage_buffer_offset_alignment,
8✔
302
        );
303
        let entries = [
16✔
304
            // @group(1) @binding(0) var<storage, read> particle_buffer : ParticleBuffer;
305
            BindGroupLayoutEntry {
16✔
306
                binding: 0,
16✔
307
                visibility: ShaderStages::VERTEX_FRAGMENT,
16✔
308
                ty: BindingType::Buffer {
16✔
309
                    ty: BufferBindingType::Storage { read_only: true },
24✔
310
                    has_dynamic_offset: false,
16✔
311
                    min_binding_size: Some(particle_layout.min_binding_size()),
16✔
312
                },
313
                count: None,
16✔
314
            },
315
            // @group(1) @binding(1) var<storage, read> indirect_buffer : IndirectBuffer;
316
            BindGroupLayoutEntry {
16✔
317
                binding: 1,
16✔
318
                visibility: ShaderStages::VERTEX,
16✔
319
                ty: BindingType::Buffer {
16✔
320
                    ty: BufferBindingType::Storage { read_only: true },
24✔
321
                    has_dynamic_offset: false,
16✔
322
                    min_binding_size: Some(NonZeroU64::new(INDIRECT_INDEX_SIZE as u64).unwrap()),
24✔
323
                },
324
                count: None,
16✔
325
            },
326
            // @group(1) @binding(2) var<storage, read> spawner : Spawner;
327
            BindGroupLayoutEntry {
8✔
328
                binding: 2,
8✔
329
                visibility: ShaderStages::VERTEX,
8✔
330
                ty: BindingType::Buffer {
8✔
331
                    ty: BufferBindingType::Storage { read_only: true },
8✔
332
                    has_dynamic_offset: true,
8✔
333
                    min_binding_size: Some(spawner_params_size),
8✔
334
                },
335
                count: None,
8✔
336
            },
337
        ];
338
        let label = format!(
16✔
339
            "hanabi:bind_group_layout:render:particles@1:vfx{}",
340
            slab_id.0
341
        );
342
        trace!(
8✔
343
            "Creating render layout '{}' with {} entries",
3✔
344
            label,
345
            entries.len(),
6✔
346
        );
347
        let render_particles_buffer_layout =
8✔
348
            render_device.create_bind_group_layout(&label[..], &entries[..]);
32✔
349

350
        Self {
351
            particle_buffer,
352
            indirect_index_buffer,
353
            particle_layout,
354
            render_particles_buffer_layout,
355
            capacity,
356
            used_size: 0,
357
            free_slices: vec![],
16✔
358
            asset,
359
            sim_bind_group: None,
360
            sim_bind_group_key: SimBindGroupKey::INVALID,
361
        }
362
    }
363

364
    // TODO - move; this only depends on the particle and spawner layouts, can be
365
    // shared across slabs
366
    pub fn render_particles_buffer_layout(&self) -> &BindGroupLayout {
2✔
367
        &self.render_particles_buffer_layout
2✔
368
    }
369

370
    #[inline]
371
    pub fn particle_buffer(&self) -> &Buffer {
×
372
        &self.particle_buffer
×
373
    }
374

375
    #[inline]
376
    pub fn indirect_index_buffer(&self) -> &Buffer {
×
377
        &self.indirect_index_buffer
×
378
    }
379

380
    #[inline]
381
    pub fn particle_offset(&self, row: u32) -> u32 {
×
382
        self.particle_layout.min_binding_size().get() as u32 * row
×
383
    }
384

385
    #[inline]
386
    pub fn indirect_index_offset(&self, row: u32) -> u32 {
×
387
        row * 12
×
388
    }
389

390
    /// Return a binding for the entire particle buffer.
391
    pub fn as_entire_binding_particle(&self) -> BindingResource<'_> {
5✔
392
        let capacity_bytes = self.capacity as u64 * self.particle_layout.min_binding_size().get();
20✔
393
        BindingResource::Buffer(BufferBinding {
5✔
394
            buffer: &self.particle_buffer,
10✔
395
            offset: 0,
5✔
396
            size: Some(NonZeroU64::new(capacity_bytes).unwrap()),
10✔
397
        })
398
        //self.particle_buffer.as_entire_binding()
399
    }
400

401
    /// Return a binding source for the entire particle buffer.
402
    pub fn max_binding_source(&self) -> BufferBindingSource {
×
403
        let capacity_bytes = self.capacity * self.particle_layout.min_binding_size32().get();
×
404
        BufferBindingSource {
405
            buffer: self.particle_buffer.clone(),
×
406
            offset: 0,
407
            size: NonZeroU32::new(capacity_bytes).unwrap(),
×
408
        }
409
    }
410

411
    /// Return a binding for the entire indirect buffer associated with the
412
    /// current effect buffer.
413
    pub fn as_entire_binding_indirect(&self) -> BindingResource<'_> {
5✔
414
        let capacity_bytes = self.capacity as u64 * 12;
10✔
415
        BindingResource::Buffer(BufferBinding {
5✔
416
            buffer: &self.indirect_index_buffer,
10✔
417
            offset: 0,
5✔
418
            size: Some(NonZeroU64::new(capacity_bytes).unwrap()),
10✔
419
        })
420
        //self.indirect_index_buffer.as_entire_binding()
421
    }
422

423
    /// Create the "particle" bind group @1 for the init and update passes if
424
    /// needed.
425
    ///
426
    /// The `slab_id` must be the ID of the current [`ParticleSlab`] inside the
427
    /// [`EffectCache`].
428
    pub fn create_particle_sim_bind_group(
1,012✔
429
        &mut self,
430
        layout: &BindGroupLayout,
431
        slab_id: &SlabId,
432
        render_device: &RenderDevice,
433
        parent_binding_source: Option<&BufferBindingSource>,
434
    ) {
435
        let key: SimBindGroupKey = parent_binding_source.into();
4,048✔
436
        if self.sim_bind_group.is_some() && self.sim_bind_group_key == key {
3,033✔
437
            return;
1,009✔
438
        }
439

440
        let label = format!("hanabi:bind_group:sim:particle@1:vfx{}", slab_id.index());
NEW
441
        let entries: &[BindGroupEntry] = if let Some(parent_binding) =
×
NEW
442
            parent_binding_source.as_ref().map(|bbs| bbs.as_binding())
×
443
        {
444
            &[
445
                BindGroupEntry {
446
                    binding: 0,
447
                    resource: self.as_entire_binding_particle(),
448
                },
449
                BindGroupEntry {
450
                    binding: 1,
451
                    resource: self.as_entire_binding_indirect(),
452
                },
453
                BindGroupEntry {
454
                    binding: 2,
455
                    resource: parent_binding,
456
                },
457
            ]
458
        } else {
459
            &[
3✔
460
                BindGroupEntry {
6✔
461
                    binding: 0,
6✔
462
                    resource: self.as_entire_binding_particle(),
6✔
463
                },
464
                BindGroupEntry {
3✔
465
                    binding: 1,
3✔
466
                    resource: self.as_entire_binding_indirect(),
3✔
467
                },
468
            ]
469
        };
470

471
        trace!(
472
            "Create particle simulation bind group '{}' with {} entries (has_parent:{})",
3✔
473
            label,
474
            entries.len(),
6✔
475
            parent_binding_source.is_some(),
6✔
476
        );
477
        let bind_group = render_device.create_bind_group(Some(&label[..]), layout, entries);
478
        self.sim_bind_group = Some(bind_group);
479
        self.sim_bind_group_key = key;
480
    }
481

482
    /// Invalidate any existing simulate bind group.
483
    ///
484
    /// Invalidate any existing bind group previously created by
485
    /// [`create_particle_sim_bind_group()`], generally because a buffer was
486
    /// re-allocated. This forces a re-creation of the bind group
487
    /// next time [`create_particle_sim_bind_group()`] is called.
488
    ///
489
    /// [`create_particle_sim_bind_group()`]: self::ParticleSlab::create_particle_sim_bind_group
490
    #[allow(dead_code)] // FIXME - review this...
491
    fn invalidate_particle_sim_bind_group(&mut self) {
×
492
        self.sim_bind_group = None;
×
493
        self.sim_bind_group_key = SimBindGroupKey::INVALID;
×
494
    }
495

496
    /// Return the cached particle@1 bind group for the simulation (init and
497
    /// update) passes.
498
    ///
499
    /// This is the per-buffer bind group at binding @1 which binds all
500
    /// per-buffer resources shared by all effect instances batched in a single
501
    /// buffer. The bind group is created by
502
    /// [`create_particle_sim_bind_group()`], and cached until a call to
503
    /// [`invalidate_particle_sim_bind_groups()`] clears the
504
    /// cached reference.
505
    ///
506
    /// [`create_particle_sim_bind_group()`]: self::ParticleSlab::create_particle_sim_bind_group
507
    /// [`invalidate_particle_sim_bind_groups()`]: self::ParticleSlab::invalidate_particle_sim_bind_groups
508
    pub fn particle_sim_bind_group(&self) -> Option<&BindGroup> {
2,009✔
509
        self.sim_bind_group.as_ref()
4,018✔
510
    }
511

512
    /// Try to recycle a free slice to store `size` items.
513
    fn pop_free_slice(&mut self, size: u32) -> Option<Range<u32>> {
20✔
514
        if self.free_slices.is_empty() {
40✔
515
            return None;
17✔
516
        }
517

518
        struct BestRange {
519
            range: Range<u32>,
520
            capacity: u32,
521
            index: usize,
522
        }
523

524
        let mut result = BestRange {
525
            range: 0..0, // marker for "invalid"
526
            capacity: u32::MAX,
527
            index: usize::MAX,
528
        };
529
        for (index, slice) in self.free_slices.iter().enumerate() {
3✔
530
            let capacity = slice.end - slice.start;
531
            if size > capacity {
532
                continue;
1✔
533
            }
534
            if capacity < result.capacity {
4✔
535
                result = BestRange {
2✔
536
                    range: slice.clone(),
6✔
537
                    capacity,
2✔
538
                    index,
2✔
539
                };
540
            }
541
        }
542
        if !result.range.is_empty() {
543
            if result.capacity > size {
2✔
544
                // split
545
                let start = result.range.start;
2✔
546
                let used_end = start + size;
2✔
547
                let free_end = result.range.end;
2✔
548
                let range = start..used_end;
2✔
549
                self.free_slices[result.index] = used_end..free_end;
2✔
550
                Some(range)
1✔
551
            } else {
552
                // recycle entirely
553
                self.free_slices.remove(result.index);
1✔
554
                Some(result.range)
555
            }
556
        } else {
557
            None
1✔
558
        }
559
    }
560

561
    /// Allocate a new entry in the slab to store the particles of a single
562
    /// effect.
563
    pub fn allocate(&mut self, capacity: u32) -> Option<SlabSliceRef> {
21✔
564
        trace!("ParticleSlab::allocate(capacity={})", capacity);
24✔
565

566
        if capacity > self.capacity {
21✔
567
            return None;
1✔
568
        }
569

570
        let range = if let Some(range) = self.pop_free_slice(capacity) {
20✔
571
            range
572
        } else {
573
            let new_size = self.used_size.checked_add(capacity).unwrap();
90✔
574
            if new_size <= self.capacity {
18✔
575
                let range = self.used_size..new_size;
32✔
576
                self.used_size = new_size;
16✔
577
                range
16✔
578
            } else {
579
                if self.used_size == 0 {
2✔
580
                    warn!(
×
NEW
581
                        "Cannot allocate slice of size {} in particle slab of capacity {}.",
×
582
                        capacity, self.capacity
583
                    );
584
                }
585
                return None;
586
            }
587
        };
588

589
        trace!("-> allocated slice {:?}", range);
3✔
590
        Some(SlabSliceRef {
591
            range,
592
            particle_layout: self.particle_layout.clone(),
593
        })
594
    }
595

596
    /// Free an allocated slice, and if this was the last allocated slice also
597
    /// free the buffer.
598
    pub fn free_slice(&mut self, slice: SlabSliceRef) -> SlabState {
11✔
599
        // If slice is at the end of the buffer, reduce total used size
600
        if slice.range.end == self.used_size {
11✔
601
            self.used_size = slice.range.start;
5✔
602
            // Check other free slices to further reduce used size and drain the free slice
603
            // list
604
            while let Some(free_slice) = self.free_slices.last() {
15✔
605
                if free_slice.end == self.used_size {
5✔
606
                    self.used_size = free_slice.start;
5✔
607
                    self.free_slices.pop();
5✔
608
                } else {
609
                    break;
×
610
                }
611
            }
612
            if self.used_size == 0 {
5✔
613
                assert!(self.free_slices.is_empty());
12✔
614
                // The buffer is not used anymore, free it too
615
                SlabState::Free
4✔
616
            } else {
617
                // There are still some slices used, the last one of which ends at
618
                // self.used_size
619
                SlabState::Used
1✔
620
            }
621
        } else {
622
            // Free slice is not at end; insert it in free list
623
            let range = slice.range;
6✔
624
            match self.free_slices.binary_search_by(|s| {
6✔
625
                if s.end <= range.start {
6✔
626
                    Ordering::Less
6✔
627
                } else if s.start >= range.end {
×
628
                    Ordering::Greater
×
629
                } else {
630
                    Ordering::Equal
×
631
                }
632
            }) {
633
                Ok(_) => warn!("Range {:?} already present in free list!", range),
×
634
                Err(index) => self.free_slices.insert(index, range),
30✔
635
            }
636
            SlabState::Used
637
        }
638
    }
639

640
    /// Check whether this slab is compatible with the given asset.
641
    ///
642
    /// This allows determining whether an instance of the effect can be stored
643
    /// inside this slab.
644
    pub fn is_compatible(
2✔
645
        &self,
646
        handle: &Handle<EffectAsset>,
647
        _particle_layout: &ParticleLayout,
648
    ) -> bool {
649
        // TODO - replace with check particle layout is compatible to allow tighter
650
        // packing in less buffers, and update in the less dispatch calls
651
        *handle == self.asset
2✔
652
    }
653
}
654

655
/// A single cached effect in the [`EffectCache`].
656
#[derive(Debug, Component)]
657
pub(crate) struct CachedEffect {
658
    /// ID of the slab of the slab storing the particles for this effect in the
659
    /// [`EffectCache`].
660
    pub slab_id: SlabId,
661
    /// The allocated effect slice within that slab.
662
    pub slice: SlabSliceRef,
663
}
664

665
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666
pub(crate) enum AnyDrawIndirectArgs {
667
    /// Args of a non-indexed draw call.
668
    NonIndexed(GpuDrawIndirectArgs),
669
    /// Args of an indexed draw call.
670
    Indexed(GpuDrawIndexedIndirectArgs),
671
}
672

673
impl AnyDrawIndirectArgs {
674
    /// Create from a vertex buffer slice and an optional index buffer one.
675
    pub fn from_slices(
1,014✔
676
        vertex_slice: &MeshBufferSlice<'_>,
677
        index_slice: Option<&MeshBufferSlice<'_>>,
678
    ) -> Self {
679
        if let Some(index_slice) = index_slice {
2,028✔
680
            Self::Indexed(GpuDrawIndexedIndirectArgs {
681
                index_count: index_slice.range.len() as u32,
682
                instance_count: 0,
683
                first_index: index_slice.range.start,
684
                base_vertex: vertex_slice.range.start as i32,
685
                first_instance: 0,
686
            })
687
        } else {
NEW
688
            Self::NonIndexed(GpuDrawIndirectArgs {
×
NEW
689
                vertex_count: vertex_slice.range.len() as u32,
×
NEW
690
                instance_count: 0,
×
NEW
691
                first_vertex: vertex_slice.range.start,
×
NEW
692
                first_instance: 0,
×
693
            })
694
        }
695
    }
696

697
    /// Check if this args are for an indexed draw call.
698
    #[inline(always)]
NEW
699
    pub fn is_indexed(&self) -> bool {
×
NEW
700
        matches!(*self, Self::Indexed(..))
×
701
    }
702

703
    /// Bit-cast the args to the row entry of the GPU buffer.
704
    ///
705
    /// If non-indexed, this returns an indexed struct bit-cast from the actual
706
    /// non-indexed one, ready for GPU upload.
707
    pub fn bitcast_to_row_entry(&self) -> GpuDrawIndexedIndirectArgs {
2✔
708
        match self {
2✔
709
            AnyDrawIndirectArgs::NonIndexed(args) => GpuDrawIndexedIndirectArgs {
710
                index_count: args.vertex_count,
711
                instance_count: args.instance_count,
712
                first_index: args.first_vertex,
713
                base_vertex: args.first_instance as i32,
714
                first_instance: 0,
715
            },
716
            AnyDrawIndirectArgs::Indexed(args) => *args,
4✔
717
        }
718
    }
719
}
720

721
impl From<GpuDrawIndirectArgs> for AnyDrawIndirectArgs {
NEW
722
    fn from(args: GpuDrawIndirectArgs) -> Self {
×
NEW
723
        Self::NonIndexed(args)
×
724
    }
725
}
726

727
impl From<GpuDrawIndexedIndirectArgs> for AnyDrawIndirectArgs {
NEW
728
    fn from(args: GpuDrawIndexedIndirectArgs) -> Self {
×
NEW
729
        Self::Indexed(args)
×
730
    }
731
}
732

733
/// Index of a row (entry) into the [`BufferTable`] storing the indirect draw
734
/// args of a single draw call.
735
#[derive(Debug, Clone, Copy, Component)]
736
pub(crate) struct CachedDrawIndirectArgs {
737
    pub row: BufferTableId,
738
    pub args: AnyDrawIndirectArgs,
739
}
740

741
impl Default for CachedDrawIndirectArgs {
UNCOV
742
    fn default() -> Self {
×
743
        Self {
744
            row: BufferTableId::INVALID,
NEW
745
            args: AnyDrawIndirectArgs::NonIndexed(default()),
×
746
        }
747
    }
748
}
749

750
impl CachedDrawIndirectArgs {
751
    /// Check if the index is valid.
752
    ///
753
    /// An invalid index doesn't correspond to any allocated args entry. A valid
754
    /// one may, but note that the args entry in the buffer may have been freed
755
    /// already with this index. There's no mechanism to detect reuse either.
756
    #[inline(always)]
UNCOV
757
    pub fn is_valid(&self) -> bool {
×
NEW
758
        self.get_row_raw().is_valid()
×
759
    }
760

761
    /// Check if this row index refers to an indexed draw args entry.
762
    #[inline(always)]
UNCOV
763
    pub fn is_indexed(&self) -> bool {
×
NEW
764
        self.args.is_indexed()
×
765
    }
766

767
    /// Get the raw index value.
768
    ///
769
    /// Retrieve the raw index value, losing the discriminant between indexed
770
    /// and non-indexed draw. This is useful when storing the index value into a
771
    /// GPU buffer. The rest of the time, prefer retaining the typed enum for
772
    /// safety.
773
    ///
774
    /// # Panics
775
    ///
776
    /// Panics if the index is invalid, whether indexed or non-indexed.
777
    pub fn get_row(&self) -> BufferTableId {
1,016✔
778
        let idx = self.get_row_raw();
3,048✔
779
        assert!(idx.is_valid());
3,048✔
780
        idx
1,016✔
781
    }
782

783
    #[inline(always)]
784
    fn get_row_raw(&self) -> BufferTableId {
1,016✔
785
        self.row
1,016✔
786
    }
787
}
788

789
/// The indices in the indirect dispatch buffers for a single effect, as well as
790
/// that of the metadata buffer.
791
#[derive(Debug, Default, Clone, Copy, Component)]
792
pub(crate) struct DispatchBufferIndices {
793
    /// The index of the [`GpuDispatchIndirect`] row in the GPU buffer
794
    /// [`EffectsMeta::update_dispatch_indirect_buffer`].
795
    ///
796
    /// [`GpuDispatchIndirect`]: super::GpuDispatchIndirect
797
    /// [`EffectsMeta::update_dispatch_indirect_buffer`]: super::EffectsMeta::dispatch_indirect_buffer
798
    pub(crate) update_dispatch_indirect_buffer_row_index: u32,
799
}
800

801
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
802
struct ParticleBindGroupLayoutKey {
803
    pub min_binding_size: NonZeroU32,
804
    pub parent_min_binding_size: Option<NonZeroU32>,
805
}
806

807
/// Cache for effect instances sharing common GPU data structures.
808
#[derive(Resource)]
809
pub struct EffectCache {
810
    /// Render device the GPU resources (buffers) are allocated from.
811
    render_device: RenderDevice,
812
    /// Collection of particle slabs managed by this cache. Some slabs might be
813
    /// `None` if the entry is not used. Since the slabs are referenced
814
    /// by index, we cannot move them once they're allocated.
815
    particle_slabs: Vec<Option<ParticleSlab>>,
816
    /// Cache of bind group layouts for the particle@1 bind groups of the
817
    /// simulation passes (init and update). Since all bindings depend only
818
    /// on buffers managed by the [`EffectCache`], we also cache the layouts
819
    /// here for convenience.
820
    particle_bind_group_layouts: HashMap<ParticleBindGroupLayoutKey, BindGroupLayout>,
821
    /// Cache of bind group layouts for the metadata@3 bind group of the init
822
    /// pass.
823
    metadata_init_bind_group_layout: [Option<BindGroupLayout>; 2],
824
    /// Cache of bind group layouts for the metadata@3 bind group of the
825
    /// updatepass.
826
    metadata_update_bind_group_layouts: HashMap<u32, BindGroupLayout>,
827
}
828

829
impl EffectCache {
830
    /// Create a new empty cache.
831
    pub fn new(device: RenderDevice) -> Self {
4✔
832
        Self {
833
            render_device: device,
834
            particle_slabs: vec![],
8✔
835
            particle_bind_group_layouts: default(),
8✔
836
            metadata_init_bind_group_layout: [None, None],
4✔
837
            metadata_update_bind_group_layouts: default(),
4✔
838
        }
839
    }
840

841
    /// Get all the particle slab slots. Unallocated slots are `None`. This can
842
    /// be indexed by the slab index.
843
    #[allow(dead_code)]
844
    #[inline]
845
    pub fn slabs(&self) -> &[Option<ParticleSlab>] {
1,019✔
846
        &self.particle_slabs
1,019✔
847
    }
848

849
    /// Get all the particle slab slots. Unallocated slots are `None`. This can
850
    /// be indexed by the slab ID.
851
    #[allow(dead_code)]
852
    #[inline]
NEW
853
    pub fn slabs_mut(&mut self) -> &mut [Option<ParticleSlab>] {
×
NEW
854
        &mut self.particle_slabs
×
855
    }
856

857
    /// Fetch a specific slab by ID.
858
    #[inline]
859
    pub fn get_slab(&self, slab_id: &SlabId) -> Option<&ParticleSlab> {
2,009✔
860
        self.particle_slabs.get(slab_id.0 as usize)?.as_ref()
6,027✔
861
    }
862

863
    /// Fetch a specific buffer by ID.
864
    #[allow(dead_code)]
865
    #[inline]
NEW
866
    pub fn get_slab_mut(&mut self, slab_id: &SlabId) -> Option<&mut ParticleSlab> {
×
NEW
867
        self.particle_slabs.get_mut(slab_id.0 as usize)?.as_mut()
×
868
    }
869

870
    /// Invalidate all the particle@1 bind group for all buffers.
871
    ///
872
    /// This iterates over all valid buffers and calls
873
    /// [`ParticleSlab::invalidate_particle_sim_bind_group()`] on each one.
874
    #[allow(dead_code)] // FIXME - review this...
875
    pub fn invalidate_particle_sim_bind_groups(&mut self) {
×
NEW
876
        for buffer in self.particle_slabs.iter_mut().flatten() {
×
877
            buffer.invalidate_particle_sim_bind_group();
878
        }
879
    }
880

881
    /// Insert a new effect instance in the cache.
882
    pub fn insert(
6✔
883
        &mut self,
884
        asset: Handle<EffectAsset>,
885
        capacity: u32,
886
        particle_layout: &ParticleLayout,
887
    ) -> CachedEffect {
888
        trace!("Inserting new effect into cache: capacity={capacity}");
9✔
889
        let (slab_id, slice) = self
18✔
890
            .particle_slabs
6✔
891
            .iter_mut()
892
            .enumerate()
893
            .find_map(|(slab_index, maybe_slab)| {
10✔
894
                // Ignore empty (non-allocated) entries as we're trying to fit the new allocation inside an existing slab.
895
                let Some(slab) = maybe_slab else { return None; };
8✔
896

897
                // The slab must be compatible with the effect's layout, otherwise ignore it.
898
                if !slab.is_compatible(&asset, particle_layout) {
NEW
899
                    return None;
×
900
                }
901

902
                // Try to allocate a slice into the slab
903
                slab
2✔
904
                    .allocate(capacity)
4✔
905
                    .map(|slice| (SlabId::new(slab_index as u32), slice))
2✔
906
            })
907
            .unwrap_or_else(|| {
12✔
908
                // Cannot find any suitable slab; allocate a new one
909
                let index = self.particle_slabs.iter().position(|buf| buf.is_none()).unwrap_or(self.particle_slabs.len());
42✔
910
                let byte_size = capacity.checked_mul(particle_layout.min_binding_size().get() as u32).unwrap_or_else(|| panic!(
36✔
NEW
911
                    "Effect size overflow: capacity={:?} particle_layout={:?} item_size={}",
×
912
                    capacity, particle_layout, particle_layout.min_binding_size().get()
×
913
                ));
914
                trace!(
6✔
915
                    "Creating new particle slab #{} for effect {:?} (capacity={:?}, particle_layout={:?} item_size={}, byte_size={})",
3✔
916
                    index,
917
                    asset,
918
                    capacity,
919
                    particle_layout,
920
                    particle_layout.min_binding_size().get(),
9✔
921
                    byte_size
922
                );
923
                let slab_id = SlabId::new(index as u32);
18✔
924
                let mut slab = ParticleSlab::new(
12✔
925
                    slab_id,
6✔
926
                    asset,
6✔
927
                    capacity,
6✔
928
                    particle_layout.clone(),
12✔
929
                    &self.render_device,
6✔
930
                );
931
                let slice_ref = slab.allocate(capacity).unwrap();
30✔
932
                if index >= self.particle_slabs.len() {
16✔
933
                    self.particle_slabs.push(Some(slab));
8✔
934
                } else {
935
                    debug_assert!(self.particle_slabs[index].is_none());
2✔
936
                    self.particle_slabs[index] = Some(slab);
4✔
937
                }
938
                (slab_id, slice_ref)
6✔
939
            });
940

941
        let slice = SlabSliceRef {
942
            range: slice.range.clone(),
12✔
943
            particle_layout: slice.particle_layout,
6✔
944
        };
945

946
        trace!(
6✔
947
            "Insert effect slab_id={} slice={}B particle_layout={:?}",
3✔
948
            slab_id.0,
949
            slice.particle_layout.min_binding_size().get(),
9✔
950
            slice.particle_layout,
951
        );
952
        CachedEffect { slab_id, slice }
953
    }
954

955
    /// Remove an effect from the cache. If this was the last effect, drop the
956
    /// underlying buffer and return the index of the dropped buffer.
957
    pub fn remove(&mut self, cached_effect: &CachedEffect) -> Result<SlabState, ()> {
3✔
958
        // Resolve the buffer by index
959
        let Some(maybe_buffer) = self
9✔
960
            .particle_slabs
6✔
961
            .get_mut(cached_effect.slab_id.0 as usize)
3✔
962
        else {
UNCOV
963
            return Err(());
×
964
        };
965
        let Some(buffer) = maybe_buffer.as_mut() else {
3✔
966
            return Err(());
×
967
        };
968

969
        // Free the slice inside the resolved buffer
970
        if buffer.free_slice(cached_effect.slice.clone()) == SlabState::Free {
971
            *maybe_buffer = None;
6✔
972
            return Ok(SlabState::Free);
3✔
973
        }
974

975
        Ok(SlabState::Used)
976
    }
977

978
    //
979
    // Bind group layouts
980
    //
981

982
    /// Ensure a bind group layout exists for the bind group @1 ("particles")
983
    /// for use with the given min binding sizes.
984
    pub fn ensure_particle_bind_group_layout(
1,015✔
985
        &mut self,
986
        min_binding_size: NonZeroU32,
987
        parent_min_binding_size: Option<NonZeroU32>,
988
    ) -> &BindGroupLayout {
989
        // FIXME - This "ensure" pattern means we never de-allocate entries. This is
990
        // probably fine, because there's a limited number of realistic combinations,
991
        // but could cause wastes if e.g. loading widely different scenes.
992
        let key = ParticleBindGroupLayoutKey {
993
            min_binding_size,
994
            parent_min_binding_size,
995
        };
996
        self.particle_bind_group_layouts
1,015✔
997
            .entry(key)
2,030✔
998
            .or_insert_with(|| {
1,017✔
999
                trace!("Creating new particle sim bind group @1 for min_binding_size={} parent_min_binding_size={:?}", min_binding_size, parent_min_binding_size);
4✔
1000
                create_particle_sim_bind_group_layout(
2✔
1001
                    &self.render_device,
2✔
1002
                    min_binding_size,
2✔
1003
                    parent_min_binding_size,
2✔
1004
                )
1005
            })
1006
    }
1007

1008
    /// Get the bind group layout for the bind group @1 ("particles") for use
1009
    /// with the given min binding sizes.
1010
    pub fn particle_bind_group_layout(
1,014✔
1011
        &self,
1012
        min_binding_size: NonZeroU32,
1013
        parent_min_binding_size: Option<NonZeroU32>,
1014
    ) -> Option<&BindGroupLayout> {
1015
        let key = ParticleBindGroupLayoutKey {
1016
            min_binding_size,
1017
            parent_min_binding_size,
1018
        };
1019
        self.particle_bind_group_layouts.get(&key)
3,042✔
1020
    }
1021

1022
    /// Ensure a bind group layout exists for the metadata@3 bind group of
1023
    /// the init pass.
1024
    pub fn ensure_metadata_init_bind_group_layout(&mut self, consume_gpu_spawn_events: bool) {
3✔
1025
        let layout = &mut self.metadata_init_bind_group_layout[consume_gpu_spawn_events as usize];
6✔
1026
        if layout.is_none() {
8✔
1027
            *layout = Some(create_metadata_init_bind_group_layout(
6✔
1028
                &self.render_device,
2✔
1029
                consume_gpu_spawn_events,
2✔
1030
            ));
1031
        }
1032
    }
1033

1034
    /// Get the bind group layout for the metadata@3 bind group of the init
1035
    /// pass.
1036
    pub fn metadata_init_bind_group_layout(
1,014✔
1037
        &self,
1038
        consume_gpu_spawn_events: bool,
1039
    ) -> Option<&BindGroupLayout> {
1040
        self.metadata_init_bind_group_layout[consume_gpu_spawn_events as usize].as_ref()
2,028✔
1041
    }
1042

1043
    /// Ensure a bind group layout exists for the metadata@3 bind group of
1044
    /// the update pass.
1045
    pub fn ensure_metadata_update_bind_group_layout(&mut self, num_event_buffers: u32) {
2✔
1046
        self.metadata_update_bind_group_layouts
2✔
1047
            .entry(num_event_buffers)
4✔
1048
            .or_insert_with(|| {
4✔
1049
                create_metadata_update_bind_group_layout(&self.render_device, num_event_buffers)
6✔
1050
            });
1051
    }
1052

1053
    /// Get the bind group layout for the metadata@3 bind group of the
1054
    /// update pass.
1055
    pub fn metadata_update_bind_group_layout(
1,014✔
1056
        &self,
1057
        num_event_buffers: u32,
1058
    ) -> Option<&BindGroupLayout> {
1059
        self.metadata_update_bind_group_layouts
1,014✔
1060
            .get(&num_event_buffers)
2,028✔
1061
    }
1062

1063
    //
1064
    // Bind groups
1065
    //
1066

1067
    /// Get the "particle" bind group for the simulation (init and update)
1068
    /// passes a cached effect stored in a given GPU particle buffer.
1069
    pub fn particle_sim_bind_group(&self, slab_id: &SlabId) -> Option<&BindGroup> {
2,009✔
1070
        self.get_slab(slab_id)
6,027✔
1071
            .and_then(|slab| slab.particle_sim_bind_group())
6,027✔
1072
    }
1073

1074
    pub fn create_particle_sim_bind_group(
1,012✔
1075
        &mut self,
1076
        slab_id: &SlabId,
1077
        render_device: &RenderDevice,
1078
        min_binding_size: NonZeroU32,
1079
        parent_min_binding_size: Option<NonZeroU32>,
1080
        parent_binding_source: Option<&BufferBindingSource>,
1081
    ) -> Result<(), ()> {
1082
        // Create the bind group
1083
        let layout = self
3,036✔
1084
            .ensure_particle_bind_group_layout(min_binding_size, parent_min_binding_size)
2,024✔
1085
            .clone();
1086
        let slot = self
3,036✔
1087
            .particle_slabs
2,024✔
1088
            .get_mut(slab_id.index() as usize)
2,024✔
1089
            .ok_or(())?;
2,024✔
1090
        let effect_buffer = slot.as_mut().ok_or(())?;
1,012✔
1091
        effect_buffer.create_particle_sim_bind_group(
1092
            &layout,
1093
            slab_id,
1094
            render_device,
1095
            parent_binding_source,
1096
        );
1097
        Ok(())
1098
    }
1099
}
1100

1101
/// Create the bind group layout for the "particle" group (@1) of the init and
1102
/// update passes.
1103
fn create_particle_sim_bind_group_layout(
2✔
1104
    render_device: &RenderDevice,
1105
    particle_layout_min_binding_size: NonZeroU32,
1106
    parent_particle_layout_min_binding_size: Option<NonZeroU32>,
1107
) -> BindGroupLayout {
1108
    let mut entries = Vec::with_capacity(3);
4✔
1109

1110
    // @group(1) @binding(0) var<storage, read_write> particle_buffer :
1111
    // ParticleBuffer
1112
    entries.push(BindGroupLayoutEntry {
6✔
1113
        binding: 0,
2✔
1114
        visibility: ShaderStages::COMPUTE,
2✔
1115
        ty: BindingType::Buffer {
2✔
1116
            ty: BufferBindingType::Storage { read_only: false },
4✔
1117
            has_dynamic_offset: false,
2✔
1118
            min_binding_size: Some(particle_layout_min_binding_size.into()),
2✔
1119
        },
1120
        count: None,
2✔
1121
    });
1122

1123
    // @group(1) @binding(1) var<storage, read_write> indirect_buffer :
1124
    // IndirectBuffer
1125
    entries.push(BindGroupLayoutEntry {
6✔
1126
        binding: 1,
2✔
1127
        visibility: ShaderStages::COMPUTE,
2✔
1128
        ty: BindingType::Buffer {
2✔
1129
            ty: BufferBindingType::Storage { read_only: false },
4✔
1130
            has_dynamic_offset: false,
2✔
1131
            min_binding_size: Some(NonZeroU64::new(INDIRECT_INDEX_SIZE as _).unwrap()),
4✔
1132
        },
1133
        count: None,
2✔
1134
    });
1135

1136
    // @group(1) @binding(2) var<storage, read> parent_particle_buffer :
1137
    // ParentParticleBuffer;
1138
    if let Some(min_binding_size) = parent_particle_layout_min_binding_size {
2✔
1139
        entries.push(BindGroupLayoutEntry {
1140
            binding: 2,
1141
            visibility: ShaderStages::COMPUTE,
1142
            ty: BindingType::Buffer {
1143
                ty: BufferBindingType::Storage { read_only: true },
1144
                has_dynamic_offset: false,
1145
                min_binding_size: Some(min_binding_size.into()),
1146
            },
1147
            count: None,
1148
        });
1149
    }
1150

1151
    let hash = calc_hash(&entries);
6✔
1152
    let label = format!("hanabi:bind_group_layout:sim:particles_{:016X}", hash);
6✔
1153
    trace!(
2✔
1154
        "Creating particle bind group layout '{}' for init pass with {} entries. (parent_buffer:{})",
2✔
1155
        label,
1156
        entries.len(),
4✔
1157
        parent_particle_layout_min_binding_size.is_some(),
4✔
1158
    );
1159
    render_device.create_bind_group_layout(&label[..], &entries)
8✔
1160
}
1161

1162
/// Create the bind group layout for the metadata@3 bind group of the init pass.
1163
fn create_metadata_init_bind_group_layout(
2✔
1164
    render_device: &RenderDevice,
1165
    consume_gpu_spawn_events: bool,
1166
) -> BindGroupLayout {
1167
    let storage_alignment = render_device.limits().min_storage_buffer_offset_alignment;
4✔
1168
    let effect_metadata_size = GpuEffectMetadata::aligned_size(storage_alignment);
6✔
1169

1170
    let mut entries = Vec::with_capacity(3);
4✔
1171

1172
    // @group(3) @binding(0) var<storage, read_write> effect_metadata :
1173
    // EffectMetadata;
1174
    entries.push(BindGroupLayoutEntry {
6✔
1175
        binding: 0,
2✔
1176
        visibility: ShaderStages::COMPUTE,
2✔
1177
        ty: BindingType::Buffer {
2✔
1178
            ty: BufferBindingType::Storage { read_only: false },
2✔
1179
            has_dynamic_offset: false,
2✔
1180
            // This WGSL struct is manually padded, so the Rust type GpuEffectMetadata doesn't
1181
            // reflect its true min size.
1182
            min_binding_size: Some(effect_metadata_size),
2✔
1183
        },
1184
        count: None,
2✔
1185
    });
1186

1187
    if consume_gpu_spawn_events {
2✔
1188
        // @group(3) @binding(1) var<storage, read> child_info_buffer : ChildInfoBuffer;
1189
        entries.push(BindGroupLayoutEntry {
×
1190
            binding: 1,
×
1191
            visibility: ShaderStages::COMPUTE,
×
1192
            ty: BindingType::Buffer {
×
1193
                ty: BufferBindingType::Storage { read_only: true },
×
1194
                has_dynamic_offset: false,
×
1195
                min_binding_size: Some(GpuChildInfo::min_size()),
×
1196
            },
1197
            count: None,
×
1198
        });
1199

1200
        // @group(3) @binding(2) var<storage, read> event_buffer : EventBuffer;
1201
        entries.push(BindGroupLayoutEntry {
×
1202
            binding: 2,
×
1203
            visibility: ShaderStages::COMPUTE,
×
1204
            ty: BindingType::Buffer {
×
1205
                ty: BufferBindingType::Storage { read_only: true },
×
1206
                has_dynamic_offset: false,
×
1207
                min_binding_size: Some(NonZeroU64::new(4).unwrap()),
×
1208
            },
1209
            count: None,
×
1210
        });
1211
    }
1212

1213
    let hash = calc_hash(&entries);
6✔
1214
    let label = format!(
4✔
1215
        "hanabi:bind_group_layout:init:metadata@3_{}{:016X}",
1216
        if consume_gpu_spawn_events {
2✔
1217
            "events"
×
1218
        } else {
1219
            "noevent"
2✔
1220
        },
1221
        hash
1222
    );
1223
    trace!(
2✔
1224
        "Creating metadata@3 bind group layout '{}' for init pass with {} entries. (consume_gpu_spawn_events:{})",
2✔
1225
        label,
1226
        entries.len(),
4✔
1227
        consume_gpu_spawn_events,
1228
    );
1229
    render_device.create_bind_group_layout(&label[..], &entries)
8✔
1230
}
1231

1232
/// Create the bind group layout for the metadata@3 bind group of the update
1233
/// pass.
1234
fn create_metadata_update_bind_group_layout(
2✔
1235
    render_device: &RenderDevice,
1236
    num_event_buffers: u32,
1237
) -> BindGroupLayout {
1238
    let storage_alignment = render_device.limits().min_storage_buffer_offset_alignment;
4✔
1239
    let effect_metadata_size = GpuEffectMetadata::aligned_size(storage_alignment);
6✔
1240

1241
    let mut entries = Vec::with_capacity(num_event_buffers as usize + 2);
6✔
1242

1243
    // @group(3) @binding(0) var<storage, read_write> effect_metadata :
1244
    // EffectMetadata;
1245
    entries.push(BindGroupLayoutEntry {
6✔
1246
        binding: 0,
2✔
1247
        visibility: ShaderStages::COMPUTE,
2✔
1248
        ty: BindingType::Buffer {
2✔
1249
            ty: BufferBindingType::Storage { read_only: false },
2✔
1250
            has_dynamic_offset: false,
2✔
1251
            // This WGSL struct is manually padded, so the Rust type GpuEffectMetadata doesn't
1252
            // reflect its true min size.
1253
            min_binding_size: Some(effect_metadata_size),
2✔
1254
        },
1255
        count: None,
2✔
1256
    });
1257

1258
    if num_event_buffers > 0 {
2✔
1259
        // @group(3) @binding(1) var<storage, read_write> child_infos : array<ChildInfo,
1260
        // N>;
1261
        entries.push(BindGroupLayoutEntry {
×
1262
            binding: 1,
×
1263
            visibility: ShaderStages::COMPUTE,
×
1264
            ty: BindingType::Buffer {
×
1265
                ty: BufferBindingType::Storage { read_only: false },
×
1266
                has_dynamic_offset: false,
×
1267
                min_binding_size: Some(GpuChildInfo::min_size()),
×
1268
            },
1269
            count: None,
×
1270
        });
1271

1272
        for i in 0..num_event_buffers {
×
1273
            // @group(3) @binding(2+i) var<storage, read_write> event_buffer_#i :
1274
            // EventBuffer;
1275
            entries.push(BindGroupLayoutEntry {
1276
                binding: 2 + i,
1277
                visibility: ShaderStages::COMPUTE,
1278
                ty: BindingType::Buffer {
1279
                    ty: BufferBindingType::Storage { read_only: false },
1280
                    has_dynamic_offset: false,
1281
                    min_binding_size: Some(NonZeroU64::new(4).unwrap()),
1282
                },
1283
                count: None,
1284
            });
1285
        }
1286
    }
1287

1288
    let hash = calc_hash(&entries);
6✔
1289
    let label = format!("hanabi:bind_group_layout:update:metadata_{:016X}", hash);
6✔
1290
    trace!(
2✔
1291
        "Creating particle bind group layout '{}' for init update with {} entries. (num_event_buffers:{})",
2✔
1292
        label,
1293
        entries.len(),
4✔
1294
        num_event_buffers,
1295
    );
1296
    render_device.create_bind_group_layout(&label[..], &entries)
8✔
1297
}
1298

1299
#[cfg(all(test, feature = "gpu_tests"))]
1300
mod gpu_tests {
1301
    use std::borrow::Cow;
1302

1303
    use bevy::math::Vec4;
1304

1305
    use super::*;
1306
    use crate::{
1307
        graph::{Value, VectorValue},
1308
        test_utils::MockRenderer,
1309
        Attribute, AttributeInner,
1310
    };
1311

1312
    #[test]
1313
    fn effect_slice_ord() {
1314
        let particle_layout = ParticleLayout::new().append(Attribute::POSITION).build();
1315
        let slice1 = EffectSlice {
1316
            slice: 0..32,
1317
            slab_id: SlabId::new(1),
1318
            particle_layout: particle_layout.clone(),
1319
        };
1320
        let slice2 = EffectSlice {
1321
            slice: 32..64,
1322
            slab_id: SlabId::new(1),
1323
            particle_layout: particle_layout.clone(),
1324
        };
1325
        assert!(slice1 < slice2);
1326
        assert!(slice1 <= slice2);
1327
        assert!(slice2 > slice1);
1328
        assert!(slice2 >= slice1);
1329

1330
        let slice3 = EffectSlice {
1331
            slice: 0..32,
1332
            slab_id: SlabId::new(0),
1333
            particle_layout,
1334
        };
1335
        assert!(slice3 < slice1);
1336
        assert!(slice3 < slice2);
1337
        assert!(slice1 > slice3);
1338
        assert!(slice2 > slice3);
1339
    }
1340

1341
    const F4A_INNER: &AttributeInner = &AttributeInner::new(
1342
        Cow::Borrowed("F4A"),
1343
        Value::Vector(VectorValue::new_vec4(Vec4::ONE)),
1344
    );
1345
    const F4B_INNER: &AttributeInner = &AttributeInner::new(
1346
        Cow::Borrowed("F4B"),
1347
        Value::Vector(VectorValue::new_vec4(Vec4::ONE)),
1348
    );
1349
    const F4C_INNER: &AttributeInner = &AttributeInner::new(
1350
        Cow::Borrowed("F4C"),
1351
        Value::Vector(VectorValue::new_vec4(Vec4::ONE)),
1352
    );
1353
    const F4D_INNER: &AttributeInner = &AttributeInner::new(
1354
        Cow::Borrowed("F4D"),
1355
        Value::Vector(VectorValue::new_vec4(Vec4::ONE)),
1356
    );
1357

1358
    const F4A: Attribute = Attribute(F4A_INNER);
1359
    const F4B: Attribute = Attribute(F4B_INNER);
1360
    const F4C: Attribute = Attribute(F4C_INNER);
1361
    const F4D: Attribute = Attribute(F4D_INNER);
1362

1363
    #[test]
1364
    fn slice_ref() {
1365
        let l16 = ParticleLayout::new().append(F4A).build();
1366
        assert_eq!(16, l16.size());
1367
        let l32 = ParticleLayout::new().append(F4A).append(F4B).build();
1368
        assert_eq!(32, l32.size());
1369
        let l48 = ParticleLayout::new()
1370
            .append(F4A)
1371
            .append(F4B)
1372
            .append(F4C)
1373
            .build();
1374
        assert_eq!(48, l48.size());
1375
        for (range, particle_layout, len, byte_size) in [
1376
            (0..0, &l16, 0, 0),
1377
            (0..16, &l16, 16, 16 * 16),
1378
            (0..16, &l32, 16, 16 * 32),
1379
            (240..256, &l48, 16, 16 * 48),
1380
        ] {
1381
            let sr = SlabSliceRef {
1382
                range,
1383
                particle_layout: particle_layout.clone(),
1384
            };
1385
            assert_eq!(sr.len(), len);
1386
            assert_eq!(sr.byte_size(), byte_size);
1387
        }
1388
    }
1389

1390
    #[test]
1391
    fn effect_buffer() {
1392
        let renderer = MockRenderer::new();
1393
        let render_device = renderer.device();
1394

1395
        let l64 = ParticleLayout::new()
1396
            .append(F4A)
1397
            .append(F4B)
1398
            .append(F4C)
1399
            .append(F4D)
1400
            .build();
1401
        assert_eq!(64, l64.size());
1402

1403
        let asset = Handle::<EffectAsset>::default();
1404
        let capacity = 4096;
1405
        let mut buffer = ParticleSlab::new(
1406
            SlabId::new(42),
1407
            asset,
1408
            capacity,
1409
            l64.clone(),
1410
            &render_device,
1411
        );
1412

1413
        assert_eq!(buffer.capacity, capacity.max(ParticleSlab::MIN_CAPACITY));
1414
        assert_eq!(64, buffer.particle_layout.size());
1415
        assert_eq!(64, buffer.particle_layout.min_binding_size().get());
1416
        assert_eq!(0, buffer.used_size);
1417
        assert!(buffer.free_slices.is_empty());
1418

1419
        assert_eq!(None, buffer.allocate(buffer.capacity + 1));
1420

1421
        let mut offset = 0;
1422
        let mut slices = vec![];
1423
        for size in [32, 128, 55, 148, 1, 2048, 42] {
1424
            let slice = buffer.allocate(size);
1425
            assert!(slice.is_some());
1426
            let slice = slice.unwrap();
1427
            assert_eq!(64, slice.particle_layout.size());
1428
            assert_eq!(64, buffer.particle_layout.min_binding_size().get());
1429
            assert_eq!(offset..offset + size, slice.range);
1430
            slices.push(slice);
1431
            offset += size;
1432
        }
1433
        assert_eq!(offset, buffer.used_size);
1434

1435
        assert_eq!(SlabState::Used, buffer.free_slice(slices[2].clone()));
1436
        assert_eq!(1, buffer.free_slices.len());
1437
        let free_slice = &buffer.free_slices[0];
1438
        assert_eq!(160..215, *free_slice);
1439
        assert_eq!(offset, buffer.used_size); // didn't move
1440

1441
        assert_eq!(SlabState::Used, buffer.free_slice(slices[3].clone()));
1442
        assert_eq!(SlabState::Used, buffer.free_slice(slices[4].clone()));
1443
        assert_eq!(SlabState::Used, buffer.free_slice(slices[5].clone()));
1444
        assert_eq!(4, buffer.free_slices.len());
1445
        assert_eq!(offset, buffer.used_size); // didn't move
1446

1447
        // this will collapse all the way to slices[1], the highest allocated
1448
        assert_eq!(SlabState::Used, buffer.free_slice(slices[6].clone()));
1449
        assert_eq!(0, buffer.free_slices.len()); // collapsed
1450
        assert_eq!(160, buffer.used_size); // collapsed
1451

1452
        assert_eq!(SlabState::Used, buffer.free_slice(slices[0].clone()));
1453
        assert_eq!(1, buffer.free_slices.len());
1454
        assert_eq!(160, buffer.used_size); // didn't move
1455

1456
        // collapse all, and free buffer
1457
        assert_eq!(SlabState::Free, buffer.free_slice(slices[1].clone()));
1458
        assert_eq!(0, buffer.free_slices.len());
1459
        assert_eq!(0, buffer.used_size); // collapsed and empty
1460
    }
1461

1462
    #[test]
1463
    fn pop_free_slice() {
1464
        let renderer = MockRenderer::new();
1465
        let render_device = renderer.device();
1466

1467
        let l64 = ParticleLayout::new()
1468
            .append(F4A)
1469
            .append(F4B)
1470
            .append(F4C)
1471
            .append(F4D)
1472
            .build();
1473
        assert_eq!(64, l64.size());
1474

1475
        let asset = Handle::<EffectAsset>::default();
1476
        let capacity = 2048; // ParticleSlab::MIN_CAPACITY;
1477
        assert!(capacity >= 2048); // otherwise the logic below breaks
1478
        let mut buffer = ParticleSlab::new(
1479
            SlabId::new(42),
1480
            asset,
1481
            capacity,
1482
            l64.clone(),
1483
            &render_device,
1484
        );
1485

1486
        let slice0 = buffer.allocate(32);
1487
        assert!(slice0.is_some());
1488
        let slice0 = slice0.unwrap();
1489
        assert_eq!(slice0.range, 0..32);
1490
        assert!(buffer.free_slices.is_empty());
1491

1492
        let slice1 = buffer.allocate(1024);
1493
        assert!(slice1.is_some());
1494
        let slice1 = slice1.unwrap();
1495
        assert_eq!(slice1.range, 32..1056);
1496
        assert!(buffer.free_slices.is_empty());
1497

1498
        let state = buffer.free_slice(slice0);
1499
        assert_eq!(state, SlabState::Used);
1500
        assert_eq!(buffer.free_slices.len(), 1);
1501
        assert_eq!(buffer.free_slices[0], 0..32);
1502

1503
        // Try to allocate a slice larger than slice0, such that slice0 cannot be
1504
        // recycled, and instead the new slice has to be appended after all
1505
        // existing ones.
1506
        let slice2 = buffer.allocate(64);
1507
        assert!(slice2.is_some());
1508
        let slice2 = slice2.unwrap();
1509
        assert_eq!(slice2.range.start, slice1.range.end); // after slice1
1510
        assert_eq!(slice2.range, 1056..1120);
1511
        assert_eq!(buffer.free_slices.len(), 1);
1512

1513
        // Now allocate a small slice that fits, to recycle (part of) slice0.
1514
        let slice3 = buffer.allocate(16);
1515
        assert!(slice3.is_some());
1516
        let slice3 = slice3.unwrap();
1517
        assert_eq!(slice3.range, 0..16);
1518
        assert_eq!(buffer.free_slices.len(), 1); // split
1519
        assert_eq!(buffer.free_slices[0], 16..32);
1520

1521
        // Allocate a second small slice that fits exactly the left space, completely
1522
        // recycling
1523
        let slice4 = buffer.allocate(16);
1524
        assert!(slice4.is_some());
1525
        let slice4 = slice4.unwrap();
1526
        assert_eq!(slice4.range, 16..32);
1527
        assert!(buffer.free_slices.is_empty()); // recycled
1528
    }
1529

1530
    #[test]
1531
    fn effect_cache() {
1532
        let renderer = MockRenderer::new();
1533
        let render_device = renderer.device();
1534

1535
        let l32 = ParticleLayout::new().append(F4A).append(F4B).build();
1536
        assert_eq!(32, l32.size());
1537

1538
        let mut effect_cache = EffectCache::new(render_device);
1539
        assert_eq!(effect_cache.slabs().len(), 0);
1540

1541
        let asset = Handle::<EffectAsset>::default();
1542
        let capacity = ParticleSlab::MIN_CAPACITY;
1543
        let item_size = l32.size();
1544

1545
        // Insert an effect
1546
        let effect1 = effect_cache.insert(asset.clone(), capacity, &l32);
1547
        //assert!(effect1.is_valid());
1548
        let slice1 = &effect1.slice;
1549
        assert_eq!(slice1.len(), capacity);
1550
        assert_eq!(
1551
            slice1.particle_layout.min_binding_size().get() as u32,
1552
            item_size
1553
        );
1554
        assert_eq!(slice1.range, 0..capacity);
1555
        assert_eq!(effect_cache.slabs().len(), 1);
1556

1557
        // Insert a second copy of the same effect
1558
        let effect2 = effect_cache.insert(asset.clone(), capacity, &l32);
1559
        //assert!(effect2.is_valid());
1560
        let slice2 = &effect2.slice;
1561
        assert_eq!(slice2.len(), capacity);
1562
        assert_eq!(
1563
            slice2.particle_layout.min_binding_size().get() as u32,
1564
            item_size
1565
        );
1566
        assert_eq!(slice2.range, 0..capacity);
1567
        assert_eq!(effect_cache.slabs().len(), 2);
1568

1569
        // Remove the first effect instance
1570
        let buffer_state = effect_cache.remove(&effect1).unwrap();
1571
        // Note: currently batching is disabled, so each instance has its own buffer,
1572
        // which becomes unused once the instance is destroyed.
1573
        assert_eq!(buffer_state, SlabState::Free);
1574
        assert_eq!(effect_cache.slabs().len(), 2);
1575
        {
1576
            let slabs = effect_cache.slabs();
1577
            assert!(slabs[0].is_none());
1578
            assert!(slabs[1].is_some()); // id2
1579
        }
1580

1581
        // Regression #60
1582
        let effect3 = effect_cache.insert(asset, capacity, &l32);
1583
        //assert!(effect3.is_valid());
1584
        let slice3 = &effect3.slice;
1585
        assert_eq!(slice3.len(), capacity);
1586
        assert_eq!(
1587
            slice3.particle_layout.min_binding_size().get() as u32,
1588
            item_size
1589
        );
1590
        assert_eq!(slice3.range, 0..capacity);
1591
        // Note: currently batching is disabled, so each instance has its own buffer.
1592
        assert_eq!(effect_cache.slabs().len(), 2);
1593
        {
1594
            let slabs = effect_cache.slabs();
1595
            assert!(slabs[0].is_some()); // id3
1596
            assert!(slabs[1].is_some()); // id2
1597
        }
1598
    }
1599
}
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