准备工作:OpenXR Sample 示例工程“去掉 UI 渲染”& RK3588→Windows 低延迟 UDP 视频链路

本文分两部分:

  1. OpenXR 示例工程:去掉 UI(面板/文字/按钮)等渲染,同时可选保留最小的射线/点击 hit-test 输入链路。
  2. RK3588(Orange Pi/Rockchip)上用 GStreamer 采集 USB 摄像头→硬编 H.264→RTP/UDP 推流,Windows 端用 FFplay 低延迟播放验证。

目标导向:先把"画面可控、渲染最小化、链路能跑通"做出来,再逐步加回需要的功能。


Part 1:OpenXR 示例工程------去掉 UI 等渲染

目标是:

  • 不渲染 UI(面板/文字/按钮不显示、不 draw)
  • 尽量不跑 UI 相关逻辑(或仅保留激光/射线所需的最小 hit-test 链路)

为此笔者做了"可编译裁剪"的 feature toggles,把 UI 从不可控的大块逻辑拆成多个开关。


1)顶部新增"渲染功能开关宏"(核心)

Main.cpp 顶部(include 后)新增:

复制代码
// ===== Render feature toggles =====
#define RENDER_UI_PANELS      0   // TinyUI labels/buttons panels
#define RENDER_TOOL_AND_CUBES 0   // toolRenderer_, cubeRenderer_, templateCubeRenderer_
#define RENDER_HAND_JOINTS    0   // hand joint cubes
#define RENDER_BEAM           1   // cursorBeamRenderer_ (laser)
#define KEEP_UI_LOGIC         1   // keep ui hit-test + click logic without rendering UI
// ==================================

含义(按当前逻辑):

  • RENDER_UI_PANELS=0不创建、不更新、不渲染 面板(CreateSampleDescriptionPanel / SetupActionUIPanels / SetupMenuPanels / UpdateUI / ui_.Render 全部会被裁剪)
  • KEEP_UI_LOGIC=1仍初始化 TinyUI ,维护 HitTestDevices,执行 ui_.Update(in),为 beam 提供命中/点击输入数据(即使没有任何可见 UI 组件)
  • RENDER_BEAM=1:仍画激光(通常依赖 ui_.HitTestDevices()
  • 其余开关分别控制 tool/cubes 与 hand joints 的渲染和逻辑

这一步是"去 UI"的关键:让 UI 成为可控的 feature,而不是难以拆分的耦合块。


2)AppInit():TinyUI 初始化从"总是 init"改为"按宏 init"

原本无条件 ui_.Init(...),改为:

复制代码
#if RENDER_UI_PANELS || KEEP_UI_LOGIC
    int fontVertexBufferSize = 32 * 1024;
    bool updateColors = true;
    if (false == ui_.Init(context, GetFileSys(), updateColors, fontVertexBufferSize)) {
        ALOG("TinyUI::Init FAILED.");
        return false;
    }
#endif

解释:

  • 如果未来把 KEEP_UI_LOGIC=0,TinyUI 将完全不初始化,也就不会引入 UI 的 hit-test/update/render。
  • 建议补齐对称性 :如果 ui_ 可能未 init,那么 AppShutdown()ui_.Shutdown() 也建议加宏保护(见后文 SessionEnd/AppShutdown 部分)。

3)SessionInit():UI 面板创建全部放到宏里

把:

  • CreateSampleDescriptionPanel();
  • SetupActionUIPanels();
  • SetupMenuPanels();

改成:

复制代码
#if RENDER_UI_PANELS
    CreateSampleDescriptionPanel();
    SetupActionUIPanels();
    SetupMenuPanels();
#elif KEEP_UI_LOGIC
    // 不创建任何可见面板/按钮,但 TinyUI 可继续用于 hit test/ray 输入链路
#endif

这一步是"去掉 UI 可见部分"的关键:

不创建 label/button/panel => 不会有 UI 物体,也不会有 UI drawcalls


4)Update():把 UI hit-test 与 UI 渲染更新拆开控制

把原先耦合在一起的流程拆开,并加宏:

  • ui_.HitTestDevices().clear()
  • UpdateHands(...)
  • ui_.AddHitTestRay(...)
  • ui_.Update(in)
  • cursorBeamRenderer_.Update(...)
  • UpdateUI(in)(更新 label 文本等)

当前结构等价于:

  • KEEP_UI_LOGIC=1:清空 hit-test 设备、添加 hit-test ray、执行 ui_.Update(in)
  • RENDER_BEAM=1:beam 的 update 依赖 ui_.HitTestDevices()
  • RENDER_UI_PANELS=1:才跑 UpdateUI(in)(更新文字标签)

推荐写法(示意):

复制代码
#if KEEP_UI_LOGIC
    ui_.HitTestDevices().clear();
#endif

#if RENDER_HAND_JOINTS
    if (supportsHandTracking_) {
        UpdateHands(in.PredictedDisplayTime);
    }
#endif

并保留控制器位姿更新(否则手柄不动):

复制代码
if ((locationGripLeft_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) {
    controllerRenderL_.Update(FromXrPosef(locationGripLeft_.pose));
}
if ((locationGripRight_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) {
    controllerRenderR_.Update(FromXrPosef(locationGripRight_.pose));
}
保留"按按钮 hit-test 链路"(无可见 UI)

如果希望仍保留射线/点击逻辑(即使不显示按钮),保留:

复制代码
#if KEEP_UI_LOGIC
    bool menuBeamActiveLeft = ActionPoseIsActive(actionMenuBeamPose_, leftHandPath_);
    if (menuBeamActiveLeft &&
        (locationMenuBeamLeft_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) &&
        (locationMenuBeamLeft_.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT)) {
        bool click = GetActionStateBoolean(actionSelect_, leftHandPath_).currentState;
        ui_.AddHitTestRay(FromXrPosef(locationMenuBeamLeft_.pose), click);
    }

    bool menuBeamActiveRight = ActionPoseIsActive(actionMenuBeamPose_, rightHandPath_);
    if (menuBeamActiveRight &&
        (locationMenuBeamRight_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) &&
        (locationMenuBeamRight_.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT)) {
        bool click = GetActionStateBoolean(actionSelect_, rightHandPath_).currentState;
        ui_.AddHitTestRay(FromXrPosef(locationMenuBeamRight_.pose), click);
    }

    ui_.Update(in);
#endif

说明:如果把所有 AddButton(...) 都禁用了,那么即使射线和 click 在跑,也不会触发任何按钮回调------这很正常。如果是希望"不可见但可点击",TinyUI 多数实现是"创建即渲染",不天然支持"逻辑按钮"。更工程化的做法是:另做一个纯逻辑的 hit-test target(AABB/平面),与渲染解耦。

Beam update 受控
复制代码
#if RENDER_BEAM
    cursorBeamRenderer_.Update(in, ui_.HitTestDevices());
#endif

以及 UI 文本更新只在需要渲染面板时做:

复制代码
#if RENDER_UI_PANELS
    UpdateUI(in);
#endif

5)Render():UI 渲染与其他对象渲染全部宏包起来

UI 渲染改为:

复制代码
#if RENDER_UI_PANELS
    ui_.Render(in, out);
#endif

tool/cube/hand joints 同理宏裁剪。


6)Beam:保持"透明物体最后渲染"的顺序

复制代码
#if RENDER_BEAM
    cursorBeamRenderer_.Render(in, out);
#endif

激光往往使用 alpha blending,放到最后更符合渲染顺序与深度混合预期。


7)按函数分组的"注释/保留"清单(便于对照)

A)AppInit(...):UI 初始化受宏控制(上文已给)

建议:AppShutdown() 里对 ui_.Shutdown() 也用相同宏保护,避免未 init 时调用不安全实现。

