本文主要介紹使用OpenGL ES來渲染I420(YUV420P) , NV12(YUV420SP)的方法,關于YUV的知識,可以看這里《YUV顏色編碼解析》,同樣會用到一些簡單的OpenGL shader知識,可以看看OpenGL的著色器語言。為了書寫方便,以下所談的OpenGL特指OpenGL ES。
OpenGL ES是OpenGL的精簡版本,主要針對于手機、游戲主機等嵌入式設備,它提供了一套設備圖形硬件的軟件接口,通過直接操作圖形硬件,使我們能夠高效地繪制圖形。OpenGL在iOS架構中屬于媒體層,與quartz(core graphics)類似,是相對底層的技術,可以控制每一幀的圖形繪制。由于圖形渲染是通過圖形硬件(GPU)來完成的,相對于使用CPU,能夠獲得更高的幀率同時不會因為負載過大而造成卡頓。

創(chuàng)建GLView
我們需要創(chuàng)建一個用來展示OpenGL繪制內(nèi)容的View,只需要將UIView的根圖層(underlying layer)替換成CAEAGLLayer實例即可。通過覆蓋UIView的類方法+(Class)layerClass,可以實現(xiàn)這一點,CAEAGLLayer默認是透明的,這會影響性能,所以將它設為不透明。
+ (class)layerClass {
return [CAEAGLLayer class];
}
- (void)setupLayer {
_eaglLayer = (CAEAGLLayer*) self.layer;
_eaglLayer.opaque = YES;
}
創(chuàng)建EAGLContext
EAGLContext對象管理OpenGL繪制所需要的所有信息,和Quartz 2D所使用的CGContext類似。
- (void)setupContext {
//創(chuàng)建一個OpenGLES 2.0接口的context
EAGLRenderingAPI api = kEAGLRenderingAPIOpenGLES2;
_context = [[EAGLContext alloc] initWithAPI:api];
if (!_context) {
NSLog(@"Failed to initialize OpenGLES 2.0 context");
exit(1);
}
//將其設置為current context
if (![EAGLContext setCurrentContext:_context]) {
NSLog(@"Failed to set current OpenGL context");
exit(1);
}
}
創(chuàng)建render buffer
render buffer用來存儲將要繪制到屏幕上圖像。OpenGL中的對象都需要創(chuàng)建、綁定,并且都是ID引用的。
- (void)setupRenderBuffer {
//創(chuàng)建一個render buffer對象,并綁定到GL_RENDERBUFFER目標上
glGenRenderbuffers(1, &_renderBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, _renderBuffer);
//為render buffer分配存儲空間
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:_eaglLayer];
}
創(chuàng)建frame buffer
一個frame buffer對象包括render buffer, depth buffer, stencil buffer等,擁有OpenGL繪制時需要的信息。
- (void)setupFrameBuffer {
GLuint framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
//將之前創(chuàng)建的render buffer附著到frame buffer作為其logical buffer
//GL_COLOR_ATTACHMENT0指定第一個顏色緩沖區(qū)附著點
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER, _renderBuffer);
}
glFramebufferRenderbuffer調(diào)用后,render buffer通過GL_COLOR_ATTACHMENT0引用使用render buffer
渲染
- (void)render {
//設置用來清除屏幕的顏色,類似于quartz中設置CGcontext畫筆的顏色
glClearColor(0, 0, 0, 1.0);
//執(zhí)行清除操作,設置render buffer中的像素顏色為上一步指定的顏色
glClear(GL_COLOR_BUFFER_BIT);
//渲染render buffer中的圖像到GLView的CAEAGLLayer
[_context presentRenderbuffer:GL_RENDERBUFFER];
}
shader(著色器)
shader是上是在GPU上執(zhí)行的程序,保存在.glsl文件中或以字符串形式寫在OpenGL代碼里,使用GLSL(OpenGL shading language)語言編寫,shader在運行時編譯,鏈接,最終在GPU上執(zhí)行采樣操作。
OpenGL中有兩種shader:
- vertex shader(頂點著色器):vertex shader在每個頂點上都執(zhí)行執(zhí)行一次,通過不同世界的坐標系轉化定位頂點的最終位置。它可以數(shù)據(jù)給fragment shader,如紋理坐標、頂點坐標,變換矩陣等。
- fragment shader(片段著色器):fragment shader在每個像素上都會執(zhí)行一次,通過插值確定像素的最終顯示顏色。
創(chuàng)建shader
以下是兩個簡單的shader,用來說明GLSL的語法特點。
vertex shader:
//attribute 關鍵字用來描述傳入shader的變量
attribute vec4 vertexPosition; // 需要從外部獲取的4分量vector
attribute vec4 pixelColor;
//varying 關鍵字用來描述從vertex shader傳遞給fragment shader的變量
//精度修飾符分為三種:highp, mediump, lowp
varying mediump vec4 finalPixelColor; //mediumP修飾代表中等精度,提高效率。
void main(void) {
finalPixelColor = pixelColor; // 將pixelColor的值通過finalPixelColor傳遞給fragment shader
gl_Position = vertexPosition; // gl_Position是vertex shader的內(nèi)建變量,gl_Position中的頂點值最終輸出到渲染管線中
}
fragment shader:
varying mediump vec4 finalPixelColor;
void main(void) {
gl_FragColor = finalPixelColor; // gl_FragColor是fragment shader的內(nèi)建變量,gl_FragColor中的像素值最終輸出到渲染管線中
}
}
使用shader
shader在運行時完成編譯、鏈接,是在GPU上執(zhí)行的小程序,以下是shader編譯、鏈接的過程,為了閱讀方便,省略了調(diào)試異常情況的判斷和調(diào)試log輸出。
//編譯shader函數(shù)
- (GLuint)compileShader:(NSString*)shaderName withType:(GLenum)shaderType {
// 讀取shader文件的內(nèi)容為字符串
NSString* shaderPath = [[NSBundle mainBundle] pathForResource:shaderName
ofType:@"glsl"];
NSError* error;
NSString* shaderString = [NSString stringWithContentsOfFile:shaderPath
encoding:NSUTF8StringEncoding error:&error];
if (!shaderString) {
NSLog(@"Error loading shader: %@", error.localizedDescription);
exit(1);
}
// 創(chuàng)建shader對象,返回其引用
GLuint shaderHandle = glCreateShader(shaderType);
// 獲取C字符串,作為源代碼傳給OpenGL
const char * shaderStringUTF8 = [shaderString UTF8String];
int shaderStringLength = [shaderString length];
glShaderSource(shaderHandle, 1, &shaderStringUTF8, &shaderStringLength);
// 運行時編譯shader
glCompileShader(shaderHandle);
return shaderHandle;
}
//編譯、鏈接shader
-(void)configuerShader{
// 創(chuàng)建并編譯shader
GLuint vertexShader = [self compileShader:@"vertexShader"
withType:GL_VERTEX_SHADER];
GLuint fragmentShader = [self compileShader:@"fragmentShader"
withType:GL_FRAGMENT_SHADER];
//創(chuàng)建一個程序?qū)ο螅祷仄湟? _programHandle = glCreateProgram();
//將兩個shader綁定到程序?qū)ο? 不需要時可以使用glDetachShader解綁
glAttachShader(programHandle, vertexShader);
glAttachShader(programHandle, fragmentShader);
//鏈接兩個shader
glLinkProgram(_programHandle);
//選擇創(chuàng)建的程序?qū)ο鬄楫斍笆褂玫某绦?,類似setCurrentContext, 不需要時使用glDeleteProgram刪除
glUseProgram(_programHandle);
}
-(void)configuerSlot{
//獲取shader中attribute變量的引用
_vertexPosition = glGetAttribLocation(programHandle, "vertexPosition");
_pixelColor = glGetAttribLocation(programHandle, "pixelColor");
//啟用attribute變量,使其對GPU可見,默認為關閉
glEnableVertexAttribArray(_vertexPosition);
glEnableVertexAttribArray(_pixelColor);
}
-(void)initOpenGl{
[self configuerShader];
[self coniigureSlot];
}
使用OpenGL繪制一個簡單的矩形
以上內(nèi)容介紹了OpenGL的基本數(shù)據(jù)結構,現(xiàn)在先來繪制一個簡單的矩形
初始化
現(xiàn)在需要給OpenGL提供attribute變量值與頂點數(shù)據(jù)。頂點數(shù)據(jù)用來提供繪制時的幾何信息。OpenGL中只能繪制三角形,三角形保證了其內(nèi)部像素都在同一個平面。要繪制復雜的幾何圖形,可以用三角形組合的方式實現(xiàn)。
頂點數(shù)據(jù)使用VBO(vertex buffer object)來傳遞給GPU。
初始化VBO
OpenGL需要有兩種VBO來確定幾何圖形,vertex VBO提供頂點本身,index VBO提供三角形所使用的頂點的index序列。這樣保證了顯存中存儲的頂點數(shù)據(jù)是唯一的,不會浪費資源。VBO中存儲著CPU傳給GPU的數(shù)據(jù),存儲在顯存里,在執(zhí)行大量重復的繪制操作時,可以提高效率。
初始化attribute變量
之前創(chuàng)建的shader中,有兩個attribute變量,需要使用glVertexAttribPointer輸入給shader。
//我們需要在一個矩形中繪制圖像,需要兩個三角形模擬,所以需要四個頂點,索引數(shù)組說明了兩個三角形頂點組成。
//默認情況下,OpenGL 的Viewport左下角頂點為(-1,-1),右上角頂點為(1,1)。
const float vertices[] = {
1, -1, 0,//index 0
1, 1, 0,//index 1
-1, 1, 0,//index 2
-1, -1, 0 //index 3
}
const GLubyte Indices[] = {
0, 1, 2,
2, 3, 0
};
- (void)setupVBOs {
//頂點VBO
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
//將頂點坐標寫入頂點VBO
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), Vertices, GL_STATIC_DRAW);
//索引VBO
GLuint indexBuffer;
glGenBuffers(1, &indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
//將頂點索引數(shù)據(jù)寫入索引VBO
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(Indices), Indices, GL_STATIC_DRAW);
}
-(void)feedAttributeSlot{
//由于使用了VBO,所以最后一個參數(shù)傳數(shù)據(jù)在VBO中的偏移量,這點需要注意
glVertexAttribPointer(_vertexPosition, 3, GL_FLOAT, GL_FALSE, sizeof(float)*3, 0);
//沒有使用VBO,直接傳指針給函數(shù),
float blueColor[] = {0, 0, 1, 0};
glVertexAttribPointer(_pixelColor, 4, GL_FLOAT, GL_FALSE, 0, blueColor);
}
注意此時寫入VBO的只是一些二進制的數(shù)據(jù),需要在讀取數(shù)據(jù)是,給出數(shù)據(jù)類型才能正確讀取。
繪制
- (void)render {
//繪制黑色背景
glClearColor(0, 0, 0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
//創(chuàng)建一個OpenGL繪制的窗口
glViewport(0, 0, self.frame.size.width, self.frame.size.height);
//使用頂點索引,繪制圖形。調(diào)用函數(shù)后,vertex shader會在每個頂點執(zhí)行一遍,確定頂點信息。fragment shader會在每個像素執(zhí)行一遍,確定像素顏色。
//在使用VBO的情況下,最后一個參數(shù)傳0
glDrawElements(GL_TRIANGLES, sizeof(Indices)/sizeof(Indices[0]),
GL_UNSIGNED_BYTE, 0);
//EACAGLContext 渲染OpenGL繪制好的圖像到EACAGLLayer
[_context presentRenderbuffer:GL_RENDERBUFFER];
}
由于shader中pixelColor變量類型是varying類型,在處理未知值是,會自動插值,默認為線性插值。如果對shader的GLSL語法不熟悉,可以看這篇文章。
所以,當頂點之間的顏色不同是,fragment shader在處理圖形內(nèi)部的像素后會返回一個根據(jù)頂點插值后的數(shù)值,整個圖形是漸變色的。
這里因為四個頂點都設置為了藍色,所以繪制出來是一個藍色的矩形。
(添加圖)
使用OpenGL繪制YUV數(shù)據(jù)
以上內(nèi)容簡單介紹了如何使用OpenGL繪制,現(xiàn)在重點如何使用OpenGL繪制YUV數(shù)據(jù)。
YUV是一種顏色編碼格式,常用的格式有YUV444,YUV422P,YUV420P,YUV420SP等。
本文主要研究YUV420P的I420與YUV420SP的NV12。
紋理
我們需要將YUV數(shù)據(jù)紋理的方式加載到OpenGL,再將紋理貼到之前創(chuàng)建矩形上,完成繪制。
將每個頂點賦予一個紋理坐標,OpenGL會根據(jù)紋理坐標插值得到圖形內(nèi)部的像素值。OpenGL的紋理坐標系是歸一化的,取值范圍是0 - 1,左下角是原點。

紋理目標、紋理對象、紋理單元
- 紋理目標是顯卡的軟件接口中定義的句柄,指向要進行當前操作的顯存。
- 紋理對象是我們創(chuàng)建的用來存儲紋理的顯存,在實際使用過程中使用的是創(chuàng)建后返回的ID。
- 紋理單元是顯卡中所有的可用于在shader中進行紋理采樣的顯存,數(shù)量與顯卡類型相關,至少16個。在激活某個紋理單元后,紋理目標就該紋理單元,默認激活的是GL_TEXTURE0。
可以這么想象,紋理目標是轉輪手槍正對彈膛的單孔,紋理對象就是子彈,紋理單元是手槍的六個彈孔。下面用代碼說明它們之間的關系。
//創(chuàng)建一個紋理對象數(shù)組,數(shù)組里是紋理對象的ID
GLuint texture[3];
//創(chuàng)建紋理對象,第一個參數(shù)是要創(chuàng)建的數(shù)量,第二個參數(shù)是數(shù)組的基址
glGenTextures(3, &texture);
//激活GL_TEXTURE0這個紋理單元,用于之后的紋理采樣
glActiveTexture(GL_TEXTURE0);
//綁定紋理對象texture[0]到紋理目標GL_TEXTURE_2D,接下來對紋理目標的操作都發(fā)生在此對象上
glBindTexture(GL_TEXTURE_2D, texture[0]);
//創(chuàng)建圖像,采樣工作在GL_TEXTURE0中完成,圖像數(shù)據(jù)存儲在GL_TEXTURE_2D綁定的對象,即texture[0]中。
glTexImage(GL_TEXTURE_2D, ...);
//解除綁定,此時再對GL_TEXTURE_2D不會影響到texture[0],texture[0]的內(nèi)存不會回收。
glBindTexture(GL_TEXTURE_2D, 0);
//可以不斷創(chuàng)建新的紋理對象,直到顯存耗凈
修改shader
之前創(chuàng)建的簡單shader現(xiàn)在要修改代碼,實現(xiàn)對YUV數(shù)據(jù)的繪制。如果對GLSL語法與YUV不熟悉,可以看OpenGL的著色語言:GLSL和YUV顏色編碼解析。
//vertex shader
ttribute vec4 position;
attribute mediump vec2 textureCoordinate;//要獲取的紋理坐標
varying mediump vec2 coordinate;//傳遞給fragm shader的紋理坐標,會自動插值
void main(void) {
gl_Position = vertexPosition;
coordinate = textureCoordinate;
}
//fragment shader
precision mediump float;
uniform sampler2D SamplerY;//sample2D的常量,用來獲取I420數(shù)據(jù)的Y平面數(shù)據(jù)
uniform sampler2D SamplerU;//U平面
uniform sampler2D SamplerV;//V平面
uniform sampler2D SamplerNV12_Y;//NV12數(shù)據(jù)的Y平面
uniform sampler2D SamplerNV12_UV;//NV12數(shù)據(jù)的UV平面
varying highp vec2 coordinate;//紋理坐標
uniform int yuvType;//0 代表 I420, 1 代表 NV12
//用來做YUV --> RGB 的變換矩陣
const vec3 delyuv = vec3(-0.0/255.0,-128.0/255.0,-128.0/255.0);
const vec3 matYUVRGB1 = vec3(1.0,0.0,1.402);
const vec3 matYUVRGB2 = vec3(1.0,-0.344,-0.714);
const vec3 matYUVRGB3 = vec3(1.0,1.772,0.0);
void main()
{
vec3 CurResult;
highp vec3 yuv;
if (yuvType == 0){
yuv.x = texture2D(SamplerY, coordinate).r;//因為是YUV的一個平面,所以采樣后的r,g,b,a這四個參數(shù)的數(shù)值是一樣的
yuv.y = texture2D(SamplerU, coordinate).r;
yuv.z = texture2D(SamplerV, coordinate).r;
}
else{
yuv.x = texture2D(SamplerY, coordinate).r;
yuv.y = texture2D(SamplerUV, coordinate).r;//因為NV12是2平面的,對于UV平面,在加載紋理時,會指定格式,讓U值存在r,g,b中,V值存在a中。
yuv.z = texture2D(SamplerUV, coordinate).a;//這里會在下面解釋
}
yuv += delyuv;//讀取值得范圍是0-255,讀取時要-128回歸原值
//用數(shù)量積來模擬矩陣變換,轉換成RGB值
CurResult.x = dot(yuv,matYUVRGB1);
CurResult.y = dot(yuv,matYUVRGB2);
CurResult.z = dot(yuv,matYUVRGB3);
//輸出像素值給光柵器
gl_FragColor = vec4(CurResult.rgb, 1);
}
加載YUV數(shù)據(jù)到紋理對象
現(xiàn)在有了可以處理YUV數(shù)據(jù)的shader,我們需要加載YUV數(shù)據(jù),來讓OpenGL完成繪制。
//創(chuàng)建紋理對象,需要3個紋理對象來獲取不同平面的數(shù)據(jù)
-(void)setupTexture{
_planarTextureHandles = (GLuint *)malloc(3*sizeof(GLuint));
glGenTextures(3, _planarTextureHandles);
}
-(void)feedTextureWithImageData:(Byte*)imageData imageSize:(CGSize)imageSize type:(NSInteger)type{
//根據(jù)YUV編碼的特點,獲得不同平面的基址
Byte * yPlane = imageData;
Byte * uPlane = imageData + imageSize.width*imageSize.height;
Byte * vPlane = imageData + imageSize.width*imageSize.height * 5 / 4;
if (type == 0) {
[self textureYUV:yPlane widthType:imageSize.width heightType:imageSize.height index:0];
[self textureYUV:uPlane widthType:imageSize.width/2 heightType:imageSize.height/2 index:1];
[self textureYUV:vPlane widthType:imageSize.width/2 heightType:imageSize.height/2 index:2];
}else{
[self textureYUV:yPlane widthType:imageSize.width heightType:imageSize.height index:0];
[self textureNV12:uPlane widthType:imageSize.width/2 heightType:imageSize.height/2 index:1];
}
}
- (void) textureYUV: (Byte*)imageData widthType: (int) width heightType: (int) height index: (int) index
{
//將紋理對象綁定到紋理目標
glBindTexture(GL_TEXTURE_2D, _planarTextureHandles[index]);
//設置放大和縮小時,紋理的過濾選項為:線性過濾
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
//設置紋理X,Y軸的紋理環(huán)繞選項為:邊緣像素延伸
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
//加載圖像數(shù)據(jù)到紋理,GL_LUMINANCE指明了圖像數(shù)據(jù)的像素格式為只有亮度,雖然第三個和第七個參數(shù)都使用了GL_LUMINANCE,
//但意義是不一樣的,前者指明了紋理對象的顏色分量成分,后者指明了圖像數(shù)據(jù)的像素格式
//獲得紋理對象后,其每個像素的r,g,b,a值都為相同,為加載圖像的像素亮度,在這里就是YUV某一平面的分量值
glTexImage2D( GL_TEXTURE_2D, 0, GL_LUMINANCE, width, height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, imageData );
//解綁
glBindTexture(GL_TEXTURE_2D, 0);
}
CADisplayLink定時繪制
現(xiàn)在已經(jīng)能夠?qū)UV數(shù)據(jù)加載到紋理對象了,下一步來改造render方法,將其繪制到屏幕上??梢杂肅ADisplayLink定時調(diào)用render方法,可以根據(jù)屏幕刷新頻率來控制YUV視頻流的幀率。
- (void)render {
//繪制黑色背景
glClearColor(0, 0, 0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
//獲取平面的scale
CGFloat scale = [[UIScreen mainScreen] scale];
CGFloat width = _frame.size.width*scale;
CGFloat height = _frame.size.height*scale;
//創(chuàng)建一個OpenGL繪制的窗口
glViewport(0, 0,width,height);
[self drawTexture];
//EACAGLContext 渲染OpenGL繪制好的圖像到EACAGLLayer
[_context presentRenderbuffer:GL_RENDERBUFFER];
}
//fragment shader的sample數(shù)組
GLint sampleHandle[3];
//繪制紋理
- (void) drawTexture{
//傳紋理坐標給fragment shader
glVertexAttribPointer([AVGLShareInstance shareInstance].texCoordAttributeLocation, 2, GL_FLOAT, GL_FALSE,
sizeof(Vertex), (void*)offsetof(Vertex, TexCoord));
glEnableVertexAttribArray([AVGLShareInstance shareInstance].texCoordAttributeLocation);
//傳紋理的像素格式給fragment shader
GLint yuvType = glGetUniformLocation(_programHandle, "yuvType");
glUniform1i([AVGLShareInstance shareInstance].drawTypeUniform, yuvType);
//type: 0是I420, 1是NV12
int planarCount = 0;
if (type == 0) {
planarCount = 3;//I420有3個平面
sampleHandle[1] = glGetUniformLocation(_programHandle, "samplerY");
sampleHandle[2] = glGetUniformLocation(_programHandle, "samplerU");
sampleHandle[3] = glGetUniformLocation(_programHandle, "samplerV");
}else{
planarCount = 2;//NV12有兩個平面
sampleHandle[1] = glGetUniformLocation(_programHandle, "SamplerNV12_Y");
sampleHandle[2] = glGetUniformLocation(_programHandle, "SamplerNV12_UV");
}
for (int i=0; i<planarCount; i++){
glActiveTexture(GL_TEXTURE0+i);
glBindTexture(GL_TEXTURE_2D, _planarTextureHandles[i]);
glUniform1i(sampleHandle[i], i);
}
//繪制函數(shù),使用三角形作為圖元構造要繪制的幾何圖形,由于頂點的indexs使用了VBO,所以最后一個參數(shù)傳0
//調(diào)用這個函數(shù)后,vertex shader先在每個頂點執(zhí)行一次,之后fragment shader在每個像素執(zhí)行一次,繪制后的圖像存儲在render buffer中。
glDrawElements(GL_TRIANGLES, 6,GL_UNSIGNED_BYTE, 0);
}
可以想象的應用場景
使用OpenGL繪制視頻,是實現(xiàn)簡單AR最簡單的方式,也可以根據(jù)業(yè)務來對視頻播放做進一步的個性定制,比如動態(tài)打碼,貼紙等。