最终效果

当上图缩放变小的时候,相邻的热力点会进行颜色混合,效果如下

前言
可能会有这样的需求, 希望在原来 three.js 3D 地图的基础上,加上热力图。
常见方案
- 高德地图 本身支持在基础地图之上,加上热力图
- Echarts 也支持热力图
- Heatmap.js 类似这种第三方库,也是可以直接拿来用,而且还非常简单好用
问题场景
我现在不是在 web 2D 场景下画出热力图, 是要在 3D 场景下画出热力图,以上几种方式,好像都不是很理想, 最终想了下, 还是自己想个方案来实现。
涉及知识点
- Three.js 相关基础知识, 以及后期处理 EffectComposer 的运用
- Shader 着色器编程, UV 纹理坐标
- Canvas 基础知识
- 3D 性能监控以及优化
如果上面的知识点比较欠缺, 那看这篇博客会比较难懂。用 Shader 来实现热力图这种多颜色混合,真的是非常合适。如果你有耐心,请跟着下面的思路一步步实现。
一、Canvas 画出黑白渐变圆形贴图
首先,需要一张贴图, 这张贴图从中间往四周渐变, 由白变黑, 不透明度由 1 逐渐变成 0。
这张贴图有什么用? 其实,热力图就是靠这张贴图,将热力点互相叠加,从而辅助着色。
热力点越密集的地方,越亮; 热力点越稀疏的地方,越暗。
代码如下:
js
createOpacityTexture() {
const radius = 50;
const canvas2d = document.createElement('canvas');
canvas2d.width = radius * 2;
canvas2d.height = radius * 2;
const ctx = canvas2d.getContext('2d');
if (ctx) {
const grd = ctx.createRadialGradient(radius, radius, 0, radius, radius, radius);
grd.addColorStop(0, 'rgba(255,255,255,1)');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, radius * 2, radius * 2);
}
this.heatMapTexture = new CanvasTexture(canvas2d);
}
将 Canvas 画出的图,右键保留在本地,在 vscode 浏览效果如下:

二、Canvas 画出彩色渐变贴图
上面已经将黑白灰的圆形渐变贴图做好了, 但是热力图是彩色的, 需要将黑白灰的着色变成彩色的, 则需要着色板贴图。
贴图长度为 256, 高度为 1, 从左向右, 颜色由冷色渐变成暖色。
为什么颜色长度为 256 ? 其实是参考 rgba 的颜色构造, rgba 的数值也是从 0 到 256, 所以长度设置为 256,高度为 1 即可, 高度其实不影响, 但节省资源, 没有必要花那么大的篇幅。
代码如下:
js
createColorTexture() {
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;
canvasColor.height = 1;
const ctxColor = canvasColor.getContext('2d');
if (ctxColor) {
const grd = ctxColor.createLinearGradient(0, 0, 256, 0);
colors.forEach(([percent, color]) => {
//@ts-ignore
grd.addColorStop(percent, color);
});
ctxColor.fillStyle = grd;
ctxColor.fillRect(0, 0, 256, 1);
}
this.colorTexture = new CanvasTexture(canvasColor);
}
在 web 展示中以后,右键保存,在 vscode 浏览效果如下:

其实,高度可以稍微变大,在 vscode 浏览效果如下:

三、Shader 将贴图颜色转化
总结贴图
如上面所述, 做好了2张贴图
- 热力点黑白渐变贴图 ------ 贴图1
- 着色板贴图 ------ 贴图2
首先,每个热力点的数据, 设定为 (x, y, v);
其中,(x, y)表示热力点的位置, v 表示热力点的强度,可以理解为贴图的不透明度 opacity;
每个热力点, 用最简单的几何材质 PlaneGeometry, 也就是平面;
但是,这个平面还需要材质, 这里用到了着色器材质 ShaderMaterial;
着色器材质,通过 uniforms 传进去三个参数,即上面2张贴图, 还有不透明度 v。
颜色如何做映射
- 首先通过 uv 坐标,读取到第一张贴图的某一像素的颜色 color
- color 颜色是一个四分量,即 (r, g, b, a)
- 因为都是黑白颜色, 所以 r、g、b 这三个值对我们来说没有用;
- a 表示黑白纹理的不透明度, 范围是 0 ~ 1
- 我们又传进来一个整体的不透明度 opacity, 计算出新的颜色不透明度 a = opacity * a
- 这个新的不透明度 a = opacity * a,范围同样是 0 ~ 1, 因为传进去的参数 opacity 不会超过1
- a 其实表达了颜色的浓淡程度, 所以可以在另外一张贴图上,根据这个浓淡程度来取色
- 贴图2,虽然长度是 256, 但作为贴图, 这个贴图最左边坐标是0, 最右边的坐标是1
- 贴图2,uv 坐标也是 0 ~ 1,而表示颜色浓淡的 a 范围同样是 0 ~ 1,这样完成颜色的映射转化
最终效果
这样一来,贴图一最中心的点,也就是最白最亮的那个点, 通过上面的映射, 最终变成贴图2最右端的颜色,即最暖色(红色)。
颜色转化代码
- 首先 test2 这个方法, 创建一个热点平面, 加入 3D 场景中
- createShaderMaterial 这个方法生成对应的材质,将
js
test2(scene: Scene) {
const geometry = new PlaneGeometry(10, 10);
geometry.translate(0, 0, 6);
const material = this.createShaderMaterial({
opacity: 0.7,
heatMapTexture: this.heatMapTexture!,
colorTexture: this.colorTexture!,
});
const mesh = new Mesh(geometry, material);
mesh.renderOrder = 7;
scene.add(mesh);
}
createShaderMaterial(uniforms: uniformsParams) {
const shaderMaterial = new ShaderMaterial({
uniforms: {
opacity: { value: uniforms.opacity },
heatMapTexture: { value: uniforms.heatMapTexture },
colorTexture: { value: uniforms.colorTexture },
},
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform float opacity;
uniform sampler2D heatMapTexture;
uniform sampler2D colorTexture;
void main(){
vec4 colors = texture2D(heatMapTexture, vUv);
float a = colors.a * opacity;
vec4 colorsFinal = texture2D(colorTexture, vec2(a, 0));
gl_FragColor = colorsFinal;
}
`,
transparent: true,
depthTest: false,
blending: AdditiveBlending,
});
return shaderMaterial;
}
四、后期处理 EffectComposer
问题描述
假设现在需要在地图上,画出两个热力点, 如下图所示。
这时候,两个热力点交集部分,是不是被着色了2次 ? 答案是肯定的。
有没有办法,将交集部分,获得统一的不透明度 opacity, 再进行一次颜色映射?
当然可以,这里涉及到 Three.js 后期处理 EffectComposer。

EffectComposer 用途
后期处理,是 three.js 做 3D 应用常见的方式,核心优势:
- 增强视觉效果 ------ 在原有效果上, 再加上一层处理
- 性能优化 ------ 例如上面所说,可以减少着色次数
- 易于集成与使用 ------ 可以写得很通用, 在多个场景中应用
关键代码剖析
假设现在已经做了一个 3D 应用, 这个应用包含 Three.js 常见的几个概念: scene、camera、renderer 。 现在,如果加上后期处理, 关键代码如下:
js
//创建EffectComposer
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
我们的目标是要做热力图, 假设,现在已经在地图上添加了很多热力点平面, 每个热力点平面用到了贴图1,也就是那张黑白的贴图, 多个热力点进行叠合, 按这样的思路, 最终画出来的, 就是黑白配色的热力图, 现在就需要加一层后期处理, 将黑白配色,转化成彩色的, 那么,其实只需要加一个染色的 Shader 即可, 代码如下:
js
addShader(composer: any) {
const colorFullShader = {
uniforms: {
tDiffuse: { value: null },
opacity: { value: 1 },
colorTexture: { value: this.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;
}
`,
};
const colorFullPass = new ShaderPass(colorFullShader);
composer.addPass(colorFullPass);
}
上面的代码中, tDiffuse 在 uniform 中,固定这样写就可以了, 不用问为什么。
tDiffuse含义:
执行后处理操作之前, 页面的呈现效果。 也就是如果你不执行后处理操作, 页面展示的画面, 将会是 tDiffuse 这张图。 那么, 我们做热力图的染色, 就是在原有图层 tDiffuse 上取到每一个像素的黑白灰程度, 再映射成彩色, 逻辑如上面的 Shader 所示。
疑问:
这时候你可能说, 那页面不全是黑白灰的热力点, 可能还有其他的 mesh, 你这样处理, 是不是把那些非热力点的Mesh 也染了颜色? 是的, 所以,可能通过 renderer 叠加, 将热力点单独抽出来, 放在一个 renderer 和 scene 里面, 再共用一个 camera, 将两个 renderer 的 dom 叠加在一起, 但要注意 z-index 这个设置。
五、热力点分离和聚合

问题描述
如上图所示, 左右两边的数据是一样的, 热力点的数量也是一样的, 但是缩放程度不一样, 怎么实现当放大时, 热力点分离, 缩小时, 热力点聚合?
解决思路
其实思路很简单, 那就是实时监控 three.js 的缩放程度, 再改变热力点的大小, 使得热力点的大小看起来永远都不会变,这样,当放大时, 热力点就会分裂, 缩小时, 热力点就会聚合, 跟上图一样的效果,这样更逼真,用户体验也会更好。
代码如下
js
addControlsEvent(controls: OrbitControls) {
const newZoom = Math.floor(controls.getDistance());
this.initZoom = newZoom;
this.lastZoom = newZoom;
//注意一下,这里可以加上节流和防抖
controls.addEventListener('change', () => {
const zoom = Math.floor(controls.getDistance());
if (zoom !== this.lastZoom) {
this.lastZoom = zoom;
this.changeSize();
}
});
}
changeSize() {
const newSize = this.lastZoom / this.initZoom;
this.fixedObjs.forEach((element: Mesh) => {
element.scale.set(newSize, newSize, 1);
});
}
上面方法 addControlsEvent 就是监控缩放, 缩放到一定程度, 触发 changeSize 去改变热力点, changeSize 方法中, fixedObjs 保留了所有的热力点, 遍历进行尺寸改变。
六、性能优化
还剩下一个老生常谈的问题, 那就是性能优化。
热力点肯定是很多的,每个热力点都是用了相同的几何体(平面),相同的贴图(黑白配色贴图), 相同的材质, 这里, 最好的实现方式, 并不是像上面所说的,将一个个平面加入 3D 世界中, 这样会引发很多的 DrawCall, 使得页面性能不佳,最好是通过实例化网格 InstancedMesh
。
InstancedMesh
是一种特殊的 Mesh,在 形状
和 材质
都一样的情况下,它可以复制大量仅 矩阵变换
不同的物体。(简单说就是,InstancedMesh
可以影分身,把本体复制出很多个,放在不同的位置)。
实时渲染的时候,哪怕性能再高,也扛不住每帧对几十上百万的海量点的遍历。 InstancedMesh
的方案能有效将页面的性能控制在一个非常合理的范围。
后面,我将抽时间,改成 InstancedMesh 的实现方式, 并给出 Demo, 敬请期待~