Three.js 3D地图拓展——省份标注与渐变

1,254 阅读4分钟

Three.js 3D地图拓展——省份标注与渐变

本文进一步探讨了Three.js中3d地图实现,以及主流实现的一些坑点与技巧。完整代码见 github.com/igdswzcd/th… .

image.png

数据获取与处理

可以从阿里云DataV datav.aliyun.com/portal/scho… 获取geoJson数据,但是,通常的教程代码,都会碰到渲染失败的问题,排查发现,除了内蒙古为Polygon,其余地区均为MultiPolygon,后者多了一层数组包装,因此需要对Polygon数据进行预处理。

features.forEach((elem, index) => {
    const province = new THREE.Object3D();
    let coordinates = elem.geometry.coordinates;
    if (elem.geometry.type === "Polygon") {
      coordinates = [coordinates];
    }
    //...
})

省份标注

省份标注一般通过HTML元素对齐到3D对象,需要以下实现:

  1. 绝对定位图层
  2. 文本坐标(世界坐标→窗口偏移量)
  3. 视图变更时更新坐标

此处只讨论2、3。

我们拥有的是省份边界点集的经纬度数据,在常规的平面地图实现中,一般使用墨卡托投影进行映射,指定经纬度中心,输入经纬度,得到Three.js场景中的世界坐标。

import * as d3 from 'd3';
const projection = d3.geoMercator().center([104.0, 36.0]).scale(80).translate([0, 0]);

那么,用什么作为省份标注的锚点呢?省份中心是一个比较好的选择。

检查数据可以发现,geoJson有时也会附赠区域的中心坐标,以阿里云DataV数据为例,存在centercentroid两个属性,前者代表省会坐标,后者代表几何中心。

