Hannikainen's blog

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 in IUpdatable with FixedUpdate and DrawUpdate
    • Replace almost all calls to Update with FixedUpdate
      • there are some calls which should use `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
    • Go through usages of Entity.Transform and change those to Entity.GraphicsTransform if necessary
      • Notably: SpriteRenderer and Camera

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.

Copyright (c) 2024 Jaakko Hannikainen