Skip to main content

concinnity_engine/gfx/
quality_preset.rs

1// src/gfx/quality_preset.rs
2//
3// The master "Graphics Quality" preset and how it resolves to a performance
4// ceiling over a world's authored look. The preset is persisted in
5// GraphicsSettings; at init it produces a QualityCeiling that clamps the
6// perf-relevant Tier-A settings DOWN where the chosen tier (or detected GPU,
7// under Auto) cannot honor them. A ceiling never turns a feature on -- it only
8// reduces -- so a world authored conservatively is never "upgraded", and an
9// explicit per-row user override always wins over the ceiling (applied by the
10// caller). The component defaults author the top-tier look, so under `Auto`
11// this is what actually picks a world's quality: every tier below Ultra is
12// reached by clamping, not by opting in. This keeps the per-field `None = use the world's value` contract: the
13// only thing persisted is the one preset marker, not a bake of every field.
14
15use serde::{Deserialize, Serialize};
16
17use crate::components::{
18    AaMode, ReflectionBlurResolution, ShadowUpdate, SsgiResolution, UpscaleQuality,
19};
20use crate::gfx::backend::{GpuProfile, GpuTier};
21
22/// Persisted master graphics-quality choice. `Auto` resolves from the detected
23/// GPU tier each launch; a named tier (Low..Ultra) is a fixed ceiling; `Custom`
24/// imposes no ceiling (the user's per-row overrides drive). In GraphicsSettings
25/// a `None` (never persisted) means "never configured": the first launch seeds
26/// `Auto` and saves once.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub enum QualityPreset {
29    /// Resolve the ceiling from the GPU tier detected at launch.
30    Auto,
31    /// Fixed low-tier ceiling.
32    Low,
33    /// Fixed mid-tier ceiling.
34    Medium,
35    /// Fixed high-tier ceiling.
36    High,
37    /// Fixed top-tier ceiling; the only one that permits ray-traced reflections.
38    Ultra,
39    /// No ceiling: the world's authored look and the per-row overrides stand.
40    Custom,
41}
42
43// A ceiling on the perf-relevant Tier-A settings: which feature toggles are
44// permitted (`false` forces the feature off), and the least aggressive render
45// scale allowed. A ceiling only reduces quality; it cannot enable a feature the
46// world did not author.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub(crate) struct QualityCeiling {
49    // The most aggressive anti-aliasing mode the tier permits. Clamps a world's
50    // authored mode DOWN by cost rank (Off < FXAA < TAA): a lower tier can force
51    // TAA to FXAA (skipping the velocity pre-pass) but never enables a mode the
52    // world did not author. The no-ceiling value is `Taa` (the maximum), so an
53    // authored mode always stands under it.
54    pub aa_mode: AaMode,
55    pub ssao: bool,
56    pub ssr: bool,
57    pub ray_traced_reflections: bool,
58    pub ssgi: bool,
59    pub auto_exposure: bool,
60    // The minimum upscaling the ceiling forces: the effective render scale is the
61    // more aggressive (lower internal resolution) of the world's choice and this.
62    // `Quality` (the least aggressive) means "no forced upscaling" -- the world's
63    // choice stands.
64    pub(crate) min_upscale: UpscaleQuality,
65    // Caps on the SSGI gather sub-quality (they only bite where `ssgi` is
66    // permitted): the finest gather resolution, and the most rays / ray-march
67    // steps per pixel. Each clamps DOWN: the effective value is the coarser
68    // resolution / smaller count of the world's choice and the cap. The
69    // no-ceiling values are the engine maxima (`Full`, 32, 64), so a world's
70    // authored value always stands under them.
71    pub(crate) ssgi_resolution: SsgiResolution,
72    pub(crate) ssgi_rays: u32,
73    pub(crate) ssgi_steps: u32,
74    // Cap on the roughness-aware reflection blur resolution (only bites where
75    // `ssr` or `ray_traced_reflections` is permitted): the finest blur the tier
76    // allows, clamping the world's choice coarser. The no-ceiling value is `Full`
77    // (finest), so a world's authored value always stands under it.
78    pub(crate) reflection_blur_resolution: ReflectionBlurResolution,
79    // Cap on the shadow-map cascade resolution in texels (restart-required): the
80    // effective size is the smaller of the world's choice and this cap. The
81    // no-ceiling value is `u32::MAX`, so a world's authored size always stands.
82    pub shadow_map_size: u32,
83    // Whether the tier permits the `EveryFrame` shadow re-render cadence (live).
84    // When false the cadence is clamped to the cheaper `Hybrid`; the no-ceiling
85    // value is `true`, so a world's authored cadence always stands.
86    pub(crate) allow_every_frame_shadows: bool,
87    // Cap on the scene sampler's max anisotropic-filtering degree
88    // (restart-required): the effective degree is the smaller of the world's
89    // choice and this cap. The no-ceiling value is `ANISO_MAX` (16, the GPU
90    // maximum), so a world's authored degree always stands.
91    pub(crate) anisotropy: u32,
92    // Cap on the shadow distance in world units (live): the effective distance is
93    // the smaller of the world's choice and this cap. The no-ceiling value is
94    // `u32::MAX`, so a world's authored distance always stands. A lower tier
95    // shadows a shorter distance (cheaper, and sharper per texel).
96    pub shadow_distance: u32,
97    // Cap on the shadow cascade count (live): the effective count is the smaller
98    // of the world's choice and this cap. The no-ceiling value is `4` (the
99    // maximum), so a world's authored count always stands. A lower tier renders
100    // fewer cascades (one shadow-map render saved per dropped cascade).
101    pub shadow_cascades: u32,
102    // Cap on the number of distinct planar reflection planes that render a mirror
103    // pass each frame (restart-required -- the mirror targets are allocated at
104    // backend init). There is no world-authored value: the engine baseline is the
105    // capacity maximum (`PLANAR_PLANES_MAX`), so this cap is the effective budget
106    // directly. The no-ceiling value is the maximum, so a full-budget world stands.
107    // A lower tier renders fewer full render-res MSAA mirror passes (saving VRAM +
108    // GPU cost); reflectors past the budget fall back to the box-projected probe
109    // cube.
110    pub(crate) planar_reflection_planes: u32,
111}
112
113// The coarser (higher render-resolution divisor) of two SSGI resolutions, the
114// resolution analogue of `more_aggressive_upscale`. Used to clamp a world's
115// gather resolution under a ceiling without ever making it finer.
116pub(crate) fn coarser_ssgi_resolution(a: SsgiResolution, b: SsgiResolution) -> SsgiResolution {
117    if a.scale_divisor() >= b.scale_divisor() {
118        a
119    } else {
120        b
121    }
122}
123
124// The authored anti-aliasing mode clamped under a ceiling: the cheaper of the
125// world's mode and the cap, by the ascending-cost rank `aa_mode_index` defines
126// (Off < FXAA < TAA). Never makes AA more aggressive than the world authored.
127pub(crate) fn clamp_aa_mode(authored: AaMode, cap: AaMode) -> AaMode {
128    use crate::gfx::settings::aa_mode_index;
129    if aa_mode_index(authored) <= aa_mode_index(cap) {
130        authored
131    } else {
132        cap
133    }
134}
135
136// The coarser of two reflection-blur resolutions (the SSGI helper's sibling for
137// the reflection blur enum).
138pub(crate) fn coarser_reflection_blur(
139    a: ReflectionBlurResolution,
140    b: ReflectionBlurResolution,
141) -> ReflectionBlurResolution {
142    if a.scale_divisor() >= b.scale_divisor() {
143        a
144    } else {
145        b
146    }
147}
148
149// No ceiling: everything permitted, no forced upscaling. The resolved ceiling
150// for `Custom` alone -- every `Auto` tier, unclassified hardware included,
151// resolves to a named tier.
152// The engine maxima for the SSGI sub-quality caps, used wherever a tier imposes
153// no SSGI ceiling: `Full` gather resolution, and the upper clamp bounds the
154// gather honours (rays <= 32, steps <= 64). A world's authored value always
155// stands under these.
156const SSGI_RES_MAX: SsgiResolution = SsgiResolution::Full;
157const SSGI_RAYS_MAX: u32 = 32;
158const SSGI_STEPS_MAX: u32 = 64;
159// `Full` (finest) is the no-cap reflection-blur resolution: a world's choice
160// always stands coarser-or-equal under it.
161const REFLECTION_BLUR_MAX: ReflectionBlurResolution = ReflectionBlurResolution::Full;
162// `u32::MAX` is the no-cap shadow-map resolution: a world's authored size always
163// stands smaller-or-equal under it.
164const SHADOW_SIZE_MAX: u32 = u32::MAX;
165// `16` is the no-cap anisotropy degree -- the maximum every backend's API
166// guarantees -- so a world's authored degree always stands smaller-or-equal
167// under it.
168const ANISO_MAX: u32 = 16;
169// `u32::MAX` is the no-cap shadow distance: a world's authored distance always
170// stands smaller-or-equal under it.
171const SHADOW_DIST_MAX: u32 = u32::MAX;
172// `4` is the no-cap shadow cascade count (the engine maximum = NUM_SHADOW_CASCADES):
173// a world's authored count always stands smaller-or-equal under it.
174const SHADOW_CASCADES_MAX: u32 = 4;
175// The no-cap planar reflection plane budget: the engine capacity ceiling every
176// backend sizes its mirror allocations to. Sourced from the single
177// `gfx::planar_reflection` constant so a capacity bump flows here automatically.
178const PLANAR_PLANES_MAX: u32 = crate::gfx::planar_reflection::MAX_PLANAR_PLANES as u32;
179
180// The world's shadow re-render cadence clamped under the ceiling: a tier that
181// disallows `EveryFrame` forces the cheaper `Hybrid`; otherwise the authored
182// cadence stands. Never raises (`Hybrid` -> `EveryFrame`).
183pub(crate) fn clamp_shadow_update(
184    authored: ShadowUpdate,
185    ceiling: &QualityCeiling,
186) -> ShadowUpdate {
187    if ceiling.allow_every_frame_shadows {
188        authored
189    } else {
190        ShadowUpdate::Hybrid
191    }
192}
193
194const NONE: QualityCeiling = QualityCeiling {
195    aa_mode: AaMode::Taa,
196    ssao: true,
197    ssr: true,
198    ray_traced_reflections: true,
199    ssgi: true,
200    auto_exposure: true,
201    min_upscale: UpscaleQuality::Quality,
202    ssgi_resolution: SSGI_RES_MAX,
203    ssgi_rays: SSGI_RAYS_MAX,
204    ssgi_steps: SSGI_STEPS_MAX,
205    reflection_blur_resolution: REFLECTION_BLUR_MAX,
206    shadow_map_size: SHADOW_SIZE_MAX,
207    allow_every_frame_shadows: true,
208    anisotropy: ANISO_MAX,
209    shadow_distance: SHADOW_DIST_MAX,
210    shadow_cascades: SHADOW_CASCADES_MAX,
211    planar_reflection_planes: PLANAR_PLANES_MAX,
212};
213const LOW: QualityCeiling = QualityCeiling {
214    aa_mode: AaMode::Fxaa,
215    ssao: false,
216    ssr: false,
217    ray_traced_reflections: false,
218    ssgi: false,
219    auto_exposure: true,
220    min_upscale: UpscaleQuality::Performance,
221    ssgi_resolution: SsgiResolution::Quarter,
222    ssgi_rays: 4,
223    ssgi_steps: 8,
224    reflection_blur_resolution: ReflectionBlurResolution::Quarter,
225    shadow_map_size: 1024,
226    allow_every_frame_shadows: false,
227    anisotropy: 4,
228    shadow_distance: 40,
229    shadow_cascades: 2,
230    // Integrated / weakest tier: keep only the two most impactful mirror planes
231    // (e.g. a floor plus one wall); further reflectors take the probe cube.
232    planar_reflection_planes: 2,
233};
234const MEDIUM: QualityCeiling = QualityCeiling {
235    aa_mode: AaMode::Taa,
236    ssao: true,
237    ssr: false,
238    ray_traced_reflections: false,
239    ssgi: false,
240    auto_exposure: true,
241    min_upscale: UpscaleQuality::Balanced,
242    ssgi_resolution: SsgiResolution::Half,
243    ssgi_rays: 8,
244    ssgi_steps: 12,
245    reflection_blur_resolution: ReflectionBlurResolution::Half,
246    shadow_map_size: 2048,
247    allow_every_frame_shadows: false,
248    anisotropy: 8,
249    shadow_distance: 80,
250    shadow_cascades: 3,
251    // Entry discrete: one more mirror plane than Low before the probe fallback.
252    planar_reflection_planes: 3,
253};
254const HIGH: QualityCeiling = QualityCeiling {
255    aa_mode: AaMode::Taa,
256    ssao: true,
257    ssr: true,
258    ray_traced_reflections: false,
259    ssgi: true,
260    auto_exposure: true,
261    min_upscale: UpscaleQuality::Quality,
262    ssgi_resolution: SsgiResolution::Half,
263    ssgi_rays: 8,
264    ssgi_steps: 12,
265    reflection_blur_resolution: ReflectionBlurResolution::Half,
266    shadow_map_size: 4096,
267    allow_every_frame_shadows: false,
268    anisotropy: 16,
269    shadow_distance: 160,
270    shadow_cascades: 4,
271    // Mid discrete and up run the full mirror-plane budget, matching the flat
272    // pre-scaling default, so a capable GPU is never downgraded.
273    planar_reflection_planes: PLANAR_PLANES_MAX,
274};
275const ULTRA: QualityCeiling = QualityCeiling {
276    aa_mode: AaMode::Taa,
277    ssao: true,
278    ssr: true,
279    ray_traced_reflections: true,
280    ssgi: true,
281    auto_exposure: true,
282    min_upscale: UpscaleQuality::Quality,
283    ssgi_resolution: SSGI_RES_MAX,
284    ssgi_rays: SSGI_RAYS_MAX,
285    ssgi_steps: SSGI_STEPS_MAX,
286    reflection_blur_resolution: REFLECTION_BLUR_MAX,
287    shadow_map_size: SHADOW_SIZE_MAX,
288    allow_every_frame_shadows: true,
289    anisotropy: ANISO_MAX,
290    shadow_distance: SHADOW_DIST_MAX,
291    shadow_cascades: SHADOW_CASCADES_MAX,
292    planar_reflection_planes: PLANAR_PLANES_MAX,
293};
294
295// The active ceiling for the persisted preset and detected GPU. `Auto` maps the
296// GPU tier to a named tier; `Custom` imposes no ceiling; a named tier is fixed.
297pub(crate) fn resolve_ceiling(preset: QualityPreset, profile: &GpuProfile) -> QualityCeiling {
298    match preset {
299        QualityPreset::Custom => NONE,
300        QualityPreset::Low => LOW,
301        QualityPreset::Medium => MEDIUM,
302        QualityPreset::High => HIGH,
303        QualityPreset::Ultra => ULTRA,
304        QualityPreset::Auto => auto_tier(profile).map_or(NONE, |(_, ceiling)| ceiling),
305    }
306}
307
308// The more aggressive (lower internal resolution) of two upscale qualities,
309// ordered by `settings::render_scale_index` (Quality < Balanced < Performance <
310// UltraPerformance). Used to clamp a world's render scale under a ceiling's
311// `min_upscale` without ever raising it.
312pub(crate) fn more_aggressive_upscale(a: UpscaleQuality, b: UpscaleQuality) -> UpscaleQuality {
313    use crate::gfx::settings::render_scale_index;
314    if render_scale_index(a) >= render_scale_index(b) {
315        a
316    } else {
317        b
318    }
319}
320
321impl QualityPreset {
322    /// The presets in menu-cycle order. The settings-menu master row cycles
323    /// through these; `GRAPHICS_QUALITY_OPTIONS` in `gfx::settings` holds the
324    /// matching display labels in the same order (locked by a test there).
325    pub const ALL: [QualityPreset; 6] = [
326        Self::Auto,
327        Self::Low,
328        Self::Medium,
329        Self::High,
330        Self::Ultra,
331        Self::Custom,
332    ];
333
334    // The display name for this preset (the bare label, without an `Auto`
335    // tier suffix; see `preset_label`).
336    pub(crate) fn name(self) -> &'static str {
337        match self {
338            Self::Auto => "Auto",
339            Self::Low => "Low",
340            Self::Medium => "Medium",
341            Self::High => "High",
342            Self::Ultra => "Ultra",
343            Self::Custom => "Custom",
344        }
345    }
346}
347
348// The cycle index of a preset, and the preset at an index, over `ALL`. The
349// master settings row cycles indices; these convert to and from the live
350// `QualityPreset`. An out-of-range index falls back to `Auto`.
351pub(crate) fn preset_index(preset: QualityPreset) -> usize {
352    QualityPreset::ALL
353        .iter()
354        .position(|&p| p == preset)
355        .unwrap_or(0)
356}
357pub(crate) fn preset_at(index: usize) -> QualityPreset {
358    QualityPreset::ALL
359        .get(index)
360        .copied()
361        .unwrap_or(QualityPreset::Auto)
362}
363
364// The named tier `Auto` resolves to on this GPU, for the menu label (e.g.
365// "Auto (High)"). Always `Some` today; the `Option` keeps the label honest if a
366// future tier is left unmapped.
367pub(crate) fn auto_resolved_name(profile: &GpuProfile) -> Option<&'static str> {
368    auto_tier(profile).map(|(name, _)| name)
369}
370
371// The named tier `Auto` resolves to on this GPU, as (label, ceiling). One
372// table, so the menu label can never disagree with the ceiling actually
373// applied.
374fn auto_tier(profile: &GpuProfile) -> Option<(&'static str, QualityCeiling)> {
375    match profile.tier {
376        // A discrete GPU whose driver would not report its VRAM. The world's
377        // defaults describe the top-tier look, so declining to clamp here would
378        // hand an unclassified card the whole stack; the mid tier keeps the
379        // cheap wins (TAA, ambient occlusion) and drops what an unknown GPU
380        // might not carry, matching `GpuProfile::UNKNOWN`'s fail-safe intent.
381        GpuTier::Unknown => Some(("Medium", MEDIUM)),
382        GpuTier::Integrated => Some(("Low", LOW)),
383        GpuTier::EntryDiscrete => Some(("Medium", MEDIUM)),
384        GpuTier::MidDiscrete => Some(("High", HIGH)),
385        GpuTier::HighDiscrete => Some(("Ultra", ULTRA)),
386    }
387}
388
389// The master row's display text for a preset: a named tier shows its own name,
390// while `Auto` annotates the tier it resolved to on the detected GPU (e.g.
391// "Auto (High)") so the user can see what the auto-config chose.
392pub(crate) fn preset_label(preset: QualityPreset, profile: &GpuProfile) -> String {
393    match preset {
394        QualityPreset::Auto => match auto_resolved_name(profile) {
395            Some(tier) => format!("Auto ({tier})"),
396            None => "Auto".to_string(),
397        },
398        other => other.name().to_string(),
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    fn profile_with_tier(tier: GpuTier) -> GpuProfile {
407        GpuProfile {
408            tier,
409            ..GpuProfile::UNKNOWN
410        }
411    }
412
413    #[test]
414    fn only_custom_imposes_no_ceiling() {
415        // Custom never clamps, regardless of hardware.
416        assert_eq!(
417            resolve_ceiling(
418                QualityPreset::Custom,
419                &profile_with_tier(GpuTier::Integrated)
420            ),
421            NONE
422        );
423        // Auto on an unclassified GPU takes the mid tier rather than handing it
424        // the whole stack the component defaults author.
425        assert_eq!(
426            resolve_ceiling(QualityPreset::Auto, &profile_with_tier(GpuTier::Unknown)),
427            MEDIUM
428        );
429    }
430
431    #[test]
432    fn auto_maps_tier_to_named_ceiling() {
433        assert_eq!(
434            resolve_ceiling(QualityPreset::Auto, &profile_with_tier(GpuTier::Integrated)),
435            LOW
436        );
437        assert_eq!(
438            resolve_ceiling(
439                QualityPreset::Auto,
440                &profile_with_tier(GpuTier::EntryDiscrete)
441            ),
442            MEDIUM
443        );
444        assert_eq!(
445            resolve_ceiling(
446                QualityPreset::Auto,
447                &profile_with_tier(GpuTier::MidDiscrete)
448            ),
449            HIGH
450        );
451        assert_eq!(
452            resolve_ceiling(
453                QualityPreset::Auto,
454                &profile_with_tier(GpuTier::HighDiscrete)
455            ),
456            ULTRA
457        );
458    }
459
460    #[test]
461    fn named_presets_resolve_independent_of_hardware() {
462        // A named preset ignores the GPU tier.
463        let weak = profile_with_tier(GpuTier::Integrated);
464        assert_eq!(resolve_ceiling(QualityPreset::Low, &weak), LOW);
465        assert_eq!(resolve_ceiling(QualityPreset::Ultra, &weak), ULTRA);
466    }
467
468    #[test]
469    fn ceilings_are_monotonic_in_tier() {
470        // Each tier permits a superset of the next-lower tier's features, so a
471        // higher tier never disables something a lower tier allows.
472        let order = [LOW, MEDIUM, HIGH, ULTRA];
473        for pair in order.windows(2) {
474            let (lo, hi) = (pair[0], pair[1]);
475            for (lo_on, hi_on) in [
476                (lo.ssao, hi.ssao),
477                (lo.ssr, hi.ssr),
478                (lo.ray_traced_reflections, hi.ray_traced_reflections),
479                (lo.ssgi, hi.ssgi),
480                (lo.auto_exposure, hi.auto_exposure),
481            ] {
482                assert!(!lo_on || hi_on, "a higher tier dropped a feature");
483            }
484            // The AA-mode cap rises (or holds) with the tier: a higher tier
485            // never permits a less aggressive (cheaper) anti-aliasing mode.
486            assert!(
487                crate::gfx::settings::aa_mode_index(lo.aa_mode)
488                    <= crate::gfx::settings::aa_mode_index(hi.aa_mode),
489                "a higher tier capped AA at a cheaper mode"
490            );
491            // And never forces more aggressive upscaling than a lower tier.
492            assert_eq!(
493                more_aggressive_upscale(lo.min_upscale, hi.min_upscale),
494                lo.min_upscale
495            );
496            // The SSGI sub-quality caps rise (or hold) with the tier too: a
497            // higher tier never permits fewer rays / steps or a coarser gather.
498            assert!(lo.ssgi_rays <= hi.ssgi_rays, "ssgi_rays cap dropped");
499            assert!(lo.ssgi_steps <= hi.ssgi_steps, "ssgi_steps cap dropped");
500            assert_eq!(
501                coarser_ssgi_resolution(lo.ssgi_resolution, hi.ssgi_resolution),
502                lo.ssgi_resolution,
503                "a higher tier permitted a coarser SSGI gather"
504            );
505            assert_eq!(
506                coarser_reflection_blur(
507                    lo.reflection_blur_resolution,
508                    hi.reflection_blur_resolution
509                ),
510                lo.reflection_blur_resolution,
511                "a higher tier permitted a coarser reflection blur"
512            );
513            // The shadow caps rise (or hold) with the tier: a higher tier never
514            // permits a smaller shadow map or forbids a cadence a lower tier
515            // allowed.
516            assert!(
517                lo.shadow_map_size <= hi.shadow_map_size,
518                "shadow_map_size cap dropped"
519            );
520            assert!(
521                !lo.allow_every_frame_shadows || hi.allow_every_frame_shadows,
522                "a higher tier forbade the EveryFrame shadow cadence"
523            );
524            // The anisotropy cap rises (or holds) with the tier too.
525            assert!(lo.anisotropy <= hi.anisotropy, "anisotropy cap dropped");
526            // The shadow-distance cap rises (or holds) with the tier too.
527            assert!(
528                lo.shadow_distance <= hi.shadow_distance,
529                "shadow_distance cap dropped"
530            );
531            // The shadow cascade-count cap rises (or holds) with the tier too.
532            assert!(
533                lo.shadow_cascades <= hi.shadow_cascades,
534                "shadow_cascades cap dropped"
535            );
536            // The planar reflection plane budget rises (or holds) with the tier too.
537            assert!(
538                lo.planar_reflection_planes <= hi.planar_reflection_planes,
539                "planar plane budget cap dropped"
540            );
541        }
542    }
543
544    // Callers clamp with `authored.min(ceiling.field)`, so what each tier
545    // actually promises is the cap value itself.
546    #[test]
547    fn tier_caps_bound_the_shadow_and_texture_knobs() {
548        use crate::components::ShadowUpdate;
549        // No ceiling (Custom / Ultra) leaves a world's authored values alone.
550        let none = resolve_ceiling(QualityPreset::Custom, &GpuProfile::UNKNOWN);
551        assert_eq!(none.shadow_map_size, SHADOW_SIZE_MAX);
552        assert_eq!(none.anisotropy, ANISO_MAX);
553        assert_eq!(none.shadow_distance, SHADOW_DIST_MAX);
554        assert_eq!(none.shadow_cascades, SHADOW_CASCADES_MAX);
555        assert_eq!(
556            clamp_shadow_update(ShadowUpdate::EveryFrame, &none),
557            ShadowUpdate::EveryFrame
558        );
559
560        // Low caps every knob hard and forces the cheaper Hybrid cadence.
561        let low = resolve_ceiling(QualityPreset::Low, &GpuProfile::UNKNOWN);
562        assert_eq!(low.shadow_map_size, 1024);
563        assert_eq!(low.anisotropy, 4);
564        assert_eq!(low.shadow_distance, 40);
565        assert_eq!(low.shadow_cascades, 2);
566        assert_eq!(
567            clamp_shadow_update(ShadowUpdate::EveryFrame, &low),
568            ShadowUpdate::Hybrid
569        );
570        // The cadence clamp never raises Hybrid to EveryFrame.
571        assert_eq!(
572            clamp_shadow_update(ShadowUpdate::Hybrid, &none),
573            ShadowUpdate::Hybrid
574        );
575
576        let medium = resolve_ceiling(QualityPreset::Medium, &GpuProfile::UNKNOWN);
577        assert_eq!(medium.shadow_cascades, 3);
578    }
579
580    #[test]
581    fn planar_plane_budget_scales_with_tier_within_capacity() {
582        // No ceiling (Custom / Ultra) permits the full engine capacity, matching
583        // the flat pre-scaling default so capable GPUs are never downgraded.
584        let none = resolve_ceiling(QualityPreset::Custom, &GpuProfile::UNKNOWN);
585        assert_eq!(none.planar_reflection_planes, PLANAR_PLANES_MAX);
586        let ultra = resolve_ceiling(QualityPreset::Ultra, &GpuProfile::UNKNOWN);
587        assert_eq!(ultra.planar_reflection_planes, PLANAR_PLANES_MAX);
588        // High (mid discrete under Auto) also runs the full budget.
589        let high = resolve_ceiling(QualityPreset::High, &GpuProfile::UNKNOWN);
590        assert_eq!(high.planar_reflection_planes, PLANAR_PLANES_MAX);
591        // Lower tiers reduce the budget but keep at least one sharp mirror plane,
592        // and never exceed the capacity every backend's allocation is sized to.
593        for preset in [QualityPreset::Low, QualityPreset::Medium] {
594            let c = resolve_ceiling(preset, &GpuProfile::UNKNOWN);
595            assert!(
596                c.planar_reflection_planes >= 1,
597                "{preset:?} dropped all planes"
598            );
599            assert!(
600                c.planar_reflection_planes < PLANAR_PLANES_MAX,
601                "{preset:?} did not reduce the budget"
602            );
603        }
604    }
605
606    #[test]
607    fn ssgi_caps_clamp_down_only() {
608        // The no-ceiling values are the engine maxima, so any authored value
609        // stands under them.
610        assert_eq!(NONE.ssgi_rays, 32);
611        assert_eq!(NONE.ssgi_steps, 64);
612        assert_eq!(NONE.ssgi_resolution, SsgiResolution::Full);
613        // The coarser-resolution helper picks the higher divisor (lower quality),
614        // and an equal input is returned as-is.
615        assert_eq!(
616            coarser_ssgi_resolution(SsgiResolution::Full, SsgiResolution::Quarter),
617            SsgiResolution::Quarter
618        );
619        assert_eq!(
620            coarser_ssgi_resolution(SsgiResolution::Half, SsgiResolution::Half),
621            SsgiResolution::Half
622        );
623        // Ultra imposes the maxima (no clamp); Low caps hard.
624        let ultra = resolve_ceiling(QualityPreset::Ultra, &GpuProfile::UNKNOWN);
625        assert_eq!(ultra.ssgi_rays, 32);
626        let low = resolve_ceiling(QualityPreset::Low, &GpuProfile::UNKNOWN);
627        assert_eq!(low.ssgi_rays, 4);
628        assert_eq!(low.ssgi_resolution, SsgiResolution::Quarter);
629    }
630
631    #[test]
632    fn low_disables_the_expensive_effects() {
633        // Resolve through the public path so the assertions are on a runtime
634        // value, not the `const LOW` directly.
635        let low = resolve_ceiling(QualityPreset::Low, &GpuProfile::UNKNOWN);
636        assert!(!low.ssr);
637        assert!(!low.ssgi);
638        assert!(!low.ray_traced_reflections);
639        assert!(!low.ssao);
640        // Low caps anti-aliasing at FXAA: it keeps edges smooth nearly for free
641        // but skips TAA's velocity pre-pass and history buffer. Auto-exposure is
642        // cheap enough to keep on.
643        assert_eq!(low.aa_mode, AaMode::Fxaa);
644        assert!(low.auto_exposure);
645    }
646
647    #[test]
648    fn aa_mode_clamps_down_only() {
649        // The no-ceiling cap is TAA (the maximum), so any authored mode stands.
650        assert_eq!(NONE.aa_mode, AaMode::Taa);
651        assert_eq!(clamp_aa_mode(AaMode::Taa, NONE.aa_mode), AaMode::Taa);
652        // A lower cap forces a more expensive authored mode down to it, but
653        // never raises a cheaper authored mode up.
654        assert_eq!(clamp_aa_mode(AaMode::Taa, AaMode::Fxaa), AaMode::Fxaa);
655        assert_eq!(clamp_aa_mode(AaMode::Fxaa, AaMode::Taa), AaMode::Fxaa);
656        assert_eq!(clamp_aa_mode(AaMode::Fxaa, AaMode::Off), AaMode::Off);
657        assert_eq!(clamp_aa_mode(AaMode::Off, AaMode::Taa), AaMode::Off);
658        // Ultra imposes the maximum (no clamp); Low caps at FXAA.
659        let ultra = resolve_ceiling(QualityPreset::Ultra, &GpuProfile::UNKNOWN);
660        assert_eq!(ultra.aa_mode, AaMode::Taa);
661        let low = resolve_ceiling(QualityPreset::Low, &GpuProfile::UNKNOWN);
662        assert_eq!(low.aa_mode, AaMode::Fxaa);
663    }
664
665    #[test]
666    fn preset_index_and_at_round_trip() {
667        for p in QualityPreset::ALL {
668            assert_eq!(preset_at(preset_index(p)), p);
669        }
670        // Auto leads the cycle order; an out-of-range index falls back to Auto.
671        assert_eq!(preset_at(0), QualityPreset::Auto);
672        assert_eq!(preset_at(99), QualityPreset::Auto);
673    }
674
675    #[test]
676    fn auto_label_annotates_the_resolved_tier() {
677        // Auto shows the tier it resolved to, so the user sees the auto choice.
678        assert_eq!(
679            preset_label(
680                QualityPreset::Auto,
681                &profile_with_tier(GpuTier::MidDiscrete)
682            ),
683            "Auto (High)"
684        );
685        assert_eq!(
686            preset_label(QualityPreset::Auto, &profile_with_tier(GpuTier::Integrated)),
687            "Auto (Low)"
688        );
689        // An unclassified GPU resolves to the mid tier, and says so.
690        assert_eq!(
691            preset_label(QualityPreset::Auto, &profile_with_tier(GpuTier::Unknown)),
692            "Auto (Medium)"
693        );
694        // A named preset is just its own name, hardware-independent.
695        assert_eq!(
696            preset_label(
697                QualityPreset::Ultra,
698                &profile_with_tier(GpuTier::Integrated)
699            ),
700            "Ultra"
701        );
702        // The Auto suffix tracks the resolved ceiling.
703        for tier in [
704            GpuTier::Integrated,
705            GpuTier::EntryDiscrete,
706            GpuTier::MidDiscrete,
707            GpuTier::HighDiscrete,
708        ] {
709            let profile = profile_with_tier(tier);
710            let suffix = auto_resolved_name(&profile).unwrap();
711            assert_eq!(
712                preset_label(QualityPreset::Auto, &profile),
713                format!("Auto ({suffix})")
714            );
715        }
716    }
717
718    #[test]
719    fn more_aggressive_picks_the_lower_resolution() {
720        // Higher index = more aggressive (lower internal resolution).
721        assert_eq!(
722            more_aggressive_upscale(UpscaleQuality::Quality, UpscaleQuality::Performance),
723            UpscaleQuality::Performance
724        );
725        assert_eq!(
726            more_aggressive_upscale(UpscaleQuality::UltraPerformance, UpscaleQuality::Balanced),
727            UpscaleQuality::UltraPerformance
728        );
729        // Equal inputs return that quality; a ceiling of Quality never raises.
730        assert_eq!(
731            more_aggressive_upscale(UpscaleQuality::Balanced, UpscaleQuality::Quality),
732            UpscaleQuality::Balanced
733        );
734    }
735}