Skip to content

Instantly share code, notes, and snippets.

@seanofw
Last active April 27, 2022 15:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save seanofw/49648280ce4618acd5b908fe0f6c3575 to your computer and use it in GitHub Desktop.
Save seanofw/49648280ce4618acd5b908fe0f6c3575 to your computer and use it in GitHub Desktop.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
// Define this if you're running on Microsoft Windows, which supports
// high-precision sleep timers. Enabling this can result in better CPU usage
// and power usage if your program doesn't need the CPU fully in every frame.
#define HIGH_PRECISION_SLEEP
/// <summary>
/// A game window with a custom Run() loop with specific timing rules: This will
/// attempt to enforce a fixed frame rate at TargetFPS, and will only ever drop at
/// most one render frame to meet it. The update rate, however, can be tuned to
/// any speed relative to the target FPS.
/// </summary>
/// <remarks>
/// If the update/render is much faster than TargetFPS, this will yield time
/// back to the OS, reducing CPU load and power consumption. If the update/render
/// is slower, than TargetFPS, this will do one of two things: In the range of
/// TargetFPS/2 to TargetFPS, this will "catch up" a single dropped frame, performing
/// an OnUpdateFrame() without a matching OnRenderFrame(). But if the update/render
/// is slower than TargetFPS/2, this will simply let the program "run slow".
///
/// Example behavior for a TargetFPS of 60 (i.e., a frame time of 16.667 msec):
/// - Render/update takes 3 msec: 13.667 msec yielded back to OS.
/// - Render/update takes 13 msec: 3.667 msec busy wait (too short to yield).
/// - Render/update takes 20 msec: One render frame will be skipped, so rendering
/// will happen at 30 FPS, but update rate still targets 60 FPS. Some time
/// will be yielded back to OS during second frame.
/// - Render/update takes 40 msec: One render frame will be skipped, but the other
/// render frames will not. Program will run at slower speed until render/update
/// frames get faster again.
/// </summary>
public class TimedGameWindow : GameWindow
{
/// <summary>
/// Set this to whatever your target render frame rate is. This must not change
/// during the Run() loop.
/// <summary>
/// <remarks>
/// This should generally be a value that either matches the frame rate
/// of your video hardware or is an even multiple or division of that frame rate for best results.
/// (i.e., for video hardware that runs at 60 FPS, acceptable values are 12, 15, 20, 30, 60,
/// 120, 180, 240, etc.) Note that if you set this to a value other than the actual frame rate
/// of your hardware and you also enable VSync, you may get strange timing results.
///
/// IMPORTANT NOTE: On AVERAGE, the duration of your OnUpdateFrame() plus the duration of your
/// OnRenderFrame() should fit within this frame rate! For example, if you're rendering/updating
/// so much content that you're consistently just barely able to fit in 60 FPS, you should
/// consider switching to 30 FPS instead, or the timing logic in this class may no longer be
/// able to recover from OS or hardware timing gaps, and your game's performance may start to
/// become choppy.
/// </remarks>
public int TargetFPS { get; private set; } = 60;
#if HIGH_PRECISION_SLEEP
// A high-precision sleep timer, on systems that support it.
public HighPrecisionSleep HighPrecisionSleep { get; } = new HighPrecisionSleep();
#endif
/// <summary>
/// The program will work hard to both update and render at *exactly* TargetFPS. If you want
/// the updates to happen faster or slower than TargetFPS, use this ratio to adjust them; this
/// will simply alter the number of update frames executed per target frame. This defaults
/// to 1.0, meaning that the number of update frames should exactly match the number of
/// render frames.
/// </summary>
public double FpsRatio
{
get => _fpsRatio;
set
{
_fpsRatio = value;
_fractionalUpdateAmount = (long)(0x10000 * _fpsRatio + 0.5);
}
}
private double _fpsRatio = 1;
private long _fractionalUpdateAmount = 0x10000;
private long _fractionalFrameNumber = 0;
/// <summary>
/// Run the main loop of the program. This will invoke OnLoad() at start,
/// and then will OnUpdateFrame() and OnRenderFrame() at the desired rates until
/// the program exits.
/// </summary>
public override void Run()
{
OnLoad();
OnResize(new ResizeEventArgs(Size));
long targetRate = Stopwatch.Frequency / TargetFPS;
long oneMsec = Stopwatch.Frequency / 1000;
_fractionalFrameNumber = 0;
Stopwatch updateStopwatch = new Stopwatch();
updateStopwatch.Start();
long nextStop = updateStopwatch.ElapsedTicks + targetRate;
while (true)
{
long elapsedTicks = updateStopwatch.ElapsedTicks;
if (elapsedTicks >= nextStop)
{
if (elapsedTicks >= nextStop + targetRate)
{
// If we dropped a frame for some reason, we catch up ONE next frame.
// This is enough to sync correctly any time we get stuck in the
// range of 30-60 FPS.
ProcessEvents();
OnNextTimeSlice();
nextStop = elapsedTicks + targetRate;
ProcessEvents();
OnNextTimeSlice();
}
else
{
nextStop += targetRate;
ProcessEvents();
OnNextTimeSlice();
}
if (!Exists || IsExiting)
break;
OnRenderFrame(default);
}
if (VSync == VSyncMode.Off)
{
// If we're somehow a long way away, let the OS sleep first, which
// is good for power consumption, but do it in a fairly-high-precision
// way to within the nearest two milliseconds.
#if HIGH_PRECISION_SLEEP
long remainder = nextStop - updateStopwatch.ElapsedTicks;
if (remainder > oneMsec * 2)
HighPrecisionSleep.Sleep(remainder - oneMsec * 2);
#endif
// High-precision spin-loop when we get closer.
while (updateStopwatch.ElapsedTicks < nextStop) ;
}
}
DestroyWindow();
}
/// <summary>
/// This is triggered at TargetFPS, and should perform as many frame
/// updates as necessary to keep the program running at the desired rate.
/// In general, you shouldn't override this; override OnUpdateFrame() instead.
/// This is only really overridable so you can record metrics on it.
/// </summary>
protected virtual void OnNextTimeSlice()
{
if (_fractionalUpdateAmount == 0x10000)
{
OnUpdateFrame(default);
}
else
{
long lastFractionalFrameNumber = _fractionalFrameNumber;
_fractionalFrameNumber += _fractionalUpdateAmount;
long lastWholeFrameNumber = lastFractionalFrameNumber >> 16;
long nextWholeFrameNumber = _fractionalFrameNumber >> 16;
int numElapsedFrames = (int)(nextWholeFrameNumber - lastWholeFrameNumber);
for (int i = 0; i < numElapsedFrames; i++)
{
OnUpdateFrame(default);
}
}
}
#if HIGH_PRECISION_SLEEP
protected override unsafe void Dispose(bool disposing)
{
// Clean up the high-precision sleep timer, resetting the system's timing
// hardware back to its normal behavior.
if (disposing)
HighPrecisionSleep.Dispose();
}
#endif
#if HIGH_PRECISION_SLEEP
/// <summary>
/// A high-precision sleep timer, accurate to within plus or minus 1 msec.
/// This implementation only works on Microsoft Windows, and only on WinXP or newer.
/// </summary>
public class HighPrecisionSleep : IDisposable
{
private IntPtr _waitableTimerHandle;
public HighPrecisionSleep()
{
_waitableTimerHandle = Win32.CreateWaitableTimer(IntPtr.Zero, true, null);
Win32.timeBeginPeriod(1);
}
~HighPrecisionSleep()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected void Dispose(bool isDisposing)
{
if (_waitableTimerHandle != IntPtr.Zero)
{
Win32.CloseHandle(_waitableTimerHandle);
_waitableTimerHandle = IntPtr.Zero;
Win32.timeEndPeriod(1);
}
}
public void Sleep(long ticks)
{
long duration = -ticks;
Win32.SetWaitableTimer(_waitableTimerHandle, ref duration, 0, null, IntPtr.Zero, false);
Win32.WaitForSingleObject(_waitableTimerHandle, -1);
}
internal static class Win32
{
public delegate void TimerCompleteDelegate();
[DllImport("kernel32")]
public static extern IntPtr CreateWaitableTimer(IntPtr lpTimerAttributes, bool bManualReset, string? lpTimerName);
[DllImport("kernel32")]
public static extern bool SetWaitableTimer(IntPtr hTimer, [In] ref long ft, int lPeriod,
TimerCompleteDelegate? pfnCompletionRoutine, IntPtr pArgToCompletionRoutine, bool fResume);
[DllImport("kernel32")]
public static extern int WaitForSingleObject(IntPtr Handle, int Wait);
[DllImport("kernel32")]
public static extern bool CloseHandle(IntPtr hHandle);
[DllImport("winmm.dll")]
public static extern uint timeBeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll")]
public static extern uint timeEndPeriod(uint uMilliseconds);
}
}
#endif
}
@seanofw
Copy link
Author

seanofw commented Apr 27, 2022

The code above is covered under the terms of the MIT open-source license; use this code as you see fit:


Copyright 2020-2022 Sean Werkema

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@seanofw
Copy link
Author

seanofw commented Apr 27, 2022

This is a (slightly hacked) copy of the run loop I use in my game. (Hopefully I included all the pieces necessary for it to work outside my game!)

This uses a very different way of managing the frame rate than the standard OpenTK code does, and it recovers much better for long delays and stopped breakpoints. However, in exchange, it will only drop at most one frame when recovering from a delay; if the delay is long enough (more than two render frames), the delay will be able to impact gameplay.

This class will always attempt to hit the provided TargetFPS value for invocations of OnRenderFrame(); if the default of 60 isn't what you need, adjust TargetFPS accordingly, as per its included documentation.

With the HIGH_PRECISION_SLEEP option, this also can reliably yield the CPU back to the OS (on Windows) if you don't need the CPU for an entire frame, thus reducing CPU load and power consumption. (No, I do not currently have a port of this part of the code to Linux or Mac, although I'm sure an equivalent likely exists.)

This class also supports variable gameplay speeds via the FpsRatio property, so you can speed up or slow down your update rate — that's an accessibility option I offer in my game for gamers who find the default gameplay speed to be too fast (or who like an extra challenge and want it to be faster). This property controls how many invocations of OnUpdateFrame() occur for each invocation of OnRenderFrame().

I've battle-tested the version of this code that exists in my game pretty heavily, and it works really well, for many different configurations. If you like it, feel free to use it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment