使用canvas实现太阳系动画

2,489 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第6天,点击查看活动详情

基本思路

  1. 搭建静态页面
  2. 在完成静态页面的基础上,为每一个需要添加动画的元素添加上对应的动画效果

搭建静态页面

绘制太阳

window.onload = () => {
  const canvas = document.getElementById('canvas')

  // 对图片资源进行缓存,避免频繁重复加载图片资源
  let sun
  let moon
  let earth

  const ctx = canvas.getContext('2d')

  function init() {
    // 背景虽然是不动的
    // 但是每次canvas执行动画的时候,都需要清除画布,并重新绘制 (因为canvas没有图层的概念)
    // 所以每次都需要重新绘制对应的背景
    if (!sun) {
      sun = new Image()
      sun.crossOrigin = ''
      sun.src = 'https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/409d9c94f04e4eabb55f9d522a05fd6c~tplv-k3u1fbpfcp-watermark.image?'
    }

    if (!earth) {
      // 对于静态资源,绝大多数情况下,会被存放在CDN上
      // 而对于跨域资源,浏览器因为同源策略,会认为其会污染画布(对画布的操作变得不在安全),所以会阻止该图片的渲染
      // 如果的确有跨域图片需要渲染,那么需要满足如下两个条件
      // 1. 图片开启Access-Control-Allow-Origin响应头
      // 2. 设置图片的crossOrigin属性为anonymous
      //    - 对于img, video, script这类元素默认执行支持CORS(Cross-Origin Resource Sharing)(跨域资源共享)
      //    - 对应的属性为crossOrigin
      //    - crossOrigin可以设置如下值
      //      - anonymous 元素在请求跨域资源的时候 不需要凭证 只要crossOrigin的属性值不是use-credentials
      //        那么对应的值就会被解析为anonymous, 例如 空字符串,'abc’等
      //      - use-credentials 元素在请求跨域资源的时候 需要凭证(cookie, token, 证书等)
      //        服务器会对我们的凭证进行校对,如果凭证校对正确,那么就返回对应的资源,如果校验失败,则拒绝请求
      //    总结:
      //      1. crossOrigin不设置的时候 表示只要是跨域资源 就不允许加载跨域资源
      //      2. crossOrigin的值是 anonymous表示的是 向服务器请求资源的时候 不需要携带对应的凭证
      //      3. crossOrigin的值是 use-credentials 表示的是 向服务器请求资源的同时 需要携带上对应的凭证进行鉴权操作
      earth = new Image()
      earth.crossOrigin = ''
      earth.src = 'https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e8175edef0f24cfd8985c65f59a156ba~tplv-k3u1fbpfcp-watermark.image?'
    }

    if (!moon) {
      moon = new Image()
      moon.crossOrigin = ''
      moon.src = 'https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0e97c5487e284fce87884216e2ccfd5d~tplv-k3u1fbpfcp-watermark.image?'
    }
  }

  function drawCricle() {
    ctx.save()

    // ctx的位移 默认单位是px,并不支持%
    ctx.translate(150, 150)
    ctx.beginPath()
    ctx.arc(0, 0, 100, 0, Math.PI * 2)
    ctx.strokeStyle = 'rgba(0, 153, 255, 0.4)'
    ctx.stroke()

    ctx.restore()
  }

  function drawBg() {
    // 在每次对canvas进行操作的时候,尤其是修改了canvas的状态或修改了canvas的坐标轴(也就是对canvas进行形变操作)的时候
    // 应该先将canvas原始状态进行保存,以方便canvas坐标轴的恢复
    ctx.save()

    // 对于不存在的图片,canvas会静默失败
    // 所以在刚刚开始执行requestAnimationFrame方法的时候,图片可能没有加载完全,所以此时界面上没有任何内容
    // 等到图片加载完全后,requestAnimationFrame方法就可以正确绘制出所需要的图形
    // 而图片加载的过程,本质上是很快的,所以不会出现刚刚开始没有图形,过一段时间再出现图形,而造成的闪屏效果
    // 因此 这里在加载图片的时候,并不需要通过onload去监听图片是否已经加载完成
    ctx.drawImage(sun, 0, 0)

    // 绘制圆环
    drawCricle()

    ctx.restore()
  }

	// 开启动画的执行
  requestAnimationFrame(function fn() {
    ctx.save()
    ctx.clearRect(0, 0, 300, 300)

    init()

    drawBg()

    ctx.restore()
    requestAnimationFrame(fn)
  })
}

此时界面中存在的坐标轴如下

image.png

绘制地球

function drawCricle() {
  ctx.save()

  ctx.translate(150, 150)
  ctx.beginPath()
  ctx.arc(0, 0, 100, 0, Math.PI * 2)
  ctx.strokeStyle = 'rgba(0, 153, 255, 0.4)'
  ctx.stroke()

  // 之所以在这里绘制地球 是因为地球的坐标轴需要在圆环对应的坐标轴上进行二次位移
  drawEarth()

  ctx.restore()
}

