OpenGL ES教程——YUV渲染

976 阅读9分钟

out.jpg

现在,我们学会了绘制三角形,也学会了静态图片绘制,是时候来绘制视频了。

如果没有视频基础的小伙伴,看到标题可能会奇怪,yuv是什么,视频就是yuv吗?视频其实也是由一幅幅静态图片组成,图片当然也是rgb色系构成,不过视频领域,为了更好地压缩,一般是用yuv存储数据。yuv数据再经过一系列编码、压缩,最后封装到各种视频容器中,比如mp4、avi等。由于opengl无法做视频的解封装,所以我们现在只能用opengl来绘制视频的原始材料,yuv。等到讲ffmpeg时,我们再来绘制mp4等文件。

1、YUV渲染思路

yuv文件,可以理解为初始的视频文件(也确实如此,示例中的yuv文件只有3秒,不过文件有70几M)。既然是视频,它肯定是由一帧帧静态图组成。

每一个静态图,都可以看成是一个纹理,纹理我们已经会绘制了,那yuv当然也不在话下啦。

dss.jpg

不过,纹理绘制中,片段着色器最后输出的是rgb,所以我们需要把yuv转换成rgb。

所以yuv渲染大概就是两个步骤:

  • yuv数据获取
  • yuv数据转换成rgb

回忆下纹理渲染的一行代码:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height,
             0, GL_RGBA, GL_UNSIGNED_BYTE, data);

纹理渲染,本质上是定义一个2D纹理,然后此纹理正确解析它的data参数,绘制(开发传给纹理正确的数据,纹理正确地解析)。代码中的data是一张图片的像素数据,图片的每个像素点必然是由RGB数据组成的,所以纹理解析的格式就是GL_RGBA

那yuv数据,纹理怎么解析data呢?我们传给纹理的是yuv数据,不是rgb数据,肯定不能使用 GL_RGBA

image.png

yuv绘制,我们使用GL_LUMINANCE ,它表示只取一个颜色通道,如果传入的值为 L,则纹理读出来的数据则是(L、L、L、1)。如果我们将yuv视频分离成3个data,每个data中分别存储y、u、v,然后使用三个纹理来解析,每个纹理的格式都是 GL_LUMINANCE ,则三个纹理将分别解析出对应的y值、u值、v值,然后再将此yuv值转换成rgb值来绘制,是不是就大功告成了?

2、YUV转换RGB

yuv可以和rgb互转,而且它们的转换规则还有很多,这里我们任取其中一个:

image.png

补全一下,其实是这样:

r = 1*y + 0*u + 1.540*(v - 128)
g = 1*y - 0.183*(u-128) - 0.459*(v - 128)
r = 1*y + 1.816*(u - 128) + 0*v

所以,最后是通过如下矩阵来转换yuv的:

rgb = mat3(
    1.0, 1.0, 1.0,
    0.0, -0.183, 1.816,
    1.540, -0.459, 0.0
) * yuv;

因为mat3中的矩阵是按列的顺序排布的,所在转换矩阵如上所示,和我们常规理解的矩阵有点不一样。

另外,转换公式中,u和v都要减去128。又因为yuv每个分量都是使用一个字节存储,所以yuv的每个分量值都在 [0, 256] 区间,即u和v都要减去自身的一半。

因为glsl语句中,数值都是作归一化处理,即最大值是1,所以在处理u、v数据时,需要把u、v的值分别减去0.5。

3、YUV数据获取

首先,yuv视频怎么获取呢?我们可以借助ffmpeg命令行得到。本将示例中用到的ffmpeg命令行如下:

 ffmpeg.exe -i demo.mp4 -t 3 output.mp4 //从demo.mp4中截取3秒视频
 ffmpeg.exe -i output.mp4 output.yuv //将mp4转换成yuv视频
 ffmpeg.exe -i output.mp4 //查看mp4视频的格式,宽高等
 ffmpeg.exe -i ds.jpeg -vf scale=220:-1 dss.jpg //压缩图片,宽度为220,高度为等比例压缩

值得注意的是,纹理绘制中需要获取视频的宽高,这些宽高可以通过 ffmpeg.exe -i output.mp4指令查看到,前提是在转换yuv的时候没有指定其它宽高。另外,mp4的编码也存在多种可能,通过如上指令,我们也能知道它的编码方式,它的编码方式决定了我们如何解析yuv视频。

image.png

如上图所示,本示例中使用的视频,编码方式为yuv420p,宽高为576*1024

yuv420p格式是什么样的呢?

image.png

如上图所示:假设当前yuv视频的宽高为8×4,那么它将先连续存储8×4=32个y,8个u,8个v。

YUV 4:2:0 采样,并不是指只采样 U 分量而不采样 V 分量。而是指,在每一行扫描时,只扫描一种色度分量(U 或者 V),和 Y 分量按照 2 : 1 的方式采样。比如,第一行扫描时,YU 按照 2 : 1 的方式采样,那么第二行扫描时,YV 分量按照 2:1 的方式采样。对于每个色度分量来说,它的水平方向和竖直方向的采样和 Y 分量相比都是 2:1 。

