22.Three.js优化之OffscreenCanvasWebWorker

22 Three.js优化之OffscreenCanvasWebWorker

我们知道JS是单线程,可以通过WebWorker将一些复杂计算执行命令从主线程中分离出来。

关于WebWorker的使用方法,请参考:

https://developer.mozilla.org/zh-cn/docs/web/api/web_workers_api/using_web_workers

或者查看我写的另外一篇文章:WebWorker学习笔记.md


一些比较新的浏览器(例如谷歌浏览器)还有另外一个和WebWorker搭配使用、针对画布的类:OffscreenCanvas。

OffscreenCanvas的作用就是将canvas的控制权转让给Web Worker

因此OffscreenCanvas必须搭配Web Worker一起使用。

补充一个知识点:

OffscreenCanvas类似的还有ArrayBuffer、 MessagePort、ImageBitmap,他们都可以与WebWorker搭配使用


OffscreenCanvas的概念和用法

OffscreenCanvas的基本概念

你可以把WebWorker做为各种复杂类型的后台运算线程,作用范围比较广泛。

OffscreenCanvas则专门针对Canvas做离屏渲染。

注意,这里提到的 离屏渲染 和 我们使用WebGLRendererTarget来做的离屏渲染,从概念上是类似的

OffscreenCanvas的 “离屏” 是指浏览器DOM而言

WebGLRenderTarget的 “离屏” 只指Three.js的主场景(Scene)而言

你可以把OffscreenCanvas看作是针对Canvas的特殊WebWorker应用场景。

但是请记得:目前绝大多数浏览器均已支持WebWorker,但是对OffscreenCanvas的支持度并不高。


如何创建OffscreenCanvas

不可以使用new OffscreenWorker()的方式来创建OffscreenCanvas,而是使用canvas.transferControlToOffscreen()来获得canvas对应的OffscreenCanvas


如何检测当前浏览器是否支持OffscreenCanvas

我们只需检查canvas是否存在transferControlToOffscreen方法:

if(canvas.transferControlToOffscreen !== null){
    console.log('当前浏览器支持 OffscreenCanvas')
}else{
    console.log('当前浏览器不支持 OffscreenCanvas')
}

我们是通过检查canvas对象上是否包含.transferControlToOffscreen方法来判断是否当前浏览器支持OffscreenCanvas的。

这里补充一个JS知识,如何判断某个对象上是否有某个属性。

假设有一个对象

const obj = {
  a:undefined,
  b:null
}

此时我们使用if(obj.a)if(obj.b)都是无法准确判断出到底 属性ab是否存在。

那么这个时候就可以使用以下2种方式来进行判断:

if('a' in obj) { ... }

if(Reflect.has(obj,'b')){ ... }

使用in或者Reflect.has()就可以准确判断出对象上是否具有某属性或方法,即使该属性的值为undefined

注意:上面2种查询方式都需要属性名的字符串值,为了更好的语法提示,我们示例中并不这样用。


OffscreenCanvas的用法

再次强调:通常情况下OffscreenCanvas必须搭配Web Worker一起使用。

我们单独创建一个JS文件,将canvas绘制的一些JS代码写在这个文件中。

大体步骤:

  1. 创建一个单独的JS文件,用来编写Three.js场景内容和渲染代码

    会以这个JS文件来作为web worker的调用文件对象

  2. 在主场景中,获得DOM中的canvas

  3. 获取canvas对应的OffscreenCanvas

    const offscreen = canvas.transferCountrolToOffscreen()
    
  4. 创建一个worker对象,并且设置一些消息参数

    const worker = new Window.Worker('xx/xxxx.js',{type:'module'})
    worker.postMessage({type:'main',canvas:offscreen},[offscreen])
    
  5. 由于web worker不允许访问DOM事件,例如浏览器窗口尺寸改变事件、鼠标事件等,所以当这些事件发生后,我们需要通知worker,将事件对应的一些参数和变动发送给worker,以便worker中的canvas渲染逻辑作出对应的响应。

    窗口尺寸改变事件我们还比较容易解决,无非就是把新的尺寸发送给worker,比较难的是像 鼠标事件、键盘事件等,需要稍微复杂的一些传递方式才可以解决。

    在本文后半部分会有详细讲解。


