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