teksilo_render/stream_buffer.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Persistent per-pipeline streaming buffers.
5//!
6//! Each frame, the renderer accumulates vertex data into per-pipeline
7//! batches and flushes them to the GPU. The historical code path called
8//! `device.create_buffer_init` on every flush, allocating and dropping
9//! a fresh `wgpu::Buffer` pair (vertex + index) per batch — a known
10//! antipattern that causes driver allocation pressure at high glyph
11//! counts.
12//!
13//! [`StreamBuffer`] owns a single growable GPU buffer that persists
14//! across frames. [`StreamBuffer::ensure_capacity`] is called once per
15//! frame with the worst-case byte count (requires `&mut self`), growing
16//! the underlying buffer if needed. After that, [`StreamBuffer::write`]
17//! copies a batch to the GPU at the current write offset and advances
18//! the cursor — all via interior mutability, so calls can interleave
19//! freely with other `&self` methods on the renderer.
20
21use std::cell::Cell;
22
23/// A growable, ring-reset GPU buffer for streaming vertex or index data
24/// across contiguous batches within a single frame.
25///
26/// Capacity growth requires `&mut self`; the per-batch `write` path only
27/// needs `&self` thanks to a `Cell<u64>` cursor, so it composes cleanly
28/// with methods that borrow other parts of the renderer immutably.
29pub struct StreamBuffer {
30 buffer: Option<wgpu::Buffer>,
31 capacity_bytes: u64,
32 write_offset: Cell<u64>,
33 usage: wgpu::BufferUsages,
34 label: &'static str,
35}
36
37impl StreamBuffer {
38 pub fn new(usage: wgpu::BufferUsages, label: &'static str) -> Self {
39 Self {
40 buffer: None,
41 capacity_bytes: 0,
42 write_offset: Cell::new(0),
43 usage,
44 label,
45 }
46 }
47
48 /// Reset the write cursor to the start of the buffer.
49 ///
50 /// Call once at the top of `render()` — previous frame's contents
51 /// are orphaned and overwritten in place.
52 pub fn reset(&self) {
53 self.write_offset.set(0);
54 }
55
56 /// Grow the underlying buffer if `required_bytes` exceeds the current
57 /// capacity. Growth rounds up to the next power of two (minimum 1 KiB)
58 /// to amortize reallocation. If the existing buffer already fits, this
59 /// is a no-op.
60 pub fn ensure_capacity(&mut self, device: &wgpu::Device, required_bytes: u64) {
61 if required_bytes <= self.capacity_bytes && self.buffer.is_some() {
62 return;
63 }
64 let new_cap = required_bytes.max(1024).next_power_of_two();
65 self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
66 label: Some(self.label),
67 size: new_cap,
68 usage: self.usage | wgpu::BufferUsages::COPY_DST,
69 mapped_at_creation: false,
70 }));
71 self.capacity_bytes = new_cap;
72 }
73
74 /// Upload `data` at the current write cursor and advance the cursor.
75 ///
76 /// Returns `Some((buffer, offset, len_bytes))` on success, or `None`
77 /// if [`ensure_capacity`](Self::ensure_capacity) was never called OR
78 /// the write would overflow the buffer's capacity. The caller should
79 /// slice the returned buffer at `offset..offset + len` when binding.
80 ///
81 /// An overflow means the frame-start sizing undercounted this
82 /// pipeline's quads (see `stream_quad_counts` in `renderer.rs`).
83 /// Debug builds assert with the accounting details; release builds
84 /// skip the batch — a dropped draw is recoverable, whereas
85 /// forwarding an out-of-bounds `write_buffer` to wgpu is a
86 /// validation error that kills the device.
87 pub fn write(&self, queue: &wgpu::Queue, data: &[u8]) -> Option<(&wgpu::Buffer, u64, u64)> {
88 let buf = self.buffer.as_ref()?;
89 let offset = self.write_offset.get();
90 let len = data.len() as u64;
91 if offset + len > self.capacity_bytes {
92 debug_assert!(
93 false,
94 "StreamBuffer overflow: {} + {} > {} ({}) — frame-start quad count \
95 undercounted this pipeline; the batch is dropped",
96 offset, len, self.capacity_bytes, self.label
97 );
98 return None;
99 }
100 queue.write_buffer(buf, offset, data);
101 self.write_offset.set(offset + len);
102 Some((buf, offset, len))
103 }
104}
105
106/// All per-pipeline streaming buffers owned by a `Renderer`.
107///
108/// Grouped so the renderer can `reset()` them all at once and so macros
109/// can pass a single handle around the render loop.
110pub struct StreamBuffers {
111 pub rect: StreamBuffer,
112 pub sdf: StreamBuffer,
113 pub quad: StreamBuffer,
114 pub shadow: StreamBuffer,
115 /// Shader-driven animated-quad vertices (procedural pipeline —
116 /// IndeterminateSweep, future Pulse / Shimmer). Per-frame uniform
117 /// state lives in a separate `wgpu::Buffer` owned by `Renderer`.
118 pub anim_proc: StreamBuffer,
119 /// Gradient-filled path vertices (Tier 3, `path_gradient_pipeline`).
120 /// Solid-filled paths still stream through `quad` above; only
121 /// `PathEntry`s whose `paint_data` is a gradient variant land here.
122 pub path_gradient: StreamBuffer,
123 /// Shared index buffer — quad indices are deterministic so one buffer
124 /// serves every pipeline that renders quads.
125 pub index: StreamBuffer,
126}
127
128impl StreamBuffers {
129 pub fn new() -> Self {
130 Self {
131 rect: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "rect_stream"),
132 sdf: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "sdf_stream"),
133 quad: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "quad_stream"),
134 shadow: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "shadow_stream"),
135 anim_proc: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "anim_proc_stream"),
136 path_gradient: StreamBuffer::new(wgpu::BufferUsages::VERTEX, "path_gradient_stream"),
137 index: StreamBuffer::new(wgpu::BufferUsages::INDEX, "index_stream"),
138 }
139 }
140
141 pub fn reset(&self) {
142 self.rect.reset();
143 self.sdf.reset();
144 self.quad.reset();
145 self.shadow.reset();
146 self.anim_proc.reset();
147 self.path_gradient.reset();
148 self.index.reset();
149 }
150}
151
152impl Default for StreamBuffers {
153 fn default() -> Self {
154 Self::new()
155 }
156}