Skip to main content

rlx_core/render/
aux_target.rs

1//! The secondary present target: a second surface on the renderer's *existing*
2//! device (ADR-0143).
3//!
4//! The core learns nothing about what the second window means. It is handed a
5//! window handle and a list of [`TextRun`]s, and it presents them. Every
6//! question about *which* rows, *what* they say and *when* they change stays in
7//! the shell, where the modal state machines already live.
8//!
9//! Behind the `text` feature, because a secondary target that carries no text
10//! and no picture has no consumer: the only frontend that opens one is the
11//! standalone, which enables the feature. The plugin's `cdylib`, the default
12//! `cargo build` and the core test suite compile this module out entirely,
13//! exactly as they do the text layer it is built on.
14
15// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). Runs
16// once per displayed frame while the target is attached; a panic here crashes
17// the app the operator is driving.
18#![deny(
19    clippy::unwrap_used,
20    clippy::expect_used,
21    clippy::indexing_slicing,
22    clippy::panic,
23    clippy::unreachable
24)]
25
26use super::RenderError;
27use super::context::RenderContext;
28use super::gpu;
29use super::panel::Panel;
30use super::preview::{PreviewTarget, preview_rect};
31use super::text::{TextLayer, TextRun};
32
33/// The program preview's blit: one positioned quad sampling the intermediate.
34///
35/// A quad and not a fullscreen triangle, because the preview is letterboxed
36/// into a corner of the console rather than filling it — the rectangle arrives
37/// as a uniform in NDC and the vertex shader interpolates the corners across it.
38///
39/// **Only the console samples the intermediate.** The show's own copy out of it
40/// is a `copy_texture_to_texture` with no shader in the path, which is what
41/// keeps the output exact; this side is a monitor and a resample is what it is
42/// for.
43struct Blit {
44    pipeline: wgpu::RenderPipeline,
45    layout: wgpu::BindGroupLayout,
46    sampler: wgpu::Sampler,
47    rect: wgpu::Buffer,
48    /// The bound intermediate's identity and the group built against it. Rebuilt
49    /// only when the renderer hands over a different intermediate — a resize or
50    /// a close/reopen — so the per-frame path creates no GPU resource.
51    bound: Option<(u64, wgpu::BindGroup)>,
52}
53
54/// The blit's shader. `rect` is `(x0, y0, x1, y1)` in NDC, with `y0` the top
55/// edge; `uv` runs `0..1` across the quad, which is already the texture's
56/// top-left-origin convention, so no flip is applied anywhere.
57const BLIT_WGSL: &str = r#"
58struct Rect { ndc: vec4<f32> };
59@group(0) @binding(0) var<uniform> rect: Rect;
60@group(0) @binding(1) var src: texture_2d<f32>;
61@group(0) @binding(2) var samp: sampler;
62
63struct VsOut {
64    @builtin(position) pos: vec4<f32>,
65    @location(0) uv: vec2<f32>,
66};
67
68@vertex
69fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
70    var corners = array<vec2<f32>, 6>(
71        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
72        vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
73    );
74    let c = corners[vi];
75    var out: VsOut;
76    out.pos = vec4<f32>(
77        mix(rect.ndc.x, rect.ndc.z, c.x),
78        mix(rect.ndc.y, rect.ndc.w, c.y),
79        0.0,
80        1.0,
81    );
82    out.uv = c;
83    return out;
84}
85
86@fragment
87fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
88    return vec4<f32>(textureSample(src, samp, in.uv).rgb, 1.0);
89}
90"#;
91
92impl Blit {
93    fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
94        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
95            label: Some("rlx-console-blit-layout"),
96            entries: &[
97                gpu::uniform(0, wgpu::ShaderStages::VERTEX),
98                gpu::texture(1, true),
99                gpu::sampler(2),
100            ],
101        });
102        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
103            label: Some("rlx-console-blit"),
104            source: wgpu::ShaderSource::Wgsl(BLIT_WGSL.into()),
105        });
106        let pipeline = gpu::fullscreen_pipeline(
107            device,
108            &shader,
109            &[&layout],
110            format,
111            wgpu::BlendState::REPLACE,
112            "rlx-console-blit",
113        );
114        Self {
115            pipeline,
116            layout,
117            sampler: device.create_sampler(&wgpu::SamplerDescriptor {
118                label: Some("rlx-console-blit-sampler"),
119                // Linear: the preview is a heavy minification of the show and a
120                // nearest sample of it aliases into unreadable noise. Exactness
121                // is the output copy's job, not this one's.
122                mag_filter: wgpu::FilterMode::Linear,
123                min_filter: wgpu::FilterMode::Linear,
124                ..Default::default()
125            }),
126            rect: gpu::uniform_buffer(
127                device,
128                "rlx-console-blit-rect",
129                std::mem::size_of::<[f32; 4]>(),
130            ),
131            bound: None,
132        }
133    }
134
135    /// The bind group for `preview`, rebuilt only when the intermediate's
136    /// identity has changed.
137    ///
138    /// `Option` rather than an infallible reference so the caller skips the
139    /// preview on the one path that cannot produce a group; this file denies
140    /// panics, and a console frame is worth nothing next to the show.
141    fn bind(&mut self, device: &wgpu::Device, preview: &PreviewTarget) -> Option<&wgpu::BindGroup> {
142        let generation = preview.generation();
143        if self.bound.as_ref().is_none_or(|(g, _)| *g != generation) {
144            let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
145                label: Some("rlx-console-blit-group"),
146                layout: &self.layout,
147                entries: &[
148                    wgpu::BindGroupEntry {
149                        binding: 0,
150                        resource: self.rect.as_entire_binding(),
151                    },
152                    wgpu::BindGroupEntry {
153                        binding: 1,
154                        resource: wgpu::BindingResource::TextureView(&preview.view),
155                    },
156                    wgpu::BindGroupEntry {
157                        binding: 2,
158                        resource: wgpu::BindingResource::Sampler(&self.sampler),
159                    },
160                ],
161            });
162            self.bound = Some((generation, group));
163        }
164        self.bound.as_ref().map(|(_, group)| group)
165    }
166}
167
168/// The background the secondary surface clears to before its text is
169/// composited. Near-black rather than black so an operator can tell a live
170/// console from a dead one across a dim room, and dark enough that it throws no
171/// usable light onto a stage.
172const CLEAR: wgpu::Color = wgpu::Color {
173    r: 0.02,
174    g: 0.02,
175    b: 0.025,
176    a: 1.0,
177};
178
179/// The present mode a secondary surface ended up with, so the shell can record
180/// which arm ran (ADR-0071 reporting: a frame-time measurement that does not
181/// name its present mode cannot be compared with another machine's).
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum AuxPresentMode {
184    /// A non-blocking mode was offered and taken: the console's present does
185    /// not block on its own display's vblank.
186    ///
187    /// That is a property of this surface's present, **not** a guarantee about
188    /// the output's cadence — the two presents still run on one thread, and
189    /// what the second costs the first is a measurement rather than a
190    /// deduction. Measured at Plan 0147 Phase 4 on an integrated Radeon: at the
191    /// 165 Hz vsync cap, 14,797 console presents cost the output 0.0 fps.
192    NonBlocking(&'static str),
193    /// Only `Fifo` was offered. The console presents in lockstep with its own
194    /// display, which is the configuration where a slower second monitor can be
195    /// felt on the output.
196    Fifo,
197}
198
199impl AuxPresentMode {
200    /// The mode's name, for the diagnostic log line.
201    pub fn as_str(self) -> &'static str {
202        match self {
203            Self::NonBlocking(name) => name,
204            Self::Fifo => "Fifo",
205        }
206    }
207}
208
209/// What [`AuxTarget::present`] did with the calls it was given, since attach.
210///
211/// The witness a cost measurement of this surface needs (ADR-0172). Every arm
212/// of the present path that returns without reaching `queue.present` returns
213/// the same `Ok(())` a successful present does, so a surface that was occluded
214/// for a whole run and one that presented every frame produce the same log and
215/// the same frame rate. `presented` is what separates them; a measurement
216/// reading zero cost against a zero present count has measured nothing.
217///
218/// `presented + skipped` is the number of calls the target received, which is
219/// what lets a caller reconcile its own totals: whatever it decimated, plus
220/// these two, is the frames it ran with this target attached.
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
222pub struct AuxCounts {
223    /// Calls that reached this surface's own `queue.present`.
224    pub presented: u64,
225    /// Calls that returned without presenting — the surface had no texture to
226    /// give, or refused validation.
227    pub skipped: u64,
228}
229
230/// A second swapchain plus its own text layer.
231///
232/// Its own layer, not the renderer's: glyphon's atlas and viewport are built
233/// against one surface format and one resolution, and the console's differ from
234/// the output's. Sharing one would make the console's size the output's, which
235/// is the bug ADR-0037 describes in its other clothes.
236pub struct AuxTarget {
237    surface: wgpu::Surface<'static>,
238    config: wgpu::SurfaceConfiguration,
239    text: TextLayer,
240    mode: AuxPresentMode,
241    blit: Blit,
242    counts: AuxCounts,
243}
244
245/// The range a secondary surface's `desired_maximum_frame_latency` is held to.
246///
247/// A depth of 0 configures no images and is rejected by the backend; past 3 the
248/// queue is deeper than any presentation engine here will run ahead, so the
249/// extra images cost memory and buy latency. The caller's value is clamped into
250/// this range at the boundary rather than validated and refused: it is a pacing
251/// hint, and a surface that will not attach is a worse answer than one that
252/// attaches at the nearest depth.
253const AUX_FRAME_LATENCY: std::ops::RangeInclusive<u32> = 1..=3;
254
255impl AuxTarget {
256    /// Attach a secondary surface for `target` to `ctx`'s device.
257    ///
258    /// `frame_latency` is the swapchain's `desired_maximum_frame_latency`,
259    /// clamped to `1..=3` — the range `AUX_FRAME_LATENCY` holds, whose doc
260    /// comment carries why those bounds (private, hence named rather than
261    /// linked). It is a **pacing** control and not a
262    /// picture one: at 1 the surface holds a single in-flight image, so
263    /// `get_current_texture` waits for this surface's own previous present to
264    /// retire before it returns — one vblank, spent on whichever thread calls
265    /// it. A caller presenting this surface from the same thread as another one
266    /// pays that wait inside that thread's frame.
267    ///
268    /// Fails — rather than panicking or degrading silently — when the surface
269    /// cannot be configured on the adapter this device was created on. That is
270    /// the dual-GPU path: a window on a monitor driven by the *other* GPU may
271    /// present no format this adapter can write. The caller degrades; the core
272    /// only reports.
273    pub fn new(
274        ctx: &RenderContext,
275        target: impl Into<wgpu::SurfaceTarget<'static>>,
276        width: u32,
277        height: u32,
278        frame_latency: u32,
279    ) -> Result<Self, RenderError> {
280        let surface = ctx
281            .instance
282            .create_surface(target)
283            .map_err(RenderError::CreateSurface)?;
284
285        let mut config = surface
286            .get_default_config(&ctx.gpu, width.max(1), height.max(1))
287            .ok_or(RenderError::UnsupportedSurface)?;
288
289        // A non-blocking mode where the surface offers one. The console must not
290        // become a second pacing source for the output: under `Fifo` on a slower
291        // display, `get_current_texture` blocks on *that* display's vblank, and
292        // the show's frame loop waits behind it. Mailbox first (tear-free),
293        // Immediate second, `Fifo` only when neither is offered — and the caps
294        // query is what decides, not an assumption about the backend.
295        let caps = surface.get_capabilities(&ctx.gpu);
296        let mode = if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
297            config.present_mode = wgpu::PresentMode::Mailbox;
298            AuxPresentMode::NonBlocking("Mailbox")
299        } else if caps.present_modes.contains(&wgpu::PresentMode::Immediate) {
300            config.present_mode = wgpu::PresentMode::Immediate;
301            AuxPresentMode::NonBlocking("Immediate")
302        } else {
303            config.present_mode = wgpu::PresentMode::Fifo;
304            AuxPresentMode::Fifo
305        };
306        config.desired_maximum_frame_latency =
307            frame_latency.clamp(*AUX_FRAME_LATENCY.start(), *AUX_FRAME_LATENCY.end());
308        surface.configure(&ctx.device, &config);
309
310        let text = TextLayer::new(&ctx.device, &ctx.queue, config.format);
311        let blit = Blit::new(&ctx.device, config.format);
312        Ok(Self {
313            surface,
314            config,
315            text,
316            mode,
317            blit,
318            counts: AuxCounts::default(),
319        })
320    }
321
322    /// The present mode this surface was configured with.
323    pub fn present_mode(&self) -> AuxPresentMode {
324        self.mode
325    }
326
327    /// What the present path has done since attach. Reset with the target: the
328    /// counts describe one open session, not the process.
329    pub fn counts(&self) -> AuxCounts {
330        self.counts
331    }
332
333    /// The frame latency this surface was configured with, **after clamping** —
334    /// so a caller reporting which arm ran quotes the depth the swapchain got
335    /// rather than the one it asked for.
336    pub fn frame_latency(&self) -> u32 {
337        self.config.desired_maximum_frame_latency
338    }
339
340    /// The surface's current size in physical pixels.
341    pub fn size(&self) -> (u32, u32) {
342        (self.config.width, self.config.height)
343    }
344
345    /// Reconfigure for a new size. A zero dimension is ignored — the window is
346    /// minimized and the old config stays valid for when it returns.
347    pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
348        if width == 0 || height == 0 {
349            return;
350        }
351        self.config.width = width;
352        self.config.height = height;
353        self.surface.configure(device, &self.config);
354    }
355
356    /// Draw `runs` onto the secondary surface and present it.
357    ///
358    /// Wholly independent of the output's frame: its own encoder, its own
359    /// submit, its own present. Nothing here touches the primary swapchain, the
360    /// scene clock or the dissolve, so a console that stalls or drops a frame
361    /// cannot alter the **pixels** the show puts on screen — which the golden
362    /// suite asserts byte-exactly.
363    ///
364    /// **It says nothing about when.** This runs on the display thread, so its
365    /// cost is inside the caller's frame whatever this surface's present mode
366    /// is; the separation above is of *state*, not of *time*. What that costs
367    /// is measured rather than argued — Plan 0147 Phase 4, five arms in three
368    /// frame-time regimes on an integrated Radeon, found it inside noise, with
369    /// [`AuxCounts`] beside each arm to prove the presents happened.
370    ///
371    /// **Every exit counts itself** into [`AuxCounts`]: the four surface states
372    /// that skip return the same `Ok(())` a present does, so without the
373    /// counter a caller cannot tell a console that ran from one that never
374    /// acquired a texture. The validation arm counts as a skip too — it is the
375    /// only exit that returns `Err`, and leaving it uncounted would break the
376    /// caller's reconciliation by one frame on exactly the frame the console
377    /// dies.
378    pub fn present(
379        &mut self,
380        ctx: &RenderContext,
381        runs: &[TextRun<'_>],
382        panels: &[Panel],
383        preview: Option<&PreviewTarget>,
384    ) -> Result<(), RenderError> {
385        use wgpu::CurrentSurfaceTexture as C;
386        let frame = match self.surface.get_current_texture() {
387            C::Success(frame) | C::Suboptimal(frame) => frame,
388            // Transient: the window is resizing, occluded or hidden. Skipping
389            // this console frame is correct, and the output is unaffected —
390            // which is the whole reason the console presents on its own encoder.
391            C::Timeout | C::Occluded => {
392                self.counts.skipped = self.counts.skipped.saturating_add(1);
393                return Ok(());
394            }
395            // Reconfigure and skip. Unlike the output path this does not retry
396            // in the same frame: a console frame is worth nothing and the next
397            // one is 16 ms away, so the retry would only add a stall the show
398            // could feel.
399            C::Outdated | C::Lost => {
400                self.counts.skipped = self.counts.skipped.saturating_add(1);
401                self.surface.configure(&ctx.device, &self.config);
402                return Ok(());
403            }
404            C::Validation => {
405                self.counts.skipped = self.counts.skipped.saturating_add(1);
406                return Err(RenderError::SurfaceValidation);
407            }
408        };
409
410        let view = frame
411            .texture
412            .create_view(&wgpu::TextureViewDescriptor::default());
413        let mut encoder = ctx
414            .device
415            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
416                label: Some("rlx-console-frame"),
417            });
418
419        self.text.queue(runs);
420        self.text.queue_panels(panels);
421        let (width, height) = (self.config.width, self.config.height);
422        let drew = self.text.prepare(&ctx.device, &ctx.queue, width, height);
423
424        // The preview's rectangle, in this surface's NDC. Its aspect comes from
425        // the intermediate — which is the *output* render target's size — so the
426        // console window's own shape never reaches the picture (ADR-0037).
427        // `None` here means no preview is open, or this console is too small to
428        // show one; either way the pass below just clears and draws text.
429        let quad = preview.and_then(|p| {
430            let rect = preview_rect(p.size(), (width, height))?;
431            let (w, h) = (width as f32, height as f32);
432            let ndc = [
433                rect.x / w * 2.0 - 1.0,
434                1.0 - rect.y / h * 2.0,
435                (rect.x + rect.width) / w * 2.0 - 1.0,
436                1.0 - (rect.y + rect.height) / h * 2.0,
437            ];
438            ctx.queue
439                .write_buffer(&self.blit.rect, 0, bytemuck::cast_slice(&ndc));
440            self.blit.bind(&ctx.device, p).is_some().then_some(())
441        });
442        // Re-borrowed immutably below rather than held across the pass: `bind`
443        // takes `&mut self.blit` to refresh its cache, and the pass needs the
444        // pipeline from the same field.
445        let quad = quad.and(self.blit.bound.as_ref().map(|(_, group)| group));
446
447        {
448            let mut pass = gpu::color_pass(
449                &mut encoder,
450                "rlx-console-pass",
451                &view,
452                wgpu::LoadOp::Clear(CLEAR),
453            );
454            // The preview first, the text over it: the modal list is what the
455            // operator is reading and the monitor must not cover it.
456            if let Some(group) = quad {
457                pass.set_pipeline(&self.blit.pipeline);
458                pass.set_bind_group(0, group, &[]);
459                pass.draw(0..6, 0..1);
460            }
461            if drew {
462                self.text.render_panels(&mut pass);
463                self.text.render(&mut pass);
464            }
465        }
466
467        ctx.queue.submit(std::iter::once(encoder.finish()));
468        ctx.queue.present(frame);
469        self.counts.presented = self.counts.presented.saturating_add(1);
470        self.text.end_frame();
471        Ok(())
472    }
473}