用 three.js 实现 3D 地图

8 阅读5分钟

之前写过一篇文章介绍了使用ECharts GL实现立体地图 => 用 ECharts GL 把地图「立」起来,那如果ECharts GL满足不了需求,就可以考虑使用Three.js啦。

Three.js

three.js 是一个在浏览器里把 3D 画出来的图形库。你可以把它想成搭舞台,那咋把舞台搭起来呢?

  1. Scene:搭舞台的“背景板”(灯光、模型、网格)。
  2. Camera:决定你从哪个方向看(透视相机更像“人眼”视角,正交相机更像“测绘”视角)。
  3. Renderer:负责“出画面”(每一帧把 scene + camera 渲染到画布)。

看一眼最小骨架,后面不迷路:

import * as THREE from "three"

const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
camera.position.set(0, 0, 10)

const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
container.appendChild(renderer.domElement)

function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
}
animate()

理解了这三件事,你再把“地区轮廓”转换成 3D 网格,就等于把数据变成了可以被光照和相机看到的模型。

实现流程

  1. 读取 GeoJSON:先把 Polygon 统一成和 MultiPolygon 一样的数据形态;
  2. 遍历每个 polygon ring:把点序列写进 THREE.ShapemoveTo/lineTo
  3. ExtrudeGeometry 把二维轮廓“拉起来”成三维实体(depth 决定高度,bevel 决定边缘质感)
  4. 给几何体分配材质组合:上表面更亮、侧边更有层次,立体感才会成立
  5. Box3.expandByObject() 算中心与尺寸:一键对齐相机,让视角永远落在地图上
  6. 初始化 CSS2DRenderer:在动画循环里把 WebGL 和 2D 标签叠加起来,再驱动光柱/粒子等小动效

1) 统一 GeoJSON:让 PolygonMultiPolygon 数据结构一致

很多 GeoJSON 在实际项目里会“有时是 Polygon,有时是 MultiPolygon”。如果你写死一套遍历逻辑,就会出现:某些地区根本没被绘制出来,或者逻辑分支越写越乱。

我是这样实现的:如果几何类型是 Polygon,就把 coordinates 包一层,让它变成 MultiPolygon 风格的二维数组。这样后续只写一套循环就够了。

// 统一 GeoJSON(关键点:Polygon -> MultiPolygon-like)
export default function useConversionStandardData() {
  const transfromGeoJSON = (worldData) => {
    const features = worldData.features
    for (let i = 0; i < features.length; i++) {
      const element = features[i]
      if (element.geometry.type === "Polygon") {
        // Polygon: [ [ [x,y], ... ] ]
        // 包一层 -> MultiPolygon-like: [ [ [x,y], ... ] ]
        element.geometry.coordinates = [element.geometry.coordinates]
      }
    }
    return worldData
  }

  return { transfromGeoJSON }
}

数据与坐标:先想清楚你画的是“平面”还是“球面”

THREE.Shape 本质上只认“二维平面坐标”。所以第一件事不是写代码,而是先确认:你的 GeoJSON 点 (x, y) 在你的 Three.js 世界里,究竟应该落到哪里。

  • 如果你的数据已经是“平面化坐标”(例如直接用 GeoJSON 的 (x, y) 去描轮廓),那就可以直接 Shape -> ExtrudeGeometry,不用经纬度转换。
  • 如果你的数据是经纬度 (lon, lat),你就必须先做坐标转换。路线是:用球面映射把点投到球面上,再用四元数让面朝向球面法线。

2) 核心:从轮廓到 3D 面(Shape + ExtrudeGeometry)

这一段就是“把平面地图变成立体模型”的关键啦:只要你理解了它,后面的居中、标签、动效就都只是加配件。

在 Three.js 里:

  • THREE.Shape 负责把轮廓“描一遍”(moveTo/lineTo
  • THREE.ExtrudeGeometry 负责把描好的轮廓“拉高变厚”(depth/bevel 等参数决定你要多立体)

我们可以把GeoJSON 的 ring 逐点喂给 Shape,再一口气拉伸成网格。注意:Mesh 的材质传数组是为了“上表面更亮、侧边更有阴影感”。

import * as THREE from "three"

const extrudeSettings = { depth: 0.2, bevelEnabled: true, bevelSegments: 1, bevelThickness: 0.1 }

function buildRegion3D({ geoJson, topFaceMaterial, sideMaterial }) {
  const mapGroup = new THREE.Group()

  geoJson.features.forEach((feature) => {
    const province = new THREE.Object3D()
    const coordinates = feature.geometry.coordinates

    coordinates.forEach((multiPolygon) => {
      multiPolygon.forEach((polygon) => {
        const shape = new THREE.Shape()
        polygon.forEach(([x, y], i) => (i === 0 ? shape.moveTo(x, y) : shape.lineTo(x, y)))

        const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
        province.add(new THREE.Mesh(geometry, [topFaceMaterial, sideMaterial]))
      })
    })

    mapGroup.add(province)
  })

  return mapGroup
}

3) 自动居中

