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