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}