B)SessionInit():创建可视化对象的大头都可裁剪
  1. 工具/方块/模板方块:整段包起来

    #if RENDER_TOOL_AND_CUBES
    // toolGeometry / toolRenderer_ init
    // templateCubeGeometry / templateCubeRenderer_ init
    #endif

  2. UI 面板创建:只在 RENDER_UI_PANELS 下执行(上文已给)

  3. 手柄渲染器:保留

    controllerRenderL_.Init(true);
    controllerRenderR_.Init(false);

  4. 光束 renderer:受控

    #if RENDER_BEAM
    cursorBeamRenderer_.Init(GetFileSys(), nullptr, OVR::Vector4f(1.0f), 1.0f);
    #endif

C)Update(...):输入逻辑保留,但生成/更新可视化对象的逻辑裁剪
  • UpdateHands() 只在 RENDER_HAND_JOINTS 下运行(省 CPU)
  • 工具/方块/模板的拾取/放置/旋转缩放:建议用 RENDER_TOOL_AND_CUBES 整段禁用,避免占用 trigger/squeeze 等动作
D)Render(...):只画手柄,其他全关即可达目标

示意结构:

复制代码
virtual void Render(const OVRFW::ovrApplFrameIn& in, OVRFW::ovrRendererOutput& out) override {

#if RENDER_UI_PANELS
    ui_.Render(in, out);
#endif

#if RENDER_TOOL_AND_CUBES
    toolRenderer_.Render(out.Surfaces);
    cubeRenderer_.Render(out.Surfaces);
    templateCubeRenderer_.Render(out.Surfaces);
#endif

    if ((locationGripLeft_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) {
        controllerRenderL_.Render(out.Surfaces);
    }
    if ((locationGripRight_.locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0) {
        controllerRenderR_.Render(out.Surfaces);
    }

#if RENDER_HAND_JOINTS
    if (supportsHandTracking_) {
        for (...) { /* handJointRenderers Render */ }
    }
#endif

#if RENDER_BEAM
    cursorBeamRenderer_.Render(in, out);
#endif
}
E)SessionEnd():Shutdown 必须与 Init 对齐(非常关键)

避免"未 Init 的 renderer 仍被 Shutdown":

复制代码
controllerRenderL_.Shutdown();
controllerRenderR_.Shutdown();

#if RENDER_BEAM
    cursorBeamRenderer_.Shutdown();
#endif

#if RENDER_TOOL_AND_CUBES
    toolRenderer_.Shutdown();
    cubeRenderer_.Shutdown();
    templateCubeRenderer_.Shutdown();
#endif

#if RENDER_HAND_JOINTS
    if (supportsHandTracking_) {
        OXR(xrDestroyHandTrackerEXT_(handTrackerL_));
        OXR(xrDestroyHandTrackerEXT_(handTrackerR_));
        for (...) { /* handJointRenderers Shutdown */ }
    }
#endif

8)背景颜色改为纯色(可选)

新增宏:

复制代码
// ---- Background toggles ----
#define BG_MODE_SOLID 1
#define BG_SOLID_R 0.0f
#define BG_SOLID_G 0.0f
#define BG_SOLID_B 0.0f
// ----------------------------

在构造函数中(搜索 BackgroundColor):

复制代码
XrInputSampleApp() : OVRFW::XrApp() {
#if BG_MODE_SOLID
    BackgroundColor = OVR::Vector4f(BG_SOLID_R, BG_SOLID_G, BG_SOLID_B, 1.0f);
#else
    BackgroundColor = OVR::Vector4f(0.0f, 0.0f, 0.0f, 1.0f);
#endif
    SkipInputHandling = true;
}

Part 2:跑通 UDP 传输视频数据链路(RK3588 → Windows)

目标:USB 摄像头(UVC)→ RK3588(GStreamer)采集/编码 → UDP 发出,PC 端低延迟播放验证网络与编码正确:

  • 摄像头采集正常(V4L2/UVC OK)
  • GStreamer caps 协商正确
  • 解码 MJPEG 正常
  • RK 上 Rockchip MPP H.264 硬编正常
  • RTP over UDP 推流正常
  • PC 接收端未被网络/防火墙/路由隔离阻断

1)Linux 下 USB 摄像头为什么会出现多个设备节点?

  • /dev/video0
  • /dev/video1
  • /dev/media0

