Skip to main content

rlx_core/render/
context.rs

1//! wgpu device/surface ownership. All raw GPU access lives behind this layer
2//! (ADR-0001): scene code sees wgpu types, never a backend.
3
4// Hot-path panic-denial pragma (Plan 0002 Phase 2). GPU bring-up returns
5// Result; the render path must not panic.
6#![deny(
7    clippy::unwrap_used,
8    clippy::expect_used,
9    clippy::indexing_slicing,
10    clippy::panic,
11    clippy::unreachable
12)]
13
14use wgpu::{CreateSurfaceError, RequestAdapterError, RequestDeviceError, SurfaceTarget};
15
16use crate::audio::FormatError;
17
18/// Offscreen texture format for the headless capture path (Plan 0013). A tight
19/// 8-bit RGBA the readback strips straight into a [`crate::render::CaptureImage`].
20pub(crate) const HEADLESS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
21
22/// Something went wrong bringing up or drawing with the GPU context.
23#[derive(Debug)]
24pub enum RenderError {
25    /// Creating the wgpu surface for the window failed.
26    CreateSurface(CreateSurfaceError),
27    /// No GPU adapter compatible with the surface was found.
28    RequestAdapter(RequestAdapterError),
29    /// Requesting a logical device from the adapter failed.
30    RequestDevice(RequestDeviceError),
31    /// The surface reported no supported configuration on this adapter.
32    UnsupportedSurface,
33    /// Frames are being produced at a texture format whose channel order has no
34    /// name a frame consumer knows.
35    ///
36    /// Refused rather than published under a guessed name: a consumer reading
37    /// four bytes per pixel and told the wrong order draws the right picture in
38    /// the wrong colours, which looks like an authoring mistake rather than a
39    /// protocol one (ADR-0187).
40    UnnameablePixelOrder(wgpu::TextureFormat),
41    /// Acquiring the frame raised a validation error — a bug, not a
42    /// recoverable surface state.
43    SurfaceValidation,
44    /// A headless capture failed to map or read back its offscreen buffer
45    /// (Plan 0013 tooling path — never the live render path).
46    CaptureReadback,
47    /// A capture requested a preset name not in the loaded roster (Plan 0013).
48    UnknownPreset(String),
49    /// An audio-driven capture was handed a PCM format the analyzer rejected at
50    /// the intake boundary (Plan 0013).
51    AudioFormat(FormatError),
52    /// A requested graphics adapter is not on this machine. Carries the roster
53    /// so the message can name what is available rather than an index nobody
54    /// can interpret (ADR-0146).
55    NoSuchAdapter {
56        /// What the caller asked for, as they wrote it.
57        requested: String,
58        /// Every adapter this machine enumerates, described.
59        available: Vec<String>,
60    },
61    /// A requested adapter name matched more than one adapter. A substring
62    /// cannot separate two adapters whose descriptions share it, so the caller
63    /// is told which ones collided rather than handed an arbitrary pick.
64    AmbiguousAdapter {
65        /// What the caller asked for, as they wrote it.
66        requested: String,
67        /// The adapters the request matched.
68        matched: Vec<String>,
69    },
70    /// A named or indexed adapter exists on this machine but cannot present to
71    /// the window that asked for it.
72    ///
73    /// Only reachable on the surface path and only for the enumerated variants:
74    /// the preference variants hand the surface to wgpu as `compatible_surface`
75    /// and so cannot select an adapter that fails this, while `Named`/`Index`
76    /// pick out of the unfiltered roster. Refusing is the point — falling back
77    /// to a working adapter would render on a GPU the operator did not ask for
78    /// and say nothing, which is the failure `--gpu` exists to end (ADR-0155).
79    AdapterCannotPresent {
80        /// What the caller asked for, as they wrote it.
81        requested: String,
82        /// The adapter that request resolved to, described.
83        adapter: String,
84    },
85    /// The consumer of a **streamed** capture refused a frame — the offline
86    /// render mode's pipe closed, the encoder died, the file could not be
87    /// written (Plan 0101 / ADR-0114). The string is the consumer's own message,
88    /// carried verbatim rather than flattened to "write failed": that consumer
89    /// is a child process, and a mystery broken pipe is the obvious way this
90    /// path goes wrong.
91    Sink(String),
92    /// 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}