统一加速

Acceleration in Unity

我正在尝试在 Unity 中模拟加速和减速。

我编写了代码以在 Unity 中生成轨道并根据时间将对象放置在轨道上的特定位置。结果看起来有点像这样。

我目前遇到的问题是样条曲线的每个部分长度不同,立方体以不同但均匀的速度穿过每个部分。这导致在部分之间转换时立方体速度的变化突然跳跃。

为了尝试解决这个问题,我尝试在 GetTime(Vector3 p0, Vector3 p1, float alpha) 方法上使用 Robert Penner's easing equations。然而,虽然这确实有所帮助,但还不够。在转换之间仍然有速度跳跃。

有没有人知道如何动态地缓和立方体的位置,使其看起来像是在加速和减速,而不会在轨道各段之间出现大幅速度跳跃?


我已经编写了一个脚本来显示我的代码的简单实现。它可以附加到任何游戏对象。为了便于查看代码运行时发生的情况,请附加到立方体或球体之类的东西。

using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class InterpolationExample : MonoBehaviour {
    [Header("Time")]
    [SerializeField]
    private float currentTime;
    private float lastTime = 0;
    [SerializeField]
    private float timeModifier = 1;
    [SerializeField]
    private bool running = true;
    private bool runningBuffer = true;

    [Header("Track Settings")]
    [SerializeField]
    [Range(0, 1)]
    private float catmullRomAlpha = 0.5f;
    [SerializeField]
    private List<SimpleWayPoint> wayPoints = new List<SimpleWayPoint>
    {
        new SimpleWayPoint() {pos = new Vector3(-4.07f, 0, 6.5f), time = 0},
        new SimpleWayPoint() {pos = new Vector3(-2.13f, 3.18f, 6.39f), time = 1},
        new SimpleWayPoint() {pos = new Vector3(-1.14f, 0, 4.55f), time = 6},
        new SimpleWayPoint() {pos = new Vector3(0.07f, -1.45f, 6.5f), time = 7},
        new SimpleWayPoint() {pos = new Vector3(1.55f, 0, 3.86f), time = 7.2f},
        new SimpleWayPoint() {pos = new Vector3(4.94f, 2.03f, 6.5f), time = 10}
    };

    [Header("Debug")]
    [Header("WayPoints")]
    [SerializeField]
    private bool debugWayPoints = true;
    [SerializeField]
    private WayPointDebugType debugWayPointType = WayPointDebugType.SOLID;
    [SerializeField]
    private float debugWayPointSize = 0.2f;
    [SerializeField]
    private Color debugWayPointColour = Color.green;
    [Header("Track")]
    [SerializeField]
    private bool debugTrack = true;
    [SerializeField]
    [Range(0, 1)]
    private float debugTrackResolution = 0.04f;
    [SerializeField]
    private Color debugTrackColour = Color.red;

    [System.Serializable]
    private class SimpleWayPoint
    {
        public Vector3 pos;
        public float time;
    }

    [System.Serializable]
    private enum WayPointDebugType
    {
        SOLID,
        WIRE
    }

    private void Start()
    {
        wayPoints.Sort((x, y) => x.time.CompareTo(y.time));
        wayPoints.Insert(0, wayPoints[0]);
        wayPoints.Add(wayPoints[wayPoints.Count - 1]);
    }

    private void LateUpdate()
    {
        //This means that if currentTime is paused, then resumed, there is not a big jump in time
        if(runningBuffer != running)
        {
            runningBuffer = running;
            lastTime = Time.time;
        }

        if(running)
        {
            currentTime += (Time.time - lastTime) * timeModifier;
            lastTime = Time.time;
            if(currentTime > wayPoints[wayPoints.Count - 1].time)
            {
                currentTime = 0;
            }
        }
        transform.position = GetPosition(currentTime);
    }

    #region Catmull-Rom Math
    public Vector3 GetPosition(float time)
    {
        //Check if before first waypoint
        if(time <= wayPoints[0].time)
        {
            return wayPoints[0].pos;
        }
        //Check if after last waypoint
        else if(time >= wayPoints[wayPoints.Count - 1].time)
        {
            return wayPoints[wayPoints.Count - 1].pos;
        }

        //Check time boundaries - Find the nearest WayPoint your object has passed
        float minTime = -1;
        float maxTime = -1;
        int minIndex = -1;
        for(int i = 1; i < wayPoints.Count; i++)
        {
            if(time > wayPoints[i - 1].time && time <= wayPoints[i].time)
            {
                maxTime = wayPoints[i].time;
                int index = i - 1;
                minTime = wayPoints[index].time;
                minIndex = index;
            }
        }

        float timeDiff = maxTime - minTime;
        float percentageThroughSegment = 1 - ((maxTime - time) / timeDiff);

        //Define the 4 points required to make a Catmull-Rom spline
        Vector3 p0 = wayPoints[ClampListPos(minIndex - 1)].pos;
        Vector3 p1 = wayPoints[minIndex].pos;
        Vector3 p2 = wayPoints[ClampListPos(minIndex + 1)].pos;
        Vector3 p3 = wayPoints[ClampListPos(minIndex + 2)].pos;

        return GetCatmullRomPosition(percentageThroughSegment, p0, p1, p2, p3, catmullRomAlpha);
    }

    //Prevent Index Out of Array Bounds
    private int ClampListPos(int pos)
    {
        if(pos < 0)
        {
            pos = wayPoints.Count - 1;
        }

        if(pos > wayPoints.Count)
        {
            pos = 1;
        }
        else if(pos > wayPoints.Count - 1)
        {
            pos = 0;
        }

        return pos;
    }

    //Math behind the Catmull-Rom curve. See here for a good explanation of how it works. 
    private Vector3 GetCatmullRomPosition(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float alpha)
    {
        float dt0 = GetTime(p0, p1, alpha);
        float dt1 = GetTime(p1, p2, alpha);
        float dt2 = GetTime(p2, p3, alpha);

        Vector3 t1 = ((p1 - p0) / dt0) - ((p2 - p0) / (dt0 + dt1)) + ((p2 - p1) / dt1);
        Vector3 t2 = ((p2 - p1) / dt1) - ((p3 - p1) / (dt1 + dt2)) + ((p3 - p2) / dt2);

        t1 *= dt1;
        t2 *= dt1;

        Vector3 c0 = p1;
        Vector3 c1 = t1;
        Vector3 c2 = (3 * p2) - (3 * p1) - (2 * t1) - t2;
        Vector3 c3 = (2 * p1) - (2 * p2) + t1 + t2;
        Vector3 pos = CalculatePosition(t, c0, c1, c2, c3);

        return pos;
    }

    private float GetTime(Vector3 p0, Vector3 p1, float alpha)
    {
        if(p0 == p1)
            return 1;
        return Mathf.Pow((p1 - p0).sqrMagnitude, 0.5f * alpha);
    }

    private Vector3 CalculatePosition(float t, Vector3 c0, Vector3 c1, Vector3 c2, Vector3 c3)
    {
        float t2 = t * t;
        float t3 = t2 * t;
        return c0 + c1 * t + c2 * t2 + c3 * t3;
    }

    //Utility method for drawing the track
    private void DisplayCatmullRomSpline(int pos, float resolution)
    {
        Vector3 p0 = wayPoints[ClampListPos(pos - 1)].pos;
        Vector3 p1 = wayPoints[pos].pos;
        Vector3 p2 = wayPoints[ClampListPos(pos + 1)].pos;
        Vector3 p3 = wayPoints[ClampListPos(pos + 2)].pos;

        Vector3 lastPos = p1;
        int maxLoopCount = Mathf.FloorToInt(1f / resolution);

        for(int i = 1; i <= maxLoopCount; i++)
        {
            float t = i * resolution;
            Vector3 newPos = GetCatmullRomPosition(t, p0, p1, p2, p3, catmullRomAlpha);
            Gizmos.DrawLine(lastPos, newPos);
            lastPos = newPos;
        }
    }
    #endregion

    private void OnDrawGizmos()
    {
        #if UNITY_EDITOR
        if(EditorApplication.isPlaying)
        {
            if(debugWayPoints)
            {
                Gizmos.color = debugWayPointColour;
                foreach(SimpleWayPoint s in wayPoints)
                {
                    if(debugWayPointType == WayPointDebugType.SOLID)
                    {
                        Gizmos.DrawSphere(s.pos, debugWayPointSize);
                    }
                    else if(debugWayPointType == WayPointDebugType.WIRE)
                    {
                        Gizmos.DrawWireSphere(s.pos, debugWayPointSize);
                    }
                }
            }

            if(debugTrack)
            {
                Gizmos.color = debugTrackColour;
                if(wayPoints.Count >= 2)
                {
                    for(int i = 0; i < wayPoints.Count; i++)
                    {
                        if(i == 0 || i == wayPoints.Count - 2 || i == wayPoints.Count - 1)
                        {
                            continue;
                        }

                        DisplayCatmullRomSpline(i, debugTrackResolution);
                    }
                }
            }
        }
        #endif
    }
}

