iOS中如何使用GLSL编写的自定义着色器

2,419 阅读17分钟

      上一篇中我们熟悉了GLSL的基本语法,这一篇我们来看看在iOS中如何利用GLSL编写自己的着色器。

       这里我们先来完成一个最简单的案例,利用GLSL渲染一张图片。

顶点着色器部分

attribute vec4 position; 
attribute vec2 texturePos;
varying lowp vec2 varyingTexturePos;
void main() {
    varyingTexturePos = texturePos;
    gl_Position = position;
}

     首先我们先来回顾一下顶点着色器的功能。它可以用来执行自定义计算,实施各种变换,照明或者传统的固定管线所不允许的基于顶点的功能,但是不管它最终做了哪些牛逼的操作,其最终的目的都是计算顶点的坐标。所以最后我们都必须把计算好的顶点坐标赋值给 gl_Position这个顶点着色器中的内置变量。

       这段代码很简单只有7行,首先我们定义了两个attribute 修饰的变量,一个是四维向量position  表示顶点坐标,另一个是二维向量texturePos表示纹理坐标。  至于这里为什么用attribute修饰,是因为顶点坐标和纹理坐标是根据顶点的不同而变化的,换言之每次执行时position 以及texturePos都不一样。对于这样的变量必须用attribute来修饰,因为attribute修饰的变量的值才可以通过OpenGL vertex API或作为顶点数组(VBO)的一部分传递给顶点着色器。这里如果看不明白不要紧大家只要记住一点:凡是根据顶点不同而变化的变量用attribute修饰就行了,比如顶点坐标、纹理坐标、法线等等。另外attribute只能用在顶点着色器中,这一点上一篇说基本语法的时候已经说过了。

      再看varying修饰的varyingTexturePos,要明白这句代码的意思,我们必须先搞清楚varying的作用。varying限定符提供了顶点着色器到片元着色器之间的接口,简单点说,就是varying修饰的变量是要传送到片元着色器的。(当然不是简单的直接传递过去,顶点数量和片元数量也对不上,实际上还要经过图元装配差值运算后再传递到片元着色器的)。明白这一点后我们就知道这个varyingTexturePos是要传递到片元着色器的变量,表示纹理坐标。那么为什么要定义两个纹理坐标?一个用attribute修饰用来接收外包VBO传参,一个用varying修饰表示将要传递到片元着色器。

     最后看一下main函数,每个着色器都有且仅有一个main函数作为着色器的入口。函数中我们首先把外部VBO中传递给着色器的纹理坐标texturePos赋值给了varying变量texturePos确保传递到片元着色器的时候有值。接着把外部VBO中传递给着色器的顶点坐标直接赋值给了内建变量gl_Position。

片元着色器部分

uniform sampler2D colorMap;
varying lowp vec2 varyingTexturePos;
void main() {
    gl_FragColor = texture2D(colorMap, varyingTexturePos);
}

     这段代码更简单只有5句,第一句我们用uniform声明了一个sampler2D类型的变量colorMap,表示纹理采样器。这里简单解释一下uniform的用法,官方文档是这样解释的:The uniform qualifier is used to declare global variables whose values are the same across the entire primitive being processed.意思是说uniform限定符用来声明那些在整个图元处理过程中都不会变化的变量,比如这里的纹理采样器,在本例中不管是哪个片段都是采用的同一个纹理单元。

     再来看一下sampler2D这个数据类型,表示2维的纹理采样器。那么到底什么是纹理采样器呢,简单点说就是GLSL给开发者定义好的用来把传递纹理对象传进片元着色器的数据类型。类似的数据类型还有sampler1D和sampler3D分别表示不同维度的纹理。这种变量的值为纹理单元,即GLuint类型的纹理标识符或者称之为纹理名称。

     然后我们看到这么一句代码varying lowp vec2 varyingTexturePos;是不是发现和顶点着色器中一模一样。没错,这个varyingTexturePos就是顶点着色器传过来的纹理坐标(实际是传递过来的是通过插着处理的纹理坐标)。注意这里的写法包括变量名称都必须和片元处理器一模一样不然是拿不到这个纹理坐标的。

    通过前面的篇幅我们知道片元着色器的任务其实就是计算每个像素的颜色。这里看main还是中只有一个赋值语句,其中gl_FragColor表示像素的颜色,是个内建变量。texture2D是个内建函数,这个函数有2个参数,第一个为纹理采样器,第二个为纹理坐标,函数的作用为提取纹理中对应像素的颜色。 所以本例中其实就是把纹理中每个像素的颜色提取出来赋值给内建变量gl_FragColor。

 在iOS中如何利用自己编写的着色器处理图像

      前面的篇幅已经说过要想利用OpenGL完成绘制,首先必须要用渲染上下文和绘制表面。所以我们先来完成这2部分。

