贝塞尔曲线的切线及其 AABB 问题

贝塞尔曲线的切线及其 AABB 问题

AABB 即图形界中常说的 AABB (axis-aligned bounding box) 包围盒, 严格来说是未能实现 BB

规则的图形很容易通过顶点距离就可以计算出 BB, 但像贝塞尔曲线这样的曲线就不太好算

是时候解决一下了

要实现的效果

三阶贝塞尔曲线的 BB

之前在翻译 贝塞尔曲线文字路径 一文中提到过三阶贝塞尔曲线

它是用 C# 伪代码来讲解的

定义 4 个控制点:

(x1, y1), (x2, y2), (x3, y3), (x4,y4)

定义 A…H 系数

A = x3 - 3 * x2 + 3 * x1 - x0
B = 3 * x2 - 6 * x1 + 3 * x0
C = 3 * x1 - 3 * x0
D = x0

E = y3 - 3 * y2 + 3 * y1 - y0
F = 3 * y2 - 6 * y1 + 3 * y0
G = 3 * y1 - 3 * y0
H = y0

得到多项式:

x = At3 + Bt2 + Ct + D 
y = Et3 + Ft2 + Gt + H

那么我们先用 Javascript 实现一下那篇文章中提到过的垂直于曲线的单位向量

假设我们要绘制的三阶贝塞尔曲线的四个控制点

[
      { x: 120, y: 320 },
      { x: 135, y: 440 },
      { x: 320, y: 280 },
      { x: 480, y: 340 },
];

下面是它三阶贝塞尔曲线采样点,t 取值 0-1 :

// 用 t 获取“样条曲线” 采样点
let sx = A * Math.pow(t, 3) + B * Math.pow(t, 2) + C * t + D
let sy = E * Math.pow(t, 3) + F * Math.pow(t, 2) + G * t + H

sx, sy 就是 t 从 0 - 1 时算出的曲线上的每个点

如果 t 取值足够小,那么在 canvas 上画出所有的点它就是一条贝塞尔曲线

t 间隔为 0.1 时:

t 间隔为 0.001 时:

画出垂直于曲线的向量关键, 在于对三阶贝塞尔曲线多项式的求导

如果你忘记了什么是求导(导函数), 没关系, 直接用公式就完了

我这个学渣都会用,你肯定也可以,

当然最好是回去复习一下高中后期的导函数部分,有助于理解曲线切线的几何意义

求导后得到向量:

// 求导前
x = At3 + Bt2 + Ct + D 
y = Et3 + Ft2 + Gt + H 

// 求导后
Vx = 3At2 + 2Bt + C 
Vy = 3Et2 + 2Ft + G

用 Javascript 实现如下:

// (求导)用于计算曲线上采样点的切线向量
let tx = 3 * A * Math.pow(t, 2) + 2 * B * t + C
let ty = 3 * E * Math.pow(t, 2) + 2 * F * t + G

// 旋转 90 度或 270 度垂直于曲线采样点
let px = ty
let py = -tx

// 缩至单位向量
let magnitude = Math.sqrt(px * px + py * py)
px = px / magnitude
py = py / magnitude

// 为了向量可见,扩大 20 个单位
px *= 20;
py *= 20;

// 从采样点连接至切线向量偏移位置
console.log(sx + px, sy + py);

源码尽量平铺直叙:…

https://github.com/willian12345/blogpost/blob/main/curve/bezier/cubic-bezier-tangent-test.html

如果你对贝塞尔曲线感兴趣还可以看一下我翻译的《曲线编程艺术》的 贝塞尔曲线 这一章

把三阶贝塞尔曲线包起来

要实现三阶贝塞尔曲线的 AABB(包围合) 还是得从切线入手

比如像下面这个曲线

let points = [
      {x: 120, y: 160 }, 
      {x:  35, y: 200 }, 
      {x: 220, y: 260 }, 
      {x: 180, y:  40 }, 
];

四个点得出的结果:

先把它的四个点用直线连接画出来

ctx.beginPath();
ctx.lineWidth = 2;
ctx.setLineDash([1, 2]);
ctx.strokeStyle = '#076c75';
ctx.moveTo(points[0].x, points[0].y);
ctx.lineTo(points[1].x, points[1].y);
ctx.stroke()

ctx.beginPath();
ctx.lineWidth = 1;
ctx.moveTo(points[1].x, points[1].y);
ctx.strokeStyle = 'black';
ctx.lineTo(points[2].x, points[2].y);
ctx.stroke()

ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = '#076c75';
ctx.moveTo(points[2].x, points[2].y);
ctx.lineTo(points[3].x, points[3].y);
ctx.stroke();

蓝色的线就像是控制手柄

点 points[1] 和 points[2] 分别就是控制手柄

控制手柄就是 PS 内的钢笔工具用过吧?就是这个,长短与位置调节就控制了曲线的形状

BB 包围盒就是找到曲线所有转折点中最小和最大的转折点

找转折点,可理解为找到曲线上的斜率

还是从公式入手

在上一节中贝塞尔公式系数直接把 x, y 都用 A…H 表示出来了

这次先简化到一维比如 x , 系数用 A…D 表示

x 坐标方程即(y 轴坐标方程其实是一样的,只是算了两遍):

x = A (1-t)^3 +3 B t (1-t)^2 + 3 C t^2 (1-t) + D t^3

对其求导,关于 t 的微分,得到微分方程

dx/dt =  3 (B - A) (1-t)^2 + 6 (C - B) (1-t) t + 3 (D - C) t^2
      =  [3 (D - C) - 6 (C - B) + 3 (B - A)] t^2
      + [ -6 (B - A) - 6 (C - B)] t
      + 3 (B - A) 
      =  (3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)

