Skip to main content

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}