canvas 实现卫星绕月动画

1,554 阅读11分钟

前阵子刚好是中秋佳节,我就用 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 时显示:

image.png

注意,闭合标签 </canvas> 是不能省略的,否则文档的其余部分都会被认为是 canvas 的替代内容。

我们可以给 <canvas> 添加样式,以模拟宇宙的背景颜色:

body {
  margin: 0;
}
#canvas {
  background-color: darkslateblue;
}

效果如下:

image.png

获取渲染上下文对象

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) 打印查看,结果如下:
image.png

它里面的属性和方法,可以用于绘制形状,文本、图像等二维图形。如果想画些三维的图形,则可以传入 "webgl" 去获取封装了 WebGL API 的三维渲染上下文对象。

移动坐标空间

canvas 本身有个默认的坐标空间,并且会完全被网格所覆盖,其原点位于画布的左上角,x 轴方向朝右,y 轴方向朝下,下面放张来自 MDN 的示意图:
image.png

而我们需要在画布的中心位置绘制月亮,所以我将坐标原点通过 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) 点,就被推入一个栈中保存:
2023-10-16_102026.png

之后调用了 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() 用于给路径填充颜色以让我们可以看到它们。

目前的效果如下:

image.png

绘制阴影

阴影的绘制同样使用的是 arc() 方法,只不过画出的圆弧的弧度为 Math.PI / 2(对应的角度就是 90°),然后使用 bezierCurveTo() 绘制了三次贝塞尔曲线,前 4 个参数是分别是控制点一和二的坐标,后 2 个参数为曲线的结束点,也就是上一行 arc 绘制的圆弧的起点,从而绘制出月牙形的阴影。下图是我使用 Adobe Illustrators 绘制的样例,以方便直观地判断控制点大概位置:
2023-10-16_103146.png

在绘制前,使用了 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()
}

现在效果如下,可以看到月球右下角出现了阴影:

image.png

绘制陨石坑

绘制陨石坑用到的知识点之前都介绍过了,无非是移动下坐标空间,然后绘制个圆填充上颜色再画个阴影而已:

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()
}

至此,月亮已经绘制完成,效果如下:

image.png

绘制卫星

绘制三角形

绘制卫星的代码封装到了方法 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()),完成填充。

现在效果如下:

image.png

绘制矩形

接着绘制卫星的翅膀与连接线,它们都是使用 fillRect() 绘制的填充矩形,前 2 个参数为矩形的起始点坐标,后 2 个为矩形的宽高:

// 翅膀连接线
ctx.fillRect(-17, 6, 34, 2)

// 翅膀
ctx.fillRect(17, 1, 20, 12)
ctx.fillRect(-37, 1, 20, 12)

现在效果如下:

image.png

绘制图像

为了添加点掘金特色,我决定给卫星放上个掘金的 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 绘制的图形是由一个个的像素点构成的,所以放大后会失真:

image.png

为了让卫星看起来是正对着月亮的,我还在绘制前调整了下坐标空间的角度,并使用 ctx.scale() 整体缩小了卫星:

ctx.rotate((Math.PI / 180) * 270)
ctx.scale(0.6, 0.6)

现在效果如下:

image.png

绘制轨迹

在前文中我们定义的 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() 就是用来画线的。

现在效果如下:

image.png

线条看起来或许有些虚,这不完全是设置了透明度的原因。在 canvas 中,我们可以通过 lineWidth 设置线条的宽度,默认为 1px,相当于设置了:

ctx.lineWidth = 1

在上色时,是从路径的中心向两边绘制的,那么 1px 宽度的线条,路径两边其实只有 0.5px 宽的部分会被填充上实际的颜色。当路径位刚好是比如取自 MDN 的下图中的 (3, 1) 到 (3, 5) 时,路径左右两边的那一像素网格就会剩下一半,填充上实际画笔颜色的一半色调,所以看起来就会有些虚;而当路径为 (3.5, 1) 到 (3.5, 5) 时,则刚好能以画笔颜色填充完整的一网格,看起来就很清晰: image.png

添加动画

通过 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)
}

执行结果如下:

image.png

动画也可以通过 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 可以设置绘制的文字的样式,可以设置字体的粗细、字号和字体等。

感谢.gif 点赞.png