🌪️ 超震撼!用Cesium实现3D风场可视化,动态流线美到窒息✨
家人们谁懂啊😭!当地理数据遇上3D可视化,原来风的流动可以这么直观、这么有氛围感——
想象一下:在数字地球的上空,无数条彩色流线随风向舒展、蜿蜒,快慢交织、明暗渐变,既有真实气象数据的严谨,又有视觉艺术的灵动,这就是Cesium风场可视化的魅力!今天就带大家拆解一款超完整的3D风场实现方案,代码可直接复用,新手也能快速上手👇
🎬 先看效果:风场可视化有多绝?
话不多说,直接上核心亮点,看完你一定会被惊艳到!

-
✅ 3D立体呈现:基于Cesium数字地球,流线悬浮于地表之上,高度可自由调节,告别平面化的单调展示
-
✅ 动态光影质感:自定义流线材质,光点流动+渐变淡出,每一条流线都有“呼吸感”,视觉冲击力拉满
-
✅ 双数据源切换:支持真实GRIB气象数据加载,也能随机生成合成数据,测试、实战两不误
-
✅ 全参数可控:流动速度、流线高度、轨迹长度、数量一键调节,实时预览效果
-
✅ 平滑衔接过渡:流线首尾衔接自然,弧线路径贴合风向,模拟真实风的流动轨迹
放一张动态示意图(实际效果更丝滑):无数条彩色流线在数字地球上穿梭,蓝色代表微风,红色代表强风,明暗交替间,风的走向、强弱一目了然🌬️
🔧 核心功能拆解:每一处细节都拉满
这款风场可视化组件,不仅颜值能打,功能更是实用到飞起,每一个设计都藏着巧思,咱们逐一看~
1. 🗺️ 数字地球底座:Cesium加持,质感拉满
以Cesium为核心,搭建高性能3D地球场景,搭配天地图影像底图+标注图层,还原真实地理环境,同时隐藏多余控件,专注风场展示体验。
初始视角默认定位中国区域,一键复位功能,再也不怕不小心拖动视角找不到目标范围,新手也能轻松操作✅
2. 🌬️ 风场数据:真实与合成双模式,灵活切换
这是组件的核心灵魂!支持两种数据源,满足不同使用场景:
-
📊 真实GRIB数据:加载本地JSON格式的风场数据(包含u/v分量、经纬度范围、风速范围),精准还原真实气象情况,适用于科研、气象展示等专业场景
-
🎲 随机合成数据:基于多层噪声+旋涡算法生成风场,无需依赖外部数据,快速测试效果,适合demo演示、前端开发调试
更贴心的是,数据加载失败时会自动切换到合成数据,避免页面报错,用户体验直接拉满💯
3. 🎨 流线效果:自定义材质,颜值与实用并存
流线不是单调的线条,而是有“生命”的流动光影:
-
🌈 颜色映射:根据风速自动变色——微风是清新蓝色,强风是热烈红色,风速越大概率越红,直观区分风的强弱
-
✨ 动态效果:自定义流线材质,光点随时间流动,轨迹有渐变淡出效果,每条流线都有独立生命周期,避免视觉杂乱
-
📏 弧线路径:不是生硬的直线,而是贴合风向的平滑弧线,模拟真实风的流动轨迹,更具真实感
4. 🎛️ 控制面板:全参数可调,实时预览
左侧控制面板,操作简单易懂,小白也能轻松上手,支持5大核心参数调节:
-
⏩ 流动速度:20~150可调,数值越大,流线流动越快
-
📈 流线高度:100~5000米可调,按需展示不同高度的风场
-
📏 轨迹长度:0.2~1.0可调,控制流线光点的显示范围
-
🔢 流线数量:5~100可调,数量越多,风场覆盖越密集
-
🔄 数据源切换:一键切换真实数据/随机生成,重新生成风场数据
还有播放/暂停、复位视角按钮,操作逻辑清晰,无需复杂操作,就能调出想要的效果👏
5. 📊 信息面板+加载提示:细节拉满,体验更佳
底部信息面板,实时显示当前数据源、流线数量、数据覆盖范围,让你随时掌握组件状态;加载数据时,还有动态加载动画+提示文字,避免用户误以为页面卡顿,细节感直接拉满~
💻 技术亮点:新手也能复用的核心代码
这款组件基于Vue+Cesium开发,代码结构清晰,注释完整,核心亮点值得借鉴:
-
✅ 自定义Cesium材质:封装WindTrailMaterialProperty,实现流线的动态光影效果,可直接复用
-
✅ 风场网格优化:对真实数据进行下采样处理,提升性能,避免页面卡顿
-
✅ 生命周期管理:流线自动创建、过期删除,实现循环流动,避免内存泄漏
-
✅ 异常处理:数据加载失败、边界判断,全方位保障组件稳定性
关键代码已经整理好(就是开头给的完整代码),复制粘贴到Vue项目中,替换天地图key,就能快速运行,省去大量开发时间⏳
🎯 适用场景:不止于好看,更实用
这款3D风场可视化组件,可不是单纯的“花架子”,实用性拉满,适用于多种场景:
-
🌡️ 气象展示:科研机构、气象部门,直观展示风场分布、风速变化
-
🗺️ 地理教学:学校、培训机构,让学生直观理解风向、风场的概念
-
💻 前端演示:开发者用于Cesium项目demo,提升项目质感和说服力
-
🚀 行业应用:无人机、航空、航海等领域,辅助分析风场对作业的影响
✨ 最后:福利送上,快速上手
看到这里,是不是已经迫不及待想试试了?
完整代码已经为大家准备好,包含Vue模板、JS逻辑、CSS样式,无需额外配置,只需3步即可运行:
-
将代码复制到Vue项目组件中
-
替换代码中的天地图KEY(自行申请,免费可用)
-
安装Cesium依赖,运行项目,即可看到完整风场效果
<template>
<div class="wind-field-container">
<div class="cesium-container" ref="cesiumContainer"></div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="panel-header">
<span>风场控制</span>
<button class="close-btn" @click="togglePanel">−</button>
</div>
<div class="panel-content" v-show="panelVisible">
<div class="control-item">
<label>流动速度:</label>
<input
type="range"
v-model.number="flowSpeed"
min="20"
max="150"
step="10"
@change="updateSpeed"
/>
<span>{{ flowSpeed }}</span>
</div>
<div class="control-item">
<label>流线高度:</label>
<input
type="range"
v-model.number="lineHeight"
min="100"
max="5000"
step="100"
@change="updateHeight"
/>
<span>{{ lineHeight }}m</span>
</div>
<div class="control-item">
<label>轨迹长度:</label>
<input
type="range"
v-model.number="trailLength"
min="0.2"
max="1.0"
step="0.1"
@change="updateTrail"
/>
<span>{{ trailLength }}</span>
</div>
<div class="control-item">
<label>流线数量:</label>
<input
type="range"
v-model.number="flowCount"
min="5"
max="100"
step="5"
@change="updateFlowCount"
/>
<span>{{ flowCount }}</span>
</div>
<div class="control-item">
<label>数据源:</label>
<select v-model="dataSource" @change="switchDataSource" style="flex: 1; padding: 4px 8px; background: #1E3A5F; border: 1px solid #1E90FF; color: #fff; border-radius: 4px;">
<option value="real">真实数据 (GRIB)</option>
<option value="synthetic">随机生成</option>
</select>
</div>
<div class="control-item">
<label>风场数据:</label>
<button class="btn" @click="regenerateWindData">重新生成</button>
</div>
<div class="control-item">
<button class="btn btn-primary" @click="toggleAnimation">
{{ isAnimating ? '暂停' : '播放' }}
</button>
<button class="btn" @click="resetView">复位视角</button>
</div>
</div>
</div>
<!-- 信息显示 -->
<div class="info-panel">
<div>🌬️ 数据源: {{ dataSource === 'real' ? '真实数据' : '随机生成' }}</div>
<div v-if="dataSource === 'real' && windData" style="color: #87CEEB; font-size: 11px; margin: 4px 0;">
覆盖: {{ windData.header.lo1 }}°~{{ windData.header.lo2 }}°E, {{ windData.header.la2 }}°~{{ windData.header.la1 }}°N
</div>
<div v-if="dataSource === 'synthetic'" style="color: #87CEEB; font-size: 11px; margin: 4px 0;">
基于多层噪声+旋涡生成
</div>
<div>流线数: {{ flowLineEntities.length }} / {{ flowCount }}</div>
<div v-if="isLoading" style="color: #87CEEB; margin-top: 8px;">{{ loadingMessage }}</div>
</div>
<!-- 加载指示器 -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">{{ loadingMessage }}</div>
</div>
</div>
</template>
<script>
/* global Cesium */
export default {
name: 'CesiumWindField',
data() {
return {
viewer: null,
TIANDITU_KEY: '你的key',
// 流线实体
flowLineEntities: [],
// 加载状态
isLoading: false,
loadingMessage: '',
// 数据源类型
dataSource: 'real', // 'real' 或 'synthetic'
// 控制参数
flowSpeed: 80,
lineHeight: 1000,
trailLength: 0.4, // 缩短轨迹,只显示流动的光点
flowCount: 30, // 流线数量
isAnimating: true,
panelVisible: true,
// 风场数据
windData: null, // 存储加载的风场数据
windGrid: [],
gridSize: 30,
// 流线生命周期管理
lineLifetime: 4000, // 每条流线存在4秒
respawnInterval: null
};
},
mounted() {
this.initCesium();
this.loadBasemap();
this.initTrailMaterial();
this.loadWindData(); // 先加载风场数据
},
beforeDestroy() {
this.cleanup();
},
methods: {
initCesium() {
const container = this.$refs.cesiumContainer;
if (!container) return console.error('Cesium容器未找到');
window.CESIUM_BASE_URL = './cesium';
const viewerOptions = {
baseLayer: false,
timeline: false,
animation: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
sceneModePicker: false,
selectionIndicator: false,
navigationHelpButton: false,
baseLayerPicker: false,
terrainProvider: new Cesium.EllipsoidTerrainProvider(),
imageryProvider: false,
requestRenderMode: false,
contextOptions: {
webgl: {
preserveDrawingBuffer: true,
powerPreference: "high-performance",
antialias: true
}
}
};
this.viewer = new Cesium.Viewer(container, viewerOptions);
this.viewer.cesiumWidget.creditContainer.style.visibility = 'hidden';
this.viewer.scene.fxaa = true;
// 设置初始视角(中国区域)
this.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(105, 35, 5000000),
orientation: {
heading: 0,
pitch: -1.57,
roll: 0
}
});
},
loadBasemap() {
if (!this.viewer || !this.TIANDITU_KEY) return;
const tdImgProvider = new Cesium.UrlTemplateImageryProvider({
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${this.TIANDITU_KEY}`,
minimumLevel: 1,
maximumLevel: 18,
credit: '',
});
const tdAnnoProvider = new Cesium.UrlTemplateImageryProvider({
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${this.TIANDITU_KEY}`,
minimumLevel: 1,
maximumLevel: 18,
credit: '',
});
this.viewer.imageryLayers.addImageryProvider(tdImgProvider);
this.viewer.imageryLayers.addImageryProvider(tdAnnoProvider);
},
initTrailMaterial() {
// 参考 CesiumFlylineDemo 的流线材质
if (Cesium.Material.WIND_TRAIL_TYPE) return;
Cesium.Material.WIND_TRAIL_TYPE = 'WindTrail';
Cesium.Material.WIND_TRAIL_SOURCE = `
float gauss(float x, float sigma){
return exp(-(x*x) / (2.0*sigma*sigma));
}
czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
float w = abs(st.t - 0.5) * 3.0;
float roundMask = pow(clamp(1.0 - w, 0.0, 1.0), edgeSoft);
float dir = 1.0;
float t = time * constantSpeed * 0.0035;
float u = fract(st.s + dir * t);
float headBall = gauss(u, 0.1);
float headSmooth = pow(smoothstep(0.6, 0.0, u), 0.8);
float head = headBall * 0.5 + headSmooth * 0.2;
float tailMask = clamp((trailLength - u) / max(trailLength, 1e-4), 0.0, 1.0);
float tail = pow(tailMask, trailPower);
float tailLift = mix(tail, max(tail, 0.35), clamp(tailBoost, 0.0, 1.0));
float intensity = (headBoost * head) * tailLift;
float alpha = baseAlpha + intensity;
material.emission = color.rgb * 1.8;
material.alpha = color.a * clamp(alpha, 0.0, 0.9) * roundMask;
return material;
}
`;
Cesium.Material._materialCache.addMaterial(Cesium.Material.WIND_TRAIL_TYPE, {
fabric: {
type: Cesium.Material.WIND_TRAIL_TYPE,
uniforms: {
color: Cesium.Color.fromCssColorString('#00C8FF').withAlpha(0.95),
time: 0.0,
constantSpeed: 80.0,
trailLength: 0.9,
headSharp: 0.045,
headBoost: 2.6,
trailPower: 1.35,
tailBoost: 0.75,
baseAlpha: 0.0,
edgeSoft: 0.75,
reverse: 0.0
},
source: Cesium.Material.WIND_TRAIL_SOURCE
},
translucent: () => true
});
class WindTrailMaterialProperty {
constructor(options) {
this._definitionChanged = new Cesium.Event();
this._color = Cesium.defaultValue(options.color, Cesium.Color.fromCssColorString('#00C8FF').withAlpha(0.95));
this._constantSpeed = Cesium.defaultValue(options.constantSpeed, 80);
this._trailLength = Cesium.defaultValue(options.trailLength, 0.9);
this._headSharp = Cesium.defaultValue(options.headSharp, 0.045);
this._headBoost = Cesium.defaultValue(options.headBoost, 2.6);
this._trailPower = Cesium.defaultValue(options.trailPower, 1.35);
this._tailBoost = Cesium.defaultValue(options.tailBoost, 0.75);
this._baseAlpha = Cesium.defaultValue(options.baseAlpha, 0.0);
this._edgeSoft = Cesium.defaultValue(options.edgeSoft, 0.75);
this._reverse = Cesium.defaultValue(options.reverse, false);
this._startMs = Date.now();
}
get isConstant() { return false; }
get definitionChanged() { return this._definitionChanged; }
getType() { return Cesium.Material.WIND_TRAIL_TYPE; }
getValue(_time, result) {
if (!Cesium.defined(result)) result = {};
result.color = Cesium.Color.clone(this._color, result.color);
result.time = (Date.now() - this._startMs) / 1000.0;
result.constantSpeed = Number(this._constantSpeed);
result.trailLength = Number(this._trailLength);
result.headSharp = Number(this._headSharp);
result.headBoost = Number(this._headBoost);
result.trailPower = Number(this._trailPower);
result.tailBoost = Number(this._tailBoost);
result.baseAlpha = Number(this._baseAlpha);
result.edgeSoft = Number(this._edgeSoft);
result.reverse = this._reverse ? 1.0 : 0.0;
return result;
}
equals(other) {
return other instanceof WindTrailMaterialProperty &&
Cesium.Color.equals(this._color, other._color) &&
this._constantSpeed === other._constantSpeed &&
this._trailLength === other._trailLength;
}
}
Cesium.WindTrailMaterialProperty = WindTrailMaterialProperty;
},
async loadWindData() {
try {
this.isLoading = true;
this.loadingMessage = '🌬️ 正在加载风场数据...';
console.log(this.loadingMessage);
const response = await fetch('./json/wind.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.windData = {
uData: data[0], // u-component of wind
vData: data[1], // v-component of wind
header: data[0].header
};
this.loadingMessage = '✅ 风场数据加载成功!';
console.log(`✅ 风场数据加载成功!`);
console.log(` 覆盖范围: 经度 ${this.windData.header.lo1}° ~ ${this.windData.header.lo2}°`);
console.log(` 纬度 ${this.windData.header.la2}° ~ ${this.windData.header.la1}°`);
console.log(` 网格尺寸: ${this.windData.header.nx} x ${this.windData.header.ny}`);
console.log(` 风速范围: ${this.windData.header.min} ~ ${this.windData.header.max} m/s`);
// 初始化风场网格
this.generateWindField();
this.createFlowLines();
this.startRespawnLoop();
setTimeout(() => {
this.isLoading = false;
}, 1000);
} catch (error) {
console.error('❌ 风场数据加载失败:', error);
this.loadingMessage = '❌ 数据加载失败,使用合成数据';
// 如果加载失败,使用生成的方式
setTimeout(() => {
this.isLoading = false;
this.generateSyntheticWindField();
this.createFlowLines();
this.startRespawnLoop();
}, 2000);
}
},
generateWindField() {
if (!this.windData) {
console.log('⚠️ 使用生成风场数据');
this.generateSyntheticWindField();
return;
}
// 使用真实风场数据构建网格
this.windGrid = [];
const { uData, vData, header } = this.windData;
const { nx, ny } = header; // 只解构需要的变量
// 验证数据
console.log(`📊 数据验证:`, {
uDataLength: uData.data.length,
vDataLength: vData.data.length,
expectedLength: nx * ny,
firstU: uData.data[0],
firstV: vData.data[0]
});
// 下采样网格以提高性能
const downsample = Math.max(1, Math.floor(nx / this.gridSize));
console.log(`🔄 构建风场网格,下采样因子: ${downsample}`);
for (let y = 0; y < this.gridSize; y++) {
this.windGrid[y] = [];
for (let x = 0; x < this.gridSize; x++) {
// 映射到原始数据网格
const origX = Math.floor(x * (nx - 1) / this.gridSize);
const origY = Math.floor(y * (ny - 1) / this.gridSize);
const idx = origY * nx + origX;
if (idx < uData.data.length && idx < vData.data.length) {
const u = uData.data[idx];
const v = vData.data[idx];
this.windGrid[y][x] = { u, v };
} else {
this.windGrid[y][x] = { u: 0, v: 0 };
}
}
}
// 验证风场网格数据
const sampleWind = this.windGrid[Math.floor(this.gridSize/2)][Math.floor(this.gridSize/2)];
console.log(`✅ 风场网格构建完成!样本数据:`, sampleWind);
console.log(`🌍 使用真实GRIB风场数据,覆盖全球范围`);
},
generateSyntheticWindField() {
// 原来的生成逻辑作为后备
this.windGrid = [];
const size = this.gridSize;
for (let y = 0; y < size; y++) {
this.windGrid[y] = [];
for (let x = 0; x < size; x++) {
const nx = x / size;
const ny = y / size;
const angle1 = nx * Math.PI * 4 + ny * Math.PI * 2;
const angle2 = nx * Math.PI * 8 - ny * Math.PI * 4;
const angle3 = nx * Math.PI * 2 + ny * Math.PI * 6;
const u = Math.sin(angle1) * 0.5 + Math.sin(angle2) * 0.3 + Math.cos(angle3) * 0.2;
const v = Math.cos(angle1) * 0.5 + Math.cos(angle2 - 1.5) * 0.3 + Math.sin(angle3 + 1) * 0.2;
const cx = 0.5;
const cy = 0.5;
const dx = nx - cx;
const dy = ny - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const vortexStrength = Math.exp(-dist * 5) * 0.5;
const vortexAngle = Math.atan2(dy, dx) + Math.PI / 2;
this.windGrid[y][x] = {
u: (u + Math.cos(vortexAngle) * vortexStrength) * 12,
v: (v + Math.sin(vortexAngle) * vortexStrength) * 12
};
}
}
console.log('⚠️ 生成风场网格(合成数据)');
},
getWindAt(lon, lat) {
if (!this.windData) {
// 使用默认边界(中国区域)
const bounds = { west: 70, south: 15, east: 140, north: 55 };
return this.getWindAtBounds(lon, lat, bounds);
}
// 使用真实风场数据的边界
const { header } = this.windData;
const bounds = {
west: header.lo1,
south: header.la2,
east: header.lo2,
north: header.la1
};
return this.getWindAtBounds(lon, lat, bounds);
},
getWindAtBounds(lon, lat, bounds) {
const size = this.gridSize;
const x = ((lon - bounds.west) / (bounds.east - bounds.west)) * (size - 1);
const y = ((lat - bounds.south) / (bounds.north - bounds.south)) * (size - 1);
const x0 = Math.floor(x);
const y0 = Math.floor(y);
if (x0 >= 0 && x0 < size && y0 >= 0 && y0 < size) {
return this.windGrid[y0][x0];
}
return { u: 0, v: 0 };
},
createFlowLines() {
if (!this.viewer) return;
// 清除旧的流线
this.flowLineEntities.forEach(entity => {
this.viewer.entities.remove(entity);
});
this.flowLineEntities = [];
// 使用真实风场数据的边界,或默认边界
let bounds;
if (this.windData) {
const { header } = this.windData;
bounds = {
west: header.lo1,
south: header.la2,
east: header.lo2,
north: header.la1
};
} else {
bounds = { west: 70, south: 15, east: 140, north: 55 };
}
console.log(`🌬️ 创建流线,边界: [${bounds.west}, ${bounds.south}, ${bounds.east}, ${bounds.north}]`);
let lastEndLon = null;
let lastEndLat = null;
let lastEndAngle = null; // 存储上一条线终点的角度
for (let i = 0; i < this.flowCount; i++) {
// 如果有上一条线的终点,则使用它作为起点
let startLon, startLat, startAngle;
if (lastEndLon !== null && lastEndLat !== null && lastEndAngle !== null) {
// 检查上一个终点是否在边界内
if (lastEndLon >= bounds.west && lastEndLon <= bounds.east &&
lastEndLat >= bounds.south && lastEndLat <= bounds.north) {
startLon = lastEndLon;
startLat = lastEndLat;
startAngle = lastEndAngle;
} else {
// 超出边界,重新随机起点
startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
startAngle = null;
}
} else {
// 第一条线随机起点
startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
startAngle = null;
}
// 获取起点的风向
const wind = this.getWindAt(startLon, startLat);
const windAngle = Math.atan2(wind.v, wind.u);
// 沿风向延伸得到终点
const lineLength = 300000 + Math.random() * 200000; // 线的长度
const endLon = startLon + Math.cos(windAngle) * lineLength * 0.00001;
const endLat = startLat + Math.sin(windAngle) * lineLength * 0.00001;
// 生成弧线路径
const arcPositions = [];
if (startAngle !== null) {
// 如果有上一条线的方向,创建平滑过渡弧线
const transitionLength = lineLength * 0.15; // 过渡段长度
// 过渡段的起点(从上一条线的终点开始)
const transStartPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
// 过渡段的终点(沿着新方向延伸一小段)
const transEndLon = startLon + Math.cos(windAngle) * transitionLength * 0.00001;
const transEndLat = startLat + Math.sin(windAngle) * transitionLength * 0.00001;
const transEndPos = Cesium.Cartesian3.fromDegrees(transEndLon, transEndLat, this.lineHeight);
// 计算控制点,使用三次贝塞尔曲线实现平滑过渡
// 第一个控制点:沿着上一个方向延伸
const cp1Lon = startLon + Math.cos(startAngle) * transitionLength * 0.00001 * 0.5;
const cp1Lat = startLat + Math.sin(startAngle) * transitionLength * 0.00001 * 0.5;
const cp1Pos = Cesium.Cartesian3.fromDegrees(cp1Lon, cp1Lat, this.lineHeight);
// 第二个控制点:沿着新方向反向延伸
const cp2Lon = transEndLon - Math.cos(windAngle) * transitionLength * 0.00001 * 0.3;
const cp2Lat = transEndLat - Math.sin(windAngle) * transitionLength * 0.00001 * 0.3;
const cp2Pos = Cesium.Cartesian3.fromDegrees(cp2Lon, cp2Lat, this.lineHeight);
// 使用三次贝塞尔曲线生成过渡段
const transitionSamples = 30;
for (let t = 0; t <= transitionSamples; t++) {
const tt = t / transitionSamples;
const mt = 1 - tt;
// 三次贝塞尔曲线公式
const x = mt * mt * mt * transStartPos.x +
3 * mt * mt * tt * cp1Pos.x +
3 * mt * tt * tt * cp2Pos.x +
tt * tt * tt * transEndPos.x;
const y = mt * mt * mt * transStartPos.y +
3 * mt * mt * tt * cp1Pos.y +
3 * mt * tt * tt * cp2Pos.y +
tt * tt * tt * transEndPos.y;
const z = mt * mt * mt * transStartPos.z +
3 * mt * mt * tt * cp1Pos.z +
3 * mt * tt * tt * cp2Pos.z +
tt * tt * tt * transEndPos.z;
arcPositions.push(new Cesium.Cartesian3(x, y, z));
}
// 更新起点为过渡段终点
startLon = transEndLon;
startLat = transEndLat;
}
// 起点和终点的笛卡尔坐标
const startPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
const endPos = Cesium.Cartesian3.fromDegrees(endLon, endLat, this.lineHeight);
// 计算弧线的控制点
const midLon = (startLon + endLon) / 2;
const midLat = (startLat + endLat) / 2;
// 垂直于线段方向的偏移
const perpAngle = windAngle + Math.PI / 2;
const arcHeight = lineLength * 0.2; // 弧度高度
const controlLon = midLon + Math.cos(perpAngle) * arcHeight * 0.00001;
const controlLat = midLat + Math.sin(perpAngle) * arcHeight * 0.00001;
const controlPos = Cesium.Cartesian3.fromDegrees(controlLon, controlLat, this.lineHeight + arcHeight * 0.5);
// 使用二次贝塞尔曲线生成平滑弧线
const arcSamples = 100;
for (let j = 0; j <= arcSamples; j++) {
const t = j / arcSamples;
// 二次贝塞尔曲线公式: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
const mt = 1 - t;
const x = mt * mt * startPos.x + 2 * mt * t * controlPos.x + t * t * endPos.x;
const y = mt * mt * startPos.y + 2 * mt * t * controlPos.y + t * t * endPos.y;
const z = mt * mt * startPos.z + 2 * mt * t * controlPos.z + t * t * endPos.z;
arcPositions.push(new Cesium.Cartesian3(x, y, z));
}
// 计算风速强度用于颜色映射
const windSpeed = Math.sqrt(wind.u * wind.u + wind.v * wind.v);
const normalizedSpeed = Math.min(1, windSpeed / 15); // 归一化到0-1
// 根据风速选择颜色(从蓝色到红色)
const speedColor = new Cesium.Color(
0.2 + normalizedSpeed * 0.8, // R: 0.2 -> 1.0
0.5 - normalizedSpeed * 0.3, // G: 0.5 -> 0.2
1.0 - normalizedSpeed * 0.8, // B: 1.0 -> 0.2
0.95
);
// 创建流线实体
const entity = this.viewer.entities.add({
polyline: {
positions: arcPositions,
width: 6, // 增加宽度让流线更明显
clampToGround: false,
material: new Cesium.WindTrailMaterialProperty({
color: speedColor, // 使用基于风速的颜色
constantSpeed: this.flowSpeed,
trailLength: this.trailLength,
headBoost: 3.5,
trailPower: 0.2,
edgeSoft: 0.95,
baseAlpha: 0.08,
reverse: true
})
}
});
// 为每条流线添加创建时间和生命周期
entity._createTime = Date.now();
entity._lifetime = this.lineLifetime + Math.random() * 1000; // 随机生命周期
entity._endLon = endLon; // 存储终点坐标,供下一条线使用
entity._endLat = endLat;
entity._endAngle = windAngle; // 存储终点处的风向角度
this.flowLineEntities.push(entity);
// 更新lastEnd位置和角度供下一条线使用
lastEndLon = endLon;
lastEndLat = endLat;
lastEndAngle = windAngle;
}
this.viewer.scene.globe.depthTestAgainstTerrain = false;
console.log(`已创建 ${this.flowLineEntities.length} 条风场圆弧`);
},
// 定期重新生成流线
startRespawnLoop() {
const checkInterval = 500; // 每500ms检查一次
this.respawnInterval = setInterval(() => {
if (!this.isAnimating || !this.viewer) return;
const now = Date.now();
// 移除超时的流线
this.flowLineEntities = this.flowLineEntities.filter(entity => {
const age = now - entity._createTime;
if (age > entity._lifetime) {
// 移除过期流线
this.viewer.entities.remove(entity);
return false;
}
// 随着时间推移降低透明度(淡出效果)
const fadeStartTime = entity._lifetime * 0.5;
if (age > fadeStartTime) {
const fadeProgress = (age - fadeStartTime) / (entity._lifetime - fadeStartTime);
const alpha = 1 - fadeProgress;
if (entity.polyline && entity.polyline.material) {
const material = entity.polyline.material;
material._color = material._color || Cesium.Color.fromCssColorString('#00C8FF');
material._color = material._color.withAlpha(alpha);
}
}
return true;
});
// 补充新流线
const currentCount = this.flowLineEntities.length;
if (currentCount < this.flowCount) {
const needCount = this.flowCount - currentCount;
this.createSingleFlowLine(needCount);
}
}, checkInterval);
},
// 创建单条流线(用于补充)
createSingleFlowLine(count = 1) {
const bounds = { west: 70, south: 15, east: 140, north: 55 };
for (let k = 0; k < count; k++) {
// 获取上一条流线的终点作为起点
let startLon, startLat, startAngle;
const lastEntity = this.flowLineEntities[this.flowLineEntities.length - 1];
if (lastEntity && lastEntity._endLon !== undefined) {
// 检查上一个终点是否在边界内
if (lastEntity._endLon >= bounds.west && lastEntity._endLon <= bounds.east &&
lastEntity._endLat >= bounds.south && lastEntity._endLat <= bounds.north) {
startLon = lastEntity._endLon;
startLat = lastEntity._endLat;
startAngle = lastEntity._endAngle;
} else {
// 超出边界,重新随机起点
startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
startAngle = null;
}
} else {
// 如果没有上一条线,则随机起点
startLon = bounds.west + Math.random() * (bounds.east - bounds.west);
startLat = bounds.south + Math.random() * (bounds.north - bounds.south);
startAngle = null;
}
// 获取起点的风向
const wind = this.getWindAt(startLon, startLat);
const windAngle = Math.atan2(wind.v, wind.u);
// 沿风向延伸得到终点
const lineLength = 300000 + Math.random() * 200000;
const endLon = startLon + Math.cos(windAngle) * lineLength * 0.00001;
const endLat = startLat + Math.sin(windAngle) * lineLength * 0.00001;
// 生成弧线路径
const arcPositions = [];
if (startAngle !== null) {
// 如果有上一条线的方向,创建平滑过渡弧线
const transitionLength = lineLength * 0.15; // 过渡段长度
// 过渡段的起点(从上一条线的终点开始)
const transStartPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
// 过渡段的终点(沿着新方向延伸一小段)
const transEndLon = startLon + Math.cos(windAngle) * transitionLength * 0.00001;
const transEndLat = startLat + Math.sin(windAngle) * transitionLength * 0.00001;
const transEndPos = Cesium.Cartesian3.fromDegrees(transEndLon, transEndLat, this.lineHeight);
// 计算控制点,使用三次贝塞尔曲线实现平滑过渡
const cp1Lon = startLon + Math.cos(startAngle) * transitionLength * 0.00001 * 0.5;
const cp1Lat = startLat + Math.sin(startAngle) * transitionLength * 0.00001 * 0.5;
const cp1Pos = Cesium.Cartesian3.fromDegrees(cp1Lon, cp1Lat, this.lineHeight);
const cp2Lon = transEndLon - Math.cos(windAngle) * transitionLength * 0.00001 * 0.3;
const cp2Lat = transEndLat - Math.sin(windAngle) * transitionLength * 0.00001 * 0.3;
const cp2Pos = Cesium.Cartesian3.fromDegrees(cp2Lon, cp2Lat, this.lineHeight);
// 使用三次贝塞尔曲线生成过渡段
const transitionSamples = 30;
for (let t = 0; t <= transitionSamples; t++) {
const tt = t / transitionSamples;
const mt = 1 - tt;
const x = mt * mt * mt * transStartPos.x +
3 * mt * mt * tt * cp1Pos.x +
3 * mt * tt * tt * cp2Pos.x +
tt * tt * tt * transEndPos.x;
const y = mt * mt * mt * transStartPos.y +
3 * mt * mt * tt * cp1Pos.y +
3 * mt * tt * tt * cp2Pos.y +
tt * tt * tt * transEndPos.y;
const z = mt * mt * mt * transStartPos.z +
3 * mt * mt * tt * cp1Pos.z +
3 * mt * tt * tt * cp2Pos.z +
tt * tt * tt * transEndPos.z;
arcPositions.push(new Cesium.Cartesian3(x, y, z));
}
// 更新起点为过渡段终点
startLon = transEndLon;
startLat = transEndLat;
}
// 起点和终点的笛卡尔坐标
const startPos = Cesium.Cartesian3.fromDegrees(startLon, startLat, this.lineHeight);
const endPos = Cesium.Cartesian3.fromDegrees(endLon, endLat, this.lineHeight);
// 计算弧线的控制点
const midLon = (startLon + endLon) / 2;
const midLat = (startLat + endLat) / 2;
// 垂直于线段方向的偏移
const perpAngle = windAngle + Math.PI / 2;
const arcHeight = lineLength * 0.2;
const controlLon = midLon + Math.cos(perpAngle) * arcHeight * 0.00001;
const controlLat = midLat + Math.sin(perpAngle) * arcHeight * 0.00001;
const controlPos = Cesium.Cartesian3.fromDegrees(controlLon, controlLat, this.lineHeight + arcHeight * 0.5);
// 使用二次贝塞尔曲线生成平滑弧线
const arcSamples = 100;
for (let j = 0; j <= arcSamples; j++) {
const t = j / arcSamples;
const mt = 1 - t;
const x = mt * mt * startPos.x + 2 * mt * t * controlPos.x + t * t * endPos.x;
const y = mt * mt * startPos.y + 2 * mt * t * controlPos.y + t * t * endPos.y;
const z = mt * mt * startPos.z + 2 * mt * t * controlPos.z + t * t * endPos.z;
arcPositions.push(new Cesium.Cartesian3(x, y, z));
}
// 计算风速强度用于颜色映射
const windSpeed = Math.sqrt(wind.u * wind.u + wind.v * wind.v);
const normalizedSpeed = Math.min(1, windSpeed / 15); // 归一化到0-1
// 根据风速选择颜色(从蓝色到红色)
const speedColor = new Cesium.Color(
0.2 + normalizedSpeed * 0.8, // R: 0.2 -> 1.0
0.5 - normalizedSpeed * 0.3, // G: 0.5 -> 0.2
1.0 - normalizedSpeed * 0.8, // B: 1.0 -> 0.2
0.95
);
// 创建流线实体
const entity = this.viewer.entities.add({
polyline: {
positions: arcPositions,
width: 6, // 增加宽度让流线更明显
clampToGround: false,
material: new Cesium.WindTrailMaterialProperty({
color: speedColor, // 使用基于风速的颜色
constantSpeed: this.flowSpeed,
trailLength: this.trailLength,
headBoost: 3.5,
trailPower: 0.2,
edgeSoft: 0.95,
baseAlpha: 0.08,
reverse: true
})
}
});
// 设置创建时间和生命周期
entity._createTime = Date.now();
entity._lifetime = this.lineLifetime + Math.random() * 1000;
entity._endLon = endLon; // 存储终点坐标,供下一条线使用
entity._endLat = endLat;
entity._endAngle = windAngle; // 存储终点处的风向角度
this.flowLineEntities.push(entity);
}
},
updateSpeed() {
this.flowLineEntities.forEach(entity => {
if (entity.polyline && entity.polyline.material) {
entity.polyline.material._constantSpeed = this.flowSpeed;
}
});
},
updateHeight() {
this.createFlowLines();
},
updateTrail() {
this.flowLineEntities.forEach(entity => {
if (entity.polyline && entity.polyline.material) {
entity.polyline.material._trailLength = this.trailLength;
}
});
},
updateFlowCount() {
// 清除所有现有流线
this.flowLineEntities.forEach(entity => {
this.viewer.entities.remove(entity);
});
this.flowLineEntities = [];
// 创建新数量的流线
this.createFlowLines();
console.log(`流线数量已更新为 ${this.flowCount}`);
},
regenerateWindData() {
if (this.windData) {
// 如果已加载真实数据,重新构建网格
this.generateWindField();
} else {
// 使用合成数据
this.generateSyntheticWindField();
}
this.createFlowLines();
console.log('🔄 风场数据已重新生成');
},
async switchDataSource() {
console.log(`🔄 切换数据源到: ${this.dataSource === 'real' ? '真实数据' : '随机生成'}`);
// 停止当前的重新生成循环
if (this.respawnInterval) {
clearInterval(this.respawnInterval);
this.respawnInterval = null;
}
if (this.dataSource === 'real') {
// 切换到真实数据
if (!this.windData) {
await this.loadWindData();
} else {
this.generateWindField();
this.createFlowLines();
this.startRespawnLoop();
}
} else {
// 切换到随机生成数据
this.windData = null; // 清除真实数据
this.generateSyntheticWindField();
this.createFlowLines();
this.startRespawnLoop();
}
console.log(`✅ 数据源切换完成`);
},
toggleAnimation() {
this.isAnimating = !this.isAnimating;
// 流线材质会自动根据时间动画,这里主要用于显示状态
console.log('动画状态:', this.isAnimating ? '播放' : '暂停');
},
togglePanel() {
this.panelVisible = !this.panelVisible;
},
resetView() {
if (this.viewer) {
this.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(105, 35, 5000000),
orientation: {
heading: 0,
pitch: -1.57,
roll: 0
}
});
}
},
cleanup() {
// 清除定时器
if (this.respawnInterval) {
clearInterval(this.respawnInterval);
this.respawnInterval = null;
}
// 清除流线实体
this.flowLineEntities.forEach(entity => {
if (this.viewer && entity) {
this.viewer.entities.remove(entity);
}
});
this.flowLineEntities = [];
// 销毁 Viewer
if (this.viewer) {
this.viewer.destroy();
this.viewer = null;
}
}
}
};
</script>
<style scoped>
.wind-field-container {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
}
.cesium-container {
width: 100%;
height: 100%;
}
.control-panel {
position: absolute;
top: 20px;
left: 20px;
width: 280px;
background: rgba(10, 25, 47, 0.95);
border: 1px solid #165DFF;
border-radius: 8px;
color: #fff;
z-index: 999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #2E4057;
font-size: 16px;
font-weight: bold;
}
.close-btn {
background: none;
border: none;
color: #ccc;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
line-height: 1;
transition: color 0.2s;
}
.close-btn:hover {
color: #fff;
}
.panel-content {
padding: 16px;
}
.control-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
}
.control-item label {
color: #8CBFFF;
flex-shrink: 0;
margin-right: 8px;
}
.control-item input[type="range"] {
flex: 1;
margin: 0 8px;
cursor: pointer;
}
.control-item span {
min-width: 60px;
text-align: right;
color: #fff;
}
.btn {
padding: 6px 16px;
background: rgba(22, 93, 255, 0.3);
border: 1px solid #165DFF;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
outline: none;
}
.btn:hover {
background: rgba(22, 93, 255, 0.6);
border-color: #4080FF;
}
.btn-primary {
background: #165DFF;
border-color: #165DFF;
}
.btn-primary:hover {
background: #4080FF;
border-color: #4080FF;
}
.info-panel {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(10, 25, 47, 0.8);
border: 1px solid #165DFF;
border-radius: 4px;
padding: 8px 12px;
color: #fff;
font-size: 12px;
z-index: 999;
}
.info-panel div {
margin: 2px 0;
}
/* 加载指示器 */
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(20, 30, 48, 0.95);
border: 2px solid #1E90FF;
border-radius: 12px;
padding: 30px 40px;
text-align: center;
z-index: 2000;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
}
.loading-spinner {
width: 50px;
height: 50px;
margin: 0 auto 20px;
border: 4px solid #1E3A5F;
border-top-color: #1E90FF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: #87CEEB;
font-size: 16px;
font-weight: 500;
}
</style>

如果运行过程中遇到问题,比如Cesium加载失败、数据不显示,评论区留言,我会一一回复解答👇
其实3D可视化并不难,只要找对方法,就能把枯燥的数据变得生动又直观。这款风场组件,既有颜值又有实力,无论是用于学习、演示还是实战,都非常合适~
收藏起来,下次做3D风场可视化,直接拿来用!觉得有用的话,记得点赞+在看,转发给身边需要的朋友哦❤️
关注我,后续分享更多Cesium可视化实战案例,带你解锁更多3D开发小技巧✨
欢迎关注微信公众号“书图工厂”
256

被折叠的 条评论
为什么被折叠?



