很多语言的入门均是从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坐标转换为有实际颜色的像素。
从上图我们可以知道顶点着色器需要的输入是顶点数据,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