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}