教程地址:简介 - LearnOpenGL CN
前言
这篇文章是干啥的?前面学习过一遍LearnOpenGL,但是很多都忘了,而且使用的是旧版教程,很多东西都没更新,这次打算重新学习一遍新版本的LearnOpenGL。这篇会在教程的基础上增加自己的笔记,方便理解。若有错误,还请指出。
OpenGL
- 原文链接:OpenGL - LearnOpenGL CN
- 总结: 本节介绍了 OpenGL 相关概念。
基础概念
- OpenGL 的本质:
- OpenGL 常被认为是 API,但实际上它是由 Khronos 组织制定并维护的规范,包含一系列操作图形、图像的函数相关规定。
- 函数实现相关规定:
- OpenGL 规范严格规定了每个函数的执行方式以及输出值,不过函数具体如何实现由 OpenGL 库的开发者自行决定,只要其功能和结果与规范相匹配,用户就感受不到功能上的差异。
- OpenGL 库的开发者情况:
- 实际中 OpenGL 库的开发者通常是显卡生产商,购买的显卡所支持的 OpenGL 版本是为该系列显卡专门开发的。
- 在 Apple 系统中,OpenGL 库由 Apple 自身维护;在 Linux 系统下,既有显卡生产商提供的 OpenGL 库,也有爱好者改编的版本。
- 关于不一致情况说明:
- 当 OpenGL 库表现的行为与规范规定不一致时,基本都是库的开发者留下的 bug。
核心模式与立即渲染模式
- OpenGL 渲染模式发展及变化:
- 早期 OpenGL 采用立即渲染模式(固定渲染管线),绘图方便,但多数功能被库隐藏,开发者缺乏控制计算的自由,且该模式效率低。
- 随着规范发展越发灵活,从 OpenGL3.2 开始废弃立即渲染模式,鼓励在核心模式下开发,核心模式完全移除旧特性。
- 核心模式与现代函数特点:
- 使用核心模式时会迫使使用现代函数,若用已废弃函数会抛错并终止绘图。
- 现代函数优势在于灵活性高、效率高,但学习难度大;而立即渲染模式虽易学习理解,却让人难以把握 OpenGL 具体运作。
扩展
- 显卡公司若提出新特性或渲染大优化,常以扩展方式在驱动中实现。
- 程序运行在支持该扩展的显卡上时,开发者可利用其提供的先进、有效的图形功能,只需检查显卡是否支持该扩展,就可以使用这些新的渲染特性。
if(GL_ARB_extension_name)
{
// 使用硬件支持的全新的现代特性
}
else
{
// 不支持此扩展: 用旧的方式去做
}
状态机
- OpenGL 自身是一个巨大的状态机,通过一系列变量描述其此刻应如何运行,其状态被称作 OpenGL 上下文。
- 通常可通过设置选项、操作缓冲的方式去更改 OpenGL 状态,然后利用当前 OpenGL 上下文来进行渲染。
- 比如想让 OpenGL 画线段而非三角形,可改变上下文变量来改变状态以告知 OpenGL 绘图方式,改变状态后,后续绘制命令就会按新状态执行相应绘图操作。
- 存在状态设置函数,这类函数会改变上下文;还有状态使用函数,会依据当前 OpenGL 的状态执行相应操作。
对象
- 语言特性:OpenGL 库由 C 语言编写,虽支持多种语言派生但内核是 C 库,因 C 语言部分结构不易翻译到其他高级语言,开发时引入了抽象层,“对象” 就是其中之一。
- 对象:对象是一些选项的集合,代表 OpenGL 状态的一个子集,可类比 C 风格的结构体,例如能用来代表绘图窗口设置,设置其大小、颜色位数等相关选项。
struct object_name {
float option1;
int option2;
char[] name;
};
使用流程
// 对象ID索引
unsigned int objectId = 0;
// 创建一个对象,绑定其ID到objectId
glGenObject(1, &objectId);
// 绑定对象至上下文
glBindObject(GL_WINDOW_TARGET, objectId);
// 设置当前绑定到 GL_WINDOW_TARGET 的对象的一些选项
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_WIDTH, 800);
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_HEIGHT, 600);
// 将上下文对象设回默认
glBindObject(GL_WINDOW_TARGET, 0);
- 首先创建一个对象,然后用一个id保存它的引用(实际数据被储存在后台)
- 然后将对象绑定至上下文的目标位置(例子中窗口对象目标的位置被定义成GL_WINDOW_TARGET)
- 接下来我们设置窗口的选项
- 最后将目标位置的对象id设回0,解绑这个对象
使用优势
- 在程序中,我们能够定义不止一个对象。每个对象都可设置独特的选项,以满足不同的功能需求
- 当执行依赖 OpenGL 状态的操作时,仅需将包含所需设置的对象绑定到相应的操作环境中
创建窗口
- 原文链接:创建窗口 - LearnOpenGL CN
- 总结: 本节主要工作为搭建 glfw + glad 的 opengl 环境。
GLFW
- 创建 OpenGL 上下文和用于显示的窗口,这些操作在各系统上不同,OpenGL 将其抽象出去了,所以开发者得自行处理窗口创建、定义 OpenGL 上下文以及处理用户输入等工作。
- GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入。
配置过程:
- Download | GLFW 下载 GLFW 源码
- Download CMake 下载 Cmake
- 用 Cmake 编译 GLFW
- 配置项目属性
GLAD
- OpenGL 是标准 / 规范,具体实现由驱动开发商针对特定显卡落实,因其驱动版本众多,多数函数位置在编译时无法确定,需在运行时查询,所以开发者要在运行时获取函数地址并保存到函数指针中供后续使用。
// 定义函数原型 typedef void (*GL_GENBUFFERS) (GLsizei, GLuint*); // 找到正确的函数并赋值给函数指针 GL_GENBUFFERS glGenBuffers = (GL_GENBUFFERS)wglGetProcAddress("glGenBuffers"); // 现在函数可以被正常调用了 GLuint buffer; glGenBuffers(1, &buffer);
- 代码非常复杂,而且很繁琐,GLAD库能简化此过程
配置过程:
- glad.dav1d.de 进入 glad在线服务
- 语言(Language)设置为C/C++
- 选择3.3以上的OpenGL(gl)版本
- 将模式(Profile)设置为Core
- 点击生成(Generate)按钮来生成库文件
- 下载 zip 文件
- 配置项目属性
Others
后续OpenGL 的学习还会引入更多的外部库,为了方便我直接在 C:\Program Files\OpenGL_Lib 存放了 include、lib、dll 等文件,每次编译了外部库之后。只需将对应的 include、lib、dll 拷贝到对应的文件夹下面即可,再在项目属性增加需要用到的 lib 文件,减少了配置的过程。
所以我的 OpenGL 项目只需要配置这两个路径即可:
引入的 Lib 文件现目前包括:
对于 GLAD 库,只包含了三个文件,不存在使用 cmake 对其进行编译生成 lib 文件,所以我将其全部放在了 include/glad 文件夹下:
并修改了它的源码以适配我的路径:
// glad.c 第25行
#include "glad.h"
// glad.h 第89行
#include "khrplatform.h"
同时项目需要将 glad.c 包含进来(没有 lib 文件,需要项目存在 glad.c 文件才能找到 glad.h 内部函数的定义):
但其实我们完全可以自己生成 glad.lib 文件,避免每次都要将 glad.c 文件包含进项目,这可以避免很多潜在的问题。
生成 glad.lib 文件方法很简单,创建一个空项目,引入 glad.c 、glad.h 、khrplatform.h 这三个文件,然后更改配置类型为 .lib
随后 ctrl shift + b 构建项目,在项目的 debug 目录下找到对应的 lib 文件改名字为 glad.lib ,然后放到 OpenGL_Lib/Lib下即可
最后别忘了在 OpenGL项目中引入 glad.lib 文件
到此项目属性配置完成。新建 main.cpp 输入下面的代码(你好,窗口小节的代码)开始测试吧!
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);
// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;
int main()
{
// glfw: initialize and configure
// ------------------------------
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
// glad: load all OpenGL function pointers
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// render loop
// -----------
while (!glfwWindowShouldClose(window))
{
// input
// -----
processInput(window);
// glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
// -------------------------------------------------------------------------------
glfwSwapBuffers(window);
glfwPollEvents();
}
// glfw: terminate, clearing all previously allocated GLFW resources.
// ------------------------------------------------------------------
glfwTerminate();
return 0;
}
// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
// make sure the viewport matches the new window dimensions; note that width and
// height will be significantly larger than specified on retina displays.
glViewport(0, 0, width, height);
}
你好,窗口
- 原文链接:你好,窗口 - LearnOpenGL CN
- 总结: 本节创建了第一个 opengl 窗口。
GLFW
首先创建 main 函数:
int main()
{
// 初始化glfw
glfwInit();
// 告诉glfw使用opengl的主(major)版本号 - 3
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
// 告诉glfw使用opengl的次(minor)版本号 - 3
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
// 告诉GLFW我们使用的是核心模式(Core-profile)
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
return 0;
}
glfwInit
:初始化 glfwglfwWindowHint
:配置GLFW- 第一个参数:选项的名称(更多请见:GLFW: Window guide)
- 第二个参数:选项的值
接下来创建一个窗口对象:
// 创建一个窗口对象
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
// 通知GLFW将我们窗口设置为当前线程的主上下文
glfwMakeContextCurrent(window);
glfwCreateWindow
- 第一个参数:窗口的宽度
- 第二个参数:窗口的高度
- 第三个参数:窗口的标题
glfwMakeContextCurrent
:将给定的GLFWwindow
所代表的窗口设置为当前的 OpenGL 上下文- OpenGL 是一个状态机,很多操作都是基于当前的上下文来进行的,所以在进行后续的 OpenGL 渲染等操作之前,需要先指定好当前的上下文是哪个窗口对应的上下文,这样才能确保渲染等操作作用在正确的窗口上。
GLAD
首先初始化 glad
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
gladLoadGLLoader
gladLoadGLLoader
函数接收一个函数指针(通常是glfwGetProcAddress
),其主要目的是使用这个函数指针来加载 OpenGL 函数的地址,并将这些地址存储在 GLAD 内部的函数指针表中。这样就可以直接使用 OpenGL 函数,而无需手动调用glGetProcAddress
glfwGetProcAddress
:接受一个 OpenGL 函数名(字符串),并返回该函数在运行时环境中的地址(GLADloadproc)
:强制类型转换,gladLoadGLLoader
接受这种类型的函数指针
gladLoadGLLoader
执行的详细流程:
- 遍历 OpenGL 函数列表: GLAD 包含一个它支持的所有 OpenGL 函数的列表(包括核心函数和扩展函数)。
- 调用加载函数: 对于列表中的每个 OpenGL 函数,
gladLoadGLLoader
都会调用传递给它的函数指针(例如glfwGetProcAddress
),并将 OpenGL 函数的名称(字符串)作为参数传递给它。 - 获取函数地址:
glfwGetProcAddress
(或其他提供的加载函数)会根据当前运行环境(操作系统、显卡驱动等)查找指定 OpenGL 函数的地址。如果找到了该函数,则返回其地址;否则,返回NULL
。 - 存储函数地址:
gladLoadGLLoader
将glfwGetProcAddress
返回的地址存储在 GLAD 内部的一个函数指针表中。这个表本质上就是一个存储函数指针的数组或结构体。
视口
前面我们通过 glfw 定义了窗口的大小,这里又跑出来一个视口,这两者之间的关系是什么?来看下面的解释:
- 窗口(Window)
- 窗口是由操作系统管理的应用程序界面的一部分。它通常具有标题栏、边框、以及用于显示内容的区域。在 OpenGL 的上下文中,窗口是由 GLFW、SDL 或 GLUT 等窗口管理库创建和管理的。窗口的大小由操作系统或用户调整。
- 视口(Viewport)
- 视口是窗口内部的一个矩形区域,OpenGL 渲染的内容会被绘制到这个区域中。
glViewport
函数定义了视口在窗口中的位置和大小。
- 视口是窗口内部的一个矩形区域,OpenGL 渲染的内容会被绘制到这个区域中。
- 视口与窗口的关系
- 包含关系: 视口总是包含在窗口内部。你可以将视口设置为与窗口大小相同,也可以设置为比窗口小。
- 映射关系: 视口定义了 OpenGL 裁剪空间(Normalized Device Coordinates,NDC,范围为 -1 到 1)到屏幕坐标的映射。也就是说,OpenGL 在 NDC 中生成的图像会被缩放和转换,以适应视口的尺寸和位置。
- 独立性: 窗口和视口是相对独立的。改变窗口的大小不会自动改变视口的大小。你需要手动调用
glViewport
来更新视口,以适应新的窗口尺寸。
这里拿一个我正在做的 cad 软件来看一下:
中间红框的即为视口,整个界面为窗口(包括工具栏、视口)。
glViewport
函数解释:
glViewport(GLint x, GLint y, GLsizei width, GLsizei height);
x
和y
:指定视口左下角在窗口坐标系中的位置。窗口坐标系的原点通常在窗口的左下角,x 轴向右,y 轴向上。width
和height
:指定视口的宽度和高度,以像素为单位。
假设有一个 800x600 的窗口:
glViewport(0, 0, 800, 600);
:视口与窗口大小相同,OpenGL 渲染的内容会填充整个窗口。glViewport(100, 50, 600, 500);
:视口位于窗口的 (100, 50) 位置,宽度为 600 像素,高度为 500 像素。OpenGL 渲染的内容只会在窗口的这个较小区域内显示。glViewport(0, 0, 400, 300);
:视口位于窗口的左下角,宽度和高度分别是窗口的一半。OpenGL 渲染的内容只会在窗口的左下四分之一区域内显示。
当窗口大小改变时,需要重新调用 glViewport
来更新视口。这通常在窗口大小改变的回调函数中完成。
// 定义回调函数
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
int main()
{
// 绑定回调函数
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
}
准备好你的引擎
我们可不希望只绘制一个图像之后我们的应用程序就立即退出并关闭窗口。我们希望程序在我们主动关闭它之前不断绘制图像并能够接受用户输入。因此,我们需要在程序中添加一个 while 循环,我们可以把它称之为渲染循环(Render Loop),它能在我们让GLFW退出前一直保持运行。下面几行的代码就实现了一个简单的渲染循环:
while(!glfwWindowShouldClose(window))
{
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwWindowShouldClose
函数在我们每次循环的开始前检查一次GLFW是否被要求退出,如果是的话,该函数返回true
,渲染循环将停止运行,之后我们就可以关闭应用程序。glfwPollEvents
函数检查有没有触发什么事件(比如键盘输入、鼠标移动等)、更新窗口状态,并调用对应的回调函数(可以通过回调方法手动设置)。glfwSwapBuffers
函数会交换颜色缓冲(它是一个储存着GLFW窗口每一个像素颜色值的大缓冲),它在这一迭代中被用来绘制,并且将会作为输出显示在屏幕上。
双缓冲(Double Buffer)
应用程序使用单缓冲绘图时可能会存在图像闪烁的问题。这是因为生成的图像不是一下子被绘制出来的,而是按照从左到右,由上而下逐像素地绘制而成的。最终图像不是在瞬间显示给用户,而是通过一步一步生成的,这会导致渲染的结果很不真实。为了规避这些问题,我们应用双缓冲渲染窗口应用程序。前缓冲保存着最终输出的图像,它会在屏幕上显示;而所有的的渲染指令都会在后缓冲上绘制。当所有的渲染指令执行完毕后,我们交换(Swap)前缓冲和后缓冲,这样图像就立即呈显出来,之前提到的不真实感就消除了。
最后一件事
当渲染循环结束后我们需要正确释放/删除之前的分配的所有资源。我们可以在
main
函数的最后调用glfwTerminate
函数来完成。
glfwTerminate();
return 0;
输入
使用 glfw 的 glfwGetKey
函数,它需要一个窗口以及一个按键作为输入,这个函数将会返回这个按键是否正在被按下,创建一个 processInput
使代码保持整洁。
void processInput(GLFWwindow *window)
{
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
- 检查用户是否按下了返回键(Esc)
- 如果没有按下,
glfwGetKey
将会返回GLFW_RELEASE
。 - 如果用户的确按下了返回键,我们将通过使用
glfwSetwindowShouldClose
把WindowShouldClose
属性设置为true
来关闭 GLFW。下一次while循环的条件检测将会失败,程序将关闭。
- 如果没有按下,
接下来在渲染循环的每一个迭代中调用 processInput
:
while (!glfwWindowShouldClose(window))
{
processInput(window);
glfwSwapBuffers(window);
glfwPollEvents();
}
记得在每次循环中调用 glfwPollEvents
,它会更新按键状态。
渲染
在每一帧的渲染开始时调用,以确保上一帧的渲染结果不会影响到当前帧。两行代码通常一起使用,先使用 glClearColor
设置清除颜色,然后使用 glClear
清除颜色缓冲区。
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClearColor
是 OpenGL 函数,用于设置清除颜色缓冲区时所使用的颜色。- 它接受四个参数,分别代表红色(Red)、绿色(Green)、蓝色(Blue)和 alpha(Alpha)分量,取值范围都是 0.0 到 1.0。
glClear(GL_COLOR_BUFFER_BIT);
glClear
是 OpenGL 函数,用于清除指定的缓冲区。- 它接受一个或多个缓冲区位掩码作为参数,用于指定要清除的缓冲区。
GL_COLOR_BUFFER_BIT
:这是一个常量,表示颜色缓冲区。 因此,这行代码会使用glClearColor
设置的颜色来清除颜色缓冲区。
其他缓冲区位掩码
除了 GL_COLOR_BUFFER_BIT
,glClear
函数还可以使用其他缓冲区位掩码来清除其他类型的缓冲区:
GL_DEPTH_BUFFER_BIT
:清除深度缓冲区。深度缓冲区用于实现深度测试,决定哪些物体应该遮挡其他物体。GL_STENCIL_BUFFER_BIT
:清除模板缓冲区。模板缓冲区用于实现一些高级的渲染效果,例如遮罩和轮廓。
如果要同时清除多个缓冲区,可以使用按位或运算符 |
将它们组合起来:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 同时清除颜色缓冲区和深度缓冲区
在每一帧的渲染开始时清空缓冲区非常重要,如果不清除缓冲区,上一帧的渲染结果会残留在缓冲区中,导致画面混乱或出现重影。
完整代码:Code Viewer. Source code: src/1.getting_started/1.2.hello_window_clear/hello_window_clear.cpp
你好,三角形
- 原文链接:你好,三角形 - LearnOpenGL CN
- 总结: 介绍了图形渲染管线,VAO、VBO、EBO 等编程概念,绘制出了第一个三角形!
基础概念
- 顶点数组对象:Vertex Array Object,VAO
- 顶点缓冲对象:Vertex Buffer Object,VBO
- 元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object,IBO
- OpenGL 坐标转换:在 OpenGL 里,事物处于 3D 空间,但屏幕和窗口是 2D 像素数组,所以 OpenGL 的多数工作是把 3D 坐标转化为适配屏幕的 2D 像素。
- 图形渲染管线作用:3D 坐标到 2D 坐标的处理过程由 OpenGL 的图形渲染管线负责管理,它是原始图形数据经各种变化处理后呈现在屏幕的过程。
- 图形渲染管线组成:
- 第一部分将 3D 坐标转换为 2D 坐标。
- 第二部分把 2D 坐标转变为实际的有颜色的像素。
2D 坐标和像素也是不同的,2D 坐标精确表示一个点在 2D 空间中的位置,而 2D 像素是这个点的近似值,2D 像素受到你的屏幕/窗口分辨率的限制。
- 图形渲染管线输入输出:接受一组3D坐标,将其转变为屏幕上的有色2D像素输出。
- 图形渲染管线阶段划分:被划分为多个阶段,每个阶段将前一阶段输出作为输入,各阶段高度专门化。
- 图形渲染管线执行特性:具有并行执行特性,大多数显卡有成千上万小处理核心,在GPU上为每个阶段运行小程序,这些小程序被称为着色器(Shader)。
- 着色器:
- 部分着色器可由开发者配置,用自定义着色器替代默认的,能更细致控制图形渲染管线特定部分。
- 着色器运行在GPU上,节省CPU时间,OpenGL着色器使用OpenGL着色器语言(GLSL)编写。
图形渲染管线的每个阶段的抽象展示,蓝色部分代表的是我们可以注入自定义的着色器的部分:
- 首先,我们以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个 数组 叫做 顶点数据(Vertex Data);
- 顶点数据 是一系列 顶点的集合。
- 顶点(Vertex) 是一个3D坐标的数据的集合。
- 每个 顶点的数据 用 顶点属性(Vertex Attribute) 表示,可以包含任何我们想用的数据。
- 图元: 为让 OpenGL 明确坐标和颜色值所构成的图形类型,必须指定数据的渲染类型,此即为 图元 (Primitive) 。在调用任何绘制指令时,都需将图元传递给 OpenGL ,通过指定不同图元,能精准控制渲染效果。
GL_POINTS
表示将数据渲染为一系列点GL_TRIANGLES
表示渲染为一系列三角形,GL_LINE_STRIP
表示渲染为一条连续的线。
- 顶点着色器(Vertex Shader)
- 输入: 单个顶点及其顶点属性(例如位置、颜色、纹理坐标等)
- 作用:
- 顶点变换: 将顶点坐标从模型空间转换到裁剪空间(通过模型-视图-投影矩阵)
- 顶点属性处理: 对顶点属性进行处理,例如颜色计算、纹理坐标变换等,并将处理后的属性传递给管线的下一个阶段
- 输出: 转换后的顶点坐标和处理后的顶点属性
- 几何着色器(Geometry Shader)(可选)
- 输入: 一组顶点,形成一个图元(例如点、线、三角形)
- 作用:
- 图元生成: 可以生成新的顶点和图元,或者丢弃输入的图元。例如,可以从一个点生成多个点,或者从一个三角形生成多个三角形。
- 图元修改: 可以修改输入图元的属性
- 输出: 新生成的或修改后的图元
- 图元装配(Primitive Assembly)
- 输入: 顶点着色器或几何着色器的输出
- 作用: 将顶点组合成图元(例如点、线、三角形)。根据指定的图元类型(例如
GL_TRIANGLES
、GL_LINES
、GL_POINTS
),将顶点连接起来形成几何形状 - 输出: 装配好的图元
- 光栅化(Rasterization)
- 输入: 装配好的图元
- 作用:
- 图元到片段的转换: 将图元转换为一系列的片段。一个片段对应屏幕上的一个像素,但它包含的信息比像素更多,例如深度值、纹理坐标等。
- 裁剪(Clipping): 丢弃位于视锥体之外的片段,提高渲染效率。
- 透视除法(Perspective Division): 将裁剪空间坐标转换为标准化设备坐标(NDC),NDC 的 x、y 和 z 分量都在 -1 到 1 之间。
- 视口变换(Viewport Transform): 将 NDC 坐标映射到屏幕坐标。
glViewport
函数定义了视口的大小和位置,影响了这个映射过程。
- 输出: 一系列片段。
- 片段着色器(Fragment Shader)
- 输入: 来自光栅化阶段的片段。
- 作用:
- 像素着色: 计算每个片段(像素)的最终颜色。这是实现光照、阴影、纹理等效果的关键阶段。
- 输出: 每个片段的颜色值。
- 测试与混合(Tests and Blending)
- 输入: 片段着色器的输出。
- 作用:
- 深度测试(Depth Test): 比较片段的深度值,决定哪些片段应该被丢弃(即被其他片段遮挡)。
- 模板测试(Stencil Test): 使用模板缓冲区进行一些高级的渲染效果,例如遮罩。
- 混合(Blending): 将片段的颜色与帧缓冲区中已有的颜色进行混合,实现透明效果。
- 输出: 最终的像素颜色,写入帧缓冲区。
可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。
必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。
顶点输入
- 在绘制图形前,需向OpenGL输入顶点数据。
- OpenGL作为3D图形库,指定坐标均为3D坐标(x、y、z)。
- OpenGL仅处理3D坐标在x、y、z轴上均处于-1.0到1.0范围内的坐标,这些坐标被称为标准化设备坐标。
- 标准化设备坐标范围内的坐标最终会显示在屏幕上,范围外的坐标则不会显示 。
渲染一个三角形,一共要指定三个顶点,每个顶点都有一个3D位置。将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个 float
数组。
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
我们渲染的是一个2D三角形,所以将它顶点的z坐标设置为0.0,这样子的话三角形每一点的深度(Depth)都是一样的,从而使它看上去像是2D的。
通常深度可以理解为z坐标,它代表一个像素在空间中和你的距离,如果离你远就可能被别的像素遮挡,你就看不到它了,它会被丢弃,以节省资源。
顶点在顶点着色器中处理过后,会自动进行透视除法,将坐标转化为标准化设备坐标,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在屏幕上。
下面是上述三个顶点坐标在标准化设备坐标中的三角形(忽略z轴):
(0, 0)坐标是这个图像的中心,而不是左上角。
- 定义上述的顶点数据以后,需要把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。
- 我们需要在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。
- 可以通过顶点缓冲对象(Vertex Buffer Objects, VBO) 来管理这个内存,它能在 GPU 显存中存储大量顶点。
- 使用 VBO 的优势在于可一次性将大批数据发送至显卡,避免逐个顶点发送。
- 鉴于从 CPU 向显卡发送数据速度相对较慢,应尽量一次性多发送数据。而数据发送至显卡内存后,顶点着色器能快速访问顶点。
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
unsigned int VBO;
- 声明了一个无符号整型变量
VBO
。这个变量将用于存储 VBO 的 ID(标识符)。在 OpenGL 中,所有的对象(包括 VBO、纹理、着色器等)都通过唯一的 ID 来标识。
- 声明了一个无符号整型变量
glGenBuffers(1, &VBO);
glGenBuffers
用于生成缓冲区对象。- 第一个参数
1
指定要生成的缓冲区对象的数量。 - 第二个参数
&VBO
是一个指向无符号整型变量的指针,用于存储生成的缓冲区对象的 ID。执行完这行代码后,VBO
变量将包含一个新生成的 VBO 的 ID。
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBindBuffer
用于绑定缓冲区对象。- 第一个参数
GL_ARRAY_BUFFER
是一个目标缓冲区类型。GL_ARRAY_BUFFER
表示这是一个顶点数组缓冲区,用于存储顶点属性数据(例如位置、颜色、纹理坐标等)。- OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。
- 元素缓冲对象(Element Buffer Object,EBO)->
GL_FRAMEBUFFER
- 帧缓冲对象(Frame Buffer Object,FBO)->
GL_FRAMEBUFFER
- 纹理缓冲对象(Texture Buffer Object,TBO)->
GL_TEXTURE_BUFFER
- 第二个参数
VBO
是要绑定的缓冲区对象的 ID。 - 绑定缓冲区对象意味着后续的缓冲区操作(例如
glBufferData
)将作用于当前绑定的缓冲区。可以把绑定理解为“选中”或“激活”一个缓冲区。
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData
用于将数据复制到当前绑定的缓冲区对象中。- 第一个参数
GL_ARRAY_BUFFER
指定目标缓冲区类型,必须与glBindBuffer
中使用的类型一致。 - 第二个参数
sizeof(vertices)
指定要复制的数据的大小,以字节为单位。 - 第三个参数
vertices
指向要复制的数据的指针。 - 第四个参数
GL_STATIC_DRAW
是一个使用提示(Usage Hint),用于告知 OpenGL 如何使用这些数据。它可以取以下值:GL_STATIC_DRAW
:数据不会或几乎不会改变。GL_DYNAMIC_DRAW
:数据会被改变很多。GL_STREAM_DRAW
:数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。
现在我们已经把顶点数据储存在显卡的内存中,用VBO这个顶点缓冲对象管理。下面我们会创建一个顶点着色器和片段着色器来真正处理这些数据。
顶点着色器
顶点着色器(Vertex Shader)是几个可编程着色器中的一个,OpenGL需要我们至少设置一个顶点和一个片段着色器。下面会配置两个非常简单的着色器来绘制我们第一个三角形。
流程:
- 用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器
- 编译这个着色器
- 在程序中使用它
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
1. #version 330 core
- 这行代码声明了 GLSL 的版本。
330
指的是 GLSL 3.30 版本,core
指的是使用核心模式 (Core Profile)。
2.layout (location = 0) in vec3 aPos;
layout (location = 0)
:布局限定符 (Layout Qualifier),用于指定顶点属性 (Vertex Attribute) 的位置。在这里,它指定了aPos
属性的位置为 0。这个位置需要在应用程序代码中与顶点属性的绑定相匹配,才能正确传递数据。in
:这是一个输入限定符 (Input Qualifier),表示aPos
是一个输入变量,从顶点数组 (Vertex Array) 中接收数据。vec3 aPos;
:这声明了一个名为aPos
的三维向量 (vec3)。vec3
表示它包含三个浮点数分量,通常用于表示顶点的位置坐标 (x, y, z)。
3.gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
gl_Position
:这是一个内置的输出变量 (Built-in Output Variable),类型为vec4
(四维向量)。顶点着色器必须将最终的顶点位置赋值给gl_Position
。这个位置是裁剪空间坐标,后续的管线阶段 (例如裁剪、透视除法) 会使用这个坐标。vec4(aPos.x, aPos.y, aPos.z, 1.0)
:这使用aPos
的分量创建了一个四维向量。aPos.x
、aPos.y
和aPos.z
分别对应于新向量的 x、y 和 z 分量。最后一个分量1.0
是 w 分量。在齐次坐标系中,w 分量用于进行透视除法。
这个顶点着色器对输入数据什么都没有处理就把它传到着色器的输出,在真实的程序里输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内( MVP 变化 + 裁剪 + 透视除法 + 视口变换)。
编译着色器
- 暂时将顶点着色器的源代码硬编码在代码文件顶部的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";
- 创建一个着色器对象
unsigned int vertexShader; // GL_VERTEX_SHADER代表创建的是顶点着色器 vertexShader = glCreateShader(GL_VERTEX_SHADER);
- 将着色器源代码加载到着色器对象中
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
vertexShader
:着色器对象的 ID。1
:传递的字符串数量。这里只有一个字符串。&vertexShaderSource
:指向包含着色器源代码的字符串的指针的指针。NULL
:表示字符串是以空字符\0
结尾的。如果字符串不是以空字符结尾,可以传递一个整数数组,指定每个字符串的长度。
- 编译着色器源代码,编译后的着色器可以被链接到程序对象中使用。
glCompileShader(vertexShader);
- 检验着色器编译是否成功
int success; char infoLog[512]; // 用于获取着色器的参数。 // GL_COMPILE_STATUS:要获取的参数类型,这里是编译状态。 glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success); if(!success) { // 用于获取着色器的信息日志,通常包含编译错误或警告信息。 // NULL:可以传递一个指针来存储实际写入 `infoLog` 的字符数。这里不需要,所以传递 NULL glGetShaderInfoLog(vertexShader, 512, NULL, infoLog); std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl; }
片段着色器
片段着色器(Fragment Shader)是第二个也是最后一个我们打算创建的用于渲染三角形的着色器。片段着色器所做的是计算像素最后的颜色输出。为了让事情更简单,我们的片段着色器将会一直输出橘黄色。
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片段着色器只需要一个输出变量,这个变量是一个4分量向量,它表示的是最终的输出颜色,我们应该自己将其计算出来。声明输出变量可以使用
out
关键字,这里我们命名为FragColor。下面,我们将一个Alpha值为1.0(1.0代表完全不透明)的橘黄色的vec4
赋值给颜色输出。
Tips: 在现代 OpenGL(核心模式)中,gl_FragColor
不是 一个内置的输出变量,但在 WebGL 中,gl_FragColor
是一个内置的输出变量,通常代表片段的颜色。
编译片段着色器:
unsigned int fragmentShader;
// GL_FRAGMENT_SHADER代表片段着色器
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
两个着色器现在都编译了,剩下的事情是把两个着色器对象链接到一个用来渲染的着色器程序(Shader Program)中。
着色器程序
- 着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。
- 如果要使用刚才编译的着色器,我们必须把它们 链接(Link) 为一个着色器程序对象,然后在渲染对象的时候 激活 这个着色器程序。
- 已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。
创建一个着色器程序对象
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
将着色器对象附加到程序对象(附加的顺序无影响)
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
链接着色器程序对象。链接过程会将所有附加的着色器组合在一起,生成最终的可执行程序。链接过程中会进行各种检查,例如检查着色器之间的接口是否匹配。
glLinkProgram(shaderProgram);
// 检查链接是否成功
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
激活程序对象。激活后,后续的渲染操作将使用该程序对象中包含的着色器。
glUseProgram(shaderProgram);
删除着色器对象。一旦着色器被链接到程序对象,并且程序对象被激活后,就可以删除着色器对象,因为程序对象已经包含了着色器的编译代码。
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
- 现在,我们已经把输入顶点数据(
vertices[]
)放到了GPU内存中 - 并指示了GPU如何在顶点和片段着色器中处理输入的数据(
layout (location = 0) in vec3 aPos;
) - 但OpenGL还不知道该如何解释内存中的顶点数据,即将 GPU 内存中的数据赋值给着色器的输入数据(
layout (location = 0) in vec3 aPos;
)
链接顶点属性
^86109c
顶点着色器允许我们指定任何以顶点属性为形式的输入,极具灵活性的同时,就意味着我们必须手动指定输入的顶点数据的哪一个部分对应顶点着色器的哪一个顶点属性。
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
我们的顶点数据具有如下的字节分布:
- 一个顶点有 3 个 flaot 变量,一个 float 变量为 4 字节(32bit)
- 共有 3 个顶点,每个顶点占 4 ∗ 3 = 12 4 * 3 = 12 4∗3=12 字节
- 3 个顶点之间没有空隙,在数组中精密排列。
可以使用 glVertexAttribPointer
函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
0
:指定顶点属性的索引(location)。这个值需要与顶点着色器中layout (location = 0)
匹配。3
:指定每个顶点属性的分量数。这里是 3,表示每个顶点有三个分量(例如 x、y、z 坐标)。GL_FLOAT
:指定每个分量的数据类型。这里是GL_FLOAT
,表示每个分量都是一个单精度浮点数。GL_FALSE
:指定是否需要将数据标准化。如果设置为GL_TRUE
,则会将数据映射到 [ − 1 , 1 ] [-1, 1] [−1,1](有符号型signed) 或 [ 0 , 1 ] [0, 1] [0,1] 的范围内。这里设置为GL_FALSE
,表示不需要标准化。3 * sizeof(float)
:指定顶点属性之间的步距(stride)。步距是指两个连续的顶点属性之间的字节数。- 由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙),所以可以设置为0来让OpenGL决定具体步长是多少。
- 一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔。
(void*)0
:指定顶点属性的偏移量(offset)。偏移量是指顶点属性在顶点数据中的起始位置。这里是(void*)0
,表示顶点属性从顶点数据的开头开始(虽然参数类型是void*
,但它不是一个实际的指针,而是一个相对于 VBO 起始地址的字节偏移量)。glEnableVertexAttribArray(0);
:顶点属性默认是禁用的,参数0
指定要启用的顶点属性的索引。
下面给出另一个例子方便理解
float vertices[] = {
// Position (x, y, z) Color (r, g, b)
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // Vertex 1
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // Vertex 2
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // Vertex 3
};
此时每个顶点有两个顶点属性,Position(前三个) 与 Color(后 3 个),一共有 3 个顶点。此时的顶点着色器类似于这种
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 color;
.......
Position:
- 对于 position ,它占据了 location = 0,索引 = 0
- 每个 position 的分量为 3 个float
- 相邻两个 position 顶点属性的步距为(一个 position + 一个 color)=
sizeof(float) * 6
- position 顶点属性在顶点数据 vertices 的起始位置,则偏移 = 0。
综上,Position 的设置如下:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
Color:
- 占据了 location = 1,索引 = 1
- 每个 color 的分量为 3 个float
- 相邻两个 color 顶点属性的步距为(一个 position + 一个 color)=
sizeof(float) * 6
- color 顶点属性在顶点数据 vertices 的一个 position 之后,则偏移 =
3 * sizeof(float)
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVertexAttribPointer时绑定到GL_ARRAY_BUFFER的VBO决定的。由于在调用glVertexAttribPointer之前绑定的是先前定义的VBO对象,顶点属性
0
现在会链接到它的顶点数据。
这一段话的意思是,glVertexAttribPointer
函数的作用是配置顶点属性的读取方式。它并不直接读取 VBO 中的数据,而是告诉 OpenGL 如何解释 VBO 中的数据,而在 glVertexAttribPointer
函数之前,应该使用 glBindBuffer(GL_ARRAY_BUFFER, VBO)
将指定的 VBO 绑定到 GL_ARRAY_BUFFER
目标,这样后续调用 glVertexAttribPointer
时,OpenGL 就会正确的从当前绑定的 GL_ARRAY_BUFFER
中读取数据配置信息。
所以整个 VBO 的使用流程如下:
- 创建 VBO: 使用
glGenBuffers
创建一个 VBO,并获取其 ID。 - 绑定 VBO: 使用
glBindBuffer(GL_ARRAY_BUFFER, VBO)
绑定 VBO。 - 填充 VBO: 使用
glBufferData
将顶点数据复制到 VBO 中。 - 配置顶点属性:
- 调用
glVertexAttribPointer
配置顶点属性的读取方式。此时,由于之前已经绑定了 VBO,glVertexAttribPointer
会将该 VBO 与指定的顶点属性索引关联起来。
- 调用
- 启用顶点属性: 使用
glEnableVertexAttribArray
启用顶点属性。 - 创建 VAO(后续): 使用
glGenVertexArrays
创建一个 VAO,并使用glBindVertexArray
绑定它。然后,将上述 VBO 绑定、数据填充、属性配置和属性启用等操作放在 VAO 的绑定范围内。这样,就可以将所有顶点属性的配置信息保存在 VAO 中,方便后续切换。 - 绘制: 使用
glDrawArrays
或glDrawElements
等函数进行绘制。在绘制时,OpenGL 会根据 VAO 中存储的配置信息,从绑定的 VBO 中读取顶点数据,并将其传递给顶点着色器。
// 0. 创建VBO
unsigned int VBO;
glGenBuffers(1, &VBO);
// 1. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 2. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 3. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 4. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();
每当我们绘制一个物体的时候都必须重复这一过程。这看起来可能不多,但是如果有超过5个顶点属性,上百个不同物体呢(这其实并不罕见)。绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
顶点数组对象(VAO)
- 顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。
- 这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。
- 这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中
VAO 的作用
VAO 本质上是一个状态容器,它存储了以下信息:
- 绑定的 VBO: VAO 记录了哪些 VBO 被绑定到
GL_ARRAY_BUFFER
目标。 - 顶点属性配置: VAO 存储了通过
glVertexAttribPointer
设置的顶点属性的配置信息,包括属性的索引 (location)、大小 (size)、类型 (type)、步长 (stride)、偏移量 (pointer) 等。 - 启用的顶点属性: VAO 记录了哪些顶点属性通过
glEnableVertexAttribArray
被启用。
假设我们有两个物体:一个三角形和一个正方形,它们的顶点数据和属性配置不同。同时假设我们可以不使用 VAO 来渲染,下面给出不使用 VAO 和使用的代码。
float triangleVertices[] = {
// Position (x, y, z)
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
float squareVertices[] = {
// Position (x, y, z) Texture Coordinates (s, t)
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.0f, 1.0f, 1.0f,
-0.5f, 0.5f, 0.0f, 0.0f, 1.0f
};
不使用 VAO:
GLuint triangleVBO, squareVBO;
// 1. 创建 VBO
glGenBuffers(1, &triangleVBO);
glGenBuffers(1, &squareVBO);
// 2. 将数据复制到 VBO
glBindBuffer(GL_ARRAY_BUFFER, triangleVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, squareVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(squareVertices), squareVertices, GL_STATIC_DRAW);
// 绘制循环
while (!glfwWindowShouldClose(window)) {
// ...
// 绘制三角形
glBindBuffer(GL_ARRAY_BUFFER, triangleVBO); // 绑定VBO,但不再重新填充数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glDrawArrays(GL_TRIANGLES, 0, 3);
// 绘制正方形
glBindBuffer(GL_ARRAY_BUFFER, squareVBO); // 绑定VBO,但不再重新填充数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); // Position
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); // Texture Coordinates
glEnableVertexAttribArray(1);
glDrawArrays(GL_QUADS, 0, 4);
// ...
}
// ...
使用 VAO:
GLuint triangleVAO, squareVAO;
GLuint triangleVBO, squareVBO;
// 初始化
glGenVertexArrays(1, &triangleVAO);
glGenBuffers(1, &triangleVBO);
glBindVertexArray(triangleVAO);
glBindBuffer(GL_ARRAY_BUFFER, triangleVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glGenVertexArrays(1, &squareVAO);
glGenBuffers(1, &squareVBO);
glBindVertexArray(squareVAO);
glBindBuffer(GL_ARRAY_BUFFER, squareVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(squareVertices), squareVertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, 0); // 解绑
glBindVertexArray(0); // 解绑
// 绘制循环
while (!glfwWindowShouldClose(window)) {
// ...
// 绘制三角形
glBindVertexArray(triangleVAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
// 绘制正方形
glBindVertexArray(squareVAO);
glDrawArrays(GL_QUADS, 0, 4);
glBindVertexArray(0);
// ...
}
// ...
这样你应该就可以明显地感受到 VAO 的好处了
OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。
在 OpenGL 的核心模式下,VAO 的使用是必须的,只绑定 VBO 而不绑定 VAO,OpenGL会拒绝绘制任何东西。
绘制三角形
要想绘制我们想要的物体,OpenGL给我们提供了
glDrawArrays
函数,它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
void glDrawArrays(GLenum mode, GLint first, GLsizei count);
GLenum mode
: 指定要绘制的图元类型。常用的值有:GL_POINTS
:绘制一系列点。GL_LINES
:绘制一系列线段。每两个顶点定义一条线段。GL_TRIANGLES
:绘制一系列三角形。每三个顶点定义一个三角形。
GLint first
: 指定从顶点数组的哪个索引开始读取顶点数据。GLsizei count
: 指定要绘制的顶点数量。
此时可以绘制出一个三角形了:
源码:Code Viewer. Source code: src/1.getting_started/2.1.hello_triangle/hello_triangle.cpp
元素缓冲对象(EBO)
在渲染顶点这一话题上我们还有最后一个需要讨论的东西——元素缓冲对象(Element Buffer Object,EBO),也叫索引缓冲对象(Index Buffer Object,IBO)。假设我们不再绘制一个三角形而是绘制一个矩形。我们可以绘制两个三角形来组成一个矩形(OpenGL主要处理三角形)。这会生成下面的顶点的集合:
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
可以看到,有几个顶点叠加了。我们指定了
右下角
和左上角
两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。当我们有包括上千个三角形的模型之后这个问题会更糟糕,这会产生一大堆浪费。更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。这样子我们只要储存4个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。如果OpenGL提供这个功能就好了,对吧?
索引绘制:
在传统的非索引绘制中(例如使用 glDrawArrays
),你需要为每个图元(例如三角形)指定所有顶点。如果一个顶点被多个图元共享,那么就需要重复存储该顶点的数据。这会浪费内存和带宽。
索引绘制通过引入一个索引数组来解决这个问题。索引数组存储的是顶点数组的索引,而不是顶点本身。绘制时,OpenGL 根据索引数组中的索引来查找对应的顶点,从而避免了重复存储顶点数据。
EBO:
EBO 就是用来存储这个索引数组的缓冲区对象。它与 VBO 类似,都是在 GPU 内存中存储数据的缓冲区,但存储的数据类型不同。
工作流程:
- 定义(不重复的)顶点,和绘制出矩形所需的索引数组
float vertices[] = { 0.5f, 0.5f, 0.0f, // 右上角 0.5f, -0.5f, 0.0f, // 右下角 -0.5f, -0.5f, 0.0f, // 左下角 -0.5f, 0.5f, 0.0f // 左上角 }; unsigned int indices[] = { // 注意索引从0开始! // 此例的索引(0,1,2,3)就是顶点数组vertices的下标, // 这样可以由下标代表顶点组合成矩形 0, 1, 3, // 第一个三角形 1, 2, 3 // 第二个三角形 };
- 创建元素缓冲对象
unsigned int EBO;
glGenBuffers(1, &EBO);
- 绑定与填充 EBO(缓冲区类型为:
GL_ELEMENT_ARRAY_BUFFER
)glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
- 用
glDrawElements
来替换glDrawArrays
函数,glDrawElements
函数会根据当前绑定的 EBO 中的索引来查找 VBO 中的顶点数据,并进行绘制。GLenum mode
:指定要绘制的图元类型,与glDrawArrays
相同(例如GL_TRIANGLES
、GL_QUADS
等)。GLsizei count
:指定要绘制的索引数量。GLenum type
:指定索引数据的类型,通常是GL_UNSIGNED_INT
或GL_UNSIGNED_SHORT
。const void* indices
:指向索引数据的指针,这个参数是一个偏移量,表示索引数据在 EBO 中的起始位置。
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); // void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices); glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
顶点数组对象(VAO)也会跟踪元素缓冲区对象(EBO)的绑定,因此不需要在每次绘制前都显式地绑定 EBO,只需要绑定对应的 VAO 即可。
// ..:: 初始化代码 :: ..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
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);
当目标是
GL_ELEMENT_ARRAY_BUFFER
的时候,VAO会储存glBindBuffer
的函数调用。这也意味着它也会储存解绑调用,所以确保你没有在解绑VAO之前解绑索引数组缓冲,否则它就没有这个EBO配置了。
VBO 是允许在 VAO 绑定过程之中解绑的(看注释):
glBindVertexArray(VAO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
glBindBuffer(GL_ARRAY_BUFFER, 0);
// remember: do NOT unbind the EBO while a VAO is active as the bound element buffer object IS stored in the VAO; keep the EBO bound.
//glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
// You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
glBindVertexArray(0);
此时可以得到一个矩形(源码:Code Viewer. Source code: src/1.getting_started/2.2.hello_triangle_indexed/hello_triangle_indexed.cpp):
可以使用线框模式渲染(取消对 140 行的注释):
练习
First
目标: 添加更多顶点到数据中,使用 glDrawArrays
,尝试绘制两个彼此相连的三角形:参考解答
Second
目标: 创建相同的两个三角形,但对它们的数据使用不同的VAO和VBO:参考解答
Third
目标: 创建两个着色器程序,第二个程序使用一个不同的片段着色器,输出黄色;再次绘制这两个三角形,让其中一个输出为黄色:参考解答
着色器
- 原文链接: 着色器 - LearnOpenGL CN
- 总结: 介绍了 glsl 相关语法,抽象了一个 shader 类用于处理着色器的链接与编写
基础概念
- 着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。
- 从基本意义上来说,着色器只是一种把输入转化为输出的程序。
- 着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。
GLSL
基本
- OpenGL着色器是使用一种叫GLSL的类C语言写成的。
- GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。
结构
- 着色器的开头总是要声明版本
- 接着是输入和输出变量、uniform和main函数。
一个典型的着色器有下面的结构:
#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;
数据类型
- 和其他编程语言一样,GLSL有数据类型可以来指定变量的种类。
- GLSL中包含C等其它语言大部分的默认基础数据类型:
int
、float
、double
、uint
和bool
。 - GLSL也有两种容器类型,它们会在这个教程中使用很多,分别是向量(Vector)和矩阵(Matrix)。
Tips: 旧版教程OpenGL - LearnOpenGL-CN中介绍了OpenGL定义的基元类型:
struct object_name {
GLfloat option1;
GLuint option2;
GLchar[] name;
};
基元类型(Primitive Type)
使用OpenGL时,建议使用OpenGL定义的基元类型。比如使用
float
时我们加上前缀GL
(因此写作GLfloat
)。int
、uint
、char
、bool
等等也类似。OpenGL定义的这些GL基元类型的内存布局是与平台无关的,而int等基元类型在不同操作系统上可能有不同的内存布局。使用GL基元类型可以保证你的程序在不同的平台上工作一致。
向量
GLSL中的向量是一个可以包含有2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(
n
代表分量的数量):
类型 | 含义 |
---|---|
vecn | 包含n 个float分量的默认向量 |
bvecn | 包含n 个bool分量的向量 |
ivecn | 包含n 个int分量的向量 |
uvecn | 包含n 个unsigned int分量的向量 |
dvecn | 包含n 个double分量的向量 |
- 一个向量的分量可以通过
vec.x
这种方式获取,这里x
是指这个向量的第一个分量。 - 可以分别使用
.x
、.y
、.z
和.w
来获取它们的第1、2、3、4个分量。 - GLSL也允许你对颜色使用
rgba
,或是对纹理坐标使用stpq
访问相同的分量。
vec3 myVector = vec3(1.0, 2.0, 3.0);
float x = myVector.x;
float y = myVector.y;
float z = myVector.z;
vec4 myColor = vec4(0.8, 0.2, 0.5, 1.0);
float red = myColor.r;
float green = myColor.g;
float blue = myColor.b;
float alpha = myColor.a;
- 向量这一数据类型也允许一些有趣而灵活的分量选择方式,叫做重组(Swizzling)。重组允许这样的语法:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
- 可以使用上面4个字母任意组合来创建一个和原来向量一样长的(同类型)新向量,只要原来向量有那些分量即可
- 然而,不允许在一个
vec2
向量中去获取.z
元素。 - 也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:
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
关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。但在顶点和片段着色器中会有点不同。
顶点着色器
- 顶点着色器接收的是顶点数据中的顶点属性,为了定义顶点数据该如何管理,我们使用
location
这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。 - 我们已经在前面的教程看过这个了,
layout (location = 0)
。 - 顶点着色器需要为它的输入提供一个额外的
layout
标识,这样我们才能把它链接到顶点数据。
你也可以忽略
layout (location = 0)
标识符,通过在OpenGL代码中使用glGetAttribLocation
查询属性位置值(Location),但是我更喜欢在着色器中设置它们,这样会更容易理解而且节省你(和OpenGL)的工作量。
GLint glGetAttribLocation(GLuint program, const GLchar *name);
GLuint program
: 着色器程序对象的 IDconst GLchar* name
: 属性变量的名称,这个名称必须与顶点着色器中声明的属性变量的名称完全一致。
片段着色器
- 需要一个
vec4
颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。 - 如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。
Tips:片段着色器可以有大于 1 个的输出,也通过 layout(location = 0)
指定,这被称为多重渲染目标 (Multiple Render Targets, MRT)。
数据传递
- 如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类型和名称一模一样的输入。
- 在链接程序对象时,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了。
为了展示这是如何工作的,我们会稍微改动一下之前教程里的那个着色器,让顶点着色器为片段着色器决定颜色。
顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
void main()
{
gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
片段着色器
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
void main()
{
FragColor = vertexColor;
}
我们在顶点着色器中声明了一个
vertexColor
变量作为vec4
输出,并在片段着色器中声明了一个类似的vertexColor
。
由于它们名字相同且类型相同,片段着色器中的vertexColor
就和顶点着色器中的vertexColor
链接了。
由于我们在顶点着色器中将颜色设置为深红色,最终的片段也是深红色的
UniForm
- Uniform是另一种从我们的应用程序在 CPU 上传递数据到 GPU 上的着色器的方式,但uniform和顶点属性有些不同。
- 首先,uniform是全局的(Global)。
- 全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
- 第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。
要在 GLSL 中声明 uniform,我们只需在着色器中使用 uniform
关键字,并带上类型和名称,然后我们就可以在着色器中使用新声明的 uniform。
通过uniform设置三角形的颜色:
片段着色器
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量
void main()
{
FragColor = ourColor;
}
- 在片段着色器中声明了一个uniform
vec4
的ourColor,并把片段着色器的输出颜色设置为uniform值的内容。 - 因为uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。
- 顶点着色器中不需要这个uniform,所以我们不用在那里定义它。
此时这个uniform还是空的,我们需要给它添加数据:
- 首先需要找到着色器中uniform属性的索引/位置值。
- 当我们得到uniform的索引/位置值后,就可以更新它的值了。
这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:
// 通过glfwGetTime()获取运行的秒数
float timeValue = glfwGetTime();
// 使用sin函数让颜色在0.0到1.0之间改变
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
// glGetUniformLocation查询uniform ourColor的位置值,返回-1就代表没有找到这个uniform变量
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
// 通过glUniform4f函数设置uniform值
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
注意: 查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先使用程序(调用 glUseProgram
),因为它是在当前激活的着色器程序中设置uniform的。
因为OpenGL在其核心是一个C库,所以它不支持类型重载,在函数参数不同的时候就要为其定义新的函数;glUniform
是一个典型例子。这个函数有一个特定的后缀,标识设定的uniform的类型。可能的后缀有:
后缀 | 含义 |
---|---|
f | 函数需要一个float作为它的值 |
i | 函数需要一个int作为它的值 |
ui | 函数需要一个unsigned int作为它的值 |
3f | 函数需要3个float作为它的值 |
fv | 函数需要一个float向量/数组作为它的值 |
每当你打算配置一个OpenGL的选项时就可以简单地根据这些规则选择适合你的数据类型的重载函数。在我们的例子里,我们希望分别设定uniform的4个float值,所以我们通过 glUniform4f
传递我们的数据(注意,我们也可以使用 fv
版本)。
现在你知道如何设置uniform变量的值了,我们可以使用它们来渲染了。如果我们打算让颜色慢慢变化,我们就要在游戏循环的每一次迭代中(所以他会逐帧改变)更新这个uniform,否则三角形就不会改变颜色。下面我们就计算greenValue然后每个渲染迭代都更新这个uniform:
while(!glfwWindowShouldClose(window))
{
// 输入
processInput(window);
// 渲染
// 清除颜色缓冲
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 记得激活着色器
glUseProgram(shaderProgram);
// 更新uniform颜色
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);
// 绘制三角形
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
// 交换缓冲并查询IO事件
glfwSwapBuffers(window);
glfwPollEvents();
}
这里的代码对之前代码是一次非常直接的修改。这次,我们在每次迭代绘制三角形前先更新uniform值。如果你正确更新了uniform,你会看到你的三角形逐渐由绿变黑再变回绿色。
源码: Code Viewer. Source code: src/1.getting_started/3.1.shaders_uniform/shaders_uniform.cpp
- 可以看到,uniform对于设置一个在渲染迭代中会改变的属性是一个非常有用的工具,它也是一个在程序和着色器间数据交互的很好工具,但假如我们打算为每个顶点设置一个颜色的时候该怎么办?
这种情况下,我们就不得不声明和顶点数目一样多的uniform了。- 在这一问题上更好的解决方案是在顶点属性中包含更多的数据,这是我们接下来要做的事情。
更多属性!
- 在前面的教程中,我们了解了如何填充 VBO、配置顶点属性指针以及如何把它们都储存到一个 VAO 里。
- 这次,我们打算把颜色数据加进顶点数据中。把颜色数据添加为 3 个 float 值至 vertices 数组,指定三个顶点分别为红色、绿色和蓝色
float vertices[] = {
// 位置 // 颜色
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶部
};
调整顶点着色器,用 layout
标识符把 aColor
属性的位置值设置为1:
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1
out vec3 ourColor; // 向片段着色器输出一个颜色
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}
取消片段着色器的 uniform 变量
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
void main()
{
FragColor = vec4(ourColor, 1.0);
}
添加了另一个顶点属性 aColor
,更新了VBO的内存,必须重新配置顶点属性指针。更新后的VBO内存中的数据现在看起来像这样:
知道了现在 VBO 内存的布局,我们就可以更新 glVertexAttribPointer
函数以让 GPU 成功解析顶点数据得到两个顶点属性:
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);
此处与[[LearnOpenGL——入门#^86109c]]举的例子一致,不再赘述。
运行程序会得到如下结果:
源码: Code Viewer. Source code: src/1.getting_started/3.2.shaders_interpolation/shaders_interpolation.cpp
我们只输入了顶点的三个颜色值,但是在三角形内部却表现出了平滑的颜色过渡。这是 片段插值 的功劳。
- 在顶点着色器与片段着色器之间,会发生光栅化
- 顶点着色器处理每个顶点,并输出变换后的顶点坐标。
- 光栅化器接收顶点着色器的输出,并根据图元的类型(例如
GL_TRIANGLES
)将图元分解成一系列的片段。每个片段对应屏幕上的一个像素,但它包含的信息比像素更多,例如深度值、纹理坐标等。 - 片段插值是光栅化过程中的一个关键步骤。它负责计算每个片段的属性值,根据每个片段在图元上的位置,对顶点属性进行插值,插值算法通常是线性的。
- 如果线段的一个端点是绿色,另一个端点是蓝色,那么线段中间的片段颜色就是绿色和蓝色的线性混合。在线段 70% 的位置的片段,其颜色就是 70% 绿色 + 30% 蓝色。
- 所有从顶点着色器传递到片段着色器的变量都会经过插值。这包括颜色、纹理坐标、法线、或其他任何你定义的顶点属性。
着色器类
- 编写、编译、管理着色器是件麻烦事,我们可以抽象出一个 Shader类专门用于管理这个流程
- 它可以从硬盘读取着色器,然后编译并链接它们,并对它们进行错误检测
Shader.h
#pragma once
#include <glad/glad.h>;
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
class Shader
{
public:
// 程序ID
unsigned int ID;
// 构造器读取并构建着色器
Shader(const char* vertexPath, const char* fragmentPath);
// 使用/激活程序
void use();
// 删除程序
void deactivate();
// uniform工具函数
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;
};
Shader.cpp
#include "Shader.h"
Shader::Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. 从文件路径中获取顶点/片段着色器
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// 保证ifstream对象可以抛出异常:
vShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
fShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
try
{
// 打开文件
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
// 读取文件的缓冲内容到数据流中
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// 关闭文件处理器
vShaderFile.close();
fShaderFile.close();
// 转换数据流到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. 编译着色器
unsigned int vertex, fragment;
int success;
char infoLog[512];
// 顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// 打印编译错误(如果有的话)
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};
// 片段着色器
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fShaderCode, NULL);
glCompileShader(fragment);
// 打印编译错误(如果有的话)
glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragment, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
};
// 着色器程序
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// 打印连接错误(如果有的话)
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(ID, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// 删除着色器,它们已经链接到我们的程序中了,已经不再需要了
glDeleteShader(vertex);
glDeleteShader(fragment);
}
void Shader::use()
{
glUseProgram(ID);
}
void Shader::deactivate()
{
glDeleteProgram(ID);
}
void Shader::setBool(const std::string& name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void Shader::setInt(const std::string& name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void Shader::setFloat(const std::string& name, float value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
使用
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
...
while(...)
{
ourShader.use();
ourShader.setFloat("someUniform", 1.0f);
DrawStuff();
}
ourShader.deactivate();
源码: 着色器 - GitCode
练习
First
目标: 修改顶点着色器让三角形上下颠倒:参考解答
Seconde
目标: 使用uniform定义一个水平偏移量,在顶点着色器中使用这个偏移量把三角形移动到屏幕右侧:参考解答
Third
目标: 使用out
关键字把顶点位置输出到片段着色器,并将片段的颜色设置为与顶点位置相等(来看看连顶点位置值都在三角形中被插值的结果)。做完这些后,尝试回答下面的问题:为什么在三角形的左下角是黑的?:参考解答
纹理
- 原文链接: 纹理 - LearnOpenGL CN
- 总结: 介绍了纹理相关的知识,在 OpenGL 中加载纹理为渲染增加更多的细节
基础概念
- 我们可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。
- 但是,如果想让图形看起来更真实,
我们就必须有足够多的顶点,从而指定足够多的颜色- 这将会产生很多额外开销,因为每个模型都会需求更多的顶点,每个顶点又需求一个颜色属性。
- 艺术家和程序员更喜欢使用纹理(Texture)。
纹理:
- 纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节
- 可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的3D的房子上,这样你的房子看起来就像有砖墙外表了。
- 我们可以在纹理中插入非常多的细节,从而让物体表现得非常精致,但实际上并没有对物体指定额外的顶点。
这是一张砖墙纹理:
如果把它应用到前面的三角形上,会表现成如下的效果:
- 为了能够把纹理映射(Map)到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。
- 这个过程就是 纹理映射,将图像(称为纹理)粘贴到3D模型表面,为模型上的每个像素计算出对应的纹理坐标。
- 这样每个顶点就会关联着一个 纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样(Sampling)。
- 纹理采样是根据计算出的纹理坐标,从纹理图像中获取颜色值的过程。简单来说,就是查找纹理图像上对应位置的颜色。
- 三角形顶点之间部分的纹理坐标会通过前面讲过的片段插值得到。
纹理坐标:
- 纹理坐标通常使用 U 和 V 两个分量表示,范围从 0 到 1。U 表示纹理的水平方向,V 表示纹理的垂直方向。
- 纹理坐标起始于(0, 0),也就是纹理图片的左下角,终止于(1, 1),即纹理图片的右上角。
下面的图片展示了我们是如何把纹理坐标映射到三角形上的。
- 一共为三角形指定了3个纹理坐标点。
- 如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角形左下角顶点的纹理坐标设置为(0, 0);
- 同理右下方的顶点设置为(1, 0);
- 三角形的上顶点对应于图片的上中位置所以我们把它的纹理坐标设置为(0.5, 1.0)。
- 我们只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传到片段着色器中,它会为每个片段进行纹理坐标的插值。
纹理坐标看起来就像这样:
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};
对纹理采样的解释非常宽松,它可以采用几种不同的插值方式。所以我们需要自己告诉OpenGL该怎样对纹理采样。
纹理环绕方式
- 纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?
- OpenGL默认的行为是重复这个纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:
环绕方式 | 描述 |
---|---|
GL_REPEAT | 对纹理的默认行为。重复纹理图像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE | 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐标为用户指定的边缘颜色。 |
当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:
// 1. GL_REPEAT (重复)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 2. GL_MIRRORED_REPEAT (镜像重复)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
// 3. GL_CLAMP_TO_EDGE (边缘钳制)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 4. GL_CLAMP_TO_BORDER (边框钳制)
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f }; // 设置边框颜色为黄色 (R, G, B, A)
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GL_TEXTURE_2D
代表我们使用的纹理类型是 2D 的,第二个参数代表纹理在 S 轴(通常对应于 U 坐标,即水平方向)上或在 T 轴(通常对应于 V 坐标,即垂直方向)上的环绕方式。
纹理过滤 (Texture Filtering)
首先理解一下纹理像素(Texture Pixel) 的基本概念:Texture Pixel 也叫 Texel,它是 纹理图像 上的像素。你可以想象一张位图图像,不断放大后看到的最小单元就是 Texel。它存储了纹理在该位置的颜色和其他信息。注意不要和屏幕像素搞混,Texel 是纹理图像上的数据单元。
再来看一下纹理采样的过程:根据计算出的纹理坐标,从纹理图像中 确定 最终用于渲染的颜色值的过程。纹理坐标通常使用 U 和 V 两个分量表示,范围从 0 到 1,分别对应纹理的水平和垂直方向。纹理坐标是你给模型顶点设置的那个数组,OpenGL 以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色
纹理坐标是标准化的,范围通常在 0.0 到 1.0 之间,它们与纹理的分辨率无关。这意味着无论纹理是 256x256 像素还是 1024x1024 像素,纹理坐标 (0.5, 0.5) 始终指向纹理的中心
我们如何根据一个标准化的纹理坐标得到一个颜色值呢?如果一个纹理坐标刚好对应一个纹理像素的中心位置,那么颜色值无可争议的就为这个纹理像素的颜色。但在实际场景中,纹理坐标很少直接对应到一个纹理像素的中心点,尤其是在纹理被放大或缩小时,就会出现失真。那么就需要采取一定的策略来决定此时纹理采样得到的颜色。这个决策就是 纹理过滤(Texture Filtering) 方式。纹理过滤用于解决纹理放大和缩小带来的问题,常见的过滤方式有 GL_NEAREST
(最近邻过滤,速度快但效果差)和 GL_LINEAR
(线性过滤,效果较好)。
GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:
那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):
GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。
我们可以对纹理放大(Magnify)和缩小(Minify)分别设置不同的纹理过滤方式,比如可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。
我们需要使用
glTexParameter*
函数为放大和缩小指定纹理过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多级渐远纹理(Mipmap)
想象一个场景:一个包含成千上万个物体的巨大房间,每个物体都贴有纹理。有些物体距离观察者很远,但仍然使用与近处物体相同的高分辨率纹理。由于远处物体在屏幕上可能只占据极少的像素(对应极少的片段),OpenGL 在处理这些片段时,需要从高分辨率纹理中提取颜色信息,这会变得非常困难。本质上,OpenGL 需要为一个覆盖纹理图像很大区域的片段,仅采样一个纹理颜色。这种做法会导致远处物体出现失真,例如细节丢失、锯齿或闪烁,从而产生不真实感。更重要的是,为这些小物体使用高分辨率纹理是对内存和带宽的极大浪费。
这就引出了纹理渲染中一个经典的问题,即 纹理缩小(Texture Minification) 或 欠采样(Undersampling)。当一个纹理被映射到屏幕上非常小的区域时(例如远处的物体),多个纹理像素(texel)会被映射到同一个屏幕像素。如果直接使用原始的高分辨率纹理进行采样,就会出现以下问题:
- 锯齿(Aliasing): 纹理细节丢失,出现明显的锯齿状边缘或闪烁。
- 摩尔纹(Moire patterns): 在重复的纹理图案中,会出现不自然的波纹或图案。
- 浪费内存和带宽: 即使远处物体只占用屏幕上几个像素,仍然需要加载和处理高分辨率纹理,造成不必要的开销。
为了解决远处物体纹理失真的问题,OpenGL 采用了一种称为 多级渐远纹理(Mipmap) 的技术。Mipmap 是一系列预先计算好的纹理图像集合,每个后续的图像在宽度和高度上都是前一个图像的一半大小,形成一个“金字塔”结构。这意味着,后续图像的每个像素,都是前一个图像中对应区域的四个像素的平均值计算得来的。例如,如果前一个图像的某个 2x2 像素区域的颜色分别是红、绿、蓝、黑,那么后续图像中对应位置的像素颜色就是这四种颜色的平均值。其核心思想是:根据物体与观察者的距离,OpenGL 会自动选择最合适的 mipmap 级别进行纹理采样。当物体距离较远时,OpenGL 会使用分辨率较低的 mipmap 图像,这样既能避免远处物体出现锯齿、闪烁等失真现象,又能显著提高渲染性能,并且由于物体在远处看起来较小,较低的分辨率也不会被用户察觉。
让我们看一下多级渐远纹理是什么样子的:
手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个
glGenerateMipmap
函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。
如何选取多级渐远纹理级别(Level),从而确定使用哪一种分辨率的纹理?同样有两种方式,一种是直接选取最邻近的多级渐远纹理级别,一种是在两个最匹配的多级渐远纹理之间进行线性插值。结合纹理过滤的方式,一共有 4 种方式对纹理采样过程进行配置:
过滤方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样 |
GL_LINEAR_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样 |
GL_NEAREST_MIPMAP_LINEAR | 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样 |
GL_LINEAR_MIPMAP_LINEAR | 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样 |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个
GL_INVALID_ENUM
错误代码。
加载与创建纹理
使用纹理之前要做的第一件事是把它们加载到我们的应用中。纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列,所以我们如何才能把这些图像加载到应用中呢?一个解决方案是选一个需要的文件格式,比如
.PNG
,然后自己写一个图像加载器,把图像转化为字节序列。写自己的图像加载器虽然不难,但仍然挺麻烦的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。另一个解决方案也许是一种更好的选择,使用一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说我们要用的
stb_image.h
库。
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
将会用图像的宽度、高度和颜色通道的个数填充这三个变量。我们之后生成纹理的时候会用到的图像的宽度和高度的。
这里的配置过程挺简单的,但我同样在本地做了一些额外操作,将 stb_image.h
放进了 C:\Program Files\OpenGL_Lib\stb
路径下,然后将 stb_image.h
与 stb_image.cpp
放到了一个新工程,生成了 stb_image.lib
,并将这个包含在了我的 OpenGL 项目中。这样就避免了新建一个 cpp 的操作(似乎并没有更方便?)。 stb_image.cpp
内容如下:
#define STB_IMAGE_IMPLEMENTATION
#include "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);
- 第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
- 第二个参数指定 mipmap 的级别。
0
表示基本图像级别(即原始图像)。后续的 mipmap 级别依次为 1、2、3…。如果你手动创建了不同的 mipmap 级别,可以使用这个参数来指定要加载到哪个级别。通常我们使用glGenerateMipmap
自动生成,所以这里使用0
- 第三个参数指定纹理的内部格式。它告诉 OpenGL 如何在内部存储纹理数据。
GL_RGB
表示纹理的每个像素由红、绿、蓝三个分量组成。其他常见的内部格式包括GL_RGBA
(红、绿、蓝、alpha)和GL_SRGB
(用于存储 sRGB 颜色空间的纹理)。 - 第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
- 第六个参数指定边框宽度(border)。在现代 OpenGL 中,这个参数通常设置为
0
。 - 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组。
- 第九个参数指向图像数据的指针。这是一个包含实际图像数据的字节数组。
当调用
glTexImage2D
时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap
。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。
生成了纹理和相应的多级渐远纹理后,释放图像的内存是一个很好的习惯。
stbi_image_free(data);
生成一个纹理的过程应该看起来像这样:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 为当前绑定的纹理对象设置环绕、过滤方式
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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加载并生成纹理
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);
应用纹理
后面的这部分我们会使用glDrawElements绘制「你好,三角形」教程最后一部分的矩形。我们需要告知OpenGL如何采样纹理,所以我们必须使用纹理坐标更新顶点数据:
float vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};
由于我们添加了一个额外的顶点属性,我们必须告诉OpenGL我们新的顶点格式:
// Position
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(0));
glEnableVertexAttribArray(0);
// Color
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
// 纹理
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:
#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
把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个uniform。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
我们使用GLSL内建的texture函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。
现在只剩下在调用glDrawElements之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
如果你跟着这个教程正确地做完了,你会看到下面的图像:
源码: Code Viewer. Source code: src/1.getting_started/4.1.textures/textures.cpp
我们还可以把得到的纹理颜色与顶点颜色混合,来获得更有趣的效果。我们只需把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
最终的效果应该是顶点颜色和纹理颜色的混合色:
你可能对 sampler2D
变量是 uniform
,但我们并没有使用 glUniform
函数直接给它赋值而感到疑惑。实际上,我们使用 glUniform1i
函数来为纹理采样器分配一个 纹理单元(Texture Unit) 的索引值。纹理单元可以看作是纹理的“插槽”,通过分配不同的索引值,我们可以在一个片段着色器中使用多个纹理。
默认情况下,纹理单元 0 是激活的,这也是为什么在之前的教程中我们没有显式地分配纹理单元索引。然而,要使用多个纹理,我们就需要激活不同的纹理单元,并将相应的纹理绑定到这些单元上。
使用 glActiveTexture
函数可以激活一个纹理单元,它接受一个枚举值作为参数,例如 GL_TEXTURE0
、GL_TEXTURE1
、GL_TEXTURE2
等。激活纹理单元后,任何后续的 glBindTexture
调用都会将纹理绑定到 当前激活的纹理单元。
例如,以下代码展示了如何激活纹理单元 0 并绑定一个 2D 纹理:
glActiveTexture(GL_TEXTURE0); // 激活纹理单元 0
glBindTexture(GL_TEXTURE_2D, texture); // 将纹理 texture 绑定到纹理单元 0
在着色器中,sampler2D
uniform 变量的值就是纹理单元的索引。因此,当着色器执行 texture(ourTexture, TexCoord)
时,它会使用 ourTexture
对应的纹理单元索引去查找绑定的纹理,并根据 TexCoord
进行采样。
激活纹理单元之后,接下来的 glBindTexture
函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元 GL_TEXTURE0
默认总是被激活,所以在前面的例子里当我们使用 glBindTexture
的时候,无需激活任何纹理单元。
uniform sampler2D ourTexture;
若未对 ourTexture
进行显示赋值,那么 ourTexture
就会是其默认值 0,而 GL_TEXTURE0
的纹理单元索引值恰恰为 0,所以我们也不需要通过 glUniform1i
函数来设置 ourTexture = 0
。
OpenGL至少保证有16个纹理单元供你使用,也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按顺序定义的,所以我们也可以通过GL_TEXTURE0 + 8的方式获得GL_TEXTURE8,这在当我们需要循环一些纹理单元的时候会很有用。
请注意 textureID
与 sampler2D
不一致:
textureID
是通过glGenTextures
生成的,表示一个纹理对象。- 它只是一个整数,用于标识 OpenGL 内部的一个纹理对象。
- 你可以将多个纹理对象(多个
textureID
)绑定到不同的纹理单元(Texture Unit)上。
sampler2D
是片段着色器中的一个变量,它并不直接引用textureID
,而是引用一个纹理单元(Texture Unit)。- 纹理单元是 OpenGL 中的一个抽象概念,它是一个槽位(Slot),可以绑定一个纹理对象(
textureID
)。 - OpenGL 有多个纹理单元(例如
GL_TEXTURE0
,GL_TEXTURE1
, …,GL_TEXTURE15
),每个纹理单元可以绑定一个纹理对象。
- 纹理单元是 OpenGL 中的一个抽象概念,它是一个槽位(Slot),可以绑定一个纹理对象(
接下来将再引入一个纹理渲染在矩形上。
我们仍然需要编辑片段着色器来接收另一个采样器。这应该相对来说非常直接了:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
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 int texture[2];
glGenTextures(2, texture);
glBindTexture(GL_TEXTURE_2D, texture[0]);
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);
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 1" << std::endl;
}
stbi_image_free(data);
glBindTexture(GL_TEXTURE_2D, texture[1]);
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);
data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture 2" << std::endl;
}
stbi_image_free(data);
注意: 我们现在要读取一张包含alpha(透明度)通道的 awesomeface.png
图片,这意味着我们现在需要使用GL_RGBA参数,指定该图片数据包含了alpha通道;否则OpenGL将无法正确解析图片数据。
为了使用第二个纹理(以及第一个),我们必须改变一点渲染流程,先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture[0]);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture[1]);
我们还要通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元。我们只需要设置一次即可,所以这个会放在渲染循环的前面:
ourShader.use(); // 不要忘记在设置uniform变量之前激活着色器程序!
ourShader.setInt("texture1", 0);
ourShader.setInt("texture2", 1);
while(...)
{
[...]
}
通过使用glUniform1i设置采样器,我们保证了每个uniform采样器对应着正确的纹理单元。你应该能得到下面的结果:
你可能注意到纹理上下颠倒了!这是因为OpenGL要求y轴
0.0
坐标是在图片的底部的,但是图片的y轴0.0
坐标通常在顶部。很幸运,stb_image.h
能够在图像加载时帮助我们翻转y轴,只需要在加载任何图像前加入以下语句即可:
stbi_set_flip_vertically_on_load(true);
源码: 纹理 - GitCode
练习
First
目标: 修改片段着色器,仅让笑脸图案朝另一个方向看,参考解答
Second
目标: 尝试用不同的纹理环绕方式,设定一个从 0.0f
到 2.0f
范围内的(而不是原来的 0.0f
到 1.0f
)纹理坐标。试试看能不能在箱子的角落放置4个笑脸:参考解答,结果。记得一定要试试其它的环绕方式。
Third
目标: 尝试在矩形上只显示纹理图像的中间一部分,修改纹理坐标,达到能看见单个的像素的效果。尝试使用GL_NEAREST的纹理过滤方式让像素显示得更清晰:参考解答
Fourth
目标: 使用一个uniform变量作为mix函数的第三个参数来改变两个纹理可见度,使用上和下键来改变箱子或笑脸的可见度:参考解答。
变换
- 原文链接: 变换 - LearnOpenGL CN
- 总结: 介绍了向量、矩阵等基本数学概念,使用 GLM 实现基本变换操作
原文中的数学知识介绍省略,直接跳到 GLM 的使用。
GLM
GLM是OpenGL Mathematics的缩写,它是一个只有头文件的库,也就是说我们只需包含对应的头文件就行了,不用链接和编译。GLM可以在它们的网站上下载。把头文件的根目录复制到你的includes文件夹,然后你就可以使用这个库了。
注意: GLM库从0.9.9版本起,默认会将矩阵类型初始化为一个零矩阵(所有元素均为0),而不是单位矩阵(对角元素为1,其它元素为0)。如果你使用的是0.9.9或0.9.9以上的版本,你需要将所有的矩阵初始化改为
glm::mat4 mat = glm::mat4(1.0f)
。如果你想与本教程的代码保持一致,请使用低于0.9.9版本的GLM,或者改用上述代码初始化所有的矩阵。
我们需要的GLM的大多数功能都可以从下面这3个头文件中找到:
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
基本变换:
// 0.9.9及以上版本记得初始化
glm::mat4 trans = glm::mat4(1.0f);
// 平移变换
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
// 旋转变换 glm::radians将度数转化为弧度
trans = glm::rotate(trans, glm::radians(90.0f), glm::vec3(0.0, 0.0, 1.0));
// 缩放变换
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
注意最后的 trans = 单位矩阵 * 平移 * 旋转 * 缩放,最后应用到物体上变换顺序是 缩放-> 旋转 -> 平移,这个顺序很重要!
将箱子先缩放到原来的 1/2,然后逆时针旋转 45°。
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 transform;
void main()
{
gl_Position = transform * vec4(aPos, 1.0f);
TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
}
// 别忘了在之前 glUseProgram
glm::mat4 trans(1.f);
trans = glm::rotate(trans, glm::radians(45.0f), glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
// glm::value_ptr(trans) = &trans[0][0]
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
glUniformMatrix4fv
:
- 第一个参数是uniform的位置值
- 第二个参数告诉OpenGL我们将要发送多少个矩阵,这里是1。
- 第三个参数询问我们是否希望对我们的矩阵进行转置(Transpose),也就是说交换我们矩阵的行和列。
- OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局。
- GLM的默认布局就是列主序,所以并不需要转置矩阵,我们填
GL_FALSE
。 - 列主序意味着使用列向量
- 第四个参数需要传入矩阵数据的起始内存地址
template<typename T, qualifier Q> GLM_FUNC_QUALIFIER T* value_ptr(mat<4, 4, T, Q>& m) { return &(m[0].x); }
此时效果如下:
源码: Code Viewer. Source code: src/1.getting_started/5.1.transformations/transformations.cpp
若一次性传入两个矩阵,则代码如下:
uniform mat4 transforms[2]; // 声明一个包含两个 mat4 的数组
glm::mat4 trans1(1.0f);
glm::mat4 trans2(1.0f);
// 在同一个数组内的 uniform 的索引是递增的,并且在内存中是连续存储的。
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans1)); // 上传 transforms[0]
glUniformMatrix4fv(transformLoc + 1, 1, GL_FALSE, glm::value_ptr(trans2)); // 上传 transforms[1]
// 一次性上传
glm::mat4 transforms[2] = {trans1, trans2};
// glm::value_ptr(transforms[0]) = &trans[0][0][0]
glUniformMatrix4fv(transformLoc, 2, GL_FALSE, glm::value_ptr(transforms[0]));
让箱子随时间旋转,注意还是先旋转,再平移的!
while(!glfwWindowShouldClose(window))
{
// .........
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f, 0.0f, 1.0f));
// .........
}
练习
First
目标: 使用应用在箱子上的最后一个变换,尝试将其改变为先旋转,后位移。看看发生了什么,试着想想为什么会发生这样的事情:参考解答
Second
目标: 尝试再次调用 glDrawElements
画出第二个箱子,只使用变换将其摆放在不同的位置。让这个箱子被摆放在窗口的左上角,并且会不断的缩放(而不是旋转)。(sin
函数在这里会很有用,不过注意使用 sin
函数时应用负值会导致物体被翻转):参考解答
源码: 变换 - GitCode
坐标系统
- 原文链接: 坐标系统 - LearnOpenGL CN
- 总结: 介绍了渲染管线顶点的坐标变换流程,通过各种变换从 2D 空间的渲染,转到了 3D 空间的渲染。
在上一个教程中,我们学习了如何有效地利用矩阵的变换来对所有顶点进行变换。OpenGL希望在每次顶点着色器运行后,我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在 -1.0 到 1.0 之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标变换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),将它们变换为屏幕上的二维坐标或像素。
这里我需要强调一下,在顶点着色器之内,我们只需要对顶点进行 MVP 变换,即将顶点坐标从局部空间坐标系依次转换到世界空间坐标系、观察空间坐标系,最终转换到裁剪空间坐标系。这时候的坐标仍然是裁剪空间坐标,而不是 NDC 坐标。顶点着色器执行完毕后,OpenGL 会自动执行裁剪操作,将 w 分量不在 [ − w , w ] [-w, w] [−w,w] 范围内的顶点剔除(即不在视锥体内的顶点)。裁剪完成后,OpenGL 紧接着会自动进行透视除法,将裁剪空间坐标转换为 NDC 坐标。教程中将裁剪、透视除法与顶点着色器放在一起描述,是为了方便理解坐标变换的整体流程,但我们需要清楚地认识到,这两个步骤是在顶点着色器之后由 OpenGL 硬件自动完成的。
将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。将物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易,这一点很快就会变得很明显。对我们来说比较重要的总共有5个不同的坐标系统:
- 局部空间(Local Space,或者称为物体空间(Object Space))
- 世界空间(World Space)
- 观察空间(View Space,或者称为视觉空间(Eye Space))
- 裁剪空间(Clip Space)
- 屏幕空间(Screen Space)
这就是一个顶点在最终被转化为片段之前需要经历的所有不同状态。
概述
为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是 模型(Model)、观察(View)、投影(Projection) 三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为 局部坐标(Local Coordinate),它在之后会变为 世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以 屏幕坐标(Screen Coordinate) 的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:
- 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
- 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
- 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
- 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
- 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做 视口变换(Viewport Transform) 的过程。视口变换将位于-1.0到1.0范围的坐标变换到由
glViewport
函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。
- 你可能已经大致了解了每个坐标空间的作用。
- 我们之所以将顶点变换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。
- 例如,当需要对物体进行修改的时候,在局部空间中来操作会更说得通;
- 如果要对一个物体做出一个相对于其它物体位置的操作时,在世界坐标系中来做这个才更说得通,等等。
- 如果我们愿意,我们也可以定义一个直接从局部空间变换到裁剪空间的变换矩阵,但那样会失去很多灵活性。
局部空间
局部空间,也称为物体空间或模型空间,是物体自身内部的坐标系统。它描述了物体相对于其自身原点的几何形状。想象一下你在建模软件(例如 Blender、3ds Max)中创建一个立方体:
- 在创建立方体时,它的顶点坐标是相对于立方体自身中心的。通常,立方体的中心会被放置在局部空间的原点 (0, 0, 0)。
- 立方体的各个顶点相对于这个中心点的位置就被称为局部坐标。例如,一个边长为 1 的立方体,其顶点坐标范围可能在 (-0.5, -0.5, -0.5) 到 (0.5, 0.5, 0.5) 之间。
- 即使这个立方体最终在游戏或渲染场景中处于完全不同的位置,其顶点在局部空间中的坐标始终保持不变。换句话说,局部坐标描述的是物体的 固有属性,与它在场景中的位置无关。
我们之前例子中使用的箱子,其顶点坐标被定义在 -0.5 到 0.5 的范围内,原点位于 (0, 0, 0)。这些坐标就是典型的局部坐标。它们描述了箱子自身的形状和大小,而没有考虑箱子在世界中的位置。
世界空间
当我们把所有模型导入到场景中时,它们的初始位置都在局部空间的原点 (0, 0, 0) 处,这会导致所有物体都重叠在一起,显然不是我们想要的效果。我们需要为每个物体定义一个在场景中的位置,才能将它们合理地摆放在更大的世界中。
世界空间,顾名思义,是描述物体在整个游戏或渲染世界中所处位置的坐标系。它是一个全局坐标系,所有物体都共享同一个世界原点。世界空间中的坐标表示顶点相对于世界原点的绝对位置。为了将物体分散到世界各地(尤其是在创建逼真的场景时),我们需要将物体的坐标从局部空间转换到世界空间。
模型矩阵(Model Matrix) 正是用于执行这种转换的工具。模型矩阵是一个变换矩阵,它通过对物体进行平移(Translation)、旋转(Rotation)和缩放(Scale) 等变换,将其放置在世界中的正确位置、方向和大小。
举个例子,想象一下你要在场景中放置一座房子:
- 房子在建模软件中创建时,可能尺寸很大,并且中心位于局部原点。
- 使用模型矩阵,你可以先缩放房子,使其尺寸符合场景比例。
- 然后,你可以平移房子,将其移动到郊区小镇的某个具体位置。
- 最后,你还可以旋转房子,使其朝向与周围的房屋一致。
之前我们学习的将箱子摆放在场景不同位置的矩阵,实际上就是一种模型矩阵。它将箱子的局部坐标转换到世界空间中的不同位置,从而实现了箱子在场景中的摆放。
观察空间
观察空间,也常被称为摄像机空间或视觉空间,是相对于摄像机或观察者的坐标系。它将世界空间中的物体变换到以摄像机为原点的坐标系中,从而模拟了我们从摄像机角度观察世界的效果。
更具体地说,观察空间将摄像机放置在原点 (0, 0, 0),并通常将其正 Z 轴指向观察者的后方(即 -Z 轴指向观察方向),+Y 轴指向上方,+X 轴指向右方,构成一个右手坐标系。因此,观察空间中的坐标表示物体相对于摄像机的位置和方向。
将世界坐标转换到观察坐标的过程是通过 观察矩阵(View Matrix) 来实现的。观察矩阵本质上是世界空间到摄像机空间的逆变换。它通常由一系列的平移(Translation)和旋转(Rotation) 变换组合而成,这些变换模拟了摄像机在世界空间中的位置和朝向。与其说是“平移/旋转场景从而使得特定的对象被变换到摄像机的前方”,不如说是通过逆向变换摄像机的位置和朝向,从而达到相同的效果,即把摄像机放到原点,并调整场景的朝向。
例如,如果摄像机在世界空间中位于 (2, 3, 4),并朝向 (0, 0, 0),那么观察矩阵会将世界原点 (0, 0, 0) 变换到观察空间中的 (-2, -3, -4)。这样就模拟了摄像机位于原点观察世界的效果。
裁剪空间
在顶点着色器执行完毕后,OpenGL 期望所有顶点坐标都位于一个特定的范围内,超出此范围的顶点将被裁剪(Clipped)。被裁剪的顶点将被丢弃,只有剩余的顶点会被用于后续的渲染,最终生成屏幕上可见的片段。裁剪空间正是因此而得名。
由于直接在 [ − 1 , 1 ] [-1, 1] [−1,1] 的范围内指定可见坐标并不直观,我们通常会定义自己的坐标范围(例如一个视锥体),并使用 投影矩阵(Projection Matrix) 将其变换到裁剪空间。投影矩阵定义了观察空间如何映射到裁剪空间。
投影矩阵会将观察空间中的坐标转换到裁剪空间,裁剪空间坐标的分量范围为
[
−
w
,
w
]
[-w, w]
[−w,w],其中 w 是裁剪坐标的 w 分量。OpenGL 会根据 x/w
, y/w
, z/w
是否在
[
−
1
,
1
]
[-1,1]
[−1,1]这个范围内进行裁剪,超出范围的顶点将被剔除。例如,如果使用正交投影矩阵定义了一个 x 轴范围为
[
−
1000
,
1000
]
[-1000, 1000]
[−1000,1000] 的视锥体,那么观察空间中的坐标 (1250, 500, 750) 经过投影变换后,其 x 分量在裁剪空间中除以 w 后绝对值会大于 1,从而被裁剪。
如果一个图元(例如三角形)只有部分超出裁剪体积(Clipping Volume,由裁剪空间定义),OpenGL 会对该图元进行裁剪,将其分割成一个或多个完全位于裁剪体积内的图元。
由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3D坐标投影(Project)到很容易映射到2D的标准化设备坐标系中。
一旦所有顶点都变换到裁剪空间,OpenGL 会自动执行透视除法(Perspective Division)。透视除法会将裁剪空间坐标的 x、y 和 z 分量分别除以其 w 分量,将 4D 裁剪空间坐标转换为 3D 标准化设备坐标(Normalized Device Coordinates,NDC)。NDC 的 x、y 和 z 分量范围为 [ − 1 , 1 ] [-1, 1] [−1,1]。透视除法是硬件在顶点着色器执行 之后、光栅化 之前 自动完成的。
在透视除法之后,NDC 坐标会通过视口变换(Viewport Transform) 映射到屏幕空间,该变换使用 glViewport
函数定义的视口参数。屏幕空间坐标是最终用于在屏幕上绘制像素的 2D 坐标。
将观察坐标变换为裁剪坐标的投影矩阵可以为两种不同的形式,每种形式都定义了不同的平截头体。
- 正交投影矩阵(Orthographic Projection Matrix): 常用于 2D 渲染或不需要透视效果的 3D 场景。
- 透视投影矩阵(Perspective Projection Matrix): 模拟了人眼近大远小的透视效果,更符合我们对 3D 世界的感知。
严格意义上,正交投影的观察箱不是一个平截头体。平截头体指的是圆锥或棱锥被两个平行平面所截后,位于两个平行平面之间的立体,而正交投影的观察箱是一个长方体。所以平截头体这个术语一般用于描述透视投影中的观察体积。
正交投影
正交投影矩阵定义了一个长方体形状的平截头体,它构成了一个裁剪空间。位于该空间之外的顶点将被剔除(裁剪)。要创建正交投影矩阵,需要指定平截头体的宽度(width)、高度(height)以及近平面(Near Plane)和远平面(Far Plane)之间的距离(深度)。经过正交投影矩阵变换后,位于平截头体内部的坐标将保留。这个平截头体就像一个盒子或容器。
上面的平截头体定义了可见的坐标,它由宽、高、近(Near)平面和远(Far)平面所指定。任何出现在近平面之前或远平面之后的坐标都会被裁剪掉。正交平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,因为每个向量的w分量都没有进行改变;如果w分量等于1.0,透视除法则不会改变这个坐标。
要创建一个正交投影矩阵,我们可以使用GLM的内置函数 glm::ortho
:
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
- 第一个参数
left
: 指定了视锥体左边界的x坐标。 - 第二个参数
right
: 指定了视锥体右边界的x坐标。 - 第三个参数
bottom
: 指定了视锥体下边界的y坐标。 - 第四个参数
top
: 指定了视锥体上边界的y坐标。
这四个参数共同定义了视锥体在近平面和远平面上的大小,构成了一个矩形区域。
- 第五个参数
near
: 指定了近平面距离摄像机的距离。 - 第六个参数
far
: 指定了远平面距离摄像机的距离。
这两个参数定义了视锥体的深度范围,即近平面和远平面之间的距离。正交投影的视锥体是一个长方体。
glm::ortho
会将位于上述x、y和z值范围内的坐标变换为标准化设备坐标(NDC)。在NDC空间中,x、y和z的取值范围通常为[-1, 1]。
透视投影
如果你曾经体验过实际生活给你带来的景象,你就会注意到离你越远的东西看起来更小。这个奇怪的效果称之为透视(Perspective)。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:
正如你所见,由于透视现象,两条平行的线在远处看起来会相交于一点(即灭点)。透视投影正是要模拟这种近大远小的视觉效果,这是通过透视投影矩阵来实现的。透视投影矩阵不仅将视锥体(平截头体)的范围映射到裁剪空间,还会修改每个顶点坐标的齐次坐标w分量,使得离观察者越远的顶点,其w分量的值越大。变换到裁剪空间的坐标值范围在-w到w之间,超出此范围的坐标将被裁剪。为了符合OpenGL的要求,所有可见的顶点坐标都必须位于标准化设备坐标(NDC)空间内,其范围为-1.0到1.0。因此,在顶点坐标变换到裁剪空间后,需要进行透视除法:
o u t = ( x / w , y / w , z / w ) out=(x/w, y/w, z/w) out=(x/w,y/w,z/w)
即顶点坐标的每个分量(x、y、z)都除以其w分量。由于远处顶点的w分量较大,除法运算后得到的坐标值就越小,从而实现了近大远小的透视效果。这正是w分量在透视投影中如此重要的原因。透视除法后的坐标就位于标准化设备坐标(NDC)空间中,可以用于后续的视口变换和光栅化。
如果你对正射投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧的话)我推荐这篇由Songho写的文章。
在GLM中可以这样创建一个透视投影矩阵:
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
glm::perspective
函数的作用是创建一个定义了可视空间范围的透视投影平截头体。任何位于该平截头体之外的物体都不会出现在裁剪空间中,并会被剔除(裁剪)。透视投影平截头体可以想象成一个不规则的锥形体,其形状类似于一个被截断的金字塔,在这个锥形体内部的所有坐标都会被映射到裁剪空间中的一个点。下图展示了一个透视投影平截头体的示意图:
- 第一个参数
fov
: 定义了视野(Field of View,FOV),单位为弧度。它决定了观察空间的大小。通常,为了获得较为真实的视觉效果,fov
值设置为45.0°。如果想要类似《毁灭战士》(DOOM)等经典第一人称射击游戏的风格,可以将其设置为更大的值,以获得更广阔的视野。 - 第二个参数
aspect
: 设置了宽高比(Aspect Ratio),即视口的宽度除以高度的比值。例如,如果视口宽度为800像素,高度为600像素,则宽高比为800/600 = 4/3。 - 第三个参数
near
: 设置了近平面(Near Plane)距离摄像机的距离。近平面定义了视锥体的前边界,决定了离摄像机最近的可视距离。 - 第四个参数
far
: 设置了远平面(Far Plane)距离摄像机的距离。远平面定义了视锥体的后边界,决定了离摄像机最远的可视距离。
通常,为了平衡精度和渲染性能,near
值设置为0.1f,far
值设置为100.0f。所有位于近平面和远平面之间,且处于视锥体内部的顶点才会被渲染。
当你把透视矩阵的 near 值设置太大时(如10.0f),OpenGL会将靠近摄像机的坐标(在0.0f和10.0f之间)都裁剪掉,这会导致一个你在游戏中很熟悉的视觉效果:在太过靠近一个物体的时候你的视线会直接穿过去。
当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视除法(它仍然会进行透视除法,只是w分量没有被改变(它保持为1),因此没有起作用)。因为正射投影没有使用透视,远处的物体不会显得更小,所以产生奇怪的视觉效果。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中我们更希望顶点不会被透视所干扰。某些如 Blender 等进行三维建模的软件有时在建模时也会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。下面你能够看到在Blender里面使用两种投影方式的对比:
你可以看到,使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。
把它们都组合到一起
我们为上述的每一个步骤都创建了一个变换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点坐标将会根据以下过程被变换到裁剪坐标:
V c l i p = M p r o j e c t i o n ⋅ M v i e w ⋅ M m o d e l ⋅ V l o c a l V_{clip}=M_{projection}⋅M_{view}⋅M_{model}⋅V_{local} Vclip=Mprojection⋅Mview⋅Mmodel⋅Vlocal
注意矩阵运算的顺序是相反的(记住我们需要从右往左阅读矩阵的乘法)。最后的顶点应该被赋值到顶点着色器中的
gl_Position
,OpenGL将会自动进行透视除法和裁剪。
然后呢?
顶点着色器的输出要求所有的顶点都在裁剪空间内,这正是我们刚才使用变换矩阵所做的。OpenGL然后对裁剪坐标执行透视除法从而将它们变换到标准化设备坐标。OpenGL会使用
glViewPort
内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中是一个800x600的屏幕)。这个过程称为视口变换。
进入3D
既然我们知道了如何将3D坐标变换为2D坐标,我们可以开始使用真正的3D物体,而不是枯燥的2D平面了。
在开始进行3D绘图时,我们首先创建一个模型矩阵。这个模型矩阵包含了位移、缩放与旋转操作,它们会被应用到所有物体的顶点上,以变换它们到全局的世界空间。让我们变换一下我们的平面,将其绕着x轴旋转,使它看起来像放在地上一样。这个模型矩阵看起来是这样的
glm::mat4 model(1.f);
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));
通过将顶点坐标乘以这个模型矩阵,我们将该顶点坐标变换到世界坐标。我们的平面看起来就是在地板上,代表全局世界里的平面。
接下来,我们需要创建一个观察矩阵(View Matrix)。由于在世界空间中,默认的摄像机位置位于原点(0, 0, 0),这可能会导致物体被遮挡或不可见。为了观察场景中的物体,我们需要将摄像机稍微向后移动。理解观察矩阵的关键在于以下概念:
- 将摄像机向后移动,等同于将整个场景向前移动。
观察矩阵正是基于这个原理工作的:它通过以与摄像机移动方向相反的方向移动整个场景来模拟摄像机的移动。由于我们希望将摄像机向后移动,并且OpenGL使用的是右手坐标系,因此在概念上,我们需要沿着z轴的正方向移动摄像机。然而,为了实现这一效果,我们实际上是将整个场景沿着z轴的_负方向_进行平移。这种反向平移会产生摄像机向后移动的视觉效果。
简单来说,观察矩阵模拟了摄像机在世界空间中的位置和朝向。
在下一个教程中我们将会详细讨论如何在场景中移动。就目前来说,观察矩阵是这样的:
glm::mat4 view(1.f);
// 注意,我们将矩阵向我们要进行移动场景的反方向移动。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
最后我们需要做的是定义一个投影矩阵。我们希望在场景中使用透视投影,所以像这样声明一个投影矩阵:
glm::mat4 projection(1.f);
projection = glm::perspective(glm::radians(45.0f), static_cast<float>(SCR_WIDTH) / SCR_HEIGHT, 0.1f, 100.0f);
既然我们已经创建了变换矩阵,我们应该将它们传入着色器。首先,让我们在顶点着色器中声明一个uniform变换矩阵然后将它乘以顶点坐标:
#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
// 注意乘法要从右向左读
gl_Position = projection * view * model * vec4(aPos, 1.0);
...
}
我们还应该将矩阵传入着色器(这通常在每次的渲染迭代中进行,因为变换矩阵会经常变动):
int modelLoc = glGetUniformLocation(ourShader.ID, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
unsigned int viewLoc = glGetUniformLocation(ourShader.ID, "view");
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
unsigned int projectLoc = glGetUniformLocation(ourShader.ID, "projection");
glUniformMatrix4fv(projectLoc, 1, GL_FALSE, glm::value_ptr(projection));
我们的顶点坐标已经使用模型、观察和投影矩阵进行变换了,最终的物体应该会:
- 稍微向后倾斜至地板方向。
- 离我们有一些距离。
- 有透视效果(顶点越远,变得越小)。
让我们检查一下结果是否满足这些要求:
它看起来就像是一个3D的平面,静止在一个虚构的地板上。如果你得到的不是相同的结果,请检查下完整的源代码。
更加3D
到目前为止,我们一直都在使用一个2D平面,而且甚至是在3D空间里!所以,让我们大胆地拓展我们的2D平面为一个3D立方体。要想渲染一个立方体,我们一共需要36个顶点(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点),这36个顶点的位置你可以从这里获取。
为了有趣一点,我们将让立方体随着时间旋转:
model = glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f));
然后我们使用glDrawArrays
来绘制立方体,但这一次总共有36个顶点。
glDrawArrays(GL_TRIANGLES, 0, 36);
如果一切顺利的话你应该能得到下面这样的效果:
这的确有点像是一个立方体,但又有种说不出的奇怪。立方体的某些本应被遮挡住的面被绘制在了这个立方体其他面之上。之所以这样是因为OpenGL是一个三角形一个三角形地来绘制你的立方体的,所以即便之前那里有东西它也会覆盖之前的像素。因为这个原因,有些三角形会被绘制在其它三角形上面,虽然它们本不应该是被覆盖的。
幸运的是,OpenGL存储深度信息在一个叫做 Z缓冲(Z-buffer) 的缓冲中,它允许OpenGL决定何时覆盖一个像素而何时不覆盖。通过使用Z缓冲,我们可以配置OpenGL来进行深度测试。
Z缓冲
OpenGL使用Z缓冲(Z-buffer),也称为深度缓冲(Depth Buffer),来存储场景中每个像素的深度信息。GLFW会自动为我们创建一个深度缓冲(类似于它创建的用于存储输出图像颜色的颜色缓冲)。每个片段都有一个深度值(z值),当片段即将被渲染时,OpenGL会将该片段的深度值与深度缓冲中对应位置的深度值进行比较。如果当前片段的深度值大于(或根据深度测试的设置而定)深度缓冲中的值,则该片段将被丢弃(即不进行渲染),因为它被其他片段遮挡了;否则,该片段将被渲染,并更新深度缓冲中的值。这个比较和判断的过程称为深度测试(Depth Testing),它由OpenGL自动执行。
然而,默认情况下,深度测试是禁用的。为了使OpenGL真正执行深度测试,我们需要显式地启用它。这可以通过glEnable
函数来实现。glEnable
和glDisable
函数用于启用或禁用特定的OpenGL功能。一旦某个功能被启用或禁用,它将保持该状态,直到再次调用相应的函数进行更改。要启用深度测试,我们需要传递GL_DEPTH_TEST
参数给glEnable
函数:
glEnable(GL_DEPTH_TEST);
启用深度测试后,为了避免前一帧的深度信息影响当前帧的渲染结果,我们需要在每次渲染迭代开始之前清除深度缓冲。类似于清除颜色缓冲,我们可以使用glClear
函数,并在其中使用GL_DEPTH_BUFFER_BIT
标志来指定清除深度缓冲:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
这段代码同时清除了颜色缓冲和深度缓冲,这是通常的做法。我们来重新运行下程序看看OpenGL是否执行了深度测试:
就是这样!一个开启了深度测试,各个面都是纹理,并且还在旋转的立方体!如果你的程序有问题可以到这里下载源码进行比对。
更多的立方体
现在我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的图形布局已经定义好了,所以当渲染更多物体的时候我们不需要改变我们的缓冲数组和属性数组,我们唯一需要做的只是改变每个对象的模型矩阵来将立方体变换到世界坐标系中。
首先,让我们为每个立方体定义一个位移向量来指定它在世界空间的位置。我们将在一个
glm::vec3
数组中定义10个立方体位置:
glm::vec3 cubePositions[] = {
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 2.0f, 5.0f, -15.0f),
glm::vec3(-1.5f, -2.2f, -2.5f),
glm::vec3(-3.8f, -2.0f, -12.3f),
glm::vec3( 2.4f, -0.4f, -3.5f),
glm::vec3(-1.7f, 3.0f, -7.5f),
glm::vec3( 1.3f, -2.0f, -2.5f),
glm::vec3( 1.5f, 2.0f, -2.5f),
glm::vec3( 1.5f, 0.2f, -1.5f),
glm::vec3(-1.3f, 1.0f, -1.5f)
};
这段代码将会在每次新立方体绘制出来的时候更新模型矩阵,如此总共重复10次。然后我们应该就能看到一个拥有10个正在奇葩地旋转着的立方体的世界。
源码: 坐标系统 - GitCode
练习
First
目标: 对GLM的 projection
函数中的 FoV
和 aspect-ratio
参数进行实验。看能否搞懂它们是如何影响透视平截头体的。
Second
目标: 将观察矩阵在各个方向上进行位移,来看看场景是如何改变的。注意把观察矩阵当成摄像机对象。
Third
目标: 使用模型矩阵只让是3倍数的箱子旋转(以及第1个箱子),而让剩下的箱子保持静止。参考解答。
摄像机
- 原文链接: 摄像机 - LearnOpenGL CN
- 总结: 配置了一个摄像机,实现了在 3D 世界里的自由移动。
前面的教程中我们讨论了观察矩阵以及如何使用观察矩阵移动场景(我们向后移动了一点)。OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。
本节我们将会讨论如何在OpenGL中配置一个摄像机,并且将会讨论FPS风格的摄像机,让你能够在3D场景中自由移动。我们也会讨论键盘和鼠标输入,最终完成一个自定义的摄像机类。
摄像机/观察空间
当我们讨论摄像机/观察空间(Camera/View Space)时,我们指的是以摄像机的视角为原点来描述场景中所有顶点的新坐标系。观察矩阵的作用是将所有世界坐标转换为相对于摄像机的位置和方向的观察坐标。
为了定义一个摄像机,我们需要以下信息:
- 位置(Camera Position): 表示摄像机在世界坐标系中的坐标。
- 观察方向(Camera Direction/Target): 表示摄像机所观察的方向。通常,我们会指定一个目标点,摄像机的观察方向就是从摄像机位置指向目标点的向量。
- 右向量(Camera Right): 表示摄像机坐标系的x轴正方向,即摄像机的右侧方向。
- 上向量(Camera Up): 表示摄像机坐标系的y轴正方向,即摄像机的上方方向。
仔细观察会发现,我们实际上创建了一个以摄像机位置为原点的、三个单位轴相互垂直的正交坐标系。这个坐标系就是观察空间,也称为摄像机空间。
摄像机位置
摄像机位置简单来说就是世界空间中一个指向摄像机位置的向量。我们把摄像机位置设置为上一节中的那个相同的位置:
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
需要注意的是,在OpenGL的右手坐标系中,正z轴的方向是从屏幕指向观察者(也就是朝向你的方向)。因此,如果我们希望将摄像机向后移动(远离屏幕),我们需要沿着z轴的 正方向 移动。相反,如果希望将摄像机向前移动(靠近屏幕),则需要沿着z轴的 负方向 移动。
摄像机方向
接下来,我们需要计算摄像机的方向向量,它描述了摄像机所 看向 的方向。我们假设摄像机观察的目标点位于场景的原点(0, 0, 0)。回顾向量减法的几何意义:向量A减去向量B,(A - B)的结果是一个从向量B的终点指向向量A的终点的向量。因此,如果我们用摄像机的目标点向量(cameraTarget
)减去位置向量(cameraPos
),即 cameraTarget - cameraPos
,我们会得到一个从摄像机位置指向目标点的向量即摄像机的观察方向。
然而,在OpenGL中,我们通常需要一个表示摄像机 正前方 方向的向量,也就是摄像机局部坐标系的 正z轴 方向。这个方向与摄像机实际 看向 的方向(即从摄像机指向目标点的方向,也就是摄像机局部坐标系的 负z轴 方向)正好相反。因此,为了得到我们需要的方向向量,我们需要交换向量相减的顺序,使用 cameraPos - cameraTarget
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
这段代码计算了从目标点指向摄像机位置的向量,并将其标准化。虽然我们称其为 方向向量(Direction Vector),但需要注意的是,它实际上指向的是与摄像机观察方向相反的方向,即摄像机局部坐标系的正z轴方向。因此,称其为“负方向向量或者“反方向向量”可能更准确,但“方向向量”的称呼已经广泛使用,所以我们沿用此称呼。关键是要理解它表示的实际方向。
右轴
我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。为获取右向量我们需要先使用一个小技巧:先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘。两个向量叉乘的结果会同时垂直于两向量,因此我们会得到指向x轴正方向的那个向量(如果我们交换两个向量叉乘的顺序就会得到相反的指向x轴负方向的向量):
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
注意叉乘的顺序,因为 cameraDirection
实际代表的是摄像机负方向向量,所以是
u
p
×
c
a
m
e
r
a
D
i
r
e
c
t
i
o
n
up \times cameraDirection
up×cameraDirection 。
上轴
现在我们已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量就相对简单了:我们把右向量和方向向量进行叉乘:
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
同样注意叉乘的顺序,因为 cameraDirection
实际代表的是摄像机负方向向量,所以是
c
a
m
e
r
a
D
i
r
e
c
t
i
o
n
×
c
a
m
e
r
a
R
i
g
h
t
cameraDirection \times cameraRight
cameraDirection×cameraRight 。
在叉乘和一些小技巧的帮助下,我们创建了所有构成观察/摄像机空间的向量。对于想学到更多数学原理的读者,提示一下,在线性代数中这个处理叫做格拉姆—施密特正交化(Gram-Schmidt Process)。使用这些摄像机向量我们就可以创建一个LookAt矩阵了,它在创建摄像机的时候非常有用
Look At
矩阵的一个重要优势在于,如果我们使用三个相互垂直(或线性无关)的轴定义了一个坐标空间,我们可以利用这三个轴以及一个表示原点位置的向量来构建一个变换矩阵。使用该矩阵乘以任何向量,就可以将该向量从一个坐标空间变换到新定义的坐标空间。LookAt 矩阵正是利用了这个特性。
现在,我们已经拥有了构成摄像机局部坐标系(观察空间)的三个相互垂直的轴:右向量(R)、上向量(U)和方向向量(D),以及定义摄像机在世界空间中位置的向量(P),我们可以构建自己的 LookAt 矩阵:
其中:
- R: 右向量(Right Vector),表示摄像机局部坐标系的x轴正方向。
- U: 上向量(Up Vector),表示摄像机局部坐标系的y轴正方向。
- D: 方向向量(Direction Vector),表示摄像机局部坐标系的负z轴方向(摄像机实际观察的方向)。
- P: 摄像机在世界空间中的位置向量(Camera Position)。
注意公式中的 − P -P −P 部分。这是因为观察矩阵的目的是将世界坐标系变换到以摄像机为原点的观察坐标系。为了达到这个目的,我们需要将整个世界沿着与摄像机移动方向 相反 的方向平移。例如,如果摄像机向正z轴方向移动了3个单位,那么我们需要将整个世界沿着负z轴方向平移3个单位,才能使摄像机位于观察坐标系的原点。
将这个LookAt矩阵作为观察矩阵,可以高效地将所有世界坐标变换到我们刚刚定义的观察空间(摄像机空间)。LookAt矩阵正如其名称所示:它创建一个“看向”(Look at)给定目标的观察矩阵。
幸运的是,GLM已经提供了这些支持。我们要做的只是定义一个 摄像机位置,一个 目标位置和一个表示 世界空间中的上向量 的向量(我们计算右向量使用的那个上向量)。接着GLM就会创建一个LookAt矩阵,我们可以把它当作我们的观察矩阵:
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
glm::LookAt
函数需要一个位置、目标和上向量。它会创建一个和在上一节使用的一样的观察矩阵。
在讨论用户输入之前,我们先来做些有意思的事,把我们的摄像机在场景中旋转。我们会将摄像机的注视点保持在(0, 0, 0)。
我们需要用到一点三角学的知识来在每一帧创建一个x和z坐标,它会代表圆上的一点,我们将会使用它作为摄像机的位置。通过重新计算x和y坐标,我们会遍历圆上的所有点,这样摄像机就会绕着场景旋转了。我们预先定义这个圆的半径radius,在每次渲染迭代中使用GLFW的glfwGetTime函数重新创建观察矩阵,来扩大这个圆。
float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0));
通过这一小段代码,摄像机现在会随着时间流逝围绕场景转动了。自己试试改变半径和位置/方向参数,看看LookAt矩阵是如何工作的。同时,如果你在哪卡住的话,这里有源码。
自由移动
让摄像机绕着场景转的确很有趣,但是让我们自己移动摄像机会更有趣!首先我们必须设置一个摄像机系统,所以在我们的程序前面定义一些摄像机变量很有用:
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
LookAt
函数现在成了:
// cameraFront = cameraTarget - cameraPos
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
首先将摄像机位置设置为之前定义的cameraPos
,将 cameraPos + cameraFront
作为目标点,好处是无论摄像机的位置如何变化,目标点始终位于摄像机的前方。这样就保证了摄像机始终朝着 cameraFront
所指定的方向观察。这意味着,即使我们移动了摄像机的位置(例如通过键盘或鼠标控制),摄像机的朝向也不会改变,它会一直“注视”着目标方向。
例如,如果 cameraPos
为(0, 0, 3),cameraFront
为(0, 0, -1),那么 cameraPos + cameraFront
的结果就是(0, 0, 2),摄像机将朝向(0,0,2)这个点观察。如果我们将 cameraPos
移动到(1, 0, 3),cameraFront
仍然为(0, 0, -1),那么 cameraPos + cameraFront
的结果就是(1, 0, 2),摄像机仍然会朝向其正前方的点观察。
为了实现更灵活的摄像机控制,我们可以通过用户输入(例如键盘按键或鼠标移动)来更新 cameraPos
向量。通过不断更新 cameraPos
并重新计算观察矩阵,我们就可以实现摄像机在场景中的自由移动和观察。
我们已经为GLFW的键盘输入定义过一个processInput函数了,我们来新添加几个需要检查的按键命令:
void processInput(GLFWwindow *window)
{
...
float cameraSpeed = 0.05f; // adjust accordingly
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}
当我们按下WASD键的任意一个,摄像机的位置都会相应更新。如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。如果我们希望向左右移动,我们使用叉乘来创建一个右向量(Right Vector),并沿着它相应移动就可以了。这样就创建了使用摄像机时熟悉的横移(Strafe)效果。
注意,我们对右向量进行了标准化。如果我们没对这个向量进行标准化,最后的叉乘结果会根据cameraFront变量返回大小不同的向量。如果我们不对向量进行标准化,我们就得根据摄像机的朝向不同加速或减速移动了,但如果进行了标准化移动就是匀速的。
移动速度
目前我们的移动速度是个常量。理论上没什么问题,但是实际情况下根据处理器的能力不同,有些人可能会比其他人每秒绘制更多帧,也就是以更高的频率调用processInput函数。结果就是,根据配置的不同,有些人可能移动很快,而有些人会移动很慢。当你发布你的程序的时候,你必须确保它在所有硬件上移动速度都一样。
图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值。结果就是,如果我们的deltaTime很大,就意味着上一帧的渲染花费了更多时间,所以这一帧的速度需要变得更高来平衡渲染所花去的时间。使用这种方法时,无论你的电脑快还是慢,摄像机的速度都会相应平衡,这样每个用户的体验就都一样了。
我们跟踪两个全局变量来计算出deltaTime值:
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
在每一帧中我们计算出新的
deltaTime
以备后用。
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
现在我们有了
deltaTime
,在计算速度的时候可以将其考虑进去了:
void processInput(GLFWwindow *window)
{
float cameraSpeed = 2.5f * deltaTime;
...
}
与前面的部分结合在一起,我们有了一个更流畅点的摄像机系统:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
现在我们有了一个在任何系统上移动速度都一样的摄像机。同样,如果你卡住了,查看一下源码。我们可以看到任何移动都会影响返回的deltaTime值。
视角移动
只用键盘移动没什么意思。特别是我们还不能转向,移动很受限制。是时候加入鼠标了!
为了能够改变视角,我们需要根据鼠标的输入改变
cameraFront
向量。然而,根据鼠标移动改变方向向量有点复杂,需要一些三角学知识。如果你对三角学知之甚少,别担心,你可以跳过这一部分,直接复制粘贴我们的代码;当你想了解更多的时候再回来看。
欧拉角
欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:
- 俯仰角(Pitch) 描述的是摄像机绕其 x轴 旋转的角度,决定了我们向上或向下看的程度。你可以将其想象成点头的动作。
- 偏航角(Yaw) 描述的是摄像机绕其 y轴 旋转的角度,决定了我们向左或向右看的程度。你可以将其想象成摇头的动作。
- 滚转角(Roll) 描述的是摄像机绕其 z轴 旋转的角度,表示摄像机的倾斜程度。你可以将其想象成飞机或太空飞船在飞行过程中的翻滚动作。滚转角通常在模拟飞行或太空场景中使用。
每个欧拉角都用一个数值(通常以度或弧度表示)来定义旋转的幅度。通过组合这三个角度,我们可以表示3D空间中任意的旋转姿态。
在我们的摄像机系统中,我们只考虑 俯仰角(Pitch) 和 偏航角(Yaw),暂不涉及滚转角(Roll)。给定俯仰角和偏航角,我们可以将其转换为一个代表新的方向向量的3D向量。从俯仰角和偏航角到方向向量的转换需要用到一些基本的三角学知识。我们先从最简单的情况开始回顾:
考虑一个直角三角形,如果我们将斜边的长度定义为1,那么:
- 邻边的长度等于斜边乘以余弦值: c o s ( x ) / h = c o s ( x ) / 1 = c o s ( x ) cos(x) / h = cos(x) / 1 = cos(x) cos(x)/h=cos(x)/1=cos(x)
- 对边的长度等于斜边乘以正弦值: s i n ( y ) / h = s i n ( y ) / 1 = s i n ( y ) sin(y) / h = sin(y) / 1 = sin(y) sin(y)/h=sin(y)/1=sin(y)
因此,我们得到了根据给定角度计算 x x x 和 y y y 方向长度的通用公式。我们可以利用这些公式来计算方向向量的各个分量。
假设三维向量 o v ⃗ \vec{ov} ov 在 xoz 平面投影得到 d 点:
易知 ∠ d o x = y a w \angle dox = yaw ∠dox=yaw , ∠ v o d = p i t c h \angle vod = pitch ∠vod=pitch , ∣ ∣ o v ⃗ ∣ ∣ = 1 ||\vec{ov}|| = 1 ∣∣ov∣∣=1。
首先我们只考虑俯仰角(pitch)的影响,观察 y o d v yodv yodv 平面:
则向量 o v ⃗ . y = sin ( p i t c h ) \vec{ov} .y = \sin(pitch) ov.y=sin(pitch) , cos ( p i t c h ) = ∣ ∣ o d ⃗ ∣ ∣ \cos(pitch) = ||\vec{od}|| cos(pitch)=∣∣od∣∣。
再考虑偏航角(yaw)的影响,观察 x o z d xozd xozd 平面:
则向量 o v ⃗ . x = ∣ ∣ o d ⃗ ∣ ∣ ∗ cos ( y a w ) \vec{ov}.x = ||\vec{od}|| * \cos(yaw) ov.x=∣∣od∣∣∗cos(yaw), o v ⃗ . z = ∣ ∣ o d ⃗ ∣ ∣ ∗ sin ( y a w ) \vec{ov}.z = ||\vec{od}|| * \sin(yaw) ov.z=∣∣od∣∣∗sin(yaw)。
然后将两者结合到一起,带入得到:
o
v
⃗
.
x
=
∣
∣
o
d
⃗
∣
∣
∗
cos
(
y
a
w
)
=
cos
(
p
i
t
c
h
)
∗
cos
(
y
a
w
)
\vec{ov}.x = ||\vec{od}|| * \cos(yaw) = \cos(pitch) * \cos(yaw)
ov.x=∣∣od∣∣∗cos(yaw)=cos(pitch)∗cos(yaw)
o
v
⃗
.
y
=
sin
(
p
i
t
c
h
)
\vec{ov} .y = \sin(pitch)
ov.y=sin(pitch)
o
v
⃗
.
z
=
∣
∣
o
d
⃗
∣
∣
∗
sin
(
y
a
w
)
=
cos
(
p
i
t
c
h
)
∗
sin
(
y
a
w
)
\vec{ov}.z = ||\vec{od}|| * \sin(yaw) = \cos(pitch) * \sin(yaw)
ov.z=∣∣od∣∣∗sin(yaw)=cos(pitch)∗sin(yaw)
所以有:
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 译注:direction代表摄像机的前轴(Front),这个前轴是和本文第一幅图片的第二个摄像机的方向向量是相反的
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
这样我们就有了一个可以把俯仰角和偏航角转化为用来自由旋转视角的摄像机的3维方向向量了。你可能会奇怪:我们怎么得到俯仰角和偏航角?
鼠标输入
偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。
首先我们要告诉GLFW,它应该隐藏光标,并捕捉(Capture)它。捕捉光标表示的是,如果焦点在你的程序上(译注:即表示你正在操作这个程序,Windows中拥有焦点的程序标题栏通常是有颜色的那个,而失去焦点的程序标题栏则是灰色的),光标应该停留在窗口中(除非程序失去焦点或者退出)。我们可以用一个简单地配置调用来完成:
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
在调用这个函数之后,无论我们怎么去移动鼠标,光标都不会显示了,它也不会离开窗口。对于FPS摄像机系统来说非常完美。
为了计算俯仰角和偏航角,我们需要让GLFW监听鼠标移动事件。(和键盘输入相似)我们会用一个回调函数来完成,函数的原型如下:
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
这里的xpos和ypos代表当前鼠标的位置。当我们用GLFW注册了回调函数之后,鼠标一移动
mouse_callback
函数就会被调用
glfwSetCursorPosCallback(window, mouse_callback);
在处理FPS风格摄像机的鼠标输入的时候,我们必须在最终获取方向向量之前做下面这几步:
- 计算鼠标距上一帧的偏移量。
- 把偏移量添加到摄像机的俯仰角和偏航角中。
- 对偏航角和俯仰角进行最大和最小值的限制。
- 计算方向向量。
第一步是计算鼠标自上一帧的偏移量。我们必须先在程序中储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心(屏幕的尺寸是800x600):
float lastX = 400, lastY = 300;
然后在鼠标的回调函数中我们计算当前帧和上一帧鼠标位置的偏移量:
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;
注意我们把偏移量乘以了sensitivity(灵敏度)值。如果我们忽略这个值,鼠标移动就会太大了;你可以自己实验一下,找到适合自己的灵敏度值。
接下来我们把偏移量加到全局变量pitch和yaw上:
yaw += xoffset;
pitch += yoffset;
初始时,我们正视 -z 方向,则
float yaw = -90.f, pitch = 0.f;
第三步,我们需要给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了(这样也会避免一些奇怪的问题)。对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。我们可以在值超过限制的时候将其改为极限值来实现:
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
注意我们没有给偏航角设置限制,这是因为我们不希望限制用户的水平旋转。当然,给偏航角设置限制也很容易,如果你愿意可以自己实现。
第四也是最后一步,就是通过俯仰角和偏航角来计算以得到真正的方向向量:
glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);
计算出来的方向向量就会包含根据鼠标移动计算出来的所有旋转了。由于cameraFront向量已经包含在GLM的lookAt函数中,我们这就没什么问题了。
如果你现在运行代码,你会发现在窗口第一次获取焦点的时候摄像机会突然跳一下。这个问题产生的原因是,在你的鼠标移动进窗口的那一刻,鼠标回调函数就会被调用,这时候的xpos和ypos会等于鼠标刚刚进入屏幕的那个位置。这通常是一个距离屏幕中心很远的地方,因而产生一个很大的偏移量,所以就会跳了。我们可以简单的使用一个
bool
变量检验我们是否是第一次获取鼠标输入,如果是,那么我们先把鼠标的初始位置更新为xpos和ypos值,这样就能解决这个问题;接下来的鼠标移动就会使用刚进入的鼠标位置坐标来计算偏移量了:
static bool firstMouse = true;
if(firstMouse) // 这个bool变量初始时是设定为true的
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
最后的代码应该是这样的:
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
static bool firstMouse = true;
if(firstMouse)
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05;
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(front);
}
缩放
作为我们摄像机系统的一个附加内容,我们还会来实现一个缩放(Zoom)接口。在之前的教程中我们说视野(Field of View)或fov定义了我们可以看到场景中多大的范围。当视野变小时,场景投影出来的空间就会减小,产生放大(Zoom In)了的感觉。我们会使用鼠标的滚轮来放大。与鼠标移动、键盘输入一样,我们需要一个鼠标滚轮的回调函数:
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
if(fov >= 1.0f && fov <= 45.0f)
fov -= yoffset;
if(fov <= 1.0f)
fov = 1.0f;
if(fov >= 45.0f)
fov = 45.0f;
}
当滚动鼠标滚轮的时候,yoffset值代表我们竖直滚动的大小。当scroll_callback函数被调用后,我们改变全局变量fov变量的内容。因为
45.0f
是默认的视野值,我们将会把缩放级别(Zoom Level)限制在1.0f
到45.0f
。
我们现在在每一帧都必须把透视投影矩阵上传到GPU,但现在使用fov变量作为它的视野:
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
最后不要忘记注册鼠标滚轮的回调函数:
glfwSetScrollCallback(window, scroll_callback);
现在,我们就实现了一个简单的摄像机系统了,它能够让我们在3D环境中自由移动。
你可以去自由地实验,如果遇到困难,可以对比源代码。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
注意,使用欧拉角的摄像机系统并不完美。根据你的视角限制或者是配置,你仍然可能引入万向节死锁问题。最好的摄像机系统是使用四元数(Quaternions)的,但我们将会把这个留到后面讨论。(译注:这里可以查看四元数摄像机的实现)
摄像机类
接下来的教程中,我们将会一直使用一个摄像机来浏览场景,从各个角度观察结果。然而,由于一个摄像机会占用每篇教程很大的篇幅,我们将会从细节抽象出来,创建我们自己的摄像机对象,它会完成大多数的工作,而且还会提供一些附加的功能。与着色器教程不同,我们不会带你一步一步创建摄像机类,我们只会提供你一份(有完整注释的)代码,如果你想知道它的内部构造的话可以自己去阅读。
和着色器对象一样,我们把摄像机类写在一个单独的头文件中。你可以在这里找到它,你现在应该能够理解所有的代码了。我们建议您至少看一看这个类,看看如何创建一个自己的摄像机类。
我们介绍的摄像机系统是一个FPS风格的摄像机,它能够满足大多数情况需要,而且与欧拉角兼容,但是在创建不同的摄像机系统,比如飞行模拟摄像机,时就要当心。每个摄像机系统都有自己的优点和不足,所以确保对它们进行了详细研究。比如,这个FPS摄像机不允许俯仰角大于90度,而且我们使用了一个固定的上向量(0, 1, 0),这在需要考虑滚转角的时候就不能用了。
使用新摄像机对象,更新后版本的源码可以在这里找到。
我项目的代码:摄像机 - GitCode,类比于 UE 视角移动的操作,增加了 Q 与 E 的按键响应。
练习
First
目标: 看看你是否能够修改摄像机类,使得其能够变成一个真正的FPS摄像机(也就是说不能够随意飞行);你只能够呆在xz平面上:参考解答
Second
目标: 试着创建你自己的LookAt函数,其中你需要手动创建一个我们在一开始讨论的观察矩阵。用你的函数实现来替换GLM的LookAt函数,看看它是否还能一样地工作:参考解答
复习
- 原文链接: 复习 - LearnOpenGL CN