认识贝塞尔曲线
用途
贝塞尔曲线用于计算机图形绘制形状,CSS 动画和许多其他地方。
它们其实非常简单,值得学习一次并且在矢量图形和高级动画的世界里非常受用。
构成
有两个控制点的曲线方程:
P = (1-t)P1 + tP2
有三个控制点的曲线方程:
P = (1−t)2P1 + 2(1−t)tP2 + t2P3
有四个控制点的曲线方程:
P = (1−t)3P1 + 3(1−t)2tP2 +3(1−t)t2P3 + t3P4
tips:p表示曲线上一个点的横纵坐标
贝塞尔曲线通常由3个点(一个弯曲位置)或者4个点(两个完全位置)来描述,当然也可以是2个点(直线)或者其他更多的点(弯曲的位置更多),事实上,更多弯曲的曲线完全可以由更少弯曲的曲线拼接而成,这在成功绘制后可以很容易看出来
使用canvas绘制
绘制思路:线是由点构成的,所以我们只需要沿着线的轨迹画点即可,
初始化canvas画布
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const data = document.getElementById('data');
canvas.width = 600 * devicePixelRatio;
canvas.height = 400 * devicePixelRatio;
// // 视口比例查询
// console.log(devicePixelRatio);// 1.75
使用canvas时要注意视口的像素比例DPR(devicePixelRatio),确保canvas的图形显示清晰 ,
只由当画布的像素和物理像素(显示器)一致时图像才会清晰,
即 canvas的实际宽高 = canvas的css宽高 * DPR
绘制网格背景
beginPath()
//新建一条路径,(生成之后,图形绘制命令被指向到路径上生成路径)。
closePath()
//闭合路径,(之后图形绘制命令又重新指向到上下文中)。
moveTo(x, y)
//将笔触移动到指定的坐标 x 以及 y 上。
lineTo(x, y)
//绘制一条从当前位置到指定 x 以及 y 位置的直线。
这里要区分moveTo和lineTo,moveTo只是移动位置不会绘制线条,lineTo是移动位置并绘制线条,
// 制作网格
const grid = () => {
ctx.strokeStyle = gridColor;// 填充颜色
for (let i = 0; i <= canvas.width; i += 50) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
ctx.stroke();
ctx.closePath();
}
for (let i = 0; i <= canvas.height; i += 50) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(canvas.width, i);
ctx.stroke();
ctx.closePath();
}
}
封装一个绘制网格背景的方法 gird,
arc()绘制一个弧线
arc(x, y, radius, startAngle, endAngle, anticlockwise)
该方法有六个参数:x,y为绘制圆弧所在圆上的圆心坐标。radius为半径。startAngle以及endAngle参数用弧度定义了开始以及结束的弧度。这些都是以 x 轴为基准。参数anticlockwise为一个布尔值。为 true 时,是逆时针方向,否则顺时针方向。
我们通过arc()方法来画点,一个点其实就是,一个完整的半径为1的圆弧(圆),
// 绘制一个点(半径为1的圆)
const setP = (x, y, r, color, stroke) => {//x,y不能为0
ctx.beginPath();
ctx.arc(x, y, (r ? r : 5), 0, Math.PI * 2 * 1, false);
ctx.fillStyle = color ? color : 'red';
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = 5;
ctx.stroke();
ctx.lineWidth = 1;
}
ctx.closePath();
}
封装一个setP方法,接受5个参数,点的圆心坐标,半径,填充颜色,描边颜色,绘制一个点,
默认半径为5,填充红色,无描边
setP(50,50)
测试在50,50的位置画一个点
封装一个Bezier类
Bezier类应该包括 :
- 构造器:constructor(pArr, color)
pArr :[[x1,y1],[x2,y2],[x3,y3],[x4,y4]],4个点坐标的2维数组
color: 曲线颜色
修改点的位置:changeP(newPArr)
newPArr :[[x1,y1],[x2,y2],[x3,y3],[x4,y4]],4个点坐标的2维数组
修改颜色: changeColor(color)
color: 曲线颜色
绘制贝塞尔曲线: draw()
获取贝塞尔曲线的点: get toString()
class Bezier {
// pArr = [[x1,y1],[x2,y2],[x3,y3],[x4,y4]];
constructor(pArr, color) {
this.x1 = pArr[0][0];
this.y1 = pArr[0][1];
this.x2 = pArr[1][0];
this.y2 = pArr[1][1];
if (pArr[2]) {
this.x3 = pArr[2][0];
this.y3 = pArr[2][1];
if (pArr[3]) {
this.x4 = pArr[3][0];
this.y4 = pArr[3][1];
}
}
this.color = color | '#000000';//默认黑色
}
// 修改点的位置
changeP(newPArr) {
this.x1 = newPArr[0][0];
this.y1 = newPArr[0][1];
this.x2 = newPArr[1][0];
this.y2 = newPArr[1][1];
if (newPArr[2]) {
this.x3 = newPArr[2][0];
this.y3 = newPArr[2][1];
if (newPArr[3]) {
this.x4 = newPArr[3][0];
this.y4 = newPArr[3][1];
}
}
// 显示出点的参数
data.innerText = bezier.toString;
}
// 修改颜色
changeColor(color) {
this.color = color;
}
// 绘制贝塞尔曲线
draw() {
// 至少2个点,最多4个点
if ((this.x1 && this.x2) === false) {
console.log('至少2个点');
return;
} else if (this.x1 && this.x2 && (this.x3 === undefined)) {//直线
let t = 0;
while (t <= 1) {
// P = (1-t)P1 + tP2
setP(this.x1 * t + this.x2 * (1 - t), this.y1 * t + this.y2 * (1 - t), 1)
t += 0.001;
}
} else if (this.x1 && this.x2 && this.x3 && (this.x4 === undefined)) {//一个凹、凸曲线
let t = 0;
while (t <= 1) {
// P = (1−t)^2P1 + 2(1−t)tP2 + t^2P3
setP(this.x1 * Math.pow(1 - t, 2) + this.x2 * 2 * t * (1 - t) + this.x3 * Math.pow(t, 2),
this.y1 * Math.pow(1 - t, 2) + this.y2 * 2 * t * (1 - t) + this.y3 * Math.pow(t, 2), 1)
t += 0.001;
}
} else if (this.x1 && this.x2 && this.x3 && this.x4) {//一条曲线
let t = 0;
while (t <= 1) {
// P = (1−t)3P1 + 3(1−t)2tP2 +3(1−t)t2P3 + t3P4
setP(this.x1 * Math.pow(1 - t, 3) +
this.x2 * 3 * t * Math.pow(1 - t, 2) +
this.x3 * 3 * Math.pow(t, 2) * (1 - t) +
this.x4 * Math.pow(t, 3),
this.y1 * Math.pow(1 - t, 3) +
this.y2 * 3 * t * Math.pow(1 - t, 2) +
this.y3 * 3 * Math.pow(t, 2) * (1 - t) +
this.y4 * Math.pow(t, 3),
1
)
t += 0.001;
}
}
}
// 获取贝塞尔曲线的点
get toString() {
return `p1: ${this.x1},${this.y1}\np2: ${this.x2},${this.y2}\np3: ${this.x3},${this.y3}\np4: ${this.x4},${this.y4}\n`;
}
}
关键代码是draw方法,其中的t,表示循环的此时(点的数量),点的数量密集时就形成了一条线,之后根据点的个数,选择贝塞尔曲线的轨迹方程绘制点
绘制曲线
实现了Bezier类后,开始绘制一条曲线
// 网格背景
grid();
// 点
let p1 = [100,350];
let p2 = [300,150];
let p3 = [300,550];
let p4 = [450,350];
// 曲线
const bezier = new Bezier([p1, p2,p3,p4])
bezier.draw();
按顺序绘制背景和点,曲线
这样就成功绘制出了一条4个点的曲线,(也可以测试一下2个点和3个点)
扩展:通过点击绘制贝塞尔曲线
思路:当鼠标点击空白的画布时,根据画布上的点,来画点,当点的数量达到2,3,4时同时绘制曲线,当鼠标点击点时,可以进行拖动改变点和曲线的位置,这个过程需要不断的清除画布重绘,实现动画效果,
清空画布
// 清空画布,重置网格
const clear = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
grid();
}
点击事件
canvas.onmousedown = (e) => {
// console.log(e.offsetX * devicePixelRatio, e.offsetY * devicePixelRatio);
// 记录鼠标按下的位置
let x = e.offsetX * devicePixelRatio;
let y = e.offsetY * devicePixelRatio;
// 如果按下的位置是p1点,则跟随鼠标
if (x > p1[0] - 10 && x < p1[0] + 10 && y > p1[1] - 10 && y < p1[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p1 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
if (p2[0]) {
setP(p2[0], p2[1], 10, 'red', 'white');
if (p3[0]) {
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
}
}
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
} // 如果按下的位置是p2点,则跟随鼠标
else if (x > p2[0] - 10 && x < p2[0] + 10 && y > p2[1] - 10 && y < p2[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p2 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
if (p3[0]) {
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
}
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
// 如果按下的位置时p3点,则跟随鼠标
else if (x > p3[0] - 10 && x < p3[0] + 10 && y > p3[1] - 10 && y < p3[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p3 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
bezier.changeP([p1, p2, p3]);//移动点
bezier.draw();
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
}
// 如果按下的位置时p4点,则跟随鼠标
else if (x > p4[0] - 10 && x < p4[0] + 10 && y > p4[1] - 10 && y < p4[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p4 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
setP(p4[0], p4[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
}
else {
// 如果按下时画布上没有p1则绘制p1
if (!p1[0]) {
p1 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
}// 如果按下时画布上有p1没有点p2则绘制p2
else if (p1[0] != undefined && p2[0] == undefined) {
p2 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
bezier.changeP([p1, p2]);//移动点
bezier.draw();
} // 如果按下时画布上有p1有p2没有p3则绘制p3
else if (p1[0] != undefined && p2[0] != undefined && p3[0] == undefined) {
p3 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3]);//移动点
bezier.draw();
}// 如果按下时画布上有p1有p2有p3没有p4则绘制p4
else if (p1[0] != undefined && p2[0] != undefined && p3[0] != undefined && p4[0] == undefined) {
p4 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
setP(p4[0], p4[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
}
}
最终效果
完整代码展示
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>贝塞尔曲线</title>
<style>
*{
margin: 0;
padding: 0;
}
body{
width: 100vw;
height: 80vh;
padding-top: 20vh;
/* display: flex;
justify-content: center;
align-self: center; */
}
#canvas{
display: block;
width: 600px;
margin: 0 auto;
/* 以上是必要的三个条件居中 :定宽,块级,自动边距*/
height: 400px;
background-color: #e8e8e8;
border: 1px dashed #a3a3a383;
}
#data{
margin: 0 auto;
width: 600px;
line-height: 40px;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<p id="data"></p>
<script src="index.js"></script>
</body>
</html>
index.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const data = document.getElementById('data');
canvas.width = 600 * devicePixelRatio;
canvas.height = 400 * devicePixelRatio;
// // 视口比例查询
// console.log(devicePixelRatio);// 1.75
// 定义颜色
const gridColor = '#bbb';// 网格颜色
// 清空画布,重置网格
const clear = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
grid();
}
// 制作网格
const grid = () => {
ctx.strokeStyle = gridColor;// 填充颜色
for (let i = 0; i <= canvas.width; i += 50) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
ctx.stroke();
ctx.closePath();
}
for (let i = 0; i <= canvas.height; i += 50) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(canvas.width, i);
ctx.stroke();
ctx.closePath();
}
}
// 绘制一个点(半径为1的圆)
const setP = (x, y, r, color, stroke) => {//x,y不能为0
ctx.beginPath();
ctx.arc(x, y, (r ? r : 5), 0, Math.PI * 2 * 1, false);
ctx.fillStyle = color ? color : 'red';
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = 5;
ctx.stroke();
ctx.lineWidth = 1;
}
ctx.closePath();
}
class Bezier {
// pArr = [[x1,y1],[x2,y2],[x3,y3],[x4,y4]];
constructor(pArr, color) {
this.x1 = pArr[0][0];
this.y1 = pArr[0][1];
this.x2 = pArr[1][0];
this.y2 = pArr[1][1];
if (pArr[2]) {
this.x3 = pArr[2][0];
this.y3 = pArr[2][1];
if (pArr[3]) {
this.x4 = pArr[3][0];
this.y4 = pArr[3][1];
}
}
this.color = color | '#000000';//默认黑色
}
// 修改点的位置
changeP(newPArr) {
this.x1 = newPArr[0][0];
this.y1 = newPArr[0][1];
this.x2 = newPArr[1][0];
this.y2 = newPArr[1][1];
if (newPArr[2]) {
this.x3 = newPArr[2][0];
this.y3 = newPArr[2][1];
if (newPArr[3]) {
this.x4 = newPArr[3][0];
this.y4 = newPArr[3][1];
}
}
// 显示出点的参数
data.innerText = bezier.toString;
}
// 修改颜色
changeColor(color) {
this.color = color;
}
// 绘制贝塞尔曲线
draw() {
// 至少2个点,最多4个点
if ((this.x1 && this.x2) === false) {
console.log('至少2个点');
return;
} else if (this.x1 && this.x2 && (this.x3 === undefined)) {//直线
let t = 0;
while (t <= 1) {
// P = (1-t)P1 + tP2
setP(this.x1 * t + this.x2 * (1 - t), this.y1 * t + this.y2 * (1 - t), 1)
t += 0.001;
}
} else if (this.x1 && this.x2 && this.x3 && (this.x4 === undefined)) {//一个凹、凸曲线
let t = 0;
while (t <= 1) {
// P = (1−t)^2P1 + 2(1−t)tP2 + t^2P3
setP(this.x1 * Math.pow(1 - t, 2) + this.x2 * 2 * t * (1 - t) + this.x3 * Math.pow(t, 2),
this.y1 * Math.pow(1 - t, 2) + this.y2 * 2 * t * (1 - t) + this.y3 * Math.pow(t, 2), 1)
t += 0.001;
}
} else if (this.x1 && this.x2 && this.x3 && this.x4) {//一条曲线
let t = 0;
while (t <= 1) {
// P = (1−t)3P1 + 3(1−t)2tP2 +3(1−t)t2P3 + t3P4
setP(this.x1 * Math.pow(1 - t, 3) +
this.x2 * 3 * t * Math.pow(1 - t, 2) +
this.x3 * 3 * Math.pow(t, 2) * (1 - t) +
this.x4 * Math.pow(t, 3),
this.y1 * Math.pow(1 - t, 3) +
this.y2 * 3 * t * Math.pow(1 - t, 2) +
this.y3 * 3 * Math.pow(t, 2) * (1 - t) +
this.y4 * Math.pow(t, 3),
1
)
t += 0.001;
}
}
}
// 获取贝塞尔曲线的点
get toString() {
return `p1: ${this.x1},${this.y1}\np2: ${this.x2},${this.y2}\np3: ${this.x3},${this.y3}\np4: ${this.x4},${this.y4}\n`;
}
}
// 网格背景
grid();
// 点
let p1 = [];
let p2 = [];
let p3 = [];
let p4 = [];
// 曲线
const bezier = new Bezier([p1, p2,p3,p4])
canvas.onmousedown = (e) => {
// console.log(e.offsetX * devicePixelRatio, e.offsetY * devicePixelRatio);
// 记录鼠标按下的位置
let x = e.offsetX * devicePixelRatio;
let y = e.offsetY * devicePixelRatio;
// 如果按下的位置是p1点,则跟随鼠标
if (x > p1[0] - 10 && x < p1[0] + 10 && y > p1[1] - 10 && y < p1[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p1 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
if (p2[0]) {
setP(p2[0], p2[1], 10, 'red', 'white');
if (p3[0]) {
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
}
}
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
} // 如果按下的位置是p2点,则跟随鼠标
else if (x > p2[0] - 10 && x < p2[0] + 10 && y > p2[1] - 10 && y < p2[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p2 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
if (p3[0]) {
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
}
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
// 如果按下的位置时p3点,则跟随鼠标
else if (x > p3[0] - 10 && x < p3[0] + 10 && y > p3[1] - 10 && y < p3[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p3 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
if (p4[0]) {
setP(p4[0], p4[1], 10, 'red', 'white');
}
bezier.changeP([p1, p2, p3]);//移动点
bezier.draw();
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
}
// 如果按下的位置时p4点,则跟随鼠标
else if (x > p4[0] - 10 && x < p4[0] + 10 && y > p4[1] - 10 && y < p4[1] + 10) {
canvas.onmousemove = (e) => {
let mX = e.offsetX * devicePixelRatio;
let mY = e.offsetY * devicePixelRatio;
p4 = [mX, mY];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
setP(p4[0], p4[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
canvas.onmouseup = (e) => {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
}
else {
// 如果按下时画布上没有p1则绘制p1
if (!p1[0]) {
p1 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
}// 如果按下时画布上有p1没有点p2则绘制p2
else if (p1[0] != undefined && p2[0] == undefined) {
p2 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
bezier.changeP([p1, p2]);//移动点
bezier.draw();
} // 如果按下时画布上有p1有p2没有p3则绘制p3
else if (p1[0] != undefined && p2[0] != undefined && p3[0] == undefined) {
p3 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3]);//移动点
bezier.draw();
}// 如果按下时画布上有p1有p2有p3没有p4则绘制p4
else if (p1[0] != undefined && p2[0] != undefined && p3[0] != undefined && p4[0] == undefined) {
p4 = [x, y];
clear();
setP(p1[0], p1[1], 10, 'red', 'white');
setP(p2[0], p2[1], 10, 'red', 'white');
setP(p3[0], p3[1], 10, 'red', 'white');
setP(p4[0], p4[1], 10, 'red', 'white');
bezier.changeP([p1, p2, p3, p4]);//移动点
bezier.draw();
}
}
}