关于我纯手工用 Three.JS 撸了一个热力图组件的那件事

4,130 阅读11分钟

先看效果

动图较大(约40M)

Video 2021-10-17 203233-1.gif

热力图数据来自高德地图的热力图 Demo

背景

背景这一节,记录了我制作地图组件的背景,想看热力图设计思路的,可以直接跳过这一节。

这个功能其实来自于我最近在做的一个地图组件,还正在开发。

作为一个地图组件,该有的功能都要有吧。那么,热力图的功能不能少吧?

关于为什么要再开发一个地图组件

市面上地图 SDK已经非常多了,为什么我还要再搞一个地图组件?

我当然不是要重新开发一个 地图SDK 了,毕竟市面上的 MapBox.JS、Leaflet、OpenLayers、ArcGIS.JS 等,都已经非常优秀了,而且地图引擎对计算机图形学的技能要求很高,以我目前的能力想要完成类似上述的任何一个地图引擎都是极其困难的。

所以,我想要做的其实是一个更加抽象的地图组件,对外API保持一致,底层 地图SDK 可以自由更换。以解决以下两大的问题:

  1. 不同地图 SDK 的 API 差距比较大
  2. SDK 支持的图层协议有限,有的地图厂商只提供私有图层协议

第一个问题好理解,不同的 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 的逻辑,我们可以看到,热力图有两个重要的参数:gradientradius

image.png

热力图,其实就是在地图上的某个位置,画一个半径为 radius 的圆,圆心的值为1,向外辐射逐渐递减,一直到圆周上降为0(如下图)。

image.png

而其中 gradient 参数指的是,这个圆内,特定的关键值所代表的颜色,关键值之间的颜色则是线性过度。形成下面这样的效果。

image.png

热力图的数据,需要包含3个值:xycount。分别代表热力点的位置和热力值。同时,你可以提供一个最大值 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

热力图的绘制步骤(canvas)

heatmap.js 绘制热力图的原理大概如下:

  1. 数据预处理,得到 max 的值

  2. 遍历数据,根据热力值,绘制带有透明度的单色渐变圆

    部分代码:

    // 根据 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

使用ThreeJS绘制热力图

分析了 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 讲述的比较少,这个比较遗憾。我也只是看了少量文档和案例,仿照着写了一下。 threejs.org/docs/index.…

然后,我们就可以得到了文章开头的热力图的渲染效果了。

关于热力图的实现方案,有更好的 idea 欢迎一同探讨。