27.Three.js 解决方案之多画布、多场景

27 Three.js 解决方案之多画布、多场景

在我们之前的示例中,通常都是 1 个网页中只有 1 个画布,1 个渲染器,1 个场景。

1 个画布(Canvas) + 1 个渲染器 相当于在当前浏览器的 JS 中创建了 1 个 webgl。

1 个 webgl 就会占用一定量的内存和性能,浏览器也是为了用户体验着想,所以才会限制 webgl 数量的。


请注意:

浏览器并不限制 DOM 中 画布 <canvas > 标签的数量,浏览器只是限制 webgl 的数量。


网页中 webgl 数量限制

不同浏览器都会对 webgl 创建的数量进行限制,通常情况下可以创建 8 个左右。

如果超出浏览器对 webgl 数量,则新创建的会顶替较早之前创建的。

此时较早之前创建的 webgl 会消失,变为不可用


如果我们一个网页中需要多个 webgl,那是不是我们多创建几个画布就可以了?

试想一下这个场景

假设我们现在要制作一个产品列表页,该页面上需要展示 15 个产品,且每一个产品我们都希望搭配一个 3D 模型展示。

那么我们现在就会遇到一些问题:

  1. 问题一:如果每一个产品对应 1 个 webgl,因此我们就需要创建 15 个 webgl,这超出了浏览器对于一个页面上可创建 webgl 数量限制。

    我们假设浏览器最多只允许我们创建 8 个 webgl

    特别强调:假设我们在一段 JS 代码中创建了 N 个渲染器 或 N 个场景,这并不会创建 N 个 webgl,他们仍然被视为仅仅是 1 个 webgl

    你可以简单粗暴得去理解:webgl 的数量仅和画布(canvas)数量有关,和创建几个渲染器或场景无关。

  2. 问题二:假设每个产品只是模型不同,但是所使用的材质相同,或者多个产品使用同一个纹理贴图,如果我们对每一个产品都创建一套 webgl,那同一个材质或贴图就可能需要被我们反复多次加载。换句话说每一个 Three.js 创建的产品 3D 展示都相互独立(孤立),资源无法共享。

    上面我们说 “创建一套 webgl” 的意思是:创建一个 canvas,创建 一个渲染器,创建一个场景 等等


那…解决方案是什么呢?

第 1 种解决方案:用其他标签充当占位,然后使用渲染器的剪裁渲染功能

用 1 个 画布来渲染全部,用一些其他元素标签来 “代替” “充当” N 个画布。


具体的事实细节:

  1. 创建一个 <canvas > 标签,并设置 z-index:-1,这样该画布就会显示在其他元素的下面

    事实上相当于将 画布 当成了 “大背景”

  2. 在需要展示 “画布” 的位置,我们添加一些网页标签,用来启到 “占位” 的作用。

    该标签里并没有实际内容,但是我们通过 CSS 给该标签添加宽和高

  3. 在 JS 中使用 Three.js,添加不同的灯光和镜头。

    一组灯光和镜头 对应一个 需要渲染的对象内容

  4. 我们 “判断元素当前是否可见”,然后通过渲染器的以下 3 个方法,对渲染器进行 “裁剪”。

    1. Renderer.setScissorTest()

      该方法接收 1 个参数:boolean,来决定是否启用或禁用裁剪检测。

    2. Renderer.setViewport()

      该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个矩形的裁剪框。

      若此时已启用 剪裁检测,那么只有在该矩形框内的才会被渲染,不在该矩形框内的则不会被渲染。

    3. Renderer.setScissor()

      该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个视窗(视框)。

  5. 不断判断,不断清空画布内容,已实现实时更新裁剪可见区域。

    但是请注意:由于 Three.js 渲染需要一定时间,当网页快速滚动时可能会出现 “渲染不及时”,看上去似乎是一个 “bug”,具体我们会稍后讲解。

  6. 最终,我们将那些 “占位”标签的位置和尺寸 传递给 Three.js,通过 裁剪,只在渲染出相应内容。


下面我们将针对以上步骤中,一些关键的点进行详细讲解。


第 1:启到占位作用的网页标签

我们知道这些标签本身不需要显示任何内容,我们会通过 CSS 来给他们设定宽高。

那究竟使用什么标签呢?

我们会很容易想到 <div > 、<span > 这些标签都可以。无论使用哪个标签,我们只要确保这些标签统一即可。

我们推荐一种更加优雅、通用、明确的做法:给标签添加 html5 新增的 data-* 属性


data-* 属性介绍:

在传统的网页标签中,例如 <span > 标签,默认它只能有以下几种信息:

  1. 该标签拥有的 属性和处理事件函数,例如 id、onclick 等
  2. 该标签的样式,例如 class、style
  3. 该标签和闭合标签之间的内容

除此之外,该标签无法承载其他信息。

实际上若想还包含其他信息,通常变相的实现手段是将其他信息 包装成 样式名称(class name)


在 HTML5 出现之后,任何标签都可以新增以 data-* 的自定义属性。

请注意上面中的 * 是需要我们自己根据实际情况来自定义的

例如我们给 <span /> 添加一个额外的属性,也就是自定义信息 data-author:

<span id='myspan' data-author='ypx'></span>

上面代码中,我们给 span 增加了一个自定义属性 data-author,我们假设用这个属性来记录作者名字


我们可以通过以下 JS 获取该标签:

document.querySelector('#span')

现在,我们还可以通过查找自定义属性的方式,来获取:

document.getAttribute('data-author')

如果要获取多个拥有该属性的 DOM 元素,我们可以使用:getAttributes() 这个方法


