前阵子刚好是中秋佳节,我就用 canvas 实现了个卫星绕月的小动画,本篇文章就来一步步复盘实现的过程。
定义画布
在 MDN 里有关于 canvas 的教程文章,开头的第一句话如下:
<canvas>
是一个可以使用脚本 (通常为JavaScript) 来绘制图形的 HTML 元素。
也就是说 <canvas>
和诸如 <img>
等一样,都是 html 的元素,都可以有 id
等属性,被所有主流的浏览器支持。要使用 canvas 绘图,首先就需要在 html 中放上 <canvas>
标签,作为相当于画布的存在。这块画布默认宽 300px,高 150px,我们可以通过属性 width
和 height
来改变默认的宽高,值不需要跟上单位,在 canvas 中,默认单位为 px
:
<body>
<canvas id="canvas" width="400" height="400">
让我看看是谁不支持 canvas
</canvas>
</body>
“让我看看是谁不支持 canvas”是 canvas 的替代内容,当遇到一些古董级别的浏览器 —— 比如 ie8 —— 不支持 canvas 时显示:
注意,闭合标签 </canvas>
是不能省略的,否则文档的其余部分都会被认为是 canvas 的替代内容。
我们可以给 <canvas>
添加样式,以模拟宇宙的背景颜色:
body {
margin: 0;
}
#canvas {
background-color: darkslateblue;
}
效果如下:
获取渲染上下文对象
canvas 的绘图能力,需要调用 API 来实现,这些 API 都被封装到了渲染上下文对象中,我们可以通过 canvas.getContext('2d')
来获取该对象:
window.onload = function () {
const canvas = document.getElementById('canvas')
// 判断是否支持 canvas
if (!canvas.getContext) {
return
}
const ctx = canvas.getContext('2d')
// 具体绘制...
}
传入 "2d"
意味着我们获取的渲染上下文对象是 CanvasRenderingContext2D,如果执行 console.log(ctx)
打印查看,结果如下:
它里面的属性和方法,可以用于绘制形状,文本、图像等二维图形。如果想画些三维的图形,则可以传入 "webgl"
去获取封装了 WebGL API 的三维渲染上下文对象。
移动坐标空间
canvas 本身有个默认的坐标空间,并且会完全被网格所覆盖,其原点位于画布的左上角,x 轴方向朝右,y 轴方向朝下,下面放张来自 MDN 的示意图:
而我们需要在画布的中心位置绘制月亮,所以我将坐标原点通过 ctx.translate()
移动到画布中心,以让接下去的步骤中的计算变得简单:
// 获取画布的宽高
const width = canvas.width
const height = canvas.height
function draw() {
// 保存状态
ctx.save()
// 移动坐标系原点至画布中心
ctx.translate(width / 2, height / 2)
// 月球 - 稍后实现
drawMoon()
// 卫星 - 稍后实现
drawSatellite()
// 恢复状态
ctx.restore()
}
坐标空间变换示意:
状态保存与恢复
ctx.save()
的目的在于存储画布的所有状态,包括当前应用的变形(比如 translate
等),以及一些属性(比如定义填充颜色的 fillStyle
等),在应用变形前调用它是个好习惯。与 save()
几乎成对存在的 restore()
则用于恢复状态。所谓的状态,就是当前画面应用的所有样式和变形的一个快照。以上面这段代码为例,当在第 7 行调用了 ctx.save()
,当前的状态,比如坐标原点为 (0, 0) 点,就被推入一个栈中保存:
之后调用了 translate()
方法将原点改到了画布中心,那么在第 7 行到第15 行之间的这些代码,它们的所处的坐标空间原点都是画布中心。等第 15 行调用 restore()
使得之前存储的状态出栈,于是从第 16 行开始,原点又恢复到了 (0, 0) 点。
绘制月亮
绘制月亮主体
绘制月亮的代码封装到了函数 drawMoon
中,我们先来画个填充了月色的圆:
function drawMoon() {
ctx.beginPath()
ctx.arc(0, 0, 80, 0, Math.PI * 2)
ctx.fillStyle = '#FEF1BC'
ctx.fill()
// 阴影 - 稍后实现
drawShadow(80, '#F9E8A5')
// 陨石坑 - 稍后实现
drawMeteoriteCrater(-40, -30, 10)
drawMeteoriteCrater(40, -30, 20)
drawMeteoriteCrater(40, 40, 8)
}
beginPath()
用于清空子路径列表开始一个新路径(路径是图形的基本元素,而路径又是由很多子路径构成的)。在创建新路径前最好都调用下beginPath()
,否则像下面这样,最终 2 个圆都会是蓝色而非预想的一红一蓝:
ctx.arc(40, 40, 20, 0, Math.PI * 2)
ctx.fillStyle = 'red'
ctx.fill()
ctx.arc(80, 80, 10, 0, Math.PI * 2)
ctx.fillStyle = 'blue'
ctx.fill()
arc()
方法用于绘制圆弧路径,其前 2 个参数为圆心的坐标,调用drawMoon()
的位置对应的坐标空间的原点已经被平移到了画布中心,所以我们直接传入0, 0
;第 3 个参数为圆弧的半径;第 4、5 个参数分别为圆弧的起止点,值为弧度,如果你忘了初中学的弧度是啥,只需记住弧度是角的度量单位,当弧长与半径相等时,所对应的那个圆心角,就是 1 弧度,而圆的周长为 2πr,意味着一整圈的圆有2πr / r
的弧度,也就是 2π,用 js 表示就是2 * Math.PI
,那么 1° 的角用弧度表示就是Math.PI / 180
;fillStyle
属性用于描述填充的颜色,其值默认是黑色(#000
),也可以是任意合法的颜色值字符串,比如'red'
、'rgb()'
、'rgba()'
,或是渐变对象,亦或是可重复图像 pattern(可以利用它来制作水印效果);fill()
用于给路径填充颜色以让我们可以看到它们。
目前的效果如下:
绘制阴影
阴影的绘制同样使用的是 arc()
方法,只不过画出的圆弧的弧度为 Math.PI / 2
(对应的角度就是 90°),然后使用 bezierCurveTo()
绘制了三次贝塞尔曲线,前 4 个参数是分别是控制点一和二的坐标,后 2 个参数为曲线的结束点,也就是上一行 arc
绘制的圆弧的起点,从而绘制出月牙形的阴影。下图是我使用 Adobe Illustrators 绘制的样例,以方便直观地判断控制点大概位置:
在绘制前,使用了 rotate()
方法对坐标空间进行旋转,值为 -(Math.PI / 180) * 15
代表逆时针转了 15°:
function drawShadow(r, c) {
ctx.save()
ctx.rotate(-(Math.PI / 180) * 15)
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI / 2)
ctx.bezierCurveTo(0.55 * r, 0.77 * r, 0.88 * r, 0.44 * r, r, 0)
ctx.fillStyle = c
ctx.fill()
ctx.restore()
}
现在效果如下,可以看到月球右下角出现了阴影:
绘制陨石坑
绘制陨石坑用到的知识点之前都介绍过了,无非是移动下坐标空间,然后绘制个圆填充上颜色再画个阴影而已:
function drawMeteoriteCrater(x, y, r) {
ctx.save()
ctx.translate(x, y)
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = '#F8E497'
ctx.fill()
// 阴影
drawShadow(r, '#E6CE6F')
ctx.restore()
}
至此,月亮已经绘制完成,效果如下:
绘制卫星
绘制三角形
绘制卫星的代码封装到了方法 drawSatellite
中。我们先来画一个实心的三角形:
function drawSatellite() {
ctx.save()
ctx.translate(140, 0)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(12, 14)
ctx.lineTo(-12, 14)
ctx.fillStyle = '#D8E0E5'
ctx.fill()
// 绘制翅膀、连接线、logo...
ctx.restore()
}
ctx.moveTo(0, 0)
用于将一个新的子路径的起始点移动到了 (0, 0) 点。请注意,drawSatellite()
的调用是在前面说过的移动坐标系那个小节,draw
方法内位于drawMoon()
之后,所以drawSatellite
中,ctx.save()
保存的坐标空间的原点是画布,也就是月球的中心点,第 3 行调用ctx.translate(140, 0)
后,原点,也就是此时的 (0, 0) 点位于月球的右侧;ctx.lineTo(x, y)
的作用是使用直线连接子路径的终点到 x,y 坐标。而子路径的起点则为之前路径的终点,或者是moveTo()
指定的点。执行完上列代码的第 8 行,也就通过 3 个点,连成了 2 条直线路径,等调用了ctx.fill()
,会自动封闭路径(相当于调用了ctx.closePath()
),完成填充。
现在效果如下:
绘制矩形
接着绘制卫星的翅膀与连接线,它们都是使用 fillRect()
绘制的填充矩形,前 2 个参数为矩形的起始点坐标,后 2 个为矩形的宽高:
// 翅膀连接线
ctx.fillRect(-17, 6, 34, 2)
// 翅膀
ctx.fillRect(17, 1, 20, 12)
ctx.fillRect(-37, 1, 20, 12)
现在效果如下:
绘制图像
为了添加点掘金特色,我决定给卫星放上个掘金的 logo 图片:
const juejin = new Image()
juejin.src = '../imgs/juejin.png'
ctx.drawImage(juejin, -6.425, 3, 12.85, 10)
drawImage()
用于绘制图像,图像可以是图片,也可以是 svg 或是 canvas 等,当传入的参数总共有 5 个时,其第 2、3 个参数为图像的左上角在画布上的坐标,第 4、5 个参数为图像的宽高。注意,需要等待图片加载完毕(比如通过 juejin.onload = () => draw()
)再调用 ctx.drawImage
才能确保图片显示出来。
为了看得更清楚些,我通过调整浏览器缩放,放大了图像比例,效果如下,看起来有些模糊,这是因为 canvas 绘制的图形是由一个个的像素点构成的,所以放大后会失真:
为了让卫星看起来是正对着月亮的,我还在绘制前调整了下坐标空间的角度,并使用 ctx.scale()
整体缩小了卫星:
ctx.rotate((Math.PI / 180) * 270)
ctx.scale(0.6, 0.6)
现在效果如下:
绘制轨迹
在前文中我们定义的 draw
方法内,调用 drawTrajectory()
来绘制卫星轨迹:
function drawTrajectory() {
ctx.beginPath()
ctx.arc(0, 0, 143, 0, Math.PI * 2)
ctx.strokeStyle = 'rgba(255,255,255,0.2)'
ctx.setLineDash([4, 8])
ctx.stroke()
}
strokeStyle
用于描述画笔的颜色,也就是轨迹线的颜色;setLineDash()
则用于设置虚线,传入的数组用于描述线和间隙的长度;stroke()
就是给路径描边上色。如果说fill()
是用来画面的,那么stroke()
就是用来画线的。
现在效果如下:
线条看起来或许有些虚,这不完全是设置了透明度的原因。在 canvas 中,我们可以通过 lineWidth
设置线条的宽度,默认为 1px,相当于设置了:
ctx.lineWidth = 1
在上色时,是从路径的中心向两边绘制的,那么 1px 宽度的线条,路径两边其实只有 0.5px 宽的部分会被填充上实际的颜色。当路径位刚好是比如取自 MDN 的下图中的 (3, 1) 到 (3, 5) 时,路径左右两边的那一像素网格就会剩下一半,填充上实际画笔颜色的一半色调,所以看起来就会有些虚;而当路径为 (3.5, 1) 到 (3.5, 5) 时,则刚好能以画笔颜色填充完整的一网格,看起来就很清晰:
添加动画
通过 requestAnimationFrame
添加上卫星的旋转动画,让其 60s 围绕月球转动一圈:
requestAnimationFrame(draw)
function draw() {
ctx.clearRect(0, 0, width, height) // 清除画布
// ...
const time = new Date()
ctx.rotate(
((2 * Math.PI) / 60) * time.getSeconds() +
((2 * Math.PI) / 60000) * time.getMilliseconds()
)
// 卫星
drawSatellite()
// ...
requestAnimationFrame(draw)
}
requestAnimationFrame()
用于让浏览器在下次重绘之前调用指定的回调函数更新动画。其本身是一次性的,所以在回调中需要再次调用 requestAnimationFrame()
以在浏览器下次重绘之前继续更新下一帧动画。由于 requestAnimationFrame
是跟随浏览器的刷新频率的,一般为 60 Hz,也就意味着我们传入的回调 draw
方法,1s 中会被调用 60 次。
每次调用 draw
方法,绘制的画面,其实就是动画的一帧。在 draw
方法内,调用 drawSatellite()
绘制卫星之前,通过旋转坐标空间,就能得到卫星位于不同角度的画面。那么我们就可以通过 Date
对象的 getSeconds()
和 getMilliseconds()
获取到当前的秒数和毫秒数,然后计算 60s 内当前那 1s 和那 1ms 对应的坐标空间的旋转弧度,得到一帧画面。然后在绘画每一帧的最开始,先调用 ctx.clearRect(0, 0, width, height)
清除画布再进行绘制。这样通过 requestAnimationFrame
调用 draw
,1s 就能得到 60 帧的实时画面,实现动画效果。
关于 requestAnimationFrame
的执行频率,可以通过下列代码验证:
const startTime = Date.now()
requestAnimationFrame(draw)
function draw() {
const endTime = Date.now()
if (endTime - startTime > 1000) return
console.log(1)
requestAnimationFrame(draw)
}
执行结果如下:
动画也可以通过 setInterval()
方法实现,但是 requestAnimationFrame
在切换标签时会自动暂停或继续,其可以避免一些多余的重绘,性能更好。另外,如果觉得仅仅是给卫星添加一个动画就需要每秒重绘整个画布 60 次浪费性能,可以考虑将背景和月球等静态的图形绘制在一个单独的 canvas 里。
绘制签名
在 draw
方法的一开始,第一次调用 ctx.save()
前,调用 drawSignature()
以绘制签名,这样坐标空间的原点就位于默认的画布左上角:
function drawSignature() {
ctx.font = '16px san-serif'
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.fillText('by: 亦黑迷失', 290, 380)
}
fillText()
用于绘制文本字符串,第 1 个参数就是要绘制的文本,第 2、3 个为开始绘制文本的起点坐标;font
可以设置绘制的文字的样式,可以设置字体的粗细、字号和字体等。