Introduction
游戏开发过程中,我们希望角色物体按固定路线移动,我们一般需要通过路点编辑器工具去预先编辑好所有路点,然后导出路点数据通过数学运算去移动角色物体。
结合以前学习的数学缓动和UnityEditor知识
本章节通过编写纯Editor的路点编辑器,从实战角度去利用数学缓动和UnityEditor知识实现我们需要的路点编辑器工具,以及基于路点编辑器数据的路线插值缓动系统。
觉得好用的可以购买淘宝商店版本支持作者:
PathPointTool路点工具(淘宝)
路点编辑和缓动
实现一个工具,首先第一步,我们还是需要理清需求:
- 纯Editor非运行时路点编辑器。
- 路点编辑器需要生成可视化编辑对象和路点路线展示(支持纯Editor绘制和LineRenderer组件绘制两种方式)。
- 路点编辑器要支持指定起始位置和固定位置偏移的路点编辑自动矫正(方便固定单位间隔的路点配置)。
- 路点编辑器要支持指定路线移动时长,是否循环和是否自动更新朝向等路线缓动模拟设置。
- 路点编辑器要支持自定义数据导出自定义格式(比如Json,CSV)数据。
- 路点编辑器要支持多种路线类型(e.g. 直线,Bezier,CubicBezier等)。
- 路线移动支持缓动曲线配置。
- 路点编辑器要支持纯Editor模拟运行路点移动效果。
- 路点编辑器编辑完成后的数据要支持运行时使用并模拟路线缓动,同时路线缓动要支持纯运行时构建使用。
- 实现一个纯Editor的Tile可视化绘制脚本(方便路点编辑位置参考)。
- 不同路点类型要支持DIY数据定义加配置导出。
- 支持自定义导出的数据加载快速使用触发路点移动表现。
- 路点移动要支持移动完成,经过路点,循环开始等回调。
Bezier曲线
我们路点编辑器要支持直线和曲线运动,说到曲线运动,这里就不得不提Bezier曲线。
贝塞尔曲线是电脑图形学中相当重要的参数曲线。
个人理解Bezier曲线的核心原理就是多个点(N阶)之间的同时递归插值0-1比例值得出的一系列最终插值位置
线性Bezier曲线
线性Bezier曲线其实就是我们2个点位置的线性插值,公式如下(P0第一个点,P1第二个点):
B(t) = P0 + (P1 - P0) * t,t∈[0, 1]
一阶Bezier曲线公式简化后:
B(t) = (1 - t) * P0 - t * P1,t∈[0, 1]
从公式可以看出,线性Bezier就是2个点位置通过0-1的比例值插值的过程。

二阶Bezier曲线
同理二阶Beizer曲线是由3个顶点位置递归插值而来的,公式如下(P0第一个点,P1第二个点,P2第三个点):
首先我们先我们先插值P0和P1:
P0P1 = (1 - t) * P0 + t * P1,t∈[0, 1]
接下来插值P1和P2:
P1P2 = (1 - t) * P1 + t * P2,t∈[0, 1]
三个点之间的插值完成后,接下来我们还要递归插值三个点插值后的两个点(P0P1和P1P2):
p0p1p2 = (1 - t) * p0p1 + t * p1p2,t∈[0, 1]
二阶Bezier曲线公式简化后:
B(t) = (1 - t)² * P0 + 2 * (1 - t) * t * P1 + t² * P2,t∈[0, 1]
三阶Bezier曲线
同理三阶Beizer曲线是由3个顶点位置递归插值而来的,公式如下(P0第一个点,P1第二个点,P2第三个点,P3第四个点):
首先我们先我们先插值P0和P1:
P0P1 = (1 - t) * P0 + t * P1,t∈[0, 1]
接下来插值P1和P2:
P1P2 = (1 - t) * P1 + t * P2,t∈[0, 1]
接下来插值P2和P3:
P2P3 = (1 - t) * P2 + t * P3,t∈[0, 1]
以上插值位置动画如下:

P0P1,P1P2,P2P3分别对应动画中的a,b,c三个插值点
四个点之间的插值完成后,接下来我们还要递归插值四个点插值后的三个点(P0P1,P1P2和P2P3):
插值P0P1和P1P2:
P0P1P2 = (1 - t) * P0P1 + t * P1P2,t∈[0, 1]
插值P1P2和P2P3:
P1P2P3 = (1 - t) * P1P2 + t * P2P3,t∈[0, 1]
以上插值位置动画如下:

P0P1P2和P1P2P3分别对应动画中的d和e两个插值点
最后一步,我们接着把P0P1P2和P1P2P3进行插值就能得到3阶Bezier曲线的最终插值位置了:
插值P0P1P2和P1P2P3:
P0P1P2P3 = (1 - t) * P0P1P2 + t * P1P2P3,t∈[0, 1]
三阶Bezier曲线公式简化后:
B(t) = (1 - t)³ * P0 + 3 * (1 - t)² * t * P1 + 3 * t² * (1 - t) * P2 + t³ * P3,t∈[0, 1]
以上插值位置动画如下:

N阶Bezier曲线公式参考:

N阶Bezier的代码实现请参考:
unity利用高阶贝塞尔曲线进行的轨道移动
由于N阶Bezier的计算复杂度过高,一般来说,路点过多的情况下,我们不会直接采用N阶Bezier曲线进行插值计算,而是采用N个3阶Bezier曲线进行拼接组装成一个N阶Bezier曲线
Catmull-Rom Spline
虽然Bezier已经能实现大部分曲线的需求,但Bezier曲线里移动控制点(无论2阶还是3阶)就会导致整个曲线发生变化,即无法局部控制曲线走向,同时Bezier曲线不能确保通过所有控制点。
Catmull-Rom Spline样条线是一根比较特殊的Bezier曲线,这条Bezier曲线能够保证穿过从控制点的第二个点到控制点的倒数第二点之间的所有点。所以说,Catmull-Rom样条线最少需要4个控制点来进行控制。
Catmull-Rom算法保证两点:
1、点Pi 的一阶导数等于Pi+1 - Pi-1,即点Pi 的切向量和其相邻两点连线的切向量是平行的
2、穿过所有Pi 点。这是与贝塞尔曲线的最大区别,正因为这样的特性,使得Catmull-Rom算法适于用作轨迹线算法。

可以看到Catmull-Rom Spline和三阶Bezier曲线一样需要4个点:

P1点的切线和P0P2一致,P2点的切线和P1P3一致。
值得注意的是虽然输入点有4个,但我们t(0-1)最终绘制的是P1到P2和P2到P3这部分
那么如何确保所有的控制点都连接绘制了?
利用Catmull-Rom Spline曲线会通过中间两个控制点且中间两个点经过时的切线与前后两个控制点连线平行,那么我可以可以通过模拟构造一个P(-1)=2P0-P1(确保P(-1)P1和P0切线平行从而确保从P0处切线平行),利用P(-1)P0P1P2构造一个CatmullRomSpline曲线即可画出P0开始的P0P1的曲线。最后一段曲线同理,构造一个P(N+1)=2P(N)-P(N-1),然后绘制P(N-2)P(N-1)P(N)P(N+1)即可绘制出P(N-1)P(N)的曲线。
这里直接给出最终推导公式,详细推导过程参考后续其他博主分享:
P = _point = P0 * (-0.5ttt + tt – 0.5t) + P1 * (1.5ttt - 2.5tt + 1.0) + P2 * (-1.5ttt + 2.0tt + 0.5t) + P3 * (0.5ttt – 0.5t*t);
详细的推导过程博主并没有完全看懂,这里大家可以参考这位博主的推导分析:
Catmull-Rom插值算法
理解了Bezier曲线和Catmull-Rom Spline样条线原理,接下来让我们进入路点编辑器实战。
实战
接下来针对前面提到的需求,我们一一分析我们应该用什么方案和技术来实现。
需求:
- 纯Editor非运行时路点编辑器。
- 路点编辑器需要生成可视化编辑对象和路点路线展示(支持纯Editor绘制和LineRenderer组件绘制两种方式)。
- 路点编辑器要支持指定起始位置和固定位置偏移的路点编辑自动矫正(方便固定单位间隔的路点配置)。
- 路点编辑器要支持指定路线移动时长,是否循环和是否自动更新朝向等路线缓动模拟设置。
- 路点编辑器要支持自定义数据导出自定义格式(比如Json)数据。
- 路点编辑器要支持多种路线类型(e.g. 直线,Bezier,CubicBezier等)。
- 路点编辑器要支持纯Editor模拟运行路点移动效果。
- 路线移动支持缓动曲线配置。
- 路点编辑器编辑完成后的数据要支持运行时使用并模拟路线缓动,同时路线缓动要支持纯运行时构建使用。
- 实现一个纯Editor的Tile可视化绘制脚本(方便路点编辑位置参考)。
- 不同路点类型要支持DIY数据定义加配置导出。
- 支持自定义导出的数据加载快速使用触发路点移动表现。
- 路点移动要支持移动完成,经过路点,循环开始等回调。
实现思路:
- 结合自定义Inspector面板(继承Editor)定义的方式实现纯Editor配置和操作
- 利用Gizmos(Monobehaviour:OnDrawGizmos()),Handles(Editor.OnSceneGUI())和自定义Inspector(Editor)面板编辑操作实现可视化编辑对象生成和展示。LineRenderer通过挂在指定LinRenderer组件将路点细分的点通过LineRenderer:SetPositions()设置显示。
- 利用自定义Inspector面板支持起始位置和路点间隔配置,然后通过配置数据进行路点位置矫正操作。
- 自定义Inspecotr面板支持配置即可。
- 同上,自定义Inspector面板支持导出文件名和导出类型即可,路点数据导出前统一封装然后根据导出类型导出即可。
- 利用Bezier曲线知识,实现不同路线类型(e.g. 直线,Bezier,CubicBezier等)。
- 利用InitializeOnLoad,ExecuteInEditMode和InitializeOnLoadMethod标签加EditorApplication.update实现纯Editor初始化和注入Update更新实现纯Editor模拟路点移动效果。
- 利用缓动曲线去重新计算插值t(0-1)的值作为插值比例即可。
- 实现一套超级简陋版DoTween支持运行时路线缓动模拟即可(见TPathTweener和TPathTweenerManager)。
- 利用Gizmos的自定义Mesh绘制+自定义Inspector面板实现Tile网格自定义配置绘制。
- 通过定义TPathPointType定义路点类型,然后统一在TPathPointData里定义自定义数据成员,在自定义绘制Inspector面板时根据路点类型去决定是否绘制特定自定义数据面板。
- 通过构造统一导出数据结构,给TPathTweenerManager添加使用导出数据结构作为路点数据表现的接口即可。
- 通过TPathTweener去根据进度计算对应回调实际触发对应回调即可。
核心思路和技术实现方案都在上面介绍了,这里就不一一介绍代码实现了,这里只放关于Bezier曲线插值相关的代码,让我们直接实战看效果,需要源码的直接在文章末尾Github链接去取即可。
BezierUtilities.cs(Bezier曲线插值相关)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
| using System; using System.Collections; using System.Collections.Generic; using UnityEngine;
public static class BezierUtilities {
public static Vector3 CaculateLinerPoint(Vector3 p0, Vector3 p1, float t) { t = Mathf.Clamp01(t);
return (1 - t) * p0 + t * p1; }
public static Vector3 CaculateBezierPoint(Vector3 p0, Vector3 p1, Vector3 p2, float t) { t = Mathf.Clamp01(t);
var u = 1 - t; var tt = t * t; var uu = u * u; return uu * p0 + 2 * u * t * p1 + tt * p2; }
public static Vector3 CaculateCubicBezierPoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t);
var u = 1 - t; var tt = t * t; var uu = u * u; var ttt = tt * t; var uuu = uu * u; return uuu * p0 + 3 * uu * t * p1 + 3 * tt * u * p2 + ttt * p3; }
public static Vector3 CaculateCRSplinePoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t);
var tt = t * t; var ttt = tt * t; return (-0.5f * ttt + tt - 0.5f * t) * p0 + (1.5f * ttt - 2.5f * tt + 1) * p1 + (-1.5f * ttt + 2 * tt + 0.5f * t) * p2 + (0.5f * ttt - 0.5f * tt) * p3; }
public static Vector3[] GetLinerList(Vector3 p0, Vector3 p1, int segmentNum) { segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue); var pathPointNum = segmentNum + 1; Vector3[] path = new Vector3[pathPointNum]; for (int i = 0; i < pathPointNum; i++) { float t = i / (float)segmentNum; Vector3 pathPoint = CaculateLinerPoint(p0, p1, t); path[i] = pathPoint; } return path; }
public static Vector3[] GetBeizerList(Vector3 p0, Vector3 p1, Vector3 p2, int segmentNum) { segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue); var pathPointNum = segmentNum + 1; Vector3[] path = new Vector3[pathPointNum]; for (int i = 0; i < pathPointNum; i++) { float t = i / (float)segmentNum; Vector3 pathPoint = CaculateBezierPoint(p0, p1, p2, t); path[i] = pathPoint; } return path; }
public static Vector3[] GetCubicBeizerList(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, int segmentNum) { segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue); var pathPointNum = segmentNum + 1; Vector3[] path = new Vector3[pathPointNum]; for (int i = 0; i < pathPointNum; i++) { float t = i / (float)segmentNum; Vector3 pathPoint = CaculateCubicBezierPoint(p0, p1, p2, p3, t); path[i] = pathPoint; } return path; }
public static Vector3[] GetCRSplineList(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, int segmentNum) { segmentNum = Mathf.Clamp(segmentNum, 1, Int32.MaxValue); var pathPointNum = segmentNum + 1; Vector3[] path = new Vector3[pathPointNum]; for (int i = 0; i < pathPointNum; i++) { float t = i / (float)segmentNum; Vector3 pathPoint = CaculateCRSplinePoint(p0, p1, p2, p3, t); path[i] = pathPoint; } return path; } }
|
TPath.cs(M个点换算成P个N阶Bezier曲线插值)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
|
private Vector3 GetLinerPoinAt(float t) { var pointNum = CaculatePathPointList.Count; var maxPointIndex = pointNum - 1; TSegment currentUnderSegment; float currentUnderSegmentPercent; GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent); if(currentUnderSegment == null) { return Vector3.zero; } var firstPointIndex = currentUnderSegment.StartPointIndex; var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex); return BezierUtilities.CaculateLinerPoint(CaculatePathPointList[firstPointIndex], CaculatePathPointList[secondPointIndex], currentUnderSegmentPercent); }
private Vector3 GetBezierPointAt(float t) { var pointNum = CaculatePathPointList.Count; var maxPointIndex = pointNum - 1; TSegment currentUnderSegment; float currentUnderSegmentPercent; GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent); if (currentUnderSegment == null) { return Vector3.zero; } var firstPointIndex = currentUnderSegment.StartPointIndex; var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex); var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex); return BezierUtilities.CaculateBezierPoint(CaculatePathPointList[firstPointIndex], CaculatePathPointList[secondPointIndex], CaculatePathPointList[thirdPointIndex], currentUnderSegmentPercent); }
private Vector3 GetCubicBezierPointAt(float t) { var pointNum = CaculatePathPointList.Count; var maxPointIndex = pointNum - 1; TSegment currentUnderSegment; float currentUnderSegmentPercent; GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent); if (currentUnderSegment == null) { return Vector3.zero; } var firstPointIndex = currentUnderSegment.StartPointIndex; var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex); var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex); var fourthPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 3, 0, maxPointIndex); return BezierUtilities.CaculateCubicBezierPoint(CaculatePathPointList[firstPointIndex], CaculatePathPointList[secondPointIndex], CaculatePathPointList[thirdPointIndex], CaculatePathPointList[fourthPointIndex], currentUnderSegmentPercent); }
private Vector3 GetCRSplinePointAt(float t) { var pointNum = CaculatePathPointList.Count; var maxPointIndex = pointNum - 1; TSegment currentUnderSegment; float currentUnderSegmentPercent; GetRadioSegmentAndPercent(t, out currentUnderSegment, out currentUnderSegmentPercent); if (currentUnderSegment == null) { return Vector3.zero; } var firstPointIndex = currentUnderSegment.StartPointIndex; var secondPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 1, 0, maxPointIndex); var thirdPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 2, 0, maxPointIndex); var fourthPointIndex = Mathf.Clamp(currentUnderSegment.StartPointIndex + 3, 0, maxPointIndex); return BezierUtilities.CaculateCRSplinePoint(CaculatePathPointList[firstPointIndex], CaculatePathPointList[secondPointIndex], CaculatePathPointList[thirdPointIndex], CaculatePathPointList[fourthPointIndex], currentUnderSegmentPercent); }
private void GetRadioSegmentAndPercent(float t, out TSegment segment, out float segmentPercent) { var pointNum = PathPointList.Count; if (pointNum == 0) { segment = null; segmentPercent = 0f; return; } else if (pointNum == 1) { segment = SegmentList[0]; segmentPercent = 1f; return; } t = Mathf.Clamp01(t); var easeFunc = EasingFunction.GetEasingFunction(Ease); t = easeFunc(0, 1, t); var distance = t * Length; segment = SegmentList[0]; for (int i = 0, length = SegmentList.Count - 1; i < length; i++) { distance -= SegmentList[i].Length; segment = SegmentList[i]; if (distance < 0) { break; } } segmentPercent = (segment.Length + distance) / segment.Length; }
|
自定义路点数据编辑面板:

自定义Tile绘制配置面板:

可视化路点路线展示:

自定义路线数据导出:

LineRenderer可视化展示:

Ease插值类型:

M个点的N个3阶Bezier插值计算思路如下:
- N个3阶Bezier曲线的组合插值是通过将M个点分成N段3阶Bezier,计算出总长度且每段Bezier存储起始点索引和Bezier类型(影响当前Bezier的采样点数)和路段长度
- 当我们要计算一个插值比例t(0-1)进度插值计算时,首先根据总距离和进度映射计算出在哪一段Bezier路段
- 映射计算到对应3阶Bezier段后,再进行单个3阶Bezier曲线比例插值从而得到我们M个点的插值比例t(0-1)的最终插值位置
Cutmull-Rom Spline曲线经过首尾两个控制点思路:
- 利用Catmull-Rom Spline曲线会通过中间两个控制点且中间两个点经过时的切线与前后两个控制点连线平行,那么我可以可以通过模拟构造一个P(-1)=2P0-P1(确保P(-1)P1和P0切线平行从而确保从P0处切线平行),利用P(-1)P0P1P2构造一个CatmullRomSpline曲线即可画出P0开始的P0P1的曲线。最后一段曲线同理,构造一个P(N+1)=2P(N)-P(N-1),然后绘制P(N-2)P(N-1)P(N)P(N+1)即可绘制出P(N-1)P(N)的曲线。
路点类型数据配置
路点类型的自定义数据配置是通过统一定义在TPathPointData结构里,然后结合Inspector绘制决定路点类型是否绘制特定字段决定的。
TPathPointType.cs
1 2 3 4 5 6 7 8 9 10 11
|
public enum TPathPointType { Invalide = 1, Normal, Wait, Jump, }
|
TPathPointData.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
[Serializable] public class TPathPointData { [Header("坐标位置")] public Vector3 Position;
[Header("路点类型")] public TPathPointType PathPointType = TPathPointType.Normal;
[Header("等待路点的等待时长")] public float WaitTime = 0f;
[Header("跳跃路点的跳跃动画名称")] public string JumpAnimName = string.Empty; ****** }
|
TPathDataEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
|
private void DrawOnePathPointDataPropertyByIndex(int pathPointDataIndex) { var pathPointDataProperty = mPathPointDataListProperty.GetArrayElementAtIndex(pathPointDataIndex); DrawPathPointPositionProperty(pathPointDataIndex, pathPointDataProperty); DrawPathPointTypeProperty(pathPointDataIndex, pathPointDataProperty); DrawPathPointCustomProperties(pathPointDataIndex, pathPointDataProperty); }
******
private void DrawPathPointCustomProperties(int pathPointDataIndex, SerializedProperty pathPointDataProperty) { if (pathPointDataProperty == null) { return; } var pathPointTypeProperty = pathPointDataProperty.FindPropertyRelative("PathPointType"); if(pathPointTypeProperty == null) { return; } var pathPointType = (TPathPointType)pathPointTypeProperty.intValue; switch (pathPointType) { case TPathPointType.Wait: DrawWaitPathPointProperties(pathPointDataIndex, pathPointDataProperty); break; case TPathPointType.Jump: DrawJumpPathPointProperties(pathPointDataIndex, pathPointDataProperty); break; default: break; } }
private void DrawWaitPathPointProperties(int pathPointDataIndex, SerializedProperty pathPointDataProperty) { var waitTimeProperty = pathPointDataProperty.FindPropertyRelative("WaitTime"); if(waitTimeProperty == null) { return; } EditorGUILayout.LabelField("等待时长:", GUILayout.Width(60f)); waitTimeProperty.floatValue = EditorGUILayout.FloatField(waitTimeProperty.floatValue, GUILayout.Width(60f)); }
private void DrawJumpPathPointProperties(int pathPointDataIndex, SerializedProperty pathPointDataProperty) { var jumpAnimNameProperty = pathPointDataProperty.FindPropertyRelative("JumpAnimName"); if (jumpAnimNameProperty == null) { return; } EditorGUILayout.LabelField("跳跃动画:", GUILayout.Width(60f)); jumpAnimNameProperty.stringValue = EditorGUILayout.TextField(jumpAnimNameProperty.stringValue, GUILayout.Width(60f)); }
|
自定义路点类型数据绘制如下:

自定义数据导出
自定义数据导出是通过在TPathData的Inspector面板支持导出类型和自定义导出名字(默认不填取GameObject名,方便制作多个关卡的路点数据)。

在自定义导出数据之前,我通过统一导出结构为TPathDataExport类,然后在通过对应序列化方式实现自定义导出类型支持:
TPathDataExport.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
|
[Serializable] public class TPathDataExport { [Header("路线类型")] public TPathType PathType = TPathType.Normal;
[Header("路线绘制类型")] public TPathwayType PathwayType = TPathwayType.Liner;
[Header("缓动类型")] public EasingFunction.Ease Ease = EasingFunction.Ease.Linear;
[Header("是否循环")] public bool IsLoop = false;
[Header("是否更新朝向")] public bool UpdateForward = false;
[Header("持续时长")] public float Duration = 0f;
[Header("每段顶点细分数量")] [Range(1, 100)] public int Segment = 15;
[Header("路点数据列表")] public List<TPathPointData> PathPointDatas = new List<TPathPointData>();
****** }
|
TPathDataEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
|
private TPathDataExport ConstructPathDataExport() { var pathDataExport = new TPathDataExport(); pathDataExport.PathType = (TPathType)mPathTypeProperty.intValue; pathDataExport.PathwayType = (TPathwayType)mPathwayTypeProperty.intValue; pathDataExport.Ease = (EasingFunction.Ease)mEaseProperty.intValue; pathDataExport.IsLoop = mIsLoopProperty.boolValue; pathDataExport.UpdateForward = mUpdateForwardProperty.boolValue; pathDataExport.Duration = mDurationProperty.floatValue; pathDataExport.Segment = mSegmentProperty.intValue; for (int i = 0, length = mPathPointDataListProperty.arraySize; i < length; i++) { var pathPointDataProperty = mPathPointDataListProperty.GetArrayElementAtIndex(i); var pathPointData = pathPointDataProperty.managedReferenceValue as TPathPointData; pathDataExport.AddPathPointData(pathPointData); } return pathDataExport; }
private bool ExportPathPointDatas() { if(!CheckCanExportPathPointDatas()) { Debug.LogError($"不满足导出条件,导出路点数据失败!"); return false; } CorrectPathPointPositions(); var exportFileName = mExportFileNameProperty.stringValue; if(string.IsNullOrEmpty(exportFileName)) { exportFileName = mTarget.gameObject.name; } var exportType = (TPathExportType)mExportTypeProperty.intValue; var pathType = (TPathType)mPathTypeProperty.intValue; var pathDataExport = ConstructPathDataExport();
var exportFileFullPath = TPathUtilities.GetExportFileFullPath(exportFileName, exportType, pathType); var exportFileFolderFullPath = Path.GetDirectoryName(exportFileFullPath); FolderUtilities.CheckAndCreateSpecificFolder(exportFileFolderFullPath);
if(exportType == TPathExportType.Json) { return DoExportJsonPathPointDatas(exportFileFullPath, pathDataExport, pathType); } Debug.LogError($"不支持的导出类型:{exportType.ToString()}数据导出!"); return false; }
private bool DoExportJsonPathPointDatas(string exportFileFullPath, TPathDataExport pathDataExport, TPathType pathType) { if(pathDataExport == null) { Debug.LogError($"导出Json数据失败,导出数据结构为空!"); return false; } var jsonStr = JsonUtility.ToJson(pathDataExport, true); File.WriteAllText(exportFileFullPath, jsonStr); Debug.Log($"导出Json数据文件全路径:{exportFileFullPath}成功!"); return true; }
|
目前导出的类型只支持了Json,导出后内容如下:

自定义数据导出使用
自定义数据统一导出结构并导出后,我们如何快速使用这个结构进行路点数据模拟移动了?
首先我们要反序列化加载对应导出路点数据:
TPathUtilities.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
public static TPathDataExport LoadTPathDataExport(string exportFileName, TPathExportType exportType = TPathExportType.Json, TPathType pathType = TPathType.Normal) { var exportFileFullPath = GetExportFileFullPath(exportFileName, exportType, pathType); var exportAssetFileRelativePath = PathUtilities.GetAssetsRelativeFolderPath(exportFileFullPath); var pathDataExportAsset = AssetDatabase.LoadAssetAtPath<TextAsset>(exportAssetFileRelativePath); var pathDataExport = JsonUtility.FromJson<TPathDataExport>(pathDataExportAsset.text); if(pathDataExport == null) { Debug.LogError($"加载导出文件:{exportAssetFileRelativePath}失败,无法获取有效的TPathDataExport!"); return null; } return pathDataExport; }
|
然后使用封装好的TPathTweenerManager.DoPathTweenByTPathDataExport()接口快速使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
private void OnBtnStartPathMove() { Debug.Log($"GameLaucher:OnBtnStartPathMove()"); if(mPathTweener != null) { TPathTweenerManager.Singleton.RemovePathTween(mPathTweener); mPathTweener = null; } if(mLoadedPathDataExport == null) { Debug.LogError($"没有加载有效的路点数据,无法开始路线缓动!"); return; } mPathTweener = TPathTweenerManager.Singleton.DoPathTweenByTPathDataExport(PathMoveGo.transform, mLoadedPathDataExport, ***) }
|
路点运行回调
目前路点运行回调支持了移动完成,经过路点,循环开始三个回调时机。
完成和循环开始主要是通过比较进度判定完成,经过路点的判定是通过一开始记录了所有的路点对应进度数据,然后结合当前进度去比较是否跨过下一个路点的进度来判定的。
TPathTeewner.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
|
public void Update(float deltaTime) { if(IsPaused) { return; } var preProgress = GetTimeProgress(mTimePassed); mTimePassed += deltaTime; var newProgress = GetTimeProgress(mTimePassed); UpdateTargetByPercent(newProgress); if(IsLoop && newProgress < preProgress) { OnPathLoopStart(); } if (CheckPathPointReached(preProgress, newProgress, out int reachedIndex)) { OnPassPathPoint(reachedIndex); } if (!IsLoop && Mathf.Approximately(newProgress, 1)) { OnPathTweenComplete(); } }
private bool CheckPathPointReached(float newProgress, out int reachedIndex) { reachedIndex = -1; if(mPath == null) { return false; } var pointNum = mPath.GetPointNum(); if(pointNum <= 0) { return false; } for(int i = mNextPathPointIndex; i < pointNum; i++) { var pointRatio = mPath.GetPointRatioByIndex(i); if(newProgress >= pointRatio) { reachedIndex = i; return true; } else { break; } } return false; }
|
运行时加载导出路点数据进行路点移动播放就会得到如下结果:
GameLauncher.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
private void OnBtnStartPathMove() { Debug.Log($"GameLaucher:OnBtnStartPathMove()"); if(mPathTweener != null) { TPathTweenerManager.Singleton.RemovePathTween(mPathTweener); mPathTweener = null; } if(mLoadedPathDataExport == null) { Debug.LogError($"没有加载有效的路点数据,无法开始路线缓动!"); return; } mPathTweener = TPathTweenerManager.Singleton.DoPathTweenByTPathDataExport(PathMoveGo.transform, mLoadedPathDataExport, () => { Debug.Log($"运行时路线缓动完成!"); mPathTweener = null; }, (index) => { Debug.Log($"经过路点:{index}"); var pathPointData = mLoadedPathDataExport.GetPathPointData(index); if(pathPointData != null) { Debug.Log($"路点数据:{pathPointData.ToString()}"); } }, () => { Debug.Log($"路线缓动循环开始!"); }); }
|


可以看到通过对应回调我们访问对应路点数据已经可以拿到路点类型+自定义路点数据了,如果未来想根据路点类型进行DIY逻辑编写,则可以直接在回调里访问数据编写对应逻辑即可。
TODO:
- 将运行时使用TPathTweenerManager运动的曲线支持可配置化可视化绘制
学习总结
- Bezier曲线是一个N个点之间递归插值计算的过程
- 复杂的很多点路线插值一般不会采用N阶Bezier曲线插值而是采用换算成N个3阶Bezier曲线插值的方式降低计算复杂度
- Catmull-Rom Spline确保通过首尾控制点是通过插入头尾两个虚拟点的方式实现的。
- 缓动结合Bezier曲线的使用主要体现在最后一步插值时对t值的运算替换上
- 纯Editor模拟更新驱动需要InitializeOnLoad,ExecuteInEditMode和InitializeOnLoadMethod标签加EditorApplication.update实现即使代码编译后也能正确注入和取消EditorApplication.update的流程
- LineRenerer默认useWorldSpace为true,表示设置的SetPositions是世界坐标。
- LineRenderer有两种朝向显示模式Alignment.View和Alignment.TransformZ,前者是类似BillBoard朝向摄像机,后者是朝向Z轴,一般纯3D路线个人觉得应该是后者加上旋转的方式。
- LineRenderer的纹理渲染(Texture Mode)方式有四种,Stretch(使用单次纹理拉伸铺满的方式),Tile(重复显示,渲染里Tile的概念,基于世界单位长度细分,使用Material.SetTextureScale来设置重复多少次纹理填充),DistributePerSegment(好像是每个顶点间映射一次纹理,默认假设顶点间间距已经平均好了),RepeatPerSegment(重复显示纹理,使用Material.SetTextureScale来设置重复多少次纹理填充)。
- LineRenderer修改Width后如果细分的点不够多,可能出现即使连接的是直线也在拐角处显示有问题,需要细分更多的点来解决此问题。
Github
个人Github:
数学缓动
UnityEditor知识
Reference
Unity 之 贝塞尔曲线介绍和实际使用
unity利用高阶贝塞尔曲线进行的轨道移动
Bezier Curve 贝塞尔曲线 - 在Unity中实现路径编辑
bezier-curves-unity-package-included
贝塞尔曲线
Bezier Curves
Catmull-Rom插值算法
插值技术之Catmull-Rom Spline Interpolating(2)