使用 CSS 统一获取并设置样式:

span{
	content:attr(data-author)
}

span[data-author]{
	...
}

甚至直接给所有拥有 data-author 属性的标签统一设置样式

[data-author]{
	...
}

补充说明:

上面讲解的都是我们在 JS 中获取标签的自定义属性,假设要通过 JS 给标签添加自定义属性,还是以 span 为例,具体操作方式为:

第 1 种方式:使用 setAttribute()

span.setAttribute('data-author','xxxx')

第 2 种方式:使用 dataset

span.dataset.author = 'xxxx'

请注意:

  1. dataset 作为该标签的自定义属性统一对象,该标签的所有自定义属性都将挂载在该属性值下面

  2. 我们在去设置自定义属性名时,是无需添加 “data-” 的,例如原本的 data-author 我们只需 dataset.author

  3. 自定义属性名需遵循驼峰命名方式,在上面示例中我们自定义属性为 data-author,去掉不用写的 data-,那剩下的就只有 author,我们可以直接这样写。但是假设我们自定义属性名为 data-author-name,此时去掉不用写的 data- 后,还剩下 author-name,我们就需要遵循驼峰命名方式,实际代码应为:

    span.dataset.authorName = 'xxx'
    

    浏览器会自动将驼峰命名转化为 data-xxx-xxx 赋予给标签


回到我们本文要讲解的内容上面,我们可以将负责 “占位” 的标签都添加上统一的自定义属性,这样在 JS 中可根据该自定义字段来获取所有占位的标签。

这样的做法对于我们来说有一个好处,就是不用再考虑标签究竟使用的是 <div > 还是 <span >


第 2:判断网页中某标签当前是否在可见窗口内,并告知渲染器进行如何裁切渲染

大体思路为:

  1. 在 JS 中获取该标签,假设该标签(DOM 元素)在 js 中的变量引用名为 elem

  2. 通过 elem.getBoundingClientRect() 获取该标签相对于视窗的位置信息

    这些位置信息有:left、right、top、bottom、width、height

  3. 然后进行判断,如果出现以下情况,只要符合一条,那么我们就可以直接认为该标签当前不在可见窗口内。

    bottom < 0
    top > canvas.clientHeight
    right < 0
    left > canvas.clientWidth
    
  4. 假设我们经过判断元素在可见窗口内,那么我们就要告知渲染器可以根据该元素的位置和尺寸,来进行裁剪渲染。

    //让画布的高 - 元素的底部,从而计算出超出的部分,这些部分不必再做渲染了
    const positiveYUpBottom = canvas.clientHeight - bottom
    
    renderer.setScissor(left,positiveYUpBottom,width,height)
    renderer.setViewport(left,positiveYUpBottom,width,height)
    

第 3:添加轨道控制器、将光添加到镜头中,而非场景中

这里讲解一个新的知识点。

在以前所有的示例中,假设我们希望物体有反射光,那么我们都会创建光,并将光添加到场景(Three.Scene)中。

此时我们添加镜头轨道控制器,当移动鼠标修改镜头位置时,光的位置是不变的。

因为我们是将 光 添加到了场景中,所以光的位置是和场景保持固定不变的。


假设我们的场景中有多个物体,每个物体都有自己对应的镜头,我们希望对每个物体的镜头添加轨道控制器,且保证物体对应的光永远跟随着镜头移动,那么我就要将光添加到镜头里。


你没有听错,我再说一遍:将光由原来添加到场景中,修改为添加到镜头中。

-	scene.add(light)
+	camera.add(light)

如此操作之后,光就不再跟随场景,而是跟随着镜头移动而移动。

这样可以保证我们每个物体的镜头中,始终有该物体的光


对于本文示例讲解的场景,不推荐使用 OrbitControls,而是推荐使用 TrackballControls。

TrackballControls 不提供滚动鼠标中轴缩放镜头这个功能,因为在这个示例场景中,滚动鼠标应该出现的是网页的滚动,而不是 Three.js 场景的视角缩放。

请一定记得在每次渲染函数中,要对轨道控制器进行更新:

  1. controls.handleResize()
  2. controls.update()

第 2 种解决方案:通过 web worker 来创建和渲染场景

该方案的优点很明确:

  1. 本身就是对网页性能的一种提升
  2. 由于是 web worker,不再受限于浏览器对 webgl 数量的限制

不过缺点也很明确:

  1. 需要浏览器支持 OffscreenCanvas 才可以

    目前火狐、苹果浏览器均不支持 OffscreenCanvas

  2. 默认 web worker 内部不支持对 DOM 元素交互事件的侦听,也就是说无法添加 轨道控制器

    不过可以通过变相的方式,请参考本系列教程 22 Three.js 优化之 OffscreenCanvas 与 WebWorker.md


第 3 种解决方案:Three.js 渲染的画布不直接显示,让不同位置的标签(画布)去复制该画布的局部结果

由于浏览器并不显示 画布 的数量,我们可以将不同位置的 占位标签 直接使用画布标签,然后让不同的画布去复制渲染出的画布结果内容。

这样做的缺点是:性能不好,速度慢,每个区域都需要进行相应的复制操作。


本文只是阐述了某些特殊场景,例如需要多画布、多场景的情况下的解决方案。

并没有深入、完整编写示例代码。

我个人认出现这种场景的几率并不大,所以就偷懒一下,不去写完整的示例了。


本文此致结束。

下一节,我们将讲解一个非常重要的内容,关乎绝大多数我们编写的 Three.js 程序。

那就是:鼠标选中场景中的物体,并发生交互。

上一页
下一页