游戏引擎开发-图形篇(二)——光栅化

在完成了框架的搭建后,在今天的开发过程中,将会集中完成渲染管线的主要部分

目前的成果展示

原理讲解

图形渲染管线是实时渲染的核心组件。渲染管线的功能是通过给定虚拟相机、3D场景物体 以及光源等场景要素来产生或者渲染一副2D的图像。场景中的3D物体通过管线转变为屏幕上的2D图像。 图形渲染管线主要包括两个功能:一是将物体3D坐标转变为屏幕空间2D坐标,二是为屏幕 每个像素点进行着色。渲染管线的一般流程如下图所示。分别是:顶点数据的输入、顶点着色器、曲面细分过程、几何着色器、图元组装、裁剪剔除、光栅化、片段着色器以及混合测试。

顶点缓存(Vertex Buffer)

要渲染图形首先需要向OpenGL提供顶点数据,为了节省数据的传输效率,OpenGL直接使用显存保存数据,而我们需要通过OpenGL对象来管理这些数据,顶点缓存便是其中之一。
注册并绑定VBO对象:

1
2
3
4
unsigned int VBO; 
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

至此,顶点数据便被保存在了显存中等待读取。

顶点数组(Vertex Array)

OpenGL的核心模式要求使用VAO,如果不绑定VAO对象,OpenGL会拒绝绘制东西。

顶点数组顾名思义,是用来存储顶点属性配置和应使用的VBO的顶点数组对象的OpenGL对象,可以大大节省我们切换配置所费的精力。

注册方式同顶点缓存,需要注意的是参数中的pointer的索引以及步长,之后在设计数据结构的时候需要设计相应的方法。

1
2
3
4
unsigned int VAO; 
glGenVertexArrays(1, &VAO);
//绑定VBO
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);

切记一定要先绑定VAO再绑定VBO,VAO不会记录绑定之前的状态。

顶点着色(Vertex Shader)

顶点数据被送至显卡后首先会进行坐标映射将输入的局部坐标变换到世界坐标、 观察坐标和裁剪坐标。

虽然顶点着色器也可以进行光照计算,但是经过光栅化插值得到的光照比较不自然,所以一般在片段着色器才会进行光照计算。

要使用着色器需要独特的着色器语言,OpenGL支持的是GLSL,这是一种类似于C语言的着色器语言。

in关键字,可以在顶点着色器中声明所有的输入顶点属性,之后使用out输出运算结果。(这点很重要)

由于顶点着色一般只涉及坐标映射,这里暂且不作展示。

片段着色(Fragment Shader)

片段着色器也是渲染管线一个可编程的阶段。我们知道,顶点着色器的输入是单个顶点(以及属性), 输出的是经过变换后的顶点。与顶点着色器不同,几何着色器的输入是完整的图元(比如,点),输出可以是一个或多个其他的图元(比如,三角面),或者不输出任何的图元。几何着色器的拿手好戏就是将输入的点或线扩展成多边形。

这里将顶点着色与片段着色放在一个文件中方便程序读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#shader vertex
#version 330 core

layout (location = 0) in vec3 aPos;

void main()
{
   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

#shader fragment
#version 330 core

out vec4 FragColor;

void main()
{
   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

可以看到这段着色器代码以顶点数据作为输入,在顶点着色器中映射为(1,1,1)的比例(也就是直接输出),然后片段着色器将每个像素着色为rgba(255,126,51,1)(大致)后输出,最终结果就是将物体染成橘色,这属于着色器的最简单应用。

如何注册着色器对象(以片段着色为例):

1
2
3
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL); glCompileShader(fragmentShader);

注册完着色器后要将他们依附在着色器程序中才能使用:

1
2
3
4
5
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

索引缓存(Index Buffer)

为了节省重复顶点所带来的额外开销,OpenGL可以使用索引缓冲储存不同的顶点,并设定绘制这些顶点的顺序。

构造方式与顶点缓冲类似,无需多言:

1
2
3
4
unsigned int EBO; 
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

项目优化

到目前为止,与呈现的效果相比,项目的代码量已经过于庞大了,为了之后的开发,有必要精简目前的项目。

目前所有的代码都是存储在单个文件中,显而易见的,我们可以设计数据结构来管理这些OpenGL对象。其中,顶点数组依赖与顶点缓存与索引缓存,所以在构造Vertex Array类的时候有必要将VBO与IBO作为对象进行传递。

因为OpenGL对象基本上是一次只能绑定一个的,所以不希望这些类在内存中发生复制,于是使用unique_ptr进行类的实例化。

最后,设计一个类用来存储顶点的类型与偏移值。

篇幅原因,这里不展示源代码。

踩过的坑

这回做的比上次轻松很多,一口气进度推了太多博客写起来都有些费劲了,这次遇到的主要困难是有关成员函数中无法进行模板函数的特化的问题。
C++标准中规定,嵌套类模板在类的定义中不允许被显示特化声明,只允许偏特化,因此不能在类中直接实现模板函数的特化。
正确写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VertexBufferLayout.h
class VertexBufferLayout
{

    private:
        std::vector<VertexBufferElement> m_Elements;
        unsigned int m_Stride;

    public:
        VertexBufferLayout():m_Stride(0){};

        template<typename T> void Push(unsigned int count);


        inline const std::vector<VertexBufferElement> GetElements() const{return m_Elements;}
        inline const unsigned int GetStride()const {return m_Stride;}        
};
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
VertexBufferLayout.cpp
template<typename T>
void VertexBufferLayout::Push(unsigned int count)
{
   static_assert(false);
}



template<>
void VertexBufferLayout::Push<float>(unsigned int count)
{
    m_Elements.push_back({GL_FLOAT,count,false});
    m_Stride+=count*VertexBufferElement::GetSizeOfType(GL_FLOAT);
}

template<>
void VertexBufferLayout::Push<unsigned int>(unsigned int count)
{
    m_Elements.push_back({GL_UNSIGNED_INT,count,false});
    m_Stride+=count*VertexBufferElement::GetSizeOfType(GL_UNSIGNED_INT);
}

template<>
void VertexBufferLayout::Push<unsigned char>(unsigned int count)
{
    m_Elements.push_back({GL_UNSIGNED_BYTE,count,true});
    m_Stride+=count*VertexBufferElement::GetSizeOfType(GL_UNSIGNED_BYTE);
}

之后要试着进行3D模型的渲染了,可以的话还想就着色器的话题再多扩展几篇博客(三言两语无法概括啊),目前的部分虽然很教科书,但是也最有讲头。

之后进入引擎架构篇后恐怕就不怎么会继续展开话题了,让我们尽可能地多享受图形的魅力吧。