1. 项目概述当贝塞尔曲线遇上加减速控制在Unity里做移动动画尤其是像小车、角色这类需要平滑路径和速度变化的场景直接线性插值或者简单的Lerp经常会显得很“假”。要么是启动太突兀要么是刹车像撞墙中间过程也缺乏那种真实的物理惯性感。我最近在做一个模拟驾驶的小项目核心需求就是让小车沿着一条预定的路线实现从静止到加速、匀速巡航、再到减速停止的整个过程并且整个过程要平滑、自然符合人的直觉。这时候贝塞尔曲线Bézier Curve就成了一个绝佳的选择。它不只是用来画平滑的路径更关键的是我们可以通过参数t的巧妙控制来实现对运动速度的精细化管理。网上很多教程只讲了怎么用贝塞尔曲线画路径小车匀速跑完就结束了但这只是第一步。真正的难点和精髓在于如何让小车在这条漂亮的曲线上按照我们想要的加速度和减速度来跑比如起步时慢慢加速快到了缓缓刹车中间还能保持一段稳定的速度。这个项目就是要把“路径规划”和“速度控制”这两件事结合起来。我们用C#在Unity里实现一个系统先定义一条或多条贝塞尔曲线作为小车的轨道然后设计一套速度曲线算法让小车沿着这条轨道运动时其瞬时速度完全受我们设定的加速度、减速度参数控制。最终效果是你给定一个目标路径和一套速度参数比如最大速度、加速度、减速度小车就能自动平滑、拟真地跑完全程。2. 核心思路拆解分离路径与时间实现这个效果最核心的设计思想是将“空间位置”和“时间进度”解耦。这是很多新手容易混淆的地方。2.1 贝塞尔曲线负责“空间位置”贝塞尔曲线无论是二次、三次还是更高阶它的本质是一个参数方程。以最常用的三次贝塞尔曲线为例它由四个点P0, P1, P2, P3定义。给定一个参数t通常在0到1之间我们可以通过公式计算出一个对应的坐标点B(t)。// 三次贝塞尔曲线公式 public static Vector3 CalculateBezierPoint(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3) { float u 1 - t; float tt t * t; float uu u * u; float uuu uu * u; float ttt tt * t; Vector3 p uuu * p0; // (1-t)^3 * P0 p 3 * uu * t * p1; // 3*(1-t)^2 * t * P1 p 3 * u * tt * p2; // 3*(1-t) * t^2 * P2 p ttt * p3; // t^3 * P3 return p; }这个t是曲线参数它均匀地从0增长到1对应的B(t)就会从起点P0平滑地移动到终点P3。但请注意t的均匀增加并不代表小车在曲线上移动的距离是均匀的在曲率大的地方即使t变化很小实际位置可能移动了很长一段路在曲率平缓的地方则相反。所以我们不能直接用Time.deltaTime去累加这个t来控制运动那样速度会时快时慢非常奇怪。2.2 速度曲线负责“时间进度”我们需要另一个层级的控制时间参数T。这个T代表的是整个动画过程的标准化时间也是从0到1。T0代表动画开始T1代表动画结束。而速度控制就是去定义T随时间真实时间的变化关系。我们想要加速就意味着在动画初期真实时间流过一秒T的增加值应该从小变大想要减速则是在动画末期真实时间流过一秒T的增加值从大变小。2.3 两者的映射t f(T)关键的一步来了。我们需要建立一个映射函数t f(T)。这个函数的意思是当动画进度进行到T时小车应该位于贝塞尔曲线参数t的位置上。如何得到这个函数我们需要一个“预计算”的过程采样将曲线参数t从0到1等分成很多份比如1000份计算每一份对应的曲线长度。这可以通过累加相邻采样点之间的距离来近似得到总弧长L和每个t对应的累积弧长s(t)。建立映射表现在我们知道了t和弧长s的关系。而我们理想的速度曲线定义的是弧长s随时间T的变化关系s(T)。例如匀加速运动时s(T)是一个二次函数。关联对于给定的动画时间T我们先通过速度曲线算出应走的弧长s。然后通过查询或插值采样表找到这个弧长s对应的曲线参数t。这个t才是我们最终要代入CalculateBezierPoint函数的参数。简单来说流程就是真实时间 - 动画进度T - 目标弧长s - 对应的曲线参数t - 最终世界坐标。注意这里有一个常见的性能取舍。预计算采样Pre-computation会消耗一些初始化时间并占用一点内存来存储映射表但它在运行时效率极高只需要进行简单的查表插值。另一种方法是实时计算但需要求解复杂的积分方程不适用于实时游戏。对于大多数游戏场景预计算是更实用、更稳定的选择。3. 核心模块实现详解理解了核心思路我们开始动手实现。我会把系统拆分成几个可复用的C#类。3.1 贝塞尔曲线路径模块首先我们需要一个健壮的、可以编辑和采样贝塞尔曲线的类。这个类不关心速度只关心给定t返回位置和切线方向用于小车朝向。using UnityEngine; using System.Collections.Generic; [System.Serializable] public class BezierPath { public ListVector3 controlPoints new ListVector3(); public bool isClosed false; private Listfloat cumulativeLengths; // 累积弧长表 private Listfloat sampleTs; // 对应的t值表 public float totalLength { get; private set; } private const int SAMPLE_COUNT 1000; // 采样精度 // 初始化/重新计算弧长表 public void BakePath() { if (controlPoints.Count 4 || (controlPoints.Count - 1) % 3 ! 0) { Debug.LogError(控制点数量无效。三次贝塞尔曲线需要至少4个点且满足13N格式。); return; } sampleTs new Listfloat(); cumulativeLengths new Listfloat(); cumulativeLengths.Add(0f); sampleTs.Add(0f); Vector3 lastPoint GetPoint(0f); float lengthSoFar 0f; // 从0到1采样 for (int i 1; i SAMPLE_COUNT; i) { float t i / (float)SAMPLE_COUNT; Vector3 currentPoint GetPoint(t); lengthSoFar Vector3.Distance(lastPoint, currentPoint); cumulativeLengths.Add(lengthSoFar); sampleTs.Add(t); lastPoint currentPoint; } totalLength lengthSoFar; } // 根据t获取曲线上的点 public Vector3 GetPoint(float t) { t Mathf.Clamp01(t); int segmentCount (controlPoints.Count - 1) / 3; if (segmentCount 0) return Vector3.zero; int segmentIndex Mathf.FloorToInt(t * segmentCount); segmentIndex Mathf.Min(segmentIndex, segmentCount - 1); float localT (t * segmentCount) - segmentIndex; int i segmentIndex * 3; return CalculateCubicBezierPoint(localT, controlPoints[i], controlPoints[i 1], controlPoints[i 2], controlPoints[i 3]); } // 根据弧长s0到totalLength获取对应的t public float GetTFromDistance(float targetDistance) { if (cumulativeLengths null || cumulativeLengths.Count 0) { BakePath(); // 懒加载计算 } targetDistance Mathf.Clamp(targetDistance, 0, totalLength); // 二分查找或线性查找目标弧长在累积弧长表中的位置 int index cumulativeLengths.BinarySearch(targetDistance); if (index 0) index ~index; // 如果没找到取补码得到插入点 if (index 0) return sampleTs[0]; if (index cumulativeLengths.Count) return sampleTs[sampleTs.Count - 1]; // 线性插值得到精确的t float lengthBefore cumulativeLengths[index - 1]; float lengthAfter cumulativeLengths[index]; float tBefore sampleTs[index - 1]; float tAfter sampleTs[index]; float interpolant (targetDistance - lengthBefore) / (lengthAfter - lengthBefore); return Mathf.Lerp(tBefore, tAfter, interpolant); } private Vector3 CalculateCubicBezierPoint(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3) { // 同上文公式此处省略 } }这个BezierPath类做了几件关键事存储控制点并支持多段曲线连接。提供了BakePath方法预计算弧长映射表这是实现非匀速运动的基础。提供了GetTFromDistance方法这是连接“目标弧长”和“曲线参数t”的桥梁。3.2 速度曲线与时间控制模块接下来我们需要定义速度如何随时间变化。这里我设计一个SpeedProfile类它封装了加速、匀速、减速的过程。public class SpeedProfile { public float maxSpeed; // 最大速度单位/秒 public float acceleration; // 加速度单位/秒^2 public float deceleration; // 减速度单位/秒^2 public float totalDistance; // 需要移动的总距离由路径提供 private float _accelTime; private float _decelTime; private float _cruiseTime; private float _totalTime; // 计算运动学参数 public void CalculatePhases() { // 计算加速到最大速度所需的时间和距离 _accelTime maxSpeed / acceleration; float accelDistance 0.5f * acceleration * _accelTime * _accelTime; // 计算从最大速度减速到0所需的时间和距离 _decelTime maxSpeed / deceleration; float decelDistance 0.5f * deceleration * _decelTime * _decelTime; // 判断是否能达到最大速度 if (accelDistance decelDistance totalDistance) { // 三角形速度曲线加速后立即减速达不到最大速度 // 解方程总距离 0.5 * a * t_acc^2 0.5 * d * t_dec^2且 a*t_acc d*t_dec // 可得t_acc sqrt( (2 * totalDistance) / (a * (1 a/d)) ) _accelTime Mathf.Sqrt((2 * totalDistance) / (acceleration * (1 acceleration / deceleration))); _decelTime _accelTime * (acceleration / deceleration); _cruiseTime 0f; maxSpeed acceleration * _accelTime; // 实际达到的峰值速度 } else { // 梯形速度曲线有匀速阶段 float cruiseDistance totalDistance - accelDistance - decelDistance; _cruiseTime cruiseDistance / maxSpeed; } _totalTime _accelTime _cruiseTime _decelTime; } // 根据真实时间time从0开始返回已走过的距离 public float GetDistanceAtTime(float currentTime) { currentTime Mathf.Clamp(currentTime, 0, _totalTime); float distance 0f; if (currentTime _accelTime) { // 加速阶段s 0.5 * a * t^2 distance 0.5f * acceleration * currentTime * currentTime; } else if (currentTime _accelTime _cruiseTime) { // 匀速阶段 float accelDist 0.5f * acceleration * _accelTime * _accelTime; float cruiseElapsedTime currentTime - _accelTime; distance accelDist maxSpeed * cruiseElapsedTime; } else { // 减速阶段 float accelDist 0.5f * acceleration * _accelTime * _accelTime; float cruiseDist maxSpeed * _cruiseTime; float decelElapsedTime currentTime - (_accelTime _cruiseTime); // 减速阶段距离s v0*t - 0.5*d*t^2 其中v0是maxSpeed distance accelDist cruiseDist (maxSpeed * decelElapsedTime - 0.5f * deceleration * decelElapsedTime * decelElapsedTime); } return distance; } public float totalTime { get { return _totalTime; } } }这个类的核心是CalculatePhases和GetDistanceAtTime方法。它根据你设定的最大速度、加速度、减速度和总距离自动判断小车会经历一个“加速-匀速-减速”的梯形速度曲线还是一个“加速-减速”的三角形速度曲线当距离不够时。GetDistanceAtTime则是整个速度控制的核心输出输入当前时间输出小车应该已经走过的总弧长。3.3 小车控制器整合最后我们创建一个BezierCarController的MonoBehaviour把路径和速度控制整合起来。using UnityEngine; public class BezierCarController : MonoBehaviour { public BezierPath path; public float maxSpeed 10f; public float acceleration 3f; public float deceleration 5f; private SpeedProfile _speedProfile; private float _currentTime 0f; private bool _isMoving false; void Start() { if (path null) { Debug.LogError(未指定贝塞尔路径); return; } // 烘焙路径计算总长 path.BakePath(); // 初始化速度曲线 _speedProfile new SpeedProfile { maxSpeed this.maxSpeed, acceleration this.acceleration, deceleration this.deceleration, totalDistance path.totalLength }; _speedProfile.CalculatePhases(); Debug.Log($路径总长{path.totalLength:F2} 预计运动总时间{_speedProfile.totalTime:F2}秒); _isMoving true; } void Update() { if (!_isMoving || _speedProfile null) return; _currentTime Time.deltaTime; if (_currentTime _speedProfile.totalTime) { _currentTime _speedProfile.totalTime; _isMoving false; // 运动结束 // 可以触发结束事件 } // 关键步骤 // 1. 根据当前时间获取应走的弧长 float targetDistance _speedProfile.GetDistanceAtTime(_currentTime); // 2. 根据弧长查询路径得到对应的曲线参数t float targetT path.GetTFromDistance(targetDistance); // 3. 根据t获取世界坐标并更新小车位置 transform.position path.GetPoint(targetT); // 4. 可选计算朝向使用当前位置的切线方向 // 可以通过计算t附近两个点的向量来近似切线或者使用贝塞尔曲线的导数公式精确计算 UpdateRotation(targetT); } void UpdateRotation(float t) { // 简单方法取t附近一个极小增量的点计算方向 float delta 0.001f; Vector3 currentPos path.GetPoint(t); Vector3 nextPos path.GetPoint(Mathf.Min(t delta, 1f)); Vector3 direction (nextPos - currentPos).normalized; if (direction.sqrMagnitude 0.001f) { transform.forward direction; } } // 在Scene视图绘制路径和速度信息用于调试 void OnDrawGizmosSelected() { if (path ! null path.controlPoints.Count 4) { path.BakePath(); // 编辑模式下也可以预览 Gizmos.color Color.green; int segments 50; Vector3 prevPoint path.GetPoint(0); for (int i 1; i segments; i) { float t i / (float)segments; Vector3 currPoint path.GetPoint(t); Gizmos.DrawLine(prevPoint, currPoint); prevPoint currPoint; } // 绘制控制点 Gizmos.color Color.red; foreach (var point in path.controlPoints) { Gizmos.DrawSphere(point, 0.1f); } } } }这个控制器在Start中初始化路径和速度曲线。在每一帧的Update中它执行了最关键的三个步骤时间 - 弧长 - 曲线参数t - 位置。同时它还提供了一个简单的朝向更新方法让小车能沿着路径切线方向移动。4. 高级技巧与优化实战基础功能跑通后我们来看看如何让它更强大、更实用。4.1 使用动画曲线定制速度曲线上面的SpeedProfile只实现了梯形/三角形速度曲线。但现实中加速度可能不是恒定的或者你想要更复杂的速度变化比如先快后慢再快。这时Unity的AnimationCurve就派上用场了。我们可以改造速度控制模块用一个AnimationCurve来定义“标准化距离s随标准化时间T的变化关系”。曲线图的X轴是时间T0到1Y轴是走过的路程比例0到1。这条曲线的斜率就代表了瞬时速度。public class AdvancedSpeedController { public AnimationCurve distanceCurve; // 必须是从(0,0)到(1,1)的单调递增曲线 private float _totalDistance; private float _totalTime; public void Initialize(float totalDistance, float totalTime) { _totalDistance totalDistance; _totalTime totalTime; // 可以在这里验证distanceCurve的起点和终点 } public float GetDistanceAtTime(float currentTime) { float normalizedTime Mathf.Clamp01(currentTime / _totalTime); float normalizedDistance distanceCurve.Evaluate(normalizedTime); return normalizedDistance * _totalDistance; } }在Inspector里编辑这条曲线你可以轻松创造出各种缓入缓出EaseInOut、先慢后快等效果控制自由度大大增加。将控制器中的SpeedProfile替换为AdvancedSpeedController并设置好总时间和总距离即可。4.2 动态路径与实时速度调整有时路径或目标速度会在运行时改变。例如玩家点击一个新位置小车需要动态规划一条新贝塞尔曲线过去或者中途收到指令要突然刹车。动态路径当控制点改变时只需调用path.BakePath()重新计算弧长表并更新SpeedProfile或AdvancedSpeedController中的totalDistance和totalTime即可。注意如果小车正在运动需要处理好当前进度到新路径的映射一种简单的方法是先计算小车在当前路径上的弧长将其作为在新路径上继续运动的初始弧长。实时速度调整比如中途刹车。我们可以在BezierCarController里增加一个targetSpeed变量和currentSpeed变量。在Update中不再直接使用预设的SpeedProfile而是根据currentSpeed、targetSpeed和加速度参数每帧计算新的currentSpeed。然后用currentSpeed * Time.deltaTime得到本帧应增加的弧长累加到总弧长上。这样就能实现更动态的、响应输入的速度控制。4.3 弧长采样精度与性能优化BezierPath中的SAMPLE_COUNT决定了弧长映射表的精度。精度越高GetTFromDistance查询的位置越准确但初始化计算量和内存占用也越大。自适应采样一个优化策略是进行自适应采样。在曲线曲率大的地方二阶导数大增加采样点在平直的地方减少采样点。这需要在BakePath方法中实现更复杂的逻辑通过递归或角度判断来插入采样点。缓存与复用如果场景中有大量相同路径的小车应该共享同一个BezierPath实例及其烘焙好的弧长表避免重复计算。使用查找表(LUT)对于非常长的路径或移动设备可以将cumulativeLengths和sampleTs存储为原生数组(float[])并使用二分查找这比Listfloat.BinarySearch在极高频查询下可能略有性能优势但大多数情况下List已足够。5. 常见问题与调试技巧在实际集成到项目时你可能会遇到下面这些问题。5.1 小车运动“打滑”或抖动症状小车位置更新正常但旋转时发生剧烈抖动或方向突然翻转。原因UpdateRotation中计算切线方向的方法过于简单。当t接近1时tdelta可能超过1当曲线本身有非常尖锐的拐角时前后两点的方向可能变化剧烈。解决方案使用精确的导数公式贝塞尔曲线的一阶导数切线是有解析解的。对于三次贝塞尔曲线导数仍然是二次贝塞尔曲线。实现GetTangent(float t)方法直接计算切线向量这比两点差分法更稳定。方向平滑Slerp不要直接将transform.forward设置为计算出的方向而是使用Quaternion.Slerp在当前朝向和目标朝向之间进行平滑插值并设置一个合理的最大角速度。void UpdateRotation(float t, Vector3 targetForward) { float maxDegreesDelta 180f * Time.deltaTime; // 每秒最多转180度 Quaternion targetRotation Quaternion.LookRotation(targetForward); transform.rotation Quaternion.RotateTowards(transform.rotation, targetRotation, maxDegreesDelta); }5.2 速度曲线与路径长度不匹配导致“跳帧”症状小车在应该开始减速的位置没有减速或者运动突然结束/循环。原因SpeedProfile计算出的totalTime内GetDistanceAtTime(totalTime)返回的距离略小于path.totalLength导致最后一帧查询GetTFromDistance时传入的距离值无法在弧长表中精确匹配到t1的位置。解决方案精度容错在BezierPath.GetTFromDistance方法的开头增加一个容错判断。public float GetTFromDistance(float targetDistance) { if (Mathf.Approximately(targetDistance, totalLength)) { return 1.0f; // 直接返回终点 } // ... 原有查找逻辑 }确保计算一致性检查SpeedProfile.CalculatePhases中的浮点数计算确保accelDistance cruiseDistance decelDistance在浮点误差范围内等于totalDistance。可以在计算完cruiseDistance后用totalDistance减去前两者之和来反向修正cruiseDistance消除累积误差。5.3 在Inspector中可视化编辑与调试光靠代码调试不够直观。我们可以扩展Editor脚本让路径和运动状态在Scene视图中一目了然。绘制路径与速度向量在OnDrawGizmosSelected中除了画线还可以在采样点上绘制小箭头表示该点的切线方向即速度方向。绘制速度标量可以用不同颜色来表示路径上各点的理想速度大小。例如在采样点处根据该点对应的t和SpeedProfile计算出时间再得到速度然后用Gizmos.color Color.Lerp(Color.blue, Color.red, speed/maxSpeed)来绘制点红色代表高速蓝色代表低速。实时绘制小车轨迹在BezierCarController的Update中将每一帧的位置存储到一个ListVector3中并在OnDrawGizmos里用Gizmos.DrawLine将这些点连起来。这样可以清晰看到小车实际运动的轨迹和间距反映了速度。5.4 从3D到2D的适配如果你的项目是2D的比如使用Unity的2D物理系统原理完全通用只需要注意以下几点坐标轴将所有的Vector3替换为Vector2并忽略Z轴。在计算位置和方向时只处理X和Y。朝向2D物体的旋转通常只有一个角度。可以使用Mathf.Atan2(direction.y, direction.x)来计算切线方向与X轴的夹角弧度然后赋值给transform.eulerAngles的Z轴。路径编辑可以在Scene视图的2D模式下通过自定义Editor脚本用鼠标点击来添加和拖动2D控制点。这套基于贝塞尔曲线和速度曲线解耦的方案其核心思想——用参数曲线描述空间用时间函数描述进度——是一种非常强大且通用的动画控制模式。它不仅适用于小车移动还可以拓展到摄像机漫游、UI元素动态、特效轨迹等任何需要遵循特定路径且速度可控的场景。关键在于理解“弧长参数化”这个桥梁一旦掌握你就能创造出极其平滑和专业的运动效果。