原作:Keith Peters https://www.bit-101.com/blog/2022/11/coding-curves/
译者:池中物王二狗(sheldon)
曲线艺术编程系列第8章 贝赛尔曲线
让我们回到真正的曲线上来。贝赛尔曲线编程就非常有趣领人止不住的想探索一翻, 你可以自己深入学习它的组成以及相应的公式。在我的视频或我的书里面这些事我做过很多次了。 下面是我做的两个视频你可以先看看:
https://www.youtube.com/watch?v=dXECQRlmIaE
https://www.youtube.com/watch?v=2hL1LGMVnVM
这是 Freya Holmer的 (译者注:此女的视频相当给力)
https://www.youtube.com/watch?v=aVwxzDHniEw
https://www.youtube.com/watch?v=jvPPXbo87ds
我还是仅限介绍最基础的一些函数以及这些年积累的一些很酷实用技巧与经验。
贝塞尔曲线由两个端点和一个控制点定义而成。它从一个点出发向控制点(不穿过控制点)再至另一个端点。你可以通过控制这些点中的任意改变曲线的形状。这些曲线通常很优美,应用于各种各样的设计工具,从绘制文字到绘制汽车,它是各种形状绘制的关键组成部分。
两个端点与一个控制点组成,如下:
控制点靠近 canvas 底部。如果你把它移动到右边,它会影响到曲线:
细一点的线和那个点主要用于可视化演示控制点的位置。
三阶贝塞尔曲线拥有两个端点和两个控制点,如图:
高阶贝塞尔曲线拥有更多的控制点,但花费的计算成本也相应会变的更高。 可以看看 Freya 的相关视频讲解。
大多数绘图程序 api 都有提供二阶和三阶曲线的函数,但名字可能有比较大的出入。
我看过二阶贝塞尔曲线的函数有被命名为:
三阶贝塞尔曲线被命名为:
你得确保你使用的编程语言用的是哪一个。通常你可以参考上面列出的几个例子,起始点用 moveTo 定义, 否则起点将会是绘图 api 最近一次的绘制点,然后调用贝塞尔曲线函数定义控制点与结束点。你可以像下面这么做:
moveTo(100, 100)
cubicCurveTo(200, 100, 200, 500, 100, 300)
stroke()
但有些编程语言可以允许你一次设定所有点。它是作为基础的内建函数,当然我们还是会忽略具体内建的 api 我们必须自己实现一遍。
我们先从二阶贝塞尔曲线开始然后转向三阶贝塞尔曲线。但在我们开始画曲线路径前,我们需要先另外创建一个基础函数。它会提供贝塞尔曲线上任意点的点的位置。
有趣的一点是贝塞尔曲线基础公式是一维的。为达到二维,三四,或更高阶,你权需为每一维应用公式。这里我们需要用两个一维组合成二维,所以我们将执行两次。单参数公式如下:
x = (1 - t) * (1 - t) * x0 + 2 * (1 - t) * t * x1 + t * t * x2
此处,x0, x1, 和 x2 是两个端点与控制点,t 的值范围是 0.0 到 1.0。 它会根据 t 的值返回这条贝塞尔曲线上对应 x 点。 当 t 为 0, x 等于 x0。 当 t 为 1, x 等于 x2。 当 t 在 0 和 1 之间时,x 会是是插值。
所以要创建一个二阶贝塞尔曲线点的函数应该像下面这样做:
function quadBezierPoint(x0, y0, x1, y1, x2, y2) {
x = (1 - t) * (1 - t) * x0 + 2 * (1 - t) * t * x1 + t * t * x2
y = (1 - t) * (1 - t) * y0 + 2 * (1 - t) * t * y1 + t * t * y2
return x, y
}
你可以这么做,如果你的编程语言支持返回多个值的话。否则你需要将返回值变成类似点对象。
注意,我们先去重一下。我们可以先提取 1-t 为 m 因子:
function quadBezierPoint(x0, y0, x1, y1, x2, y2, t) {
m = (1 - t)
x = m * m * x0 + 2 * m * t * x1 + t * t * x2
y = m * m * y0 + 2 * m * t * y1 + t * t * y2
return x, y
}
然后
function quadBezierPoint(x0, y0, x1, y1, x2, y2, t) {
m = (1 - t)
a = m * m
b = 2 * m * t
c = t * t
x = a * x0 + b * x1 + c * x2
y = a * y0 + b * y1 + c * y2
return x, y
}
无它,就是更易读。
有了它就可以用它画二阶贝塞尔曲线了。为了清晰的定义二阶与三阶,我把它们分别命名为 quadCurve 和 cubicCurve。
function quadCurve(x0, y0, x1, y1, x2, y2, res) {
moveTo(x0, y0)
for (t = res; t < 1; t += res) {
x, y = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
lineTo(x, y)
}
lineTo(x2, y2)
}
确保在 quadCurve 我们将起始点与结束点拆出来了,我们 moveTo 拆出第一个点,用 lineTo 拆出最后一个点。 函数接受一个 res 参数,用于指定沿曲线上迭代多少次。我们将 t 初始值为 res 因为函数外已经移动到第一个点了,无论 t 值是否为 0。中间的所有点根据当前 t 绘制出线条。
当然,你也可以创建一个 quadCurveTo 函数去掉函数内前两个参数还有 moveTo(译者注:这里并非是让你去掉,而是让你自己决定是否单独在函数外面调用)。 这取决于用户自己是否需要指定曲线起始点(或从已有的路径开始绘制)。以下是调用方式:
canvas(800, 800)
quadCurve(100, 100, 200, 700, 700, 300, 0.01)
stroke()
这会生成如下图:
如果 res 变大一点,则会生成一个有点糙的曲线:
你已经对 res 分辨率这个值有一定经验了。内建的贝塞尔曲线会自动给定一个合适的 res 值。但我们自己实现的 quadCurve 函数内 res 值可能还是有点儿问题的。但在此处并不重要,因为它已经能让 quadBezierPoint 返回给我们足够的坐标值了,正如你所见的这样。
我们的 quadBezierPoint 能用于实现动画,而内建函数做不到(译者注:内建函数只能一次性画出路径)。在这一节, 就像之前章节我做的那样, 我已经假定你有或有能力实现无限循环的函数用于创建动画了。 还是叫它 loop 函数。 我不会像之前那样用 t 实现 0 到 1 绘制曲线,我将 让 t 从 0 到 finalT , finalT 的值会一直变化。
canvas(400, 400)
x0 = 50
y0 = 50
x1 = 150
y1 = 360
x2 = 360
y2 = 150
finalT = 0
dt = 0.01
res = 0.025
function loop() {
clearCanvas()
moveTo(x0, y0)
for (t = res; t < finalT; t += res) {
x, y = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
lineto(x, y)
}
stroke()
// add to finalT
finalT += dt
// if we go past 1, turn it around
if (finalT > 1) {
finalT = 1
dt = -dt
} else if (finalT < 0) {
// if we go past 0, turn it back
finalT = 0
dt = -dt
}
}
结果应该会像下面这样的动画
此处, for 环境内 t 是从 res 到 finalT 变化的所以不会画出完整的曲线(除非 finalT 为 1)。然后我们给 finalT 加上 dt。 这会让 finalT 慢慢接近 1, 这会曲线越来越完整。当 finalT 超过 1 时, 我们将它设为负值,这会让整个过程反转直到 finalT 变为 0, 这是我们变回出发点的方法。(译都注:其实就是当 finalT 超过临界点后,通过将 dt 设为 -dt 使得 finalT 一直在 1 和 0 之间来回变动)
相比于画一条线, 我们这次做一个沿贝塞尔曲线运动的动画!下面是代码示例。相当清晰明了。我只是添加了一个实心圆的逻辑放进 loop 函数内,剩下的代码和之前一样。
function loop() {
clearCanvas()
x, y = quadBezierPoint(x0, y0, x1, y1, x2, y2, finalT)
circle(x, y, 10)
fill()
// no changes beyond here...
// add to finalT
finalT += dt
// if we go past 1, turn it around
if (finalT > 1) {
finalT = 1
dt = -dt
} else if (finalT < 0) {
// if we go past 0, turn it back
finalT = 0
dt = -dt
}
}
这样我们就得到了 finalT 的当前值对应点的 x, y 并在 x, y 处画了个圆。假定你已经有了 circle 绘制函数。你如果有需要你可以在第三章里复制一个过来。
在下面这个 gif 图,是我用内建的函数绘制的相同曲线,多来了一条细线表示运动轨道展示动画一直在我们我们定义的标准的二阶贝塞尔曲线上。
好的,稍作休息后让我们进入三阶贝塞尔曲线。
上面介绍的二阶贝塞尔曲线都将应用到三阶上。只是公式不一样 - 更复杂一点点。下面是一维的定义:
x = (1 - t) * (1 - t) * (1 - t) * x0 + 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t * x3
还有二维函数的定义:
function cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
x = (1 - t) * (1 - t) * (1 - t) * x0 + 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t * x3
y = (1 - t) * (1 - t) * (1 - t) * y0 + 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t * y3
return x, y
}
是的看起来相当乱,我们同样提取出 1- t 因子出来整理一下:
function cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
m = 1 - t
x = m * m * m * x0 + 3 * m * m * t * x1 + 3 * m * t * t * x2 + t * t * t * x3
y = m * m * m * y0 + 3 * m * m * t * y1 + 3 * m * t * t * y2 + t * t * t * y3
return x, y
}
好一点儿了,更进一步优化后:
function cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t) {
m = 1 - t
a = m * m * m
b = 3 * m * m * t
c = 3 * m * t * t
d = t * t * t
x = a * x0 + b * x1 + c * x2 + d * x3
y = a * y0 + b * y1 + c * y2 + d * y3
return x, y
}
还可以!
现在可以创建 cubicCurve 三阶贝塞尔曲线函数了。
function cubicCurve(x0, y0, x1, y1, x2, y2, x3, y3, res) {
moveTo(x0, y0)
for (t = res; t < 1; t += res) {
x, y = cubicBezierPoint(x0, y0, x1, y1, x2, y2, x3, y3, t)
lineTo(x, y)
}
lineTo(x2, y2)
}
很简单。我想不需要更多的解释了。
现在你的任务是:调整二阶动画用三阶来实现一遍。仅仅需要加一个 x3, y3 的新坐标点并调用这个新函数。
这些就是对贝塞尔曲线和路径的基础代码实现了。但我这里还准备了一些其它有用的小技巧给你。
那些刚开始使用贝塞尔曲来线编程的人经常会说
这很巧妙,但我希望曲线能穿过控制点 ---- 这也是我-大约在 2000 年左右想要实现的功能
当然可以实现了!这对二阶贝塞尔曲线相当容易实现。你只需要在更远的地方创建另一个控制点,控制曲线刚好穿过原控制点的位置。新的控制点很容易计算。以点 x0, y0, x1, y1, x2, y1 为例,那么新控制点会是:
x = x1 * 2 - x0 / 2 - x2 / 2
y = y1 * 2 - x0 / 2 - x2 / 2
现在我们可以创建地一个新函数 quadCurveThrough 实现上面的代码公式。下面是计算获取新控制点并使用内建函数实现贝塞尔曲线绘制。我假定你的系统中也有名为 quadraticCurveTo 的函数,当然也可能名字不同。
function quadCurveThrough(x0, y0, x1, y1, x2, y2) {
xc = x1 * 2 - x0 / 2 - x2 / 2
yc = y1 * 2 - y0 / 2 - y2 / 2
moveTo(x0, y0)
quadraticCurveTo(xc, yc, x2, y2)
}
下图中红的是我用标准的二阶贝塞尔曲线画的,蓝的是用新函数画的。并且绘制了那些控制点用于证明。
你下一个问题一定是如何在三阶贝塞尔曲线实现同样的过控制点绘制曲线。我暂时还不知道,但我会一直探索。我猜这也是一个机会,也许有人会在评论区给出答案,或直接告诉我这不可能实现🙂
人们通常会问的另一个问题是:
我如何绘制 N 个控制点的贝塞尔曲线(N 为 3 到 无穷)?早期这个问题也困扰着我
正如我之前提到过的,在数学上是可行的,但可以肯定的是从三阶往上它相当消耗性能。这也就是为什么你几乎没怎么见过四阶贝塞尔曲线函数。但对于创建一条拥有任意控制点的平滑曲线还是非常有用的。当然,你肯定已经在某些绘图软件用过“钢笔”这种类工具了。
在上面视频样条曲线(第二个作者为 Freya), 她展示了如何使用多个三阶贝赛尔曲线组成长曲线(样条曲线)
https://www.youtube.com/watch?v=jvPPXbo87ds
有时被称为分段二阶贝塞尔曲线,我将展示一点简单的例子使用二阶曲线。它的实现并不难且它支持任意多的控制点。我甚至会为你创建一个曲线闭环的版本。
这项技术在我的视频中讲过了:
https://www.youtube.com/watch?v=2hL1LGMVnVM
所以在此处我不会深入太多,仅覆盖基础部分并给你一些示例。
基本原则是在 “p0 p1” 之间画创建一个中点称为 pA。然后再创建一个 p1 与 p2 之间的中点设为 pB 。连接 p0 到 pA,然后使用 pA,p1 和 pB 绘制二阶贝塞尔曲线。
然后你找到 p2 和 p3 之间的 pC 并且从 pB 绘制二阶贝塞尔曲线通过控制点 p2 到达 pC
继续以上步骤直到倒数第二个中间点,穿过倒数第二点,结束在最后一个中间点。最终连接最后一个中间点到最后一个结束点。
下面是画出的曲线:
实现这个效果的代码看起来有点儿棘手,但我已经弄过好几回了,很庆幸已经有了像下面这样的函数。注意,为了能传递大量参数, 参数需要定义成某种类型的对象。无论它是类,结构,或拥有x, y 属性的普通对象...。根据你自己使用的编程语言选择吧。此函数使用数组存储坐标点。假定数组有个 length 属性,在你的编程语言中有可能它不是一个属性,而是一个获取数组长度的方法。
function multiCurve(points) {
// line from the first point to the first midpoint.
// 连接第一个点到第一个中间点
moveTo(points[0].x, points[0].y)
midX = (points[0].x + points[1].x) / 2
midY = (points[0].y + points[1].y) / 2
lineTo(midX, midY)
// loop through the points array, starting at index 1
// and ending at the second-to-last point
// 循环数组,index 从 1 开始至倒数第二个点结束
// (译者注:注意这循环内的最开始的 p0 其实是数组中的第二个点了)
for (i = 1; i < points.length - 1; i++) {
// find the next two points and their midpoint
p0 = points[i]
p1 = points[i+1]
midX = (p0.x + p1.x) / 2
midY = (p0.y + p1.y) / 2
// curve through next point to midpoint
// 从下一个点开始绘制二阶曲线至中间点
quadraticCurveTo(p0.x, p0.y, midX, midY)
}
// we'll be left at the last midpoint
// draw line to last point
// 连接最后一个中间点到结束点。
p = points[points.length - 1]
lineTo(p.x, p.y)
}
方法看起来有点儿长,但我为每部分加了对应的注释。
如下例子中,我添加了半打(译者注:一打是 12 个 半打 是 6 个)随机坐标点。我不知道你使用的编程语言中如何生成这些随机数,我假定你会有 randomPoint(xmin, ymin, xmax, ymax) 这样的函数。(事实上我在自已函数库中确实实现过这样的函数!)一旦你有了这样的数组,把数组传进 multiCurve 后再调用 stroke 进行描边渲染:
context(800, 800)
points = []
for (i = 0; i < 6; i++) {
points.push(randomPoint(0, 0, 800, 800))
}
multiCurve(points)
stroke()
The glorious result:
看看这图:
相当不错。 曲线之所以看起来是样,是因为在生成这些随机数的数组时也是要根据上下文环境来的
最后部分将要介绍的是如何将函数改造成封闭曲线。主要是去除掉开始与结束线断,并且将它首尾相连。
function multiLoop(points) {
// find the first midpoint and move to it.
// we'll keep this around for later
// 找到最开始的那个中间点,将绘制点移至此点。
// 先存下来后面会用到
midX0 = (points[0].x + points[1].x) / 2
midY0 = (points[0].y + points[1].y) / 2
moveTo(midX0, midY0)
// the for loop doesn't change
// 循环和之前一样不用变
for (i = 1; i < points.length - 1; i++) {
p0 = points[i]
p1 = points[i+1]
midX = (p0.x + p1.x) / 2
midY = (p0.y + p1.y) / 2
quadraticCurveTo(p0.x, p0.y, midX, midY)
}
// we'll be left at the last midpoint
// find the midpoint between the last and first points
// 找到数组首尾间的中间点
p = points[points.length - 1]
midX1 = (p.y + points[0].x) / 2
midY1 = (p.y + points[0].y) / 2
// curve through the last point to that new midpoint
// 将最后一个点与首尾中间点相连
quadraticCurveTo(p.x, p.y, midX1, midY1)
// then curve through the first point to that first midpoint you saved earlier
// 然后再将数组第一个点与早前我们保存的第一个中间点连接
quadraticCurveTo(points[0].x, points[0].y, midX0, midY0)
}
我们先从第一个中间点开始,循环剩下的点,找到各自点的中间点并用二阶贝塞尔曲线相连。我们最终停留在最后一个中间点。然后...
找到首尾间的中间点,并将剩下两段曲线连在一起将形状闭合。如下图与上面一样的设置一样,但用 multiLoop 函数取代了之前的 multiCurve函数 (随机出的 points 数组值也不一样)。
这是我最爱的函数,我很乐意分享给你们。
最后要分享的技巧是如何在二阶贝塞尔曲线中均匀地分布对象。 一个实用的例子是要把文本放到曲线上并且均匀的分布。当然你肯定希望文本的角度也根据曲线的位置跟着旋转相应的角度,但这一部分超出了本篇文章的讨论范围。
乍一看很容易实现。你可以用 t 来分割曲线。如果你想在曲线上给 20 个对象留出空间, 每个对象占 0.05 份。 20 x 0.05 = 1.0。 搞定。让我们试试:
canvas(800, 800)
x0 = 100
y0 = 700
x1 = 100
y1 = 100
x2 = 700
y2 = 400
moveTo(x0, y0)
quadraticCurveTo(x1, y1, x2, y2)
stroke()
// 20 evenly spaced t values (21 counting the end one)
// 20 个等距的 t 值(算上最后一个是21个)
for (t = 0; t <= 1; t += 0.05) {
x, y = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
circle(x, y, 6)
fill()
}
Here’s what that gives us.
下面是我们得到的结果:
最终没有均分。尾部间隔较大,中间间隔又比较紧。这就是贝塞尔曲线的特点。所以我们得找到方法让这些点平均分布。
遗憾的是没有什么简单的方式来实现。我将用粗暴的方式强行实现它,当然也会给你一些用于优化它的提示。
为了在曲线上均分等距空格,直觉告诉我们需要先获取曲线的总长度。如果长度是 200 像素, 你想要 20 个点均分, 那么每间隔 10 像素就放一个。
意外的是没什么现成的公式可以在贝塞尔曲线实现这一效果。但我们可以在曲线上采样一堆的点,获取每个点之间的距离来模拟实现。代码大致如下:
function quadBezLength(x0, y0, x1, y1, x2, y2, count) {
length = 0.0
dt = 1.0 / count
x, y = x0, y0
for (t = dt; t < 1; t += dt) {
xn, yn = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
length += distance(x, y, xn, yn)
x, y = xn, yn
}
// (译者注:这里原作者用了 '==' 打错字了, 应该是 '+=')
length += distance(x, y, x2, y2)
return length
}
变量 count 是采样数量。采样越多,越精准。
然后 dt 是我们在曲线上循环迭代的步长。
我们追踪每一步循环的 x, y 点,初始值是 x0, y0 。然后循环迭代得到曲线上每一个新坐标点 xn, yn 并计算出上一次迭代点到此时新点的距离, 然后再将新值赋值给 x, y。不我打算展示如何计算两点间的距离 ,我假定你已经有这样的函数了。将距离累加进 length.
最后再将算最后x2, y2 与 x, y 最后值的距离加到 length 上。然后返回 length 结果
确保你完全明白了,因为我已准备在此处添加更多代码了。
沿曲线追踪每个点的距离非常有用。所以我们要把这些距离存进数组。这比返回整个长度有用,我们将直接返回这个数组
function quadBezLengths(x0, y0, x1, y1, x2, y2, count) {
lengths = []
length = 0.0
dt = 1.0 / count
x, y = x0, y0
for (t = dt; t < 1; t += dt) {
xn, yn = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
length += distance(x, y, xn, yn)
lengths.push(length)
x, y = xn, yn
}
length == distance(x, y, x2, y2)
lengths.push(length)
return lengths
}
现在曲线的完整长度存在了最后的数组元素中,并且我们还有一堆其它长度,下面看我如何应用:
count = 500
lengths = quadBezLengths(x0, y0, x1, y1, x2, y2, count)
length = lengths[count-1]
for (i = 0.0; i <= 1; i += 0.05) {
// the length of the curve up to the next point
// 曲线上下一个目标点的长度
targetLength = i * length
// loop through the array until the length is higher than the target length
// 循环数组直到 length 高于目标长度
for (j = 0; j < count; j++) {
if (lengths[j] > targetLength) {
// t is now the percentage of the way we got through the array.
// this is the t value we need to get the next point
// t 现在是数组的百分比
// 这是下一个点的 t 值
t = j / count
// get the point and draw the next circle.
// 获取下一个点,并绘制圆。
x, y = quadBezierPoint(x0, y0, x1, y1, x2, y2, t)
circle(x, y, 6)
fill()
break
}
}
}
好的,有点儿小复杂, 让我们过一遍代码。我们用设 count 为 500 采样得到了长度数组,且获取了总长度。。
就像之前一样创建了一个 0.05 为步长 从 0 至 1 的循环。但并不像在画贝塞尔曲线时使用的 t, 我们用它寻找曲线长度的百分比。意思是当曲线长度为 500 像素且 i 为 0.5 时,我们寻找的的目标点长度即为 250 像素。
现在我们用循环遍历数组 通过 j 获取长度值直到值大于 targetLength 结束内循环。我们将 j / count 得到这一部分特定的长度。我们 t 传入 quadBezierPoint 函数得到下一个点并把它绘制出来。此时我们应该跳出内部的循环,直到完成这些再入下一步循环。
忘了还有最后一个点(因为循环并未超过它的长度),但我们仅需要再绘制另一个坐标在 x2, y2 的点即可。这回相当接近均分了。 count 值设的越高,均分的精度就会越高。如果 count 降为 100 ,我们可以看下它的效果:
现在,代码中有许多错误。大多是优化上的。
首先,二分查询就比从 0 开始循环整个数组要好。
其次,我们不得不添加一大堆任意精度的点。用于在内循环中匹配查找。取一个预定义的点太费性能了, 我们可以在这个点与前一个点之间插值。
举个例子,假设我们的目标长度是150,然后在 index 87 位置我们得到了长度是值是 160。 index 往回到 86 我们得到 140。现在我希望得到 86 与 87 中间的值。 比起用 87 / count , 或 86 / count ,我们用插值 50% 即 86.5 / count。虽然还是不太完美,但你现在可以将 count 设低一点,但得到结果却依旧很好。
我将这些工作当作练习留给你
如果你想获取更多这类相关的技术信息和完整解释,可以参考以下网站(译者注:大致看了一眼链接的这篇文章,考虑后期也给翻译出来)
http://www.planetclegg.com/projects/WarpingTextToSplines.html
就到这里了,一些基础知识,一些小提示,一些小技巧,下一章见...
本章 Javascript 源码 https://github.com/willian12345/coding-curves/blob/main/examples/ch08
博客园: http://cnblogs.com/willian/
github: https://github.com/willian12345/