实际差异:

刚才将的是理论上大体步骤,但是由于我们本教程的示例代码,实际上是运行在React + TypeScript环境上的,也就是说我们需要编写的是worker.ts而不是worker.js

当然你也可以采取在编写worker时使用.js而不是.ts,只不过这样就失去了TypeScript的便利性。

我们推荐的解决方案是使用webpack的插件:worker-loader来解决react + typescript环境中编写worker

具体的配置步骤,请参考我写的另外一篇文章:React内嵌WebWorker代码

接下来,我们将通过一个实际的例子,来演示一遍OffscreenCanvas + Worker


离屏画布渲染示例:HelloOffscreenCanvas

假设你已经配置好了worker-loader,那么我们开始本示例。


示例目标:

  1. 场景上有3个不同颜色,不断旋转的立方体
  2. 我们将场景中的渲染工作,从主程序中抽离出去,让Web Worker来负责场景的渲染工作,依次来减轻主程序的运算负担。

补充说明:

  1. 我们场景中动画渲染本身计算量并不是很大,即使我们不使用web worker浏览器也不会卡顿,但本示例只是为了演示如何使用OffscreenCanvas + Worker
  2. 我们先假设你的浏览器是支持OffscreenCanvas的。

关键点说明:

默认 主程序(index.tsxindex.ts)与 分线程(Worker.ts)彼此通过.postMessage()互相发送数据。

.postMessage()默认发送的数据是深拷贝,而不是引用。

因为本质上index.tsxworker.ts就不在同一个线程中,无法共享数据

支持共享数据的SharedWorker目前浏览器支持度还不够高。

假设我们是把场景渲染的计算工作转移到了worker.ts中,但是worker.ts每次计算好canvas画面内容后再发送给index.tsx,每次都执行一次深拷贝,性能反而低下。

Web Worker本身是无法访问DOM元素的

幸好.postMessage()方法的第2个参数,允许我们将一些数据类型比较大的对象,直接将控制权转移给worker.ts

也就是说,OffscreenCanvas + Worker不是走以下流程:

  1. worker.ts负责创建Three.js场景和物体
  2. worket.ts负责渲染得到 场景画面数据
  3. worket.ts通过.postMessage()将离屏渲染得到的 画布画面内容数据 发送给index.tsx
  4. index.tsx接收 画布画面内容数据 并渲染到canvas DOM

而是走以下流程:

  1. index.tsx通过canvas.transferToOffscreenCanvas()得到OffscreenCanvas
  2. index.tsx通过.postMessage()2个参数,将[OffscreenCanavs]传递给worker.ts,也就是说将canvas的控制权完全交给worker.ts
  3. 接下来就是worker.ts负责创建Three.js场景和物体,并且渲染场景画面内容直接赋予给OffscreenCanavas

其他补充:

由于worker本身无法获取DOM ,以及无法获取浏览器某些事件,例如浏览器的窗口大小变动事件。

所以当浏览器窗口尺寸发生变化后,我们要让index.tsx及时通知worker.ts新的浏览器窗口宽高,以便让worker.ts作出相应的调整。

与窗口尺寸改变相似的还有鼠标移动事件,也可以通过传递当前鼠标坐标位置传递给Worker以便做出相应的处理。后期我们会学习如何做场景物体拾取效果,就是鼠标放到某个物体上时物体做出相应变化,这种场景就会需要用到鼠标坐标。

接下来,就开始具体编写代码吧。


message-data.ts

src/components/hello-offscreen-canvas/message-data.ts

message-data.ts的作用是定义一些参数名、参数值的类型,以便我们获得好的TS语法提示。

注意:message-data.ts会同时被index.tsxworker.ts引入,这样做的效果是:

  1. index.tsx可以比较容易知道worker.ts内部定义的函数名叫什么
  2. worker.ts可以比较容易知道index.tsx传递过来的参数类型是什么
//定义画布的尺寸类型数据结构
export type CanvasSize = {
    width: number,
    height: number
}

export enum WorkerFunName {
    main = 'main',
    updateSize = 'updateSize'
}

//定义 MessageEvent data 的数据结构
export type MessageData =
    { type: WorkerFunName.main, params: OffscreenCanvas }
    |
    { type: WorkerFunName.updateSize, params: CanvasSize }

worker.ts

src/components/hello-offscreen-canvas/worker.ts

import * as Three from 'three'
import { CanvasSize, MessageData, WorkerFunName } from './message-data'

let renderer: Three.WebGLRenderer
let camera: Three.PerspectiveCamera
let scene: Three.Scene

//定义初始化的函数
const main = (canvas: OffscreenCanvas) => {
    //开始创建 3D 相关场景
    renderer = new Three.WebGLRenderer({ canvas })
    camera = new Three.PerspectiveCamera(45, 2, 0.1, 100)
    camera.position.z = 4
    scene = new Three.Scene()

    const colors = ['blue', 'red', 'green']
    const cubes: Three.Mesh[] = []
    colors.forEach((color, index) => {
        const material = new Three.MeshPhongMaterial({ color })
        const geometry = new Three.BoxBufferGeometry(1, 1, 1)
        const mesh = new Three.Mesh(geometry, material)
        mesh.position.x = (index - 1) * 2
        scene.add(mesh)
        cubes.push(mesh)
    })

    const light = new Three.DirectionalLight(0xFFFFFF, 1)
    light.position.set(-2, 2, 2)
    scene.add(light)

    const render = (time: number) => {
        time *= 0.001
        cubes.forEach((item) => {
            item.rotation.set(time, time, 0)
        })
        renderer.render(scene, camera)
        self.requestAnimationFrame(render)
    }
    self.requestAnimationFrame(render)
}

//定义用来接收画布尺寸更新的函数
const updateSize = (newSize: CanvasSize) => {
    camera.aspect = newSize.width / newSize.height
    camera.updateProjectionMatrix()
    renderer.setSize(newSize.width, newSize.height, false)
}

const handleMessage = ((eve: MessageEvent<MessageData>) => {
    switch (eve.data.type) {
        case WorkerFunName.main:
            main(eve.data.params)
            break
        case WorkerFunName.updateSize:
            updateSize(eve.data.params)
            break
        default:
            throw new Error(`no handle for the type`)
    }
})
self.addEventListener('message', handleMessage)

const handleMessageError = () => {
    throw new Error('Worker.ts: message error ...')
}
self.addEventListener('messageerror', handleMessageError)

//导出 {} 是因为 .ts 类型的文件必须有导出对象才可以被 TS 编译成模块,而不是全局对象
export { }

index.tsx

src/components/hello-offscreen-canvas/index.tsx

import { useEffect, useRef } from 'react'
import { WorkerFunName } from './message-data'
import Worker from 'worker-loader!./worker'

import './index.scss'

const HelloOffscreenCanvas = () => {

    const canvasRef = useRef<HTMLCanvasElement | null>(null)

    useEffect(() => {
        if (canvasRef.current === null) { return }

        const canvas = canvasRef.current as HTMLCanvasElement
        const offscreen = canvas.transferControlToOffscreen()

        const worker = new Worker()
        worker.postMessage({ type: WorkerFunName.main, params: offscreen}, [offscreen])

        const handleMessageError = (error: MessageEvent<any>) => {
            console.log(error)
        }
        const handleError = (error: ErrorEvent) => {
            console.log(error)
        }
        worker.addEventListener('messageerror', handleMessageError)
        worker.addEventListener('error', handleError)

        const handleResize = () => {
            worker.postMessage({
                type: WorkerFunName.updateSize,
                params: { width: canvas.clientWidth, height: canvas.clientHeight }
            })
        }
        handleResize()
        window.addEventListener('resize', handleResize)

        return () => {
            worker.removeEventListener('messageerror', handleMessageError)
            worker.removeEventListener('error', handleError)
        }
    }, [canvasRef])


    return (
        <canvas ref={canvasRef} className='full-screen' />
    )
}