据我所知,大部分解决方案都已包含在内,只是初始化不当。

局部速度取决于样条的长度,因此您应该通过 段长度的倒数 来调制速度(您可以很容易地用几个步骤)。

当然,在你的情况下你无法控制速度,只能控制输入时间,所以你需要的是根据顺序正确分配 SimpleWayPoint.time 的值和先前样条段的长度,而不是在字段声明中手动初始化它。这样percentageThroughSegment应该分布均匀

如评论中所述,Lerp() 中的一些数学运算看起来更简单 :)

让我们先定义一些术语:

  1. t:每个样条的插值变量,范围从01
  2. s:每条样条的长度。根据您使用的样条曲线类型(catmull-rom、bezier 等),有计算估计总长度的公式。
  3. dt:每帧t的变化。在你的情况下,如果这在所有不同的样条曲线上都是恒定的,你会看到样条曲线端点的速度突然变化,因为每个样条曲线的长度不同 s.

减轻每个关节速度变化的最简单方法是:

void Update() {
    float dt = 0.05f; //this is currently your "global" interpolation speed, for all splines
    float v0 = s0/dt; //estimated linear speed in the first spline.
    float v1 = s1/dt; //estimated linear speed in the second spline.
    float dt0 = interpSpeed(t0, v0, v1) / s0; //t0 is the current interpolation variable where the object is at, in the first spline
    transform.position = GetCatmullRomPosition(t0 + dt0*Time.deltaTime, ...); //update your new position in first spline
}

