Three.js 实时机器人动画:时间戳插值与缓冲
机器人 Pose、Joint、路径数据由后端按时间戳持续返回。前端不是收到一条就直接更新模型——那样画面只会一顿一顿地跳——而是:
- 为每个机器人维护一份独立的
RobotBuffer; - 用统一的
originTime把后端的绝对时间戳换算成相对动画时间; - 缓存的数量和时间跨度达到阈值后,再建立前端的
playTime开始播放; - 渲染时按当前播放时间,在时间戳 Buffer 里找到前后两个采样点,算出插值比例;
- 位置用
Lerp、姿态用Quaternion SLERP、关节值用线性插值; - 最后把算出的
position、quaternion、jointValues写回URDFRobot和URDFJoint。
这样做把后端数据频率和 Three.js 渲染频率解耦,同时抵抗一定的网络抖动,让机器人运动连续、平滑。
这篇按这个链路走一遍,核心代码都贴出来。

1. 先说问题:为什么不能收到就更新
先说清两个频率对不上在哪:
| 数据 | 频率 | 说明 |
|---|---|---|
位姿 pose |
约 1 Hz | 每包只有 1 帧 |
关节 joint_values |
约 10 Hz | 每包带一串多帧 |
| 渲染循环 | 60 fps | 这里节流到 16 fps |
做法一:收到就赋值。
1 | socket.onmessage = (data) => { |
画面在数据到达那一瞬间跳到新位置,然后冻住,下一秒再跳。位姿一秒一条,画面上就是每秒跳一下——这叫幻灯片,不叫动画。
做法二:定时去读最新数据。
好一点,但”最新数据”这个取法有个陷阱:它等于假设数据已经到位了。于是两个问题绕不开:
- 没得插。 算
t时刻的值,需要t之后的那个采样点。而t紧贴最新数据,后面什么都没有,插值退化成”显示原始采样值”,又回到跳变。 - 抖动直接传导。 网络一慢,最新数据停在原地,画面也跟着停住等。
根因有两条,而且是连着的:
- 离散点之间缺”中间态”——要用插值补;
- 这些点的到达时间不确定——要用缓冲挡。
插值需要 t 前后各一个点,所以播放时间必须落后于数据。落后出来的这段距离,就是缓冲。缓冲不是给插值找数据,是给插值留余量——数据在手、时间在后面追,这个”追”的距离决定了能吸收多大的抖动。
2. 数据层:每个机器人一份 RobotBuffer
后端返回的数据长这样:
1 | type RobotBufferDataType = { |
注意两件事:位姿每包只有 1 帧,关节每包是一串多帧——它们天然就是两条长度不同的时间轴。路径则是一串点,没有自己的时间序列,跟着位姿走。
前端为每个 device_number 建一份 RobotBuffer,里面三条缓冲各配一条时间轴:
1 | class RobotBuffer { |
分工是清楚的:RobotBufferManager 管多个机器人、对账、维护公共时间轴;RobotBuffer 只管一个机器人的数据和插值;RobotArea 只管场景。 数据层不 import 任何 URDF 对象,插值逻辑因此可以单独测——给它一串时间戳和一个时间,输出一组数值,不需要 Three.js 对象在场。这就是”数据层与渲染层解耦”。
3. 统一时间轴:originTime
后端给的是绝对时间戳(epoch 毫秒),播放要的是”动画播了多久”。这是两把尺子,得先对齐。
取所有数据里最早的那个时间戳当零点:
1 | // 取所有时间戳的最小值作为时间零点 |
为什么是全局最小值,而不是”第一批数据的时间”?因为数据来自多条流——位姿一路、关节一路,还有多台机器人,它们的首帧时间戳不一定对齐。取最小值能保证相对时间非负,之后所有比较就只需要判断”谁大谁小”,不用考虑符号。
换算很直接,两处都要减:第一包初始化时减,后续每包追加时也减。
1 | // 第一包:绝对时间戳 → 相对时间 |
归一化之后,缓冲区里存的就不再是”什么时候采到的”,而是”这段动画里的第几毫秒”——和后面播放时钟用的是同一把尺。
4. 延迟播放:攒够了再定 playTime
数据不是来一条播一条,是先攒。判断有两个条件:
1 | bufferDuration = 5000 // 最短缓冲时长 |
这两个条件是不同维度的保险,缺一不可:
- 条数管插值本身。算
t时刻至少要一前一后两个点,2 条是理论下限、没有余量,下一包稍微晚一点就断粮;4 条留了容错。 - 跨度管能撑多久。只看条数不够——断网重连后后端补发一批,4 条挤在 200 ms 里进来,条数够了,但一眨眼就播完,接着又卡住。
所以条数保证当下能插,跨度保证接下来一段能插。判断用的是严格大于,刚好 5000 ms 不算通过。
还有一条容易忽略:这个判断对所有机器人取”与”。
1 | isBufferOk() { |
任何一台没攒够,整场都不开播。看起来不合理——新上线一台机器人,凭什么把跑得好好的其他机器人一起卡住?因为所有机器人共用同一条播放时间轴。如果让攒够的先播、没攒够的后播,它们的零点就不同,画面上两台机器人的”当前时刻”会不一致:明明在同一个地图里协同作业,一台显示 3 秒前的位置、一台显示 1 秒前的位置——那个才是真错了。宁可整体等一下,也不能让时间轴分裂。
攒够之后才建立播放零点:
1 | update() { |
播放零点定下之后不跟着数据走——数据来了不重置,数据断了也不重置。这正是缓冲的意义:画面时间轴和数据到达时间彻底脱钩。
- 数据慢了,画面按自己的节奏继续走,用手里已有的点插值;
- 数据断久了,时间轴走到缓冲区末尾就停住——后面没有点了,插不出来——等数据补上再继续。
结果就是画面时间轴单调、匀速,不会因为网络抖动而倒退或跳跃。抖动还在,只是被挡在这一层外面了。
5. 前后帧查找:定位和插值分开
每一帧要做的事是:给定播放时间 time 和一条递增的时间戳数组,找到 time 夹在哪两个采样点之间,算出比例。
1 | 时间戳: [0, 1000, 2000, 3000] |
先找左边那一帧:
1 | // 找当前时间的前一帧下标 |
从前往后扫,记住最后一个”被 time 追上”的位置,一旦追上不了就停。数据量级是几条到几十条,O(n) 完全够,不值得上二分。
拿到 i1 之后,i2 = i1 + 1,比例就是一行:
1 | // 只做时间定位,不插值 |
insert 范围是 [0, 1):0 表示正好压在前一帧上,越接近 1 越靠近后一帧,理论上到不了 1——真到 1 就该用下一对采样点了。
这里最值得说的是接口设计:定位和插值被拆开了。 tween() 只回答”在哪两帧之间、比例多少”,通过回调把 (insert, i1, i2) 交出去,自己不碰任何数据。为什么?因为同一套定位要服务三种插法:
| 数据 | 几何对象 | 插法 |
|---|---|---|
| 位置 | 平面上的点 | Lerp 直线插值 |
| 姿态 | 四维单位球面上的点 | SLERP 球面插值 |
| 关节值 | 标量 | 线性插值 |
| 路径 | 一串点 | 不插值,整体替换 |
如果把插值塞进 tween(),它就得知道每种数据的格式和语义,从一个纯时间函数变成一个大杂烩。回调让它只依赖”一个递增的时间戳数组”这一件事——它甚至不需要知道那是什么数据。定位函数管时间轴,调用方管怎么算。
6. 三种插值:Lerp / SLERP / 线性
因为 tween() 只给比例,外面就按数据类型的几何意义各挑各的算法:
1 | update(time: number) { |
三个点值得展开。
位置上用 Vector2 而不是 Vector3。 位姿虽然带了 7 个数,但地面机器人只在地面上跑——z 恒为 0,朝向也只有绕 Z 的一个自由度,所以位置只要 x、y。这不是省事,是数据本身就只有两个自由度。
姿态为什么必须 SLERP,不能直接 Lerp。
四元数不是四维空间里的普通向量,它是四维单位球面上的一个点。这条球面还有个特点:q 和 −q 表示同一个旋转(双覆盖),所以两个四元数之间的”距离”是球面上的弧长,不是直线距离。于是:
- Lerp 是从球面外穿过去的。 两个球面上的点连直线,直线从球内部穿过,中间结果长度小于 1,不是合法旋转,必须再归一化。
- 归一化之后不是不能用,是角速度不匀。 中间段被压缩得最多,归一化时”补”回来的角度也最多,表现为旋转到中段时快一下。挂在机器人上,就是关节摆到一半明显”抢了一下”。
- SLERP 沿大圆走,把两个旋转之间的夹角线性等分,角速度恒定,视觉上才是匀速转。
公式是 slerp(q1, q2, α) = sin((1−α)θ)/sinθ · q1 + sin(αθ)/sinθ · q2。注意分母里的 sinθ:θ 趋近 0(两次旋转几乎相同)时会变成 0/0,所以任何 SLERP 实现都要处理这个退化情况——θ 足够小时直接退回 Lerp。Three.js 里还能看到另一件事:点积为负时先把其中一个四元数取反,否则会沿大圆绕远路——明明转 10° 就够,结果转了 350°。
关节值就是标量,线性插值 v1 + (v2 - v1) * insert 就是它本来的语义,没有争议。
至于路径:它是”前方一段的预览线”,本身就是一串点,两批数据之间的路径不是同一个几何对象(点数可能不同、起点可能不同),逐个点插没有意义。所以直接整体替换,而且取后一条——它画的是”车前方”,越新的越贴近真实位置。
一句话收尾:几何对象决定插值算法。 这也正是必须把定位和插值拆开的原因。
7. 写回模型:真正改 Three.js 状态的地方
前面几节算出来的全是数值:position、quaternion、jointValues、pathPoints。数据层从头到尾没碰过任何一个 Three.js 对象——真正改 Three.js 状态的就是这里。RobotArea 订阅了管理器派出来的 updateRobots 事件:
1 | updateRobots(robotBufferMap: Map<string, RobotBuffer>) { |
三个动作,三种改法,值得分开看:
- 位姿:
robot.position是URDFRobot根节点的位移,只取插值出来的x、y;robot.quaternion整个copy过去。根节点一动,整条运动链下面的关节再各自叠加自己的变换。 - 关节:数据里带的是关节名字,模型上是对象引用,中间靠
jointMap对上——jointNames和jointValues一一对应,setValue()把角度写到URDFJoint上,子 link 跟着动。这张映射表是前面几篇里反复出现的那张,在这里派上用场。 - 路径:路径是一条
Line,顶点每帧都在变,所以换新的position属性之前先path.geometry.dispose(),把旧数据占的 GPU 缓冲释放掉,不然显存只增不减。
分界线很清楚:RobotBuffer 只碰数据,不碰场景;RobotArea 才碰场景。 中间靠”机器人名字”这一根线对上——RobotBuffer 的 key 是 device_number,场景里 robot.name 和 path.name 也是同一个 device_number。
8. 整条链路怎么转起来
前面几节讲的是”一帧里算什么”,这一节讲这些帧由谁驱动、数据从哪来、用完的怎么扔。
数据:异步拉取 + 对账
数据是异步来的,一次拉一小段,再用一个延时器把请求节奏卡在每秒一次:
1 | minDuration = 1000 // 连续请求的最短间隔 |
四个集合,各代表一个不同的时间切片:
| 集合 | 含义 |
|---|---|
robotDataList |
这一次后端返回的机器人 |
robotBufferMap |
前端当前已经管理的机器人 |
newRobotDataList |
这一次新出现的机器人 |
deviceNumberList |
遍历划掉之后,剩下的是已经消失的机器人 |
对账的结果只有三件事,第一条最重要:已存在的机器人不重建,直接把新数据追加进它已有的 Buffer。 位姿是连续流,”这一包又出现了 robot1”的意思不是新建,而是接着往下补时间轴。
另外两条是新增和删除。这里有个顺序讲究:先把数据层的 RobotBuffer 建出来,再派事件通知场景层去建模型——反过来做的话,模型会先出现而没有数据可播。
还有一处细节:请求间隔是启动间隔,不是”等请求完成后再等 1 秒”。这次请求花了 200 ms,就只再等 800 ms。这样数据的到达节奏才稳定,不会被请求耗时拖成忽快忽慢。
注意 initOriginTime 只在第一包时调一次——时间零点定下就不再变,后面所有数据的相对时间都以它为基准。
渲染:16 fps,一个时钟源
数据每秒十来条,渲染每秒 60 次——插值出来的相邻两帧之间只差几毫秒的运动量,肉眼分辨不出,5/6 的帧是白算的。所以渲染被节流到 16 fps:
1 | // 动画更新频率:16 帧/s |
requestAnimationFrame 仍按屏幕刷新率(约 60 Hz)每帧调用 continuousRender,但里面那层把关让 update() 和 render() 每 62.5 ms 才真跑一次。
这层把关用的 TimeController 本身就是一个”每帧被问一次”的轮询器,不是定时器:
1 | class TimeController { |
用轮询而不是 setInterval,好处是整个系统只有一个时钟源:拉数据、更新动画、清理过期数据,全挂在同一个 requestAnimationFrame 循环上。用 setInterval 的话,定时器和渲染帧各走各的时钟,偶尔会撞在一起(同一帧里更新两次,或者两次渲染之间夹了两次数据更新),标签页切到后台还会继续跑。
渲染频率应该匹配数据的信息量,不是越高越好。
清理:滑动窗口
缓冲区只进不出就是内存泄漏。位姿每秒一条、关节每秒十来条,几台机器人跑一小时,数字不小。
能安全删的依据是时间轴单调向前:定位找的是 i1 ≤ time < i2,而 time 只会越来越大,所以 i1 之前的数据永远不会再被用到,可以整段丢掉,只保留 i1 本身——它是左端点,下一帧还要用。
1 | clearExpiredData(time: number) { |
一个容易写错的地方:位姿和关节是两条独立的时间轴(一个每秒一两条,一个每秒十来条)。清理时必须各用各的时间戳数组分别定位左边界——拿一个数组算出来的下标去切另一个数组,同一个下标在两条轴上指向的是完全不同的时刻,切多了丢数据,切少了漏删。
清理不用每帧做,隔一秒扫一次就够:窗口边界是一秒一秒往前走的,中间那些帧没有新东西可删。
切到后台时重置
页面藏起来时,这一层直接清空所有缓冲和时间零点;回到前台再重新拉数据、重新攒。
因为播放时间轴按真实时间走,在后台待 30 秒,time 就往前跑了 30 秒,而缓冲区里的数据早就被清理过——时间轴跑到末尾就再也追不上,回到前台只会看到画面僵在原地。与其去补这段空档,不如直接重来。
9. 缓冲的代价:延迟
延迟从哪来?播放时钟的零点比数据轴的零点晚了多少。
数据轴零点在收到第一包时就定了,而播放零点要等缓冲攒够才能定——攒够至少要 5 秒。所以从机器人真实动作发生,到画面上开始跟着动,中间至少压了 5 秒。
这个组合对”演示缓冲机制、保证动画稳定、类似回放”是合适的,但它不像低延迟实时监控该有的参数。真实项目会按业务调这组值,常见的能压到几百毫秒量级——具体多少没有统一标准,取决于网络状况、数据频率和业务对延迟的容忍度。
取舍关系可以写成一句话:
1 | 能吸收的网络抖动 ≤ 缓冲深度 |
缓冲深度每增加一点,抗抖动能力强一点,延迟也大一点。 这是没有最优解、只有取舍的位置。
还要澄清一句:加缓冲只是缓解,不是消灭。 WebSocket 不保证数据到达时间均匀——理论上每 100 ms 一条,实际可能忽快忽慢,甚至断一下再补一批。缓冲只是把短时波动吸收掉,不至于立刻变成画面上一顿。
参考
- Three.js
Quaternion.slerpQuaternions:https://threejs.org/docs/#api/en/math/Quaternion - Three.js
Vector2.lerpVectors:https://threejs.org/docs/#api/en/math/Vector2 - Three.js
BufferAttribute(更新路径几何用):https://threejs.org/docs/#api/en/core/BufferAttribute - Three.js
EventDispatcher(事件传递用):https://threejs.org/docs/#api/en/core/EventDispatcher - 上一篇:Three.js 如何显示坐标系、碰撞体和质心 —— https://wywzixin.github.io/threejs/urdf-helpers/
Three.js 实时机器人动画:时间戳插值与缓冲