Captury无标记动作捕捉使用及插件接入问题

无标记动作捕捉使用及插件接入

相关资料网站

CapturyLiveLink_5.2
API文档
官方教程

Captury测试和使用方法

打开 CapturyReplay

image-20251212111952293

导入 lanqiu 动捕数据

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

image-20251212111946140

打开 ShotInfo 和 retarget 视图

image-20251212111936943

image-20251212111930610

加载目标

image-20251212111921791

选择骨骼

image-20251212111913119

设置数据源

image-20251212111904207

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

image-20251212111717722

Captury原始插件底层sock阻塞问题

问题描述

在插件接入的时候(插件已经完成了接入,数据已经连通),但遇到一个问题:

用户反馈,如果要从Captury信号源切换到别的信号源的时候,会发生卡顿的现象。

卡顿时间在固定的20s左右。

image-20251205150209762image-20251205150637519

且这个问题仅仅发生在Captury信号源没连接上的状态中。如果信号连接上了,切换这个操作不会发生卡顿。

问题分析

这个操作背后的逻辑是,用户切到别的信号源,我会销毁掉已经生成的CapturyLivelink,确保Livelink信号池子干净。

同时,刷新信号的按钮也会出现这个问题,因为刷新的时候,我背后做的操作是:先删除掉老的CapturyLivelink,然后在创建新的连接数据。

且为了防止出现底层数据竞争的问题。我永远只允许一个CapturyLivelink信号源出现。

所以,在切到别的信号源的时候,我永远会销毁掉老的信号源。而问题就发生在删除老的信号源上面,只要一旦要销毁没有连上的老的信号源,就会发生这个20s左右的卡顿。

可是为什么是“没有连接上”才会发生卡顿,而正常情况下不会呢?暂时不得而知。所以暂时推断问题应该在底层断连逻辑这一块。

通过Unreal Insight发现,在执行了Captury_disconnect操作之后。整个系统陷入等待,时间会有三十多秒。

image-20251205153031066

所以这里确定了是断连操作造成了这个卡顿。

然后 我在卡顿的(这十几秒里)时候,暂停了IDE,看看当前Game停在了哪里,Game停在了

1
WaitForSingleObject(receiveThread, INFINITE);

这一句话。这句话是我前几天加的,考虑到两个CapturyLivelink会出现多线程的问题,底层占用会导致崩溃,所以我写了需要等待底下工作的线程真正地退出,才算完成退出操作。

所以我意识到了,问题应该是出在了receiveThread线程的loop里。

于是开始在整个链路上打Log寻找罪魁祸首看看是哪个接口造成的。
并且在链路中添加更多地退出机制(stopReceiving、bExternalShutdownRequested),看看能否去掉卡顿。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#ifdef WIN32
DWORD RemoteCaptury::receiveLoop()
#else
void* RemoteCaptury::receiveLoop()
#endif
{
bool handshaking = !handshakeFinished;
// UE_LOG(LogCapturyRemote , Warning, TEXT("01 starting receive loop %d %s"), testcount++, *(FDateTime::Now().ToString()));
//log("starting receive loop\n");
while (!stopReceiving
&& !bExternalShutdownRequested
&& (!handshaking || !handshakeFinished)) {

// UE_LOG(LogCapturyRemote , Warning, TEXT("02 receive loop iteration %d %s"), testcount++, *(FDateTime::Now().ToString()));

if (!receive(sock)) {
if (sock == -1
&& !stopReceiving
&& !bExternalShutdownRequested )
{
deleteActors();
cameras.clear();
numCameras = -1;

// UE_LOG(LogCapturyRemote , Warning, TEXT("03 socket closed, reconnecting %d %s"), testcount++, *(FDateTime::Now().ToString()));
if (isStreamThreadRunning) {
stopStreamThread = 1;

#ifdef WIN32
WaitForSingleObject(streamThread, 1000);
#else
void* retVal;
pthread_join(streamThread, &retVal);
#endif
}
// UE_LOG(LogCapturyRemote , Warning, TEXT("03 a socket closed, reconnecting %d %s"), testcount++, *(FDateTime::Now().ToString()));

// UE_LOG(LogCapturyRemote , Warning, TEXT("04-1 %d %s"), testcount++, *(FDateTime::Now().ToString()));
while (!stopReceiving) {
// 停止重连
sock = openTcpSocket();
//UE_LOG(LogCapturyRemote , Warning, TEXT("04-2 %d %s"), testcount++, *(FDateTime::Now().ToString()));
if (sock != -1)
break;

sleepMicroSeconds(1000);
}
// UE_LOG(LogCapturyRemote , Warning, TEXT("04-3 %d %s"), testcount++, *(FDateTime::Now().ToString()));

if (streamWhat != CAPTURY_STREAM_NOTHING)
Captury_startStreamingImagesAndAngles(this, streamWhat, streamCamera, (int)streamAngles.size(), streamAngles.data());
//UE_LOG(LogCapturyRemote , Warning, TEXT("05 %d %s"), testcount++, *(FDateTime::Now().ToString()));

handshaking = false; // this is a lie but makes it go into the normal loop
}
}
}
//log("stopping receive loop\n");
// UE_LOG(LogCapturyRemote , Warning, TEXT("06 %d %s"), testcount++, *(FDateTime::Now().ToString()));
return 0;
}

