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//! 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;