export default HelloOffscreenCanvas

我们可以看到index.tsx中已经没有任何Three.js相关的代码了。

运行调试一切正常。


接下来我们要解决2个问题:

  1. 控制3D场景用到的OrbitControls类,在新建时需要传递HTML DOM元素,交互的过程中需要DOM元素的鼠标事件和键盘事件,但是worker内部又不能访问DOM元素,那该如何解决?

  2. 假设浏览器不支持OffscreenCanvas ,那又该如何拆分我们的代码可以做到兼容?

    在软件开发术语中,会使用 “鲁棒性或健壮性” 来指代码的兼容性和容错性。


模拟并添加OrbitControls

你需要先忘记我们上面刚才讲过的示例代码,本小节中所有的代码和上面示例代码没有任何关联。

目前无法使用OrbitControls的困境

在以前所有的例子中,我们添加镜头轨道控制器,都是使用以下代码:

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

const controls = new OrbitControls(camera,canvas)
或者
const controls = new OrbitControls(camera,window.body)

OrbitControls构造函数第2个参数无论是canvas还是window.body,一定是一个DOM元素。

我们已经将Three.js相关代码都转移到了Worker内部,但是Web Worker内部是无法获取DOM元素的,那么意味着OrbitControls根本就无法初始化。

不要想着尝试将DOM元素作为 参数,使用.postMessage()函数传递给Worker内部,因为.postMessage()函数中的参数并不是传递引用,而是直接深度复制一份。


那么究竟该怎么办呢?

我们先研究一下OrbitControls的源码:

https://github.com/mrdoob/three.js/blob/dev/examples/jsm/controls/OrbitControls.js


我把源码中和Dom元素相关的一些关键代码摘录出来:

//设置 scope = this
var scope = this;

var OrbitControls = function ( object, domElement ) {
  //下面这行代码相当于 scope.domElement = domElement
  this.domElement = domElement;
}

//添加鼠标右键(上下文)菜单事件侦听
scope.domElement.addEventListener( 'contextmenu', onContextMenu);

//添加触控笔和鼠标摁下事件侦听
scope.domElement.addEventListener( 'pointerdown', onPointerDown);

//添加鼠标滚轴事件侦听
scope.domElement.addEventListener( 'wheel', onMouseWheel );

//添加触摸开始、结束、移动事件侦听
scope.domElement.addEventListener( 'touchstart', onTouchStart);
scope.domElement.addEventListener( 'touchend', onTouchEnd);
scope.domElement.addEventListener( 'touchmove', onTouchMove);

//添加键盘事件侦听
scope.domElement.addEventListener( 'keydown', onKeyDown);

if ( state !== STATE.NONE ) {
    //添加触控笔和鼠标移动事件侦听
    scope.domElement.ownerDocument.addEventListener( 'pointermove', onPointerMove, false );
    //添加触控笔和鼠标松开事件侦听
    scope.domElement.ownerDocument.addEventListener( 'pointerup', onPointerUp, false );
    scope.dispatchEvent( startEvent );
}

补充说明:在最新的浏览器事件中pointer相关事件即包含触控笔,也包含鼠标。所以pointerdown、pointermove、pointerup3个事件对应 触控笔或鼠标 对应的事件,相当于是2者的合体。


从上面可以看出,我们初始化传递给OrbitControlsDOM元素主要是用来添加各种事件侦听。

当然OrbitControls代码中也有对应的removeEventListener移除事件侦听。

补充一点:contextmenu事件虽然目前部分浏览器支持(主要是火狐浏览器),但是在MDN的文档中已经明确该事件即将被废除。


