www.learncpp.com
既然你来到这里, 你可能想学习计算机图形的内部工作原理, 并做一些所有酷孩子们会做的事情. 自己做事情是非常有趣和聪明的, 让你对图形编程有很大的理解. 然而, 在开始你的旅程之前, 你需要考虑几件事情.
因为 OpenGL
是一个图形 API
, 而不是一个单独的平台, 它需要一种语言来操作, 所选择的语言是 C++
. 因此, 这些章节需要你对 C++
编程语言有一定的了解. 不过我将尝试解释大部分 C++
使用的概念, 包括必要的高级 C++
主题, 所以你不需要成为 C++
专家, 但你的能力也不应该仅仅是能写一个 “Hello World” 程序. 如果你没有太多的 C++
经验, 我可以向你推荐www.learncpp.com上的免费教程.
同时, 我们还会用到一些数学知识(线性代数, 几何和三角), 我会试着解释所有的数学概念. 然而, 我不是一个专门的数学家, 所以即使我的解释可能很容易理解, 它们也很可能是不完整的. 因此, 在必要的地方, 我会提供一些好的参考资料, 以更完整的方式解释材料. 开始你的 OpenGL
之旅之前, 不要害怕所需的数学知识; 几乎所有的概念都可以使用基本的数学背景理解, 我将尽量保持数学的最低限度. 大多数功能甚至不需要你理解所有的数学支持, 你只需要知道如何使用它.
LearnOpenGL
被分解为若干个节. 每一节包含几个章节, 每个章节都详细地解释了不同的概念. 每个章节都可以在目录菜单中找到. 概念以线性的方式教授(所以建议从上到下学习, 除非另有说明), 每一章会解释背景理论
和实践方面
. 为了使这些概念更容易理解, 我们给内容添加一些文本注释结构, 本书包含了盒子(Boxes)
、代码块(Code)
、颜色提示(Color hints)
和OpenGL 函数引用(OpenGL function references)
.
绿框包含一些关于 OpenGL 或手边主题的注释或有用的特性/提示. |
红框将包含警告或其他你必须格外小心的功能. |
你会在网站中发现大量的代码片段, 它们位于代码盒子中, 语法突出显示的代码如下图所示:
// This box contains code
由于它们只提供了代码片段, 所以在必要的地方, 我将提供给定主题
所需的整个源代码的链接
.
有些单词会用不同的颜色显示, 以更清楚地表达出特殊的含义:
OpenGL
常量的变量.LearnOpenGL
的一个特别受欢迎的原因是, 无论 OpenGL
的函数出现在什么地方, 它都可以查看它们的详细内容. 每当一个函数在网站的文档内容中被出现时, 该函数将显示一个稍微明显的下划线. 您可以将鼠标悬停在该函数上, 经过一小段时间后, 弹出窗口将显示该函数的相关信息, 包括该函数实际功能的概述. 现在, 将鼠标悬停在 glEnable 上可以看到它相关信息.
现在你对该网站的结构有了一点了解, 跳转到入门部分开始你的 OpenGL
之旅吧!
在开始我们的旅程之前, 我们应该首先定义什么是 OpenGL
. OpenGL
主要被认为是一个 API
( 一个应用程序编程接口 ), 它为我们提供了大量的函数, 我们可以使用它们来操作图形和图像. 然而, OpenGL
本身并不是一个 API
, 而仅仅是一个规范(标准), 由Khronos Group开发和维护.
OpenGL
规范准确地指定了每个函数的结果/输出
应该是什么, 以及它应该如何执行. 然后由实现该规范的开发人员提出该函数应该如何操作的解决方案. 由于 OpenGL
规范没有给我们实现的细节, OpenGL
的实际开发版本允许有不同的实现, 只要它们的结果/输出符合规范(因此对用户是相同的). 开发实际 OpenGL
库的人通常是显卡制造商. 你购买的每个显卡都支持特定的 OpenGL
版本, 也就是专门为该显卡(系列)开发的 OpenGL
版本. 当使用苹果系统时, OpenGL
库是由苹果自己维护的, 而在 Linux
下, 存在着图形供应商的版本
和爱好者对这些库的改编
. 这也意味着每当 OpenGL
渲染出它不应该显示的奇怪行为时, 这很可能是显卡制造商(或开发/维护库的人)的错误.
由于大多数实现是由显卡制造商构建的, 无论何时在实现中有一个 bug, 这通常通过更新你的显卡驱动程序来解决; 这些驱动程序包括您的显卡支持的最新版本的 OpenGL. 这就是为什么它总是建议偶尔更新你的图形驱动程序的原因之一. |
Khronos 公开托管所有 OpenGL
版本的所有规范文档. 感兴趣的读者可以在这里找到 OpenGL 3.3
版规范(我们将使用它), 如果你想深入了解 OpenGL
的细节, 这是一个很好的读物(注意它们大多只描述了结果
而不是实现
). 规范还为查找其功能的确切工作方式提供了很好的参考.
在过去, 使用 OpenGL
在 即时模式 下开发(通常称为 固定函数管线 ), 这是绘制图形的一种易于使用的方法. OpenGL
的大部分功能都隐藏在库中, 开发人员对 OpenGL
如何进行计算没有太多的控制. 但开发人员希望更多的灵活性, 随着时间的推移, 规范变得更加灵活; 开发者获得了对图像的更多控制权. 即时模式
虽然非常容易使用和理解, 但它的效率也非常低. 出于这个原因, 规范从 3.2
版本开始弃用即时模式
, 并开始鼓励开发人员在 OpenGL
的 核心配置模式 下开发, 该模式是 OpenGL
规范的一个分支, 它删除了所有旧的已弃用功能.
在使用 OpenGL
的核心配置时, OpenGL
迫使我们使用现代实践. 每当我们试图使用 OpenGL
的一个已弃用函数时, OpenGL
会引发一个错误并停止绘制. 学习现代方法的优点是它非常灵活和高效. 然而, 它也更难学. 即时模式
从 OpenGL
执行的实际工作中抽象了很多, 虽然它很容易学习, 但很难掌握 OpenGL
的实际工作方式. 现代方法要求开发人员真正理解 OpenGL
和图形编程, 虽然这有点困难, 但它允许更灵活, 更高效, 最重要的是: 更好地理解图形编程.
这也是本书面向 OpenGL 3.3
版本的原因. 虽然它更困难, 但它是非常值得努力的.
迄今为止, 已经可以使用 OpenGL
的更高版本了(在写的时候是 4.6
), 你可能会问: 为什么 OpenGL 4.6
已经出来了, 我还想学 OpenGL 3.3
? 这个问题的答案相对简单. 从 3.3
开始的所有 OpenGL
未来版本都在不改变 OpenGL
核心机制的情况下为 OpenGL
添加了额外的有用功能; 新版本只是引入了更有效或更有用的方法来完成相同的任务. 结果是, 所有的概念和技术在现代 OpenGL
版本中保持不变, 所以学习 OpenGL 3.3
是完全有效的. 只要你准备好了, 或者更有经验了, 你就可以轻松地使用最新 OpenGL
版本的特定功能.
当使用最新版本的 OpenGL 的功能时, 只有最现代的显卡才能运行您的应用程序. 这就是为什么大多数开发人员通常瞄准低版本的 OpenGL, 并可选地启用更高版本的功能. |
在一些章节中, 你会发现更多的现代特征也被这样记录下来.
OpenGL
的一个重要特性是它对扩展的支持. 每当图形公司提出一种新的技术或新的大型优化渲染, 这通常是在驱动程序实现的扩展中发现的. 如果运行应用程序的硬件支持这样的扩展, 那么开发人员可以使用扩展提供的功能来获得更高级或更高效的图形. 通过这种方式, 图形开发人员仍然可以使用这些新的渲染技术, 而不必等待 OpenGL
在其未来的版本中包含该功能, 只需要检查显卡是否支持该扩展. 通常, 当一个扩展很受欢迎或非常有用时, 它最终会成为未来 OpenGL
版本的一部分. 开发人员必须在使用这些扩展(或使用 OpenGL
扩展库)之前查询它们是否可用. 这允许开发人员根据扩展是否可用来做得更好或更有效:
if(GL_ARB_extension_name)
{
// Do cool new and modern stuff supported by hardware
}
else
{
// Extension not supported: do it the old way
}
在 OpenGL 3.3
版本中, 我们很少需要对大多数技术进行扩展, 但只要有必要, 就会提供适当的说明.
OpenGL
本身是一个大型状态机: 一个变量集合, 定义了 OpenGL
当前应该如何操作. OpenGL
的状态通常被称为 OpenGL 上下文 . 当使用 OpenGL
时, 我们经常通过设置一些选项、操作一些缓冲区, 然后使用当前上下文渲染来改变它的状态.
例如, 当我们告诉 OpenGL
我们现在想要绘制直线
而不是三角形
时,
我们 通过改变一些上下文变量 来改变 OpenGL
的状态, 以告诉 OpenGL
应该如何绘制. 一旦我们通过改变上下文告诉 OpenGL
它应该绘制线条
, 它下一个绘制命令将绘制线条而不是三角形。
在 OpenGL
工作时, 我们会遇到一些改变上下文状态的函数
(state-changing) , 以及一些基于 OpenGL
当前状态执行一些操作的状态使用函数
(state-using) . 只要你记住 OpenGL
本质上是一个大型状态机, 它的大部分功能就会更容易理解.
OpenGL
库是用 C 编写的, 并允许其他语言的许多派生, 但其核心仍然是 C 库. 由于 C 的许多语言结构不能很好地翻译到其他高级语言, OpenGL
在开发时考虑了几个抽象. 其中一个抽象是 OpenGL
中的 对象 .
OpenGL
中的一个 对象 是一个操作的集合, 代表了 OpenGL
状态的一个子集. 例如, 我们可以有一个对象来表示绘图窗口的设置
; 然后我们可以设置它的大小, 支持多少种颜色等等. 我们可以将对象可视化为类 c 结构体:
struct object_name {
float option1;
int option2;
char[] name;
};
每当我们想要使用对象时, 它通常看起来是这样的( OpenGL 的上下文可视化为一个大型结构):
// The State of OpenGL
struct OpenGL_Context {
...
object_name* object_Window_Target;
...
};
// create object
unsigned int objectId = 0;
glGenObject(1, &objectId);
// bind/assign object to context
glBindObject(GL_WINDOW_TARGET, objectId);
// set options of object currently bound to GL_WINDOW_TARGET
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_WIDTH, 800);
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_HEIGHT, 600);
// set context target back to default
glBindObject(GL_WINDOW_TARGET, 0);
这一小段代码是使用 OpenGL
时经常看到的工作流. 我们首先创建一个对象, 并将对象的引用存储为 id (对象的实际数据存储在后台). 然后我们将对象(通过使用它的 id)绑定到上下文的目标位置
(示例窗口对象目标的位置被定义为 GL_WINDOW_TARGET )(目标位置用于区分对象类型, 绑定操作后会切换上下文状态, 之后的针对于目标位置的操作将会作用于绑定的对象实例). 接下来, 我们设置窗口选项, 最后通过将窗口目标
的当前对象 id 设置为 0 来解除上一个对象绑定. 我们设置的选项已经被存储在 objectId
引用的对象中, 并在我们将对象绑定回 GL_WINDOW_TARGET 时立即恢复.
目前提供的代码示例只是 OpenGL 操作方式的近似; 在整本书中, 你会遇到足够多的实际例子. |
使用这些对象的好处是, 我们可以在应用程序中定义多个对象, 设置它们的选项, 每当我们开始使用 OpenGL
的状态的操作时, 我们将对象绑定到我们的首选设置. 例如,
现在, 您了解了一些 OpenGL
作为规范和库的知识, OpenGL
是如何在底层操作的, 以及 OpenGL
使用的一些自定义技巧.
如果你没有全部理解, 也不要担心; 在整个书中, 我们将通过每一步, 你会看到足够的例子来真正掌握 OpenGL
.
opengl.org: OpenGL
官方网站.
OpenGL registry: 托管所有 OpenGL
版本的 OpenGL
规范和扩展.
在我们开始创建令人惊叹的图形之前, 我们需要做的第一件事是创建一个 OpenGL
上下文和一个应用程序窗口
来绘制. 然而, 这些操作是特定于每个操作系统的, OpenGL
有意地试图从这些操作中抽象自己. 这意味着我们必须自己创建窗口
、定义上下文
和处理用户输入
.
幸运的是, 有相当多的库提供了我们想要的功能, 其中一些专门针对 OpenGL
. 这些库为我们节省了所有操作系统特定的工作(跨平台), 并为我们提供了一个窗口
和 OpenGL 上下文
来呈现. 一些比较流行的库有 GLUT
、SDL
、SFML
和 GLFW
. 在 LearnOpenGL
, 我们将使用 GLFW
. 您可以随意使用任何其他库, 大多数库的设置与 GLFW 的设置类似.
GLFW
是一个用 C语言
编写的库, 专门针对 OpenGL
. GLFW
为我们提供了将物品呈现到屏幕上所需的基本必需品. 它允许我们创建一个 OpenGL 上下文
, 定义窗口参数
, 并处理用户输入
, 这对于我们的目的来说已经足够了. 本章和下一章的重点是让 GLFW
启动并运行, 确保它正确地创建一个 OpenGL 上下文
, 并显示一个简单的窗口, 让我们在其中瞎折腾. 本章在检索
、构建
和链接
GLFW
库的过程中循序渐进. 在撰写本文时, 我们将使用 Microsoft Visual Studio 2019 IDE
(注意, 在较新的 Visual Studio
版本中, 过程是相同的). 如果您不使用 Visual Studio
(或使用更老的版本), 不要担心, 这个过程与大多数其他 ide
类似.
GLFW
可以从他们的网页下载页面获得. GLFW
已经有了 Visual Studio 2012
到 2019
的预编译的二进制文件
和头文件
, 但是为了完整起见, 我们将从源代码中自己编译 GLFW
. 这是为了让您了解自己编译开源库的过程, 因为并不是每个库都有预编译的二进制文件可用. 因此, 让我们下载 Source
包.
我们将以 64 位二进制文件的形式构建所有库, 所以如果您使用预编译的二进制文件, 请确保获得 64 位二进制文件. |
下载源代码包后, 将其解压并打开其内容. 我们只对少数内容感兴趣:
从源代码编译库可以确保生成的库完全适合您的 CPU/OS
, 一个难得的预编译二进制文件并不总是被提供(有时, 预编译的二进制文件对于您的系统是不可用的). 然而, 向开放世界提供源代码的问题是, 并不是每个人都使用相同的 IDE
或 构建系统
来开发他们的应用程序, 这意味着所提供的项目/解决方案
文件可能与其他人的设置不兼容. 因此, 人们必须使用给定的 .c/.cpp
和 .h/.hpp
文件建立自己的项目/解决方案, 这是很麻烦的. 正是因为这些原因, 有一个叫做 CMake
的工具.
CMake
是一种工具, 它可以使用预定义的 CMake 脚本
从一组源代码文件中生成用户选择的项目/解决方案文件
(例如, Visual Studio
, Code::Blocks
, Eclipse
). 这允许我们从 GLFW
的源包中生成一个 Visual Studio 2019 项目文件
, 我们可以使用它来编译库. 首先我们需要下载并安装 CMake
, 可以在他们的下载页面上下载.
一旦 CMake
安装, 你可以选择从命令行
或通过他们的 GUI
运行 CMake
. 因为我们不想让事情变得过于复杂, 所以我们将使用 GUI
. CMake
需要一个源代码文件夹
和一个用于二进制文件的目标文件夹
. 对于源代码文件夹, 我们将选择下载的 GLFW 源包的根文件夹
, 对于构建文件夹, 我们将创建并使用一个新目录: build
.
设置源文件夹
和目标文件夹
之后, 单击 Configure
按钮, 这样 CMake
就可以读取所需的设置和源代码。然后我们必须为项目选择生成器, 因为我们使用的是 Visual Studio 2019
, 所以我们将选择 Visual Studio 16
选项(Visual Studio 2019
也被称为 Visual Studio 16
)。然后 CMake
将显示可能的构建选项来配置生成的库。我们可以保留它们的默认值, 并再次单击 Configure
来存储设置。设置完成后, 单击Generate
, 项目文件就会生成.
现在可以在构建文件夹找到中有一个名为 GLFW.sln 的文件。我们用 Visual Studio 2019
打开它。由于 CMake
生成的项目文件已经包含了正确的配置设置, CMake
应该自动配置解决方案, 我们只需要构建解决方案, 使其编译为64位库; 现在点击构建解决方案, 可以在 build/src/Debug
中找到名为 glfw3.lib
的编译后的库文件。
一旦我们生成了库, 我们需要确保 IDE
知道在哪里可以找到我们的 OpenGL
程序的库
和include文件
。有两种常见的方法:
IDE/编译器
的 /lib
和 /include
文件夹, 并将 GLFW
的 include文件
添加到 IDE
的 /include 文件夹
中, 然后类似地添加 glfw3.lib
到 IDE
的 /lib文件夹
。这是可行的, 但不是推荐的方法。很难跟踪库和 include
文件, 而 IDE/编译器
的新安装将导致您不得不重新进行这个过程。头文件/库
。例如, 你可以创建一个单独的文件夹, 其中包含一个 Libs
和 Include
文件夹, 我们分别存储 OpenGL 项目
的所有库和头文件。 现在所有的第三方库都组织在一个位置(可以跨多台计算机共享)。然而, 我们的要求是, 每次创建新项目时, 都必须告诉 IDE 在哪里找到这些目录。一旦需要的文件存储在您选择的位置, 我们可以开始创建我们的第一个OpenGL GLFW 项目
。
首先, 让我们打开 Visual Studio
并创建一个新项目。如果有多个选项, 选择 c++
, 并接受空项目(不要忘记给你的项目一个合适的名称)。因为我们将在 64 位
中进行所有操作, 项目默认为 32位
, 所以我们需要将 Debug
旁边的下拉菜单从 x86
更改为 x64
:
一旦完成, 我们现在有了一个工作空间来创建我们的第一个 OpenGL 应用程序
!
为了让项目使用 GLFW
, 我们需要将库与我们的项目链接起来。这可以通过在链接器设置中指定我们想要使用的 glfw3.lib
来完成。但是我们的项目还不知道在哪里可以找到 glfw3.lib
, 因为我们将第三方库存储在不同的目录中。因此, 我们首先将这个目录添加到项目中。
当 IDE 需要查找库和包含文件时, 我们可以告诉它考虑这个目录。在解决方案资源管理器
中右键单击项目名称
, 然后进入 vc++ 目录
, 如下图所示:
您可以添加自己的目录, 让项目知道在哪里搜索。这可以通过手动将其插入到文本中或单击适当的位置字符串并选择 include
目录都这样做:
在这里, 您可以添加任意多的额外目录, 从那时起, IDE
在搜索库和头文件时也会搜索这些目录。一旦你的 Include文件夹
从 GLFW
被包含, 你将能够找到 GLFW
的所有头文件, 包括
。这同样适用于库目录。
由于 VS
现在可以找到所有需要的文件, 我们最终可以通过链接选项卡
和输入
将 GLFW
链接到项目:
要链接到一个库, 您必须向链接器指定库的名称。因为库名是 glfw3.lib
, 我们将其添加到附加依赖项字段
(手动或使用
选项), 然后在编译时链接到 GLFW
。除了 GLFW
, 我们还应该添加一个链接到OpenGL库
, 但这可能会因操作系统而不同:
如果你在 Windows
上, Microsoft SDK
附带 OpenGL
库 opengl32.lib
, 当你安装 Visual Studio
时, 它是默认安装的. 由于本章使用 VS
编译器, 并且是在 Windows
上, 我们添加 opengl32.lib
为链接器设置. 注意, 与 OpenGL
库等价的 64 位
库也称为 opengl32.lib
, 跟 32位
的一样, 这是一个有点不幸的名字.
在 Linux
系统上, 您需要链接到 libGL.so
所以库添加 -lGL
到您的链接器设置。如果你找不到库, 你可能需要安装任何 Mesa
, NVidia
或 AMD
的开发包。
然后, 一旦你将 GLFW
和 OpenGL
库都添加到链接器设置中, 你可以包括如下所示的 GLFW
头文件:
#include
对于使用 GCC 编译的 Linux 用户, 以下命令行选项可能会帮助你编译项目: -lglfw3 -lGL -lX11 -lpthread -lXrandr -lXi -ldl. 不正确地链接相应的库将会产生许多未定义的引用错误。 |
到此位置, 我们就完成了 GLFW
的设置和配置。
我们还没有完成所有准备, 因为还有一件事我们还需要做. 因为 OpenGL
实际上只是一个标准/规范, 它是由驱动制造商根据规范实现到特定显卡支持的驱动程序. 由于有许多不同版本的 OpenGL
驱动程序, 其大多数函数的位置在编译时并不知道, 需要在运行时查询. 开发人员的任务是检索他/她需要的函数的位置, 并将它们存储在函数指针中以供以后使用. 检索这些位置是特定于操作系统的. 在 Windows
中, 它看起来像这样:
// define the function's prototype
typedef void (*GL_GENBUFFERS) (GLsizei, GLuint*);
// find the function and assign it to a function pointer
GL_GENBUFFERS glGenBuffers = (GL_GENBUFFERS)wglGetProcAddress("glGenBuffers");
// function can now be called as normal
unsigned int buffer;
glGenBuffers(1, &buffer);
正如您所看到的, 代码看起来很复杂, 对于每个可能需要但尚未声明的函数, 这样做是一个繁琐的过程. 值得庆幸的是, 有一些库也有这个用途, 比如 GLAD 是一个很受欢迎的最新库.
GLAD
是一个开源库, 它管理我们谈到的所有繁琐工作.
与大多数常见的开放源码库相比, GLAD
的配置设置略有不同. GLAD
使用一个web 服务, 在这个服务中, 我们可以告诉 GLAD
我们想要根据那个版本定义
和加载
所有相关的 OpenGL 函数
.
转到 GLAD
的 web 服务, 确保语言设置为 C++
, 并在 API
部分选择至少 3.3
的 OpenGL 版本
(这是我们将要使用的; 更高版本也可以). 还要确保配置文件被设置为 Core
, 并且Generate a loader
选项被选中. 忽略扩展(目前)并单击 Generate
生成库文件.
到目前为止, GLAD
应该已经为您提供了一个包括两个 include 文件
和一个 glad.c 文件
的 .zip
文件. 将两个 include
文件夹 (glad
和 KHR
) 复制到 include(s) 目录
中(或添加一个额外的条目指向这些文件夹), 并将 glad.c
文件添加到项目中.
在前面的步骤之后, 你应该能够在你的源文件上面添加以下 include
指令:
#include
点击编译按钮不应该出现任何错误, 在这一点上基础上, 我们将进入下一章, 我们将讨论如何实际使用 GLFW
和 GLAD
来配置 OpenGL 上下文
和生成窗口. 请确保检查所有 include
和库
目录是否正确, 以及链接器设置中的库名称是否与相应的库匹配.
GLFW
指南设置和配置 GLFW
窗口.Code::Blocks IDE
中构建 GLFW
.Windows
和 Linux
上运行 CMake
.Wouter Verholst
编写的关于如何在 Linux
中编写自动构建系统工具的教程.看看能不能让 GLFW
运行起来. 首先, 创建一个 .cpp
文件, 并将以下内容添加到新创建的文件的顶部.
#include
#include
确保在 GLFW 之前包含 GLAD. GLAD 的 include 文件在幕后包含了所需的 OpenGL 头文件(如 GL/gl.h), 因此请确保在其他需要 OpenGL 的头文件(如 GLFW )之前包含 GLAD. |
接下来, 我们创建主函数, 我们将实例化 GLFW
窗口:
int main()
{
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
return 0;
}
在 main
函数中, 我们首先使用 glfwInit 初始化 GLFW
, 然后我们可以使用 glfwWindowHint 配置 GLFW
. glfwWindowHint 的第一个参数告诉我们想要配置的选项, 我们可以从以 GLFW_
为前缀的大量可能选项中选择该选项. 第二个参数是一个整数, 用于设置选项的值. 所有可能的选项及其相应值的列表可以在 GLFW
的窗口处理文档中找到. 如果你现在尝试运行应用程序, 它给出了许多未定义的引用错误, 这意味着你没有成功链接 GLFW
库.
因为这本书的重点是 OpenGL 3.3
版本, 我们想告诉 GLFW
我们想要使用的 OpenGL
版本是 3.3
. 通过这种方式, GLFW
可以在创建 OpenGL 上下文
时做出适当的安排. 这可以确保当用户没有正确的 OpenGL
版本时, GLFW
无法运行. 我们将主要和次要版本都设置为 3
. 我们还告诉 GLFW
我们想要明确地使用 core-profile
模式. 告诉 GLFW
我们想使用 core-profile
意味着我们将访问 OpenGL
功能的一个小子集, 而无需再使用向后兼容的功能. 注意, 在 Mac OS X
上你需要添加 GLFW_OPENGL_FORWARD_COMPAT (GL_TRUE);
修改初始化代码, 使其工作.
确保你的系统/硬件上安装了 OpenGL 3.3 或更高版本, 否则应用程序将崩溃或显示未定义的行为. 要在你的机器上找到 OpenGL 版本, 要么在 Linux 机器上调用 glxinfo, 要么使用像 Windows 的 OpenGL 扩展查看器这样的实用工具. 如果你支持的版本较低, 试着检查你的显卡是否支持 OpenGL 3.3+(否则它真的很旧)和/或更新你的驱动程序. |
接下来, 我们需要创建一个窗口对象. 这个 window
对象保存了所有的窗口数据, GLFW
的大多数其他函数都需要它.
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwCreateWindow 函数要求窗口宽度和高度分别作为它的前两个参数. 第三个参数允许我们为窗口创建一个名称; 现在我们称它为 “LearnOpenGL”, 当然但你可以随意命名. 我们可以忽略最后两个参数. 该函数返回一个 GLFWwindow 对象, 我们将在以后的其他 GLFW
操作中使用该对象. 之后, 我们通过 glfwMakeContextCurrent 函数告诉 GLFW
将窗口的上下文作为当前线程的主上下文.
在上一章中我们提到了 GLAD
管理 OpenGL
的函数指针, 所以我们想在调用任何 OpenGL 函数
之前初始化 GLAD
:
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
我们将函数传递给 GLAD
来加载 OpenGL 函数指针的地址
, 这是操作系统特定的. GLFW
提供给我们 glfwGetProcAddress , 它定义了基于我们编译的 OS
的正确函数.
在我们开始渲染之前, 我们必须做最后一件事. 我们必须告诉 OpenGL
渲染窗口的大小这样 OpenGL
就知道我们想要如何显示数据以及与窗口相关的坐标. 我们可以通过 glViewport 函数设置这些坐标:
glViewport(0, 0, 800, 600);
glViewport 的前两个参数设置窗口左下角的位置. 第三和第四个参数以像素为单位设置渲染窗口的宽度和高度, 我们将其设置为等同于 GLFW
的窗口大小.
我们可以将视口的尺寸设置为比 GLFW
的尺寸更小的值; 那么所有的 OpenGL
渲染将显示在一个较小的窗口中, 例如, 我们可以显示 OpenGL
视口之外的其他元素.
在幕后, OpenGL 使用通过 glViewport 指定的数据将其处理的 2D 坐标转换为屏幕上的坐标. 例如, 一个经过处理的位置点(-0.5, 0.5)将(作为其最终转换)映射到屏幕坐标中的(200, 450). 注意, OpenGL 中处理过的坐标在 -1 和 1 之间, 所以我们有效地从(-1到1)范围映射到(0, 800)和(0, 600). |
然而, 当用户调整窗口大小时, 视口也应该调整. 我们可以在窗口上注册一个回调函数, 该函数在每次窗口调整大小时被调用. 这个 resize 回调函数的原型如下:
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
framebuffer size
函数接受一个 GLFWwindow 作为它的第一个参数和两个表示新窗口坐标的整数. 每当窗口大小发生变化时, GLFW
就会调用这个函数, 并填充适当的参数供您处理.
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
我们必须告诉 GLFW
, 我们想在每次窗口调整大小时通过注册它来调用这个函数:
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
当窗口第一次显示时, framebuffer_size_callback 并生成窗口尺寸. 对于视网膜显示器
, 宽度和高度最终将显著高于原始输入值.
我们可以设置许多回调函数来注册我们自己的函数. 例如, 我们可以使用回调函数来处理操纵杆输入的变化, 处理错误消息等. 我们在创建窗口之后和开始渲染循环之前注册回调函数.
我们不希望应用程序绘制一个单一的图像, 然后立即退出并关闭窗口. 我们希望应用程序继续绘制图像和处理用户输入, 直到程序被明确地告知停止. 因为这个原因, 我们必须创建一个 while
循环, 我们现在称之为渲染循环, 它会一直运行, 直到我们告诉 GLFW
停止. 下面的代码显示了一个非常简单的渲染循环:
while(!glfwWindowShouldClose(window))
{
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwWindowShouldClose 函数在每次循环迭代开始时检查 GLFW
是否已经被指示关闭. 如果是, 函数返回 true
, 并且渲染循环停止运行, 之后我们可以关闭应用程序. glfwPollEvents 函数检查是否触发了任何事件(如键盘输入或鼠标移动事件), 更新窗口状态, 并调用相应的函数(我们可以通过回调方法注册). glfwSwapBuffers 将交换颜色缓冲区(一个大的 2D 缓冲区, 包含 GLFW
窗口中每个像素的颜色值), 用于在渲染迭代期间渲染, 并将其作为输出显示在屏幕上.
Double buffer 当应用程序在单个缓冲区中绘制时, 产生的图像可能会显示闪烁问题. 这是因为产生的输出图像不是在瞬间绘制的, 而是逐像素绘制的, 通常是从左到右、从上到下. 由于该图像在渲染时不会立即显示给用户, 因此结果可能包含瑕疵. 为了解决这些问题, 窗口应用程序应用了一个双缓冲区来进行渲染. 前缓冲区包含屏幕上显示的最终输出图像, 而所有渲染命令都绘制到后缓冲区. 当所有的渲染命令完成后, 我们将后缓冲区交换到前缓冲区, 这样图像就可以显示, 而不需要继续渲染, 删除所有前面提到的工件. |
一旦我们退出渲染循环, 我们想要正确地清理/删除所有已分配的 GLFW
资源. 我们可以通过在 main
函数末尾调用的 glfwTerminate
函数来实现这一点.
glfwTerminate();
return 0;
这将清除所有资源并正确退出应用程序. 现在尝试编译你的应用程序, 如果一切正常, 你应该看到以下输出:
如果这是一个非常无趣和无聊的黑色图像, 你做的事情是正确的! 如果您没有得到正确的图像, 或者您对所有的东西是如何组合在一起感到困惑, 请在这里检查完整的源代码(如果它开始闪烁不同的颜色, 继续阅读).
如果在编译应用程序时遇到问题, 首先要确保所有链接器选项都设置正确, 并且在 IDE
中正确地包含了正确的目录(如前一章所述). 还要确保你的代码是正确的; 您可以通过将其与完整的源代码进行比较来验证它.
我们还希望在 GLFW
中有某种形式的输入控制, 我们可以通过几个 GLFW
的输入函数来实现这一点. 我们将使用 GLFW
的 glfwGetKey 函数, 它将窗口和按键起作为参数. 该函数返回当前是否按下该键. 我们创建了一个 processInput 函数来组织所有的输入代码:
void processInput(GLFWwindow *window)
{
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
这里我们检查用户是否按下了 escape
键(如果没有按下, glfwGetKey 返回 GLFW_RELEASE
). 如果用户按了转义键, 我们通过使用 glfwSetwindowShouldClose 将它的 WindowShouldClose
属性设置为 true
来关闭 GLFW
. 主 while
循环的下一次条件检查将失败, 应用程序会关闭.
然后在渲染循环的每次迭代中调用 processInput :
while (!glfwWindowShouldClose(window))
{
processInput(window);
glfwSwapBuffers(window);
glfwPollEvents();
}
这为我们提供了一种检查特定按键并在每帧做出相应反应的简单方法. 渲染循环的迭代通常称为帧.
我们想要将所有的渲染命令放在渲染循环中, 因为我们想要在循环的每一次迭代或每一帧中执行所有的渲染命令. 这看起来有点像这样:
// render loop
while(!glfwWindowShouldClose(window))
{
// input
processInput(window);
// rendering commands here
...
// check and call events and swap the buffers
glfwPollEvents();
glfwSwapBuffers(window);
}
只是为了测试事情是否实际工作, 我们想要用我们选择的颜色清除屏幕. 在帧的开始, 我们想清除屏幕. 否则我们仍然会看到前一帧的结果(这可能是你想要的效果, 但通常你不想看到). 我们可以使用 glClear 清除屏幕的颜色缓冲区, 其中我们传递缓冲区位来指定我们想清除的缓冲区. 我们可以设置的位是 GL_COLOR_BUFFER_BIT
, GL_DEPTH_BUFFER_BIT
和 GL_STENCIL_BUFFER_BIT
. 现在我们只关心颜色值, 所以我们只清除颜色缓冲.
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
注意, 我们还使用 glClearColor 指定了清除屏幕的颜色。每当我们调用 glClear 并清除颜色缓冲时, 整个颜色缓冲将被 glClearColor 所配置的颜色填充。这将出现一个深绿蓝色的颜色。
在 OpenGL 的章节中, glClearColor 函数是一个状态设置函数, 而 glClear 函数是一个状态使用函数, 它使用当前状态来检索清除颜色。 |
该应用程序的完整源代码可以在这里找到。
所以现在我们已经准备好了用大量的渲染调用来填充渲染循环, 但这是下一章的内容。我想我们已经说得够久了。
在 OpenGL
中一切都是在 3D
空间中, 但屏幕或窗口是一个 2D
像素数组所以 OpenGL
的大部分工作是将所有 3D 坐标
转换为 2D 像素
以适应你的屏幕. 3D 坐标
到 2D 像素
的转换过程由 OpenGL
的 图形管道 管理. 图像管道可以分为两大部分: 第一部分将 3D 坐标转换为 2D 坐标, 第二部分将 2D 坐标转换为实际的彩色像素. 在本章中, 我们将简要讨论图形管道, 以及如何利用它来创建漂亮的像素.
图形管道将一组 3D 坐标
作为输入, 并将其转换为屏幕上的 彩色 2D 像素
. 图形管道可以分为几个步骤, 其中每个步骤都需要上一步的输出作为输入. 所有这些步骤都是高度专门化的(它们有一个特定的功能), 并且可以很容易地并行执行. 由于并行的特性, 今天的显卡有数千个小型处理核心, 可以在图形管道中快速处理数据. 处理核心在 GPU
上为管道的每一步运行小程序. 这些小程序被称为 着色器 (shaders) .
其中一些着色器是可由开发者自定义的, 这允许我们编写自己的着色器来替换现有的默认着色器. 这让我们能够更细粒度地控制管道的特定部分, 因为它们运行在 GPU
上, 它们也能够节省我们宝贵的 CPU
时间. 着色器是用 OpenGL 着色语言(GLSL) 编写的, 我们将在下一章深入探讨.
下面你会发现图形管道所有阶段的抽象表示. 注意, 蓝色的部分表示我们可以注入自己着色器的部分.
可以看到, 图形管道包含大量的部分, 每个部分处理将顶点数据转换为完全渲染的像素的一个特定部分. 我们将以一种简化的方式简要地解释管道的每个部分, 以便让您对管道如何运行有一个良好的概览.
作为图形管道的输入, 我们将传递一个包含三个 3D
坐标的数组, 它们将在一个名为 顶点数据 (Vertex Data)
的数组中形成一个三角形; 这个顶点数据
是顶点
的集合. 顶点 是每个 3D
坐标的数据集合. 这个顶点的数据
是用 顶点属性 表示的, 它可以包含任何我们想要的数据, 但为了简单起见, 让我们假设每个顶点
只包含一个 3D 位置
和一些颜色值
.
为了让 OpenGL 知道如何使用你的坐标和颜色值集合, OpenGL 要求你提示你想用数据形成什么样的渲染类型. 我们是否希望将数据呈现为点的集合、三角形的集合, 或者仅仅是一条长线? 这些提示被称为原语(primitive), 并在调用任何绘图命令时提供给 OpenGL. 这些提示包括 GL_POINTS 、GL_TRIANGLES 和 GL_LINE_STRIP. |
管道的第一部分是顶点 着色器(vertex shader) , 它将单个顶点作为输入. 顶点着色器的主要目的是将 3D 坐标
转换成不同的 3D 坐标
(稍后会详细介绍), 顶点着色器允许我们对顶点属性进行一些基本的处理.
原语组装(primitive assembly) 阶段从顶点着色器中获取所有的顶点集(或顶点, 如果选择 GL_POINTS )作为输入, 形成一个原语, 并将所有的点(s)组装成给定的原语形状; 在本例中是三角形.
原语组装阶段的输出被传递给 几何着色器(geometry shader) . 几何着色器以一组顶点作为输入, 这些顶点形成一个原语, 并有能力通过发出新的顶点来形成新的(或其他)原语来生成其他形状. 在本例中, 它根据给定的形状生成第二个三角形.
然后, 几何体着色器的输出被传递到 光栅化阶段(rasterization stage) , 在该阶段, 它将生成的 原语 primitive(s)
映射到最终屏幕上的相应像素, 从而生成 片元着色器
要使用的 片元(fragment)
. 在片元着色器运行之前, 将执行 剪裁. 剪切会丢弃视图之外的所有片元, 从而提高性能.
OpenGL 中的片元包含 OpenGL 渲染单个像素所需的所有数据. |
片段着色器(fragment shader) 的主要目的是计算像素的最终颜色, 这通常是所有 高级 OpenGL 效果
出现的阶段. 通常, 片元着色器包含有关 3D 场景
的数据, 可用于计算最终像素颜色(如灯光、阴影、灯光颜色等).
在确定了所有相应的颜色值之后, 最终对象将再经过一个阶段, 我们称之为 alpha 测试 和 混合(blending) 阶段. 此阶段检查片段的相应 深度(depth)
(和模具(stencil))值(我们稍后将讨论这些值), 并使用这些值检查生成的片段是在其他对象的前面还是后面, 并且应该相应地丢弃. 该阶段还检查 alpha
值(alpha 值定义对象的不透明度), 并相应地混合
对象. 因此, 即使在片段着色器中计算像素输出颜色, 在渲染多个三角形时, 最终的像素颜色仍然可能完全不同.
可以看到, 图形管道是一个相当复杂的整体, 包含许多可配置的部分. 然而, 对于几乎所有的情况下, 我们只需要完成 顶点
和 片元
着色器工作. 几何着色器是可选的, 通常保持其默认着色器. 还有细分着色器(tessellation stage)阶段和转换反馈循环, 我们没有在这里描述, 但那是以后的事情.
在现代的 OpenGL
中, 我们被要求至少定义一个我们自己的顶点
和片元
着色器(GPU 上没有默认的顶点/碎片着色器). 由于这个原因, 开始学习现代 OpenGL
通常是相当困难的, 因为在渲染你的第一个三角形之前需要大量的知识. 一旦你在本章最后完成了三角形的渲染, 你就会对图形编程有更多的了解.
为了开始绘制一些东西, 我们必须首先给 OpenGL
一些输入顶点数据. OpenGL
是一个 3D 图形库
, 所以我们在 OpenGL
中指定的所有坐标都是 3D (x, y 和 z 坐标). OpenGL
并不是简单地将屏幕上的所有 3D 坐标转换为 2D 像素; OpenGL
只处理 3 个轴(x, y 和 z)上在 -1.0 和 1.0 之间的特定范围内的 3D 坐标. 在这个所谓的 规范化设备坐标(normalized device coordinates) 范围内的所有坐标最终都会显示在你的屏幕上(而在这个区域之外的所有坐标则不会).
因为我们想要渲染一个单一的三角形, 所以我们想指定三个顶点, 每个顶点都有一个 3D 位置. 我们在一个 浮点数组
的规范化设备坐标(OpenGL 的可见区域)
中定义它们:
// 左 -> 右 -> 上
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
因为 OpenGL
工作于 3D 空间, 我们渲染一个二维三角形, 每个顶点的 z 坐标为 0.0. 这样, 三角形的深度保持不变, 使它看起来像 2D 的.
标准化设备坐标(NDC) |
一旦你的顶点坐标在顶点着色器中被处理, 它们应该在规范化的设备坐标中, 这是一个小空间, x, y 和 z 值从 -1.0 到 1.0 变化. 任何超出此范围的坐标将被丢弃/剪切, 在屏幕上不可见. 下面你可以看到我们在规范化的设备坐标中指定的三角形(忽略 z 轴):
与通常的屏幕空间坐标不同, 向上的正 y 轴点和 (0,0) 坐标位于图形的中心, 而不是左上角. 最终, 你希望所有(转换后的)坐标最终都在这个坐标空间中, 否则它们将不可见. |
使用 glViewport 提供的数据, 你的 NDC 坐标 将通过 viewport 转换 转换为 屏幕空间坐标 . 最终的屏幕空间坐标将被转换为片元, 作为片元着色器的输入.
定义好顶点数据后, 我们希望将其作为输入发送给图形管道的第一个阶段: 顶点着色器. 这是通过在 GPU
上创建存储顶点数据的内存, 配置 OpenGL
应该如何解释内存, 并指定如何将数据发送到显卡来完成的. 然后, 顶点着色器处理我们从其内存中告诉它的尽可能多的顶点.
我们通过所谓的 顶点缓冲对象(VBO) 来管理这些内存, 它可以在 GPU
的内存中存储大量的顶点. 使用这些缓冲区对象的好处是, 我们可以一次性向显卡发送大量数据, 并在内存足够的情况下保持这些数据, 而不必每次发送一个顶点的数据. 从 CPU
向显卡发送数据的速度相对较慢, 所以只要有可能, 我们就会尝试同时发送尽可能多的数据. 旦数据进入显卡内存, 顶点着色器几乎可以即时访问顶点, 速度非常快.
顶点缓冲区对象是我们在OpenGL一章中讨论过的 OpenGL 对象
的第一次出现. 就像 OpenGL
中的任何对象一样, 这个缓冲区有一个唯一的 ID
对应于那个缓冲区, 所以我们可以使用 glGenBuffers 函数来生成一个具有缓冲区 ID
的缓冲区:
unsigned int VBO;
glGenBuffers(1, &VBO);
OpenGL
有很多类型的缓冲区对象, 顶点缓冲区对象的缓冲区类型是 GL_ARRAY_BUFFER . OpenGL
允许我们一次绑定多个缓冲区, 只要它们具有不同的缓冲区类型. 我们可以用 glBindBuffer 函数将新创建的缓冲区绑定到 GL_ARRAY_BUFFER 目标上:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
从那时起( glBindBuffer 后), 我们(在 GL_ARRAY_BUFFER 目标上)所做的任何缓冲区调用都将用于配置当前绑定的缓冲区, 即 VBO
. 然后, 我们可以调用 glBufferData 函数, 将之前定义的顶点数据复制到缓冲区的内存中:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData
是一个专门用于将用户定义的数据复制到当前绑定的缓冲区的函数. 它的第一个参数是我们想要复制数据的缓冲区的类型: 当前绑定到 GL_ARRAY_BUFFER 目标的顶点缓冲区对象. 第二个参数指定要传递给缓冲区的数据的大小(以字节为单位); 对顶点数据进行简单的 sizeof
就足够了. 第三个参数是我们想要发送的实际数据.
第四个参数指定我们希望图形卡如何管理给定的数据. 这可以有三种形式:
三角形的位置数据不会改变, 而是经常使用, 并且在每次渲染调用中保持不变, 所以它的使用类型最好是 GL_STATIC_DRAW . 例如, 如果一个缓冲区的数据可能经常变化, 那么 GL_DYNAMIC_DRAW 的使用类型可以确保显卡将数据放在允许更快写入的内存中. 到目前为止, 我们将顶点数据存储在显卡的内存中, 由一个名为 VBO
的顶点缓冲区对象管理. 接下来我们想要创建一个顶点和片元着色器来实际处理这些数据, 所以让我们开始构建他们.
顶点着色器是像我们可以编程的着色器之一. 现代 OpenGL
要求我们至少设置一个顶点和片元着色器, 如果我们想做一些渲染, 所以我们将简要介绍着色器, 并配置两个非常简单的着色器来绘制我们的第一个三角形. 在下一章, 我们将更详细地讨论着色器.
我们需要做的第一件事是用着色器语言 GLSL (OpenGL着色语言)
编写顶点着色器, 然后编译这个着色器, 这样我们就可以在我们的应用程序中使用它. 下面你会发现 GLSL
中一个非常基本的顶点着色器的源代码:
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
正如您所看到的, GLSL
看起来类似于 C
. 每个着色器都以声明它的版本开始. 从 OpenGL 3.3
及更高版本开始, GLSL
的版本号与 OpenGL
的版本号匹配(例如, GLSL
版本 420
对应 OpenGL
版本 4.2
). 我们还明确提到我们正在使用核心配置文件
功能.
接下来, 我们用 in
关键字在顶点着色器中声明所有的输入顶点属性. 现在我们只关心位置数据, 所以我们只需要一个顶点属性. GLSL
有一个矢量数据类型, 它包含 1
到 4
(基于后缀数字) 个的浮点数. 例如:vec3 包含 3 个浮点数. 由于每个顶点都有一个 3D 坐标, 我们创建了一个名为 aPos
的 vec3
输入变量. 我们还通过 layout
专门设置了输入变量的 位置(location = 0)
, 稍后您将看到我们为什么需要这个位置.
Vector 在图形编程中, 我们经常使用向量的数学概念, 因为它可以整齐地表示任何空间中的位置/方向, 并且具有有用的数学属性. GLSL 中的向量最大大小为 4, 它的每个值都可以通过 vec.x, vec.y, vec.z 和 vec.w 检索到. 它们分别代表空间中的一个坐标. 注意 vec.w 分量并不用作空间中的位置(我们处理的是 3D, 而不是 4D), 而是用于所谓的 `透视划分(perspective division)`. 我们将在后面的章节中更深入地讨论向量. |
为了设置顶点着色器的输出, 我们必须将位置数据赋给预定义的 gl_Position 变量, 这是一个后台的 vec4. 在 main 函数的最后, 我们设置 gl_Position 的值将被用作顶点着色器的输出. 因为我们的输入是一个大小为 3 的向量, 我们必须将其转换为大小为 4 的向量. 我们可以在 vec4 的构造函数中插入 vec3 的值, 并将其 w 部分设置为 1.0f(我们将在后面的章节中解释为什么).
当前的顶点着色器可能是我们能想象到的最简单的顶点着色器, 因为我们没有对输入数据进行任何处理, 只是简单地将其转发到着色器的输出. 在实际应用中, 输入数据通常不是在规范化的设备坐标中, 所以我们首先必须将输入数据转换为 OpenGL
可见区域内的坐标.
我们获取顶点着色器的源代码, 并将其存储在代码文件的顶部的 const C字符串
中:
const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
为了让 OpenGL
使用着色器, 它必须在运行时从源代码 动态编译
它. 我们需要做的第一件事是创建一个着色器对象, 同样由 ID
引用. 所以我们将顶点着色器存储为 unsigned int
类型, 并使用 glCreateShader 创建着色器:
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
我们提供我们想要创建的着色器类型作为 glCreateShader 的参数. 因为我们正在创建一个顶点着色器, 所以我们传入 GL_VERTEX_SHADER .
接下来, 我们将着色器的源代码附加到着色器对象上, 并编译着色器:
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
glShaderSource 函数的第一个参数是要编译的 shader
对象. 第二个参数指定了作为(shader)源代码传递的字符串数量, 只有一个. 第三个参数是实际的顶点着色器的源代码, 我们可以把第 4 个参数留为 NULL
.
在调用 glCompileShader 之后, 您可能需要检查编译是否成功, 如果没有, 则需要检查发现了哪些错误, 以便修复这些错误. 检查编译时错误的方法如下: |
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
首先, 我们定义一个整数来表示成功, 并定义一个错误消息的存储容器(如果有的话). 然后用 glGetShaderiv 检查编译是否成功. 如果编译失败, 我们应该使用 glGetShaderInfoLog 检索错误消息, 并打印错误消息. |
if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
如果在编译顶点着色器时没有检测到错误, 那么它将被正确编译了.
片元着色器是第二个也是最后一个着色器, 我们将为渲染三角形而创建. 片元着色器是关于计算你的像素的颜色输出. 为了简单起见, 片元着色器将总是输出一个橙色的颜色.
在计算机图形中, 颜色以包含 4 个值的数组表示: 红色, 绿色, 蓝色和 alpha(不透明度) 分量, 通常缩写为 RGBA. 在 OpenGL 或 GLSL 中定义颜色时, 我们将每个颜色分量的强度设置为 0.0 到 1.0 之间的值. 例如, 如果我们将红色设置为1.0, 将绿色设置为 1.0, 我们将得到两种颜色的混合, 并得到黄色. 有了这三种颜色成分, 我们可以产生超过 1600 万种不同的颜色! |
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片元着色器只需要一个输出变量, 这是一个大小为 4 的向量, 它定义了我们应该自己计算的最终颜色输出. 我们可以用 out
关键字声明输出值, 这里我们立即将其命名为 FragColor
. 接下来, 我们简单地将 vec4 赋值给颜色输出, 作为一个橙色, alpha 值为 1.0(1.0是完全不透明的).
编译片元着色器的过程类似于顶点着色器, 不过这次我们使用 GL_FRAGMENT_SHADER 常量作为着色器类型:
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
现在这两个着色器都被编译了, 唯一要做的就是将这两个着色器对象链接到一个着色器程序中, 我们可以用它来进行渲染. 确保在这里检查编译错误!
一个着色器程序对象是多个着色器(对象)组合链接的最终版本. 为了使用最近编译的着色器, 我们必须将它们链接到一个 着色器程序对象
, 然后在渲染对象时激活这个着色器程序. 激活 着色器程序
的 着色器
将在我们发出 渲染调用
时触发.
当将着色器链接到程序中时, 它将每个着色器的输出链接到下一个着色器的输入. 如果输出和输入不匹配, 也会在这里出现链接错误.
创建程序对象很简单:
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glCreateProgram 函数创建一个程序, 并返回对新创建的程序对象的 ID 引用. 现在我们需要将之前编译过的着色器附加到程序对象上, 然后将它们链接到 glLinkProgram :
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
代码非常的简单, 我们将着色器附加到程序中, 并通过 glLinkProgram 链接它们.
就像着色器编译一样, 我们也可以检查链接的着色器程序是否失败并检索相应的日志. 但是, 我们现在使用的不是 glGetShaderiv 和 glGetShaderInfoLog : |
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
结果是一个程序对象, 我们可以用新创建的程序对象 ID 作为参数调用 glUseProgram 来激活它:
glUseProgram(shaderProgram);
glUseProgram 之后的每个着色器和渲染调用现在都将使用此程序对象(以及着色器). 哦, 是的, 不要忘记删除着色器对象一旦我们把它们链接到程序对象; 我们不再需要它们了:
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
现在我们将输入的顶点数据发送给 GPU
, 并指示 GPU
如何在顶点和片元着色器中处理顶点数据. 我们已经快成功了, 但还没有完全成功. OpenGL
还不知道它应该如何解释内存中的顶点数据, 以及它应该如何将 顶点数据
连接到 顶点着色器的属性
. 我们会告诉 OpenGL
怎么做.
顶点着色器允许我们以 顶点属性
的形式指定任何我们想要的输入, 虽然这允许很大的灵活性, 但这意味着我们必须手动指定 输入数据的哪一部分
到 顶点着色器
中的哪个 顶点属性
. 这意味着我们必须在渲染之前指定 OpenGL
应该如何解释顶点数据. 我们的顶点缓冲区数据格式如下:
有了这些知识, 我们可以告诉 OpenGL
它应该如何解释顶点数据(每个顶点属性)使用 glVertexAttribPointer :
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
函数 glVertexAttribPointer 有相当多的参数, 所以让我们仔细地浏览它们:
location = 0
) . 这会将顶点属性的位置设置为 0, 因为我们想要将数据传递给这个顶点属性, 所以我们传入 0.GL_FLOAT
(GLSL 中的vec* 由浮点值组成).规范化
. 如果我们输入的是整数数据类型(int, byte), 并且我们已经将其设置为 GL_TRUE
, 那么整数数据将被规范化为 0 (对于有符号的数据, 则为 -1), 当转换为 float
时则为 1. 在本例中这与我们无关, 所以我们把它设置为 GL_FALSE
.顶点属性
之间的间隔. 由于下一组 位置数据
的位置正好是一个浮点数大小的 3 倍, 所以我们将该值(3 * sizeof(float))
指定为 stride
. 请注意, 由于我们知道数组是紧密压缩的(下一个顶点属性值之间没有空格), 我们还可以将步长指定为0, 以让 OpenGL
确定步长(这仅在值紧密压缩时有效). 每当我们有更多的顶点属性时, 我们都必须仔细定义每个顶点属性之间的间距, 稍后我们将看到更多这样的示例.void*
类型, 因此需要进行奇怪的强制转换. 这是数据在缓冲区中起始位置的偏移量. 由于位置数据位于数据数组的开头, 因此该值仅为 0. 我们将在后面更详细地探讨这个参数.每个顶点属性从一个 VBO 管理的内存中获取数据, 它从哪个 VBO(可以有多个VBO) 获取数据, 这是由调用 glVertexAttribPointer 时当前绑定到 GL_ARRAY_BUFFER 的 VBO 确定的. 本例中由于之前定义的 VBO 在调用 glVertexAttribPointer 之前仍然是绑定的, 所以现在 0 属性与它的顶点数据相关联. |
既然我们已经指定了 OpenGL
应该如何解释顶点数据, 我们还应该使用 glEnableVertexAttribArray
来启用顶点属性, 给出顶点属性的位置(location)作为参数; 默认情况下, 顶点属性是禁用的. 从那时起, 我们便设置好了一切: 我们使用 顶点缓冲对象
在缓冲区中 初始化顶点数据
, 设置顶点和片元着色器, 并告诉 OpenGL
如何将顶点数据链接到顶点着色器的顶点属性. 在 OpenGL
中绘制一个对象现在看起来像这样:
// 0. 将我们的顶点数组复制到缓冲区中供 OpenGL 使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 然后设置 `顶点属性` 指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们想要渲染一个对象时, 使用我们的着色程序
glUseProgram(shaderProgram);
// 3. 现在画出物体
someOpenGLFunctionThatDrawsOurTriangle();
每次我们想要绘制一个对象时, 都要重复这个过程. 它看起来可能不是那么多, 但想象一下, 如果我们有超过 5 个顶点属性和可能 100 个不同的对象(这并不罕见). 绑定适当的缓冲区对象并为每个对象配置所有顶点属性很快就变成了一个繁琐的过程. 如果有某种方法可以将所有这些状态配置存储到一个对象中, 并简单地绑定该对象以恢复其状态, 会怎么样呢?
一个 顶点数组对象
(也称为 VAO
) 可以像绑定 顶点缓冲区对象
一样绑定, 从该点开始的任何后续 顶点属性调用
都将存储在 VAO
中. 这样做的好处是, 当配置顶点属性指针时, 你只需要调用一次, 当我们想要绘制对象时, 我们只需绑定相应的 VAO
. 这使得在不同的顶点数据和属性配置之间切换就像绑定不同的 VAO
一样容易. 我们刚刚设置的所有状态都存储在 VAO
中.
核心 OpenGL 要求我们使用 VAO, 这样它就知道如何处理顶点输入. 如果我们绑定一个 VAO 失败, OpenGL 很可能会拒绝绘制任何东西. |
顶点数组对象
存储以下内容:
生成 VAO
的过程看起来类似于 VBO
:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
要使用 VAO
, 你需要做的就是使用 glBindVertexArray 绑定 VAO
. 从那时起, 我们应该 绑定/配置 相应的 VBO
和属性指针, 然后取消绑定 VAO
以供以后使用. 当我们想要绘制一个对象时, 我们只需要在绘制对象之前将 VAO
与首选设置绑定就可以了. 在代码中, 这看起来像这样:
// ..:: 初始化代码(一次完成(除非你的对象经常改变)) :: ..
// 1. 绑定顶点数组对象 VAO
glBindVertexArray(VAO);
// 2. 将我们的顶点数组复制到缓冲区(VBO)中供 OpenGL 使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 然后设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// [...]
// ..:: 绘制代码(在渲染循环中) :: ..
// 4. 绘制对象
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
就是这样!在过去的几百万页中, 我们所做的一切都是为了这一刻, 一个 VAO
存储我们的顶点属性配置和使用哪个 VBO
. 通常, 当您有多个对象想要绘制时, 您首先 生成/配置 所有的 VAOs
(以及所需的 VBO
和 属性指针), 并存储它们以供以后使用. 当我们想要绘制一个对象时, 我们使用相应的 VAO
, 绑定它, 然后绘制对象并再次解除绑定 VAO
.
为了绘制我们选择的对象, OpenGL
为我们提供了 glDrawArrays 函数, 它使用当前活动的 着色器绘制原语
, 之前定义的顶点属性配置和 VBO
的顶点数据(通过 VAO
间接绑定).
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays
函数的第一个参数是我们想要绘制的 OpenGL
原语 类型. 由于我在开始时说过我们想画一个三角形, 我不想对您撒谎, 所以我们传入 GL_TRIANGLES. 第二个参数指定我们要绘制的顶点数组的起始索引; 我们让它等于 0. 最后一个参数指定了我们要画多少个顶点, 也就是 3 (我们只从数据中渲染了一个三角形, 正好是 3 个顶点长).
现在尝试编译代码, 如果出现任何错误, 则按反向的方式进行编译. 一旦你的应用程序编译, 你应该看到以下结果:
完整程序的源代码可以在这里找到.
如果您的输出看起来不一样, 那么您可能在整个过程中做错了什么, 所以请检查完整的源代码, 看看是否遗漏了什么.
在渲染顶点时, 我们还想讨论最后一件事, 那就是元素缓冲对象
(简称 EBO). 为了解释元素缓冲区对象
是如何工作的, 最好给出一个例子: 假设我们要画一个矩形而不是三角形. 我们可以使用两个三角形绘制一个矩形(OpenGL
主要处理三角形). 这将生成以下一组顶点:
float vertices[] = {
// first triangle
0.5f, 0.5f, 0.0f, // top right
0.5f, -0.5f, 0.0f, // bottom right
-0.5f, 0.5f, 0.0f, // top left
// second triangle
0.5f, -0.5f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f // top left
};
正如您所看到的, 在指定的顶点上有一些重叠. 我们指定了右下和左上两次! 这是一个 50% 的开销, 因为同样的矩形也可以只指定 4 个顶点, 而不是 6 个. 当我们拥有超过 1000 个三角形的复杂模型时, 情况只会变得更糟. 一个更好的解决方案是只存储唯一的顶点, 然后指定我们想要绘制这些顶点的顺序. 在这种情况下, 我们只需要为矩形存储 4 个顶点, 然后指定我们想要绘制它们的顺序. 如果 OpenGL
给我们提供这样的功能不是很好吗?
幸运的是, 元素缓冲区对象就是这样工作的. EBO
是一个缓冲区, 就像一个顶点缓冲区对象, 它存储 OpenGL
用来决定画什么顶点的索引. 这个所谓的索引绘图正是我们问题的解决方案. 首先, 我们必须指定(唯一的)顶点和索引, 将它们绘制成一个矩形:
float vertices[] = {
0.5f, 0.5f, 0.0f, // top right
0.5f, -0.5f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f // top left
};
unsigned int indices[] = { // note that we start from 0!
0, 1, 3, // first triangle
1, 2, 3 // second triangle
};
你可以看到, 当使用下标时, 我们只需要 4 个顶点而不是 6 个. 接下来, 我们需要创建元素缓冲区对象:
unsigned int EBO;
glGenBuffers(1, &EBO);
与 VBO 类似, 我们绑定 EBO 并使用 glBufferData 将索引复制到缓冲区中. 此外, 就像 VBO
一样, 我们希望将这些调用放在 bind
和 unbind
调用之间, 尽管这一次我们指定 GL_ELEMENT_ARRAY_BUFFER 作为缓冲区类型.
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
注意, 我们现在将 GL_ELEMENT_ARRAY_BUFFER 作为缓冲区目标. 最后要做的事情是用 glDrawElements 替换 glDrawArrays 调用, 以表明我们想要从索引缓冲区渲染三角形. 当使用 glDrawElements 时, 我们将使用当前绑定的元素缓冲区对象提供的索引进行绘制:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
第一个参数指定了我们想要绘制的模式, 类似于 glDrawArrays . 第二个参数是我们想要绘制的元素的数量. 我们指定了 6 个指标, 所以我们总共要画 6 个顶点. 第三个参数是索引的类型, 类型为 GL_UNSIGNED_INT. 最后一个参数允许我们在 EBO
中指定偏移量(或传入索引数组, 但这是在不使用元素缓冲区对象的情况下), 本例中默认设置为 0.
glDrawElements 函数从当前绑定到 GL_ELEMENT_ARRAY_BUFFER
目标的 EBO
中获取索引. 这意味着每次我们想要渲染一个带有索引的对象时, 我们都必须绑定相应的 EBO
, 这同样有点麻烦. 恰好, 顶点数组对象也会跟踪 元素缓冲对象 绑定. 绑定 VAO
时绑定的最后一个元素缓冲区对象存储为 VAO
的元素缓冲区对象. 绑定到 VAO
, 然后也会自动绑定 EBO
.
当目标是 GL_ELEMENT_ARRAY_BUFFER 时, VAO 存储 glBindBuffer 调用. 这也意味着它会存储它的 unbind 调用, 所以请确保在解绑定 VAO 之前不要解绑定元素数组缓冲区, 否则它没有配置 EBO. |
结果的初始化和绘图代码现在看起来像这样:
// ..:: 初始化 :: ..
// 1. 绑定 VAO
glBindVertexArray(VAO);
// 2. 将我们的顶点数组复制到一个顶点缓冲区中供 OpenGL 使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 将索引数组复制到 OpenGL 使用的元素缓冲区中 EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 然后设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// [...]
// ..:: 绘制代码(在渲染循环中) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);
运行该程序应该会得到如下所示的图像. 左边的图像看起来很熟悉, 右边的图像是线框模式下绘制的矩形. 线框矩形显示, 该矩形确实由两个三角形组成.
线框几何模型(Wireframe mode) 要在线框模式下绘制三角形, 你可以配置 OpenGL 如何通过 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 来绘制它的原语. 第一个参数说我们想把它应用到所有三角形的前面和后面, 第二条线告诉我们把它们画成直线. 任何后续的绘图调用都将在线框模式中渲染三角形, 直到我们使用 glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 将其设置回默认值. |
如果你有任何错误, 你可以倒着查找, 看看你是否遗漏了什么. 您可以在这里找到完整的源代码.
如果你成功地画了一个三角形或矩形, 就像我们做的那样, 那么恭喜你, 你成功地通过了现代 OpenGL
中最难的部分之一: 画第一个三角形. 这是一个困难的部分, 因为在绘制第一个三角形之前需要大量的知识. 值得庆幸的是, 我们现在已经克服了这个障碍, 希望接下来的章节会更容易理解.
OpenGL
调试的内容(直到调试输出部分).为了真正很好地掌握所讨论的概念, 我们建立了一些练习. 建议你在继续下一个主题之前先把它们通读一遍, 以确保你对发生了什么有一个很好的把握.
VAOs
和 VBOs
来创建相同的两个三角形: 解决方案.正如在 Hello Triangle 章节中提到的, 着色器是基于 GPU
的小程序. 这些程序针对图形管道的每个特定部分运行. 本质上, 着色器不过是将输入转换为输出的程序. 着色器也是非常孤立的程序, 因为它们不允许彼此通信; 他们唯一的交流是通过他们的输入和输出.
在前一章中, 我们简要地介绍了着色器的表面以及如何正确地使用它们. 现在, 我们将以更一般的方式解释着色器, 特别是 OpenGL 着色语言
.
着色器是用类似 c 语言
的 GLSL
编写的. GLSL
是为图形使用量身定制的, 包含专门针对向量和矩阵操作的有用功能.
着色器(Shaders)
总是以一个版本声明开始, 然后是一列输入输出变量(variables)
, uniforms
和它的 main 函数
. 每个着色器的入口点都在它的 main 函数
中, 在这里我们处理任何输入变量
并将结果输出到它的输出变量
中. 如果你不知道什么是 uniforms
, 别担心, 我们很快就会讲到.
一个着色器通常有以下结构:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
void main()
{
// 处理输入并做一些奇怪的图形处理
...
// 将处理过的东西输出到输出变量
out_variable_name = weird_stuff_we_processed;
}
当我们具体谈到顶点着色器时, 每个输入变量
也被称为顶点属性(vertex attribute). 我们允许声明的顶点属性的最大数量受硬件的限制. OpenGL
保证至少有 16 个 4-组件顶点属性可用, 但一些硬件可能允许更多, 你可以通过查询 GL_MAX_VERTEX_ATTRIBS 来获取:
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;
这通常会返回最小值 16, 对于大多数用途来说应该已经足够了.
任何其他编程语言一样, GLSL
有用于指定我们想要处理的变量类型的数据类型. GLSL
拥有我们从 C语言 中知道的大多数默认基本类型: int
、float
、double
、uint
和 bool
. GLSL
还有两种我们将经常使用的容器类型, 即向量(vectors)
和矩阵(matrices).
我们将在后面的章节中讨论矩阵.
GLSL
中的向量是刚刚提到的任何基本类型的 2, 3 或 4-组件容器. 它们可以采用如下形式(n 表示组件的数量):
浮点数
的默认向量.布尔值
的向量.整数
组成的向量.无符号整数
组成的向量.double
分量的向量.大多数情况下, 我们将使用基本的 vecn, 因为浮点数足以满足我们的大多数目的.
矢量的分量可以通过 vec 访问. 其中 x 是向量的第一个分量. 可以使用 .x, .y, .z 和 .w 分别访问它们的第一个、第二个、第三个和第四个分量. GLSL
还允许你使用 rgba 作为颜色或 stpq 作为纹理坐标, 访问相同的组件.
矢量数据类型允许进行一些有趣而灵活的组件选择, 称为 swizling. Swizzling 允许我们这样使用语法:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
你可以使用最多 4 个字母的任何组合来创建一个新的矢量(相同的类型), 只要原始矢量有这些组件; 例如, 它不允许访问 vec2 的 .z 组件. 我们还可以将 vector 作为参数传递给不同的 vector 构造函数调用, 以减少所需的参数数量:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
因此, 向量是一种灵活的数据类型, 可以用于所有类型的输入和输出. 在本书中, 你会看到很多关于我们如何创造性地管理矢量的例子.
着色器本身是一个很好的小程序, 但它们是整体的一部分, 因此我们希望在单个着色器上有输入和输出, 这样我们就可以移动东西. GLSL
为此专门定义了 in 和 out 关键字. 个着色器都可以使用这些关键字指定输入和输出, 只要输出变量
与下一个着色器阶段的输入变量
匹配, 它们就会被传递. 顶点和片元着色器有一点不同.
顶点着色器应该接收某种形式的输入, 否则它将非常无效. 顶点着色器的不同之处在于它的输入, 它直接从顶点数据接收输入. 为了定义顶点数据的组织方式, 我们用位置元数据
指定输入变量, 这样我们就可以在 CPU
上配置顶点属性. 我们已经在前面的章节中看到过 layout(location = 0)
. 因此, 顶点着色器的输入需要一个额外的布局规范, 这样我们就可以将它与顶点数据链接起来.
也可以省略 `layout(location = 0)` 说明符, 并通过 `glGetAttribLocation` 在 OpenGL 代码中查询属性位置, 但我更喜欢在顶点着色器中设置它们. 它更容易理解, 并为您(和 OpenGL)节省了一些工作. |
另一个例外是片段着色器需要 vec4 颜色输出变量, 因为片段着色器需要生成一个最终的输出颜色. 如果你没有在你的片段着色器中指定一个输出颜色, 这些片段的颜色缓冲输出将是未定义的(这通常意味着 OpenGL
将呈现它们要么黑要么白).
因此, 如果我们想从一个着色器发送数据到另一个着色器, 我们必须在发送着色器中声明一个输出, 在接收着色器中声明一个类似的输入. 当两边的 类型
和 名称
相等时, OpenGL
将把这些变量链接在一起, 然后就有可能在着色器之间发送数据(这是在链接程序对象时完成的). 为了向你展示这在实践中是如何工作的, 我们将改变上一章中的着色器, 让顶点着色器决定片段着色器的颜色.
#version 330 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0
out vec4 vertexColor; // specify a color output to the fragment shader
void main()
{
gl_Position = vec4(aPos, 1.0); // see how we directly give a vec3 to vec4's constructor
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // set the output variable to a dark-red color
}
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // the input variable from the vertex shader (same name and same type)
void main()
{
FragColor = vertexColor;
}
可以看到, 我们在顶点着色器中声明了一个 vec4 类型的变量 vertexColor
作为输出, 我们在 片元着色器
中声明了一个类似的 vertexColor
输入. 因为它们都有相同的类型和名称, 碎片着色器中的 vertexColor
链接到顶点着色器中的 vertexColor
. 因为我们在顶点着色器中将颜色设置为暗红色, 所以产生的碎片也应该是暗红色的. 输出如下图所示:
我们走吧! 我们只是设法从顶点着色器发送一个值到碎片着色器. 让我们来增加一点趣味, 看看我们是否可以从我们的应用程序发送一个颜色到片元着色器!
Uniforms 是将数据从我们的 CPU
应用程序传递到 GPU 着色器
的另一种方式. 然而, Uniforms 与顶点属性相比略有不同. 首先, Uniforms 是全局性的. 全局变量, 意思是一个统一变量对 每个
着色器程序对象都是唯一的, 可以在着色器程序的任何阶段从任何着色器访问. 其次, 无论您将 Uniforms 值设置为什么, Uniforms 值都将保持不变, 直到重置或更新为止.
要在 GLSL
中声明 Uniforms, 我们只需将 uniform
关键字添加到带有类型和名称的着色器中. 从那时起, 我们就可以在着色器中使用新声明的 Uniforms. 让我们看看这次是否可以通过 Uniforms 的颜色来设置三角形的颜色:
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // we set this variable in the OpenGL code.
void main()
{
FragColor = ourColor;
}
我们在片元着色器中声明了一个 Uniforms
的 vec4 ourColor
, 并将片段的输出颜色设置为该 Uniforms 值的内容. 由于 Uniforms 是全局变量, 我们可以在任何着色器阶段定义它们, 这样就不需要再通过顶点着色器来获得片元着色器. 我们没有在顶点着色器中使用这个 Uniforms, 所以没有必要在那里定义它.
如果你在你的 GLSL 代码中声明了一个没有用到的 Uniforms 变量, 编译器会无声地从编译版本中删除该变量, 这是导致一些令人沮丧的错误的原因; 记住这一点! |
Uniforms 目前是空的; 我们还没有添加任何数据到 Uniforms 中, 让我们试试. 我们首先需要在着色器中找到 Uniforms 属性的 索引/位置. 一旦我们有了 Uniforms 的索引/位置, 我们就可以更新它的值. 而不是传递一个单一的颜色片元着色器, 让我们通过时间逐渐改变颜色来增加乐趣:
float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
首先, 我们通过 glfwGetTime() 检索以秒为单位的运行时间. 然后我们使用 sin 函数在 0.0 - 1.0 范围内改变颜色, 并将结果存储在 greenValue 中.
我们使用 glGetUniformLocation 查询 ourColor Uniforms 的位置. 我们向查询函数提供 着色器程序
和 Uniforms
的名称(我们想从中检索位置(location)). 如果 glGetUniformLocation 返回 -1, 它无法找到位置. 最后, 我们可以使用 glUniform4f 函数设置 Uniforms 的值. 注意, 找到 Uniforms 的 location 不需要你首先使用着色器程序, 但更新 Uniforms 需要你首先使用程序(通过调用 glUseProgram ), 因为它在当前活动的着色器程序上设置 Uniforms.
因为 OpenGL 的核心是一个 C 库, 它没有对函数重载的原生支持, 所以当一个函数可以用不同的类型调用时, OpenGL 会为每种类型定义新的函数; glUniform 就是一个很好的例子. 该函数要求为您想要设置的统一格式的类型使用特定的后缀. 一些可能的后缀有: |
当您想配置 OpenGL 的一个选项时, 只需选择与您的类型相对应的重载函数. 在我们的例子中, 我们想单独设置 Uniforms 的 4 个 float, 所以我们通过 glUniform4f 传递数据(注意, 我们也可以使用 fv 版本).
现在我们知道了如何设置 Uniforms 变量的值, 我们可以使用它们进行渲染. 如果我们想要颜色逐渐改变, 我们希望每一帧都更新这个 Uniforms 的颜色, 否则如果我们只设置一次, 三角形就会保持单一的纯色. 所以我们计算 greenValue 并在每次渲染迭代中更新 Uniforms:
while(!glfwWindowShouldClose(window))
{
// input
processInput(window);
// render
// clear the colorbuffer
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// be sure to activate the shader
glUseProgram(shaderProgram);
// update the uniform color
float timeValue = glfwGetTime();
float greenValue = sin(timeValue) / 2.0f + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
// now render the triangle
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
// swap buffers and poll IO events
glfwSwapBuffers(window);
glfwPollEvents();
}
该代码是对前面代码相对简单的修改. 这一次, 我们在绘制三角形之前每帧更新一个 Uniforms 的值. 如果你正确地更新了 Uniforms, 你应该看到你的三角形的颜色逐渐从绿色到黑色, 然后回到绿色.
shaders.mp4
如果您被卡住了, 请查看这里的源代码.
正如你所看到的, Uniforms 是一个很有用的工具, 用于设置每一帧可能改变的属性, 或者用于在 应用程序
和 着色器
之间交换数据, 但如果我们想为每个顶点设置颜色呢? 在这种情况下, 我们必须声明相同数量的顶点. 一个更好的解决方案是在顶点属性中包含更多的数据, 这就是我们现在要做的.
我们在前一章看到了如何填充 VBO
, 配置顶点属性指针并将其存储在 VAO
中. 这一次, 我们还想向顶点数据添加颜色数据. 我们将添加颜色数据为 3 个浮点数到顶点数组. 我们分别为三角形的每个角赋值红色、绿色和蓝色:
float vertices[] = {
// positions // colors
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // bottom left
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // top
};
因为我们现在有更多的数据要发送到顶点着色器, 所以有必要调整顶点着色器来接收我们的颜色值作为顶点属性的输入. 注意, 我们用布局(layout)说明符将 aColor 属性的位置(location)设置为 1:
#version 330 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0
layout (location = 1) in vec3 aColor; // the color variable has attribute position 1
out vec3 ourColor; // output a color to the fragment shader
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // set ourColor to the input color we got from the vertex data
}
因为我们不再使用一个 Uniforms 的片元的颜色, 但现在使用 ourColor
输出变量, 我们将不得不改变片元着色器:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
void main()
{
FragColor = vec4(ourColor, 1.0);
}
因为我们添加了另一个顶点属性并更新了 VBO
的内存, 所以我们必须重新配置 顶点属性指针
. VBO
内存中的更新数据现在看起来有点像这样:
vertex_attribute_pointer_interleaved.png
知道了当前的布局, 我们可以用 glVertexAttribPointer 来更新顶点格式:
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// color attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);
glVertexAttribPointer 的前几个参数相对简单. 这次我们在属性位置 1 上配置顶点属性. 颜色值的大小为 3 个浮点数, 我们没有对这些值进行规范化.
因为我们现在有两个顶点属性, 我们必须重新计算步幅值(stride). 为了获得数据数组中的下一个属性值(例如位置向量的下一个 x 分量), 我们必须向右移动 6 个浮点数, 3 个为位置值, 3 个为颜色值. 这为我们提供了一个步幅值, 是浮点数大小的 6 倍(= 24 字节). 而且, 这次我们必须指定偏移量. 对于每个顶点, 位置顶点属性是第一位的, 因此我们声明偏移量为 0. color 属性在位置数据之后开始, 所以偏移量为 3 * sizeof(float), 以字节为单位(= 12 字节).
运行这个应用程序应该会产生如下图片:
如果您被卡住了, 请查看这里的源代码.
图像可能不完全是你所期望的, 因为我们只提供了 3 种颜色, 不是我们现在看到的巨大的调色板. 这都是片元着色器中所谓的 分段插值(fragment interpolation) 的结果. 当渲染一个三角形时, 栅格化阶段
通常会产生比最初指定的顶点更多的片元. 然后光栅化器根据它们在三角形上的位置来确定每个片元的位置. 基于这些位置, 它 插入 所有片元着色器的输入变量. 例如, 我们有一条线, 上面的点是绿色的, 下面的点是蓝色的. 如果片元着色器运行在一个位于 70% 的位置的片段上, 它的结果颜色输入属性将是绿色和蓝色的线性组合;更准确地说, 是 30% 的蓝色和 70% 的绿色.
这就是三角形的情况. 我们拥有 3 个顶点和 3 种颜色, 从三角形的像素来看, 它可能包含 50000 个片元, 而片元着色器将在这些像素中插入颜色. 如果你仔细观察这些颜色, 你会发现这一切都是有规律的: 红色到蓝色首先是紫色, 然后是蓝色. 片段插值被应用于所有片段着色器的输入属性.
编写、编译和管理着色器是相当麻烦的. 作为着色器主题的最后一笔, 我们将通过构建一个着色器类来让我们的生活变得更容易一些, 它从磁盘读取着色器, 编译和链接它们, 检查错误, 并且易于使用. 这也让您了解到我们如何将迄今为止学到的一些知识封装到有用的抽象对象中. 我们将完全在头文件中创建着色器类, 主要是为了学习目的和可移植性. 让我们先添加必要的 include
并定义类结构:
#ifndef SHADER_H
#define SHADER_H
#include // include glad to get all the required OpenGL headers
#include
#include
#include
#include
class Shader
{
public:
// the program ID
unsigned int ID;
// constructor reads and builds the shader
Shader(const char* vertexPath, const char* fragmentPath);
// use/activate the shader
void use();
// utility uniform functions
void setBool(const std::string &name, bool value) const;
void setInt(const std::string &name, int value) const;
void setFloat(const std::string &name, float value) const;
};
#endif
我们在头文件的顶部使用了几个预处理器指令. 使用这些小代码行告诉你的编译器只包含和编译这个头文件, 如果它还没有被包含, 即使多个文件包括着色器头文件. 这可以防止链接冲突. |
shader
类保存了 shader
程序的 ID
. 它的构造函数分别需要顶点着色器和片段着色器的源代码的文件路径, 我们可以将它们作为简单的文本文件存储在磁盘上. 为了增加一点额外的东西, 我们还添加了几个实用函数来稍微缓解我们的生活: 使用激活着色程序, 所有设置 …
函数查询 Uniforms 位置并设置其值.
我们使用 c++ 文件流将文件内容读入几个字符串对象:
Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. retrieve the vertex/fragment source code from filePath
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// ensure ifstream objects can throw exceptions:
vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
try
{
// open files
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
// read file's buffer contents into streams
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// close file handlers
vShaderFile.close();
fShaderFile.close();
// convert stream into string
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch(std::ifstream::failure e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
}
const char* vShaderCode = vertexCode.c_str();
const char* fShaderCode = fragmentCode.c_str();
[...]
接下来我们需要编译和链接着色器. 注意, 我们还检查编译/链接是否失败, 如果失败, 则打印编译时错误. 这在调试时非常有用(你最终会需要这些错误日志):
// 2. compile shaders
unsigned int vertex, fragment;
int success;
char infoLog[512];
// vertex Shader
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// print compile errors if any
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};
// similiar for Fragment Shader
[...]
// shader Program
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// print linking errors if any
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if(!success)
{
glGetProgramInfoLog(ID, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// delete the shaders as they're linked into our program now and no longer necessary
glDeleteShader(vertex);
glDeleteShader(fragment);
使用函数很简单:
void use()
{
glUseProgram(ID);
}
类似地, 任何统一的setter函数:
void setBool(const std::string &name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void setInt(const std::string &name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void setFloat(const std::string &name, float value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}
这样我们就有了一个完整的着色器类. 使用 shader
类是相当容易的; 我们创建了一个着色器对象, 然后开始使用它:
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
[...]
while(...)
{
ourShader.use();
ourShader.setFloat("someUniform", 1.0f);
DrawStuff();
}
这里我们将顶点和片元着色器的源代码存储在两个名为 shader
的文件中. shader.vs
和 shader.fs
. 你可以随意命名你的着色器文件; 我个人认为扩展名 .vs
和 .fs
非常直观.
你可以在这里找到源代码, 使用我们新创建的着色器类. 注意, 你可以点击着色器文件路径来找到着色器的源代码.
我们了解到, 为了给我们的对象添加更多的细节, 我们可以为每个顶点使用颜色来创建一些有趣的图像. 然而, 为了获得相当真实的效果, 我们必须有很多顶点, 这样我们就可以指定很多颜色. 这将占用大量的额外开销, 因为每个模型都需要更多的顶点, 并且每个顶点都需要一个颜色属性.
美工和程序员通常更喜欢使用纹理
. 纹理是一种 2D 图像(甚至 1D 和 3D 纹理也存在), 用于为对象添加细节; 把纹理想象成一张纸, 上面有一个漂亮的砖块图像(例如), 整齐地贴在你的 3D 房子上, 这样你的房子看起来就像有一个石头外观. 因为我们可以在一张图片中插入大量的细节, 这样我们可以不需要指定额外的顶点, 而人一种物体非常详细的错觉.
除了图像, 纹理还可以用来存储大量`任意数据`, 然后发送给着色器, 但我们将把它留给另一个主题. |
下面你将看到一个砖墙的纹理图像映射到前一章的三角形上.
为了将纹理映射到三角形, 我们需要告诉三角形的每个顶点它对应于纹理的哪个部分. 因此, 每个顶点都应该有一个与它们相关的纹理坐标, 用于指定从纹理图像的哪个部分采样. 然后片元插值为其他片元完成剩下的工作.
纹理坐标在 x轴 和 y轴 上的范围从 0 到 1 (记住我们使用的是 2D 纹理图像). 使用纹理坐标检索纹理颜色称为 采样
. 纹理坐标从纹理图像左下角的(0, 0)开始, 到右上角的(1, 1). 下图展示了我们如何将纹理坐标映射到三角形:
我们为三角形指定 3 个纹理坐标点. 我们希望三角形的左下角与纹理的左下角相对应, 所以我们使用(0, 0)纹理坐标作为三角形的左下角顶点. 同样的方法也适用于带有(1, 0)纹理坐标的右下角. 三角形的顶部应该与纹理图像的顶部中心相对应, 所以我们取(0.5, 1.0)作为它的纹理坐标. 我们只需要将 3 个纹理坐标传递给顶点着色器, 然后将它们传递给片元着色器, 片元着色器将巧妙地为每个片元插入所有纹理坐标.
最终的纹理坐标将会像这样:
float texCoords[] = {
0.0f, 0.0f, // lower-left corner
1.0f, 0.0f, // lower-right corner
0.5f, 1.0f // top-center corner
};
纹理采样有一个松散的解释, 可以用许多不同的方法来完成. 因此, 我们的工作是告诉 OpenGL 它应该如何采样它的纹理.
纹理坐标通常在(0, 0)到(1, 1)之间, 但是如果我们指定超出这个范围的坐标会发生什么呢? OpenGL
的默认行为是重复纹理图像(我们基本上忽略浮点纹理坐标的整数部分), 但 OpenGL
提供了更多的选项:
repeat
都镜像映像.当在默认范围外使用纹理坐标时, 每个选项都有不同的视觉输出. 让我们看看这些看起来像一个样本纹理图像(原始图像来源:Hólger Rezende):
前面提到的每个选项都可以通过 glTexParameter* 函数设置每个坐标轴(s, t (和 r, 如果你使用 3D 纹理)相当于x, y, z):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一个参数指定纹理目标
; 我们使用的是 2D 纹理, 所以纹理目标是 GL_TEXTURE_2D . 第二个参数要求我们告诉我们想要设置什么选项和哪个纹理轴; 我们想把它同时放在 S轴 和 T轴 上. 最后一个参数要求我们传递我们想要的纹理包装模式, 在这种情况下, OpenGL
将使用 GL_MIRRORED_REPEAT 在当前活动的纹理上设置它的纹理包装选项.
如果我们选择 GL_CLAMP_TO_BORDER 选项, 我们还应该指定边框颜色. 这是使用 fv 版本的 glTexParameter 函数与 GL_TEXTURE_BORDER_COLOR 作为它的选项, 我们在边界的颜色值的 float 数组中传递:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
纹理坐标不依赖于分辨率, 但可以是任何浮点值, 因此 OpenGL
必须找出将纹理坐标映射到哪个纹理像素(也称为 texel). 如果你有一个非常大的对象和一个低分辨率的纹理, 这就变得尤为重要. 你可能已经猜到 OpenGL
也有这个纹理滤波的选项. 有几个可用的选项, 但现在我们将讨论最重要的选项: GL_NEAREST 和 GL_LINEAR .
GL_NEAREST ( 最近邻居(nearest neighbor) 或者 点(point) 过滤) 是 OpenGL
默认的纹理过滤方法. 当设置为 GL_NEAREST 时, OpenGL
选择中心最靠近纹理坐标的贴图. 下面你可以看到 4 个像素的交叉点代表了确切的纹理坐标. 左上角的 texel
的中心最接近纹理坐标, 因此被选为采样颜色:
GL_LINEAR (也被称为 线性过滤 )从纹理坐标的相邻贴图中获取一个插值值, 近似于贴图之间的颜色. 纹理坐标到 texel 中心的距离越小, texel 的颜色对采样颜色的贡献就越大. 下面我们可以看到, 返回的是相邻像素的混合颜色:
但是这样的纹理过滤方法的视觉效果是怎样的呢?让我们看看当在一个大的物体上使用一个低分辨率的纹理时, 这些方法是如何工作的(因此纹理向上缩放, 单个贴图是明显的):
GL_NEAREST 的结果是阻塞模式, 我们可以清楚地看到构成纹理的像素, 而 GL_LINEAR 产生的是更平滑的模式, 每个像素都不太明显. GL_LINEAR 产生了更真实的输出, 但是一些开发人员更喜欢 8 位(像素风)的外观, 因此选择了 GL_NEAREST 选项.
纹理过滤可以设置为放大和缩小操作(当缩放或向下缩放时), 所以你可以在纹理向下缩放时使用最近邻居过滤和缩放纹理的线性过滤. 因此, 我们必须通过 glTexParameter* 为这两个选项指定过滤方法. 代码看起来应该类似于设置包装方法:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
想象一下, 我们有一个大房间, 里面有数千件物品, 每件物品都有一个附加的纹理. 远处的物体与靠近观看者的物体具有相同的高分辨率纹理. 由于这些物体很远, 可能只产生一些碎片, OpenGL
很难从高分辨率纹理中为其片段检索正确的颜色值, 因为它必须为跨越大部分纹理的片段选择纹理颜色. 这将在小对象上产生可见的瑕疵, 更不用说在小对象上使用高分辨率纹理会浪费内存带宽.
为了解决这个问题, OpenGL
使用了一个叫做 mipmaps
的概念, 它基本上是一个纹理图像的集合, 其中每个后续的纹理比前一个小两倍. mipmaps
背后的原理应该很容易理解: 在距离查看器一定的距离阈值后, OpenGL
将使用一个不同的 mipmap
纹理, 以最适合到对象的距离. 因为物体距离较远, 所以较小的分辨率不会被用户注意到. 然后, OpenGL
能够对正确的贴图进行采样, 并且在对 mipmap
的这一部分进行采样时, 涉及到的缓存内存更少. 让我们仔细看看 mimapped
纹理是什么样的:
手动为每个纹理图像创建一个 mipmap
纹理集合是很麻烦的, 但幸运的是, OpenGL
能够为我们完成所有的工作, 在我们创建一个纹理后, 只需调用 glGenerateMipmaps.
在渲染过程中在 mipmap
层之间切换时, OpenGL
可能会显示一些瑕疵, 比如在两个 mipmap
层之间可见的尖锐边缘. 就像普通的纹理过滤一样, 它也可以在 mipmap
级别之间进行过滤, 使用 NEAREST 和 LINEAR 过滤在 mipmap
级别之间进行切换. 要指定 mipmap
级别之间的过滤方法, 我们可以用以下四个选项之一替换原始的过滤方法:
mipmap
来匹配像素大小, 并使用最近的邻居插值来进行纹理采样.mipmap
级别, 并使用线性插值对该级别进行采样.mipmap
之间进行线性插值, 并通过最近邻插值对插值水平进行采样.mipmap
之间进行线性插值, 并通过线性插值对插值水平进行采样.就像纹理过滤一样, 我们可以使用 glTexParameteri 将过滤方法设置为前面提到的 4 种方法之一:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是将 mipmap
过滤选项之一设置为放大过滤器. 这没有任何影响, 因为 mipmaps
主要用于纹理缩放: 纹理放大不使用 mipmaps
, 给它一个 mipmap
过滤选项将生成一个 OpenGL
GL_INVALID_ENUM 错误代码.
使用纹理需要做的第一件事是将它们加载到我们的应用程序中. 纹理图像可以以几十种文件格式存储, 每种格式都有自己的结构和数据排序, 那么我们如何在应用程序中获得这些图像呢? 一种解决方案是选择我们想要使用的文件格式, 比如 .png
, 然后编写我们自己的图像加载程序来将图像格式转换为大量字节. 虽然编写自己的图像加载器并不难, 但仍然很麻烦, 如果希望支持更多的文件格式怎么办? 您必须为希望支持的每种格式编写一个图像加载程序.
另一个解决方案, 可能也是一个很好的解决方案, 是使用一个支持几种流行格式的图像加载库, 并为我们完成所有困难的工作. 像 stb_image.h
这样的库.
stb_image.h
是 Sean Barrett 设计的一个非常流行的 单头 图像加载库, 它可以加载最流行的文件格式, 并且很容易集成到你的项目中. stb_image.h
可以从这里下载. 只需下载单个头文件, 将其中 stb_image.h
添加到您的项目中, 并使用以下代码创建一个额外的 C++
文件:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
通过定义 STB_IMAGE_IMPLEMENTATION, 编译预处理器将修改头文件, 使其只包含相关的定义源代码, 从而有效地将头文件转换为 .cpp
文件, 仅此而已. 现在只需在程序的某个地方包含 stb_image.h
并编译.
对于下面的纹理部分, 我们将使用一个木制容器的图像
. 要使用 stb_image.h
加载一个图像, 我们使用它的 stbi_load 函数:
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
该函数首先接受图像文件的位置作为输入. 然后, 它希望你提供三个 int
作为它的第二个、第三个和第四个参数, stb_image.h
将用结果图像的宽度、高度和颜色通道的数量填充. 我们需要图像的宽度和高度来生成纹理.
与 OpenGL
中之前的任何对象一样, 纹理对象是通过 ID
来引用的; 让我们创建一个:
unsigned int texture;
glGenTextures(1, &texture);
glGenTextures
函数首先将我们想要生成的纹理对象数量作为输入, 并将它们存储在 unsigned int
数组中, 作为它的第二个参数(在我们的例子中只是一个unsigned int
). 就像其他对象一样, 我们需要绑定它, 这样任何后续的纹理命令都会配置当前绑定的纹理对象:
glBindTexture(GL_TEXTURE_2D, texture);
现在纹理对象已经绑定, 我们可以开始使用之前加载的图像数据生成纹理. 纹理是用 glTexImage2D 生成的:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
这是一个带有相当多参数的大型函数, 所以我们将逐步通过它们:
mipmap
级别, 我们希望为其创建纹理的 mipmap
级别, 但我们将保留它在基本级别, 即 0.OpenGL
我们希望以何种格式存储纹理(在 GPU
存储?). 我们的图像只有 RGB
值, 所以我们将用 ( GL_RGB ) 值存储纹理.RGB
值的图像(GL_RGB), 并将它们存储为字符( GL_UNSIGNED_BYTE ), 因此我们将传入相应的值.一旦调用 glTexImage2D, 纹理图像附加到当前绑定的纹理对象上. 然而, 目前它只加载了纹理图像的基本级别, 如果我们想使用 mipmaps
, 我们必须手动指定所有不同的图像(通过不断增加 glTexImage2D 的第二个参数), 或者, 我们可以在生成纹理后调用 glGenerateMipmap. 这将为当前绑定的纹理自动生成所有必需的 mipmap
.
在我们完成生成纹理和它对应的 mipmaps
之后, 释放图像内存是一个很好的习惯:
stbi_image_free(data);
生成纹理的整个过程如下图所示:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// set the texture wrapping/filtering options (on the currently bound texture object)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// load and generate the texture
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);
在接下来的章节中, 我们将使用在 Hello Triangle 章节的最后使用 glDrawElements 绘制的矩形形状那部分. 我们需要告知 OpenGL
如何采样纹理, 所以我们必须用纹理坐标更新顶点数据:
float vertices[] = {
// positions // colors // texture coords
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // top right
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // top left
};
因为我们添加了一个额外的顶点属性, 我们必须再次通知 OpenGL
新的顶点格式:
vertex_attribute_pointer_interleaved_textures.png
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
注意, 我们必须调整前两个顶点属性的 stride 参数为 8 * sizeof(float).
接下来我们需要改变顶点着色器, 以接受纹理坐标作为顶点属性, 然后将坐标转发给片元着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
片元着色器应该接受 TexCoord
输出变量作为输入变量.
片元着色器也应该访问纹理对象
, 但我们如何将纹理对象
传递给片元着色器? GLSL 有一个内置的纹理对象数据类型
, 称为采样器 sampler, 它以我们想要的纹理类型作为后缀, 例如 sampler1D、sampler3D 或本例中的 sampler2D. 然后, 我们可以通过简单地声明一个 Uniform 的 sampler2D 来添加一个纹理到片元着色器中, 然后我们将纹理分配给它.
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
为了对纹理的颜色进行采样, 我们使用 GLSL
的内置纹理函数
, 该函数的第一个参数是纹理采样器
, 第二个参数是对应的纹理坐标
. 然后纹理函数使用我们之前设置的纹理参数
对相应的颜色值
进行采样
. 最总这个片元着色器的输出(插值)纹理坐标的(过滤)纹理颜色.
现在剩下要做的就是在调用 glDrawElements 之前绑定纹理, 然后它会自动将纹理分配给片元着色器的采样器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
如果你所做的一切都正确, 你应该看到下面的图像:
如果你的矩形是完全白色或黑色的, 你可能会在整个过程中出错. 检查你的着色器日志, 并尝试将你的代码与应用程序的源代码进行比较.
如果你的纹理代码不工作或显示为完全黑色, 继续阅读并工作到最后一个应该工作的例子. 在一些驱动程序中, 它需要为每个采样均匀分配一个纹理单元, 这是我们将在本章中进一步讨论的东西. |
我们还可以将产生的纹理颜色与顶点颜色混合. 我们简单地将产生的纹理颜色与碎片着色器中的顶点颜色相乘, 以混合两种颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
结果应该是顶点的颜色和纹理的颜色的混合:
我想你可以说我们的集装箱喜欢迪斯科.
你可能想知道为什么 sampler2D
变量是一个 Uniform
的, 如果我们甚至没有使用 glUniform 赋值. 使用 glUniform1i, 我们可以给纹理采样器
分配一个位置值
, 这样我们就可以在片段着色器中一次性设置多个纹理. 纹理的这个位置值 通常被称为 纹理单元
. 一个纹理的默认 纹理单位
是 0
, 这是默认的活动(active)纹理单位, 所以在前一节中我们不需要分配位置; 注意, 并不是所有的图形驱动都会分配一个默认的纹理单元
, 所以前面的部分的实验可能没有为你渲染.
纹理单元
的主要目的是允许我们在着色器中使用不止一个纹理. 通过将纹理单元
分配给采样器
, 我们可以同时绑定多个纹理, 只要我们先激活相应的纹理单元. 就像 glBindTexture 一样, 我们可以使用 glActiveTexture 来激活纹理单元, 传递我们想要使用的纹理单元:
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
激活一个纹理单元后, 后续的 glBindTexture 调用将把该纹理对象
绑定到当前激活的纹理单元
. 纹理单元 GL_TEXTURE0 在默认情况下总是被激活的, 所以在之前的例子中使用 glBindTexture 时, 我们不需要激活任何纹理单元.
`OpenGL` 应该至少有 16 个纹理单元供你使用, 你可以使用 GL_TEXTURE0 到 GL_TEXTURE15 来激活它们. 它们是按顺序定义的, 所以我们也可以通过 GL_TEXTURE0 + 8 获得 GL_TEXTURE8, 这在我们必须循环多个纹理单元时很有用. |
我们仍然需要编辑片元着色器来接受另一个采样器
. 现在这应该是相对简单的:
#version 330 core
...
uniform sampler2D texture1;
uniform sampler2D texture2;
void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
最终的输出颜色现在是两个纹理查找的组合. GLSL
的内置 mix 函数将两个值作为输入, 并根据第三个参数在它们之间进行线性插值. 如果第三个值是 0.0, 它返回第一个输入; 如果是 1.0, 则返回第二个输入值. 0.2 的值将返回第一个输入颜色的 80% 和第二个输入颜色的 20%, 从而产生我们两种纹理的混合.
现在我们要加载并创建另一个纹理; 现在你应该熟悉这些步骤了. 确保创建另一个纹理对象, 加载图像并使用 glTexImage2D 生成最终的纹理. 对于第二个纹理, 我们将使用你的学习 OpenGL 时的面部表情:
unsigned char *data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
注意, 我们现在加载了一个包含 alpha(透明) 通道的 .png
图像. 这意味着我们现在需要通过使用 GL_RGBA; 否则 OpenGL
将错误地解释图像数据.
为了使用第二个纹理(和第一个纹理), 我们必须通过将两个纹理绑定到相应的纹理单元
来改变渲染过程:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
我们还必须告诉 OpenGL
每个着色器采样器
属于哪个纹理单元
, 通过使用 glUniform1i 设置每个采样器. 我们只需要设置一次, 所以我们可以在进入渲染循环之前这样做:
ourShader.use(); // don't forget to activate the shader before setting uniforms!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // set it manually
ourShader.setInt("texture2", 1); // or with shader class
while(...)
{
[...]
}
通过通过 glUniform1i 设置采样器, 我们可以确保每个 Uniform
的采样器对应于合适的纹理单元
. 你应该会得到以下结果:
你可能注意到纹理颠倒了! 这是因为 OpenGL
期望 y 轴上的 0.0 坐标位于图像的底部, 但图像通常在 y轴 的顶部有 0.0. 幸运的是, stb_image.h
可以在图像加载期间翻转 y轴, 在加载任何图像之前添加以下语句:
stbi_set_flip_vertically_on_load(true);
在告诉 stb_image.h
在加载图像时翻转 y轴 后, 你应该会得到以下结果:
如果你看到一个快乐的容器, 你就做对了. 您可以将其与源代码进行比较.
为了获得更舒适的纹理, 建议在继续之前完成这些练习.