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    /// Selects the largest tier allowed by scene size before frame-time
65    /// adaptation.
66    ///
67    /// The bands bound expensive surface, temporal and upload work without
68    /// touching coordinates. Callers may still force publication quality; this
69    /// policy only constrains adaptive realtime rendering.
70    #[must_use]
71    pub const fn for_atom_count(atom_count: u64) -> Self {
72        if atom_count <= 10_000 {
73            Self::High
74        } else if atom_count <= 100_000 {
75            Self::Standard
76        } else if atom_count <= 500_000 {
77            Self::Reduced
78        } else {
79            Self::Minimal
80        }
81    }
82
83    const fn index(self) -> usize {
84        match self {
85            Self::Minimal => 0,
86            Self::Reduced => 1,
87            Self::Standard => 2,
88            Self::High => 3,
89        }
90    }
91
92    /// The next cheaper tier, or `None` at the floor.
93    #[must_use]
94    pub const fn cheaper(self) -> Option<Self> {
95        match self {
96            Self::Minimal => None,
97            Self::Reduced => Some(Self::Minimal),
98            Self::Standard => Some(Self::Reduced),
99            Self::High => Some(Self::Standard),
100        }
101    }
102
103    /// The next richer tier, or `None` at the ceiling.
104    #[must_use]
105    pub const fn richer(self) -> Option<Self> {
106        match self {
107            Self::Minimal => Some(Self::Reduced),
108            Self::Reduced => Some(Self::Standard),
109            Self::Standard => Some(Self::High),
110            Self::High => None,
111        }
112    }
113
114    /// Surface field grid spacing in Ångström for this tier.
115    ///
116    /// The two richer tiers share the finest spacing: it is the resolution at
117    /// which a surface stops changing visibly, so a richer tier spends its
118    /// budget on samples instead.
119    #[must_use]
120    pub const fn surface_grid_spacing(self) -> f32 {
121        [0.75, 0.5, 0.25, 0.25][self.index()]
122    }
123
124    /// Maximum ribbon samples per trace interval for this tier.
125    ///
126    /// Fewer samples coarsen the spline between residues only; the residue
127    /// positions the ribbon passes through are unchanged.
128    #[must_use]
129    pub const fn ribbon_steps(self) -> u8 {
130        [3, 5, 8, 8][self.index()]
131    }
132
133    /// The scene-synchronization detail budgets this tier selects.
134    #[must_use]
135    pub(crate) const fn detail(self) -> crate::scene_gpu::detail::TierDetail {
136        crate::scene_gpu::detail::TierDetail {
137            surface_spacing: self.surface_grid_spacing(),
138            ribbon_steps: self.ribbon_steps(),
139        }
140    }
141
142    /// Temporal accumulation budget for this tier, in samples.
143    #[must_use]
144    pub const fn temporal_samples(self) -> u8 {
145        [4, 8, 16, 64][self.index()]
146    }
147
148    /// Off-screen image sample count for this tier.
149    #[must_use]
150    pub const fn image_samples(self) -> u32 {
151        [4, 16, 32, 64][self.index()]
152    }
153}
154
155/// Caller policy for the adaptive loop.
156#[derive(Clone, Copy, PartialEq, Eq, Debug)]
157pub struct AdaptiveQualityConfig {
158    /// Frame rate the loop steers toward, in frames per second.
159    pub target_fps: u16,
160    /// Whether the loop may move a tier. Publication callers clear this so
161    /// converged output stays reproducible.
162    pub enabled: bool,
163}
164
165impl AdaptiveQualityConfig {
166    /// An adapting loop steering toward `target_fps`.
167    #[must_use]
168    pub const fn interactive(target_fps: u16) -> Self {
169        Self {
170            target_fps,
171            enabled: true,
172        }
173    }
174
175    /// A fixed-tier loop for deterministic publication output.
176    #[must_use]
177    pub const fn publication() -> Self {
178        Self {
179            target_fps: 1,
180            enabled: false,
181        }
182    }
183
184    /// The frame budget in nanoseconds, clamped to a representable rate.
185    const fn budget_ns(self) -> u64 {
186        let fps = if self.target_fps == 0 {
187            1
188        } else if self.target_fps > 1_000 {
189            1_000
190        } else {
191            self.target_fps
192        };
193        1_000_000_000 / fps as u64
194    }
195}
196
197impl Default for AdaptiveQualityConfig {
198    fn default() -> Self {
199        Self::interactive(60)
200    }
201}
202
203/// Exponentially smoothed frame time with hysteresis over [`QualityTier`].
204#[derive(Clone, Copy, Debug)]
205pub struct AdaptiveQuality {
206    requested: bool,
207    publication: bool,
208    target_fps: u16,
209    target_ns: u64,
210    ema_ns: u64,
211    tier: QualityTier,
212    size_cap: QualityTier,
213    overrun_frames: u32,
214    headroom_frames: u32,
215}
216
217impl AdaptiveQuality {
218    /// Builds a controller at the tier one render mode starts from.
219    ///
220    /// `publication` is the engine's own determinism switch: the cinematic path
221    /// and off-screen publication never adapt regardless of policy, and they
222    /// start at [`QualityTier::Standard`] — the tier it holds for every frame.
223    /// An interactive path starts at [`QualityTier::Reduced`], the tier whose
224    /// sampling matches the realtime presets the engine shipped before the
225    /// loop existed, and the loop raises it once there is measured headroom.
226    #[must_use]
227    pub const fn new(config: AdaptiveQualityConfig, publication: bool) -> Self {
228        Self {
229            requested: config.enabled,
230            publication,
231            target_fps: config.target_fps,
232            target_ns: config.budget_ns(),
233            ema_ns: 0,
234            tier: if publication {
235                QualityTier::Standard
236            } else {
237                QualityTier::Reduced
238            },
239            size_cap: QualityTier::High,
240            overrun_frames: 0,
241            headroom_frames: 0,
242        }
243    }
244
245    /// The frame rate the loop steers toward.
246    #[must_use]
247    pub const fn target_fps(&self) -> u16 {
248        self.target_fps
249    }
250
251    /// Whether the loop is allowed to move a tier.
252    #[must_use]
253    pub const fn enabled(&self) -> bool {
254        self.requested && !self.publication
255    }
256
257    /// Whether this instant accumulates to a still image.
258    ///
259    /// The cinematic path and off-screen publication converge, so their
260    /// sub-pixel coverage is already averaged; the realtime path does not, so
261    /// its edges need explicit smoothing whatever tier it happens to hold.
262    #[must_use]
263    pub const fn converged(&self) -> bool {
264        self.publication
265    }
266
267    /// The tier every frame of this instant presents at.
268    #[must_use]
269    pub const fn tier(&self) -> QualityTier {
270        self.tier
271    }
272
273    /// The smoothed frame time in nanoseconds; zero before the first frame.
274    #[must_use]
275    pub const fn smoothed_ns(&self) -> u64 {
276        self.ema_ns
277    }
278
279    /// Applies the size-based realtime ceiling for the next frame.
280    ///
281    /// This is a cheap scene-level operation. Changing to a smaller band
282    /// immediately drops the tier and clears its timing window; growing a
283    /// scene's budget never causes a sudden expensive jump.
284    pub fn set_atom_count(&mut self, atom_count: u64) {
285        if self.publication {
286            return;
287        }
288        let cap = QualityTier::for_atom_count(atom_count);
289        if cap == self.size_cap {
290            return;
291        }
292        self.size_cap = cap;
293        if self.tier > cap {
294            self.tier = cap;
295            self.reset_window();
296        }
297    }
298
299    /// Marks the engine as running the deterministic publication path.
300    ///
301    /// Entering publication holds the tier constant from that frame on.
302    pub fn set_publication(&mut self, publication: bool) {
303        if self.publication != publication {
304            self.publication = publication;
305            self.tier = if publication {
306                QualityTier::Standard
307            } else {
308                QualityTier::Reduced
309            };
310            self.size_cap = QualityTier::High;
311            self.reset_window();
312        }
313    }
314
315    /// Feeds one frame's wall-clock duration and returns the tier the next
316    /// frame presents at.
317    ///
318    /// The caller chooses the sample source per target: the native `render`
319    /// call duration (CPU encoding/submission) or the browser
320    /// submission-to-fence-completion elapsed time. Both are host-clock
321    /// durations, neither is GPU execution time, and each frame feeds exactly
322    /// one sample from exactly one source.
323    pub fn observe(&mut self, frame_ns: u64) -> QualityTier {
324        if !self.enabled() {
325            return self.tier;
326        }
327        self.ema_ns = if self.ema_ns == 0 {
328            frame_ns
329        } else {
330            self.ema_ns
331                .saturating_mul(EMA_KEEP)
332                .saturating_add(frame_ns)
333                / EMA_TOTAL
334        };
335        let budget = self.target_ns;
336        if self.ema_ns.saturating_mul(OVERRUN_DENOMINATOR)
337            > budget.saturating_mul(OVERRUN_NUMERATOR)
338        {
339            self.overrun_frames = self.overrun_frames.saturating_add(1);
340            self.headroom_frames = 0;
341        } else if self.ema_ns.saturating_mul(HEADROOM_DENOMINATOR)
342            < budget.saturating_mul(HEADROOM_NUMERATOR)
343        {
344            self.headroom_frames = self.headroom_frames.saturating_add(1);
345            self.overrun_frames = 0;
346        } else {
347            self.overrun_frames = 0;
348            self.headroom_frames = 0;
349        }
350        if self.overrun_frames >= DOWN_FRAMES {
351            self.step(self.tier.cheaper());
352        } else if self.headroom_frames >= UP_FRAMES {
353            let next = match self.tier.richer() {
354                Some(tier) if tier <= self.size_cap => Some(tier),
355                _ => None,
356            };
357            self.step(next);
358        }
359        self.tier
360    }
361
362    const fn step(&mut self, next: Option<QualityTier>) {
363        match next {
364            Some(tier) => self.tier = tier,
365            None => self.overrun_frames = 0,
366        }
367        self.reset_window();
368    }
369
370    const fn reset_window(&mut self) {
371        self.ema_ns = 0;
372        self.overrun_frames = 0;
373        self.headroom_frames = 0;
374    }
375}
376
377impl Default for AdaptiveQuality {
378    fn default() -> Self {
379        Self::new(AdaptiveQualityConfig::default(), false)
380    }
381}
382
383#[cfg(test)]
384#[path = "adaptive_tests.rs"]
385mod tests;