用 Canvas 实现一个太阳系动画效果

lxf2023-03-11 16:23:01

开启AdminJS成长之旅!这是我参与「AdminJS日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情

原文来自我的个人博客

前言

先直接展示下最终效果,代码已上传至码上AdminJS

用 Canvas 实现一个太阳系动画效果

本章将会主要介绍关于 Canvas基础知识,看完之后应该就能理解最终的代码了。

1. 什么是 Canvas ?

Canvas 最初由 Apple2004 年 引入,用于 Mac OS X Webkit 组件,为仪表盘小组件和 Safari 浏览器等应用程序提供支持。后来,它被 Gecko 内核的浏览器(尤其是 Mozilla Firefox),OperaChrome 实现,并被网页超文本应用技术工作小组提议为下一代的网络技术的标准元素(HTML5新增元素)。

Canvas 提供了非常多的 JavaScript绘图 API (比如:绘制路径、矩形、圆、文本和图像等方法,与 <canvas>元素可以绘制各种 2D 图形。

Canvas API 主要聚焦于 2D 图形。当然也可以使用 <canvas> 元素对象的 WebGL API 来绘制 2D 和 3D 图形。

Canvas 可用于动画、游戏画面、数据可视化、图片编辑以及实现视频处理等方面。

1.1 浏览器兼容性

Canvas 的浏览器兼容性还是不错的,能兼容 e9 及其以上版本

用 Canvas 实现一个太阳系动画效果

1.2 Canvas 的优点:

  1. Canvas 提供的功能更原始,适合像素处理,动态渲染和数据量大的绘制,如:图片编辑、热力图、炫光尾迹特效等。
  2. Canvas 非常适合图像密集的游戏开发,适合频繁重绘许多的对象。
  3. Canvas 能够以 .png.jpg 格式保存结果图片,适合对图像进行像素级的处理。

1.3 Canvas 的缺点:

  1. 在移动端可能因为 Canvas 数量多,而导致内存占用超出了手机的承受能力,导致浏览器崩溃。
  2. Canvas 绘图只能通过 JavaScript 脚本操作 (all in js)
  3. Canvas 是由一个个像素点构成的图形,放大会使图形变得颗粒状和像素化,导致模糊。

2. Canvas 绘制图形

Canvas 支持两种方式来绘制矩形:"矩形方法""路径方法"

2.1 矩形方法

  • 路径是通过不同颜色和宽度的线段或曲线相连形成的不同形状的点的集合,

  • 除了矩形,其他的图形都是通过一条或者多条路径组合而成

  • 通常我们会通过众多的路径来绘制复杂的图形。

下面是常见的绘制方法:

  1. fillRect(x, y, width, height): 绘制一个填充的矩形
  2. strokeRect(x, y, width, height): 绘制一个矩形的边框
  3. clearRect(x, y, width, height): 清除指定矩形区域,让清除部分完全透明。

Canvas 绘制一个矩形:

<canvas id="tutorial" width="300" height="300px">
  你的浏览器不兼容Canvas,请升级您的浏览器!
</canvas>

<script>
  window.onload = function() {
    let canvasEl = document.getElementById('tutorial')
    if(!canvasEl.getContext){
      return
    }
    let ctx = canvasEl.getContext('2d') // 2d | webgl
    
    ctx.fillRect(10,10, 100, 50) // 单位也是不用写 px
  }
</script>

效果:

用 Canvas 实现一个太阳系动画效果

代码解析: 忽略做兼容性的几行代码,上面的代码最终通过 ctx.fillRect(10,10,100,50) 在坐标为 (10,10)的位置,绘制了一个长 10050 的实心矩形(默认为黑色)

2.2 路径方法

使用路径绘制图形的步骤:

  1. 首先需要创建路径起始点(beginPath)。
  2. 然后使用画图命令去画出路径( arc 绘制圆弧 、lineTo 画直线 )。
  3. 之后把路径闭合( closePath , 不是必须)。
  4. 一旦路径生成,就能通过 描边(stroke)填充路径区域(fill) 来渲染图形。

以下是绘制路径时,所要用到的函数

  • beginPath():新建一条路径,生成之后,图形绘制命令被指向到新的路径上绘图,不会关联到旧的路径
  • closePath():闭合路径之后图形绘制命令又重新指向到 beginPath之前的上下文中。
  • stroke():通过线条来绘制图形轮廓/描边 (针对当前路径图形)
  • fill():通过填充路径的内容区域生成实心的图形 (针对当前路径图形)

代码实现:

<canvas id="tutorial" width="300" height="300px">
  你的浏览器不兼容Canvas,请升级您的浏览器!
</canvas>

<script>
  window.onload = function () {
    let canvasEl = document.getElementById("tutorial");
    if (!canvasEl.getContext) {
      return;
    }
    let ctx = canvasEl.getContext("2d"); // 2d | webgl

    // 1.创建一个路径
    ctx.beginPath();
    // 2.绘图指令
    // ctx.moveTo(0, 0)
    // ctx.rect(100, 100, 100, 50);
    ctx.moveTo(100, 100);
    ctx.lineTo(200, 100);
    ctx.lineTo(200, 150);
    ctx.lineTo(100, 150);

    // 3.闭合路径
    ctx.closePath();
    // 4.填充和描边
    ctx.stroke();
  };
</script>

lineToarc 两个函数结合既能绘制直线也能绘制圆弧,因此路径方法还可以绘制许多图形,比如三角形菱形梯形椭圆形圆形等等。。。

效果: 用 Canvas 实现一个太阳系动画效果

3. Canvas 样式和颜色

3.1 色彩 Colors

如果我们想要给图形上色,有两个重要的属性可以做到:

  • fillStyle = color: 设置图形的填充颜色,需在 fill() 函数前调用
  • strokeStyle = color: 设置图形轮廓的颜色,需在 stroke() 函数前调用

一旦设置了 strokeStyle 或者 fillStyle 的值,那么这个新值就会成为新绘制的图形的默认值。

如果你要给图形上不同的颜色,你需要重新设置 fillStylestrokeStyle

3.2 透明度 Transparent

除了可以绘制实色图形,我们还可以用 canvas 来绘制半透明的图形。

1. 方式一:strokeStylefillStyle 属性结合 RGBA

// 指定透明颜色,用于描边和填充样式
ctx.strokeStyle = "rgba(255,0,0,0.5)";
ctx.fillStyle = "rgba(255,0,0,0.5)";

2. 方式二:globalAlpha 属性

// 针对于Canvas中所有的图形生效
ctx.globalAlpha = 0.3

// 2.修改画笔的颜色
// ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'
ctx.fillRect(0,0, 100, 50) // 单位也是不用写 px

ctx.fillStyle = 'blue'
ctx.fillRect(200, 0, 100, 50)

ctx.fillStyle = 'green' // 关键字, 十六进制, rbg , rgba
ctx.beginPath()
ctx.rect(0, 100, 100, 50)
ctx.fill()
  • globalAlpha = 0 ~ 1

✓ 这个属性影响到 canvas所有图形的透明度

✓ 有效的值范围是 0.0(完全透明)到 1.0(完全不透明),默认是 1.0

3.3 线型 Line styles

调用 lineTo()函数绘制的线条,是可以通过一系列属性来设置线的样式。

常见的属性有:

  1. lineWidth = value: 设置线条宽度。
  2. lineCap = type: 设置线条末端样式。
  3. lineJoin = type: 设定线条与线条间接合处的样式。
  4. ......
  • lineWidth
    • 设置线条宽度的属性值必须为正数。默认值是 1.0px,不需单位。( 零、负数、InfinityNaN 值将被忽略
    • 线宽是指给定路径的中心到两边的粗细。换句话说就是在路径的两边各绘制线宽的一半
    • 如果你想要绘制一条从 (3,1)(3,5),宽度是 1.0 的线条,你会得到像第二幅图一样的结果。
      1. 路径的两边各延伸半个像素填充并渲染出 1 像素的线条(深蓝色部分)

      2. 两边剩下的半个像素又会以实际画笔颜色一半色调来填充(浅蓝部分)

      3. 实际画出线条的区域为(浅蓝和深蓝的部分),填充色大于 1 像素了,这就是为何宽度为 1.0 的线经常并不准确的原因。

    • 要解决这个问题,必须对路径精确的控制。如,1px 的线条会在路径两边各延伸半像素,那么像第三幅图那样绘制从 (3.5 ,1)(3.5, 5) 的线条,其边缘正好落在像素边界,填充出来就是准确的宽为 1.0 的线条。

用 Canvas 实现一个太阳系动画效果

  • lineCap: 属性的值决定了线段端点显示的样子。它可以为下面的三种的其中之一:
    • butt 截断,默认是 butt。

    • round 圆形

    • square 正方形

如下图所示: 用 Canvas 实现一个太阳系动画效果

  • lineJoin: 属性的值决定了图形中线段连接处所显示的样子。它可以是这三种之一:
    • round 圆形
    • bevel 斜角
    • miter 斜槽规,默认是 miter

如下图所示: 用 Canvas 实现一个太阳系动画效果

3.4 绘制文本

canvas 提供了两种方法来渲染文本:

  • fillText(text, x, y [, maxWidth])
    1. (x,y) 位置,填充指定的文本
    2. 绘制的最大宽度(可选)。
  • strokeText(text, x, y [, maxWidth])
    1. (x,y) 位置,绘制文本边框
    2. 绘制的最大宽度(可选)。

文本的样式(需在绘制文本前调用)

  • font = value: 当前绘制文本的样式。这个字符串使用和 CSS font 属性相同的语法。默认的字体是:10px sans-serif

  • textAlign = value:文本对齐选项。可选的值包括:start, end, left, right or center. 默认值是 start

  • textBaseline = value:基线对齐选项。可选的值包括:

    1. top
    2. hanging
    3. middle
    4. alphabetic
    5. ideographic
    6. bottom

    ✓ 默认值是 alphabetic

用 Canvas 实现一个太阳系动画效果

下面是一个绘制文本你的例子

<canvas id="tutorial" width="300" height="300px">
  你的浏览器不兼容Canvas,请升级您的浏览器!
</canvas>

<script>
  window.onload = function () {
    let canvasEl = document.getElementById("tutorial");
    if (!canvasEl.getContext) {
      return;
    }
    let ctx = canvasEl.getContext("2d"); // 2d | webgl

    ctx.font = "60px sen-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.strokeStyle = "red";
    ctx.fillStyle = "red";

    // 将字体绘制在 100, 100 这个坐标点
    ctx.fillText("Ay", 100, 100);
    // ctx.strokeText("Ay", 100, 100);
  };
</script>

用 Canvas 实现一个太阳系动画效果

3.5 绘制图片

绘制图片,可以使用 drawImage 方法将它渲染到 canvas 里。drawImage 方法有三种形态:

  • drawImage(image, x, y)
    • 其中 imageimage 或者 canvas 对象,xy 是其在目标 canvas 里的起始坐标
  • drawImage(image, x, y, width, height)
    • 这个方法多了 2 个参数:widthheight,这两个参数用来控制 当向 canvas 画入时应该缩放的大小
  • drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
    • 第一个参数和其它的是相同的,都是一个图像或者另一个 canvas 的引用。其它 8 个参数最好参照下边的图解来理解,前 4 个是定义图像源的切片位置和大小,后 4 个则是定义切片的目标显示位置和大小。(剪切)

用 Canvas 实现一个太阳系动画效果

  1. HTMLImageElement:这些图片是由 Image() 函数构造出来的,或者任何的 <img> 元素。
  2. HTMLVideoElement:用一个 HTML<video> 元素作为你的图片源,可以从视频中抓取当前帧作为一个图像。
  3. HTMLCanvasElement:可以使用另一个 <canvas> 元素作为你的图片源。
  4. 等等...

4. Canvas 状态和形变

4.1 Canvas 绘画状态-保存和恢复

Canvas 绘画状态是当前绘画时所产生的样式和变形的一个快照,Canvas 在绘画时,会产生相应的绘画状态,其实我们是可以将某些绘画的状态存储在栈中来为以后复用,Canvas 绘画状态的可以调用 saverestore 方法是用来保存和恢复,这两个方法都没有参数,并且它们是成对存在的。

保存和恢复(Canvas)绘画状态

  1. save():保存画布 (canvas) 的所有绘画状态
  2. restore():恢复画布 (canvas) 的所有绘画状态

Canvas 绘画状态包括:

  1. 当前应用的变形(即移动,旋转和缩放)
  2. 以及这些属性strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, font, textAlign, textBaseline......
  3. 当前的裁切路径clipping path

4.2 移动 - translate

translate 方法,它用来移动 canvas 和它的原点到一个不同的位置。

  • translate(x, y)
    • x 是左右偏移量,y 是上下偏移量(无需单位)。
  • 移动 canvas 原点的好处
    • 如不使用 translate 方法,那么所有矩形默认都将被绘制在相同的(0,0)坐标原点。
    • translate 方法可让我们任意放置图形,而不需要手工一个个调整坐标值。

移动矩形案例一:形变( 没有保存状态)

<script>
///1.形变( 没有保存状态)
ctx.translate(100, 100);
ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px

ctx.translate(100, 100);
ctx.strokeRect(0, 0, 100, 50);
</script>

效果:

用 Canvas 实现一个太阳系动画效果

移动矩形案例一:形变(保存形变之前的状态)

<script>
// 2.形变(保存形变之前的状态)
ctx.save();
ctx.translate(100, 100);
ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px
ctx.restore(); // 恢复了形变之前的状态( 0,0)

ctx.save(); // (保存形变之前的状态)
ctx.translate(100, 100);
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 50, 30);
ctx.restore();
</script>

用 Canvas 实现一个太阳系动画效果

4.3 旋转 - rotate

rotate方法,它用于以原点为中心旋转 canvas,即沿着 z轴 旋转。

rotate(angle)

  • 只接受一个参数:旋转的角度 (angle),它是顺时针方向,以弧度为单位的值。
  • 角度与弧度的 JS 表达式:弧度= ( Math.PI / 180 ) * 角度 ,即 **1 角度 = Math.PI/180 ** 个弧度。
  • 比如:
    1. 旋转 90°:Math.PI / 2
    2. 旋转 180°:Math.PI
    3. 旋转 360°:Math.PI * 2
    4. 旋转 -90°:-Math.PI / 2
  • 旋转的中心点始终是 canvas 的原坐标点,如果要改变它,我们需要用到 translate方法。
<SCRIPT>
// 保存形变之前的状态
ctx.save()
// 1.形变
ctx.translate(100, 100)
// 360 -> Math.PI * 2
// 180 -> Math.PI
// 1 -> Math.PI / 180
// 45 -> Math.PI / 180 * 45
ctx.rotate(Math.PI / 180 * 45)
ctx.fillRect(0, 0, 50, 50) 

// ctx.translate(100, 0)
// ctx.fillRect(0, 0, 50, 50)
// 绘图结束(恢复形变之前的状态)
ctx.restore()


ctx.save()
ctx.translate(100, 0)
ctx.fillRect(0, 0, 50, 50)
ctx.restore()

// ....下面在继续写代码的话,坐标轴就是参照的是原点了
<SCRIPT>

用 Canvas 实现一个太阳系动画效果

4.4 缩放 - scale

scale(x, y) 方法可以缩放画布。可用它来增减图形在 canvas 中的像素数目,对图形进行缩小或者放大x 为水平缩放因子,y 为垂直缩放因子,也支持负数。

<script>
// 保存形变之前的状态
ctx.save()
// 1.形变
ctx.translate(100, 100) // 平移坐标系统
ctx.scale(2, 2) // 对坐标轴进行了放大(2倍)
ctx.translate(10, 0) // 10px  -> 20px
ctx.fillRect(0, 0, 50, 50)
// 绘图结束(恢复形变之前的状态)
ctx.restore()

// ....下面在继续写代码的话,坐标轴就是参照的是原点了
</script>

用 Canvas 实现一个太阳系动画效果

5. 实现太阳系动画代码

window.onload = function () {
  let canvasEl = document.getElementById("tutorial");
  if (!canvasEl.getContext) {
    return;
  }
  let ctx = canvasEl.getContext("2d"); // 2d | webgl

  let sun = new Image();
  sun.src = "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/640b4df0a5074e9a9bf4777fdf1fd74e~tplv-k3u1fbpfcp-watermark.image";
  let earth = new Image();
  earth.src = "https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f4ad71733e934b818a52bcfea56a683f~tplv-k3u1fbpfcp-watermark.image";
  let moon = new Image();
  moon.src = "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/05bc3992bd5044448f029b7d68049b38~tplv-k3u1fbpfcp-watermark.image";

  requestAnimationFrame(draw);

  /**
 1秒钟会回调 61次
*/
  function draw() {
    console.log("draw");
    ctx.clearRect(0, 0, 300, 300);
    ctx.save();
    // 1.绘制背景
    drawBg();
    // 2.地球
    drawEarth();
    ctx.restore();
    requestAnimationFrame(draw);
  }

  function drawBg() {
    ctx.save();
    ctx.drawImage(sun, 0, 0); // 背景图
    ctx.translate(150, 150); // 移动坐标
    ctx.strokeStyle = "rgba(0, 153, 255, 0.4)";
    ctx.beginPath(); // 绘制轨道
    ctx.arc(0, 0, 105, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();
  }

  function drawEarth() {
    let time = new Date();
    let second = time.getSeconds();
    let milliseconds = time.getMilliseconds();
    ctx.save(); // earth start
    ctx.translate(150, 150); // 中心点坐标系
    // 地球的旋转
    // Math.PI * 2  一整个圆的弧度
    // Math.PI * 2 / 60   分成 60 份
    // Math.PI * 2 / 60   1s
    // Math.PI * 2 / 60 / 1000    1mm

    // 1s 1mm
    // Math.PI * 2 / 60 * second + Math.PI * 2 / 60 / 1000 * milliseconds
    ctx.rotate(
      ((Math.PI * 2) / 10) * second +
        ((Math.PI * 2) / 10 / 1000) * milliseconds
    );
    ctx.translate(105, 0); // 圆上的坐标系
    ctx.drawImage(earth, -12, -12);
    // 3.绘制月球
    drawMoon(second, milliseconds);
    // 4.绘制地球的蒙版
    drawEarthMask();

    ctx.restore(); // earth end
  }

  function drawMoon(second, milliseconds) {
    ctx.save(); // moon start
    // 月球的旋转
    // Math.PI * 2   一圈   360
    // Math.PI * 2 / 10  1s(10s一圈)
    // Math.PI * 2 / 10 * 2  2s(10s一圈)

    // Math.PI * 2 / 10 / 1000  1mm 的弧度

    // 2s + 10mm = 弧度
    //  Math.PI * 2 / 10  * second + Math.PI * 2 / 10 / 1000 * milliseconds

    ctx.rotate(
      ((Math.PI * 2) / 2) * second +
        ((Math.PI * 2) / 2 / 1000) * milliseconds
    );
    ctx.translate(0, 28);
    ctx.drawImage(moon, -3.5, -3.5);
    ctx.restore(); // moon end
  }

  function drawEarthMask() {
    // 这里的坐标系是哪个? 圆上的坐标系
    ctx.save();
    ctx.fillStyle = "rgba(0, 0, 0, 0.4)";
    ctx.fillRect(0, -12, 40, 24);
    ctx.restore();
  }
};