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 轴之后如下:
顶点着色器的一大任务就是需要将输入的顶点处理成标准坐标,因为这只是一个例子,我们在后面的顶点着色器中会直接使用输入的坐标,所以这里生成的三个顶点是以标准坐标轴来构建的;
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);
}
- 版本号
上述代码中,第一行首先声明了 GLSL 的版本为 ES 版本,且版本号为 300;
GLSL ES 中,需要在第一行使用 #version number es
来声明版本,且必须在所有预处理命令和注释之前;
如果不使用 #version 来声明版本,则默认使用 1.0 版本的 GLSL ES;
文档如下:
- 向量
向量是 GLSL 中的一种变量类型,文档如下:
解释一下:
vec + number :表示该向量有多少个分量,比如上文中的 vec3 表示有三个分量,这里用来表示顶点的 x,y,z,而输出的 vec4 表示有 4 个分量。这里 vec4 中第四个分量为w,不表示空间位置 ,而是和透视除法有关,暂时都设置为1;
type + vec :表示向量中分量的类型,默认是 float,所以省略了。但是如果是其他类型则需要加上 type 的前缀,比如 ivec2 表示有 2 个分量类型为 int 的向量;
in + location
in 的官方文档如下:
因为顶点着色器是第一个着色器,直接接受外部的数据。而其他着色器只能从上一个着色器接收输入参数。所以,顶点着色器可以使用 location 来表示需要从顶点数组中的哪个位置开始取数据。这个示例中显然第一个顶点就是有效数据,所以设置 location = 0;
in 表示输入的顶点属性。顶点属性是指顶点数据会被怎样的方式解析,类似于 MVC 中的 Data Model 的角色,后文会讲到。这里,使用 in 来表示输入的顶点属性是一个 vec3 的向量;
至于 layout 的其他作用,可以自行查阅官方文档;
- out
官方文档如下:
同顶点着色器类似,片段着色器作为混合前的最后一个输出,也可以定义 location。
另外,out 表述输出到下一个着色器的数据类型,暂不赘述;
- 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);
}
这里有几个注意点:
- layout + location 上文已经讲了个大概,不再赘述;
- 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,且已经告诉了着色器如何取解析顶点数据,那是不是万事大吉了?
此时需要考虑一个场景:重复绘制。假设上图的顶点表示的三角形需要重复多次绘制,那么是不是上面的步骤中:
- Vertex Array 的生成;
- Buffer 的创建;
- 从 CPU 传递数据到 GPU 的 Buffer;
- 顶点数据属性设置;
这些步骤都需要重新再做一次。
其实这种场景并不少见,比如 GLKViewController 中一秒会调用 60 次代理方法 - glkView:drawInRect:
进行绘制,如果上述的代码写在代理方法中,上述 4 个步骤每秒都要重复 60 次?这个是个人猜测,实际情况可以使用 Instrument 进行验证;
所以,此时 VAO 就出场了。
VAO:Vertex Array Object,顶点数组对象。用于记录顶点数组的数据和顶点数据属性的解析格式;
猜测 VAO 应该是指向 GPU 中的内存的?这样避免了频繁地从 CPU 向 GPU 传递数据;
另外,需要说明两点:
- OpenGL 的核心模式(core)要求我们使用 VAO,如果不绑定 VAO 或者绑定失败,那么 OpenGL 就不会进行任何绘制;
- draw call 调用时,以当前绑定的 VAO 所存储的数据来进行绘制;
总之,VAO 是你绕不过去的,所以还是好好学习下这是个啥吧~~~
官方解释有点虚幻,说人话就是这个方法会影响下列函数:
- glBufferData :将数据从 CPU 复制到 GPU
- glVertexAttribPointer :属性设置相关方法
- glEnableVertexAttribArray/glDisableVertexAttribArray:开启/关闭顶点数组
以上方法调用的结果会存储在 VAO 中,下次如果需要使用这些顶点和对应的属性解析格式,不需要进行上述四步,只需要重新 bind 这个 VAO 即可。
官方图示如下:
所以,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 就作为当前着色器的数据源了。后续继续调用了 glBufferData
、glVertexAttribPointer
、glEnableVertexAttribArray
三个函数,相关的数据被绑定到了这个 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);
}
}
演示效果如下: