-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFMXAnimation.GameLoop.pas
More file actions
282 lines (250 loc) · 9.1 KB
/
Copy pathFMXAnimation.GameLoop.pas
File metadata and controls
282 lines (250 loc) · 9.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
/// <summary>
/// FMXAnimation.GameLoop
/// Standalone, reusable fixed-timestep game loop for FMX (Delphi 13+).
/// Core unit of FireMonkey Animation Demos — copy into any FMX project.
/// </summary>
///
/// <remarks>
/// <para>
/// <b>Independence:</b> This unit has no knowledge of any demo scene, Skia,
/// or form layout. Dependencies are only System.* and FMX.Ani / FMX.Types
/// (for TAnimation). Copy this single unit into any FMX project and wire
/// OnUpdate / OnRender — that is all that is required for a VSync-aligned
/// animation or game loop since Delphi 13's Display Link–driven TAnimation.
/// </para>
/// <para>
/// <b>The core idea (Delphi 13 / Embarcadero FMX):</b> derive from
/// <c>TAnimation</c> and override <c>ProcessAnimation</c>.
/// From Delphi 13, FMX drives animations through the platform
/// <b>Display Link Service</b> (VSync / display refresh). The framework
/// calls <c>ProcessAnimation</c> on each display-link tick for every
/// running animation.
/// </para>
/// <para>
/// <b>Three different "FPS" concepts (do not conflate them):</b>
/// </para>
/// <para>
/// 1. <b>Display-link / VSync rate</b> — how often FMX calls
/// <c>ProcessAnimation</c>. On iOS/macOS this is CADisplayLink (often respects
/// <c>GlobalPreferredFramesPerSecond</c>). On Windows with DWM composition
/// enabled, FMX uses <c>DwmFlush</c> and is effectively locked to the monitor
/// refresh (typically 60 Hz); the preferred interval is largely ignored there.
/// </para>
/// <para>
/// 2. <b>Preferred FPS</b> (<c>GlobalPreferredFramesPerSecond</c> in FMX.Types)
/// — a *request* for how often this loop should run update+render work. The
/// demo UI exposes 30/60/120. When the platform still ticks faster (Windows
/// DWM at 60 while preferred is 30), this unit <b>paces</b>: it returns early
/// from <c>ProcessAnimation</c> until at least 1/preferred seconds of wall
/// time have elapsed since the last processed frame. That is what makes
/// "30" work on Windows. Preferred can never push the rate *above* the
/// display-link ceiling (e.g. preferred 120 on a 60 Hz panel stays ~60).
/// </para>
/// <para>
/// 3. <b>FixedTimeStep</b> (default 1/60) — simulation/physics step size only.
/// Independent of preferred and of display Hz. Multiple fixed updates may run
/// per processed frame after a hitch; render happens once per processed frame.
/// Do not change FixedTimeStep when the user picks Preferred FPS.
/// </para>
/// <para>
/// <b>FMX requirement:</b> <c>TAnimation.Start</c> only subscribes to the
/// Display Link when <c>Root <> nil</c>. This unit parents itself to the
/// owner when the owner is a <c>TFmxObject</c>. <c>StartLoop</c> raises if
/// Root is still nil (fail loud instead of a one-shot freeze). Call
/// <c>StartLoop</c> when the form is visible (e.g. OnShow).
/// </para>
/// <para>
/// Timing uses an internal <c>TStopwatch</c> (not FMX animation NormalizedTime)
/// so the fixed-timestep accumulator is independent of TAnimation.Duration.
/// </para>
/// </remarks>
///
/// <copyright>
/// Copyright © 2026 Olaf Monien
/// Licensed under MIT
/// </copyright>
unit FMXAnimation.GameLoop;
interface
uses
// System
System.Classes,
System.Diagnostics,
System.SysUtils,
System.Math,
// FMX (TAnimation only — no forms, no Skia, no demo types)
FMX.Types,
FMX.Ani;
type
/// <summary>
/// Called on every fixed timestep tick. Put game logic / physics here.
/// </summary>
TGameUpdateEvent = procedure(const ADeltaTime: Double) of object;
/// <summary>
/// Called once per frame after updates. Put invalidation / render trigger here.
/// </summary>
TGameRenderEvent = procedure of object;
/// <summary>
/// High-precision, VSync-driven game loop based on TAnimation.
/// Paces work to GlobalPreferredFramesPerSecond when the display link is faster.
/// </summary>
TGameLoop = class(TAnimation)
private
FStopwatch: TStopwatch;
FLastTime: Double;
FAccumulator: Double;
FFixedTimeStep: Double;
FOnUpdate: TGameUpdateEvent;
FOnRender: TGameRenderEvent;
FMaxFrameTime: Double;
FPaceToPreferredFps: Boolean;
protected
/// <summary>
/// Central hook: FMX Display Link calls this each VSync tick.
/// May no-op when pacing to Preferred FPS (see unit remarks).
/// </summary>
procedure ProcessAnimation; override;
public
constructor Create(AOwner: TComponent); override;
/// <summary>
/// Starts the loop. Raises if Root is nil (Parent not in FMX tree).
/// Prefer calling when the host form is visible (OnShow).
/// </summary>
procedure StartLoop;
/// <summary>
/// Stops the loop.
/// </summary>
procedure StopLoop;
/// <summary>
/// Simulation step in seconds (default 1/60). Not the display or preferred rate.
/// </summary>
property FixedTimeStep: Double read FFixedTimeStep write FFixedTimeStep;
/// <summary>
/// Clamp for spiral-of-death after long pauses (debugger, Alt-Tab).
/// </summary>
property MaxFrameTime: Double read FMaxFrameTime write FMaxFrameTime;
/// <summary>
/// When True (default), skip update/render until wall time reaches
/// 1/GlobalPreferredFramesPerSecond. Needed so Preferred FPS works on
/// Windows DWM (display link stays at monitor Hz). Set False in unit tests
/// that fire ProcessAnimation back-to-back without real-time delays.
/// </summary>
property PaceToPreferredFps: Boolean read FPaceToPreferredFps write FPaceToPreferredFps;
property OnUpdate: TGameUpdateEvent read FOnUpdate write FOnUpdate;
property OnRender: TGameRenderEvent read FOnRender write FOnRender;
end;
implementation
const
cDefaultFixedTimeStep = 1 / 60;
cDefaultMaxFrameTime = 0.1;
cInfiniteAnimationDuration = 1E10;
// Allow tiny jitter below the ideal period so 60 Hz display + preferred 60
// still processes every VSync instead of occasionally dropping a frame.
cPreferredPaceSlack = 0.92;
resourcestring
rsGameLoopNeedsRoot =
'TGameLoop.StartLoop requires Root <> nil (Parent in the FMX tree) so the ' +
'Display Link can drive ProcessAnimation. Pass the form as Owner or set ' +
'Parent before StartLoop.';
{ TGameLoop }
constructor TGameLoop.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FFixedTimeStep := cDefaultFixedTimeStep;
FMaxFrameTime := cDefaultMaxFrameTime;
FPaceToPreferredFps := True;
FAccumulator := 0;
FLastTime := 0;
FStopwatch := TStopwatch.StartNew;
// Display Link subscription requires Root <> nil
if AOwner is TFmxObject then
begin
Parent := TFmxObject(AOwner);
end;
Loop := True;
Duration := cInfiniteAnimationDuration;
end;
procedure TGameLoop.StartLoop;
begin
if Root = nil then
begin
raise EInvalidOpException.Create(rsGameLoopNeedsRoot);
end;
FStopwatch.Reset;
FStopwatch.Start;
FLastTime := 0;
FAccumulator := 0;
if Running then
begin
Stop;
end;
Start;
end;
procedure TGameLoop.StopLoop;
begin
if Running then
begin
Stop;
end;
end;
procedure TGameLoop.ProcessAnimation;
// CORE: called by FMX (Delphi 13 Display Link) each VSync / display-link tick.
//
// Call rate ≠ work rate:
// - Display link may fire at monitor Hz (Windows DWM) or preferred CADisplayLink rate.
// - We optionally pace work to GlobalPreferredFramesPerSecond (see unit remarks).
// - FixedTimeStep only sizes OnUpdate; it does not control this call rate.
var
LNow: Double;
LFrameTime: Double;
LMinFrameTime: Double;
LPreferredFps: Integer;
begin
LNow := FStopwatch.Elapsed.TotalSeconds;
LFrameTime := LNow - FLastTime;
// --- Preferred-FPS pacing -------------------------------------------------
// FMX.Types.GlobalPreferredFramesPerSecond is a *request*. On iOS/macOS the
// Display Link often honours it; on Windows with DWM, FMX.DisplayLink.Default
// waits on DwmFlush and keeps calling us at display refresh (~60/120), even
// when preferred is 30. Without this early exit, the footer would stay at ~60
// FPS while the radio already shows Preferred: 30.
//
// Strategy: do not advance FLastTime / accumulator until enough wall time
// has passed. The next tick then sees a larger LFrameTime and runs normally
// (fixed-step catch-up still applies via MaxFrameTime).
//
// FLastTime = 0 after StartLoop → always process the first frame.
if FPaceToPreferredFps and (FLastTime > 0) then
begin
LPreferredFps := GlobalPreferredFramesPerSecond;
if LPreferredFps < 1 then
begin
LPreferredFps := 60;
end;
LMinFrameTime := (1.0 / LPreferredFps) * cPreferredPaceSlack;
if LFrameTime < LMinFrameTime then
begin
Exit;
end;
end;
// --------------------------------------------------------------------------
FLastTime := LNow;
if LFrameTime > FMaxFrameTime then
begin
LFrameTime := FMaxFrameTime;
end;
FAccumulator := FAccumulator + LFrameTime;
// Fixed timestep: simulation rate, independent of preferred / display Hz.
while FAccumulator >= FFixedTimeStep do
begin
if Assigned(FOnUpdate) then
begin
FOnUpdate(FFixedTimeStep);
end;
FAccumulator := FAccumulator - FFixedTimeStep;
end;
if Assigned(FOnRender) then
begin
FOnRender;
end;
end;
end.