第57篇:三维场景演示_基础版——同一秒,播过去和拖过去差出一千多米

上一篇结尾我说要换个思路:不再靠相机去"看",而是让引擎老老实实把一块地的地形数据交出来,一格一格地算。

那句先记在账上------空间分析-地形高度场不是一篇,是一条完整的支线(服务端采样、派生着色器、拾取深度反投影、五方案对比、自请瓦片光栅化、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() 里事件早于时间广播、回退时状态不清理、结算时会吃掉最后一帧、场景里还有一处没固定种子的随机。但它把"一场演示的时间轴是怎么被驱动的、又会在哪儿对不上"这件事讲清楚了,这就够了。

真要往汇报场景里用,我建议这么改:

  1. 把 checkEvents 挪到 emit(timeupdate) 之后 ,或者干脆让事件回调显式接收 seconds 参数,别让下游去猜"现在是几秒"。
  2. seek() 改成"状态重建":给定时刻,直接算该显示哪些图层、哪个弹窗、车流该在哪里,一次性写回;别依赖"重放已发生的事件"。
  3. 给每段动线一条显式的过渡曲线 :把 transition 真接上(用 HermiteSpline 分段,或在时间轴上重参数化),配置里写 easeIn 就真的慢起。
  4. restoreSceneOnEnd 加一个"停留 N 秒再回",或者干脆改成"回开场但不重置时间轴",别让最后一帧没有展示时间。
  5. Clock.tick 的墙钟差值加上限 (比如 Math.min(milliseconds, 100)),后台回来最多推进 0.1 秒,别一口气吃掉半场;同时给演示加个"最大倍率"提示。
  6. 随机场景固定种子,或者把生成结果固化成数据文件。
  7. 关键帧坐标做一次合法性检查:至少确认相机不在实体内部、道路半径在目标纬度下与名义值一致。

六、写在最后

演示这件事,做久了会有一个错觉:/ 以为自己在排一条"什么时候看哪里"的时间轴,/ 其实是在排三条互不认识的轨道------位置、朝向、事件,/ 它们各自都走得很准时,只是没走出同一个故事。

从 #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 年的秋夜,与你共探"演一次就对一次"

相关推荐
菜鸟~noob2333 小时前
【电子战】第31篇:3D AOA 定位——方位+俯仰联合
开发语言·matlab·3d
四季豆334 小时前
数据挖掘:从数据中获取知识
人工智能·数据挖掘
Martina_03214 小时前
AI生成3D模型怎么判断能不能继续编辑?用6步检查Mesh、材质、UV与导出文件
图像处理·人工智能·3d·ai·自然语言处理·uv·游戏策划
一招合理4 小时前
数据分析:业务理解比技巧更重要
数据挖掘·数据分析
xiaomuzuirenzhen4 小时前
2027银行管培求职:数学与应用数学如何连接业务指标、数据分析与决策
数据挖掘·数据分析
外收内放4 小时前
数据分析(DataFrame与series)
python·学习·数据分析
shujudang4 小时前
智能数据分析平台有哪些?五个主流平台的数据模型与选型对比
大数据·人工智能·数据挖掘·数据分析
LiuPig刘皮哥4 小时前
xiaoke.ai 小可爱技术路线图解:3D 陪玩、虚拟陪伴、透明桌宠
人工智能·3d·开源
派森诺生物8 小时前
Cell首篇棉花研究:3D基因组破解纤维品质密码
3d·cell·派森诺·棉花育种