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}