Skip to main content

frust_render/
headless.rs

1//! Offscreen engine rendering: the harness the crate's own pixel-level
2//! regression tests render a [`frust_scene::Scene`] through when there is no
3//! window and no surface.
4//!
5//! `frust-engine` draws through ordinary render passes into a plain
6//! `RENDER_ATTACHMENT` texture, so an offscreen render needs no swapchain, no
7//! display server and no `wgpu::Surface` — only a GPU, a driver and device-node
8//! permission (see `docs/TESTING.md` § Native Headless GPU Rendering). The
9//! target itself is `frust_gpu::HeadlessTarget`, the substrate's own offscreen
10//! target, rather than a second implementation here.
11//!
12//! Three properties make this the harness a pixel comparison can be trusted
13//! against, as opposed to a hand-rolled render in each test:
14//!
15//! 1. **The adapter is the one the environment asked for.** Selection goes
16//!    through [`wgpu::util::initialize_adapter_from_env_or_default`], the same
17//!    call the live device path uses, so `WGPU_ADAPTER_NAME` is honoured on a
18//!    multi-GPU host rather than silently ignored by a bare `request_adapter`.
19//! 2. **A wrong adapter fails before it can produce pixels.**
20//!    [`HeadlessOptions::expect_adapter`]/[`HeadlessOptions::expect_backend`]
21//!    (or [`GOLDEN_EXPECT_ADAPTER_ENV_VAR`]/[`GOLDEN_EXPECT_BACKEND_ENV_VAR`])
22//!    are checked against the *resolved* adapter in [`HeadlessRenderer::new`],
23//!    so a baseline can never be promoted from the wrong GPU with the right
24//!    file name.
25//! 3. **The device is the app's device.** Limits, optional features and the
26//!    engine's adapter-capability gate are the ones a real surface resolves,
27//!    so a headless run cannot pass on capabilities a real surface would never
28//!    get, and an adapter the engine cannot drive is refused here in the same
29//!    words `RenderContext`'s own surface path refuses it.
30//!
31//! Every render is bracketed by a `Validation` error scope and fails on any
32//! captured error, and the readback strips wgpu's 256-byte row padding
33//! (`frust_gpu::HeadlessTarget::read_back`), so an arbitrary width — not just
34//! a multiple of 64 px — reads back exactly.
35//!
36//! **Alpha.** Pixels come back PREMULTIPLIED, which is what the engine's strip
37//! pipelines write and what every frust GPU path uses; an erased pixel still
38//! reads `[0, 0, 0, 0]`, and an opaque one is unaffected by the convention.
39//!
40//! The API is `async` because wgpu's adapter/device requests and its error
41//! scope are futures and this crate has no executor dependency; callers drive
42//! it with whatever they already have (`pollster::block_on` in tests).
43//!
44//! No `wgpu` type crosses this module's public surface — the confinement rule
45//! in `docs/RENDER_ARCHITECTURE.md`. Pixels come back as plain bytes.
46
47use anyhow::{Result, anyhow};
48
49/// Environment variable naming the adapter a golden/oracle run must have
50/// resolved. Case-insensitive substring match on the adapter name, the same
51/// shape `WGPU_ADAPTER_NAME` itself matches by — `T400` accepts
52/// `NVIDIA T400 4GB`.
53///
54/// This is a *check*, not a selector: `WGPU_ADAPTER_NAME` chooses the adapter,
55/// this refuses the run when the choice did not land where the operator
56/// believed it would. Also read by `frust-testing`'s own engine oracle.
57pub const GOLDEN_EXPECT_ADAPTER_ENV_VAR: &str = "FRUST_GOLDEN_EXPECT_ADAPTER";
58
59/// Environment variable naming the wgpu backend a golden/oracle run must have
60/// resolved (`vulkan`, `metal`, `dx12`, `gl`, …; case-insensitive, matched
61/// whole). The backend counterpart of [`GOLDEN_EXPECT_ADAPTER_ENV_VAR`].
62pub const GOLDEN_EXPECT_BACKEND_ENV_VAR: &str = "FRUST_GOLDEN_EXPECT_BACKEND";
63
64/// How a [`HeadlessRenderer`] should pick, and then verify, its GPU.
65#[derive(Clone, Debug, Default, PartialEq, Eq)]
66pub struct HeadlessOptions {
67    /// Backend(s) to restrict adapter enumeration to, as a comma-separated
68    /// list (`vulkan`, `metal`, `dx12`, `gl`, `vulkan,metal`, …).
69    ///
70    /// A *hint*: `WGPU_BACKEND` wins when it is set, so an operator can pin the
71    /// backend of a run without editing the caller.
72    pub backend_hint: Option<String>,
73    /// Adapter name the resolved adapter must match (case-insensitive
74    /// substring). Overrides [`GOLDEN_EXPECT_ADAPTER_ENV_VAR`] when set.
75    pub expect_adapter: Option<String>,
76    /// Backend the resolved adapter must be on (case-insensitive, whole word).
77    /// Overrides [`GOLDEN_EXPECT_BACKEND_ENV_VAR`] when set.
78    pub expect_backend: Option<String>,
79}
80
81/// One offscreen render's parameters.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct HeadlessSpec {
84    /// Target width in device pixels. Any width is valid — the readback pads
85    /// and strips rows as wgpu requires.
86    pub width: u32,
87    /// Target height in device pixels.
88    pub height: u32,
89    /// The colour the renderer clears the target to before drawing the scene.
90    pub base_color: peniko::Color,
91    /// A transform applied on top of the whole encoded scene — the headless
92    /// analogue of the root a live surface encodes under.
93    pub root: kurbo::Affine,
94}
95
96impl HeadlessSpec {
97    /// A `width` x `height` render over an opaque black base, unrooted — the
98    /// defaults a case overrides field by field with struct-update syntax.
99    #[must_use]
100    pub fn new(width: u32, height: u32) -> Self {
101        Self {
102            width,
103            height,
104            base_color: peniko::color::palette::css::BLACK,
105            root: kurbo::Affine::IDENTITY,
106        }
107    }
108}
109
110/// Which GPU actually produced an image — the provenance every promoted
111/// baseline and every failing artifact has to record (`docs/TESTING.md`
112/// § GPU Run Metadata).
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct HeadlessMeta {
115    /// The wgpu backend, lowercase (`vulkan`, `metal`, `dx12`, `gl`).
116    pub backend: String,
117    /// The adapter name as the driver reports it (e.g. `NVIDIA T400 4GB`).
118    pub adapter: String,
119    /// The driver name and, when the backend reports one, its version detail.
120    pub driver: String,
121}
122
123impl HeadlessMeta {
124    fn from_info(info: &wgpu::AdapterInfo) -> Self {
125        let driver = if info.driver_info.is_empty() {
126            info.driver.clone()
127        } else if info.driver.is_empty() {
128            info.driver_info.clone()
129        } else {
130            format!("{} ({})", info.driver, info.driver_info)
131        };
132        Self {
133            backend: info.backend.to_str().to_string(),
134            adapter: info.name.clone(),
135            driver,
136        }
137    }
138}
139
140impl std::fmt::Display for HeadlessMeta {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        write!(
143            f,
144            "backend={} adapter={:?} driver={:?}",
145            self.backend, self.adapter, self.driver
146        )
147    }
148}
149
150/// The pixels of one headless render, plus the provenance of the GPU that
151/// produced them.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct HeadlessImage {
154    /// Width in pixels, matching the [`HeadlessSpec`] that produced it.
155    pub width: u32,
156    /// Height in pixels.
157    pub height: u32,
158    /// Tightly packed RGBA8 rows — `width * height * 4` bytes, **no** row
159    /// padding, in the engine's PREMULTIPLIED alpha convention (an erased
160    /// pixel reads `[0, 0, 0, 0]`).
161    pub rgba8: Vec<u8>,
162    /// The GPU this image came from.
163    pub meta: HeadlessMeta,
164}
165
166impl HeadlessImage {
167    /// The RGBA bytes of pixel `(x, y)`.
168    ///
169    /// # Panics
170    ///
171    /// Panics when `(x, y)` is outside the image — an out-of-bounds probe in a
172    /// pixel assertion is a broken test, not a runtime condition to handle.
173    #[must_use]
174    pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
175        assert!(
176            x < self.width && y < self.height,
177            "pixel ({x}, {y}) is outside a {}x{} image",
178            self.width,
179            self.height
180        );
181        let at = ((y * self.width + x) * 4) as usize;
182        [
183            self.rgba8[at],
184            self.rgba8[at + 1],
185            self.rgba8[at + 2],
186            self.rgba8[at + 3],
187        ]
188    }
189}
190
191/// The colour format every headless render targets.
192///
193/// `Rgba8Unorm` is `frust-engine`'s own off-screen format and is renderable on
194/// every wgpu backend, so a readback's channel order needs no per-backend
195/// correction — unlike a swapchain, whose reported format is the platform's
196/// business.
197const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
198
199/// A reusable offscreen engine renderer: one adapter, one device, one
200/// [`frust_engine::EngineRenderer`] and one size-matched
201/// [`frust_gpu::HeadlessTarget`], across any number of renders.
202///
203/// See the module docs for what makes it a harness rather than a convenience
204/// wrapper.
205pub struct HeadlessRenderer {
206    device: wgpu::Device,
207    queue: wgpu::Queue,
208    engine: frust_engine::EngineRenderer,
209    meta: HeadlessMeta,
210    /// The current render target, rebuilt only when a render asks for a
211    /// different size.
212    target: Option<frust_gpu::HeadlessTarget>,
213}
214
215impl HeadlessRenderer {
216    /// Resolves an adapter, verifies it against the expectations, checks it
217    /// can drive the engine, and creates the device and engine renderer every
218    /// later [`render`](Self::render) reuses.
219    ///
220    /// # Errors
221    ///
222    /// Fails when no adapter is available, when the resolved adapter does not
223    /// meet an `expect_adapter`/`expect_backend` expectation (**before** any
224    /// rendering), when the engine's capability gate refuses the adapter, or
225    /// when device/renderer creation fails.
226    pub async fn new(options: HeadlessOptions) -> Result<Self> {
227        let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
228        descriptor.backends = resolve_backends(
229            wgpu::Backends::from_env(),
230            options.backend_hint.as_deref(),
231            descriptor.backends,
232        );
233        let instance = wgpu::Instance::new(descriptor);
234
235        // The environment-aware initializer, exactly as `RenderContext`'s
236        // device creation calls it: a bare `request_adapter` ignores
237        // `WGPU_ADAPTER_NAME`, which on a dual-GPU host silently decides which
238        // GPU a baseline was captured on.
239        let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
240            .await
241            .map_err(|e| anyhow!("frust-render headless: no compatible GPU adapter: {e}"))?;
242        let meta = HeadlessMeta::from_info(&adapter.get_info());
243
244        let expect_adapter = options
245            .expect_adapter
246            .or_else(|| golden_env(GOLDEN_EXPECT_ADAPTER_ENV_VAR));
247        let expect_backend = options
248            .expect_backend
249            .or_else(|| golden_env(GOLDEN_EXPECT_BACKEND_ENV_VAR));
250        check_expectations(&meta, expect_adapter.as_deref(), expect_backend.as_deref())?;
251
252        // The same capability gate the surface path asks at surface creation,
253        // so a headless render can never pass on an adapter the app itself
254        // would refuse — and it refuses in the identical words.
255        let caps = crate::tier::TierCaps {
256            downlevel_flags: adapter.get_downlevel_capabilities().flags,
257            adapter_name: meta.adapter.clone(),
258        };
259        crate::tier::engine_support(&caps).map_err(|refusal| anyhow!(refusal.to_string()))?;
260
261        let required_features = adapter.features() & crate::context::optional_device_features();
262        let required_limits =
263            crate::context::effective_limits(adapter.limits(), crate::context::is_ios_simulator());
264        let (device, queue) = adapter
265            .request_device(&wgpu::DeviceDescriptor {
266                label: Some("frust-render headless device"),
267                required_features,
268                required_limits,
269                ..Default::default()
270            })
271            .await
272            .map_err(|e| anyhow!("frust-render headless: failed to create GPU device: {e}"))?;
273
274        let tier_caps = frust_gpu::TierCaps::probe(&adapter);
275        let mut engine =
276            frust_engine::EngineRenderer::new(&device, &tier_caps, TARGET_FORMAT, None).map_err(
277                |e| anyhow!("frust-render headless: the engine refused this device: {e}"),
278            )?;
279        // Warm-up is forced to completion here rather than left to a background
280        // worker holding its own handle on the device: a caller that drops this
281        // renderer mid-compile tears the device out from under that worker,
282        // a driver-level hazard that surfaces at process exit long after every
283        // assertion has passed.
284        engine.finish_warm_up(&device);
285
286        log::info!("frust-render headless: {meta}");
287        Ok(Self {
288            device,
289            queue,
290            engine,
291            meta,
292            target: None,
293        })
294    }
295
296    /// The GPU this renderer resolved — the provenance to record with any
297    /// image it produces.
298    #[must_use]
299    pub fn meta(&self) -> &HeadlessMeta {
300        &self.meta
301    }
302
303    /// Renders `scene` offscreen and reads the pixels back, unpadded.
304    ///
305    /// # Errors
306    ///
307    /// Fails on a zero-sized spec, on a refused frame, and on **any** wgpu
308    /// validation error captured during the render — a harness that renders
309    /// through a validation error is producing pixels nobody should trust.
310    pub async fn render(
311        &mut self,
312        scene: &frust_scene::Scene,
313        spec: &HeadlessSpec,
314    ) -> Result<HeadlessImage> {
315        if spec.width == 0 || spec.height == 0 {
316            return Err(anyhow!(
317                "frust-render headless: a render target must have a non-zero size, got {}x{}",
318                spec.width,
319                spec.height
320            ));
321        }
322        self.ensure_target(spec.width, spec.height);
323        let target = self
324            .target
325            .as_ref()
326            .expect("ensure_target always leaves a target in place");
327
328        let scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
329        let mut encoder = self
330            .device
331            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
332                label: Some("frust-render headless frame"),
333            });
334        let encoded = self.engine.encode(
335            &self.device,
336            &self.queue,
337            &mut encoder,
338            scene,
339            frust_engine::EngineTarget {
340                view: target.view(),
341                format: TARGET_FORMAT,
342                width: spec.width,
343                height: spec.height,
344                // The engine owns its own depth attachment here: this harness
345                // composites no pass of its own, so there is no surface-owned
346                // buffer for the frame to test against.
347                depth: None,
348                output: frust_engine::OutputAlpha::Premultiplied,
349            },
350            spec.base_color,
351            spec.root,
352        );
353        // Submitted whether or not the frame encoded: a refused frame leaves
354        // the encoder exactly as it was found, and finishing it keeps the
355        // device's own bookkeeping in step before the error scope is drained.
356        self.queue.submit([encoder.finish()]);
357        self.engine.end_frame(&self.queue);
358
359        let validation = scope.pop().await;
360        let encoded =
361            encoded.map_err(|e| anyhow!("frust-render headless: the frame was refused: {e}"));
362        finish_scoped(encoded, validation, "render")?;
363
364        let target = self
365            .target
366            .as_ref()
367            .expect("ensure_target always leaves a target in place");
368        Ok(HeadlessImage {
369            width: spec.width,
370            height: spec.height,
371            rgba8: target.read_back(&self.device, &self.queue),
372            meta: self.meta.clone(),
373        })
374    }
375
376    /// Ensures [`Self::target`] is a `width` x `height` render target,
377    /// rebuilding it only on a size change and telling the renderer about the
378    /// resize so nothing sized against the old extent survives into the next
379    /// frame.
380    fn ensure_target(&mut self, width: u32, height: u32) {
381        if matches!(&self.target, Some(t) if t.width() == width && t.height() == height) {
382            return;
383        }
384        if self.target.is_some() {
385            self.engine.resize(&self.device, width, height);
386        }
387        self.target = Some(frust_gpu::HeadlessTarget::new(
388            &self.device,
389            width,
390            height,
391            TARGET_FORMAT,
392        ));
393    }
394}
395
396/// Combines a scoped operation's own result with whatever its `Validation`
397/// error scope captured.
398///
399/// A captured validation error fails the operation even when it otherwise
400/// "succeeded" — the case this exists for is a render that produced plausible
401/// pixels through an error wgpu's default handler would only have logged.
402fn finish_scoped<T>(result: Result<T>, validation: Option<wgpu::Error>, what: &str) -> Result<T> {
403    match (result, validation) {
404        (Ok(value), None) => Ok(value),
405        (Ok(_), Some(error)) => Err(anyhow!(
406            "frust-render headless: wgpu validation error during {what}: {error}"
407        )),
408        (Err(error), None) => Err(error),
409        (Err(error), Some(validation)) => {
410            Err(error.context(format!("wgpu validation error during {what}: {validation}")))
411        }
412    }
413}
414
415/// The backends adapter enumeration should be restricted to.
416///
417/// `WGPU_BACKEND` (already parsed into `env_backends`) wins over the caller's
418/// `hint`, so an operator can pin a run's backend without editing the caller;
419/// with neither set, `fallback` (the instance descriptor's own env-derived
420/// default) applies.
421fn resolve_backends(
422    env_backends: Option<wgpu::Backends>,
423    hint: Option<&str>,
424    fallback: wgpu::Backends,
425) -> wgpu::Backends {
426    env_backends
427        .or_else(|| hint.map(wgpu::Backends::from_comma_list))
428        .unwrap_or(fallback)
429}
430
431/// The value of a `FRUST_GOLDEN_EXPECT_*` variable, treating an empty value as
432/// unset. The runtime half only — these are operator knobs for a host test
433/// run, never baked into a binary, so there is no compile-time half to resolve
434/// against.
435fn golden_env(name: &str) -> Option<String> {
436    std::env::var(name).ok().filter(|value| !value.is_empty())
437}
438
439/// Refuses a run whose resolved GPU is not the expected one, **before** it can
440/// render anything.
441///
442/// `expect_adapter` matches case-insensitively as a substring, the same way
443/// `WGPU_ADAPTER_NAME` selects (so the selector and the check cannot disagree
444/// about what `T400` means); `expect_backend` matches the whole backend name,
445/// case-insensitively.
446fn check_expectations(
447    meta: &HeadlessMeta,
448    expect_adapter: Option<&str>,
449    expect_backend: Option<&str>,
450) -> Result<()> {
451    if let Some(expected) = expect_adapter.map(str::trim).filter(|e| !e.is_empty())
452        && !meta
453            .adapter
454            .to_lowercase()
455            .contains(&expected.to_lowercase())
456    {
457        return Err(anyhow!(
458            "frust-render headless: expected adapter matching `{expected}`, but the run resolved \
459             `{}` ({meta}); set WGPU_ADAPTER_NAME (or isolate the driver ICD) so the intended GPU \
460             is selected",
461            meta.adapter
462        ));
463    }
464    if let Some(expected) = expect_backend.map(str::trim).filter(|e| !e.is_empty())
465        && !meta.backend.eq_ignore_ascii_case(expected)
466    {
467        return Err(anyhow!(
468            "frust-render headless: expected backend `{expected}`, but the run resolved `{}` \
469             ({meta}); set WGPU_BACKEND to pin it",
470            meta.backend
471        ));
472    }
473    Ok(())
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn meta(backend: &str, adapter: &str) -> HeadlessMeta {
481        HeadlessMeta {
482            backend: backend.to_string(),
483            adapter: adapter.to_string(),
484            driver: "test driver".to_string(),
485        }
486    }
487
488    #[test]
489    fn no_expectation_accepts_any_adapter() {
490        assert!(check_expectations(&meta("vulkan", "Intel UHD 770"), None, None).is_ok());
491        assert!(check_expectations(&meta("vulkan", "Intel UHD 770"), Some(""), Some("  ")).is_ok());
492    }
493
494    #[test]
495    fn an_expected_adapter_matches_case_insensitively_as_a_substring() {
496        let resolved = meta("vulkan", "NVIDIA T400 4GB");
497        assert!(check_expectations(&resolved, Some("T400"), None).is_ok());
498        assert!(check_expectations(&resolved, Some("t400"), None).is_ok());
499        assert!(check_expectations(&resolved, Some(" NVIDIA T400 "), None).is_ok());
500    }
501
502    #[test]
503    fn the_wrong_adapter_is_refused_naming_both_names() {
504        // The dual-GPU host this exists for: the run asked for the T400 and
505        // enumeration handed back the integrated GPU.
506        let error = check_expectations(
507            &meta("vulkan", "Intel UHD Graphics 770"),
508            Some("T400"),
509            None,
510        )
511        .expect_err("a mismatched adapter must be refused");
512        let message = error.to_string();
513        assert!(message.contains("T400"), "{message}");
514        assert!(message.contains("Intel UHD Graphics 770"), "{message}");
515        assert!(message.contains("WGPU_ADAPTER_NAME"), "{message}");
516    }
517
518    #[test]
519    fn the_wrong_backend_is_refused() {
520        let resolved = meta("gl", "NVIDIA T400 4GB");
521        assert!(check_expectations(&resolved, None, Some("vulkan")).is_err());
522        assert!(check_expectations(&resolved, None, Some("GL")).is_ok());
523        assert!(check_expectations(&resolved, Some("T400"), Some("vulkan")).is_err());
524    }
525
526    #[test]
527    fn the_backend_env_knob_wins_over_the_caller_hint() {
528        assert_eq!(
529            resolve_backends(
530                Some(wgpu::Backends::VULKAN),
531                Some("metal"),
532                wgpu::Backends::all()
533            ),
534            wgpu::Backends::VULKAN
535        );
536        assert_eq!(
537            resolve_backends(None, Some("vulkan"), wgpu::Backends::all()),
538            wgpu::Backends::VULKAN
539        );
540        assert_eq!(
541            resolve_backends(None, None, wgpu::Backends::PRIMARY),
542            wgpu::Backends::PRIMARY
543        );
544    }
545
546    #[test]
547    fn meta_renders_the_provenance_a_baseline_must_record() {
548        let info = meta("vulkan", "NVIDIA T400 4GB");
549        let line = info.to_string();
550        assert!(line.contains("backend=vulkan"), "{line}");
551        assert!(line.contains("NVIDIA T400 4GB"), "{line}");
552        assert!(line.contains("test driver"), "{line}");
553    }
554
555    /// A stand-in for what a scope pops, since a real one needs a device.
556    fn validation_error(description: &str) -> wgpu::Error {
557        wgpu::Error::Validation {
558            source: Box::new(std::io::Error::other(description.to_string())),
559            description: description.to_string(),
560        }
561    }
562
563    #[test]
564    fn a_clean_scope_passes_the_result_through() {
565        assert_eq!(finish_scoped(Ok(7_u8), None, "render").unwrap(), 7);
566        let failed: Result<u8> = Err(anyhow!("the frame was refused"));
567        let error = finish_scoped(failed, None, "render").expect_err("the failure must survive");
568        assert!(error.to_string().contains("the frame was refused"));
569    }
570
571    #[test]
572    fn a_captured_validation_error_fails_an_otherwise_successful_operation() {
573        // The case this exists for: plausible pixels produced through an error
574        // wgpu's default handler would only have logged.
575        let error = finish_scoped(Ok(7_u8), Some(validation_error("bad bind group")), "render")
576            .expect_err("a validation error must fail the operation");
577        let message = error.to_string();
578        assert!(
579            message.contains("validation error during render"),
580            "{message}"
581        );
582        assert!(message.contains("bad bind group"), "{message}");
583    }
584
585    #[test]
586    fn a_captured_validation_error_annotates_a_failed_operation() {
587        let failed: Result<u8> = Err(anyhow!("the frame was refused"));
588        let error = finish_scoped(failed, Some(validation_error("bad bind group")), "render")
589            .expect_err("the failure must survive");
590        let chain = format!("{error:#}");
591        assert!(chain.contains("the frame was refused"), "{chain}");
592        assert!(chain.contains("bad bind group"), "{chain}");
593    }
594}