Unity中的曲线绘制(二)——贝塞尔曲线
发表于2016-11-02
贝塞尔曲线
贝塞尔曲线是一种特殊的曲线,它由一系列的点所定义。它开始于第一点结束于最后一个点,但并不需要经过中间点。中间点只用于端点插值,使得曲线平滑。


右图为二次贝塞尔曲线演示动画,t在[0,1]区间,可以看到曲线从p0开始,到p2结束,但是并不经过p1.
新建一个BezierCurve类,它包含三个点,同样定义Reset方法来初始化点的值,当绑定了BezierCurve脚本的对象创建或重置时,Unity editor会自动调用Reset方法。这个类代表了一个二次贝塞尔曲线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | using UnityEngine; public class BezierCurve : MonoBehaviour { public Vector3[] points; public void Reset () { points = new Vector3[] { new Vector3(1f, 0f, 0f), new Vector3(2f, 0f, 0f), new Vector3(3f, 0f, 0f) }; } } |
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 | using UnityEditor; using UnityEngine; [CustomEditor( typeof (BezierCurve))] public class BezierCurveInspector : Editor { private BezierCurve curve; private Transform handleTransform; private Quaternion handleRotation; private void OnSceneGUI () { curve = target as BezierCurve; handleTransform = curve.transform; handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; Vector3 p0 = ShowPoint(0); Vector3 p1 = ShowPoint(1); Vector3 p2 = ShowPoint(2); Handles.color = Color.white; Handles.DrawLine(p0, p1); Handles.DrawLine(p1, p2); } private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(curve.points[index]); EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(curve, "Move Point" ); EditorUtility.SetDirty(curve); curve.points[index] = handleTransform.InverseTransformPoint(point); } return point; } } |


贝塞尔曲线可以定义为一个参数方程。给定一个[0,1]区间内的参数t,就能得到曲线上的一个点,当t从0增至1,我们就能得到一条完整的贝塞尔曲线。
为了在场景中显示一条贝塞尔曲线,我们可以通过连续地画直线来模拟。做一个简单的循环,在点和点之间连续地绘制直线,并且将颜色设置为灰色。假设已经有GetPoint方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | private const int lineSteps = 10; private void OnSceneGUI () { curve = target as BezierCurve; handleTransform = curve.transform; handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; Vector3 p0 = ShowPoint(0); Vector3 p1 = ShowPoint(1); Vector3 p2 = ShowPoint(2); Handles.color = Color.gray; Handles.DrawLine(p0, p1); Handles.DrawLine(p1, p2); Handles.color = Color.white; Vector3 lineStart = curve.GetPoint(0f); for ( int i = 1; i <= lineSteps; i++) { Vector3 lineEnd = curve.GetPoint(i / ( float )lineSteps); Handles.DrawLine(lineStart, lineEnd); lineStart = lineEnd; } } |
1 2 3 | public Vector3 GetPoint ( float t) { return transform.TransformPoint(Bezier.GetPoint(points[0], points[1], points[2], t)); } |
1 2 3 4 5 | public static class Bezier { public static Vector3 GetPoint (Vector3 p0, Vector3 p1, Vector3 p2, float t) { return Vector3.Lerp(p0, p2, t); } } |

当然,端点之间的线性插值完全忽略了中间点。那么,我们如何将中间点也考虑进来呢?答案是多次插值。第一步,在起点和中间点之间进行插值,第二步在中间点和终点之间进行插值。这样就得到了两个点。再对这两个点进行一次插值,得到的结果就是贝塞尔曲线上的一个点。
1 2 3 | public static Vector3 GetPoint (Vector3 p0, Vector3 p1, Vector3 p2, float t) { return Vector3.Lerp(Vector3.Lerp(p0, p1, t), Vector3.Lerp(p1, p2, t), t); } |
这种曲线又被称为二次贝塞尔曲线,因为它的数学表示是二次多项式。
线性贝塞尔曲线:
。


升一阶就可以得到
,即将线性贝塞尔曲线中的P0和P1换成了两个新的插值点。整理后可以得到二次贝塞尔曲线的数学表示:
。



根据上面的公式我们可以用二次多项式来代替原代码中的Vector3.Lerp。
既然已经得到了二次贝塞尔曲线的多项式表示,我们可以写出它的一阶导:
,添加相关方法。

1 2 3 4 5 | public static Vector3 GetFirstDerivative (Vector3 p0, Vector3 p1, Vector3 p2, float t) { return 2f * (1f - t) * (p1 - p0) + 2f * t * (p2 - p1); } |
曲线上某个点的一阶导就是该点的切线,这个方法返回的值可以认为是点沿曲线移动的速度,因此我们可以在BezierCurve中添加GetVelocity方法来获取速度。
因为它返回的是一个速度的向量而非一个点,它同样会受到曲线对象位置的影响,因此我们需要对它进行位置变换。
1 2 3 4 | public Vector3 GetVelocity ( float t) { return transform.TransformPoint(Bezier.GetFirstDerivative(points[0], points[1], points[2], t)) - transform.position; } |
为什么这里要用TransformPoint而不是TransformDirection呢?
因为TransformDirection方法不会考虑缩放的转换,但是我们需要把这个因素也考虑进去。因此我们把速度向量先当成一个点来处理,得到转换后的结果后再减去曲线对象的位置,就能得到正确的速度向量,即使缩放系数为负数,这个方法也会生效。
现在我们也绘制一下速度,同样在BezierCurveInspector中的OnSceneGUI方法中进行。
1 2 3 4 5 6 7 8 9 10 11 | Vector3 lineStart = curve.GetPoint(0f); Handles.color = Color.green; Handles.DrawLine(lineStart, lineStart + curve.GetVelocity(0f)); for ( int i = 1; i <= lineSteps; i++) { Vector3 lineEnd = curve.GetPoint(i / ( float )lineSteps); Handles.color = Color.white; Handles.DrawLine(lineStart, lineEnd); Handles.color = Color.green; Handles.DrawLine(lineEnd, lineEnd + curve.GetVelocity(i / ( float )lineSteps)); lineStart = lineEnd; } |

我们可以很清楚地看到速度沿曲线变化的过程,但是这些线的长度太长,影响视图。我们改为只显示速度方向即可。
1 2 3 4 5 6 7 8 9 10 11 | Vector3 lineStart = curve.GetPoint(0f); Handles.color = Color.green; Handles.DrawLine(lineStart, lineStart + curve.GetDirection(0f)); for ( int i = 1; i <= lineSteps; i++) { Vector3 lineEnd = curve.GetPoint(i / ( float )lineSteps); Handles.color = Color.white; Handles.DrawLine(lineStart, lineEnd); Handles.color = Color.green; Handles.DrawLine(lineEnd, lineEnd + curve.GetDirection(i / ( float )lineSteps)); lineStart = lineEnd; } |
1 2 3 | public Vector3 GetDirection ( float t) { return GetVelocity(t).normalized; } |

接下来我们再升一阶,为Bezier添加新的方法,来处理三次贝塞尔曲线。和二次贝塞尔曲线类似,只不过它有四个点,其中两个控制点,两个起始点,共需要6次线性插值。整理后的曲线方程为:
,它的一阶导为
。



1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public static Vector3 GetPoint (Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float oneMinusT = 1f - t; return oneMinusT * oneMinusT * oneMinusT * p0 + 3f * oneMinusT * oneMinusT * t * p1 + 3f * oneMinusT * t * t * p2 + t * t * t * p3; } public static Vector3 GetFirstDerivative (Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { t = Mathf.Clamp01(t); float oneMinusT = 1f - t; return 3f * oneMinusT * oneMinusT * (p1 - p0) + 6f * oneMinusT * t * (p2 - p1) + 3f * t * t * (p3 - p2); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public Vector3 GetPoint ( float t) { return transform.TransformPoint(Bezier.GetPoint(points[0], points[1], points[2], points[3], t)); } public Vector3 GetVelocity ( float t) { return transform.TransformPoint( Bezier.GetFirstDerivative(points[0], points[1], points[2], points[3], t)) - transform.position; } public void Reset () { points = new Vector3[] { new Vector3(1f, 0f, 0f), new Vector3(2f, 0f, 0f), new Vector3(3f, 0f, 0f), new Vector3(4f, 0f, 0f) }; } |
1 2 3 4 5 6 7 8 | Vector3 p0 = ShowPoint(0); Vector3 p1 = ShowPoint(1); Vector3 p2 = ShowPoint(2); Vector3 p3 = ShowPoint(3); Handles.color = Color.gray; Handles.DrawLine(p0, p1); Handles.DrawLine(p2, p3); |

可以看到,我们利用直线绘制出来了初步的贝塞尔曲线,通过增加lineSteps的值,曲线会变得更为平滑。实际上,Unity已经封装了三次贝塞尔曲线的绘制方法 - Handles.DrawBezier
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 | private const float directionScale = 0.5f; private void OnSceneGUI () { curve = target as BezierCurve; handleTransform = curve.transform; handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; Vector3 p0 = ShowPoint(0); Vector3 p1 = ShowPoint(1); Vector3 p2 = ShowPoint(2); Vector3 p3 = ShowPoint(3); Handles.color = Color.gray; Handles.DrawLine(p0, p1); Handles.DrawLine(p2, p3); ShowDirections(); Handles.DrawBezier(p0, p3, p1, p2, Color.white, null , 2f); } private void ShowDirections () { Handles.color = Color.green; Vector3 point = curve.GetPoint(0f); Handles.DrawLine(point, point + curve.GetDirection(0f) * directionScale); for ( int i = 1; i <= lineSteps; i++) { point = curve.GetPoint(i / ( float )lineSteps); Handles.DrawLine(point, point + curve.GetDirection(i / ( float )lineSteps) * directionScale); } } |
