平台:Quest(Android / Meta OpenXR Sample,C++)
目标:把左右手 Grip/Aim Pose + Trigger/Squeeze(0~1) 通过 UDP 发到局域网另一端(RK/PC Linux),接收端实时解析打印。

 

1. 我最终实现了什么

Quest 端每帧采样并通过 UDP 发出:

  • 左手:l_gripl_aiml_triggerl_squeeze
  • 右手:r_gripr_aimr_triggerr_squeeze
  • flags:标注 grip/aim pose 是否有效(追踪丢失时很有用)
  • ts_ns:单调时钟时间戳,方便延迟/抖动分析

UDP 发送频率:100Hz(10ms)
接收端:Linux recvfrom() 后解析并打印。


2. 开发路线(从网络测试到读出参数)

  1. 最小 UDP 通信测试
    先不碰 OpenXR,确认同网段、端口、AP 不隔离、Quest 端权限接通。

  2. 把 UDP 接入 OpenXR Sample(先发假数据)
    只验证线程/生命周期稳定:SessionInit() 启动、SessionEnd() 停止。

  3. 读取 Pose(Grip/Aim)并发出去
    xrLocateSpace 拿到 spaceGrip* + spaceMenuBeam* 的位姿。

  4. 读取 Trigger/Squeeze 的 float value(0~1)
    新增 XR_ACTION_TYPE_FLOAT_INPUT,绑定到 .../value 路径。

  5. 协议稳定化(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 的区别

我最终协议里传的是:

  • gripspaceGripLeft_/Right_/input/grip/pose
  • aimspaceMenuBeamLeft_/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. 还存在一些隐形问题

  1. 字段端序约定:目前 Quest/RK 多为 little-endian
  2. 增加 payload_len:接收端更容易兼容
  3. 发送频率跟随帧率:例如 72/90Hz(跟 Quest 刷新同步)
  4. 加入更多输入:摇杆(vector2)、A/B/X/Y、menu 点击等(同样做成 action + 协议字段)

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