function drawShadow() {
  ctx.save()
  ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'
  // 1. 绘图的时候 基准点位于左上角 所以需要对遮罩层的位置进行微调
  // 2. 因为图片背景默认是黑色,而遮罩层的颜色也是半透明的黑色
  //    所以在这里直接绘制矩形即可,不需要绘制半圆
  ctx.fillRect(0, -12, 24, 24)

  // 绘制地球的阴影,即面向太阳的部分高亮,背向太阳的部分阴暗
  // 基本思路: 绘制一个遮罩层
  drawShadow()

  ctx.restore()
}

function drawEarth() {
  ctx.save()

  ctx.translate(100, 0)

  ctx.drawImage(earth, 0, 0)

  ctx.restore()
}

但是此时,我们会发现对应的地球和太阳的对齐方式会存在问题,地球的位置明显靠下

这是因为,在绘制图片的时候,默认坐标轴的基准点是左上角,所以会导致实际绘制的图片靠下

所以需要在drawImage的时候,对图片的位置进行微调

image.png

ctx.drawImage(earth, -12, -12)

绘制月亮

function drawMoon() {
  ctx.save()

  ctx.translate(0, 28)

  // 因为默认基准点在左上角,所以对坐标轴进行微调
  ctx.drawImage(moon, -3.5, -3.5)

  ctx.restore()
}

function drawEarth() {
  ctx.save()

  ctx.translate(100, 0)

  ctx.drawImage(earth, -12, -12)

  drawShadow()

  // 绘制月亮,月亮是围绕地球进行旋转的
  // 所以其坐标轴依旧是相对于地球
  drawMoon()

  ctx.restore()
}

添加动画

页面的静态结构依旧搭建完成,此时就可以对需要变化的部分添加对应的动效

地球

  1. 地球绕太阳旋转
  2. 一周的执行时间为60s
function drawCricle(second) {
  ctx.save()

  ctx.translate(150, 150)

  // 地球绕太阳转,那么本质上旋转的是太阳对应的那个坐标轴

  // 旋转一圈是 2* Math.PI, 旋转一圈用时60s
  // 所以 每秒需要转动的角度为 Math.PI * 2 / 60
  // 则第一秒 旋转 Math.PI * 2 / 60 * 1
  // 第二秒 旋转 Math.PI * 2 / 60 * 2
  //  。。。
  // 第n秒 旋转 Math.PI * 2 / 60 * n
  ctx.rotate(Math.PI * 2 / 60 * second)

  ctx.beginPath()
  ctx.arc(0, 0, 100, 0, Math.PI * 2)
  ctx.strokeStyle = 'rgba(0, 153, 255, 0.4)'

  ctx.stroke()

  drawEarth()

  ctx.restore()
}

但是此时旋转会发现,地球的旋转动画十分的卡顿,因为此时地球的旋转角度是每秒变化一次,此时对应的动画看上去就会非常的卡顿

所以为了可以流畅的执行对应的动画,我们需要让太阳对应的坐标轴的旋转角度的变化达到毫秒级别

function drawCricle(second, millisecond) {
  ctx.save()

  ctx.translate(150, 150)

  // 地球绕太阳转,那么本质上旋转的是太阳对应的那个坐标轴
  // 这样就可以保证地球的高亮面永远朝向太阳

  // 旋转一圈是 2* Math.PI, 旋转一圈用时60s
  // 所以 每秒需要转动的角度为 Math.PI * 2 / 60
  // 则第一秒 旋转 Math.PI * 2 / 60 * 1
  // 第二秒 旋转 Math.PI * 2 / 60 * 2
  //  。。。
  // 第n秒 旋转 Math.PI * 2 / 60 * n

  // 类似于秒,对应的每毫秒 坐标轴旋转的角度为
  // Math.PI * 2 / 60 / 1000 * i
  ctx.rotate(Math.PI * 2 / 60 * second + Math.PI * 2 / 60 / 1000 * millisecond)

  ctx.beginPath()
  ctx.arc(0, 0, 100, 0, Math.PI * 2)
  ctx.strokeStyle = 'rgba(0, 153, 255, 0.4)'

  ctx.stroke()

  drawEarth()

  ctx.restore()
}

月球

月球的动画的执行思路和地球的动画对应的执行思路是一模一样的

function drawMoon(second, millisecond) {
  ctx.save()

  // 月球是绕着地球转的,所以实际转动的坐标轴是地球的坐标轴
  // 但是对应的坐标轴的渲染不能在drawEarth中完成
  // 因为对于地球而言存在对应的高亮面,所以在绘制遮罩层的时候,地球的坐标轴是不能发生改变的
  // 所以对应的坐标轴的渲染应该在drawMoon中完成,因为drawMoon保证了对应的状态,且drawMoon函数执行完毕后,会恢复初始坐标轴
  // 此外,旋转的坐标轴是地球对应的坐标轴,不是月球对应的坐标轴
  // 所以需要在坐标轴改变之前完成对应的旋转
  ctx.rotate(Math.PI * 2 / 10 * second + Math.PI * 2 / 10 / 1000 * millisecond)

  ctx.translate(0, 28)

  ctx.drawImage(moon, -3.5, -3.5)

  ctx.restore()
}

function drawEarth(second, millisecond) {
  ctx.save()

  ctx.translate(100, 0)

  ctx.drawImage(earth, -12, -12)

  drawShadow()

  drawMoon(second, millisecond)

  ctx.restore()
}

效果展示

太阳系