Skip to main content

rlx_core/render/
preview.rs

1//! The program preview's intermediate target and its letterbox geometry
2//! (ADR-0143).
3//!
4//! While a secondary surface is attached, the frame is drawn **once** into the
5//! intermediate here and reaches its real destination by
6//! `copy_texture_to_texture` — exact, no shader, no sampling. The same
7//! intermediate is then sampled, scaled and letterboxed onto the console. One
8//! render, one frame, two destinations.
9//!
10//! **Not behind the `text` feature, unlike [`super::aux_target`].** The console
11//! that consumes a preview is text-gated, but the intermediate is a render path
12//! and the property that matters about it — that a frame routed through it is
13//! byte-identical to one drawn straight at the target — is asserted on the
14//! headless capture path, which compiles with glyphon out.
15
16// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). The
17// copy runs once per displayed frame while a preview is open.
18#![deny(
19    clippy::unwrap_used,
20    clippy::expect_used,
21    clippy::indexing_slicing,
22    clippy::panic,
23    clippy::unreachable
24)]
25
26use super::gpu;
27use std::sync::atomic::{AtomicU64, Ordering};
28
29/// Hands out an identity for each intermediate ever built, so a consumer that
30/// caches GPU state against one can tell it has been handed a different
31/// texture. A resize destroys and rebuilds the intermediate at the same size in
32/// principle, and a pointer comparison on `wgpu::Texture` is not available.
33static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
34
35/// The offscreen a frame is drawn into while a preview is open, plus the view
36/// and identity its two consumers need.
37///
38/// Sized to the output's configured target and **never resized in place**: the
39/// copy's extent is fixed against `width`x`height`, so a renderer resize
40/// discards this and builds another. [`super::Renderer::open_preview`] and
41/// `resize` are the only things that construct one.
42pub struct PreviewTarget {
43    /// `RENDER_ATTACHMENT | COPY_SRC | TEXTURE_BINDING` — drawn into, copied
44    /// out of, and sampled by the console blit.
45    pub(crate) texture: wgpu::Texture,
46    /// A view of `texture`, held rather than recreated per frame.
47    pub(crate) view: wgpu::TextureView,
48    width: u32,
49    height: u32,
50    format: wgpu::TextureFormat,
51    generation: u64,
52}
53
54impl PreviewTarget {
55    /// Build the intermediate at `format` — which must be the destination's
56    /// format, since `copy_texture_to_texture` refuses a mismatch and that
57    /// refusal is the whole guarantee of exactness.
58    pub(crate) fn new(
59        device: &wgpu::Device,
60        format: wgpu::TextureFormat,
61        width: u32,
62        height: u32,
63    ) -> Self {
64        let (width, height) = (width.max(1), height.max(1));
65        let texture = device.create_texture(&wgpu::TextureDescriptor {
66            label: Some("rlx-preview-intermediate"),
67            size: wgpu::Extent3d {
68                width,
69                height,
70                depth_or_array_layers: 1,
71            },
72            mip_level_count: 1,
73            sample_count: 1,
74            dimension: wgpu::TextureDimension::D2,
75            format,
76            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
77                | wgpu::TextureUsages::COPY_SRC
78                | wgpu::TextureUsages::TEXTURE_BINDING,
79            view_formats: &[],
80        });
81        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
82        Self {
83            texture,
84            view,
85            width,
86            height,
87            format,
88            generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
89        }
90    }
91
92    /// The pixel size this intermediate was built against.
93    pub fn size(&self) -> (u32, u32) {
94        (self.width, self.height)
95    }
96
97    /// The texture format this intermediate was built at — the destination's,
98    /// since the copy out of it refuses a mismatch.
99    pub(crate) fn format(&self) -> wgpu::TextureFormat {
100        self.format
101    }
102
103    /// This intermediate's identity, unique across every one ever built in this
104    /// process. A consumer caching a bind group against it compares this rather
105    /// than the texture.
106    pub fn generation(&self) -> u64 {
107        self.generation
108    }
109
110    /// Record the exact copy from this intermediate into `dst`.
111    ///
112    /// Both must carry the same format and the same size; the caller builds
113    /// them that way and wgpu rejects the pair if it did not.
114    pub(crate) fn record_copy_to(&self, encoder: &mut wgpu::CommandEncoder, dst: &wgpu::Texture) {
115        encoder.copy_texture_to_texture(
116            self.texture.as_image_copy(),
117            dst.as_image_copy(),
118            wgpu::Extent3d {
119                width: self.width,
120                height: self.height,
121                depth_or_array_layers: 1,
122            },
123        );
124    }
125}
126
127/// The **fixed-size** mirror the preview readback copies out of (ADR-0187).
128///
129/// [`PreviewTarget`] above is the show's own size and is rebuilt whenever the
130/// window is, so a readback taken straight off it changes size mid-run. The
131/// reader on the other end of a byte pipe cannot survive that: the frames carry
132/// no header, a raw frame can hold any byte pattern so no sentinel finds the
133/// boundary, and bytes already in the OS pipe are still the old geometry. This
134/// texture is built once, at the size the caller asked for, and a **sampling
135/// blit** fills it from the intermediate every frame — so the window may be
136/// resized, maximized or thrown fullscreen and the bytes leaving here keep one
137/// shape for the life of the run.
138///
139/// Built at the intermediate's own format, so the blit preserves the channel
140/// order rather than converting it — whoever announces the pipe can then name
141/// what it actually carries.
142///
143/// The show's aspect is **letterboxed** into that fixed shape rather than
144/// stretched to it (see [`fit_rect`]).
145pub(crate) struct PreviewTap {
146    texture: wgpu::Texture,
147    view: wgpu::TextureView,
148    width: u32,
149    height: u32,
150    pipeline: wgpu::RenderPipeline,
151    layout: wgpu::BindGroupLayout,
152    sampler: wgpu::Sampler,
153    /// The bind group for the intermediate, keyed on that intermediate's
154    /// [`generation`](PreviewTarget::generation): a renderer resize builds
155    /// another intermediate, and a group still bound to the old one samples a
156    /// texture nothing owns.
157    bound: Option<(u64, wgpu::BindGroup)>,
158}
159
160/// The blit's fragment stage. Alpha is forced to 1: the pipe's consumer reads
161/// four channels per pixel, and an intermediate carrying anything but opaque
162/// there would paint the preview translucent over whatever is behind it.
163const TAP_WGSL: &str = r#"
164@group(0) @binding(0) var src: texture_2d<f32>;
165@group(0) @binding(1) var samp: sampler;
166
167@fragment
168fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
169    return vec4<f32>(textureSample(src, samp, in.uv).rgb, 1.0);
170}
171"#;
172
173impl PreviewTap {
174    /// Build the tap at `format` — the intermediate's — and the requested size.
175    pub(crate) fn new(
176        device: &wgpu::Device,
177        format: wgpu::TextureFormat,
178        width: u32,
179        height: u32,
180    ) -> Self {
181        let (width, height) = (width.max(1), height.max(1));
182        let texture = device.create_texture(&wgpu::TextureDescriptor {
183            label: Some("rlx-preview-tap"),
184            size: wgpu::Extent3d {
185                width,
186                height,
187                depth_or_array_layers: 1,
188            },
189            mip_level_count: 1,
190            sample_count: 1,
191            dimension: wgpu::TextureDimension::D2,
192            format,
193            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
194            view_formats: &[],
195        });
196        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
197        // `[Texture, Sampler]` — a shape no other layout in `core/src` holds,
198        // which `no_two_layouts_share_a_shape_without_recorded_evidence`
199        // enforces (ADR-0058).
200        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
201            label: Some("rlx-preview-tap-layout"),
202            entries: &[gpu::texture(0, true), gpu::sampler(1)],
203        });
204        let shader = gpu::fullscreen_shader(
205            device,
206            "rlx-preview-tap",
207            gpu::FULLSCREEN_VS_UV_FLIPPED,
208            TAP_WGSL,
209        );
210        let pipeline = gpu::fullscreen_pipeline(
211            device,
212            &shader,
213            &[&layout],
214            format,
215            wgpu::BlendState::REPLACE,
216            "rlx-preview-tap",
217        );
218        Self {
219            texture,
220            view,
221            width,
222            height,
223            pipeline,
224            layout,
225            sampler: device.create_sampler(&wgpu::SamplerDescriptor {
226                label: Some("rlx-preview-tap-sampler"),
227                // Linear: the tap is a heavy minification of the show in every
228                // ordinary case, and a nearest sample of it aliases into noise
229                // a viewer reads as detail that is not in the picture.
230                mag_filter: wgpu::FilterMode::Linear,
231                min_filter: wgpu::FilterMode::Linear,
232                ..Default::default()
233            }),
234            bound: None,
235        }
236    }
237
238    /// The pixel size this was built at, and the size of every frame copied out
239    /// of it for as long as it lives.
240    pub(crate) fn size(&self) -> (u32, u32) {
241        (self.width, self.height)
242    }
243
244    /// The texture a readback copies out of.
245    pub(crate) fn texture(&self) -> &wgpu::Texture {
246        &self.texture
247    }
248
249    /// Record the scaling, letterboxing blit from `preview` into this target.
250    ///
251    /// Returns whether it recorded. A caller copies out of this texture only
252    /// when it did, so a frame with no bind group is skipped rather than
253    /// published as whatever the tap happened to hold before.
254    pub(crate) fn record_fill_from(
255        &mut self,
256        device: &wgpu::Device,
257        encoder: &mut wgpu::CommandEncoder,
258        preview: &PreviewTarget,
259    ) -> bool {
260        let generation = preview.generation();
261        if self.bound.as_ref().is_none_or(|(g, _)| *g != generation) {
262            self.bound = Some((
263                generation,
264                device.create_bind_group(&wgpu::BindGroupDescriptor {
265                    label: Some("rlx-preview-tap-group"),
266                    layout: &self.layout,
267                    entries: &[
268                        wgpu::BindGroupEntry {
269                            binding: 0,
270                            resource: wgpu::BindingResource::TextureView(&preview.view),
271                        },
272                        wgpu::BindGroupEntry {
273                            binding: 1,
274                            resource: wgpu::BindingResource::Sampler(&self.sampler),
275                        },
276                    ],
277                }),
278            ));
279        }
280        let Some((_, group)) = self.bound.as_ref() else {
281            return false;
282        };
283        let (x, y, width, height) = fit_rect(preview.size(), (self.width, self.height));
284        // Cleared rather than loaded: the letterbox bars are the part of the
285        // target the blit never writes, and a loaded target would keep showing
286        // the previous frame's picture in them.
287        let mut pass = gpu::color_pass(
288            encoder,
289            "rlx-preview-tap-blit",
290            &self.view,
291            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
292        );
293        pass.set_viewport(x as f32, y as f32, width as f32, height as f32, 0.0, 1.0);
294        // The viewport transforms; it does not clip. Without the scissor the
295        // oversized fullscreen triangle rasterizes over the bars too, and the
296        // letterbox is a stretch again.
297        pass.set_scissor_rect(x, y, width, height);
298        pass.set_pipeline(&self.pipeline);
299        pass.set_bind_group(0, group, &[]);
300        pass.draw(0..3, 0..1);
301        true
302    }
303}
304
305/// The largest rectangle carrying `src`'s aspect that fits inside `dst`,
306/// centred — origin top-left, in `dst`'s own pixels.
307///
308/// The tap is a fixed **resolution** and the show's window is any shape, so the
309/// two aspects disagree the moment anyone drags a window edge. Fitting rather
310/// than stretching is ADR-0037's rule at the one place a scaled copy of the show
311/// leaves the engine: bars are honest about the shape, a squash is not.
312///
313/// Every returned dimension is at least 1 — a zero-extent viewport is a wgpu
314/// validation error, and a degenerate `src` is a window mid-minimize rather than
315/// a caller's mistake.
316pub(crate) fn fit_rect(src: (u32, u32), dst: (u32, u32)) -> (u32, u32, u32, u32) {
317    let (dst_w, dst_h) = (dst.0.max(1), dst.1.max(1));
318    let (src_w, src_h) = (f64::from(src.0.max(1)), f64::from(src.1.max(1)));
319    let scale = (f64::from(dst_w) / src_w).min(f64::from(dst_h) / src_h);
320    let width = ((src_w * scale).round() as u32).clamp(1, dst_w);
321    let height = ((src_h * scale).round() as u32).clamp(1, dst_h);
322    ((dst_w - width) / 2, (dst_h - height) / 2, width, height)
323}
324
325/// A rectangle in a console surface's device pixels, origin top-left.
326#[derive(Debug, Clone, Copy, PartialEq)]
327pub struct Rect {
328    /// Distance from the surface's left edge to the rectangle's.
329    pub x: f32,
330    /// Distance from the surface's top edge to the rectangle's.
331    pub y: f32,
332    /// Rectangle width in device pixels.
333    pub width: f32,
334    /// Rectangle height in device pixels.
335    pub height: f32,
336}
337
338impl Rect {
339    /// Width over height. Undefined for a zero-height rectangle, which
340    /// [`preview_rect`] never returns.
341    pub fn aspect(&self) -> f32 {
342        self.width / self.height
343    }
344}
345
346/// The preview slot's longer side, as a fraction of the console surface.
347///
348/// A monitor, not a second show: large enough to read a cut from across a desk,
349/// small enough that the modal list it sits beside keeps most of the window.
350const SLOT_FRACTION: f32 = 0.32;
351
352/// Gap between the slot and the console's bottom and right edges, in device
353/// pixels at any console size — a fixed inset reads as a margin where a
354/// proportional one reads as an error at small sizes.
355const SLOT_MARGIN: f32 = 16.0;
356
357/// Below this, on either side, the preview is not a picture of anything and is
358/// better absent than misleading.
359const MIN_SIDE: f32 = 32.0;
360
361/// Where to draw the program preview inside a console surface, letterboxed.
362///
363/// **The aspect comes from `output` — the render target — and from nothing
364/// else** (ADR-0037). The console window's own aspect and the slot's are both
365/// containers: the returned rectangle fits inside the slot and keeps the
366/// output's shape, so a 16:9 show in a square slot gets bars above and below
367/// rather than a stretch. This project has shipped the other reading twice, and
368/// both times the tests were written where the two sources agree.
369///
370/// `None` when either size is degenerate or the slot comes out too small to be
371/// worth drawing.
372pub fn preview_rect(output: (u32, u32), console: (u32, u32)) -> Option<Rect> {
373    let (out_w, out_h) = (output.0 as f32, output.1 as f32);
374    let (con_w, con_h) = (console.0 as f32, console.1 as f32);
375    if out_w <= 0.0 || out_h <= 0.0 || con_w <= 0.0 || con_h <= 0.0 {
376        return None;
377    }
378    let aspect = out_w / out_h;
379    if !aspect.is_finite() || aspect <= 0.0 {
380        return None;
381    }
382
383    // The slot is a square fraction of the console; the picture is then fitted
384    // inside it. Two steps rather than one so the container's own shape cannot
385    // leak into the picture's.
386    let slot = (con_w.min(con_h) * SLOT_FRACTION).min(con_w - 2.0 * SLOT_MARGIN);
387    if slot < MIN_SIDE {
388        return None;
389    }
390
391    let (width, height) = if aspect >= 1.0 {
392        (slot, slot / aspect)
393    } else {
394        (slot * aspect, slot)
395    };
396    if width < MIN_SIDE || height < MIN_SIDE {
397        return None;
398    }
399
400    Some(Rect {
401        x: con_w - SLOT_MARGIN - width,
402        y: con_h - SLOT_MARGIN - height,
403        width,
404        height,
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    /// Two sizes chosen so no pair of them shares an aspect: a wide output, a
413    /// tall console, and a square one. At 16:9 against 16:9 — the shape the
414    /// dev box and every golden run at — the target's aspect and the
415    /// container's coincide, and no assertion written there can say which one
416    /// the code read.
417    const WIDE: (u32, u32) = (1920, 1080);
418    const TALL: (u32, u32) = (600, 1000);
419    const SQUARE: (u32, u32) = (900, 900);
420
421    fn approx(a: f32, b: f32) -> bool {
422        (a - b).abs() < 1e-3
423    }
424
425    #[test]
426    fn the_preview_carries_the_output_aspect_and_not_the_consoles() {
427        for console in [TALL, SQUARE, (1280, 400)] {
428            let rect = preview_rect(WIDE, console).expect("a preview fits in every console here");
429            let want = WIDE.0 as f32 / WIDE.1 as f32;
430            assert!(
431                approx(rect.aspect(), want),
432                "console {console:?}: preview aspect {} is not the output's {want} — a \
433                 container's shape reached the picture (ADR-0037)",
434                rect.aspect()
435            );
436        }
437    }
438
439    #[test]
440    fn a_portrait_output_letterboxes_the_other_way() {
441        // The control on the test above: if the code read the container rather
442        // than the target, this case and that one cannot both pass, because
443        // the two aspects cross over.
444        let rect = preview_rect(TALL, WIDE).expect("a preview fits");
445        let want = TALL.0 as f32 / TALL.1 as f32;
446        assert!(
447            approx(rect.aspect(), want),
448            "portrait output came back at {} rather than {want}",
449            rect.aspect()
450        );
451        assert!(
452            rect.height > rect.width,
453            "a portrait output must produce a taller-than-wide rectangle, got \
454             {}x{}",
455            rect.width,
456            rect.height
457        );
458    }
459
460    #[test]
461    fn the_rectangle_stays_inside_the_console_with_its_margin() {
462        for console in [WIDE, TALL, SQUARE] {
463            let rect = preview_rect(WIDE, console).expect("a preview fits");
464            assert!(
465                rect.x >= 0.0 && rect.y >= 0.0,
466                "{rect:?} starts off-surface"
467            );
468            assert!(
469                approx(rect.x + rect.width, console.0 as f32 - SLOT_MARGIN),
470                "{rect:?} is not inset from the right edge of {console:?}"
471            );
472            assert!(
473                approx(rect.y + rect.height, console.1 as f32 - SLOT_MARGIN),
474                "{rect:?} is not inset from the bottom edge of {console:?}"
475            );
476        }
477    }
478
479    #[test]
480    fn the_tap_keeps_the_shows_aspect_and_centres_the_bars() {
481        // 16:10 into 16:9: bars left and right, and the picture centred between
482        // them. The tap is a resolution and not a shape (ADR-0037), so the
483        // show's aspect survives and the tap's does not reach the picture.
484        assert_eq!(fit_rect((1280, 800), (640, 360)), (32, 0, 576, 360));
485        // The other way: 4:3 into 16:9 is the same rule, and 21:9 into 16:9 puts
486        // the bars above and below instead — the case a test written only at
487        // 16:9 cannot tell apart from a stretch.
488        assert_eq!(fit_rect((1024, 768), (640, 360)), (80, 0, 480, 360));
489        assert_eq!(fit_rect((2560, 1080), (640, 360)), (0, 45, 640, 270));
490    }
491
492    #[test]
493    fn a_matching_aspect_fills_the_tap_with_no_bars() {
494        // The control on the test above: where the two aspects agree there is
495        // nothing to letterbox, and a fit that still inset the picture would be
496        // shrinking the show for no reason.
497        assert_eq!(fit_rect((1920, 1080), (640, 360)), (0, 0, 640, 360));
498        assert_eq!(fit_rect((640, 360), (640, 360)), (0, 0, 640, 360));
499    }
500
501    #[test]
502    fn a_degenerate_size_still_produces_a_drawable_rectangle() {
503        // A window mid-minimize reports a zero dimension, and a zero-extent
504        // viewport is a wgpu validation error rather than an empty frame.
505        for (src, dst) in [((0, 0), (640, 360)), ((1920, 0), (640, 360))] {
506            let (_, _, width, height) = fit_rect(src, dst);
507            assert!(
508                width >= 1 && height >= 1,
509                "fit_rect({src:?}, {dst:?}) produced a {width}x{height} viewport"
510            );
511        }
512        assert_eq!(fit_rect((1920, 1080), (0, 0)), (0, 0, 1, 1));
513    }
514
515    #[test]
516    fn a_console_too_small_to_show_anything_gets_no_preview() {
517        assert_eq!(preview_rect(WIDE, (120, 90)), None);
518        assert_eq!(preview_rect(WIDE, (0, 0)), None);
519        assert_eq!(preview_rect((0, 1080), SQUARE), None);
520    }
521}