Cesium绘制3D热力山丘图

之前用three.js和Canvas添加热力平面和热力山丘图等高线(等值线),热力阶梯,是时候给cesium也添加个热力!

1.用Canvas绘制热力图

实现流程:

  • 将经纬度作为像素坐标xy,计算Canvas大小、点的值范围、经纬度范围等相关信息,为绘制Canvas热力做准备

其中经纬度作为像素坐标xy采用了墨卡托投影,可以参考我之前的文从零开始用Canvas画一个2D地图

ts 复制代码
  const zoom = 11;
  const info: any = {
    max: Number.MIN_SAFE_INTEGER,
    min: Number.MAX_SAFE_INTEGER,
    maxlng: Number.MIN_SAFE_INTEGER,
    minlng: Number.MAX_SAFE_INTEGER,
    maxlat: Number.MIN_SAFE_INTEGER,
    minlat: Number.MAX_SAFE_INTEGER,

    maxlng1: Number.MIN_SAFE_INTEGER,
    minlng1: Number.MAX_SAFE_INTEGER,
    maxlat1: Number.MIN_SAFE_INTEGER,
    minlat1: Number.MAX_SAFE_INTEGER,
    data: []
  };
  mockdata.forEach((item: any) => {
    const [lng, lat] = SphericalMercator.lnglat2px([item.lng, item.lat], zoom);
    item.lat1 = lat;
    item.lng1 = lng;

    info.max = Math.max(item.value, info.max);
    info.min = Math.min(item.value, info.min);

    info.maxlng1 = Math.max(lng, info.maxlng1);
    info.maxlat1 = Math.max(lat, info.maxlat1);

    info.minlng1 = Math.min(lng, info.minlng1);
    info.minlat1 = Math.min(lat, info.minlat1);
    info.data.push(item);
  });
  info.size = info.max - info.min;

  const radius = 20;
  info.minlng1 -= radius;
  info.minlat1 -= radius;
  info.maxlng1 += radius;
  info.maxlat1 += radius;
  info.sizelng = info.maxlng1 - info.minlng1;
  info.sizelat = info.maxlat1 - info.minlat1;
  //加上半径radius后的经纬度坐标范围
  const minpoint = SphericalMercator.px2lnglat([info.minlng1, info.minlat1], zoom);
  const maxpoint = SphericalMercator.px2lnglat([info.maxlng1, info.maxlat1], zoom);
  info.minlng = Math.min(minpoint[0], maxpoint[0]);
  info.minlat = Math.min(minpoint[1], maxpoint[1]);
  info.maxlng = Math.max(minpoint[0], maxpoint[0]);
  info.maxlat = Math.max(minpoint[1], maxpoint[1]);
  • 根据点坐标在Canvas上绘制径向渐变变圆形,其透明度根据当前点的值大小对应映射
ts 复制代码
export function drawCircle(ctx: CanvasRenderingContext2D, option: HeatMapOptionType, item) {
  const {lat1, lng1, value} = item;

  //在Canvas上的坐标
  const x = lng1 - option.minlng1;
  const y = lat1 - option.minlat1;

  const grad = ctx.createRadialGradient(x, y, 0, x, y, option.radius);
  grad.addColorStop(0.0, "rgba(0,0,0,1)");
  grad.addColorStop(1.0, "rgba(0,0,0,0)");
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(x, y, option.radius, 0, 2 * Math.PI);
  ctx.closePath();
  //根据当前值绘制不同透明度的渐变黑色圆形
  ctx.globalAlpha = (value - option.min) / option.size;
  ctx.fill();
}
  • 绘制热力颜色映射Canvas,获取Canvas的像素颜色数组作为颜色映射
ts 复制代码
function createColors(option: HeatMapOptionType) {
  const canvas = document.createElement("canvas");
  // document.body.appendChild(canvas);
  const ctx = canvas.getContext("2d")!;
  //Canvas的ImageData颜色值范围是0~255,渐变条256个像素值对应颜色映射
  canvas.width = 256;
  canvas.height = 1;
  const grad = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
  for (const k in option.colors) {
    grad.addColorStop(Number(k), option.colors[k]);
  }

  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  return ctx.getImageData(0, 0, canvas.width, 1).data;
}
  • 将黑白的热力图进行热力颜色映射,转换成彩色。
