一、理论基础
1、渲染管线
3D建模设计师在完成模型设计后,会产生一个模型文件。这个文件中储存了模型中每个顶点的数据(一般包括坐标、法线、uv等),以及哪些顶点构成一个面。渲染管线即是对这些数据进行处理,并在计算机中绘制图形的过程。
接下来,我将为大家简要介绍一下渲染管线的全过程。
此处只是对渲染管线的简要介绍,省略了其中繁杂的细节。更加详细的内容,请读者自行查阅相关资料。
首先,我们需要确定要绘制哪些内容,并将这些内容的数据打包交给GPU。这一阶段完全由CPU负责,被称为应用阶段。
GPU接收到CPU传来的数据后,就要根据数据绘制图形了,这一阶段被称为几何阶段。几何阶段的具体流程如下:
- 顶点处理(Vertex Processing)。CPU传来的数据都是顶点数据,其中最重要的是顶点的坐标,而这些坐标是在模型坐标系(每个模型自身拥有的独立的三维坐标系)中的。顶点处理阶段的一个重要任务就是将模型坐标转换为NDC坐标。具体来说,模型坐标首先通过模型变换(Model Transformation)转换为世界坐标,之后通过视图变换(View Transformation)转换为观察坐标,最后通过投影变换(Projection Transformation)和透视除法转换为NDC坐标。模型变换、视图变换、投影变换合在一起称为MVP变换。顶点处理是通过一个叫做顶点着色器(Vertex Shader)的运行在GPU中的程序实现的,顶点着色器程序由用户自行编写。
NDC坐标在上一章有详细介绍。
- 图元装配。这一阶段的主要任务是将NDC坐标通过视口变换(Viewport Transformation)转换为屏幕坐标。同时也将进行裁剪、背面剔除等提高效率的工作。这一阶段由硬件自动完成。
- 光栅化(Rasterization)。经过上述步骤,原本用模型坐标表示的顶点数据已经被转换为屏幕坐标数据。这一步的任务就是将三角形绘制在屏幕上。三角形中的每一个像素都有自己的颜色,而颜色值是由片元着色器(Fragment Shader)确定的。片元着色器程序由用户编写,GPU运行。
经过上述步骤,就能够在屏幕中绘制三角形了。
2、MVP变换
上面已经说过,MVP变换时将模型坐标转换为NDC坐标的过程。具体说来,我们可以通过模型在世界中的摆放位置和方向、摄像机在世界中的位置和朝向等信息,可以计算出一个矩阵(MVP矩阵)。将原来的模型坐标与MVP矩阵相乘,即可将模型坐标转换为NDC坐标。MVP矩阵的具体计算方法这里不做介绍,在OpenGL中,可以通过调用API自动完成计算。
3、着色器
上面说过,在几何阶段,有两个程序需要用户自行编写:顶点着色器和片元着色器。每个顶点都会运行一次顶点着色器程序,而片元着色器则是针对每个像素而言的。
着色器的具体内容会在以后介绍,在本章,会直接为读者提供最基础的着色器程序。
二、绘制三角形
1、顶点数据
首先,提供三角形的顶点数据。
import numpy as np
# 顶点坐标数组,一定要是numpy数组!数据类型为np.float32(4字节float数据)
triangle = np.array([
# 每一行为一个顶点,前三个元素为顶点坐标,后三个元素为顶点颜色
-0.5, -0.5, 0, 1, 0, 0,
0.5, -0.5, 0, 0, 1, 0,
0, 0.5, 0, 0, 0, 1
], dtype=np.float32)
注意,上述的数组中,每一行为一个顶点数据,前三个元素构成一个顶点坐标,此处的顶点坐标为NDC坐标。事实上模型文件中的顶点坐标都是模型坐标,通过MVP变换后才会转换成NDC坐标。我们以后会讲到如何进行MVP变换。
2、VBO
刚才定义的顶点数据是储存在内存中的,要将这些数据传入GPU,就需要VBO(Vertex Buffer Object,顶点缓冲对象)的帮助。
from OpenGL.arrays.vbo import VBO # 引入VBO类
vbo = VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER) # 创建VBO
vbo.bind() # 绑定VBO
VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER)
中的最后一个参数就是默认值,所以可以省略为VBO(triangle, GL_STATIC_DRAW)
。
VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER)
:这句代码创建了一个VBO对象,其中第一个参数是顶点数据。
第二个参数usage指定了我们希望显卡如何管理给定的数据。它有三种形式:
usage | 说明 |
---|---|
GL_STATIC_DRAW | 数据不会或几乎不会改变 |
GL_DYNAMIC_DRAW | 数据常常会发生改变 |
GL_STREAM_DRAW | 数据每次绘制时都会改变 |
第三个参数target是什么意思呢?这实际上与OpenGL的状态机机制有关。在OpenGL中,很多对象需要被绑定到被称为“目标”的位置才能使用,而GL_ARRAY_BUFFER
正是这些目标位置之一。所以这里的target参数,表示了这个VBO对象应该占据的目标位置。而下面那句vbo.bind()
则是真正占据这个目标位置。此后,对于所有针对GL_ARRAY_BUFFER
目标的操作,实际上都是对此对象的操作。
3、着色器
下面直接提供顶点着色器和片元着色器的代码:
# 顶点着色器
vs = """
#version 330 core
in vec3 aPos;
in vec3 aColor;
out vec3 VertexColor;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
VertexColor = aColor;
}
"""
# 片元着色器
fs = """
#version 330 core
in vec3 VertexColor;
out vec4 FragColor;
void main()
{
FragColor = vec4(VertexColor.rgb, 1.0f);
}
"""
注意上面的着色器代码是直接嵌入python程序中的,在正式程序中,这些代码常常会从其它文件读取,python读取文件的操作很容易,这里不再讲解。
以后我们会详细介绍着色器的编写方式。现在读者只需要关注顶点着色器中的这两行代码:
in vec3 aPos;
in vec3 aColor;
这两行代码都是定义变量的代码。其中,in
表示这个变量由CPU传入,vec3
定义数据类型为3维向量。
上述代码只是定义了着色器的程序代码,接下来我们还要对这段程序代码进行编译:
from OpenGL.GL import shaders
vsProgram = shaders.compileShader(vs, GL_VERTEX_SHADER)
fsProgram = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vsProgram, fsProgram)
经过上述代码,最终的program变量即为编译后的shader。
4、解释数据含义
之前我们已经定义了表示顶点数据的数组:
triangle = np.array([
# 每一行为一个顶点,前三个元素为顶点坐标,后三个元素为顶点颜色
-0.5, -0.5, 0, 1, 0, 0,
0.5, -0.5, 0, 0, 1, 0,
0, 0.5, 0, 0, 0, 1
], dtype=np.float32)
我们也通过VBO将这些数据交给了GPU,但GPU并不知道这些数据的含义。通过以下代码,可以让GPU理解数据所表示的含义:
# 坐标
aPosLoc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(aPosLoc)
# 颜色
aColorLoc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(aColorLoc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(aColorLoc)
前面给出的顶点着色器代码中,有两个变量需要CPU传入:aPos
和aColor
。在着色器编译时,编译器会为它们指定一个索引,通过glGetAttribLocation(program, name)
即可获取这个索引。
glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(0))
:这句代码是解释了顶点的某一个属性的信息。它的参数较多,我们一一介绍:
第一个参数:该属性的索引。
第二个参数:该属性的元素数量。在本文中,属性aPos
的类型为vec3
,需要三个浮点型的元素。
第三个参数:该属性的元素的类型。注意不能用float
或np.float32
代替GL_FLOAT
。
第四个参数:是否标准化。如果为GL_TRUE
,则数据会自动标准化到
[
0
,
1
]
[0, 1]
[0,1]之间。可以用True
或False
代替。
第五个参数:步长。这个参数表示两个顶点的同一属性之间的距离(字节)。对于本文而言,每两个顶点之间相差了6个np.float32
类型的元素,因此步长为
4
×
6
=
24
4\times6=24
4×6=24个字节。
第六个参数:数组中第一个表示当前属性的元素相对于数组起始位置的偏移量(位)。对于aPos
而言,其偏移量为0;对于aColor
而言,其偏移量为12位。
最后,glEnableVertexAttribArray(loc)
,启用顶点属性。
5、绘制图形
def render():
glUseProgram(program) # 使用着色器
glDrawArrays(GL_TRIANGLES, 0, 3) # 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3)
用于绘制三角形。其第一个参数表示需要绘制的图形的类型;第二个参数是顶点数组的起始索引;第三个参数是要绘制的顶点个数。
完整代码:
from OpenGL.GL import *
from OpenGL.GL import shaders
from OpenGL.arrays.vbo import VBO
from window import Window # 第一章中封装的Window类
import numpy as np
# 创建窗口
w = Window(1920, 1080, "Test")
# 定义数据
triangle = np.array([
-0.5, -0.5, 0, 1, 0, 0,
0.5, -0.5, 0, 0, 1, 0,
0, 0.5, 0, 0, 0, 1
], dtype=np.float32)
# 创建、绑定VBO
vbo = VBO(triangle, GL_STATIC_DRAW)
vbo.bind()
# 着色器(具体内容在前面)
vs = """ ... """
fs = """ ... """
# 编译着色器
vsProgram = shaders.compileShader(vs, GL_VERTEX_SHADER)
fsProgram = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vsProgram, fsProgram)
# 解释数据含义
aPosLoc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, False, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(aPosLoc)
aColorLoc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(aColorLoc, 3, GL_FLOAT, False, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(aColorLoc)
# 渲染循环
def render():
glUseProgram(program)
glDrawArrays(GL_TRIANGLES, 0, 3)
w.loop(render)
结果:
6、VAO
至此,我们已经成功绘制出了三角形。但是依然存在一个问题:现在我们只绘制了一个物体,如果我们的场景中有上百个不同的物体呢?我们对每一个物体,都要重复一次上述过程,配置顶点的状态信息。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
VAO(Vertex Array Object, 顶点数组对象)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中。
一个VAO会储存以下这些内容:
glEnableVertexAttribArray
和glDisableVertexAttribArray
的调用。- 通过
glVertexAttribPointer
设置的顶点属性配置。 - 通过
glVertexAttribPointer
调用与顶点属性关联的顶点缓冲对象。
以上对于VAO的介绍来自于LearnOpenGL CN。
创建VAO:
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
glGenVertexArrays
用于创建VAO对象,其参数表示一次创建多少个对象。glBindVertexArray
用于绑定VAO。
绑定VAO后再配置顶点数据信息,VAO会存储这些内容。
使用VAO后的完整代码:
from OpenGL.GL import *
from OpenGL.GL import shaders
from OpenGL.arrays.vbo import VBO
from window import Window # 第一章中封装的Window类
import numpy as np
# 创建窗口
w = Window(1920, 1080, "Test")
# 定义数据
triangle = np.array([
-0.5, -0.5, 0, 1, 0, 0,
0.5, -0.5, 0, 0, 1, 0,
0, 0.5, 0, 0, 0, 1
], dtype=np.float32)
# 创建、绑定VAO
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
# 创建、绑定VBO
vbo = VBO(triangle, GL_STATIC_DRAW)
vbo.bind()
# 着色器(具体内容在前面)
vs = """ ... """
fs = """ ... """
# 编译着色器
vsProgram = shaders.compileShader(vs, GL_VERTEX_SHADER)
fsProgram = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vsProgram, fsProgram)
# 解释数据含义
aPosLoc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, False, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(aPosLoc)
aColorLoc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(aColorLoc, 3, GL_FLOAT, False, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(aColorLoc)
# 渲染循环
def render():
glUseProgram(program)
glBindVertexArray(vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
w.loop(render)
结果与之前完全相同。
除了VAO、VBO之外,OpenGL中还有一种常用的用于绘制物体的对象——EBO。关于EBO的内容,限于篇幅不再介绍,感兴趣的读者可以自行搜索相关资料了解。
三、结语
本章介绍了渲染管线,并讲解了一种常用的绘制三角形的方法——glDrawArrays
。在下一章中,将详细讲解着色器的编写过程。