介绍
最近对公司内部的可视化图层组件做了重大优化,解决了不少问题,涉及到多个场景的合并渲染,后期效果的叠加渲染等等,在这里总结一些经验跟大家分享。
在可视图层的开发过程中必须解决的一个问题就是多个场景(图层)的渲染,比如在地图上显示交通路线、区域范围、兴趣点3个场景,在视觉上是处于同个空间系统,又能够实现分层控制。最容易想到的办法就是为每个场景创建一个Canvas,并将Canvas标签叠加起来。
这样做首先会遇到的问题是图层的数量会受限。浏览器对单个页面虽然不限制Canvas标签的数量,但对运行的WebGL上下文数量是有限制的,例如PC上的chrome支持的上限可能是8个(没有明确说法),手机平板等移动终端会更受限, 超越上限时就会出现类似下面的错误,最先创建的上下文会直接被启用不显示。本文的核心问题就是解决WebGL共享的问题。
WARNING: Too many active WebGL contexts
多个场景合并渲染
实现思路
让多个图层的渲染器共享同一个WebGL上下文,就是把所有的图层内容都渲染到同一个Canvas里,这似乎很简单,只要在实例化WebGLRenderer的时候指定同一个context: gl ,这样在渲染的时候所有renderer最终输出的目标就是同一个。
在这里需要注意的有两点:
- 渲染器的autoClear属性定义了渲染器是否在渲染每一帧之前自动抹除上一次的输出结果,必须设为false,否则你只能看到最后一个渲染器输出的画面
- 需要实现多个渲染器能够共享同个空间深度关系(近大远小视觉遮盖),这里也需要保证构造参数depth为true。
最终我们就能够得到理想的输出结果。
代码实现
由于本文的初衷是为了解决高德地图上多个可视化图层叠加在一个容器显示的问题,在具体实现中使用到CustomLayer,实际上我们需要的是canvas,可以根据具体项目的需要做调整。
jsx
// 1.创建CustomLayer,为最终渲染结果提供容器
customLayer = new AMap.CustomLayer(canvas, {
zooms: [3, 22],
zIndex: 0,
alwaysRender: true
})
const gl = canvas.getContext('webgl')
// 2.逐个创建renderer
const renderer1 = createRenderer()
const renderer2 = createRenderer()
function createRenderer(){
const {innerWidth, innerHeight} = window
const renderer = new THREE.WebGLRenderer({
context:gl,
alpha: true,
depth: true //多个场景共享深度关系
})
// 保证多个场景叠加不会互相清除
renderer.autoClear = false
renderer.setClearAlpha(0)
renderer.setSize(innerWidth, innerHeight)
return renderer
}
// 3.逐个渲染renderer
function animate() {
// 由于是同个坐标系的图层,camera可以是共享的
renderer.render(scene1, camera)
renderer2.render(scene2, camera)
requestAnimationFrame(animate)
}
最终效果如图,这是由两个渲染器叠加渲染的场景,共享同一个空间深度关系。
多个图层后期合并渲染
实现思路
如果你接到的需求只到场景叠加合成这个程度,那么问题到这里就解决了。然而如果又有了新需求,支持给每个图层添加后期效果,也支持图层合并后添加后期效果,事情开始没有那么简单。
这里稍微讲一下EffectComposer的工作原理。
- 创建一个EffectComposer实例,并传入一个渲染器(Renderer)和渲染目标(RenderTarget)。渲染目标可以是屏幕(默认值)或者自定义的帧缓冲对象。
- 通过addPass方法向EffectComposer实例添加一系列的通道(Pass)。通道可以是渲染通道(RenderPass)、着色器通道(ShaderPass)或其他自定义的通道。
- 在渲染循环中,调用EffectComposer的render方法来执行后期处理效果的渲染。EffectComposer会按照添加通道的顺序,依次将渲染结果传递给每个通道进行处理。
- 每个通道在处理渲染结果时,可以应用不同的后期处理效果,如色调映射、模糊、阴影等。通常,通道会使用着色器程序(Shader)来定义定制的渲染效果。
- 最后,EffectComposer将经过所有通道处理的最终渲染结果输出到渲染目标。如果渲染目标是屏幕,则将结果显示在浏览器中;如果是自定义的帧缓冲对象,则可以进一步处理或传递给其他渲染器。
使用EffectComposer效果处理器实现后期效果,多个后期效果处理器直接叠加渲染的话,后者输出到内容会整个覆盖前者,如下图所示,我们只能看到最后一个处理器输出的结果。
对于这个问题我尝试了各种解决思路均不理想,后来终于在一篇关于如何给场景内容做局部效果的文章里发现了答案------把每个图层的效果处理器转为着色通道,实现效果叠加,如下图所示。
实现原理是这样:
- 每个图层对应一个EffectComposer,可以独立添加各种通道Pass最后期效果处理,将处理结果转换为一个着色通道ShaderPass待用;
- 创建一个EffectComposer实例作为最终效果合成器(需要一个空的渲染通道RenderPass垫底),把步骤1产生的ShaderPass逐个添加进来;
- 最终效果合成器还可以增加Pass对所有图层整体加效果(如有必要),在最后增加一个OutputPass通道,调用render将结果渲染到屏幕。
为加深理解我画了一张原理图,执行顺序从左到右,从上到下。
代码实现
- 创建容器CustomLayer,每个图层创建renderer和对应composer
jsx
// 1.创建CustomLayer,为最终渲染结果提供容器
customLayer = new AMap.CustomLayer(canvas, {
zooms: [3, 22],
zIndex: 0,
alwaysRender: true
})
const gl = canvas.getContext('webgl')
// 2.逐个创建renderer
const renderer1 = createRenderer()
const renderer2 = createRenderer()
// 3.创建效果处理器
const composer1 = createComposer(renderer1, scene1)
const composer2 = createComposer(renderer2, scene2)
function createComposer(renderer, scene){
const renderScene = new RenderPass(scene, camera)
renderScene.clearDepth = false
const composer = new EffectComposer(renderer)
composer.renderToScreen = false
composer.addPass(renderScene)
//... 根据需要,添加一些后期效果通道
return composer
}
- 创建最终处理器,把其他处理器结果转为ShaderPass加入,叠加的模式其实可以根据实际需要做定制,比如将叠加的内容进行互相遮盖或者混合。
jsx
// 最终合成处理器
const renderer = this.createRenderer()
const composer = new EffectComposer(renderer)
const renderPass = new RenderPass(new THREE.Scene(), new THREE.Camera())
renderPass.clear = true
composer.addPass(renderPass) // 渲染通道
// 添加其他处理器结果
composer.addPass(createPassShader(composer1))
composer.addPass(createPassShader(composer2))
// 添加最终效果和输出通道
const bloomPass = this.createBloomPass()
composer.addPass(bloomPass)
composer.addPass(this.createOutputPass())
/**
* 创建1个着色通道用于叠加渲染处理器
* @param { EffectComposer} composer
* @param {Object} option
*/
createPassShader (composer, option = { mode: 0 }) {
const { mode } = option
const res = new ShaderPass(new THREE.ShaderMaterial({
uniforms: {
baseTexture: { value: null },
coverTexture: { value: null },
mode: { value: mode } // 0 颜色叠加,1 baseTexture遮盖coverTexture, 2 coverTexture遮盖baseTexture
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: `
uniform sampler2D baseTexture;
uniform sampler2D coverTexture;
uniform int mode;
varying vec2 vUv;
void main() {
vec4 baseColor = texture2D(baseTexture, vUv);
vec4 bloomColor = texture2D(coverTexture, vUv);
if(mode == 0){
gl_FragColor = ( baseColor + vec4( 1.0 ) * bloomColor );
}else if(mode == 1){
gl_FragColor = bloomColor * (1.0 - baseColor.a) + baseColor;
}else if(mode ==2){
gl_FragColor = baseColor * (1.0 - bloomColor.a) + bloomColor;
}
}
`,
defines: {}
}), 'baseTexture')
- 逐帧渲染过程
jsx
animate (time) {
// 各图层仅渲染到缓冲对象
composer1.render()
composer2.render()
// 最终合并渲染
composer.render()
requestAnimationFrame(this.animate)
}
至此可以看到如图所示最终效果,三个图层的内容都加上了后期的辉光效果,但是所有物体在一个画布渲染,所有后期效果在另一个画布渲染。
拓展思考
-
既然每个图层都包含场景(scene),那么让多个场景共享1个渲染器不是更方便,比如下面的代码
jsxrenderer.render(scene1, camera) renderer.render(scene2, camera) renderer.render(scene3, camera)
这样做确实可以减少内存开销,减少代码复杂度。但是目前使用的地图引擎,每个可视化图层是相对独立的,每个图层有独立的renderer、scene、camera可控,减少耦合性,似乎更利于可持续化地开发完善这个项目。
-
高德地图官方提供的GLCustomLayer就已经实现了多个图层共享gl上下文,为什么不用GLCustomLayer。
GLCustomLayer确实实现了文本的核心需求共享gl上下文,它还''贴心''地把地图图层的渲染结果合并进来了,这样做的后果就是,每一帧我要更新画面的时候都必须调用map.render()把地图渲染一遍,这是一个很大的开销,而且会持续占据着cpu的调用。
-
如果进一步优化图层,是否使其能够支持高德地图以外的其他地图呢?
确实如此,写一个新的CustomLayer,实现以下所有能就可以实现适配所有地图引擎了
jsxvar layer = new CustomLayer(canvas, { map, zooms: [2, 18], zIndex: 120, init(gl){ // 初始化后执行 } render() { // 对 canvas 进行绘制 } }) layer.show() layer.hide() layer.setMap() layer.destroy() layer.syncCoords() //同步地图和THREEJS镜头