frust_gpu/caps.rs
1//! Adapter capability probing: [`TierCaps`] and [`DownlevelProfile`].
2//!
3//! [`TierCaps`] is plain data pulled off a real `wgpu::Adapter` at device-init
4//! time ([`TierCaps::probe`]), so downstream tier/pipeline decisions are
5//! unit-testable against [`TierCaps::fake`] with no GPU in the loop — the same
6//! pure-decision/platform-lookup split `frust-render`'s tier selection follows.
7//!
8//! [`DownlevelProfile`] tells a caller whether the adapter's limits are the
9//! full desktop set or clamped to the GLES-3.0/WebGL2 downlevel defaults,
10//! which a browser target (a future host) will always hit and which
11//! `FRUST_ENGINE_DOWNLEVEL=1` lets a desktop developer rehearse today.
12
13use std::sync::OnceLock;
14
15/// The GLES-3.0/WebGL2 downlevel default resource-texture ceiling
16/// (`wgpu::Limits::downlevel_webgl2_defaults().max_texture_dimension_2d`).
17const WEBGL2_MAX_TEXTURE_DIMENSION_2D: u32 = 2048;
18
19/// The desktop (Vulkan/Metal/Dx12) default resource-texture ceiling
20/// (`wgpu::Limits::defaults().max_texture_dimension_2d`).
21const DESKTOP_MAX_TEXTURE_DIMENSION_2D: u32 = 8192;
22
23/// The ceiling [`TierCaps::resource_texture_dim`] never exceeds regardless of
24/// what the adapter itself reports, keeping any texture-pool/atlas sizing
25/// decision built on it bounded even on a very generous desktop adapter.
26const MAX_RESOURCE_TEXTURE_DIM: u32 = 4096;
27
28/// The texture format a texture atlas is backed by. `Rgba8Unorm` is the one
29/// target format used everywhere else a `wgpu::TextureFormat` is chosen in
30/// this workspace's GPU backend.
31const ATLAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
32
33/// Whether an adapter's usable limits are the full native set, or clamped to
34/// the GLES-3.0/WebGL2 downlevel defaults (`wgpu::Limits::downlevel_webgl2_defaults`).
35///
36/// Derived, never chosen directly by a caller building [`TierCaps::probe`]:
37/// `WebGl2` whenever the adapter's backend is [`wgpu::Backend::Gl`], or
38/// whenever the process-wide `FRUST_ENGINE_DOWNLEVEL=1` override is set — the
39/// latter lets a desktop (Vulkan/Metal/Dx12) adapter rehearse the browser's
40/// downlevel ceiling without an actual GL/WebGL2 context.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum DownlevelProfile {
43 /// Native desktop/mobile limits (Vulkan, Metal, Dx12).
44 Full,
45 /// Clamped to the GLES-3.0/WebGL2 downlevel default limits.
46 WebGl2,
47}
48
49impl DownlevelProfile {
50 fn resolve(backend: wgpu::Backend) -> Self {
51 if backend == wgpu::Backend::Gl || downlevel_override_enabled() {
52 DownlevelProfile::WebGl2
53 } else {
54 DownlevelProfile::Full
55 }
56 }
57}
58
59/// Whether the `FRUST_ENGINE_DOWNLEVEL` process-wide override is set to a
60/// non-zero value, checking both the compile-time (`option_env!`) and runtime
61/// (`std::env::var`) halves like `frust-render`'s `FRUST_TRACE` knob. Cached:
62/// read once per process.
63fn downlevel_override_enabled() -> bool {
64 static ENABLED: OnceLock<bool> = OnceLock::new();
65 *ENABLED.get_or_init(|| {
66 env_flag_enabled(
67 option_env!("FRUST_ENGINE_DOWNLEVEL"),
68 std::env::var("FRUST_ENGINE_DOWNLEVEL").ok(),
69 )
70 })
71}
72
73/// Whether a process-wide flag knob is on: set to anything but `"0"` in either
74/// its compile-time (`option_env!`) or its runtime (`std::env::var`) half.
75/// Shared by every knob in this crate so they all parse alike.
76pub(crate) fn env_flag_enabled(compile_time: Option<&str>, runtime: Option<String>) -> bool {
77 fn is_set_non_zero(value: Option<&str>) -> bool {
78 matches!(value, Some(v) if v != "0")
79 }
80 is_set_non_zero(compile_time) || is_set_non_zero(runtime.as_deref())
81}
82
83/// Plain-data adapter capabilities the GPU crate's device/pipeline/atlas
84/// decisions are built over. Constructed from a real `wgpu::Adapter` via
85/// [`TierCaps::probe`], or by hand in host tests via [`TierCaps::fake`].
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct TierCaps {
88 /// The adapter's `wgpu::DownlevelCapabilities::flags`.
89 pub downlevel_flags: wgpu::DownlevelFlags,
90 /// The adapter's `wgpu::AdapterInfo::name`, used only for diagnostics.
91 pub adapter_name: String,
92 /// The adapter's `wgpu::AdapterInfo::backend`.
93 pub backend: wgpu::Backend,
94 /// The adapter's `wgpu::Limits::max_texture_dimension_2d`, clamped down to
95 /// `wgpu::Limits::downlevel_webgl2_defaults().max_texture_dimension_2d`
96 /// when [`Self::downlevel_profile`] is [`DownlevelProfile::WebGl2`].
97 pub max_texture_dimension_2d: u32,
98 /// The adapter's `wgpu::Limits::max_texture_array_layers`, clamped the
99 /// same way as [`Self::max_texture_dimension_2d`].
100 pub max_texture_array_layers: u32,
101 /// The adapter's `wgpu::Limits::max_bind_groups`, clamped the same way as
102 /// [`Self::max_texture_dimension_2d`].
103 pub max_bind_groups: u32,
104 /// The adapter's `wgpu::Limits::max_uniform_buffer_binding_size`,
105 /// saturated into a `u32` (the field is `u64` on `wgpu::Limits`; no
106 /// adapter this workspace targets reports a value anywhere near
107 /// `u32::MAX`) and clamped the same way as
108 /// [`Self::max_texture_dimension_2d`].
109 pub max_uniform_buffer_binding_size: u32,
110 /// The adapter's `wgpu::Limits::min_uniform_buffer_offset_alignment`,
111 /// raised to
112 /// `wgpu::Limits::downlevel_webgl2_defaults().min_uniform_buffer_offset_alignment`
113 /// (256) when [`Self::downlevel_profile`] is [`DownlevelProfile::WebGl2`]
114 /// and the adapter reports a looser (smaller) value — an alignment
115 /// requirement is a floor, so it is only ever raised, never lowered.
116 pub min_uniform_buffer_offset_alignment: u32,
117 /// The adapter's `wgpu::Limits::max_vertex_attributes`, clamped the same
118 /// way as [`Self::max_texture_dimension_2d`].
119 pub max_vertex_attributes: u32,
120 /// Whether the adapter supports storage buffers at all
121 /// (`wgpu::Limits::max_storage_buffers_per_shader_stage > 0`) — forced
122 /// `false` whenever [`Self::downlevel_profile`] is
123 /// [`DownlevelProfile::WebGl2`], since the GLES-3.0/WebGL2 downlevel
124 /// default limits report zero storage buffers per shader stage.
125 pub has_storage_buffers: bool,
126 /// Whether the adapter exposes `wgpu::Features::TIMESTAMP_QUERY`.
127 pub has_timestamp_query: bool,
128 /// The adapter's `wgpu::AdapterInfo::transient_saves_memory` — whether
129 /// adding `wgpu::TextureUsages::TRANSIENT_ATTACHMENT` to a texture (which
130 /// itself requires `wgpu::StoreOp::Discard`) reduces memory usage on this
131 /// adapter.
132 ///
133 /// `wgpu` reports this as `Option<bool>`, where `None` means "the adapter
134 /// does not say" (only the web backend, which no frust shell targets).
135 /// [`Self::probe`] folds `None` to `false`: an unknown answer must not buy
136 /// the transient flag, since it is a memory optimization with a hard
137 /// clear/discard precondition and no upside when the driver ignores it.
138 pub transient_saves_memory: bool,
139 /// The texture format a texture atlas is backed by.
140 pub atlas_format: wgpu::TextureFormat,
141 /// The texture dimension a pooled/atlas resource texture is sized
142 /// against: `min(max_texture_dimension_2d, 4096)` against the (possibly
143 /// already WebGL2-clamped) [`Self::max_texture_dimension_2d`], so a very
144 /// generous desktop adapter's real ceiling never drives an oversized
145 /// allocation.
146 pub resource_texture_dim: u32,
147 /// Whether this adapter's usable limits are the full native set or
148 /// clamped to the GLES-3.0/WebGL2 downlevel defaults.
149 pub downlevel_profile: DownlevelProfile,
150 /// Whether the live device can import an Android `AHardwareBuffer` as a
151 /// texture without a copy ([`crate::import`]'s Android half).
152 ///
153 /// A property of how the *device* was opened, not of the adapter: only a
154 /// Vulkan device `frust-gpu` opened itself with every hardware-buffer
155 /// import extension enabled can import one, and `wgpu`'s own
156 /// `request_device` never enables them. So [`Self::probe`] and
157 /// [`Self::fake`] always report `false`, and the device-open path in
158 /// [`crate::context`] sets it once such a device actually exists. A shell
159 /// installs its hardware-buffer importer only when this is `true`; every
160 /// other device (GLES, a refused or knob-off Vulkan device, every non-Android
161 /// target) keeps CPU frames.
162 pub hwbuffer_import: bool,
163}
164
165impl TierCaps {
166 /// Probes a real `wgpu::Adapter` for the capabilities this crate's
167 /// device/pipeline/atlas decisions are built over. Synchronous: every
168 /// value it reads (`get_info`, `get_downlevel_capabilities`, `limits`,
169 /// `features`) is available before device creation.
170 ///
171 /// When the resolved [`DownlevelProfile`] is [`DownlevelProfile::WebGl2`]
172 /// — a real `wgpu::Backend::Gl` adapter, or `FRUST_ENGINE_DOWNLEVEL=1`
173 /// rehearsing it against a desktop backend — the probed limits are run
174 /// through `clamp_to_webgl2_defaults` and storage buffers are forced
175 /// off, so the override actually rehearses the downlevel shape instead of
176 /// only relabelling desktop values.
177 pub fn probe(adapter: &wgpu::Adapter) -> Self {
178 let info = adapter.get_info();
179 let downlevel = adapter.get_downlevel_capabilities();
180 let limits = adapter.limits();
181 let features = adapter.features();
182 let downlevel_profile = DownlevelProfile::resolve(info.backend);
183 let limits = clamp_to_webgl2_defaults(limits, downlevel_profile);
184 let max_texture_dimension_2d = limits.max_texture_dimension_2d;
185 Self {
186 downlevel_flags: downlevel.flags,
187 adapter_name: info.name,
188 backend: info.backend,
189 max_texture_dimension_2d,
190 max_texture_array_layers: limits.max_texture_array_layers,
191 max_bind_groups: limits.max_bind_groups,
192 max_uniform_buffer_binding_size: saturating_u32(limits.max_uniform_buffer_binding_size),
193 min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment,
194 max_vertex_attributes: limits.max_vertex_attributes,
195 has_storage_buffers: downlevel_profile == DownlevelProfile::Full
196 && limits.max_storage_buffers_per_shader_stage > 0,
197 has_timestamp_query: features.contains(wgpu::Features::TIMESTAMP_QUERY),
198 transient_saves_memory: info.transient_saves_memory.unwrap_or(false),
199 atlas_format: ATLAS_FORMAT,
200 resource_texture_dim: max_texture_dimension_2d.min(MAX_RESOURCE_TEXTURE_DIM),
201 downlevel_profile,
202 hwbuffer_import: false,
203 }
204 }
205
206 /// Builds synthetic caps for the given [`DownlevelProfile`] with no
207 /// `wgpu::Adapter` at all — the host-test counterpart to [`Self::probe`].
208 ///
209 /// `Full` mirrors `wgpu::Limits::defaults()`'s desktop values (backend
210 /// `Vulkan`, `max_texture_dimension_2d` 8192, storage buffers present);
211 /// `WebGl2` mirrors `wgpu::Limits::downlevel_webgl2_defaults()` (backend
212 /// `Gl`, `max_texture_dimension_2d` 2048, no storage buffers).
213 pub fn fake(profile: DownlevelProfile) -> Self {
214 match profile {
215 DownlevelProfile::Full => Self {
216 downlevel_flags: wgpu::DownlevelFlags::all(),
217 adapter_name: "fake-full".to_string(),
218 backend: wgpu::Backend::Vulkan,
219 max_texture_dimension_2d: DESKTOP_MAX_TEXTURE_DIMENSION_2D,
220 max_texture_array_layers: 256,
221 max_bind_groups: 4,
222 max_uniform_buffer_binding_size: 64 << 10,
223 min_uniform_buffer_offset_alignment: 256,
224 max_vertex_attributes: 16,
225 has_storage_buffers: true,
226 has_timestamp_query: true,
227 transient_saves_memory: false,
228 atlas_format: ATLAS_FORMAT,
229 resource_texture_dim: DESKTOP_MAX_TEXTURE_DIMENSION_2D
230 .min(MAX_RESOURCE_TEXTURE_DIM),
231 downlevel_profile: DownlevelProfile::Full,
232 hwbuffer_import: false,
233 },
234 DownlevelProfile::WebGl2 => Self {
235 downlevel_flags: wgpu::DownlevelFlags::empty(),
236 adapter_name: "fake-webgl2".to_string(),
237 backend: wgpu::Backend::Gl,
238 max_texture_dimension_2d: WEBGL2_MAX_TEXTURE_DIMENSION_2D,
239 max_texture_array_layers: 256,
240 max_bind_groups: 4,
241 max_uniform_buffer_binding_size: 16 << 10,
242 min_uniform_buffer_offset_alignment: 256,
243 max_vertex_attributes: 16,
244 has_storage_buffers: false,
245 has_timestamp_query: false,
246 transient_saves_memory: false,
247 atlas_format: ATLAS_FORMAT,
248 resource_texture_dim: WEBGL2_MAX_TEXTURE_DIMENSION_2D.min(MAX_RESOURCE_TEXTURE_DIM),
249 downlevel_profile: DownlevelProfile::WebGl2,
250 hwbuffer_import: false,
251 },
252 }
253 }
254}
255
256/// Clamps `limits` to the GLES-3.0/WebGL2 downlevel default ceiling on every
257/// field [`TierCaps`] tracks, when `profile` is [`DownlevelProfile::WebGl2`];
258/// returns `limits` unchanged for [`DownlevelProfile::Full`].
259///
260/// Each tracked field the WebGL2 defaults constrain is clamped toward that
261/// default rather than replaced outright, so a real (non-desktop) `Gl`
262/// adapter reporting something even lower than the WebGL2 default is not
263/// raised past its own hardware ceiling. `max_texture_dimension_2d`,
264/// `max_texture_array_layers`, `max_bind_groups`,
265/// `max_uniform_buffer_binding_size` and `max_vertex_attributes` are ceilings
266/// (`min` with the default); `min_uniform_buffer_offset_alignment` is a floor
267/// (`max` with the default) — never lowered. Fields the WebGL2 defaults do
268/// not constrain (e.g. `max_texture_dimension_3d` is unused by this crate)
269/// pass through untouched via `..limits`.
270fn clamp_to_webgl2_defaults(limits: wgpu::Limits, profile: DownlevelProfile) -> wgpu::Limits {
271 if profile != DownlevelProfile::WebGl2 {
272 return limits;
273 }
274 let webgl2 = wgpu::Limits::downlevel_webgl2_defaults();
275 wgpu::Limits {
276 max_texture_dimension_2d: limits
277 .max_texture_dimension_2d
278 .min(webgl2.max_texture_dimension_2d),
279 max_texture_array_layers: limits
280 .max_texture_array_layers
281 .min(webgl2.max_texture_array_layers),
282 max_bind_groups: limits.max_bind_groups.min(webgl2.max_bind_groups),
283 max_uniform_buffer_binding_size: limits
284 .max_uniform_buffer_binding_size
285 .min(webgl2.max_uniform_buffer_binding_size),
286 min_uniform_buffer_offset_alignment: limits
287 .min_uniform_buffer_offset_alignment
288 .max(webgl2.min_uniform_buffer_offset_alignment),
289 max_vertex_attributes: limits
290 .max_vertex_attributes
291 .min(webgl2.max_vertex_attributes),
292 ..limits
293 }
294}
295
296/// `u64 -> u32` saturating conversion for `wgpu::Limits` fields declared
297/// wider than the `u32` this crate's [`TierCaps`] stores them as.
298fn saturating_u32(value: u64) -> u32 {
299 u32::try_from(value).unwrap_or(u32::MAX)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn full_profile_fake_reports_desktop_defaults() {
308 let caps = TierCaps::fake(DownlevelProfile::Full);
309 assert_eq!(caps.downlevel_profile, DownlevelProfile::Full);
310 assert_eq!(caps.backend, wgpu::Backend::Vulkan);
311 assert!(caps.has_storage_buffers);
312 assert_eq!(
313 caps.max_texture_dimension_2d,
314 DESKTOP_MAX_TEXTURE_DIMENSION_2D
315 );
316 assert_eq!(caps.resource_texture_dim, MAX_RESOURCE_TEXTURE_DIM);
317 }
318
319 #[test]
320 fn webgl2_profile_fake_reports_clamped_resource_dim_and_no_storage_buffers() {
321 let caps = TierCaps::fake(DownlevelProfile::WebGl2);
322 assert_eq!(caps.downlevel_profile, DownlevelProfile::WebGl2);
323 assert!(caps.resource_texture_dim <= 2048);
324 assert!(!caps.has_storage_buffers);
325 }
326
327 #[test]
328 fn resource_texture_dim_never_exceeds_the_shared_ceiling() {
329 let caps = TierCaps::fake(DownlevelProfile::Full);
330 assert!(caps.resource_texture_dim <= MAX_RESOURCE_TEXTURE_DIM);
331 }
332
333 #[test]
334 fn atlas_format_is_set_on_both_profiles() {
335 assert_eq!(
336 TierCaps::fake(DownlevelProfile::Full).atlas_format,
337 ATLAS_FORMAT
338 );
339 assert_eq!(
340 TierCaps::fake(DownlevelProfile::WebGl2).atlas_format,
341 ATLAS_FORMAT
342 );
343 }
344
345 #[test]
346 fn no_fake_profile_claims_hardware_buffer_import() {
347 // Import is a property of the opened device, never of an adapter, so
348 // neither synthetic profile may report it: a shell would otherwise
349 // install an importer no device can serve.
350 assert!(!TierCaps::fake(DownlevelProfile::Full).hwbuffer_import);
351 assert!(!TierCaps::fake(DownlevelProfile::WebGl2).hwbuffer_import);
352 }
353
354 #[test]
355 fn a_knob_is_on_for_any_value_but_zero_in_either_half() {
356 assert!(!env_flag_enabled(None, None));
357 assert!(!env_flag_enabled(Some("0"), Some("0".to_string())));
358 assert!(env_flag_enabled(Some("1"), None));
359 assert!(env_flag_enabled(None, Some("1".to_string())));
360 }
361
362 #[test]
363 fn saturating_u32_clamps_a_too_large_u64() {
364 assert_eq!(saturating_u32(u64::MAX), u32::MAX);
365 assert_eq!(saturating_u32(64 << 10), 64 << 10);
366 }
367
368 #[test]
369 fn full_profile_clamp_is_a_no_op() {
370 let desktop = wgpu::Limits::defaults();
371 assert_eq!(
372 clamp_to_webgl2_defaults(desktop.clone(), DownlevelProfile::Full),
373 desktop
374 );
375 }
376
377 #[test]
378 fn webgl2_clamp_of_desktop_limits_matches_the_webgl2_fake_in_every_clamped_field() {
379 // Simulates what `FRUST_ENGINE_DOWNLEVEL=1` rehearses: a desktop
380 // adapter's full limits run through the same clamp `TierCaps::probe`
381 // applies. The clamped fields must land exactly on `fake(WebGl2)`'s
382 // values — the rehearsal is worthless if it does not actually
383 // reproduce the downlevel shape a real GLES-3.0/WebGL2 host would
384 // report.
385 let desktop = wgpu::Limits::defaults();
386 let clamped = clamp_to_webgl2_defaults(desktop, DownlevelProfile::WebGl2);
387 let webgl2_fake = TierCaps::fake(DownlevelProfile::WebGl2);
388 assert_eq!(
389 clamped.max_texture_dimension_2d,
390 webgl2_fake.max_texture_dimension_2d
391 );
392 assert_eq!(
393 clamped.max_texture_array_layers,
394 webgl2_fake.max_texture_array_layers
395 );
396 assert_eq!(clamped.max_bind_groups, webgl2_fake.max_bind_groups);
397 assert_eq!(
398 saturating_u32(clamped.max_uniform_buffer_binding_size),
399 webgl2_fake.max_uniform_buffer_binding_size
400 );
401 assert_eq!(
402 clamped.min_uniform_buffer_offset_alignment,
403 webgl2_fake.min_uniform_buffer_offset_alignment
404 );
405 assert_eq!(
406 clamped.max_vertex_attributes,
407 webgl2_fake.max_vertex_attributes
408 );
409 }
410
411 #[test]
412 fn webgl2_clamp_never_lowers_the_alignment_floor_below_the_adapters_own_value() {
413 let stricter = wgpu::Limits {
414 min_uniform_buffer_offset_alignment: 512,
415 ..wgpu::Limits::defaults()
416 };
417 let clamped = clamp_to_webgl2_defaults(stricter, DownlevelProfile::WebGl2);
418 assert_eq!(clamped.min_uniform_buffer_offset_alignment, 512);
419 }
420
421 #[test]
422 fn webgl2_clamp_never_raises_a_real_adapter_ceiling_past_its_own_hardware_limit() {
423 // A real (non-desktop) `Gl` adapter reporting something even lower
424 // than the WebGL2 default must not be raised past its own hardware
425 // ceiling.
426 let constrained = wgpu::Limits {
427 max_texture_dimension_2d: 1024,
428 ..wgpu::Limits::defaults()
429 };
430 let clamped = clamp_to_webgl2_defaults(constrained, DownlevelProfile::WebGl2);
431 assert_eq!(clamped.max_texture_dimension_2d, 1024);
432 }
433
434 #[test]
435 fn probe_downlevel_override_forces_storage_buffers_off_via_probe_semantics() {
436 // `probe` cannot be exercised without a real `wgpu::Adapter`, so this
437 // asserts the same has_storage_buffers policy `probe` applies:
438 // forced false whenever the resolved profile is WebGl2, regardless of
439 // what the underlying (possibly desktop) limits report.
440 let desktop = wgpu::Limits::defaults();
441 assert!(
442 desktop.max_storage_buffers_per_shader_stage > 0,
443 "fixture precondition"
444 );
445 let clamped = clamp_to_webgl2_defaults(desktop, DownlevelProfile::WebGl2);
446 // The clamp itself does not touch max_storage_buffers_per_shader_stage
447 // (probe's `has_storage_buffers` line is a separate, explicit force);
448 // this documents that split so a future edit to the clamp cannot
449 // silently reintroduce storage buffers under the override.
450 assert!(clamped.max_storage_buffers_per_shader_stage > 0);
451 }
452}