经历
最近在一个Unity项目中遇到了一个诡异的问题。在这个游戏中,玩家可以变成障碍物,并且进行移动,在一段时间内录制障碍物移动的信息并且反复播放。而在游戏的第三关中,引入了地图旋转的机制,为了能够使得障碍物跟着地图一起旋转,我选择将其绑定为地图的子物体。然而就是在这个过程中出现了一个诡异的问题。
可以看到在旋转之前障碍物是可以正常下落的,但是旋转之后就好像被拉住了一样难以动弹。接下来就是要找原因了。
首先看到障碍物预制体的具体结构。

可以看到最外层有着名为TimeRecord的游戏对象,然后下面才是障碍的本体。之所以会这样是因为早期设计的时候设计成了只有在固定区域中才可以录制,也就是在名为Area的游戏对象的范围内。于是为了让玩家可以移动整个TimeRecord来选择开始录制时的位置,选择了让TimeRecord先整体进行自由移动的输入接受,然后让再开始录制之后让Barrier进行物理模拟的移动。而由于TimeRecord的移动不需要物理模拟,就写成了逻辑移动,也就是直接操作transform。本来开始录制了之后,它就不能再接受玩家的输入了,但是我的设置晚了一步。
private void Update()
{
if (CheckIsInBound() && IsRecording)
{
if (m_targetBarrier is ICanBeRecord targetRecordObj)
{
m_path.Add(targetRecordObj.Capture());
}
}
if (!IsCompleted)
{
Vector2 direction = m_recordMove.ReadValue<Vector2>();
transform.position += new Vector3(direction.x, direction.y) * speed * Time.deltaTime;
}
}RecordSystem.cs
protected virtual void FixedUpdate()
{
// 水平移动
if (!banHorizontal)
{
HandleHorizontal();
}
// 垂直移动
if (!banVertical)
{
HandleVertical();
}
currentSpeed += (Vector3)attachSpeed;
// 移动
if (currentSpeed.x != 0 || currentSpeed.y != 0)
{
rb.MovePosition(transform.position + currentSpeed * Time.fixedDeltaTime);
}
attachSpeed = Vector2.zero;
}PlayerObjects.cs
可以看到父物体和子物体的移动分别使用了transform和rigidbody,然而关键的变量IsCompleted的修改时间却晚了。
private void OnRecordEndEvent()
{
IsCompleted = true;
Dispose();
RecordManager.Instance.OnRecordEnd -= OnRecordEndEvent;
}可以看到在OnRecordEndEvent的时候我们才修改了IsCompleted的值也就导致了在录制的时候,父物体子物体在分别以不同的方式移动。但是这与旋转有什么关系呢。实际上,虽然我忘记设置IsCompleted的值,但是在那之前,我就禁用了RecordSystem所有的输入。
private void OnStartRecord(InputAction.CallbackContext context)
{
PlayerInputController.Instance.playerInput.RecordSystem.Disable();
Debug.Log("开始录制被按下");
RecordManager.Instance.OnRecordEnd += OnRecordEndEvent;
RecordManager.Instance.StartRecord();
}也就是说, transform.position += new Vector3(direction.x, direction.y) * speed * Time.deltaTime;其实相当于 transform.position += new Vector3(0,0,0); 但是,这样就无法解释旋转之后的事情了,既然旋转之前RecordSystem没有跟着一起移动,那么旋转之后也肯定没有影响啊。然而,有的时候不能太想当然了。只要我们打印一下它的position的值Debug.LogWarning($"当前RecordSystem的position({transform.position.x},{transform.position.y})");

可以看到,它其实是在以极小的数值在改变,虽然这完全看不出来,但是物理位移和逻辑位移是同时存在的。
问题抽解
由于上述的场景过于复杂,所以这里我将他简化一下。实际上,这个问题的参与对象只有两方,父物体和子物体,父物体通过transform移动而子物体通过rigidbody移动。所以这里我分别创建两个游戏对象表示Father和Son

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Father : MonoBehaviour
{
public float speed;
// Update is called once per frame
void Update()
{
transform.position += Vector3.right * speed * Time.deltaTime;
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class Son : MonoBehaviour
{
public float speed;
public Rigidbody2D rb;
void FixedUpdate()
{
rb.MovePosition(rb.position + Vector2.down * speed * Time.fixedDeltaTime);
}
void Reset()
{
rb = GetComponent<Rigidbody2D>();
}
}当我们没有激活Father的时候。
当我们激活Father的时候。
可以看到激活了Father的时候,Son就像被拉住一样动不了。通过Log我们可以将原因窥探一二。

可以看到,在禁用Father的情况下Son的FixedUpdate以每次0.02的速度正常增长。另外,如果仔细观察的话,第一次FixedUpdate打印的时(0,0),也就是说,rigidbody的修改会在下一次物理帧中生效。

而激活了Father的时候,可以看到,在FixedUpdate中的修改有3帧都被控制在了0.02才终于到达了0.04,这也就是看起来像被拉住了的原因。
原理
遗憾的是,因为Unity的引擎完全闭源,我们无法知道到底是为什么导致的这种情况,只能初步推测父物体的移动传导到了子物体,而子物体同时也使用了刚体进行移动,而双方的移动信息又不是实时同步的,所有才出现了这种情况。
一种有效的解决办法是在设置中启用强制同步。

不过,我认为在编写代码以及设计系统的时候就应该避免这种情况,要么都进行逻辑移动,要么都进行物理移动。