mapbox+three.js实现泛光效果

泛光一般用于增强图像或场景的亮度和光线效果,如灯光效果增强,绘制发光的边界线等。在一些游戏引擎以及cesium中泛光是作为一种后处理技术实现的。后处理技术简单理解就是整个画布渲染完成后的二次加工。

本篇将介绍mapbox结合three.js实现泛光效果,先看看最终效果:

bloom.png

bloom.png

泛光的处理流程通常分为以下几步:

bloom.awebp

bloom.awebp

  1. 设置亮度阈值。根据阈值提取发光区域
  2. 模糊发光区域。通过高斯模糊对发光区域处理,为了使效果更加自然,通常会根据不同半径,做多次模糊
  3. 将模糊后的纹理,叠加到原始图像,完成效果混合

mapbox中并没有直接提供泛光的能力,但其CustomLayerInterface提供了非常灵活的扩展能力,使得mapbox加载three.js成为可能。

three.js官方参照虚幻引擎(Unreal Engine)提供了UnrealBloom后处理器,官方示例:https://threejs.org/examples/?q=bloom#webgl_postprocessing_unreal_bloom。

本篇就是将两者结合起来,在mapbox中扩展泛光的能力

首先我们实现一个发光线效果,先思考下我们即将面临的问题:

  1. three.js的线只有1像素,而我们需要绘制带宽度的线
  2. three.js的相机如何与mapbox相机同步
  3. 如何按照经纬度的方式绘制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

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

image.png

分析了泛光相关的源码发现UnrealBloomPass.js中发现了这行代码

1gl_FragColor = vec4(diffuseSum/weightSum, 1.0);

着色器alpha 通道始终为1,参照一些其他资料,推荐的方式是取样时除颜色外,也将拾取alpha值,并按照权重输出,但效果看起来并不理想

image.png

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

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

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

image.png

到了这一步效果基本达到要求,可这个方案毕竟是将一个three.js的画布盖在了mapbox上面,终究会出现图层遮挡问题,也就是说mapbox图层无法覆盖泛光的图层。

image.png

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

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

image.png

测试下图层顺序,mapbox画一条红色的线,可以覆盖到泛光图形之上,说明与mapbox图层混合成功

image.png

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~

  • 17
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MapboxThree.js 是两个独立的开源项目,可以结合使用来创建交互式地图和可视化效果Mapbox 是一个提供地图数据和地图服务的平台,而 Three.js 是一个基于 WebGL 的 3D 图形库。 结合 MapboxThree.js,你可以在地图上创建各种令人惊叹的视觉效果,例如在地图上展示 3D 模型、动态地图标记或者添加自定义的动画效果。 以下是一个简单的示例,展示如何在 Mapbox 地图上使用 Three.js 创建一个旋转的立方体: 1. 首先,引入 MapboxThree.js 的库文件,并创建一个容器来放置地图: ```html <div id="map"></div> ``` 2. 然后,在 JavaScript 中初始化地图,获取地图容器的大小,并创建一个 Three.js 场景: ```javascript mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN'; var map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/streets-v11', center: [0, 0], zoom: 1 }); var scene = new THREE.Scene(); ``` 3. 接下来,在场景中创建一个旋转的立方体: ```javascript var geometry = new THREE.BoxGeometry(1, 1, 1); var material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); var cube = new THREE.Mesh(geometry, material); scene.add(cube); function animate() { requestAnimationFrame(animate); cube.rotation.x += 0.01; cube.rotation.y += 0.01; // 渲染 Three.js 场景 renderer.render(scene, camera); } animate(); ``` 4. 最后,将 Three.js 的渲染结果嵌入到 Mapbox 地图中: ```javascript var renderer = new THREE.WebGLRenderer({ alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); var camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z = 5; function render() { requestAnimationFrame(render); // 将 Mapbox 地图渲染到 Three.js 的纹理上 renderer.autoClear = false; renderer.clear(); renderer.render(scene, camera); // 将 Three.js 的渲染结果放置在地图上 map.triggerRepaint(); } map.on('load', function() { map.on('render', render); }); ``` 通过结合 MapboxThree.js,你可以根据自己的需求创建更复杂的交互式地图和可视化效果。希望这个例子能帮助到你!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值