结果Log查出问题了:

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
Display LogCaptury CapturyLiveLink: request shutdown
Warning LogCapturyRemote Captury_stopStreaming() called
Warning LogCapturyRemote Captury_disconnect() called
Warning LogCapturyRemote RemoteCaptury::disconnect() called

Warning LogCapturyRemote 01 starting receive loop 267 2025.12.04-18.57.03
Warning LogCapturyRemote 02 receive loop iteration 268 2025.12.04-18.57.03
Warning LogCapturyRemote 03 socket closed, reconnecting 269 2025.12.04-18.57.03
Warning LogCapturyRemote 03 a socket closed, reconnecting 270 2025.12.04-18.57.03
Warning LogCapturyRemote Captury_disconnect() called
Warning LogCapturyRemote 04-1 271 2025.12.04-18.57.03
Warning LogCapturyRemote RemoteCaptury::disconnect() called
Warning LogCapturyRemote openTcpSocket() 01 2025.12.04-18.57.03
Warning LogCapturyRemote openTcpSocket() 02 2025.12.04-18.57.03
Warning LogCapturyRemote openTcpSocket() 03 2025.12.04-18.57.03
Display LogCaptury CapturyLiveLink: request shutdown
Warning LogCapturyRemote Captury_stopStreaming() called
Warning LogCapturyRemote Captury_disconnect() called
Warning LogCapturyRemote RemoteCaptury::disconnect() called
Warning LogCapturyRemote RemoteCaptury::disconnect() **: waiting for receiveThread 2025.12.04-18.57.05
Warning LogCapturyRemote 04-2 272 2025.12.04-18.57.24
Warning LogCapturyRemote 04-3 273 2025.12.04-18.57.24
Warning LogCapturyRemote 06 274 2025.12.04-18.57.24
Warning LogCapturyRemote RemoteCaptury::disconnect() **: receiveThread finished 2025.12.04-18.57.24

Display LogCaptury CapturyLiveLink: request shutdown
Warning LogCapturyRemote Captury_stopStreaming() called
Warning LogCapturyRemote Captury_disconnect() called
Warning LogCapturyRemote RemoteCaptury::disconnect() called

其中openTcpSocket()+数字的部分以及RemoteCaptury::disconnect() 是我游戏线程。

当我下达关机指令的时候,remote线程卡在了04-1和04-2之间。并且remote线程是活的。所以大家都在等remote线程。

而这两个Log之间夹的函数就是sock = openTcpSocket();

这个函数的作用是:创建一个 TCP,套接字并尝试与远程地址建立连接。

下面是原始的代码:

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
SOCKET RemoteCaptury::openTcpSocket()
{
log("opening TCP socket\n");

SOCKET sok = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sok == -1)
return (SOCKET)-1;

if (localAddress.sin_port != 0 && bind(sok, (sockaddr*) &localAddress, sizeof(localAddress)) != 0) {
closesocket(sok);
return (SOCKET)-1;
}

if (::connect(sok, (sockaddr*) &remoteAddress, sizeof(remoteAddress)) != 0) {
closesocket(sok);
return (SOCKET)-1;
}

// set read timeout
setSocketTimeout(sok, 500);

