mapbox+three.js实现泛光效果

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

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

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

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

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

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

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

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

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

绘制宽度的线我找到了three.js官方提供的另一个插件:Line2

相机同步及坐标转换,感谢伟大的开源社区,我找到了threebox, threebox初始版本开起来已不再维护,可以关注这个fork版本: github.com/jscastro76/...

在mapbox中添加一条three.js线

添加一个自定义图层:

lua 复制代码
 map.addLayer({
    id: 'custom_layer',
    type: 'custom',
    onAdd: function (map, gl) {
    },
    render: function (gl, matrix) {
     
    },
  });

为方便调试,以及避免与mapbox纹理混合时出现的问题,我们先降低难度,使用独立的canvas作为three.js的容器,将three.js的容器尺寸设置与地图容器完全一样,并完美覆盖到地图容器之上。

ini 复制代码
 onAdd: function (map, gl) {
  container = map.getCanvas();
      const w = container.clientWidth;
      const h = container.clientHeight;
      const mapContainer = map.getContainer();
      let bloomContainer = mapContainer.querySelector('#_THREE_EFFECTS_CONTAINER_');
      if (!bloomContainer) {
        bloomContainer = document.createElement('canvas');
        bloomContainer.id = '_THREE_EFFECTS_CONTAINER_';
        bloomContainer.style.position = 'absolute';
        bloomContainer.style.zIndex = '99999';
        bloomContainer.style.pointerEvents = 'none';
        bloomContainer.style.width = '100%';
        bloomContainer.style.height = '100%';
        bloomContainer.width = w;
        bloomContainer.height = h;
        mapContainer.appendChild(bloomContainer);
      }
 }

在onAdd方法中初始化three.js渲染器及相机:

ini 复制代码
renderer = new THREE.WebGLRenderer({
        alpha: true,
        antialias: true,
        canvas: bloomContainer,
      });

renderer.setPixelRatio(window.devicePixelRatio);
renderer.autoClear = false;
camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);

这里需要注意的是要开启alpha通道以便于透明度的设置,并且关闭autoClear。PerspectiveCamera的后两个参数,即near和far的设置,尤其要注意,near可以设置接近无限小,但不能为0,far可以很大,但不能是infinity,否则会影响到事件的处理。事件处理将在后面段落介绍。

另外需要设置清除颜色的透明度,否则three.js场景中的背景会覆盖地图,这个问题可以说是全篇最难的一个点,后面会介绍。

ini 复制代码
renderer.setClearAlpha(0.0);

接下来是相机同步:

arduino 复制代码
 new CameraSync(map, camera, group);

相机同步非常重要,它保证了鼠标交互时three.js相机与mapbox相机观察的范围一致。threebox提供了CameraSync方法,同时提供了坐标转换的工具方法,非常重要,但本篇不作为重点展开,感兴趣的同学可以看下threebox源码。

接下来我们创建three.js线:输入经纬度点位,及自定义样式生成对应的mesh

ini 复制代码
function createLine2(obj) {
  // Geometry
  var straightProject = utils.lnglatsToWorld(obj.geometry);
  var normalized = utils.normalizeVertices(straightProject);
  var flattenedArray = utils.flattenVectors(normalized.vertices);
  var geometry = new LineGeometry();
  geometry.setPositions(flattenedArray);
  // Material
  let matLine = new LineMaterial({
    color: obj.color,
    linewidth: obj.width,
    dashed: false,
    opacity: obj.opacity,
  });

  matLine.resolution.set(obj.containerWidth, obj.containerHeight);
  matLine.isMaterial = true;
  matLine.transparent = true;
  matLine.depthWrite = false;

  // Mesh
  let line = new Line2(geometry, matLine);
  line.position.copy(normalized.position);
  return line;
}

其中重要的两步是lnglatsToWorld将经纬度坐标转换成three.js的世界坐标,以及normalized归一化得到mesh的position信息

line2的写法可以参考three.js官方示例:threejs.org/examples/?q...

将线条添加到场景

less 复制代码
line = createLine2({
    color: 0x00bfff,
    width: 4,
    opacity: 1,
    containerWidth: w,
    containerHeight: h,
});
group.add(line);

至此,我们成功的实现了第一个目标:传入经纬度坐标的数组及样式,生成three.js的线,并将three.js线绘制到mapbox上

实现three.js线的泛光效果

这里建议大家先了解下three.js泛光的基本用法:threejs.org/examples/?q...

通过EffectComposer设置渲染通道:

ini 复制代码
const renderScene = new RenderPass( scene, camera );
const bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), params.strength, params.radius, params.threshold);
const outputPass = new OutputPass();

composer = new EffectComposer( renderer );
composer.addPass( renderScene );
composer.addPass( bloomPass );
composer.addPass( outputPass );

在自定义图层的render方法中更新three.js容器及后处理的渲染

ini 复制代码
composer.render();
renderer.resetState();
renderer.render(scene, camera);

但是我们很快发现,泛光效果的背景是黑色,覆盖了地图

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

ini 复制代码
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);

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

最终发现需要控制alpha的最大值,会有较大改善

ini 复制代码
void main() {
    float weightSum = gaussianCoefficients[0];
    vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
    float alphaSum = 0.0;
    for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
            float x = float(i);
            float w = gaussianCoefficients[i];
            vec2 uvOffset = direction * invSize * x;
            vec4 sample1 = texture2D( colorTexture, vUv + uvOffset );
            vec4 sample2 = texture2D( colorTexture, vUv - uvOffset );
            diffuseSum += (sample1.rgb + sample2.rgb) * w;
            alphaSum += (sample1.a + sample2.a);  // Sum of alpha values
            weightSum += 2.0 * w;
    }

    alphaSum /= weightSum; // Normalize alpha sum
    alphaSum = min(alphaSum, 0.15); //Limit the value of alphaSum
    gl_FragColor = vec4(diffuseSum / weightSum, alphaSum);
}`

即便如此,最终的效果仍然不能让人满意,泛光的区域边界明显,尤其在亮色系地图中,效果较差

不过我们还是基本完成了第二阶段的目标,成功实现了泛光在地图上的叠加。但除了效果不理想外,同时泛光效果是全局的,无法精确的针对单个图形控制泛光效果。 因此我们需要进一步优化

局部泛光,以及效果的进一步优化

单个图形的泛光,可以参照three.js官方提供的另一个例子:threejs.org/examples/?q...

实现的原理实际上是将图形拆分成不同的图层,拾取亮度之前将不需要泛光图层纹理置为黑色,需要泛光的图层提取亮度以及后续的处理,渲染时再将置黑的纹理还原,最后将两者混合。

按照three.js官方示例修改后,最终实现了此前同样的效果。

至于效果的优化,我找到了three.js的issues: github.com/mrdoob/thre...

alpha通道的问题看来是个难题,这个问题2018年就存在了。。在最后有位大佬给出的方案是不修改UnrealBloomPass,而是在shader中将源纹理和目标纹理混合

ini 复制代码
void main() {
  vec4 base_color = texture2D(baseTexture, vUv);
  vec4 bloom_color = texture2D(bloomTexture, vUv);

  float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
  gl_FragColor = vec4(base_color.rgb + bloom_color.rgb, max(base_color.a, lum));
}

这个方案基本解决了透明度的问题,但是在个别机器(我的测试环境中大屏显示器有问题,笔记本屏幕没问题)显示会存在明显的颜色边界

所以我们需要在此基础上,将alpha的取值增加个差值,让颜色平滑过渡

ini 复制代码
void main() {
        vec4 base_color = texture2D(baseTexture, vUv);
        vec4 bloom_color = texture2D(bloomTexture, vUv);
        
        float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
        vec3 blendedColor = base_color.rgb + bloom_color.rgb;
        float alpha = max(base_color.a, lum);

        alpha = mix(alpha, 0.05, 0.1);
        gl_FragColor = vec4(blendedColor, alpha);
    }

主要就是下面这一行,差值和混合因子可以按照不同情况调整

ini 复制代码
alpha = mix(alpha, 0.05, 0.1);

问题解决:

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

实际上现有的方案我们并不完全依赖mapbox的自定义图层,我们只需要map对象就够了...

所以我们继续优化!

将泛光图层与mapbox混合

three.js容器说到底是个canvas,而canvas可以作为wengl的纹理,所以我们将three.js容器内容作为纹理,绘制到mapbox的画布中,再与原纹理混合即可。这也是mapbox自定义图层的基本用法

我们在onAdd 和render方法中加入着色器相关代码

onAdd方法

onAdd方法 复制代码
  const vertexShaderSource = `
  attribute vec2 a_position;
  attribute vec2 a_texCoord;
  uniform vec2 u_resolution;
  varying vec2 v_texCoord;
  void main() {
      vec2 zeroToOne = a_position / u_resolution;
      vec2 zeroToTwo = zeroToOne * 2.0;
      vec2 clipSpace = zeroToTwo - 1.0;
      gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
      v_texCoord = a_texCoord;
  }
