3D熱力圖的簡單實現
一、寫在前面
在閱讀這篇文章之前,你可能需要了解 mapbox-gl regl glsl 相關的一些知識;這裡給出的一些實現方法並不是完美的,可能會有一些 BUG 和疏漏,並不建議直接使用,僅做學習和參考。
本篇會提及兩種實現方式,包含 2 維熱力圖和三維熱力圖的實現,也會給出一些優化的方案;地圖類庫上會涉及 mapbox-gl。
二、原理簡介
- 我們大部分人可能知道在如何在 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,我們追溯程式碼可以看到layoutVertexArray
是 CircleLayoutArray
的例項
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);
}
- 密度圖渲染: 這裡使用
Framebuffer
作為密度圖儲存渲染結果,而且提及一下 Attributes 屬性 和 Uniforms 全域性變數 的來源
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,如有侵權,請聯絡刪除。