#ifndef WIN32
char buf[100];
log("connected to %s:%d\n", inet_ntop(AF_INET, &remoteAddress.sin_addr, buf, 100), ntohs(remoteAddress.sin_port));
#endif

return sok;
}

经过加Log发现,函数卡在了。

if (::connect(sok, (sockaddr*) &remoteAddress, sizeof(remoteAddress)) != 0)

这个if判断里面,而这个接口是windows的sock接口。ok,至此,已经找到罪魁祸首了。

底层逻辑阐述

image-20251205123239125

这一节想解释清楚两件事:

  1. 为什么阻塞式 connect 会在「没连上」的时候卡住几十秒
  2. 这个行为和阻塞 / 非阻塞、缓冲区、IO 多路复用之间的关系。

1. 阻塞 connect 为什么会卡很久

原始代码里的 openTcpSocket 使用的是阻塞 socket + 阻塞 connect

1
2
3
4
5
6
SOCKET sok = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
...
if (::connect(sok, (sockaddr*)&remoteAddress, sizeof(remoteAddress)) != 0) {
closesocket(sok);
return (SOCKET)-1;
}

在阻塞模式下:

  • 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 在发送缓冲区满时会一直等;
    • 线程就挂在这些系统调用里,直到有数据或有空位。

这几张图,其实就是描述:应用线程在访问一个内核缓冲区——

image-20251205142827661
  • 读的时候:如果缓冲区里暂时没数据,阻塞 read/recv 会卡住线程;
  • 写的时候:如果缓冲区已经写满,阻塞 write/send 会卡在那儿等内核把旧数据发走。
image-20251205144248299 image-20251205144319439

在“阻塞模型”下(原来的代码),如果网络线程直接在这些调用上阻塞:

  • 它就没法及时响应退出信号stopReceiving / bExternalShutdownRequested 等);

  • disconnect() 这种需要“等待线程正常退出”的逻辑,也都会被一起拖住。比如下下面这张图片,多线程的阻塞模式,会在有大量连接调用的时候,开辟多个线程,去做连接,一个服务一个连接,这个服务断掉了这个,这个线程占用才结束,这是一个很古早的调用方式,因为线程其实也是很珍贵的资源,一台电脑总共也没几个线程。(回到我的项目里,因为我只允许运行一个Captury线程,而且Game线程会等这个线程,所以整个游戏卡着。当然我这么做也有别的考量)

    image-20251205191914919

