shuyi_ui/frame_cap.rs
1//! The frame cap: the most frames a second the platform shows
2//! ([`FrameCap`]), 60 unless the game or the player says otherwise, so a
3//! simple game doesn't run as fast as it can and keep a CPU core busy
4//! (fans, heat, the battery).
5//!
6//! The cap is the player's setting
7//! ([`Settings::frame_cap`](crate::settings::Settings::frame_cap)), whose
8//! default and choices are the game's
9//! ([`GameSettings::DEFAULT_FRAME_CAP`](crate::settings::GameSettings::DEFAULT_FRAME_CAP),
10//! [`FRAME_CAP_CHOICES`](crate::settings::GameSettings::FRAME_CAP_CHOICES)).
11//! Only the platform can apply it, so a change is a request it confirms:
12//! every change of the cap takes the next serial number, the platform is
13//! handed the cap with its serial each frame, and before each frame it
14//! reports what it applied ([`Pacing`], through
15//! [`Game::set_pacing`](crate::Game::set_pacing)). A game that asks for a
16//! cap ([`Ctx::request_frame_cap`](crate::Ctx::request_frame_cap)) gets a
17//! [`CapRequest`] and checks it like a promise
18//! ([`Ctx::frame_cap_status`](crate::Ctx::frame_cap_status)): pending until
19//! the platform reports that serial, then applied, with the report.
20//!
21//! **With vsync and variable refresh (VRR).** The platform never waits
22//! twice: when vsync, the display or a slow machine already keeps the
23//! frames as far apart as the cap asks, it doesn't wait at all, so a cap
24//! of 60 on a 60 Hz display with vsync on never misses a vblank. On a VRR
25//! display (G-Sync, `FreeSync`, adaptive sync), a cap a few frames under the
26//! display's refresh (141 on a 144 Hz display) keeps it in its variable
27//! range, so the display follows the frames.
28//!
29//! **With fixed steps.** A tick stage steps 60 times a second. At a cap
30//! above 60 each step shows for whole frames: evenly at 120 and 240, but 2
31//! and 3 frames in turn at 144 and 165, which moves unevenly. A cap that
32//! is a multiple of 60 avoids it.
33
34use crate::prelude::*;
35
36/// The lowest cap [`FrameCap::fps`] takes, in frames a second. *Tunable.*
37pub const MIN_FPS: u16 = 15;
38
39/// The highest cap [`FrameCap::fps`] takes, in frames a second. *Tunable.*
40pub const MAX_FPS: u16 = 1000;
41
42/// The choices an options screen offers when the game doesn't give its own
43/// ([`GameSettings::FRAME_CAP_CHOICES`](crate::settings::GameSettings::FRAME_CAP_CHOICES)):
44/// the common refresh rates, then no cap.
45pub const CHOICES: [FrameCap; 7] = [
46 FrameCap::Fps(30),
47 FrameCap::Fps(60),
48 FrameCap::Fps(120),
49 FrameCap::Fps(144),
50 FrameCap::Fps(165),
51 FrameCap::Fps(240),
52 FrameCap::Off,
53];
54
55/// Text key ([`Ctx::text`](crate::Ctx::text)) of a cap's name, with the
56/// number of frames a second as `{n}` (`"{n} FPS"`).
57pub const FPS_KEY: &str = "options.value.fps";
58
59/// Text key of no cap's name (`"Unlimited"`).
60pub const UNLIMITED_KEY: &str = "options.value.unlimited";
61
62/// The most frames a second the platform shows.
63///
64/// 60 by default ([`FrameCap::DEFAULT`]): enough for a smooth picture on
65/// most displays, and a game that does little uses little of the CPU.
66/// Build one with [`FrameCap::fps`], which checks the range; a cap out of
67/// the range ([`MIN_FPS`] to [`MAX_FPS`]), set as `Fps(n)` in code or read
68/// from a file, is held to it when the platform applies it
69/// ([`limit`](Self::limit)).
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
71#[non_exhaustive]
72pub enum FrameCap {
73 /// No cap: the platform shows frames as fast as vsync, the display or
74 /// the machine let it, on purpose.
75 Off,
76 /// At most this many frames a second.
77 Fps(u16),
78}
79
80impl Default for FrameCap {
81 fn default() -> Self {
82 Self::DEFAULT
83 }
84}
85
86impl FrameCap {
87 /// The engine's default: 60 frames a second.
88 pub const DEFAULT: Self = Self::Fps(60);
89
90 /// A cap of `n` frames a second; out of [`MIN_FPS`] to [`MAX_FPS`] is
91 /// [`CapError::OutOfRange`].
92 pub const fn fps(n: u16) -> Result<Self, CapError> {
93 if n < MIN_FPS || n > MAX_FPS {
94 return Err(CapError::OutOfRange);
95 }
96 Ok(Self::Fps(n))
97 }
98
99 /// The most frames a second it allows, held to [`MIN_FPS`] to
100 /// [`MAX_FPS`]; `None` for no cap.
101 pub fn limit(self) -> Option<u16> {
102 match self {
103 Self::Off => None,
104 Self::Fps(n) => Some(n.clamp(MIN_FPS, MAX_FPS)),
105 }
106 }
107
108 /// The text key of its name: [`FPS_KEY`] or [`UNLIMITED_KEY`].
109 pub const fn key(self) -> &'static str {
110 match self {
111 Self::Off => UNLIMITED_KEY,
112 Self::Fps(_) => FPS_KEY,
113 }
114 }
115
116 /// Its name on an options screen, in `ctx`'s language: `"60 FPS"`
117 /// ([`FPS_KEY`], with the number as `{n}`) or `"Unlimited"`
118 /// ([`UNLIMITED_KEY`]).
119 pub fn name<G: crate::GameData>(self, ctx: &crate::Ctx<G>) -> String {
120 match self {
121 Self::Off => ctx.text(UNLIMITED_KEY).to_owned(),
122 Self::Fps(n) => ctx.text_with(FPS_KEY, &[("n", &n)]),
123 }
124 }
125}
126
127/// Why [`FrameCap::fps`] refused a cap.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
129#[non_exhaustive]
130pub enum CapError {
131 /// Fewer than [`MIN_FPS`] or more than [`MAX_FPS`] frames a second.
132 #[error("a frame cap is {MIN_FPS} to {MAX_FPS} frames a second, or off")]
133 OutOfRange,
134}
135
136/// A request for a frame cap
137/// ([`Ctx::request_frame_cap`](crate::Ctx::request_frame_cap)): a handle to
138/// check it with ([`Ctx::frame_cap_status`](crate::Ctx::frame_cap_status)),
139/// as a promise is, without waiting on it.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub struct CapRequest {
142 serial: u32,
143}
144
145impl CapRequest {
146 /// The request with `serial`, the serial its change took.
147 pub(crate) const fn new(serial: u32) -> Self {
148 Self { serial }
149 }
150
151 /// The serial number its change took: the platform reports it once it
152 /// has applied the cap ([`Pacing::serial`]).
153 pub const fn serial(self) -> u32 {
154 self.serial
155 }
156}
157
158/// Where a [`CapRequest`] is.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160#[non_exhaustive]
161pub enum CapStatus {
162 /// The platform hasn't reported applying it yet. A platform that never
163 /// reports (one whose rate is fixed and that doesn't say so) leaves
164 /// every request here.
165 Pending,
166 /// The platform applied it: its report.
167 Applied(Pacing),
168 /// A later change of the cap (another request, the options screen, a
169 /// profile switched to) took the next serial: the cap asked for is no
170 /// longer the one in force, whether or not it had applied.
171 Superseded,
172}
173
174/// What the platform reports of its pacing, before each frame
175/// ([`Game::set_pacing`](crate::Game::set_pacing)): the cap it applied and
176/// its serial, whether it asked for vsync, and the frames a second it
177/// measured. Built with [`Pacing::new`]; the rest is assigned.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
179#[serde(deny_unknown_fields)]
180#[non_exhaustive]
181pub struct Pacing {
182 /// The cap applied.
183 pub cap: FrameCap,
184 /// The serial the cap came with: the latest the platform was handed.
185 pub serial: u32,
186 /// Whether the platform asked the display's driver for vsync (the
187 /// driver may not give it).
188 pub vsync: bool,
189 /// The frames a second it measured: a rolling average, 0 before it has
190 /// measured.
191 pub fps: u16,
192}
193
194impl Pacing {
195 /// A report of `cap` applied, which came with `serial`: no vsync and
196 /// nothing measured until assigned.
197 pub const fn new(cap: FrameCap, serial: u32) -> Self {
198 Self {
199 cap,
200 serial,
201 vsync: false,
202 fps: 0,
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn a_cap_is_15_to_1000_frames_a_second() {
213 assert_eq!(FrameCap::fps(14), Err(CapError::OutOfRange));
214 assert_eq!(FrameCap::fps(1001), Err(CapError::OutOfRange));
215 assert_eq!(FrameCap::fps(15), Ok(FrameCap::Fps(15)));
216 assert_eq!(FrameCap::fps(1000), Ok(FrameCap::Fps(1000)));
217 assert_eq!(FrameCap::default(), FrameCap::Fps(60));
218 assert!(CapError::OutOfRange.to_string().contains("15 to 1000"));
219 }
220
221 #[test]
222 fn a_cap_out_of_range_is_held_to_it() {
223 assert_eq!(FrameCap::Off.limit(), None);
224 assert_eq!(FrameCap::Fps(60).limit(), Some(60));
225 assert_eq!(FrameCap::Fps(0).limit(), Some(MIN_FPS));
226 assert_eq!(FrameCap::Fps(5000).limit(), Some(MAX_FPS));
227 }
228
229 #[test]
230 fn a_cap_is_named_by_its_text_key() {
231 assert_eq!(FrameCap::Off.key(), "options.value.unlimited");
232 assert_eq!(FrameCap::Fps(60).key(), "options.value.fps");
233 assert_eq!(
234 (UNLIMITED_KEY, FPS_KEY),
235 (FrameCap::Off.key(), FrameCap::Fps(1).key())
236 );
237 }
238
239 #[test]
240 fn the_choices_are_in_range_and_end_with_no_cap() {
241 for cap in CHOICES {
242 let checked = cap.limit().map(FrameCap::fps);
243 assert_eq!(checked, cap.limit().map(|_| Ok(cap)));
244 }
245 assert_eq!(CHOICES.last(), Some(&FrameCap::Off));
246 assert!(CHOICES.contains(&FrameCap::DEFAULT));
247 }
248
249 #[test]
250 fn a_cap_is_saved_by_name() {
251 assert_eq!(ron::to_string(&FrameCap::Fps(60)).unwrap(), "Fps(60)");
252 assert_eq!(ron::from_str::<FrameCap>("Off"), Ok(FrameCap::Off));
253 }
254
255 #[test]
256 fn a_report_starts_unmeasured() {
257 let pacing = Pacing::new(FrameCap::Off, 3);
258 assert_eq!((pacing.cap, pacing.serial), (FrameCap::Off, 3));
259 assert!(!pacing.vsync);
260 assert_eq!(pacing.fps, 0);
261 assert_eq!(CapRequest::new(4).serial(), 4);
262 }
263}