From beab7068377e93a10930d2b0f313d18f1ab8b40e Mon Sep 17 00:00:00 2001 From: Meow J Date: Sat, 8 Aug 2015 23:48:27 +0800 Subject: [PATCH] 01-08,10 --- 01 Getting started/08 Coordinate Systems.md | 206 +++++++++++--------- 01 Getting started/10 Review.md | 4 +- img/01-08-01.png | Bin 0 -> 2829 bytes 3 files changed, 111 insertions(+), 99 deletions(-) create mode 100644 img/01-08-01.png diff --git a/01 Getting started/08 Coordinate Systems.md b/01 Getting started/08 Coordinate Systems.md index a6ca2ce..e0c2109 100644 --- a/01 Getting started/08 Coordinate Systems.md +++ b/01 Getting started/08 Coordinate Systems.md @@ -1,144 +1,158 @@ -# 坐标系统(Coordinate Systems) +# 坐标系统(Coordinate Systems) + +// TODO 翻译太生硬,可能还要重写。并且可能还有漏掉的地方。 +// 简单说下问题,供再次校对的时候参考: +// 1.术语翻译不统一 +// 2.从句还保留着英语语序,翻译出来很奇怪 +// 3.To do很多未翻译出来 +// 4.某些地方很不通顺,特别是遇上连词的时候 原文 | [Coordinate Systems](http://learnopengl.com/#!Getting-started/Coordinate-Systems) ---|--- 作者 | JoeyDeVries 翻译 | linkoln -校对 | Geequlim +校对 | Geequlim, Meow J -在上一个教程中,我们学习了如何有效地利用矩阵变换来对所有顶点进行转换。OpenGL希望在所有顶点着色器运行后,所有我们可见的顶点都在一个规则化的设备范围(NDC)里面。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。我们经常做的是,通过自己定义坐标的范围并且在顶点着色器中将这些坐标转换为标准化设备坐标系(Normalized Device Coordinates,NDC)。然后将这些标准化设备坐标传入光栅器,再将他们转换为屏幕上的二维坐标或像素。 +在上一个教程中,我们学习了如何有效地利用矩阵变换来对所有顶点进行转换。OpenGL希望在所有顶点着色器运行后,所有我们可见的顶点都变为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标转换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),再将他们转换为屏幕上的二维坐标或像素。 -将坐标转换为标准化设备坐标接着转化为屏幕坐标通常是在一个流水线里面实现的,在流水线里面我们在将对象转换到屏幕坐标系之前会先将其转换到多个坐标系统。将对象的坐标转换到几个中间坐标系的优点在于,在这些特定的坐标系统中进行一些操作或运算更加方便和容易,这一点很快将会变得很明显。对我们来说比较重要的总共有5个不同的坐标系统: +将坐标转换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步,也就是类似于流水线那样子,实现的,在流水线里面我们在将对象转换到屏幕空间之前会先将其转换到多个坐标系统。将对象的坐标转换到几个过渡坐标系(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)**结束。下面的图示显示了整个流程及各个转换过程做了什么: ![coordinate_systems](http://learnopengl.com/img/getting-started/coordinate_systems.png) - - 1. 本地坐标是对象相对于本地原点的坐标;也是对象开始的坐标。 - 2. 将本地坐标转换为世界坐标,世界坐标是作为一个更大空间范围的坐标系统。这些坐标是相对于世界的原点的。 - 3. 接下来我们将世界坐标转换为视口坐标,视口坐标是指以摄像机或观察者的角度观察的坐标。 - 4. 在将坐标处理到视口空间之后,我们需要将其投影到裁剪坐标。裁剪坐标是处理-1.0到1.0范围内并判断哪些顶点将会出现在屏幕上。 - 5. 最后,我们需要将裁剪坐标转换为屏幕坐标,我们将这一过程成为视口变换。视口变换将位于-1.0到1.0范围的坐标转换到由`glViewport`函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片段。 - -你可能了解了每个单独的坐标空间的作用。我们之所以将顶点转换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当修改对象时,如果在本地空间中则是有意义的;当对对象做相对于其它对象的位置的操作时,在世界坐标系中则是有意义的;等等这些。如果我们愿意,本可以定义一个直接从本地空间到裁剪空间的转换矩阵,但那样会失去灵活性。接下来我们将要更仔细地讨论各个坐标系。 - -## 本地空间(Local spcae) - -本地空间是指对象所在的坐标空间,例如,对象最开始所在的地方。想象你在一个模型建造软件中创建了一个立方体。你创建的立方体的原点有可能位于(0,0,0),即使有可能在最后的应用中位于另外一个位置。有可能你创建的所有模型都以(0,0,0)为初始位置。则你的模型的所有顶点都是在本地空间:他们相对于你的对象来说都是本地的。 + 1. 局部坐标是对象相对于局部原点的坐标;也是对象开始的坐标。 + 2. 将局部坐标转换为世界坐标,世界坐标是作为一个更大空间范围的坐标系统。这些坐标是相对于世界的原点的。 + 3. 接下来我们将世界坐标转换为观察坐标,观察坐标是指以摄像机或观察者的角度观察的坐标。 + 4. 在将坐标处理到观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标是处理-1.0到1.0范围内并判断哪些顶点将会出现在屏幕上。 + 5. 最后,我们需要将裁剪坐标转换为屏幕坐标,我们将这一过程成为**视口变换(Viewport Transform)**。视口变换将位于-1.0到1.0范围的坐标转换到由`glViewport`函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片段。 -## 世界空间(World space) +你可能了解了每个单独的坐标空间的作用。我们之所以将顶点转换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当修改对象时,如果在局部空间中则是有意义的;当对对象做相对于其它对象的位置的操作时,在世界坐标系中则是有意义的;等等这些。如果我们愿意,本可以定义一个直接从局部空间到裁剪空间的转换矩阵,但那样会失去灵活性。接下来我们将要更仔细地讨论各个坐标系。 -如果我们想将我们所有的对象传入应用中,它们有可能会在世界的原点(0,0,0)互相重叠,而这并不是我们想见到的。我们想为每一个对象定义一个位置,以使对象位于更大的世界坐标系中。世界空间中的坐标就如它们听起来那样:是指顶点相对于(虚拟)世界的坐标。世界坐标系是将分散的各个对象转换到同一个坐标系中(或者一个更实际的方式)。对象的坐标将会从本地坐标转换到世界坐标;该转换是由模型矩阵实现的。模型矩阵是一种转换矩阵,它能通过对对象进行平移、缩放、旋转来将它置于它本应该在的位置或方向。你可以想象一下,转换一栋房子,通过将它缩小(因为它在本地坐标系中显得太大了),将它往郊区的方向平移且沿着y轴往坐标旋转,经过这样的变换之后,它将恰好能够与邻居的房子重合。你能够想到上一节讲到的利用模型矩阵将各个容器放置到这个屏幕上;我们能够将容器中的本地坐标转换为视口坐标或世界坐标。 +### 局部空间(Local Space) -## 观察空间(View space) +局部空间是指对象所在的坐标空间,例如,对象最开始所在的地方。想象你在一个模型建造软件(比如说Blender)中创建了一个立方体。你创建的立方体的原点有可能位于(0,0,0),即使有可能在最后的应用中位于完全不同的另外一个位置。甚至有可能你创建的所有模型都以(0,0,0)为初始位置,然而他们会在世界的不同位置。则你的模型的所有顶点都是在**局部**空间:他们相对于你的对象来说都是局部的。 -观察空间经常被人们称之OpenGL的摄像机(所以有时也称为摄像机空间或视觉空间)。观察空间就是将对象的世界空间的坐标转换为观察者视野前面的坐标。因此观察空间就是从摄像机的角度观察到的空间。而这通常是由一系列的平移和旋转的组合来平移和旋转场景以使特定的对象被转换到摄像机前面。这些组合在一起的转换通常存储在一个视图矩阵用来将世界坐标转换到观察空间。在下一个教程我们将广泛讨论如何创建一个这样的视图矩阵来模拟一个摄像机。 +我们一直使用的那个箱子的坐标范围为-0.5到0.5,设定(0, 0)为它的原点。这些都是局部坐标。 -## 裁剪空间(Clip space) -在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个给定的范围内,且任何在这个范围之外的点都应该被裁剪。被裁剪的坐标就变成无效的,所以剩下的坐标就将变为屏幕上可见的片段。这也是裁剪空间名字的由来。 +### 世界空间(World Space) -因为将所有可见的坐标都放置在-1.0到1.0的范围内不是很直观,我们会指定自己的坐标集的方式并将它转换回规则化设备坐标系就像OpenGL期望它做的那样。 +如果我们想将我们所有的对象导入到程序当中,它们有可能会全挤在世界的原点上(0,0,0),然而这并不是我们想要的结果。我们想为每一个对象定义一个位置,从而使对象位于更大的世界当中。世界空间中的坐标就如它们听起来那样:是指顶点相对于(游戏)世界的坐标。物体变换到的最终空间就是世界坐标系,并且你会想让这些物体分散开来摆放(从而显得更真实)。对象的坐标将会从局部坐标转换到世界坐标;该转换是由**模型矩阵(Model Matrix)**实现的。 -为了将顶点坐标从观察空间转换到裁剪空间,我们定义一个投影矩阵,它指定了坐标的范围,例如,每个维度都是从-1000到1000。投影矩阵接着会将在它指定的范围内的坐标转换到规则化设备坐标系中(-1.0,1.0)。所有在范围外的坐标在-1.0到1.0之间都不会被绘制出来且会被裁剪。在投影矩阵所指定的范围内,坐标(1250,500,750)将是不可见的,这是由于它的x坐标超出了范围,随后被转化为在规则化设备坐标中坐标值大于1.0的值并且被裁剪掉。 +模型矩阵是一种转换矩阵,它能通过对对象进行平移、缩放、旋转来将它置于它本应该在的位置或方向。你可以想象一下,我们需要转换一栋房子,通过将它缩小(因为它在局部坐标系中显得太大了),将它往郊区的方向平移,然后沿着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 - 如果只是图元的一部分例如三角形,超出了裁剪区域,则OpenGL会重新构建三角形以使一个或多个三角形能适应在裁剪范围内。 + 如果只是片段的一部分例如三角形,超出了裁剪体积(Clipping Volume),则OpenGL会重新构建三角形以使一个或多个三角形能适应在裁剪范围内。 -由投影矩阵创建的观察区域被称为平截头体(Frustum),且每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将坐标转化到指定的规则化设备坐标系,它可以很容易被映射到2维观察空间的坐标系,这个过程被称之为投影,使用投影矩阵能将3维坐标投影到标准化设备坐标系中。 +由投影矩阵创建的**观察区域(Viewing Box)**被称为**平截头体(Frustum)**,且每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将一定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为**投影(Projection)**,因为使用投影矩阵能将3维坐标**投影(Project)**到很容易映射的2D标准化设备坐标系中。 -一旦所有顶点被转换到裁剪空间,一个最终操作——投射除法将会执行,在于我们将位置向量的x,y,z分量分别除以向量的均匀w分量;投射除法是将4维裁剪空间坐标转换为3维规则化设备坐标系。这一步会在每一个顶点着色器运行的最后被自动执行。 -在这一阶段之后,坐标经过转换的结果将会被映射到屏幕坐标系(由`glViewport`设置)且被转变成片元。 +一旦所有顶点被转换到裁剪空间,最终的操作——**透视划分(Perspective Division)**将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视划分是将4维裁剪空间坐标转换为3维标准化设备坐标。这一步会在每一个顶点着色器运行的最后被自动执行。 -投影矩阵将观察坐标转换为裁剪坐标的过程采用两种不同的方式,每种方式分别定义自己的平截头体。我们可以创建一个正视投影矩阵或一个透视投影矩阵。 +在这一阶段之后,坐标经过转换的结果将会被映射到屏幕空间(由`glViewport`设置)且被转换成片段。 -## 正视投影(Orthographic projection) +投影矩阵将观察坐标转换为裁剪坐标的过程采用两种不同的方式,每种方式分别定义自己的平截头体。我们可以创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)。 -正视投影矩阵定义了一个类似立方体的平截头体,指定了一个裁剪空间,每一个在这空间外面的顶点都会被裁剪。创建一个正视投影矩阵需要指定可见平截头体的宽、高和长度。所有在使用正视投影矩阵转换到裁剪空间后如果还处于这个平截头体里面的坐标就不会被裁剪。它的平截头体看起来像一个容器: +#### 正射投影(Orthographic Projection) -![orthographic projection frustum ](http://learnopengl.com/img/getting-started/orthographic_frustum.png) +正射投影矩阵定义了一个类似立方体的平截头体,指定了一个裁剪空间,每一个在这空间外面的顶点都会被裁剪。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。所有在使用正射投影矩阵转换到裁剪空间后如果还处于这个平截头体里面的坐标就不会被裁剪。它的平截头体看起来像一个容器: -上面的平截头体定义了由宽、高、近平面和远平面决定的可视的坐标系。任何出现在近平面前面或远平面后面的坐标都会被裁剪掉。正视平截头体直接将平截头体内部的顶点映射到规则化设备坐标系中,因为每个向量的w分量都是不变的;如果w分量等于1.0,则透视除法不会改变坐标的值。 +![orthographic projection frustum](http://learnopengl.com/img/getting-started/orthographic_frustum.png) -为了创建一个正视投影矩阵,我们利用GLM的构建函数`glm::ortho`: +上面的平截头体定义了由宽、高、**近**平面和**远**平面决定的可视的坐标系。任何出现在近平面前面或远平面后面的坐标都会被裁剪掉。正视平截头体直接将平截头体内部的顶点映射到标准化设备坐标系中,因为每个向量的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范围之间的坐标转换到规则化设备坐标系中。 +前两个参数指定了平截头体的左右坐标,第三和第四参数指定了平截头体的底部和上部。通过这四个参数我们定义了近平面和远平面的大小,然后第五和第六个参数则定义了近平面和远平面的距离。这个指定的投影矩阵将处于这些x,y,z范围之间的坐标转换到标准化设备坐标系中。 -正视投影矩阵直接将坐标映射到屏幕的二维平面内,但实际上一个直接的投影矩阵将会产生不真实的结果,如果这个投影不把透视考虑进去的话。这就是透视投影矩阵为我们解决的。 +正射投影矩阵直接将坐标映射到屏幕的二维平面内,但实际上一个直接的投影矩阵将会产生不真实的结果,因为这个投影没有将**透视(Perspective)**考虑进去。所以我们需要**透视投影**矩阵来解决这个问题。 -## 透视投影(Perspective projection) +#### 透视投影(Perspective Projection) -如果你曾经体验过实际生活给你带来的景象,你就会注意到离你越远的东西看起来更小。这个神奇的效果我们称之为透视,它是因为我们是从两个视口看世界的:分别从两只眼睛。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样: +如果你曾经体验过**实际生活**给你带来的景象,你就会注意到离你越远的东西看起来更小。这个神奇的效果我们称之为透视。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样: ![perspective](http://learnopengl.com/img/getting-started/perspective.png) -正如你看到的那样,由于透视的原因,平行线似乎在很远的地方看起来会相交。这个投影矩阵将给定的平截头体范围映射到裁剪空间,但每个顶点坐标仍然产生了w值,顶点如果离观察者越远,则w分量越大。被转换到裁剪控件的坐标都会在-w到w的范围之间(任何处于这个范围的对象都会被裁剪)。OpenGL需要可见的坐标都落在-1.0到1.0范围内作为最后的顶点着色器输出,因此一旦坐标在裁剪空间内,透视划分就会被应用到裁剪空间坐标内: +正如你看到的那样,由于透视的原因,平行线似乎在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。这个投影矩阵不仅将给定的平截头体范围映射到裁剪空间,同样还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被转换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的对象都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内从而作为最后的顶点着色器输出,因此一旦坐标在裁剪空间内,透视划分就会被应用到裁剪空间坐标: -![](http://learnopengl.com/img/getting-started/perspective_frustum.png) +![](../img/01-08-01.png) -每个顶点坐标的分量都会除以它的w分量,得到一个距离观察者的较小的顶点坐标。这是另一个w分量很重要的原因,因为它帮助我们进行透射投影。最后的结果坐标就是处于标准化设备空间内的。如果你对研究正视投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧)我推荐[这篇由Songho写的文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)。 +每个顶点坐标的分量都会除以它的w分量,得到一个距离观察者的较小的顶点坐标。这是也是另一个w分量很重要的原因,因为它能够帮助我们进行透射投影。最后的结果坐标就是处于标准化设备空间内的。如果你对研究正射投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧的话)我推荐[这篇由Songho写的文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)。 -一个透视投影矩阵可以在GLM中被创建,如下: +在GLM中可以这样创建一个透视投影矩阵: ```c++ glm::mat4 proj = glm::perspective(45.0f, (float)width/(float)height, 0.1f, 100.0f); ``` -`glm::perspective`所做的其实就是再次创建了一个定义了可视空间的大的平截头体,任何在这个平截头体的对象最后都不会出现在裁剪空间体积内并且将会被裁剪。一个透视平截头体可以被可视化为一个不均匀形状的盒子,在这个盒子内部的每个坐标都会被映射到裁剪空间的点。一张透视平截头体的照片如下所示: +`glm::perspective`所做的其实就是再次创建了一个定义了可视空间的大的**平截头体**,任何在这个平截头体的对象最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被可视化为一个不均匀形状的盒子,在这个盒子内部的每个坐标都会被映射到裁剪空间的点。一张透视平截头体的照片如下所示: + ![ perspective_frustum](http://learnopengl.com/img/getting-started/perspective_frustum.png) -它的第一个参数定义了fov的值,代表了视界范围,且设置了视图空间有多大。对于一个真实的视图,它的值经常设置为45.0,但想要看到更多结果你可以设置一个更大的值。第二个参数设置了宽高比,由视图的高除以宽。第三和第四个参数设置了平截头体的近和远平面。我们经常设置近距离为0.1而远距离设为100.0。所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染。 +它的第一个参数定义了**fov**的值,它表示的是**视野(Field of View)**,并且设置了观察空间的大小。对于一个真实的观察效果,它的值经常设置为45.0,但想要看到更多结果你可以设置一个更大的值。第二个参数设置了宽高比,由视口的高除以宽。第三和第四个参数设置了平截头体的近和远平面。我们经常设置近距离为0.1而远距离设为100.0。所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染。 !!! Important - 当你把透视矩阵的`near`值设置太大时(如10.0),OpenGL会将靠近摄像机的坐标都裁剪掉(在0.0和10.0之间),这提供了一个相似的视觉效果就如视频游戏中如果你移动得太靠近某个对象时你会把它看穿那样。 + 当你把透视矩阵的*near*值设置太大时(如10.0),OpenGL会将靠近摄像机的坐标都裁剪掉(在0.0和10.0之间),这会导致一个你很熟悉的视觉效果:在太过靠近一个物体的时候视线会直接穿过去。 -当使用正视投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何透视划分(它仍然有进行透视划分,只是w分量没有被操作(它保持为1)因此没有起作用)。因为正视投影没有使用透视,远处的对象不会显得小以产生神奇的视觉输出。由于这个原因,正视投影主要用于二维渲染以及一些建筑或工程的应用,那些我们不需要使用投影来变形顶点的情况下。某些应用如Blender是用来进行三维建模的,有时在建模时会使用正视投影,因为它更准确地藐视了每个对象的各个方面。下面你能够看到在Blender里面使用两种投影方式的对比: +当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视划分(它仍然有进行透视划分,只是w分量没有被操作(它保持为1)因此没有起作用)。因为正射投影没有使用透视,远处的对象不会显得小以产生神奇的视觉输出。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的应用,或者是那些我们不需要使用投影来转换顶点的情况下。某些如Blender的进行三维建模的软件有时在建模时会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。下面你能够看到在Blender里面使用两种投影方式的对比: ![perspective_orthographic](http://learnopengl.com/img/getting-started/perspective_orthographic.png) -你可以看到使用透视投影的话,远处的顶点看起来比较小,而在正视投影中每个顶点距离观察者的距离都是一样的。 +你可以看到使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。 -### 组合 +### 把它们都组合到一起 -我们为上述的每一个步骤都创建了一个转换矩阵:模型矩阵、视图矩阵和投影矩阵。一个顶点的坐标将会被转换到裁剪坐标,如下: +我们为上述的每一个步骤都创建了一个转换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点的坐标将会根据以下过程被转换到裁剪坐标: ![](../img/coordinate_system_2.png) -注意每个矩阵被运算的顺序是相反的(记住我们需要从右往左乘上每个矩阵)。最后的顶点应该被赋予顶点着色器中的`gl_Position`且OpenGL将会自动进行透视划分和裁剪。 +注意每个矩阵被运算的顺序是相反的(记住我们需要从右往左乘上每个矩阵)。最后的顶点应该被赋予顶点着色器中的`gl_Position`且OpenGL将会自动进行透视划分和裁剪。 !!! Important **然后呢?** - 顶点着色器的输出需要所有的顶点都在裁剪空间内,而这是我们的转换矩阵所做的。OpenGL然后在裁剪空间中执行透视划分从而将它们转换到规则化设备坐标。OpenGL会使用`glViewPort`内部的参数来将规则化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中屏幕是800 *600)。这个过程称为视口转换。 + 顶点着色器的输出需要所有的顶点都在裁剪空间内,而这是我们的转换矩阵所做的。OpenGL然后在裁剪空间中执行透视划分从而将它们转换到标准化设备坐标。OpenGL会使用`glViewPort`内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中屏幕是800 *600)。这个过程称为视口转换。 这一章的主题可能会比较难理解,如果你仍然不确定每个空间的作用的话,你也不必太担心。接下来你会看到我们是怎样好好运用这些坐标空间的并且会有足够的展示例子在接下来的教程中。 ## 进入三维 -既然我们知道了如何将三维坐标转换为二维坐标,我们可以开始将我们的对象展示为三维对象而不是目前我们所展示的差劲的二维图像。 +既然我们知道了如何将三维坐标转换为二维坐标,我们可以开始将我们的对象展示为三维对象而不是目前我们所展示的缺胳膊少腿的二维平面。 -在开始进行三维画图时,我们首先创建一个模型矩阵。这个模型矩阵包含了平移、缩放与旋转,我们将会运用来将对象的顶点转换到全局世界空间。让我们平移一下我们的平面,通过将其绕着x轴旋转使它看起来像位于地板上一样。这个模型矩阵看起来是这样的: +在开始进行三维画图时,我们首先创建一个模型矩阵。这个模型矩阵包含了平移、缩放与旋转,我们将会运用它来将对象的顶点转换到全局世界空间。让我们平移一下我们的平面,通过将其绕着x轴旋转使它看起来像放在地上一样。这个模型矩阵看起来是这样的: ```c++ glm::mat4 model; @@ -147,15 +161,15 @@ model = glm::rotate(model, -55.0f, glm::vec3(1.0f, 0.0f, 0.0f)); 通过将顶点坐标乘以这个模型矩阵我们将该顶点坐标转换到世界坐标。我们的平面看起来就是在地板上的因此可以代表真实世界的平面。 -接下来我们需要创建一个视图矩阵。我们想要在场景里面稍微往后移动以使得对象变成可见的(当在世界空间时,我们位于原点(0,0,0))。在场景里面移动,思考下面的问题: +接下来我们需要创建一个观察矩阵。我们想要在场景里面稍微往后移动以使得对象变成可见的(当在世界空间时,我们位于原点(0,0,0))。要想在场景里面移动,思考下面的问题: -将摄像机往后移动跟将整个场景往前移是一样的。 +- 将摄像机往后移动跟将整个场景往前移是一样的。 -这就是视图空间所做的,我们移动整个场景正如我们相反地想让摄像机所做的那样。因为我们想要往后移动且OpenGL是一个右手坐标系所以我们沿着z轴的负方向移动。我们实现这个,通过将场景沿着z轴正方向平移。这个我们的感觉就是我们在往后移动。 +这就是观察空间所做的,我们以相反于移动摄像机的方向移动整个场景。因为我们想要往后移动,并且OpenGL是一个右手坐标系(Right-handed System)所以我们沿着z轴的负方向移动。我们会通过将场景沿着z轴正方向平移来实现这个。它会给我们一种我们在往后移动的感觉。 !!! Important - **右手坐标系** + **右手坐标系(Right-handed System)** 按照约定,OpenGL是一个右手坐标系。最基本的就是说正x轴在你的右手边,正y轴往上而正z轴是往后的。想象你的屏幕处于三个轴的中心且正z轴穿过你的屏幕朝向你。坐标系画起来如下: @@ -168,17 +182,17 @@ model = glm::rotate(model, -55.0f, glm::vec3(1.0f, 0.0f, 0.0f)); - 使你的食指往上。 - 向下90度弯曲你的中指。 -如果你都正确地做了,那么你的大拇指朝着正x轴方向,食指朝着正y轴方向,中指朝着正z轴方向。如果你用左手来做这些动作,你会发现z轴的方向是相反的。这就是有名的左手坐标系,被DirectX广泛地使用。注意在标准化设备坐标系中OpenGL使用的是左手坐标系(投影矩阵转换了使用右手或左手的习惯)。 +如果你都正确地做了,那么你的大拇指朝着正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; @@ -187,7 +201,7 @@ projection = glm::perspective(45.0f, screenWidth / screenHeight, 0.1f, 100.0f); !!! Attention - 再重复一次,在glm指定角度的时候要注意。这里我们将参数fov设置为45度,但有些GLM的实现是将fov当成弧度,在这种情况你需要使用`glm::radians(45.0)`来设置。 + 再重复一遍,在glm指定角度的时候要注意。这里我们将参数fov设置为45度,但有些GLM的实现是将fov当成弧度,在这种情况你需要使用`glm::radians(45.0)`来设置。 既然我们创建了转换矩阵,我们应该将它们传入着色器。首先,让我们在顶点着色器中声明一个单位转换矩阵然后将它乘以顶点坐标: @@ -201,35 +215,35 @@ 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)); -... // 视图矩阵和投影矩阵与之类似 +... // 观察矩阵和投影矩阵与之类似 ``` -现在我们的顶点坐标通过模型,视图和投影矩阵来转换,最后的对象应该是: +现在我们的顶点坐标通过模型、观察和投影矩阵来转换,最后的对象应该是: - 往后向地板倾斜。 - 离我们有点距离。 - - 由透视展示(顶点越远,变得越小)。 + - 由透视展示(顶点越远,变得越小)。 让我们检查一下结果是否满足这些要求: ![coordinate_systems_result](http://learnopengl.com/img/getting-started/coordinate_systems_result.png) -它看起来就像是一个三维的平面,是静止在一些虚构的地板上的。如果你不是得到相同的结果,请检查下完整的[源代码](http://learnopengl.com/code_viewer.php?code=getting-started/cube_vertices) 以及[顶点](http://learnopengl.com/code_viewer.php?code=getting-started/transform&type=vertex) 和[片段](http://learnopengl.com/code_viewer.php?code=getting-started/transform&type=fragment)着色器。 +它看起来就像是一个三维的平面,是静止在一些虚构的地板上的。如果你不是得到相同的结果,请检查下完整的[源代码](http://learnopengl.com/code_viewer.php?code=getting-started/cube_vertices) 以及[顶点](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个面 \* 每个面有2个三角形组成 \* 每个三角形有3个顶点),这36个顶点的位置你可以[从这里获取](http://learnopengl.com/code_viewer.php?code=getting-started/cube_vertices)。注意,这一次我们省略了颜色值,因为这次我们只在乎顶点的位置和,我们使用纹理贴图。 +到目前为止,我们在二维平面甚至在三维空间中画图,所以让我们采取大胆的方式来将我们的二维平面扩展为三维立方体。要渲染一个立方体,我们一共需要36个顶点(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点),这36个顶点的位置你可以[从这里获取](http://learnopengl.com/code_viewer.php?code=getting-started/cube_vertices)。注意,这一次我们省略了颜色值,因为这次我们只在乎顶点的位置和,我们使用纹理贴图。 为了好玩,我们将让立方体随着时间旋转: @@ -243,18 +257,17 @@ model = glm::rotate(model, (GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.5f, 1.0f, glDrawArrays(GL_TRIANGLES, 0, 36); ``` -如果一切顺利对的话绘制效果将于下面的类似: +如果一切顺利的话绘制效果将与下面的类似: -这有点像一个立方体,但有些东西不是。立方体的某些面是通过其它面绘制的。之所以这样是因为OpenGL是通过画一个一个三角形来画你的立方体的,所以它将会覆盖之前已经画在那里的像素。因为这个原因,有些三角形会画在其它三角形上面,虽然它们本不应该是被覆盖的。 +这有点像一个立方体,但又有种说不出的奇怪。立方体的某些面是通过其它面绘制的。之所以这样是因为OpenGL是通过画一个一个三角形来画你的立方体的,所以它将会覆盖之前已经画在那里的像素。因为这个原因,有些三角形会画在其它三角形上面,虽然它们本不应该是被覆盖的。 -幸运的是,OpenGL存储的深度信息在缓冲区里面成为z缓冲区,它允许OpenGL决定何时覆盖一个像素何时不覆盖。通过使用z缓冲区我们可以设置OpenGL来进行深度测试。 +幸运的是,OpenGL存储深度信息在z缓冲区(Z-buffer)里面,它允许OpenGL决定何时覆盖一个像素何时不覆盖。通过使用z缓冲区我们可以设置OpenGL来进行深度测试。 -## z缓冲区 +### z缓冲区 - -OpenGL存储它的所有深度信息于z缓冲区中,也被成为深度缓冲区。GLFW自动为你生成这样一个缓冲区 (就如它有一个颜色缓冲区来存储输出图像的颜色)。深度存储在每个片段里面(作为片段的z值)当片段像输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较然后如果当前的片段在其它片段之后它将会被丢弃,然后重写。这个过程称为深度测试且是由OpenGL自动完成的。 +OpenGL存储它的所有深度信息于z缓冲区中,也被称为深度缓冲区(Depth Buffer)。GLFW会自动为你生成这样一个缓冲区 (就如它有一个颜色缓冲区来存储输出图像的颜色)。深度存储在每个片段里面(作为片段的z值)当片段像输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较然后如果当前的片段在其它片段之后它将会被丢弃,然后重写。这个过程称为**深度测试(Depth Testing)**并且它是由OpenGL自动完成的。 然而,如果我们想要确定OpenGL是否真的执行深度测试,首先我们要告诉OpenGL我们想要开启深度测试;而这通常是默认关闭的。我们通过`glEnable`函数来开启深度测试。`glEnable`和`glDisable`函数允许我们开启或关闭某一个OpenGL的功能。该功能会一直是开启或关闭的状态直到另一个调用来关闭或开启它。现在我们想开启深度测试就需要开启`GL_DEPTH_TEST`: @@ -262,21 +275,21 @@ OpenGL存储它的所有深度信息于z缓冲区中,也被成为深度缓冲 glEnable(GL_DEPTH_TEST); ``` -既然我们使用了深度测试我们也想要在每次重复渲染之前清除深度缓冲区(否则前一个片段的深度信息仍然保存在缓冲区中)。就像清除颜色缓冲区一样,我们可以通过在`glclear`函数中指定`DEPTH_BUFFER_BIT`位来清除深度缓冲区: +既然我们使用了深度测试我们也想要在每次重复渲染之前清除深度缓冲区(否则前一个片段的深度信息仍然保存在缓冲区中)。就像清除颜色缓冲区一样,我们可以通过在`glclear`函数中指定`DEPTH_BUFFER_BIT`位来清除深度缓冲区: ```c++ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); ``` -接着运行你的程序,你将会看到一个开启了深度测试且各个面都是纹理的立方体在旋转。 +我们来重新运行下程序看看OpenGL是否执行了深度测试: -检查代码请[点击](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems_with_depth)。 +就是这样!一个开启了深度测试,各个面都是纹理,并且还在旋转的立方体!如果你的程序有问题可以到[这里](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems_with_depth)下载源码进行比对。 -### 更多立方体 +### 更多的立方体 -现在我们想在屏幕上显示10个立方体的话。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的顶点已经定义好了,所以我们不用改变我们的缓冲数组及属性数组当渲染更多对象的时候,我们唯一需要做的是改变每个对象的模型矩阵来将立方体转换到世界坐标系中。 +现在我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的图形布局已经定义好了,所以当渲染更多物体的时候我们不需要改变我们的缓冲数组和属性数组,我们唯一需要做的只是改变每个对象的模型矩阵来将立方体转换到世界坐标系中。 首先,让我们为每个立方体定义一个转换向量来指定它在世界空间的位置。我们将要在`glm::vec3`数组中定义10个立方体位置向量。 @@ -295,7 +308,7 @@ glm::vec3 cubePositions[] = { }; ``` -现在,在循环中,我们调用`glDrawArrays`10次,在我们开始渲染之前每次传入一个不同的模型矩阵到顶点着色器中。我们将会创建一个小的循环来通过一个不同的模型矩阵重复渲染我们的对象10次。注意我们也传入了一个旋转参数到每个容器中: +现在,在循环中,我们调用`glDrawArrays`10次,在我们开始渲染之前每次传入一个不同的模型矩阵到顶点着色器中。我们将会创建一个小的循环来通过一个不同的模型矩阵重复渲染我们的对象10次。注意我们也传入了一个旋转参数到每个箱子中: ```c++ glBindVertexArray(VAO); @@ -312,16 +325,15 @@ for(GLuint i = 0; i < 10; i++) glBindVertexArray(0); ``` -这个代码将会每次都更新模型矩阵然后画出新的立方体,如此总共重复10次。然后我们应该就能看到一个填充着10个古怪的旋转立方体的世界。 +这个代码将会每次都更新模型矩阵然后画出新的立方体,如此总共重复10次。然后我们应该就能看到一个拥有10个正在奇葩旋转着的立方体的世界。 ![coordinate_systems_multiple_objects](http://learnopengl.com/img/getting-started/coordinate_systems_multiple_objects.png) -完美!这就像我们的容器找到了志同道合的小伙伴一样。如果你在这里卡住了,你可以检查一下[代码](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) 。 +完美!这就像我们的箱子找到了志同道合的小伙伴一样。如果你在这里卡住了,你可以对照一下[代码](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)。 +- 对GLM的投影函数中的`FoV`和`aspect-ratio`参数进行试验。看能否搞懂它们是如何影响透视平截头体的。 +- 将观察矩阵在各个方向上进行平移,来看看场景是如何改变的。注意把观察矩阵当成摄像机对象。 +- 只使用模型矩阵每次只让3个箱子旋转(包括第1个)而让剩下的箱子保持静止。[参考解答](http://learnopengl.com/code_viewer.php?code=getting-started/coordinate_systems-exercise3)。 diff --git a/01 Getting started/10 Review.md b/01 Getting started/10 Review.md index 060c8d4..1ca025f 100644 --- a/01 Getting started/10 Review.md +++ b/01 Getting started/10 Review.md @@ -15,10 +15,10 @@ - **OpenGL**: 一个定义了函数布局和输出的图形API的正式规范. - **GLEW**: 一个拓展加载库用来为我们加载并设定所有OpenGL函数指针从而让我们能够使用所有(现代)OpenGL函数. -- **视区(Viewport)**: 我们需要渲染的窗口. +- **视口(Viewport)**: 我们需要渲染的窗口. - **图形管道(Graphics Pipeline)**: 一个顶点在呈现为像素之前通过的过程. - **着色器(Shader)**: 一个运行在显卡上的小型程序.很多阶段的图形管道都可以使用自定义的着色器来代替原来的功能. -- **规格化设备坐标(Normalized Device Coordinates)**: 顶点在通过在剪裁坐标系中剪裁与透视划分后最终呈现在的坐标系. 所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见. +- **标准化设备坐标(Normalized Device Coordinates)**: 顶点在通过在剪裁坐标系中剪裁与透视划分后最终呈现在的坐标系. 所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见. - **顶点缓冲对象(Vertex Buffer Object)**: 一个调用显存并存储所有顶点数据供显卡使用的缓冲对象. - **顶点数组对象(Vertex Array Object)**: 存储缓冲区和顶点属性状态. - **元素缓冲对象(Element Buffer Object)**: 一个存储索引供索引化绘制使用的缓冲对象. diff --git a/img/01-08-01.png b/img/01-08-01.png new file mode 100644 index 0000000000000000000000000000000000000000..6f39b430b141fac493ec4014eed7bf577fd0de80 GIT binary patch literal 2829 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 BGXnqs literal 0 HcmV?d00001