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/// ```json
31/// {
32///   "name": "gfx",
33///   "type": "GraphicsConfig",
34///   "args": { "clear_color": [0.1, 0.1, 0.15, 1.0], "frames_in_flight": 2 }
35/// }
36/// ```
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38#[serde(default)]
39pub struct GraphicsConfig {
40    /// Cap the render loop at this many frames, then exit. Unset runs until the
41    /// window is closed.
42    pub max_frames: Option<u64>,
43    /// Preferred number of frames in flight (1-3). Higher can smooth pacing at
44    /// the cost of input latency.
45    pub frames_in_flight: u32,
46    /// Cap the frame rate to the display refresh (vsync). Defaults to `false`:
47    /// the render loop runs uncapped (DirectX presents with tearing allowed,
48    /// Vulkan uses a mailbox present mode), which is what a benchmark wants. Set
49    /// to `true` to lock presentation to the monitor refresh, eliminating tearing
50    /// and the wasted frames that never reach the screen.
51    pub vsync: bool,
52    /// Cap the frame rate to this many frames per second. `0` (default) leaves
53    /// the loop uncapped. The cap is a CPU-side frame pacer, so it composes with
54    /// `vsync`: the more restrictive of the two wins. Useful for limiting heat,
55    /// fan noise, and power draw, or matching a fixed refresh.
56    pub fps_cap: u32,
57    /// Background clear colour [r, g, b, a] in linear 0..1 space.
58    pub clear_color: [f32; 4],
59    /// Shadow map resolution in texels (e.g. 2048). Set to 0 to disable shadows.
60    pub shadow_map_size: u32,
61    /// How often shadow cascades are re-rendered. `hybrid` (default) amortizes
62    /// the far cascades across frames; `every_frame` refreshes them all every
63    /// frame.
64    pub shadow_update: ShadowUpdate,
65    /// How far from the camera shadows are cast, in world units (e.g. 80). The
66    /// cascades cover from the near plane out to this distance; a larger value
67    /// shadows more of the scene but spreads the same shadow-map resolution over
68    /// more area (softer, blockier shadows). Capped at the camera far plane.
69    pub shadow_distance: u32,
70    /// Number of shadow cascades, 1 to 4 (`4` is the default and the maximum).
71    /// More cascades keep distant shadows sharper by splitting the view range
72    /// into finer slices, at the cost of an extra shadow-map render per cascade;
73    /// fewer is cheaper but blockier far from the camera. The slice count covers
74    /// the same `shadow_distance` regardless.
75    pub shadow_cascades: u32,
76    /// Maximum anisotropic-filtering degree for the scene texture sampler
77    /// (albedo + normal maps), e.g. 8. Higher keeps textures viewed at a grazing
78    /// angle (floors, walls receding into the distance) sharp instead of blurring
79    /// along the minor axis, at a small sampling cost. `1` disables anisotropy
80    /// (plain trilinear). Clamped to the GPU's supported range (1..16) at init.
81    pub anisotropy: u32,
82}
83
84impl Default for GraphicsConfig {
85    fn default() -> Self {
86        Self {
87            max_frames: None,
88            frames_in_flight: 2,
89            vsync: false,
90            fps_cap: 0,
91            clear_color: [0.01, 0.01, 0.02, 1.0],
92            shadow_map_size: 2048,
93            shadow_update: ShadowUpdate::default(),
94            shadow_distance: 80,
95            shadow_cascades: 4,
96            anisotropy: 8,
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn defaults_run_uncapped_with_hybrid_cascaded_shadows() {
107        let g = GraphicsConfig::default();
108        // No frame ceiling and no fps cap: `cn run` renders until it is closed.
109        assert_eq!(g.max_frames, None);
110        assert_eq!(g.fps_cap, 0);
111        assert!(!g.vsync);
112        assert_eq!(g.frames_in_flight, 2);
113        assert_eq!(g.shadow_update, ShadowUpdate::Hybrid);
114        assert_eq!(g.shadow_map_size, 2048);
115        assert_eq!(g.shadow_cascades, 4);
116        assert_eq!(g.shadow_distance, 80);
117        assert_eq!(g.anisotropy, 8);
118        assert_eq!(ShadowUpdate::default(), ShadowUpdate::Hybrid);
119    }
120
121    #[test]
122    fn shadow_update_names_parse_in_snake_case() {
123        assert_eq!(
124            serde_json::from_str::<ShadowUpdate>(r#""every_frame""#).unwrap(),
125            ShadowUpdate::EveryFrame
126        );
127        assert_eq!(
128            serde_json::from_str::<ShadowUpdate>(r#""hybrid""#).unwrap(),
129            ShadowUpdate::Hybrid
130        );
131        assert_eq!(
132            serde_json::to_string(&ShadowUpdate::EveryFrame).unwrap(),
133            r#""every_frame""#
134        );
135    }
136
137    #[test]
138    fn an_authored_config_parses_and_round_trips_through_postcard() {
139        let g: GraphicsConfig = serde_json::from_str(
140            r#"{"max_frames":120,"vsync":true,"fps_cap":60,"clear_color":[0,0,0,1],
141                "shadow_update":"every_frame","shadow_map_size":4096,"shadow_cascades":2,
142                "anisotropy":16}"#,
143        )
144        .unwrap();
145        assert_eq!(g.max_frames, Some(120));
146        assert!(g.vsync);
147        assert_eq!(g.shadow_update, ShadowUpdate::EveryFrame);
148
149        let bytes = postcard::to_allocvec(&g).unwrap();
150        let back: GraphicsConfig = postcard::from_bytes(&bytes).unwrap();
151        assert_eq!(back.max_frames, Some(120));
152        assert_eq!(back.fps_cap, 60);
153        assert_eq!(back.clear_color, [0.0, 0.0, 0.0, 1.0]);
154        assert_eq!(back.shadow_map_size, 4096);
155        assert_eq!(back.shadow_cascades, 2);
156        assert_eq!(back.anisotropy, 16);
157        // Frames in flight was not authored, so it keeps the schema default.
158        assert_eq!(back.frames_in_flight, 2);
159    }
160}