CesiumJS 凤凰送福:一只凤凰飞过中国 18 城的新年特效教程
难度级别:中等
核心关键词:CesiumJS、CatmullRomSpline、CallbackPositionProperty、粒子系统、路径动画
来由
前置知识:本文涉及粒子系统和相机跟随,建议先阅读 Cesium实战(三十二)粒子效果随距离变化 和 Cesium实战(六十)视角跟随。
每次在 CesiumJS Sandcastle 看到那个圣诞老人环游世界的 demo,你是不是也在想:如果换成凤凰、龙舟,或者别的中国元素,会不会更有趣?
这篇文章就带你完整复刻凤凰从武汉起飞,飞越 18 座中国城市,沿途洒下福字的新年特效。效果包含:卫星底图、平滑曲线飞行、动态轨迹、粒子福字、背景音乐——一套完整的春节祝福方案。结尾处有视频哦~
一、项目结构
最终效果包含以下几个核心模块:
| 功能模块 | 技术方案 | 关键 API |
|---|---|---|
| 卫星底图 | Google Maps + NaturalEarthII | Google2DImageryProvider |
| 飞行路径 | Catmull-Rom 样条曲线 | CatmullRomSpline |
| 动态位置 | 回调属性实时计算 | CallbackPositionProperty |
| 自动朝向 | 速度方向自动计算 | VelocityOrientationProperty |
| 轨迹光带 | 内置 path 属性 | PolylineGlowMaterialProperty |
| 福字粒子 | 粒子系统跟随模型 | ParticleSystem |
| 距离优化 | 根据相机距离调整粒子大小 | scene.preRender |
二、核心实现
2.1 双底图配置
同时加载两层底图:NaturalEarthII 作为基础,Google 卫星图叠加:
const assetId = 3830184;
const base = Cesium.ImageryLayer.fromProviderAsync(
Cesium.Google2DImageryProvider.fromIonAssetId({
assetId,
mapType: "satellite",
}),
);
const viewer = new Cesium.Viewer("cesiumContainer", {
baseLayer: Cesium.ImageryLayer.fromProviderAsync(
Cesium.TileMapServiceImageryProvider.fromUrl(
Cesium.buildModuleUrl("Assets/Textures/NaturalEarthII"),
),
),
baseLayerPicker: false,
infoBox: false,
geocoder: false,
shouldAnimate: true, // 启用动画
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
creditContainer: "credit",
fullscreenButton: false,
});
viewer.imageryLayers.add(base);
viewer.scene.globe.enableLighting = true;
viewer.scene.globe.depthTestAgainstTerrain = true;
注意:Google 底图可以 Cesium Ion 的 assetId,可以去 Cesium Ion 申请免费 token,目前版本估计可以免费试用一段时间
2.2 时间系统配置
整个飞行周期 2 小时(模拟时间),比例尺 600 倍速:
let ratio = 600; // 时间比例尺
const start = Cesium.JulianDate.fromDate(new Date(2026, 2, 10, 8));
const duration = 12 * ratio; // 7200 秒 = 2 小时
const stop = Cesium.JulianDate.addSeconds(start, duration, new Cesium.JulianDate());
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.multiplier = 1.0;
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
viewer.clock.shouldAnimate = true;
viewer.timeline.zoomTo(start, stop);
2.3 Catmull-Rom 样条曲线定义飞行路径
这是整篇文章的核心。为什么用 Catmull-Rom 而不是简单的直线插值?
因为 Catmull-Rom 样条能保证曲线经过所有控制点,同时自动生成平滑的切线,让凤凰飞行轨迹更自然,不会出现生硬折线。
const h = 8000; // 飞行高度 8000 米
const infos = [
[Cesium.Cartesian3.fromDegrees(114.31, 30.52, h), "武汉", ratio * 0],
[Cesium.Cartesian3.fromDegrees(112.93, 28.23, h), "长沙", ratio * 1],
[Cesium.Cartesian3.fromDegrees(113.27, 23.13, h), "广州", ratio * 2],
[Cesium.Cartesian3.fromDegrees(110.35, 20.03, h), "海口", ratio * 3],
[Cesium.Cartesian3.fromDegrees(108.33, 22.82, h), "南宁", ratio * 4],
[Cesium.Cartesian3.fromDegrees(102.73, 25.04, h), "昆明", ratio * 5],
[Cesium.Cartesian3.fromDegrees(104.07, 30.67, h), "成都", ratio * 6],
[Cesium.Cartesian3.fromDegrees(106.55, 29.57, h), "重庆", ratio * 7],
[Cesium.Cartesian3.fromDegrees(108.95, 34.27, h), "西安", ratio * 8],
[Cesium.Cartesian3.fromDegrees(112.55, 37.87, h), "太原", ratio * 9],
[Cesium.Cartesian3.fromDegrees(116.40, 39.90, h), "北京", ratio * 10],
[Cesium.Cartesian3.fromDegrees(114.48, 38.03, h), "石家庄", ratio * 11],
[Cesium.Cartesian3.fromDegrees(117.00, 36.67, h), "济南", ratio * 12],
[Cesium.Cartesian3.fromDegrees(113.65, 34.76, h), "郑州", ratio * 13],
[Cesium.Cartesian3.fromDegrees(118.78, 32.06, h), "南京", ratio * 14],
[Cesium.Cartesian3.fromDegrees(121.48, 31.23, h), "上海", ratio * 15],
[Cesium.Cartesian3.fromDegrees(120.15, 30.28, h), "杭州", ratio * 16],
[Cesium.Cartesian3.fromDegrees(115.89, 28.68, h), "南昌", ratio * 17],
[Cesium.Cartesian3.fromDegrees(114.31, 30.52, h), "武汉", ratio * 18],
];
const points = infos.map(item => item[0]);
const citys = infos.map(item => item[1]);
const times = infos.map(item => item[2]);
const firstTime = times[0];
const lastTime = times[times.length - 1];
const delta = lastTime - firstTime;
// 起点和终点外推,用于计算首末切线
const before = Cesium.Cartesian3.fromDegrees(114.31, 30.52, 8000);
const after = Cesium.Cartesian3.fromDegrees(114.31, 30.52, 8000);
// 计算首末切线向量
const firstTangent = Cesium.Cartesian3.subtract(
points[0], before, new Cesium.Cartesian3()
);
const lastTangent = Cesium.Cartesian3.subtract(
after, points[8], new Cesium.Cartesian3()
);
// 创建 Catmull-Rom 样条曲线
const positionSpline = new Cesium.CatmullRomSpline({
times: times,
points: points,
firstTangent: firstTangent,
lastTangent: lastTangent,
});

