【GAD翻译馆】曲线和样条曲线-制作你自己的轨迹
译者: 陈敬凤(nunu) 审校:王磊(未来的未来)
这篇教程将会讲授从创建一条简单的线段到编辑你自己贝塞尔样条曲线的全部内容。 你在这篇文章中会学到以下这些东西:
l 创建自定义编辑器。
l 在场景视图里面进行绘制。
l 创建贝塞尔样条曲线并理解他们后面的数学含义。
l 绘制曲线及其运动方向。
l 通过组合曲线来构建贝塞尔样条曲线。
l 支持任意、对齐和镜像的控制点。
l 支持循环样条曲线。
l 沿着样条曲线移动和放置物体。
这篇教程的内容会基于以前的教程内容。如果你已经完成了迷宫教程(地址是:http://catlikecoding.com/unity/tutorials/maze/),那么就非常好,可以很容易继续阅读这里的内容。
这篇教程是使用Unity 4.5.2开发的。 它可能不适用于旧的Unity版本。
样条曲线非常有意思。
线段
让我们从创建一个线段组件来开始我们的教程。它需要两个点 - p0和p1 - 它们定义了从第一个点到第二个点的线段。
1 2 3 4 5 6 | using UnityEngine; public class Line : MonoBehaviour { public Vector3 p0, p1; } |
一个简单的线段组件。
虽然我们现在可以使用线段组件创建游戏对象并调整相应的点,但我们在场景中看不到任何东西。 当我们的线段被选中的时候,让我们提供一些有用的视觉信息。我们可以通过为我们的组件创建自定义的检视器来做到这一点。
与编辑器相关的代码需要放在一个Editor文件夹中,所以创建一个Editor文件夹并在其中放置一个新的LineInspector脚本。
检视器需继承自UnityEditor.Editor。 我们还必须给它UnityEditor.CustomEditor属性。 这让Unity知道它应该使用我们的类而不是线段组件的默认编辑器。
1 2 3 4 5 6 | using UnityEditor; using UnityEngine; [CustomEditor( typeof (Line))] public class LineInspector : Editor { } |
一个空的编辑器不会改变任何东西。 我们需要添加一个OnSceneGUI方法,它是一个特殊的Unity事件方法。 我们可以使用它在我们的组件的场景视图中绘制东西。
Editor类有一个target变量,它被设置为在调用OnSceneGUI的时候所绘制的对象。我们可以将此变量转换为一个线段,然后使用Handles实用程序类在我们的点之间来绘制一条线段。
1 2 3 4 5 6 | private void OnSceneGUI () { Line line = target as Line; Handles.color = Color.white; Handles.DrawLine(line.p0, line.p1); } |
显示一条线段。
我们现在能够看到一条线段,但是这条线段不会跟着变换的设置而发生相应的变化。 移动,旋转和缩放不会影响它们。 这是因为Handles 是在世界空间中运行,而线段点位于线段的本地空间。我们必须明确地将这些点转换成世界空间中的点。
1 2 3 4 5 6 7 8 9 | private void OnSceneGUI () { Line line = target as Line; Transform handleTransform = line.transform; Vector3 p0 = handleTransform.TransformPoint(line.p0); Vector3 p1 = handleTransform.TransformPoint(line.p1); Handles.color = Color.white; Handles.DrawLine(p0, p1); } |
没有变换到世界空间中的点和变换到世界空间中的点的效果对比图。
除了显示线段以外,我们还可以显示我们两点的位置句柄。 为了做到这一点,我们也需要我们的旋转信息,以便我们可以正确的对齐它们。
1 2 3 4 5 6 7 8 9 10 11 12 | private void OnSceneGUI () { Line line = target as Line; Transform handleTransform = line.transform; Quaternion handleRotation = handleTransform.rotation; Vector3 p0 = handleTransform.TransformPoint(line.p0); Vector3 p1 = handleTransform.TransformPoint(line.p1); Handles.color = Color.white; Handles.DrawLine(p0, p1); Handles.DoPositionHandle(p0, handleRotation); Handles.DoPositionHandle(p1, handleRotation); } |
虽然我们现在有了句柄的实现,但是它们不符合Unity的旋转模式。幸运的是,我们可以使用Tools.pivotRotation来确定当前模式并相应地设置旋转。
1 2 | Quaternion handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; |
本地空间的旋转和全局空间的旋转的效果对比图。
为了使手柄能够实际工作起来,我们需要将其结果返回给线段。但是,由于句柄的值是在世界空间中的,因此我们需要使用InverseTransformPoint方法将其转换回线段的本地空间。 另外,我们只需要当点改变的时候才这样做。我们可以为此使用EditorGUI.BeginChangeCheck和EditorGUI.EndChangeCheck。第二个方法能够告诉我们,在调用了第一个方法之后是否发生了更改。
1 2 3 4 5 6 7 8 9 10 | EditorGUI.BeginChangeCheck(); p0 = Handles.DoPositionHandle(p0, handleRotation); if (EditorGUI.EndChangeCheck()) { line.p0 = handleTransform.InverseTransformPoint(p0); } EditorGUI.BeginChangeCheck(); p1 = Handles.DoPositionHandle(p1, handleRotation); if (EditorGUI.EndChangeCheck()) { line.p1 = handleTransform.InverseTransformPoint(p1); } |
曲线
现在是将线段升级到曲线的时候了。 曲线就像一条线段,但它不需要是直线。 具体来说,我们将创建一个贝塞尔曲线。
贝塞尔曲线由点序列定义。贝塞尔曲线从第一点开始,在最后一点结束,但贝塞尔曲线不需要经过中间点。相反,这些点使贝塞尔曲线远离直线。
创建一个新的BezierCurve组件并给它一个点的数组。还要给它一个Reset方法,它用三点进行初始化。 该方法也可用作特殊的Unity方法,该方法在创建或重置组件的时候由编辑器调用。
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) }; } } |
我们还创建了一个基于LineInspector的曲线检视器。为了减少代码重复,我们将显示点的代码移动到单独的ShowPoint方法里去,在这个方法里我们可以使用索引调用该方法。我们还将curve,handleTransform和handleRotation转换为类变量,因此我们不需要将这三个变量传递给ShowPoint方法。
虽然它是一个新的脚本,但是我已经标记了这个脚本相对于LineInspector的差异。
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; } } |
一个三个点生成的贝塞尔曲线。
贝塞尔曲线的想法是整条曲线是参数化的。 如果你给它一个值 - 通常叫做t - 在零和一之间,你会得到一个点在曲线上。 当t从零增加到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; } } |
现在我们必须将GetPoint方法添加到BezierCurve中去,否则它将不会被编译。这里我们再次做出一个假设,这次有一个实用的贝塞尔类可以对任何一个点进行计算。把我们的点传给这个贝塞尔类,将得到的结果转换为世界空间的位置。
1 2 3 | public Vector3 GetPoint ( float t) { return transform.TransformPoint(Bezier.GetPoint(points[0], points[1], points[2], t)); } |
所以我们要添加一个静态Bezier类,并带有所需的方法。现在,让我们忽略中间点,简单地在第一个点和最后一个点之间进行线性内插。
1 2 3 4 5 6 7 8 | using UnityEngine; 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); } |
一条二次贝塞尔曲线。
这种曲线被称为二次贝塞尔曲线,因为涉及多项式数学。
线性曲线可以写为B(t)=(1-t)P0 + t P1。
更进一步,可以得到B(t)=(1-t)((1-t)P0 + t P1)+ t((1-t)P1 + t P2)。 这只是线性曲线,P0和P1被两条新的线性曲线取代。 它也可以被重写为更紧凑的形式B(t)=(1-t)2 P0 + 2(1-t)t P1 + t2 P2。
所以我们可以使用二次公式而不需要调用Vector3.Lerp函数三次。
1 2 3 4 5 6 7 8 | public static Vector3 GetPoint (Vector3 p0, Vector3 p1, Vector3 p2, float t) { t = Mathf.Clamp01(t); float oneMinusT = 1f - t; return oneMinusT * oneMinusT * p0 + 2f * oneMinusT * t * p1 + t * t * p2; } |
现在我们有一个多项式函数,我们也可以描述它的倒数。我们的二次贝塞尔曲线的一阶导数是B'(t)= 2(1-t)(P1-P0)+ 2t(P2-P1)。 让我们在代码里补充一下。
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); } |
这个函数能够产生与曲线相切的线段,可以将其解释为沿着曲线移动的速度。所以现在我们可以添加一个GetVelocity方法到BezierCurve里面。
因为它产生的是一个速度矢量而不是一个点,它不应该受到曲线的位置的影响,所以我们在变换后减去曲线的位置。
1 2 3 4 | public Vector3 GetVelocity ( float t) { return transform.TransformPoint(Bezier.GetFirstDerivative(points[0], points[1], points[2], t)) - transform.position; } |
现在我们可以在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; } |
这需要我们将GetDirection方法添加到BezierCurve中去,它会简单地对速度进行归一化。
1 2 3 | public Vector3 GetDirection ( float t) { return GetVelocity(t).normalized; } |
将方向显示出来。
让我们再进一步,为Bezier添加新的方法,以获得立体化的曲线! 它的工作原理就像二次贝塞尔曲线的版本,除了它需要第四点以外,所以公式会再进一步,导致六个线性插值的组合。其函数变为B(t) = (1 - t)3 P0 + 3 (1- t)2 t P1 + 3 (1 - t),它的其一阶导数是B'(t)= 3 (1 - t)2 (P1 - P0) + 6 (1 - t) t (P2 -P1) + 3 t2 (P3 - P2).。
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); } |
有了这个以后,我们可以通过考虑另外一点来将BezierCurve从二次曲线升级到三次曲线。 确保将第四个点手动添加到其数组中,或者重置该组件。
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) }; } |
BezierCurveInspector现在需要更新,以便来显示第四个点。
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); |
三次贝塞尔曲线的效果。
现在我们可以很明显地看出,我们是在用直的线段来绘制曲线。我们可以增加分段的数量来提高视觉的质量。我们也可以使用迭代方法来获得精确到像素级别的视觉质量。但是我们也可以使用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); } } |
使用Handles.DrawBezier方法,并缩放代表方向的线段。
样条曲线
有一条单独的曲线很好,但是要创建复杂的路径,我们需要连接多条曲线。这样的一个连接被成为样条曲线。让我们通过复制BezierCurve代码来创建一个类,并将类型更改为BezierSpline。
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 | using UnityEngine; public class BezierSpline : MonoBehaviour { public Vector3[] points; 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 Vector3 GetDirection ( float t) { return GetVelocity(t).normalized; } 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) }; } } |
我们通过复制和调整BezierCurveInspector的代码针对这个类创建了一个编辑器。然后,我们可以创建样条曲线对象并进行编辑,这里做的事情就像对曲线做的事情一样。
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 | using UnityEditor; using UnityEngine; [CustomEditor( typeof (BezierSpline))] public class BezierSplineInspector : Editor { private const int lineSteps = 10; private const float directionScale = 0.5f; private BezierSpline spline; private Transform handleTransform; private Quaternion handleRotation; private void OnSceneGUI () { spline = target as BezierSpline; handleTransform = spline.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 = spline.GetPoint(0f); Handles.DrawLine(point, point + spline.GetDirection(0f) * directionScale); for ( int i = 1; i <= lineSteps; i++) { point = spline.GetPoint(i / ( float )lineSteps); Handles.DrawLine(point, point + spline.GetDirection (i / ( float )lineSteps) * directionScale); } } private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline.points [index]); EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.points[index] = handleTransform. InverseTransformPoint (point); } return point; } } |
一个新的样条曲线类型。
让我们给BezierSpline添加一个方法来为样条曲线添加另一条曲线。 因为我们希望样条曲线是连续的,所以前一条曲线的最后一点与下一条曲线的第一点要相同。所以每个额外的曲线会增加三个控制点。
1 2 3 4 5 6 7 8 9 10 | public void AddCurve () { Vector3 point = points[points.Length - 1]; Array.Resize( ref points, points.Length + 3); point.x += 1f; points[points.Length - 3] = point; point.x += 1f; points[points.Length - 2] = point; point.x += 1f; points[points.Length - 1] = point; } |
我们使用Array.Resize方法来创建一个更大的数组来保存新的控制点。 它位于system命名空间内,所以我们应该声明下,我们在我们的脚本的顶部使用这个声明。
1 2 | using UnityEngine; using System; |
为了能够添加曲线,我们必须在样条曲线的检视器中添加一个按钮。我们可以通过覆盖BezierSplineInspector的OnInspectorGUI方法来定制Unity用于组件的检视器。 请注意,这不是一个特殊的Unity方法,它依赖于继承。
要继续绘制默认的检视器,我们调用DrawDefaultInspector方法。 然后我们使用GUILayout来绘制一个按钮,当点击的时候会添加一个曲线。
1 2 3 4 5 6 7 8 9 | public override void OnInspectorGUI () { DrawDefaultInspector(); spline = target as BezierSpline; if (GUILayout.Button( "Add Curve" )) { Undo.RecordObject(spline, "Add Curve" ); spline.AddCurve(); EditorUtility.SetDirty(spline); } } |
添加一条曲线。
当然我们还是能够看到第一条曲线。所以我们调整BezierSplineInspector,使其循环遍历所有的曲线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | private void OnSceneGUI () { spline = target as BezierSpline; handleTransform = spline.transform; handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; Vector3 p0 = ShowPoint(0); for ( int i = 1; i < spline.points.Length; i += 3) { Vector3 p1 = ShowPoint(i); Vector3 p2 = ShowPoint(i + 1); Vector3 p3 = ShowPoint(i + 2); Handles.color = Color.gray; Handles.DrawLine(p0, p1); Handles.DrawLine(p2, p3); Handles.DrawBezier(p0, p3, p1, p2, Color.white, null , 2f); p0 = p3; } ShowDirections(); } |
整个样条曲线的全貌。
现在我们可以看到所有的曲线,但方向线段只对第一条曲线做了添加。这是因为BezierSpline的方法也只适用于第一条曲线。现在是改变这个实现的时候了。
为了覆盖整个样条曲线从t等于零到t等于一的范围,我们首先要弄清楚我们现在在哪条曲线上。我们可以通过将t乘以曲线的数量,然后丢弃分数部分来获得曲线的索引。 让我们添加一个CurveCount属性,使其变得简单。
1 2 3 4 5 | public int CurveCount { get { return (points.Length - 1) / 3; } } |
之后,我们可以将t减小到小数部分,得到我们曲线的内插值。 要得到实际的控制点,我们必须将曲线索引乘以3。
然而,当原始的t等于1的时候,之前的方法将失败。 在这种情况下,我们可以将其设置为最后一条曲线。
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 | public Vector3 GetPoint ( float t) { int i; if (t >= 1f) { t = 1f; i = points.Length - 4; } else { t = Mathf.Clamp01(t) * CurveCount; i = ( int )t; t -= i; i *= 3; } return transform.TransformPoint(Bezier.GetPoint( points[i], points[i + 1], points[i + 2], points[i + 3], t)); } public Vector3 GetVelocity ( float t) { int i; if (t >= 1f) { t = 1f; i = points.Length - 4; } else { t = Mathf.Clamp01(t) * CurveCount; i = ( int )t; t -= i; i *= 3; } return transform.TransformPoint(Bezier.GetFirstDerivative( points[i], points[i + 1], points[i + 2], points[i + 3], t)) - transform.position; } |
我们现在可以看到整个样条曲线的方向线段,但是我们可以通过确保每个曲线段获得相同的线段数量来改善可视化的效果。幸运的是,很容易对BezierSplineInspector.ShowDirections做改变,所以它使用BezierSpline.CurveCount来确定要绘制多少个线段。
1 2 3 4 5 6 7 8 9 10 11 12 | private const int stepsPerCurve = 10; private void ShowDirections () { Handles.color = Color.green; Vector3 point = spline.GetPoint(0f); Handles.DrawLine(point, point + spline.GetDirection(0f) * directionScale); int steps = stepsPerCurve * spline.CurveCount; for ( int i = 1; i <= steps; i++) { point = spline.GetPoint(i / ( float )steps); Handles.DrawLine(point, point + spline.GetDirection (i / ( float )steps) * directionScale); } } |
整个样条曲线的方向线段。
有了这些变换手柄以后就变得相当拥挤。我们可以只给激活的点显示一个变换手柄,而其他的点只要有一个点就好了。
我们来更新下ShowPoint,以便它显示一个按钮,而不是位置句柄。该按钮将看起来像是一个白点,当点击时的时候变成激活点。然后我们只显示位置句柄,如果点的索引与所选的索引匹配,我们初始化为-1,默认情况下没有选中任何点。
那么只有在选中的点的索引与所选索引匹配的话,我们才显示位置句柄,因为我们初始化为-1,所以在默认情况下没有选中的点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | private const float handleSize = 0.04f; private const float pickSize = 0.06f; private int selectedIndex = -1; private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline.points [index]); Handles.color = Color.white; if (Handles.Button(point, handleRotation, handleSize, pickSize, Handles.DotCap)) { selectedIndex = index; } if (selectedIndex == index) { EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.points[index] = handleTransform. InverseTransformPoint(point); } } return point; } |
显示点的效果。
这个方法是有效的,但是很难对于点来说获得一个很好的尺寸。 根据你工作的规模的大小,他们可能会太大或是太小。如果我们可以保持点的大小和屏幕尺寸的比例固定,就像位置手柄总是具有相同的屏幕尺寸大小,那么效果会很好。我们可以通过HandleUtility.GetHandleSize的系数来做到这一点。 这种方法为我们提供了世界空间中任何点的固定屏幕大小。
1 2 3 4 5 | float size = HandleUtility.GetHandleSize(point); Handles.color = Color.white; if (Handles.Button(point, handleRotation, size * handleSize, size * pickSize, Handles.DotCap)) { selectedIndex = index; } |
带有固定屏幕大小的点的效果。
给控制点添加约束
虽然我们的样条曲线是连续的,但是我们的样条曲线在曲线段之间会急剧的改变方向。方向和速度的这些突然变化是可能的,这是因为两条曲线之间的共享控制点具有与之相关联的两个不同的速度,每个曲线具有一个不同的速度。
如果我们希望速度相等,那么我们必须确保定义它们的两个控制点 - 前一个曲线的三分之一位置的控制点和下一个曲线的第二个控制点 - 在共享控制点之间相互镜像。这样就能确保组合起来的曲线的一阶导数和二阶导数是连续的。
或者,我们可以对齐它们,但是让它们与共享控制点的距离不同。这将导致速度变化,但是同时仍然保持方向连续。在这种情况下,组合起来的曲线的一阶导数是连续的,但组合起来的曲线的二阶导数不是连续的。
最灵活的方法是根据每个曲线边界来确定哪些约束应该适用,所以我们采用这样的方法。当然,一旦我们有了这些限制,我们就不能让任何人直接编辑贝塞尔样条曲线的控制点。所以我们让我们的数组是私有的,并提供间接的访问。确保让Unity知道我们仍然希望序列化我们的控制点,否则这些控制点不会被保存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | [SerializeField] private Vector3[] points; public int ControlPointCount { get { return points.Length; } } public Vector3 GetControlPoint ( int index) { return points[index]; } public void SetControlPoint ( int index, Vector3 point) { points[index] = point; } |
现在BezierSplineInspector必须使用新的方法和属性,而不是直接访问控制点数组。
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 | private void OnSceneGUI () { spline = target as BezierSpline; handleTransform = spline.transform; handleRotation = Tools.pivotRotation == PivotRotation.Local ? handleTransform.rotation : Quaternion.identity; Vector3 p0 = ShowPoint(0); for ( int i = 1; i < spline.ControlPointCount; i += 3) { Vector3 p1 = ShowPoint(i); Vector3 p2 = ShowPoint(i + 1); Vector3 p3 = ShowPoint(i + 2); Handles.color = Color.gray; Handles.DrawLine(p0, p1); Handles.DrawLine(p2, p3); Handles.DrawBezier(p0, p3, p1, p2, Color.white, null , 2f); p0 = p3; } ShowDirections(); } private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline. GetControlPoint(index)); float size = HandleUtility.GetHandleSize(point); Handles.color = Color.white; if (Handles.Button(point, handleRotation, size * handleSize, size * pickSize, Handles.DotCap)) { selectedIndex = index; } if (selectedIndex == index) { EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(index, handleTransform. InverseTransformPoint(point)); } } return point; } |
当我们在现在这个情况下,我们也不再希望允许直接访问检视器中的数组,因此删除对DrawDefaultInspector的调用。 但是仍然允许通过打字来进行更改,让我们来显示所选点的向量字段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public override void OnInspectorGUI () { spline = target as BezierSpline; if (selectedIndex >= 0 && selectedIndex < spline.ControlPointCount) { DrawSelectedPointInspector(); } if (GUILayout.Button( "Add Curve" )) { Undo.RecordObject(spline, "Add Curve" ); spline.AddCurve(); EditorUtility.SetDirty(spline); } } private void DrawSelectedPointInspector() { GUILayout.Label( "Selected Point" ); EditorGUI.BeginChangeCheck(); Vector3 point = EditorGUILayout.Vector3Field( "Position" , spline.GetControlPoint(selectedIndex)); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(selectedIndex, point); } } |
不幸的是,事实证明,当我们在场景视图中选中一个点的时候,检视器不会自动刷新。我们可以通过为样条曲线调用SetDirty方法来解决这个问题,但是得到的结果并不正确,这是因为样条曲线并没有改变。幸运的是,我们可以发出重新绘制请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline. GetControlPoint(index)); float size = HandleUtility.GetHandleSize(point); Handles.color = Color.white; if (Handles.Button(point, handleRotation, size * handleSize, size * pickSize, Handles.DotCap)) { selectedIndex = index; Repaint(); } if (selectedIndex == index) { EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(index, handleTransform. InverseTransformPoint(point)); } } return point; } |
只有选中的点。
让我们定义一个枚举类型来描述我们的三种模式。 创建一个新脚本,删除默认代码,并使用三个选项来定义一个枚举。
1 2 3 4 5 | public enum BezierControlPointMode { Free, Aligned, Mirrored } |
现在我们可以将这些模式添加到BezierSpline中。 我们只需要将模式存储在曲线之间,所以让我们把它们放在长度等于曲线数量加一的数组中。你需要重新设置样条曲线或是创建一个新的样条曲线,以确保你具有正确大小的数组。
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 | [SerializeField] private BezierControlPointMode[] modes; public void AddCurve () { Vector3 point = points[points.Length - 1]; Array.Resize( ref points, points.Length + 3); point.x += 1f; points[points.Length - 3] = point; point.x += 1f; points[points.Length - 2] = point; point.x += 1f; points[points.Length - 1] = point; Array.Resize( ref modes, modes.Length + 1); modes[modes.Length - 1] = modes[modes.Length - 2]; } 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) }; modes = new BezierControlPointMode[] { BezierControlPointMode.Free, BezierControlPointMode.Free }; } |
虽然我们将模式存储在曲线之间,但是如果我们可以得到并设置每个控制点的模式,这是很方便的。因此,我们需要将控制点的索引转换为模式索引,因为在现实中控制点会共享模式。 作为示例,控制点索引序列0,1,2,3,4,5,6对应于模式索引序列0,0,1,1,2,2。因此,我们需要加一然后除以三。
1 2 3 4 5 6 7 | public BezierControlPointMode GetControlPointMode ( int index) { return modes[(index + 1) / 3]; } public void SetControlPointMode ( int index, BezierControlPointMode mode) { modes[(index + 1) / 3] = mode; } |
现在BezierSplineInspector可以让我们改变所选点的模式。 你会注意到,改变一个点的模式似乎也改变了与它相关联的点的模式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | private void DrawSelectedPointInspector() { GUILayout.Label( "Selected Point" ); EditorGUI.BeginChangeCheck(); Vector3 point = EditorGUILayout.Vector3Field( "Position" , spline.GetControlPoint(selectedIndex)); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(selectedIndex, point); } EditorGUI.BeginChangeCheck(); BezierControlPointMode mode = (BezierControlPointMode) EditorGUILayout.EnumPopup( "Mode" , spline.GetControlPointMode (selectedIndex)); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Change Point Mode" ); spline.SetControlPointMode(selectedIndex, mode); EditorUtility.SetDirty(spline); } } |
现在可以调节控制点的模式了。
如果我们在场景视图中也能获得有关我们的控制点类型的一些视觉反馈,这将是非常有用的。我们可以通过对控制点着色来轻松的添加这些视觉反馈。我会使用白色来对自由的控制点着色,用黄色来对对齐的控制点着色,用黄色来对镜像的控制点着色。
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 | private static Color[] modeColors = { Color.white, Color.yellow, Color.cyan }; private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline. GetControlPoint(index)); float size = HandleUtility.GetHandleSize(point); Handles.color = modeColors[( int )spline.GetControlPointMode (index)]; if (Handles.Button(point, handleRotation, size * handleSize, size * pickSize, Handles.DotCap)) { selectedIndex = index; Repaint(); } if (selectedIndex == index) { EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(index, handleTransform. InverseTransformPoint(point)); } } return point; } |
现在控制点已经成功着色了。
到目前为止,我们只是给控制点着色。 现在是执行约束的时候了。我们向BezierSpline添加了一个新方法,当移动一个点或是更改模式的时候,调用这个方法。 它需要一个控制点的索引,并通过检索相关模式开始。
1 2 3 4 5 6 7 8 9 10 11 12 13 | public void SetControlPoint ( int index, Vector3 point) { points[index] = point; EnforceMode(index); } public void SetControlPointMode ( int index, BezierControlPointMode mode) { modes[(index + 1) / 3] = mode; EnforceMode(index); } private void EnforceMode ( int index) { int modeIndex = (index + 1) / 3; } |
我们应该检查我们是否真的不必执行任何事情。当模式设置为“自由”的时候,或是当我们在曲线的终点的时候,就是这种情况。 在这些情况下,我们可以直接返回而不做任何事情。
1 2 3 4 5 6 7 | private void EnforceMode ( int index) { int modeIndex = (index + 1) / 3; BezierControlPointMode mode = modes[modeIndex]; if (mode == BezierControlPointMode.Free || modeIndex == 0 || modeIndex == modes.Length - 1) { return ; } } |
现在哪一个控制点应该调整?当我们改变控制点的模式的时候,它要么是曲线之间的控制点或是其邻居之一。 当我们选中中间的控制点的时候,我们可以将上一个控制点保持固定,并对相反方向的控制点加以约束。如果我们选中的是其他的控制点,那么我们应该把它固定,并调整相反方向上的点。这样我们的选中点总是保持原样。所以让我们来定义这些点的索引。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | if (mode == BezierControlPointMode.Free || modeIndex == 0 || modeIndex == modes.Length - 1) { return ; } int middleIndex = modeIndex * 3; int fixedIndex, enforcedIndex; if (index <= middleIndex) { fixedIndex = middleIndex - 1; enforcedIndex = middleIndex + 1; } else { fixedIndex = middleIndex + 1; enforcedIndex = middleIndex - 1; } |
我们首先考虑镜像的情况。要围绕中间的控制点进行镜像,我们必须取得从中间的控制点到固定点的向量,并将其反转。 得到强制的切线,并将其添加到中间来赋予我们的强制点。
1 2 3 4 5 6 7 8 9 10 11 12 | if (index <= middleIndex) { fixedIndex = middleIndex - 1; enforcedIndex = middleIndex + 1; } else { fixedIndex = middleIndex + 1; enforcedIndex = middleIndex - 1; } Vector3 middle = points[middleIndex]; Vector3 enforcedTangent = middle - points[fixedIndex]; points[enforcedIndex] = middle + enforcedTangent; |
对于对齐模式,我们还必须确保新的切线的长度与旧的切线的长度相同。所以我们把它归一化,然后乘以中间点和旧的强制点之间的距离。
1 2 3 4 5 | Vector3 enforcedTangent = middle - points[fixedIndex]; if (mode == BezierControlPointMode.Aligned) { enforcedTangent = enforcedTangent.normalized * Vector3. Distance(middle, points[enforcedIndex]); } points[enforcedIndex] = middle + enforcedTangent; |
强制约束。
从现在开始,无论何时移动点或是更改点的模式,约束将被强制执行。但是当移动中间控制点的时候,上一个控制点始终保持固定,并且下一个控制点总是被强制执行。这可能效果很好,但如果其他两个控制点与中间控制点一起移动的话,是很直观的。所以我们来调整SetControlPoint,使它们一起移动。
1 2 3 4 5 6 7 8 9 10 11 12 13 | public void SetControlPoint ( int index, Vector3 point) { if (index % 3 == 0) { Vector3 delta = point - points[index]; if (index > 0) { points[index - 1] += delta; } if (index + 1 < points.Length) { points[index + 1] += delta; } } points[index] = point; EnforceMode(index); } |
为了能够衔接起来,我们还应该确保在添加曲线的时候强制执行约束。我们可以通过简单的调用EnforceMode方法来添加新的曲线。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public void AddCurve () { Vector3 point = points[points.Length - 1]; Array.Resize( ref points, points.Length + 3); point.x += 1f; points[points.Length - 3] = point; point.x += 1f; points[points.Length - 2] = point; point.x += 1f; points[points.Length - 1] = point; Array.Resize( ref modes, modes.Length + 1); modes[modes.Length - 1] = modes[modes.Length - 2]; EnforceMode(points.Length - 4); } |
还有另外一个限制,我们可以添加。通过让第一个控制点和最后一个控制点共享相同的位置,我们可以将我们的样条曲线变成循环。当然,我们也要考虑现在使用的模式。
所以我们给BezierSpline添加一个循环属性。 无论何时设置为true的时候,我们都会确保端点的模式匹配,并调用SetPosition方法,相信它将处理位置和模式约束的事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [SerializeField] private bool loop; public bool Loop { get { return loop; } set { loop = value; if (value == true ) { modes[modes.Length - 1] = modes[0]; SetControlPoint(0, points[0]); } } } |
现在我们可以将循环属性添加到BezierSplineInspector中去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public override void OnInspectorGUI () { spline = target as BezierSpline; EditorGUI.BeginChangeCheck(); bool loop = EditorGUILayout.Toggle( "Loop" , spline.Loop); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Toggle Loop" ); EditorUtility.SetDirty(spline); spline.Loop = loop; } if (selectedIndex >= 0 && selectedIndex < spline. ControlPointCount) { DrawSelectedPointInspector(); } if (GUILayout.Button( "Add Curve" )) { Undo.RecordObject(spline, "Add Curve" ); spline.AddCurve(); EditorUtility.SetDirty(spline); } } |
可选的循环属性。
要确保曲线的循环是正确的,我们需要对BezierSpline进行一些更改。
首先,SetControlPointMode方法需要确保在循环的情况下,第一个和最后一个模式保持相等。
1 2 3 4 5 6 7 8 9 10 11 12 13 | public void SetControlPointMode ( int index, BezierControlPointMode mode) { int modeIndex = (index + 1) / 3; modes[modeIndex] = mode; if (loop) { if (modeIndex == 0) { modes[modes.Length - 1] = mode; } else if (modeIndex == modes.Length - 1) { modes[0] = mode; } } EnforceMode(index); } |
接下来,SetControlPoint在处理循环时需要不同的边情况,因为它需要围绕控制点数组来衔接上。
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 | public void SetControlPoint ( int index, Vector3 point) { if (index % 3 == 0) { Vector3 delta = point - points[index]; if (loop) { if (index == 0) { points[1] += delta; points[points.Length - 2] += delta; points[points.Length - 1] = point; } else if (index == points.Length - 1) { points[0] = point; points[1] += delta; points[index - 1] += delta; } else { points[index - 1] += delta; points[index + 1] += delta; } } else { if (index > 0) { points[index - 1] += delta; } if (index + 1 < points.Length) { points[index + 1] += delta; } } } points[index] = point; EnforceMode(index); } |
接下来,EnforceMode现在只能在没有循环的情况下对终点进行控制。 它还必须检查固定或强制控制点是否包围在数组周围。
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 | private void EnforceMode ( int index) { int modeIndex = (index + 1) / 3; BezierControlPointMode mode = modes[modeIndex]; if (mode == BezierControlPointMode.Free || !loop && (modeIndex == 0 || modeIndex == modes.Length - 1)) { return ; } int middleIndex = modeIndex * 3; int fixedIndex, enforcedIndex; if (index <= middleIndex) { fixedIndex = middleIndex - 1; if (fixedIndex < 0) { fixedIndex = points.Length - 2; } enforcedIndex = middleIndex + 1; if (enforcedIndex >= points.Length) { enforcedIndex = 1; } } else { fixedIndex = middleIndex + 1; if (fixedIndex >= points.Length) { fixedIndex = 1; } enforcedIndex = middleIndex - 1; if (enforcedIndex < 0) { enforcedIndex = points.Length - 2; } } Vector3 middle = points[middleIndex]; Vector3 enforcedTangent = middle - points[fixedIndex]; if (mode == BezierControlPointMode.Aligned) { enforcedTangent = enforcedTangent.normalized * Vector3.Distance(middle, points[enforcedIndex]); } points[enforcedIndex] = middle + enforcedTangent; } |
最后,当向样条曲线添加曲线的时候,我们也必须考虑到循环。结果可能是一个缠绕的结果,但它将保持一个适当的循环。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public void AddCurve () { Vector3 point = points[points.Length - 1]; Array.Resize( ref points, points.Length + 3); point.x += 1f; points[points.Length - 3] = point; point.x += 1f; points[points.Length - 2] = point; point.x += 1f; points[points.Length - 1] = point; Array.Resize( ref modes, modes.Length + 1); modes[modes.Length - 1] = modes[modes.Length - 2]; EnforceMode(points.Length - 4); if (loop) { points[points.Length - 1] = points[0]; modes[modes.Length - 1] = modes[0]; EnforceMode(0); } } |
一个样条曲线循环起来的效果。
很好,现在我们有循环了,但是我们现在看到样条曲线开始的位置是很不方便的。 通过让BezierSplineInspector总是将第一个控制点的大小加倍,可以使这一点显而易见。
请注意,在循环的最后一点将被绘制在第一个点的顶部,所以如果你点击大的控制点的中心,你将选择最后一个控制点,而如果你点击的是大的控制点中心偏离一点的位置,那么你就能选中第一个控制点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | private Vector3 ShowPoint ( int index) { Vector3 point = handleTransform.TransformPoint(spline. GetControlPoint(index)); float size = HandleUtility.GetHandleSize(point); if (index == 0) { size *= 2f; } Handles.color = modeColors[( int )spline.GetControlPointMode(index)]; if (Handles.Button(point, handleRotation, size * handleSize, size * pickSize, Handles.DotCap)) { selectedIndex = index; Repaint(); } if (selectedIndex == index) { EditorGUI.BeginChangeCheck(); point = Handles.DoPositionHandle(point, handleRotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(spline, "Move Point" ); EditorUtility.SetDirty(spline); spline.SetControlPoint(index, handleTransform. InverseTransformPoint(point)); } } return point; } |
我们是从大的那个控制点开始的。
使用样条曲线
我们已经在样条曲线上花费了不少时间了,但是我们到现在为止还没有使用它们。使用样条线可以进行无数的操作,比如说是沿着对象的轨迹来移动对象。让我们来创建一个SplineWalker组件来做这个事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | using UnityEngine; public class SplineWalker : MonoBehaviour { public BezierSpline spline; public float duration; private float progress; private void Update () { progress += Time.deltaTime / duration; if (progress > 1f) { progress = 1f; } transform.localPosition = spline.GetPoint(progress); } } |
现在我们可以创建一个walker对象,并将我们的样条曲线赋予给它,设置一个持续时间,并且在我们进入播放模式之后观察它的移动。 我只是简单的使用一个立方体来表示它,并给它一个小的多的立方体来表示眼睛,所以你可以看到它在往哪个方向看。
沿着曲线进行移动。
walker现在能够移动了,但是它并没有朝着它正在移动的方向看。我们可以针对这个事情添加一个选项。
1 2 3 4 5 6 7 8 9 10 11 12 13 | public bool lookForward; private void Update () { progress += Time.deltaTime / duration; if (progress > 1f) { progress = 1f; } Vector3 position = spline.GetPoint(progress); transform.localPosition = position; if (lookForward) { transform.LookAt(position + spline.GetDirection(progress)); } } |
让物体往正在移动的方向看。
另外一个选择是让样条不断循环,而不是只走一次。当我们在上面移动的时候,我们也可以让walker来回移动,不停的往返移动。让我们来创建一个枚举类型来在这些模式中进行选择。
1 2 3 4 5 | public enum SplineWalkerMode { Once, Loop, PingPong } |
现在SplineWalker必须记住它是在前进还是在后退。 在通过样条曲线的终点的时候,它还需要根据模式来调整进度。
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 | public SplineWalkerMode mode; private bool goingForward = true ; private void Update () { if (goingForward) { progress += Time.deltaTime / duration; if (progress > 1f) { if (mode == SplineWalkerMode.Once) { progress = 1f; } else if (mode == SplineWalkerMode.Loop) { progress -= 1f; } else { progress = 2f - progress; goingForward = false ; } } } else { progress -= Time.deltaTime / duration; if (progress < 0f) { progress = -progress; goingForward = true ; } } Vector3 position = spline.GetPoint(progress); transform.localPosition = position; if (lookForward) { transform.LookAt(position + spline.GetDirection(progress)); } } |
可以用不同的方式来移动。
我们可以做的另一件事是创建一个装饰器,它在唤醒的时候沿着样条曲线来实例化一系列的物件。我们也给它一个往前看的选项,这样在它唤醒的时候可以对这些物件进行更改。物件的序列具有一定的频率,允许重复。当然,如果频率为零或是没有物件的话,我们什么也不做。
我们需要一些物件,所以也要为了这个目的来创建一些预制件。
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 | using UnityEngine; public class SplineDecorator : MonoBehaviour { public BezierSpline spline; public int frequency; public bool lookForward; public Transform[] items; private void Awake () { if (frequency <= 0 || items == null || items.Length == 0) { return ; } float stepSize = 1f / (frequency * items.Length); for ( int p = 0, f = 0; f < frequency; f++) { for ( int i = 0; i < items.Length; i++, p++) { Transform item = Instantiate(items[i]) as Transform; Vector3 position = spline.GetPoint(p * stepSize); item.transform.localPosition = position; if (lookForward) { item.transform.LookAt(position + spline. GetDirection(p * stepSize)); } item.transform.parent = transform; } } } } |
让我们来装饰下样条曲线。
这在循环的情况下工作的很好,但是对于那些不循环的样条曲线的终点来说可能会有问题。我们可以通过增加步长来覆盖样条曲线的整个长度来解决这个问题,只要它不是一个循环就可了,并且我们有多个物件可以放置。
1 2 3 4 5 6 7 8 9 10 | if (frequency <= 0 || items == null || items.Length == 0) { return ; } float stepSize = frequency * items.Length; if (spline.Loop || stepSize == 1) { stepSize = 1f / stepSize; } else { stepSize = 1f / (stepSize - 1); } |
修改以后,工作的很好。
还有更多的方法来使用样条曲线,而且还有更多的功能可以添加到样条线本身。 比如说是移除曲线,或者将曲线分成两个小的曲线,或者将两个曲线合并在一起。还有其他类型的样条曲线可供探索,比如硕士Centripetal Catmull-Rom或是NURB。如果你掌握了贝塞尔曲线的话,你应该能够自己处理这些样条曲线。所以这篇教程会在这里就结束了,其余的部分留给你自己去探索!
你喜欢这个教程么?如果你喜欢这个教程的话,在patron上面进行赞助来帮助我做更多的内容!
可供下载的链接:
有了线段以后的项目工程文件。
有了曲线以后的项目工程文件。
有了样条曲线以后的项目工程文件。
对控制点做了约束以后的项目工程文件。
.最终完成的项目工程文件。
问答部分
Typeof是做什么的?
typeof运算符用于获取某些东西的类型对象,通常是一个类。你不能将它用于变量,只能用于显式类型名称。
为什么不直接写下类的名字? 因为这会导致编译错误! 需要额外的步骤是因为你需要将一个类型转换为一个变量。
编辑器文件夹是否需要?
Unity将项目分解成按特定顺序编译的多个部分。编辑器文件夹用于将与编辑器相关的所有内容与不相关的所有内容所分开。编辑器文件夹的内容不包括在游戏构建中,而其外的代码无法访问它。举个简单的例子来说, LineInspector需要知道Line,反过来则不是。
Vector3.Lerp是如何工作?
Vector3.Lerp方法执行两个向量或是点之间的线性插值。线性插值意味着你从第一个值开始并以第二个值结束,以它们之间的恒定速度在直线上移动。
在数学上,这种插值是通过提供一个参数(通常称为t )来指定沿着这个过程移动有多远。 它从零开始,以一来结束。
所以Vector3.Lerp(a,b,0f)的结果应该是a,Vector3.Lerp(a,b,1f)的结果应该是b,而Vector3.Lerp(a,b,0.5f) 应该是他们的平均值。 这是通过将第一个参数乘以(1f-t),将第二个参数乘以t,然后将它们相加来完成的。
因为这只有当t在0-1范围内的时候才有效,所以得到的值会被限制在两个值的之间。
DrawBezier是如何工作的?
这个方法有点奇怪,其参数列表以终点开始,后跟两个中间点。 中间点被命名为切线,但它们被预期为实际的控制点,而不是方向向量。
颜色参数是显而易见的,但是它也需要纹理和宽度信息。宽度信息的单位是像素,如果你想要一个反锯齿的外观那么它应该是2。 纹理也需要有一个特定的形式来允许反锯齿,尽管默认值也能工作正常,我总是提供null。
什么是导数?
函数的导数会衡量函数的变化率,它本身也是一个函数。
举个简单的例子来说,函数f(t)= 3是一个常数,因此其导数为f'(t)= 0。
让我们举另外一个例子,f(t)= t是线性的,所以它的变化率是恒定的f'(t)= 1。与f(t)= 2 t可以进行比较,它的导数是f'(t)= 2。
让我们跳到二次函数的情况具有线性导数f'(t)= 2 t,这意味着它增长的更快。
组合也能顺利的工作。 具有导数f'(t)= 2t + 3 + 0。
通常来说,只要n大于零,会变为。还有更复杂的规则,但你不需要处理贝塞尔曲线的导数。
那么我们如何得到的一阶导数?
需要注意的是,重写为,它具有导数2t-2,2(1-t)t重写为。它的导数是2 -4t。
所以我们最终得到的是。
然后我们重写一下,将P1部分转换为 - (2 t - 2)P1 - 2 tP1。 这使得我们可以将其与P0和P2部分组合,所以我们得到B'(t)=(2 t - 2)(P0 - P1)+ 2t(P2 - P1)。
作为最后一步,我们将第一项和第二项反转,从而得到漂亮的B'(t)= 2(1-t)(P1-P0)+ 2t(P2-P1)。
二阶导数怎么办?
二阶导数是一阶导数的导数,它定义了沿曲线的加速度 - 速度的变化。 对于二次贝塞尔曲线,它是B“(t)= 2(P2 - 2 P1 + P0)。由于t不是公式的一部分,所以二次曲线具有恒定的加速度。
为什么不使用TransformDirection?
TransformDirection方法仅考虑对象的旋转,但是我们还需要应用它的缩放。所以我们变换我们的矢量,就像它是一个点一样,然后撤消对位置的改变。 这样,即使使用的是负的大小,它也总是能够产生正确的速度。
什么是ref?
ref关键字表示我们是通过引用传递参数。 这意味着这个方法是直接使用我们的值而不是这个值的副本。所以如果我们提供的是一个整数变量,并且这个方法对这个整数变量做了赋值,那么我们这个整数变量就发生了改变。这就像这个方法返回新值,并将这个新值分配给我们自己的变量一样。
对对象引用使用的话也是一样。如果方法改变了引用,那么就意味着我们的引用被修改了。这意味着在方法完成后,我们可以持有对不同对象的引用。
你不能将常量值作为ref参数传递,它必须是一个初始化了的变量。
还有一个关键字 out。它的作用与ref相同,不同之处在于它不需要初始化,并强制该方法为其分配一个值。
ref和out修饰符通常仅在方法具有多个独立结果或是希望保证调用者变量被赋值的时候才使用。有时候在处理大型结构的时候为了性能会考虑这些方法。 一般来说,这是要避免使用的。
Array.Resize是如何工作的?
Array.Resize是一个通用方法,它接受一个数组变量作为引用,创建一个给定长度相同类型的新数组,并将其分配给传进来的变量。它还将旧数组的内容复制到新数组,直到其容量的上限。
所以创建一个新的数组,而旧的数组(如果有的话)可能不再被任何人使用,并且在某些时候会被垃圾收集器所关注。
请注意,它是一种通用方法,但我们没有指出要使用什么类型。 编译器可以足够聪明地从参数中推断出它需要的类型,并将其转换为Array.Resize <Vector3>(ref points,points.Length + 3)。
GUILayout.Button是如何工作的?
Button方法会显示一个按钮,并返回是否被点击的信息。所以你通常在一个if语句对它进行调用并在相应的代码块中执行必要的工作。
实际发生的是,你自己的GUI方法(在这种情况下为OnInspectorGUI)被调用的频率远远超过一次。 在执行布局的时候,重绘的时候以及每当发生重大的GUI事件的时候,这个方法都会被调用,这是很常见的。只有当按钮所需要的鼠标点击事件出现的时候,它将返回true。
要了解GUI方法调用的频率,请将Debug.Log(Event.current)方法放在OnInspectorGUI方法的开始,然后愚弄一下你的编辑器,并观看控制台。
通常你不需要担心这一点,但是在进行开销比较重的工作的时候就会意识到这一点,比如说像是生成纹理。如果你不需要的话,你不想让这个方法每秒钟被调用几十次。
为什么要在两种方法中都对spline进行赋值?
我们在OnInspectorGUI和OnSceneGUI方法中都使用了spline,但这两种方法基本上彼此独立。 OnInspectorGUI方法在整个组件被选中的时候会被调用一次,如果我们支持的话,这可以包含多个对象。 OnSceneGUI方法在每个适当的组件被选中的时候会被调用一次,而且在每次目标更改的时候都会被调用一次。 所以最好不要让这些方法相互依赖。
Handles.Button是如何工作的?
Button方法在场景视图中的3D空间中显示一个按钮。 除了常规尺寸,它也有一个选择大小。 可以认为这是用于确定用户是否触摸按钮的碰撞体的大小。 我们使这个碰撞体比可见的区域更大,所以这个按钮会更容易被选中。
像其他一些Handles方法一样,还需要告诉它你要画什么形状。这是通过给它一个删除绘制方法来完成的。 我们使用Handles.DotCap,它会绘制一个忽略旋转的正方形,并且这个正方形会始终面向场景相机。
LookAt是如何工作的?
Transform.LookAt方法有多个版本。 我们正在使用的是一个我们在世界空间中放置它的位置,并且让它自动旋转,使其向前的方向指向该位置的方法。由于这个方法是相对于自己的位置,我们不得不把它的位置加到我们样条的方向上。
提供一个方向不足以定义3D旋转。该方法也保持了变换向上的方向与世界向上的方向一致。 这意味着沿着曲线前进的函数会尽量保持它是向上的。 你也可以提供一个替代向量,来代替向上的向量。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。