OPenGL ES 摘要 - iOS开发

OpenGL ES

OpenGL ES是OpenGL的子集,是基于c语言的API,可以无缝移植到OC中,然后通过创建上下文来接受命令和操纵帧缓存;在 iOS中,可以使用GLKView将OpenGL ES绘制的内容渲染到屏幕上,还可以使用CAEAGLLayer图层将动画与视图相结合。

注意:当应用处于后台状态时,不能调用OpenGL ES中的函数,否则应用便会被终止,应当在进入后台时调用glfinish()来表明所有提交的指令均被执行完毕,而且上下文也不能在同一时刻被不同的线程访问;

EAGLContext

OpenGL ES中需要先创建一个EAGLContext的渲染上下文对象。而且每个线程都只能对应一个上下文,在同一个线程中切换不同的上下文时,需要对上下文进行强引用以防止其被释放,并且在切换之前应屌用glFlush()函数将当前的上下文提交的指令传到徒刑硬件中去。有两个重要的函数设置和获取当前的上下文:

 +(BOOL)setCurrentContext:(EAGLContext *)context;
 +(EAGLContext *) currentContext;

在创建上下文时,不同的设备可能支持的OpenGL ES版本也不同,不过基本都是向后兼容的,若在创建时返回值为nil,那么表示设备不支持制定版本的OpenGL ES。

 - (instantype) initWithAPI:(EAGLRenderingAPI) api;
 - (instantype) initWithAPI:(EAGLRenderingAPI)api sharegroup:(EAGLSharegroup *)sharegroup;

sharegroup作用:在一个线程中,上下文的状态与上下文对象是分离的,其状态都保存在group实力对象中,该对象是透明的,不应该主动创建该类的实例,这种设计方式是为了节约系统资源,对于不同的上下文可能拥有相同的上下文状态,那么这种设计方式十分便利。如需要在子线程中加载数据,在主线程中进行渲染,那么当数据加载完成之后,可以直接将子线程中的上下文状态绑定到主线程上下文中;

GLKView和CAEAGLLayer

GLKView

GLKView类为渲染提供了一个显示视图,在创建GLKView之后,要将其与上下文绑定,可以手动创建,通过以下函数:

- (instancetype)initWithFrame:(CGRect)frame context:(EAGLContext *)context;

也可以将某个UIView强制转换成GLKView,并绑定上下文,设置属性:

GLKView *view = (GLKView *)self.view;
view.context =[[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
view.srawableColorFormat = GLKViewDrawableColorFormatRGBA8888;

当创建了GLKView之后,就可以通过重写drawRect:方法来进行绘制:

 - (void)glkView:(GLKView *)view drawInRect:(CGRect)rect;

注意:ELKit提供了GLKBaseEffect着色器对象来进行绘图,下面这段代码来讲述着色器的使用;

@property (nonatomic , strong) GLKBaseEffect* mEffect;
self.mEffect = [[GLKBaseEffect alloc] init];
self.mEffect.texture2d0.enabled = GL_TRUE;
 self.mEffect.texture2d0.name = textureInfo.name;
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
glClearColor(0.3f, 0.6f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
//启动着色器
[self.mEffect prepareToDraw];
glDrawArrays(GL_TRIANGLES, 0, 6);
}

CAEAGLLayer

当使用CAEAGLLayer图层来进行渲染时,可以将某个view的layer强制转换成CAEAGLLLayer,设置放大倍数,描绘属性,并且重写改view的layerClass方法,下面这块代码将讲述改图层的初始设置;

+ (Class)layerClass {
    return [CAEAGLLayer class];
}

- (void)setupLayer
{
    self.myEagLayer = (CAEAGLLayer*) self.layer;
    //设置放大倍数
    [self setContentScaleFactor:[[UIScreen mainScreen] scale]];
    
    // CALayer 默认是透明的,必须将它设为不透明才能让其可见
    self.myEagLayer.opaque = YES;
    
    // 设置描绘属性,在这里设置不维持渲染内容以及颜色格式为 RGBA8
    self.myEagLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:
                                     [NSNumber numberWithBool:NO], kEAGLDrawablePropertyRetainedBacking, kEAGLColorFormatRGBA8, kEAGLDrawablePropertyColorFormat, nil];    
}

VBO和EBO

顶点缓冲对象(VBO)和索引缓冲对象(EBO)是两种管理顶点内存的方式,开始绘制图形之前,我们必须先给OpenGL输入一些顶点数据。它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
如下过程是创建一个VBO的过程:

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO); 
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glDrawArrays(GL_TRIANGLES, 0, 3);

