From c392d4f996a012448aa696ef16d7eaacaa3814ca Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sat, 15 Jul 2023 15:22:02 +0200 Subject: [PATCH] Precise, time period-independent timing for Windows game loop. --- RELEASE-NOTES.md | 1 + .../GameController/GameController.cs | 7 +- Robust.Server/BaseServer.cs | 7 +- Robust.Shared/CVars.cs | 6 + Robust.Shared/Timing/GameLoop.cs | 49 +++++++- Robust.Shared/Timing/IGameTiming.cs | 3 +- Robust.Shared/Timing/PrecisionSleep.cs | 110 ++++++++++++++++++ .../Shared/Timing/GameLoop_Test.cs | 7 +- 8 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 Robust.Shared/Timing/PrecisionSleep.cs diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ca3549162b0..e95db920331 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -49,6 +49,7 @@ END TEMPLATE--> ### Other * BQL `with` now includes paused entities. +* The game loop now times more accurately and avoids sleeping more than necessary. ### Internal diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs index 1ac35b7ec72..a1b84f70a02 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -200,7 +200,12 @@ internal bool StartupContinue(DisplayMode displayMode) // Setup main loop if (_mainLoop == null) { - _mainLoop = new GameLoop(_gameTiming, _runtimeLog, _prof, _logManager.GetSawmill("eng")) + _mainLoop = new GameLoop( + _gameTiming, + _runtimeLog, + _prof, + _logManager.GetSawmill("eng"), + GameLoopOptions.FromCVars(_configurationManager)) { SleepMode = displayMode == DisplayMode.Headless ? SleepMode.Delay : SleepMode.None }; diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs index 8a7d22c3cbc..d2d794f8bbc 100644 --- a/Robust.Server/BaseServer.cs +++ b/Robust.Server/BaseServer.cs @@ -510,7 +510,12 @@ internal void SetupMainLoop() { if (_mainLoop == null) { - _mainLoop = new GameLoop(_time, _runtimeLog, _prof, _log.GetSawmill("eng")) + _mainLoop = new GameLoop( + _time, + _runtimeLog, + _prof, + _log.GetSawmill("eng"), + GameLoopOptions.FromCVars(_config)) { SleepMode = SleepMode.Delay, DetectSoftLock = true, diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 729ed131fd4..7b9ff6396de 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -319,6 +319,12 @@ protected CVars() public static readonly CVarDef SysGCCollectStart = CVarDef.Create("sys.gc_collect_start", true); + /// + /// Use precise sleeping methods in the game loop. + /// + public static readonly CVarDef SysPreciseSleep = + CVarDef.Create("sys.precise_sleep", true); + /* * METRICS */ diff --git a/Robust.Shared/Timing/GameLoop.cs b/Robust.Shared/Timing/GameLoop.cs index 2dc209303e4..c722bc6134d 100644 --- a/Robust.Shared/Timing/GameLoop.cs +++ b/Robust.Shared/Timing/GameLoop.cs @@ -2,13 +2,13 @@ using System.Threading; using Robust.Shared.Log; using Robust.Shared.Exceptions; -using Robust.Shared.Maths; using Prometheus; +using Robust.Shared.Configuration; using Robust.Shared.Profiling; namespace Robust.Shared.Timing { - public interface IGameLoop + internal interface IGameLoop { event EventHandler Input; event EventHandler Tick; @@ -46,8 +46,10 @@ public interface IGameLoop /// /// Manages the main game loop for a GameContainer. /// - public sealed class GameLoop : IGameLoop + internal sealed class GameLoop : IGameLoop { + private static readonly TimeSpan DelayTime = TimeSpan.FromMilliseconds(1); + public const string ProfTextStartFrame = "Start Frame"; private static readonly Histogram _frameTimeHistogram = Metrics.CreateHistogram( @@ -100,18 +102,27 @@ public sealed class GameLoop : IGameLoop private readonly ProfManager _prof; private readonly ISawmill _sawmill; + private readonly PrecisionSleep _precisionSleep; + #if EXCEPTION_TOLERANCE private int _tickExceptions; private const int MaxSoftLockExceptions = 10; #endif - public GameLoop(IGameTiming timing, IRuntimeLog runtimeLog, ProfManager prof, ISawmill sawmill) + public GameLoop( + IGameTiming timing, + IRuntimeLog runtimeLog, + ProfManager prof, + ISawmill sawmill, + GameLoopOptions options) { _timing = timing; _runtimeLog = runtimeLog; _prof = prof; _sawmill = sawmill; + + _precisionSleep = options.Precise ? new PrecisionSleepUniversal() : PrecisionSleep.Create(); } /// @@ -208,6 +219,8 @@ public void Run() using var tickGroup = _prof.Group("Tick"); _prof.WriteValue("Tick", ProfData.Int64(_timing.CurTick.Value)); + // System.Console.WriteLine($"Tick started at: {_timing.RealTime - _timing.LastTick}"); + if (EnableMetrics) { using (_frameTimeHistogram.NewTimer()) @@ -304,12 +317,36 @@ public void Run() // Set sleep to 1 if you want to be nice and give the rest of the timeslice up to the os scheduler. // Set sleep to 0 if you want to use 100% cpu, but still cooperate with the scheduler. // do not call sleep if you want to be 'that thread' and hog 100% cpu. - if (SleepMode != SleepMode.None) - Thread.Sleep((int)SleepMode); + switch (SleepMode) + { + case SleepMode.Yield: + Thread.Sleep(0); + break; + + case SleepMode.Delay: + // We try to sleep exactly until the next tick. + // But no longer than 1ms so input can keep processing. + var timeToSleep = (_timing.LastTick + _timing.TickPeriod) - _timing.RealTime; + if (timeToSleep > DelayTime) + timeToSleep = DelayTime; + + if (timeToSleep.Ticks > 0) + _precisionSleep.Sleep(timeToSleep); + + break; + } } } } + internal sealed record GameLoopOptions(bool Precise) + { + public static GameLoopOptions FromCVars(IConfigurationManager cfg) + { + return new GameLoopOptions(cfg.GetCVar(CVars.SysPreciseSleep)); + } + } + /// /// Methods the GameLoop can use to limit the Update rate. /// diff --git a/Robust.Shared/Timing/IGameTiming.cs b/Robust.Shared/Timing/IGameTiming.cs index ba76e978c25..ac565c4a5fc 100644 --- a/Robust.Shared/Timing/IGameTiming.cs +++ b/Robust.Shared/Timing/IGameTiming.cs @@ -78,7 +78,8 @@ public interface IGameTiming GameTick CurTick { get; set; } /// - /// Timespan for the last tick. + /// Time, relative to , the last tick started at. + /// If we're currently in simulation, that's THIS tick. /// TimeSpan LastTick { get; set; } diff --git a/Robust.Shared/Timing/PrecisionSleep.cs b/Robust.Shared/Timing/PrecisionSleep.cs new file mode 100644 index 00000000000..26c57d532b0 --- /dev/null +++ b/Robust.Shared/Timing/PrecisionSleep.cs @@ -0,0 +1,110 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using TerraFX.Interop.Windows; + +namespace Robust.Shared.Timing; + +/// +/// Helper for more precise sleeping functionality than . +/// +internal abstract class PrecisionSleep : IDisposable +{ + /// + /// Sleep for the specified amount of time. + /// + public abstract void Sleep(TimeSpan time); + + /// + /// Create the most optimal optimization for the current platform. + /// + public static PrecisionSleep Create() + { + // Check Windows 10 1803 + if (OperatingSystem.IsWindows() && Environment.OSVersion.Version.Build >= 17134) + return new PrecisionSleepWindowsHighResolution(); + + return new PrecisionSleepUniversal(); + } + + public virtual void Dispose() + { + } +} + +/// +/// Universal cross-platform implementation of . Not very precise! +/// +internal sealed class PrecisionSleepUniversal : PrecisionSleep +{ + public override void Sleep(TimeSpan time) + { + Thread.Sleep(time); + } +} + +/// +/// High-precision implementation of that is available since Windows 10 1803. +/// +internal sealed unsafe class PrecisionSleepWindowsHighResolution : PrecisionSleep +{ + private HANDLE _timerHandle; + + public PrecisionSleepWindowsHighResolution() + { + // CREATE_WAITABLE_TIMER_HIGH_RESOLUTION is only supported since Windows 10 1803 + _timerHandle = Windows.CreateWaitableTimerExW( + null, + null, + CREATE.CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + Windows.TIMER_ALL_ACCESS); + + if (_timerHandle == HANDLE.NULL) + Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error()); + } + + public override void Sleep(TimeSpan time) + { + LARGE_INTEGER due; + Windows.GetSystemTimeAsFileTime((FILETIME*)(&due)); + + due.QuadPart += time.Ticks; + + var success = Windows.SetWaitableTimer( + _timerHandle, + &due, + 0, + null, + null, + BOOL.FALSE + ); + + if (!success) + Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error()); + + var waitResult = Windows.WaitForSingleObject(_timerHandle, Windows.INFINITE); + if (waitResult == WAIT.WAIT_FAILED) + Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error()); + + GC.KeepAlive(this); + } + + private void DisposeCore() + { + Windows.CloseHandle(_timerHandle); + + _timerHandle = default; + } + + public override void Dispose() + { + DisposeCore(); + + GC.SuppressFinalize(this); + } + + ~PrecisionSleepWindowsHighResolution() + { + DisposeCore(); + } +} diff --git a/Robust.UnitTesting/Shared/Timing/GameLoop_Test.cs b/Robust.UnitTesting/Shared/Timing/GameLoop_Test.cs index 0d199ee42a2..db444941673 100644 --- a/Robust.UnitTesting/Shared/Timing/GameLoop_Test.cs +++ b/Robust.UnitTesting/Shared/Timing/GameLoop_Test.cs @@ -29,7 +29,12 @@ public void SingleStepTest() newStopwatch.SetupGet(p => p.Elapsed).Returns(elapsedVal); var gameTiming = GameTimingFactory(newStopwatch.Object); gameTiming.Paused = false; - var loop = new GameLoop(gameTiming, new RuntimeLog(), new ProfManager(), new LogManager().RootSawmill); + var loop = new GameLoop( + gameTiming, + new RuntimeLog(), + new ProfManager(), + new LogManager().RootSawmill, + new GameLoopOptions(false)); var callCount = 0; loop.Tick += (sender, args) => callCount++;