1. 程式人生 > 其它 >3D熱力圖的簡單實現

3D熱力圖的簡單實現

一、寫在前面

在閱讀這篇文章之前,你可能需要了解 mapbox-gl regl glsl 相關的一些知識;這裡給出的一些實現方法並不是完美的,可能會有一些 BUG 和疏漏,並不建議直接使用,僅做學習和參考。

本篇會提及兩種實現方式,包含 2 維熱力圖和三維熱力圖的實現,也會給出一些優化的方案;地圖類庫上會涉及 mapbox-gl。

二、原理簡介

  1. 我們大部分人可能知道在如何在 canvas 上實現熱力圖,比較出名的開源類庫是

和 V神的

其基本原理是根據位置(coordinates)、繪製半徑(radius)、權重(weight)繪製 circle生成密度圖,再根據給定的 gradient colors 進行密度圖的著色。他們實現的效果如下圖:

密度圖

密度圖著色

webgl 的熱力圖也基本遵循這個流程:先生成密度圖,然後再進行密度圖的著色,下面我們會深入具體的技術細節。

三、技術實現原理

先剖析 mapbox-gl 原版的技術實現,再用 regl 簡單復刻一個

1、mapbox-gl 版 heatmap 實現

  • 資料:mapbox 的資料載入流程我們這裡不再細說,網上已經有很多關於這塊的文章,我們主要關注資料塊 heatmap_bucket 的實現, heatmap_bucket 又繼承自 circle_bucket, heatmap的頂點資料和索引資料在這裡進行處理,這裡我們可以看一下addFeature 方法,此函式對向量瓦片的資料做了一次處理,將每個點資料拆分為兩個三角形;至於為什麼需要拆分兩個三角形合併的矩形,我們可以思考一下如果我們直接使用點會出現什麼問題。
function addCircleVertex(layoutVertexArray, x, y, extrudeX, extrudeY) {
    layoutVertexArray.emplaceBack(
        (x * 2) + ((extrudeX + 1) / 2),
        (y * 2) + ((extrudeY + 1) / 2));
}

for (const ring of geometry) {
            for (const point of ring) {
                const x = point.x;
                const y = point.y;

                // Do not include points that are outside the tile boundaries.
                // 這裡將越界的資料過濾掉,不進行渲染
                if (x < 0 || x >= EXTENT || y < 0 || y >= EXTENT) continue;

                // this geometry will be of the Point type, and we'll derive
                // two triangles from it.
                //
                // ┌─────────┐
                // │ 3     2 │
                // │         │
                // │ 0     1 │
                // └─────────┘
                const segment = this.segments.prepareSegment(4, this.layoutVertexArray, this.indexArray, feature.sortKey);
                const index = segment.vertexLength;

                // 頂點資料
                addCircleVertex(this.layoutVertexArray, x, y, -1, -1);
                addCircleVertex(this.layoutVertexArray, x, y, 1, -1);
                addCircleVertex(this.layoutVertexArray, x, y, 1, 1);
                addCircleVertex(this.layoutVertexArray, x, y, -1, 1);

                // 索引資料
                this.indexArray.emplaceBack(index, index + 1, index + 2);
                this.indexArray.emplaceBack(index, index + 3, index + 2);

                segment.vertexLength += 4;
                segment.primitiveLength += 2;
            }
        }

        this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature, index, {}, canonical);
    }

在上面程式碼中頂點資料最終都新增到 layoutVertexArray,我們追溯程式碼可以看到layoutVertexArrayCircleLayoutArray 的例項

class StructArrayLayout2i4 extends StructArray {
    uint8: Uint8Array;
    int16: Int16Array;

    _refreshViews() {
        this.uint8 = new Uint8Array(this.arrayBuffer);
        this.int16 = new Int16Array(this.arrayBuffer);
    }

    emplaceBack(v0: number, v1: number) {
        const i = this.length;
        this.resize(i + 1);
        return this.emplace(i, v0, v1);
    }

    emplace(i: number, v0: number, v1: number) {
        const o2 = i * 2;
        this.int16[o2 + 0] = v0;
        this.int16[o2 + 1] = v1;
        return i;
    }
}

export { StructArrayLayout2i4 as CircleLayoutArray }

你是否發現這裡的頂點資料都是存在16 位整型中,這個是因為向量瓦片會將經緯度編碼到每個瓦片的 0-8192 範圍中,而且這樣也避免了常見的

問題。關於向量瓦片你可以檢視這裡瞭解

關於渲染,我們可以在

這裡看到熱力圖的整體渲染流程。

  • 密度圖頂點著色器
uniform mat4 u_matrix;
uniform float u_extrude_scale;
uniform float u_opacity;
uniform float u_intensity;

attribute vec2 a_pos;

varying vec2 v_extrude;
uniform float weight;
uniform float radius;
// #pragma mapbox: define highp float weight
// #pragma mapbox: define mediump float radius