其中:

float interpSpeed(float t, float v0, float v1, float tEaseStart=0.5f) {
    float u = (t - tEaseStart)/(1f - tEaseStart);
    return Mathf.Lerp(v0, v1, u);
}

上面的直觉是,当我到达第一个样条曲线的末端时,我预测下一个样条曲线的预期速度,并放慢我当前的速度到达那里。

最后,为了使缓动看起来更好:

  • 考虑在 interpSpeed() 中使用非线性插值函数。
  • 考虑在第二个样条曲线的开头实现 "ease-into"

您可以尝试使用他们为轮子系统提供的 wheelcollider 教程。

它有一些变量可以与刚体变量一起调整以实现模拟驾驶。

如他们所写

You can have up to 20 wheels on a single vehicle instance, with each of them applying steering, motor or braking torque.

免责声明:我对 WheelColliders 的使用经验很少。但我觉得它们正是你要找的东西。

https://docs.unity3d.com/Manual/WheelColliderTutorial.html

好的,让我们在上面放一些数学

我一直提倡数学在 gamedev 中的重要性和实用性,也许我在这个答案上过于深入了,但我真的认为你的问题根本不是关于编码,而是关于建模并解决代​​数问题。不管怎样,我们走吧。

参数化

如果您拥有大学学位,您可能还记得一些关于 函数 - 接受参数并产生结果的操作 - 和 graphs - 函数及其参数演变的图形表示(或绘图)。 f(x) 可能会提醒你一些事情:它说一个名为 f 的函数依赖于参数 x。所以,"to parameterize" 大致意思是用一个或多个参数来表达一个系统。

您可能不熟悉这些术语,但您一直都在这样做。例如,您的 Track 是一个具有 3 个参数的系统:f(x,y,z).

