From 1ae391078fc435b8db5f21e4b33c44cc797f35b0 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:25:56 +0700
Subject: [PATCH 1/7] Improve FPS limiter performance
---
source/ObjectViewer/ProgramS.cs | 4 -
source/ObjectViewer/System/GameWindow.cs | 4 +
source/ObjectViewer/formOptions.cs | 1 -
source/OpenBVE/Graphics/Screen.cs | 7 +-
source/OpenBVE/System/GameWindow.cs | 4 +
source/OpenBveApi/System/Timers.cs | 99 ++++++++++++++++++++++++
source/RouteViewer/ProgramR.cs | 4 -
source/RouteViewer/System/Gamewindow.cs | 7 +-
source/RouteViewer/formOptions.cs | 1 -
9 files changed, 116 insertions(+), 15 deletions(-)
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..f291cb268 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,8 @@ protected override void OnRenderFrame(FrameEventArgs e)
Program.Renderer.Lighting.Initialize();
Program.Renderer.RenderScene(timeElapsed);
SwapBuffers();
+ // A hard cap of 540fps is always applied, even when unlimited is selected
+ FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
RenderRealTimeElapsed = 0.0;
}
@@ -327,6 +330,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.cs b/source/ObjectViewer/formOptions.cs
index aa9fea2b4..32617bbfa 100644
--- a/source/ObjectViewer/formOptions.cs
+++ b/source/ObjectViewer/formOptions.cs
@@ -280,7 +280,6 @@ private void CloseButton_Click(object sender, EventArgs e)
int[] fpsPresets = { 0, 30, 60, 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..3840222d0 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,8 @@ protected override void OnRenderFrame(FrameEventArgs e)
{
Interface.CurrentOptions.BlackBox = false;
}
+ // A hard cap of 540fps is always applied, even when unlimited is selected
+ FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
}
protected override void OnUpdateFrame(FrameEventArgs e)
@@ -547,6 +550,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/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index 116c03170..978025e6a 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,102 @@ 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, timeBeginPeriod(8) is a good compromise between accuracy and power consumption
+ private const uint WindowsTimerPeriod = 8;
+ // Hard cap applied even when the user selects 'Unlimited'
+ private const int HardFpsLimit = 540;
+
+ 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.
+ public static void ApplyLimit(int fpsLimit)
+ {
+ int limit = fpsLimit > 0 ? System.Math.Min(fpsLimit, HardFpsLimit) : HardFpsLimit;
+ RaiseTimerResolution();
+ long now = Stopwatch.GetTimestamp();
+ long target = frameStartTimestamp + (long)((1000.0 / fpsLimit / 1000.0) * Stopwatch.Frequency);
+ double remainingMs = (double)(target - now) * 1000.0 / Stopwatch.Frequency;
+ if (remainingMs <= 0.0)
+ {
+ return;
+ }
+ 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..cc9c364b4 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,9 @@ 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();
+ // A hard cap of 540fps is always applied, even when unlimited is selected
+ FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
}
protected override void OnResize(EventArgs e)
@@ -144,6 +146,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.cs b/source/RouteViewer/formOptions.cs
index 784f0d2a1..5f1f46d23 100644
--- a/source/RouteViewer/formOptions.cs
+++ b/source/RouteViewer/formOptions.cs
@@ -327,7 +327,6 @@ private void button1_Click(object sender, EventArgs e)
int[] fpsPresets = { 0, 30, 60, 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
From 3cf76df224b07193cd3543ef11d8097cbae5a1ba Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:45:23 +0700
Subject: [PATCH 2/7] Fix: OverflowException in FrameLimiter when FPS limit is
unlimited
---
source/OpenBveApi/System/Timers.cs | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index 978025e6a..d5acd6bc6 100644
--- a/source/OpenBveApi/System/Timers.cs
+++ b/source/OpenBveApi/System/Timers.cs
@@ -122,9 +122,13 @@ public static void StartFrame()
public static void ApplyLimit(int fpsLimit)
{
int limit = fpsLimit > 0 ? System.Math.Min(fpsLimit, HardFpsLimit) : HardFpsLimit;
+ if (frameStartTimestamp == 0)
+ {
+ return;
+ }
RaiseTimerResolution();
long now = Stopwatch.GetTimestamp();
- long target = frameStartTimestamp + (long)((1000.0 / fpsLimit / 1000.0) * Stopwatch.Frequency);
+ long target = frameStartTimestamp + (long)(Stopwatch.Frequency / limit);
double remainingMs = (double)(target - now) * 1000.0 / Stopwatch.Frequency;
if (remainingMs <= 0.0)
{
From b06d34596b7e0f31150e248aadd833be9addeb96 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:51:10 +0700
Subject: [PATCH 3/7] Fix: Improve frame pacing accuracy of FPS limiter (1ms
timer resolution, double-precision slot)
---
source/OpenBveApi/System/Timers.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index d5acd6bc6..a5080f680 100644
--- a/source/OpenBveApi/System/Timers.cs
+++ b/source/OpenBveApi/System/Timers.cs
@@ -96,8 +96,8 @@ 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, timeBeginPeriod(8) is a good compromise between accuracy and power consumption
- private const uint WindowsTimerPeriod = 8;
+ // 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 = 540;
@@ -128,7 +128,7 @@ public static void ApplyLimit(int fpsLimit)
}
RaiseTimerResolution();
long now = Stopwatch.GetTimestamp();
- long target = frameStartTimestamp + (long)(Stopwatch.Frequency / limit);
+ long target = frameStartTimestamp + (long)((double)Stopwatch.Frequency / limit);
double remainingMs = (double)(target - now) * 1000.0 / Stopwatch.Frequency;
if (remainingMs <= 0.0)
{
From b9dd78c0e262b7096d1c37fc74716e6faa20cde6 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:03:08 +0700
Subject: [PATCH 4/7] Change: Only raise timer resolution when the frame
limiter actually needs to wait
---
source/OpenBveApi/System/Timers.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index a5080f680..76006fa55 100644
--- a/source/OpenBveApi/System/Timers.cs
+++ b/source/OpenBveApi/System/Timers.cs
@@ -126,14 +126,16 @@ public static void ApplyLimit(int fpsLimit)
{
return;
}
- RaiseTimerResolution();
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)
From 5657d313695c667ebb8f440751feb1690b4d4b08 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:10:34 +0700
Subject: [PATCH 5/7] Change: Raise hard FPS cap to 1000
---
source/ObjectViewer/System/GameWindow.cs | 2 +-
source/OpenBVE/System/GameWindow.cs | 2 +-
source/OpenBveApi/System/Timers.cs | 2 +-
source/RouteViewer/System/Gamewindow.cs | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/source/ObjectViewer/System/GameWindow.cs b/source/ObjectViewer/System/GameWindow.cs
index f291cb268..2873f5f2c 100644
--- a/source/ObjectViewer/System/GameWindow.cs
+++ b/source/ObjectViewer/System/GameWindow.cs
@@ -264,7 +264,7 @@ protected override void OnRenderFrame(FrameEventArgs e)
Program.Renderer.Lighting.Initialize();
Program.Renderer.RenderScene(timeElapsed);
SwapBuffers();
- // A hard cap of 540fps is always applied, even when unlimited is selected
+ // A hard cap of 1000fps is always applied, even when unlimited is selected
FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
RenderRealTimeElapsed = 0.0;
diff --git a/source/OpenBVE/System/GameWindow.cs b/source/OpenBVE/System/GameWindow.cs
index 3840222d0..a5c030a66 100644
--- a/source/OpenBVE/System/GameWindow.cs
+++ b/source/OpenBVE/System/GameWindow.cs
@@ -310,7 +310,7 @@ protected override void OnRenderFrame(FrameEventArgs e)
{
Interface.CurrentOptions.BlackBox = false;
}
- // A hard cap of 540fps is always applied, even when unlimited is selected
+ // A hard cap of 1000fps is always applied, even when unlimited is selected
FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
}
diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index 76006fa55..2da05b941 100644
--- a/source/OpenBveApi/System/Timers.cs
+++ b/source/OpenBveApi/System/Timers.cs
@@ -99,7 +99,7 @@ public static class FrameLimiter
// 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 = 540;
+ private const int HardFpsLimit = 1000;
private static bool timerResolutionRaised;
private static int schedulerPeriod = 1;
diff --git a/source/RouteViewer/System/Gamewindow.cs b/source/RouteViewer/System/Gamewindow.cs
index cc9c364b4..8fcbe7556 100644
--- a/source/RouteViewer/System/Gamewindow.cs
+++ b/source/RouteViewer/System/Gamewindow.cs
@@ -85,7 +85,7 @@ protected override void OnRenderFrame(FrameEventArgs e)
Program.Renderer.RenderScene(TimeElapsed);
MessageManager.UpdateMessages(TimeElapsed);
SwapBuffers();
- // A hard cap of 540fps is always applied, even when unlimited is selected
+ // A hard cap of 1000fps is always applied, even when unlimited is selected
FrameLimiter.ApplyLimit(Interface.CurrentOptions.FPSLimit);
}
From 097e026d8891f87f7b6bf9d479ab312795e3b9d5 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:15:36 +0700
Subject: [PATCH 6/7] Change: Skip frame pacing above ~330fps where sleep-based
limiting is impractical
---
source/ObjectViewer/System/GameWindow.cs | 3 ++-
source/OpenBVE/System/GameWindow.cs | 3 ++-
source/OpenBveApi/System/Timers.cs | 11 ++++++++++-
source/RouteViewer/System/Gamewindow.cs | 3 ++-
4 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/source/ObjectViewer/System/GameWindow.cs b/source/ObjectViewer/System/GameWindow.cs
index 2873f5f2c..57b82bfde 100644
--- a/source/ObjectViewer/System/GameWindow.cs
+++ b/source/ObjectViewer/System/GameWindow.cs
@@ -264,7 +264,8 @@ protected override void OnRenderFrame(FrameEventArgs e)
Program.Renderer.Lighting.Initialize();
Program.Renderer.RenderScene(timeElapsed);
SwapBuffers();
- // A hard cap of 1000fps is always applied, even when unlimited is selected
+ // 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;
diff --git a/source/OpenBVE/System/GameWindow.cs b/source/OpenBVE/System/GameWindow.cs
index a5c030a66..8c7b6c600 100644
--- a/source/OpenBVE/System/GameWindow.cs
+++ b/source/OpenBVE/System/GameWindow.cs
@@ -310,7 +310,8 @@ protected override void OnRenderFrame(FrameEventArgs e)
{
Interface.CurrentOptions.BlackBox = false;
}
- // A hard cap of 1000fps is always applied, even when unlimited is selected
+ // 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);
}
diff --git a/source/OpenBveApi/System/Timers.cs b/source/OpenBveApi/System/Timers.cs
index 2da05b941..b39943aa6 100644
--- a/source/OpenBveApi/System/Timers.cs
+++ b/source/OpenBveApi/System/Timers.cs
@@ -100,6 +100,9 @@ public static class FrameLimiter
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;
@@ -118,7 +121,8 @@ public static void StartFrame()
}
/// 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.
+ /// 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;
@@ -126,6 +130,11 @@ public static void ApplyLimit(int fpsLimit)
{
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;
diff --git a/source/RouteViewer/System/Gamewindow.cs b/source/RouteViewer/System/Gamewindow.cs
index 8fcbe7556..bbf683aa7 100644
--- a/source/RouteViewer/System/Gamewindow.cs
+++ b/source/RouteViewer/System/Gamewindow.cs
@@ -85,7 +85,8 @@ protected override void OnRenderFrame(FrameEventArgs e)
Program.Renderer.RenderScene(TimeElapsed);
MessageManager.UpdateMessages(TimeElapsed);
SwapBuffers();
- // A hard cap of 1000fps is always applied, even when unlimited is selected
+ // 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);
}
From 985bd07fd187210856ac882f8c65323daeff3065 Mon Sep 17 00:00:00 2001
From: adfriz <76892624+adfriz@users.noreply.github.com>
Date: Sun, 23 Aug 2026 23:23:20 +0700
Subject: [PATCH 7/7] New: Add 45 and 75 FPS presets to FPS limit option
---
source/ObjectViewer/formOptions.Designer.cs | 2 ++
source/ObjectViewer/formOptions.cs | 12 +++++++-----
source/OpenBVE/UserInterface/formMain.Designer.cs | 2 ++
source/OpenBVE/UserInterface/formMain.cs | 12 +++++++-----
source/RouteViewer/formOptions.Designer.cs | 2 ++
source/RouteViewer/formOptions.cs | 12 +++++++-----
6 files changed, 27 insertions(+), 15 deletions(-)
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 32617bbfa..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,7 +279,7 @@ 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;
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/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 5f1f46d23..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,7 +326,7 @@ 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;