总之yuv三者之间的数量对比为:4:1:1

image.png

我们示例中示例的宽高为:576×1024,假设为 w * h,那么这个视频的一帧内,则会有 w * h个y,w * h/4个u,w * h/4个v。如果视频总buf大小为n,那么一共有多少帧呢?n/(w * h * 3 / 2)

现在我们已经知道如何解析yuv视频了,那现在我们就来读取它吧。

ndk中提供asset资源读取接口,我们通过输入流,一次读取一帧数据的内容,然后将读取的值设置给yuv对应的三个纹理:

AAssetManager* asManager = MyGlRenderContext::getInstance()->getAsset();
AAsset* dataAsset = AAssetManager_open(asManager, "res/demo_576_1024.yuv", AASSET_MODE_STREAMING);

off_t bufferSize = AAsset_getLength(dataAsset);
long frameCount = bufferSize / (width * height * 3 / 2);
LOGI("frameCount%d", frameCount);

for (int i = 0; i < frameCount; ++i) {
    int bufYRead = AAsset_read(dataAsset, buf[0], width * height);
    int bufURead = AAsset_read(dataAsset, buf[1], width * height / 4);
    int bufVRead = AAsset_read(dataAsset, buf[2], width * height / 4);

    if (bufYRead <= 0 || bufURead <= 0 || bufVRead <= 0) {
        AAsset_close(dataAsset);
        return;
    }
    //设置data参数给纹理
    ......
}

4、实现

整个的实现思路如下:

  • 以文件流方式解析yuv视频,遍历每一帧,分别读取出每一帧y数据、u数据、v数据的buf
  • 使用3个纹理,每个纹理的格式为 GL_LUMINANCE,将第一步得到的3个buf分别赋值给3个纹理,每个纹理读取出当前的数据,得到当前像素点的yuv,再转换成rgb,绘制
void YuvSample::init() {
    if (m_ProgramObj != 0) {
        return;
    }
    LOGI("yuv sample init");
    auto verShader = MyGlRenderContext::getInstance()->getAssetResource("yuv/yuv.vert");
    auto fragShader = MyGlRenderContext::getInstance()->getAssetResource("yuv/yuv.frag");
    m_ProgramObj = GLUtils::createProgram(verShader.data(), fragShader.data(),
                                          m_VertexShader, m_FragmentShader);
    LOGI("m_ProgramObj = %d", m_ProgramObj);
    prepareData();
    prepareTexture();
}

void YuvSample::prepareData() {
    //这里包含了三角形顶点坐标,也包含了纹理顶点坐标,和解析颜色一样解析
    GLfloat verticesCoords[] = {
            -1.0f,  1.0f, 0.0f,  0.0f,  0.0f,// Position 0
            -1.0f, -1.0f, 0.0f,  0.0f,  1.0f,// Position 1
            1.0f, -1.0f, 0.0f,   1.0f,  1.0f,// Position 2
            1.0f,  1.0f, 0.0f,  1.0f,  0.0f // Position 3
    };

    unsigned int indices[] = {0, 1, 2, 0, 2, 3};

    glGenBuffers(1, &VBO);
    //绑定VBO,GL_ARRAY_BUFFER类型的buffer,它只能有一个,所有三角形顶点和纹理就放在一起解析了
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(verticesCoords), verticesCoords, GL_STATIC_DRAW);

    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5* sizeof(float), (const void*)0);
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5* sizeof(float), (const void*)(3 * sizeof(float)));

    glBindBuffer(GL_ARRAY_BUFFER, 0);

    //绑定EBO,GL_ELEMENT_ARRAY_BUFFER
    glGenBuffers(1, &EBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}

void YuvSample::prepareTexture() {
    int width = 576;
    int height = 1024;
    //处理纹理
    glGenTextures(3, texture);
    glBindTexture(GL_TEXTURE_2D, texture[0]);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width, height, 0, GL_LUMINANCE,
                 GL_UNSIGNED_BYTE, NULL);

    glBindTexture(GL_TEXTURE_2D, texture[1]);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width/2, height/2, 0, GL_LUMINANCE,
                 GL_UNSIGNED_BYTE, NULL);

    glBindTexture(GL_TEXTURE_2D, texture[2]);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width/2, height/2, 0, GL_LUMINANCE,
                 GL_UNSIGNED_BYTE, NULL);
}