合并整理后是一个二次函数:

dx/dt = (3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)

用其 a, b, c 简化系数代替后:

dx/dt = a t^2 + b t + c

我们要解决的是 dx/dt = 0

“斜率为 0 可能意味着曲线在该点处有一个极小值或极大值,或者曲线在该点处是一个水平切线”

反正我这个学渣是这么理解的

那么就是对二交方程求解

a t^2 + b t + c = 0

可用求根公式

- b +/- sqrt(b^2-4 a c)
-----------------------
      2 a

解方程可得 两个解(根) t0, t1, 无解,或 1 个解

这就有了四个点的极值,起点,终点,和两个解

系数 a, b, c 就是根据公式代入, 比如 x 的坐标代入后:

let a = 3 * points[3].x - 9 * points[2].x + 9 * points[1].x - 3 * points[0].x;
let b = 6 * points[0].x - 12 * points[1].x + 6 * points[2].x;
let c = 3 * points[1].x - 3 * points[0].x;

还记得初中数学如何判断二次函数有几个根吧?

delta 即 b^2-4ac 判断 大于等于 0 即为有解

let delta = b * b - 4 * a * c;

判断有解后找到局部极限值 (local extreme)

代入求根公式:

t1 = (-b + Math.sqrt(delta)) / (2 * a);
t2 = (-b - Math.sqrt(delta)) / (2 * a);

我们只关心 0 <= t <= 1 的情况

将得到和 t1, t2 分别代入贝塞尔曲线公式

x = A (1-t)^3 +3 B t (1-t)^2 + 3 C t^2 (1-t) + D t^3

得到的就是真实的 x 坐标值,

所以需 x 要判断

if (x < xl) xl = x;
if (x > xh) xh = x;

记住是求出的二个根 t1, t2 分别代入判断

它有可能是最大值,也有可能是最小值 记作: xl, xh

对 y 同样进行一模一样的计算,t3, t4 也可以得到一最大值与最小值 记作:yl, yh

将它们从起点 左下,左上,右上,右下,左下终点 的顺序连接起来就是我们要的 BB 包围盒

ctx.moveTo(xl, yl); // 起点,左下
ctx.lineTo(xl, yh); // 左上
ctx.lineTo(xh, yh); // 右上
ctx.lineTo(xh, yl); // 右下
ctx.lineTo(xl, yl); // 终点,左下

如上图,包围盒围起来了,解决了计算贝塞尔曲线宽高计算的问题

画出切线验证

再把曲线的切线画出来,这回我们不画垂直向量,直接画切线

切线向量这道菜已经吃过了…

将 t 步长设为 0.1, 进行曲线采样, 画出绿色的切线

for( let t=0; t <=1; t += 0.1){
      // 绘制起点移动到对应的曲线点上
      const sx = calcBezierByT(pointXArray, t);
      const sy = calcBezierByT(pointYArray, t);
      ctx.moveTo(sx, sy)

      // a t^2 + b t + c 
      // 切线向量
      let vx = a1 * Math.pow(t,2) + b1 * t + c1
      let vy = a2 * Math.pow(t,2) + b2 * t + c2
      // 缩至单位向量
      let magnitude = Math.sqrt(vx * vx + vy * vy)
      // vx = -vx / magnitude;
      // vy = -vy / magnitude;
      vx = vx / magnitude;
      vy = vy / magnitude;
      // 向量长度变长 30 个单位
      vx *= 30
      vy *= 30
      ctx.strokeStyle = 'green';  
      ctx.lineTo(sx + vx,  sy + vy);
      }
      ctx.stroke();
}


(绿色颜色有点儿淡了感觉…)

代入上一节算出的 t1, t2, t3, t4 用红色画出局部极限值 (local extreme) 验证

注意 曲线不同,t1, t2, t3, t4 的值有可能有,有可能没有,且我们需要的是 t1 >= 0

需要这样处理

// 过滤
const tArray = [t1, t2, t3, t4].filter((t)=> t >= 0);

for( let i=0; i <= tArray.length; i++){
      ...与上面生成切线一样,只是 t 值是从 tArray 获取,而不是 0.1 步长
}

可以看到,红色标出的果然很 “极限”

代入不同的坐标值看看

const points = [
      { x: 20, y: 340 },
      { x: 50, y: 400 },
      { x: 320, y: 180 },
      { x: 480, y: 340 },
    ];

const points = [
{x:  13, y: 224 }, 
{x: 150, y: 100 },
{x: 251, y:  93 }, 
{x: 341, y: 224 }, 
];


(绿色颜色快看不出来了,PC 上的微信截图工具会模糊截图…)

可以看到,有些曲线极限值就不一定有四个

https://github.com/willian12345/blogpost/blob/main/curve/bezier/aabb.html

后续

贝塞尔曲线虽然原理很简单,但深入后就会特别复杂,你们好好深入,反正以我的能力是深入不了的

作为一个打工人,就要有打工人的觉悟,主打一个随意,没必要在一个问题上死磕

东看看,西看看,说不定回头再来看问题,已具备足够的知识与资料后就解决了

创业公司麻,就是这么的不稳定,何况是在这样一个环境下

最近公司要让我重新再接触 unity ,这又绕回来了, c# 其实挺好的

参考资料:

https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/bezierCurveTo

https://floris.briolas.nl/floris/2009/10/bounding-box-of-cubic-bezier/

https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve

https://pomax.github.io/bezierinfo/#boundingbox

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值