OpenGL ES 入门之iOS平台

本文仅适合零基础入门OpenGL ES的同学

参考自:https://www.raywenderlich.com/3664/opengl-tutorial-for-ios-opengl-es-2-0

简介

也许你曾使用其他的游戏引擎,比如Cocos2D,Sparrow, Corona或者是Unity,这些都是基于OpenGL之上的。为什么程序员们喜欢这些框架,因为OpenGL太难学了。写这篇入门教程的原因就是缩短OpenGL 初学者的学习曲线。

在这片教程里,你可以动手通过opengl es创建一个hello world程序,显示一些几何图形。

在过程中,你将会学到:

  1. 如何通过scratch得到一个基本的opengl app
  2. 如何编译和运行基于vertex和fragment的shader文件的 app
  3. 如何渲染一个立方体,通过vertex 缓存对象
  4. 如何应用投影和模型视图转换
  5. 如何通过深度测试渲染一个3D物体闲话少说,我们开始学习OpenGL ES!OpenGL ES 1.0 vs OpenGL ES 2.0
  • 你要知道OpenGL ES只有两个版本,V1和V2,他们完全不同
  • V1.0使用的是固态管线,一种方式使用内部函数实现设置灯光,顶点坐标,颜色,摄像头等等。
  • V2.0使用的是可编程管线,V1.0的内嵌函数都没了,你必须自己实现所有东西,你想要的。
  • 哦天哪!你可能会想,为什么我要用V2.0,我要做这么多额外的工作!虽然V2需要做额外的工作,但是,V2可以实现更多V1无法实现的更酷的效果,比如toon shader(卡通渲染).说到toon shader,不得不说一下shader。Shader

着色器

是一段在GPU上执行的针对3D对象进行操作的程序。应用于计算机图形学领域,指一组供计算机图形资源在执行渲染任务时使用的指令,用于计算图像的颜色和明暗。但近来,它也能用于处理一些特殊效果,或者视频后处理。通俗的说,着色器告诉电脑如何用特有的一种方法去绘制物体。程序员将着色器应用于图形处理器GPU的可编程流水线,来实现三维应用程序。这样的图形处理器有别于传统的固定流水线处理器,为GPU编程带来更高的灵活性和适应性。以前固有的流水线只能进行一些几何变换和像素灰度计算。现在可编程流水线还能处理所有像素、顶点、纹理的位置、色调、饱和度、明度、对比度并实时的绘制图像。着色器还能产生如模糊、高光、有体积光源、失焦、卡通渲染、色调分离、畸变、凹凸贴图、边缘检测、运动检测等效果。准备开始虽然Xcode准备了一个opengles的模板,我认为它会对初学者造成困惑,因为自动生成了很多令人困惑的代码。还要努力去理解它怎么工作的。我认为全部用scratch写代码相对容易,所以你能理解所有的是怎么协作的,我们现在开始。

首先创建一个测试View,从UIView派生,layerClass指定是OpenGL的layer。
然后在一个UIViewController上添加这个View
代码如下:

- (void)viewDidLoad {
        [super viewDidLoad];

        YMOpenGLView* ogv = [[YMOpenGLView alloc] initWithFrame:self.view.bounds];
        [self.view addSubview:ogv];
}

运行之后,会是一个被填充绿色的界面。
在OpenGL ES里,渲染任何一个几何图形到场景中,你必须创建两个小的程序,叫做shaders。
shaders是通过类似C语言的GLSL写成。不用担心学习这块,我们下面开始。

shaders 包括两种类型:
1、Vertex shaders: 在你的场景里每个定点都会调用一次的程序。所以如果你打算在场景里渲染一个正方形,每个转角都有一个定点,那么将会发生四次调用。它的工作是执行一些计算,比如光照、几何变换等等。找到定点的坐标,并传递一些数据给另外一个shader (Fragment shader)。
2、Fragment shader: 在你的场景里每个像素都会调用一次的程序。所以如果你同样渲染一个正方形,它将被正方形覆盖的每个像素分别调用一次。Fragment shaders 也能执行一些光照计算等操作,但它更重要的工作是设置每个像素的颜色。

为了更好的理解,结合示例说明。

// 这是一个vertex shader

attribute vec4 Position; //1

attribute vec4 SourceColor; //2

varying vec4 DestinationColor; //3

void main(void) { //4
    DestinationColor = SourceColor; //5
    gl_Position = Position; //6
}

