Three.js 3D地图开发实录:从GeoJSON到流光飞线,一个纯前端SPA可视化引擎的12个坑

纯原生 HTML/JS,无框架无构建,Three.js r128,单文件起步到产品化交付。

缘起

做数据可视化这些年,ECharts 的 3D 地图用了不少,但始终觉得不够"自由"------想加个自定义 Shader 飞线、想做几何体生长动画、想精确控制辉光范围,处处受限于图表库的封装。

于是决定用 Three.js 从零写一个。

项目代号 LockScope(中文名:瞰境),定位是纯前端 3D 地理数据可视化引擎。从第一版全国地图到最终浙江省地级市产品化交付,经历了 12 个版本迭代,踩了 12 个实打实的坑。这篇文章把整个过程拆开来讲------架构怎么设计、坑怎么填、效果怎么实现。

最终效果:3D 拉伸地图 + 锥形光柱 + Shader 流光飞线 + 辉光后期 + 三配色热切换,全部纯原生实现。

技术选型

考量 选择 理由
3D 引擎 Three.js r128 生态成熟,CDN 直接引,无需 npm
构建工具 <script> 标签,IIFE 封装,ES5 兼容
框架 不引入 Vue/React/JQuery,DOM 操作手写
动画库 requestAnimationFrame + 数学函数,不引入 Tween.js
后期处理 UnrealBloomPass 辉光是科技感的灵魂
标签 CSS2DRenderer HTML 标签比 Sprite 文字灵活得多
地理数据 GeoJSON 本地 JSON,不依赖第三方地图 API

整体思路:能不引的依赖一律不引。交付目录就是一堆静态文件,丢到任何 HTTP 服务器下就能跑。

架构设计

坐标系:Z-up 到 Y-up 的转换

这是第一个需要想清楚的问题。

地理数据经纬度映射到平面后,天然是 Z-up 坐标系------X 向东、Y 向北、Z 是高度。但 Three.js 默认 Y-up,OrbitControls 也是围绕 Y 轴设计的。

如果直接在 Y-up 里构建地图,Z 轴变成了"水平方向",经纬度映射会很别扭。

解决方案是引入一个 root Group:

javascript 复制代码
// 地图内部全部用 Z-up 构建
var root = new THREE.Group();
root.rotation.x = -Math.PI / 2;  // 整体旋转到 Y-up 世界
scene.add(root);

root.add(mapGroup);     // 3D 地图几何体
root.add(markerGroup);  // 光柱 + 标签
root.add(flyGroup);     // 飞线 + 粒子
root.add(decorGroup);   // 装饰环 + 星点

这样地图构建逻辑保持直觉(Z 是高度),渲染层面又兼容了 Three.js 的 Y-up 生态。一个 rotation 解决所有问题。

投影函数

小范围地理数据用简化的等距圆柱投影即可:

javascript 复制代码
function proj(lng, lat) {
  return {
    x: (lng - mapCenter.lng) * mapScale,
    y: (lat - mapCenter.lat) * mapScale
  };
}

自动计算 GeoJSON 包围盒,居中并缩放到 fitSize(默认 38 单位)。省级数据精度足够,国家级需要换 Mercator。

配置三层架构

复制代码
CONFIG(基础配置,不常改)
  ├── geojson 路径、标题、缩放尺寸、拉伸高度
  └── hubCity 飞线枢纽城市

settings(用户可调设置)
  ├── texture / textureUrl   贴图模式
  ├── colorScheme            配色方案
  ├── heightSource + mult    柱高数据源 + 倍率
  ├── 5 个显示开关
  └── cameraView             相机视角

COLOR_PRESETS(配色预设)
  └── teal / gold / red,每套 13 个色值

配色的一个关键设计是 CSS 变量 + JS 颜色双轨制 :Three.js 材质直接读数值颜色,DOM 元素通过 applyCSSColors() 同步 CSS 变量。切换配色时两边一起变。

核心效果实现

1. ExtrudeGeometry:拉伸地图块

把 GeoJSON 的 Polygon/MultiPolygon 转成 THREE.Shape,再用 ExtrudeGeometry 拉伸:

javascript 复制代码
var shape = new THREE.Shape();
shape.moveTo(points[0].x, points[0].y);
for (var i = 1; i < points.length; i++) {
  shape.lineTo(points[i].x, points[i].y);
}

