OpenTK中文教程——1.2. 你好 三角形

522 阅读24分钟

你好 三角形

图形管道

在OpenGL中,一切都是3D空间的,但屏幕和窗口是像素的2D数组,因此OpenGL的大部分工作都是关于将所有3D坐标转换为适合屏幕显示的2D像素。3D坐标转换为2D像素的过程由OpenGL的图形流水线管理。图形流水线可以分为两个主要部分:第一部分将3D坐标转换为2D坐标,第二部分将2D坐标转换为实际的彩色像素。在本教程中,我们将简要讨论图形流水线以及如何利用它来创建精美的像素。

图形管道以一组3D坐标作为输入,并将其转换为屏幕上的彩色2D像素。图形管道可以分为几个步骤,每个步骤都需要前一步骤的输出作为其输入。所有这些步骤都高度专业化(它们具有一个特定的功能),并且可以很容易地并行执行。由于它们的并行性质,当今的显卡拥有数千个小型处理核心,可以通过在GPU上为管道的每个步骤运行小程序来快速处理您的数据。这些小程序被称为着色器。

这些着色器中的一些可以由开发人员配置,这使我们能够编写自己的着色器来替换现有的默认着色器。这为我们提供了对管道特定部分更精细的控制,并且由于它们在GPU上运行,还可以为我们节省宝贵的CPU时间。着色器是用OpenGL着色语言(GLSL)编写的,我们将在下一个教程中更深入地探讨这一点。

以下是图形管道所有阶段的抽象表示。

image.png

【万恶的水印】 Vertex data[] -> Vertex shader -> Shape assembly -> Gemometry shader -> Rasterization -> Fragment shader -> Tests and blending

具有蓝色背景的部分是可编程的,而具有灰色背景的部分可以使用功能进行轻微定制。阶段如下:

  1. 顶点着色器:顶点被移动到指定位置。这是应用模型位置等操作的地方。
  2. 形状组装。OpenGL通过将顶点组合成三角形来工作;这就是这个阶段发生的事情。
  3. 几何着色器:过程的一个可选阶段。允许您微调来自形状组装的结果。
  4. 光栅化:三角形被转换为片段。
  5. 片段着色器:片段被修改以包含颜色数据等信息。这是应用纹理和光照等效果的地方。
  6. 测试和混合:片段着色器的结果与场景的其余部分集成。

这听起来可能很多,但一旦设置完成并进入流程,就相当直观了。

一些新功能

我们需要覆盖一些额外的函数以开始。首先,我们覆盖OnLoad。

// OpenTK 4
protected override void OnLoad()
{
    base.OnLoad();

    GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f);

    //Code goes here
}

// OpenTK 3
protected override void OnLoad(EventArgs e)
{
    base.OnLoad(e);

    GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f);

    //Code goes here
}

这个函数在窗口首次打开时运行一次。任何与初始化相关的代码都应放在这里。

这里也是我们第一次调用OpenGL函数:GL.ClearColor。这个函数接受四个介于0.0f和1.0f之间的浮点数。这决定了窗口在每帧之间清除后的颜色。

接下来,我们有OnRenderFrame。

// OpenTK 4
protected override void OnRenderFrame(FrameEventArgs e)
{
    base.OnRenderFrame(e);

    GL.Clear(ClearBufferMask.ColorBufferBit);

    //Code goes here.

    SwapBuffers();
}

// OpenTK 3
protected override void OnRenderFrame(FrameEventArgs e)
{
    base.OnRenderFrame(e);

    GL.Clear(ClearBufferMask.ColorBufferBit);

    //Code goes here.

    Context.SwapBuffers();
}

我们这里有两个调用。首先,GL.Clear 清除屏幕,使用在 OnLoad 中设置的颜色。这应该是渲染时调用的第一个函数。

然后,我们有 Context.SwapBuffers。几乎任何现代 OpenGL 上下文都是所谓的“双缓冲”。双缓冲意味着 OpenGL 有两个绘制区域。本质上:一个区域正在显示,而另一个区域正在被渲染。然后,当你调用 SwapBuffers 时,这两个区域会互换。单缓冲上下文可能会出现屏幕撕裂等问题。

