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 - 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}