diff --git a/source/ObjectViewer/ProgramS.cs b/source/ObjectViewer/ProgramS.cs index 1dbfae4c1..d658a69ba 100644 --- a/source/ObjectViewer/ProgramS.cs +++ b/source/ObjectViewer/ProgramS.cs @@ -182,10 +182,6 @@ internal static void Main(string[] args) Title = "Object Viewer" }; Renderer.GameWindow.VSync = Interface.CurrentOptions.VerticalSynchronization ? VSyncMode.On : VSyncMode.Off; - if (Interface.CurrentOptions.FPSLimit > 0) - { - Renderer.GameWindow.TargetRenderFrequency = Interface.CurrentOptions.FPSLimit; - } Renderer.GameWindow.Run(); // quit Renderer.TextureManager.UnloadAllTextures(false); diff --git a/source/ObjectViewer/System/GameWindow.cs b/source/ObjectViewer/System/GameWindow.cs index 59e58e97e..57b82bfde 100644 --- a/source/ObjectViewer/System/GameWindow.cs +++ b/source/ObjectViewer/System/GameWindow.cs @@ -39,6 +39,7 @@ public ObjectViewer(int width, int height, GraphicsMode currentGraphicsMode, str protected override void OnRenderFrame(FrameEventArgs e) { + FrameLimiter.StartFrame(); if (Program.Renderer.RenderThreadJobWaiting) { while (!Program.Renderer.RenderThreadJobs.IsEmpty) @@ -263,6 +264,9 @@ protected override void OnRenderFrame(FrameEventArgs e) Program.Renderer.Lighting.Initialize(); Program.Renderer.RenderScene(timeElapsed); SwapBuffers(); + // Applies the FPS limit; a hard cap of 1000fps applies when unlimited is selected, + // although no pacing is performed above around 330fps as sleep granularity makes it impractical + FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit); RenderRealTimeElapsed = 0.0; } @@ -327,6 +331,7 @@ protected override void OnClosing(CancelEventArgs e) Interface.CurrentOptions.Save(Path.CombineFile(Program.FileSystem.SettingsFolder, "1.5.0/options_ov.cfg")); Program.Renderer.VisibilityThreadShouldRun = false; Program.Renderer.DeInitialize(); + FrameLimiter.RestoreTimerResolution(); if (Program.CurrentHost.MonoRuntime) { Environment.Exit(0); diff --git a/source/ObjectViewer/formOptions.Designer.cs b/source/ObjectViewer/formOptions.Designer.cs index e07a8743c..3c4ca9d7a 100644 --- a/source/ObjectViewer/formOptions.Designer.cs +++ b/source/ObjectViewer/formOptions.Designer.cs @@ -987,7 +987,9 @@ private void InitializeComponent() this.comboBoxFPSLimit.Items.AddRange(new object[] { "Unlimited", "30", + "45", "60", + "75", "120", "240"}); this.comboBoxFPSLimit.Location = new System.Drawing.Point(160, 279); diff --git a/source/ObjectViewer/formOptions.cs b/source/ObjectViewer/formOptions.cs index aa9fea2b4..013ff30e0 100644 --- a/source/ObjectViewer/formOptions.cs +++ b/source/ObjectViewer/formOptions.cs @@ -90,13 +90,15 @@ private formOptions() // VSync and FPS Limit comboBoxVSync.SelectedIndex = Interface.CurrentOptions.VerticalSynchronization ? 1 : 0; - // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=60, 3=120, 4=240 + // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=45, 3=60, 4=75, 5=120, 6=240 switch (Interface.CurrentOptions.FPSLimit) { case 30: comboBoxFPSLimit.SelectedIndex = 1; break; - case 60: comboBoxFPSLimit.SelectedIndex = 2; break; - case 120: comboBoxFPSLimit.SelectedIndex = 3; break; - case 240: comboBoxFPSLimit.SelectedIndex = 4; break; + case 45: comboBoxFPSLimit.SelectedIndex = 2; break; + case 60: comboBoxFPSLimit.SelectedIndex = 3; break; + case 75: comboBoxFPSLimit.SelectedIndex = 4; break; + case 120: comboBoxFPSLimit.SelectedIndex = 5; break; + case 240: comboBoxFPSLimit.SelectedIndex = 6; break; default: comboBoxFPSLimit.SelectedIndex = 0; break; } UpdateFPSLimitEnabled(); @@ -277,10 +279,9 @@ private void CloseButton_Click(object sender, EventArgs e) // VSync and FPS Limit Interface.CurrentOptions.VerticalSynchronization = comboBoxVSync.SelectedIndex == 1; // Map combo index to FPSLimit value - int[] fpsPresets = { 0, 30, 60, 120, 240 }; + int[] fpsPresets = { 0, 30, 45, 60, 75, 120, 240 }; Interface.CurrentOptions.FPSLimit = comboBoxFPSLimit.SelectedIndex >= 0 ? fpsPresets[comboBoxFPSLimit.SelectedIndex] : 0; Program.Renderer.GameWindow.VSync = Interface.CurrentOptions.VerticalSynchronization ? OpenTK.VSyncMode.On : OpenTK.VSyncMode.Off; - Program.Renderer.GameWindow.TargetRenderFrequency = Interface.CurrentOptions.FPSLimit > 0 ? Interface.CurrentOptions.FPSLimit : 0; // Saving shadow settings switch (comboBoxShadowResolution.SelectedIndex) diff --git a/source/OpenBVE/Graphics/Screen.cs b/source/OpenBVE/Graphics/Screen.cs index ad8b3fff9..2ea37ad00 100644 --- a/source/OpenBVE/Graphics/Screen.cs +++ b/source/OpenBVE/Graphics/Screen.cs @@ -131,10 +131,11 @@ internal static void Initialize() return; } - // Cap the render rate only, leaving the update rate uncapped. - // Setting the update rate too caused train shaking and dropped input device plugin events (issue #957) + // Cap the render rate via our own sleep-based limiter in OpenBVEGame.OnRenderFrame, + // as OpenTK's TargetRenderFrequency uses a spin-wait which causes very high power consumption. + // Setting the update rate caused train shaking and dropped input device plugin events (issue #957) Program.Renderer.GameWindow.TargetUpdateFrequency = 0; - Program.Renderer.GameWindow.TargetRenderFrequency = Interface.CurrentOptions.FPSLimit > 0 ? Interface.CurrentOptions.FPSLimit : 0; + Program.Renderer.GameWindow.TargetRenderFrequency = 0; Program.Renderer.GameWindow.VSync = Interface.CurrentOptions.VerticalSynchronization ? VSyncMode.On : VSyncMode.Off; } diff --git a/source/OpenBVE/System/GameWindow.cs b/source/OpenBVE/System/GameWindow.cs index d139b5595..8c7b6c600 100644 --- a/source/OpenBVE/System/GameWindow.cs +++ b/source/OpenBVE/System/GameWindow.cs @@ -100,6 +100,7 @@ protected override void OnRenderFrame(FrameEventArgs e) //If the load is not complete, then we shouldn't be running the mainloop return; } + FrameLimiter.StartFrame(); if (Program.Renderer.RenderThreadJobWaiting) { @@ -309,6 +310,9 @@ protected override void OnRenderFrame(FrameEventArgs e) { Interface.CurrentOptions.BlackBox = false; } + // Applies the FPS limit; a hard cap of 1000fps applies when unlimited is selected, + // although no pacing is performed above around 330fps as sleep granularity makes it impractical + FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit); } protected override void OnUpdateFrame(FrameEventArgs e) @@ -547,6 +551,7 @@ protected override void OnClosing(CancelEventArgs e) } } Program.Renderer.TextureManager.UnloadAllTextures(false); + FrameLimiter.RestoreTimerResolution(); Program.Renderer.VisibilityThreadShouldRun = false; for (int i = 0; i < InputDevicePlugin.AvailablePluginInfos.Count; i++) { diff --git a/source/OpenBVE/UserInterface/formMain.Designer.cs b/source/OpenBVE/UserInterface/formMain.Designer.cs index 5eda0d59b..0f397b1b9 100644 --- a/source/OpenBVE/UserInterface/formMain.Designer.cs +++ b/source/OpenBVE/UserInterface/formMain.Designer.cs @@ -1718,7 +1718,9 @@ private void InitializeComponent() { this.comboBoxFPSLimit.Items.AddRange(new object[] { "Unlimited", "30", + "45", "60", + "75", "120", "240"}); this.comboBoxFPSLimit.Location = new System.Drawing.Point(156, 99); diff --git a/source/OpenBVE/UserInterface/formMain.cs b/source/OpenBVE/UserInterface/formMain.cs index 729452d8b..1f1af2ddd 100644 --- a/source/OpenBVE/UserInterface/formMain.cs +++ b/source/OpenBVE/UserInterface/formMain.cs @@ -387,13 +387,15 @@ private void formMain_Load(object sender, EventArgs e) comboboxVSync.Items.Add(""); comboboxVSync.Items.Add(""); comboboxVSync.SelectedIndex = Interface.CurrentOptions.VerticalSynchronization ? 1 : 0; - // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=60, 3=120, 4=240 + // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=45, 3=60, 4=75, 5=120, 6=240 switch (Interface.CurrentOptions.FPSLimit) { case 30: comboBoxFPSLimit.SelectedIndex = 1; break; - case 60: comboBoxFPSLimit.SelectedIndex = 2; break; - case 120: comboBoxFPSLimit.SelectedIndex = 3; break; - case 240: comboBoxFPSLimit.SelectedIndex = 4; break; + case 45: comboBoxFPSLimit.SelectedIndex = 2; break; + case 60: comboBoxFPSLimit.SelectedIndex = 3; break; + case 75: comboBoxFPSLimit.SelectedIndex = 4; break; + case 120: comboBoxFPSLimit.SelectedIndex = 5; break; + case 240: comboBoxFPSLimit.SelectedIndex = 6; break; default: comboBoxFPSLimit.SelectedIndex = 0; break; } UpdateFPSLimitEnabled(); @@ -1243,7 +1245,7 @@ private void formMain_FormClosing() Interface.CurrentOptions.FullscreenMode = radiobuttonFullscreen.Checked; Interface.CurrentOptions.VerticalSynchronization = comboboxVSync.SelectedIndex == 1; // Map combo index to FPSLimit value - int[] fpsPresets = { 0, 30, 60, 120, 240 }; + int[] fpsPresets = { 0, 30, 45, 60, 75, 120, 240 }; Interface.CurrentOptions.FPSLimit = comboBoxFPSLimit.SelectedIndex >= 0 ? fpsPresets[comboBoxFPSLimit.SelectedIndex] : 0; Interface.CurrentOptions.WindowWidth = (int)Math.Round(updownWindowWidth.Value); Interface.CurrentOptions.WindowHeight = (int)Math.Round(updownWindowHeight.Value); diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs index 116c03170..b39943aa6 100644 --- a/source/OpenBveApi/System/Timers.cs +++ b/source/OpenBveApi/System/Timers.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; namespace OpenBveApi { @@ -85,4 +86,117 @@ public static int GetClockTicks() } + + /// + /// Cross-platform frame rate limiter. + /// Uses a bulk Thread.Sleep followed by a short Thread.Yield wait, mirroring the approach used by modern OpenTK, + /// in order to avoid the high power consumption of a pure spin-wait based limiter. + /// + public static class FrameLimiter + { + // Tolerance as a fraction of the scheduler period, left unslept so that we do not overshoot the target + private const double Tolerance = 0.02; + // On Windows, raise the timer resolution to 1ms for accurate frame pacing + private const uint WindowsTimerPeriod = 1; + // Hard cap applied even when the user selects 'Unlimited' + private const int HardFpsLimit = 1000; + // Frameslots shorter than this multiple of the scheduler period cannot be paced accurately by + // Thread.Sleep without a power-hungry busy spin, so limiting is skipped above around 330fps + private const double MinimumSlotPeriods = 3.0; + + private static bool timerResolutionRaised; + private static int schedulerPeriod = 1; + private static long frameStartTimestamp; + + [DllImport("winmm")] + private static extern uint timeBeginPeriod(uint uPeriod); + + [DllImport("winmm")] + private static extern uint timeEndPeriod(uint uPeriod); + + /// Marks the start of a frame. Must be called once at the beginning of each rendered frame. + public static void StartFrame() + { + frameStartTimestamp = Stopwatch.GetTimestamp(); + } + + /// Waits until the end of the current frame's allotted timeslot. + /// The maximum frames per second selected by the user. A value of zero or less means unlimited, subject to the hard cap. + /// Above around 330fps no limiting is performed, as sleep-based pacing is impractical at such frame rates. + public static void ApplyLimit(int fpsLimit) + { + int limit = fpsLimit > 0 ? System.Math.Min(fpsLimit, HardFpsLimit) : HardFpsLimit; + if (frameStartTimestamp == 0) + { + return; + } + if ((double)Stopwatch.Frequency / limit < schedulerPeriod * MinimumSlotPeriods) + { + // The timeslot is too short to pace accurately without busy-spinning - skip limiting + return; + } + long now = Stopwatch.GetTimestamp(); + long target = frameStartTimestamp + (long)((double)Stopwatch.Frequency / limit); + double remainingMs = (double)(target - now) * 1000.0 / Stopwatch.Frequency; + if (remainingMs <= 0.0) + { + // The frame overran its timeslot (or was throttled by VSync) - nothing to wait for, + // so do not raise the system timer resolution needlessly + return; + } + RaiseTimerResolution(); + double sleepMs = remainingMs - schedulerPeriod * Tolerance; + int ticks = (int)(sleepMs / schedulerPeriod); + if (ticks > 0) + { + Thread.Sleep(ticks * schedulerPeriod); + } + while (Stopwatch.GetTimestamp() < target) + { + Thread.Yield(); + } + } + + /// Restores the system timer resolution, if it was previously raised. + public static void RestoreTimerResolution() + { + if (timerResolutionRaised) + { + timerResolutionRaised = false; + try + { + timeEndPeriod(WindowsTimerPeriod); + } + catch + { + // Not on Windows, or winmm unavailable + } + } + } + + private static void RaiseTimerResolution() + { + if (!timerResolutionRaised) + { + timerResolutionRaised = true; + try + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + timeBeginPeriod(WindowsTimerPeriod); + schedulerPeriod = (int)WindowsTimerPeriod; + } + else + { + // Linux and macOS can accurately sleep for around 1ms + schedulerPeriod = 1; + } + } + catch + { + schedulerPeriod = 1; + } + } + } + } } diff --git a/source/RouteViewer/ProgramR.cs b/source/RouteViewer/ProgramR.cs index 73dfeb6d0..eb5cc8770 100644 --- a/source/RouteViewer/ProgramR.cs +++ b/source/RouteViewer/ProgramR.cs @@ -190,10 +190,6 @@ internal static void Main(string[] args) Renderer.GameWindow.TargetRenderFrequency = 0; Renderer.GameWindow.Title = "Route Viewer"; Renderer.GameWindow.VSync = Interface.CurrentOptions.VerticalSynchronization ? VSyncMode.On : VSyncMode.Off; - if (Interface.CurrentOptions.FPSLimit > 0) - { - Renderer.GameWindow.TargetRenderFrequency = Interface.CurrentOptions.FPSLimit; - } processCommandLineArgs = true; Renderer.GameWindow.Run(); //Unload diff --git a/source/RouteViewer/System/Gamewindow.cs b/source/RouteViewer/System/Gamewindow.cs index 81473826f..bbf683aa7 100644 --- a/source/RouteViewer/System/Gamewindow.cs +++ b/source/RouteViewer/System/Gamewindow.cs @@ -53,6 +53,7 @@ protected override void OnUpdateFrame(FrameEventArgs e) //This renders the frame protected override void OnRenderFrame(FrameEventArgs e) { + FrameLimiter.StartFrame(); Program.MouseMovement(); Program.Renderer.FrameRate = RenderFrequency; @@ -83,8 +84,10 @@ protected override void OnRenderFrame(FrameEventArgs e) Program.Renderer.Lighting.UpdateLighting(Program.CurrentRoute.SecondsSinceMidnight, Program.CurrentRoute.LightDefinitions); Program.Renderer.RenderScene(TimeElapsed); MessageManager.UpdateMessages(TimeElapsed); - SwapBuffers(); - + SwapBuffers(); + // Applies the FPS limit; a hard cap of 1000fps applies when unlimited is selected, + // although no pacing is performed above around 330fps as sleep granularity makes it impractical + FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit); } protected override void OnResize(EventArgs e) @@ -144,6 +147,7 @@ protected override void OnClosing(CancelEventArgs e) Loading.Cancel = true; } Program.Renderer.DeInitialize(); + FrameLimiter.RestoreTimerResolution(); if (Program.CurrentHost.MonoRuntime) { // Mono often fails to close the main window properly diff --git a/source/RouteViewer/formOptions.Designer.cs b/source/RouteViewer/formOptions.Designer.cs index 3fdd49873..ba525dcc4 100644 --- a/source/RouteViewer/formOptions.Designer.cs +++ b/source/RouteViewer/formOptions.Designer.cs @@ -963,7 +963,9 @@ private void InitializeComponent() this.comboBoxFPSLimit.Items.AddRange(new object[] { "Unlimited", "30", + "45", "60", + "75", "120", "240"}); this.comboBoxFPSLimit.Location = new System.Drawing.Point(160, 495); diff --git a/source/RouteViewer/formOptions.cs b/source/RouteViewer/formOptions.cs index 784f0d2a1..517408a50 100644 --- a/source/RouteViewer/formOptions.cs +++ b/source/RouteViewer/formOptions.cs @@ -93,13 +93,15 @@ public FormOptions() // VSync and FPS Limit comboBoxVSync.SelectedIndex = Interface.CurrentOptions.VerticalSynchronization ? 1 : 0; - // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=60, 3=120, 4=240 + // Map FPSLimit value to combo index: 0=Unlimited, 1=30, 2=45, 3=60, 4=75, 5=120, 6=240 switch (Interface.CurrentOptions.FPSLimit) { case 30: comboBoxFPSLimit.SelectedIndex = 1; break; - case 60: comboBoxFPSLimit.SelectedIndex = 2; break; - case 120: comboBoxFPSLimit.SelectedIndex = 3; break; - case 240: comboBoxFPSLimit.SelectedIndex = 4; break; + case 45: comboBoxFPSLimit.SelectedIndex = 2; break; + case 60: comboBoxFPSLimit.SelectedIndex = 3; break; + case 75: comboBoxFPSLimit.SelectedIndex = 4; break; + case 120: comboBoxFPSLimit.SelectedIndex = 5; break; + case 240: comboBoxFPSLimit.SelectedIndex = 6; break; default: comboBoxFPSLimit.SelectedIndex = 0; break; } UpdateFPSLimitEnabled(); @@ -324,10 +326,9 @@ private void button1_Click(object sender, EventArgs e) // VSync and FPS Limit Interface.CurrentOptions.VerticalSynchronization = comboBoxVSync.SelectedIndex == 1; // Map combo index to FPSLimit value - int[] fpsPresets = { 0, 30, 60, 120, 240 }; + int[] fpsPresets = { 0, 30, 45, 60, 75, 120, 240 }; Interface.CurrentOptions.FPSLimit = comboBoxFPSLimit.SelectedIndex >= 0 ? fpsPresets[comboBoxFPSLimit.SelectedIndex] : 0; Program.Renderer.GameWindow.VSync = Interface.CurrentOptions.VerticalSynchronization ? OpenTK.VSyncMode.On : OpenTK.VSyncMode.Off; - Program.Renderer.GameWindow.TargetRenderFrequency = Interface.CurrentOptions.FPSLimit > 0 ? Interface.CurrentOptions.FPSLimit : 0; // Sun direction is already updated in real-time via slider events