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

djeedai / bevy_hanabi / 30176553818

25 Jul 2026 09:55PM UTC coverage: 58.445% (-0.7%) from 59.154%
30176553818

Pull #553

github

web-flow
Merge ac8a87faf into 1caed80dc
Pull Request #553: Pack `EffectMetadata` and bind as array

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%)

679.04 hits per line

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

32.34
/src/render/sort.rs
1
use std::num::{NonZeroU32, NonZeroU64};
2

3
use bevy::{
4
    asset::Handle,
5
    ecs::{resource::Resource, world::World},
6
    platform::collections::{hash_map::Entry, HashMap},
7
    render::{
8
        render_resource::{
9
            binding_types::{
10
                storage_buffer, storage_buffer_read_only, storage_buffer_read_only_sized,
11
                storage_buffer_sized,
12
            },
13
            BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, Buffer,
14
            BufferId, CachedComputePipelineId, CachedPipelineState, ComputePipelineDescriptor,
15
            PipelineCache, ShaderSize, ShaderType,
16
        },
17
        renderer::RenderDevice,
18
    },
19
    shader::Shader,
20
    utils::default,
21
};
22
use bytemuck::{Pod, Zeroable};
23
use wgpu::{
24
    BufferAddress, BufferBinding, BufferDescriptor, BufferUsages, CommandEncoder, ShaderStages,
25
};
26

27
use super::{gpu_buffer::GpuBuffer, GpuDispatchIndirectArgs, GpuEffectMetadata, StorageType};
28
use crate::{
29
    render::{GpuIndirectIndex, GpuSpawnerParams},
30
    Attribute, ParticleLayout,
31
};
32

33
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34
struct SortFillBindGroupLayoutKey {
35
    particle_min_binding_size: NonZeroU32,
36
    particle_ribbon_id_offset: u32,
37
    particle_age_offset: u32,
38
}
39

40
impl SortFillBindGroupLayoutKey {
41
    pub fn from_particle_layout(particle_layout: &ParticleLayout) -> Result<Self, ()> {
×
42
        let particle_ribbon_id_offset = particle_layout
×
43
            .byte_offset(Attribute::RIBBON_ID)
×
44
            .ok_or(())?;
×
45
        let particle_age_offset = particle_layout.byte_offset(Attribute::AGE).ok_or(())?;
×
46
        let key = SortFillBindGroupLayoutKey {
47
            particle_min_binding_size: particle_layout.min_binding_size32(),
×
48
            particle_ribbon_id_offset,
49
            particle_age_offset,
50
        };
51
        Ok(key)
×
52
    }
53
}
54

55
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56
struct SortFillBindGroupKey {
57
    particle: BufferId,
58
    indirect_index: BufferId,
59
    effect_metadata: BufferId,
60
    // Bound at binding 4 with a per-effect dynamic offset; reallocates as
61
    // effects grow, so it must be keyed or a stale buffer overruns the offset.
62
    spawner: BufferId,
63
}
64

65
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66
struct SortCopyBindGroupKey {
67
    indirect_index: BufferId,
68
    sort: BufferId,
69
    effect_metadata: BufferId,
70
    // Bound at binding 3 with a per-effect dynamic offset; reallocates as
71
    // effects grow, so it must be keyed or a stale buffer overruns the offset.
72
    spawner: BufferId,
73
}
74

75
/// GPU representation of a single dual-key value pair, with the added buffer
76
/// count as prefix. This is mainly used for shorcuts in bindings, not directly
77
/// as a type.
78
#[repr(C)]
79
#[derive(Debug, Copy, Clone, Pod, Zeroable, ShaderType)]
80
struct GpuSortBufferSingleEntry {
81
    /// Number of key-value pairs to sort. This is the first element of the
82
    /// entire buffer.
83
    pub count: u32,
84
    /// Key for the first entry.
85
    pub key: u32,
86
    /// Secondary key for the first entry.
87
    pub key2: u32,
88
    /// Value for the first entry.
89
    pub value: u32,
90
}
91

