CesiumJS凤凰送福:从武汉出发让3D地图说出新年祝福

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 粒子系统的性能优化

  1. 限制粒子数量:通过 emissionRate 和生命周期控制
  2. 距离裁剪:远距离时减少或隐藏粒子
  3. 使用对象池:Cesium 的 ParticleSystem 内部已实现
  4. 降低纹理分辨率:福字图片尽量小(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,头发不掉线!


推荐阅读

示例源码:已包含在本文中,完整文件可访问 https://sandbox.cesium.xin/Apps/NewYear.html

API 版本:CesiumJS 1.137

发表评论