Skip to content

Commit a0cc4c3

Browse files
committed
A large refactor to how the internal game timers and physics calculations has been made. We've now swapped to using a fixed time step internally across Phaser, instead of the variable one we had before that caused glitchse on low-fps systems. Thanks to pjbaron for his help with all of these related changes.
We have separated the logic and render updates to permit slow motion and time slicing effects. We've fixed time calling to fix physics problems caused by variable time updates (i.e. collisions sometimes missing, objects tunneling, etc) Once per frame calling for rendering and tweening to keep things as smooth as possible Calculates a `suggestedFps` value (in multiples of 5 fps) based on a 2 second average of actual elapsed time values in the `Time.update` method. This is recalculated every 2 seconds so it could be used on a level-by-level basis if a game varies dramatically. I.e. if the fps rate consistently drops, you can adjust your game effects accordingly. Game loop now tries to "catch up" frames if it is falling behind by iterating the logic update. This will help if the logic is occasionally causing things to run too slow, or if the renderer occasionally pushes the combined frame time over the FPS time. It's not a band-aid for a game that floods a low powered device however, so you still need to code accordingly. But it should help capture issues such as gc spikes or temporarily overloaded CPUs. It now detects 'spiralling' which happens if a lot of frames are pushed out in succession meaning the CPU can never "catch up". It skips frames instead of trying to catch them up in this case. Note: the time value passed to the logic update functions is always constant regardless of these shenanigans. Signals to the game program if there is a problem which might be fixed by lowering the desiredFps Time.desiredFps is the new desired frame rate for your game. Time.suggestedFps is the suggested frame rate for the game based on system load. Time.slowMotion allows you to push the game into a slow motion mode. The default value is 1.0. 2.0 would be half speed, and so on. Time.timeCap is no longer used and now deprecated. All timing is now handled by the fixed time-step code we've introduced.
1 parent 5595307 commit a0cc4c3

6 files changed

