Three.js 利用 shader 实现 3D 热力图

最终效果

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

前言

可能会有这样的需求, 希望在原来 three.js 3D 地图的基础上,加上热力图。

常见方案

  1. 高德地图 本身支持在基础地图之上,加上热力图
  2. Echarts 也支持热力图
  3. Heatmap.js 类似这种第三方库,也是可以直接拿来用,而且还非常简单好用

问题场景

我现在不是在 web 2D 场景下画出热力图, 是要在 3D 场景下画出热力图,以上几种方式,好像都不是很理想, 最终想了下, 还是自己想个方案来实现。

涉及知识点

  1. Three.js 相关基础知识, 以及后期处理 EffectComposer 的运用
  2. Shader 着色器编程, UV 纹理坐标
  3. Canvas 基础知识
  4. 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. 热力点黑白渐变贴图 ------ 贴图1
  2. 着色板贴图 ------ 贴图2

首先,每个热力点的数据, 设定为 (x, y, v);

其中,(x, y)表示热力点的位置, v 表示热力点的强度,可以理解为贴图的不透明度 opacity;

每个热力点, 用最简单的几何材质 PlaneGeometry, 也就是平面;

但是,这个平面还需要材质, 这里用到了着色器材质 ShaderMaterial;

着色器材质,通过 uniforms 传进去三个参数,即上面2张贴图, 还有不透明度 v。

颜色如何做映射

  1. 首先通过 uv 坐标,读取到第一张贴图的某一像素的颜色 color
  2. color 颜色是一个四分量,即 (r, g, b, a)
  3. 因为都是黑白颜色, 所以 r、g、b 这三个值对我们来说没有用;
  4. a 表示黑白纹理的不透明度, 范围是 0 ~ 1
  5. 我们又传进来一个整体的不透明度 opacity, 计算出新的颜色不透明度 a = opacity * a
  6. 这个新的不透明度 a = opacity * a,范围同样是 0 ~ 1, 因为传进去的参数 opacity 不会超过1
  7. a 其实表达了颜色的浓淡程度, 所以可以在另外一张贴图上,根据这个浓淡程度来取色
  8. 贴图2,虽然长度是 256, 但作为贴图, 这个贴图最左边坐标是0, 最右边的坐标是1
  9. 贴图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 应用常见的方式,核心优势:

  1. 增强视觉效果 ------ 在原有效果上, 再加上一层处理
  2. 性能优化 ------ 例如上面所说,可以减少着色次数
  3. 易于集成与使用 ------ 可以写得很通用, 在多个场景中应用

关键代码剖析

假设现在已经做了一个 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, 敬请期待~

相关推荐
curdcv_po5 分钟前
🔥🔥🔥结合 vue 或 react,去写three.js
前端·react.js·three.js
猫头_33 分钟前
uni-app 转微信小程序 · 避坑与实战全记录
前端·微信小程序·uni-app
天生我材必有用_吴用36 分钟前
网页接入弹窗客服功能的完整实现(Vue3 + WebSocket预备方案)
前端
海拥42 分钟前
8 Ball Pool:在浏览器里打一局酣畅淋漓的桌球!
前端
Cache技术分享1 小时前
148. Java Lambda 表达式 - 捕获局部变量
前端·后端
YGY Webgis糕手之路1 小时前
Cesium 快速入门(二)底图更换
前端·经验分享·笔记·vue
神仙别闹1 小时前
基于JSP+MySQL 实现(Web)毕业设计题目收集系统
java·前端·mysql
前端李二牛1 小时前
Web字体使用最佳实践
前端·http
YGY_Webgis糕手之路1 小时前
Cesium 快速入门(六)实体类型介绍
前端·gis·cesium
Jacob02341 小时前
UI 代码不写也行?我用 MCP Server 和 ShadCN 自动生成前端界面
前端·llm·ai编程