Skip to main content

concinnity_core/components/
graphics_config.rs

1// World rendering configuration schema.
2
3/// How often each cascaded-shadow-map slice is re-rendered. The shadow pass
4/// re-rasterizes all scene geometry into every cascade, so it is one of the
5/// heavier passes; updating distant cascades less often cuts that cost.
6///
7/// `hybrid` (the default) re-renders the nearest cascade every frame (so close
8/// shadows stay crisp) and rotates through the farther cascades one per frame.
9/// Distant shadows then lag a few frames while the camera moves, which is
10/// imperceptible at that range. `every_frame` re-renders all cascades every
11/// frame: pick it for scenes with fast-moving shadow casters where even distant
12/// shadow lag is unacceptable. Each cascade is always primed (rendered once)
13/// before it is sampled, so there is never missing shadow data.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[derive(Default)]
17pub enum ShadowUpdate {
18    /// Re-render every cascade every frame.
19    EveryFrame,
20    /// Re-render the near cascades every frame and the distant ones on a
21    /// rotation.
22    #[default]
23    Hybrid,
24}
25
26/// Rendering settings for the world: frame pacing, shadows, and clear colour.
27/// One per world. The GPU backend is chosen by the engine for the platform and
28/// is not user-configurable.
29///
30/// The shadow and anisotropy defaults describe the quality capable hardware
31/// runs, not what every GPU runs: the `Auto` graphics quality preset resolves
32/// the detected GPU into a ceiling that caps them tier by tier. Frame pacing
33/// (`vsync`, `fps_cap`, `frames_in_flight`) is a user preference rather than a
34/// quality tier, so no preset touches it.
35///
36/// ```json
37/// {
38///   "name": "gfx",
39///   "type": "GraphicsConfig",
40///   "args": { "clear_color": [0.1, 0.1, 0.15, 1.0], "frames_in_flight": 2 }
41/// }
42/// ```
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44#[serde(default)]
45pub struct GraphicsConfig {
46    /// Cap the render loop at this many frames, then exit. Unset runs until the
47    /// window is closed.
48    pub max_frames: Option<u64>,
49    /// Preferred number of frames in flight (1-3). Higher can smooth pacing at
50    /// the cost of input latency.
51    pub frames_in_flight: u32,
52    /// Cap the frame rate to the display refresh (vsync). Defaults to `false`:
53    /// the render loop runs uncapped (DirectX presents with tearing allowed,
54    /// Vulkan uses a mailbox present mode), which is what a benchmark wants. Set
55    /// to `true` to lock presentation to the monitor refresh, eliminating tearing
56    /// and the wasted frames that never reach the screen.
57    pub vsync: bool,
58    /// Cap the frame rate to this many frames per second. `0` (default) leaves
59    /// the loop uncapped. The cap is a CPU-side frame pacer, so it composes with
60    /// `vsync`: the more restrictive of the two wins. Useful for limiting heat,
61    /// fan noise, and power draw, or matching a fixed refresh.
62    pub fps_cap: u32,
63    /// Background clear colour [r, g, b, a] in linear 0..1 space.
64    pub clear_color: [f32; 4],
65    /// Shadow map resolution in texels. `4096` by default, capped by the quality
66    /// preset down to 1024 on the lowest tier. Set to 0 to disable shadows.
67    pub shadow_map_size: u32,
68    /// How often shadow cascades are re-rendered. `every_frame` (default)
69    /// refreshes them all every frame; `hybrid` amortizes the far cascades
70    /// across frames. Only the top quality tier permits `every_frame`, so
71    /// everything below it runs `hybrid`.
72    pub shadow_update: ShadowUpdate,
73    /// How far from the camera shadows are cast, in world units (e.g. 80). The
74    /// cascades cover from the near plane out to this distance; a larger value
75    /// shadows more of the scene but spreads the same shadow-map resolution over
76    /// more area (softer, blockier shadows). Capped at the camera far plane.
77    pub shadow_distance: u32,
78    /// Number of shadow cascades, 1 to 4 (`4` is the default and the maximum).
79    /// More cascades keep distant shadows sharper by splitting the view range
80    /// into finer slices, at the cost of an extra shadow-map render per cascade;
81    /// fewer is cheaper but blockier far from the camera. The slice count covers
82    /// the same `shadow_distance` regardless.
83    pub shadow_cascades: u32,
84    /// Maximum anisotropic-filtering degree for the scene texture sampler
85    /// (albedo + normal maps), e.g. 8. Higher keeps textures viewed at a grazing
86    /// angle (floors, walls receding into the distance) sharp instead of blurring
87    /// along the minor axis, at a small sampling cost. `1` disables anisotropy
88    /// (plain trilinear). `16` by default, capped by the quality preset down to
89    /// 4 on the lowest tier. Clamped to the GPU's supported range (1..16) at
90    /// init.
91    pub anisotropy: u32,
92}
93
94impl Default for GraphicsConfig {
95    fn default() -> Self {
96        Self {
97            max_frames: None,
98            frames_in_flight: 2,
99            vsync: false,
100            fps_cap: 0,
101            clear_color: [0.01, 0.01, 0.02, 1.0],
102            shadow_map_size: 4096,
103            shadow_update: ShadowUpdate::EveryFrame,
104            shadow_distance: 80,
105            shadow_cascades: 4,
106            anisotropy: 16,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn defaults_run_uncapped_with_live_cascaded_shadows() {
117        let g = GraphicsConfig::default();
118        // No frame ceiling and no fps cap: `cn run` renders until it is closed.
119        assert_eq!(g.max_frames, None);
120        assert_eq!(g.fps_cap, 0);
121        assert!(!g.vsync);
122        assert_eq!(g.frames_in_flight, 2);
123        // The shadow + sampler quality capable hardware runs; the quality
124        // preset's ceiling caps each of these down per GPU tier.
125        assert_eq!(g.shadow_update, ShadowUpdate::EveryFrame);
126        assert_eq!(g.shadow_map_size, 4096);
127        assert_eq!(g.shadow_cascades, 4);
128        assert_eq!(g.shadow_distance, 80);
129        assert_eq!(g.anisotropy, 16);
130        // The bare enum default stays the cheap cadence, which is the right
131        // fallback wherever a `ShadowUpdate` is defaulted on its own.
132        assert_eq!(ShadowUpdate::default(), ShadowUpdate::Hybrid);
133    }
134
135    #[test]
136    fn shadow_update_names_parse_in_snake_case() {
137        assert_eq!(
138            serde_json::from_str::<ShadowUpdate>(r#""every_frame""#).unwrap(),
139            ShadowUpdate::EveryFrame
140        );
141        assert_eq!(
142            serde_json::from_str::<ShadowUpdate>(r#""hybrid""#).unwrap(),
143            ShadowUpdate::Hybrid
144        );
145        assert_eq!(
146            serde_json::to_string(&ShadowUpdate::EveryFrame).unwrap(),
147            r#""every_frame""#
148        );
149    }
150
151    #[test]
152    fn an_authored_config_parses_and_round_trips_through_postcard() {
153        let g: GraphicsConfig = serde_json::from_str(
154            r#"{"max_frames":120,"vsync":true,"fps_cap":60,"clear_color":[0,0,0,1],
155                "shadow_update":"every_frame","shadow_map_size":4096,"shadow_cascades":2,
156                "anisotropy":16}"#,
157        )
158        .unwrap();
159        assert_eq!(g.max_frames, Some(120));
160        assert!(g.vsync);
161        assert_eq!(g.shadow_update, ShadowUpdate::EveryFrame);
162
163        let bytes = postcard::to_allocvec(&g).unwrap();
164        let back: GraphicsConfig = postcard::from_bytes(&bytes).unwrap();
165        assert_eq!(back.max_frames, Some(120));
166        assert_eq!(back.fps_cap, 60);
167        assert_eq!(back.clear_color, [0.0, 0.0, 0.0, 1.0]);
168        assert_eq!(back.shadow_map_size, 4096);
169        assert_eq!(back.shadow_cascades, 2);
170        assert_eq!(back.anisotropy, 16);
171        // Frames in flight was not authored, so it keeps the schema default.
172        assert_eq!(back.frames_in_flight, 2);
173    }
174}