I am creating bezier curves with the following code. The curves can be extended to join several bezier curves by shift clicking in the scene view. My code has functionality for making the whole curve continuous or non-continuous. I realised that I need to make individual points (specifically anchor points) have this functionality.
I believe the most ideal way to go about this is creating a new class for the points with this functionality (making points continuous or non-continuous) since this can be used to add other properties that might be specific to the points. How can do this?
Path
[System.Serializable]
public class Path {
[SerializeField, HideInInspector]
List<Vector2> points;
[SerializeField, HideInInspector]
public bool isContinuous;
public Path(Vector2 centre)
{
points = new List<Vector2>
{
centre+Vector2.left,
centre+(Vector2.left+Vector2.up)*.5f,
centre + (Vector2.right+Vector2.down)*.5f,
centre + Vector2.right
};
}
public Vector2 this[int i]
{
get
{
return points[i];
}
}
public int NumPoints
{
get
{
return points.Count;
}
}
public int NumSegments
{
get
{
return (points.Count - 4) / 3 + 1;
}
}
public void AddSegment(Vector2 anchorPos)
{
points.Add(points[points.Count - 1] * 2 - points[points.Count - 2]);
points.Add((points[points.Count - 1] + anchorPos) * .5f);
points.Add(anchorPos);
}
public Vector2[] GetPointsInSegment(int i)
{
return new Vector2[] { points[i * 3], points[i * 3 + 1], points[i * 3 + 2], points[i * 3 + 3] };
}
public void MovePoint(int i, Vector2 pos)
{
if (isContinuous)
{
Vector2 deltaMove = pos - points[i];
points[i] = pos;
if (i % 3 == 0)
{
if (i + 1 < points.Count)
{
points[i + 1] += deltaMove;
}
if (i - 1 >= 0)
{
points[i - 1] += deltaMove;
}
}
else
{
bool nextPointIsAnchor = (i + 1) % 3 == 0;
int correspondingControlIndex = (nextPointIsAnchor) ? i + 2 : i - 2;
int anchorIndex = (nextPointIsAnchor) ? i + 1 : i - 1;
if (correspondingControlIndex >= 0 && correspondingControlIndex < points.Count)
{
float dst = (points[anchorIndex] - points[correspondingControlIndex]).magnitude;
Vector2 dir = (points[anchorIndex] - pos).normalized;
points[correspondingControlIndex] = points[anchorIndex] + dir * dst;
}
}
}
}
else {
points[i] = pos;
}
}
PathCreator
public class PathCreator : MonoBehaviour {
[HideInInspector]
public Path path;
public void CreatePath()
{
path = new Path(transform.position);
}
}
PathEditor
[CustomEditor(typeof(PathCreator))]
public class PathEditor : Editor {
PathCreator creator;
Path path;
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
EditorGUI.BeginChangeCheck();
bool continuousControlPoints = GUILayout.Toggle(path.isContinuous, "Set Continuous Control Points");
if (continuousControlPoints != path.isContinuous)
{
Undo.RecordObject(creator, "Toggle set continuous controls");
path.isContinuous = continuousControlPoints;
}
if (EditorGUI.EndChangeCheck())
{
SceneView.RepaintAll();
}
}
void OnSceneGUI()
{
Input();
Draw();
}
void Input()
{
Event guiEvent = Event.current;
Vector2 mousePos = HandleUtility.GUIPointToWorldRay(guiEvent.mousePosition).origin;
if (guiEvent.type == EventType.MouseDown && guiEvent.button == 0 && guiEvent.shift)
{
Undo.RecordObject(creator, "Add segment");
path.AddSegment(mousePos);
}
}
void Draw()
{
for (int i = 0; i < path.NumSegments; i++)
{
Vector2[] points = path.GetPointsInSegment(i);
Handles.color = Color.black;
Handles.DrawLine(points[1], points[0]);
Handles.DrawLine(points[2], points[3]);
Handles.DrawBezier(points[0], points[3], points[1], points[2], Color.green, null, 2);
}
Handles.color = Color.red;
for (int i = 0; i < path.NumPoints; i++)
{
Vector2 newPos = Handles.FreeMoveHandle(path[i], Quaternion.identity, .1f, Vector2.zero, Handles.CylinderHandleCap);
if (path[i] != newPos)
{
Undo.RecordObject(creator, "Move point");
path.MovePoint(i, newPos);
}
}
}
void OnEnable()
{
creator = (PathCreator)target;
if (creator.path == null)
{
creator.CreatePath();
}
path = creator.path;
}
}
Drawing a Bézier Curve To activate an anchor point, select the Pen tool and click on the desired point. You'll know it's active when the point is filled with color. If you'd like to create a curve, click on the point and drag in the direction that you'd like it to bend.
You can extend an open Bézier path. To do so, briefly click one of the path's two end points with the Bézier tool while holding down the SHIFT key. You can then add further Bézier points by clicking. Bézier paths can be connected to other Bézier paths and also to lines, rectangles, polygons and ellipses.
The Bezier tool allows you to create a type of curve defined by off-curve control handles. A Bezier curve is a type of curve defined by additional off-curve control handles and were originally developed for computer modeling in automotive design, but are popular in many vector drawing applications.
I think your idea is fine: you can write two classes, named ControlPoint
and HandlePoint
(make them serializable).
ControlPoint
may represent p0
and p3
of each curve - the points the path indeed pass through. For continuity, you must assert that p3
of one segment equals to p0
of the next segment.
HandlePoint
may represent p1
and p2
of each curve - the points that are tangents of the curve and provide direction and inclination. For smoothness, you must assert that (p3 - p2).normalized
of one segment equals to (p1 - p0).normalized
of the next segment. (if you want symetric smoothness, p3 - p2
of one must equals p1 - p0
of the other.)
Tip #1: Always consider matrix transformations when assigning or comparing points of each segment. I suggest you to convert any point to global space before performing the operations.
Tip #2: consider applying a constraint between points inside a segment, so when you move arround p0
or p3
of a curve, p1
or p2
move accordingly by the same amount, respectively (just like any graphics editor software do on bezier curves).
I did a sample implementation of the idea. Actually, after start coding I realized that just one class ControlPoint
(instead of two) will do the job. A ControlPoint
have 2 tangents. The desired behaviour is controled by the field smooth
, that can be set for each point.
ControlPoint.cs
using System;
using UnityEngine;
[Serializable]
public class ControlPoint
{
[SerializeField] Vector2 _position;
[SerializeField] bool _smooth;
[SerializeField] Vector2 _tangentBack;
[SerializeField] Vector2 _tangentFront;
public Vector2 position
{
get { return _position; }
set { _position = value; }
}
public bool smooth
{
get { return _smooth; }
set { if (_smooth = value) _tangentBack = -_tangentFront; }
}
public Vector2 tangentBack
{
get { return _tangentBack; }
set
{
_tangentBack = value;
if (_smooth) _tangentFront = _tangentFront.magnitude * -value.normalized;
}
}
public Vector2 tangentFront
{
get { return _tangentFront; }
set
{
_tangentFront = value;
if (_smooth) _tangentBack = _tangentBack.magnitude * -value.normalized;
}
}
public ControlPoint(Vector2 position, bool smooth = true)
{
this._position = position;
this._smooth = smooth;
this._tangentBack = -Vector2.one;
this._tangentFront = Vector2.one;
}
}
I also coded a custom PropertyDrawer
for the ControlPoint
class, so it can be shown better on the inspector. It is just a naive implementation. You could improve it very much.
ControlPointDrawer.cs
using UnityEngine;
using UnityEditor;
[CustomPropertyDrawer(typeof(ControlPoint))]
public class ControlPointDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
EditorGUI.BeginProperty(position, label, property);
int indent = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0; //-= 1;
var propPos = new Rect(position.x, position.y, position.x + 18, position.height);
var prop = property.FindPropertyRelative("_smooth");
EditorGUI.PropertyField(propPos, prop, GUIContent.none);
propPos = new Rect(position.x + 20, position.y, position.width - 20, position.height);
prop = property.FindPropertyRelative("_position");
EditorGUI.PropertyField(propPos, prop, GUIContent.none);
EditorGUI.indentLevel = indent;
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight;
}
}
I followed the same architecture of your solution, but with the needed adjustments to fit the ControlPoint
class, and other fixes/changes. For example, I stored all the point values in local coordinates, so the transformations on the component or parents reflect in the curve.
Path.cs
using System;
using UnityEngine;
using System.Collections.Generic;
[Serializable]
public class Path
{
[SerializeField] List<ControlPoint> _points;
[SerializeField] bool _loop = false;
public Path(Vector2 position)
{
_points = new List<ControlPoint>
{
new ControlPoint(position),
new ControlPoint(position + Vector2.right)
};
}
public bool loop { get { return _loop; } set { _loop = value; } }
public ControlPoint this[int i] { get { return _points[(_loop && i == _points.Count) ? 0 : i]; } }
public int NumPoints { get { return _points.Count; } }
public int NumSegments { get { return _points.Count - (_loop ? 0 : 1); } }
public ControlPoint InsertPoint(int i, Vector2 position, bool smooth)
{
_points.Insert(i, new ControlPoint(position, smooth));
return this[i];
}
public ControlPoint RemovePoint(int i)
{
var item = this[i];
_points.RemoveAt(i);
return item;
}
public Vector2[] GetBezierPointsInSegment(int i)
{
var pointBack = this[i];
var pointFront = this[i + 1];
return new Vector2[4]
{
pointBack.position,
pointBack.position + pointBack.tangentFront,
pointFront.position + pointFront.tangentBack,
pointFront.position
};
}
public ControlPoint MovePoint(int i, Vector2 position)
{
this[i].position = position;
return this[i];
}
public ControlPoint MoveTangentBack(int i, Vector2 position)
{
this[i].tangentBack = position;
return this[i];
}
public ControlPoint MoveTangentFront(int i, Vector2 position)
{
this[i].tangentFront = position;
return this[i];
}
}
PathEditor
is pretty much the same thing.
PathCreator.cs
using UnityEngine;
public class PathCreator : MonoBehaviour
{
public Path path;
public Path CreatePath()
{
return path = new Path(Vector2.zero);
}
void Reset()
{
CreatePath();
}
}
Finally, all the magic happens in the PathCreatorEditor
. Two comments here:
1) I moved the drawing of the lines to a custom DrawGizmo
static function, so you can have the lines even when the object is not Active
(i.e. shown in the Inspector) You could even make it pickable if you want to. I don't know if you want this behaviour, but you could easily revert;
2) Notice the Handles.matrix = creator.transform.localToWorldMatrix
lines over the class. It automatically transforms the scale and rotation of the points to the world coordinates. There is a detail with PivotRotation
over there too.
PathCreatorEditor.cs
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(PathCreator))]
public class PathCreatorEditor : Editor
{
PathCreator creator;
Path path;
SerializedProperty property;
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(property, true);
if (EditorGUI.EndChangeCheck()) serializedObject.ApplyModifiedProperties();
}
void OnSceneGUI()
{
Input();
Draw();
}
void Input()
{
Event guiEvent = Event.current;
Vector2 mousePos = HandleUtility.GUIPointToWorldRay(guiEvent.mousePosition).origin;
mousePos = creator.transform.InverseTransformPoint(mousePos);
if (guiEvent.type == EventType.MouseDown && guiEvent.button == 0 && guiEvent.shift)
{
Undo.RecordObject(creator, "Insert point");
path.InsertPoint(path.NumPoints, mousePos, false);
}
else if (guiEvent.type == EventType.MouseDown && guiEvent.button == 0 && guiEvent.control)
{
for (int i = 0; i < path.NumPoints; i++)
{
if (Vector2.Distance(mousePos, path[i].position) <= .25f)
{
Undo.RecordObject(creator, "Remove point");
path.RemovePoint(i);
break;
}
}
}
}
void Draw()
{
Handles.matrix = creator.transform.localToWorldMatrix;
var rot = Tools.pivotRotation == PivotRotation.Local ? creator.transform.rotation : Quaternion.identity;
var snap = Vector2.zero;
Handles.CapFunction cap = Handles.CylinderHandleCap;
for (int i = 0; i < path.NumPoints; i++)
{
var pos = path[i].position;
var size = .1f;
Handles.color = Color.red;
Vector2 newPos = Handles.FreeMoveHandle(pos, rot, size, snap, cap);
if (pos != newPos)
{
Undo.RecordObject(creator, "Move point position");
path.MovePoint(i, newPos);
}
pos = newPos;
if (path.loop || i != 0)
{
var tanBack = pos + path[i].tangentBack;
Handles.color = Color.black;
Handles.DrawLine(pos, tanBack);
Handles.color = Color.red;
Vector2 newTanBack = Handles.FreeMoveHandle(tanBack, rot, size, snap, cap);
if (tanBack != newTanBack)
{
Undo.RecordObject(creator, "Move point tangent");
path.MoveTangentBack(i, newTanBack - pos);
}
}
if (path.loop || i != path.NumPoints - 1)
{
var tanFront = pos + path[i].tangentFront;
Handles.color = Color.black;
Handles.DrawLine(pos, tanFront);
Handles.color = Color.red;
Vector2 newTanFront = Handles.FreeMoveHandle(tanFront, rot, size, snap, cap);
if (tanFront != newTanFront)
{
Undo.RecordObject(creator, "Move point tangent");
path.MoveTangentFront(i, newTanFront - pos);
}
}
}
}
[DrawGizmo(GizmoType.Selected | GizmoType.NonSelected)]
static void DrawGizmo(PathCreator creator, GizmoType gizmoType)
{
Handles.matrix = creator.transform.localToWorldMatrix;
var path = creator.path;
for (int i = 0; i < path.NumSegments; i++)
{
Vector2[] points = path.GetBezierPointsInSegment(i);
Handles.DrawBezier(points[0], points[3], points[1], points[2], Color.green, null, 2);
}
}
void OnEnable()
{
creator = (PathCreator)target;
path = creator.path ?? creator.CreatePath();
property = serializedObject.FindProperty("path");
}
}
Moreover, I added a loop
field in case you want the curve to be closed, and I added a naive funcionality to remove points by Ctrl+click
on the Scene.
Summing up, this is just basic stuff, but you could do it as advanced as you want. Also, you can reuse your ControlPoint class with other Components, like a Catmull-Rom spline, geometric shapes, other parametric functions...
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With