Administrator
发布于 2025-04-24 / 9 阅读
0
0

记一次unity的物理计算导致的bug

记一次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脚本对象的生命周期

先上图

Alt text

可以看到除开初始化和销毁的内容,脚本对象的生命周期总体可以被分为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模式

fbdf8af6e7f708e056b6ca7a05c091d.png

然后在负责碰撞的白色方块上附加一个脚本

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中触发传送

0f214085239cc5211bae83995722cae.png

可以看到,在第85次的FixedUpdate之后就立刻触发了OnTriggerEnter2D, 而之后两次触发了Update, 全部都触发了传送的代码, 直到第86次的FixedUpdate,才触发了OnTriggerExit2D

如果在FixedUpdate中触发传送的话

76c1aaa304bf8db2eb34ab164af3b90.png

可以看到, 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);
    }

至于重新做一个更强的输入系统嘛,以后再说吧.


评论