你也不想每换一份 GeoJSON 就手动调相机坐标,对吧?那就用包围盒做“自动聚焦”。

Box3().expandByObject() 会把整个地图包起来,你拿到中心点以后就可以:让相机 lookAt 它,控制器 target 也跟着指向它。

import * as THREE from "three"

function initCameraTargetByBox({ group, camera, controls }) {
  const box3 = new THREE.Box3().expandByObject(group)
  const center = new THREE.Vector3()
  box3.getCenter(center)

  camera.lookAt(center.x, center.y, 0)
  if (controls?.target) controls.target = new THREE.Vector3(center.x, center.y, 0)
}

效果会立刻变“稳定”:换地区数据也能落在视野正中。


4) 标签(CSS2D)与光柱

做到“有形”还不够,得让人看得懂。标签负责告诉你“这是什么”,光柱负责把注意力“引到那里”。

标签(CSS2DRenderer)

这里我没有做3D字体几何体(太重也太麻烦),而是用 HTML div 作为“贴纸”。CSS2DObject 让它能跟随相机正确投影。

import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer"

const create2DTag = (html, className = "") => {
  const div = document.createElement("div")
  div.innerHTML = html
  div.className = className
  div.style.pointerEvents = "none"
  div.style.visibility = "hidden"

  const label = new CSS2DObject(div)
  label.show = (text, point) => {
    label.element.innerHTML = text
    label.element.style.visibility = "visible"
    label.position.copy(point)
  }
  label.hide = () => { label.element.style.visibility = "hidden" }
  return label
}

渲染时只要每帧调用一次 css2dRender.render(scene, camera),标签就会“自动跟镜头走”。

光柱

光柱的漂亮之处在于“看起来更立体”,实现方案:两张切图交叉,再配合透明渲染参数避免穿帮。

import * as THREE from "three"

// textureLoader / 纹理 url 在外层准备
const createLightPillar = (lon, lat, height, textureUrl, color = 0x00ffff) => {
  const group = new THREE.Group()
  const geometry = new THREE.PlaneBufferGeometry(height / 6.219, height)
  geometry.rotateX(Math.PI / 2)
  geometry.translate(0, 0, height / 2)

  const material = new THREE.MeshBasicMaterial({
    map: textureLoader.load(textureUrl),
    color,
    transparent: true,
    depthWrite: false,
    side: THREE.DoubleSide,
  })

  const a = new THREE.Mesh(geometry, material)
  const b = a.clone()
  b.rotateZ(Math.PI / 2)

  group.add(a, b)
  group.position.set(lon, lat, 0)
  return group
}

地图面生成后把光柱加进 mapGroup 就行

const light = createLightPillar(...lightCenter, heightScaleFactor, lightPillarTextureUrl)
light.position.z = 0.31
mapGroup.add(light)

5) 让动画活起来

你不需要把每一帧都烧到极致,但需要让“画面在动”,让它不显得生硬:

  • 背景光圈缓慢旋转
  • 粒子沿 z 轴上升再重置
  • 2D 标签每帧由 CSS2DRenderer 重新渲染

核心循环就四件小事:WebGL 渲染、2D 标签叠加、粒子/旋转等状态更新、以及 TWEEN.update() 推进动画:

loop() {
  requestAnimationFrame(() => this.loop())
  this.renderer.render(this.scene, this.camera)
  if (this.rotatingApertureMesh) this.rotatingApertureMesh.rotation.z += 0.0005
  if (this.css2dRender) this.css2dRender.render(this.scene, this.camera)
  for (const p of this.particleArr || []) {
    p.updateSequenceFrame()
    p.position.z += 0.01
    if (p.position.z >= 6) p.position.z = -6
  }
  TWEEN.update()
}

踩过的坑分享给大家,少走些弯路

  1. 坐标系不匹配THREE.Shape 只认平面坐标。你的 GeoJSON 如果和 Three.js 的绘制坐标不一致,就会出现“地图飞走了”的尴尬,需要先做投影/坐标转换。
  2. 空洞(holes)处理:把 ring 直接塞进 Shape,没有显式处理 shape.holes。一旦你的 GeoJSON 带内环(岛/湖泊/凹洞),不处理 holes 就会“该挖的地方没挖开”。
  3. bevel 参数太大:倒角太厚会让面数暴涨,性能变差。一般从小 bevel 开始试,满足质感再加料。
  4. 数据点顺序:点序自交或乱序时,Shape 可能生成失败,或者“看起来像被折弯”。这类问题通常要先检查几何数据本身。