一看就懂的OpenGL ES教程——渲染宫崎骏动漫重拾童年

5,135 阅读16分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

通过阅读本文,你将获得以下收获:
1.如何读取解析YUV视频文件
2.如何将YUV转化为RGBA
3.如何将YUV帧画面通过OpenGL渲染到屏幕

上篇回顾

上一篇一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(实践篇)已经用代码实例详细(可能很难找到比我更详细的哈哈)展示了如何进行纹理映射,以及纹理映射能做一些什么有趣效果。有了上一篇的基础,这一篇就可以乘胜追击,从上一篇的静态图片进阶到视频的渲染(跟了这么多篇博文,终于摸到视频的影子了,本系列已经渐入高潮!)。

今天的任务,就是渲染一个YUV视频,立马送上效果图:

output.gif

取自于宫崎骏大师的《龙猫》,满满的童年回忆~~

为什么是YUV视频呢?如果看过之前我这篇博文音视频开发基础知识之YUV颜色编码以及解析H264视频编码原理——从孙艺珍的电影说起(一) 的朋友应该知道,yuv一般就是视频用来传输的原始数据,所以这里作为OpenGL渲染视频的第一篇博文,当然是从渲染原始数据构成的视频讲起。毕竟勿在浮沙筑高台嘛~

46d43030194f0553d0b2f7e4cbc0f3f1.jpeg

温馨提示:如果没有读过一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(实践篇),本文阅读起来可能会非常吃力,所以非常建议阅读本文之前先读完上面两篇文章。

整体思路

根据前两篇讲解纹理映射的文章,我们要将图片纹理渲染到屏幕上,就要拿到图片的像素数据数组,然后将像素数据数组通过纹理单元传到片段着色器中,再通过纹理采样函数将纹理中对应坐标的颜色值采样出来,最后赋值一个最终的片段颜色值

现在换成了YUV视频,我们又要如何处理呢?我们可以从最终产出目标的最终的片段颜色值触发”回溯“一下:因为最终的片段颜色值是RGBA格式的,所以我们要采样到纹理对应坐标位置的RGBA颜色值,但是视频是YUV格式的,这里就需要一个转化过程,即将YUV转为RGBA。怎么将YUV传给片段着色器呢?有没有现成的YUV格式可以直接用呢?

也许你尝试过在OpenGL的glTexImage2D搜索过所有的颜色格式,但是却沮丧地发现并没有YUV格式可以直接用

7dbd709b6fce6a95fbe4acf4dcaadcd5.jpeg

别慌,所谓山穷水复疑无路,柳暗花明又一村。

OpenGL又给我们提供了GL_LUMINANCE这种格式,它表示只取一个颜色通道,假如传入的对应值为L,则在片段着色器中的纹理单元读取出来的值为(L, L, L, 1)

为什么说可以用过GL_LUMINANCE这种格式来读取YUV图像呢?因为通过GL_LUMINANCE这种格式,我们可以对YUV图像拆分为3个通道来读取。

拆分为3个通道来读取,那么应该如何重新合成为一个RGBA颜色值呢?这时候,上一篇文章介绍过的纹理单元就可以派上用场了,我们可以用3个纹理单元,分别将每个通道数据传入着色器中,最后在着色器中将3个通道数据又合在一起

然后再通过音视频开发基础知识之YUV颜色编码介绍的方式将YUV转为RGBA就一切迎刃而解了。

这里是整体思路,下面立刻呈现详解。

首先要做的一点就是,读取YUV视频,将其分解为3个颜色通道,并且可以一帧帧读取

读取解析YUV视频

温馨提示:读本章节之前如果对于YUV还不熟悉,那请务必先读一读音视频开发基础知识之YUV颜色编码,不然本章节理解起来会很吃力。

dee95b6e40caaf6719bad736b9b724dc.jpeg

要读取解析YUV视频,首先就要明白它的内部结构,要明白其内部结构,就要首先明白其具体格式。

当前这段YUV视频,是一段每帧由yuv420p、宽高分别为640272的YUV图像组成的视频,并且帧与帧之间无缝衔接。让我们来温习一下YUV420P的结构,先存储所有的 Y 分量后, 再存储所有的 U 分量,最后再存储V分量

image.png

(图来源于: 音视频基础知识---像素格式YUV

又因为yuv420p中从数量上来看y:u:v=4:1:1,所以我们的解析方案就浮出水面了:

假设每帧图像的宽度为w,高度为h,由于YUV每个通道分量占一个字节大小,所以我们可以每一帧都先读取w * h个y,然后读取w * h/4个u,再读取w * h/4个v,一帧数据读取完毕,然后对这些数据进行渲染。接下来再继续读取下一帧数据重复这个步骤,直到文件没有剩下的数据可以被读取。

有了方案,那么就要制定具体的方式了。由于每次读取一帧就要进行渲染,那么因为渲染逻辑是写在Native层的,所以读取解析YUV视频文件的逻辑也要写在Native层。在Native层中读取文件,可以使用C语言最基本的File相关的方法(fread)读取,不过这样需要我们将文件拷贝到手机某个文件夹中并且需要在代码中写死文件路径,显示不符合”高级程序员“的身份。由于我们是在Android系统,Android已经提供了asset资源目录了,所以我们可以通过将视频文件放置在asset资源目录中,从而更加方便地读取视频数据

首先将YUV视频文件放到asset文件夹中: image.png

然后通过AssetManager去读取YUV视频文件。你可能会问,在Java层有AssetManager,Native没有怎么办?其实不用担心,谷歌的服务向来都是很周(yi)到(ban)的,ndk在Native层同样提供了AssetManager。名曰AAssetManager

首先在Java层创建Native方法:

public native void loadYuv(Object surface, AssetManager assetManager);

然后在Java层获取到AssetManager,并传入loadYuv方法中:

AssetManager assetManager = getContext().getAssets();
loadYuv(getHolder().getSurface(),assetManager);

然后在Native层创建对应的loadYuv方法,其中读取YUV视频文件代码如下:

//创建3个buffer数组分别用于存放YUV三个分量
unsigned char *buf[3] = {0};
buf[0] = new unsigned char[width * height];//y
buf[1] = new unsigned char[width * height / 4];//u
buf[2] = new unsigned char[width * height / 4];//v

//通过Java层传入的AssetManager对象得到AAssetManager对象指针
AAssetManager *mManeger = AAssetManager_fromJava(env, assetManager);
//得到AAsset对象指针
AAsset *dataAsset = AAssetManager_open(mManeger, "video1_640_272.yuv",
                                       AASSET_MODE_STREAMING);//get file read AAsset
//文件总长度
off_t dataBufferSize = AAsset_getLength(dataAsset);
//纵帧数
long frameCount = dataBufferSize / (width * height * 3 / 2);

LOGD("frameCount:%d", frameCount);

//读取每帧的YUV数据
for (int i = 0; i < frameCount; ++i) {
    //读取y分量
    int bufYRead = AAsset_read(dataAsset, buf[0],
                               width * height);  //begin to read data once time
    //读取u分量
    int bufURead = AAsset_read(dataAsset, buf[1],
                               width * height / 4);  //begin to read data once time
    //读取v分量
    int bufVRead = AAsset_read(dataAsset, buf[2],
                               width * height / 4);  //begin to read data once time
    LOGD("bufYRead:%d,bufURead:%d,bufVRead:%d", bufYRead, bufURead, bufVRead);

    //读到文件末尾了
    if (bufYRead <= 0 || bufURead <= 0 || bufVRead <= 0) {
        AAsset_close(dataAsset);
        return;
    }

1.首先准备好3个水桶:创建3个buffer数组分别用于存放Y、U、V三个分量。
2.接好水管通过Java层传入的AssetManager对象得到AAssetManager对象指针从而得到AAsset对象指针,并算好要接多少桶水(计算好总帧数)
3.接水:3个水桶按顺序接水,通过AAsset_read方法分别将每一帧的Y、U、V分量数据存放到三个buffer数组中。

就这样,YUV视频文件的三个通道就被解析存放到3个buffer数组中

fc967eb8c61c1add863b87cb2004fd7f.jpeg

按照整体思路提到的,接下来就是要把这3个数组分别装进3个纹理中传送到片段着色器里

纹理对象配置

关于缓冲对象、顶点纹理坐标的配置,显然对于现阶段的我们来说已经不在话下了。

ddb732c30d08082fa4bed236f9d727d2.jpeg

不过……还是提一下顶点坐标和纹理坐标吧,依然是将整个纹理映射到整个显示区

//加入三维顶点数据
static float ver[] = {
        1.0f, -1.0f, 0.0f,
        -1.0f, -1.0f, 0.0f,
        1.0f, 1.0f, 0.0f,
        -1.0f, 1.0f, 0.0f
};
//加入纹理坐标数据
static float fragment[] = {
        1.0f, 0.0f,
        0.0f, 0.0f,
        1.0f, 1.0f,
        0.0f, 1.0f
};

让我们直接绕过这些繁琐的东西,直捣黄龙,看下纹理本身如何配置:

1.首先创建3个纹理单元

//纹理ID
GLuint textures[3] = {0};
//创建若干个纹理对象,并且得到纹理ID
glGenTextures(3, textures);

2.配置纹理单元

第一个纹理单元是用来盛放Y通道的,配置如下:

//绑定纹理。后面的的设置和加载全部作用于当前绑定的纹理对象
//GL_TEXTURE_1D、GL_TEXTURE_2D、CUBE_MAP为纹理目标
//通过 glBindTexture 函数将纹理目标和纹理绑定后,对纹理目标所进行的操作都反映到对纹理上
glBindTexture(GL_TEXTURE_2D, textures[0]);
//缩小的过滤器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
//放大的过滤器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
//设置纹理的格式和大小
// 加载纹理到 OpenGL,读入 buffer 定义的位图数据,并把它复制到当前绑定的纹理对象
// 当前绑定的纹理对象就会被附加上纹理图像。
glTexImage2D(GL_TEXTURE_2D,
             0,//细节基本 默认0
             GL_LUMINANCE,//gpu内部格式 亮度,灰度图(这里就是只取一个亮度的颜色通道的意思)
             width,//加载的纹理宽度。最好为2的次幂
             height,//加载的纹理高度。最好为2的次幂
             0,//纹理边框
             GL_LUMINANCE,//数据的像素格式 亮度,灰度图
             GL_UNSIGNED_BYTE,//像素点存储的数据类型
             NULL //纹理的数据(先不传)
);

先绑定纹理单元,然后配置纹理过滤,接着就是最关键的glTexImage2D方法了。

glTexImage2D方法在上一篇博文中已经调过,就是将纹理像素数据传递到片段着色器的方法。

这次颜色格式改为上面提到的GL_LUMINANCE,表示亮度,用于形成灰度图。这样假如某个Y的值为L,则传入片段着色器中的纹理就成了(L,L,L,1)。因为YUV的位深为8,所以这里像素点存储的数据类型为GL_UNSIGNED_BYTE即可。最后一个参数即实际像素数据传NULL。当然传NULL不表示不需要传像素数据就可以渲染(就像那有不付钱就有手抓饼吃呢),而是这里只是提前配置一下,真正的传数据还在后头(真正的好戏还在后头)。

另外还要注意的是这里Y传入的宽高为width和height,即为视频本身的像素分辨率,是因为YUV420中Y的数量和图像的像素个数相等

第二、三个纹理单元分别用来盛放U,V通道:

//设置纹理的格式和大小
glTexImage2D(GL_TEXTURE_2D,
             0,//细节基本 默认0
             GL_LUMINANCE,//gpu内部格式 亮度,灰度图(这里就是只取一个颜色通道的意思)
             width / 2,//u、v数据数量为屏幕的4分之1
             height / 2,
             0,//边框
             GL_LUMINANCE,//数据的像素格式 亮度,灰度图
             GL_UNSIGNED_BYTE,//像素点存储的数据类型
             NULL //纹理的数据(先不传)
);

其他配置和Y相同,唯一的不同点就是传入的尺寸,之前已经说过,对于YUV420P来说,U,V各自都是每4个像素采样一个,所以它们都是各自只占总像素数的4分之1,所以传入的尺寸值都为:宽width / 2,高height / 2

通过以上设置,我们已经做好了一切准备工作:创建三个纹理单元,将YUV三个通道分别传入三个纹理单元中,接下来,就是在片段着色器中分别对这三个纹理单元绑定的纹理进行采样了

片段着色器

#version 300 es
precision mediump float;
//纹理坐标
in vec2 vTextCoord;
//输入的yuv三个纹理
uniform sampler2D yTexture;//采样器
uniform sampler2D uTexture;//采样器
uniform sampler2D vTexture;//采样器
out vec4 FragColor;
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);
 };

