大家好,我是前端西瓜哥。
今天来实现贝塞尔曲线的包围盒算法。
本文将以三阶贝塞尔曲线为例。
由图可知,要求得贝塞尔曲线的包围盒,我们就需要求出贝塞尔曲线上的转折点,然后加上贝塞尔起点和终点,得出包围这些点的包围盒。
这些点的特点为,x 或 y 的值从递增转为递减状态或反过来,这两个状态的分界点,便是要求的转折点。我们要找出这些点对应的 t。
你也可以认为找出贝塞尔曲线上 切线斜率为 0 或无穷大 对应的点,二者是等价的。
导函数
三阶贝塞尔曲线的参数方程为:
P0 到 P3 表示三阶贝塞尔曲线顺序的 4 个点。
需要注意的是我们用的是参数方程,所有对应的是两个方程 x=f(t) 和 y=g(t),x 和 y 是要隔离开分别计算的。
我们对这个方程求导,得到对应的 导函数。这个要用到高中知识套公式来计算,这里直接给出结果。
这个导函数代表的几何意义,是 x 或 y 曲线在 t 位置上的斜率。
顺带一提,我们仔细观察这个导函数的特征,会发现它是一个二阶贝塞尔曲线,其点值为三阶贝塞尔相邻点相减然后乘以 3。
找到导函数中 f(t) 为 0 的点(原函数上斜率为 0 的点),那就是转折点了。
解方程
我们把导函数转换一下,尝试转换为标准的一元二次方程。
在这之前,我们假设:
const a = 3 * (P1 - P0);
const b = 3 * (P2 - P1);
const c = 3 * (P3 - P2);
导函数就变成了:
转换为标准一元二次方程:
令 B'(t) 等于 0,求 t。
我们用 求根公式 求这个一元二次方程。
首先判断二次项系数 a - 2b + c 是否为 0。
一元二次方程的求根公式
如果不为 0,说明是一元二次方程。接着我们就用求根公式求解了。
(注意这里的 a 并不对应上面我们的 a)
约分得到:
// 二次项系数,同时求根公式的分母 denominator
const d = a - 2 * b + c;
if (d !== 0) {
// 正常的一元二次方程
// 一元二次方程组的判别式 delta 的的化简版的平方,常数提取出去了,并被约掉了。
const deltaSquare = b * b - a * c;
if (deltaSquare < 0) {
// 负数,方程无实数根,无解
return [];
}
const delta = Math.sqrt(deltaSquare);
const m = a - b;
if (delta === 0) {
// 两个相等的实数根
return [(m - delta) / d];
} else {
// 两个不等的实数根
return [(m - delta) / d, (m + delta) / d];
}
}
如果二次项系数是 0,且一次项系数不为 0,说明是一元一次方程。
如果一次项系数也是 0,那就啥也不是。
if (d !== 0) {
// 正常的一元二次方程
// ...
} else if (a !== b) {
// d 为 0,代表一元二次方程的 x^2 前面的系数也是 0,退化为一元一次方程,只有一个解
// 但也要确保 x 前的系数不为 0,否则连一次方程都不是了
return [a / (a - b) / 2];
} else {
return [];
}
讨论完所有的情况,最后我们计算出 t 的值。
然后记得丢掉不在 [0, 1] 区间内的 t。
extrema.filter((t) => t >= 0 && t <= 1);
计算点位置和包围盒
求出所有极值点的 t 后,我们计算 t 对应的贝塞尔曲线上的点,这个我们之前的文章讲过,直接套贝塞尔公式即可。
然后再加上贝塞尔曲线的起点和终点,计算包围盒。
完整代码
还是看看完整代码吧。
/** 求三阶贝塞尔曲线包围盒 */
const getBezier3Bbox = (pts: Point[]) => {
const extremas = bezier3Extrema(pts);
const extremaPts = extremas.map((t) => getBezier3Point(pts, t));
return getPointsBbox([...extremaPts, pts[0], pts[pts.length - 1]]);
};
/** 计算三阶贝塞尔曲线 t 对应的点(套用参数方程公式) */
const getBezier3Point = (pts: Point[], t: number) => {
const t2 = t * t;
const ct = 1 - t;
const ct2 = ct * ct;
const a = ct2 * ct;
const b = 3 * t * ct2;
const c = 3 * t2 * ct;
const d = t2 * t;
const [p1, cp1, cp2, p2] = pts;
return {
x: a * p1.x + b * cp1.x + c * cp2.x + d * p2.x,
y: a * p1.y + b * cp1.y + c * cp2.y + d * p2.y,
};
};
/* 求三阶贝塞尔曲线的转折点 */
const bezier3Extrema = (pts: Point[]) => {
// 先求 y 维度的极值
const extrema = getRoot(
3 * (pts[1].y - pts[0].y),
3 * (pts[2].y - pts[1].y),
3 * (pts[3].y - pts[2].y),
);
// 再求 x
extrema.push(
...getRoot(
3 * (pts[1].x - pts[0].x),
3 * (pts[2].x - pts[1].x),
3 * (pts[3].x - pts[2].x),
),
);
// 这里可以再做个去重
return extrema.filter((t) => t >= 0 && t <= 1);
};
/* 求方程的根 */
const getRoot = (a: number, b: number, c: number) => {
// 二次项系数,同时求根公式的分母 denominator
const d = a - 2 * b + c;
if (d !== 0) {
// 正常的一元二次方程
// 一元二次方程组的判别式 delta 的的化简版的平方,常数提取出去了,并被约掉了。
const deltaSquare = b * b - a * c;
if (deltaSquare < 0) {
// 负数,方程无实数根,无解
return [];
}
const delta = Math.sqrt(deltaSquare);
const m = a - b;
if (delta === 0) {
// 两个相等的实数根
return [(m - delta) / d];
} else {
// 两个不等的实数根
return [(m - delta) / d, (m + delta) / d];
}
} else if (a !== b) {
// d 为 0,代表一元二次方程的 x^2 前面的系数也是 0,退化为一元一次方程,只有一个解
// 但也要确保 x 前的系数不为 0,否则连一次方程都不是了
return [a / (a - b) / 2];
} else {
return [];
}
};
const getPointsBbox = (points: Point[]) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const pt of points) {
minX = Math.min(minX, pt.x);
minY = Math.min(minY, pt.y);
maxX = Math.max(maxX, pt.x);
maxY = Math.max(maxY, pt.y);
}
return {
minX,
minY,
maxX,
maxY,
};
};
interface Point {
x: number;
y: number;
}
效果
结尾
全是数学公式,没有一点技巧。
我是前端西瓜哥,关注我,学习更多平面几何知识。
相关阅读,
贝塞尔曲线算法:求 t 在三阶贝塞尔曲线上的点、切向量、法向量