前言
风场可视化是气象数据展示中最具挑战性的任务之一。传统的 Canvas 2D 方案在处理大量粒子时性能堪忧,而 Mapjar 基于 WebGL2 的粒子系统可以轻松渲染 50000+ 粒子,实现流畅的 60 FPS 动画效果。
本文将详细介绍如何使用 Mapjar 的 WindLayer 实现专业级的风场动画效果。
核心技术架构
1. GPU 粒子系统
Mapjar 的 WindLayer 采用完全基于 GPU 的粒子系统:
- 粒子状态纹理:使用浮点纹理存储每个粒子的位置、年龄、生命周期
- 双缓冲技术:乒乓缓冲避免纹理反馈循环
- 计算着色器:在 GPU 上更新粒子状态,CPU 零负担
- 屏幕空间拖尾:使用帧缓冲实现平滑的轨迹效果
2. 数据格式
风场数据使用图片编码,高效且易于传输:
R 通道:归一化的 U 分量(东西向风速)
G 通道:归一化的 V 分量(南北向风速)
B 通道:保留(未使用)
A 通道:有效性标记(0 = 无效数据,1 = 有效数据)
优势:
- 利用 GPU 纹理硬件加速
- 数据压缩率高(PNG 格式)
- 支持 Alpha 通道标记无效区域
- 浏览器原生支持,无需额外解码
快速开始
安装
npm install mapjar
基础示例
import { MapEngine, TileLayer, WindLayer } from 'mapjar';
// 1. 创建地图引擎
const engine = new MapEngine('#map', {
center: [105, 29],
zoom: 4,
});
// 2. 添加底图
const tileLayer = new TileLayer(
'osm',
'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
);
engine.addLayer(tileLayer);
// 3. 创建风场图层
const windLayer = new WindLayer('wind', {
particleCount: 10000, // 粒子数量
speedFactor: 0.5, // 速度因子
fadeOpacity: 0.96, // 拖尾透明度
particleAge: 200, // 粒子生命周期(帧数)
wrapX: true, // 跨世界渲染
colorRamp: ['#ffffff'], // 白色粒子
});
// 4. 加载风场数据
const windData = await loadWindDataFromImage(
'wind-data.png',
{
width: 1001,
height: 561,
minU: -12.35,
maxU: 22.81,
minV: -22.71,
maxV: 14.65,
bounds: {
minLon: 55,
minLat: 1,
maxLon: 155,
maxLat: 57,
}
}
);
windLayer.setData(windData);
engine.addLayer(windLayer);
数据加载详解
从图片加载风场数据
async function loadWindDataFromImage(
url: string,
config: {
width: number;
height: number;
minU: number;
maxU: number;
minV: number;
maxV: number;
bounds: {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
};
}
): Promise<WindData> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// 创建离屏 canvas 读取像素数据
const canvas = document.createElement('canvas');
canvas.width = config.width;
canvas.height = config.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, config.width, config.height);
const pixels = imageData.data;
// 创建 UV 数据数组
const uv = new Float32Array(config.width * config.height * 2);
const alpha = new Float32Array(config.width * config.height);
for (let i = 0; i < config.width * config.height; i++) {
const pixelIndex = i * 4;
// 读取归一化的 U 和 V
const r = pixels[pixelIndex] / 255;
const g = pixels[pixelIndex + 1] / 255;
const a = pixels[pixelIndex + 3] / 255;
alpha[i] = a;
if (a === 0) {
// 无效数据区域
uv[i * 2] = 0;
uv[i * 2 + 1] = 0;
} else {
// 反归一化到实际风速值
const u = config.minU + r * (config.maxU - config.minU);
const v = config.minV + g * (config.maxV - config.minV);
uv[i * 2] = u;
uv[i * 2 + 1] = v;
}
}
resolve({
uv,
alpha,
width: config.width,
height: config.height,
minU: config.minU,
maxU: config.maxU,
minV: config.minV,
maxV: config.maxV,
bounds: config.bounds,
});
};
img.onerror = reject;
img.src = url;
});
}
数据准备流程
- 获取原始风场数据(如 GRIB2 格式)
- 归一化处理:
# Python 示例 u_normalized = (u - min_u) / (max_u - min_u) v_normalized = (v - min_v) / (max_v - min_v) - 生成图片:
from PIL import Image import numpy as np # 创建 RGBA 图像 img = np.zeros((height, width, 4), dtype=np.uint8) img[:, :, 0] = (u_normalized * 255).astype(np.uint8) # R: U img[:, :, 1] = (v_normalized * 255).astype(np.uint8) # G: V img[:, :, 3] = alpha_mask * 255 # A: 有效性 Image.fromarray(img).save('wind-data.png')
参数调优指南
核心参数详解
1. particleCount(粒子数量)
影响: 视觉密度和性能
particleCount: 10000 // 推荐值:5000-20000
- 5000:适合移动设备,性能优先
- 10000:桌面端默认值,平衡性能和效果
- 20000+:高端设备,追求极致视觉效果
性能测试:
- 5000 粒子:~2ms/帧
- 10000 粒子:~3ms/帧
- 20000 粒子:~5ms/帧
2. speedFactor(速度因子)
影响: 粒子移动速度
speedFactor: 0.5 // 推荐值:0.1-2.0
- 0.1-0.3:慢速,适合观察细节
- 0.5:默认值,平衡速度
- 1.0-2.0:快速,动感强烈
注意: 速度过快会导致轨迹断裂,过慢则不够生动。
3. fadeOpacity(拖尾透明度)
影响: 轨迹长度和视觉效果
fadeOpacity: 0.96 // 推荐值:0.90-0.98
衰减计算:
0.90:10 帧后剩余 35%,轨迹短而清晰0.94:10 帧后剩余 54%,轨迹适中0.96:10 帧后剩余 66%,轨迹长而流畅0.98:10 帧后剩余 82%,轨迹很长
选择建议:
- 白色粒子:0.94-0.96(避免灰色残影)
- 彩色粒子:0.96-0.98(颜色渐变更明显)
4. particleAge(粒子生命周期)
影响: 粒子存活时间
particleAge: 200 // 推荐值:80-400(帧数)
- 80-120:短生命周期,粒子更新快
- 200:默认值,平衡效果
- 300-400:长生命周期,轨迹更连续
换算: 200 帧 ≈ 3.3 秒(60 FPS)
5. colorRamp(颜色渐变)
影响: 视觉风格
// 单色(白色)
colorRamp: ['#ffffff']
// 根据风速渐变(蓝→绿→黄→红)
colorRamp: [
'#3288bd', // 低速:蓝色
'#66c2a5',
'#abdda4',
'#e6f598',
'#fee08b',
'#fdae61',
'#f46d43',
'#d53e4f', // 高速:红色
]
应用场景:
- 白色:叠加在彩色热力图上
- 渐变色:独立展示风场,直观显示风速
高级应用场景
场景 1:风场 + 热力图叠加
// 1. 创建热力图层(温度)
const heatmapLayer = new HeatmapLayer('temperature', {
colorRamp: [
{ value: 0.0, color: '#313695' },
{ value: 0.5, color: '#ffffbf' },
{ value: 1.0, color: '#a50026' },
],
});
// 2. 加载温度数据
const tempData = await loadHeatmapData('temperature.png');
heatmapLayer.setData(tempData);
heatmapLayer.setOpacity(0.7);
engine.addLayer(heatmapLayer);
// 3. 叠加白色风场
const windLayer = new WindLayer('wind', {
particleCount: 50000,
speedFactor: 0.3,
fadeOpacity: 0.94,
particleAge: 120,
colorRamp: ['#ffffff'], // 白色粒子
});
const windData = await loadWindData('wind.png');
windLayer.setData(windData);
engine.addLayer(windLayer);
效果: 同时看到温度分布和风向风速,适合气象分析。
场景 2:多时次风场动画
const timestamps = [
'2025111800',
'2025111803',
'2025111806',
'2025111809',
];
let currentIndex = 0;
// 定时切换风场数据
setInterval(async () => {
const timestamp = timestamps[currentIndex];
const windData = await loadWindData(`wind_${timestamp}.png`);
windLayer.setData(windData);
currentIndex = (currentIndex + 1) % timestamps.length;
}, 3000); // 每 3 秒切换
效果: 展示风场随时间的演变。
场景 3:交互式风场探索
// 点击地图获取风速信息
engine.on('click', (event) => {
const { lon, lat } = event;
// 从风场数据中查询该点的风速
const windSpeed = queryWindSpeed(windData, lon, lat);
// 显示信息弹窗
showPopup(lon, lat, {
speed: windSpeed.magnitude.toFixed(2),
direction: windSpeed.direction.toFixed(0),
});
});
function queryWindSpeed(data: WindData, lon: number, lat: number) {
// 将经纬度转换为数据索引
const x = Math.floor(
((lon - data.bounds.minLon) /
(data.bounds.maxLon - data.bounds.minLon)) * data.width
);
const y = Math.floor(
((lat - data.bounds.minLat) /
(data.bounds.maxLat - data.bounds.minLat)) * data.height
);
const index = y * data.width + x;
const u = data.uv[index * 2];
const v = data.uv[index * 2 + 1];
return {
magnitude: Math.sqrt(u * u + v * v),
direction: Math.atan2(v, u) * 180 / Math.PI,
};
}
性能优化技巧
1. 动态调整粒子数量
根据缩放级别自动调整粒子数量:
engine.getCamera().on('zoomend', () => {
const zoom = engine.getZoom();
let particleCount;
if (zoom < 3) {
particleCount = 5000; // 全球视图:少量粒子
} else if (zoom < 6) {
particleCount = 10000; // 区域视图:中等粒子
} else {
particleCount = 20000; // 局部视图:大量粒子
}
// 重新创建图层
updateWindLayer({ particleCount });
});
2. 交互时暂停渲染
Mapjar 内置了交互检测,拖拽和缩放时自动暂停风场渲染,交互结束后恢复。
原理:
// WindLayer 内部实现
private hasViewMatrixChanged(viewMatrix: Float32Array): boolean {
// 检测视图变化
if (changed) {
this.markInteracting(); // 标记为交互中
}
return changed;
}
private markInteracting(): void {
this.isInteracting = true;
// 150ms 后恢复渲染
setTimeout(() => {
this.isInteracting = false;
}, 150);
}
3. 按需加载数据
只加载当前视口范围的风场数据:
async function loadVisibleWindData(bounds: BBox) {
// 根据视口范围请求数据
const url = `/api/wind?` +
`minLon=${bounds.minLon}&maxLon=${bounds.maxLon}&` +
`minLat=${bounds.minLat}&maxLat=${bounds.maxLat}`;
const response = await fetch(url);
const data = await response.json();
return data;
}
// 监听视口变化
engine.on('moveend', async () => {
const bounds = engine.getBounds();
const windData = await loadVisibleWindData(bounds);
windLayer.setData(windData);
});
4. 使用 Web Worker 预处理数据
// wind-processor.worker.ts
self.onmessage = async (e) => {
const { imageUrl, config } = e.data;
// 在 Worker 中加载和处理图片
const response = await fetch(imageUrl);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
// 读取像素数据
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const windData = processWindData(imageData, config);
self.postMessage(windData);
};
// 主线程
const worker = new Worker('wind-processor.worker.ts');
worker.postMessage({ imageUrl, config });
worker.onmessage = (e) => {
windLayer.setData(e.data);
};
常见问题与解决方案
Q1: 粒子轨迹太短,看不清楚
原因: fadeOpacity 设置过低或 particleAge 太短
解决方案:
// 增加拖尾透明度和生命周期
const windLayer = new WindLayer('wind', {
fadeOpacity: 0.96, // 提高到 0.96
particleAge: 200, // 增加到 200 帧
speedFactor: 0.5, // 适当提高速度
});
Q2: 有灰色残影,轨迹不消失
原因: fadeOpacity 设置过高(如 0.996)
解决方案:
// 降低拖尾透明度
const windLayer = new WindLayer('wind', {
fadeOpacity: 0.94, // 降低到 0.94
particleAge: 120, // 缩短生命周期
});
计算公式:
- 10 帧后剩余:
fadeOpacity^10 - 20 帧后剩余:
fadeOpacity^20 - 建议保持 20 帧后 < 30%
Q3: 粒子移动太慢或太快
原因: speedFactor 设置不当
解决方案:
// 根据数据范围调整
const windRange = Math.sqrt(
(maxU - minU) ** 2 + (maxV - minV) ** 2
);
let speedFactor;
if (windRange < 10) {
speedFactor = 1.0; // 弱风:加快显示
} else if (windRange < 30) {
speedFactor = 0.5; // 中等风速
} else {
speedFactor = 0.3; // 强风:减慢显示
}
Q4: 性能问题,帧率低
诊断步骤:
- 检查粒子数量
console.log('Particle count:', windLayer.getParticleCount());
// 建议:移动端 < 10000,桌面端 < 20000
- 检查数据尺寸
console.log('Data size:', windData.width, 'x', windData.height);
// 建议:< 2000 x 2000
- 使用性能监控
const stats = new Stats();
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
engine.render();
stats.end();
requestAnimationFrame(animate);
}
优化方案:
- 降低粒子数量
- 减小数据分辨率
- 禁用跨世界渲染(如果不需要)
- 使用较短的
particleAge
Q5: 数据边界有断层
原因: 数据范围与地图范围不匹配
解决方案:
// 确保 bounds 与实际数据范围一致
const windData = {
uv: ...,
width: 1001,
height: 561,
minU: -12.35,
maxU: 22.81,
minV: -22.71,
maxV: 14.65,
bounds: {
minLon: 55, // 必须与数据实际范围匹配
minLat: 1,
maxLon: 155,
maxLat: 57,
},
};
最佳实践总结
1. 参数配置推荐
通用场景(白色粒子 + 热力图):
{
particleCount: 50000,
speedFactor: 0.3,
fadeOpacity: 0.94,
particleAge: 120,
colorRamp: ['#ffffff'],
}
独立风场展示(彩色渐变):
{
particleCount: 10000,
speedFactor: 0.5,
fadeOpacity: 0.96,
particleAge: 200,
colorRamp: [
'#3288bd', '#66c2a5', '#abdda4', '#e6f598',
'#fee08b', '#fdae61', '#f46d43', '#d53e4f',
],
}
移动端优化:
{
particleCount: 5000,
speedFactor: 0.5,
fadeOpacity: 0.94,
particleAge: 100,
colorRamp: ['#ffffff'],
}
2. 数据准备清单
- 确保数据已归一化到 [0, 1]
- 使用 PNG 格式(支持 Alpha 通道)
- 记录原始数据的 min/max 值
- 标记无效数据区域(Alpha = 0)
- 验证数据地理范围
3. 性能检查清单
- 粒子数量 < 20000(桌面端)
- 数据分辨率 < 2000x2000
- 帧率稳定在 60 FPS
- 内存占用 < 500MB
- 首次加载时间 < 3 秒
4. 视觉效果检查清单
- 轨迹长度适中(0.5-2 秒)
- 无明显灰色残影
- 粒子分布均匀
- 颜色对比度合适
- 无数据断层或空白
技术原理深入
GPU 粒子系统架构
┌─────────────────────────────────────────┐
│ 粒子状态纹理(双缓冲) │
│ Texture0 ←→ Texture1 │
│ [x, y, age, life] × N 个粒子 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 更新着色器(Compute) │
│ 1. 读取当前状态 │
│ 2. 采样风场纹理 │
│ 3. 更新位置和年龄 │
│ 4. 写入新状态 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 屏幕纹理(双缓冲) │
│ 1. 读取上一帧 │
│ 2. 应用淡化效果 │
│ 3. 绘制新粒子 │
│ 4. 输出到屏幕 │
└─────────────────────────────────────────┘
关键着色器代码
粒子更新着色器:
// 采样风场
vec2 wind = texture(u_wind, pos).rg;
vec2 normalizedSpeed = wind / windRange;
// 更新位置
pos = pos + normalizedSpeed * u_speedFactor * 0.01;
// 更新年龄
age += 1.0;
// 检查是否需要重置
if (age > life || outOfBounds(pos)) {
pos = randomPosition();
age = 0.0;
}
拖尾淡化着色器:
vec4 color = texture(u_screen, v_texCoord);
// 线性淡化
float fadedAlpha = color.a * u_fadeOpacity;
// 阈值丢弃
if (fadedAlpha < 0.005) {
discard;
}
fragColor = vec4(color.rgb * u_fadeOpacity, fadedAlpha);
实战案例:气象预报系统
完整实现
import { MapEngine, TileLayer, WindLayer, HeatmapLayer } from 'mapjar';
class WeatherVisualization {
private engine: MapEngine;
private windLayer: WindLayer;
private heatmapLayer: HeatmapLayer;
constructor(container: string) {
// 初始化地图
this.engine = new MapEngine(container, {
center: [105, 29],
zoom: 4,
});
// 添加底图
const tileLayer = new TileLayer(
'base',
'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
);
this.engine.addLayer(tileLayer);
// 初始化图层
this.initLayers();
// 加载数据
this.loadData();
}
private initLayers() {
// 温度热力图
this.heatmapLayer = new HeatmapLayer('temperature', {
colorRamp: [
{ value: 0.0, color: '#313695' },
{ value: 0.2, color: '#4575b4' },
{ value: 0.4, color: '#abd9e9' },
{ value: 0.6, color: '#fee090' },
{ value: 0.8, color: '#f46d43' },
{ value: 1.0, color: '#a50026' },
],
});
this.heatmapLayer.setOpacity(0.7);
this.engine.addLayer(this.heatmapLayer);
// 风场动画
this.windLayer = new WindLayer('wind', {
particleCount: 50000,
speedFactor: 0.3,
fadeOpacity: 0.94,
particleAge: 120,
colorRamp: ['#ffffff'],
});
this.engine.addLayer(this.windLayer);
}
private async loadData() {
try {
// 并行加载温度和风场数据
const [tempData, windData] = await Promise.all([
this.loadTemperatureData(),
this.loadWindData(),
]);
this.heatmapLayer.setData(tempData);
this.windLayer.setData(windData);
console.log('数据加载完成');
} catch (error) {
console.error('数据加载失败:', error);
}
}
private async loadTemperatureData() {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://api.example.com/temperature.png';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const bitmap = await createImageBitmap(img);
return {
image: bitmap,
bounds: {
minLon: 55,
minLat: 1,
maxLon: 155,
maxLat: 57,
},
};
}
private async loadWindData() {
// 使用前面定义的 loadWindDataFromImage 函数
return loadWindDataFromImage(
'https://api.example.com/wind.png',
{
width: 1001,
height: 561,
minU: -12.35,
maxU: 22.81,
minV: -22.71,
maxV: 14.65,
bounds: {
minLon: 55,
minLat: 1,
maxLon: 155,
maxLat: 57,
},
}
);
}
// 切换图层可见性
toggleWind(visible: boolean) {
this.windLayer.setVisible(visible);
}
toggleTemperature(visible: boolean) {
this.heatmapLayer.setVisible(visible);
}
// 更新时次
async updateTimestamp(timestamp: string) {
const [tempData, windData] = await Promise.all([
this.loadTemperatureData(timestamp),
this.loadWindData(timestamp),
]);
this.heatmapLayer.setData(tempData);
this.windLayer.setData(windData);
}
// 销毁
destroy() {
this.engine.destroy();
}
}
// 使用
const weather = new WeatherVisualization('#map');
// 控制面板
document.getElementById('toggle-wind')?.addEventListener('change', (e) => {
weather.toggleWind((e.target as HTMLInputElement).checked);
});
document.getElementById('toggle-temp')?.addEventListener('change', (e) => {
weather.toggleTemperature((e.target as HTMLInputElement).checked);
});
Mapjar 的 WindLayer 提供了专业级的风场可视化能力:
✅ 高性能:50000+ 粒子,60 FPS 流畅动画
✅ 易用性:简洁的 API,5 分钟快速上手
✅ 灵活性:丰富的参数配置,适应各种场景
✅ 可扩展:支持多图层叠加,交互式探索
无论是气象预报、环境监测,还是科学研究,Mapjar 都能提供出色的风场可视化解决方案。
相关资源
- API 文档:完整文档
欢迎进群交流!!!