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}