图形管道
在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(EventArgs e) { GL.ClearColor(0.2f, 0.3f, 0.3f, 1.0f); base.OnLoad(e); }
当窗口首次打开时,此函数将运行一次。任何初始化相关的代码都应转到此处。
同时在这里,我们得到我们OpenGL
调用的第一个函数:GL.ClearColor
。这需要四个浮点,范围在0.0f
和1.0f
之间。这将决定在窗口在帧之间清除后的颜色。
之后,我们需要重写OnRenderFrame
。
protected override void OnRenderFrame(FrameEventArgs e) { GL.Clear(ClearBufferMask.ColorBufferBit); Context.SwapBuffers(); base.OnRenderFrame(e); }
我们这里有两个调用。首先,GL.Clear
使用OnLoad
中设置的颜色清除屏幕。这应始终是呈现时调用的第一个函数。
之后我们使用Context.SwapBuffers
。几乎任何现代OpenGL
上下文都是所谓的"双缓冲"。双缓冲意味着OpenGL
绘制到的两个领域。本质上:显示一个区域,而另一个区域用来展示。然后,当您调用交换缓冲区时,两者将反转。单缓冲上下文可能会有屏幕卡顿等问题。
现在我们重写OnResize
protected override void OnResize(EventArgs e) { GL.Viewport(0, 0, Width, Height); base.OnResize(e); }
每次调整窗口大小时,都会运行此功能。GL.Viewport
将NDC
映射到窗口。OnResize
不是非常重要,除了我们已经写入的,这里后期将不会添加任何代码。
顶点输入
要开始绘制某些数据,我们必须首先给OpenGL
一些输入顶点数据。OpenGL
是一个3D
图形库,因此我们在OpenGL
中指定的所有坐标都位于3D
(x
、y
和z
坐标中)。OpenGL
不会简单地将所有3D
坐标转换为屏幕上的2D
像素;当 3 个轴(x
、y
和z
)上的3D
坐标在 -1.0 和 1.0 之间的特定范围内时,OpenGL
才处理它们。此所谓的规范化设备坐标范围内的所有坐标最终都将在屏幕上可见(并且该区域外的所有坐标不会)。
因为我们想要渲染一个三角形,所以我们要指定三个顶点,每个顶点都有一个3D
位置。我们在浮动数组中的规范化设备坐标(OpenGL
的可见区域)中定义它们。在类中作为属性来表示:
private float[] vertices = { -0.5f, -0.5f, 0.0f, // Bottom-left vertex 0.5f, -0.5f, 0.0f, // Bottom-right vertex 0.0f, 0.5f, 0.0f // Top vertex };
由于OpenGL
在3D
空间中工作,因此我们渲染一个2D
三角形,每个顶点具有0.0
的z
坐标。这样,三角形的深度保持不变,使其看起来像是2D
。
规范化设备坐标(NDC)
在顶点着色器中处理顶点坐标后,它们应位于规范化设备坐标中,这是 x、y 和 z 值从 -1.0 到 1.0 变化的一个小空间。超出此范围的任何坐标都将被丢弃/剪切,并且在屏幕上不可见。下面你可以看到我们在规范化设备坐标中指定的三角形(忽略 z 轴):
与通常的屏幕不同,屏幕坐标是向上方向的正y
轴点,而(0,0)
坐标位于图形的中心,而不是左上角。最终,您希望所有(已转换的)坐标最终到达此坐标空间中,否则它们将不可见。
然后,使用GL.Viewport
提供的数据,通过视口变换将NDC
坐标转换为屏幕空间坐标。然后,生成的屏幕空间坐标将转换为片段,作为片段着色器的输入。
缓冲区
定义顶点数据后,我们希望将其作为输入发送到图形管道的第一个过程:顶点着色器。这是通过创建GPU
上的内存来完成的,我们在其中存储顶点数据,配置OpenGL
应如何解释内存,并指定如何将数据发送到图形卡。然后,顶点着色器或通过我们告诉它的信息,从而在它的内存中处理尽可能多的顶点。
我们通过所谓的顶点缓冲对象(VBO
)管理此内存,该对象可以在GPU
的内存中存储大量顶点。使用这些缓冲对象的优点是,我们可以一次向图形卡发送大量数据,而无需一次发送顶点数据。从CPU
将数据发送到显卡相对缓慢,因此,只要我们可能,我们尝试一次发送尽可能多的数据。一旦数据进入显卡的内存,顶点着色器几乎可以即时访问顶点,使其非常快。
顶点缓冲区对象是我们第一次出现OpenGL
对象,正如我们在OpenGL
教程中讨论过的。就像OpenGL
中的任何对象一样,此缓冲区具有与该缓冲区对应的唯一ID
,因此我们可以使用GL.GenBuffers
函数生成具有缓冲区ID
的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
区目标的顶点缓冲区对象。第二个参数指定要传递给缓冲区的数据的大小(以字节为单位);数据类型的简单大小,乘以顶点的长度,就足够了。第三个参数是我们想要发送的实际数据。
第四个参数是BufferUsageHint
,它指定我们希望图形卡如何管理给定的数据。这有 3 种形式:
StaticDraw: 数据很可能不改变或者改变的很少.
DynamicDraw: 数据可能会改变很多.
StreamDraw: 每次绘制数据时都会更改
三角形的位置数据不会更改,并且对于每个渲染调用都保持不变,因此其使用类型最好为StaticDraw
。如果有一个缓冲区,其数据可能会频繁更改,则DynamicDraw
或StreamDraw
的使用类型可确保图形卡将数据放在内存中,从而允许更快的写入速度。
注意:当编程结束时,我们需要手动清除缓冲区。为此,我们需要添加以下函数:
protected override void OnUnload(EventArgs e) { GL.BindBuffer(BufferTarget.ArrayBuffer, 0); GL.DeleteBuffer(VertexBufferObject); base.OnUnload(e); }
将缓冲区绑定到 0 基本上会将其设置为 null,因此,任何修改缓冲区而不首先绑定缓冲区的调用都会导致崩溃。这比意外修改不希望修改的缓冲区更容易调试。
到目前为止,我们存储了图形卡内存中的顶点数据,由名为 VBO
的顶点缓冲区对象管理。接下来,我们要创建一个顶点和片段着色器,实际处理这些数据,所以让我们开始构建这些
着色
现在,我们已经拥有了数据,是时候创建我们的管道了。为此,我们创建顶点着色器和线段着色器。
顶点着色器是像我们这样的人可编程的着色器之一。现代OpenGL
要求我们至少设置一个顶点和片段着色器,如果我们想要做一些渲染,我们将简要介绍着色器,并配置两个非常简单的着色器来绘制我们的第一个三角形。在下一教程中,我们将更详细地讨论着色器。
我们需要做的第一件事是在着色器语言GLSL
(OpenGL
着色语言)中编写顶点着色器,然后编译此着色器,以便我们可以在应用程序中使用它。下面您将在GLSL
中找到非常基本的顶点着色器的源代码:
#version 330 core layout (location = 0) in vec3 aPosition void main() { gl_Position = vec4(aPosition, 1.0); }
将之另存为shader.vert
如您所看到的,GLSL
看起来与C
类似。每个着色器以其版本的声明开头。由于OpenGL 3.3
及更高版本号与OpenGL
的版本号匹配(例如,GLSL
版本420
对应于OpenGL
版本4.2
)。我们还明确提到我们使用的核心配置文件功能。
接下来,我们使用in
关键字声明顶点着色器中的所有输入顶点属性。现在,我们只关心位置数据,因此我们只需要一个顶点属性。GLSL
具有一个矢量数据类型,该数据类型基于其后缀数字包含1到4个浮点。由于每个顶点都有一个3D
坐标,因此我们创建一个带aPosition
的vec3
输入变量。我们还通过布局(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
分配给颜色输出,作为橙色,Alpha
值为 1.0(1.0 完全不透明)。
编译着色器
我们有着色器源,但现在我们需要编译着色器。这在运行时完成;无法预先编译着色器并打包程序,因为编译的着色器取决于许多因素,如图形卡模型、制造商和驱动程序。相反,我们包括着色器源代码,并在程序开始时编译它。
我们将通过创建一个着色器类来做到这一点,该类编译着色器并包装几个函数,我们将在稍后看到。
public class Shader { int Handle; public Shader(string vertexPath, string fragmentPath) { } }
句柄将表示我们最终着色器程序在编译完成后的位置。我们将在构造函数中进行所有初始化。
首先,在构造函数中,定义两个 int:VertexShader
和FragmentShader
。这些是各个着色器的句柄。它们在构造函数中定义,因为在完整着色器程序完成后,我们不需要单独的着色器。
接下来,我们需要从各个着色器文件加载源代码。我们可以像这样做:
string VertexShaderSource; using (StreamReader reader = new StreamReader(vertexPath, Encoding.UTF8)) { VertexShaderSource = reader.ReadToEnd(); } string FragmentShaderSource; using (StreamReader reader = new StreamReader(fragmentPath, Encoding.UTF8)) { FragmentShaderSource = reader.ReadToEnd(); }
然后,我们生成着色器,并将源代码绑定到着色器。
VertexShader = GL.CreateShader(ShaderType.VertexShader); GL.ShaderSource(VertexShader, VertexShaderSource); FragmentShader = GL.CreateShader(ShaderType.FragmentShader); GL.ShaderSource(FragmentShader, FragmentShaderSource);
然后,我们编译着色器并检查错误。
GL.CompileShader(VertexShader); string infoLogVert = GL.GetShaderInfoLog(VertexShader); if (infoLogVert != System.String.Empty) System.Console.WriteLine(infoLogVert); GL.CompileShader(FragmentShader); string infoLogFrag = GL.GetShaderInfoLog(FragmentShader); if (infoLogFrag != System.String.Empty) System.Console.WriteLine(infoLogFrag);
如果在编译时出现任何错误,可以使用函数GL.GetShaderInfoLog
获取调试字符串。假设没有问题,我们可以继续链接。
GL.DetachShader(Handle, VertexShader);
GL.DetachShader(Handle, FragmentShader);
GL.DeleteShader(FragmentShader);
GL.DeleteShader(VertexShader);
我们现在有一个有效的着色器,所以让我们添加一种方法来使用它。将此函数添加Shader
类:
void Use() { GL.UseProgram(Handle); }
最后,我们需要在此类使用完成后清理句柄。由于面向对象语言问题,无法在最终化器中完成。相反,我们必须从 IDisposable
派生,并记住手动调用着色器上的Dispose
。在代码的其余部分下方添加以下内容:
private bool disposedValue = false; protected virtual void Dispose(bool disposing) { if (!disposedValue) { GL.DeleteProgram(Handle); disposedValue = true; } } ~Shader() { GL.DeleteProgram(Handle); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); }
祝贺!我们现在有一个功能齐全的着色器类
回到Game
类中,添加新属性Shader shader
;然后,在OnLoad
中,添加shader = new Shader("shader.vert", "shader.frag");
。然后,转到 OnUnload
,然后添加行shader.Dispose();
。
尝试运行;如果没有打印到控制台, 您的着色器已正确编译!
链接顶点属性
顶点数组对象
增编:动态检索着色器布局
原文地址:https://blog.csdn.net/u014786187/article/details/109356789