Skip to main content

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