“背景这一节,记录了我制作地图组件的背景,想看热力图设计思路的,可以直接跳过这一节。
”
这个功能其实来自于我最近在做的一个地图组件,还正在开发。
作为一个地图组件,该有的功能都要有吧。那么,热力图的功能不能少吧?
市面上地图 SDK
已经非常多了,为什么我还要再搞一个地图组件?
我当然不是要重新开发一个 地图SDK
了,毕竟市面上的 MapBox.JS、Leaflet、OpenLayers、ArcGIS.JS 等,都已经非常优秀了,而且地图引擎对计算机图形学的技能要求很高,以我目前的能力想要完成类似上述的任何一个地图引擎都是极其困难的。
所以,我想要做的其实是一个更加抽象的地图组件,对外API保持一致,底层 地图SDK
可以自由更换。以解决以下两大的问题:
地图 SDK
的 API 差距比较大第一个问题好理解,不同的 SDK 嘛,提供的接口肯定不太一样,当我们因为各种原因需要切换地图 SDK
的时候,相关的业务代码就不得不再做迁移。因此,设计一个更为抽象的 API,使其能够适配各种底层地图 SDK
的方案,就是我制作该地图组件核心动力。
“我对于 GIS 相关的认知,多数来源于项目实践,所以了解比较浅薄,请谅解。
”
我们要知道,地图引擎渲染出来的内容,来自于地图的图层服务
。
一般情况下,每个地图厂商,都会提供自己的地图 SDK
及图层服务
,比如我们常见的高德地图、百度地图等。
地图 SDK
和图层服务
之间,通过约定好的图层协议
进行通讯。
每个厂商各自搞一套肯定是不友好的,为了能够统一,OGC(开放地理空间信息联盟)提出了很多套和地图服务相关的标准协议,其中就包括涉及图层服务
的 WMS 协议。
地图 SDK
只需要做好对 WMS 协议
的支持,图层服务
按照 WMS 协议
的标准提供,两者就能相互解耦,像下面这样。
image.png
但是,即使是WMS 协议
,里面也有非常多的针对不同图层格式的标准,每个地图 SDK
支持的标准都是有限的,而图层服务
提供的标准又是各种各样的,所以还是会出现有的图层服务
需要特定的地图 SDK
才能够解析的情况(比如现在的高德地图)。当然他们可能有着各种各样的原因,版权问题,安全问题等。
所以我需要一个更好的方案,让我的地图组件做到 Write once, run anywhere.
。
想要做到通用,我就不应该从变化多端的图层协议
入手。
事实上,所有地图有一个更加统一的东西,叫做Viewport
。你想查看地图,总要有一个坐标
吧,想放大缩小,要有一个缩放级别
吧。有的地图还支持旋转和倾斜。这几个特性合起来,就构成了地图的 Viewport
。而几乎所有地图,都必须有这几项内容。而我只需要抽象Viewport
这一个特性。
“对于不支持旋转、倾斜的地图 SDK,将这两个属性锁定为 0 即可。
”
好在现在绝大部分的地图 SDK
内置的投影模式都是Web墨卡托投影。我的Viewport
层设计起来方便多了(当然对于经纬度投影
,我也设计了对应的方案,至于还有其他小众的投影模式,我就暂不支持了,我们也没有这类场景。
由于只抽象了Viewport
这一特性,确实减少了适配不同地图 SDK
的工作量,但反之而来的是,地图上需要呈现的功能,需要由我自己实现(例如散点,飞线,矢量图形等)。这个也确实,不同地图 SDK
支持的散点、图形都不太一样,我就是没法抽象的,所以只能自己干了,其中就包括这次讲到的热力图层
。
设计完成之后的地图组件架构如下:
image.png
为了最终能实现海量点
、热力图
、3D模型渲染
等功能,我在地图组件中引入了ThreeJS
,并将ThreeJS
与高德地图、Mapbox两个地图 SDK
做好了矩阵的同步(这里暂时不展开讲了)。
既然引入了ThreeJS
,我首先想到的是,是否有现成的,基于ThreeJS
实现的热力图方案呢?
我确实也找到了一些,但是最终都没有采用。因为这些方法,大同小异,都是先利用 canvas
,按像素的方式绘制出热力图,再通过 CanvasTexture
的方式引入 ThreeJS
。
预先用 canvas
绘制的方式有一个弊端,就是当你调整了地图的缩放级别后,必须重新绘制一次 canvas
的内容,以适配当前的 Viewport
。不然你移动了视口,原来看不见的位置,现在能看见了,那热力图不也得重新画么。
但是,在 canvas
上重绘热力图的效率是非常低的,和 canvas
的分辨率、热力点数量都有关。当你给 canvas
设置太低的分辨率,绘制出来的图形精细程度就比较低,很难看。如果设置了较高分辨率,那就无法做到实时渲染。
“实测,利用 heatmap.js 在分辨率为 1024x768 的 canvas 上动态绘制20个热力点,帧数只有10~20。 测试机器配置为: CPU i5-1135g7 显卡 MX 450
”
对于市面上基于 canvas
实现的地图,都会遇到渲染效率低的问题,无法做到实时渲染,比如高德地图的2D模式:热力图-自有数据图层-示例中心-JS API 示例 | 高德地图API (amap.com)
而基于 WebGL
实现的地图,效果则相对好不少,如 MapBoxGL
、高德地图3D模式
。
由于我的地图架构设计,这些功能是一定不能依赖地图本身的,我必须自己实现。
不熟悉 ThreeJS
的我,绕了不少弯路,最终实现了开头的效果。
我们先来分析 2D 模式的热力图,看看热力图算法到底是怎样实现的。
通过查看 heatmap.js 的逻辑,我们可以看到,热力图有两个重要的参数:gradient
和 radius
。
image.png
热力图,其实就是在地图上的某个位置,画一个半径为 radius
的圆,圆心的值为1,向外辐射逐渐递减,一直到圆周上降为0(如下图)。
image.png
而其中 gradient
参数指的是,这个圆内,特定的关键值所代表的颜色,关键值之间的颜色则是线性过度。形成下面这样的效果。
image.png
热力图的数据,需要包含3个值:x
、y
、count
。分别代表热力点的位置和热力值。同时,你可以提供一个最大值 max
或由热力图工具自动计算出一个合适的 max
。这样,你的热力点的中心值,就是count
/max
。再从 gradient
色卡中匹配颜色。
当出现两个热力点,且两点之间的距离小于 半径
x2 时,两个点辐射出的热力将相互干涉。发生干涉后,两点之间的位置的热力值将比原来要高。
假设下面这个热力值为 1 的点,在距离该点 75%
辐射半径的位置有一个点A,那么点A的热力值应该为 0.25。
image.png
当出现另一个距离较近的,热力值为 0.5 的点,且到点A的距离为 50%
辐射半径。如下图所示,则热力点A的热力值为 1 * (1 - 0.75) + 0.5 * (1 - 0.5)
= 0.5
。
image.png
叠加了颜色后,效果如下:
image.png
heatmap.js 绘制热力图的原理大概如下:
max
的值单色渐变圆
“部分代码:
”
// 根据 count 创建渐变颜色
var grd = ctx.createRadialGradient(x, y, 0, x, y, radius);
grd.addColorStop(0, `rgba(0, 0, 0, ${count/max})`);
grd.addColorStop(1, `rgba(0, 0, 0, 0)`);
ctx.fillStyle = grd;
// 画圆
ctx.arc(x, y, radius, 0, 2 * Math.PI)
这一步的目的在于通过不断绘制单色渐变圆,使它们的颜色叠加到一起,实现热力干涉的效果。
然后你就可以得到这样一张图:
image.png
3 . 上色,遍历上图的每一个像素点,获得其透明度(0~1),再匹配 gradient
得到具体颜色,进行像素替换,你就得到了最终看到的热力图。
image.png
“分析了 canvas 的绘图方式,我们将上面提到的 1、2、3 点搬运到 ThreeJS就可以了。但是还是有一些细节不一样的。
”
第一步数据处理我就不详细说了,遍历,没啥特别的。
文章最开头的那张动图效果,热力图会随着地图视野的变化,而呈现不同的样子,并且是实时的
。
canvas
的绘图性能非常差,如果要实时渲染,在一帧的时间里,对N个热力点进行绘图,再对所有像素进行着色,是不可能的。
所以我使用了 InstancedMesh
的方案进行单色圆的绘制。
// 使用 canvas 构造用于热力图纹理的渐变圆
const canvas2d = document.createElement('canvas');
canvas2d.width = 100;
canvas2d.height = 100;
const ctx = canvas2d.getContext('2d');
if (ctx) {
const grd = ctx.createRadialGradient(50, 50, 0, 50, 50, 50);
grd.addColorStop(0, 'rgba(255,255,255,1');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, 100, 100);
}
// 将 canvas 的作为 ThreeJS 的纹理备用
this.heatmapTexture = new CanvasTexture(canvas2d);
然后,我将 0 ~ 1 的热力值划分为 20 份(大约每 5% 为一份),所有 0 ~ 5% 的点,都视为 5%, 5 ~ 10% 的点都视为 10%,以此类推。(热力图本身的作用是查看宏观的热力分布,5%的精度丢失肉眼基本无法察觉。精度我也作为了参数,如果有需要更精确的可以随时调整)
const precision = 20;
// pointsArray 是长度为 20 的数组,数组中是已经按照精度规整后的点数组
pointsArray.forEach((points, index) => {
// 按精度生成透明度
const opacity = (index + 1) / precision;
// 按透明度生成一个平面,使用上面生成的渐变圆为纹理
const mesh = new InstancedMesh(
this.planeGeometry,
new MeshBasicMaterial({
opacity,
// 这里注意,纹理的 blending 要设置为叠加模式,这样才能实现干涉效果
blending: AdditiveBlending,
depthTest: false,
transparent: true,
map: this.heatmapTexture,
}),
points.length,
);
const obj = new Object3D();
// 将当前精度下的撒点位置更新到 InstancedMesh 中
points.forEach(({ x, y }, i) => {
obj.position.set(x, y, this.z);
obj.updateMatrix();
mesh.setMatrixAt(i, obj.matrix);
});
this.heatmapObj3D.add(mesh);
});
“
InstancedMesh
是一种特殊的 Mesh,在形状
和材质
都一样的情况下,它可以复制大量仅矩阵变换
不同的物体。(简单说就是,InstancedMesh
可以影分身,把本体复制出很多个,放在不同的位置)。 你问普通的 Mesh 行不行?实时渲染的时候,哪怕性能再高,也扛不住每帧对几十上百万的海量点的遍历。InstancedMesh
的方案我只按精度生成了 20 个Mesh
。所以你说呢?”
上色前要先准备调色板。
// 颜色配置 (可由参数修改)
const colors = [
[0, "rgba(0, 0, 255, 0)"],
[0.1, "rgba(0, 0, 255, 0.5)"],
[0.3, "rgba(0, 255, 0, 0.5)"],
[0.5, "yellow"],
[1.0, "rgb(255, 0, 0)"]
]
const canvasColor = document.createElement('canvas');
canvasColor.width = 256; // 调色板 256 精度
canvasColor.height = 1;
const ctxColor = canvasColor.getContext('2d');
if (ctxColor) {
const grd = ctxColor.createLinearGradient(0, 0, 256, 0);
// 遍历颜色配置,创建渐变
colors.forEach(([percent, color], index) => {
grd.addColorStop(percent, color);
});
ctxColor.fillStyle = grd;
ctxColor.fillRect(0, 0, 256, 1);
}
// 创建调色板材质
const colorTexture = new CanvasTexture(canvasColor)
我使用了后期处理的方式,为 ThreeJS
整体上色。
// 自定义一个 shader
const HeatmapShader = {
uniforms: {
tDiffuse: { value: null },
opacity: { value: 1 }, // 整体透明度(参数可调)
colorTexture: { value: colorTexture }, // 调色板材质
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: `
uniform float opacity;
uniform sampler2D tDiffuse;
uniform sampler2D colorTexture;
varying vec2 vUv;
void main() {
// 得到一个像素点的 rgba 颜色
vec4 texel = texture2D( tDiffuse, vUv );
// 以 alpha 值作为热力
float alpha = texel.a;
// 从色阶表中取得该热力对应的颜色
vec4 color = texture2D( colorTexture, vec2( alpha, 0 ));
// 过滤透明度特别低的区域(否则热力图边界会出现白边)
gl_FragColor = opacity * step(0.04, alpha) * color;
}`,
};
this.shaderPass = new ShaderPass(HeatmapShader);
this.composer.addPass(this.shaderPass);
this.composer.render();
ThreeJS
文档中对于 Composer
讲述的比较少,这个比较遗憾。我也只是看了少量文档和案例,仿照着写了一下。https://threejs.org/docs/index.html#manual/zh/introduction/How-to-use-post-processing
然后,我们就可以得到了文章开头的热力图的渲染效果了。
关于热力图的实现方案,有更好的 idea 欢迎一同探讨。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8