使用 Mapjar 实现高性能风场动画可视化

95 阅读10分钟

前言

风场可视化是气象数据展示中最具挑战性的任务之一。传统的 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;
  });
}

数据准备流程

  1. 获取原始风场数据(如 GRIB2 格式)
  2. 归一化处理
    # Python 示例
    u_normalized = (u - min_u) / (max_u - min_u)
    v_normalized = (v - min_v) / (max_v - min_v)
    
  3. 生成图片
    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: 性能问题,帧率低

诊断步骤:

  1. 检查粒子数量
console.log('Particle count:', windLayer.getParticleCount());
// 建议:移动端 < 10000,桌面端 < 20000
  1. 检查数据尺寸
console.log('Data size:', windData.width, 'x', windData.height);
// 建议:< 2000 x 2000
  1. 使用性能监控
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 都能提供出色的风场可视化解决方案。

相关资源

欢迎进群交流!!!

group.png