[mapbox-gl] mapbox-gl + three.js 地图添加飞线效果

5,142 阅读4分钟

一开始,希望在mapbox-gl上实现一些链路的可视化时,并没有一些比较直接的方法,例如:飞机航线图、人口迁徙图等。

后来发现通过deck.gl这个插件可以比较轻松的实现一些3D 链路的效果。就像下面这样:

deck.gl 官网 : https://deck.gl/

最后又希望让链路样式更丰富一些,比如让链路有动的效果,所以就想到了three.js抛物线的流动效果。最后实现效果就是下面这样:

实现原理就是通过three.js实现抛物线流动的效果——飞线,并且将这个飞线效果集成到mapbox-gl地图上。整体步骤就分为:

1、three.js 实现抛物线流动

2、将three.js 集成到maobox-gl

three.js 实现抛物线流动

分成两步走,1、画出抛物线,2、实现抛物线流动效果。

初始化three.js场景

代码:

function init() {
  let containerWidth = containerDom.value.clientWidth
  let containerHeight = containerDom.value.clientHeight
  scene = new THREE.Scene()
  camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000)
  camera.position.z = 50

  renderer = new THREE.WebGLRenderer()
  renderer.setSize(containerWidth, containerHeight)
  renderer.setClearColor(0x666666, 1)
  containerDom.value.appendChild(renderer.domElement)
  renderer.render(scene, camera)

  // 辅助线
  const axesHelper = new THREE.AxesHelper(100)
  scene.add(axesHelper)

  // 控制器
  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true // 阻尼(惯性)
  controls.dampingFactor = 0.05 // 阻尼大小


  animate()

  makeArcline() // 创建抛物线
}

创建抛物线

我们可以通过二次贝塞尔曲线来画抛物线,three.js中提供了创建二次贝塞尔曲线的方法:QuadraticBezierCurve3

我们只需要提供3个点的坐标就可以画抛物线,3个点分别是起始点、目的点和控制点坐标。当然控制点坐标可以通过起始点和目的点来算。所以只需要提供起始点和目的点就好了。

控制点其实就是在起始点与目的点中间并高于这两个点所在的平面。比较简单,具体可以看下面代码。

  const yHeight = 50
  const point1 = new THREE.Vector3(50, 0, 0)
  const point2 = new THREE.Vector3(-50, 0, 0)
  const controlPoint = new THREE.Vector3(
    (point1.x + point2.x) / 2, 
    (point1.y + point2.y) / 2 + yHeight, 
    (point1.z + point2.z) / 2
  )

  // 创建三维二次贝塞尔曲线
  const curve = new THREE.QuadraticBezierCurve3(
    new THREE.Vector3(point1.x, point1.y, point1.z),
    new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
    new THREE.Vector3(point2.x, point2.y, point2.z),
  )

添加抛物线到场景

现在抛物线有了,只需要再通过几何体(BufferGeometry) + 材质(LineBasicMaterial)就可以创建一条线了(Line)。

几何体的顶点位置我们可以通过刚刚创建的贝塞尔曲线获得,并同时赋予顶点颜色,方便实现后面的流动效果。所以使用材质的时候就需要设置开启顶点颜色。具体代码如下:

const divisions = 20  // 曲线的分段数量
const points = curve.getPoints(divisions) // 返回分段的点 31个

// 创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points)
line.geometry = geometry  // 全局保存一下
let colors = new Float32Array(points.length * 4)

points.forEach((d, i) => {
  colors[i * 4] = colorHigh.r
  colors[i * 4 + 1] = colorHigh.g
  colors[i * 4 + 2] = colorHigh.b
  colors[i * 4 + 3] = 1
})
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))

// 材质
const material = new THREE.LineBasicMaterial({
  vertexColors: true, // 顶点着色
  linewidth: 5,
  transparent: true,
  side: THREE.DoubleSide
})

const mesh = new THREE.Line(geometry, material)


scene.add(mesh)

注意 LineBasicMaterial 中设置linewidth 是没有效果的,官网是这样解释的:

颜色我这里设置了一个蓝色

let colorHigh = new THREE.Color("rgb(33, 150, 243)")

所以最后效果是这样:

makeArcline 的完整代码:

// 绘制抛物线
function makeArcline() {
  const yHeight = 50
  const point1 = new THREE.Vector3(50, 0, 0)
  const point2 = new THREE.Vector3(-50, 0, 0)
  const controlPoint = new THREE.Vector3(
    (point1.x + point2.x) / 2, 
    (point1.y + point2.y) / 2 + yHeight, 
    (point1.z + point2.z) / 2
  )

  // 创建三维二次贝塞尔曲线
  const curve = new THREE.QuadraticBezierCurve3(
    new THREE.Vector3(point1.x, point1.y, point1.z),
    new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
    new THREE.Vector3(point2.x, point2.y, point2.z),
  )

  // 流动线效果
  const divisions = 20  // 曲线的分段数量
  const points = curve.getPoints(divisions) // 返回分段的点 31个
  console.log(points)

  // 创建几何体
  const geometry = new THREE.BufferGeometry().setFromPoints(points)
  line.geometry = geometry  // 全局保存一下
  let colors = new Float32Array(points.length * 4)

  points.forEach((d, i) => {
    colors[i * 4] = colorHigh.r
    colors[i * 4 + 1] = colorHigh.g
    colors[i * 4 + 2] = colorHigh.b
    colors[i * 4 + 3] = 1
  })

  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))

  // 材质
  const material = new THREE.LineBasicMaterial({
    vertexColors: true, // 顶点着色
    linewidth: 5,
    transparent: true,
    side: THREE.DoubleSide
  })

  const mesh = new THREE.Line(geometry, material)

  scene.add(mesh)
}

抛物线的动态效果

其实这里实现"动态"的主要思路就是去顺序不断的改变几何体中顶点的颜色,当然也可以修改透明度。具体代码图下:

let timestamp = 0
let colorIndex = 0
// 线的动画
function arclineAnimate() {
  if (!line.geometry) {
    return
  }
  let color = line.geometry.getAttribute('color')

  let now = Date.now()
  if (now - timestamp > 30) {
    timestamp = now
    colorIndex++
    if (colorIndex >= color.count) {
      colorIndex = 0
    }
  }

  for(let i = 0; i < color.array.length; i += 4) {
    if ((i / 4) === colorIndex) {
      color.array[i + 3] = 1
    } else {
      color.array[i + 3] = 0
    }
  }

  line.geometry.attributes.color.needsUpdate = true // 更新颜色
}

效果如下:

这里只是改了一下透明度,当然也可以修改为不同的颜色。

mapbox-gl 集成 three.js

mapbox-gl 光网有一个添加3D model到地图上的例子,详细内容可以点击查看这里

另外mapbox-gl上集成也有一个插件——threebox,这里是它的github。它原本这个库很久没有维护了,并不支持最新版本的three.js。有大神在这个库基础上继续开发,并还在持续维护,详情可以点击访问这个github

我这里没有选择用threebox插件,因为功能相对简单,自己写代码也不多。

有了上面实现three.js抛物的基础上,我们只需要在mapbox-gl添加一个three.js场景的图层即可。

添加自定义图层

自定义图层也没啥好说的,可以直接看官网API。这里直接贴代码:

// 创建three.js图层
function initThreeLayer() {
  let threeLayer = {
    id: 'three-layer',
    type: 'custom',
    renderingMode: '3d',
    onAdd: async function (map, mbxContext) {
      this.camera = new THREE.Camera()
      this.scene = new THREE.Scene()
      this.map = map
      this.renderer = new THREE.WebGLRenderer({
        canvas: map.getCanvas(),
        context: mbxContext,
        antialias: true
      })
      this.renderer.autoClear = false

      // 辅助线
      let axes = new THREE.AxesHelper(100000)
      this.scene.add(axes)

      let lineGroup = makeArcGroup()	// 抛物线集合

      this.scene.add(lineGroup)
    },
    render: function (gl, matrix) {
      this.camera.projectionMatrix = new THREE.Matrix4().fromArray(matrix)
      this.renderer.state.reset()
      this.renderer.render(this.scene, this.camera)
      this.map.triggerRepaint()

      arclineAnimate()	// 抛物流动逻辑
    }
  }

  return threeLayer
}

画抛物线

画抛物线这里涉及到一个经纬度坐标转three.js场景坐标的问题。mapbox-gl提供mapboxgl.MercatorCoordinate.fromLngLat方法,方法使用如下:

官网API地址

