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
2
3
4
socket.onmessage = (data) => {
robot.position.set(data.x, data.y, 0)
joint.setValue(data.value)
}

画面在数据到达那一瞬间跳到新位置,然后冻住,下一秒再跳。位姿一秒一条,画面上就是每秒跳一下——这叫幻灯片,不叫动画。

做法二:定时去读最新数据。

好一点,但”最新数据”这个取法有个陷阱:它等于假设数据已经到位了。于是两个问题绕不开:

  • 没得插。 算 t 时刻的值,需要 t 之后的那个采样点。而 t 紧贴最新数据,后面什么都没有,插值退化成”显示原始采样值”,又回到跳变。
  • 抖动直接传导。 网络一慢,最新数据停在原地,画面也跟着停住等。

根因有两条,而且是连着的:

  1. 离散点之间缺”中间态”——要用插值补;
  2. 这些点的到达时间不确定——要用缓冲挡。

插值需要 t 前后各一个点,所以播放时间必须落后于数据。落后出来的这段距离,就是缓冲。缓冲不是给插值找数据,是给插值留余量——数据在手、时间在后面追,这个”追”的距离决定了能吸收多大的抖动。

2. 数据层:每个机器人一份 RobotBuffer

后端返回的数据长这样:

1
2
3
4
5
6
7
8
9
10
type RobotBufferDataType = {
device_number: string // 机器人代号,唯一
device_type: string // 机器人类型,决定用哪套 URDF
pose_timestamp: number // 位姿时间戳
pose: [number, number, number, number, number, number, number] // [x,y,z,qx,qy,qz,qw]
path_points: number[] // 前方预瞄路径点,扁平 xyz 排列
joint_names: string[] // 关节名,决定顺序
joint_timestamp: number[] // 每个关节帧的时间戳
joint_values: number[][] // 关节帧,每帧一组
}

注意两件事:位姿每包只有 1 帧,关节每包是一串多帧——它们天然就是两条长度不同的时间轴。路径则是一串点,没有自己的时间序列,跟着位姿走。

前端为每个 device_number 建一份 RobotBuffer,里面三条缓冲各配一条时间轴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class RobotBuffer {
parent: RobotBufferManager
// 缓冲队列
poseTimestampBuffer: number[] = []
poseBuffer: number[][] = []
pathPointsBuffer: number[][] = []
jointTimestampBuffer: number[] = []
jointNames: string[] = []
jointValuesBuffer: number[][] = []
// 本帧插值结果
position = new Vector2()
quaternion = new Quaternion()
jointValues: number[] = []
pathPoints: number[] = []
}

分工是清楚的:RobotBufferManager 管多个机器人、对账、维护公共时间轴;RobotBuffer 只管一个机器人的数据和插值;RobotArea 只管场景。 数据层不 import 任何 URDF 对象,插值逻辑因此可以单独测——给它一串时间戳和一个时间,输出一组数值,不需要 Three.js 对象在场。这就是”数据层与渲染层解耦”。

3. 统一时间轴:originTime

后端给的是绝对时间戳(epoch 毫秒),播放要的是”动画播了多久”。这是两把尺子,得先对齐。

取所有数据里最早的那个时间戳当零点:

1
2
3
4
5
6
7
8
9
10
11
// 取所有时间戳的最小值作为时间零点
initOriginTime(robotsData: RobotBufferDataType[]) {
const times = robotsData.map(({ pose_timestamp, joint_timestamp }) => {
if (joint_timestamp && joint_timestamp.length) {
return Math.min(pose_timestamp, joint_timestamp[0] || 0)
} else {
return pose_timestamp
}
})
this.originTime = Math.min(...times)
}

为什么是全局最小值,而不是”第一批数据的时间”?因为数据来自多条流——位姿一路、关节一路,还有多台机器人,它们的首帧时间戳不一定对齐。取最小值能保证相对时间非负,之后所有比较就只需要判断”谁大谁小”,不用考虑符号。

换算很直接,两处都要减:第一包初始化时减,后续每包追加时也减。

1
2
3
4
5
6
7
// 第一包:绝对时间戳 → 相对时间
this.poseTimestampBuffer = [pose_timestamp - originTime]
this.jointTimestampBuffer = joint_timestamp.map((t) => t - originTime)