ts 复制代码
export function createHeatmap(option: HeatMapOptionType) {
  const canvas = document.createElement("canvas");

  canvas.width = option.width;
  canvas.height = option.height;
  const ctx = canvas.getContext("2d")!;

  //根据数据绘制热力范围圆形
  option.data.forEach((item) => {
    drawCircle(ctx, option, item);
  });

  //颜色映射
  const colorData = createColors(option);
  //将黑白透明度的热力圆转换成彩色热力
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  for (let i = 3; i < imageData.data.length; i = i + 4) {
    const opacity = imageData.data[i];
    const offset = opacity * 4;
    //red
    imageData.data[i - 3] = colorData[offset];
    //green
    imageData.data[i - 2] = colorData[offset + 1];
    //blue
    imageData.data[i - 1] = colorData[offset + 2];
  }

  //修改后的像素数据赋值回Canvas
  ctx.putImageData(imageData, 0, 0);
  return canvas;
}

上图的呈现的热力效果是根据深圳市行政边界随机生成的模拟数据

Canvas绘制热力图需要配置相关参数说明

ts 复制代码
export type HeatMapOptionType = {
  //canvas大小
  width: number;
  height: number;
  //lat1,lng1,经纬度经过墨卡托投影转换后的像素坐标
  data: Array<{lat1: number; lng1: number; value: number}>;
  //value值的范围
  size: number;
  //value最大值
  max: number;
  //value最小值
  min: number;
  //颜色映射
  colors: {[n: number]: string};
  //热力半径像素
  radius: number;
  //最小经度投影像素坐标
  minlng1: number;
  //最小纬度投影像素坐标
  minlat1: number;
};

更详细的如何用Canvas绘制热力图,可以参考我之前的文用Three.js搞个炫酷热力山丘图

2. 给Cesium添加热力图平面

  • Canvas热力图转成base64
ts 复制代码
const {heatmapCanvas, info} = getHeatmap();
    this.heatmapCanvasData = heatmapCanvas.toDataURL();
    this.info = info;
  • 创建贴图材质
ts 复制代码
const material = Cesium.Material.fromType("Image", {
      image: this.heatmapCanvasData
    });
    const appearance = new Cesium.EllipsoidSurfaceAppearance({
      material
    });
  • 根据数据最大最小经纬度,创建长方形平面
ts 复制代码
const geometry = new Cesium.RectangleGeometry({
      rectangle: Cesium.Rectangle.fromDegrees(this.info.minlng, this.info.minlat, this.info.maxlng, this.info.maxlat)
    });
  • 创建贴地图元,避免看不见
ts 复制代码
 const heat = new Cesium.GroundPrimitive({
      geometryInstances: new Cesium.GeometryInstance({
        geometry
      }),
      appearance
    });

    this.viewer.scene.primitives.add(heat);

3. 给Cesium添加热力山丘图

若需要平面可以起伏,那么平面需要足够多的三角形,而CesiumRectangleGeometry创建的长方形平面,在贴地的情况下三角形数量,不能满足需求,那么这时候需要使用自定义形状。

3.1 创建自定义平面

  • 相关参数设置: 经纬度范围lnglat,lnglat1,平面所在高度height3D,横向网格划分数widthSegments,纵向网格划分数heightSegments
ts 复制代码
  getPlaneGeometry(
    lnglat: [number, number],
    lnglat1: [number, number],
    widthSegments: number = 100,
    heightSegments: number = 100,
    height3D: number = 0
  ) {
const minlng = Math.min(lnglat[0], lnglat1[0]);
    const minlat = Math.min(lnglat[1], lnglat1[1]);
    const maxlng = Math.max(lnglat[0], lnglat1[0]);
    const maxlat = Math.max(lnglat[1], lnglat1[1]);
    const sizelng = maxlng - minlng;
    const sizelat = maxlat - minlat;
    const gridX = Math.floor(widthSegments);
    const gridY = Math.floor(heightSegments);
    //经纬度网格单位间隔
    const unitx = sizelng / gridX;
    const unity = sizelat / gridY;
    const gridX1 = gridX + 1;
}
  • 根据网格划分数量和经纬度范围,计算并收集平面的顶点,uv贴图坐标和元素索引,其中顶点坐标需要将经纬度转为Cesium三维坐标

注意: webgl三角形顶点顺序决定正反面,正面逆时针方向,反面顺时针方向。