重点来了,请听好:

  1. 既然OrbitControls需要DOM元素的目的是为了获取并添加各种事件侦听

  2. Worker中无法获取DOM元素

  3. 那么有没有可能我们虚拟出来一个对象,让该对象拥有和DOM元素相同的事件API

    换句话说,就是让这个虚拟出来的对象具备抛出事件的能力

    补充一点,这里说的DOM事件其实有2种:

    1. DOM元素上的各种用户交互5种事件:鼠标事件、滚轴事件、键盘事件、触摸事件、右键菜单事件
    2. 浏览器窗口尺寸发生变化引发DOM元素尺寸发生变化的window resize事件
    3. 我们需要做的就是分别模拟出以上6种事件
  4. 然后我们让OrbitControls去侦听这个虚拟对象所发出的各种事件

  5. 也就是说让这个虚拟对象代替真实的DOM元素,以此来解决我们目前的困境


思路有了,那该具体怎么做呢?

首先我们要明白一件事,原生DOM对应的属性、方法、事件、以及事件携带的属性 种类繁多且复杂。

OrbitControls并不是每一个属性、方法、事件、事件每一个属性 都使用到了,也就是说我们所谓的 “模拟”不需要100%一模一样,我们只需要提供OrbitControls需要的即可。

究竟需要模拟出DOM元素哪些属性和方法,我们会在具体代码时讲解。

此刻,我们只以事件携带的属性来举例说明:

  1. 对于鼠标滚轴滚动来说,OrbitControls只需要使用到该事件的deltaY

  2. 对于键盘事件来说,OrbitControls只需要使用到该事件的ctrlKey、metaKey、shiftKey、keyCode

    meta键?

    这个meta键在Windows键盘上相当于windows键、在苹果键盘上是一个四瓣的小花。

    OrbitControls内部并未使用到altKey

    注意:由于OrbitControls仅使用到键盘 上///4个键,我们还可以主动过滤掉一些无用的摁键,换句话说也就是提前判断一下是否是以上4个方向键,如果不是,则直接跳过,不传递该事件

    注意:对于目前版本Three.js r126版本而言,OrbitControls键盘事件读取使用的是keyCode,但是event.keyCode事实上已经不被推荐使用,建议使用event.code属性。

    所以我顺带向Three.js官方提交了PR,将keyCode修改为code,这个PR已经被官方审查通过了,会在r127版本中使用。因此,我也成为Three.js代码贡献者了。

    我提交的这个PR:https://github.com/mrdoob/three.js/pull/21409

    关于为什么不再推荐使用event.keyCode主要是因为keyCode不能够比较清晰正确返回键盘所摁键,例如 冒号和分号 都是同一个键,此时keyCode就无法精确区分。

  3. 对于鼠标事件,OrbitControls需要使用到该事件的pointerType、button、clientX、clientY、ctrlKey、metaKey、shiftKey

  4. 对于触摸(touch)事件,OrbitControls需要使用每一个 触摸点的pageXpageY属性值


我们需要让 “虚拟对象” 抛出 “虚拟事件”,并且虚拟事件拥有上面那些属性值。

补充说明:这里所说的 “抛出事件” 暗含2种事件:

  1. 用户交互事件:鼠标事件、滚轴事件、键盘事件…

  2. 浏览器窗口变化引发 “DOM元素” 内部尺寸属性相关的修改事件

    除了window拥有resize事件之外,普通DOM元素是不具备resize事件的,我们可以将这个原本不存在的DOM元素resize追加到 我们的消息中,对于 用户交互事件 我们选择直接抛出、对于window resize引发的尺寸修改事件,我们选择内部直接处理。


属性补充说明:

OrbitControls内部,还会对监听的DOM元素的根元素添加2个事件侦听。

// scope = this
scope.domElement.ownerDocument.addEventListener( 'pointermove', onPointerMove );
scope.domElement.ownerDocument.addEventListener( 'pointerup', onPointerUp );

因此我们模拟的元素还要拥有.ownerDocument属性值。

在后面的实际代码中你会看到,我们会让 模拟元素.ownerDocument指向自身。

this.ownerDocument = this


document补充说明:

OrbitControls类的构造函数中,有以下代码:

if ( domElement === undefined ) console.warn( 'THREE.OrbitControls: The second parameter "domElement" is now mandatory.' );

if ( domElement === document ) console.error( 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.' );

也就是说在初始化OrbitControls时会对要侦听的DOM元素进行检查。在第2行代码中需要使用到document这个对象,但是在web worker中根本无法访问document,所以我们需要给worker添加一个document对象。代码如下:

//@ts-ignore
self.document = {}
  1. 添加//@ts-ignore这样注释,可以让TypeScript忽略下面一行代码的检查。

    因为window.documentTS版本里被定义为 只读对象,是无法修改的。

    如果我们不选择忽略TS检查,当去执行self.document = {}TS就会报错。

  2. 我们添加的self.document = {}纯粹是为了让OrbitControls构造函数可以去访问到document对象,避免报错。


所谓的 “抛出事件” 实现的途径是由 我们虚构出的一个 事件派发器 来实现的。

该事件派发器的3个功能责任:

  1. 监听功能:监听真实DOM元素事件:用户交互事件、浏览器尺寸变化事件
  2. 事件转化:将监听到的事件内容,转化为一条数据,该数据包含该事件中OrbitControls所需要的各种属性值
  3. 消息传递:将事件转化后的数据,通过web workerpostMessage()传递给web worker

所谓的“模拟的DOM元素”,也就是在web worker中工作的DOM元素。

该“模拟的DOM元素”的2个功能责任:

  1. 模拟DOM元素的属性和方法
  2. 处理DOM元素需要处理的各种事件

该“模拟的DOM元素”的生命工作流程为:

  1. index.tsx中添加对 真实DOM元素的监听,并且通知worker创建“模拟元素”的消息

  2. worker.ts中接收消息,开始创建一个 “模拟元素”,并且把该元素传递给OrbitControls

  3. index.tsx中监听到真实DOM元素触发的各种事件,将该事件分析处理,转化为一条约定好的消息 并发送给worker

  4. worker.ts中接收到事件消息后,将该消息转发给 “模拟的DOM元素”

  5. “模拟的DOM元素” 接收该消息,然后将该消息(包含事件类型、事件属性)抛出,供OrbitControls使用

    我们抛出的事件,也是模拟出来的事件,并不是原始DOM事件,只不过我们模拟出来的事件中恰好包含OrbitControls需要的所有属性和方法。

    web worker中工作的OrbitControls,从始至终都不知道自己操作的其实是一个假的DOM元素,以及监听的事件也是假的DOM事件。


整个事件的流程为:

真实DOM事件>转化为消息>发送给worker >传递给“模拟的DOM元素” >抛出该消息(相当于抛出该事件)

通过 事件>消息>消息>事件 这样一波操作过后,可以让OrbitControls就像监控真实DOM元素一样监控运行在web worker中的那个 “模拟的DOM元素”。

我故意一直使用 “模拟的DOM元素”这个词,而没有使用 “虚拟DOM”,就是为了让我们避免和react中 虚拟DOM的一词弄混淆。


关于 事件抛出 的补充说明:

我们让 “虚构元素” 继承于Three已内置的EventDispatcher,这样 “虚构元素” 就 具备.dispatcheEvent()方法。

import { EventDispatcher } from 'three'

为什么不使用 原生JS提供的EventTarget

这是因为原生JS提供的EventTarget虽然也有.dispatcheEvent(),但问题是它只可以抛出JS中的Event实例,而我们在worker中并不能使用Event


补充说明:

无论JS原生的EventTarget,还是Three.js内置的EventDispatcher,他们内部本质上都是执行的是函数调用,所以他们的执行过程都是 同步的。

浏览器中原生的各种事件处理函数 其实是异步的。


整体解决思路回顾:为什么我们可以实现?

回顾一下本小节开头的困境问题:web worker本身不可以访问真实DOM元素,当然也包括DOM事件。


那么我们究竟是怎么做到的?以及为什么我们可以做到?

答:机缘巧合

**1种机缘巧合:**虽然web worker无法访问真实的DOM元素,但是canvas元素对应的OffscreenCanvas却是一个例外,通过主线程让出canvas绘制控制权,让web worker拥有了可以操作并绘制canvas元素的能力。

目前火狐浏览器并不支持OffscreenCanvas,所以本示例还要考虑在非worker情况下的场景创建。


**2种机缘巧合:**虽然web worker无法直接监听真实DOM元素事件,但是web worker内部却可以运行 抛出事件 这个操作。于是我们通过 事件>消息>消息>事件 的操作让web worker内部模拟出的DOM元素拥有了像真实DOM元素一样的各种事件抛出机制。

再次提醒一下:在web worker中,不光DOM元素是我们模拟出来的,就连抛出的DOM元素事件也是我们模拟出来的。

最终我们让web workerOrbitControls正常运行起来了。


如果你对我上面的讲述还不太理解,那么多读几遍,不要着急接着往下看。因为如果整体的思路你没有理解透,那么下面这些具体实现的代码,阅读起来也会一头雾水。

实话实说,在学习本章内容,看英文原版教程,我花了将近一周的时间,才弄明白整个原理。

本文讲的内容可能是整个three.js教程中最绕、最复杂的,但是为了性能优化,学习一下是非常值得的。


补充一个和本文无关的知识点:

除了window之外,其他DOM元素尺寸发生变化时,是不会触发任何事件的。

  1. 通过css修改DOM元素尺寸
  2. 因为window resize事件而修改DOM元素尺寸

如果你想监听某DOM元素尺寸的变化,在最新的DOM3标准中,可以通过MutationObserver来监控,具体用法请查阅MDN官方文档。

https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver


整体思路示意图

using-orbitcontrols-in-worker.jpg


接下来开始讲解具体代码如何实现。

由于我使用的是React + TypeScript,加上我有一些自己代码理解和划分,所以我下面讲述的代码和原教程很多地方都不一样。

过程有点复杂,希望我能讲清楚。


代码模块规划

  1. 定义一个类FictionalElement继承于EventDispatcher,用这个类的实例来模拟 “DOM元素本身”

    请注意:FictionalElement只具备(模仿、模拟)DOM元素” 本身的一些属性和方法(例如width、height、left、top、getBoundingClientRect()),但并不具备可以直接和web worker通信的能力

    在模拟一些 “无用” 的方法时,例如focus()、preventDefault()、stopPropagation(),将该方法代码体内不添加任何代码,只是保证有这个方法但无需有实际执行内容。

  2. 定义一个类FictionalElementManager用来创建和管理所有FictionalElement实例

    事实上我认为这一步骤不是必须的,因为实际项目中,绝大多数情况下网页中都只会有一个 用户交互元素(通常为canvasdocument.body),并不会有太多元素需要我们管理。但是本文对应的 原版教程 中有这一环节(管理层),那么我们也继续遵循。

    为了区别不同的管理对象,我们将在内部给每一个ElementProxyReceiver添加一个对应的id

    原版教程中,id是由FictionalElementManager提供的,但是我认为这样并不合理,我采用的是通过外部传递idstring类型的值。

  3. 定义一个类FictionalWindow用来模拟window

    请注意,目前来说FictionalWindow仅仅是继承了EventDispatcher,并没有做其他设置,先这样做,也许未来有其他需求了,再根据需要扩展它。

  4. 定义一个类ControlsProxy用来负责基础的 真实DOM元素与Web Worker之间通信。

    当浏览器窗口尺寸发生变化时,我们模拟的 “DOM元素本身"也应该会发生尺寸变化,而这个变化的触发并不是由FictionalElement完成的,而是由ControlsProxy来完成的。

  5. 定义一个类OrbitControlsProxy继承于ControlsProxy,然后重写configEventListener()dispose()

    你可能疑惑为什么我没有直接把代码写在同一个类里,而是要拆分成2个。我这样做的原因是想将一些基础的、共性的属性和方法抽离出来,这样未来有一天我们要去实现其他轨道控制器,都可以继承于ControlsProxy

  6. 定义一个类WorkerMessageType,用来定义 发送消息 的类型

    每次都靠手写消息类型是不靠谱的,万一手抖拼写错字母了想检查出来都不容易。

  7. 此外,虽然本教程一直都是用的是TypeScript,但是在编写这些类的时候,考虑有些人并不使用TS,所以我采用的是.js + JSDoc的方式,通过JSDoc代码注释的形式来定义不同消息的数据结构,没有使用.ts

    我也是因为这个示例而去学习了JSDoc,感兴趣可查看我另外一篇学习笔记:JSDoc的安装与使用.md


以上仅仅是 “虚构” 的核心代码,除此之外,我们还需要编写对应的 “应用” 层面的代码:

  1. index.tsx:JS主场景main代码

  2. worker.ts:Worker场景代码

  3. create-world.ts:负责创建Three.js 3D场景的核心代码

    create-world.ts中的代码并不知道自己将来是运行在Worker中还是运行在 主场景中(Main)


如果你能坚持看到这里没有被我绕晕,那恭喜你。

是不是该展示具体代码了?


项目实际代码

原理都讲述完了,但是每个类的代码细节实在是太多,我也不想再细致讲述了,所以在Github单独创建了一个项目:

https://github.com/puxiao/using-orbitcontrols-in-worker


为了让全世界的人能看到我的这个代码,我竟然写了一个英文版README.MD

你可以直接查看我写的简体中文介绍文档:

https://github.com/puxiao/using-orbitcontrols-in-worker/blob/main/README-zh_CN.md


说一下我的感受

本节后面的代码实现,花费了我有1个月的时间,整个过程即充实有痛苦。

  1. 从阅读原版教程,完全看不懂 理解不了
  2. 后来可以理解
  3. 改为自己的实现方式
  4. 不断得修改、优化代码
  5. 上传到Github编写文档

尽管掌握了本章所写的示例代码,但是这些对实际Three.js的使用提升并不会有立竿见影的效果。


但是!

因为不断的深入去理解,学习,我也有很多收获:

  1. 我成功向Three.js贡献了自己的一点点代码,尤其是在提交自己的PR过程中,用蹩脚英语与Three.js代码审查人员的不断沟通,是一次很神奇的体验。

  2. 通过OrbitControls,顺带我也看了其他 轨道控制器的 一些源码,事实上我原本的野心计划是编写出所有轨道控制器的ControlsProxy版,但是时间和精力,这个事情只能暂时放下,等待将来或者其他感兴趣的人来编写吧。

    目前我的项目中只编写了OrbitControlsProxy.js,如果你感兴趣,也可以尝试编写出其他 轨道控制器对应的XxxControlsProxy

    提醒:如果你想编写其他轨道控制器的代理管理类,它需要继承于ControlsProxy,然后重写configEventListener()dispose()2个方法。

  3. 学习了JSDoc注释规范,也就是即使我们不使用TypeScript,通过JSDoc注释依然可以进行类型定义。

    通过JSDoc的学习,让我对TypeScript有更加深一层的理解。

    无论TypeScript还是JSDoc,还是VSCode IDE本身的代码提示和自动检查,本质上都仅仅在 开发阶段 对我们编写的代码进行约束,将来JS运行阶段就管不了那么多了。

  4. 接触了//@ts-ignore这个特殊注释


本小节结束

本小节至此结束,同时也意味着本系列教程的 “优化” 篇章讲解完成。

接下来,我们要开始新的阶段学习,下一阶段才决定我们Three.js实际应用领域 “质的飞越”。


下一阶段,我们要开始学习Three.js中常见的各种应用场景解决方案。

加油,好玩有趣的事情终于要开始了。


上一页
下一页