背景
例子TFLive這個項目里,是我按著ijkPlayer寫的直播播放器,要運行需要編譯ffmpeg的庫,網盤里存了一份, 提取碼:vjce。OpenGL ES播放相關的在在OpenGLES的文件夾里。
learnOpenGL學到會使用紋理就可以了。
播放視頻,就是把畫面一副一副的顯示,跟幀動畫那樣。在解碼視頻幀數據之后得到的就是某種格式的一段內存,這段數據構成了一副畫面所需的顏色信息,比如yuv420p。圖文詳解YUV420數據格式這篇寫的很好。
YUV和RGB這些都叫顏色空間,我的理解便是:它們是一種約定好的顏色值的排列方式。比如RGB,便是紅綠藍三種顏色分量依次排列,一般每個顏色分量就占一個字節(jié),值為0-255。
YUV420p, 是YUV三個分量分別三層,就像:YYYYUUVV。就是Y全部在一起,而RGB是RGBRGBRGB這樣混合的。每個分量各自在一起的就是有平面(Plane)的。而420樣式是4個Y分量和一對UV分量組合,節(jié)省空間。
要顯示YUV420p的圖像,需要轉化yuv到rgba,因為OpenGL輸出只認rgba。
iOS上準備工作
OpenGL部分在各平臺邏輯是一致的,不在iOS上的可以跳過這段。
使用frameBuffer來顯示:
- 新建一個UIView子類,修改layer為
CAEAGLLayer
:
+(Class)layerClass{
return [CAEAGLLayer class];
}
- 開始繪制前構建Context:
-(BOOL)setupOpenGLContext{
_renderLayer = (CAEAGLLayer *)self.layer;
_renderLayer.opaque = YES;
_renderLayer.contentsScale = [UIScreen mainScreen].scale;
_renderLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:NO], kEAGLDrawablePropertyRetainedBacking,
kEAGLColorFormatRGBA8, kEAGLDrawablePropertyColorFormat,
nil];
_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
//_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
if (!_context) {
NSLog(@"alloc EAGLContext failed!");
return false;
}
EAGLContext *preContex = [EAGLContext currentContext];
if (![EAGLContext setCurrentContext:_context]) {
NSLog(@"set current EAGLContext failed!");
return false;
}
[self setupFrameBuffer];
[EAGLContext setCurrentContext:preContex];
return true;
}
-
opaque
設為YES是為了不做圖層混合,去掉不必要的性能消耗。 -
contentsScale
保持跟手機主屏幕一致,在不同手機上自適應。 -
kEAGLDrawablePropertyRetainedBacking
為YES的時候會保存渲染之后數據不變,我們不需要這個,一幀視頻數據顯示完就沒用了,所以這個功能關閉,去掉不必要的性能消耗。
有了這個context,并且把它設為CurrentContext
,那么在繪制過程里的那些OpenGL代碼才能在這個context生效,它才能把結果輸出到需要的地方。
- 構建frameBuffer,它是輸出結果:
-(void)setupFrameBuffer{
glGenBuffers(1, &_frameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer);
glGenRenderbuffers(1, &_colorBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, _colorBuffer);
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:_renderLayer];
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorBuffer);
GLint width,height;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &width);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &height);
_bufferSize.width = width;
_bufferSize.height = height;
glViewport(0, 0, _bufferSize.width, _bufferSize.height);
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER) ;
if(status != GL_FRAMEBUFFER_COMPLETE) {
NSLog(@"failed to make complete framebuffer object %x", status);
}
}
- 建一個framebuffer
- 建一個存儲顏色的renderBuffer,但是它的內存是由contex來分配:
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:_renderLayer];
這一句比較關鍵。因為它,renderBuffer、context和layer才聯系到了一起。根據Apple文檔,負責顯示的layer和renderbuffer是共用內存的,這樣輸出到renderBuffer里的內容,layer才顯示。
OpenGL部分
分為兩部分:第一次繪制開始前準備數據和每次繪制循環(huán)。
準備部分
使用OpenGL顯示的邏輯是:畫一個正方形,然后把輸出的視頻幀數據制作成紋理(texture)給這個正方形,把紋理顯示處理就OK里。
所以繪制的圖形是不變的,那么shader和數據(AVO等)都是固定的,在第一次開始前搞定后面就不需要變了。
if (!_renderConfiged) {
[self configRenderData];
}
-(BOOL)configRenderData{
if (_renderConfiged) {
return true;
}
GLfloat vertices[] = {
-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, //left top
-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, //left bottom
1.0f, 1.0f, 0.0f, 1.0f, 0.0f, //right top
1.0f, -1.0f, 0.0f, 1.0f, 1.0f, //right bottom
};
// NSString *vertexPath = [[NSBundle mainBundle] pathForResource:@"frameDisplay" ofType:@"vs"];
// NSString *fragmentPath = [[NSBundle mainBundle] pathForResource:@"frameDisplay" ofType:@"fs"];
//_frameProgram = new TFOPGLProgram(std::string([vertexPath UTF8String]), std::string([fragmentPath UTF8String]));
_frameProgram = new TFOPGLProgram(TFVideoDisplay_common_vs, TFVideoDisplay_yuv420_fs);
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5*sizeof(GL_FLOAT), 0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5*sizeof(GL_FLOAT), (void*)(3*(sizeof(GL_FLOAT))));
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
//gen textures
glGenTextures(TFMAX_TEXTURE_COUNT, textures);
for (int i = 0; i<TFMAX_TEXTURE_COUNT; i++) {
glBindTexture(GL_TEXTURE_2D, textures[i]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
}
_renderConfiged = YES;
return YES;
}
- vertices 是正方形4個角的頂點坐標數據,每個點5個float數,前3個是xyz坐標,后兩個是紋理坐標(uv)。xyz范圍[-1, 1], uv范圍[0, 1]。
- 加載shader、編譯,鏈接program,都在
TFOPGLProgram
這個類里做了。 - 然后生成一個VAO和VBO綁定數據。
- 最后構建幾個紋理,雖然這時還沒有數據,先占個位置。
繪制
先上shader:
const GLchar *TFVideoDisplay_common_vs =" \n\
#version 300 es \n\
\n\
layout (location = 0) in highp vec3 position; \n\
layout (location = 1) in highp vec2 inTexcoord; \n\
\n\
out highp vec2 texcoord; \n\
\n\
void main() \n\
{ \n\
gl_Position = vec4(position, 1.0); \n\
texcoord = inTexcoord; \n\
} \n\
";
const GLchar *TFVideoDisplay_yuv420_fs =" \n\
#version 300 es \n\
precision highp float; \n\
\n\
in vec2 texcoord; \n\
out vec4 FragColor; \n\
uniform lowp sampler2D yPlaneTex; \n\
uniform lowp sampler2D uPlaneTex; \n\
uniform lowp sampler2D vPlaneTex; \n\
\n\
void main() \n\
{ \n\
// (1) y - 16 (2) rgb * 1.164 \n\
vec3 yuv; \n\
yuv.x = texture(yPlaneTex, texcoord).r; \n\
yuv.y = texture(uPlaneTex, texcoord).r - 0.5f; \n\
yuv.z = texture(vPlaneTex, texcoord).r - 0.5f; \n\
\n\
mat3 trans = mat3(1, 1 ,1, \n\
0, -0.34414, 1.772, \n\
1.402, -0.71414, 0 \n\
); \n\
\n\
FragColor = vec4(trans*yuv, 1.0); \n\
} \n\
";
vertex shader就是輸出一下gl_Position然后把紋理坐標傳給fragment shader。
fragment shader是重點,因為要在這里完成從yuv到rgb的轉換。
因為yuv420p是yuv3個分量分層存放的,如果將整個yuv數據作為整個紋理加載進來,那么用一個紋理坐標想取到3個分量,計算起來就比較麻煩了,每個fragment都需要計算。
YyYYYYYY
YYYYYYYY
uUUUvVVV
yuv420p的樣子是這樣的,加入你要取(2,1)這個坐標的顏色信息,那么y在(2,1),u在(1,3),v在(5,3)。而且高寬比例會影響布局:
YyYYYYYY
YYYYYYYY
YyYYYYYY
YYYYYYYY
uUUUuUUU
vVVVvVVV
這樣uv不在同一行了。
所以采用每個分量單獨的紋理。這樣厲害的地方就是他們可以共用同一個紋理坐標:
glBindTexture(GL_TEXTURE_2D, textures[0]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width, height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, overlay->pixels[0]);
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, textures[1]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width/2, height/2, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, overlay->pixels[1]);
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, textures[2]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width/2, height/2, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, overlay->pixels[2]);
glGenerateMipmap(GL_TEXTURE_2D);
- 3個紋理,y的紋理和圖像大小一樣,u和v的高寬都減半。
-
overlay
只是用來打包視頻幀數據的一個結構體,pixels的0、1、2分別就是yuv3個分量的平面的開始位置。 - 有一個關鍵點是紋理格式使用
GL_LUMINANCE
,也就是單顏色通道??淳W上的例子,之前寫的是GL_RED
的是不行的。 - 因為威力坐標是一個相對坐標,是映射到[0, 1]范圍內的。所以對于紋理坐標[x, y],在u和v紋理的上取到的點跟y紋理坐標上[2x, 2y]是對應的,而這正是yuv420需要的:4個y對應一組uv。
最后用的把yuv轉成rgb,用的公式:
R = Y + 1.402 (Cr-128)
G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128)
B = Y + 1.772 (Cb-128)
這里還有一個注意的就是,YUV和YCrCb的區(qū)別:
YCrCb是YUV的一個偏移版本,所以需要減去0.5(因為都映射到0-1范圍了128就是0.5)。當然我覺得這個公式還是要看編碼的時候設置了什么格式,視頻拍攝的時候是怎么把rgb轉成yuv的,兩者配套就ok了!
繪制正方形
glBindFramebuffer(GL_FRAMEBUFFER, self.frameBuffer);
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
_frameProgram->use();
_frameProgram->setTexture("yPlaneTex", GL_TEXTURE_2D, textures[0], 0);
_frameProgram->setTexture("uPlaneTex", GL_TEXTURE_2D, textures[1], 1);
_frameProgram->setTexture("vPlaneTex", GL_TEXTURE_2D, textures[2], 2);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindRenderbuffer(GL_RENDERBUFFER, self.colorBuffer);
[self.context presentRenderbuffer:GL_RENDERBUFFER];
- 開啟program,并把三個紋理輸入
- 使用GL_TRIANGLE_STRIP繪制,這樣可以更簡單些,用GL_TRIANGLES就得兩個三角形了。因為這個,所以vertices的4個點是左上、左下、右上、右下的順序,具體規(guī)律看【OpenGL】理解GL_TRIANGLE_STRIP等繪制三角形序列的三種方式。
細節(jié)處理
- 監(jiān)測一下app前后臺切換,后臺就不要渲染了:
[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(catchAppResignActive) name:UIApplicationWillResignActiveNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(catchAppBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil];
......
-(void)catchAppResignActive{
_appIsUnactive = YES;
}
-(void)catchAppBecomeActive{
_appIsUnactive = NO;
}
.......
if (self.appIsUnactive) {
return; //繪制之前檢查,直接取消
}
把繪制移到副線程
iOS中OpenGL ES的的這些操縱是可以全部放到副線程處理的,包括最后的presentRenderbuffer
。關鍵是context構建、數組準備(VAO texture等)、渲染這些得在一個線程里,當然也可以多線程操作,但對于視屏播放而言沒有必要,去除沒必要的性能消耗吧,鎖都不用加了。layer的frame改變處理
-(void)layoutSubviews{
[super layoutSubviews];
//If context has setuped and layer's size has changed, realloc renderBuffer.
if (self.context && !CGSizeEqualToSize(self.layer.frame.size, self.bufferSize)) {
_needReallocRenderBuffer = YES;
}
}
...........
if (_needReallocRenderBuffer) {
[self reallocRenderBuffer];
_needReallocRenderBuffer = NO;
}
.........
-(void)reallocRenderBuffer{
glBindRenderbuffer(GL_RENDERBUFFER, _colorBuffer);
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:_renderLayer];
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorBuffer);
......
}
- 改變之后,重新分配render buffer的內存
- 為了在同一個線程里處理,所以沒有直接在
layoutSubviews
里重新分配render buffer,這里肯定是主線程。所以只是做了個標記 - 在渲染的方法里,先查看_needReallocRenderBuffer,然后realloc render buffer.
最后
重點是fragment shader里對yuv分量的讀?。?/p>
- 采取3個紋理
- 使用同一個紋理坐標
- 構建紋理是使用
GL_LUMINANCE
, u、v紋理寬高相對y都減半。