// Effective "0" in the kernel density texture to adjust the kernel size to;
// this empirically chosen number minimizes artifacts on overlapping kernels
// for typical heatmap cases (assuming clustered source)
const highp float ZERO = 1.0 / 255.0 / 16.0;

// Gaussian kernel coefficient: 1 / sqrt(2 * PI)
#define GAUSS_COEF 0.3989422804014327

void main(void) {
    // #pragma mapbox: initialize highp float weight
    // #pragma mapbox: initialize mediump float radius

    // unencode the extrusion vector that we snuck into the a_pos vector
    vec2 unscaled_extrude = vec2(mod(a_pos, 2.0) * 2.0 - 1.0);

    // This 'extrude' comes in ranging from [-1, -1], to [1, 1].  We'll use
    // it to produce the vertices of a square mesh framing the point feature
    // we're adding to the kernel density texture.  We'll also pass it as
    // a varying, so that the fragment shader can determine the distance of
    // each fragment from the point feature.
    // Before we do so, we need to scale it up sufficiently so that the
    // kernel falls effectively to zero at the edge of the mesh.
    // That is, we want to know S such that
    // weight * u_intensity * GAUSS_COEF * exp(-0.5 * 3.0^2 * S^2) == ZERO
    // Which solves to:
    // S = sqrt(-2.0 * log(ZERO / (weight * u_intensity * GAUSS_COEF))) / 3.0
    float S = sqrt(-2.0 * log(ZERO / weight / u_intensity / GAUSS_COEF)) / 3.0;

    // Pass the varying in units of radius
    v_extrude = S * unscaled_extrude;

    // Scale by radius and the zoom-based scale factor to produce actual
    // mesh position
    vec2 extrude = v_extrude * radius * u_extrude_scale;

    // multiply a_pos by 0.5, since we had it * 2 in order to sneak
    // in extrusion data
    vec4 pos = vec4(floor(a_pos * 0.5) + extrude, elevation(floor(a_pos * 0.5)), 1);

    gl_Position = u_matrix * pos;
}
  • 密度圖片段著色器 (注意這裡的顏色值寫入的是 r 通道,所以生成的並不是灰度密度圖)
uniform highp float u_intensity;

varying vec2 v_extrude;
uniform float weight;
// #pragma mapbox: define highp float weight

// Gaussian kernel coefficient: 1 / sqrt(2 * PI)
#define GAUSS_COEF 0.3989422804014327

void main() {
    // #pragma mapbox: initialize highp float weight

    // Kernel density estimation with a Gaussian kernel of size 5x5
    float d = -0.5 * 3.0 * 3.0 * dot(v_extrude, v_extrude);
    float val = weight * u_intensity * GAUSS_COEF * exp(d);

    gl_FragColor = vec4(val, 1.0, 1.0, 1.0);
}

a_pos:來源於上面提到layoutVertexArray

elements: 索引資料來源於上面提及的 indexArray

u_matrix: 瓦片的posMatrix

u_opacity: 0-1

u_intensity: 全域性密度權重,>= 0

radius: 畫素半徑 >=1

weight: 密度權重 https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#paint-heatmap-heatmap-weight

u_extrude_scale: 拉伸比例 pixelsToTileUnits(tile, 1, map.getZoom()) https://sourcegraph.com/github.com/mapbox/mapbox-gl-js/-/blob/src/source/pixels_to_tile_units.js#L19

if (painter.renderPass === 'offscreen') {
        const context = painter.context;
        const gl = context.gl;

        // Allow kernels to be drawn across boundaries, so that
        // large kernels are not clipped to tiles
        // 這裡允許密度圖跨邊界繪製防止裁剪
        const stencilMode = StencilMode.disabled;
        // Turn on additive blending for kernels, which is a key aspect of kernel density estimation formula
        // 啟用blend允許顏色混合
        const colorMode = new ColorMode([gl.ONE, gl.ONE], Color.transparent, [true, true, true, true]);

        bindFramebuffer(context, painter, layer);

        context.clear({color: Color.transparent});

        for (let i = 0; i < coords.length; i++) {
            const coord = coords[i];

            // Skip tiles that have uncovered parents to avoid flickering; we don't need
            // to use complex tile masking here because the change between zoom levels is subtle,
            // so it's fine to simply render the parent until all its 4 children are loaded
            if (sourceCache.hasRenderableParent(coord)) continue;

            const tile = sourceCache.getTile(coord);
            const bucket: ?HeatmapBucket = (tile.getBucket(layer): any);
            if (!bucket) continue;

            const programConfiguration = bucket.programConfigurations.get(layer.id);
            const program = painter.useProgram('heatmap', programConfiguration);
            const {zoom} = painter.transform;

            // 使用gl.TRIANGLES進行繪製,並關閉深度測試和麵剔除
            program.draw(context, gl.TRIANGLES, DepthMode.disabled, stencilMode, colorMode, CullFaceMode.disabled,
                heatmapUniformValues(coord.posMatrix,
                    tile, zoom, layer.paint.get('heatmap-intensity')),
                layer.id, bucket.layoutVertexBuffer, bucket.indexBuffer,
                bucket.segments, layer.paint, painter.transform.zoom,
                programConfiguration);
        }

        context.viewport.set([0, 0, painter.width, painter.height]);

    }

