Unity编辑器扩展——实现样条线编辑器
视频效果
Unity编辑器扩展样条线编辑
引言
一直以来有一个想法,想实现在unity中程序化摆放物体,比如,沿着公路自动摆放路灯,在一个范围内自动摆放建筑物,生成自动化城市场景等。于是,就开始了编辑器扩展的研究,遗憾的是,网络上虽然有很多的编辑器扩展方面的文章,但极少有在场景中实现样条线编辑方面的内容,只好自己研究啦,幸好,我记得Cinemachine
中自带一个路径摄像机的功能,于是深入的研究了一下,发现完全可以拿来学习。
实现视频中的效果,所需要的几个要点:
- 贝塞尔曲线的算法
- 曲线功能描述
- 可以实现闭环曲线
- 可以给定0-1之间的值,获得曲线上位置的点坐标,以及任意位置的切线方向。
- 可视化的编辑(本文重点介绍)
-
ReorderableList
类的用法,重载OnInspectorGUI
,绘制Inspector面板。 - 重载
OnSceneGUI
,实现坐标点的绘制,以及坐标点的在Scene窗口中的位置编辑。 - 通过
DrawGizmos
在Scene窗口中绘制编辑的结果,完成曲线可视化。
原理
贝塞尔曲线
贝塞尔曲线的原理网络上很多文章,这里不再赘述,但是这里值得一提的是,本来我以为贝塞尔计算起来也就是这样:
public static Vector3 BezierLerp(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3)
{
t = Mathf.Clamp01(t);
Vector3 w0 = Vector3.Lerp( p0, p1, t );
Vector3 w1 = Vector3.Lerp( p1, p2, t );
Vector3 w2 = Vector3.Lerp( p2, p3, t );
Vector3 r0 = Vector3.Lerp( w0, w1, t );
Vector3 r1 = Vector3.Lerp( w1, w2, t );
return Vector3.Lerp( r0, r1, t );
}
上面的代码虽然浅显易懂,但是计算挺复杂,研究了Cinemachine
中的代码,发现人家的代码效率很高,研究了好一会儿,没怎么理解背后的几何原理,哎,学好数学是多么重要啊。算球,公式记下来,以后就这么用吧:
public static Vector3 Bezier3(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3)
{
t = Mathf.Clamp01(t);
float d = 1f - t;
return d * d * d * p0 + 3f * d * d * t * p1
+ 3f * d * t * t * p2 + t * t * t * p3;
}
另附一个贝塞尔切线算法:
public static Vector3 BezierTangent3(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3)
{
t = Mathf.Clamp01(t);
return (-3f * p0 + 9f * p1 - 9f * p2 + 3f * p3) * (t * t)
+ (6f * p0 - 12f * p1 + 6f * p2) * t
- 3f * p0 + 3f * p1;
}
Spline类
公共成员:
// 是否循环
public bool Looped;
// 两点之间曲线细节(分辨率)
public int Resolution = 20;
// 曲线所经过的位置点
public Vector3 [] positions = Array.Empty<Vector3>();
这个类最重要的是两个方法:
// 给定0-1的值,获得曲线上任意点位置
public Vector3 EvaluatePosition(float pos);
// 给定0-1的值,获得曲线上任意点切线
public Vector3 EvaluateTangent(float pos);
这个类其实并不复杂,也比较容易理解,这里不展开介绍了。
SplineEditor类(重点)
编辑器扩展,用它来实现Spline的编辑。这里面有几个要点:
ReorderableList
类的用法,重载OnInspectorGUI
,绘制Inspector面板。
ReorderableList
用来在Inspector面板展示可以调整顺序的列表,如下图:
使用方法也很简单,只要在OnEnable里初始化一下:
private void OnEnable()
{
positions = new ReorderableList(serializedObject, serializedObject.FindProperty("positions"))
{
elementHeight = EditorGUIUtility.singleLineHeight + 8,
drawHeaderCallback = rect => { EditorGUI.LabelField(rect, $"坐标点列表 [{positions.count}]"); },
drawElementCallback = (rect, index, _, _) =>
{
var item = positions.serializedProperty.GetArrayElementAtIndex(index);
rect.y += 4;
rect.height -= 8;
EditorGUI.PropertyField(rect, item, new GUIContent($"坐标{index}"));
}
};
}
然后在OnInspectorGUI
中调用下DoLayoutList()
方法。
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(serializedObject.FindProperty("Looped"), new GUIContent("循环"));
EditorGUILayout.IntSlider(serializedObject.FindProperty("Resolution"), 1, 100, new GUIContent("细节"));
EditorGUILayout.Space();
positions.DoLayoutList();
serializedObject.ApplyModifiedProperties();
}
重载
OnSceneGUI
,实现坐标点的绘制,以及坐标点的在Scene窗口中的位置编辑。
private void OnSceneGUI()
{
if (Tools.current == Tool.Move)
{
Color oldColor = Handles.color;
var localToWorld = Target.transform.localToWorldMatrix;
for (int i = 0; i < Target.positions.Length; ++i)
{
DrawSelectionHandle(i, localToWorld);
if (positions.index == i) // Selected
{
DrawPositionControl(i, localToWorld, Target.transform.rotation);
}
}
Handles.color = oldColor;
}
}
Scene窗口中,有三种基本操作:移动位置,旋转,缩放,对应快捷点:W、E、R,Tools.current==Tool.Move
,其实就是判断一下是不是处于位置移动模式。因为贝塞尔曲线的编辑,只需要移动位置点的位置,控制点和位置点的朝向信息是不需要的,所以,只有在移动模式下,才去绘制位置点并允许编辑。DrawSelectionHandle
是绘制位置点上的小圆球。
private void DrawSelectionHandle(int i, Matrix4x4 localToWorld)
{
if (Event.current.button != 1)
{
Vector3 pos = localToWorld.MultiplyPoint(Target.positions[i]);
float size = HandleUtility.GetHandleSize(pos) * 0.2f;
Handles.color = Color.white;
if (Handles.Button(pos, Quaternion.identity, size, size, Handles.SphereHandleCap)
&& positions.index != i)
{
positions.index = i;
EditorApplication.QueuePlayerLoopUpdate();
InternalEditorUtility.RepaintAllViews();
}
// Label it
Handles.BeginGUI();
Vector2 labelSize = new Vector2(
EditorGUIUtility.singleLineHeight * 2, EditorGUIUtility.singleLineHeight);
Vector2 labelPos = HandleUtility.WorldToGUIPoint(pos);
labelPos.y -= labelSize.y / 2;
labelPos.x -= labelSize.x / 2;
GUILayout.BeginArea(new Rect(labelPos, labelSize));
GUIStyle style = new GUIStyle
{
normal =
{
textColor = Color.black
},
alignment = TextAnchor.MiddleCenter
};
GUILayout.Label(new GUIContent(i.ToString(), "坐标点" + i), style);
GUILayout.EndArea();
Handles.EndGUI();
}
}
DrawPositionControl
则是绘制被选中的位置点的坐标轴,允许用户进行位置移动。
private void DrawPositionControl(int i, Matrix4x4 localToWorld, Quaternion localRotation)
{
Vector3 wp = Target.positions[i];
Vector3 pos = localToWorld.MultiplyPoint(wp);
EditorGUI.BeginChangeCheck();
Handles.color = Color.red;
Quaternion rotation = (Tools.pivotRotation == PivotRotation.Local)
? localRotation
: Quaternion.identity;
float size = HandleUtility.GetHandleSize(pos) * 0.1f;
Handles.SphereHandleCap(0, pos, rotation, size, EventType.Repaint);
pos = Handles.PositionHandle(pos, rotation);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(target, "Move Waypoint");
wp = Matrix4x4.Inverse(localToWorld).MultiplyPoint(pos);
Target.positions[i] = wp;
Target.Invalidate();
EditorApplication.QueuePlayerLoopUpdate();
InternalEditorUtility.RepaintAllViews();
}
}
通过
DrawGizmos
在Scene窗口中绘制编辑的结果,完成曲线可视化。
[DrawGizmo(GizmoType.Active | GizmoType.NotInSelectionHierarchy |
GizmoType.InSelectionHierarchy | GizmoType.Pickable, typeof(Spline))]
private static void DrawGizmos(Spline path, GizmoType selectionType)
{
DrawPathGizmo(path, Selection.activeGameObject == path.gameObject);
}
private static void DrawPathGizmo(Spline path, bool isActive)
{
// Draw the path
Color colorOld = Gizmos.color;
Gizmos.color = Color.green;
float step = 1f / path.Resolution;
Vector3 lastPos = path.EvaluatePosition(path.MinPos);
float tEnd = path.MaxPos + step / 2;
for (float t = path.MinPos + step; t <= tEnd; t += step)
{
Vector3 p = path.EvaluatePosition(t);
Gizmos.DrawLine(p, lastPos);
lastPos = p;
}
Gizmos.color = colorOld;
}