上一篇,本猿立了一个 flag:换一套坐标系,看同样的账会不会又变个算法。
现在来还这笔账。
上一篇我们量的是一张贴在地面上的彩色画布——半径 70 是 70 个像素,落地是个 18.557 km 的椭圆,模糊度滑到最右反而变成硬边实心圆。那篇里所有的账,都发生在"平面"上:画布多大、笔刷多宽、颜色多深。
这一次,同一张画布被竖起来了——不再贴在地上,而是被当成一张高度图,一格一格地长出地面。
我是 Cesium酱,一只在 WebGIS 领域摸爬滚打多年的前端猿。这个系列写到第 52 篇,规矩一直没变:只要面板上有数字,就得拿进几何里对一遍账。而这一篇的账,比上一篇更离谱一点——因为这次被"算错"的不是长度,是高度。
本文目标:把一份二维热力数据"立起来"变成三维地形的全过程拆开——为什么一半的地面会落在同一个高度上、为什么 60×60 个采样点只读到了画布的 9.28%、为什么七个滑块里有一个从出生就没生效过、以及为什么相机公式给的 77.5 km 只装得下三成网格。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么「把热力图立起来」不只是一次拉伸?
🎯 背景现状:二维热力图能告诉你"哪里热",但说不出"热多少"。做汇报的时候,一张平平的彩色图很难让人有体积感——同样的数据,立起来之后,高的地方一眼就能看出来,低的地方自然沉下去。于是"三维热力图"(也叫热力地形、数据海拔)成了数据大屏上的常客。
- 🎯告警密度地形:把网格化的告警数量当海拔,一眼看出哪个片区"堆积"得最高
- 🎯客流热力沙盘:商圈人流采样值抬升成山丘,做选址与疏导预演
- 🎯设备负载地图:机柜、基站、充电桩的负载值铺成起伏,运维一眼定位重载区
- 🎯环境监测:PM2.5、噪声、温度等格网观测值做成"污染山脉"
🤦 传统痛点:把二维数据抬成三维,听起来只是"给每个格点加个 z",真做起来每一环都有坑。
| 方案 | 问题 |
|---|---|
| 每个数据点画一根柱子 | 只表达点,不表达面;柱子之间全是缝,看着像刺猬不像地形 |
用PolygonGraphics逐个抬高 | 一个格点一个实体,几千个实体进场景,帧率当场跪下 |
| 找现成的三维热力图库 | 大多只支持网格/体渲染,和 Cesium 的椭球坐标系之间还得再套一层转换 |
| 自己贴一张半透明彩色画布 | 那还是二维的——有颜色,没高度,转视角就露馅 |
💡 本文方案:这一次我们不去新造轮子,而是复用上一篇那张画布,把它从"贴图"改成"高度图":
1️⃣生成热力画布:和上一篇完全同一份引擎——撒点、落笔、染色,得到一张200×194的 RGBA 画布;
2️⃣按网格采样:在经纬度上铺一张60×60的采样网,每个格点去画布上取一次颜色,顺手把那个像素的 alpha 读成强度;
3️⃣强度变高度:高度 = 基准高度 + 强度 × 高度倍率,把每个采样点抬到对应的海拔上;
4️⃣织成三角网:相邻格点两两成面,织出一片连续的起伏地形,扔给一个Primitive。
✨ 一句话总结:把二维热力图当成一张高度图,用画布 alpha 的能力,把平面上的"深浅",一格一格地"翻译"成三维里的"高低"。
二、核心技术原理:从一张彩色画布到一片会起伏的地形
2.1 数据结构:四个场景,一个种子,一份引擎
三维版没有自己的数据层——它和上一篇共用同一个heatmap-lib。四个场景、同一个种子:
// 源码级剧透:heatmap-lib/heatmap-data.tsexportconstheatmapScenes:HeatmapScene[]=[{id:'beijing-clusters',label:'北京多中心',bounds:{west:114.9,south:38.4,east:118.0,north:41.4},defaultRadius:70,pointCount:120,description:'三个高值核心的高斯聚集点'},{id:'shanghai-band',label:'上海沿江带状',bounds:{west:120.8,south:30.6,east:122.3,north:31.9},defaultRadius:55,pointCount:100,description:'沿江线性分布、随位置波动的高值'},{id:'guangzhou-ring',label:'广州环形',bounds:{west:112.8,south:22.5,east:114.0,north:23.6},defaultRadius:50,pointCount:80,description:'环形分布的模拟数据'},{id:'chengdu-core',label:'成都核心聚集',bounds:{west:103.6,south:30.3,east:104.6,north:31.1},defaultRadius:65,pointCount:60,description:'中心高密度聚集模拟数据'}]- 🔑单位还是"度":
bounds写的是经纬度,四个场景的跨度都在 1°~3° 之间——上一篇算过的那笔账(1° ≈ 85~111 km)在这里继续有效; - 🔑种子固定:默认
20260827,所以同一次运行、同一个场景,画出来的地形永远一样;点"随机生成"只是把种子换成Math.floor(Math.random() * 1e9)——换种子,不换公式; - 🔑引擎是同一份:
createHeatmapCanvas()一个字没改,三维版和二维版读的是同一张画布——只是读的通道不一样。这一点,后面 3.4 节会算成一笔账。
2.2 完整技术路线
我们设计的完整技术路线:
核心亮点:第 3 步"把 alpha 读成高度"是本文的杀手锏 🔪——它让上一篇那张画布里的每一个像素,都同时背了两个身份:一个是颜色,一个是海拔。
2.3 为什么直接拿画布的 alpha 当高度
抬高度这件事,最少有四种做法。我们选了最"抠"的那一种:
| 方案 | 原理 | 性能 | 复杂度 |
|---|---|---|---|
| 每个数据点画一个柱体 | CylinderGraphics逐个抬升 | 🐢 实体数 = 点数,场景被拖垮 | 低 |
| 每个格点建一个多边形 | PolygonGraphics+extrudedHeight | 🐢 几千个实体 + 各自的批次 | 中 |
| 用着色器实时算高度 | 顶点着色器里采样纹理 | ⚡ 快,但要走CustomShader | 高 |
| 把画布 alpha 当高度场(本文) | 采一次画布,CPU 端算出 z,织一张三角网 | ⚡ 一张网格一个Primitive | 低 |
简单说:画布的 alpha 本来就已经是"强度"了,我们只是没让它继续当强度用。