// 后续每包追加,同样换算
poseTimestampBuffer.push(pose_timestamp - originTime)
jointTimestampBuffer.push(...joint_timestamp.map((t) => t - originTime))

归一化之后,缓冲区里存的就不再是”什么时候采到的”,而是”这段动画里的第几毫秒”——和后面播放时钟用的是同一把尺。

4. 延迟播放:攒够了再定 playTime

数据不是来一条播一条,是先攒。判断有两个条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bufferDuration = 5000   // 最短缓冲时长
minBufferCount = 4 // 最少缓冲数据量

updateBufferState() {
const bufferCount = this.poseTimestampBuffer.length
// 数量不够
if (bufferCount < this.parent.minBufferCount) {
this.bufferOK = false
return
}
// 再看首尾时间跨度
const time1 = this.poseTimestampBuffer[0]
const time2 = this.poseTimestampBuffer[bufferCount - 1]
this.bufferOK = time2 - time1 > this.parent.bufferDuration
}

这两个条件是不同维度的保险,缺一不可:

  • 条数管插值本身。算 t 时刻至少要一前一后两个点,2 条是理论下限、没有余量,下一包稍微晚一点就断粮;4 条留了容错。
  • 跨度管能撑多久。只看条数不够——断网重连后后端补发一批,4 条挤在 200 ms 里进来,条数够了,但一眨眼就播完,接着又卡住。

所以条数保证当下能插,跨度保证接下来一段能插。判断用的是严格大于,刚好 5000 ms 不算通过。

还有一条容易忽略:这个判断对所有机器人取”与”。

1
2
3
4
5
6
7
8
isBufferOk() {
if (this.originTime == undefined || !this.robotBufferMap.size) return false
// 所有机器人缓冲都要有效
for (let robotBuffer of this.robotBufferMap.values()) {
if (!robotBuffer.bufferOK) return false
}
return true
}

任何一台没攒够,整场都不开播。看起来不合理——新上线一台机器人,凭什么把跑得好好的其他机器人一起卡住?因为所有机器人共用同一条播放时间轴。如果让攒够的先播、没攒够的后播,它们的零点就不同,画面上两台机器人的”当前时刻”会不一致:明明在同一个地图里协同作业,一台显示 3 秒前的位置、一台显示 1 秒前的位置——那个才是真错了。宁可整体等一下,也不能让时间轴分裂。

攒够之后才建立播放零点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
update() {
// 1. 到点就拉下一包数据
this.delayFetchController.run(() => this.delayFetchBufferData())

// 2. 缓冲没攒够,这一帧不动
if (!this.isBufferOk()) return

// 3. 攒够了,第一次进来记下播放零点
if (this.playTime == undefined) {
this.playTime = Date.now()
}

// 4. 相对时间,和缓冲数据同一把尺
const time = Date.now() - this.playTime

// 5. 每个机器人各自插值
this.robotBufferMap.forEach((b) => b.update(time))

// 6. 通知渲染层
this.dispatchEvent({ type: 'updateRobots', robotBufferMap: this.robotBufferMap })

// 7. 每隔一秒清理一次过期数据
clearExpiredController.run(() => {
this.robotBufferMap.forEach((b) => b.clearExpiredData(time))
})
}

播放零点定下之后不跟着数据走——数据来了不重置,数据断了也不重置。这正是缓冲的意义:画面时间轴和数据到达时间彻底脱钩。

  • 数据慢了,画面按自己的节奏继续走,用手里已有的点插值;
  • 数据断久了,时间轴走到缓冲区末尾就停住——后面没有点了,插不出来——等数据补上再继续。

结果就是画面时间轴单调、匀速,不会因为网络抖动而倒退或跳跃。抖动还在,只是被挡在这一层外面了。

5. 前后帧查找:定位和插值分开

每一帧要做的事是:给定播放时间 time 和一条递增的时间戳数组,找到 time 夹在哪两个采样点之间,算出比例。

1
2
3
4
时间戳:  [0,    1000,  2000,  3000]
当前时间: 1500
↑
夹在 1000 和 2000 之间,比例 0.5

先找左边那一帧:

1
2
3
4
5
6
7
8
9
10
11
12
// 找当前时间的前一帧下标
preFrameIdx(time: number, timestampBuffer: number[], startIdx = 0) {
let timeIdx: number | undefined
for (let i = startIdx, len = timestampBuffer.length; i < len; i++) {
if (time > (timestampBuffer[i] || 0)) {
timeIdx = i
} else {
break
}
}
return timeIdx
}

从前往后扫,记住最后一个”被 time 追上”的位置,一旦追上不了就停。数据量级是几条到几十条,O(n) 完全够,不值得上二分。

拿到 i1 之后,i2 = i1 + 1,比例就是一行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 只做时间定位,不插值
tween(time, timestampBuffer, callback = (insert, i1, i2) => {}) {
const i1 = this.preFrameIdx(time, timestampBuffer)
if (i1 != undefined) {
const i2 = i1 + 1
const t1 = timestampBuffer[i1]
const t2 = timestampBuffer[i2]
// 后一帧可能还没到(时间轴走到缓冲末尾)
if (t1 !== undefined && t2 !== undefined) {
const insert = (time - t1) / (t2 - t1)
callback(insert, i1, i2)
}
}
}

insert 范围是 [0, 1):0 表示正好压在前一帧上,越接近 1 越靠近后一帧,理论上到不了 1——真到 1 就该用下一对采样点了。

这里最值得说的是接口设计:定位和插值被拆开了。 tween() 只回答”在哪两帧之间、比例多少”,通过回调把 (insert, i1, i2) 交出去,自己不碰任何数据。为什么?因为同一套定位要服务三种插法:

数据 几何对象 插法
位置 平面上的点 Lerp 直线插值
姿态 四维单位球面上的点 SLERP 球面插值
关节值 标量 线性插值
路径 一串点 不插值,整体替换

如果把插值塞进 tween(),它就得知道每种数据的格式和语义,从一个纯时间函数变成一个大杂烩。回调让它只依赖”一个递增的时间戳数组”这一件事——它甚至不需要知道那是什么数据。定位函数管时间轴,调用方管怎么算。

6. 三种插值:Lerp / SLERP / 线性

因为 tween() 只给比例,外面就按数据类型的几何意义各挑各的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
update(time: number) {
// 位姿:位置 Lerp + 姿态 SLERP
this.tween(time, this.poseTimestampBuffer, (insert, i1, i2) => {
const pose1 = this.poseBuffer[i1]
const pose2 = this.poseBuffer[i2]
if (pose1 && pose2) {
this.position.lerpVectors(
new Vector2(pose1[0], pose1[1]),
new Vector2(pose2[0], pose2[1]),
insert,
)
this.quaternion.slerpQuaternions(
new Quaternion(pose1[3], pose1[4], pose1[5], pose1[6]).normalize(),
new Quaternion(pose2[3], pose2[4], pose2[5], pose2[6]).normalize(),
insert,
)
}
// 路径不插值,直接换成这一段的“后一条”
this.pathPoints = this.pathPointsBuffer[i2] || []
})

// 关节:标量线性插值
this.tween(time, this.jointTimestampBuffer, (insert, i1, i2) => {
const v1Arr = this.jointValuesBuffer[i1]
const v2Arr = this.jointValuesBuffer[i2]
if (v1Arr && v2Arr) {
this.jointValues = this.jointNames.map((_, i) => {
const v1 = v1Arr[i]
const v2 = v2Arr[i]
return v1 + (v2 - v1) * insert
})
}
})
}

三个点值得展开。

位置上用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
updateRobots(robotBufferMap: Map<string, RobotBuffer>) {
const { robots, paths } = this
// 更新机器人位姿和关节
;(robots.children as URDFRobot[]).forEach((robot) => {
const {
name,
userData: { jointMap },
} = robot
const robotBuffer = robotBufferMap.get(name)
if (!robotBuffer) {
return
}
const { position, quaternion, jointValues, jointNames } = robotBuffer
// 更新位姿
robot.position.x = position.x
robot.position.y = position.y
robot.quaternion.copy(quaternion)
// 更新关节
jointNames.forEach((jointName, jointInd) => {
const jointValue = jointValues[jointInd]
jointValue && jointMap.get(jointName)?.setValue(jointValue)
})
})
// 更新路径
;(paths.children as Line[]).forEach((path) => {
const robotBuffer = robotBufferMap.get(path.name)
if (!robotBuffer) {
return
}
path.geometry.dispose()
path.geometry.setAttribute(
'position',
new BufferAttribute(new Float32Array(robotBuffer.pathPoints), 3),
)
})
}