IO 多路复用(select / poll / epoll / WSAPoll,本质上是在做一件事:

  • 不把线程挂死在 read/write/connect 上,而是:
    • 先在一个“描述符集合”上等「可读 / 可写 / 异常」事件;
    • 只有当内核告诉你“这个 fd 已经准备好”时,再真正去 read/recvwrite/send
    • 这个等待可以设置自定义超时(例如 10ms、100ms、1s),并在每次醒来时检查退出条件。

3. 非阻塞 + ioctlsocket 在这里的作用

ioctlsocket(sok, FIONBIO, &NonBlocking) 就是把 socket 从“阻塞模式”切换到“非阻塞模式”。

在非阻塞模式下:

  • connect 不再把线程挂死,而是:
    • 立刻返回一个错误码(例如 WSAEWOULDBLOCK / WSAEINPROGRESS),表示“连接还在进行中”;
    • 后续由你自己用 select / WSAPoll 等来轮询「这条连接是否已经成功/失败」,并且可以自己控制最大等待时间
  • read/recvwrite/send 遇到没数据 / 缓冲区满时,也会立刻返回 EWOULDBLOCK,由你的逻辑决定下一步怎么做,而不是直接卡死线程。

总结一下这一节:

  • 根因:阻塞 connect 在目标不可达时会触发系统级长超时,线程被内核挂起几十秒;
  • 放大器:你的 receiveLoop 在重连路径里频繁调用 openTcpSocket(),而 disconnect() 又必须等线程退出;
  • 改成非阻塞的目的:用 ioctlsocket(FIONBIO) + select 自己控制等待时长和退出时机,把“不可控的 20s 超时”变成“可控的 1s 以内失败”,从而让 disconnect() 快速返回。

image-20251205123239125

非阻塞是“通过修改套接字模式”实现的——代码在 socket() 之后、connect() 之前把套接字设为非阻塞,因此 connect() 就是在非阻塞模式下执行的;连接完成后再把套接字恢复成阻塞。

  • 所以其实我这个修改后的代码,并不是完全非阻塞的。

解决方案

因为这里调用了底层 WinSock 的 connect。
它是 Windows 提供的系统调用,没办法在内部加条件判断。

我们只能:

  • 要么换一种调用方式(非阻塞 + 自己用 select/WSAPoll 控制超时);
  • 要么在调用 connect 前后做逻辑,比如提前检测 stopReceiving,或者调用后在另一个线程里强制 closesocket。

但这个方案不是很问题,需要维护另外一个线程来关闭这个线程。听起来就很麻烦。所以我用前者,来改造一个非阻塞的sock。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
SOCKET RemoteCaptury::openTcpSocket()
{
// UE_LOG(LogCapturyRemote , Warning, TEXT("openTcpSocket() 01 %s"), *(FDateTime::Now().ToString()));
// 函数通过调用 socket 创建一个套接字。如果创建失败(返回 INVALID_SOCKET),函数直接返回错误状态
SOCKET sok = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sok == INVALID_SOCKET)
return INVALID_SOCKET;

// UE_LOG(LogCapturyRemote , Warning, TEXT("openTcpSocket() 02 %s"), *(FDateTime::Now().ToString()));
// 如果 localAddress 的端口号不为 0,函数会尝试将套接字绑定到指定的本地地址。如果绑定失败,同样关闭套接字并返回错误
if (localAddress.sin_port != 0 && bind(sok, (sockaddr*)&localAddress, sizeof(localAddress)) != 0) {
closesocket(sok);
return INVALID_SOCKET;
}

// UE_LOG(LogCapturyRemote , Warning, TEXT("openTcpSocket() 03 %s"), *(FDateTime::Now().ToString()));
// 1. 先设成非阻塞
u_long NonBlocking = 1;
if (ioctlsocket(sok, FIONBIO, &NonBlocking) == SOCKET_ERROR) {
closesocket(sok);
return INVALID_SOCKET;
}

// 2. 尝试发起 connect
int ConnectResult = ::connect(sok, (sockaddr*)&remoteAddress, sizeof(remoteAddress));
if (ConnectResult == SOCKET_ERROR) {
int Err = WSAGetLastError();
if (Err != WSAEWOULDBLOCK && Err != WSAEINPROGRESS && Err != WSAEALREADY) {
// 真错误,直接失败
closesocket(sok);
return INVALID_SOCKET;
}

// 3. 连接正在进行中,用 select 等待一小段时间
const int MaxWaitMillis = 1000; // 1 秒上限
fd_set WriteSet;
FD_ZERO(&WriteSet);
FD_SET(sok, &WriteSet);

struct timeval Tv;
Tv.tv_sec = MaxWaitMillis / 1000;
Tv.tv_usec = (MaxWaitMillis % 1000) * 1000;

int Sel = select((int)(sok + 1), nullptr, &WriteSet, nullptr, &Tv);
if (Sel <= 0) {
// 超时或 select 出错,都当失败
closesocket(sok);
return INVALID_SOCKET;
}

// 4. select 说 socket 可写了,用 SO_ERROR 再确认一次
int so_error = 0;
int optLen = sizeof(so_error);
if (getsockopt(sok, SOL_SOCKET, SO_ERROR, (char*)&so_error, &optLen) == SOCKET_ERROR || so_error != 0) {
closesocket(sok);
return INVALID_SOCKET;
}
}

// 走到这说明 connect 已经完成了,可以恢复成阻塞模式
u_long Blocking = 0;
ioctlsocket(sok, FIONBIO, &Blocking);

// UE_LOG(LogCapturyRemote , Warning, TEXT("openTcpSocket() 04 %s"), *(FDateTime::Now().ToString()));

setSocketTimeout(sok, 500);
// UE_LOG(LogCapturyRemote , Warning, TEXT("openTcpSocket() 05 %s"), *(FDateTime::Now().ToString()));

return sok;
}

这个函数实现了客户端创建socket,且用非阻塞模式connect。然后恢复阻塞。

参考资料

  1. 网络多线程细节处理:Socket阻塞与非阻塞

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 用作等待/超时窗口一类的参数(具体行为由实现决定),但它不是“局域网发现”。