用纯原生 HTML/JS 重写了一套 Three.js 3D 地图。零构建、零依赖(除 Three.js CDN)、单文件直接扔到 PHPStudy 就能跑。本文完整记录技术选型、核心实现、以及一个导致地图完全不显示的 GeoJSON 嵌套层级 Bug 的排查全过程。
缘起
在做数据可视化产品线时,需要一个可复用的 3D 地图组件,从墨卡托投影到 ExtrudeGeometry 拉伸建模,再到光柱、粒子、CSS2D 标签,完整度很高。
- 纯原生 HTML/JS
- Three.js 用经典
<script>标签引入(r128),不碰 ES Module importmap - Canvas 程序化生成全部纹理,不加载任何外部 PNG
- 单文件交付,一个 HTML 搞定一切
最终效果:浙江省 90 个区县级行政区,3D 拉伸 + 光柱标记 + 呼吸光环 + 扫描线 + 上升粒子 + hover 高亮 + click 飞行聚焦。
技术架构
整体数据流很清晰:
arduino
GeoJSON 数据
│
▼
墨卡托投影 + 包围盒归一化
│
▼
ExtrudeGeometry 拉伸建模(Shape → 3D Mesh)
│
├──► 光柱标记
├──► 呼吸光环
├──► 扫描线
├──► 上升粒子
└──► CSS2D 标签
│
▼
OrbitControls 交互(hover 高亮 / click 飞行聚焦)
核心实现拆解
1. 墨卡托投影与坐标归一化
经纬度不能直接喂给 Three.js,得先转成平面坐标。用标准的墨卡托投影公式:
javascript
function geoMercator(lng, lat) {
var x = (lng * 20037508.34) / 180;
var y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180);
y = (y * 20037508.34) / 180;
return { x: x, y: y };
}
投影后的坐标范围很大(百万级),需要计算所有要素的包围盒,归一化到 ±40 单位范围内:
javascript
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
var range = Math.max(maxX - minX, maxY - minY);
var scale = range / TARGET_SIZE;
// normalize(lng, lat) → { x, y } 落在 ±40 内
这样不管是哪个省、哪个国家,地图都会自动居中且尺寸一致。
2. ExtrudeGeometry 3D 拉伸建模
这是整个地图的核心。遍历 GeoJSON 的每个区县,将外环坐标转为 Three.js Shape,其余环作为孔洞(处理飞地/岛屿),通过 ExtrudeGeometry 拉伸成立体模型:
javascript
coords.forEach(function(polygon) {
polygon.forEach(function(ring, ringIdx) {
if (ringIdx === 0) {
// 第一个 ring = 外环
var shape = new THREE.Shape();
ring.forEach(function(ll, i) {
var n = normalizer.normalize(ll[0], ll[1]);
if (i === 0) shape.moveTo(n.x, n.y);
else shape.lineTo(n.x, n.y);
});
// 其余 ring 作为孔洞(飞地/岛屿)
for (var h = 1; h < polygon.length; h++) {
var hole = new THREE.Path();
polygon[h].forEach(function(ll) {
var n = normalizer.normalize(ll[0], ll[1]);
// ... hole.lineTo(n.x, n.y)
});
shape.holes.push(hole);
}
var geo = new THREE.ExtrudeGeometry(shape, {
depth: MAP_DEPTH,
bevelEnabled: true,
bevelSegments: 2,
bevelSize: 0.08
});
}
});
});
顶面和侧面用不同材质------顶面亮色、侧面暗色,立体感立刻就出来了。
3. Canvas 程序化纹理(零图片依赖)
所有视觉纹理全部用 Canvas 2D API 动态绘制:
- 光柱纹理:线性渐变 + 径向渐变,从底部亮白向上过渡到透明青色
- 呼吸光环 :径向渐变圆环,配合
sin函数做缩放 + 透明度脉冲 - 底盘光圈:1024×1024 Canvas 绘制同心圆、虚线环、72 刻度线,缓慢旋转
- 扫描线 :横向渐变条,沿 Y 轴 sin 往复运动,
AdditiveBlending混合 - 上升粒子:Sprite + 径向渐变纹理,50 个粒子随机位置循环上升
以光柱纹理为例:
javascript
function createPillarTexture() {
var canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 512;
var ctx = canvas.getContext('2d');
var grad = ctx.createLinearGradient(0, 512, 0, 0);
grad.addColorStop(0, 'rgba(0,212,255,0.9)');
grad.addColorStop(0.4, 'rgba(0,180,255,0.4)');
grad.addColorStop(1, 'rgba(0,120,200,0)');
ctx.fillStyle = grad;
ctx.fillRect(32, 0, 64, 512);
// ... 叠加径向光晕
return new THREE.CanvasTexture(canvas);
}
代码量比直接贴 PNG 大,但换来了单文件可移植------不需要操心图片路径、CORS、打包配置。
4. 交互系统
Raycaster 拾取 :mousemove 时将屏幕坐标转为 NDC,与所有 provinceMeshes 求交,命中则改 emissive 发光。
飞行聚焦 :click 时用 easeOutQuart 缓动函数在 800ms 内将相机从当前位置插值到目标区县上方,比直接 lookAt 丝滑很多。
数据面板 :hover 时右上角 HTML 面板显示区县名、ADCODE、模拟数据值,带进度条动画。面板用 CSS backdrop-filter 做毛玻璃效果。
踩坑实录:一个 NaN 导致地图完全消失
这是本次开发最值得记录的部分。
初版写完打开页面------Loading 消失了,但 3D 区域一片空白。控制台报:
vbnet
THREE.BufferGeometry.computeBoundingSphere():
Computed radius is NaN.
The "position" attribute is likely to have NaN values.
根因:GeoJSON MultiPolygon 坐标嵌套层级理解错误
GeoJSON 的 MultiPolygon 正确结构是三层嵌套:
less
coordinates = [ polygon, ... ] // Level 0: MultiPolygon
polygon = [ ring, ... ] // Level 1: ring[0]=外环, 其余=孔洞
ring = [ [x,y], [x,y], ... ] // Level 2: 坐标点数组
但我写代码时多套了一层 forEach,把 ring 当成了 polygon 来处理:
javascript
// ❌ 错误:多了一层嵌套
coords.forEach(multiPolygon => {
multiPolygon.forEach(polygon => {
var outer = polygon[0]; // polygon[0] 取到的是单个点 [x,y]!
outer.forEach(ll => {
// ll 此时是数字(经度或纬度),ll[0] = undefined → NaN
});
});
});
polygon[0] 取到的是 [120.17, 30.25] 这样的单点坐标,再 forEach 时 ll 已经是数字 120.17,ll[0] 是 undefined,传给投影函数就产生了 NaN。Three.js 拿到全是 NaN 的顶点数据,computeBoundingSphere 算不出包围球,整个几何体就"消失"了。
修复:去掉多余层级 + 防御性校验
修复后的代码:
javascript
// ✅ 正确:coords → polygon → ring 三层
coords.forEach(function(polygon) {
polygon.forEach(function(ring, ringIdx) {
if (ringIdx === 0) {
var shape = new THREE.Shape();
ring.forEach(function(ll, i) {
if (!ll || ll.length < 2) return; // 防御性过滤
var n = normalizer.normalize(ll[0], ll[1]);
if (isNaN(n.x) || isNaN(n.y)) return; // NaN 拦截
if (i === 0) shape.moveTo(n.x, n.y);
else shape.lineTo(n.x, n.y);
});
}
});
});
同时排查并修复了三处相同问题:① buildMap 的 Shape 构建 ② makeNormalizer 包围盒计算 ③ fallback 中心点计算。
刷新页面,90 个区县从底部依次升起,地图正常显示。
教训
处理 GeoJSON 前,先
console.log确认实际嵌套深度。
不同数据源(DataV.GeoAtlas、高德、天地图)的 Polygon/MultiPolygon 层级可能有差异,不能凭假设写遍历代码。NaN 是 Three.js 最常见的"静默杀手"------它不会直接报错崩溃,但会让几何体悄无声息地消失。在坐标入口加 isNaN() 防御性校验,能省很多排查时间。
功能清单
| 功能 | 状态 | 说明 |
|---|---|---|
| 3D 拉伸地图 | ✅ 完成 | 90 个区县 ExtrudeGeometry 建模,顶面/侧面双材质 |
| 墨卡托投影 | ✅ 完成 | 经纬度→墨卡托→归一化,自动居中缩放 |
| 入场动画 | ✅ 完成 | 90 个区县从底部依次升起(错峰 15ms),easeOutCubic |
| 光柱标记 | ✅ 完成 | 交叉光柱 + 底部光点 + 呼吸光环,Canvas 纹理 |
| CSS2D 标签 | ✅ 完成 | 区县名称随 3D 位置渲染,hover 放大高亮 |
| Hover 高亮 | ✅ 完成 | Raycaster 拾取,emissive 发光,信息面板弹出 |
| Click 聚焦 | ✅ 完成 | easeOutQuart 缓动飞行到目标区县 |
| 自动旋转 | ✅ 完成 | 按钮开关,用户交互后自动暂停 |
| 底盘光圈 | ✅ 完成 | 同心圆 + 虚线 + 刻度,缓慢旋转 |
| 扫描线 | ✅ 完成 | 横向光带沿 Y 轴 sin 往复运动 |
| 上升粒子 | ✅ 完成 | 50 个 Sprite 粒子随机分布、循环上升 |
| 边框线 | ✅ 完成 | 每个区外环 LineLoop 青色描边 |
| FPS 统计 | ✅ 完成 | 右下角实时帧率 + 区县计数 |
| 重置视角 | ✅ 完成 | 一键回到初始相机位置 |
| 真实数据接入 | ⏳ 待做 | 当前 value 为模拟值,需对接业务 API |
| 飞线动效 | ⏳ 待做 | 区县之间的迁徙/连接飞线(贝塞尔曲线 + 纹理动画) |
| 区域下钻 | ⏳ 待做 | 点击区县加载下级 GeoJSON(乡镇/街道级) |
开发时间线
| 时间 | 节点 | 说明 |
|---|---|---|
| 09:56 | 需求提出 | 参考掘金文章生成 Three.js 3D 地图 |
| 10:00 | 初版(中国地图) | ES Module importmap 方式,加载阿里云 DataV 全国 GeoJSON |
| 10:06 | 需求调整 | 改用本地浙江省 GeoJSON + 纯原生 HTML,写入 PHPStudy 目录 |
| 10:09 | 第二版完成 | Three.js r128 script 标签,883 行,31.3KB |
| 10:11 | Bug 反馈 | 地图空白,控制台 NaN 错误 |
| 10:11 | Bug 修复 | 修正 MultiPolygon 坐标嵌套层级,增至 909 行 / 32.5KB |
| 12:16 | 日志整理 | 归档技术决策、Bug 根因和经验教训 |
从需求提出到 Bug 修复,实际开发时间约 15 分钟。
几点经验
1. 参考 ≠ 照搬。 理解核心原理(墨卡托投影、ExtrudeGeometry、Raycaster)后,用原生 JS 重写反而更轻量------少了 Vite 构建、npm 依赖、node_modules,一个 HTML 文件扔到任何静态服务器都能跑。
2. Canvas 纹理是单文件方案的关键。 虽然代码量比贴 PNG 大,但零图片依赖意味着不用操心路径、CORS、打包。对于需要快速交付的演示场景,这个 trade-off 很值。
3. 性能要留意。 90 个区县的 ExtrudeGeometry 顶点数不少,老旧设备可能掉帧。后续优化方向:
BufferGeometryUtils.mergeGeometries()合并几何体减少 draw call- 重复元素用
InstancedMesh - LOD(Level of Detail)分级渲染
4. 数据还是模拟的。 当前光柱高度和面板数值是 hash 生成的随机值,仅用于视觉演示。对接真实业务数据时,把 provinceData[fIdx].value 替换为 API 返回值即可,注意根据实际值域调整映射比例。
@漏刻有时