我这里一般画线的数据都是包含经纬度坐标,大概就是下面这样:

  let linklist = [
    {
      source: {
        latitude: 37.28669,
        longitude: -98.02589,
      },
      target: {
        latitude: 36.54614,
        longitude: -98.27007,
      },
    },
    {
      source: {
        latitude: 37.28527,
        longitude: -98.577433,
      },
      target: {
        latitude: 36.45336,
        longitude: -100.53737,
      },
    },
    {
      source: {
        latitude: 37.28113,
        longitude: -98.577433,
      },
      target: {
        latitude: 36.43222,
        longitude: -100.14111,
      },
    },
    {
      source: {
        latitude: 37.28113,
        longitude: -98.577433,
      },
      target: {
        latitude: 36.45169,
        longitude: -103.1841,
      },
    },
    {
      source: {
        latitude: 36.910248,
        longitude: -103.807756,
      },
      target: {
        latitude: 36.638648,
        longitude: -103.882987,
      },
    },
    {
      source: {
        latitude: 37.28113,
        longitude: -98.577433,
      },
      target: {
        latitude: 36.43222,
        longitude: -100.14111,
      },
    }
  ]

画线的代码基本和上面three.js画线一样,只是这里多了一个转坐标的步骤。

// 全局变量
let colorHigh = new THREE.Color("rgb(244, 67, 54)")  // line color high
let colorNormal = new THREE.Color("rgb(255, 235, 59)")  // line color
let lineGeometryList = []	

// 绘制抛物线
function makeArcline(link) {
  const source = [link.source.longitude, link.source.latitude]
  const target = [link.target.longitude, link.target.latitude]

  let sourcePoint = mapboxMercator(source)
  let targetPoint = mapboxMercator(target)
  let controlPoint = getControlPoint(sourcePoint, targetPoint)


  // 创建三维二次贝塞尔曲线
  const curve = new THREE.QuadraticBezierCurve3(
    new THREE.Vector3(sourcePoint.x, sourcePoint.y, sourcePoint.z),
    new THREE.Vector3(controlPoint.x, controlPoint.y, controlPoint.z),
    new THREE.Vector3(targetPoint.x, targetPoint.y, targetPoint.z),
  )

  // 流动线效果
  const divisions = 30  // 曲线的分段数量
  const points = curve.getPoints(divisions) // 返回分段的点 divisions + 1 个

  // 创建几何体
  const geometry = new THREE.BufferGeometry().setFromPoints(points)
  let colors = new Float32Array(points.length * 4)

  points.forEach((d, i) => {
    colors[i * 4] = colorNormal.r
    colors[i * 4 + 1] = colorNormal.g
    colors[i * 4 + 2] = colorNormal.b
    colors[i * 4 + 3] = 1
  })

  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))

  // 材质
  const material = new THREE.LineBasicMaterial({
    vertexColors: true, // 顶点着色
    linewidth: 5, // 没用,不支持设置lineWidth
    transparent: true,
    side: THREE.DoubleSide
  })

  const mesh = new THREE.Line(geometry, material)

  lineGeometryList.push(geometry)

  return mesh
}

// 经纬度转坐标点
function mapboxMercator(lngLat) {
  return mapboxgl.MercatorCoordinate.fromLngLat(lngLat, 0);
}

画出来结果就是这样:

让抛物动起来

最后这里的动画逻辑和上面的一样,直接放代码了:

// 线的动画
let timestamp = 0
let colorIndex = 0
function arclineAnimate() {
  lineGeometryList.forEach(geometry => {
    let color = geometry.getAttribute('color')

    let now = Date.now()
    if (now - timestamp > 30) {
      timestamp = now
      colorIndex++
      if (colorIndex >= color.count) {
        colorIndex = 0
      }
    }

    for (let i = 0; i < color.array.length; i += 4) {
      if ((i / 4) === colorIndex) {
        color.array[i + 0] = colorHigh.r
        color.array[i + 1] = colorHigh.g
        color.array[i + 2] = colorHigh.b
        color.array[i + 3] = 1
      } else {
        color.array[i + 0] = colorNormal.r
        color.array[i + 1] = colorNormal.g
        color.array[i + 2] = colorNormal.b
        color.array[i + 3] = 0.1
      }
    }
    geometry.attributes.color.needsUpdate = true
  })
}

最后效果就是这样: