iOS学习OpenGL ES

关于OpenGL:

OpenGL是一个跨平台的库,可用于与GPU进行接口编程,以呈现实时3D图形。其在CAD、内容创作、能源、娱乐、游戏开发、制造业、制药业及虚拟现实等数据可视化应用中的使用是常见的。
OpenGL的前身是SGI公司为其图形工作站开发的IRIS GL。IRIS GL是一个工业标准的3D图形软件接口,功能虽然强大但是移植性不好,于是SGI公司便在IRIS GL的基础上开发了OpenGL。
在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。关于OpenGL管线,这里有一篇非常不错的文章,主要讲解着色器的,顺带描述了管线的各个阶段底层执行原理。OpenGL管线(用经典管线代说着色器内部)

近年来, Khronos集团对OpenGL已经采取了标准的管理,将其更新为支持现代GPU可编程的特性,在OpenGL 3中将其精简化,弃用过时凌乱的早期版本库的功能。并且针对针对手机、PDA和游戏主机等嵌入式设备而推出移动领域的OpenGL ES和在线领域的WebGL。这里我们就主要介绍移动领域的OpenGL ES的开发使用。

图形管线:
先来一张图形管线每个阶段的抽象展示图:

可以看出图形管线包含很多部分:先接受一组3D坐标,然后经过图元装配 -- 几何着色器 -- 光栅化 -- 片段着色器 -- 最后测试混合才转变为你屏幕上的有色2D像素输出。图形渲染管线的每个阶段将会把前一个阶段的输出作为这个阶段的输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

注意上图中的蓝色阶段代表我们可以注入自定义的着色器,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。

图形管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。

顶点输入
在开始绘图之前,我们需要先给OpenGL输入一些顶点数据。由于我们希望渲染一个三角形,我们需要指定三个顶点,每个顶点都有一个3D位置。我们会将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个float数组。

const GLfloat vertices[] = {
        0.0f,  0.5f, 0.0f,
        -0.5f, -0.5f, 0.0f,
        0.5f,  -0.5f, 0.0f };
/*
由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0。
这样子的话三角形每一点的深度都是一样的,从而使它看上去像是2D的。

标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间:
                (0,1)
                  ^    Z
                  |   /
                  |  /
                  | /
            (0,0) |/
(-1,0)------------/---------- >(1,0)
                 /|
                / |
               /  |
              /   |
               (0,-1)
*/

定义好这样的顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器,它会在GPU上创建内存用于储存我们的顶点数据。

我们通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

在学习顶点缓冲对象之前,建议将下面这三个单词先记下来:
顶点数组对象:Vertex Array Object,VAO
顶点缓冲对象:Vertex Buffer Object,VBO
索引缓冲对象:Element Buffer Object,EBO或Index Buffer Object,IBO

顶点缓冲对象
OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。这个缓冲有一个独一无二的ID,所以我们可以使用glGenBuffers函数和一个缓冲ID生成一个顶点缓冲对象:

// 创建一个渲染缓冲区对象
GLuint vertexBuffer;
    
 // 使用glGenBuffers()生成新缓存对象并指定缓存对象标识符ID
glGenBuffers(1, &vertexBuffer);

OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:

// 绑定vertexBuffer到GL_ARRAY_BUFFER目标
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);

从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:

// 为VBO申请空间,初始化并传递数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

/*
glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数
第一个参数是目标缓冲的类型:把当前顶点缓冲对象绑定到GL_ARRAY_BUFFER目标上
第二个参数指定传输数据的大小(以字节为单位),用sizeof计算出顶点数据大小就行
第三个参数是我们希望发送的实际数据,就是前面定义的顶点坐标数组
第四个参数是一个枚举,指定了我们希望显卡如何管理给定的数据:
     GL_STATIC_DRAW :数据不会或几乎不会改变。
     GL_DYNAMIC_DRAW:数据会被改变很多。
     GL_STREAM_DRAW :数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时都保持原样,
所以它的使用类型最好是GL_STATIC_DRAW

如果说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,
这样就能确保显卡把数据放在能够高速写入的内存部分。
*/

到这里我们已经把顶点数据储存在显卡的内存中了,并且交给上面的顶点缓冲对象管理。至于如何处理这些数据已渲染出不同的图像那就需要了解顶点着色器和片段着色器了。

着色器
在OpenGL ES 3.0中,除非加载有效的顶点和片段着色器,否则不会绘制任何几何图形。在前面我们已经简单讲解了图形管线,学习了有关顶点和片段着色器的知识。这两个着色器程序描述顶点变换和片段绘制。

下面来看一个最基本的顶点着色器:

attribute vec4 Position; 
void main(void) {
    gl_Position = Position; 
}
1.声明一个输入属性数组 -- 一个名为Position的4分量向量
3.main函数表示着色器执行的开始
4.着色器主体:将Position输入属性拷贝到名为gl_Position的特殊输出变量,
每个顶点着色器必须在gl_Position变量中输出一个位置,这个变量定义传递到线管下一个阶段的位置。

下面是一个片段着色器源码:

precision mediump float;
void main(void) {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 
}
/*
 第一行是声明着色器中浮点变量的默认精度。
 第二行表示着色器的入口函数
 第三行入口函数主体代码,一个4分享的值被输出到颜色缓冲区,它表示的是最终的输出颜色。
 */

Xcode如果创建着色器源码文件:Xcode顶部菜单栏 -- File -- New -- File -- iOS Other -- Empty(注意Save As 的文件后缀为.glsl),如下图:

上面这些只是写好的源码,并不具有处理数据的能力,我们需要在工程中动态编译生成着色器对象。

编译着色器
前面定义里顶点着色器和片段着色器源代码,如果程序中需要用到它,我们必须在运行时动态编译源码以得到一个着色器对象。下面是封装好的动态编译方法:

- (GLuint)compileShader:(NSString *)shaderName withType:(GLenum)shaderType {
    
    // NSBundle中加载文件
    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);
    }
    
    // 使用glCreateShader函数可以创建指定类型的着色器对象。shaderType是指定创建的着色器类型
    GLuint shader = glCreateShader(shaderType);
    
    // 这里把NSString转换成C-string
    const char* shaderStringUTF8 = [shaderString UTF8String];
    
    int shaderStringLength = (int)shaderString.length;
    
    // 使用glShaderSource将着色器源码加载到上面生成的着色器对象上
    glShaderSource(shader, 1, &shaderStringUTF8, &shaderStringLength);
    
    // 调用glCompileShader 在运行时编译shader
    glCompileShader(shader);
    
    // glGetShaderiv检查编译错误(然后退出)
    GLint compileSuccess;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &compileSuccess);
    if (compileSuccess == GL_FALSE) {
        GLchar messages[256];
        NSString *messageString = [NSString stringWithUTF8String:messages];
        NSLog(@"%@", messageString);
        exit(1);
    }
    
    // 返回一个着色器对象
    return shader;
}

创建了两个着色器对象之后,我们还需要创建一个程序对象。以为只有着色器程序才具备渲染功能。
从概念上说,程序对象可以视为最终链接的程序。不同的着色器编译为一个着色器程序对象之后,它们必须链接到一个程序对象并一起链接,才能绘制图形。下面是处理代码:

- (void)compileShaders {
    
// 生成一个顶点着色器对象
GLuint vertexShader = [self compileShader:@"SimpleVertex" withType:GL_VERTEX_SHADER];
    
// 生成一个片段着色器对象
GLuint fragmentShader = [self compileShader:@"SimpleFragment" withType:GL_FRAGMENT_SHADER];
    
/*
 调用了glCreateProgram glAttachShader  glLinkProgram 连接 vertex 和 fragment shader成一个完整的program。
 着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。
 如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,
 然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。
*/
    
GLuint programHandle = glCreateProgram();   // 创建一个程序对象
glAttachShader(programHandle, vertexShader); // 链接顶点着色器
glAttachShader(programHandle, fragmentShader); // 链接片段着色器
glLinkProgram(programHandle); // 链接程序
    
// 把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
    
// 调用 glGetProgramiv来检查是否有error,并输出信息。
GLint linkSuccess;
glGetProgramiv(programHandle, GL_LINK_STATUS, &linkSuccess);
if (linkSuccess == GL_FALSE) {
    GLchar messages[256];
    NSString *messageString = [NSString stringWithUTF8String:messages];
    NSLog(@"着色器程序:%@", messageString);
    exit(1);
}
    
// 调用 glUseProgram绑定程序对象 让OpenGL ES真正执行你的program进行渲染
glUseProgram(programHandle);
}

至此,我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。就快要完成了,但还没结束,OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。

链接顶点属性
顶点着色器允许我们指定任何以顶点属性形式的数据输入。这使其具有很强的灵活性的同时,它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。顶点缓冲数据在显存中会被解析成这样:

然后使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据:

// 使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
/*
glVertexAttribPointer函数的参数非常多,下面逐一说明:
第一个参数GLuint indx:指定要配置的顶点属性,设置数据传递到指定位置顶点属性中
第二个参数GLint size:指定顶点属性的大小
第三个参数GLenum type:指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)
第四个参数GLboolean normalized:定义数据是否被标准化(Normalize)。如果设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。因为我们传入的数据就是标准化数据,所以我们把它设置为GL_FALSE
第五个参数GLsizei stride:设置连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,
        我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。
        一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子
      (这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)
最后一个参数const GLvoid *ptr:类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。
*/

由于顶点属性可以被enable或者disable,并且被disable的属性是不会传递给shader的,即便在shader里定义了这些属性,它们读出的值也会是一个常量,而非真正的数据。顶点属性默认是禁用的,所以需要调用函数glEnableVertexAttribArray启用顶点属性,与其对应的是glDisableVertexAttribArray函数:

glEnableVertexAttribArray(0);
参数GLuint indx直接传入顶点属性编号索引

至此我们已经使用一个顶点缓冲对象将顶点数据初始化至缓冲区中,并且建立了一个顶点和一个片段着色器程序对象,告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。最后只需要调用绘制函数glDrawArrays进行绘制:

glDrawArrays(GL_TRIANGLES, 0, 3);
/*
参数说明:
第一个参数GLenum mode:绘制方式,这里是绘制三角形,所以选择GL_TRIANGLES
第二个参数GLint first:从数组缓存中的哪一位开始绘制,一般为0。
第三个参数GLsizei cout:数组中顶点数据的数量。
*/

展示最后的绘制结果:

就是一个三角形了。如果glDrawArrays的第一个参数选择GL_POINTS,那绘制结果就是三个点。

绘制矩形
在OpenGL中基本图元是构成几何体的基本要素,在OpenGL 2.0的时候支持的图元包括10种,有:点(GL_POINTS)、线段(GL_LINES)、多线段(GL_LINE_STRIP)、线圈(GL_LINE_LOOP)、三角形(GL_TRIANGLES)、三角形条带(GL_TRIANGLE_STRIP)、三角形扇(GL_TRIANGLE_FAN)、四边形(GL_QUADS)、四边形条带(GL_QUAD_STRIP)、多边形(凸)(GL_POLYGON),但是到OpenGL 3.3版本,对之前版本中可能引起效率低下的图元类型进行了精简,同时为了配合着色语言,添加了新的类型,相比之前的版本,删除了四边形和多边形的类型(由于四边形和多边形不能保证所有组成它们的点一定共面),同时新增了一些应用在Geomtry Shader中的几何类型。在最新版本的OpenGL 4.5中支持的图元类型与3.3基本一致,添加了一项斑块图元(GL_PATCHES)。
这里需要绘制一个矩形,由于新版本不支持四边形图元,我们可以采用绘制两个三角形组成一个矩形。
首先需要修改顶点坐标数组:

