OpenGL学习——着色器绘制与坐标系

上一篇学习如何将EGL和本地窗口系统联系在一起,这时候我们拥有了OpenGL的Contex(上下文环境)和本地渲染视图。接下来我们就可以愉快的开始进行OpenGL的渲染程序编写了。

在进行渲染程序编写之前,我们先要了解一下着色器(Shader)的基本工作流程。这里的着色器主要指的是顶点着色器(Vertex Shader)和片段着色器(Fragament Shader)。它们负责图形和图像颜色的绘制。简单地说,我们通过操作顶点着色器来描述图的形状,通过片段着色器来描述图上每个像素点的颜色。

坐标系

我们从前面的总结知道,使用OpenGL引擎的第一个程序入口就是从编写顶点着色器开始的。既然要去描述图形的坐标信息,我们就有必要知道如何去构建坐标系给OpenGL,如何转换成我们看到的图形坐标的。

在构建坐标系有一套标准的流水构建流程,依次从下面这些空间去转换到屏幕坐标系:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

将物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易。同时它们之间可以很方便的通过矩阵变换来实现转换。

  1. 首先我们描述一个物体的空间信息会建立一个局部坐标,从物体本身的坐标原点开始来描述一个物体的形状。
  1. 然后假设我们有多个物体,我们就需要构建一个世界坐标来描述物体与物体之间的相对坐标关系。这些坐标相对于世界的全局原点,物体从这个世界坐标系的原点开始进行摆放。
  1. 接下来,假如我们从不同的角度去观察这些物体的话,就需要再加入一个观察者的坐标系。使得每个坐标都是从摄像机或者说观察者的角度进行描述的。
  1. 坐标到达观察空间之后,就已经描绘出一个完整的坐标空间了,这时候我们需要将它传递给OpenGL进行绘制了。我们通过投影将它转换到裁剪空间即裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
  1. 最后,OpenGL要绘制到屏幕上又需要将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程让位于-1.0到1.0范围的裁剪空间坐标变换到由glViewport函数所定义的屏幕坐标范围内。最后变换出来的坐标将会送到光栅器将其转化为片段。

上述流程中我们通过模型(Model)观察(View)投影(Projection)这三个矩阵来实现不同坐标系之间的转换。

我们知道矩阵是可以相乘的并且不满足交换律的(有方向的),而上面的全部流程我们可以通过下面的左乘矩阵来一步到位实现坐标转换。

在OpenGL里面它希望传入顶点着色器的值是标准化设备的坐标(Normalized Device Coordinate, NDC)即裁剪空间的坐标。

在目前我所接触到2D图像处理中,都是从单一观察角度去描述一个图像的。所以我所面临的构建坐标系统一般有两种情况:

  1. 在观察者空间中构建真实的坐标系然后通过投影变换转换到裁剪空间再传给OpenGL。
  2. 直接描述裁剪空间中的坐标并传给OpenGL。

例如,在描述一张图像渲染的时候直接描述它的裁剪空间坐标然后交由OpenGL渲染就行了。但是假如我要描述它是如何运动的(旋转、平移、缩放)就先在观察空间描述完它的坐标运动然后通过投影变换映射到裁剪空间再交由OpenGL渲染。

下图就是OpenGL中裁剪空间示意:

投影变换

上面描述了一个完整构建坐标系的流程,而在实践过程中(平面图像处理)我更关注观察空间和裁剪空间的转换,即投影变换,这里常用到两种投影方式:

  • 正射投影矩阵(Orthographic Projection Matrix)
  • 透视投影矩阵(Perspective Projection Matrix)

投影这个概念相比大家并不陌生,形象的说投影区域就像是阳光直射照见物体所形成的阴影面积一样,而在坐标的投影变换里面由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。

而当真正表示这个平截头体(Frustum)的时候我们需要赋予OpenGL (x,y,z,w)四个分量,其中x,y,z可以描述这个容器而w分量(齐次坐标)则表示观察者远近的概念。

这里有必要再学习一下w分量(齐次坐标)的含义,我们知道在欧式空间里面两条共面的平行线无法相交然而在投影空间内却不是这样的。比如下图中两个平行的轨道间距随着视线变远而减小在远处某点相交(透视投影):

为了解决这个问题,数学家们引入齐次坐标这个概念,在N维空间中采用N+1个量来表示N维坐标。如(x,y,z)的空间就表示为(x,y,z,w)。而(x,y,z)与w之间的转换关系如下:

那么w分量(齐次坐标)所代表的含义在这里我们可以看做是操作远近的分量。

  • 当w = 1的时候,坐标不会增大或缩小,投影的区域保持原有的大小。
  • 当w < 1的时候,坐标会变小,投影的区域也跟着缩小。
  • 当w > 1的时候,坐标会变大,投影的区域也跟着变大。
  • 当w = 0的时候,它表示无穷远(或者表示无限长的一个向量)。

正射投影(Orthographic Projection)

