Unlimited FPS with fixed physics timestep in MonoGame and Nez
MonoGame has two built-in versions of how the engine can be used: Either with a fixed framerate and fixed physics or with a variable framerate and variable physics. Both of these have problems when the computer is too slow to run the game. In the former, the entire game will start to slow down, and in the latter, the physics get weird when the game can’t process ticks with small enough timesteps. These are unfortunately also limitations coming straight from XNA, and I believe these are unfixable, as any changes in the APIs would cause incompatibilities between the two.
Game engines that support fixed physics and variable fps have two different update functions: one for physics and one for rendering. MonoGame has only one and is thus functionally incompatible with such an idea. How much work do we have to do if we modify MonoGame?
Background reading ¶
The excellent post Fix Your
Timestep! has a good
explanation of how the various calculations work. For reference, MonoGamo
supports the ‘Fixed delta time’ (with added sleeps if the computer is fast
enough) and ‘Variable delta time’ approaches, which can be switched with the
IsFixedTimeStep
option.
Patching MonoGame ¶
With the last example from the page, we should be able to adjust the code in MonoGame’s Tick function so that it combines the two existing loops into one that has the best of both worlds. Let’s start with some supporting changes:
(these patches are slightly reduced in size; see the repo for complete patches)
diff --git a/MonoGame.Framework/GameTime.cs b/MonoGame.Framework/GameTime.cs
index 6e39e53a7..9829c942b 100644
--- a/MonoGame.Framework/GameTime.cs
+++ b/MonoGame.Framework/GameTime.cs
@@ -30,6 +30,8 @@ namespace Microsoft.Xna.Framework
/// </summary>
public bool IsRunningSlowly { get; set; }
+ public float Alpha { get; set; }
+
/// <summary>
/// Create a <see cref="GameTime"/> instance with a <see cref="TotalGameTime"/> and
/// <see cref="ElapsedGameTime"/> of <code>0</code>.
diff --git a/MonoGame.Framework/IUpdateable.cs b/MonoGame.Framework/IUpdateable.cs
index 4215210fc..5d0ab2432 100644
--- a/MonoGame.Framework/IUpdateable.cs
+++ b/MonoGame.Framework/IUpdateable.cs
@@ -16,7 +16,8 @@ namespace Microsoft.Xna.Framework
/// Called when this <see cref="IUpdateable"/> should update itself.
/// </summary>
/// <param name="gameTime">The elapsed time since the last call to <see cref="Update"/>.</param>
- void Update(GameTime gameTime);
+ void FixedUpdate(GameTime gameTime);
+ void DrawUpdate(GameTime gameTime);
#endregion
#region Events
We split the existing update functions into two in order to support separate
updates. In addition, we also added a new Alpha
field to GameTime
.
What about the actual game loop? The patch is long-ish, as it essentially removes the existing game loop and replaces it with a new one.
diff --git a/MonoGame.Framework/Game.cs b/MonoGame.Framework/Game.cs
index 8894ae04e..30474bb39 100644
--- a/MonoGame.Framework/Game.cs
+++ b/MonoGame.Framework/Game.cs
@@ -523,8 +523,6 @@ namespace Microsoft.Xna.Framework
// any change fully in both the fixed and variable timestep
// modes across multiple devices and platforms.
- RetryTick:
-
if (!IsActive && (InactiveSleepTime.TotalMilliseconds >= 1.0))
{
#if WINDOWS_UAP
@@ -545,84 +543,57 @@ namespace Microsoft.Xna.Framework
_accumulatedElapsedTime += TimeSpan.FromTicks(currentTicks - _previousTicks);
_previousTicks = currentTicks;
- if (IsFixedTimeStep && _accumulatedElapsedTime < TargetElapsedTime)
- {
- // Sleep for as long as possible without overshooting the update time
- var sleepTime = (TargetElapsedTime - _accumulatedElapsedTime).TotalMilliseconds;
- // We only have a precision timer on Windows, so other platforms may still overshoot
-#if WINDOWS && !DESKTOPGL
- MonoGame.Framework.Utilities.TimerHelper.SleepForNoMoreThan(sleepTime);
-#elif WINDOWS_UAP
- lock (_locker)
- if (sleepTime >= 2.0)
- System.Threading.Monitor.Wait(_locker, 1);
-#elif DESKTOPGL || ANDROID || IOS
- if (sleepTime >= 2.0)
- System.Threading.Thread.Sleep(1);
-#endif
- // Keep looping until it's time to perform the next update
- goto RetryTick;
- }
-
// Do not allow any update to take longer than our maximum.
if (_accumulatedElapsedTime > _maxElapsedTime)
_accumulatedElapsedTime = _maxElapsedTime;
- if (IsFixedTimeStep)
- {
- _gameTime.ElapsedGameTime = TargetElapsedTime;
- var stepCount = 0;
-
- // Perform as many full fixed length time steps as we can.
- while (_accumulatedElapsedTime >= TargetElapsedTime && !_shouldExit)
- {
- _gameTime.TotalGameTime += TargetElapsedTime;
- _accumulatedElapsedTime -= TargetElapsedTime;
- ++stepCount;
-
- DoUpdate(_gameTime);
- }
-
- //Every update after the first accumulates lag
- _updateFrameLag += Math.Max(0, stepCount - 1);
-
- //If we think we are running slowly, wait until the lag clears before resetting it
- if (_gameTime.IsRunningSlowly)
- {
- if (_updateFrameLag == 0)
- _gameTime.IsRunningSlowly = false;
- }
- else if (_updateFrameLag >= 5)
- {
- //If we lag more than 5 frames, start thinking we are running slowly
- _gameTime.IsRunningSlowly = true;
- }
-
- //Every time we just do one update and one draw, then we are not running slowly, so decrease the lag
- if (stepCount == 1 && _updateFrameLag > 0)
- _updateFrameLag--;
-
- // Draw needs to know the total elapsed time
- // that occured for the fixed length updates.
- _gameTime.ElapsedGameTime = TimeSpan.FromTicks(TargetElapsedTime.Ticks * stepCount);
- }
- else
- {
- // Perform a single variable length update.
- _gameTime.ElapsedGameTime = _accumulatedElapsedTime;
- _gameTime.TotalGameTime += _accumulatedElapsedTime;
- _accumulatedElapsedTime = TimeSpan.Zero;
-
- DoUpdate(_gameTime);
- }
+ _gameTime.ElapsedGameTime = TargetElapsedTime;
+ var stepCount = 0;
+
+ // Perform as many full fixed length time steps as we can.
+ while (_accumulatedElapsedTime >= TargetElapsedTime && !_shouldExit)
+ {
+ _gameTime.TotalGameTime += TargetElapsedTime;
+ _accumulatedElapsedTime -= TargetElapsedTime;
+ ++stepCount;
+
+ DoFixedUpdate(_gameTime);
+ }
+
+ //Every update after the first accumulates lag
+ _updateFrameLag += Math.Max(0, stepCount - 1);
+
+ //If we think we are running slowly, wait until the lag clears before resetting it
+ if (_gameTime.IsRunningSlowly)
+ {
+ if (_updateFrameLag == 0)
+ _gameTime.IsRunningSlowly = false;
+ }
+ else if (_updateFrameLag >= 5)
+ {
+ //If we lag more than 5 frames, start thinking we are running slowly
+ _gameTime.IsRunningSlowly = true;
+ }
+
+ //Every time we just do one update and one draw, then we are not running slowly, so decrease the lag
+ if (stepCount == 1 && _updateFrameLag > 0)
+ _updateFrameLag--;
+
+ // Draw needs to know the total elapsed time
+ // that occured for the fixed length updates.
+ _gameTime.ElapsedGameTime = TimeSpan.FromTicks(TargetElapsedTime.Ticks * stepCount);
+
+ _gameTime.Alpha = (float)_accumulatedElapsedTime.Ticks / (float)TargetElapsedTime.Ticks;
+
// Draw unless the update suppressed it.
if (!_suppressDraw)
There are some additional changes (some renaming of Update
-> FixedUpdate
, and
initial values of Alpha=0
) which I left out of here.
Using the patched MonoGame ¶
So we know the changes, how do we use them?
$ dotnet new install MonoGame.Templates.CSharp
$ dotnet new mgdesktopgl -o MyGame
$ cd MyGame
$ mkdir GameImpl
$ mv * GameImpl
$ git init
$ git submodule add https://github.com/MonoGame/MonoGame.git MonoGame
$ git submodule update --init --recursive
$ mkdir patches
$ cp /path/to/patch/file 0001-Unlocked-monogame.patch
$ cd ../MonoGame
$ git am --ignore-space-change ../patches/0001-Unlocked-monogame.patch
$ cd ../GameImpl
Edit out .csproj to reference the MonoGame repository rather than the MonoGame package:
diff --git a/GameImpl/MyGame.csproj b/GameImpl/MyGame.csproj
index 56aae2f..a7fb71d 100644
--- a/GameImpl/MyGame.csproj
+++ b/GameImpl/MyGame.csproj
@@ -19,11 +19,11 @@
<EmbeddedResource Include="Icon.bmp" />
</ItemGroup>
<ItemGroup>
- <PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.1.303" />
- <PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.303" />
+ <ProjectReference Include="..\MonoGame\MonoGame.Framework\MonoGame.Framework.DesktopGL.csproj" />
+ <ProjectReference Include="..\MonoGame\Tools\MonoGame.Content.Builder.Task\MonoGame.Content.Builder.Task.csproj" />
</ItemGroup>
+ <ItemGroup>
+ <Content Include="Content\bin\DesktopGL\**\*">
+ <Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
+ </ItemGroup>
<Target Name="RestoreDotnetTools" BeforeTargets="Restore">
I’m using Linux to build, so I had to do some additional hacks at this point:
$ wget https://raw.githubusercontent.com/MonoGame/MonoGame/develop/Tools/MonoGame.Effect.Compiler/mgfxc_wine_setup.sh $ chmod +x mgfxc_wine_setup.sh $ export MGFXC_WINE_PATH=~/.winemonogame $ export PATH=$PATH:/usr/lib/wine $ ./mgfxc_wine_setup.sh
When we try to build the game, we should get the following errors:
$ dotnet build
[...]
/path/to/MyGame/GameImpl/Game1.cs(33,29): error CS0115: 'Game1.Update(GameTime)': no suitable method found to override [/path/to/MyGame/GameImpl/MyGame.csproj]
We are using the modified MonoGame! Now we just need to change Game1.cs a bit:
diff --git a/GameImpl/Game1.cs b/GameImpl/Game1.cs
index eb2fbf8..498df55 100644
--- a/GameImpl/Game1.cs
+++ b/GameImpl/Game1.cs
@@ -1,4 +1,5 @@
-using Microsoft.Xna.Framework;
+using System;
+using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
@@ -11,12 +12,16 @@ public class Game1 : Game
float ballSpeed;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch;
+ public readonly int PhysicsFps;
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
+ this.PhysicsFps = 5; // Change this to something sensible later
+ TargetElapsedTime = TimeSpan.FromSeconds(1f / PhysicsFps);
+ IsFixedTimeStep = true;
}
@@ -30,14 +35,19 @@ public class Game1 : Game
// TODO: use this.Content to load your game content here
}
- protected override void Update(GameTime gameTime)
+ protected override void FixedUpdate(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
Exit();
// TODO: Add your update logic here
- base.Update(gameTime);
+ base.FixedUpdate(gameTime);
+ }
+
+ protected override void DrawUpdate(GameTime gameTime)
+ {
+ base.DrawUpdate(gameTime);
}
protected override void Draw(GameTime gameTime)
Actually using the thing ¶
Okay, that’s cool, what now? It builds, but how do we actually use it? Let’s
follow the MonoGame basic
tutorial
to create a moving ball. The automatic build for the content doesn’t seem to
work if the project is in a subdirectory, so we need to run dotnet mgcb Content/Content.mgcb
.
We can now see that the ball doesn’t move smoothly (because our physics fps is 5, or 200 ms per frame). But we were promised high fps, how do we get it?
We need to interpolate between the previous physics position and the current
one. This is as simple as saving the previous position, and calculating the position with Vector2.Lerp
:
diff --git a/GameImpl/Game1.cs b/GameImpl/Game1.cs
index af2ef0d..2b5f28c 100644
--- a/GameImpl/Game1.cs
+++ b/GameImpl/Game1.cs
@@ -9,6 +9,7 @@ public class Game1 : Game
{
Texture2D ballTexture;
Vector2 ballPosition;
+ Vector2 previousPosition;
float ballSpeed;
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch;
@@ -46,6 +47,8 @@ public class Game1 : Game
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
Exit();
+ previousPosition = ballPosition;
+
var kstate = Keyboard.GetState();
if (kstate.IsKeyDown(Keys.Up))
@@ -98,11 +101,13 @@ public class Game1 : Game
{
GraphicsDevice.Clear(Color.CornflowerBlue);
+ var renderPosition = Vector2.Round(Vector2.Lerp(previousPosition, ballPosition, gameTime.Alpha));
+
// TODO: Add your drawing code here
_spriteBatch.Begin();
_spriteBatch.Draw(
ballTexture,
- ballPosition,
+ renderPosition,
null,
Color.White,
0f,
After that change, the ball is moving smoothly despite physics running at 5 fps. The ball is lagging one frame behind the actual physics tick.
Going further with Nez ¶
I like using Nez as an ECS system (+ whatever nice additional tools it happens to come with). Unfortunately, it requires relatively large changes to support our customized MonoGame.
- The essential needed changes are:
- Set Nez to use the local MonoGame instead of the packaged version
- Replace the
Update
function inIUpdatable
withFixedUpdate
andDrawUpdate
- Replace almost all calls to
Update
withFixedUpdate
- there are some calls which should use `DrawUpdate
- Add
PreviousTransform
andGraphicsTransform
toEntity
. - Set the
PreviousTransform
to the value ofTransform
at everyFixedUpdate
call - Set the
GraphicsTransform
to the pointTime.Alpha
betweenPreviousTransform
andTransform
at everyDrawUpdate
call - Go through usages of
Entity.Transform
and change those toEntity.GraphicsTransform
if necessary- Notably:
SpriteRenderer
andCamera
- Notably:
All in all, that is quite a lot of changes:
$ wc -l patches/0001-Patched-Nez.patch
2368 patches/0001-Patched-Nez.patch
Here are the most important ones:
Change Nez to use the local MonoGame:
diff --git a/Nez.Portable/Nez.MG38.csproj b/Nez.Portable/Nez.MG38.csproj
index 5d7632b5..c4fd4fa7 100644
--- a/Nez.Portable/Nez.MG38.csproj
+++ b/Nez.Portable/Nez.MG38.csproj
@@ -5,7 +5,7 @@
<Import Sdk="Microsoft.NET.Sdk" Project="Sdk.props" />
<PropertyGroup>
- <TargetFrameworks>netstandard2.0</TargetFrameworks>
+ <TargetFrameworks>net6.0</TargetFrameworks>
<AssemblyName>Nez</AssemblyName>
<RootNamespace>Nez</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
@@ -34,7 +34,7 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.0.1641" />
+ <ProjectReference Include="..\..\MonoGame\MonoGame.Framework\MonoGame.Framework.DesktopGL.csproj" />
<PackageReference Include="System.Drawing.Common" Version="5.0.2" />
</ItemGroup>
Replace the Update
function in IUpdatable
with FixedUpdate
and DrawUpdate
:
diff --git a/Nez.Portable/ECS/Components/IUpdatable.cs b/Nez.Portable/ECS/Components/IUpdatable.cs
index c71f53c7..10e760ea 100644
--- a/Nez.Portable/ECS/Components/IUpdatable.cs
+++ b/Nez.Portable/ECS/Components/IUpdatable.cs
@@ -12,7 +12,8 @@ namespace Nez
bool Enabled { get; }
int UpdateOrder { get; }
- void Update();
+ void FixedUpdate();
+ void DrawUpdate();
}
Add PreviousTransform
and GraphicsTransform
to Entity
, set the PreviousTransform
to the value of Transform
at every FixedUpdate
call, set the GraphicsTransform
to the point Time.Alpha
between PreviousTransform
and Transform
at every DrawUpdate
call:
diff --git a/Nez.Portable/ECS/Entity.cs b/Nez.Portable/ECS/Entity.cs
index cb054edc..bd59d84a 100644
--- a/Nez.Portable/ECS/Entity.cs
+++ b/Nez.Portable/ECS/Entity.cs
@@ -32,6 +32,9 @@ namespace Nez
/// </summary>
public readonly Transform Transform;
+ public readonly Transform PreviousTransform = new Transform(null);
+ public readonly Transform GraphicsTransform = new Transform(null);
+
/// <summary>
/// list of all the components currently attached to this entity
/// </summary>
@@ -386,7 +389,18 @@ namespace Nez
/// <summary>
/// called each frame as long as the Entity is enabled
/// </summary>
- public virtual void Update() => Components.Update();
+ public virtual void FixedUpdate() {
+ PreviousTransform.CopyFrom(Transform);
+ Components.FixedUpdate();
+ }
+
+ /// <summary>
+ /// called each frame as long as the Entity is enabled
+ /// </summary>
+ public virtual void DrawUpdate() {
+ GraphicsTransform.SetWithLerp(PreviousTransform, Transform, Time.Alpha);
+ Components.DrawUpdate();
+ }
/// <summary>
/// called if Core.debugRenderEnabled is true by the default renderers. Custom renderers can choose to call it or not.
diff --git a/Nez.Portable/ECS/Transform.cs b/Nez.Portable/ECS/Transform.cs
index 82afbacf..89ab0c2a 100644
--- a/Nez.Portable/ECS/Transform.cs
+++ b/Nez.Portable/ECS/Transform.cs
@@ -598,6 +600,32 @@ namespace Nez
SetDirty(DirtyType.ScaleDirty);
}
+ public void SetWithLerp(Transform from, Transform to, float alpha)
+ {
+ var dirtyPosition = _position != from.Position || from.Position != to.Position
+ || _localPosition != from._localPosition || from._localPosition != to._localPosition;
+ // Position must be rounded to ints, otherwise we get tearing
+ _position = Vector2.Round(Vector2.Lerp(from.Position, to.Position, alpha));
+ _localPosition = Vector2.Round(Vector2.Lerp(from._localPosition, to._localPosition, alpha));
+
+ var dirtyRotation = _rotation != from._rotation || from._rotation != to._rotation
+ || _localRotation != from._localRotation || from._localRotation != to._localRotation;
+ _rotation = Mathf.LerpAngleRadians(from._rotation, to._rotation, alpha);
+ _localRotation = Mathf.LerpAngleRadians(from._localRotation, to._localRotation, alpha);
+
+ var dirtyScale = _scale != from._scale || from._scale != to._scale
+ || _localScale != from._localScale || from._localScale != to._localScale;
+ _scale = Vector2.Lerp(from._scale, to._scale, alpha);
+ _localScale = Vector2.Lerp(from._localScale, to._localScale, alpha);
+
+ if (dirtyPosition)
+ SetDirty(DirtyType.PositionDirty);
+ if (dirtyRotation)
+ SetDirty(DirtyType.RotationDirty);
+ if (dirtyScale)
+ SetDirty(DirtyType.ScaleDirty);
+ }
+
public override string ToString()
{
Phew! That’s a lot.
Closing thoughts ¶
Are any of these changes upstreamable? I don’t think so. They cause breaking
changes, which deviate from the XNA in quite large ways. Technically, MonoGame
could merge an alternative implementation of Game
, but I don’t think they
would want to. I think the maintainer of Nez wouldn’t either appreciate
receiving a patch with 109 files changed, 361 insertions(+), 211 deletions(-)
consisting of only breaking changes.
Check out the final repository in GitHub.