Skip to main content

game_toolkit_gfx/
config.rs

1//! Settings [`Graphics::new`](crate::Graphics::new) creates the device and surface with.
2
3use std::fmt;
4
5use crate::canvas::{ScaleFilter, ScaleMode};
6use crate::color::Color;
7
8/// Called with the adapter and the device descriptor the toolkit is about to request, after
9/// it has merged its own limits and features with [`GraphicsConfig::required_limits`] and
10/// [`GraphicsConfig::required_features`]. Change the descriptor to request more (or less)
11/// once you have looked at what the adapter offers, e.g. `adapter.features()`.
12///
13/// Whatever the hook asks for is passed to `request_device` as is, without the clear
14/// "missing limit" error the config fields get.
15///
16/// A closure can capture runtime settings (a user's quality preset, a command-line flag):
17///
18/// ```
19/// # use std::sync::Arc;
20/// # use game_toolkit_gfx::{DeviceHook, wgpu};
21/// let want_f16 = true; // e.g. from a settings file
22/// let hook: DeviceHook = Arc::new(move |adapter, desc| {
23///     if want_f16 && adapter.features().contains(wgpu::Features::SHADER_F16) {
24///         desc.required_features |= wgpu::Features::SHADER_F16;
25///     }
26/// });
27/// ```
28pub type DeviceHook =
29    std::sync::Arc<dyn Fn(&wgpu::Adapter, &mut wgpu::DeviceDescriptor<'_>) + Send + Sync>;
30
31/// Settings for [`Graphics::new`](crate::Graphics::new).
32///
33/// Construct it with `..Default::default()` so fields added in future minor releases don't
34/// break your call site. Games usually set these through `AppConfig`, which forwards them.
35#[derive(Clone)]
36pub struct GraphicsConfig {
37    pub vsync: bool,
38    /// Depth attachment format, `None` for none. See `AppConfig::depth_format`.
39    pub depth_format: Option<wgpu::TextureFormat>,
40    /// MSAA sample count; `1` disables MSAA.
41    pub msaa_samples: u32,
42    /// Device limits the game needs beyond the toolkit's own. `None` (default) requests only
43    /// what the toolkit needs: `Limits::downlevel_defaults()` (`Limits::default()` with the
44    /// `vector` feature, `downlevel_webgl2_defaults()` on WebGL2). With `Some(limits)`, each
45    /// limit is the better of the two, so asking for e.g. `max_storage_buffers_per_shader_stage:
46    /// 8` on top of `Limits::downlevel_defaults()` raises only that one.
47    ///
48    /// If the adapter can't provide a limit, [`Graphics::new`](crate::Graphics::new) fails
49    /// with a [`DeviceRequirementsError`] naming it.
50    pub required_limits: Option<wgpu::Limits>,
51    /// Optional device features the game needs (`SHADER_F16`, `TIMESTAMP_QUERY`, ...).
52    /// Default `Features::empty()`. Missing ones fail init with a [`DeviceRequirementsError`].
53    pub required_features: wgpu::Features,
54    /// Device limits the game would like but can do without. Each is requested up to what the
55    /// adapter offers and never fails init; read what you got from
56    /// `Graphics::device().limits()`. `None` (default) asks for nothing extra. E.g. `Some(Limits
57    /// { max_storage_buffers_per_shader_stage: 8, ..Limits::downlevel_defaults() })` gets 8 on
58    /// WebGPU and native, and whatever WebGL2 has there.
59    pub preferred_limits: Option<wgpu::Limits>,
60    /// Device features the game would like but can do without: the ones the adapter has are
61    /// enabled, the others skipped. Check `Graphics::device().features()`.
62    pub preferred_features: wgpu::Features,
63    /// What to do when the adapter lacks a [`Self::required_limits`] or
64    /// [`Self::required_features`] entry. `false` (default): [`Graphics::new`](crate::Graphics::new)
65    /// fails with a [`DeviceRequirementsError`]. `true`: create the device without them and
66    /// report the error through `Graphics::unmet_requirements()`, so the game can start and
67    /// explain itself (e.g. a "WebGPU required" screen on WebGL2).
68    pub continue_without_requirements: bool,
69    /// Last look at the device request before it is sent; see [`DeviceHook`].
70    pub device_hook: Option<DeviceHook>,
71    /// Draw into a fixed-size canvas of `(width, height)` pixels (e.g. `(320, 180)`) and
72    /// scale it onto the window with [`Self::scale_mode`], for crisp pixel art. `None`
73    /// (default) draws straight into the window.
74    ///
75    /// With a canvas, everything the painter draws (sprites, shapes, text, tilemaps, vector
76    /// art, 3D meshes) lands in it; [`Graphics::size`](crate::Graphics::size) and the
77    /// cameras report the canvas size, and the toolkit's input reports the mouse and touches
78    /// in canvas pixels. [`Graphics::window_size`](crate::Graphics::window_size) and
79    /// `Frame::view` still give the window.
80    pub virtual_resolution: Option<(u32, u32)>,
81    /// How the canvas is scaled onto the window; default [`ScaleMode::Integer`].
82    pub scale_mode: ScaleMode,
83    /// Filter for scaling the canvas; default [`ScaleFilter::Nearest`].
84    pub scale_filter: ScaleFilter,
85    /// Colour around the canvas; default black.
86    pub border_color: Color,
87}
88
89impl Default for GraphicsConfig {
90    fn default() -> Self {
91        Self {
92            vsync: true,
93            depth_format: None,
94            msaa_samples: 1,
95            required_limits: None,
96            required_features: wgpu::Features::empty(),
97            preferred_limits: None,
98            preferred_features: wgpu::Features::empty(),
99            continue_without_requirements: false,
100            device_hook: None,
101            virtual_resolution: None,
102            scale_mode: ScaleMode::Integer,
103            scale_filter: ScaleFilter::Nearest,
104            border_color: Color::BLACK,
105        }
106    }
107}
108
109impl fmt::Debug for GraphicsConfig {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.debug_struct("GraphicsConfig")
112            .field("vsync", &self.vsync)
113            .field("depth_format", &self.depth_format)
114            .field("msaa_samples", &self.msaa_samples)
115            .field("required_limits", &self.required_limits)
116            .field("required_features", &self.required_features)
117            .field("preferred_limits", &self.preferred_limits)
118            .field("preferred_features", &self.preferred_features)
119            .field(
120                "continue_without_requirements",
121                &self.continue_without_requirements,
122            )
123            .field("device_hook", &self.device_hook.is_some())
124            .field("virtual_resolution", &self.virtual_resolution)
125            .field("scale_mode", &self.scale_mode)
126            .field("scale_filter", &self.scale_filter)
127            .field("border_color", &self.border_color)
128            .finish()
129    }
130}
131
132/// One device limit the adapter can't provide.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct MissingLimit {
135    /// The `wgpu::Limits` field, e.g. `max_storage_buffers_per_shader_stage`.
136    pub name: &'static str,
137    pub requested: u64,
138    pub available: u64,
139}
140
141/// The adapter can't provide the limits or features the game asked for in
142/// [`GraphicsConfig`]. Returned (inside the `anyhow::Error`) by
143/// [`Graphics::new`](crate::Graphics::new); `downcast_ref` it to tell it apart from other init
144/// failures.
145///
146/// WebGL2 (the web fallback when the browser has no WebGPU) has no compute shaders and much
147/// lower limits, so a game that requires compute fails here there. To show a "WebGPU
148/// required" screen instead, set `continue_without_requirements` and check
149/// `Graphics::unmet_requirements()`, or ask with `preferred_limits` and check
150/// [`Graphics::supports_compute`](crate::Graphics::supports_compute) and
151/// `ctx.gfx.device().limits()` in `Game::init`.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct DeviceRequirementsError {
154    /// The adapter's name, as wgpu reports it.
155    pub adapter: String,
156    pub backend: wgpu::Backend,
157    pub missing_limits: Vec<MissingLimit>,
158    pub missing_features: wgpu::Features,
159}
160
161impl fmt::Display for DeviceRequirementsError {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(
164            f,
165            "adapter {:?} ({:?}) can't provide the requested device",
166            self.adapter, self.backend
167        )?;
168        if !self.missing_features.is_empty() {
169            write!(f, "; missing features: {:?}", self.missing_features)?;
170        }
171        for m in &self.missing_limits {
172            write!(
173                f,
174                "; limit {}: requested {}, available {}",
175                m.name, m.requested, m.available
176            )?;
177        }
178        Ok(())
179    }
180}
181
182impl std::error::Error for DeviceRequirementsError {}
183
184/// The limits the toolkit itself needs on `backend`.
185pub(crate) fn toolkit_limits(backend: wgpu::Backend) -> wgpu::Limits {
186    // WebGL2 can't meet even the downlevel limits (and has no compute, so no vello).
187    // Otherwise vello needs the standard (non-downlevel) limits; bump them when it's on.
188    if backend == wgpu::Backend::Gl {
189        wgpu::Limits::downlevel_webgl2_defaults()
190    } else if cfg!(feature = "vector") {
191        wgpu::Limits::default()
192    } else {
193        wgpu::Limits::downlevel_defaults()
194    }
195}
196
197/// Merge the toolkit's limits for `backend` with the game's, and the features likewise.
198/// Texture dimensions follow the adapter (as before: the toolkit asks for the largest the
199/// adapter supports) unless the game asked for more. Preferred limits and features are
200/// clamped to the adapter, so they never make the request fail; required ones are added as
201/// asked when `with_required` is set.
202pub(crate) fn merged_requirements(
203    config: &GraphicsConfig,
204    backend: wgpu::Backend,
205    adapter_limits: &wgpu::Limits,
206    adapter_features: wgpu::Features,
207    with_required: bool,
208) -> (wgpu::Limits, wgpu::Features) {
209    let mut limits = toolkit_limits(backend).using_resolution(adapter_limits.clone());
210    let mut features = config.preferred_features & adapter_features;
211    if let Some(preferred) = &config.preferred_limits {
212        limits =
213            limits.or_better_values_from(&preferred.clone().or_worse_values_from(adapter_limits));
214    }
215    if with_required {
216        if let Some(extra) = &config.required_limits {
217            limits = limits.or_better_values_from(extra);
218        }
219        features |= config.required_features;
220    }
221    (limits, features)
222}
223
224/// Check `limits` and `features` against what the adapter offers.
225pub(crate) fn check_requirements(
226    adapter: &str,
227    backend: wgpu::Backend,
228    adapter_limits: &wgpu::Limits,
229    adapter_features: wgpu::Features,
230    limits: &wgpu::Limits,
231    features: wgpu::Features,
232) -> Result<(), DeviceRequirementsError> {
233    let mut missing_limits = Vec::new();
234    limits.check_limits_with_fail_fn(adapter_limits, false, |name, requested, available| {
235        missing_limits.push(MissingLimit {
236            name,
237            requested,
238            available,
239        });
240    });
241    let missing_features = features - adapter_features;
242    if missing_limits.is_empty() && missing_features.is_empty() {
243        return Ok(());
244    }
245    Err(DeviceRequirementsError {
246        adapter: adapter.to_owned(),
247        backend,
248        missing_limits,
249        missing_features,
250    })
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn game_limits_raise_only_what_they_ask_for() {
259        let config = GraphicsConfig {
260            required_limits: Some(wgpu::Limits {
261                max_storage_buffers_per_shader_stage: 8,
262                ..wgpu::Limits::downlevel_defaults()
263            }),
264            ..Default::default()
265        };
266        let adapter = wgpu::Limits::default();
267        let (limits, _) = merged_requirements(
268            &config,
269            wgpu::Backend::Vulkan,
270            &adapter,
271            wgpu::Features::empty(),
272            true,
273        );
274        assert_eq!(limits.max_storage_buffers_per_shader_stage, 8);
275        let base = toolkit_limits(wgpu::Backend::Vulkan);
276        assert_eq!(limits.max_bind_groups, base.max_bind_groups);
277        // Texture sizes follow the adapter.
278        assert_eq!(
279            limits.max_texture_dimension_2d,
280            adapter.max_texture_dimension_2d
281        );
282    }
283
284    #[test]
285    fn missing_limits_and_features_are_named() {
286        let adapter = wgpu::Limits::downlevel_defaults();
287        let wanted = wgpu::Limits {
288            max_storage_buffers_per_shader_stage: 16,
289            ..adapter.clone()
290        };
291        let err = check_requirements(
292            "test",
293            wgpu::Backend::Vulkan,
294            &adapter,
295            wgpu::Features::empty(),
296            &wanted,
297            wgpu::Features::SHADER_F16,
298        )
299        .unwrap_err();
300        assert_eq!(
301            err.missing_limits,
302            vec![MissingLimit {
303                name: "max_storage_buffers_per_shader_stage",
304                requested: 16,
305                available: u64::from(adapter.max_storage_buffers_per_shader_stage),
306            }]
307        );
308        assert_eq!(err.missing_features, wgpu::Features::SHADER_F16);
309        let msg = err.to_string();
310        assert!(
311            msg.contains("max_storage_buffers_per_shader_stage"),
312            "{msg}"
313        );
314        assert!(msg.contains("SHADER_F16"), "{msg}");
315    }
316
317    #[test]
318    fn satisfiable_requirements_pass() {
319        let adapter = wgpu::Limits::default();
320        assert!(
321            check_requirements(
322                "test",
323                wgpu::Backend::Vulkan,
324                &adapter,
325                wgpu::Features::empty(),
326                &wgpu::Limits::downlevel_defaults(),
327                wgpu::Features::empty(),
328            )
329            .is_ok()
330        );
331    }
332
333    #[test]
334    fn preferred_limits_are_clamped_to_the_adapter() {
335        let config = GraphicsConfig {
336            preferred_limits: Some(wgpu::Limits {
337                max_storage_buffers_per_shader_stage: 8,
338                ..wgpu::Limits::downlevel_defaults()
339            }),
340            preferred_features: wgpu::Features::SHADER_F16 | wgpu::Features::TIMESTAMP_QUERY,
341            ..Default::default()
342        };
343        // A WebGPU-class adapter has the limit and one of the features.
344        let webgpu = wgpu::Limits::default();
345        let (limits, features) = merged_requirements(
346            &config,
347            wgpu::Backend::BrowserWebGpu,
348            &webgpu,
349            wgpu::Features::SHADER_F16,
350            true,
351        );
352        assert_eq!(limits.max_storage_buffers_per_shader_stage, 8);
353        assert_eq!(features, wgpu::Features::SHADER_F16);
354        assert!(
355            check_requirements(
356                "t",
357                wgpu::Backend::BrowserWebGpu,
358                &webgpu,
359                features,
360                &limits,
361                features
362            )
363            .is_ok()
364        );
365
366        // WebGL2 has no storage buffers at all: the request is clamped, never failing.
367        let gl = wgpu::Limits::downlevel_webgl2_defaults();
368        let (limits, features) = merged_requirements(
369            &config,
370            wgpu::Backend::Gl,
371            &gl,
372            wgpu::Features::empty(),
373            true,
374        );
375        assert_eq!(limits.max_storage_buffers_per_shader_stage, 0);
376        assert!(features.is_empty());
377        assert!(
378            check_requirements("t", wgpu::Backend::Gl, &gl, features, &limits, features).is_ok()
379        );
380    }
381
382    #[test]
383    fn without_required_leaves_out_only_the_required_part() {
384        let config = GraphicsConfig {
385            required_limits: Some(wgpu::Limits {
386                max_storage_buffers_per_shader_stage: 8,
387                ..wgpu::Limits::downlevel_defaults()
388            }),
389            required_features: wgpu::Features::SHADER_F16,
390            ..Default::default()
391        };
392        let gl = wgpu::Limits::downlevel_webgl2_defaults();
393        let (limits, features) = merged_requirements(
394            &config,
395            wgpu::Backend::Gl,
396            &gl,
397            wgpu::Features::empty(),
398            true,
399        );
400        assert!(
401            check_requirements(
402                "t",
403                wgpu::Backend::Gl,
404                &gl,
405                wgpu::Features::empty(),
406                &limits,
407                features
408            )
409            .is_err()
410        );
411        let (limits, features) = merged_requirements(
412            &config,
413            wgpu::Backend::Gl,
414            &gl,
415            wgpu::Features::empty(),
416            false,
417        );
418        assert!(
419            check_requirements(
420                "t",
421                wgpu::Backend::Gl,
422                &gl,
423                wgpu::Features::empty(),
424                &limits,
425                features
426            )
427            .is_ok()
428        );
429    }
430}