大家好,我是前端西瓜哥。
今天我们来学习点到贝塞尔曲线的投影。
点到贝塞尔曲线投影(project),也有别的类似的名称:
-
点到贝塞尔曲线的最近点(closestPoint 或 nearestPoint);
-
点到贝塞尔曲线的距离(distance),这个距离是需要先求出最近点位置,然后才能计算出来的。
这些基本都是用的一个算法。通常算法库会把投影点、距离、还有 t 打包进一个对象返回。
一些用途
-
判断点到贝塞尔曲线的距离是否小于某个阈值,对曲线做高精度的图形拾取;
-
找到光标到贝塞尔的最近点,将一条贝塞尔曲线打断为两条贝塞尔曲线。
思路
直接能想到的暴力思路是,把贝塞尔上的所有点都计算起来,一个个计算到目标点的距离,取其中距离最小的点。
当然我们是不可能拿到所有点,一条线上有无数个点,我们不可能真的取无数个点出来。
所以我们应该选取合适的间距取出一些点。
几个好呢?999999?
这个也太多了,算力有限,我们可以选择一个较小的间隔,大概排查一下,确定最近点应该再哪个区域。
然后对这个区域做更精细针对性排查,然后再确定最近点所在的子区域,重复前面的操作,越来越逼近正确位置。
这样就能有效减少无用功。
完整思路为:
-
记 t1 为 0,t2 为 1。从 t1 到 t2,不断步进特定长度 step(假设为 0.01,将遍历 101 次),计算步进过程中 t 对应的所有点;
-
遍历这些点,找出最近点;
-
取最近点的上一个点和下一个点对应的 t,设置为 t1、t2;
-
重复步骤 1 和 2,但步进值 step 要比上一次的步进值更小(比如原来的十分之一,即 0.001,将遍历 21 次),重复步骤 1 和 步骤 2 操作;
-
当 step 小于某个值时结束,我们求出了最近点。
如果你希望得到更准确的值,我们可以提高精度,用更小的精度继续递归算下去。当然,精度的提高对应的代价是计算量的增加。
这种思路属于 牛顿迭代法,求的是 近似解。
看起来有点像二分查找,但可惜的是,t 到 t 对应点和目标的距离这二者之间不满足单调性,所以我们还是要切得很细,一个个判断,运算量还是很大的。
完整算法实现
下面是点投影到三阶贝塞尔曲线的算法实现。
如果要扩展到其他 N 阶贝塞尔曲线,替换掉 getBezier3Point 方法然后调整传入的点的数量即可。
/** 点到三阶贝塞尔曲线上的投影 */
const calcBezier3Project = (
p1: Point,
cp1: Point,
cp2: Point,
p2: Point,
targetPt: Point,
lookupTable: { t: number; pt: Point }[] = [],
) => {
const count = 100;
if (lookupTable.length === 0) {
// 第一步是一定要执行的,缓存起来。
// 因为 t += step 这种写法会不断累加浮点数误差,稍微换成 i / count 的写法
for (let i = 0; i <= count; i++) {
const t = i / count;
const pt = getBezier3Point(p1, cp1, cp2, p2, t);
lookupTable[i] = { t, pt };
}
}
let minDist = Number.MAX_SAFE_INTEGER;
let minIndex = -1;
for (let i = 0; i < lookupTable.length; i++) {
const item = lookupTable[i];
const dist = distance(targetPt, item.pt); // 可优化,考虑到单调性,可以不开平方
if (dist < minDist) {
minDist = dist;
minIndex = i;
// 找到 0 距离点,提前结束
if (dist === 0) {
break;
}
}
}
if (minDist === 0) {
const projectPt = getBezier3Point(
p1,
cp1,
cp2,
p2,
lookupTable[minIndex].t,
);
return {
point: projectPt,
t: lookupTable[minIndex].t,
dist: distance(targetPt, projectPt),
};
}
let minT = lookupTable[minIndex].t;
const t1 = minIndex > 0 ? lookupTable[minIndex - 1].t : minT;
const t2 =
minIndex < lookupTable.length - 1 ? lookupTable[minIndex + 1].t : minT;
let step = 0.001; // 原来的 1/10
for (let t = t1; t <= t2; t += step) {
const pt = getBezier3Point(p1, cp1, cp2, p2, t);
const dist = distance(targetPt, pt);
if (dist < minDist) {
minDist = dist;
minT = t;
// 找到 0 距离点,提前结束
if (dist === 0) {
break;
}
}
}
if (minT < 0) {
minT = 0;
}
if (minT > 1) {
minT = 1;
}
const projectPt = getBezier3Point(p1, cp1, cp2, p2, minT);
return {
point: projectPt,
t: minT,
dist: distance(targetPt, projectPt),
};
};
/** 计算三阶贝塞尔曲线 t 对应的点(套用参数方程公式) */
const getBezier3Point = (
p1: Point,
cp1: Point,
cp2: Point,
p2: 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;
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 distance = (p1: Point, p2: Point) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return Math.sqrt(dx * dx + dy * dy);
};
这里我们缓存了 t 为 0、0.01、... 0.99、 1 和对应点的查找表(LookUp Table)。
如果你的贝塞尔曲线一直不变,变得只是目标点,比如实现图形拾取,缓存第一轮每次都会执行的遍历结果是很要必要的。
后面我们又基于 0.001 步长再遍历了一轮就结束了,此时看起来效果已经足够,所以就没有进行下一轮的遍历了。
如果觉得还不够准确,可以用更小的步长再继续遍历几轮。
该遍历几轮这个通常是动态的。比如画布放得越放大,精度就要求越高。或者贝塞尔曲线越长,相比短的贝塞尔曲线点分布会越稀疏,需要加多几轮来减少误差,或减小 step 值。
结尾
我是前端西瓜哥,关注我,学习更多平面几何知识。
相关阅读,
贝塞尔曲线算法:求 t 在三阶贝塞尔曲线上的点、切向量、法向量