使用canvas绘制冰墩墩(贝塞尔曲线) 精华

战场小包
发布于 2022-2-16 09:53
浏览
6收藏

春节不停更,此文正在参加「星光计划-春节更帖活动」

前言

一墩难求,一墩难求,冬奥会都过半了,小包的墩还是没有到位,难受~~~感觉小包暂时是无法得到真墩了。没办法,又得拾起老手艺,想方设法绘制个糊弄糊弄自己吧。

由于存在版权问题,所以小包本文就不全程带大家写冰墩墩的代码了,咱们一起在学习一下实现思路。源码地址

学习本文,你可以收获:

  • 理解贝塞尔曲线
  • 学会 canvas 中绘制二阶贝塞尔曲线和三阶贝塞尔曲线的方法
  • 获得冰墩墩各曲线的数据,成功学会绘制冰墩墩

分析

首先我们整体看一下封面图,大致会有两种实现思路。

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

  1. 方案一: 基于 CSS 实现

冰墩墩大部分部位都可以使用 CSS 的椭圆角配合为元素来模拟实现,比如眼睛、防护罩等,但有两个问题。

  • 轮廓实现问题: 小包观摩了大佬们的创意,大佬将冰墩墩进行拆分,将耳朵手臂等部位单独拆分出来,形象化一下冰墩墩,实现的墩墩非常可爱。小包仔细的思考了一下,好像很难有更好的创意了。
  • 椭圆尺寸问题: 小包找到样板后突然发现,如何获取椭圆的半径是个大问题。
  1. 方案二: 基于 canvas 实现

canvas stroke() 方法会实际地绘制出通过 moveTo()lineTo() 等方法定义的路径。

我们找到了绘制曲线的方法,因此冰墩墩的绘制难点集中在如何绘制每条曲线上。

二维数学空间中存在贝塞尔曲线,冰墩墩整体就可以用贝塞尔曲线来绘制。**那什么是贝塞尔曲线那?我们又如何求解出贝塞尔曲线?**下面我们来一起学习一下。

贝塞尔曲线

贝塞尔曲线(Bezier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。贝塞尔曲线是计算机图形学中相当重要的参数曲线,在一些比较成熟的位图软件中也有贝塞尔曲线工具,如 PhotoShop 等 ———— 百度百科

光看定义感觉这是嘛玩意啊?接下来来看几个案例,了解一下各阶贝塞尔曲线。

贝塞尔曲线根据点的数量可以分为:

  • 一阶贝塞尔曲线 (2 个控制点)

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

  • 二阶贝塞尔曲线 (3 个控制点)

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

  • 三阶贝塞尔曲线 (4 个控制点)

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

  • 四阶贝塞尔曲线 (5 个控制点)

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

  • n阶贝塞尔曲线 (n + 1 个控制点)

跟随着上面的动画,不知道是否对贝塞尔曲线产生了一定的理解?不急下面小包慢慢道来~

贝塞尔曲线中有个重要的参数 t ,取值 [0,1]t 参数的值等于线段上某一个点距离起点的长度除以线段长度。现在对 t 的理解有可能有些空洞,下面我们来具体了解一下。

一阶贝塞尔曲线

一阶贝塞尔曲线包括两个控制点 P0P_0P1P_1,两控制点连接成线段P0P_0P1P_1

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

在一阶贝塞尔曲线中,只有一条线段,t=P01P0P0P1t = \frac{P_0^1P_0}{P_0P_1},因此随着 t 从 0 -> 1 ,可以绘制出一条直线。

根据几何知识,我们可以求解出一阶贝塞尔曲线的公式为:

B1(t)=P1+(P2P1)t,t[0,1]B_1(t) = P_1 + (P_2 - P_1)t, t \in [0,1]

一阶贝塞尔曲线总结一下就是随着参数t增大,贝塞尔曲线上的点从线段一端移动到另一端

二阶贝塞尔曲线

二阶贝塞尔曲线分别有三个控制点,P0P_0P1P_1P2P_2,二阶贝塞尔曲线两条线段 P0P1P_0P_1P1P2P_1P_2t 参数的值等于线段上某一个点距离起点的长度除以线段长度,且所有的线段上都要满足上述要求,因此 t=P0P01P0P1=P1P11P1P2t = \frac{P_0P_0^1}{P_0P_1} = \frac{P_1P_1^1}{P_1P_2}P01P_0^1P11P_1^1 构成新的线段,所以根据 t=P01P02P01P11t = \frac{P_0^1P_0^2}{P_0^1P_1^1} 值可以求得位于贝塞尔曲线上的点 P02P_0^2

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

根据上面的计算流程,我们可以一步一步推出二阶贝塞尔曲线的公式:

  1. 利用一阶公式在 P0P1P_0P_1 上求解出 P01P_0^1,在 P1P2P_1P_2 上求出 P11P_1^1

P01=(1t)P0+tP1P_0^1 = (1 - t)P_0 + tP_1

P11=(1t)P1+tP2P_1^1 = (1 - t)P_1 + tP_2

  1. 利用一阶公式,在 P01P11P_0^1P_1^1 上求解出 P02P_0^2,也就是上述公式代入下列公式中:

B2(t)=(1t)P01+tP11B_2(t) = (1-t)P_0^1 + tP_1^1
=(1t)((1t)P0+tP1)+t((1t)P1+tP2)= (1-t)((1 - t)P_0 + tP_1) + t((1 - t)P_1 + tP_2)
=(1t)2P0+2t(1t)P1+t2P2= (1-t)^2P_0+2t(1-t)P_1+t^2P_2

求解出二阶贝塞尔曲线的公式如下:
B2(t)=(1t)2P0+2t(1t)P1+t2P2,t[0,1]B_2(t) = (1-t)^2P_0+2t(1-t)P_1+t^2P_2, t\in [0,1]

随着参数 t 增大,就可以获得一条图示二阶贝塞尔曲线。

三阶贝塞尔曲线

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

三阶贝塞尔曲线有四个控制点,类似于二阶贝塞尔曲线的求解过程。

  1. 分别在线段 ABABBCBCCDCD 上去点 FGHF、G、H,且 t=AFAB=AFAB=BGBC=CHCDt = \frac{AF}{AB} = \frac{AF}{AB} = \frac{BG}{BC} = \frac{CH}{CD}
  2. FGHF、G、H 组成新的线段 FGGHFG、GH,在两端线段上分别取点 IJI、J,且满足 t=FIFG=GJGHt = \frac{FI}{FG} = \frac{GJ}{GH}
  3. IJI、J 组成线段 IJIJ,取点 EE ,且满足 t=IEIJt = \frac{IE}{IJ}
  4. 依次带入公式,可以求得三阶贝塞尔曲线的公式

B3(t)=(1t)3P0+3t(1t)2P1+3t2(1t)P2+t3P3t[0,1]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]

