贝塞尔曲线在二维上的算法, 可以分解成两个相同的一维上的算法. 而任意阶的贝塞尔曲线都可以写作一个方程. 有方程, 就能尝试求解.
本文所有代码均已上传至 GitHub 仓库: SlimeNull/SolveBezierCurve
算法概述
以二阶贝塞尔曲线开始, 它有三个控制点, 构成了两个线段. 计算指定时间 t 上的点, 步骤如下:
- 在两个线段上, 取得进度为 t 的点, 得到两个点, 即一个线段
- 在一个线段上, 取得进度为 t 的点, 得到一个点, 即结果.
这里说的 “进度为 t 的点”, 则是对两点进行插值, t 为参数. 当 t 越接近 0 时, 结果越接近起始点. 当 t 越接近 1 时, 结果越接近结束点.
写成 C# 代码如下:
// 定义表示 Point 的结构体
public record struct Point(double X, double Y);
public class BezierUtilities
{
// 对 double 进行插值的函数
public static double Lerp(double a, double b, double t)
{
return a * (1 - t) + b * t;
}
// 对 Point 进行插值的函数
public static Point Lerp(Point a, Point b, double t)
{
return new Point(Lerp(a.X, b.X, t), Lerp(a.Y, b.Y, t));
}
}
同理, 三阶贝塞尔曲线由四个控制点, 也就是三个线段构成, 其计算过程如下:
- 在三个线段上, 取得进度为 t 的点, 得到三个点, 即两个线段
- 在两个线段上, 取得进度为 t 的点, 得到两个点, 即一个线段
- 在一个线段上, 取得进度为 t 的点, 得到一个点, 即结果.
可以看到, 不论多少阶的贝塞尔曲线, 其算法都是单调的
逻辑拆解
通过前面的插值算法可以知道, 贝塞尔曲线不同维度上的计算结果互不影响, 也就是说, 我们完全可以将二维上的二阶贝塞尔曲线, 降维到一维. 这样, 原本由两个值构成的 “控制点”, 现在变成了纯数字.
二阶贝塞尔曲线
那么接下来, 设一维二阶贝塞尔曲线三个控制点的值分别为 a, b, c , 时间参数为 t, 计算逻辑如下:
- 计算线段 a-b 之间的插值点
计算线段 b-c 之间的插值点 - 计算线段 d-e 之间的插值点, 即最终的插值点:
将它们展开, 最终的计算公式是:
即:
对于编程中的 “动画” 所使用的贝塞尔曲线, a 为 0, c 为 1, 此时公式是:
当 b 值确定时, 这就是一个简单的一元二次方程. 将其化为标准形式:
随便扔给 WolframAlpha 就能得出:
- 当 b 为 0.5 时, 此时解为:
- 当 b 不为 0.5 时, 解:
三阶贝塞尔曲线
对于三阶贝塞尔曲线,
那么接下来, 设一维二阶贝塞尔曲线三个控制点的值分别为 a, b, c , 时间参数为 t, 计算逻辑如下:
- 计算线段 a-b 之间的插值点
计算线段 b-c 之间的插值点
计算线段 c-d 之间的插值点 - 计算线段 e-f 之间的插值点
计算线段 f-g 之间的插值点 - 计算线段 h-i 之间的插值点, 即最终的插值点:
将它们展开, 最终的计算公式是:
即:
对于编程中的 “动画” 所使用的贝塞尔曲线, a 为 0, d 为 1, 此时公式是:
当 b 值确定时, 这就是一个简单的一元二次方程. 将其化为标准形式:
当然, 三次方程的求根公式就比二次方程的复杂多了, 在这就不写出来了. 在下一部分中给出的逻辑代码, 使用的是盛金法求三次方程.
选取合适的根
对于贝塞尔曲线, t 的值应该总是大于等于 0, 小于等于 1 的. 所以在解方程之后, 不在此范围的根应该抛弃掉.
代码实现
简单抽象一个 “点”:
public record struct Point(double X, double Y)
{
public bool IsEmpty
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => Is(0, 0);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Is(double x, double y) => X == x && Y == y;
public static Point operator +(Point p1, Point p2)
{
return new Point(p1.X + p2.X, p1.Y + p2.Y);
}
public static Point operator -(Point p1, Point p2)
{
return new Point(p1.X - p2.X, p1.Y - p2.Y);
}
public static Point operator *(Point point, double factor)
{
return new Point(point.X * factor, point.Y * factor);
}
public static Point operator /(Point point, double factor)
{
return new Point(point.X / factor, point.Y / factor);
}
public static Point Lerp(Point a, Point b, double t)
{
return a * (1 - t) + b * t;
}
}
一次方程, 二次方程, 三次方程的求解逻辑:
internal static class MathUtilities
{
/// <summary>
/// 解一元一次方程 (标准形式)
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public static IEnumerable<double> SolveLinearFunction(double a, double b)
{
yield return -b / a;
}
/// <summary>
/// 解一元二次方程 (标准形式)
/// </summary>
/// <param name="a">二次项系数</param>
/// <param name="b">一次项系数</param>
/// <param name="c">常数项</param>
/// <returns></returns>
public static IEnumerable<double> SolveQuadraticFunction(double a, double b, double c)
{
if (a == 0)
{
foreach (var root in SolveLinearFunction(b, c))
{
yield return root;
}
yield break;
}
var delta = b * b - 4 * a * c;
if (delta < 0)
{
// 没有实数根
yield break;
}
var x1 = (-b + Math.Sqrt(delta)) / (2 * a);
yield return x1;
if (delta == 0)
{
yield break;
}
var x2 = (-b - Math.Sqrt(delta)) / (2 * a);
yield return x2;
}
/// <summary>
/// 解一元三次方程 (标准形式)
/// </summary>
/// <param name="a">三次项系数</param>
/// <param name="b">二次项系数</param>
/// <param name="c">一次项系数</param>
/// <param name="d">常数项</param>
/// <returns></returns>
public static IEnumerable<double> SolveCubicFunction(double a, double b, double c, double d)
{
if (a == 0)
{
foreach (var root in SolveQuadraticFunction(b, c, d))
{
yield return root;
}
yield break;
}
// 盛金公式法
double bigA = b * b - 3 * a * c;
double bigB = b * c - 9 * a * d;
double bigC = c * c - 3 * b * d;
double delta = bigB * bigB - 4 * bigA * bigC;
if (bigA == bigB && bigA == 0)
{
// 三个相同的实数根
yield return -c / b;
yield break;
}
else if (delta > 0)
{
var bigY1 = bigA * b + 3 * a * ((-bigB + Math.Sqrt(delta)) / 2);
var bigY2 = bigA * b + 3 * a * ((-bigB - Math.Sqrt(delta)) / 2);
// 只有一个实数根, 剩下两个是虚的
yield return (-b - (Math.Pow(bigY1, 1.0 / 3.0) + Math.Pow(bigY2, 1.0 / 3.0))) / (3 * a);
}
else if (delta == 0)
{
var bigK = bigB / bigA;
yield return -b / a + bigK;
// 两个相同的实数根
yield return -bigK / 2;
}
else
{
var bigT = (2 * bigA * b - 3 * a * bigB) / (2 * Math.Pow(bigA, 3.0 / 2.0));
var theta = Math.Acos(bigT);
// 三个根
yield return (-b - 2 * Math.Sqrt(bigA) * Math.Cos(theta / 3)) / (3 * a);
yield return (-b + Math.Sqrt(bigA) * (Math.Cos(theta / 3) + Math.Sqrt(3) * Math.Sin(theta / 3))) / (3 * a);
yield return (-b + Math.Sqrt(bigA) * (Math.Cos(theta / 3) - Math.Sqrt(3) * Math.Sin(theta / 3))) / (3 * a);
}
}
}
贝塞尔曲线抽象:
public class BezierCurve
{
private readonly Point[] _points;
public BezierCurve(IEnumerable<Point> points)
{
ArgumentNullException.ThrowIfNull(points);
_points = points.ToArray();
if (_points.Length < 1)
{
throw new ArgumentException("Point count can not be less than 1");
}
}
private double SolveTimeForPoint(double controlPoint1, double controlPoint2, double point)
{
double a = -controlPoint1 + controlPoint2;
double b = controlPoint1 - point;
foreach (var root in MathUtilities.SolveLinearFunction(a, b))
{
if (root < 0 || root > 1)
{
continue;
}
return root;
}
return double.NaN;
}
private double SolveTimeForPoint(double controlPoint1, double controlPoint2, double controlPoint3, double point)
{
double a = controlPoint1 - 2 * controlPoint2 + controlPoint3;
double b = -2 * controlPoint1 + 2 * controlPoint2;
double c = controlPoint1 - point;
foreach (var root in MathUtilities.SolveQuadraticFunction(a, b, c))
{
if (root < 0 || root > 1)
{
continue;
}
return root;
}
return double.NaN;
}
private double SolveTimeForPoint(double controlPoint1, double controlPoint2, double controlPoint3, double controlPoint4, double point)
{
double a = -controlPoint1 + 3 * controlPoint2 - 3 * controlPoint3 + controlPoint4;
double b = 3 * controlPoint1 - 6 * controlPoint2 + 3 * controlPoint3;
double c = -3 * controlPoint1 + 3 * controlPoint2;
double d = controlPoint1 - point;
foreach (var root in MathUtilities.SolveCubicFunction(a, b, c, d))
{
if (root < 0 || root > 1)
{
continue;
}
return root;
}
return double.NaN;
}
/// <summary>
/// 传入进度 t 值, 求曲线上点坐标
/// </summary>
/// <param name="t"></param>
/// <returns></returns>
public Point Solve(double t)
{
Point[] buffer = new Point[_points.Length];
_points.CopyTo(buffer, 0);
int pointCount = _points.Length;
while (pointCount > 1)
{
for (int i = 0; i < pointCount - 1; i++)
{
buffer[i] = Point.Lerp(buffer[i], buffer[i + 1], t);
}
pointCount--;
}
return buffer[0];
}
/// <summary>
/// 根据曲线上某点的 X 值求解 t 参数
/// </summary>
/// <param name="pointX"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
public double SolveTimeForPointX(double pointX)
{
if (_points.Length == 2)
{
return SolveTimeForPoint(_points[0].X, _points[1].X, pointX);
}
else if (_points.Length == 3)
{
return SolveTimeForPoint(_points[0].X, _points[1].X, _points[2].X, pointX);
}
else if (_points.Length == 4)
{
return SolveTimeForPoint(_points[0].X, _points[1].X, _points[2].X, _points[3].X, pointX);
}
else
{
throw new NotSupportedException();
}
}
/// <summary>
/// 根据曲线上某点的 Y 值求解 t 参数
/// </summary>
/// <param name="pointY"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
public double SolveTimeForPointY(double pointY)
{
if (_points.Length == 2)
{
return SolveTimeForPoint(_points[0].Y, _points[1].Y, pointY);
}
else if (_points.Length == 3)
{
return SolveTimeForPoint(_points[0].Y, _points[1].Y, _points[2].Y, pointY);
}
else if (_points.Length == 4)
{
return SolveTimeForPoint(_points[0].Y, _points[1].Y, _points[2].Y, _points[3].Y, pointY);
}
else
{
throw new NotSupportedException();
}
}
}