using System; using System.Diagnostics.Tracing; using System.Threading; using Robust.Shared.Log; using Robust.Shared.Exceptions; using Prometheus; using Robust.Shared.Configuration; using Robust.Shared.Profiling; namespace Robust.Shared.Timing { internal interface IGameLoop { event EventHandler Input; event EventHandler Tick; event EventHandler Update; event EventHandler Render; /// /// Enables single step mode. If this is enabled, after every tick the GameTime will pause. /// Unpausing GameTime will run another single tick. /// bool SingleStep { get; set; } /// /// Setting this to false will stop the loop after it has started running. /// bool Running { get; set; } /// /// How many ticks behind the simulation can get before it starts to slow down. /// int MaxQueuedTicks { get; set; } /// /// Minimum frame time to attain if is set to . /// TimeSpan LimitMinFrameTime { get; set; } /// /// The method currently being used to limit the Update rate. /// SleepMode SleepMode { get; set; } /// /// Start running the loop. This function will block for as long as the loop is Running. /// Set Running to false to exit the loop and return from this function. /// void Run(); } /// /// Manages the main game loop for a GameContainer. /// 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( "robust_game_loop_frametime", "Histogram of frametimes in ms", new HistogramConfiguration { Buckets = Histogram.ExponentialBuckets(.001, 1.5, 10) }); private readonly IGameTiming _timing; private TimeSpan _lastKeepUp; // last wall time keep up announcement public event EventHandler? Input; public event EventHandler? Tick; public event EventHandler? Update; public event EventHandler? Render; /// /// Enables single step mode. If this is enabled, after every tick the GameTime will pause. /// Unpausing GameTime will run another single tick. /// public bool SingleStep { get; set; } = false; /// /// Setting this to false will stop the loop after it has started running. /// public bool Running { get; set; } /// /// How many ticks behind the simulation can get before it starts to slow down. /// public int MaxQueuedTicks { get; set; } = 5; public TimeSpan LimitMinFrameTime { get; set; } /// /// If true and the same event causes an event 10 times in a row, the game loop will shut itself down. /// public bool DetectSoftLock { get; set; } public bool EnableMetrics { get; set; } = false; /// /// The method currently being used to limit the Update rate. /// public SleepMode SleepMode { get; set; } = SleepMode.Yield; // Only used on release mode. // ReSharper disable once NotAccessedField.Local private readonly IRuntimeLog _runtimeLog; 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, GameLoopOptions options) { _timing = timing; _runtimeLog = runtimeLog; _prof = prof; _sawmill = sawmill; _precisionSleep = options.Precise ? PrecisionSleep.Create() : new PrecisionSleepUniversal(); } /// /// Start running the loop. This function will block for as long as the loop is Running. /// Set Running to false to exit the loop and return from this function. /// public void Run() { if (_timing.TickRate <= 0) throw new InvalidOperationException("TickRate must be greater than 0."); Running = true; FrameEventArgs realFrameEvent; FrameEventArgs simFrameEvent; while (Running) { var profFrameStart = _prof.WriteValue(ProfTextStartFrame, ProfData.Int64(_timing.CurFrame)); var profFrameGroupStart = _prof.WriteGroupStart(); var profFrameSw = ProfSampler.StartNew(); var profFrameGcGen0 = GC.CollectionCount(0); var profFrameGcGen1 = GC.CollectionCount(1); var profFrameGcGen2 = GC.CollectionCount(2); _timing.StartFrame(); // maximum number of ticks to queue before the loop slows down. var maxTime = TimeSpan.FromTicks(_timing.TickPeriod.Ticks * MaxQueuedTicks); var accumulator = _timing.RealTime - _timing.LastTick; // If the game can't keep up, limit time. if (accumulator > maxTime) { // limit accumulator to max time. accumulator = maxTime; // pull LastTick up to the current realTime // This will slow down the simulation, but if we are behind from a // lag spike hopefully it will be able to catch up. _timing.LastTick = _timing.RealTime - maxTime; // announce we are falling behind if ((_timing.RealTime - _lastKeepUp).TotalSeconds >= 15.0) { GameLoopEventSource.Log.CannotKeepUp(); _sawmill.Warning("MainLoop: Cannot keep up!"); _lastKeepUp = _timing.RealTime; } } realFrameEvent = new FrameEventArgs((float)_timing.RealFrameTime.TotalSeconds); GameLoopEventSource.Log.InputStart(); #if EXCEPTION_TOLERANCE try #endif { using var _ = _prof.Group("Input"); // process Net/KB/Mouse input Input?.Invoke(this, realFrameEvent); } #if EXCEPTION_TOLERANCE catch (Exception exp) { _runtimeLog.LogException(exp, "GameLoop Input"); } #endif GameLoopEventSource.Log.InputStop(); _timing.InSimulation = true; var tickPeriod = _timing.CalcAdjustedTickPeriod(); using (_prof.Group("Ticks")) { var countTicksRan = 0; // run the simulation for every accumulated tick while (accumulator >= tickPeriod) { accumulator -= tickPeriod; _timing.LastTick += tickPeriod; // only run the simulation if unpaused, but still use up the accumulated time if (_timing.Paused) continue; _timing.TickRemainder = accumulator / _timing.TimeScale; countTicksRan += 1; // update the simulation simFrameEvent = new FrameEventArgs((float)_timing.FrameTime.TotalSeconds); #if EXCEPTION_TOLERANCE var threw = false; try { #endif GameLoopEventSource.Log.TickStart(_timing.CurTick.Value); 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()) { Tick?.Invoke(this, simFrameEvent); } } else { Tick?.Invoke(this, simFrameEvent); } GameLoopEventSource.Log.TickStop(_timing.CurTick.Value); #if EXCEPTION_TOLERANCE } catch (Exception exp) { threw = true; _runtimeLog.LogException(exp, "GameLoop Tick"); _tickExceptions += 1; if (_tickExceptions > MaxSoftLockExceptions && DetectSoftLock) { _sawmill.Fatal( "MainLoop: 10 consecutive exceptions inside GameLoop Tick, shutting down!"); Running = false; } } if (!threw) { _tickExceptions = 0; } #endif _timing.CurTick = new GameTick(_timing.CurTick.Value + 1); tickPeriod = _timing.CalcAdjustedTickPeriod(); if (SingleStep) _timing.Paused = true; } _prof.WriteValue("Tick count", ProfData.Int32(countTicksRan)); } // if not paused, save how close to the next tick we are so interpolation works if (!_timing.Paused) _timing.TickRemainder = accumulator / _timing.TimeScale; _timing.InSimulation = false; // update out of the simulation GameLoopEventSource.Log.UpdateStart(); #if EXCEPTION_TOLERANCE try #endif { using var _ = _prof.Group("Update"); Update?.Invoke(this, realFrameEvent); } #if EXCEPTION_TOLERANCE catch (Exception exp) { _runtimeLog.LogException(exp, "GameLoop Update"); } #endif GameLoopEventSource.Log.UpdateStop(); // render the simulation #if EXCEPTION_TOLERANCE try #endif { using (_prof.Frame("Render")) { Render?.Invoke(this, realFrameEvent); } } #if EXCEPTION_TOLERANCE catch (Exception exp) { _runtimeLog.LogException(exp, "GameLoop Render"); } #endif { using var gc = _prof.Group("GC Overview"); var gcGen0 = GC.CollectionCount(0); var gcGen1 = GC.CollectionCount(1); var gcGen2 = GC.CollectionCount(2); _prof.WriteValue("Gen 0 Count", ProfData.Int32(gcGen0 - profFrameGcGen0)); _prof.WriteValue("Gen 1 Count", ProfData.Int32(gcGen1 - profFrameGcGen1)); _prof.WriteValue("Gen 2 Count", ProfData.Int32(gcGen2 - profFrameGcGen2)); _prof.EmitMemoryPlots(gcGen0, gcGen1, gcGen2); } _prof.WriteGroupEnd(profFrameGroupStart, "Frame", profFrameSw); _prof.MarkIndex(profFrameStart, ProfIndexType.Frame); GameLoopEventSource.Log.SleepStart(); // 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. 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; case SleepMode.Limit: var lastFrameOver = _timing.RealFrameTime - LimitMinFrameTime; if (lastFrameOver.Ticks < 0) lastFrameOver = TimeSpan.Zero; var curTimeSpent = _timing.RealTime - _timing.FrameStartTime; var timeRemaining = LimitMinFrameTime - curTimeSpent - lastFrameOver; if (timeRemaining.Ticks > 0) { var sw2 = RStopwatch.StartNew(); _precisionSleep.Sleep(timeRemaining); } break; } GameLoopEventSource.Log.SleepStop(); } } } 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. /// public enum SleepMode : sbyte { /// /// Thread will not yield to the scheduler or sleep, and consume 100% cpu. Use this if you are /// limiting the rate by other means (rendering FPS with vsync), otherwise your computer will turn into a heater. /// None = -1, /// /// Same as None, except you are yielding the rest of your timeslice to other OS threads at the end of each update. /// This will run at 100% CPU if another thread does not hog all the CPU time, and your OS scheduler will be happier. /// Yield = 0, /// /// Adds ~1ms thread sleep after every update. Use this to limit the Update rate of the loop, conserve power and /// have low CPU usage. You should use this on a dedicated server. /// Delay = 1, /// /// "FPS Limiter". Sleep to keep the loop frame time at least at . /// Limit = 2, } [EventSource(Name = "Robust.GameLoop")] internal sealed class GameLoopEventSource : EventSource { public static GameLoopEventSource Log { get; } = new(); [Event(1)] public void CannotKeepUp() => WriteEvent(1); [Event(2)] public void InputStart() => WriteEvent(2); [Event(3)] public void InputStop() => WriteEvent(3); [Event(4)] public void TickStart(uint tick) => WriteEvent(4, tick); [Event(5)] public void TickStop(uint tick) => WriteEvent(5, tick); [Event(6)] public void UpdateStart() => WriteEvent(6); [Event(7)] public void UpdateStop() => WriteEvent(7); [Event(8)] public void SleepStart() => WriteEvent(8); [Event(9)] public void SleepStop() => WriteEvent(9); } }