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); } }