正交投影所形成的平截头体(Frustum)就如同下图一样,它更像是一个矩形容器,位于近平面(Near Plane)和远平面(Far Plane)之间的空间区域坐标就是这个平截头体,超出这个区域的坐标将会被裁剪掉。

可以看到这个平截头体(Frustum)是由宽,高,远近组成的。而当你操作(x,y,z,w)分量的时候,在正交投影里面操作w保持为1通过计算并不影响(x,y,z),它用于控制裁剪区域而不会形成透视的视觉效果。

透视投影(Perspective Projection)

透视投影所形成的平截头体(Frustum)就如同下图一样,也就是我们常见的相机视角。它通过定义宽,高,远近,视野这几个分量来形成一个观察空间。在这个空间内的物体经过透视投影变换后会出现近大远小的效果。

着色器脚本绘制

学会怎么构建物体坐标系后就可以开始编写一些简单的着色器脚本了。在前面的文章里总结了如何将OpenGL和本地窗口系统联系到一起的流程,并简单的用glClear和glClearCorlor来操作上下文渲染一个颜色到屏幕视图上,而接下来将开始真正进入OpenGL的渲染绘制编程。

回想一下,OpenGL本质是一个状态机(通过一系列的变量描述OpenGL此刻应当如何运行),这个状态机的一个状态称作OpenGL的上下文(Context)。然后将一个个标准绘制流程包裹成渲染管线程序(Program)在上下文中去执行。

也就是上下文(Context)提供设置选项,操作缓冲这些环境变量,而渲染管线(Program)负责真正的渲染绘制。

不同的渲染管线(Program)可以运行在同一个上下文(Context)中达到资源共享和程序顺序。也可以运行在不同上下文(Context)来实现资源独占和程序并行,而不同的上下文(Context)之间可可以通过ShareObject实现资源共享。

Demo工程:OpenGL学习实例

iOS上绘制一个三角形


按照之前的思路,将上下文(Context),渲染管线(Program),绘制程序(Render)分别抽象出来。Context负责提供上下文环境和切换操作,Render包裹渲染管线Program执行绘制,Program负责渲染脚本编译链接和添加渲染变量。

这里简单编写一个Vertex脚本和Fragment脚本实现控制输入顶点坐标绘制一个图形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
NSString *const kVertexShaderString = SHADER_STRING
(
attribute vec2 position;
attribute vec2 inputTextureCoordinate;
varying vec2 textureCoordinate;

void main() {
gl_Position = vec4(position.xy, 0, 1);
textureCoordinate = inputTextureCoordinate;
}
);

NSString *const kFragmentShaderString = SHADER_STRING
(
varying highp vec2 textureCoordinate;
uniform sampler2D inputTexture;

void main() {
gl_FragColor = texture2D(inputTexture, textureCoordinate);
}
);

在将脚本编译链接到Program后,设置glViewport(输出到屏幕的Rect)和一个三角形的顶点坐标,然后利用glDrawArrays绘制到这个glContext中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 通过默认脚本绘制一个三角形
- (void)render:(CGSize)size {
// 当前上下文使用该渲染管线
[self.program use];

// 定义裁剪空间转换到屏幕上的空间大小
glViewport(0, 0, size.width, size.height);

// 输入顶点坐标
glEnableVertexAttribArray(self.positionAttribute);
const float vertex[8] = {
-0.5, -0.5,
-0.5, 0.5,
0.5, 0.5 };

// 将顶点坐标和programz中position这个变量联系到一起
glVertexAttribPointer(self.positionAttribute, 2, GL_FLOAT, GL_FALSE, 0, vertex);

// 使用三角形图元绘制
glDrawArrays(GL_TRIANGLE_STRIP, 0, 3);

glDisableVertexAttribArray(self.positionAttribute);
}

android上绘制一个三角形


类似的,抽象出渲染管线(Program),绘制程序(Render),将渲染脚本(Shader)放到每个渲染管线中包裹在绘制程序中执行渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 当前上下文使用该渲染管线
mProgram->use();

// 定义裁剪空间转换到屏幕上的空间大小
glViewport(0, 0, size.width, size.height);

glClearColor(0,1,0,1);
glClear(GL_COLOR_BUFFER_BIT);

// 输入顶点坐标
// 安卓中申请的每个attr必须要填入值,不然效果会失效
glEnableVertexAttribArray(mPositionAttribute);

const float vertex[6] = {
-0.5, -0.5,
-0.5, 0.5,
0.5, 0.5 };

// 将顶点坐标和program中position这个变量联系到一起
glVertexAttribPointer(mPositionAttribute, 2, GL_FLOAT, GL_FALSE, 0, vertex);

// 使用三角形图元绘制
glDrawArrays(GL_TRIANGLE_STRIP, 0, 3);

glDisableVertexAttribArray(mPositionAttribute);

总结

这里总结了如何规范化的去描述一个坐标系统将物体从物理空间世界转换到屏幕上的过程。这里分别用OC和Android+Cpp写了一套基础的渲染框架,之后的工作就可以开始集中注意力在编写Shader脚本上了。