之前写过一篇文章介绍了使用ECharts GL实现立体地图 => 用 ECharts GL 把地图「立」起来,那如果ECharts GL满足不了需求,就可以考虑使用Three.js啦。
Three.js
three.js 是一个在浏览器里把 3D 画出来的图形库。你可以把它想成搭舞台,那咋把舞台搭起来呢?
Scene:搭舞台的"背景板"(灯光、模型、网格)。Camera:决定你从哪个方向看(透视相机更像"人眼"视角,正交相机更像"测绘"视角)。Renderer:负责"出画面"(每一帧把scene + camera渲染到画布)。
看一眼最小骨架,后面不迷路:
js
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 网格,就等于把数据变成了可以被光照和相机看到的模型。
实现流程
- 读取 GeoJSON:先把
Polygon统一成和MultiPolygon一样的数据形态; - 遍历每个 polygon ring:把点序列写进
THREE.Shape(moveTo/lineTo) - 用
ExtrudeGeometry把二维轮廓"拉起来"成三维实体(depth 决定高度,bevel 决定边缘质感) - 给几何体分配材质组合:上表面更亮、侧边更有层次,立体感才会成立
- 用
Box3.expandByObject()算中心与尺寸:一键对齐相机,让视角永远落在地图上 - 初始化
CSS2DRenderer:在动画循环里把 WebGL 和 2D 标签叠加起来,再驱动光柱/粒子等小动效
1) 统一 GeoJSON:让 Polygon 和 MultiPolygon 数据结构一致
很多 GeoJSON 在实际项目里会"有时是 Polygon,有时是 MultiPolygon"。如果你写死一套遍历逻辑,就会出现:某些地区根本没被绘制出来,或者逻辑分支越写越乱。
我是这样实现的:如果几何类型是 Polygon,就把 coordinates 包一层,让它变成 MultiPolygon 风格的二维数组。这样后续只写一套循环就够了。
js
// 统一 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 的材质传数组是为了"上表面更亮、侧边更有阴影感"。
js
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 也跟着指向它。
js
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 让它能跟随相机正确投影。
js
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),标签就会"自动跟镜头走"。
光柱
光柱的漂亮之处在于"看起来更立体",实现方案:两张切图交叉,再配合透明渲染参数避免穿帮。
js
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 就行
js
const light = createLightPillar(...lightCenter, heightScaleFactor, lightPillarTextureUrl)
light.position.z = 0.31
mapGroup.add(light)
5) 让动画活起来
你不需要把每一帧都烧到极致,但需要让"画面在动",让它不显得生硬:
- 背景光圈缓慢旋转
- 粒子沿 z 轴上升再重置
- 2D 标签每帧由 CSS2DRenderer 重新渲染
核心循环就四件小事:WebGL 渲染、2D 标签叠加、粒子/旋转等状态更新、以及 TWEEN.update() 推进动画:
js
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()
}
踩过的坑分享给大家,少走些弯路
- 坐标系不匹配 :
THREE.Shape只认平面坐标。你的 GeoJSON 如果和 Three.js 的绘制坐标不一致,就会出现"地图飞走了"的尴尬,需要先做投影/坐标转换。 - 空洞(holes)处理 :把 ring 直接塞进
Shape,没有显式处理shape.holes。一旦你的 GeoJSON 带内环(岛/湖泊/凹洞),不处理 holes 就会"该挖的地方没挖开"。 - bevel 参数太大:倒角太厚会让面数暴涨,性能变差。一般从小 bevel 开始试,满足质感再加料。
- 数据点顺序 :点序自交或乱序时,
Shape可能生成失败,或者"看起来像被折弯"。这类问题通常要先检查几何数据本身。