1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
//! Persistent per-frame-slot upload buffers for the composite pass's transient
//! HUD text geometry. Creating a vertex and an index buffer per label per frame
//! put two suballocator allocations (and two `VkBuffer` creations) on the hot
//! path for every line of HUD text; a bistro-sized overlay pays that dozens of
//! times a frame.
//!
//! Instead each frame-in-flight slot keeps one host-visible, persistently mapped
//! buffer. Every frame the slot's cursor resets to zero and each block of
//! geometry is appended at a rolling, aligned offset; the draw binds a sub-range
//! of the shared buffer. A slot is reallocated only when a frame's geometry
//! exceeds its capacity, which after warm-up never happens. The frame fence
//! (waited before a slot is reused) guarantees the GPU has finished reading a
//! slot's buffer before the CPU overwrites or replaces it.
//!
//! Mirrors `directx/upload_ring.rs`; `metal/text_upload.rs` does the same job
//! against one `StorageModeShared` buffer per slot.
use ash::vk;
use concinnity_core::render::buffer_growth::grow_capacity;
use concinnity_core::render::error::RenderResult;
use concinnity_core::render::fullscreen::align_up;
use std::cell::RefCell;
use super::allocator::{DeviceAllocator, PooledBuffer};
// Sub-range alignment. 256 bytes satisfies every offset rule a bound
// vertex / index sub-range can face, MoltenVK's translation to Metal's
// stricter buffer-offset alignment included, and costs a HUD's worth of
// labels a few kilobytes.
pub(in crate::vulkan) const UPLOAD_ALIGN: u64 = 256;
// First-allocation capacity for a slot's buffer. A HUD's worth of text is a few
// kilobytes, so this avoids any growth in practice while staying tiny.
const UPLOAD_MIN_CAPACITY: u64 = 64 * 1024;
// One frame slot's persistently mapped upload buffer. `buffer` is null until the
// slot's first reservation.
struct Slot {
buffer: PooledBuffer,
capacity: u64,
cursor: u64,
}
impl Slot {
fn empty() -> Self {
Slot {
buffer: PooledBuffer::null(),
capacity: 0,
cursor: 0,
}
}
}
// A persistently mapped upload buffer per frame-in-flight slot for transient
// geometry. Interior-mutable because the pass encoders run through `&self`; only
// the composite pass touches it, and that pass stays on the main thread (see
// `vulkan/parallel_encoder.rs`).
pub(in crate::vulkan) struct UploadRing {
slots: Vec<RefCell<Slot>>,
}
impl UploadRing {
pub(in crate::vulkan) fn new(frames: usize) -> Self {
UploadRing {
slots: (0..frames.max(1))
.map(|_| RefCell::new(Slot::empty()))
.collect(),
}
}
// Begin a frame for `frame`'s slot: reset the write cursor and ensure the
// buffer holds at least `needed` bytes, reallocating it if not. The caller
// must invoke this once per frame before any `push`, after the frame fence
// has confirmed the GPU is done with this slot.
pub(in crate::vulkan) fn reserve(
&self,
alloc: &DeviceAllocator,
frame: usize,
needed: u64,
) -> RenderResult<()> {
let mut slot = self.slots[frame % self.slots.len()].borrow_mut();
slot.cursor = 0;
let Some(new_cap) = grow_capacity(slot.capacity, needed, UPLOAD_MIN_CAPACITY) else {
return Ok(());
};
let buffer = alloc.create_buffer(
new_cap,
vk::BufferUsageFlags::VERTEX_BUFFER | vk::BufferUsageFlags::INDEX_BUFFER,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)?;
if buffer.mapped_ptr().is_null() {
return Err(concinnity_core::render::error::RenderError::Other(
"text upload buffer is not host-mapped".to_string(),
));
}
// Replacing `buffer` retires the old one through the allocator, which
// withholds its range until every in-flight frame has passed.
slot.buffer = buffer;
slot.capacity = new_cap;
Ok(())
}
// Append `bytes` at the slot's next aligned offset and return the buffer to
// bind plus the offset of the copy. Errors if the running total would exceed
// the reserved capacity, which cannot happen when `reserve` was called with
// the aligned-block sum of the same blocks.
pub(in crate::vulkan) fn push(
&self,
frame: usize,
bytes: &[u8],
) -> RenderResult<(vk::Buffer, vk::DeviceSize)> {
let mut slot = self.slots[frame % self.slots.len()].borrow_mut();
let offset = align_up(slot.cursor, UPLOAD_ALIGN);
let end = offset + bytes.len() as u64;
if end > slot.capacity {
return Err(concinnity_core::render::error::RenderError::Other(format!(
"text upload ring overflow: need {end} bytes, reserved {}",
slot.capacity
)));
}
slot.buffer.write_bytes(offset as usize, bytes);
slot.cursor = end;
Ok((slot.buffer.buffer(), offset))
}
// Release every slot's buffer. Called from the context teardown while the
// allocator is still alive, so each buffer's range returns to its block.
pub(in crate::vulkan) fn destroy(&self) {
for slot in &self.slots {
*slot.borrow_mut() = Slot::empty();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// The composite pass reserves `text_upload_bytes` for the frame and then
// appends each block at the ring's alignment: the reservation has to bound
// the cursor for every block sequence, or a push could overflow mid-frame.
#[test]
fn reserved_bytes_bound_the_ring_cursor() {
let blocks: [u64; 5] = [128, 12, 4096, 1, 255];
let total: u64 = blocks.iter().map(|&n| align_up(n, UPLOAD_ALIGN)).sum();
let mut cursor = 0u64;
for &n in &blocks {
cursor = align_up(cursor, UPLOAD_ALIGN) + n;
assert!(cursor <= total, "cursor {cursor} exceeded reserved {total}");
}
}
}