92
#[derive(Resource)]
93
pub struct SortBindGroups {
94
    /// Sort-fill pass compute shader.
95
    sort_fill_shader: Handle<Shader>,
96
    /// GPU buffer of key-value pairs to sort.
97
    sort_buffer: Buffer,
98
    /// GPU buffer containing the [`GpuDispatchIndirect`] structs for the
99
    /// sort-fill and sort passes.
100
    indirect_args_buffer: GpuBuffer<GpuDispatchIndirectArgs>,
101
    /// Bind group layouts for group #0 of the sort-fill compute pass.
102
    sort_fill_bind_group_layout_descs:
103
        HashMap<SortFillBindGroupLayoutKey, (BindGroupLayoutDescriptor, CachedComputePipelineId)>,
104
    /// Bind groups for group #0 of the sort-fill compute pass.
105
    sort_fill_bind_groups: HashMap<SortFillBindGroupKey, BindGroup>,
106
    /// Bind group layout descriptor for group #0 of the sort compute pass.
107
    sort_bind_group_layout_desc: BindGroupLayoutDescriptor,
108
    /// Bind group for group #0 of the sort compute pass.
109
    sort_bind_group: Option<BindGroup>,
110
    sort_copy_bind_group_layout_desc: BindGroupLayoutDescriptor,
111
    /// Pipeline for sort pass.
112
    sort_pipeline_id: CachedComputePipelineId,
113
    /// Pipeline for sort-copy pass.
114
    sort_copy_pipeline_id: CachedComputePipelineId,
115
    /// Bind groups for group #0 of the sort-copy compute pass.
116
    sort_copy_bind_groups: HashMap<SortCopyBindGroupKey, BindGroup>,
117
}
118

119
impl SortBindGroups {
120
    pub fn new(
4✔
121
        world: &mut World,
122
        sort_fill_shader: Handle<Shader>,
123
        sort_shader: Handle<Shader>,
124
        sort_copy_shader: Handle<Shader>,
125
    ) -> Self {
126
        let render_device = world.resource::<RenderDevice>();
12✔
127
        let pipeline_cache = world.resource::<PipelineCache>();
12✔
128

129
        let sort_buffer = render_device.create_buffer(&BufferDescriptor {
16✔
130
            label: Some("hanabi:buffer:sort:pairs"),
8✔
131
            size: 3 * 1024 * 1024,
4✔
132
            usage: BufferUsages::COPY_DST | BufferUsages::STORAGE,
4✔
133
            mapped_at_creation: false,
4✔
134
        });
135

136
        // Initial room for 256 ribbon sort dispatches; the GpuBuffer grows on demand.
137
        let indirect_item_size = GpuDispatchIndirectArgs::SHADER_SIZE.get();
8✔
138
        let indirect_buffer = render_device.create_buffer(&BufferDescriptor {
16✔
139
            label: Some("hanabi:buffer:sort:indirect"),
8✔
140
            size: 256 * indirect_item_size,
4✔
141
            usage: BufferUsages::COPY_SRC
4✔
142
                | BufferUsages::COPY_DST
4✔
143
                | BufferUsages::STORAGE
4✔
144
                | BufferUsages::INDIRECT,
4✔
145
            mapped_at_creation: false,
4✔
146
        });
147
        let indirect_buffer = GpuBuffer::new_allocated(
148
            indirect_buffer,
4✔
149
            Some("hanabi:buffer:sort:indirect".to_string()),
4✔
150
        );
151

152
        let sort_bind_group_layout_desc = BindGroupLayoutDescriptor::new(
153
            "hanabi:bgl:sort",
154
            &BindGroupLayoutEntries::single(
8✔
155
                ShaderStages::COMPUTE,
4✔
156
                storage_buffer_sized(false, Some(NonZeroU64::new(16).unwrap())), /* count + dual
8✔
157
                                                                                  * kv pair */
4✔
158
            ),
159
        );
160

161
        let sort_pipeline_id = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
16✔
162
            label: Some("hanabi:pipeline:sort".into()),
8✔
163
            layout: vec![sort_bind_group_layout_desc.clone()],
16✔
164
            shader: sort_shader,
8✔
165
            shader_defs: vec!["HAS_DUAL_KEY".into()],
16✔
166
            entry_point: Some("main".into()),
4✔
167
            immediate_size: 0,
4✔
168
            zero_initialize_workgroup_memory: false,
4✔
169
        });
170

171
        let alignment = render_device.limits().min_storage_buffer_offset_alignment;
8✔
172
        let sort_copy_bind_group_layout_desc = BindGroupLayoutDescriptor::new(
173
            "hanabi:bgl:sort_copy",
174
            &BindGroupLayoutEntries::sequential(
8✔
175
                ShaderStages::COMPUTE,
4✔
176
                (
177
                    // @group(0) @binding(0) var<storage, read_write> indirect_index_buffer :
178
                    // IndirectIndexBuffer;
179
                    storage_buffer::<GpuIndirectIndex>(false),
8✔
180
                    // @group(0) @binding(1) var<storage, read> sort_buffer : SortBuffer;
181
                    storage_buffer_read_only::<GpuSortBufferSingleEntry>(false),
8✔
182
                    // @group(0) @binding(2) var<storage, read_write> effect_metadatas :
183
                    // array<EffectMetadata>;
184
                    storage_buffer::<GpuEffectMetadata>(false),
8✔
185
                    // @group(0) @binding(3) var<storage, read> spawner : Spawner;
186
                    storage_buffer_read_only_sized(
8✔
187
                        true,
4✔
188
                        Some(GpuSpawnerParams::aligned_size(alignment)),
4✔
189
                    ),
190
                ),
191
            ),
192
        );
