上一篇结尾我说要换个思路:不再靠相机去"看",而是让引擎老老实实把一块地的地形数据交出来,一格一格地算。
那句先记在账上------空间分析-地形高度场不是一篇,是一条完整的支线(服务端采样、派生着色器、拾取深度反投影、五方案对比、自请瓦片光栅化、getHeight 同步查询,六个子案例)。它跑不了,但我得先把手里这几篇场景示例补完,回头专门腾一篇给它。
今天换个方向。不谈"算得对不对",谈**"演得好不好"**。
前面这十几篇,我们一直在跟"引擎算出来的数字准不准"较劲:缓冲区的米数、剖面的高程、通视的结论。今天这篇不一样------它算的东西全都是对的,一个数都没错。它只是把这一堆正确的数字,在时间轴上排错了顺序。
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
写这篇之前,我请同事看了一遍这个演示。他看完说:"挺流畅的,就是最后那下有点怪,画面好像闪回去了。"我当时以为他在说浏览器卡了一下。直到我把时间轴拖到 59.5 秒、盯着屏幕等它走到 60------画面确实在最后一刻当着我的面翻回了开场视角。
一个 60 秒的汇报演示,最后 0.5 秒的镜头被吃掉了。而这份讲解词里,那一句是"最后我们登上塔顶,远眺整个园区"。
本文目标 :把一套"时间驱动"的演示引擎拆开,看清一条 193 米的动线为什么会被走成 65 米的弯、配置里明明全是 0 的 roll 为什么会歪到 5.18°、以及为什么同一个时间点,用鼠标拖过去和让它自己播过去,看到的是两个画面。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么"演示"这件事最容易翻车
🎯 背景现状 :交互式地图和演示型地图是两种物种。前者允许用户随便拖、随便点,错了还能重来;后者是一次性的------几十号人坐在会议室里,屏幕前面只有你,动作一旦发出去就收不回来。它不要求"什么都能做",只要求**"每一次都一模一样"**。
典型场景里,它要同时扛住这些:
- 🏙️ 招商 / 规划汇报:一条固定动线走完整个园区,哪一秒看主楼、哪一秒开弹窗、哪一秒切到地下管网,全部是编排好的
- 🏗️ 工程验收与交底:把开挖、支护、管线的时空关系按施工顺序演一遍,不能倒回去看
- 🚨 指挥调度大屏:无人值守循环巡演,一天播几百遍
- 🎞️ 数字孪生常态化展示:既要能自动播,又要能让人随时拖到任意时刻"看一下那会儿是什么样"
🤦 传统痛点:这门技术不新,翻车的姿势倒是很稳定。
| 方案 | 问题 |
|---|---|
把相机路径写死,requestAnimationFrame 里硬插值 |
能跑,但事件和相机耦合在一根 if-else 链里;暂停、回退、变速全得自己重写一遍 |
| 演示时人工切镜头 | 现场手抖一次就没了;两场讲得不一样,观众能看出来 |
| 录一段视频播 | 最稳,但彻底放弃交互------用户说"能倒回去看看刚才那一段吗",你只能重播整段 |
| 接现成的演示引擎(本文这条) | 靠 viewer.clock 统一驱动,能拖能停能变速;代价是"时间"这件事从此不完全归你管 |
💡 本文方案:这个 demo 走的最后一条路,思路是这样的:
1️⃣ 把演示写成数据 :一份 Presentation,里面是 5 个关键帧(时间 + 经纬高 + 朝向)加 10 个事件(时刻 + 类型 + 载荷),纯 JSON,不写一行动画代码。
2️⃣ 把数据喂给属性系统 :关键帧塞进 SampledPositionProperty 和 SampledProperty(Quaternion),由 Cesium 负责插值出"任意时刻相机在哪、朝哪"。
3️⃣ 把时间交给 viewer.clock :播放就是 clock.shouldAnimate = true,暂停就是 false;倍率、循环、起止时间全部交给引擎的时钟。
4️⃣ 把事件挂到时钟上:每帧 tick 时对一遍事件表,到点就广播出去,UI 层收到通知再去开图层、开弹窗、启车流。
✨ 一句话总结:相机、图层、弹窗、特效全都降级成"时间的函数"------只要时间是对的,画面就该是对的。
思路没毛病。问题在于,第 2 步里"插值出任意时刻"的那个插值,和第 4 步里"每帧对一遍事件表"的那个时刻,并不是同一件事。
二、核心技术原理:一套时间驱动的编排引擎
2.1 引擎长什么样
整个 PresentationEngine 只有 307 行,核心很短。构造函数里做了两件事------定一个时间的起点,然后准备两条属性通道:
ts
constructor(viewer: Viewer) {
this.viewer = viewer
this.epoch = JulianDate.fromDate(new Date(0)) // ⚠️ 时间的零点是 1970-01-01
this.positionProperty = new SampledPositionProperty()
this.positionProperty.setInterpolationOptions({
interpolationDegree: 2,
interpolationAlgorithm: HermitePolynomialApproximation // 位置:Hermite 二次
})
this.orientationProperty = new SampledProperty(Quaternion)
this.orientationProperty.setInterpolationOptions({
interpolationDegree: 1,
interpolationAlgorithm: LagrangePolynomialApproximation // 朝向:Lagrange 一次
})
this.viewer.clock.onTick.addEventListener(this.onTick)
}
两件事值得先说清楚:
第一,位置和朝向用的是两套完全不同的插值算法。 位置是 Hermite 二次(会用相邻点的切向做平滑),朝向是 Lagrange 一次(就是线性插值,只不过插的对象是四元数,Cesium 内部会先 convertPackedArrayForInterpolation 把相邻四元数的相对旋转转成"旋转向量、轴 × 角度",插完再 unpackInterpolationResult 用指数映射还原,所以结果仍然是单位四元数)。两套算法各自的端点都是精确的,但中间的走法不一样。
第二,epoch 用的是 new Date(0),也就是 1970 年。 所以这场 60 秒的演示,viewer.clock.currentTime 实际运行在 1970-01-01 00:00:00 到 00:01:00 之间。它不影响画面(demo 没开日照),但它意味着这套引擎直接接管了 viewer.clock------如果场景里还有别的时序数据(CZML、动态实体),它们的时钟会被一起拖到 1970 年。
关键帧进属性系统,代码很直白:
ts
presentation.keyframes.forEach((keyframe) => {
const time = this.secondsToJulianDate(keyframe.time)
this.positionProperty.addSample(time, Cartesian3.fromDegrees(
keyframe.position.longitude, keyframe.position.latitude, keyframe.position.height))
const hpr = new HeadingPitchRoll(keyframe.orientation.heading,
keyframe.orientation.pitch, keyframe.orientation.roll)
this.orientationProperty.addSample(time, Quaternion.fromHeadingPitchRoll(hpr))
})
一行注释都没有,但这里藏着一个后面要展开的东西:keyframe.transition 这个字段,上面这段代码根本没用。 配置里每个关键帧都老老实实写了 smooth / easeIn / easeOut,它们从头到尾没有参与任何计算。
2.2 位置和朝向,是两条各走各的轨道
先看一组最直接的数字。第 2 个关键帧(t = 15 s)到第 3 个关键帧(t = 30 s),两端点在三维空间里只隔 193.159 m,高度从 300 m 降到 120 m。这条 15 秒的动线,配置得很朴素。
我在 Node 里用同一份数据复刻了 PresentationEngine 的两条属性通道,每 0.25 秒采一次样,看每个量"从出发站走到下一站"的完成度:
| 走到 22.5 s(行程正中间)时 | 该量的完成度 |
|---|---|
| 朝向 heading(0° → 90°) | 52.6% |
| 高度 h(300 m → 120 m) | 120.8% |
| 经度 lon(+42.754 m) | 37.5% |
| 纬度 lat(−55.516 m) | 12.5% |

