use anyhow::{Result, anyhow};
pub const GOLDEN_EXPECT_ADAPTER_ENV_VAR: &str = "FRUST_GOLDEN_EXPECT_ADAPTER";
pub const GOLDEN_EXPECT_BACKEND_ENV_VAR: &str = "FRUST_GOLDEN_EXPECT_BACKEND";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeadlessOptions {
pub backend_hint: Option<String>,
pub expect_adapter: Option<String>,
pub expect_backend: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HeadlessSpec {
pub width: u32,
pub height: u32,
pub base_color: peniko::Color,
pub root: kurbo::Affine,
}
impl HeadlessSpec {
#[must_use]
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
base_color: peniko::color::palette::css::BLACK,
root: kurbo::Affine::IDENTITY,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HeadlessMeta {
pub backend: String,
pub adapter: String,
pub driver: String,
}
impl HeadlessMeta {
fn from_info(info: &wgpu::AdapterInfo) -> Self {
let driver = if info.driver_info.is_empty() {
info.driver.clone()
} else if info.driver.is_empty() {
info.driver_info.clone()
} else {
format!("{} ({})", info.driver, info.driver_info)
};
Self {
backend: info.backend.to_str().to_string(),
adapter: info.name.clone(),
driver,
}
}
}
impl std::fmt::Display for HeadlessMeta {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"backend={} adapter={:?} driver={:?}",
self.backend, self.adapter, self.driver
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HeadlessImage {
pub width: u32,
pub height: u32,
pub rgba8: Vec<u8>,
pub meta: HeadlessMeta,
}
impl HeadlessImage {
#[must_use]
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
assert!(
x < self.width && y < self.height,
"pixel ({x}, {y}) is outside a {}x{} image",
self.width,
self.height
);
let at = ((y * self.width + x) * 4) as usize;
[
self.rgba8[at],
self.rgba8[at + 1],
self.rgba8[at + 2],
self.rgba8[at + 3],
]
}
}
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub struct HeadlessRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
engine: frust_engine::EngineRenderer,
meta: HeadlessMeta,
target: Option<frust_gpu::HeadlessTarget>,
}
impl HeadlessRenderer {
pub async fn new(options: HeadlessOptions) -> Result<Self> {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
descriptor.backends = resolve_backends(
wgpu::Backends::from_env(),
options.backend_hint.as_deref(),
descriptor.backends,
);
let instance = wgpu::Instance::new(descriptor);
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.map_err(|e| anyhow!("frust-render headless: no compatible GPU adapter: {e}"))?;
let meta = HeadlessMeta::from_info(&adapter.get_info());
let expect_adapter = options
.expect_adapter
.or_else(|| golden_env(GOLDEN_EXPECT_ADAPTER_ENV_VAR));
let expect_backend = options
.expect_backend
.or_else(|| golden_env(GOLDEN_EXPECT_BACKEND_ENV_VAR));
check_expectations(&meta, expect_adapter.as_deref(), expect_backend.as_deref())?;
let caps = crate::tier::TierCaps {
downlevel_flags: adapter.get_downlevel_capabilities().flags,
adapter_name: meta.adapter.clone(),
};
crate::tier::engine_support(&caps).map_err(|refusal| anyhow!(refusal.to_string()))?;
let required_features = adapter.features() & crate::context::optional_device_features();
let required_limits =
crate::context::effective_limits(adapter.limits(), crate::context::is_ios_simulator());
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-render headless device"),
required_features,
required_limits,
..Default::default()
})
.await
.map_err(|e| anyhow!("frust-render headless: failed to create GPU device: {e}"))?;
let tier_caps = frust_gpu::TierCaps::probe(&adapter);
let mut engine =
frust_engine::EngineRenderer::new(&device, &tier_caps, TARGET_FORMAT, None).map_err(
|e| anyhow!("frust-render headless: the engine refused this device: {e}"),
)?;
engine.finish_warm_up(&device);
log::info!("frust-render headless: {meta}");
Ok(Self {
device,
queue,
engine,
meta,
target: None,
})
}
#[must_use]
pub fn meta(&self) -> &HeadlessMeta {
&self.meta
}
pub async fn render(
&mut self,
scene: &frust_scene::Scene,
spec: &HeadlessSpec,
) -> Result<HeadlessImage> {
if spec.width == 0 || spec.height == 0 {
return Err(anyhow!(
"frust-render headless: a render target must have a non-zero size, got {}x{}",
spec.width,
spec.height
));
}
self.ensure_target(spec.width, spec.height);
let target = self
.target
.as_ref()
.expect("ensure_target always leaves a target in place");
let scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-render headless frame"),
});
let encoded = self.engine.encode(
&self.device,
&self.queue,
&mut encoder,
scene,
frust_engine::EngineTarget {
view: target.view(),
format: TARGET_FORMAT,
width: spec.width,
height: spec.height,
depth: None,
output: frust_engine::OutputAlpha::Premultiplied,
},
spec.base_color,
spec.root,
);
self.queue.submit([encoder.finish()]);
self.engine.end_frame(&self.queue);
let validation = scope.pop().await;
let encoded =
encoded.map_err(|e| anyhow!("frust-render headless: the frame was refused: {e}"));
finish_scoped(encoded, validation, "render")?;
let target = self
.target
.as_ref()
.expect("ensure_target always leaves a target in place");
Ok(HeadlessImage {
width: spec.width,
height: spec.height,
rgba8: target.read_back(&self.device, &self.queue),
meta: self.meta.clone(),
})
}
fn ensure_target(&mut self, width: u32, height: u32) {
if matches!(&self.target, Some(t) if t.width() == width && t.height() == height) {
return;
}
if self.target.is_some() {
self.engine.resize(&self.device, width, height);
}
self.target = Some(frust_gpu::HeadlessTarget::new(
&self.device,
width,
height,
TARGET_FORMAT,
));
}
}
fn finish_scoped<T>(result: Result<T>, validation: Option<wgpu::Error>, what: &str) -> Result<T> {
match (result, validation) {
(Ok(value), None) => Ok(value),
(Ok(_), Some(error)) => Err(anyhow!(
"frust-render headless: wgpu validation error during {what}: {error}"
)),
(Err(error), None) => Err(error),
(Err(error), Some(validation)) => {
Err(error.context(format!("wgpu validation error during {what}: {validation}")))
}
}
}
fn resolve_backends(
env_backends: Option<wgpu::Backends>,
hint: Option<&str>,
fallback: wgpu::Backends,
) -> wgpu::Backends {
env_backends
.or_else(|| hint.map(wgpu::Backends::from_comma_list))
.unwrap_or(fallback)
}
fn golden_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn check_expectations(
meta: &HeadlessMeta,
expect_adapter: Option<&str>,
expect_backend: Option<&str>,
) -> Result<()> {
if let Some(expected) = expect_adapter.map(str::trim).filter(|e| !e.is_empty())
&& !meta
.adapter
.to_lowercase()
.contains(&expected.to_lowercase())
{
return Err(anyhow!(
"frust-render headless: expected adapter matching `{expected}`, but the run resolved \
`{}` ({meta}); set WGPU_ADAPTER_NAME (or isolate the driver ICD) so the intended GPU \
is selected",
meta.adapter
));
}
if let Some(expected) = expect_backend.map(str::trim).filter(|e| !e.is_empty())
&& !meta.backend.eq_ignore_ascii_case(expected)
{
return Err(anyhow!(
"frust-render headless: expected backend `{expected}`, but the run resolved `{}` \
({meta}); set WGPU_BACKEND to pin it",
meta.backend
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(backend: &str, adapter: &str) -> HeadlessMeta {
HeadlessMeta {
backend: backend.to_string(),
adapter: adapter.to_string(),
driver: "test driver".to_string(),
}
}
#[test]
fn no_expectation_accepts_any_adapter() {
assert!(check_expectations(&meta("vulkan", "Intel UHD 770"), None, None).is_ok());
assert!(check_expectations(&meta("vulkan", "Intel UHD 770"), Some(""), Some(" ")).is_ok());
}
#[test]
fn an_expected_adapter_matches_case_insensitively_as_a_substring() {
let resolved = meta("vulkan", "NVIDIA T400 4GB");
assert!(check_expectations(&resolved, Some("T400"), None).is_ok());
assert!(check_expectations(&resolved, Some("t400"), None).is_ok());
assert!(check_expectations(&resolved, Some(" NVIDIA T400 "), None).is_ok());
}
#[test]
fn the_wrong_adapter_is_refused_naming_both_names() {
let error = check_expectations(
&meta("vulkan", "Intel UHD Graphics 770"),
Some("T400"),
None,
)
.expect_err("a mismatched adapter must be refused");
let message = error.to_string();
assert!(message.contains("T400"), "{message}");
assert!(message.contains("Intel UHD Graphics 770"), "{message}");
assert!(message.contains("WGPU_ADAPTER_NAME"), "{message}");
}
#[test]
fn the_wrong_backend_is_refused() {
let resolved = meta("gl", "NVIDIA T400 4GB");
assert!(check_expectations(&resolved, None, Some("vulkan")).is_err());
assert!(check_expectations(&resolved, None, Some("GL")).is_ok());
assert!(check_expectations(&resolved, Some("T400"), Some("vulkan")).is_err());
}
#[test]
fn the_backend_env_knob_wins_over_the_caller_hint() {
assert_eq!(
resolve_backends(
Some(wgpu::Backends::VULKAN),
Some("metal"),
wgpu::Backends::all()
),
wgpu::Backends::VULKAN
);
assert_eq!(
resolve_backends(None, Some("vulkan"), wgpu::Backends::all()),
wgpu::Backends::VULKAN
);
assert_eq!(
resolve_backends(None, None, wgpu::Backends::PRIMARY),
wgpu::Backends::PRIMARY
);
}
#[test]
fn meta_renders_the_provenance_a_baseline_must_record() {
let info = meta("vulkan", "NVIDIA T400 4GB");
let line = info.to_string();
assert!(line.contains("backend=vulkan"), "{line}");
assert!(line.contains("NVIDIA T400 4GB"), "{line}");
assert!(line.contains("test driver"), "{line}");
}
fn validation_error(description: &str) -> wgpu::Error {
wgpu::Error::Validation {
source: Box::new(std::io::Error::other(description.to_string())),
description: description.to_string(),
}
}
#[test]
fn a_clean_scope_passes_the_result_through() {
assert_eq!(finish_scoped(Ok(7_u8), None, "render").unwrap(), 7);
let failed: Result<u8> = Err(anyhow!("the frame was refused"));
let error = finish_scoped(failed, None, "render").expect_err("the failure must survive");
assert!(error.to_string().contains("the frame was refused"));
}
#[test]
fn a_captured_validation_error_fails_an_otherwise_successful_operation() {
let error = finish_scoped(Ok(7_u8), Some(validation_error("bad bind group")), "render")
.expect_err("a validation error must fail the operation");
let message = error.to_string();
assert!(
message.contains("validation error during render"),
"{message}"
);
assert!(message.contains("bad bind group"), "{message}");
}
#[test]
fn a_captured_validation_error_annotates_a_failed_operation() {
let failed: Result<u8> = Err(anyhow!("the frame was refused"));
let error = finish_scoped(failed, Some(validation_error("bad bind group")), "render")
.expect_err("the failure must survive");
let chain = format!("{error:#}");
assert!(chain.contains("the frame was refused"), "{chain}");
assert!(chain.contains("bad bind group"), "{chain}");
}
}