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}