ts 复制代码
    const indices: number[] = [];
    const vertices: number[] = [];
    const uvs: number[] = [];
    //计算并收集顶点坐标,uv贴图坐标和三角形元素索引
    for (let iy = 0; iy <= gridY; iy++) {
      for (let ix = 0; ix <= gridX; ix++) {
        //将坐标点转换成Cesium三维坐标
        const pos = Cesium.Cartesian3.fromDegrees(ix * unitx + minlng, iy * unity + minlat, height3D);
        vertices.push(pos.x, pos.y, pos.z);
        uvs.push(ix / gridX);
        uvs.push(iy / gridY);

        if (iy < gridX1) {
          // a b
          // c d
          const a = ix + gridX1 * iy;
          const b = a + 1;
          const c = ix + gridX1 * (iy + 1);
          const d = c + 1;
          indices.push(a, c, b);
          indices.push(c, d, b);
        }
      }
    }
  • 创建自定义形状Cesium.Geometry,其中attributes.position对应顶点数据,attributes.st对应uv贴图坐标,indices对应元素索引
ts 复制代码
const positions = new Float64Array(vertices);
    const geometry = new Cesium.Geometry({
      //属性值
      attributes: new Cesium.GeometryAttributes(),
      //元素索引
      indices: new Uint16Array(indices),
      //绘制类型 三角面
      primitiveType: Cesium.PrimitiveType.TRIANGLES,
      //包围框
      boundingSphere: Cesium.BoundingSphere.fromVertices(vertices, new Cesium.Cartesian3(0, 0, 0), 3)
    });
    //添加顶点数据
    geometry.attributes.position = new Cesium.GeometryAttribute({
      componentDatatype: Cesium.ComponentDatatype.DOUBLE,
     //每三个一组
      componentsPerAttribute: 3,
      values: positions
    });
    //添加uv贴图数据
    geometry.attributes.st = new Cesium.GeometryAttribute({
      componentDatatype: Cesium.ComponentDatatype.FLOAT,
      //每两个一组
      componentsPerAttribute: 2,
      values: new Float32Array(uvs)
    });
    return geometry;
  }

3.2 修改shader,绘制热力山丘

  • 打印一下内置的图片贴图材质的shader代码
ts 复制代码
  const material = Cesium.Material.fromType("Image", {
      image: this.heatmapCanvasData
    });
console.log(material.shaderSource);

可以看到以下代码

c++ 复制代码
uniform vec4 color_2;
uniform vec2 repeat_1;
uniform sampler2D image_0;
czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    material.diffuse = czm_gammaCorrect(texture(image_0, fract(repeat_1 * materialInput.st)).rgb * color_2.rgb);
    material.alpha = texture(image_0, fract(repeat_1 * materialInput.st)).a * color_2.a;
    return material;
}

不用理会那些复杂的函数,根据shader常识去猜:

  • material.diffuse即显示的颜色rgb
  • material.alpha即显示的颜色透明度

接下来,就可以修改材质shader

ts 复制代码
import materialGlsl from "./material.glsl";
 material.shaderSource = materialGlsl;
  • 材质shader,将贴图颜色直接复制给material.diffuse,而贴图透明度修改成当前的两倍,让热力贴图得更明显。
c++ 复制代码
uniform vec4 color_2;
uniform vec2 repeat_1;
uniform sampler2D image_0;
czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec4 tex = texture(image_0, materialInput.st);
    material.diffuse = tex.rgb;
    material.alpha = tex.a * 2.0;
    return material;
}
  • 热力山丘当然要有起伏的高度,故技重施打印一下顶点着色器
ts 复制代码
 const appearance = new Cesium.MaterialAppearance({
      material
});
console.log(appearance.vertexShaderSource);
c++ 复制代码
in vec3 position3DHigh;
in vec3 position3DLow;
in vec3 normal;
in vec2 st;
in float batchId;

out vec3 v_positionEC;
out vec3 v_normalEC;
out vec2 v_st;

void main()
{
    vec4 p = czm_computePosition();

    v_positionEC = (czm_modelViewRelativeToEye * p).xyz;      // position in eye coordinates
    v_normalEC = czm_normal * normal;                         // normal in eye coordinates
    v_st = st;

    gl_Position = czm_modelViewProjectionRelativeToEye * p;
}

跟我们之前常写的顶点着色器差不多,只不过Cesium通过czm_computePosition()函数计算获得顶点位置

因为Cesium的点分布在球体表面,跟我们平常的坐标有点不一样,所以需要山丘起伏的高度对应乘以该点的标准向量,

