Hello, OpenGL World!

很多语言的入门均是从Hello World开始,那开启OpenGL的旅程我们也从Hello World开始。但OpenGL的Hello World相对于其它语言来说是具有一定难度的,因为在完成它时我们需要理解许多的概念,这让它变得没那么容易。本文通过一个在iOS设备上绘制一个三角形的案例来讲解OpenGL中的一些概念。
在iPhone中我们知道其坐标的原点是从左上角开始向下向右增加的坐标系,异于我们数学中所熟知的坐标系,而OpenGL中的坐标系则与数学中的坐标系相差无几(暂时忽略Z轴),不同的是OpenGL的坐标范围无论是哪一个方向上的范围均为-1至1。



在本案例中,我们在iPhone上绘制了一个具有a、b、c三个顶点的三角形,通过下图坐标系我们可以很容易地知道三角形的三个顶点的坐标分别是a(-0.5, -0.5)、b(0.5, -0.5)、c(0, 0.5)。



OpenGL中的世界是一个3D世界,而屏幕中的世界是一个2D世界,所以OpenGL大多数的工作是将3D世界的坐标转化为2D世界的坐标,并在相应的屏幕上现实相关的物体。OpenGL中将3D转化为2D的工作是有图形渲染管线管理的,图形渲染管线大致完成两部分工作:
1.将3D坐标转换为2D坐标;
2.将2D坐标转换为有实际颜色的像素。
图像渲染管线的工作流程(图片来源于LearnOpenGL CN)

从上图我们可以知道顶点着色器需要的输入是顶点数据,OpenGL的世界是一个3D世界,所以OpenGL的坐标均是3D坐标(x, y, z),前面我们已经得到了三角形的三个顶点的2D坐标,因为我们要渲染的三角形是一个2D三角形,所以我们设置三角形三个顶点的z坐标的值均为0,所以我们定义的顶点数据可以使用一个float的数组。

const float vertices[] = {
-0.5, -0.5, 0,      // point a
 0.5, -0.5, 0,      // point b
 0.0,  0.5, 0       // point c
};

在得到顶点数据以后,我们进入图像渲染管线工作的顶点着色器阶段。在顶点着色器阶段会在GPU上创建一块内存用于存储顶点数据,并告诉OpenGL如何解析这些顶点数据,最后将解析出来的数据发送到显卡上。在GPU上创建的这块内存会通过VBO(Vertex Buffer Object)顶点缓冲对象进行管理,使用VBO可以一次性将大量的顶点数据发送到显卡内存中,顶点着色器可以立即访问顶点,节省资源。

顶点输入

1.顶点缓冲对象的生成

顶点缓冲对象的生成是通过glGenBuffers (GLsizei n, GLuint* buffers)函数生成。下面的代码中我们生成了一个VBO对象,该VBO对象有一个唯一的ID(GLUint类型其实就是unsigned int类型)。

GLuint  VBO;
// 参数含义:
// GLsizei n: 生成多少个VBO对象
// GLuint* buffers: 缓冲ID
glGenBuffers(1, &VBO);
2.顶点对象的绑定

使用glBindBuffer函数把VBO对象绑定到指定的目标上,使用较多的是GL_ARRAY_BUFFER和GL_ELEMENT_ARRAY_BUFFER。

glBindBuffer(GL_ARRAY_BUFFER, VBO);
3.复制缓冲数据至顶点内存

绑定缓冲后,在所绑定的目标上的所有缓冲都会用来配置所绑定的VBO。这是可以使用glBufferData函数将缓冲数据复制到顶点内存。

// 参数含义:
// target: 目标缓冲类型
// size: 需要传输数据的大小
// data: 需要复制的数据
// usage: 显卡管理给定数据的方式
// GL_STREAM_DRAW: 数据会改变较多
// GL_STATIC_DRAW: 数据不会或几乎不会改变(因三角形的三个顶点固定不会改变,所以使用该类型)
// GL_DYNAMIC_DRAW: 数据会每次绘制改变
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

着色器(shader)

在OpenGL中如果我们需要渲染的话,我们必须至少实现一个顶点着色器和一个片段着色器。顶点着色器、几何着色器、片段着色器都是可编程的。而着色器程序的编写是使用着色器语言(glsl)进行编写的。

1.顶点着色器、片段着色器的编写

