Skip to content

Commit f31c60c

Browse files
authored
Feature/fix fps calculation (#191)
* Fix PeriodicAsyncTimer for Avalonia and Blazor apps for more accurate timer. * Make common FrameTimer class in the Highbyte.DotNet6502.Systems library that is used by Avalonia, Blazor WASM and Headless apps.
1 parent 9d49566 commit f31c60c

8 files changed

Lines changed: 227 additions & 211 deletions

File tree

Lines changed: 6 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,16 @@
1-
using System;
2-
using System.Diagnostics;
3-
using System.Threading;
4-
using System.Threading.Tasks;
51
using Avalonia.Threading;
6-
using Highbyte.DotNet6502.Systems;
2+
using Highbyte.DotNet6502.Systems.Timing;
73

84
namespace Highbyte.DotNet6502.App.Avalonia.Core;
95

106
/// <summary>
11-
/// A timer that uses .NET built-in PeriodicTimer.
12-
/// As PeriodicTimer runs on the thread it is created on, we need to marshal the Elapsed event to the UI thread.
7+
/// Avalonia-specific <see cref="FrameTimer"/> that marshals the Elapsed event to the UI thread.
8+
/// All pacing/precision logic lives in <see cref="FrameTimer"/>.
139
/// </summary>
14-
public class PeriodicAsyncTimer : IScriptingTickTimer, IAsyncDisposable
10+
public sealed class PeriodicAsyncTimer : FrameTimer
1511
{
16-
private CancellationTokenSource? _cts;
17-
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
18-
private long _lastTick;
19-
20-
public double IntervalMilliseconds { get; set; }
21-
public long TimeSinceLastTickMilliseconds { get; private set; }
22-
23-
public event EventHandler? Elapsed;
24-
25-
event EventHandler IScriptingTickTimer.Elapsed
26-
{
27-
add => Elapsed += value;
28-
remove => Elapsed -= value;
29-
}
30-
31-
public void Start()
32-
{
33-
Stop();
34-
_cts?.Cancel();
35-
_cts?.Dispose();
36-
_cts = new CancellationTokenSource();
37-
_ = StartTimerAsync();
38-
}
39-
40-
private async Task StartTimerAsync()
41-
{
42-
var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(IntervalMilliseconds));
43-
44-
try
45-
{
46-
while (await timer.WaitForNextTickAsync(_cts!.Token))
47-
{
48-
var time = _stopwatch.ElapsedMilliseconds;
49-
TimeSinceLastTickMilliseconds = time - _lastTick;
50-
_lastTick = time;
51-
52-
// Marshal to UI thread for Avalonia
53-
await Dispatcher.UIThread.InvokeAsync(() =>
54-
{
55-
Elapsed?.Invoke(this, EventArgs.Empty);
56-
}, DispatcherPriority.Render);
57-
}
58-
}
59-
catch (OperationCanceledException)
60-
{
61-
// Expected when cancellation is requested
62-
}
63-
finally
64-
{
65-
timer.Dispose();
66-
}
67-
}
68-
69-
public void Stop()
70-
{
71-
_cts?.Cancel();
72-
}
73-
74-
public void Dispose()
75-
{
76-
Stop();
77-
}
78-
79-
public ValueTask DisposeAsync()
12+
public PeriodicAsyncTimer()
13+
: base(action => Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Render).GetTask())
8014
{
81-
Dispose();
82-
return ValueTask.CompletedTask;
8315
}
8416
}

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/ViewModels/MainViewModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,8 +1356,8 @@ private void UpdateStatusFps()
13561356
{
13571357
var fpsStat = _hostApp.GetStats()
13581358
.FirstOrDefault(s => s.name.EndsWith("OnUpdateFPS", StringComparison.OrdinalIgnoreCase));
1359-
if (fpsStat.stat is Highbyte.DotNet6502.Systems.Instrumentation.Stats.AveragedStat averaged && averaged.Value.HasValue)
1360-
StatusFpsText = $"{Math.Round(averaged.Value.Value)} fps";
1359+
if (fpsStat.stat is Highbyte.DotNet6502.Systems.Instrumentation.Stats.PerSecondTimedStat perSecond && perSecond.Value.HasValue)
1360+
StatusFpsText = $"{Math.Round(perSecond.Value.Value)} fps";
13611361
else
13621362
StatusFpsText = string.Empty;
13631363
}

