手摸手带你写3d地图(three.js)

5,701 阅读22分钟

本文章主要是实现一个具有波纹动画、地图下钻功能的3d地区域地图,废话不多说直接开始

创建场景并渲染出来


// 场景,用来存放物体的地方
const scene = new THREE.Scene();

// 透视相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

const renderer = new THREE.WebGLRenderer(); // webgl渲染器

renderer.render(scene, camera);

document.body.appendChild(renderer.domElement);

代码的理解:

  • 假设我们在拍一部电影,拍摄的时候我们首先需要选定一个场地(scene),未来的演员和演出道具将在这里展示(Object:物体,比如一个立方体,在本篇文章中就是地图)
  • 拍电影肯定是需要摄像机的(这里我们使用的是透视摄像机PerspectiveCamera),这样我们才能记录下内容,而在拍摄的过程中我们可以调整拍摄的范围大小,确保我们拍摄的场景有多大,这就是PerspectiveCamera类的第一个参数(fov),是一个角度,这里定义的是75,我们也可以定义相机的拍摄内容的宽高比相机的第二个参数aspect,在拍摄的时候我们可以规定太近、太远的可以不展示,也就是相机的第三个参数near,小于这个距离的我们不展示,第四个参数far大于这个距离的不展示,为了方便理解,这里贴一张图

