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}