Time, frames and ticks
TDM engine has:
- many variables storing current "time"
- some clocks, tics, and timers
- some cvars causing wildly different behavior of time
- some time-modifying cvars
It is often necessary to understand the differences between various notions of time. The purpose of this article is to help coders with it.
Preliminary note: the original Doom 3 engine works under 60 FPS limit, with fixed game modeling timestep of 16 ms. It is also the default mode of TDM (as of 2.08). The new Uncapped FPS mode was added specifically in TDM: it can run with higher FPS, but models game with varying timestep. It can be enabled either as com_fixedTic 1 and by checkbox in graphics menu. The cvar was hijacked: it meant something completely different in original Doom 3 engine.
Times
The main types of time are:
- astronomical time. This time goes exactly as the physical time outside of your computer does.
- game time. This is the time going in-game in terms of gameplay. Mainly, all the physics are simulated based on this time.
Of course, even if you choose one of these types, the exact value depends also on things like (not counting obvious beginning of era and measurement units):
- Is the time paused or accelerated under some conditions? (e.g. some astronomical time can exclude time spent in main menu)
- Is the time save-game persistent, i.e. written to savefile and restored on load? (e.g. gameplay time shown to player at the end)
Some additional timelike quantities include:
- frame number (tr.frameCount). This often serves as time in renderer. Some time ago similar notions were also used as time in game code, but it is completely wrong in "uncapped FPS" mode.
- async tick number (com_ticNumber). This is a counter incremented in separate thread every with 60 FPS speed. It approximately controls game time in default mode (uncapped FPS disabled). See modeling section for details.
Notable values
gameLocal.time represents game time in milliseconds. It starts from zero when game/map is started. It is persistent on save/load. While it is kept roughly in sync with astronomical time, it can drift away due to accidental missed ticks and such. Also, it is paused when player is in main menu, when g_stopTime is enabled, and at "press attack to start" screen. As seen in idGameLocal::RunFrame, its speed is multiplied by g_timeModifier, which is often used for debugging purposes. This is the main time used everywhere: it controls physics, AI, animation, etc.
gameLocal.framenum is the number of game ticks modeled during current game. Unlike what words say, it has nothing to do with rendering frames. Game tick is one call of idGameLocal::RunFrame. Every such call, game time is incremented by the passed timestep. Obviously, timestep varies due to various factors (uncapped FPS, g_timeModifier), so number of game ticks and game time can't be obtained one from the other. This value is persistent on save/load, paused by same things as gameLocal.time.
tr.frameCount is number of idRenderSystemLocal::BeginFrame calls. This is the number of frames rendered, this it has little relation to game time or astronomical time. Used inside renderer for various "last updated" properties and etc. Should not be used outside renderer.
std::time(), Sys_GetTimeMicroseconds() both return astronomical time since year 1970. Obviously, they never stop. The latter function was added in TDM, and unless one second resolution is enough, better prefer it. Sys_Milliseconds() returns astronomical time since the moment executable was started. These values should not be used in general. Exceptions include: high-level time/game modeling code (to sync game time to astronomical), reporting gameplay time to user (use m_GamePlayTimer for this).
Sys_GetClockTicks() relies on RDTSC counter, and returns number of CPU clocks passed since some moment. God knows how it interacts with sleeping, variable CPU frequency, multicore coherency, etc., so it cannot be called exactly astronomical. It can only be used for high-precision low-duration benchmarking on single core (e.g. SIMD code benchmarks), and never for anything important.
gameLocal.m_GamePlayTimer (both _timePassed and _msecPassed) reflects how much astronomical time player has spent in action during current playthrough. It's sole purpose is to report "played for 45 minutes 13 seconds" to player when he finished mission. Never use it for anything related to gameplay/physics/rendering! While it runs with the speed of astronomical time, it is 1) saved/restored in savegames, and 2) paused when player is at main menu or at "press attack to start" screen.
com_ticNumber is the number of async ticks happened since async thread was started (most likely since executable was started) Async ticks are the calls of function idCommonLocal::SingleAsyncTic, happening on a separate thread with help of OS timer. It plays important role when uncapped FPS is off, since game ticks are more or less synchronized to async tics (although some drifts are possible as usual). Unless you decide to change high-level game modeling code, better not mess with this value.
com_frameNumber is number of times idCommonLocal::Frame was called. This is the upper-most function called in infinite loop. It is the number reported as "frame" by com_speeds. Not much else can be said here: just keep away from it.
com_frameTime is some sort of overall runtime. Looks astronomical, but with the a strict discrete nature of updates. It is often used for GUI modeling. For instance, game console is modeled based on this time, so slowing down game time via cvars does not reduce console usability. Note that it never stops or changes speed, unlike game time.
Important cvars
com_fixedTic: enables "Uncapped FPS" mode. When value is 0, old 60-FPS mode runs, and every game modeling step is fixed to 16 ms. When value is 0, FPS can go higher than 60, but game modeling timesteps vary.
com_maxTicTimestep, com_maxTicsPerFrame, com_maxFPS: additional parameters for uncapped FPS mode. They are used to cap timesteps in game modeling in order to make physics stable. See rope physics issue #4924 and Missing footsteps issue #4696 for rationale behind limits.
timescale: accelerates async ticks, making them happen faster. The intended effect is that game ticks (which are more of less tied to async ticks) will also happen faster, so the whole game will accelerate. I think it should only work without uncapped FPS. Unlike effect of g_timeModifier, timesteps remain the same, but ticks happen more frequently than 60 times per second.
g_timeModifier: accelerates game time by this multiplier. Note that it is applied directly to timestep in idGameLocal::RunFrame, it does not change the number of modeled game ticks, but makes every timestep longer. So it affects physics stability: setting high values can easily break things.
g_stopTime: stops game time from running completely. Game ticks don't happen, game time does not increase. The only exception is that idPlayer::Think is called in place of game ticks, so player can move around. Sometimes it's very useful to enable it straight in debugger, in order to teleport to the place where some event has happened.
Modeling
The outermost loop is located in WinMain in win_main.cpp. It simply runs idCommonLocal::Frame every iteration.
Before main loop starts, a separate thread is started in Sys_StartAsyncThread, calling Sys_AsyncThread function every 3 ms (as of 2.08) by OS timer (SetWaitableTimer). This separate thread generates async tics. The function calls idCommonLocal::Async, which looks at astronomical time and decides how many async tics to generate (targeting exact 60 Hz frequency). The async ticks are done in idCommonLocal::SingleAsyncTic, which increments com_ticNumber and pushes some more data to sound output.
The iteration of main loop on main thread idCommonLocal::Frame calls:
- idSession::Frame --- responsible for game modeling and possible waiting if working too fast.
- idSession::UpdateScreen --- backend renders new frame to display, waiting for VSync if r_swapInterval is enabled.
idSessionLocal::Frame is the most interesting place because it explains how rendering frames, async tics and game ticks are all tied together. While this function is called once per rendering frame, it does not necessarily performs one game tick per frame. The way it works depends on whether Uncapped FPS is enabled or not:
- Disabled (default): Looks at async ticks number com_ticNumber, let's says it has increased by K since last time. If K == 0, then waits (nothing to do yet). Otherwise, models K game ticks, with timestep = 16 ms each.
- Enabled: Looks at how much astronomical time has passed since last time, let's say it is dT ms. Unless this dT is too large, models one game tick with timestamp = dT. In order to cap timestep and stabilize game physics, timesteps longer than 17 ms are broken into several game ticks of equal duration. If dT is too small, then the function spinlocks a bit (limits FPS from above).
Both code paths have a limit of doing 10 game ticks per call. If it is exceeded, then deltatime is lowered, allowing game time to lag behind astronomical time. Also, in both cases game ticks are not modeled immediately due because of com_smp, only some variables are set.
In fact, both approaches are trying to synchronize game time with astronomical time, but the old approach does this with intermediate layer of async tics generates by separate thread, while uncapped FPS mode is tied to astronomical time directly. The former is more deterministic in terms of game modeling, while the latter is more flexible to adapt timestep at higher FPS.
idSessionLocal::ActivateFrontend is the function which calls:
- idSessionLocal::RunGameTics --- performs game ticks modeling.
- idSessionLocal::DrawFrame --- runs renderer frontend.
It is called on separate thread if com_smp is enabled, and directly from idSessionLocal::UpdateScreen if it is not.
idSessionLocal::RunGameTics calls idGameLocal::RunFrame with timestep computed by idSessionLocal::Frame, as many times as required. The latter function actually does the game modeling, increasing game tick number gameLocal.framenum and game time gameLocal.time.
Misc
An attentive reader might have noticed that async tics are generated at frequency 60 Hz, game modeling timestep is 16 ms, and these two numbers don't match. As you know, VSync on standard monitors happens 60 times per second with good precision. In order to avoid micro-stutters, game ticks should happen with same frequency. In original Doom 3, async tics happened every 16 ms, which caused a regular micro-stutter. This was fixed as part of #4614 (linked forum threads are interesting to read). But since most of the game code stores time in integer number of milliseconds, the timestep had to remain 16 ms. This leads to a strange effect that when uncapped FPS is off, game runs slower by 4% =)