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 /// An adapter change was asked of a context that has no surface — the
93 /// headless capture path, which renders on the adapter it was built on
94 /// for the life of the run (ADR-0246). Refused by name rather than
95 /// ignored, so a caller cannot take a switch that never happened for one
96 /// that did; [`adapter_change_permitted`] is the condition.
97 Headless,
98}
99
100impl std::fmt::Display for RenderError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 RenderError::CreateSurface(e) => write!(f, "surface creation failed: {e}"),
104 RenderError::RequestAdapter(e) => write!(f, "no suitable GPU adapter: {e}"),
105 RenderError::RequestDevice(e) => write!(f, "device request failed: {e}"),
106 RenderError::UnsupportedSurface => write!(f, "surface has no supported config"),
107 RenderError::UnnameablePixelOrder(format) => write!(
108 f,
109 "frames are produced at {format:?}, whose channel order has no \
110 name a frame consumer knows (expected an 8-bit RGBA or BGRA \
111 format)"
112 ),
113 RenderError::SurfaceValidation => {
114 write!(f, "surface texture acquisition failed validation")
115 }
116 RenderError::CaptureReadback => {
117 write!(f, "headless capture readback failed")
118 }
119 RenderError::UnknownPreset(name) => {
120 write!(f, "no preset named '{name}' in the roster")
121 }
122 RenderError::AudioFormat(e) => write!(f, "invalid audio format for capture: {e}"),
123 RenderError::NoSuchAdapter {
124 requested,
125 available,
126 } => write!(
127 f,
128 "no graphics adapter matching '{requested}'; this machine has: {}",
129 available.join("; ")
130 ),
131 RenderError::AmbiguousAdapter { requested, matched } => write!(
132 f,
133 "'{requested}' matches {} adapters: {}",
134 matched.len(),
135 matched.join("; ")
136 ),
137 RenderError::AdapterCannotPresent { requested, adapter } => write!(
138 f,
139 "'{requested}' resolves to {adapter}, which cannot draw into this window; \
140 pick an adapter that can drive the display this window is on"
141 ),
142 RenderError::Sink(msg) => write!(f, "{msg}"),
143 RenderError::Headless => write!(
144 f,
145 "this renderer has no window surface, so it cannot change adapter; a \
146 headless capture renders on the adapter it was built on"
147 ),
148 }
149 }
150}
151
152/// **Whether a runtime adapter change is allowed at all** (ADR-0246): only on
153/// a context that has a surface.
154///
155/// The same guard [`super::tier::tier_change_permitted`] gives the tier, and
156/// for the same reason: a surface-less context is the headless capture path,
157/// where the adapter is part of what makes a capture a pure function of its
158/// inputs (NFR 6) — a baseline is blessed on one rasterizer, and a public
159/// mutator that could move a capture onto another mid-run would be the hole in
160/// that guarantee. Expressed as a value rather than a branch so both
161/// directions are assertable: a `Renderer` **with** a surface cannot be built
162/// in CI, and a test that only observed the headless refusal would pass
163/// against a `set_adapter` that did nothing at all.
164pub fn adapter_change_permitted(has_surface: bool) -> bool {
165 has_surface
166}
167
168impl std::error::Error for RenderError {}
169
170/// One line naming a GPU and its driver, for an ADR-0071 report.
171///
172/// Every field is taken verbatim from wgpu rather than interpreted; a report
173/// that paraphrases its machine is worse than one that quotes it. Empty fields
174/// are dropped so a backend that reports no driver string does not print an
175/// empty pair of parentheses.
176fn describe_adapter(info: &wgpu::AdapterInfo) -> String {
177 let mut out = if info.name.is_empty() {
178 "unnamed adapter".to_string()
179 } else {
180 info.name.clone()
181 };
182 out.push_str(&format!(" ({:?}, {:?})", info.backend, info.device_type));
183 if !info.driver.is_empty() {
184 out.push_str(&format!(", driver {}", info.driver));
185 }
186 if !info.driver_info.is_empty() {
187 out.push_str(&format!(" {}", info.driver_info));
188 }
189 out
190}
191
192/// The device features to ask `adapter` for: the ones the engine can **use**
193/// where it offers them, and nothing it cannot run without.
194///
195/// wgpu grants a device exactly the requested set, so a feature not named here
196/// is unavailable even on hardware that has it — and a feature named here that
197/// the adapter lacks makes `request_device` fail outright. Intersecting with
198/// the adapter's own set is what makes this a capability query rather than a
199/// requirement.
200///
201/// **`TIMESTAMP_QUERY` is the whole list**, and it buys per-pass GPU timings
202/// for a tapped run ([`PassTimer`](super::gpu::PassTimer)). Pass-boundary
203/// writes only, so `TIMESTAMP_QUERY_INSIDE_PASSES` — which tile-based GPUs
204/// generally do not have — is deliberately not asked for. An adapter without
205/// even this one (the software rasterizers) renders exactly as before and
206/// reports no table.
207fn optional_features(adapter: &wgpu::Adapter) -> wgpu::Features {
208 adapter.features() & wgpu::Features::TIMESTAMP_QUERY
209}
210
211/// Which graphics adapter a headless context should render on.
212///
213/// Stated in wgpu's own vocabulary and nothing else: no platform type, no
214/// vendor branch, no backend branch, so `core` stays GPU-abstract (ADR-0001)
215/// while still letting a shell say which GPU it means.
216///
217/// **The variants are not interchangeable views of one preference.** The first
218/// three ask wgpu to choose and accept whatever it returns; the last two name
219/// one adapter out of the enumerated roster and fail if it is not there.
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub enum AdapterChoice {
222 /// Whatever wgpu picks with default options. On a hybrid machine this is
223 /// the power-saving GPU for a console process, which is why a live path
224 /// wants `HighPerformance` instead.
225 ///
226 /// The `Default` **impl** resolves here, which is what keeps every caller
227 /// that does not care — the C ABI window path included — asking for exactly
228 /// what it asked for before the choice existed.
229 #[default]
230 Default,
231 /// Force a fallback (software) adapter - WARP on DX12 - so captures
232 /// rasterize identically across machines. What the golden suite asks for.
233 Software,
234 /// `PowerPreference::HighPerformance`: the discrete GPU on a hybrid
235 /// machine.
236 HighPerformance,
237 /// The one enumerated adapter whose name contains this string, matched
238 /// case-insensitively — an adapter whose whole name **equals** it wins
239 /// over any that merely contain it, so a full name read back from the
240 /// roster resolves to exactly that adapter even where it is a prefix of
241 /// another's. More than one match is an error, not a pick.
242 Named(String),
243 /// The adapter at this position in [`list_adapters`]'s roster.
244 Index(usize),
245}
246
247impl From<bool> for AdapterChoice {
248 /// The `prefer_software` bool every capture path already passes.
249 fn from(prefer_software: bool) -> Self {
250 if prefer_software {
251 AdapterChoice::Software
252 } else {
253 AdapterChoice::Default
254 }
255 }
256}
257
258/// One enumerated adapter: the name a caller matches against, and the full
259/// description a caller prints.
260///
261/// Two fields because the two jobs want different strings. `name` is wgpu's
262/// bare `AdapterInfo::name`, which is what a substring is matched against and
263/// what a DXGI `Description` is expected to equal; `detail` adds backend,
264/// device type and driver, which help a reader choose and would wreck a match.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct AdapterDescription {
267 /// wgpu's bare adapter name - the match key.
268 pub name: String,
269 /// Name plus backend, device type and driver, for printing.
270 pub detail: String,
271}
272
273/// Every graphics adapter wgpu enumerates on this machine, in wgpu's order.
274///
275/// The order is the enumeration's own and is **not** promised to agree with any
276/// other API's roster; a caller that needs one adapter across two APIs matches
277/// by name on each side rather than by shared index (ADR-0146).
278pub fn list_adapters() -> Vec<AdapterDescription> {
279 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
280 describe_roster(&instance)
281}
282
283fn describe_roster(instance: &wgpu::Instance) -> Vec<AdapterDescription> {
284 pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
285 .iter()
286 .map(|adapter| {
287 let info = adapter.get_info();
288 AdapterDescription {
289 name: info.name.clone(),
290 detail: describe_adapter(&info),
291 }
292 })
293 .collect()
294}
295
296/// Resolve a choice to one adapter, or say why it could not be.
297///
298/// `surface`, when present, is the window this adapter has to be able to draw
299/// into, and it constrains the two kinds of variant differently. The preference
300/// variants pass it to wgpu as `compatible_surface`, so wgpu picks only from
301/// adapters that can present to it. The enumerated variants — `Named` and
302/// `Index` — name one adapter out of the whole roster, which wgpu has not
303/// filtered, so the surface check is ours to make: an operator can name the one
304/// adapter that cannot drive their window, and the honest answer is a refusal
305/// that says so rather than a silent fall-back to a different GPU (ADR-0155).
306fn resolve_adapter(
307 instance: &wgpu::Instance,
308 choice: &AdapterChoice,
309 surface: Option<&wgpu::Surface<'static>>,
310) -> Result<wgpu::Adapter, RenderError> {
311 let by_preference = |options: wgpu::RequestAdapterOptions<'_, '_>| {
312 pollster::block_on(instance.request_adapter(&options)).map_err(RenderError::RequestAdapter)
313 };
314 // Every enumerated pick goes through here, so the surface constraint cannot
315 // be honoured on one variant and forgotten on the other.
316 let presenting = |adapter: wgpu::Adapter, requested: &str| match surface {
317 Some(surface) if !adapter.is_surface_supported(surface) => {
318 Err(RenderError::AdapterCannotPresent {
319 requested: requested.to_owned(),
320 adapter: describe_adapter(&adapter.get_info()),
321 })
322 }
323 _ => Ok(adapter),
324 };
325 match choice {
326 AdapterChoice::Default => by_preference(wgpu::RequestAdapterOptions {
327 compatible_surface: surface,
328 ..Default::default()
329 }),
330 AdapterChoice::Software => by_preference(wgpu::RequestAdapterOptions {
331 force_fallback_adapter: true,
332 compatible_surface: surface,
333 ..Default::default()
334 }),
335 AdapterChoice::HighPerformance => by_preference(wgpu::RequestAdapterOptions {
336 power_preference: wgpu::PowerPreference::HighPerformance,
337 compatible_surface: surface,
338 ..Default::default()
339 }),
340 AdapterChoice::Index(wanted) => {
341 let roster = describe_roster(instance);
342 let picked = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
343 .into_iter()
344 .nth(*wanted)
345 .ok_or_else(|| RenderError::NoSuchAdapter {
346 requested: format!("index {wanted}"),
347 available: roster.into_iter().map(|entry| entry.detail).collect(),
348 })?;
349 presenting(picked, &format!("index {wanted}"))
350 }
351 AdapterChoice::Named(wanted) => {
352 let needle = wanted.to_lowercase();
353 let roster = describe_roster(instance);
354 // Exact before containment: a name written back from this roster
355 // — by the settings row into `[output] gpu` — must select the
356 // adapter it was read from, and a substring rule alone would call
357 // `RTX 3080` ambiguous beside `RTX 3080 Ti`.
358 let exact: Vec<usize> = roster
359 .iter()
360 .enumerate()
361 .filter(|(_, entry)| entry.name.to_lowercase() == needle)
362 .map(|(at, _)| at)
363 .collect();
364 let hits: Vec<usize> = if exact.len() == 1 {
365 exact
366 } else {
367 roster
368 .iter()
369 .enumerate()
370 .filter(|(_, entry)| entry.name.to_lowercase().contains(&needle))
371 .map(|(at, _)| at)
372 .collect()
373 };
374 match hits.as_slice() {
375 [] => Err(RenderError::NoSuchAdapter {
376 requested: wanted.clone(),
377 available: roster.into_iter().map(|entry| entry.detail).collect(),
378 }),
379 [only] => {
380 let picked =
381 pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()))
382 .into_iter()
383 .nth(*only)
384 .ok_or_else(|| RenderError::NoSuchAdapter {
385 requested: wanted.clone(),
386 available: roster
387 .iter()
388 .map(|entry| entry.detail.clone())
389 .collect(),
390 })?;
391 presenting(picked, wanted)
392 }
393 several => Err(RenderError::AmbiguousAdapter {
394 requested: wanted.clone(),
395 matched: several
396 .iter()
397 .filter_map(|at| roster.get(*at).map(|entry| entry.detail.clone()))
398 .collect(),
399 }),
400 }
401 }
402 }
403}
404
405/// The kind of adapter a context runs on, as far as the renderer's own
406/// decisions care (ADR-0245).
407///
408/// Crate-local on purpose: it feeds one table (`tier::grid_scale_for`) and no
409/// scene ever branches on it, so a scene cannot grow a per-GPU look. Everything
410/// wgpu names beyond these four — a virtual GPU, an unknown type — is `Other`,
411/// which the table treats as it treats a discrete part.
412#[derive(Clone, Copy, Debug, PartialEq, Eq)]
413pub(crate) enum AdapterClass {
414 /// A GPU sharing system memory with the CPU — the bandwidth-bound case the
415 /// grid scale exists for.
416 Integrated,
417 /// A GPU with its own memory.
418 Discrete,
419 /// A CPU rasterizer (WARP, llvmpipe) — what every golden capture runs on.
420 Software,
421 /// Anything wgpu reports that is none of the above.
422 Other,
423}
424
425impl AdapterClass {
426 /// The class of an adapter wgpu describes as `device_type`.
427 pub(crate) fn of(device_type: wgpu::DeviceType) -> Self {
428 match device_type {
429 wgpu::DeviceType::IntegratedGpu => AdapterClass::Integrated,
430 wgpu::DeviceType::DiscreteGpu => AdapterClass::Discrete,
431 wgpu::DeviceType::Cpu => AdapterClass::Software,
432 wgpu::DeviceType::VirtualGpu | wgpu::DeviceType::Other => AdapterClass::Other,
433 }
434 }
435}
436
437/// Owns the wgpu instance, surface, device, and queue for one output window.
438///
439/// `surface` is `None` for a **headless** context (Plan 0013): a device+queue
440/// with no swapchain, drawing into offscreen capture textures. The on-surface
441/// present path always has `Some`; `config` still carries the render size and
442/// format for both paths.
443pub struct RenderContext {
444 pub(crate) surface: Option<wgpu::Surface<'static>>,
445 pub(crate) device: wgpu::Device,
446 pub(crate) queue: wgpu::Queue,
447 pub(crate) config: wgpu::SurfaceConfiguration,
448 /// The instance the primary surface came from, and the adapter the device
449 /// was requested on. Both are retained solely so a *secondary* surface can
450 /// be created later on this same device (ADR-0143): a surface is only
451 /// usable with a device whose adapter came from the same instance, and
452 /// `get_default_config` needs the adapter to negotiate a format. Retaining
453 /// them costs two handles and no GPU memory.
454 ///
455 /// Both are `Some` on the on-surface path. The headless path leaves them
456 /// filled too — it has both in hand — but nothing there attaches an
457 /// auxiliary surface.
458 pub(crate) instance: wgpu::Instance,
459 pub(crate) gpu: wgpu::Adapter,
460 /// Whether the selected adapter is a CPU/software rasterizer (WARP on DX12,
461 /// llvmpipe on Vulkan). The headless capture path forces this for
462 /// reproducibility; visual-QA tests read it to skip checks the software
463 /// rasterizer can't render faithfully (e.g. fullscreen-scene + background
464 /// pipeline coexistence, a documented WARP quirk).
465 is_software: bool,
466 /// What kind of adapter this is, for the one decision that reads it: the
467 /// internal-grid scale (ADR-0245). Set beside [`is_software`](Self::is_software)
468 /// from the same `device_type`, so the two cannot disagree about a CPU
469 /// rasterizer.
470 class: AdapterClass,
471 /// The selected adapter's own description — name, backend, device type and
472 /// driver — kept as a formatted string rather than as `wgpu::AdapterInfo` so
473 /// no consumer has to name a wgpu type to read it.
474 ///
475 /// **This exists for `ADR-0071` reports, and only for them.** A frame time is
476 /// a fact about a GPU and a driver rather than about the code, so a test that
477 /// prints one has to be able to say which GPU and which driver; before Plan
478 /// 0113 Phase 2 nothing in the crate could. Nothing on a render path reads
479 /// it.
480 adapter: String,
481}
482
483impl RenderContext {
484 /// Create a context rendering into `target` (any window-handle provider —
485 /// the core never sees the windowing library behind it).
486 pub fn new(
487 target: impl Into<SurfaceTarget<'static>>,
488 width: u32,
489 height: u32,
490 adapter: &AdapterChoice,
491 ) -> Result<Self, RenderError> {
492 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
493 let surface = instance
494 .create_surface(target)
495 .map_err(RenderError::CreateSurface)?;
496 Self::from_surface(&instance, surface, width, height, adapter)
497 }
498
499 /// Context from raw display/window handles — the C ABI path, where the
500 /// host (e.g. the foobar2000 shim) owns the window.
501 ///
502 /// # Safety
503 /// The handles must be valid and the window must outlive this context.
504 pub unsafe fn new_unsafe(
505 target: wgpu::SurfaceTargetUnsafe,
506 width: u32,
507 height: u32,
508 adapter: &AdapterChoice,
509 ) -> Result<Self, RenderError> {
510 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
511 let surface = unsafe { instance.create_surface_unsafe(target) }
512 .map_err(RenderError::CreateSurface)?;
513 Self::from_surface(&instance, surface, width, height, adapter)
514 }
515
516 fn from_surface(
517 instance: &wgpu::Instance,
518 surface: wgpu::Surface<'static>,
519 width: u32,
520 height: u32,
521 choice: &AdapterChoice,
522 ) -> Result<Self, RenderError> {
523 let adapter = resolve_adapter(instance, choice, Some(&surface))?;
524 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
525 label: Some("rlx-device"),
526 required_features: optional_features(&adapter),
527 ..Default::default()
528 }))
529 .map_err(RenderError::RequestDevice)?;
530
531 let mut config = surface
532 .get_default_config(&adapter, width.max(1), height.max(1))
533 .ok_or(RenderError::UnsupportedSurface)?;
534 // Vsync everywhere; the render loop paces itself off the display.
535 config.present_mode = wgpu::PresentMode::AutoVsync;
536 // Explicit swapchain depth (NFR 12 secondary lever): pin a 2-frame
537 // latency (double-buffered) rather than leaving it to the backend
538 // default, so the in-flight image count - and its VRAM - is bounded and
539 // stated, not implicit.
540 config.desired_maximum_frame_latency = 2;
541 // `COPY_DST` where the surface offers it, so a frame drawn into the
542 // preview intermediate can reach this swapchain by an exact
543 // `copy_texture_to_texture` rather than through a sampling blit, which
544 // would round-trip the encoded values ADR-0096 dithers. A usage flag
545 // costs nothing while nothing copies; the caps query is what decides,
546 // and `Renderer::open_preview` reports the refusal rather than
547 // degrading to an inexact path behind the operator's back.
548 let caps = surface.get_capabilities(&adapter);
549 if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
550 config.usage |= wgpu::TextureUsages::COPY_DST;
551 }
552 surface.configure(&device, &config);
553
554 let info = adapter.get_info();
555 let is_software = info.device_type == wgpu::DeviceType::Cpu;
556 let description = describe_adapter(&info);
557 Ok(Self {
558 surface: Some(surface),
559 device,
560 queue,
561 config,
562 is_software,
563 class: AdapterClass::of(info.device_type),
564 adapter: description,
565 instance: instance.clone(),
566 gpu: adapter,
567 })
568 }
569
570 /// Build a surface-less context for headless capture (Plan 0013): a device
571 /// and queue with no swapchain, drawing into offscreen textures. No window,
572 /// no present, no added dependency. `prefer_software` forces a fallback
573 /// adapter (WARP on DX12) so tests rasterize identically on any machine.
574 ///
575 /// The synthesized [`wgpu::SurfaceConfiguration`] carries only the render
576 /// size and the offscreen format (`HEADLESS_FORMAT`); its present-related
577 /// fields are inert with no surface to configure.
578 pub fn new_headless(
579 width: u32,
580 height: u32,
581 prefer_software: bool,
582 ) -> Result<Self, RenderError> {
583 Self::new_headless_on(width, height, &AdapterChoice::from(prefer_software))
584 }
585
586 /// A headless context on a **named** adapter (ADR-0146).
587 ///
588 /// The one real constructor of the two; [`new_headless`](Self::new_headless)
589 /// delegates here. It exists because a live video-out has to render on a
590 /// GPU the operator can name - on a hybrid machine Windows hands a console
591 /// process the power-saving one - while every capture path wants exactly
592 /// the adapter it already asks for.
593 pub fn new_headless_on(
594 width: u32,
595 height: u32,
596 choice: &AdapterChoice,
597 ) -> Result<Self, RenderError> {
598 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
599 // No surface: a headless context presents to nothing, so every adapter
600 // on the machine is a candidate and there is no compatibility to check.
601 let adapter = resolve_adapter(&instance, choice, None)?;
602 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
603 label: Some("rlx-headless-device"),
604 required_features: optional_features(&adapter),
605 ..Default::default()
606 }))
607 .map_err(RenderError::RequestDevice)?;
608
609 let config = wgpu::SurfaceConfiguration {
610 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
611 format: HEADLESS_FORMAT,
612 color_space: wgpu::SurfaceColorSpace::Auto,
613 width: width.max(1),
614 height: height.max(1),
615 present_mode: wgpu::PresentMode::AutoVsync,
616 desired_maximum_frame_latency: 2,
617 alpha_mode: wgpu::CompositeAlphaMode::Auto,
618 view_formats: vec![],
619 };
620
621 let info = adapter.get_info();
622 let is_software = info.device_type == wgpu::DeviceType::Cpu;
623 let description = describe_adapter(&info);
624 Ok(Self {
625 surface: None,
626 device,
627 queue,
628 config,
629 is_software,
630 class: AdapterClass::of(info.device_type),
631 adapter: description,
632 instance,
633 gpu: adapter,
634 })
635 }
636
637 /// Reconfigure the surface for a new size (a zero dimension is ignored).
638 pub fn resize(&mut self, width: u32, height: u32) {
639 if width == 0 || height == 0 {
640 return; // minimized; keep the old config until we're visible again
641 }
642 self.config.width = width;
643 self.config.height = height;
644 if let Some(surface) = &self.surface {
645 surface.configure(&self.device, &self.config);
646 }
647 }
648
649 /// The texture format the surface is configured with.
650 pub fn surface_format(&self) -> wgpu::TextureFormat {
651 self.config.format
652 }
653
654 /// Whether this context's frame destination accepts a texture-to-texture
655 /// copy — the requirement the program preview's exact path rests on.
656 ///
657 /// With a surface it is what the swapchain was actually configured with,
658 /// which `from_surface` sets only where the surface's capabilities offer it.
659 /// Headless there is no swapchain and the destination is a capture target,
660 /// which is built with `COPY_DST` unconditionally.
661 pub(crate) fn can_copy_to_target(&self) -> bool {
662 match self.surface {
663 Some(_) => self.config.usage.contains(wgpu::TextureUsages::COPY_DST),
664 None => true,
665 }
666 }
667
668 /// Whether the active adapter is a CPU/software rasterizer (see the field).
669 pub(crate) fn is_software(&self) -> bool {
670 self.is_software
671 }
672
673 /// The active adapter's class (see the field).
674 pub(crate) fn adapter_class(&self) -> AdapterClass {
675 self.class
676 }
677
678 /// The active adapter's description — name, backend, device type, driver —
679 /// for a report that has to name the machine it was taken on (ADR-0071).
680 pub(crate) fn adapter(&self) -> &str {
681 &self.adapter
682 }
683
684 /// Re-apply the current configuration (after a Lost/Outdated surface).
685 /// A no-op on a headless context (no surface to reconfigure).
686 pub(crate) fn reconfigure(&self) {
687 if let Some(surface) = &self.surface {
688 surface.configure(&self.device, &self.config);
689 }
690 }
691
692 /// Stage a move onto another adapter (ADR-0246): resolve `choice` against
693 /// this context's window, request its device, negotiate the surface
694 /// configuration, and create a fresh surface for `target` — **without
695 /// touching anything this context owns**. Every step that can fail is
696 /// here, so an `Err` leaves the running context exactly as it was, and
697 /// [`commit`](Self::commit) is the one step that cannot.
698 ///
699 /// `Ok(None)` when `choice` resolves to the adapter already in use: the
700 /// device that would be built is the one that exists, and rebuilding onto
701 /// it would restart every accumulation for no change.
702 ///
703 /// The window's surface is **re-created rather than re-configured**. A
704 /// surface is instance-scoped and the instance is retained, but a
705 /// swapchain belongs to the device it was configured on, and
706 /// `Surface::configure` replaces the previous swapchain through whichever
707 /// device it is handed — sound only while that is the same device. A new
708 /// surface for the same window carries no swapchain until it is
709 /// configured, and dropping the old one releases its swapchain through
710 /// the device that made it.
711 ///
712 /// Refused with [`RenderError::Headless`] on a context without a surface:
713 /// there is no window to re-create a surface for, and the headless path is
714 /// the one [`adapter_change_permitted`] excludes.
715 pub(crate) fn stage_adapter(
716 &self,
717 choice: &AdapterChoice,
718 target: impl Into<SurfaceTarget<'static>>,
719 ) -> Result<Option<StagedContext>, RenderError> {
720 let Some(current) = self.surface.as_ref() else {
721 return Err(RenderError::Headless);
722 };
723 // Resolved against the surface the window already has: the new one
724 // below is for the same window, so presentability is the same question.
725 let adapter = resolve_adapter(&self.instance, choice, Some(current))?;
726 let info = adapter.get_info();
727 let description = describe_adapter(&info);
728 if description == self.adapter {
729 return Ok(None);
730 }
731 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
732 label: Some("rlx-device"),
733 required_features: optional_features(&adapter),
734 ..Default::default()
735 }))
736 .map_err(RenderError::RequestDevice)?;
737 let mut config = current
738 .get_default_config(
739 &adapter,
740 self.config.width.max(1),
741 self.config.height.max(1),
742 )
743 .ok_or(RenderError::UnsupportedSurface)?;
744 // The same three choices `from_surface` makes for a fresh window, so a
745 // switched context is configured exactly as a launched one.
746 config.present_mode = wgpu::PresentMode::AutoVsync;
747 config.desired_maximum_frame_latency = 2;
748 if current
749 .get_capabilities(&adapter)
750 .usages
751 .contains(wgpu::TextureUsages::COPY_DST)
752 {
753 config.usage |= wgpu::TextureUsages::COPY_DST;
754 }
755 // Created last, after everything that can refuse: a surface is a
756 // handle on the window and not yet a swapchain, so two of them on one
757 // window coexist until one is configured.
758 let surface = self
759 .instance
760 .create_surface(target)
761 .map_err(RenderError::CreateSurface)?;
762 Ok(Some(StagedContext {
763 surface,
764 device,
765 queue,
766 config,
767 gpu: adapter,
768 is_software: info.device_type == wgpu::DeviceType::Cpu,
769 class: AdapterClass::of(info.device_type),
770 adapter: description,
771 }))
772 }
773
774 /// Make a staged context this context, releasing the old device.
775 ///
776 /// Order is load-bearing. The old surface goes first, because dropping it
777 /// is what releases its swapchain through the device that made it, and
778 /// because DXGI allows one swap chain per window: the new surface is
779 /// configured only once the old swapchain is gone. The old device, queue
780 /// and adapter are dropped last; every resource built on them holds its
781 /// own reference, so the caller's replacements can be built before this
782 /// and the old ones dropped after it.
783 pub(crate) fn commit(&mut self, staged: StagedContext) {
784 self.surface = None;
785 staged.surface.configure(&staged.device, &staged.config);
786 self.surface = Some(staged.surface);
787 self.config = staged.config;
788 self.device = staged.device;
789 self.queue = staged.queue;
790 self.gpu = staged.gpu;
791 self.is_software = staged.is_software;
792 self.class = staged.class;
793 self.adapter = staged.adapter;
794 }
795}
796
797/// A device on another adapter, requested and validated, with a fresh surface
798/// for the same window — not yet the context's own. Built by
799/// [`RenderContext::stage_adapter`], consumed by [`RenderContext::commit`],
800/// and dropped whole if the caller's own rebuild between the two fails.
801pub(crate) struct StagedContext {
802 surface: wgpu::Surface<'static>,
803 pub(crate) device: wgpu::Device,
804 pub(crate) queue: wgpu::Queue,
805 pub(crate) config: wgpu::SurfaceConfiguration,
806 gpu: wgpu::Adapter,
807 is_software: bool,
808 class: AdapterClass,
809 adapter: String,
810}
811
812impl StagedContext {
813 /// The class of the adapter this context would move onto — read before the
814 /// commit, so the grid scale the rebuilt scenes take is the new adapter's.
815 pub(crate) fn adapter_class(&self) -> AdapterClass {
816 self.class
817 }
818}