对于OpenTK 4,接下来,我们有OnFramebufferResize。这个函数每次窗口帧缓冲区大小改变时运行。当这种情况发生时,NDC 到窗口坐标的转换不会自动更新,导致渲染仍按照旧的帧缓冲区大小进行。通过使用 GL.Viewport,我们可以更新此转换,以便正确地渲染到整个帧缓冲区。

对于OpenTK 3,接下来,我们有OnResize。这个函数每次窗口调整大小时都会运行。当这种情况发生时,NDC到窗口坐标转换不会自动更新,导致渲染以旧的帧缓冲区大小进行。通过GL.Viewport,我们可以更新此转换,以便正确地渲染到整个帧缓冲区。

// OpenTK 4
protected override void OnFramebufferResize(FramebufferResizeEventArgs e)
{
    base.OnFramebufferResize(e);

    GL.Viewport(0, 0, e.Width, e.Height);
}

// OpenTK 3
protected override void OnResize(EventArgs e)
{
    base.OnResize(e);

    GL.Viewport(0, 0, Width, Height);
}

顶点输入

要开始绘制某些东西,我们首先必须给OpenGL提供一些输入顶点数据。OpenGL是一个3D图形库,因此我们在OpenGL中指定的所有坐标都是3D坐标(x、y和z坐标)。OpenGL并不会简单地将所有3D坐标转换为屏幕上的2D像素;OpenGL仅在3D坐标处于特定范围内时处理它们,这个范围在所有三个轴(x、y和z)上都是-1.0到1.0。所有在这个所谓的标准化设备坐标范围内的坐标最终都会显示在屏幕上(而在此区域之外的所有坐标则不会)。

因为我们想要渲染一个单独的三角形,所以我们希望指定总共三个顶点,每个顶点都有一个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轴):

image.png

与通常的屏幕坐标不同,正y轴指向上方,(0,0)坐标位于图形的中心,而不是左上角。最终,您希望所有(转换后的)坐标都出现在这个坐标空间中,否则它们将不可见。

你的NDC坐标将通过使用你提供的GL.Viewport数据进行视口变换,转换为屏幕空间坐标。生成的屏幕空间坐标然后被转换为片段,作为你的片段着色器的输入。

缓冲区

定义了顶点数据后,我们希望将其作为输入发送到图形管道的第一个处理阶段:顶点着色器。这是通过在GPU上创建内存来完成的,在这里我们存储顶点数据,配置OpenGL如何解释内存,并指定如何将数据发送到显卡。然后,顶点着色器会根据我们告知的数量从其内存中处理尽可能多的顶点。

我们通过所谓的顶点缓冲区对象(VBO)来管理这些内存,这些对象可以存储大量顶点在GPU的内存中。使用这些缓冲区对象的优点是我们可以一次性将大量数据发送到显卡,而无需一次发送一个顶点。从CPU向显卡发送数据相对较慢,因此,只要有可能,我们就尽量一次性发送尽可能多的数据。一旦数据进入显卡的内存,顶点着色器几乎可以立即访问顶点,这使得它极其快速。

顶点缓冲区对象是我们第一次遇到的OpenGL对象,正如我们在OpenGL教程中所讨论的那样。就像OpenGL中的任何对象一样,这个缓冲区有一个唯一的ID与之对应,因此我们可以使用GL.GenBuffers函数生成一个带有缓冲区ID的对象。

在您的 Game 类中添加一个 int 来存储句柄:

int VertexBufferObject;

然后,在 OnLoad 函数中,加入这一行:

VertexBufferObject = 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,它指定了我们希望显卡如何管理给定的数据。这可以有三种形式:

  • StaticDraw:数据很可能根本不会改变或极少改变。
  • DynamicDraw: 数据可能会频繁改变。
  • StreamDraw: 数据每次绘制时都会改变。

三角形的位置数据不会改变,并且在每次渲染调用时都保持不变,因此其使用类型最好为StaticDraw。例如,如果有一个缓冲区,其中的数据可能会频繁更改,则使用DynamicDraw或StreamDraw类型可以确保显卡将数据放置在允许更快写入的内存中。

注意:程序结束后,进程使用的所有资源都会被释放。这意味着在关闭程序之前不需要删除缓冲区。但是,如果您出于其他原因(例如限制显存使用)想要删除缓冲区,可以执行以下操作:

GL.BindBuffer(BufferTarget.ArrayBuffer, 0);
GL.DeleteBuffer(VertexBufferObject);

将缓冲区绑定到0基本上是将其设置为null,因此在未先绑定缓冲区的情况下对其进行修改的任何调用都会导致崩溃。这比意外修改我们不希望被修改的缓冲区更容易调试。

目前,我们将顶点数据存储在图形卡内存中,由名为VBO的顶点缓冲对象管理。接下来,我们希望创建一个顶点着色器和片段着色器来实际处理这些数据,所以让我们开始构建这些着色器。

着色器

现在我们有了数据,是时候创建我们的管道了。我们通过创建一个顶点着色器和一个片段着色器来实现这一点。

顶点着色器是可编程的着色器之一。现代OpenGL要求我们至少设置一个顶点着色器和片段着色器,如果我们想进行一些渲染的话,因此我们将简要介绍着色器,并配置两个非常简单的着色器来绘制我们的第一个三角形。在下一个教程中,我们将更详细地讨论着色器。

我们首先需要用着色器语言GLSL(OpenGL着色语言)编写顶点着色器,然后编译这个着色器以便在我们的应用程序中使用。下面是你将找到的一个非常基础的顶点着色器的源代码:

#version 330 core
layout (location = 0) in vec3 aPosition;

void main()
{
    gl_Position = vec4(aPosition, 1.0);
}

将此保存为 shader.vert。

如你所见,GLSL 看起来与 C 类似。每个着色器都以声明其版本开始。自 OpenGL 3.3 及更高版本起,GLSL 的版本号与 OpenGL 的版本号相匹配(例如,GLSL 版本 420 对应于 OpenGL 版本 4.2)。我们还明确提到我们正在使用核心配置文件功能。

接下来我们在顶点着色器中使用 in 关键字声明所有的输入顶点属性。目前我们只关心位置数据,所以我们只需要一个顶点属性。GLSL 有一个向量数据类型,包含 1 到 4 个基于其后缀数字的浮点数。由于每个顶点都有一个 3D 坐标,我们创建了一个名为 aPosition 的 vec3 输入变量。我们还特别通过 layout (location = 0) 设置了输入变量的位置,稍后你会看到为什么我们需要这个位置。

每个着色器的入口点是 void main() 函数。这是你可以进行任何所需处理的地方。然而,在这里,我们只是将值赋给 gl_Position,这是顶点着色器中的一个内置变量,表示该顶点的最终位置。但是,gl_Position 是一个 vec4,而我们的输入顶点是 vec3。为了实现这一点,我们使用 vec4 函数来使向量足够长。

当前的顶点着色器可能是我们可以想象的最简单的顶点着色器,因为我们对输入数据完全没有进行任何处理,只是简单地将其转发到着色器的输出。在实际应用中,输入数据通常不是已经处于归一化设备坐标中,所以我们首先必须将输入数据转换为落在OpenGL可见区域内的坐标。

片段着色器是我们为了渲染三角形而要创建的第二个也是最后一个着色器。片段着色器主要是计算像素的颜色输出。为了保持简单,片段着色器将始终输出一种橙色。

在计算机图形中,颜色表示为包含4个值的向量:红色、绿色、蓝色和透明度(alpha)分量,通常简写为RGBA。在OpenGL或GLSL中定义颜色时,我们将每个分量的强度设置为0.0到1.0之间的值。例如,如果我们把红色设置为1.0f,绿色也设置为1.0f,那么这两种颜色的混合会得到黄色。通过这3个颜色分量,我们可以生成超过1600万种不同的颜色!

#version 330 core
out vec4 FragColor;

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

将其保存为 shader.frag。

片段着色器只需要一个输出变量,那就是一个大小为4的向量,用于定义我们自己计算的最终颜色输出。我们可以使用out关键字声明输出值,在这里我们将其命名为FragColor。接下来,我们简单地将一个vec4赋值给颜色输出,作为带有1.0 alpha值(1.0表示完全不透明)的橙色。

编译着色器

我们已经有了着色器源代码,但现在我们需要编译这些着色器。这是在运行时完成的;预先编译着色器并将其与程序打包是不可能的,因为编译后的着色器依赖于许多因素,如显卡型号、制造商和驱动程序。相反,我们在程序开始时包含着色器源代码并进行编译。

我们将通过创建一个着色器类来实现这一点,该类编译着色器并封装了我们将在后面看到的几个函数。

public class Shader
{
    int Handle;

