iOS开发OpenGL ES - 自定义纹理

多重纹理的强大和灵活性在使用自定义的着色器程序时会变得更加明显。在之前已经讲过,在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

还是看一下图形管线每个阶段的抽象展示图:

可以看出图形管线包含很多部分:先接受一组3D坐标,然后经过图元装配 --> 几何着色器 --> 光栅化 --> 片段着色器 --> 最后测试混合才转变为你屏幕上的有色2D像素输出。图形渲染管线的每个阶段将会把前一个阶段的输出作为这个阶段的输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

注意上图中的蓝色阶段代表我们可以注入自定义的着色器,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。

图形管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。

在前面的工程中我们并没有为应用配置着色器。而是用到了GLkit中的GLKBaseEffect类自动构建的程序来做着色器。但如果我们需要自定义着色器程序来进行图像的渲染工作,那么就得学习另外一门语言来 -- GLSL。没错,着色器是使用一种叫GLSL的类C语言完成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。

下面来看一个最基本的顶点着色器:

attribute vec4 position;
void main(void) {
    gl_Position = position;
}
 1.声明一个输入属性数组 -- 一个名为Position的3分量向量
 3.main函数表示着色器执行的开始
 4.着色器主体:将Position输入属性拷贝到名为gl_Position的特殊输出变量,
 每个顶点着色器必须在gl_Position变量中输出一个位置,这个变量定义传递到线管下一个阶段的位置。

片段着色器:

precision mediump float;
void main(void) {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 
}
/*
 第一行是声明着色器中浮点变量的默认精度。
 第二行表示着色器的入口函数
 第三行入口函数主体代码,一个4分享的值被输出到颜色缓冲区,它表示的是最终的输出颜色。
 */

在Xcode中创建着色器源码文件:Xcode顶部菜单栏 -- File -- New -- File iOS Other -- Empty(文件扩展名通常使用.glsl),如下图:

可以在新建文件内编写着色器源码了,但这并不具备任何功能,我们需要在工程中动态编译以生成一个个程序。

编译着色器

- (GLuint)compileShader:(NSString *)shaderName withType:(GLenum)shaderType {
    
    // 加载文件内容
    NSString *shaderPath = [[NSBundle mainBundle] pathForResource:shaderName ofType:@"glsl"];
    
    NSError* error;
    NSString* shaderString = [NSString stringWithContentsOfFile:shaderPath encoding:NSUTF8StringEncoding error:&error];
    
    // 如果为空就打印错误并退出
    if (!shaderString) {
        NSLog(@"Error loading shader: %@", error.localizedDescription);
        exit(1);
    }
    
   /* 
    使用glCreateShader函数可以创建指定类型的着色器对象。shaderType是指定创建的着色器类型
   */ 
    GLuint shader = glCreateShader(shaderType);
    
    // 这里把NSString转换成C-string
    const char* shaderStringUTF8 = [shaderString UTF8String];
    
    int shaderStringLength = (int)shaderString.length;
    
    // 使用glShaderSource将着色器源码加载到上面生成的着色器对象上
    glShaderSource(shader, 1, &shaderStringUTF8, &shaderStringLength);
    
    // 调用glCompileShader 在运行时编译shader
    glCompileShader(shader);
    
    // glGetShaderiv检查编译错误(然后退出)
    GLint compileSuccess;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &compileSuccess);
    if (compileSuccess == GL_FALSE) {
        GLchar messages[256];
        NSString *messageString = [NSString stringWithUTF8String:messages];
        NSLog(@"%@", messageString);
        exit(1);
    }
    
    // 返回一个着色器对象
    return shader;
}

现在可以使用封装的方法生成着色器对象了:

// 生成一个顶点着色器对象
GLuint vertexShader = [self compileShader:@"SimpleVertex" withType:GL_VERTEX_SHADER];
    
// 生成一个片段着色器对象
GLuint fragmentShader = [self compileShader:@"SimpleFragment" withType:GL_FRAGMENT_SHADER];