关于参数化的一件有趣的事情是您可以抓取一个系统并根据其他参数来描述它。同样,您已经在这样做了。当你描述你的轨迹随时间的演变时,你是说每个坐标都是时间的函数,f(x,y,z) = f(x(t),y(t),z(t)) = f(t)。换句话说,您可以使用时间来计算每个坐标,并使用坐标将您的对象定位在给定时间的 space 中。

轨道系统建模

最后,我要开始回答你的问题了。为了完整地描述你想要的轨道系统,你需要两件事:

  1. 路径;

您实际上已经解决了这部分。您在场景 space 中设置了一些点,并使用 Catmull–Rom 样条曲线 对这些点进行插值并生成一条路径。这很聪明,而且没有什么可做的了。

此外,您在每个点上添加了一个字段 time,因此您希望确保移动物体将在这个准确的时间通过此检查。我稍后会回来。

  1. 一个移动的物体。

关于您的 Path 解决方案的一件有趣的事情是,您使用 percentageThroughSegment 参数对路径计算进行了参数化 - 一个从 0 到 1 的值,表示线段内的相对位置。在您的代码中,您以固定的时间步长进行迭代,并且您的 percentageThroughSegment 将是花费的时间与该段的总时间跨度之间的比例。由于每个段都有特定的时间跨度,因此您可以模拟许多恒定速度。

这很标准,但有一个微妙之处。您忽略了描述运动的一个非常重要的部分:行进的距离

我建议您采用不同的方法。使用行进的距离来参数化您的路径。然后,对象的运动将是相对于时间参数化的行进距离。这样您将拥有两个独立且一致的系统。动手工作!

示例:

从现在开始,为了简单起见,我会将所有内容都设为 2D,但稍后将其更改为 3D 将变得微不足道。

考虑以下路径:

其中i是线段的索引,d是行进的距离,x, y是平面坐标。这可能是由像您这样的样条曲线创建的路径,也可能是贝塞尔曲线或其他任何东西。

一个物体在您当前的解决方案下发展的运动可以描述为 distance traveled on the pathtime 的图表,如下所示:

其中table中的t是物体必须到达检查点的时间,d又是到达该位置的距离,v是速度和 a 是加速度。

上图显示了物体如何随时间前进。横轴是时间,纵轴是行进的距离。我们可以想象,纵轴是一条直线上的路径"unrolled"。下图是速度随时间的演变。

此时我们必须回想一些物理学知识,并注意在每一段,距离图都是一条直线,对应于匀速运动,没有加速度。这样的系统用这个等式描述:d = do + v*t

只要物体到达检查点,它的速度值就会突然改变(因为它的图形没有连续性),这在场景中产生了一种奇怪的效果。是的,您已经知道了,这正是您发布问题的原因。

好的,我们怎样才能让它变得更好?嗯...如果速度图是连续的,就不会那么烦人的速度跳跃了。对此类运动的最简单描述可能是均匀加速。这样的系统由这个等式描述:d = do + vo*t + a*t^2/2。我们还必须假设一个初始速度,我在这里选择零(与静止分开)。

正如我们预期的那样,速度图是连续的,移动ent 通过路径加速。这可以编码到 Unity 中,像这样更改方法 StartGetPosition

private List<float> lengths = new List<float>();
private List<float> speeds = new List<float>();
private List<float> accels = new List<float>();
public float spdInit = 0;

private void Start()
{
  wayPoints.Sort((x, y) => x.time.CompareTo(y.time));
  wayPoints.Insert(0, wayPoints[0]);
  wayPoints.Add(wayPoints[wayPoints.Count - 1]);
       for (int seg = 1; seg < wayPoints.Count - 2; seg++)
  {
    Vector3 p0 = wayPoints[seg - 1].pos;
    Vector3 p1 = wayPoints[seg].pos;
    Vector3 p2 = wayPoints[seg + 1].pos;
    Vector3 p3 = wayPoints[seg + 2].pos;
    float len = 0.0f;
    Vector3 prevPos = GetCatmullRomPosition(0.0f, p0, p1, p2, p3, catmullRomAlpha);
    for (int i = 1; i <= Mathf.FloorToInt(1f / debugTrackResolution); i++)
    {
      Vector3 pos = GetCatmullRomPosition(i * debugTrackResolution, p0, p1, p2, p3, catmullRomAlpha);
      len += Vector3.Distance(pos, prevPos);
      prevPos = pos;
    }
    float spd0 = seg == 1 ? spdInit : speeds[seg - 2];
    float lapse = wayPoints[seg + 1].time - wayPoints[seg].time;
    float acc = (len - spd0 * lapse) * 2 / lapse / lapse;
    float speed = spd0 + acc * lapse;
    lengths.Add(len);
    speeds.Add(speed);
    accels.Add(acc);
  }
}