var geo = new THREE.ExtrudeGeometry(shape, {
  depth: CONFIG.extrudeHeight,  // 0.65
  bevelEnabled: true,
  bevelThickness: 0.02,
  bevelSize: 0.02,
  bevelSegments: 1
});

看起来简单,但这里藏着全项目最大的坑

2. 全局 UV:解决贴图碎裂(坑 #1)

ExtrudeGeometry 会为每个 Shape 独立生成 0~1 的 UV。这意味着一张卫星贴图会在每个城市块上各贴一次,视觉上完全碎裂。

解决方案是两遍扫描重写 UV

javascript 复制代码
// 第一遍:遍历所有环,计算全局包围盒
var gMinX = Infinity, gMaxX = -Infinity;
var gMinY = Infinity, gMaxY = -Infinity;
walkRings(geo, function(x, y) {
  gMinX = Math.min(gMinX, x); gMaxX = Math.max(gMaxX, x);
  gMinY = Math.min(gMinY, y); gMaxY = Math.max(gMaxY, y);
});

// 第二遍:重写顶面和底面的 UV
var uv = geo.attributes.uv;
var pos = geo.attributes.position;
for (var i = 0; i < uv.count; i++) {
  var px = pos.getX(i), py = pos.getY(i), pz = pos.getZ(i);
  if (pz >= topZ - bevT || pz <= bevT) {  // 顶面或底面
    uv.setXY(i,
      (px - gMinX) / gSpanX,
      (py - gMinY) / gSpanY
    );
  }
}
uv.needsUpdate = true;

核心思路:把每个城市块的 UV 映射到全局坐标系中的归一化位置,而不是各自的 0~1。这样整张卫星图连续地铺在所有城市块上。

3. 锥形光柱

每个城市一根光柱,用双层圆锥 + 光晕 + 脉冲环:

  • 外锥:CylinderGeometry,Canvas 渐变贴图,AdditiveBlending
  • 内锥:更细更亮,模拟光芯
  • 顶部光晕:Sprite + radial gradient,呼吸缩放
  • 双脉冲环:RingGeometry,错相位扩散

CylinderGeometry 默认沿 Y 轴,而我们的地图在 Z-up 空间里,需要旋转:

javascript 复制代码
coneGeo.rotateX(Math.PI / 2);           // Y 轴 → Z 轴
coneGeo.translate(0, 0, height / 2);    // 底面移到地面

光柱高度通过数据源映射,以 GDP 为例:

javascript 复制代码
var norm = (val - min) / (max - min);   // 0~1
var height = (3 + norm * 6) * multiplier; // 3~9 单位

支持 GDP、人口、企业数、增速、统一、随机六种数据源,倍率 0.3~3.0 可调。

4. 流光飞线:Shader + TubeGeometry

这是视觉上最出彩的部分,也是坑最多的。

为什么不用 THREE.Line?(坑 #6)

THREE.Line 的 lineWidth 在绝大多数浏览器的 WebGL 实现中固定为 1px,无法加粗。设置 lineWidth=3 没有任何效果。

改用 TubeGeometry 管状网格:

javascript 复制代码
var curve = new THREE.QuadraticBezierCurve3(
  new THREE.Vector3(hub.x, hub.y, CONFIG.extrudeHeight),
  new THREE.Vector3(
    (hub.x + pt.x) / 2, (hub.y + pt.y) / 2,
    Math.max(hub.h, pt.h) + Math.max(2.5, dist * 0.18)
  ),
  new THREE.Vector3(pt.x, pt.y, CONFIG.extrudeHeight)
);
var lineGeo = new THREE.TubeGeometry(curve, 80, 0.03, 6, false);

贝塞尔曲线参数顺序(坑 #5)

QuadraticBezierCurve3 的签名是 (start, controlPoint, end),不是 (start, end, controlPoint)

这个坑浪费了不少时间------参数写反后曲线先飞到目标城市上空再折回半空,形成一个异常巨大的弧线,横跨整个屏幕。

Shader 三层动画

Fragment Shader 里叠了三层效果:

glsl 复制代码
// 1. 入场绘制:uDraw 从 0→1,未绘制部分 discard
if (vProgress > uDraw) discard;

// 2. 双正弦脉冲流光
float wave1 = sin(vProgress * 28.0 - uTime * 3.2);
float wave2 = sin(vProgress * 18.0 - uTime * 2.1 + 1.5);
float pulse = (wave1 * 0.5 + wave2 * 0.3 + 0.7);

// 3. 绘制前沿亮头
float head = smoothstep(uDraw - 0.02, uDraw, vProgress) * 1.2;

gl_FragColor = vec4(uColor * pulse + uGlow * head, alpha);

两个正弦波频率和速度不同,形成错落有致的流动感而非机械的等距条纹。

TubeGeometry 的顶点结构(坑 #7)

给 Shader 传 aProgress 属性时,需要知道 TubeGeometry 的顶点排列方式:

复制代码
每一环有 radialSegments + 1 个顶点(不是 radialSegments)

radialSegments=6 时每环 7 个顶点,总共 81 环(80 段 + 1),567 个顶点。如果按 6 个顶点写属性,UV 会错位。

javascript 复制代码
var vertsPerRing = radialSeg + 1;  // 7
for (var i = 0; i <= segs; i++) {
  var pp = i / segs;
  for (var j = 0; j < vertsPerRing; j++) {
    progArr[i * vertsPerRing + j] = pp;
  }
}

粒子拖尾 + 目的地脉冲

每条飞线配 4 个 Sprite 粒子沿曲线运动:

javascript 复制代码
var t = (clock.getElapsedTime() * speed + phase) % 1;
var pos = curve.getPoint(t);
particle.position.copy(pos);
particle.material.opacity = Math.sin(t * Math.PI);  // 两端淡入淡出

目的地放一个 RingGeometry 脉冲圈,每 2 秒扩散一次:scale 从 0.5 到 4.7,opacity 从 0.5 衰减到 0。

飞线颜色硬编码为金黄色(#ffc040),不随配色方案切换------金色在青绿、蓝金、暗红三套底色上对比度都很好。

5. 辉光后期

UnrealBloomPass 是科技感的关键,但参数需要反复调:

javascript 复制代码
bloomPass = new THREE.UnrealBloomPass(
  new THREE.Vector2(w, h),
  0.35,  // strength
  0.35,  // radius
  0.55   // threshold
);

threshold=0.55 是个关键值。设低了整张地图发白,贴图被辉光洗掉;设高了光柱和飞线的发光感出不来。0.55 意味着只有亮度超过中灰的区域才产生辉光。

配合材质设置 emissive: 0x000000, emissiveIntensity: 0,确保辉光只来自 AdditiveBlending 的光柱和飞线,不影响地图贴图。

6. 场景热重建

切换贴图、配色、柱高数据源时,不能刷新页面(会丢失当前视角和选中状态)。通过 rebuildScene() 完成:

javascript 复制代码
function rebuildScene() {
  disposeGroup(mapGroup);
  disposeGroup(markerGroup);
  disposeGroup(flyGroup);
  disposeGroup(decorGroup);
  // 清理星点...
  // 更新配色引用...
  // 重新加载纹理 → 构建地图 → 光柱 → 飞线 → 装饰
}

dispose 必须递归遍历,而且要处理 Array 材质(ExtrudeGeometry 的材质是 [topMat, sideMat])和共享纹理:

javascript 复制代码
function disposeGroup(group) {
  group.traverse(function(obj) {
    if (obj.geometry) obj.geometry.dispose();
    if (obj.material) {
      var mats = Array.isArray(obj.material) ? obj.material : [obj.material];
      mats.forEach(function(m) {
        if (m.map && m.map !== pTex) m.map.dispose();  // 共享纹理不释放
        m.dispose();
      });
    }
  });
  // ...清空 children
}

坑 #8 :strict mode 下禁止 arguments.callee,递归必须用命名内部函数。

交互系统

  • Raycaster 拾取:只检测 cityMeshes(地图块),不含光柱和装饰
  • hoveredMesh 与 selectedMesh 分离(坑 #11):hover 其他城市不会清除点击选中的高亮态
  • 每城市独立材质实例topMat.clone() / sideMat.clone(),hover 时只改当前 mesh 的 material[0].emissive
  • CSS2DRenderer 标签层pointer-events: none,不阻挡鼠标事件
  • 城市信息面板:点击城市弹出 GDP/人口/企业/增速 + 区域排行

纹理系统

除卫星贴图外部加载外,所有纹理通过 Canvas 程序化生成:

纹理 尺寸 用途
pTex 512² 地图顶面(卫星图/地形/纯色)
sTex 128×512 地图侧面(渐变+竖线+高光)
cnTex 64×512 光柱锥面
glTex 128² 光晕 Sprite
ptTex 64² 飞线粒子

性能铁律

  • Canvas 纹理不超过 512²
  • 纹理全局共享不 clone
  • pixelRatio = Math.min(devicePixelRatio, 1.5)
  • 不开阴影,用 AdditiveBlending 模拟发光

12 个坑汇总

# 问题 根因 解法
1 ExtrudeGeometry 贴图碎裂 每 Shape 独立 0~1 UV 两遍扫描全局包围盒重写 UV
2 辉光洗掉地图贴图 emissive + 强 Bloom emissive=0, threshold≥0.55
3 OrbitControls 旋转异常 Z-up 与 Y-up 冲突 root.rotation.x = -π/2
4 锥形光柱方向错误 CylinderGeometry 默认 Y 轴 rotateX(π/2) + translate
5 飞线异常大弧 Bezier 参数顺序写反 (start, control, end)
6 飞线无法加粗 WebGL Line lineWidth=1px TubeGeometry 替代
7 Shader 属性错位 TubeGeometry 每环 radialSeg+1 顶点 vertsPerRing = radialSeg + 1
8 strict mode 报错 arguments.callee 被禁 命名内部函数递归
9 切换配色 DOM 不变 Three.js 与 CSS 两套系统 applyCSSColors() 同步 CSS 变量
10 rebuild 后内存增长 geometry/material 未释放 disposeGroup 递归 dispose
11 hover 清除选中态 单一 currentMesh 变量 hovered/selected 分离
12 bounds is not defined 重构时删了全局变量 改用局部变量

这 12 个坑里,1、5、6、7 是 Three.js 特有且文档里不太会强调的,建议做类似项目前先扫一眼。

产品化

功能稳定后做了三件事:

1. 文件目录分离

将单文件 index.html 拆成标准目录结构:

bash 复制代码
lockscope/
├── index.html
├── css/style.css
├── js/main.js
├── images/terrain_texture.jpg
├── data/js.geojson
└── docs/

2. 文档交付

  • 操作说明 HTML(暗色科技风,与产品风格统一)
  • 技术日志 Markdown(架构、API、踩坑记录、扩展指南)

性能数据

  • 11 个地级市,约 300+ 个顶点环,首屏渲染 < 500ms
  • 常态帧率 60fps(中端笔记本集显)
  • 内存占用稳定(rebuild 后无增长,dispose 验证通过)
  • Bloom 开启后帧率约 45~55fps,可通过控制面板降低光晕强度

写在最后

从第一版到产品化,最大的感受是:Three.js 的 API 不难,难的是坐标系、UV、几何体拓扑结构这些底层概念。一旦理解了 ExtrudeGeometry 怎么生成顶点、TubeGeometry 怎么排列环、UV 怎么映射,剩下的就是组合和调参。

纯原生方案的好处是交付极其简单------一堆静态文件,不需要 node_modules,不需要构建步骤,丢到任何 Web 服务器上就能跑。对于数据大屏这类部署环境受限的场景,这一点非常重要。


瞰境 LockScope · 基于 Three.js 的 3D 数据可视化地图引擎

相关推荐
Vuji1 小时前
Pi 插件解剖|ssh.ts:只用 221 行,让 Agent 直接在远程机器干活
前端·人工智能·agent
clawdashi2 小时前
2026 办公 Agent 选型指南:按场景分五类,一次讲清
前端·人工智能
无糖可可果2 小时前
从零看懂 JWT 登录鉴权:一个 React Demo 的完整拆解
前端·javascript
驳是2 小时前
代码洁癖患者会喜欢的几款 CLI 小工具
前端
Csvn2 小时前
🛋️ requestIdleCallback:把非关键任务塞进浏览器的"空闲时间",告别卡顿
前端
江华森2 小时前
从 fork 到协程:一次完整的操作系统并发编程实操
前端·程序员
四千岁2 小时前
稀疏向量BM25Retriever不支持中文怎么办?jieba来帮忙
前端·javascript·后端
这是个栗子2 小时前
【JS代码分析】前端鉴权基础:Token 的本地存储与状态重置实践
开发语言·前端·javascript
颜进强2 小时前
14 - OpenSpec 老页面改造骨架:定位 + 增量 + 回归三件套
前端·后端·ai编程