MonoGame笔记(七)Game Loop Timing

前面2篇笔记提到Game.Tick是在系统Idle状态下被调用的, Idle状态比我们想象的要多的多的多, 而MonoGame默认的帧率是60FPS, 所以在Game.Tick内部还需要对时间进行精确控制. 看过不少资料, 在这方面介绍最好的, 还是XNA的官方文档, 简洁明了. https://msdn.microsoft.com/en-us/library/bb203873.aspx,

Game Loop Timing

A Game is either fixed step or variable step, defaulting to fixed step. The type of step determines how often Update will be called and affects how you need to represent time-based procedures such as movement and animation.

这一段话包含很多信息. 首先Game Loop Timing有两类: fixed step和variable step, 默认是fixed step(60FPS). 它影响的是Update方法被调用的频率, 而不是Draw. 在编写位置动画相关的代码时, 需要注意. 一个小人往前走, 它既有行走的动画, 同时它的位置也在改变.

Fixed-Step Game Loops

A fixed-step Game tries to call its Update method on the fixed interval specified in TargetElapsedTime. Setting Game.IsFixedTimeStep to true causes aGame to use a fixed-step game loop. A new XNA project uses a fixed-step game loop with a default TargetElapsedTime of 1/60th of a second.

1.设置Game.IsFixedTimeStep为true
2.设置帧率TargetElapsedTime

In a fixed-step game loop, Game calls Update once the TargetElapsedTime has elapsed. After Update is called, if it is not time to call Update again,Game calls Draw. After Draw is called, if it is not time to call Update again, Game idles until it is time to call Update.

在fixed-step下, 每隔TargetElapsedTime(假设1/60秒)调用一次Update. 假如一次Update所用的时间小于1/60秒, 那么就继续调用Draw方法. 假如Update+Draw加起来的时间还是小于1/60秒, 那么就等在那里(wait/idle), 直到1/60秒用完, 开始下一个1/60秒.

If Update takes too long to process, Game sets IsRunningSlowly to true and calls Update again, without calling Draw in between. When an update runs longer than the TargetElapsedTime, Game responds by calling Update extra times and dropping the frames associated with those updates to catch up. This ensures that Update will have been called the expected number of times when the game loop catches up from a slowdown. You can check the value of IsRunningSlowly in your Update if you want to detect dropped frames and shorten your Update processing to compensate. You can reset the elapsed times by calling ResetElapsedTime.

如果调用Update方法用了很长时间, Game会将IsRunningSlowly设置为True, 并且再次调用Update方法(可能多次), 跳过Draw.每次Update方法调用所花的时间并不一样(有些地方要进行大量的物理碰撞和AI计算), 可以在Update方法内部对IsRunningSlowly进行判断, 如果为真, 则简化某些计算, 这样就可以缩短Update的调用时间. 这部分需要结合代码去理解.

When your game pauses in the debugger, Game will not make extra calls to Update when the game resumes.

Variable-Step Game Loops(更推荐用这个)

A variable-step game calls its Update and Draw methods in a continuous loop without regard to the TargetElapsedTime. Setting Game.IsFixedTimeStepto false causes a Game to use a variable-step game loop.

在variable-step方式下, 调用完Update就接着调用Draw, 之后又是Update, 往复循环, 和TargetElapsedTime无关.

Animation and Timing

For operations that require precise timing, such as animation, the type of game loop your game uses (fixed-step or variable-step) is important.

Using a fixed step allows game logic to use the TargetElapsedTime as its basic unit of time and assume that Update will be called at that interval. Using a variable step requires the game logic and animation code to be based on ElapsedGameTime to ensure smooth gameplay. Because the Update method is called immediately after the previous frame is drawn, the time between calls to Update can vary. Without taking the time between calls into account, the game would seem to speed up and slow down. The time elapsed between calls to the Update method is available in the Update method’s gameTime parameter. You can reset the elapsed times by calling ResetElapsedTime.

在fixed step方式下, 可以使用TargetElapsedTime进行计算; 在variable step方式下, 则使用ElapsedGameTime, 这个值是变化的.

When using a variable-step game loop, you should express rates—such as the distance a sprite moves—in game units per millisecond (ms). The amount a sprite moves in any given update can then be calculated as the rate of the sprite times the elapsed time. Using this approach to calculate the distance the sprite moved ensures that the sprite will move consistently if the speed of the game or computer varies.

在variable step方式下, 应该使用速率(rate) * ElapsedGameTime来计算距离. 这样在性能好的与性能不好的机器上, 都能有流畅连续的移动

上面的介绍大致有了一些概念, 具体还是要看Game.Tick()代码实现