const GLfloat 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,  
        -0.5f, -0.5f, 0.0f, 
        -0.5f, 0.5f, 0.0f   
    };

定义了两个三角形的坐标。
然后修改绘制函数的参数,这里的顶点数组中总共有6个点,所以函数应该为:

glDrawArrays(GL_TRIANGLES, 0, 6);

这样绘制出来的就是一个矩形了:

如上图,我们使用两个三角形就绘制出了一个矩形,观察两个三角形的顶点坐标发现,第2个顶点与第4个顶点相同(0.5,-0.5,0.0),第3个顶点与第6个顶点相同(-0.5,0.5,0.0)。其实对于矩形来说,它只需要4个坐标,在这里我门指定的右下角和左上角两次,这样就产生了不必要的开销。在本工程中,我们只是绘制一个矩形,如果我们需要绘制成千上万个矩形的时候,这种绘制方式会产生更多的消耗。
OpenGL提供了解决方案,只需要存储不同的顶点,并且设定绘制顺序,这样的话我们只要存储4个顶点和指定绘制顺序就可以了。而存储这4个顶点绘制顺序的对象就是索引缓冲对象(Element Buffer Object,EBO)了,和顶点缓冲对象一样,EBO也是一个缓冲,它专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。所以这种绘制方法可以称之为索引绘制了。
修改代码,首先还是修改顶点坐标:

// 定义4个顶点坐标
const GLfloat 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 // 左上角
};

// 定义绘制顺序
const GLubyte indices[] = {
        0,1,3,   // 绘制第一个三角形
        1,2,3    // 绘制第二个三角形       
 };

当使用索引绘制的时候,我们只定义了4个顶点,而不是6个。下一步我们需要创建索引缓冲对象:

GLuint indexBuffer;
glGenBuffers(1, &indexBuffer);

与VBO类似,我们先绑定EBO然后用glBufferData把索引复制到缓冲里。同样,和顶点缓冲对象类似,我们会把这些函数调用放在绑定和解绑函数调用之间,只不过这次我们把缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER。

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

最后是绘制,需要注意的是如果我们继续调用glDrawArrays传输或指定的顶点数据进行绘制的话,得到的图形就只是一个三角形,因为在vertices中只够绘制一个三角形的坐标顶点,这里需要根据指定的数据索引顺序进行绘制,用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染。使用glDrawElements时,我们会使用当前绑定的索引缓冲对象中的索引进行绘制:

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

参数说明:
第一个参数GLenum mode:与glDrawArrays函数的第一个参数一样,指定绘制方式,这里是绘制三角形,所以选择GL_TRIANGLES
第二个参数GLsizei cout:绘制顶点的个数
第三个参数GLenum type:为索引数组(indices)中元素的类型,只能是下列值之一
       GL_UNSIGNED_BYTE, 
       GL_UNSIGNED_SHORT, 
       GL_UNSIGNED_INT
第四个参数const GLvoid *indices:指向索引存贮位置的指针

这样的话绘制出的矩形就如上图那样了。这里我们可以发现,根据vertices中的坐标应该是个正方形,但是绘制出来的却是长方形,这事由于屏幕的宽高不一样了,对应的OpenGL坐标系的X、Y轴坐标单元也会不同。

看完这篇,如果你可以成功的绘制出这个三角形,那表示你已经熟悉OpenGL的几个最重要知识点了,如果你还有懵懂的地方,没关系,后面我还会再深入讲解,一点点的深入每个部分知识点。万事开头难,学习OpenGL也是一样,前期枯燥的概念会让你提不起兴趣,还有那些繁杂的缓存对象以及处理方式会让你无从下手学起。这些都没有关系,只要你能坚持,多花时间,我相信一定会在iOS中玩转OpenGL的。

文章有讲解不到位或者是错误的地方欢迎留言讨论,谢谢!
如果有需要源码的朋友可以移步GitHub Hello-Triangle--01

参考:《OpenGL ES 编程指南》