在 Quest 上用 OpenXR + MediaCodec + OES 外部纹理做一个“低延迟视频面板”(48小时的编码复盘)

总体流程:

  • 局域网 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 端:

  1. RtpH264Receiver(C++ 网络线程)接收 RTP 包
  2. depay:支持 Single / STAP-A / FU-A → 组装成 Annex-B NAL00 00 00 01 前缀)
  3. Render 线程:每帧 Pop 多个 NAL,批量喂给 AndroidVideoDecoder::QueueH264(...)
  4. Java VideoDecoder.queue(...)MediaCodec 解码,输出到 Surface(SurfaceTexture)
  5. Render 线程:AndroidVideoDecoder::UpdateTexImage() → Java updateTexImageIfAvailable()SurfaceTexture.updateTexImage() 推进到 OES external texture
  6. VideoPanelRenderer:raw GLES3 shader 做 OES → RGBA 2D(FBO blit)
  7. OVRFW::GlProgram + ovrDrawSurface:用 sampler2D 画到面板上
  8. 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 是否真的拿到新帧
  • RtpH264ReceiverrtpPackets/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 tokens
  • samplerExternalOES 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-B00 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

这层做两件事:

  1. 在 C++ 层绑定 Java VideoDecoder 实例和方法 ID
  2. 提供可跨线程调用的 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 并配置参数(uTexuColorMul):

复制代码
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);
    }
}

这里的"顺序价值"非常大:

  1. 先尽可能把积压 NAL 喂进 codec(追上实时)
  2. UpdateTexImage 把最新解码输出推进 OES
  3. 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. 遇到的所有问题,按故障树总结

  1. 面板链路不通

    • 用紫色 FBO clear 测试
    • 看不到紫色:panel draw / pose / depth / surfaceList 有问题
  2. blit draw 是否真的执行

    • GLCheck() 有没有 0x502
    • VAO/VBO/IBO 是否 ready
    • FBO 是否 complete
  3. OES shader 编译不成功

    • 重点看 #extension 位置
    • 如果走 OVRFW Build,先怀疑"头部注入破坏顺序"
  4. SurfaceTexture 没更新

    • updateTexImage() 是否调用
    • tsNs 是否递增
    • tsNs 永远 0:先关掉 frameAvailable gate
  5. MediaCodec 是否真的在出帧

    • Queue 是否返回 true
    • 是否 drain/releaseOutputBuffer(render=true)
  6. 喂的 H264 不完整

    • RTP depay 是否正确组 FU-A/STAP-A
    • 是否确保 Annex-B start code
    • 是否有 SPS/PPS(通常发送端 payloader/parse 要保证)
  7. 面板位置/跟随问题

    • 优先检查 reference space:VIEW vs LOCAL/STAGE
    • 每帧 locate + pose 更新
相关推荐
之歆2 小时前
磁盘分区与文件系统管理
linux·文件系统·磁盘分区
犽戾武2 小时前
准备工作:OpenXR Sample 示例工程“去掉 UI 渲染”& RK3588→Windows 低延迟 UDP 视频链路
linux·c++·ubuntu·vr
王老师青少年编程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