MSDN Game.Tick

  • Updates the game’s clock and calls Update and Draw.
  • In a fixed-step game, Tick calls Update only after a target time interval has elapsed.
  • In a variable-step game, Update is called every time Tick is called.

以下是MonoGame的实现

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
public void Tick()
{
// NOTE: This code is very sensitive and can break very badly
// with even what looks like a safe change. Be sure to test
// any change fully in both the fixed and variable timestep
// modes across multiple devices and platforms.

RetryTick:

// Advance the accumulated elapsed time.
var currentTicks = _gameTimer.Elapsed.Ticks;
_accumulatedElapsedTime += TimeSpan.FromTicks(currentTicks - _previousTicks);
_previousTicks = currentTicks;

// If we're in the fixed timestep mode and not enough time has elapsed
// to perform an update we sleep off the the remaining time to save battery
// life and/or release CPU time to other threads and processes.
if (IsFixedTimeStep && _accumulatedElapsedTime < TargetElapsedTime)
{
var sleepTime = (int)(TargetElapsedTime - _accumulatedElapsedTime).TotalMilliseconds;

// NOTE: While sleep can be inaccurate in general it is
// accurate enough for frame limiting purposes if some
// fluctuation is an acceptable result.
#if WINRT
Task.Delay(sleepTime).Wait();
#else
System.Threading.Thread.Sleep(sleepTime);
#endif
goto RetryTick;
}

// Do not allow any update to take longer than our maximum.
if (_accumulatedElapsedTime > _maxElapsedTime)
_accumulatedElapsedTime = _maxElapsedTime;

if (IsFixedTimeStep)
{
_gameTime.ElapsedGameTime = TargetElapsedTime;
var stepCount = 0;

// Perform as many full fixed length time steps as we can.
while (_accumulatedElapsedTime >= TargetElapsedTime)
{
_gameTime.TotalGameTime += TargetElapsedTime;
_accumulatedElapsedTime -= TargetElapsedTime;
++stepCount;

DoUpdate(_gameTime);
}

//Every update after the first accumulates lag
_updateFrameLag += Math.Max(0, stepCount - 1);

//If we think we are running slowly, wait until the lag clears before resetting it
if (_gameTime.IsRunningSlowly)
{
if (_updateFrameLag == 0)
_gameTime.IsRunningSlowly = false;
}
else if (_updateFrameLag >= 5)
{
//If we lag more than 5 frames, start thinking we are running slowly
_gameTime.IsRunningSlowly = true;
}

//Every time we just do one update and one draw, then we are not running slowly, so decrease the lag
if (stepCount == 1 && _updateFrameLag > 0)
_updateFrameLag--;

// Draw needs to know the total elapsed time
// that occured for the fixed length updates.
_gameTime.ElapsedGameTime = TimeSpan.FromTicks(TargetElapsedTime.Ticks * stepCount);
}
else
{
// Perform a single variable length update.
_gameTime.ElapsedGameTime = _accumulatedElapsedTime;
_gameTime.TotalGameTime += _accumulatedElapsedTime;
_accumulatedElapsedTime = TimeSpan.Zero;

DoUpdate(_gameTime);
}

// Draw unless the update suppressed it.
if (_suppressDraw)
_suppressDraw = false;
else
{
DoDraw(_gameTime);
}
}

Tick中的顺序是wait->Update->Draw, 在fixed-step模式下, 分为2种情况:

  1. 流畅. _accumulatedElapsedTime为上一次执行Tick到这一次执行Tick的TimeSpan. _accumulatedElapsedTime小于TargetElapsedTime, 则用Sleep将剩下的时间补足. 回到Tick的第一行代码, 此时_accumulatedElapsedTime约等于TargetElapsedTime, 往下执行Update方法和Draw方法.

  2. 卡顿. _accumulatedElapsedTime为上一次执行Tick到这一次执行Tick的TimeSpan, _accumulatedElapsedTime大于TargetElapsedTime, _accumulatedElapsedTime最大可以为1秒2帧(_maxElapsedTime), 是默认1秒60帧的30倍. 此时不需要Sleep, 直接进入While循环

1
2
3
4
5
6
7
8
9
// Perform as many full fixed length time steps as we can.
while (_accumulatedElapsedTime >= TargetElapsedTime)
{
_gameTime.TotalGameTime += TargetElapsedTime;
_accumulatedElapsedTime -= TargetElapsedTime;
++stepCount;

DoUpdate(_gameTime);
}