    public Shader(string vertexPath, string fragmentPath)
    {

    }
}

句柄将表示我们的最终着色器程序在编译完成后的位置。我们将在构造函数中完成所有初始化。

首先,在构造函数中定义两个 int:VertexShader 和 FragmentShader。这些是单个着色器的句柄。它们在构造函数中定义是因为在完整的着色器程序完成后,我们不再需要单个着色器。

接下来,我们需要从各个着色器文件中加载源代码。我们这样做:

string VertexShaderSource = File.ReadAllText(vertexPath);
string FragmentShaderSource = File.ReadAllText(fragmentPath);

然后,我们生成着色器,并将源代码绑定到着色器。

VertexShader = GL.CreateShader(ShaderType.VertexShader);
GL.ShaderSource(VertexShader, VertexShaderSource);

FragmentShader = GL.CreateShader(ShaderType.FragmentShader);
GL.ShaderSource(FragmentShader, FragmentShaderSource);

然后,我们编译着色器并检查错误。

GL.CompileShader(VertexShader);

GL.GetShader(VertexShader, ShaderParameter.CompileStatus, out int success);
if (success == 0)
{
    string infoLog = GL.GetShaderInfoLog(VertexShader);
    Console.WriteLine(infoLog);
}

GL.CompileShader(FragmentShader);

GL.GetShader(FragmentShader, ShaderParameter.CompileStatus, out int success);
if (success == 0)
{
    string infoLog = GL.GetShaderInfoLog(FragmentShader);
    Console.WriteLine(infoLog);
}

如果在编译时出现任何错误,可以使用函数 GL.GetShaderInfoLog 获取调试字符串。假设没有问题,我们可以继续进行链接。

我们的单个着色器已经编译完成,但要实际使用它们,我们必须将它们链接在一起,形成一个可以在GPU上运行的程序。从现在起,当我们谈到“着色器”时,指的就是这个意思。我们这样做:

Handle = GL.CreateProgram();

GL.AttachShader(Handle, VertexShader);
GL.AttachShader(Handle, FragmentShader);

GL.LinkProgram(Handle);

GL.GetProgram(Handle, GetProgramParameterName.LinkStatus, out int success);
if (success == 0)
{
    string infoLog = GL.GetProgramInfoLog(Handle);
    Console.WriteLine(infoLog);
}

这就完成了!Handle 现在是一个可用的着色器程序。

在我们离开构造函数之前,应该做一些清理工作。现在顶点和片段着色器已经链接,它们单独存在已经没有意义;当你链接着色器时,编译后的数据会被复制到着色器程序中。你也不需要让这些单独的着色器附加到程序上;让我们先分离然后删除它们。

GL.DetachShader(Handle, VertexShader);
GL.DetachShader(Handle, FragmentShader);
GL.DeleteShader(FragmentShader);
GL.DeleteShader(VertexShader);

我们现在有一个有效的着色器了,所以让我们添加一种使用它的方式。在 Shader 类中添加这个函数:

public void Use()
{
    GL.UseProgram(Handle);
}

最后,我们需要在该类销毁后清理句柄。由于面向对象语言的问题,我们不能在终结器(finalizer)中执行此操作。相反,我们必须从IDisposable派生,并记住手动调用Dispose来释放我们的着色器。在你的代码其余部分下方,添加以下内容:

private bool disposedValue = false;

protected virtual void Dispose(bool disposing)
{
    if (!disposedValue)
    {
        GL.DeleteProgram(Handle);

        disposedValue = true;
    }
}

~Shader()
{
    if (disposedValue == false)
    {
        Console.WriteLine("GPU Resource leak! Did you forget to call Dispose()?");
    }
}


public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

恭喜!我们现在有一个功能完善的着色器类了。

回到你的 Game 类中,添加一个新属性,Shader shader;。然后,在 OnLoad 中添加一行代码 shader = new Shader("shader.vert", "shader.frag");。接着,前往 OnUnload,并添加一行代码 shader.Dispose();

尝试运行;如果控制台没有任何输出,说明你的着色器已成功编译!

链接顶点属性

顶点着色器允许我们以顶点属性的形式指定任何输入。虽然这提供了极大的灵活性,但也意味着我们必须手动指定输入数据的哪一部分对应于顶点着色器中的哪个顶点属性。这意味着在渲染之前,我们必须指定OpenGL应该如何解释顶点数据。

