Skip to main content

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