贝赛尔曲线及其应用全面解析
1.概念
贝塞尔曲线(Bezier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,一条贝塞尔曲线由路径点和控制点确定,控制点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。贝塞尔曲线是计算机图形学中相当重要的参数曲线,在一些比较成熟的位图软件中也有贝塞尔曲线工具,如PhotoShop等。
通俗一点讲,大家每个人的Windows电脑自带的画图软件就有贝赛尔曲线,如下图所示:
这是一条三次贝赛尔曲线。我们在画图工具中选中曲线模式(红色箭头所指),将鼠标从左拖到右边(起始点、结束点,为该曲线的路径点),然后在左下方、右上方(为该曲线的控制点)分别点一下鼠标左键,就形成了上图这条贝赛尔曲线。大家马上就可以通过你系统自带的画图软件来感受一下什么是贝赛尔曲线。下面从数学角度描述一下贝赛尔曲线的基本概念。
上文说到了“画图”软件使用的曲线是一个“三次曲线”,那么这个三次是什么意思?实际上,三次贝塞尔曲线就表示控制点有两个,而二次贝赛尔曲线就表示控制点只有一个,以此类推。本文将着重讨论二次贝赛尔曲线和三次贝塞尔曲线。后文使用的程序语言均为JavaScript。
1.1二次方公式
二次方贝兹曲线的路径由给定点P0(起始点)、P1(控制点)、P2(结束点)的函数B(t)描述:
B(t)=(1−t)2P0+2t(1−t)P1+t2P2,t∈[0,1]
1.2三次方公式
P0、P1、P2、P3四个点在平面或在三维空间中定义了三次方贝兹曲线。曲线起始于P0走向P1,并从P2的方向来到P3。P1和P2是两个控制点(一般不会经过控制点P1或P2,除非是直线),这两个点只是在那里提供方向资讯。曲线开始于P0,结束于P3:
B(t)=P0(1−t)3+3P1t(1−t)2+3P22(1−t)+P3t3,t∈[0,1]
大家如果读到这里对这两个公式看的一头雾水的,可以看一下下面这段提示说明。如果对矩阵运算以及代数掌握较好,对这两个公式的表述没什么疑问的读者,可以跳过下面这段提示说明。
对贝塞尔曲线公式的详细说明
上面两个公式实际上是对一个矩阵运算(向量运算)的描述。那么什么是一个向量运算呢,举个最简单的例子:有一个坐标点P1坐标为(0,0),一个P2坐标为(1,1),P3坐标为(1,1),以(0,0)为原点建立向量坐标系,则可以得出P0,P1,P2的关系为:P1+P2=P3。转化为坐标点公式就是:(x1,y1)+(x2,y2)=(x3,y3)。进一步转化就可以得到下面的方程式:
{x1+x2=x3y1+y2=y3}上面的公式就描述了向量P1,P2,P3之间的关系,也就是一个基本的向量运算。相信一些读者看到这里应该已经能明白贝塞尔曲线的数学公式是怎么回事了。对于二次贝塞尔曲线公式:
B(t)=(1−t)2P0+2t(1−t)P1+t2P2,t∈[0,1] P0为向量起点,P1为一个控制点向量,P2为向量重点,曲线以t为变量,t的作用域为0到1,当t=0时,公式化简为B(t)=(1−0)2P0+2∗0(1−0)P1+02P2,即B(t)=P0,因此t=0时,就是起点P0的坐标点。同理,当t=1时,就是结束点P2的坐标点。我们再针对二次贝塞尔曲线公式举一个具体的例子来加深理解:假设有一个起点P0(0,0),一个控制点P1(1,1),和一个结束点P2(2,1),当取t的值为0.5时,根据这三个点描述的贝赛尔曲线的坐标即为:
得 {x=1y=0.75},即(1,0.75),这个点就是以P0,P1,P2控制的贝塞尔曲线,当t为0.5时的坐标点。如果通过微积分的方式把0<=t<=1的范围内坐标值全部求出来,连成线,就形成了一条贝赛尔曲线,后文(2.3节)会具体描述如何通过数学公式自己实现一条贝塞尔曲线。
2.绘制贝塞尔曲线
下面将具体描述使用Html5画布如何绘制一个静态的贝塞尔曲线。本文中,我们以Html5画布为绘制平台,JavaScript为编程语言。
2.1HTML5-Canvas API绘制贝塞尔曲线
Html5-Canvas API提供了原生绘制贝塞尔曲线的方法:quadraticCurveTo(二次方)和bezierCurveTo(三次方)。二次方方法传入控制点坐标以及结束点坐标,三次方方法传入控制点1、控制点2和结束点的坐标。下面以代码实例来讲解这两个方法的使用。
我们基于Html5-Canvas的原点为坐标系原点建立二维向量坐标系(即画布元素的左上角顶点为(0,0)原点,向右为x轴正方向,向下为y轴正方向),假设我们有P0(20,200),P1(110,220),P2(200,200)三个坐标点,要绘制一条起始于P0,以P1为控制点,最终结束于P2的二次方贝塞尔曲线的JavaScript代码如下:
//使用canvasAPI方法实现二次贝塞尔曲线
(function(){
var p0={x:20,y:200},p2={x:200,y:200},p1={x:110,y:220};
var canvas=document.getElementById("test2");
var context=canvas.getContext("2d");
context.beginPath();
context.moveTo(p0.x,p0.y);
context.quadraticCurveTo(p1.x,p1.y,p2.x,p2.y);
context.stroke();
})();
绘制结果如图(为了便于展示,同时绘制出了三个坐标点):
对于三次方贝塞尔曲线的绘制方法几乎相同。假设我们要绘制一条起始于P0(20,200),两个控制点分别为P1(80,220)P2(150,180),最终结束于P3(200,200)的三次贝塞尔曲线,代码如下:
//使用canvasAPI方法实现三次贝塞尔曲线
(function(){
var p0={x:20,y:200},p2={x:150,y:180},p1={x:80,y:220},p3={x:200,y:200};
var canvas=document.getElementById(“test2”);
var context=canvas.getContext(“2d”);
context.beginPath();
context.moveTo(p0.x,p0.y);
context.bezierCurveTo(p1.x,p1.y,p2.x,p2.y,p3.x,p3.y);
context.stroke();
})();
绘制结果如图:
使用API原生方法绘制一条简单的贝塞尔曲线示例是非常简单的,但是此刻也许有的读者会思考几个问题:上文只展示了最简单的曲线形式,那如何绘制一条经过多个项点的曲线?绘制曲线过程中的控制点应该如何选取?下面我们就来回答这两个问题。
2.2绘制经过多个端点的贝塞尔曲线图(折线图转化为平滑曲线的技术)
我们在许多场景下会希望将生硬的折线图优化成柔美的贝塞尔曲线图,那么如何绘制出一条穿过多个路径点的平滑曲线呢?其实最核心的技术点就在于控制点的选取。这一节我们着重介绍如何使用三次贝塞尔曲线来绘制穿过多个项点的平滑曲线,二次贝塞尔曲线绘制多点曲线的原理基本相同,我们不会多做介绍(二次比三次更简单),仅在本节末尾用一张坐标图来展示设计思想。
假设我们用下面这段代码绘制了下面这幅折线图,经过的端点依次为points数组内的坐标点(注意,Html5画布是以左上角为坐标原点,右为x正,下为y正):
(function(){
var points=[
{x:0,y:100},
{x:80,y:146},
{x:160,y:132},
{x:240,y:166},
{x:320,y:122},
{x:400,y:146},
{x:480,y:156}
];
var canvas=document.getElementById("test1");
var context=canvas.getContext("2d");
context.beginPath();
context.moveTo(points[0].x,points[0].y);
for(var i=1,l=points.length;i<l;i++){
context.lineTo(points[i].x,points[i].y);
}
context.stroke();
})();
折线图如下所示:
让我们来设想一下如何将这幅折线图绘制成曲线图:要保证曲线在顶点处连续,就要求左边曲线在顶点处的切线和右边曲线在顶点处的切线一致。即函数的左导数等于右导数。具体的做法有很多种,这里我们介绍一种简单的控制点定位方法,如下图所示:
在上图中我们看到的每一条虚线都是各个端点处的贝塞尔曲线切线(此处我们让每一条切线都平行于x轴),每一个小箭头处指示的坐标点都是我们计算出的贝塞尔曲线控制点(每段线段的三等分点平移到端点处与x轴平行),通过这样的控制方式,我们最终能够得到一条平滑的贝塞尔曲线,具体实现代码如下:
//折线图转三次曲线
(function(){
//经过的坐标点
var points=[
{x:0,y:100},
{x:80,y:146},
{x:160,y:132},
{x:240,y:166},
{x:320,y:122},
{x:400,y:146},
{x:480,y:156}
];
//控制点坐标数组
var ctrlPoints=[];
//计算控制点
var x0,y0,x1,y1;
//计算每个线段的控制点坐标,并存入ctrlPoints数组内
for(var i=0,l=points.length;i<l-1;i++){
x0=points[i].x+0.33*(points[i+1].x-points[i].x);
y0=points[i].y;
x1=points[i].x+0.66*(points[i+1].x-points[i].x);
y1=points[i+1].y;
ctrlPoints.push({
c0:{
x:x0,
y:y0
},
c1:{
x:x1,
y:y1
}
});
}
//开始绘图
var canvas=document.getElementById("test1");
var context=canvas.getContext("2d");
context.beginPath();
context.moveTo(points[0].x,points[0].y);
//使用HTML5API直接生成曲线
for(i=0;i<l-1;i++){
context.bezierCurveTo(ctrlPoints[i].c0.x,ctrlPoints[i].c0.y,
ctrlPoints[i].c1.x,ctrlPoints[i].c1.y,
points[i+1].x,points[i+1].y);
}
context.stroke();
})();
效果图如下(为方便说明,我们同时用小黑点绘制出了控制点):
控制点的计算方式可以是任意方式,例如我们可以改变各个端点处切线的斜率(上例中我们让每条切线都与x轴平行)、我们也可以改变控制点的x坐标(上例中我们选取在每条线段的距端点三分之一处)。
二次贝塞尔曲线的切线选取方式更简单,这里我们以二次曲线为例,同时介绍另一种选取控制点的策略:假设我们要绘制一条经过A,B,C三个端点的曲线,首先取线段AB和BC的中点,分别设为E和F点,连接EF,取EF的中点为D,将D点平移至B点,平移后的E’和F’就是我们得到的控制点,具体设计如下图所示:
用这种策略计算控制点坐标同样很简单,只需将DB的坐标偏移量加到E和F点上就能得到E’和F’,具体实现在此就不赘述了。这种控制点的计算策略同样可以应用于三次贝塞尔曲线。
注意1
这种选取策略在绘制经过“最大值”、“最小值”的端点时可能会出现非端点值“越界”的情况,因为不能保证每个端点的切线斜率与x轴平行,因此在绘制数据量较少的曲线图时应避免使用。下图展示了一种“越界”的情况:
B点是数据A,B,C的最大值,这张图的最高点应该在B点,但由于我们选取控制点的策略影响,贝塞尔曲线实际的最高点并不在B点,图中我们发现最高点比B点高出许多,有时候这种误差也许是不可接受的。
.
注意2
当路径点超过3个时,要绘制平滑的曲线至少得使用三次贝塞尔曲线,不能使用一次、二次贝塞尔曲线。
介绍完了API绘制方法,我们来介绍一下如何使用贝塞尔曲线公式自己来计算一条贝塞尔曲线的坐标点。
2.3通过贝赛尔曲线公式自己实现曲线
这里会用到微积分的思想来计算贝塞尔曲线坐标点并绘制曲线。我们知道,计算机图形里没有真正的“曲线”,实际上曲线都是通过许多条细微的直线拼接而成,贝塞尔曲线也是如此。数学中的微积分是通过将一段曲线包围的区域划分成无数个细小的长方形区域,以此来计算不规则图形的面积,正如下图所示:
对于贝塞尔曲线,我们可以使用相同的原理进行绘制:通过将变量t拆分的非常细小,将每个细微的t对应的贝塞尔曲线坐标点计算而出,当t最终从0经过细微的变化变成1时,将这些所有的路径点用直线连接起来,就形成了一条贝塞尔曲线。具体代码如下:
提示
二次贝塞尔曲线公式:B(t)=(1−t)2P0+2t(1−t)P1+t2P2,t∈[0,1]
//使用贝塞尔曲线公式实现
(function(){
var p0={x:20,y:200},p2={x:200,y:200},p1={x:110,y:220};
var t=0,step=0.02;
var x,y;
var canvas=document.getElementById("test2");
var context=canvas.getContext("2d");
context.beginPath();
context.moveTo(p0.x,p0.y);
for(;t<1;t+=step){
//通过二次贝塞尔曲线公式计算路径坐标点
x=(1-t)*(1-t)*p0.x+2*t*(1-t)*p1.x+t*t*p2.x;
y=(1-t)*(1-t)*p0.y+2*t*(1-t)*p1.y+t*t*p2.y;
context.lineTo(x,y);
}
context.lineTo(p2.x,p2.y);
context.stroke();
})();
和本文第一个示例一样,我们绘制一条由P0(20,200),P1(110,220),P2(200,200)控制的二次贝塞尔曲线,通过在一个for循环之内逐渐将t从0增加到1,在每一个t变化时刻都计算出贝塞尔曲线坐标点,最终用直线将折线点连起来。上面这段代码最终会绘制出一个和2.1节中使用canvasAPI绘制的二次贝塞尔曲线完全相同的图形。感兴趣的读者可以试着改变step的值,来观察图形的绘制结果的细微差异。我们会发现,当step特别小时(即表明将直线拆分的非常非常细小),绘制出来的曲线会特别的“粗糙”,这是因为计算机屏幕是有最小绘制像素的,如果step取值特别小,最终计算出来的贝塞尔曲线各个坐标点之间的距离小于屏幕的最小像素距离,则会引起一个像素点的多次重绘;而当step取的不够小时,绘制出来的“曲线”又会显得非常的像直线。理解了二次贝塞尔曲线的计算方式之后,要编写计算三次、四次或者任何多次贝塞尔曲线的代码是完全相同的,变化的只有计算公式而已。
上面这段代码非常简单,希望大家能完全理解如何自己计算一条贝塞尔曲线的实现方式。因为后文(3.2节)中实现贝塞尔曲线动画的关键技术就在于此。
3.制作贝赛尔曲线动画
在掌握了基本的贝塞尔曲线绘制方法之后,让我们来看一些贝塞尔曲线动画。
3.1波形动画
我们先从一个简单的动画开始讲。通过前文的学习,我们已经明白,贝塞尔曲线的曲率是由控制点来定义的,那么如果固定起始点和结束点,通过渐变修改控制点的坐标,是否可以让连续的贝塞尔曲线行程一种类似“彩带飞舞”的动画效果呢,答案是肯定的。下面这段代码使用canvasAPI绘制了一个简单的波形动画:
(function(){
//起始点,控制点和结束点
var p0={x:20,y:200},p2={x:200,y:200},p1={x:110,y:220};
//波动距离
var movementY=1,movementX=2;
var canvas=document.getElementById("test2");
var context=canvas.getContext("2d");
//通过微小的偏移量来改变控制点的坐标
function calcMovePoint(point){
if(point.x>150){
movementX*=-1;
}
if(point.x<70){
movementX*=-1;
}
if(point.y>250){
movementY*=-1;
}
if(point.y<150){
movementY*=-1;
}
point.y+=movementY;
point.x+=movementX;
return point;
}
function draw(){
//每一帧动画前清除上一帧图形
context.clearRect(0,0,canvas.width,canvas.height);
context.beginPath();
context.moveTo(p0.x,p0.y);
//改变控制点坐标
p1=calcMovePoint(p1);
//每次绘制出来的贝塞尔曲线会有细微的差别,以此形成过渡动画
context.quadraticCurveTo(p1.x,p1.y,p2.x,p2.y);
context.stroke();
//循环播放动画
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
})();
起始状态我们仍然使用前文的P0,P1,P2三个坐标点,这次我们增加了calcMovePoint方法,来修改P1控制点,每一次变化都变化的很小,来形成一个过渡动画的效果。我们定义了draw方法,在每次执行时先清除画布,这在动画制作中即意味着清除上一帧的图形;接着我们根据calcMovePoint计算后的新的控制点P1来绘制一条新的贝塞尔曲线;最后通过requestAnimationFrame方法不断的循环draw方法,达到动画播放的效果。上面这段代码将会产生一种类似彩带飞舞,或是海浪波纹的简单动画效果。
3.2平滑曲线图动画
很多编程语言提供的贝塞尔曲线原生实现方法仅仅提供直接生成三点控制的曲线图形,使用这些编程语言提供的API往往会让我们传入固定的坐标点,然后由程序直接生成一条曲线。那如果我们想要制作一个贝塞尔曲线动画效果(需要绘制出这条曲线的中间过程),这些编程语言提供的API就没法满足我们的需求了,例如下图展示的一组动画模型:
为了能实现出这种曲线动画效果,我们需要知道这条曲线上的每一个坐标点,只有精确的知道每一个坐标点,我们才能一点点通过动画效果,增量的绘制出这条曲线。其实实现这个动画最核心的技术我们都已经介绍过了,主要有两点:
1.计算控制点以使经过多点的贝塞尔曲线平滑(2.2节)
2.通过贝塞尔曲线公式自己计算路径坐标点(2.3节)
简而言之,我们遇到的难题就两个:计算出贝塞尔曲线控制点;利用起始点、结束点、控制点计算出整条贝塞尔曲线的坐标点(将曲线拆分成小段,用直线连接,我们要计算的这些坐标点就是连接直线的各个端点)。
仔细观察上面的效果图,我们发现,整条贝塞尔曲线经过了三个路径点,可以用两条二次贝塞尔曲线简单的实现上面的效果图,下图展示了我们的想法:
实现代码如下:
var canvas=document.getElementById("test1");
var context=canvas.getContext("2d");
//曲线经过的坐标点
var points=[
{x:20,y:20},
{x:220,y:120},
{x:420,y:220}
];
//计算控制点
function calcCtrl(){
var ctrl=[],x,y;
//控制点的y坐标都是一样的,都是中间路径点的y坐标
y=points[1].y;
//第一个控制点的x坐标,是前两个路径点的中值
x=(points[0].x+points[1].x)/2;
ctrl.push({x:x,y:y});
//第二个控制点的x坐标,是后两个路径点的中值
x=(points[1].x+points[2].x)/2;
ctrl.push({x:x,y:y});
return ctrl;
}
//根据传入的起点、控制点、终点计算出路径点数组
function calcPath(p0,p1,p2){
//设置过渡值
var t=0,step=0.02;
var x,y,path=[];
//t从0-1,计算出每个路径坐标点
for(;t<1;t+=step){
//通过二次贝塞尔曲线公式计算路径坐标点
x=(1-t)*(1-t)*p0.x+2*t*(1-t)*p1.x+t*t*p2.x;
y=(1-t)*(1-t)*p0.y+2*t*(1-t)*p1.y+t*t*p2.y;
path.push({x:x,y:y});
}
return path;
}
var ctrlPoints,path1,path2,pathPoints;
//计算控制点
ctrlPoints=calcCtrl();
//计算第一段曲线的坐标
path1=calcPath(points[0],ctrlPoints[0],points[1]);
//计算第二段曲线的坐标
path2=calcPath(points[1],ctrlPoints[1],points[2]);
//合并所有路径点
pathPoints=path1.concat(path2);
//开始绘制
var i=0;
context.beginPath();
context.moveTo(points[0].x,points[0].y);
//执行动画
animate(pathPoints);
//动画绘制曲线
function animate(){
i++;
if(i<pathPoints.length){
context.lineTo(pathPoints[i].x,pathPoints[i].y);
context.stroke();
}
requestAnimationFrame(animate);
}
我们有一个calcCtrl方法来计算经过三点的平滑曲线的控制点坐标,接着利用路径点和calcCtrl计算得出的控制点,我们用calcPath方法来计算出两段曲线的详细的曲线坐标点,将算出来的坐标点全部存入数组,最终通过动画的形式增量绘制出这条曲线。具体实现在代码过程中都注释的非常详细,读者可以对照代码来理解实现过程。
上面这段代码呈现出的动画效果如下:
最终我们实现了这条平滑曲线的动画。大家如果有实际运行代码的可以尝试更改代码开始points数组的三个坐标点值(请确保points数组的长度始终为3),相应的会形成不同的贝塞尔曲线。
3.3贝塞尔曲线原理图动画
上文的内容主要在讲贝塞尔曲线函数的应用,作为本文的最后一节,在这一节里我们就深入骨髓,抛开繁琐的数学计算,用最简单的代码来从底层阐述贝塞尔曲线到底是什么。
//贝塞尔曲线原理
(function(){
//变量
var p0,p1,p2,t,step,lineP1,lineP2,path,
canvas,context;
//方法
var init,calcPath,drawFrame,draw;
//初始化
init=function(){
//公式中的t
t=0;
//t的变化率
step=0.02;
//起点
p0={x:100,y:200};
//控制点
p1={x:200,y:100};
//终点
p2={x:300,y:200};
//辅助计算点
lineP1={};
lineP2={};
//路径点
path=[];
//dom
canvas=document.getElementById("test1");
context=canvas.getContext("2d");
draw();
};
//计算该t对应的曲线坐标
calcPath=function(){
//计算辅助点
lineP1.x=p0.x+(p1.x-p0.x)*t;
lineP1.y=p0.y+(p1.y-p0.y)*t;
lineP2.x=p1.x+(p2.x-p1.x)*t;
lineP2.y=p1.y+(p2.y-p1.y)*t;
//计算路径点
path.push({
x:lineP1.x+(lineP2.x-lineP1.x)*t,
y:lineP1.y+(lineP2.y-lineP1.y)*t
});
};
//绘制动画每一帧
drawFrame=function(){
var i,l,r;
r=2;
context.clearRect(0,0,canvas.width,canvas.height);
//绘制基础标记
context.beginPath();
context.fillRect(p0.x,p0.y,r,r);
context.fillRect(p1.x,p1.y,r,r);
context.fillRect(p2.x,p2.y,r,r);
context.strokeStyle="#999";
context.moveTo(p0.x,p0.y);
context.lineTo(p1.x,p1.y);
context.lineTo(p2.x,p2.y);
context.stroke();
//绘制辅助标记
context.beginPath();
context.fillRect(lineP1.x,lineP1.y,r,r);
context.fillRect(lineP2.x,lineP2.y,r,r);
context.moveTo(lineP1.x,lineP1.y);
context.lineTo(lineP2.x,lineP2.y);
context.stroke();
//绘制曲线
context.beginPath();
context.strokeStyle="#000";
context.moveTo(path[0].x,path[0].y);
for(i=1,l=path.length;i<l;i++){
context.lineTo(path[i].x,path[i].y);
}
context.stroke();
};
//绘制
draw=function(){
calcPath();
drawFrame();
t+=step;
if(t.toFixed(2)<=1){
setTimeout(draw,50);
}
};
init();
})();
我们以较为简单的二次贝塞尔曲线为例,用代码实现了一个动画过程,这个实现过程从最基础描述了贝塞尔曲线的原理(贝塞尔函数就是对这个过程进行了数学归纳),大家如果有感兴趣的可以阅读一下。
4.小结
贝塞尔曲线本身并不是一个非常复杂的数学问题,大家也能够在网上找到许多介绍贝塞尔函数的基础概念的内容,但是贝塞尔曲线的真正价值在于如何应用它。相对应的,一些比较复杂的问题也就是在各种场景下如何优雅的使用贝塞尔曲线。本文希望能填补一部分这方面资料的空缺,并起到抛砖引玉的效果,希望大家在浏览这篇文章后能开发出更多的贝塞尔曲线使用方法,并分享出来!

本文全面解析贝塞尔曲线,包括概念、二次及三次方公式,以及如何使用HTML5 Canvas API绘制贝塞尔曲线。文章还探讨了制作贝塞尔曲线动画的技巧,如波形动画和平滑曲线图动画,旨在帮助读者深入理解贝塞尔曲线在计算机图形学中的应用。

被折叠的 条评论
为什么被折叠?