image.png

  • 现在假设电影的内容拍摄完成要上映了,想要播放电影我们肯定需要观看设备,就比如手机播放器,我们只需要把资源加载过来就可以看了,这个播放器就是renderer渲染器,这里用的是WebGLRenderer,通过这个我们就可以将相机拍摄的内容给渲染出来
  • 一切就绪,我们只需要将手机放在一个合适的位置观看就可以了(将renderer.domElement放在body中

通过上面的步骤我们实现了一个场景的渲染,但目前我们并没有在场景中添加内容,所以看到的是一个黑色的背景

向场景中添加物体

  1. 首先呢我们先在场景中添加一个坐标系,方便观看坐标情况,用的是THREE.AxesHelper构造函数
 ....
 // 坐标系轴线
 const axis = new THREE.AxesHelper();
 // 将坐标轴添加到场景中
 scene.add(axis)
 ....

我们会发现我们在页面上仍然看不到内容,这是因为相机默认的位置是在(0,0,0)位置,而坐标轴的远点就是在这个位置,因此看不到,为了方便看到,我们调整一下相机的位置

 ....
    // 调整位置,这里的position是一个三维向量(Vector3),
    // 可以通过分别给position.x、position.y、 position.z赋值,也可以通过以下方式设置
    camera.position.set(0, 0, 2);
 ....

这时候页面上就有内容显示了,如下

image.png

红色表示x轴,绿色代表y轴,蓝色代表z轴,由于我们相机设置的位置在z轴上,所以看不到蓝色的线

  1. 我们会发现虽然画面存在了,但是和我们想要的并不一样,我们无法通过鼠标拖拽来移动视角,那么接下来,我们就来实现这个功能,来添加控制,这里用到的是OrbitControls这是个轨道控制器,可以让我们的相机camera围绕目标进行进行轨道运行
   ....
   // 第一个参数是相机
   // 第二个参数是用于监听事件的Html元素
   new OrbitControls(camera, renderer.domElement);
   ....

好,到目前为止我们已经添加完成控制功能了,但是我们会发现,我们在页面上进行拖动结果并没有像我们预期的一样转动视角,这是为什么呢?答案很简单,我们虽然在鼠标拖动的时候改变的camera的位置,但是我们并没有再次渲染场景,导致我们看到的始终是最初的视角,我们需要做的就是在camera的位置发生变化后进行渲染,为了实现这一目的我们在这里使用了requestAnimationFrame,这个我就不多解释了,不清楚的话就自己搜一下吧,实现如下


// 我们将上面的renderer.render(scene, camera);放到animate的函数中
- renderer.render(scene, camera)

+const animate = (callback) => {
+  if (callback && typeof callback === "function") callback();
+  requestAnimationFrame(() => {
+    animate(callback);
+  });
+  reRender();
+};

好了,我们现在再次拖动页面就会发现视角发生变化了,如下

track.gif

到这里,我们最基本的准备已经完成了,接下来正式进入正题

获取地图geoJson数据

对于地图的获取我们可以通过阿里云可视化平台获取,这里我们自己封装一个方法网络请求获取

/**
 * @description 获取地图数据
 * @param adcode 地区code
 * @param isFull 是否包含子区域
 */
const getGeoJson = async (adcode, isFull = true) => {
  const response = await fetch(
    `https://geo.datav.aliyun.com/areas_v3/bound/geojson?code=${adcode}${
      isFull ? "_full" : ""
    }`,
    {
      method: "GET",
    }
  );

  return response.json();
};

绘制地图前的准备

一、数据处理步骤

  1. 获取所绘制地图的最大和最小经纬度信息(即地图的四边)。
  2. 根据地图的四边计算地图的中心点坐标,目的是方便后期经纬度转墨卡托使用(下面介绍),以及将整个地图移动到场景中心。

这里解释下为什么要将地图移动到场景中心:我们绘制的地图的经纬度信息可能不是围绕场景中心的,这就导致地图可能距离中心很遥远,当我们相机设置的位置不是很合理的情况下,我们可能会发现绘制完成后根本看不到绘制的内容,这并不是我们想要的结果。

  1. 处理经纬度信息,将经纬度坐标转化为墨卡托投影坐标。众所周知,地球是一个球体,每一个经纬度坐标并不在同一平面上,而我们在绘制3d地图的时候是将他们放在同一个平面上的,因此geoJson得到的经纬度信息我们无法直接使用,为此我们需要使用墨卡托投影对经纬度信息进行处理,使得他们能够在同一平面展示

二、根据上述步骤进行数据处理代码实现

  1. 定义一个变量mapSideInfo用于存储所绘制地图的最大、最小经纬度信息;centerPos用于存储中心点坐标信息
// 用于计算整个图形的中心位置
const mapSideInfo = {
  minlon: Infinity,
  maxlon: -Infinity,
  minlat: Infinity,
  maxlat: -Infinity,
};

// 中心坐标,用于到时候将图形绘制到坐标系原点计算使用
let centerPos = {};
  1. 包装一个根据geoJson格式数据获取经纬度坐标集合的方法,该方法在计算中心点和绘制地图的时候都有用到,因此我在这里将其单独实现
/**
 * @description 处理坐标循环
 * @param {*} geometry
 * @param {*} callback 回调函数返回的是经经纬度数组的集合
 */
const dealWithCoord = (geometry, callback) => {
  const { type, coordinates } = geometry;

  // 多面处理
  if (type === "MultiPolygon") {
    coordinates.forEach((polyArray) => {
      polyArray.forEach((lonlatArr) => {
        callback(lonlatArr);
      });
    });
  } else
    coordinates.forEach((lonlatArr) => {
      callback(lonlatArr);
    });
};

  1. 包装获取最大最小经纬度信息以及中心点坐标信息的方法,传递的参数就是获取到的geoJson数据
/**
 * @description 计算中心位置、四边位置,请务必在调用drawMap之前调用
 * @param {*} geoJson 请求获取到的geoJson数据
 */
const calcSide = (geoJson) => {
  const { features } = geoJson;

  features.forEach((feature) => {
    dealWithCoord(feature.geometry, (lonlatArr) => {
      lonlatArr.forEach(([lon, lat]) => {
        if (lon > mapSideInfo.maxlon) mapSideInfo.maxlon = lon;
        if (lon < mapSideInfo.minlon) mapSideInfo.minlon = lon;
        if (lat > mapSideInfo.maxlat) mapSideInfo.maxlat = lat;
        if (lat < mapSideInfo.minlat) mapSideInfo.minlat = lat;
      });
    });
  });

  centerPos = {
    x: (mapSideInfo.maxlon + mapSideInfo.minlon) / 2,
    y: (mapSideInfo.maxlat + mapSideInfo.minlat) / 2,
  };

};
  1. 接下来我们处理经纬度信息转化为墨卡托投影坐标,这里我们用了d3-geo这个库中的geoMercator方法,它返回一个GeoProjection对象,我们可以直接调用d3geo.geoMercator()([lon,lat])获取墨卡托投影坐标我们这里介绍下本文中用到该对象的几个方法:

    • center([lon,lat]):该方法的作用是设置投影中心,默认是在(0°,0°),你可以把它理解成地球经纬度的(0°,0°)位置,该方法返回的是GeoProjection对象自身
    • translate([x,y]):你可以理解为将投影中心移动到坐标系的[x,y]点。该方法仍然返回的是GeoProjection对象自身

根据上面方法的介绍,现在我们想要将经纬度坐标转化为以场景中心为投影中心的坐标我们只需要将geojson获取的经纬度信息传入d3geo.geoMercator().center([centerPos.x, centerPos.y]).translate([0, 0])返回的方法即可,现在我们代码实现:

centerPos的下面再定义一个变量merTrans,用于接收墨卡托投影转换方法

// 墨卡托投影转换方法
let merTrans;

在计算最大最小经纬度和中心坐标的地方赋值该方法

/**
 * @description 计算中心位置、四边位置,请务必在调用drawMap之前调用
 * @param {*} geoJson 请求获取到的geoJson数据
 */
const calcSide = (geoJson) => {
    ....
    merTrans = d3geo.geoMercator().center([centerPos.x, centerPos.y]).translate([0, 0]);

};

这样在后续绘制图形坐标转换的时候只需要使用merTrans函数即可

地图绘制

在上面的步骤周我们已经完成了数据的处理和场景的展示,现在我们只需要创建物体并将物体添加到场景中就可以了。

概念的理解

好,现在我们想象一下我们要建设一座桥,一座桥的诞生不可能是凭空而出的,在我们的心中一定有了构想,进而构想体现在设计稿上面,这个设计稿上标注的有所要建桥的一些几何信息,当我们开始建造的时候肯定还要选择构建的材料,就比如用什么石头啊,涂什么颜色啊等等,这些是和材料有关系的,当这些都做完的时候我们就需要建造师傅结合设计稿的几何信息材料来完成桥梁的搭建,从而展示在我们面前。

概念的结合

three创造物体就和构建桥梁一样,首先需要设计稿(Geometry)来确定物体的点面信息,同时还需要确定构建物体的材料(Material),最后让师傅(Object)将两者结合起来最终构建成我们想要的物体

开始实战

第一步

首先我们已知了地图的经纬度点信息,那么我们就需要将这几点组合连接起来构建成一个面,我们用到了Shape该类能够使用路径来定义一个二维平面,如果你对canvas比较熟悉,那么你一定知道getContext('2d')moveTolineTo,对的,没错,Shape也存在这两个方法,且你可以把它们划等号,好了记下来我们就用它们来写一个绘制二维平面的方法:

/**
 * @description 根据点绘制二维形状平面
 * @param {*} lonlatArr  经纬度坐标数组集合
 */
const drawPlan = (lonlatArr) => {
  // 可以理解为canvas的绘制形状,moveTo、lineTo
  const shap = new THREE.Shape();

  lonlatArr.forEach((lonlat, index) => {
    const [x, y] = merTrans(lonlat);
    if (!index) shap.moveTo(x, y);
    else shap.lineTo(x, y);
  });

  return shap;
};

该函数传递的参数就是一个地图板块的所有经纬度信息,截个图方便理解一下吧(经纬度数组组成的数组)

image.png

第二步

现在我们有了地图轮廓的平面设计稿,但我们要的是立体的是具有厚度的地图呀,好接下来有请ExtrudeGeometry(挤压缓冲集合体),为了方便理解这个Geometry,我们直接上图

image.png

好吃不好吃我也不知道,网上找的,也不要问我为什么有马赛克,回归正题,我们会发现每个面包片其实长的都一样对吧,一长条的面包其实都是由相同的一块面包堆叠起来形成的。

有了上边面包的解释我想理解ExtrudeGeometry就容易多了,我们可以把通过Shape构建出来的地图二维平面比较面包片,然后这样相同的面堆叠了起来,从而构建成了一个几何体,他的第一个参数就是一个Shape平面或者Shape平面数组,第二个参数是配置参数,来配置这个堆叠形成的几何体的一些 “样貌”,这里我们解释几个参数,其余的参数想要了解可以去官网查看喽

  1. steps:你可以可理解为在相同堆叠长度面包的情况下,这个面包被分成了多少片,数值越大,片数越多
  2. depth:可以理解为面包的堆叠长度
  3. bevelEnabled:可以理解为这个长条面包两端的边是否有倒角,直接上图,红色线明显是有弧度的,对就这个意思

image.png

有了这些知识我们开始构建几何体(设计稿)

    const shap = drawPlan(lonlatArr);
      // 几何体
    const geo = new THREE.ExtrudeGeometry(shap, {
    bevelEnabled: false,
    });

选择材料,我们在这里选择的MeshBasicMaterial网络基础材质,在不配置的情况下默认材质的颜色是白色的,我们顺便写一个随机生成颜色的方法吧

// 获取随机颜色
const randomColor = () =>
  `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
    Math.random() * 255
  )},${Math.floor(Math.random() * 255)})`;
  
// --------------------------------------------------------------------------------------  
 // 材质
  const material = new THREE.MeshBasicMaterial({
    color: randomColor(),
  });

建造师结合设计稿和材料建造,这里使用的Mesh,两个参数分别是设计稿信息和材料,返回的就是构建成的物体

const mesh = new THREE.Mesh(geo, material);

最后我们只需要将物体添加到场景Scene中即可

这里我举例绘制的是河南的地图板块,其中包含很多市区,我们在获取geoJsonfeatures包含的就是各个市区的信息,绘制河南地图,其实就是分别将河南的各个市区绘制出来,从而组合形成了河南地图板块,知道了这个,我们可以根据上面的描述包装一个绘制方法如下

/**
 * @description 根据某地区经纬度信息绘制地图板块
 * @param {*} geometry
 * @returns
 */
const drawMap = (geometry) =>
  new Promise((resolve) => {
    const meshArray = [];

    dealWithCoord(geometry, (lonlatArr) => {
      const shap = drawPlan(lonlatArr);
      // 几何体
      const geo = new THREE.ExtrudeGeometry(shap, {
        bevelEnabled: false,
      });
      // 材质
      const material = new THREE.MeshBasicMaterial({
        color: randomColor(),
      });
      // 物体
      const mesh = new THREE.Mesh(geo, material);
      meshArray.push(mesh);
    });

    resolve(meshArray);
  });

我们先调用该方法绘制一个示例市区,代码如下

const meshArr = await drawMap(geoJson.features[0].geometry);
scene.add(...meshArr);

如图,颜色是随机的

example.gif

其他的市区按照这样的方法进行渲染就可以了,代码我就不贴了,直接上图

com.gif

图虽然出来了,但我们会发现图太大了,我们还需要滚动滚轮进行缩放才能看到完整的图像,而且地图板块是和实际的实际相比较是相反的,下面我们来处理这个问题,地图太大,我们可以调整相机在Z轴的位置,地图相反,我们可以调整Geometryrotate方法进行旋转即可,效果如下

camera.position.set(0, 0, 20);


// 几何体
const geo = new THREE.ExtrudeGeometry(shap, {
bevelEnabled: false,
});

geo.rotateX(Math.PI);

com1.gif

到了这一步我们地图的绘制算是完成了,接下来就是就是对地图上信息的一些完善,接着往下看吧

鼠标移动选中地图区域

实现这一功能需要我们使用Raycaster(光纤投射)类,该类用于进行鼠标拾取,即在三维空间中鼠标移过了什么物体 这里直接复制官方网站的例子,然后进行一些修改即可。

我们要实现的一个功能是当我们鼠标移动到一个市区的时候我们将板块颜色设置为黄色,当离开的时候恢复其原本的颜色,下面来整理一下实现思路:

  1. 记录市区板块材料的初始颜色
  2. 获取鼠标拾取的拾取板块并将市区板块材料颜色设置为黄色
  3. 备份鼠标拾取的市区板块信息,当进行下一次板块拾取的时候恢复这些板块材质初始颜色

下面我们进行代码实现:

  1. 记录板块初始颜色,在设置材料颜色的时候给material增加一个backup属性用于记录材料初始颜色
  ..........................其他代码
  // 材质
  const material = new THREE.MeshBasicMaterial({
    color: randomColor(),
  });
  // 备份一份颜色
  material.backup = material.color.getHex();
  
  ..........................其他代码

getHex获取的是颜色的十六进制值

2,实现获取鼠标划过物体的方法,就是官方的例子,结合自己的情况加以修改

/**
 * @description 获取鼠标触发过的地图信息
 * @param {*} mouseEvent 鼠标事件
 * @returns
 */
const getSelMap = (mouseEvent) => {
  const mapChildren = scene.children.find(
    (item) => item.describe === "map"
  ).children;

  pointer.x = (mouseEvent.offsetX / mouseEvent.target.clientWidth) * 2 - 1;
  pointer.y = -(mouseEvent.offsetY / mouseEvent.target.clientHeight) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);

  const intersects = raycaster.intersectObjects(mapChildren, false);

  return intersects;
};

在这个函数中你可能会注意到mapChildren这个变量,其实这个变量获取的就是所有市区板块物体Mesh

在实际的开发过程中我们的场景Scene中可能会存在很多很多的物体,但有些时候物体之间是存在关系的,就比如,有一些物体的形状是球形、有些是线段,这时候我们就想要将他们进行归类划分以便统一处理。

three.js中就存在这么一个“容器”Group,我们按照我们的意愿将物体进行归类,然后将这些“相似”的物体添加到Group中,在添加完成后我们只需要将Group添加到Scene中就可以了,当我们需要对这些物体进行统一处理的时候,只需要找到scene.children这个归类的Group即可,从而减少数据处理的复杂程度。

  1. 添加鼠标移动事件,在内部进行选中物体颜色的修改和,上一次选中物体颜色的恢复

document.body.addEventListener("mousemove", (event) => {
  const mapList = getSelMap(event);

  // 上次选中的元素恢复初始颜色
  beforeMapList.forEach((bMap) => {
    const hex = bMap.object.material.backup;
    bMap.object.material.color.setHex(hex);
  });

  // 激活元素改变颜色
  mapList.forEach((map) => {
    map.object.material.color.set(0xffff00);
  });

  beforeMapList = mapList;
});

效果如下

mouse.gif