193

194
        let sort_copy_pipeline_id =
4✔
195
            pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
12✔
196
                label: Some("hanabi:pipeline:sort_copy".into()),
8✔
197
                layout: vec![sort_copy_bind_group_layout_desc.clone()],
16✔
198
                shader: sort_copy_shader,
8✔
199
                shader_defs: vec![],
8✔
200
                entry_point: Some("main".into()),
4✔
201
                immediate_size: 0,
4✔
202
                zero_initialize_workgroup_memory: false,
4✔
203
            });
204

205
        Self {
206
            sort_fill_shader,
207
            sort_buffer,
208
            indirect_args_buffer: indirect_buffer,
209
            sort_fill_bind_group_layout_descs: default(),
8✔
210
            sort_fill_bind_groups: default(),
8✔
211
            sort_bind_group_layout_desc,
212
            // This bind group is created later, once the pipeline and its bind group layouts are
213
            // created by the pipeline cache. Technically we could create the bind group layout
214
            // immediately because the PipelineCache pretends to but actually creates them
215
            // on-the-fly in get_bind_group_layout(), but this is brittle as any behavior change
216
            // would break Hanabi. Instead we create this bind group alongside all others, which is
217
            // more consistent too.
218
            sort_bind_group: None,
219
            sort_copy_bind_group_layout_desc,
220
            sort_pipeline_id,
221
            sort_copy_pipeline_id,
222
            sort_copy_bind_groups: default(),
4✔
223
        }
224
    }
225

226
    #[inline]
227
    pub fn clear_indirect_args_buffer(&mut self) {
570✔
228
        self.indirect_args_buffer.clear();
1,140✔
229
    }
230

231
    /// Allocate a [`GpuDispatchIndirectArgs`] slot in the global shared buffer.
232
    #[inline]
NEW
233
    pub fn allocate_indirect_args(&mut self) -> u32 {
×
NEW
234
        self.indirect_args_buffer.allocate()
×
235
    }
236

237
    #[inline]
NEW
238
    pub fn get_indirect_args_byte_offset(&self, index: u32) -> BufferAddress {
×
NEW
239
        self.indirect_args_buffer.item_size() as BufferAddress * index as BufferAddress
×
240
    }
241

242
    #[inline]
243
    #[allow(dead_code)]
244
    pub fn sort_buffer(&self) -> &Buffer {
×
245
        &self.sort_buffer
×
246
    }
247

248
    #[inline]
NEW
249
    pub fn indirect_args_buffer(&self) -> Option<&Buffer> {
×
NEW
250
        self.indirect_args_buffer.buffer()
×
251
    }
252

253
    #[inline]
254
    pub fn sort_pipeline_id(&self) -> CachedComputePipelineId {
×
255
        self.sort_pipeline_id
×
256
    }
257

258
    /// Check if the sort pipeline is ready to run for the given effect
259
    /// instance.
260
    ///
261
    /// This ensures all compute pipelines are compiled and ready to be used
262
    /// this frame.
263
    pub fn is_pipeline_ready(
×
264
        &self,
265
        particle_layout: &ParticleLayout,
266
        pipeline_cache: &PipelineCache,
267
    ) -> bool {
268
        // Validate the sort-fill pipeline. It was created and queued for compile by
269
        // ensure_sort_fill_bind_group_layout(), which normally is called just before
270
        // is_pipeline_ready().
271
        let Some(pipeline_id) = self.get_sort_fill_pipeline_id(particle_layout) else {
×
272
            return false;
×
273
        };
274
        if !matches!(
×
275
            pipeline_cache.get_compute_pipeline_state(pipeline_id),
×
276
            CachedPipelineState::Ok(_)
277
        ) {
278
            return false;
×
279
        }
280

281
        // The 2 pipelines below are created and queued for compile in new(), so are
282
        // almost always ready.
283
        // FIXME - they could be checked once a frame only, not once per effect...
284

285
        // Validate the sort pipeline
286
        if !matches!(
×
287
            pipeline_cache.get_compute_pipeline_state(self.sort_pipeline_id()),
×
288
            CachedPipelineState::Ok(_)
289
        ) {
290
            return false;
×
291
        }
292

293
        // Validate the sort-copy pipeline
294
        if !matches!(
×
295
            pipeline_cache.get_compute_pipeline_state(self.get_sort_copy_pipeline_id()),
×
296
            CachedPipelineState::Ok(_)
297
        ) {
298
            return false;
×
299
        }
300

301
        true
×
302
    }
303

304
    #[inline]
305
    pub fn prepare_buffers(&mut self, render_device: &RenderDevice) {
570✔
306
        self.indirect_args_buffer.prepare_buffers(render_device);
1,710✔
307
    }
308

309
    #[inline]
310
    pub fn write_buffers(&self, command_encoder: &mut CommandEncoder) {
570✔
311
        self.indirect_args_buffer.write_buffers(command_encoder);
1,710✔
312
    }
313

314
    #[inline]
315
    pub fn clear_previous_frame_resizes(&mut self) {
570✔
316
        self.indirect_args_buffer.clear_previous_frame_resizes();
1,140✔
317
    }
318

319
    pub fn ensure_sort_fill_bind_group_layout_desc(
×
320
        &mut self,
321
        render_device: &RenderDevice,
322
        pipeline_cache: &PipelineCache,
323
        particle_layout: &ParticleLayout,
324
    ) -> Result<&BindGroupLayoutDescriptor, ()> {
325
        let key = SortFillBindGroupLayoutKey::from_particle_layout(particle_layout)?;
×
326
        let (layout, _) = self
×
327
            .sort_fill_bind_group_layout_descs
×
328
            .entry(key)
×
329
            .or_insert_with(|| {
×
330
                let alignment = render_device.limits().min_storage_buffer_offset_alignment;
×
331
                let bind_group_layout_desc = BindGroupLayoutDescriptor::new(
×
332
                    "hanabi:bgl:sort_fill",
333
                    &BindGroupLayoutEntries::sequential(
×
334
                        ShaderStages::COMPUTE,
×
335
                        (
336
                            // @group(0) @binding(0) var<storage, read_write> sort_buffer :
337
                            // SortBuffer;
338
                            storage_buffer::<GpuSortBufferSingleEntry>(false),
×
339
                            // @group(0) @binding(1) var<storage, read> particle_buffer :
340
                            // RawParticleBuffer;
341
                            storage_buffer_read_only_sized(
×
342
                                false,
×
343
                                Some(key.particle_min_binding_size.into()),
×
344
                            ),
345
                            // @group(0) @binding(2) var<storage, read> indirect_index_buffer :
346
                            // array<u32>;
347
                            storage_buffer_read_only::<GpuIndirectIndex>(false),
×
348
                            // @group(0) @binding(3) var<storage, read_write> effect_metadatas :
349
                            // array<EffectMetadata>;
NEW
350
                            storage_buffer::<GpuEffectMetadata>(false),
×
351
                            // @group(0) @binding(4) var<storage, read> spawner : Spawner;
352
                            storage_buffer_read_only_sized(
×
353
                                true,
×
354
                                Some(GpuSpawnerParams::aligned_size(alignment)),
×
355
                            ),
356
                        ),
357
                    ),
358
                );
359
                let pipeline_id =
×
360
                    pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {
×
361
                        label: Some("hanabi:pipeline:sort_fill".into()),
×
362
                        layout: vec![bind_group_layout_desc.clone()],
×
363
                        shader: self.sort_fill_shader.clone(),
×
364
                        shader_defs: vec!["HAS_DUAL_KEY".into()],
×
365
                        entry_point: Some("main".into()),
×
366
                        immediate_size: 0,
×
367
                        zero_initialize_workgroup_memory: false,
×
368
                    });
369
                (bind_group_layout_desc, pipeline_id)
×
370
            });
371
        Ok(layout)
×
372
    }
373

374
    // We currently only use the bind group layout internally in
375
    // ensure_sort_fill_bind_group()
376
    #[allow(dead_code)]
377
    pub fn get_sort_fill_bind_group_layout_desc(
×
378
        &self,
379
        particle_layout: &ParticleLayout,
380
    ) -> Option<&BindGroupLayoutDescriptor> {
381
        let key = SortFillBindGroupLayoutKey::from_particle_layout(particle_layout).ok()?;
×
382
        self.sort_fill_bind_group_layout_descs
×
383
            .get(&key)
×
384
            .map(|(layout, _)| layout)
×
385
    }
386

387
    pub fn get_sort_fill_pipeline_id(
×
388
        &self,
389
        particle_layout: &ParticleLayout,
390
    ) -> Option<CachedComputePipelineId> {
391
        let key = SortFillBindGroupLayoutKey::from_particle_layout(particle_layout).ok()?;
×
392
        self.sort_fill_bind_group_layout_descs
×
393
            .get(&key)
×
394
            .map(|(_, pipeline_id)| *pipeline_id)
×
395
    }
396

397
    pub fn get_sort_copy_pipeline_id(&self) -> CachedComputePipelineId {
×
398
        self.sort_copy_pipeline_id
×
399
    }
400

