【Quest/OpenXR】通过Quest设备用 UDP 实时发送手柄 Pose + Trigger/Squeeze:从 0 到跑通的完整复盘(含关键源码片段)
本文介绍了在Meta Quest设备上通过OpenXR采集手柄数据并通过UDP实时传输的实现方案。系统以100Hz频率发送左右手的Grip/Aim位姿、Trigger/Squeeze数值(0-1)及有效性标志,接收端采用Linux平台解析数据。关键技术点包括:1)OpenXR输入动作绑定和位姿获取;2)UDP网络通信实现;3)跨平台数据协议设计(采用固定结构体布局);4)Android权限配置。文
平台:Quest(Android / Meta OpenXR Sample,C++)
目标:把左右手 Grip/Aim Pose + Trigger/Squeeze(0~1) 通过 UDP 发到局域网另一端(RK/PC Linux),接收端实时解析打印。
1. 我最终实现了什么
Quest 端每帧采样并通过 UDP 发出:
- 左手:
l_grip、l_aim、l_trigger、l_squeeze - 右手:
r_grip、r_aim、r_trigger、r_squeeze flags:标注 grip/aim pose 是否有效(追踪丢失时很有用)ts_ns:单调时钟时间戳,方便延迟/抖动分析
UDP 发送频率:100Hz(10ms)
接收端:Linux recvfrom() 后解析并打印。
2. 开发路线(从网络测试到读出参数)
-
最小 UDP 通信测试
先不碰 OpenXR,确认同网段、端口、AP 不隔离、Quest 端权限接通。 -
把 UDP 接入 OpenXR Sample(先发假数据)
只验证线程/生命周期稳定:SessionInit()启动、SessionEnd()停止。 -
读取 Pose(Grip/Aim)并发出去
用xrLocateSpace拿到spaceGrip*+spaceMenuBeam*的位姿。 -
读取 Trigger/Squeeze 的 float value(0~1)
新增XR_ACTION_TYPE_FLOAT_INPUT,绑定到.../value路径。 -
协议稳定化(magic/version/flags)
接收端先判 magic/version,再按结构体解析,避免后续升级踩坑。
3. Android 端:Manifest 权限(UDP 必备)
这个是一开始最容易忽略的点:没权限就是发不出去/收不到。需要在 AndroidManifest.xml 增加:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
INTERNET:网络 socket 必须ACCESS_NETWORK_STATE:调试/判断网络状态用(很建议保留)WAKE_LOCK:长时间运行、100Hz 发包更稳(避免休眠影响线程调度)
4. 协议设计
我最终采用 HandPacketV2(带 magic/version/flags)规范了一下发送结构体,原因:
- 方便 RK 端做版本兼容
- 避免 bitfield 布局/对齐带来的跨端解析风险
- 后面要扩展按钮、摇杆等字段也更从容
4.1 协议结构
#pragma pack(push, 1)
struct Pose7 {
float x, y, z;
float qx, qy, qz, qw;
};
struct HandPacketV2 {
uint32_t magic; // 'HPK2'
uint32_t version; // 2
uint64_t ts_ns;
uint32_t flags; // bit0 l_grip_valid, bit1 r_grip_valid, bit2 l_aim_valid, bit3 r_aim_valid
Pose7 l_grip;
Pose7 l_aim;
float l_trigger;
float l_squeeze;
Pose7 r_grip;
Pose7 r_aim;
float r_trigger;
float r_squeeze;
};
#pragma pack(pop)
static constexpr uint32_t kMagicHPK2 = 0x324B5048; // 'HPK2' little-endian
static constexpr uint32_t kVersion2 = 2;
enum : uint32_t {
F_L_GRIP_VALID = 1u << 0,
F_R_GRIP_VALID = 1u << 1,
F_L_AIM_VALID = 1u << 2,
F_R_AIM_VALID = 1u << 3,
};
注:
#pragma pack(push,1)是为了把结构体布局固定下来,避免 padding 导致sizeof不一致。
5. Quest 端改造(OpenXR Input Sample)
注意:优先从 Meta SDK 里的现成 Sample 改,不要自己从空工程起步。因为 Sample 已经把 OpenXR 初始化、Swapchain、参考空间、ActionSet/Action、绑定、渲染循环、Android 打包 都跑通了,只需要在“输入读取”和“UDP 发送”处做增量修改。
网址:https://github.com/meta-quest/Meta-OpenXR-SDK/tree/main
sample的目录下有很多例程:
\Meta-OpenXR-SDK-main\Samples\XrSamples
用Android Studio 打开:
Meta-OpenXR-SDK-main\Samples\XrSamples\XrInput\Projects\Android
工程中自动Sync(注意,这期间会遇到很多兼容性问题,包括但不限于,使用合适版本的NDK和CPP和CMake,笔者使用的是下面这个版本,可以在工程的build.gradle中写死)


还有一个问题就是:如果出现SSL失败,就是你的网络问题,记得关闭所有的tizi,笔者下载的包主要有这些:

JDK用的JDK17:

以上都是出现各种bug后修修补补环境得出来的结论,但是肯定不止这些问题,具体要看你们自己的配置对症下药。
5.1 新增 include 与网络头文件
修改的主力位置位于xrinput的src下的main.cpp,工程中的位置是(必须要Sync成功之后才会出现):

#include <mutex>
#include <thread>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <cstring>
5.2 类成员:UDP 状态、socket、线程、最新包
- lambda 里直接捕获
this - 生命周期归类管理(SessionInit/End)
- 不污染全局变量
std::atomic<bool> udpRunning_{false};
std::thread udpThread_;
int udpSock_{-1};
sockaddr_in udpAddr_{};
HandPacketV2 latestPkt_{};
std::mutex pktMtx_;
std::string udpIp_ = "192.168.10.192";
uint16_t udpPort_ = 9000;
5.3 StartUdp/StopUdp:SessionInit 启动,SessionEnd 停止
5.3.1 StartUdp(普通UDP流程没啥好说的)
void StartUdp() {
ALOG("[UDP] UDP start");
if (udpRunning_.exchange(true)) return;
udpSock_ = ::socket(AF_INET, SOCK_DGRAM, 0);
if (udpSock_ < 0) {
ALOG("[UDP] UDP socket() failed: errno=%d (%s)", errno, strerror(errno));
udpRunning_ = false;
return;
}
memset(&udpAddr_, 0, sizeof(udpAddr_));
udpAddr_.sin_family = AF_INET;
udpAddr_.sin_port = htons(udpPort_);
inet_pton(AF_INET, udpIp_.c_str(), &udpAddr_.sin_addr);
udpThread_ = std::thread([this]() {
while (udpRunning_.load()) {
HandPacketV2 p;
{
std::lock_guard<std::mutex> lk(pktMtx_);
p = latestPkt_;
}
ssize_t n = ::sendto(udpSock_, &p, sizeof(p), 0,
reinterpret_cast<sockaddr*>(&udpAddr_), sizeof(udpAddr_));
if (n < 0) {
ALOG("[UDP] UDP sendto failed: errno=%d (%s)", errno, strerror(errno));
} else if (n != sizeof(p)) {
ALOG("[UDP] UDP sendto partial: %zd/%zu", n, sizeof(p));
}
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 100Hz
}
});
}
5.3.2 StopUdp
void StopUdp() {
if (!udpRunning_.exchange(false)) return;
if (udpThread_.joinable()) udpThread_.join();
if (udpSock_ >= 0) { ::close(udpSock_); udpSock_ = -1; }
}
5.3.3 生命周期挂钩(这两个函数别忘了放到主程序里面)
在 SessionInit() 末尾:
StartUdp();
return true;
在 SessionEnd() 开头:
StopUdp();
SessionEnd 先 StopUdp 再 Shutdown 其他东西,这样线程不会在对象释放后继续跑。还有避免一些奇怪的问题。
5.4 新增 float actions:Trigger/Squeeze 取 0~1(写不写都行)
5.4.1 增加 action 成员(private)
XrAction actionTriggerValue_{XR_NULL_HANDLE};
XrAction actionSqueezeValue_{XR_NULL_HANDLE};
5.4.2 在 GetSuggestedBindings() 创建 action
actionTriggerValue_ = CreateAction(
actionSetWorld_,
XR_ACTION_TYPE_FLOAT_INPUT,
"trigger_value",
"Trigger Value",
2,
bothHands);
actionSqueezeValue_ = CreateAction(
actionSetWorld_,
XR_ACTION_TYPE_FLOAT_INPUT,
"squeeze_value",
"Squeeze Value",
2,
bothHands);
5.4.3 在 touch/touchPro bindings 里绑定到 value 路径
{actionTriggerValue_, "/user/hand/left/input/trigger/value"},
{actionSqueezeValue_, "/user/hand/left/input/squeeze/value"},
{actionTriggerValue_, "/user/hand/right/input/trigger/value"},
{actionSqueezeValue_, "/user/hand/right/input/squeeze/value"},
simpleBindings(手势/简单控制器)通常没有 trigger/squeeze value,这时候isActive=false,在 Update 里发 0 就行。
5.5 Update():xrLocateSpace + xrGetActionStateFloat → 填包
在读取 trigger/squeeze 之前,确保本帧已经对对应 action set 调用了 xrSyncActions(XrInput sample 已包含,改动时不要误删)。
5.5.1 先 locate pose(原本的 sample 中就有)
XrTime time = ToXrTime(in.PredictedDisplayTime);
OXR(xrLocateSpace(spaceGripRight_, mainReferenceSpace_, time, &locationGripRight_));
OXR(xrLocateSpace(spaceGripLeft_, mainReferenceSpace_, time, &locationGripLeft_));
OXR(xrLocateSpace(spaceMenuBeamLeft_, mainReferenceSpace_, time, &locationMenuBeamLeft_));
OXR(xrLocateSpace(spaceMenuBeamRight_, mainReferenceSpace_, time, &locationMenuBeamRight_));
重点:一定要用
PredictedDisplayTime,否则会凭空多出延迟。
5.5.2 判定 pose 是否有效(flags 用)
auto poseValid = [](const XrSpaceLocation& loc) {
const XrSpaceLocationFlags need =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
return (loc.locationFlags & need) == need;
};
5.5.3 读 trigger/squeeze(float 0~1)
auto lTrig = GetActionStateFloat(actionTriggerValue_, leftHandPath_);
auto rTrig = GetActionStateFloat(actionTriggerValue_, rightHandPath_);
auto lSq = GetActionStateFloat(actionSqueezeValue_, leftHandPath_);
auto rSq = GetActionStateFloat(actionSqueezeValue_, rightHandPath_);
5.5.4 组包 HandPacketV2 并写入 latestPkt_
HandPacketV2 pkt{};
pkt.magic = kMagicHPK2;
pkt.version = kVersion2;
pkt.ts_ns = NowNs();
if (poseValid(locationGripLeft_)) pkt.flags |= F_L_GRIP_VALID;
if (poseValid(locationGripRight_)) pkt.flags |= F_R_GRIP_VALID;
if (poseValid(locationMenuBeamLeft_)) pkt.flags |= F_L_AIM_VALID;
if (poseValid(locationMenuBeamRight_)) pkt.flags |= F_R_AIM_VALID;
auto toPose7 = [](const XrPosef& p) -> Pose7 {
return Pose7{
p.position.x, p.position.y, p.position.z,
p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w
};
};
pkt.l_grip = toPose7(locationGripLeft_.pose);
pkt.r_grip = toPose7(locationGripRight_.pose);
pkt.l_aim = toPose7(locationMenuBeamLeft_.pose);
pkt.r_aim = toPose7(locationMenuBeamRight_.pose);
pkt.l_trigger = lTrig.isActive ? lTrig.currentState : 0.0f;
pkt.r_trigger = rTrig.isActive ? rTrig.currentState : 0.0f;
pkt.l_squeeze = lSq.isActive ? lSq.currentState : 0.0f;
pkt.r_squeeze = rSq.isActive ? rSq.currentState : 0.0f;
{
std::lock_guard<std::mutex> lk(pktMtx_);
latestPkt_ = pkt;
}
5.6 GripPose 和 AimPose 的区别
我最终协议里传的是:
grip:spaceGripLeft_/Right_(/input/grip/pose)aim:spaceMenuBeamLeft_/Right_(/input/aim/pose)
原因:
- Grip:更适合“手里拿着的东西”
- Aim:更适合“射线指向/UI点击”
- 我刻意没用
cube_aim_pose当通用 aim,因为 sample 对 cube aim 做了 -5cm 偏移,发出去会让接收端“永远偏一截”
6. Linux/RK 端接收解析(C++ 示例,可直接跑)
下面给一个完整可编译的 udp_recv.cpp,支持当前 HandPacketV2:
编译:
g++ -O2 udp_recv.cpp -o udp_recv
运行:./udp_recv 9000
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#pragma pack(push, 1)
struct Pose7 {
float x, y, z;
float qx, qy, qz, qw;
};
struct HandPacketV2 {
uint32_t magic; // 'HPK2'
uint32_t version; // 2
uint64_t ts_ns;
uint32_t flags;
Pose7 l_grip;
Pose7 l_aim;
float l_trigger;
float l_squeeze;
Pose7 r_grip;
Pose7 r_aim;
float r_trigger;
float r_squeeze;
};
#pragma pack(pop)
static constexpr uint32_t kMagicHPK2 = 0x324B5048; // 'HPK2' little-endian
static constexpr uint32_t kVersion2 = 2;
enum : uint32_t {
F_L_GRIP_VALID = 1u << 0,
F_R_GRIP_VALID = 1u << 1,
F_L_AIM_VALID = 1u << 2,
F_R_AIM_VALID = 1u << 3,
};
int main(int argc, char** argv) {
int port = 9000;
if (argc >= 2) port = std::atoi(argv[1]);
int sock = ::socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
perror("socket");
return 1;
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons((uint16_t)port);
if (::bind(sock, (sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
printf("Listening UDP on 0.0.0.0:%d ...\n", port);
alignas(8) uint8_t buf[2048];
while (true) {
sockaddr_in src{};
socklen_t slen = sizeof(src);
ssize_t n = ::recvfrom(sock, buf, sizeof(buf), 0, (sockaddr*)&src, &slen);
if (n < 0) {
perror("recvfrom");
continue;
}
if (n < 8) continue;
uint32_t magic = 0, version = 0;
std::memcpy(&magic, buf + 0, 4);
std::memcpy(&version, buf + 4, 4);
if (magic == kMagicHPK2 && version == kVersion2) {
if (n != (ssize_t)sizeof(HandPacketV2)) {
printf("V2 size mismatch: got %zd, expected %zu\n", n, sizeof(HandPacketV2));
continue;
}
HandPacketV2 p{};
std::memcpy(&p, buf, sizeof(p));
char ip[64];
inet_ntop(AF_INET, &src.sin_addr, ip, sizeof(ip));
printf("\nFrom %s:%d ts=%llu flags=0x%08x\n",
ip, ntohs(src.sin_port),
(unsigned long long)p.ts_ns, p.flags);
printf("L grip pos(%.3f %.3f %.3f) trig=%.3f sq=%.3f valid=%d\n",
p.l_grip.x, p.l_grip.y, p.l_grip.z,
p.l_trigger, p.l_squeeze, (p.flags & F_L_GRIP_VALID) != 0);
printf("L aim pos(%.3f %.3f %.3f) valid=%d\n",
p.l_aim.x, p.l_aim.y, p.l_aim.z, (p.flags & F_L_AIM_VALID) != 0);
printf("R grip pos(%.3f %.3f %.3f) trig=%.3f sq=%.3f valid=%d\n",
p.r_grip.x, p.r_grip.y, p.r_grip.z,
p.r_trigger, p.r_squeeze, (p.flags & F_R_GRIP_VALID) != 0);
printf("R aim pos(%.3f %.3f %.3f) valid=%d\n",
p.r_aim.x, p.r_aim.y, p.r_aim.z, (p.flags & F_R_AIM_VALID) != 0);
fflush(stdout);
} else {
// ignore unknown packets
}
}
::close(sock);
return 0;
}
7. 我踩过的坑与排查清单
7.1 收不到 UDP
- Quest 端 Manifest 是否加了
INTERNET - IP/端口是否写对
- 是否同网段、路由可达
- Wi‑Fi AP 是否开启“客户端隔离”
- RK 端是否
bind(0.0.0.0:9000),而不是只 bind 某个错误网卡
7.2 Trigger/Squeeze 读不到 0~1
- 必须用
XR_ACTION_TYPE_FLOAT_INPUT - 绑定路径必须是
.../value(不是 click) - Update 里要检查
isActive,否则可能是“没输入源”而不是 0
7.3 Pose 看起来“漂”或者“突然归零”,这个前期不太能看出来,后面上rviz就能看出来了(quest内部的画面好像表现不出来)
- 一定要用
locationFlags判定 validity - 系统菜单/追踪丢失时 validity 会掉
7.4 Grip 和 Aim 别搞混了
- Grip 是拿在手里的姿态
- Aim 是射线姿态
- UI 射线建议用 aim
8. 还存在一些隐形问题
- 字段端序约定:目前 Quest/RK 多为 little-endian
- 增加 payload_len:接收端更容易兼容
- 发送频率跟随帧率:例如 72/90Hz(跟 Quest 刷新同步)
- 加入更多输入:摇杆(vector2)、A/B/X/Y、menu 点击等(同样做成 action + 协议字段)
更多推荐
所有评论(0)