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}