use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, OnceLock};
use anyhow::{Result, anyhow};
use crate::caps::{DownlevelProfile, TierCaps};
use crate::surface::{
ConfiguredSurface, SurfaceAlphaRequest, SurfaceFactory, resolve_alpha_mode,
select_surface_format,
};
const DEFAULT_DEVICE_LABEL: &str = "frust-gpu device";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContextOptions {
pub device_label: String,
pub backends: Option<wgpu::Backends>,
}
impl Default for ContextOptions {
fn default() -> Self {
Self {
device_label: DEFAULT_DEVICE_LABEL.to_string(),
backends: None,
}
}
}
#[derive(Clone, Debug)]
pub struct DeviceHandle {
pub adapter: wgpu::Adapter,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub caps: TierCaps,
first_uncaptured_error: Arc<OnceLock<String>>,
}
impl DeviceHandle {
pub fn first_uncaptured_error(&self) -> Option<&str> {
self.first_uncaptured_error.get().map(String::as_str)
}
}
pub struct RenderContext {
instance: wgpu::Instance,
options: ContextOptions,
device: Option<DeviceHandle>,
}
impl Default for RenderContext {
fn default() -> Self {
Self::new()
}
}
impl RenderContext {
pub fn new() -> Self {
Self::with_options(ContextOptions::default())
}
pub fn with_options(options: ContextOptions) -> Self {
let backends = options
.backends
.unwrap_or_else(|| wgpu::Backends::from_env().unwrap_or_default());
let build_flags = wgpu::InstanceFlags::from_build_config().with_env();
#[cfg(target_os = "android")]
let flags = effective_instance_flags(build_flags, is_android_emulator());
#[cfg(not(target_os = "android"))]
let flags = build_flags;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
display: None,
backends,
flags,
memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
backend_options: wgpu::BackendOptions::from_env_or_default(),
});
Self {
instance,
options,
device: None,
}
}
pub fn surface_factory(&self) -> SurfaceFactory {
SurfaceFactory::new(&self.instance)
}
#[doc(hidden)]
pub fn instance(&self) -> &wgpu::Instance {
&self.instance
}
#[doc(hidden)]
pub fn device_handle(&self) -> &DeviceHandle {
self.device
.as_ref()
.expect("device must be created before it is used (surface creation creates it)")
}
pub async fn device(&mut self) -> Result<&DeviceHandle> {
self.ensure_device_headless().await?;
Ok(self
.device
.as_ref()
.expect("device was just created or already present"))
}
pub fn caps(&self) -> Option<&TierCaps> {
self.device.as_ref().map(|handle| &handle.caps)
}
pub fn pipeline_cache_supported(&self) -> bool {
self.device_handle()
.device
.features()
.contains(wgpu::Features::PIPELINE_CACHE)
}
pub fn adapter_cache_key(&self) -> String {
crate::pipeline_cache::adapter_cache_key(&self.device_handle().adapter.get_info())
}
#[doc(hidden)]
pub unsafe fn create_pipeline_cache(&self, blob: Option<&[u8]>) -> Option<wgpu::PipelineCache> {
if !self.pipeline_cache_supported() {
return None;
}
let handle = self.device_handle();
let key = crate::pipeline_cache::adapter_cache_key(&handle.adapter.get_info());
let data = blob.and_then(|b| crate::pipeline_cache::unframe(b, &key));
log::debug!(
"frust-gpu: creating wgpu PipelineCache (seed: {})",
if data.is_some() {
"persisted blob"
} else {
"empty"
}
);
let cache = unsafe {
handle
.device
.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
label: Some("frust-gpu pipeline cache"),
data,
fallback: true,
})
};
Some(cache)
}
#[doc(hidden)]
pub async fn ensure_device(&mut self, surface: &wgpu::Surface<'static>) -> Result<()> {
if let Some(existing) = &self.device
&& existing.adapter.is_surface_supported(surface)
{
return Ok(());
}
self.device = Some(create_device(&self.instance, &self.options, Some(surface)).await?);
Ok(())
}
pub async fn ensure_device_headless(&mut self) -> Result<()> {
if self.device.is_some() {
return Ok(());
}
self.device = Some(create_device(&self.instance, &self.options, None).await?);
Ok(())
}
#[doc(hidden)]
pub async fn create_render_surface(
&mut self,
surface: wgpu::Surface<'static>,
width: u32,
height: u32,
present_mode: wgpu::PresentMode,
alpha: SurfaceAlphaRequest,
) -> Result<ConfiguredSurface> {
self.ensure_device(&surface).await?;
let handle = self.device_handle();
let capabilities = surface.get_capabilities(&handle.adapter);
let alpha_mode = resolve_alpha_mode(alpha, &capabilities);
let format = select_surface_format(&capabilities)?;
Ok(ConfiguredSurface::configure(
surface,
&handle.device,
format,
alpha_mode,
(width, height),
present_mode,
))
}
}
async fn create_device(
instance: &wgpu::Instance,
options: &ContextOptions,
compatible_surface: Option<&wgpu::Surface<'static>>,
) -> Result<DeviceHandle> {
let adapter = wgpu::util::initialize_adapter_from_env_or_default(instance, compatible_surface)
.await
.map_err(|e| anyhow!("frust-gpu: no compatible GPU adapter: {e}"))?;
let caps = TierCaps::probe(&adapter);
let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
let required_limits = effective_limits(base_limits, is_ios_simulator());
let required_features =
device_features(adapter.features(), &caps, cfg!(feature = "perf-trace"));
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some(&options.device_label),
required_features,
required_limits,
..Default::default()
})
.await
.map_err(|e| anyhow!("frust-gpu: failed to create GPU device: {e}"))?;
let error_count = Arc::new(AtomicU32::new(0));
let first_error: Arc<OnceLock<String>> = Arc::new(OnceLock::new());
let latch = Arc::clone(&first_error);
device.on_uncaptured_error(Arc::new(move |error| {
let count = error_count.fetch_add(1, Ordering::Relaxed) + 1;
let _ = latch.set(error.to_string());
match decide_log_action(count) {
LogAction::Log => {
log::error!("frust-gpu: uncaptured wgpu error: {error}");
}
LogAction::SuppressionNotice => {
log::error!(
"frust-gpu: further uncaptured wgpu errors suppressed \
(total so far: {count})"
);
}
LogAction::Silent { debug_bump } => {
if debug_bump {
log::debug!(
"frust-gpu: uncaptured wgpu error count now {count} \
(still suppressed)"
);
}
}
}
}));
Ok(DeviceHandle {
adapter,
device,
queue,
caps,
first_uncaptured_error: first_error,
})
}
fn base_device_limits(profile: DownlevelProfile, adapter_limits: wgpu::Limits) -> wgpu::Limits {
if profile == DownlevelProfile::WebGl2 {
wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits)
} else {
adapter_limits
}
}
pub fn test_device_limits(adapter: &wgpu::Adapter, caps: &TierCaps) -> wgpu::Limits {
let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
effective_limits(base_limits, is_ios_simulator())
}
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
fn effective_instance_flags(flags: wgpu::InstanceFlags, is_emulator: bool) -> wgpu::InstanceFlags {
if is_emulator {
flags - (wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION)
} else {
flags
}
}
#[cfg(target_os = "android")]
fn is_android_emulator() -> bool {
android_system_properties::AndroidSystemProperties::new()
.get("ro.kernel.qemu")
.as_deref()
== Some("1")
}
const IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT: u32 = 256;
pub fn effective_limits(base: wgpu::Limits, is_ios_simulator: bool) -> wgpu::Limits {
if is_ios_simulator
&& base.min_uniform_buffer_offset_alignment
< IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT
{
wgpu::Limits {
min_uniform_buffer_offset_alignment: IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT,
..base
}
} else {
base
}
}
pub const fn is_ios_simulator() -> bool {
cfg!(all(target_os = "ios", target_abi = "sim"))
}
pub fn optional_device_features() -> wgpu::Features {
wgpu::Features::PIPELINE_CACHE
}
fn required_features(caps: &TierCaps, perf_trace: bool) -> wgpu::Features {
if perf_trace && caps.has_timestamp_query {
wgpu::Features::TIMESTAMP_QUERY
} else {
wgpu::Features::empty()
}
}
fn device_features(
adapter_features: wgpu::Features,
caps: &TierCaps,
perf_trace: bool,
) -> wgpu::Features {
(adapter_features & optional_device_features()) | required_features(caps, perf_trace)
}
const MAX_LOGGED_UNCAPTURED_ERRORS: u32 = 5;
const UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD: u32 = 100;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LogAction {
Log,
SuppressionNotice,
Silent { debug_bump: bool },
}
pub fn decide_log_action(count: u32) -> LogAction {
if count <= MAX_LOGGED_UNCAPTURED_ERRORS {
LogAction::Log
} else if count == MAX_LOGGED_UNCAPTURED_ERRORS + 1 {
LogAction::SuppressionNotice
} else {
LogAction::Silent {
debug_bump: count.is_multiple_of(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caps::DownlevelProfile;
#[test]
fn emulator_strips_debug_and_validation() {
let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
let flags = effective_instance_flags(build_flags, true);
assert!(!flags.contains(wgpu::InstanceFlags::DEBUG));
assert!(!flags.contains(wgpu::InstanceFlags::VALIDATION));
}
#[test]
fn physical_device_keeps_debug_and_validation() {
let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
let flags = effective_instance_flags(build_flags, false);
assert!(flags.contains(wgpu::InstanceFlags::DEBUG));
assert!(flags.contains(wgpu::InstanceFlags::VALIDATION));
}
#[test]
fn emulator_with_no_debug_flags_stays_empty() {
let flags = effective_instance_flags(wgpu::InstanceFlags::empty(), true);
assert!(flags.is_empty());
}
#[test]
fn ios_simulator_bumps_alignment_to_256() {
let base = wgpu::Limits::default();
let limits = effective_limits(base.clone(), true);
assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
assert_eq!(
wgpu::Limits {
min_uniform_buffer_offset_alignment: base.min_uniform_buffer_offset_alignment,
..limits.clone()
},
base
);
}
#[test]
fn non_simulator_leaves_limits_untouched() {
let base = wgpu::Limits::default();
let limits = effective_limits(base.clone(), false);
assert_eq!(limits, base);
}
#[test]
fn base_already_at_or_above_256_is_not_lowered() {
let base = wgpu::Limits {
min_uniform_buffer_offset_alignment: 512,
..wgpu::Limits::default()
};
let limits = effective_limits(base.clone(), true);
assert_eq!(limits.min_uniform_buffer_offset_alignment, 512);
assert_eq!(limits, base);
}
#[test]
fn simulator_alignment_uses_real_adapter_limits_not_defaults() {
let adapter = wgpu::Limits {
min_uniform_buffer_offset_alignment: 64,
max_texture_dimension_2d: 4096,
..wgpu::Limits::default()
};
let limits = effective_limits(adapter.clone(), true);
assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
assert_eq!(limits.max_texture_dimension_2d, 4096);
}
#[test]
fn first_n_uncaptured_errors_log() {
for count in 1..=MAX_LOGGED_UNCAPTURED_ERRORS {
assert_eq!(
decide_log_action(count),
LogAction::Log,
"expected Log at count={count}"
);
}
}
#[test]
fn nplus1_uncaptured_error_suppresses() {
assert_eq!(
decide_log_action(MAX_LOGGED_UNCAPTURED_ERRORS + 1),
LogAction::SuppressionNotice
);
}
#[test]
fn further_uncaptured_errors_stay_silent_between_debug_bumps() {
let past_notice = MAX_LOGGED_UNCAPTURED_ERRORS + 2;
assert_eq!(
decide_log_action(past_notice),
LogAction::Silent { debug_bump: false }
);
}
#[test]
fn uncaptured_error_debug_bump_is_periodic() {
assert_eq!(
decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
LogAction::Silent { debug_bump: true }
);
assert_eq!(
decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD * 2),
LogAction::Silent { debug_bump: true }
);
assert_eq!(
decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD + 1),
LogAction::Silent { debug_bump: false }
);
}
#[test]
fn default_build_requires_no_device_features() {
let caps = TierCaps::fake(DownlevelProfile::Full);
assert!(caps.has_timestamp_query, "fixture precondition");
assert_eq!(required_features(&caps, false), wgpu::Features::empty());
}
#[test]
fn perf_trace_build_requires_timestamp_query_when_offered() {
let caps = TierCaps::fake(DownlevelProfile::Full);
assert_eq!(
required_features(&caps, true),
wgpu::Features::TIMESTAMP_QUERY
);
}
#[test]
fn perf_trace_build_requires_nothing_when_adapter_lacks_timestamp_query() {
let caps = TierCaps::fake(DownlevelProfile::WebGl2);
assert!(!caps.has_timestamp_query, "fixture precondition");
assert_eq!(required_features(&caps, true), wgpu::Features::empty());
}
#[test]
fn pipeline_cache_is_requested_whenever_the_adapter_offers_it() {
let caps = TierCaps::fake(DownlevelProfile::Full);
let adapter = wgpu::Features::PIPELINE_CACHE | wgpu::Features::DEPTH_CLIP_CONTROL;
assert_eq!(
device_features(adapter, &caps, false),
wgpu::Features::PIPELINE_CACHE,
"an offered optional feature is taken, and nothing else is"
);
}
#[test]
fn an_adapter_without_pipeline_cache_is_never_asked_for_it() {
let caps = TierCaps::fake(DownlevelProfile::Full);
assert_eq!(
device_features(wgpu::Features::empty(), &caps, false),
wgpu::Features::empty()
);
}
#[test]
fn a_perf_trace_build_asks_for_both_halves_when_both_are_offered() {
let caps = TierCaps::fake(DownlevelProfile::Full);
assert!(caps.has_timestamp_query, "fixture precondition");
assert_eq!(
device_features(wgpu::Features::PIPELINE_CACHE, &caps, true),
wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY
);
}
#[test]
fn base_device_limits_full_profile_passes_adapter_limits_through_unclamped() {
let adapter_limits = wgpu::Limits {
max_inter_stage_shader_variables: 15,
..wgpu::Limits::default()
};
let limits = base_device_limits(DownlevelProfile::Full, adapter_limits.clone());
assert!(limits.max_inter_stage_shader_variables <= 15);
assert_eq!(limits, adapter_limits);
}
#[test]
fn base_device_limits_webgl2_profile_never_exceeds_the_constrained_adapter() {
let adapter_limits = wgpu::Limits {
max_inter_stage_shader_variables: 15,
..wgpu::Limits::default()
};
let limits = base_device_limits(DownlevelProfile::WebGl2, adapter_limits);
assert!(limits.max_inter_stage_shader_variables <= 15);
}
#[test]
fn default_options_label_the_device_and_leave_backends_to_the_environment() {
let options = ContextOptions::default();
assert_eq!(options.device_label, DEFAULT_DEVICE_LABEL);
assert_eq!(options.backends, None);
}
#[test]
fn a_fresh_context_has_no_device_and_therefore_no_caps() {
let context = RenderContext::new();
assert!(context.caps().is_none());
}
#[test]
#[ignore = "needs a real GPU adapter; run with `cargo test -p frust-gpu -- --ignored` \
(pin the adapter on a multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
fn gpu_device_creation_reports_adapter_caps() {
pollster::block_on(async {
let mut context = RenderContext::new();
let handle = context.device().await.expect("device creation");
let info = handle.adapter.get_info();
println!(
"frust-gpu adapter: name={:?} backend={:?} device_type={:?} \
driver={:?} driver_info={:?} vendor={:#06x} device={:#06x}",
info.name,
info.backend,
info.device_type,
info.driver,
info.driver_info,
info.vendor,
info.device
);
println!("frust-gpu caps: {:#?}", handle.caps);
println!(
"frust-gpu device limits: max_texture_dimension_2d={} \
min_uniform_buffer_offset_alignment={}",
handle.device.limits().max_texture_dimension_2d,
handle.device.limits().min_uniform_buffer_offset_alignment
);
assert_eq!(handle.caps.adapter_name, info.name);
assert_eq!(handle.caps.backend, info.backend);
assert!(!handle.caps.adapter_name.is_empty());
assert!(handle.caps.max_texture_dimension_2d > 0);
assert!(handle.caps.resource_texture_dim > 0);
assert_eq!(handle.first_uncaptured_error(), None);
let downlevel_env_set = std::env::var("FRUST_ENGINE_DOWNLEVEL").is_ok_and(|v| v != "0");
if downlevel_env_set {
assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::WebGl2);
assert!(!handle.caps.has_storage_buffers);
assert!(handle.caps.max_texture_dimension_2d <= 2048);
assert_eq!(handle.caps.min_uniform_buffer_offset_alignment, 256);
assert!(handle.device.limits().max_texture_dimension_2d > 0);
} else {
assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::Full);
}
let caps = context.caps().cloned().expect("caps after device creation");
assert_eq!(caps.adapter_name, info.name);
let again = context.device().await.expect("device reuse");
assert_eq!(again.caps, caps);
});
}
}