一、理论基础
1、渲染管线
3D建模设计师在完成模型设计后,会产生一个模型文件。这个文件中储存了模型中每个顶点的数据(一般包括坐标、法线、uv等),以及哪些顶点构成一个面。渲染管线即是对这些数据进行处理,并在计算机中绘制图形的过程。
接下来,我将为大家简要介绍一下渲染管线的全过程。
此处只是对渲染管线的简要介绍,省略了其中繁杂的细节。更加详细的内容,请读者自行查阅相关资料。
首先,我们需要确定要绘制哪些内容,并将这些内容的数据打包交给GPU。这一阶段完全由CPU负责,被称为应用阶段。
GPU接收到CPU传来的数据后,就要根据数据绘制图形了,这一阶段被称为几何阶段。几何阶段的具体流程如下:
- 顶点处理(Vertex Processing)。CPU传来的数据都是顶点数据,其中最重要的是顶点的坐标,而这些坐标是在模型坐标系(每个模型自身拥有的独立的三维坐标系)中的。顶点处理阶段的一个重要任务就是将模型坐标转换为NDC坐标。具体来说有三个步骤:
1.1 模型坐标首先通过模型变换(Model Transformation)转换为世界坐标。模型坐标只记录了模型内的各个顶点之间的相对位置关系,而并没有确定这个模型在世界内是如何摆放的,游戏开发者或场景建模师可以将这些模型摆在世界的任何位置,并且对模型进行缩放和旋转,以满足他们自己的要求。
1.2 之后,世界坐标通过视图变换(View Transformation)转换为观察坐标。这一步的目的是将场景中的摄像机移动到世界坐标原点的位置,并使其朝向-z方向(场景中的所有物体都会跟随摄像机移动,以保持物体相对位置不变),便于后续将场景投影到2D屏幕上。
1.3 最后,通过投影变换(Projection Transformation)和透视除法转换为NDC坐标。投影变换能够将摄像机能看到的场景投影到2D空间内,它的实现原理比较复杂,这里不展开讲解。
模型变换、视图变换、投影变换合在一起称为MVP变换。顶点处理是通过一个叫做顶点着色器(Vertex Shader)的运行在GPU中的程序实现的,顶点着色器程序由用户自行编写。
NDC坐标在上一章有详细介绍。
投影变换的作用是将3D转2D,但是NDC空间仍然是3D空间,那这个空间的z轴的含义是什么呢?
实际上,经过投影变换后,顶点的z坐标已经不能具体表示该顶点在z轴上的位置,但是经过变换后,不同顶点的z坐标大小关系仍不变,因此z坐标仍然可以作为判断物体前后遮挡关系的依据,这正是投影变换后保留z坐标的原因。后续章节会介绍的深度测试正是用到了这一原理。
- 图元装配。这一阶段的主要任务是将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变换。暂时我们直接定义顶点的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
目标的操作,实际上都是对此对象的操作。
上面这段话可能难以理解,这里打个比方。
你可以把OpenGL看成一个大公司,在这个公司里有很多职位,每种职位都有它对应的工位。VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER)
中的target,就是为当前正在创建的vbo分配一个名为GL_ARRAY_BUFFER
的职位,而vbo.bind()
则是让这个vbo在它的工位上落座,开始干活。
但是,OpenGL公司有些奇葩,职位为GL_ARRAY_BUFFER
的员工可能有很多个,但是GL_ARRAY_BUFFER
所对应的工位却只有一个,如果其他的vbo想要工作怎么办呢?只能由这个vbo调用bind()
方法,抢占掉原来vbo的位置。因此,在每次使用vbo之前,都应该调用它的bind方法,让它能够正常工作。
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
vs_program = shaders.compileShader(vs, GL_VERTEX_SHADER)
fs_program = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vs_program, fs_program)
经过上述代码,最终的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理解数据所表示的含义:
# 坐标
a_pos_loc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(a_pos_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(a_pos_loc)
# 颜色
a_color_loc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(a_color_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(a_color_loc)
前面给出的顶点着色器代码中,有两个变量需要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)
用于绘制三角形。其第一个参数表示需要绘制的图形的类型;第二个参数是顶点数组的起始索引;第三个参数是要绘制的顶点个数。
如果你运行时出现类似于
numpy.dtype size changed, may indicate binary incompatibility. Expected 96 from C header, got 88 from PyObject
的错误,尝试重新安装版本为1.26.4的numpy。
完整代码:
import numpy as np
from OpenGL.GL import *
from OpenGL.GL import shaders
from Window import Window
from OpenGL.arrays.vbo import VBO
program = None
def render():
glUseProgram(program) # 使用着色器
glDrawArrays(GL_TRIANGLES, 0, 3) # 绘制三角形
if __name__ == '__main__':
width, height, title = 1920, 1080, "Test"
w = Window(width, height, title)
# 顶点坐标数组,一定要是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)
vbo = VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER)
vbo.bind()
# 顶点着色器
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);
}
"""
vs_program = shaders.compileShader(vs, GL_VERTEX_SHADER)
fs_program = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vs_program, fs_program)
# 坐标
a_pos_loc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(a_pos_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(a_pos_loc)
# 颜色
a_color_loc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(a_color_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(a_color_loc)
w.add_render_event(render)
w.loop()
结果:
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后的完整代码:
import numpy as np
from OpenGL.GL import *
from OpenGL.GL import shaders
from Window import Window
from OpenGL.arrays.vbo import VBO
program = None
def render():
glUseProgram(program) # 使用着色器
glDrawArrays(GL_TRIANGLES, 0, 3) # 绘制三角形
if __name__ == '__main__':
width, height, title = 1920, 1080, "Test"
w = Window(width, height, title)
# 顶点坐标数组,一定要是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)
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
vbo = VBO(triangle, usage=GL_STATIC_DRAW, target=GL_ARRAY_BUFFER)
vbo.bind()
# 顶点着色器
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);
}
"""
vs_program = shaders.compileShader(vs, GL_VERTEX_SHADER)
fs_program = shaders.compileShader(fs, GL_FRAGMENT_SHADER)
program = shaders.compileProgram(vs_program, fs_program)
# 坐标
a_pos_loc = glGetAttribLocation(program, 'aPos')
glVertexAttribPointer(a_pos_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(0))
glEnableVertexAttribArray(a_pos_loc)
# 颜色
a_color_loc = glGetAttribLocation(program, 'aColor')
glVertexAttribPointer(a_color_loc, 3, GL_FLOAT, GL_FALSE, 24, ctypes.c_void_p(12))
glEnableVertexAttribArray(a_color_loc)
w.add_render_event(render)
w.loop()
结果与之前完全相同。
除了VAO、VBO之外,OpenGL中还有一种常用的用于绘制物体的对象——EBO。关于EBO的内容,限于篇幅不再介绍,感兴趣的读者可以自行搜索相关资料了解。
三、结语
本章介绍了渲染管线,并讲解了一种常用的绘制三角形的方法——glDrawArrays
。在下一章中,将详细讲解着色器的编写过程。