canvas 实现卫星绕月动画

1,147 阅读11分钟

前阵子刚好是中秋佳节,我就用 canvas 实现了个卫星绕月的小动画,本篇文章就来一步步复盘实现的过程。

定义画布

在 MDN 里有关于 canvas 的教程文章,开头的第一句话如下:

<canvas>是一个可以使用脚本 (通常为JavaScript) 来绘制图形的 HTML 元素。

也就是说 <canvas> 和诸如 <img> 等一样,都是 html 的元素,都可以有 id 等属性,被所有主流的浏览器支持。要使用 canvas 绘图,首先就需要在 html 中放上 <canvas> 标签,作为相当于画布的存在。这块画布默认宽 300px,高 150px,我们可以通过属性 widthheight 来改变默认的宽高,值不需要跟上单位,在 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