OpenGL ES渲染管线(渲染流程)
渲染管线
苹果提供了两种OpenGL ES的可视化模型,一种是客户端—服务端的架构模型,另一种就是管线的模型。
![](https://img.haomeiwen.com/i113685/7ec9e05529a57319.png)
客户端—服务端架构:应用程序状态一旦更改,纹理、顶点数据以及渲染命令都将传递给OpenGL ES客户端。客户端将这些数据转换成图形硬件可以理解的格式,发送给GPU。这个过程增加了app图形性能的开销。为了获得好的性能需要管理好这种开销。一个好的设计需要减少OpenGL ES调用的频率,使用适合硬件的数据格式来减少转换成本,并且管理好自身与OpenGL ES的数据流。
管线架构:另外就是本文将要说的管线架构,由于管线中每个独立阶段都依赖于上一阶段的产出,所以任何阶段的工作量太大或者运行太慢,其他阶段都会被迫闲置以等待前一阶段的完成。好的设计会根据硬件功能平衡每个阶段的执行工作。
![](https://img.haomeiwen.com/i113685/f936a0556c6c8a11.png)
在 OpenGL ES 1.0 版本中,支持固定管线(fixed-function pipeline),有一系列固定的函数用来在屏幕上渲染对象,而不是创建一个单独的程序来指导 GPU 的行为。这样有很大的局限性,你不能做出任何特殊的效果。如果想知道着色器在工程中可以造成怎样的不同,看看这篇 Brad Larson 写的他用着色器替代固定函数重构 Molecules 应用的博客
而 OpenGL ES 2.0 版本不再支持固定管线,只支持可编程管线。
什么是管线?什么又是固定管线和可编程管线?
管线(pipeline):也称渲染管线,因为 OpenGL ES在渲染处理过程中会顺序执行一系列操作,这一系列相关的处理阶段就被称为OpenGL ES 渲染管线。OpenGL ES 渲染过程就如流水线作业一样,这样的实现极大地提高了渲染的效率。如图就是 OpenGL ES 的管线图,学习OpenGL ES 就是学习这张图中的每一个部分。
![](https://img.haomeiwen.com/i113685/c9f7a7393950e744.png)
图中阴影部分的 Vertex Shader 和 Fragment Shader 是可编程管线。可编程管线就是说这个操作可以动态编程而不必写死在代码中。可动态编程实现这一功能一般都是脚本提供的,在OpenGL ES 中也一样,编写这样脚本的能力是由着色语言GLSL提供的。那可编程管线有什么好处呢?方便我们动态修改渲染过程,而无需重写编译代码,当然也和很多脚本语言一样,调试起来不太方便。
渲染管线中的各个模块
Vertex Shader
![](https://img.haomeiwen.com/i113685/d0ee2481e3500c50.png)
由图可见,顶点着色器分输入输出两部分。顶点着色器定义了在 2D 或者 3D 场景中几何图形是如何处理的,实现了顶点操作的通用可编程方法。一个顶点指的是 2D 或者 3D 空间中的一个点。在图像处理中,有 4 个顶点:每一个顶点代表图像的一个角。顶点着色器设置顶点的位置,并且把位置和纹理坐标这样的参数发送到片段着色器。
输入:
- 着色器程序(Shader Program,图中没有画出):由 main 申明的一段程序源码或可执行文件,描述在顶点上执行的操作:如坐标变换、计算光照公式产生每个顶点颜色、计算纹理坐标。
- 属性(Attribute):由 vertext array 提供的顶点数据,如空间位置,法向量,纹理坐标以及顶点颜色,属性可以理解为针对每一个顶点的输入数据。属性只在顶点着色器中才有,片元着色器中没有属性。OpenGL ES 2.0 规定了所有实现应该支持的最大属性个数不能少于 8 个。
- 常量(Uniforms): Uniforms保存由应用程序传递给着色器的只读常量数据。在顶点着色器中,这些数据通常是变换矩阵,光照参数,颜色等。由 uniform 修饰符修饰的变量属于全局变量,该全局性对顶点着色器与片元着色器均可见,也就是说,这两个着色器如果被连接到同一个应用程序中,它们共享同一份 uniform 全局变量集。因此如果在这两个着色器中都声明了同名的 uniform 变量,要保证这对同名变量完全相同:同名+同类型,因为它们实际是同一个变量。此外,uniform 变量存储在常量存储区,因此限制了 uniform 变量的个数,OpenGL ES 2.0 也规定了所有实现应该支持的最大顶点着色器 uniform 变量个数不能少于 128 个,最大的片元着色器 uniform 变量个数不能少于 16 个。
- 采样器(Samplers): 一种特殊的 uniform,用于呈现纹理。sampler 可用于顶点着色器和片元着色器。
输出: - 可变变量(Varying):varying 变量用于存储顶点着色器的输出数据,也存储片元着色器的输入数据。varying 变量会在光栅化处理阶段被线性插值。顶点着色器如果声明了 varying 变量,它必须被传递到片元着色器中才能进一步传递到下一阶段,因此顶点着色器中声明的 varying 变量都应在片元着色器中重新声明为同名同类型的 varying 变量。OpenGL ES 2.0 也规定了所有实现应该支持的最大 varying 变量个数不能少于 8 个。
- 在顶点着色器阶段至少应输出位置信息-即内建变量:gl_Position,其它两个可选的变量为:gl_FrontFacing 和 gl_PointSize。
Primitive Assembly图元装配(还有啥好的翻译?)
图元(Primitive):OpenGL ES 支持三种基本图元:点,线和三角形,它们是可被 OpenGL ES 渲染的。经过着色器处理之后的顶点在这一阶段被装配为基本图元。对于每个图元,必须确定图元是否位于视锥体(屏幕上可见的3D空间区域)内,保留完全在视锥体中的图元,丢弃完全不在视锥体中的图元,对一半在一半不在的图元进行裁剪。裁剪之后,顶点位置就被转换成了屏幕坐标。也可以再对在视锥体中的图元进行剔除(cull):这个过程可编码来决定是剔除正面,背面还是全部剔除。裁剪和剔除之后,图元便准备传递给管线的下一个阶段——光栅化阶段。
Rasterization
![](https://img.haomeiwen.com/i113685/54fbcb7856e14cb6.png)
在光栅化阶段,基本图元被转换为一组二维的片元(fragment),fragment 表示可以被渲染到屏幕上的像素,它包含位置,颜色,纹理坐标等信息,这些值是由图元的顶点信息进行插值计算得到的。这些片元接着被送到片元着色器中处理。这是从顶点数据到可渲染在显示设备上的像素的质变过程。
Fragment Shader
![](https://img.haomeiwen.com/i113685/f7d270be43304d56.png)
GPU 使用片元着色器在对象或者图片的每一个像素上进行计算,处理由光栅化阶段生成的每个片元,最终计算出每个像素的最终颜色。图片,归根结底,实际上仅仅是数据的集合。图片的文档包含每一个像素的各个颜色分量和像素透明度的值。因为对每一个像素,算式是相同的,GPU 可以流水线作业这个过程,从而更加有效的进行处理。使用正确优化过的着色器,在 GPU 上进行处理,将获得百倍于在 CPU 上用同样的过程进行图像处理的效率。
- 可变变量(Varyings):这个在前面已经讲过了,顶点着色器阶段输出的 varying 变量在光栅化阶段被线性插值计算之后输出到片元着色器中作为它的输入,即上图中的 gl_FragCoord,gl_FrontFacing 和 gl_PointCoord。OpenGL ES 2.0 也规定了所有实现应该支持的最大 varying 变量个数不能少于 8 个。
- 常量(Uniforms):前面也已经讲过,这里是用于片元着色器的常量,如雾化参数,纹理参数等;OpenGL ES 2.0 也规定了所有实现应该支持的最大的片元着色器 uniform 变量个数不能少于 16 个。
- 采样器(Samples):一种特殊的 uniform,用于呈现纹理。
- 着色器程序(Shader program):由 main 申明的一段程序源码,描述在片元上执行的操作。
- 在顶点着色器阶段只有唯一的 varying 输出变量-即内建变量:gl_FragColor。
顶点着色器与片元着色器的编程区别
-
精度上的差异
着色语言定了三种级别的精度:lowp, mediump, highp。我们可以在 glsl 脚本文件的开头定义默认的精度。precision highp float;
- 在顶点着色阶段,如果没有自定义默认精度,那么 int 和 float 都默认为 highp 级别;
- 在片元着色阶段,如果没有自定义默认精度,就真的没有默认精度了.我们必须在每个变量前放置精度描述符。此外,OpenGL ES 2.0 标准也没有强制要求所有实现在片元阶段都支持 highp 精度。我们可以通过查看是否定义 GL_FRAGMENT_PRECISION_HIGH 来判断具体实现是否在片元着色器阶段支持 highp 精度,从而编写出可移植的代码。通常不需要在片元着色器阶段使用 highp 级别的精度,推荐先使用 mediump 级别的精度,只有在效果不够好的情况下再考虑 highp 精度。
-
attribute 修饰符只可用于顶点着色。
使用顶点着色器和片元着色器
可编程管线通过用 GLSL 语言编写脚本文件实现的,这些脚本文件相当于 C 源码,有源码就需要编译链接,因此需要对应的编译器与链接器,shader 对象与 program 对象就相当于编译器与链接器。shader 对象载入源码,然后编译成 object 形式(就像C源码编译成 .obj文件)。经过编译的 shader 就可以装配到 program 对象中,每个 program对象必须装配两个 shader 对象:一个顶点 shader,一个片元 shader,然后 program 对象被连接成“可执行文件”,这样就可以在 render 中运行该“可执行文件”了。
-
创建,装载和编译 shader
- 向工程中添加新的类 GLESUtils,让它继承自 NSObject。GLESUtils.h 为
#import <Foundation/Foundation.h> #include <OpenGLES/ES2/gl.h> @interface GLESUtils : NSObject // Create a shader object, load the shader source string, and compile the shader. // +(GLuint)loadShader:(GLenum)type withString:(NSString *)shaderString; +(GLuint)loadShader:(GLenum)type withFilepath:(NSString *)shaderFilepath; @end
- GLESUtils.m
#import "GLESUtils.h" @implementation GLESUtils +(GLuint)loadShader:(GLenum)type withFilepath:(NSString *)shaderFilepath { NSError* error; NSString* shaderString = [NSString stringWithContentsOfFile:shaderFilepath encoding:NSUTF8StringEncoding error:&error]; if (!shaderString) { NSLog(@"Error: loading shader file: %@ %@", shaderFilepath, error.localizedDescription); return 0; } return [self loadShader:type withString:shaderString]; } +(GLuint)loadShader:(GLenum)type withString:(NSString *)shaderString { //创建shader GLuint shader = glCreateShader(type); if (shader == 0) { NSLog(@"Error: failed to create shader."); return 0; } //装载shader const char * shaderStringUTF8 = [shaderString UTF8String]; glShaderSource(shader, 1, &shaderStringUTF8, NULL); //编译shader glCompileShader(shader); //查询变异状态 GLint compiled = 0; glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); if (!compiled) { GLint infoLen = 0; glGetShaderiv ( shader, GL_INFO_LOG_LENGTH, &infoLen ); if (infoLen > 1) { char * infoLog = malloc(sizeof(char) * infoLen); glGetShaderInfoLog (shader, infoLen, NULL, infoLog); NSLog(@"Error compiling shader:\n%s\n", infoLog ); free(infoLog); } glDeleteShader(shader); return 0; } return shader; } @end
辅助类 GLESUtils 中有两个类方法用来跟进 shader 脚本字符串或 shader 脚本文件创建 shader,然后装载它,编译它。下面详细介绍每个步骤。
-
创建/删除 shader
函数 glCreateShader 用来创建 shader,参数 GLenum type 表示我们要处理的 shader 类型,它可以是 GL_VERTEX_SHADER 或 GL_FRAGMENT_SHADER,分别表示顶点 shader 或 片元 shader。它返回一个句柄指向创建好的 shader 对象。
函数 glDeleteShader 用来销毁 shader,参数为 glCreateShader 返回的 shader 对象句柄。 -
装载 shader
函数 glShaderSource 用来给指定 shader 提供 shader 源码。第一个参数是 shader 对象的句柄;第二个参数表示 shader 源码字符串的个数;第三个参数是 shader 源码字符串数组;第四个参数一个 int 数组,表示每个源码字符串应该取用的长度,如果该参数为 NULL,表示假定源码字符串是 \0 结尾的,读取该字符串的内容指定 \0 为止作为源码,如果该参数不是 NULL,则读取每个源码字符串中前 length(与每个字符串对应的 length)长度个字符作为源码。 -
编译 shader
函数 glCompileShader 用来编译指定的 shader 对象,这将编译存储在 shader 对象中的源码。 -
查询shader对象信息
函数 glGetShaderiv 来查询 shader 对象的信息,如本例中查询编译情况。此外还可以查询 GL_DELETE_STATUS,GL_INFO_LOG_STATUS,GL_SHADER_SOURCE_LENGTH 和 GL_SHADER_TYPE。在这里我们查询编译情况,如果返回 0,表示编译出错了,错误信息会写入 info 日志中,我们可以查询该 info 日志,从而获得错误信息。
-
编写着色脚本
添加VertexShader.glsl和FragmentShader.glsl文件- VertexShader.glsl
attribute vec4 vPosition; void main(void) { gl_Position = vPosition; }
attribute 属性 vPosition 表示从应用程序输入的类型为 vec4 的位置信息,输出内建 vary 变量 vPosition。注意:这里使用了默认的精度。
- FragmentShader.glsl
precision mediump float; void main() { gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);//RGBA,此处为黄色 }
片元着色脚本源码也很简单,前面说过片元着色要么自己定义默认精度,要么在每个变量前添加精度描述符,在这里自定义 float 的精度为 mediump。然后为内建输出变量 gl_FragColor 指定为黄色。
-
创建 program,装配 shader,链接 program,使用 program
- OpenGLView.h中添加两个成员
GLuint _programHandle; GLuint _positionSlot;
- 在 OpenGLView.m 中的匿名 category 中添加成员方法:
- (void)setupProgram { // Load shaders NSString * vertexShaderPath = [[NSBundle mainBundle] pathForResource:@"VertexShader" ofType:@"glsl"]; NSString * fragmentShaderPath = [[NSBundle mainBundle] pathForResource:@"FragmentShader" ofType:@"glsl"]; GLuint vertexShader = [GLESUtils loadShader:GL_VERTEX_SHADER withFilepath:vertexShaderPath]; GLuint fragmentShader = [GLESUtils loadShader:GL_FRAGMENT_SHADER withFilepath:fragmentShaderPath]; // Create program, attach shaders. _programHandle = glCreateProgram(); if (!_programHandle) { NSLog(@"Failed to create program."); return; } glAttachShader(_programHandle, vertexShader); glAttachShader(_programHandle, fragmentShader); // Link program glLinkProgram(_programHandle); // Check the link status GLint linked; glGetProgramiv(_programHandle, GL_LINK_STATUS, &linked ); if (!linked) { GLint infoLen = 0; glGetProgramiv (_programHandle, GL_INFO_LOG_LENGTH, &infoLen ); if (infoLen > 1) { char * infoLog = malloc(sizeof(char) * infoLen); glGetProgramInfoLog (_programHandle, infoLen, NULL, infoLog ); NSLog(@"Error linking program:\n%s\n", infoLog ); free (infoLog ); } glDeleteProgram(_programHandle); _programHandle = 0; return; } glUseProgram(_programHandle); // Get attribute slot from program _positionSlot = glGetAttribLocation(_programHandle, "vPosition"); }
首先由 GLESUtils 提供的辅助方法从前面创建的脚本中创建,装载和编译顶点 shader 和片元 shader;然后我们创建 program,将顶点 shader 和片元 shader 装配到 program 对象中,再使用 glLinkProgram 将装配的 shader 链接起来,这样两个 shader 就可以合作干活了。注意:链接过程会对 shader 进行可链接性检查,也就是前面说到同名变量必须同名同型以及变量个数不能超出范围等检查。我们如何检查 shader 编译情况一样,对 program 的链接情况进行检查。如果一切正确,那我们就可以调用 glUseProgram 激活 program 对象从而在 render 中使用它。通过调用 glGetAttribLocation 我们获取到 shader 中定义的变量 vPosition 在 program 的槽位,通过该槽位我们就可以对 vPosition 进行操作。
-
使用示例
在 - (void)layoutSubviews 中调用 render 方法之前,插入对 setupProgram 的调用[self setupProgram]; [self render];
render方法:
- (void)render {
glClearColor(0.5, 1.0, 0.5, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
// Setup viewport
glViewport(0, 0, self.frame.size.width, self.frame.size.height);
GLfloat vertices[] = {
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f };
// Load the vertex data
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, vertices );
glEnableVertexAttribArray(_positionSlot);
// Draw triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
[_context presentRenderbuffer:GL_RENDERBUFFER];
}
在新增的代码中,第一句 glViewport 表示渲染 surface 将在屏幕上的哪个区域呈现出来,然后我们创建一个三角形顶点数组,通过 glVertexAttribPointer 将三角形顶点数据装载到 OpenGL ES 中并与 vPositon 关联起来,最后通过 glDrawArrays 将三角形图元渲染出来。
![](https://img.haomeiwen.com/i113685/130ee4051ebb9fa0.png)
代码存放在绘制一个三角形中New Group文件夹
OpenGL ES渲染管线与着色器
OpenGL ES 3.0编程指南
Apple文档——OpenGL ES Design Guidelines
Notes on OpenGL ES Graphics Pipeline
GPU 加速下的图像处理