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