使用Worker+OffScreenCanvas优化Vue3塔防小游戏

1,863 阅读5分钟

LegendTD

游戏地址: game.codeape.site

游戏源码: github.com/ApeWhoLoves…

往期文章: juejin.cn/post/721451…

基本介绍

开发技术: Vue3 + Ts + Worker + OffScreenCanvas + Canvas

这是一款支持 pc端移动端 的网页塔防小游戏。

前因

由于之前的设计有部分不合理,再加上代码的实现存在部分问题,导致玩到后面的关卡会出现卡顿。

主要是因为到了后面关卡会增加怪的数量,而且有几个怪可以每隔几秒就召唤几个小弟,如果没有及时使用塔防将这些怪击杀,就会越堆越多,当数量到一定程度,并且塔防也在不断往它们发射子弹;

此时就会使计算量不断增大,而且 canvas 每次都需要绘画非常大量的图片,进而导致出现卡顿。

试玩卡顿的兄弟们

image.png

当然还有其他兄弟们的意见,感谢各位的试玩,还有那些给我提bug和建议的兄弟姐妹们。

收到 worker + offscreenCanvas 的建议

当很多人都在提卡顿,我还在努力想哪里内存泄漏,完善算法等代码的时候,有位好兄弟给我提了个建议。

image.png

当时我还不知道这是个什么玩意,然后上网查了下,根据资料的描述好像能为我解决不少卡顿的问题。

Worker + OffscreenCanvas

简单总结如下:

Worker 可以开启一个新线程,用于计算等,而不影响到页面的主线程。

OffscreenCanvas 结合 Worker 使用能实现离屏渲染,将 canvas 的渲染交给另一个线程。(OffscreenCanvascanvasapi 是一致的)

这两个东东的更详细信息,大家可以参考一下其他的文章

这两篇的描述就挺不错的:

www.ngui.cc/el/2561559.…

juejin.cn/post/709106…

简单的使用方式

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
  </style>
</head>
<body>
  <canvas id="canvas" height="450"></canvas>
  <script>
    // 创建
    const offscreen = document.getElementById("canvas").transferControlToOffscreen()
    // 加载worker文件
    const worker = new Worker("01-worker.js")
    worker.postMessage({canvas: offscreen}, [offscreen])
  </script>
</body>
</html>

创建一个名为:01-worker.js 的文件

self.onmessage = function(evt) {
  const canvas = evt.data.canvas
  const ctx = canvas.getContext("2d")
  // do something
  function render(time) {
    ctx.fillStyle = 'skyblue'
    ctx.fillRect(100, 100, 100, 100)
  }
  render()
}

在 Vue 项目中使用 Worker

一开始我在摸索怎么在我的项目中使用呢,后面发现有vue-worker,但是很遗憾 vue-worker 只支持 vue2 的项目,而且已经有6年没更新了,果断弃用。

而在搜索如何在 vue3 项目中使用时,看到说要下 worker-loader 等插件,然后各种配置什么的,最后发现其实都不用。

因为这个项目是 vite 搭建的,而 vite 已经配置好 worker 了。

vite中的worker配置传送门

在 Vite 构建的 Vue3 项目中使用

不需要额外的配置,直接创建一个 worker 的目录以及启动文件,然后在需要使用的 .vue 文件中直接引入就行。

具体代码可以看这里

  • 简单demo
<script setup lang='ts'>
import { onMounted, ref } from 'vue';
// 引入对应的 worker 文件
import Worker from "@/workers/test.ts?worker"

const workerRef = ref<Worker>()

const initWorker = () => {
  const canvasBitmap = document.getElementById('canvas') as HTMLCanvasElement;
  const offscreen = canvasBitmap.transferControlToOffscreen();
  workerRef.value = new Worker()
  // 该vue文件(主线程)往 worker (另外的线程) 传递数据
  workerRef.value.postMessage({ init: true, canvas: offscreen }, [offscreen]);
  // worker(另外的线程) 往 该vue文件(主线程)传递数据
  workerRef.value.onmessage = e => {
    console.log(e.data)
  }
}

onMounted(() => {
  initWorker()
})
</script>