行程走到一半,朝向已经转了一半(52.6%,合理),高度却已经走过头了(120.8%),经度只走了 37.5%,而纬度才走了 12.5%------它在头两秒甚至往回退了一点点(完成度一度掉到 −2.7%,因为 Hermite 在段起点继承了上一段"向北加速"的切向,先冲过头再折回来)。
这就是两套算法并排跑的后果:朝向走的是旋转向量线性插值,位置走的是 Hermite 二次样条,两者在时间轴上的"进度"没有任何约定。 观众看到的是"镜头一边转、一边靠近",实际发生的是"转头已经过半,人还在原地打转"。
那 Hermite 这条轨道,走出来的路到底长什么样?我把每段动线上实际的位置点,投影到"两端点连成的那条直线"上,算垂直偏离:
| 段 | 起止 | 端点三维距离 | 最大垂距 | 出现在 | 占段长 |
|---|---|---|---|---|---|
| kf-1 → kf-2 | 0 ~ 15 s | 1205.127 m | 10.453 m | 7.50 s | 0.867% |
| kf-2 → kf-3 | 15 ~ 30 s | 193.159 m | 65.218 m | 22.50 s | 33.764% |
| kf-3 → kf-4 | 30 ~ 45 s | 135.000 m | 8.759 m | 37.50 s | 6.488% |
| kf-4 → kf-5 | 45 ~ 60 s | 858.877 m | 5.325 m | 52.50 s | 0.620% |
一条 193 米的直线,相机给它走出了一个 65 米的肚子。
第三段更离谱:kf-3 和 kf-4 的经纬度完全相同 (都是 116.3975E / 39.9085N),这一段的意图就是"原地垂直升降 135 米"。但实测下来,相机在这 15 秒里横向画了一个 8.759 m 的圈------电梯在晃。
原因就是 Hermite:它为了让相邻两段的拼接处看起来顺滑,会在每个采样点估计一条切向。切向是拿前后邻居算出来的,跟"这一段想去哪"没关系,所以每一段都被拉成了一个 S 形。配置里写的是直线,跑出来的是弓形;每段都弓一下,弓的方向还各不相同(第一段往北 20.7 m、第三段往东 5.3 m 往南 6.9 m、第四段往西南 33.7 m)。
顺带把这五个路点的尺度摊开看一下:关键帧的经纬度总跨度只有 256.5 × 222.1 m 。第 1 站(1500 m 高空俯瞰)到第 5 站(800 m 离场远眺)的地表距离 339.288 m ,三维直线距离 777.919 m 。这条在讲解词里叫"全域概览 → 离场远眺"的动线,首尾两站的直线距离还没它的高差(700 m)长。
2.3 事件这一步,慢了一拍
相机之外,还有一半东西挂在事件上。引擎的每帧 tick 长这样:
ts
private onTick = (): void => {
if (this.disposed || !this.presentation || !this.viewer.clock.shouldAnimate) return
const currentTime = this.viewer.clock.currentTime
const seconds = this.julianDateToSeconds(currentTime)
const duration = this.presentation.duration
this.updateCamera() // ① 相机跟着时间走
this.checkEvents(seconds) // ② ⚠️ 事件先触发
...
this.emit({ event: 'timeupdate', seconds }) // ③ ⚠️ 通知 UI「现在是几秒」在后
}
checkEvents 扫一遍事件表,凡是 seconds >= event.time 且还没触发过的,就广播出去。而 UI 层收到事件后,读到的"当前时间"是 currentTime 这个 ref------它在第 ③ 步才更新。
于是链条是这样断的:effect_start 事件在 25 秒触发 → UI 的 setEffectActive 去拿"当前时间"当车流起点 → 拿到的却是上一帧的值。
正常播放时,这个滞后只有一帧(60 fps 下 16.67 ms × 55 m/s = 车流起点偏 0.9167 m),谁也看不出来。但如果用户是拖过去的呢?
ts
seek(time: number): void {
...
this.viewer.clock.currentTime = this.secondsToJulianDate(clamped)
this.triggeredEvents.clear()
this.presentation.events.forEach((event) => {
if (event.time <= clamped) { this.triggerEvent(event); this.triggeredEvents.add(event.id) }
})
this.updateCamera()
this.emit({ event: 'timeupdate', seconds: clamped }) // ← 同样是最后才更新
}
从 0 秒直接拖到 30 秒:effect_start(在 25 秒)被重放,setEffectActive 此时读到的"当前时间"还是 0。车流起点被记成了 0 秒,而正确的应该是 25 秒。