在这里,我们可以使用glGenBuffers函数和一个缓冲ID生成一个VBO对象,使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上,然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中,最后调用glDrawArrays来进行渲染顶点数据。
glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据,第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:

  • GL_STATIC_DRAW :数据不会或几乎不会改变。
  • GL_DYNAMIC_DRAW:数据会被改变很多。
  • GL_STREAM_DRAW :数据每次绘制时都会改变。

接下来,我们再来看EBO的创建过程:

float vertices[] = {
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

unsigned int indices[] = { // 注意索引从0开始! 
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

与VBO类似,我们先绑定EBO然后用glBufferData把索引复制到缓冲里。同样,和VBO类似,我们会把这些函数调用放在绑定和解绑函数调用之间,只不过这次我们把缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER。
要注意的是,我们传递了GL_ELEMENT_ARRAY_BUFFER当作缓冲目标。最后一件要做的事是用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染

着色器shader

我们需要先了解一个概念,图形渲染管线:图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的,图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入,并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。
在这里,我们只需要关心顶点着色器和片段着色器,如果我们打算做渲染的话,现代OpenGL需要我们至少设置一个顶点和一个片段着色器,接下来,我们就逐一介绍这两个着色器

顶点着色器

它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。接下来我先上一段着色器的代码:

#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

着色器语言要求我们必须指定版本号,所以开头指明330;
下一步,使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute),我们看到这里用到了location = 0,这里我们给这个输入变量制定了一个位置值,便于之后将数据绑定到这个输入变量上,这里也可以不用location值,我们直接有函数可以拿到某个变量的位置值:

GL_API int GL_APIENTRY glGetAttribLocation (GLuint program, const GLchar* name)  __OSX_AVAILABLE_STARTING(__MAC_NA,__IPHONE_3_0);

为了设置顶点着色器的输出,我们必须把位置数据赋值给预定义的gl_Position变量,它在幕后是vec4类型的。在main函数的最后,我们将gl_Position设置的值会成为该顶点着色器的输出。由于我们的输入是一个3分量的向量,我们必须把它转换为4分量的,当然顶点着色器可以有多个输出,out关键字可以指定变量为输出变量;

片段着色器

片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。片段着色器的编写同顶点着色器一样,不过片段着色器只能有一个输出,将输出赋值给gl_FragColor,是一个四分量的值。

编译着色器

当我们编写了着色器程序之后,需要对其进行编译,链接,最终才能进行执行,接下来这段代码表明了如何编译一个着色器:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

我们首先要做的是创建一个着色器对象,注意还是用ID来引用的。所以我们储存这个顶点着色器为unsigned int,然后用glCreateShader创建这个着色器,我们把需要创建的着色器类型以参数形式提供给glCreateShader。由于我们正在创建一个顶点着色器,传递的参数是GL_VERTEX_SHADER,当需要创建一个片段着色器时我们需要传入GL_FRAGMENT_SHADER;
接下来我们把这个着色器源码附加到着色器对象上,然后对其进行编译,glShaderSource函数把要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码,第四个参数我们先设置为NULL。

着色器程序

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

下面是一个链接着色器程序的例子:

unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glUseProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

glCreateProgram函数创建一个程序,并返回新创建程序对象的ID引用。现在我们需要把之前编译的着色器附加到程序对象上,然后用glLinkProgram链接它们,得到的结果就是一个程序对象,我们可以调用glUseProgram函数,用刚创建的程序对象作为它的参数,以激活这个程序对象,在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象(也就是之前写的着色器)了。在把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了。

链接顶点属性

现在,我们已经创建了着色器程序和编译连接了他们,那么我们之前说的着色器的输入属性是何时有值的,这个时候,我们需要把顶点数据跟顶点属性关联起来,用到了以下函数:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

glVertexAttribPointer函数的参数非常多,所以我会逐一介绍它们:

  • 第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0。
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

最后,我们应该使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的;

纹理

纹理是一个2D图片,它可以用来添加物体的细节,纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角,接下来我们用一段程序来看看纹理的创建:

unsigned int texture;
glGenTextures(1, &texture);
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);   
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
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_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

