在 ios16.4
版本中已经开始支持了 OffscreenCanvas
,那看样子,是时候再把Three做一波优化了
背景介绍 在之前的项目经验中,如果使用threejs加载比较大的3d场景,那么在创建 threejs
的对象和绘制的时候,会占用浏览器线程执行一个大时长的任务,导致页面卡住,不能交互。
那有什么即可以绘制 canvas
又不占用主线程的方法吗?
今天它来了(其实已经来了很久了)
使用WebWorker
+ OffscreenCanvas
就可以实现在另外的线程中绘制canvas
,从而做到不影响主线程。
本文不会主要介绍 WebWorker
和 Threejs
基础知识,只是一篇实操(辛酸史),但是在必要的时候会提供相关的链接
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 { onMounted } from 'vue' import Worker from './worker?worker' const worker = new Worker()onMounted(() => { const offCanvas = canvas.value.transferControlToOffscreen() })
和 WebWorker
的通信通过 postMessage
和 onmessage
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 ) { 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 function dispatchCreateCamera (data ) { self.postMessage({ type : 'create-camera' , data : data }) } function initCamera (w, h ) { 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
的修改
在 OrbitControls
的 update
方法中,除了 对 Camera
的 position
的修改外,还调用了 Camera
的 lookAt
方法,所以这里我们做一个投机取巧的操作。
对 Camera
做代理,每次调用 lookAt
的时候,就把 Camera
的 position
、zoom
和 lookAt
的参数,传递给 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 { Vector3 } from 'three' self.onmessage = function ({ data } ) { switch (data.type) { 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)) camera.updateProjectionMatrix() }
现在已经完成了在 WebWorker
中操作 Three
,在做一个绘制大量元素的场景,看一下浏览器是否还会有大时长任务阻塞
worker.js
1 2 3 4 5 6 7 8 function initScene ( ) { for (let i = 0 ; i < 10000 ; i ++) { createMesh(i) } }
可以看到在threejs绘制期间,浏览器的渲染并没有被阻塞,在WebWorker
中有一个 2.43s
的长任务,这个任务的执行,并不会阻塞浏览器的渲染,这就是 WebWorker
的后台渲染
问题 ios16.4支持了 OffscreenCanvas
但是并没有支持 3d 的应用,OffscreenCanvas
获取 webgl
的上下文返回的是 null
现在的主流设备并没有完全支持 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