@qidiandasheng
2020-07-08T16:06:44.000000Z
字数 17722
阅读 1697
音视频
OpenGL(Open Graphics Library)是 Khronos Group (一个图形软硬件行业协会,该协会主要关注图形和多媒体方面的开放标准)开发维护的一个规范,它是硬件无关的。它主要为我们定义了用来操作图形和图片的一系列函数的 API,OpenGL 本身并非 API。
OpenGL ES(OpenGL for Embedded Systems)是 OpenGL 的子集,针对手机、PDA 和游戏主机等嵌入式设备而设计。该规范也是由 Khronos Group 开发维护。
OpenGL ES 去除了四边形(GL_QUADS)、多边形(GL_POLYGONS)等复杂图元,以及许多非绝对必要的特性,剩下最核心有用的部分。可以理解成是一个在移动平台上能够支持 OpenGL 最基本功能的精简规范。
目前 iOS 平台支持的有 OpenGL ES 1.0,2.0,3.0。OpenGL ES 3.0 加入了一些新的特性,但是它除了需要 iOS 7.0 以上之外,还需要 iPhone 5S 之后的设备才能支持。出于现有设备的考虑,我们主要使用 OpenGL ES 2.0。
随机存取存储器(英语:Random Access Memory,缩写:RAM;也叫主存)是与CPU直接交换数据的内部存储器。它可以随时读写,而且速度很快,通常作为操作系统或其他正在运行中的程序的临时数据存储介质。
只读存储器(英语:Read-Only Memory,缩写:ROM)是一种半导体存储器,其特性是一旦存储数据就无法再将之改变或删除,且内容不会因为电源关闭而消失。
RAM是运行内存,相当于电脑内存;
ROM是储存空间,相当于电脑的硬盘;
OpenGL 是一个状态机,它维持自己的状态,并根据用户调用的函数来改变自己的状态。根据状态的不同,调用同样的函数也可能产生不同的效果。
在 OpenGL 的世界里,大多数元素都可以用状态来描述,比如:
OpenGL 会保持状态,除非我们调用 OpenGL 函数来改变它。
上面提到的各种状态值,将保存在对应的上下文(Context
)中。
OpenGL ES 上下文(EAGLContext) : 管理所有 iOS 要绘制的 OpenGL ES 信息。
上下文中的信息可能会被保存在CPU所控制的内存中,也可能会被保存在 GPU 所控制的内存中。OpenGL ES会按需在两个内存区域之间复制信息,知道何时发生复制有助于程序的优化。
通过放置这些状态到上下文中,上下文可以跟踪用于渲染的帧缓存、用于几何数据、颜色等的缓存。还会决定是否使用如纹理、灯光等功能以及会为渲染定义当前的坐标系统等。并且在多任务的情况下,就能很容易的共享硬件设备,而互不影响各自的状态。
因此渲染的时候,要指定对应的当前上下文。
图元(Primitive)
是指 OpenGL ES 中支持渲染的基本图形。OpenGL ES 只支持三种图元,分别是顶点、线段、三角形。复杂的图形得通过渲染多个三角形来实现。
纹素(Texel):一个图像初始化为一个纹理缓存后,每个像素会变成一个纹素。纹理的坐标是范围是0~1,在这个单位长度内,可能包含任意多个纹素。
加载图片生成纹理缓存对应下面缓存中的步骤1、2、3。
视口坐标中的颜色像素。没有使用纹理时,会使用对象顶点来计算片段的颜色;使用纹理时,会根据纹素来计算。
计算机系统中 CPU、GPU 是协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。所以,尽可能让 CPU 和 GPU 各司其职发挥作用是提高渲染效率的关键。
OpenGL 正是给我们提供了访问GPU的能力,不仅如此,它还引入了缓存(Buffer)这个概念,大大提高了处理效率。
图中的剪头,代表着数据交换,也是主要的性能瓶颈。
从一个内存区域复制到另一个内存区域的速度是相对较慢的,并且在内存复制的过程中,CPU 和 GPU 都不能处理这区域内存,避免引起错误。此外,CPU / GPU 执行计算的速度是很快的,而内存的访问是相对较慢的,这也导致处理器的性能处于次优状态,这种状态叫做“数据饥饿”,简单来说就是空有一身本事却无用武之地。
针对此,OpenGL 为了提升渲染的性能,为两个内存区域间的数据交换定义了缓存。缓存是指 GPU能够控制和管理的连续RAM。
程序从CPU的内存复制数据到OpenGL ES
的缓存。在GPU取得一个缓存的所有权以后,运行在CPU中的程序理想情况下将不再接触这个缓存。通过独占缓存,GPU能够尽可能以有效的方式读写内存。 GPU把它处理数据的能力异步地应用在缓存上,意味着GPU使用缓存中的数据工作的同时,运行在 CPU 中的程序可以继续执行。
所以OpenGL ES中主要的一步就是生成缓存,几乎所有的程序提供给GPU的数据都应该放入缓存中,为缓存提供数据。
生成缓存并使用缓存完成最终渲染显示的过程可以分为以下7步:
glGenBuffers()
)。glBindBuffer()
)。glBufferData() / glBufferSubData()
)。glEnableVertexAttribArray() / glDisableVertexAttribArray()
)。glVertexAttribPointer()
)。glDrawArrays() / glDrawElements()
)。glDeleteBuffers()
)。OpenGL ES
坐标系的范围是 -1 ~ 1,是一个三维的坐标系,通常用 X、Y、Z 来表示。Z 轴的正方向指向屏幕外。在不考虑 Z 轴的情况下,左下角为 (-1, -1, 0),右上角为 (1, 1, 0)。
GLfloat coordinate[] = {
-1.0f, -1.0f, //左下角坐标
1.0f, -1.0f, //右下角坐标
-1.0f, 1.0f, //左上角坐标
1.0f, 1.0f //右上角坐标
}
纹理坐标系的范围是 0 ~ 1,是一个二维坐标系,横轴称为 S 轴,纵轴称为 T 轴。在坐标系中,点的横坐标一般用 U 表示,点的纵坐标一般用 V 表示。左下角为 (0, 0),右上角为 (1, 1)。
GLfloat noRotationTextureCoordinates[] = {
0.0f, 0.0f, //左下角坐标
1.0f, 0.0f, //右下角坐标
0.0f, 1.0f, //左上角坐标
1.0f, 1.0f, //右上角坐标
};
贴图也就是根据坐标映射的一个过程,如上图所示,坐标系一一对应就能把一张图正确的显示出来了。
无旋转,正常显示:
self.vertices = malloc(sizeof(SenceVertex) * 4); // 4 个顶点
//顶点坐标和纹理坐标的映射
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {0, 0}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {1, 0}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {0, 1}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {1, 1}}; // 右上角
向右旋转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {1, 0}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {1, 1}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {0, 0}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {0, 1}}; // 右上角
向左旋转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {0, 1}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {0, 0}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {1, 1}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {1, 0}}; // 右上角
竖直方向翻转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {0, 1}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {1, 1}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {0, 0}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {1, 0}}; // 右上角
水平方向翻转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {1, 0}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {0, 0}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {1, 1}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {0, 1}}; // 右上角
先向左旋转,然后水平方向翻转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {0, 0}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {0, 1}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {1, 0}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {1, 1}}; // 右上角
先向左旋转,然后竖直方向翻转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {1, 1}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {1, 0}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {0, 1}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {0, 0}}; // 右上角
180度旋转
self.vertices[0] = (SenceVertex){{-1, -1, 0}, {1, 1}}; // 左下角
self.vertices[1] = (SenceVertex){{1, -1, 0}, {0, 1}}; // 右下角
self.vertices[2] = (SenceVertex){{-1, 1, 0}, {1, 0}}; // 左上角
self.vertices[3] = (SenceVertex){{1, 1, 0}, {0, 0}}; // 右上角
帧缓冲存储器(Frame Buffer
):简称帧缓存或显存,它是屏幕所显示画面的一个直接映象,又称为位映射图(Bit Map
)或光栅。帧缓存的每一存储单元对应屏幕上的一个像素,整个帧缓存对应一帧图像。
GPU需要知道应该在显存中的哪个位置存储渲染出来的2D 图像像素数据。就像为GPU提供数据的缓存一样,接收渲染结果的缓冲区叫做帧缓存(frame buffer)。程序会像任何其他种类的缓存一样生成、绑定、删除帧缓存。但是帧缓存不需要初始化,因为渲染指令会在适当的时候替换缓存的内容。帧缓存会在被绑定的时候隐式开启,同时OpenGL ES
会自动地根据特定平台的硬件配置和功能来设置数据的类型和偏移。
我们屏幕显示的时候会用到很多屏幕缓冲:用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲(Framebuffer),它被储存在内存中。
帧缓冲区对象是一个容器概念,它需要纹理缓冲区对象或渲染缓冲区对象填充它,一般来说纹理缓冲区对象用作颜色附件,渲染缓冲区对象则用作深度和模板附件,原因是纹理缓冲区对象尽量用作读数据,渲染缓冲区对象尽量用作写数据,两者底层优化不一样。
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
这样创建的帧缓冲并不是一个完整的缓冲,一个完整的帧缓冲需要满足以下的条件:
附加至少一个缓冲(颜色、深度或模板缓冲):GL_COLOR_ATTACHMENT0
、GL_DEPTH_ATTACHMENT
、GL_STENCIL_ATTACHMENT
。
至少有一个附件(Attachment)。
所有的附件都必须是完整的(保留了内存)。
每个缓冲都应该有相同的样本数。
之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中。附件是一个内存位置,它能够作为帧缓冲的一个缓冲,可以将它想象为一个图像。当创建一个附件的时候我们有两个选项:纹理或渲染缓冲对象(Renderbuffer Object)。
当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就想它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,我们之后可以在着色器中很方便地使用它。
添加纹理附件到帧缓冲上:
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
渲染缓冲对象(Renderbuffer Object)是在纹理之后引入到OpenGL中,作为一个可用的帧缓冲附件类型的,所以在过去纹理是唯一可用的附件。和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生的渲染格式,它是为离屏渲染到帧缓冲优化过的。
渲染缓冲对象直接将所有的渲染数据储存到它的缓冲中,不会做任何针对纹理格式的转换,让它变为一个更快的可写储存介质。然而,渲染缓冲对象通常都是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素。
因为它的数据已经是原生的格式了,当写入或者复制它的数据到其它缓冲中时是非常快的。所以,交换缓冲这样的操作在使用渲染缓冲对象时会非常快。我们在每个渲染迭代最后使用的glfwSwapBuffers,也可以通过渲染缓冲对象实现:只需要写入一个渲染缓冲图像,并在最后交换到另外一个渲染缓冲就可以了。渲染缓冲对象对这种操作非常完美。
深度渲染缓冲对象:
//创建深度缓冲对象
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24_OES, 800, 600);
//添加渲染缓冲对象附件到帧缓冲上
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rbo);
颜色渲染缓冲对象:
//创建颜色缓冲对象
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8_OES, 800, 600);
//添加渲染缓冲对象附件到帧缓冲上
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo);
在创建帧缓冲和添加附件之后我们可以使用以下方法检查帧缓冲的完整性。
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
渲染三角形的基本流程按照上图所示。其中,顶点着色器和片段着色器是可编程的部分,着色器(Shader)是一个小程序,它们运行在GPU上,在主程序运行的时候进行动态编译,而不用写死在代码里面。编写着色器用的语言是 GLSL(OpenGL Shading Language)。
下面介绍一下渲染流程的每一步都做了什么:
为了渲染一个三角形,我们需要传入一个包含 3 个三维顶点坐标的数组,每个顶点都有对应的顶点属性,顶点属性中可以包含任何我们想用的数据。在上图的例子里,我们的每个顶点包含了一个颜色值。
并且,为了让OpenGL ES
知道我们是要绘制三角形,而不是点或者线段,我们在调用绘制指令的时候,都会把图元信息传递给 OpenGL ES
。
顶点着色器会对每个顶点执行一次运算,它可以使用顶点数据来计算该顶点的坐标、颜色、光照、纹理坐标等。
顶点着色器的一个重要任务是进行坐标转换,例如将模型的原始坐标系(一般是指其 3D 建模工具中的坐标)转换到屏幕坐标系。
在顶点着色器程序输出顶点坐标之后,各个顶点按照绘制命令中的图元类型参数,以及顶点索引数组被组装成一个个图元。
通过这一步,模型中 3D 的图元已经被转化为屏幕上 2D 的图元。
在「OpenGL」的版本中,顶点着色器和片段着色器之间有一个可选的着色器,叫做几何着色器(Geometry Shader)。
几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的图元来生成其他形状。
OpenGL ES 目前还不支持几何着色器,这个部分我们可以先不关注。
光栅化阶段,即基本图元被转换为供片段着色器使用的片段的渲染步骤(片段就是上图所示的一个个栅格)。片段表示可以被渲染到屏幕上的像素,即每个片段对应帧缓冲区(光栅)中的一像素,它包含位置、颜色、纹理坐标等信息,这些值是由图元的顶点信息进行插值计算得到的。
在片段着色器运行之前会执行裁切,处于视图以外的所有像素会被裁切掉,用来提升执行效率。
片段着色器的主要作用是计算每一个片段最终的颜色值(或者丢弃该片段)。片段着色器决定了最终屏幕上每一个像素点的颜色值。
在这一步,OpenGL ES会根据片段是否被遮挡、视图上是否已存在绘制好的片段等情况,对片段进行丢弃或着混合,最终被保留下来的片段会被写入帧缓存(光栅)中,最终呈现在设备屏幕上。
整个流程基本上跟渲染三角形的流程差不多,只是其中多了某几个步骤:
同上
纹素(Texel):一个图像初始化为一个纹理缓存后,每个像素会变成一个纹素。纹理的坐标是范围是0~1,在这个单位长度内,可能包含任意多个纹素。
同上
同上
同上
同上
视口坐标中的颜色像素。没有使用纹理时,会使用对象顶点来计算片段的颜色;使用纹理时,会根据纹素来计算。
映射(Mapping):对齐顶点和纹素的方式。即将顶点坐标 (X, Y, Z) 与 纹理坐标 (U, V) 对应起来。
取样(Sampling):在顶点固定后,每个片段根据计算出来的 (U, V) 坐标,去找相应纹素的过程。
同上
在 GLKit 中,苹果爸爸对 OpenGL ES 中的一些操作进行了封装,因此我们使用 GLKit 来渲染会省去一些步骤。
定义顶点数据,用一个三维向量来保存 (X, Y, Z) 坐标,用一个二维向量来保存 (U, V) 坐标:
typedef struct {
GLKVector3 positionCoord; // (X, Y, Z)
GLKVector2 textureCoord; // (U, V)
} SenceVertex;
初始化顶点数据:
self.vertices = malloc(sizeof(SenceVertex) * 4); // 4 个顶点
self.vertices[0] = (SenceVertex){{-1, 1, 0}, {0, 1}}; // 左上角
self.vertices[1] = (SenceVertex){{-1, -1, 0}, {0, 0}}; // 左下角
self.vertices[2] = (SenceVertex){{1, 1, 0}, {1, 1}}; // 右上角
self.vertices[3] = (SenceVertex){{1, -1, 0}, {1, 0}}; // 右下角
退出的时候,记得手动释放内存:
- (void)dealloc {
// other code ...
if (_vertices) {
free(_vertices);
_vertices = nil;
}
}
// 创建上下文,使用 2.0 版本
EAGLContext *context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
// 初始化 GLKView
CGRect frame = CGRectMake(0, 100, self.view.frame.size.width, self.view.frame.size.width);
self.glkView = [[GLKView alloc] initWithFrame:frame context:context];
self.glkView.backgroundColor = [UIColor clearColor];
self.glkView.delegate = self;
[self.view addSubview:self.glkView];
// 设置 glkView 的上下文为当前上下文
[EAGLContext setCurrentContext:self.glkView.context];
使用 GLKTextureLoader
来加载纹理,并用 GLKBaseEffect
保存纹理的 ID,为后面渲染做准备。
NSString *imagePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"sample.jpg"];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft : @(YES)};
GLKTextureInfo *textureInfo = [GLKTextureLoader textureWithCGImage:[image CGImage]
options:options
error:NULL];
self.baseEffect = [[GLKBaseEffect alloc] init];
self.baseEffect.texture2d0.name = textureInfo.name;
self.baseEffect.texture2d0.target = textureInfo.target;
因为纹理坐标系和 UIKit 坐标系的纵轴方向是相反的,所以将 GLKTextureLoaderOriginBottomLeft
设置为 YES,用来消除两个坐标系之间的差异。
在 glkView:drawInRect:
代理方法中,我们要去实现顶点数据和纹理数据的绘制逻辑。这一步是重点,注意观察「缓存管理的 7 个步骤」的具体用法。
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
[self.baseEffect prepareToDraw];
// 创建顶点缓存
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer); // 步骤一:生成
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer); // 步骤二:绑定
GLsizeiptr bufferSizeBytes = sizeof(SenceVertex) * 4;
glBufferData(GL_ARRAY_BUFFER, bufferSizeBytes, self.vertices, GL_STATIC_DRAW); // 步骤三:缓存数据
// 设置顶点数据
glEnableVertexAttribArray(GLKVertexAttribPosition); // 步骤四:启用或禁用
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, positionCoord)); // 步骤五:设置指针
// 设置纹理数据
glEnableVertexAttribArray(GLKVertexAttribTexCoord0); // 步骤四:启用或禁用
glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, textureCoord)); // 步骤五:设置指针
// 开始绘制
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 步骤六:绘图
// 删除顶点缓存
glDeleteBuffers(1, &vertexBuffer); // 步骤七:删除
vertexBuffer = 0;
}
我们调用 GLKView
的 display
方法,即可以触发 glkView:drawInRect:
回调,开始绘制的逻辑。
[self.glkView display];
首先,我们需要自己编写着色器,包括顶点着色器和片段着色器,使用的语言是 GLSL 。
新建一个文件,一般顶点着色器用后缀 .vsh ,片段着色器用后缀 .fsh (当然你不喜欢这么命名也可以,但是为了方便其他人阅读,最好是还是按照这个规范来),然后就可以写代码了。
顶点着色器的代码如下:
attribute vec4 Position;
attribute vec2 TextureCoords;
varying vec2 TextureCoordsVarying;
void main (void) {
gl_Position = Position;
TextureCoordsVarying = TextureCoords;
}
片段着色器的代码如下:
precision mediump float;
uniform sampler2D Texture;
varying vec2 TextureCoordsVarying;
void main (void) {
vec4 mask = texture2D(Texture, TextureCoordsVarying);
gl_FragColor = vec4(mask.rgb, 1.0);
}
GLSL 是类 C 语言写成,如果学习过 C 语言,上手是很快的。下面对这两个着色器的代码做一下简单的解释。
attribute
修饰符只存在于顶点着色器中,用于储存每个顶点信息的输入,比如这里定义了 Position
和 TextureCoords
,用于接收顶点的位置和纹理信息。
vec4
和 vec2
是数据类型,分别指四维向量和二维向量。
varying
修饰符指顶点着色器的输出,同时也是片段着色器的输入,要求顶点着色器和片段着色器中都同时声明,并完全一致,则在片段着色器中可以获取到顶点着色器中的数据。
gl_Position
和 gl_FragColor
是内置变量,对这两个变量赋值,可以理解为向屏幕输出片段的位置信息和颜色信息。
precision
可以为数据类型指定默认精度,precision mediump float
这一句的意思是将 float
类型的默认精度设置为 mediump
。
uniform
用来保存传递进来的只读值,该值在顶点着色器和片段着色器中都不会被修改。顶点着色器和片段着色器共享了 uniform
变量的命名空间,uniform
变量在全局区声明,同个 uniform
变量在顶点着色器和片段着色器中都能访问到。
sampler2D
是纹理句柄类型,保存传递进来的纹理。
texture2D()
方法可以根据纹理坐标,获取对应的颜色信息。
那么这两段代码的含义就很明确了,顶点着色器将输入的顶点坐标信息直接输出,并将纹理坐标信息传递给片段着色器;片段着色器根据纹理坐标,获取到每个片段的颜色信息,输出到屏幕。
少了 GLKTextureLoader
的相助,我们就只能自己去生成纹理了。生成纹理的步骤比较固定,以下封装成一个方法:
- (GLuint)createTextureWithImage:(UIImage *)image {
// 将 UIImage 转换为 CGImageRef
CGImageRef cgImageRef = [image CGImage];
GLuint width = (GLuint)CGImageGetWidth(cgImageRef);
GLuint height = (GLuint)CGImageGetHeight(cgImageRef);
CGRect rect = CGRectMake(0, 0, width, height);
// 绘制图片
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
void *imageData = malloc(width * height * 4);
CGContextRef context = CGBitmapContextCreate(imageData, width, height, 8, width * 4, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextTranslateCTM(context, 0, height);
CGContextScaleCTM(context, 1.0f, -1.0f);
CGColorSpaceRelease(colorSpace);
CGContextClearRect(context, rect);
CGContextDrawImage(context, rect, cgImageRef);
// 生成纹理
GLuint textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, imageData); // 将图片数据写入纹理缓存
// 设置如何把纹素映射成像素
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 解绑
glBindTexture(GL_TEXTURE_2D, 0);
// 释放内存
CGContextRelease(context);
free(imageData);
return textureID;
}
对于写好的着色器,需要我们在程序运行的时候,动态地去编译链接。编译一个着色器的代码也比较固定,这里通过后缀名来区分着色器类型,直接看代码:
- (GLuint)compileShaderWithName:(NSString *)name type:(GLenum)shaderType {
// 查找 shader 文件
NSString *shaderPath = [[NSBundle mainBundle] pathForResource:name ofType:shaderType == GL_VERTEX_SHADER ? @"vsh" : @"fsh"]; // 根据不同的类型确定后缀名
NSError *error;
NSString *shaderString = [NSString stringWithContentsOfFile:shaderPath encoding:NSUTF8StringEncoding error:&error];
if (!shaderString) {
NSAssert(NO, @"读取shader失败");
exit(1);
}
// 创建一个 shader 对象
GLuint shader = glCreateShader(shaderType);
// 获取 shader 的内容
const char *shaderStringUTF8 = [shaderString UTF8String];
int shaderStringLength = (int)[shaderString length];
glShaderSource(shader, 1, &shaderStringUTF8, &shaderStringLength);
// 编译shader
glCompileShader(shader);
// 查询 shader 是否编译成功
GLint compileSuccess;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileSuccess);
if (compileSuccess == GL_FALSE) {
GLchar messages[256];
glGetShaderInfoLog(shader, sizeof(messages), 0, &messages[0]);
NSString *messageString = [NSString stringWithUTF8String:messages];
NSAssert(NO, @"shader编译失败:%@", messageString);
exit(1);
}
return shader;
}
顶点着色器和片段着色器同样都需要经过这个编译的过程,编译完成后,还需要生成一个着色器程序,将这两个着色器链接起来,代码如下:
- (GLuint)programWithShaderName:(NSString *)shaderName {
// 编译两个着色器
GLuint vertexShader = [self compileShaderWithName:shaderName type:GL_VERTEX_SHADER];
GLuint fragmentShader = [self compileShaderWithName:shaderName type:GL_FRAGMENT_SHADER];
// 挂载 shader 到 program 上
GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
// 链接 program
glLinkProgram(program);
// 检查链接是否成功
GLint linkSuccess;
glGetProgramiv(program, GL_LINK_STATUS, &linkSuccess);
if (linkSuccess == GL_FALSE) {
GLchar messages[256];
glGetProgramInfoLog(program, sizeof(messages), 0, &messages[0]);
NSString *messageString = [NSString stringWithUTF8String:messages];
NSAssert(NO, @"program链接失败:%@", messageString);
exit(1);
}
return program;
}
这样,我们只要将两个着色器命名统一,按照规范添加后缀名。然后将着色器名称传入这个方法,就可以获得一个编译链接好的着色器程序。
有了着色器程序后,我们就需要往程序中传入数据,首先要获取着色器中定义的变量,具体操作如下:
GLuint positionSlot = glGetAttribLocation(program, "Position");
GLuint textureSlot = glGetUniformLocation(program, "Texture");
GLuint textureCoordsSlot = glGetAttribLocation(program, "TextureCoords");
传入生成的纹理 ID:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureID);
glUniform1i(textureSlot, 0);
glUniform1i(textureSlot, 0)
的意思是,将 textureSlot
赋值为 0,而 0 与 GL_TEXTURE0
对应,这里如果写 1,glActiveTexture
也要传入 GL_TEXTURE1
才能对应起来。
设置顶点数据:
glEnableVertexAttribArray(positionSlot);
glVertexAttribPointer(positionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, positionCoord));
设置纹理数据:
glEnableVertexAttribArray(textureCoordsSlot);
glVertexAttribPointer(textureCoordsSlot, 2, GL_FLOAT, GL_FALSE, sizeof(SenceVertex), NULL + offsetof(SenceVertex, textureCoord));
在渲染纹理的时候,我们需要指定 Viewport 的尺寸,可以理解为渲染的窗口大小。调用 glViewport 方法来设置:
glViewport(0, 0, self.drawableWidth, self.drawableHeight);
// 获取渲染缓存宽度
- (GLint)drawableWidth {
GLint backingWidth;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth);
return backingWidth;
}
// 获取渲染缓存高度
- (GLint)drawableHeight {
GLint backingHeight;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight);
return backingHeight;
}
通过以上步骤,我们已经拥有了纹理,以及顶点的位置信息。现在到了最后一步,我们要怎么将缓存与视图关联起来?换句话说,假如屏幕上有两个视图,OpenGL ES 要怎么知道将图像渲染到哪个视图上?
所以我们要进行渲染层绑定。通过 renderbufferStorage:fromDrawable:
来实现:
- (void)bindRenderLayer:(CALayer <EAGLDrawable> *)layer {
GLuint renderBuffer; // 渲染缓存
GLuint frameBuffer; // 帧缓存
// 绑定渲染缓存要输出的 layer
glGenRenderbuffers(1, &renderBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, renderBuffer);
[self.context renderbufferStorage:GL_RENDERBUFFER fromDrawable:layer];
// 将渲染缓存绑定到帧缓存上
glGenFramebuffers(1, &frameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER,
renderBuffer);
}
以上代码生成了一个帧缓存和一个渲染缓存,并将渲染缓存挂载到帧缓存上,然后设置渲染缓存的输出层为 layer。
最后,将绑定的渲染缓存呈现到屏幕上:
[self.context presentRenderbuffer:GL_RENDERBUFFER];
GLKit 主要帮我们做了以下几个点:
GLKTextureLoader
封装了一个将 Image 转化为 Texture 的方法。GLKBaseEffect
内部实现了着色器的编译链接过程,我们在使用过程中基本可以忽略「着色器」这个概念。renderbufferStorage:fromDrawable:
将自身的 layer 设置为渲染缓存的输出层。因此,在调用 display 方法的时候,内部会调用 presentRenderbuffer:
去将渲染缓存呈现到屏幕上。