concinnity_core/render/fullscreen.rs
1//! Backend-agnostic fullscreen-pass encoder seam, the first pilot of a hardware
2//! abstraction layer over the three render backends. The bloom
3//! prefilter -> downsample -> upsample chain is structurally identical on every
4//! backend, so its orchestration lives here once and each backend implements
5//! `BloomEncoder` to bind + draw one sub-pass in its own command stream.
6//!
7//! Two associated types absorb the only real divergence, so the trait names no
8//! backend types: `Rec` hides the per-backend command recorder, and `Args`
9//! carries the per-invocation binding context (DirectX passes the scene-colour
10//! SRV its prefilter samples; Vulkan threads the frame-in-flight index that
11//! selects its per-frame framebuffers + descriptor sets). Everything else each
12//! impl reads from `&self`, consistent with the read-only parallel-encode
13//! contract.
14//!
15//! Implemented by DirectX + Vulkan. Metal keeps its hand-rolled `encode_bloom`,
16//! already factored through its own `fullscreen_pass`, so this seam is unused
17//! (dead code) on a Metal build.
18
19use crate::gfx::render_types::TextDrawCall;
20use crate::math::{ceil, floor};
21use alloc::string::String;
22
23/// Convert a `TextDrawCall.clip_rect` (a rectangle `[x, y, w, h]` in overlay
24/// units, already mapped through the overlay transform by
25/// `gfx::text::band_to_window`) into an integer scissor rect `(x, y, w, h)` in
26/// attachment pixels, clamped to the attachment's bounds. Returns `None` when the
27/// clamped rectangle is empty (a row scrolled fully out of its band), so the
28/// caller skips the draw entirely.
29///
30/// `ui` is the overlay's logical size (see `RenderBackend::logical_size`) and
31/// `attach` the pixel size of the target the text pass writes. The two are equal
32/// wherever a window's logical units are pixels (Windows, unscaled X11), leaving
33/// a pure clamp; on a hi-DPI surface (macOS retina, scaled Wayland) the
34/// attachment is larger by the backing scale and the rect scales up with it. A
35/// zero logical dimension (minimised / mid-resize) falls back to a 1.0 scale
36/// rather than dividing by zero.
37pub fn clip_rect_to_scissor(
38 clip: [f32; 4],
39 ui: (f32, f32),
40 attach: (u32, u32),
41) -> Option<(i32, i32, u32, u32)> {
42 let aw = attach.0 as f32;
43 let ah = attach.1 as f32;
44 let sx = if ui.0 > 0.0 { aw / ui.0 } else { 1.0 };
45 let sy = if ui.1 > 0.0 { ah / ui.1 } else { 1.0 };
46 let x0 = floor(clip[0] * sx).clamp(0.0, aw);
47 let y0 = floor(clip[1] * sy).clamp(0.0, ah);
48 let x1 = ceil((clip[0] + clip[2]) * sx).clamp(0.0, aw);
49 let y1 = ceil((clip[1] + clip[3]) * sy).clamp(0.0, ah);
50 if x1 <= x0 || y1 <= y0 {
51 return None;
52 }
53 Some((x0 as i32, y0 as i32, (x1 - x0) as u32, (y1 - y0) as u32))
54}
55
56/// Round `offset` up to the next multiple of `align` (a power of two).
57pub fn align_up(offset: u64, align: u64) -> u64 {
58 (offset + align - 1) & !(align - 1)
59}
60
61/// Total bytes a frame's text geometry occupies in a backend's per-frame upload
62/// buffer, once each label's vertex and index blocks start on an `align`-byte
63/// boundary. Every sub-allocation aligns its start up and a prior aligned start
64/// plus an aligned size stays aligned, so this sum is an exact upper bound on the
65/// buffer cursor after all of a frame's blocks are appended: a slot reserved to
66/// it can never overflow mid-frame.
67///
68/// `align` is per backend: the alignment its buffer bindings require of a
69/// sub-range's offset.
70pub fn text_upload_bytes(text_calls: &[TextDrawCall], align: u64) -> u64 {
71 text_calls
72 .iter()
73 .map(|c| {
74 let v = core::mem::size_of_val(c.vertices.as_slice()) as u64;
75 let i = core::mem::size_of_val(c.indices.as_slice()) as u64;
76 align_up(v, align) + align_up(i, align)
77 })
78 .sum()
79}
80
81/// Per-backend hooks the shared bloom driver encodes through.
82pub trait BloomEncoder {
83 /// Per-backend command recorder (DX `ID3D12GraphicsCommandList`, VK `vk::CommandBuffer`).
84 type Rec;
85 /// Per-invocation binding context (DX scene-colour SRV handle, VK frame index).
86 type Args;
87
88 /// Number of bloom mips; zero means bloom is off and the driver no-ops.
89 fn bloom_mip_count(&self) -> usize;
90 /// One-time per-encode preamble, run once before the sub-passes and on the
91 /// same recorder: state every sub-pass shares belongs here, not in the
92 /// per-mip hooks (DX root signature / heap / IA state and the post-process
93 /// root constants; VK the post-process push constants).
94 fn begin_bloom(&self, rec: &Self::Rec, args: &Self::Args);
95 /// Prefilter: scene colour -> mip 0 (soft-knee threshold + Karis average).
96 fn bloom_prefilter(&self, rec: &Self::Rec, args: &Self::Args);
97 /// Downsample: mip `dst - 1` -> mip `dst`.
98 fn bloom_downsample(&self, rec: &Self::Rec, args: &Self::Args, dst: usize);
99 /// Upsample: mip `dst + 1` -> mip `dst`, additively blended.
100 fn bloom_upsample(&self, rec: &Self::Rec, args: &Self::Args, dst: usize);
101}
102
103/// The bloom chain orchestration, previously hand-duplicated in each backend's
104/// `encode_bloom`. On return, mip 0 holds the accumulated glow the composite pass
105/// samples.
106pub fn encode_bloom_chain<E: BloomEncoder>(enc: &E, rec: &E::Rec, args: E::Args) {
107 let n = enc.bloom_mip_count();
108 if n == 0 {
109 return;
110 }
111 enc.begin_bloom(rec, &args);
112 // Prefilter: scene -> mip 0.
113 enc.bloom_prefilter(rec, &args);
114 // Downsample chain: mip i-1 -> mip i.
115 for dst in 1..n {
116 enc.bloom_downsample(rec, &args, dst);
117 }
118 // Upsample chain: mip i+1 -> mip i, walking back down to mip 0.
119 for dst in (0..n - 1).rev() {
120 enc.bloom_upsample(rec, &args, dst);
121 }
122}
123
124/// The text-overlay state one draw call would set that the previous call in the
125/// same pass already left bound. A heads-up display is overwhelmingly many labels
126/// sharing one atlas and one full-window scissor, so tracking the last value bound
127/// turns a per-label bind into a per-change bind.
128///
129/// The scissor is the canonical `(x, y, w, h)` in attachment pixels that
130/// [`clip_rect_to_scissor`] returns and every backend's own rect type converts
131/// from, so one cache serves all three backends.
132///
133/// A cache starts empty rather than seeded with whatever the pass began with, so
134/// the first call of each kind always binds and the cache never has to assume what
135/// the surrounding pass left in place.
136///
137/// Both queries record as they answer, so a caller must ask only where it goes on
138/// to bind: asking and then skipping the bind desynchronises the cache from the
139/// recorder.
140#[derive(Default)]
141pub struct TextBindCache {
142 atlas: Option<usize>,
143 scissor: Option<(i32, i32, u32, u32)>,
144}
145
146impl TextBindCache {
147 /// An empty cache: the first query of each kind reports a change.
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 /// Whether the atlas at `idx` still needs binding, recording it as bound.
153 pub fn atlas_changed(&mut self, idx: usize) -> bool {
154 self.atlas.replace(idx) != Some(idx)
155 }
156
157 /// Whether `rect` still needs setting as the scissor, recording it as set.
158 pub fn scissor_changed(&mut self, rect: (i32, i32, u32, u32)) -> bool {
159 self.scissor.replace(rect) != Some(rect)
160 }
161}
162
163/// The composite pass: tonemap (+ optional LUT grade) the post-stack scene onto
164/// the swapchain image, then layer the text overlay on top in the same pass. Its
165/// begin -> composite-draw -> text-loop -> end shape is identical on every
166/// backend; the swapchain target lifecycle, the descriptor binding, and the
167/// text-geometry uploads stay backend-specific behind the trait. `Args`
168/// carries the per-frame binding context each backend needs (DX: the swapchain
169/// back-buffer + its RTV, the scene SRV, the window size, the frame slot; VK: the
170/// acquired image index + the frame slot).
171///
172/// Every backend uploads a frame's text geometry into one persistent buffer per
173/// frame-in-flight slot, reserved up front with [`text_upload_bytes`] and
174/// appended to per call, and binds sub-ranges of it: no GPU buffer is created
175/// per label per frame anywhere. DX and VK append inside `text_draw`; Metal
176/// (which drives its own composite loop rather than this trait) writes the whole
177/// frame's geometry into its slot before the render graph runs.
178pub trait CompositeEncoder {
179 /// Per-backend command recorder (DX `ID3D12GraphicsCommandList`, VK `vk::CommandBuffer`).
180 type Rec;
181 /// Per-invocation binding context (see the trait doc).
182 type Args;
183
184 /// Begin the pass: target the swapchain image (DX transitions it to
185 /// RENDER_TARGET + binds the RTV; VK begins the composite render pass) and set
186 /// the full-window viewport / scissor.
187 fn begin_composite(&self, rec: &Self::Rec, args: &Self::Args);
188 /// The fullscreen tonemap draw: bind the composite pipeline + its inputs
189 /// (scene, bloom, LUT) + push constants, draw the fullscreen triangle.
190 fn composite_draw(&self, rec: &Self::Rec, args: &Self::Args);
191 /// Bind the text pipeline + any one-time text state. Returns false when text
192 /// is inert (no pipeline or no atlases), so the driver skips the call loop.
193 fn begin_text(&self, rec: &Self::Rec, args: &Self::Args) -> bool;
194 /// Encode one text draw call: append its vertex/index geometry to this frame
195 /// slot's persistent upload buffer, bind the atlas plus the two sub-ranges,
196 /// and draw. `cache` carries what the previous call in this pass left bound;
197 /// consult it for the atlas and the scissor so a run of labels sharing either
198 /// binds it once (see [`TextBindCache`]).
199 fn text_draw(
200 &self,
201 rec: &Self::Rec,
202 args: &Self::Args,
203 call: &TextDrawCall,
204 cache: &mut TextBindCache,
205 ) -> Result<(), String>;
206 /// End the pass: DX transitions the back-buffer back to PRESENT; VK ends the
207 /// render pass.
208 fn end_composite(&self, rec: &Self::Rec, args: &Self::Args);
209}
210
211/// The composite + text orchestration, previously hand-duplicated in each
212/// backend's `encode_composite_and_text`. An error mid-text propagates without
213/// closing the pass, matching the prior DX/VK behaviour (the frame fails either
214/// way: the target is just left mis-stated). This is unused on Metal, where a
215/// render encoder must be `endEncoding`-ed before the command buffer commits:
216/// skipping `end_composite` on a text error would crash at commit, so Metal's
217/// `encode_composite_and_text` ends the encoder on any `?` with a `ScopedEncoder`
218/// RAII guard instead.
219pub fn encode_composite_chain<E: CompositeEncoder>(
220 enc: &E,
221 rec: &E::Rec,
222 args: &E::Args,
223 text_calls: &[TextDrawCall],
224) -> Result<(), String> {
225 enc.begin_composite(rec, args);
226 enc.composite_draw(rec, args);
227 if !text_calls.is_empty() && enc.begin_text(rec, args) {
228 // One cache per pass: the text calls are encoded back to back into the
229 // same recorder, so what one call binds is still bound for the next.
230 let mut cache = TextBindCache::new();
231 for call in text_calls {
232 enc.text_draw(rec, args, call, &mut cache)?;
233 }
234 }
235 enc.end_composite(rec, args);
236 Ok(())
237}
238
239/// A single-draw fullscreen post pass (SSR resolve, TAA resolve, ...): target a
240/// render target, bind a pipeline + inputs, draw one fullscreen triangle, restore.
241/// Unlike the bloom + composite chains (whose drivers hold a mip / text loop), a
242/// fullscreen pass has no loop, so the driver is a fixed begin -> draw -> end. The
243/// value is the shared per-backend lifecycle factored behind begin/end (DX: the
244/// PSR<->RENDER_TARGET barrier bracket + render-target bind; VK: the render-pass
245/// bracket), reused across every such pass instead of re-pasted per pass.
246///
247/// The inert-pass guard lives at each backend's call site: it resolves the pass's
248/// resources (returning early if a required one is absent) BEFORE constructing the
249/// encoder, so the driver always runs all three steps over a fully-resolved pass
250/// and can never leave a render pass / barrier half-open. There is no `Args`: each
251/// backend's encoder is a small struct holding the already-resolved references +
252/// per-call scalars, so the trait names no backend types (like `BloomEncoder`).
253///
254/// Implemented by DirectX + Vulkan. Metal keeps its own `fullscreen_pass` helper,
255/// which already factors this begin/draw/end skeleton, so this seam is unused
256/// (dead code) on a Metal build.
257pub trait FullscreenPass {
258 /// Per-backend command recorder (DX `ID3D12GraphicsCommandList`, VK `vk::CommandBuffer`).
259 type Rec;
260
261 /// Begin: bind the target render target + set the full-resolution viewport /
262 /// scissor. DX transitions the target PIXEL_SHADER_RESOURCE -> RENDER_TARGET,
263 /// binds its RTV + the SRV heap; VK begins the pass's render pass.
264 fn begin(&self, rec: &Self::Rec);
265 /// Bind the pipeline + inputs + per-frame params and draw the fullscreen
266 /// triangle (3 vertices; the vertex shader builds the triangle from the id).
267 fn draw(&self, rec: &Self::Rec);
268 /// End: DX transitions the target back to PIXEL_SHADER_RESOURCE; VK ends the
269 /// render pass.
270 fn end(&self, rec: &Self::Rec);
271}
272
273/// The fullscreen-pass orchestration. Trivial by design (a single draw), but kept
274/// as a driver so every fullscreen post pass shares one begin -> draw -> end
275/// contract across backends, matching `encode_bloom_chain` / `encode_composite_chain`.
276pub fn encode_fullscreen<E: FullscreenPass>(enc: &E, rec: &E::Rec) {
277 enc.begin(rec);
278 enc.draw(rec);
279 enc.end(rec);
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::gfx::render_types::TextDrawCall;
286 use core::cell::RefCell;
287
288 use alloc::format;
289 use alloc::string::ToString;
290 use alloc::vec;
291 use alloc::vec::Vec;
292 #[test]
293 fn clip_inside_attachment_passes_through() {
294 // Logical units are attachment pixels (Windows, unscaled X11): 1:1.
295 assert_eq!(
296 clip_rect_to_scissor([100.0, 50.0, 300.0, 200.0], (1280.0, 720.0), (1280, 720)),
297 Some((100, 50, 300, 200))
298 );
299 }
300
301 #[test]
302 fn clip_scales_from_logical_units_to_a_hi_dpi_attachment() {
303 // A 2x backing scale (macOS retina, scaled Wayland): the band covers the
304 // same fraction of an attachment twice the logical size.
305 assert_eq!(
306 clip_rect_to_scissor([100.0, 50.0, 300.0, 200.0], (1024.0, 768.0), (2048, 1536)),
307 Some((200, 100, 600, 400))
308 );
309 // A non-integer scale still lands on whole pixels, rounded outward so a
310 // band never crops the glyphs it should show.
311 assert_eq!(
312 clip_rect_to_scissor([10.0, 10.0, 100.0, 100.0], (1000.0, 1000.0), (1500, 1500)),
313 Some((15, 15, 150, 150))
314 );
315 }
316
317 #[test]
318 fn clip_is_clamped_to_attachment_bounds() {
319 // A band hanging off the right / bottom edge is clamped to the target.
320 assert_eq!(
321 clip_rect_to_scissor([1200.0, 700.0, 400.0, 400.0], (1280.0, 720.0), (1280, 720)),
322 Some((1200, 700, 80, 20))
323 );
324 // A negative origin is clamped to zero, shrinking the width/height.
325 assert_eq!(
326 clip_rect_to_scissor([-40.0, -10.0, 100.0, 100.0], (1280.0, 720.0), (1280, 720)),
327 Some((0, 0, 60, 90))
328 );
329 // The clamp is against the attachment, after scaling.
330 assert_eq!(
331 clip_rect_to_scissor([600.0, 350.0, 200.0, 200.0], (640.0, 360.0), (1280, 720)),
332 Some((1200, 700, 80, 20))
333 );
334 }
335
336 #[test]
337 fn fully_offscreen_clip_is_skipped() {
338 // A band entirely past the attachment yields no scissor (skip the draw).
339 assert_eq!(
340 clip_rect_to_scissor([2000.0, 50.0, 100.0, 100.0], (1280.0, 720.0), (1280, 720)),
341 None
342 );
343 // A zero-area band is also skipped.
344 assert_eq!(
345 clip_rect_to_scissor([10.0, 10.0, 0.0, 50.0], (1280.0, 720.0), (1280, 720)),
346 None
347 );
348 }
349
350 #[test]
351 fn a_zero_logical_size_falls_back_to_an_unscaled_clip() {
352 // Minimised / mid-resize: no divide by zero, and the rect is still
353 // clamped into the attachment.
354 assert_eq!(
355 clip_rect_to_scissor([10.0, 20.0, 100.0, 100.0], (0.0, 0.0), (1280, 720)),
356 Some((10, 20, 100, 100))
357 );
358 }
359
360 // A text-only draw call for the composite driver: the drivers never inspect
361 // its contents, so the geometry is empty.
362 fn text_call() -> TextDrawCall {
363 TextDrawCall {
364 vertices: Vec::new(),
365 indices: Vec::new(),
366 atlas_slot: 0,
367 clip_rect: None,
368 layer: 0,
369 }
370 }
371
372 // A call carrying `glyphs` quads: 4 vertices + 6 indices each, the shape
373 // `gfx::text::build_text_calls` emits.
374 fn glyph_call(glyphs: usize) -> TextDrawCall {
375 TextDrawCall {
376 vertices: vec![
377 crate::gfx::render_types::TextVertex {
378 pos: [0.0; 2],
379 uv: [0.0; 2],
380 color: [0.0; 3],
381 mode: 0.0,
382 };
383 glyphs * 4
384 ],
385 indices: vec![0u16; glyphs * 6],
386 atlas_slot: 0,
387 clip_rect: None,
388 layer: 0,
389 }
390 }
391
392 #[test]
393 fn align_up_rounds_to_multiple() {
394 assert_eq!(align_up(0, 16), 0);
395 assert_eq!(align_up(1, 16), 16);
396 assert_eq!(align_up(16, 16), 16);
397 assert_eq!(align_up(17, 16), 32);
398 assert_eq!(align_up(257, 256), 512);
399 }
400
401 #[test]
402 fn text_upload_bytes_is_zero_without_calls() {
403 assert_eq!(text_upload_bytes(&[], 256), 0);
404 // An empty call still contributes nothing: both blocks are zero bytes.
405 assert_eq!(text_upload_bytes(&[text_call()], 256), 0);
406 }
407
408 #[test]
409 fn text_upload_bytes_aligns_each_block() {
410 // One glyph: 4 * 32 B of vertices (already a multiple of 16) and 12 B of
411 // indices (rounded up).
412 assert_eq!(text_upload_bytes(&[glyph_call(1)], 16), 128 + 16);
413 assert_eq!(text_upload_bytes(&[glyph_call(1)], 256), 256 + 256);
414 }
415
416 // The reserved size must be an upper bound on the cursor after a run of
417 // appends (an aligned start plus an aligned size stays aligned), so a slot
418 // reserved to it can never overflow mid-frame.
419 #[test]
420 fn text_upload_bytes_bounds_a_simulated_cursor() {
421 let calls = [glyph_call(3), glyph_call(1), glyph_call(17), glyph_call(0)];
422 for align in [16u64, 256] {
423 let total = text_upload_bytes(&calls, align);
424 let mut cursor = 0u64;
425 for c in &calls {
426 for block in [
427 core::mem::size_of_val(c.vertices.as_slice()) as u64,
428 core::mem::size_of_val(c.indices.as_slice()) as u64,
429 ] {
430 cursor = align_up(cursor, align) + block;
431 assert!(cursor <= total, "cursor {cursor} exceeded reserved {total}");
432 }
433 }
434 }
435 }
436
437 // A mock bloom encoder recording each sub-pass in call order. The trait's
438 // associated types name no backend types, so both are `()`.
439 struct MockBloom {
440 mips: usize,
441 log: RefCell<Vec<String>>,
442 }
443
444 impl BloomEncoder for MockBloom {
445 type Rec = ();
446 type Args = ();
447
448 fn bloom_mip_count(&self) -> usize {
449 self.mips
450 }
451 fn begin_bloom(&self, _rec: &(), _args: &()) {
452 self.log.borrow_mut().push("begin".into());
453 }
454 fn bloom_prefilter(&self, _rec: &(), _args: &()) {
455 self.log.borrow_mut().push("prefilter".into());
456 }
457 fn bloom_downsample(&self, _rec: &(), _args: &(), dst: usize) {
458 self.log.borrow_mut().push(format!("down{dst}"));
459 }
460 fn bloom_upsample(&self, _rec: &(), _args: &(), dst: usize) {
461 self.log.borrow_mut().push(format!("up{dst}"));
462 }
463 }
464
465 #[test]
466 fn bloom_chain_encodes_prefilter_downsample_upsample_in_order() {
467 // 3 mips: prefilter, then the downsample chain 1..3, then the upsample
468 // chain walking back down (1, 0).
469 let enc = MockBloom {
470 mips: 3,
471 log: RefCell::new(Vec::new()),
472 };
473 encode_bloom_chain(&enc, &(), ());
474 assert_eq!(
475 *enc.log.borrow(),
476 ["begin", "prefilter", "down1", "down2", "up1", "up0"]
477 );
478 }
479
480 #[test]
481 fn bloom_chain_begins_once_whatever_the_mip_count() {
482 // Backends push the shared post-process constants in `begin_bloom` and
483 // rely on them surviving every sub-pass, so the preamble must run
484 // exactly once per chain, ahead of the first draw.
485 for mips in 1..8 {
486 let enc = MockBloom {
487 mips,
488 log: RefCell::new(Vec::new()),
489 };
490 encode_bloom_chain(&enc, &(), ());
491 let log = enc.log.borrow();
492 assert_eq!(log.iter().filter(|e| *e == "begin").count(), 1);
493 assert_eq!(log[0], "begin");
494 }
495 }
496
497 #[test]
498 fn bloom_chain_with_zero_mips_is_a_noop() {
499 // Bloom off: the driver returns before touching the encoder at all.
500 let enc = MockBloom {
501 mips: 0,
502 log: RefCell::new(Vec::new()),
503 };
504 encode_bloom_chain(&enc, &(), ());
505 assert!(enc.log.borrow().is_empty());
506 }
507
508 // A mock composite encoder. `text_ready` is the `begin_text` return; when
509 // `fail_at` matches a text-draw index that draw returns an error. `binds`
510 // records what the cache answered per call, so a test can see which calls
511 // would have rebound the atlas.
512 struct MockComposite {
513 text_ready: bool,
514 fail_at: Option<usize>,
515 log: RefCell<Vec<String>>,
516 text_seen: RefCell<usize>,
517 binds: RefCell<Vec<bool>>,
518 }
519
520 impl MockComposite {
521 fn new(text_ready: bool, fail_at: Option<usize>) -> Self {
522 Self {
523 text_ready,
524 fail_at,
525 log: RefCell::new(Vec::new()),
526 text_seen: RefCell::new(0),
527 binds: RefCell::new(Vec::new()),
528 }
529 }
530 }
531
532 impl CompositeEncoder for MockComposite {
533 type Rec = ();
534 type Args = ();
535
536 fn begin_composite(&self, _rec: &(), _args: &()) {
537 self.log.borrow_mut().push("begin".into());
538 }
539 fn composite_draw(&self, _rec: &(), _args: &()) {
540 self.log.borrow_mut().push("draw".into());
541 }
542 fn begin_text(&self, _rec: &(), _args: &()) -> bool {
543 self.log.borrow_mut().push("begin_text".into());
544 self.text_ready
545 }
546 fn text_draw(
547 &self,
548 _rec: &(),
549 _args: &(),
550 _call: &TextDrawCall,
551 _cache: &mut TextBindCache,
552 ) -> Result<(), String> {
553 let mut n = self.text_seen.borrow_mut();
554 self.binds
555 .borrow_mut()
556 .push(_cache.atlas_changed(_call.atlas_slot));
557 self.log.borrow_mut().push(format!("text{}", *n));
558 let fail = self.fail_at == Some(*n);
559 *n += 1;
560 if fail {
561 return Err("text upload failed".into());
562 }
563 Ok(())
564 }
565 fn end_composite(&self, _rec: &(), _args: &()) {
566 self.log.borrow_mut().push("end".into());
567 }
568 }
569
570 #[test]
571 fn composite_chain_orders_passes_then_text_then_end() {
572 let enc = MockComposite::new(true, None);
573 let calls = [text_call(), text_call()];
574 let r = encode_composite_chain(&enc, &(), &(), &calls);
575 assert!(r.is_ok());
576 assert_eq!(
577 *enc.log.borrow(),
578 ["begin", "draw", "begin_text", "text0", "text1", "end"]
579 );
580 }
581
582 #[test]
583 fn composite_chain_propagates_text_error_without_ending() {
584 // The first text draw fails: the error propagates and, matching the
585 // prior DX/VK behaviour, the pass is left open (no `end_composite`) and
586 // the remaining text calls are skipped.
587 let enc = MockComposite::new(true, Some(0));
588 let calls = [text_call(), text_call()];
589 let r = encode_composite_chain(&enc, &(), &(), &calls);
590 assert_eq!(r, Err("text upload failed".into()));
591 let log = enc.log.borrow();
592 assert_eq!(*log, ["begin", "draw", "begin_text", "text0"]);
593 assert!(!log.contains(&"end".to_string()), "pass must stay open");
594 }
595
596 #[test]
597 fn composite_chain_with_no_text_skips_the_text_loop() {
598 // Empty text: `begin_text` is never called, but the pass still ends.
599 let enc = MockComposite::new(true, None);
600 let r = encode_composite_chain(&enc, &(), &(), &[]);
601 assert!(r.is_ok());
602 assert_eq!(*enc.log.borrow(), ["begin", "draw", "end"]);
603 }
604
605 #[test]
606 fn composite_chain_skips_draws_when_text_is_inert() {
607 // `begin_text` returns false (no pipeline / atlases): no per-call draws,
608 // but the pass still ends cleanly.
609 let enc = MockComposite::new(false, None);
610 let calls = [text_call()];
611 let r = encode_composite_chain(&enc, &(), &(), &calls);
612 assert!(r.is_ok());
613 assert_eq!(*enc.log.borrow(), ["begin", "draw", "begin_text", "end"]);
614 }
615
616 #[test]
617 fn an_empty_cache_reports_the_first_bind_of_each_kind() {
618 let mut cache = TextBindCache::new();
619 assert!(cache.atlas_changed(0));
620 assert!(cache.scissor_changed((0, 0, 1280, 720)));
621 }
622
623 #[test]
624 fn a_repeated_value_is_not_rebound() {
625 // The heads-up-display case: every label on one atlas, none clipped, so
626 // only the first call of the run binds either.
627 let mut cache = TextBindCache::new();
628 let full = (0, 0, 1280, 720);
629 assert!(cache.atlas_changed(2));
630 assert!(cache.scissor_changed(full));
631 for _ in 0..100 {
632 assert!(!cache.atlas_changed(2));
633 assert!(!cache.scissor_changed(full));
634 }
635 }
636
637 #[test]
638 fn a_changed_value_rebinds_and_then_settles() {
639 // A clipped call in the middle of a run sets its own band and the next
640 // unclipped call restores the full-window rect; a third unclipped call
641 // then rides the restored one.
642 let mut cache = TextBindCache::new();
643 let full = (0, 0, 1280, 720);
644 let band = (10, 20, 300, 100);
645 assert!(cache.scissor_changed(full));
646 assert!(cache.scissor_changed(band));
647 assert!(cache.scissor_changed(full));
648 assert!(!cache.scissor_changed(full));
649 // The two kinds are tracked independently.
650 assert!(cache.atlas_changed(0));
651 assert!(!cache.atlas_changed(0));
652 assert!(cache.atlas_changed(1));
653 assert!(cache.atlas_changed(0));
654 }
655
656 #[test]
657 fn a_cache_distinguishes_rects_that_differ_in_one_field() {
658 let mut cache = TextBindCache::new();
659 assert!(cache.scissor_changed((0, 0, 100, 100)));
660 assert!(cache.scissor_changed((1, 0, 100, 100)));
661 assert!(cache.scissor_changed((1, 2, 100, 100)));
662 assert!(cache.scissor_changed((1, 2, 101, 100)));
663 assert!(cache.scissor_changed((1, 2, 101, 99)));
664 assert!(!cache.scissor_changed((1, 2, 101, 99)));
665 }
666
667 #[test]
668 fn one_cache_spans_the_whole_text_loop() {
669 // The driver must hand every call in a pass the same cache, or nothing
670 // is ever deduplicated: three calls on one atlas bind it once.
671 let enc = MockComposite::new(true, None);
672 let calls = [text_call(), text_call(), text_call()];
673 let r = encode_composite_chain(&enc, &(), &(), &calls);
674 assert!(r.is_ok());
675 assert_eq!(*enc.binds.borrow(), [true, false, false]);
676 }
677
678 #[test]
679 fn each_pass_starts_from_an_empty_cache() {
680 // A cache must not outlive its pass: the next frame records into a fresh
681 // recorder that has none of the previous frame's state bound.
682 for _ in 0..2 {
683 let enc = MockComposite::new(true, None);
684 let calls = [text_call(), text_call()];
685 assert!(encode_composite_chain(&enc, &(), &(), &calls).is_ok());
686 assert_eq!(*enc.binds.borrow(), [true, false]);
687 }
688 }
689
690 // A mock single-draw fullscreen pass recording its lifecycle.
691 struct MockFullscreen {
692 log: RefCell<Vec<String>>,
693 }
694
695 impl FullscreenPass for MockFullscreen {
696 type Rec = ();
697
698 fn begin(&self, _rec: &()) {
699 self.log.borrow_mut().push("begin".into());
700 }
701 fn draw(&self, _rec: &()) {
702 self.log.borrow_mut().push("draw".into());
703 }
704 fn end(&self, _rec: &()) {
705 self.log.borrow_mut().push("end".into());
706 }
707 }
708
709 #[test]
710 fn fullscreen_encodes_begin_draw_end() {
711 let enc = MockFullscreen {
712 log: RefCell::new(Vec::new()),
713 };
714 encode_fullscreen(&enc, &());
715 assert_eq!(*enc.log.borrow(), ["begin", "draw", "end"]);
716 }
717}