[OpenGL ES 02]OpenGL ES渲染管线与着色器
[OpenGL ES 02]OpenGL ES渲染管线与着色器
罗朝辉 (http://www.cnblogs.com/kesalin/)
前言
在前文《[OpenGL ES 01]iOS上OpenGL ES之初体验》中我们学习了如何在 iOS 平台上设置OpenGL ES 环境,主要是设置 CAEAGLLayer 属性,创建 EAGLContext,创建和使用 renderbuffer 和 framebuffer,并知道如何清屏。但实际上并没有真正描绘点什么。在本文中,我们将学习OpenGL ES 渲染管线,顶点着色器和片元着色器相关知识,然后使用可编程管线在屏幕上描绘一个简单三角形。
一,渲染管线
在 OpenGL ES 1.0 版本中,支持固定管线,而 OpenGL ES 2.0 版本不再支持固定管线,只支持可编程管线。什么是管线?什么又是固定管线和可编程管线?管线(pipeline)也称渲染管线,因为 OpenGL ES在渲染处理过程中会顺序执行一系列操作,这一系列相关的处理阶段就被称为OpenGL ES 渲染管线。pipeline 来源于福特汽车生产车间的流水线作业,在OpenGL ES 渲染过程中也是一样,一个操作接着一个操作进行,就如流水线作业一样,这样的实现极大地提供了渲染的效率。整个渲染管线如下图所示:
图中阴影部分的 Vertex Shader 和 Fragment Shader 是可编程管线。可编程管线就是说这个操作可以动态编程实现而不必固定写死在代码中。可动态编程实现这一功能一般都是脚本提供的,在OpenGL ES 中也一样,编写这样脚本的能力是由着色语言(Shader Language)提供的。那可编程管线有什么好处呢?方便我们动态修改渲染过程,而无需重写编译代码,当然也和很多脚本语言一样,调试起来不太方便。
再回到上图,这张图就是 OpenGL ES 的“架构图”,学习OpenGL ES 就是学习这张图中的每一个部分,在这里先粗略地介绍一下。
Vertex Array/Buffer objects:顶点数据来源,这时渲染管线的顶点输入,通常使用 Buffer objects效率更好。在今天的示例中,简单起见,使用的是 Vertex Array;
Vertex Shader:顶点着色器通过可编程的方式实现对顶点的操作,如进行坐标空间转换,计算 per-vertex color以及纹理坐标;
Primitive Assembly:图元装配,经过着色器处理之后的顶点在图片装配阶段被装配为基本图元。OpenGL ES 支持三种基本图元:点,线和三角形,它们是可被 OpenGL ES 渲染的。接着对装配好的图元进行裁剪(clip):保留完全在视锥体中的图元,丢弃完全不在视锥体中的图元,对一半在一半不在的图元进行裁剪;接着再对在视锥体中的图元进行剔除处理(cull):这个过程可编码来决定是剔除正面,背面还是全部剔除。
Rasterization:光栅化。在光栅化阶段,基本图元被转换为二维的片元(fragment),fragment 表示可以被渲染到屏幕上的像素,它包含位置,颜色,纹理坐标等信息,这些值是由图元的顶点信息进行插值计算得到的。这些片元接着被送到片元着色器中处理。这是从顶点数据到可渲染在显示设备上的像素的质变过程。
Fragment Shader:片元着色器通过可编程的方式实现对片元的操作。在这一阶段它接受光栅化处理之后的fragment,color,深度值,模版值作为输入。
Per-Fragment Operation:在这一阶段对片元着色器输出的每一个片元进行一系列测试与处理,从而决定最终用于渲染的像素。这一系列处理过程如下:
Pixel ownership test:该测试决定像素在 framebuffer 中的位置是不是为当前 OpenGL ES 所有。也就是说测试某个像素是否对用户可见或者被重叠窗口所阻挡;
Scissor Test:剪裁测试,判断像素是否在由 glScissor 定义的剪裁矩形内,不在该剪裁区域内的像素就会被剪裁掉;
Stencil Test:模版测试,将模版缓存中的值与一个参考值进行比较,从而进行相应的处理;
Depth Test:深度测试,比较下一个片段与帧缓冲区中的片段的深度,从而决定哪一个像素在前面,哪一个像素被遮挡;
Blending:混合,混合是将片段的颜色和帧缓冲区中已有的颜色值进行混合,并将混合所得的新值写入帧缓冲;
Dithering:抖动,抖动是使用有限的色彩让你看到比实际图象更多色彩的显示方式,以缓解表示颜色的值的精度不够大而导致的颜色剧变的问题。
Framebuffer:这是流水线的最后一个阶段,Framebuffer 中存储这可以用于渲染到屏幕或纹理中的像素值,也可以从Framebuffer 中读回像素值,但不能读取其他值(如深度值,模版值等)。
二,顶点着色器
下面来仔细看看顶点着色器:
顶点着色器接收的输入:
Attributes:由 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 可用于顶点着色器和片元着色器。
Shader program:由 main 申明的一段程序源码,描述在顶点上执行的操作:如坐标变换,计算光照公式来产生 per-vertex 颜色或计算纹理坐标。
顶点着色器的输出:
Varying:varying 变量用于存储顶点着色器的输出数据,当然也存储片元着色器的输入数据,varying 变量最终会在光栅化处理阶段被线性插值。顶点着色器如果声明了 varying 变量,它必须被传递到片元着色器中才能进一步传递到下一阶段,因此顶点着色器中声明的 varying 变量都应在片元着色器中重新声明同名同类型的 varying 变量。OpenGL ES 2.0 也规定了所有实现应该支持的最大 varying 变量个数不能少于 8 个。
在顶点着色器阶段至少应输出位置信息-即内建变量:gl_Position,其它两个可选的变量为:gl_FrontFacing 和 gl_PointSize。
三,片元着色器
接下来仔细看看片元着色器:
片元管理器接受如下输入:
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。
四,顶点着色与片元着色在编程上的差异
1,精度上的差异
着色语言定了三种级别的精度:lowp, mediump, highp。我们可以在 glsl 脚本文件的开头定义默认的精度。如下代码定义在 float 类型默认使用 highp 级别的精度
precision highp float;
在顶点着色阶段,如果没有用户自定义的默认精度,那么 int 和 float 都默认为 highp 级别;而在片元着色阶段,如果没有用户自定义的默认精度,那么就真的没有默认精度了,我们必须在每个变量前放置精度描述符。此外,OpenGL ES 2.0 标准也没有强制要求所有实现在片元阶段都支持 highp 精度的。我们可以通过查看是否定义 GL_FRAGMENT_PRECISION_HIGH 来判断具体实现是否在片元着色器阶段支持 highp 精度,从而编写出可移植的代码。当然,通常我们不需要在片元着色器阶段使用 highp 级别的精度,推荐的做法是先使用 mediump 级别的精度,只有在效果不够好的情况下再考虑 highp 精度。
2,attribute 修饰符只可用于顶点着色。这个前面已经说过了。
3,或由于精度的不同,或因为编译优化的原因,在顶点着色和片元着色阶段同样的计算可能会得到不同的结果,这会导致一些问题(z-fighting)。因此 glsl 引入了 invariant 修饰符来修饰在两个着色阶段的同一变量,确保同样的计算会得到相同的值。
五,使用顶点着色器与片元着色器
好了,理论知识讲得足够多了,下面我们来看看如何在代码中添加顶点着色器与片元着色器。我们在前一篇文章《[OpenGL ES 01]iOS上OpenGL ES之初体验》代码的基础上进行编码。在前面提到可编程管线通过用 shader 语言编写脚本文件实现的,这些脚本文件相当于 C 源码,有源码就需要编译链接,因此需要对应的编译器与链接器,shader 对象与 program 对象就相当于编译器与链接器。shader 对象载入源码,然后编译成 object 形式(就像C源码编译成 .obj文件)。经过编译的 shader 就可以装配到 program 对象中,每个 program对象必须装配两个 shader 对象:一个顶点 shader,一个片元 shader,然后 program 对象被连接成“可执行文件”,这样就可以在 render 中是由该“可执行文件”了。
1,创建,装载和编译 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 { // Create the shader object GLuint shader = glCreateShader(type); if (shader == 0) { NSLog(@"Error: failed to create shader."); return 0; } // Load the shader source const char * shaderStringUTF8 = [shaderString UTF8String]; glShaderSource(shader, 1, &shaderStringUTF8, NULL); // Compile the shader glCompileShader(shader); // Check the compile status 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,然后装载它,编译它。下面详细介绍每个步骤。
1),创建/删除 shader
函数 glCreateShader 用来创建 shader,参数 GLenum type 表示我们要处理的 shader 类型,它可以是 GL_VERTEX_SHADER 或 GL_FRAGMENT_SHADER,分别表示顶点 shader 或 片元 shader。它返回一个句柄指向创建好的 shader 对象。
函数 glDeleteShader 用来销毁 shader,参数为 glCreateShader 返回的 shader 对象句柄。
2),装载 shader
函数 glShaderSource 用来给指定 shader 提供 shader 源码。第一个参数是 shader 对象的句柄;第二个参数表示 shader 源码字符串的个数;第三个参数是 shader 源码字符串数组;第四个参数一个 int 数组,表示每个源码字符串应该取用的长度,如果该参数为 NULL,表示假定源码字符串是 \0 结尾的,读取该字符串的内容指定 \0 为止作为源码,如果该参数不是 NULL,则读取每个源码字符串中前 length(与每个字符串对应的 length)长度个字符作为源码。
3),编译 shader
函数 glCompileShader 用来编译指定的 shader 对象,这将编译存储在 shader 对象中的源码。我们可以通过函数 glGetShaderiv 来查询 shader 对象的信息,如本例中查询编译情况,此外还可以查询 GL_DELETE_STATUS,GL_INFO_LOG_STATUS,GL_SHADER_SOURCE_LENGTH 和 GL_SHADER_TYPE。在这里我们查询编译情况,如果返回 0,表示编译出错了,错误信息会写入 info 日志中,我们可以查询该 info 日志,从而获得错误信息。
2,编写着色脚本
GLESUtils 提供的接口让我们可以使用两种方式:脚本字符串或脚本文件来提供 shader 源码,通常使用脚本文件方式有更大的灵活性。(Cocos2D 源码中倒是提供了不少脚本字符串应对一些常见的情况,有兴趣的同学可以查看下)。在这里,我们使用脚本文件方式。
1),添加顶点着色脚本
右击 Supporting Files 目录,New File->Other->Empty,输入名称:VertexShader.glsl,去除 target Tutorial02 中的勾选。后缀glsl 表示 GL Shader Language。
编辑其内容如下:
attribute vec4 vPosition; void main(void) { gl_Position = vPosition; }
然后选择 Tutorial02,在 Build Phases -> Copy Bundle Sources 中添加 VertexShader.glsl。
顶点着色脚本的源码很简单,如果你仔细阅读了前面的介绍,就一目了然。 attribute 属性 vPosition 表示从应用程序输入的类型为 vec4 的位置信息,输出内建 vary 变量 vPosition。留意:这里使用了默认的精度。
2),添加片元着色脚本
用于添加顶点着色脚本同样的方式添加名为 FragmentShader.glsl 的文件,编辑其内容如下:
precision mediump float; void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }
不用忘记在 Build Phases -> Copy Bundle Sources 中添加 FragmentShader.glsl。
片元着色脚本源码也很简单,前面说过片元着色要么自己定义默认精度,要么在每个变量前添加精度描述符,在这里自定义 float 的精度为 mediump。然后为内建输出变量 gl_FragColor 指定为红色。
3,创建 program,装配 shader,链接 program,使用 program
1),创建 program
在 OpenGLView.h 的 OpenGLView 类声明中添加两个成员:
GLuint _programHandle;
GLuint _positionSlot;
然后依然在 OpenGLView.m 中的匿名 category 中添加成员方法:
- (void)setupProgram;
在 - (void)render 方法前,添加其实现:
- (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 进行操作。
4,使用示例
在 - (void)layoutSubviews 中调用 render 方法之前,插入对 setupProgram 的调用:
[self setupProgram];
[self render];
然后改写 render 方法:
- (void)render { glClearColor(0, 1.0, 0, 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 将三角形图元渲染出来。
5,编译运行
编译运行,将看到一个红色的三角形显示在屏幕中央。知道为什么是红色的么?那是因为 program 也链接了片元着色器,在片元着色脚本文件中,我们指定 gl_FragColor 的值为红色 vec4(1.0, 0.0, 0.0, 1.0)。
六,总结
在前文《[OpenGL ES 01]iOS上OpenGL ES之初体验》和本文中,我们详细了解了如何在 iPhone 中使用 OpenGL ES 的整个过程,包括设置 CAEAGLLayer 属性,创建 EAGLContext,创建和使用 renderbuffer 和 framebuffer,了解OpenGL ES 渲染管线,创建和使用 shader,创建和实现 program,使用顶点数组进行描绘。流程已经走通,接下来让我们进入 OpenGL ES 各个具体的技术领域。
本文源码可以在这里获得:https://github.com/kesalin/OpenGLES/tree/master/Tutorial02