關於我純手工用 Three-JS 擼了一個熱力圖組件的那件事

背景

背景這一節,記錄了我製作地圖組件的背景,想看熱力圖設計思路的,可以直接跳過這一節。

這個功能其實來自於我最近在做的一個地圖組件,還正在開發。

作爲一個地圖組件,該有的功能都要有吧。那麼,熱力圖的功能不能少吧?

關於爲什麼要再開發一個地圖組件

市面上地圖 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 協議的標準提供,兩者就能相互解耦,像下面這樣。

但是,即使是WMS 協議,裏面也有非常多的針對不同圖層格式的標準,每個地圖 SDK支持的標準都是有限的,而圖層服務提供的標準又是各種各樣的,所以還是會出現有的圖層服務需要特定的地圖 SDK才能夠解析的情況(比如現在的高德地圖)。當然他們可能有着各種各樣的原因,版權問題,安全問題等。

所以我需要一個更好的方案,讓我的地圖組件做到 Write once, run anywhere.

地圖組件的架構

想要做到通用,我就不應該從變化多端的圖層協議入手。

事實上,所有地圖有一個更加統一的東西,叫做Viewport。你想查看地圖,總要有一個座標吧,想放大縮小,要有一個縮放級別吧。有的地圖還支持旋轉和傾斜。這幾個特性合起來,就構成了地圖的 Viewport。而幾乎所有地圖,都必須有這幾項內容。而我只需要抽象Viewport這一個特性。

對於不支持旋轉、傾斜的地圖 SDK,將這兩個屬性鎖定爲 0 即可。

好在現在絕大部分的地圖 SDK內置的投影模式都是 Web 墨卡託投影。我的Viewport層設計起來方便多了(當然對於經緯度投影,我也設計了對應的方案,至於還有其他小衆的投影模式,我就暫不支持了,我們也沒有這類場景。

由於只抽象了Viewport這一特性,確實減少了適配不同地圖 SDK的工作量,但反之而來的是,地圖上需要呈現的功能,需要由我自己實現(例如散點,飛線,矢量圖形等)。這個也確實,不同地圖 SDK支持的散點、圖形都不太一樣,我就是沒法抽象的,所以只能自己幹了,其中就包括這次講到的熱力圖層

設計完成之後的地圖組件架構如下:

爲了最終能實現海量點熱力圖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

熱力圖,其實就是在地圖上的某個位置,畫一個半徑爲 radius 的圓,圓心的值爲 1,向外輻射逐漸遞減,一直到圓周上降爲 0(如下圖)。

而其中 gradient 參數指的是,這個圓內,特定的關鍵值所代表的顏色,關鍵值之間的顏色則是線性過度。形成下面這樣的效果。

熱力圖的數據,需要包含 3 個值:xycount。分別代表熱力點的位置和熱力值。同時,你可以提供一個最大值 max 或由熱力圖工具自動計算出一個合適的 max。這樣,你的熱力點的中心值,就是count/max。再從 gradient 色卡中匹配顏色。

熱力干涉

當出現兩個熱力點,且兩點之間的距離小於 半徑x2 時,兩個點輻射出的熱力將相互干涉。發生干涉後,兩點之間的位置的熱力值將比原來要高。

假設下面這個熱力值爲 1 的點,在距離該點 75% 輻射半徑的位置有一個點 A,那麼點 A 的熱力值應該爲 0.25。

當出現另一個距離較近的,熱力值爲 0.5 的點,且到點 A 的距離爲 50% 輻射半徑。如下圖所示,則熱力點 A 的熱力值爲 1 * (1 - 0.75) + 0.5 * (1 - 0.5) = 0.5

疊加了顏色後,效果如下:

熱力圖的繪製步驟 (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)

    這一步的目的在於通過不斷繪製單色漸變圓,使它們的顏色疊加到一起,實現熱力干涉的效果。

    然後你就可以得到這樣一張圖:

  3. 上色,遍歷上圖的每一個像素點,獲得其透明度 (0~1),再匹配 gradient 得到具體顏色,進行像素替換,你就得到了最終看到的熱力圖。

使用 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 講述的比較少,這個比較遺憾。我也只是看了少量文檔和案例,仿照着寫了一下。https://threejs.org/docs/index.html#manual/zh/introduction/How-to-use-post-processing

然後,我們就可以得到了文章開頭的熱力圖的渲染效果了。

關於熱力圖的實現方案,有更好的 idea 歡迎一同探討。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TZ_2dXYIonWdbAGDA7vCHA