1、[attribute]这个关键字声明将要传入一个输入参数叫做Position。稍后,你将要写一些代码传入这个变量。将被用来表示顶点的坐标。注意类型是vec4,表示四分量向量。
2、声明了第二个输入变量,定义的是顶点的颜色。
3、声明了另外一个变量,但它不包含attribute关键字,所以它是一个输出的变量,输出给fragment shader.但它包含[varying]关键字,是一种幻想的方式在说:“我要告诉你特定顶点的值,但当你要找出特定像素的值的时候,通过附近顶点的平滑值获得(附近顶点的平均值)”。所以基本上,你可以为每个顶点设定不同的颜色,实现一个平滑整洁的渐变效果!一会儿实现这个效果。
4、每个shader文件(程序)以一个入口函数开始,类似C!
5、设置顶点的destination color等于source color,让OpenGL 做差值。
6、有个已有的顶点在vertex shader里必须设置,它叫gl_Position,这里值不做变化。

好了,这就是一个简单的vertex shader!让我们继续添加 fragment shader。

varying lowp vec4 DestinationColor; // 1

void main(void) { // 2
  gl_FragColor = DestinationColor; // 3
}

这个非常短,但是我们要一行行解释:

1、这是一个输入变量来自vertex shader。它和vertex shader 中定义的是一样的,例外的是它有了一个额外的关键字定义[lowp],当你在fragment shader中设定变量值的时候,你需要给它一个精度。一个很好的经验法则是尽量使用最低的精度,以获得性能奖金(更佳的性能)。我们设置了最低,但是也可以设置中和高在这里,它们是medp and highp,如果你需要的话。
2、就像vertex shader一样,fragment shader也需要一个main作为入口函数。
3、就像在vertex shader中必须要设置gl_Position一样,你必须设置gl_FragColor在这里。这里做了简单的设置。

好了,还不错吧,现在让我写一些代码将它们应用在我们的app中。

编译Vertex和Fragment Shaders

在 - (instancetype) initWithFrame:(CGRect)frame 函数前加入如下函数:

- (GLuint) compileShader:(NSString*)shaderName withType:(GLenum)shaderType
{
// 1
NSString* shaderPath = [[NSBundle mainBundle] pathForResource:shaderName ofType:@"glsl"];
NSError* error;
NSString* shaderString = [NSString stringWithContentsOfFile:shaderPath encoding:NSUTF8StringEncoding error:&error];

if (!shaderString) {
NSLog(@"Unable read shader file:%@", shaderPath);
} else {
NSLog(@"Read shader file success! %@", shaderPath);
}

// 2
GLuint shaderHandle = glCreateShader(shaderType);

// 3
const char* shaderStringUTF8 = [shaderString UTF8String];
int shaderStringLength = (int)[shaderString length];
glShaderSource(shaderHandle, 1, &shaderStringUTF8, &shaderStringLength);

// 4
glCompileShader(shaderHandle);

// 5
GLint compileSuccess;
glGetShaderiv(shaderHandle, GL_COMPILE_STATUS, &compileSuccess);

if (compileSuccess == GL_FALSE) {
GLchar messages[256];
glGetShaderInfoLog(shaderHandle, sizeof(messages), 0, &messages[0]);
NSString* messageString = [NSString stringWithUTF8String:messages];
NSLog(@"%@", messageString);
}
return shaderHandle;
}

OK,让我们看看这些代码如何工作的。

1、拿到glsl文件的字符串内容。
2、调用glCreateShader 去创建一个OpenGL对象来表示这个shader。当你调用这个函数的时候你需要传入一个参数,是vertex shader或者是fragment shader。
3、调用glShaderSource告诉OpenGL这个shader的内容。在这里只是为了opengl的调用做了c类型的字符数组的转换。
4、最后,调用glCompileShader完成运行时的编译。
5、这个过程会失败,如果你的GLSL代码有问题会报错。如果错了,glGetShaderiv会获得更多有价值的信息。还缺少一个链接,让OpenGL使用shader程序。加入如下代码:

- (void) compileShaders
{
// 1
GLuint vertexShader = [self compileShader:@"YMSimpleVertex" withType:GL_VERTEX_SHADER];
GLuint fragmentShader = [self compileShader:@"YMSimpleFragment" withType:GL_FRAGMENT_SHADER];

// 2
GLuint programHandle = glCreateProgram();
glAttachShader(programHandle, vertexShader);
glAttachShader(programHandle, fragmentShader);
glLinkProgram(programHandle);

// 3
GLint linkSuccess;
glGetProgramiv(programHandle, GL_LINK_STATUS, &linkSuccess);
if (linkSuccess == GL_FALSE) {
GLchar messages[256];
glGetProgramInfoLog(programHandle, sizeof(messages), 0, &messages[0]);
NSString* messageString = [NSString stringWithUTF8String:messages];
NSLog(@"%@", messageString);
}

// 4
glUseProgram(programHandle);

// 5
_positionSlot = glGetAttribLocation(programHandle, "Position");
_colorSlot = glGetAttribLocation(programHandle, "SourceColor");
glEnableVertexAttribArray(_positionSlot);
glEnableVertexAttribArray(_colorSlot);
}

让我们看下以上代码如何工作的:

1、用之前封装的代码编译两个shader,返回shader句柄
2、通过glCreateProgram,glAttachShader,glLinkProgram去链接两个shader到program
3、通过glGetProgramiv,glGetProgramInfoLog获得必要的充分的错误信息
4、通过glUseProgram告诉OpenGL当提供了vertex信息后真正的去使用它
5、最后,通过glGetAttribLocation可以获得vertex shader中的变量指针,这些变量默认是无效的,

通过代码将他们设置为有效。

在initWithFrame调用render后,在调用 compileShaders函数。

下面为简单的四边形创建顶点数据当你要通过OpenGL渲染任何一个几何图形的时候,记住它不能渲染四边形-只能渲染三角形。然而我们如果创建一个四边形可以通过创建两个三角形拼接而成。
有一个比较好的事情是,在OpenGL ES2.0里你能以你喜欢的方式组织顶点数据。

下面代码是一组c结构体和数组定义。

// 一个顶点,位置:x,y,z和颜色:rgba

typedef struct

{

float Position[3]; // 坐标:x,y,z

float Color[4]; // 颜色:r,g,b,alpha

} Vertex;

// 一个四边形,包含了四个顶点的定义。包括每个顶点包含的坐标和颜色。

const Vertex Vertices[] = {

{{1,-1,0},{1,0,0,1}}, // v0, red

{{1,1,0},{0,1,0,1}}, // v1, green

{{-1,1,0},{0,0,1,1}}, // v2, blue

{{-1,-1,0},{0,0,0,1}} // v3, black

};

// 代表两个三角形,组成了一个四边形

const GLubyte Indices[] = {

0,1,2,

2,3,0

};

创建Vertex Buffer对象给OpenGL传递数据的最佳方式是调用Vertex Buffer Objects。
基本上VBO是储存buffer和顶点数据的OpenGL对象。
所以在initWithFrame中加入如下函数的调用。

- (void) setupVBOs
{
GLuint vertexBuffer;
glGenBuffers(1, &vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(Vertices), Vertices, GL_STATIC_DRAW);
GLuint indexBuffer;
glGenBuffers(1, &indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(Indices), Indices, GL_STATIC_DRAW);
}

你可以发现它非常简单。它使用了和之前类似的方式创建Vertex Buffer,通过调用glGenBuffer创建一个VBO的句柄。通过glBindBuffer去告诉OpenGL:“嘿,当我说GL_ARRAY_BUFFER的时候,我的意思就是创建了VBO,我要给你数据啦”,然后通过调用glBufferData将VBO数据给OpenGL。
给OpenGL配置了VBO后,需要修改render函数的实现,如下:

- (void) render
{
glClearColor(0, 104.0/255.0, 55.0/255.0, 1.0);

glClear(GL_COLOR_BUFFER_BIT);

//1
glViewport(0, 0, self.bounds.size.width, self.bounds.size.height);

//2
glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), (GLvoid*)(sizeof(float)*3));

//3
glDrawElements(GL_TRIANGLES, sizeof(Indices)/sizeof(Indices[0]), GL_UNSIGNED_BYTE, 0);
[_context presentRenderbuffer:GL_RENDERBUFFER];

}

