纯原生 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 数据可视化地图引擎