含义分别是:

/dev/video0:Video Capture(画面节点)

最常用的节点,用于拿到实际帧数据。v4l2-ctl -d /dev/video0 --all 会显示:

  • Video Capture
  • 当前 Pixel Format : 'MJPG'
  • 当前分辨率/帧率等
/dev/video1:Metadata Capture(元数据节点)

常见输出:

  • Format Metadata Capture: 'UVCH' (UVC Payload Header Metadata)

这不是画面,是 UVC 帧头/统计/控制相关的元数据。推流/显示通常不需要它。

/dev/media0:Media Controller(媒体拓扑)

用于描述实体/Pad/Link 拓扑,复杂相机/ISP 更常用。
v4l2-ctl -d /dev/media0 --all 可能不合适,改用:

复制代码
media-ctl -d /dev/media0 -p

2)如何获取摄像头能力(格式/分辨率/帧率)?

Step A:列设备
复制代码
v4l2-ctl --list-devices

用途:确认摄像头对应哪些 /dev/videoX,以及是不是同一设备暴露出多个节点。

Step B:看某节点当前状态与控制项
复制代码
v4l2-ctl -d /dev/video0 --all

用途:

  • 当前正在使用的格式(不代表全部能力)
  • 当前分辨率/帧率(可能是默认值)
  • 亮度/曝光/白平衡等控制项
Step C:枚举全部支持的格式/分辨率/帧率(最关键)
复制代码
v4l2-ctl -d /dev/video0 --list-formats-ext

这一步决定能否写死 caps

  • MJPG 支持的分辨率/帧率档位多(如 720p/60、1080p/30 等)
  • YUYV 往往帧率很低(例如 720p 只有 7.5fps)

因此优先使用 MJPG 作为采集格式(省带宽/帧率更高)。


3)为什么会报 not-negotiated (-4)

遇到报错:

  • Internal data stream error ... reason not-negotiated (-4)
"协商(negotiation)"是什么?

GStreamer 是拼管道的:每个 element 的 pad 需要同意一套 caps,例如:

  • 这是 image/jpeg 还是 video/x-raw

  • 宽高是多少?

  • 帧率是多少?

  • 像素格式是 NV12/I420/RGB

    v4l2src ! image/jpeg,width=1280,height=720,framerate=30/1 ! ...

相当于强制要求摄像头必须输出 720p30 的 MJPG

但枚举到的能力在 1280×720 可能只有:

  • 60fps(0.017s)

所以 30/1 会导致 v4l2src 申请不到该模式,从源头 caps 就协商失败,最终 not-negotiated

fakesink 验证
复制代码
gst-launch-1.0 -v \
  v4l2src device=/dev/video0 io-mode=2 ! image/jpeg,width=1280,height=720,framerate=60/1 ! \
  fakesink

fakesink 是"黑洞接收器":不显示、不处理,只用于验证前面是否能产出数据、caps 是否协商成功。
-v 会打印 negotiated caps(证明问题在 v4l2src 输出模式请求,而非后面的解码/编码)。


4)最终推流管道:每个环节含义

最终指令如下(RK3588 端):

复制代码
gst-launch-1.0 -v \
  v4l2src device=/dev/video0 io-mode=2 ! image/jpeg,width=1280,height=720,framerate=60/1 ! \
  jpegdec ! videoconvert ! video/x-raw,format=NV12 ! \
  mpph264enc ! h264parse config-interval=1 ! \
  rtph264pay pt=96 config-interval=1 ! \
  udpsink host=192.168.10.236 port=5004 sync=false async=false

下面逐段解释:

1)v4l2src:从 /dev/video0 抓取 MJPG 压缩流
复制代码
v4l2src device=/dev/video0 io-mode=2 ! image/jpeg, ...
  • device=/dev/video0:选画面节点
  • io-mode=2:指定 V4L2 I/O 模式(平台相关;在某些驱动上某些模式更稳/更快。)
  • image/jpeg,...:声明下游收到的是 MJPG,并强制宽高/帧率与设备能力匹配