Lines changed: 199 additions & 40 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ Version 2.1.4 - "Bethal" - in development
9393
* ScaleManager.documentWidth returns the document width in pixels.
9494
* ScaleManager.documentHeight returns the document height in pixels.
9595
* TilemapLayers have been given a decent performance boost on canvas with map shifting edge-redraw (thanks @pnstickne #1250)
96+
* A large refactor to how the internal game timers and physics calculations has been made. We've now swapped to using a fixed time step internally across Phaser, instead of the variable one we had before that caused glitchse on low-fps systems. Thanks to pjbaron for his help with all of these related changes.
97+
* We have separated the logic and render updates to permit slow motion and time slicing effects. We've fixed time calling to fix physics problems caused by variable time updates (i.e. collisions sometimes missing, objects tunneling, etc)
98+
* Once per frame calling for rendering and tweening to keep things as smooth as possible
99+
* Calculates a `suggestedFps` value (in multiples of 5 fps) based on a 2 second average of actual elapsed time values in the `Time.update` method. This is recalculated every 2 seconds so it could be used on a level-by-level basis if a game varies dramatically. I.e. if the fps rate consistently drops, you can adjust your game effects accordingly.
100+
* Game loop now tries to "catch up" frames if it is falling behind by iterating the logic update. This will help if the logic is occasionally causing things to run too slow, or if the renderer occasionally pushes the combined frame time over the FPS time. It's not a band-aid for a game that floods a low powered device however, so you still need to code accordingly. But it should help capture issues such as gc spikes or temporarily overloaded CPUs.
101+
* It now detects 'spiralling' which happens if a lot of frames are pushed out in succession meaning the CPU can never "catch up". It skips frames instead of trying to catch them up in this case. Note: the time value passed to the logic update functions is always constant regardless of these shenanigans.
102+
* Signals to the game program if there is a problem which might be fixed by lowering the desiredFps
103+
* Time.desiredFps is the new desired frame rate for your game.
104+
* Time.suggestedFps is the suggested frame rate for the game based on system load.
105+
* Time.slowMotion allows you to push the game into a slow motion mode. The default value is 1.0. 2.0 would be half speed, and so on.
106+
* Time.timeCap is no longer used and now deprecated. All timing is now handled by the fixed time-step code we've introduced.
96107

97108
### Updates
98109

src/core/Game.js

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ Phaser.Game = function (width, height, renderer, parent, state, transparent, ant
6666
*/
6767
this.height = 600;
6868

69+
/**
70+
* @property {integer} _width - Private internal var.
71+
* @private
72+
*/
73+
this._width = 800;
74+
75+
/**
76+
* @property {integer} _height - Private internal var.
77+
* @private
78+
*/
79+
this._height = 600;
80+
6981
/**
7082
* @property {boolean} transparent - Use a transparent canvas background or not.
7183
* @default
@@ -274,8 +286,36 @@ Phaser.Game = function (width, height, renderer, parent, state, transparent, ant
274286
*/
275287
this._codePaused = false;
276288

277-
this._width = 800;
278-
this._height = 600;
289+
/**
290+
* @property {number} _deltaTime - accumulate elapsed time until a logic update is due
291+
* @private
292+
*/
293+
this._deltaTime = 0;
294+
295+
/**
296+
* @property {number} _lastCount - remember how many 'catch-up' iterations were used on the logicUpdate last frame
297+
* @private
298+
*/
299+
this._lastCount = 0;
300+
301+
/**
302+
* @property {number} _spiralling - if the 'catch-up' iterations are spiralling out of control, this counter is incremented
303+
* @private
304+
*/
305+
this._spiralling = 0;
306+
307+
/**
308+
* @property {Phaser.Signal} fpsProblemNotifier - if the game is struggling to maintain the desiredFps, this signal will be dispatched
309+
* to suggest that the program adjust it's fps closer to the Time.suggestedFps value
310+
* @public
311+
*/
312+
this.fpsProblemNotifier = new Phaser.Signal();
313+
314+
/**
315+
* @property {number} _nextNotification - the soonest game.time.time value that the next fpsProblemNotifier can be dispatched
316+
* @private
317+
*/
318+
this._nextFpsNotification = 0;
279319

280320
// Parse the configuration object (if any)
281321
if (arguments.length === 1 && typeof arguments[0] === 'object')
@@ -650,12 +690,68 @@ Phaser.Game.prototype = {
650690
*
651691
* @method Phaser.Game#update
652692
* @protected
653-
* @param {number} time - The current time as provided by RequestAnimationFrame.
693+
* @param {number} time - The current time as provided by Date.now (see updateRAF in RequestAnimationFrame.js) in milliseconds
654694
*/
655695
update: function (time) {
656696

657697
this.time.update(time);
658698

699+
// if the logic time is spiralling upwards, skip a frame entirely
700+
if (this._spiralling > 1)
701+
{
702+
// cause an event to warn the program that this CPU can't keep up with the current desiredFps rate
703+
if (this.time.time > this._nextFpsNotification)
704+
{
705+
// only permit one fps notification per 10 seconds
706+
this._nextFpsNotification = this.time.time + 1000 * 10;
707+
708+
// dispatch the notification signal
709+
this.fpsProblemNotifier.dispatch();
710+
}
711+
712+
// reset the _deltaTime accumulator which will cause all pending dropped frames to be permanently skipped
713+
this._deltaTime = 0;
714+
this._spiralling = 0;
715+
}
716+
else
717+
{
718+
// step size taking into account the slow motion speed
719+
var slowStep = this.time.slowMotion * 1000.0 / this.time.desiredFps;
720+
721+
// accumulate time until the slowStep threshold is met or exceeded
722+
this._deltaTime += Math.max(Math.min(1000, this.time.elapsed), 0);
723+
724+
// call the game update logic multiple times if necessary to "catch up" with dropped frames
725+
var count = 0;
726+
727+
while (this._deltaTime >= slowStep)
728+
{
729+
this._deltaTime -= slowStep;
730+
this.updateLogic(1.0 / this.time.desiredFps);
731+
count++;
732+
}
733+
734+
// detect spiralling (if the catch-up loop isn't fast enough, the number of iterations will increase constantly)
735+
if (count > this._lastCount)
736+
{
737+
this._spiralling++;
738+
}
739+
else if (count < this._lastCount)
740+
{
741+
// looks like it caught up successfully, reset the spiral alert counter
742+
this._spiralling = 0;
743+
}
744+
745+
this._lastCount = count;
746+
}
747+
748+
// call the game render update exactly once every frame
749+
this.updateRender(this._deltaTime / slowStep);
750+
751+
},
752+
753+
updateLogic: function (timeStep) {
754+
659755
if (!this._paused && !this.pendingStep)
660756
{
661757
if (this.stepping)
@@ -677,7 +773,6 @@ Phaser.Game.prototype = {
677773

678774
this.state.update();
679775
this.stage.update();
680-
this.tweens.update();
681776
this.sound.update();
682777
this.input.update();
683778
this.physics.update();
@@ -696,6 +791,15 @@ Phaser.Game.prototype = {
696791
this.debug.preUpdate();
697792
}
698793
}
794+
},
795+
796+
updateRender: function (elapsedTime) {
797+
798+
// update tweens once every frame along with the render logic (to keep them smooth in slowMotion scenarios)
799+
if (!this._paused && !this.pendingStep)
800+
{
801+
this.tweens.update(elapsedTime);
802+
}
699803

700804
if (this.renderType != Phaser.HEADLESS)
701805
{

src/particles/arcade/Emitter.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Phaser.Particles.Arcade.Emitter.prototype.update = function () {
265265
}
266266
}
267267

268-
this._timer = this.game.time.now + this.frequency;
268+
this._timer = this.game.time.now + this.frequency * this.game.time.slowMotion;
269269
}
270270

271271
var i = this.children.length;
@@ -436,7 +436,7 @@ Phaser.Particles.Arcade.Emitter.prototype.start = function (explode, lifespan, f
436436
this.on = true;
437437
this._quantity += quantity;
438438
this._counter = 0;
439-
this._timer = this.game.time.now + frequency;
439+
this._timer = this.game.time.now + frequency * this.game.time.slowMotion;
440440
}
441441

442442
};

src/system/RequestAnimationFrame.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@ Phaser.RequestAnimationFrame.prototype = {
103103
/**
104104
* The update method for the requestAnimationFrame
105105
* @method Phaser.RequestAnimationFrame#updateRAF
106+
*
106107
*/
107-
updateRAF: function () {
108+
updateRAF: function (rafTime) {
108109

109-
this.game.update(Date.now());
110+
// floor the rafTime to make it equivalent to the Date.now() provided by updateSetTimeout (just below)
111+
this.game.update(Math.floor(rafTime));
110112

111113
this._timeOutID = window.requestAnimationFrame(this._onLoop);
112114

src/time/Time.js

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Phaser.Time = function (game) {
2121

2222
/**
2323
* @property {number} time - Game time counter. If you need a value for in-game calculation please use Phaser.Time.now instead.
24+
* - This always contains Date.now, but Phaser.Time.now will hold the high resolution RAF timer value (if RAF is available)
2425
* @protected
2526
*/
2627
this.time = 0;
@@ -49,6 +50,35 @@ Phaser.Time = function (game) {
4950
*/
5051
this.pausedTime = 0;
5152

53+
/**
54+
* @property {number} desiredFps = 60 - The desired frame-rate for this project.
55+
*/
56+
this.desiredFps = 60;
57+
58+
/**
59+
* @property {number} suggestedFps = null - The suggested frame-rate for this project.
60+
* NOTE: not available until after a few frames have passed, it is recommended to use this after a few seconds (eg. after the menus)
61+
*/
62+
this.suggestedFps = null;
63+
64+
/**
65+
* @property {number} _frameCount - count the number of calls to time.update since the last suggestedFps was calculated
66+
* @private
67+
*/
68+
this._frameCount = 0;
69+
70+
/**
71+
* @property {number} _elapsedAcumulator - sum of the elapsed time since the last suggestedFps was calculated
72+
* @private
73+
*/
74+
this._elapsedAccumulator = 0;
75+
76+
/**
77+
* @property {number} slowMotion = 1.0 - Scaling factor to make the game move smoothly in slow motion (1.0 = normal speed, 2.0 = half speed)
78+
* @type {Number}
79+
*/
80+
this.slowMotion = 1.0;
81+
5282
/**
5383
* @property {boolean} advancedTiming - If true Phaser.Time will perform advanced profiling including the fps rate, fps min/max and msMin and msMax.
5484
* @default
@@ -93,9 +123,12 @@ Phaser.Time = function (game) {
93123
this.deltaCap = 0;
94124

95125
/**
96-
* @property {number} timeCap - If the difference in time between two frame updates exceeds this value, the frame time is reset to avoid huge elapsed counts.
126+
* @property {number} timeCap - If the difference in time between two frame updates exceeds this value in ms, the frame time is reset to avoid huge elapsed counts.
127+
* - assumes a desiredFps of 60
128+
*
129+
* DEPRECATED: this no longer has any effect since the change to fixed-time stepping in game.update 3rd November 2014
97130
*/
98-
this.timeCap = 1 / 60 * 1000;
131+
this.timeCap = 1000 / 60;
99132

100133
/**
101134
* @property {number} frames - The number of frames record in the last second. Only calculated if Time.advancedTiming is true.
@@ -113,9 +146,9 @@ Phaser.Time = function (game) {
113146
this.timeToCall = 0;
114147

115148
/**
116-
* @property {number} lastTime - Internal value used by timeToCall as part of the setTimeout loop
149+
* @property {number} timeExpected - The time when the next call is expected when using setTimer to control the update loop
117150
*/
118-
this.lastTime = 0;
151+
this.timeExpected = 0;
119152

120153
/**
121154
* @property {Phaser.Timer} events - This is a Phaser.Timer object bound to the master clock to which you can add timed events.
@@ -242,25 +275,39 @@ Phaser.Time.prototype = {
242275
*/
243276
update: function (time) {
244277

278+
// this.time always holds Date.now, this.now may hold the RAF high resolution time value if RAF is available (otherwise it also holds Date.now)
279+
this.time = Date.now();
280+
281+
// 'now' is currently still holding the time of the last call, move it into prevTime
245282
this.prevTime = this.now;
246283

284+
// update 'now' to hold the current time
247285
this.now = time;
248286

249-
this.timeToCall = this.game.math.max(0, 16 - (time - this.lastTime));
287+
// elapsed time between previous call and now
288+
this.elapsed = this.now - this.prevTime;
250289

251-
this.elapsed = this.now - this.time;
290+
// time to call this function again in ms in case we're using timers instead of RequestAnimationFrame to update the game
291+
this.timeToCall = Math.floor(this.game.math.max(0, (1000.0 / this.desiredFps) - (this.timeCallExpected - time)));
252292

253-
// spike-dislike
254-
if (this.elapsed > this.timeCap)
293+
// time when the next call is expected if using timers
294+
this.timeCallExpected = time + this.timeToCall;
295+
296+
// count the number of time.update calls
297+
this._frameCount++;
298+
this._elapsedAccumulator += this.elapsed;
299+
300+
// occasionally recalculate the suggestedFps based on the accumulated elapsed time
301+
if (this._frameCount >= this.desiredFps * 2)
255302
{
256-
// For some reason the time between now and the last time the game was updated was larger than our timeCap
257-
// This can happen if the Stage.disableVisibilityChange is true and you swap tabs, which makes the raf pause.
258-
// In this case we'll drop to some default values to stop the game timers going nuts.
259-
this.elapsed = this.timeCap;
303+
// this formula calculates suggestedFps in multiples of 5 fps
304+
this.suggestedFps = Math.floor(200 / (this._elapsedAccumulator / this._frameCount)) * 5;
305+
this._frameCount = 0;
306+
this._elapsedAccumulator = 0;
260307
}
261308

262-
// Calculate physics elapsed, ensure it's > 0, use 1/60 as a fallback
263-
this.physicsElapsed = this.elapsed / 1000 || 1 / 60;
309+
// Set the physics elapsed time... this will always be 1 / this.desiredFps because we're using fixed time steps in game.update now
310+
this.physicsElapsed = 1 / this.desiredFps;
264311

265312
if (this.deltaCap > 0 && this.physicsElapsed > this.deltaCap)
266313
{
@@ -284,9 +331,6 @@ Phaser.Time.prototype = {
284331
}
285332
}
286333

287-
this.time = this.now;
288-
this.lastTime = time + this.timeToCall;
289-
290334
// Paused but still running?
291335
if (!this.game.paused)
292336
{
@@ -322,7 +366,7 @@ Phaser.Time.prototype = {
322366
*/
323367
gamePaused: function () {
324368

325-
this._pauseStarted = this.now;
369+
this._pauseStarted = Date.now();
326370

327371
this.events.pause();
328372

@@ -343,8 +387,8 @@ Phaser.Time.prototype = {
343387
*/
344388
gameResumed: function () {
345389

346-
// Level out the elapsed timer to avoid spikes
347-
this.time = this.now = Date.now();
390+
// Set the parameter which stores Date.now() to make sure it's correct on resume
391+
this.time = Date.now();
348392

349393
this.pauseDuration = this.time - this._pauseStarted;
350394

0 commit comments

Comments
 (0)