这一次的片段着色器看起来已经不那么”demo“了。我们的核心思想就是把Y、U、V三个通道分别当做三个纹理,然后分别对三个纹理进行采样,然后将采样结果合并为一个最终的RGBA的颜色值作为当前片段的颜色值

这里有不少细节值得好好揣摩。

首先yuv.x,yuv.y,yuv.z是什么呢?

其实在GLSL中,向量的组件可以通过 {x, y, z, w} , {r, g, b, a} 或 {s, t, r, q} 等操作来获取。之所以采用这三种不同的命名方法,是因为向量常常会用来表示数学向量、颜色、纹理坐标等。

分量访问符符号描述
(x,y,z,w)与位置相关的分量
(r,g,b,a)与颜色相关的分量
(s,t,p,q)与纹理坐标相关的分量

所以yuv.x,yuv.y,yuv.z分别表示yuv向量的第1,2,3个元素。同样的,texture(yTexture, vTextCoord).r表示的是texture函数采样得到的颜色向量的第一个分量。

所以以下采样函数调用表示的就是对yTexture所对应的纹理在vTextCoord位置坐标上进行采样,得到的向量的第一个元素赋值给yuv向量的第一个元素。

yuv.x = texture(yTexture, vTextCoord).r;

因为我们纹理配置指定的格式为GL_LUMINANCE,这样假如对应位置的Y的值为L,则texture的返回值就为(L,L,L,1),即此时yuv的第一个元素值为L。而texture(yTexture, vTextCoord).r就表示获取texture方法返回的向量的第一个元素