<template>
  <div class='worker'>
    <canvas id="canvas" width="1000" height="450" :style="{border: '1px solid #fff'}"></canvas>
  </div>
</template>
  • workers/test.ts
const state = {
  ctx: null,
  offscreen: null,
}
addEventListener('message', e => {
  const { data } = e;
  // 这里可以接收到传递进来的消息
  console.log('data: ', data);
  state.offscreen = data.canvas;
  // 获取到canvas的上下文
  state.ctx = state.offscreen.getContext('2d');
  // 可以随意进行canvas的绘画了
})
export default {}

项目中 Worker 的使用简要说明

worker的MDN文档

下面我简单描述在该项目中初始化页面,worker文件 和 .vue 文件的事件通信和数据交互过程。

  • postMessage:用来传递事件

在项目中的使用,在 .vue 起始文件初始化后,向 worker 传递信号,告诉 worker 可以开始绘画了,并且传递需要传递的数据过去。

// .vue文件 初始化完成
const canvasBitmap = document.getElementById('game-canvas') as HTMLCanvasElement;
const offscreen = canvasBitmap.transferControlToOffscreen();
const worker = new Worker()
worker.postMessage({
  init: true, // 通过这个值判断当前是初始化
  canvasInfo: {
    offscreen,
  },
  // ...传递其他初始化时的数据过去
}, [offscreen]);
  • onmessage:用来接受传递的事件

在项目中的使用,接受到 vue 中传递事件,比如接收到初始化的信号了,然后就可以调用函数执行需要的操作了。

// worker的 .ts 文件
addEventListener('message', e => {
  const { data } = e;
  if(data.init) { // 初始化
    const offscreen = data.canvasInfo.offscreen;
    canvasInfo.offscreen = offscreen
    gameConfigState.ctx = (offscreen.getContext('2d') as CanvasRenderingContext2D);
    // ...其他的一些初始化操作
  } 
  // 接收其他传递过来的事件,触发对应的函数
  switch (data.fnName as WorkerFnName) {
    case 'getMouse': {
      getMouse(data.event); break;
    }
    case 'buildTower': {
      buildTower(data.event); break;
    }
    case 'saleTower': {
      saleTower(data.event); break;
    }
    case 'handleSkill': {
      handleSkill(data.event); break;
    }
    // ...可以用来处理其他事件
  }
})

worker 中初始化完成,就可以传递事件告诉 .vue 那边完成了初始化。

// worker的 .ts 文件
postMessage({fnName: 'onWorkerReady', param})

当然 .vue 中也有一个 onmessage 的监听,接收到 onWorkerReady 信号后就可以执行对应的函数。

worker.onmessage = e => {
  const { data } = e
  const param = data.param
  switch (data.fnName as VueFnName) {
      case 'onWorkerReady': {
        onWorkerReady(); break;
      }
    // ... 其他需要的接受函数
  }
}
function onWorkerReady() {
  // ...
}

使用 Worker 遇到的问题和解决方法

图片等数据问题

使用 postMessage 传递的数据有要求,mdn 的原话是:任何可以被结构化克隆算法处理的 JavaScript 对象。

image.png

这对于大部分的数据都可以传递的,但是在我的项目中,我是一开始先加载并处理好用于 canvas 绘画的图片,然后再绘画的,这意味着我将无法传递图片过去给 OffScreenCanvas 绘画。

所以我就需要在 worker 中加载图片了,这里就又引发了另外的问题,我需要将 gif 图处理成多张用于 canvas 绘画的图片。而我处理 gif 图是借助 libgif 来实现的,我尝试在 worker 中使用,果然报错了。

不能访问DOM等方法

libgif 里面是使用 document 的方法创建一个 canvas ,然后在这个 canvas 的基础上再进行处理的。而 worker 不能访问 DOM,所以直接G了。我最终是通过将源码里面的 canvas 修改为 OffScreenCanvas ,再通过进一步处理解决的。

具体的解决方法可以看我之前的这篇文章:juejin.cn/post/722932…

总结

使用 worker 重构游戏代码后,最终的实现版本在绘画大量图片的情况下也能流畅不少,已经很难出现卡顿情况了。除非你能一直堆怪~~。