美文网首页
openGL ES 教程(三):画一个三角形

openGL ES 教程(三):画一个三角形

作者: 康小曹 | 来源:发表于2022-04-20 16:26 被阅读0次

    1. iOS 中初始化上下文

    iOS 中不需要开发者调用 openGL ES 相关 Api 来设置上下文,貌似也没有找到类似 glfw 的三方框架来设置 window,感觉也没必要,所以 window 和上下文的概念就不再赘述了。

    iOS 中直接使用 GLKViewController 中的 GLKView 就可以初始化上下文:

    - (void)setupConfig {
        //新建OpenGLES 上下文
        self.mContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
        //VC是GLViewContrlller,storyboard要修改类型
        GLKView* view = (GLKView *)self.view;
        view.context = self.mContext;
        view.drawableColorFormat = GLKViewDrawableColorFormatRGBA8888;  //颜色缓冲区格式
        [EAGLContext setCurrentContext:self.mContext];
    }
    

    2. 顶点数据准备

    这里使用三个顶点来画一个三角形:

    // create Vertex Array
    GLfloat vertex[] = {
        0.0,0.5,0.0, // point1:x,y,z
        0.5,-0.5,0.0,// point2:x,y,z
        -0.5,-0.5,0.0// point3:x,y,z
    };
    

    这里需要知道一个概念:标准化设备坐标(Normalized Device Coordinates, NDC);

    标准坐标轴忽略 z 轴之后如下:

    NDC

    顶点着色器的一大任务就是需要将输入的顶点处理成标准坐标,因为这只是一个例子,我们在后面的顶点着色器中会直接使用输入的坐标,所以这里生成的三个顶点是以标准坐标轴来构建的;

    3. 传递顶点数据到 GPU

    上文中的三个顶点是在 CPU 内存中创建,而渲染管线是在 GPU 中完成的,所以需要将这些顶点着色器传入 GPU 中供后续阶段的着色器使用。

    GPU 中的内存通常使用 Buffer 来表示,Buffer 有种,常见的 Frame Buffer 就是其中的一种。

    现在需要将顶点数组传递给 GPU 中的 Buffer,就需要创建一个 Buffer 来接收这些数据,这就是 VBO(Vertex Buffer Object)。VBO 可以一次性发送一大批数据到显卡上,当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,其创建过程如下:

    // 创建VBO(Vertex Buffer Object),用于从CPU发送顶点数据到GPU
    unsigned int VBO;
    // 第一个参数是数量?第二个参数是地址,当id来使用
    glGenBuffers(1, &VBO);
    

    上文就是一个 Buffer 的创建,创建完成之后还需要进行绑定类型,以此来告诉 GPU 这个 Buffer 是用来做什么的,顶点数据 Buffer 的类型是 GL_ARRAY_BUFFER,绑定代码如下:

    // 绑定缓冲类型,顶点缓冲类型是GL_ARRAY_BUFFER
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    

    绑定完成之后,接下来在 GL_ARRAY_BUFFER 类型的缓冲函数的调用就都会操作这个 VBO,可以使用 glBufferData 来从 CPU 传递数据到 GPU :

    // 复制顶点数据到缓冲内存(CPU->GPU)
    glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);
    

    glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数,其入参如下:

    • 第一个参数:目标缓冲的类型;

    当顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上;

    • 第二个参数:指定传输数据的大小(以字节为单位);

    用一个简单的sizeof 计算出顶点数据大小就行。这个 demo 中顶点数组直接定义在同一函数内,如果数组作为指针传递过来的,那么还需要同时传递 length,因为 sizeof 计算出来的永远是指针的大小,而不是数组的真实大小;

    • 第三个参数:实际数据;

    这里直接传入顶点数组即可;

    • 第四个参数:指定了我们希望显卡如何管理给定的数据;

    这个参数决定数据被写入内存中哪个部分,如高速缓存还是普通内存。它有三种形式:
    GL_STATIC_DRAW :数据不会或几乎不会改变。
    GL_DYNAMIC_DRAW:数据会被改变很多。
    GL_STREAM_DRAW :数据每次绘制时都会改变。
    三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是 GL_STATIC_DRAW。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是 GL_DYNAMIC_DRAW 或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。

    上述函数调用完毕之后,顶点数据已经从 CPU 传递到了 GPU,接下来可以进行渲染操作了;

    虽然有些手机设备上 GPU 和 CPU 共享内存,即 GPU 的 Buffer 也是位于 CPU 内存上的,但是学习时应当做区分,这样对概念会比较清晰;

    4. 顶点着色器

    要使用着色器,就需要知道 GLSL(OpenGL Shading Language),openGL ES 中的着色器语言则称为 GLSL ES;

    着色器本身是一个微型程序,编写该程序需要使用对应的语言,iOS 中使用的是 OpenGL ES 3.0,对应的 GLSL ES 3.0。GLSL 其实和 C 语言很类似,语法也很简单,只需要注意一些特定的规则即可,比如 version 的声明、in 和 out 来制定输入和输出参数等,这里就不再赘述了,具体语法可以参考官方文档:

    https://www.khronos.org/registry/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf

    顶点被传入 GPU 之后首先需要被顶点着色器处理成标准坐标轴,这里我们简单起见,直接使用将输入的顶点进行输出,所以,这个简单的着色器代码如下:

    // GLSL ES  3.0 版本
    #version 300 es
    // 输入的顶点是一个分量为3(vec3)的向量aPos(变量名)
    layout (location = 0) in vec3 aPos;
    
    void main() {
      // 这里直接传递输入的顶点数据作为输出
      gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
      // 第二种写法:
      gl_Position = vec4(aPos,1.0);
    }
    
    1. 版本号

    上述代码中,第一行首先声明了 GLSL 的版本为 ES 版本,且版本号为 300;

    GLSL ES 中,需要在第一行使用 #version number es 来声明版本,且必须在所有预处理命令和注释之前;

    如果不使用 #version 来声明版本,则默认使用 1.0 版本的 GLSL ES;

    文档如下:

    version
    1. 向量

    向量是 GLSL 中的一种变量类型,文档如下:

    向量

    解释一下:

    1. vec + number :表示该向量有多少个分量,比如上文中的 vec3 表示有三个分量,这里用来表示顶点的 x,y,z,而输出的 vec4 表示有 4 个分量。这里 vec4 中第四个分量为w,不表示空间位置 ,而是和透视除法有关,暂时都设置为1;

    2. type + vec :表示向量中分量的类型,默认是 float,所以省略了。但是如果是其他类型则需要加上 type 的前缀,比如 ivec2 表示有 2 个分量类型为 int 的向量;

    3. in + location

    in 的官方文档如下:

    in + location

    因为顶点着色器是第一个着色器,直接接受外部的数据。而其他着色器只能从上一个着色器接收输入参数。所以,顶点着色器可以使用 location 来表示需要从顶点数组中的哪个位置开始取数据。这个示例中显然第一个顶点就是有效数据,所以设置 location = 0;

    in 表示输入的顶点属性。顶点属性是指顶点数据会被怎样的方式解析,类似于 MVC 中的 Data Model 的角色,后文会讲到。这里,使用 in 来表示输入的顶点属性是一个 vec3 的向量;

    至于 layout 的其他作用,可以自行查阅官方文档;

    1. out

    官方文档如下:

    out

    同顶点着色器类似,片段着色器作为混合前的最后一个输出,也可以定义 location。

    另外,out 表述输出到下一个着色器的数据类型,暂不赘述;

    1. gl_Position

    gl_Position 表示顶点着色器输出结果,顶点着色器需要输出一个分量为4的向量,所以这里没有声明 out ;

    至于代码中的两种写法皆可,具体可以参照 GLSL ES 的语法标准;

    5. 编译顶点着色器

    顶点着色器的代码写完之后还需要编译,而编译时只接受 C 类型的 char 字符,所以需要这样转换:

    const char *vertexShaderSource = "#version 300 es\n"
        "layout (location = 0) in vec3 aPos;\n"
        "void main() {\n"
        "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
        "}\0";
    

    接下来需要编译着色器,编译着色器之前需要创建着色器:

    // 创建着色器
    GLuint vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    

    创建完成之后就可以编译了:

    /**
     * glShaderSource函数把要编译的着色器对象作为第一个参数。
     * 第二参数指定了传递的源码字符串数量,这里只有一个。
     * 第三个参数是顶点着色器真正的源码
     * 第四个参数我们先设置为NULL。
     */
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    // 编译着色器
    glCompileShader(vertexShader);
    

    编译着色器时,可能会因为语法或者版本等原因而报错,可以通过下面的方法来获取报错信息:

    // 检测编译是否成功
    int  success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success) {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
        NSLog(@"shader compile failed:%s",infoLog);
    }
    

    6. 片段着色器

    因为系统有提供几何着色器,所以为了简单起见,我们使用系统默认的几何着色器即可,直接来书写片段着色器;

    本例子中的着色器相当简单,直接输出蓝色即可:

    #version 300 es
    layout (location = 0) out lowp vec4 myColor;
    void main() {
        myColor = vec4(1.0, 0.5, 0.2, 1.0);
    }
    

    这里有几个注意点:

    1. layout + location 上文已经讲了个大概,不再赘述;
    2. GLSL 3.0 中,对于片段片段着色器的输出,需要指示精度;

    如果不指示精度会报错:

    2022-04-20 11:08:45.211535+0800 XKOpenGL[11112:1652554] ERROR: 0:2: 'vec4' : declaration must include a precision qualifier for type
    

    2D 中精度使用 lowp 即可:

    精度选择

    7. 编译片段着色器

    编译过程和顶点着色器一致,不再赘述:

    const char *fragmentShaderSource = "#version 300 es\n"
        "layout (location = 0) out lowp vec4 myColor;\n"
        "void main() {\n"
            "myColor = vec4(1.0, 0.5, 0.2, 1.0);\n"
        "}\0";
    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    
    int  fragmentCompileSuccess;
    char fragmentInfoLog[512];
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &fragmentCompileSuccess);
    if(!fragmentCompileSuccess){
        glGetShaderInfoLog(fragmentShader, 512, NULL, fragmentInfoLog);
        NSLog(@"%s",fragmentInfoLog);
    }
    

    8. 链接着色器生成着色器程序

    编译之后的着色器程序还需要连接到主着色器程序中,其代码如下:

    // 创建着色器程序
    unsigned int shaderProgram;
    shaderProgram = glCreateProgram();
    // 添加着色器
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    // 链接
    glLinkProgram(shaderProgram);
    
    int linkSuccess;
    char linkInfoLog[512];
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &linkSuccess);
    if(!linkSuccess) {
        glGetProgramInfoLog(shaderProgram, 512, NULL, linkInfoLog);
        NSLog(@"%s",linkInfoLog);
    }
    

    9. 激活着色器程序并清理已链接完成的着色器

    激活着色器主程序之后,pipline 中就会使用该着色器程序来执行整个 pipline;

    另外,链接完成的着色器代码已经被复制到了着色器主程序,应当删除以释放内存;

    代码如下:

    // 激活程序,每个着色器调用和渲染调用都会使用这个程序对象
    glUseProgram(shaderProgram);
    // 链接到着色器程序之后(相当于被打包生成了可执行程序),就可以删除原来的两个小着色器了
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
    

    10. 顶点属性解析

    通过上面的步骤,我们已经完成了:
    1.已经把顶点数据从CPU内存复制到了GPU缓存
    2.使用顶点着色器指示了 GPU 如何处理顶点并输出给下一个着色器
    3.使用片段着色器指示了 GPU 生成的像素的色值
    4.编译并链接了两个着色器生成了最终的着色器程序
    5.激活了着色器程序,后续pipline都使用这个着色器程序
    6.删除了已经链接完成的两个着色器

    至此,是不是可以调用 draw call 了?并不能,因为 OpenGL 还不知道它该如何解释 Buffer 中的顶点数据。

    顶点数据传递到 GPU 后仍然是一对浮点类型的数组,一个顶点该取 4 个元素还是 3 个?从哪个位置开始取值?第二个点又从哪里取?等等一系列问题 GPU 都是不知道的,所以还需要告诉顶点着色器如何解析这些顶点数据:

    // 以点的方式来解析顶点
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,  3 * sizeof(float), (void*)0);
    // 顶点属性默认是禁用的,这里启动顶点数据
    glEnableVertexAttribArray(0);
    
    • 第一个参数:指定我们要配置的顶点属性

    这个参数和 layout (location = 0) 中的 location 类似,告诉着色器应该从顶点数组的第几个元素开始取数据;

    • 第二个参数:指定顶点属性的大小

    这里的大小是指数组中元素的个数。顶点属性是一个 vec3,它由 3 个 float 类型的值来标识一个顶点,所以大小是3。

    • 第三个参数:指定数据的类型

    这里是 GL_FLOAT;

    • 第四个参数:是否希望数据被标准化(Normalize)。

    如果我们设置为 GL_TRUE,所有数据都会被映射到 0(对于有符号型signed数据是-1)到1之间。我们把它设置为 GL_FALSE。

    • 第五个参数:步长(Stride)

    补偿告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个 float 之后,我们把步长设置为 3 * sizeof(float)。

    要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为 0 来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子;

    • 第六个参数:缓冲中起始位置的偏移量

    该参数类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是 0。

    11. VAO

    上面已经将顶点数据传递到了 GPU,且已经告诉了着色器如何取解析顶点数据,那是不是万事大吉了?

    此时需要考虑一个场景:重复绘制。假设上图的顶点表示的三角形需要重复多次绘制,那么是不是上面的步骤中:

    1. Vertex Array 的生成;
    2. Buffer 的创建;
    3. 从 CPU 传递数据到 GPU 的 Buffer;
    4. 顶点数据属性设置;
      这些步骤都需要重新再做一次。

    其实这种场景并不少见,比如 GLKViewController 中一秒会调用 60 次代理方法 - glkView:drawInRect: 进行绘制,如果上述的代码写在代理方法中,上述 4 个步骤每秒都要重复 60 次?这个是个人猜测,实际情况可以使用 Instrument 进行验证;

    所以,此时 VAO 就出场了。

    VAO:Vertex Array Object,顶点数组对象。用于记录顶点数组的数据和顶点数据属性的解析格式;

    猜测 VAO 应该是指向 GPU 中的内存的?这样避免了频繁地从 CPU 向 GPU 传递数据;

    另外,需要说明两点:

    1. OpenGL 的核心模式(core)要求我们使用 VAO,如果不绑定 VAO 或者绑定失败,那么 OpenGL 就不会进行任何绘制;
    2. draw call 调用时,以当前绑定的 VAO 所存储的数据来进行绘制;

    总之,VAO 是你绕不过去的,所以还是好好学习下这是个啥吧~~~

    官方解释有点虚幻,说人话就是这个方法会影响下列函数:

    1. glBufferData :将数据从 CPU 复制到 GPU
    2. glVertexAttribPointer :属性设置相关方法
    3. glEnableVertexAttribArray/glDisableVertexAttribArray:开启/关闭顶点数组

    以上方法调用的结果会存储在 VAO 中,下次如果需要使用这些顶点和对应的属性解析格式,不需要进行上述四步,只需要重新 bind 这个 VAO 即可。

    官方图示如下:

    VAO和VBO

    所以,VAO 相关的代码必须写在 VBO 之前,前面步骤的代码更新后如下:

    // 创建 VAO
    unsigned int VAO;
    glGenVertexArrays(1, &VAO);
    // 绑定 VAO
    glBindVertexArray(VAO);
    
    // 初始化顶点数组
    GLfloat triangleVertices[] = {
        -0.5f, -0.5f, 0.0f,
         0.5f, -0.5f, 0.0f,
         0.0f,  0.5f, 0.0f
    };
    
    // 创建VBO
    unsigned int VBO;
    glGenBuffers(1, &VBO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    
    // 从CPU传递数据到GPU
    glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);
    
    // 设置顶点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,  3 * sizeof(float), (void*)0);
    // 启动顶点数据
    glEnableVertexAttribArray(0);
    
    ... 省略着色器等代码...
    

    上述代码调用了 glBindVertexArray 之后,该 VAO 就作为当前着色器的数据源了。后续继续调用了 glBufferDataglVertexAttribPointerglEnableVertexAttribArray 三个函数,相关的数据被绑定到了这个 VAO 上,所以可以直接进行 draw call 调用了~~~

    12. draw call

    iOS 中需要在 GLKView 的代理方法中进行 draw call 的调用:

    - (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);
        
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }
    

    13. surprise

    结果如下:

    结果

    14. 多个 VBO 之间的切换

    官网代代码演示的只有一个 VAO,这里使用两个 VAO 来进一步认识 VAO 的角色和作用;

    用代码来表示展示,首先创建两个 VAO:

    @property(assign, nonatomic) GLuint vaoTriangleOne;
    @property(assign, nonatomic) GLuint vaoTriangleTwo;
    

    这里我们首先对上述 4 个可能重复的步骤以及 VAO 的绑定操作进行了封装,源码如下:

    - (void)setupVertexArrayObject:(GLuint *)vao vertices:(GLfloat[])vertices length:(GLuint)length strideCount:(GLuint)strideCount {
        // create Vertex Array Object
        glGenVertexArrays(1, vao);
        glBindVertexArray(*vao);
        // create vertex buffer object
        GLuint vbo;
        glGenBuffers(1, &vbo);
        // bind buffer object
        glBindBuffer(GL_ARRAY_BUFFER, vbo);
        // copy data to buffer
        glBufferData(GL_ARRAY_BUFFER, length *sizeof(GLfloat), vertices, GL_STATIC_DRAW);
        // attr
        glVertexAttribPointer(0, strideCount, GL_FLOAT, GL_FALSE,  strideCount * sizeof(float), (void*)0);
        // 顶点属性默认是禁用的,这里启动顶点数据
        glEnableVertexAttribArray(0);
    }
    

    此时,就可以创建两个顶点数据来绑定 VAO 了:

    GLfloat triangleOne[] = {
        0,0.5,1.0,
        0.5,-0.5,1.0,
        0,-0.5,1.0
    };
    [self setupVertexArrayObject:&_vaoTriangleOne vertices:triangleOne length:9 strideCount:3];
    
    GLfloat triangleTwo[] = {
        0,0.5,1.0,
        -0.5,-0.5,1.0,
        0,-0.5,1.0
    };
    [self setupVertexArrayObject:&_vaoTriangleTwo vertices:triangleTwo length:9 strideCount:3];
    

    上述代码调用了两次 glBindVertexArray 函数,也就是顶点数据在 GPU 中的位置、如何解析顶点属性、顶点数组是否开启,这个结果已经被保存在了两个 VAO 中了,接下来的调用代码可以简化成下面:

    - (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);
        
        if (self.shouldShowSecond) {
            glBindVertexArray(self.vaoTriangleTwo);
        } else {
            glBindVertexArray(self.vaoTriangleOne);
        }
    
        // shader program
        if (self.program != 0) {
            glDrawArrays(GL_TRIANGLES, 0, 3);
        }
    }
    

    演示效果如下:

    switchVAO.gif

    相关文章

      网友评论

          本文标题:openGL ES 教程(三):画一个三角形

          本文链接:https://www.haomeiwen.com/subject/plisertx.html