创建了两个着色器对象之后,我们还需要创建一个程序对象。因为只有着色器程序才具备渲染功能。

从概念上说,程序对象可以视为最终链接的程序。不同的着色器编译为一个着色器程序对象之后,它们必须链接到一个程序对象并一起链接,才能绘制图形。下面是处理代码:

/*
 调用了glCreateProgram glAttachShader  glLinkProgram 连接 vertex 和 fragment shader成一个完整的program。
 着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。
 如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,
 然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。
*/
    
GLuint programHandle = glCreateProgram();   // 创建一个程序对象
glAttachShader(programHandle, vertexShader); // 链接顶点着色器
glAttachShader(programHandle, fragmentShader); // 链接片段着色器
glLinkProgram(programHandle); // 链接程序
    
// 把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
    
// 调用 glGetProgramiv来检查是否有error,并输出信息。
GLint linkSuccess;
glGetProgramiv(programHandle, GL_LINK_STATUS, &linkSuccess);
if (linkSuccess == GL_FALSE) {
    GLchar messages[256];
    NSString *messageString = [NSString stringWithUTF8String:messages];
    NSLog(@"着色器程序:%@", messageString);
    exit(1);
}

至此着色器程序已经链接完成,最后我们要使用到的就是着色器程序对象: programHandle

我们可以使用这个着色器程序来绘制图形。

代码实现:

// 顶点坐标
const GLfloat vertices[9] = {
 //   X     Y    Z
    0.0,  0.5, 0.0,
   -0.5, -0.5, 0.0,
    0.5, -0.5, 0.0,
};

-(void)update和- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect是在GLKViewController中被循环调用的方法,也被称为Game Loop,一般在update中更新变量,- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect中进行绘制。

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
    
    // 清除前面的绘制的颜色缓冲区,每次绘制出图形的背景色
    glClearColor(1.0, 1.0, 1.0, 1.0);
    // 清楚深度缓冲区中的内容
    glClear(GL_COLOR_BUFFER_BIT);
    
    // 前面生成了着色器程序,这里要开始使用了
    glUseProgram(program);
    
    // 如果要把顶点数据赋值给顶点着色器中的position,我们需要先获取这个属性的位置,然后启动
    GLuint positionLocation = glGetAttribLocation(program, "position");
    glEnableVertexAttribArray(positionLocation);
    
    /*
     获取到位置之后就可以赋值了,直接使用glVertexAttribPointer函数
     这个函数告诉OpenGL ES顶点数组中的每个数据如何使用。
     
     第一个参数:指示当前绑定的缓存包含每个顶点的位置信息,也就是给哪个顶点属性赋值
     第二个参数:指定每个位置有3个数据部分
     第三个参数:告诉OpenGL ES每个部分都保存为一个浮点类型的值
     第四个参数:告诉OpenGL ES小数点固定数据是否可以被改变,本例中没有使用小数点固定的数据,因此赋值为GL_FALSE
     第五个参数:每一个点包含几个byte,可以称为"步幅",指定了没哥顶点的保存需要多少个字节。简单点就是指定了GPU从一个顶点的内存碍事转到下一个顶点的内存开始位置需要跳过多少字节
     第六个参数:数据开始的指针,告诉OpenGL ES可以从当前绑定的顶点缓存的开始位置访问顶点数据
     */
    glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (char *)vertices);

    /*
     数据赋值完毕就可以告诉OpenGL ES如何使用缓存数据之后就可以调用glDrawArrays()函数通过这些数据来绘制图形了
     
     第一个参数:告诉OpenGL ES怎么处理在绑定的顶点缓存内的顶点数据。本例中屎指示OpenGL ES去渲染三角形
     第二个参数:指定缓存内的需要渲染的第一个顶点的位置
     第三个参数:需要渲染的顶点数量
     */
    glDrawArrays(GL_TRIANGLES, 0, 3);
    
}

