Cesium实现标注动画
继续我们的Cesium系列,这次来整点有意思的——给地图上的标注加点动画效果。静态的点线面看久了总觉得少点什么,动起来的标注不仅更吸引眼球,在实际项目中也能更好地突出重要信息。
动画实现的核心原理
在Cesium中实现标注动画主要有两种方式:
- CallbackProperty方式:通过回调函数动态计算属性值,适合简单的动画效果
- GPU着色器方式:使用GLSL着色器在GPU上计算,适合复杂的视觉效果,性能更好
基础动画效果
1. 呼吸动画
最经典的动画效果,点的大小按正弦波规律变化。通过Math.sin()函数控制大小的周期性变化,就像呼吸一样有节奏感。
2. 闪烁动画
通过快速改变透明度来实现闪烁效果,特别适合用来标识警告信息或需要紧急关注的位置。使用条件判断让颜色在两个状态间快速切换。
3. 颜色渐变
利用HSL颜色空间,让标注的颜色在整个色环上连续变化,产生彩虹般的渐变效果。这种效果在展示数据变化或状态转换时特别有用。
4. 弹跳动画
控制标签的垂直位置,让文字上下弹跳。使用Math.abs(Math.sin())确保弹跳始终向上,增加趣味性的同时也能有效吸引注意。
5. 旋转动画
对Billboard图标应用旋转变换,适合展示方向性信息。旋转速度可控,可以表示风向、车辆朝向等动态信息。
6. 组合动画
将多种动画效果组合使用,比如同时改变大小、颜色、位置和透明度,创造更丰富的视觉效果。这种方式虽然复杂,但视觉冲击力更强。
7.波纹扩散效果
使用GPU着色器技术实现真正的水波涟漪效果。不同于之前用多个点模拟的方法,这种实现方式性能更好,效果也更自然。
<template><div id="cesiumContainer" style="width: 100%; height: 100vh;"></div>
</template><script setup>
import { onMounted } from 'vue';
import {Ion,Viewer,Cartesian3,Color,PointGraphics,LabelGraphics,BillboardGraphics,CallbackProperty,Math as CesiumMath,JulianDate,ScreenSpaceEventType,defined,HorizontalOrigin,VerticalOrigin,LabelStyle,HeightReference,Material,MaterialAppearance,EllipseGeometry,GeometryInstance,Primitive
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";onMounted(() => {// 设置 Cesium Ion 访问令牌Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJhNmQ5NDYyNi1lZTdhLTRiYTItODFiZi1mYzNiYWNjNDFjMzgiLCJpZCI6NTk3MTIsImlhdCI6MTY2MDE4MDAyNX0.bDTaHEah0hRjUyJWz0hyxIL0Fg63awPXV26OmQ5MCdM';const viewer = new Viewer('cesiumContainer', {animation: false, // 移除动画控件timeline: false, // 移除时间轴控件geocoder: false, // 移除地理编码控件homeButton: false, // 移除主页按钮sceneModePicker: false, // 移除场景模式选择器selectionIndicator: false, // 移除选择指示器fullscreenButton: false, // 移除全屏按钮vrButton: false // 移除 VR 按钮});// ==================== 动画相关变量 ====================let startTime = performance.now(); // 动画开始时间// ==================== 1. 呼吸动画点标注 ====================// 使用 CallbackProperty 创建动态属性const breathingSize = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000; // 经过的秒数const breathingFactor = Math.sin(elapsed * 2) * 0.5 + 1; // 0.5-1.5之间变化return 15 * breathingFactor; // 基础大小15,动态变化}, false);viewer.entities.add({id: 'breathing-point',name: '呼吸动画点',position: Cartesian3.fromDegrees(114.0579, 22.5431),point: new PointGraphics({pixelSize: breathingSize, // 动态大小color: Color.RED,outlineColor: Color.WHITE,outlineWidth: 2,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '呼吸动画点',font: '16pt Arial',fillColor: Color.WHITE,outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new Cartesian3(0, -50, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 2. 闪烁动画点标注 ====================const blinkingAlpha = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const blinkFactor = Math.sin(elapsed * 4) > 0 ? 1.0 : 0.3; // 快速闪烁return blinkFactor;}, false);viewer.entities.add({id: 'blinking-point',name: '闪烁动画点',position: Cartesian3.fromDegrees(113.2278, 23.1291),point: new PointGraphics({pixelSize: 20,color: new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const alpha = Math.sin(elapsed * 4) > 0 ? 1.0 : 0.3;return Color.BLUE.withAlpha(alpha);}, false),outlineColor: Color.YELLOW,outlineWidth: 3,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '闪烁动画点',font: '16pt Arial',fillColor: Color.YELLOW,outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new Cartesian3(0, -50, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 3. 颜色渐变动画点标注 ====================const gradientColor = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const hue = (elapsed * 50) % 360; // 色相循环变化return Color.fromHsl(hue / 360, 1.0, 0.5); // HSL颜色空间}, false);viewer.entities.add({id: 'gradient-point',name: '颜色渐变点',position: Cartesian3.fromDegrees(108.9478, 34.2317),point: new PointGraphics({pixelSize: 25,color: gradientColor,outlineColor: Color.WHITE,outlineWidth: 2,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '颜色渐变点',font: '16pt Arial',fillColor: gradientColor, // 标签颜色也跟着变化outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new Cartesian3(0, -50, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 4. 弹跳动画点标注 ====================const bounceOffset = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const bounce = Math.abs(Math.sin(elapsed * 3)) * 80; // 弹跳高度0-80像素return new Cartesian3(0, -50 - bounce, 0);}, false);viewer.entities.add({id: 'bounce-point',name: '弹跳动画点',position: Cartesian3.fromDegrees(104.0614, 30.6515), // 成都point: new PointGraphics({pixelSize: 18,color: Color.GREEN,outlineColor: Color.DARKGREEN,outlineWidth: 3,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '弹跳动画点',font: '16pt Arial',fillColor: Color.GREEN,outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: bounceOffset, // 动态偏移horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 5. 旋转动画Billboard标注 ====================// 创建一个简单的箭头图标SVGconst arrowSvg = `<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"><polygon points="20,5 35,25 25,25 25,35 15,35 15,25 5,25" fill="orange" stroke="black" stroke-width="1"/></svg>`;const arrowDataUri = 'data:image/svg+xml;base64,' + btoa(arrowSvg);const rotationAngle = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;return elapsed * CesiumMath.PI_OVER_TWO; // 每2秒旋转90度}, false);viewer.entities.add({id: 'rotation-billboard',name: '旋转Billboard',position: Cartesian3.fromDegrees(87.6177, 43.7928),billboard: new BillboardGraphics({image: arrowDataUri,scale: 1.0,rotation: rotationAngle, // 动态旋转角度pixelOffset: new Cartesian3(0, -20, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '旋转动画标注',font: '16pt Arial',fillColor: Color.ORANGE,outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new Cartesian3(0, -80, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 6. 组合动画标注 ====================// 综合多种动画效果的复杂标注const complexSize = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const sizeFactor = Math.sin(elapsed * 1.5) * 0.3 + 1; // 缓慢的呼吸return 20 * sizeFactor;}, false);const complexColor = new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const alpha = Math.sin(elapsed * 2) * 0.3 + 0.7; // 透明度变化return Color.PURPLE.withAlpha(alpha);}, false);viewer.entities.add({id: 'complex-animation',name: '组合动画标注',position: Cartesian3.fromDegrees(91.1406, 29.6522),point: new PointGraphics({pixelSize: complexSize,color: complexColor,outlineColor: Color.WHITE,outlineWidth: 2,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '组合动画',font: '18pt Arial',fillColor: new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const hue = (elapsed * 30) % 360;return Color.fromHsl(hue / 360, 0.8, 0.6);}, false),outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new CallbackProperty(function(time, result) {const elapsed = (performance.now() - startTime) / 1000;const wobble = Math.sin(elapsed * 4) * 10; // 左右摆动return new Cartesian3(wobble, -60, 0);}, false),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 7. 波纹扩散效果====================// 创建波纹扩散函数function createRippleEffect(viewer, longitude, latitude, radius, color, speed) {const instance = new GeometryInstance({geometry: new EllipseGeometry({center: Cartesian3.fromDegrees(longitude, latitude, 0),semiMinorAxis: radius,semiMajorAxis: radius,}),});const appearance = new MaterialAppearance({material: new Material({fabric: {uniforms: {color: Color.fromCssColorString(color),speed: speed,},source: `czm_material czm_getMaterial(czm_materialInput materialInput) {czm_material material = czm_getDefaultMaterial(materialInput);material.diffuse = 1.5 * color.rgb;vec2 st = materialInput.st;float dis = distance(st, vec2(0.5, 0.5));// 创建三道波纹,每道间隔0.3的时间float per1 = fract(czm_frameNumber * 0.032 * speed);float per2 = fract((czm_frameNumber * 0.032 * speed) - 0.3);float per3 = fract((czm_frameNumber * 0.032 * speed) - 0.6);// 计算每道波纹的透明度float pass1 = step(per1 * 0.3, dis) == 0.0? color.a * dis / per1: 0.0;float pass2 = step(per2 * 0.3, dis) == 0.0? color.a * dis / per2: 0.0;float pass3 = step(per3 * 0.3, dis) == 0.0? color.a * dis / per3: 0.0;// 实现渐变消失效果pass1 = pass1 * (1.0 - per1) * 2.0;pass2 = pass2 * (1.0 - per2) * 2.0;pass3 = pass3 * (1.0 - per3) * 2.0;// 取最大值作为最终透明度material.alpha = max(max(pass1, pass2), pass3);return material;}`,}}),});return viewer.scene.primitives.add(new Primitive({geometryInstances: instance,appearance: appearance}));}// 添加波纹效果// 波纹 - 哈尔滨 - 青色createRippleEffect(viewer, 126.5382, 45.8036, 100000, 'rgba(0, 255, 255, 0.8)', 0.2);// 为波纹中心添加标注点和标签viewer.entities.add({id: 'ripple-center-1',name: '哈尔滨波纹中心',position: Cartesian3.fromDegrees(126.5382, 45.8036),point: new PointGraphics({pixelSize: 8,color: Color.CYAN,outlineColor: Color.WHITE,outlineWidth: 2,heightReference: HeightReference.CLAMP_TO_GROUND}),label: new LabelGraphics({text: '哈尔滨-波纹扩散',font: '14pt Arial',fillColor: Color.CYAN,outlineColor: Color.BLACK,outlineWidth: 2,style: LabelStyle.FILL_AND_OUTLINE,pixelOffset: new Cartesian3(0, -60, 0),horizontalOrigin: HorizontalOrigin.CENTER,verticalOrigin: VerticalOrigin.BOTTOM})});// ==================== 设置相机视角 ====================/*viewer.camera.setView({destination: Cartesian3.fromDegrees(116, 30, 3000000),orientation: {heading: 0.0,pitch: -0.99,roll: 0.0}});*/// 使用 flyTo 方法,自动计算最佳视角viewer.flyTo(viewer.entities);// ==================== 点击事件处理 ====================viewer.cesiumWidget.screenSpaceEventHandler.setInputAction(function onLeftClick(event) {const pickedObject = viewer.scene.pick(event.position);if (defined(pickedObject) && defined(pickedObject.id)) {console.log('点击的动画标注:', pickedObject.id.name || pickedObject.id.id);// 可以在这里添加点击后的交互效果if (pickedObject.id.name) {alert(`点击了: ${pickedObject.id.name}`);}}}, ScreenSpaceEventType.LEFT_CLICK);// ==================== 动画控制函数 ====================// 可以添加暂停/恢复动画的功能window.pauseAnimations = function() {startTime = performance.now() - (performance.now() - startTime);};window.resumeAnimations = function() {startTime = performance.now();};// 在控制台输出提示信息console.log('标注动画示例已加载!');console.log('可以使用 pauseAnimations() 和 resumeAnimations() 来控制动画');
});
</script><style>
/* 隐藏页面底部的 Cesium logo 和数据归属 */
.cesium-viewer .cesium-widget-credits {display: none !important;
}/* 隐藏右上角的 Imagery 和 Navigation instructions */
.cesium-viewer .cesium-viewer-toolbar {display: none !important;
}
</style>
源码