401
    pub fn ensure_sort_fill_bind_group(
×
402
        &mut self,
403
        render_device: &RenderDevice,
404
        particle_layout: &ParticleLayout,
405
        particle: &Buffer,
406
        indirect_index: &Buffer,
407
        effect_metadata: &Buffer,
408
        spawner_buffer: &Buffer,
409
        pipeline_cache: &PipelineCache,
410
    ) -> Result<&BindGroup, ()> {
411
        let key = SortFillBindGroupKey {
412
            particle: particle.id(),
×
413
            indirect_index: indirect_index.id(),
×
414
            effect_metadata: effect_metadata.id(),
×
415
            spawner: spawner_buffer.id(),
×
416
        };
417
        let entry = self.sort_fill_bind_groups.entry(key);
×
418
        let bind_group = match entry {
×
419
            Entry::Occupied(entry) => entry.into_mut(),
×
420
            Entry::Vacant(entry) => {
×
421
                // Note: can't use get_bind_group_layout() because the function call mixes the
422
                // lifetimes of the two hash maps and complains the bind group one is already
423
                // borrowed. Doing a manual access to the layout one instead makes the compiler
424
                // happy.
425
                let key = SortFillBindGroupLayoutKey::from_particle_layout(particle_layout)?;
×
426
                let layout_desc = &self
×
427
                    .sort_fill_bind_group_layout_descs
×
428
                    .get(&key)
×
429
                    .ok_or(())?
×
430
                    .0;
431
                entry.insert(render_device.create_bind_group(
×
432
                    "hanabi:bg:sort_fill",
×
433
                    &pipeline_cache.get_bind_group_layout(layout_desc),
×
434
                    &BindGroupEntries::sequential((
×
435
                        // @group(0) @binding(0) var<storage, read_write> pairs:
436
                        // array<KeyValuePair>;
437
                        self.sort_buffer.as_entire_binding(),
×
438
                        // @group(0) @binding(1) var<storage, read> particle_buffer:
439
                        // ParticleBuffer;
440
                        particle.as_entire_binding(),
×
441
                        // @group(0) @binding(2) var<storage, read> indirect_index_buffer :
442
                        // array<u32>;
443
                        indirect_index.as_entire_binding(),
×
444
                        // @group(0) @binding(3) var<storage, read_write> effect_metadatas :
445
                        // array<EffectMetadata>;
NEW
446
                        effect_metadata.as_entire_binding(),
×
447
                        // @group(0) @binding(4) var<storage, read> spawner : Spawner;
448
                        BufferBinding {
×
449
                            buffer: spawner_buffer,
×
450
                            offset: 0,
×
NEW
451
                            size: Some(GpuSpawnerParams::aligned_size(
×
NEW
452
                                render_device.limits().min_storage_buffer_offset_alignment,
×
453
                            )),
454
                        },
455
                    )),
456
                ))
457
            }
458
        };
459
        Ok(bind_group)
×
460
    }
461

462
    pub fn sort_fill_bind_group(
×
463
        &self,
464
        particle: BufferId,
465
        indirect_index: BufferId,
466
        effect_metadata: BufferId,
467
        spawner: BufferId,
468
    ) -> Option<&BindGroup> {
469
        let key = SortFillBindGroupKey {
470
            particle,
471
            indirect_index,
472
            effect_metadata,
473
            spawner,
474
        };
475
        self.sort_fill_bind_groups.get(&key)
×
476
    }
477

478
    /// Ensure the bind group for the sort pass is created.
479
    pub fn ensure_sort_bind_group(
×
480
        &mut self,
481
        render_device: &RenderDevice,
482
        pipeline_cache: &PipelineCache,
483
    ) -> &BindGroup {
484
        if self.sort_bind_group.is_none() {
×
485
            let sort_bind_group = render_device.create_bind_group(
×
486
                "hanabi:bg:sort",
487
                &pipeline_cache.get_bind_group_layout(&self.sort_bind_group_layout_desc),
×
488
                // @group(0) @binding(0) var<storage, read_write> pairs : array<KeyValuePair>;
489
                &BindGroupEntries::single(self.sort_buffer.as_entire_binding()),
×
490
            );
491
            self.sort_bind_group = Some(sort_bind_group);
×
492
        }
493
        self.sort_bind_group.as_ref().unwrap()
×
494
    }
495

496
    #[inline]
497
    pub fn sort_bind_group(&self) -> Option<&BindGroup> {
×
498
        self.sort_bind_group.as_ref()
×
499
    }
500