基于上面的代码,我们可以添加一个color属性来控制图形的颜色。
虽然图形的最后颜色输出是在片段着色器处理的,但是数据从CPU发送到GPU首先只能传递到顶点着色器,所以color属性我们只能在顶点着色器内声明,然后再传递到片段着色器。

由于新增了颜色值,所以在顶点着色器中我们需要定义一个输入属性来接受顶点数组中传进来的颜色数据。并且还需要定义一个输出属性,用来传递颜色值给片段着色器。所以现在的顶点着色器源码如下:

attribute vec4 position;
attribute vec4 color;

// 向片段着色器输出一个颜色
varying vec4 fColor;
void main(void) {
    fColor = color;

// 我们对颜色值没有做任何处理,直接赋值给输出了
    gl_Position = position;
}

顶点着色器做了修改,那么片段着色器肯定也要修改了,不再把颜色值写死。

precision mediump float;

// 接受顶点着色器输出的颜色
varying lowp vec4 fColor; 
void main(void) {
    gl_FragColor = fColor;
}

定义好了顶点和片段着色器,我们现在只需要定义颜色值,并赋值给color顶点属性就可以了。

const GLfloat vertices[] = {
//   X     Y    Z   R     G    B
    0.0,  0.5, 0.0, 1.0, 0.0, 0.0,   
   -0.5, -0.5, 0.0, 0.0, 1.0, 0.0,  
    0.5, -0.5, 0.0, 0.0, 0.0, 1.0
};

在顶点数组中我定义的三个顶点的颜色值都不同,那么这样的话三角形会怎么渲染呢,先往下看实现代码。

就如上面给position属性赋值一样,看看怎么为color怎么赋值:

// 还是先获取color的location
GLuint colorLocation = glGetAttribLocation(program, "color");
    
// 启用这个属性
glEnableVertexAttribArray(colorLocation);
    
/*
 这里赋值有点不一样了
 第一个: 上面Get到的Location
 第二个: 有几个类型为type的数据,比如位置有x,y,z三个GLfloat元素,值就为3
 第三个: 一般就是数组里元素数据的类型
 第四个: GL_FALSE
 第五个: 每一个点包含几个byte,现在是6个GLfloat,x,y,z,r,g,b
 第六个: 数据开始的指针,位置就是从头开始,颜色则跳过3个GLFloat的大小
*/
glVertexAttribPointer(colorLocation, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (char *)vertices + 3 * sizeof(GLfloat));

关于最后一个参数,我们看下张图就知道了:

在赋值position属性的时候,我们只需要指向顶点数组的首位地址就可以了,然后读取三位就是位置值了。现在顶点数组里面包含了颜色值,所以在给color赋值的时候,我们需要跳过位置值,也就是3 * sizeof(GLfloat),然后取3个值就是颜色了。看下这张图大概应该能理解。

最后看一下出来的效果:

没想道吧,我们在每个顶点设置不同颜色值,出来的是这种渐变效果,这是什么原因呢?

这个图片可能不是你所期望的那种,因为我们只提供了3个颜色,而不是我们现在看到的大调色板。这是在片段着色器中进行的所谓片段插值(Fragment Interpolation)的结果。当渲染一个三角形时,光栅化(Rasterization)阶段通常会造成比原指定顶点更多的片段。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。
基于这些位置,它会插值(Interpolate)所有片段着色器的输入变量。比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝 + 70%绿。
这正是在这个三角形中发生了什么。我们有3个顶点,和相应的3个颜色,从这个三角形的像素来看它可能包含50000左右的片段,片段着色器为这些像素进行插值颜色。如果你仔细看这些颜色就应该能明白了:红首先变成到紫再变为蓝色。片段插值会被应用到片段着色器的所有输入属性上。

关于插值你们可以在网上搜一下,我对这块理解不是很深。如果有相关研究的欢迎留言补充。谢谢了...

你可能感兴趣的:(iOS开发OpenGL ES - 自定义纹理)