三个动作,三种改法,值得分开看:

  • 位姿: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
minDuration = 1000 // 连续请求的最短间隔

async delayFetchBufferData() {
const { robotBufferMap, minDuration, delayFetchController } = this
const fetchTime = Date.now()
// 异步:拉下一段数据
const robotDataList = await fetchPoseList()

// 两次请求的“启动时刻”之间至少隔 minDuration,扣掉这次请求本身的耗时
const timeDiff = Date.now() - fetchTime
delayFetchController.setDelayTime(Math.max(0, minDuration - timeDiff))
if (!robotDataList || !robotDataList.length) return

// 第一包数据到了,定下时间零点
if (this.originTime == undefined) {
this.initOriginTime(robotDataList)
}

// 现有机器人代号。遍历时把“这包还在”的逐个划掉,剩下的自然就是消失的
const deviceNumberList = [...robotBufferMap.keys()]
// 这包新出现的机器人
const newRobotDataList: RobotBufferDataType[] = []

robotDataList.forEach((robotData) => {
const { device_number } = robotData
const robotBuffer = robotBufferMap.get(device_number)
if (robotBuffer) {
// 已存在:只追加数据,不重建
robotBuffer.addData(robotData)
deviceNumberList.splice(deviceNumberList.indexOf(device_number), 1)
} else {
// 新出现:数据层先建 RobotBuffer
const robotBuffer = new RobotBuffer(robotData, this)
this.robotBufferMap.set(device_number, robotBuffer)
newRobotDataList.push(robotData)
}
})

// 没被划掉的,说明这一包已经不返回了 → 销毁缓冲
deviceNumberList.forEach((device_number) => robotBufferMap.delete(device_number))

// 再通知场景层:新增 / 移除
newRobotDataList.length &&
this.dispatchEvent({ type: 'hasNewRobots', robotDataList: newRobotDataList })
deviceNumberList.length &&
this.dispatchEvent({ type: 'hasOldRobots', deviceNumberList })
}

四个集合,各代表一个不同的时间切片:

集合 含义
robotDataList 这一次后端返回的机器人
robotBufferMap 前端当前已经管理的机器人
newRobotDataList 这一次新出现的机器人
deviceNumberList 遍历划掉之后,剩下的是已经消失的机器人

对账的结果只有三件事,第一条最重要:已存在的机器人不重建,直接把新数据追加进它已有的 Buffer。 位姿是连续流,”这一包又出现了 robot1”的意思不是新建,而是接着往下补时间轴。

另外两条是新增和删除。这里有个顺序讲究:先把数据层的 RobotBuffer 建出来,再派事件通知场景层去建模型——反过来做的话,模型会先出现而没有数据可播。

还有一处细节:请求间隔是启动间隔,不是”等请求完成后再等 1 秒”。这次请求花了 200 ms,就只再等 800 ms。这样数据的到达节奏才稳定,不会被请求耗时拖成忽快忽慢。

注意 initOriginTime 只在第一包时调一次——时间零点定下就不再变,后面所有数据的相对时间都以它为基准。

渲染:16 fps,一个时钟源

数据每秒十来条,渲染每秒 60 次——插值出来的相邻两帧之间只差几毫秒的运动量,肉眼分辨不出,5/6 的帧是白算的。所以渲染被节流到 16 fps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 动画更新频率:16 帧/s
timer16f = new TimeController(1000 / 16)

// 连续渲染
continuousRender() {
const { renderer, scene, camera, timer16f, robotBufferManager } = this
// 每帧都问一次“够 62.5ms 了吗”,够了才真正更新动画并渲染
timer16f.run(() => {
// 更新机器人动画:拉数据、插值、清理,都在这一步里
robotBufferManager.update()
// 渲染
renderer.render(scene, camera)
})
this.continuousFrame = requestAnimationFrame(this.continuousRender.bind(this))
}

requestAnimationFrame 仍按屏幕刷新率(约 60 Hz)每帧调用 continuousRender,但里面那层把关让 update() 和 render() 每 62.5 ms 才真跑一次。

