Skip to main content

unlit_wgpu/
capabilities.rs

1//! What the device can do beyond the WebGPU baseline, and how to ask for a
2//! narrower device.
3//!
4//! WebGPU sets a floor every implementation meets, but wgpu also runs on
5//! implementations that miss parts of it — WebGL2 most notably, which has no
6//! storage buffers, no `base_vertex` and no view formats. Which parts are
7//! missing is a property of the *adapter*, reported by
8//! [`wgpu::Adapter::get_downlevel_capabilities`]. [`wgpu::Device`] exposes its
9//! limits and features but not the downlevel flags, and the adapter is
10//! normally dropped once the device is built.
11//!
12//! So [`DeviceCapabilities`] is captured where the adapter still exists, and
13//! carried to where the frame is recorded. It holds only what the device
14//! cannot answer for itself: the rest is already visible on the device —
15//! whether a storage buffer can be bound is
16//! [`supports_storage_buffers`](crate::pipeline::supports_storage_buffers), read
17//! from the device's own limits — and asking two sources for one answer is how
18//! they drift apart.
19//!
20//! [`DeviceTier`] is the other half: how much of the baseline to *ask* for.
21//! The two are separate because a device can be narrower than the adapter
22//! underneath it, and asking for the baseline rather than the adapter's own
23//! limits is what keeps a frame within reach of every implementation it claims
24//! to run on.
25
26use core::fmt;
27
28/// The environment variable that narrows the device to a tier.
29const TIER_ENV: &str = "UNLIT3D_DEVICE_TIER";
30
31/// The downlevel capabilities a frame is recorded against.
32///
33/// See the [module documentation](self) for why this is captured rather than
34/// read from the device.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct DeviceCapabilities {
37    /// Whether an indexed draw may offset its indices by a non-zero
38    /// `base_vertex`.
39    base_vertex: bool,
40}
41
42impl DeviceCapabilities {
43    /// Everything the WebGPU baseline requires.
44    ///
45    /// What a device assumed rather than asked about gets: the built-in
46    /// pipeline then takes its fastest path, which is the only path a
47    /// full-featured device needs.
48    pub const WEBGPU: Self = Self { base_vertex: true };
49
50    /// What a WebGL2 device has.
51    ///
52    /// WebGL2 is GLES 3.0, and `base_vertex` needs GLES 3.2, so an indexed draw
53    /// there must keep `base_vertex` at zero.
54    pub const WEBGL2: Self = Self { base_vertex: false };
55
56    /// Read the capabilities of `adapter`.
57    pub fn from_adapter(adapter: &wgpu::Adapter) -> Self {
58        let flags = adapter.get_downlevel_capabilities().flags;
59        Self {
60            base_vertex: flags.contains(wgpu::DownlevelFlags::BASE_VERTEX),
61        }
62    }
63
64    /// Whether an indexed draw may offset its indices by a non-zero
65    /// `base_vertex`.
66    ///
67    /// Where this is missing, a mesh cannot name its vertices through the
68    /// draw's `base_vertex` — the backend would have to call a GL entry point
69    /// that does not exist — so its indices are offset at upload instead. The
70    /// mesh source is what decides which of the two a mesh's indices get; the
71    /// addressing the draw then reads is the metadata entry's
72    /// [`vertex_offset`](crate::mesh::MeshMetadata::vertex_offset).
73    pub fn base_vertex(&self) -> bool {
74        self.base_vertex
75    }
76}
77
78impl Default for DeviceCapabilities {
79    fn default() -> Self {
80        Self::WEBGPU
81    }
82}
83
84/// How much of the WebGPU baseline to ask a device for.
85///
86/// A device is requested with a set of limits, so the tier is what decides how
87/// large a device the frame has to work within. [`Self::WebGpu`] is the
88/// baseline every WebGPU implementation guarantees, and is the default, so a
89/// frame asks for no more than it has to and runs wherever the API does.
90/// [`Self::WebGl2`] reproduces a WebGL2 device's *shape* on hardware that is
91/// capable of much more, which is the only way to exercise the paths a browser
92/// takes from a machine that has storage buffers and `base_vertex`; it is
93/// [`Self::WebGpu`] with
94/// [`downlevel_webgl2_defaults`](wgpu::Limits::downlevel_webgl2_defaults) as
95/// its floor.
96///
97/// [`Self::from_env`] reads the tier from `UNLIT3D_DEVICE_TIER`, so a test run
98/// can pick the tier without recompiling.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum DeviceTier {
101    /// The limits the WebGPU baseline guarantees, lowered where the adapter
102    /// offers even less. The default.
103    #[default]
104    WebGpu,
105    /// The adapter's own limits, which on a desktop backend are its full
106    /// capabilities rather than the baseline's floor.
107    Native,
108    /// WebGL2's limits and capabilities.
109    WebGl2,
110}
111
112impl DeviceTier {
113    /// Every tier, for a caller that has to enumerate them.
114    ///
115    /// [`Display`](core::fmt::Display) and [`FromStr`](core::str::FromStr) are inverses over exactly
116    /// these, and a test asserts it, so the set cannot grow without the
117    /// environment variable learning the new name.
118    pub const ALL: &[Self] = &[Self::WebGpu, Self::Native, Self::WebGl2];
119
120    /// The tier named by `UNLIT3D_DEVICE_TIER`, or [`Self::WebGpu`] when it is
121    /// unset.
122    ///
123    /// The value is parsed by [`FromStr`](core::str::FromStr), so an unrecognized one — a
124    /// typo like `webgl` — panics rather than silently selecting the default.
125    /// Running the wrong tier is the failure this variable exists to prevent:
126    /// it would pass while exercising none of the paths under test, which is
127    /// worse than not running.
128    ///
129    /// # Panics
130    ///
131    /// If the variable is set to a value that names no tier.
132    pub fn from_env() -> Self {
133        match std::env::var(TIER_ENV) {
134            Ok(value) => value.parse().unwrap_or_else(|()| {
135                let names: Vec<_> = Self::ALL.iter().map(Self::to_string).collect();
136                panic!(
137                    "{TIER_ENV}={value:?} names no device tier; unset it for the WebGPU \
138                     baseline's limits, or set it to one of {}",
139                    names.join(", ")
140                )
141            }),
142            Err(_) => Self::WebGpu,
143        }
144    }
145
146    /// The limits to request from an adapter whose own limits are
147    /// `adapter_limits`.
148    ///
149    /// [`Self::Native`] asks for the adapter's own limits, and so can.
150    /// [`Self::WebGpu`] asks for the baseline's guaranteed values, which is
151    /// what keeps the device the frame is recorded against within reach of
152    /// every implementation — but an adapter may offer less than the baseline,
153    /// an adapter narrowed to a browser's WebGL2 most of all, and a request
154    /// above what the adapter supports is refused outright. Every other limit
155    /// is therefore lowered to the adapter's, which leaves a compliant
156    /// adapter's request exactly the baseline and lets a weaker one through on
157    /// its own terms.
158    ///
159    /// Both narrowed tiers keep the adapter's texture resolution: a tier is a
160    /// floor, and lowering the texture sizes below what the adapter has would
161    /// test a device smaller than any browser ships.
162    pub fn limits(self, adapter_limits: &wgpu::Limits) -> wgpu::Limits {
163        match self {
164            Self::Native => adapter_limits.clone(),
165            Self::WebGpu => wgpu::Limits::defaults()
166                .or_worse_values_from(adapter_limits)
167                .using_resolution(adapter_limits.clone()),
168            Self::WebGl2 => {
169                wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits.clone())
170            }
171        }
172    }
173
174    /// The capabilities a device of this tier has.
175    ///
176    /// Independent of the adapter: the tier *is* the set of flags, and a forced
177    /// tier has to report what the real device of that tier would.
178    pub fn capabilities(self) -> DeviceCapabilities {
179        match self {
180            Self::WebGpu | Self::Native => DeviceCapabilities::WEBGPU,
181            Self::WebGl2 => DeviceCapabilities::WEBGL2,
182        }
183    }
184
185    /// The capabilities a device of this tier ends up with.
186    ///
187    /// A narrowed tier is a request, so what it can rely on is the tier's own
188    /// capabilities intersected with what the adapter really reports: a forced
189    /// WebGL2 tier is only as capable as WebGL2, and an adapter that is even
190    /// more limited is only as capable as itself.
191    ///
192    /// The WebGPU baseline is a floor every implementation claims to meet, so
193    /// for [`Self::WebGpu`] the intersection is whatever the adapter reports —
194    /// which is also what [`Self::Native`] yields, since a tier narrowed by
195    /// limits does not change the adapter's flags.
196    pub fn capabilities_of(self, adapter: &wgpu::Adapter) -> DeviceCapabilities {
197        match self {
198            Self::WebGpu | Self::Native => DeviceCapabilities::from_adapter(adapter),
199            Self::WebGl2 => DeviceCapabilities::WEBGL2,
200        }
201    }
202}
203
204impl fmt::Display for DeviceTier {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(match self {
207            Self::WebGpu => "webgpu",
208            Self::Native => "native",
209            Self::WebGl2 => "webgl2",
210        })
211    }
212}
213
214impl core::str::FromStr for DeviceTier {
215    type Err = ();
216
217    /// Parse a tier name, case-insensitively.
218    ///
219    /// This is the inverse of [`Display`](core::fmt::Display), and the two are written
220    /// against the same names so a value printed by one is accepted by the
221    /// other.
222    fn from_str(name: &str) -> Result<Self, ()> {
223        match name {
224            _ if name.eq_ignore_ascii_case("webgpu") => Ok(Self::WebGpu),
225            _ if name.eq_ignore_ascii_case("native") => Ok(Self::Native),
226            _ if name.eq_ignore_ascii_case("webgl2") => Ok(Self::WebGl2),
227            _ => Err(()),
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn the_default_is_the_webgpu_baseline() {
238        assert!(DeviceCapabilities::default().base_vertex());
239        assert_eq!(DeviceCapabilities::default(), DeviceCapabilities::WEBGPU);
240    }
241
242    #[test]
243    fn a_webgl2_device_has_no_base_vertex() {
244        assert!(!DeviceCapabilities::WEBGL2.base_vertex());
245    }
246
247    #[test]
248    fn the_webgpu_tier_asks_for_the_baseline_and_no_more() {
249        let adapter = wgpu::Limits {
250            max_texture_dimension_2d: 16384,
251            max_storage_buffers_per_shader_stage: 16,
252            max_uniform_buffer_binding_size: 64 << 10,
253            ..wgpu::Limits::default()
254        };
255
256        let requested = DeviceTier::WebGpu.limits(&adapter);
257
258        // The baseline's own values rather than the adapter's better ones: a
259        // device is asked for only what the frame needs, which is what keeps it
260        // within reach of every implementation.
261        assert_eq!(requested.max_storage_buffers_per_shader_stage, 8);
262        assert_eq!(requested.max_uniform_buffer_binding_size, 64 << 10);
263        // The resolution is the exception: a tier is a floor, so it is raised
264        // to what the adapter has rather than shrinking the textures below it.
265        assert_eq!(requested.max_texture_dimension_2d, 16384);
266    }
267
268    #[test]
269    fn the_webgpu_tier_lowers_the_baseline_to_a_weaker_adapter() {
270        // A device narrower than the baseline exists — a browser's WebGL2 is
271        // one — and asking it for more than it has is refused outright, so the
272        // request follows it down.
273        let adapter = wgpu::Limits {
274            max_texture_dimension_2d: 2048,
275            max_storage_buffers_per_shader_stage: 0,
276            max_uniform_buffer_binding_size: 16 << 10,
277            ..wgpu::Limits::default()
278        };
279
280        let requested = DeviceTier::WebGpu.limits(&adapter);
281
282        assert_eq!(requested.max_texture_dimension_2d, 2048);
283        assert_eq!(requested.max_storage_buffers_per_shader_stage, 0);
284        assert_eq!(requested.max_uniform_buffer_binding_size, 16 << 10);
285        // What the adapter itself offers is untouched, so the two agree
286        // wherever the adapter is already at or below the baseline.
287        assert_eq!(
288            requested.min_uniform_buffer_offset_alignment,
289            adapter.min_uniform_buffer_offset_alignment
290        );
291    }
292
293    #[test]
294    fn the_native_tier_leaves_the_adapters_limits_alone() {
295        let adapter = wgpu::Limits {
296            max_texture_dimension_2d: 4096,
297            max_storage_buffers_per_shader_stage: 8,
298            ..wgpu::Limits::default()
299        };
300
301        let requested = DeviceTier::Native.limits(&adapter);
302
303        assert_eq!(requested.max_texture_dimension_2d, 4096);
304        assert_eq!(requested.max_storage_buffers_per_shader_stage, 8);
305    }
306
307    #[test]
308    fn the_webgl2_tier_has_no_storage_buffers_but_keeps_the_texture_size() {
309        let adapter = wgpu::Limits {
310            max_texture_dimension_2d: 16384,
311            max_storage_buffers_per_shader_stage: 16,
312            ..wgpu::Limits::default()
313        };
314
315        let requested = DeviceTier::WebGl2.limits(&adapter);
316
317        // What makes a storage-buffer bind-group layout fail outright, which is
318        // how the pipeline detects that it has to read its arrays from
319        // textures instead.
320        assert_eq!(requested.max_storage_buffers_per_shader_stage, 0);
321        assert_eq!(requested.max_storage_buffer_binding_size, 0);
322        // The floor's own resolution, raised to what the adapter has: a tier
323        // must not shrink the textures below any browser's.
324        assert_eq!(requested.max_texture_dimension_2d, 16384);
325    }
326
327    #[test]
328    fn each_tier_is_at_least_as_limited_as_the_next() {
329        let adapter = wgpu::Limits {
330            max_texture_dimension_2d: 16384,
331            ..wgpu::Limits::default()
332        };
333        let webgl2 = DeviceTier::WebGl2.limits(&adapter);
334        let webgpu = DeviceTier::WebGpu.limits(&adapter);
335        let native = DeviceTier::Native.limits(&adapter);
336
337        assert!(webgl2.max_uniform_buffer_binding_size <= webgpu.max_uniform_buffer_binding_size);
338        assert!(webgpu.max_uniform_buffer_binding_size <= native.max_uniform_buffer_binding_size);
339        assert!(webgl2.max_bind_groups <= webgpu.max_bind_groups);
340        assert!(webgpu.max_bind_groups <= native.max_bind_groups);
341        assert!(
342            webgl2.max_storage_buffers_per_shader_stage
343                <= webgpu.max_storage_buffers_per_shader_stage
344        );
345        assert!(
346            webgpu.max_storage_buffers_per_shader_stage
347                <= native.max_storage_buffers_per_shader_stage
348        );
349    }
350
351    #[test]
352    fn the_tier_decides_the_capabilities() {
353        assert!(DeviceTier::WebGpu.capabilities().base_vertex());
354        // A tier narrowed by limits does not change the adapter's flags, so
355        // the baseline and the adapter's own agree on what the format says.
356        assert!(DeviceTier::Native.capabilities().base_vertex());
357        assert!(!DeviceTier::WebGl2.capabilities().base_vertex());
358        assert_eq!(DeviceTier::default(), DeviceTier::WebGpu);
359    }
360
361    #[test]
362    fn the_tier_prints_as_the_name_the_environment_variable_takes() {
363        assert_eq!(DeviceTier::WebGpu.to_string(), "webgpu");
364        assert_eq!(DeviceTier::Native.to_string(), "native");
365        assert_eq!(DeviceTier::WebGl2.to_string(), "webgl2");
366    }
367
368    #[test]
369    fn every_tier_name_round_trips() {
370        for &tier in DeviceTier::ALL {
371            let name = tier.to_string();
372            assert_eq!(name.parse(), Ok(tier), "{name} parses back to its tier");
373            assert_eq!(
374                name.to_uppercase().parse(),
375                Ok(tier),
376                "{name} is case-insensitive"
377            );
378        }
379    }
380
381    #[test]
382    fn a_name_that_is_no_tier_is_refused() {
383        // `webgl` is the typo this exists for: accepting it as the default
384        // would run every test against the wrong device and still pass.
385        for name in ["webgl", "webgl3", "", "webgpu ", "gles"] {
386            assert_eq!(
387                name.parse::<DeviceTier>(),
388                Err(()),
389                "{name:?} names no tier"
390            );
391        }
392    }
393}