案例:根据对GLSL语言的理解,自定义一个顶点着色器和一个片元着色器,使用着色器API完成纹理的加载。
进阶:解决纹理倒置问题。
效果如下:
准备工作
- 新建iOS应用工程,修改当前controller的view。将原来的view继承于UIView改成继承于HView。
- 自定义一个HVIew类,后续绘制图片在该类中完成。
- 新建顶点着色器文件和片元着色器文件。
3.1 command + N,开始新建文件。
3.2 选择iOS->Other->Empty,新建两个空文件,分别命名为:shaderv.vsh、shaderf.fsh
至此准备工作完成,接下来就开始编码工作。
自定义着色器
自定义着色器本质上其实是一个字符串,但是在Xcode的编写过程没有任何错误提示,因此,在编写过程中需要格外仔细。
- 顶点着色器shaderv.vsh
- 定义两个attribute修饰符修饰的变量,分别表示顶点坐标和纹理坐标
- 定义一个varying修饰符修饰的变量,用于将纹理坐标从顶点着色器传递给片元着色器
- main函数,在该函数内给内建变量
gl_Position
赋值。若顶点坐标不需要变换,则直接将顶点坐标赋值给内建变量gl_Position。若顶点坐标需要进行变换,则将变换后的结果赋值给内建变量gl_Position。
attribute vec4 position;
attribute vec2 textCoordinate;
varying lowp vec2 varyTextCoord;
void main(){
varyTextCoord = textCoordinate;
gl_Position = position;
}
2.片元着色器shaderf.fsh
- 指定片元着色器中float类型的精度,如果不写,可能会报一些异常错误
- 定义一个与顶点着色器桥接的纹理坐标,写法必须同在顶点着色器写法一致,否则将无法收到从顶点着色器传递过来的数据
- 定义一个unifom修饰符修饰的变量,用于获取纹理坐标上每个像素点的纹素。
- main函数,在函数内给内建变量
gl_FragColor
赋值。通过texture2D内建函数获取当前颜色值,它有两个参数:参数1:纹理图片;参数2:纹理坐标,返回值:vec4类型的颜色值。当颜色不需要进行修改时,可直接将vec4类型的颜色值赋值给内建变量gl_FragColor。当颜色需要修改时,将最终修改的结果赋值给内建变量gl_FragColor。
precision highp float;
varying lowp vec2 varyTextCoord;
uniform sampler2D colorMap;
void main(){
gl_FragColor = texture2D(colorMap, varyTextCoord);
}
初始化
1. 创建图层
1.1 图层主要是显示OpenGL ES绘制内容的载体。它的创建有两种方式:
- 直接使用当前view的layer。但是view的layer是继承于CALayer,需要重写类方法
layerClass
,使其继承于CAEAGLLayer
。 - 直接使用[[CAEAGLLayer alloc] init]方法创建一个CAEAGLLayer类型的图层,并将新创建的图层添加到当前图层上。
self. myEagLayer = (CAEAGLLayer*)self.layer;
+ (Class)layerClass{
return [CAEAGLLayer class];
}
1.2 设置scale,这里设置当前view的scale与屏幕的scale一样大
[self setContentScaleFactor:[[UIScreen mainScreen] scale]];
1.3 设置描述属性,这里设置不维持渲染内容以及颜色格式为RGBA8
- kEAGLDrawablePropertyRetainedBacking:表示绘图表面显示后,是否保留其内容,
true-保留,false-不保留
。 - kEAGLDrawablePropertyColorFormat:可绘制表面的内部颜色缓存区格式,这个key对应的值是一个NSString指定特定颜色缓存区对象。默认是kEAGLColorFormatRGBA8;
颜色缓冲区格式 | 描述 |
---|---|
kEAGLColorFormatRGBA8 | 32位RGBA的颜色,4*8=32位 |
kEAGLColorFormatRGB565 | 16位RGB的颜色 |
kEAGLColorFormatSRGBA8 | sRGB代表了标准的红、绿、蓝,即CRT显示器、LCD显示器、投影机、打印机以及其他设备中色彩再现所使用的三个基本色素。sRGB的色彩空间基于独立的色彩坐标,可以使色彩在不同的设备使用传输中对应于同一个色彩坐标体系,而不受这些设备各自具有的不同色彩坐标的影响。 |
self.myEagLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:@false, kEAGLDrawablePropertyRetainedBacking, kEAGLColorFormatSRGBA8, kEAGLDrawablePropertyColorFormat, nil];
2. 创建上下文
上下文主要用来保存OpenGL ES的状态,是一个状态机,不论GLKit还是GLSL,都需要使用context。
2.1 创建上下文,并指定OpenGL ES渲染API的版本号
self.myContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
2.2 设置当前上下文
[EAGLContext setCurrentContext:self.myContext];
3. 清空缓冲区
清除缓冲区的残留数据,防止其它无用数据对绘制效果造成影响
//清空渲染缓存区
glDeleteBuffers(1, &_myColorRenderBuffer);
self.myColorRenderBuffer = 0;
//清空帧缓存区
glDeleteBuffers(1, &_myColorFrameBuffer);
self.myColorFrameBuffer = 0;
4. 设置缓冲区
设置缓冲区包括设置RenderBuffer和FrameBuffer。
-
RenderBuffer:是一个通过应用分配的2D图像缓冲区,需要附着在FrameBuffer上。
1.1 RenderBuffer有3种缓冲区- 深度缓冲区(Depth Buffer):存储深度值等
- 纹理缓冲区(Depth Buffer):存储纹理坐标中对应的纹素、颜色值等
- 模板缓冲区(Stencil Buffer):存储模板等
1.2 设置RenderBuffer
- 定义一个缓存区ID
- 申请一个缓冲区标志
- 将缓冲区标识绑定到
GL_RENDERBUFFER
- 绑定一个可绘制对象(layer)的存储到一个OpenGL ES RenderBuffer对象
-(void)setupRenderBuffer{
//1.定义一个缓存区ID
GLuint buffer;
//2.申请一个缓存区标志
glGenRenderbuffers(1, &buffer);
self.myColorRenderBuffer = buffer;
glBindRenderbuffer(GL_RENDERBUFFER, self.myColorRenderBuffer);
[self.myContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.myEagLayer];
}
-
FrameBuffer:是一个收集颜色、深度、模板缓冲区的附着点,简称FBO,即是一个管理者,用来管理RenderBuffer,且FrameBuffer没有实际的存储功能,真正实现存储的是RenderBuffer。
2.1 FrameBuffer有3个附着点- 颜色附着点(Color Attachment):管理纹理、颜色缓冲区
- 深度附着点(depth Attachment):管理深度缓冲区,会根据当前深度缓冲中的值修改颜色缓冲中的内容
- 模板附着点(Stencil Attachment):管理模板缓冲区
2.2 设置FrameBuffer
- 定义一个缓存区ID
- 申请一个缓冲区标志
- 将缓冲区标识绑定到GL_FRAMEBUFFER
- 通过FrameBuffer来管理RenderBuffer,将RenderBuffer附着到FrameBuffer的GL_COLOR_ATTACHMENT0附着点上。
-(void)setupFrameBuffer{
GLuint buffer;
glGenFramebuffers(1, &buffer);
self.myColorFrameBuffer = buffer;
glBindFramebuffer(GL_FRAMEBUFFER, self.myColorFrameBuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, self.myColorRenderBuffer);
}
注意点:绑定renderBuffer和FrameBuffer是有顺序的,先有RenderBuffer,才有FrameBuffer。
开始绘制
初始化
清除屏幕颜色,清空颜色缓冲区,设置视口大小。
//设置清屏颜色
glClearColor(0.3, 0.45, 0.5, 1.0);
//清除屏幕
glClear(GL_COLOR_BUFFER_BIT);
//1.设置视口大小
CGFloat scale = [[UIScreen mainScreen] scale];
glViewport(self.frame.origin.x * scale, self.frame.origin.y * scale, self.frame.size.width * scale, self.frame.size.height * scale);
加载自定义着色器
1. 读取并编译顶点着色程序、片元着色程序
1.1 创建一个顶点/片元着色器
*shader = glCreateShader(type);
1.2 以字符串的形式将着色器源码读取出来,并将着色器源码加载到着色器对象上
NSString* content = [NSString stringWithContentsOfFile:file encoding:NSUTF8StringEncoding error:nil];
const GLchar* source = (GLchar*)content.UTF8String;
glShaderSource(*shader, 1, &source, NULL);
1.3 编译着色器,把着色器源代码编译成目标代码。此时得到一个可附着到程序的着色器对象
glCompileShader(*shader);
2. 加载着色器
2.1 创建program
GLint program = glCreateProgram();
2.2 将编译好的着色器对象附着到程序中
glAttachShader(program, verShader);
glAttachShader(program, fragShader);
2.3 释放不需要的着色器对象
glDeleteShader(verShader);
glDeleteShader(fragShader);
- 链接program
在链接之后可调用glGetProgramiv
函数判断当前是否链接成功
glLinkProgram(self.myPrograme);
- 使用program
glUseProgram(self.myPrograme);
设置并处理顶点数据
- 设置顶点数据
GLfloat attrArr[] ={
0.5f, -0.5f, -1.0f, 1.0f, 0.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 1.0f,
-0.5f, -0.5f, -1.0f, 0.0f, 0.0f,
0.5f, 0.5f, -1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 1.0f,
0.5f, -0.5f, -1.0f, 1.0f, 0.0f,
};
- 申请一个顶点缓冲区ID,并将它绑定到GL_ARRAY_BUFFER标识符上
GLuint attrBuffer;
glGenBuffers(1, &attrBuffer);
glBindBuffer(GL_ARRAY_BUFFER, attrBuffer);
- 把顶点数据从CPU拷贝到GPU
glBufferData(GL_ARRAY_BUFFER, sizeof(attrArr), attrArr, GL_DYNAMIC_DRAW);
- 打开顶点/片元着色器属性通道
- 通过
glGetAttribLocation
函数获取顶点属性入口,它需要两个参数,参数1:program;参数2:自定义着色器文件中变量名称的字符串,重点:这里的字符串必须同自定义着色器文件中变量名称保持一致
。 - 通过
glEnableVertexAttribArray
函数打开着色器的属性通道 - 通过
glVertexAttribPointer
函数设置读取方式
//设置顶点坐标
GLuint position = glGetAttribLocation(self.myPrograme, "position");
glEnableVertexAttribArray(position);
glVertexAttribPointer(position, 3, GL_FLOAT, GL_FALSE, 5*sizeof(GLfloat), NULL);
//设置纹理坐标
GLuint textCoor = glGetAttribLocation(self.myPrograme, "textCoordinate");
glEnableVertexAttribArray(textCoor);
glVertexAttribPointer(textCoor, 2, GL_FLOAT, GL_FALSE, 5*sizeof(GLfloat), (float *)NULL + 3);
加载纹理
加载纹理的过程是将png/jpg图片解压缩成位图,并通过自定义着色器读取每个像素点的纹素。
- 解压缩png/jpg图片,将UIImage转换为CGImageRef。
CGImageRef spriteImage = [UIImage imageNamed:fileName].CGImage;
- 根据CGImageRef属性获取图片的宽和高,并开辟一段空间用于存放解压缩后的位图信息。位图数据的大小为宽高4。为什么是宽高4?因为图片共有宽高个像素点,每个像素点有4个字节,即RGBA,因此共有宽高*4大小的空间。
//读取图片的大小,宽和高
size_t width = CGImageGetWidth(spriteImage);
size_t height = CGImageGetHeight(spriteImage);
//获取图片字节数 宽*高*4(RGBA)
GLubyte * spriteData = (GLubyte *) calloc(width * height * 4, sizeof(GLubyte));
- 创建CGContextRef上下文
/*
参数1:data,指向要渲染的绘制图像的内存地址
参数2:width,bitmap的宽度,单位为像素
参数3:height,bitmap的高度,单位为像素
参数4:bitPerComponent,内存中像素的每个组件的位数,比如32位RGBA,就设置为8
参数5:bytesPerRow,bitmap的没一行的内存所占的比特数
参数6:colorSpace,bitmap上使用的颜色空间 kCGImageAlphaPremultipliedLast:RGBA
*/
CGContextRef spriteContext = CGBitmapContextCreate(spriteData, width, height, 8, width*4,CGImageGetColorSpace(spriteImage), kCGImageAlphaPremultipliedLast);
- 在CGContextRef上将图片绘制出来,调用CGContextDrawImage函数,使用默认方式绘制
/*
CGContextDrawImage 使用的是Core Graphics框架,坐标系与UIKit 不一样。UIKit框架的原点在屏幕的左上角,Core Graphics框架的原点在屏幕的左下角。
CGContextDrawImage
参数1:绘图上下文
参数2:rect坐标
参数3:绘制的图片
*/
CGContextDrawImage(spriteContext, rect, spriteImage);
- 绘制完成之后,需要将上下文释放掉
CGContextRelease(spriteContext);
- 经过重绘之后,就将jpg/png图片转换成了位图得到了纹理数据。接下来就是载入纹理数据。
6.1 绑定纹理到默认的纹理ID
6.2 设置纹理属性
6.3 载入2D纹理数据
//绑定纹理到默认的纹理ID
glBindTexture(GL_TEXTURE_2D, 0);
//设置纹理属性
/*
参数1:纹理维度
参数2:线性过滤、为s,t坐标设置模式
参数3:wrapMode,环绕模式
*/
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
/*
参数1:纹理模式,GL_TEXTURE_1D、GL_TEXTURE_2D、GL_TEXTURE_3D
参数2:加载的层次,一般设置为0
参数3:纹理的颜色值GL_RGBA
参数4:宽
参数5:高
参数6:border,边界宽度
参数7:format
参数8:type
参数9:纹理数据
*/
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, spriteData);
//释放spriteData
free(spriteData);
- 设置纹理采样器
主要是用来获取纹理中对应像素点的颜色值,即纹素。
- 通过glGetUniformLocation函数获取片元着色器中uniform的入口。该函数需要传入两个参数,参数1:program;参数2:在片元着色器中用uniform修饰的变量名字的字符串。
注意,该字符串必须同片元着色器中对应的变量名保持一致
。 - 使用glUniform1i函数获取纹素,它也有两个参数,参数1:片元着色器中uniform的入口;参数2:纹理ID,默认为0。
glUniform1i(glGetUniformLocation(self.myPrograme, "colorMap"), 0);
绘制
开始绘制,存储到RenderBuffer,从RenderBuffer将图片显示到屏幕上。
- 调用glDrawArrays函数,指定图元连接方式进行绘制
- context调用presentRenderbuffer函数将绘制好的图片渲染到屏幕上显示
glDrawArrays(GL_TRIANGLES, 0, 6);
[self.myContext presentRenderbuffer:GL_RENDERBUFFER];
至此,使用GLSL加载纹理已经完成,完整代码见Demo地址;
从效果图上看到,图片呈倒立显示,这是因为OpenGL要求原点(0,0)位于图片的左下角,Y坐标从下往上增加,而图片纹理的原点(0,0)是位于图片的左上角,Y坐标从上往下增加
。所以最后的照片呈上下倒置的效果。
以下是几种解决方案:
- 方案1:将顶点绕Y轴进行翻转。这样可以实现正常显示。
问题:如何实现绕Y轴翻转
解决:将顶点坐标与一个旋转矩阵相乘,得到的结果就是翻转之后的顶点坐标。
重点:在3D课程中用的是横向量,在OpenGL ES用的是列向量。顶点坐标是一个1行4列的矩阵,因此,旋转矩阵必须是4行4列,这样相乘之后才能得到新的1行4列的顶点坐标。另外,要实现翻转,只需要将该方向的坐标数据进行反向,如当前需要沿X轴反向,只需要将X轴的数据全部*-1,即可将X轴的数据翻转。
代码详见方案1代码 - 方案2:可以解压缩图片的时候对图片进行翻转。
解决:在context绘制的图片,对图片进行翻转。
重点:由于翻转之后,顶点数据的坐标会发生变化,超过绘制的区域,因此在翻转之后需要将顶点移至绘制区域内。
主要使用的函数有
//先平移至合适的位置,也可以在翻转之后再移至绘制区域内
CGContextTranslateCTM(context, 0, height);
//将Y轴翻转
CGContextScaleCTM(context, 1, -1);
代码详见方案2代码
- 方案3:修改片元着色器纹理坐标,将片元着色器中的纹理坐标在Y轴方向翻转。
重点:如何获取纹理坐标的Y轴方向数据,通过'varyTextCoord.y'即可得到Y轴数据。将1.0-varyTextCoord.y即可实现翻转。
vec2 newCoord = vec2(varyTextCoord.x, 1.0-varyTextCoord.y);
gl_FragColor = texture2D(colorMap, newCoord);
代码详见方案3代码
方案4:修改顶点着色器纹理坐标,将顶点着色器的纹理坐标在Y轴方向翻转。
该方案原理同方案3一样,只是在不同的着色器完成纹理坐标的翻转。
代码详见方案4代码方案5:修改源顶点数据中顶点坐标和纹理坐标的映射关系。
原理同方案3、4一致,只是直接在顶点数组中修改源数据。
原顶点数据数组
GLfloat attrArr[] ={
0.5f, -0.5f, -1.0f, 1.0f, 0.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 1.0f,
-0.5f, -0.5f, -1.0f, 0.0f, 0.0f,
0.5f, 0.5f, -1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 1.0f,
0.5f, -0.5f, -1.0f, 1.0f, 0.0f,
};
修改后的顶点数组
GLfloat attrArr[] ={
0.5f, -0.5f, -1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, -1.0f, 0.0f, 1.0f,
0.5f, 0.5f, -1.0f, 1.0f, 0.0f,
-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
0.5f, -0.5f, -1.0f, 1.0f, 1.0f,
};
代码详见方案5代码