之前用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添加热力山丘图
若需要平面可以起伏,那么平面需要足够多的三角形,而Cesium
的RectangleGeometry
创建的长方形平面,在贴地的情况下三角形数量,不能满足需求,那么这时候需要使用自定义形状。
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
参考: