使用 OffscreenCanvas 和 Web Workers 实现更快的 WebGL/Three.js 3D 图形
翻译: 俄语
了解如何使用Three.js库创建复杂场景时提升 WebGL 性能,方法是将渲染从主线程移至 Web Worker OffscreenCanvas
。您的 3D 渲染在低端设备上将获得更佳效果,平均性能也将提升。
当我在我的个人网站上添加了一个地球的 3D WebGL 模型后,我发现我在Google Lighthouse上立即损失了 5% 。
在本文中,我将向您展示如何使用我为此目的编写的一个小型库来恢复性能,而不会牺牲跨浏览器兼容性。
问题
使用 Three.js 可以轻松创建复杂的 WebGL 场景。但不幸的是,这需要付出代价。Three.js 会增加大约563 KB的 JS 包大小(而且由于其架构原因,它实际上无法进行 tree-shaking)。
您可能会说,普通的背景图片可能也有同样的 500 KB。但每 KB 的 JavaScript 对网站整体性能的影响比每 KB 的图像数据更大。如果您想要一个快速的网站,延迟和带宽并不是唯一需要考虑的因素:考虑 CPU 处理内容的时间也很重要。在低端设备上,处理资源可能比下载资源花费更多时间。
当浏览器处理 500KB 的 Three.js 代码时,您的网页将被冻结,因为执行 JavaScript 会占用主线程。在场景完全渲染之前,您的用户将无法与页面交互。
Web Workers 和 Offscreen Canvas
Web Workers 是一种避免 JS 执行期间页面冻结的解决方案。它是一种将部分 JavaScript 代码移至独立线程的方法。
遗憾的是,多线程编程非常困难。为了简化起见,Web Workers 无法访问 DOM。只有主 JavaScript 线程才拥有此访问权限。然而,Three.js 需要访问<canvas>
DOM 中的节点。
OffscreenCanvas
<canvas>
是解决此问题的一个方案。它允许您将画布访问权限转移到 Web Worker。它仍然是线程安全的,因为一旦您选择此解决方法,主线程就无法访问。
听起来我们已经做好了准备,但问题是:只有 Google Chrome 支持 Offscreen Canvas API。
然而,即使面对我们的主要敌人——跨浏览器问题,我们也无所畏惧。让我们采用渐进式增强:我们将提升 Chrome 和未来浏览器的性能。其他浏览器仍将以传统方式在主 JavaScript 线程中运行 Three.js。
我们需要想出一种方法来为两个不同的环境编写一个文件,同时记住许多 DOM API 无法在 Web Worker 内部运行。
解决方案
为了隐藏所有 hack 代码并保持代码可读性,我创建了一个很小的屏幕外画布JS 库(只有 400 字节)。接下来的示例将依赖它,但我也会解释它背后的工作原理。
首先,将offscreen-canvas
npm 包添加到您的项目中:
npm install offscreen-canvas
我们需要为 Web Worker 提供一个单独的 JS 文件。让我们在 webpack 或 Parcel 的配置中创建一个单独的 JS 包。
entry: {
'app': './src/app.js',
+ 'webgl-worker': './src/webgl-worker.js'
}
在生产环境中,打包器会为打包文件的文件名添加缓存清除功能。为了在主 JS 文件中使用该名称,我们需要添加一个preload标签。具体代码取决于您生成 HTML 的方式。
<link type="preload" as="script" href="./webgl-worker.js">
</head>
现在我们应该在主 JS 文件中获取画布节点和工作 URL。
import createWorker from 'offscreen-canvas/create-worker'
const workerUrl = document.querySelector('[rel=preload][as=script]').href
const canvas = document.querySelector('canvas')
const worker = createWorker(canvas, workerUrl)
createWorker
查找canvas.transferControlToOffscreen
以检测OffscreenCanvas
浏览器是否支持。如果浏览器支持,该库将以 Web Worker 的形式加载 JS 文件。否则,它将以常规脚本的形式加载 JS 文件。
现在,让我们打开webgl-worker.js
import insideWorker from 'offscreen-canvas/inside-worker'
const worker = insideWorker(e => {
if (e.data.canvas) {
// Here we will initialize Three.js
}
})
insideWorker
检查它是否在 Web Worker 中加载。根据环境的不同,它会使用不同的方式与主线程通信。
该库将在主线程收到任何消息时执行回调。对于我们的工作线程来说,第一个消息始终是用于初始化画布的createWorker
对象。{ canvas, width, height }
+ import {
+ WebGLRenderer, Scene, PerspectiveCamera, AmbientLight,
+ Mesh, SphereGeometry, MeshPhongMaterial
+ } from 'three'
import insideWorker from 'offscreen-canvas/inside-worker'
+ const scene = new Scene()
+ const camera = new PerspectiveCamera(45, 1, 0.01, 1000)
+ scene.add(new AmbientLight(0x909090))
+
+ let sphere = new Mesh(
+ new SphereGeometry(0.5, 64, 64),
+ new MeshPhongMaterial()
+ )
+ scene.add(sphere)
+
+ let renderer
+ function render () {
+ renderer.render(scene, camera)
+ }
const worker = insideWorker(e => {
if (e.data.canvas) {
+ // canvas in Web Worker will not have size, we will set it manually to avoid errors from Three.js
+ if (!canvas.style) canvas.style = { width, height }
+ renderer = new WebGLRenderer({ canvas, antialias: true })
+ renderer.setPixelRatio(pixelRatio)
+ renderer.setSize(width, height)
+
+ render()
}
})
在创建场景的初始状态时,我们发现 Three.js 中存在一些错误信息。并非所有 DOM API 都能在 Web Worker 中使用。例如,无法document.createElement
加载 SVG 纹理。我们需要为 Web Worker 和常规脚本环境分别使用不同的加载器。我们可以通过以下属性来检测环境worker.isWorker
:
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height)
+ const loader = worker.isWorker ? new ImageBitmapLoader() : new ImageLoader()
+ loader.load('/texture.png', mapImage => {
+ sphere.material.map = new CanvasTexture(mapImage)
+ render()
+ })
render()
我们渲染了场景的初始状态。但大多数 WebGL 场景都需要对用户操作做出反应。例如,用鼠标旋转相机,或者canvas
在窗口大小调整时进行更新。遗憾的是,Web Worker 无法访问任何 DOM 事件。我们需要在主线程中监听事件并向 Worker 发送消息:
import createWorker from 'offscreen-canvas/create-worker'
const workerUrl = document.querySelector('[rel=preload][as=script]').href
const canvas = document.querySelector('canvas')
const worker = createWorker(canvas, workerUrl)
+ window.addEventListener('resize', () => {
+ worker.post({
+ type: 'resize', width: canvas.clientWidth, height: canvas.clientHeight
+ })
+ })
const worker = insideWorker(e => {
if (e.data.canvas) {
if (!canvas.style) canvas.style = { width, height }
renderer = new WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height)
const loader = worker.isWorker ? new ImageBitmapLoader() : new ImageLoader()
loader.load('/texture.png', mapImage => {
sphere.material.map = new CanvasTexture(mapImage)
render()
})
render()
- }
+ } else if (e.data.type === 'resize') {
+ renderer.setSize(width, height)
+ render()
+ }
})
结果
使用OffscreenCanvas
,我修复了 Chrome 个人网站上的 UI 卡顿问题,并在 Google Lighthouse 上获得了满分 100 分。而且我的 WebGL 场景在所有其他浏览器中仍然有效。