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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
//! Persistent per-frame-slot upload buffer for the composite pass's transient
//! HUD text geometry. Every label used to mint a vertex and an index buffer with
//! `newBufferWithBytes` during the composite encode: two driver allocations per
//! label per frame, each retained by the committed command buffer until the GPU
//! retired the frame.
//!
//! Instead each frame-in-flight slot keeps one `StorageModeShared` buffer. The
//! frame's whole text geometry is written into this frame's slot before the
//! render graph runs, each label's vertex and index block at a rolling aligned
//! offset, and the composite pass binds sub-ranges of that one buffer. A slot
//! grows power-of-two on demand and is never shrunk, so steady state does zero
//! allocation. The frames-in-flight fence guarantees frame `R - depth` retired
//! before frame `R` reuses slot `R % depth`, so overwriting a slot never races
//! an in-flight GPU read -- the same argument as `TransientRing` in
//! `metal/frame_rings.rs`.
//!
//! Mirrors `directx/upload_ring.rs` and `vulkan/upload_ring.rs`. Metal writes the
//! frame's geometry up front rather than appending during the encode because the
//! pass encoders run through `&MtlContext`, whose parallel-encode contract is
//! read-only field access (see `metal/parallel_encoder.rs`).
use concinnity_core::gfx::render_types::TextDrawCall;
use concinnity_core::render::error::RenderResult;
use concinnity_core::render::fullscreen;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_metal::{MTLBuffer, MTLDevice, MTLResourceOptions};
use super::context::write_buffer_region;
use super::error::allocation_failed;
use super::frame_rings::grow_to;
// Sub-range alignment. 256 bytes satisfies the strictest offset rule a Metal
// buffer binding can face, and costs a HUD's worth of labels a few kilobytes.
const TEXT_UPLOAD_ALIGN: u64 = 256;
// Where one text call's vertex and index blocks sit in the frame's buffer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct TextRange {
pub(super) vertex_offset: usize,
pub(super) index_offset: usize,
}
fn align_up(offset: usize) -> usize {
fullscreen::align_up(offset as u64, TEXT_UPLOAD_ALIGN) as usize
}
// Lay out every call's vertex and index block back to back at aligned offsets,
// filling `out` with one range per call (in `text_calls` order, so the encoder
// indexes it alongside the calls), and return the total byte size. Never exceeds
// `fullscreen::text_upload_bytes` for the same calls and alignment.
fn plan_text_upload(text_calls: &[TextDrawCall], out: &mut Vec<TextRange>) -> usize {
out.clear();
out.reserve(text_calls.len());
let mut cursor = 0usize;
for call in text_calls {
let vertex_offset = align_up(cursor);
let index_offset =
align_up(vertex_offset + std::mem::size_of_val(call.vertices.as_slice()));
cursor = index_offset + std::mem::size_of_val(call.indices.as_slice());
out.push(TextRange {
vertex_offset,
index_offset,
});
}
cursor
}
// One shared-storage buffer per frame-in-flight slot, holding a whole frame's
// text geometry.
pub(super) struct TextUploadRing {
slots: Vec<Option<Retained<ProtocolObject<dyn MTLBuffer>>>>,
// This frame's per-call block offsets, parallel to the frame's text calls.
// Kept across frames so the per-frame layout reuses one heap allocation.
ranges: Vec<TextRange>,
// The slot [`Self::binding`] hands out, `None` until a frame with text has
// been uploaded (and again on any frame that carries none).
frame_slot: Option<usize>,
}
impl TextUploadRing {
// `depth` is the frames-in-flight count; clamped to >= 1. Buffers are
// allocated lazily on first use of each slot.
pub(super) fn new(depth: usize) -> Self {
Self {
slots: (0..depth.max(1)).map(|_| None).collect(),
ranges: Vec::new(),
frame_slot: None,
}
}
// Copy this frame's text geometry into `slot`'s buffer, growing it first if
// the frame outgrew it. Call once per frame, after the frames-in-flight
// fence has confirmed the GPU is done with this slot and before the frame's
// composite pass encodes.
pub(super) fn upload(
&mut self,
device: &ProtocolObject<dyn MTLDevice>,
slot: usize,
text_calls: &[TextDrawCall],
) -> RenderResult<()> {
let Self {
slots,
ranges,
frame_slot,
} = self;
*frame_slot = None;
let total = plan_text_upload(text_calls, ranges);
if total == 0 {
return Ok(());
}
let idx = slot % slots.len();
let have = slots[idx].as_ref().map_or(0, |buf| buf.length());
if let Some(capacity) = grow_to(have, total) {
slots[idx] = Some(
device
.newBufferWithLength_options(capacity, MTLResourceOptions::StorageModeShared)
.ok_or_else(|| allocation_failed("text upload buffer"))?,
);
}
let buffer = slots[idx]
.as_ref()
.expect("text ring slot was just ensured");
for (call, range) in text_calls.iter().zip(ranges.iter()) {
write_buffer_region(
buffer,
range.vertex_offset,
bytemuck::cast_slice(&call.vertices),
)?;
write_buffer_region(
buffer,
range.index_offset,
bytemuck::cast_slice(&call.indices),
)?;
}
*frame_slot = Some(idx);
Ok(())
}
// This frame's buffer plus one block range per text call, or `None` when the
// frame uploaded no text geometry.
pub(super) fn binding(&self) -> Option<(&ProtocolObject<dyn MTLBuffer>, &[TextRange])> {
let buffer = self.slots[self.frame_slot?].as_ref()?;
Some((buffer, &self.ranges))
}
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::gfx::render_types::TextVertex;
// A call carrying `glyphs` quads: 4 vertices + 6 indices each, the shape
// `gfx::text::build_text_calls` emits.
fn glyph_call(glyphs: usize) -> TextDrawCall {
TextDrawCall {
vertices: vec![
TextVertex {
pos: [0.0; 2],
uv: [0.0; 2],
color: [0.0; 3],
mode: 0.0,
};
glyphs * 4
],
indices: vec![0u16; glyphs * 6],
atlas_slot: 0,
clip_rect: None,
layer: 0,
}
}
#[test]
fn planning_no_calls_needs_no_buffer() {
let mut ranges = Vec::new();
assert_eq!(plan_text_upload(&[], &mut ranges), 0);
assert!(ranges.is_empty());
}
#[test]
fn planning_emits_one_range_per_call() {
let calls = [glyph_call(2), glyph_call(0), glyph_call(5)];
let mut ranges = Vec::new();
plan_text_upload(&calls, &mut ranges);
assert_eq!(ranges.len(), calls.len());
}
#[test]
fn planned_blocks_are_aligned_and_ordered() {
let calls = [glyph_call(1), glyph_call(9), glyph_call(3)];
let mut ranges = Vec::new();
let total = plan_text_upload(&calls, &mut ranges);
let align = TEXT_UPLOAD_ALIGN as usize;
let mut prev_end = 0usize;
for (call, range) in calls.iter().zip(ranges.iter()) {
assert_eq!(range.vertex_offset % align, 0);
assert_eq!(range.index_offset % align, 0);
// Blocks never overlap the previous one, and stay inside the total.
assert!(range.vertex_offset >= prev_end);
let vertex_end = range.vertex_offset + std::mem::size_of_val(call.vertices.as_slice());
assert!(range.index_offset >= vertex_end);
prev_end = range.index_offset + std::mem::size_of_val(call.indices.as_slice());
assert!(prev_end <= total);
}
}
// The layout has to fit inside what the shared reservation helper reports,
// which is what every other backend sizes its slot with.
#[test]
fn planned_total_fits_the_shared_reservation() {
let calls = [glyph_call(4), glyph_call(1), glyph_call(120)];
let mut ranges = Vec::new();
let total = plan_text_upload(&calls, &mut ranges) as u64;
let reserved = fullscreen::text_upload_bytes(&calls, TEXT_UPLOAD_ALIGN);
assert!(
total <= reserved,
"planned {total} exceeded reserved {reserved}"
);
}
#[test]
fn replanning_drops_the_previous_frames_ranges() {
let mut ranges = Vec::new();
plan_text_upload(&[glyph_call(2), glyph_call(2)], &mut ranges);
assert_eq!(ranges.len(), 2);
plan_text_upload(&[glyph_call(1)], &mut ranges);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].vertex_offset, 0);
}
}