fix: cap fixed-step catch-up

This commit is contained in:
2026-06-22 17:02:00 +04:00
parent ccd61c05b0
commit 42441082f0
+74 -12
View File
@@ -105,6 +105,8 @@ pub struct FixedStepClock {
tick: Tick, tick: Tick,
paused: bool, paused: bool,
platform_event_collections: u64, platform_event_collections: u64,
dropped_presentation_millis: u64,
dropped_presentation_frames: u64,
} }
/// Fixed-step configuration. /// Fixed-step configuration.
@@ -112,11 +114,16 @@ pub struct FixedStepClock {
pub struct FixedStepConfig { pub struct FixedStepConfig {
/// Milliseconds per simulation tick. /// Milliseconds per simulation tick.
pub step_millis: u32, pub step_millis: u32,
/// Maximum simulation ticks executed for a single presentation frame.
pub max_steps_per_frame: u32,
} }
impl Default for FixedStepConfig { impl Default for FixedStepConfig {
fn default() -> Self { fn default() -> Self {
Self { step_millis: 16 } Self {
step_millis: 16,
max_steps_per_frame: 8,
}
} }
} }
@@ -176,7 +183,9 @@ impl std::fmt::Display for WorldError {
Self::DuplicateOriginalObjectId(id) => { Self::DuplicateOriginalObjectId(id) => {
write!(f, "original object id {} is already registered", id.0) write!(f, "original object id {} is already registered", id.0)
} }
Self::InvalidFixedStep => write!(f, "fixed-step configuration must be non-zero"), Self::InvalidFixedStep => {
write!(f, "fixed-step configuration values must be non-zero")
}
} }
} }
} }
@@ -433,9 +442,10 @@ pub fn canonical_state_hash(world: &World) -> StateHash {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. /// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> { pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
if config.step_millis == 0 { if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep); return Err(WorldError::InvalidFixedStep);
} }
Ok(FixedStepClock { Ok(FixedStepClock {
@@ -443,6 +453,8 @@ pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, World
tick: Tick(0), tick: Tick(0),
paused: false, paused: false,
platform_event_collections: 0, platform_event_collections: 0,
dropped_presentation_millis: 0,
dropped_presentation_frames: 0,
}) })
} }
@@ -462,13 +474,14 @@ pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. /// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn advance_fixed_step( pub fn advance_fixed_step(
clock: &mut FixedStepClock, clock: &mut FixedStepClock,
config: FixedStepConfig, config: FixedStepConfig,
elapsed_millis: u64, elapsed_millis: u64,
) -> Result<u32, WorldError> { ) -> Result<u32, WorldError> {
if config.step_millis == 0 { if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep); return Err(WorldError::InvalidFixedStep);
} }
if clock.paused { if clock.paused {
@@ -476,12 +489,20 @@ pub fn advance_fixed_step(
} }
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis); clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
let step = u64::from(config.step_millis); let step = u64::from(config.step_millis);
let mut ticks = 0_u32; let available_steps = clock.accumulated_millis / step;
while clock.accumulated_millis >= step { let ticks_u64 = available_steps.min(u64::from(config.max_steps_per_frame));
clock.accumulated_millis -= step; let consumed = ticks_u64.saturating_mul(step);
clock.tick.0 = clock.tick.0.saturating_add(1); if available_steps > u64::from(config.max_steps_per_frame) {
ticks = ticks.saturating_add(1); let dropped = clock.accumulated_millis.saturating_sub(consumed);
clock.dropped_presentation_millis =
clock.dropped_presentation_millis.saturating_add(dropped);
clock.dropped_presentation_frames = clock.dropped_presentation_frames.saturating_add(1);
clock.accumulated_millis = 0;
} else {
clock.accumulated_millis = clock.accumulated_millis.saturating_sub(consumed);
} }
let ticks = u32::try_from(ticks_u64).unwrap_or(u32::MAX);
clock.tick.0 = clock.tick.0.saturating_add(ticks_u64);
Ok(ticks) Ok(ticks)
} }
@@ -497,6 +518,18 @@ pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
clock.platform_event_collections clock.platform_event_collections
} }
/// Returns total presentation time dropped by fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_millis(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_millis
}
/// Returns how many presentation frames exceeded fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_frames(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_frames
}
/// Runs end-frame callbacks in stable sequence order. /// Runs end-frame callbacks in stable sequence order.
#[must_use] #[must_use]
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> { pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
@@ -805,7 +838,10 @@ mod tests {
#[test] #[test]
fn fixed_step_pause_and_long_determinism_are_stable() { fn fixed_step_pause_and_long_determinism_are_stable() {
let config = FixedStepConfig { step_millis: 20 }; let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 8,
};
let mut clock = fixed_step_clock(config).expect("clock"); let mut clock = fixed_step_clock(config).expect("clock");
collect_platform_events(&mut clock); collect_platform_events(&mut clock);
set_paused(&mut clock, true); set_paused(&mut clock, true);
@@ -829,6 +865,32 @@ mod tests {
assert_eq!(first_hashes, second_hashes); assert_eq!(first_hashes, second_hashes);
} }
#[test]
fn fixed_step_catch_up_is_capped_and_reports_dropped_time() {
let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 3,
};
let mut clock = fixed_step_clock(config).expect("clock");
assert_eq!(advance_fixed_step(&mut clock, config, 95), Ok(3));
assert_eq!(fixed_step_tick(&clock), Tick(3));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(0));
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(1));
assert_eq!(fixed_step_tick(&clock), Tick(4));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(
advance_fixed_step(&mut clock, config, u64::MAX),
Ok(config.max_steps_per_frame)
);
assert_eq!(dropped_presentation_frames(&clock), 2);
}
#[test] #[test]
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() { fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
let callbacks = vec![ let callbacks = vec![