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

djeedai / bevy_hanabi / 27885589619

20 Jun 2026 10:18PM UTC coverage: 58.545% (-0.02%) from 58.561%
27885589619

push

github

web-flow
Grow the sort indirect-dispatch buffer with correct size (#538)

## Problem

With many ribbon effects alive (~260 trailed projectiles), the render
thread crashes:

```
dispatch indirect: buffer uses bytes 3072..3084 which overruns indirect buffer of size 3072
```

(The crash reported in #493.)

## Cause

The sort indirect-dispatch buffer is a growable `GpuBuffer` whose
capacity is tracked in **elements**: `allocate()` may hand out an index
past the current capacity, and `prepare_buffers()` then grows the GPU
buffer to fit.

`GpuBuffer::new_allocated` took the element capacity as a separate
argument, and `SortBindGroups::new` passed the buffer's **byte** size
(3072) there instead of its element count. With 12-byte
`GpuDispatchIndirectArgs` the physical buffer holds 256 elements, while
`GpuBuffer` believed it had 3072 elements of headroom — so
`prepare_buffers()` never grew it. The 257th sort dispatch then read
past the end of the physical buffer (byte 3072 in a 3072-byte buffer),
exactly the reported numbers.

## Fix

Remove the separate capacity argument from `new_allocated` and derive
the element capacity from the physical buffer (`buffer.size() /
item_size`), so the recorded and physical capacities can't disagree.
`new_allocated` has a single caller, which now just sizes the buffer; a
`debug_assert` guards a non-multiple size.

## Testing

Verified in a ribbon-heavy app (every projectile carries a ribbon
trail): pre-fix it crashes reliably once ~256 ribbon sort dispatches are
concurrently active; post-fix it runs well past that as the buffer
grows. `cargo build` passes.

Independent of #537 (stale-buffer overruns in the sort bind-group
*cache*); this is the buffer *growth* bug, #493.

Closes #493

8 of 12 new or added lines in 2 files covered. (66.67%)

5221 of 8918 relevant lines covered (58.54%)

187.85 hits per line

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

55.91
/src/render/gpu_buffer.rs
1
use std::marker::PhantomData;
2

3
use bevy::{
4
    log::trace,
5
    render::{
6
        render_resource::{
7
            BindingResource, Buffer, BufferAddress, BufferDescriptor, BufferUsages, ShaderSize,
8
            ShaderType,
9
        },
10
        renderer::RenderDevice,
11
    },
12
};
13
use bytemuck::Pod;
14
use wgpu::CommandEncoder;
15

16
struct BufferAndSize {
17
    /// Allocate GPU buffer.
18
    pub buffer: Buffer,
19
    /// Size of the buffer, in number of elements.
20
    pub size: u32,
21
}
22

23
/// GPU-only buffer without CPU-side storage.
24
///
25
/// This is a rather specialized helper to allocate an array on the GPU and
26
/// manage its buffer, depending on the device constraints and the WGSL rules
27
/// for data alignment, and allowing to resize the buffer without losing its
28
/// content (so, scheduling a buffer-to-buffer copy on GPU after reallocatin).
29
///
30
/// The element type `T` needs to implement the following traits:
31
/// - [`Pod`] to prevent user error. This is not strictly necessary, as there's
32
///   no copy from or to CPU, but if the placeholder type is not POD this might
33
///   indicate some user error.
34
/// - [`ShaderSize`] to ensure a fixed footprint, to allow packing multiple
35
///   instances inside a single buffer. This therefore excludes any
36
///   runtime-sized array (T being the element type here; it will itself be part
37
///   of an array).
38
pub struct GpuBuffer<T: Pod + ShaderSize> {
39
    /// GPU buffer if already allocated, or `None` otherwise.
40
    buffer: Option<BufferAndSize>,
41
    /// Previous GPU buffer, pending copy.
42
    old_buffer: Option<BufferAndSize>,
43
    /// GPU buffer usages.
44
    buffer_usage: BufferUsages,
45
    /// Optional GPU buffer name, for debugging.
46
    label: Option<String>,
47
    /// Used size, in element count. Elements past this are all free. Elements
48
    /// with a lower index are either allocated or in the free list.
49
    used_size: u32,
50
    /// Free list.
51
    free_list: Vec<u32>,
52
    _phantom: PhantomData<T>,
53
}
54

55
impl<T: Pod + ShaderType + ShaderSize> Default for GpuBuffer<T> {
56
    fn default() -> Self {
9✔
57
        Self {
58
            buffer: None,
59
            old_buffer: None,
60
            buffer_usage: BufferUsages::all(),
18✔
61
            label: None,
62
            used_size: 0,
63
            free_list: vec![],
9✔
64
            _phantom: PhantomData,
65
        }
66
    }
67
}
68

69
impl<T: Pod + ShaderType + ShaderSize> GpuBuffer<T> {
70
    /// Create a new collection.
71
    ///
72
    /// The buffer usage is always augmented by [`BufferUsages::COPY_SRC`] and
73
    /// [`BufferUsages::COPY_DST`] in order to allow buffer-to-buffer copy when
74
    /// reallocating, to preserve old content.
75
    ///
76
    /// # Panics
77
    ///
78
    /// Panics if `buffer_usage` contains [`BufferUsages::UNIFORM`] and the
79
    /// layout of the element type `T` does not meet the requirements of the
80
    /// uniform address space, as tested by
81
    /// [`ShaderType::assert_uniform_compat()`].
82
    ///
83
    /// [`BufferUsages::UNIFORM`]: bevy::render::render_resource::BufferUsages::UNIFORM
84
    #[allow(dead_code)]
85
    pub fn new(buffer_usage: BufferUsages, label: Option<String>) -> Self {
6✔
86
        // GPU-aligned item size, compatible with WGSL rules
87
        let item_size = <T as ShaderSize>::SHADER_SIZE.get() as usize;
12✔
88
        trace!("GpuBuffer: item_size={}", item_size);
6✔
89
        if buffer_usage.contains(BufferUsages::UNIFORM) {
12✔
90
            <T as ShaderType>::assert_uniform_compat();
×
91
        }
92
        Self {
93
            // We need both COPY_SRC and COPY_DST for copy_buffer_to_buffer() on realloc
94
            buffer_usage: buffer_usage | BufferUsages::COPY_SRC | BufferUsages::COPY_DST,
12✔
95
            label,
96
            ..Default::default()
97
        }
98
    }
99

100
    /// Create a new collection from an allocated buffer.
101
    ///
102
    /// The buffer usage must contain [`BufferUsages::COPY_SRC`] and
103
    /// [`BufferUsages::COPY_DST`] in order to allow buffer-to-buffer copy when
104
    /// reallocating, to preserve old content.
105
    ///
106
    /// # Panics
107
    ///
108
    /// Panics if `buffer_usage` doesn't contain [`BufferUsages::COPY_SRC`] or
109
    /// [`BufferUsages::COPY_DST`].
110
    ///
111
    /// Panics if `buffer_usage` contains [`BufferUsages::UNIFORM`] and the
112
    /// layout of the element type `T` does not meet the requirements of the
113
    /// uniform address space, as tested by
114
    /// [`ShaderType::assert_uniform_compat()`].
115
    ///
116
    /// [`BufferUsages::UNIFORM`]: bevy::render::render_resource::BufferUsages::UNIFORM
117
    pub fn new_allocated(buffer: Buffer, label: Option<String>) -> Self {
3✔
118
        // GPU-aligned item size, compatible with WGSL rules.
119
        let item_size = <T as ShaderSize>::SHADER_SIZE.get();
6✔
120
        let buffer_usage = buffer.usage();
6✔
121
        assert!(
3✔
122
            buffer_usage.contains(BufferUsages::COPY_SRC | BufferUsages::COPY_DST),
9✔
123
            "GpuBuffer requires COPY_SRC and COPY_DST buffer usages to allow copy on reallocation."
×
124
        );
125
        if buffer_usage.contains(BufferUsages::UNIFORM) {
6✔
126
            <T as ShaderType>::assert_uniform_compat();
×
127
        }
128
        // Capacity is derived from the physical buffer so the two can't disagree.
129
        debug_assert_eq!(
3✔
130
            buffer.size() % item_size,
3✔
NEW
131
            0,
×
NEW
132
            "GpuBuffer physical size ({} bytes) is not a multiple of the element size ({} bytes)",
×
NEW
133
            buffer.size(),
×
NEW
134
            item_size,
×
135
        );
136
        let size = (buffer.size() / item_size) as u32;
6✔
137
        trace!("GpuBuffer: item_size={item_size} capacity={size}");
3✔
138
        Self {
139
            buffer: Some(BufferAndSize { buffer, size }),
6✔
140
            buffer_usage,
141
            label,
142
            ..Default::default()
143
        }
144
    }
145

146
    /// Clear the buffer.
147
    ///
148
    /// This doesn't de-allocate any GPU buffer.
149
    pub fn clear(&mut self) {
660✔
150
        self.free_list.clear();
1,320✔
151
        self.used_size = 0;
660✔
152
    }
153

154
    /// Allocate a new entry in the buffer.
155
    ///
156
    /// If the GPU buffer has not enough storage, or is not allocated yet, this
157
    /// schedules a (re-)allocation, which must be applied by calling
158
    /// [`allocate_gpu()`] once a frame after all [`allocate()`] calls were made
159
    /// for that frame.
160
    ///
161
    /// # Returns
162
    ///
163
    /// The index of the allocated entry.
164
    ///
165
    /// [`allocate_gpu()`]: Self::allocate_gpu
166
    /// [`allocate()`]: Self::allocate
167
    pub fn allocate(&mut self) -> u32 {
312✔
168
        if let Some(index) = self.free_list.pop() {
312✔
169
            index
×
170
        } else {
171
            // Note: we may return an index past the buffer capacity. This will instruct
172
            // allocate_gpu() to re-allocate the buffer.
173
            let index = self.used_size;
624✔
174
            self.used_size += 1;
312✔
175
            index
312✔
176
        }
177
    }
178

179
    /// Free an existing entry.
180
    ///
181
    /// # Panics
182
    ///
183
    /// In debug only, panics if the entry is not allocated (double-free). In
184
    /// non-debug, the behavior is undefined and will generally lead to bugs.
185
    // Currently we use GpuBuffer in sorting, and re-allocate everything each frame.
186
    #[allow(dead_code)]
187
    pub fn free(&mut self, index: u32) {
×
188
        if index < self.used_size {
×
189
            debug_assert!(
×
190
                !self.free_list.contains(&index),
×
191
                "Double-free in GpuBuffer at index #{}",
×
192
                index
×
193
            );
194
            self.free_list.push(index);
×
195
        }
196
    }
197

198
    /// Get the current GPU buffer, if allocated.
199
    #[inline]
200
    pub fn buffer(&self) -> Option<&Buffer> {
624✔
201
        self.buffer.as_ref().map(|b| &b.buffer)
1,872✔
202
    }
203

204
    /// Get a binding for the entire GPU buffer, if allocated.
205
    #[inline]
206
    #[allow(dead_code)]
207
    pub fn as_entire_binding(&self) -> Option<BindingResource<'_>> {
×
208
        let buffer = self.buffer()?;
×
209
        Some(buffer.as_entire_binding())
×
210
    }
211

212
    /// Get the current buffer capacity, in element count.
213
    ///
214
    /// This is the CPU view of allocations, which counts the number of
215
    /// [`allocate()`] and [`free()`] calls.
216
    ///
217
    /// [`allocate()`]: Self::allocate
218
    /// [`free()`]: Self::allocate_gpu
219
    #[inline]
220
    #[allow(dead_code)]
221
    pub fn capacity(&self) -> u32 {
×
222
        debug_assert!(self.used_size >= self.free_list.len() as u32);
×
223
        self.used_size - self.free_list.len() as u32
×
224
    }
225

226
    /// Get the current GPU buffer capacity, in element count.
227
    ///
228
    /// Note that it is possible for [`allocate()`] to return an index greater
229
    /// than or equal to the value returned by [`capacity()`], at least
230
    /// temporarily until [`allocate_gpu()`] is called.
231
    ///
232
    /// [`allocate()`]: Self::allocate
233
    /// [`gpu_capacity()`]: Self::gpu_capacity
234
    /// [`allocate_gpu()`]: Self::allocate_gpu
235
    #[inline]
236
    pub fn gpu_capacity(&self) -> u32 {
990✔
237
        self.buffer.as_ref().map(|b| b.size).unwrap_or(0)
3,960✔
238
    }
239

240
    /// Size in bytes of a single item in the buffer.
241
    ///
242
    /// This is equal to [`ShaderSize::SHADER_SIZE`] for the buffer element `T`.
243
    #[inline]
244
    pub fn item_size(&self) -> usize {
2✔
245
        <T as ShaderSize>::SHADER_SIZE.get() as usize
2✔
246
    }
247

248
    /// Check if the buffer is empty.
249
    ///
250
    /// The check is based on the CPU representation of the buffer, that is the
251
    /// number of calls to [`allocate()`]. The buffer is considered empty if no
252
    /// [`allocate()`] call was made, or they all have been followed by a
253
    /// corresponding [`free()`] call. This makes no assumption about the GPU
254
    /// buffer.
255
    ///
256
    /// [`allocate()`]: Self::allocate
257
    /// [`free()`]: Self::free
258
    #[inline]
259
    #[allow(dead_code)]
260
    pub fn is_empty(&self) -> bool {
×
261
        self.used_size == 0
×
262
    }
263

264
    /// Allocate or reallocate the GPU buffer if needed.
265
    ///
266
    /// This allocates or reallocates a GPU buffer to ensure storage for all
267
    /// previous calls to [`allocate()`]. This is a no-op if a GPU buffer is
268
    /// already allocated and has sufficient storage.
269
    ///
270
    /// This should be called once a frame after any new [`allocate()`] in that
271
    /// frame. After this call, [`buffer()`] is guaranteed to return `Some(..)`.
272
    ///
273
    /// # Returns
274
    ///
275
    /// `true` if the buffer was (re)allocated, or `false` if an existing buffer
276
    /// was reused which already had enough capacity.
277
    ///
278
    /// [`reserve()`]: Self::reserve
279
    /// [`allocate()`]: Self::allocate
280
    /// [`buffer()`]: Self::buffer
281
    pub fn prepare_buffers(&mut self, render_device: &RenderDevice) -> bool {
990✔
282
        // Don't do anything if we still have some storage.
283
        let old_capacity = self.gpu_capacity();
2,970✔
284
        if self.used_size <= old_capacity {
990✔
285
            return false;
988✔
286
        }
287

288
        // Ensure we allocate at least 256 more entries than what we need this frame,
289
        // and round that to make it nicer for the GPU.
290
        let new_capacity = (self.used_size + 256).next_multiple_of(1024);
×
291
        if new_capacity <= old_capacity {
×
292
            return false;
×
293
        }
294

295
        // Save the old buffer, we will need to copy it to the new one later.
296
        assert!(self.old_buffer.is_none(), "Multiple calls to GpuTable::prepare_buffers() before write_buffers() was called to copy old content.");
×
297
        self.old_buffer = self.buffer.take();
6✔
298

299
        // Allocate a new buffer of the appropriate size.
300
        let byte_size = self.item_size() * new_capacity as usize;
6✔
301
        trace!(
2✔
302
            "prepare_buffers(): increase capacity from {} to {} elements, new size {} bytes",
×
303
            old_capacity,
×
304
            new_capacity,
×
305
            byte_size
×
306
        );
307
        let buffer = render_device.create_buffer(&BufferDescriptor {
6✔
308
            label: self.label.as_ref().map(|s| &s[..]),
8✔
309
            size: byte_size as BufferAddress,
2✔
310
            usage: BufferUsages::COPY_DST | self.buffer_usage,
2✔
311
            mapped_at_creation: false,
×
312
        });
313
        self.buffer = Some(BufferAndSize {
4✔
314
            buffer,
2✔
315
            size: new_capacity,
2✔
316
        });
317

318
        true
2✔
319
    }
320

321
    /// Schedule any pending buffer copy.
322
    ///
323
    /// If a new buffer was (re-)allocated this frame, this schedules a
324
    /// buffer-to-buffer copy from the old buffer to the new one, then releases
325
    /// the old buffer.
326
    ///
327
    /// This should be called once a frame after [`prepare_buffers()`]. This is
328
    /// a no-op if there's no need for a buffer copy.
329
    ///
330
    /// [`prepare_buffers()`]: Self::prepare_buffers
331
    pub fn write_buffers(&self, command_encoder: &mut CommandEncoder) {
990✔
332
        if let Some(old_buffer) = self.old_buffer.as_ref() {
990✔
333
            let new_buffer = self.buffer.as_ref().unwrap();
×
334
            assert!(
×
335
                new_buffer.size >= old_buffer.size,
×
336
                "Old buffer is smaller than the new one. This is unexpected."
×
337
            );
338
            command_encoder.copy_buffer_to_buffer(
×
339
                &old_buffer.buffer,
×
340
                0,
341
                &new_buffer.buffer,
×
342
                0,
343
                old_buffer.size as u64,
×
344
            );
345
        }
346
    }
347

348
    /// Clear any stale buffer used for resize in the previous frame during
349
    /// rendering while the data structure was immutable.
350
    ///
351
    /// This must be called before any new [`allocate()`].
352
    ///
353
    /// [`allocate()`]: Self::allocate
354
    pub fn clear_previous_frame_resizes(&mut self) {
990✔
355
        if let Some(old_buffer) = self.old_buffer.take() {
990✔
356
            old_buffer.buffer.destroy();
×
357
        }
358
    }
359
}
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