Skip to main content

unlit_wgpu/
vertex_pool.rs

1//! A pool that packs many vertex streams into one buffer per layout.
2//!
3//! Uploading a mesh's vertices per buffer is wasteful: every `wgpu::Buffer`
4//! carries driver overhead, and a draw can only bind a handful of them. A
5//! [`VertexStreamPool`] instead owns one large buffer per vertex layout and
6//! hands out element ranges inside them, so every mesh that shares a layout
7//! shares a buffer.
8//!
9//! # Why one allocator for every layout
10//!
11//! Every vertex stream a draw binds reads the *same* element index: a draw's
12//! `firstVertex`, or its `baseVertex` plus an index, picks one element out of
13//! each stream at once. So a mesh whose vertices live in a pool has to land at
14//! the same element index in all of them, and one allocation has to cover all
15//! of its streams.
16//!
17//! That is why the pool hands out **elements, not bytes**, and keeps a *single*
18//! allocator for every layout: one [`VertexAllocation`] is one range of
19//! element indices, and each layout's buffer is that many elements times its
20//! stride. Giving each layout its own allocator would not do, because two
21//! allocators asked for the same count in different units — bytes, scaled by
22//! different strides — drift apart: the allocator's size bins are a
23//! logarithmic approximation, so `n * 8` and `n * 12` can round into different
24//! bins and two streams of the same mesh end up at different indices, which
25//! silently reads the wrong vertices.
26//!
27//! A byte offset inside a layout's buffer is its element index times that
28//! layout's stride. Since a stride is always a multiple of
29//! [`wgpu::VERTEX_ALIGNMENT`], every offset is a multiple of it too, which is
30//! what [`wgpu::Queue::write_buffer`] asks for.
31//!
32//! # Example
33//!
34//! ```rust
35//! use unlit_wgpu::specialize::{VertexAttributes, VertexBufferLayoutDesc};
36//! use unlit_wgpu::vertex_pool::VertexStreamPool;
37//!
38//! let (device, queue) = wgpu::Device::noop(&wgpu::DeviceDescriptor::default());
39//! let layout = VertexBufferLayoutDesc {
40//!     array_stride: 8,
41//!     step_mode: wgpu::VertexStepMode::Vertex,
42//!     attributes: VertexAttributes::new(),
43//! };
44//!
45//! let mut pool = VertexStreamPool::new(
46//!     &device,
47//!     "example::vertices",
48//!     wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
49//!     1024,
50//! );
51//!
52//! let vertices = pool
53//!     .allocate(&device, &queue, &[layout.clone()], 3)
54//!     .expect("the pool has room");
55//! let buffer = pool.buffer(&layout).expect("the layout has a buffer");
56//! let offset = VertexStreamPool::byte_offset(&layout, vertices.allocation.offset);
57//! queue.write_buffer(buffer, offset, &[0u8; 24]);
58//! pool.release(vertices.allocation);
59//! ```
60
61use core::num::NonZeroU32;
62use hashbrown::HashMap;
63
64use crate::offset_allocator::{Allocation, Allocator, min_allocator_size};
65use crate::specialize::VertexBufferLayoutDesc;
66
67/// The alignment the allocator hands out element indices at.
68///
69/// One element is the unit, so an index never needs rounding.
70const ELEMENT_ALIGNMENT: NonZeroU32 = match NonZeroU32::new(1) {
71    Some(alignment) => alignment,
72    None => unreachable!(),
73};
74
75/// How many allocations a pool tracks unless the caller says otherwise.
76///
77/// The allocator's own default (128 Ki nodes, roughly 3.5 MB of metadata) is
78/// sized for a general-purpose allocator; a pool holds one allocation per
79/// mesh, so it needs far fewer.
80const DEFAULT_MAX_ALLOCATIONS: u32 = 4096;
81
82/// The step an element count grows by, and the floor for the first one.
83const MIN_ELEMENTS: u32 = 1;
84
85/// One mesh's vertices in a [`VertexStreamPool`]: one range of element
86/// indices, shared by every stream the mesh binds.
87///
88/// The indices stay put until the allocation is handed back with
89/// [`VertexStreamPool::release`]. Growing the pool replaces the buffers but
90/// keeps every index, so read a buffer from
91/// [`VertexStreamPool::buffer`] instead of holding one.
92#[derive(Clone, Copy, Debug)]
93pub struct VertexAllocation {
94    /// The allocation that keeps the element range reserved.
95    pub allocation: Allocation,
96    /// How many elements the range covers.
97    pub count: u32,
98}
99
100/// One large buffer per vertex layout, sub-allocated into element ranges.
101///
102/// See the [module documentation](self) for why every layout shares one
103/// allocator, and for what a pool is for.
104pub struct VertexStreamPool {
105    /// The label every buffer is created with, reused when one is replaced.
106    label: &'static str,
107    /// The usages every buffer is created with.
108    usage: wgpu::BufferUsages,
109    /// Where the free element indices are, shared by every layout.
110    allocator: Allocator,
111    /// The buffer of each layout the pool has been asked for.
112    streams: HashMap<VertexBufferLayoutDesc, Stream>,
113}
114
115/// One layout's buffer and how much of it is in use.
116struct Stream {
117    /// The buffer every mesh of this layout shares.
118    buffer: wgpu::Buffer,
119    /// The highest element index the buffer has to cover, rounded up to the
120    /// pool's growth step.
121    ///
122    /// A layout only needs to cover the elements something actually allocated
123    /// in it, not the allocator's whole capacity: a layout used by one small
124    /// mesh should not pay for a capacity a busier layout drove up.
125    water_mark: u32,
126}
127
128impl VertexStreamPool {
129    /// Creates a pool whose buffers hold `initial_element_capacity` elements
130    /// each.
131    ///
132    /// No buffer exists until a layout is asked for; the capacity is only what
133    /// the allocator starts with, and it grows on demand.
134    pub fn new(
135        _device: &wgpu::Device,
136        label: &'static str,
137        usage: wgpu::BufferUsages,
138        initial_element_capacity: u32,
139    ) -> Self {
140        Self {
141            label,
142            usage,
143            allocator: Allocator::with_max_nodes_and_alignment(
144                initial_element_capacity.max(MIN_ELEMENTS),
145                DEFAULT_MAX_ALLOCATIONS,
146                ELEMENT_ALIGNMENT,
147            ),
148            streams: HashMap::new(),
149        }
150    }
151
152    /// How many elements the pool can hold.
153    pub fn element_capacity(&self) -> u32 {
154        self.allocator.size()
155    }
156
157    /// The byte offset of element `index` in a layout's buffer.
158    ///
159    /// This is `index` times the layout's stride, which is always a multiple
160    /// of [`wgpu::VERTEX_ALIGNMENT`], so the result satisfies the alignment
161    /// [`wgpu::Queue::write_buffer`] asks for.
162    pub fn byte_offset(layout: &VertexBufferLayoutDesc, index: u32) -> u64 {
163        u64::from(index) * layout.array_stride
164    }
165
166    /// The buffer of `layout`, if the pool has been asked for it.
167    ///
168    /// This changes when the pool grows, so read it again instead of holding
169    /// one.
170    pub fn buffer(&self, layout: &VertexBufferLayoutDesc) -> Option<&wgpu::Buffer> {
171        self.streams.get(layout).map(|stream| &stream.buffer)
172    }
173
174    /// Makes sure every layout in `layouts` has a buffer, then reserves
175    /// `count` elements in all of them at once.
176    ///
177    /// A layout with no attributes and no stride declares nothing to read, so
178    /// it is skipped: it gets no buffer and no part in the allocation.
179    ///
180    /// Returns `None` only if the pool cannot grow any further (its element
181    /// count would overflow a `u32`, or it has run out of node slots for its
182    /// allocations).
183    ///
184    /// Growing allocates new buffers and copies the old contents into them;
185    /// existing allocations keep their element indices, but the buffers they
186    /// name change, so a caller holding one must re-read
187    /// [`Self::buffer`].
188    pub fn allocate(
189        &mut self,
190        device: &wgpu::Device,
191        queue: &wgpu::Queue,
192        layouts: &[VertexBufferLayoutDesc],
193        count: u32,
194    ) -> Option<VertexAllocation> {
195        assert!(count > 0, "a vertex allocation covers at least one element");
196
197        if let Some(allocation) = self.allocator.allocate(count) {
198            self.reserve(device, queue, layouts, allocation.offset + count);
199            return Some(VertexAllocation { allocation, count });
200        }
201
202        // The allocation may fail because the pool is full or because its
203        // ranges are fragmented; growing helps in both cases as long as there
204        // is a free node to describe the appended space.
205        self.grow(count)?;
206        let allocation = self
207            .allocator
208            .allocate(count)
209            .expect("the grown pool has room for the allocation that did not fit");
210        self.reserve(device, queue, layouts, allocation.offset + count);
211        Some(VertexAllocation { allocation, count })
212    }
213
214    /// Hands an allocation back to the pool.
215    ///
216    /// # Panics
217    ///
218    /// Panics if the allocation was already released.
219    pub fn release(&mut self, allocation: Allocation) {
220        self.allocator.free(allocation);
221    }
222
223    /// Grows every layout's buffer until it covers `end` elements.
224    ///
225    /// A layout that already covers them is left alone, so a pool shared by
226    /// meshes of wildly different strides only pays for each layout as far as
227    /// that layout goes.
228    fn reserve(
229        &mut self,
230        device: &wgpu::Device,
231        queue: &wgpu::Queue,
232        layouts: &[VertexBufferLayoutDesc],
233        end: u32,
234    ) {
235        for layout in layouts {
236            if layout.array_stride == 0 {
237                // Nothing to read, so nothing to store: a stream that declares
238                // no attributes gets no buffer and no part in the allocation.
239                continue;
240            }
241            let stream = self
242                .streams
243                .entry(layout.clone())
244                .or_insert_with(|| Stream {
245                    buffer: create_buffer(device, self.label, self.usage, 0),
246                    water_mark: 0,
247                });
248            // A stream that already covers the allocation's end needs nothing;
249            // only then is a grown capacity chosen.
250            if stream.water_mark >= end {
251                continue;
252            }
253            let wanted = growth_step(stream.water_mark, end);
254            stream.buffer = copy_into_larger(
255                device,
256                queue,
257                &stream.buffer,
258                self.label,
259                self.usage,
260                Self::byte_offset(layout, stream.water_mark),
261                Self::byte_offset(layout, wanted),
262            );
263            stream.water_mark = wanted;
264        }
265    }
266
267    /// Grows the allocator until `count` elements fit.
268    ///
269    /// Returns `None` if it cannot grow, leaving the pool unchanged.
270    fn grow(&mut self, count: u32) -> Option<()> {
271        // Grow by 1.5x and at least as much as the allocation that did not
272        // fit needs, so one grow is always enough for it.
273        //
274        // The size to grow by is the allocator's minimum for `count`, not
275        // `count` itself: a free range is filed under its rounded-down size
276        // while an allocation searches from its rounded-up size, so a range
277        // sized exactly `count` would not be found by an allocation of
278        // `count`. `min_allocator_size` is the smallest size that both holds
279        // `count` and is representable by a bin, which is what makes the
280        // range findable.
281        let current = self.allocator.size();
282        let needed = min_allocator_size(count, ELEMENT_ALIGNMENT);
283        let additional = needed.max(current / 2);
284        if !self.allocator.extend(additional) {
285            return None;
286        }
287        Some(())
288    }
289}
290
291/// Replaces `buffer` with a larger one, copying `old_size` bytes across.
292///
293/// A buffer cannot be resized, so growing one means creating another and
294/// copying into it — which also means the buffers the pool hands out change
295/// under a caller that held one. The copy covers only what was in use, so a
296/// buffer that has never been written to costs nothing to grow.
297fn copy_into_larger(
298    device: &wgpu::Device,
299    queue: &wgpu::Queue,
300    buffer: &wgpu::Buffer,
301    label: &'static str,
302    usage: wgpu::BufferUsages,
303    old_size: u64,
304    new_size: u64,
305) -> wgpu::Buffer {
306    let larger = create_buffer(device, label, usage, new_size);
307    if old_size > 0 {
308        let mut encoder =
309            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
310        encoder.copy_buffer_to_buffer(buffer, 0, &larger, 0, old_size);
311        queue.submit([encoder.finish()]);
312    }
313    larger
314}
315
316/// The element count to grow `current` to so it covers `count`.
317///
318/// Growth is geometric (1.5x) so repeated growth does not reallocate every
319/// frame, and never rounds below the minimum the pool needs.
320fn growth_step(current: u32, count: u32) -> u32 {
321    count.max(current + current / 2).max(MIN_ELEMENTS)
322}
323
324/// Creates a buffer of `size` bytes with the usages the pool needs.
325fn create_buffer(
326    device: &wgpu::Device,
327    label: &'static str,
328    usage: wgpu::BufferUsages,
329    size: u64,
330) -> wgpu::Buffer {
331    device.create_buffer(&wgpu::BufferDescriptor {
332        label: Some(label),
333        size,
334        usage: usage | wgpu::BufferUsages::COPY_SRC,
335        mapped_at_creation: false,
336    })
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::specialize::VertexAttributes;
343
344    fn noop_device() -> (wgpu::Device, wgpu::Queue) {
345        wgpu::Device::noop(&wgpu::DeviceDescriptor::default())
346    }
347
348    fn layout(stride: u64) -> VertexBufferLayoutDesc {
349        VertexBufferLayoutDesc {
350            array_stride: stride,
351            step_mode: wgpu::VertexStepMode::Vertex,
352            attributes: VertexAttributes::new(),
353        }
354    }
355
356    #[test]
357    fn a_layout_gets_a_buffer_once_it_is_allocated_from() {
358        let (device, queue) = noop_device();
359        let mut pool = VertexStreamPool::new(
360            &device,
361            "test::vertices",
362            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
363            16,
364        );
365        let eight = layout(8);
366
367        assert!(pool.buffer(&eight).is_none());
368        let vertices = pool
369            .allocate(&device, &queue, std::slice::from_ref(&eight), 4)
370            .expect("the pool has room");
371        assert!(pool.buffer(&eight).is_some());
372        assert_eq!(vertices.allocation.offset, 0);
373        assert_eq!(vertices.count, 4);
374    }
375
376    #[test]
377    fn a_stream_with_no_stride_gets_no_buffer() {
378        let (device, queue) = noop_device();
379        let mut pool = VertexStreamPool::new(
380            &device,
381            "test::vertices",
382            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
383            16,
384        );
385        let empty = layout(0);
386
387        let vertices = pool
388            .allocate(&device, &queue, std::slice::from_ref(&empty), 4)
389            .expect("the pool has room");
390        assert!(pool.buffer(&empty).is_none());
391        assert_eq!(vertices.count, 4);
392    }
393
394    #[test]
395    fn every_layout_of_an_allocation_covers_the_same_elements() {
396        let (device, queue) = noop_device();
397        let mut pool = VertexStreamPool::new(
398            &device,
399            "test::vertices",
400            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
401            64,
402        );
403        let layouts = [layout(8), layout(12), layout(24)];
404
405        let vertices = pool
406            .allocate(&device, &queue, &layouts, 10)
407            .expect("the pool has room");
408        for stream in &layouts {
409            // A buffer only has to cover the elements the pool has handed out
410            // up to here, which is what makes one shared allocator safe: the
411            // byte sizes differ but the element index does not.
412            let size = pool.buffer(stream).expect("the layout has a buffer").size();
413            assert!(
414                size >= VertexStreamPool::byte_offset(
415                    stream,
416                    vertices.allocation.offset + vertices.count
417                ),
418                "the buffer covers the whole allocation"
419            );
420        }
421    }
422
423    #[test]
424    fn allocations_do_not_overlap_and_are_reused() {
425        let (device, queue) = noop_device();
426        let mut pool = VertexStreamPool::new(
427            &device,
428            "test::vertices",
429            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
430            64,
431        );
432        let eight = layout(8);
433
434        let first = pool
435            .allocate(&device, &queue, std::slice::from_ref(&eight), 8)
436            .expect("the pool has room");
437        let second = pool
438            .allocate(&device, &queue, std::slice::from_ref(&eight), 8)
439            .expect("the pool has room");
440        assert!(
441            first.allocation.offset + first.count <= second.allocation.offset
442                || second.allocation.offset + second.count <= first.allocation.offset,
443            "the two allocations do not overlap"
444        );
445
446        pool.release(first.allocation);
447        let third = pool
448            .allocate(&device, &queue, std::slice::from_ref(&eight), 8)
449            .expect("the pool has room");
450        assert_eq!(
451            third.allocation.offset, first.allocation.offset,
452            "the freed range comes back"
453        );
454    }
455
456    #[test]
457    fn growing_keeps_element_offsets_and_replaces_the_buffers() {
458        let (device, queue) = noop_device();
459        let mut pool = VertexStreamPool::new(
460            &device,
461            "test::vertices",
462            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
463            8,
464        );
465        let eight = layout(8);
466
467        let first = pool
468            .allocate(&device, &queue, std::slice::from_ref(&eight), 8)
469            .expect("the pool has room");
470        let before = pool
471            .buffer(&eight)
472            .expect("the layout has a buffer")
473            .clone();
474
475        // This does not fit in the remaining elements, so the pool grows.
476        let second = pool
477            .allocate(&device, &queue, std::slice::from_ref(&eight), 32)
478            .expect("the pool grows");
479        // A caller syncing a pool into a resource graph detects a grow by
480        // comparing the buffer it holds against the pool's, so growing has to
481        // hand out a buffer that compares unequal to the old one.
482        assert_ne!(
483            pool.buffer(&eight).expect("the layout has a buffer"),
484            &before,
485            "growing replaces the buffer"
486        );
487        assert_eq!(
488            first.allocation.offset, 0,
489            "the first allocation did not move"
490        );
491        assert!(pool.element_capacity() >= first.count + second.count);
492    }
493
494    #[test]
495    fn byte_offset_is_the_element_index_times_the_stride() {
496        let stream = layout(12);
497        assert_eq!(VertexStreamPool::byte_offset(&stream, 0), 0);
498        assert_eq!(VertexStreamPool::byte_offset(&stream, 3), 36);
499    }
500
501    #[test]
502    fn two_layouts_of_one_mesh_land_on_the_same_element_index() {
503        // The regression this guards: giving each layout its own allocator
504        // lets two streams of one mesh drift onto different element indices,
505        // because the allocator's bins are a logarithmic approximation and
506        // `n * stride` rounds differently per stride. A shared, element-unit
507        // allocator cannot drift, and this is what says so.
508        let (device, queue) = noop_device();
509        let mut pool = VertexStreamPool::new(
510            &device,
511            "test::vertices",
512            wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
513            16,
514        );
515        let layouts = [layout(8), layout(12)];
516
517        // A pseudo-random churn of allocations and frees.
518        let mut live: Vec<VertexAllocation> = Vec::new();
519        let mut state = 0x2545_F491_4F6C_DD1Du64;
520        for step in 0..20_000u32 {
521            let rolls = u64::from(step) % 3;
522            state = state
523                .wrapping_mul(6364136223846793005)
524                .wrapping_add(1442695040888963407);
525            let free_one = state.is_multiple_of(2);
526            if free_one && let Some(allocation) = live.pop() {
527                pool.release(allocation.allocation);
528            }
529            let count = 1 + (state >> 33) % 64;
530            let count = u32::try_from(count).expect("the count fits in u32");
531            let allocation = pool
532                .allocate(&device, &queue, &layouts, count)
533                .expect("the pool grows");
534            // One allocation names one element index, so every stream of the
535            // mesh reads the same vertex: there is nothing to compare here
536            // beyond the allocation itself, and the assertion below is what
537            // catches a design that gave each stream an index of its own.
538            assert_eq!(allocation.count, count);
539            if rolls == 0 {
540                live.push(allocation);
541            } else {
542                pool.release(allocation.allocation);
543            }
544        }
545    }
546}