ts 复制代码
import vertexGlsl from "./vertex.glsl";
 const appearance = new Cesium.MaterialAppearance({
      material,
      vertexShaderSource: vertexGlsl
    });
  • 顶点着色器,起伏高度根据透明度计算,另外czm_computePosition()得到的点不是经过模型变换矩阵的点,所以需要乘以模型变换矩阵czm_inverseModelView才能得到原始点坐标,在根据该原始点计算出其标准向量。
c++ 复制代码
in vec3 position3DHigh;
in vec3 position3DLow;
in vec3 normal;
in vec2 st;

uniform sampler2D image_0;
in float batchId;

out vec3 v_positionEC;
out vec3 v_normalEC;
out vec2 v_st;

void main() {
        vec4 p = czm_computePosition();

        v_positionEC = (czm_modelViewRelativeToEye * p).xyz;      // position in eye coordinates
        v_normalEC = czm_normal * normal;                         // normal in eye coordinates
        v_st = st;
        //贴图颜色
        vec4 tex = texture(image_0, st);
        //原始点坐标
        vec4 orgPos = czm_inverseModelView * p;
        //起伏高度
        p.xyz += tex.a * 5000.0 * normalize(orgPos.xyz);
        gl_Position = czm_modelViewProjectionRelativeToEye * p;
}
  • 添加热力山丘图元,其中需要将异步参数asynchronous置为false,避免自定义形状Geometry报错。
ts 复制代码
const heat = new Cesium.Primitive({
      geometryInstances: new Cesium.GeometryInstance({
        geometry: this.getPlaneGeometry(
          [this.info.minlng, this.info.minlat],
          [this.info.maxlng, this.info.maxlat],
          400,
          200,
          100
        )
      }),
      appearance,
      asynchronous: false
    });
    this.viewer.scene.primitives.add(heat);

噔噔噔,大功告成!

3.3 写个vite glsl小插件

vite环境直接引入glsl会报错,只需要自己写个简单的插件GlslPlugin

  • 预处理插件,将glsl识别为字符串。
ts 复制代码
import {type Plugin} from "vite";
const fileRegex = /\.(glsl)$/;
export default function WgslPlugin(): Plugin {
  return {
    // 插件名称
    name: "vite:glsl",
    //使用时机,是编译前还是编译后
    enforce: "pre",
    // 代码转译,这个函数的功能类似于 `webpack` 的 `loader`,编译输出为js可读的文件
    transform(code, id, opt) {
      //匹配要处理的文件类型
      if (fileRegex.test(id)) {
        return {code: `export default \`${code}\``, map: null};
      }
    }
  };
}
  • 配置vite.config.ts
javascript 复制代码
import GlslPlugin from "./glslPlugin";

export default{
 plugins: [GlslPlugin()],
}
  • 配置tsconfig.node.json
ts 复制代码
{
"include": ["vite.config.ts", "glslPlugin.ts"]
}

可以看到glsl文件作为一个export default为字符串的js

4. Github

https://github.com/xiaolidan00/cesium-demo

参考:

相关推荐
德育处主任12 小时前
p5.js 四边形(quad)的基础用法
前端·数据可视化·canvas
德育处主任12 小时前
p5.js 入门:用 point () 绘制点的超简单教程
前端·javascript·canvas
叫我:松哥21 小时前
基于网络爬虫的在线医疗咨询数据爬取与医疗服务分析系统,技术采用django+朴素贝叶斯算法+boostrap+echart可视化
人工智能·爬虫·python·算法·django·数据可视化·朴素贝叶斯
Watermelo6171 天前
极致的灵活度满足工程美学:用Vue Flow绘制一个完美流程图
前端·javascript·vue.js·数据挖掘·数据分析·流程图·数据可视化
山烛1 天前
Python 数据可视化之 Matplotlib 库
开发语言·python·matplotlib·数据可视化
德育处主任2 天前
p5.js 线段的用法
javascript·数据可视化·canvas
云天徽上2 天前
【数据可视化-70】奶茶店销量数据可视化:打造炫酷黑金风格的可视化大屏
python·信息可视化·数据分析·数据可视化·pyecharts
云天徽上2 天前
【数据可视化-72】苏超第七轮战罢:黑金大屏下的足球数据洞察(含完整代码、数据和大屏)
信息可视化·数据挖掘·数据分析·数据可视化·pyecharts·苏超
FastCAE20222 天前
首发即开源!DAWorkBench数据可视化分析软件正式发布!(附源码下载网址)
数据可视化