从'Maximum Allowed Timestep'看 Unity 中的 Physics 阶段

前言

在 Projects Settings - Time 设置中,有一个和 Fixed Timestep 并列的设置项 Maximum Allowed Timestep;当游戏中大量运用物理计算时候,这个设置项可能就会引起你的注意。

探讨

Maximum Allowed Timestep 作用

在游戏帧率很低的情况下这个值用来限制最坏的情况发生,同时它将物理模块的执行时间限定在设定值之中。

上面描述中有两个关键点,在弄清楚这两个关键点之前需要先深入了解一下关于物理模块(以 FixedUpdate 方法为例)在 MonoBehaviour 生命流水线中「占据」的阶段。

Physics 阶段

物理模块在整个 MonoBehaviour 生命周期中位于 Start 之后、输入事件检测之前;模块由 FixedUpdate、Animation update 和物理引擎计算组成。在每一帧中物理模块执行次数由设置的 Maximum Allowed Timestep 以及实际帧率决定。

下图是物理模块在整个 MonoBehaviour 生命周期中的位置:

从上图中可以看出,在帧主循环基础上 Physics 阶段还拥有一个自循环。

Unity 被设计为单线程,因此在每一帧中 Physics 阶段自循环一定有跳出该自循环的条件,这样才能够继续当前帧中的其他操作(如检测输入事件、渲染等)。那么这个条件是什么呢?下面从 FixedUpdateUpdateLateUpdate 三个方法来看看分析。

首先使用代码设置游戏帧率为 50FPS,其余设置默认。观察各个「Update」方法打印的日志,如下:

1
2
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = 50;

从上图日志可以看出运行稳定时每帧间隔为 20ms 左右,FixedUpdate 方法基本都会在 LateUpdateUpdate 方法之间执行一次。

接着讲游戏帧率修改为 10FPS,继续查看日志,如下图:

可以看出运行稳定时每帧间隔变成了 100ms 左右,FixedUpdate 方法此时会在 LateUpdateUpdate 方法之间执行 5 次左右。后面再修改帧率为其它值,游戏运行稳定时 FixedUpdate 方法调用顺序和次数都呈现一个规律:

总是在 LateUpdateUpdate 方法之间执行大约 \(\frac{Time.deltaTime}{Fixed Timestep}\) 次。

上面提到了 Fixed Timestep,它是一个独立于帧率的用来指示物理模块执行的一个常量。通过这个值可以独立于帧率来驱动物理模拟,从而保证了不同帧率下物理模块计算的一致性。现在我们大概知道了 Physics 阶段的自循环大概是怎么实现的了,简单的模拟伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Stopwatch _stopwatch = new Stopwatch();
float _physicsTime = .0f;
float _fixedTimeStep = 0.02f;
_stopwatch.Start();
while(Running())
{
float elapsedSeconds = _stopwatch.ElapsedMilliseconds / 1000.0f;
while(_physicsTime < elapsedSeconds)
{
_physicsTime += _fixedTimeStep;
DoPhysics();
}
DoInput();
DoUpdate();
}

就像 MonoBehaviour 生命周期图中描述的那样,物理模块通过一个自循环来进行物理香相关计算,通过 Fixed Timestep 和当前帧间隔的时间来控制自循环次数。FixedUpdate 方法中的 Time.fixedDeltaTimeTime.deltaTime 的值也是 Fixed Timestep 设置的值。

下面再来看看最文章开始提到的两个关键点。

“最坏”情况是如何发生的?

通过上面对 Physics 阶段介绍知道,Unity 物理模块在主线程中也是和当前帧率有关联的。当帧率很低时,物理模块循环次数会变多,若此时每次物理模块计算量也很繁重,就会导致整个物理模块循环非常耗时,进而再次降低帧率。

Maximum Allowed Timestep 如何将物理模块的执行时间限定在设定值之中?

继续保持游戏帧率为 10FPS,对 Time 进行如下设置:

运行观察各个「Update」打印的日志情况,如下:

此时真实帧间隔 100ms 左右,Time.deltaTime 也和真实帧间隔几乎保持相同;FixedUpdate 方法执行顺序以及次数依旧满足之前伪代码的实现。接着将 Maximum Allowed Timestep 设置为 0.06s,再次运行观察日志:

可以看出设置了 Maximum Allowed Timestep 为 0.06s 之后,真实帧间隔依旧是 100ms 左右,但是 Time.deltaTime 固定在了 60ms,并且 FixedUpdate 方法执行次数变成了三次。这和上面伪代码的模拟好像有点出入。

再次将 Maximum Allowed Timestep 设置为 0.04s,运行后 Time.deltaTime 固定在 40ms,FixedUpdate 方法执行次数变成了两次。出现这些结果并不意外,因为此时正是 Maximum Allowed Timestep 设置的值在起作用。

在测试中帧率设置为 10FPS(每帧的时间间隔为 100ms)时,帧间隔时间大于设置的 Maximum Allowed Timestep 值。此时系统认为游戏帧率过低,如果继续使用当前帧间隔时间来自循环物理模块,假如此时每循环一次物理模块计算也很耗时,这必然会导致帧率进一步降低,“最坏”情况就发生了;因此通过 Maximum Allowed Timestep 来限制物理模块自循环次数,当真实帧间隔时间大于这个值时,将自循环次数降到大约 \(\frac{Maximum Allowed Timestep}{Fixed Timestep}\) 次。这也就是 FixedUpdate 方法执行次数和上面伪代码的模拟有出入的原因。

所以,将 Maximum Allowed Timestep 控制也加入到上面模拟伪代码中:

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
Stopwatch _stopwatch = new Stopwatch();
float _physicsTime = .0f;
float _fixedTimeStep = 0.02f;
float _maxAllowedTimestep = 0.06f;
int _maxAllowedPhysicsLoopCount = _maxAllowedTimestep / _fixedTimeStep;
_stopwatch.Start();
while(Running())
{
int physicsLoopCount = 0;
float elapsedSeconds = _stopwatch.ElapsedMilliseconds / 1000.0f;
while(_physicsTime < elapsedSeconds && physicsLoopCount < _maxAllowedPhysicsLoopCount)
{
_physicsTime += _fixedTimeStep;
physicsLoopCount++;
DoPhysics();
}
// update frame delta time
Time.deltaTime = Mathf.Min(Time.deltaTime, _maxAllowedTimestep);
DoInput();
DoUpdate();
}

上面的代码加入了 Maximum Allowed Timestep 控制,从而能够在帧率较低时使用它设置的值来控制物理模块的自循环次数,这样物理模块计算就会慢下来(等待渲染进度赶上)。当物理模块计算也比较耗时的时候,“最坏情况”也被削弱。

上面代码中间还有一行用来更新 Time.deltaTime,当帧间隔时间大于 Maximum Allowed Timestep 设置的值时,Time.deltaTime 被设置为 Maximum Allowed Timestep 的值,因此就出现了 Update 方法中的 Time.deltaTime 固定值的情况。