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}