初始化渲染上下文

      这一步其实和之前用GLKit是一样的,这里就不多说了,不明白的可以去看前面的GLKit应用

具体代码如下:

- (void)setupContext {
    self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
    [EAGLContext setCurrentContext:self.context];
}

初始化绘制表面

     在GLKit中绘制表面就是GLKView,apple已经帮我们封装好了可以直接拿来用,现在我们不用GLKView怎么办呢,因为apple 是唯一不支持EGL的平台,自己搞了个EAGL,在这个EAGL中我们已经用过EAGLContext了,对于绘制表面也一样有个对应的类就是CAEAGLLayer,有的大神可能已经玩过这玩意了。那么怎么初始化这个layer呢,请看下面的代码:

+ (Class)layerClass {
    return [CAEAGLLayer class];
}

- (void)setupDrawableSurface {
    self.eaglLayer = (CAEAGLLayer *)self.layer;
    [self.eaglLayer setContentsScale:[UIScreen mainScreen].scale];
    //self.eaglLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking:@false,kEAGLDrawablePropertyColorFormat:kEAGLColorFormatRGBA8};
}

          这段代码很简单就不多说了,提一下drawableProperties这个属性,这个属性用来指定可绘制表面的一些特征。是个字典类型的属性,包含两个Key值kEAGLDrawablePropertyRetainedBacking和kEAGLDrawablePropertyColorFormat。kEAGLDrawablePropertyRetainedBacking对应的value是个bool值,表示是否需要缓存当前绘制的这一帧数据,默认为False,因为开启这个属性会的性能和内存都有一定的开销。kEAGLDrawablePropertyColorFormat表示可绘制表面对应颜色缓冲区的格式。默认为kEAGLColorFormatRGBA8表示RGBA各占8位。

清理缓冲区

      我们知道OpenGL渲染管线的最终目的地是帧缓存(framebuffer),而Framebuffer对象实际上不是缓冲区,而是包含一个或多个附着点的聚合器对象,这些附着点附着的对象才是实际的缓冲区。比如color attachment,depth attachment,stencli attatchment等等分别附着着颜色缓冲区,深度缓冲区、和模板缓冲区。而这里我们只是简单的想要渲染一张图片所以只需要用的颜色缓冲区就行了,所以这里我们需要声明颜色渲染缓冲区和帧缓冲区renderBuffer和frameBuffer。那么在用之前最好先清理一下这些个缓冲区,确保数据的安全,如果你能确定这些个缓冲区之前没有用过不会残留脏数据这一步其实也是可以省略的。具体清理颜色渲染缓冲区和帧缓冲区的代码如下:

- (void)clearBuffers {
    glDeleteRenderbuffers(1, &_renderBuffer);
    self.renderBuffer = 0;
    glDeleteFramebuffers(1, &_frameBuffer);
    self.frameBuffer = 0;
}

配置渲染缓冲区和帧缓冲区

清理完脏数据,接着就要看看怎么配置这些缓冲区了,具体代码如下:

- (void)setupBuffers {
    glGenRenderbuffers(1, &_renderBuffer);
    glBindRenderbuffer(GL_RENDERBUFFER, self.renderBuffer);
    [self.context renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.eaglLayer];
    glGenFramebuffers(1, &_frameBuffer);
    glBindFramebuffer(GL_FRAMEBUFFER, self.frameBuffer);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, self.renderBuffer);
}

     renderBuffer、frameBuffer,和普通buffer一样先gen生成缓冲区标识符(或者说缓冲区名字),然后绑定缓冲区。但是需要注意的是必须将可绘制表面和渲染缓冲区联系起来,通过renderbufferStorage:(NSUInteger)target fromDrawable:(nullable id<EAGLDrawable>)drawable;这个方法将可绘制对象self.eaglLayer作为OpenGL渲染缓冲区的存储附着到GL_RENDERBUFFER这个target上。怎么理解这句话呢,官方文档有这么个解释:要创建可以显示在屏幕上的renderbuffer,您需要绑定renderbuffer,然后通过调用这个方法为它分配共享存储,renderBuffer只有通过这个方法为它分配了共享存储才能在后面通过调用presentRenderbuffer将其display出来。

     我们知道frameBuffer其实只是个管理各种缓冲区对象的聚合对象,那么怎么样将renderBuffer附着到frameBuffer呢?我们可以通过这个方法glFramebufferRenderbuffer将renderBuffer附着到GL_COLOR_ATTACHMENT0这个附着点上。做完这一步我们就将帧缓冲区、渲染缓冲区、绘制表面串联起来了,只有这样我们才能开始我们的绘制工作。