P0,P1,P2,P3P_0,P_1,P_2,P_3 分别代表图中的 A,B,C,DA,B,C,D

反推贝塞尔曲线控制点

下面只是小包的个人思考过程,数学大佬轻喷。

通过上面的公式推导,我们可以得出每种贝塞尔曲线都有两个固定的控制点——曲线的起始点和终止点。

那么一阶贝塞尔曲线控制点就是起止点

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

二阶贝塞尔曲线控制点分别是起止点及起止点处切线交点

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

三阶贝塞尔曲线有四个控制点,其中两个是起止点。另外两个控制点是起止点处切线及曲线极值点处切线相交点

问题来了,虽然思考出贝塞尔曲线的控制点寻找方法,但小包却没有找到如何求出各段曲线的控制点。那该如何通过上面的算法进行测量那?奈何小包的 PS 水平太差,最终只能参考大佬的测量数据。

膜拜大佬,下面是大佬的测量过程,敬佩大佬。

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

小包将大佬的数据进行汇总,得出下表格:

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

canvas 的贝塞尔曲线方法

实现冰墩墩主要使用两个贝塞尔方法: bezierCurveTo quadraticCurveTo

quadraticCurveTo

quadraticCurveTo 方法用于绘制二阶贝塞尔曲线,使用语法

context.quadraticCurveTo(cpx,cpy,x,y);
  • 1.

cpx/cpy 为控制点的坐标,x/y 为结束点坐标。

二阶贝塞尔曲线共需要三个控制点,因此使用 quadraticCurveTo 会基于当前路径的结束点继续绘制。如果不存在路径,需要使用 beginPath()moveTo() 方法来定义起始点。

使用案例:

// ctx 代表当前 canvas
ctx.moveTo(20,20)
ctx.quadraticCurveTo(20,100,200,20)
  • 1.
  • 2.
  • 3.

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

bezierCurveTo

bezierCurveTo 方法用于绘制三阶贝塞尔曲线,使用语法

context.bezierCurveTo(cp1x,cp1y,cp2x,cp2y,x,y);
  • 1.

cpx1/cpy1,cpx2/cpy2 为控制点的坐标,x/y 为结束点坐标。

quadraticCurveTo 方法相同,方法基于当前路径的结束点继续绘制。如果不存在路径,需要使用 beginPath()moveTo() 方法来定义起始点。

使用案例:

ctx.moveTo(20,20)
ctx.bezierCurveTo(20,100,200,100,200,20)
  • 1.
  • 2.

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

冰墩墩绘制

准备工作

由于大佬测量参数太大,因此小包首先对数据做了一下缩放。并封装一下常用函数。

// 缩放参数为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));
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

轮廓绘制

轮廓绘制非常简单,我们只需按照测量数据,调用 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();
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

通过上面的代码,我们就可以成功的绘制出墩的轮廓了!!!

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

绘制耳朵

// 冰墩墩左耳朵
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();
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

绘制冰墩墩小手小脚

// 冰墩墩左手
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();
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

绘制黑眼圈

// 左黑眼圈
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();
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.

使用canvas绘制冰墩墩(贝塞尔曲线)-鸿蒙开发者社区

其余冰墩墩部分绘制都是调用 canvas 的 bezierCurveTo 及 quadraticCurveTo 方法,与上文实现类似,小包就不在文章中重复了,具体可以参考源码或者添加小包索要汇总数据表。

冰墩墩曲线数据来源于: Bulbul

分类
标签
bingdwendwen.zip 2.96K 120次下载
已于2022-2-18 20:25:38修改
10
收藏 6
回复
举报
10
4
6
4条回复
按时间正序
/
按时间倒序
红叶亦知秋
红叶亦知秋

刚刚就看到一个讲如何画冰墩墩的帖子,看来大佬们对"贝塞尔曲线"都挺情有独钟的。

回复
2022-2-16 10:13:54
战场小包
战场小包 回复了 红叶亦知秋
刚刚就看到一个讲如何画冰墩墩的帖子,看来大佬们对"贝塞尔曲线"都挺情有独钟的。

哈哈哈,贝塞尔绘制出的效果比较好看,我去看一下这位大佬的学习一下

回复
2022-2-16 10:20:29
SummerRic
SummerRic

贝塞尔曲线,唔,很有知识点的样子。hhh

回复
2022-2-18 11:06:51
战场小包
战场小包

好多大佬下载了,谢谢大佬们

回复
2022-5-16 14:48:29
回复
    相关推荐