添加tooltip市区名提示

基本步骤和上方选中区域是一样的,下面直接来写实现思路:

  1. body中添加一个元素div,作为内容展示容器。
  2. 当鼠标移动的时候判断是否存在选中区域,存在的话设置div位置为鼠标位置,显示div并设置文本内容。
  3. 当不存在选中区域的时候隐藏div

代码实现如下:

  1. 设置div元素相对body定位,方便后面设置div位置
     body {
        position: relative;
      }
     #tooltip {
        position: absolute;
        left: 0;
        top: 0;
        padding: 6px;
        border-radius: 4px;
        display: none;
        background: #fff;
        pointer-events: none; // 解决当鼠标移动到tooltip元素上时候停顿的问题
      }
  1. 修改drawMap方法的传参,将整个区域信息feature作为参数,并feature.properties.name设置为Meshname
const drawMap = (feature) =>
  new Promise((resolve) => {
    const { geometry, properties } = feature;
    ......
    dealWithCoord(geometry, (lonlatArr) => {
      ......
      // 物体
      const mesh = new THREE.Mesh(geo, material);

      mesh.name = properties.name;
      ......
    });

    resolve(meshArray);
  });
  1. 添加div容器处理方法
/**
 * @description 处理tooltip
 * @param {*} mapList 鼠标捕获的物体列表
 * @param {*} mouseEvent 鼠标事件
 */
