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 个 webgl,因此我们就需要创建 15 个 webgl,这超出了浏览器对于一个页面上可创建 webgl 数量限制。
我们假设浏览器最多只允许我们创建 8 个 webgl
特别强调:假设我们在一段 JS 代码中创建了 N 个渲染器 或 N 个场景,这并不会创建 N 个 webgl,他们仍然被视为仅仅是 1 个 webgl
你可以简单粗暴得去理解:webgl 的数量仅和画布(canvas)数量有关,和创建几个渲染器或场景无关。
-
问题二:假设每个产品只是模型不同,但是所使用的材质相同,或者多个产品使用同一个纹理贴图,如果我们对每一个产品都创建一套 webgl,那同一个材质或贴图就可能需要被我们反复多次加载。换句话说每一个 Three.js 创建的产品 3D 展示都相互独立(孤立),资源无法共享。
上面我们说 “创建一套 webgl” 的意思是:创建一个 canvas,创建 一个渲染器,创建一个场景 等等
那…解决方案是什么呢?
第 1 种解决方案:用其他标签充当占位,然后使用渲染器的剪裁渲染功能
用 1 个 画布来渲染全部,用一些其他元素标签来 “代替” “充当” N 个画布。
具体的事实细节:
-
创建一个 <canvas > 标签,并设置 z-index:-1,这样该画布就会显示在其他元素的下面
事实上相当于将 画布 当成了 “大背景”
-
在需要展示 “画布” 的位置,我们添加一些网页标签,用来启到 “占位” 的作用。
该标签里并没有实际内容,但是我们通过 CSS 给该标签添加宽和高
-
在 JS 中使用 Three.js,添加不同的灯光和镜头。
一组灯光和镜头 对应一个 需要渲染的对象内容
-
我们 “判断元素当前是否可见”,然后通过渲染器的以下 3 个方法,对渲染器进行 “裁剪”。
-
Renderer.setScissorTest()
该方法接收 1 个参数:boolean,来决定是否启用或禁用裁剪检测。
-
Renderer.setViewport()
该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个矩形的裁剪框。
若此时已启用 剪裁检测,那么只有在该矩形框内的才会被渲染,不在该矩形框内的则不会被渲染。
-
Renderer.setScissor()
该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个视窗(视框)。
-
-
不断判断,不断清空画布内容,已实现实时更新裁剪可见区域。
但是请注意:由于 Three.js 渲染需要一定时间,当网页快速滚动时可能会出现 “渲染不及时”,看上去似乎是一个 “bug”,具体我们会稍后讲解。
-
最终,我们将那些 “占位”标签的位置和尺寸 传递给 Three.js,通过 裁剪,只在渲染出相应内容。
下面我们将针对以上步骤中,一些关键的点进行详细讲解。
第 1:启到占位作用的网页标签
我们知道这些标签本身不需要显示任何内容,我们会通过 CSS 来给他们设定宽高。
那究竟使用什么标签呢?
我们会很容易想到 <div > 、<span > 这些标签都可以。无论使用哪个标签,我们只要确保这些标签统一即可。
我们推荐一种更加优雅、通用、明确的做法:给标签添加 html5 新增的 data-* 属性
data-* 属性介绍:
在传统的网页标签中,例如 <span > 标签,默认它只能有以下几种信息:
- 该标签拥有的 属性和处理事件函数,例如 id、onclick 等
- 该标签的样式,例如 class、style
- 该标签和闭合标签之间的内容
除此之外,该标签无法承载其他信息。
实际上若想还包含其他信息,通常变相的实现手段是将其他信息 包装成 样式名称(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'
请注意:
-
dataset 作为该标签的自定义属性统一对象,该标签的所有自定义属性都将挂载在该属性值下面
-
我们在去设置自定义属性名时,是无需添加 “data-” 的,例如原本的 data-author 我们只需 dataset.author
-
自定义属性名需遵循驼峰命名方式,在上面示例中我们自定义属性为
data-author
,去掉不用写的 data-,那剩下的就只有 author,我们可以直接这样写。但是假设我们自定义属性名为data-author-name
,此时去掉不用写的 data- 后,还剩下 author-name,我们就需要遵循驼峰命名方式,实际代码应为:span.dataset.authorName = 'xxx'
浏览器会自动将驼峰命名转化为 data-xxx-xxx 赋予给标签
回到我们本文要讲解的内容上面,我们可以将负责 “占位” 的标签都添加上统一的自定义属性,这样在 JS 中可根据该自定义字段来获取所有占位的标签。
这样的做法对于我们来说有一个好处,就是不用再考虑标签究竟使用的是 <div > 还是 <span >
第 2:判断网页中某标签当前是否在可见窗口内,并告知渲染器进行如何裁切渲染
大体思路为:
-
在 JS 中获取该标签,假设该标签(DOM 元素)在 js 中的变量引用名为 elem
-
通过 elem.getBoundingClientRect() 获取该标签相对于视窗的位置信息
这些位置信息有:left、right、top、bottom、width、height
-
然后进行判断,如果出现以下情况,只要符合一条,那么我们就可以直接认为该标签当前不在可见窗口内。
bottom < 0 top > canvas.clientHeight right < 0 left > canvas.clientWidth
-
假设我们经过判断元素在可见窗口内,那么我们就要告知渲染器可以根据该元素的位置和尺寸,来进行裁剪渲染。
//让画布的高 - 元素的底部,从而计算出超出的部分,这些部分不必再做渲染了 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 场景的视角缩放。
请一定记得在每次渲染函数中,要对轨道控制器进行更新:
- controls.handleResize()
- controls.update()
第 2 种解决方案:通过 web worker 来创建和渲染场景
该方案的优点很明确:
- 本身就是对网页性能的一种提升
- 由于是 web worker,不再受限于浏览器对 webgl 数量的限制
不过缺点也很明确:
-
需要浏览器支持 OffscreenCanvas 才可以
目前火狐、苹果浏览器均不支持 OffscreenCanvas
-
默认 web worker 内部不支持对 DOM 元素交互事件的侦听,也就是说无法添加 轨道控制器
不过可以通过变相的方式,请参考本系列教程 22 Three.js 优化之 OffscreenCanvas 与 WebWorker.md
第 3 种解决方案:Three.js 渲染的画布不直接显示,让不同位置的标签(画布)去复制该画布的局部结果
由于浏览器并不显示 画布 的数量,我们可以将不同位置的 占位标签 直接使用画布标签,然后让不同的画布去复制渲染出的画布结果内容。
这样做的缺点是:性能不好,速度慢,每个区域都需要进行相应的复制操作。
本文只是阐述了某些特殊场景,例如需要多画布、多场景的情况下的解决方案。
并没有深入、完整编写示例代码。
我个人认出现这种场景的几率并不大,所以就偷懒一下,不去写完整的示例了。
本文此致结束。
下一节,我们将讲解一个非常重要的内容,关乎绝大多数我们编写的 Three.js 程序。
那就是:鼠标选中场景中的物体,并发生交互。