Skip to main content

rlx_core/render/
context.rs

1//! wgpu device/surface ownership. All raw GPU access lives behind this layer
2//! (ADR-0001): scene code sees wgpu types, never a backend.
3
4// Hot-path panic-denial pragma (Plan 0002 Phase 2). GPU bring-up returns
5// Result; the render path must not panic.
6#![deny(
7    clippy::unwrap_used,
8    clippy::expect_used,
9    clippy::indexing_slicing,
10    clippy::panic,
11    clippy::unreachable
12)]
13
14use wgpu::{CreateSurfaceError, RequestAdapterError, RequestDeviceError, SurfaceTarget};
15
16use crate::audio::FormatError;
17
18/// Offscreen texture format for the headless capture path (Plan 0013). A tight
19/// 8-bit RGBA the readback strips straight into a [`crate::render::CaptureImage`].
20pub(crate) const HEADLESS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
21
22/// Something went wrong bringing up or drawing with the GPU context.
23#[derive(Debug)]
24pub enum RenderError {
25    /// Creating the wgpu surface for the window failed.
26    CreateSurface(CreateSurfaceError),
27    /// No GPU adapter compatible with the surface was found.
28    RequestAdapter(RequestAdapterError),
29    /// Requesting a logical device from the adapter failed.
30    RequestDevice(RequestDeviceError),
31    /// The surface reported no supported configuration on this adapter.
32    UnsupportedSurface,
33    /// Frames are being produced at a texture format whose channel order has no
34    /// name a frame consumer knows.
35    ///
36    /// Refused rather than published under a guessed name: a consumer reading
37    /// four bytes per pixel and told the wrong order draws the right picture in
38    /// the wrong colours, which looks like an authoring mistake rather than a
39    /// protocol one (ADR-0187).
40    UnnameablePixelOrder(wgpu::TextureFormat),
41    /// Acquiring the frame raised a validation error — a bug, not a
42    /// recoverable surface state.
43    SurfaceValidation,
44    /// A headless capture failed to map or read back its offscreen buffer
45    /// (Plan 0013 tooling path — never the live render path).
46    CaptureReadback,
47    /// A capture requested a preset name not in the loaded roster (Plan 0013).
48    UnknownPreset(String),
49    /// An audio-driven capture was handed a PCM format the analyzer rejected at
50    /// the intake boundary (Plan 0013).
51    AudioFormat(FormatError),
52    /// A requested graphics adapter is not on this machine. Carries the roster
53    /// so the message can name what is available rather than an index nobody
54    /// can interpret (ADR-0146).
55    NoSuchAdapter {
56        /// What the caller asked for, as they wrote it.
57        requested: String,
58        /// Every adapter this machine enumerates, described.
59        available: Vec<String>,
60    },
61    /// A requested adapter name matched more than one adapter. A substring
62    /// cannot separate two adapters whose descriptions share it, so the caller
63    /// is told which ones collided rather than handed an arbitrary pick.
64    AmbiguousAdapter {
65        /// What the caller asked for, as they wrote it.
66        requested: String,
67        /// The adapters the request matched.
68        matched: Vec<String>,
69    },
70    /// A named or indexed adapter exists on this machine but cannot present to
71    /// the window that asked for it.
72    ///
73    /// Only reachable on the surface path and only for the enumerated variants:
74    /// the preference variants hand the surface to wgpu as `compatible_surface`
75    /// and so cannot select an adapter that fails this, while `Named`/`Index`
76    /// pick out of the unfiltered roster. Refusing is the point — falling back
77    /// to a working adapter would render on a GPU the operator did not ask for
78    /// and say nothing, which is the failure `--gpu` exists to end (ADR-0155).
79    AdapterCannotPresent {
80        /// What the caller asked for, as they wrote it.
81        requested: String,
82        /// The adapter that request resolved to, described.
83        adapter: String,
84    },
85    /// The consumer of a **streamed** capture refused a frame — the offline
86    /// render mode's pipe closed, the encoder died, the file could not be
87    /// written (Plan 0101 / ADR-0114). The string is the consumer's own message,
88    /// carried verbatim rather than flattened to "write failed": that consumer
89    /// is a child process, and a mystery broken pipe is the obvious way this
90    /// path goes wrong.
91    Sink(String),
92}
93
94impl std::fmt::Display for RenderError {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            RenderError::CreateSurface(e) => write!(f, "surface creation failed: {e}"),
98            RenderError::RequestAdapter(e) => write!(f, "no suitable GPU adapter: {e}"),
99            RenderError::RequestDevice(e) => write!(f, "device request failed: {e}"),
100            RenderError::UnsupportedSurface => write!(f, "surface has no supported config"),
101            RenderError::UnnameablePixelOrder(format) => write!(
102                f,
103                "frames are produced at {format:?}, whose channel order has no \
104                 name a frame consumer knows (expected an 8-bit RGBA or BGRA \
105                 format)"
106            ),
107            RenderError::SurfaceValidation => {
108                write!(f, "surface texture acquisition failed validation")
109            }
110            RenderError::CaptureReadback => {
111                write!(f, "headless capture readback failed")
112            }
113            RenderError::UnknownPreset(name) => {
114                write!(f, "no preset named '{name}' in the roster")
115            }
116            RenderError::AudioFormat(e) => write!(f, "invalid audio format for capture: {e}"),
117            RenderError::NoSuchAdapter {
118                requested,
119                available,
120            } => write!(
121                f,
122                "no graphics adapter matching '{requested}'; this machine has: {}",
123                available.join("; ")
124            ),
125            RenderError::AmbiguousAdapter { requested, matched } => write!(
126                f,
127                "'{requested}' matches {} adapters: {}",
128                matched.len(),
129                matched.join("; ")
130            ),
131            RenderError::AdapterCannotPresent { requested, adapter } => write!(
132                f,
133                "'{requested}' resolves to {adapter}, which cannot draw into this window; \
134                 pick an adapter that can drive the display this window is on"
135            ),
136            RenderError::Sink(msg) => write!(f, "{msg}"),
137        }
138    }
139}
140
141impl std::error::Error for RenderError {}
142
143/// One line naming a GPU and its driver, for an ADR-0071 report.
144///
145/// Every field is taken verbatim from wgpu rather than interpreted; a report
146/// that paraphrases its machine is worse than one that quotes it. Empty fields
147/// are dropped so a backend that reports no driver string does not print an
148/// empty pair of parentheses.
149fn describe_adapter(info: &wgpu::AdapterInfo) -> String {
150    let mut out = if info.name.is_empty() {
151        "unnamed adapter".to_string()
152    } else {
153        info.name.clone()
154    };
155    out.push_str(&format!(" ({:?}, {:?})", info.backend, info.device_type));
156    if !info.driver.is_empty() {
157        out.push_str(&format!(", driver {}", info.driver));
158    }
159    if !info.driver_info.is_empty() {
160        out.push_str(&format!(" {}", info.driver_info));
161    }
162    out
163}
164
165/// Which graphics adapter a headless context should render on.
166///
167/// Stated in wgpu's own vocabulary and nothing else: no platform type, no
168/// vendor branch, no backend branch, so `core` stays GPU-abstract (ADR-0001)
169/// while still letting a shell say which GPU it means.
170///
171/// **The variants are not interchangeable views of one preference.** The first
172/// three ask wgpu to choose and accept whatever it returns; the last two name
173/// one adapter out of the enumerated roster and fail if it is not there.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub enum AdapterChoice {
176    /// Whatever wgpu picks with default options. On a hybrid machine this is
177    /// the power-saving GPU for a console process, which is why a live path
178    /// wants `HighPerformance` instead.
179    ///
180    /// The `Default` **impl** resolves here, which is what keeps every caller
181    /// that does not care — the C ABI window path included — asking for exactly
182    /// what it asked for before the choice existed.
183    #[default]
184    Default,
185    /// Force a fallback (software) adapter - WARP on DX12 - so captures
186    /// rasterize identically across machines. What the golden suite asks for.
187    Software,
188    /// `PowerPreference::HighPerformance`: the discrete GPU on a hybrid
189    /// machine.
190    HighPerformance,
191    /// The one enumerated adapter whose name contains this string, matched
192    /// case-insensitively. More than one match is an error, not a pick.
193    Named(String),
194    /// The adapter at this position in [`list_adapters`]'s roster.
195    Index(usize),
196}
197
198impl From<bool> for AdapterChoice {
199    /// The `prefer_software` bool every capture path already passes.
200    fn from(prefer_software: bool) -> Self {
201        if prefer_software {
202            AdapterChoice::Software
203        } else {
204            AdapterChoice::Default
205        }
206    }
207}
208
209/// One enumerated adapter: the name a caller matches against, and the full
210/// description a caller prints.
211///
212/// Two fields because the two jobs want different strings. `name` is wgpu's
213/// bare `AdapterInfo::name`, which is what a substring is matched against and
214/// what a DXGI `Description` is expected to equal; `detail` adds backend,
215/// device type and driver, which help a reader choose and would wreck a match.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct AdapterDescription {
218    /// wgpu's bare adapter name - the match key.
219    pub name: String,
220    /// Name plus backend, device type and driver, for printing.
221    pub detail: String,
222}
223
224/// Every graphics adapter wgpu enumerates on this machine, in wgpu's order.
225///
226/// The order is the enumeration's own and is **not** promised to agree with any
227/// other API's roster; a caller that needs one adapter across two APIs matches
228/// by name on each side rather than by shared index (ADR-0146).
229pub fn list_adapters() -> Vec<AdapterDescription> {
230    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
231    describe_roster(&instance)
232}
233
234fn describe_roster(instance: &wgpu::Instance) -> Vec<AdapterDescription> {
235    pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
236        .iter()
237        .map(|adapter| {
238            let info = adapter.get_info();
239            AdapterDescription {
240                name: info.name.clone(),
241                detail: describe_adapter(&info),
242            }
243        })
244        .collect()
245}
246
247/// Resolve a choice to one adapter, or say why it could not be.
248///
249/// `surface`, when present, is the window this adapter has to be able to draw
250/// into, and it constrains the two kinds of variant differently. The preference
251/// variants pass it to wgpu as `compatible_surface`, so wgpu picks only from
252/// adapters that can present to it. The enumerated variants — `Named` and
253/// `Index` — name one adapter out of the whole roster, which wgpu has not
254/// filtered, so the surface check is ours to make: an operator can name the one
255/// adapter that cannot drive their window, and the honest answer is a refusal
256/// that says so rather than a silent fall-back to a different GPU (ADR-0155).
257fn resolve_adapter(
258    instance: &wgpu::Instance,
259    choice: &AdapterChoice,
260    surface: Option<&wgpu::Surface<'static>>,
261) -> Result<wgpu::Adapter, RenderError> {
262    let by_preference = |options: wgpu::RequestAdapterOptions<'_, '_>| {
263        pollster::block_on(instance.request_adapter(&options)).map_err(RenderError::RequestAdapter)
264    };
265    // Every enumerated pick goes through here, so the surface constraint cannot
266    // be honoured on one variant and forgotten on the other.
267    let presenting = |adapter: wgpu::Adapter, requested: &str| match surface {
268        Some(surface) if !adapter.is_surface_supported(surface) => {
269            Err(RenderError::AdapterCannotPresent {
270                requested: requested.to_owned(),
271                adapter: describe_adapter(&adapter.get_info()),
272            })
273        }
274        _ => Ok(adapter),
275    };
276    match choice {
277        AdapterChoice::Default => by_preference(wgpu::RequestAdapterOptions {
278            compatible_surface: surface,
279            ..Default::default()
280        }),
281        AdapterChoice::Software => by_preference(wgpu::RequestAdapterOptions {
282            force_fallback_adapter: true,
283            compatible_surface: surface,
284            ..Default::default()
285        }),
286        AdapterChoice::HighPerformance => by_preference(wgpu::RequestAdapterOptions {
287            power_preference: wgpu::PowerPreference::HighPerformance,
288            compatible_surface: surface,
289            ..Default::default()
290        }),
291        AdapterChoice::Index(wanted) => {
292            let roster = describe_roster(instance);
293            let picked = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
294                .into_iter()
295                .nth(*wanted)
296                .ok_or_else(|| RenderError::NoSuchAdapter {
297                    requested: format!("index {wanted}"),
298                    available: roster.into_iter().map(|entry| entry.detail).collect(),
299                })?;
300            presenting(picked, &format!("index {wanted}"))
301        }
302        AdapterChoice::Named(wanted) => {
303            let needle = wanted.to_lowercase();
304            let roster = describe_roster(instance);
305            let hits: Vec<usize> = roster
306                .iter()
307                .enumerate()
308                .filter(|(_, entry)| entry.name.to_lowercase().contains(&needle))
309                .map(|(at, _)| at)
310                .collect();
311            match hits.as_slice() {
312                [] => Err(RenderError::NoSuchAdapter {
313                    requested: wanted.clone(),
314                    available: roster.into_iter().map(|entry| entry.detail).collect(),
315                }),
316                [only] => {
317                    let picked =
318                        pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
319                            .into_iter()
320                            .nth(*only)
321                            .ok_or_else(|| RenderError::NoSuchAdapter {
322                                requested: wanted.clone(),
323                                available: roster
324                                    .iter()
325                                    .map(|entry| entry.detail.clone())
326                                    .collect(),
327                            })?;
328                    presenting(picked, wanted)
329                }
330                several => Err(RenderError::AmbiguousAdapter {
331                    requested: wanted.clone(),
332                    matched: several
333                        .iter()
334                        .filter_map(|at| roster.get(*at).map(|entry| entry.detail.clone()))
335                        .collect(),
336                }),
337            }
338        }
339    }
340}
341
342/// Owns the wgpu instance, surface, device, and queue for one output window.
343///
344/// `surface` is `None` for a **headless** context (Plan 0013): a device+queue
345/// with no swapchain, drawing into offscreen capture textures. The on-surface
346/// present path always has `Some`; `config` still carries the render size and
347/// format for both paths.
348pub struct RenderContext {
349    pub(crate) surface: Option<wgpu::Surface<'static>>,
350    pub(crate) device: wgpu::Device,
351    pub(crate) queue: wgpu::Queue,
352    pub(crate) config: wgpu::SurfaceConfiguration,
353    /// The instance the primary surface came from, and the adapter the device
354    /// was requested on. Both are retained solely so a *secondary* surface can
355    /// be created later on this same device (ADR-0143): a surface is only
356    /// usable with a device whose adapter came from the same instance, and
357    /// `get_default_config` needs the adapter to negotiate a format. Retaining
358    /// them costs two handles and no GPU memory.
359    ///
360    /// Both are `Some` on the on-surface path. The headless path leaves them
361    /// filled too — it has both in hand — but nothing there attaches an
362    /// auxiliary surface.
363    pub(crate) instance: wgpu::Instance,
364    pub(crate) gpu: wgpu::Adapter,
365    /// Whether the selected adapter is a CPU/software rasterizer (WARP on DX12,
366    /// llvmpipe on Vulkan). The headless capture path forces this for
367    /// reproducibility; visual-QA tests read it to skip checks the software
368    /// rasterizer can't render faithfully (e.g. fullscreen-scene + background
369    /// pipeline coexistence, a documented WARP quirk).
370    is_software: bool,
371    /// The selected adapter's own description — name, backend, device type and
372    /// driver — kept as a formatted string rather than as `wgpu::AdapterInfo` so
373    /// no consumer has to name a wgpu type to read it.
374    ///
375    /// **This exists for `ADR-0071` reports, and only for them.** A frame time is
376    /// a fact about a GPU and a driver rather than about the code, so a test that
377    /// prints one has to be able to say which GPU and which driver; before Plan
378    /// 0113 Phase 2 nothing in the crate could. Nothing on a render path reads
379    /// it.
380    adapter: String,
381}
382
383impl RenderContext {
384    /// Create a context rendering into `target` (any window-handle provider —
385    /// the core never sees the windowing library behind it).
386    pub fn new(
387        target: impl Into<SurfaceTarget<'static>>,
388        width: u32,
389        height: u32,
390        adapter: &AdapterChoice,
391    ) -> Result<Self, RenderError> {
392        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
393        let surface = instance
394            .create_surface(target)
395            .map_err(RenderError::CreateSurface)?;
396        Self::from_surface(&instance, surface, width, height, adapter)
397    }
398
399    /// Context from raw display/window handles — the C ABI path, where the
400    /// host (e.g. the foobar2000 shim) owns the window.
401    ///
402    /// # Safety
403    /// The handles must be valid and the window must outlive this context.
404    pub unsafe fn new_unsafe(
405        target: wgpu::SurfaceTargetUnsafe,
406        width: u32,
407        height: u32,
408        adapter: &AdapterChoice,
409    ) -> Result<Self, RenderError> {
410        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
411        let surface = unsafe { instance.create_surface_unsafe(target) }
412            .map_err(RenderError::CreateSurface)?;
413        Self::from_surface(&instance, surface, width, height, adapter)
414    }
415
416    fn from_surface(
417        instance: &wgpu::Instance,
418        surface: wgpu::Surface<'static>,
419        width: u32,
420        height: u32,
421        choice: &AdapterChoice,
422    ) -> Result<Self, RenderError> {
423        let adapter = resolve_adapter(instance, choice, Some(&surface))?;
424        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
425            label: Some("rlx-device"),
426            ..Default::default()
427        }))
428        .map_err(RenderError::RequestDevice)?;
429
430        let mut config = surface
431            .get_default_config(&adapter, width.max(1), height.max(1))
432            .ok_or(RenderError::UnsupportedSurface)?;
433        // Vsync everywhere; the render loop paces itself off the display.
434        config.present_mode = wgpu::PresentMode::AutoVsync;
435        // Explicit swapchain depth (NFR 12 secondary lever): pin a 2-frame
436        // latency (double-buffered) rather than leaving it to the backend
437        // default, so the in-flight image count - and its VRAM - is bounded and
438        // stated, not implicit.
439        config.desired_maximum_frame_latency = 2;
440        // `COPY_DST` where the surface offers it, so a frame drawn into the
441        // preview intermediate can reach this swapchain by an exact
442        // `copy_texture_to_texture` rather than through a sampling blit, which
443        // would round-trip the encoded values ADR-0096 dithers. A usage flag
444        // costs nothing while nothing copies; the caps query is what decides,
445        // and `Renderer::open_preview` reports the refusal rather than
446        // degrading to an inexact path behind the operator's back.
447        let caps = surface.get_capabilities(&adapter);
448        if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
449            config.usage |= wgpu::TextureUsages::COPY_DST;
450        }
451        surface.configure(&device, &config);
452
453        let info = adapter.get_info();
454        let is_software = info.device_type == wgpu::DeviceType::Cpu;
455        let description = describe_adapter(&info);
456        Ok(Self {
457            surface: Some(surface),
458            device,
459            queue,
460            config,
461            is_software,
462            adapter: description,
463            instance: instance.clone(),
464            gpu: adapter,
465        })
466    }
467
468    /// Build a surface-less context for headless capture (Plan 0013): a device
469    /// and queue with no swapchain, drawing into offscreen textures. No window,
470    /// no present, no added dependency. `prefer_software` forces a fallback
471    /// adapter (WARP on DX12) so tests rasterize identically on any machine.
472    ///
473    /// The synthesized [`wgpu::SurfaceConfiguration`] carries only the render
474    /// size and the offscreen format (`HEADLESS_FORMAT`); its present-related
475    /// fields are inert with no surface to configure.
476    pub fn new_headless(
477        width: u32,
478        height: u32,
479        prefer_software: bool,
480    ) -> Result<Self, RenderError> {
481        Self::new_headless_on(width, height, &AdapterChoice::from(prefer_software))
482    }
483
484    /// A headless context on a **named** adapter (ADR-0146).
485    ///
486    /// The one real constructor of the two; [`new_headless`](Self::new_headless)
487    /// delegates here. It exists because a live video-out has to render on a
488    /// GPU the operator can name - on a hybrid machine Windows hands a console
489    /// process the power-saving one - while every capture path wants exactly
490    /// the adapter it already asks for.
491    pub fn new_headless_on(
492        width: u32,
493        height: u32,
494        choice: &AdapterChoice,
495    ) -> Result<Self, RenderError> {
496        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
497        // No surface: a headless context presents to nothing, so every adapter
498        // on the machine is a candidate and there is no compatibility to check.
499        let adapter = resolve_adapter(&instance, choice, None)?;
500        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
501            label: Some("rlx-headless-device"),
502            ..Default::default()
503        }))
504        .map_err(RenderError::RequestDevice)?;
505
506        let config = wgpu::SurfaceConfiguration {
507            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
508            format: HEADLESS_FORMAT,
509            color_space: wgpu::SurfaceColorSpace::Auto,
510            width: width.max(1),
511            height: height.max(1),
512            present_mode: wgpu::PresentMode::AutoVsync,
513            desired_maximum_frame_latency: 2,
514            alpha_mode: wgpu::CompositeAlphaMode::Auto,
515            view_formats: vec![],
516        };
517
518        let info = adapter.get_info();
519        let is_software = info.device_type == wgpu::DeviceType::Cpu;
520        let description = describe_adapter(&info);
521        Ok(Self {
522            surface: None,
523            device,
524            queue,
525            config,
526            is_software,
527            adapter: description,
528            instance,
529            gpu: adapter,
530        })
531    }
532
533    /// Reconfigure the surface for a new size (a zero dimension is ignored).
534    pub fn resize(&mut self, width: u32, height: u32) {
535        if width == 0 || height == 0 {
536            return; // minimized; keep the old config until we're visible again
537        }
538        self.config.width = width;
539        self.config.height = height;
540        if let Some(surface) = &self.surface {
541            surface.configure(&self.device, &self.config);
542        }
543    }
544
545    /// The texture format the surface is configured with.
546    pub fn surface_format(&self) -> wgpu::TextureFormat {
547        self.config.format
548    }
549
550    /// Whether this context's frame destination accepts a texture-to-texture
551    /// copy — the requirement the program preview's exact path rests on.
552    ///
553    /// With a surface it is what the swapchain was actually configured with,
554    /// which `from_surface` sets only where the surface's capabilities offer it.
555    /// Headless there is no swapchain and the destination is a capture target,
556    /// which is built with `COPY_DST` unconditionally.
557    pub(crate) fn can_copy_to_target(&self) -> bool {
558        match self.surface {
559            Some(_) => self.config.usage.contains(wgpu::TextureUsages::COPY_DST),
560            None => true,
561        }
562    }
563
564    /// Whether the active adapter is a CPU/software rasterizer (see the field).
565    pub(crate) fn is_software(&self) -> bool {
566        self.is_software
567    }
568
569    /// The active adapter's description — name, backend, device type, driver —
570    /// for a report that has to name the machine it was taken on (ADR-0071).
571    pub(crate) fn adapter(&self) -> &str {
572        &self.adapter
573    }
574
575    /// Re-apply the current configuration (after a Lost/Outdated surface).
576    /// A no-op on a headless context (no surface to reconfigure).
577    pub(crate) fn reconfigure(&self) {
578        if let Some(surface) = &self.surface {
579            surface.configure(&self.device, &self.config);
580        }
581    }
582}