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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
//! Closed-loop adaptive quality: sustained frame time in, quality tier out.
//!
//! The loop is deliberately slow and hysteretic. A tier is a presentation
//! contract, not a per-frame guess: a texture pool rebuild, a surface grid
//! rebuild and a temporal history reset all follow a change, so reacting to
//! one noisy frame would cost more than it saves. A tier therefore moves only
//! after a sustained run of frames past the band edge, and any move resets the
//! measurement window so the new tier is judged on its own evidence.
//!
//! Publication rendering never adapts. Converged output must be reproducible,
//! so the controller holds a constant tier whenever the caller requests it.
//!
//! Native rendering feeds the controller CPU frame duration — the elapsed time
//! of one `render` call, synchronization, recording and submission included.
//! Queue submission is asynchronous on native, so host time is the honest
//! per-frame CPU cost; a fence sample there would double-count the same frame.
//! Browser rendering instead samples elapsed time from tracked submission to
//! fence completion, observed on a later frame poll: submission returns
//! immediately in the browser, so measuring the `render` call would classify
//! queued GPU work as free. Neither source measures GPU execution time; the
//! browser sample additionally includes host callback-dispatch latency, and
//! exact device time requires timestamp queries through the profiling path.
//! Completion samples never read pixels or wait synchronously.
/// Frame-time smoothing weight: the previous average keeps seven eighths.
const EMA_KEEP: u64 = 7;
/// Divisor matching [`EMA_KEEP`].
const EMA_TOTAL: u64 = EMA_KEEP + 1;
/// Sustained overrun frames before a tier steps down.
const DOWN_FRAMES: u32 = 12;
/// Sustained headroom frames before a tier steps up.
const UP_FRAMES: u32 = 48;
/// Overrun band: the average must exceed `5/4` of the target budget.
const OVERRUN_NUMERATOR: u64 = 5;
/// Denominator of the overrun band.
const OVERRUN_DENOMINATOR: u64 = 4;
/// Headroom band: the average must fall below `3/4` of the target budget.
const HEADROOM_NUMERATOR: u64 = 3;
/// Denominator of the headroom band.
const HEADROOM_DENOMINATOR: u64 = 4;
/// Quality tiers from cheapest to richest.
///
/// The order is the control axis: [`AdaptiveQuality`] steps one tier at a
/// time, so a tier carries no meaning beyond its position in this list.
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum QualityTier {
/// Lowest cost: coarsest surface grids, narrowest sample budgets.
Minimal,
/// Below the standard tier; still progressive, never below one sample.
Reduced,
/// The default interactive tier, and the tier every non-adaptive
/// presentation holds.
#[default]
Standard,
/// Full sample budgets for converged interactive output.
High,
}
impl QualityTier {
/// Every tier, cheapest first.
pub const ALL: [Self; 4] = [Self::Minimal, Self::Reduced, Self::Standard, Self::High];
/// Selects the largest tier allowed by scene size before frame-time
/// adaptation.
///
/// The bands bound expensive surface, temporal and upload work without
/// touching coordinates. Callers may still force publication quality; this
/// policy only constrains adaptive realtime rendering.
#[must_use]
pub const fn for_atom_count(atom_count: u64) -> Self {
if atom_count <= 10_000 {
Self::High
} else if atom_count <= 100_000 {
Self::Standard
} else if atom_count <= 500_000 {
Self::Reduced
} else {
Self::Minimal
}
}
const fn index(self) -> usize {
match self {
Self::Minimal => 0,
Self::Reduced => 1,
Self::Standard => 2,
Self::High => 3,
}
}
/// The next cheaper tier, or `None` at the floor.
#[must_use]
pub const fn cheaper(self) -> Option<Self> {
match self {
Self::Minimal => None,
Self::Reduced => Some(Self::Minimal),
Self::Standard => Some(Self::Reduced),
Self::High => Some(Self::Standard),
}
}
/// The next richer tier, or `None` at the ceiling.
#[must_use]
pub const fn richer(self) -> Option<Self> {
match self {
Self::Minimal => Some(Self::Reduced),
Self::Reduced => Some(Self::Standard),
Self::Standard => Some(Self::High),
Self::High => None,
}
}
/// Surface field grid spacing in Ångström for this tier.
///
/// The two richer tiers share the finest spacing: it is the resolution at
/// which a surface stops changing visibly, so a richer tier spends its
/// budget on samples instead.
#[must_use]
pub const fn surface_grid_spacing(self) -> f32 {
[0.75, 0.5, 0.25, 0.25][self.index()]
}
/// Maximum ribbon samples per trace interval for this tier.
///
/// Fewer samples coarsen the spline between residues only; the residue
/// positions the ribbon passes through are unchanged.
#[must_use]
pub const fn ribbon_steps(self) -> u8 {
[3, 5, 8, 8][self.index()]
}
/// The scene-synchronization detail budgets this tier selects.
#[must_use]
pub(crate) const fn detail(self) -> crate::scene_gpu::detail::TierDetail {
crate::scene_gpu::detail::TierDetail {
surface_spacing: self.surface_grid_spacing(),
ribbon_steps: self.ribbon_steps(),
}
}
/// Temporal accumulation budget for this tier, in samples.
#[must_use]
pub const fn temporal_samples(self) -> u8 {
[4, 8, 16, 64][self.index()]
}
/// Off-screen image sample count for this tier.
#[must_use]
pub const fn image_samples(self) -> u32 {
[4, 16, 32, 64][self.index()]
}
}
/// Caller policy for the adaptive loop.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct AdaptiveQualityConfig {
/// Frame rate the loop steers toward, in frames per second.
pub target_fps: u16,
/// Whether the loop may move a tier. Publication callers clear this so
/// converged output stays reproducible.
pub enabled: bool,
}
impl AdaptiveQualityConfig {
/// An adapting loop steering toward `target_fps`.
#[must_use]
pub const fn interactive(target_fps: u16) -> Self {
Self {
target_fps,
enabled: true,
}
}
/// A fixed-tier loop for deterministic publication output.
#[must_use]
pub const fn publication() -> Self {
Self {
target_fps: 1,
enabled: false,
}
}
/// The frame budget in nanoseconds, clamped to a representable rate.
const fn budget_ns(self) -> u64 {
let fps = if self.target_fps == 0 {
1
} else if self.target_fps > 1_000 {
1_000
} else {
self.target_fps
};
1_000_000_000 / fps as u64
}
}
impl Default for AdaptiveQualityConfig {
fn default() -> Self {
Self::interactive(60)
}
}
/// Exponentially smoothed frame time with hysteresis over [`QualityTier`].
#[derive(Clone, Copy, Debug)]
pub struct AdaptiveQuality {
requested: bool,
publication: bool,
target_fps: u16,
target_ns: u64,
ema_ns: u64,
tier: QualityTier,
size_cap: QualityTier,
overrun_frames: u32,
headroom_frames: u32,
}
impl AdaptiveQuality {
/// Builds a controller at the tier one render mode starts from.
///
/// `publication` is the engine's own determinism switch: the cinematic path
/// and off-screen publication never adapt regardless of policy, and they
/// start at [`QualityTier::Standard`] — the tier it holds for every frame.
/// An interactive path starts at [`QualityTier::Reduced`], the tier whose
/// sampling matches the realtime presets the engine shipped before the
/// loop existed, and the loop raises it once there is measured headroom.
#[must_use]
pub const fn new(config: AdaptiveQualityConfig, publication: bool) -> Self {
Self {
requested: config.enabled,
publication,
target_fps: config.target_fps,
target_ns: config.budget_ns(),
ema_ns: 0,
tier: if publication {
QualityTier::Standard
} else {
QualityTier::Reduced
},
size_cap: QualityTier::High,
overrun_frames: 0,
headroom_frames: 0,
}
}
/// The frame rate the loop steers toward.
#[must_use]
pub const fn target_fps(&self) -> u16 {
self.target_fps
}
/// Whether the loop is allowed to move a tier.
#[must_use]
pub const fn enabled(&self) -> bool {
self.requested && !self.publication
}
/// The tier every frame of this instant presents at.
#[must_use]
pub const fn tier(&self) -> QualityTier {
self.tier
}
/// The smoothed frame time in nanoseconds; zero before the first frame.
#[must_use]
pub const fn smoothed_ns(&self) -> u64 {
self.ema_ns
}
/// Applies the size-based realtime ceiling for the next frame.
///
/// This is a cheap scene-level operation. Changing to a smaller band
/// immediately drops the tier and clears its timing window; growing a
/// scene's budget never causes a sudden expensive jump.
pub fn set_atom_count(&mut self, atom_count: u64) {
if self.publication {
return;
}
let cap = QualityTier::for_atom_count(atom_count);
if cap == self.size_cap {
return;
}
self.size_cap = cap;
if self.tier > cap {
self.tier = cap;
self.reset_window();
}
}
/// Marks the engine as running the deterministic publication path.
///
/// Entering publication holds the tier constant from that frame on.
pub fn set_publication(&mut self, publication: bool) {
if self.publication != publication {
self.publication = publication;
self.tier = if publication {
QualityTier::Standard
} else {
QualityTier::Reduced
};
self.size_cap = QualityTier::High;
self.reset_window();
}
}
/// Feeds one frame's wall-clock duration and returns the tier the next
/// frame presents at.
///
/// The caller chooses the sample source per target: the native `render`
/// call duration (CPU encoding/submission) or the browser
/// submission-to-fence-completion elapsed time. Both are host-clock
/// durations, neither is GPU execution time, and each frame feeds exactly
/// one sample from exactly one source.
pub fn observe(&mut self, frame_ns: u64) -> QualityTier {
if !self.enabled() {
return self.tier;
}
self.ema_ns = if self.ema_ns == 0 {
frame_ns
} else {
self.ema_ns
.saturating_mul(EMA_KEEP)
.saturating_add(frame_ns)
/ EMA_TOTAL
};
let budget = self.target_ns;
if self.ema_ns.saturating_mul(OVERRUN_DENOMINATOR)
> budget.saturating_mul(OVERRUN_NUMERATOR)
{
self.overrun_frames = self.overrun_frames.saturating_add(1);
self.headroom_frames = 0;
} else if self.ema_ns.saturating_mul(HEADROOM_DENOMINATOR)
< budget.saturating_mul(HEADROOM_NUMERATOR)
{
self.headroom_frames = self.headroom_frames.saturating_add(1);
self.overrun_frames = 0;
} else {
self.overrun_frames = 0;
self.headroom_frames = 0;
}
if self.overrun_frames >= DOWN_FRAMES {
self.step(self.tier.cheaper());
} else if self.headroom_frames >= UP_FRAMES {
let next = match self.tier.richer() {
Some(tier) if tier <= self.size_cap => Some(tier),
_ => None,
};
self.step(next);
}
self.tier
}
const fn step(&mut self, next: Option<QualityTier>) {
match next {
Some(tier) => self.tier = tier,
None => self.overrun_frames = 0,
}
self.reset_window();
}
const fn reset_window(&mut self) {
self.ema_ns = 0;
self.overrun_frames = 0;
self.headroom_frames = 0;
}
}
impl Default for AdaptiveQuality {
fn default() -> Self {
Self::new(AdaptiveQualityConfig::default(), false)
}
}
#[cfg(test)]
#[path = "adaptive_tests.rs"]
mod tests;