{"type":"Feature","properties":{"adcode":110000,"name":"北京","center":[116.405285,39.904989],"centroid":[116.41995,40.18994],......

同样观察DataV数据,河北存在多组Polygon,不存在centroid,因此仅对河北使用后者作为标注锚点,其余均使用前者。

将中心经纬度坐标投影,得到了场景世界坐标,还差一步什么呢?我们基于Camera进行观察,但省份标注是HTML元素,并不真的属于世界,因此,我们还需要手动计算世界坐标相对于Camera的位置,再将Camera空间[(-1, -1), (1, 1)]映射到Window空间[(0, innerHeight), (innerWidth, 0)]即可。

// 绘制每个省份时计算_centroid
province.properties = elem.properties;
if (elem.properties.centroid || elem.properties.center) {
    const [x, y] = projection(elem.properties.centroid || elem.properties.center);
    province.properties._centroid = [x, y];
}
mapObj.add(province);

// ...
// mapData在此前附着到场景中,并命名标记
const mapData = scene.children.find((item) => item.name === 'mapData');
// _centroid为预计算的墨卡托投射结果,世界坐标的不变性
const provinces = mapData.children;
if (Array.isArray(provinces)) {
    provinces.forEach(({ properties }) => {
        const { _centroid, name } = properties;
        if (_centroid) {
            const [worldX, worldY] = _centroid;
            // 此处-y: 常见的绘制方式都使用了翻转的y,保持一致
            const vector = new THREE.Vector3(worldX, -worldY, 4);
            const { x: cameraX, y: cameraY } = vector.project(camera);
            const windowX = ((cameraX + 1) / 2) * width;
            const windowY = ((-cameraY + 1) / 2) * height;

            // 添加标注到DOM
            const labelElem = document.createElement('div');
            labelElem.textContent = name;
            labelElem.style.transform = `translate(-50%, -50%) translate(${windowX}px,${windowY}px)`;
            labels.value.appendChild(labelElem);
        }
    });
}

image.png

至于更新时机,逐帧更新最简单流畅,但性能上存在一些隐患,也可以对OrbitControls实例监听change事件,或监听dom上的mousedownmouseup以设置鼠标动作状态进行拦截,不再赘述。

局部渐变

需求中,需要为每个省份单独设置中心渐变,但是,即使使用可自定义Shader的ShaderMaterial,着色公式中使用的uv变量,也仍然是世界坐标,而非局部坐标,因此,常规的渐变总是全局连续的。

但是,拥有完整的uv坐标,也意味着可以计算出其中点与极差,这样,在Shader中,我们就可以计算出局部距离,进而实现局部渐变了。

const pointsX = new Set();
const pointsY = new Set();
for (let i = 0; i < polygon.length; i++) {
    const [x, y] = projection(polygon[i]);
    //...
   	// 收集投射后的uv坐标集
    pointsX.add(x);
    pointsY.add(-y);
}

// 保存在class或uniforms中均可

// 计算中点与极差
function calcMidAndDist(someSet) {
  const sortedArr = Array.from(someSet).sort((a, b) => a - b);
  const max = sortedArr[sortedArr.length - 1];
  const min = sortedArr[0];
  const mid = (max + min) / 2;
  const dist = max - min;
  return [mid, dist];
}

有了局部坐标系,我们就可以在Fragment Shader中进行渐变渲染了,此处简单缩小了color1的比重,使中心颜色范围更小。也可以使用其他的混合函数绘制不同的渐变,好文参考blog.csdn.net/weixin_4302…

image.png

同时,由于使用了自定义的ShaderMaterial,并不能直接修改材质的颜色实现选中效果,因此将hover与否写入uniform,据此进行条件渲染。

const fragmentShader = (
  color1: number[],
  color2: number[],
  hoverColor: number[],
  { midX, midY, disX, disY }: GradUVParams,
) => `
  varying vec2 vUV;
  uniform bool hover;
  void main() {
    if (hover) {
      gl_FragColor = vec4(${hoverColor.join(', ')}, 1.);
      return;
    }
    float t = vUV.y;
    float kx = 1. - abs((vUV.x - ${midX}) * 1.5 / ${disX});
    float ky = 1. - abs((vUV.y - ${midY}) * 1.5 / ${disY});
    float k1 = (kx + ky) / 2.;
    float k2 = 1. - k1;
    vec3 color1 = vec3(${color1.join(', ')});
    vec3 color2 = vec3(${color2.join(', ')});
    vec3 finalColor = color1 * k1 + color2 * k2;
    gl_FragColor = vec4(finalColor, 1.);
  }
`;

线条与锯齿

在官方文档中也写道,由于底层的一些限制,绘制省份边界常见的解决方案,LineBasicMaterial的许多参数都是无效的,例如linewidth,因此,改用addons中的Line2体系,进一步定制线条。

大多数用法都与LineBasicMaterial相同,只有在写入顶点时,不能继承自BufferGeometry的setFromPoints(),而应该使用setPositions(),将原参数扁平化作为连续数组传入即可。

import {
  Line2,
  LineGeometry,
  LineMaterial,
} from 'three/examples/jsm/Addons.js';

//...
lineGeometry.setPositions(points);
//...
const line = new Line2(lineGeometry, lineMaterial);

但是,线条转折较多,该如何让线条变得更平缓呢?可以采取同步分辨率与抗锯齿的策略。

// 开启自带的抗锯齿
renderer = new THREE.WebGLRenderer({ antialias: true });
// 渲染考虑放大倍数
renderer.setPixelRatio(devicePixelRatio);
// FXAA后处理
import {
  RenderPass,
  ShaderPass,
  FXAAShader,
  EffectComposer,
} from 'three/examples/jsm/Addons.js';
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const fxaaPass = new ShaderPass(FXAAShader);
// 超分辨率
fxaaPass.uniforms['resolution'].value.set(
    0.5 / width / devicePixelRatio,
    0.5 / height / devicePixelRatio,
);
composer.addPass(fxaaPass);

现在,线条看上去更柔缓了。

image.png