准备绘制

       上述前置条件准备好之后我们就可以开始本节的主题了,在iOS中怎么使用那些自定义的着色器呢?我们知道着色器其实就是一段完成特定功能的代码,而代码要跑起来需要经过编译链接成可执行程序,所以要想在iOS中使用这些个自定义着色器,我们首先必须编译着色器、然后链接成程序。具体看如下代码:

- (GLuint)shaderWithType:(GLenum)type path:(NSString *)path {
    GLuint shader = glCreateShader(type);
    NSString *shaderContent = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
    const char *content = [shaderContent UTF8String];
    glShaderSource(shader, 1,&content , NULL);
    glCompileShader(shader);
    GLint status;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
    if(status == GL_FALSE) {
        char message[512];
        glGetShaderInfoLog(shader, sizeof(message), NULL, message);
        NSLog(@"shader compile error: %@",[NSString stringWithUTF8String:message]);
    }
    return shader;
}
- (void)createProgram {
    GLuint program = glCreateProgram();
    self.program = program;
    NSString *vertexShaderPath = [[NSBundle mainBundle] pathForResource:@"shader" ofType:@"vsh"];
    NSString *fragShaderPath = [[NSBundle mainBundle] pathForResource:@"shader" ofType:@"fsh"];
    GLuint vertexShader = [self shaderWithType:GL_VERTEX_SHADER path:vertexShaderPath];
    GLuint fragShader = [self shaderWithType:GL_FRAGMENT_SHADER path:fragShaderPath];
    glAttachShader(program, vertexShader);
    glAttachShader(program, fragShader);
    glLinkProgram(program);
    GLint status;
    glGetProgramiv(program, GL_LINK_STATUS, &status);
    if(status == GL_FALSE) {
        char *message[512];
        glGetProgramInfoLog(program, sizeof(message), NULL, message);
        NSLog(@"program link error: %@",[NSString stringWithUTF8String:message]);
    }
    NSLog(@"program link success");
    glUseProgram(program);
    glDeleteShader(vertexShader);
    glDeleteShader(fragShader);
}

     先看第一个方法,这个方法的主要功能是生成一个编译好的着色器,这个方法有两个入参第一个表示着色器类型(顶点着色器或者片元着色器);第二个表示这个着色器代码所在文件的路径。在这个方法里面我们首先通过glCreateShader来创建一个对应类型的着色器,这里的入参是一个GLenum类型(只能传GL_VERTEX_SHADER或者GL_FRAGMENT_SHADER分别表示顶点着色器和片元着色器)。不过这个时候我们拿到的只是一个空的着色器,里面什么代码都没有,所以我们需要把最开始我们用GLSL写的着色器代码通过glShaderSource给指定到对应的着色器中去。其函数原型为glShaderSource (GLuint shader, GLsizei count, const GLchar* const *string, const GLint* length),4个参数分别表示:

  • shader:表示着色器对象的句柄
  • count:表示着⾊器源字符串的数量,着⾊器可以由多个源字符串组成,但是每个着⾊器只能有一个main函数
  • string:指向保存数量为count 的着⾊器源字符串的数组指针 
  • length:指向保存每个着⾊器字符串大⼩且元素数量为count 的整数数组指针,NULL表示字符串是NULL终止的

做完这一步就可以调用glCompileShader编译着色器了。但是这里的编译着色器,不像我们用Xcode编译iOS程序可以直接看一下编译器的提示信息,错误提示等等。这里我们自己用代码完成编译工作后根本没有任何提示,成功与否都不知道所以OpenGL又给我们提供了glGetShaderiv和glGetShaderInfoLog两个接口。void glGetShaderiv(GLuint shader , GLenum pname , GLint *params )函数中三个参数的含义如下:

  • shader表示需要编译的着⾊器对象句柄,
  • pname 表示获取的信息参数,可以为 GL_COMPILE_STATUS/GL_DELETE_STATUS/ GL_INFO_LOG_LENGTH/GL_SHADER_SOURCE_LENGTH/ GL_SHADER_TYPE,
  • params — 指向查询结果的整数存储位置的指针.

void glGetShaderInfolog(GLuint shader , GLSizei maxLength, GLSizei *length , GLChar *infoLog)这个方法的参数如下:

  • shader — 需要获取信息⽇志的着⾊器对象句柄 
  • maxLength — 保存信息日志的缓存区⼤小
  • length — 写⼊的信息日志的⻓度(减去null 终⽌符); 如果不需要知道⻓度. 这个参数可以为Null
  • infoLog — 指向保存信息日志的字符缓存区的指针.

通过这两个接口我们就可以知道编译是否成功,以及如果出错,错误信息是什么。

       到这里我们已经可以利用OC代码来完成自定义着色器的编译操作了,那么编译完成后如何连接成程序呢,这就要看第二个方法了,在第二个方法里我们先利用glCreateProgram创建了一个程序,然后两次调用第一个方法分别获得编译好的顶点着色器和片元着色器,再利用glAttachShader将这两个着色器附着到应用程序上,最后再利用glLinkProgram链接程序。当然程序链接的时候也可能会出错,这里查看链接成功与否以及错误信息的API与编译着色器的类似,就不说了。链接成功后这个program我们就可以使用了,利用glUseProgram使用这个程序。

      注意这里的program是运行在GPU的,而我们其他的OC代码是运行在CPU的,那么如果给这个program传递数据呢?来看下面这段代码:

- (void)setupCoordData {
    GLfloat vertexData[] = {
        -0.65f,0.414f,0.0f,   0.0f,1.0f,
        -0.65f,-0.414f,0.0f,  0.0f, 0.0f,
        0.65f,-0.414f,0.0f,   1.0f, 0.0f,
        
        0.65f,-0.414f,0.0f,   1.0f,0.0f,
        0.65f,0.414f,0.0f,    1.0f,1.0f,
        -0.65f,0.414f,0.0f,   0.0f,1.0f,
    };
    GLuint buffer;
    glGenBuffers(1, &buffer);
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertexData), vertexData, GL_STATIC_DRAW);
    int vertexCoordPosition = glGetAttribLocation(self.program, "position");
    glEnableVertexAttribArray(vertexCoordPosition);
    glVertexAttribPointer(vertexCoordPosition, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, 0);
    int textureCoordPosition = glGetAttribLocation(self.program, "texturePos");
    glEnableVertexAttribArray(textureCoordPosition);
    glVertexAttribPointer(textureCoordPosition, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (GLfloat *)NULL + 3);
}

      这段代码是不是很熟悉,前面用GLKIt的时候也写过类似的代码,这里先我们先定义了一个顶点数组,存入了顶点坐标和纹理坐标。(0.65和0.414只是为了保证图片不变形,我的图片是网上找的1300*828图片)。然后生成缓冲区、绑定缓冲区并将顶点数据从CPU拷贝到GPU显存中,这段代码其实前面已经多次用到过了,如果有看不懂的同学不防看看前面的篇幅。

      坐标数据(顶点坐标和纹理坐标)拷贝的GPU显存后,我们还需要告诉program怎么去读取这些数据。在前面的顶点着色器中我们定义了两个attribute变量:attribute vec4 position和attribute vec2 texturePos,这样的变量我们可以通过glGetAttribLocation获取其在program中的位置,然后通过glVertexAttribPointer来为这个位置的attribute变量指定其从缓冲区读取数据的方式。注意必现先调用glEnableVertexAttribArray这个函数打开这个attribute变量读取数据的通道,因为iOS中为提高效率默认是关闭的。

     这里大家其实可以对比一下和GLKit里面的实现,不难发现基本都是一样的,只不过GLKit中的attribute变量是固定好的GLKVertexAttribPosition和GLKVertexAttribTexCoord0,毕竟这里的program是人家写好固定的所以这些attribute名称肯定也是固定的。而我们自定义的着色器则有着更高的自由度,只需要在调用glGetAttribLocation时注意第二个参数必须和着色器中attribute变量名一致即可。

     到这里前面GLKIt中不好理解的概念其实就很很清晰了,比如:GLKVertexAttrib枚举