DoUpdate会在While里面最多连续调用30次. 为什么要这么做, 它是怎么能Catch Up from slow down的? 我想, 这里有一个很重要的假设: 这一次Tick调用Update所花费的时间是正常的(假设1/60秒). 上一次Tick调用Update所花费的时间是不正常的(假设1/2秒). 对于Game来说, 上一次的Tick所花费的时间相当于正常情况下的30倍, 即相当于正常情况下的30个Update所需要的时间.30个Update可以做很多事情. 连续30个Update之后, 再调用Draw, 画面上就会出现跳跃. 这应该就是所谓的跳帧, 虽然画面不连续, 但还是赶上了其他人的步伐.

为什么有这样一个假设(这一次Tick的Update是正常开销). 因为如果这一次的Update依旧开销很大, 那么永远都无法catch up.

那正常开销, 就能赶上来吗? while循环中的30个Update()叠加所用的时间不也是很大的开销么?
可以这样想, 虽然默认是1/60帧, 这是为了和显示器的刷新频率对应, 本身Update所占的时间可能都在1/600秒(随便举个数字), 1/60帧中的大部分都在Idle. 只要将多次调用Update的时间, 挪到Idle里面, 少Sleep即可

注: 这里有一个疑惑点

1
2
3
4
5
6
7
// Draw unless the update suppressed it. 注释有问题
if (_suppressDraw)
_suppressDraw = false;
else
{
DoDraw(_gameTime);
}

_suppressDraw为true是在Exit中被调用设置, 并没有在其他地方看到. 所以一次Tick当中, Draw是一定会被调用的, 除非是Exit.

XNA 4.0 Refresh中的实现:

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
// Microsoft.Xna.Framework.Game
public void Tick()
{
if (this.ShouldExit)
{
return;
}
if (!this.isActive)
{
Thread.Sleep((int)this.inactiveSleepTime.TotalMilliseconds);
}
this.clock.Step();
bool flag = true;
TimeSpan timeSpan = this.clock.ElapsedAdjustedTime;
if (timeSpan < TimeSpan.Zero)
{
timeSpan = TimeSpan.Zero;
}
if (this.forceElapsedTimeToZero)
{
timeSpan = TimeSpan.Zero;
this.forceElapsedTimeToZero = false;
}
if (timeSpan > this.maximumElapsedTime)
{
timeSpan = this.maximumElapsedTime;
}
if (this.isFixedTimeStep)
{
if (Math.Abs(timeSpan.Ticks - this.targetElapsedTime.Ticks) < this.targetElapsedTime.Ticks >> 6)
{
timeSpan = this.targetElapsedTime;
}
this.accumulatedElapsedGameTime += timeSpan;
long num = this.accumulatedElapsedGameTime.Ticks / this.targetElapsedTime.Ticks;
this.accumulatedElapsedGameTime = TimeSpan.FromTicks(this.accumulatedElapsedGameTime.Ticks % this.targetElapsedTime.Ticks);
this.lastFrameElapsedGameTime = TimeSpan.Zero;
if (num == 0L)
{
return;
}
TimeSpan timeSpan2 = this.targetElapsedTime;
if (num > 1L)
{
this.updatesSinceRunningSlowly2 = this.updatesSinceRunningSlowly1;
this.updatesSinceRunningSlowly1 = 0;
}
else
{
if (this.updatesSinceRunningSlowly1 < 2147483647)
{
this.updatesSinceRunningSlowly1++;
}
if (this.updatesSinceRunningSlowly2 < 2147483647)
{
this.updatesSinceRunningSlowly2++;
}
}
this.drawRunningSlowly = (this.updatesSinceRunningSlowly2 < 20);
while (num > 0L)
{
if (this.ShouldExit)
{
break;
}
num -= 1L;
try
{
this.gameTime.ElapsedGameTime = timeSpan2;
this.gameTime.TotalGameTime = this.totalGameTime;
this.gameTime.IsRunningSlowly = this.drawRunningSlowly;
this.Update(this.gameTime);
flag &= this.suppressDraw;
this.suppressDraw = false;
}
finally
{
this.lastFrameElapsedGameTime += timeSpan2;
this.totalGameTime += timeSpan2;
}
}
}
else
{
TimeSpan t = timeSpan;
this.drawRunningSlowly = false;
this.updatesSinceRunningSlowly1 = 2147483647;
this.updatesSinceRunningSlowly2 = 2147483647;
if (!this.ShouldExit)
{
try
{
this.gameTime.ElapsedGameTime = (this.lastFrameElapsedGameTime = t);
this.gameTime.TotalGameTime = this.totalGameTime;
this.gameTime.IsRunningSlowly = false;
this.Update(this.gameTime);
flag &= this.suppressDraw;
this.suppressDraw = false;
}
finally
{
this.totalGameTime += t;
}
}
}
if (!flag)
{
this.DrawFrame();
}
}