`;
const fragmentShaderSource = `
  #ifdef GL_ES
  precision mediump float;
  #endif
  uniform sampler2D u_image;
  varying vec2 v_texCoord;
  void main() {
      gl_FragColor = texture2D(u_image, v_texCoord);
  }
`;

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(vertexShader));
gl.deleteShader(vertexShader);
return;
}

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(fragmentShader));
gl.deleteShader(fragmentShader);
return;
}

program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return;
}

// attrib
positionLocation = gl.getAttribLocation(program, 'a_position');
texcoordLocation = gl.getAttribLocation(program, 'a_texCoord');
resolutionLocation = gl.getUniformLocation(program, 'u_resolution');

// buffer
positionBuffer = gl.createBuffer();
texcoordBuffer = gl.createBuffer();

// texture
texture = gl.createTexture();

render方法

render方法 复制代码
  gl.useProgram(program);

  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  setRectangle(gl, 0, 0, container.width, container.height);

  gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    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]),
    gl.STATIC_DRAW,
  );

  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bloomContainer);

  gl.enableVertexAttribArray(positionLocation);
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

  gl.enableVertexAttribArray(texcoordLocation);
  gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
  gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);

  gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

  gl.drawArrays(gl.TRIANGLES, 0, 6);

泛光效果太淡了

ini 复制代码
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

上面这行是目前的混合方法,我们需要调整下,同时将three.js内的alpha设置为1.0

ini 复制代码
void main() {
    vec4 base_color = texture2D(baseTexture, vUv);
    vec4 bloom_color = texture2D(bloomTexture, vUv);
    vec3 blendedColor = base_color.rgb + bloom_color.rgb;
    gl_FragColor = vec4(blendedColor, 1.0);
}

将blendFunc设置为简单的相加

ini 复制代码
gl.blendFunc(gl.ONE, gl.ONE);

解决

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

添加事件处理

最后我们需要处理下事件,mapbox自定义图层是无法触发图层事件的,而我们的效果在three.js体系中,可以通过raycaster来处理事件

ini 复制代码
  var raycaster = new THREE.Raycaster();
  var mouse = new THREE.Vector2();

  function onMouseClick(event) {
    const w = container.width / window.devicePixelRatio;
    const h = container.height / window.devicePixelRatio;

    mouse.x = (event.clientX / w) * 2 - 1;
    mouse.y = -(event.clientY / h) * 2 + 1;
    raycaster.setFromCamera(mouse, camera);
    var intersects = raycaster.intersectObjects(scene.children, true);
    if (intersects.length > 0) {
      console.log('Object clicked!');
      intersects[0].object.material.color.set(0xff0000);
    }
  }

  window.addEventListener('click', onMouseClick, false);

看起来很简单是吧,从官网抄个demo就行了。但实际上这里我踩了很大一个坑。

首先intersectObjects始终无法命中,返回结果永远是空的,没有任何报错。为此专门看了three.js相关的源码,最后发现是设置相机的参数不正确导致的,也就是文章前面提到过的问题。

一开始我是这样设置的:

arduino 复制代码
new 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

ini 复制代码
camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);

事件问题终于解决。

至此,可应用于实践的案例就算完成了,我们实现了相机同步、坐标系同步、泛光效果、局部泛光控制、mapbox图层层级控制以及事件响应。但泛光对性能是有一定影响的,在实际项目中,还需要进一步封装,如:以单例的形式来维护泛光的容器;拆分动态效果与静态效果,静态效果无需持续刷新等等

感兴趣的同学可以安装源码本地运行, github 源码:github.com/ethan-zf/ma... 如有帮助,欢迎star~

相关推荐
小马哥编程2 小时前
Function.prototype和Object.prototype 的区别
javascript
王小王和他的小伙伴2 小时前
解决 vue3 中 echarts图表在el-dialog中显示问题
javascript·vue.js·echarts
学前端的小朱2 小时前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具
outstanding木槿2 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08213 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
摇光933 小时前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
胡西风_foxww3 小时前
【ES6复习笔记】Class类(15)
javascript·笔记·es6·继承··class·静态成员
布兰妮甜4 小时前
使用 WebRTC 进行实时通信
javascript·webrtc·实时通信
艾斯特_4 小时前
JavaScript甘特图 dhtmlx-gantt
前端·javascript·甘特图
飞翔的渴望4 小时前
react18与react17有哪些区别
前端·javascript·react.js