Skip to main content

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}