本文档详细描述了从NetCDF (NC) 格式的WRF风场数据到WebGL可视化数据的完整转换过程,包括数据计算、格式转换和压缩优化。
NC原始数据 → 数据提取 → 计算处理 → 数据压缩 → PNG纹理 + JSON元数据 → WebGL可视化
↓ ↓ ↓ ↓ ↓ ↓
WRF模型输出 U10,V10 全流速 元数据 纹理编码 粒子系统
文件 组件提取 方向计算 压缩 像素编码 动画渲染
- 文件来源: WRF (Weather Research and Forecasting) 模型输出
- 主要变量:
U10: 10米高度东向风速分量 (m/s)V10: 10米高度北向风速分量 (m/s)XLAT: 纬度坐标XLONG: 经度坐标
- 空间分辨率: 221×171网格点
- 地理范围:
- 经度: 104°E - 126°E
- 纬度: 14°N - 31°N
- 时间步长: 每2小时一个时间点
- 数据精度: 32位浮点数
# NC文件中的坐标转换
longitude = XLONG.flatten() # 经度数组
latitude = XLAT.flatten() # 纬度数组
# 计算地理边界
lon_min, lon_max = longitude.min(), longitude.max()
lat_min, lat_max = latitude.min(), latitude.max()// 提取U10, V10分量
const u10 = data.variables.U10[time_idx].data; // 东向风速
const v10 = data.variables.V10[time_idx].data; // 北向风速
// 展平为1D数组
const uFlat = u10.flatten();
const vFlat = v10.flatten();全风速 (Wind Speed)
speed = √(U10² + V10²)
// 计算每个网格点的风速
for (let i = 0; i < uFlat.length; i++) {
const speed = Math.sqrt(uFlat[i] * uFlat[i] + vFlat[i] * vFlat[i]);
speedArray.push(speed);
}风速统计值
speedMin: 最小风速 = Math.min(...speedArray)speedMax: 最大风速 = Math.max(...speedArray)
风向定义
- 风向角: 从正北方向顺时针测量的角度
- 0°: 正北风
- 90°: 正东风
- 180°: 正南风
- 270°: 正西风
计算公式
direction = Math.atan2(U10, V10) * 180/π + 180
// 计算风向(0-360度)
for (let i = 0; i < uFlat.length; i++) {
const direction = Math.atan2(uFlat[i], vFlat[i]) * 180 / Math.PI + 180;
directionArray.push(direction);
}风分量极值
uMin: U10分量的最小值uMax: U10分量的最大值vMin: V10分量的最小值vMax: V10分量的最大值
// 计算统计值
const uMin = Math.min(...uFlat);
const uMax = Math.max(...uFlat);
const vMin = Math.min(...vFlat);
const vMax = Math.max(...vFlat);原始问题:
- 完整坐标数组约4MB/文件
- 包含所有经纬度坐标点信息
- 造成文件过大和加载缓慢
解决方案: 只保存必要元数据
// 压缩后的元数据结构
const metadata_json = {
width: 221, // 网格宽度
height: 171, // 网格高度
uMin: -6.128969669342041, // U分量最小值
uMax: 10.277409553527832, // U分量最大值
vMin: -3.172968864440918, // V分量最小值
vMax: 8.976096153259277, // V分量最大值
speedMin: 0.022266598160266608, // 最小风速
speedMax: 11.081674543723993, // 最大风速
longitude: [104, 126], // 经度范围 [最小, 最大]
latitude: [14, 31], // 纬度范围 [最小, 最大]
source: "WRF Model Output", // 数据源
date: "2025-06-27T01:00Z" // 时间戳
};压缩效果:
- 原始文件: ~4MB (3,913,960字节)
- 压缩后: 362字节
- 压缩比: 约10,000:1
编码原理: 使用PNG图像的RGBA通道存储风场数据
- R通道 (Red): 存储U10分量(归一化到0-255)
- G通道 (Green): 存储V10分量(归一化到0-255)
- B通道 (Blue): 保持为0
- A通道 (Alpha): 保持为255(完全不透明)
// 归一化编码
const r = Math.round((uFlat[i] - uMin) / (uMax - uMin) * 255);
const g = Math.round((vFlat[i] - vMin) / (vMax - vMin) * 255);
// 创建PNG数据
pngData.data[i * 4] = r; // R
pngData.data[i * 4 + 1] = g; // G
pngData.data[i * 4 + 2] = 0; // B
pngData.data[i * 4 + 3] = 255; // A// 从JSON文件加载元数据
fetch('2025062701.json')
.then(response => response.json())
.then(windData => {
// windData包含所有必要信息
const {width, height, uMin, uMax, vMin, vMax} = windData;
});// PNG图像解码
windData.image.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = windData.width;
canvas.height = windData.height;
ctx.drawImage(windData.image, 0, 0);
const imageData = ctx.getImageData(0, 0, width, height);
// 解码U10, V10分量
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i]; // U10分量 (0-255)
const g = imageData.data[i + 1]; // V10分量 (0-255)
// 反归一化
const u10 = r / 255 * (uMax - uMin) + uMin;
const v10 = g / 255 * (vMax - vMin) + vMin;
}
};位置映射
// 将网格坐标映射到屏幕坐标
function mapToScreen(gridX, gridY, width, height) {
const x = gridX / width;
const y = gridY / height;
return [x * canvas.width, y * canvas.height];
}速度映射
// 根据风速计算粒子移动距离
function calculateParticleVelocity(u10, v10, speedFactor) {
const speed = Math.sqrt(u10 * u10 + v10 * v10);
const normalizedSpeed = speed / speedMax; // 归一化到0-1
return {
dx: u10 * speedFactor * normalizedSpeed,
dy: v10 * speedFactor * normalizedSpeed
};
}PNG纹理编码
- R通道 (Red): 存储U10分量(归一化到0-255)
- G通道 (Green): 存储V10分量(归一化到0-255)
- B通道 (Blue): 保持为0
- A通道 (Alpha): 保持为255(完全不透明)
// 编码归一化过程
const r = Math.round((u10 - uMin) / (uMax - uMin) * 255); // U分量
const g = Math.round((v10 - vMin) / (vMax - vMin) * 255); // V分量片元着色器逻辑 (draw.frag.glsl)
precision mediump float;
uniform sampler2D u_wind; // 风场纹理 (R=U分量, G=V分量)
uniform vec2 u_wind_min; // U、V分量的最小值
uniform vec2 u_wind_max; // U、V分量的最大值
uniform sampler2D u_color_ramp; // 颜色渐变纹理
varying vec2 v_particle_pos; // 粒子位置
void main() {
// 1. 从纹理读取归一化的U、V分量
vec2 normalized_velocity = texture2D(u_wind, v_particle_pos).rg;
// 2. 还原到实际风速值
vec2 velocity = mix(u_wind_min, u_wind_max, normalized_velocity);
// 3. 计算风速大小
float speed = length(velocity);
// 4. 归一化风速 (0.0-1.0)
float speed_t = speed / length(u_wind_max);
// 5. 颜色映射:选择颜色渐变
vec2 ramp_pos = vec2(
fract(16.0 * speed_t), // 16x16颜色纹理
floor(16.0 * speed_t) / 16.0
);
// 6. 输出最终颜色
gl_FragColor = texture2D(u_color_ramp, ramp_pos);
}关键算法步骤:
- 纹理采样:
texture2D(u_wind, v_particle_pos).rg- 获取归一化U、V - 数值还原:
mix(u_wind_min, u_wind_max, normalized)- 转换到实际数值 - 速度计算:
length(velocity)- 计算风速大小 √(u²+v²) - 归一化:
speed / max_speed- 标准化到0-1范围 - 颜色选择: 根据speed_t值从颜色渐变纹理中选择对应颜色
默认颜色映射
const defaultRampColors = {
0.0: '#3288bd', // 深蓝色 - 无风/微风 (0-1 m/s)
0.1: '#66c2a5', // 青色 - 轻风 (1-3 m/s)
0.2: '#abdda4', // 浅绿 - 和风 (3-5 m/s)
0.3: '#e6f598', // 黄绿 - 清风 (5-7 m/s)
0.4: '#fee08b', // 黄色 - 劲风 (7-9 m/s)
0.5: '#fdae61', // 橙色 - 清劲风 (9-11 m/s)
0.6: '#f46d43', // 橙红 - 强风 (11-13 m/s)
1.0: '#d53e4f' // 红色 - 飓风级 (13+ m/s)
};颜色渐变原理:
- 冷色调 (蓝-青): 代表低风速区域
- 暖色调 (黄-橙-红): 代表高风速区域
- 平滑过渡: 使用线性插值实现颜色渐变
风速-颜色对应关系:
- 蓝色粒子 (RGB: 50,136,189): 风速接近0,静止或微风区域
- 绿色粒子 (RGB: 102,194,165): 风速中等,温和的风力
- 黄色粒子 (RGB: 254,224,139): 风速较大,明显的风力
- 红色粒子 (RGB: 213,62,79): 风速很强,剧烈风力
视觉反馈机制:
- 粒子颜色直观反映局部风场强度
- 颜色变化平滑,避免视觉跳跃
- 高对比度颜色便于识别风力等级
GPU并行计算:
- 每个粒子独立计算着色
- 向量化操作支持批量处理
- 纹理采样硬件加速
内存效率:
- 颜色渐变纹理预生成 (16×16像素)
- 单纹理同时存储U、V分量
- 归一化减少数值精度损失
WebGL更新着色器 (update.frag.glsl)
precision mediump float;
uniform sampler2D u_wind; // 风场纹理
uniform sampler2D u_particles; // 粒子状态纹理
uniform vec2 u_wind_res; // 风场分辨率
uniform vec2 u_wind_min; // 风场最小值
uniform vec2 u_wind_max; // 风场最大值
uniform float u_speed_factor; // 速度因子
uniform float u_drop_rate; // 重新生成粒子概率
uniform float u_drop_rate_bump; // 速度相关重新生成概率
uniform float u_rand_seed; // 随机种子
varying vec2 v_particle_pos; // 粒子位置
// 伪随机函数
float random(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 position = texture2D(u_particles, v_particle_pos).xy;
vec2 velocity = texture2D(u_wind, position).rg;
// 还原实际风速值
velocity = mix(u_wind_min, u_wind_max, velocity);
// 计算粒子移动
position += velocity * u_speed_factor;
// 边界重置
if (position.x < 0.0 || position.x > 1.0 ||
position.y < 0.0 || position.y > 1.0 ||
random(position) < u_drop_rate + length(velocity) * u_drop_rate_bump) {
position = vec2(random(position), random(position.yx));
}
gl_FragColor = vec4(position, 0.0, 1.0);
}重生成机制:
- 边界溢出: 粒子超出显示区域时重新生成
- 随机重新生成: 按固定概率随机重新分布粒子
- 速度相关重新生成: 高风速区域粒子更容易重新生成
// 重生成概率计算
const dropProbability = baseDropRate + windSpeed * speedBumpRate;
if (Math.random() < dropProbability) {
// 重新生成粒子位置
particle.position = getRandomPosition();
}- 元数据压缩: 移除完整坐标数组,只保留边界信息
- 纹理压缩: 使用PNG格式进行无损压缩
- 数据分离: 将元数据和小量纹理数据分离加载
- GPU加速: 使用WebGL进行粒子系统计算
- 批处理: 一次性处理大量粒子
- 纹理采样: 使用线性插值平滑粒子运动
// 验证数据范围
function validateWindData(metadata) {
const checks = [
{name: 'width', valid: metadata.width > 0},
{name: 'height', valid: metadata.height > 0},
{name: 'uMin < uMax', valid: metadata.uMin < metadata.uMax},
{name: 'vMin < vMax', valid: metadata.vMin < metadata.vMax},
{name: 'speedMin < speedMax', valid: metadata.speedMin < metadata.speedMax}
];
return checks.every(check => check.valid);
}- 风速分布: 检查极值是否合理
- 风向一致性: 确保风向计算正确
- 边界条件: 验证海岸线边界处理
- 动画流畅性: 检查粒子运动是否自然
- 支持多时间步数据切换
- 实现平滑的时间插值
- 提供播放控制界面
- 温度数据叠加显示
- 气压等值线显示
- 卫星云图背景
- 鼠标悬停显示详细风场信息
- 点击选择特定区域
- 动态调整显示参数
- 数据处理: Node.js + netcdfjs
- 图像处理: PNG.js
- 可视化: WebGL + GLSL着色器
- 前端框架: 原生JavaScript
- 构建工具: Rollup
- 服务: Simple HTTP Server
这个转换系统实现了从科学计算数据到实时可视化的完整链路,在保证数据精度的同时实现了高效的压缩和渲染。