使用canvas绘制冰墩墩(贝塞尔曲线) 精华
春节不停更,此文正在参加「星光计划-春节更帖活动」
前言
一墩难求,一墩难求,冬奥会都过半了,小包的墩还是没有到位,难受~~~感觉小包暂时是无法得到真墩了。没办法,又得拾起老手艺,想方设法绘制个糊弄糊弄自己吧。
由于存在版权问题,所以小包本文就不全程带大家写冰墩墩的代码了,咱们一起在学习一下实现思路。源码地址
学习本文,你可以收获:
- 理解贝塞尔曲线
- 学会
canvas
中绘制二阶贝塞尔曲线和三阶贝塞尔曲线的方法 - 获得冰墩墩各曲线的数据,成功学会绘制冰墩墩
分析
首先我们整体看一下封面图,大致会有两种实现思路。
- 方案一: 基于
CSS
实现
冰墩墩大部分部位都可以使用 CSS
的椭圆角配合为元素来模拟实现,比如眼睛、防护罩等,但有两个问题。
- 轮廓实现问题: 小包观摩了大佬们的创意,大佬将冰墩墩进行拆分,将耳朵手臂等部位单独拆分出来,形象化一下冰墩墩,实现的墩墩非常可爱。小包仔细的思考了一下,好像很难有更好的创意了。
- 椭圆尺寸问题: 小包找到样板后突然发现,如何获取椭圆的半径是个大问题。
- 方案二: 基于
canvas
实现
canvas stroke()
方法会实际地绘制出通过 moveTo()
和 lineTo()
等方法定义的路径。
我们找到了绘制曲线的方法,因此冰墩墩的绘制难点集中在如何绘制每条曲线上。
二维数学空间中存在贝塞尔曲线,冰墩墩整体就可以用贝塞尔曲线来绘制。**那什么是贝塞尔曲线那?我们又如何求解出贝塞尔曲线?**下面我们来一起学习一下。
贝塞尔曲线
贝塞尔曲线(
Bezier curve
),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。贝塞尔曲线是计算机图形学中相当重要的参数曲线,在一些比较成熟的位图软件中也有贝塞尔曲线工具,如PhotoShop
等 ———— 百度百科
光看定义感觉这是嘛玩意啊?接下来来看几个案例,了解一下各阶贝塞尔曲线。
贝塞尔曲线根据点的数量可以分为:
- 一阶贝塞尔曲线 (2 个控制点)
- 二阶贝塞尔曲线 (3 个控制点)
- 三阶贝塞尔曲线 (4 个控制点)
- 四阶贝塞尔曲线 (5 个控制点)
- n阶贝塞尔曲线 (n + 1 个控制点)
跟随着上面的动画,不知道是否对贝塞尔曲线产生了一定的理解?不急下面小包慢慢道来~
贝塞尔曲线中有个重要的参数 t
,取值 [0,1]
,t
参数的值等于线段上某一个点距离起点的长度除以线段长度。现在对 t
的理解有可能有些空洞,下面我们来具体了解一下。
一阶贝塞尔曲线
一阶贝塞尔曲线包括两个控制点 $P_0$、$P_1$,两控制点连接成线段$P_0$$P_1$。
在一阶贝塞尔曲线中,只有一条线段,$t = \frac{P_0^1P_0}{P_0P_1}$,因此随着 t 从 0 -> 1 ,可以绘制出一条直线。
根据几何知识,我们可以求解出一阶贝塞尔曲线的公式为:
$B_1(t) = P_1 + (P_2 - P_1)t, t \in [0,1]$
一阶贝塞尔曲线总结一下就是随着参数t增大,贝塞尔曲线上的点从线段一端移动到另一端。
二阶贝塞尔曲线
二阶贝塞尔曲线分别有三个控制点,$P_0$、$P_1$、$P_2$,二阶贝塞尔曲线两条线段 $P_0P_1$、$P_1P_2$。t
参数的值等于线段上某一个点距离起点的长度除以线段长度,且所有的线段上都要满足上述要求,因此 $t = \frac{P_0P_0^1}{P_0P_1} = \frac{P_1P_1^1}{P_1P_2}$,$P_0^1$ 和 $P_1^1$ 构成新的线段,所以根据 $t = \frac{P_0^1P_0^2}{P_0^1P_1^1}$ 值可以求得位于贝塞尔曲线上的点 $P_0^2$
根据上面的计算流程,我们可以一步一步推出二阶贝塞尔曲线的公式:
- 利用一阶公式在 $P_0P_1$ 上求解出 $P_0^1$,在 $P_1P_2$ 上求出 $P_1^1$
$P_0^1 = (1 - t)P_0 + tP_1$
$P_1^1 = (1 - t)P_1 + tP_2$
- 利用一阶公式,在 $P_0^1P_1^1$ 上求解出 $P_0^2$,也就是上述公式代入下列公式中:
$B_2(t) = (1-t)P_0^1 + tP_1^1$
$= (1-t)((1 - t)P_0 + tP_1) + t((1 - t)P_1 + tP_2)$
$= (1-t)^2P_0+2t(1-t)P_1+t^2P_2$
求解出二阶贝塞尔曲线的公式如下:
$B_2(t) = (1-t)^2P_0+2t(1-t)P_1+t^2P_2, t\in [0,1]$
随着参数 t 增大,就可以获得一条图示二阶贝塞尔曲线。
三阶贝塞尔曲线
三阶贝塞尔曲线有四个控制点,类似于二阶贝塞尔曲线的求解过程。
- 分别在线段 $AB$、$BC$、$CD$ 上去点 $F、G、H$,且 $t = \frac{AF}{AB} = \frac{AF}{AB} = \frac{BG}{BC} = \frac{CH}{CD}$
- 点 $F、G、H$ 组成新的线段 $FG、GH$,在两端线段上分别取点 $I、J$,且满足 $t = \frac{FI}{FG} = \frac{GJ}{GH}$
- 点 $I、J$ 组成线段 $IJ$,取点 $E$ ,且满足 $t = \frac{IE}{IJ}$
- 依次带入公式,可以求得三阶贝塞尔曲线的公式
$B_3(t) = (1-t)^3P_0 + 3t(1-t)^2P_1+3t^2(1-t)P_2+t^3P_3 t \in [0,1]$
$P_0,P_1,P_2,P_3$ 分别代表图中的 $A,B,C,D$
反推贝塞尔曲线控制点
下面只是小包的个人思考过程,数学大佬轻喷。
通过上面的公式推导,我们可以得出每种贝塞尔曲线都有两个固定的控制点——曲线的起始点和终止点。
那么一阶贝塞尔曲线控制点就是起止点。
二阶贝塞尔曲线控制点分别是起止点及起止点处切线交点。
三阶贝塞尔曲线有四个控制点,其中两个是起止点。另外两个控制点是起止点处切线及曲线极值点处切线相交点。
问题来了,虽然思考出贝塞尔曲线的控制点寻找方法,但小包却没有找到如何求出各段曲线的控制点。那该如何通过上面的算法进行测量那?奈何小包的 PS 水平太差,最终只能参考大佬的测量数据。
膜拜大佬,下面是大佬的测量过程,敬佩大佬。
小包将大佬的数据进行汇总,得出下表格:
canvas 的贝塞尔曲线方法
实现冰墩墩主要使用两个贝塞尔方法: bezierCurveTo
quadraticCurveTo
quadraticCurveTo
quadraticCurveTo
方法用于绘制二阶贝塞尔曲线,使用语法
context.quadraticCurveTo(cpx,cpy,x,y);
cpx/cpy
为控制点的坐标,x/y
为结束点坐标。
二阶贝塞尔曲线共需要三个控制点,因此使用
quadraticCurveTo
会基于当前路径的结束点继续绘制。如果不存在路径,需要使用beginPath()
和moveTo()
方法来定义起始点。
使用案例:
// ctx 代表当前 canvas
ctx.moveTo(20,20)
ctx.quadraticCurveTo(20,100,200,20)
bezierCurveTo
bezierCurveTo
方法用于绘制三阶贝塞尔曲线,使用语法
context.bezierCurveTo(cp1x,cp1y,cp2x,cp2y,x,y);
cpx1/cpy1,cpx2/cpy2
为控制点的坐标,x/y
为结束点坐标。
与
quadraticCurveTo
方法相同,方法基于当前路径的结束点继续绘制。如果不存在路径,需要使用beginPath()
和moveTo()
方法来定义起始点。
使用案例:
ctx.moveTo(20,20)
ctx.bezierCurveTo(20,100,200,100,200,20)
冰墩墩绘制
准备工作
由于大佬测量参数太大,因此小包首先对数据做了一下缩放。并封装一下常用函数。
// 缩放参数为3
const SCALE = 3;
// 对测量参数进行缩放
function scaleParam(x) {
return x / SCALE;
}
// 根据缩放参数封装一下几个函数
function bezierCurveTo(ctx, ...args) {
ctx.bezierCurveTo(...args.map(scaleParam));
}
function moveTo(ctx, ...args) {
ctx.moveTo(...args.map(scaleParam));
}
function quadraticCurveTo(ctx, ...args) {
ctx.quadraticCurveTo(...args.map(scaleParam));
}
轮廓绘制
轮廓绘制非常简单,我们只需按照测量数据,调用 canvas 的贝塞尔曲线绘制方法即可。
ctx.beginPath();
moveTo(ctx, 497, 462);
bezierCurveTo(ctx, 452, 380, 497, 184, 666, 297);
bezierCurveTo(ctx, 792, 255, 921, 261, 1017, 278);
bezierCurveTo(ctx, 1127, 155, 1227, 305, 1183, 404);
bezierCurveTo(ctx, 1208, 443, 1238, 488, 1254, 544);
bezierCurveTo(ctx, 1251, 421, 1503, 398, 1472, 577);
bezierCurveTo(ctx, 1407, 758, 1336, 789, 1279, 876);
bezierCurveTo(ctx, 1270, 924, 1255, 1044, 1147, 1222);
bezierCurveTo(ctx, 1098, 1372, 1211, 1454, 1031, 1457);
bezierCurveTo(ctx, 877, 1469, 892, 1434, 901, 1376);
bezierCurveTo(ctx, 924, 1313, 783, 1324, 802, 1378);
bezierCurveTo(ctx, 822, 1432, 819, 1467, 691, 1469);
bezierCurveTo(ctx, 571, 1473, 569, 1448, 571, 1332);
bezierCurveTo(ctx, 572, 1218, 530, 1226, 464, 1038);
bezierCurveTo(ctx, 386, 1244, 233, 1115, 272, 1017);
bezierCurveTo(ctx, 306, 916, 365, 845, 407, 777);
bezierCurveTo(ctx, 433, 669, 449, 545, 497, 462);
ctx.stroke();
通过上面的代码,我们就可以成功的绘制出墩的轮廓了!!!
绘制耳朵
// 冰墩墩左耳朵
ctx.beginPath();
moveTo(ctx, 526, 437);
bezierCurveTo(ctx, 498, 263, 667, 325, 641, 329);
quadraticCurveTo(ctx, 600, 343, 526, 437);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
// 冰墩墩右耳朵
ctx.beginPath();
moveTo(ctx, 1050, 285);
bezierCurveTo(ctx, 1144, 232, 1167, 342, 1162, 387);
quadraticCurveTo(ctx, 1119, 317, 1050, 285);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
绘制冰墩墩小手小脚
// 冰墩墩左手
ctx.beginPath();
moveTo(ctx, 417, 804);
bezierCurveTo(ctx, 430, 837, 435, 914, 457, 968);
bezierCurveTo(ctx, 445, 1016, 440, 1022, 428, 1053);
bezierCurveTo(ctx, 396, 1142, 307, 1112, 304, 1048);
quadraticCurveTo(ctx, 300, 987, 418, 803);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
// 冰墩墩右手
ctx.beginPath();
moveTo(ctx, 1267, 593);
bezierCurveTo(ctx, 1275, 584, 1279, 574, 1280, 555);
bezierCurveTo(ctx, 1282, 448, 1480, 477, 1429, 575);
bezierCurveTo(ctx, 1403, 621, 1374, 689, 1287, 757);
quadraticCurveTo(ctx, 1291, 693, 1267, 594);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
// 冰墩墩左脚
ctx.beginPath();
moveTo(ctx, 585, 1231);
bezierCurveTo(ctx, 626, 1261, 776, 1297, 792, 1336);
bezierCurveTo(ctx, 756, 1387, 838, 1427, 710, 1428);
bezierCurveTo(ctx, 505, 1431, 644, 1381, 585, 1231);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
// 冰墩墩右脚
ctx.beginPath();
moveTo(ctx, 910, 1342);
bezierCurveTo(ctx, 981, 1318, 938, 1293, 1125, 1226);
bezierCurveTo(ctx, 1087, 1370, 1172, 1404, 1014, 1420);
bezierCurveTo(ctx, 875, 1425, 959, 1403, 910, 1342);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
绘制黑眼圈
// 左黑眼圈
ctx.beginPath();
moveTo(ctx, 806, 552);
bezierCurveTo(ctx, 706, 492, 512, 681, 603, 777);
bezierCurveTo(ctx, 738, 882, 896, 600, 806, 552);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
// 右黑眼圈
ctx.beginPath();
moveTo(ctx, 989, 541);
bezierCurveTo(ctx, 1080, 477, 1251, 684, 1168, 768);
bezierCurveTo(ctx, 1077, 837, 893, 607, 989, 541);
ctx.fillStyle = "#000000";
ctx.fill();
ctx.stroke();
![dweneyeborder.png](https://dl-harmonyos.51cto.com/images/202202/954dabf04eb64c9ce0b795ac4eb86a76bf9fc3.png?x-oss-process=image/resize,w_644,h_685)
### 绘制能量圈
```js
// 能量圈
ctx.beginPath();
ctx.lineWidth = 7;
ctx.strokeStyle = "#73fd94";
moveTo(ctx, 497, 772);
bezierCurveTo(ctx, 425, 371, 1145, 80, 1262, 699);
bezierCurveTo(ctx, 1294, 945, 1105, 1031, 907, 1040);
bezierCurveTo(ctx, 716, 1049, 519, 962, 497, 772);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "#f97dfe";
moveTo(ctx, 515, 794);
bezierCurveTo(ctx, 405, 421, 1093, 119, 1242, 646);
bezierCurveTo(ctx, 1316, 881, 1130, 1001, 898, 1003);
bezierCurveTo(ctx, 732, 1005, 562, 961, 515, 794);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 9;
ctx.strokeStyle = "#ecea87";
moveTo(ctx, 611, 909);
bezierCurveTo(ctx, 301, 602, 878, 185, 1137, 487);
bezierCurveTo(ctx, 1495, 981, 840, 1066, 611, 909);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 7;
ctx.strokeStyle = "#9ad6ff";
moveTo(ctx, 611, 909);
bezierCurveTo(ctx, 281, 592, 878, 200, 1137, 487);
bezierCurveTo(ctx, 1495, 1001, 840, 1076, 611, 909);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "#9ad6ff";
moveTo(ctx, 515, 794);
bezierCurveTo(ctx, 405, 421, 1053, 109, 1242, 646);
bezierCurveTo(ctx, 1316, 911, 1150, 1001, 898, 1023);
bezierCurveTo(ctx, 732, 1025, 562, 971, 515, 794);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 7;
ctx.strokeStyle = "#d2fbe5";
moveTo(ctx, 545, 674);
bezierCurveTo(ctx, 673, 289, 1265, 370, 1215, 773);
bezierCurveTo(ctx, 1177, 1083, 453, 1010, 545, 674);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 7;
ctx.strokeStyle = "#4a46be";
moveTo(ctx, 549, 752);
bezierCurveTo(ctx, 548, 421, 1037, 320, 1191, 640);
bezierCurveTo(ctx, 1309, 1058, 597, 1021, 549, 752);
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "#b5e7fe";
moveTo(ctx, 549, 752);
bezierCurveTo(ctx, 548, 441, 1057, 300, 1191, 640);
bezierCurveTo(ctx, 1319, 1048, 567, 1021, 549, 752);
ctx.stroke();
其余冰墩墩部分绘制都是调用 canvas
的 bezierCurveTo
及 quadraticCurveTo
方法,与上文实现类似,小包就不在文章中重复了,具体可以参考源码或者添加小包索要汇总数据表。
冰墩墩曲线数据来源于: Bulbul
刚刚就看到一个讲如何画冰墩墩的帖子,看来大佬们对"贝塞尔曲线"都挺情有独钟的。
哈哈哈,贝塞尔绘制出的效果比较好看,我去看一下这位大佬的学习一下
贝塞尔曲线,唔,很有知识点的样子。hhh
好多大佬下载了,谢谢大佬们