void YuvSample::draw() {
    if (m_ProgramObj == 0) {
        return;
    }
    glClearColor(1.0, 1.0, 1.0, 1.0);
    glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    //获取默认的display以及winsurface,绘制好一帧后swap,否则后面的帧无法显示
    //因为java端的glsurfaceview中已经处理好了相关的egl逻辑,如果我们要写就必须写全套,比较麻烦,先用这个代替
    auto display = eglGetCurrentDisplay();
    auto winSurface = eglGetCurrentSurface(EGL_READ);

    glUseProgram(m_ProgramObj);

    int width = 576;
    int height = 1024;

    //使用vbo及ebo数据,不用每次绘制都解析
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);

    //这段代码必须每次绘制的时候都调用,不指定的话,颜色会变绿,仔细想想也对,每次绘制的时候必须重新为每个纹理指定标号,不能只在初始化的时候指定一次
    glUniform1i(glGetUniformLocation(m_ProgramObj, "yTexture"), 0);
    glUniform1i(glGetUniformLocation(m_ProgramObj, "uTexture"), 1);
    glUniform1i(glGetUniformLocation(m_ProgramObj, "vTexture"), 2);

    unsigned char* buf[3] = {0};
    buf[0] = new unsigned char[width * height];
    buf[1] = new unsigned char[width * height / 4];
    buf[2] = new unsigned char[width * height / 4];

    AAssetManager* asManager = MyGlRenderContext::getInstance()->getAsset();
    AAsset* dataAsset = AAssetManager_open(asManager, "res/demo_576_1024.yuv", AASSET_MODE_STREAMING);

    off_t bufferSize = AAsset_getLength(dataAsset);
    long frameCount = bufferSize / (width * height * 3 / 2);
    LOGI("frameCount%d", frameCount);

    for (int i = 0; i < frameCount; ++i) {
        int bufYRead = AAsset_read(dataAsset, buf[0], width * height);
        int bufURead = AAsset_read(dataAsset, buf[1], width * height / 4);
        int bufVRead = AAsset_read(dataAsset, buf[2], width * height / 4);

        if (bufYRead <= 0 || bufURead <= 0 || bufVRead <= 0) {
            AAsset_close(dataAsset);
            return;
        }

        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, texture[0]);
        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height,
                        GL_LUMINANCE, GL_UNSIGNED_BYTE, buf[0]);

        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, texture[1]);
        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width/2, height/2,
                        GL_LUMINANCE, GL_UNSIGNED_BYTE, buf[1]);

        glActiveTexture(GL_TEXTURE2);
        glBindTexture(GL_TEXTURE_2D, texture[2]);
        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width/2, height/2,
                        GL_LUMINANCE, GL_UNSIGNED_BYTE, buf[2]);


        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        eglSwapBuffers(display, winSurface);
        //usleep(20000);
    }
    AAsset_close(dataAsset);
    delete[] buf[0];
    delete[] buf[1];
    delete[] buf[2];
}

对应的着色器代码为:

#version 300 es
layout(location = 0) in vec4 a_position;
layout(location = 1) in vec2 a_texCoord;
out vec2 vTextCoord;
void main()
{
   gl_Position = a_position;
   vTextCoord = a_texCoord;
}
#version 300 es
precision mediump float;
//输入的yuv三个纹理
uniform sampler2D yTexture;//采样器
uniform sampler2D uTexture;//采样器
uniform sampler2D vTexture;//采样器
out vec4 FragColor;
//纹理坐标
in vec2 vTextCoord;
void main() {
    //采样到的yuv向量数据
    vec3 yuv;
    //yuv转化得到的rgb向量数据
    vec3 rgb;
    //分别取yuv各个分量的采样纹理
    yuv.x = texture(yTexture, vTextCoord).r;
    yuv.y = texture(uTexture, vTextCoord).g - 0.5;
    yuv.z = texture(vTexture, vTextCoord).b - 0.5;
    //yuv转化为rgb
    rgb = mat3(
        1.0, 1.0, 1.0,
        0.0, -0.183, 1.816,
        1.540, -0.459, 0.0
    ) * yuv;
    //gl_FragColor是OpenGL内置的
    FragColor = vec4(rgb, 1.0);
}

5、问题点记录

  • 由于我没有在c++部分实现egl那一套,如果不执行 eglSwapBuffers,将导致画面会卡在第一帧,但执行eglSwapBuffers需要 display 以及 surface 对象,看GLSurfaceView源码时,java端其实已经帮助我们初始化了egl环境,按理说c++这端不需要了,但现实如此,没办法,只得想办法来执行 eglSwapBuffers,百无聊赖中看egl提供了哪些函数,发现里边居然能获取默认的display和surface,一试果然灵

  • 示例中把纹理对象的初始化放到另一个方法中,在类初始化的时候执行一次。draw方法是会被反复执行的,如果初始化纹理方式放在draw方法中,当然能实现效果,但没必要。不过这里有个问题,卡住本人好几天,glUniform1i,这个方法必须在draw的时候重新执行一遍,否则整个画面会泛绿,仔细想想,确实如此,因为这个方法本意是为三个纹理分配id,以便后续激活,它需要在program中找到对应名字的纹理,如果program还没有启用就执行,肯定会有问题

  • 这个点是我现在还没想清楚的:示例中我把顶点数据放到一个单独的方法中,本来我是用一个vao来管理所有顶点数据的,但整个画面会闪,后来我去掉vao,使用vbo及ebo来管理顶点,不使用vao,画面正常了,也不闪了,真难,哪里有问题都不知道,也不知道怎么调试。。。

  • 最后一个点,目前仍然没有解决:现在有demo示例,一开始并不显示画面,要等待几秒才会显示画面,百思不得其解,如果有哪位大神能指出问题,感激不尽

image.png