无标记动作捕捉使用及插件接入
相关资料网站
Captury测试和使用方法
打开 CapturyReplay

导入 lanqiu 动捕数据
数据的存储路径不能出现中文路径否则会导入失败

打开 ShotInfo 和 retarget 视图


加载目标

选择骨骼

设置数据源

unknown和unknown-2分别对应两个角色

Captury原始插件底层sock阻塞问题
问题描述
在插件接入的时候(插件已经完成了接入,数据已经连通),但遇到一个问题:
用户反馈,如果要从Captury信号源切换到别的信号源的时候,会发生卡顿的现象。
卡顿时间在固定的20s左右。


且这个问题仅仅发生在Captury信号源没连接上的状态中。如果信号连接上了,切换这个操作不会发生卡顿。
问题分析
这个操作背后的逻辑是,用户切到别的信号源,我会销毁掉已经生成的CapturyLivelink,确保Livelink信号池子干净。
同时,刷新信号的按钮也会出现这个问题,因为刷新的时候,我背后做的操作是:先删除掉老的CapturyLivelink,然后在创建新的连接数据。
且为了防止出现底层数据竞争的问题。我永远只允许一个CapturyLivelink信号源出现。
所以,在切到别的信号源的时候,我永远会销毁掉老的信号源。而问题就发生在删除老的信号源上面,只要一旦要销毁没有连上的老的信号源,就会发生这个20s左右的卡顿。
可是为什么是“没有连接上”才会发生卡顿,而正常情况下不会呢?暂时不得而知。所以暂时推断问题应该在底层断连逻辑这一块。
通过Unreal Insight发现,在执行了Captury_disconnect操作之后。整个系统陷入等待,时间会有三十多秒。

所以这里确定了是断连操作造成了这个卡顿。
然后 我在卡顿的(这十几秒里)时候,暂停了IDE,看看当前Game停在了哪里,Game停在了
1 | WaitForSingleObject(receiveThread, INFINITE); |
这一句话。这句话是我前几天加的,考虑到两个CapturyLivelink会出现多线程的问题,底层占用会导致崩溃,所以我写了需要等待底下工作的线程真正地退出,才算完成退出操作。
所以我意识到了,问题应该是出在了receiveThread线程的loop里。
于是开始在整个链路上打Log寻找罪魁祸首看看是哪个接口造成的。
并且在链路中添加更多地退出机制(stopReceiving、bExternalShutdownRequested),看看能否去掉卡顿。
1 |
|
结果Log查出问题了:
1 | Display LogCaptury CapturyLiveLink: request shutdown |
其中openTcpSocket()+数字的部分以及RemoteCaptury::disconnect() 是我游戏线程。
当我下达关机指令的时候,remote线程卡在了04-1和04-2之间。并且remote线程是活的。所以大家都在等remote线程。
而这两个Log之间夹的函数就是sock = openTcpSocket();
这个函数的作用是:创建一个 TCP,套接字并尝试与远程地址建立连接。
下面是原始的代码:
1 | SOCKET RemoteCaptury::openTcpSocket() |
经过加Log发现,函数卡在了。
if (::connect(sok, (sockaddr*) &remoteAddress, sizeof(remoteAddress)) != 0)
这个if判断里面,而这个接口是windows的sock接口。ok,至此,已经找到罪魁祸首了。
底层逻辑阐述

