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}