glGenTextures函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的unsigned int数组中,就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理,其后是设置的一些参数,设置纹理的环绕方式以及过滤方式;我们可以使用载入的图片数据生成一个纹理了。纹理可以通过glTexImage2D来生成,函数很长,参数也不少,所以我们一个一个地讲解:

  • 第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
  • 第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
  • 第三个参数告诉OpenGL我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
  • 第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
  • 下个参数应该总是被设为0(历史遗留的问题)。
  • 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
  • 最后一个参数是真正的图像数据。

Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform和顶点属性有些不同。首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问;
我们可以用glGetUniformLocation查询uniform 属性的位置值。我们为查询函数提供着色器程序和uniform的名字。如果glGetUniformLocation返回-1就代表没有找到这个位置值。最后,我们可以通过glUniform类的函数函数设置uniform值,例如:glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);。

注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先使用程序(调用glUseProgram),因为它是在当前激活的着色器程序中设置uniform的。

FBO

帧缓冲区对象(FBO),在OpenGL渲染管线中几何数据和纹理经过变换和一些测试处理,最终会被展示到屏幕上。OpenGL渲染管线的最终位置是在帧缓冲区中。帧缓冲区是一系列二维的像素存储数组,包括了颜色缓冲区、深度缓冲区、模板缓冲区以及累积缓冲区。默认情况下OpenGL使用的是窗口系统提供的帧缓冲区。
OpenGL的GL_ARB_framebuffer_object这个扩展提供了一种方式来创建额外的帧缓冲区对象(FBO)。使用帧缓冲区对象,OpenGL可以将原先绘制到窗口提供的帧缓冲区重定向到FBO之中。
FBO中有两类绑定的对象:纹理图像(texture images)和渲染图像(renderbuffer images)。如果纹理对象绑定到FBO,那么OpenGL就会执行渲染到纹理(render to texture)的操作,如果渲染对象绑定到FBO,那么OpenGL会执行离屏渲染(offscreen rendering),FBO可以理解为包含了许多挂接点的一个对象,他提供了一种可以快速切换外部纹理对象和渲染对象挂接点的方式,在FBO中必然包含一个深度缓冲区挂接点和一个模板缓冲区挂接点,同时还包含许多颜色缓冲区挂节点。FBO提供了一种快速有效的方法挂接或者解绑这些外部的对象,对于纹理对象使用 glFramebufferTexture2D,对于渲染对象使用glFramebufferRenderbuffer ;

渲染到纹理

(1):创建帧缓存并绑定,glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
(2):创建纹理缓存并绑定;
glBindTexture(GL_TEXTURE_2D, texture);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

glActiveTexture(GL_TEXTURE1);
glGenFramebuffers(1, &_outputFrameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, _outputFrameBuffer);
glBindTexture(CVOpenGLESTextureGetTarget(_renderTexture), CVOpenGLESTextureGetName(_renderTexture));
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, CVOpenGLESTextureGetName(_renderTexture), 0);

这里的GL_COLOR_ATTACHMENT0是颜色缓冲,还可以再指定深度缓冲,这里的颜色缓冲区域有多个,具体多少个受OpenGL实现的影响,可以通过GL_MAX_COLOR_ATTACHMENTS使用glGet查询;

离屏渲染

(1):创建帧缓存并绑定,glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
(2):创建颜色渲染缓存并绑定,glBindRenderbuffer(GL_RENDERBUFFER, colorRenderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorRenderbuffer);
(3):创建渲染深度并绑定,glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRenderbuffer);//这一步可以不用;
其实渲染到纹理和离屏渲染的渲染目标不一样,离屏渲染最终渲染到renderbuffer之上,我们可以调用
[context presentRenderbuffer:GL_RENDERBUFFER];
将离屏渲染的图像绘制出来,在每一次绘制之前,我们需要调用
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
清空颜色和深度缓冲区,可以使用glViewport()函数指定渲染的视频区域, glViewport函数前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度(像素);

在同一个线程中切换不同的上下文时,需要注意应用应自己对上下文进行强引用以防止其被释放,并且在切换之前应调用 glFlush 函数将当前上下文提交的指令传到图形硬件中去。

最后,可以去看看OpenGL的教程~~

参考文献:
Learn OpenGL
OpenGL缓冲区对象之FBO
IOS OpenGL ES Guide