记一次unity的物理计算导致的bug
起因
事情的起因是因为在游戏项目中产生的一个bug
DoorItem.cs
public class DoorItem : MonoBehaviour
{
public bool locked;
public bool inScope;
public SpriteRenderer roadIcon;
public RoomItem.Direction direction;
public TMP_Text buttonHit;
protected void Start()
{
roadIcon.enabled = true;
}
protected virtual void Update()
{
// 检测开门按钮
if (Input.GetButton("DoorButton") && inScope)
{
// 激活之后就显示道路的图标
// if (roadIcon!= null) roadIcon.enabled = true;
// 如果没有上锁且在区域内,就传送
RoomManager.Instance.TransferRoom(direction);
}
}
/// <summary>
/// 如果是玩家触碰到了门,根据当前房间是否锁定来判断是否可以移动
/// </summary>
/// <param name="other"></param>
protected virtual void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player") && !locked)
{
inScope = true;
// 显示提示文本
if (buttonHit != null) buttonHit.enabled = true;
}
}
/// <summary>
/// 如果是玩家离开了门,则将inScope设置为false
/// </summary>
/// <param name="other"></param>
protected virtual void OnTriggerExit2D(Collider2D other)
{
if(other.CompareTag("Player"))
{
inScope = false;
// 隐藏提示文本
if (buttonHit!= null) buttonHit.enabled = false;
}
}
}
RoomManager.cs 部分代码
public void TransferRoom(RoomItem.Direction direction)
{
int dx = CurrentRoomPosition.x + directionX[(int)direction];
int dy = CurrentRoomPosition.y + directionY[(int)direction];
// 检查越界
if (dx < 0 || dx >= mapSize.x || dy < 0 || dy >= mapSize.y) return;
// 检查是否有房间
if (!allroom.ContainsKey(new Vector2Int(dx, dy))) return;
// 检查是否有门
if (!CurrentRoom.TransferTargetRoom.ContainsKey(direction)) return;
// 获取目标房间
RoomItem targetRoom = CurrentRoom.TransferTargetRoom[direction];
if (targetRoom == null) return;
// 计算玩家的新位置
Vector2 newPosition = targetRoom.GetTransferPosition(direction);
// 移动玩家
GameObject.FindWithTag("Player").transform.position = newPosition;
SetCurrentRoom(targetRoom,new Vector2Int(dx,dy));
}
这是一个简单的房间之间的传送效果,当玩家靠近门之后,只要按下F键就会传送到对应的方向的房间,并且触发怪物的生成,已经锁住房间不允许再传送.但是,在测试的时候触发了一个神奇的bug,如果玩家能够使用足够快的速度快速的连续按下两次F,就可以连续传送两个房间, 并且中间的房间也会触发怪物的生成.
很显然,TransferRoom被触发了两次, 并且是在第一次完全完成之后才触发的第二次,否则就是传送到同一个地方两次,而不是连续传送. 那么,为什么会被调用两次呢?
从DoorItem的代码中可以看见,只有在inScope为真的时候按下F才会触发传送,而inScope是被OnTriggerEnter2D和OnTriggerExit2D所管理的,那么就可以得出一个结论,在触发了传送的这一帧之后,OnTriggerExit2D并没有在下一帧之前执行,所以下一帧的按键检测任然有效.
所以问题就从为什么会调用两次TransferRoom变成了为什么OnTriggerExit2D没有被触发, 也就是今天的主题了.
Unity脚本对象的生命周期
先上图
可以看到除开初始化和销毁的内容,脚本对象的生命周期总体可以被分为Physice的部分和GameLogic的部分(更具体的生命周期,这里就不讨论了), 其中GameLogic的部分就是以平常unity用的最多的Update()的部分了,可以看到处理Update之外还有大部分的协程的处理以及动画系统的更新.
而上方的物理部分,就是物理检测和计算的重点了, 可以看到像我们熟悉的OnTriggerXXX OnCollsionXXX都在这里. 值得注意的是, 这部分周期的最后有一个循环的箭头跳转回一开始,也就是说这一部分在一个周期中,还有一个小的循环. 再看到最上面,会发现另一个熟悉的身影,FixedUpdate, 用于固定频率更新的函数. 从Unity的官方文档可以看到,在FixedUpdate执行之后,其中的所有设计物理计算的逻辑都会被立即触发,对应的就是FixedUpdate后面的部分. 所以说对于FixedUpdate,就又有了一个新的理解,他不仅是固定频率更新,还是和物理计算强相关的,并且触发的时机还要早于Update.
分析玩这些之后,就可以解释上面提到的bug了. 首先玩家走进了范围之中, 触发了OnTriggerEnter2D. 然后玩家按下了F,由于inScope为真, 触发了传送函数修改了玩家的transform. 但是由于包括FixedUpdate在内的物理计算是一个固定频率的更新, 而unity中默认每0.02s执行一次,也就是50帧, 如果游戏帧数高于这个数值的话,就有可能过了几帧才进行物理计算. 那么在这几帧之间, inScope就都是真了.
关于FixedUpdate更多详细的信息,可以参考这位大佬的文章
https://zhuanlan.zhihu.com/p/30335370
验证
接下来就可以通过实际的脚本来验证一下了.
首先创建一个白色的方块,附加上刚体和碰撞箱,再创建一个黑色的矩形附加上碰撞箱, 并且都调整成trigger模式
然后在负责碰撞的白色方块上附加一个脚本
public class pysicalTest : MonoBehaviour
{
public float fixedDeltaTime = 0.02f;
public int FixedUpdateIncrement = 1;
public int UpdateIncrement = 1;
public int speed = 10;
private bool triggered = false;
// Start is called before the first frame update
void Start()
{
Time.fixedDeltaTime = fixedDeltaTime;
}
void FixedUpdate()
{
Debug.Log("FixedUpdate:" + FixedUpdateIncrement);
// if (triggered)
// {
// Debug.Log("被触发了,这一帧触传送" + FixedUpdateIncrement);
// transform.position = Vector3.zero;
// }
FixedUpdateIncrement++;
}
// Update is called once per frame
void Update()
{
HandleMovement();
Debug.Log("Update:" + UpdateIncrement);
if (triggered)
{
Debug.Log("被触发了,这一帧触传送" + UpdateIncrement);
transform.position = Vector3.zero;
}
UpdateIncrement++;
}
private void OnTriggerEnter2D(Collider2D other)
{
Debug.LogWarning("OnTriggerEnter2D");
triggered = true;
}
private void OnTriggerExit2D(Collider2D other)
{
Debug.LogError("OnTriggerExit2D");
triggered = false;
}
void HandleMovement()
{
if (Input.GetAxis("Horizontal")>0)
{
transform.position += Vector3.right * Time.deltaTime * speed;
}
}
}
我在OnTriggerExit2D进行logerror,这样就可以通过error pause来暂停游戏了
首先在Update中触发传送
可以看到,在第85次的FixedUpdate之后就立刻触发了OnTriggerEnter2D, 而之后两次触发了Update, 全部都触发了传送的代码, 直到第86次的FixedUpdate,才触发了OnTriggerExit2D
如果在FixedUpdate中触发传送的话
可以看到, FixedUpdate后面就立刻处理了OnTriggerExit2D的逻辑.
至此,就搞清楚了生命周期中物理帧和逻辑帧的区别
修改
搞懂了原理之后,就要着手修改bug了. 通过上面的结论, 可以得出一个很简单的修改方法, 那就是将所有的传送逻辑,都放在FixedUpdate中,这样就可以保证物理计算的立即执行.
但是, 这么做还是有很多不妥的, 比如说我们在FixedUpdate中直接修改了transform,而在FixedUpdate中,最好只处理物理相关的逻辑. 而且, 从unity的生命周期可以看出, 输入检测发生在物理计算之后,也就是说,我们在FixedUpdate中获取的玩家输入其实是上一帧的输入. 所以说,我们需要使用别的方法来解决这个问题.
我们再换一个角度看这个问题的话就可以发现, 实际上导致这个问题的还有一个原因, 就是我使用了宏观的全局单例类RoomMangaer来处理这个逻辑,而这个全局函数并不在乎触发它的具体对象,而是忠实的根据自己记录的全局属性来判断应该往哪传送. 除此之外在每一帧的输入都执行了交互的函数,却没有考虑到交互需要执行的时间(事实上,除了物理逻辑, 像协程之类的操作也很难在一帧之内完成), 创造输入缓冲区之类的设计也是很有必要的.
于是,最后的解决方案就变成了
public void TransferRoom(DoorItem doorItem, RoomItem.Direction direction)
{
// 检测是否是当前房间的门操作的
if(!doorItem.isCurrentRoom(CurrentRoom))return;
int dx = CurrentRoomPosition.x + directionX[(int)direction];
int dy = CurrentRoomPosition.y + directionY[(int)direction];
// 检查越界
if (dx < 0 || dx >= mapSize.x || dy < 0 || dy >= mapSize.y) return;
// 检查是否有房间
if (!allroom.ContainsKey(new Vector2Int(dx, dy))) return;
// 检查是否有门
if (!CurrentRoom.TransferTargetRoom.ContainsKey(direction)) return;
// 获取目标房间
RoomItem targetRoom = CurrentRoom.TransferTargetRoom[direction];
if (targetRoom == null) return;
// 计算玩家的新位置
Vector2 newPosition = targetRoom.GetTransferPosition(direction);
// 移动玩家
GameObject.FindWithTag("Player").transform.position = newPosition;
SetCurrentRoom(targetRoom,new Vector2Int(dx,dy));
}
public bool isCurrentRoom(RoomItem roomItem)
{
return transform.IsChildOf(roomItem.transform);
}
至于重新做一个更强的输入系统嘛,以后再说吧.