一、明确Axis、BVH和MocapApi的关系
当前输入链路是:
Axis Studio内部姿态解算
↓
普通BVH二进制广播
↓
TCP 127.0.0.1:7003
↓
Noitom MocapApi r70解析
↓
AvatarUpdated事件
↓
NoitomMocapApiSource
其中:
- Axis Studio负责接收动捕设备数据并解算人体骨骼;
- 普通BVH广播通过本机TCP端口7003输出二进制数据;
- MocapApi是C++ SDK,负责连接7003并解析Axis数据;
- MocapApi向程序提供
AvatarUpdated事件、骨骼位置、旋转和posture_index; - MocapApi与我们的C++程序之间不是新的网络,而是SDK函数调用。
因此96 Hz到约63 Hz的差异可能发生在:
Axis内部姿态解算
↓ 可能降采样
Axis普通BVH广播
↓ TCP 7003
MocapApi解析和事件生成
↓ 可能覆盖
我们的采集线程
二、Axis Studio放在后台容易掉帧。
我们没有立刻把它认定为根因,而是把它作为一个待验证变量。
可能的机制包括:
- Axis窗口最小化后降低实时更新频率;
- Windows降低后台进程调度优先级;
- MuJoCo、录屏或其他程序抢占CPU/GPU;
- Axis实时捕捉页面失去激活;
- 系统处于节能模式。
我们形成的验证方法是:
测试A:Axis保持前台20秒
测试B:Axis最小化到后台20秒
然后比较:
source_received
source_missing_indices
first_posture_index
last_posture_index
思考原则是:
不因为别人说"后台会掉帧"就直接下结论,而是通过两个只改变"前台/后台"条件的实验验证。
今天没有实际执行这组测试,只确定了测试方法。
三、重点分析MocapApi覆盖问题
MocapApi可能只保留最新姿态,旧姿态会被覆盖。
这个解释与当前现象是相符的。
假设Axis内部按96 Hz更新:
约每10.42毫秒产生一次姿态
如果原程序按下面方式串行运行:
调用MocapApi取一帧
↓
执行GMR IK
↓
protobuf序列化
↓
网络发送
↓
回来再取下一帧
假设这一整套处理花15毫秒,那么SDK内部可能已经产生下一帧甚至下下帧。
如果MocapApi内部只保留"最新Avatar状态",就可能出现:
取到100
忙着执行GMR
SDK内部出现101
SDK内部又出现102,并覆盖101
程序回来后只能取到102
最终看到:
100 → 102
这就是一种合理的"覆盖"机制。
四、确认之前拆线程加FIFO的改造是有效的
当前程序已经改成:
MocapApi采集线程
↓
Input FIFO
↓
GMR线程
↓
Robot FIFO
↓
ZMQ发送线程
这个改造的目的不是直接让Axis输出96 Hz,而是防止:
GMR计算太慢
↓
阻塞MocapApi下一次读取
拆分后,采集线程只负责:
读取Avatar
转换成NoitomFrame
放入FIFO
立即继续读取
GMR在另一个线程中慢慢处理。
过去的审计结果已经证明:
source_received = gmr_processed
input_pending最终为0
说明程序从MocapApi拿到的每一帧,都被GMR处理了。
五、进一步确认FIFO保存的是独立数据
今天直接审查了实际代码,接收线程中使用:
input_queue_.push_back(std::move(*frame));
队列类型是:
std::deque<NoitomFrame>
也就是说,FIFO中保存的是完整的NoitomFrame对象,不是MocapApi内部Avatar指针。
GMR线程再从队列取出:
source_frame = std::move(input_queue_.front());
input_queue_.pop_front();
因此确认:
已经从MocapApi读取并生成的NoitomFrame,不会因为SDK下一次更新而被覆盖。
这一步排除了一个重要风险:
FIFO保存SDK内部指针
↓
SDK复用内存
↓
队列里的旧帧全部变成最新帧
当前代码不是这种错误做法。
六、明确FIFO能够解决什么、不能解决什么
FIFO位于:
MocapApi读取之后
所以它能解决:
- GMR阻塞采集线程;
- GMR偶尔计算慢;
- 已读取帧等待处理;
- 已经生成的
NoitomFrame被后续处理覆盖; - 采集和IK速度不完全一致。
但它不能解决:
- Axis本身只广播约63 Hz;
- MocapApi在返回事件之前已经覆盖中间姿态;
- SDK只产生约63个
AvatarUpdated事件; posture_index本来就是内部时钟编号;- Axis后台运行导致广播变慢。
可以用一句大白话概括:
FIFO能保证"拿到手以后不再丢",但不能找回"SDK交给我们之前就已经被覆盖的数据"。
七、审查MocapApi当前轮询代码
当前核心逻辑大致是:
PollApplicationNextEvent();
如果没有事件:
return null
如果不是Avatar事件:
return null
如果是AvatarUpdated:
读取Avatar
生成NoitomFrame
return frame
外层NoitomMocapApiSource当前还有:
if (!frame) {
sleep_for(1ms);
}
这带来一个潜在问题:
遇到空事件或非Avatar事件
↓
固定等待1毫秒
↓
再调用MocapApi
如果SDK真的只保留最新姿态,这1毫秒就是一个额外的覆盖窗口。
虽然1毫秒不太可能单独解释稳定少约32 Hz,但为了把自己的程序做到最好,这个固定等待值得优化。
七、第一项新改造:sleep改为yield
计划从:
if (!frame) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
改成:
if (!frame) {
std::this_thread::yield();
}
两者区别:
sleep_for(1ms)
= 明确暂停至少约1毫秒
= Windows实际唤醒可能更晚
yield()
= 主动让其他可运行线程先执行
= 通常更快重新获得CPU
这样做的依据是:
- 采集线程已经独立;
- 它应该尽快再次轮询SDK;
- 不应在采集热路径中加入固定等待;
- 同时使用
yield()避免完全空转占满CPU。
这项改造属于:
低风险
可回滚
不影响GMR和通信协议
八、第二项新改造:立即排空非Avatar事件
当前程序遇到非Avatar事件后,会返回外层,然后触发1毫秒等待。
更合理的逻辑是:
for (;;) {
poll一个SDK事件;
if没有任何事件:
返回空;
if系统错误:
抛出异常;
if不是Avatar事件:
立即继续poll;
如果是AvatarUpdated:
立即深拷贝为NoitomFrame;
返回这一帧;
}
这样如果SDK队列为:
Notify
SensorUpdated
AvatarUpdated
程序不会:
读Notify → 等1ms
读SensorUpdated → 再等1ms
读AvatarUpdated
而是:
读Notify
立即继续
读SensorUpdated
立即继续
读AvatarUpdated
深拷贝并返回
这样做的依据是:
- 非Avatar事件不是GMR需要的动作数据;
- 但不能让这些事件拖慢后续Avatar读取;
- SDK提供了
Error_MoreEvent,说明可能存在多个待取事件; - 我们应尽快排空无关事件。
九、为什么没有再次尝试事件数组和Handle缓存
之前曾尝试:
- 修改
PollApplicationNextEvent读取数量; - 缓存关节Handle;
- 使用更激进的SDK事件读取方式。
结果出现:
收不到动作
机器人保持站立
程序闪退
退出码-1073741819
回退后程序恢复正常。
所以今天的设计遵循:
不再改变SDK对象生命周期,不猜测事件数组参数,不跨Avatar更新缓存Joint Handle。
当前源码中也明确写着:
MocapApi joint handles与当前缓存姿态绑定,
跨AvatarUpdated事件保留是不安全的。
因此这轮只优化:
轮询节奏
事件排空
统计证据
不改危险的SDK内存用法。
十、第三项新改造:增加更细的MocapApi审计
当前日志只有:
posture_index
avg_posture_delta
skipped_frames
avg_avatar_read_ms
max_avatar_read_ms
它告诉我们"编号有跳",但还不能解释为什么跳。
所以准备增加:
poll_calls
avatar_events
empty_polls
other_events
more_event_returns
duplicate_indices
backward_indices
max_poll_gap_ms
max_poll_block_ms
poll_calls
每秒调用PollApplicationNextEvent多少次。
作用:
判断我们的线程到底有没有积极轮询
avatar_events
SDK每秒实际交付多少个AvatarUpdated事件。
作用:
直接测量MocapApi输出事件频率
empty_polls
SDK回答"当前没有事件"的次数。
如果:
poll_calls非常高
empty_polls非常多
avatar_events只有约64
就说明程序一直在问SDK,但SDK只提供约64个Avatar事件。
other_events
每秒收到多少个非Avatar事件。
如果这个数很大,说明之前"非Avatar事件后睡1毫秒"可能确实影响采集。
more_event_returns
SDK返回Error_MoreEvent的次数。
它可以说明SDK是否经常提示:
后面还有未读取事件
如果这个数字很高,立即排空策略可能有效。
如果始终为0,说明SDK很少向应用暴露事件积压。
duplicate_indices
连续收到相同posture_index的次数。
它可以检查:
SDK是否触发了多个事件,
但事件内容都指向已经被覆盖的最新姿态
backward_indices
姿态编号倒退的次数。
它可以识别:
- SDK重连;
- Axis状态重置;
- 索引回绕;
- 异常乱序。
max_poll_gap_ms
两次调用SDK之间最长间隔。
如果超过:
10.42ms
就有可能错过96 Hz周期。
max_poll_block_ms
单次SDK调用最长阻塞多久。
如果SDK调用始终只需:
0.03~0.1ms
但每秒只提供64个Avatar事件,说明瓶颈不在函数执行速度。
十一、今天哪些内容实际改了,哪些还没改
已经存在并确认有效的代码改造
这些是此前已完成、今天重新检查确认的:
- MocapApi采集线程与GMR线程分离;
- 中间使用FIFO;
- FIFO保存独立
NoitomFrame; - GMR与ZMQ发送分离;
- 分层审计已经存在;
- ZMQ与MuJoCo无损计数已验证;
- 逐条
NoitomJump日志默认关闭; - Axis和代码现在统一使用XYZ。
今天完成的分析和设计
- 明确"丢帧"重点位于Axis/BVH/MocapApi侧;
- 分析MocapApi最新姿态覆盖机制;
- 审查当前
pollFrame()实现; - 找到空轮询后固定休眠1毫秒;
- 找到非Avatar事件会返回外层并休眠;
- 设计立即排空非Avatar事件;
- 设计新的九项MocapApi审计指标;
- 确定不再使用危险的事件数组和Handle缓存方案;
- 确定在8_3使用全新构建目录;
- 确定7_28必须在8_3独立验证后再删除。
今天尚未写入的改动
由于当前可视化任务仍绑定7_28,以下内容还没有真正落盘:
sleep_for(1ms)改为yield();- 非Avatar事件内部循环排空;
- 新增poll和索引审计字段;
- 新建8_3干净构建目录;
- 重新编译和测试。
十二、今天的完整思考框架
```mermaid
flowchart TD
A["现象:内部索引约96 Hz<br/>MocapApi约63 Hz"] --> B["重新确认问题边界"]
B --> C["不是先怀疑ZMQ<br/>重点检查Axis/BVH/MocapApi"]
C --> D["审查现有线程结构"]
D --> E["确认采集与GMR已分离"]
E --> F["确认FIFO保存独立NoitomFrame"]
F --> G["已拿到的数据不会再被SDK覆盖"]
G --> H["继续检查MocapApi读取热路径"]
H --> I["发现无帧后固定sleep 1 ms"]
H --> J["发现非Avatar事件返回外层"]
I --> K["计划改为yield"]
J --> L["计划内部立即排空"]
K --> M["减少SDK覆盖窗口"]
L --> M
M --> N["增加精细轮询审计"]
N --> O["poll次数"]
N --> P["Avatar事件数"]
N --> Q["空轮询/其他事件/MoreEvent"]
N --> R["重复/倒退索引"]
N --> S["最大调度/阻塞时间"]
O --> T["20秒实物对照测试"]
P --> T
Q --> T
R --> T
S --> T
T --> U{"结果"}
U -->|"接近96 Hz"| V["程序轮询优化有效"]
U -->|"略有提升"| W["程序与SDK均有影响"]
U -->|"仍约63 Hz"| X["SDK/Axis实际只交付约63个事件"]
```
十三、今天形成的工程逻辑
整个问题可以划成四道边界。
第一道:Axis内部
96 Hz姿态解算和posture_index
第二道:Axis输出
普通BVH二进制TCP 7003
第三道:MocapApi
解析BVH并产生AvatarUpdated事件
第四道:我们的程序
深拷贝 → FIFO → GMR → ZMQ → MuJoCo
我们已经能够保证第四道:
MocapApi实际交给我们的每一帧
↓
都被复制
↓
都进入FIFO
↓
都被GMR处理
↓
都被ZMQ发送
↓
都被MuJoCo接收和应用
今天继续优化的是第三道与第四道之间的缝隙:
SDK有事件
↓
程序是否足够快地poll
↓
是否因无关事件或sleep扩大覆盖窗口
二十一、一句话汇报版本
今天我主要继续分析MocapApi可能覆盖姿态的问题。我检查了现有代码,确认采集和GMR已经分线程,中间FIFO保存的是独立的NoitomFrame,所以数据只要被程序取出来,后面不会再被覆盖。接着发现当前MocapApi空轮询或遇到非Avatar事件后会回到外层固定休眠1毫秒,这可能扩大SDK内部覆盖窗口。因此准备把固定休眠改成线程让出,并在SDK内部连续排空非Avatar事件,同时增加poll次数、Avatar事件数、空轮询、MoreEvent、重复索引和最大轮询间隔等统计。这样既能尽量减少程序侧漏取,也能进一步证明约63 Hz到底是不是SDK实际交付上限。今天还检查到8_3中的旧CMake缓存仍带有7_28绝对路径,因此后续必须在8_3重新建立干净构建,验证独立运行后再删除7_28。