const dealTooltip = (mapList, mouseEvent) => {
  const { offsetX, offsetY } = mouseEvent;
  const tooltip = document.getElementById("tooltip");
  if (mapList.length) {
    tooltip.style.display = "block";
    tooltip.style.left = offsetX + "px";
    tooltip.style.top = offsetY + "px";

    mapList.forEach((map) => {
      tooltip.innerText = map.object.name;
    });
  } else tooltip.style.display = "none";
};

4.在鼠标移动事件中调用该方法

document.body.addEventListener("mousemove", (mouseEvent) => {
  const mapList = getSelMap(mouseEvent);
  
  ......其他代码
  
  // 处理tooltip展示
  dealTooltip(mapList, mouseEvent);
});

效果如下:

tooltip.gif

地图下钻

所谓的下钻就是当我们点击某个区域的时候能够展示详细的区域信息,这里我先展示一下最终实现的效果吧

click.gif

好了,效果图已经有了,接下来我们先理清楚实现的思路吧:

  1. 当点击某个区域的时候通过鼠标捕获获取点击区域信息,通过区域adcode获取该区域的详细信息,即新的geoJson数据
  2. 记录并删除当前页面绘制的区域信息,方便下钻后点击返回使用,删除后重复文章中地图绘制步骤
  3. 使用div+css实现一个返回按钮,当存在历史绘制数据的时候展示在页面中
  4. 下钻后点击返回按钮清空当前绘制区域信息,重新添加上历史绘制区域信息

好了思路有了,下面我们来进行代码的实现吧:

  1. 首先我们需要在生成Mesh的地方(drawMap方法)给其添加上区域信息adcode方便在后面鼠标捕获选中区域时候能够拿到

  const mesh = new THREE.Mesh(geo, material);
  // 记录该地区的名字
  mesh.name = properties.name;
  // 用于获取该地区的详细信息
  mesh.adcode = properties.adcode;
  1. 修改calcSide方法,当我们下钻的时候是需要重新计算该区域的最大最小经纬度、以及中心点的,目的是为了新绘制的地区能够居中展示,因此需要在该方法的开始初始化mapSideInfo信息,防止历史信息导致的绘制位置不正确问题
/**
 * @description 计算中心位置、四边位置,请务必在调用drawMap之前调用
 * @param {*} geoJson 请求获取到的geoJson数据
 */
const calcSide = (geoJson) => {
  const { features } = geoJson;

  // 每次计算恢复初始值,以免下钻的时候计算中心点错误
  mapSideInfo = {
    minlon: Infinity,
    maxlon: -Infinity,
    minlat: Infinity,
    maxlat: -Infinity,
  };

   ......其他代码
   };
  1. body中添加一个div用来作为返回按钮
   <div id="back"></div>
     #back {
        position: absolute;
        left: 100px;
        top: 50px;
        background: #fff;
        font-size: 16px;
        font-weight: bold;
        padding: 6px;
        border-radius: 4px;
        cursor: pointer;
        user-select: none;
        z-index: 100;
        display: none;
      }

      #back::after {
        content: "返回";
      }
  // 当鼠标移动到上卖弄的时候禁止事件传播,否则会触发three的光纤投射
  document.getElementById("back").addEventListener("mousemove", (event) => {
    event.stopPropagation();
  });

上面这一步防止事件转播,不然就会出现当悬浮在返回按钮的时候触发鼠标捕获 err.gif

  1. 添加body点击事件,处理当前页面展示内容的保存及删除、获取点击捕获区域地区区域信息并渲染
document.body.addEventListener("mousedown", async (mouseEvent) => {
  const mapList = getSelMap(mouseEvent);

  // 不存在点击区域的话不触发
  if (!mapList.length) return;

  // 获取选中区域的geoJson数据
  const activeArea = await getGeoJson(mapList[0].object.adcode);

  // 记录当前页面展示内容数据
  historyMeshList = [...scene.children];
  // 展示返回按钮
  document.getElementById("back").style.display = "block";

  // 移除当前页面上展示的内容
  scene.remove(...scene.children);
  // 渲染获取到的最新区域地图
  drawMapFunc(activeArea);

});

drawMapFunc方法内部就是重新计算地图中心点信息等信息,以及循环遍历features执行drawMap方法

  1. 返回按钮功能实现
// 点击返回按钮触发
document.getElementById("back").addEventListener("mousedown", () => {
  //移除当前展示的区域
  scene.remove(...scene.children);
  // 将历史展示内容添加到场景中
  scene.add(...historyMeshList);
  // 初始化历史数据数组
  historyMeshList = [];
  // 返回后隐藏返回按钮
  document.getElementById("back").style.display = "none";
});

ok!,下钻完成啦😓

波纹动画实现

先看效果吧

wave.gif

效果图已经出来了,查看动图我们不难发现,波纹主要分为两部分,一部分是波纹中心的黄色圆形面,另一部分是拥有三圈的红色波纹,因此我们可以把总的实现分为三个步骤

  1. 绘制黄色圆形面
  2. 绘制红色波纹
  3. 让红色波纹动起来

首先我们看到波纹中心有一个黄色的圆形面

有了绘制地图drawMap方法的经历,我们绘制圆形面就简单了很多,这里我们主要用了CircleGeometry整个几何体,具体的传参你可以查看官网,相对来说比较好理解,所以这里就不多赘述,下面直接上代码

/**
 * @description 绘制mark
 * @param {*} feature 获取板块地图的子板块信息
 * @returns
 */
const drawMark = (feature) =>
  new Promise((resolve) => {
    const radius = getRadius(waveRate);
    const { properties } = feature;
    const [x, y] = merTrans(properties.center);
    const geometry = new THREE.CircleGeometry(radius);
    const material = new THREE.MeshBasicMaterial({
      color: 0xffa500,
      side: THREE.BackSide,
      depthTest: false,
    });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(x, y, 0);
    resolve(mesh);
  });

你可能注意到了getRadius这个方法,该方法是用于获取绘制黄色圆形的半径的,传递的参数是一个百分比,内部主要是计算得到地图的长、宽值的最小值,然后乘以传入的比例。

然后在drawMapFunc方法中添加一个Group接收圆形物体

  ......
  const markGroup = new THREE.Group();
  markGroup.describe = "mark";
  markGroup.rotateX(Math.PI);
  geoJson.features.forEach(async (feature) => {
    const mark = await drawMark(feature);
    markGroup.add(mark);
  });
  ......

效果如下

mark.gif

当然你可以通过调整waveRate的大小来调整圆形的大小,你可能不会注意到depthTest:false这个键值对,这个牵扯到深度测试,这个主要是用来解决当两个物体在同一位置的时候应该谁展示在前面的,depthTest这个布尔值是用来确定是否要参与到深度测试的,参与的话true,如果两个物体在同一位置的话,展示情况将按照物体与屏幕的距离情况确定展示,这里设置为false,也就是不参与,那么展示情况将按照绘制的先后顺序进行处理,后绘制的将会展示在最前面。