同一个 30 秒,播过去和拖过去,第一辆车差了 1374.9 米。 而这条环形道路一圈才 2827.433 米------差了将近半圈。
这个差值跟拖到哪一秒无关,恒等于 (30 − 0) × 55 = 1375 m,因为滞后量就是"seek 的距离"。
2.4 三条路子的取舍
| 方案 | 时间归谁管 | 能拖能停能变速 | 代价 |
|---|---|---|---|
自己写 requestAnimationFrame 插值 |
你 | 要自己实现一遍 | 时间、事件、UI 三份状态容易打架 |
用 viewer.clock 统一驱动(本文) |
引擎 | 天然支持,clockRange / multiplier 直接调 |
受时钟的脾气:按真实墙钟推进、切后台会攒账、事件判定与状态广播的顺序要自己保证 |
| 用 CZML / 录屏 | 数据源 / 视频 | CZML 能拖,录屏不能 | CZML 结构更重;录屏彻底放弃交互 |
简单说:把时间交出去,换来的是现成的播放控制;代价是"时间"这件事从此不完全归你管。 这个 demo 选了最省事的那条。
三、代码实战:怎么把这件事量出来
3.1 在 Node 里把引擎的插值跑起来
这次的实测比上一篇简单得多------不需要 GPU 。SampledPositionProperty、SampledProperty、Quaternion、JulianDate 全是纯数学类,@cesium/engine 的 Core / DataSources 部分在 Node 里直接就能跑。
要复刻的只有两样:一份和 presentation.ts 逐字相同的键帧数据,和 PresentationEngine 构造函数里那四行 setInterpolationOptions。
js
const posProp = new SampledPositionProperty()
posProp.setInterpolationOptions({ interpolationDegree: 2, interpolationAlgorithm: HermitePolynomialApproximation })
const rotProp = new SampledProperty(Quaternion)
rotProp.setInterpolationOptions({ interpolationDegree: 1, interpolationAlgorithm: LagrangePolynomialApproximation })
for (const k of KEYFRAMES) {
const t = S2J(k.t)
posProp.addSample(t, Cartesian3.fromDegrees(k.p.lon, k.p.lat, k.p.h))
rotProp.addSample(t, Quaternion.fromHeadingPitchRoll(new HeadingPitchRoll(k.o.heading, k.o.pitch, k.o.roll)))
}
采样就是把 getValue(time) 拿到的 Cartesian3 / Quaternion 反算回经纬高和欧拉角:
js
const cg = Cartographic.fromCartesian(posProp.getValue(S2J(t)), Ellipsoid.WGS84)
const hpr = HeadingPitchRoll.fromQuaternion(rotProp.getValue(S2J(t)))
顺手记一个坑 :JulianDate.secondsDifference(left, right) 返回的是 left − right (源码 JulianDate.js L939-951,两个参数的名字就叫 left / right,不是 start / stop)。我在探针里第一次写成了 secondsDifference(EPOCH, date),结果所有时间都变成了负数------而这个负数一路传进了 while (seconds < 60) 的循环条件,脚本直接死循环挂住,查了半天才发现是参数顺序。
demo 自己的写法是对的:julianDateToSeconds(date) 里是 secondsDifference(date, this.epoch)。
3.2 真值:"配置里本该是什么"
有了两条属性通道,就可以算出基准------如果这段动线老老实实按端点匀速直推,每个量应该在什么位置。位置基准是两端点连成的三维直线,朝向基准是角度线性插值。实际值减去基准,"错位"就量化出来了。
这里要坦白一句:这套基准是我定的,不是 demo 的意图。 Hermite 二次样条本来就是"有意为之"的平滑,引擎没写错。下面这些数字不是"bug 有多严重",而是"配置里那 5 个路点之间,实际发生了什么"------因为没有人在配置里写"中间要鼓 65 米",这个信息只存在于插值算法的行为里。
3.3 把不正确的东西画出来