1、调用glViewport 设置UIView渲染的部分。这里设置了整个屏幕,如果你想渲染一部分,可以修改此值。
2、调用glVertexAttribPointer 设置两个vertex shader中的两个输入值。
这是(glVertexAttribPointer)特别重要的函数,我们详细研究下:
1)、第一个参数是之前获取的shader输入参数
2)、第二个参数是制定多少个值传递个顶点。如果你查看Vertex结构,你会发现坐标position包含三个float,分别是x,y,z。然后颜色是四个float,rgba.
3)、第三个参数指定每个参数的类型,坐标和颜色都是float类型。
4)、第四个参数永远是false
5)、第五个参数是设定参数的幅度,这是一种特殊的方式说:“这个数据的大小指的是每个顶点的数据”。所以我们提供给它真个顶点结构的大小。
6)、第六个参数是如何找到对应数据的偏移量。坐标数据偏移是0,颜色数据是跳过3个float的长度作为偏移。

3、调用glDrawElements让奇迹发生。这实际上最后调用了vertex shader 和 在屏幕的每一个像素上的fragment shader 。

这也是一个很重要的函数:
1)、第一个参数设定了绘制顶点的风格。会有诸多风格供选择,比如GL_LINE_STRIP、GL_TRIANGLE_FAN,但是GL_TRIANGLES一般是有用的(特别是连接Vertex Buffer Objects,VBO的时候),所以在这里设置了GL_TRIANGLES标志。
2)、第二个参数是绘制三角形的个数,这个例子绘制一个四边形由两个三角形组成。这里我们用了C的方式计算个数。
3)、第三个参数是每个私有索引的变量类型。
4)、第四个参数参考文档,应该是一个指向indices的指针,但是我们在setupVBO函数中已经将数组通过GL_ELEMENT_ARRAY_BUFFER 传给opengl了。所以此处不需要传,设置为0.

编译,会看到一个渐变的红、绿、蓝、黑的铺满屏幕的矩形。
你可以能会奇怪为什么这个矩形适配屏幕这么合适。OpenGL有个“摄像头”在坐标原点(0,0,0),从Z轴俯视。
显然,在实际的应用中你会希望更多的控制摄像头。所以我们看一下如何通过投影变换实现它。
添加投影:
让物体在二维场景中出现三维效果,我们需要在物体上增加投影变换。
基本上,我们有一个近板和一个远板,所有我们要显示的物体都会在两个板子之间。近一些的物体接近近板,我们缩小它感觉离我们越近,反之放大一些看起来离我们更远。这也是模仿人类眼镜的工作。
一起看看如何修改代码实现投影。需要修改Vertex.glsl脚本程序。代码如下:

attribute vec4 Position; // 1

attribute vec4 SourceColor; // 2

varying vec4 DestinationColor; // 3

uniform mat4 Projection; //  增加投射矩阵

void main(void) { // 4

DestinationColor = SourceColor; // 5

gl_Position = Projection * Position; // 坐标重新计算

}

在此我们添加了一个新的输入变量 Projection。注意我们没有设置属性为attribute,而是定义为uniform类型。这意味着传入的是一个常亮而不是pre-vertex value.
同样,Projection定义为mat4类型。mat4代表 4x4的矩阵。矩阵数学是一个非常大的课题,无法在这里展开
讲清楚,但是现在你可以把它理解成为可以缩放、旋转、平移的顶点。我们传入一个矩阵,根据投影移动我们的顶点。

下一步,我们设置最终的坐标为投影乘以坐标。在某物上做矩阵变化就像这样做。
现在你需要想vertex shader传入投影矩阵参数。然而,复杂的矩阵只是已经在毕业多年忘在脑后该怎么办,

别气馁,有聪明人已经做好了解决方案!

聪明人比如Bill Hollings,他是Cocos3D的作者。他已经写了一个全功能的3D 图形库,已经很好的整合进了cocos2d。但无论如何,Cocos3D包含了很好的oc vector和matrix库,我们可以包含到我们的工程里。

在compileShader函数中添加代码:

_projectionUniform = glGetUniformLocation(programHandle, "Projection");

获取 Projection矩阵变量的指针。

在render函数 glViewport调用之前添加代码:

CC3GLMatrix* projection = [CC3GLMatrix matrix];

float h = 4.0 * self.frame.size.height / self.frame.size.width;

[projection populateFromFrustumLeft:-2 andRight:2 andBottom:-h/2 andTop:h/2 andNear:4 andFar:10];

glUniformMatrix4fv(_projectionUniform, 1, 0, projection.glMatrix);

CC3GLMatrix是第三方封装好的类,可以设置左右前后的值。

给vertex shader传值的方式是通过glUniformMatrix4fv,CC3GLMatrix类有一个友好的函数方法glMatrix,他能把矩阵转换成OpenGL能够识别的数组。
最后一步是将我们的顶点扭动,让他们可以两个板之间(之前举例的近板和远板)。这个只需要在数组里修改z轴的值,这里由0改为-7.