2)jpegdec(或 mppjpegdec):把 MJPG 解成 raw
复制代码
jpegdec

image/jpeg 解成 video/x-raw。也可以用 mppjpegdec 走硬解省 CPU,但当时的关键问题发生在 v4l2src 协商阶段,换解码器不影响结论。

3)videoconvert + 指定 NV12:整理像素格式给硬编吃
复制代码
videoconvert ! video/x-raw,format=NV12

不同解码器输出可能是 I420/YUY2/NV12

硬编(尤其 MPP)常对输入格式更挑剔,NV12 通常兼容性更好,因此显式转换更稳。

4)mpph264enc:Rockchip MPP 硬件 H.264 编码
复制代码
mpph264enc
  • 相比 x264enc(CPU 软编),在 RK3588 上硬编更合适:CPU 占用低、延迟更可控。
  • 如果遇到 x264enc no element 说明插件缺失;而系统已有 mpph264enc 是更优解。
5)h264parse:整理码流,周期性输出 SPS/PPS
复制代码
h264parse config-interval=1

H.264 初始化需要 SPS/PPS。config-interval=1 通常表示周期性插入参数集(具体行为也与下游有关),对实时流、接收端中途加入、抗丢包更友好。

6)rtph264pay:封装成 RTP
复制代码
rtph264pay pt=96 config-interval=1
  • pt=96:动态 payload type(H264 常用 96)
  • 按 RTP 规则把 NAL 单元打包(含分片 FU-A 等)
7)udpsink:UDP 发往 PC
复制代码
udpsink host=192.168.10.236 port=5004 sync=false async=false
  • sync=false:尽快发送,不按时钟"卡点"同步(低延迟常用)
  • async=false:减少状态切换等待,让管道更直接进入播放

5)Windows 端:用 FFplay + SDP 低延迟播放

如果 Windows 还没装 ffplay,先安装 FFmpeg(含 ffplay)。

创建 recv.sdp(内容如下):

复制代码
v=0
o=- 0 0 IN IP4 0.0.0.0
s=H264 RTP
c=IN IP4 0.0.0.0
t=0 0
m=video 5004 RTP/AVP 96
a=rtpmap:96 H264/90000

在sdp文件的文件夹下播放(PowerShell):

复制代码
ffplay -fflags nobuffer -flags low_delay -framedrop -protocol_whitelist file,udp,rtp recv.sdp

低延迟提示:

  • -fflags nobuffer/-flags low_delay 会明显降低缓冲,但网络抖动时更容易卡顿或花屏;这是"延迟 vs 抗抖动"的取舍。
  • 若收不到数据,优先检查 Windows 防火墙放行 UDP 5004,且 RK 与 PC 在同一网段/路由可达。

这只是做前期的准备,后续还需要做:

  1. 将RK3588上的摄像头数据传输到Quest2内部,并显示到一块2D平面上

  2. 对采集到的图像进行识别,标注出识别出来的物品

  3. Quest可以用手柄点击识别到的物品将标志传回给RK3588

相关推荐
王老师青少年编程2 小时前
2021年信奥赛C++提高组csp-s初赛真题及答案解析(阅读程序第2题)
c++·题解·真题·初赛·信奥赛·csp-s·提高组
生活很暖很治愈2 小时前
Linux——线程异常
linux·c++·ubuntu
市安2 小时前
基于Centos构建Nginx镜像(Dokerfile)
linux·运维·nginx·docker·容器·centos·镜像
生活很暖很治愈2 小时前
Linux——线程概念&控制&创建&等待
linux·服务器·c++·ubuntu
PPPPPaPeR.2 小时前
深入理解 Linux 文件系统:元数据、inode 与 block 核心原理
linux·运维·服务器
czxyvX2 小时前
006-Linux第一个小程序-进度条-初步理解缓冲区
linux
袁袁袁袁满2 小时前
Linux防火墙UFW和宝塔显示不同步问题如何解决?
linux·运维·服务器·宝塔·防火墙ufw
悲伤小伞2 小时前
Linux_传输层协议Udp
linux·网络协议·udp