mirror of
https://github.com/LearnOpenGL-CN/LearnOpenGL-CN.git
synced 2025-08-23 12:45:29 +08:00
New theme
This commit is contained in:
127
docs/01 Getting started/01 OpenGL.md
Normal file
127
docs/01 Getting started/01 OpenGL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# OpenGL
|
||||
|
||||
原文 | [OpenGL](http://learnopengl.com/#!Getting-started/OpenGL)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | gjy_1992
|
||||
校对 | Geequlim
|
||||
|
||||
|
||||
在开始这段旅程之前我们先了解一下OpenGL到底是什么。一般它被认为是一个API(**Application Programming Interface**, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。然而,OpenGL本身并不是一个API,它仅仅是一个由[Khronos组织](http://www.khronos.org/)制定并维护的规范(Specification)。
|
||||
|
||||
<img alt="OpenGL Logo" src="http://learnopengl.com/img/getting-started/opengl.jpg" align="right" />
|
||||
|
||||
OpenGL规范严格规定了每个函数该如何执行,以及它们的输出值。至于内部具体每个函数是如何实现(Implement)的,将由OpenGL库的开发者自行决定(注:这里开发者是指编写OpenGL库的人)。因为OpenGL规范并没有规定实现的细节,具体的OpenGL库允许使用不同的实现,只要其功能和结果与规范相匹配(亦即,作为用户不会感受到功能上的差异)。
|
||||
|
||||
实际的OpenGL库的开发者通常是显卡的生产商。你购买的显卡所支持的OpenGL版本都为这个系列的显卡专门开发的。当你使用Apple系统的时候,OpenGL库是由Apple自身维护的。在Linux下,有显卡生产商提供的OpenGL库,也有一些爱好者改编的版本。这也意味着任何时候OpenGL库表现的行为与规范规定的不一致时,基本都是库的开发者留下的bug。
|
||||
|
||||
!!! important
|
||||
|
||||
由于OpenGL的大多数实现都是由显卡厂商编写的,当产生一个bug时通常可以通过升级显卡驱动来解决。这些驱动会包括你的显卡能支持的最新版本的OpenGL,这也是为什么总是建议你偶尔更新一下显卡驱动。
|
||||
|
||||
所有版本的OpenGL规范文档都被公开的寄存在Khronos那里。有兴趣的读者可以找到OpenGL3.3(我们将要使用的版本)的[规范文档](https://www.opengl.org/registry/doc/glspec33.core.20100311.withchanges.pdf)。如果你想深入到OpenGL的细节(只关心函数功能的描述而不是函数的实现),这是个很好的选择。如果你想知道每个函数**具体的**运作方式,这个规范也是一个很棒的参考。
|
||||
|
||||
## 核心模式与立即渲染模式
|
||||
|
||||
早期的OpenGL使用**立即渲染模式**(Immediate mode,也就是**固定渲染管线**),这个模式下绘制图形很方便。OpenGL的大多数功能都被库隐藏起来,开发者很少能控制OpenGL如何进行计算的自由。而开发者迫切希望能有更多的灵活性。随着时间推移,规范越来越灵活,开发者对绘图细节有了更多的掌控。立即渲染模式确实容易使用和理解,但是效率太低。因此从OpenGL3.2开始,规范文档开始废弃立即渲染模式,推出核心模式(Core-profile),这个模式完全移除了旧的特性。
|
||||
|
||||
当使用OpenGL的核心模式时,OpenGL迫使我们使用现代的函数。当我们试图使用一个已废弃的函数时,OpenGL会抛出一个错误并终止绘图。现代函数的优势是更高的灵活性和效率,然而也更难于学习。立即渲染模式从OpenGL**实际**运作中抽象掉了很多细节,因而它易于学习的同时,也很难去把握OpenGL具体是如何运作的。现代函数要求使用者真正理解OpenGL和图形编程,它有一些难度,然而提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。
|
||||
|
||||
这也是为什么我们的教程面向OpenGL3.3的核心模式。虽然上手更困难,但这份努力是值得的。
|
||||
|
||||
现今,更高版本的OpenGL已经发布(写作时最新版本为4.5),你可能会问:既然OpenGL 4.5 都出来了,为什么我们还要学习OpenGL 3.3?答案很简单,所有OpenGL的更高的版本都是在3.3的基础上,引入了额外的功能,并没有改动核心架构。新版本只是引入了一些更有效率或更有用的方式去完成同样的功能。因此,所有的概念和技术在现代OpenGL版本里都保持一致。当你的经验足够,你可以轻松使用来自更高版本OpenGL的新特性。
|
||||
|
||||
!!! attention
|
||||
|
||||
当使用新版本的OpenGL特性时,只有新一代的显卡能够支持你的应用程序。这也是为什么大多数开发者基于较低版本的OpenGL编写程序,并只提供选项启用新版本的特性。
|
||||
|
||||
在有些教程里你会看见更现代的特性,它们同样会以这种红色注释方式标明。
|
||||
|
||||
## 扩展
|
||||
|
||||
OpenGL的一大特性就是对扩展(Extension)的支持,当一个显卡公司提出一个新特性或者渲染上的大优化,通常会以**扩展**的方式在驱动中实现。如果一个程序在支持这个扩展的显卡上运行,开发者可以使用这个扩展提供的一些更先进更有效的图形功能。通过这种方式,开发者不必等待一个新的OpenGL规范面世,就可以使用这些新的渲染特性了,只需要简单地检查一下显卡是否支持此扩展。通常,当一个扩展非常流行或者非常有用的时候,它将最终成为未来的OpenGL规范的一部分。
|
||||
|
||||
使用扩展的代码大多看上去如下:
|
||||
|
||||
```c++
|
||||
if(GL_ARB_extension_name)
|
||||
{
|
||||
// 使用一些新的特性
|
||||
}
|
||||
else
|
||||
{
|
||||
// 不支持此扩展: 用旧的方式去做
|
||||
}
|
||||
```
|
||||
|
||||
使用OpenGL3.3时,我们很少需要使用扩展来完成大多数功能,当需要的时候,本教程将提供适当的指示。
|
||||
|
||||
## 状态机
|
||||
|
||||
OpenGL自身是一个巨大的状态机(State Machine):一系列的变量描述OpenGL此刻应当如何运行。OpenGL的状态通常被称为OpenGL**上下文(Context)**。我们通常使用如下途径去更改OpenGL状态:设置选项,操作缓冲。最后,我们使用当前OpenGL上下文来渲染。
|
||||
|
||||
假设当我们想告诉OpenGL去画线段而不是三角形的时候,我们通过改变一些上下文变量来改变OpenGL状态,从而告诉OpenGL如何去绘图。一旦我们改变了OpenGL的状态为绘制线段,下一个绘制命令就会画出线段而不是三角形。
|
||||
|
||||
当使用OpenGL的时候,我们会遇到一些**状态设置**函数(State-changing Function),这类函数将会改变上下文。以及**状态应用**函数(State-using Function),这类函数会根据当前OpenGL的状态执行一些操作。只要你记住OpenGL本质上是个大状态机,就能更容易理解它的大部分特性。
|
||||
|
||||
## 对象
|
||||
|
||||
OpenGL库是用C语言写的,同时也支持多种语言的派生,但其内核仍是一个C库。由于C的一些语言结构不易被翻译到其它的高级语言,因此OpenGL开发的时候引入了一些抽象层。“对象(Object)”就是其中一个。
|
||||
|
||||
在OpenGL中一个**对象**是指一些选项的集合,它代表OpenGL状态的一个子集。比如,我们可以用一个对象来代表绘图窗口的设置,之后我们就可以设置它的大小、支持的颜色位数等等。可以把对象看做一个C风格的结构体(Struct):
|
||||
|
||||
```c++
|
||||
struct object_name {
|
||||
GLfloat option1;
|
||||
GLuint option2;
|
||||
GLchar[] name;
|
||||
};
|
||||
```
|
||||
|
||||
!!! important
|
||||
|
||||
**基元类型(Primitive Type)**
|
||||
|
||||
使用OpenGL时,建议使用OpenGL定义的基元类型。比如使用`float`时我们加上前缀`GL`(因此写作`GLfloat`)。`int`、`uint`、`char`、`bool`等等也类似。OpenGL定义的这些GL基元类型的内存布局是与平台无关的,而int等基元类型在不同操作系统上可能有不同的内存布局。使用GL基元类型可以保证你的程序在不同的平台上工作一致。
|
||||
|
||||
当我们使用一个对象时,通常看起来像如下一样(把OpenGL上下文看作一个大的结构体):
|
||||
|
||||
```c++
|
||||
// OpenGL的状态
|
||||
struct OpenGL_Context
|
||||
{
|
||||
...
|
||||
object* object_Window_Target;
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
```c++
|
||||
// 创建对象
|
||||
GLuint objectId = 0;
|
||||
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);
|
||||
```
|
||||
|
||||
这一小段代码展现了你以后使用OpenGL时常见的工作流。我们首先创建一个对象,然后用一个id保存它的引用(实际数据被储存在后台)。然后我们将对象绑定至上下文的目标位置(例子中窗口对象目标的位置被定义成`GL_WINDOW_TARGET`)。接下来我们设置窗口的选项。最后我们将目标位置的对象id设回0,解绑这个对象。设置的选项将被保存在`objectId`所引用的对象中,一旦我们重新绑定这个对象到`GL_WINDOW_TARGET`位置,这些选项就会重新生效。
|
||||
|
||||
!!! attention
|
||||
|
||||
目前提供的示例代码只是OpenGL如何操作的一个大致描述,通过阅读以后的教程你会遇到很多实际的例子。
|
||||
|
||||
使用对象的一个好处是在程序中,我们不止可以定义一个对象,并设置它们的选项,每个对象都可以是不同的设置。在我们执行一个使用OpenGL状态的操作的时候,只需要绑定含有需要的设置的对象即可。比如说我们有一些作为3D模型数据(一栋房子或一个人物)的容器对象,在我们想绘制其中任何一个模型的时候,只需绑定一个包含对应模型数据的对象就可以了(当然,我们需要先创建并设置对象的选项)。拥有数个这样的对象允许我们指定多个模型,在想画其中任何一个的时候,直接将对应的对象绑定上去,便不需要再重复设置选项了。
|
||||
|
||||
## 让我们开始吧
|
||||
|
||||
你现在已经知道一些OpenGL的相关知识了,OpenGL规范和库,OpenGL幕后大致的运作流程,以及OpenGL使用的一些传统技巧。不要担心你还没有完全消化它们,后面的教程我们会仔细地讲解每一个步骤,你会通过足够的例子来真正掌握OpenGL。如果你已经做好了开始下一步的准备,我们可以在[这里](02 Creating a window.md)开始创建OpenGL上下文以及我们的第一个窗口了。
|
||||
|
||||
## 附加资源
|
||||
|
||||
- [opengl.org](https://www.opengl.org/):OpenGL官方网站。
|
||||
- [OpenGL registry](https://www.opengl.org/registry/):包含OpenGL各版本的规范和扩展。
|
147
docs/01 Getting started/02 Creating a window.md
Normal file
147
docs/01 Getting started/02 Creating a window.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 创建窗口
|
||||
|
||||
原文 | [Creating a window](http://learnopengl.com/#!Getting-started/Creating-a-window)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | gjy_1992
|
||||
校对 | Geequlim
|
||||
|
||||
在我们画出出色的效果之前,首先要做的就是创建一个OpenGL上下文(Context)和一个用于显示的窗口。然而,这些操作在每个系统上都是不一样的,OpenGL有目的的抽象(Abstract)这些操作。这意味着我们不得不自己处理创建窗口,定义OpenGL上下文以及处理用户输入。
|
||||
|
||||
幸运的是,有一些库已经提供了我们所需的功能,其中一部分是特别针对OpenGL的。这些库节省了我们书写平台相关代码的时间,提供给我们一个窗口和上下文用来渲染。最流行的几个库有GLUT,SDL,SFML和GLFW。在教程里我们将使用**GLFW**。
|
||||
|
||||
## GLFW
|
||||
|
||||
GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物件所需的最低限度的接口。它允许用户创建OpenGL上下文,定义窗口参数以及处理用户输入。
|
||||
|
||||
这一节和下一节的内容是建立GLFW环境,并保证它恰当地创建窗口和OpenGL上下文。本教程会一步步从获取,编译,链接GLFW库讲起。我们使用Microsoft Visual Studio 2012 IDE,如果你用的不是它(或者只是Visual Studio的旧版本)请不要担心,大多数IDE上的操作都是类似的。Visual Studio 2012(或其他版本)可以从微软网站上免费下载(选择Express版本或Community版本)。
|
||||
|
||||
## 构建GLFW
|
||||
|
||||
GLFW可以从它们网站的[下载页](http://www.glfw.org/download.html)上获取。GLFW已经有针对Visual Studio 2012/2013的预编译的二进制版本和相应的头文件,但是为了完整性我们将从编译源代码开始,所以需要下载**源代码包**。
|
||||
|
||||
|
||||
!!! Attention
|
||||
|
||||
当你下载二进制版本时,请下载32位的版本而不是64位的除非你清楚你在做什么。大部分读者报告64位版本会出现很多奇怪的问题。
|
||||
|
||||
|
||||
一旦下载完了源码包,解压到某处。我们只关心里面的这些内容:
|
||||
|
||||
- 编译生成的库
|
||||
- **include**文件夹
|
||||
|
||||
从源代码编译库可以保证生成的目标代码是针对你的操作系统和CPU的,而一个预编译的二进制代码并不保证总是适合。提供源代码的一个问题是不是每个人都用相同的IDE来编译,因而提供的工程文件可能和一些人的IDE不兼容。所以人们只能从.cpp和.h文件来自己建立工程,这是一项笨重的工作。因此诞生了一个叫做CMake的工具。
|
||||
|
||||
### CMake
|
||||
|
||||
CMake是一个工程文件生成工具,可以使用预定义好的CMake脚本,根据用户的选择生成不同IDE的工程文件。这允许我们从GLFW源码里创建一个Visual Studio 2012工程文件。首先,我们需要从[这里](http://www.cmake.org/cmake/resources/software.html)下载安装CMake。我们选择Win32安装程序。
|
||||
|
||||
一旦CMake安装成功,你可以选择从命令行或者GUI启动CMake,为了简易我们选择后者。CMake需要一个源代码目录和一个存放编译结果的目标文件目录。源代码目录我们选择GLFW的源代码的根目录,然后我们新建一个_build_文件夹来作为目标目录。
|
||||
|
||||

|
||||
|
||||
之后,点击**Configure(设置)**按钮,我们选择生成的目标平台为**Visual Studio 11**(因为Visual Studio 2012的内部版本号是11.0)。CMake会显示可选的编译选项,这里我们使用默认设置,再次点击**Configure(设置)**按钮,保存这些设置。保存之后,我们可以点击**Generate(生成)**按钮,生成的工程文件就会出现在你的*build*文件夹中。
|
||||
|
||||
### 编译
|
||||
|
||||
在**build**文件夹里可以找到**GLFW.sln**文件,用Visual Studio 2012打开。因为CMake已经配置好了项目所以我们直接点击**Build Solution(构建解决方案)**然后编译的结果**glfw3.lib**就会出现在**src/Debug**文件夹内。(注意我们现在使用的glfw的版本号为3.1)
|
||||
|
||||
生成库之后,我们需要让IDE知道库和头文件的位置。有两种方法:
|
||||
|
||||
1. 找到IDE或者编译器的**/lib**和**/include**文件夹,之后添加GLFW的**include**目录到**/include**里去,相似的将**glfw3.lib**添加到**/lib**里去。这不是推荐的方式,因为很难去追踪library/include文件夹,而且重新安装IDE/Compiler可能会导致这些文件丢失。
|
||||
2. 推荐的方式是建立一个新的目录包含所有的第三方库文件和头文件,并且在你的IDE/Compiler中指定这些文件夹。我个人使用一个单独的文件夹包含**Libs**和**Include**文件夹,在这里存放OpenGL工程用到的所有第三方库和头文件。这样我的所有第三方库都在同一个路径(并且应该在你的多台电脑间共享),然而要求是每次新建一个工程我们都需要告诉IDE/编译器在哪能找到这些文件
|
||||
|
||||
完成上面步骤后,我们就可以使用GLFW创建我们的第一个OpenGL工程了!
|
||||
|
||||
## 我们的第一个工程
|
||||
|
||||
现在,让我们打开Visual Studio,创建一个新的工程。如果提供了多个选项,选择Visual C++,然后选择**空工程(Empty Project)**,别忘了给你的工程起一个合适的名字。现在我们有了一个空的工程去创建我们的OpenGL程序。
|
||||
|
||||
## 链接(Linking)
|
||||
|
||||
为了使我们的程序使用GLFW,我们需要把GLFW库**链接(Link)**进工程。于是我们需要在链接器的设置里写上**glfw3.lib**。但是我们的工程还不知道在哪寻找这个文件,于是我们首先需要将我们放第三方库的目录添加进设置。
|
||||
|
||||
为了添加这些目录,我们首先进入Project Properties(工程属性)(在解决方案窗口里右键项目),然后选择**VC++ Directories**选项卡(如下图)。在下面的两栏添加目录:
|
||||
|
||||

|
||||
|
||||
从这里你可以把自己的目录加进去从而工程知道从哪去寻找库文件和头文件。可以手动把目录加在后面,也可以点**<Edit..>**选项,下面的图是Include Directories的设置:
|
||||
|
||||

|
||||
|
||||
这里可以添加任意多个目录,IDE会从这些目录里寻找头文件。所以只要你将GLFW的**Include**文件夹加进路径中,你就可以使用**<GLFW/..>**来引用头文件。库文件也是一样的。
|
||||
|
||||
现在VS可以找到我们链接GLFW需要的所有文件了。最后需要在**Linker(链接器)**选项卡里的**Input**选项卡里添加**glfw3.lib**这个文件:
|
||||
|
||||

|
||||
|
||||
要链接一个库我们必须告诉链接器它的文件名。因为我们的库名字是**glfw3.lib**,我们把它加到**Additional Dependencies**域里面(手动或者使用**<Edit..>**选项)。这样GLFW就会被链接进我们的工程。除了GLFW,你也需要链接OpenGL的库,但是这个库可能因为系统的不同而有一些差别。
|
||||
|
||||
### Windows上的OpenGL库
|
||||
|
||||
如果你是Windows平台,**opengl32.lib**已经随着Microsoft SDK装进了Visual Studio的默认目录,所以Windows上我们只需将**opengl32.lib**添加进Additional Dependencies。
|
||||
|
||||
### Linux上的OpenGL库
|
||||
|
||||
在Linux下你需要链接**libGl.so**,所以要添加**-lGL**到你的链接器设置里。如果找不到这个库你可能需要安装Mesa,NVidia或AMD的开发包,这部分因平台而异就不仔细讲解了。
|
||||
|
||||
现在,如果你添加好了GLFW和OpenGL库,你可以用如下方式添加GLFW头文件:
|
||||
|
||||
```c++
|
||||
#include <GLFW\glfw3.h>
|
||||
```
|
||||
|
||||
这个头文件包含了GLFW的设置。
|
||||
|
||||
## GLEW
|
||||
|
||||
到这里,我们仍然有一件事要做。因为OpenGL只是一个规范,具体的实现是由驱动开发商针对特定显卡实现的。由于显卡驱动版本众多,大多数函数都无法在编译时确定下来,需要在运行时获取。开发者需要运行时获取函数地址并保存下来供以后使用。取得地址的方法因平台而异,Windows下看起来类似这样:
|
||||
|
||||
```c++
|
||||
// 定义函数类型
|
||||
typedef void (*GL_GENBUFFERS) (GLsizei, GLuint*);
|
||||
// 找到正确的函数并赋值给函数指针
|
||||
GL_GENBUFFERS glGenBuffers = (GL_GENBUFFERS)wglGetProcAddress("glGenBuffers");
|
||||
// 现在函数可以被正常调用了
|
||||
GLuint buffer;
|
||||
glGenBuffers(1, &buffer);
|
||||
```
|
||||
|
||||
你可以看到代码复杂而笨重,因为我们对于每个函数都必须这样。幸运的是,有一个针对此目的的库,GLEW,是目前最流行的做这件事的方式。
|
||||
|
||||
### 编译和链接GLEW
|
||||
|
||||
GLEW是OpenGL Extension Wrangler Library的缩写,它管理我们上面提到的一系列繁琐的任务。因为GLEW也是一个库,我们同样需要链接进工程。GLEW可以从[这里](http://glew.sourceforge.net/index.html)下载,你可以选择下载二进制版本或者下载源码编译。记住,优先选择32位的二进制版本。
|
||||
|
||||
我们使用GLEW的静态版本glew32s.lib(注意这里的's'),用如上的方式添加其库文件和头文件,最后在链接器的选项里加上glew32s.lib。注意GLFW3也是编译成了一个静态库。
|
||||
|
||||
|
||||
!!! Important
|
||||
|
||||
**静态(Static)**链接是指编译时就将库代码里的内容合并进二进制文件。优点就是你不需要再放额外的文件,只需要发布你最终的二进制代码文件。缺点就是你的程序会变得更大,另外当库有升级版本时,你必须重新进行编译。
|
||||
**动态(Dynamic)**链接是指一个库通过.dll或.so的方式存在,它的代码与你的二进制文件的代码是分离的。优点是使你的程序大小变小并且更容易升级,缺点是你发布时必须带上这些dll。
|
||||
|
||||
|
||||
如果你希望静态链接GLEW,必须在包含GLEW头文件之前定义预编译宏`GLEW_STATIC`:
|
||||
|
||||
```c++
|
||||
#define GLEW_STATIC
|
||||
#include <GL/glew.h>
|
||||
```
|
||||
|
||||
如果你希望动态链接,那么就不要定义这个宏。但是使用动态链接的话你需要拷贝一份dll文件到你的应用程序目录。
|
||||
|
||||
!!! Important
|
||||
|
||||
对于Linux用户建议使用这个命令行`-lGLEW -lglfw3 -lGL -lX11 -lpthread -lXrandr -lXi`。没有正确链接相应的库会产生*undefined reference*(未定义的引用)这个错误。
|
||||
|
||||
我们现在成功编译了GLFW和GLEW库,我们将进入[下一节](http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/03%20Hello%20Window/)去使用GLFW和GLEW来设置OpenGL上下文并创建窗口。记住确保你的头文件和库文件的目录设置正确,以及链接器里引用的库文件名正确。如果仍然遇到错误,请参考额外资源中的例子。
|
||||
|
||||
##额外的资源
|
||||
|
||||
- [Building applications](http://www.opengl-tutorial.org/miscellaneous/building-your-own-c-application/): 提供了很多编译链接相关的信息以及一大批错误的解决方法。
|
||||
- [GLFW with Code::Blocks](http://wiki.codeblocks.org/index.php?title=Using_GLFW_with_Code::Blocks):使用Code::Blocks IDE编译GLFW。
|
||||
- [Running CMake](http://www.cmake.org/runningcmake/): 简要的介绍如何在Windows和Linux上使用CMake。
|
||||
- [Writing a build system under Linux](http://learnopengl.com/demo/autotools_tutorial.txt): Wouter Verholst写的一个自动工具的教程,关于如何在Linux上建立编译环境,尤其是针对这些教程。
|
||||
- [Polytonic/Glitter](https://github.com/Polytonic/Glitter): 一个简单的样板项目,它已经提前配置了所有相关的库;如果你想要很方便地搞到一个LearnOpenGL教程的范例工程,这是一个很好的东西。
|
201
docs/01 Getting started/03 Hello Window.md
Normal file
201
docs/01 Getting started/03 Hello Window.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 你好,窗口
|
||||
|
||||
原文 | [Hello Window](http://learnopengl.com/#!Getting-started/Hello-Window)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | Geequlim
|
||||
校对 | Geequlim
|
||||
|
||||
|
||||
上一节中我们获取并编译了GLFW和GLEW这两个开源库,现在我们就可以使用它们来创建一个OpenGL绘图窗口了。首先,新建一个`.cpp`文件,然后把下面的代码粘贴到该文件的最前面。注意,之所以定义`GLEW_STATIC`宏,是因为我们使用GLEW的静态链接库。
|
||||
|
||||
```c++
|
||||
// GLEW
|
||||
#define GLEW_STATIC
|
||||
#include <GL/glew.h>
|
||||
// GLFW
|
||||
#include <GLFW/glfw3.h>
|
||||
```
|
||||
|
||||
!!! Attention
|
||||
|
||||
请确认在包含GLFW的头文件之前包含了GLEW的头文件。在包含glew.h头文件时会引入许多OpenGL必要的头文件(例如GL/gl.h),所以#include <GL/glew.h>应放在引入其他头文件的代码之前。
|
||||
|
||||
接下来我们创建`main`函数,并做一些初始化GLFW的操作:
|
||||
|
||||
```c++
|
||||
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_RESIZABLE, GL_FALSE);
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
首先我们在`main`函数中调用`glfwInit`函数来初始化GLFW,然后我们可以使用`glfwWindowHint`函数来配置GLFW。`glfwWindowHint`函数的第一个参数表示我们要进行什么样的配置,我们可以选择大量以`GLFW_`开头的枚举值;第二个参数接受一个整形,用来设置这个配置的值。该函数的所有的选项以及对应的值都可以在 [GLFW's window handling](http://www.glfw.org/docs/latest/window.html#window_hints) 这篇文档中找到。如果你现在编译你的cpp文件会得到大量的连接错误,这是因为你还需要进一步设置GLFW。
|
||||
|
||||
由于本站的教程都是基于OpenGL3.3以后的版本展开讨论的,所以我们需要告诉GLFW我们要使用的OpenGL版本是3.3,这样GLFW会在创建OpenGL上下文时做出适当的调整。这也可以确保用户在没有适当的OpenGL版本支持的情况下无法运行。在这里我们告诉GLFW想要的OpenGL版本号是3.3,并且不允许用户调整窗口的大小。我们明确地告诉GLFW我们想要使用核心模式(Core-profile),这将导致我们无法使用那些已经废弃的API,而这不正是一个很好的提醒吗?当我们不小心用了旧功能时报错,就能避免使用一些被废弃的用法了。如果你使用的是Mac OSX系统你还需要加下面这行代码这些配置才能起作用:
|
||||
|
||||
```c++
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
|
||||
```
|
||||
|
||||
!!! Important
|
||||
|
||||
请确认您的系统支持OpenGL3.3或更高版本,否则此应用有可能会崩溃或者出现不可预知的错误。可以通过运行glew附带的glxinfo程序或者其他的工具(例如[OpenGL Extension Viewer](http://download.cnet.com/OpenGL-Extensions-Viewer/3000-18487_4-34442.html)来查看你的OpenGL版本。如果你的OpenGL版本低于3.3请更新你的驱动程序或者有必要的话更新设备。
|
||||
|
||||
接下来我们创建一个窗口对象,这个窗口对象中具有和窗口相关的许多数据,而且会被GLFW的其他函数频繁地用到。
|
||||
|
||||
```c++
|
||||
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", nullptr, nullptr);
|
||||
if (window == nullptr)
|
||||
{
|
||||
std::cout << "Failed to create GLFW window" << std::endl;
|
||||
glfwTerminate();
|
||||
return -1;
|
||||
}
|
||||
glfwMakeContextCurrent(window);
|
||||
```
|
||||
|
||||
`glfwCreateWindow`函数需要窗口的宽和高作为它的前两个参数;第三个参数表示只这个窗口的名称(标题),这里我们使用**"LearnOpenGL"**,当然你也可以使用你喜欢的名称;最后两个参数我们暂时忽略,先置为空指针就行。它的返回值`GLFWwindow`对象的指针会在其他的GLFW操作中使用到。创建完窗口我们就可以通知GLFW给我们的窗口在当前的线程中创建我们等待已久的OpenGL上下文了。
|
||||
|
||||
### GLEW
|
||||
|
||||
在之前的教程中已经提到过,GLEW是用来管理OpenGL的函数指针的,所以在调用任何OpenGL的函数之前我们需要初始化GLEW。
|
||||
|
||||
```c++
|
||||
glewExperimental = GL_TRUE;
|
||||
if (glewInit() != GLEW_OK)
|
||||
{
|
||||
std::cout << "Failed to initialize GLEW" << std::endl;
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
请注意,我们在初始化GLEW之前设置`glewExperimental`变量的值为`GL_TRUE`,这样做能让GLEW在管理OpenGL的函数指针时更多地使用现代化的技术,如果把它设置为`GL_FALSE`的话可能会在使用OpenGL的核心模式(Core-profile)时出现一些问题。
|
||||
|
||||
### 视口(Viewport)
|
||||
|
||||
在我们绘制之前还有一件重要的事情要做,我们必须告诉OpenGL渲染窗口的尺寸大小,这样OpenGL才只能知道要显示数据的窗口坐标。我们可以通过调用`glViewport`函数来设置这些维度:
|
||||
|
||||
```c++
|
||||
glViewport(0, 0, 800, 600);
|
||||
```
|
||||
|
||||
前两个参数设置窗口左下角的位置。第三个和第四个参数设置渲染窗口的宽度和高度,我们设置成与GLFW的窗口的宽高大小,我们也可以将这个值设置成比窗口小的数值,然后所有的OpenGL渲染将会显示在一个较小的区域。
|
||||
|
||||
!!!Important
|
||||
|
||||
OpenGL使用`glViewport`定义的位置和宽高进行位置坐标的转换,将OpenGL中的位置坐标转换为你的屏幕坐标。例如,OpenGL中的坐标(0.5,0.5)有可能被转换为屏幕中的坐标(200,450)。注意,OpenGL只会把-1到1之间的坐标转换为屏幕坐标,因此在此例中(-1,1)转换为屏幕坐标是(0,600)。
|
||||
|
||||
## 准备好你的引擎
|
||||
|
||||
我们可不希望只绘制一个图像之后我们的应用程序就关闭窗口并立即退出。我们希望程序在我们明确地关闭它之前一直保持运行状态并能够接受用户输入。因此,我们需要在程序中添加一个while循环,我们可以把它称之为游戏循环(Game Loop),这样我们的程序就能在我们让GLFW退出前保持运行了。下面几行的代码就实现了一个简单的游戏循环:
|
||||
|
||||
```c++
|
||||
while(!glfwWindowShouldClose(window))
|
||||
{
|
||||
glfwPollEvents();
|
||||
glfwSwapBuffers(window);
|
||||
}
|
||||
```
|
||||
|
||||
- `glfwWindowShouldClose`函数在我们每次循环的开始前检查一次GLFW是否准备好要退出,如果是这样的话该函数返回true然后游戏循环便结束了,之后为我们就可以关闭应用程序了。
|
||||
- `glfwPollEvents`函数检查有没有触发什么事件(比如键盘有按钮按下、鼠标移动等)然后调用对应的回调函数(我们可以手动设置这些回调函数)。我们一般在游戏循环的一开始就检查事件。
|
||||
- 调用`glfwSwapBuffers`会交换缓冲区(储存着GLFW窗口每一个像素颜色的缓冲区)
|
||||
|
||||
|
||||
!!! Important
|
||||
|
||||
**双缓冲区(Double buffer)**
|
||||
|
||||
应用程序使用单缓冲区绘图可能会存在图像闪烁的问题。 这是因为生成的图像不是一下子被绘制出来的,而是按照从左到右,由上而下逐像素地绘制而成的。最终图像不是在瞬间显示给用户,而是通过一步一步地计算结果绘制的,这可能会花费一些时间。为了规避这些问题,我们应用双缓冲区渲染窗口应用程序。前面的缓冲区保存着计算后可显示给用户的图像,被显示到屏幕上;所有的的渲染命令被传递到后台的缓冲区进行计算。当所有的渲染命令执行结束后,我们交换前台缓冲和后台缓冲,这样图像就立即呈显出来,之后清空缓冲区。
|
||||
|
||||
### 最后一件事
|
||||
|
||||
当游戏循环结束后我们需要释放之前的操作分配的资源,我们可以在main函数的最后调用`glfwTerminate`函数来释放GLFW分配的内存。
|
||||
|
||||
```c++
|
||||
glfwTerminate();
|
||||
return 0;
|
||||
```
|
||||
|
||||
这样便能清空GLFW分配的内存然后正确地退出应用程序。现在你可以尝试编译并运行你的应用程序了,你将会看到如下的一个黑色窗口:
|
||||
|
||||

|
||||
|
||||
如果你没有编译通过或者有什么问题的话,首先请检查你程序的的链接选项是否正确
|
||||
。然后对比本教程的代码,检查你的代码是不是哪里写错了,你也可以[点击这里](http://learnopengl.com/code_viewer.php?code=getting-started/hellowindow)获取我的完整代码。
|
||||
|
||||
### 输入
|
||||
|
||||
我们同样也希望能够在GLFW中实现一些键盘控制,这是通过设置GLFW的**回调函数(Callback Function)**来实现的。回调函数事实上是一个函数指针,当我们为GLFW设置回调函数后,GLWF会在恰当的时候调用它。**按键回调(KeyCallback)**是众多回调函数中的一种,当我们为GLFW设置按键回调之后,GLFW会在用户有键盘交互时调用它。该回调函数的原型如下所示:
|
||||
|
||||
```c++
|
||||
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
|
||||
```
|
||||
|
||||
按键回调函数接受一个`GLFWwindow`指针作为它的第一个参数;第二个整形参数用来表示事件的按键;第三个整形参数描述用户是否有第二个键按下或释放;第四个整形参数表示事件类型,如按下或释放;最后一个参数是表示是否有Ctrl、Shift、Alt、Super等按钮的操作。GLFW会在恰当的时候调用它,并为各个参数传入适当的值。
|
||||
|
||||
|
||||
```c++
|
||||
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
|
||||
{
|
||||
// 当用户按下ESC键,我们设置window窗口的WindowShouldClose属性为true
|
||||
// 关闭应用程序
|
||||
if(key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
|
||||
glfwSetWindowShouldClose(window, GL_TRUE);
|
||||
}
|
||||
```
|
||||
|
||||
在这个`key_callback`函数中,它检测键盘是否按下了Escape键。如果键的确按下了(不释放),我们使用`glfwSetwindowShouldClose`函数设定`WindowShouldClose`属性为true从而关闭GLFW。main函数的while循环下一次的检测将失败并且程序关闭。
|
||||
|
||||
最后一件事就是通过GLFW使用适合的回调来注册我们的函数,代码是这样的:
|
||||
|
||||
```c++
|
||||
glfwSetKeyCallback(window, key_callback);
|
||||
```
|
||||
|
||||
除了按键回调函数之外,我们还能为GLFW注册其他的回调函数。例如,我们可以注册一个函数来处理窗口尺寸变化、处理一些错误信息等。我们可以在创建窗口之后到开始游戏循环之前注册各种回调函数。
|
||||
|
||||
|
||||
### 渲染(Rendering)
|
||||
|
||||
我们要把所有的渲染操作放到游戏循环中,因为我们想让这些渲染操作在每次游戏循环迭代的时候都能被执行。我们将做如下的操作:
|
||||
|
||||
```c++
|
||||
// 程序循环
|
||||
while(!glfwWindowShouldClose(window))
|
||||
{
|
||||
// 检查事件
|
||||
glfwPollEvents();
|
||||
|
||||
// 在这里执行各种渲染操作
|
||||
...
|
||||
|
||||
//交换缓冲区
|
||||
glfwSwapBuffers(window);
|
||||
}
|
||||
```
|
||||
|
||||
为了测试一切都正常,我们想让屏幕清空为一种我们选择的颜色。在每次执行新的渲染之前我们都希望清除上一次循环的渲染结果,除非我们想要看到上一次的结果。我们可以通过调用`glClear`函数来清空屏幕缓冲区的颜色,他接受一个整形常量参数来指定要清空的缓冲区,这个常量可以是`GL_COLOR_BUFFER_BIT`,`GL_DEPTH_BUFFER_BIT`和`GL_STENCIL_BUFFER_BIT`。由于现在我们只关心颜色值,所以我们只清空颜色缓冲区。
|
||||
|
||||
```c++
|
||||
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
```
|
||||
注意,除了`glClear`之外,我们还要调用`glClearColor`来设置要清空缓冲的颜色。当调用`glClear`函数之后,整个指定清空的缓冲区都被填充为`glClearColor`所设置的颜色。在这里,我们将屏幕设置为了类似黑板的深蓝绿色。
|
||||
|
||||
!!! Important
|
||||
|
||||
你应该能够想起来我们在[OpenGL](http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/01%20OpenGL/)教程的内容,`glClearColor`函数是一个状态设置函数,而`glClear`函数则是一个状态应用的函数。
|
||||
|
||||

|
||||
|
||||
此程序的完整源代码可以在[这里](http://learnopengl.com/code_viewer.php?code=getting-started/hellowindow2)找到。
|
||||
|
||||
好了,到目前为止我们已经做好开始在游戏循环中添加许多渲染操作的准备了,我认为我们的闲扯已经够多了,从下一篇教程开始我们将真正的征程。
|
538
docs/01 Getting started/04 Hello Triangle.md
Normal file
538
docs/01 Getting started/04 Hello Triangle.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# 你好,三角形
|
||||
|
||||
原文 | [Creating a window](http://www.learnopengl.com/#!Getting-started/Hello-Triangle)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | [Django](http://bullteacher.com/)
|
||||
校对 | Geequlim
|
||||
|
||||
## 图形渲染管线(Pipeline)
|
||||
|
||||
在OpenGL中任何事物都在3D空间中,但是屏幕和窗口是一个2D像素阵列,所以OpenGL的大部分工作都是关于如何把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的**图形渲染管线**(Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一个部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。这个教程里,我们会简单地讨论一下图形渲染管线,以及如何使用它创建一些像素,这对我们来说是有好处的。
|
||||
|
||||
!!! Important
|
||||
|
||||
2D坐标和像素也是不同的,2D坐标是在2D空间中的一个点的非常精确的表达,2D像素是这个点的近似值,它受到你的屏幕/窗口解析度的限制。
|
||||
|
||||
图形渲染管线接收一组3D坐标,然后把它们转变为你屏幕上的有色2D像素。图形渲染管线可以被划分为几个阶段,每个阶段需要把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们有一个特定的函数),它们能简单地并行执行。由于它们的并行执行特性,当今大多数显卡都有成千上万的小处理核心,在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做 **着色器**(Shader)。
|
||||
|
||||
有些着色器允许开发者自己配置,我们可以用自己写的着色器替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,因为它们运行在GPU上,所以它们会节约宝贵的CPU时间。OpenGL着色器是用**OpenGL着色器语言**(OpenGL Shading Language, GLSL)写成的,我们在下一节会花更多时间研究它。
|
||||
|
||||
在下面,你会看到一个图形渲染管线的每个阶段的抽象表达。要注意蓝色部分代表的是我们可以自定义的着色器。
|
||||
|
||||

|
||||
|
||||
如你所见,图形渲染管线包含很多部分,每个都是将你的顶点数据转变为最后渲染出来的像素这个大过程中的一个特定阶段。我们会概括性地解释渲染管线的每个部分,从而使你对图形渲染管线的工作方式有个大概了解。
|
||||
|
||||
我们以数组的形式传递3个3D坐标作为图形渲染管线的输入,它用来表示一个三角形,这个数组叫做顶点数据(Vertex Data);这里顶点数据是一些顶点的集合。一个**顶点**是一个3D坐标的集合(也就是x、y、z数据)。而顶点数据是用**顶点属性**(Vertex Attributes)表示的,它可以包含任何我们希望用的数据,但是简单起见,我们还是假定每个顶点只由一个3D位置(译注1)和几个颜色值组成的吧。
|
||||
|
||||
!!! note "译注1"
|
||||
|
||||
当我们谈论一个“位置”的时候,它代表在一个“空间”中所处地点的这个特殊属性;同时“空间”代表着任何一种坐标系,比如x、y、z三维坐标系,x、y二维坐标系,或者一条直线上的x和y的线性关系,只不过二维坐标系是一个扁扁的平面空间,而一条直线是一个很瘦的长长的空间。
|
||||
|
||||
!!! Important
|
||||
|
||||
为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去提示你希望这些数据所表示的是什么类型。我们是希望把这些数据渲染成一系列的点?一系列的三角形?还是仅仅是一个长长的线?做出的这些提示叫做**基本图形**(Primitives),任何一个绘制命令的调用都必须把基本图形类型传递给OpenGL。这是其中的几个:**GL_POINTS**、**GL_TRIANGLES**、**GL_LINE_STRIP**。
|
||||
|
||||
图形渲染管线的第一个部分是**顶点着色器**(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。
|
||||
|
||||
**基本图形装配**(Primitive Assembly)阶段把顶点着色器的表示为基本图形的所有顶点作为输入(如果选择的是`GL_POINTS`,那么就是一个单独顶点),把所有点组装为特定的基本图形的形状;本节例子是一个三角形。
|
||||
|
||||
基本图形装配阶段的输出会传递给**几何着色器**(Geometry Shader)。几何着色器把基本图形形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其他的)基本图形来生成其他形状。例子中,它生成了另一个三角形。
|
||||
|
||||
**细分着色器**(Tessellation Shaders)拥有把给定基本图形**细分**为更多小基本图形的能力。这样我们就能在物体更接近玩家的时候通过创建更多的三角形的方式创建出更加平滑的视觉效果。
|
||||
|
||||
细分着色器的输出会进入**光栅化**(Rasterization也译为像素化)阶段,这里它会把基本图形映射为屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前,会执行**裁切**(Clipping)。裁切会丢弃超出你的视图以外的那些像素,来提升执行效率。
|
||||
|
||||
|
||||
!!! Important
|
||||
|
||||
OpenGL中的一个fragment是OpenGL渲染一个独立像素所需的所有数据。
|
||||
|
||||
**片段着色器**的主要目的是计算一个像素的最终颜色,这也是OpenGL高级效果产生的地方。通常,片段着色器包含用来计算像素最终颜色的3D场景的一些数据(比如光照、阴影、光的颜色等等)。
|
||||
|
||||
在所有相应颜色值确定以后,最终它会传到另一个阶段,我们叫做**alpha测试**和**混合**(Blending)阶段。这个阶段检测像素的相应的深度(和Stencil)值(后面会讲),使用这些,来检查这个像素是否在另一个物体的前面或后面,如此做到相应取舍。这个阶段也会检查**alpha值**(alpha值是一个物体的透明度值)和物体之间的**混合**(Blend)。所以,即使在片段着色器中计算出来了一个像素所输出的颜色,最后的像素颜色在渲染多个三角形的时候也可能完全不同。
|
||||
|
||||
正如你所见的那样,图形渲染管线非常复杂,它包含很多要配置的部分。然而,对于大多数场合,我们必须做的只是顶点和片段着色器。几何着色器和细分着色器是可选的,通常使用默认的着色器就行了。
|
||||
|
||||
在现代OpenGL中,我们**必须**定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。出于这个原因,开始学习现代OpenGL的时候非常困难,因为在你能够渲染自己的第一个三角形之前需要一大堆知识。本节结束就是你可以最终渲染出你的三角形的时候,你也会了解到很多图形编程知识。
|
||||
|
||||
## 顶点输入(Vertex Input)
|
||||
|
||||
开始绘制一些东西之前,我们必须给OpenGL输入一些顶点数据。OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是在3D坐标里(x、y和z)。OpenGL不是简单的把你所有的3D坐标变换为你屏幕上的2D像素;OpenGL只是在当它们的3个轴(x、y和z)在特定的-1.0到1.0的范围内时才处理3D坐标。所有在这个范围内的坐标叫做**标准化设备坐标**(Normalized Device Coordinates,NDC)会最终显示在你的屏幕上(所有出了这个范围的都不会显示)。
|
||||
|
||||
由于我们希望渲染一个三角形,我们指定所有的这三个顶点都有一个3D位置。我们把它们以`GLfloat`数组的方式定义为标准化设备坐标(也就是在OpenGL的可见区域)中。
|
||||
|
||||
```c++
|
||||
GLfloat vertices[] = {
|
||||
-0.5f, -0.5f, 0.0f,
|
||||
0.5f, -0.5f, 0.0f,
|
||||
0.0f, 0.5f, 0.0f
|
||||
};
|
||||
```
|
||||
|
||||
由于OpenGL是在3D空间中工作的,我们渲染一个2D三角形,它的每个顶点都要有同一个z坐标0.0。在这样的方式中,三角形的每一处的深度(Depth, 译注2)都一样,从而使它看上去就像2D的。
|
||||
|
||||
!!! note "译注2"
|
||||
|
||||
通常深度可以理解为z坐标,它代表一个像素在空间中和你的距离,如果离你远就可能被别的像素遮挡,你就看不到它了,它会被丢弃,以节省资源。
|
||||
|
||||
!!! Important
|
||||
|
||||
**标准化设备坐标(Normalized Device Coordinates, NDC)**
|
||||
|
||||
一旦你的顶点坐标已经在顶点着色器中处理过,它们就应该是**标准化设备坐标**了,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。下面你会看到我们定义的在标准化设备坐标中的三角形(忽略z轴):
|
||||
|
||||

|
||||
|
||||
与通常的屏幕坐标不同,y轴正方向上的点和(0,0)坐标是这个图像的中心,而不是左上角。最后你希望所有(变换过的)坐标都在这个坐标空间中,否则它们就不可见了。
|
||||
|
||||
你的标准化设备坐标接着会变换为**屏幕空间坐标**(Screen-space Coordinates),这是使用你通过`glViewport`函数提供的数据,进行**视口变换**(Viewport Transform)完成的。最后的屏幕空间坐标被变换为像素输入到片段着色器。
|
||||
|
||||
有了这样的顶点数据,我们会把它作为输入数据发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建储存空间用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定如何发送给显卡。顶点着色器接着会处理我们告诉它要处理内存中的顶点的数量。
|
||||
|
||||
我们通过**顶点缓冲对象**(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大批顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以无论何处我们都要尝试尽量一次性发送尽可能多的数据。当数据到了显卡内存中时,顶点着色器几乎立即就能获得顶点,这非常快。
|
||||
|
||||
顶点缓冲对象(VBO)是我们在OpenGL教程中第一个出现的OpenGL对象。就像OpenGL中的其他对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用`glGenBuffers`函数生成一个缓冲ID:
|
||||
|
||||
```c++
|
||||
GLuint VBO;
|
||||
glGenBuffers(1, &VBO);
|
||||
```
|
||||
|
||||
OpenGL有很多缓冲对象类型,`GL_ARRAY_BUFFER`是其中一个顶点缓冲对象的缓冲类型。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用`glBindBuffer`函数把新创建的缓冲绑定到`GL_ARRAY_BUFFER`上:
|
||||
|
||||
```c++
|
||||
glBindBuffer(GL_ARRAY_BUFFER, VBO);
|
||||
```
|
||||
|
||||
从这一刻起,我们使用的任何缓冲函数(在`GL_ARRAY_BUFFER`目标上)都会用来配置当前绑定的缓冲(`VBO`)。然后我们可以调用`glBufferData`函数,它会把之前定义的顶点数据复制到缓冲的内存中:
|
||||
|
||||
```c++
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
|
||||
```
|
||||
|
||||
`glBufferData`是一个用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是我们希望把数据复制到上面的缓冲类型:顶点缓冲对象当前绑定到`GL_ARRAY_BUFFER`目标上。第二个参数指定我们希望传递给缓冲的数据的大小(以字节为单位);用一个简单的`sizeof`计算出顶点数据就行。第三个参数是我们希望发送的真实数据。
|
||||
|
||||
第四个参数指定了我们希望显卡如何管理给定的数据。有三种形式:
|
||||
|
||||
- `GL_STATIC_DRAW` :数据不会或几乎不会改变。
|
||||
- `GL_DYNAMIC_DRAW`:数据会被改变很多。
|
||||
- `GL_STREAM_DRAW` :数据每次绘制时都会改变。
|
||||
|
||||
三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它使用的类型最好是`GL_STATIC_DRAW`。如果,比如,一个缓冲中的数据将频繁被改变,那么使用的类型就是`GL_DYNAMIC_DRAW`或`GL_STREAM_DRAW`。这样就能确保图形卡把数据放在高速写入的内存部分。
|
||||
|
||||
现在我们把顶点数据储存在显卡的内存中,用VBO顶点缓冲对象管理。下面我们会创建一个顶点和片段着色器,来处理这些数据。现在我们开始着手创建它们吧。
|
||||
|
||||
## 顶点着色器(Vertex Shader)
|
||||
|
||||
顶点着色器是几个着色器中的一个,它是可编程的。现代OpenGL需要我们至少设置一个顶点和一个片段着色器,如果我们打算做渲染的话。我们会简要介绍一下着色器以及配置两个非常简单的着色器来绘制我们第一个三角形。下个教程里我们会更详细的讨论着色器。
|
||||
|
||||
我们需要做的第一件事是用着色器语言GLSL写顶点着色器,然后编译这个着色器,这样我们就可以在应用中使用它了。下面你会看到一个非常基础的顶点着色器的源代码,它就是使用GLSL写的:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
|
||||
layout (location = 0) in vec3 position;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(position.x, position.y, position.z, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
就像你所看到的那样,GLSL看起来很像C语言。每个着色器都起始于一个版本声明。这是因为OpenGL 3.3和更高的GLSL版本号要去匹配OpenGL的版本(GLSL420版本对应于OpenGL 4.2)。我们同样显式地表示我们会用核心模式(Core-profile)。
|
||||
|
||||
下一步,我们在顶点着色器中声明所有的输入顶点属性,使用in关键字。现在我们只关心位置(Position)数据,所以我们只需要一个顶点属性(Attribute)。GLSL有一个向量数据类型,它包含1到4个`float`元素,包含的数量可以从它的后缀看出来。由于每个顶点都有一个3D坐标,我们就创建一个`vec3`输入变量来表示位置(Position)。我们同样也指定输入变量的位置值(Location),这是用`layout (location = 0)`来完成的,你后面会看到为什么我们会需要这个位置值。
|
||||
|
||||
!!! Important
|
||||
|
||||
**向量(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元素。我们可以通过把`vec3`数据作为`vec4`初始化构造器的参数,同时把`w`元素设置为`1.0f`(我们会在后面解释为什么)。
|
||||
|
||||
这个顶点着色器可能是能想到的最简单的了,因为我们什么都没有处理就把输入数据输出了。在真实的应用里输入数据通常都没有在标准化设备坐标中,所以我们首先就必须把它们放进OpenGL的可视区域内。
|
||||
|
||||
## 编译一个着色器
|
||||
|
||||
我们已经写了一个顶点着色器源码,但是为了OpenGL能够使用它,我们必须在运行时动态编译它的源码。
|
||||
|
||||
我们要做的第一件事是创建一个着色器对象,再次引用它的ID。所以我们储存这个顶点着色器为`GLuint`,然后用`glCreateShader`创建着色器:
|
||||
|
||||
```c++
|
||||
GLuint vertexShader;
|
||||
vertexShader = glCreateShader(GL_VERTEX_SHADER);
|
||||
```
|
||||
|
||||
我们把着色器的类型提供`glCreateShader`作为它的参数。这里我们传递的参数是`GL_VERTEX_SHADER`这样就创建了一个顶点着色器。
|
||||
|
||||
下一步我们把这个着色器源码附加到着色器对象,然后编译它:
|
||||
|
||||
```c++
|
||||
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
|
||||
glCompileShader(vertexShader);
|
||||
```
|
||||
|
||||
`glShaderSource`函数把着色器对象作为第一个参数来编译它。第二参数指定了源码中有多少个**字符串**,这里只有一个。第三个参数是顶点着色器真正的源码,我们可以把第四个参数先设置为`NULL`。
|
||||
|
||||
!!! Important
|
||||
|
||||
你可能会希望检测调用`glCompileShader`后是否编译成功了,是否要去修正错误。检测编译时错误的方法是:
|
||||
|
||||
GLint success;
|
||||
GLchar 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;
|
||||
}
|
||||
|
||||
如果编译的时候没有任何错误,顶点着色器就被编译成功了。
|
||||
|
||||
## 片段着色器(Fragment Shader)
|
||||
|
||||
片段着色器是第二个也是最终我们打算创建的用于渲染三角形的着色器。片段着色器的全部,都是用来计算你的像素的最后颜色输出。为了让事情比较简单,我们的片段着色器只输出橘黄色。
|
||||
|
||||
!!! Important
|
||||
|
||||
在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度)元素,通常缩写为RGBA。当定义一个OpenGL或GLSL的颜色的时候,我们就把每个颜色的强度设置在0.0到1.0之间。比如,我们设置红色为1.0f,绿色为1.0f,这样这个混合色就是黄色了。这三种颜色元素的不同调配可以生成1600万不同颜色!
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
|
||||
out vec4 color;
|
||||
|
||||
void main()
|
||||
{
|
||||
color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
|
||||
}
|
||||
```
|
||||
|
||||
片段着色器只需要一个输出变量,这个变量是一个4元素表示的最终输出颜色的向量,我们可以自己计算出来。我们可以用`out`关键字声明输出变量,这里我们命名为`color`。下面,我们简单的把一个带有alpha值为1.0(1.0代表完全不透明)的橘黄的`vec4`赋值给`color`作为输出。
|
||||
|
||||
编译片段着色器的过程与顶点着色器相似,尽管这次我们使用`GL_FRAGMENT_SHADER`作为着色器类型:
|
||||
|
||||
```c++
|
||||
GLuint fragmentShader;
|
||||
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
|
||||
glShaderSource(fragmentShader, 1, &fragmentShaderSource, null);
|
||||
glCompileShader(fragmentShader);
|
||||
```
|
||||
|
||||
每个着色器现在都编译了,剩下的事情是把两个着色器对象链接到一个着色器程序中(Shader Program),它是用来渲染的。
|
||||
|
||||
### 着色器程序
|
||||
|
||||
着色器程序对象(Shader Program Object)是多个着色器最后链接的版本。如果要使用刚才编译的着色器我们必须把它们链接为一个着色器程序对象,然后当渲染物体的时候激活这个着色器程序。激活了的着色器程序的着色器,在调用渲染函数时才可用。
|
||||
|
||||
把着色器链接为一个程序就等于把每个着色器的输出链接到下一个着色器的输入。如果你的输出和输入不匹配那么就会得到一个链接错误。
|
||||
|
||||
创建一个程序对象很简单:
|
||||
|
||||
```c++
|
||||
GLuint shaderProgram;
|
||||
shaderProgram = glCreateProgram();
|
||||
```
|
||||
|
||||
`glCreateProgram`函数创建一个程序,返回新创建的程序对象的ID引用。现在我们需要把前面编译的着色器附加到程序对象上,然后用`glLinkProgram`链接它们:
|
||||
|
||||
```c++
|
||||
glAttachShader(shaderProgram, vertexShader);
|
||||
glAttachShader(shaderProgram, fragmentShader);
|
||||
glLinkProgram(shaderProgram);
|
||||
```
|
||||
|
||||
代码不言自明,我们把着色器附加到程序上,然后用`glLinkProgram`链接。
|
||||
|
||||
!!! Important
|
||||
|
||||
就像着色器的编译一样,我们也可以检验链接着色器程序是否失败,获得相应的日志。与glGetShaderiv和glGetShaderInfoLog不同,现在我们使用:
|
||||
|
||||
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
|
||||
if(!success) {
|
||||
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
|
||||
...
|
||||
}
|
||||
|
||||
我们可以调用`glUseProgram`函数,用新创建的程序对象作为它的参数,这样就能激活这个程序对象:
|
||||
|
||||
```c++
|
||||
glUseProgram(shaderProgram);
|
||||
```
|
||||
|
||||
现在在`glUseProgram`函数调用之后的每个着色器和渲染函数都会用到这个程序对象(当然还有这些链接的着色器)了。
|
||||
|
||||
在我们把着色器对象链接到程序对象以后,不要忘记删除着色器对象;我们不再需要它们了:
|
||||
|
||||
```c++
|
||||
glDeleteShader(vertexShader);
|
||||
glDeleteShader(fragmentShader);
|
||||
```
|
||||
|
||||
现在,我们把输入顶点数据发送给GPU,指示GPU如何在顶点和片段着色器中处理它。还没结束,OpenGL还不知道如何解释内存中的顶点数据,以及怎样把顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。
|
||||
|
||||
## 链接顶点属性
|
||||
|
||||
顶点着色器允许我们以任何我们想要的形式作为顶点属性(Vertex Attribute)的输入,同样它也具有很强的灵活性,这意味着我们必须手动指定我们的输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。这意味着我们必须在渲染前指定OpenGL如何解释顶点数据。
|
||||
|
||||
我们的顶点缓冲数据被格式化为下面的形式:
|
||||
|
||||

|
||||
|
||||
- 位置数据被储存为32-bit(4 byte)浮点值。
|
||||
- 每个位置包含3个这样的值。
|
||||
- 在这3个值之间没有空隙(或其他值)。这几个值紧密排列为一个数组。
|
||||
- 数据中第一个值是缓冲的开始位置。
|
||||
|
||||
有了这些信息我们就可以告诉OpenGL如何解释顶点数据了(每一个顶点属性),我们使用`glVertexAttribPointer`这个函数:
|
||||
|
||||
```c++
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
|
||||
glEnableVertexAttribArray(0);
|
||||
```
|
||||
|
||||
`glVertexAttribPointer`函数有很多参数,所以我们仔细来了解它们:
|
||||
|
||||
- 第一个参数指定我们要配置哪一个顶点属性。记住,我们在顶点着色器中使用`layout(location = 0)`定义了顶点属性——位置(Position)的位置值(Location)。这样要把顶点属性的位置值(Location)设置为0,因为我们希望把数据传递到这个顶点属性中,所以我们在这里填0。
|
||||
- 第二个参数指定顶点属性的大小。顶点属性是`vec3`类型,它由3个数值组成。
|
||||
- 第三个参数指定数据的类型,这里是`GL_FLOAT`(GLSL中`vec*`是由浮点数组成的)。
|
||||
- 下个参数定义我们是否希望数据被标准化。如果我们设置为`GL_TRUE`,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为`GL_FALSE`。
|
||||
- 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性之间间隔有多少。由于下个位置数据在3个`GLfloat`后面的位置,我们把步长设置为`3 * sizeof(GLfloat)`。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。每当我们有更多的顶点属性,我们就必须小心地定义每个顶点属性之间的空间,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
|
||||
- 最后一个参数有奇怪的`GLvoid*`的强制类型转换。它表示我们的位置数据在缓冲中起始位置的偏移量。由于位置数据是数组的开始,所以这里是0。我们会在后面详细解释这个参数。
|
||||
|
||||
!!! Important
|
||||
|
||||
每个顶点属性从VBO管理的内存中获得它的数据,它所获取数据的那个VBO,就是当调用`glVetexAttribPointer`的时候,最近绑定到`GL_ARRAY_BUFFER`的那个VBO。由于在调用`glVertexAttribPointer`之前绑定了VBO,顶点属性0现在链接到了它的顶点数据。
|
||||
|
||||
现在我们定义OpenGL如何解释顶点数据,我们也要开启顶点属性,使用`glEnableVertexAttribArray`,把顶点属性位置值作为它的参数;顶点属性默认是关闭的。自此,我们把每件事都做好了:我们使用一个顶点缓冲对象初始化了一个缓冲中的顶点数据,设置了一个顶点和片段着色器,告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL绘制一个物体,看起来会像是这样:
|
||||
|
||||
```c++
|
||||
// 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(GLfloat), (GLvoid*)0);
|
||||
glEnableVertexAttribArray(0);
|
||||
// 2. 当我们打算渲染一个物体时要使用着色器程序
|
||||
glUseProgram(shaderProgram);
|
||||
// 3. 绘制物体
|
||||
someOpenGLFunctionThatDrawsOurTriangle();
|
||||
```
|
||||
|
||||
我们绘制一个物体的时候必须重复这件事。这看起来也不多,但是如果有超过5个顶点属性,100多个不同物体呢(这其实并不罕见)。绑定合适的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事。有没有一些方法可以使我们把所有的配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态?
|
||||
|
||||
### 顶点数组对象(Vertex Array Object, VAO)
|
||||
|
||||
**顶点数组对象(Vertex Array Object, VAO)**可以像顶点缓冲对象一样绑定,任何随后的顶点属性调用都会储存在这个VAO中。这有一个好处,当配置顶点属性指针时,你只用做一次,每次绘制一个物体的时候,我们绑定相应VAO就行了。切换不同顶点数据和属性配置就像绑定一个不同的VAO一样简单。所有状态我们都放到了VAO里。
|
||||
|
||||
!!! Attention
|
||||
|
||||
OpenGL核心模式版要求我们使用VAO,这样它就能知道对我们的顶点输入做些什么。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。
|
||||
|
||||
一个顶点数组对象储存下面的内容:
|
||||
|
||||
- 调用`glEnableVertexAttribArray`和`glDisableVertexAttribArray`。
|
||||
- 使用`glVertexAttribPointer`的顶点属性配置。
|
||||
- 使用`glVertexAttribPointer`进行的顶点缓冲对象与顶点属性链接。
|
||||
|
||||

|
||||
|
||||
生成一个VAO和生成VBO类似:
|
||||
|
||||
```c++
|
||||
GLuint VAO;
|
||||
glGenVertexArrays(1, &VAO);
|
||||
```
|
||||
|
||||
使用VAO要做的全部就是使用`glBindVertexArray`绑定VAO。自此我们就应该绑定/配置相应的VBO和属性指针,然后解绑VAO以备后用。当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把VAO绑定到希望用到的配置就行了。这段代码应该看起来像这样:
|
||||
|
||||
```c++
|
||||
// ..:: 初始化代码 (一次完成 (除非你的物体频繁改变)) :: ..
|
||||
|
||||
// 1. 绑定VAO
|
||||
glBindVertexArray(VAO);
|
||||
|
||||
// 2. 把顶点数组复制到缓冲中提供给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(GLfloat), (GLvoid * )0);
|
||||
glEnableVertexAttribArray(0);
|
||||
|
||||
//4. 解绑VAO
|
||||
glBindVertexArray(0);
|
||||
|
||||
[...]
|
||||
|
||||
// ..:: 绘制代码 (in Game loop) :: ..
|
||||
|
||||
// 5. 绘制物体
|
||||
glUseProgram(shaderProgram);
|
||||
glBindVertexArray(VAO);
|
||||
someOpenGLFunctionThatDrawsOurTriangle();
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
!!! Attention
|
||||
|
||||
通常情况下当我们配置好它们以后要解绑OpenGL对象,这样我们才不会在某处错误地配置它们。
|
||||
|
||||
就是现在!前面做的一切都是等待这一刻,我们已经把我们的顶点属性配置和打算使用的VBO储存到一个VAO中。一般当你有多个物体打算绘制时,你首先要生成/配置所有的VAO(它需要VBO和属性指针),然后储存它们准备后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。
|
||||
|
||||
### 我们一直期待的三角形
|
||||
|
||||
OpenGL的`glDrawArrays`函数为我们提供了绘制物体的能力,它使用当前激活的着色器、前面定义的顶点属性配置和VBO的顶点数据(通过VAO间接绑定)来绘制基本图形。
|
||||
|
||||
```c++
|
||||
glUseProgram(shaderProgram);
|
||||
glBindVertexArray(VAO);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
`glDrawArrays`函数第一个参数是我们打算绘制的OpenGL基本图形的类型。由于我们在一开始时说过,我们希望绘制三角形,我们传递`GL_TRIANGLES`给它。第二个参数定义了我们打算绘制的那个顶点数组的起始位置的索引;我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据渲染一个三角形,它只有3个顶点)。
|
||||
|
||||
现在尝试编译代码,如果弹出了任何错误,回头检查你的代码。如果你编译通过了,你应该看到下面的结果:
|
||||
|
||||

|
||||
|
||||
完整的程序源码可以在[这里](http://learnopengl.com/code_viewer.php?code=getting-started/hellotriangle)找到。
|
||||
|
||||
如果你的输出和这个不一样,你可能做错了什么,去看源码,看看是否遗漏了什么东西或者在评论部分提问。
|
||||
|
||||
## 索引缓冲对象(Element Buffer Objects,EBO)
|
||||
|
||||
这是我们最后一件在渲染顶点这个问题上要讨论的事——索引缓冲对象简称EBO(或IBO)。解释索引缓冲对象的工作方式最好是举例子:假设我们不再绘制一个三角形而是矩形。我们就可以绘制两个三角形来组成一个矩形(OpenGL主要就是绘制三角形)。这会生成下面的顶点的集合:
|
||||
|
||||
```c++
|
||||
GLfloat 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%的额外开销。当我们有超过1000个三角的模型这个问题会更糟糕,这会产生一大堆浪费。最好的解决方案就是每个顶点只储存一次,当我们打算绘制这些顶点的时候只调用顶点的索引。这种情况我们只要储存4个顶点就能绘制矩形了,我们只要指定我们打算绘制的那个顶点的索引就行了。如果OpenGL提供这个功能就好了,对吧?
|
||||
|
||||
很幸运,索引缓冲的工作方式正是这样的。一个EBO是一个像顶点缓冲对象(VBO)一样的缓冲,它专门储存索引,OpenGL调用这些顶点的索引来绘制。索引绘制正是这个问题的解决方案。我们先要定义(独一无二的)顶点,和绘制出矩形的索引:
|
||||
|
||||
```c++
|
||||
GLfloat 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 // 左上角
|
||||
};
|
||||
|
||||
GLuint indices[] = { // 起始于0!
|
||||
|
||||
0, 1, 3, // 第一个三角形
|
||||
1, 2, 3 // 第二个三角形
|
||||
};
|
||||
```
|
||||
|
||||
你可以看到,当时用索引的时候,我们只定义了4个顶点,而不是6个。下一步我们需要创建索引缓冲对象:
|
||||
|
||||
```c++
|
||||
GLuint EBO;
|
||||
glGenBuffers(1, &EBO);
|
||||
```
|
||||
|
||||
与VBO相似,我们绑定EBO然后用`glBufferData`把索引复制到缓冲里。同样,和VBO相似,我们会把这些函数调用放在绑定和解绑函数调用之间,这次我们把缓冲的类型定义为`GL_ELEMENT_ARRAY_BUFFER`。
|
||||
|
||||
```c++
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
|
||||
```
|
||||
|
||||
要注意的是,我们现在用`GL_ELEMENT_ARRAY_BUFFER`当作缓冲目标。最后一件要做的事是用`glDrawElements`来替换`glDrawArrays`函数,来指明我们从索引缓冲渲染。当时用`glDrawElements`的时候,我们就会用当前绑定的索引缓冲进行绘制:
|
||||
|
||||
```c++
|
||||
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绑定之后可以索引缓冲对象,EBO就成为了VAO的索引缓冲对象。再次绑定VAO的同时也会自动绑定EBO。
|
||||
|
||||

|
||||
|
||||
!!! Attention
|
||||
|
||||
当目标是`GL_ELEMENT_ARRAY_BUFFER`的时候,VAO储存了`glBindBuffer`的函数调用。这也意味着它也会储存解绑调用,所以确保你没有在解绑VAO之前解绑索引数组缓冲,否则就没有这个EBO配置了。
|
||||
|
||||
最后的初始化和绘制代码现在看起来像这样:
|
||||
|
||||
```c++
|
||||
// ..:: 初始化代码 :: ..
|
||||
// 1. 绑定VAO
|
||||
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);
|
||||
|
||||
// 3. 设置顶点属性指针
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid * )0);
|
||||
glEnableVertexAttribArray(0);
|
||||
|
||||
// 4. 解绑VAO,不解绑EBO(译注:解绑缓冲相当于没有绑定缓冲,可以在解绑VAO之后解绑缓冲)
|
||||
glBindVertexArray(0);
|
||||
|
||||
[...]
|
||||
|
||||
// ..:: 绘制代码(在游戏循环中) :: ..
|
||||
|
||||
glUseProgram(shaderProgram);
|
||||
glBindVertexArray(VAO);
|
||||
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
运行程序会获得下面这样的图片的结果。左侧图片看起来很熟悉,而右侧的则是使用线框模式(Wireframe Mode)绘制的。线框矩形可以显示出矩形的确是由两个三角形组成的。
|
||||
|
||||

|
||||
|
||||
!!! Important
|
||||
|
||||
**线框模式(Wireframe Mode)**
|
||||
|
||||
如果用线框模式绘制你的三角,你可以配置OpenGL绘制用的基本图形,调用`glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)`。第一个参数说:我们打算应用到所有的三角形的前面和背面,第二个参数告诉我们用线来绘制。在随后的绘制函数调用后会一直以线框模式绘制三角形,直到我们用`glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)`设置回了默认模式。
|
||||
|
||||
如果你遇到任何错误,回头检查代码,看看是否遗漏了什么。同时,你可以[在这里获得全部源码](http://learnopengl.com/code_viewer.php?code=getting-started/hellotriangle2),也可以在评论区自由提问。
|
||||
|
||||
如果你绘制出了这个三角形或矩形,那么恭喜你,你成功地通过了现代OpenGL最难部分之一:绘制你自己的第一个三角形。这部分很难,因为在可以绘制第一个三角形之前需要很多知识。幸运的是我们现在已经越过了这个障碍,接下来的教程会比较容易理解一些。
|
||||
|
||||
## 附加资源
|
||||
|
||||
- [antongerdelan.net/hellotriangle](http://antongerdelan.net/opengl/hellotriangle.html): 一个渲染第一个三角形的教程。
|
||||
- [open.gl/drawing](https://open.gl/drawing): Alexander Overvoorde的关于渲染第一个三角形的教程。
|
||||
- [antongerdelan.net/vertexbuffers](http://antongerdelan.net/opengl/vertexbuffers.html): 顶点缓冲对象的一些深入探讨。
|
||||
|
||||
# 练习
|
||||
|
||||
为了更好的理解讨论的那些概念最好做点练习。建议在继续下面的主题之前做完这些练习,确保你对这些有比较好的理解。
|
||||
|
||||
- 尝试使用`glDrawArrays`以在你的数据中添加更多顶点的方式,绘制两个彼此相连的三角形:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/hello-triangle-exercise1)
|
||||
- 现在,使用不同的VAO(和VBO)创建同样的2个三角形,每个三角形的数据要不同(提示:创建2个顶点数据数组,而不是1个):[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/hello-triangle-exercise2)
|
||||
- 创建两个着色器程序(Shader Program),第二个程序使用不同的片段着色器,它输出黄色;绘制这两个三角形,其中一个输出为黄色:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/hello-triangle-exercise3)
|
448
docs/01 Getting started/05 Shaders.md
Normal file
448
docs/01 Getting started/05 Shaders.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# 着色器(Shader)
|
||||
|
||||
原文 | [Shaders](http://learnopengl.com/#!Getting-started/Shaders)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | [Django](http://bullteacher.com/)
|
||||
校对 | Geequlim
|
||||
|
||||
在[Hello Triangle](http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/04%20Hello%20Triangle/)教程中提到,着色器是运行在GPU上的小程序。这些小程序为图形渲染管线的一个特定部分而运行。从基本意义上来说,着色器不是别的,只是一种把输入转化为输出的程序。着色器也是一种相当独立的程序,它们不能相互通信;只能通过输入和输出的方式来进行沟通。
|
||||
|
||||
前面的教程里我们简要地触及了一点着色器的皮毛。了解了如何恰当地使用它们。现在我们会用一种更加通用的方式详细解释着色器,特别是OpenGL着色器语言。
|
||||
|
||||
## GLSL
|
||||
|
||||
着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含针对向量和矩阵操作的有用特性。
|
||||
|
||||
着色器的开头总是要声明版本,接着是输入和输出变量、uniform和`main`函数。每个着色器的入口都是`main`函数,在这里我们处理所有输入变量,用输出变量输出结果。如果你不知道什么是uniform也不用担心,我们后面会进行讲解。
|
||||
|
||||
一个典型的着色器有下面的结构:
|
||||
|
||||
```c++
|
||||
#version version_number
|
||||
|
||||
in type in_variable_name;
|
||||
in type in_variable_name;
|
||||
|
||||
out type out_variable_name;
|
||||
|
||||
uniform type uniform_name;
|
||||
|
||||
int main()
|
||||
{
|
||||
// 处理输入
|
||||
...
|
||||
// 输出
|
||||
out_variable_name = weird_stuff_we_processed;
|
||||
}
|
||||
```
|
||||
|
||||
当我们谈论特别是谈到顶点着色器的时候,每个输入变量也叫顶点属性(Vertex Attribute)。能声明多少个顶点属性是由硬件决定的。OpenGL确保至少有16个包含4个元素的顶点属性可用,但是有些硬件或许可用更多,你可以查询`GL_MAX_VERTEX_ATTRIB`S来获取这个数目。
|
||||
|
||||
```c++
|
||||
GLint 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也有两种容器类型,教程中我们会使用很多,它们是向量(Vector)和矩阵(Matrix),其中矩阵我们会在之后的教程里再讨论。
|
||||
|
||||
## 向量(Vector)
|
||||
|
||||
GLSL中的向量可以包含有1、2、3或者4个分量,分量类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(n代表元素数量):
|
||||
|
||||
类型|含义
|
||||
---|---
|
||||
vecn | 包含n个默认为float元素的向量
|
||||
bvecn| 包含n个布尔元素向量
|
||||
ivecn| 包含n个int元素的向量
|
||||
uvecn| 包含n个unsigned int元素的向量
|
||||
dvecn| 包含n个double元素的向量
|
||||
|
||||
大多数时候我们使用vecn,因为float足够满足大多数要求。
|
||||
|
||||
一个向量的元素可以通过`vec.x`这种方式获取,这里`x`是指这个向量的第一个元素。你可以分别使用`.x`、`.y`、`.z`和`.w`来获取它们的第1、2、3、4号元素。GLSL也允许你使用**rgba**来获取颜色的元素,或是**stpq**获取纹理坐标元素。
|
||||
|
||||
向量的数据类型也允许一些有趣而灵活的元素选择方式,叫做重组(Swizzling)。重组允许这样的语法:
|
||||
|
||||
```c++
|
||||
vec2 someVec;
|
||||
vec4 differentVec = someVec.xyxx;
|
||||
vec3 anotherVec = differentVec.zyw;
|
||||
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
|
||||
```
|
||||
|
||||
你可以使用上面任何4个字母组合来创建一个新的和原来向量一样长的向量(但4个元素需要是同一种类型);不允许在一个vec2向量中去获取.z元素。我们可以把一个向量作为一个参数传给不同的向量构造函数,以减少参数需求的数量:
|
||||
|
||||
```c++
|
||||
vec2 vect = vec2(0.5f, 0.7f);
|
||||
vec4 result = vec4(vect, 0.0f, 0.0f);
|
||||
vec4 otherResult = vec4(result.xyz, 1.0f);
|
||||
```
|
||||
|
||||
向量是一种灵活的数据类型,我们可以把用在所有输入和输出上。学完教程你会看到很多如何创造性地管理向量的例子。
|
||||
|
||||
## 输入与输出(in vs out)
|
||||
|
||||
着色器是各自独立的小程序,但是它们都是一个整体的局部,出于这样的原因,我们希望每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL定义了`in`和`out`关键字来实现这个目的。每个着色器使用这些关键字定义输入和输出,无论在哪儿,一个输出变量就能与一个下一个阶段的输入变量相匹配。他们在顶点和片段着色器之间有点不同。
|
||||
|
||||
顶点着色器应该接收的输入是一种特有形式,否则就会效率低下。顶点着色器的输入是特殊的,它所接受的是从顶点数据直接输入的。为了定义顶点数据被如何组织,我们使用`location`元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。我们已经在前面的教程看过`layout (location = 0)`。顶点着色器需要为它的输入提供一个额外的`layout`定义,这样我们才能把它链接到顶点数据。
|
||||
|
||||
!!! Important
|
||||
|
||||
也可以移除`layout (location = 0)`,通过在OpenGL代码中使用`glGetAttribLocation`请求属性地址(Location),但是我更喜欢在着色器中设置它们,理解容易而且节省时间。
|
||||
|
||||
另一个例外是片段着色器需要一个`vec4`颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。
|
||||
|
||||
所以,如果我们打算从一个着色器向另一个着色器发送数据,我们必须**在发送方着色器中声明一个输出,在接收方着色器中声明一个同名输入**。当名字和类型都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序(Program)对象时完成的)。为了展示这是这么工作的,我们会改变前面教程里的那个着色器,让顶点着色器为片段着色器决定颜色。
|
||||
|
||||
#### 顶点着色器
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
layout (location = 0) in vec3 position; // 位置变量的属性为0
|
||||
|
||||
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(position, 1.0); // 把一个vec3作为vec4的构造器的参数
|
||||
vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // 把输出颜色设置为暗红色
|
||||
}
|
||||
```
|
||||
#### 片段着色器
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
in vec4 vertexColor; // 和顶点着色器的vertexColor变量类型相同、名称相同
|
||||
|
||||
out vec4 color; // 片段着色器输出的变量名可以任意命名,类型必须是vec4
|
||||
|
||||
void main()
|
||||
{
|
||||
color = vertexColor;
|
||||
}
|
||||
```
|
||||
|
||||
你可以看到我们在顶点着色器中声明了一个`vertexColor`变量作为`vec4`输出,在片段着色器声明了一个一样的`vertexColor`。由于它们**类型相同并且名字也相同**,片段着色器中的`vertexColor`就和顶点着色器中的`vertexColor`链接了。因为我们在顶点着色器中设置的颜色是深红色的,片段着色器输出的结果也是深红色的。下面的图片展示了输出结果:
|
||||
|
||||

|
||||
|
||||
我们完成了从顶点着色器向片段着色器发送数据。让我们更上一层楼,看看能否从应用程序中直接给片段着色器发送一个颜色!
|
||||
|
||||
## Uniform
|
||||
|
||||
uniform是另一种从CPU应用向GPU着色器发送数据的方式,但uniform和顶点属性有点不同。首先,uniform是**全局的(Global)**。这里全局的意思是uniform变量必须在所有着色器程序对象中都是独一无二的,它可以在着色器程序的任何着色器任何阶段使用。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。
|
||||
|
||||
我们可以简单地通过在片段着色器中设置uniform关键字接类型和变量名来声明一个GLSL的uniform。之后,我们可以在着色器中使用新声明的uniform了。我们来看看这次是否能通过uniform设置三角形的颜色:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
out vec4 color;
|
||||
|
||||
uniform vec4 ourColor; //在程序代码中设置
|
||||
|
||||
void main()
|
||||
{
|
||||
color = ourColor;
|
||||
}
|
||||
```
|
||||
|
||||
我们在片段着色器中声明了一个uniform vec4的`ourColor`,并把片段着色器的输出颜色设置为uniform值。因为uniform是全局变量,我们我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。顶点着色器中不需要这个uniform所以不用在那里定义它。
|
||||
|
||||
!!! Attention
|
||||
|
||||
如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,从而最后编译出的版本中并不会包含它,如果有一个从没用过的uniform出现在已编译版本中会出现几个错误,记住这点!
|
||||
|
||||
uniform现在还是空的;我们没有给它添加任何数据,所以下面就做这件事。我们首先需要找到着色器中uniform的索引/地址。当我们得到uniform的索引/地址后,我们就可以更新它的值了。这里我们不去给像素传递一个颜色,而是随着时间让它改变颜色:
|
||||
|
||||
```c++
|
||||
GLfloat timeValue = glfwGetTime();
|
||||
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
|
||||
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
|
||||
glUseProgram(shaderProgram);
|
||||
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
|
||||
```
|
||||
|
||||
首先我们通过`glfwGetTime()`获取运行的秒数。然后我们使用余弦函数在0.0到-1.0之间改变颜色,最后储存到`greenValue`里。
|
||||
|
||||
接着,我们用`glGetUniformLocation`请求`uniform ourColor`的地址。我们为请求函数提供着色器程序和uniform的名字(这是我们希望获得的地址的来源)。如果`glGetUniformLocation`返回`-1`就代表没有找到这个地址。最后,我们可以通过`glUniform4f`函数设置uniform值。注意,查询uniform地址不需要在之前使用着色器程序,但是更新一个unform之前**必须**使用程序(调用`glUseProgram`),因为它是在当前激活的着色器程序中设置unform的。
|
||||
|
||||
!!! Important
|
||||
|
||||
因为OpenGL是C库内核,所以它不支持函数重载,在函数参数不同的时候就要定义新的函数;glUniform是一个典型例子。这个函数有一个特定的作为类型的后缀。有几种可用的后缀:
|
||||
|
||||
后缀|含义
|
||||
---|--
|
||||
f | 函数需要以一个float作为它的值
|
||||
i | 函数需要一个int作为它的值
|
||||
ui| 函数需要一个unsigned int作为它的值
|
||||
3f| 函数需要3个float作为它的值
|
||||
fv| 函数需要一个float向量/数组作为它的值
|
||||
|
||||
每当你打算配置一个OpenGL的选项时就可以简单地根据这些规则选择适合你的数据类型的重载的函数。在我们的例子里,我们使用uniform的4float版,所以我们通过`glUniform4f`传递我们的数据(注意,我们也可以使用fv版本)。
|
||||
|
||||
现在你知道如何设置uniform变量的值了,我们可以使用它们来渲染了。如果我们打算让颜色慢慢变化,我们就要在游戏循环的每一帧更新这个uniform,否则三角形就不会改变颜色。下面我们就计算greenValue然后每个渲染迭代都更新这个uniform:
|
||||
|
||||
```c++
|
||||
while(!glfwWindowShouldClose(window))
|
||||
{
|
||||
// 检测事件
|
||||
glfwPollEvents();
|
||||
|
||||
// 渲染
|
||||
// 清空颜色缓冲
|
||||
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
// 激活着色器
|
||||
glUseProgram(shaderProgram);
|
||||
|
||||
// 更新uniform颜色
|
||||
GLfloat timeValue = glfwGetTime();
|
||||
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
|
||||
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
|
||||
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
|
||||
|
||||
// 绘制三角形
|
||||
glBindVertexArray(VAO);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
glBindVertexArray(0);
|
||||
}
|
||||
```
|
||||
|
||||
新代码和上一节的很相似。这次,我们在每个循环绘制三角形前先更新uniform值。如果你成功更新uniform了,你会看到你的三角形逐渐由绿变黑再变绿。
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/shaders.mp4" controls="controls"/></video>
|
||||
|
||||
如果你在哪儿卡住了,[这里有源码](http://www.learnopengl.com/code_viewer.php?code=getting-started/shaders-uniform)。
|
||||
|
||||
就像你所看到的那样,uniform是个设置属性的很有用的工具,它可以在渲染循环中改变,也可以在你的应用和着色器之间进行数据交互,但假如我们打算为每个顶点设置一个颜色的时候该怎么办?这种情况下,我们就不得不声明和顶点数目一样多的uniform了。在顶点属性问题上一个更好的解决方案一定要能包含足够多的数据,这是我们接下来要讲的内容。
|
||||
|
||||
## 更多属性
|
||||
|
||||
前面的教程中,我们了解了如何填充VBO、配置顶点属性指针以及如何把它们都储存到VAO里。这次,我们同样打算把颜色数据加进顶点数据中。我们将把颜色数据表示为3个float的**顶点数组(Vertex Array)**。我们为三角形的每个角分别指定为红色、绿色和蓝色:
|
||||
|
||||
```c++
|
||||
GLfloat 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`标识符来吧`color`属性的`location`设置为1:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
layout (location = 0) in vec3 position; // 位置变量的属性position为 0
|
||||
layout (location = 1) in vec3 color; // 颜色变量的属性position为 1
|
||||
|
||||
out vec3 ourColor; // 向片段着色器输出一个颜色
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(position, 1.0);
|
||||
ourColor = color; // 把ourColor设置为我们从顶点数据那里得到的输入颜色
|
||||
}
|
||||
```
|
||||
|
||||
由于我们不再使用uniform来传递片段的颜色了,现在使用的`ourColor`输出变量要求必须也去改变片段着色器:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
in vec3 ourColor
|
||||
out vec4 color;
|
||||
void main()
|
||||
{
|
||||
color = vec4(ourColor, 1.0f);
|
||||
}
|
||||
```
|
||||
|
||||
因为我们添加了另一个顶点属性,并且更新了VBO的内存,我们就必须重新配置顶点属性指针。更新后的VBO内存中的数据现在看起来像这样:
|
||||
|
||||

|
||||
|
||||
知道了当前使用的layout,我们就可以使用`glVertexAttribPointer`函数更新顶点格式,
|
||||
|
||||
```c++
|
||||
// 顶点属性
|
||||
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
|
||||
glEnableVertexAttribArray(0);
|
||||
// 颜色属性
|
||||
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
|
||||
glEnableVertexAttribArray(1);
|
||||
```
|
||||
|
||||
`glVertexAttribPointer`函数的前几个参数比较明了。这次我们配置属性location为1的顶点属性。颜色值有3个float那么大,我们不去标准化这些值。
|
||||
|
||||
由于我们现在有了两个顶点属性,我们不得不重新计算步长值(Stride)。为获得数据队列中下一个属性值(比如位置向量的下个x元素)我们必须向右移动6个float,其中3个是位置值,另外三个是颜色值。这给了我们6个步长的大小,每个步长都是float的字节数(=24字节)。
|
||||
|
||||
同样,这次我们必须指定一个偏移量(Offset)。对于每个顶点来说,位置(Position)顶点属性是先声明的,所以它的偏移量是0。颜色属性紧随位置数据之后,所以偏移量就是`3*sizeof(GLfloat)`,用字节来计算就是12字节。
|
||||
|
||||
运行应用你会看到如下结果:
|
||||

|
||||
|
||||
如果你有困惑,可以[在这里获得源码](http://learnopengl.com/code_viewer.php?code=getting-started/shaders-interpolated)。
|
||||
|
||||
这个图片可能不是你所期望的那种,因为我们只提供3个颜色,而不是我们现在看到的大调色板。这是所谓片段着色器进行**片段插值(Fragment Interpolation)**的结果。当渲染一个三角形在像素化(Rasterization 也译为光栅化)阶段通常生成比原来的顶点更多的像素。像素器就会基于每个像素在三角形的所处相对位置决定像素的位置。
|
||||
|
||||
基于这些位置,它**插入(Interpolate)**所有片段着色器的输入变量。比如说,我们有一个线段,上面的那个点是绿色的,下面的点是蓝色的。如果一个片段着色器正在处理的那个片段(实际上就是像素)是在线段的70%的位置,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝+70%绿。
|
||||
|
||||
这正是这个三角形里发生的事。我们有3个顶点,和相应的3个颜色,从这个三角形的像素来看它可能包含50,000左右的像素,片段着色器为这些像素进行插值。如果你仔细看这些颜色,你会发现其中的奥秘:红到紫再到蓝。像素插值会应用到所有片段着色器的输入属性上。
|
||||
|
||||
## 我们自己的着色器类
|
||||
|
||||
编写、编译、管理着色器是件麻烦事。在着色器的最后主题里,我们会写一个类来让我们的生活轻松一点,这个类从硬盘读着色器,然后编译和链接它们,对它们进行错误检测,这就变得很好用了。这也会给你一些关于如何把我们目前所学的知识封装到一个抽象的对象里的灵感。
|
||||
|
||||
我们会在头文件里创建整个类,主要为了学习,也可以方便移植。我们先来添加必要的include,定义类结构:
|
||||
|
||||
```c++
|
||||
#ifndef SHADER_H
|
||||
#define SHADER_H
|
||||
|
||||
#include <string>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <iostream>
|
||||
|
||||
using namespace std;
|
||||
|
||||
#include <GL/glew.h>; // 包含glew获取所有的OpenGL必要headers
|
||||
|
||||
class Shader
|
||||
{
|
||||
public:
|
||||
// 程序ID
|
||||
GLuint Program;
|
||||
// 构造器读取并创建Shader
|
||||
Shader(const GLchar * vertexSourcePath, const GLchar * fragmentSourcePath);
|
||||
// 使用Program
|
||||
void Use();
|
||||
};
|
||||
|
||||
#endif
|
||||
```
|
||||
|
||||
!!! Important
|
||||
|
||||
在上面,我们用了几个预处理指令(Preprocessor Directives)。这些预处理指令告知你的编译器,只在没被包含过的情况下才包含和编译这个头文件,即使多个文件都包含了这个shader头文件,它是用来防止链接冲突的。
|
||||
|
||||
shader类保留了着色器程序的ID。它的构造器需要顶点和片段着色器源代码的文件路径,我们可以把各自的文本文件储存在硬盘上。`Use`函数看似平常,但是能够显示这个自造类如何让我们的生活变轻松(虽然只有一点)。
|
||||
|
||||
### 从文件读取
|
||||
|
||||
我们使用C++文件流读取着色器内容,储存到几个string对象里([译注1])
|
||||
|
||||
```c++
|
||||
Shader(const GLchar * vertexPath, const GLchar * fragmentPath)
|
||||
{
|
||||
// 1. 从文件路径获得vertex/fragment源码
|
||||
std::string vertexCode;
|
||||
std::string fragmentCode;
|
||||
|
||||
try {
|
||||
// 打开文件
|
||||
std::ifstream vShaderFile(vertexPath);
|
||||
std::ifstream fShaderFile(fragmentPath);
|
||||
|
||||
std::stringstream vShaderStream, fShaderStream;
|
||||
// 读取文件缓冲到流
|
||||
vShaderStream << vShaderFile.rdbuf();
|
||||
fShaderStream << fShaderFile.rdbuf();
|
||||
|
||||
// 关闭文件句柄
|
||||
vShaderFile.close();
|
||||
fShaderFile.close();
|
||||
|
||||
// 将流转为GLchar数组
|
||||
vertexCode = vShaderStream.str();
|
||||
fragmentCode = fShaderStream.str();
|
||||
}
|
||||
catch(std::exception e)
|
||||
{
|
||||
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
|
||||
}
|
||||
```
|
||||
|
||||
下一步,我们需要编译和链接着色器。注意,我们也要检查编译/链接是否失败,如果失败,打印编译错误,调试的时候这及其重要(这些错误日志你总会需要的):
|
||||
|
||||
```c++
|
||||
// 2. 编译着色器
|
||||
GLuint vertex, fragment;
|
||||
GLint success;
|
||||
GLchar 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;
|
||||
};
|
||||
|
||||
// 对片段着色器进行类似处理
|
||||
[...]
|
||||
|
||||
// 着色器程序
|
||||
this->Program = glCreateProgram();
|
||||
glAttachShader(this->Program, vertex);
|
||||
glAttachShader(this->Program, fragment);
|
||||
glLinkProgram(this->Program);
|
||||
|
||||
// 打印连接错误
|
||||
glGetProgramiv(this->Program, GL_LINK_STATUS, &success);
|
||||
if(!success)
|
||||
{
|
||||
glGetProgramInfoLog(this->Program, 512, NULL, infoLog);
|
||||
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
|
||||
}
|
||||
|
||||
// 删除着色器
|
||||
glDeleteShader(vertex);
|
||||
glDeleteShader(fragment);
|
||||
```
|
||||
|
||||
最后我们也要实现Use函数:
|
||||
|
||||
```c++
|
||||
void Use()
|
||||
{
|
||||
glUseProgram(this->Program);
|
||||
}
|
||||
```
|
||||
|
||||
现在我们写完了一个完整的着色器类。使用着色器类很简单;我们创建一个着色器对象以后,就可以简单的使用了:
|
||||
|
||||
```c++
|
||||
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.frag");
|
||||
...
|
||||
while(...)
|
||||
{
|
||||
ourShader.Use();
|
||||
glUniform1f(glGetUniformLocation(ourShader.Program, "someUniform"), 1.0f);
|
||||
DrawStuff();
|
||||
}
|
||||
```
|
||||
|
||||
我们把顶点和片段着色器储存为两个叫做`shader.vs`和`shader.frag`的文件。你可以使用自己喜欢的名字命名着色器文件;我自己觉得用`.vs`和`.frag`作为扩展名很直观。
|
||||
|
||||
使用新着色器类的[程序](http://learnopengl.com/code_viewer.php?code=getting-started/shaders-using-object),[着色器类](http://learnopengl.com/code_viewer.php?type=header&code=shader),[顶点着色器](http://learnopengl.com/code_viewer.php?type=vertex&code=getting-started/basic),[片段着色器](http://learnopengl.com/code_viewer.php?type=fragment&code=getting-started/basic)。
|
||||
|
||||
## 练习
|
||||
|
||||
1. 修改顶点着色器让三角形上下颠倒:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/shaders-exercise1)
|
||||
2. 通过使用uniform定义一个水平偏移,在顶点着色器中使用这个偏移量把三角形移动到屏幕右侧:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/shaders-exercise2)
|
||||
3. 使用`out`关键字把顶点位置输出到片段着色器,把像素的颜色设置为与顶点位置相等(看看顶点位置值是如何在三角形中进行插值的)。做完这些后,尝试回答下面的问题:为什么在三角形的左下角是黑的?:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/shaders-exercise3)
|
||||
|
||||
[译注1]: http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/05%20Shaders/#_5 "译者注:实际上把着色器代码保存在文件中适合学习OpenGL的时候,实际开发中最好把一个着色器直接储存为多个字符串,这样具有更高的灵活度。"
|
370
docs/01 Getting started/06 Textures.md
Normal file
370
docs/01 Getting started/06 Textures.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 纹理(Textures)
|
||||
|
||||
原文 | [Textures](http://learnopengl.com/#!Getting-started/Textures)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | [Django](http://bullteacher.com/)
|
||||
校对 | Geequlim, [BLumia](https://github.com/blumia/)
|
||||
|
||||
我们已经了解到,我们可以为每个顶点使用颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点和顶点颜色。
|
||||
|
||||
艺术家和程序员更喜欢使用**纹理(Texture)**。纹理是一个2D图片(也有1D和3D),它用来添加物体的细节;这就像有一张绘有砖块的图片贴到你的3D的房子上,你的房子看起来就像一堵砖墙。因为我们可以在一张图片上插入足够多的细节,这样物体就会拥有很多细节而不用增加额外的顶点。
|
||||
|
||||
!!! Important
|
||||
|
||||
除了图像以外,纹理也可以储存大量的数据,这些数据用来发送到着色器上,但是这不是我们现在的主题。
|
||||
|
||||
下面你会看到之前教程的那个三角形贴上了一张[砖墙](http://learnopengl.com/img/textures/wall.jpg)图片。
|
||||
|
||||

|
||||
|
||||
为了能够把纹理映射到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会有一个**纹理坐标(Texture Coordinate)**,它指明从纹理图像的哪个地方采样(采集像素颜色)。之后在所有的其他的片段上进行片段插值(Fragment Interpolation)。
|
||||
|
||||
纹理坐标是x和y轴上0到1之间的范围(注意我们使用的是2D纹理图片)。使用纹理坐标获取纹理颜色叫做**采样(Sampling)**。纹理坐标起始于(0,0)也就是纹理图片的左下角,终结于纹理图片的右上角(1,1)。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。
|
||||
|
||||

|
||||
|
||||
我们为三角形准备了3个纹理坐标点。如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角左下角的顶点的纹理坐标设置为(0,0);三角形的上顶点对应于图片的中间所以我们把它的纹理坐标设置为(0.5,1.0);同理右下方的顶点设置为(1.0,0)。我们只要传递这三个纹理坐标给顶点着色器就行了,接着片段着色器会为每个片段生成纹理坐标的插值。
|
||||
|
||||
纹理坐标看起来就像这样:
|
||||
|
||||
```c++
|
||||
GLfloat texCoords[] = {
|
||||
0.0f, 0.0f, // 左下角
|
||||
1.0f, 0.0f, // 右下角
|
||||
0.5f, 1.0f // 顶部位置
|
||||
};
|
||||
```
|
||||
|
||||
纹理采样有几种不同的插值方式。我们需要自己告诉OpenGL在纹理中采用哪种采样方式。
|
||||
|
||||
### 纹理环绕方式(Texture Wrapping)
|
||||
|
||||
纹理坐标通常的范围是从(0, 0)到(1, 1),如果我们把纹理坐标设置为范围以外会发生什么?OpenGL默认的行为是重复这个纹理图像(我们简单地忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:
|
||||
|
||||
环绕方式 | 描述
|
||||
---|---
|
||||
GL_REPEAT | 纹理的默认行为,重复纹理图像
|
||||
GL_MIRRORED_REPEAT | 和`GL_REPEAT`一样,除了重复的图片是镜像放置的
|
||||
GL_CLAMP_TO_EDGE | 纹理坐标会在0到1之间,超出的部分会重复纹理坐标的边缘,就是边缘被拉伸
|
||||
GL_CLAMP_TO_BORDER | 超出的部分是用户指定的边缘的颜色
|
||||
|
||||
当纹理坐标超出默认范围时,每个值都有不同的视觉效果输出。我们来看看这些纹理图像的例子:
|
||||
|
||||

|
||||
|
||||
前面提到的选项都可以使用`glTexParameter`函数单独设置每个坐标轴`s`、`t`(如果是使用3D纹理那么还有一个`r`)它们和`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`。
|
||||
|
||||
第二个参数需要我们去告知我们希望去设置哪个纹理轴。
|
||||
|
||||
我们打算设置的是`WRAP`选项,并且指定S和T轴。最后一个参数需要我们传递放置方式,在这个例子里我们在当前激活纹理上应用`GL_MIRRORED_REPEAT`。
|
||||
|
||||
如果我们选择`GL_CLAMP_TO_BORDER`选项,我们还要指定一个边缘的颜色。这次使用`glTexParameter`函数的`fv`后缀形式,加上`GL_TEXTURE_BORDER_COLOR`作为选项,这个函数需要我们传递一个边缘颜色的float数组作为颜色值:
|
||||
|
||||
```c++
|
||||
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
|
||||
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
|
||||
```
|
||||
|
||||
### 纹理过滤(Texture Filtering)
|
||||
|
||||
纹理坐标不依赖于解析度,它可以是任何浮点数值,这样OpenGL需要描述出哪个纹理像素对应哪个纹理坐标(Texture Pixel,也叫Texel,[译注1])。当你有一个很大的物体但是纹理解析度很低的时候这就变得很重要了。你可能已经猜到了,OpenGL也有一个叫做纹理过滤的选项。有多种不同的选项可用,但是现在我们只讨论最重要的两种:`GL_NEAREST`和`GL_LINEAR`。
|
||||
|
||||
[译注1]: http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/06%20Textures/ "Texture Pixel也叫Texel,你可以想象你打开一张.jpg格式图片不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色"
|
||||
|
||||
**GL_NEAREST(Nearest Neighbor Filtering,邻近过滤)** 是一种OpenGL默认的纹理过滤方式。当设置为`GL_NEAREST`的时候,OpenGL选择最接近纹理坐标中心点的那个像素。下图你会看到四个像素,加号代表纹理坐标。左上角的纹理像素是距离纹理坐标最近的那个,这样它就会选择这个作为采样颜色:
|
||||
|
||||

|
||||
|
||||
**GL_LINEAR((Bi)linear Filtering,线性过滤)** 它会从纹理坐标的临近纹理像素进行计算,返回一个多个纹理像素的近似值。一个纹理像素距离纹理坐标越近,那么这个纹理像素对最终的采样颜色的影响越大。下面你会看到临近像素返回的混合颜色:
|
||||
|
||||

|
||||
|
||||
不同的纹理过滤方式有怎样的视觉效果呢?让我们看看当在一个很大的物体上应用一张地解析度的纹理会发生什么吧(纹理被放大了,纹理像素也能看到):
|
||||
|
||||

|
||||
|
||||
如上面两张图片所示,`GL_NEAREST`返回了格子一样的样式,我们能够清晰看到纹理由像素组成,而`GL_LINEAR`产生出更平滑的样式,看不出纹理像素。`GL_LINEAR`是一种更真实的输出,但有些开发者更喜欢8-bit风格,所以他们还是用`GL_NEAREST`选项。
|
||||
|
||||
纹理过滤可以为放大和缩小设置不同的选项,这样你可以在纹理被缩小的时候使用最临近过滤,被放大时使用线性过滤。我们必须通过`glTexParameter`为放大和缩小指定过滤方式。这段代码看起来和纹理环绕方式(Texture Wrapping)的设置相似:
|
||||
|
||||
```c++
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
```
|
||||
|
||||
#### 多级渐远纹理(Mipmaps)
|
||||
|
||||
想象一下,如果我们在一个有着上千物体的大房间,每个物体上都有纹理。距离观察者远的与距离近的物体的纹理的解析度是相同的。由于远处的物体可能只产生很少的片段,OpenGL从高解析度纹理中为这些片段获取正确的颜色值就很困难。这是因为它不得不拾为一个纹理跨度很大的片段取纹理颜色。在小物体上这会产生人工感,更不用说在小物体上使用高解析度纹理浪费内存的问题了。
|
||||
|
||||
OpenGL使用一种叫做 **多级渐远纹理(Mipmap)** 的概念解决这个问题,大概来说就是一系列纹理,每个后面的一个纹理是前一个的二分之一。多级渐远纹理背后的思想很简单:距离观察者更远的距离的一段确定的阈值,OpenGL会把最适合这个距离的物体的不同的多级渐远纹理纹理应用其上。由于距离远,解析度不高也不会被使用者注意到。同时,多级渐远纹理另一加分之处是,执行效率不错。让我们近距离看一看多级渐远纹理纹理:
|
||||
|
||||

|
||||
|
||||
手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个`glGenerateMipmaps`函数,它可以在我们创建完一个纹理后帮我们做所有的多级渐远纹理创建工作。后面的纹理教程中你会看到如何使用它。
|
||||
|
||||
OpenGL渲染的时候,两个不同级别的多级渐远纹理之间会产生不真实感的生硬的边界。就像普通的纹理过滤一样,也可以在两个不同多级渐远纹理级别之间使用`NEAREST`和`LINEAR`过滤。指定不同多级渐远纹理级别之间的过滤方式可以使用下面四种选项代替原来的过滤方式:
|
||||
|
||||
|
||||
过滤方式 | 描述
|
||||
---|---
|
||||
GL_NEAREST_MIPMAP_NEAREST | 接收最近的多级渐远纹理来匹配像素大小,并使用最临近插值进行纹理采样
|
||||
GL_LINEAR_MIPMAP_NEAREST | 接收最近的多级渐远纹理级别,并使用线性插值采样
|
||||
GL_NEAREST_MIPMAP_LINEAR | 在两个多级渐远纹理之间进行线性插值,通过最邻近插值采样
|
||||
GL_LINEAR_MIPMAP_LINEAR | 在两个相邻的多级渐远纹理进行线性插值,并通过线性插值进行采样
|
||||
|
||||
就像纹理过滤一样,前面提到的四种方法也可以使用`glTexParameteri`设置过滤方式:
|
||||
|
||||
```c++
|
||||
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,用它来把图像转化为byte序列。写自己的图像加载器虽然不难,但是仍然挺烦人的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。
|
||||
|
||||
另一个解决方案是,也许是更好的一种选择,就是使用一个支持多种流行格式的图像加载库,来为我们解决这个问题。就像SOIL这种库①。
|
||||
|
||||
### SOIL
|
||||
|
||||
SOIL是Simple OpenGL Image Library(简易OpenGL图像库)的缩写,它支持大多数流行的图像格式,使用起来也很简单,你可以从他们的主页下载。像大多数其他库一样,你必须自己生成**.lib**。你可以使用**/projects**文件夹里的解决方案(Solution)文件之一(不用担心他们的Visual Studio版本太老,你可以把它们转变为新的版本;这总是可行的。译注:用VS2010的时候,你要用VC8而不是VC9的解决方案,想必更高版本的情况亦是如此),你也可以使用CMake自己生成。你还要添加**src**文件夹里面的文件到你的**includes**文件夹;对了,不要忘记添加**SOIL.lib**到你的连接器选项,并在你代码文件的开头加上`#include <SOIL.h>`。
|
||||
|
||||
下面的纹理部分,我们会使用一张木箱的图片。使用SOIL加载图片,我们会使用它的`SOIL_load_image`函数:
|
||||
|
||||
```c++
|
||||
int width, height;
|
||||
unsigned char* image = SOIL_load_image("container.jpg", &width, &height, 0, SOIL_LOAD_RGB);
|
||||
```
|
||||
|
||||
函数首先需要输入图片文件的路径。然后需要两个int指针作为第二个和第三个参数,SOIL会返回图片的宽度和高度到其中。之后,我们需要图片的宽度和高度来生成纹理。第四个参数指定图片的通道(Channel)数量,但是这里我们只需留`0`。最后一个参数告诉SOIL如何来加载图片:我们只对图片的RGB感兴趣。结果储存为一个大的char/byte数组。
|
||||
|
||||
### 生成纹理
|
||||
|
||||
和之前生成的OpenGL对象一样,纹理也是使用ID引用的。
|
||||
|
||||
```c++
|
||||
GLuint texture;
|
||||
glGenTextures(1, &texture);
|
||||
```
|
||||
|
||||
`glGenTextures`函数首先需要输入纹理生成的数量,然后把它们储存在第二个参数的`GLuint`数组中(我们的例子里只有一个`GLuint`),就像其他对象一样,我们需要绑定它,所以下面的纹理命令会配置当前绑定的纹理:
|
||||
|
||||
```c++
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
```
|
||||
|
||||
现在纹理绑定了,我们可以使用前面载入的图片数据生成纹理了,纹理通过`glTexImage2D`来生成:
|
||||
|
||||
```c++
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
|
||||
glGenerateMipmap(GL_TEXTURE_2D);
|
||||
```
|
||||
|
||||
函数很长,参数也不少,所以我们一个一个地讲解:
|
||||
|
||||
- 第一个参数指定纹理目标(环境);设置为`GL_TEXTURE_2D`意味着会生成与当前绑定的纹理对象在同一个目标(Target)上的纹理(任何绑定到`GL_TEXTURE_1D`和`GL_TEXTURE_3D`的纹理不会受到影响)。
|
||||
- 第二个参数为我们打算创建的纹理指定多级渐远纹理的层级,如果你希望单独手工设置每个多级渐远纹理的层级的话。这里我们填0基本级。
|
||||
- 第三个参数告诉OpenGL,我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们把纹理储存为`GL_RGB`值。
|
||||
- 第四个和第五个参数设置最终的纹理的宽度和高度。我们加载图像的时候提前储存它们这样我们就能使用相应变量了。
|
||||
下个参数应该一直被设为`0`(遗留问题)。
|
||||
- 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存在char(byte),我们将会传入相应值。
|
||||
- 最后一个参数是真实的图像数据。
|
||||
|
||||
当调用`glTexImage2D`,当前绑定的纹理对象就会被附加上纹理图像。然而,当前只有基本级别(Base-level)纹理图像加载了,如果要使用多级渐远纹理,我们必须手工设置不同的图像(通过不断把第二个参数增加的方式)或者,在生成纹理之后调用`glGenerateMipmap`。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。
|
||||
|
||||
生成了纹理和相应的多级渐远纹理后,解绑纹理对象、释放图像的内存很重要。
|
||||
|
||||
```c++
|
||||
SOIL_free_image_data(image);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
```
|
||||
|
||||
生成一个纹理的过程应该看起来像这样:
|
||||
|
||||
```c++
|
||||
GLuint texture;
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
//为当前绑定的纹理对象设置环绕、过滤方式
|
||||
...
|
||||
//加载并生成纹理
|
||||
int width, height;
|
||||
unsigned char * image = SOIL_load_image("container.jpg", &width, &height, 0, SOIL_LOAD_RGB);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
|
||||
glGenerateMipmap(GL_TEXTURE_2D);
|
||||
SOIL_free_image_data(image);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
```
|
||||
|
||||
### 应用纹理
|
||||
|
||||
后面的部分我们会使用`glDrawElements`绘制[Hello Triangle](http://learnopengl-cn.readthedocs.org/zh/latest/01%20Getting%20started/04%20Hello%20Triangle/)教程的最后一部分的矩形。我们需要告知OpenGL如何采样纹理,这样我们必须更新顶点纹理坐标数据:
|
||||
|
||||
```c++
|
||||
GLfloat 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新的顶点格式:
|
||||
|
||||

|
||||
|
||||
```c++
|
||||
glVertexAttribPointer(2, 2, GL_FLOAT,GL_FALSE, 8 * sizeof(GLfloat), (GLvoid*)(6 * sizeof(GLfloat)));
|
||||
glEnableVertexAttribArray(2);
|
||||
```
|
||||
|
||||
注意,我们必须修正前面两个顶点属性的步长参数为`8 * sizeof(GLfloat)`。
|
||||
|
||||
接着我们需要让顶点着色器把纹理坐标作为一个顶点属性,把坐标传给片段着色器:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
layout (location = 0) in vec3 position;
|
||||
layout (location = 1) in vec3 color;
|
||||
layout (location = 2) in vec2 texCoord;
|
||||
out vec3 ourColor;
|
||||
out vec2 TexCoord;
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(position, 1.0f);
|
||||
ourColor = color;
|
||||
TexCoord = texCoord;
|
||||
}
|
||||
```
|
||||
片段着色器应该把输出变量`TexCoord`作为输入变量。
|
||||
|
||||
片段着色器应该也获取纹理对象,但是我们怎样把纹理对象传给片段着色器?GLSL有一个内建数据类型,供纹理对象使用,叫做采样器(Sampler),它以纹理类型作为后缀,比如`sampler1D`、`sampler3D`,在我们的例子中它是`sampler2D`。我们可以简单的声明一个`uniform sampler2D`把一个纹理传到片段着色器中,稍后我们把我们的纹理赋值给这个uniform。
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
in vec3 ourColor;
|
||||
in vec2 TexCoord;
|
||||
out vec4 color;
|
||||
uniform sampler2D ourTexture;
|
||||
void main()
|
||||
{
|
||||
color = texture(ourTexture, TexCoord);
|
||||
}
|
||||
```
|
||||
|
||||
我们使用GLSL的内建`texture`函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是相应的纹理坐标。`texture`函数使用前面设置的纹理参数对相应颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤)颜色。
|
||||
|
||||
现在要做的就是在调用`glDrawElements`之前绑定纹理,它会自动把纹理赋值给片段着色器的采样器:
|
||||
|
||||
```c++
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glBindVertexArray(VAO);
|
||||
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
如果你跟着这个教程正确的做完了,你会看到下面的图像:
|
||||
|
||||

|
||||
|
||||
如果你的矩形是全黑或全白的你可能在哪儿做错了什么。检查你的着色器日志,或者尝试对比一下[源码](http://learnopengl.com/code_viewer.php?code=getting-started/textures)。
|
||||
|
||||
我们还可以把纹理颜色和顶点颜色混合,来获得有趣的效果。我们简单的把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:
|
||||
|
||||
```c++
|
||||
color = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0f);
|
||||
```
|
||||
|
||||
最终的效果应该是顶点颜色和纹理颜色的混合色:
|
||||
|
||||

|
||||
|
||||
这个箱子看起来有点70年代迪斯科风格。
|
||||
|
||||
### 纹理单元(Texture Units)
|
||||
|
||||
你可能感到奇怪为什么`sampler2D`是个uniform变量,你却不用`glUniform`给它赋值,使用`glUniform1i`我们就可以给纹理采样器确定一个位置,这样的话我们能够一次在一个片段着色器中设置多纹理。一个纹理的位置通常称为一个纹理单元。一个纹理的默认纹理单元是0,它是默认激活的纹理单元,所以教程前面部分我们不用给它确定一个位置。
|
||||
|
||||
纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多纹理,只要我们首先激活相应的纹理单元。就像`glBindTexture`一样,我们可以使用`glActiveTexture`激活纹理单元,传入我们需要使用的纹理单元:
|
||||
|
||||
```c++
|
||||
glActiveTexture(GL_TEXTURE0); //在绑定纹理之前,先激活纹理单元
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
```
|
||||
|
||||
激活纹理单元之后,接下来`glBindTexture`调用函数,会绑定这个纹理到当前激活的纹理单元,纹理单元`GL_TEXTURE0`总是默认被激活,所以我们在前面的例子里当我们使用`glBindTexture`的时候,无需激活任何纹理单元。
|
||||
|
||||
!!! Important
|
||||
|
||||
OpenGL至少提供16个纹理单元供你使用,也就是说你可以激活`GL_TEXTURE0`到`GL_TEXTRUE15`。它们都是顺序定义的,所以我们也可以通过`GL_TEXTURE0+8`的方式获得`GL_TEXTURE8`,这个例子在当我们不得不循环几个纹理的时候变得很有用。
|
||||
|
||||
我们仍然要编辑片段着色器来接收另一个采样器。方法现在相对简单了:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
...
|
||||
uniform sampler2D ourTexture1;
|
||||
uniform sampler2D ourTexture2;
|
||||
void main()
|
||||
{
|
||||
color = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord), 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
最终输出颜色现在结合了两个纹理查找。GLSL的内建`mix`函数需要两个参数将根据第三个参数为前两者作为输入,并在之间进行线性插值。如果第三个值是0.0,它返回第一个输入;如果是1.0,就返回第二个输入值。0.2返回80%的第一个输入颜色和20%的第二个输入颜色,返回两个纹理的混合。
|
||||
|
||||
我们现在需要载入和创建另一个纹理;我们应该对这些步骤感到熟悉了。确保创建另一个纹理对象,载入图片,使用`glTexImage2D`生成最终纹理。对于第二个纹理我们使用一张你学习OpenGL时的表情图片。
|
||||
|
||||
为了使用第二个纹理(也包括第一个),我们必须改变一点渲染流程,先绑定两个纹理到相应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:
|
||||
|
||||
```c++
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, texture1);
|
||||
glUniform1i(glGetUniformLocation(ourShader.Program, "ourTexture1"), 0);
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, texture2);
|
||||
glUniform1i(glGetUniformLocation(ourShader.Program, "ourTexture2"), 1);
|
||||
|
||||
glBindVertexArray(VAO);
|
||||
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_IN, 0);
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
注意,我们使用了`glUniform1i`设置uniform采样器的位置或曰纹理单元。通过`glUniform1i`的设置,我们保证了每个uniform采样器对应于合适的纹理单元。可以获得下面的结果:
|
||||
|
||||

|
||||
|
||||
你可能注意到纹理上下颠倒了!这是因为OpenGL要求y轴0.0坐标是在图片的下面的,但是图片通常y轴0.0坐标在上面。一些图片加载器比如DevIL在加载的时候有选项重置y原点,但是SOIL没有。SOIL有一个叫做`SOIL_load_OGL_texture`函数可以使用一个叫做`SOIL_FLAG_INVERT_Y`的标记加载和生成纹理,它用来解决我们的问题。不过这个函数在现代OpenGL中的这个特性失效了,所以现在我们必须坚持使用`SOIL_load_image`,自己做纹理生成。
|
||||
|
||||
所以修复我们的小问题,有两个选择:
|
||||
|
||||
1. 我们切换顶点数据的纹理坐标,翻转`y`值(用1减去y坐标)。
|
||||
2. 我们可以编辑顶点着色器来翻转`y`坐标,自动替换`TexCoord`赋值:`TexCoord = vec2(texCoord.x, 1 - texCoord.y);`
|
||||
|
||||
!!! Attention
|
||||
|
||||
上面提供的解决方案仅仅通过一些hacks让图片翻转。它们在大多数情况下都能正常工作,然而实际上这种方案的效果取决于你的实现和纹理,所以最好的解决方案是调整你的图片加载器,或者以一种y原点符合OpenGL需求的方式编辑你的纹理图像。
|
||||
|
||||
如果你编辑了顶点数据,在顶点着色器中翻转了纵坐标,你会得到下面的结果:
|
||||
|
||||

|
||||
|
||||
如果你看到了图片上的笑脸容器,你就做对了。你可以对比[程序源代码](http://learnopengl.com/code_viewer.php?code=getting-started/textures_combined),以及[顶点着色器](http://learnopengl.com/code_viewer.php?type=vertex&code=getting-started/texture)和[片段着色器](http://learnopengl.com/code_viewer.php?type=fragment&code=getting-started/texture)。
|
||||
|
||||
### 练习
|
||||
|
||||
为了更熟练地使用纹理,建议在继续之后的学习之前做完这些练习:
|
||||
|
||||
- 使用片段着色器**仅**对笑脸图案进行翻转,[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/textures-exercise1)
|
||||
- 尝试用不同的纹理环绕方式,并将纹理坐标的范围设定为从`0.0f`到`2.0f`而不是原来的`0.0f`到`1.0f`,在木箱子的角落放置4个笑脸:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/textures-exercise2),[结果](http://learnopengl.com/img/getting-started/textures_exercise2.png)。记得一定要试试其他的环绕方式。
|
||||
- 尝试在矩形范围内只显示纹理图的中间一部分,并通过修改纹理坐标来设置显示效果。尝试使用`GL_NEAREST`的纹理过滤方式让图像显示得更清晰:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/textures-exercise3)
|
||||
- 使用一个uniform变量作为`mix`函数的第三个参数来改变两个纹理可见度,使用上和下键来改变容器的大小和笑脸是否可见:[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/textures-exercise4),[片段着色器](http://learnopengl.com/code_viewer.php?code=getting-started/textures-exercise4_fragment)。
|
480
docs/01 Getting started/07 Transformations.md
Normal file
480
docs/01 Getting started/07 Transformations.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# 变换(Transformations)
|
||||
|
||||
原文 | [Transformations](http://learnopengl.com/#!Getting-started/Transformations)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | Django
|
||||
校对 | Meow J, [BLumia](https://github.com/blumia/)
|
||||
|
||||
尽管我们现在已经知道了如何创建一个物体、着色、加入纹理从而给它们一些细节的表现,但是它们仍然还是不够有趣,因为它们都还是静态的物体。我们可以尝试着在每一帧改变物体的顶点并且重设缓冲区从而使他们移动,但这太繁琐了,而且会消耗很多的处理时间。然而,我们现在有一个更好的解决方案,使用(多个)矩阵(Matrix)对象可以更好的变换(Transform)一个物体。当然,这并不是说我们会去讨论武术和数字虚拟世界(译注:Matrix同样也是电影「黑客帝国」的英文名,电影中人类生活在数字虚拟世界,主角会武术)。
|
||||
|
||||
**矩阵**(Matrix)是一种非常有用的数学工具,尽管听起来可能有些吓人,不过一旦你理解了它们后,它们会非常有用。在讨论矩阵的过程中,我们需要使用到一些数学知识。对于一些愿意多了解这些知识的读者,我会附加一些资源给你们阅读。
|
||||
|
||||
为了深入了解变换,我们首先要在讨论矩阵之前了解一点向量(Vector)。这一节的目标是让你拥有将来需要的最基础的数学背景知识. 如果你发现这节十分困难,尽量尝试去理解它们,当你以后需要它们的时候回过头来复习这些概念。
|
||||
|
||||
## 向量(Vector)
|
||||
|
||||
向量最最基本的定义就是一个方向。或者更正式的说,向量有一个**方向(Direction)**和**大小(Magnitude,也叫做强度或长度)**。你可以把向量想成一个藏宝图上的指示:“向左走10步,向北走3步,然后向右走5步”;“左”就是方向,“10步”就是向量的长度。你可以发现,这个藏宝图的指示一共有3个向量。向量可以在任意**维度**(Dimension)上,但是我们通常只使用2至4维。如果一个向量有2个维度,它表示一个平面的方向(想象一下2D的图像),当它有3个维度的时候它可以表达一个3D世界的方向。
|
||||
|
||||
下面你会看到3个向量,每个向量在图像中都用一个箭头(x, y)表示。我们在2D图片中展示这些向量,因为这样子会更直观. 你仍然可以把这些2D向量当做z坐标为0的3D向量。由于向量表示的是方向,起始于何处**并不会**改变它的值。下图我们可以看到向量\(\color{red}{\bar{v}}\)和\(\color{blue}{\bar{w}}\)是相等的,尽管他们的起始点不同:
|
||||
|
||||

|
||||
|
||||
数学家喜欢在字母上面加一横表示向量,比如说\(\bar{v}\)。当用在公式中时它们通常是这样的:
|
||||
|
||||
$$
|
||||
\bar{v} = \begin{pmatrix} \color{red}x \\ \color{green}y \\ \color{blue}z \end{pmatrix}
|
||||
$$
|
||||
|
||||
由于向量是一个方向,所以有些时候会很难形象地将它们用位置(Position)表示出来。我们通常设定这个方向的原点为(0,0,0),然后指向对应坐标的点,使其变为**位置向量(Position Vector)**来表示(你也可以把起点设置为其他的点,然后说:这个向量从这个点起始指向另一个点)。位置向量(3, 5)的在图像中起点是(0, 0),指向(3, 5)。我们可以使用向量在2D或3D空间中表示方向**与**位置.
|
||||
|
||||
和普通数字一样,我们也可以用向量进行多种运算(其中一些你可能已经知道了)。
|
||||
|
||||
### 向量与标量运算(Scalar Vector Operations)
|
||||
|
||||
**标量(Scalar)**只是一个数字(或者说是仅有一个分量的矢量)。当把一个向量加/减/乘/除一个标量,我们可以简单的把向量的每个分量分别进行该运算。对于加法来说会像这样:
|
||||
|
||||
$$
|
||||
\begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix} + x = \begin{pmatrix} \color{red}1 + x \\ \color{green}2 + x \\ \color{blue}3 + x \end{pmatrix}
|
||||
$$
|
||||
|
||||
其中的+可以是+,-,·或÷,其中·是乘号。注意-和÷运算时不能颠倒,因为颠倒的运算是没有定义的(标量-/÷矢量)
|
||||
|
||||
### 向量取反(Vector Negation)
|
||||
|
||||
对一个向量取反会将其方向逆转。一个指向东北的向量取反后就指向西南方向了。我们在一个向量的每个分量前加负号就可以实现取反了(或者说用-1数乘该向量):
|
||||
|
||||
$$
|
||||
-\bar{v} = -\begin{pmatrix} \color{red}{v_x} \\ \color{blue}{v_y} \\ \color{green}{v_z} \end{pmatrix} = \begin{pmatrix} -\color{red}{v_x} \\ -\color{blue}{v_y} \\ -\color{green}{v_z} \end{pmatrix}
|
||||
$$
|
||||
|
||||
### 向量加减
|
||||
|
||||
向量的加法可以被定义为是**分量的(Component-wise)**相加,即将一个向量中的每一个分量加上另一个向量的对应分量:
|
||||
|
||||
$$
|
||||
\bar{v} = \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix}, \bar{k} = \begin{pmatrix} \color{red}4 \\ \color{green}5 \\ \color{blue}6 \end{pmatrix} \rightarrow \bar{v} + \bar{k} = \begin{pmatrix} \color{red}1 + \color{red}4 \\ \color{green}2 + \color{green}5 \\ \color{blue}3 + \color{blue}6 \end{pmatrix} = \begin{pmatrix} \color{red}5 \\ \color{green}7 \\ \color{blue}9 \end{pmatrix}
|
||||
$$
|
||||
|
||||
向量v = (4, 2)和k = (1, 2)直观地表示为:
|
||||
|
||||

|
||||
|
||||
就像普通数字的加减一样,向量的减法等于加上第二个向量的相反数:
|
||||
|
||||
$$
|
||||
\bar{v} = \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix}, \bar{k} = \begin{pmatrix} \color{red}4 \\ \color{green}5 \\ \color{blue}6 \end{pmatrix} \rightarrow \bar{v} + -\bar{k} = \begin{pmatrix} \color{red}1 + (-\color{red}{4}) \\ \color{green}2 + (-\color{green}{5}) \\ \color{blue}3 + (-\color{blue}{6}) \end{pmatrix} = \begin{pmatrix} -\color{red}{3} \\ -\color{green}{3} \\ -\color{blue}{3} \end{pmatrix}
|
||||
$$
|
||||
|
||||
两个向量的相减会得到这两个向量指向位置的差. 这在我们想要获取两点的差会非常有用.
|
||||
|
||||

|
||||
|
||||
### 长度(Length)
|
||||
|
||||
我们使用**勾股定理(Pythagoras Theorem)**来获取向量的长度/大小. 如果你把向量的x与y分量画出来,该向量会形成一个以x与y分量为边的三角形:
|
||||
|
||||

|
||||
|
||||
因为两条边(x和y)是已知的,而且我们希望知道斜边\(\color{red}{\bar{v}}\)的长度,所以我们可以通过勾股定理来计算出它:
|
||||
|
||||
$$
|
||||
||\color{red}{\bar{v}}|| = \sqrt{\color{green}x^2 + \color{blue}y^2}
|
||||
$$
|
||||
|
||||
\(||\color{red}{\bar{v}}||\)表示向量\(\color{red}{\bar{v}}\)的大小,我们也可以很容易加上\(z^2\)把这个公式拓展到三维空间
|
||||
|
||||
例子中向量(4, 2)的长度等于:
|
||||
|
||||
$$
|
||||
||\color{red}{\bar{v}}|| = \sqrt{\color{green}4^2 + \color{blue}2^2} = \sqrt{\color{green}16 + \color{blue}4} = \sqrt{20} = 4.47
|
||||
$$
|
||||
|
||||
结果是4.47。
|
||||
|
||||
有一个特殊类型向量叫做**单位向量(Unit Vector)**。单位向量有一个特别的性质——它的长度是1。我们可以用任意向量的每个分量除以向量的长度得到它的单位向量\(\hat{n}\):
|
||||
|
||||
$$
|
||||
\hat{n} = \frac{\bar{v}}{||\bar{v}||}
|
||||
$$
|
||||
|
||||
我们把这种方法叫做一个向量的**标准化(Normalizing)**。单位向量头上有一个^样子的记号,并且它会变得很有用,特别是在我们只关心方向不关系长度的时候(如果我们改变向量的长度,它的方向并不会改变)。
|
||||
|
||||
### 向量相乘(Vector-vector Multiplication)
|
||||
|
||||
两个向量相乘是一种很奇怪的情况。普通的乘法在向量上是没有定义的,因为它在视觉上是没有意义的,但是有两种特定情境,当需要乘法时我们可以从中选择:一个是**点乘(Dot Product)**,记作\(\bar{v} \cdot \bar{k}\),另一个是**叉乘(Cross Product)**,记作\(\bar{v} \times \bar{k}\)。
|
||||
|
||||
#### 点乘(Dot Product)
|
||||
|
||||
两个向量的点乘等于它们的数乘结果乘以两个向量之间夹角的余弦值。听起来有点费解,先看一下公式:
|
||||
|
||||
$$
|
||||
\bar{v} \cdot \bar{k} = ||\bar{v}|| \cdot ||\bar{k}|| \cdot \cos \theta
|
||||
$$
|
||||
|
||||
它们之间的夹角我们记作\(\theta\)。为什么这很有用?想象如果\(\bar{v}\)和\(\bar{k}\)都是单位向量,它们的长度等于1。公式会有效简化成:
|
||||
|
||||
$$
|
||||
\bar{v} \cdot \bar{k} = 1 \cdot 1 \cdot \cos \theta = \cos \theta
|
||||
$$
|
||||
|
||||
现在点乘**只**和两个向量的角度有关。你也许记得当90度的余弦是0,0度的余弦是1。使用点乘可以很容易测试两个向量是否正交(Orthogonal)或平行(正交意味着两个向量互为**直角**)。你可能想要了解更多的关于正弦或余弦的知识,我推荐你看[可汗学院](https://www.khanacademy.org/math/trigonometry/basic-trigonometry/basic_trig_ratios/v/basic-trigonometry)的基础三角学视频。
|
||||
|
||||
!!! Important
|
||||
|
||||
你可以通过点乘的结果计算两个非单位向量的夹角,点乘的结果除以两个向量的大小之积,得到的结果就是夹角的余弦值,即\(cos \theta\)。
|
||||
|
||||
译注:通过上面点乘定义式可推出:
|
||||
|
||||
$$
|
||||
\cos \theta = \frac{\bar{v} \cdot \bar{k}}{||\bar{v}|| \cdot ||\bar{k}||}
|
||||
$$
|
||||
|
||||
所以,我们如何计算点乘?点乘是按分量逐个相乘,然后再把结果相加。两个单位向量点乘就像这样(你可以用两个长度为1的验证):
|
||||
|
||||
$$
|
||||
\begin{pmatrix} \color{red}{0.6} \\ -\color{green}{0.8} \\ \color{blue}0 \end{pmatrix} \cdot \begin{pmatrix} \color{red}0 \\ \color{green}1 \\ \color{blue}0 \end{pmatrix} = (\color{red}{0.6} * \color{red}0) + (-\color{green}{0.8} * \color{green}1) + (\color{blue}0 * \color{blue}0) = -0.8
|
||||
$$
|
||||
|
||||
计算两个单位余弦的角度,我们使用反余弦\(cos^{-1}\) ,结果是143.1度。现在我们很快就计算出了两个向量的角度。点乘在计算光照的时候会很有用。
|
||||
|
||||
#### 叉乘(Cross Product)
|
||||
|
||||
叉乘只在3D空间有定义,它需要两个不平行向量作为输入,生成正交于两个输入向量的第三个向量。如果输入的两个向量也是正交的,那么叉乘的结果将会返回3个互相正交的向量。接下来的教程中,这很有用。下面的图片展示了3D空间中叉乘的样子:
|
||||
|
||||

|
||||
|
||||
不同于其他运算,如果你没有钻研过线性代数,会觉得叉乘很反直觉,所以最好记住公式,就没问题(记不住也没问题)。下面你会看到两个正交向量A和B叉乘结果:
|
||||
|
||||
$$
|
||||
\begin{pmatrix} \color{red}{A_{x}} \\ \color{green}{A_{y}} \\ \color{blue}{A_{z}} \end{pmatrix} \times \begin{pmatrix} \color{red}{B_{x}} \\ \color{green}{B_{y}} \\ \color{blue}{B_{z}} \end{pmatrix} = \begin{pmatrix} \color{green}{A_{y}} \cdot \color{blue}{B_{z}} - \color{blue}{A_{z}} \cdot \color{green}{B_{y}} \\ \color{blue}{A_{z}} \cdot \color{red}{B_{x}} - \color{red}{A_{x}} \cdot \color{blue}{B_{z}} \\ \color{red}{A_{x}} \cdot \color{green}{B_{y}} - \color{green}{A_{y}} \cdot \color{red}{B_{x}} \end{pmatrix}
|
||||
$$
|
||||
|
||||
就像你所看到的,看起来毫无头绪。可如果你这么做了,你会得到第三个向量,它正交于你的输入向量。
|
||||
|
||||
## 矩阵(Matrix)
|
||||
|
||||
现在我们已经讨论了向量的全部内容,是时候看看矩阵了!矩阵简单说是一个矩形的数字、符号或表达式数组。矩阵中每一项叫做矩阵的**元素(Element)**。下面是一个2×3矩阵的例子:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix}
|
||||
$$
|
||||
|
||||
矩阵可以通过(i, j)进行索引,i是行,j是列,这就是上面的矩阵叫做2×3矩阵的原因(3列2行,也叫做矩阵的**维度(Dimension)**)。这与你在索引2D图像时的(x, y)相反,获取4的索引是(2, 1)(第二行,第一列)(译注:如果是图像索引应该是(1, 2),先算列,再算行)。
|
||||
|
||||
关于矩阵基本也就是这些了,它就是矩形数学表达式阵列。矩阵也有非常漂亮的数学属性,就跟向量一样。矩阵有几个运算,叫做:矩阵加法、减法和乘法。
|
||||
|
||||
### 矩阵的加减
|
||||
|
||||
矩阵与标量的加减如下所示:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} + \color{green}3 = \begin{bmatrix} 1 + \color{green}3 & 2 + \color{green}3 \\ 3 + \color{green}3 & 4 + \color{green}3 \end{bmatrix} = \begin{bmatrix} 4 & 5 \\ 6 & 7 \end{bmatrix}
|
||||
$$
|
||||
|
||||
标量值要加到矩阵的每一个元素上。矩阵与标量的减法也是同样的:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} - \color{green}3 = \begin{bmatrix} 1 - \color{green}3 & 2 - \color{green}3 \\ 3 - \color{green}3 & 4 - \color{green}3 \end{bmatrix} = \begin{bmatrix} -2 & -1 \\ 0 & 1 \end{bmatrix}
|
||||
$$
|
||||
|
||||
矩阵与矩阵之间的加减就是两个矩阵对应元素的加减运算,所以总体的规则和与标量运算是差不多的,只不过在相同索引下的元素才能进行运算。这也就是说加法和减法只在同维度的矩阵中是有定义的。一个3×2矩阵和一个2×3矩阵(或一个3×3矩阵与4×4矩阵)是不能进行加减的。我们看看两个2×2矩阵是怎样加减的:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}1 & \color{red}2 \\ \color{green}3 & \color{green}4 \end{bmatrix} + \begin{bmatrix} \color{red}5 & \color{red}6 \\ \color{green}7 & \color{green}8 \end{bmatrix} = \begin{bmatrix} \color{red}1 + \color{red}5 & \color{red}2 + \color{red}6 \\ \color{green}3 + \color{green}7 & \color{green}4 + \color{green}8 \end{bmatrix} = \begin{bmatrix} \color{red}6 & \color{red}8 \\ \color{green}{10} & \color{green}{12} \end{bmatrix}
|
||||
$$
|
||||
|
||||
同样的法则也适用于减法:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}4 & \color{red}2 \\ \color{green}1 & \color{green}6 \end{bmatrix} - \begin{bmatrix} \color{red}2 & \color{red}4 \\ \color{green}0 & \color{green}1 \end{bmatrix} = \begin{bmatrix} \color{red}4 - \color{red}2 & \color{red}2 - \color{red}4 \\ \color{green}1 - \color{green}0 & \color{green}6 - \color{green}1 \end{bmatrix} = \begin{bmatrix} \color{red}2 & -\color{red}2 \\ \color{green}1 & \color{green}5 \end{bmatrix}
|
||||
$$
|
||||
|
||||
### 矩阵的数乘(Matrix-scalar Products)
|
||||
|
||||
和矩阵与标量的加减一样,矩阵与标量之间的乘法也是矩阵的每一个元素分别乘以该标量。下面的例子展示了乘法的过程:
|
||||
|
||||
$$
|
||||
\color{green}2 \cdot \begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix} = \begin{bmatrix} \color{green}2 \cdot 1 & \color{green}2 \cdot 2 \\ \color{green}2 \cdot 3 & \color{green}2 \cdot 4 \end{bmatrix} = \begin{bmatrix} 2 & 4 \\ 6 & 8 \end{bmatrix}
|
||||
$$
|
||||
|
||||
现在我们也就能明白为什么一个单独的数字要叫做标量(Scalar)了。简单来说,标量就是用它的值缩放(Scale)矩阵的所有元素(译注:注意Scalar是由Scale + -ar演变过来的)。前面的例子里,所有的元素都被放大了2倍。
|
||||
|
||||
到目前为止都还好,我们的例子都不复杂。不过矩阵与矩阵的乘法就不一样了。
|
||||
|
||||
### 矩阵相乘(Matrix-matrix Multiplication)
|
||||
|
||||
矩阵之间的乘法不见得有多复杂,但的确很难让人适应。矩阵乘法基本上意味着遵照规定好的法则进行相乘。当然,相乘还有一些限制:
|
||||
|
||||
1. 只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘。
|
||||
2. 矩阵相乘不遵守**交换律(Commutative)**,\(A \cdot B \neq B \cdot A\)。
|
||||
|
||||
我们先看一个两个2×2矩阵相乘的例子:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}1 & \color{red}2 \\ \color{green}3 & \color{green}4 \end{bmatrix} \cdot \begin{bmatrix} \color{blue}5 & \color{purple}6 \\ \color{blue}7 & \color{purple}8 \end{bmatrix} = \begin{bmatrix} \color{red}1 \cdot \color{blue}5 + \color{red}2 \cdot \color{blue}7 & \color{red}1 \cdot \color{purple}6 + \color{red}2 \cdot \color{purple}8 \\ \color{green}3 \cdot \color{blue}5 + \color{green}4 \cdot \color{blue}7 & \color{green}3 \cdot \color{purple}6 + \color{green}4 \cdot \color{purple}8 \end{bmatrix} = \begin{bmatrix} 19 & 22 \\ 43 & 50 \end{bmatrix}
|
||||
$$
|
||||
|
||||
现在你可能会在想了:我勒个去,刚刚到底发生了什么? 矩阵的乘法是一系列乘法和加法组合的结果,它使用到了左侧矩阵的行和右侧矩阵的列。我们可以看下面的图片:
|
||||
|
||||

|
||||
|
||||
我们先把左侧矩阵的行和右侧矩阵的列拿出来。这些我们挑出来行和列决定着作为结果的2×2矩阵的输出值。如果我们拿出来的是左矩阵的第一行,最终的值就会出现在作为结果的矩阵的第一行,如果我们拿出来的是右矩阵的第一列,最终值会出现在作为结果的矩阵的第一列。这正是红框里的情况。如果想计算结果矩阵右下角的值,我们要用第一个矩阵的第二行和第二个矩阵的第二列(译注:简单来说就是结果矩阵的元素的行取决于第一个矩阵,列取决于第二个矩阵)。
|
||||
|
||||
计算一项的结果值的方式是先计算左侧矩阵对应行和右侧矩阵对应列的第一个元素之积,然后是第二个,第三个,第四个等等,然后把所有的乘积相加,这就是结果了。现在我们就能解释为什么左侧矩阵的列数必须和右侧矩阵的行数相等了,如果不相等这一步的操作我们就无法完成了!
|
||||
|
||||
结果的矩阵的维度是(n, m),n等于左侧矩阵的行数,m等于右侧矩阵的列数。
|
||||
|
||||
如果你在脑子里想象出乘法有困难别担心。用笔写下来,如果遇到困难回头看这页的内容。随着时间流逝,矩阵乘法对你来说会变成很自然的事。
|
||||
|
||||
我们用一个更大的例子来结束矩阵与矩阵乘法的讨论。尝试使用颜色来让这个公式更容易理解。作为一个有用的练习,你可以自己回答这个乘法问题然后对比你的结果和图中的这个(如果你用笔计算,你很快就能掌握它们)。
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}4 & \color{red}2 & \color{red}0 \\ \color{green}0 & \color{green}8 & \color{green}1 \\ \color{blue}0 & \color{blue}1 & \color{blue}0 \end{bmatrix} \cdot \begin{bmatrix} \color{red}4 & \color{green}2 & \color{blue}1 \\ \color{red}2 & \color{green}0 & \color{blue}4 \\ \color{red}9 & \color{green}4 & \color{blue}2 \end{bmatrix} = \begin{bmatrix} \color{red}4 \cdot \color{red}4 + \color{red}2 \cdot \color{red}2 + \color{red}0 \cdot \color{red}9 & \color{red}4 \cdot \color{green}2 + \color{red}2 \cdot \color{green}0 + \color{red}0 \cdot \color{green}4 & \color{red}4 \cdot \color{blue}1 + \color{red}2 \cdot \color{blue}4 + \color{red}0 \cdot \color{blue}2 \\ \color{green}0 \cdot \color{red}4 + \color{green}8 \cdot \color{red}2 + \color{green}1 \cdot \color{red}9 & \color{green}0 \cdot \color{green}2 + \color{green}8 \cdot \color{green}0 + \color{green}1 \cdot \color{green}4 & \color{green}0 \cdot \color{blue}1 + \color{green}8 \cdot \color{blue}4 + \color{green}1 \cdot \color{blue}2 \\ \color{blue}0 \cdot \color{red}4 + \color{blue}1 \cdot \color{red}2 + \color{blue}0 \cdot \color{red}9 & \color{blue}0 \cdot \color{green}2 + \color{blue}1 \cdot \color{green}0 + \color{blue}0 \cdot \color{green}4 & \color{blue}0 \cdot \color{blue}1 + \color{blue}1 \cdot \color{blue}4 + \color{blue}0 \cdot \color{blue}2 \end{bmatrix}
|
||||
\\ = \begin{bmatrix} 20 & 8 & 12 \\ 25 & 4 & 34 \\ 2 & 0 & 4 \end{bmatrix}
|
||||
$$
|
||||
|
||||
就像你所看到的那样,矩阵与矩阵相乘复杂而容易犯错(这就是我们通常让计算机做这件事的原因),而且当矩阵变大以后很快就会出现问题。如果你仍然希望了解更多,对矩阵的数学属性感到好奇,我强烈推荐你看看[可汗学院](https://www.khanacademy.org/math/algebra2/algebra-matrices)的矩阵内容视频。
|
||||
|
||||
不管怎样,反正现在我们知道如何进行矩阵相乘了,我们可以开始了解好东西了。
|
||||
|
||||
## 矩阵与向量相乘
|
||||
|
||||
到目前,通过这些教程我们已经相当了解向量了。我们用向量来表示位置、颜色和纹理坐标。让我们进到兔子洞更深处:向量基本上就是一个**N×1**矩阵,N是向量分量的个数(也叫**N维(N-dimensional)**向量)。如果你仔细思考这个问题,会很有意思。向量和矩阵一样都是一个数字序列,但是它只有1列。所以,这个新信息能如何帮助我们?如果我们有一个M×N矩阵,我们可以用这个矩阵乘以我们的N×1向量,因为我们的矩阵的列数等于向量的行数,所以它们就能相乘。
|
||||
|
||||
但是为什么我们关心矩阵是否能够乘以一个向量?有很多有意思的2D/3D变换本质上都是矩阵,而矩阵与我们的向量相乘会变换我们的向量。假如你仍然有些困惑,我们看一些例子,你很快就能明白了。
|
||||
|
||||
### 单位矩阵(Identity Matrix)
|
||||
|
||||
在OpenGL中,因为有一些原因我们通常使用4×4的变换矩阵,而其中最重要的原因就是因为每一个向量都有4个分量的。我们能想到的最简单的变换矩阵就是**单位矩阵(Identity Matrix)**。单位矩阵是一个除了对角线以外都是0的N × N矩阵。就像你看到的,这个变换矩阵使一个向量完全不变:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{bmatrix} 1 \\ 2 \\ 3 \\ 4 \end{bmatrix} = \begin{bmatrix} \color{red}1 \cdot 1 \\ \color{green}1 \cdot 2 \\ \color{blue}1 \cdot 3 \\ \color{purple}1 \cdot 4 \end{bmatrix} = \begin{bmatrix} 1 \\ 2 \\ 3 \\ 4 \end{bmatrix}
|
||||
$$
|
||||
|
||||
向量看起来完全没动。从乘法法则来看很明显:第一个结果分量是矩阵的第一行的每个对应分量乘以向量的每一个分量。因为每行的分量除了第一个都是0,可得: \(\color{red}1\cdot1 + \color{red}0\cdot2 + \color{red}0\cdot3 + \color{red}0\cdot4 = 1\),这对向量的其他3个分量同样适用。
|
||||
|
||||
!!! Important
|
||||
|
||||
你可能会奇怪一个没变换的变换矩阵有什么用?单位矩阵通常是生成其他变换矩阵的起点,如果我们深挖线性代数,这就是一个对证明定理、解线性方程非常有用的矩阵。
|
||||
|
||||
### 缩放(Scaling)
|
||||
|
||||
当我们对一个向量进行缩放的时候就是对向量的长度进行缩放,而它的方向保持不变。如果我们进行2或3维操作,那么我们可以分别定义一个有2或3个缩放变量的向量,每个变量缩放一个轴(x、y或z)。
|
||||
|
||||
我们可以尝试去缩放向量\(\color{red}{\bar{v}} = (3,2)\)。我们可以把向量沿着x轴缩放0.5,使它的宽度缩小为原来的二分之一;我们可以沿着y轴把向量的高度缩放为原来的两倍。我们看看把向量缩放(0.5, 2)所获得的\(\color{blue}{\bar{s}}\)是什么样的:
|
||||
|
||||

|
||||
|
||||
记住,OpenGL通常是在3D空间操作的,对于2D的情况我们可以把z轴缩放1这样z轴的值就不变了。我们刚刚的缩放操作是**不均匀(Non-uniform)**缩放,因为每个轴的缩放因子(Scaling Factor)都不一样。如果每个轴的缩放都一样那么就叫**均匀缩放(Uniform Scale)**。
|
||||
|
||||
我们下面设置一个变换矩阵来为我们提供缩放功能。我们从单位矩阵了解到,每个对角线元素乘以对应的向量分量。如果我们把1变为3会怎样?这种情况,我们就把向量的每个分量乘以3了,这事实上就把向量缩放3。如果我们把缩放变量表示为\((\color{red}{S_1}, \color{green}{S_2}, \color{blue}{S_3})\)我们可以为任意向量\((x,y,z)\)定义一个缩放矩阵:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}{S_1} & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{S_2} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}{S_3} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{S_1} \cdot x \\ \color{green}{S_2} \cdot y \\ \color{blue}{S_3} \cdot z \\ 1 \end{pmatrix}
|
||||
$$
|
||||
|
||||
注意,第四个缩放的向量仍然是1,因为不会缩放3D空间中的w分量。w分量另有其他用途,在后面我们会看到。
|
||||
|
||||
### 平移(Translation)
|
||||
|
||||
**平移(Translation)**是在原来向量的基础上加上另一个的向量从而获得一个在不同位置的新向量的过程,这样就基于平移向量**移动(Move)**了向量。我们已经讨论了向量加法,所以你应该不会陌生。
|
||||
|
||||
和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于平移来说它们是第四列最上面的3个值。如果我们把缩放向量表示为\((\color{red}{T_x},\color{green}{T_y},\color{blue}{T_z})\)我们就能把平移矩阵定义为:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}{T_x} \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}{T_y} \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}{T_z} \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} x + \color{red}{T_x} \\ y + \color{green}{T_y} \\ z + \color{blue}{T_z} \\ 1 \end{pmatrix}
|
||||
$$
|
||||
|
||||
这样是能工作的,因为所有的平移值都要乘以向量的w列,所以平移值会加到向量的原始坐标上(想想矩阵乘法法则)。而如果你用3x3矩阵我们的平移值就没地方放也没地方乘了,所以是不行的。
|
||||
|
||||
!!! Important
|
||||
|
||||
**齐次坐标(Homogeneous coordinates)**
|
||||
|
||||
向量的w分量也叫**齐次坐标**。想要从齐次坐标得到3D坐标,我们可以把x、y和z坐标除以w坐标。我们通常不会注意这个问题,因为w分量通常是1.0。使用齐次坐标有几点好处:它允许我们在3D向量上进行平移(如果没有w分量我们是不能平移向量的),下一章我们会用w值创建3D图像。
|
||||
|
||||
如果一个向量的齐次坐标是0,这个坐标就是**方向向量(Direction Vector)**,因为w坐标是0,这个向量就不能平移(译注:这也就是我们说的不能平移一个方向)。
|
||||
|
||||
有了平移矩阵我们就可以在3个方向(x、y、z)上移动物体,它是我们的变换工具箱中非常有用的一个变换矩阵。
|
||||
|
||||
### 旋转(Rotation)
|
||||
|
||||
上面几个的变换内容相对容易理解,在2D或3D空间中也容易表示出来,但旋转稍复杂些。如果你想知道旋转矩阵是如何构造出来的,我推荐你去看可汗学院[线性代数](https://www.khanacademy.org/math/linear-algebra/matrix_transformations)视频。
|
||||
|
||||
首先我们来定义一个向量的旋转到底是什么。2D或3D空间中点的旋转用**角(Angle)**来表示。角可以是角度制或弧度制的,周角是360度或2 [PI](https://en.wikipedia.org/wiki/Pi)弧度。我个人更喜欢用角度,因为它们看起来更直观。
|
||||
|
||||
!!! Important
|
||||
|
||||
大多数旋转函数需要用弧度制的角,但是角度制的角也可以很容易地转化为弧度制:
|
||||
|
||||
- 弧度转角度:角度 = 弧度 * (180.0f / PI)
|
||||
- 角度转弧度:弧度 = 角度 * (PI / 180.0f)
|
||||
|
||||
PI约等于3.14159265359。
|
||||
|
||||
转半圈会向右旋转360/2 = 180度,向右旋转1/5圈表示向右旋转360/5 = 72度。这表明2D空间的向量\(\color{red}{\bar{v}}\)是由\(\color{green}{\bar{k}}\)向右旋转72度得到的:
|
||||
|
||||

|
||||
|
||||
在3D空间中旋转需要一个角**和**一个**旋转轴(Rotation Axis)**。物体会沿着给定的旋转轴旋转特定角度。如果你想要更形象化的描述,可以试试向下看着一个特定的旋转轴,同时将你的头部旋转一定角度。比如2D向量在3D空间中旋转时,我们把旋转轴设为z轴(尝试想象这种情况)。
|
||||
|
||||
使用三角学就能把一个向量变换为一个经过旋转特定角度的新向量。这通常是使用一系列正弦和余弦各种巧妙的组合得到的(一般简称sin和cos)。当然,讨论如何生成变换矩阵超出了这个教程的范围。
|
||||
|
||||
旋转矩阵在3D空间中每个单位轴都有不同定义,这个角度表示为\(\theta\):
|
||||
|
||||
沿x轴旋转:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{\cos \theta} & - \color{green}{\sin \theta} & \color{green}0 \\ \color{blue}0 & \color{blue}{\sin \theta} & \color{blue}{\cos \theta} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} x \\ \color{green}{\cos \theta} \cdot y - \color{green}{\sin \theta} \cdot z \\ \color{blue}{\sin \theta} \cdot y + \color{blue}{\cos \theta} \cdot z \\ 1 \end{pmatrix}
|
||||
$$
|
||||
|
||||
沿y轴旋转:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}{\cos \theta} & \color{red}0 & \color{red}{\sin \theta} & \color{red}0 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}0 \\ - \color{blue}{\sin \theta} & \color{blue}0 & \color{blue}{\cos \theta} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{\cos \theta} \cdot x + \color{red}{\sin \theta} \cdot z \\ y \\ - \color{blue}{\sin \theta} \cdot x + \color{blue}{\cos \theta} \cdot z \\ 1 \end{pmatrix}
|
||||
$$
|
||||
|
||||
沿z轴旋转:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}{\cos \theta} & - \color{red}{\sin \theta} & \color{red}0 & \color{red}0 \\ \color{green}{\sin \theta} & \color{green}{\cos \theta} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{\cos \theta} \cdot x - \color{red}{\sin \theta} \cdot y \\ \color{green}{\sin \theta} \cdot x + \color{green}{\cos \theta} \cdot y \\ z \\ 1 \end{pmatrix}
|
||||
$$
|
||||
|
||||
利用旋转矩阵我们可以把我们的位置向量(Position Vectors)沿一个或多个轴进行旋转。也可以把多个矩阵结合起来,比如先沿着X轴旋转再沿着Y轴旋转。但是这会很快导致一个问题——**万向节死锁(Gimbal Lock,可以看看[这个视频](https://www.youtube.com/watch?v=zc8b2Jo7mno)[(优酷)](http://v.youku.com/v_show/id_XNzkyOTIyMTI=.html)来了解)**。我们不会讨论它的细节,但是一个更好的解决方案是沿着任意轴比如(0.662, 0.2, 0.7222)(注意,这是个单位向量)旋转,而不是使用一系列旋转矩阵的组合。这样一个(超级麻烦)的矩阵是存在的,下面\((\color{red}{R_x}, \color{green}{R_y}, \color{blue}{R_z})\)代表任意旋转轴:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \cos \theta + \color{red}{R_x}^2(1 - \cos \theta) & \color{red}{R_x}\color{green}{R_y}(1 - \cos \theta) - \color{blue}{R_z} \sin \theta & \color{red}{R_x}\color{blue}{R_z}(1 - \cos \theta) + \color{green}{R_y} \sin \theta & 0 \\ \color{green}{R_y}\color{red}{R_x} (1 - \cos \theta) + \color{blue}{R_z} \sin \theta & \cos \theta + \color{green}{R_y}^2(1 - \cos \theta) & \color{green}{R_y}\color{blue}{R_z}(1 - \cos \theta) - \color{red}{R_x} \sin \theta & 0 \\ \color{blue}{R_z}\color{red}{R_x}(1 - \cos \theta) - \color{green}{R_y} \sin \theta & \color{blue}{R_z}\color{green}{R_y}(1 - \cos \theta) + \color{red}{R_x} \sin \theta & \cos \theta + \color{blue}{R_z}^2(1 - \cos \theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}
|
||||
$$
|
||||
|
||||
在数学上讨论如何生成这样的矩阵仍然超出了本节内容。但是记住,即使这样一个矩阵也不能完全解决万向节死锁问题(尽管会极大地避免)。避免万向节死锁的真正解决方案是使用**四元数(Quaternion)**,它不仅安全,而且计算更加友好。有关四元数会在后面的教程中讨论。
|
||||
|
||||
### 矩阵的组合
|
||||
|
||||
使用矩阵变换的真正力量在于,根据矩阵之前的乘法,我们可以把多个变换组合到一个矩阵中。让我们看看我们是否能生成一个多个变换相结合而成的变换矩阵。我们有一个顶点(x, y, z),我们希望将其缩放2倍,然后用位移(1, 2, 3)来平移它。我们需要一个平移和缩放矩阵来完成这些变换。结果的变换矩阵看起来像这样:
|
||||
|
||||
$$
|
||||
Trans . Scale = \begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}1 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}2 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}3 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} . \begin{bmatrix} \color{red}2 & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}2 & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}2 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} = \begin{bmatrix} \color{red}2 & \color{red}0 & \color{red}0 & \color{red}1 \\ \color{green}0 & \color{green}2 & \color{green}0 & \color{green}2 \\ \color{blue}0 & \color{blue}0 & \color{blue}2 & \color{blue}3 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix}
|
||||
$$
|
||||
|
||||
注意,当矩阵相乘时我们先写平移再写缩放变换的。矩阵乘法是不可交换的,这意味着它们的顺序很重要。当矩阵相乘时,在最右边的矩阵是第一个乘以向量的,所以你应该从右向左读这个乘法。我们建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是平移,否则它们会(消极地)互相影响。比如,如果你先平移然后缩放,平移的向量也会同样被缩放(译注:比如向某方向移动2米,2米也许会被缩放成1米)!
|
||||
|
||||
将我们的矢量左乘最终的变换矩阵会得到以下结果:
|
||||
|
||||
$$
|
||||
\begin{bmatrix} \color{red}2 & \color{red}0 & \color{red}0 & \color{red}1 \\ \color{green}0 & \color{green}2 & \color{green}0 & \color{green}2 \\ \color{blue}0 & \color{blue}0 & \color{blue}2 & \color{blue}3 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} . \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} \color{red}2x + \color{red}1 \\ \color{green}2y + \color{green}2 \\ \color{blue}2z + \color{blue}3 \\ 1 \end{bmatrix}
|
||||
$$
|
||||
|
||||
不错!向量先缩放2倍,然后平移了(1, 2, 3)个单位。
|
||||
|
||||
## 实践
|
||||
|
||||
现在我们已经解释了所有变换背后的理论,是时候将这些知识利用起来了。OpenGL没有任何自带的矩阵和向量形式,所以我们必须自己定义数学类和方法。在这个教程中我们更愿意抽象所有的数学细节,使用已经做好了的数学库。幸运的是有个使用简单的专门为OpenGL量身定做的数学库,那就是GLM。
|
||||
|
||||
### GLM
|
||||
|
||||
GLM是Open**GL** **M**athematics的缩写,它是一个只有头文件的库,也就是说我们只需包含合适的头文件就行了;不用链接和编译。GLM可以从他们的[网站](http://glm.g-truc.net/0.9.5/index.html)上下载。把头文件的根目录复制到你的`includes`文件夹,然后你就可以使用这个库了。
|
||||
|
||||

|
||||
|
||||
我们需要的GLM的大多数功能都可以从下面这3个头文件中找到:
|
||||
|
||||
```c++
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
#include <glm/gtc/type_ptr.hpp>
|
||||
```
|
||||
|
||||
我们来看看是否可以利用我们刚学的变换知识把一个向量(1, 0, 0)平移(1, 1, 0)个单位(注意,我们把它定义为一个glm::vec4类型的值,其中齐次坐标我们设定为1.0):
|
||||
|
||||
```c++
|
||||
glm::vec4 vec(1.0f, 0.0f, 0.0f, 1.0f);
|
||||
glm::mat4 trans;
|
||||
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
|
||||
vec = trans * vec;
|
||||
std::cout << vec.x << vec.y << vec.z << std::endl;
|
||||
```
|
||||
|
||||
我们先用GLM内建的向量类定义一个叫做`vec`的向量。接下来我们定义一个`mat4`类型的`trans`,默认是4×4单位矩阵。接下来我们创建一个变换矩阵,我们是把单位矩阵和一个平移向量传递给`glm::translate`函数来完成这个工作的(然后用给定的矩阵乘以平移矩阵就能获得最后需要的矩阵)。
|
||||
|
||||
之后我们把向量乘以平移矩阵并且输出最后的结果。如果我们仍然记得平移矩阵是如何工作的话,得到的向量应该是(1 + 1, 0 + 1, 0 + 0),也就是(2, 1, 0)。这个代码片段将会输出210,所以这个平移矩阵是正确的。
|
||||
|
||||
我们来做些更有意思的事情,让我们来旋转和缩放之前教程中的那个箱子。首先我们把箱子逆时针旋转90度。然后缩放0.5倍,使它变成原来的二分之一。我们先来创建变换矩阵:
|
||||
|
||||
```c++
|
||||
glm::mat4 trans;
|
||||
trans = glm::rotate(trans, 90.0f, glm::vec3(0.0, 0.0, 1.0));
|
||||
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
|
||||
```
|
||||
|
||||
首先,我们把箱子在每个轴缩放到0.5倍,然后沿Z轴旋转90度。注意有纹理的那面矩形是在XY平面上的,我们需要把它绕着z轴旋转。因为我们把这个矩阵传递给了GLM的每个函数,GLM会自动将矩阵相乘,返回的结果是一个包括了多个变换的变换矩阵。
|
||||
|
||||
!!! Attention
|
||||
|
||||
有些GLM版本接收的是弧度而不是角度,这种情况下你可以用`glm::radians(90.0f)`将角度转换为弧度。
|
||||
|
||||
下一个大问题是:如何把矩阵传递给着色器?我们在前面简单提到过GLSL里的`mat4`类型。所以我们改写顶点着色器来接收一个`mat4`的uniform变量,然后再用矩阵uniform乘以位置向量:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
layout (location = 0) in vec3 position;
|
||||
layout (location = 1) in vec3 color;
|
||||
layout (location = 2) in vec2 texCoord;
|
||||
|
||||
out vec3 ourColor;
|
||||
out vec2 TexCoord;
|
||||
|
||||
uniform mat4 transform;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = transform * vec4(position, 1.0f);
|
||||
ourColor = color;
|
||||
TexCoord = vec2(texCoord.x, 1.0 - texCoord.y);
|
||||
}
|
||||
```
|
||||
|
||||
!!! Attention
|
||||
|
||||
GLSL也有`mat2`和`mat3`类型从而允许了像向量一样的混合运算。前面提到的所有数学运算(比如标量-矩阵乘法,矩阵-向量乘法和矩阵-矩阵乘法)在矩阵类型里都可以使用。当出现特殊的矩阵运算的时候我们会特别说明发生了什么的。
|
||||
|
||||
在把位置向量传给`gl_Position`之前,我们添加一个uniform,并且用变换矩阵乘以它。我们的箱子现在应该是原来的二分之一大小并旋转了90度(向左倾斜)。当然,我们仍需要把变换矩阵传递给着色器:
|
||||
|
||||
```c++
|
||||
GLuint transformLoc = glGetUniformLocation(ourShader.Program, "transform");
|
||||
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
|
||||
```
|
||||
|
||||
我们首先请求uniform变量的地址,然后用有`Matrix4fv`后缀的`glUniform`函数把矩阵数据发送给着色器。第一个参数你现在应该很熟悉了,它是uniform的地址(Location)。第二个参数告诉OpenGL我们将要发送多少个矩阵,目前是1。第三个参数询问我们我们是否希望对我们的矩阵进行置换(Transpose),也就是说交换我们矩阵的行和列。OpenGL开发者通常使用一种内部矩阵布局叫做**以列为主顺序的(Column-major Ordering)**布局。GLM已经是用以列为主顺序定义了它的矩阵,所以并不需要置换矩阵,我们填`GL_FALSE`、最后一个参数是实际的矩阵数据,但是GLM并不是把它们的矩阵储存为OpenGL所希望的那种,因此我们要先用GLM的自带的函数`value_ptr`来变换这些数据。
|
||||
|
||||
我们创建了一个变换矩阵,在顶点着色器中声明了一个uniform,并把矩阵发送给了着色器,着色器会变换我们的顶点坐标。最后的结果应该看起来像这样:
|
||||
|
||||

|
||||
|
||||
完美!我们的箱子向左侧倾斜,是原来的二分之一大小,看来变换成功了。我们现在做些更有意思的,看看我们是否可以让箱子随着时间旋转,我们还会重新把箱子放在窗口的左下角。要让箱子随着时间推移旋转,我们必须在游戏循环中更新变换矩阵,因为它需要在每一次渲染迭代中被更新。我们使用GLFW的时间函数来获取不同时间的角度:
|
||||
|
||||
```c++
|
||||
glm::mat4 trans;
|
||||
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
|
||||
trans = glm::rotate(trans,(GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.0f, 0.0f, 1.0f));
|
||||
```
|
||||
|
||||
要记住的是前面的例子中我们可以在任何地方声明变换矩阵,但是现在我们必须在每一次迭代中创建它,从而保证我们能够更新旋转矩阵。这也就意味着我们不得不在每次迭代中中重新创建变换矩阵。通常在渲染场景的时候,我们也会有多个在每次渲染迭代中都用新的值重新创建的变换矩阵
|
||||
|
||||
在这里我们先把箱子围绕原点(0, 0, 0)旋转,之后,我们把旋转过后的箱子平移到屏幕的右下角。记住,实际的变换顺序应该从下向上阅读:尽管在代码中我们先平移再旋转,实际的变换却是先应用旋转然后平移的。明白所有这些变换的组合,并且知道它们是如何应用到物体上的并不简单。只有尝试和实验这些变换你才能快速地掌握它们。
|
||||
|
||||
如果你做对了,你将看到下面的结果:
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/transformations.mp4" controls="controls">
|
||||
</video>
|
||||
|
||||
这就是我们刚刚做到的!一个平移过的箱子,它会一直转,一个变换矩阵就做到了!现在你可以明白为什么矩阵在图形领域是一个如此重要的工具了。我们可以定义一个无限数量的变换,把它们组合为一个单独的矩阵,如果愿意的话我们还可以重复使用它。在着色器中使用矩阵可以省去重新定义顶点数据的力气,它也能够节省处理时间,因为我们没有一直重新发送我们的数据(这是个非常慢的过程)。
|
||||
|
||||
如果你没有得到正确的结果,或者你有哪儿不清楚的地方。可以看[源码](http://learnopengl.com/code_viewer.php?code=getting-started/transformations)和[顶点](http://learnopengl.com/code_viewer.php?code=getting-started/transformations&type=vertex)、[片段](http://learnopengl.com/code_viewer.php?code=getting-started/transformations&type=fragment)着色器。
|
||||
|
||||
下个教程中,我们会讨论怎样使用矩阵为顶点定义不同的坐标空间。这将是我们进入实时3D图像的第一步!
|
||||
|
||||
## 练习
|
||||
|
||||
- 使用应用在箱子上的最后的变换,尝试将其改变成先旋转,后平移。看看发生了什么,试着想想为什么会发生这样的事情: [参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/transformations-exercise1)
|
||||
- 尝试着再次调用`glDrawElements`画出第二个箱子,但是**只能**使用变换将其摆放在不同的位置。保证这个箱子被摆放在窗口的左上角,并且会不断的缩放(而不是旋转)。使用sin函数在这里会很有用;注意使用sin函数取到负值时会导致物体被翻转: [参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/transformations-exercise2)
|
336
docs/01 Getting started/08 Coordinate Systems.md
Normal file
336
docs/01 Getting started/08 Coordinate Systems.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# 坐标系统(Coordinate System)
|
||||
|
||||
原文 | [Coordinate Systems](http://learnopengl.com/#!Getting-started/Coordinate-Systems)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | linkoln
|
||||
校对 | Geequlim, Meow J, [BLumia](https://github.com/blumia/)
|
||||
|
||||
在上一个教程中,我们学习了如何有效地利用矩阵变换来对所有顶点进行转换。OpenGL希望在所有顶点着色器运行后,所有我们可见的顶点都变为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标转换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),再将他们转换为屏幕上的二维坐标或像素。
|
||||
|
||||
将坐标转换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步,也就是类似于流水线那样子,实现的,在流水线里面我们在将对象转换到屏幕空间之前会先将其转换到多个坐标系统。将对象的坐标转换到几个过渡坐标系(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. 局部坐标是对象相对于局部原点的坐标;也是对象开始的坐标。
|
||||
2. 将局部坐标转换为世界坐标,世界坐标是作为一个更大空间范围的坐标系统。这些坐标是相对于世界的原点的。
|
||||
3. 接下来我们将世界坐标转换为观察坐标,观察坐标是指以摄像机或观察者的角度观察的坐标。
|
||||
4. 在将坐标处理到观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标是处理-1.0到1.0范围内并判断哪些顶点将会出现在屏幕上。
|
||||
5. 最后,我们需要将裁剪坐标转换为屏幕坐标,我们将这一过程成为**视口变换(Viewport Transform)**。视口变换将位于-1.0到1.0范围的坐标转换到由`glViewport`函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片段。
|
||||
|
||||
|
||||
你可能了解了每个单独的坐标空间的作用。我们之所以将顶点转换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当修改对象时,如果在局部空间中则是有意义的;当对对象做相对于其它对象的位置的操作时,在世界坐标系中则是有意义的;等等这些。如果我们愿意,本可以定义一个直接从局部空间到裁剪空间的转换矩阵,但那样会失去灵活性。接下来我们将要更仔细地讨论各个坐标系。
|
||||
|
||||
### 局部空间(Local Space)
|
||||
|
||||
局部空间是指对象所在的坐标空间,例如,对象最开始所在的地方。想象你在一个模型建造软件(比如说Blender)中创建了一个立方体。你创建的立方体的原点有可能位于(0,0,0),即使有可能在最后的应用中位于完全不同的另外一个位置。甚至有可能你创建的所有模型都以(0,0,0)为初始位置,然而他们会在世界的不同位置。则你的模型的所有顶点都是在**局部**空间:他们相对于你的对象来说都是局部的。
|
||||
|
||||
我们一直使用的那个箱子的坐标范围为-0.5到0.5,设定(0, 0)为它的原点。这些都是局部坐标。
|
||||
|
||||
|
||||
### 世界空间(World Space)
|
||||
|
||||
如果我们想将我们所有的对象导入到程序当中,它们有可能会全挤在世界的原点上(0,0,0),然而这并不是我们想要的结果。我们想为每一个对象定义一个位置,从而使对象位于更大的世界当中。世界空间中的坐标就如它们听起来那样:是指顶点相对于(游戏)世界的坐标。物体变换到的最终空间就是世界坐标系,并且你会想让这些物体分散开来摆放(从而显得更真实)。对象的坐标将会从局部坐标转换到世界坐标;该转换是由**模型矩阵(Model Matrix)**实现的。
|
||||
|
||||
模型矩阵是一种转换矩阵,它能通过对对象进行平移、缩放、旋转来将它置于它本应该在的位置或方向。你可以想象一下,我们需要转换一栋房子,通过将它缩小(因为它在局部坐标系中显得太大了),将它往郊区的方向平移,然后沿着y轴往坐标旋转。经过这样的变换之后,它将恰好能够与邻居的房子重合。你能够想到上一节讲到的利用模型矩阵将各个箱子放置到这个屏幕上;我们能够将箱子中的局部坐标转换为观察坐标或世界坐标。
|
||||
|
||||
### 观察空间(View Space)
|
||||
|
||||
观察空间经常被人们称之OpenGL的**摄像机(Camera)**(所以有时也称为摄像机空间(Camera Space)或视觉空间(Eye Space))。观察空间就是将对象的世界空间的坐标转换为观察者视野前面的坐标。因此观察空间就是从摄像机的角度观察到的空间。而这通常是由一系列的平移和旋转的组合来平移和旋转场景从而使得特定的对象被转换到摄像机前面。这些组合在一起的转换通常存储在一个**观察矩阵(View Matrix)**里,用来将世界坐标转换到观察空间。在下一个教程我们将广泛讨论如何创建一个这样的观察矩阵来模拟一个摄像机。
|
||||
|
||||
### 裁剪空间(Clip Space)
|
||||
|
||||
在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个给定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就被忽略了,所以剩下的坐标就将变为屏幕上可见的片段。这也就是**裁剪空间**名字的由来。
|
||||
|
||||
因为将所有可见的坐标都放置在-1.0到1.0的范围内不是很直观,所以我们会指定自己的坐标集(Coordinate Set)并将它转换回标准化设备坐标系,就像OpenGL期望它做的那样。
|
||||
|
||||
为了将顶点坐标从观察空间转换到裁剪空间,我们需要定义一个**投影矩阵(Projection Matrix)**,它指定了坐标的范围,例如,每个维度都是从-1000到1000。投影矩阵接着会将在它指定的范围内的坐标转换到标准化设备坐标系中(-1.0,1.0)。所有在范围外的坐标在-1.0到1.0之间都不会被绘制出来并且会被裁剪。在投影矩阵所指定的范围内,坐标(1250,500,750)将是不可见的,这是由于它的x坐标超出了范围,随后被转化为在标准化设备坐标中坐标值大于1.0的值并且被裁剪掉。
|
||||
|
||||
!!! Important
|
||||
|
||||
如果只是片段的一部分例如三角形,超出了裁剪体积(Clipping Volume),则OpenGL会重新构建三角形以使一个或多个三角形能适应在裁剪范围内。
|
||||
|
||||
由投影矩阵创建的**观察区域(Viewing Box)**被称为**平截头体(Frustum)**,且每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将一定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为**投影(Projection)**,因为使用投影矩阵能将3维坐标**投影(Project)**到很容易映射的2D标准化设备坐标系中。
|
||||
|
||||
一旦所有顶点被转换到裁剪空间,最终的操作——**透视划分(Perspective Division)**将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视划分是将4维裁剪空间坐标转换为3维标准化设备坐标。这一步会在每一个顶点着色器运行的最后被自动执行。
|
||||
|
||||
在这一阶段之后,坐标经过转换的结果将会被映射到屏幕空间(由`glViewport`设置)且被转换成片段。
|
||||
|
||||
投影矩阵将观察坐标转换为裁剪坐标的过程采用两种不同的方式,每种方式分别定义自己的平截头体。我们可以创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)。
|
||||
|
||||
#### 正射投影(Orthographic Projection)
|
||||
|
||||
正射投影矩阵定义了一个类似立方体的平截头体,指定了一个裁剪空间,每一个在这空间外面的顶点都会被裁剪。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。所有在使用正射投影矩阵转换到裁剪空间后如果还处于这个平截头体里面的坐标就不会被裁剪。它的平截头体看起来像一个容器:
|
||||
|
||||

|
||||
|
||||
上面的平截头体定义了由宽、高、**近**平面和**远**平面决定的可视的坐标系。任何出现在近平面前面或远平面后面的坐标都会被裁剪掉。正视平截头体直接将平截头体内部的顶点映射到标准化设备坐标系中,因为每个向量的w分量都是不变的;如果w分量等于1.0,则透视划分不会改变坐标的值。
|
||||
|
||||
为了创建一个正射投影矩阵,我们利用GLM的构建函数`glm::ortho`:
|
||||
|
||||
```c++
|
||||
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
|
||||
```
|
||||
|
||||
前两个参数指定了平截头体的左右坐标,第三和第四参数指定了平截头体的底部和上部。通过这四个参数我们定义了近平面和远平面的大小,然后第五和第六个参数则定义了近平面和远平面的距离。这个指定的投影矩阵将处于这些x,y,z范围之间的坐标转换到标准化设备坐标系中。
|
||||
|
||||
正射投影矩阵直接将坐标映射到屏幕的二维平面内,但实际上一个直接的投影矩阵将会产生不真实的结果,因为这个投影没有将**透视(Perspective)**考虑进去。所以我们需要**透视投影**矩阵来解决这个问题。
|
||||
|
||||
#### 透视投影(Perspective Projection)
|
||||
|
||||
如果你曾经体验过**实际生活**给你带来的景象,你就会注意到离你越远的东西看起来更小。这个神奇的效果我们称之为透视。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:
|
||||
|
||||

|
||||
|
||||
正如你看到的那样,由于透视的原因,平行线似乎在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。这个投影矩阵不仅将给定的平截头体范围映射到裁剪空间,同样还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被转换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的对象都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内从而作为最后的顶点着色器输出,因此一旦坐标在裁剪空间内,透视划分就会被应用到裁剪空间坐标:
|
||||
|
||||
$$
|
||||
out = \begin{pmatrix} x /w \\ y / w \\ z / w \end{pmatrix}
|
||||
$$
|
||||
|
||||
每个顶点坐标的分量都会除以它的w分量,得到一个距离观察者的较小的顶点坐标。这是也是另一个w分量很重要的原因,因为它能够帮助我们进行透射投影。最后的结果坐标就是处于标准化设备空间内的。如果你对研究正射投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧的话)我推荐[这篇由Songho写的文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)。
|
||||
|
||||
在GLM中可以这样创建一个透视投影矩阵:
|
||||
|
||||
```c++
|
||||
glm::mat4 proj = glm::perspective(45.0f, (float)width/(float)height, 0.1f, 100.0f);
|
||||
```
|
||||
|
||||
`glm::perspective`所做的其实就是再次创建了一个定义了可视空间的大的**平截头体**,任何在这个平截头体的对象最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被可视化为一个不均匀形状的盒子,在这个盒子内部的每个坐标都会被映射到裁剪空间的点。一张透视平截头体的照片如下所示:
|
||||
|
||||

|
||||
|
||||
它的第一个参数定义了**fov**的值,它表示的是**视野(Field of View)**,并且设置了观察空间的大小。对于一个真实的观察效果,它的值经常设置为45.0,但想要看到更多结果你可以设置一个更大的值。第二个参数设置了宽高比,由视口的高除以宽。第三和第四个参数设置了平截头体的近和远平面。我们经常设置近距离为0.1而远距离设为100.0。所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染。
|
||||
|
||||
!!! Important
|
||||
|
||||
当你把透视矩阵的*near*值设置太大时(如10.0),OpenGL会将靠近摄像机的坐标都裁剪掉(在0.0和10.0之间),这会导致一个你很熟悉的视觉效果:在太过靠近一个物体的时候视线会直接穿过去。
|
||||
|
||||
当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视划分(它仍然有进行透视划分,只是w分量没有被操作(它保持为1)因此没有起作用)。因为正射投影没有使用透视,远处的对象不会显得小以产生神奇的视觉输出。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的应用,或者是那些我们不需要使用投影来转换顶点的情况下。某些如Blender的进行三维建模的软件有时在建模时会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。下面你能够看到在Blender里面使用两种投影方式的对比:
|
||||
|
||||

|
||||
|
||||
你可以看到使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。
|
||||
|
||||
### 把它们都组合到一起
|
||||
|
||||
我们为上述的每一个步骤都创建了一个转换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点的坐标将会根据以下过程被转换到裁剪坐标:
|
||||
|
||||
$$
|
||||
V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local}
|
||||
$$
|
||||
|
||||
注意每个矩阵被运算的顺序是相反的(记住我们需要从右往左乘上每个矩阵)。最后的顶点应该被赋予顶点着色器中的`gl_Position`且OpenGL将会自动进行透视划分和裁剪。
|
||||
|
||||
!!! Important
|
||||
|
||||
**然后呢?**
|
||||
|
||||
顶点着色器的输出需要所有的顶点都在裁剪空间内,而这是我们的转换矩阵所做的。OpenGL然后在裁剪空间中执行透视划分从而将它们转换到标准化设备坐标。OpenGL会使用`glViewPort`内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中屏幕是800 *600)。这个过程称为视口转换。
|
||||
|
||||
这一章的主题可能会比较难理解,如果你仍然不确定每个空间的作用的话,你也不必太担心。接下来你会看到我们是怎样好好运用这些坐标空间的并且会有足够的展示例子在接下来的教程中。
|
||||
|
||||
## 进入三维
|
||||
|
||||
既然我们知道了如何将三维坐标转换为二维坐标,我们可以开始将我们的对象展示为三维对象而不是目前我们所展示的缺胳膊少腿的二维平面。
|
||||
|
||||
在开始进行三维画图时,我们首先创建一个模型矩阵。这个模型矩阵包含了平移、缩放与旋转,我们将会运用它来将对象的顶点转换到全局世界空间。让我们平移一下我们的平面,通过将其绕着x轴旋转使它看起来像放在地上一样。这个模型矩阵看起来是这样的:
|
||||
|
||||
```c++
|
||||
glm::mat4 model;
|
||||
model = glm::rotate(model, -55.0f, glm::vec3(1.0f, 0.0f, 0.0f));
|
||||
```
|
||||
|
||||
通过将顶点坐标乘以这个模型矩阵我们将该顶点坐标转换到世界坐标。我们的平面看起来就是在地板上的因此可以代表真实世界的平面。
|
||||
|
||||
接下来我们需要创建一个观察矩阵。我们想要在场景里面稍微往后移动以使得对象变成可见的(当在世界空间时,我们位于原点(0,0,0))。要想在场景里面移动,思考下面的问题:
|
||||
|
||||
- 将摄像机往后移动跟将整个场景往前移是一样的。
|
||||
|
||||
这就是观察空间所做的,我们以相反于移动摄像机的方向移动整个场景。因为我们想要往后移动,并且OpenGL是一个右手坐标系(Right-handed System)所以我们沿着z轴的负方向移动。我们会通过将场景沿着z轴正方向平移来实现这个。它会给我们一种我们在往后移动的感觉。
|
||||
|
||||
!!! Important
|
||||
|
||||
**右手坐标系(Right-handed System)**
|
||||
|
||||
按照约定,OpenGL是一个右手坐标系。最基本的就是说正x轴在你的右手边,正y轴往上而正z轴是往后的。想象你的屏幕处于三个轴的中心且正z轴穿过你的屏幕朝向你。坐标系画起来如下:
|
||||
|
||||

|
||||
|
||||
为了理解为什么被称为右手坐标系,按如下的步骤做:
|
||||
|
||||
- 张开你的右手使正y轴沿着你的手往上。
|
||||
- 使你的大拇指往右。
|
||||
- 使你的食指往上。
|
||||
- 向下90度弯曲你的中指。
|
||||
|
||||
如果你都正确地做了,那么你的大拇指朝着正x轴方向,食指朝着正y轴方向,中指朝着正z轴方向。如果你用左手来做这些动作,你会发现z轴的方向是相反的。这就是有名的左手坐标系,它被DirectX广泛地使用。注意在标准化设备坐标系中OpenGL使用的是左手坐标系(投影矩阵改变了惯用手的习惯)。
|
||||
|
||||
在下一个教程中我们将会详细讨论如何在场景中移动。目前的观察矩阵是这样的:
|
||||
|
||||
```c++
|
||||
glm::mat4 view;
|
||||
// 注意,我们将矩阵向我们要进行移动场景的反向移动。
|
||||
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
|
||||
```
|
||||
|
||||
最后我们需要做的是定义一个投影矩阵。我们想要在我们的场景中使用透视投影所以我们声明的投影矩阵是像这样的:
|
||||
|
||||
```c++
|
||||
glm::mat4 projection;
|
||||
projection = glm::perspective(45.0f, screenWidth / screenHeight, 0.1f, 100.0f);
|
||||
```
|
||||
|
||||
!!! Attention
|
||||
|
||||
再重复一遍,在glm指定角度的时候要注意。这里我们将参数fov设置为45度,但有些GLM的实现是将fov当成弧度,在这种情况你需要使用`glm::radians(45.0)`来设置。
|
||||
|
||||
既然我们创建了转换矩阵,我们应该将它们传入着色器。首先,让我们在顶点着色器中声明一个单位转换矩阵然后将它乘以顶点坐标:
|
||||
|
||||
```c++
|
||||
#version 330 core
|
||||
layout (location = 0) in vec3 position;
|
||||
...
|
||||
uniform mat4 model;
|
||||
uniform mat4 view;
|
||||
uniform mat4 projection;
|
||||
|
||||
void main()
|
||||
{
|
||||
// 注意从右向左读
|
||||
gl_Position = projection * view * model * vec4(position, 1.0f);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
我们应该将矩阵传入着色器(这通常在每次渲染的时候即转换矩阵将要改变的时候完成):
|
||||
|
||||
```c++
|
||||
GLint modelLoc = glGetUniformLocation(ourShader.Program, "model");
|
||||
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
|
||||
... // 观察矩阵和投影矩阵与之类似
|
||||
```
|
||||
|
||||
现在我们的顶点坐标通过模型、观察和投影矩阵来转换,最后的对象应该是:
|
||||
|
||||
- 往后向地板倾斜。
|
||||
- 离我们有点距离。
|
||||
- 由透视展示(顶点越远,变得越小)。
|
||||
|
||||
让我们检查一下结果是否满足这些要求:
|
||||
|
||||

|
||||
|
||||
它看起来就像是一个三维的平面,是静止在一些虚构的地板上的。如果你不是得到相同的结果,请检查下完整的[源代码](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems) 以及[顶点](http://learnopengl.com/code_viewer.php?code=getting-started/transform&type=vertex)和[片段](http://learnopengl.com/code_viewer.php?code=getting-started/transform&type=fragment)着色器。
|
||||
|
||||
## 更多的3D
|
||||
|
||||
到目前为止,我们在二维平面甚至在三维空间中画图,所以让我们采取大胆的方式来将我们的二维平面扩展为三维立方体。要渲染一个立方体,我们一共需要36个顶点(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点),这36个顶点的位置你可以[从这里获取](http://learnopengl.com/code_viewer.php?code=getting-started/cube_vertices)。注意,这一次我们省略了颜色值,因为这次我们只在乎顶点的位置和,我们使用纹理贴图。
|
||||
|
||||
为了好玩,我们将让立方体随着时间旋转:
|
||||
|
||||
```c++
|
||||
model = glm::rotate(model, (GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.5f, 1.0f, 0.0f));
|
||||
```
|
||||
|
||||
然后我们使用`glDrawArrays`来画立方体,这一次总共有36个顶点。
|
||||
|
||||
```c++
|
||||
glDrawArrays(GL_TRIANGLES, 0, 36);
|
||||
```
|
||||
|
||||
如果一切顺利的话绘制效果将与下面的类似:
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/coordinate_system_no_depth.mp4" controls="controls"></video>
|
||||
|
||||
这有点像一个立方体,但又有种说不出的奇怪。立方体的某些本应被遮挡住的面被绘制在了这个立方体的其他面的上面。之所以这样是因为OpenGL是通过画一个一个三角形来画你的立方体的,所以它将会覆盖之前已经画在那里的像素。因为这个原因,有些三角形会画在其它三角形上面,虽然它们本不应该是被覆盖的。
|
||||
|
||||
幸运的是,OpenGL存储深度信息在z缓冲区(Z-buffer)里面,它允许OpenGL决定何时覆盖一个像素何时不覆盖。通过使用z缓冲区我们可以设置OpenGL来进行深度测试。
|
||||
|
||||
### z缓冲区
|
||||
|
||||
OpenGL存储它的所有深度信息于z缓冲区中,也被称为深度缓冲区(Depth Buffer)。GLFW会自动为你生成这样一个缓冲区 (就如它有一个颜色缓冲区来存储输出图像的颜色)。深度存储在每个片段里面(作为片段的z值)当片段像输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较然后如果当前的片段在其它片段之后它将会被丢弃,然后重写。这个过程称为**深度测试(Depth Testing)**并且它是由OpenGL自动完成的。
|
||||
|
||||
然而,如果我们想要确定OpenGL是否真的执行深度测试,首先我们要告诉OpenGL我们想要开启深度测试;而这通常是默认关闭的。我们通过`glEnable`函数来开启深度测试。`glEnable`和`glDisable`函数允许我们开启或关闭某一个OpenGL的功能。该功能会一直是开启或关闭的状态直到另一个调用来关闭或开启它。现在我们想开启深度测试就需要开启`GL_DEPTH_TEST`:
|
||||
|
||||
```c++
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
```
|
||||
|
||||
既然我们使用了深度测试我们也想要在每次重复渲染之前清除深度缓冲区(否则前一个片段的深度信息仍然保存在缓冲区中)。就像清除颜色缓冲区一样,我们可以通过在`glclear`函数中指定`DEPTH_BUFFER_BIT`位来清除深度缓冲区:
|
||||
|
||||
```c++
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
```
|
||||
|
||||
我们来重新运行下程序看看OpenGL是否执行了深度测试:
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/coordinate_system_depth.mp4" controls="controls"></video>
|
||||
|
||||
就是这样!一个开启了深度测试,各个面都是纹理,并且还在旋转的立方体!如果你的程序有问题可以到[这里](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems_with_depth)下载源码进行比对。
|
||||
|
||||
### 更多的立方体
|
||||
|
||||
现在我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的图形布局已经定义好了,所以当渲染更多物体的时候我们不需要改变我们的缓冲数组和属性数组,我们唯一需要做的只是改变每个对象的模型矩阵来将立方体转换到世界坐标系中。
|
||||
|
||||
首先,让我们为每个立方体定义一个转换向量来指定它在世界空间的位置。我们将要在`glm::vec3`数组中定义10个立方体位置向量。
|
||||
|
||||
```c++
|
||||
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)
|
||||
};
|
||||
```
|
||||
|
||||
现在,在循环中,我们调用`glDrawArrays`10次,在我们开始渲染之前每次传入一个不同的模型矩阵到顶点着色器中。我们将会创建一个小的循环来通过一个不同的模型矩阵重复渲染我们的对象10次。注意我们也传入了一个旋转参数到每个箱子中:
|
||||
|
||||
```c++
|
||||
glBindVertexArray(VAO);
|
||||
for(GLuint i = 0; i < 10; i++)
|
||||
{
|
||||
glm::mat4 model;
|
||||
model = glm::translate(model, cubePositions[i]);
|
||||
GLfloat angle = 20.0f * i;
|
||||
model = glm::rotate(model, angle, glm::vec3(1.0f, 0.3f, 0.5f));
|
||||
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
|
||||
|
||||
glDrawArrays(GL_TRIANGLES, 0, 36);
|
||||
}
|
||||
glBindVertexArray(0);
|
||||
```
|
||||
|
||||
这个代码将会每次都更新模型矩阵然后画出新的立方体,如此总共重复10次。然后我们应该就能看到一个拥有10个正在奇葩旋转着的立方体的世界。
|
||||
|
||||

|
||||
|
||||
完美!这就像我们的箱子找到了志同道合的小伙伴一样。如果你在这里卡住了,你可以对照一下[代码](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems_multiple_objects) 以及[顶点着色器](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems&type=vertex)和[片段着色器](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems&type=fragment) 。
|
||||
|
||||
## 练习
|
||||
|
||||
- 对GLM的投影函数中的`FoV`和`aspect-ratio`参数进行试验。看能否搞懂它们是如何影响透视平截头体的。
|
||||
- 将观察矩阵在各个方向上进行平移,来看看场景是如何改变的。注意把观察矩阵当成摄像机对象。
|
||||
- 只使用模型矩阵每次只让3个箱子旋转(包括第1个)而让剩下的箱子保持静止。[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems-exercise3)。
|
||||
|
487
docs/01 Getting started/09 Camera.md
Normal file
487
docs/01 Getting started/09 Camera.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 摄像机(Camera)
|
||||
|
||||
原文 | [Camera](http://learnopengl.com/#!Getting-started/Camera)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | [Django](http://bullteacher.com/)
|
||||
校对 | Geequlim, [BLumia](https://github.com/blumia/)
|
||||
|
||||
前面的教程中我们讨论了观察矩阵以及如何使用观察矩阵移动场景。OpenGL本身没有摄像机的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,这样感觉就像我们在移动,而不是场景在移动。
|
||||
|
||||
本节我们将会讨论如何在OpenGL中模拟一个摄像机,将会讨论FPS风格的可自由在3D场景中移动的摄像机。我们也会讨论键盘和鼠标输入,最终完成一个自定义的摄像机类。
|
||||
|
||||
### 摄像机/观察空间(Camera/View Space)
|
||||
|
||||
当我们讨论摄像机/观察空间的时候,是我们在讨论以摄像机的透视图作为场景原点时场景中所有可见顶点坐标。观察矩阵把所有的世界坐标变换到观察坐标,这些新坐标是相对于摄像机的位置和方向的。定义一个摄像机,我们需要一个摄像机在世界空间中的位置、观察的方向、一个指向它的右测的向量以及一个指向它上方的向量。细心的读者可能已经注意到我们实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。
|
||||
|
||||

|
||||
|
||||
#### 1. 摄像机位置
|
||||
|
||||
获取摄像机位置很简单。摄像机位置简单来说就是世界空间中代表摄像机位置的向量。我们把摄像机位置设置为前面教程中的那个相同的位置:
|
||||
|
||||
```c++
|
||||
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
|
||||
```
|
||||
|
||||
!!! Important
|
||||
|
||||
不要忘记正z轴是从屏幕指向你的,如果我们希望摄像机向后移动,我们就往z轴正方向移动。
|
||||
|
||||
#### 2. 摄像机方向
|
||||
|
||||
下一个需要的向量是摄像机的方向,比如它指向哪个方向。现在我们让摄像机指向场景原点:(0, 0, 0)。用摄像机位置向量减去场景原点向量的结果就是摄像机指向向量。由于我们知道摄像机指向z轴负方向,我们希望方向向量指向摄像机的z轴正方向。如果我们改变相减的顺序,我们就会获得一个指向摄像机正z轴方向的向量(译注:注意看前面的那个图,所说的「方向向量/Direction Vector」是指向z的正方向的,而不是摄像机所注视的那个方向):
|
||||
|
||||
```c++
|
||||
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
|
||||
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
|
||||
```
|
||||
|
||||
!!! Attention
|
||||
|
||||
方向向量(Direction Vector)并不是最好的名字,因为它正好指向从它到目标向量的相反方向。
|
||||
|
||||
#### 3. 右轴(Right axis)
|
||||
|
||||
我们需要的另一个向量是一个**右向量(Right Vector)**,它代表摄像机空间的x轴的正方向。为获取右向量我们需要先使用一个小技巧:定义一个**上向量(Up Vector)**。我们把上向量和第二步得到的摄像机方向向量进行叉乘。两个向量叉乘的结果就是同时垂直于两向量的向量,因此我们会得到指向x轴正方向的那个向量(如果我们交换两个向量的顺序就会得到相反的指向x轴负方向的向量):
|
||||
|
||||
```c++
|
||||
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
|
||||
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
|
||||
```
|
||||
|
||||
#### 4. 上轴(Up axis)
|
||||
|
||||
现在我们已经有了x轴向量和z轴向量,获取摄像机的正y轴相对简单;我们把右向量和方向向量(Direction Vector)进行叉乘:
|
||||
|
||||
```c++
|
||||
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
|
||||
```
|
||||
|
||||
在叉乘和一些小技巧的帮助下,我们创建了所有观察/摄像机空间的向量。对于想学到更多数学原理的读者,提示一下,在线性代数中这个处理叫做[Gram-Schmidt(葛兰—施密特)正交](http://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process)。使用这些摄像机向量我们就可以创建一个**LookAt**矩阵了,它在创建摄像机的时候非常有用。
|
||||
|
||||
### Look At
|
||||
|
||||
使用矩阵的好处之一是如果你定义了一个坐标空间,里面有3个相互垂直的轴,你可以用这三个轴外加一个平移向量来创建一个矩阵,你可以用这个矩阵乘以任何向量来变换到那个坐标空间。这正是LookAt矩阵所做的,现在我们有了3个相互垂直的轴和一个定义摄像机空间的位置坐标,我们可以创建我们自己的LookAt矩阵了:
|
||||
|
||||
$$
|
||||
LookAt = \begin{bmatrix} \color{red}{R_x} & \color{red}{R_y} & \color{red}{R_z} & 0 \\ \color{green}{U_x} & \color{green}{U_y} & \color{green}{U_z} & 0 \\ \color{blue}{D_x} & \color{blue}{D_y} & \color{blue}{D_z} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} * \begin{bmatrix} 1 & 0 & 0 & -\color{purple}{P_x} \\ 0 & 1 & 0 & -\color{purple}{P_y} \\ 0 & 0 & 1 & -\color{purple}{P_z} \\ 0 & 0 & 0 & 1 \end{bmatrix}
|
||||
$$
|
||||
|
||||
\(\color{red}R\)是右向量,\(\color{green}U\)是上向量,\(\color{blue}D\)是方向向量\(\color{purple}P\)是摄像机位置向量。注意,位置向量是相反的,因为我们最终希望把世界平移到与我们自身移动的相反方向。使用这个LookAt矩阵坐标观察矩阵可以很高效地把所有世界坐标变换为观察坐标LookAt矩阵就像它的名字表达的那样:它会创建一个观察矩阵looks at(看着)一个给定目标。
|
||||
|
||||
幸运的是,GLM已经提供了这些支持。我们要做的只是定义一个摄像机位置,一个目标位置和一个表示上向量的世界空间中的向量(我们使用上向量计算右向量)。接着GLM就会创建一个LookAt矩阵,我们可以把它当作我们的观察矩阵:
|
||||
|
||||
```c++
|
||||
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和z表示一个在一个圆圈上的一点,我们会使用它作为摄像机的位置。通过重复计算x和y坐标,遍历所有圆圈上的点,这样摄像机就会绕着场景旋转了。我们预先定义这个圆圈的半径,使用`glfwGetTime`函数不断增加它的值,在每次渲染迭代创建一个新的观察矩阵。
|
||||
|
||||
```c++
|
||||
GLfloat radius = 10.0f;
|
||||
GLfloat camX = sin(glfwGetTime()) * radius;
|
||||
GLfloat 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));
|
||||
```
|
||||
|
||||
如果你运行代码你会得到下面的东西:
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/camera_circle.mp4" controls="controls">
|
||||
</video>
|
||||
|
||||
这一小段代码中,摄像机围绕场景转动。自己试试改变半径和位置/方向参数,看看LookAt矩阵是如何工作的。同时,这里有[源码](http://learnopengl.com/code_viewer.php?code=getting-started/camera_circle)、[顶点](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems&type=vertex)和[片段](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems&type=fragment)着色器。
|
||||
|
||||
## 自由移动
|
||||
|
||||
让摄像机绕着场景转很有趣,但是让我们自己移动摄像机更有趣!首先我们必须设置一个摄像机系统,在我们的程序前面定义一些摄像机变量很有用:
|
||||
|
||||
```c++
|
||||
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函数现在成了:
|
||||
|
||||
```c++
|
||||
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
|
||||
```
|
||||
|
||||
我们首先设置之前定义的`cameraPos`为摄像机位置。方向(Direction)是当前的位置加上我们刚刚定义的方向向量。这样能保证无论我们怎么移动,摄像机都会注视目标。我们在按下某个按钮时更新`cameraPos`向量。
|
||||
|
||||
我们已经为GLFW的键盘输入定义了一个`key_callback`函数,我们来添加几个新按键命令:
|
||||
|
||||
```c++
|
||||
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
|
||||
{
|
||||
...
|
||||
GLfloat cameraSpeed = 0.05f;
|
||||
if(key == GLFW_KEY_W)
|
||||
cameraPos += cameraSpeed * cameraFront;
|
||||
if(key == GLFW_KEY_S)
|
||||
cameraPos -= cameraSpeed * cameraFront;
|
||||
if(key == GLFW_KEY_A)
|
||||
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
|
||||
if(key == GLFW_KEY_D)
|
||||
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
|
||||
}
|
||||
```
|
||||
|
||||
当我们按下WASD键,摄像机的位置都会相应更新。如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。如果我们希望向旁边移动,我们做一个叉乘来创建一个右向量,沿着它移动就可以了。这样就创建了类似使用摄像机横向、前后移动的效果。
|
||||
|
||||
!!! Important
|
||||
|
||||
注意,我们对右向量进行了标准化。如果我们没对这个向量进行标准化,最后的叉乘结果会根据`cameraFront`变量的大小返回不同的大小。如果我们不对向量进行标准化,我们就得根据摄像机的方位加速或减速移动了,但假如进行了标准化移动就是匀速的。
|
||||
|
||||
如果你用这段代码更新`key_callback`函数,你就可以在场景中自由的前后左右移动了。
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/camera_inside.mp4" controls="controls">
|
||||
</video>
|
||||
|
||||
你可能会注意到这个摄像机系统不能同时朝两个方向移动,当你按下一个按键时,它会先顿一下才开始移动。这是因为大多数事件输入系统一次只能处理一个键盘输入,它们的函数只有当我们激活了一个按键时才被调用。大多数GUI系统都是这样的,它对摄像机来说用并不合理。我们可以用一些小技巧解决这个问题。
|
||||
|
||||
这个技巧是只在回调函数中跟踪哪个键被按下/释放。在游戏循环中我们读取这些值,检查那个按键被激活了,然后做出相应反应。我们只储存哪个键被按下/释放的状态信息,在游戏循环中对状态做出反应,我们来创建一个布尔数组代表按下/释放的键:
|
||||
|
||||
```c++
|
||||
bool keys[1024];
|
||||
```
|
||||
|
||||
然后我们必须在`key_callback`函数中设置按下/释放键为`true`或`false`:
|
||||
|
||||
```c++
|
||||
if(action == GLFW_PRESS)
|
||||
keys[key] = true;
|
||||
else if(action == GLFW_RELEASE)
|
||||
keys[key] = false;
|
||||
```
|
||||
|
||||
我们创建一个新的叫做`do_movement`的函数,用它根据按下的按键来更新摄像机的值:
|
||||
|
||||
```c++
|
||||
void do_movement()
|
||||
{
|
||||
// 摄像机控制
|
||||
GLfloat cameraSpeed = 0.01f;
|
||||
if(keys[GLFW_KEY_W])
|
||||
cameraPos += cameraSpeed * cameraFront;
|
||||
if(keys[GLFW_KEY_S])
|
||||
cameraPos -= cameraSpeed * cameraFront;
|
||||
if(keys[GLFW_KEY_A])
|
||||
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
|
||||
if(keys[GLFW_KEY_D])
|
||||
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
|
||||
}
|
||||
```
|
||||
|
||||
之前的代码移动到了`do_movement`函数中。由于所有GLFW的按键枚举都是整数,我们可以把它们当数组索引使用。
|
||||
|
||||
最后,我们需要在游戏循环中添加新函数的调用:
|
||||
|
||||
```c++
|
||||
while(!glfwWindowShouldClose(window))
|
||||
{
|
||||
// 检测并调用事件
|
||||
glfwPollEvents();
|
||||
do_movement();
|
||||
|
||||
// 渲染
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
至此,你可以同时向多个方向移动了,并且当你按下按钮也会立刻运动了。如遇困难查看[源码](http://learnopengl.com/code_viewer.php?code=getting-started/camera_keyboard)。
|
||||
|
||||
### 移动速度
|
||||
|
||||
目前我们的移动速度是个常量。看起来不错,但是实际情况下根据处理器的能力不同,有的人在同一段时间内会比其他人绘制更多帧。也就是调用了更多次`do_movement`函数。每个人的运动速度就都不同了。当你要发布的你应用的时候,你必须确保在所有硬件上移动速度都一样。
|
||||
|
||||
图形和游戏应用通常有回跟踪一个`deltaTime`变量,它储存渲染上一帧所用的时间。我们把所有速度都去乘以`deltaTime`值。当我们的`deltaTime`变大时意味着上一帧渲染花了更多时间,所以这一帧使用这个更大的`deltaTime`的值乘以速度,会获得更高的速度,这样就与上一帧平衡了。使用这种方法时,无论你的机器快还是慢,摄像机的速度都会保持一致,这样每个用户的体验就都一样了。
|
||||
|
||||
我们要用两个全局变量来计算出`deltaTime`值:
|
||||
|
||||
```c++
|
||||
GLfloat deltaTime = 0.0f; // 当前帧遇上一帧的时间差
|
||||
GLfloat lastFrame = 0.0f; // 上一帧的时间
|
||||
```
|
||||
|
||||
在每一帧中我们计算出新的`deltaTime`以备后用
|
||||
|
||||
```c++
|
||||
GLfloat currentFrame = glfwGetTime();
|
||||
deltaTime = currentFrame - lastFrame;
|
||||
lastFrame = currentFrame;
|
||||
```
|
||||
|
||||
现在我们有了`deltaTime`在计算速度的使用可以使用了:
|
||||
|
||||
```c++
|
||||
void Do_Movement()
|
||||
{
|
||||
GLfloat cameraSpeed = 5.0f * deltaTime;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
与前面的部分结合在一起,我们有了一个更流畅点的摄像机系统:
|
||||
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/camera_smooth.mp4" controls="controls">
|
||||
</video>
|
||||
|
||||
现在我们有了一个在任何系统上移动速度都一样的摄像机。这里是源码。我们可以看到任何移动都会影响返回的`deltaTime`值。
|
||||
|
||||
|
||||
## 自由观看
|
||||
|
||||
只用键盘移动没什么意思。特别是我们还不能转向。是时候使用鼠标了!
|
||||
|
||||
为了能够改变方向,我们必须根据鼠标的输入改变`cameraFront`向量。然而,根据鼠标旋转改变方向向量有点复杂,需要更多的三角学知识。如果你对三角学知之甚少,别担心,你可以跳过这一部分,直接复制粘贴我们的代码;当你想了解更多的时候再回来看。
|
||||
|
||||
### 欧拉角
|
||||
|
||||
欧拉角是表示3D空间中可以表示任何旋转的三个值,由莱昂哈德·欧拉在18世纪提出。有三种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:
|
||||
|
||||

|
||||
|
||||
**俯仰角**是描述我们如何往上和往下看的角,它在第一张图中表示。第二张图显示了**偏航角**,偏航角表示我们往左和往右看的大小。**滚转角**代表我们如何翻滚摄像机。每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转了。
|
||||
|
||||
对于我们的摄像机系统来说,我们只关心俯仰角和偏航角,所以我们不会讨论滚转角。用一个给定的俯仰角和偏航角,我们可以把它们转换为一个代表新的方向向量的3D向量。俯仰角和偏航角转换为方向向量的处理需要一些三角学知识,我们以最基本的情况开始:
|
||||
|
||||

|
||||
|
||||
如果我们把斜边边长定义为1,我们就能知道邻边的长度是\(\cos \ \color{red}x/\color{purple}h = \cos \ \color{red}x/\color{purple}1 = \cos\ \color{red}x\),它的对边是\(\sin \ \color{green}y/\color{purple}h = \sin \ \color{green}y/\color{purple}1 = \sin\ \color{green}y\)。这样我们获得了能够得到x和y方向的长度的公式,它们取决于所给的角度。我们使用它来计算方向向量的元素:
|
||||
|
||||

|
||||
|
||||
这个三角形看起来和前面的三角形很像,所以如果我们想象自己在xz平面上,正望向y轴,我们可以基于第一个三角形计算长度/y方向的强度(我们往上或往下看多少)。从图中我们可以看到一个给定俯仰角的y值等于sinθ:
|
||||
|
||||
```c++
|
||||
direction.y = sin(glm::radians(pitch)); // 注意我们先把角度转为弧度
|
||||
```
|
||||
|
||||
这里我们只更新了y值,仔细观察x和z元素也被影响了。从三角形中我们可以看到它们的值等于:
|
||||
|
||||
```c++
|
||||
direction.x = cos(glm::radians(pitch));
|
||||
direction.z = cos(glm::radians(pitch));
|
||||
```
|
||||
|
||||
看看我们是否能够为偏航角找到需要的元素:
|
||||
|
||||

|
||||
|
||||
就像俯仰角一样我们可以看到x元素取决于cos(偏航角)的值,z值同样取决于偏航角的正弦值。把这个加到前面的值中,会得到基于俯仰角和偏航角的方向向量:
|
||||
|
||||
!!! note "译注"
|
||||
|
||||
这里的球坐标与笛卡尔坐标的转换把x和z弄反了,如果你去看最后的源码,会发现作者在摄像机源码那里写了`yaw = yaw – 90`,实际上在这里x就应该是`sin(glm::radians(yaw))`,z也是同样处理,当然也可以认为是这个诡异的坐标系,但是在这里使用球坐标转笛卡尔坐标有个大问题,就是在初始渲染时,无法指定摄像机的初始朝向,还要花一些功夫自己实现这个;此外这只能实现像第一人称游戏一样的简易摄像机,类似Maya、Unity3D编辑器窗口的那种摄像机还是最好自己设置摄像机的位置、上、右、前轴,在旋转时用四元数对这四个变量进行调整,才能获得更好的效果,而不是仅仅调整摄像机前轴。
|
||||
|
||||
```c++
|
||||
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));//译注:direction代表摄像机的“前”轴,但此前轴是和本文第一幅图片的第二个摄像机的direction是相反的
|
||||
direction.y = sin(glm::radians(pitch));
|
||||
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
|
||||
```
|
||||
|
||||
这样我们就有了一个可以把俯仰角和偏航角转化为用来自由旋转的摄像机的3个维度的方向向量了。你可能会奇怪:我们怎么得到俯仰角和偏航角?
|
||||
|
||||
### 鼠标输入
|
||||
|
||||
偏航角和俯仰角是从鼠标移动获得的,鼠标水平移动影响偏航角,鼠标垂直移动影响俯仰角。它的思想是储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置和上一帧的位置相差多少。如果差别越大那么俯仰角或偏航角就改变越大。
|
||||
|
||||
首先我们要告诉GLFW,应该隐藏光标,并**捕捉(Capture)**它。捕捉鼠标意味着当应用集中焦点到鼠标上的时候光标就应该留在窗口中(除非应用拾取焦点或退出)。我们可以进行简单的配置:
|
||||
|
||||
|
||||
```c++
|
||||
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
|
||||
```
|
||||
|
||||
这个函数调用后,无论我们怎么去移动鼠标,它都不会显示了,也不会离开窗口。对于FPS摄像机系统来说很好:
|
||||
|
||||
为计算俯仰角和偏航角我们需要告诉GLFW监听鼠标移动事件。我们用下面的原型创建一个回调函数来做这件事(和键盘输入差不多):
|
||||
|
||||
```c++
|
||||
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
|
||||
```
|
||||
|
||||
这里的`xpos`和`ypos`代表当前鼠标的位置。我们注册了GLFW的回调函数,鼠标一移动`mouse_callback`函数就被调用:
|
||||
|
||||
```c++
|
||||
glfwSetCursorPosCallback(window, mouse_callback);
|
||||
```
|
||||
|
||||
在处理FPS风格的摄像机鼠标输入的时候,我们必须在获取最终的方向向量之前做下面这几步:
|
||||
|
||||
1. 计算鼠标和上一帧的偏移量。
|
||||
2. 把偏移量添加到摄像机和俯仰角和偏航角中。
|
||||
3. 对偏航角和俯仰角进行最大和最小值的限制。
|
||||
4. 计算方向向量。
|
||||
|
||||
第一步计算鼠标自上一帧的偏移量。我们必须先储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心(屏幕的尺寸是800乘600):
|
||||
|
||||
```c++
|
||||
GLfloat lastX = 400, lastY = 300;
|
||||
```
|
||||
|
||||
然后在回调函数中我们计算当前帧和上一帧鼠标位置的偏移量:
|
||||
|
||||
```c++
|
||||
GLfloat xoffset = xpos - lastX;
|
||||
GLfloat yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标的范围是从下往上的
|
||||
lastX = xpos;
|
||||
lastY = ypos;
|
||||
|
||||
GLfloat sensitivity = 0.05f;
|
||||
xoffset *= sensitivity;
|
||||
yoffset *= sensitivity;
|
||||
```
|
||||
|
||||
注意我们把偏移量乘以了`sensitivity`值。如果我们移除它,鼠标移动就会太大了;你可以自己调整`sensitivity`的值。
|
||||
|
||||
下面我们把偏移量加到全局变量`pitch`和`yaw`上:
|
||||
|
||||
```c++
|
||||
yaw += xoffset;
|
||||
pitch += yoffset;
|
||||
```
|
||||
|
||||
第三步我们给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了。对于俯仰角,要让用户不能看向高于89度(90度时视角会逆转,所以我们把89度作为极限)的地方,同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下但是不能更进一步超越过去。限制可以这样做:
|
||||
|
||||
```c++
|
||||
if(pitch > 89.0f)
|
||||
pitch = 89.0f;
|
||||
if(pitch < -89.0f)
|
||||
pitch = -89.0f;
|
||||
```
|
||||
|
||||
注意我们没有给偏航角设置限制是因为我们不希望限制用户的水平旋转。然而,给偏航角设置限制也很容易,只要你愿意。
|
||||
|
||||
第四也是最后一步,就是通过俯仰角和偏航角来计算以得到前面提到的实际方向向量:
|
||||
|
||||
```c++
|
||||
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`。这通常是一个距离屏幕中心很远的地方,因而产生一个很大的偏移量,所以就会跳了。我们可以简单的使用一个布尔变量检验我们是否是第一次获取鼠标输入,如果是,那么我们先把鼠标的位置更新为`xpos`和`ypos`,这样就能解决这个问题;最后的鼠标移动会使用进入以后鼠标的位置坐标来计算它的偏移量:
|
||||
|
||||
```c++
|
||||
if(firstMouse) // 这个bool变量一开始是设定为true的
|
||||
{
|
||||
lastX = xpos;
|
||||
lastY = ypos;
|
||||
firstMouse = false;
|
||||
}
|
||||
```
|
||||
|
||||
最后的代码应该是这样的:
|
||||
|
||||
```c++
|
||||
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
|
||||
{
|
||||
if(firstMouse)
|
||||
{
|
||||
lastX = xpos;
|
||||
lastY = ypos;
|
||||
firstMouse = false;
|
||||
}
|
||||
|
||||
GLfloat xoffset = xpos - lastX;
|
||||
GLfloat yoffset = lastY - ypos;
|
||||
lastX = xpos;
|
||||
lastY = ypos;
|
||||
|
||||
GLfloat 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);
|
||||
}
|
||||
```
|
||||
|
||||
现在我们可以自由的在3D场景中移动了!如果你遇到困难,[这是](http://www.learnopengl.com/code_viewer.php?code=getting-started/camera_mouse)源码。
|
||||
|
||||
### 缩放
|
||||
|
||||
我们还要往摄像机系统里加点东西,实现一个缩放接口。前面教程中我们说视野(Field of View或fov)定义了我们可以看到场景中多大的范围。当视野变小时可视区域就会减小,产生放大了的感觉。我们用鼠标滚轮来放大。和鼠标移动、键盘输入一样我们需要一个鼠标滚轮的回调函数:
|
||||
|
||||
```c++
|
||||
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
|
||||
{
|
||||
if(aspect >= 1.0f && aspect <= 45.0f)
|
||||
aspect -= yoffset;
|
||||
if(aspect <= 1.0f)
|
||||
aspect = 1.0f;
|
||||
if(aspect >= 45.0f)
|
||||
aspect = 45.0f;
|
||||
}
|
||||
```
|
||||
|
||||
`yoffset`值代表我们滚动的大小。当`scroll_callback`函数调用后,我们改变全局`aspect`变量的内容。因为`45.0f`是默认的`fov`,我们将会把缩放级别限制在`1.0f`到`45.0f`。
|
||||
|
||||
我们现在在每一帧都必须把透视投影矩阵上传到GPU,但这一次使`aspect`变量作为它的fov:
|
||||
|
||||
```c++
|
||||
projection = glm::perspective(aspect, (GLfloat)WIDTH/(GLfloat)HEIGHT, 0.1f, 100.0f);
|
||||
```
|
||||
|
||||
最后不要忘记注册滚动回调函数:
|
||||
|
||||
```c++
|
||||
glfwSetScrollCallback(window, scroll_callback);
|
||||
```
|
||||
|
||||
现在我们实现了一个简单的摄像机系统,它能够让我们在3D环境中自由移动。
|
||||
|
||||
<video src="http://learnopengl.com/video/getting-started/camera_mouse.mp4" controls="controls">
|
||||
</video>
|
||||
|
||||
自由的去实验,如果遇到困难对比[源代码](http://learnopengl.com/code_viewer.php?code=getting-started/camera_zoom)。
|
||||
|
||||
!!! Important
|
||||
|
||||
注意,使用欧拉角作为摄像机系统并不完美。你仍然可能遇到[万向节死锁](http://en.wikipedia.org/wiki/Gimbal_lock)。最好的摄像机系统是使用四元数的,后面会有讨论。
|
||||
|
||||
## 摄像机类
|
||||
|
||||
接下来的教程我们会使用一个摄像机来浏览场景,从各个角度观察结果。然而由于一个摄像机会占教程的很大的篇幅,我们会从细节抽象出创建一个自己的摄像机对象。与着色器教程不同我们不会带你一步一步创建摄像机类,如果你想知道怎么工作的的话,只会给你提供一个(有完整注释的)源码。
|
||||
|
||||
像着色器对象一样,我们把摄像机类写在一个单独的头文件中。你可以在[这里](http://learnopengl.com/code_viewer.php?type=header&code=camera)找到它。你应该能够理解所有的代码。我们建议您至少看一看这个类,看看如何创建一个自己的摄像机类。
|
||||
|
||||
!!! Attention
|
||||
|
||||
我们介绍的欧拉角FPS风格摄像机系统能够满足大多数情况需要,但是在创建不同的摄像机系统,比如飞行模拟就要当心。每个摄像机系统都有自己的有点和不足,所以确保对它们进行了详细研究。比如,这个FPS摄像机不允许俯仰角大于90度,由于使用了固定的上向量(0, 1, 0),我们就不能用滚转角。
|
||||
|
||||
使用新的摄像机对象的更新后的版本源码可以[在这里找到](http://learnopengl.com/code_viewer.php?code=getting-started/camera_with_class)。(译注:总而言之这个摄像机实现并不十分完美,你可以看看最终的源码。建议先看[这篇文章](https://github.com/cybercser/OpenGL_3_3_Tutorial_Translation/blob/master/Tutorial%2017%20Rotations.md),对旋转有更深的理解后,你就能做出更好的摄像机类,不过本文有些内容比如如何防止按键停顿和glfw鼠标事件实现摄像机的注意事项比较重要,其它的就要做一定的取舍了)
|
||||
|
||||
|
||||
## 练习
|
||||
|
||||
- 看看你是否能够变换摄像机类从而使得其能够变- 成一个**真正的**FPS摄像机(也就是说不能够随意飞行);你只能够呆在xz平面上: [参考解答](http://www.learnopengl.com/code_viewer.php?code=getting-started/camera-exercise1)
|
||||
|
||||
- 试着创建你自己的LookAt函数,使你能够手动创建一个我们在一开始讨论的观察矩阵。用你的函数实现来替换glm的LookAt函数,看看它是否还能一样的工作:[参考解答](http://www.learnopengl.com/code_viewer.php?code=getting-started/camera-exercise2)
|
41
docs/01 Getting started/10 Review.md
Normal file
41
docs/01 Getting started/10 Review.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 复习
|
||||
|
||||
原文 | [Review](http://learnopengl.com/#!Getting-started/Review)
|
||||
---|---
|
||||
作者 | JoeyDeVries
|
||||
翻译 | Meow J
|
||||
校对 | Geequlim
|
||||
|
||||
恭喜您完成了本章的学习,至此为止你应该能够创建一个窗口,创建并且编译着色器,通过缓冲对象或者uniform发送顶点数据,绘制物体,使用纹理,理解向量和矩阵,并且可以综合上述知识创建一个3D场景并可以通过摄像机来移动.
|
||||
|
||||
这些就是我们在前几章学习的内容,尝试在教程的基础上进行改动程序,或者实验你自己的想法并解决问题. 一旦你认为你真正熟悉了我们讨论的所有的东西,你就可以进行[下一节](http://learnopengl-cn.readthedocs.org/zh/latest/02%20Lighting/01%20Colors/)的学习.
|
||||
|
||||
词汇表
|
||||
--------
|
||||
|
||||
- **OpenGL**: 一个定义了函数布局和输出的图形API的正式规范.
|
||||
- **GLEW**: 一个拓展加载库用来为我们加载并设定所有OpenGL函数指针从而让我们能够使用所有(现代)OpenGL函数.
|
||||
- **视口(Viewport)**: 我们需要渲染的窗口.
|
||||
- **图形管道(Graphics Pipeline)**: 一个顶点在呈现为像素之前通过的过程.
|
||||
- **着色器(Shader)**: 一个运行在显卡上的小型程序.很多阶段的图形管道都可以使用自定义的着色器来代替原来的功能.
|
||||
- **标准化设备坐标(Normalized Device Coordinates)**: 顶点在通过在剪裁坐标系中剪裁与透视划分后最终呈现在的坐标系. 所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见.
|
||||
- **顶点缓冲对象(Vertex Buffer Object)**: 一个调用显存并存储所有顶点数据供显卡使用的缓冲对象.
|
||||
- **顶点数组对象(Vertex Array Object)**: 存储缓冲区和顶点属性状态.
|
||||
- **元素缓冲对象(Element Buffer Object)**: 一个存储索引供索引化绘制使用的缓冲对象.
|
||||
- **Uniform**: 一个特殊类型的GLSL变量.它是全局的(在一个着色器程序中每一个着色器都能够访问uniform变量)并且只能被设定一次.
|
||||
- **纹理(Texture)**: 一种缠绕物体的特殊类型图片,给物体精细的视觉效果.
|
||||
- **纹理缠绕(Texture Wrapping)**: 定义了一种当纹理顶点超出范围(0,1)时指定OpenGL如何采样纹理的模式
|
||||
- **纹理过滤(Texture Filtering)**: 定义了一种当有多种纹素选择时指定OpenGL如何采样纹理的模式. 这通常在纹理被放大情况下发生.
|
||||
- **多级渐远纹理(Mipmaps)**: 被存储的材质一些的缩小版本,根据距观察者的距离会使用材质的合适大小.
|
||||
- **SOIL**: 图像加载库
|
||||
- **纹理单元(Texture Units)**: 通过绑定纹理到不同纹理单元从而允许多个纹理在同一对象上渲染.
|
||||
- **向量(Vector)**: 一个定义了在空间中方向和/或位置数学实体.
|
||||
- **矩阵(Matrix)**: 一个矩形阵列的数学表达式.
|
||||
- **GLM**: 一个为OpenGL打造的数学库.
|
||||
- **局部空间(Local Space)**: 一个对象的初始空间. 所有的坐标都是相对于对象的原点的.
|
||||
- **世界空间(World Space)**: 所有的坐标都相对于全局原点.
|
||||
- **观察空间(View Space)**: 所有的坐标都是从摄像机的视角观察的.
|
||||
- **裁剪空间(Clip Space)**: 所有的坐标都是从摄像机视角观察的,但是该空间应用了投影.这个空间应该是一个顶点坐标最终的空间,作为顶点着色器的输出. OpenGL负责处理剩下的事情(裁剪/透视划分).
|
||||
- **屏幕空间(Screen Space)**: 所有的坐标都由屏幕视角来观察. 坐标的范围是从0到屏幕的宽/高.
|
||||
- **LookAt矩阵**: 一种特殊类型的观察矩阵,它创建了一个坐标系,其中所有坐标都根据从一个位置正在观察目标的用户旋转或者平移.
|
||||
- **欧拉角(Euler Angles)**: 被定义为偏航角(yaw),俯仰角(pitch),和滚动角(roll)从而允许我们通过这三个值构造任何3D方向.
|
Reference in New Issue
Block a user