Skip to main content

valo_renderer/
host_buffer.rs

1use std::num::NonZeroU64;
2
3/// Bytes of one per-draw uniform record: mat4 MVP + vec4 color + the generic
4/// payload (see shaders/solid.wgsl's layout contract). 512 = 2× the dynamic
5/// offset alignment — stride and record size stay equal, nothing is wasted.
6pub(crate) const UNIFORM_SIZE: u64 = 512;
7/// Frames in flight the arena ring covers.
8const FRAMES: usize = 3;
9/// Ring passes a trailing block sits unused before draining (×3 frames each).
10const IDLE_PASSES: u8 = 3;
11/// Uniform slots per block (block size = slots × stride).
12const SLOTS_PER_BLOCK: u64 = 1024;
13/// Default vertex block size (fans allocate ranges; oversized meshes get a
14/// dedicated block).
15const VERTEX_BLOCK_SIZE: u64 = 256 * 1024;
16
17/// `HostBuffer` bump-allocates per-draw uniforms and transient vertex data.
18///
19/// Each frame writes into CPU scratch; [`Self::flush`] copies touched blocks
20/// with one `queue.write_buffer` each. wgpu stages those writes, so the ring
21/// needs no fences. A 3-frame ring of persistent buffers means warm frames
22/// create nothing — the cost that matters most on wasm.
23///
24/// Uniforms bind once per block via a dynamic offset. Vertex data (stencil
25/// fans, stroke strips) lands in a second family of blocks. All uploads go
26/// through the alloc/`flush` seam so a mapped staging backend can replace
27/// this implementation without touching call sites.
28pub struct HostBuffer {
29    device: wgpu::Device,
30    layout: wgpu::BindGroupLayout,
31    frames: [FrameArena; FRAMES],
32    frame: usize,
33    stride: u64,
34    uniform_block_size: u64,
35    /// Total blocks ever created (stats: should go quiet after warm-up).
36    pub(crate) blocks_created: u64,
37}
38
39#[derive(Default)]
40struct FrameArena {
41    uniforms: Vec<Block>,
42    cursor: Cursor,
43    vertices: Vec<Block>,
44    vertex_cursor: Cursor,
45}
46
47#[derive(Default, Clone, Copy)]
48struct Cursor {
49    block: usize,
50    offset: u64,
51}
52
53struct Block {
54    buffer: wgpu::Buffer,
55    /// Uniform blocks carry their bind group; vertex blocks don't need one.
56    bind_group: Option<wgpu::BindGroup>,
57    scratch: Vec<u8>,
58    used: u64,
59    /// Ring passes since this block last held data (see `begin_frame`).
60    idle: u8,
61}
62
63/// Where one draw's uniforms live this frame.
64#[derive(Clone, Copy, Debug)]
65pub(crate) struct DrawSlot {
66    pub block: usize,
67    pub offset: u32,
68}
69
70/// A transient vertex range (offsets in bytes into the block's buffer).
71#[derive(Clone, Copy, Debug)]
72pub(crate) struct VertexSlot {
73    pub block: usize,
74    pub offset: u64,
75    pub bytes: u64,
76}
77
78impl HostBuffer {
79    /// `new` creates an empty host buffer for `device`.
80    ///
81    /// Uniform stride is at least the per-draw record size and at least the
82    /// device's `min_uniform_buffer_offset_alignment`.
83    pub fn new(device: &wgpu::Device) -> Self {
84        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
85            label: Some("valo.host_buffer"),
86            entries: &[wgpu::BindGroupLayoutEntry {
87                binding: 0,
88                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
89                ty: wgpu::BindingType::Buffer {
90                    ty: wgpu::BufferBindingType::Uniform,
91                    has_dynamic_offset: true,
92                    min_binding_size: NonZeroU64::new(UNIFORM_SIZE),
93                },
94                count: None,
95            }],
96        });
97        let stride = (device.limits().min_uniform_buffer_offset_alignment as u64).max(UNIFORM_SIZE);
98        Self {
99            device: device.clone(),
100            layout,
101            frames: Default::default(),
102            frame: 0,
103            stride,
104            uniform_block_size: stride * SLOTS_PER_BLOCK,
105            blocks_created: 0,
106        }
107    }
108
109    /// `bind_group_layout` returns the group-0 layout for per-draw uniforms.
110    ///
111    /// Binding 0 is a dynamic-offset uniform buffer. Pipeline layouts are
112    /// built from this layout.
113    pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
114        &self.layout
115    }
116
117    /// `begin_frame` rotates to the next arena and resets its cursors.
118    ///
119    /// Blocks are retained so warm frames never create buffers. Trailing
120    /// unused blocks from a spike drain after a few idle ring passes so a
121    /// recurring large mesh does not recreate them every frame.
122    pub fn begin_frame(&mut self) {
123        self.frame = (self.frame + 1) % FRAMES;
124        let arena = &mut self.frames[self.frame];
125        arena.cursor = Cursor::default();
126        arena.vertex_cursor = Cursor::default();
127        for blocks in [&mut arena.uniforms, &mut arena.vertices] {
128            for b in blocks.iter_mut() {
129                b.idle = if b.used > 0 {
130                    0
131                } else {
132                    b.idle.saturating_add(1)
133                };
134                b.used = 0;
135            }
136            while blocks.len() > 1 && blocks.last().is_some_and(|b| b.idle >= IDLE_PASSES) {
137                blocks.pop();
138            }
139        }
140    }
141
142    /// Bump-allocate one uniform slot and copy `bytes` into scratch.
143    pub(crate) fn alloc_uniform(&mut self, bytes: &[u8]) -> DrawSlot {
144        debug_assert!(bytes.len() as u64 <= self.stride);
145        let stride = self.stride;
146        let block_size = self.uniform_block_size;
147        let (device, layout) = (self.device.clone(), self.layout.clone());
148        let arena = &mut self.frames[self.frame];
149
150        advance_cursor(&mut arena.cursor, &arena.uniforms, stride);
151        if arena.cursor.block >= arena.uniforms.len() {
152            arena
153                .uniforms
154                .push(new_uniform_block(&device, &layout, block_size));
155            self.blocks_created += 1;
156        }
157        let cursor = arena.cursor;
158        write_scratch(&mut arena.uniforms[cursor.block], cursor.offset, bytes);
159        arena.cursor.offset += stride;
160        DrawSlot {
161            block: cursor.block,
162            offset: cursor.offset as u32,
163        }
164    }
165
166    /// Bump-allocate a transient vertex range and copy `bytes` into scratch.
167    pub(crate) fn alloc_vertices(&mut self, bytes: &[u8]) -> VertexSlot {
168        let len = bytes.len() as u64;
169        let block_size = VERTEX_BLOCK_SIZE.max(len);
170        let device = self.device.clone();
171        let arena = &mut self.frames[self.frame];
172
173        advance_cursor(&mut arena.vertex_cursor, &arena.vertices, len);
174        if arena.vertex_cursor.block >= arena.vertices.len() {
175            arena.vertices.push(new_vertex_block(&device, block_size));
176            self.blocks_created += 1;
177        }
178        let cursor = arena.vertex_cursor;
179        write_scratch(&mut arena.vertices[cursor.block], cursor.offset, bytes);
180        arena.vertex_cursor.offset += len.next_multiple_of(4);
181        VertexSlot {
182            block: cursor.block,
183            offset: cursor.offset,
184            bytes: len,
185        }
186    }
187
188    /// `flush` uploads this frame's used scratch to the GPU.
189    ///
190    /// One `write_buffer` runs per touched block. Returns
191    /// `(uniform_bytes, vertex_bytes)` written, for frame statistics.
192    pub fn flush(&mut self, queue: &wgpu::Queue) -> (u64, u64) {
193        let arena = &self.frames[self.frame];
194        let mut written = (0u64, 0u64);
195        for b in &arena.uniforms {
196            written.0 += b.used;
197        }
198        for b in &arena.vertices {
199            written.1 += b.used;
200        }
201        for b in arena.uniforms.iter().chain(arena.vertices.iter()) {
202            if b.used > 0 {
203                queue.write_buffer(&b.buffer, 0, &b.scratch[..b.used as usize]);
204            }
205        }
206        written
207    }
208
209    /// Retained blocks across the whole ring: what a spike frame pins.
210    pub(crate) fn report(&self) -> crate::PoolReport {
211        let mut count = 0u32;
212        let mut bytes = 0u64;
213        for arena in &self.frames {
214            for b in arena.uniforms.iter().chain(arena.vertices.iter()) {
215                count += 1;
216                bytes += b.scratch.len() as u64;
217            }
218        }
219        crate::PoolReport { count, bytes }
220    }
221
222    pub(crate) fn bind_group(&self, block: usize) -> &wgpu::BindGroup {
223        self.frames[self.frame].uniforms[block]
224            .bind_group
225            .as_ref()
226            .expect("uniform blocks always carry a bind group")
227    }
228
229    pub(crate) fn vertex_buffer(&self, block: usize) -> &wgpu::Buffer {
230        &self.frames[self.frame].vertices[block].buffer
231    }
232}
233
234/// Walk to the first RETAINED block with room for `needed` bytes (blocks
235/// keep whatever size they were created with — judging fit by anything
236/// else can strand the cursor on a too-small block and overrun it). Lands
237/// past the end when nothing fits; the caller pushes a right-sized block.
238fn advance_cursor(cursor: &mut Cursor, blocks: &[Block], needed: u64) {
239    while let Some(block) = blocks.get(cursor.block) {
240        if cursor.offset + needed <= block.scratch.len() as u64 {
241            return;
242        }
243        cursor.block += 1;
244        cursor.offset = 0;
245    }
246}
247
248fn new_uniform_block(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: u64) -> Block {
249    let buffer = device.create_buffer(&wgpu::BufferDescriptor {
250        label: Some("valo.host_buffer.uniforms"),
251        size,
252        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
253        mapped_at_creation: false,
254    });
255    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
256        label: Some("valo.host_buffer.uniforms"),
257        layout,
258        entries: &[wgpu::BindGroupEntry {
259            binding: 0,
260            resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
261                buffer: &buffer,
262                offset: 0,
263                size: NonZeroU64::new(UNIFORM_SIZE),
264            }),
265        }],
266    });
267    Block {
268        buffer,
269        bind_group: Some(bind_group),
270        scratch: vec![0; size as usize],
271        used: 0,
272        idle: 0,
273    }
274}
275
276fn new_vertex_block(device: &wgpu::Device, size: u64) -> Block {
277    let buffer = device.create_buffer(&wgpu::BufferDescriptor {
278        label: Some("valo.host_buffer.vertices"),
279        size,
280        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
281        mapped_at_creation: false,
282    });
283    Block {
284        buffer,
285        bind_group: None,
286        scratch: vec![0; size as usize],
287        used: 0,
288        idle: 0,
289    }
290}
291
292fn write_scratch(block: &mut Block, offset: u64, bytes: &[u8]) {
293    block.scratch[offset as usize..offset as usize + bytes.len()].copy_from_slice(bytes);
294    block.used = block.used.max(offset + bytes.len() as u64);
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn headless() -> Option<wgpu::Device> {
302        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
303        let adapter =
304            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
305                .ok()?;
306        let (device, _queue) =
307            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
308        Some(device)
309    }
310
311    /// The B2 repro: a big retained block followed by a small one, revisited
312    /// next ring pass with allocations that fit the BIG block's remaining
313    /// space. The old fit check judged by the incoming allocation's would-be
314    /// block size, evicted to the small block, and overran its scratch.
315    #[test]
316    fn retained_mixed_size_blocks_never_overrun() {
317        let Some(device) = headless() else {
318            eprintln!("SKIP retained_mixed_size_blocks_never_overrun: no GPU adapter");
319            return;
320        };
321        let mut host = HostBuffer::new(&device);
322        host.begin_frame();
323        // Ring slot N: one oversized dedicated block, then a default block.
324        host.alloc_vertices(&vec![1u8; 1024 * 1024]);
325        host.alloc_vertices(&[2u8; 64]);
326        // Come back around to the same ring slot (blocks are retained).
327        for _ in 0..FRAMES {
328            host.begin_frame();
329        }
330        let a = host.alloc_vertices(&vec![3u8; 200 * 1024]);
331        let b = host.alloc_vertices(&vec![4u8; 800 * 1024]); // used to panic
332        assert_eq!(a.block, 0);
333        assert_eq!(b.block, 0, "800 KB still fits the 1 MB block");
334
335        // And a genuine overflow walks PAST the small block into a fresh
336        // right-sized one instead of overrunning it.
337        let c = host.alloc_vertices(&vec![5u8; 900 * 1024]);
338        assert_eq!(c.bytes, 900 * 1024);
339        assert!(c.block >= 2, "small retained block is skipped, not overrun");
340    }
341}