WebWorker、ThreeJs的渲染和控制

ios16.4 版本中已经开始支持了 OffscreenCanvas ,那看样子,是时候再把Three做一波优化了

背景介绍

在之前的项目经验中,如果使用threejs加载比较大的3d场景,那么在创建 threejs 的对象和绘制的时候,会占用浏览器线程执行一个大时长的任务,导致页面卡住,不能交互。

那有什么即可以绘制 canvas 又不占用主线程的方法吗?

今天它来了(其实已经来了很久了)

使用WebWorker + OffscreenCanvas 就可以实现在另外的线程中绘制canvas ,从而做到不影响主线程。

本文不会主要介绍 WebWorkerThreejs 基础知识,只是一篇实操(辛酸史),但是在必要的时候会提供相关的链接

 WebWorker 可以在后台启动一个线程执行js脚本,并且不会影响到主线程。

关于Webworker: 使用 Web Workers - Web API 接口参考 | MDN

OffscreenCanvas 是一个可以脱离屏幕渲染的canvas 对象,在串口环境和 WebWorker 环境都可以使用

关于OffscreenCanvas: OffscreenCanvas - Web API 接口参考 | MDN

项目开始

接下来就实践一下WebWorker + Threejs 渲染3d场景,并使用 OrbitControls 实现人机交互

Demo使用 vue3 开发

app.vue

1
2
3
4
5
6
7
<template> 
    <canvas ref="canvas"></canvas>
</template>
<script setup>
import { ref } from 'vue'
canvas = ref()
</script>

WebWorker 中只能使用 OffscreenCanvas,不能直接操作 DOM,所以需要把 canvas 元素转成 OffscreenCanvas 对象,在传递给 WebWorker 中使用

vue 的组件 onMounted 生命周期中,可以访问 DOM 元素

App.vue

1
2
3
4
5
6
7
8
9
import {
// ...other
onMounted
} from 'vue'
import Worker from './worker?worker'
const worker = new Worker()
onMounted(() =>{
const offCanvas = canvas.value.transferControlToOffscreen()
})

WebWorker 的通信通过 postMessageonmessage

1
2
3
4
5
6
worker.postMessage({
type: 'init',
data: {
offCanvas
}
}, [offCanvas])    

webworker 中接收传入的 OffscreenCanvas 对象

worker.js

1
2
3
4
5
6
7
self.onmessage = function ({ data }) {
switch (data.type) {
case 'init':
init(data.data)
break;
}
}

使用传入的 OffscreenCanvas 对象做threejs的渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import {
OrthographicCamera,
Scene,
WebGLRenderer,
BoxGeometry,
MeshLambertMaterial,
Mesh,
AmbientLight
} from 'three'


self.onmessage = function ({ data }) {
switch (data.type) {
case 'init':
init(data.data)
break;
}
}

let scene;
let camera;
let renderer;

function init(data) {
const { offCanvas } = data
const { width, height } = offCanvas
initScene()
initLight()
initCamera(width, height)
initRenderer(offCanvas, width, height)
render()
}
// 初始化相机
function initCamera(w, h) {
const k = w / h;
const s = 300;
camera = new OrthographicCamera(-s * k, s * k, s, -s, 1, 1000)
camera.position.set(550, 600, 100);
camera.lookAt(scene.position);
}

// 初始化渲染器
function initRenderer(canvas, w, h) {
renderer = new WebGLRenderer({canvas})
renderer.setSize(w, h, false);
renderer.setClearColor(0xb9d3ff, 1)
}

// 初始化场景
function initScene() {
scene = new Scene();
function createMesh(i) {
// 立方体网格模型
const geometry1 = new BoxGeometry(10, 10, 10);
const material1 = new MeshLambertMaterial({
color: 0x0000ff
})
const mesh1 = new Mesh(geometry1, material1);
mesh1.translateZ(i * 10)
scene.add(mesh1);
}

for (let i = 0; i < 10; i ++) {
createMesh(i)
}
}

// 初始化环境光
function initLight() {
const ambient = new AmbientLight(0x444444)
scene.add(ambient)
}

// 开始渲染
function render() {
function _render() {
renderer.render(scene, camera);
self.requestAnimationFrame(_render)
}
_render()
}

写完 worker.js之后,3d场景已经可以渲染但是不能交互,threejs 中有提供OrbitControls 轨道控制器做交互

OrbitControls 需要在页面上绑定DOM事件 实现人机交互

因为 WebWorker 中不能操作 DOM 所以 OrbitControls 不能直接在 WebWorker 中使用,要在主线程中使用

用法很简单,只需要传入一个 Camera 对象,和一个绑定事件用的 DOM元素 就可以了

1
2
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
new OrbitControls(camera, canvas)

主线程中已经有了一个canvas 元素,现在还需要一个 camera 对象,第一考虑直接把 WebWorker 中创建的 Camera 传到主线程中

worker.js

1
2
3
4
5
6
7
8
// 初始化相机
function initCamera(w, h) {
// ...other
self.postMessage({
type: 'create-camera',
data: { camera }
})
}

App.vue

1
2
3
4
5
6
7
8
9
10
11
let camera;
function createCamera(data) {
camera = data.camera;
}
worker.onmessage = function ({data}) {
switch (data.type) {
case "create-camera":
createCamera(data.data);
break;
}
}

控制台出现报错,不可行

原因是因为 postMessage 方法传递的参数,必须是可以被结构化克隆算法处理的JavaScript对象。

Function 对象和 Dom 对象是不能被结构化克隆算法复制的

关于WebWorker.postMessage:Worker.postMessage() - Web API 接口参考 | MDN

关于结构化克隆算法:结构化克隆算法 - Web API 接口参考 | MDN

既然不能直接传 Camera 对象,那就传递创建 Camera 使用的参数,在主线程中创建一个一样的 Camera 对象

worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 通知主线程camera创建成功
function dispatchCreateCamera(data) {
self.postMessage({
type: 'create-camera',
data: data
})
}
// 初始化相机
function initCamera(w, h) {
// ...other
dispatchCreateCamera({
args: [-s * k, s * k, s, -s, 1, 1000],
position: [550, 600, 100],
lookAt: [scene.position.x, scene.position.y, scene.position.z]
})
}

App.vue

1
2
3
4
5
6
7
8
import { OrthographicCamera, Vector3 } from 'three'
function createCamera(data) {
const { args, position, lookAt} = data;
camera = new OrthographicCamera(...args)
camera.position.set(...position);
camera.lookAt(new Vector3(...lookAt));
}
new OrbitControls(camera, canvas.value)

这样就创建了控制器了,但是现在控制器还是没有实现交互,因为现在修改的是主线程的Camera ,而canvas绘制是用的 WebWorker 中的 Camera,所以还需要把控制器对 Camera 的修改同步到 WebWorker

OrbitControls 的代码(这里就不展开看了)发现在事件处理中,通过调用 scope.update 完成对 Camera 的修改

OrbitControlsupdate 方法中,除了 对 Cameraposition 的修改外,还调用了 CameralookAt 方法,所以这里我们做一个投机取巧的操作。

Camera 做代理,每次调用 lookAt 的时候,就把 CamerapositionzoomlookAt 的参数,传递给 WebWorker ,对 WebWorker 中的 Camera 做一样的操作,完成交互

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function dispatchCameraUpdate(data) {
worker.postMessage({
type: 'update-camera',
data
})
}
function createCamera(data) {
// ...other
const $camera = new Proxy(camera, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
if (key === 'lookAt') {
return function ($target) {
value.call(target, $target)
dispatchCameraUpdate({
position: [camera.position.x, camera.position.y, camera.position.z],
lookAt: [$target.x, $target.y, $target.z],
zoom: camera.zoom
})
}
}
return value
}
})
new OrbitControls($camera, canvas.value)
}

worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {
// ...other
Vector3
} from 'three'
self.onmessage = function ({ data }) {
switch (data.type) {
// ...other
case 'update-camera':
updateCamera(data.data)
break;
}
}
function updateCamera(data) {
const { position, lookAt, zoom } = data;
camera.zoom = zoom;
camera.position.set(...position)
camera.lookAt(new Vector3(...lookAt))
// 修改了zoom之后需要调用updateProjectionMatrix
camera.updateProjectionMatrix()
}

现在已经完成了在 WebWorker 中操作 Three ,在做一个绘制大量元素的场景,看一下浏览器是否还会有大时长任务阻塞

worker.js

1
2
3
4
5
6
7
8
// 初始化场景
function initScene() {
// ...other

for (let i = 0; i < 10000; i ++) {
createMesh(i)
}
}

可以看到在threejs绘制期间,浏览器的渲染并没有被阻塞,在WebWorker 中有一个 2.43s的长任务,这个任务的执行,并不会阻塞浏览器的渲染,这就是 WebWorker的后台渲染

问题

  1. ios16.4支持了 OffscreenCanvas 但是并没有支持 3d 的应用,OffscreenCanvas 获取 webgl 的上下文返回的是 null

  2. 现在的主流设备并没有完全支持 OffscreenCanvas ,所以开发中还需要考虑好兼容性

参考链接

使用 Web Workers - Web API 接口参考 | MDN

OffscreenCanvas - Web API 接口参考 | MDN

Worker.postMessage() - Web API 接口参考 | MDN

结构化克隆算法 - Web API 接口参考 | MDN

代码地址

GitHub - wukang0718/webworker-three: 在webworker中渲染three的Demo