以此类推出yuv.y,yuv.z分别是对uTexture、vTexture采样得到的值第二、三个元素值(其实这里随便取第1,2,3个元素值都可以,因为都相等)减去0.5。

但是有意思的是,为什么要减去0.5呢?这也是经典面试题来的(敲黑板)

81c7788923a0d5f81e89bcffcb19e94d.jpeg

将YUV数据转化为RGBA格式

首先我们回顾一下YUV转RGB的公式,之前说过,不同的转换标准,运用不同的公式:

image.png

image.png

image.png

image.png

我们以BT709 Limited为例,

WeChat4a0d753724d9ec76a055b90c8f68defe.png

首先讲下U、V的默认值。之前说过,YUV中的U、V分别表示和R、B和亮度Y的偏差U、V的默认值为128,由上图公式可知当U、V分别为128的时候,对应的R和B刚好分别等于Y

从上图公式可以看出,代入的U、V都是减去默认值128的

image.png

转化公式用的是U、V和默认值的偏移值,所以,我们可以在代入公式之前,先求出这个偏移值,又因为texture函数得到的是一个归一化的,即范围为0-1的值,而128又是U、V的中间值,所以在采样之后需要减的不是128,而是0-1的中间值0.5

减去0.5得到U、V相对于默认值的偏移值之后,代入公式,此时公式可以用矩阵(mat3表示三个 vec3构成的矩阵)相乘表示:

rgb = mat3(
            1.0, 1.0, 1.0,// 第一列
            0.0, -0.183, 1.816,// 第二列
            1.540, -0.459, 0.0// 第三列
    ) * yuv;

要注意的是,mat3里面的矩阵是按照列顺序排的,如注释所示。

于是便愉快地拿到了对应的RGB值。

de947d3fed1b91a0e08455aada5c668e.jpeg

渲染视频帧

当我们每次拿到每一帧的YUV数据的时候,接下来就是将YUV每个通道分别传入每个纹理单元中。

将片段着色器中定义的sampler变量和纹理单元进行绑定

