Skip to main content

frust_render/
tier.rs

1//! The engine's adapter-capability gate.
2//!
3//! There is no render *tier* to select any more: `frust-engine`'s strip
4//! pipeline is the only renderer this crate contains, it is a plain dependency
5//! (`frust-render/Cargo.toml` declares no feature for it), and nothing chooses
6//! between renderers at build or run time. The tier enum, the env var that
7//! overrode it, the CLI flag that set that env var and the whole
8//! override/fallback vocabulary were retired with the choice they described:
9//! the vello-classic `Gpu` tier and the experimental `vello_cpu`-backed `Cpu`
10//! tier are both gone, so an override could only ever have named the renderer
11//! that was already running.
12//!
13//! What survives is the one question an adapter can still answer "no" to:
14//! [`engine_support`] checks a real adapter's downlevel flags against
15//! [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`] and refuses one that cannot run the
16//! engine, with the diagnosis its caller fails fast on. It is pure decision
17//! logic over [`TierCaps`] (a plain struct a caller builds from a real
18//! `wgpu::Adapter`'s downlevel flags + name), so it is unit-testable with fake
19//! caps and needs no GPU — mirroring `context.rs`'s split of pure decision vs.
20//! platform lookup (e.g. `effective_instance_flags`/`is_android_emulator`).
21//! `context::create_engine_surface` is the production call site (once per
22//! surface creation), with [`HeadlessRenderer::new`](crate::HeadlessRenderer)
23//! asking the identical question of the adapter it resolved.
24
25/// Plain-data capability inputs [`engine_support`] probes. Built from a real
26/// `wgpu::Adapter` at the surface-creation call site
27/// (`context::create_engine_surface`), but constructible by hand in tests with
28/// no GPU.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct TierCaps {
31    /// The adapter's `wgpu::DownlevelCapabilities::flags`.
32    pub downlevel_flags: wgpu::DownlevelFlags,
33    /// The adapter's `wgpu::AdapterInfo::name`, used only for diagnostics.
34    pub adapter_name: String,
35}
36
37/// The [`wgpu::DownlevelFlags`] the engine (`frust-engine`) cannot run
38/// without: **none of them**.
39///
40/// Deliberately empty. `frust-engine` is built to the
41/// GLES-3.0/WebGL2 downlevel ceiling by design rule — no compute pass, no
42/// storage buffer, no indirect draw (`frust-gpu`'s `lint` module enforces that
43/// against the engine's own shaders) — so it runs on adapters the deleted
44/// vello-classic renderer could not: it needed `COMPUTE_SHADERS` and
45/// `INDIRECT_EXECUTION`, precisely the pair the iOS Simulator's `Apple2` GPU
46/// family cannot supply. No adapter can raise a capability objection here.
47///
48/// Named as a constant rather than left implicit so a flag the engine ever
49/// does need is honoured by [`engine_support`] without a second edit.
50pub const ENGINE_REQUIRED_DOWNLEVEL_FLAGS: wgpu::DownlevelFlags = wgpu::DownlevelFlags::empty();
51
52/// An adapter that cannot run the engine: the refusal [`engine_support`]
53/// returns, naming the adapter and the flags it is missing.
54///
55/// Carries its own diagnosis through [`std::fmt::Display`] — the one message a
56/// refused adapter surfaces through, shared by the surface path
57/// (`context::create_engine_surface`) and the headless harness
58/// ([`crate::HeadlessRenderer`]), so both refuse in the same words.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct EngineUnsupported {
61    /// The adapter that was refused, for the diagnosis.
62    pub adapter_name: String,
63    /// The subset of [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`] this adapter does not
64    /// report. Never empty in a value that exists: an adapter missing nothing
65    /// is [`Ok`].
66    pub missing_flags: wgpu::DownlevelFlags,
67}
68
69impl std::fmt::Display for EngineUnsupported {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "frust-render: adapter `{}` cannot drive the frust-engine renderer — it lacks the \
74             required downlevel flags ({:?}; the engine requires \
75             {ENGINE_REQUIRED_DOWNLEVEL_FLAGS:?}), and this build contains no other renderer to \
76             fall back to.",
77            self.adapter_name, self.missing_flags,
78        )
79    }
80}
81
82impl std::error::Error for EngineUnsupported {}
83
84/// Whether `caps` reports every flag in `required`.
85///
86/// Factored out of [`engine_support`] so the refusal arm — otherwise
87/// unreachable in production, since [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`] is
88/// deliberately empty — stays exercised by a real call rather than a
89/// hand-built [`EngineUnsupported`] value: tests drive it with a non-empty
90/// injected `required` set instead.
91fn engine_support_with(
92    required: wgpu::DownlevelFlags,
93    caps: &TierCaps,
94) -> Result<(), EngineUnsupported> {
95    let missing_flags = required - caps.downlevel_flags;
96    if missing_flags.is_empty() {
97        return Ok(());
98    }
99    Err(EngineUnsupported {
100        adapter_name: caps.adapter_name.clone(),
101        missing_flags,
102    })
103}
104
105/// Whether `caps` can run the engine.
106///
107/// `Ok(())` when the adapter reports every flag in
108/// [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`] — which, that constant being
109/// deliberately empty, is every adapter that reaches the probe. The refusal
110/// arm is kept (rather than assumed away) so a flag ever added to the constant
111/// is honoured here without a second edit, and so the diagnosis for an adapter
112/// that genuinely cannot run the engine lives in one place.
113pub fn engine_support(caps: &TierCaps) -> Result<(), EngineUnsupported> {
114    engine_support_with(ENGINE_REQUIRED_DOWNLEVEL_FLAGS, caps)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn caps(flags: wgpu::DownlevelFlags) -> TierCaps {
122        TierCaps {
123            downlevel_flags: flags,
124            adapter_name: "fake adapter".to_string(),
125        }
126    }
127
128    #[test]
129    fn full_caps_run_the_engine() {
130        assert_eq!(engine_support(&caps(wgpu::DownlevelFlags::all())), Ok(()));
131    }
132
133    #[test]
134    fn a_downlevel_adapter_still_runs_the_engine() {
135        // The engine's reason for existing on the adapters the deleted
136        // vello-classic renderer refused: `ENGINE_REQUIRED_DOWNLEVEL_FLAGS` is
137        // empty, so no missing flag disqualifies it — including the
138        // `COMPUTE_SHADERS`/`INDIRECT_EXECUTION` pair the iOS Simulator lacks.
139        for flags in [
140            wgpu::DownlevelFlags::empty(),
141            wgpu::DownlevelFlags::all() - wgpu::DownlevelFlags::COMPUTE_SHADERS,
142            wgpu::DownlevelFlags::all() - wgpu::DownlevelFlags::INDIRECT_EXECUTION,
143        ] {
144            assert_eq!(
145                engine_support(&caps(flags)),
146                Ok(()),
147                "the engine was refused on {flags:?}, which requires no flag at all"
148            );
149        }
150    }
151
152    #[test]
153    fn engine_requires_no_downlevel_flags() {
154        let none = wgpu::DownlevelFlags::empty();
155        assert!(none.contains(ENGINE_REQUIRED_DOWNLEVEL_FLAGS));
156    }
157
158    /// The refusal arm, exercised against a requirement the constant does not
159    /// carry today: a flag added to [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`] later
160    /// must refuse the adapter that lacks it rather than be assumed away.
161    /// Drives the refusal through [`engine_support_with`] itself (with a
162    /// non-empty injected `required` set — [`ENGINE_REQUIRED_DOWNLEVEL_FLAGS`]
163    /// never exercises this arm in production) rather than hand-building an
164    /// [`EngineUnsupported`] value, and asserts the diagnosis names the
165    /// missing flag.
166    #[test]
167    fn a_missing_required_flag_is_refused_by_name() {
168        let required = wgpu::DownlevelFlags::COMPUTE_SHADERS;
169        let adapter = caps(wgpu::DownlevelFlags::all() - required);
170        let refusal = engine_support_with(required, &adapter)
171            .expect_err("an adapter missing a required flag must be refused");
172        assert_eq!(refusal.missing_flags, required);
173        let diagnosis = refusal.to_string();
174        assert!(diagnosis.contains("fake adapter"), "{diagnosis}");
175        assert!(diagnosis.contains("COMPUTE_SHADERS"), "{diagnosis}");
176        assert!(diagnosis.contains("frust-engine"), "{diagnosis}");
177    }
178}