这里如果你不设置的话你将会看到以下效果,

mark-err.gif

会看到有闪烁的情况,这可能并不是你想要的结果,后续出现这种问题也是要通过这样解决的

其实吧,这个和绘制波纹关系不大,但我还是觉得有说的必要,因为我被这个问题困扰过🥹(这种情况仅限你将他们绘制在同一平面上

绘制波纹

这里绘制波纹是用canvas绘制的,然后将canvas作为纹理贴图应用在Material上,说白了就是用这个canvas作为材料的颜色了,直接上canvas代码


/**
 * @description 波纹
 * @returns HtmlCanvasElement
 */
const getWave = () => {
  const radius = getRadius(waveRate);
  const canvas = document.createElement("canvas");
  canvas.width = radius * 1000;
  canvas.height = radius * 1000;
  const ctx = canvas.getContext("2d");

  const center = {
    x: canvas.width / 2,
    y: canvas.height / 2,
  };

  const waveMaxRadius = canvas.width / 2;

  ctx.strokeStyle = "red";

  ctx.beginPath();
  ctx.lineWidth = waveMaxRadius / 6;
  ctx.arc(center.x, center.y, waveMaxRadius * 0.3, 0, 2 * Math.PI);
  ctx.stroke();

  ctx.beginPath();
  ctx.lineWidth = waveMaxRadius / 12;
  ctx.arc(center.x, center.y, waveMaxRadius * 0.6, 0, 2 * Math.PI);
  ctx.stroke();

  ctx.beginPath();
  ctx.lineWidth = waveMaxRadius / 18;
  ctx.arc(center.x, center.y, waveMaxRadius * 0.9, 0, 2 * Math.PI);
  ctx.stroke();

  return canvas;
};

有了波纹接下来就是创建波纹物体,方法和绘制圆形相似,只需要调整一下绘制的半径,让波纹的最内层圆的半径大于圆形的半径就差不多了

/**
 * @description 绘制mark
 * @param {*} feature 获取板块地图的子板块信息
 * @returns
 */
const drawWave = (feature) =>
  new Promise((resolve) => {
    const radius = getRadius(waveRate);
    const { properties } = feature;
    const [x, y] = merTrans(properties.center);
    const geometry = new THREE.CircleGeometry(radius / 0.2);

    const waveTexture = new THREE.CanvasTexture(getWave());

    const material = new THREE.MeshBasicMaterial({
      map: waveTexture,
      transparent: true,
      opacity: 1,
      side: THREE.BackSide,
      depthTest: false,
    });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(x, y, 0);
    resolve(mesh);
  });

然后同绘制圆形一样,在drawMapFunc添加波纹分组,代码重复,我就不贴了哈,直接上图,图是静止的,我就不动图了

image.png

让波纹动起来

我们仔细查看最终的效果图会发现波纹的动态其实是在缩放变大的同时透明度也在发生变化,说变了就是在缩放Mesh的同时改变Material的透明度, 还记得我们之前包装的animate方法吗,有一个回调函数,我们直接在回调函数中处理就可以了,方法就是找到波纹分组,然后遍历物体修改参数即可,代码如下:

animate(() => {
  const waveObject = scene.children.find(
    (object) => object.describe === "wave"
  );
  if (waveObject) {
    waveObject.children.forEach((mesh) => {
      if (mesh.material.opacity > 0) {
        mesh.material.opacity -= 0.005;
        scale += 0.00006;
      } else {
        mesh.material.opacity = 1;
        scale = 1;
      }
      mesh.scale.set(scale, scale, 1);
    });
  }
});

我们可以通过跳帧参数值的大小调整波纹动画的快慢!

到这里终于是写完了,如果对你有所帮助,帮忙点个赞呗,这里我还直接给你源码呢!

image.png

github地址:github.com/fan975326/t…