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
73fn env_flag_enabled(compile_time: Option<&str>, runtime: Option<String>) -> bool {
74    fn is_set_non_zero(value: Option<&str>) -> bool {
75        matches!(value, Some(v) if v != "0")
76    }
77    is_set_non_zero(compile_time) || is_set_non_zero(runtime.as_deref())
78}
79
80/// Plain-data adapter capabilities the GPU crate's device/pipeline/atlas
81/// decisions are built over. Constructed from a real `wgpu::Adapter` via
82/// [`TierCaps::probe`], or by hand in host tests via [`TierCaps::fake`].
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct TierCaps {
85    /// The adapter's `wgpu::DownlevelCapabilities::flags`.
86    pub downlevel_flags: wgpu::DownlevelFlags,
87    /// The adapter's `wgpu::AdapterInfo::name`, used only for diagnostics.
88    pub adapter_name: String,
89    /// The adapter's `wgpu::AdapterInfo::backend`.
90    pub backend: wgpu::Backend,
91    /// The adapter's `wgpu::Limits::max_texture_dimension_2d`, clamped down to
92    /// `wgpu::Limits::downlevel_webgl2_defaults().max_texture_dimension_2d`
93    /// when [`Self::downlevel_profile`] is [`DownlevelProfile::WebGl2`].
94    pub max_texture_dimension_2d: u32,
95    /// The adapter's `wgpu::Limits::max_texture_array_layers`, clamped the
96    /// same way as [`Self::max_texture_dimension_2d`].
97    pub max_texture_array_layers: u32,
98    /// The adapter's `wgpu::Limits::max_bind_groups`, clamped the same way as
99    /// [`Self::max_texture_dimension_2d`].
100    pub max_bind_groups: u32,
101    /// The adapter's `wgpu::Limits::max_uniform_buffer_binding_size`,
102    /// saturated into a `u32` (the field is `u64` on `wgpu::Limits`; no
103    /// adapter this workspace targets reports a value anywhere near
104    /// `u32::MAX`) and clamped the same way as
105    /// [`Self::max_texture_dimension_2d`].
106    pub max_uniform_buffer_binding_size: u32,
107    /// The adapter's `wgpu::Limits::min_uniform_buffer_offset_alignment`,
108    /// raised to
109    /// `wgpu::Limits::downlevel_webgl2_defaults().min_uniform_buffer_offset_alignment`
110    /// (256) when [`Self::downlevel_profile`] is [`DownlevelProfile::WebGl2`]
111    /// and the adapter reports a looser (smaller) value — an alignment
112    /// requirement is a floor, so it is only ever raised, never lowered.
113    pub min_uniform_buffer_offset_alignment: u32,
114    /// The adapter's `wgpu::Limits::max_vertex_attributes`, clamped the same
115    /// way as [`Self::max_texture_dimension_2d`].
116    pub max_vertex_attributes: u32,
117    /// Whether the adapter supports storage buffers at all
118    /// (`wgpu::Limits::max_storage_buffers_per_shader_stage > 0`) — forced
119    /// `false` whenever [`Self::downlevel_profile`] is
120    /// [`DownlevelProfile::WebGl2`], since the GLES-3.0/WebGL2 downlevel
121    /// default limits report zero storage buffers per shader stage.
122    pub has_storage_buffers: bool,
123    /// Whether the adapter exposes `wgpu::Features::TIMESTAMP_QUERY`.
124    pub has_timestamp_query: bool,
125    /// The adapter's `wgpu::AdapterInfo::transient_saves_memory` — whether
126    /// adding `wgpu::TextureUsages::TRANSIENT_ATTACHMENT` to a texture (which
127    /// itself requires `wgpu::StoreOp::Discard`) reduces memory usage on this
128    /// adapter.
129    ///
130    /// `wgpu` reports this as `Option<bool>`, where `None` means "the adapter
131    /// does not say" (only the web backend, which no frust shell targets).
132    /// [`Self::probe`] folds `None` to `false`: an unknown answer must not buy
133    /// the transient flag, since it is a memory optimization with a hard
134    /// clear/discard precondition and no upside when the driver ignores it.
135    pub transient_saves_memory: bool,
136    /// The texture format a texture atlas is backed by.
137    pub atlas_format: wgpu::TextureFormat,
138    /// The texture dimension a pooled/atlas resource texture is sized
139    /// against: `min(max_texture_dimension_2d, 4096)` against the (possibly
140    /// already WebGL2-clamped) [`Self::max_texture_dimension_2d`], so a very
141    /// generous desktop adapter's real ceiling never drives an oversized
142    /// allocation.
143    pub resource_texture_dim: u32,
144    /// Whether this adapter's usable limits are the full native set or
145    /// clamped to the GLES-3.0/WebGL2 downlevel defaults.
146    pub downlevel_profile: DownlevelProfile,
147}
148
149impl TierCaps {
150    /// Probes a real `wgpu::Adapter` for the capabilities this crate's
151    /// device/pipeline/atlas decisions are built over. Synchronous: every
152    /// value it reads (`get_info`, `get_downlevel_capabilities`, `limits`,
153    /// `features`) is available before device creation.
154    ///
155    /// When the resolved [`DownlevelProfile`] is [`DownlevelProfile::WebGl2`]
156    /// — a real `wgpu::Backend::Gl` adapter, or `FRUST_ENGINE_DOWNLEVEL=1`
157    /// rehearsing it against a desktop backend — the probed limits are run
158    /// through `clamp_to_webgl2_defaults` and storage buffers are forced
159    /// off, so the override actually rehearses the downlevel shape instead of
160    /// only relabelling desktop values.
161    pub fn probe(adapter: &wgpu::Adapter) -> Self {
162        let info = adapter.get_info();
163        let downlevel = adapter.get_downlevel_capabilities();
164        let limits = adapter.limits();
165        let features = adapter.features();
166        let downlevel_profile = DownlevelProfile::resolve(info.backend);
167        let limits = clamp_to_webgl2_defaults(limits, downlevel_profile);
168        let max_texture_dimension_2d = limits.max_texture_dimension_2d;
169        Self {
170            downlevel_flags: downlevel.flags,
171            adapter_name: info.name,
172            backend: info.backend,
173            max_texture_dimension_2d,
174            max_texture_array_layers: limits.max_texture_array_layers,
175            max_bind_groups: limits.max_bind_groups,
176            max_uniform_buffer_binding_size: saturating_u32(limits.max_uniform_buffer_binding_size),
177            min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment,
178            max_vertex_attributes: limits.max_vertex_attributes,
179            has_storage_buffers: downlevel_profile == DownlevelProfile::Full
180                && limits.max_storage_buffers_per_shader_stage > 0,
181            has_timestamp_query: features.contains(wgpu::Features::TIMESTAMP_QUERY),
182            transient_saves_memory: info.transient_saves_memory.unwrap_or(false),
183            atlas_format: ATLAS_FORMAT,
184            resource_texture_dim: max_texture_dimension_2d.min(MAX_RESOURCE_TEXTURE_DIM),
185            downlevel_profile,
186        }
187    }
188
189    /// Builds synthetic caps for the given [`DownlevelProfile`] with no
190    /// `wgpu::Adapter` at all — the host-test counterpart to [`Self::probe`].
191    ///
192    /// `Full` mirrors `wgpu::Limits::defaults()`'s desktop values (backend
193    /// `Vulkan`, `max_texture_dimension_2d` 8192, storage buffers present);
194    /// `WebGl2` mirrors `wgpu::Limits::downlevel_webgl2_defaults()` (backend
195    /// `Gl`, `max_texture_dimension_2d` 2048, no storage buffers).
196    pub fn fake(profile: DownlevelProfile) -> Self {
197        match profile {
198            DownlevelProfile::Full => Self {
199                downlevel_flags: wgpu::DownlevelFlags::all(),
200                adapter_name: "fake-full".to_string(),
201                backend: wgpu::Backend::Vulkan,
202                max_texture_dimension_2d: DESKTOP_MAX_TEXTURE_DIMENSION_2D,
203                max_texture_array_layers: 256,
204                max_bind_groups: 4,
205                max_uniform_buffer_binding_size: 64 << 10,
206                min_uniform_buffer_offset_alignment: 256,
207                max_vertex_attributes: 16,
208                has_storage_buffers: true,
209                has_timestamp_query: true,
210                transient_saves_memory: false,
211                atlas_format: ATLAS_FORMAT,
212                resource_texture_dim: DESKTOP_MAX_TEXTURE_DIMENSION_2D
213                    .min(MAX_RESOURCE_TEXTURE_DIM),
214                downlevel_profile: DownlevelProfile::Full,
215            },
216            DownlevelProfile::WebGl2 => Self {
217                downlevel_flags: wgpu::DownlevelFlags::empty(),
218                adapter_name: "fake-webgl2".to_string(),
219                backend: wgpu::Backend::Gl,
220                max_texture_dimension_2d: WEBGL2_MAX_TEXTURE_DIMENSION_2D,
221                max_texture_array_layers: 256,
222                max_bind_groups: 4,
223                max_uniform_buffer_binding_size: 16 << 10,
224                min_uniform_buffer_offset_alignment: 256,
225                max_vertex_attributes: 16,
226                has_storage_buffers: false,
227                has_timestamp_query: false,
228                transient_saves_memory: false,
229                atlas_format: ATLAS_FORMAT,
230                resource_texture_dim: WEBGL2_MAX_TEXTURE_DIMENSION_2D.min(MAX_RESOURCE_TEXTURE_DIM),
231                downlevel_profile: DownlevelProfile::WebGl2,
232            },
233        }
234    }
235}
236
237/// Clamps `limits` to the GLES-3.0/WebGL2 downlevel default ceiling on every
238/// field [`TierCaps`] tracks, when `profile` is [`DownlevelProfile::WebGl2`];
239/// returns `limits` unchanged for [`DownlevelProfile::Full`].
240///
241/// Each tracked field the WebGL2 defaults constrain is clamped toward that
242/// default rather than replaced outright, so a real (non-desktop) `Gl`
243/// adapter reporting something even lower than the WebGL2 default is not
244/// raised past its own hardware ceiling. `max_texture_dimension_2d`,
245/// `max_texture_array_layers`, `max_bind_groups`,
246/// `max_uniform_buffer_binding_size` and `max_vertex_attributes` are ceilings
247/// (`min` with the default); `min_uniform_buffer_offset_alignment` is a floor
248/// (`max` with the default) — never lowered. Fields the WebGL2 defaults do
249/// not constrain (e.g. `max_texture_dimension_3d` is unused by this crate)
250/// pass through untouched via `..limits`.
251fn clamp_to_webgl2_defaults(limits: wgpu::Limits, profile: DownlevelProfile) -> wgpu::Limits {
252    if profile != DownlevelProfile::WebGl2 {
253        return limits;
254    }
255    let webgl2 = wgpu::Limits::downlevel_webgl2_defaults();
256    wgpu::Limits {
257        max_texture_dimension_2d: limits
258            .max_texture_dimension_2d
259            .min(webgl2.max_texture_dimension_2d),
260        max_texture_array_layers: limits
261            .max_texture_array_layers
262            .min(webgl2.max_texture_array_layers),
263        max_bind_groups: limits.max_bind_groups.min(webgl2.max_bind_groups),
264        max_uniform_buffer_binding_size: limits
265            .max_uniform_buffer_binding_size
266            .min(webgl2.max_uniform_buffer_binding_size),
267        min_uniform_buffer_offset_alignment: limits
268            .min_uniform_buffer_offset_alignment
269            .max(webgl2.min_uniform_buffer_offset_alignment),
270        max_vertex_attributes: limits
271            .max_vertex_attributes
272            .min(webgl2.max_vertex_attributes),
273        ..limits
274    }
275}
276
277/// `u64 -> u32` saturating conversion for `wgpu::Limits` fields declared
278/// wider than the `u32` this crate's [`TierCaps`] stores them as.
279fn saturating_u32(value: u64) -> u32 {
280    u32::try_from(value).unwrap_or(u32::MAX)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn full_profile_fake_reports_desktop_defaults() {
289        let caps = TierCaps::fake(DownlevelProfile::Full);
290        assert_eq!(caps.downlevel_profile, DownlevelProfile::Full);
291        assert_eq!(caps.backend, wgpu::Backend::Vulkan);
292        assert!(caps.has_storage_buffers);
293        assert_eq!(
294            caps.max_texture_dimension_2d,
295            DESKTOP_MAX_TEXTURE_DIMENSION_2D
296        );
297        assert_eq!(caps.resource_texture_dim, MAX_RESOURCE_TEXTURE_DIM);
298    }
299
300    #[test]
301    fn webgl2_profile_fake_reports_clamped_resource_dim_and_no_storage_buffers() {
302        let caps = TierCaps::fake(DownlevelProfile::WebGl2);
303        assert_eq!(caps.downlevel_profile, DownlevelProfile::WebGl2);
304        assert!(caps.resource_texture_dim <= 2048);
305        assert!(!caps.has_storage_buffers);
306    }
307
308    #[test]
309    fn resource_texture_dim_never_exceeds_the_shared_ceiling() {
310        let caps = TierCaps::fake(DownlevelProfile::Full);
311        assert!(caps.resource_texture_dim <= MAX_RESOURCE_TEXTURE_DIM);
312    }
313
314    #[test]
315    fn atlas_format_is_set_on_both_profiles() {
316        assert_eq!(
317            TierCaps::fake(DownlevelProfile::Full).atlas_format,
318            ATLAS_FORMAT
319        );
320        assert_eq!(
321            TierCaps::fake(DownlevelProfile::WebGl2).atlas_format,
322            ATLAS_FORMAT
323        );
324    }
325
326    #[test]
327    fn saturating_u32_clamps_a_too_large_u64() {
328        assert_eq!(saturating_u32(u64::MAX), u32::MAX);
329        assert_eq!(saturating_u32(64 << 10), 64 << 10);
330    }
331
332    #[test]
333    fn full_profile_clamp_is_a_no_op() {
334        let desktop = wgpu::Limits::defaults();
335        assert_eq!(
336            clamp_to_webgl2_defaults(desktop.clone(), DownlevelProfile::Full),
337            desktop
338        );
339    }
340
341    #[test]
342    fn webgl2_clamp_of_desktop_limits_matches_the_webgl2_fake_in_every_clamped_field() {
343        // Simulates what `FRUST_ENGINE_DOWNLEVEL=1` rehearses: a desktop
344        // adapter's full limits run through the same clamp `TierCaps::probe`
345        // applies. The clamped fields must land exactly on `fake(WebGl2)`'s
346        // values — the rehearsal is worthless if it does not actually
347        // reproduce the downlevel shape a real GLES-3.0/WebGL2 host would
348        // report.
349        let desktop = wgpu::Limits::defaults();
350        let clamped = clamp_to_webgl2_defaults(desktop, DownlevelProfile::WebGl2);
351        let webgl2_fake = TierCaps::fake(DownlevelProfile::WebGl2);
352        assert_eq!(
353            clamped.max_texture_dimension_2d,
354            webgl2_fake.max_texture_dimension_2d
355        );
356        assert_eq!(
357            clamped.max_texture_array_layers,
358            webgl2_fake.max_texture_array_layers
359        );
360        assert_eq!(clamped.max_bind_groups, webgl2_fake.max_bind_groups);
361        assert_eq!(
362            saturating_u32(clamped.max_uniform_buffer_binding_size),
363            webgl2_fake.max_uniform_buffer_binding_size
364        );
365        assert_eq!(
366            clamped.min_uniform_buffer_offset_alignment,
367            webgl2_fake.min_uniform_buffer_offset_alignment
368        );
369        assert_eq!(
370            clamped.max_vertex_attributes,
371            webgl2_fake.max_vertex_attributes
372        );
373    }
374
375    #[test]
376    fn webgl2_clamp_never_lowers_the_alignment_floor_below_the_adapters_own_value() {
377        let stricter = wgpu::Limits {
378            min_uniform_buffer_offset_alignment: 512,
379            ..wgpu::Limits::defaults()
380        };
381        let clamped = clamp_to_webgl2_defaults(stricter, DownlevelProfile::WebGl2);
382        assert_eq!(clamped.min_uniform_buffer_offset_alignment, 512);
383    }
384
385    #[test]
386    fn webgl2_clamp_never_raises_a_real_adapter_ceiling_past_its_own_hardware_limit() {
387        // A real (non-desktop) `Gl` adapter reporting something even lower
388        // than the WebGL2 default must not be raised past its own hardware
389        // ceiling.
390        let constrained = wgpu::Limits {
391            max_texture_dimension_2d: 1024,
392            ..wgpu::Limits::defaults()
393        };
394        let clamped = clamp_to_webgl2_defaults(constrained, DownlevelProfile::WebGl2);
395        assert_eq!(clamped.max_texture_dimension_2d, 1024);
396    }
397
398    #[test]
399    fn probe_downlevel_override_forces_storage_buffers_off_via_probe_semantics() {
400        // `probe` cannot be exercised without a real `wgpu::Adapter`, so this
401        // asserts the same has_storage_buffers policy `probe` applies:
402        // forced false whenever the resolved profile is WebGl2, regardless of
403        // what the underlying (possibly desktop) limits report.
404        let desktop = wgpu::Limits::defaults();
405        assert!(
406            desktop.max_storage_buffers_per_shader_stage > 0,
407            "fixture precondition"
408        );
409        let clamped = clamp_to_webgl2_defaults(desktop, DownlevelProfile::WebGl2);
410        // The clamp itself does not touch max_storage_buffers_per_shader_stage
411        // (probe's `has_storage_buffers` line is a separate, explicit force);
412        // this documents that split so a future edit to the clamp cannot
413        // silently reintroduce storage buffers under the override.
414        assert!(clamped.max_storage_buffers_per_shader_stage > 0);
415    }
416}