3D行政区划显示地图图层(高德结合Three.js@0.142.0)

1,924 阅读4分钟

我正在参加「掘金·启航计划」

开篇一句诗:

盛年不重来,一日难再晨。及时当勉励,岁月不待人。    — 陶渊明《杂诗》

之前做3D行政区划都是一个纯色的立体区划块,没办法技术不允许啊啊啊! 即使UI给了3D行政区划上加地图的设计稿也只能换其他方式去实现,别问 问就是不会。很难吗? 要不你来?不过呢现在我可以了,不过我已经离开了。哈哈哈哈

ceeb653ely1g9na2k0k6ug206o06oaa8.gif

高德在前几个月就把GLCustomLayer这个自定义图层的方法支持到了Three.js@0.142.0就很棒。可以看下高德官方的示例

废话不多说开始叭叭

初始化高德3D区域掩模

官网示例只是在Map方法里添加一个mask属性把要展示的区域点位作为value传过去就行了,这不就有画面了嘛。easy 不得不说高德对于开发者快速上手还是很友好的

Snipaste_2023-05-13_10-29-04.png

宝~再悄悄告诉你在区域掩模的情况下是可以加背景图片的哦打开控制台检测元素就可以看到这一行代码

Snipaste_2023-05-13_10-37-31.png 直接给container添加样式直接把行内式替换掉就ok了,学到了吧彦祖亦菲

#container {
  width: 100%;
  height: 100vh;
  background: url('/image/mapbg.jpg') 100% 100% / 100% 100% no-repeat;
}

imageMaskMap.png

用GLCustomLayer初始化Three

官网示例 初始化方法和官网示例一定要和官网一样哦

下面两个回调函数里面的代码可以封装成函数这样代码不就优雅了,写代码是为了什么?不就是为了优雅吗。优雅永不过时

init: gl => {

},
render: () => {

}

okThree已经初始化完成开始下一步

加载行政区划

数据就还是用做掩模的点位但是要记得把经纬度转换一下毕竟Three不认识经纬度,使用高德提供的这个方法

注意: 数据使用转换工具进行转换,这个操作必须要提前执行(在获取镜头参数 函数之前执行),否则将会获得一个错误信息。

    const customCoords = map.customCoords;
    const data = customCoords.lngLatsToCoords(path);

转换后的坐标是这样的

Snipaste_2023-05-13_11-06-34.png

行政区划掩模、Three和数据都准备好了下面开始加载3D行政区划了Three可以有两种方法去实现3D区划,我们来一一尝试。在这之前咱要知道Three创建物体的大体流程吧

Snipaste_2023-05-13_11-43-03.png

第一种

那么我们就按照上面的流程使用THREE.Shape()搭配THREE.ExtrudeGeometry() 进行物体的创建和加载

function drawShape() {
    //创建路径
  let shape = new THREE.Shape();
  for (let i = 0; i < paths.length; i++) {
    let elp = paths[i];
    if (i === 0) {
      // 起点
      shape.moveTo(elp[0], elp[1]);
    } else {
      shape.lineTo(elp[0], elp[1]);
    }
  }
  const extrudeSettings = {
    steps: 2, // 沿着挤压样条的深度细分段的点数。默认值为1。
    depth: 5000, // 挤压形状的深度
    bevelEnabled: true,
    bevelThickness: 1,
    bevelSize: 1,
    bevelOffset: 0,
    bevelSegments: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({ color: '#0e639c', transparent: true });
  const mesh = new THREE.Mesh(geometry, material);
  // 要和depth 保持一致
  mesh.position.z = -5000
  scene.add(mesh)
}

上面这段代码的要点就是在mesh.position.z = -5000 调整网格的位置让它保持在地图图层的下面别像我之前傻乎乎的去调整new AMap.GLCustomLayerzIndex为 -1 这样虽然在地图图层的下边了但是在操作的时候会有偏移的

1678267286774_76543fd9-4358-45a9-bbc2-dfcc158a3b04.png

那么咱们的第一种方法就实现了下面看效果

Snipaste_2023-05-13_12-11-36.png

第二种

第二种方法呢是使用new THREE.BufferGeometry()进行顶点绘制,去绘制侧面相当于一道围墙要注意咱们是从上往下绘制的!!!

机器人对 THREE.BufferGeometry()的解释:

当我们渲染一个场景时,Three.js 需要将场景中每个对象的顶点数据转化为 GPU 中的可用数据。顶点数据通常包含对象的形状、位置、表面数据和光照信息等。当需要渲染大量对象时,转换大量顶点数据并在 GPU 中处理这些数据是非常昂贵和耗时的。

为了解决这个问题,Three.js 提供了基于缓冲区的几何模型 BufferGeometry() 类。顾名思义,BufferGeometry() 使用缓冲区来存储和管理几何数据,在渲染时更加高效。BufferGeometry() 可以包含大量的顶点数据信息,如位置、法向量、颜色、纹理坐标等。

这个类里面的顶点数据三个数值为一个点(x,y,z)最少要有三个点会组成一个三角形所以数据要是三个倍数否则会造成图像丢失,这是我个人的理解。

Snipaste_2023-05-13_14-11-47.png

  // 顶点数据
  const positions = new Float32Array([
    100.0,  100.0,  0.0,
    -100.0,  100.0,  0.0,
    100.0, -100.0,  0.0,
  ]);

  // 设置 BufferAttribute,把顶点数据放入 positions 变量中
  const geometry = new THREE.BufferGeometry()
  console.log(geometry)
  // 顶点三角面
  geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(new Float32Array(positions), 3)
  )
  var mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({color: '#fff',side: THREE.DoubleSide,}));
  scene.add(mesh);

关于BufferGeometry()可以看下这篇文章我觉得讲的很好下面的实现方法也参考了这位大佬的文章 参考链接 下面是第二种方法的具体代码:

function drawSide() {
  const arr = paths
  // 保持闭合路线
  if (arr[0].toString() !== arr[arr.length - 1].toString()) {
    arr.push(arr[0])
  }

  const vec3List = [] // 顶点数组
  let faceList = [] // 三角面数组
  let faceVertexUvs = [] // 面的UV层队列,用于纹理和几何信息映射

  const t0 = [0, 0]
  const t1 = [1, 0]
  const t2 = [1, 1]
  const t3 = [0, 1]

  for (let i = 0; i < arr.length; i++) {
    const [x1, y1] = arr[i]
    // 向下绘制-----
    vec3List.push([x1, y1, -7000])
    // -------
    vec3List.push([x1, y1, 0])
  }
  console.log("🚀 ~ file: gdAndThree.html:182 ~ drawSide ~ vec3List", vec3List);

  for (let i = 0; i < vec3List.length - 2; i++) {
    if (i % 2 === 0) {
      // 下三角
      faceList = [
        ...faceList,
        ...vec3List[i],
        ...vec3List[i + 2],
        ...vec3List[i + 1]
      ]
      // UV
      faceVertexUvs = [...faceVertexUvs, ...t0, ...t1, ...t3]
    } else {
      // 上三角
      faceList = [
        ...faceList,
        ...vec3List[i],
        ...vec3List[i + 1],
        ...vec3List[i + 2]
      ]
      // UV
      faceVertexUvs = [...faceVertexUvs, ...t3, ...t1, ...t2]
    }
  }

  const geometry = new THREE.BufferGeometry()
  // 顶点三角面
  geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(new Float32Array(faceList), 3)
  )
  // UV面
  geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(new Float32Array(faceVertexUvs), 2)
  )
  const material = new THREE.MeshBasicMaterial({
    color: 'rgb(5,38,56)',
    side: THREE.DoubleSide,
    transparent: true,
    depthWrite: true
  })
  const sideMesh = new THREE.Mesh(geometry, material)
  scene.add(sideMesh)
}

注意: 看这句代码vec3List.push([x1, y1, -7000]) 这个是精髓没它不往下画啊宝~


好了以上就是这两种方法的实现,要说这两种方法哪个更好这个我个人感觉看到什么区别在加载速度上来说时间都是差不多的只是加载一个面而已,如果要是使用城市级别的json数据我觉得BufferGeometry()会好一点,而且用BufferGeometry()画一个城市建筑的侧面再搭配new THREE.Shape()构建建筑的顶面。就可以加载两种材质,侧面和顶面也可以分别操作

总的来说我建议如果要实现这种3D行政区划的话第一种写起来是快的也没有那么多的心智负担,不过第二种方法看着厉害一点哦。哈哈哈哈哈

src=http___c-ssl.duitang.com_uploads_item_202004_27_20200427203840_tjahd.thumb.1000_0.jpg&refer=http___c-ssl.duitang.webp

ok 结束

仰天大笑出门去,我辈岂是蓬蒿人。——《南陵别儿童入京》

下面就是完整代码

<template>
  <div id="container"></div>
</template>
 
<script setup>
import * as THREE from 'three';
import data from './datas/json.js'
import { onMounted } from 'vue'
let container
let map = null
let paths
let customCoords
const height = 5000
// THREE相关变量
let camera, scene, renderer
onMounted(() => {
  initMap()
})

function initMap() {
  container = document.getElementById('container')
  map = new AMap.Map('container', {
    center: [119.50129462, 29.63775178],
    zooms: [2, 20],
    mask: data.features[0].geometry.coordinates,
    zoom: 9,
    viewMode: '3D',
    pitch: 70,
    layers:[
      new AMap.TileLayer.Satellite()
    ]
  })
  map.on('complete', () => {
    customCoords = map.customCoords
    let path = data.features[0].geometry.coordinates[0]
    paths = customCoords.lngLatsToCoords(path)[0]
    initLayer()
    window.addEventListener('resize', onWindowResize)
    const marker = new AMap.Marker({
      icon: "https://webapi.amap.com/theme/v1.3/markers/n/mark_b.png",
      position: [119.50129462, 29.63775178],
      anchor:'bottom-center'
    });
    map.add(marker)
  })
}

function initLayer() {
  const layer = new AMap.GLCustomLayer({
    // 调整地图层级
    zIndex: -1,
    init: gl => {
      initThree(gl)
      // 第二种
      drawSide()
      // 第一种
      // drawShape()
      animate()
    },
    render: () => {
      const { near, far, fov, up, lookAt, position } =
        customCoords.getCameraParams()

      camera.near = near // 近平面
      camera.far = far // 远平面
      camera.fov = fov // 视野范围
      camera.position.set(...position)
      camera.up.set(...up)
      camera.lookAt(...lookAt)

      // 更新相机坐标系
      camera.updateProjectionMatrix()

      renderer.render(scene, camera)

      // 这里必须执行!重新设置 three 的 gl 上下文状态
      renderer.resetState()
    }
  })
  map.add(layer)
}

// 初始化Three
function initThree(gl) {
  camera = new THREE.PerspectiveCamera(
    60,
    container.clientWidth / container.clientHeight,
    100,
    1 << 30
  )
  renderer = new THREE.WebGLRenderer({
    context: gl,
    antialias: true // 抗锯齿, 默认为false 耗性能
  })
  // 自动清空画布这里必须设置为 false, 否则地图地图将无法显示
  renderer.autoClear = false
  renderer.outputEncoding = THREE.sRGBEncoding

  scene = new THREE.Scene()
  // 增加环境光
  const aLight = new THREE.AmbientLight(0xffffff, 0.3)
  scene.add(aLight)
}
/**
* 第一种
*/
function drawShape() {
  let shape = new THREE.Shape();
  for (let i = 0; i < paths.length; i++) {
    let elp = paths[i];
    if (i === 0) {
      shape.moveTo(elp[0], elp[1]);
    } else {
      shape.lineTo(elp[0], elp[1]);
    }
  }
  const extrudeSettings = {
    steps: 2, // 用于沿着挤压样条的深度细分段的点数。默认值为1。
    depth: height, // 挤压形状的深度
    bevelEnabled: true,
    bevelThickness: 1,
    bevelSize: 1,
    bevelOffset: 0,
    bevelSegments: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({ color: '#0e639c', transparent: true });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.z = -height
  scene.add(mesh)
}
/**
 * 第二种方法
 */
function drawSide() {
  const arr = paths
  // 保持闭合路线
  if (arr[0].toString() !== arr[arr.length - 1].toString()) {
    arr.push(arr[0])
  }

  const vec3List = [] // 顶点数组
  let faceList = [] // 三角面数组
  let faceVertexUvs = [] // 面的UV层队列,用于纹理和几何信息映射

  const t0 = [0, 0]
  const t1 = [1, 0]
  const t2 = [1, 1]
  const t3 = [0, 1]

  for (let i = 0; i < arr.length; i++) {
    const [x1, y1] = arr[i]
    vec3List.push([x1, y1, -height])
    vec3List.push([x1, y1, 0])
  }
  console.log("🚀 ~ file: gdAndThree.html:182 ~ drawSide ~ vec3List", vec3List);

  for (let i = 0; i < vec3List.length - 2; i++) {
    if (i % 2 === 0) {
      // 下三角
      faceList = [
        ...faceList,
        ...vec3List[i],
        ...vec3List[i + 2],
        ...vec3List[i + 1]
      ]
      // UV
      faceVertexUvs = [...faceVertexUvs, ...t0, ...t1, ...t3]
    } else {
      // 上三角
      faceList = [
        ...faceList,
        ...vec3List[i],
        ...vec3List[i + 1],
        ...vec3List[i + 2]
      ]
      // UV
      faceVertexUvs = [...faceVertexUvs, ...t3, ...t1, ...t2]
    }
  }

  const geometry = new THREE.BufferGeometry()
  // 顶点三角面
  geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(new Float32Array(faceList), 3)
  )
  // UV面
  geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(new Float32Array(faceVertexUvs), 2)
  )
  const material = new THREE.MeshBasicMaterial({
    color: 'rgb(5,38,56)',
    side: THREE.DoubleSide,
    transparent: true,
    depthWrite: true
  })
  const sideMesh = new THREE.Mesh(geometry, material)
  scene.add(sideMesh)
}


// 动画
function animate() {
  if (map) {
    map.render()
  }
  requestAnimationFrame(() => {
    animate()
  })
}
function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(window.innerWidth, window.innerHeight)
}
</script>
 
<style scoped lang='scss'>
#container {
  width: 100%;
  height: 100vh;
}
</style>