上面那块是椭球高。灰虚线是"端点直连"的基准,金色是实际值。五个路点的值都精确命中(误差小于 1e-8 m),但两站之间被拉成了弓形:
| 段 | 配置高度 | 实测区间 | 越过端点 |
|---|---|---|---|
| kf-1 → kf-2 | 1500 → 300 m | 300.0000, 1500.0000 | 无 |
| kf-2 → kf-3 | 300 → 120 m | 66.6177, 300.0000 | 下冲 53.3823 m(t = 25.14 s) |
| kf-3 → kf-4 | 120 → −15 m | −14.8500, 120.0000 | 无 |
| kf-4 → kf-5 | −15 → 800 m | −15.0000, 798.2808 | 无 |
第二段被拉到了 66.62 m ------ 比它的终点还低 53.38 m。 而这一段在讲解词里是"从空中降下来,平视主楼",那多出来的 53 米,相当于镜头贴到楼顶裙房那一层再弹回去。
下面那块是 roll。配置里 5 个关键帧的 roll 全是 0 ,实测却在 −5.1775°, +1.7328° 之间来回摆,绝对值最大 −5.1775°(t = 52.78 s)。
原因是欧拉角耦合:朝向走的是旋转向量(指数映射)插值,当 heading 和 pitch 同时变化时,两个旋转不可交换,多出来的分量就落到了第三个欧拉角 roll 上。相机本身没歪,但它的"上方向"歪了------观众看到的画面跟着斜了 5 度多。
同理,heading 偏离"线性推进"基准最大 2.5544°(t = 53.28 s)。
3.4 时间轴状态机的复刻
事件那一半,光看插值不够,还得把 Clock 的 tick 复刻出来。这一步不用猜------直接照 Source/Core/Clock.js L258-315 逐行搬,把"上一次 tick 的时间戳"做成可控参数,就能手工制造掉帧、切后台、跳帧这些场景:
js
function tick(clock, nowMs) {
let currentTime = JulianDate.clone(clock._currentTime)
if (clock.canAnimate && clock._shouldAnimate) {
const milliseconds = nowMs - clock._lastSystemTime
currentTime = JulianDate.addSeconds(currentTime, clock._multiplier * (milliseconds / 1000.0), currentTime)
// ... CLAMPED / LOOP_STOP 的边界处理
}
clock._currentTime = currentTime
clock._lastSystemTime = nowMs
return { seconds: J2S(currentTime), looped }
}
再给策略层加一个事件表和一个 effectStartTime,就能把"播过去 / 拖过去"的差异算出来。车流位置本身就是一行取模:
js
const dist = (elapsed * CAR_SPEED + roadOffsets[i]) % circumference
const angle = dist / ROAD_RADIUS
四个数字:ROAD_RADIUS = 450、CAR_SPEED = 55、CAR_COUNT = 16、circumference = 2π × 450 = 2827.433 m。
补充一句局限:这份状态机模拟的是
PresentationEngine+ Demo 策略层的逻辑,不含 GPU 渲染。演示进度跟墙钟的关系、事件触发顺序、车流位置的算术,全都在这层,所以结论是可靠的;但"画面到底长什么样",还得等真机截图。
四、功能亮点与性能优势

4.1 演示时长跟帧率无关,但跟"你离开了多久"有关
viewer.clock 默认的 clockStep 是 SYSTEM_CLOCK_MULTIPLIER,含义是"按真实经过的墙钟时间 × 倍率推进"。实测两种帧率跑到终点:
| 帧率 | 帧数 | 真实耗时 | 末帧 seconds |
|---|---|---|---|
| 60 fps | 3600 | 60.000 s | 60.000000 |
| 20 fps | 1200 | 60.000 s | 60.000000 |
帧率不影响总时长,这是这套机制最大的优点------不管机器好坏,同事看到的是同一场 60 秒。
但代价在 Clock.tick 里:
js
const milliseconds = currentSystemTime - this._lastSystemTime
currentTime = JulianDate.addSeconds(currentTime, multiplier * (milliseconds / 1000.0), currentTime)
milliseconds 是"上次 tick 到现在"的真实差值,没有任何上限 。而浏览器在标签页切到后台时,requestAnimationFrame 是停的。于是:
| 场景 | 结果 |
|---|---|
| 已播到 10 s,切后台 1 s | 回来第一帧跳到 11.000 s |
| 已播到 10 s,切后台 10 s | 跳到 20.000 s(占全程 16.7%) |
| 已播到 10 s,切后台 30 s | 跳到 40.000 s(占全程 50%) |
| 已播到 55 s,切后台 12 s | 直接夹到 60.000 s,演示结束 |
演示给谁看的时候,有人切了一下微信,回来就是半场。
4.2 事件只触发一次,这件事是对的
triggeredEvents 是个 Set,只在 load() 和 seek() 时清空。同一帧里就算 seconds 一下跨过好几个事件,也只会各触发一次,不会重复广播。变速到 0.5x 或者 2x 也不影响触发次数。这一点做得比很多手写方案干净。
4.3 循环勾选框:名不副实的地方比想象中多
UI 上的"循环"是 clock.clockRange = ClockRange.LOOP_STOP。名字里带 LOOP,但它不是终点停住,而是绕回起点继续 (Clock.js L295-306 里那个 while 会把超出 stopTime 的部分加回 startTime)。实测连续跑 180 秒真实时间,绕回 3 次:
| 配置 | 连跑 180 s 的末态 | 是否广播 ended |
|---|---|---|
| loop 关(CLAMPED) | 停在 60.000000 s | 100% 触发 |
| loop 开(LOOP_STOP) | 绕回 3 次,停在 0.0000 s | 0% 触发 |
这个对比引出了两个具体后果:
- loop 关的时候,
seconds >= duration这个判据 100% 会命中。 它做的事是:shouldAnimate = false→ 广播最终时间 →restoreInitialScene()把相机拉回开场快照 → 广播ended。全部在同一帧内。 - loop 开的时候,它 0% 命中。 于是
ended事件永远不广播,而 UI 里engine.on('ended', () => { isPlaying.value = false })------ 按钮会一直停在"暂停",永远变不回"播放"。
核心优势:
- 🚀 演示即数据:动线和事件是纯配置,改一份 JSON 就是另一场演示,不用碰动画代码
- 🎛️ 播放控制一次到位 :播放 / 暂停 / 拖拽 / 倍率 / 循环,全部交给
viewer.clock,代码量极小 - 🧩 事件与渲染解耦 :引擎只管"到点了",图层、弹窗、车流各管各的,加一类事件只需要加一个
case - 🔍 可读性强 :
duration、keyframes、events三段结构,非开发者也能看懂这场演示的编排
代价:这四个优点的前提都是"时间按你想的那样走"。它借用了引擎的时钟,就继承了引擎时钟的一切脾气。
五、小贴士:避坑指南
🎯 关于 transition ------ 它是个装饰品
presentation.ts 给每个关键帧都定义了过渡曲线:
ts
export type KeyframeTransition = 'smooth' | 'linear' | 'easeIn' | 'easeOut'
kf-4 写的是 easeIn,kf-5 写的是 easeOut,看着挺讲究。但 PresentationEngine.load() 里从头到尾没读过这个字段------插值算法是全局设一次的,跟单个关键帧无关。
实测:把 5 个关键帧的 transition 全改成 smooth、全改成 easeIn、全改成 easeOut,以及保留原配置,四种情况下在 t = 7.5 / 22.5 / 37.5 / 52.5 秒采样的结果逐位相同:
smooth×5 -> 772.49978/0.00000 82.50000/47.38405 ...
easeIn×5 -> 772.49978/0.00000 82.50000/47.38405 ...
easeOut×5 -> 772.49978/0.00000 82.50000/47.38405 ...
原配置 -> 772.49978/0.00000 82.50000/47.38405 ...
⚠️ 重要提示 :如果你在 UI 上让用户选"这段用 easeIn 还是 linear",先确认这个选择真的接到了插值上。Cesium 的 SampledPositionProperty 本身不支持逐段过渡曲线,要做得自己拿 HermiteSpline / LinearApproximation 分段构造,或者在时间轴上做重参数化。
⚡ 关于 seek() ------ 拖过去和播过去不是一回事
seek() 里事件的触发顺序是"先广播事件、后广播时间",于是事件回调读到的"当前时间"永远是 seek 之前的值。这个差值等于你拖了多远。
| 到达 30 s 的方式 | 事件回调读到的时刻 | 车流起点 | 与正常播放的差 |
|---|---|---|---|
| 正常播放 | 25 s(滞后一帧 16.67 ms) | 275.9 m | 0.9167 m |
| 从 0 s 直接拖到 30 s | 0 s | 1650 m | 1374.9 m |
两者相差 1800 倍。
更糟的是往回拖。播到 40 秒时 effectStartTime 已经是 25,此时拖回 10 秒:
js
elapsed = 10 - 25 = -15 // 负数
dist = (-15 * 55 + offset) % 2827.433
JS 的 % 对负数返回负数 ,于是 dist = -825 m、angle = -105.042°------车流跑到环路的镜像位置去了。而 10 秒这个时刻,effect_start 根本还没发生,车本来不该出现。
同时,seek() 只清空 triggeredEvents 并重放 time <= clamped 的事件,它不会"倒着执行"那些关闭类事件。播到 48 秒再拖回 10 秒,实测残留:
建筑标签 true(应为 false)
车流 true(应为 false)
地下管网 true(应为 false)
地下模式 true(应为 false)
10 秒的时刻,画面里却是 48 秒的场景。
⚠️ 重要提示 :seek() 应该按"目标时刻应有的完整状态"重建,而不是"重放一遍已发生的事件"。要么给每类事件写一对正反操作,要么把 triggeredEvents 换成"状态计算函数":给定时刻,直接算出该显示什么,然后一次性写回。
🧭 关于两个地图常数 ------ 111320 和 110540
createRoad() 用一个圆近似环形道路,经纬度偏移是这么算的:
js
const roadLonOffset = ROAD_RADIUS / (111320 * Math.cos((CENTER_LAT * Math.PI) / 180))
const roadLatOffset = ROAD_RADIUS / 110540
实际落点:
| 项 | demo 用的常数 | WGS84 在 39.908°N 的真值 | 偏差 |
|---|---|---|---|
| 纬度 1° | 110540 m | 111032.8651 m | 偏小 492.87 m(0.4439%) |
| 经度 1° | 85390.8537 m | 85508.3481 m | 偏小 117.49 m(0.1374%) |
于是"半径 450 m 的圆"实际是 450.619 m × 452.006 m 的椭圆 ,长短轴差 1.387 m。用 EllipsoidGeodesic.surfaceDistance 复核,南北 452.0066 m、东西 450.6192 m,与推算一致。
同一段代码还有第二个小误差:圆是用 48 段折线连出来的(steps = 48),多边形周长比理想圆短 0.07138%(2.018 m) ;车走的是理想圆弧(angle = dist / ROAD_RADIUS),所以最多会偏离道路中心线 0.9635 m。
顺带一个问题:CAR_SPEED = 55,单位是米/秒------198 km/h。这条 2827 米的园区环路,一辆车跑一圈只要 51.4 秒。汇报演示里的车流本来是个"氛围元素",但 198 这个数字如果被现场的交通专业观众问起来,会有点尴尬。
⚠️ 重要提示 :跨经纬度做米制布局,别用 111320 这类赤道常数往下套。要精确就用 EllipsoidGeodesic 反算,要好看就直接用 ENU 局部坐标系画几何、最后一个矩阵转回经纬度。
📦 关于 Math.random() ------ 同一份讲稿,每次演出都不一样
createBuildings() 里 26 栋楼全是随机生成的:
js
const lon = CENTER_LON + (Math.random() - 0.5) * 0.0075
const lat = CENTER_LAT + (Math.random() - 0.5) * 0.006
const height = 25 + Math.random() * 90
const width = 25 + Math.random() * 40
散布范围约 ±320 m × ±333 m,高度 25 ~ 115 m。而地标主建筑是固定的:位置在 (116.3975, 39.9085),尺寸 70 × 70 × 130 m。
我跑了 20000 次抽样:
| 统计项 | 结果 |
|---|---|
| 至少有一栋随机楼与主建筑包围盒相交的概率 | 56.39% |
| 每次相交的楼数(平均) | 0.814 栋 |
| 最近那栋楼到主建筑中心的距离(中位) | 60.04 m |
| 同上(最小,20000 次里) | 0.10 m |
超过一半的概率,会有随机楼穿过地标主楼。 而这条动线的第 3 站(kf-3)相机在 (116.3975, 39.9085, 120 m)------和主建筑中心的水平距离是 0.000 m ,而建筑高 130 m。也就是说,只要地表低于 120 m(北京城区约 40 ~ 50 m),这一站的相机就站在那栋楼的肚子里。
更要紧的是"可复现性":同一份讲稿,今天彩排看到的园区和明天正式汇报看到的不是同一个园区;这次镜头正好停在两栋楼中间,下次可能就是一面墙。⚠️ 重要提示 :演示场景里的随机数,必须固定种子(或者干脆把生成结果存成数据)。汇报类场景,"每次都一样"是需求,不是愿望。
🖱️ 关于时间轴点击 ------ 拖不到头也拖不到尾
handleTrackClick 的绑定和计算有个 16 px 的错位:
js
const rect = el.getBoundingClientRect() // el 是 .timeline-container(外层)
const percent = (event.clientX - rect.left) / rect.width
seekTo(Math.max(0, Math.min(duration, percent * duration)))
但 .timeline-track 有 margin: 0 16px,刻度条两侧各缩进 16 px。所以点击轨道 的最左端时,percent 算出来不是 0:
| 容器宽 | 点轨道最左 | 点轨道最右 |
|---|---|---|
| 1160 px | seekTo(0.8276 s) | seekTo(59.1724 s) |
| 900 px | seekTo(1.0667 s) | seekTo(58.9333 s) |
| 600 px | seekTo(1.6000 s) | seekTo(58.4000 s) |
永远拖不到 0 秒,也永远拖不到 60 秒。
当前局限与后续优化
这个案例还不完美:位置与朝向用两套插值、transition 字段没接线、seek() 里事件早于时间广播、回退时状态不清理、结算时会吃掉最后一帧、场景里还有一处没固定种子的随机。但它把"一场演示的时间轴是怎么被驱动的、又会在哪儿对不上"这件事讲清楚了,这就够了。
真要往汇报场景里用,我建议这么改:
- 把
checkEvents挪到emit(timeupdate)之后 ,或者干脆让事件回调显式接收seconds参数,别让下游去猜"现在是几秒"。 seek()改成"状态重建":给定时刻,直接算该显示哪些图层、哪个弹窗、车流该在哪里,一次性写回;别依赖"重放已发生的事件"。- 给每段动线一条显式的过渡曲线 :把
transition真接上(用HermiteSpline分段,或在时间轴上重参数化),配置里写easeIn就真的慢起。 restoreSceneOnEnd加一个"停留 N 秒再回",或者干脆改成"回开场但不重置时间轴",别让最后一帧没有展示时间。Clock.tick的墙钟差值加上限 (比如Math.min(milliseconds, 100)),后台回来最多推进 0.1 秒,别一口气吃掉半场;同时给演示加个"最大倍率"提示。- 随机场景固定种子,或者把生成结果固化成数据文件。
- 关键帧坐标做一次合法性检查:至少确认相机不在实体内部、道路半径在目标纬度下与名义值一致。
六、写在最后
演示这件事,做久了会有一个错觉:/ 以为自己在排一条"什么时候看哪里"的时间轴,/ 其实是在排三条互不认识的轨道------位置、朝向、事件,/ 它们各自都走得很准时,只是没走出同一个故事。
从 #54 挖地、#55 切剖面、#56 判通视,我们一直在跟"算得对不对"较劲;今天抬头一看,原来"演得好不好"是另一个独立的问题。而且更麻烦------算错了能验证,演歪了,只有坐在会议室里的那几十号人能看出来。🎉
现在,Cesium 不仅能告诉你"这座山挡不挡路",还能把一场 60 秒的汇报从头到尾自己走一遍。虽然它走的时候,位置、朝向和事件各走各的------需要你替它们把时间轴对齐。
这个案例还不完美:插值算法混搭、transition 是装饰品、拖拽与播放不等价、结算会吃掉最后一帧、随机园区不可复现。但它把"一场三维汇报的时间轴是怎么被驱动的、又会在哪儿对不上"这件事讲清楚了,这就够了。
下一篇我们回到数据侧------不再演一条线的故事,而是让上万个点自己决定什么时候合成一个、什么时候散开。点聚合,我们下一篇见。
如果你也做过"演示型"的三维项目,欢迎留言说说你是怎么保证"同一个时间点,永远是同一个画面"的------是靠固定相机路径,还是干脆不让用户碰时间轴。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/skyline-presentation/ 目录下,四个文件(presentation.ts 配置、PresentationEngine.ts 引擎、SkylinePresentationDemo.vue 界面、index.ts 注册)加起来 932 行,复制进你的项目、把 Bing 影像换成自己的底图就能跑。
Cesium酱の百宝箱 · 第 57 篇
时间轴给得明明白白,三条轨道各走各的
2026 年的秋夜,与你共探"演一次就对一次"