在本文中我们不过多的去介绍着色器相关的知识,在以后的文章中会详细介绍着色器的内容,前面我们提到,OpenGL进行渲染至少需要一个顶点着色器和一个片段着色器,我们在这里就贴出两个着色器的相关glsl的代码。

// 顶点着色器
char vShaderStr[] =
"#version 300 es                                \n"
"layout (location = 0) in vec4 vPosition;       \n"
"out vec4 fragColor;                            \n"
"void main()                                    \n"
"{                                              \n"
"   gl_Position = vPosition;                    \n"
"}                                              \n";

// 片段着色器
char fShaderStr[] =
"#version 300 es                                \n"
"precision mediump float;                       \n"
"out vec4 fragColor;                            \n"
"void main()                                    \n"
"{                                              \n"
"   fragColor = vec4(1.0, 0, 0, 1.0);           \n"
"}
2.着色器的创建和编译
a.着色器的创建

创建着色器我们使用glCreateShader函数,其返回的是一个GLuint类型ID,参数是需要创建的着色器的类型,可以使用GL_FRAGMENT_SHADER、GL_VERTEX_SHADER等值。当我们创建顶点着色器的时候我们使用GL_VERTEX_SHADER类型,需要创建片段着色器的时候使用GL_FRAGMENT_SHADER类型。

GLuint vShader = glCreateShader(GL_VERTEX_SHADER);           // 创建顶点着色器
GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER);         // 创建片段着色器
b.将着色器源码附加至着色器对象

将着色器源码附加至着色器对象上,我们使用glShaderSource函数。

// 绑定shader源码
// 参数含义
// shader: 指定将源码附加至哪个着色器
// count:  字符串源码数组的个数
// string: 字符串源码
// length: 字符串源码的长度
glShaderSource(vshader, 1, &vShaderStr, NULL);
glShaderSource(fshader, 1, &fShaderStr, NULL);
c.着色器编译

着色器编译使用glCompileShader函数,传入的值是需要编译的着色器。

// 编译着色器
glCompileShader(vShader);   // 编译顶点着色器
glCompileShader(fshader);   // 编译片段着色器

在编译期间可能会出现一些错误,我们要获得对应的错误信息可以结合glGetShaderiv、glGetShaderInfoLog函数获得相关的信息。

// 获取编译着色器失败的相关消息
int result;
// 参数含义
// shader: 需要查询的着色器
// pname: 查询类别:GL_COMPILE_STATUS、GL_SHADER_TYPE、GL_DELETE_STATUS、GL_INFO_LOG_LENGTH、GL_SHADER_SOURCE_LENGTH
// params: 返回查询对象的结果值
glGetShaderiv(shader, GL_COMPILE_STATUS, &result);
if (!result) {
    GLint infoLen = 0;
    glGetShaderiv(shaderType, GL_INFO_LOG_LENGTH, &infoLen);
    
    if (infoLen) {
        char *infoLog = malloc(sizeof(char) * infoLen);
        glGetShaderInfoLog(shaderType, infoLen, NULL, infoLog);
        NSLog(@"Error compiling shader:%@", [NSString stringWithUTF8String:infoLog]);
        free(infoLog);
    }
    
    glDeleteShader(shader);
}

上面的代码是已经封装好的代码其中的shaderType是从外界传入,即创建着色器时所用的类型,shader值得是前文的vShader或者fShader,因着色器的创建和编译的过程是相同的,不同的只是着色器的源码以及着色器的类型,所以,将该整个过程封装为一个函数。

- (GLuint)createShader:(GLenum)shaderType source:(const char *)source {

    // 创建shader
    GLuint shader = glCreateShader(shaderType);

    // 绑定shader源码
    // 参数含义
    // shader: 指定将源码附加至哪个着色器
    // count:  字符串源码数组的个数
    // string: 字符串源码
    // length: 字符串源码的长度
    glShaderSource(shader, 1, &source, NULL);
    // 编译着色器
    glCompileShader(shader);

    // 获取编译着色器失败的相关消息
    int result;
    // 参数含义
    // shader: 需要查询的着色器
    // pname: 查询类别:GL_COMPILE_STATUS、GL_SHADER_TYPE、GL_DELETE_STATUS、GL_INFO_LOG_LENGTH、GL_SHADER_SOURCE_LENGTH
    // params: 返回查询对象的结果值
    glGetShaderiv(shader, GL_COMPILE_STATUS, &result);
    if (!result) {
        GLint infoLen = 0;
        glGetShaderiv(shaderType, GL_INFO_LOG_LENGTH, &infoLen);
    
        if (infoLen) {
            char *infoLog = malloc(sizeof(char) * infoLen);
            glGetShaderInfoLog(shaderType, infoLen, NULL, infoLog);
            NSLog(@"Error compiling shader:%@", [NSString stringWithUTF8String:infoLog]);
            free(infoLog);
        }
    
        glDeleteShader(shader);
        return 0;
    }

    return shader;
}
d.着色器程序

