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 composite pass: tonemap (+ optional LUT grade) the post-stack scene onto
125/// the swapchain image, then layer the text overlay on top in the same pass. Its
126/// begin -> composite-draw -> text-loop -> end shape is identical on every
127/// backend; the swapchain target lifecycle, the descriptor binding, and the
128/// text-geometry uploads stay backend-specific behind the trait. `Args`
129/// carries the per-frame binding context each backend needs (DX: the swapchain
130/// back-buffer + its RTV, the scene SRV, the window size, the frame slot; VK: the
131/// acquired image index + the frame slot).
132///
133/// Every backend uploads a frame's text geometry into one persistent buffer per
134/// frame-in-flight slot, reserved up front with [`text_upload_bytes`] and
135/// appended to per call, and binds sub-ranges of it: no GPU buffer is created
136/// per label per frame anywhere. DX and VK append inside `text_draw`; Metal
137/// (which drives its own composite loop rather than this trait) writes the whole
138/// frame's geometry into its slot before the render graph runs.
139pub trait CompositeEncoder {
140    /// Per-backend command recorder (DX `ID3D12GraphicsCommandList`, VK `vk::CommandBuffer`).
141    type Rec;
142    /// Per-invocation binding context (see the trait doc).
143    type Args;
144
145    /// Begin the pass: target the swapchain image (DX transitions it to
146    /// RENDER_TARGET + binds the RTV; VK begins the composite render pass) and set
147    /// the full-window viewport / scissor.
148    fn begin_composite(&self, rec: &Self::Rec, args: &Self::Args);
149    /// The fullscreen tonemap draw: bind the composite pipeline + its inputs
150    /// (scene, bloom, LUT) + push constants, draw the fullscreen triangle.
151    fn composite_draw(&self, rec: &Self::Rec, args: &Self::Args);
152    /// Bind the text pipeline + any one-time text state. Returns false when text
153    /// is inert (no pipeline or no atlases), so the driver skips the call loop.
154    fn begin_text(&self, rec: &Self::Rec, args: &Self::Args) -> bool;
155    /// Encode one text draw call: append its vertex/index geometry to this frame
156    /// slot's persistent upload buffer, bind the atlas plus the two sub-ranges,
157    /// and draw.
158    fn text_draw(
159        &self,
160        rec: &Self::Rec,
161        args: &Self::Args,
162        call: &TextDrawCall,
163    ) -> Result<(), String>;
164    /// End the pass: DX transitions the back-buffer back to PRESENT; VK ends the
165    /// render pass.
166    fn end_composite(&self, rec: &Self::Rec, args: &Self::Args);
167}
168
169/// The composite + text orchestration, previously hand-duplicated in each
170/// backend's `encode_composite_and_text`. An error mid-text propagates without
171/// closing the pass, matching the prior DX/VK behaviour (the frame fails either
172/// way: the target is just left mis-stated). This is unused on Metal, where a
173/// render encoder must be `endEncoding`-ed before the command buffer commits:
174/// skipping `end_composite` on a text error would crash at commit, so Metal's
175/// `encode_composite_and_text` ends the encoder on any `?` with a `ScopedEncoder`
176/// RAII guard instead.
177pub fn encode_composite_chain<E: CompositeEncoder>(
178    enc: &E,
179    rec: &E::Rec,
180    args: &E::Args,
181    text_calls: &[TextDrawCall],
182) -> Result<(), String> {
183    enc.begin_composite(rec, args);
184    enc.composite_draw(rec, args);
185    if !text_calls.is_empty() && enc.begin_text(rec, args) {
186        for call in text_calls {
187            enc.text_draw(rec, args, call)?;
188        }
189    }
190    enc.end_composite(rec, args);
191    Ok(())
192}
193
194/// A single-draw fullscreen post pass (SSR resolve, TAA resolve, ...): target a
195/// render target, bind a pipeline + inputs, draw one fullscreen triangle, restore.
196/// Unlike the bloom + composite chains (whose drivers hold a mip / text loop), a
197/// fullscreen pass has no loop, so the driver is a fixed begin -> draw -> end. The
198/// value is the shared per-backend lifecycle factored behind begin/end (DX: the
199/// PSR<->RENDER_TARGET barrier bracket + render-target bind; VK: the render-pass
200/// bracket), reused across every such pass instead of re-pasted per pass.
201///
202/// The inert-pass guard lives at each backend's call site: it resolves the pass's
203/// resources (returning early if a required one is absent) BEFORE constructing the
204/// encoder, so the driver always runs all three steps over a fully-resolved pass
205/// and can never leave a render pass / barrier half-open. There is no `Args`: each
206/// backend's encoder is a small struct holding the already-resolved references +
207/// per-call scalars, so the trait names no backend types (like `BloomEncoder`).
208///
209/// Implemented by DirectX + Vulkan. Metal keeps its own `fullscreen_pass` helper,
210/// which already factors this begin/draw/end skeleton, so this seam is unused
211/// (dead code) on a Metal build.
212pub trait FullscreenPass {
213    /// Per-backend command recorder (DX `ID3D12GraphicsCommandList`, VK `vk::CommandBuffer`).
214    type Rec;
215
216    /// Begin: bind the target render target + set the full-resolution viewport /
217    /// scissor. DX transitions the target PIXEL_SHADER_RESOURCE -> RENDER_TARGET,
218    /// binds its RTV + the SRV heap; VK begins the pass's render pass.
219    fn begin(&self, rec: &Self::Rec);
220    /// Bind the pipeline + inputs + per-frame params and draw the fullscreen
221    /// triangle (3 vertices; the vertex shader builds the triangle from the id).
222    fn draw(&self, rec: &Self::Rec);
223    /// End: DX transitions the target back to PIXEL_SHADER_RESOURCE; VK ends the
224    /// render pass.
225    fn end(&self, rec: &Self::Rec);
226}
227
228/// The fullscreen-pass orchestration. Trivial by design (a single draw), but kept
229/// as a driver so every fullscreen post pass shares one begin -> draw -> end
230/// contract across backends, matching `encode_bloom_chain` / `encode_composite_chain`.
231pub fn encode_fullscreen<E: FullscreenPass>(enc: &E, rec: &E::Rec) {
232    enc.begin(rec);
233    enc.draw(rec);
234    enc.end(rec);
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::gfx::render_types::TextDrawCall;
241    use core::cell::RefCell;
242
243    use alloc::format;
244    use alloc::string::ToString;
245    use alloc::vec;
246    use alloc::vec::Vec;
247    #[test]
248    fn clip_inside_attachment_passes_through() {
249        // Logical units are attachment pixels (Windows, unscaled X11): 1:1.
250        assert_eq!(
251            clip_rect_to_scissor([100.0, 50.0, 300.0, 200.0], (1280.0, 720.0), (1280, 720)),
252            Some((100, 50, 300, 200))
253        );
254    }
255
256    #[test]
257    fn clip_scales_from_logical_units_to_a_hi_dpi_attachment() {
258        // A 2x backing scale (macOS retina, scaled Wayland): the band covers the
259        // same fraction of an attachment twice the logical size.
260        assert_eq!(
261            clip_rect_to_scissor([100.0, 50.0, 300.0, 200.0], (1024.0, 768.0), (2048, 1536)),
262            Some((200, 100, 600, 400))
263        );
264        // A non-integer scale still lands on whole pixels, rounded outward so a
265        // band never crops the glyphs it should show.
266        assert_eq!(
267            clip_rect_to_scissor([10.0, 10.0, 100.0, 100.0], (1000.0, 1000.0), (1500, 1500)),
268            Some((15, 15, 150, 150))
269        );
270    }
271
272    #[test]
273    fn clip_is_clamped_to_attachment_bounds() {
274        // A band hanging off the right / bottom edge is clamped to the target.
275        assert_eq!(
276            clip_rect_to_scissor([1200.0, 700.0, 400.0, 400.0], (1280.0, 720.0), (1280, 720)),
277            Some((1200, 700, 80, 20))
278        );
279        // A negative origin is clamped to zero, shrinking the width/height.
280        assert_eq!(
281            clip_rect_to_scissor([-40.0, -10.0, 100.0, 100.0], (1280.0, 720.0), (1280, 720)),
282            Some((0, 0, 60, 90))
283        );
284        // The clamp is against the attachment, after scaling.
285        assert_eq!(
286            clip_rect_to_scissor([600.0, 350.0, 200.0, 200.0], (640.0, 360.0), (1280, 720)),
287            Some((1200, 700, 80, 20))
288        );
289    }
290
291    #[test]
292    fn fully_offscreen_clip_is_skipped() {
293        // A band entirely past the attachment yields no scissor (skip the draw).
294        assert_eq!(
295            clip_rect_to_scissor([2000.0, 50.0, 100.0, 100.0], (1280.0, 720.0), (1280, 720)),
296            None
297        );
298        // A zero-area band is also skipped.
299        assert_eq!(
300            clip_rect_to_scissor([10.0, 10.0, 0.0, 50.0], (1280.0, 720.0), (1280, 720)),
301            None
302        );
303    }
304
305    #[test]
306    fn a_zero_logical_size_falls_back_to_an_unscaled_clip() {
307        // Minimised / mid-resize: no divide by zero, and the rect is still
308        // clamped into the attachment.
309        assert_eq!(
310            clip_rect_to_scissor([10.0, 20.0, 100.0, 100.0], (0.0, 0.0), (1280, 720)),
311            Some((10, 20, 100, 100))
312        );
313    }
314
315    // A text-only draw call for the composite driver: the drivers never inspect
316    // its contents, so the geometry is empty.
317    fn text_call() -> TextDrawCall {
318        TextDrawCall {
319            vertices: Vec::new(),
320            indices: Vec::new(),
321            atlas_slot: 0,
322            clip_rect: None,
323            layer: 0,
324        }
325    }
326
327    // A call carrying `glyphs` quads: 4 vertices + 6 indices each, the shape
328    // `gfx::text::build_text_calls` emits.
329    fn glyph_call(glyphs: usize) -> TextDrawCall {
330        TextDrawCall {
331            vertices: vec![
332                crate::gfx::render_types::TextVertex {
333                    pos: [0.0; 2],
334                    uv: [0.0; 2],
335                    color: [0.0; 3],
336                    mode: 0.0,
337                };
338                glyphs * 4
339            ],
340            indices: vec![0u16; glyphs * 6],
341            atlas_slot: 0,
342            clip_rect: None,
343            layer: 0,
344        }
345    }
346
347    #[test]
348    fn align_up_rounds_to_multiple() {
349        assert_eq!(align_up(0, 16), 0);
350        assert_eq!(align_up(1, 16), 16);
351        assert_eq!(align_up(16, 16), 16);
352        assert_eq!(align_up(17, 16), 32);
353        assert_eq!(align_up(257, 256), 512);
354    }
355
356    #[test]
357    fn text_upload_bytes_is_zero_without_calls() {
358        assert_eq!(text_upload_bytes(&[], 256), 0);
359        // An empty call still contributes nothing: both blocks are zero bytes.
360        assert_eq!(text_upload_bytes(&[text_call()], 256), 0);
361    }
362
363    #[test]
364    fn text_upload_bytes_aligns_each_block() {
365        // One glyph: 4 * 32 B of vertices (already a multiple of 16) and 12 B of
366        // indices (rounded up).
367        assert_eq!(text_upload_bytes(&[glyph_call(1)], 16), 128 + 16);
368        assert_eq!(text_upload_bytes(&[glyph_call(1)], 256), 256 + 256);
369    }
370
371    // The reserved size must be an upper bound on the cursor after a run of
372    // appends (an aligned start plus an aligned size stays aligned), so a slot
373    // reserved to it can never overflow mid-frame.
374    #[test]
375    fn text_upload_bytes_bounds_a_simulated_cursor() {
376        let calls = [glyph_call(3), glyph_call(1), glyph_call(17), glyph_call(0)];
377        for align in [16u64, 256] {
378            let total = text_upload_bytes(&calls, align);
379            let mut cursor = 0u64;
380            for c in &calls {
381                for block in [
382                    core::mem::size_of_val(c.vertices.as_slice()) as u64,
383                    core::mem::size_of_val(c.indices.as_slice()) as u64,
384                ] {
385                    cursor = align_up(cursor, align) + block;
386                    assert!(cursor <= total, "cursor {cursor} exceeded reserved {total}");
387                }
388            }
389        }
390    }
391
392    // A mock bloom encoder recording each sub-pass in call order. The trait's
393    // associated types name no backend types, so both are `()`.
394    struct MockBloom {
395        mips: usize,
396        log: RefCell<Vec<String>>,
397    }
398
399    impl BloomEncoder for MockBloom {
400        type Rec = ();
401        type Args = ();
402
403        fn bloom_mip_count(&self) -> usize {
404            self.mips
405        }
406        fn begin_bloom(&self, _rec: &(), _args: &()) {
407            self.log.borrow_mut().push("begin".into());
408        }
409        fn bloom_prefilter(&self, _rec: &(), _args: &()) {
410            self.log.borrow_mut().push("prefilter".into());
411        }
412        fn bloom_downsample(&self, _rec: &(), _args: &(), dst: usize) {
413            self.log.borrow_mut().push(format!("down{dst}"));
414        }
415        fn bloom_upsample(&self, _rec: &(), _args: &(), dst: usize) {
416            self.log.borrow_mut().push(format!("up{dst}"));
417        }
418    }
419
420    #[test]
421    fn bloom_chain_encodes_prefilter_downsample_upsample_in_order() {
422        // 3 mips: prefilter, then the downsample chain 1..3, then the upsample
423        // chain walking back down (1, 0).
424        let enc = MockBloom {
425            mips: 3,
426            log: RefCell::new(Vec::new()),
427        };
428        encode_bloom_chain(&enc, &(), ());
429        assert_eq!(
430            *enc.log.borrow(),
431            ["begin", "prefilter", "down1", "down2", "up1", "up0"]
432        );
433    }
434
435    #[test]
436    fn bloom_chain_begins_once_whatever_the_mip_count() {
437        // Backends push the shared post-process constants in `begin_bloom` and
438        // rely on them surviving every sub-pass, so the preamble must run
439        // exactly once per chain, ahead of the first draw.
440        for mips in 1..8 {
441            let enc = MockBloom {
442                mips,
443                log: RefCell::new(Vec::new()),
444            };
445            encode_bloom_chain(&enc, &(), ());
446            let log = enc.log.borrow();
447            assert_eq!(log.iter().filter(|e| *e == "begin").count(), 1);
448            assert_eq!(log[0], "begin");
449        }
450    }
451
452    #[test]
453    fn bloom_chain_with_zero_mips_is_a_noop() {
454        // Bloom off: the driver returns before touching the encoder at all.
455        let enc = MockBloom {
456            mips: 0,
457            log: RefCell::new(Vec::new()),
458        };
459        encode_bloom_chain(&enc, &(), ());
460        assert!(enc.log.borrow().is_empty());
461    }
462
463    // A mock composite encoder. `text_ready` is the `begin_text` return; when
464    // `fail_at` matches a text-draw index that draw returns an error.
465    struct MockComposite {
466        text_ready: bool,
467        fail_at: Option<usize>,
468        log: RefCell<Vec<String>>,
469        text_seen: RefCell<usize>,
470    }
471
472    impl MockComposite {
473        fn new(text_ready: bool, fail_at: Option<usize>) -> Self {
474            Self {
475                text_ready,
476                fail_at,
477                log: RefCell::new(Vec::new()),
478                text_seen: RefCell::new(0),
479            }
480        }
481    }
482
483    impl CompositeEncoder for MockComposite {
484        type Rec = ();
485        type Args = ();
486
487        fn begin_composite(&self, _rec: &(), _args: &()) {
488            self.log.borrow_mut().push("begin".into());
489        }
490        fn composite_draw(&self, _rec: &(), _args: &()) {
491            self.log.borrow_mut().push("draw".into());
492        }
493        fn begin_text(&self, _rec: &(), _args: &()) -> bool {
494            self.log.borrow_mut().push("begin_text".into());
495            self.text_ready
496        }
497        fn text_draw(&self, _rec: &(), _args: &(), _call: &TextDrawCall) -> Result<(), String> {
498            let mut n = self.text_seen.borrow_mut();
499            self.log.borrow_mut().push(format!("text{}", *n));
500            let fail = self.fail_at == Some(*n);
501            *n += 1;
502            if fail {
503                return Err("text upload failed".into());
504            }
505            Ok(())
506        }
507        fn end_composite(&self, _rec: &(), _args: &()) {
508            self.log.borrow_mut().push("end".into());
509        }
510    }
511
512    #[test]
513    fn composite_chain_orders_passes_then_text_then_end() {
514        let enc = MockComposite::new(true, None);
515        let calls = [text_call(), text_call()];
516        let r = encode_composite_chain(&enc, &(), &(), &calls);
517        assert!(r.is_ok());
518        assert_eq!(
519            *enc.log.borrow(),
520            ["begin", "draw", "begin_text", "text0", "text1", "end"]
521        );
522    }
523
524    #[test]
525    fn composite_chain_propagates_text_error_without_ending() {
526        // The first text draw fails: the error propagates and, matching the
527        // prior DX/VK behaviour, the pass is left open (no `end_composite`) and
528        // the remaining text calls are skipped.
529        let enc = MockComposite::new(true, Some(0));
530        let calls = [text_call(), text_call()];
531        let r = encode_composite_chain(&enc, &(), &(), &calls);
532        assert_eq!(r, Err("text upload failed".into()));
533        let log = enc.log.borrow();
534        assert_eq!(*log, ["begin", "draw", "begin_text", "text0"]);
535        assert!(!log.contains(&"end".to_string()), "pass must stay open");
536    }
537
538    #[test]
539    fn composite_chain_with_no_text_skips_the_text_loop() {
540        // Empty text: `begin_text` is never called, but the pass still ends.
541        let enc = MockComposite::new(true, None);
542        let r = encode_composite_chain(&enc, &(), &(), &[]);
543        assert!(r.is_ok());
544        assert_eq!(*enc.log.borrow(), ["begin", "draw", "end"]);
545    }
546
547    #[test]
548    fn composite_chain_skips_draws_when_text_is_inert() {
549        // `begin_text` returns false (no pipeline / atlases): no per-call draws,
550        // but the pass still ends cleanly.
551        let enc = MockComposite::new(false, None);
552        let calls = [text_call()];
553        let r = encode_composite_chain(&enc, &(), &(), &calls);
554        assert!(r.is_ok());
555        assert_eq!(*enc.log.borrow(), ["begin", "draw", "begin_text", "end"]);
556    }
557
558    // A mock single-draw fullscreen pass recording its lifecycle.
559    struct MockFullscreen {
560        log: RefCell<Vec<String>>,
561    }
562
563    impl FullscreenPass for MockFullscreen {
564        type Rec = ();
565
566        fn begin(&self, _rec: &()) {
567            self.log.borrow_mut().push("begin".into());
568        }
569        fn draw(&self, _rec: &()) {
570            self.log.borrow_mut().push("draw".into());
571        }
572        fn end(&self, _rec: &()) {
573            self.log.borrow_mut().push("end".into());
574        }
575    }
576
577    #[test]
578    fn fullscreen_encodes_begin_draw_end() {
579        let enc = MockFullscreen {
580            log: RefCell::new(Vec::new()),
581        };
582        encode_fullscreen(&enc, &());
583        assert_eq!(*enc.log.borrow(), ["begin", "draw", "end"]);
584    }
585}