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