diff --git a/src/Core/Silk.NET.Core/Miscellaneous/BreakneckSleep.cs b/src/Core/Silk.NET.Core/Miscellaneous/BreakneckSleep.cs
index 6e1d480e71..3c743f1fce 100644
--- a/src/Core/Silk.NET.Core/Miscellaneous/BreakneckSleep.cs
+++ b/src/Core/Silk.NET.Core/Miscellaneous/BreakneckSleep.cs
@@ -4,6 +4,7 @@
#if NET7_0_OR_GREATER
using System;
using System.Diagnostics;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Runtime.Intrinsics.Arm;
@@ -39,6 +40,22 @@ public enum AccuracyMode
BusyLoopOnly
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ private static double TrustFactor(AccuracyMode mode) => mode switch
+ {
+ AccuracyMode.HighestResolution => 0.9,
+ _ => 0.75
+ };
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ private static bool ShouldTrust(double fps, AccuracyMode mode) => (fps, mode) switch
+ {
+ (>= 200, AccuracyMode.HighestResolution) => false,
+ (>= 100, AccuracyMode.HighResolutionWithBusyLoop) => false,
+ (>= 50, _) => false,
+ _ => true
+ };
+
///
/// The underlying handle of the timer, if any.
///
@@ -74,41 +91,41 @@ public BreakneckSleep()
///
/// Sleeps for .
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Sleep(TimeSpan duration)
{
var start = Stopwatch.GetTimestamp();
- var emergencySpin = false;
- if (OperatingSystem.IsWindows())
+ if (ShouldTrust(1 / duration.TotalSeconds, Accuracy))
{
- emergencySpin = !WindowsWait(duration);
- }
+ if (OperatingSystem.IsWindows())
+ {
+ WindowsWait(duration);
+ }
- if (OperatingSystem.IsLinux())
- {
- LinuxWait(duration);
- }
+ if (OperatingSystem.IsLinux())
+ {
+ LinuxWait(duration);
+ }
- if (OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS() ||
- OperatingSystem.IsWatchOS() || OperatingSystem.IsMacCatalyst())
- {
- AppleWait(duration);
+ if (OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS() ||
+ OperatingSystem.IsWatchOS() || OperatingSystem.IsMacCatalyst())
+ {
+ AppleWait(duration);
+ }
}
- if (Accuracy is AccuracyMode.HighResolutionWithBusyLoop or AccuracyMode.BusyLoopOnly || emergencySpin)
+ do
{
- do
+ if (X86Base.IsSupported)
{
- if (X86Base.IsSupported)
- {
- X86Base.Pause();
- }
-
- if (ArmBase.IsSupported)
- {
- ArmBase.Yield();
- }
- } while (Stopwatch.GetElapsedTime(start, Stopwatch.GetTimestamp()) < duration);
- }
+ X86Base.Pause();
+ }
+
+ if (ArmBase.IsSupported)
+ {
+ ArmBase.Yield();
+ }
+ } while (Stopwatch.GetElapsedTime(start, Stopwatch.GetTimestamp()) < duration);
}
private static unsafe (nint Handle, bool IsHighResolution) WindowsCreate()
@@ -160,7 +177,7 @@ static FILETIME CreateFileTime(TimeSpan ts)
(
Accuracy == AccuracyMode.HighestResolution
? duration
- : TimeSpan.FromMicroseconds(duration.TotalMicroseconds * 0.9)
+ : TimeSpan.FromMicroseconds(duration.TotalMicroseconds * TrustFactor(Accuracy))
);
if (SetWaitableTimerEx(Handle, &ft, 0, null, null, null, 0) == 1)
{
@@ -173,6 +190,8 @@ static FILETIME CreateFileTime(TimeSpan ts)
private unsafe void LinuxWait(TimeSpan duration)
{
+ duration *= TrustFactor(AccuracyMode.HighResolutionWithBusyLoop);
+
[DllImport("libc", EntryPoint = "nanosleep")]
static extern int Nanosleep(Timespec* req, Timespec* rem);
@@ -188,7 +207,7 @@ private void AppleWait(TimeSpan duration)
{
[DllImport("libc", EntryPoint = "usleep")]
static extern int Usleep(uint micros);
- _ = Usleep((uint) (duration.TotalMicroseconds * 0.9));
+ _ = Usleep((uint) (duration.TotalMicroseconds * TrustFactor(AccuracyMode.HighResolutionWithBusyLoop)));
}
public void Dispose()
diff --git a/src/Core/Silk.NET.Core/Miscellaneous/TimeWindow.cs b/src/Core/Silk.NET.Core/Miscellaneous/TimeWindow.cs
new file mode 100644
index 0000000000..d30e8ef7de
--- /dev/null
+++ b/src/Core/Silk.NET.Core/Miscellaneous/TimeWindow.cs
@@ -0,0 +1,129 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace Silk.NET.Core;
+
+///
+/// Represents a window/interval of time in which the caller can execute.
+///
+///
+/// To create a 60 FPS loop:
+///
+/// var window = new TimeWindow();
+/// window.Window = TimeSpan.FromSeconds(1.0 / 60.0);
+/// while (true)
+/// {
+/// if (window.BeginWindow())
+/// {
+/// // It's time for the next piece of work (e.g. rendering the next frame)
+/// window.EndWindow(); // don't forget this!
+/// }
+/// // Anything that isn't necessarily dependent on a time window goes here.
+/// // In most cases, BeginWindow will sleep and always return true. This is not guaranteed, however, so anything
+/// // "real-time" (e.g. event processing) should be done here.
+/// }
+///
+///
+public class TimeWindow
+{
+#if NET7_0_OR_GREATER
+ private readonly BreakneckSleep _sleep = new();
+#endif
+ private readonly Stopwatch _sw = Stopwatch.StartNew();
+ ///
+ /// Creates a .
+ ///
+ ///
+ /// Whether should sleep until the next window is due to start.
+ ///
+ // ReSharper disable once UnusedParameter.Local
+ public TimeWindow(bool sleeping = true)
+ {
+#if NET7_0_OR_GREATER
+ Sleeping = sleeping;
+#endif
+ }
+
+ ///
+ /// The duration of a single window. For example, if you wanted to render 60 frames per second, a single frame shall
+ /// be thought of as being rendered in a 1.0 / 60.0 second window.
+ ///
+ public TimeSpan Window { get; set; } = TimeSpan.FromSeconds(1.0 / 60.0);
+
+ ///
+ /// The offset from the point at which this was constructed (or the last time
+ /// was called) at which the next window is due to begin.
+ ///
+ public TimeSpan NextWindowStart { get; private set; } = TimeSpan.Zero;
+
+ ///
+ /// The offset from the point at which this was constructed (or the last time
+ /// was called) at which the next window is due to begin.
+ ///
+ public TimeSpan NextWindowEnd
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining | (MethodImplOptions)768)]
+ get => NextWindowStart + Window;
+ }
+
+ ///
+ /// The offset from the point at which this was constructed (or the last time
+ /// was called).
+ ///
+ public TimeSpan CurrentTime
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining | (MethodImplOptions)768)]
+ get => _sw.Elapsed;
+ }
+
+#if NET7_0_OR_GREATER
+ ///
+ /// Whether should use .
+ ///
+ private bool Sleeping { get; }
+#endif
+
+ ///
+ /// Resets the to zero and the offset from which is
+ /// measured.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining | (MethodImplOptions)768)]
+ public void Reset()
+ {
+ _sw.Restart();
+ NextWindowStart = TimeSpan.Zero;
+ }
+
+ ///
+ /// Determines whether the next window is due.
+ ///
+ /// True if the next window is due or overdue, false otherwise.
+ ///
+ /// If false, do any time-independent, real-time work and call this method again.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining | (MethodImplOptions)768)]
+ public bool BeginWindow()
+ {
+#if NET7_0_OR_GREATER
+ var ct = CurrentTime;
+ if (Sleeping && ct < NextWindowStart)
+ {
+ _sleep.Sleep(NextWindowStart - ct);
+ }
+#endif
+ return CurrentTime >= NextWindowStart;
+ }
+
+ ///
+ /// Completes this time window.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining | (MethodImplOptions)768)]
+ public void EndWindow()
+ {
+ NextWindowStart += Window;
+ }
+}
diff --git a/src/Lab/Experiments/TimeTrials/Program.cs b/src/Lab/Experiments/TimeTrials/Program.cs
index 36c7519d15..5ba9e83dda 100644
--- a/src/Lab/Experiments/TimeTrials/Program.cs
+++ b/src/Lab/Experiments/TimeTrials/Program.cs
@@ -1,16 +1,47 @@
// See https://aka.ms/new-console-template for more information
+using System.Collections.Concurrent;
using System.Diagnostics;
using Silk.NET.Core;
-for (var fps = 60; fps < 61440; fps *= 2)
+var timeWindow = new TimeWindow();
+var bag = new ConcurrentBag();
+var sw = new Stopwatch();
+Console.Write("Enter frames per second: ");
+timeWindow.Window = TimeSpan.FromSeconds(1 / double.Parse(Console.ReadLine()!));
+Console.WriteLine("Press Ctrl+C to measure statistics.");
+Console.CancelKeyPress += (_, _) =>
{
- var sleep = new BreakneckSleep();
- for (var t = 0; t < 5; t++)
+ var overshoots = bag.ToList();
+ var count = overshoots.Count;
+ sw.Stop();
+ Console.WriteLine($"Run time: {sw.Elapsed.TotalSeconds}");
+ Console.WriteLine($"Frames: {overshoots.Count}");
+ Console.WriteLine($"Expected Frames: {sw.Elapsed.TotalSeconds / timeWindow.Window.TotalSeconds}");
+ Console.WriteLine($"Overshoots: {overshoots.Count(x => x > 0)}");
+ Console.WriteLine($"Undershoots: {overshoots.Count(x => x < 0)}");
+ Console.WriteLine($"Max overshoot: {overshoots.Max()}");
+ Console.WriteLine($"Max undershoot: {overshoots.Min()}");
+ Console.WriteLine($"Mean overshoot/undershoot: {overshoots.Sum() / overshoots.Count}");
+ Console.WriteLine($"Median overshoot/undershoot: {overshoots.Order().ElementAt(overshoots.Count / 2)}");
+ Console.WriteLine($"Overshoot/undershoot range: {overshoots.Max() - overshoots.Min()}");
+ overshoots.RemoveAll(x => x < 0);
+ Console.WriteLine($"Min overshoot/wasted time per frame: {overshoots.Min()}");
+ Console.WriteLine($"Mean wasted time per frame: {overshoots.Sum() / overshoots.Count}");
+ Console.WriteLine($"Median wasted time per frame: {overshoots.Order().ElementAt(overshoots.Count / 2)}");
+ Console.WriteLine($"Range for wasted time per frame: {overshoots.Max() - overshoots.Min()}");
+ Console.WriteLine($"Final FPS: {1 / (sw.Elapsed.TotalSeconds / count)}");
+};
+sw.Start();
+while (true)
+{
+ if (timeWindow.BeginWindow())
+ {
+ bag.Add((timeWindow.CurrentTime - timeWindow.NextWindowStart).TotalSeconds);
+ timeWindow.EndWindow();
+ }
+ else
{
- var now = Stopwatch.GetTimestamp();
- sleep.Sleep(TimeSpan.FromSeconds(1 / (double)fps));
- Console.WriteLine
- ($"{fps} FPS = {1 / Stopwatch.GetElapsedTime(now, Stopwatch.GetTimestamp()).TotalSeconds} FPS");
+ bag.Add(-(timeWindow.NextWindowStart - timeWindow.CurrentTime).TotalSeconds);
}
}