本文分两部分:
- OpenXR 示例工程:去掉 UI(面板/文字/按钮)等渲染,同时可选保留最小的射线/点击 hit-test 输入链路。
- 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():创建可视化对象的大头都可裁剪
-
工具/方块/模板方块:整段包起来
#if RENDER_TOOL_AND_CUBES
// toolGeometry / toolRenderer_ init
// templateCubeGeometry / templateCubeRenderer_ init
#endif -
UI 面板创建:只在
RENDER_UI_PANELS下执行(上文已给) -
手柄渲染器:保留
controllerRenderL_.Init(true);
controllerRenderR_.Init(false); -
光束 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 在同一网段/路由可达。
这只是做前期的准备,后续还需要做:
-
将RK3588上的摄像头数据传输到Quest2内部,并显示到一块2D平面上
-
对采集到的图像进行识别,标注出识别出来的物品
-
Quest可以用手柄点击识别到的物品将标志传回给RK3588