这层把关用的 TimeController 本身就是一个”每帧被问一次”的轮询器,不是定时器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TimeController {
duration: number
startTime: number = Date.now()
constructor(duration: number = 40) {
this.duration = duration
}
run(fn: () => void) {
const now = Date.now()
// 距上次执行够久了才执行,并把起点挪到当下
if (now - this.startTime >= this.duration) {
fn()
this.startTime = now
}
}
}

用轮询而不是 setInterval,好处是整个系统只有一个时钟源:拉数据、更新动画、清理过期数据,全挂在同一个 requestAnimationFrame 循环上。用 setInterval 的话,定时器和渲染帧各走各的时钟,偶尔会撞在一起(同一帧里更新两次,或者两次渲染之间夹了两次数据更新),标签页切到后台还会继续跑。

渲染频率应该匹配数据的信息量,不是越高越好。

清理:滑动窗口

缓冲区只进不出就是内存泄漏。位姿每秒一条、关节每秒十来条,几台机器人跑一小时,数字不小。

能安全删的依据是时间轴单调向前:定位找的是 i1 ≤ time < i2,而 time 只会越来越大,所以 i1 之前的数据永远不会再被用到,可以整段丢掉,只保留 i1 本身——它是左端点,下一帧还要用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
clearExpiredData(time: number) {
// 位姿:保留 i1,删掉它之前的
const idx = this.preFrameIdx(time, this.poseTimestampBuffer, 1)
if (idx !== undefined) {
this.poseTimestampBuffer.splice(0, idx)
this.poseBuffer.splice(0, idx)
this.pathPointsBuffer.splice(0, idx)
}
// 关节是另一条时间轴,要用自己的时间戳数组单独定位
const jointIdx = this.preFrameIdx(time, this.jointTimestampBuffer, 1)
if (jointIdx !== undefined) {
this.jointTimestampBuffer.splice(0, jointIdx)
this.jointValuesBuffer.splice(0, jointIdx)
}
}

一个容易写错的地方:位姿和关节是两条独立的时间轴(一个每秒一两条,一个每秒十来条)。清理时必须各用各的时间戳数组分别定位左边界——拿一个数组算出来的下标去切另一个数组,同一个下标在两条轴上指向的是完全不同的时刻,切多了丢数据,切少了漏删。

清理不用每帧做,隔一秒扫一次就够:窗口边界是一秒一秒往前走的,中间那些帧没有新东西可删。

切到后台时重置

页面藏起来时,这一层直接清空所有缓冲和时间零点;回到前台再重新拉数据、重新攒。

因为播放时间轴按真实时间走,在后台待 30 秒,time 就往前跑了 30 秒,而缓冲区里的数据早就被清理过——时间轴跑到末尾就再也追不上,回到前台只会看到画面僵在原地。与其去补这段空档,不如直接重来。

9. 缓冲的代价:延迟

延迟从哪来?播放时钟的零点比数据轴的零点晚了多少。

数据轴零点在收到第一包时就定了,而播放零点要等缓冲攒够才能定——攒够至少要 5 秒。所以从机器人真实动作发生,到画面上开始跟着动,中间至少压了 5 秒。

这个组合对”演示缓冲机制、保证动画稳定、类似回放”是合适的,但它不像低延迟实时监控该有的参数。真实项目会按业务调这组值,常见的能压到几百毫秒量级——具体多少没有统一标准,取决于网络状况、数据频率和业务对延迟的容忍度。

取舍关系可以写成一句话:

1
能吸收的网络抖动  ≤  缓冲深度

缓冲深度每增加一点,抗抖动能力强一点,延迟也大一点。 这是没有最优解、只有取舍的位置。

还要澄清一句:加缓冲只是缓解,不是消灭。 WebSocket 不保证数据到达时间均匀——理论上每 100 ms 一条,实际可能忽快忽慢,甚至断一下再补一批。缓冲只是把短时波动吸收掉,不至于立刻变成画面上一顿。

参考

Three.js 实时机器人动画:时间戳插值与缓冲

https://wywzixin.github.io/threejs/urdf-animation-buffer/

作者

wangyawei

发布于

2026-09-18

更新于

2026-09-18

许可协议