public Vector3 GetPosition(float time)
{
  //Check if before first waypoint
  if (time <= wayPoints[0].time)
  {
    return wayPoints[0].pos;
  }
  //Check if after last waypoint
  else if (time >= wayPoints[wayPoints.Count - 1].time)
  {
    return wayPoints[wayPoints.Count - 1].pos;
  }

  //Check time boundaries - Find the nearest WayPoint your object has passed
  float minTime = -1;
  // float maxTime = -1;
  int minIndex = -1;
  for (int i = 1; i < wayPoints.Count; i++)
  {
    if (time > wayPoints[i - 1].time && time <= wayPoints[i].time)
    {
      // maxTime = wayPoints[i].time;
      int index = i - 1;
      minTime = wayPoints[index].time;
      minIndex = index;
    }
  }

  float spd0 = minIndex == 1 ? spdInit : speeds[minIndex - 2];
  float len = lengths[minIndex - 1];
  float acc = accels[minIndex - 1];
  float t = time - minTime;
  float posThroughSegment = spd0 * t + acc * t * t / 2;
  float percentageThroughSegment = posThroughSegment / len;

  //Define the 4 points required to make a Catmull-Rom spline
  Vector3 p0 = wayPoints[ClampListPos(minIndex - 1)].pos;
  Vector3 p1 = wayPoints[minIndex].pos;
  Vector3 p2 = wayPoints[ClampListPos(minIndex + 1)].pos;
  Vector3 p3 = wayPoints[ClampListPos(minIndex + 2)].pos;

  return GetCatmullRomPosition(percentageThroughSegment, p0, p1, p2, p3, catmullRomAlpha);
}

好的,让我们看看进展如何...

错误...uh-oh。 它看起来几乎不错,除了在某些时候它会向后移动然后再次前进。实际上,如果我们检查我们的图表,就会在那里进行描述。在 12 到 16 秒之间,速度为负值。为什么会这样?因为这个运动功能(恒定加速度)虽然简单,但也有一定的局限性。对于一些突然的速度变化,可能没有加速度的恒定值可以保证我们的前提(在正确的时间通过检查点)而没有像那些 side-effects。

我们现在做什么?

你有很多选择:

  • 描述一个具有线性加速度变化的系统并应用边界条件(警告:很多 个方程需要求解);
  • 描述一个在一段时间内具有恒定加速度的系统,例如仅加速或减速 before/after 曲线,然后在该段的其余部分保持恒定速度(警告:甚至更多方程式求解,难以保证按时通过关卡的前提);
  • 使用插值法生成位置随时间变化的图表。我试过 Catmull-Rom 本身,但我不喜欢这个结果,因为速度看起来不是很流畅。贝塞尔曲线似乎是一种更可取的方法,因为您可以直接操纵控制点上的斜率(也称为速度)并避免向后移动;
  • 我最喜欢的:在 class 上添加一个 public AnimationCurve 字段,并在带有 ts awesome built-in 抽屉的编辑器中自定义您的运动图!您可以使用其 AddKey 方法轻松添加控制点,并使用其 Evaluate 方法获取一段时间的位置。 您甚至可以在组件 class 上使用 OnValidate 方法在曲线和 vice-versa.
  • 中编辑场景时自动更新场景中的点

不要停在那里!在路径的线条 Gizmo 上添加渐变,以便轻松查看它在何处变快或变慢,在编辑器模式下添加用于操纵路径的手柄...发挥创意!