熱力圖渲染(密度圖著色)

  • 頂點著色器
uniform mat4 u_matrix;
uniform vec2 u_world;
attribute vec2 a_pos;
varying vec2 v_pos;

void main() {
    gl_Position = u_matrix * vec4(a_pos * u_world, 0, 1);

    v_pos.x = a_pos.x;
    v_pos.y = 1.0 - a_pos.y;
}
  • 片段著色器
uniform sampler2D u_image;
uniform sampler2D u_color_ramp;
uniform float u_opacity;
varying vec2 v_pos;

void main() {
    float t = texture2D(u_image, v_pos).r;
    vec4 color = texture2D(u_color_ramp, vec2(t, 0.5));
    gl_FragColor = color * u_opacity;
}
  • 渲染

相對來說,密度圖著色的流程相對簡單的多,只是將上步生成的 fbo 進行著色,而且需要注意的是上步為了效能考慮 fbo 的大小設定為 painter.width / 4, painter.height / 4;這裡同樣也說明一下Attributes 屬性Uniforms 全域性變數 的來源:

a_pos:

elements: 索引資料 https://sourcegraph.com/github.com/mapbox/mapbox-gl-js/-/blob/src/render/painter.js#L244

u_image:密度圖

u_color_ramp:熱力圖漸變色帶

u_opacity:0-1 的全域性透明度

u_world:[gl.drawingBufferWidth, gl.drawingBufferHeight] https://sourcegraph.com/github.com/mapbox/mapbox-gl-js/-/blob/src/render/program/heatmap_program.js#L71

u_matrix: mat4.ortho(mat4.create(), 0, painter.width, painter.height, 0, 0, 1)

function renderTextureToMap(painter, layer) {
    const context = painter.context;
    const gl = context.gl;

    // Here we bind two different textures from which we'll sample in drawing
    // heatmaps: the kernel texture, prepared in the offscreen pass, and a
    // color ramp texture.
    const fbo = layer.heatmapFbo;
    if (!fbo) return;
    context.activeTexture.set(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, fbo.colorAttachment.get());

    context.activeTexture.set(gl.TEXTURE1);
    let colorRampTexture = layer.colorRampTexture;
    if (!colorRampTexture) {
        colorRampTexture = layer.colorRampTexture = new Texture(context, layer.colorRamp, gl.RGBA);
    }
    colorRampTexture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);

    painter.useProgram('heatmapTexture').draw(context, gl.TRIANGLES,
        DepthMode.disabled, StencilMode.disabled, painter.colorModeForRenderPass(), CullFaceMode.disabled,
        heatmapTextureUniformValues(painter, layer, 0, 1),
        layer.id, painter.viewportBuffer, painter.quadTriangleIndexBuffer,
        painter.viewportSegments, layer.paint, painter.transform.zoom);
}

if (painter.renderPass === 'translucent') {
        painter.context.setColorMode(painter.colorModeForRenderPass());
        renderTextureToMap(painter, layer);
}

到這裡,mapbox-gl 的 heatmap 的實現原理就結束了,其基本流程和 canvas 的 heatmap 區別不大。這裡值得一提的是 gl 的密度圖的混合和 canvas 預設的

這兩個都是生成密度圖的關鍵,而且據大佬所說 globalCompositeOperation 其實就是一個簡單的 blend 實現。

2、mapbox-gl + regl 如何實現二維熱力圖

這裡瞭解原理後考慮一下如何實現一個自定義熱力圖渲染,當然原理和上述 mapbox-gl 內建的 heatmap 基本一致(daimaquanchao),只是資料部分和著色器有細微的改動,下面我們看下詳細的程式碼實現,完整程式碼

密度圖頂點著色器 grayVs:

attribute vec2 a_position;
attribute float a_instance_count;

uniform mat4 u_matrix;

uniform float u_extrude_scale;
uniform float u_intensity;

uniform float u_radius;

varying vec2 v_extrude;
varying float v_weight;

// Effective "0" in the kernel density texture to adjust the kernel size to;
// this empirically chosen number minimizes artifacts on overlapping kernels
// for typical heatmap cases (assuming clustered source)
const float ZERO = 1.0 / 255.0 / 16.0;

// Gaussian kernel coefficient: 1 / sqrt(2 * PI)
#define GAUSS_COEF 0.3989422804014327