这一节想解释清楚两件事:
- 为什么阻塞式
connect会在「没连上」的时候卡住几十秒; - 这个行为和阻塞 / 非阻塞、缓冲区、IO 多路复用之间的关系。
1. 阻塞 connect 为什么会卡很久
原始代码里的 openTcpSocket 使用的是阻塞 socket + 阻塞 connect:
1 | SOCKET sok = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); |
在阻塞模式下:
connect会进入内核,等待 TCP 三次握手完成或者失败;- 如果远端服务没开 / IP 不可达 / 包被丢弃,系统会按自己的重试策略不断发 SYN、等待回应;
- 在这段时间里,调用
connect的线程会一直被挂起,直到内核判定“连接失败”并超时返回; - 这个超时通常是几十秒级(你实际观测到的就是 ~20s)。
结合上面的 receiveLoop,可以看到:
receiveLoop在重连时会反复调用sock = openTcpSocket();;- 当 Captury 端没开的时候,每次
openTcpSocket()都会在connect内部卡到系统超时; - 你的
disconnect()又在WaitForSingleObject(receiveThread, INFINITE);等这个线程自然退出; - 于是 Game 线程就被这一条阻塞式
connect间接拖住了几十秒。
也就是说,这里并不是逻辑死锁,而是单纯被内核的阻塞连接等待住了。
2. 阻塞 I/O、缓冲区和 IO 多路复用问题(扩展)
阻塞的问题不只出现在 connect,也同样存在于 read / write(WinSock 里的 recv / send):
- 阻塞模式下:
recv在缓冲区“没数据可读”时会一直等;send在发送缓冲区满时会一直等;- 线程就挂在这些系统调用里,直到有数据或有空位。
这几张图,其实就是描述:应用线程在访问一个内核缓冲区——
- 读的时候:如果缓冲区里暂时没数据,阻塞
read/recv会卡住线程; - 写的时候:如果缓冲区已经写满,阻塞
write/send会卡在那儿等内核把旧数据发走。
在“阻塞模型”下(原来的代码),如果网络线程直接在这些调用上阻塞:
它就没法及时响应退出信号(
stopReceiving/bExternalShutdownRequested等);像
disconnect()这种需要“等待线程正常退出”的逻辑,也都会被一起拖住。比如下下面这张图片,多线程的阻塞模式,会在有大量连接调用的时候,开辟多个线程,去做连接,一个服务一个连接,这个服务断掉了这个,这个线程占用才结束,这是一个很古早的调用方式,因为线程其实也是很珍贵的资源,一台电脑总共也没几个线程。(回到我的项目里,因为我只允许运行一个Captury线程,而且Game线程会等这个线程,所以整个游戏卡着。当然我这么做也有别的考量)
IO 多路复用(select / poll / epoll / WSAPoll),本质上是在做一件事:
- 不把线程挂死在
read/write/connect上,而是:- 先在一个“描述符集合”上等「可读 / 可写 / 异常」事件;
- 只有当内核告诉你“这个 fd 已经准备好”时,再真正去
read/recv或write/send; - 这个等待可以设置自定义超时(例如 10ms、100ms、1s),并在每次醒来时检查退出条件。
3. 非阻塞 + ioctlsocket 在这里的作用
ioctlsocket(sok, FIONBIO, &NonBlocking) 就是把 socket 从“阻塞模式”切换到“非阻塞模式”。
在非阻塞模式下:
connect不再把线程挂死,而是:- 立刻返回一个错误码(例如
WSAEWOULDBLOCK / WSAEINPROGRESS),表示“连接还在进行中”; - 后续由你自己用
select/WSAPoll等来轮询「这条连接是否已经成功/失败」,并且可以自己控制最大等待时间;
- 立刻返回一个错误码(例如
read/recv、write/send遇到没数据 / 缓冲区满时,也会立刻返回EWOULDBLOCK,由你的逻辑决定下一步怎么做,而不是直接卡死线程。
总结一下这一节:
- 根因:阻塞
connect在目标不可达时会触发系统级长超时,线程被内核挂起几十秒; - 放大器:你的
receiveLoop在重连路径里频繁调用openTcpSocket(),而disconnect()又必须等线程退出; - 改成非阻塞的目的:用
ioctlsocket(FIONBIO)+select自己控制等待时长和退出时机,把“不可控的 20s 超时”变成“可控的 1s 以内失败”,从而让disconnect()快速返回。

非阻塞是“通过修改套接字模式”实现的——代码在 socket() 之后、connect() 之前把套接字设为非阻塞,因此 connect() 就是在非阻塞模式下执行的;连接完成后再把套接字恢复成阻塞。
- 所以其实我这个修改后的代码,并不是完全非阻塞的。
解决方案
因为这里调用了底层 WinSock 的 connect。
它是 Windows 提供的系统调用,没办法在内部加条件判断。
我们只能:
- 要么换一种调用方式(非阻塞 + 自己用 select/WSAPoll 控制超时);
- 要么在调用 connect 前后做逻辑,比如提前检测 stopReceiving,或者调用后在另一个线程里强制 closesocket。
但这个方案不是很问题,需要维护另外一个线程来关闭这个线程。听起来就很麻烦。所以我用前者,来改造一个非阻塞的sock。
1 | SOCKET RemoteCaptury::openTcpSocket() |
这个函数实现了客户端创建socket,且用非阻塞模式connect。然后恢复阻塞。
参考资料
Livelink的数据的实现方式的不同
可以理解成两种:可被 Finder 发现的 Message Bus Provider,以及直连 IP/端口的自定义/专用 Source。
它们最终都会把数据喂给 LiveLink Client,但“发现、连接、网络形态、运维方式”差异很大。
| Finder+Message Bus | 直连 IP | |
|---|---|---|
| 发现与连接方式 | 通过 UE 的 UDP Messaging/Message Bus 做 广播/多播 Ping/Pong。 ULiveLinkMessageBusFinder::GetAvailableProviders() 只能列出“会回 FLiveLinkPongMessage 的 Provider”。 优点:自动发现,不用手填 IP。 |
Source 自己实现网络协议(TCP/UDP/自定义端口),通常在 UI/蓝图里 手动输入 IP(和可能的端口) 来连。 你项目里的 Captury 就属于这一类:ConnectCapturyLiveLinkSource(const FString& IpAddress, bool bUseTCP, …) 明确以 IP 为入口,并且还能选 TCP/压缩/Tag 等传输选项。 优点:不依赖 Message Bus 生态,协议可控。 |
| 网络依赖与可达性 | 依赖:UDP Messaging、正确网卡绑定、允许多播/广播、Windows 防火墙放行。 常见问题:多网卡绑错、跨网段/VLAN/AP 隔离导致发现不到。 |
依赖:目标 IP/端口可达即可(更像传统 C/S)。 更容易跨子网/路由(只要网络策略允许),但要处理端口开放与 NAT/防火墙规则。 |
| 数据“来源形态”与协议语义 | “数据来源”通常是一个 Provider 服务,对外暴露“可发现的 Provider+若干 Subjects”。 通信语义包含“发现/握手/版本”等(例如 Pong 里带版本、机器名等)。 |
“数据来源”就是某个设备/应用在某端口输出的数据流,只要连上就收数据。 语义完全由插件定义(例如 Captury 的 bStreamCompressed、bStreamARTags 这类能力开关)。 |
| 生命周期与稳定性 | Provider 上下线会通过发现机制变化;但“只发现不等于稳定连接”,仍可能受消息总线环境波动影响。 | 连接状态更直观(连上/断开/重连),一般由插件自己做重连策略与超时。 |
| 蓝图/代码使用差异(以你的工程为例) | 典型流程:Finder 获取 Provider 列表 (==可发现) → 选择 Provider → 创建对应 Source。 | 典型流程:直接调用 ConnectCapturyLiveLinkSource(IpAddress, bUseTCP, …) 建立 Source。 这里 meta=(Latent, … Duration=”0.2”) 表示这是一个延迟/等待式蓝图调用,Duration 用作等待/超时窗口一类的参数(具体行为由实现决定),但它不是“局域网发现”。 |