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}