样条曲线与贝塞尔曲线: 重新审视它们的比较
样条曲线与贝塞尔曲线
样条曲线
样条曲线的起源
样条曲线起源于一个常见问题,即已知若干点的条件下,如何得到通过这些点的一条光滑曲线?
一个简单且行之有效的方法是,把这些点作为限制点,然后在这些限制点中放置一条具有弹性的金属片,最后金属片绕过这些点后的最终状态即为所需曲线。而最终得到的形状曲线,就是样条曲线。这也是该名字的由来,其中金属片就是样条,形成的曲线就是样条曲线。如下图(插值样条曲线)
样条曲线的分类
- 插值样条曲线(interpolate)
插值样条曲线生成的样条曲线通过这组控制点
- 逼近样条曲线(approximate)
逼近样条曲线生面的样条曲线不通过或通过部分控制点。
贝塞尔曲线
贝塞尔曲线的起源
贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。
贝塞尔曲线的数学基础是早在 1912 年就广为人知的伯恩斯坦多项式。
但直到 1959 年,当时就职于雪铁龙的法国数学家保尔·德·卡斯特里奥( Paul de Casteljau )才开始对它进行图形化应用的尝试,并提出了一种数值稳定的 德卡斯特里奥(de Casteljau)算法。
然而贝塞尔曲线的得名,却是由于 1962 年另一位就职于雷诺的法国工程师 皮埃尔·贝塞尔(Pierre Bézier) 的广泛宣传。
它使用这种只需要很少的控制点就能够生成复杂平滑曲线的方法,来辅助汽车车体的工业设计 于是我们就能看到这张精美的汽车模型图:
贝塞尔曲线的地位
正是因为控制简便却具有极强的描述能力,贝塞尔曲线在工业设计领域迅速得到了广泛的应用。
不仅如此,在计算机图形学领域,尤其是矢量图形学,贝塞尔曲线也占有重要的地位。 今天我们最常见的一些矢量绘图软件,如 Flash、Illustrator、CorelDraw 等,无一例外都提供了绘制贝塞尔曲线的功能。 甚至像 Photoshop 这样的位图编辑软件,也把贝塞尔曲线作为仅有的矢量绘制工具(钢笔工具)包含其中。
贝塞尔曲线在 web 开发领域同样占有一席之地。很多 JavaScript 动画,甚至 CSS3 新增的 transition-timing-function 属性,它的取值就可以设置为一个三次贝塞尔曲线方程。
卡斯特里奥(de Casteljau)算法
在平面内任选 3 个不共线的点,依次用线段连接
在第一条线段上任选一个点 D。计算该点到线段起点的距离 AD,与该线段总长 AB 的比例
根据上一步得到的比例,从第二条线段上找出对应的点 E,使得 AD/AB = BE/BC
连接这两点 DE
从新的线段 DE 上再次找出相同比例的点 F,使得 DF/DE = AD/AB = BE/BC
到这里,我们就确定了贝塞尔曲线上的一个点 F
接下来,让选取的点 D 在第一条线段上从起点 A 移动到终点 B,找出所有的贝塞尔曲线上的点 F
所有的点找出来之后,我们也得到了这条贝塞尔曲线
如果你实在想象不出这个过程,没关系,看动画
回过头来看这条贝塞尔曲线,为了确定曲线上的一个点,需要进行两轮取点的操作,因此我们称得到的贝塞尔曲线为二次曲线
当控制点个数为 4 时,情况是怎样的?
步骤都是相同的,只不过每确定一个贝塞尔曲线上的点,要进行三轮取点操作
如图,AE/AB = BF/BC = CG/CD = EH/EF = FI/FG = HJ/HI
其中点 J 就是最终得到的贝塞尔曲线上的一个点
这样我们得到的是一条三次贝塞尔曲线
三次贝塞尔绘制动画
从上面的演示图中,我们可以看到三次贝塞尔曲线需要 4 个坐标点
1、两个起始和终点坐标,也是两个控制点
2、另外两个控制点坐标
绘制样条曲线
绘制样条曲线
我们选用HTML5的画布(canvas)来实现绘制
canvas的函数bezierCurveTo(三次贝塞尔曲线)可以通过平滑曲线来连接两个点,因此是可以通过它来连接每一对的相邻点来实现绘制样条曲线 三次贝塞尔曲线由其端点(通常称为节点(knots))和两个控制点(control points)指定。但是贝塞尔曲线不会插值它们的控制点(它们不会穿过它们),所以我们不能只将端点列表传递给Bezier CurveTo(),我们必须找出这些控制点在哪里。
样条曲线必须穿过每一个节点,而且在节点处”平滑”连接,也就是说,在节点处拥有相同的斜率(一阶导数),但不需要具有相同的曲率(二阶导数)。同时,我们也希望可以调节曲线的平滑度
图1展示了我们的目标:如果我们可以创建两条贝塞尔曲线(以红色和橙色显示),它们在点x0和x2开始和结束,并在点x1平滑连接,那么我们可以重复这个过程并将任意数量的节点拼接在一起。
第一,为了实现平滑连接,节点两侧的控制点必须位于穿过节点的直线上
第二,该直线应平行于连接节点两侧的直线。
如图2所示,我们的控制点将位于L1线上的某个位置,L1与L02线平行。
为了更清晰,图3显示了我们需要的更多东西:首先,节点d01和d12之间的距离。这些都很容易从节点(x,y)坐标计算出来,这是我们的输入数据。我们还需要直角三角形T,它连接x0和x2,宽度为w,高度为h,也可以通过节点坐标轻松计算。
最后一步是画出T的两个较小的相似三角形(相同的角度和方向),它们的长边沿L1线(平行)并在x1点连接。
这些较小的相似三角形Ta和Tb是从T按两个因素缩小: 第一,分别是距离d01和d12,
第二,是常量参数t。
按节点间距离成比例缩放是获得适当曲率的简单方法:如果两个节点非常接近,那么它们之间的控制点也应该靠近节点,得到的曲线也会比较陡峭。T的缩放为我们提供了Ta和Tb的宽度和高度,通过这些,我们很容易得到控制点p1和p2的坐标。
过程对应的Javascript:
function getControlPoints(x0,y0,x1,y1,x2,y2,t){
var d01=Math.sqrt(Math.pow(x1-x0,2)+Math.pow(y1-y0,2));
var d12=Math.sqrt(Math.pow(x2-x1,2)+Math.pow(y2-y1,2));
var fa=t*d01/(d01+d12); // scaling factor for triangle Ta
var fb=t*d12/(d01+d12); // ditto for Tb, simplifies to fb=t-fa
var p1x=x1-fa*(x2-x0); // x2-x0 is the width of triangle T
var p1y=y1-fa*(y2-y0); // y2-y0 is the height of T
var p2x=x1+fb*(x2-x0);
var p2y=y1+fb*(y2-y0);
return [p1x,p1y,p2x,p2y];
}
在这些草图中,我们发现了两个控制点,但对于不同的贝塞尔曲线:需要控制点p1(图4)来绘制左贝塞尔曲线(图1和图2中为红色),需要控制点p2来绘制右贝塞尔曲线(橙色)。
这只意味着我们必须在绘图之前计算所有的控制点
绘制出所有控制点后,我们就可以结合本来的节点绘制出样本曲线,不过还需要注意如下的规则:
1、起始段跟结束段(非闭合曲线)(黄色矩形框框着的曲线)的贝塞尔曲线需要用二次贝塞尔曲线绘制(因为只有一个控制点)(黄色圆框框着的控制点)
2、其余曲线段需要用三次贝塞尔曲线绘制(例如蓝色框框着的曲线),因为可以找到两个控制点(蓝色圆框框着的控制点)
绘制示例网站
示例网址: scaledinnovation.com/analytics/s…
在演示中,当t=0时,曲线成为连接结点的直线,当t=1时,曲线对于开放式之字形曲线来说“太弯曲”
t没有上限,但在t=1以上,你肯定会得到不相关的尖点和环。t也可以是负数,这会画出一些环。
动态绘制
动态绘制
动态绘制的关键是我们需要求出我们画出的贝塞尔曲线非节点上的点的坐标
求贝塞尔曲线上的点,我们可以借助贝塞尔曲线的公式
二次贝塞尔曲线(二次方公式)
三次贝塞尔曲线(三次方公式)
一般参数曲线
方程对应的代码实现
// t代表起始与终点之间的进度,从0到1,例如算p0和p3中间的点,t取0.5
// 三次贝塞尔曲线方程
const CalculateBezierPointForCubic = (t, p0, p1, p2, p3) => {
var point = {x:0,y:0};
var k = 1 - t;
point.x = p0.x * k * k * k + 3 * p1.x * t * k * k + 3 * p2.x * t * t * k + p3.x * t * t * t;
point.y = p0.y * k * k * k + 3 * p1.y * t * k * k + 3 * p2.y * t * t * k + p3.y * t * t * t;
return point;
}
// 二次贝塞尔曲线方程
const quadraticBezier = (t, p0, p1, p2) => {
var point = {x:0,y:0}
var k = 1 - t;
point.x = k * k * p0.x + 2 * (1 - t) * t * p1.x + t * t * p2.x;
point.y = k * k * p0.y + 2 * (1 - t) * t * p1.y + t * t * p2.y;
return point
}
实现动态绘制的代码实现
// HTML部分
<canvas id="smooth_line" height="500" width="1000"></canvas>
// js实现部分
const canvasHeight = 500
const canvasWidth = 1000
// 根据起起始点、中间经过点、终点算出两个控制点
function getControlPoints(x0,y0,x1,y1,x2,y2,t){
var d01=Math.sqrt(Math.pow(x1-x0,2)+Math.pow(y1-y0,2));
var d12=Math.sqrt(Math.pow(x2-x1,2)+Math.pow(y2-y1,2));
var fa=t*d01/(d01+d12); // scaling factor for triangle Ta
var fb=t*d12/(d01+d12); // ditto for Tb, simplifies to fb=t-fa
var p1x=x1-fa*(x2-x0); // x2-x0 is the width of triangle T
var p1y=y1-fa*(y2-y0); // y2-y0 is the height of T
var p2x=x1+fb*(x2-x0);
var p2y=y1+fb*(y2-y0);
return [p1x,p1y,p2x,p2y];
}
// 算出一组点对应的控制点
function createControlPoints(points, t=0.5) {
let controlPoints = []
for(let i=0;i<points.length-2;i++) {
const controlPoint = getControlPoints(points[i].x, points[i].y, points[i+1].x, points[i+1].y, points[i+2].x, points[i+2].y, t)
controlPoints.push({x:controlPoint[0], y:controlPoint[1]}, {x:controlPoint[2], y:controlPoint[3]})
}
return controlPoints
}
// 三次贝塞尔曲线方程
const CalculateBezierPointForCubic = (t, p0, p1, p2, p3) => {
var point = {x:0,y:0};
var k = 1 - t;
point.x = p0.x * k * k * k + 3 * p1.x * t * k * k + 3 * p2.x * t * t * k + p3.x * t * t * t;
point.y = p0.y * k * k * k + 3 * p1.y * t * k * k + 3 * p2.y * t * t * k + p3.y * t * t * t;
return point;
}
// 二次贝塞尔曲线方程
const quadraticBezier = (t, p0, p1, p2) => {
var point = {x:0,y:0}
var k = 1 - t;
point.x = k * k * p0.x + 2 * (1 - t) * t * p1.x + t * t * p2.x;
point.y = k * k * p0.y + 2 * (1 - t) * t * p1.y + t * t * p2.y;
return point
}
let smoothCanvas = document.querySelector('#smooth_line')
let smoothCanvasContext2D = smoothCanvas.getContext('2d')
const cleanCanvas = () => {
smoothCanvas = document.querySelector('#smooth_line')
smoothCanvasContext2D = smoothCanvas.getContext('2d')
smoothCanvasContext2D.clearRect(0, 0, canvasWidth, canvasHeight);
smoothCanvasContext2D.strokeStyle = '#0cc'
smoothCanvasContext2D.strokeRect(0, 0, canvasWidth, canvasHeight)
}
// 根据给定的一组点、平滑度绘制出样条曲线
const drawPointsSmoothLine = (ctx, points, t=0.5) => {
ctx.beginPath()
let controlPoints = createControlPoints(points, t)
for(let i=0;i<points.length-1;i++) {
if(i===0) {
ctx.save()
ctx.beginPath()
ctx.moveTo(points[i].x, points[i].y)
ctx.quadraticCurveTo(controlPoints[0].x, controlPoints[0].y, points[i+1].x, points[i+1].y)
ctx.stroke();
ctx.closePath()
ctx.restore()
} else if(i===points.length - 2) {
ctx.save()
ctx.beginPath()
ctx.moveTo(points[i].x, points[i].y)
ctx.quadraticCurveTo(controlPoints[controlPoints.length -1].x, controlPoints[controlPoints.length -1].y, points[i+1].x, points[i+1].y)
ctx.stroke();
ctx.closePath()
ctx.restore()
} else {
ctx.save()
ctx.lineTo(points[i].x, points[i].y)
ctx.beginPath()
ctx.moveTo(points[i].x, points[i].y)
ctx.bezierCurveTo(controlPoints[2*(i-1) + 1].x, controlPoints[2*(i-1) + 1].y, controlPoints[2*(i-1) + 2].x, controlPoints[2*(i-1) + 2].y, points[i+1].x, points[i+1].y)
ctx.stroke();
ctx.closePath()
ctx.restore()
}
}
}
// 绘制某个点
const drawPoint = (ctx, {x,y}, radius, color='#0cc') => {
ctx.save()
ctx.beginPath()
ctx.fillStyle = color
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
ctx.restore()
}
// 需要外部停止绘制时使用
let animationId = 0
// staticData是传入的数据, 这里是一些示例的数据
const staticData = [
{
"x": 10,
"y": 41.02601317115307
}, {
"x": 60,
"y": 367.53590353849495
}, {
"x": 110,
"y": 55.44857431904126
}, {
"x": 160,
"y": 228.40188926709214
}, {
"x": 210,
"y": 243.49719756836208
}, {
"x": 260,
"y": 31.12232864622555
}, {
"x": 310,
"y": 312.980045442814
}, {
"x": 360,
"y": 380.36533757874724
}, {
"x": 410,
"y": 153.25910899820738
}, {
"x": 460,
"y": 370.25905804521585
}
]
let originPoints = staticData
// 绘制曲线的平滑度(0到1)
const smoothDegree = 0.5
let startTime
// 当前时间位于两点间的百分比
let nowT = 0
// 当前的起始点序号
let nowIndex = 0
// 表示每两个点的时间间距是1000毫秒
let perPointTime = 1000
const draw = (timestamp) => {
if (startTime === undefined) {
// 第一次进入绘制函数时初始化赋值
startTime = timestamp;
}
// 绘制开始后经过的时间
const elapsed = timestamp - startTime;
// 当前处于第intNum个点与第intNum+1个点之间
let intNum = Math.floor(elapsed / perPointTime)
// 当前处于第intNum个点与第intNum+1个点之间的百分比(保留两位小数)
let otherNum = Math.floor((elapsed / perPointTime - Math.floor(elapsed / perPointTime)) * 100)/100
nowIndex = intNum
nowT = otherNum
if(intNum === originPoints.length - 1) {
// 绘制到最后的点后停止继续绘制
return
}
// 清空之前的绘制与绘制边框
cleanCanvas()
let points = originPoints.map(item => { return { x: item.x, y:item.y} })
// 算出的控制点数组
let controlPoints = createControlPoints(originPoints).map(item => { return { x: item.x, y:item.y } })
// 当前时间对应的点
let NowPoint = {x:0,y:0}
// 算出当前时间点的x和y坐标,
if(nowIndex === 0) {
// 第一段曲线是用二次贝塞尔曲线绘制,所以用二次贝塞尔曲线方程
NowPoint = quadraticBezier(nowT, points[nowIndex], controlPoints[0], points[nowIndex + 1])
} else if(nowIndex === points.length - 2) {
// 最后一段曲线是用二次贝塞尔曲线绘制,所以用二次贝塞尔曲线方程
NowPoint = quadraticBezier(nowT, points[nowIndex], controlPoints[controlPoints.length -1], points[nowIndex+1])
} else {
// 其他段曲线是用三次贝塞尔曲线绘制,所以用三次贝塞尔曲线方程
NowPoint = CalculateBezierPointForCubic(nowT, points[nowIndex], controlPoints[2*(nowIndex-1)+1], controlPoints[2*(nowIndex-1)+2], points[nowIndex + 1])
}
// 画控制点与控制线
//
// for(let i=0;i<controlPoints.length;i=i+2) {
// drawPoint(smoothCanvasContext2D, controlPoints[i], 2, 'black')
// drawPoint(smoothCanvasContext2D, controlPoints[i+1], 2, 'black')
// smoothCanvasContext2D.save()
// smoothCanvasContext2D.beginPath()
// smoothCanvasContext2D.moveTo(controlPoints[i].x, controlPoints[i].y)
// smoothCanvasContext2D.lineTo(controlPoints[i+1].x, controlPoints[i+1].y)
// smoothCanvasContext2D.strokeStyle = 'rgba(0,0,0,0.5)'
// smoothCanvasContext2D.stroke()
// smoothCanvasContext2D.closePath()
// smoothCanvasContext2D.restore()
// }
// 当前点在第一个点和最后一个点之间所占距离的百分比
const nowLastLinePercent = (NowPoint.x - points[0].x)/(points[points.length - 1].x - points[0].x)
smoothCanvasContext2D.save()
// 创建一个线性渐变, 在nowLastLinePercent之前使用之前设置的填充样式,在nowLastLinePercent后设置为透明
const gradient = smoothCanvasContext2D.createLinearGradient(points[0].x,0, points[points.length - 1].x,0);
gradient.addColorStop(0, smoothCanvasContext2D.strokeStyle);
gradient.addColorStop(nowLastLinePercent, smoothCanvasContext2D.strokeStyle);
gradient.addColorStop(nowLastLinePercent,"white");
gradient.addColorStop(1,"white");
// 设置填充样式
smoothCanvasContext2D.strokeStyle = gradient
// 绘制曲线
drawPointsSmoothLine(smoothCanvasContext2D, points, smoothDegree)
// 画出所有节点
// for(let i=0;i<points.length;i++) {
// drawPoint(smoothCanvasContext2D, points[i], 2, 'red')
// }
// 画出当前所在点
// drawPoint(smoothCanvasContext2D, NowPoint, 2, 'red')
animationId = window.requestAnimationFrame(draw);
}
animationId = window.requestAnimationFrame(draw);
实际上上述代码的本质就是通过算出当前时间对应在整段曲线上的点的位置,然后再根据这个位置生成一段渐变样式,在当前点后的渐变样式变成透明,然后使用这段渐变作为填充样式绘制样条曲线。
然后把这段逻辑通过requestAnimationFrame来一直计算新的点和重新绘制,就达到我们想要的动态绘制效果。
当然,我们还可以通过把所有对应的点根据时间进行平移和管理,做到动态添加节点和实时显示样条曲线的效果,这里就不再展开讨论。
参考资料
# 详解样条曲线(上)(包含贝塞尔曲线)
# Canvas 按路径画圆滑曲线 bezierCurveTo
# 贝塞尔曲线的入门
# Spline Interpolation
# # Spline Interpolation DEMO
# Canvas 按路径画圆滑曲线 bezierCurveTo
# 百度百科 贝塞尔曲线
上一篇: NCL专辑 | 汇总常用插值函数
下一篇: 使用MATLAB实现三次样条插值函数