这种格式的信息存储在一个称为顶点数组对象(VAO)的结构中。VAO 包含有关顶点格式和从哪些缓冲区读取的信息。我们稍后会详细讨论这一点,但为了开始,我们创建一个 VAO 并像以下这样绑定它:

int VertexArrayObject = GL.GenVertexArray();
GL.BindVertexArray(VertexArrayObject);

VAO 创建并绑定后,我们可以开始指定顶点格式和数据缓冲区。为此,我们首先需要查看顶点缓冲区的格式。

我们的顶点缓冲区数据格式如下:

image.png

  • 位置数据存储为32位(4字节)浮点值。
  • 每个位置由这3个值组成。
  • 每组3个值之间没有空格(或其他值)。这些值在数组中紧密排列。
  • 数据中的第一个值位于缓冲区的开头。

有了这些知识,我们可以告诉OpenGL如何解释顶点数据(每个顶点属性)使用GL.VertexAttribPointer:

GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);

函数 GL.VertexAttribPointer 有几个参数,所以我们仔细地逐一介绍:

  • 第一个参数指定了我们想要配置的顶点属性。记得我们在顶点着色器中用 layout (location = 0) 指定了位置顶点属性的位置。这将顶点属性的位置设置为 0,由于我们希望向此顶点属性传递数据,所以我们传入 0。
  • 下一个参数指定顶点属性的大小。顶点属性是一个vec3,因此它由3个值组成。
  • 第三个参数指定了数据的类型,这里是浮点数(GLSL 中的 vec* 由浮点数值组成)。
  • 下一个参数指定了我们是否希望数据被归一化。如果我们输入的是整数数据类型(int, byte),并且我们将此参数设置为true,那么整数数据在转换为浮点数时会被归一化到0(对于有符号数据为-1)和1。这对我们来说不重要,所以我们将其保留为false。
  • 第五个参数被称为步长,它告诉我们连续顶点属性之间的间隔。由于下一组位置数据正好位于距离当前数据3个浮点数大小的位置,我们指定该值为步长。请注意,由于我们知道数组是紧密打包(packed)的(下一个顶点属性值之间没有间隔),我们也可以将步长指定为0,让OpenGL确定步长(这仅在值紧密打包时有效)。每当我们有更多的顶点属性时,我们必须仔细定义每个顶点属性之间的间距,但稍后我们会看到更多这样的例子。
  • 最后一个参数是位置数据在缓冲区中开始的偏移量。由于位置数据位于数据数组的开头,因此该值仅为0。我们将在稍后更详细地探讨此参数。

每个顶点属性从由VBO管理的内存中获取数据,它从哪个VBO获取数据(你可以有多个VBO)取决于在调用GL.VertexAttribPointer时当前绑定到ArrayBuffer的VBO。由于在调用glVertexAttribPointer之前先前定义的VBO仍然绑定,因此顶点属性0现在与其顶点数据相关联。

现在我们已经指定了OpenGL应该如何解释顶点数据,我们也应该通过调用GL.EnableVertexAttribArray并以顶点属性位置作为参数来启用顶点属性;顶点属性默认是禁用的。

从那时起,我们已经设置好了一切:使用顶点缓冲区对象初始化了顶点数据,设置了顶点着色器和片段着色器,并告诉OpenGL如何将顶点数据链接到顶点着色器的顶点属性。现在,在OpenGL中绘制一个对象看起来像这样:

GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);

GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);

shader.Use()
// 3. 现在画这个物体
someOpenGLFunctionThatDrawsOurTriangle();

我们每次想要绘制一个对象时都必须重复这个过程。这看起来可能没什么大不了的,但想象一下如果我们有五个或更多的顶点属性,以及可能有数百个不同的对象(这并不少见)。为这些对象中的每一个绑定适当的缓冲区对象并配置所有顶点属性很快就会变得繁琐。如果有一种方法可以将所有这些状态配置存储到一个对象中,并简单地绑定该对象以恢复其状态,那该多好啊?

顶点数组对象

顶点数组对象(也称为VAO)可以像顶点缓冲区对象一样绑定,从那时起的所有后续顶点属性调用都将存储在VAO中。这有一个优点,即在配置顶点属性指针时,您只需调用一次,每当我们要绘制对象时,只需绑定相应的VAO即可。这使得在不同的顶点数据和属性配置之间切换就像绑定一个不同的VAO一样简单。我们刚刚设置的所有状态都存储在VAO中。