501
    pub fn ensure_sort_copy_bind_group(
×
502
        &mut self,
503
        render_device: &RenderDevice,
504
        indirect_index_buffer: &Buffer,
505
        effect_metadata_buffer: &Buffer,
506
        spawner_buffer: &Buffer,
507
        pipeline_cache: &PipelineCache,
508
    ) -> Result<&BindGroup, ()> {
509
        let key = SortCopyBindGroupKey {
510
            indirect_index: indirect_index_buffer.id(),
×
511
            sort: self.sort_buffer.id(),
×
512
            effect_metadata: effect_metadata_buffer.id(),
×
513
            spawner: spawner_buffer.id(),
×
514
        };
515
        let entry = self.sort_copy_bind_groups.entry(key);
×
516
        let bind_group = match entry {
×
517
            Entry::Occupied(entry) => entry.into_mut(),
×
518
            Entry::Vacant(entry) => {
×
519
                entry.insert(render_device.create_bind_group(
×
520
                    "hanabi:bg:sort_copy",
×
521
                    &pipeline_cache.get_bind_group_layout(&self.sort_copy_bind_group_layout_desc),
×
522
                    &BindGroupEntries::sequential((
×
523
                        // @group(0) @binding(0) var<storage, read_write> indirect_index_buffer
524
                        // : IndirectIndexBuffer;
525
                        indirect_index_buffer.as_entire_binding(),
×
526
                        // @group(0) @binding(1) var<storage, read> sort_buffer : SortBuffer;
527
                        self.sort_buffer.as_entire_binding(),
×
528
                        // @group(0) @binding(2) var<storage, read_write> effect_metadatas :
529
                        // array<EffectMetadata>;
NEW
530
                        effect_metadata_buffer.as_entire_binding(),
×
531
                        // @group(0) @binding(3) var<storage, read> spawner : Spawner;
532
                        BufferBinding {
×
533
                            buffer: spawner_buffer,
×
534
                            offset: 0,
×
NEW
535
                            size: Some(GpuSpawnerParams::aligned_size(
×
NEW
536
                                render_device.limits().min_storage_buffer_offset_alignment,
×
537
                            )),
538
                        },
539
                    )),
540
                ))
541
            }
542
        };
543
        Ok(bind_group)
×
544
    }
545

546
    pub fn sort_copy_bind_group(
×
547
        &self,
548
        indirect_index: BufferId,
549
        effect_metadata: BufferId,
550
        spawner: BufferId,
551
    ) -> Option<&BindGroup> {
552
        let key = SortCopyBindGroupKey {
553
            indirect_index,
554
            sort: self.sort_buffer.id(),
×
555
            effect_metadata,
556
            spawner,
557
        };
558
        self.sort_copy_bind_groups.get(&key)
×
559
    }
560
}
561

562
#[cfg(all(test, feature = "gpu_tests"))]
563
mod gpu_tests {
564
    use bevy::{
565
        math::FloatOrd,
566
        render::render_resource::{
567
            binding_types::storage_buffer_sized, BindGroupEntries, BindGroupLayoutEntries,
568
            ShaderSize, ShaderType,
569
        },
570
    };
571
    #[allow(unused_imports)]
572
    use bytemuck::{cast_slice, Pod, Zeroable};
573
    use wgpu::{
574
        BufferDescriptor, BufferUsages, ComputePassDescriptor, ComputePipelineDescriptor,
575
        PipelineCompilationOptions, PipelineLayoutDescriptor, ShaderModuleDescriptor, ShaderSource,
576
        ShaderStages,
577
    };
578

579
    use crate::{plugin::VFX_SORT_WGSL, test_utils::*};
580

581
    #[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, ShaderType)]
582
    #[repr(C)]
583
    struct DualKeyValuePair {
584
        pub key: u32,
585
        pub key2: f32,
586
        pub value: u32,
587
    }
588

589
    // Ignore weirdnesses with f32 NaN etc. here, we should never have a key with
590
    // such values.
591
    impl std::cmp::Eq for DualKeyValuePair {}
592

593
    impl std::cmp::PartialOrd for DualKeyValuePair {
594
        fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1,917✔
595
            Some(self.cmp(other))
3,834✔
596
        }
597
    }
598

599
    impl std::cmp::Ord for DualKeyValuePair {
600
        fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1,917✔
601
            if self.key != other.key {
1,917✔
602
                return self.key.cmp(&other.key);
2,472✔
603
            }
604
            FloatOrd(self.key2).cmp(&FloatOrd(other.key2))
605
        }
606
    }
607

608
    #[test]
609
    fn test_serial_insertion_sort() {
610
        let renderer = MockRenderer::new();
611
        let device = renderer.device();
612
        let queue = renderer.queue();
613
        let num_kv = 257u32;
614
        let byte_size = 4 + num_kv as u64 * DualKeyValuePair::SHADER_SIZE.get();
615
        let sort_buffer = device.create_buffer(&BufferDescriptor {
616
            label: Some("sort_buffer"),
617
            size: byte_size,
618
            usage: BufferUsages::STORAGE | BufferUsages::MAP_READ,
619
            mapped_at_creation: true,
620
        });
621

622
        let mut expected = Vec::with_capacity(num_kv as usize);
623
        for i in 0..num_kv {
624
            expected.push(DualKeyValuePair {
625
                key: i % 4,
626
                key2: ((i / 4) % 3) as f32,
627
                value: i,
628
            });
629
        }
630
        {
631
            let mut mapped = sort_buffer.slice(..).get_mapped_range_mut();
632
            mapped.slice(..4).copy_from_slice(cast_slice(&[num_kv]));
633
            mapped
634
                .slice(4..)
635
                .copy_from_slice(cast_slice(expected.as_slice()));
636
        }
637
        sort_buffer.unmap();
638
        expected.sort();
639

640
        let bind_group_layout = device.create_bind_group_layout(
641
            "bind_group_layout",
642
            &BindGroupLayoutEntries::single(
643
                ShaderStages::COMPUTE,
644
                storage_buffer_sized(false, None),
645
            ),
646
        );
647
        let bind_group = device.create_bind_group(
648
            None,
649
            &bind_group_layout,
650
            &BindGroupEntries::single(sort_buffer.as_entire_binding()),
651
        );
652
        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
653
            label: Some("pipeline_layout"),
654
            bind_group_layouts: &[Some(&bind_group_layout)],
655
            immediate_size: 0,
656
        });
