0%

图形渲染管线及Shader编程

前面我已经将Android的显示原理及如何在Android如何构建OpenGL环境向你介绍清楚了,那么今天咱们再来聊聊图形渲染管线以及Shader编程。

图形渲染管线

视频、图像的渲染实际上是属于图形学的范畴,在图形学上为了提高渲染图像的效率和灵活性,提出了渲染管线

渲染管线分为固定渲染管线和可编程渲染管线。其中固定渲染管线指的是,图像在渲染时必须按照固定的顺序一步步执行,用户在渲染开始前提供需要渲染的数据和参数,然后等待渲染结果;而可编程渲染管线指的是,在图像渲染时仍然按照固定的顺序一步步执行,但在某些阶段,如顶点着色器阶段、片元处理阶段,可以通过编写Shader程序进行控制。在Android系统下,OpenGLES1.0采用的是固定管线,而从OpenGLES2.0之后则变成了可编程管线。

如上图所示,图形渲染管线从大的方面分为:应用阶段几何阶段光栅化阶段。其中,应用阶段是在CPU中运行的,而几何阶段和光栅化阶段是在GPU中运行的。

应用阶段主要负责提供各种渲染数据和Shader程序;几何阶段又分为顶点着色器阶段几何着色器裁剪屏幕映射四个小阶段;光栅化阶段分为连接三角形遍历三角形片元着色片元操作几个小阶段。

现在我们以顶点着色器阶段为例,看看使用固定管线与使用可编程管线的区别。依然如上图所示,我们可以看到在顶点着色器阶段需要做三件事儿:模型变换、视图变换、顶点着色。如果我们想绘制一个三角形,在Android系统下采用固定渲染管线时,我们需要将顶点坐标、模型变换矩阵和视图变换矩阵通过OpenGL接口传给GPU,这样在GPU内部它就会按照管线的步骤一步步执行最终将三角形绘制出来。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
// 设置顶点数据
glVertexPointer(3, GL_FLOAT, 0, vertices); // 顶点坐标
glColorPointer(4, GL_FLOAT, 0, colors); // 顶点颜色
glEnableClientState(GL_VERTEX_ARRAY); // 启用顶点数组
glEnableClientState(GL_COLOR_ARRAY); // 启用颜色数组

// 设置模型变换矩阵
glMatrixMode(GL_MODELVIEW); // 选择模型视图矩阵

// 设置视图变换矩阵
gluLookAt(0.0f, 0.0f, 0.0f, // 相机位置
0.0f, 0.0f, -1.0f, // 相机朝向
0.0f, 1.0f, 0.0f); // 相机上方向

...

// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3); // 绘制三个顶点
...

通过上面的代码我们可以看到,在使用固定管线渲染三角形时,我们只需要调用指定的API将顶点坐标、模型变换矩阵、视图变换矩阵传给GPU,然后再调用glDrawArrays方法启动绘制即可将三角形绘制出来。

而使用可编程渲染管线则与固定渲染管线不同,我们首先要编写Shader程序,然后将数据传给Shader程序,最后由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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
...
// 定义顶点着色器
String vertexShaderSource = "#version 300 es\n"
+ "layout (location = 0) in vec3 aPos;\n" // 顶点坐标
+ "layout (location = 1) in vec4 aColor;\n" // 顶点颜色
+ "uniform mat4 model;\n" // 模型变换矩阵
+ "uniform mat4 view;\n" // 视图变换矩阵
+ "uniform mat4 projection;\n" // 投影变换矩阵
+ "out vec4 vColor;\n" // 输出颜色
+ "void main()\n"
+ "{\n"
+ " gl_Position = projection * view * model * vec4(aPos, 1.0);\n" // 计算裁剪空间坐标
+ " vColor = aColor;\n" // 传递颜色
+ "}\0";

// 定义片元着色器
String fragmentShaderSource = ...

// 创建顶点着色器对象
int vertexShader;
vertexShader = GL20.glCreateShader(GL20.GL_VERTEX_SHADER);
// 将着色器源码附加到着色器对象上
GL20.glShaderSource(vertexShader, vertexShaderSource);
// 编译着色器
GL20.glCompileShader(vertexShader);

// 创建片元着色器对象
...

// 创建着色器程序对象
int shaderProgram;
shaderProgram = GL20.glCreateProgram();
// 将着色器对象链接到程序对象上
GL20.glAttachShader(shaderProgram, vertexShader);
GL20.glAttachShader(shaderProgram, fragmentShader);
GL20.glLinkProgram(shaderProgram);

...

// 设置顶点数据
float[] vertices = {
// 顶点坐标 // 顶点颜色
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 顶点0
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, // 顶点1
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f // 顶点2
};

...

// 设置顶点属性指针
GL20.glVertexAttribPointer(0, ...); // 顶点坐标
GL20.glEnableVertexAttribArray(0); // 启用顶点属性

// 设置模型变换矩阵
Matrix4f model = new Matrix4f(); // 初始化为单位矩阵
...
int modelLoc = GL20.glGetUniformLocation(shaderProgram, "model"); // 获取模型变换矩阵的统一变量位置
FloatBuffer modelBuffer = BufferUtils.createFloatBuffer(16);
model.get(modelBuffer); // 将矩阵转换为缓冲区
GL20.glUniformMatrix4fv(modelLoc, false, modelBuffer); // 设置模型变换矩阵的值

// 设置视图变换矩阵
Matrix4f view = new Matrix4f(); // 初始化为单位矩阵
view.lookAt(0.0f, 0.0f, 0.0f, // 相机位置
0.0f, 0.0f, -1.0f, // 相机朝向
0.0f, 1.0f, 0.0f); // 相机上方向
int viewLoc = GL20.glGetUniformLocation(shaderProgram, "view"); // 获取视图变换矩阵的统一变量位置
FloatBuffer viewBuffer = BufferUtils.createFloatBuffer(16);
view.get(viewBuffer); // 将矩阵转换为缓冲区
GL20.glUniformMatrix4fv(viewLoc, false, viewBuffer); // 设置视图变换矩阵的值

// 设置投影变换矩阵
Matrix4f projection = new Matrix4f(); // 初始化为单位矩阵
projection.frustum(-1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 10.0f); // 设置透视投影
int projectionLoc = GL20.glGetUniformLocation(shaderProgram, "projection"); // 获取投影变换矩阵的统一变量位置
FloatBuffer projectionBuffer = BufferUtils.createFloatBuffer(16);
projection.get(projectionBuffer); // 将矩阵转换为缓冲区
GL20.glUniformMatrix4fv(projectionLoc, false, projectionBuffer); // 设置投影变换矩阵的值
...
// 绘制三角形
GL11.glDrawArrays(...); // 绘制三个顶点
...

上面代码中,我们先实现了Shader程序,然后编译、链接该程序。当链接好程序好后就可以将它传给GPU运行了。在Shader程序中,我们定义了aPos、model、view、projection等变量用来接收用户传入的顶点数据、模型变换矩阵、视图变换矩阵、投影变换矩阵。最后,当用户调用glDrawArrays后,触发可编程管线运行,可编程管线在顶点着色器阶段运行顶点Shader,这样渲染过程就运转起来了。

至此,我们应该清楚图形渲染管线的作用(为了提高效率和灵活性)及固定管线与可编程管线的区别(都是按固定步骤对图像进行渲染,只不过可编程渲染管线中的某些阶段可通过程序进行控制,这样提高了图型渲染的能力,可以渲染出更加复杂的效果)。而渲染管线各阶段的详细作用我在课程中都做了详细介绍这里就不赘述了。

接下来我们来看看如何编写Shader程序。

Shader编程

如上面我们在渲染管线中讲到的,对于可编程渲染管线来说,有好几个阶段是可编程的,其中我接触最多的是顶点Shader和片元Shader。顶点Shader是在顶点着色阶段被调用,而片元Shader是在片元着色阶段被调用。除了这两个阶段外,几何着色阶段也可调用Shader,该阶段被调用的Shader称为几何Shader。不过由于几何Shader是在OpenGL3.2之后才实现的,所以Android下的OpenGLES是不支持的。接下来咱们就来看看如何实现顶点Shader和片元Shader。

顶点Shader

顶点Shader顾名思义,就是在顶点着色阶段调用的Shader。它的工作往往非常简单,就是确定绘制图形顶点的位置。比如我们要绘制一个三角形,它有三个顶点,那么这三个顶点在标准设备坐标系下的坐置就是由顶点Shader确定的。

我们先来看一下顶点Shader的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义顶点着色器
String vertexShaderSource = "#version 300 es\n"
+ "layout (location = 0) in vec3 aPos;\n" // 顶点坐标
+ "layout (location = 1) in vec4 aColor;\n" // 顶点颜色
+ "uniform mat4 model;\n" // 模型变换矩阵
+ "uniform mat4 view;\n" // 视图变换矩阵
+ "uniform mat4 projection;\n" // 投影变换矩阵
+ "out vec4 vColor;\n" // 输出颜色
+ "void main()\n"
+ "{\n"
+ " gl_Position = projection * view * model * vec4(aPos, 1.0);\n" // 计算裁剪空间坐标
+ " vColor = aColor;\n" // 传递颜色
+ "}\n";

上述代码中,第1行指定了使用的OpenGL版本,300表示使用的是OpenGL3.0,es表示使用OpenGL的嵌入式版本,即OpenGLES;第2-3行中的in表示这两个变量都是输入变量,而前面的Layout(location=…)指明了变量的位置,通过这种方式,外面向该变量传数据时,就不再需要通过glGetAttribLocation()函数获取其位置了,可以直接使用glVertexAttribPointerb()函数对其赋值。上面的这种写法是OpenGL3.0的新语法;第4-6行没什么特别的,这里不赘述了;第7行中的out指明vColor是一个输出变量,该变量是片元Shader的输入变量,因此在片元Shader中一定有一个与该变量名一样,但前面有in修饰的变量;最后面的main()函数逻辑非常简单,计算每个顶点的位置,并将顶点位置输出给gl_Position,同时将传入的颜色值输出到vColor。

片元Shader

片元Shader的作用是对图形中每个像素的颜色进行填充。我们来看一个具体的例子:

1
2
3
4
5
6
7
String fragmentShaderSource = "#version 300 es\n"
+ "in vec4 vColor;\n" // 输入颜色
+ "out vec4 FragColor;\n" // 输出颜色
+ "void main()\n"
+ "{\n"
+ " FragColor = vColor;\n" // 直接输出颜色
+ "}\n";

上述代码中第1行代码指定了使用的OpenGLES版本为3.0;第2行指明vColor变量的数据是从顶点Shader中获得的;第3行out指定FragColor是一个输出变量;后面的main函数逻辑非常简单,就是将顶点Shader传过来的颜色直接输出给FragColor,也就是输出到屏幕上。

从上面我们可以看到其实OpenGL的Shader程序并不难,它使用的是类C语言的语法,其中顶点Shader负责处理图形的顶点位置,而片元Shader用于为图形中的像素着色。

小结

本篇我们对图形渲染管线和Shader编程进行重新梳理,在我看来,如果想学好OpenGL必须要将图形渲染管线的作用,工作机制搞清楚,只有这样你才能真正的学好、用好OpenGL。

参考资料

系统玩转OpenGL+AI,实现各种酷炫视频特效

欢迎关注我的其它发布渠道