src/apps/Highbyte.DotNet6502.App.Headless/HeadlessHostApp.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Highbyte.DotNet6502.Systems;
44
using Highbyte.DotNet6502.Systems.Audio;
55
using Highbyte.DotNet6502.Systems.Input;
6+
using Highbyte.DotNet6502.Systems.Timing;
67
using Microsoft.Extensions.Logging;
78

89
namespace Highbyte.DotNet6502.App.Headless;
@@ -16,7 +17,7 @@ public class HeadlessHostApp : HostApp<NullInputHandlerContext, NullAudioHandler
1617
private new readonly ILogger _logger;
1718
private readonly CancellationTokenSource _appCts;
1819

19-
private HeadlessPeriodicTimer? _updateTimer;
20+
private FrameTimer? _updateTimer;
2021

2122
// IDebuggableHostApp
2223
public bool WaitForExternalDebugger { get; set; }
@@ -112,7 +113,7 @@ public override void OnAfterClose()
112113
// --- Scripting timer ---
113114

114115
protected override IScriptingTickTimer CreateScriptingTickTimer(double intervalMs) =>
115-
new HeadlessPeriodicTimer { IntervalMilliseconds = intervalMs };
116+
new FrameTimer { IntervalMilliseconds = intervalMs };
116117

117118
protected override void OnScriptingEngineSet()
118119
{
@@ -161,10 +162,10 @@ public override void OnAfterRunEmulatorOneFrame(ExecEvaluatorTriggerResult execE
161162

162163
// --- Timer helpers ---
163164

164-
private HeadlessPeriodicTimer CreateUpdateTimerForSystem(ISystem system)
165+
private FrameTimer CreateUpdateTimerForSystem(ISystem system)
165166
{
166167
double updateIntervalMS = (1 / system.Screen.RefreshFrequencyHz) * 1000;
167-
var timer = new HeadlessPeriodicTimer { IntervalMilliseconds = updateIntervalMS };
168+
var timer = new FrameTimer { IntervalMilliseconds = updateIntervalMS };
168169
timer.Elapsed += UpdateTimerElapsed;
169170
return timer;
170171
}

src/apps/Highbyte.DotNet6502.App.Headless/HeadlessPeriodicTimer.cs

Lines changed: 0 additions & 68 deletions
This file was deleted.

src/apps/Highbyte.DotNet6502.App.WASM/Emulator/Skia/PeriodicAsyncTimer.cs

Lines changed: 0 additions & 45 deletions
This file was deleted.

src/apps/Highbyte.DotNet6502.App.WASM/Emulator/Skia/SkiaWASMHostApp.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Highbyte.DotNet6502.Systems.Instrumentation.Stats;
1515
using Highbyte.DotNet6502.Systems.Rendering;
1616
using Highbyte.DotNet6502.Systems.Rendering.VideoFrameProvider;
17+
using Highbyte.DotNet6502.Systems.Timing;
1718
using Toolbelt.Blazor.Gamepad;
1819

1920
namespace Highbyte.DotNet6502.App.WASM.Emulator.Skia;
@@ -43,7 +44,7 @@ public class SkiaWASMHostApp : HostApp<AspNetInputHandlerContext, WASMAudioHandl
4344

4445
private readonly IJSRuntime _jsRuntime;
4546
private readonly Highbyte.DotNet6502.App.WASM.Pages.Index _wasmHostUIViewModel;
46-
private PeriodicAsyncTimer? _updateTimer;
47+
private FrameTimer? _updateTimer;
4748

4849
private WasmMonitor _monitor = default!;
4950
public WasmMonitor Monitor => _monitor;
@@ -213,11 +214,11 @@ public override void OnAfterClose()
213214
}
214215

215216

216-
private PeriodicAsyncTimer CreateUpdateTimerForSystem(ISystem system)
217+
private FrameTimer CreateUpdateTimerForSystem(ISystem system)
217218
{
218219
// Number of milliseconds between each invokation of the main loop. 60 fps -> (1/60) * 1000 -> approx 16.6667ms
219220
double updateIntervalMS = (1 / system.Screen.RefreshFrequencyHz) * 1000;
220-
var updateTimer = new PeriodicAsyncTimer();
221+
var updateTimer = new FrameTimer();
221222
updateTimer.IntervalMilliseconds = updateIntervalMS;
222223
updateTimer.Elapsed += UpdateTimerElapsed;
223224
return updateTimer;

src/libraries/Highbyte.DotNet6502.Systems/Instrumentation/Stats/PerSecondTimedStat.cs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,63 @@
22

33
namespace Highbyte.DotNet6502.Systems.Instrumentation.Stats;
44

5-
// Credit to instrumentation/stat code to: https://github.com/davidwengier/Trains.NET
6-
public class PerSecondTimedStat : AveragedStat
5+
// Credit to instrumentation/stat code to: https://github.com/davidwengier/Trains.NET
6+
public class PerSecondTimedStat : IStat
77
{
8-
private readonly Stopwatch _sw;
9-
public PerSecondTimedStat()
10-
: base(60) // Average over 60 samples
8+
private const int SampleCount = 60;
9+
10+
private readonly Stopwatch _sw = new();
11+
private double? _emaElapsedMs;
12+
private double? _fakeValue;
13+
14+
// Compute FPS as 1 / E[T], not E[1/T]. The latter is upward-biased when intervals vary
15+
// (Jensen's inequality), which on browsers - where Task.Delay clamping causes 30%+ jitter
16+
// in frame intervals - makes a true 59.83 fps loop read as ~62 fps. Averaging the elapsed
17+
// time and inverting gives an unbiased estimate of the true average rate.
18+
public double? Value
1119
{
12-
_sw = new Stopwatch();
20+
get
21+
{
22+
if (_fakeValue.HasValue)
23+
return _fakeValue.Value;
24+
if (!_emaElapsedMs.HasValue || _emaElapsedMs.Value <= 0)
25+
return null;
26+
return 1000.0 / _emaElapsedMs.Value;
27+
}
1328
}
1429

1530
public void Update()
1631
{
1732
if (_sw.IsRunning)
1833
{
19-
//var elapsedMs = _sw.ElapsedMilliseconds;
2034
var elapsedMs = _sw.Elapsed.TotalMilliseconds;
2135
#if DEBUG
2236
if (elapsedMs == 0)
2337
throw new NotImplementedException("Elapsed 0.0 milliseconds, cannot handle division by 0");
2438
#endif
25-
var perSecond = 1000.0 / elapsedMs;
26-
SetValue(perSecond);
39+
if (_emaElapsedMs == null)
40+
_emaElapsedMs = elapsedMs;
41+
else
42+
_emaElapsedMs = (_emaElapsedMs.Value * (SampleCount - 1) + elapsedMs) / SampleCount;
2743
}
2844
_sw.Restart();
2945
}
3046

31-
32-
public override string GetDescription()
47+
public string GetDescription()
3348
{
34-
if (Value == null)
49+
var value = Value;
50+
if (value == null)
3551
return "null";
36-
if (Value < 0.01)
52+
if (value < 0.01)
3753
return "< 0.01";
38-
return Math.Round(Value ?? 0, 2).ToString();
54+
return Math.Round(value ?? 0, 2).ToString();
3955
}
4056

57+
public bool ShouldShow() => Value.HasValue;
58+
4159
// For unit testing
4260
public void SetFakeFPSValue(double fps)
4361
{
44-
SetValue(fps);
62+
_fakeValue = fps;
4563
}
4664
}

0 commit comments

Comments
 (0)