我们继续,下面添加平移和旋转。
上面的代码中其实手动的去改-7本身就是不方便,甚至令人厌恶的做法。有没有更优雅的办法呢?

有!

哪些事情以及类似的事情都是矩阵变换,called Matrix Transform.他们使你移动顶点变得非常容易。

到目前为止,我们的vertex shader修改了顶点的坐标通过矩阵变换,所以为什么不可以平移、缩放和旋转呢?
我们将称之为“模型-视图”变换(model-view)。
编译运行,可以看到和之前直接设置数组是同样的效果,但是没有动,是的,我们只是让它运行一次。
我们通过CADisplayLink实现渲染。
理想情况是opengl定时刷新和屏幕刷新保持一致,这样不会有卡顿等情况。

幸运的是,苹果提供了一个方便的方式去实现,通过CADisplayLink!它非常方便使用,让我们开始吧。

在View中加入如下代码:

- (void)setupDisplayLink
{
  CADisplayLink* displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(render:)];
  [displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}

将initWithFrame中的render调用改为setupDisplayLink的调用。

编译运行!酷!就是这样!

通过CADisplayLink在系统的每一帧调用你的render,它将基于sin函数更新变换,所以这个四边形将左右来回移动。
我不认为这是足够酷的,它如果增加旋转会更酷,然后它会看起来像真实的3D!
在render函数的populateFromTranslation的调用后面加入代码:

_currentRotation += displayLink.duration * 90;
[modelView rotateBy:CC3VectorMake(_currentRotation, _currentRotation, 0)];

矩阵变换会在x,y轴上做变换,z轴不动。

编译运行,它看起来真是3D的!

到目前为止,它看起来还不足够酷,我不叫讨厌四边形,是时候转成立方体了。

非常简单,扩展顶点数组就可以了。配置如下:

// 一个四边形,包含了四个顶点的定义。包括每个顶点包含的坐标和颜色。

// 一个立方体的八个顶点

const Vertex Vertices[] = {

{{1,-1,0},{1,0,0,1}}, // v0, red

{{1,1,0},{0,1,0,1}}, // v1, green

{{-1,1,0},{0,0,1,1}}, // v2, blue

{{-1,-1,0},{1,1,0,1}}, // v3, black

{{1,-1,-1},{1,0,0,1}}, // v4, red

{{1,1,-1},{0,1,0,1}}, // v5, green

{{-1,1,-1},{0,0,1,1}}, // v6, blue

{{-1,-1,-1},{1,1,0,1}} // v7, black

};

// 代表两个切面,组成了一个四边形

const GLubyte Indices[] = {

// front

0,1,2,

2,3,0,

//Back

4,6,5,

4,7,6,

//left

2,7,3,

7,6,2,

//right

0,4,1,

4,1,5,

//top

6,2,1,

1,6,5,

//bottom

0,3,7,

0,7,4

};

编译运行,看起来确实是个立方体,但看起来别扭,有时看着是透明的!

幸运的是我们可以通过景深测试来解决这个问题。通过depth testing,OpenGL可以z轴上的追踪,而且只是绘制前面没有任何物体遮挡的顶点。

添加代码如下:

- (void) setupDepthBuffer
{
  glGenRenderbuffers(1, &_depthRenderBuffer);

  glBindRenderbuffer(GL_RENDERBUFFER, _depthRenderBuffer);

  glRenderbufferStorage(GL_RENDERBUFFER,

  GL_DEPTH_COMPONENT16,

  self.frame.size.width,

  self.frame.size.height);
}

setupDepthBuffer 函数创建了一个depth buffer,以相似的方式创建render buffer。然而,注意它使用了glRenderBufferStorage替换了context的成员方法renderBufferStorage。后者是只用于color的renderbuffer。

然后我们调用 glFramebufferRenderbuffer 去关联刚创建的景深buffer和frame buffer。记住我怎么说frame buffer可以包含不同类型buffer的吗?这是我们第一次关联了新的buffer进入framebuffer。

在render函数中,我们在每次屏幕刷新的时候都清除了景深buffer,开启景深测试。
编译并运行,就会看到一个真实的立方体在做平移和旋转了。

That's all

OpenGL ES 入门之iOS平台_第1张图片
请我喝杯茶

你可能感兴趣的:(OpenGL ES 入门之iOS平台)