在OpenGL中,一切都在3D空间中,但屏幕和窗口是一个2D像素数组,因此OpenGL的大部分工作是将所有3D坐标转换为适合您屏幕的2D像素。将3D坐标转换为2D像素的过程由OpenGL的图形管线管理。图形管线可以分为两个大部分:第一部分将您的3D坐标转换为2D坐标,第二部分将2D坐标转换为实际的彩色像素。在本教程中,我们将简要讨论图形管线,以及我们如何利用它来创建华丽的像素。
图形管线以一组 3D 坐标为输入,并将其转换为屏幕上的彩色 2D 像素。图形管线可以分为几个步骤,每个步骤都需要前一个步骤的输出作为其输入。所有这些步骤都是高度专业化的(它们具有一个特定的功能),并且可以轻松并行执行。由于它们的并行特性,现代图形卡拥有数千个小处理核心,通过在 GPU 上为管线的每个步骤运行小程序,快速处理图形管线中的数据。这些小程序被称为着色器。
这些着色器中的一些可以由开发者配置,这使我们能够编写自己的着色器来替换现有的默认着色器。这使我们能够对管道的特定部分进行更细粒度的控制,并且由于它们在GPU上运行,它们还可以为我们节省宝贵的CPU时间。着色器是用OpenGL着色语言(GLSL)编写的,我们将在下一个教程中深入探讨。
下面您将找到图形管线所有阶段的抽象表示。
背景为蓝色的部分是可编程的,背景为灰色的部分可以通过函数进行轻微自定义。各个阶段如下:
- 顶点着色器:顶点被移动到位置。这是应用模型位置等内容的地方。
- 形状组装。OpenGL通过将顶点分组为三角形来工作;这是发生的阶段。
- 几何着色器:过程的可选阶段。允许您微调形状组装的结果。
- 光栅化:三角形被转换为片段。
- 片段着色器:片段被修改以包含颜色数据等内容。这是应用纹理和光照等内容的地方。
- 测试和混合:片段着色器的结果与场景的其余部分集成。
这可能看起来很多,但一旦设置完成并进入流程后,这非常直观。
一些新功能
我们需要重写几个额外的函数以开始。首先,我们重写 OnLoad。
protected override void OnLoad()
{
base.OnLoad();
GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f);
//代码在这里
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f);
//代码在这里
}
此函数在窗口首次打开时运行一次。任何与初始化相关的代码应放在这里。
在这里我们也得到了第一个 OpenGL 函数调用:GL.ClearColor
。这个函数接受四个浮点数,范围在 0.0f 到 1.0f 之间。它决定了在帧之间窗口被清除后显示的颜色。
接下来,我们有 OnRenderFrame.
protected override void OnRenderFrame(FrameEventArgs e)
{
base.OnRenderFrame(e);
GL.Clear(ClearBufferMask.ColorBufferBit);
//代码在这里。
交换缓冲区();
}
protected override void OnRenderFrame(FrameEventArgs e)
{
base.OnRenderFrame(e);
GL.Clear(ClearBufferMask.ColorBufferBit);
//代码在这里。
Context.SwapBuffers();
}
我们这里有两个调用。首先,GL.Clear
清除屏幕,使用在 OnLoad 中设置的颜色。这应该始终是渲染时调用的第一个函数。
然后,我们有 Context.SwapBuffers
。几乎所有现代的 OpenGL 上下文都被称为 "双缓冲"。双缓冲意味着 OpenGL 绘制到两个区域。实质上:一个区域被显示,而另一个区域正在被渲染。然后,当你调用 SwapBuffers 时,这两个区域会互换。单缓冲上下文可能会出现屏幕撕裂等问题。
接下来,我们有 OnFramebufferResize.
protected override void OnFramebufferResize(FramebufferResizeEventArgs e)
{
base.OnFramebufferResize(e);
GL.Viewport(0, 0, e.Width, e.Height);
}
此函数在每次窗口帧缓冲区大小调整时运行。当发生这种情况时,NDC 到窗口坐标的转换不会自动更新,导致渲染在旧的帧缓冲区大小上进行。通过 GL.Viewport
我们可以更新此转换,以便正确地渲染到整个帧缓冲区。
接下来,我们有 OnResize.
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
GL.Viewport(0, 0, 宽度, 高度);
}
此函数在每次窗口调整大小时运行。当发生这种情况时,NDC 到窗口坐标的转换不会自动更新,导致渲染在旧的帧缓冲区大小上进行。通过 GL.Viewport
我们可以更新此转换,以便正确渲染到整个帧缓冲区。
顶点输入
要开始绘制某些东西,我们首先必须给 OpenGL 一些输入顶点数据。OpenGL 是一个 3D 图形库,因此我们在 OpenGL 中指定的所有坐标都是 3D 的(x、y 和 z 坐标)。OpenGL 并不会简单地将所有 3D 坐标转换为屏幕上的 2D 像素;OpenGL 仅在所有 3 个轴(x、y 和 z)上的特定范围内(-1.0 到 1.0)处理 3D 坐标。所有在这个所谓的标准化设备坐标范围内的坐标最终都会在屏幕上可见(而所有在这个区域之外的坐标则不会)。
因为我们想渲染一个单一的三角形,所以我们需要指定总共三个顶点,每个顶点都有一个 3D 位置。我们在一个浮点数组中以标准化设备坐标(OpenGL 的可见区域)定义它们。将其作为属性放入你的类中:
float[] vertices = {
-0.5f, -0.5f, 0.0f, //左下角顶点
0.5f, -0.5f, 0.0f, //右下角顶点
0.0f, 0.5f, 0.0f //顶部顶点
};
因为 OpenGL 在 3D 空间中工作,我们渲染一个 2D 三角形,每个顶点的 z 坐标为 0.0。这样三角形的深度保持不变,使其看起来像是 2D 的。
标准化设备坐标 (NDC)
一旦您的顶点坐标在顶点着色器中处理完毕,它们应该处于标准化设备坐标中,这是一个小空间,其中 x、y 和 z 值的范围从 -1.0 到 1.0。任何超出此范围的坐标将被丢弃/裁剪,并且在您的屏幕上不可见。下面您可以看到我们在标准化设备坐标中指定的三角形(忽略 z 轴):
与通常的屏幕坐标不同,正 y 轴指向上方,(0,0) 坐标位于图形的中心,而不是左上角。最终,您希望所有 (变换后的) 坐标都位于这个坐标空间中,否则它们将不可见。
您的 NDC 坐标将通过视口变换使用您提供的 GL.Viewport
数据转换为屏幕空间坐标。生成的屏幕空间坐标随后被转换为片段,作为输入传递给您的片段着色器。
缓冲区
定义了顶点数据后,我们希望将其作为输入发送到图形管线的第一个处理过程:顶点着色器。这是通过在 GPU 上创建内存来完成的,我们在其中存储顶点数据,配置 OpenGL 如何解释内存,并指定如何将数据发送到显卡。然后,顶点着色器从其内存中处理我们告诉它的尽可能多的顶点。
我们通过所谓的顶点缓冲对象(VBO)来管理这块内存,这些对象可以在GPU的内存中存储大量的顶点。使用这些缓冲对象的优点是,我们可以一次性将大量数据发送到显卡,而不必逐个发送数据。由于从CPU向显卡发送数据相对较慢,因此我们尽可能地尝试一次性发送尽可能多的数据。一旦数据进入显卡的内存,顶点着色器几乎可以瞬间访问这些顶点,从而使其速度极快。
顶点缓冲对象是我们在OpenGL教程中讨论的OpenGL对象的第一次出现。就像OpenGL中的任何对象一样,这个缓冲区有一个唯一的ID对应于该缓冲区,因此我们可以使用GL.GenBuffers
函数生成一个缓冲区ID。
在你的游戏类中添加一个整数以存储句柄:
int 顶点缓冲对象;
然后,在 OnLoad
函数中,放入这一行:
顶点缓冲对象 = GL.GenBuffer();
OpenGL 有许多类型的缓冲对象,顶点缓冲对象的缓冲类型是 BufferTarget.ArrayBuffer
。OpenGL 允许我们同时绑定多个缓冲,只要它们具有不同的缓冲类型。我们可以使用 GL.BindBuffer
函数将新创建的缓冲绑定到 BufferTarget.ArrayBuffer
目标上:
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
从那时起,我们所做的任何缓冲区调用(在 BufferTarget.ArrayBuffer
目标上)将用于配置当前绑定的缓冲区,即 VertexBufferObject。然后我们可以调用 GL.BufferData
函数,将之前定义的顶点数据复制到缓冲区的内存中:
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);
GL.BufferData
是一个专门用于将用户定义的数据复制到当前绑定缓冲区的函数。它的第一个参数是我们想要复制数据到的缓冲区的类型:当前绑定到 BufferTarget.ArrayBuffer
目标的顶点缓冲区对象。第二个参数指定我们想要传递给缓冲区的数据大小(以字节为单位);数据类型的简单 sizeof 乘以顶点的长度即可。第三个参数是我们想要发送的实际数据。
第四个参数是一个 BufferUsageHint,它指定了我们希望显卡如何管理给定的数据。这可以有 3 种形式:
静态绘制:数据很可能根本不会改变或很少改变。
动态绘制:数据很可能会频繁改变。
流式绘制:数据每次绘制时都会改变。