Redesigned clock logic to track real and game time independently

This commit is contained in:
Joshua Barretto 2026-06-02 22:06:05 +01:00
parent 8ff8641f6a
commit 3990d336a1
11 changed files with 71 additions and 32 deletions

View file

@ -89,7 +89,7 @@ fn main() {
client.send_chat(msg)
}
let events = match client.tick(comp::ControllerInputs::default(), clock.dt()) {
let events = match client.tick(comp::ControllerInputs::default(), clock.game_dt()) {
Ok(events) => events,
Err(err) => {
error!("Error: {:?}", err);

View file

@ -103,7 +103,7 @@ impl BotClient {
for (username, client) in self.bot_clients.iter_mut() {
trace!(?username, "tick");
let _msgs: Result<Vec<veloren_client::Event>, veloren_client::Error> =
client.tick(comp::ControllerInputs::default(), self.clock.dt());
client.tick(comp::ControllerInputs::default(), self.clock.game_dt());
}
}

View file

@ -135,7 +135,7 @@ fn run_client(
let mut tick = |client: &mut Client| -> Result<(), veloren_client::Error> {
clock.tick();
client.tick_network(clock.dt())?;
client.tick_network(clock.real_dt())?;
Ok(())
};

View file

@ -3596,7 +3596,7 @@ mod tests {
//tick
let events_result: Result<Vec<Event>, Error> =
client.tick(ControllerInputs::default(), clock.dt());
client.tick(ControllerInputs::default(), clock.game_dt());
//chat functionality
client.send_chat("foobar".to_string());

View file

@ -9,6 +9,10 @@ pub struct Clock {
target_dt: Duration,
// Working state
/// The amount of real time that has passed on the clock
real_time: Duration,
/// The amount of game time that has passed on the clock
game_time: Duration,
/// The last time the clock was ticked
last_tick: Instant,
/// The last time we started performing work
@ -16,7 +20,6 @@ pub struct Clock {
/// The number of ticks that have elapsed so far
tick: u64,
// Outputs
/// The average time between ticks, seconds
average_dt: f64,
/// The average amount of time within each tick in which we're busy (i.e:
@ -25,7 +28,9 @@ pub struct Clock {
/// The average amount of variance between ticks
average_variance: f64,
/// The time that passed between the last tick, and the tick before it
last_dt: f64,
last_real_dt: f64,
/// The dt to be used for the next game tick, in game time.
last_game_dt: f64,
}
pub struct ClockStats {
@ -40,13 +45,20 @@ pub struct ClockStats {
}
/// The weighting used to calculate averages. Must be > 0.0. 1.0 = no averaging.
const SMOOTH_WEIGHT: f64 = 0.025;
const SMOOTH_WEIGHT: f64 = 0.05;
/// The proportion of the difference between real and game time that gets
/// applied each tick to keep the two aligned.
const NUDGE_RATE: f64 = 0.05;
/// The maximum dt that the game should ever run at.
const MAX_GAME_DT: f64 = 1.0 / 5.0;
impl Clock {
pub fn new(target_dt: Duration) -> Self {
Self {
target_dt,
real_time: Duration::ZERO,
game_time: Duration::ZERO,
last_tick: Instant::now(),
last_work: Instant::now(),
tick: 0,
@ -54,14 +66,16 @@ impl Clock {
average_dt: target_dt.as_secs_f64(),
average_busy: target_dt.as_secs_f64(),
average_variance: 0.0,
last_dt: target_dt.as_secs_f64(),
last_real_dt: target_dt.as_secs_f64(),
last_game_dt: target_dt.as_secs_f64(),
}
}
pub fn set_target_dt(&mut self, target_dt: Duration) {
if target_dt != self.target_dt {
self.target_dt = target_dt;
// The target dt has changed, throw out the existing state to avoid problems
// The target dt has changed, throw out the existing stats to avoid problems
self.average_dt = target_dt.as_secs_f64();
self.average_busy = target_dt.as_secs_f64();
self.average_variance = 0.0;
@ -76,9 +90,9 @@ impl Clock {
}
}
pub fn dt(&self) -> Duration { Duration::from_secs_f64(self.last_dt) }
pub fn real_dt(&self) -> Duration { Duration::from_secs_f64(self.last_real_dt) }
pub fn get_stable_dt(&self) -> Duration { Duration::from_secs_f64(self.average_dt) }
pub fn game_dt(&self) -> Duration { Duration::from_secs_f64(self.last_game_dt) }
pub fn tick(&mut self) {
span!(_guard, "tick", "Clock::tick");
@ -86,15 +100,19 @@ impl Clock {
// Give the tick thread realtime priority to minimise stuttering. Don't do this
// all the time to avoid upsetting the scheduler.
if self.tick % 30 == 0 {
if self.tick == 0
/* .is_multiple_of(30) */
{
use thread_priority::*;
// Try to target a tick period that's consistent with our current FPS (a low but
// consistent framerate is a better outcome than one that's faster on paper but
// is bouncing around all over the place).
/*
let stable_dt = self.average_busy
// Don't try to schedule for a tick rate that's higher than our target, even if we
// could achieve it.
.max(self.target_dt.as_secs_f64());
*/
_ = std::thread::current().set_priority_and_policy(
ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo),
// ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Deadline),
@ -112,10 +130,13 @@ impl Clock {
let this_tick = Instant::now();
// Calculate average metrics
let busy_time = self.last_work.elapsed().as_secs_f64();
self.average_busy = Lerp::lerp(self.average_busy, busy_time, SMOOTH_WEIGHT);
let tick_time = self.last_tick.elapsed().as_secs_f64();
self.average_dt = Lerp::lerp(self.average_dt, tick_time, SMOOTH_WEIGHT);
let variance = (tick_time - self.average_dt).abs();
self.average_variance = Lerp::lerp(self.average_variance, variance, SMOOTH_WEIGHT);
@ -129,9 +150,28 @@ impl Clock {
spin_sleep::sleep(sleep_dur);
}
// Update clock state
self.last_tick = this_tick;
self.last_work = Instant::now();
self.last_dt = tick_time;
// Progress real and game time
self.real_time += Duration::from_secs_f64(self.last_real_dt);
self.game_time += Duration::from_secs_f64(self.last_game_dt);
// Calculate the deltas for both real and game clocks. The real clock is
// absolute: we can't alter the progression of time. However, we can
// alter the game clock and nudge is toward real time. The reason we
// don't want to keep the two *exactly* in time is that a lag spike on a
// single tick would cause a corresponding jump in dt on the next tick, which
// might produce strange results for any dt-dependent gameplay systems.
// Instead, we gradually nudge the game time back toward real time over
// several ticks.
self.last_real_dt = tick_time;
self.last_game_dt = (self.average_dt
+ (self.real_time.as_secs_f64() - self.game_time.as_secs_f64()) * NUDGE_RATE)
.min(MAX_GAME_DT);
self.tick += 1;
}
}

View file

@ -321,7 +321,7 @@ fn server_loop(
}
let events = server
.tick(Input::default(), clock.dt())
.tick(Input::default(), clock.game_dt())
.expect("Failed to tick server");
for event in events {

View file

@ -253,10 +253,10 @@ impl PlayState for CharSelectionState {
// Tick the client (currently only to keep the connection alive).
let localized_strings = &global_state.i18n.read();
let res = self
.client
.borrow_mut()
.tick(comp::ControllerInputs::default(), global_state.clock.dt());
let res = self.client.borrow_mut().tick(
comp::ControllerInputs::default(),
global_state.clock.game_dt(),
);
match res {
Ok(events) => {
let mut join_metadata = None;

View file

@ -274,7 +274,10 @@ impl PlayState for MainMenuState {
// Tick the client to keep the connection alive if we are waiting on pipelines
if let InitState::Pipeline(client, _) = &mut self.init {
match client.tick(comp::ControllerInputs::default(), global_state.clock.dt()) {
match client.tick(
comp::ControllerInputs::default(),
global_state.clock.game_dt(),
) {
Ok(events) => {
for event in events {
match event {
@ -399,7 +402,7 @@ impl PlayState for MainMenuState {
// Maintain the UI.
for event in self
.main_menu_ui
.maintain(global_state, global_state.clock.dt())
.maintain(global_state, global_state.clock.real_dt())
{
match event {
MainMenuEvent::LoginAttempt {

View file

@ -179,10 +179,10 @@ impl PlayState for ServerInfoState {
}
if let Some(char_select) = &mut self.char_select
&& let Err(err) = char_select
.client()
.borrow_mut()
.tick(comp::ControllerInputs::default(), global_state.clock.dt())
&& let Err(err) = char_select.client().borrow_mut().tick(
comp::ControllerInputs::default(),
global_state.clock.game_dt(),
)
{
error!(?err, "[server_info] Failed to tick the client");
global_state.info_message =

View file

@ -608,7 +608,7 @@ impl PlayState for SessionState {
let client = self.client.borrow();
let player_entity = client.entity();
let dt = global_state.clock.get_stable_dt().as_secs_f32();
let dt = global_state.clock.real_dt().as_secs_f32();
#[cfg(feature = "discord")]
if global_state.discord.is_active()
@ -1646,11 +1646,7 @@ impl PlayState for SessionState {
// Runs if either in a multiplayer server or the singleplayer server is unpaused
if !global_state.paused() {
// Perform an in-game tick.
match self.tick(
global_state.clock.get_stable_dt(),
global_state,
&mut outcomes,
) {
match self.tick(global_state.clock.game_dt(), global_state, &mut outcomes) {
Ok(TickAction::Continue) => {}, // Do nothing
Ok(TickAction::Disconnect) => return PlayStateResult::Pop, // Go to main menu
Err(Error::ClientError(error)) => {
@ -1753,7 +1749,7 @@ impl PlayState for SessionState {
global_state,
&debug_info,
self.scene.camera(),
global_state.clock.get_stable_dt(),
global_state.clock.real_dt(),
HudInfo {
is_aiming,
active_mine_tool,

View file

@ -217,7 +217,7 @@ fn run_server(mut server: Server, stop_server_r: Receiver<()>, paused: Arc<Atomi
}
let events = server
.tick(Input::default(), clock.dt())
.tick(Input::default(), clock.game_dt())
.expect("Failed to tick server!");
for event in events {