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}