molgfx_render/engine/adaptive.rs
1//! Closed-loop adaptive quality: sustained frame time in, quality tier out.
2//!
3//! The loop is deliberately slow and hysteretic. A tier is a presentation
4//! contract, not a per-frame guess: a texture pool rebuild, a surface grid
5//! rebuild and a temporal history reset all follow a change, so reacting to
6//! one noisy frame would cost more than it saves. A tier therefore moves only
7//! after a sustained run of frames past the band edge, and any move resets the
8//! measurement window so the new tier is judged on its own evidence.
9//!
10//! Publication rendering never adapts. Converged output must be reproducible,
11//! so the controller holds a constant tier whenever the caller requests it.
12//!
13//! Frame times arrive as host wall-clock nanoseconds covering one complete
14//! `Engine::render` call, submission and presentation included. That is the
15//! only per-frame duration the normal path can observe: the presentation path
16//! records no timestamp queries, and a query readback would charge a blocking
17//! map to every interactive frame.
18
19/// Frame-time smoothing weight: the previous average keeps seven eighths.
20const EMA_KEEP: u64 = 7;
21/// Divisor matching [`EMA_KEEP`].
22const EMA_TOTAL: u64 = EMA_KEEP + 1;
23/// Sustained overrun frames before a tier steps down.
24const DOWN_FRAMES: u32 = 12;
25/// Sustained headroom frames before a tier steps up.
26const UP_FRAMES: u32 = 48;
27/// Overrun band: the average must exceed `5/4` of the target budget.
28const OVERRUN_NUMERATOR: u64 = 5;
29/// Denominator of the overrun band.
30const OVERRUN_DENOMINATOR: u64 = 4;
31/// Headroom band: the average must fall below `3/4` of the target budget.
32const HEADROOM_NUMERATOR: u64 = 3;
33/// Denominator of the headroom band.
34const HEADROOM_DENOMINATOR: u64 = 4;
35
36/// Quality tiers from cheapest to richest.
37///
38/// The order is the control axis: [`AdaptiveQuality`] steps one tier at a
39/// time, so a tier carries no meaning beyond its position in this list.
40#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)]
41pub enum QualityTier {
42 /// Lowest cost: coarsest surface grids, narrowest sample budgets.
43 Minimal,
44 /// Below the standard tier; still progressive, never below one sample.
45 Reduced,
46 /// The default interactive tier, and the tier every non-adaptive
47 /// presentation holds.
48 #[default]
49 Standard,
50 /// Full sample budgets for converged interactive output.
51 High,
52}
53
54impl QualityTier {
55 /// Every tier, cheapest first.
56 pub const ALL: [Self; 4] = [Self::Minimal, Self::Reduced, Self::Standard, Self::High];
57
58 const fn index(self) -> usize {
59 match self {
60 Self::Minimal => 0,
61 Self::Reduced => 1,
62 Self::Standard => 2,
63 Self::High => 3,
64 }
65 }
66
67 /// The next cheaper tier, or `None` at the floor.
68 #[must_use]
69 pub const fn cheaper(self) -> Option<Self> {
70 match self {
71 Self::Minimal => None,
72 Self::Reduced => Some(Self::Minimal),
73 Self::Standard => Some(Self::Reduced),
74 Self::High => Some(Self::Standard),
75 }
76 }
77
78 /// The next richer tier, or `None` at the ceiling.
79 #[must_use]
80 pub const fn richer(self) -> Option<Self> {
81 match self {
82 Self::Minimal => Some(Self::Reduced),
83 Self::Reduced => Some(Self::Standard),
84 Self::Standard => Some(Self::High),
85 Self::High => None,
86 }
87 }
88
89 /// Surface field grid spacing in Ångström for this tier.
90 #[must_use]
91 pub const fn surface_grid_spacing(self) -> f32 {
92 [0.75, 0.5, 0.375, 0.25][self.index()]
93 }
94
95 /// Temporal accumulation budget for this tier, in samples.
96 #[must_use]
97 pub const fn temporal_samples(self) -> u8 {
98 [4, 8, 16, 64][self.index()]
99 }
100
101 /// Off-screen image sample count for this tier.
102 #[must_use]
103 pub const fn image_samples(self) -> u32 {
104 [4, 16, 32, 64][self.index()]
105 }
106}
107
108/// Caller policy for the adaptive loop.
109#[derive(Clone, Copy, PartialEq, Eq, Debug)]
110pub struct AdaptiveQualityConfig {
111 /// Frame rate the loop steers toward, in frames per second.
112 pub target_fps: u16,
113 /// Whether the loop may move a tier. Publication callers clear this so
114 /// converged output stays reproducible.
115 pub enabled: bool,
116}
117
118impl AdaptiveQualityConfig {
119 /// An adapting loop steering toward `target_fps`.
120 #[must_use]
121 pub const fn interactive(target_fps: u16) -> Self {
122 Self {
123 target_fps,
124 enabled: true,
125 }
126 }
127
128 /// A fixed-tier loop for deterministic publication output.
129 #[must_use]
130 pub const fn publication() -> Self {
131 Self {
132 target_fps: 1,
133 enabled: false,
134 }
135 }
136
137 /// The frame budget in nanoseconds, clamped to a representable rate.
138 const fn budget_ns(self) -> u64 {
139 let fps = if self.target_fps == 0 {
140 1
141 } else if self.target_fps > 1_000 {
142 1_000
143 } else {
144 self.target_fps
145 };
146 1_000_000_000 / fps as u64
147 }
148}
149
150impl Default for AdaptiveQualityConfig {
151 fn default() -> Self {
152 Self::interactive(60)
153 }
154}
155
156/// Exponentially smoothed frame time with hysteresis over [`QualityTier`].
157#[derive(Clone, Copy, Debug)]
158pub struct AdaptiveQuality {
159 requested: bool,
160 publication: bool,
161 target_fps: u16,
162 target_ns: u64,
163 ema_ns: u64,
164 tier: QualityTier,
165 overrun_frames: u32,
166 headroom_frames: u32,
167}
168
169impl AdaptiveQuality {
170 /// Builds a controller at the tier one render mode starts from.
171 ///
172 /// `publication` is the engine's own determinism switch: the cinematic path
173 /// and off-screen publication never adapt regardless of policy, and they
174 /// start at [`QualityTier::Standard`] — the tier it holds for every frame.
175 /// An interactive path starts at [`QualityTier::Reduced`], the tier whose
176 /// sampling matches the realtime presets the engine shipped before the
177 /// loop existed, and the loop raises it once there is measured headroom.
178 #[must_use]
179 pub const fn new(config: AdaptiveQualityConfig, publication: bool) -> Self {
180 Self {
181 requested: config.enabled,
182 publication,
183 target_fps: config.target_fps,
184 target_ns: config.budget_ns(),
185 ema_ns: 0,
186 tier: if publication {
187 QualityTier::Standard
188 } else {
189 QualityTier::Reduced
190 },
191 overrun_frames: 0,
192 headroom_frames: 0,
193 }
194 }
195
196 /// The frame rate the loop steers toward.
197 #[must_use]
198 pub const fn target_fps(&self) -> u16 {
199 self.target_fps
200 }
201
202 /// Whether the loop is allowed to move a tier.
203 #[must_use]
204 pub const fn enabled(&self) -> bool {
205 self.requested && !self.publication
206 }
207
208 /// The tier every frame of this instant presents at.
209 #[must_use]
210 pub const fn tier(&self) -> QualityTier {
211 self.tier
212 }
213
214 /// The smoothed frame time in nanoseconds; zero before the first frame.
215 #[must_use]
216 pub const fn smoothed_ns(&self) -> u64 {
217 self.ema_ns
218 }
219
220 /// Marks the engine as running the deterministic publication path.
221 ///
222 /// Entering publication holds the tier constant from that frame on.
223 pub const fn set_publication(&mut self, publication: bool) {
224 if self.publication != publication {
225 self.publication = publication;
226 self.tier = if publication {
227 QualityTier::Standard
228 } else {
229 QualityTier::Reduced
230 };
231 self.reset_window();
232 }
233 }
234
235 /// Feeds one completed frame's wall-clock duration and returns the tier
236 /// the next frame presents at.
237 pub const fn observe(&mut self, frame_ns: u64) -> QualityTier {
238 if !self.enabled() {
239 return self.tier;
240 }
241 self.ema_ns = if self.ema_ns == 0 {
242 frame_ns
243 } else {
244 self.ema_ns
245 .saturating_mul(EMA_KEEP)
246 .saturating_add(frame_ns)
247 / EMA_TOTAL
248 };
249 let budget = self.target_ns;
250 if self.ema_ns.saturating_mul(OVERRUN_DENOMINATOR)
251 > budget.saturating_mul(OVERRUN_NUMERATOR)
252 {
253 self.overrun_frames = self.overrun_frames.saturating_add(1);
254 self.headroom_frames = 0;
255 } else if self.ema_ns.saturating_mul(HEADROOM_DENOMINATOR)
256 < budget.saturating_mul(HEADROOM_NUMERATOR)
257 {
258 self.headroom_frames = self.headroom_frames.saturating_add(1);
259 self.overrun_frames = 0;
260 } else {
261 self.overrun_frames = 0;
262 self.headroom_frames = 0;
263 }
264 if self.overrun_frames >= DOWN_FRAMES {
265 self.step(self.tier.cheaper());
266 } else if self.headroom_frames >= UP_FRAMES {
267 self.step(self.tier.richer());
268 }
269 self.tier
270 }
271
272 const fn step(&mut self, next: Option<QualityTier>) {
273 match next {
274 Some(tier) => self.tier = tier,
275 None => self.overrun_frames = 0,
276 }
277 self.reset_window();
278 }
279
280 const fn reset_window(&mut self) {
281 self.ema_ns = 0;
282 self.overrun_frames = 0;
283 self.headroom_frames = 0;
284 }
285}
286
287impl Default for AdaptiveQuality {
288 fn default() -> Self {
289 Self::new(AdaptiveQualityConfig::default(), false)
290 }
291}
292
293#[cfg(test)]
294#[path = "adaptive_tests.rs"]
295mod tests;