这篇文章的实验内容,我觉得很有意思,我们将模拟 OpenGL 的相关接口。
在文章 《空间变换》 中,我们把坐标数据、矩阵运算等内容一股脑塞在一个渲染任务类里面。这肯定是不好的,我们需要重构一下,便于管理和扩展。
我们也不用再想着法子,思考如何进行重构。因为 OpenGL 这套接口已经是抽象过后的产物了,所以我们直接拿过来用。这篇文章以 OpenGL 接口为参考,分为四个部分。
第一部分,我们引入 OpenGL 中 VBO/VAO 这套数据管理机制。
第二部分,我们进行着色器概念的抽象,实现一个着色器类。
第三部分,我们实现 OpenGL 的绘制接口模拟,在其中实现渲染管线的流程。
第四部分,记录一下显示不符合预期问题的排查过程,加深对空间变换原理的理解。
因为核心是重构问题,所以在本篇文章中,代码会贴的很多。
1. VBO/VAO
1.1 glGenBuffers
我们先回顾一下 glGenBuffers 接口。参数 n 指定要生成的缓冲区对象数量;参数 buffers,是一个数组,返回生成的缓冲区对象的名称。
- void glGenBuffers(GLsizei n, GLuint* buffers);
所以,当务之急,我们要先定义 buffer object。代码清单 1.1.1 是我们实现的 buffer object 类,其中记录了 buffer 的名称、大小和分配的地址。
如代码清单 1.1.2 所示,BufferObject 初始化时不分配大小,而是使用 SetBufferData 接口分配 buffer 的大小以及具体的内容。
buffer object 实现完成后,我们就可以着手实现 glGenBuffers 接口了。如代码清单 1.1.3 所示,我们实现了 GenBuffers 接口。buffer 名称依次递增,但是释放了的名称,还能“回收利用”。内部使用 map 表,记录 buffer 名称和实际 buffer object 的映射。
接口对应的 delete 接口实现都很简单,文章中都不赘述。
1.2 glBindBuffer
回顾 glBindBuffer 接口。参数 target 指定缓冲区对象绑定的目标;参数 buffer 指定要绑定的缓冲区名称。
- void glBindBuffer(GLenum target, GLuint buffer);
绑定目标的话,目前我们只使用到 GL_ARRAY_BUFFER 和 GL_ELEMENT_ARRAY_BUFFER。它们分别表示顶点属性的缓冲区和顶点索引的缓冲区。所以如代码清单 1.2.1 所示,我们对这两个目标进行定义。
BindBuffer 的逻辑不复杂,如代码清单 1.2.2 所示,就是状态的记录。在内部,我们记录绑定的 VBO 和 EBO。
缓冲区名称传递为 0,解除当前绑定。此处我们将其指向 NULL。
1.3 glBufferData
回顾 glBufferData 接口。参数 target 指定要更新的缓冲区目标;参数 size 指定数据的存储大小;参数 data 指定要复制数据的指针;参数 usage 指定缓冲区的使用模式。
- void glBufferData(GLenum target, GLsizeiptr size, const void* data, GLenum usage);
实现好 GenBuffers 和 BindBuffer 之后,我们就可以通过 BufferData 进行 buffer 内容的设置。实现见代码清单 1.3,就是内部调用了之前实现的 BufferObject 的 SetBufferData 接口。
1.4 glGenVertexArrays
buffer object 相关的接口已经都实现完毕,现在我们需要实现 vertex array object。VAO 存储多个顶点的相关状态,包括顶点缓冲区对象和顶点属性配置。即 VAO 不仅存储需要使用的 buffer object,还记录你如何使用这些 buffer object。
VAO 通过 glGenVertexArrays 接口创建。不过在此之前,我们需要先定义 VAO。
- void glGenVertexArrays(GLsizei n, GLuint* arrays);
代码清单 1.4.1 是我们对 VAO 的定义。它以 index 为“主键”查询记录的 VBO,同时还记录 VBO 的顶点属性信息。
GenVertexArrays 的实现和 GenBuffers 的逻辑是一模一样的。贴出代码清单 1.4.2 中定义的相关变量,就能“脑补”出实现,这边不再赘述。
1.5 glBindVertexArray
回顾 glBindVertexArray 接口。参数 array 指定要绑定的顶点数组对象的名称。
- void glBindVertexArray(GLuint array);
BindVertexArray 的实现方式和 BindBuffer 一样。如代码清单 1.5 所示,我们内部记录绑定的 VAO。
1.6 glVertexAttribPointer
回顾 glVertexAttribPointer 接口。参数 index 指定要修改的顶点属性索引;参数 size 指定每个顶点属性的组件数量;参数 type 指定数据类型;参数 normalized 指定数据是否要被标准化;参数 stride 指定属性组之间的偏移;参数 pointer 指定组件内的偏移量。
- void glVertexAttribPointer(
- GLuint index,
- GLint size,
- GLenum type,
- GLboolean normalized,
- GLsizei stride,
- const void* pointer
- );
这么多参数,其实就是我们之前定义的顶点属性 VertexAttribute。GenVertexArrays 的实现也不复杂,如代码清单 1.6 所示,我们调用 AddVertexAttribBinding 将当前绑定的 VBO,以及 index 和顶点属性进行映射。
1.7 测试
实现了以上接口后,我们就可以通过这些接口设置我们的数据内容了。如代码清单 1.7 所示,和 OpenGL 的使用是一样的,我们创建 3 个 VBO,分别存储三角形的顶点、颜色和 UV 坐标。创建了 1 个 VAO 记录这些 VBO。同时创建了一个 EBO,并进行了绑定。
代码里还实现了 VAO 信息的打印,可以打印信息进行核对调试。代码清单 1.7 对应的打印内容如下:
- VertexArray ID: 1
- ----------------------------------------
- Attrib Index: 0
- Type: 0
- Count: 3
- Stride: 12
- Offset: 0
- Bound Buffer: Buffer ID: 1, Size: 36 bytes.
- ----------------------------------------
- Attrib Index: 1
- Type: 0
- Count: 4
- Stride: 16
- Offset: 0
- Bound Buffer: Buffer ID: 2, Size: 48 bytes.
- ----------------------------------------
- Attrib Index: 2
- Type: 0
- Count: 2
- Stride: 8
- Offset: 0
- Bound Buffer: Buffer ID: 3, Size: 24 bytes.
- ----------------------------------------
这节的完整代码在提交 99385b1: Introduce and implement VAO/VBO。
2. 着色器类
我们也仿照 OpenGL 着色器的概念,抽象顶点和片元的处理。我们不用实现编译器那套这么复杂,如代码清单 2.1 所示,我们只要实现我们具体的顶点处理和片元处理函数即可。
具体的顶点信息需要在 VAO 结构里进行查询,但是我不想把 VAO 以及 VBO 这些数据结构暴露给着色器。所以为了封装 VAO 结构,如代码清单 2.2 中的那样,我们定义了一个 Shader Context 类。其中的 GetAttribute 函数,是想模拟 GLSL 中的 layout location 变量定义,它会返回具体的 VBO 内容。
- layout(location = 0) in vec3 aPos;
- layout(location = 1) in vec3 aColor;
关于着色器的输入和输出,我们的定义如代码清单 2.3 所示。在着色器函数中,我们只需要查询或填充它们即可。
2.1 Simple Shader
定义好着色器类之后,如代码清单 2.4 所示,我们实现一个最简单的着色器,它只进行 MVP 操作。
里面的公共成员就看成 uniform 变量。方便设置。
具体的顶点着色器和片元着色器实现,如代码清单 2.5 所示,其中顶点进行 MVP 变换操作,颜色信息不做改变。
3. 渲染管线
我们先回顾一下 glDrawElements 接口。参数 mode 指定要渲染的图元类型;参数 count 指定要渲染的索引数量;参数 type 指定索引的类型;indices 指定索引数组,如果已经有了索引缓冲区,则定义为相对于索引缓冲区的偏移。
- void glDrawElements(
- GLenum mode,
- GLsizei count,
- GLenum type,
- const void* indices);
如代码清单 3.1 所示,我们实现了 DrawElements,其中实现固定管线部分,并调用着色器实现。具体的流程为,在 EBO 中索引图元顶点信息,然后调用顶点着色器,之后进行透视除法,并将 NDC 坐标转化为屏幕坐标,接着进行光栅化。
片元着色器在光栅化阶段进行逐像素调用。如代码清单 3.2 所示,RasterizeTriangle 函数还是之前画三角形的那套逻辑,只不过是增加了片元着色器的调用。
3.1 测试
我们实现一个三角形旋转样例。接着代码清单 1.7 的基础,如代码清单 3.3 所示,我们使用实现的 Simple Shader 类,并设置 MVP 矩阵。接着使用 UseProgram 函数,设置着色器。渲染过程中,我们不断更新旋转矩阵,并调用 DrawElements 函数进行绘制。
最终的显示效果如视频 1 所示。
本篇文章对应的完整代码在 tag/ogl_pipeline。
4. 左手坐标系?右手坐标系?
关注坐标系的“契机”是,自己实现的旋转三角形的位置以及旋转方向都和样例不同。确定了不是管线逻辑这部分实现差异引起的,所以排查范围落到了 MPV 这套矩阵的定义。
样例中使用的是右手坐标系,而我在之前公式的推导中,发现左手坐标系推导更自然,所以代码中一直使用的左手坐标系。但这就导致了我不能对照样例程序逐帧调试差异点了。
排查这个问题的“指导方针”是,无论是左手坐标系,还是右手坐标系,如图 1 所示,在“上帝视角”观察,只要相机位置和物体位置是一样的,那么显示的效果就一定是一样的。
也就是说,要想显示效果一样,最终的 MVP 矩阵内容一定要是一样的。
所以需要依次核对排查 M、V、P 矩阵的实现是否有误。我这边不确定自己左手坐标系的推导是否正确,对比了开源库 glm 的实现。如代码清单 4 所示,glm 中有透视投影矩阵的左右手坐标系实现,可以查看源码实现。
视图矩阵左右手坐标系也要区分,关键是定位好相机的位置和朝向就可以。旋转矩阵也有差异,相同的旋转矩阵,因为 z 轴的方向不同,最终的物体效果变化也是不一样的。这就是旋转方向不一致的原因。
单就针对旋转矩阵,在左右手坐标系中,要想旋转效果一样,不仅要定位好旋转轴,旋转角度也要相反(因为 z 轴相反)。可以通过代码清单 4 验证自己的想法。
最后排查到显示不一致的原因:
一是左手坐标系透视投影矩阵实现有一处笔误(之前文章中的推导过程和结果都是对的,是抄写的笔误);
二是对空间变换的理解不足,特别是旋转矩阵这块造成的效果差异。
这个问题,可以加深对空间变换的理解。
之前听到过 OpenGL 是左手坐标系,还是右手坐标系的问题?可以看到两者都可以,主要是你自己要清楚你想得到什么效果,自己心里对变换操作有数。因为无论是左手坐标系,还是右手坐标系,肯定都能达到一样的效果。