总体流程:
- 局域网 RTP/H264 over UDP(5004)接收
- MediaCodec 硬解输出到 SurfaceTexture
- OES 外部纹理每帧更新
- OES → 2D 纹理 blit
- OVRFW 画到 XR 面板上
- 面板挂在 VIEW space 上跟着头走(HUD 感)
0. 端到端链路
(最终版的过程,整个流程非常曲折,笔者也并非一开始就想到这样的流程,最开始想的是划分一个范围,直接将UDP视频流解码后填充进去即可,可惜Quest开发对于UDP编码没有那么轻松,而且画图也不像一般的GUI那样直接用一个句柄就能创建一个背景,最后整的特别复杂)
发送端(上一篇末尾的已经和PC做UDP通信跑通):
- RK3588 / Linux:GStreamer
mpph264enc ! rtph264pay ! udpsink port=5004
一键指令:
bash
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.65 port=5004 sync=false async=false
Quest 端:
RtpH264Receiver(C++ 网络线程)接收 RTP 包- depay:支持 Single / STAP-A / FU-A → 组装成 Annex-B NAL (
00 00 00 01前缀) - Render 线程:每帧 Pop 多个 NAL,批量喂给
AndroidVideoDecoder::QueueH264(...) - Java
VideoDecoder.queue(...)→MediaCodec解码,输出到Surface(SurfaceTexture) - Render 线程:
AndroidVideoDecoder::UpdateTexImage()→ JavaupdateTexImageIfAvailable()→SurfaceTexture.updateTexImage()推进到 OES external texture VideoPanelRenderer:raw GLES3 shader 做 OES → RGBA 2D(FBO blit)OVRFW::GlProgram+ovrDrawSurface:用sampler2D画到面板上XR_REFERENCE_SPACE_TYPE_VIEW定位 → 面板跟随头显(HUD)
概念图一图流:
RTP/UDP(5004)
↓
RtpH264Receiver (C++ depay, drop for low-latency)
↓ Annex-B NALs
AndroidVideoDecoder::QueueH264 (JNI -> VideoDecoder.queue)
↓
MediaCodec -> Surface(SurfaceTexture)
↓
updateTexImage() -> OES external texture updated
↓
VideoPanelRenderer: OES blit -> RGBA tex2D (FBO)
↓
OVRFW panel draw (sampler2D)
↓
OpenXR VIEW space HUD panel
1. 狠狠黑屏,无论如何都没有任何输出
最开始的典型现象:
- 渲染循环在跑
- 看起来 shader 也"写了"
- FBO 也"创建了"
- 面板上永远黑
创建四个工具:
GLCheck(where):抓0x502 GL_INVALID_OPERATION一类隐藏错误CompileShader / LinkProgram:shader 编译日志必须打印完整源SurfaceTexture.getTimestamp():判断 updateTexImage 是否真的拿到新帧RtpH264Receiver的rtpPackets/nalsOut/drops计数:判断网络与 depay 是否在出"完整 NAL"- 每 60/120 帧的节奏日志:够用但不刷屏
2. 第一刀:紫色测试(证明"面板链路"是通的)
第一步是怀疑"到底是解码没帧还是渲染没画",试错方案是:在 blit FBO 上清屏成紫色。
在 VideoPanelRenderer::BlitOesTo2D_() 里加:
glBindFramebuffer(GL_FRAMEBUFFER, blitFbo_);
glViewport(0, 0, blitW_, blitH_);
glClearColor(1.0f, 0.0f, 1.0f, 1.0f); // 紫色
glClear(GL_COLOR_BUFFER_BIT);
只要面板能看到紫色,立刻说明:
- FBO 可用、viewport 正常
- panel 的
sampler2D -> OVRFW -> XR这条链没问题 - 黑屏一定在 OES 更新 / OES blit / 解码输出 / NAL 输入 这几层
3. 第二刀:GLES3 中的 client-side vertex array 直接让 draw 失效
症状
- 以为 glDrawElements 画了
- 但画面没变化
- 打
glGetError(),出现0x502 GL_INVALID_OPERATION(通常在 draw 前后)
根因(Quest + GLES3 非常常见)
在 GLES3 环境下(尤其配合 VAO),很多"client-side arrays(CPU 指针)"的用法会直接非法,导致 draw 不执行。
解决方法:ES3 合规 VAO/VBO/IBO
EnsureBlitMesh_():
glGenVertexArrays(1, &blitVao_);
glBindVertexArray(blitVao_);
glGenBuffers(1, &blitVbo_);
glBindBuffer(GL_ARRAY_BUFFER, blitVbo_);
glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
glGenBuffers(1, &blitIbo_);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, blitIbo_);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5*sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5*sizeof(float), (void*)(3*sizeof(float)));
在关键点代码添加:
GLCheck("EnsureBlitMesh end");
这样就能明确知道 draw 有没有被 GL 状态拒绝。
4. 第三刀:shader 编译问题------#extension 必须紧跟 #version,但 OVRFW 会注入头部
这个坑非常阴间:明明检查了扩展存在,但 shader 仍然编不过。
典型报错
Extension directives must occur before any non-preprocessor tokenssamplerExternalOES requires extension GL_OES_EGL_image_external_essl3
笔者甚至打印了:
Has GL_OES_EGL_image_external_essl3: 1
扩展存在,但编译失败。
根因
OVRFW 的 GlProgram::Build 会在 shader 源码前面注入一些头(为了 multiview、UBO、TransformVertex 等)。
因此这个部分:
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
在"最终送进编译器的完整源码"里不再是第一行/第二行了,导致 extension 指令位置违规。
解决方案:OES blit shader 不走 OVRFW,用 raw GL 编译链接
- Panel 仍走 OVRFW(因为需要
TransformVertex等注入) - OES blit 完全自己掌控:raw shader +
CompileShader/LinkProgram
raw FS:
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
uniform samplerExternalOES uTex;
以及 C++ 编译:
GLuint vs = CompileShader(GL_VERTEX_SHADER, OesBlitVS_Raw, "OES Blit VS");
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, OesBlitFS_Raw, "OES Blit FS");
oesBlitProg_ = LinkProgram(vs, fs, "OES Blit Program");
这一步本质是"跨框架边界",注意:
- 框架擅长画面板
- 框架不擅长处理 OES extension 规则
5. 第四刀:OVRFW PanelProgram 的 uniform/UBO 重定义(撞注入)
遇到:
ModelMatrix / SceneMatrices / ViewID redefinition
根因很简单:OVRFW 可能已经注入了这些定义,重复再写一次就炸。代码很混乱,忘记了之前写过这个板块
修改了一下:PanelVS 用框架契约(只写 attribute/varying,变换用 TransformVertex)
PanelVS :
in vec3 Position;
in vec2 TexCoord;
out vec2 vUV;
void main() {
gl_Position = TransformVertex(vec4(Position, 1.0));
vUV = TexCoord;
}
经验总结:
- Panel pass:越想自己控制矩阵越容易撞框架
- Blit pass:越应该完全自己控制(raw GL + 自己的 program)
6. 第五刀:SurfaceTexture/attachToGLContext 中跨线程/跨 EGLContext
反直觉现象
日志中明明是:
bind EXTERNAL err=0x0(外部纹理 bind OK)bind 2D err=0x502(说明这个 texId 被当作 external)
这其实说明 texId 是有效的、目标也匹配。
但 SurfaceTexture.attachToGLContext(texId) 仍可能失败:
- attach 调用发生在 不是当前 EGLContext 的线程
- XR/OVRFW 内部切换了 context(attach 时不是同一个)
- attach/detach 时机不对(ST 或 codec 状态还没稳定)
彻底摆烂方案(真没辙了):把"创建纹理 + attach"放到 Java 端一口气做完
现在 VideoDecoder.createOesTexAndAttach():
glGenTextures...
glBindTexture(GL_TEXTURE_EXTERNAL_OES, texId);
...set params...
surfaceTexture.detachFromGLContext();
surfaceTexture.attachToGLContext(texId);
return texId;
然后 native 的 AndroidVideoDecoder::CallJavaInit() 拿回 oesTexId_:
env->CallVoidMethod(decoderGlobal_, midInit_, width, height);
jint texId = env->CallIntMethod(decoderGlobal_, midCreateOesTexAndAttach_);
oesTexId_ = (GLuint)texId;
这样做的关键收益:
- "创建 external texture + 设置参数 + attach"在 同一语言层(Java) 连续完成
- 把跨层跨线程的不确定性砍掉一大截,主要是实在不知道为啥会这样
- native 侧只需要持有 texId,然后做 blit
注意:现在的
VideoPanelRenderer里还保留了attachToGL的逻辑(但对象引用被注释了,实际不会触发)。后续不知道是否有用处,需要引用代码的同好注意一下
7. 第六刀:frameAvailable gate 让 tsNs 永远为 0
这是"终极黑屏"的经典症状之一:
- queue 在跑
- codec 也 releaseOutputBuffer(render=true)
- 但
SurfaceTexture.getTimestamp()永远 0 - 画面永远黑
摆烂方案:直接把 gate 关掉。
// if (!frameAvailable) return 0;
// frameAvailable = false;
surfaceTexture.updateTexImage();
return surfaceTexture.getTimestamp();
为什么 frameAvailable gate 会害死人?
- 只要
onFrameAvailable因某些设备/时序不触发,就永远不 update - 永远不 update → OES 纹理永远是初始黑
- blit 再正确也只能 blit 黑
所以调试期最正确的策略就是:无条件 updateTexImage,并用 timestamp/异常来判断是否有帧。
下一步更稳的做法:保留 gate 但加"看门狗"。例如连续 N 帧 tsNs=0 时强制 update 或重建 codec/ST。
8. 第七刀:原本以为在喂 H264,其实喂的是碎片------用 RTP depay
早期随缘拼包的实现最常见问题是:
- 喂给 MediaCodec 的不是完整 NAL
- 缺了 start code / 缺了 SPS PPS / FU-A 没组装
- 结果 codec 不报错但也不出帧(黑屏)
最后 RtpH264Receiver 做了的 depay:
支持三种最常见 payload:
- Single NALU (type 1..23)
- STAP-A (type 24)
- FU-A (type 28)
并且是低延迟策略:
- seq 不连续 → 当前 FU 组包直接断掉(不等待,不重传)
- 队列满了丢旧的(宁可跳帧也不堆积延迟)
关键实现点(FU-A 重建 NAL header + Annex-B 前缀):
const uint8_t reconstructedNalHdr = nalF | nalNri | origNalType;
if (start) {
fuBuf_.push_back(0);fuBuf_.push_back(0);fuBuf_.push_back(0);fuBuf_.push_back(1);
fuBuf_.push_back(reconstructedNalHdr);
...
}
只要 nalsOut 在涨,就知道喂给 codec 的一定是 完整 NAL。
9. 最后一关:面板"偏上/不跟随"不是渲染,是 reference space(VIEW space)
当视频终于显示之后,还遇到一个看着像渲染 bug的问题:
- 面板显示了,但位置偏上
- 转头不跟随(或者跟随不对)
这不是 shader、也不是纹理,是 pose 空间选错了。
在 SessionInit() 创建了 VIEW space:
ci.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
xrCreateReferenceSpace(..., &viewSpace_);
然后在 Render() 每帧 locate:
xrLocateSpace(viewSpace_, mainReferenceSpace_, predictedTime, &loc);
OVR::Posef head = FromXrPosef(loc.pose);
forward = head.Rotation.Rotate({0,0,-1});
pos = head.Translation + forward * dist + {0,-down,0};
videoPanel_.SetPose({head.Rotation, pos});
10. 代码结构解剖
下面按真实数据流从外到内拆解:每层做什么、输入输出是什么、靠哪些代码把这层钉死
10.1 契约层:Quest 端到底在接什么、输出什么
接收端明确约定:
- 网络:UDP
- 封装:RTP/H264
- 端口:5004
- 送入 MediaCodec 的数据格式 :Annex-B (
00 00 00 01+ NAL)
这个"Annex-B 约定"在 RtpH264Receiver 里被硬编码成真正的输出格式:
std::vector<uint8_t> RtpH264Receiver::Depay_::MakeAnnexB_(const uint8_t* nal, int nalLen) {
std::vector<uint8_t> out;
out.reserve(4 + nalLen);
out.push_back(0);
out.push_back(0);
out.push_back(0);
out.push_back(1);
out.insert(out.end(), nal, nal + nalLen);
return out;
}
10.2 Layer 1:网络接收 + RTP 解析 + H264 depay(RtpH264Receiver)
10.2.1 独立网络线程,不阻塞渲染
启动 socket + bind + thread:
bool RtpH264Receiver::Start() {
if (running_.exchange(true)) return true;
sock_ = ::socket(AF_INET, SOCK_DGRAM, 0);
if (sock_ < 0) {
running_ = false;
return false;
}
int rcvbuf = cfg_.recvBufBytes;
::setsockopt(sock_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(cfg_.port);
if (::bind(sock_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
::close(sock_);
sock_ = -1;
running_ = false;
return false;
}
th_ = std::thread([this]() { ThreadMain_(); });
return true;
}
线程主循环 recvfrom:
void RtpH264Receiver::ThreadMain_() {
Depay_ depay;
std::vector<uint8_t> buf((size_t)cfg_.udpReadBytes);
std::vector<std::vector<uint8_t>> outNals;
while (running_.load()) {
ssize_t n = ::recvfrom(sock_, buf.data(), buf.size(), 0, nullptr, nullptr);
if (n <= 0) {
if (!running_.load()) break;
continue;
}
rtpPackets_.fetch_add(1, std::memory_order_relaxed);
auto rtp = ParseRtp_(buf.data(), (int)n);
if (!rtp.ok) continue;
if (depay.Push(rtp, outNals)) {
std::lock_guard<std::mutex> lk(mtx_);
for (auto& nal : outNals) {
while (q_.size() >= cfg_.maxQueue) {
q_.pop_front();
drops_.fetch_add(1, std::memory_order_relaxed);
}
q_.push_back(std::move(nal));
nalsOut_.fetch_add(1, std::memory_order_relaxed);
}
}
}
}
这里做了两件低延迟工程:
- 队列满了丢旧的:
while (q_.size() >= cfg_.maxQueue) q_.pop_front(); - 网络线程只做"组装 + 入队",绝不碰渲染/GL
10.2.2 RTP header 解析:跳过 CSRC / extension,拿到 payload
RtpH264Receiver::RtpPacketView RtpH264Receiver::ParseRtp_(const uint8_t* buf, int len) {
RtpPacketView v{};
if (len < 12) return v;
const uint8_t b0 = buf[0];
const uint8_t b1 = buf[1];
const int version = (b0 >> 6) & 0x3;
const int cc = (b0 & 0x0F);
const bool x = (b0 & 0x10) != 0;
if (version != 2) return v;
int off = 12 + cc * 4;
if (off > len) return v;
v.marker = (b1 & 0x80) != 0;
v.seq = ReadBE16(buf + 2);
v.ts = ReadBE32(buf + 4);
if (x) {
if (off + 4 > len) return v;
uint16_t extLenWords = ReadBE16(buf + off + 2);
off += 4 + int(extLenWords) * 4;
if (off > len) return v;
}
v.payload = buf + off;
v.payloadLen = len - off;
v.ok = (v.payloadLen > 0);
return v;
}
这段是只取必需字段的正确姿势:对 RTP 扩展兼容,但不会让解析复杂化。
10.2.3 depay:Single / STAP-A / FU-A + seq 不连续立刻断包
最关键的低延迟策略:seq 不连续直接放弃当前 FU:
if (haveSeq_) {
uint16_t expect = uint16_t(lastSeq_ + 1);
if (rtp.seq != expect) {
fuActive_ = false;
fuBuf_.clear();
}
}
haveSeq_ = true;
lastSeq_ = rtp.seq;
Single NAL:
if (nalType >= 1 && nalType <= 23) {
outNals.push_back(MakeAnnexB_(p, n));
return true;
}
STAP-A:
if (nalType == 24) {
int off = 1;
while (off + 2 <= n) {
int size = (p[off] << 8) | p[off + 1];
off += 2;
if (size <= 0 || off + size > n) break;
outNals.push_back(MakeAnnexB_(p + off, size));
off += size;
}
return !outNals.empty();
}
FU-A 重建 NAL header + 拼接 + end 输出:
if (nalType == 28) {
if (n < 2) return false;
const uint8_t fuIndicator = p[0];
const uint8_t fuHeader = p[1];
const bool start = (fuHeader & 0x80) != 0;
const bool end = (fuHeader & 0x40) != 0;
const uint8_t origNalType = fuHeader & 0x1F;
const uint8_t nalF = fuIndicator & 0x80;
const uint8_t nalNri = fuIndicator & 0x60;
const uint8_t reconstructedNalHdr = nalF | nalNri | origNalType;
const uint8_t* frag = p + 2;
int fragLen = n - 2;
if (start) {
fuBuf_.clear();
fuBuf_.reserve(size_t(4 + 1 + fragLen + 1024));
fuBuf_.push_back(0); fuBuf_.push_back(0); fuBuf_.push_back(0); fuBuf_.push_back(1);
fuBuf_.push_back(reconstructedNalHdr);
fuBuf_.insert(fuBuf_.end(), frag, frag + fragLen);
fuActive_ = true;
} else {
if (!fuActive_) return false;
fuBuf_.insert(fuBuf_.end(), frag, frag + fragLen);
}
if (fuActive_ && end) {
outNals.push_back(std::move(fuBuf_));
fuBuf_.clear();
fuActive_ = false;
return true;
}
return false;
}
10.2.4 渲染线程取 NAL:批量 Pop(限制一次最多取多少)
int RtpH264Receiver::PopNals(std::vector<std::vector<uint8_t>>& out) {
out.clear();
std::lock_guard<std::mutex> lk(mtx_);
const int maxTake = cfg_.maxNalPerPop;
int take = 0;
while (!q_.empty() && take < maxTake) {
out.push_back(std::move(q_.front()));
q_.pop_front();
++take;
}
return take;
}
输出是 vector<vector<uint8_t>>:每个都是 完整 Annex-B NAL。
10.3 Layer 2:JNI 桥与线程附着(AndroidVideoDecoder)
这层做两件事:
- 在 C++ 层绑定 Java
VideoDecoder实例和方法 ID - 提供可跨线程调用的
QueueH264 / UpdateTexImage
10.3.1 解决"非 Java 线程调用 JNI":AttachCurrentThread
JNIEnv* AndroidVideoDecoder::GetEnvForThread(bool* didAttach) {
*didAttach = false;
if (!vm_) return nullptr;
JNIEnv* env = nullptr;
jint getEnvRes = vm_->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
if (getEnvRes == JNI_OK) return env;
if (getEnvRes == JNI_EDETACHED) {
if (vm_->AttachCurrentThread(&env, nullptr) == JNI_OK) {
*didAttach = true;
return env;
}
return nullptr;
}
return nullptr;
}
可调用但不越界:每次调用后都会按需 DetachCurrentThread()。
10.3.2 绑定 Java 对象:从 MainActivity.videoDecoder 字段拿实例
MainActivity 里保证字段永远存在:
public final VideoDecoder videoDecoder = new VideoDecoder();
C++ 侧通过 FieldID 获取:
fidVideoDecoder_ = env->GetFieldID(mainActivityClass_, "videoDecoder",
"Lcom/oculus/xrsamples/xrinput/VideoDecoder;");
...
jobject decoderLocal = env->GetObjectField(activity, fidVideoDecoder_);
...
decoderGlobal_ = env->NewGlobalRef(decoderLocal);
并缓存 methodID:
midInit_ = env->GetMethodID(videoDecoderClass_, "init", "(II)V");
midCreateOesTexAndAttach_ = env->GetMethodID(videoDecoderClass_, "createOesTexAndAttach", "()I");
midQueue_ = env->GetMethodID(videoDecoderClass_, "queue", "([BIJ)Z");
midUpdateTexImageIfAvail_ =
env->GetMethodID(videoDecoderClass_, "updateTexImageIfAvailable", "()J");
JNI 稳定性的核心:不用 singleton、不用反射,对象定位非常确定。
10.3.3 初始化时序:Render(GL) 线程中 init + createAttach,一次拿到 oesTexId
bool AndroidVideoDecoder::CallJavaInit(int width, int height) {
bool didAttach = false;
JNIEnv* env = GetEnvForThread(&didAttach);
if (!env) return false;
env->CallVoidMethod(decoderGlobal_, midInit_, (jint)width, (jint)height);
...
jint texId = env->CallIntMethod(decoderGlobal_, midCreateOesTexAndAttach_);
...
oesTexId_ = (GLuint)texId;
ALOGI("createOesTexAndAttach returned oesTexId=%u", oesTexId_);
if (didAttach) vm_->DetachCurrentThread();
return oesTexId_ != 0;
}
在 Init(...) 里强调"必须在 GL context 已就绪的线程调用",并且最终确实在 main.cpp::Render 里做了(后面会贴)。
10.3.4 每帧更新 ST → OES:UpdateTexImage
void AndroidVideoDecoder::UpdateTexImage() {
if (!ready_) return;
bool didAttach = false;
JNIEnv* env = GetEnvForThread(&didAttach);
if (!env) return;
jlong tsNs = env->CallLongMethod(decoderGlobal_, midUpdateTexImageIfAvail_);
...
static int c = 0;
if ((++c % 120) == 0) {
ALOGI("video frame tsNs=%lld", (long long)tsNs);
}
if (didAttach) vm_->DetachCurrentThread();
}
注意这里"即使 tsNs=0 也打日志",这在排障期很重要:能区分"没调用 update" 和 "调用了但没帧"。
10.3.5 喂 NAL:QueueH264(native → Java byte[])
bool AndroidVideoDecoder::QueueH264(const uint8_t* data, int len, int64_t ptsUs) {
if (!ready_ || !data || len <= 0) return false;
bool didAttach = false;
JNIEnv* env = GetEnvForThread(&didAttach);
if (!env) return false;
jbyteArray arr = env->NewByteArray(len);
env->SetByteArrayRegion(arr, 0, len, reinterpret_cast<const jbyte*>(data));
static int q=0;
if ((++q % 60) == 0) ALOGI("QueueH264 called len=%d ptsUs=%lld", len, (long long)ptsUs);
jboolean ok = env->CallBooleanMethod(decoderGlobal_, midQueue_, arr, (jint)len, (jlong)ptsUs);
env->DeleteLocalRef(arr);
if (ok != JNI_TRUE) ALOGE("QueueH264 failed (java returned false)");
...
if (didAttach) vm_->DetachCurrentThread();
return ok == JNI_TRUE;
}
10.4 Layer 3:Java 解码与 SurfaceTexture(VideoDecoder)
这层负责:
MediaCodec解码(H264 Annex-B in)- 输出到
Surface(SurfaceTexture) - 在 GL 线程 attach external texture + updateTexImage
10.4.1 init:codec 输出到 SurfaceTexture 对应的 Surface
public void init(int w, int h) throws Exception {
width = w;
height = h;
surfaceTexture = new SurfaceTexture(0);
surfaceTexture.setDefaultBufferSize(width, height);
surfaceTexture.setOnFrameAvailableListener(st -> frameAvailable = true);
surface = new Surface(surfaceTexture);
codec = MediaCodec.createDecoderByType("video/avc");
MediaFormat fmt = MediaFormat.createVideoFormat("video/avc", width, height);
fmt.setInteger(MediaFormat.KEY_PRIORITY, 0);
codec.configure(fmt, surface, null, 0);
codec.start();
Log.i(TAG, "init ok. thread=" + Thread.currentThread().getName());
}
关键点:不在 init 里碰 GL(避免 EGLContext/线程不一致),而把 attach 放到单独函数里。
10.4.2 createOesTexAndAttach:创建 OES 纹理并 attach(GL 线程调用)
public int createOesTexAndAttach() {
if (surfaceTexture == null) return 0;
int[] t = new int[1];
GLES20.glGenTextures(1, t, 0);
int texId = t[0];
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
try {
try { surfaceTexture.detachFromGLContext(); } catch (Exception ignored) {}
surfaceTexture.attachToGLContext(texId);
Log.i(TAG, "createOesTexAndAttach ok texId=" + texId +
" thread=" + Thread.currentThread().getName());
return texId;
} catch (Exception e) {
Log.e(TAG, "createOesTexAndAttach failed texId=" + texId, e);
return 0;
} finally {
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
}
}
选择Java 侧一口气做完创建+attach,这也是前面黑屏问题里最关键的一步之一。
10.4.3 updateTexImageIfAvailable:调试期无 gate,直接更新并返回 timestamp
public long updateTexImageIfAvailable() {
if (surfaceTexture == null) return 0;
// if (!frameAvailable) return 0;
// frameAvailable = false;
try {
surfaceTexture.updateTexImage();
return surfaceTexture.getTimestamp(); // ns
} catch (Exception e) {
Log.e(TAG, "updateTexImage failed", e);
return 0;
}
}
这段就是"tsNs 是否变化"的最终判据:tsNs 连续为 0,基本等于"没帧进 ST"。
10.4.4 queue:喂 H264 + 最小 drain(render=true 输出到 Surface)
public boolean queue(byte[] data, int len, long ptsUs) {
if ((++qCount % 60) == 0) Log.i(TAG, "queue count=" + qCount + " len=" + len + " ptsUs=" + ptsUs);
if (codec == null) return false;
try {
int inIndex = codec.dequeueInputBuffer(0);
if (inIndex < 0) return false;
ByteBuffer inBuf = codec.getInputBuffer(inIndex);
if (inBuf == null) return false;
inBuf.clear();
inBuf.put(data, 0, len);
codec.queueInputBuffer(inIndex, 0, len, ptsUs, 0);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (true) {
int outIndex = codec.dequeueOutputBuffer(info, 0);
if (outIndex >= 0) {
codec.releaseOutputBuffer(outIndex, true);
} else if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
break;
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
MediaFormat ofmt = codec.getOutputFormat();
Log.i(TAG, "output format changed: " + ofmt);
} else {
break;
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "queue failed", e);
return false;
}
}
10.5 Layer 4:OES → 2D(FBO blit)+ Panel 绘制(VideoPanelRenderer)
这层分两段:
- Blit pass :raw GL program,用
samplerExternalOES采样,写入GL_TEXTURE_2D - Panel pass :OVRFW program,用
sampler2D把 2D 纹理贴到面板 quad
10.5.1 只编译一次的 raw OES shader(避免 OVRFW 注入破坏 #extension)
raw FS 源:
static const char* OesBlitFS_Raw = R"glsl(#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
in vec2 vUV;
layout(location=0) out vec4 FragColor;
uniform samplerExternalOES uTex;
void main() {
FragColor = texture(uTex, vUV);
}
)glsl";
编译链接走 raw API(不是 GlProgram::Build):
GLuint vs = CompileShader(GL_VERTEX_SHADER, OesBlitVS_Raw, "OES Blit VS");
GLuint fs = CompileShader(GL_FRAGMENT_SHADER, OesBlitFS_Raw, "OES Blit FS");
oesBlitProg_ = LinkProgram(vs, fs, "OES Blit Program");
...
oesLocTex_ = glGetUniformLocation(oesBlitProg_, "uTex");
10.5.2 ES3 合规 blit 网格:VAO/VBO/IBO(解决 0x502)
void VideoPanelRenderer::EnsureBlitMesh_() {
if (blitMeshReady_) return;
const float verts[] = {
-1, -1, 0, 0, 0,
1, -1, 0, 1, 0,
1, 1, 0, 1, 1,
-1, 1, 0, 0, 1,
};
const uint16_t idx[] = {0, 1, 2, 0, 2, 3};
glGenVertexArrays(1, &blitVao_);
glBindVertexArray(blitVao_);
glGenBuffers(1, &blitVbo_);
glBindBuffer(GL_ARRAY_BUFFER, blitVbo_);
glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
glGenBuffers(1, &blitIbo_);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, blitIbo_);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
blitMeshReady_ = (blitVao_ != 0 && blitVbo_ != 0 && blitIbo_ != 0);
}
10.5.3 Blit 目标:RGBA tex2D + FBO complete 检查
void VideoPanelRenderer::EnsureBlitTarget_() {
if (blitTex2D_.IsValid() && blitFbo_ != 0) return;
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blitW_, blitH_, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
blitTex2D_ = GlTexture(tex, GL_TEXTURE_2D, blitW_, blitH_);
glGenFramebuffers(1, &blitFbo_);
glBindFramebuffer(GL_FRAMEBUFFER, blitFbo_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, blitTex2D_.texture, 0);
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("EnsureBlitTarget: FBO incomplete: 0x%x", status);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
10.5.4 真正执行 OES→2D:bind external,draw 到 FBO
void VideoPanelRenderer::BlitOesTo2D_(GLuint oesTexId) {
EnsureBlitTarget_();
EnsureBlitMesh_();
...
GLint prevFbo = 0;
GLint prevViewport[4] = {0,0,0,0};
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
glGetIntegerv(GL_VIEWPORT, prevViewport);
glBindFramebuffer(GL_FRAMEBUFFER, blitFbo_);
glViewport(0, 0, blitW_, blitH_);
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(oesBlitProg_);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTexId);
glUniform1i(oesLocTex_, 0);
glBindVertexArray(blitVao_);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, (void*)0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, 0);
glUseProgram(0);
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]);
}
10.5.5 PanelProgram:走 OVRFW,使用 TransformVertex,避免重定义注入冲突
PanelVS:
static const char* PanelVS = R"glsl(
precision highp float;
in vec3 Position;
in vec2 TexCoord;
out vec2 vUV;
void main() {
gl_Position = TransformVertex(vec4(Position, 1.0));
vUV = TexCoord;
}
)glsl";
构建 program 并配置参数(uTex、uColorMul):
static const ovrProgramParm PanelParms[] = {
{.Name = "uColorMul", .Type = ovrProgramParmType::FLOAT_VECTOR4},
{.Name = "uTex", .Type = ovrProgramParmType::TEXTURE_SAMPLED},
};
PanelProgram_ = GlProgram::Build(
PanelVS, PanelFS,
PanelParms, int(sizeof(PanelParms) / sizeof(PanelParms[0])),
GlProgram::GLSL_PROGRAM_VERSION,
false);
每帧 Render 时把 blitTex2D_ 塞进 panel 的 texture slot,并 push draw surface:
void VideoPanelRenderer::Render(std::vector<ovrDrawSurface>& surfaceList, GLuint oesTexId) {
if (!inited_) return;
if (oesTexId == 0) return;
BlitOesTo2D_(oesTexId);
UpdateModelMatrix_();
auto& gc = PanelSurface_.graphicsCommand;
gc.Textures[0] = blitTex2D_;
surfaceList.push_back(ovrDrawSurface(ModelMatrix_, &PanelSurface_));
}
现在这份
VideoPanelRenderer里还保留了videoDecoderObj_/attachToGL/updateTexImageIfAvailable的字段和逻辑,但没有提供 SetJavaObject 的最终接入,实际链路已经由AndroidVideoDecoder负责 update 了。就最终版而言,核心显示链是上面这段:只要 oesTexId 有效且 UpdateTexImage 已调用,blitTex2D_ 就会更新,面板就会动。
10.6 Layer 5:OpenXR VIEW space + HUD 跟随(main.cpp)
10.6.1 创建 VIEW space(SessionInit)
XrReferenceSpaceCreateInfo ci{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
ci.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
ci.poseInReferenceSpace.orientation = {0,0,0,1};
ci.poseInReferenceSpace.position = {0,0,0};
OXR(xrCreateReferenceSpace(GetSession(), &ci, &viewSpace_));
10.6.2 每帧 locate 并把面板放到头显前方(Render)
Render 里这一段就是 HUD 的"灵魂":
if (viewSpace_ != XR_NULL_HANDLE) {
XrSpaceLocation loc{XR_TYPE_SPACE_LOCATION};
OXR(xrLocateSpace(viewSpace_, mainReferenceSpace_, ToXrTime(in.PredictedDisplayTime), &loc));
const XrSpaceLocationFlags need =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
if ((loc.locationFlags & need) == need) {
const OVR::Posef head = FromXrPosef(loc.pose);
const OVR::Vector3f forward = head.Rotation.Rotate(OVR::Vector3f(0, 0, -1));
const float dist = 2.0f;
const float down = 0.0f;
OVR::Vector3f pos = head.Translation + forward * dist + OVR::Vector3f(0, -down, 0);
videoPanel_.SetPose(OVR::Posef(head.Rotation, pos));
}
}
这保证面板永远正对人,而且跟头走。
10.7 Layer 6:Render 线程的"总调度"(真正把所有层串起来)
所有 GL/SurfaceTexture 更新都在 Render 线程中发生,并且顺序固定。
10.7.1 一次性初始化:panel + decoder(Render 线程)
if (!videoPanelInited_) {
videoPanel_.Init(widthMeters, heightMeters);
videoPanel_.SetPose(OVR::Posef(
OVR::Quatf::Identity(),
OVR::Vector3f(Pose_x, Pose_y, Pose_z)
));
videoPanel_.SetScale({Scale_x, Scale_y, Scale_z});
videoPanelInited_ = true;
}
if (!videoInitAttempted_) {
videoInitAttempted_ = true;
if (java_ && java_->Vm && java_->ActivityObject) {
const int W = 1280;
const int H = 720;
videoReady_ = video_.Init(java_->Vm, java_->ActivityObject, W, H);
} else {
videoReady_ = false;
}
}
这里 video_.Init(...) 内部会调用 Java:
VideoDecoder.init(W,H)VideoDecoder.createOesTexAndAttach()→ 返回oesTexId_
10.7.2 每帧:Pop NAL → QueueH264 → UpdateTexImage → Render panel
这一段就是跑通的管线:
if (videoReady_) {
int got = videoRx_.PopNals(poppedNals_);
const int64_t ptsUs = (int64_t)NowNs() / 1000;
for (auto& nal : poppedNals_) {
video_.QueueH264(nal.data(), (int)nal.size(), ptsUs);
}
video_.UpdateTexImage();
// VIEW space 定位 + 更新面板 pose(上面已贴)
videoPanel_.Render(out.Surfaces, video_.GetOesTexId());
static int c = 0;
if ((++c % 120) == 0) {
ALOG("VideoRx stats: rtp=%llu nals=%llu drops=%llu gotThisFrame=%d",
(unsigned long long)videoRx_.GetRtpPackets(),
(unsigned long long)videoRx_.GetNalsOut(),
(unsigned long long)videoRx_.GetDrops(),
got);
}
}
这里的"顺序价值"非常大:
- 先尽可能把积压 NAL 喂进 codec(追上实时)
- 再
UpdateTexImage把最新解码输出推进 OES - 再
videoPanel_.Render去 blit & 画面板
这样能把显示链路延迟压到最低(同一帧内"喂+更新+显示"尽可能靠前)。
10.8 Layer 7:退出清理(SessionEnd)保证重进 Session 不脏
SessionEnd 的资源回收是成对的:
StopUdp();
if (videoPanelInited_) {
videoPanel_.Shutdown();
videoPanelInited_ = false;
}
videoRx_.Stop();
ALOG("VideoRx stopped");
if (viewSpace_ != XR_NULL_HANDLE) {
xrDestroySpace(viewSpace_);
viewSpace_ = XR_NULL_HANDLE;
}
- 网络线程会 join + close socket + clear queue(在
RtpH264Receiver::Stop) - GL 资源会 delete(在
VideoPanelRenderer::Shutdown) - OpenXR space 会 destroy
这避免下次进入黑屏/崩溃/纹理 id 冲突的经典问题。
11. 遇到的所有问题,按故障树总结
-
面板链路不通
- 用紫色 FBO clear 测试
- 看不到紫色:panel draw / pose / depth / surfaceList 有问题
-
blit draw 是否真的执行
GLCheck()有没有0x502- VAO/VBO/IBO 是否 ready
- FBO 是否 complete
-
OES shader 编译不成功
- 重点看
#extension位置 - 如果走 OVRFW Build,先怀疑"头部注入破坏顺序"
- 重点看
-
SurfaceTexture 没更新
updateTexImage()是否调用tsNs是否递增- tsNs 永远 0:先关掉 frameAvailable gate
-
MediaCodec 是否真的在出帧
- Queue 是否返回 true
- 是否 drain/releaseOutputBuffer(render=true)
-
喂的 H264 不完整
- RTP depay 是否正确组 FU-A/STAP-A
- 是否确保 Annex-B start code
- 是否有 SPS/PPS(通常发送端 payloader/parse 要保证)
-
面板位置/跟随问题
- 优先检查 reference space:VIEW vs LOCAL/STAGE
- 每帧 locate + pose 更新