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}