核心OpenGL要求我们使用VAO,以便它知道如何处理我们的顶点输入。如果我们没有绑定VAO,OpenGL很可能会拒绝绘制任何内容。

顶点数组对象存储以下内容:

  • 调用 GL.EnableVertexAttribArray 或 GL.DisableVertexAttribArray。
  • 通过 GL.VertexAttribPointer 配置顶点属性。
  • 通过调用 GL.VertexAttribPointer 与顶点属性关联的顶点缓冲区对象。

image.png

生成VAO的过程看起来与VBO的生成过程相似。作为属性,添加

int VertexArrayObject;

然后,在 OnLoad 中,在调用 GL.BindVertexArray 之前添加:

VertexArrayObject = GL.GenVertexArray();

要使用VAO,你所需要做的就是使用glBindVertexArray绑定VAO。从那时起,我们应该绑定/配置相应的VBO和属性指针,然后解绑VAO以备后用。当我们想要绘制一个对象时,只需在绘制对象之前,使用首选设置绑定VAO即可。在代码中,这看起来有点像这样:

// ..:: 初始化代码(仅需执行一次(除非您的对象经常变化)) :: ..
// 1. 绑定顶点数组对象
GL.BindVertexArray(VertexArrayObject);
// 2. 将我们的顶点数组复制到一个缓冲区中,供OpenGL使用
GL.BindBuffer(BufferTarget.ArrayBuffer, VertexBufferObject);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);
// 3. 然后设置我们的顶点属性指针
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);

然后,要实际绘制对象,您需要在渲染循环中放入以下内容:

GL.UseProgram();
GL.BindVertexArray(VertexArrayObject);
someOpenGLFunctionThatDrawsOurTriangle();

这就完成了!我们过去几百万页所做的工作都为了这一刻,一个存储了顶点属性配置和要使用的VBO的VAO。通常当你有多个要绘制的对象时,你首先生成/配置所有的VAO(因此所需的VBO和属性指针),并存储这些以备后用。当我们想要绘制我们的对象之一时,我们取出相应的VAO,绑定它,然后绘制对象,再次解绑VAO。

为了绘制我们选择的对象,OpenGL 提供了 GL.DrawArrays 函数,该函数使用当前激活的着色器、先前定义的顶点属性配置以及 VBO 的顶点数据(通过 VAO 间接绑定)来绘制图元。

shader.Use();
GL.BindVertexArray(VertexArrayObject);
GL.DrawArrays(PrimitiveType.Triangles, 0, 3);

GL.DrawArrays 函数以其第一个参数接受我们希望绘制的 OpenGL 原语类型。由于我在开始时说过我们想画一个三角形,并且我不喜欢对你撒谎,所以我们传入 PrimitiveType.Triangles。第二个参数指定我们希望绘制的顶点数组的起始索引;由于我们想绘制所有的顶点,我们只需将其保留为 0。最后一个参数指定我们希望绘制多少个顶点,这里是 3(我们仅从数据中渲染 1 个三角形,其恰好由 3 个顶点组成)。

现在尝试编译代码,如果出现任何错误,请从后向前排查。一旦您的应用程序编译成功,您应该看到以下结果:

image.png

完整的程序源代码可以在这里找到。

如果您的输出看起来不一样,那可能是您在某个地方出了错,请检查完整的源代码,看看是否遗漏了什么或在我们的官方Discord服务器上询问。

补充:动态获取着色器布局

在这个例子中,当我们调用 GL.VertexAttribPointer 时,我们为变量的位置使用了硬编码的布局 0。这只有在我们的着色器输入变量 shader.vert 显式设置布局为 0 时才有效。但如果你不想这样做呢?如果你愿意,你可以在运行时检索位置。

如果你想这样做,请将以下函数添加到你的 Shader 类中。

public int GetAttribLocation(string attribName)
{
    return GL.GetAttribLocation(Handle, attribName);
}

然后,在设置 VAO 时,你可以使用 shader.GetAttribLocation("aPosition") 而不是 0。如果你这样做,就不必在着色器中包含 layout(location=0) 这一行了。

本教程将继续使用硬编码值,但了解如何以这两种方式操作很重要。