泛光一般用于增强图像或场景的亮度和光线效果,如灯光效果增强,绘制发光的边界线等。在一些游戏引擎以及cesium中泛光是作为一种后处理技术实现的。后处理技术简单理解就是整个画布渲染完成后的二次加工。
本篇将介绍mapbox结合three.js实现泛光效果,先看看最终效果:
bloom.png
泛光的处理流程通常分为以下几步:
bloom.awebp
- 设置亮度阈值。根据阈值提取发光区域
- 模糊发光区域。通过高斯模糊对发光区域处理,为了使效果更加自然,通常会根据不同半径,做多次模糊
- 将模糊后的纹理,叠加到原始图像,完成效果混合
mapbox中并没有直接提供泛光的能力,但其CustomLayerInterface提供了非常灵活的扩展能力,使得mapbox加载three.js成为可能。
three.js官方参照虚幻引擎(Unreal Engine)提供了UnrealBloom后处理器,官方示例:https://threejs.org/examples/?q=bloom#webgl_postprocessing_unreal_bloom。
本篇就是将两者结合起来,在mapbox中扩展泛光的能力
首先我们实现一个发光线效果,先思考下我们即将面临的问题:
- three.js的线只有1像素,而我们需要绘制带宽度的线
- three.js的相机如何与mapbox相机同步
- 如何按照经纬度的方式绘制three.js的线
绘制宽度的线我找到了three.js官方提供的另一个插件:Line2
相机同步及坐标转换,感谢伟大的开源社区,我找到了threebox, threebox初始版本开起来已不再维护,可以关注这个fork版本: https://github.com/jscastro76/threebox
在mapbox中添加一条three.js线
添加一个自定义图层:
1 map.addLayer({
2 id: 'custom_layer',
3 type: 'custom',
4 onAdd: function (map, gl) {
5 },
6 render: function (gl, matrix) {
7
8 },
9 });
为方便调试,以及避免与mapbox纹理混合时出现的问题,我们先降低难度,使用独立的canvas作为three.js的容器,将three.js的容器尺寸设置与地图容器完全一样,并完美覆盖到地图容器之上。
1 onAdd: function (map, gl) {
2 container = map.getCanvas();
3 const w = container.clientWidth;
4 const h = container.clientHeight;
5 const mapContainer = map.getContainer();
6 let bloomContainer = mapContainer.querySelector('#_THREE_EFFECTS_CONTAINER_');
7 if (!bloomContainer) {
8 bloomContainer = document.createElement('canvas');
9 bloomContainer.id = '_THREE_EFFECTS_CONTAINER_';
10 bloomContainer.style.position = 'absolute';
11 bloomContainer.style.zIndex = '99999';
12 bloomContainer.style.pointerEvents = 'none';
13 bloomContainer.style.width = '100%';
14 bloomContainer.style.height = '100%';
15 bloomContainer.width = w;
16 bloomContainer.height = h;
17 mapContainer.appendChild(bloomContainer);
18 }
19 }
在onAdd方法中初始化three.js渲染器及相机:
1renderer = new THREE.WebGLRenderer({
2 alpha: true,
3 antialias: true,
4 canvas: bloomContainer,
5 });
6
7renderer.setPixelRatio(window.devicePixelRatio);
8renderer.autoClear = false;
9camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);
这里需要注意的是要开启alpha通道以便于透明度的设置,并且关闭autoClear。PerspectiveCamera的后两个参数,即near和far的设置,尤其要注意,near可以设置接近无限小,但不能为0,far可以很大,但不能是infinity,否则会影响到事件的处理。事件处理将在后面段落介绍。
另外需要设置清除颜色的透明度,否则three.js场景中的背景会覆盖地图,这个问题可以说是全篇最难的一个点,后面会介绍。
1renderer.setClearAlpha(0.0);
接下来是相机同步:
1 new CameraSync(map, camera, group);
相机同步非常重要,它保证了鼠标交互时three.js相机与mapbox相机观察的范围一致。threebox提供了CameraSync方法,同时提供了坐标转换的工具方法,非常重要,但本篇不作为重点展开,感兴趣的同学可以看下threebox源码。
接下来我们创建three.js线:输入经纬度点位,及自定义样式生成对应的mesh
1function createLine2(obj) {
2 // Geometry
3 var straightProject = utils.lnglatsToWorld(obj.geometry);
4 var normalized = utils.normalizeVertices(straightProject);
5 var flattenedArray = utils.flattenVectors(normalized.vertices);
6 var geometry = new LineGeometry();
7 geometry.setPositions(flattenedArray);
8 // Material
9 let matLine = new LineMaterial({
10 color: obj.color,
11 linewidth: obj.width,
12 dashed: false,
13 opacity: obj.opacity,
14 });
15
16 matLine.resolution.set(obj.containerWidth, obj.containerHeight);
17 matLine.isMaterial = true;
18 matLine.transparent = true;
19 matLine.depthWrite = false;
20
21 // Mesh
22 let line = new Line2(geometry, matLine);
23 line.position.copy(normalized.position);
24 return line;
25}
其中重要的两步是lnglatsToWorld将经纬度坐标转换成three.js的世界坐标,以及normalized归一化得到mesh的position信息
line2的写法可以参考three.js官方示例:https://threejs.org/examples/?q=line#webgl_lines_fat
将线条添加到场景
1line = createLine2({
2 color: 0x00bfff,
3 width: 4,
4 opacity: 1,
5 containerWidth: w,
6 containerHeight: h,
7});
8group.add(line);
至此,我们成功的实现了第一个目标:传入经纬度坐标的数组及样式,生成three.js的线,并将three.js线绘制到mapbox上
line2.png
实现three.js线的泛光效果
这里建议大家先了解下three.js泛光的基本用法:https://threejs.org/examples/?q=bloom#webgl_postprocessing_unreal_bloom
通过EffectComposer设置渲染通道:
1const renderScene = new RenderPass( scene, camera );
2const bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), params.strength, params.radius, params.threshold);
3const outputPass = new OutputPass();
4
5composer = new EffectComposer( renderer );
6composer.addPass( renderScene );
7composer.addPass( bloomPass );
8composer.addPass( outputPass );
在自定义图层的render方法中更新three.js容器及后处理的渲染
1composer.render();
2renderer.resetState();
3renderer.render(scene, camera);
但是我们很快发现,泛光效果的背景是黑色,覆盖了地图
image.png
分析了泛光相关的源码发现UnrealBloomPass.js中发现了这行代码
1gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
着色器alpha 通道始终为1,参照一些其他资料,推荐的方式是取样时除颜色外,也将拾取alpha值,并按照权重输出,但效果看起来并不理想
image.png
最终发现需要控制alpha的最大值,会有较大改善
1void main() {
2 float weightSum = gaussianCoefficients[0];
3 vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
4 float alphaSum = 0.0;
5 for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
6 float x = float(i);
7 float w = gaussianCoefficients[i];
8 vec2 uvOffset = direction * invSize * x;
9 vec4 sample1 = texture2D( colorTexture, vUv + uvOffset );
10 vec4 sample2 = texture2D( colorTexture, vUv - uvOffset );
11 diffuseSum += (sample1.rgb + sample2.rgb) * w;
12 alphaSum += (sample1.a + sample2.a); // Sum of alpha values
13 weightSum += 2.0 * w;
14 }
15
16 alphaSum /= weightSum; // Normalize alpha sum
17 alphaSum = min(alphaSum, 0.15); //Limit the value of alphaSum
18 gl_FragColor = vec4(diffuseSum / weightSum, alphaSum);
19}`
image.png
即便如此,最终的效果仍然不能让人满意,泛光的区域边界明显,尤其在亮色系地图中,效果较差
不过我们还是基本完成了第二阶段的目标,成功实现了泛光在地图上的叠加。但除了效果不理想外,同时泛光效果是全局的,无法精确的针对单个图形控制泛光效果。
因此我们需要进一步优化
局部泛光,以及效果的进一步优化
单个图形的泛光,可以参照three.js官方提供的另一个例子:https://threejs.org/examples/?q=bloom#webgl_postprocessing_unreal_bloom_selective
实现的原理实际上是将图形拆分成不同的图层,拾取亮度之前将不需要泛光图层纹理置为黑色,需要泛光的图层提取亮度以及后续的处理,渲染时再将置黑的纹理还原,最后将两者混合。
按照three.js官方示例修改后,最终实现了此前同样的效果。
至于效果的优化,我找到了three.js的issues: https://github.com/mrdoob/three.js/issues/14104
alpha通道的问题看来是个难题,这个问题2018年就存在了。。在最后有位大佬给出的方案是不修改UnrealBloomPass,而是在shader中将源纹理和目标纹理混合
1void main() {
2 vec4 base_color = texture2D(baseTexture, vUv);
3 vec4 bloom_color = texture2D(bloomTexture, vUv);
4
5 float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
6 gl_FragColor = vec4(base_color.rgb + bloom_color.rgb, max(base_color.a, lum));
7}
这个方案基本解决了透明度的问题,但是在个别机器(我的测试环境中大屏显示器有问题,笔记本屏幕没问题)显示会存在明显的颜色边界
image.png
所以我们需要在此基础上,将alpha的取值增加个差值,让颜色平滑过渡
1void main() {
2 vec4 base_color = texture2D(baseTexture, vUv);
3 vec4 bloom_color = texture2D(bloomTexture, vUv);
4
5 float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
6 vec3 blendedColor = base_color.rgb + bloom_color.rgb;
7 float alpha = max(base_color.a, lum);
8
9 alpha = mix(alpha, 0.05, 0.1);
10 gl_FragColor = vec4(blendedColor, alpha);
11 }
主要就是下面这一行,差值和混合因子可以按照不同情况调整
1alpha = mix(alpha, 0.05, 0.1);
问题解决:
image.png
到了这一步效果基本达到要求,可这个方案毕竟是将一个three.js的画布盖在了mapbox上面,终究会出现图层遮挡问题,也就是说mapbox图层无法覆盖泛光的图层。
image.png
实际上现有的方案我们并不完全依赖mapbox的自定义图层,我们只需要map对象就够了…
所以我们继续优化!
将泛光图层与mapbox混合
three.js容器说到底是个canvas,而canvas可以作为wengl的纹理,所以我们将three.js容器内容作为纹理,绘制到mapbox的画布中,再与原纹理混合即可。这也是mapbox自定义图层的基本用法
我们在onAdd 和render方法中加入着色器相关代码
onAdd方法
1 const vertexShaderSource = `
2 attribute vec2 a_position;
3 attribute vec2 a_texCoord;
4 uniform vec2 u_resolution;
5 varying vec2 v_texCoord;
6 void main() {
7 vec2 zeroToOne = a_position / u_resolution;
8 vec2 zeroToTwo = zeroToOne * 2.0;
9 vec2 clipSpace = zeroToTwo - 1.0;
10 gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
11 v_texCoord = a_texCoord;
12 }
13`;
14const fragmentShaderSource = `
15 #ifdef GL_ES
16 precision mediump float;
17 #endif
18 uniform sampler2D u_image;
19 varying vec2 v_texCoord;
20 void main() {
21 gl_FragColor = texture2D(u_image, v_texCoord);
22 }
23`;
24
25const vertexShader = gl.createShader(gl.VERTEX_SHADER);
26gl.shaderSource(vertexShader, vertexShaderSource);
27gl.compileShader(vertexShader);
28if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
29console.error(gl.getShaderInfoLog(vertexShader));
30gl.deleteShader(vertexShader);
31return;
32}
33
34const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
35gl.shaderSource(fragmentShader, fragmentShaderSource);
36gl.compileShader(fragmentShader);
37if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
38console.error(gl.getShaderInfoLog(fragmentShader));
39gl.deleteShader(fragmentShader);
40return;
41}
42
43program = gl.createProgram();
44gl.attachShader(program, vertexShader);
45gl.attachShader(program, fragmentShader);
46gl.linkProgram(program);
47if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
48console.error(gl.getProgramInfoLog(program));
49gl.deleteProgram(program);
50return;
51}
52
53// attrib
54positionLocation = gl.getAttribLocation(program, 'a_position');
55texcoordLocation = gl.getAttribLocation(program, 'a_texCoord');
56resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
57
58// buffer
59positionBuffer = gl.createBuffer();
60texcoordBuffer = gl.createBuffer();
61
62// texture
63texture = gl.createTexture();
render方法
1 gl.useProgram(program);
2
3 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
4 setRectangle(gl, 0, 0, container.width, container.height);
5
6 gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
7 gl.bufferData(
8 gl.ARRAY_BUFFER,
9 new Float32Array([0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]),
10 gl.STATIC_DRAW,
11 );
12
13 gl.bindTexture(gl.TEXTURE_2D, texture);
14 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
15 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
16 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
17 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
18 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bloomContainer);
19
20 gl.enableVertexAttribArray(positionLocation);
21 gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
22 gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
23
24 gl.enableVertexAttribArray(texcoordLocation);
25 gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
26 gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);
27
28 gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
29 gl.enable(gl.BLEND);
30 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
31
32 gl.drawArrays(gl.TRIANGLES, 0, 6);
image.png
泛光效果太淡了
1gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
上面这行是目前的混合方法,我们需要调整下,同时将three.js内的alpha设置为1.0
1void main() {
2 vec4 base_color = texture2D(baseTexture, vUv);
3 vec4 bloom_color = texture2D(bloomTexture, vUv);
4 vec3 blendedColor = base_color.rgb + bloom_color.rgb;
5 gl_FragColor = vec4(blendedColor, 1.0);
6}
将blendFunc设置为简单的相加
1gl.blendFunc(gl.ONE, gl.ONE);
解决
image.png
测试下图层顺序,mapbox画一条红色的线,可以覆盖到泛光图形之上,说明与mapbox图层混合成功
image.png
添加事件处理
最后我们需要处理下事件,mapbox自定义图层是无法触发图层事件的,而我们的效果在three.js体系中,可以通过raycaster来处理事件
1 var raycaster = new THREE.Raycaster();
2 var mouse = new THREE.Vector2();
3
4 function onMouseClick(event) {
5 const w = container.width / window.devicePixelRatio;
6 const h = container.height / window.devicePixelRatio;
7
8 mouse.x = (event.clientX / w) * 2 - 1;
9 mouse.y = -(event.clientY / h) * 2 + 1;
10 raycaster.setFromCamera(mouse, camera);
11 var intersects = raycaster.intersectObjects(scene.children, true);
12 if (intersects.length > 0) {
13 console.log('Object clicked!');
14 intersects[0].object.material.color.set(0xff0000);
15 }
16 }
17
18 window.addEventListener('click', onMouseClick, false);
看起来很简单是吧,从官网抄个demo就行了。但实际上这里我踩了很大一个坑。
首先intersectObjects始终无法命中,返回结果永远是空的,没有任何报错。为此专门看了three.js相关的源码,最后发现是设置相机的参数不正确导致的,也就是文章前面提到过的问题。
一开始我是这样设置的:
1new THREE.PerspectiveCamera(28, container.innerWidth / container.innerHeight,0.000000000001, Infinity);
除了事件似乎没发现什么问题,但调试发现将 PerspectiveCamera 的 near 参数设置为 0,或将 far 参数设置为Infinity,将导致 camera.projectionMatrix 和 camera.projectionMatrixInverse 中出现 NaN 值,使 raycaster 中的 ray 的 direction 属性变为 NaN,最终导致 intersectObjects 无法成功命中对象。
问题解决了吗?
点击事件似乎可以了,但是只有部分区域可以响应…
再仔细看,fov设置为28,而为了与mapbox完全同步,我们应直接使用mapbox相机的fov
1camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);
事件问题终于解决。
至此,可应用于实践的案例就算完成了,我们实现了相机同步、坐标系同步、泛光效果、局部泛光控制、mapbox图层层级控制以及事件响应。但泛光对性能是有一定影响的,在实际项目中,还需要进一步封装,如:以单例的形式来维护泛光的容器;拆分动态效果与静态效果,静态效果无需持续刷新等等
感兴趣的同学可以安装源码本地运行
github 源码:https://github.com/ethan-zf/mapbox-bloom-effect-sample
如有帮助,欢迎star~