关键参数说明:
firstTangent/lastTangent:首末切线向量,控制曲线起点和终点的走向times:每个城市对应的时间戳(秒)points:城市的笛卡尔坐标(笛卡尔坐标系)
2.4 CallbackPositionProperty 实时计算位置
相关基础:cesium编程中级(七)CZML路径动态改变 介绍了时间轴动画的基本概念。
SampledPositionProperty 只能做线性或多项式插值,要配合 Catmull-Rom 样条,必须用 CallbackPositionProperty:
function GetPosition(time, result) {
const splineTime =
(delta * Cesium.JulianDate.secondsDifference(time, start)) / duration;
if (splineTime < firstTime || splineTime > lastTime) {
return undefined;
}
return positionSpline.evaluate(splineTime, result);
}
// 创建回调位置属性
const position = new Cesium.CallbackPositionProperty(GetPosition, false);
// 自动朝向飞行方向
const orientation = new Cesium.VelocityOrientationProperty(position);
注意:CallbackPositionProperty 第二个参数 isConstant 设为 false,表示位置随时间变化。
2.5 添加城市标记
在每个途经城市添加点标记和名称标签:
for (let i = 0; i < points.length - 1; ++i) {
viewer.entities.add({
position: points[i],
point: {
pixelSize: 8,
color: Cesium.Color.TRANSPARENT,
outlineColor: Cesium.Color.YELLOW,
outlineWidth: 3,
},
label: {
text: citys[i],
font: "24px",
showBackground: true,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
pixelOffset: new Cesium.Cartesian2(0.0, -10),
// 距离越远标签越透明
translucencyByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e6, 0.0),
}
});
}
2.6 创建凤凰实体
相关阅读:Cesium实战(三十五)修改运动中物体朝向 详细讲解了 VelocityOrientationProperty 的使用。
const entity = viewer.entities.add({
availability: new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start: start, stop: stop }),
]),
position: position,
orientation: orientation,
model: {
uri: "./SampleData/phoenix_bird.glb",
minimumPixelSize: 64,
maximumScale: 20000,
},
path: {
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.2,
color: Cesium.Color.fromCssColorString('#FFD700').withAlpha(0.8)
}),
width: 6,
trailTime: 100, // 轨迹保留 100 秒
resolution: 1,
leadTime: 0.1,
},
trackingReferenceFrame: Cesium.TrackingReferenceFrame.INERTIAL,
viewFrom: new Cesium.Cartesian3(-1000, -2000, 1500),
});
viewer.trackedEntity = entity;
path 属性:内置的轨迹显示功能,比手动绘制 Polyline 更简洁,自动跟随 entity 移动。
2.7 福字粒子系统
这是效果的核心亮点——凤凰飞过的轨迹会洒下福字。
const viewModel = {
emissionRate: 3.0, // 每秒发射 3 个粒子
gravity: -1000.0, // 重力加速度(向下)
minimumParticleLife: 1.2,
maximumParticleLife: 5, // 粒子生命周期 1.2-5 秒
minimumSpeed: 100.0,
maximumSpeed: 400.0, // 初速度范围
startScale: 4.0,
endScale: 10.0, // 粒子逐渐变大
particleSize: 25.0, // 福字大小
};
const emitterModelMatrix = new Cesium.Matrix4();
const translation = new Cesium.Cartesian3();
const rotation = new Cesium.Quaternion();
let hpr = new Cesium.HeadingPitchRoll();
const trs = new Cesium.TranslationRotationScale();
// 计算粒子发射器相对于模型的偏移
function computeEmitterModelMatrix() {
hpr = Cesium.HeadingPitchRoll.fromDegrees(0.0, 0.0, 0.0, hpr);
// 发射器在凤凰尾部 (-4.0, 0.0, 1.4)
trs.translation = Cesium.Cartesian3.fromElements(-4.0, 0.0, 1.4, translation);
trs.rotation = Cesium.Quaternion.fromHeadingPitchRoll(hpr, rotation);
return Cesium.Matrix4.fromTranslationRotationScale(trs, emitterModelMatrix);
}
// 计算模型在当前时间的变换矩阵
function computeModelMatrix(entity, time) {
return entity.computeModelMatrix(time, new Cesium.Matrix4());
}
const particleSystem = scene.primitives.add(
new Cesium.ParticleSystem({
image: "./SampleData/zgj.png", // 福字图片
startColor: Cesium.Color.RED,
endColor: Cesium.Color.GOLD.withAlpha(0.0), // 渐变为金色并消失
startScale: viewModel.startScale,
endScale: viewModel.endScale,
minimumParticleLife: viewModel.minimumParticleLife,
maximumParticleLife: viewModel.maximumParticleLife,
minimumSpeed: viewModel.minimumSpeed,
maximumSpeed: viewModel.maximumSpeed,
imageSize: new Cesium.Cartesian2(viewModel.particleSize, viewModel.particleSize),
emissionRate: viewModel.emissionRate,
minimumRotation: 0.0,
maximumRotation: Math.PI,
bursts: [ // 爆发效果
new Cesium.ParticleBurst({ time: 15.0, minimum: 200, maximum: 300 }),
],
lifetime: 10.0,
emitter: new Cesium.SphereEmitter(2.5),
emitterModelMatrix: computeEmitterModelMatrix(),
updateCallback: applyGravity,
}),
);
// 重力回调函数
const gravityScratch = new Cesium.Cartesian3();
function applyGravity(p, dt) {
const position = p.position;
Cesium.Cartesian3.normalize(position, gravityScratch);
Cesium.Cartesian3.multiplyByScalar(
gravityScratch,
viewModel.gravity * dt,
gravityScratch,
);
p.velocity = Cesium.Cartesian3.add(p.velocity, gravityScratch, p.velocity);
}
2.8 根据距离动态调整粒子大小
当相机距离较远时,粒子会变得很小看不清;距离太近时,粒子又会太大遮挡视线。需要在每帧渲染前调整粒子大小:
// 计算两点间距离
function getSpaceDistance(pt1, pt2) {
const geodesic = new Cesium.EllipsoidGeodesic();
geodesic.setEndPoints(pt1, pt2);
const distance = geodesic.surfaceDistance;
return Math.sqrt(
Math.pow(distance, 2) +
Math.pow(pt2.height - pt1.height, 2)
);
}
viewer.scene.preRender.addEventListener(function() {
const currentTime = viewer.clock.currentTime;
const currentPos = entity.position.getValue(currentTime);
const distance = getSpaceDistance(
Cesium.Cartographic.fromCartesian(currentPos),
viewer.camera.positionCartographic
);
if (distance < 10000) {
// 距离近,使用原始大小
particleSystem.startScale = viewModel.startScale;
particleSystem.endScale = viewModel.endScale;
} else {
// 距离远,按反比放大
particleSystem.startScale = viewModel.startScale * 10000 / distance;
particleSystem.endScale = viewModel.endScale * 10000 / distance;
}
// 超过 50 万米不显示粒子
particleSystem.show = distance < 500000;
});
// 每帧更新粒子系统的位置
viewer.scene.preUpdate.addEventListener(function(scene, time) {
particleSystem.modelMatrix = computeModelMatrix(entity, time);
particleSystem.emitterModelMatrix = computeEmitterModelMatrix();
});
2.9 添加背景音乐
<audio controls autoplay loop preload="auto" style="z-index: 99;">
<source src="./SampleData/htxdgxn.mp3" type="audio/mpeg">
你的浏览器不支持音频播放,请升级浏览器!
</audio>
三、关键知识点总结
3.1 Catmull-Rom 样条 vs 其他插值
| 插值方式 | 优点 | 缺点 |
|---|---|---|
| 线性插值 | 简单快速 | 轨迹生硬有折角 |
| 多项式插值 | 曲线平滑 | 可能偏离控制点 |
| Catmull-Rom | 过控制点 + 平滑 | 计算量稍大 |
3.2 CallbackPositionProperty 的适用场景
- 需要自定义插值算法时
- 位置计算依赖复杂数学公式
- 需要实时响应外部数据(如后端推送)
3.3 粒子系统的性能优化
- 限制粒子数量:通过
emissionRate和生命周期控制 - 距离裁剪:远距离时减少或隐藏粒子
- 使用对象池:Cesium 的
ParticleSystem内部已实现 - 降低纹理分辨率:福字图片尽量小(64×64 足够)
四、避坑指南
1. 模型朝向错误
确保 orientation 使用 VelocityOrientationProperty,凤凰会自动朝向飞行方向。如果朝向不对,检查模型坐标系是否正 Y 轴朝前。详细原理参考 Cesium实战(三十五)修改运动中物体朝向。
2. 粒子不跟随模型
必须在 preUpdate 事件中每帧调用 computeModelMatrix,更新粒子的 modelMatrix。更详细的粒子系统用法见 Cesium实战(三十二)粒子效果随距离变化。
3. 轨迹光带不显示
检查 path 的 trailTime 是否设置合理,以及 availability 时间区间是否包含当前时间。
4. 距离计算不准确
使用 EllipsoidGeodesic 计算地表距离,再加上高度差,得到三维空间距离。
5. 浏览器静音策略
Chrome 等浏览器会阻止自动播放音频,需要用户先与页面交互。可以给页面添加点击监听:
document.addEventListener('click', () => {
document.querySelector('audio').play();
}, { once: true });
五、完整代码
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>凤凰送福</title>
<script src="../Build/Cesium/Cesium.js"></script>
<style>
@import url(../Build/Cesium/Widgets/widgets.css);
html, body, #cesiumContainer {
width: 100%; height: 100%;
margin: 0; padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<div id="credit" style="display: none;"></div>
<audio controls autoplay loop preload="auto" style="z-index: 99;">
<source src="./SampleData/htxdgxn.mp3" type="audio/mpeg">
</audio>
<script>
// ... 完整代码见上文 ...
</script>
</body>
</html>
六、总结
这个示例完整展示了 CesiumJS 的高级动画功能:
- CatmullRomSpline:自定义平滑曲线路径
- CallbackPositionProperty:灵活的位置计算
- ParticleSystem:视觉特效粒子
- 场景事件:
preRender/preUpdate实现动态优化
“好的三维可视化,是让用户忘记技术的存在,直接沉浸其中。”
新年将至,希望这份代码能帮你送出一份特别的祝福。技术从来不是冷冰冰的代码,它也可以有温度、有情感。
祝大家新年快乐,代码无 Bug,头发不掉线!
推荐阅读:
- cesium编程入门(十)优秀资源 – Cesium学习资源汇总
- Cesium实战(六十)视角跟随 – 相机追踪移动物体的详细教程
- Cesium实战(三十二)粒子效果随距离变化 – 粒子系统性能优化技巧
- Cesium实战(三十五)修改运动中物体朝向 – VelocityOrientationProperty 深度解析
- cesium编程中级(七)CZML路径动态改变 – 时间轴动画基础
示例源码:已包含在本文中,完整文件可访问 https://sandbox.cesium.xin/Apps/NewYear.html
API 版本:CesiumJS 1.137