657
        let src = VFX_SORT_WGSL
658
            .replace("#ifdef HAS_DUAL_KEY", "")
659
            .replace("#ifdef TEST", "")
660
            .replace("#endif", "");
661
        let shader_module = device.create_and_validate_shader_module(ShaderModuleDescriptor {
662
            label: Some("vfx_sort"),
663
            source: ShaderSource::Wgsl(src.into()),
664
        });
665
        let pipeline = device.create_compute_pipeline(&ComputePipelineDescriptor {
666
            label: Some("test_serial_insertion_sort"),
667
            layout: Some(&pipeline_layout),
668
            module: &shader_module,
669
            entry_point: Some("main"),
670
            compilation_options: PipelineCompilationOptions {
671
                constants: &[],
672
                zero_initialize_workgroup_memory: false,
673
            },
674
            cache: None,
675
        });
676

677
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
678
            label: Some("test"),
679
        });
680
        {
681
            let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor {
682
                label: Some("test_serial_insertion_sort"),
683
                timestamp_writes: None,
684
            });
685
            compute_pass.set_pipeline(&pipeline);
686
            compute_pass.set_bind_group(0, &bind_group, &[]);
687
            compute_pass.dispatch_workgroups(1, 1, 1);
688
        }
689
        queue.submit([encoder.finish()]);
690
        let (tx, rx) = futures::channel::oneshot::channel();
691
        queue.on_submitted_work_done(move || {
692
            tx.send(()).unwrap();
693
        });
694
        let _ = device.poll(wgpu::PollType::Wait {
695
            submission_index: None,
696
            timeout: None,
697
        });
698
        let _ = futures::executor::block_on(rx);
699

700
        let buffer_slice = sort_buffer.slice(..);
701
        let (tx, rx) = futures::channel::oneshot::channel();
702
        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
703
            tx.send(result).unwrap();
704
        });
705
        let _ = device.poll(wgpu::PollType::Wait {
706
            submission_index: None,
707
            timeout: None,
708
        });
709
        let _ = futures::executor::block_on(rx);
710
        let view = buffer_slice.get_mapped_range();
711
        assert_eq!(view.len(), byte_size as usize);
712
        assert_eq!(cast_slice::<_, u32>(&view[..4]), &[0]);
713
        assert_eq!(
714
            cast_slice::<_, DualKeyValuePair>(&view[4..]),
715
            expected.as_slice()
716
        );
717
    }
718

719
    /// Calculate the effect index from a particle index using a binary search
720
    /// of the base particle prefix sum.
721
    #[test]
722
    fn test_binary_search_prefix_sum() {
723
        let renderer = MockRenderer::new();
724
        let device = renderer.device();
725
        let queue = renderer.queue();
726

727
        println!(
728
            "max_compute_workgroup_storage_size = {}",
729
            device.limits().max_compute_workgroup_storage_size
730
        );
731

732
        // SAFETY : for debugging only
733
        #[allow(unsafe_code)]
734
        unsafe {
735
            device.wgpu_device().start_graphics_debugger_capture()
736
        };
737

738
        // Clamp max block size to the device's reported storage
739
        let max_block_size = device.limits().max_compute_workgroup_storage_size
740
            / (2 * DualKeyValuePair::SHADER_SIZE.get() as u32);
741
        println!("max_block_size = {}", max_block_size);
742
        let num_particle = 1024.min(max_block_size);
743

744
        let byte_size = 4 + num_particle as u64 * DualKeyValuePair::SHADER_SIZE.get();
745
        let sort_buffer = device.create_buffer(&BufferDescriptor {
746
            label: Some("sort_buffer"),
747
            size: byte_size,
748
            usage: BufferUsages::STORAGE | BufferUsages::MAP_READ,
749
            mapped_at_creation: true,
750
        });
751
        let effects = [0, 35, 399, 1000];
752
        assert!((effects.len() as u32) < num_particle);
753
        let values: Vec<_> = (0..num_particle as usize)
754
            .map(|i| DualKeyValuePair {
755
                key: effects.get(i).copied().unwrap_or(0xDEAD0000),
756
                key2: i as f32 * 0.1,
757
                value: i as u32,
758
            })
759
            .collect();
760
        {
761
            // Scope get_mapped_range_mut() to force a drop before unmap()
762
            {
763
                let mut mapped = sort_buffer.slice(..).get_mapped_range_mut();
764
                mapped
765
                    .slice(..4)
766
                    .copy_from_slice(cast_slice(&[effects.len() as u32]));
767
                mapped
768
                    .slice(4..)
769
                    .copy_from_slice(cast_slice(values.as_slice()));
770
            }
771
            sort_buffer.unmap();
772
        }
773

774
        // Create GPU resources
775
        let bind_group_layout = device.create_bind_group_layout(
776
            "bind_group_layout",
777
            &BindGroupLayoutEntries::single(
778
                ShaderStages::COMPUTE,
779
                storage_buffer_sized(false, None),
780
            ),
781
        );
782
        let bind_group = device.create_bind_group(
783
            None,
784
            &bind_group_layout,
785
            &BindGroupEntries::single(sort_buffer.as_entire_binding()),
786
        );
787
        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
788
            label: Some("pipeline_layout"),
789
            bind_group_layouts: &[Some(&bind_group_layout)],
790
            immediate_size: 0,
791
        });