//对sampler变量,使用函数glUniform1i和glUniform1iv进行设置
glUniform1i(glGetUniformLocation(program, "yTexture"), 0);
glUniform1i(glGetUniformLocation(program, "uTexture"), 1);
glUniform1i(glGetUniformLocation(program, "vTexture"), 2);

然后针对每个颜色通道进行渲染:

比如针对Y通道的渲染:

//激活第一个纹理单元
glActiveTexture(GL_TEXTURE0);
//绑定y对应的纹理
glBindTexture(GL_TEXTURE_2D, textures[0]);
//替换纹理,比重新使用glTexImage2D性能高多
glTexSubImage2D(GL_TEXTURE_2D, 0,
                0, 0,//指定纹理数组中的纹素偏移
                width, height,//加载的纹理宽度、高度。最好为2的次幂
                GL_LUMINANCE, GL_UNSIGNED_BYTE,
                buf[0]);

glTexSubImage2DglTexImage2D作用很相似,但是使用方式有区别,glTexSubImage2D是用于修改纹理,即在一个纹理上只能第一次渲染glTexImage2D,而后面每帧的修改都用glTexSubImage2D,所以glTexSubImage2D用来渲染视频的每帧再适合不过,因为如果每帧都重新创建纹理,那效率实在太低了。

void glTexSubImage2D(GLenum target,
 GLint level,
 GLint xoffset,
 GLint yoffset,
 GLsizei width,
 GLsizei height,
 GLenum format,
 GLenum type,
 const void * pixels``);

参数:
target

指定活动纹理单元的目标纹理。 必须是GL_TEXTURE_2D,GL_TEXTURE_CUBE_MAP_POSITIVE_X,GL_TEXTURE_CUBE_MAP_NEGATIVE_X,GL_TEXTURE_CUBE_MAP_POSITIVE_Y,GL_TEXTURE_CUBE_MAP_NEGATIVE_Y,GL_TEXTURE_CUBE_MAP_POSITIVE_Z或GL_TEXTURE_CUBE_MAP_NEGATIVE_Z。

level

指定详细级别编号。 0级是基本图像级别。 级别n是第n个mipmap缩小图像。

xoffset

指定纹理数组中x方向的纹素偏移量,x方向是指纹理坐标。

yoffset

指定纹理数组中y方向的纹素偏移,y方向是指纹理坐标。

width

指定纹理子图像的像素宽度。

height

指定纹理子图像的像素高度。

format

指定像素数据的格式。 接受以下符号值:GL_ALPHA,GL_RGB,GL_RGBA,GL_LUMINANCE和GL_LUMINANCE_ALPHA。

type

指定像素数据的数据类型。 接受以下符号值:GL_UNSIGNED_BYTE,GL_UNSIGNED_SHORT_5_6_5,GL_UNSIGNED_SHORT_4_4_4_4和GL_UNSIGNED_SHORT_5_5_5_1。

data

指定指向内存中图像数据的指针

对于U、V通道来说,仅仅是纹理的尺寸不同而已,上面已经讲过就不赘述:

//激活第二层纹理,绑定到创建的纹理
glActiveTexture(GL_TEXTURE1);
//绑定u对应的纹理
glBindTexture(GL_TEXTURE_2D, textures[1]);
//替换纹理,比重新使用glTexImage2D性能高
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width / 2, height / 2, GL_LUMINANCE,
                GL_UNSIGNED_BYTE,
                buf[1]);

运行一下,童年的感觉又回来了~

output.gif

项目代码

opengl-es-study-demo 不断更新中,欢迎各位来star~

参考

GLSL 详解(基础篇)

系列文章目录

体系化学习系列博文,请看音视频系统学习的浪漫马车之总目录

实践项目: 介绍一个自己刚出炉的安卓音视频播放录制开源项目

C/C++基础与进阶之路

音视频理论基础系列专栏

音视频开发实战系列专栏

轻松入门OpenGL系列
一看就懂的OpenGL ES教程——图形渲染管线的那些事
一看就懂的OpenGL ES教程——再谈OpenGL工作机制
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(一)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(二)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(三)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(四)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(五)
一看就懂的OpenGL ES教程——缓冲对象优化程序(一)
一看就懂的OpenGL ES教程——缓冲对象优化程序(二)
一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)
一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(实践篇)