typedef NS_ENUM(GLint, GLKVertexAttrib)
{
    GLKVertexAttribPosition,
    GLKVertexAttribNormal,
    GLKVertexAttribColor,
    GLKVertexAttribTexCoord0,
    GLKVertexAttribTexCoord1
}

其5个值分别代表顶点坐标、法线、顶点颜色、纹理1、纹理2这5个attribute变量。

     处理完着色器里的attribute变量的数量来源后,接下来就要处理着色器里面uniform变量的数量来源了,本例中只有片元着色器中有个uniform sampler2D colorMap变量  表示纹理采样器,看下面的代码:

- (void)setupTextureData {
    UIImage *image = [UIImage imageNamed:@"meinv"];
    CGImageRef imageRef = image.CGImage;
    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);
    GLubyte *imageData = calloc(width * height * 4, sizeof(GLubyte));
    CGContextRef contextRef = CGBitmapContextCreate(imageData, width, height, 8 , width * 4, CGImageGetColorSpace(imageRef), CGImageGetBitmapInfo(imageRef));
    CGContextTranslateCTM(contextRef, 0, height);
    CGContextScaleCTM(contextRef, 1, -1);
    CGContextDrawImage(contextRef, CGRectMake(0, 0, width, height), imageRef);
    CGContextRelease(contextRef);

    glBindTexture(GL_TEXTURE_2D, 0);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    GLsizei w = (GLsizei)width;
    GLsizei h = (GLsizei)height;
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, imageData);
    
    int samplerLocation =  glGetUniformLocation(self.program, "colorMap");
    glUniform1i(samplerLocation, 0);
}

      这段代码其实分为三个部分:1、解压图片;2、载入纹理;3、将纹理传入program中的colorMap这个uniform变量。

     先看第一部分图片的解压缩,这里我们利用CoreGraphics来的API来完成图片的解压缩,需要注意的是CG框架的坐标原点以及X、Y轴方向和UIKit是不一样的,CG框架中坐标原点在屏幕左下角X\Y轴分别往右\上延伸;而在UIKit中坐标原点是在屏幕左上方,X\Y轴分别忘右\下延伸。所以在drawImage 之前需要调用CGContextTranslateCTM(contextRef, 0, height);将坐标原点从左上角移动到左下角,然后通过CGContextScaleCTM(contextRef, 1, -1)将Y轴方向从往下翻转到往上。其实这也就是我们所谓的纹理翻转操作,如果去掉这两行代码你会发现你加载出来的纹理是倒置的。

     再看第二部分载入纹理。在调用glTexImage2D载入纹理之前,我们需要先绑定纹理并设定纹理参数,这部分内容大家可以参考我前面的文章——纹理

     最后我们就可以将纹理数据传入program中了,和attribute变量类似,uniform变量传值之前我们也需要获取其在program中的位置,通过这个API即可办到——glGetUniformLocation。然后调用glUniform1i将纹理之前绑定的纹理单元传递进program中的colorMap这个uniform变量。

     到这里我们这个准备绘制阶段就做完了,包括将着色器编译链接成程序以及为这个程序输入必要的数据。具体代码如下:

- (void)prepareDraw {
    [self createProgram];
    [self setupCoordData];
    [self setupTextureData];
}

绘制

      最后这个阶段其实没什么可说的,直接上代码吧:

- (void)draw {
    glClearColor(0.3f, 0.4f, 0.5f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    
    float scale = [UIScreen mainScreen].scale;
    glViewport(self.bounds.origin.x * scale ,self.bounds.origin.y * scale, self.bounds.size.width * scale, self.bounds.size.height * scale);
    glDrawArrays(GL_TRIANGLES, 0, 6);
    [self.context presentRenderbuffer:GL_RENDERBUFFER];
}

注意:绘制前不要忘记设置视口,绘制后不要忘记调用presentRenderbuffer 将渲染缓冲区的图像呈现到屏幕上

结语

      这个案例虽然很简单,就是加载一张图片,但是却能搞清楚在iOS项目中如何利用GLSL写的自定义着色器,这样为后面我们在iOS项目中利用GLSL写的复杂滤镜打好了基础哦!