void main() {
  // unencode the extrusion vector that we snuck into the a_pos vector
  vec2 unscaled_extrude = vec2(mod(a_position.xy, 2.0) * 2.0 - 1.0);
  v_weight = a_instance_count;

  // This 'extrude' comes in ranging from [-1, -1], to [1, 1].  We'll use
  // it to produce the vertices of a square mesh framing the point feature
  // we're adding to the kernel density texture.  We'll also pass it as
  // a varying, so that the fragment shader can determine the distance of
  // each fragment from the point feature.
  // Before we do so, we need to scale it up sufficiently so that the
  // kernel falls effectively to zero at the edge of the mesh.
  // That is, we want to know S such that
  // weight * u_intensity * GAUSS_COEF * exp(-0.5 * 3.0^2 * S^2) == ZERO
  // Which solves to:
  // S = sqrt(-2.0 * log(ZERO / (weight * u_intensity * GAUSS_COEF))) / 3.0
  float S = sqrt(-2.0 * log(ZERO / v_weight / u_intensity / GAUSS_COEF)) / 3.0;

  // Pass the varying in units of radius
  v_extrude = S * unscaled_extrude;

  // Scale by radius and the zoom-based scale factor to produce actual
  // mesh position
  vec2 extrude = v_extrude * u_radius * u_extrude_scale;

  // multiply a_pos by 0.5, since we had it * 2 in order to sneak
  // in extrusion data
  vec4 pos = vec4(floor(a_position.xy * 0.5) + extrude, 0.0, 1.0);

  gl_Position = u_matrix * pos;
}

密度圖片段著色器 grayFs:

//precision mediump float;
precision highp float;
uniform float u_intensity;

varying vec2 v_extrude;

varying float v_weight;

// Gaussian kernel coefficient: 1 / sqrt(2 * PI)
#define GAUSS_COEF 0.3989422804014327

void main() {
//  if (dot(v_iPosition, v_iPosition) > 1.0) {
//    discard;
//  }

  // Kernel density estimation with a Gaussian kernel of size 5x5
  float d = -0.5 * 3.0 * 3.0 * dot(v_extrude, v_extrude);
  float val = v_weight * u_intensity * GAUSS_COEF * exp(d);

  gl_FragColor = vec4(0.0, 0.0, 0.0, val);
}

我們通過上面著色器程式碼可以看到核心程式碼沒有變化,僅僅改動了密度圖權重weight 的獲取方式,其他基本保持不變。其核心 regl 程式碼如下:

this.regl = REGL({
      gl: gl,
      extensions: [
        'OES_texture_float',
        'OES_element_index_uint',
        'WEBGL_color_buffer_float',
        'OES_texture_half_float',
        'ANGLE_instanced_arrays'
      ],
      attributes: {
        antialias: true,
        preserveDrawingBuffer: false,
      }
    });

    const [width, height] = [this.map.transform.width, this.map.transform.height];

    this.texture = this.regl.texture({
      width,
      height,
      min: 'linear',
      mag: 'linear',
      wrap: 'clamp',
      format: 'rgba',
      type: 'half float'
    });

    this.fbo = this.regl.framebuffer({
      width,
      height,
      depth: false,
      stencil: false,
      colorFormat: 'rgba',
      colorType: 'half float',
      color: this.texture
    });

    this.gradientCommand = this.regl<GradientCommand.Uniforms, GradientCommand.Attributes, GradientCommand.Props>({
      frag: grayFs,

      vert: grayVs,

      attributes: {
        a_position: (_, { a_position }) => a_position,
        a_instance_count: (_, { a_instance_count }) => a_instance_count,
      },

      uniforms: {
        u_matrix: (_, { u_matrix }) => u_matrix,
        u_intensity: (_, { u_intensity }) => u_intensity,
        u_radius: (_, { u_radius }) => u_radius,
        u_opacity: (_, { u_opacity }) => u_opacity,
        u_extrude_scale: (_, { u_extrude_scale }) => u_extrude_scale,
      },

      depth: { // 這裡我們關閉深度測試
        enable: false,
        mask: false,
        func: 'less',
        range: [0, 1]
      },

      blend: {
        enable: true,
        func: {
          src: 'one',
          dst: 'one',
        },
        equation: 'add',
        color: [0, 0, 0, 0],
      },

      colorMask: [true, true, true, true],

      elements: (_, { elements }) => elements,

      viewport: (_, { canvasSize: [width, height] }) => ({ x: 0, y: 0, width, height })
    });

密度圖著色相關的著色器程式碼 :

// drawVs
uniform mat4 u_matrix;
uniform vec2 u_world; // 4096 * 2

attribute vec2 a_pos;

attribute vec2 a_texCoord;

varying vec2 v_texCoord;

void main() {
  v_texCoord = a_texCoord;

  gl_Position = u_matrix * vec4(a_pos * u_world, 0.0, 1.0);
}

// drawFs
precision mediump float;

uniform sampler2D u_image;
uniform sampler2D u_color_ramp;
uniform float u_opacity;

varying vec2 v_texCoord;

void main() {
  float t = texture2D(u_image, v_texCoord).a;

  vec4 color = texture2D(u_color_ramp, vec2(t, 0.5));
  gl_FragColor = color * u_opacity;
}

核心程式碼:

this.colorCommand = this.regl<ColorCommand.Uniforms, ColorCommand.Attributes, ColorCommand.Props>({
      frag: drawFs,

      vert: drawVs,

      attributes: {
        a_pos: (_, { a_pos }) => a_pos,
        a_texCoord: (_, { a_texCoord }) => a_texCoord,
      },

      uniforms: {
        u_matrix: (_, { u_matrix }) => u_matrix,
        u_image: (_, { u_image }) => u_image,
        u_color_ramp: (_, { u_color_ramp }) => u_color_ramp,
        u_world: (_, { u_world }) => u_world,
        u_opacity: (_, { u_opacity }) => u_opacity,
      },

      depth: {
        enable: false,
        mask: true,
        func: 'always',
      },

      blend: {
        enable: true,
        func: {
          src: 'one',
          dst: 'one minus src alpha',
        },
        color: [0, 0, 0, 0]
      },

      colorMask: [true, true, true, true],

      elements: (_, { elements }) => elements,

      viewport: (_, { canvasSize: [width, height] }) => ({ x: 0, y: 0, width, height })
    });

核心渲染程式碼:

這裡沒有直接使用向量瓦片,而是使用 geojson 作為元資料,使用

生成向量瓦片。

下面在註釋裡面簡單解釋一下基本原理

render(gl: WebGLRenderingContext | WebGL2RenderingContext, m: REGL.Mat4) {
    if (!this.regl || !this.hasData) return;

    // 當渲染資料需要更新時銷燬原有頂點資料
    if (this.needUpdate) {
      const keys = dataCache.keys();
      let i = 0;
      let len = keys.length;
      for (; i < len; i++) {
        const tile = dataCache.get(keys[i]) as {
          parseData: any;
          instanceData: {
            elements: REGL.Elements,
            position: {
              buffer: REGL.Buffer;
              size: number;
            },
            instanceCount: {
              buffer: REGL.Buffer;
              size: number;
              divisor: number;
              normalized: boolean;
            },
          }
        };
        if (tile) {
          // tile.instanceData?.uvs.buffer.destroy();
          tile.instanceData?.position.buffer.destroy();
          tile.instanceData?.instanceCount.buffer.destroy();
          tile.instanceData?.elements.destroy();
        }
      }
      this.needUpdate = false;
    }

    // 返回當前檢視覆蓋的瓦片
    const tiles = this.map.transform.coveringTiles({
      tileSize: this.options.tileSize,
      minzoom: this.options.minZoom,
      maxzoom: this.options.maxZoom,
      reparseOverscaled: undefined,
    });

    for (const coord of tiles) {
      const canonical = coord.canonical;
      const cache = dataCache.get(coord.key) as {
        parseData: any;
        instanceData: {
          elements: REGL.Elements,
          position: {
            buffer: REGL.Buffer;
            size: number;
          },
          instanceCount: {
            buffer: REGL.Buffer;
            size: number;
            divisor: number;
            normalized: boolean;
          };
        }
      };
      // 更新瓦片投影矩陣
      coord.posMatrix = this.map.painter.transform.calculatePosMatrix(coord.toUnwrapped());

      if (!this.tileIndex) continue;

      // 如果沒有快取,則從向量瓦片中讀取資料,構建頂點資料、索引資料和、權重資料,這裡也是使用兩個三角形構造一個點
      // 而且針對這裡其實還可以優化,使用[例項化繪製](https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-instanced-drawing.html) 可以大大減少向 gpu 傳輸的頂點數量,具體實現在下一節 3d heatmap 有使用
      if (!cache) {
        const data = this.tileIndex.getTile(canonical.z, canonical.x, canonical.y);
        const features = data?.features || [];
        let i = 0;
        const len = features.length;
        const n = new Float32Array(2 * 4 * features.length);
        const instanceCount = new Float32Array(features.length);
        const indices = new Uint32Array(features.length * 6);
        for (; i < len; i++) {
          let val = 1;
          if (this.options.weight) {
            val = this.options.weight(features[i]);
          }
          const coords = features[i].geometry[0];
          const x = coords[0];
          const y = coords[1];
          if (x < 0 || x >= EXTENT || y < 0 || y >= EXTENT)
            continue;
          const index = i * 8;
          addCircleVertex(n, index, x, y, -1, -1);
          addCircleVertex(n, index + 2, x, y, 1, -1);
          addCircleVertex(n, index + 2 * 2, x, y, 1, 1);
          addCircleVertex(n, index + 2 * 3, x, y, -1, 1);

          const tt = i * 6;
          const bb = i * 4;

          indices[tt] = bb;
          indices[tt + 1] = bb + 1;
          indices[tt + 2] = bb + 2;

          indices[tt + 3] = bb;
          indices[tt + 4] = bb + 3;
          indices[tt + 5] = bb + 2;

          instanceCount[i] = val;
        }

        coord.instanceData = {
          instanceCount: {
            buffer: this.regl.buffer({
              data: instanceCount,
              type: 'float32',
              usage: 'static',
            }),
            normalized: false,
            size: 1,
            divisor: 4,
          },
          elements: this.regl.elements({
            data: indices,
            primitive: 'triangles',
            type: 'uint32',
          }),
          position: {
            buffer: this.regl.buffer({
              data: n,
              type: 'float',
            }),
            size: 2,
          },
        };

        coord.parseData = features;
        dataCache.set(coord.key, coord);
      } else {
        // 如果有快取,直接從快取中取相關資料
        coord.instanceData = cache.instanceData;

        coord.parseData = cache.parseData;
      }
    }

    const [width, height] = [this.map.transform.width, this.map.transform.height];
    const [drawWidth, drawHeight] = [this.map.painter.width, this.map.painter.height];

    // 清空 fbo
    this.regl.clear({
      color: [0, 0, 0, 0],
      depth: 0,
      framebuffer: this.fbo
    });

    // 為了效能優化,fbo 大小設定為畫布寬高的1/4
    this.fbo.resize(drawWidth / 4, drawHeight / 4);
    let i = 0;
    const len = tiles.length;
    this.fbo.use(() => {
      for (; i < len; i++) {
        const tile = tiles[i];
        tile.tileSize = this.options.tileSize;
        // 逐瓦片繪製密度圖,和上節的過程基本類似,這裡不深入
        this.gradientCommand({
          // @ts-ignore
          u_matrix: tile.posMatrix,
          a_position: tile.instanceData.position,
          a_instance_count: tile.instanceData.instanceCount,
          elements: tile.instanceData.elements,
          u_extrude_scale: getExtrudeScale(this.options.extrudeScale !== undefined ? this.options.extrudeScale : 1, this.map.transform.zoom, tile),
          u_intensity: this.options.intensity !== undefined ? this.options.intensity : 1,
          u_radius: this.options.radius !== undefined ? this.options.radius : 20,
          canvasSize: [drawWidth / 4, drawHeight / 4],
        });
      }
    })

    // 生成頂點資料和索引資料,和上節的生成基本類似,不做深入
    const cacheBuffer = this.getPlaneBuffer(this.drawBuffer, 1, 1, 1, 1);

    this.colorCommand({
      a_pos: cacheBuffer.position,
      a_texCoord: cacheBuffer.uvs,
      elements: cacheBuffer.elements,
      u_matrix: this.map.transform.glCoordMatrix, // 這裡同上節提到的`mat4.ortho(mat4.create(), 0, painter.width, painter.height, 0, 0, 1)
      u_world: [width, height],
      u_image: this.texture,
      u_color_ramp: this.colorRampTexture,
      u_opacity: this.options.opacity !== undefined ? this.options.opacity : 1,
      canvasSize: [drawWidth, drawHeight],
    });

    this.regl._refresh();
  }

colorRampTexture的生成:

這個很容易理解,不做深入

createColorTexture(colors: [number, string][]) {
    const interpolateColor = colors
      .map((item) => ({
        key: item[0],
        value: item[1],
      }));

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    canvas.width = 256;
    canvas.height = 1;

    if (ctx) {
      const gradient = ctx.createLinearGradient(0, 0, 256, 0);

      for (let i = 0; i < interpolateColor.length; i += 1) {
        const key = interpolateColor[i].key;
        const color = interpolateColor[i].value;
        gradient.addColorStop(key, color);
      }

      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 256, 1);
      const data = new Uint8Array(ctx.getImageData(0, 0, 256, 1).data);

      return this.regl.texture({
        width: 256,
        height: 1,
        data,
        mipmap: false,
        flipY: false,
        format: 'rgba',
        min: 'linear',
        mag: 'linear',
        wrap: 'clamp'
      });
    }
  }

以上就是 mapbox-gl + regl 的2d熱力圖的簡單實現,其效果圖如下:

這裡也同樣展示一下密度圖,因為上述程式碼中寫入的是Alpha通道,所以看起來和 canvas 的密度圖類似:

![](data:image/svg+xml;utf8,)

但是如果寫入的是 red 通道(和 mapbox-gl 類似的話),其效果可能如下圖所示:

![](data:image/svg+xml;utf8,)

3、mapbox-gl + regl 如何實現三維熱力圖

在這一節我們主要關注兩個點,一是第二種方式的熱力圖實現,二是如何賦予熱力圖高度。下面我們先看新的熱力圖實現方式:

先上著色器程式碼:

密度圖著色器

// 頂點著色器

attribute vec2 a_position; // 頂點資料
attribute vec2 a_precisionBits; // 頂點資料高位

attribute vec2 a_shape_position;
attribute float a_instance_count;

uniform float u_weight; // 全域性密度權重
uniform float u_radius; // 密度圖生成的半徑
uniform float u_intensity; // 密度
uniform float u_min; // 資料權重最小值
uniform float u_max; // 資料權重最大值

varying float v_intensity;
varying vec2 v_shape_position;