792
        let src = VFX_SORT_WGSL
793
            .replace("#ifdef HAS_DUAL_KEY", "")
794
            .replace("#ifdef TEST", "")
795
            .replace("#endif", "");
796
        let shader_module = device.create_and_validate_shader_module(ShaderModuleDescriptor {
797
            label: Some("vfx_sort"),
798
            source: ShaderSource::Wgsl(src.into()),
799
        });
800
        let pipeline = device.create_compute_pipeline(&ComputePipelineDescriptor {
801
            label: Some("test_binary_search_prefix_sum"),
802
            layout: Some(&pipeline_layout),
803
            module: &shader_module,
804
            entry_point: Some("test_find_effect_from_particle"),
805
            compilation_options: PipelineCompilationOptions {
806
                constants: &[],
807
                // Ensure the shader behaves even if memory is not zero-initialized
808
                zero_initialize_workgroup_memory: false,
809
            },
810
            cache: None,
811
        });
812

813
        // Dispatch test
814
        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
815
            label: Some("test"),
816
        });
817
        {
818
            let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor {
819
                label: Some("test_binary_search_prefix_sum"),
820
                timestamp_writes: None,
821
            });
822
            compute_pass.set_pipeline(&pipeline);
823
            compute_pass.set_bind_group(0, &bind_group, &[]);
824
            compute_pass.dispatch_workgroups(1, 1, 1);
825
        }
826

827
        // Submit command queue and wait for execution
828
        println!("Executing pipeline...");
829
        let command_buffer = encoder.finish();
830
        queue.submit([command_buffer]);
831
        let (tx, rx) = futures::channel::oneshot::channel();
832
        queue.on_submitted_work_done(move || {
833
            tx.send(()).unwrap();
834
        });
835
        let _ = device.poll(wgpu::PollType::Wait {
836
            submission_index: None,
837
            timeout: None,
838
        });
839
        let _ = futures::executor::block_on(rx);
840
        println!("Pipeline executed");
841

842
        // SAFETY : for debugging only
843
        #[allow(unsafe_code)]
844
        unsafe {
845
            device.wgpu_device().stop_graphics_debugger_capture()
846
        };
847

848
        // Read back (GPU -> CPU)
849
        println!("Downloading result buffer from GPU to CPU...");
850
        let buffer_slice = sort_buffer.slice(..);
851
        let (tx, rx) = futures::channel::oneshot::channel();
852
        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
853
            tx.send(result).unwrap();
854
        });
855
        let _ = device.poll(wgpu::PollType::Wait {
856
            submission_index: None,
857
            timeout: None,
858
        });
859
        let _result = futures::executor::block_on(rx);
860
        let view = buffer_slice.get_mapped_range();
861
        println!("Result buffer downloaded to CPU");
862

863
        // Validate content
864
        assert_eq!(view.len(), byte_size as usize);
865
        let view_slice: &[DualKeyValuePair] = cast_slice(&view[4..]);
866
        for (i, kv) in view_slice.iter().enumerate() {
867
            //println!("[#{}] k={} k2={} v={}", i, kv.key, kv.key2, kv.value);
868

869
            let mut effect_index = usize::MAX;
870
            for (idx, base_particle) in effects.iter().enumerate().rev() {
871
                if i as u32 >= *base_particle {
872
                    effect_index = idx;
873
                    break;
874
                }
875
            }
876
            assert!(effect_index <= effects.len());
877
            assert_eq!(
878
                effect_index as u32, kv.value,
879
                "Test failed for particle {} : expected effect index {}, got {}",
880
                i, effect_index, kv.value
881
            );
882
        }
883
    }
884
}
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