frust_gpu/context.rs
1//! Instance, adapter and lazy logical-device creation: [`RenderContext`] and
2//! [`DeviceHandle`].
3//!
4//! This is the crate's entry point — everything else in `frust-gpu` consumes a
5//! [`DeviceHandle`] rather than reaching for a `wgpu::Adapter` itself. A
6//! [`RenderContext`] owns one `wgpu::Instance` and at most one logical device,
7//! created lazily on the first surface (or the first [`RenderContext::device`]
8//! call) and then reused: a logical device is display-independent, so surface
9//! loss and recreation (rotation, backgrounding) must not rebuild it.
10//!
11//! This is also the type `frust-render` re-exports as its own
12//! `frust_render::RenderContext`, which is what every platform shell holds: the
13//! device/surface foundation lives here, and the renderer above adds only the
14//! render-path decisions specific to how a frame is drawn.
15//!
16//! Surface creation itself lives in [`crate::surface`] and the raw-pointer
17//! constructors in [`crate::lifecycle`]; [`RenderContext::create_render_surface`]
18//! is the one entry point that ties them to a device.
19//!
20//! # Pure decision vs. platform lookup
21//!
22//! Every environment-sensitive choice here is split into a pure function taking
23//! the environment as an argument (`effective_instance_flags`,
24//! [`effective_limits`], `device_features`, [`decide_log_action`]) plus a
25//! separate lookup that answers what the environment actually is
26//! (`is_android_emulator`, [`is_ios_simulator`]). Only the lookups are
27//! platform-gated, so the policies stay unit-testable on any host with no GPU
28//! and no mobile target in the loop — the same split
29//! [`crate::caps::TierCaps::probe`]/[`crate::caps::TierCaps::fake`] gives
30//! adapter capabilities.
31//!
32//! # Two mitigations worth knowing about
33//!
34//! - The Android **emulator** cannot survive `wgpu::InstanceFlags::DEBUG`, so
35//! the instance is built with those flags stripped there and nowhere else
36//! (`effective_instance_flags`).
37//! - The iOS **Simulator** misreports its uniform-buffer alignment, so a device
38//! request made there is forced back up to 256 bytes ([`effective_limits`]).
39//!
40//! # The Android hardware-buffer device
41//!
42//! `wgpu`'s own `request_device` never enables
43//! `VK_ANDROID_external_memory_android_hardware_buffer`, so a device it opens
44//! can never import an `AHardwareBuffer` ([`crate::import`]). On Android, with
45//! the `FRUST_ANDROID_HWBUFFER_IMPORT` knob on (the compile-time or runtime
46//! environment variable, or the `debug.frust.hwbuffer_import` system property,
47//! any value but `0`), a Vulkan device is instead opened by this module itself:
48//! the same features, limits and memory hints `request_device` would have been
49//! given, plus the import extensions (`hwbuffer_import_extensions`), through
50//! `wgpu_hal::vulkan::Adapter::open_with_callback` and
51//! `wgpu::Adapter::create_device_from_hal`. When any extension is missing the
52//! device is opened through `wgpu` as usual, and [`TierCaps::hwbuffer_import`]
53//! records which path produced it. Knob off — and on every other target — the
54//! device request is exactly the plain `request_device` one.
55
56use std::ffi::CStr;
57use std::sync::atomic::{AtomicU32, Ordering};
58use std::sync::{Arc, OnceLock};
59
60use anyhow::{Result, anyhow};
61
62use crate::caps::{DownlevelProfile, TierCaps, env_flag_enabled};
63use crate::surface::{
64 ConfiguredSurface, SurfaceAlphaRequest, SurfaceFactory, resolve_alpha_mode,
65 select_surface_format,
66};
67
68/// Default `wgpu::Device` debug label, used when a caller supplies no
69/// [`ContextOptions::device_label`] of its own.
70const DEFAULT_DEVICE_LABEL: &str = "frust-gpu device";
71
72/// How a [`RenderContext`] should build its instance and request its device.
73///
74/// Every field has a working default, so `ContextOptions::default()` is the
75/// ordinary construction — a field exists here only where a host genuinely has
76/// a choice to make, and [`RenderContext::new`] takes the defaults.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct ContextOptions {
79 /// Debug label attached to the created `wgpu::Device`, surfaced by graphics
80 /// debuggers and in validation messages.
81 pub device_label: String,
82 /// Backends the `wgpu::Instance` is restricted to. `None` — the default —
83 /// takes `wgpu::Backends::from_env()` (the `WGPU_BACKEND` knob) and falls
84 /// back to every backend compiled in, which is what a shell wants. A
85 /// headless caller that must pin one backend regardless of the ambient
86 /// environment sets it explicitly.
87 pub backends: Option<wgpu::Backends>,
88}
89
90impl Default for ContextOptions {
91 fn default() -> Self {
92 Self {
93 device_label: DEFAULT_DEVICE_LABEL.to_string(),
94 backends: None,
95 }
96 }
97}
98
99/// A logical device, the adapter it was created from, the queue that executes
100/// its command buffers, and the capabilities that adapter reported.
101///
102/// Cheap to clone: `wgpu`'s `Adapter`/`Device`/`Queue` are all `Arc`-backed
103/// handles to one underlying object, so a clone is another handle to the *same*
104/// device rather than a second device. Clone it freely to hand a subsystem the
105/// device it needs instead of threading a `&RenderContext` borrow through it.
106#[derive(Clone, Debug)]
107pub struct DeviceHandle {
108 /// The adapter the device was created from.
109 pub adapter: wgpu::Adapter,
110 /// The logical device.
111 pub device: wgpu::Device,
112 /// The queue that executes this device's command buffers.
113 pub queue: wgpu::Queue,
114 /// What [`Self::adapter`] reported at device-creation time. Captured once
115 /// so downstream pipeline/atlas decisions read plain data instead of
116 /// re-probing the adapter.
117 pub caps: TierCaps,
118 /// The first uncaptured error this device raised, latched by the handler
119 /// installed in [`create_device`]. See [`Self::first_uncaptured_error`].
120 first_uncaptured_error: Arc<OnceLock<String>>,
121}
122
123impl DeviceHandle {
124 /// The **first** uncaptured `wgpu` error this device ever raised, or `None`
125 /// if it has raised none.
126 ///
127 /// Latched, never overwritten: a frame loop polling this wants the error
128 /// that started the trouble, not the last one in a storm the first one
129 /// caused. It is also the only programmatic view of an uncaptured error a
130 /// caller gets — the handler otherwise only logs (see
131 /// [`decide_log_action`]) — so a host can degrade or report instead of
132 /// silently rendering nothing every frame.
133 ///
134 /// Deliberately not clearable: "this device has seen an uncaptured error"
135 /// is a property of the device, and a device that has raised one is not
136 /// reliably recoverable by forgetting about it.
137 pub fn first_uncaptured_error(&self) -> Option<&str> {
138 self.first_uncaptured_error.get().map(String::as_str)
139 }
140}
141
142/// Owns the `wgpu::Instance` and the single logical device this crate's
143/// consumers render with.
144///
145/// A single `RenderContext` is shared across every surface a shell creates
146/// (frust is single-window); the device is created lazily on the first surface
147/// and reused across surface loss/recreation (rotation, backgrounding) since a
148/// logical device is display-independent. [`RenderContext::new`] performs no
149/// adapter enumeration at all, so a host may build one early (before it has a
150/// window, or on a thread that will never render) and pay for the device only
151/// at the first surface — or at an explicit
152/// [`ensure_device_headless`](Self::ensure_device_headless) pre-init.
153pub struct RenderContext {
154 instance: wgpu::Instance,
155 options: ContextOptions,
156 /// `None` until a surface (or an explicit pre-init) creates the device.
157 device: Option<DeviceHandle>,
158}
159
160impl Default for RenderContext {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166impl RenderContext {
167 /// Creates a context with a fresh wgpu `Instance` and no device yet
168 /// (the device is created lazily on first surface creation).
169 ///
170 /// The instance flags come from the build configuration
171 /// (`InstanceFlags::from_build_config`, which turns `DEBUG`/`VALIDATION` on
172 /// in debug builds) plus the standard `WGPU_*` environment overrides. When
173 /// actually running on an Android *emulator* they are then run through
174 /// `effective_instance_flags`, which strips `DEBUG`/`VALIDATION`: the
175 /// `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
176 /// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
177 /// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
178 /// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
179 /// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
180 /// fragility a MoltenVK Vulkan backend is also known to have. Debug object
181 /// labels are only a developer convenience, so dropping them on the
182 /// emulator is a safe way to keep GPU bring-up alive there while leaving
183 /// physical devices' validation safety net — and desktop behavior —
184 /// untouched.
185 pub fn new() -> Self {
186 Self::with_options(ContextOptions::default())
187 }
188
189 /// [`Self::new`] with an explicit [`ContextOptions`] — a headless harness
190 /// that must label its device or pin one backend regardless of the ambient
191 /// environment. Every shell takes `new()`'s defaults instead.
192 pub fn with_options(options: ContextOptions) -> Self {
193 let backends = options
194 .backends
195 .unwrap_or_else(|| wgpu::Backends::from_env().unwrap_or_default());
196 let build_flags = wgpu::InstanceFlags::from_build_config().with_env();
197 #[cfg(target_os = "android")]
198 let flags = effective_instance_flags(build_flags, is_android_emulator());
199 #[cfg(not(target_os = "android"))]
200 let flags = build_flags;
201 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
202 display: None,
203 backends,
204 flags,
205 memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
206 backend_options: wgpu::BackendOptions::from_env_or_default(),
207 });
208 Self {
209 instance,
210 options,
211 device: None,
212 }
213 }
214
215 /// A cloneable [`SurfaceFactory`] sharing this context's wgpu `Instance`,
216 /// for creating a [`DetachedSurface`](crate::surface::DetachedSurface) on
217 /// the windowing/main thread when the context itself lives on the render
218 /// thread (the render-thread split). The surface a clone produces stays
219 /// compatible with the device this context creates, since both share one
220 /// Arc-backed instance.
221 pub fn surface_factory(&self) -> SurfaceFactory {
222 SurfaceFactory::new(&self.instance)
223 }
224
225 /// This context's wgpu `Instance`, for the two raw-pointer surface
226 /// constructors in [`crate::lifecycle`] (the mobile shells' path, which
227 /// receives an `ANativeWindow*`/`CAMetalLayer*` rather than a window
228 /// handle a [`SurfaceFactory`] could take).
229 ///
230 /// Hidden from the rendered docs rather than made private, on the same
231 /// grounds as
232 /// [`DetachedSurface::into_surface`](crate::surface::DetachedSurface::into_surface):
233 /// the renderer crate above this one is the intended (and only) caller,
234 /// and no layer above *it* may re-export this accessor.
235 #[doc(hidden)]
236 pub fn instance(&self) -> &wgpu::Instance {
237 &self.instance
238 }
239
240 /// The single logical device, panicking if no surface has created it yet.
241 ///
242 /// Only called from the renderer's install/resize/render paths, all of
243 /// which run strictly after a `create_*_surface`, so the device is always
244 /// present. Use [`Self::device`] for the fallible, creating form.
245 ///
246 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
247 #[doc(hidden)]
248 pub fn device_handle(&self) -> &DeviceHandle {
249 self.device
250 .as_ref()
251 .expect("device must be created before it is used (surface creation creates it)")
252 }
253
254 /// The logical device, creating it on first call and returning the same one
255 /// afterwards.
256 ///
257 /// Adapter selection goes through
258 /// `wgpu::util::initialize_adapter_from_env_or_default`, so
259 /// `WGPU_ADAPTER_NAME`/`WGPU_POWER_PREF` pick the adapter on a host with
260 /// more than one — the only way to pin a specific GPU on a multi-adapter
261 /// machine.
262 ///
263 /// # Errors
264 ///
265 /// When no adapter is available at all, or when the device request the
266 /// adapter's own limits were computed for is nonetheless refused. Both are
267 /// terminal for GPU rendering; neither is retryable by calling again.
268 pub async fn device(&mut self) -> Result<&DeviceHandle> {
269 self.ensure_device_headless().await?;
270 Ok(self
271 .device
272 .as_ref()
273 .expect("device was just created or already present"))
274 }
275
276 /// What the live device's adapter reported, or `None` while the device is
277 /// still uncreated — capabilities are an adapter's answer, and no adapter
278 /// has been selected before the first device creation.
279 pub fn caps(&self) -> Option<&TierCaps> {
280 self.device.as_ref().map(|handle| &handle.caps)
281 }
282
283 /// Whether the live device was created with `wgpu::Features::PIPELINE_CACHE`.
284 ///
285 /// wgpu only implements the persisted pipeline cache on Vulkan — every
286 /// Vulkan adapter advertises it (Android, Linux, Windows-on-Vulkan);
287 /// Metal and DX12 adapters never do, so it is absent there and
288 /// [`create_pipeline_cache`](Self::create_pipeline_cache) returns `None` —
289 /// the renderer then behaves exactly as it did before this path existed.
290 /// Panics if no surface (and thus no device) has been created yet.
291 pub fn pipeline_cache_supported(&self) -> bool {
292 self.device_handle()
293 .device
294 .features()
295 .contains(wgpu::Features::PIPELINE_CACHE)
296 }
297
298 /// The adapter fingerprint a persisted pipeline-cache blob is tagged with
299 /// (see [`crate::pipeline_cache`]). Panics if no device has been created yet.
300 pub fn adapter_cache_key(&self) -> String {
301 crate::pipeline_cache::adapter_cache_key(&self.device_handle().adapter.get_info())
302 }
303
304 /// Creates a `wgpu::PipelineCache` for the live device, seeded from a
305 /// previously persisted, framed `blob` when it validates for this adapter.
306 ///
307 /// Returns `None` when the device lacks `PIPELINE_CACHE` support (Metal/
308 /// desktop) — the renderer then runs its original, cache-less path. A `blob`
309 /// that fails framing/adapter validation
310 /// ([`crate::pipeline_cache::unframe`]) is discarded and the cache starts
311 /// empty; a `None` `blob` is a cold start.
312 ///
313 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
314 ///
315 /// # Safety
316 ///
317 /// One of this module's two sanctioned unsafe sites (the other is the
318 /// Android hardware-buffer device open, `hwbuffer_device`), and the only
319 /// one whose obligation is the caller's, not something this method can
320 /// fully close on its own: `unframe`'s magic-tag-plus-adapter-fingerprint check proves
321 /// only that `blob` was framed by `frust-gpu`'s own framing for *this*
322 /// adapter — provenance by convention, not a proof of the actual wgpu
323 /// contract on [`wgpu::Device::create_pipeline_cache`], which requires a
324 /// non-`None` `data` to have come from a prior `PipelineCache::get_data()`
325 /// on a `pipeline_cache_key`-compatible adapter. The caller must ensure
326 /// `blob` is exactly that: a blob previously produced by this driver's own
327 /// pipeline-cache output for this adapter, as persisted by `frust-render`'s
328 /// caching layer (`SurfaceRenderer::pipeline_cache_data`/
329 /// `set_initial_pipeline_cache_data`). A forged blob that nonetheless
330 /// passes the framing/fingerprint check is undefined behaviour per wgpu's
331 /// contract — `fallback: true` only covers a residual *internal* mismatch
332 /// wgpu itself detects, not a blob that misleads it into misbehaving.
333 #[doc(hidden)]
334 pub unsafe fn create_pipeline_cache(&self, blob: Option<&[u8]>) -> Option<wgpu::PipelineCache> {
335 if !self.pipeline_cache_supported() {
336 return None;
337 }
338 let handle = self.device_handle();
339 let key = crate::pipeline_cache::adapter_cache_key(&handle.adapter.get_info());
340 let data = blob.and_then(|b| crate::pipeline_cache::unframe(b, &key));
341 log::debug!(
342 "frust-gpu: creating wgpu PipelineCache (seed: {})",
343 if data.is_some() {
344 "persisted blob"
345 } else {
346 "empty"
347 }
348 );
349 // SAFETY: see this method's `# Safety` section — `data` has already been
350 // validated against this adapter's fingerprint by `unframe`, and
351 // `fallback: true` turns any residual internal mismatch into a
352 // fall-back-to-empty cache rather than UB.
353 let cache = unsafe {
354 handle
355 .device
356 .create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
357 label: Some("frust-gpu pipeline cache"),
358 data,
359 fallback: true,
360 })
361 };
362 Some(cache)
363 }
364
365 /// Lazily create the logical device compatible with `surface`, requesting
366 /// the adapter's own limits (never `Limits::default()`, which the iOS
367 /// Simulator cannot satisfy) plus the #7057 alignment mitigation. Reuses
368 /// an already-created device when it is compatible with `surface`, so
369 /// surface loss/recreation (rotation, backgrounding) never rebuilds it — and
370 /// so a device the [`ensure_device_headless`](Self::ensure_device_headless)
371 /// pre-init created before any surface existed is adopted here rather than
372 /// rebuilt.
373 ///
374 /// Hidden from the rendered docs for the same reason as [`Self::instance`]:
375 /// the renderer above calls it to have a device in hand before it sizes its
376 /// own per-surface attachments; nothing higher may.
377 #[doc(hidden)]
378 pub async fn ensure_device(&mut self, surface: &wgpu::Surface<'static>) -> Result<()> {
379 if let Some(existing) = &self.device
380 && existing.adapter.is_surface_supported(surface)
381 {
382 return Ok(());
383 }
384 self.device = Some(create_device(&self.instance, &self.options, Some(surface)).await?);
385 Ok(())
386 }
387
388 /// Create the logical device **before any surface exists**, so the wgpu
389 /// instance/adapter/device bring-up can run on a
390 /// background thread kicked at native-library load (`JNI_OnLoad`) and be
391 /// joined by `nativeInit` instead of running serially after `surfaceCreated`.
392 /// Idempotent: a no-op when a device already exists.
393 ///
394 /// # Android singular-adapter assumption
395 ///
396 /// Requesting the adapter with `compatible_surface: None` picks wgpu's
397 /// default adapter rather than one filtered to a specific surface. On Android
398 /// the Vulkan backend exposes a single physical device, so the adapter chosen
399 /// here is the same one a later surface-filtered request would pick, and
400 /// [`ensure_device`](Self::ensure_device)'s `is_surface_supported` reuse check
401 /// accepts it — the surface created at `nativeInit` reuses this device with no
402 /// rebuild. On a hypothetical multi-adapter device where the pre-init adapter
403 /// did *not* support the eventual surface, `ensure_device` simply rebuilds the
404 /// device against that surface (still correct, just without the overlap win).
405 /// This is the sole production caller that passes `None` below; desktop/iOS
406 /// create their device through the surface path and never invoke it.
407 pub async fn ensure_device_headless(&mut self) -> Result<()> {
408 if self.device.is_some() {
409 return Ok(());
410 }
411 self.device = Some(create_device(&self.instance, &self.options, None).await?);
412 Ok(())
413 }
414
415 /// Builds a configured [`ConfiguredSurface`] from a raw wgpu `Surface`,
416 /// creating the logical device if needed.
417 ///
418 /// The result is renderer-agnostic: the swapchain, the configuration it was
419 /// brought up with, and the resolved alpha facts. Whatever per-frame render
420 /// path a renderer pairs with it is that renderer's own business — see
421 /// `frust_render::context`'s `EngineSurface`.
422 ///
423 /// Hidden from the rendered docs for the same reason as [`Self::instance`].
424 #[doc(hidden)]
425 pub async fn create_render_surface(
426 &mut self,
427 surface: wgpu::Surface<'static>,
428 width: u32,
429 height: u32,
430 present_mode: wgpu::PresentMode,
431 alpha: SurfaceAlphaRequest,
432 ) -> Result<ConfiguredSurface> {
433 self.ensure_device(&surface).await?;
434 let handle = self.device_handle();
435
436 let capabilities = surface.get_capabilities(&handle.adapter);
437 // Resolved from the request and the surface's reported caps alone; the
438 // renderer above reads it back off the returned configuration to pick
439 // its own per-frame path.
440 let alpha_mode = resolve_alpha_mode(alpha, &capabilities);
441 // Whichever supported format the surface reports FIRST — the surface's
442 // own preference order, not this module's (see `select_surface_format`).
443 let format = select_surface_format(&capabilities)?;
444
445 Ok(ConfiguredSurface::configure(
446 surface,
447 &handle.device,
448 format,
449 alpha_mode,
450 (width, height),
451 present_mode,
452 ))
453 }
454}
455
456/// Selects an adapter, probes it, and requests the logical device — the body
457/// both [`RenderContext::ensure_device`] and
458/// [`RenderContext::ensure_device_headless`] share.
459///
460/// `compatible_surface` filters adapter selection to one that can present to the
461/// given surface; `None` (the pre-init path, and every headless caller) selects
462/// wgpu's default adapter — see the singular-adapter note on
463/// `ensure_device_headless`. The capability probe, limits mitigation, feature
464/// request and uncaptured-error handler are identical either way: the surface
465/// only ever affected adapter selection, never the device it yields.
466///
467/// A free function rather than a method so it borrows the instance and options
468/// separately from the `device` field the callers assign into.
469async fn create_device(
470 instance: &wgpu::Instance,
471 options: &ContextOptions,
472 compatible_surface: Option<&wgpu::Surface<'static>>,
473) -> Result<DeviceHandle> {
474 let adapter = wgpu::util::initialize_adapter_from_env_or_default(instance, compatible_surface)
475 .await
476 .map_err(|e| anyhow!("frust-gpu: no compatible GPU adapter: {e}"))?;
477
478 let caps = TierCaps::probe(&adapter);
479
480 // The device request is built from the resolved downlevel profile, not
481 // unconditionally from the adapter's raw limits — see `base_device_limits`
482 // for the shared derivation, and `effective_limits` for the iOS Simulator
483 // alignment mitigation layered on top of it.
484 let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
485 let required_limits = effective_limits(base_limits, is_ios_simulator());
486 let required_features =
487 device_features(adapter.features(), &caps, cfg!(feature = "perf-trace"));
488
489 let descriptor = wgpu::DeviceDescriptor {
490 label: Some(&options.device_label),
491 required_features,
492 required_limits,
493 ..Default::default()
494 };
495 let (device, queue, hwbuffer_import) = open_device(&adapter, &caps, &descriptor).await?;
496 let caps = TierCaps {
497 hwbuffer_import,
498 ..caps
499 };
500
501 // Route wgpu's uncaptured errors to the log and a latch instead of its
502 // default handler, which aborts the process by panicking ("handling wgpu
503 // errors as fatal by default"). A UI framework must survive a driver's
504 // *transient* GPU error and recover on a later frame rather than crash —
505 // e.g. the Android emulator's SwiftShader path can raise a one-off
506 // swapchain-acquire validation error under load, which used to wedge the
507 // app into a per-frame panic loop (the surface stayed ready and every
508 // subsequent render re-hit the fatal handler). Pairing this with the
509 // `Invalid`-acquire → reconfigure recovery (see [`crate::lifecycle`]) lets
510 // the swapchain rebuild and rendering resume. Across a mobile FFI boundary
511 // the default handler's abort means killing the host app outright. Genuine
512 // API misuse is still surfaced — loudly, at error level — just without
513 // killing the process.
514 //
515 // Two pieces of state, both per-device (captured fresh each time this
516 // closure is installed, i.e. once per logical device) and both behind `Arc`
517 // because `on_uncaptured_error`'s handler must be `Fn`, not `FnMut`:
518 //
519 // - `error_count` drives the log latch ([`decide_log_action`]), so a device
520 // wedged in a genuine per-frame error storm (as opposed to a one-off
521 // driver hiccup) cannot flood the log forever.
522 // - `first_error` latches the first error's text for the frame loop to
523 // poll via [`DeviceHandle::first_uncaptured_error`]. `OnceLock` gives
524 // exactly first-write-wins with no lock held across the handler body.
525 let error_count = Arc::new(AtomicU32::new(0));
526 let first_error: Arc<OnceLock<String>> = Arc::new(OnceLock::new());
527 let latch = Arc::clone(&first_error);
528 device.on_uncaptured_error(Arc::new(move |error| {
529 let count = error_count.fetch_add(1, Ordering::Relaxed) + 1;
530 let _ = latch.set(error.to_string());
531 match decide_log_action(count) {
532 LogAction::Log => {
533 log::error!("frust-gpu: uncaptured wgpu error: {error}");
534 }
535 LogAction::SuppressionNotice => {
536 log::error!(
537 "frust-gpu: further uncaptured wgpu errors suppressed \
538 (total so far: {count})"
539 );
540 }
541 LogAction::Silent { debug_bump } => {
542 if debug_bump {
543 log::debug!(
544 "frust-gpu: uncaptured wgpu error count now {count} \
545 (still suppressed)"
546 );
547 }
548 }
549 }
550 }));
551
552 Ok(DeviceHandle {
553 adapter,
554 device,
555 queue,
556 caps,
557 first_uncaptured_error: first_error,
558 })
559}
560
561/// Opens the logical device `descriptor` describes on `adapter`, answering
562/// whether it can import Android hardware buffers.
563///
564/// On Android, a Vulkan adapter with the hardware-buffer knob on
565/// (`hwbuffer_import_requested`) is first offered to `hwbuffer_device::open`;
566/// a refusal there (a missing extension, a failed `vkCreateDevice`) is logged
567/// and falls through to the plain `request_device` every other case takes, so
568/// the knob can never turn a working adapter into no device at all.
569#[cfg_attr(not(target_os = "android"), allow(unused_variables))]
570async fn open_device(
571 adapter: &wgpu::Adapter,
572 caps: &TierCaps,
573 descriptor: &wgpu::DeviceDescriptor<'_>,
574) -> Result<(wgpu::Device, wgpu::Queue, bool)> {
575 #[cfg(target_os = "android")]
576 if caps.backend == wgpu::Backend::Vulkan && hwbuffer_import_requested() {
577 match hwbuffer_device::open(adapter, descriptor) {
578 Ok((device, queue)) => {
579 log::info!(
580 "frust-gpu: Vulkan device opened by frust-gpu with the AHardwareBuffer \
581 import extensions"
582 );
583 return Ok((device, queue, true));
584 }
585 Err(reason) => log::warn!(
586 "frust-gpu: AHardwareBuffer import unavailable ({reason}); opening the device \
587 through wgpu, native frames stay on the CPU path"
588 ),
589 }
590 }
591 let (device, queue) = adapter
592 .request_device(descriptor)
593 .await
594 .map_err(|e| anyhow!("frust-gpu: failed to create GPU device: {e}"))?;
595 Ok((device, queue, false))
596}
597
598/// `VK_ANDROID_external_memory_android_hardware_buffer`: import of an
599/// `AHardwareBuffer` as `VkDeviceMemory`.
600const EXT_ANDROID_HARDWARE_BUFFER: &CStr = c"VK_ANDROID_external_memory_android_hardware_buffer";
601/// `VK_EXT_queue_family_foreign`: the ownership transfer that makes a buffer a
602/// foreign producer wrote visible to this device.
603const EXT_QUEUE_FAMILY_FOREIGN: &CStr = c"VK_EXT_queue_family_foreign";
604/// `VK_KHR_external_memory`, core since Vulkan 1.1.
605const EXT_EXTERNAL_MEMORY: &CStr = c"VK_KHR_external_memory";
606/// `VK_KHR_dedicated_allocation`, core since Vulkan 1.1.
607const EXT_DEDICATED_ALLOCATION: &CStr = c"VK_KHR_dedicated_allocation";
608/// `VK_KHR_sampler_ycbcr_conversion`, core since Vulkan 1.1 (an NV12 buffer's
609/// sampler, not needed for the RGBA8 buffers imported today).
610const EXT_SAMPLER_YCBCR_CONVERSION: &CStr = c"VK_KHR_sampler_ycbcr_conversion";
611
612/// `VK_MAKE_API_VERSION(0, 1, 1, 0)`.
613const VULKAN_1_1: u32 = (1 << 22) | (1 << 12);
614
615/// Given the effective Vulkan API version (the lower of the instance's and the
616/// physical device's) and which device extensions the physical device reports,
617/// the extensions a hardware-buffer import device adds to the ones `wgpu`
618/// itself enables — or why such a device cannot be opened.
619///
620/// The import extension's own dependencies are "Vulkan 1.1 and
621/// `VK_EXT_queue_family_foreign`" (or the four pre-1.1 KHR extensions, which
622/// this refuses rather than juggle): so a 1.0 device, or one without either of
623/// the two required names, is refused. The three KHR extensions 1.1 promoted
624/// (external memory, dedicated allocation, sampler Y'CbCr conversion) are core
625/// there and are only *named* when the device also lists them — enabling a
626/// name the device does not list would fail `vkCreateDevice`.
627///
628/// Pure, over plain values: unit-testable on any host, like
629/// [`effective_limits`].
630#[cfg_attr(not(target_os = "android"), allow(dead_code))]
631fn hwbuffer_import_extensions(
632 api_version: u32,
633 supports: impl Fn(&CStr) -> bool,
634) -> std::result::Result<Vec<&'static CStr>, String> {
635 if api_version < VULKAN_1_1 {
636 return Err(format!(
637 "the device reports Vulkan {}.{}, and the import needs 1.1",
638 api_version >> 22,
639 (api_version >> 12) & 0x3ff
640 ));
641 }
642 let required = [EXT_ANDROID_HARDWARE_BUFFER, EXT_QUEUE_FAMILY_FOREIGN];
643 let missing: Vec<_> = required.iter().filter(|name| !supports(name)).collect();
644 if !missing.is_empty() {
645 return Err(format!("the device lacks {missing:?}"));
646 }
647 let promoted = [
648 EXT_EXTERNAL_MEMORY,
649 EXT_DEDICATED_ALLOCATION,
650 EXT_SAMPLER_YCBCR_CONVERSION,
651 ];
652 Ok(required
653 .into_iter()
654 .chain(promoted.into_iter().filter(|name| supports(name)))
655 .collect())
656}
657
658/// Whether the hardware-buffer knob is on, given its three halves: the
659/// compile-time and runtime `FRUST_ANDROID_HWBUFFER_IMPORT` environment
660/// variable, and the `debug.frust.hwbuffer_import` system property (the one
661/// an installed APK can be switched by: `adb shell setprop
662/// debug.frust.hwbuffer_import 1`). Any value but `0` (or an empty property,
663/// which is how an unset one can read back) turns it on.
664#[cfg_attr(not(target_os = "android"), allow(dead_code))]
665fn hwbuffer_import_knob(
666 compile_time: Option<&str>,
667 runtime: Option<String>,
668 property: Option<String>,
669) -> bool {
670 env_flag_enabled(compile_time, runtime)
671 || matches!(property.as_deref(), Some(v) if !v.is_empty() && v != "0")
672}
673
674/// [`hwbuffer_import_knob`] over this process's actual environment. Read at
675/// every device creation rather than cached: it is consulted once per device.
676#[cfg(target_os = "android")]
677fn hwbuffer_import_requested() -> bool {
678 hwbuffer_import_knob(
679 option_env!("FRUST_ANDROID_HWBUFFER_IMPORT"),
680 std::env::var("FRUST_ANDROID_HWBUFFER_IMPORT").ok(),
681 android_system_properties::AndroidSystemProperties::new()
682 .get("debug.frust.hwbuffer_import"),
683 )
684}
685
686/// The hand-opened Vulkan device: the sanctioned unsafe zone behind
687/// [`TierCaps::hwbuffer_import`].
688///
689/// # Invariants
690///
691/// - The device is opened from `adapter`'s own hal adapter with the very
692/// features, limits and memory hints of the `wgpu::DeviceDescriptor` it is
693/// then wrapped with (`create_device_from_hal`), and those were derived from
694/// that adapter's own reports (`device_features`, `base_device_limits`) — so
695/// the descriptor's features are a subset of the hal device's, as the wrap
696/// requires.
697/// - The open callback only *adds* extensions, and only ones
698/// [`hwbuffer_import_extensions`] confirmed the physical device lists; it
699/// never touches features, queues or the `pNext` chain, so it changes nothing
700/// the device does not support (`open_with_callback`'s contract).
701/// - The hal adapter guard is dropped before the wrap, and nothing here keeps a
702/// raw handle past this function: `wgpu` owns the raw device from then on and
703/// destroys it with the `wgpu::Device`.
704#[cfg(target_os = "android")]
705mod hwbuffer_device {
706 use wgpu::hal::api::Vulkan;
707 use wgpu::hal::vulkan::{CreateDeviceCallback, CreateDeviceCallbackArgs};
708
709 /// Opens `descriptor` on `adapter` with the hardware-buffer import
710 /// extensions enabled, or answers why it could not.
711 pub(super) fn open(
712 adapter: &wgpu::Adapter,
713 descriptor: &wgpu::DeviceDescriptor<'_>,
714 ) -> Result<(wgpu::Device, wgpu::Queue), String> {
715 let open_device = {
716 // SAFETY: the guard only borrows the adapter's hal object for this
717 // block; nothing here destroys it (see the module invariants).
718 let hal_adapter = unsafe { adapter.as_hal::<Vulkan>() }
719 .ok_or_else(|| "the adapter is not a Vulkan adapter".to_string())?;
720 let capabilities = hal_adapter.physical_device_capabilities();
721 let api_version = capabilities
722 .properties()
723 .api_version
724 .min(hal_adapter.shared_instance().instance_api_version());
725 let extra = super::hwbuffer_import_extensions(api_version, |name| {
726 capabilities.supports_extension(name)
727 })?;
728 let callback: Box<CreateDeviceCallback<'_>> =
729 Box::new(move |args: CreateDeviceCallbackArgs<'_, '_, '_>| {
730 for name in extra {
731 if !args.extensions.contains(&name) {
732 args.extensions.push(name);
733 }
734 }
735 });
736 // SAFETY: features and limits are the adapter's own (the module
737 // invariants), and the callback only adds extensions the physical
738 // device lists.
739 unsafe {
740 hal_adapter.open_with_callback(
741 descriptor.required_features,
742 &descriptor.required_limits,
743 &descriptor.memory_hints,
744 Some(callback),
745 )
746 }
747 .map_err(|e| format!("vkCreateDevice with the import extensions failed: {e}"))?
748 };
749 // SAFETY: `open_device` was opened from this adapter's hal adapter with
750 // exactly `descriptor.required_features`, so they are a subset of the
751 // hal device's features.
752 unsafe { adapter.create_device_from_hal::<Vulkan>(open_device, descriptor) }
753 .map_err(|e| format!("wrapping the hand-opened device failed: {e}"))
754 }
755}
756
757/// Given the resolved downlevel profile and the adapter's own reported
758/// limits, the `wgpu::Limits` a device request should ask for — the pure half
759/// of the derivation this module's `create_device` uses, extracted so both it
760/// and [`test_device_limits`] share exactly one implementation rather than two
761/// that could drift apart.
762///
763/// Under `DownlevelProfile::WebGl2` (a real `Gl` backend, or
764/// `FRUST_ENGINE_DOWNLEVEL=1` rehearsing it) this asks for the GLES-3.0/WebGL2
765/// downlevel default shape — or the override would only relabel a full
766/// desktop device rather than actually exercising it — with
767/// `using_resolution` folding in `adapter_limits`' own texture-dimension
768/// limits so the request never asks for a resolution the adapter cannot
769/// satisfy (the swapchain may need more than the downlevel default allows)
770/// while every other WebGL2 default limit is requested as-is. Under
771/// `DownlevelProfile::Full` — every shipping device — `adapter_limits` is
772/// returned unchanged: feeding the *adapter's* limits (rather than
773/// `Limits::default()`) is what keeps the request from over-asking and
774/// failing on a constrained mobile adapter — or on the iOS Simulator, whose
775/// macOS-Metal-backed device refuses `Limits::default()` outright.
776///
777/// Pure decision logic over plain values, mirroring [`effective_limits`]'s
778/// split of pure decision vs. platform lookup: `adapter_limits` is already
779/// `wgpu::Adapter::limits()`'s output by the time this runs, so the function
780/// itself needs no adapter and is unit-testable on any host with no GPU.
781fn base_device_limits(profile: DownlevelProfile, adapter_limits: wgpu::Limits) -> wgpu::Limits {
782 if profile == DownlevelProfile::WebGl2 {
783 wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits)
784 } else {
785 adapter_limits
786 }
787}
788
789/// The `wgpu::Limits` a test fixture's own `request_device` call should ask
790/// for, given `adapter` and its already-probed `caps` — exactly the
791/// derivation [`create_device`] uses for the production device request
792/// (shared via [`base_device_limits`] plus [`effective_limits`]), so a
793/// fixture requesting these limits can never over-ask relative to what the
794/// production path would request for the very same adapter.
795///
796/// This is the fix for the iOS Simulator's constrained Apple2 Metal profile
797/// (15 inter-stage shader variables, where `wgpu::Limits::default()` demands
798/// 16): a fixture that hard-codes `Limits::default()` panics with
799/// `LimitsExceeded` there even though the production path — which always
800/// requests the adapter's own limits — never would. Every fixture already
801/// probes `TierCaps::probe(&adapter)` before requesting its device, so `caps`
802/// is available at the same call site this replaces.
803///
804/// Public (not test-only/`#[cfg(test)]`) because the fixtures that need it
805/// live in other crates' `tests/` integration binaries and
806/// `frust-testing`'s own `EngineOracle`, none of which can reach a
807/// `#[cfg(test)]` item in this crate.
808pub fn test_device_limits(adapter: &wgpu::Adapter, caps: &TierCaps) -> wgpu::Limits {
809 let base_limits = base_device_limits(caps.downlevel_profile, adapter.limits());
810 effective_limits(base_limits, is_ios_simulator())
811}
812
813/// Given the build-config-derived instance flags and whether the process is
814/// currently running on an Android emulator, decides the flags wgpu's
815/// `Instance` should actually be created with.
816///
817/// The `DEBUG` flag makes wgpu enable `VK_EXT_debug_utils` and set object-name
818/// labels via `vkSetDebugUtilsObjectNameEXT`, and the emulator's gfxstream
819/// Vulkan HAL (`vulkan.ranchu.so`) segfaults inside that entry point during
820/// adapter enumeration (observed crash: `#00 vulkan.ranchu.so
821/// vk_common_SetDebugUtilsObjectNameEXT`) — the same class of debug-utils
822/// fragility a MoltenVK Vulkan backend is also known to have. Debug object
823/// labels are only a developer convenience, so dropping them on the emulator
824/// keeps GPU bring-up alive there while leaving physical devices' validation
825/// safety net — and desktop behaviour — untouched.
826///
827/// Pure decision logic, kept separate from the platform property lookup in
828/// `is_android_emulator` so it is unit-testable on any host without an Android
829/// target.
830#[cfg_attr(not(target_os = "android"), allow(dead_code))]
831fn effective_instance_flags(flags: wgpu::InstanceFlags, is_emulator: bool) -> wgpu::InstanceFlags {
832 if is_emulator {
833 flags - (wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION)
834 } else {
835 flags
836 }
837}
838
839/// Detects whether the current process is running on an Android emulator
840/// (goldfish/ranchu), as opposed to a physical device, via the standard
841/// `ro.kernel.qemu` system property (`"1"` on emulators, unset/absent on real
842/// hardware). A failed property read is treated as "not an emulator" so
843/// physical devices — and any environment where the property cannot be read —
844/// default to keeping validation on.
845#[cfg(target_os = "android")]
846fn is_android_emulator() -> bool {
847 android_system_properties::AndroidSystemProperties::new()
848 .get("ro.kernel.qemu")
849 .as_deref()
850 == Some("1")
851}
852
853/// The uniform-buffer offset alignment the iOS Simulator's Metal validation
854/// actually enforces, regardless of what the adapter reports.
855const IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT: u32 = 256;
856
857/// Given a base `wgpu::Limits` and whether the process is currently running on
858/// an iOS Simulator, decides the `Limits` a device request should actually use.
859///
860/// Mitigates [wgpu #7057](https://github.com/gfx-rs/wgpu/issues/7057): the iOS
861/// Simulator is macOS-Metal-backed and requires 256-byte
862/// `min_uniform_buffer_offset_alignment`, but wgpu's Metal backend reports the
863/// (lower) iOS-device value, which trips Metal API validation on the simulator.
864/// Physical iOS devices are unaffected and pass `base` through unchanged; a
865/// `base` whose alignment is already at or above 256 is left alone, never
866/// lowered.
867///
868/// Upstream [gfx-rs/wgpu PR #10189](https://github.com/gfx-rs/wgpu/pull/10189)
869/// makes this unnecessary — drop it once a pinned wgpu release contains it.
870///
871/// Pure decision logic, mirroring `effective_instance_flags`'s split of pure
872/// decision vs. platform lookup. It is fed the profile-resolved base limits
873/// (see this module's `create_device`) — the adapter's own limits under
874/// `DownlevelProfile::Full`, the WebGL2 downlevel defaults resolution-folded
875/// with the adapter otherwise — so the device request never over-asks.
876pub fn effective_limits(base: wgpu::Limits, is_ios_simulator: bool) -> wgpu::Limits {
877 if is_ios_simulator
878 && base.min_uniform_buffer_offset_alignment
879 < IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT
880 {
881 wgpu::Limits {
882 min_uniform_buffer_offset_alignment: IOS_SIMULATOR_MIN_UNIFORM_BUFFER_OFFSET_ALIGNMENT,
883 ..base
884 }
885 } else {
886 base
887 }
888}
889
890/// Whether this binary is running on the iOS Simulator
891/// (`aarch64-apple-ios-sim` / `x86_64-apple-ios` under the simulator), which
892/// sets `target_abi = "sim"`. Compile-time constant: the simulator mitigation
893/// only needs to apply to simulator builds, never physical-device or desktop
894/// ones.
895pub const fn is_ios_simulator() -> bool {
896 cfg!(all(target_os = "ios", target_abi = "sim"))
897}
898
899/// The `wgpu::Features` a device request opportunistically asks for when the
900/// adapter exposes them.
901///
902/// `PIPELINE_CACHE` alone: it is what
903/// [`create_pipeline_cache`](RenderContext::create_pipeline_cache) needs to
904/// seed the renderer's shader-pipeline compilation from a persisted blob. An
905/// optional feature is only ever *added* when the adapter already offers it,
906/// so this can never turn a working adapter into a failed device request.
907pub fn optional_device_features() -> wgpu::Features {
908 wgpu::Features::PIPELINE_CACHE
909}
910
911/// The `wgpu::Features` a device request must genuinely *require*, given what
912/// the adapter reported ([`TierCaps`]) and whether this build compiled the
913/// `perf-trace` feature in.
914///
915/// The policy is deliberately minimal: **empty** by default. A required feature
916/// is a hard device-creation failure on any adapter lacking it, so asking for
917/// something the crate does not actually need converts a working device into no
918/// device at all. `TIMESTAMP_QUERY` is the single exception — it is what the
919/// GPU timing probes ([`crate::diag::TimestampRing`]) are built on, so a
920/// `perf-trace` build asks for it, and even then only when the adapter offers
921/// it. A device that did not get the feature leaves the ring inert (`gpu_q=0`),
922/// exactly as a build without `perf-trace` does.
923///
924/// A plain `bool` parameter rather than reading `cfg!` internally, so both
925/// branches are unit-testable regardless of which features this crate was
926/// compiled with.
927fn required_features(caps: &TierCaps, perf_trace: bool) -> wgpu::Features {
928 if perf_trace && caps.has_timestamp_query {
929 wgpu::Features::TIMESTAMP_QUERY
930 } else {
931 wgpu::Features::empty()
932 }
933}
934
935/// The complete `required_features` set a device request is made with: the
936/// opportunistic set ([`optional_device_features`]) narrowed to what
937/// `adapter_features` actually offers, plus the genuinely required set
938/// ([`required_features`]).
939///
940/// Both halves are adapter-conditioned, so this can never turn a working
941/// adapter into a failed device request — which is why the policy is split out
942/// of [`create_device`] as a pure function over plain values.
943fn device_features(
944 adapter_features: wgpu::Features,
945 caps: &TierCaps,
946 perf_trace: bool,
947) -> wgpu::Features {
948 (adapter_features & optional_device_features()) | required_features(caps, perf_trace)
949}
950
951/// Number of uncaptured `wgpu` errors logged at error level per device before
952/// the handler latches into suppression. A single flaky frame under a driver
953/// hiccup (e.g. the Android emulator's SwiftShader path) is expected to surface
954/// a handful of errors; past this the process is either wedged in a genuine
955/// per-frame error storm or the driver is fundamentally broken, and re-logging
956/// every single one would flood the log without adding information.
957const MAX_LOGGED_UNCAPTURED_ERRORS: u32 = 5;
958
959/// How often (in error count) a latched handler bumps a debug-level "still
960/// happening" line once past [`MAX_LOGGED_UNCAPTURED_ERRORS`] and the one
961/// suppression notice. Debug level (not error) because this is diagnostic noise
962/// for someone actively investigating, not an actionable signal.
963const UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD: u32 = 100;
964
965/// What the uncaptured-error handler should do for the `count`-th uncaptured
966/// error (1-indexed) it has observed on a given device.
967///
968/// Also the latch the renderer above reuses for its own per-frame event that
969/// can reproduce every vsync (an engine frame refusal), so the two report at
970/// the same cadence.
971#[derive(Clone, Copy, Debug, PartialEq, Eq)]
972pub enum LogAction {
973 /// One of the first `MAX_LOGGED_UNCAPTURED_ERRORS`: log the error itself
974 /// at error level.
975 Log,
976 /// The first error past the cap: log one suppression notice (naming the
977 /// running total) instead of the error itself.
978 SuppressionNotice,
979 /// Past the cap and past the suppression notice: stay silent, except a
980 /// periodic debug-level count bump when `debug_bump` is set.
981 Silent { debug_bump: bool },
982}
983
984/// Pure latch policy for the uncaptured-error handler (see [`LogAction`]).
985///
986/// Split out of the handler closure in this module's `create_device` so the
987/// discipline — log the first few, announce the latch once, then go quiet
988/// except an occasional debug bump — is unit-testable without a GPU or a real
989/// `wgpu::Error`.
990pub fn decide_log_action(count: u32) -> LogAction {
991 if count <= MAX_LOGGED_UNCAPTURED_ERRORS {
992 LogAction::Log
993 } else if count == MAX_LOGGED_UNCAPTURED_ERRORS + 1 {
994 LogAction::SuppressionNotice
995 } else {
996 LogAction::Silent {
997 debug_bump: count.is_multiple_of(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
998 }
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use crate::caps::DownlevelProfile;
1006
1007 #[test]
1008 fn emulator_strips_debug_and_validation() {
1009 let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
1010 let flags = effective_instance_flags(build_flags, true);
1011 assert!(!flags.contains(wgpu::InstanceFlags::DEBUG));
1012 assert!(!flags.contains(wgpu::InstanceFlags::VALIDATION));
1013 }
1014
1015 #[test]
1016 fn physical_device_keeps_debug_and_validation() {
1017 let build_flags = wgpu::InstanceFlags::DEBUG | wgpu::InstanceFlags::VALIDATION;
1018 let flags = effective_instance_flags(build_flags, false);
1019 assert!(flags.contains(wgpu::InstanceFlags::DEBUG));
1020 assert!(flags.contains(wgpu::InstanceFlags::VALIDATION));
1021 }
1022
1023 #[test]
1024 fn emulator_with_no_debug_flags_stays_empty() {
1025 let flags = effective_instance_flags(wgpu::InstanceFlags::empty(), true);
1026 assert!(flags.is_empty());
1027 }
1028
1029 #[test]
1030 fn ios_simulator_bumps_alignment_to_256() {
1031 let base = wgpu::Limits::default();
1032 let limits = effective_limits(base.clone(), true);
1033 assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
1034 // Nothing else about the base limits should change.
1035 assert_eq!(
1036 wgpu::Limits {
1037 min_uniform_buffer_offset_alignment: base.min_uniform_buffer_offset_alignment,
1038 ..limits.clone()
1039 },
1040 base
1041 );
1042 }
1043
1044 #[test]
1045 fn non_simulator_leaves_limits_untouched() {
1046 let base = wgpu::Limits::default();
1047 let limits = effective_limits(base.clone(), false);
1048 assert_eq!(limits, base);
1049 }
1050
1051 #[test]
1052 fn base_already_at_or_above_256_is_not_lowered() {
1053 let base = wgpu::Limits {
1054 min_uniform_buffer_offset_alignment: 512,
1055 ..wgpu::Limits::default()
1056 };
1057 let limits = effective_limits(base.clone(), true);
1058 assert_eq!(limits.min_uniform_buffer_offset_alignment, 512);
1059 assert_eq!(limits, base);
1060 }
1061
1062 #[test]
1063 fn simulator_alignment_uses_real_adapter_limits_not_defaults() {
1064 // A low-alignment adapter (the #7057 shape: Metal reports a lower
1065 // alignment than the simulator driver actually enforces) is bumped to
1066 // 256 while every other adapter-reported limit is preserved — the
1067 // whole point of feeding *adapter* limits rather than `Limits::default`.
1068 let adapter = wgpu::Limits {
1069 min_uniform_buffer_offset_alignment: 64,
1070 max_texture_dimension_2d: 4096,
1071 ..wgpu::Limits::default()
1072 };
1073 let limits = effective_limits(adapter.clone(), true);
1074 assert_eq!(limits.min_uniform_buffer_offset_alignment, 256);
1075 assert_eq!(limits.max_texture_dimension_2d, 4096);
1076 }
1077
1078 #[test]
1079 fn first_n_uncaptured_errors_log() {
1080 for count in 1..=MAX_LOGGED_UNCAPTURED_ERRORS {
1081 assert_eq!(
1082 decide_log_action(count),
1083 LogAction::Log,
1084 "expected Log at count={count}"
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn nplus1_uncaptured_error_suppresses() {
1091 assert_eq!(
1092 decide_log_action(MAX_LOGGED_UNCAPTURED_ERRORS + 1),
1093 LogAction::SuppressionNotice
1094 );
1095 }
1096
1097 #[test]
1098 fn further_uncaptured_errors_stay_silent_between_debug_bumps() {
1099 let past_notice = MAX_LOGGED_UNCAPTURED_ERRORS + 2;
1100 assert_eq!(
1101 decide_log_action(past_notice),
1102 LogAction::Silent { debug_bump: false }
1103 );
1104 }
1105
1106 #[test]
1107 fn uncaptured_error_debug_bump_is_periodic() {
1108 assert_eq!(
1109 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD),
1110 LogAction::Silent { debug_bump: true }
1111 );
1112 assert_eq!(
1113 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD * 2),
1114 LogAction::Silent { debug_bump: true }
1115 );
1116 assert_eq!(
1117 decide_log_action(UNCAPTURED_ERROR_DEBUG_BUMP_PERIOD + 1),
1118 LogAction::Silent { debug_bump: false }
1119 );
1120 }
1121
1122 #[test]
1123 fn default_build_requires_no_device_features() {
1124 let caps = TierCaps::fake(DownlevelProfile::Full);
1125 assert!(caps.has_timestamp_query, "fixture precondition");
1126 assert_eq!(required_features(&caps, false), wgpu::Features::empty());
1127 }
1128
1129 #[test]
1130 fn perf_trace_build_requires_timestamp_query_when_offered() {
1131 let caps = TierCaps::fake(DownlevelProfile::Full);
1132 assert_eq!(
1133 required_features(&caps, true),
1134 wgpu::Features::TIMESTAMP_QUERY
1135 );
1136 }
1137
1138 #[test]
1139 fn perf_trace_build_requires_nothing_when_adapter_lacks_timestamp_query() {
1140 // An adapter that cannot do timestamp queries must still yield a
1141 // device: a required feature it lacks would fail the request outright,
1142 // so `perf-trace` degrades to no probes rather than to no GPU.
1143 let caps = TierCaps::fake(DownlevelProfile::WebGl2);
1144 assert!(!caps.has_timestamp_query, "fixture precondition");
1145 assert_eq!(required_features(&caps, true), wgpu::Features::empty());
1146 }
1147
1148 #[test]
1149 fn pipeline_cache_is_requested_whenever_the_adapter_offers_it() {
1150 // The shipped Android/Vulkan warm-start path: `PIPELINE_CACHE` is
1151 // opportunistically added, which is what makes
1152 // `RenderContext::pipeline_cache_supported` true there and the
1153 // persisted-blob seed possible at all. Dropping it would silently kill
1154 // the warm start on every Vulkan adapter.
1155 let caps = TierCaps::fake(DownlevelProfile::Full);
1156 let adapter = wgpu::Features::PIPELINE_CACHE | wgpu::Features::DEPTH_CLIP_CONTROL;
1157 assert_eq!(
1158 device_features(adapter, &caps, false),
1159 wgpu::Features::PIPELINE_CACHE,
1160 "an offered optional feature is taken, and nothing else is"
1161 );
1162 }
1163
1164 #[test]
1165 fn an_adapter_without_pipeline_cache_is_never_asked_for_it() {
1166 // Metal/DX12: the feature is absent, so the request must not name it —
1167 // a required feature the adapter lacks is a hard device-creation
1168 // failure, i.e. no GPU at all rather than merely no persisted cache.
1169 let caps = TierCaps::fake(DownlevelProfile::Full);
1170 assert_eq!(
1171 device_features(wgpu::Features::empty(), &caps, false),
1172 wgpu::Features::empty()
1173 );
1174 }
1175
1176 #[test]
1177 fn a_perf_trace_build_asks_for_both_halves_when_both_are_offered() {
1178 let caps = TierCaps::fake(DownlevelProfile::Full);
1179 assert!(caps.has_timestamp_query, "fixture precondition");
1180 assert_eq!(
1181 device_features(wgpu::Features::PIPELINE_CACHE, &caps, true),
1182 wgpu::Features::PIPELINE_CACHE | wgpu::Features::TIMESTAMP_QUERY
1183 );
1184 }
1185
1186 #[test]
1187 fn base_device_limits_full_profile_passes_adapter_limits_through_unclamped() {
1188 // The iOS Simulator shape: an adapter reporting fewer inter-stage
1189 // shader variables than `wgpu::Limits::default()` demands (15 vs 16).
1190 // Under `Full`, `base_device_limits` must request no more than what
1191 // the adapter itself reported, never `Limits::default()`.
1192 let adapter_limits = wgpu::Limits {
1193 max_inter_stage_shader_variables: 15,
1194 ..wgpu::Limits::default()
1195 };
1196 let limits = base_device_limits(DownlevelProfile::Full, adapter_limits.clone());
1197 assert!(limits.max_inter_stage_shader_variables <= 15);
1198 assert_eq!(limits, adapter_limits);
1199 }
1200
1201 #[test]
1202 fn base_device_limits_webgl2_profile_never_exceeds_the_constrained_adapter() {
1203 // The WebGL2 downlevel-default shape already asks for 15 inter-stage
1204 // shader variables (lower than `Limits::default()`'s 16), so folding
1205 // in a constrained adapter's own limits must still land at or below
1206 // what that adapter reports.
1207 let adapter_limits = wgpu::Limits {
1208 max_inter_stage_shader_variables: 15,
1209 ..wgpu::Limits::default()
1210 };
1211 let limits = base_device_limits(DownlevelProfile::WebGl2, adapter_limits);
1212 assert!(limits.max_inter_stage_shader_variables <= 15);
1213 }
1214
1215 /// A `supports` predicate over a fixed list of extension names.
1216 fn listing(names: &'static [&'static CStr]) -> impl Fn(&CStr) -> bool {
1217 move |name| names.contains(&name)
1218 }
1219
1220 #[test]
1221 fn a_vulkan_1_1_device_with_both_required_extensions_gets_them() {
1222 let extensions = hwbuffer_import_extensions(
1223 VULKAN_1_1,
1224 listing(&[EXT_ANDROID_HARDWARE_BUFFER, EXT_QUEUE_FAMILY_FOREIGN]),
1225 )
1226 .expect("both required extensions are listed");
1227 assert_eq!(
1228 extensions,
1229 [EXT_ANDROID_HARDWARE_BUFFER, EXT_QUEUE_FAMILY_FOREIGN],
1230 "the promoted names are not enabled when the device does not list them"
1231 );
1232 }
1233
1234 #[test]
1235 fn listed_promoted_extensions_are_named_too() {
1236 let extensions = hwbuffer_import_extensions(
1237 VULKAN_1_1 + 5,
1238 listing(&[
1239 EXT_ANDROID_HARDWARE_BUFFER,
1240 EXT_QUEUE_FAMILY_FOREIGN,
1241 EXT_EXTERNAL_MEMORY,
1242 EXT_DEDICATED_ALLOCATION,
1243 EXT_SAMPLER_YCBCR_CONVERSION,
1244 ]),
1245 )
1246 .expect("every extension is listed");
1247 assert_eq!(extensions.len(), 5);
1248 }
1249
1250 #[test]
1251 fn a_device_without_the_import_extension_is_refused() {
1252 let refusal = hwbuffer_import_extensions(VULKAN_1_1, listing(&[EXT_QUEUE_FAMILY_FOREIGN]))
1253 .expect_err("the import extension is missing");
1254 assert!(refusal.contains("VK_ANDROID_external_memory_android_hardware_buffer"));
1255 }
1256
1257 #[test]
1258 fn a_device_without_the_foreign_queue_family_is_refused() {
1259 let refusal =
1260 hwbuffer_import_extensions(VULKAN_1_1, listing(&[EXT_ANDROID_HARDWARE_BUFFER]))
1261 .expect_err("the foreign queue family extension is missing");
1262 assert!(refusal.contains("VK_EXT_queue_family_foreign"));
1263 }
1264
1265 #[test]
1266 fn a_vulkan_1_0_device_is_refused_even_with_every_extension() {
1267 let refusal = hwbuffer_import_extensions(
1268 1 << 22,
1269 listing(&[EXT_ANDROID_HARDWARE_BUFFER, EXT_QUEUE_FAMILY_FOREIGN]),
1270 )
1271 .expect_err("1.0 is refused");
1272 assert!(refusal.contains("1.0"), "{refusal}");
1273 }
1274
1275 #[test]
1276 fn the_hardware_buffer_knob_is_off_unless_one_half_turns_it_on() {
1277 assert!(!hwbuffer_import_knob(None, None, None));
1278 assert!(!hwbuffer_import_knob(None, None, Some(String::new())));
1279 assert!(!hwbuffer_import_knob(
1280 Some("0"),
1281 Some("0".to_string()),
1282 Some("0".to_string())
1283 ));
1284 assert!(hwbuffer_import_knob(Some("1"), None, None));
1285 assert!(hwbuffer_import_knob(None, Some("1".to_string()), None));
1286 assert!(hwbuffer_import_knob(None, None, Some("1".to_string())));
1287 }
1288
1289 #[test]
1290 fn default_options_label_the_device_and_leave_backends_to_the_environment() {
1291 let options = ContextOptions::default();
1292 assert_eq!(options.device_label, DEFAULT_DEVICE_LABEL);
1293 assert_eq!(options.backends, None);
1294 }
1295
1296 #[test]
1297 fn a_fresh_context_has_no_device_and_therefore_no_caps() {
1298 // Construction must not enumerate adapters, so this is a host test, not
1299 // a GPU one: it passes on a machine with no usable GPU at all.
1300 let context = RenderContext::new();
1301 assert!(context.caps().is_none());
1302 }
1303
1304 #[test]
1305 #[ignore = "needs a real GPU adapter; run with `cargo test -p frust-gpu -- --ignored` \
1306 (pin the adapter on a multi-GPU host with WGPU_BACKEND / WGPU_ADAPTER_NAME)"]
1307 fn gpu_device_creation_reports_adapter_caps() {
1308 pollster::block_on(async {
1309 let mut context = RenderContext::new();
1310 let handle = context.device().await.expect("device creation");
1311
1312 let info = handle.adapter.get_info();
1313 println!(
1314 "frust-gpu adapter: name={:?} backend={:?} device_type={:?} \
1315 driver={:?} driver_info={:?} vendor={:#06x} device={:#06x}",
1316 info.name,
1317 info.backend,
1318 info.device_type,
1319 info.driver,
1320 info.driver_info,
1321 info.vendor,
1322 info.device
1323 );
1324 println!("frust-gpu caps: {:#?}", handle.caps);
1325 println!(
1326 "frust-gpu device limits: max_texture_dimension_2d={} \
1327 min_uniform_buffer_offset_alignment={}",
1328 handle.device.limits().max_texture_dimension_2d,
1329 handle.device.limits().min_uniform_buffer_offset_alignment
1330 );
1331
1332 assert_eq!(handle.caps.adapter_name, info.name);
1333 assert_eq!(handle.caps.backend, info.backend);
1334 // Only an Android device frust-gpu opened itself can import a
1335 // hardware buffer; every host device comes from `request_device`.
1336 if !cfg!(target_os = "android") {
1337 assert!(!handle.caps.hwbuffer_import);
1338 }
1339 assert!(!handle.caps.adapter_name.is_empty());
1340 assert!(handle.caps.max_texture_dimension_2d > 0);
1341 assert!(handle.caps.resource_texture_dim > 0);
1342 // Nothing has been submitted, so the latch must still be empty.
1343 assert_eq!(handle.first_uncaptured_error(), None);
1344
1345 // With `FRUST_ENGINE_DOWNLEVEL=1` set for the whole test process
1346 // (the override is read once and cached in a `OnceLock`), both the
1347 // probed caps and the created device must actually report the
1348 // clamped WebGL2 shape rather than a desktop backend merely
1349 // relabelled `WebGl2`. Without it, this rig's real backend
1350 // (Vulkan/Metal/Dx12) must report `Full` unchanged, proving the
1351 // knob rehearses the downlevel shape rather than always forcing
1352 // it.
1353 let downlevel_env_set = std::env::var("FRUST_ENGINE_DOWNLEVEL").is_ok_and(|v| v != "0");
1354 if downlevel_env_set {
1355 assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::WebGl2);
1356 assert!(!handle.caps.has_storage_buffers);
1357 assert!(handle.caps.max_texture_dimension_2d <= 2048);
1358 assert_eq!(handle.caps.min_uniform_buffer_offset_alignment, 256);
1359 assert!(handle.device.limits().max_texture_dimension_2d > 0);
1360 } else {
1361 assert_eq!(handle.caps.downlevel_profile, DownlevelProfile::Full);
1362 }
1363
1364 let caps = context.caps().cloned().expect("caps after device creation");
1365 assert_eq!(caps.adapter_name, info.name);
1366
1367 // The device is created once and reused: a second call must hand
1368 // back the same logical device, not build another one.
1369 let again = context.device().await.expect("device reuse");
1370 assert_eq!(again.caps, caps);
1371 });
1372 }
1373}