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