From b88b87b8992ea05ebdd61553de2c063e38c64411 Mon Sep 17 00:00:00 2001 From: Pavlos Touboulidis Date: Sun, 6 Jul 2014 22:04:31 +0300 Subject: [PATCH] Improve game loop Environment.TickCount was replaced with Game.RunTime that's based on Stopwatch for increased accuracy. --- OpenRA.Game/Game.cs | 379 +++++++++++------- OpenRA.Game/Network/OrderManager.cs | 2 +- OpenRA.Game/Primitives/ActionQueue.cs | 4 +- OpenRA.Game/Server/Server.cs | 4 +- OpenRA.Game/Widgets/Widget.cs | 2 +- .../ServerTraits/MasterServerPinger.cs | 4 +- OpenRA.Mods.RA/ServerTraits/PlayerPinger.cs | 6 +- OpenRA.Mods.RA/Widgets/Logic/SettingsLogic.cs | 4 +- 8 files changed, 246 insertions(+), 159 deletions(-) mode change 100755 => 100644 OpenRA.Game/Network/OrderManager.cs diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index 0cf43ed332..a64e52ea41 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -14,6 +14,7 @@ using System.Drawing; using System.IO; using System.Linq; using System.Net; +using System.Threading; using MaxMind.GeoIP2; using OpenRA.FileSystem; using OpenRA.Graphics; @@ -73,6 +74,10 @@ namespace OpenRA JoinInner(new OrderManager("", -1, "", new EchoConnection())); } + // More accurate replacement for Environment.TickCount + static Stopwatch stopwatch = Stopwatch.StartNew(); + public static int RunTime { get { return (int)Game.stopwatch.ElapsedMilliseconds; } } + public static int RenderFrame = 0; public static int NetFrameNumber { get { return orderManager.NetFrameNumber; } } public static int LocalTick { get { return orderManager.LocalFrameNumber; } } @@ -113,129 +118,6 @@ namespace OpenRA }, parent, id); } - // Note: These delayed actions should only be used by widgets or disposing objects - // - things that depend on a particular world should be queuing them on the worldactor. - static ActionQueue delayedActions = new ActionQueue(); - public static void RunAfterTick(Action a) { delayedActions.Add(a); } - public static void RunAfterDelay(int delay, Action a) { delayedActions.Add(a, delay); } - - static float cursorFrame = 0f; - static void Tick(OrderManager orderManager) - { - if (orderManager.Connection.ConnectionState != lastConnectionState) - { - lastConnectionState = orderManager.Connection.ConnectionState; - ConnectionStateChanged(orderManager); - } - - TickInner(orderManager); - if (worldRenderer != null && orderManager.world != worldRenderer.world) - TickInner(worldRenderer.world.orderManager); - - using (new PerfSample("render")) - { - ++RenderFrame; - - // worldRenderer is null during the initial install/download screen - if (worldRenderer != null) - { - Renderer.BeginFrame(worldRenderer.Viewport.TopLeft, worldRenderer.Viewport.Zoom); - Sound.SetListenerPosition(worldRenderer.Position(worldRenderer.Viewport.CenterLocation)); - worldRenderer.Draw(); - } - else - Renderer.BeginFrame(int2.Zero, 1f); - - using (new PerfSample("render_widgets")) - { - Ui.Draw(); - if (modData != null && modData.CursorProvider != null) - { - var cursorName = Ui.Root.GetCursorOuter(Viewport.LastMousePos) ?? "default"; - modData.CursorProvider.DrawCursor(Renderer, cursorName, Viewport.LastMousePos, (int)cursorFrame); - } - } - - using (new PerfSample("render_flip")) - { - Renderer.EndFrame(new DefaultInputHandler(orderManager.world)); - } - } - - PerfHistory.items["render"].Tick(); - PerfHistory.items["batches"].Tick(); - PerfHistory.items["render_widgets"].Tick(); - PerfHistory.items["render_flip"].Tick(); - - delayedActions.PerformActions(); - } - - static void TickInner(OrderManager orderManager) - { - var tick = Environment.TickCount; - - var world = orderManager.world; - var uiTickDelta = tick - Ui.LastTickTime; - if (uiTickDelta >= Timestep) - { - // Explained below for the world tick calculation - var integralTickTimestep = (uiTickDelta / Timestep) * Timestep; - Ui.LastTickTime += integralTickTimestep >= TimestepJankThreshold ? integralTickTimestep : Timestep; - - Viewport.TicksSinceLastMove += uiTickDelta / Timestep; - - Sync.CheckSyncUnchanged(world, Ui.Tick); - cursorFrame += 0.5f; - } - - var worldTimestep = world == null ? Timestep : world.Timestep; - var worldTickDelta = (tick - orderManager.LastTickTime); - if (worldTimestep != 0 && worldTickDelta >= worldTimestep) - using (new PerfSample("tick_time")) - { - // Tick the world to advance the world time to match real time: - // If dt < TickJankThreshold then we should try and catch up by repeatedly ticking - // If dt >= TickJankThreshold then we should accept the jank and progress at the normal rate - // dt is rounded down to an integer tick count in order to preserve fractional tick components. - - var integralTickTimestep = (worldTickDelta / worldTimestep) * worldTimestep; - orderManager.LastTickTime += integralTickTimestep >= TimestepJankThreshold ? integralTickTimestep : worldTimestep; - - Sound.Tick(); - Sync.CheckSyncUnchanged(world, orderManager.TickImmediate); - - if (world != null) - { - var isNetTick = LocalTick % NetTickScale == 0; - - if (!isNetTick || orderManager.IsReadyForNextFrame) - { - ++orderManager.LocalFrameNumber; - - Log.Write("debug", "--Tick: {0} ({1})", LocalTick, isNetTick ? "net" : "local"); - - if (isNetTick) - orderManager.Tick(); - - Sync.CheckSyncUnchanged(world, () => - { - world.OrderGenerator.Tick(world); - world.Selection.Tick(world); - }); - - world.Tick(); - - PerfHistory.Tick(); - } - else - if (orderManager.NetFrameNumber == 0) - orderManager.LastTickTime = Environment.TickCount; - - Sync.CheckSyncUnchanged(world, () => world.TickRender(worldRenderer)); - } - } - } - public static event Action LobbyInfoChanged = () => { }; internal static void SyncLobbyInfo() @@ -268,7 +150,7 @@ namespace OpenRA Ui.KeyboardFocusWidget = null; orderManager.LocalFrameNumber = 0; - orderManager.LastTickTime = Environment.TickCount; + orderManager.LastTickTime = RunTime; orderManager.StartGame(); worldRenderer.RefreshPalette(); @@ -415,7 +297,7 @@ namespace OpenRA CreateServer(new ServerSettings(Settings.Server)); while (true) { - System.Threading.Thread.Sleep(100); + Thread.Sleep(100); if (server.State == Server.ServerState.GameStarted && server.Conns.Count < 1) { @@ -502,10 +384,233 @@ namespace OpenRA static RunStatus state = RunStatus.Running; public static event Action OnQuit = () => { }; - static double idealFrameTime; - public static void SetIdealFrameTime(int fps) + // Note: These delayed actions should only be used by widgets or disposing objects + // - things that depend on a particular world should be queuing them on the worldactor. + static ActionQueue delayedActions = new ActionQueue(); + public static void RunAfterTick(Action a) { delayedActions.Add(a); } + public static void RunAfterDelay(int delay, Action a) { delayedActions.Add(a, delay); } + + static float cursorFrame = 0f; + + static void InnerLogicTick(OrderManager orderManager) { - idealFrameTime = 1.0 / fps; + var tick = RunTime; + + var world = orderManager.world; + + var uiTickDelta = tick - Ui.LastTickTime; + if (uiTickDelta >= Timestep) + { + // Explained below for the world tick calculation + var integralTickTimestep = (uiTickDelta / Timestep) * Timestep; + Ui.LastTickTime += integralTickTimestep >= TimestepJankThreshold ? integralTickTimestep : Timestep; + + Viewport.TicksSinceLastMove += uiTickDelta / Timestep; + + Sync.CheckSyncUnchanged(world, Ui.Tick); + cursorFrame += 0.5f; + } + + var worldTimestep = world == null ? Timestep : world.Timestep; + var worldTickDelta = (tick - orderManager.LastTickTime); + if (worldTimestep != 0 && worldTickDelta >= worldTimestep) + { + using (new PerfSample("tick_time")) + { + // Tick the world to advance the world time to match real time: + // If dt < TickJankThreshold then we should try and catch up by repeatedly ticking + // If dt >= TickJankThreshold then we should accept the jank and progress at the normal rate + // dt is rounded down to an integer tick count in order to preserve fractional tick components. + + var integralTickTimestep = (worldTickDelta / worldTimestep) * worldTimestep; + orderManager.LastTickTime += integralTickTimestep >= TimestepJankThreshold ? integralTickTimestep : worldTimestep; + + Sound.Tick(); + Sync.CheckSyncUnchanged(world, orderManager.TickImmediate); + + if (world != null) + { + var isNetTick = LocalTick % NetTickScale == 0; + + if (!isNetTick || orderManager.IsReadyForNextFrame) + { + ++orderManager.LocalFrameNumber; + + Log.Write("debug", "--Tick: {0} ({1})", LocalTick, isNetTick ? "net" : "local"); + + if (isNetTick) + orderManager.Tick(); + + Sync.CheckSyncUnchanged(world, () => + { + world.OrderGenerator.Tick(world); + world.Selection.Tick(world); + }); + + world.Tick(); + + PerfHistory.Tick(); + } + else + if (orderManager.NetFrameNumber == 0) + orderManager.LastTickTime = RunTime; + + Sync.CheckSyncUnchanged(world, () => world.TickRender(worldRenderer)); + } + } + } + } + + static void LogicTick() + { + delayedActions.PerformActions(); + + if (orderManager.Connection.ConnectionState != lastConnectionState) + { + lastConnectionState = orderManager.Connection.ConnectionState; + ConnectionStateChanged(orderManager); + } + + InnerLogicTick(orderManager); + if (worldRenderer != null && orderManager.world != worldRenderer.world) + InnerLogicTick(worldRenderer.world.orderManager); + } + + static void RenderTick() + { + using (new PerfSample("render")) + { + ++RenderFrame; + + // worldRenderer is null during the initial install/download screen + if (worldRenderer != null) + { + Renderer.BeginFrame(worldRenderer.Viewport.TopLeft, worldRenderer.Viewport.Zoom); + Sound.SetListenerPosition(worldRenderer.Position(worldRenderer.Viewport.CenterLocation)); + worldRenderer.Draw(); + } + else + Renderer.BeginFrame(int2.Zero, 1f); + + using (new PerfSample("render_widgets")) + { + Ui.Draw(); + + if (modData != null && modData.CursorProvider != null) + { + var cursorName = Ui.Root.GetCursorOuter(Viewport.LastMousePos) ?? "default"; + modData.CursorProvider.DrawCursor(Renderer, cursorName, Viewport.LastMousePos, (int)cursorFrame); + } + } + + using (new PerfSample("render_flip")) + Renderer.EndFrame(new DefaultInputHandler(orderManager.world)); + } + + PerfHistory.items["render"].Tick(); + PerfHistory.items["batches"].Tick(); + PerfHistory.items["render_widgets"].Tick(); + PerfHistory.items["render_flip"].Tick(); + } + + static void Loop() + { + // The game loop mainly does two things: logic updates and + // drawing on the screen. + // --- + // We ideally want the logic to run every 'Timestep' ms and + // rendering to be done at 'MaxFramerate', so 1000 / MaxFramerate ms. + // Any additional free time is used in 'Sleep' so we don't + // consume more CPU/GPU resources than necessary. + // --- + // In case logic or rendering takes more time than the ideal + // and we're getting behind, we can skip rendering some frames + // but there's a fail-safe minimum FPS to make sure the screen + // gets updated at least that often. + // --- + // TODO: Separate world/UI rendering + // It would be nice to separate the world rendering from the UI rendering + // so that we can update the UI more often than the world. This would + // help make the game playable (mouse/controls) even in low world + // framerates. + // It's not possible at the moment because the render buffer is cleared + // before rendering and we don't keep the last rendered world buffer. + + // When the logic has fallen behind by this much, skip the pending + // updates and start fresh. + // For example, if we want to update logic every 10 ms but each loop + // temporarily takes 100 ms, the 'nextLogic' timestamp will be too low + // and the current timestamp ('now') will have moved on. Even if the + // update time returns to normal, it will take a long time to catch up + // (if ever). + // This also means that the 'logicInterval' cannot be longer than this + // value. + const int maxLogicTicksBehind = 250; + + // Try to maintain at least this many FPS, even if it slows down logic. + // This is easily observed when playing back a replay at max speed, + // the frame rate will slow down to this value to allow the replay logic + // to run faster. + // However, if the user has enabled a framerate limit that is even lower + // than this, then that limit will be used. + const int minRenderFps = 10; + + // Timestamps for when the next logic and rendering should run + var nextLogic = RunTime; + var nextRender = RunTime; + var forcedNextRender = RunTime; + + while (state == RunStatus.Running) + { + // Ideal time between logic updates. Timestep = 0 means the game is paused + // but we still call LogicTick() because it handles pausing internally. + var logicInterval = worldRenderer != null && worldRenderer.world.Timestep != 0 ? worldRenderer.world.Timestep : Game.Timestep; + + // Ideal time between screen updates + var maxFramerate = Settings.Graphics.CapFramerate ? Settings.Graphics.MaxFramerate.Clamp(1, 1000) : 1000; + var renderInterval = 1000 / maxFramerate; + + var now = RunTime; + + // If the logic has fallen behind too much, skip it and catch up + if (now - nextLogic > maxLogicTicksBehind) + nextLogic = now; + + // When's the next update (logic or render) + var nextUpdate = Math.Min(nextLogic, nextRender); + if (now >= nextUpdate) + { + if (now >= nextLogic) + { + nextLogic += logicInterval; + + LogicTick(); + } + + var haveSomeTimeUntilNextLogic = now < nextLogic; + var isTimeToRender = now >= nextRender; + var forceRender = now >= forcedNextRender; + + if ((isTimeToRender && haveSomeTimeUntilNextLogic) || forceRender) + { + nextRender = now + renderInterval; + + // Pick the minimum allowed FPS (the lower between 'minRenderFps' + // and the user's max frame rate) and convert it to maximum time + // allowed between screen updates. + // We do this before rendering to include the time rendering takes + // in this interval. + var maxRenderInterval = Math.Max(1000 / minRenderFps, renderInterval); + forcedNextRender = now + maxRenderInterval; + + RenderTick(); + } + } + else + { + Thread.Sleep(nextUpdate - now); + } + } } internal static RunStatus Run() @@ -516,25 +621,9 @@ namespace OpenRA Settings.Graphics.CapFramerate = false; } - SetIdealFrameTime(Settings.Graphics.MaxFramerate); - try { - while (state == RunStatus.Running) - { - if (Settings.Graphics.CapFramerate) - { - var sw = Stopwatch.StartNew(); - - Tick(orderManager); - - var waitTime = Math.Min(idealFrameTime - sw.Elapsed.TotalSeconds, 1); - if (waitTime > 0) - System.Threading.Thread.Sleep(TimeSpan.FromSeconds(waitTime)); - } - else - Tick(orderManager); - } + Loop(); } finally { diff --git a/OpenRA.Game/Network/OrderManager.cs b/OpenRA.Game/Network/OrderManager.cs old mode 100755 new mode 100644 index 3a503166a3..65d2eb454c --- a/OpenRA.Game/Network/OrderManager.cs +++ b/OpenRA.Game/Network/OrderManager.cs @@ -35,7 +35,7 @@ namespace OpenRA.Network public int LocalFrameNumber; public int FramesAhead = 0; - public int LastTickTime = Environment.TickCount; + public int LastTickTime = Game.RunTime; public bool GameStarted { get { return NetFrameNumber != 0; } } public IConnection Connection { get; private set; } diff --git a/OpenRA.Game/Primitives/ActionQueue.cs b/OpenRA.Game/Primitives/ActionQueue.cs index 1288d0bbde..7b00484dac 100644 --- a/OpenRA.Game/Primitives/ActionQueue.cs +++ b/OpenRA.Game/Primitives/ActionQueue.cs @@ -24,7 +24,7 @@ namespace OpenRA.Primitives public void Add(Action a, int delay) { lock (syncRoot) - actions.Add(new DelayedAction(a, Environment.TickCount + delay)); + actions.Add(new DelayedAction(a, Game.RunTime + delay)); } public void PerformActions() @@ -32,7 +32,7 @@ namespace OpenRA.Primitives Action a = () => {}; lock (syncRoot) { - var t = Environment.TickCount; + var t = Game.RunTime; while (!actions.Empty && actions.Peek().Time <= t) { var da = actions.Pop(); diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 9a6105907b..e8b98dcb31 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -334,7 +334,7 @@ namespace OpenRA.Server SendMessage("{0} has joined the server.".F(client.Name)); // Send initial ping - SendOrderTo(newConn, "Ping", Environment.TickCount.ToString()); + SendOrderTo(newConn, "Ping", Game.RunTime.ToString()); if (Settings.Dedicated) { @@ -480,7 +480,7 @@ namespace OpenRA.Server return; var history = pingFromClient.LatencyHistory.ToList(); - history.Add(Environment.TickCount - pingSent); + history.Add(Game.RunTime - pingSent); // Cap ping history at 5 values (25 seconds) if (history.Count > 5) diff --git a/OpenRA.Game/Widgets/Widget.cs b/OpenRA.Game/Widgets/Widget.cs index c7634fd4c8..ef35cb8443 100644 --- a/OpenRA.Game/Widgets/Widget.cs +++ b/OpenRA.Game/Widgets/Widget.cs @@ -21,7 +21,7 @@ namespace OpenRA.Widgets { public static Widget Root = new RootWidget(); - public static int LastTickTime = Environment.TickCount; + public static int LastTickTime = Game.RunTime; static Stack WindowList = new Stack(); diff --git a/OpenRA.Mods.RA/ServerTraits/MasterServerPinger.cs b/OpenRA.Mods.RA/ServerTraits/MasterServerPinger.cs index 227be96376..3be9e84a2b 100644 --- a/OpenRA.Mods.RA/ServerTraits/MasterServerPinger.cs +++ b/OpenRA.Mods.RA/ServerTraits/MasterServerPinger.cs @@ -25,7 +25,7 @@ namespace OpenRA.Mods.RA.Server public void Tick(S server) { - if ((Environment.TickCount - lastPing > MasterPingInterval * 1000) || isInitialPing) + if ((Game.RunTime - lastPing > MasterPingInterval * 1000) || isInitialPing) PingMasterServer(server); else lock (masterServerMessages) @@ -47,7 +47,7 @@ namespace OpenRA.Mods.RA.Server { if (isBusy || !server.Settings.AdvertiseOnline) return; - lastPing = Environment.TickCount; + lastPing = Game.RunTime; isBusy = true; var mod = server.ModData.Manifest.Mod; diff --git a/OpenRA.Mods.RA/ServerTraits/PlayerPinger.cs b/OpenRA.Mods.RA/ServerTraits/PlayerPinger.cs index ee9d24cd70..4501908d9e 100644 --- a/OpenRA.Mods.RA/ServerTraits/PlayerPinger.cs +++ b/OpenRA.Mods.RA/ServerTraits/PlayerPinger.cs @@ -25,12 +25,12 @@ namespace OpenRA.Mods.RA.Server bool isInitialPing = true; public void Tick(S server) { - if ((Environment.TickCount - lastPing > PingInterval) || isInitialPing) + if ((Game.RunTime - lastPing > PingInterval) || isInitialPing) { isInitialPing = false; - lastPing = Environment.TickCount; + lastPing = Game.RunTime; foreach (var p in server.Conns) - server.SendOrderTo(p, "Ping", Environment.TickCount.ToString()); + server.SendOrderTo(p, "Ping", Game.RunTime.ToString()); } } } diff --git a/OpenRA.Mods.RA/Widgets/Logic/SettingsLogic.cs b/OpenRA.Mods.RA/Widgets/Logic/SettingsLogic.cs index f3e829fe4e..4b3a7b51bb 100644 --- a/OpenRA.Mods.RA/Widgets/Logic/SettingsLogic.cs +++ b/OpenRA.Mods.RA/Widgets/Logic/SettingsLogic.cs @@ -185,9 +185,8 @@ namespace OpenRA.Mods.RA.Widgets.Logic { int fps; Exts.TryParseIntegerInvariant(frameLimitTextfield.Text, out fps); - ds.MaxFramerate = fps.Clamp(20, 200); + ds.MaxFramerate = fps.Clamp(1, 1000); frameLimitTextfield.Text = ds.MaxFramerate.ToString(); - Game.SetIdealFrameTime(ds.MaxFramerate); }; frameLimitTextfield.OnEnterKey = () => { frameLimitTextfield.YieldKeyboardFocus(); return true; }; frameLimitTextfield.IsDisabled = () => !ds.CapFramerate; @@ -213,7 +212,6 @@ namespace OpenRA.Mods.RA.Widgets.Logic gs.ShowShellmap = dgs.ShowShellmap; ds.CapFramerate = dds.CapFramerate; - Game.SetIdealFrameTime(ds.MaxFramerate); ds.MaxFramerate = dds.MaxFramerate; ds.Language = dds.Language; ds.Mode = dds.Mode;