多个着色器合并得到最终的输出需要依赖着色器程序对象。首先创建着色器程序对象,接着将需要合并的着色器attach(附加)至着色器程序对象上,最后通过着色器程序对象将attach的着色器链接起来。

- (void)setupProgram {
    // 着色器程序对象的生成
    self.program = glCreateProgram();
    // 将顶点着色器和片段着色器附加至着色器程序对象上
    glAttachShader(self.program, self.vShader);
    glAttachShader(self.program, self.fShader);
    // 开始讲着色器程序上的着色器链接
    glLinkProgram(self.program);

    int linkResult;
    // 获取着色器程序链接的状态
    glGetProgramiv(self.program, GL_LINK_STATUS, &linkResult);

    if (linkResult == GL_FALSE) {
        GLchar message[256];
        glGetProgramInfoLog(self.program, sizeof(message), 0, message);
        NSLog(@"Program link failure:%@", [NSString stringWithUTF8String:message]);
        exit(1);
    }
}

将着色器attach至着色器程序对象上后,删除相应的着色器。

- (void)deleteShaders {
    glDeleteShader(self.vShader);
    glDeleteShader(self.fShader);
}

在上文我们已经说过,在图像渲染管线的顶点着色器阶段除了会在GPU上创建一块内存存储顶点数据外,还需要告诉OpenGL如何解析这些顶点数据。下面我们来看一下如何解析顶点数据。
解析顶点数据使用glVertexAttribPointer函数。

// 参数含义
// indx: 顶点属性的位置
// size: 顶点属性的大小
// type: 顶点属性数据的类型
// normalized: 是否希望数据被标准化 GL_TRUE会把所有数据映射为0至1, GL_FALSE将所有数据映射为-1至1
// stride: 连续两个顶点属性之间的间隔
// ptr: 数据在缓冲中起始位置的偏移量
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);

告诉OpenGL如何解析顶点数据以后,启用顶点属性。

 // 启用顶点属性
glEnableVertexAttribArray(0);

最后,对物体进行渲染。

// 开始渲染
glUseProgram(self.program);

// 参数含义
// mode: 绘制的图元类型
// first: 起始索引
// count: 渲染的顶点数量
glDrawArrays(GL_TRIANGLES, 0, 3);

至此,OpenGL的渲染过程我们已经完成了,在iOS中,我们借用GLKit(Apple 对OpenGL的一些封装)的相关API区显示绘制的图形(为什么不直接讲GLKit的使用?OpenGL是跨平台的,不只针对于iOS)。这方面的使用较为简单,就是创建上下文,设置代理,实现代理方法等,我们最后的物体渲染于代理方法中实现。具体代码如下。

- (void)setupOpenGLContext {
    self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];

    GLKView *view = (GLKView *)self.view; // 需要将storyboard中的view的class改为GLKView
    view.context = self.context;
    view.drawableDepthFormat = GLKViewDrawableColorFormatRGBA8888;
    view.delegate = self;
    [EAGLContext setCurrentContext:self.context];
}

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
    [self setupRenderBuffers];
}

至此,运行Xcode可以在设备上看到一个红色的三角形。在OpenGL的渲染过程中,如有不明白之处,可以结合图像渲染管道的工作流程去理解,在这里我们只是简单的实现了将顶点数据输入,经顶点着色器,片段着色器处理,通过program链接着色器,最终渲染出图形。

本文集的所有代码均上传至Github

学习参考链接:
LearnOpenGL CN
OpenGL ES 3.0 Programming Guide

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,277评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,777评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,946评论 0 245
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,271评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,636评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,767评论 1 221
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,989评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,733评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,457评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,674评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,155评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,518评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,160评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,114评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,898评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,822评论 2 280
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,705评论 2 273

推荐阅读更多精彩内容