多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity动态路径规划:Curvy Spline实现平滑运动与A*算法结合

Unity动态路径规划:Curvy Spline实现平滑运动与A*算法结合 1. 项目概述当路径不再是直线在游戏开发、数字孪生或者任何需要物体沿特定轨迹运动的场景里我们最常想到的可能是直线移动或者简单的贝塞尔曲线。但现实世界的运动无论是赛车过弯、无人机巡航还是传送带上的机械臂路径往往复杂多变需要动态调整。手动用代码去计算这些平滑路径不仅数学门槛高调试起来更是噩梦。这就是为什么我们需要像Curvy Spline这样的专业插件。简单来说Curvy Spline 是一个功能强大的 Unity 插件它的核心价值在于把复杂的样条曲线Spline创建、编辑和控制变得可视化、傻瓜化。你只需要在场景里放置几个控制点它就能自动生成一条穿过这些点、无比平滑的连续曲线。这解决了我们路径规划中最头疼的“平滑性”问题。而“动态”二字则是这个项目的灵魂——它意味着路径不是一成不变的预制件而是可以在运行时根据游戏逻辑如玩家输入、AI决策、环境变化实时创建、修改和让物体沿其运动。想象一下你要做一个塔防游戏怪物们的行进路线需要根据新建造的塔楼动态绕行或者做一个模拟游戏车辆需要根据实时交通状况重新规划路线。如果每变一次就要美术重新做一条路径或者程序员吭哧吭哧写一堆插值算法效率就太低了。Unity 结合 Curvy Spline 实现动态路径规划与物体平滑移动正是为了解决这类需求。它适合所有需要在 Unity 中实现复杂、可变、平滑运动轨迹的开发者无论是初涉游戏逻辑的程序员还是深耕模拟仿真的技术美术。2. 核心思路与插件选型解析2.1 为什么是 Curvy Spline市面上处理曲线的 Unity 插件或内置方案不少比如 Unity 自带的AnimationCurve或者用Vector3.Lerp配合多个路点。但为什么我强烈推荐在需要动态和高质量平滑的路径规划中使用 Curvy Spline首先内置方案的局限性。AnimationCurve主要设计用于随时间变化的单个浮点值如位移、透明度虽然可以模拟一维路径但扩展到三维空间运动非常别扭且缺乏对曲线本身进行动态编辑的友好 API。手动管理路点列表然后进行线性或二次插值代码复杂度随平滑度要求指数级上升且很难实现曲率连续的平滑G2 连续移动物体时容易产生生硬的拐角感。其次Curvy Spline 的专业性。它专为三维空间曲线而生提供了多种曲线类型如 Catmull-Rom、Bezier、TCB能满足从游戏到工业仿真的不同平滑度需求。其核心优势在于可视化编辑器在 Scene 视图中直接拖拽控制点、调整切线手柄所见即所得极大降低了调试成本。丰富的 API提供了从世界坐标、切线方向、法线方向到曲率、长度等完整的曲线信息查询接口并且这些计算是高度优化的。动态构建能力支持通过代码在运行时动态添加、删除、插入控制点并实时更新曲线。这是实现动态路径规划的基石。沿曲线移动的解决方案内置了CurvySplineMove等组件可以方便地让物体沿曲线运动并处理不同的移动模式如匀速、定长步进。注意Curvy Spline 是一个商业插件在 Asset Store 需要付费购买。但对于需要频繁处理复杂路径的项目其提升的开发效率和最终效果投资回报率非常高。社区也有一些免费的开源样条解决方案但在功能完整性、稳定性和易用性上通常难以与之媲美。2.2 动态路径规划的整体架构要实现“动态”我们的系统就不能只是一个预先摆好的静态曲线。我的设计思路通常分为三层第一层数据层Spline 实例这是 Curvy Spline 的核心对象。我们会在运行时通过代码创建一个CurvySpline实例或者获取一个预设的、但控制点可修改的 Spline 实例。这个 Spline 对象存储了所有控制点CurvySplineSegment的数据和曲线计算逻辑。第二层逻辑层路径规划器这是一个自定义的 C# 类我习惯称之为DynamicPathPlanner。它的职责是接收外部的路径请求例如起点、终点、中间需要避开的障碍物位置列表然后调用 Curvy Spline 的 API动态地构建或更新数据层中的 Spline。它包含了路径生成的算法比如如何将一系列空间坐标点转化为合适的控制点。第三层表现层移动控制器负责让物体GameObject沿着规划好的路径平滑移动。可以直接使用 Curvy Spline 自带的CurvySplineMove组件或者为了更精细的控制如速度曲线、事件触发编写一个自定义的SplineFollower脚本。这个脚本会每帧从 Spline 上根据当前进度或时间获取下一个目标位置和旋转并应用到物体上。这三层分离使得路径生成逻辑和物体移动逻辑解耦非常灵活。你可以更换不同的规划算法而不影响物体的移动也可以让多个物体共享或追踪同一条动态变化的路径。3. Curvy Spline 核心功能与 API 实战3.1 创建与编辑曲线从静态到动态在编辑器中创建一条曲线非常简单添加CurvySpline组件到任一 GameObject然后在 Inspector 中点击添加控制点或者在 Scene 视图中使用其工具栏进行绘制。但我们的重点在运行时。动态创建一条新曲线using FluffyUnderware.Curvy; using FluffyUnderware.Curvy.Generator; public class PathManager : MonoBehaviour { private CurvySpline m_dynamicSpline; void Start() { // 1. 创建一个空的GameObject并添加CurvySpline组件 GameObject splineGO new GameObject(DynamicPath); m_dynamicSpline splineGO.AddComponentCurvySpline(); // 2. 配置曲线基本参数可选但很重要 m_dynamicSpline.Interpolation CurvyInterpolation.CatmullRom; // 使用Catmull-Rom插值保证曲线穿过所有控制点 m_dynamicSpline.Closed false; // 开放曲线还是闭合曲线 m_dynamicSpline.RestrictTo2D false; // 是否限制在2D平面 // 3. 动态添加控制点 Vector3[] pathPoints CalculatePathPoints(); // 你的路径点计算逻辑 for (int i 0; i pathPoints.Length; i) { // Add 方法会返回创建的 Control Point Segment CurvySplineSegment cp m_dynamicSpline.Add(pathPoints[i]); // 可以进一步设置控制点属性如自动处理切线 cp.AutoHandles true; cp.SynchronizeTCB(); // 如果使用TCB插值同步张力、连续度、偏置参数 } // 4. 刷新曲线使其生效 m_dynamicSpline.Refresh(); } private Vector3[] CalculatePathPoints() { // 这里可以是A*算法输出的路径点也可以是鼠标点击的位置序列 // 示例一个简单的弧形路径 return new Vector3[] { new Vector3(0, 0, 0), new Vector3(2, 1, 0), new Vector3(4, 0, 0), new Vector3(6, -1, 0) }; } }关键点解析Interpolation属性决定了曲线的平滑算法。CatmullRom是最常用的它确保曲线精确穿过所有控制点非常适合根据路径点生成轨迹。Bezier提供更自由的控制但曲线不一定穿过所有点TCBTension, Continuity, Bias则提供了类似3D动画软件中对曲线形态的精细控制。Add方法不仅添加了点还创建了对应的CurvySplineSegment。每个 Segment 代表两个控制点之间的一段曲线。必须调用Refresh()。在运行时动态修改了控制点位置、属性或插值方式后必须调用此方法曲线才会重新计算并更新。这是新手最容易忽略导致曲线“没变化”的坑。3.2 查询曲线信息获取移动的“地图”让物体移动我们需要知道在曲线“哪里”是什么位置、朝向何方。Curvy Spline 提供了两种主要的定位方式1. 基于 TF (Threading Factor)TF 是一个范围在 0 到 1 之间的浮点数表示在整条曲线长度上的相对位置。0 是起点1 是终点。这是最常用的方式因为它与曲线长度解耦。// 获取曲线上 TF0.5中点的位置和切线方向 Vector3 position m_dynamicSpline.Interpolate(0.5f); Vector3 tangent m_dynamicSpline.GetTangent(0.5f); // 切线方向通常用于物体的向前方向Forward Vector3 upVector m_dynamicSpline.GetOrientationUpFast(0.5f); // 上方向可用于物体的Up向量 // 将TF转换为实际距离从起点开始 float distance m_dynamicSpline.TFToDistance(0.5f); // 将实际距离转换回TF float tf m_dynamicSpline.DistanceToTF(distance);2. 基于距离 (Distance)直接使用从曲线起点开始的沿曲线测量的实际距离。这在需要精确控制移动距离如匀速移动时非常有用。float currentDistance 10.0f; // 当前在曲线10米处 Vector3 positionByDistance m_dynamicSpline.InterpolateDistance(currentDistance);3. 获取最近点动态规划中常常需要将物体“吸附”到路径上或者判断一个位置离路径有多远。Vector3 randomPoint new Vector3(3, 0, 1); float nearestTF; float nearestDistance; Vector3 nearestPoint m_dynamicSpline.GetNearestPoint(randomPoint, out nearestTF, out nearestDistance); // nearestTF 是最近点的TF值nearestDistance 是randomPoint到曲线的直线距离。实操心得Interpolate和GetTangent是移动控制器的核心。切线方向 (tangent.normalized) 通常直接赋值给物体的transform.forward这样可以保证物体始终朝向运动方向。对于飞行器、赛车这非常关键。GetOrientationUpFast可以提供曲线的副法线方向用于计算物体的倾斜如摩托车过弯但注意在曲线扭转剧烈的地方可能需要更复杂的朝向插值。3.3 动态修改曲线让路径“活”起来静态路径意义有限。动态的精髓在于修改。Curvy Spline 允许我们在运行时插入、删除、移动控制点。插入控制点// 假设我们想在现有曲线的大致中间位置插入一个新点 float insertTF 0.5f; Vector3 newPointPos m_dynamicSpline.Interpolate(insertTF) Vector3.up * 2; // 在中间点上方2单位插入 CurvySplineSegment newSegment m_dynamicSpline.InsertAfter(insertTF, newPointPos); // InsertAfter 会在指定TF位置之后插入一个新的控制点。还有 InsertBefore 等方法。 m_dynamicSpline.Refresh(); // 再次提醒修改后必须Refresh移动控制点// 移动第一个控制点 CurvySplineSegment firstCP m_dynamicSpline.ControlPointsList[0]; firstCP.transform.position Vector3.right * 5; // 向右移动5个单位 // 直接修改位置后也需要刷新 firstCP.Spline.SetDirtyAll(); // 标记该控制点所在的Spline为脏 firstCP.Spline.Refresh(); // 刷新整个Spline。对于单个点移动SetDirtyAllRefresh是标准流程。删除控制点CurvySplineSegment cpToRemove m_dynamicSpline.ControlPointsList[2]; m_dynamicSpline.Delete(cpToRemove); m_dynamicSpline.Refresh();性能提示频繁调用Refresh()在控制点很多时可能有性能开销。如果在一帧内进行了多次修改比如循环中添加多个点建议在所有修改完成后调用一次Refresh()。对于极高频的动态更新如每帧都根据某种规则变形曲线需要评估性能或考虑使用对象池复用控制点而不是反复创建删除。4. 实现物体平滑移动控制器有了动态生成的路径下一步就是让物体跟着走。虽然可以使用自带的CurvySplineMove但自己实现一个SplineFollower能获得最大的灵活性也是理解原理的关键。4.1 基础跟随器匀速移动我们先实现一个最简单的版本物体以恒定速度沿曲线移动。using UnityEngine; using FluffyUnderware.Curvy; public class SimpleSplineFollower : MonoBehaviour { public CurvySpline Spline; // 要跟随的曲线 public float Speed 5.0f; // 移动速度单位/秒 public bool MoveOnStart true; private float m_currentDistance; // 当前沿曲线移动的距离 private bool m_isMoving; void Start() { if (Spline null || !Spline.IsInitialized) { Debug.LogError(Spline not assigned or not initialized!); return; } m_currentDistance 0f; m_isMoving MoveOnStart; // 初始位置设置到曲线起点 if (Spline.ControlPointsCount 0) transform.position Spline.Interpolate(0); } void Update() { if (!m_isMoving || Spline null || Spline.Dirty) // 如果曲线正在被修改可能暂停移动 return; // 1. 根据速度和时间计算本帧应移动的距离 float deltaDistance Speed * Time.deltaTime; m_currentDistance deltaDistance; // 2. 处理移动结束到达终点 if (m_currentDistance Spline.Length) { m_currentDistance Spline.Length; m_isMoving false; // 可以触发到达终点事件 Debug.Log(Reached end of spline.); } // 3. 根据新的距离获取目标位置和朝向 float targetTF Spline.DistanceToTF(m_currentDistance); Vector3 targetPosition Spline.InterpolateDistance(m_currentDistance); Vector3 targetTangent Spline.GetTangent(targetTF); // 4. 应用位置和旋转 transform.position targetPosition; if (targetTangent ! Vector3.zero) { transform.rotation Quaternion.LookRotation(targetTangent.normalized); } } public void StartMoving() { m_isMoving true; } public void StopMoving() { m_isMoving false; } public void SetPositionByDistance(float distance) { m_currentDistance Mathf.Clamp(distance, 0, Spline.Length); } }这个脚本已经可以实现基本的平滑移动。但它有两个明显问题一是速度是恒定的缺乏加速启动和减速停止二是当曲线控制点稀疏时直接用LookRotation朝向切线在控制点之间旋转变化可能不够平滑。4.2 进阶跟随器平滑旋转与速度曲线为了解决上述问题我们引入两个概念旋转插值 (Slerp)和速度映射曲线 (AnimationCurve)。平滑旋转我们不直接设置旋转为目标切线方向而是每帧平滑地旋转过去这能消除帧间旋转的跳跃感。public float RotationSmoothTime 0.1f; // 旋转平滑时间 private Quaternion m_targetRotation; private Quaternion m_currentRotation; void Update() { // ... 前面计算 targetPosition 和 targetTangent 的代码不变 ... // 计算目标旋转 if (targetTangent ! Vector3.zero) { m_targetRotation Quaternion.LookRotation(targetTangent.normalized); } // 使用 Quaternion.Slerp 进行球形插值实现平滑旋转 m_currentRotation transform.rotation; transform.rotation Quaternion.Slerp(m_currentRotation, m_targetRotation, Time.deltaTime / RotationSmoothTime); // 应用位置位置可以立即更新或也用Lerp平滑 transform.position targetPosition; // 如果希望位置也平滑可以使用 Vector3.Lerp 或 Vector3.SmoothDamp // transform.position Vector3.Lerp(transform.position, targetPosition, Time.deltaTime / PositionSmoothTime); }速度曲线我们希望物体在路径起点加速终点减速中间匀速。可以定义一个AnimationCurve其横轴时间/TF为 0 到 1纵轴为速度缩放系数。public AnimationCurve SpeedCurve AnimationCurve.EaseInOut(0, 0, 1, 1); // 默认缓入缓出曲线 private float m_currentTF; // 我们改用TF来驱动更方便映射速度曲线 void Update() { if (!m_isMoving) return; // 1. 根据当前TF和速度曲线计算本帧的实际速度 float speedScale SpeedCurve.Evaluate(m_currentTF); // 获取当前TF对应的速度缩放比 float currentSpeed Speed * speedScale; // 2. 计算本帧TF的增量 (ΔTF 速度 * 时间 / 曲线总长度) float deltaTF (currentSpeed * Time.deltaTime) / Spline.Length; m_currentTF deltaTF; // 3. 限制TF范围 if (m_currentTF 1f) { m_currentTF 1f; m_isMoving false; } // 4. 用新的TF获取位置和切线 Vector3 targetPosition Spline.Interpolate(m_currentTF); Vector3 targetTangent Spline.GetTangent(m_currentTF); // ... 后续应用平滑旋转和位置的代码 ... }通过调整SpeedCurve你可以轻松实现任何速度模式突然启动、匀速巡航、缓慢停止甚至在中途某些路段加速或减速。4.3 与动态路径的交互应对路径变化当路径在物体移动过程中动态改变时比如避障后重新规划直接切换 Spline 会导致物体位置和进度TF或Distance的映射关系错乱。一个稳健的方案是进度保持策略当路径更新时计算物体当前位置在新路径上的最近点并将该点对应的 TF 或 Distance 作为新的移动起点。这保证了物体能“无缝”过渡到新路径上。public void UpdateSpline(CurvySpline newSpline) { if (newSpline null) return; // 记录物体当前世界位置 Vector3 currentWorldPos transform.position; // 找到这个位置在新曲线上的最近点 float nearestTF; float nearestDistance; newSpline.GetNearestPoint(currentWorldPos, out nearestTF, out nearestDistance); // 切换到新曲线并从最近点开始移动 Spline newSpline; m_currentTF nearestTF; // 或 m_currentDistance nearestDistance; // 可能需要立即更新一次位置和朝向避免视觉跳跃 SnapToCurrentPosition(); } private void SnapToCurrentPosition() { if (Spline null) return; transform.position Spline.Interpolate(m_currentTF); Vector3 tangent Spline.GetTangent(m_currentTF); if (tangent ! Vector3.zero) transform.rotation Quaternion.LookRotation(tangent.normalized); }插值过渡策略如果希望过渡更平滑可以在短时间内比如0.5秒让物体从旧路径的当前位置线性插值Lerp到新路径的对应位置。这避免了位置的瞬间“跳跃”视觉上更自然但逻辑稍复杂。5. 动态路径规划实战A* 算法与 Curvy Spline 结合单纯的曲线编辑和移动只是“器”真正的“术”在于如何生成这些控制点。一个经典的场景是将网格寻路算法如 A*输出的折线路径转化为平滑的曲线路径。5.1 从 A* 路径点到样条控制点假设我们有一个基于网格的 A* 寻路算法它返回一个ListVector3的路径点这些点通常是网格的中心或角落连接起来是一条生硬的折线。using System.Collections.Generic; using FluffyUnderware.Curvy; public class AStarPathSmoother : MonoBehaviour { public CurvySpline TargetSpline; public void SmoothAStarPath(ListVector3 rawPathPoints) { if (TargetSpline null || rawPathPoints.Count 2) return; // 1. 清空现有控制点 TargetSpline.Clear(); // 2. 直接将A*路径点作为控制点添加Catmull-Rom会穿过它们 foreach (Vector3 point in rawPathPoints) { TargetSpline.Add(point); } // 3. 关键优化对控制点进行简化移除过于接近或共线的点避免不必要的曲线波动 SimplifyControlPoints(TargetSpline, 0.1f); // 0.1为简化阈值 // 4. 配置曲线并刷新 TargetSpline.Interpolation CurvyInterpolation.CatmullRom; TargetSpline.Closed false; TargetSpline.Refresh(); Debug.Log($Smoothed path generated with {TargetSpline.ControlPointsCount} control points.); } private void SimplifyControlPoints(CurvySpline spline, float angleThreshold) { // 这是一个简化的道格拉斯-普克算法变种用于移除共线点 if (spline.ControlPointsCount 3) return; ListCurvySplineSegment cps spline.ControlPointsList; for (int i cps.Count - 2; i 0; i--) // 从倒数第二个开始向前检查 { Vector3 prev cps[i - 1].transform.position; Vector3 curr cps[i].transform.position; Vector3 next cps[i 1].transform.position; Vector3 dir1 (curr - prev).normalized; Vector3 dir2 (next - curr).normalized; // 如果前后方向几乎一致点积接近1则认为中间点是冗余的 if (Vector3.Dot(dir1, dir2) (1f - angleThreshold)) { spline.Delete(cps[i], false); // 先不刷新批量删除后统一刷新 } } spline.Refresh(); // 统一刷新一次 } }这样我们就将 A* 的“楼梯状”路径变成了一条平滑的曲线。物体沿着这条曲线移动看起来就自然多了。5.2 动态避障与路径重规划更复杂的场景是动态避障。假设我们的移动物体通过射线检测发现前方有动态出现的障碍物需要实时重新规划路径。局部重规划不需要重新运行整个 A*。我们可以以物体当前位置和原路径前方一点作为新的起点和终点结合障碍物位置运行一个局部的 A* 搜索得到一段绕过障碍物的新折线。曲线融合将这段新的局部路径替换掉原 Spline 中对应的一段。这里就需要用到之前提到的InsertAfter、Delete等动态修改 API。关键在于找到需要被替换的曲线段TF范围删除旧的控制点插入新的控制点。平滑衔接在新旧路径的连接处要确保曲线平滑一阶连续即切线方向一致。Catmull-Rom 插值本身在控制点处是 C1 连续的所以只要确保连接点即新旧路径共享的控制点的位置不变并且其前后的控制点排列方向合理衔接就会自然平滑。有时可能需要微调连接点前后控制点的位置来优化曲率。这个过程对算法和 Curvy Spline API 的调用顺序要求较高是动态路径规划中最具挑战性的部分。一个实用的技巧是在重规划时保留原路径上距离障碍物较远的前后部分只修改中间受影响的一段这样可以最大程度保持路径的稳定性和平滑性。6. 性能优化与常见问题排查6.1 性能优化要点控制点数量这是影响性能的首要因素。Curvy Spline 的曲线计算复杂度与控制点数量相关。动态生成路径时务必对原始路径点进行简化如上面的SimplifyControlPoints方法在满足平滑度要求的前提下使用最少的控制点。刷新策略Refresh()和SetDirty()是开销较大的操作。避免在Update中每帧调用。如果路径是周期性更新如每秒一次就在更新完成后调用一次。如果是连续变形如绳索模拟则需要评估性能并考虑使用对象池复用控制点而不是删除再创建。缓存查询结果如果多个物体在同一帧需要查询同一条曲线的相同信息例如所有敌人都要查询离自己最近的路径点可以考虑在LateUpdate中统一计算一次并缓存避免重复查询。使用 Approximation 模式Curvy Spline 在计算曲线上的点时有精确计算和近似计算两种模式。对于移动跟随这种对绝对精度要求不是极端高的场景可以尝试使用CacheDensity和Approximation相关的设置用空间换时间预计算采样点来加速插值查询。6.2 常见问题与解决方案实录问题一物体移动时抖动或旋转跳跃。原因A曲线控制点过于稀疏导致切线方向在控制点之间变化不连续。Catmull-Rom 是 C1 连续但在两个线段连接处如果控制点构成的角度太小切线方向变化会很大。解决增加控制点密度或者在生成路径时对原始点进行插值加密。也可以尝试使用CurvySpline的Granularity参数它会影响曲线内部的采样精度。原因B每帧直接LookRotation到目标切线没有平滑过渡。解决如 4.2 节所述使用Quaternion.Slerp进行旋转平滑。问题二动态添加控制点后曲线形状不符合预期。原因没有正确设置控制点的AutoHandles属性或插值方式。新添加的控制点会继承 Spline 的默认设置但如果前后点的处理方式不匹配会导致曲线扭曲。解决在动态添加控制点后统一设置其属性。对于 Catmull-Rom通常设置AutoHandles false因为它不需要贝塞尔手柄。更可靠的做法是在添加完所有点并Refresh()之后调用Spline.AutoSetTangents()让插件自动计算所有点的切线然后再Refresh()一次。问题三物体移动到曲线终点时没有停止或者飞出去了。原因移动逻辑没有对 TF 或 Distance 进行范围钳制。解决在更新进度后使用Mathf.Clamp01或Mathf.Clamp确保其不超过 1 或曲线总长度。参考 4.1 节代码中的判断逻辑。问题四在运行时修改曲线移动物体的位置没有立即更新。原因修改控制点位置后没有调用SetDirtyAll()和Refresh()或者物体的移动脚本在Update顺序中先于曲线刷新脚本执行。解决确保修改控制点的代码后立即调用spline.SetDirtyAll(); spline.Refresh();。并考虑使用脚本执行顺序Edit - Project Settings - Script Execution Order确保曲线先刷新跟随器再移动。问题五打包后曲线不显示或移动失效。原因Curvy Spline 的某些功能可能依赖于编辑器类或者在构建时资源没有正确包含。解决检查是否有在#if UNITY_EDITOR块内的代码被错误地用在运行时。确保场景中使用的 Spline 预制体或动态创建的 Spline GameObject 在构建后依然有效。清理代码移除所有仅用于编辑器调试的曲线绘制语句如Debug.DrawLine在 Spline 组件内部可能大量使用但发布版会被剥离不影响功能。最后调试动态路径时多利用 Curvy Spline 在 Scene 视图中的可视化工具。在播放模式下你可以清晰地看到实时生成的曲线和控制点这对于验证路径规划算法是否正确输出、曲线是否平滑至关重要。记住好的动态路径规划是算法逻辑和表现层平滑移动紧密结合的艺术Curvy Spline 提供了强大的画布而如何挥笔则取决于你对运动的理解和需求的设计。
返回列表