void main() {
  v_shape_position = a_shape_position;

  vec3 worldPosition = project_position(vec3(a_position, 0.0), vec3(a_precisionBits, 0.0));

  v_intensity = clamp((a_instance_count - u_min) / (u_max - u_min), 0.0, 1.0) * u_intensity;

  float r = project_pixel_size(project_size_to_pixel(u_radius));

  vec2 offset = a_shape_position * r;

  vec4 commonPosition = vec4(worldPosition + vec3(offset , 0.0), 1.0);

  gl_Position = project_common_position_to_clipspace(commonPosition);
}

// 片段著色器
precision highp float;

uniform float u_weight; // 全域性密度權重

varying float v_intensity;
varying vec2 v_shape_position;

void main() {
  float d = 1.0 - clamp(length(v_shape_position), 0.0, 1.0);
  float intensity = u_weight * v_intensity * d;

  gl_FragColor = vec4(0.0, 0.0, 0.0, intensity);
}

這裡使用了與 deck.gl 相同的投影方式,使用了 mercator-proj這個實現。當然,主體程式碼是從 deck.gl 提取(chao)的最小的計算Web墨卡託投影的類庫,其核心原理你可以檢視 deck.gl 的相關文章,這裡不做深入。

渲染著色器:

// toBezier
precision highp float;

vec2 toBezierInner(float t, vec2 P0, vec2 P1, vec2 P2, vec2 P3) {
  float t2 = t * t;
  float one_minus_t = 1.0 - t;
  float one_minus_t2 = one_minus_t * one_minus_t;
  return (P0 * one_minus_t2 * one_minus_t + P1 * 3.0 * t * one_minus_t2 + P2 * 3.0 * t2 * one_minus_t + P3 * t2 * t);
}

vec2 toBezier(float t, vec4 p) {
  return toBezierInner(t, vec2(0.0, 0.0), vec2(p.x, p.y), vec2(p.z, p.w), vec2(1.0, 1.0));
}

#pragma glslify: export(toBezier)

// 頂點著色器
uniform mat4 u_matrix; // map.transform.mercatorMatrix
uniform mat4 u_inverse_matrix; // map.transform.pixelMatrixInverse

uniform sampler2D u_image; // fbo texture
uniform float u_world_size; // map.transform.worldSize
uniform float u_max_height; // 
uniform vec2 u_viewport_size; // [map.transform.width, map.transform.height]
uniform vec4 u_bezier;

attribute vec2 a_pos;
attribute vec2 a_texCoord;

varying float v_alpha;
varying vec2 v_texCoord;

#pragma glslify: toBezier = require(../toBezier)

float interpolate(float a, float b, float t) {
  return (a * (1.0 - t)) + (b * t);
}

float a = PROJECT_EARTH_CIRCUMFRENCE / 1024.0 / 8.0;

void main() {
  v_texCoord = a_texCoord;

  // 紋理座標轉標準化裝置座標(Normalized Device Coordinates, NDC - -1 - 1)
  vec2 clipSpace = a_pos * 2.0 - vec2(1.0);

  vec2 screen = a_pos * u_viewport_size;

  // mapbox-gl-js src/geo/Transform.pointCoordinate
  // since we don't know the correct projected z value for the point,
  // unproject two points to get a line and then find the point on that
  // line with z=0
  vec4 p1 = vec4(screen.xy, 0.0, 1.0);
  vec4 p2 = vec4(screen.xy, 1.0, 1.0);

  vec4 inverseP1 = u_inverse_matrix * p1;
  vec4 inverseP2 = u_inverse_matrix * p2;

  inverseP1 = inverseP1 / inverseP1.w;
  inverseP2 = inverseP2 / inverseP2.w;

  float zPos = 0.0;

  if (inverseP1.z == inverseP2.z) {
    zPos = 0.0;
  } else {
    zPos = (0.0 - inverseP1.z) / (inverseP2.z - inverseP1.z);
  }

  // mapbox-gl-js src/style-spec/util/interpolate.js
  vec2 mapCoord = vec2(interpolate(inverseP1.x, inverseP2.x, zPos), interpolate(inverseP1.y, inverseP2.y, zPos));

  v_alpha = texture2D(u_image, v_texCoord).a;
  float height1 = toBezier(v_alpha, u_bezier).y;
  float height0 = toBezier(0.0, u_bezier).y;
  // 這裡的高度值並不準確,只是除錯了一個合適的值展示效果
  float height = (height1 - height0) / (a / u_max_height);

  if (height <= 0.0) {
    height = 0.0;
  } else if (height >= 1.0) {
    height = 1.0;
  }

  gl_Position = u_matrix * vec4(mapCoord.xy / u_world_size, height, 1.0);
}

// 片段著色器
precision mediump float;

uniform sampler2D u_color_ramp;
uniform float u_opacity;
uniform sampler2D u_image;

varying float v_alpha;
varying vec2 v_texCoord;

void main() {
  vec4 color = texture2D(u_color_ramp, vec2(v_alpha, 0.5));
  gl_FragColor = color * u_opacity;
//  gl_FragColor = vec4(1.0, 0.0, 0.0, 0.1);
}

在圖層上同樣是實現了 mapbox-gl 的自定義圖層,其核心需要實現兩個方法:

onAdd:

// 建立 regl,並開啟相應的擴充套件
this.regl = REGL({
  gl: gl,
  extensions: [
    'OES_texture_float',
    'ANGLE_instanced_arrays',
    // @link https://gitter.im/mikolalysenko/regl?at=57f04621b0ff456d3ad8f268
    'OES_element_index_uint',
    'WEBGL_color_buffer_float',
    'OES_texture_half_float',
    'OES_vertex_array_object'
  ],
  attributes: {
    antialias: true,
    preserveDrawingBuffer: false,
  }
});

const [width, height] = [this.map.transform.width, this.map.transform.height];

this.texture = this.regl.texture({
  width,
  height,
  min: 'linear',
  mag: 'linear',
  wrap: 'clamp',
  format: 'rgba',
  type: 'half float'
});

// 建立密度圖 fbo
this.fbo = this.regl.framebuffer({
  width,
  height,
  depth: false,
  stencil: false,
  colorFormat: 'rgba',
  colorType: 'half float',
  color: this.texture
});

const uniforms: any = {};

const keys = getUniformKeys() as MercatorUniformKeys;
keys.forEach((key: any) => {
  // @ts-ignore
  uniforms[key] = this.regl.prop(key);
});

this.gradientCommand = this.regl<GradientCommand.Uniforms, GradientCommand.Attributes, GradientCommand.Props>({
  frag: grayFs,

  // 這裡向頂點著色器注入墨卡託投影相關的著色器
  vert: injectMercatorGLSL(this.gl, grayVs),

  attributes: {
    a_position: (_, { a_position }) => a_position,
    a_shape_position: (_, { a_shape_position }) => a_shape_position,
    a_precisionBits: (_, { a_precisionBits }) => a_precisionBits,
    a_instance_count: (_, { a_instance_count }) => a_instance_count,
  },

  uniforms: {
    u_intensity: (_, { u_intensity }) => u_intensity,
    u_weight: (_, { u_weight }) => u_weight,
    u_radius: (_, { u_radius }) => u_radius,
    u_min: (_, { u_min }) => u_min,
    u_max: (_, { u_max }) => u_max,
    u_opacity: (_, { u_opacity }) => u_opacity,

    ...uniforms,
  },

  depth: {
    enable: false,
    mask: false,
    func: 'always',
  },

  blend: {
    enable: true,
    func: {
      src: 'one',
      dst: 'one',
    },
    equation: 'add',
    color: [0, 0, 0, 0],
  },

  colorMask: [true, true, true, true],

  primitive: 'triangle strip',

  instances: (_, { instances }) => instances,

  count: (_, { vertexCount }) => vertexCount,

  viewport: (_, { canvasSize: [width, height] }) => ({ x: 0, y: 0, width, height })
});

this.colorCommand = this.regl<ColorCommand.Uniforms, ColorCommand.Attributes, ColorCommand.Props>({
  frag: drawFs,

  vert: injectMercatorGLSL(this.gl, drawVs),

  attributes: {
    a_pos: (_, { a_pos }) => a_pos,
    a_texCoord: (_, { a_texCoord }) => a_texCoord,
    a_precisionBits: (_, { a_precisionBits }) => a_precisionBits,
  },

  uniforms: {
    u_matrix: (_, { u_matrix }) => u_matrix,
    u_inverse_matrix: (_, { u_inverse_matrix }) => u_inverse_matrix,
    u_image: (_, { u_image }) => u_image,
    u_bezier: (_, { u_bezier }) => u_bezier,
    u_color_ramp: (_, { u_color_ramp }) => u_color_ramp,
    u_opacity: (_, { u_opacity }) => u_opacity,
    u_world_size: (_, { u_world_size }) => u_world_size,
    u_max_height: (_, { u_max_height }) => u_max_height,
    u_viewport_size: (_, { u_viewport_size }) => u_viewport_size,
    ...uniforms,
  },

  depth: {
    enable: false,
    mask: true,
    func: 'always',
  },

  blend: {
    enable: true,
    func: {
      src: 'one',
      dst: 'one minus src alpha',
    },
    color: [0, 0, 0, 0]
  },

  colorMask: [true, true, true, true],

  elements: (_, { elements }) => elements,

  viewport: (_, { canvasSize: [width, height] }) => ({ x: 0, y: 0, width, height })
});

render(其實這步裡面的密度圖渲染可以放到 prerender 裡),這塊我就不貼程式碼了,核心流程有點長,我們用流程圖簡單描述一下基本渲染流程,如果你對原始碼感興趣請檢視

最終實現效果如下圖 :

到這裡整篇文章也就結束了,受限於本人技術水平和語言組織能力文章上可能多有疏漏,歡迎指正。

本文轉自 https://zhuanlan.zhihu.com/p/350355621,如有侵權,請聯絡刪除。