前言
在之前的文章中,我遇到了一个由于Unity刚体移动和逻辑移动导致的bug。相信无论是谁,在第一次解除游戏引擎的时候都会疑惑为什么很多引擎要区分物理更新于逻辑更新,本篇文章就将探索一下这个问题。
搜索答案
当我们对一个问题感到疑惑的时候,搜索引擎往往是一个不错的选择,只要我们使用Unity + FixedUpdate作为关键词就能搜索到很多回答,比如这牌知乎回答就很不错,(27 封私信 / 80 条消息) Unity为什么推荐在FixedUpdate处理物理模拟? - 知乎。
当然,如果这样就结束了的话那么这篇文章也就太敷衍了,所以接下来我也会尽可能的用自己浅薄的理解来解释这个问题。
FixedUpdate的特点
所谓Fixed就是固定的意思,这与Unity文档中对FixedUpdate的解释也是一样的。
用于物理计算且独立于帧率的 MonoBehaviour.FixedUpdate 消息。
MonoBehaviour.FixedUpdate 具有物理系统的频率;每个固定帧率帧调用该函数。在 FixedUpdate 之后,进行 Physics 系统计算。调用之间的默认时间为 0.02 秒(50 次调用/秒)。使用 Time.fixedDeltaTime 来访问该值。变更该值,方法是在脚本内将其设置为所需的值,或者导航到
Edit > Settings > Time > Fixed Timestep,然后在此处对其进行设置。FixedUpdate 频率高于或低于 Update。如果应用程序以 25 帧/秒 (fps) 的速度运行,Unity 大约每帧调用该应用程序两次,或者,100 fps 使每个 FixedUpdate 大约渲染两帧。通过Time设置,控制所需帧率以及Fixed Timestep速率。使用 Application.targetFrameRate 可以设置帧率。
使用 Rigidbody 时使用 FixedUpdate。将力设置为 Rigidbody,并在每个固定帧应用该力。FixedUpdate 按照测量的时间步长进行,一般不会与 MonoBehaviour.Update 冲突。
在以下示例中,Update 调用次数将与 FixedUpdate 调用次数相比较。FixedUpdate 每秒执行 50 次。(游戏代码在测试机器上的运行速度大约为 200 fps。)
可以看到在紧接着FixedUpdate之后,引擎就进行了物理计算了。而FixedUpdate的更新频率也可以在project setting中进行设置。但是这也带来了许多的疑问:
为什么FixedUpdate可以保证固定频率的更新
为什么FixedUpdate适合处理物理逻辑
为什么FixedUpdate会和Update进行区分(也就是本文一开始提过的问题)
FixedUpdate固定频率的秘密
方丈曾经说过——不要被事务的表面现象所迷惑
实际上,FixedUpdate并非真正的固定频率更新,这一点至少试一试就知道了。
public class TestObject : MonoBehaviour
{
float time;
void Start()
{
time = Time.realtimeSinceStartup;
}
void Update()
{
Debug.Log($"updateDeltaTime: {Time.deltaTime}");
}
void FixedUpdate()
{
Debug.Log($"<color=red>FixedDeltaTime: {Time.fixedDeltaTime}</color>");
Debug.Log($"<color=blue>realTime: {Math.Round(Time.realtimeSinceStartup - time,3)}</color>");
time = Time.realtimeSinceStartup;
}
}
上图中,红的Log为FixedUpdate的fixedDelateTime,蓝色Log为真实时间,白色为Update的delateTime。可以看到,一开始进行的FixedUpdate,然后是Update,而每次的FixedUpdate的真实时间都于Update不一致。在第二次的时候FixedUpdate甚至花费了0.08秒左右,而这之后在FixedUpdate更是在下一次的Update之前连续运行了3次。
于是我们可以轻易的得出结论,FixedDeltaTime的时间是一个假时间,而关于其具体的逻辑,我们可以从Unity的脚本生命周期中窥见一二。
可以看到在Physics的部分看到,在一个大的脚本生命周期中,它自己具有一个小的循环,这也顺便解释了刚刚上面执行了3次的原理。而据此我们还可以做一个测试,也就是假如Update 卡了很久会发生什么。
public class TestObject : MonoBehaviour
{
float time;
float updateTime;
void Start()
{
time = Time.realtimeSinceStartup;
updateTime = Time.realtimeSinceStartup;
}
void Update()
{
while (Time.realtimeSinceStartup - updateTime < 1)
{
}
Debug.Log($"updateDeltaTime: {Time.realtimeSinceStartup - updateTime}");
updateTime = Time.realtimeSinceStartup;
}
void FixedUpdate()
{
Debug.Log($"<color=red>FixedDeltaTime: {Time.fixedDeltaTime}</color>");
Debug.Log($"<color=blue>realTime: {Math.Round(Time.realtimeSinceStartup - time,3)}</color>");
time = Time.realtimeSinceStartup;
}
}
可以看到在第三帧之前,FixedUpdate 进行了超级多次。也就是说,它和Update 共享一个循环,但是为了保证它的时间能够尽量的接近固定的频率,它会自动根据fixedDeltaTime 决定调用的次数,也就是补帧。所以说如果用伪代码来表示的话,大概就是这样。
为什么FixedUpdate适合处理物理逻辑
既然我们已经知道了为什么FixedUpdate是固定的更新频率,接下来就是弄清楚为什么物理逻辑需要FixedUpdate了。这个问题其实很简单,无论是我们GamePlay的逻辑还是物理系统的逻辑,一个稳定不变的最小时间单位都是很重要的。
举一个简单的例子,在Update中,因为我们无法确保一帧的更新时间,所以为了保持画面的流畅,我们选择了用Time.deltaTime也就是距离上一帧的时间来进行补间,但是这就导致了我们每一帧的变化不是平均的。而假如这个时候我们要进行碰撞检测的话,很有可能就会出现对象直接飞过去的情况。不只是碰撞,很多物理逻辑甚至是游戏逻辑在这个状态之下都会出现不小的误差。
所以说,我们的游戏循环迫切的需要一个”固定的最小时间单位“来处理这些逻辑,于是FixedUpdate就应允而生了。
为什么FixedUpdate和Update要进行区分
这其实是一种妥协,在现在的Update架构上的一种妥协。上文说过,我们需要一个最小时间单位,不同于现实世界的秒,而是游戏世界中一定会一个tick一个tick走过的一个虚拟的单位时间。但是Update 不行,因为它的单次运行时间太不稳定,如果我们使用Update来作为这个单位,最后的结果就是画面一定会出现卡顿,于是为了解决卡顿,我们使用了Time.deltaTime进行补间,而又因为这个补间,Update失去了作为最小单位均匀的性质。
所以,Unity就创造了FixedUpdate并且赋予了它这个虚拟的时间,而将渲染的逻辑都放到了它之外(也就是生命周期外面的大循环)。这样既可以避免被渲染的代码影响运行的时间,又可以保证自己的更新频率。