Replaying real-time telemetry through a live rendering pipeline, without touching the components
Replaying real-time telemetry through a live rendering pipeline, without touching the components
在不改动组件的前提下,通过实时渲染流水线回放遥测数据
I have a set of React components that render live telemetry: an attitude indicator, a moving map, tapes and gauges, a scrolling event log. They take a data source, subscribe to it, and paint whatever numbers arrive. That works for a live feed. 我有一套用于渲染实时遥测数据的 React 组件:姿态指示器、动态地图、刻度尺和仪表盘,以及滚动事件日志。它们接收一个数据源,订阅它,并将收到的任何数值绘制出来。这对于实时数据流来说运行良好。
The obvious next thing you want is replay: load a recorded session, scrub a timeline, watch the same instruments play it back. The naive version of this is a trap, and it took me a wrong turn to see why. My first instinct was that replay is a data problem, load the samples, push them into the components in order, done. 接下来显而易见的需求就是回放:加载一段录制的会话,拖动时间轴,观察同样的仪表重现过程。天真的实现方式是一个陷阱,我走了一段弯路才明白原因。我的第一直觉是回放是一个数据问题:加载样本,按顺序推送到组件中,搞定。
It compiled, it ran, and the charts were empty. Not broken, not erroring. Empty. The instruments that show a single current value worked fine. The time-series charts sat blank while correct data flowed into them. That empty chart is the whole story of this post, because the reason it’s empty is the reason replay is more interesting than it looks. 代码编译通过并运行了,但图表是空的。没有崩溃,没有报错。就是空的。显示单一当前数值的仪表工作正常,而时间序列图表在正确的数据流入时却保持空白。这张空白的图表正是本文的核心,因为导致它空白的原因,正是回放功能比看起来更有趣的原因。
The components are watching a clock you forgot about. Here’s the data source interface these components consume. It’s small on purpose: 组件正在观察一个你忽略的时钟。以下是这些组件所使用的数据源接口。它被刻意设计得很简洁:
interface TelemetryValue {
timestamp: number; // wall-clock, unix ms
value: number;
channel?: string;
}
interface AltaraDataSource {
subscribe(callback: (value: TelemetryValue) => void): () => void;
getHistory(): TelemetryValue[];
readonly status: ConnectionStatus;
destroy(): void;
}
A live source stamps each sample with Date.now() as it arrives. A time-series chart, reasonably, assumes that’s what timestamps mean: it anchors its x-axis to Date.now() and draws a moving window of the last few seconds, discarding anything older than windowMs because that’s off the left edge of the view.
实时数据源在每个样本到达时都会打上 Date.now() 的时间戳。时间序列图表理所当然地认为这就是时间戳的含义:它将 X 轴锚定在 Date.now() 上,并绘制最近几秒的移动窗口,丢弃任何早于 windowMs 的数据,因为它们已经超出了视图的左边界。
Now replay a session recorded an hour ago. Every sample carries its original timestamp, an hour in the past. The chart buffers them correctly, then asks “is this within the last few seconds of now?”, the answer is no for every single sample, and it draws nothing. The data is all there. It’s just an hour to the left of the visible window, forever. 现在回放一个一小时前录制的会话。每个样本都带有其原始时间戳,即一小时前的时间。图表正确地缓冲了它们,然后询问:“这在当前时刻的最后几秒内吗?”对于每一个样本,答案都是否,于是它什么也不画。数据都在那里,只是永远位于可见窗口左侧一小时的位置。
The single-value instruments were fine precisely because they don’t care about a time axis; they show the latest number regardless of when it happened. The charts care, and they were right to. So replay isn’t a data problem. It’s a time problem. The recording lives in one timeline and the components live in another, and something has to translate. 单一数值仪表工作正常,恰恰是因为它们不关心时间轴;无论数据何时发生,它们只显示最新的数值。而图表关心时间,而且它们是对的。所以回放不是一个数据问题,而是一个时间问题。录制内容存在于一个时间轴中,而组件存在于另一个时间轴中,必须有某种机制进行转换。
Two clocks, kept deliberately apart
两个时钟,刻意保持分离
The design that falls out of this is to run two timebases at once and never confuse them. There’s recording time: milliseconds from the start of the session. Sample t: 0 is the first sample, t: 59903 is the last. This is the timeline the scrubber moves along, and it’s the only clock the transport UI ever touches. 由此得出的设计方案是同时运行两个时间基准,且绝不混淆它们。一个是录制时间:从会话开始计算的毫秒数。样本 t: 0 是第一个样本,t: 59903 是最后一个。这是进度条移动的时间轴,也是传输控制 UI 唯一接触的时钟。
When you drag to the middle of a one-minute recording, you’re at recording time 30000, and that number means the same thing every time regardless of when you press play. And there’s wall-clock time: what the components see on TelemetryValue.timestamp. This has to track real Date.now(), because that’s the assumption baked into every component.
当你拖动到一分钟录制的中间位置时,你处于录制时间 30000,无论你何时按下播放键,这个数字的含义始终不变。另一个是挂钟时间(Wall-clock time):即组件在 TelemetryValue.timestamp 中看到的时间。它必须追踪真实的 Date.now(),因为这是每个组件内置的假设。
The job of the replay source is to map one onto the other at the moment it emits. A sample sitting in the recording at t gets re-stamped, on its way out, to a wall-clock time computed from where the playhead is right now:
回放源的任务是在数据发出时,将两者进行映射。录制中位于 t 时刻的样本,在输出时会被重新打上时间戳,该时间戳是根据当前播放头的位置计算出的挂钟时间:
wallclock = anchorWall + (t - anchorT) / speed
anchorT is the recording time the playhead sat at when playback last started or changed, and anchorWall is the real clock reading at that same instant. So the recording’s timeline is pinned to the live clock at one point, and everything else is offset from that pin.
anchorT 是上次播放开始或改变时播放头所在的录制时间,而 anchorWall 是同一瞬间真实的挂钟读数。因此,录制的时间轴在某一点上被锚定到实时时钟上,其他所有时间点都相对于该锚点进行偏移。
Feed that to the chart and its Date.now() anchor now agrees with the samples: they land inside the visible window, and the chart paints exactly as it does live. The component didn’t change. It doesn’t know it’s being replayed. It’s watching wall-clock like it always did; the source just made the recording lie convincingly about what time it is.
将这些数据喂给图表,其 Date.now() 锚点现在就与样本一致了:它们落在了可见窗口内,图表的绘制效果与实时状态完全一样。组件没有改变,它不知道自己正在被回放。它像往常一样观察着挂钟时间;数据源只是让录制内容在时间上撒了一个令人信服的谎。
Speed control drops out of this for free. Dividing the offset by speed compresses or stretches recording time against wall-clock, so at 2x, two seconds of recording map to one second of wall-clock, and the chart scrolls twice as fast without knowing why. 速度控制功能可以免费获得。将偏移量除以速度,可以压缩或拉伸录制时间与挂钟时间的比例。因此在 2 倍速下,两秒的录制内容映射为一秒的挂钟时间,图表会在不知情的情况下以两倍速度滚动。
The transform is rebased on every play, pause, seek, and speed change, so each of those just re-pins the two clocks and playback continues from the new anchor rather than retroactively rewriting history. 这种转换在每次播放、暂停、跳转和速度改变时都会重新基准化,因此每一次操作都只是重新锚定两个时钟,播放从新的锚点继续,而不是追溯性地重写历史。
The inverse shows up when you pause. Pausing has to answer “where exactly is the playhead now?”, which is the wall-clock-to-recording direction of the same transform: 当你暂停时,逆向逻辑就会出现。暂停必须回答“播放头现在到底在哪里?”,这是同一转换的挂钟时间到录制时间的逆向过程:
playhead = anchorT + (now - anchorWall) * speed
Note the operations flip: emitting divides by speed, locating the playhead multiplies by it. They’re inverses, and mixing them up gives you a playhead that drifts off at the square of the speed, which is exactly the kind of bug that looks fine at 1x and falls apart the moment you test 2x. 注意操作是相反的:输出时除以速度,定位播放头时乘以速度。它们互为逆运算,如果搞混了,播放头就会以速度的平方产生漂移,这正是那种在 1 倍速下看起来正常,但一测试 2 倍速就会崩溃的典型 Bug。
The pause that quietly drifts
悄然漂移的暂停
One consequence of the two clocks is worth pulling out, because it’s the kind of thing that passes every quick test and then rots. getHistory() exists so a freshly mounted chart can seed itself with the recent past instead of starting blank. For replay it returns the window of recording time ending at the playhead, re-stamped to wall-clock like everything else.
两个时钟带来的一个后果值得一提,因为它属于那种能通过所有快速测试但随后会腐烂的问题。getHistory() 的存在是为了让新挂载的图表能用最近的过去数据进行初始化,而不是从空白开始。对于回放,它返回以播放头结束的录制时间窗口,并像其他数据一样重新打上挂钟时间戳。
But consider a paused replay. The playhead is frozen. Wall-clock is not. If the re-stamp anchor was set when you hit pause and you then leave it paused for thirty seconds, the transform keeps mapping the frozen playhead onto a wall-clock that’s marched thirty seconds forward, so the history window slides further into the past the longer you sit there. 但考虑一下暂停回放的情况。播放头冻结了,但挂钟时间没有。如果重新打戳的锚点是在你按下暂停时设置的,而你让它保持暂停三十秒,转换逻辑会继续将冻结的播放头映射到已经前进了三十秒的挂钟时间上,因此你停留的时间越长,历史窗口就越向过去滑动。
A chart that mounts during a long pause seeds itself with data that’s now off-screen, and you’re back to the empty chart, this time only sometimes, only after a pause, which is a much worse bug to find. The fix is that getHistory() re-anchors when paused before it computes the window, so “now” is always the moment you asked, not the moment you stopped:
一个在长时间暂停期间挂载的图表,会用现在已经移出屏幕的数据进行初始化,于是你又回到了空白图表的状态。这次只在特定情况下发生,且仅在暂停后出现,这是一种更难发现的 Bug。解决方法是让 getHistory() 在暂停时计算窗口前重新锚定,这样“现在”永远是你请求的那一刻,而不是你停止的那一刻:
getHistory():