1
0
mirror of https://github.com/LearnOpenGL-CN/LearnOpenGL-CN.git synced 2025-08-23 04:35:28 +08:00

Merge pull request #4 from LearnOpenGL-CN/new-theme

update with master
This commit is contained in:
Aillieo
2017-07-20 20:15:10 +08:00
committed by GitHub
47 changed files with 1375 additions and 1301 deletions

View File

@@ -2,15 +2,16 @@
[![Build Status](https://travis-ci.org/LearnOpenGL-CN/LearnOpenGL-CN.svg?branch=new-theme)](https://travis-ci.org/LearnOpenGL-CN/LearnOpenGL-CN)
learnopengl.com系列教程的中文翻译目前正在翻译中。
learnopengl.com系列教程的中文翻译目前正在校对及翻译中。
**英文原版**[learnopengl.com](http://learnopengl.com/)
**英文原版**[learnopengl.com](https://learnopengl.com/)
**目前状态**
- 原文大部分代码都有改变(使用的新的库),需要从头开始重新校对
- PBL 章节和 In Practice 章节下还有几篇教程没有翻译
- 1-8节之后都没有按照新版的格式来排版需要进行整体修改
- 原文大部分代码都有改变(使用的新的库),需要从头开始重新校对Meow J正在处理中最重要的配置部分已经更新完毕
- 2-4节之后都没有按照新版的格式来排版而且错误极多也没有统一译名需要进行整体的修改Meow J正在处理中可能比较漫长
- 从头校对整体修改之后的文章(志愿者希望)
- PBL 章节和 In Practice 章节下还有几篇教程没有翻译(志愿者希望)
## 阅读地址
@@ -50,8 +51,14 @@ learnopengl.com系列教程的中文翻译目前正在翻译中。
05 Advanced Lighting/03 Shadows/02 Point Shadows.md
```
**翻译之前请先阅读[样式指南](https://github.com/LearnOpenGL-CN/LearnOpenGL-CN/blob/new-theme/styleguide.md)**
之后请联系我们将您加入LearnOpenGL-CN组织然后提交并Push您的翻译。或者您也可以Fork这个工程在本地编辑之后发送Pull Request。
## 样式指南
在文档的写作过程中,请遵守我们的[样式指南](https://github.com/LearnOpenGL-CN/LearnOpenGL-CN/blob/new-theme/styleguide.md)方便之后的校对以及修改工作。
## 构建
首先请安装Python2和3都可以之后初始化环境
@@ -79,10 +86,6 @@ $ mkdocs serve
如果您发现教程有任何错误的话欢迎Fork这个工程并发送Pull Request到 `new-theme` 分支。如果您不想修改的话,可以点击页面上方的 `Issues` 按钮提交一个Issue我们看到后会及时更正。如果是对教程的内容有问题请先查看原文如果不是翻译错误的话请直接在原网站评论区向作者JoeyDeVries反馈。
## 样式指南
在文档的写作过程中,请遵守我们的[样式指南](https://github.com/LearnOpenGL-CN/LearnOpenGL-CN/blob/new-theme/styleguide.md)方便之后的校对以及修改工作。
## 联系方式
QQ群383745868

View File

@@ -3,8 +3,8 @@
原文 | [OpenGL](http://learnopengl.com/#!Getting-started/OpenGL)
---|---
作者 | JoeyDeVries
翻译 | gjy_1992
校对 | Geequlim
翻译 | gjy_1992, Meow J
校对 | 暂未校对
在开始这段旅程之前我们先了解一下OpenGL到底是什么。一般它被认为是一个API(<def>Application Programming Interface</def>, 应用程序编程接口)包含了一系列可以操作图形、图像的函数。然而OpenGL本身并不是一个API它仅仅是一个由[Khronos组织](http://www.khronos.org/)制定并维护的规范(Specification)。

View File

@@ -3,8 +3,8 @@
原文 | [Creating a window](http://learnopengl.com/#!Getting-started/Creating-a-window)
---|---
作者 | JoeyDeVries
翻译 | gjy_1992
校对 | 未校对
翻译 | gjy_1992, Meow J
校对 | 未校对
!!! note "译注"

View File

@@ -3,8 +3,8 @@
原文 | [Hello Window](http://learnopengl.com/#!Getting-started/Hello-Window)
---|---
作者 | JoeyDeVries
翻译 | Geequlim
校对 | 未校对
翻译 | Geequlim, Meow J
校对 | 未校对
让我们试试能不能让GLFW正常工作。首先新建一个`.cpp`文件,然后把下面的代码粘贴到该文件的最前面。

View File

@@ -3,8 +3,8 @@
原文 | [Hello Triangle](http://www.learnopengl.com/#!Getting-started/Hello-Triangle)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | Geequlim
翻译 | [Django](http://bullteacher.com/), Meow J, Geequlim
校对 | 暂未校对
!!! note "译注"

View File

@@ -3,8 +3,8 @@
原文 | [Shaders](http://learnopengl.com/#!Getting-started/Shaders)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | Geequlim
翻译 | [Django](http://bullteacher.com/), Meow J, Geequlim
校对 | 暂未校对
在[Hello Triangle](04 Hello Triangle.md)教程中提到,着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序因为它们之间不能相互通信它们之间唯一的沟通只有通过输入和输出。

View File

@@ -3,8 +3,8 @@
原文 | [Textures](http://learnopengl.com/#!Getting-started/Textures)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | Geequlim, [BLumia](https://github.com/blumia/)
翻译 | [Django](http://bullteacher.com/), Meow J, Geequlim, [BLumia](https://github.com/blumia/)
校对 | 暂未校对
!!! note "译注"
@@ -46,7 +46,7 @@ float texCoords[] = {
纹理坐标的范围通常是从(0, 0)到(1, 1)那如果我们把纹理坐标设置在范围之外会发生什么OpenGL默认的行为是重复这个纹理图像我们基本上忽略浮点纹理坐标的整数部分但OpenGL提供了更多的选择
环绕方式(Wrapping) | 描述
环绕方式 | 描述
---|---
<var>GL_REPEAT</var> | 对纹理的默认行为。重复纹理图像。
<var>GL_MIRRORED_REPEAT</var> | 和<var>GL_REPEAT</var>一样,但每次重复图片是镜像放置的。
@@ -64,7 +64,7 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
```
第一个参数指定了纹理目标我们使用的是2D纹理因此纹理目标是<var>GL_TEXTURE_2D</var>。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是`WRAP`选项,并且指定`S`和`T`轴。最后一个参数需要我们传递一个环绕方式在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为<var>GL_MIRRORED_REPEAT</var>。
第一个参数指定了纹理目标我们使用的是2D纹理因此纹理目标是<var>GL_TEXTURE_2D</var>。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是`WRAP`选项,并且指定`S`和`T`轴。最后一个参数需要我们传递一个环绕方式(Wrapping)在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为<var>GL_MIRRORED_REPEAT</var>。
如果我们选择<var>GL_CLAMP_TO_BORDER</var>选项,我们还需要指定一个边缘的颜色。这需要使用<fun>glTexParameter</fun>函数的`fv`后缀形式,用<var>GL_TEXTURE_BORDER_COLOR</var>作为它的选项并且传递一个float数组作为边缘的颜色值

View File

@@ -3,8 +3,8 @@
原文 | [Transformations](http://learnopengl.com/#!Getting-started/Transformations)
---|---
作者 | JoeyDeVries
翻译 | Django
校对 | Meow J, [BLumia](https://github.com/blumia/)
翻译 | Django, Meow J, [BLumia](https://github.com/blumia/)
校对 | 暂未校对
尽管我们现在已经知道了如何创建一个物体、着色、加入纹理,给它们一些细节的表现,但因为它们都还是静态的物体,仍是不够有趣。我们可以尝试着在每一帧改变物体的顶点并且重配置缓冲区从而使它们移动,但这太繁琐了,而且会消耗很多的处理时间。我们现在有一个更好的解决方案,使用(多个)<def>矩阵</def>(Matrix)对象可以更好的<def>变换</def>(Transform)一个物体。当然这并不是说我们会去讨论武术和数字虚拟世界译注Matrix同样也是电影「黑客帝国」的英文名电影中人类生活在数字虚拟世界主角会武术

View File

@@ -3,8 +3,8 @@
原文 | [Coordinate Systems](http://learnopengl.com/#!Getting-started/Coordinate-Systems)
---|---
作者 | JoeyDeVries
翻译 | linkoln
校对 | Geequlim, Meow J, [BLumia](https://github.com/blumia/)
翻译 | linkoln, Geequlim, Meow J, [BLumia](https://github.com/blumia/)
校对 | 暂未校对
在上一个教程中我们学习了如何有效地利用矩阵的变换来对所有顶点进行变换。OpenGL希望在每次顶点着色器运行后我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的**x****y****z**坐标都应该在**-1.0**到**1.0**之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标变换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),将它们变换为屏幕上的二维坐标或像素。
@@ -321,7 +321,7 @@ for(unsigned int i = 0; i < 10; i++)
![coordinate_systems_multiple_objects](../img/01/08/coordinate_systems_multiple_objects.png)
完美!这就像我们的箱子找到志同道合的小伙伴一样。如果你在这里卡住了,你可以对照一下[源代码](https://learnopengl.com/code_viewer_gh.php?code=src/1.getting_started/6.3.coordinate_systems_multiple/coordinate_systems_multiple.cpp) 。
完美!看起来我们的箱子已经找到志同道合的小伙伴。如果你在这里卡住了,你可以对照一下[源代码](https://learnopengl.com/code_viewer_gh.php?code=src/1.getting_started/6.3.coordinate_systems_multiple/coordinate_systems_multiple.cpp) 。
## 练习

View File

@@ -3,8 +3,8 @@
原文 | [Camera](http://learnopengl.com/#!Getting-started/Camera)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | Geequlim, [BLumia](https://github.com/blumia/)
翻译 | [Django](http://bullteacher.com/), Meow J, Geequlim, [BLumia](https://github.com/blumia/)
校对 | 暂未校对
前面的教程中我们讨论了观察矩阵以及如何使用观察矩阵移动场景我们向后移动了一点。OpenGL本身没有**摄像机**(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种**我们**在移动的感觉,而不是场景在移动。

View File

@@ -15,14 +15,14 @@
- **OpenGL** 一个定义了函数布局和输出的图形API的正式规范。
- **GLAD** 一个拓展加载库用来为我们加载并设定所有OpenGL函数指针从而让我们能够使用所有现代OpenGL函数。
- **视口(Viewport)** 我们需要渲染的窗口。
- **图形管线(Graphics Pipeline)** 一个顶点在呈现为像素之前过的过程。
- **着色器(Shader)** 一个运行在显卡上的小型程序。很多阶段的图形管道都可以使用自定义的着色器来代替原的功能。
- **标准化设备坐标(Normalized Device Coordinates, NDC)** 顶点在通过在剪裁坐标系中剪裁与透视划分后最终呈现在的坐标系。所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见。
- **图形管线(Graphics Pipeline)** 一个顶点在呈现为像素之前过的全部过程。
- **着色器(Shader)** 一个运行在显卡上的小型程序。很多阶段的图形管道都可以使用自定义的着色器来代替原的功能。
- **标准化设备坐标(Normalized Device Coordinates, NDC)** 顶点在通过在剪裁坐标系中剪裁与透视除法后最终呈现在的坐标系。所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见。
- **顶点缓冲对象(Vertex Buffer Object)** 一个调用显存并存储所有顶点数据供显卡使用的缓冲对象。
- **顶点数组对象(Vertex Array Object)** 存储缓冲区和顶点属性状态。
- **索引缓冲对象(Element Buffer Object)** 一个存储索引供索引化绘制使用的缓冲对象。
- **Uniform** 一个特殊类型的GLSL变量。它是全局的在一个着色器程序中每一个着色器都能够访问uniform变量并且只被设定一次。
- **纹理(Texture)** 一种缠绕物体的特殊类型图像,给物体精细的视觉效果。
- **Uniform** 一个特殊类型的GLSL变量。它是全局的在一个着色器程序中每一个着色器都能够访问uniform变量并且只需要被设定一次。
- **纹理(Texture)** 一种包裹着物体的特殊类型图像,给物体精细的视觉效果。
- **纹理缠绕(Texture Wrapping)** 定义了一种当纹理顶点超出范围(0, 1)时指定OpenGL如何采样纹理的模式。
- **纹理过滤(Texture Filtering)** 定义了一种当有多种纹素选择时指定OpenGL如何采样纹理的模式。这通常在纹理被放大情况下发生。
- **多级渐远纹理(Mipmaps)** 被存储的材质的一些缩小版本,根据距观察者的距离会使用材质的合适大小。

View File

@@ -3,8 +3,8 @@
原文 | [Colors](http://learnopengl.com/#!Lighting/Colors)
---|---
作者 | JoeyDeVries
翻译 | [Geequlim](http://geequlim.com/)
校对 | [Geequlim](http://geequlim.com/)
翻译 | [Geequlim](http://geequlim.com/), Meow J
校对 | 暂未校对
在前面的教程中我们已经简要提到过该如何在OpenGL中使用颜色(Color),但是我们至今所接触到的都是很浅层的知识。本节我们将会更深入地讨论什么是颜色,并且还会为接下来的光照(Lighting)教程创建一个场景。

View File

@@ -3,8 +3,8 @@
原文 | [Basic Lighting](http://learnopengl.com/#!Lighting/Basic-Lighting)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | Geequlim, [BLumia](https://github.com/blumia/)
翻译 | [Django](http://bullteacher.com/), Meow J, Geequlim, [BLumia](https://github.com/blumia/)
校对 | 暂未校对
现实世界的光照是极其复杂的而且会受到诸多因素的影响这是我们有限的计算能力所无法模拟的。因此OpenGL的光照使用的是简化的模型对现实的情况进行近似这样处理起来会更容易一些而且看起来也差不多一样。这些光照模型都是基于我们对光的物理特性的理解。其中一个模型被称为<def>冯氏光照模型</def>(Phong Lighting Model)。冯氏光照模型的主要结构由3个分量组成环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。下面这张图展示了这些光照分量看起来的样子:
@@ -179,7 +179,7 @@ FragColor = vec4(result, 1.0);
现在我们已经把法向量从顶点着色器传到了片段着色器。可是,目前片段着色器里的计算都是在世界空间坐标中进行的。所以,我们是不是应该把法向量也转换为世界空间坐标?基本正确,但是这不是简单地把它乘以一个模型矩阵就能搞定的。
首先法向量只是一个方向向量不能表达空间中的特定位置。同时法向量没有齐次坐标顶点位置中的w分量。这意味着位移不应该影响到法向量。因此如果我们打算把法向量乘以一个模型矩阵我们就要把模型矩阵左上角3×3的位移部分矩阵移除译注所谓移除就是设置为0注意我们也可以把法向量的w分量设置为0再乘以4×4矩阵这同样可以移除位移。对于法向量我们只希望对它实施缩放和旋转变换。
首先法向量只是一个方向向量不能表达空间中的特定位置。同时法向量没有齐次坐标顶点位置中的w分量。这意味着位移不应该影响到法向量。因此如果我们打算把法向量乘以一个模型矩阵我们就要从矩阵中移除位移部分只选用模型矩阵左上角3×3的矩阵注意我们也可以把法向量的w分量设置为0再乘以4×4矩阵这同样可以移除位移。对于法向量我们只希望对它实施缩放和旋转变换。
其次,如果模型矩阵执行了不等比缩放,顶点的改变会导致法向量不再垂直于表面了。因此,我们不能用这样的模型矩阵来变换法向量。下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:

View File

@@ -4,7 +4,7 @@
---|---
作者 | JoeyDeVries
翻译 | Meow J
校对 | 未校对
校对 | 未校对
在现实世界里,每个物体会对光产生不同的反应。比如说,钢看起来通常会比陶瓷花瓶更闪闪发光,木头箱子也不会像钢制箱子那样对光产生很强的反射。每个物体对镜面高光也有不同的反应。有些物体反射光的时候不会有太多的散射(Scatter)因而产生一个较小的高光点而有些物体则会散射很多产生一个有着更大半径的高光点。如果我们想要在OpenGL中模拟多种类型的物体我们必须为每个物体分别定义一个<def>材质</def>(Material)属性。

View File

@@ -3,156 +3,160 @@
原文 | [Lighting maps](http://learnopengl.com/#!Lighting/Lighting-maps)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com), [BLumia](https://github.com/blumia/)
翻译 | Meow J
校对 | 暂未校对
前面的教程,我们讨论了让不同的物体拥有各自不同的材质对光照做出不同的反应的方法。在一个光照场景中,让每个物体拥有和其他物体不同的外观很棒,但是这仍不能对一个物体的图像输出提供足够多的灵活性。
在[上一节](03 Materials.md)中,我们讨论了让每个物体拥有自己独特的材质从而对光照做出不同的反应的方法。这样子能够很容易在一个光照场景中每个物体一个独特的外观,但是这仍不能对一个物体的视觉输出提供足够多的灵活性。
前面的教程中我们将个物体自身作为一个整体为其定义了一个材质,但现实世界的物体通常不会只有这么一种材质,而是由多种材质组成。想一辆车:它的外表质地光亮,车窗会部分反射环境它的轮胎没有specular高光,轮彀却非常闪亮(在洗过之后。汽车同样有diffuse和ambient颜色它们在整个车上都不相同一辆车显示了多种不同的ambient/diffuse颜色。总之这样一个物体每个部分都有多种材质属性。
在上一节中,我们将个物体的材质定义为一个整体,但现实世界的物体通常并不只包含有一种材质,而是由多种材质组成。想一辆车:它的外壳非常有光泽,车窗会部分反射周围的环境,轮胎不会那么有光泽,所以它没有镜面高光,轮非常闪亮(如果你洗车了的话)。汽车同样会有漫反射和环境光颜色,它们在整个物体上也不会是一样的,汽车有着许多种不同的环境光/漫反射颜色。总之,这样的物体在不同的部件上都有不同的材质属性。
所以,前面的材质系统对于除了最简单的模型以外都是不够的所以我们需要扩展前面的系统我们要介绍diffuse和specular贴图。它们允许你对一个物体的diffuse而对于简洁的ambient成分来说,它们几乎总是一样的)和specular成分能够有更精确的影响
所以,上一节中的那个材质系统是肯定不够的,它只是一个最简单的模型,所以我们需要拓展之前的系统,引入**漫反射**和**镜面光**贴图(Map)。这允许我们对物体的漫反射分量(以及间接地对环境光分量,它们几乎总是一样的)和镜面光分量有着更精确的控制
# 漫反射贴图
我们希望通过某种方式对每个原始像素独立设置diffuse颜色。有可以让我们基于物体原始像素的位置来获取颜色值系统吗?
我们希望通过某种方式对物体的每个片段单独设置漫反射颜色。有能够让我们根据片段在物体上的位置来获取颜色值系统吗?
这可能听起来极其相似,坦白来讲我们使用这样的系统已经有一段时间了。听起来很像在一个[之前的教程](../01 Getting started/06 Textures.md)中谈论的**纹理**基本就是一个纹理。我们其实是使用同一个潜在原则下的不同名称:使用一张图片覆盖物体,以便我们为每个原始像素索引独立颜色值。在光照场景中,通过纹理来呈现一个物体的diffuse颜色这个做法被称做**漫反射贴图(Diffuse texture)**(因为3D建模师就是这么称呼这个做法的)
这可能听起来很熟悉,而且事实上这个系统我们已经使用很长时间了。听起来很像在[之前](../01 Getting started/06 Textures.md)教程中详细讨论过的**纹理**而这基本就是这样:一个纹理。我们仅仅是对同样的原理使用了不同的名字:其实都是使用一张覆盖物体的图像,让我们能够逐片段索引独立颜色值。在光照场景中,它通常叫做一个<def>漫反射贴图</def>(Diffuse Map)3D艺术家通常都这么叫它它是一个表现了物体所有的漫反射颜色的纹理图像
为了演示漫反射贴图,我们将会使用[下面的图片](../img/02/04/container2.png),它是一个有一圈钢边的木箱:
为了演示漫反射贴图,我们将会使用[下面的图片](../img/02/04/container2.png),它是一个有钢边的木箱:
![](../img/02/04/container2.png)
在着色器中使用漫反射贴图和纹理教程介绍的一样。这次我们把纹理以sampler2D类型储存在Material结构体中。我们使用diffuse贴图替代早期定义的vec3类型的diffuse颜色
在着色器中使用漫反射贴图的方法和纹理教程中是完全一样的。但这次我们会将纹理储存为<fun>Material</fun>结构体中的一个`sampler2D`。我们将之前定义的`vec3`漫反射颜色向量替换为漫反射贴图
!!! Attention
要记住的是sampler2D也叫做模糊类型这意味着我们不能以某种类型对它实例化,只能uniform定义它。如果我们用结构体而不是uniform实例化就像函数的参数那样GLSL会抛出奇怪的错误这同样也适用于其他模糊类型
我们也要移除amibient材质颜色向量因为ambient颜色绝大多数情况等于diffuse颜色所以不需要分别去储存它
注意`sampler2D`是所谓的<def>不透明类型</def>(Opaque Type),也就是说我们不能将它实例化,只能通过uniform定义它。如果我们使用除uniform以外的方法比如函数的参数实例化这个结构体GLSL会抛出一些奇怪的错误这同样也适用于任何封装了不透明类型的结构体
我们也移除了环境光材质颜色向量,因为环境光颜色在几乎所有情况下都等于漫反射颜色,所以我们不需要将它们分开储存:
```c++
struct Material
{
struct Material {
sampler2D diffuse;
vec3 specular;
float shininess;
};
vec3 specular;
float shininess;
};
...
in vec2 TexCoords;
```
!!! Important
如果你非把ambient颜色设置为不同的值不可不同于diffuse值,你可以继续保留ambient的vec3整个物体的ambient颜色会继续保持不变。为了使每个原始像素得到不同ambient值你需要对ambient值单独使用另一个纹理。
如果你非常固执,仍想将环境光颜色设置为一个(漫反射值之外)不同的值,你可以保留这个环境光的`vec3`,但整个物体仍只能拥有一个环境光颜色。如果想要对不同片段有不同的环境光值,你需要对环境光值单独使用另一个纹理。
注意我们将在片段着色器中再次需要纹理坐标,所以我们声明一个额外的输入变量。接下来我们只需要从纹理中采样片段的漫反射颜色值即可:
注意在片段着色器中我们将会再次需要纹理坐标所以我们声明一个额外输入变量。然后我们简单地从纹理采样来获得原始像素的diffuse颜色值
```c++
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
```
同样,不要忘记把ambient材质颜色设置为diffuse材质颜色
不要忘记将环境光得材质颜色设置为漫反射材质颜色同样的值。
```c++
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
```
这就是diffuse贴图的全部内容了。就像你看到,这不是什么新的东西,但是它却极大提升了视觉品质。为了让它工作,我们需要用纹理坐标更新顶点数据,它们作为顶点属性传递到片段着色器,把纹理加载并绑定到合适的纹理单元。
这就是使用漫反射贴图的全部步骤了。你可以看到,这不是什么新的东西,但这能够极大地提高视觉品质。为了让它正常工作,我们需要使用纹理坐标更新顶点数据,它们作为顶点属性传递到片段着色器,加载材质并绑定材质到合适的纹理单元。
更新的顶点数据可以[这里](http://learnopengl.com/code_viewer.php?code=lighting/vertex_data_textures)找到。顶点数据现在包了顶点位置,法线向量和纹理坐标,每个立方体顶点都有这些属性。让我们更新顶点着色器来接受纹理坐标作为顶点属性,然后发送到片段着色器:
更新的顶点数据可以[这里](https://learnopengl.com/code_viewer.php?code=lighting/vertex_data_textures)找到。顶点数据现在包了顶点位置、法向量和立方体顶点处的纹理坐标。让我们更新顶点着色器来以顶点属性的形式接受纹理坐标,并将它们传递到片段着色器
```c++
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
...
out vec2 TexCoords;
void main()
{
...
TexCoords = texCoords;
TexCoords = aTexCoords;
}
```
要保证更新的顶点属性指针不仅是VAO匹配新的顶点数据也要把箱子图片加载为纹理。在绘制箱子之前我们希望首选纹理单元被赋为material.diffuse这个uniform采样器并绑定箱子的纹理到这个纹理单元
记得去更新两个VAO的顶点属性指针来匹配新的顶点数据并加载箱子图像为一个纹理。在绘制箱子之前我们希望将要用的纹理单元赋值到<var>material.diffuse</var>这个uniform采样器并绑定箱子的纹理到这个纹理单元
```c++
glUniform1i(glGetUniformLocation(lightingShader.Program, "material.diffuse"), 0);
lightingShader.setInt("material.diffuse", 0);
...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);
```
现在使用一个diffuse贴图我们在细节上再次获得惊人的提升这次添加到箱子上的光照开始闪光了名符其实。你的箱子现在可能看起来像这样:
使用了漫反射贴图之后,细节再一次得到惊人的提升,这次箱子有了光照开始闪闪发光(字面意思也是)了。你的箱子看起来可能像这样:
![](../img/02/04/materials_diffuse_map.png)
你可以在这里得到应用的[全部代码](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps_diffuse)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/4.1.lighting_maps_diffuse_map/lighting_maps_diffuse.cpp)找到程序的全部代码
# 镜面贴图
# 镜面贴图
你可能注意到,specular高光看起来不怎么样由于我们的物体是个箱子大部分是木头,我们知道木头不应该有镜面高光的。我们通过把物体设置specular材质设置为vec3(0.0f)来修正它。但是这样意味着铁边会不再显示镜面高光,我们知道钢铁是会显示一些镜面高光的。我们想要控制物体部分地显示镜面高光它带有修改了的亮度。这个问题看起来和diffuse贴图的讨论一样。这是巧合吗?我想不是。
你可能注意到,镜面高光看起来有些奇怪,因为我们的物体大部分是木头,我们知道木头不应该有这么强的镜面高光的。我们可以将物体的镜面光材质设置为`vec3(0.0)`来解决这个问题,但这也意味着箱子钢制的边框将不再能够显示镜面高光,我们知道钢铁**应该**是有一些镜面高光的。所以,我们想要让物体的某些部分以不同的强度显示镜面高光。这个问题看起来和漫反射贴图非常相似。是巧合吗?我想不是。
我们同样用一个纹理贴图,来获得镜面高光。这意味着我们需要生成一个黑白(或者你喜欢的颜色)纹理来定义specular亮度把它应用到物体的每个部分。下面是一个[镜面贴图(Specular Map)](../img/02/04/container2_specular.png)的例子:
我们同样可以使用一个专门用于镜面高光的纹理贴图。这也就意味着我们需要生成一个黑白的(如果你想得话也可以是彩色的)纹理来定义物体每部分的镜面光强度。下面是一个[镜面贴图](../img/02/04/container2_specular.png)(Specular Map)的例子:
![](../img/02/04/container2_specular.png)
一个specular高光的度可以通过图片中每个纹理的亮度来获得。specular贴图的每个像素可以显示为一个颜色向量,比如:在那里黑色代表颜色向量vec3(0.0f),灰色是vec3(0.5f)。在片段着色器中,我们采样相应的颜色值,把它乘以光的specular亮度。像素越“白”乘积的结果越大物体的specualr部分越亮。
镜面高光的度可以通过图像每个像素的亮度来获取。镜面光贴图的每个像素可以一个颜色向量来表示,比如说黑色代表颜色向量`vec3(0.0)`,灰色代表颜色向量`vec3(0.5)`。在片段着色器中,我们接下来会取样对应的颜色值并将它乘以光源的镜面强度。一个像素越「白」,乘积就会越大,物体的镜面光分量就会越亮。
由于箱子几乎是由木头组成,木头作为一个材质不会有镜面高光整个木头部分的diffuse纹理被用黑色覆盖黑色部分不会包含任何specular高光。箱子的铁边有一个修改的specular亮度它自身更容易受到镜面高光影响,木纹部分则不会。
由于箱子大部分都由木头组成,而且木头材质应该没有镜面高光,所以漫反射纹理的整个木头部分全部都转换成了黑色。箱子钢制边框的镜面光强度是有细微变化的,钢铁本身会比较容易受到镜面高光影响,而裂缝则不会。
从技术上来讲木头也有镜面高光尽管这个闪亮值很小更多的光被散射影响很小但是为了学习目的我们可以假装木头不会有任何specular光反射。
!!! important
使用Photoshop或Gimp之类的工具通过将图片进行裁剪将某部分调整成黑白图样并调整亮度/对比度的做法可以非常容易将一个diffuse纹理贴图处理为specular贴图
从实际角度来说,木头其实也有镜面高光,尽管它的反光度(Shininess)很小(更多的光被散射),影响也比较小,但是为了教学目的,我们可以假设木头不会对镜面光有任何反应
使用**Photoshop**或**Gimp**之类的工具,将漫反射纹理转换为镜面光纹理还是比较容易的,只需要剪切掉一些部分,将图像转换为黑白的,并增加亮度/对比度就好了。
## 镜面贴图采样
## 采样镜面贴图
一个specular贴图和其他纹理一样所以代码和diffuse贴图的代码也相似。确保合理的加载了图片生成一个纹理对象。由于我们在同样的片段着色器中使用另一个纹理采样器,我们必须为specular贴图使用一个不同的纹理单元(参见[纹理](../01 Getting started/06 Textures.md)),所以在渲染前让我们把它绑定到合适的纹理单元
镜面光贴图和其它的纹理非常类似,所以代码也和漫反射贴图的代码很类似。记得要保证正确地加载图像并生成一个纹理对象。由于我们在同一个片段着色器中使用另一个纹理采样器,我们必须要对镜面光贴图使用一个不同的纹理单元见[纹理](../01 Getting started/06 Textures.md),所以我们在渲染之前先把它绑定到合适的纹理单元上:
```c++
glUniform1i(glGetUniformLocation(lightingShader.Program, "material.specular"), 1);
lightingShader.setInt("material.specular", 1);
...
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap);
```
然后更新片段着色器材质属性接受一个sampler2D作为这个specular部分的类型而不是vec3
接下来更新片段着色器材质属性,让其接受一个`sampler2D`而不是`vec3`作为镜面光分量
```c++
struct Material
{
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
float shininess;
};
```
最后我们希望采样这个specular贴图来获取原始像素相应的specular亮度:
最后我们希望采样镜面光贴图,来获取片段所对应的镜面光强度:
```c++
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
color = vec4(ambient + diffuse + specular, 1.0f);
FragColor = vec4(ambient + diffuse + specular, 1.0);
```
通过使用一个specular贴图我们可以定义极为精细的细节,物体的这个部分会获得闪亮的属性,我们可以设置它们应的度。specular贴图给我们一个附加的高于diffuse贴图的控制权限
通过使用镜面光贴图我们可以可以对物体设置大量的细节,比如物体的哪些部分需要有**闪闪发光**的属性,我们甚至可以设置它们应的度。镜面光贴图能够在漫反射贴图之上给予我们更高一层的控制。
如果你不想成为主流你可以在specular贴图里使用颜色不单单为每个原始像素设置specular亮度同时也设置specular高光的颜色。从真实角度来说specular的颜色基本是由光源自身决定的所以它不会生成真实的图像这就是为什么图片通常是黑色和白色的我们只关心亮度
!!! important
如果你现在运行应用,你可以清晰地看到箱子的材质现在非常类似真实的铁边的木头箱子了:
如果你想另辟蹊径,你可以在镜面光贴图中使用真正的颜色,不仅设置每个片段的镜面光强度,还设置了镜面高光的颜色。从现实角度来说,镜面高光的颜色大部分(甚至全部)都是由光源本身所决定的,所以这样并不能生成非常真实的视觉效果(这也是为什么图像通常是黑白的,我们只关心强度)。
如果你现在运行程序的话,你可以清楚地看到箱子的材质现在和真实的钢制边框箱子非常类似了:
![](../img/02/04/materials_specular_map.png)
你可以在这里找到[全部源码](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps_specular)。也对比一下你的[顶点着色器](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps&type=vertex)和[片段着色器](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps&type=fragment)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/4.2.lighting_maps_specular_map/lighting_maps_specular.cpp)找到程序的全部源码
使用diffuse和specular贴图,我们可以给相关但简单物体添加一个极为明显的细节。我们可以使用其他纹理贴图,比如法线/bump贴图或者反射贴图给物体添加更多的细节。但是这些在后面教程才会涉及。把你的箱子给你所有的朋友家人看,有一天你会很满足,我们的箱子会比现在更漂亮!
通过使用漫反射和镜面光贴图,我们可以给相简单物体添加大量的细节。我们甚至可以使用<def>法线/凹凸贴图</def>(Normal/Bump Map)或者<def>反射贴图</def>(Reflection Map)给物体添加更多的细节,但这些将会留到之后的教程中。把你的箱子给你的朋友或者家人看看,并且坚信我们的箱子有一天会比现在更漂亮!
## 练习
- 调整光源的ambientdiffuse和specular向量,看看它们如何影响实际输出的箱子外观
- 尝试在片段着色器中反转镜面贴图(Specular Map)的颜色值,然后木头就会变得反光而边框不会反光(由于贴图中钢边依然有一些残余颜色,所以钢边依然会有一些高光,不过反光明显小了很多)[参考解答](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps-exercise2)
- 使用漫反射纹理(Diffuse Texture)原本的颜色而不是黑白色来创建镜面贴图,并观察,你会发现结果显得并不那么真实了。如果你不会处理图片,你可以使用这[带颜色的镜面贴图](../img/02/04/lighting_maps_specular_color.png)[最终效果](../img/02/04/lighting_maps_exercise3.png)
- 添加一个叫做**放射光贴图(Emission Map)**的东西,即记录每个片段发光值(Emission Value)大小的贴图发光值是(模拟)物体自身**发光(Emit)**时可能产生的颜色这样的话物体就可以忽略环境光自身发光。通常在你看到游戏某个东西(比如 [机器人的眼](http://www.witchbeam.com.au/unityboard/shaders_enemy.jpg),或是[箱子上的](http://www.tomdalling.com/images/posts/modern-opengl-08/emissive.png))在发光时,使用的就是放射光贴图。使用[这个](../img/02/04/matrix.jpg)贴图(作者为 creativesam)作为放射光贴图并使用在箱子上,你就会看到箱子上有会发光的字了。[参考解答](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps-exercise4),[片段着色器](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps-exercise4_fragment), [最终效果](../img/02/04/lighting_maps_exercise4.png)
- 调整光源的环境光、漫反射和镜面光向量,看看它们如何影响箱子的视觉输出
- 尝试在片段着色器中反转镜面贴图的颜色值,让木头显示镜面高光而钢制边缘不反光(由于钢制边缘中有一些裂缝,边缘仍会显示一些镜面高光,虽然强度会小很多)[参考解答](https://learnopengl.com/code_viewer.php?code=lighting/lighting_maps-exercise2)
- 使用漫反射贴图创建一个彩色而不是黑白镜面贴图,看看结果看起来并不那么真实了。如果你不会生成的话,可以使用这[色的镜面贴图](../img/02/04/lighting_maps_specular_color.png)[最终效果](../img/02/04/lighting_maps_exercise3.png)
- 添加一个叫做<def>放射光贴图</def>(Emission Map)的东西,它是一个储存了每个片段发光值(Emission Value)的贴图发光值是一个包含(假设)光源的物体发光(Emit)时可能显现的颜色这样的话物体就能够忽略光照条件进行发光(Glow)。游戏某个物体在发光的时候,你通常看到的就是放射光贴图(比如 [机器人的眼](../img/02/04/shaders_enemy.jpg)或是[箱子上的灯](../img/02/04/emissive.png))。将[这个](../img/02/04/matrix.jpg)纹理(作者为 creativesam作为放射光贴图添加到箱子上,产生这些字母都在发光的效果:[参考解答](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/4.3.lighting_maps_exercise4/lighting_maps_exercise4.cpp)[最终效果](../img/02/04/lighting_maps_exercise4.png)

View File

@@ -3,30 +3,30 @@
原文 | [Light casters](http://www.learnopengl.com/#!Lighting/Light-casters)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com), [BLumia](https://github.com/BLumia)
翻译 | Meow J
校对 | 暂未校对
我们目前使用的所有光照都来自于一个单独的光源,这是空间中的一个点。它的效果不错,但是在真实世界,我们有多种类的光,它们每个表现都不同。一个光源把光投射到物体上,叫做投光。这个教程里我们讨论几种不同的投光类型。学模拟不同的光源是你未来丰富你的场景的另一个工具。
我们目前使用的光照都来自于空间中的一个点。它能给我们不错的效果,但现实世界,我们有多种类的光照,每种的表现都不同。将光**投射**(Cast)到物体的光源叫做<def>投光物</def>(Light Caster)。在这一节中,我们将会讨论几种不同类型的投光。学模拟不同种类的光源是又一个能够进一步丰富场景的工具。
我们首先讨论定向光(directional light),接着是作为之前学到知识的扩展的点光(point light),最后我们讨论聚光(Spotlight)。下面的教程我们会把这几种不同的光类型整合到一个场景中。
我们首先将会讨论定向光(Directional Light),接下来是点光源(Point Light)它是我们之前学习的光源的拓展,最后我们将会讨论聚光(Spotlight)。在[下一节](06 Multiple lights.md)中我们将讨论如何将这些不同种类的光类型整合到一个场景中。
# 定向
# 平行
当一个光源很远的时,来自光源的每条光线接近于平行。这看起来像所有的光线来自于同一个方向,无论物体和观察者在哪儿。当一个光源被设置为无限远时,它被称为定向光(Directional Light),因为所有光线都有着同一个方向;它会独立于光源的位置
当一个光源处于很远的地方时,来自光源的每条光线就会近似于互相平行。不论物体和/或者观察者的位置,看起来像所有的光来自于同一个方向。当我们使用一个假设光源处于**无限**远处的模型时,它被称为<def>定向光</def>,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的
我们知道的定向光源的一个例子是太阳。太阳我们不是无限远,但它也足够远了,在计算光照的时候,我们感觉它就像无限远。在下面的图片里,来自太阳的所有光线都被定义为平行光
定向光非常好的一个例子是太阳。太阳距离我们不是无限远,但它已经远到在光照计算中可以把它视为无限远了。所以来自太阳的所有光线将被模拟为平行光线,我们可以在下图看到
![](../img/02/05/light_casters_directional.png)
因为所有的光线都是平行的,对场景中每个物体光的方向都保持一致,物体和光源的位置保持怎样的关系都无所谓。由于光的方向向量保持一致,光照计算会和场景中的其他物体相似
因为所有的光线都是平行的,所以物体与光源的相对位置是不重要的,因为对场景中每个物体光的方向都是一致的。由于光的位置向量保持一致,场景中每个物体的光照计算将会是类似的
我们可以通过定义一个光方向向量来模拟这样一个定向光,而不是使用光的位置向量。着色器计算保持大致相同的要求,这次我们直接使用光的方向向量来代替用`lightDir`向量和`position`向量的计算:
我们可以定义一个光线方向向量而不是位置向量来模拟一个定向光。着色器计算基本保持不变,但这次我们直接使用光的<var>direction</var>向量而不是通过<var>direction</var>来计算<var>lightDir</var>向量。
```c++
struct Light
{
// vec3 position; // 现在不在需要光源位置了,因为它是无限远的
struct Light {
// vec3 position; // 使用定向光就不再需要了
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
@@ -34,93 +34,90 @@ struct Light
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
vec3 lightDir = normalize(-light.direction);
...
}
```
注意我们首先对`light.direction`向量取反。目前我们使用的光照计算需要光的方向作为一个来自片段朝向的光源的方向,但人们通常更习惯定义一个定向光为一个全局方向,它从光源发出。所以我们必须对全局光方向向量取反来改变它的方向它现在是一个方向向量指向光源。同时,确保对向量进行标准化处理,因为假定输入向量就是一个单位向量是不明智的。
注意我们首先对<var>light.direction</var>向量取反。我们目前使用的光照计算需求一个从片段**至**光源的光线方向,但人们更习惯定义定向光为一个**从**光源出发的全局方向。所以我们需要对全局光方向向量取反来改变它的方向它现在是一个指向光源的方向向量了。而且,记得对向量进行标准化,假设输入向量一个单位向量是不明智的。
作为结果的`lightDir`向量被使用在`diffuse`和`specular`计算之前
最终的<var>lightDir</var>向量将和以前一样用在漫反射和镜面光计算中
为了清晰地强调一个定向光对所有物体都有同样的影响,我们再次访问[坐标系教程](../01 Getting started/08 Coordinate Systems.md)结尾部分的箱子场景。例子里我们先定义10个不同的箱子位置,为每个箱子生成不同的模型矩阵,每个模型矩阵包含相应的本地到世界变换:
为了清楚地展示定向光对多个物体具有相同的影响,我们将会再次使用[坐标系](../01 Getting started/08 Coordinate Systems.md)章节最后的那个箱子派对的场景。如果你错过了派对,我们先定义了十个不同的[箱子位置](https://learnopengl.com/code_viewer.php?code=lighting/light_casters_container_positions),并对每个箱子生成了一个不同的模型矩阵,每个模型矩阵包含了对应的局部-世界坐标变换:
```c++
for(GLuint i = 0; i < 10; i++)
for(unsigned int i = 0; i < 10; i++)
{
model = glm::mat4();
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));
float angle = 20.0f * i;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
lightingShader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
```
同时,不要忘记定义光源的方向(注意我们方向定义为:从光源处发出的方向;在下面,你可以快速看到光的方向的指向):
同时,不要忘记定义光源的方向(注意我们方向定义为**从**光源出发的方向,你可以很容易看到光的方向朝下)。
```c++
GLint lightDirPos = glGetUniformLocation(lightingShader.Program, "light.direction");
glUniform3f(lightDirPos, -0.2f, -1.0f, -0.3f);
lightingShader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);
```
!!! Important
我们已经把光的位置和方向向量传递为vec3但是有些人去想更喜欢把所有的向量设置为vec4.当定义位置向量为vec4的时候把w元素设置为1.0非常重要这样平移和投影才会合理的被应用。然而当定义一个方向向量为vec4时我们并不想让平移发挥作用因为它们除了代表方向其他什么也不是所以我们把w元素设置为0.0。
方向向量被表示为vec4(0.2f, 1.0f, 0.3f, 0.0f)。这可以作为简单检查光的类型的方法你可以检查w元素是否等于1.0查看我们现在所拥有的光的位置向量w是否等于0.0,我们有一个光的方向向量,所以根据那个调整计算方法:
我们一直将光的位置和位置向量定义为`vec3`,但一些人会喜欢将所有的向量都定义为`vec4`。当我们将位置向量定义为一个`vec4`时很重要的一点是要将w分量设置为1.0,这样变换和投影才能正确应用。然而,当我们定义一个方向向量为`vec4`的时候我们不想让位移有任何的效果因为它仅仅代表的是方向所以我们将w分量设置为0.0。
if(lightVector.w == 0.0) // 请留意浮点数错误
// 执行定向光照计算
else if(lightVector.w == 1.0)
// 像上一个教程一样执行顶点光照计算
方向向量就会像这样来表示:`vec4(0.2f, 1.0f, 0.3f, 0.0f)`。这也可以作为一个快速检测光照类型的工具你可以检测w分量是否等于1.0来检测它是否是光的位置向量w分量等于0.0,则它是光的方向向量,这样就能根据这个来调整光照计算了:
有趣的事实这就是旧OpenGL固定函数式决定一个光源是一个定向光还是位置光源根据这个修改它的光照。
如果你现在编译应用,并在场景中自由移动,它看起来像有一个太阳一样的光源,把光抛到物体身上。你可以看到`diffuse`和`specular`元素都对该光源进行反射了,就像天空上有一个光源吗?看起来就像这样:
if(lightVector.w == 0.0) // 注意浮点数据类型的误差
// 执行定向光照计算
else if(lightVector.w == 1.0)
// 根据光源的位置做光照计算(与上一节一样)
你知道吗这正是旧OpenGL固定函数式决定光源是定向光还是位置光源(Positional Light Source)的方法,并根据它来调整光照。
如果你现在编译程序,在场景中自由移动,你就可以看到好像有一个太阳一样的光源对所有的物体投光。你能注意到漫反射和镜面光分量的反应都好像在天空中有一个光源的感觉吗?它会看起来像这样:
![](../img/02/05/light_casters_directional_light.png)
你可以在这里获得[应用的所有代码](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_directional),这里是[顶点](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps&type=vertex)和[片段](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_directional&type=fragment)着色器代码。
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/5.1.light_casters_directional/light_casters_directional.cpp)找到程序的所有代码。
# 点光源
定向光作为全局光可以照亮整个场景,这非常棒,但是另一方面除了定向光,我们通常也需要几个点光源(Point Light),在场景里发亮。点光是一个在时间里有位置的光源,它所有方向发光,光线随距离增加逐渐变暗。想象灯泡和火炬作为投光物,它们可以扮演点光的角色
定向光对于照亮整个场景的全局光源是非常棒,但除了定向光之外我们也需要一些分散在场景中的<def>点光源</def>(Point Light)。点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,光线会随着距离逐渐衰减。想象作为投光物的灯泡和火把,它们都是点光源
![](../img/02/05/light_casters_point.png)
之前的教程我们已经使用了(最简单的)点光。我们指定了一个光源以及其所在的位置,它从这个位置向所有方向散光线。然而,我们定义的光源模拟光线的强度却不会因为距离变远而衰减,这使得看起来像是光源亮度强。在大多数3D仿真场景中,我们希望模拟一个仅仅能照亮靠近光源附近场景的光源,而不是照亮整个场景的光源
之前的教程中,我们一直都在使用一个(简化的)点光。我们在给定位置有一个光源,它会从它的光源位置开始朝着所有方向散光线。然而,我们定义的光源模拟的是永远不会衰减的光线,这看起来像是光源亮度非常的强。在大部分的3D模拟中,我们希望模拟的光源仅照亮光源附近的区域而不是整个场景。
如果你10个箱子添加到之前教程的光照场景中,你会注意到黑暗中的每个箱子都会有同样的亮度,就像箱子在光照的前面;没有公式定义光的距离衰减。我们想让黑暗中与光源比较近的箱子被轻微地照亮。
如果你10个箱子加入到上一节光照场景中,你会注意到在最后面的箱子和在灯面前的箱子都以相同的强度被照亮,并没有定义一个公式来将光随距离衰减。我们希望在后排的箱子与前排的箱子相比仅仅是被轻微地照亮。
## 衰减
随着光线穿越距离的变远使得亮度也相应地减少的现象,通常称之为**衰减(Attenuation)**。一种随着距离减少亮度的方式是使用线性等式。这样的一个随着距离减少亮度的线性方程,可以使远处的物体更暗。然而,这样的线性方程效果会有点假。在实世界,通常光在近处时非常亮,但是一个光源的亮度开始的时候减少的非常快,之后随着距离的增加,减少的速度会慢下来。我们需要一不同的方程来减少光的度。
随着光线传播距离的增长逐渐削减光的强度通常叫做<def>衰减</def>(Attenuation)。随距离减少光强度的一种方式是使用一个线性方程。这样的方程能够随着距离的增长线性地减少光的强度,从而让远处的物体更暗。然而,这样的线性方程通常会看起来比较假。在实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。所以,我们需要一不同的公式来减少光的度。
幸运的是一些聪明人已经早就把它想到了。下面的方程把一个片段的光的亮度除以一个已经计算出来的衰减值,这个值根据光源的远近得到
幸运的是一些聪明人已经帮我们解决了这个问题。下面这个公式根据片段距光源的距离计算了衰减值,之后我们会将它乘以光的强度向量
$$
\begin{equation} F_{att} = \frac{1.0}{K_c + K_l * d + K_q * d^2} \end{equation}
$$
在这里\(d\)代表片段光源的距离。为了计算衰减值我们定义3个可配置**常数**项\(K_c\)**一次**项\(K_l\)和**二次**项\(K_q\)。
在这里\(d\)代表片段光源的距离。接下来为了计算衰减值我们定义3个可配置)项:<def>常数</def>项\(K_c\)、<def>一次</def>项\(K_l\)和<def>二次</def>项\(K_q\)。
- 常数项通常1.0它的作用是保证分母永远不会比1小因为它可以利用一定的距离增加度,这个结果不会影响到我们所寻找的
- 一次项用于与距离值相乘,这会以线性的方式减少度。
- 二次项用于与距离的平方相乘,光源设置一个亮度的二次递减。二次项在距离比较的时候相比一次项会比一次项小,但当距离更远的时候比一次项更大。
- 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小否则的话在某些距离上它反而会增加度,这肯定不是我们想要的效果
- 一次项与距离值相乘,以线性的方式减少度。
- 二次项与距离的平方相乘,光源以二次递减的方式减少强度。二次项在距离比较的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大
由于二次项的光会以线性方式减少,指导距离足够大的时候,就会超过一次项,之后,光的度会减少的更快。最后的效果就是光在近距离时亮度很高,但距离变远亮度迅速降低,最后亮度降低速度再次变慢。下面的图展示了在100以内的范围这样的衰减效果
由于二次项的存在,光线会在大部分时候以线性方式衰退,直到距离变得足够大,让二次项超过一次项,光的度会以更快的速度下降。这样的结果就是光在近距离时亮度很高,但随着距离变远亮度迅速降低,最后会以更慢的速度减少亮度。下面这张图显示了在100的距离内衰减效果
![](../img/02/05/attenuation.png)
你可以看到当距离很近的时候光有最强的亮度,但随着距离增大,亮度明显减弱,大约接近100的时候就会慢下来。这是我们想要的。
你可以看到光在近距离的时候有着最高的强度,但随着距离增长,它的强度明显减弱,并缓慢地在距离大约100的时候强度接近0。这是我们想要的。
### 选择正确的值
但是,我们把这三个项设置什么值呢?正确的值的设置由很多因素决定:环境、希望光覆盖的距离范围、光的类型等。大多数场合,这是经验的问题,也要适度调整。下面的表格展示一些各项的值,它们模拟现实(某种类型的)光源,覆盖特定半径(距离)。第一栏定义一个光的距离它覆盖所给定的项。这些值是大多数光的良好开始它是来自Ogre3D的维基的礼物
但是,该对这三个项设置什么值呢?正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等。大多数情况下,这是经验的问题,以及适量的调整。下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。第一列指定的是在给定的三项时光所能覆盖的距离。这些值是大多数光源很好的起始点,它们由[Ogre3D的Wiki](http://www.ogre3d.org/tikiwiki/tiki-index.php?page=-Point+Light+Attenuation)所提供
距离|常数项|一次项|二次项
-------|------|-----|------
@@ -137,152 +134,152 @@ $$
600|1.0|0.007|0.0002
3250|1.0|0.0014|0.000007
就像你所看到,常数项\(K_c\)一直都是1.0。一次项\(K_l\)为了覆盖更远的距离通常很小,二次项\(K_q\)更小。尝试这些值进行实验,看看它们在你的实现中各自的效果。我们的环境中32到100的距离对大多数光通常就足够了。
你可以看到,常数项\(K_c\)在所有的情况下都是1.0。一次项\(K_l\)为了覆盖更远的距离通常很小,二次项\(K_q\)甚至更小。尝试这些值进行实验,看看它们在你的实现中有什么效果。我们的环境中32到100的距离对大多数的光源都足够了。
### 实现衰减
为了实现衰减,在着色器中我们需要三个额外值:也就是公式的常、一次项和二次项。最好把它们储存在之前定义的Light结构体中。注意的是我们计算`lightDir`,就是在前面的教程中我们所做的,不是像之前的定向光的那部分。
为了实现衰减,在片段着色器中我们需要三个额外值:也就是公式的常数项、一次项和二次项。它们最好储存在之前定义的<fun>Light</fun>结构体中。注意我们使用上一节中计算<var>lightDir</var>的方法,而不是上面**定向光**部分
```c++
struct Light
{
vec3 position;
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float constant;
float linear;
float quadratic;
};
```
然后我们在OpenGL中设置这些项我们希望光覆盖50的距离所以我们会使用上面的表格中合适的常数项、一次项和二次项:
然后我们在OpenGL中设置这些项我们希望光源能够覆盖50的距离所以我们会使用表格中对应的常数项、一次项和二次项:
```c++
glUniform1f(glGetUniformLocation(lightingShader.Program, "light.constant"), 1.0f);
glUniform1f(glGetUniformLocation(lightingShader.Program, "light.linear"), 0.09);
glUniform1f(glGetUniformLocation(lightingShader.Program, "light.quadratic"), 0.032);
lightingShader.setFloat("light.constant", 1.0f);
lightingShader.setFloat("light.linear", 0.09f);
lightingShader.setFloat("light.quadratic", 0.032f);
```
在片段着色器中实现衰减直接:我们根据公式简单的计算衰减值,在乘以`ambient`、`diffuse`和`specular`元素
在片段着色器中实现衰减还是比较直接:我们根据公式计算衰减值,之后再分别乘以环境光、漫反射和镜面光分量
我们需要光源的距离提供给公式;还记得我们是怎计算向量的长度吗?我们可以通过获取片段和光源之间的不同向量把向量的长度结果作为距离项。我们可以使用GLSL内建`length`函数做这件事
我们需要公式中距光源的距离还记得我们是怎计算一个向量的长度吗?我们可以通过获取片段和光源之间的向量差,并获取结果向量的长度作为距离项。我们可以使用GLSL内建的<fun>length</fun>函数来完成这一点
```c++
float distance = length(light.position - FragPos);
float attenuation = 1.0f / (light.constant + light.linear*distance +light.quadratic*(distance*distance));
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
```
然后,我们在光照计算中,通过把衰减值乘以`ambient`、`diffuse`和`specular`颜色,包含这个衰减值
接下来,我们将包含这个衰减值到光照计算中,将它分别乘以环境光、漫反射和镜面光颜色
!!! Important
我们可以可以把`ambient`元素留着不变,这样`amient`光照不会随着距离减少,但是如果我们使用多余1个的光源,所有的`ambient`元素会开始叠加,因此这种情况我们希望`ambient`光照也衰减。简单的调试出对于你的环境来说最好的效果
我们可以将环境光分量保持不变,让环境光照不会随着距离减少,但是如果我们使用多于一个的光源,所有的环境光分量将会开始叠加,所以在这种情况我们希望衰减环境光照。简单实验一下,看看什么才能在你的环境中效果最好
```c++
ambient *= attenuation;
diffuse *= attenuation;
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;
```
如果你运行应用后获得这样的果:
如果你运行程序的话,你会获得这样的果:
![](../img/02/05/light_casters_point_light.png)
你可以看到现在只有最近处的箱子的前面被照得最亮。后的箱子一点都没照亮,因为它们离光源太远了。你可以在这里找到[应用源码](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_point)和[片段着色器](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_point&type=fragment)的代码。
你可以看到,只有前排的箱子被照亮的,距离最近的箱子是最亮。后的箱子一点都没照亮,因为它们离光源实在是太远了。你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/5.2.light_casters_point/light_casters_point.cpp)找到程序的代码。
点光就是一个可配的置位置和衰减值应用到光照计算中。还有另一种类型光可用于我们照明库当中
点光就是一个能够配置位置和衰减的光源。它是我们光照工具箱中的又一个光照类型
# 聚光
## 聚光
我们要讨论的最后一种类型的光是<def>聚光</def>(Spotlight)。聚光是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线。这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其它的物体都会保持黑暗。聚光很好的例子就是路灯或手电筒。
我们要讨论的最后一种类型光是聚光(Spotlight)。聚光是一种位于环境中某处的光源,它不是向所有方向照射,而是只朝某个方向照射。结果是只有一个聚光照射方向的确定半径内的物体才会被照亮,其他的都保持黑暗。聚光的好例子是路灯或手电筒。
OpenGL中的聚光用世界空间位置一个方向和一个指定了聚光半径的切光角来表示。我们计算的每个片段如果片段在聚光的切光方向之间就是在圆锥体内我们就会把片段照亮。下面的图可以让你明白聚光是如何工作的
OpenGL中聚光是用一个世界空间位置、一个方向和一个<def>切光</def>角(Cutoff Angle)来表示的,切光角指定了聚光的半径(译注:是圆锥的半径不是距光源距离那个半径)。对于每个片段,我们会计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话,我们就会相应地照亮片段。下面这张图会让你明白聚光是如何工作的:
![](../img/02/05/light_casters_spotlight_angles.png)
* `LightDir`:从片段指向光源的向量。
* `SpotDir`:聚光所指向的方向。
* `Phi`\(\phi\)定义聚光半径的切光角。每个落在这个角度之外的,聚光都不会照亮。
* `Theta`\(\theta\)`LightDir`向量和`SpotDir`向量之间的角度。\(\theta\)值应该比\(\Phi\)值小,这样才会在聚光内
- `LightDir`:从片段指向光源的向量。
- `SpotDir`:聚光所指向的方向。
- `Phi`\(\phi\)指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。
- `Theta`\(\theta\)<var>LightDir</var>向量和<var>SpotDir</var>向量之间的夹角。在聚光内部的话\(\theta\)值应该比\(\phi\)值小。
所以我们大致要做的是,计算`LightDir`向量和`SpotDir`向量的点乘(返回两个单位向量的点乘,还记得吗?),然后在和切光角\(\phi\)对比。现在应该明白聚光是我们下面将创建的手电筒的范例
所以我们要做的就是计算<var>LightDir</var>向量和<var>SpotDir</var>向量之间的点积(还记得它会返回两个单位向量夹角的余弦值吗?),并将它与切光角\(\phi\)对比。现在应该了解聚光究竟是什么了,下面我们将以手电筒的形式创建一个聚光
## 手电筒
手电筒(Flashlight)是一个坐落在观察者位置的聚光,通常瞄准玩家透视图的前面。基本上说,一个手电筒是一个普通的聚光,但是根据玩家的位置和方向持续的更新它的位置和方向
手电筒(Flashlight)是一个位于观察者位置的聚光,通常它都会瞄准玩家视角的正前方。基本上说,手电筒是普通的聚光,但的位置和方向会随着玩家的位置和朝向不断更新
所以我们需要为片段着色器提供的值,是聚光的位置向量(来计算光的方向坐标),聚光的方向向量和切光角。我们可以把这些值储存在`Light`结构体中:
所以,在片段着色器中我们需要的值有聚光的位置向量(来计算光的方向向量)、聚光的方向向量和一个切光角。我们可以将它们储存在<fun>Light</fun>结构体中:
```c++
struct Light
{
vec3 position;
vec3 direction;
struct Light {
vec3 position;
vec3 direction;
float cutOff;
...
};
```
下面我们把这些适当的值传着色器:
接下来我们将合适的值传着色器
```c++
glUniform3f(lightPosLoc, camera.Position.x, camera.Position.y, camera.Position.z);
glUniform3f(lightSpotdirLoc, camera.Front.x, camera.Front.y, camera.Front.z);
glUniform1f(lightSpotCutOffLoc, glm::cos(glm::radians(12.5f)));
lightingShader.setVec3("light.position", camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));
```
你可以看到,我们切光角设置一个角度,但是我们根据一个角度计算了余弦值,把这个余弦结果传给了片段着色器。这做的原因是在片段着色器中,我们计算`LightDir`和`SpotDir`向量的点乘,而点乘返回一个余弦值不是一个角度,所以我们不能直接把一个角度和余弦值对比。为了获得这个角度我们必须计算点结果的反余弦,这个操作开销很大的。所以为了节约一性能,我们先计算给定切光角的余弦值,然后把结果传递给片段着色器。由于个角度都被表示为余弦了,我们可以直接对它们而不用进行任何开销高昂的操作
你可以看到,我们并没有给切光角设置一个角度值,反而是用角度计算了一个余弦值,余弦结果传递到片段着色器。这做的原因是在片段着色器中,我们计算`LightDir`和`SpotDir`向量的点积,这个点积返回的将是一个余弦值不是角度,所以我们不能直接使用角度和余弦值进行比较。为了获角度我们需要计算点结果的反余弦,这是一个开销很大的计算。所以为了节约一性能开销,我们将会计算切光角对应的余弦值,并将它的结果传片段着色器。由于这两个角度现在都由余弦角来表示了,我们可以直接对它们进行比较而不用进行任何开销高昂的计算
现在剩下要做的是计算\(\theta\)值,用它和\(\phi\)对比,决定我们是否在或不在聚光的内部:
接下来就是计算\(\theta\)值,并将它和切光角\(\phi\)对比,决定是否在聚光的内部:
```c++
float theta = dot(lightDir, normalize(-light.direction));
if(theta > light.cutOff)
{
// 执行光照计算
if(theta > light.cutOff)
{
// 执行光照计算
}
else // 否则使用环境光,使得场景不至于完全黑暗
color = vec4(light.ambient*vec3(texture(material.diffuse,TexCoords)), 1.0f);
else // 否则使用环境光,让场景在聚光之外时不至于完全黑暗
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);
```
我们首先计算`lightDir`和取反的`direction`向量的点乘(它是取反过的因为我们想向量指向光源而不是从光源作为指向出发点。译注:前面的`specular`教程中作者却用了相反的表示方法,这里读者可以选择喜欢的表达方式)。确保对所有相关向量进行了标准化处理
我们首先计算了<var>lightDir</var>和取反的<var>direction</var>向量(取反的是因为我们想向量指向光源而不是从光源出发)之间的点积。记住要对所有相关向量标准化。
!!! Important
你可能奇怪为什么if条件中使用>符号而不是<符号。为了在聚光以内,`theta`不是应该比光的切光更小吗?这没错,但不要忘了,角度值是以余弦值来表示的一个0度的角表示1.0的余弦值,一个角是90度的时候被表示为0.0的余弦值,你可以在这里看到:
你可能奇怪为什么if条件中使用的是 > 符号而不是 < 符号。<var>theta</var>不应该比光的切光更小才是在聚光内部吗?这并没有错,但不要忘角度值现在都由余弦值来表示的一个0度的角表示的是1.0的余弦值,一个90度的角度表示的是0.0的余弦值,你可以在下图中看到:
![](../img/02/05/light_casters_cos.png)
现在可以看到,余弦越接近1.0,角度就越小。这就解释了为什么θ需要比切光值更大了。切光值当前被设置为12.5的余弦,等于0.9978,所以θ的余弦值在0.99791.0之间,片段在聚光内,被照亮。
现在可以看到,余弦越接近1.0它的角度就越小。这就解释了为什么<var>theta</var>要比切光值更大了。切光值目前设置为12.5的余弦,等于0.9978所以在0.99791.0内的<var>theta</var>值才能保证片段在聚光内,从而被照亮。
运行应用,在聚光内的片段才会被照亮。这看起来像这样:
运行程序,你将会看到一个聚光,它仅会照亮聚光圆锥内的片段。看起来像这样
![](../img/02/05/light_casters_spotlight_hard.png)
你可以在这里获得[全部源码](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_spotlight_hard)和[片段着色器的源码](http://learnopengl.com/code_viewer.php?code=lighting/light_casters_spotlight_hard&type=fragment)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/5.3.light_casters_spot/light_casters_spot.cpp)获得全部源码
它看起来仍然有点假,原因是聚光有了一个硬边。片段着色器一旦到达了聚光圆锥边缘,它就立刻黑了下来,却没有任何平滑减弱的过。一个真实的聚光的光会在它的边界处平滑减弱的
但这仍看起来有些假,主要是因为聚光有一圈硬边。当一个片段遇到聚光圆锥边缘,它会完全变暗,没有一点平滑的过。一个真实的聚光将会在边缘处逐渐减少亮度
## 平滑/软化边缘
为创建聚光的平滑边,我们希望去模拟聚光有一个内圆锥和外圆锥。我们可以内圆锥设置为前面部分定义的圆锥,我们希望外圆锥从内边到外边逐步的变暗
创建一种看起来边缘平滑的聚光,我们需要模拟聚光有一个<def>内</def>圆锥(Inner Cone)和一个<def>外</def>圆锥(Outer Cone)。我们可以内圆锥设置为上一部分中的那个圆锥,我们也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界
为创建外圆锥,我们简单定义一个余弦值,它代表聚光方向向量和外圆锥向量(等于它的半径)的角。然后,如果片段在内圆锥和外圆锥之间,会给它计算出一个0.0到1.0之间的亮度。如果片段在内圆锥以内这个亮度就等于1.0,如果在外就是0.0。
创建一个外圆锥,我们只需要再定义一个余弦值代表聚光方向向量和外圆锥向量(等于它的半径)的角。然后,如果一个片段处于内外圆锥之间,会给它计算出一个0.0到1.0之间的强度值。如果片段在内圆锥之内,它的强度就是1.0,如果在外圆锥之外强度值就是0.0。
我们可以使用下面公式计算这样的值:
我们可以用下面这个公式计算这值:
$$
\begin{equation} I = \frac{\theta - \gamma}{\epsilon} \end{equation}
$$
这里\(\epsilon\)是内\(\phi\))和外圆锥(\(\gamma\)\epsilon = \phi - \gamma)的差。结果\(I\)的值是聚光在当前片段的亮度。
这里\(\epsilon\)(Epsilon)是内(\(\phi\))和外圆锥(\(\gamma\)之间的余弦值差(\(\epsilon = \phi - \gamma\))。最终的\(I\)值就是在当前片段聚光的强度。
很难用图画描述出这个公式是怎工作的,所以我们尝试使用一个例子
很难去表现这个公式是怎工作的,所以我们用一些实例值来看看
\(\theta\)|\(\theta\)(角度)|\(\phi\)(内切)|\(\phi\)(角度)|\(\gamma\)(外切)|\(\gamma\)(角度)|\(\epsilon\)|\(I\)
\(\theta\)|\(\theta\)(角度)|\(\phi\)(内光切)|\(\phi\)(角度)|\(\gamma\)(外光切)|\(\gamma\)(角度)|\(\epsilon\)|\(I\)
--|---|---|---|---|---|---|---
0.87|30|0.91|25|0.82|35|0.91 - 0.82 = 0.09|0.87 - 0.82 / 0.09 = 0.56
0.9|26|0.91|25|0.82|35|0.91 - 0.82 = 0.09|0.9 - 0.82 / 0.09 = 0.89
@@ -291,31 +288,31 @@ $$
0.64|50|0.91|25|0.82|35|0.91 - 0.82 = 0.09|0.64 - 0.82 / 0.09 = -2.0
0.966|15|0.9978|12.5|0.953|17.5|0.966 - 0.953 = 0.0448|0.966 - 0.953 / 0.0448 = 0.29
就像你看到的那样我们基本是根据θ在外余弦和内余弦之间插值。如果你仍不明白怎么继续,不担心。你可以简单的使用这个公式计算,当你更加老道和明白的时候再看。
你可以看到,我们基本是在内外余弦值之间根据\(\theta\)插值。如果你仍不明白发生了什么,不担心,只需要记住这个公式就好了,在你更聪明的时候再回来看看。
由于我们现在有了一个亮度值,当在聚光外的时候是个负的,在内圆锥内大于1。如果我们适当地把这个值固定,我们在片段着色器中就再不需要if-else了我们可以简单地用计算出的亮度值乘以光的元素
我们现在有了一个在聚光外是负的在内圆锥内大于1.0的,在边缘处于两者之间的强度值了。如果我们正确地约束(Clamp)这个值,在片段着色器中就不再需要`if-else`了,我们能够使用计算出来的强度值直接乘以光照分量
```c++
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon,0.0, 1.0);
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
...
// Well leave ambient unaffected so we always have a little
light.diffuse* = intensity;
specular* = intensity;
// 将不对环境光做出影响,让它总是能有一点光
diffuse *= intensity;
specular *= intensity;
...
```
注意我们使用了`clamp`函数,它把第一个参数固定在0.01.0之间。这保证了亮度值不会超出[0, 1]外。
注意我们使用了<fun>clamp</fun>函数,它把第一个参数约束(Clamp)在了0.01.0之间。这保证度值不会[0, 1]区间之外。
确定你把`outerCutOff`值添加到了`Light`结构体,并在应用中设置它的uniform值。对于下面的图片,内部切光角`12.5f`,外切光角是`17.5f`
确定你<var>outerCutOff</var>值添加到了<fun>Light</fun>结构体之中,并在程序中设置它的uniform值。下面的图片中,我们使用的内切光角12.5外切光角是17.5
![](../img/02/05/light_casters_spotlight.png)
看起来好多了。仔细看看内部和外部切光角,尝试创建一个符合你求的聚光。可以在这里找到应用源码,以及片段的源码。
啊,这样看起来好多了。稍微对内外切光角实验一下,尝试创建一个更能符合你求的聚光。可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/5.4.light_casters_spot_soft/light_casters_spot_soft.cpp)找到程序的源码。
这样的一个手电筒/聚光类型的灯光非常适合恐怖游戏,结合定向和点光,环境会真的开始被照亮了。[下一节](06 Multiple lights.md)中,我们会结合所有我们目前讨论了的光和技巧。
这样的手电筒/聚光类型的灯光非常适合恐怖游戏,结合定向和点光,环境会开始被照亮了。[下一节](06 Multiple lights.md)的教程中,我们会结合我们至今讨论的所有光照和技巧。
## 练习
- 试着修改上面的各种不同种类的光源及其片段着色器。试着将部分矢量进行反向并尝试使用 < 来代替 > 。试着解释这些修改导致不同显示效果的原因。
- 尝试实验一下上面的所有光照类型和它们的片段着色器。试着对一些向量进行取反,并使用 < 来代替 >。试着解释不同视觉效果产生的原因。

View File

@@ -3,48 +3,48 @@
原文 | [Multiple lights](http://learnopengl.com/#!Lighting/Multiple-lights)
---|---
作者 | JoeyDeVries
翻译 | [Geequlim](http://geequlim.com)
校对 | [Geequlim](http://geequlim.com)
翻译 | [Geequlim](http://geequlim.com), Meow J
校对 | 暂未校对
我们在前面的教程中已经学习了许多关于OpenGL 光照的知识,其中包括冯氏照明模型(Phong shading)、光照材质Materials、光照图(Lighting maps以及各种投光物Light casters。本教程将结合上述所学的知识,创建一个包含六个光源的场景。我们将模拟一个类似阳光的平行光(Directional light和4个定点光Point lights以及一个手电筒(Flashlight).
我们在前面的教程中已经学习了许多关于OpenGL光照的知识,其中包括冯氏着色(Phong Shading)、材质(Material)、光照贴图(Lighting Map)以及不同种类的投光物(Light Caster)。在这一节中,我们将结合之前学过的所有知识,创建一个包含六个光源的场景。我们将模拟一个类似太阳的定向光(Directional Light)光源,四个分散在场景中的点光源(Point Light)以及一个手电筒(Flashlight)
在场景中使用多光源我们需要封装一些GLSL函数用来计算光照。如果我们对每个光源都去写一遍光照计算的代码这将是一件令人恶心的事情并且这些放在main函数中的代码将难以理解所以我们将一些操作封装为函数
为了在场景中使用多光源我们希望将光照计算封装到GLSL<def>函数</def>中。这样做的原因是,每一种光源都需要一种不同的计算方法,而一旦我们想对多个光源进行光照计算时,代码很快就会变得非常复杂。如果我们只在<fun>main</fun>函数中进行所有的这些计算,代码很快就会变得难以理解
GLSL中的函数与C语言的非常相似,它需要一个函数名、一个返回值类型。并且在调用前必须提前声明。接下来我们将为下面的每一种光照来写一个函数
GLSL中的函数和C函数很相似,它一个函数名、一个返回值类型如果函数不是在main函数之前声明的我们还必须在代码文件顶部声明一个原型。我们对每个光照类型都创建一个不同的函数定向光、点光源和聚光
当我们在场景中使用多个光源时一般使用以下途径:创建一个代表输出颜色的向量。每一个光源都对输出颜色贡献一些颜色。因此,场景中的每个光源将进行独立运算,并且运算结果都对最终的输出颜色有一定影响。下面是使用这种方式进行多光源运算的一般结构
当我们在场景中使用多个光源时,通常使用以下方法:我们需要有一个单独的颜色向量代表片段的输出颜色。对于每一个光源,它对片段的贡献颜色将会加到片段的输出颜色向量上。所以场景中的每个光源都会计算它们各自对片段的影响,并结合为一个最终的输出颜色。大体的结构会像是这样
```c++
out vec4 color;
out vec4 FragColor;
void main()
{
// 定义输出颜色
// 定义一个输出颜色
vec3 output;
// 将平行光的运算结果颜色添加到输出颜色
// 将定向光的贡献加到输出
output += someFunctionToCalculateDirectionalLight();
// 同样,将定点光的运算结果颜色添加到输出颜色
// 对所有的点光源也做相同的事情
for(int i = 0; i < nr_of_point_lights; i++)
output += someFunctionToCalculatePointLight();
// 添加其他光源的计算结果颜色(如投射光)
// 也加上其它的光源(比如聚光)
output += someFunctionToCalculateSpotLight();
color = vec4(output, 1.0);
}
FragColor = vec4(output, 1.0);
}
```
即使对每一种光源的运算实现不同,但此算法的结构一般是与上述出入不大的。我们定义几个用于计算个光源的函数,并将这些函数的结算结果(返回颜色)添加到输出颜色向量。例如,靠近被照射物体的光源计算结果将返回比远离背照射物体的光源更明亮的颜色
实际的代码对每一种实现都可能不同,但大体的结构都是差不多的。我们定义几个函数,用来计算个光源的影响,并将最终的结果颜色加到输出颜色向量。例如,如果两个光源都很靠近一个片段,那么它们所结合的贡献将会形成一个比单个光源照亮时更加明亮的片段
## 平行
## 定向
要在片段着色器中定义一个函数来计算平行光(Directional light)在对应的照射点上的光照颜色,这个函数需要几个参数并返回一个计算平行光照结果的颜色。
么需要在片段着色器中定义一个函数来计算定向光对相应片段的贡献:它接受一些参数并计算一个定向光照颜色。
首先我们需要设置一系列用于表示平行光的变量,正如上一节中所讲过的,我们可以将这些变量定义在一个叫做**DirLight**的结构体中,并定义一个这个结构体类型的uniform变量。
首先我们需要定义一个定向光源最少所需要的变量。我们可以将这些变量储存在一个叫做<fun>DirLight</fun>的结构体中,并将它定义一个uniform。需要的变量在[上一节](05 Light casters.md)中都介绍过:
```c++
struct DirLight {
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
@@ -52,84 +52,85 @@ struct DirLight {
uniform DirLight dirLight;
```
之后我们可以将`dirLight`这个uniform变量作为下面这个函数原型的数。
接下来我们可以将<var>dirLight</var>传入一个有着一下原型的数。
```c++
vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir);
vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir);
```
!!! Important
和C/C++一样,我们调用一个函数的前提是这个函数在调用前已经被声明过此例中我们是在main函数中调用。通常情况下我们都将函数定义在main函数之后为了能在main函数中调用这些函数我们就必须在main函数之前声明这些函数的原型,这就和我们写C语言是一样的。
已经知道,这个函数需要一个`DirLight`和两个其他的向量作为参数来计算光照。如果你看过之前的教程的话,你会觉得下面的函数定义得一点也不意外
和C/C++一样,如果我们调用一个函数(这里是在<fun>main</fun>函数中调用),这个函数需要在调用者的行数之前被定义过。在这个例子中我们更喜欢在<fun>main</fun>函数以下定义函数,所以上面要求就不满足了。所以,我们需要在<fun>main</fun>函数之上定义函数的原型,这C语言是一样的。
可以看到,这个函数需要一个<fun>DirLight</fun>结构体和其它两个向量来进行计算。如果你认真完成了上一节的话,这个函数的内容应该理解起来很容易
```c++
vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir)
{
vec3 lightDir = normalize(-light.direction);
// 计算漫反射强度
// 漫反射着色
float diff = max(dot(normal, lightDir), 0.0);
// 计算镜面反射强度
// 镜面光着色
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 合并各个光照分量
// 合并结果
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
return (ambient + diffuse + specular);
}
}
```
我们从之前的教程中复制了代码,并用两个向量来作为函数参数来计算出平行光的光照颜色向量,该结果是一个由该平行光的环境反射、漫反射和镜面反射的各个分量组成的一个向量
我们基本上只是从上一节中复制了代码,并使用函数参数的两个向量来计算定向光的贡献向量。最终环境光、漫反射和镜面光的贡献将会合并为单个颜色向量返回
## 点光源
计算平行光一样,我们同样需要定义一个函数用于计算点光源(Point Light)。同样,我们定义一个包含点光源所需属性的结构体:
定向光一样,我们也希望定义一个用于计算点光源对相应片段贡献,以及衰减,的函数。同样,我们定义一个包含点光源所需所有变量的结构体:
```c++
struct PointLight {
vec3 position;
float constant;
float linear;
float quadratic;
float quadratic;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
#define NR_POINT_LIGHTS 4
#define NR_POINT_LIGHTS 4
uniform PointLight pointLights[NR_POINT_LIGHTS];
```
如你所见我们在GLSL中使用预处理指令来定义点光源的数目。之后我们使用这个`NR_POINT_LIGHTS`常量来创建一个`PointLight`结构体的数组。和C语言一样GLSL也是用一对括号来创建数组的。现在我们有了4个`PointLight`结构体对象了
你可以看到我们在GLSL中使用预处理指令来定义了我们场景中点光源的数量。接着我们使用这个<var>NR_POINT_LIGHTS</var>常量来创建一个<fun>PointLight</fun>结构体的数组。GLSL中的数组和C数组一样可以使用一对括号来创建。现在我们有四个待填充数据的<fun>PointLight</fun>结构体
!!! Important
我们同样可以简单粗暴地定义一个大号的结构体(而不是为每种类型的光源定义一个结构体),包含所有类型光源所需要属性变量。并且将这个结构体应用与所有的光照计算函数,在各个光照结算时忽略不需要的属性变量。然而,我个人来说更喜欢分开定义,这样可以省下一些内存,因为定义一个大号的光源结构体在计算过程中会有用不到的变量
我们也可以定义**一个**大的结构体(而不是为每种类型的光源定义不同的结构体),包含**所有**不同种光照类型所需的变量,并将这个结构体用到所有的函数中,只需要忽略用不到的变量就行了。然而,我个人觉得当前的方法会更直观一点,不仅能够节省一些代码,而且由于不是所有光照类型都需要所有的变量,这样也能节省一些内存
点光源函数的原型如下:
```c++
vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir);
vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir);
```
这个函数将所有用得到的数据作为它的参数并使用一个`vec3`作为它的返回值类表示一个顶点光的结算结果。我们再一次聪明地从之前的教程中复制代码来把它定义成下面的样子
这个函数从参数中获取所需的所有数据,并返回一个代表该点光源对片段的颜色贡献的`vec3`。我们再一次聪明地从之前的教程中复制粘贴代码,完成了下面这样的函数
```c++
// 计算定点光在确定位置的光照颜色
vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
vec3 lightDir = normalize(light.position - fragPos);
// 计算漫反射强度
// 漫反射着色
float diff = max(dot(normal, lightDir), 0.0);
// 计算镜面反射
// 镜面光着色
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 计算衰减
// 衰减
float distance = length(light.position - fragPos);
float attenuation = 1.0f / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
// 将各个分量合并
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
// 合并结果
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
@@ -140,44 +141,44 @@ vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
}
```
有了这个函数我们就可以在main函数中调用它来代替写很多个计算点光源的代码了。通过循环调用此函数就能实现同样的效果当然代码更简洁
将这些功能抽象到这样一个函数中的优点是,我们能够不用重复的代码而很容易地计算多个点光源的光照了。在<fun>main</fun>函数中,我们只需要创建一个循环,遍历整个点光源数组,对每个点光源调用<fun>CalcPointLight</fun>就可以了
## 把它们放到一起
## 合并结果
我们现在定义了用于计算平行光和点光源的函数,现在我们把这些代码放到一起,写入文开始的一般结构中:
现在我们已经定义了一个计算定向光的函数和一个计算点光源的函数了,我们可以将它们合并放到<fun>main</fun>函数中。
```c++
void main()
{
// 一些属性
// 属性
vec3 norm = normalize(Normal);
vec3 viewDir = normalize(viewPos - FragPos);
// 第一步,计算平行光照
// 第一阶段:定向光照
vec3 result = CalcDirLight(dirLight, norm, viewDir);
// 第二步,计算顶点光
// 第二阶段:点光
for(int i = 0; i < NR_POINT_LIGHTS; i++)
result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);
// 第三部,计算 Spot light
//result += CalcSpotLight(spotLight, norm, FragPos, viewDir);
color = vec4(result, 1.0);
result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);
// 第三阶段:聚光
//result += CalcSpotLight(spotLight, norm, FragPos, viewDir);
FragColor = vec4(result, 1.0);
}
```
个光源的运算结果都添加到了输出颜色上,输出颜色包含了场景中所有光源的影响。如果你想实现手电筒的光照效果,同样的把计算结果添加到输出颜色。我在这里就把`CalcSpotLight`的实现留作个读者们的练习
每个光源类型都将它们的贡献加到了最终的输出颜色上,直到所有的光源都处理完了。最终的颜色包含了场景中所有光源的颜色影响所合并的结果。如果你想的话,你也可以实现一个聚光,并将它的效果加到输出颜色。我们会将<fun>CalcSpotLight</fun>函数留给读者作为练习。
设置平行光结构体的uniform值和之前所讲过的方式没什么两样,但是你可能想知道如何设置场景中`PointLight`结构体的uniforms变量数组。我们之前并未讨论过如何做这件事
设置定向光结构体的uniform应该非常熟悉了但是你可能会在想我们该如何设置点光源的uniform值因为点光源的uniform现在是一个<fun>PointLight</fun>的数组了。这并不是我们以前讨论过的话题
庆幸的是,这并不是什么难题。设置uniform变量数组和设置单个uniform变量值是相似的只需要用一个合适的下标就能够检索到数组中我们想要的uniform变量了。
很幸运的是,这并不是很复杂设置一个结构体数组的uniform和设置一个结构体的uniform是很相似的但是这一次在访问uniform位置的时候我们需要定义对应的数组下标值
```c++
glUniform1f(glGetUniformLocation(lightingShader.Program, "pointLights[0].constant"), 1.0f);
lightingShader.setFloat("pointLights[0].constant", 1.0f);
```
这样我们检索到`pointLights`数组中的第一个`PointLight`结构体元素,同时也可以获取到该结构体中的各个属性变量。不幸的是这一位置我们还需要手动对这四个光源的每一个属性都进行设置这样手动设置这28个uniform变量是相当乏味的工作。你可以尝试去定义个光源类来为你设置这些uniform属性来减少你的工作,但这依旧不能改变去设置每个uniform属性的事实
在这里我们索引了<var>pointLights</var>数组中的第一个<fun>PointLight</fun>,并获取了<var>constant</var>变量的位置。但这也意味着不幸的是我们必须对这四个光源手动设置uniform值这让点光源本身就产生了28个uniform调用非常冗长。你可以尝试将这些抽象出去一点,定义一个点光源类,让它来为你设置uniform值,但最后你仍然要用这种方式设置所有光源的uniform
别忘了,我们还需要为每个光源设置它们的位置。这里,我们定义一个`glm::vec3`类的数组来包含这些点光源的坐标
别忘了,我们还需要为每个光源定义一个位置向量,所以我们让它们在场景中分散一点。我们定义一个`glm::vec3`数组来包含点光源的位置
```c++
glm::vec3 pointLightPositions[] = {
@@ -185,24 +186,27 @@ glm::vec3 pointLightPositions[] = {
glm::vec3( 2.3f, -3.3f, -4.0f),
glm::vec3(-4.0f, 2.0f, -12.0f),
glm::vec3( 0.0f, 0.0f, -3.0f)
};
};
```
同时我们还需要根据这些光源的位置在场景中绘制4个表示光源的立方体这样的工作我们之前的教程中已经做过了
接下来我们从<var>pointLights</var>数组中索引对应的<fun>PointLight</fun>,将它的<var>position</var>值设置为刚刚定义的位置值数组中的其中一个。同时我们还要保证现在绘制的是四个灯立方体而不是仅仅一个。只要对每个灯物体创建一个不同的模型矩阵就可以了,和我们之前对箱子的处理类似
如果你在还是用了手电筒的话,所有光源结合起来看上去应该和下图差不多:
如果你还使用了手电筒的话,所有光源组合的效果将看起来和下图差不多:
![](../img/02/06/multiple_lights_combined.png)
你可以在此处获取本教程的[源代码](http://learnopengl.com/code_viewer.php?code=lighting/multiple_lights),同时可以查看[顶点着色器](http://learnopengl.com/code_viewer.php?code=lighting/lighting_maps&type=vertex)和[片段着色器](http://learnopengl.com/code_viewer.php?code=lighting/multiple_lights&type=fragment)的代码。
你可以看到,很显然天空中有一个全局照明(像一个太阳),我们有四个光源分散在场景中,以及玩家视角的手电筒。看起来是不是非常不错?
上面的图片的光源都是使用默认的属性的效果,如果你尝试对光源属性做出各种修改尝试的话,会出现很多有意思的画面。很多艺术家和场景编辑器都提供大量的按钮或方式来修改光照以使用各种环境。使用最简单的光照属性的改变我们就足已创建有趣的视觉效果:
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/2.lighting/6.multiple_lights/multiple_lights.cpp)找到最终程序的源代码。
上面图片中的所有光源都是使用上一节中所使用的默认属性,但如果你愿意实验这些数值的话,你能够得到很多有意思的结果。艺术家和关卡设计师通常都在编辑器中不断的调整这些光照参数,保证光照与环境相匹配。在我们刚刚创建的简单光照环境中,你可以简单地调整一下光源的属性,创建很多有意思的视觉效果:
![](../img/02/06/multiple_lights_atmospheres.png)
相信你现在已经对OpenGL的光照有很好的理解了。有了这些知识我们便可以创建丰富有趣的环境和氛围了。快试试改变所有的属性的值来创建你的光照环境吧
我们也改变了清屏的颜色来更好地反应光照。你可以看到,只需要简单地调整一些光照参数,你就能创建完全不同的氛围。
相信你现在已经对OpenGL的光照有很好的理解了。有了目前所学的这些知识我们已经可以创建出丰富有趣的环境和氛围了。尝试实验一下不同的值创建出你自己的氛围吧。
## 练习
* 创建一个表示手电筒光的结构体Spotlight并实现CalcSpotLight(...)函数:[解决方案](http://learnopengl.com/code_viewer.php?code=lighting/multiple_lights-exercise1)
* 你能通过调节不同的光照属性来重新创建一个不同的氛围吗?[解决方案](http://learnopengl.com/code_viewer.php?code=lighting/multiple_lights-exercise2)
- 你能通过调节光照属性变量,(大概地)重现最后一张图片上不同的氛围吗?[参考解答](https://learnopengl.com/code_viewer.php?code=lighting/multiple_lights-exercise2)

View File

@@ -4,39 +4,33 @@
---|---
作者 | JoeyDeVries
翻译 | Meow J
校对 | [Geequlim](http://geequlim.com)
校对 | [Geequlim](http://geequlim.com), Meow J
恭喜您已经学习到了这个地方!辛苦啦!不知道你有没有注意到,总的来说我们在学习光照教程的时候学习的并不是OpenGL本身,当然我们仍然学习了一些细枝末节的知识(像访问uniform数组)
恭喜您已经学习到了这个地方!辛苦啦!不知道你有没有注意到,总的来说我们在学习光照教程的时候关于OpenGL本身并没有什么新东西除了想访问uniform数组这样细枝末节的知识。目前为止的所有教程都是关于使用一些技巧或者公式来操作着色器达到真实的光照效果。这再一次想你展示了着色器的威力。着色器是非常灵活的你也亲眼见证了我们仅仅使用一些3D向量和可配置的变量就能够创造出惊人的图像这一点
到现在的所有教程都是关于用一些技巧和公式来操作着色器从而达到真实的光照效果。这同样向你展示了着色器的威力
在前面的几个教程中,你学习了颜色、冯氏光照模型(包括环境光照、漫反射光照和镜面光照)、物体的材质、可配置的光照属性、漫反射和镜面光贴图、不同种类的光,并且学习了怎样将所有所学知识融会贯通,合并到一个程序当中。记得去实验一下不同的光照、材质颜色、光照属性,并且试着利用你无穷的创造力创建自己的环境
着色器是非常灵活的你也亲眼见证了我们仅仅使用一些3D向量和可配置的变量就能够创造出惊人的图形这一点
在你学过的最后几个教程中,你学习了有关颜色,冯氏光照模型(包括环境,漫反射,镜面反射光照),对象材质,可配置的光照属性,漫反射和镜面反射贴图,不同种类的光,并且学习了怎样将所有所学知识融会贯通。
记得去实验一下不同的光照,材质颜色,光照属性,并且试着利用你无穷的创造力创建自己的环境。
在[下一个教程](../03 Model Loading/01 Assimp.md)当中,我们将加入更高级的形状到我们的场景中,这些形状将会在我们之前讨论过的光照模型中非常好看。
在[下一节](../03 Model Loading/01 Assimp.md)当中,我们在我们的场景当中加入更高级的形状,这些形状将会在我们之前讨论过的光照模型中非常好看
## 词汇表
- **颜色向量(Color Vector)**:一个通过红绿蓝(RGB)分量的组合描绘大部分真实颜色的向量。一个对象的颜色实际上是该对象不能吸收的反射颜色分量。
- **冯氏光照模型(Phong Lighting Model)**:一个通过计算环境,漫反射,和镜面反射分量的值来估计真实光照的模型。
- **环境光照(Ambient Lighting)**:通过给每个没有被光照的物体很小的亮度,使其不是完全黑暗的,从而对全局光照估计。
- **漫反射着色(Diffuse Shading)**光照随着更多的顶点/片段排列在光源上变强。该方法使用了法向量来计算角度。
- **颜色向量(Color Vector)**:一个通过红绿蓝(RGB)分量的组合描绘大部分真实颜色的向量。一个物体的颜色实际上是该物体所不能吸收的反射颜色分量。
- **冯氏光照模型(Phong Lighting Model)**:一个通过计算环境,漫反射,和镜面分量的值来估计真实光照的模型。
- **环境光照(Ambient Lighting)**:通过给每个没有被光照的物体很小的亮度,使其不是完全黑暗的,从而对全局光照进行估计。
- **漫反射着色(Diffuse Shading)**一个顶点/片段与光线方向越接近,光照会越强。使用了法向量来计算角度。
- **法向量(Normal Vector)**:一个垂直于平面的单位向量。
- **正规矩阵(Normal Matrix)**一个3x3矩阵, 或者说是没有平移的模型(或者模型观察)矩阵。它也被以某种方式修改(逆转置)从而应用非统一缩放时保持法向量朝向正确的方向。否则法向量会在使用非统一缩放时失真
- **法线矩阵(Normal Matrix)**一个3x3矩阵或者说是没有平移的模型或者模型-观察矩阵。它也被以某种方式修改逆转置从而应用非统一缩放时保持法向量朝向正确的方向。否则法向量会在使用非统一缩放时被扭曲
- **镜面光照(Specular Lighting)**:当观察者视线靠近光源在表面的反射线时会显示的镜面高光。镜面光照是由观察者的方向,光源的方向和设定高光分散量的反光度值三个量共同决定的。
- **冯氏着色(Phong Shading)**:冯氏光照模型应用在片段着色器。
- **高氏着色(Gouraud shading)**:冯氏光照模型应用在顶点着色器上。在使用很少数量的顶点时会产生明显的瑕疵。会得到效率提升但是损失了视觉质量。
- **冯氏着色(Phong Shading)**:冯氏光照模型应用在片段着色器。
- **Gouraud着色(Gouraud shading)**:冯氏光照模型应用在顶点着色器上。在使用很少数量的顶点时会产生明显的瑕疵。会得到效率提升但是损失了视觉质量。
- **GLSL结构体(GLSL struct)**一个类似于C的结构体用作着色器变量的容器。大部分时间用来管理输入/输出/uniform。
- **材质(Material)**:一个物体反射的环境,漫反射,镜面反射光照。这些东西设定了物体的颜色。
- **光照(性质)(Light(properties))**:一个光的环境,漫反射,镜面反射的强度。可以用任何颜色值对每一个冯氏分量(Phong Component)定义一个光源闪烁的颜色/强度。
- **材质(Material)**:一个物体反射的环境,漫反射,镜面光颜色。这些东西设定了物体所拥有的颜色。
- **光照属性(Light(properties))**:一个光的环境,漫反射,镜面的强度。可以使用任何颜色值对每一个冯氏分量(Phong Component)定义光源发出的颜色/强度。
- **漫反射贴图(Diffuse Map)**:一个设定了每个片段中漫反射颜色的纹理图片。
- **镜面贴图(Specular Map)**:一个设定了每一个片段的镜面强度/颜色的纹理贴图。仅在物体的特定区域允许镜面高光。
- **平行光(Directional Light)**:只有一个方向的光源。它被建模为不管距离有多长所有光束都是平行而且其方向向量在整个场景中保持不变。
- **点光源(Point Light)**:一个场景中光线逐渐淡出的光源。
- **衰减(Attenuation)**:光减少强度的过程,通常使用在点光源和聚光下。
- **聚光(Spotlight)**:一个被定义为在某一个方向上锥形的光源。
- **镜面贴图(Specular Map)**:一个设定了每一个片段的镜面强度/颜色的纹理贴图。仅在物体的特定区域显示镜面高光。
- **定向光(Directional Light)**:只有一个方向的光源。它被建模为不管距离有多长所有光束都是平行而且其方向向量在整个场景中保持不变。
- **点光源(Point Light)**:一个场景中有位置的,光线逐渐衰减的光源。
- **衰减(Attenuation)**:光随着距离减少强度的过程,通常使用在点光源和聚光下。
- **聚光(Spotlight)**:一个被定义为在某一个方向上锥形的光源。
- **手电筒(Flashlight)**:一个摆放在观察者视角的聚光。
- **GLSL uniform数组(GLSL Uniform Array)**一个uniform值数组。它的工作原理和C语言数组大致一样只是不能动态分配内存。

View File

@@ -3,73 +3,69 @@
原文 | [Assimp](http://learnopengl.com/#!Model-Loading/Assimp)
---|---
作者 | JoeyDeVries
翻译 | Cocoonshu
校对 | [Geequlim](http://geequlim.com)
翻译 | Cocoonshu, Meow J, [Geequlim](http://geequlim.com)
校对 | 暂未校对
到目前为止的所有场景中,我们一直都在滥用我们的箱子朋友,但时间久了甚至是我们最好的朋友也会感到无聊。在日常的图形程序中,通常都会使用非常复杂且好玩的模型,它们比静态的箱子要好看多了。然而,和箱子对象不同,我们不太能够对像是房子、汽车或者人形角色这样的复杂形状手工定义所有的顶点、法线和纹理坐标。我们想要的是将这些模型(Model)**导入**(Import)到程序当中。模型通常都由3D艺术家在[Blender](http://www.blender.org/)、[3DS Max](http://www.autodesk.nl/products/3ds-max/overview)或者[Maya](http://www.autodesk.com/products/autodesk-maya/overview)这样的工具中精心制作。
到目前为止我们已经在所有的场景中大面积滥用了我们的容器盒小盆友但就是容器盒是我们的好朋友时间久了我们也会喜新厌旧。一些图形应用里经常会使用很多复杂且好玩儿的模型它们看起来比静态的容器盒可爱多了。但是我们无法像定义容器盒一样手动地去指定房子、货车或人形角色这些复杂模型的顶点、法线和纹理坐标。我们需要做的也是应该要做的是把这些模型导入到应用程序中而设计制作这些3D模型的工作应该交给像[Blender](http://www.blender.org/)、[3DS Max](http://www.autodesk.nl/products/3ds-max/overview)或者[Maya](http://www.autodesk.com/products/autodesk-maya/overview)这样的工具软件
这些所谓的<def>3D建模工具</def>(3D Modeling Tool)可以让艺术家创建复杂的形状,并使用一种叫做<def>UV映射</def>(uv-mapping)的手段来应用贴图。这些工具将会在导出到模型文件的时候自动生成所有的顶点坐标、顶点法线以及纹理坐标。这样子艺术家们即使不了解图形技术细节的情况下,也能拥有一套强大的工具来构建高品质的模型了。所有的技术细节都隐藏在了导出的模型文件中。但是,作为图形开发者,我们就**必须**要了解这些技术细节了
那些3D建模工具可以让美工们构建一些复杂的形状并将贴图应用到形状上去即纹理映射。然后在导出模型文件时,建模工具会自己生成所有的顶点坐标、顶点法线和纹理坐标。这样,美工们可以不用了解大量的图像技术细节,就能有大量的工具集去随心地构建高品质的模型。所有的技术细节内容都隐藏在里导出的模型文件里。而我们,这些图形开发者,就必须得去关注这些技术细节了
所以我们的工作就是解析这些导出的模型文件以及提取所有有用的信息将它们储存为OpenGL能够理解的格式。一个很常见的问题是模型文件格式有很多种,每一种都会以它们自己的方式来导出模型数据。像是[Wavefront的.obj](http://en.wikipedia.org/wiki/Wavefront_.obj_file)这样的模型格式,只包含了模型数据以及材质信息,像是模型颜色和漫反射/镜面光贴图。而以XML为基础的[Collada文件格式](http://en.wikipedia.org/wiki/COLLADA)则非常的丰富包含模型、光照、多种材质、动画数据、摄像机、完整的场景信息等等。Wavefront的.obj格式通常被认为是一个易于解析的模型格式。建议至少去Wavefront的wiki页面上看看文件格式的信息是如何封装的。这应该能让你认识到模型文件的基本结构
因此我们的工作就是去解析这些导出的模型文件并将其中的模型数据存储为OpenGL能够使用的数据。一个常见的问题是导出的模型文件通常有几十种格式不同的工具会根据不同的文件协议把模型数据导出到不同格式的模型文件中。有的模型文件格式只包含模型的静态形状数据和颜色、漫反射贴图、高光贴图这些基本的材质信息比如Wavefront的.obj文件。而有的模型文件则采用XML来记录数据且包含了丰富的模型、光照、各种材质、动画、摄像机信息和完整的场景信息等比如Collada文件格式。Wavefront的obj格式是为了考虑到通用性而设计的一种便于解析的模型格式。建议去Wavefront的Wiki上看看obj文件格式是如何封装的。这会给你形成一个对模型文件格式的一个基本概念和印象
总而言之,不同种类的文件格式有很多,它们之间通常并没有一个通用的结构。所以如果我们想从这些文件格式中导入模型的话,我们必须要去自己对每一种需要导入的文件格式写一个导入器。很幸运的是,正好有一个库专门处理这个问题
## 模型加载库
现在市面上有一个很流行的模型加载库叫做Assimp全称为Open Asset Import Library。Assimp可以导入几十种不同格式的模型文件(同样也可以导出部分模型格式)。只要Assimp加载完模型文件,我们就可以从Assimp上获取所有我们需要的模型数据。Assimp把不同的模型文件都转换为一个统一的数据结构所有无论我们导入何种格式的模型文件都可以用同一方式访问我们需要的模型数据。
当导入一个模型文件时即Assimp加载一整个包含所有模型和场景数据的模型文件到一个scene对象时Assimp会为这个模型文件中的所有场景节点、模型节点都生成一个具有对应关系的数据结构且将这些场景中的各种元素与模型数据对应起来。下图展示了一个简化的Assimp生成的模型文件数据结构
一个非常流行的模型导入库是[Assimp](http://assimp.org/),它是**Open Asset Import Library**(开放的资产导入库)的缩写。Assimp能够导入很多种不同的模型文件格式(并也能够导出部分格式)它会将所有的模型数据加载至Assimp的通用数据结构中。当Assimp加载完模型之后,我们就能够从Assimp的数据结构中提取我们所需的所有数据了。由于Assimp的数据结构保持不变不论导入的是什么种类的文件格式它都能够将我们从这些不同的文件格式中抽象出来用同一方式访问我们需要的数据。
当使用Assimp导入一个模型的时候它通常会将整个模型加载进一个**场景**(Scene)对象,它会包含导入的模型/场景中的所有数据。Assimp会将场景载入为一系列的节点(Node)每个节点包含了场景对象中所储存数据的索引每个节点都可以有任意数量的子节点。Assimp数据结构的简化模型如下
![](../img/03/01/assimp_structure.png)
- 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在<u>Scene</u>对象中。<u>Scene</u>对象也包含了场景根节点的引用。
- 场景的<u>Root node</u>(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中<var>mMeshes</var>数组中储存的网格数据的索引。<u>Scene</u>下的<var>mMeshes</var>数组储存了真正的<u>Mesh</u>对象,节点中的<var>mMeshes</var>数组保存的只是场景中网格数组的索引。
- 一个<u>Mesh</u>对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
- 一个网格包含了多个面。<u>Face</u>代表的是物体的渲染图元(Primitive)(三角形、方形、点)。一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的(见[你好,三角形](../01 Getting started/04 Hello Triangle.md))。
- 最后,一个网格也包含了一个<u>Material</u>对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)。
- 所有的模型、场景数据都包含在scene对象中如所有的材质和Mesh。同样场景的根节点引用也包含在这个scene对象中
- 场景的根节点可能也会包含很多子节点和一个指向保存模型点云数据mMeshes[]的索引集合。根节点上的mMeshes[]里保存了实际了Mesh对象而每个子节点上的mMesshes[]都只是指向根节点中的mMeshes[]的一个引用(译者注C/C++称为指针Java/C#称为引用)
- 一个Mesh对象本身包含渲染所需的所有相关数据比如顶点位置、法线向量、纹理坐标、面片及物体的材质
- 一个Mesh会包含多个面片。一个Face面片表示渲染中的一个最基本的形状单位即图元基本图元有点、线、三角面片、矩形面片。一个面片记录了一个图元的顶点索引通过这个索引可以在mMeshes[]中寻找到对应的顶点位置数据。顶点数据和索引分开存放可以便于我们使用缓存VBO、NBO、TBO、IBO来高速渲染物体。详见[Hello Triangle](http://www.learnopengl.com/#!Getting-started/Hello-Triangle)
- 一个Mesh还会包含一个Material材质对象用于指定物体的一些材质属性。如颜色、纹理贴图漫反射贴图、高光贴图等
所以我们要做的第一件事就是加载一个模型文件为scene对象然后获取每个节点对应的Mesh对象我们需要递归搜索每个节点的子节点来获取所有的节点并处理每个Mesh对象对应的顶点数据、索引以及它的材质属性。最终我们得到一个只包含我们需要的数据的Mesh集合。
所以,我们需要做的第一件事是将一个物体加载到<u>Scene</u>对象中,遍历节点,获取对应的<u>Mesh</u>对象(我们需要递归搜索每个节点的子节点),并处理每个<u>Mesh</u>对象来获取顶点数据、索引以及它的材质属性。最终的结果是一系列的网格数据,我们会将它们包含在一个`Model`对象中
!!! Important
**网格(Mesh)**
用建模工具构建物体时,美工通常不会直接使用单个形状来构建一个完整的模型。一般来说,一个模型会由几个子模型/形状组合拼接而成。而模型中的那些子模型/形状就是我们所说的一个网格。例如一个人形模型美工通常会把头、四肢、衣服、武器这些组件都分别构建出来然后在把所有的组件拼合在一起形成最终的完整模型。一个网格包含顶点、索引和材质属性是我们在OpenGL中绘制物体的最小单位。一个模型通常有多个网格组成。
**网格**
下一节教程中我们将用上述描述的数据结构来创建我们自己的Model类和Mesh类用于加载和保存那些导入的模型。如果我们想要绘制一个模型我们不会去渲染整个模型而是去渲染这个模型所包含的所有独立的Mesh。不管怎样我们开始导入模型之前我们需要先把Assimp导入到我们的工程中
当使用建模工具对物体建模的时候,艺术家通常不会用单个形状创建出整个模型。通常每个模型都由几个子模型/形状组合而成。组合模型的每个单独的形状就叫做一个<def>网格</def>(Mesh)。比如说有一个人形的角色艺术家通常会将头部、四肢、衣服、武器建模为分开的组件并将这些网格组合而成的结果表现为最终的模型。一个网格是我们在OpenGL中绘制物体所需的最小单位顶点数据、索引和材质属性。一个模型通常会包括多个网格
在[下一节](02 Mesh.md)中,我们将创建我们自己的<fun>Model</fun><fun>Mesh</fun>类来加载并使用刚刚介绍的结构储存导入后的模型。如果我们想要绘制一个模型我们不需要将整个模型渲染为一个整体只需要渲染组成模型的每个独立的网格就可以了。然而在我们开始导入模型之前我们首先需要将Assimp包含到我们的工程当中。
## 构建Assimp
你可以在[Assimp的下载页面](http://assimp.sourceforge.net/main_downloads.html)选择一个想要的版本去下载Assimp库。到目前为止Assimp可用的最新版本3.1.1。我们建议你自己编译Assimp库因为Assimp官方的已编译库不能很好地覆盖在所有平台上运行。如果你忘记怎样使用CMake编译一个库,请详见[Creating a window(创建一个窗口)](http://www.learnopengl.com/#!Getting-started/Creating-a-window)教程
你可以在Assimp的[下载页面](http://assimp.org/main_downloads.html)选择相应的版本。在写作时使用的Assimp最高版本3.1.1。我们建议你自己编译Assimp库因为它们的预编译库在大部分系统上都是不能运行。如果你忘记如何使用CMake自己编译一个库的话,可以复习[创建窗口](../01 Getting started/02 Creating a window.md)小节
这里我们列出一些编译Assimp时可能遇到的问题以便大家参考和排除:
构建Assimp时可能会出现一些问题所以我会将它们的解决方案列在这里便于大家排除错误
- CMake在读取配置列表时报出DirectX库丢失相关的一些错误。报错如下:
- CMake在读取配置列表时不断报出DirectX库丢失错误。报错如下:
```
Could not locate DirecX
Could not locate DirectX
CMake Error at cmake-modules/FindPkgMacros.cmake:110 (message):
Required library DirectX not found! Install the library (including dev packages) and try again. If the library is already installed, set the missing variables manually in cmake.
Required library DirectX not found! Install the library (including dev packages)
and try again. If the library is already installed, set the missing variables
manually in cmake.
```
这个问题的解决方案:如果你之前没有安装DirectX SDK那么请安装。下载地址:[DirectX SDK](http://www.microsoft.com/en-us/download/details.aspx?id=6812)。
这个问题的解决方案安装DirectX SDK如果你之前没安装过的话。你可以从[这里](http://www.microsoft.com/en-us/download/details.aspx?id=6812)下载SDK
- 安装DirectX SDK时可能遇到一个错误码为<b>S1023</b>的错误。遇到这个问题请在安装DirectX SDK前先安装C++ Redistributable package(s)。
问题解释:[已知问题DirectX SDK (June 2010) 安装及S1023错误](https://blogs.msdn.microsoft.com/chuckw/2011/12/09/known-issue-directx-sdk-june-2010-setup-and-the-s1023-error/)
- 一旦配置完成你就可以生成解决方案文件了打开解决方案文件并编译Assimp库编译为Debug版本还是Release版本根据你的需要和心情来定吧
- 使用默认配置构建的Assimp是一个动态库所以我们需要把编译出来的assimp.dll文件拷贝到我们自己程序的可执行文件的同一目录里。
- 编译出来的Assimp的LIB文件和DLL文件可以在code/Debug或者code/Release里找到。
- 把编译好的LIB文件和DLL文件拷贝到工程的相应目录下并链接到你的解决方案中。同时还好记得把Assimp的头文件也拷贝到工程里去Assimp的头文件可以在include目录里找到
- 安装DirectX SDK时可能遇到一个错误码为`s1023`的错误。这种情况下请在安装SDK之前根据[这个](http://blogs.msdn.com/b/chuckw/archive/2011/12/09/known-issue-directx-sdk-june-2010-setup-and-the-s1023-error.aspx)先卸载C++ Redistributable package(s)。
- 一旦配置完成你就可以生成解决方案文件了打开解决方案文件并编译Assimp库可以编译为Debug版本也可以编译为Release版本只要能工作就行
- 使用默认配置构建的Assimp是一个动态库(Dynamic Library),所以我们需要包含所生成的**assimp.dll**文件以及程序的二进制文件。你可以简单地将DLL复制到我们程序可执行文件的同一目录中。
- Assimp编译之后生成的库和DLL文件位于**code/Debug**或者**code/Release**文件夹中
- 接着把编译好的LIB文件和DLL文件拷贝到工程的相应目录下并在解决方案中链接它们。并且记得把Assimp的头文件也复制到你的**include**目录中头文件可以在从Assimp中下载的**include**目录里找到)。
如果你遇到了其他问题,可以在下面给出的链接里获取帮助。
如果你遇到了未报告的错误,欢迎在评论区中寻求帮助。
!!! Important
如果你想让Assimp使用多线程支持来提高性能,你可以使用<b>Boost</b>库来编译 Assimp。在[Boost安装页面](http://assimp.sourceforge.net/lib_html/install.html)你能找到关于Boost的完整安装介绍。
如果你想让Assimp使用多线程来获得更高的性能,你可以使用Boost库来编译Assimp。你可以在它们的[安装页面](http://assimp.org/lib_html/install.html)找到完整安装介绍。
现在,你应该已经能够编译Assimp库并链接Assimp到你的工程里去了。下一步:[导入完美的3D物件](02 Mesh.md)
现在,你应该已经编译Assimp库并将它链接到你的程序中了。下一步:[导入](02 Mesh.md)漂亮的3D物体

View File

@@ -3,155 +3,140 @@
原文 | [Mesh](http://learnopengl.com/#!Model-Loading/Mesh)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
使用Assimp可以把多种不同格式的模型加载到程序中,但是一旦载入它们都被储存为Assimp自己的数据结构。我们最终的目的是把这些数据转为OpenGL可读的数据才能用OpenGL来渲染物体。我们从前面的教程了解到一个网格(Mesh)代表一个可绘制实体,现在我们就定义一个自己的网格类。
通过使用Assimp,我们可以加载不同的模型到程序中,但是载入它们都被储存为Assimp的数据结构。我们最终仍要将这些数据转为OpenGL能够理解的格式,这样才能渲染这个物体。我们从上一节中学到,网格(Mesh)代表的是单个的可绘制实体,我们现在先来定义一个我们自己的网格类。
先来复习一点目前学到知识,考虑一个网格最少需要哪些数据。一个网格应该至少需要一顶点,每个顶点包含一个位置向量一个法线向量一个纹理坐标向量。一个网格应该包含一个索引绘制的索引以纹理diffuse/specular map形式表现的材质数据
现在为了在OpenGL中设置一个满足最低需求的网格类我们定义一个顶点:
首先我们来回顾一下我们目前学到知识,想想一个网格最少需要什么数据。一个网格应该至少需要一系列的顶点,每个顶点包含一个位置向量一个法向量一个纹理坐标向量。一个网格应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图)
既然我们有了一个网格类的最低需求我们可以在OpenGL中定义一个顶点了
```c++
struct Vertex
{
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
```
我们把每个需要的向量储存到一个叫做`Vertex`的结构体中,它被用来索引每个顶点属性。另外除了`Vertex`结构体外,我们也希望组织纹理数据,所以我们定义一个`Texture`结构体
我们将所有需要的向量储存到一个叫做<fun>Vertex</fun>的结构体中,我们可以用它来索引每个顶点属性。除了<fun>Vertex</fun>结构体外,我们还需要将纹理数据整理到一个<fun>Texture</fun>结构体中。
```c++
struct Texture
{
GLuint id;
String type;
struct Texture {
unsigned int id;
string type;
};
```
我们储存纹理的id它的类型,比如漫反射贴图或者镜面贴图。
知道了顶点和纹理的实际表达,我们可以开始定义网格类的结构:
我们储存纹理的id以及它的类型,比如漫反射贴图或者镜面贴图。
知道了顶点和纹理的实现,我们可以开始定义网格类的结构了:
```c++
class Mesh
{
Public:
vector<Vertex> vertices;
vector<GLuint> indices;
vector<Texture> textures;
Mesh(vector<Vertex> vertices, vector<GLuint> indices, vector<Texture> texture);
Void Draw(Shader shader);
private:
GLuint VAO, VBO, EBO;
void setupMesh();
}
class Mesh {
public:
/* 网格数据 */
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
/* 函数 */
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
void Draw(Shader shader);
private:
/* 渲染数据 */
unsigned int VAO, VBO, EBO;
/* 函数 */
void setupMesh();
};
```
如你所见这个类一点都不复杂,构造方法里我们初始化网格所有必须数据。在`setupMesh`函数初始化缓冲。最后通过`Draw`函数绘制网格。注意我们把`shader`传递给`Draw`函数。通过把`shader`传递给Mesh在绘制之前我们设置几个uniform像链接采样器到纹理单元)。
构造函数的内容非常直接。我们简单设置类的公有变量,使用的是构造函数相应的参数。我们在构造函数中也调用`setupMesh`函数:
你可以看到这个类并不复杂。在构造器中,我们将所有必须数据赋予了网格,我们在<fun>setupMesh</fun>函数初始化缓冲,并最终使用<fun>Draw</fun>函数绘制网格。注意我们将一个着色器传入了<fun>Draw</fun>函数中,将着色器传入网格类中可以让我们在绘制之前设置一些uniform链接采样器到纹理单元)。
构造器的内容非常易于理解。我们只需要使用构造器的参数设置类的公有变量就可以了。我们在构造器中还调用了<fun>setupMesh</fun>函数:
```c++
Mesh(vector<Vertex> vertices, vector<GLuint> indices, vector<Texture> textures)
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
this->setupMesh();
setupMesh();
}
```
这里没什么特别的,现在让我们研究一下`setupMesh`函数。
这里没什么可说的。我们接下来讨论<fun>setupMesh</fun>函数。
## 初始化
现在我们有一大列的网格数据用于渲染,这要感谢构造函数。我们确实需要设置合适的缓冲,通过顶点属性指针vertex attribute pointers定义顶点着色器layout。现在除了将顶点数据传入结构体以外你应该对其它概念很熟悉:
由于有了构造器,我们现在有一大列的网格数据用于渲染。在此之前我们还必须配置正确的缓冲,通过顶点属性指针定义顶点着色器的布局。现在你应该对这些概念很熟悉了,但我们这次会稍微有一点变动,使用结构体中的顶点数据
```c++
void setupMesh()
{
glGenVertexArrays(1, &this->VAO);
glGenBuffers(1, &this->VBO);
glGenBuffers(1, &this->EBO);
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(this->VAO);
glBindBuffer(GL_ARRAY_BUFFER, this->VBO);
glBufferData(GL_ARRAY_BUFFER, this->vertices.size() * sizeof(Vertex),
&this->vertices[0], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, this->EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, this->indices.size() * sizeof(GLuint),
&this->indices[0], GL_STATIC_DRAW);
// 设置顶点坐标指针
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(GLvoid*)0);
// 设置法线指针
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(GLvoid*)offsetof(Vertex, Normal));
// 设置顶点的纹理坐标
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(GLvoid*)offsetof(Vertex, TexCoords));
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);
// 顶点位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// 顶点法线
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// 顶点纹理坐标
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray(0);
}
}
```
这里的代码和你设想的没什么特别不同的地方,但是向`Vertex`结构体传入数据需要有一些小技巧。
C++的结构体有一个重要的属性,那就是在内存中它们是连续的。如果我们用结构体表示一列数据,这个结构体只包含结构体的连续的变量,它就会直接转变为一个`float`实际上是byte数组我们就能用于一个数组缓冲array buffer中了。比如如果我们填充一个`Vertex`结构体,它在内存中的排布等于:
代码应该和你所想得没什么不同,但有了<fun>Vertex</fun>结构体的帮助,我们使用了一些小技巧。
C++结构体有一个很棒的特性,它们的内存布局是连续的(Sequential)。也就是说如果我们将结构体作为一个数据数组使用那么它将会以顺序排列结构体的变量这将会直接转换为我们在数组缓冲中所需要的float实际上是字节数组。比如说如果我们有一个填充后的<fun>Vertex</fun>结构体,那么它的内存布局将会等于:
```c++
Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];
```
感谢这个有用的特性,我们能直接把一个作为缓冲数据的一大列`Vertex`结构体的指针传递过去,它们会翻译成`glBufferData`能用的参数:
由于有了这个有用的特性,我们能直接传入一大列的<fun>Vertex</fun>结构体的指针作为缓冲的数据,它们将会完美地转换为<fun>glBufferData</fun>所能用的参数:
```c++
glBufferData(GL_ARRAY_BUFFER, this->vertices.size() * sizeof(Vertex),
&this->vertices[0], GL_STATIC_DRAW);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
```
自然地,`sizeof`函数也可以使用于结构体来计算字节类型的大小。应该是32字节8float * 4)。
一个预处理指令叫做`offsetof(s,m)`把结构体作为它的第一个参数第二个参数是这个结构体名字的变量。这是结构体另外的一个重要用途。函数返回这个变量从结构体开始的字节偏移量offset。这对于定义`glVertexAttribPointer`函数偏移量参数效果很好:
自然`sizeof`运算也可以用在结构体来计算它的字节大小。这个应该是32字节8float * 每个4字节)。
结构体的另外一个很好的用途是它的预处理指令`offsetof(s, m)`,它的第一个参数是一个结构体,第二个参数是这个结构体中变量的名字。这个宏会返回那个变量距结构体头部的字节偏移量(Byte Offset)。这正好可以用在定义<fun>glVertexAttribPointer</fun>函数中的偏移参数:
```c++
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
(GLvoid*)offsetof(Vertex, Normal));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
```
偏移量现在使用`offsetof`函数定义了,在这个例子里,设置法线向量的字节偏移量等于法线向量在结构体的字节偏移量,它是`3float`也就是12字节一个float占4字节。注意我们同样设置步长参数等于`Vertex`结构体的大小。
使用一个像这样的结构体,不仅能提供可读性更高的代码同时也是我们可以轻松的扩展结构体。如果我们想要增加另一个顶点属性,我们把它可以简单的添加到结构体中,由于它的可扩展性,渲染代码不会被破坏
偏移量现在是使用<fun>offsetof</fun>来定义了在这里它会将法向量的字节偏移量设置为结构体中法向量的偏移量也就是3个float即12字节。注意我们同样将步长参数设置为了<fun>Vertex</fun>结构体的大小
使用这样的一个结构体不仅能够提供可读性更高的代码,也允许我们很容易地拓展这个结构。如果我们希望添加另一个顶点属性,我们只需要将它添加到结构体中就可以了。由于它的灵活性,渲染的代码不会被破坏。
## 渲染
我们需要为`Mesh`类定义最后一个函数,它的Draw函数。在真正渲染前我们希望绑定合适的纹理然后调用`glDrawElements`。可因为我们一开始不知道这个网格有多少纹理以及它们应该是什么类型的,所以这件事变得很困难。所以我们该怎样在着色器中设置纹理单元和采样器呢?
解决这个问题,我们需要假设一个特定的名称惯例:每个漫反射贴图被命名为`texture_diffuseN`,每个镜面贴图应该被命名为`texture_specularN`。N是一个从1到纹理采样器允许使用的最大值之间的数。比如说在一个网格中我们有3个漫反射贴图和2个镜面贴图它们的纹理采样器应该在这之后被调用
我们需要为<fun>Mesh</fun>类定义最后一个函数,它的<fun>Draw</fun>函数。在真正渲染这个网格之前,我们需要在调用<fun>glDrawElements</fun>函数之前先绑定相应的纹理。然而,这实际上有些困难,我们一开始不知道这个网格(如果有的话)有多少纹理、纹理是什么类型的。所以我们该如何在着色器中设置纹理单元和采样器呢?
为了解决这个问题,我们需要设定一个命名标准:每个漫反射纹理被命名为`texture_diffuseN`,每个镜面光纹理应该被命名为`texture_specularN`,其中`N`的范围是1到纹理采样器最大允许的数字。比如说我们对某一个网格有3个漫反射纹理2个镜面光纹理它们的纹理采样器应该之后会被调用
```c++
uniform sampler2D texture_diffuse1;
@@ -161,48 +146,50 @@ uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
```
使用这样的惯例,我们能定义我们在着色器中需要的纹理采样器的数量。如果一个网格真的(这么多)纹理,我们知道它们的名字应该是什么。这个惯例也使我们能够处理一个网格上的任何数量的纹理,通过定义合适的采样器开发者可以自由使用希望使用的数量(虽然定义少的话会有点浪费绑定和uniform调用)。
根据这个标准,我们可以在着色器中定义任意需要数量的纹理采样器如果一个网格真的包含了(这么多)纹理,我们也能知道它们的名字是什么。根据这个标准,我们也能在一个网格中处理任意数量的纹理,开发者可以自由选择需要使用的数量,他只需要定义正确的采样器就可以了虽然定义少的话会有点浪费绑定和uniform调用
像这样的问题有很多不同的解决方案,如果你不喜欢这个方案,你可以自己创造一个你自己的方案。
最后的绘制代码:
!!! Important
像这样的问题有很多种不同的解决方案。如果你不喜欢这个解决方案,你可以自己想一个你自己的解决办法。
最终的渲染代码是这样的:
```c++
void Draw(Shader shader)
{
GLuint diffuseNr = 1;
GLuint specularNr = 1;
for(GLuint i = 0; i < this->textures.size(); i++)
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for(unsigned int i = 0; i < textures.size(); i++)
{
glActiveTexture(GL_TEXTURE0 + i); // 在绑定纹理前需要激活适当的纹理单元
// 检索纹理序列号 (N in diffuse_textureN)
glActiveTexture(GL_TEXTURE0 + i); // 在绑定之前激活相应的纹理单元
// 获取纹理序号(diffuse_textureN 中的 N
stringstream ss;
string number;
string name = this->textures[i].type;
string name = textures[i].type;
if(name == "texture_diffuse")
ss << diffuseNr++; // 将GLuin输入到string stream
ss << diffuseNr++; // 将 unsigned int 插入到流中
else if(name == "texture_specular")
ss << specularNr++; // 将GLuin输入到string stream
ss << specularNr++; // 将 unsigned int 插入到流中
number = ss.str();
glUniform1f(glGetUniformLocation(shader.Program, ("material." + name + number).c_str()), i);
glBindTexture(GL_TEXTURE_2D, this->textures[i].id);
shader.setFloat(("material." + name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture(GL_TEXTURE0);
// 绘制Mesh
glBindVertexArray(this->VAO);
glDrawElements(GL_TRIANGLES, this->indices.size(), GL_UNSIGNED_INT, 0);
// 绘制网格
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
```
这不是最漂亮的代码,但这部分归咎于C++转换类型时的丑陋,比如`int`转`string`时。我们首先计算N-元素每个纹理类型,把它链接到纹理类型字符串来获取合适的uniform名。然后查找合适的采样器位置,给它位置值对应当前激活纹理单元,绑定纹理。这也是我们需要在`Draw`方法是用`shader`的原因。我们添加`material.`到作为结果的uniform名因为我们通常把纹理储存材质结构体(对于每个实现也许会有不同)。
不是最漂亮的代码,但这部分归咎于C++[转换](http://www.cplusplus.com/articles/D9j2Nwbp/)int到string类型时太丑了。我们首先计算每个纹理类型的N-分量,并将其拼接到纹理类型字符串上,来获取对应的uniform名称。接下来我们查找对应的采样器,将它的位置值设置为当前激活纹理单元,绑定纹理。这也是我们在<fun>Draw</fun>函数中需要着色器的原因。我们也将`"material."`添加到了最终的uniform名称中,因为我们希望将纹理储存在一个材质结构体中(这在每个实现中可能都不同)。
!!! Important
注意,当我们把`diffuse`和`specular`传递到字符串流(`stringstream`的时候计数器会增加在C++自增叫做:变量++它会先返回自身然后加1而++变量先加1再返回自身我们的例子里我们先传递原来的计数器值到字符串流然后再加1下一轮生效
注意我们在将漫反射计数器和镜面光计数器插入`stringstream`时对它们进行了递增。在C++中,这个递增操作:`variable++`将会返回变量本身,**之后**再递增,而`++variable`则是**先**递增,再返回值。在我们的例子中是首先将原本的计数器值插入`stringstream`,之后再递增它,供下一次循环使用
你可以从这里得到[Mesh类的源码](http://learnopengl.com/code_viewer.php?code=mesh&type=header)。
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=includes/learnopengl/mesh.h)找到<fun>Mesh</fun>类的完整源代码
Mesh类是我们前面教程里讨论的很多话题的简洁抽象。在下面的教程里,我们创建一个用作盛放多个网格物体的容器模型,真正实现Assimp的加载接口。
我们刚定义的<fun>Mesh</fun>类是我们前讨论的很多话题的抽象结果。在[下一节](03 Model.md)中,我们创建一个模型,作为多个网格对象的容器,真正实现Assimp的加载接口。

View File

@@ -3,21 +3,22 @@
原文 | [Model](http://learnopengl.com/#!Model-Loading/Model)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
现在是时候着手启用Assimp,并开始创建实际的加载和转换代码了。教程的目标是创建另一个能够完整表示模型(Model)的类,更确切的说,是一个包含多个网格(Mesh),并且可能带有多个对象的模型。也就是说,一个包含木制阳台、塔楼、甚至游泳池的房子,仍然可以被加载为单独的一个模型。我们将通过Assimp加载模型把它们转变为多个在[上一节](02 Mesh.md)中创建的<fun>Mesh</fun>对象。
现在是时候接触Assimp创建实际的加载和转换代码了。这个教程的目标是创建另一个类来完整表示一个模型,或者说是包含多个网格,甚至是多个物体的模型。一个包含木制阳台、塔楼、甚至游泳池的房子可能仍会被加载为一个模型。我们会使用Assimp加载模型,并将它转换(Translate)至多个在[上一节](02 Mesh.md)中创建的<var>Mesh</var>对象。
闲话少说,我会先把<fun>Model</fun>类的结构给你:
事不宜迟,我会先把<fun>Model</fun>类的结构给你:
```c++
class Model
{
public:
/* 函数 */
Model(GLchar* path)
Model(char *path)
{
this->loadModel(path);
loadModel(path);
}
void Draw(Shader shader);
private:
@@ -26,30 +27,28 @@ class Model
string directory;
/* 函数 */
void loadModel(string path);
void processNode(aiNode* node, const aiScene* scene);
Mesh processMesh(aiMesh* mesh, const aiScene* scene);
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type,
void processNode(aiNode *node, const aiScene *scene);
Mesh processMesh(aiMesh *mesh, const aiScene *scene);
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName);
};
```
`Model`类包含一个`Mesh`对象的向量,我们需要在构造函数中给出文件的位置。之后,在构造中,它通过`loadModel`函数加载文件。私有方法都被设计为处理一部分的Assimp导入的常规动作我们会简单讲讲它们。同样我们储存文件路径的目录,这样稍后加载纹理的时候会用到。
函数`Draw`没有什么特别之处基本上是循环每个网格调用各自的Draw函数。
<fun>Model</fun>类包含一个<fun>Mesh</fun>对象的vector译注这里指的是C++中的vector模板类之后遇到均不译构造器需要我们给它一个文件路径。在构造中,它会直接通过<fun>loadModel</fun>来加载文件。私有函数将会处理Assimp导入过程中的一部分我们很快就会介绍它们。我们还将储存文件路径的目录,在之后加载纹理的时候会用到
<fun>Draw</fun>函数没有什么特别之处,基本上就是遍历了所有网格,并调用它们各自的<fun>Draw</fun>函数。
```c++
void Draw(Shader shader)
{
for(GLuint i = 0; i < this->meshes.size(); i++)
this->meshes[i].Draw(shader);
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}
```
## 导入3D模型到OpenGL
为了导入一个模型,并它转换我们自己的数据结构,第一件需要做的事是包含合适的Assimp头文件这样编译器就不会对我们抱怨了。
要想导入一个模型,并它转换我们自己的数据结构中的话,首先我们需要包含Assimp对应的头文件,这样编译器就不会抱怨我们了。
```c++
#include <assimp/Importer.hpp>
@@ -57,117 +56,111 @@ void Draw(Shader shader)
#include <assimp/postprocess.h>
```
我们将要调用的第一个函数是`loadModel`,它被构造函数直接调用。在`loadModel`函数里面我们使用Assimp加载模型Assimp中被称为scene对象的数据结构。你可能还记得模型加载系列的第一个教程中这是Assimp数据结构的根对象。一旦我们有了场景对象,我们就能从已加载模型中获取所有所需数据了。
Assimp最大优点是它简约的抽象了所加载所有不同格式文件的技术细节用一行可以做到这一切
首先需要调用的函数是<fun>loadModel</fun>,它会从构造器中直接调用。在<fun>loadModel</fun>中我们使用Assimp加载模型Assimp的一个叫做<u>scene</u>的数据结构。你可能还记得模型加载章节的[第一节](01 Assimp.md)教程中这是Assimp数据接口的根对象。一旦我们有了这个场景对象,我们就能访问到加载后的模型中所有所需数据了。
Assimp很棒的一点在于它抽象掉了加载不同文件格式的所有技术细节只需要一行代码就能完成所有的工作
```c++
Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
```
我们先来声明一个`Importer`对象,它的名字空间是`Assimp`,然后调用它的`ReadFile`函数。这个函数需要一个文件路径,第二个参数是后处理post-processing选项。除了可以简单加载文件外Assimp允许我们定义几个选项来强制Assimp去对导入数据做一些额外的计算或操作。通过设置`aiProcess_Triangulate`我们告诉Assimp如果模型不是全部由三角形组成应该转换所有的模型的原始几何形状为三角形。`aiProcess_FlipUVs`基于y轴翻转纹理坐标在处理的时候是必须的(你可能记得我们在纹理教程中,我们说过在OpenGL大多数图像会被沿着y轴反转所以这个小小的后处理选项会为我们修正这个。一少部分其他有用的选项如下
我们首先声明了Assimp命名空间内的一个<fun>Importer</fun>,之后调用它的<var>ReadFile</var>函数。这个函数需要一个文件路径,它的第二个参数是一些<def>后期处理</def>(Post-processing)的选项。除了加载文件Assimp允许我们设定一些选项来强制对导入数据做一些额外的计算或操作。通过设定<var>aiProcess_Triangulate</var>我们告诉Assimp如果模型不是(全部)由三角形组成,它需要将模型所有的图元形状变换为三角形。<var>aiProcess_FlipUVs</var>将在处理的时候翻转y轴的纹理坐标(你可能记得我们在[纹理](../01 Getting started/06 Textures.md)教程中说过在OpenGL中大部分的图像的y轴都是反的所以这个后期处理选项将会修复这个。其它一些比较有用的选项
* `aiProcess_GenNormals` : 如果模型没有包含法线向量,就为每个顶点创建法线。
* `aiProcess_SplitLargeMeshes` : 把大的网格分成几个小的的下级网格,当你渲染有一个最大数量顶点限制时或者只能处理小块网格时很有用。
* `aiProcess_OptimizeMeshes` : 和上个选项相反,它把几个网格结合为一个大的网格。以减少绘制函数调用的次数的方式来优化。
- <var>aiProcess_GenNormals</var>如果模型包含法向量的话,就为每个顶点创建法线。
- <var>aiProcess_SplitLargeMeshes</var>:将比较大的网格分割成更小的子网格,如果你的渲染有最大顶点限制,只能渲染较小的网格,那么它会非常有用。
- <var>aiProcess_OptimizeMeshes</var>和上个选项相反,它会将多个小网格拼接为一个大的网格减少绘制调用从而进行优化。
Assimp提供了后处理说明,你可以[这里](http://assimp.sourceforge.net/lib_html/postprocess_8h.html)找到所有内容。事实上通过Assimp加载一个模型超级简单。困难的是使用返回的场景对象加载的数据换到一个Mesh对象的数组。
完整的`loadModel`函数在这里列出:
Assimp提供了很多有用的后期处理指令,你可以[这里](http://assimp.sourceforge.net/lib_html/postprocess_8h.html)找到全部的指令。实际上使用Assimp加载模型是非常容易的你也可以看到。困难的是之后使用返回的场景对象加载的数据换到一个<fun>Mesh</fun>对象的数组。
完整的<fun>loadModel</fun>函数将会是这样的:
```c++
void loadModel(string path)
{
Assimp::Importer import;
const aiScene* scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
if(!scene || scene->mFlags == AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{
cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
return;
}
this->directory = path.substr(0, path.find_last_of('/'));
this->processNode(scene->mRootNode, scene);
directory = path.substr(0, path.find_last_of('/'));
processNode(scene->mRootNode, scene);
}
```
在我们加载了模型之后,我们检验是否场景和场景的根节点为空,查看这些标记中的一个来看看返回的数据是完整。如果发生了任何一个错误,我们通过导入器impoter的`GetErrorString`函数返回错误报告。我们同样重新获取文件的目录路径。
在我们加载了模型之后,我们会检查场景和其根节点不为null并且检查了它的一个标记(Flag),来查看返回的数据是不是不完整。如果遇到了任何错误,我们都会通过导入器的<fun>GetErrorString</fun>函数来报告错误并返回。我们也获取文件路径的目录路径。
如果什么错误发生,我们希望处理所有的场景节点,所以我们传递第一个节点(根节点)到递归函数`processNode`。因为每个节点(可能)包含多个子节点,我们希望先处理父节点再处理子节点,以此类推。这符合递归结构,所以我们定义一个递归函数。递归函数就是一个做一些什么处理之后,用不同的参数调用它自身的函数,此种循环不会停止,直到一个特定条件发生。在我们的例子里,特定条件是所有的节点都被处理。
也许你记得Assimp的结构每个节点包含一个网格集合的索引每个索引指向一个在场景对象中特定的网格位置。我们希望获取这些网格索引获取每个网格处理每个网格然后对其他的节点的子节点做同样的处理。`processNode`函数的内容如下:
如果什么错误都没有发生,我们希望处理场景中的所有节点,所以我们第一个节点(根节点)传入了递归的<fun>processNode</fun>函数。因为每个节点(可能)包含多个子节点,我们希望先处理参数中的节点,再继续处理该节点所有的子节点,以此类推。这符合一个递归结构,所以我们定义一个递归函数。递归函数做一些处理之后,使用不同的参数<def>递归</def>调用这个函数自身,直到某个条件被满足停止递归。在我们的例子中<def>退出条件</def>(Exit Condition)是所有的节点都被处理完毕
你可能还记得Assimp的结构中每个节点包含了一系列的网格索引每个索引指向场景对象中的那个特定网格。我们接下来就想去获取这些网格索引获取每个网格处理每个网格接着对每个节点的子节点重复这一过程。<fun>processNode</fun>函数的内容如下:
```c++
void processNode(aiNode* node, const aiScene* scene)
void processNode(aiNode *node, const aiScene *scene)
{
// 添加当前节点中的所有Mesh
for(GLuint i = 0; i < node->mNumMeshes; i++)
// 处理节点所有的网格(如果有的话)
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
this->meshes.push_back(this->processMesh(mesh, scene));
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
// 递归处理该节点的子节点
for(GLuint i = 0; i < node->mNumChildren; i++)
// 接下来对它的子节点重复这一过程
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
this->processNode(node->mChildren[i], scene);
processNode(node->mChildren[i], scene);
}
}
```
我们首先利用场景的`mMeshes`数组来检查每个节点的网格索引以获取应的网格。返回的网格被传递给`processMesh`函数,它返回一个网格对象,我们可以把它储存在`meshes`的list或vectorSTL里的两种实现链表的数据结构
我们首先检查每个节点的网格索引,并索引场景的<var>mMeshes</var>数组来获取应的网格。返回的网格将会传递到<fun>processMesh</fun>函数,它返回一个<fun>Mesh</fun>对象,我们可以将它存储在<var>meshes</var>列表/vector
一旦所有网格都被处理,我们遍历所有子节点,同样调用processNode函数。一旦一个节点不再有任何子节点函数会停止执行。
所有网格都被处理之后,我们遍历节点的所有子节点,并对它们调用相同的<fun>processMesh</fun>函数。一个节点不再有任何子节点之后,这个函数会停止执行。
!!! Important
认真的读者会注意到,我们可基本忘记处理任何的节点,简单循环出场景所有网格,而不是用索引做这复杂的。我们这么做的原因是,使用这种节点的原始的想法是,在网格之间定义一个父-子关系。通过递归遍历这关系,我们可以真正定义特定的网格作为其他网格的父(节点)。
关于这个系统的一个有用的例子是,当你想要平移一个汽车网格需要确保把它的子(节点)比如,引擎网格,方向盘网格和轮胎网格都进行平移;使用父-子关系这样的系统很容易被创建出来。
现在我们没用这种系统,但是无论何时你想要对你的网格数据进行额外的控制,这通常是一种坚持被推荐的做法。这些模型毕竟是那些定义了这些节点风格的关系的艺术家所创建的。
认真的读者可能会发现,我们可基本上忘掉处理任何的节点,只需要遍历场景对象的所有网格,就不需要为了索引做这一堆复杂的东西了。我们这么做的原因是,使用节点的最初想法是网格之间定义一个父子关系。通过这样递归遍历这关系,我们就能将某个网格定义为另一个网格的父网格了。
这个系统的一个使用案例是,当你想位移一个汽车的网格时,你可以保证它的所有子网格(比如引擎网格、方向盘网格、轮胎网格)都会随着一起位移。这样的系统能够用父子关系很容易地创建出来。
下一步是用上个教程创建的`Mesh`类开始真正处理Assimp的数据
然而,现在我们并没有使用这样一种系统,但如果你想对你的网格数据有更多的控制,通常都是建议使用这一种方法的。这种类节点的关系毕竟是由创建了这个模型的艺术家所定义
下一步就是将Assimp的数据解析到上一节中创建的<fun>Mesh</fun>类中。
### 从Assimp到网格
一个`aiMesh`对象转换为一个我们自己定义的网格对象不难。我们要做的全部是获取每个网格相关属性并把这些属性储存到我们自己的对象。通常`processMesh`函数的结构会是这样
一个`aiMesh`对象转化为我们自己的网格对象不是那么困难。我们要做的只是访问网格相关属性并将它们储存到我们自己的对象中。<fun>processMesh</fun>函数的大体结构如下
```c++
Mesh processMesh(aiMesh* mesh, const aiScene* scene)
Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
vector<Vertex> vertices;
vector<GLuint> indices;
vector<unsigned int> indices;
vector<Texture> textures;
for(GLuint i = 0; i < mesh->mNumVertices; i++)
for(unsigned int i = 0; i < mesh->mNumVertices; i++)
{
Vertex vertex;
// 处理顶点坐标、法线和纹理坐标
// 处理顶点位置、法线和纹理坐标
...
vertices.push_back(vertex);
}
// 处理顶点索引
// 处理索引
...
// 处理材质
if(mesh->mMaterialIndex >= 0)
{
...
}
return Mesh(vertices, indices, textures);
}
```
处理一个网格基本由三部分组成:获取所有顶点数据,获取网格索引,获取相关材质数据。处理的数据储存在3个向量其中之一里面一个Mesh被以这些数据创建返回到函数的调用者。
获取顶点数据很简单:我们定义一个`Vertex`结构体,在每次遍历后我们把这个结构体添加到`Vertices`数组。我们为存在于网格中的众多顶点循环(通过`mesh->mNumVertices`获取)。在遍历的过程中,我们希望用所有相关数据填充这个结构体。每个顶点位置会像这样被处理:
处理网格的过程主要有三部分:获取所有顶点数据,获取它们的网格索引,获取相关材质数据。处理的数据将会储存在三个vector当中我们会利用它们构建一个<fun>Mesh</fun>对象,并返回到函数的调用者那里
获取顶点数据非常简单,我们定义了一个<fun>Vertex</fun>结构体,我们将在每个迭代之后将它加到<var>vertices</var>数组中。我们会遍历网格中的所有顶点(使用`mesh->mNumVertices`来获取)。在每个迭代中,我们希望使用所有的相关数据填充这个结构体。顶点的位置是这样处理的:
```c++
glm::vec3 vector;
@@ -177,14 +170,13 @@ vector.z = mesh->mVertices[i].z;
vertex.Position = vector;
```
注意为了传输Assimp的数据我们定义一个`vec3`的宿主我们需要它是因为Assimp维持它自己的数据类型这些类型用于向量、材质、字符串等。这些数据类型转换到glm的数据类型时通常效果不佳
注意我们为了传输Assimp的数据我们定义一个`vec3`的临时变量。使用这样一个临时变量的原因是Assimp对向量、矩阵、字符串等都有自己的一套数据类型它们并不能完美地转换到GLM的数据类型中
!!! Important
Assimp将它的顶点位置数组称为<var>mVertices</var>,这其实并不是非常直观。
对应法线的步骤毫无疑问是这样的:
Assimp将它的顶点位置数组叫做<var>mVertices</var>,这其实并不是那么直观。
处理法线的步骤也是差不多的:
```c++
vector.x = mesh->mNormals[i].x;
@@ -193,11 +185,10 @@ vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;
```
纹理坐标也基本一样,但Assimp允许一个模型的每个顶点8个不同的纹理坐标我们可能用不到,所以我们只关第一组纹理坐标。我们也希望检查网格是否真的包含纹理坐标(可能并不总是如此):
纹理坐标的处理也大体相似但Assimp允许一个模型在一个顶点上有最多8个不同的纹理坐标我们不会用到那么多,我们只关第一组纹理坐标。我们同样也想检查网格是否真的包含纹理坐标(可能并不会一直如此)
```c++
if(mesh->mTextureCoords[0]) // Does the mesh contain texture coordinates?
if(mesh->mTextureCoords[0]) // 网格是否有纹理坐标?
{
glm::vec2 vec;
vec.x = mesh->mTextureCoords[0][i].x;
@@ -208,58 +199,54 @@ else
vertex.TexCoords = glm::vec2(0.0f, 0.0f);
```
`Vertex`结构体现在完全被所需的顶点属性填充了,我们能把它添加到`vertices`向量的尾部。要对每个网格的顶点做相同的处理
<var>vertex</var>结构体现在已经填充好了需要的顶点属性,我们会在迭代的最后将它压入<var>vertices</var>这个vector的尾部。这个过程会对每个网格的顶点都重复一遍
### 顶点
Assimp的接口定义每个网格有一个以面faces为单位的数组每个面代表一个单独的图元在我们的例子中由于`aiProcess_Triangulate`选项)总是三角形,一个面包含索引,这些索引定义我们需要绘制的顶点以在那样的顺序提供给每个图元,所以如果我们遍历所有面,把所有面的索引储存到`indices`向量,我们需要这么做:
### 索引
Assimp的接口定义了每个网格都有一个面(Face)数组,每个面代表了一个图元,在我们的例子中(由于使用了<var>aiProcess_Triangulate</var>选项)它总是三角形。一个面包含了多个索引,它们定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制,所以如果我们遍历了所有的面,并储存了面的索引到<var>indices</var>这个vector中就可以了。
```c++
for(GLuint i = 0; i < mesh->mNumFaces; i++)
for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
aiFace face = mesh->mFaces[i];
for(GLuint j = 0; j < face.mNumIndices; j++)
for(unsigned int j = 0; j < face.mNumIndices; j++)
indices.push_back(face.mIndices[j]);
}
```
所有外部循环结束,我们现在有了一个完整点的顶点和索引数据来绘制网格,这要调用`glDrawElements`函数。可是,为了结束这个讨论,并向网格提供一些细节,我们同样希望处理网格的材质。
所有外部循环结束,我们现在有了一系列的顶点和索引数据,它们可以用来通过<fun>glDrawElements</fun>函数来绘制网格。然而,为了结束这个话题,并且对网格提供一些细节,我们还需要处理网格的材质。
### 材质
如同节点,一个网格只一个指向材质对象的索引获取网格实际的材质,我们需要索引场景的`mMaterials`数组。网格材质索引被设置在`mMaterialIndex`属性中,通过这个属性我们同样能够检验一个网格是否包含一个材质:
和节点一样,一个网格只包含了一个指向材质对象的索引。如果想要获取网格真正的材质,我们需要索引场景的<var>mMaterials</var>数组。网格材质索引位于它的<var>mMaterialIndex</var>属性中,我们同样可以用它来检测一个网格是否包含材质:
```c++
if(mesh->mMaterialIndex >= 0)
{
aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = this->loadMaterialTextures(material,
aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector<Texture> specularMaps = this->loadMaterialTextures(material,
vector<Texture> specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}
```
先从场景的`mMaterials`数组获取`aimaterial`对象,然后,我们希望加载网格的漫反射贴图和(或者)镜面贴图。一个材质储存了一个数组,这个数组为每个纹理类型提供纹理位置。不同的纹理类型都以`aiTextureType_`为前缀。我们使用一个帮助函数:`loadMaterialTextures`来从材质获取纹理。这个函数返回一个`Texture`结构体的向量,我们在之后储存在模型的`textures`坐标的后面
`loadMaterialTextures`函数遍历所有给定纹理类型的纹理位置,获取纹理的文件位置,然后加载生成纹理,把信息储存到`Vertex`结构体。看起来像这样:
们首先从场景的<var>mMaterials</var>数组获取`aiMaterial`对象。接下来我们希望加载网格的漫反射和/或镜面贴图。一个材质对象的内部对每种纹理类型都存储了一个纹理位置数组。不同的纹理类型都以`aiTextureType_`为前缀。我们使用一个叫做<fun>loadMaterialTextures</fun>的工具函数来从材质获取纹理。这个函数将会返回一个<fun>Texture</fun>结构体的vector我们将在模型的<var>textures</var> vector的尾部之后存储它
<fun>loadMaterialTextures</fun>函数遍历了给定纹理类型的所有纹理位置,获取了纹理的文件位置,并加载并和生成了纹理,将信息储存在了一个<fun>Vertex</fun>结构体中。它看起来会像这样:
```c++
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName)
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(GLuint i = 0; i < mat->GetTextureCount(type); i++)
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
Texture texture;
texture.id = TextureFromFile(str.C_Str(), this->directory);
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str;
textures.push_back(texture);
@@ -268,53 +255,50 @@ vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string
}
```
我们先通过`GetTextureCount`函数检验材质中储存的纹理,以期得到我们希望得到的纹理类型。然后我们通过`GetTexture`函数获取每个纹理的文件位置,这个位置以`aiString`类型储存。然后我们使用另一个帮助函数,它被命名为:`TextureFromFile`加载一个纹理使用SOIL返回纹理的ID。可以查看列在最后的完整代码,如果你不知道这个函数应该怎样写出来的话
我们先通过<fun>GetTextureCount</fun>函数检查储存在材质中纹理的数量,这个函数需要一个纹理类型。我们会使用<fun>GetTexture</fun>获取每个纹理的文件位置,它会将结果储存在一个`aiString`中。我们接下来使用另一个叫做<fun>TextureFromFile</fun>的工具函数,它将会(用`stb_image.h`加载一个纹理返回纹理的ID。如果你不确定这样的代码是如何写出来的话,可以查看最后的完整代码。
!!! Important
注意,我们假设纹理文件与模型是在相同的目录。我们可以简单的链接纹理位置字符串和之前获取的目录字符串(在`loadModel`函数中得到的)来获完整的纹理路径(这是为什么`GetTexture`函数同样需要目录字符串)。
有些在互联网上找到的模型使用绝对路径,它们的纹理位置就不会在每台机器上都有效了。例子里,你可能希望手工编辑这个文件来使用本地路径为纹理所使用(如果可能的话)。
注意,我们假设了模型文件中纹理文件的路径是相对于模型文件的本地(Local)路径,比如说与模型文件处于同一目录。我们可以纹理位置字符串拼接到之前(在<fun>loadModel</fun>中)获取的目录字符串上,来获完整的纹理路径(这是为什么<fun>GetTexture</fun>函数也需要一个目录字符串)。
这就是使用Assimp来导入一个模型的全部了。你可以在这里找到[Model类的代码](http://learnopengl.com/code_viewer.php?code=model_loading/model_unoptimized)
在网络上找到的某些模型会对纹理位置使用绝对(Absolute)路径,这就不能在每台机器上都工作了。在这种情况下,你可能会需要手动修改这个文件,来让它对纹理使用本地路径(如果可能的话)
这就是使用Assimp导入模型的全部了。
# 重大优化
我们现在还没做完。因为我们还想做一个重大的优化(但不是必须的)。大多数场景重用若干纹理,把它们应用到网格;还是思考那个别墅,它有个花岗岩的纹理作为墙面。这个纹理也可应用到地板、天花板楼梯,或者一张桌子、一个附近的小物件。加载纹理需要不少操作,当前的实现中一个新的纹理被加载和生成,来为每个网格使用,即使同样的纹理之前已经被加载了好几次。这很快转变为你的模型加载实现的瓶颈。
所以我们打算添加一个小小的微调,把模型的代码改成,储存所有的已加载纹理到全局。无论在哪儿我们都要先检查这个纹理是否已经被加载过了。如果加载过了,我们就直接使用这个纹理并跳过整个加载流程来节省处理能力。为了对比纹理我们同样需要储存它们的路径:
这还没有完全结束,因为我们还想做一个重大的(但不是完全必须的)优化。大多数场景都会在多个网格中重用部分纹理。还是想想一个房子,它的墙壁有着花岗岩的纹理。这个纹理也可以被应用到地板、天花板楼梯、桌子,甚至是附近的一口井上。加载纹理并不是一个开销不大的操作,在我们当前的实现中,即便同样的纹理已经被加载过很多遍了,对每个网格仍会加载并生成一个新的纹理。这很快就会变成模型加载实现的性能瓶颈。
所以我们会对模型的代码进行调整,将所有加载过的纹理全局储存,每当我们想加载一个纹理的时候,首先去检查它有没有被加载过。如果有的话,我们会直接使用那个纹理,并跳过整个加载流程,来为我们省下很多处理能力。为了能够比较纹理,我们还需要储存它们的路径:
```c++
struct Texture {
GLuint id;
unsigned int id;
string type;
aiString path; // We store the path of the texture to compare with other textures
aiString path; // 我们储存纹理的路径用于与其它纹理进行比较
};
```
然后我们所有加载过的纹理储存另一个向量中,它是作为一个私有变量声明在模型类的顶部
接下来我们所有加载过的纹理储存另一个vector中在模型类的顶部声明为一个私有变量
```c++
vector<Texture> textures_loaded;
```
后,在`loadMaterialTextures`函数中,我们希望纹理路径和所有`texture_loaded`向量对比,看看是否当前纹理路径和其中任何一个是否相同如果是,我们跳过纹理加载/生成部分,简单的使用已加载纹理结构体为网格纹理。这个函数如下所示
后,在<fun>loadMaterialTextures</fun>函数中,我们希望纹理路径与储存在<var>textures_loaded</var>这个vector中的所有纹理进行比较,看看当前纹理路径是否与其中的一个相同如果是的话,则跳过纹理加载/生成部分,直接使用定位到的纹理结构体为网格纹理。更新后的函数如下:
```c++
vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName)
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(GLuint i = 0; i < mat->GetTextureCount(type); i++)
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
GLboolean skip = false;
for(GLuint j = 0; j < textures_loaded.size(); j++)
bool skip = false;
for(unsigned int j = 0; j < textures_loaded.size(); j++)
{
if(textures_loaded[j].path == str)
if(std::strcmp(textures_loaded[j].path.C_Str(), str.C_Str()) == 0)
{
textures.push_back(textures_loaded[j]);
skip = true;
@@ -322,47 +306,43 @@ vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string
}
}
if(!skip)
{ // 如果纹理没有被加载,加载
{ // 如果纹理没有被加载,加载
Texture texture;
texture.id = TextureFromFile(str.C_Str(), this->directory);
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str;
textures.push_back(texture);
this->textures_loaded.push_back(texture); // 添加到纹理列表 textures
textures_loaded.push_back(texture); // 添加到已加载的纹理中
}
}
return textures;
}
```
所以现在我们不仅有了一个通用模型加载系统,同时我们也得了一个能使加载对象快的优化版本。
所以现在我们不仅有了个灵活的模型加载系统,我们也得了一个加载对象快的优化版本。
!!! Attention
有些版本的Assimp使用调试版或/和使用你的IDE的调试模式时,模型加载模型实在慢,所以确保在当你加载得很慢的时候用发布版再测试
有些版本的Assimp使用调试版本或者使用IDE的调试模式下加载模型会非常缓慢,所以在你遇到缓慢的加载速度时,可以试试使用发布版
你可以从这里获得优化的[Model类的完整源代码](http://learnopengl.com/code_viewer.php?code=model&type=header)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=includes/learnopengl/model.h)找到优化后<fun>Model</fun>类的完整源代码
# 和箱子模型告别
现在给我们导入一个天才艺术家创建的模型看看效果,不是我这个天才做的(你不得不承认,这箱子也许是你过的最漂亮的立方体)。因为我不想过于自夸,所以我会时不时的给其他艺术家进入这个行列的机会这次我们会加载Crytek的原版孤岛危机游戏中的纳米铠甲。这个模型被输出为obj和mtl文件mtl包含模型的漫反射贴图,镜面贴图以及法线贴图(后面会讲)。你可以[下载这个模型](http://learnopengl.com/data/models/nanosuit.rar),注意所有的纹理和模型文件应该放在同一个目录,以便载入纹理。
所以,让我们导入一个由真正的艺术家所创造的模型,替代我这个天才的作品(你要承认,这箱子可能是你过的最漂亮的立方体了),测试一下我们的实现吧。由于我不想让我占太多的功劳,我会偶尔让别的艺术家也加入我们,这次我们会加载Crytek的游戏孤岛危机(Crysis)中的原版[纳米装](http://tf3dm.com/3d-model/crysis-2-nanosuit-2-97837.html)(Nanosuit)。这个模型被输出为一个`.obj`文件以及一个`.mtl`文件,`.mtl`文件包含模型的漫反射、镜面光和法线贴图(这个会在后面学习到),你可以[这里](../data/nanosuit.rar)下载到(稍微修改之后的)模型,注意所有的纹理和模型文件应该位于同一个目录,以供加载纹理。
!!! Important
你从这个站点下载的版本是修改过的版本,每个纹理文件路径已经修改改为本地相对目录,原来的资源是绝对目录。
现在在代码中声明一个Model对象把它模型的文件位置传递给它。模型应该自动加载如果没有错误的话在游戏循环中使用它的Draw函数绘制这个对象。没有更多的缓冲配置属性指针和渲染命令仅仅简单的一行。如果你创建几个简单的着色器像素着色器只输出对象的漫反射贴图颜色结果看上去会有点像这样
你从本网站中下载到的版本是修改过的版本,每个纹理的路径都被修改为了一个本地的相对路径,而不是原资源的绝对路径。
现在在代码中,声明一个<fun>Model</fun>对象,将模型的文件位置传入。接下来模型应该会自动加载并(如果没有错误的话)在渲染循环中使用它的<fun>Draw</fun>函数来绘制物体,这样就可以了。不再需要缓冲分配、属性指针和渲染指令,只需要一行代码就可以了。接下来如果你创建一系列着色器,其中片段着色器仅仅输出物体的漫反射纹理颜色,最终的结果看上去会是这样的:
![](../img/03/03/model_diffuse.png)
你可以从这里找到带有[顶点](http://learnopengl.com/code_viewer.php?code=model_loading/model&type=vertex)和[片段](http://learnopengl.com/code_viewer.php?code=model_loading/model&type=fragment)着色器的[完整的源码](http://learnopengl.com/code_viewer.php?code=model_loading/model_diffuse)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/3.model_loading/1.model_loading/model_loading.cpp)找到完整的源码
因为我们之前学习过光照教程,可以更加富有创造性的引入两个点光源渲染方程,结合镜面贴图获得惊艳效果
我们可以变得更有创造力一点,根据我们之前在[光照](../02 Lighting/05 Light casters.md)教程中学过的知识,引入两个点光源渲染方程,结合镜面贴图,我们能得到很惊人的效果
![](../img/03/03/model_lighting.png)
甚至我不得不承认这个相比之前用过的容器酷炫多了。使用Assimp可以载入无数在互联网上找到的模型。有相当多可以以多种文件格式下载免费3D模型的资源网站。一定注意有些模型仍然不能很好的载入,纹理路径无效或者这种格式Assimp不能读取
## 练习
你可以使用两个点光源重建上个场景吗?[方案](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1)[着色器](http://learnopengl.com/code_viewer.php?code=model_loading/model-exercise1-shaders)。
甚至我都必须要承认这个可能是比一直使用的箱子要好看多了。使用Assimp能够加载互联网上的无数模型。有很多资源网站都提供了多种格式的免费3D模型供你下载。但还是要注意有些模型会不能正常地载入,纹理路径会出现问题或者Assimp并不支持它的格式

View File

@@ -3,197 +3,198 @@
原文 | [Depth testing](http://learnopengl.com/#!Advanced-OpenGL/Depth-testing)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
在[坐标系的教程](../01 Getting started/08 Coordinate Systems.md)中我们呈现了一个3D容器,使用**深度缓冲(Depth Buffer)**,以防止被其他面遮挡的面渲染到前面。在本教程中我们将细致地讨论被深度缓冲(或z-buffer)所存储的**深度值**以及它是如何确定一个片段是否被其他片段遮挡
在[坐标系](../01 Getting started/08 Coordinate Systems.md)小节中我们渲染了一个3D箱子并且运用了<def>深度缓冲</def>(Depth Buffer)来防止被阻挡的面渲染到其它面的前面。在这一节中我们将会更加深入地讨论这些储存在深度缓冲或z缓冲(z-buffer))中的<def>深度值</def>(Depth Value)以及它是如何确定一个片段是处于其它片段后方的
**深度缓冲**就像**颜色缓冲(Color Buffer)**(存储所有的片段颜色:视觉输出)那样存储每个片段的信息,(通常) 和颜色缓冲区有相同的宽度和高度。深度缓冲由窗口系统自动创建并将其深度值存储为 16、 24 或 32 位浮点数。在大多数系统中深度缓冲区为24位。
深度缓冲就像<def>颜色缓冲</def>(Color Buffer)(储存所有的片段颜色视觉输出)一样,在每个片段中储存了信息,并且(通常)和颜色缓冲有着一样的宽度和高度。深度缓冲由窗口系统自动创建的,它会以16、24或32位float的形式储存它的深度值。在大部分的系统中深度缓冲的精度都是24位
当深度测试启用的时候, OpenGL 测试深度缓冲区内的深度值。OpenGL 执行深度测试的时候,如果测试通过,深度缓冲内的值将被设为新的深度值。如果深度测试失败,则丢弃该片段
当深度测试(Depth Testing)被启用的时候OpenGL会将一个片段的的深度值与深度缓冲的内容进行对比。OpenGL执行一个深度测试,如果这个测试通过了的话,深度缓冲将会更新为新的深度值。如果深度测试失败了,片段将会被丢弃
深度测试在片段着色器运行之后(并且模板测试运行之后,我们将在[接下来](http://www.learnopengl.com/#!Advanced-OpenGL/Stencil-testing)的教程中讨论)在屏幕空间中行的。屏幕空间坐标直接有关的视区由OpenGL的`glViewport`函数给定并且可以通过GLSL的片段着色器中内置的 `gl_FragCoord`变量访问。`gl_FragCoord` 的 X 和 y 表示该片段的屏幕空间坐标 ((00) 在左下角)。`gl_FragCoord`包含一个 z 坐标,它包含了片段的实际深度值。此 z 坐标值是与深度缓冲区的内容进行比较的值。
深度缓冲是在片段着色器运行之后(以及模板测试(Stencil Testing)运行之后,我们将在[下一节](02 Stencil testing.md)中讨论在屏幕空间中行的。屏幕空间坐标与通过OpenGL的<fun>glViewport</fun>所定义的视口密切相关并且可以直接使用GLSL内建变量<var>gl_FragCoord</var>从片段着色器中直接访问。<var>gl_FragCoord</var>的x和y分量代表了片段的屏幕空间坐标其中(0, 0)位于左下角)。<var>gl_FragCoord</var>中也包含一个z分量,它包含了片段真正的深度值。z值就是需要与深度缓冲内容所对比的那个值。
!!! Important
现在大多数 GPU 都支持一种称为提前深度测试(Early depth testing)的硬件功能。提前深度测试允许深度测试在片段着色器之前运行。明确一个片段永远不会可见的 (它是其它物体的后面) 我们可以更早地放弃该片段。
现在大部分的GPU都提供一个叫做提前深度测试(Early Depth Testing)的硬件特性。提前深度测试允许深度测试在片段着色器之前运行。只要我们清楚一个片段永远不会可见的(它在其他物体之后),我们就能提前丢弃这个片段。
片段着色器通常是相当费时的所以我们应该尽避免运行它们。对片段着色器提前深度测试一个限制是你不应该写入片段的深度值。如果片段着色器将写入其深度值,提前深度测试是不可能的OpenGL不能事先知道深度值。
片段着色器通常开销都是很大的,所以我们应该尽可能避免运行它们。当使用提前深度测试时,片段着色器的一个限制是你不写入片段的深度值。如果一个片段着色器对它的深度值进行了写入,提前深度测试是不可能的OpenGL不能提前知道深度值。
深度测试默认是关闭的,要启用深度测试的话,我们需要用`GL_DEPTH_TEST`选项来打开它:
深度测试默认是禁用的,所以如果要启用深度测试的话,我们需要用<var>GL_DEPTH_TEST</var>选项来启用它:
```c++
glEnable(GL_DEPTH_TEST);
```
一旦启用深度测试如果片段通过深度测试OpenGL自动在深度缓冲区存储片段的 z 值,如果深度测试失败,那么相应地丢弃该片段。如果启用深度测试,那么在每个渲染之前还应使用`GL_DEPTH_BUFFER_BIT`清除深度缓冲,否则深度缓冲区将保留上一次进行深度测试时所写的深度值
当它启用的时候,如果一个片段通过深度测试的话OpenGL在深度缓冲中储存该片段的z值如果没有通过深度缓冲则会丢弃该片段。如果启用深度缓冲,你还应该在每个渲染迭代之前使用<var>GL_DEPTH_BUFFER_BIT</var>来清除深度缓冲,否则你会仍在使用上一次渲染迭代中的写入的深度值
```c++
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
```
在某些情况下我们需要进行深度测试并相应地丢弃片段,但我们不希望更新深度缓冲区,基本上,可以使用一个只读的深度缓冲区;OpenGL允许我们通过将其深度掩码设置为`GL_FALSE`禁用深度缓冲区写入:
可以想象,在某些情况下你会需要对所有片段都执行深度测试并丢弃相应的片段,但**不**希望更新深度缓冲基本上来说,你在使用一个<def>只读的</def>(Read-only)深度缓冲OpenGL允许我们禁用深度缓冲的写入,只需要设置它的深度掩码(Depth Mask)设置为`GL_FALSE`就可以了:
```c++
glDepthMask(GL_FALSE);
```
注意这只在深度测试被启用的时候有效。
注意这只在深度测试被启用的时候有效
## 深度测试函数
OpenGL 允许我们修改深度测试使用的比较运算符(comparison operators)。这样我们能够控制OpenGL通过或丢弃碎片和如何更新深度缓冲。我们可以通过调用`glDepthFunc`来设置比较运算符 (或叫做深度函数(depth function)):
OpenGL允许我们修改深度测试使用的比较运算符。这允许我们来控制OpenGL什么时候该通过或丢弃一个片段什么时候去更新深度缓冲。我们可以调用<fun>glDepthFunc</fun>函数来设置比较运算符(或者说深度函数(Depth Function)
```c++
glDepthFunc(GL_LESS);
```
函数接受在下表中列出的几个比较运算符:
这个函数接受下面表格中的比较运算符
运算符|描述
函数|描述
----------|------------------
GL_ALWAYS |永远通过测试
GL_NEVER |永远不通过测试
GL_LESS |在片段深度值小于缓冲的深度时通过测试
GL_EQUAL |在片段深度值等于缓冲区的深度时通过测试
GL_LEQUAL |在片段深度值小于等于缓冲区的深度时通过测试
GL_GREATER |在片段深度值大于缓冲区的深度时通过测试
GL_NOTEQUAL|在片段深度值不等于缓冲区的深度时通过测试
GL_GEQUAL |在片段深度值大于等于缓冲区的深度时通过测试
GL_ALWAYS | 永远通过深度测试
GL_NEVER | 永远不通过深度测试
GL_LESS | 在片段深度值小于缓冲的深度时通过测试
GL_EQUAL | 在片段深度值等于缓冲区的深度时通过测试
GL_LEQUAL | 在片段深度值小于等于缓冲区的深度时通过测试
GL_GREATER | 在片段深度值大于缓冲区的深度时通过测试
GL_NOTEQUAL| 在片段深度值不等于缓冲区的深度时通过测试
GL_GEQUAL | 在片段深度值大于等于缓冲区的深度时通过测试
默认情况下使用`GL_LESS`,这将丢弃深度值高于或等于当前深度缓冲区的值的片段。
默认情况下使用的深度函数是<var>GL_LESS</var>,它将会丢弃深度值大于等于当前深度缓冲值的所有片段。
让我们看看改变深度函数对输出的影响。我们将使用新的代码显示一个由两个纹理的立方体和带纹理的地板组成的没有光照的基本场景。你可以在这里找到[源代码](http://learnopengl.com/code_viewer.php?code=advanced/depth_testing_func)和其[着色器](http://learnopengl.com/code_viewer.php?code=advanced/depth_testing_func_shaders)代码。
让我们看看改变深度函数会对视觉输出有什么影响。我们将使用一个新的代码配置,它会显示一个没有光照的基本场景,里面有两个纹理的立方体,放置在一个有纹理的地板上。你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/1.1.depth_testing/depth_testing.cpp)找到源代码。
代码中我们将深度函数设为`GL_ALWAYS`:
在源代码中我们将深度函数改为<var>GL_ALWAYS</var>
```c++
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_ALWAYS);
```
我们没有启用深度测试得到了相同的行为。深度测试只是简单地通过,所以这样最后绘制的片段就会呈现在之前绘制片段面,即使他们应该在前面。由于我们最后绘制地板平面,那么平面的片段会覆盖每个容器的片段:
将会模拟我们没有启用深度测试时所得到的结果。深度测试将会永远通过,所以最后绘制的片段将会总是会渲染在之前绘制片段的上面,即使之前绘制的片段本就应该渲染在最前面。因为我们最后渲染地板的,它会覆盖所有的箱子片段
![](../img/04/01/depth_testing_func_always.png)
重新设置到`GL_LESS`给了我们曾经的场景
将它重新设置为<var>GL_LESS</var>,这会将场景还原为原有的样子
![](../img/04/01/depth_testing_func_less.png)
## 深度值精度
深度缓冲区中包含深度值介于`0.0`和`1.0`之间,从观察者看到其内容与场景中所有对象的 z 值进行比较。这些视图空间中的 z 值可以在投影平头截体的近平面和远平面之间的任何值。我们因此需要一些方法来转换这些视图空间 z 值到 [01] 的范围内,方法之一就是线性将它们转换为 [01] 范围。下面的 (线性) 方程把 z 值转换为 0.0 和 1.0 之间的值 :
深度缓冲包含了一个介于0.0和1.0之间的深度值,它将会与观察者视角所看见的场景中所有物体的z值进行比较。观察空间的z值可能是投影平截头体的**近平面**(Near)和**远平面**(Far)之间的任何值。我们需要一种方式来将这些观察空间的z值变换到[0, 1]范围之间,其中的一种方式就是将它们线性变换到[0, 1]范围之间。下面这个线性方程将z值变换到了0.0到1.0之间的深度值:
$$
\begin{equation} F_{depth} = \frac{z - near}{far - near} \end{equation}
$$
这里far和near是我们用来提供投影矩阵设置可见视图截锥的远近值 (见[坐标系](../01 Getting started/08 Coordinate Systems.md))。方程带内锥截体的深度值 z,并将其转换到 [01] 范围。在下面的图给出 z 值和其相应的深度值的关系:
这里的\(near\)和\(far\)值是我们之前提供投影矩阵设置可视平截头体的(见[坐标系](../01 Getting started/08 Coordinate Systems.md))那个 *near* 和 *far* 值。这个方程需要平截头体中的一个z值,并将它变换到[0, 1]范围中。z值和对应的深度值之间的关系可以在下图中看到:
![](../img/04/01/depth_linear_graph.png)
!!! Important
注意在物体接近近平面的时候,方程给出的深度值接近0.0,物体接近远平面时,方程给出的深度接近1.0。
注意所有的方程都会将非常近的物体的深度值设置为接近0.0的值,而当物体非常接近远平面的时候,它的深度值会非常接近1.0。
然而,在实践中是几乎从来不使用这样的线性深度缓冲区。正确的投影特性的非线性深度方程是和1/z成正比的 。这样基本上做的是在z很近是的高精度和 z 很远的时候的低精度。用几秒钟想一想: 我们真的需要1000单位远的物体和只有1单位远的物体的深度值有相同的精度吗?线性方程没有考虑这一点。
然而,在实践中是几乎永远不会使用这样的<def>线性深度缓冲</def>(Linear Depth Buffer)的。要想有正确的投影性质,需要使用一个非线性深度方程,它是与 1/z 成正比的。它做的是在z值很小的时候提供非常高的精度而在z值很远的时候提供更少的精度。花时间想想这个我们真的需要1000单位远的深度值和只有1单位远的充满细节的物体使用相同的精度吗线性方程并不会考虑这一点。
由于非线性函数是和 1/z 成正比,例如1.02.0 之间的 z 值,将变为 1.0 到 0.5之间, 这样在z非常小的时候给了我们很高的精度。50.0100.0 之间的 Z 值将只占 2%的浮点数的精度,这正是我们要的。这类方程,也需要近和远距离考虑,下面给出:
由于非线性方程与 1/z 成正比,1.02.0之间的z值将会变换至1.0到0.5之间的深度值这就是一个float提供给我们的一半精度了这在z值很小的情况下提供了非常大的精度。50.0100.0之间的z值将只占2%的float精度,这正是我们所需要的。这样的一个考虑了远近距离的方程是这样的:
$$
\begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation}
$$
如果你不知道这个方程到底怎么回事也不担心。要记住的重要一点是在深度缓冲的值不是线性的屏幕空间 (它们在视图空间投影矩阵应用之前是线性)。值为 0.5 在深度缓冲区并不意味着该对象的 z 值是投影平头截体的中间;顶点的 z 值是实际上相当接近近平面!你可以看到 z 值和产生深度缓冲区的值在下列图中的非线性关系:
如果你不知道这个方程怎么回事也不担心。重要的是要记住深度缓冲的值在屏幕空间中不是线性的在透视矩阵应用之前在观察空间中是线性的。深度缓冲中0.5的值并不代表着物体的z值是位于平截头体的中间了这个顶点的z值实际上非常接近近平面你可以在下图中看到z值和最终的深度缓冲值之间的非线性关系
![](../img/04/01/depth_non_linear_graph.png)
正如你所看到,一个附近的物体的小的 z 值因此给了我们很高的深度精度。变换 (从观察者的角度) 的 z 值的方程式被嵌入在投影矩阵,所以当我们变换顶点坐标从视图到裁剪,然后到非线性方程应用了的屏幕空间中。如果你好奇的投影矩阵究竟做了什么我建议阅读[这文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)。
可以看到深度值很大一部分是由很小的z值所决定的这给了近处的物体很大的深度精度。这个(从观察者的视角变换z值的方程嵌入在投影矩阵中的,所以当我们想将一个顶点坐标从观察空间至裁剪空间的时候这个非线性方程就被应用了。如果你想深度了解投影矩阵究竟做了什么我建议阅读[这文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)。
接下来我们看看这个非线性的深度值
如果我们想要可视化深度缓冲的话,非线性方程的效果很快就会变得很清楚
## 深度缓冲的可视化
## 深度缓冲的可视化
我们知道片段渲染器的内置`gl_FragCoord`向量的 z 值包含那个片段的深度值。如果我们要吧深度值为颜色输出,那么我们可以场景中显示的所有片段的深度值。我们可以返回基于片段的深度值的颜色向量:
我们知道片段着色器中,内建<var>gl_FragCoord</var>向量的z值包含那个特定片段的深度值。如果我们将这个深度值输出为颜色我们可以显示场景中所有片段的深度值。我们可以根据片段的深度值返回一个颜色向量来完成这一工作:
```c++
void main()
{
color = vec4(vec3(gl_FragCoord.z), 1.0f);
}
FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}
```
如果再次运行同一程序你可能会发现一切都是白的,看起来像我们的深度值都是最大1.0。那么为什么没有深度值接近 0.0而发暗?
如果再次运行程序的话,你可能会注意到所有东西都是白的,看起来就想我们所有的深度值都是最大1.0。所以为什么没有靠近0.0(即变暗)的深度值呢?
你可能还记得上一节中的屏幕空间的深度值是非线性如他们在z很小的时候有很高的精度,较大的 z 值有较低的精度。片段的深度值会迅速增加,所以几乎所有顶点的深度值接近 1.0。如果我们小心靠近物体,你最终可能会看到的色彩越来越暗,意味着它们的 z 值越来越小:
你可能还记得上一部分中说到,屏幕空间的深度值是非线性的,即它在z很小的时候有很高的精度,而z值很大的时候有较低的精度。片段的深度值会随着距离迅速增加,所以几乎所有顶点的深度值都是接近1.0。如果我们小心靠近物体,你可能会最终注意到颜色会渐渐变暗显示它们的z值在逐渐变小
![](../img/04/01/depth_testing_visible_depth.png)
这清楚地表明深度值的非线性性。近的物体相对远的物体对深度值比对象较大的影响。只移动几英寸就能让暗完全变
清楚地展示了深度值的非线性性。近的物体比起远处的物体对深度值有着更大的影响。只需要移动几厘米就能让颜色从暗完全变
但是我们可以让深度值变换线性。要实现这一目标我们需要让点应用投影变换逆的逆变换,成为单独的深度值的过程。这意味着我们必须首先重新变换范围 [01] 中的深度值为单位化的设备坐标(normalized device coordinates)范围内 [-11] (裁剪空间(clip space))。然后,我们想要反转非线性方程 (等式2) 就像在投影矩阵做的那样并将此反转方程应用于所得到的深度值。然后,结果是一个线性的深度值。听起来能行对吗?
然而,我们可以让片段非线性的深度值变换线性。要实现这个,我们需要仅仅反转深度值的投影变换。这也就意味着我们需要首先将深度值从[0, 1]范围重新变换到[-1, 1]范围的标准化设备坐标裁剪空间。接下来我们需要像投影矩阵那样反转这个非线性方程方程2并将这个反转方程应用到最终的深度值上。最终的结果是一个线性的深度值。听起来是可行的,对吧?
首先我们需要并不太难的 NDC 深度值转换:
首先我们将深度值变换为NDC不是非常困难
```c++
float z = depth * 2.0 - 1.0;
```
然后把我们所得到的 z 值应用逆换来检索的线性深度值:
接下来使用获取到的z值应用逆换来获取线性深度值
```c++
float linearDepth = (2.0 * near) / (far + near - z * (far - near));
float linearDepth = (2.0 * near * far) / (far + near - z * (far - near));
```
注意此方程不是方程 2 的精确的逆方程。这个方程投影矩阵中导出可以从新使用等式2将他转换为非线性深度值。这个方程也会考虑使用[01] 而不是 [nearfar]范围内的 z 值 。[math-heavy](http://www.songho.ca/opengl/gl_projectionmatrix.html)为感兴趣的读者阐述了大量详细的投影矩阵的知识;它还表明了方程是从哪里来的。
这个方程是用投影矩阵推导得出的它使用了方程2来非线性深度值,返回一个<var>near</var>与<var>far</var>之间的深度值。这篇注重数学的[文章](http://www.songho.ca/opengl/gl_projectionmatrix.html)为感兴趣的读者详细解释了投影矩阵,它也展示了这些方程是怎么来的。
这不是从投影矩阵推导出的准确公式;这个方程是除以far的结果。深度值的范围一直到far这作为一个介于 0.0 和 1.0 之间的颜色值并不合适。除以far的值把深度值映射到介于 0.0 和 1.0,更适合用于演示目的。
这个能够将屏幕空间的非线性深度值转变为线性深度值的完整的片段着色器如下所示:
将屏幕空间中非线性的深度值变换至线性深度值的完整片段着色器如下:
```c++
#version 330 core
out vec4 FragColor;
out vec4 color;
float LinearizeDepth(float depth)
float near = 0.1;
float far = 100.0;
float LinearizeDepth(float depth)
{
float near = 0.1;
float far = 100.0;
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near) / (far + near - z * (far - near));
float z = depth * 2.0 - 1.0; // back to NDC
return (2.0 * near * far) / (far + near - z * (far - near));
}
void main()
{
float depth = LinearizeDepth(gl_FragCoord.z) / far; // 为了演示除以far
color = vec4(vec3(depth), 1.0f);
{
float depth = LinearizeDepth(gl_FragCoord.z) / far; // 为了演示除以 far
FragColor = vec4(vec3(depth), 1.0);
}
```
如果现在运行该应用程序,我们得到在距离实际上线性深度值。尝试移动现场周围看到深度值线性变化
由于线性化的深度值处于<var>near</var><var>far</var>之间它的大部分值都会大于1.0并显示为完全的白色。通过在<fun>main</fun>函数中将线性深度值除以<var>far</var>,我们近似地将线性深度值转化到[0, 1]的范围之间。这样子我们就能逐渐看到一个片段越接近投影平截头体的远平面,它就会变得越亮,更适用于展示目的。
![](../img/04/01/depth_testing_visible_linear.png)
如果我们现在运行程序,我们就能看见深度值随着距离增大是线性的了。尝试在场景中移动,看看深度值是怎样以线性变化的
颜色主要是黑色的因为深度值线性范围从 0.1 的近平面到 100 的远平面,那里离我们很远。其结果是,我们相对靠近近平面,从而得到较低 (较暗) 的深度值。
![](../img/04/01/depth_testing_visible_linear.png)
颜色大部分都是黑色因为深度值的范围是0.1的**近**平面到100的**远**平面,它离我们还是非常远的。结果就是,我们相对靠近近平面,所以会得到更低的(更暗的)深度值。
## 深度冲突
两个平面或三角形如此紧密相互平行深度缓冲区不具有足够的精度以至于无法得到哪一个靠前。结果是这两个形状不断似乎切换顺序导致怪异出问题。这被称为**深度冲突(Z-fighting)**,因为它看上去像形状争夺顶靠前的位置
一个很常见的视觉错误会在两个平面或三角形非常紧密地平行排列在一起时会发生,深度缓冲有足够的精度来决定两个形状哪个在前面。结果是这两个形状不断地在切换前后顺序,这会导致很奇怪的花纹。这个现象叫做<def>深度冲突</def>(Z-fighting),因为它看起来像是这两个形状争夺(Fight)谁该处于顶端
我们到目前为止一直使用的场景中有几个地方深度冲突很显眼。容器被置于确切高度地板被安置这意味着容器的底平面与地板平面共面。两个面的深度值是相同的,因此深度测试没有办法找出哪个是正确
我们一直使用的场景中有几个地方深度冲突还是非常明显的。箱子被放置在地板的同一高度上,这也就意味着箱子的底面和地板是共面的(Coplanar)。这两个面的深度值都是一样的,所以深度测试没有办法决定应该显示哪一个
如果您移动摄像机到容器的里面,那么这个影响清晰可,容器的底部不断切换容器的平面和地板的平面:
如果你将摄像机移动到其中一个箱子的内部,你就能清楚地看到这个效果的,箱子的底部不断地在箱子底面与地板之间切换,形成一个锯齿的花纹:
![](../img/04/01/depth_testing_z_fighting.png)
深度冲突是深度缓冲区的普遍问题,当对象的距离越远一般越强(因为深度缓冲在z值非常大的时候没有很高的精度)。深度冲突还无法完全避免,但有一般的几个技巧,将有助于减轻或完全防止深度冲突在你的场景中的出现:
深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值比较大的时候有着更小的精度。深度冲突不能够被完全避免,但一般会有一些技巧有助于在你的场景中减轻或完全避免深度冲突
### 防止深度冲突
第一个也是最重要的技巧是让物体之间不要离得太近,以至于们的三角形重叠。通过在物体之间制造一点用户无法察觉到的偏移可以完全解决深度冲突。在容器和平面的条件下,我们可以把容器像+y方向上略微移动。这微小改变可能完全不被注意但是可以有效地减少或者完全解决深度冲突。然而这需要人工的干预每个物体,并进行彻底测试,以确保这个场景的物体之间没有深度冲突。
第一个也是最重要的技巧是**永远不要把多个物体摆得太近,以至于们的一些三角形重叠**。通过在两个物体之间设置一个用户无法注意到的偏移值,你可以完全避免这两个物体之间的深度冲突。在箱子和地板的例子中我们可以将箱子沿着正y轴稍微移动一点。箱子位置的这点微小改变将不太可能被注意到,但它能够完全减少深度冲突的发生。然而这需要对每个物体都手动调整,并且需要进行彻底测试来保证场景中没有物体会产生深度冲突。
另一个技巧是尽可能近平面设置远一些。前面我们讨论过越靠近近平面的位置精度越高。所以我们移动近平面远离观察者,我们可以在椎体内很有效的提高精度。然而近平面移动的太远会导致近处的物体被裁剪掉所以不断调整测试近平面的值,为你的场景找出最好的近平面距离。
第二个技巧是**尽可能近平面设置远一些**。在前面我们提到了精度在靠近**近**平面时是非常高的,所以如果我们将**近**平面远离观察者,我们将会对整个平截头体有着更大的精度。然而,将近平面设置太远会导致近处的物体被裁剪掉所以这通常需要实验和微调来决定最适合你的场景的**近**平面距离。
另外一个技巧是放弃一些性能来得到更高的深度值的精度。大多数的深度缓冲都是24位但现在显卡支持32位深度值,这让深度缓冲区的精度提高了一大节。所以牺牲一些性能你会得到更精确的深度测试,减少深度冲突。
另外一个很好的技巧是牺牲一些性能**使用更高精度的深度缓冲**。大部分深度缓冲的精度都是24位的,但现在大部分的显卡支持32位深度缓冲,这将会极大地提高精度。所以牺牲一些性能,你就能获得更高精度的深度测试,减少深度冲突。
我们已经讨论过的 3 个技术是最常见和容易实现消除深度冲突技术。还有一些其他技术需要更多的工作,仍然不完全消除深度冲突。深度冲突是一个常见的问题,但如果你将列举的技术适当结合你可能不会真的需要处理深度冲突。
我们上面讨论的三个技术是最普遍也是很容易实现的抗深度冲突技术。还有一些更复杂的技术,但它们依然不完全消除深度冲突。深度冲突是一个常见的问题,但如果你组合使用了上面列举出来的技术,你可能不会需要处理深度冲突

View File

@@ -3,201 +3,196 @@
原文 | [Stencil testing](http://learnopengl.com/#!Advanced-OpenGL/Stencil-testing)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
当片段着色器处理完片段之后,**模板测试(Stencil Test)** 就开始执行,和深度测试一样,它能丢弃一些片段。仍然保留下来的片段进入深度测试阶段,深度测试可能丢弃更多。模板测试基于另一个缓冲,这个缓冲叫做**模板缓冲(Stencil Buffer)**,我们被允许在渲染时更新它来获有意思的效果。
当片段着色器处理完一个片段之后,<def>模板测试</def>(Stencil Test)开始执行,和深度测试一样,它也可能会丢弃片段。接下来,被保留的片段进入深度测试,它可能丢弃更多的片段。深度测试是根据又一个缓冲来进行的,它叫做<def>模板缓冲</def>(Stencil Buffer),我们可以在渲染的时候更新它来获得一些很有意思的效果。
模板缓冲中的**模板值(Stencil Value)**通常是8位的因此每个片段/像素共有256种不同的模板值译注8位就是1字节大小因此和char的容量一样是256个不同值。这样我们就能将这些模板值设置为我们链接的然后在模板测试时根据这个模板值,我们就可以决定丢弃或保留了。
一个模板缓冲中,(通常)每个<def>模板值</def>(Stencil Value)是8位的。所以每个像素/片段一共能有256种不同的模板值。我们可以将这些模板值设置为我们想要的值然后当某一个片段有某一个模板值的时候,我们就可以选择丢弃或保留这个片段了。
!!! Important
每个窗口库都需要为你设置模板缓冲。GLFW自动做了这件事所以你不必告诉GLFW创建它,但是其他库可能没默认创建模板库,所以一定要查看你使用的库的文档。
每个窗口库都需要为你配置一个模板缓冲。GLFW自动做了这件事所以我们不需要告诉GLFW创建一个,但其它的窗口库可能不会默认给你创建一个模板库,所以记得要查看库的文档。
下面是一个模板缓冲的简单例子:
模板缓冲的一个简单例子如下
![image description](../img/04/02/stencil_buffer.png)
![](../img/04/02/stencil_buffer.png)
模板缓冲先清空模板缓冲设置所有片段的模板值为0然后开启矩形片段用1填充。场景中的模板值为1的那些片段才会被渲染(其的都被丢弃)。
模板缓冲首先会被清除为0之后在模板缓冲中使用1填充了一个空心矩形。场景中的片段将会只在片段的模板值为1的时候会被渲染(其的都被丢弃)。
无论我们在渲染哪里的片段,模板缓冲操作允许我们模板缓冲设为一个特定值。改变模板缓冲的内容实际上就是对模板缓冲进行写入。在同一(或接下来的)渲染迭代我们可以读取这些值来决定丢弃还是保留这些片段。使用模板缓冲的时候你可以随心所欲,但是需要遵守下面的原则
模板缓冲操作允许我们在渲染片段时将模板缓冲设为一个特定值。通过在渲染时修改模板缓冲的内容,我们**写入**了模板缓冲。在同一(或接下来的)渲染迭代中,我们可以**读取**这些值来决定丢弃还是保留某个片段。使用模板缓冲的时候你可以尽情发挥,但大体的步骤如下
* 启模板缓冲写入。
* 渲染物体,更新模板缓冲。
* 关闭模板缓冲写入。
* 渲染(其)物体,这次基于模板缓冲内容丢弃特定片段。
-模板缓冲写入。
- 渲染物体,更新模板缓冲的内容
- 禁用模板缓冲写入。
- 渲染(其)物体,这次根据模板缓冲内容丢弃特定片段。
使用模板缓冲我们可以基于场景中已绘制的片段,来决定是否丢弃特定的片段。
所以,通过使用模板缓冲我们可以根据场景中已绘制的其它物体的片段,来决定是否丢弃特定的片段。
你可以开启`GL_STENCIL_TEST`来开启模板测试。接着所有渲染函数调用都会以这样或那样的方式影响模板缓冲。
你可以启用<var>GL_STENCIL_TEST</var>来启用模板测试。在这一行代码之后,所有渲染调用都会以某种方式影响模板缓冲。
```c++
glEnable(GL_STENCIL_TEST);
```
要注意的是,像颜色和深度缓冲一样,在每次循环,你也得清空模板缓冲。
注意,和颜色和深度缓冲一样,你也需要在每次迭代之前清除模板缓冲。
```c++
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
```
同时,和深度测试的`glDepthMask`函数一样,模板缓冲也有一个相似函数。`glStencilMask`允许我们给模板值设置一个**位掩码(Bitmask)**,它与模板值进行按位与(AND)运算决定缓冲是否可写。默认设置的位掩码都是1这样就不会影响输出,但如果我们设置为0x00所有写入深度缓冲最后都是0。这和深度缓冲的`glDepthMask(GL_FALSE)`很类似:
和深度测试的<fun>glDepthMask</fun>函数一样,模板缓冲也有一个类似的函数。<fun>glStencilMask</fun>允许我们设置一个位掩码(Bitmask),它会与将要写入缓冲的模板值进行与(AND)运算。默认情况下设置的位掩码所有位都为1影响输出,但如果我们将它设置为`0x00`写入缓冲的所有模板值最后都会变成0.这与深度测试中的<fun>glDepthMask(GL_FALSE)</fun>是等价的。
```c++
// 0xFF == 0b11111111
//此时,模板值与它进行按位与运算结果是模板值,模板缓冲可写
glStencilMask(0xFF);
// 0x00 == 0b00000000 == 0
//此时模板值与它进行按位与运算结果是0模板缓冲不可写
glStencilMask(0x00);
glStencilMask(0xFF); // 每一位写入模板缓冲时都保持原样
glStencilMask(0x00); // 每一位在写入模板缓冲时都会变成0禁用写入
```
多数情况你的模板掩码stencil mask写为0x00或0xFF就行,但是最好知道有一个选项可以自定义位掩码。
部分情况下你都只会使用`0x00`或者`0xFF`作为模板掩码(Stencil Mask),但是知道有选项可以设置自定义位掩码总是好的
## 模板函数
和深度测试一样,我们也有几个不同控制权,决定何时模板测试通过或失败以及它怎样影响模板缓冲。一共有两函数可供我们使用去配置模板测试:`glStencilFunc`和`glStencilOp`
和深度测试一样,我们对模板缓冲应该通过还是失败以及它应该如何影响模板缓冲,也是有一定控制的。一共有两函数能够用来配置模板测试:<fun>glStencilFunc</fun>和<fun>glStencilOp</fun>
`void glStencilFunc(GLenum func, GLint ref, GLuint mask)`函数有三个参数:
<fun>glStencilFunc(GLenum func, GLint ref, GLuint mask)</fun>一共包含三个参数:
* **func**:设置模板测试操作。这个测试操作应用到已储存的模板值和`glStencilFunc`的`ref`值上可用的选项是:`GL_NEVER`、`GL_LEQUAL`、`GL_GREATER`、`GL_GEQUAL`、`GL_EQUAL`、`GL_NOTEQUAL`、`GL_ALWAYS`。它们的语义和深度缓冲的似。
* **ref**:指定模板测试的参考值。模板缓冲的内容会与这个值对比
* **mask**:指定一个掩码值。在模板测试比较参考值和储存的模板值前,会用掩码值对它们分别进行按位与(AND)操作。初始情况下所有位都为1。
- `func`:设置模板测试函数(Stencil Test Function)。这个测试函数将会应用到已储存的模板值上和<fun>glStencilFunc</fun>函数的`ref`值上可用的选项有:<var>GL_NEVER</var>、<var>GL_LESS</var>、<var>GL_LEQUAL</var>、<var>GL_GREATER</var>、<var>GL_GEQUAL</var>、<var>GL_EQUAL</var>、<var>GL_NOTEQUAL</var>和<var>GL_ALWAYS</var>。它们的语义和深度缓冲的函数类似。
- `ref`:设置了模板测试的参考值(Reference Value)。模板缓冲的内容会与这个值进行比较
- `mask`:设置一个掩码,它将会与参考值和储存的模板值在测试比较它们之前进行与(AND)运算。初始情况下所有位都为1。
上面简单模板例子里,方程应该设置为:
一开始的那个简单模板例子中,函数被设置为:
```c
```c++
glStencilFunc(GL_EQUAL, 1, 0xFF)
```
会告诉OpenGL无论何时,一个片段模板值等于(`GL_EQUAL`)参考值`1`,片段就能通过测试被绘制,否则会被丢弃。
会告诉OpenGL只要一个片段模板值等于(`GL_EQUAL`)参考值1,片段将会通过测试被绘制,否则会被丢弃。
但是`glStencilFunc`只描述了OpenGL对模板缓冲做什么而不是描述我们如何更新缓冲。这就需要`glStencilOp`登场了。
但是<fun>glStencilFunc</fun>仅仅描述了OpenGL应该对模板缓冲内容做什么,而不是我们应该如何更新缓冲。这就需要<fun>glStencilOp</fun>这个函数了。
`void glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)`函数包含三个选项,我们可以指定每个选项的动作
<fun>glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)</fun>一共包含三个选项,我们能够设定每个选项应该采取的行为
* **sfail** 如果模板测试失败采取的动作
* **dpfail** 如果模板测试通过,但深度测试失败时采取的动作
* **dppass** 如果深度测试和模板测试都通过,将采取的动作
- `sfail`模板测试失败采取的行为
- `dpfail`模板测试通过,但深度测试失败时采取的行为
- `dppass`:模板测试和深度测试都通过采取的行为
每个选项都可以使用下列任何一个动作。
每个选项都可以选用以下的其中一种行为:
操作 | 描述
行为 | 描述
---|---
GL_KEEP | 保持现有的模板值
GL_ZERO | 将模板值置为0
GL_REPLACE | 将模板值设置为用`glStencilFunc`函数设置的**ref**
GL_INCR | 如果模板值不是最大值将模板值+1
GL_INCR_WRAP| 与`GL_INCR`一样将模板值+1如果模板值已经是最大值则设为0
GL_DECR | 如果模板值不是最小值将模板值-1
GL_DECR_WRAP| 与`GL_DECR`一样将模板值-1如果模板值已经是最小值则设为最大值
GL_INVERT | Bitwise inverts the current stencil buffer value.
GL_KEEP | 保持当前储存的模板值
GL_ZERO | 将模板值置为0
GL_REPLACE | 将模板值设置为<fun>glStencilFunc</fun>函数设置的`ref`
GL_INCR | 如果模板值小于最大值将模板值1
GL_INCR_WRAP| 与<var>GL_INCR</var>一样,但如果模板值超过了最大值则归零
GL_DECR | 如果模板值大于最小值将模板值1
GL_DECR_WRAP| 与<var>GL_DECR</var>一样但如果模板值小于0则将其设置为最大值
GL_INVERT | 按位翻转当前的模板缓冲值
`glStencilOp`函数默认设置为 (GL_KEEP, GL_KEEP, GL_KEEP) ,所以任何测试的任何结果,模板缓冲都会保留它的值。默认行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你必须像任意选项指定至少一个不同的动作
默认情况下<fun>glStencilOp</fun>是设置为`(GL_KEEP, GL_KEEP, GL_KEEP)`的,所以不论任何测试的结果是如何,模板缓冲都会保留它的值。默认行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你需要至少对其中一个选项设置不同的
使用`glStencilFunc`和`glStencilOp`,我们可以指定在什么时候以及我们打算怎么样去更新模板缓冲了,我们也可以指定何时让测试通过或不通过。什么时候片段会被抛弃。
所以,通过使用<fun>glStencilFunc</fun>和<fun>glStencilOp</fun>,我们可以精确地指定更新模板缓冲的时机与行为了,我们也可以指定什么时候该让模板缓冲通过,即什么时候片段需要被丢弃。
# 物体轮廓
看了前面的部分你未必能理解模板测试是如何工作的,所以我们会展示一个用模板测试实现的一个特别的和有用的功能,叫做**物体轮廓(Object Outlining)**
仅仅看了前面的部分你还是不太可能能够完全理解模板测试的工作原理,所以我们会展示一个使用模板测试就可以完成的有用特性,它叫做<def>物体轮廓</def>(Object Outlining)。
![](../img/04/02/stencil_object_outlining.png)
物体轮廓就像它的名字所描述的那样,它能够给每个(或一个)物体创建一个有颜色的边。在策略游戏中当你打算选择一个单位的时候它特别有用。给物体加上轮廓的步骤如下:
物体轮廓所能做的事情正如它名字所描述的那样。我们将会为每个(或一个)物体在它的周围创建一个很小的有色边框。当你想要在策略游戏中选中一个单位进行操作的,想要告诉玩家选中的是哪个单位的时候,这个效果就非常有用了。为物体创建轮廓的步骤如下:
1. 在绘制物体前,模板方程设置为`GL_ALWAYS`用1更新物体将被渲染的片段
2. 渲染物体,写入模板缓冲
3. 关闭模板写入深度测试。
4. 每个物体放一点点。
5. 使用一个不同的片段着色器用来输出一个颜色。
6. 再次绘制物体,但只是当它们片段的模板值不1时才进行
7. 开启模板写入和深度测试。
1. 在绘制(需要添加轮廓的)物体前,模板函数设置为<var>GL_ALWAYS</var>每当物体的片段被渲染时将模板缓冲更新为1
2. 渲染物体。
3. 禁用模板写入以及深度测试。
4. 每个物体放一点点。
5. 使用一个不同的片段着色器输出一个单独的(边框)颜色。
6. 再次绘制物体,但只它们片段的模板值不等于1时才绘制
7. 再次启用模板写入和深度测试。
这个过程将每个物体的片段模板缓冲设置为1当我们绘制边框的时候我们基本上绘制的是放大版本的物体的通过测试的地方,放大的版本绘制后物体就会有一个边。我们基本使用模板缓冲丢弃所有的不是原来物体片段的放大的版本内容
这个过程将每个物体的片段处的深度缓冲设置为1当我们想要绘制边框的时候,我们基本上只是在绘制一个放大版本的物体,并且每当模板测试通过的时候,也就是物体的边框的位置,放大的版本将会被绘制。我们基本使用模板缓冲丢弃了放大版本物体中,位于原本物体片段处的,所有片段
我们先来创建一个非常基本的片段着色器,它输出一个边框颜色。我们简单地设置一个固定的颜色值,这个着色器命名为shaderSingleColor
所以我们先来创建一个很简单的片段着色器,它输出一个边框颜色。我们简单地给它设置一个硬编码的颜色值,这个着色器命名为<var>shaderSingleColor</var>
```c++
void main()
{
outColor = vec4(0.04, 0.28, 0.26, 1.0);
FragColor = vec4(0.04, 0.28, 0.26, 1.0);
}
```
我们只打算给两个箱子加上边框,所以我们不会对地面做什么。这样我们要先绘制地面,然后再绘制两个箱子(同时写入模板缓冲),接着我们绘制放大的箱子(同时丢弃前面已经绘制的箱子的那部分片段)。
我们只想给那两个箱子加上边框,所以我们让地板不参与这个过程。我们希望首先绘制地板,再绘制两个箱子(写入模板缓冲),之后绘制放大的箱子(并丢弃覆盖了之前绘制的箱子片段的那些片段)。
我们先开启模板测试,设置模板、深度测试通过或失败时才采取动作
我们首先启用模板测试,设置测试通过或失败时的行为
```c++
glEnable(GL_DEPTH_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
```
如果任何测试失败我们什么不做,我们简单地保持深度缓冲中当前储存的值。如果模板测试和深度测试都成功了,我们就将储存的模板值替换为`1`,我们要用`glStencilFunc`来做这件事
如果其中的一个测试失败了,我们什么不做,我们仅仅保留当前储存在模板缓冲中的值。如果模板测试和深度测试都通过了,那么我们希望将储存的模板值设置为参考值,参考值能够通过<fun>glStencilFunc</fun>来设置我们之后会设置为1
我们清空模板缓冲为0箱子所有绘制的片段的模板缓冲更新为1
我们模板缓冲清除为0箱子所有绘制的片段,将模板值更新为1
```c++
glStencilFunc(GL_ALWAYS, 1, 0xFF); //所有片段都要写入模板缓冲
glStencilMask(0xFF); // 设置模板缓冲为可写状态
normalShader.Use();
glStencilFunc(GL_ALWAYS, 1, 0xFF); // 所有片段都应该更新模板缓冲
glStencilMask(0xFF); // 启用模板缓冲写入
normalShader.use();
DrawTwoContainers();
```
使用`GL_ALWAYS`模板测试函数,我们保箱子的每个片段模板值1更新模板缓冲。因为片段会通过模板测试,在我们绘制它们的地方,模板缓冲参考值更新
通过使用<var>GL_ALWAYS</var>模板测试函数,我们保证了箱子的每个片段都会将模板缓冲的模板值更新为1。因为片段永远会通过模板测试,在绘制片段的地方,模板缓冲会被更新为参考值。
现在箱子绘制之处,模板缓冲更新为1了我们将要绘制放大的箱子这次关闭模板缓冲的写入:
现在模板缓冲在箱子绘制的地方都更新为1了我们将要绘制放大的箱子但这次要禁用模板缓冲的写入:
```c++
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00); // 禁止修改模板缓冲
glStencilMask(0x00); // 禁止模板缓冲的写入
glDisable(GL_DEPTH_TEST);
shaderSingleColor.Use();
shaderSingleColor.use();
DrawTwoScaledUpContainers();
```
我们模板方程设置为`GL_NOTEQUAL`,它保证我们只箱子上不等于1的部分这样只绘制前绘制的箱子外围的那部分。注意我们也要关闭深度测试,这样放大的箱子也就是边框不会被地覆盖。
我们模板函数设置为<var>GL_NOTEQUAL</var>,它保证我们只绘制箱子上模板值不为1的部分只绘制箱子在之前绘制的箱子之外的部分。注意我们也禁用了深度测试,放大的箱子,即边框不会被地板所覆盖。
做完之后还要保证再次开启深度缓冲。
记得要在完成之后重新启用深度缓冲。
场景中物体边框的绘制方法最后看起来像这样:
场景中物体轮廓的完整步骤会看起来像这样:
```c++
glEnable(GL_DEPTH_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glStencilMask(0x00); // 绘制地板时确保关闭模板缓冲的写入
normalShader.Use();
glStencilMask(0x00); // 记得保证我们在绘制地板的时候不会更新模板缓冲
normalShader.use();
DrawFloor()
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
DrawTwoContainers();
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
glStencilMask(0x00);
glDisable(GL_DEPTH_TEST);
shaderSingleColor.Use();
shaderSingleColor.use();
DrawTwoScaledUpContainers();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);
glEnable(GL_DEPTH_TEST);
```
理解这段代码后面的模板测试的思路并不难以理解。如果还不明白尝试再仔细阅读上面的部分,尝试理解每个函数的作用,现在你已经看到了它的使用方法的例子
只要你理解了模板缓冲背后的大体思路,这个代码片段就不是那么难理解。如果还是不能理解的话,尝试再仔细阅读之前的部分,尝试通过上面使用的范例,完全理解每个函数的功能
这个边框的算法的结果在深度测试教程的那个场景中,看起来像这样:
在[深度测试](01 Depth testing.md)小节的场景中,这个轮廓算法的结果看起来会像是这样
![](../img/04/02/stencil_scene_outlined.png)
在这里[查看源码](http://learnopengl.com/code_viewer.php?code=advanced/stencil_testing)和[着色器](http://learnopengl.com/code_viewer.php?code=advanced/depth_testing_func_shaders),看看完整的物体边框算法是怎样的
可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/2.stencil_testing/stencil_testing.cpp)查看源代码,看看物体轮廓算法的完整代码
!!! Important
你可以看到两个箱子边框重合通常是我们希望得到的(想想策略游戏中,我们打算选择10个单位;我们通常会希望把边界合并)。如果你想让每个物体都有自己的边界那么你需要每个物体清空模板缓冲,创造性地使用深度缓冲。
你可以看到两个箱子边框重合了,这通常是我们想要的结果(想想策略游戏中,我们希望选择10个单位,合并边框通常是我们想需要的结果)。如果你想让每个物体都有一个完整的边框,你需要每个物体清空模板缓冲,并有创意地利用深度缓冲。
目前看到的物体边框算法在一些游戏中显示备选物体(想策略游戏)非常常用,这样的算法可以在一个模型类中轻实现。你可以简单地在模型类设置一个布尔类型的标识来决定是否绘制边框。如果你想要更多的创造性,你可以使用后处理post-processing过滤比如高斯模糊来使边框看起来更自然。
你看到的物体轮廓算法在需要显示选中物体的游戏(想策略游戏)非常常见。这样的算法能够在一个模型类中轻实现。你可以在模型类设置一个boolean标记来设置需不需要绘制边框。如果你有创造力的话,你可以使用后处理滤镜(Filter),像是高斯模糊(Gaussian Blur),让边框看起来更自然。
除了物体边框以外,模板测试还有很多其他的应用目的,比如在后视镜中绘制纹理,这样它会很好的适合镜子形状,比如使用一叫做shadow volumes的模板缓冲技术渲染实时阴影。模板缓冲我们的已扩展的OpenGL工具箱中给我们提供了另一种好用工具。
除了物体轮廓之外,模板测试还有很多用途,比如在一个后视镜中绘制纹理,让它能够绘制到镜子形状中,或者使用一叫做阴影体积(Shadow Volume)的模板缓冲技术渲染实时阴影。模板缓冲我们已经很丰富的OpenGL工具箱又提供了一个很好的工具。

View File

@@ -3,57 +3,51 @@
原文 | [Blending](http://learnopengl.com/#!Advanced-OpenGL/Blending)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J, [Django](http://bullteacher.com/)
校对 | 暂无校对
在OpenGL中物体透明技术通常被叫做**混合(Blending)**。透明是物体(或物体的一部分)非纯色而是混合色,这种颜色来自于不同浓度的自身颜色和它后面的物体颜色。一个有色玻璃窗就是一种透明物体,玻璃有自身的颜色,但是最终的颜色包含了所有玻璃后面的颜色。这也正是混合这名称的出处,因为我们将多种(来自于不同物体)颜色混合为一个颜色,透明使得我们可以看穿物体。
OpenGL中<def>混合</def>(Blending)通常是实现物体<def>透明度</def>(Transparency)的一种技术。透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。一个有色玻璃窗是一个透明的物体,玻璃有它自己的颜色,但它最终的颜色还包含了玻璃之后所有物体的颜色。这也是混合这一名字的出处,我们<def>混合</def>(Blend)(不同物体的)多种颜色为一种颜色。所以透明度能让我们看穿物体。
![](../img/04/03/blending_transparency.png)
透明物体可以是完全透明(它使颜色完全穿透)或者半透明的(它使颜色穿透的同时也显示自身颜色)。一个物体的透明度被定义为它的颜色的alpha值。alpha颜色值是一个颜色向量的第四个元素,你可能已经看到很多了。在这个教程前,我们一直把这个元素设置为1.0这样物体的透明度就是0.0同样的,当alpha值0.0时就表示物体是完全透明的alpha值为0.5时表示物体的颜色50%自身的颜色50%的后面的颜色组成
透明物体可以是完全透明的(让所有的颜色穿过),或者半透明的(它颜色通过,同时也显示自身颜色)。一个物体的透明度是通过它颜色的<def>aplha</def>值来决定的。Alpha颜色值是颜色向量的第四个分量,你可能已经看到过它很多了。在这个教程之前我们都将这个第四个分量设置为1.0让这个物体的透明度0.0当alpha值0.0时物体将会是完全透明的。当alpha值为0.5时物体的颜色50%是来自物体自身的颜色50%来自背后物体的颜色。
我们之前所使用的纹理都是由3个颜色元素组成的红、绿、蓝但是有些纹理同样有一个内嵌的aloha通道它为每个纹理像素(Texel)包含一个alpha值。这个alpha值告诉我们纹理的哪个部分透明度,以及这个透明度有多少。例如,下面[窗户纹理](../img/04/03/blending_transparent_window.png)的玻璃部分的alpha值为0.25(它的颜色是完全红色,但由于它有75的透明度它会很大程度上反映出网站背景色,看起来不那么红了),角落部分alpha是0.0。
我们目前一直使用的纹理有三个颜色分量:红、绿、蓝。但一些材质会有一个内嵌的alpha通道每个纹素(Texel)包含一个<def>alpha</def>值。这个alpha值精确地告诉我们纹理个部分透明度。比如说,下面这个[窗户纹理](../img/04/03/blending_transparent_window.png)的玻璃部分的alpha值为0.25(它在一般情况下是完全红色但由于它有75%的透明度,能让很大一部分的网站背景颜色穿过,让它看起来不那么红了),边框的alpha是0.0。
![](../img/04/03/blending_transparent_window.png)
我们很快就会这个窗纹理加到场景中,但是首先我们讨论一简单的技术来实现纹理的半透明,也就是完全透明和完全不透明。
我们很快就会这个窗纹理加到场景中,但是首先我们需要讨论一个更简单的技术来实现只有完全透明和完全不透明的纹理的透明度
## 忽略片段
## 丢弃片段
有些图并不关心半透明度,但也想基于纹理颜色值显示一部分。例如,创建草这种物体你不需要花费很大力气,通常把一个草的纹理贴2D四边形上然后这个四边形放置到你的场景中。可是,草并不是像2D四边形这样的形状,而只需要显示草纹理的部分而忽略其他部分。
有些图并不需要半透明,只需要根据纹理颜色值显示一部分,或者不显示一部分,没有中间情况。比如说草,如果想不太费劲地创建草这种东西,你需要将一个草的纹理贴在一个2D四边形(Quad)上,然后这个四边形放到场景中。然而,草的形状和2D四边形的形状并不完全相同,所以你只想显示草纹理的某些部分而忽略剩下的部分。
下面纹理正是这样的纹理,它既有完全不透明的部分alpha值为1.0也有完全透明的部分alpha值为0.0没有半透明的部分。你可以看到没有草的部分,图片显示网站的背景色而不是它身的那部分颜色。
下面这个纹理正是这样的,它要么是完全不透明的alpha值为1.0,要么是完全透明的alpha值为0.0),没有中间情况。你可以看到,只要不是草的部分,这个图片显示的都是网站的背景色而不是它身的颜色。
![](../img/04/03/grass.png)
所以,当向场景中添加像这样的纹理时,我们不希望看到一个方块图像,而是只显示实际的纹理像素,剩下的部分可以被看穿。我们要忽略(丢弃)纹理透明部分的像素,不将这些片段存到颜色缓冲中。在此之前,我们还要学一下如何加载一个带有透明像素的纹理。
所以添加像这样的植被到场景中时,我们不希望看到草的方形图像,而是只显示草的部分,并能看透图像其余的部分。我们想要<def>丢弃</def>(Discard)显示纹理透明部分的片段,不将这些片段存到颜色缓冲中。在此之前,我们还要学如何加载一个透明的纹理。
加载有alpha值的纹理我们需要告诉SOIL去加载RGBA元素图像而不再是RGB元素的。SOIL能以RGBA的方式加载大多数没有alpha值的纹理它会将这些像素的alpha值设为了1.0。
要想加载有alpha值的纹理我们并不需要改很多东西,`stb_image`在纹理有alpha通道的时候会自动加载但我们仍要在纹理生成过程中告诉OpenGL我们的纹理现在使用alpha通道了
```c++
unsigned char * image = SOIL_load_image(path, &width, &height, 0, SOIL_LOAD_RGBA);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
```
不要忘记还要改变OpenGL生成的纹理
同样保证你在片段着色器中获取了纹理的全部4个颜色分量而不仅仅是RGB分量
```c++
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image);
```
保证你在片段着色器中获取了纹理的所有4个颜色元素而不仅仅是RGB元素
```c++
void main()
{
// color = vec4(vec3(texture(texture1, TexCoords)), 1.0);
color = texture(texture1, TexCoords);
// FragColor = vec4(vec3(texture(texture1, TexCoords)), 1.0);
FragColor = texture(texture1, TexCoords);
}
```
现在我们知道如何加载透明纹理,是时候试试在深度测试教程里那个场景中添加几根草了
既然我们已经知道如何加载透明纹理,是时候将它带入实战了,我们将会在[深度测试](01 Depth testing.md)小节的场景中加入几棵草
我们创建一个`std::vector`,并向里面添加几个`glm::vec3`变量,来表示草的位置:
我们创建一个vector向里面添加几个`glm::vec3`变量来代表草的位置:
```c++
vector<glm::vec3> vegetation;
@@ -64,224 +58,220 @@ vegetation.push_back(glm::vec3(-0.3f, 0.0f, -2.3f));
vegetation.push_back(glm::vec3( 0.5f, 0.0f, -0.6f));
```
一个单独的四边形贴上草的纹理这并不能完美的表现出真实的草,但是比起加载复杂的模型还是要高效很多,利用一些小技巧,比如在同一个地方添加多个不同朝向的草,还是能获得比较好的果的。
每个草都被渲染到了一个四边形上,贴上草的纹理这并不能完美地表示3D的草,但这比加载复杂的模型要快多了。使用一些小技巧,比如在同一个位置加入一些旋转后的草四边形,你仍然能获得比较好的果的。
由于草纹理添加到四边形物体上,我们需要再次创建另一个VAO向里面填充VBO以及设置合理的顶点属性指针。在我们绘制完地和两个立方体后,我们就来绘制草
因为草的纹理添加到四边形对象上的,我们需要创建另一个VAO填充VBO设置正确的顶点属性指针。接下来,在绘制完地和两个立方体后,我们将会绘制草:
```c++
glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);
for(GLuint i = 0; i < vegetation.size(); i++)
for(unsigned int i = 0; i < vegetation.size(); i++)
{
model = glm::mat4();
model = glm::translate(model, vegetation[i]);
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
model = glm::translate(model, vegetation[i]);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
glBindVertexArray(0);
}
```
运行程序你将看到:
![](../img/04/03/blending_no_discard.png)
出现这种情况是因为OpenGL默认是不知道如何处理alpha值的不知道何时忽略(丢弃)它们。我们不得不手动做这件事。幸运的是这很简单感谢着色器GLSL为我们提供了discard命令保证片段不会被进一步处理,这样就不会进入颜色缓冲。有了这个命令我们就可以在片段着色器中检一个片段是否有在一定的阈限下的alpha值如果有那么丢弃这个片段,就好像它不存在一样:
出现这种情况是因为OpenGL默认是不知道怎么处理alpha值的不知道什么时候应该丢弃片段。我们需要自己手动来弄。幸运的是有了着色器这还是非常容易的。GLSL给了我们`discard`命令,一旦被调用,它就会保证片段不会被进一步处理,所以就不会进入颜色缓冲。有了这个指令,我们就能够在片段着色器中检一个片段的alpha值是否低于某个阈值如果是的话丢弃这个片段,就好像它不存在一样:
```c++
#version 330 core
in vec2 TexCoords;
out vec4 FragColor;
out vec4 color;
in vec2 TexCoords;
uniform sampler2D texture1;
void main()
{
{
vec4 texColor = texture(texture1, TexCoords);
if(texColor.a < 0.1)
discard;
color = texColor;
FragColor = texColor;
}
```
在这儿我们检被采样纹理颜色包含着一个低于0.1这个阈限的alpha值如果有丢弃这个片段。这个片段着色器能够保证我们只渲染哪些不是完全透明的片段。现在我们来看看效果
这里,我们检被采样纹理颜色的alpha值是否低于0.1的阈值,如果是的话,则丢弃这个片段。片段着色器保证了它只会渲染不是(几乎)完全透明的片段。现在它看起来就正常了
![](../img/04/03/blending_discard.png)
!!! Important
需要注意的是当采样纹理边缘的时候OpenGL在边界值和下一个重复的纹理的值之间进行插值(因为我们它的放置方式设置成了GL_REPEAT)。这样就行了,但是由于我们使用的是透明值,纹理图片的上部获得了它的透明值是与底边的纯色值进行插值。结果是一个有点半透明的边,你可以从我们的纹理四边形的四周看到。为了防止它的出现,当你使用alpha纹理的时候要把纹理环绕方式设置为`GL_CLAMP_TO_EDGE`
注意,当采样纹理边缘的时候OpenGL会对边缘的值和纹理下一个重复的值进行插值(因为我们它的环绕方式设置为了<var>GL_REPEAT</var>。这通常是没问题的,但是由于我们使用透明值,纹理图像的顶部将会与底部边缘的纯色值进行插值。这样的结果是一个半透明的有色边框,你可能会看见它环绕着你的纹理四边形。要想避免这个,每当你alpha纹理的时候,请将纹理环绕方式设置为<var>GL_CLAMP_TO_EDGE</var>
`glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);`
`glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);`
你可以[在这里得到源码](http://learnopengl.com/code_viewer.php?code=advanced/blending_discard)。
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/3.1.blending_discard/blending_discard.cpp)找到源码。
## 混合
上述丢弃片段的方式,不能使我们获得渲染半透明图像我们要么渲染出像素,要么完全丢弃它。为了渲染出不同的透明度级别,我们需要开启**混合**(Blending)。像大多数OpenGL的功能一样我们可以开启`GL_BLEND`来启用**混合(Blending)**功能
虽然直接丢弃片段很好,但它不能我们渲染半透明图像我们要么渲染一个片段,要么完全丢弃它。要想渲染有多个透明度级别的图像,我们需要启用<def>混合</def>(Blending)。OpenGL大多数的功能一样,我们可以启用<var>GL_BLEND</var>来启用混合
```c++
glEnable(GL_BLEND);
```
启混合后,我们需要告诉OpenGL它该如何混合。
用了混合我们需要告诉OpenGL它该**如何**混合。
OpenGL以下面的方程进行混合
OpenGL中的混合是通过下面这个方程来实现的
$$
\begin{equation}\bar{C}_{result} = \bar{\color{green}C}_{source} * \color{green}F_{source} + \bar{\color{red}C}_{destination} * \color{red}F_{destination}\end{equation}
$$
* \(\bar{\color{green}C}_{source}\):源颜色向量。这是自纹理的本来的颜色向量。
* \(\bar{\color{red}C}_{destination}\):目标颜色向量。这是储存在颜色缓冲中当前位置的颜色向量。
* \(\color{green}F_{source}\):源因子。设置了对源颜色的alpha值影响。
* \(\color{red}F_{destination}\):目标因子。设置了对目标颜色的alpha影响。
- \(\bar{\color{green}C}_{source}\):源颜色向量。这是自纹理的颜色向量。
- \(\bar{\color{red}C}_{destination}\):目标颜色向量。这是当前储存在颜色缓冲中的颜色向量。
- \(\color{green}F_{source}\):源因子值。指定了alpha值对源颜色的影响。
- \(\color{red}F_{destination}\):目标因子值。指定了alpha值对目标颜色的影响。
片段着色器运行完成并且所有的测试都通过后,混合方程才能自由执行片段颜色输出当前它在颜色缓冲中(前面片段的颜色在当前片段之前储存)。源和目标颜色会自动被OpenGL设置,而源和目标因子可以我们自由设置。我们来看一个简单的例子:
片段着色器运行完成后,并且所有的测试都通过后,这个<def>混合方程</def>(Blend Equation)才会应用到片段颜色输出当前颜色缓冲中的值(当前片段之前储存的之前片段的颜色)上。源颜色和目标颜色将会由OpenGL自动设定,但源因子和目标因子的值可以我们来决定。我们来看一个简单的例子:
![](../img/04/03/blending_equation.png)
我们有两个方,我们希望在红色方块上绘制绿色方块。红色方块会成为目标颜色(它会先进入颜色缓冲),我们将红色方上绘制绿色方
我们有两个方,我们希望将这个半透明的绿色方形绘制在红色方形之上。红色的方形将会是目标颜色(所以它应该先在颜色缓冲),我们将要在这个红色方形之上绘制这个绿色方
那么问题来了:我们怎样来设置因子呢?我们起码要把绿色方乘以它的alpha值所以我们打算把\(F_{src}\)设置为源颜色向量的alpha值0.6。接着,让目标方块的浓度等于剩下的alpha值。如果最终颜色中绿色方块的浓度为60%我们就把红色的浓度设为40%1.0 0.6。所以我们\(F_{destination}\)设置为1减去源颜色向量的alpha值。方程变成:
问题来了:我们将因子值设置为什么?嘛,我们至少想让绿色方乘以它的alpha值所以我们想要将\(F_{src}\)设置为源颜色向量的alpha值,也就是0.6。接下来就应该清楚了,目标方形的贡献应该为剩下的alpha值。如果绿色方形对最终颜色贡献了60%那么红色方块应该对最终颜色贡献了40%,即`1.0 - 0.6`。所以我们\(F_{destination}\)设置为1减去源颜色向量的alpha值。这个方程变成
$$
\begin{equation}\bar{C}_{result} = \begin{pmatrix} \color{red}{0.0} \\ \color{green}{1.0} \\ \color{blue}{0.0} \\ \color{purple}{0.6} \end{pmatrix} * \color{green}{0.6} + \begin{pmatrix} \color{red}{1.0} \\ \color{green}{0.0} \\ \color{blue}{0.0} \\ \color{purple}{1.0} \end{pmatrix} * \color{red}{(1 - 0.6)} \end{equation}
$$
最终方块结合部分包含了60%绿色40%红色,得到一种脏兮兮的颜色:
结果就是重叠方形的片段包含了一个60%绿色40%红色一种脏兮兮的颜色:
![](../img/04/03/blending_equation_mixed.png)
的颜色被储存到颜色缓冲中,取代先前的颜色。
的颜色将会被储存到颜色缓冲中,替代之前的颜色。
个方案不错,但我们怎样告诉OpenGL使用这样的因子呢?好有一个叫做`glBlendFunc`的函数
`void glBlendFunc(GLenum sfactor, GLenum dfactor)`接收两个参数来设置源source和目标destination因子。OpenGL为我们定义了很多选项我们把最常用的列在下面。注意颜色常数向量\(\bar{\color{blue}C}_{constant}\)可以用`glBlendColor`函数分开来设置。
样子很不错,但我们该如何让OpenGL使用这样的因子呢好有一个专门的函数,叫做<fun>glBlendFunc</fun>
<fun>glBlendFunc(GLenum sfactor, GLenum dfactor)</fun>函数接受两个参数,来设置<def>源</def>和<def>目标因子</def>。OpenGL为我们定义了很多个选项我们将在下面列出大部分最常用的选项。注意常数颜色向量\(\bar{\color{blue}C}_{constant}\)可以通过<fun>glBlendColor</fun>函数来另外设置。
选项 | 值
---|---
GL_ZERO | \(0\)
GL_ONE | \(1\)
GL_SRC_COLOR | 源颜色向量\(\bar{\color{green}C}_{source}\)
GL_ONE_MINUS_SRC_COLOR | \(1 - \bar{\color{green}C}_{source}\)
GL_DST_COLOR | 目标颜色向量\(\bar{\color{red}C}_{destination}\)
GL_ONE_MINUS_DST_COLOR | \(1 - \bar{\color{red}C}_{destination}\)
GL_SRC_ALPHA | \(\bar{\color{green}C}_{source}\)的\(alpha\)
GL_ONE_MINUS_SRC_ALPHA | \(1 -\) \(\bar{\color{green}C}_{source}\)的\(alpha\)
GL_DST_ALPHA | \(\bar{\color{red}C}_{destination}\)的\(alpha\)
GL_ONE_MINUS_DST_ALPHA | \(1 -\) \(\bar{\color{red}C}_{destination}\)的\(alpha\)
GL_CONSTANT_COLOR | 颜色向量\(\bar{\color{blue}C}_{constant}\)
GL_ONE_MINUS_CONSTANT_COLOR | \(1 - \bar{\color{blue}C}_{constant}\)
GL_CONSTANT_ALPHA | \(\bar{\color{blue}C}_{constant}\)的\(alpha\)
GL_ONE_MINUS_CONSTANT_ALPHA | \(1 -\) \(\bar{\color{blue}C}_{constant}\)的\(alpha\)
`GL_ZERO` | 因子等于\(0\)
`GL_ONE` | 因子等于\(1\)
`GL_SRC_COLOR` | 因子等于源颜色向量\(\bar{\color{green}C}_{source}\)
`GL_ONE_MINUS_SRC_COLOR` | 因子等于\(1 - \bar{\color{green}C}_{source}\)
`GL_DST_COLOR` | 因子等于目标颜色向量\(\bar{\color{red}C}_{destination}\)
`GL_ONE_MINUS_DST_COLOR` | 因子等于\(1 - \bar{\color{red}C}_{destination}\)
`GL_SRC_ALPHA` | 因子等于\(\bar{\color{green}C}_{source}\)的\(alpha\)分量
`GL_ONE_MINUS_SRC_ALPHA` | 因子等于\(1 -\) \(\bar{\color{green}C}_{source}\)的\(alpha\)分量
`GL_DST_ALPHA` | 因子等于\(\bar{\color{red}C}_{destination}\)的\(alpha\)分量
`GL_ONE_MINUS_DST_ALPHA` | 因子等于\(1 -\) \(\bar{\color{red}C}_{destination}\)的\(alpha\)分量
`GL_CONSTANT_COLOR` | 因子等于常数颜色向量\(\bar{\color{blue}C}_{constant}\)
`GL_ONE_MINUS_CONSTANT_COLOR` | 因子等于\(1 - \bar{\color{blue}C}_{constant}\)
`GL_CONSTANT_ALPHA` | 因子等于\(\bar{\color{blue}C}_{constant}\)的\(alpha\)分量
`GL_ONE_MINUS_CONSTANT_ALPHA` | 因子等于\(1 -\) \(\bar{\color{blue}C}_{constant}\)的\(alpha\)分量
从两个方块获得混合结果,我们打算把源颜色的\(alpha\)源因子,\(1 - alpha\)目标因子。调整到`glBlendFunc`之后就像这样
了获得之前两个方形的混合结果,我们需要使用源颜色向量的\(alpha\)作为源因子,使用\(1 - alpha\)作为目标因子。这将会产生以下的<fun>glBlendFunc</fun>
```c++
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
```
也可以为RGB和alpha通道各自设置不同的选项使用`glBlendFuncSeperate`
也可以使用<fun>glBlendFuncSeparate</fun>为RGB和alpha通道分别设置不同的选项
```c++
glBlendFuncSeperate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,GL_ONE, GL_ZERO);
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);
```
这个方程就像我们之前设置的那样设置了RGB元素,但是只让最终的alpha元素被源alpha值影响到。
这个函数和我们之前设置的那样设置了RGB分量,但这样只能让最终的alpha分量被源颜色向量的alpha值影响到。
OpenGL给了我们更多的自由,我们可以改变方程源和目标部分的操作符。现在,源和目标元素已经相加了。如果我们愿意的话,我们可以它们相减。
OpenGL甚至给了我们更多的灵活性,允许我们改变方程源和目标部分的运算符。当前源和目标是相加的,但如果愿意的话,我们可以它们相减。<fun>glBlendEquation(GLenum mode)</fun>允许我们设置运算符,它提供了三个选项:
`void glBlendEquation(GLenum mode)`允许我们设置这个操作有3种可行的选项
- GL_FUNC_ADD默认选项将两个分量相加\(\bar{C}_{result} = \color{green}{Src} + \color{red}{Dst}\)。
- GL_FUNC_SUBTRACT将两个分量相减 \(\bar{C}_{result} = \color{green}{Src} - \color{red}{Dst}\)。
- GL_FUNC_REVERSE_SUBTRACT将两个分量相减但顺序相反\(\bar{C}_{result} = \color{red}{Dst} - \color{green}{Src}\)。
* GL_FUNC_ADD默认的彼此元素相加\(\bar{C}_{result} = \color{green}{Src} + \color{red}{Dst}\)
* GL_FUNC_SUBTRACT彼此元素相减 \(\bar{C}_{result} = \color{green}{Src} - \color{red}{Dst}\)
* GL_FUNC_REVERSE_SUBTRACT彼此元素相减但顺序相反\(\bar{C}_{result} = \color{red}{Dst} - \color{green}{Src}\).
通常我们可以简单地省略`glBlendEquation`因为GL_FUNC_ADD在大多数时候就是我们想要的但是如果你如果你真想尝试努力打破主流常规其他的方程或许符合你的要求。
通常我们都可以省略调用<fun>glBlendEquation</fun>,因为<var>GL_FUNC_ADD</var>对大部分的操作来说都是我们希望的混合方程,但如果你真的想打破主流,其它的方程也可能符合你的要求。
## 渲染半透明纹理
现在我们知道OpenGL如何处理混合是时候我们的知识运用起来了,我们来添加几个半透明窗子。我们使用本教程开始时用的那个场景,但是不再渲染草纹理,取而代之的是来自教程开始处半透明窗子纹理。
既然我们已经知道OpenGL如何处理混合的了,是时候我们的知识运用到实战中了,我们将会在场景中添加几个半透明的窗户。我们使用本节开始的那个场景,但是这次不再渲染草纹理了,我们现在将使用本节开始时的那个[透明的窗户](../img/04/03/blending_transparent_window.png)纹理。
首先,初始化时我们需要开启混合,设置合适和混合方程
首先,初始化时我们启用混合,并设定相应的混合函数
```c++
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
```
由于我们开启了混合,就不需要丢弃片段了,所以我们把片段着色器设置为原来的那个版本
由于启了混合,我们就不需要丢弃片段了,所以我们把片段着色器还原
```c++
#version 330 core
in vec2 TexCoords;
out vec4 FragColor;
out vec4 color;
in vec2 TexCoords;
uniform sampler2D texture1;
void main()
{
color = texture(texture1, TexCoords);
{
FragColor = texture(texture1, TexCoords);
}
```
这一次(无论OpenGL什么时候去渲染一个片段它都根据alpha值当前片段的颜色和颜色缓冲中的颜色进行混合。因为窗子的玻璃部分的纹理是半透明的,我们应该可以透过玻璃看到整个场景。
现在(每当OpenGL渲染一个片段时)它都会将当前片段的颜色和当前颜色缓冲中的片段颜色根据alpha值来进行混合。由于窗户纹理的玻璃部分是半透明的,我们应该能通窗户中看到背后的场景
![](../img/04/03/blending_incorrect_order.png)
如果你仔细看看,就会注意到有些不对劲。前面的窗子透明部分阻塞了后面的。为什么会这样
如果你仔细看的话,你可能会注意到有些不对劲。前面窗户的透明部分遮蔽了背后的窗户?这为什么会发生呢
原因是深度测试在与混合的一同工作时出现了点状况。当写入深度缓冲的时候,深度测试不关心片段是否透明,所以透明部分写入深度缓冲,就和其他值没什么区别。结果是整个四边形的窗子被检查时都忽视了透明度。即便透明部分应该显示出后面的窗,深度缓冲还是丢弃了它们。
发生这一现象的原因是深度测试和混合一起使用的话会产生一些麻烦。当写入深度缓冲时,深度缓冲不会检查片段是否透明,所以透明部分会和其它值一样写入深度缓冲中。结果就是窗户的整个四边形不论透明度都会进行深度测试。即使透明部分应该显示背后的窗,深度测试仍然丢弃了它们。
所以我们不能简简单单地去渲染窗子,我们期待着深度缓冲为我们解决所有问题这也是混合之处代码不怎么好看的原因。为保证前面窗子显示了它后面的窗,我们必须首先绘制后面的窗子。这意味着我们必须手工调整窗子的顺序,从远到近地逐个渲染。
所以我们不能随意地决定如何渲染窗户,让深度缓冲解决所有问题了。这也是混合变得有些麻烦的部分。要想保证窗户中能够显示它们背后的窗,我们需要首先绘制背后的这部分窗户。这也就是说在绘制的时候,我们必须先手动将窗户按照最远到最近来排序,再按照顺序渲染。
!!! Important
对于全透明物体,比如草叶,我们选择简单的丢弃透明像素而不是混合,这样就减少了令我们头疼的问题(没有深度测试问题)。
注意,对于草这种全透明物体,我们可以选择丢弃透明的片段而不是混合它们,这样就解决了这些头疼的问题(没有深度问题)。
## 打乱顺序
## 不要打乱顺序
要让混合在多物体上有效,我们必须先绘制最远的物体,最后绘制最近的物体。普通的无混合物体仍然可以使用深度缓冲正常绘制,所以不必给它们排序。我们一定要保证它们在透明物体前绘制好。当无透明度物体和透明物体一起绘制的时候,通常要遵循以下原则
让混合在多物体上工作,我们需要最先绘制最远的物体,最后绘制最近的物体。普通不需要混合物体仍然可以使用深度缓冲正常绘制,所以它们不需要排序。我们要保证它们在绘制(排序的)透明物体之前已经绘制完毕了。当绘制一个有不透明和透明物体的场景的时候,大体的原则如下
先绘制所有不透明物体。
所有透明物体排序。
按顺序绘制透明物体。
一种排序透明物体的方式是获取一个物体到观察者透视图的距离。这可以通过获取摄像机的位置向量和物体的位置向量来得到。接着我们就可以把它和相应的位置向量一起储存到一个map数据结构STL库中。map会自动基于它的键排序它的值所以当我们把它们的距离作为键添加到所有位置中后它们就自动按照距离值排序了
1. 先绘制所有不透明物体。
2. 对所有透明物体排序。
3. 按顺序绘制所有透明物体。
排序透明物体的一种方法是从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得。接下来我们把距离和它对应的位置向量存储到一个STL库的<fun>map</fun>数据结构中。<fun>map</fun>会自动根据键值(Key)对它的值排序,所以只要我们添加了所有的位置,并以它的距离作为键,它们就会自动根据距离值排序了。
```c++
std::map<float, glm::vec3> sorted;
for (GLuint i = 0; i < windows.size(); i++) // windows contains all window positions
for (unsigned int i = 0; i < windows.size(); i++)
{
GLfloat distance = glm::length(camera.Position - windows[i]);
float distance = glm::length(camera.Position - windows[i]);
sorted[distance] = windows[i];
}
```
最后产生了一个容器对象,基于它们距离从低到高储存了每个窗的位置。
结果就是一个排序后的容器对象,它根据<var>distance</var>键值从低到高储存了每个窗的位置。
随后当渲染的时候,我们逆序获取到每个map的值从远到近后以正确的绘制应的窗
之后,这次在渲染的时候,我们将以逆序从远到近从map中获取值后以正确的顺序绘制应的窗
```c++
for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it)
for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it)
{
model = glm::mat4();
model = glm::translate(model, it->second);
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
model = glm::translate(model, it->second);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
```
我们从map得来一个逆序的迭代器迭代出每个逆序的条目,然后把每个窗子的四边形移到应的位置。这个相对简单的方法对透明物体进行了排序,修正了前面的问题,现在场景看起来这样:
我们使用了<fun>map</fun>的一个反向迭代器(Reverse Iterator),反向遍历其中的条目,并将每个窗四边形移到应的窗户位置。这是排序透明物体的一个比较简单的实现,它能够修复之前的问题,现在场景看起来这样
![](../img/04/03/blending_sorted.png)
你可以[这里得到完整的带有排序的源码](http://learnopengl.com/code_viewer.php?code=advanced/blending_sorted)
你可以[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/3.2.blending_sort/blending_sorted.cpp)找到带有排序的完整源代码
虽然这个按照它们的距离对物体进行排序的方法在这个特定的场景能够良好工作,但它不能进行旋转、缩放或者进行其他的变换,奇怪形状的物体需要一不同的方式,而不能简单的使用位置向量。
虽然按照距离排序物体这种方法对我们这个场景能够正常工作,但它并没有考虑旋转、缩放或者其它的变换,奇怪形状的物体需要一不同的计量,而不是仅仅一个位置向量。
在场景中排序物体是个有难度的技术,很大程度上取决于你场景的类型,更不必说会耗费额外的处理能力了。完美地渲染带有透明和透明物体的场景并不那么容易。更高级的技术例如次序无关透明度order independent transparency,但这超出本教程的范围。现在你不得不采用普通混合你的物体,但如果你小心谨慎,并知道这个局限,你仍可以得到颇为合适的混合实现。
在场景中排序物体是一个很困难的技术,很大程度上你场景的类型所决定,更别说它额外需要消耗的处理能力了。完整渲染一个包含不透明和透明物体的场景并不那么容易。更高级的技术还有<def>次序无关透明度</def>(Order Independent Transparency, OIT),但这超出本教程的范围。现在,你还是必须要普通混合你的物体,但如果你小心,并知道目前方法的限制的话,你仍然能够获得一个比较不错的混合实现。

View File

@@ -3,89 +3,88 @@
原文 | [Face culling](http://learnopengl.com/#!Advanced-OpenGL/Face-culling)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
尝试在脑中想象一下有一个3D立方体你从任何一个方向去看它,最多可以同时看到多少个面。如果你的想象力不是过于丰富,你最终最多能数出来的面是3。你可以从一个立方体的任意位置和方向上去看它,但你永远不能看到多于3个面。所以我们为何还要去绘制那三个不会显示出来的3个面呢如果我们可以以某种方式丢弃它们我们会提高片段着色器超过50%的性能
尝试在脑中想象一个3D立方体数数你从任意方向最多能同时看到个面。如果你的想象力不是过于丰富,你应该能得出最大的面是3。你可以从任意位置和任意方向看向这个球体但你永远不能看到3个以上的面。所以我们为什么要浪费时间绘制我们不能看见的那3个面呢如果我们能够以某种方式丢弃这几个看不见的面我们能省下超过50%的片段着色器执行数
!!! Important
我们所说的是超过50%而不是50%因为从一个角度只有2个或1个面能够被看到。这种情况下我们就能够提高50%以上性能了。
我说的是**超过**50%而不是50%因为从特定角度来看的话只能看见2个甚至是1个面。在这种情况下我们就能省下超过50%了。
这是一个很好的主意,但我们仍有一个问题需要解决:我们如何知道一个物体的某一个面不能从观察者视角看到呢?
如果我们想象任何一个闭合形状,它的每一个面都有两侧,每一侧要么**面向**用户,要么背对用户。如果我们能够只绘制**面向**观察者的面呢?
的确是个好主意,但是有个问题需要解决:我们如何知道某个面在观察者的视野中不会出现呢?如果我们去想象任何封闭的几何平面,它们都有两面,一面面向用户,另一面背对用户。假如我们只渲染面向观察者的面会怎样?
正是<def>面剔除</def>(Face Culling)所做的。OpenGL能够检查所有<def>面向</def>(Front Facing)观察者的面,并渲染它们,而丢弃那些<def>背向</def>(Back Facing)的面节省我们很多的片段着色器调用它们的开销很大。但我们仍要告诉OpenGL哪些面是正向面(Front Face),哪些面是背向面(Back Face)。OpenGL使用了一个很聪明的技巧分析顶点数据的<def>环绕顺序</def>(Winding Order)。
这正是**面剔除**(Face culling)所要做的。OpenGL允许检查所有正面朝向Front facing观察者的面并渲染它们而丢弃所有背面朝向Back facing的面这样就节约了我们很多片段着色器的命令它们很昂贵。我们必须告诉OpenGL我们使用的哪个面是正面哪个面是反面。OpenGL使用一种聪明的手段解决这个问题——分析顶点数据的连接顺序Winding order
## 环绕顺序
## 顶点连接顺序
当我们定义一系列的三角顶点时,我们会把它们定义为一个特定的连接顺序(Winding Order),它们可能是**顺时针**的或**逆时针**的。每个三角形由3个顶点组成我们从三角形的中间去看从而把这三个顶点指定一个连接顺序。
当我们定义一组三角形顶点时,我们会以特定的环绕顺序来定义它们,可能是<def>顺时针</def>(Clockwise)的,也可能是<def>逆时针</def>(Counter-clockwise)的。每个三角形由3个顶点所组成我们会从三角形中间来看为这3个顶点设定一个环绕顺序。
![](../img/04/04/faceculling_windingorder.png)
正如你所看到的那样我们先定义了顶点1接着我们定义顶点2或3这个不同的选择决定了这个三角形的连接顺序。下面的代码展示这点:
可以看到,我们先定义了顶点1之后我们可以选择定义顶点2或者顶点3这个选择将定义了这个三角形的环绕顺序。下面的代码展示这点:
```c++
GLfloat vertices[] = {
//顺时针
vertices[0], // vertex 1
vertices[1], // vertex 2
vertices[2], // vertex 3
float vertices[] = {
// 顺时针
vertices[0], // 顶点1
vertices[1], // 顶点2
vertices[2], // 顶点3
// 逆时针
vertices[0], // vertex 1
vertices[2], // vertex 3
vertices[1] // vertex 2
vertices[0], // 顶点1
vertices[2], // 顶点3
vertices[1] // 顶点2
};
```
三个顶点都形成了一个包含着连接顺序的基本三角形。OpenGL使用这个信息在渲染你的基本图形的时候决定个三角形是三角形的正面还是三角形的背面。默认情况下,**逆时针**的顶点连接顺序被定义三角形的**正面**
组组成三角形图元的三个顶点就包含了一个环绕顺序。OpenGL在渲染图元的时候将使用这个信息来决定个三角形是一个<def>正向</def>三角形还是<def>背向</def>三角形。默认情况下,逆时针顶点所定义三角形将会被处理为正向三角形
当定义你的顶点顺序时,你如果定义能够看到的一个三角形,那它一定是正面朝向的,所以你定义的三角形应该是逆时针的,就像你直接面向这个三角形。把所有的顶点指定成这样是件炫酷的事,实际的顶点连接顺序是在**光栅化**阶段Rasterization stage计算的所以当顶点着色器已经运行后。顶点就能够在观察者的观察点被看到
定义顶点顺序的时候,你应该想象对应的三角形是面向你的,所以你定义的三角形从正面看去应该是逆时针的。这样定义顶点很棒的一点是,实际的环绕顺序是在光栅化阶段进行的,也就是顶点着色器运行后。这些顶点就是从**观察者视角**所见的了
我们指定了它们以后,观察者面对的所有三角形顶点的连接顺序都是正确的,但是现在渲染的立方体另一面的三角形顶点的连接顺序被反转。最终,我们所面的三角形被视为正面朝向的三角形,后部的三角形被视为背面朝向的三角形。下图展示了这个效果:
观察者所面向的所有三角形顶点就是我们所指定的正确环绕顺序了,而立方体另一面的三角形顶点则是以相反的环绕顺序所渲染的。这样的结果就是,我们所面的三角形将会是正向三角形,而背面的三角形则是背向三角形。下面这张图显示了这个效果:
![](../img/04/04/faceculling_frontback.png)
在顶点数据中我们将两个三角形都以逆时针顺序定义正面的三角形是1、2、3背面的三角形也是1、2、3如果我们从正面看这个三角形的话。然而如果从观察者当前视角使用1、2、3的顺序来绘制的话从观察者的方向来看背面的三角形将会是以顺时针顺序渲染的。虽然背面的三角形是以逆时针定义的它现在是以顺时针顺序渲染的了。这正是我们想要<def>剔除</def>Cull丢弃的不可见面了
在顶点数据中我们定义的是两个逆时针顺序的三角形。然而从观察者的方面看后面的三角形是顺时针的如果我们仍以1、2、3的顺序以观察者当面的视野看的话。即使我们以逆时针顺序定义后面的三角形它现在还是变为顺时针。它正是我们打算剔除丢弃的不可见的面
## 面剔除
教程的开头我们说过OpenGL可以丢弃背面朝向的三角形。现在我们知道如何设置顶点的连接顺序,我们可以开始使用OpenGL默认关闭的面剔除选项了
本节的开头我们说过OpenGL能够丢弃那些渲染为背向三角形的三角形图元。既然已经知道如何设置顶点的环绕顺序,我们可以使用OpenGL的<def>面剔除</def>选项了,它默认是禁用状态的
记住我们上一节所使用的立方体的定点数据不是逆时针顺序定义的所以我更新了顶点数据,好去反应为一个逆时针链接顺序,你可以[从这里复制它](http://learnopengl.com/code_viewer.php?code=advanced/faceculling_vertexdata)。把所有三角的顶点都定义为逆时针是一个很好的习惯。
在之前教程中使用的立方体点数据不是按照逆时针环绕顺序定义的所以我更新了顶点数据,来反映逆时针的环绕顺序,你可以从[这里](https://learnopengl.com/code_viewer.php?code=advanced/faceculling_vertexdata)复制它们。尝试想象这些顶点,确认在每个三角形中它们都是以逆时针定义的,这是一个很好的习惯。
开启OpenGL的`GL_CULL_FACE`选项就能开启面剔除功能
要想启用面剔除,我们只需要启用OpenGL的<var>GL_CULL_FACE</var>选项
```c++
glEnable(GL_CULL_FACE);
```
从这儿以后,所有的不是正面朝向的面都被丢弃(尝试飞立方体看看,里面什么面都看不见了)。目前在渲染片段上我们节约了超过50%的性能,但记住这只对像立方体这样的封闭形状有效。当我们绘制上个教程中那个草的时候,我们必须关闭面剔除,这是因为它的前、后面都必须是可见的。
从这一句代码之后,所有背向面都被丢弃(尝试飞立方体内部,看看所有的内面是不是都被丢弃了)。目前我们在渲染片段的时候能够节省50%以上的性能,但注意这只对像立方体这样的封闭形状有效。当我们想要绘制[上一节](03 Blending.md)中的草时,我们必须要再次禁用面剔除,因为它们的正向面和背向面都应该是可见的。
OpenGL允许我们改变剔除面的类型。要是我们剔除正面而不是背面会怎样?我们可以调用`glCullFace`来做这件事
OpenGL允许我们改变需要剔除面的类型。如果我们只想剔除正面而不是背面会怎样?我们可以调用<fun>glCullFace</fun>来定义这一行为
```c++
glCullFace(GL_BACK);
glCullFace(GL_FRONT);
```
`glCullFace`函数有三个可用的选项:
<fun>glCullFace</fun>函数有三个可用的选项:
* GL_BACK只剔除背面。
* GL_FRONT只剔除正面。
* GL_FRONT_AND_BACK剔除背面和正面。
- `GL_BACK`:只剔除背面。
- `GL_FRONT`:只剔除正面。
- `GL_FRONT_AND_BACK`:剔除正向面和背向面。
`glCullFace`的初始值是`GL_BACK`。另外,我们可以告诉OpenGL使用顺时针而不是逆时针来表示正面这通过glFrontFace来设置
<fun>glCullFace</fun>的初始值是<var>GL_BACK</var>。除了需要剔除的面之外,我们可以通过调用<fun>glFrontFace</fun>告诉OpenGL我们希望将顺时针的面而不是逆时针的面定义为正向面
```c++
glFrontFace(GL_CCW);
```
默认值是`GL_CCW`,它代表逆时针`GL_CW`代表顺时针顺序。
默认值是<var>GL_CCW</var>,它代表的是逆时针的环绕顺序,另一个选项是<var>GL_CW</var>,它(显然)代表的是顺时针顺序。
我们可以做个小实验告诉OpenGL现在顺时针代表正面:
我们可以来做一个实验告诉OpenGL现在顺时针顺序代表的是正向面:
```c++
glEnable(GL_CULL_FACE);
@@ -93,19 +92,19 @@ glCullFace(GL_BACK);
glFrontFace(GL_CW);
```
最后的结果只有背面被渲染了:
这样的结果只有背面被渲染了:
![](../img/04/04/faceculling_reverse.png)
注意你可以使用默认逆时针顺序剔除正面,来创建相同的效果:
注意你可以使用默认逆时针环绕顺序,但剔除正面,来达到相同的效果:
```c
```c++
glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);
```
正如你所看到的那样面剔除是OpenGL提高效率的一个强大工具,它使应用节省运算。你必须跟踪下来哪个物体可以使用面剔除,哪些不能
可以看到,面剔除是一个提高OpenGL程序性能的很棒的工具。但你需要记住哪些物体能够从面剔除中获益,而哪些物体不应该被剔除
## 练习
你可以自己重新定义一个顺时针的顶点顺序,然后用顺时针作为正面把它渲染出来吗:[解决方案](http://learnopengl.com/code_viewer.php?code=advanced/faceculling-exercise1)
- 你能够重新定义顶点数据,将每个三角形设置为顺时针顺序,并将顺时针的三角形设置为正向面,仍将场景渲染出来吗[参考解答](https://learnopengl.com/code_viewer.php?code=advanced/faceculling-exercise1)

View File

@@ -3,163 +3,168 @@
原文 | [Framebuffers](http://learnopengl.com/#!Advanced-OpenGL/Framebuffers)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
到目前为止,我们使用了几种不同类型的屏幕缓冲:用于写入颜色值的颜色缓冲用于写入深度信息的深度缓冲,以及允许我们基于一些条件丢弃定片段的模板缓冲。把这几种缓冲结合起来叫做帧缓冲(Framebuffer),它被储存内存中。OpenGL给了我们自己定义帧缓冲的自由,我们可以选择性的定义自己的颜色缓冲、深度和模板缓冲。
到目前为止,我们已经使用了很多屏幕缓冲:用于写入颜色值的颜色缓冲用于写入深度信息的深度缓冲允许我们根据一些条件丢弃定片段的模板缓冲。这些缓冲结合起来叫做<def>帧缓冲</def>(Framebuffer),它被储存内存中。OpenGL允许我们定义我们自己帧缓冲,也就是说我们能够定义我们自己的颜色缓冲,甚至是深度缓冲和模板缓冲。
我们目前所做的渲染操作都是是在默认的帧缓冲上进行的。当你创建了你的窗口的时候默认帧缓冲就被创建和配置好了GLFW我们做了这件事)。通过创建我们自己的帧缓冲我们能够获得一种额外的渲染方式
我们目前所做的所有操作都是<def>默认帧缓冲</def>的渲染缓冲上进行的。默认的帧缓冲是在你创建窗口的时候生成和配置GLFW我们做了这些)。有了我们自己的帧缓冲我们能够有更多方式来渲染了
也许不能立刻理解应用程序的帧缓冲的含义,通过帧缓冲可以将你的场景渲染到一个不同的帧缓冲中,可以使我们能够在场景中创建镜子这样的效果,或者做出一些炫酷的特效。首先我们会讨论它是如何工作的,后我们将利用帧缓冲来实现些炫酷的效果。
可能不能很快理解帧缓冲的应用,但渲染你的场景到不同的帧缓冲能够让我们在场景中加入类似镜子的东西,或者做出很酷的后期处理效果。首先我们会讨论它是如何工作的,后我们将来实现些炫酷的后期处理效果。
## 创建一个帧缓冲
就像OpenGL中其他对象一样,我们可以使用一个叫做`glGenFramebuffers`的函数来创建一个帧缓冲对象(简称FBO
OpenGL中的其它对象一样,我们使用一个叫做<fun>glGenFramebuffers</fun>的函数来创建一个帧缓冲对象(Framebuffer Object, FBO)
```c++
GLuint fbo;
unsigned int fbo;
glGenFramebuffers(1, &fbo);
```
这种对象的创建和使用的方式我们已经见过不少了,因此它们的使用方式也和之前我们见过的其他对象的使用方式相似。首先我们创建一个帧缓冲对象,它绑定到当前帧缓冲,做一些操作,后解绑帧缓冲。我们使用`glBindFramebuffer`来绑定帧缓冲
这种创建和使用对象的方式我们已经见过很多次了,所以它的使用函数也和其它的对象类似。首先我们创建一个帧缓冲对象,它绑定为激活的(Active)帧缓冲,做一些操作,后解绑帧缓冲。我们使用<fun>glBindFramebuffer</fun>来绑定帧缓冲
```c++
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
```
绑定到`GL_FRAMEBUFFER`目标后,接下来所有的读、写帧缓冲的操作会影响当前绑定的帧缓冲。也可以把帧缓冲分开绑定到读或写目标上,分别使用`GL_READ_FRAMEBUFFER`或`GL_DRAW_FRAMEBUFFER`来做这件事。如果绑定到了`GL_READ_FRAMEBUFFER`,就能执行所有读取操作,像`glReadPixels`这样的函数使用了;绑定到`GL_DRAW_FRAMEBUFFER`上,就允许进行渲染、清空和其他的写入操作。大多数时候你不必分开用,通常把两个都绑定到`GL_FRAMEBUFFER`上就行
绑定到<var>GL_FRAMEBUFFER</var>目标后,所有的**读取**和**写入**帧缓冲的操作会影响当前绑定的帧缓冲。我们也可以使用<var>GL_READ_FRAMEBUFFER</var>或<var>GL_DRAW_FRAMEBUFFER</var>,将一个帧缓冲分别绑定到读取目标或写入目标。绑定到<var>GL_READ_FRAMEBUFFER</var>的帧缓冲将会使用在所有像是<fun>glReadPixels</fun>的读取操作中,而绑定到<var>GL_READ_FRAMEBUFFER</var>的帧缓冲将会被用作渲染、清除等写入操作的目标。大部分情况你都不需要区分它们,通常都会使用<var>GL_FRAMEBUFFER</var>,绑定到两个上
很遗憾,现在我们还不能使用自己的帧缓冲,因为还没做完呢。建构一个完整的帧缓冲必须满足以下条件:
不幸的是,我们现在还不能使用我们的帧缓冲,因为它还不<def>完整</def>(Complete)一个完整的帧缓冲需要满足以下条件:
* 我们必须往里面加入至少一个附件(颜色、深度模板缓冲)。
* 其中至少有一个颜色附件。
* 所有的附件都应该是已经完全做好的(已经存储在内存之中)。
* 每个缓冲都应该有同样数目的样本。
- 附加至少一个缓冲(颜色、深度模板缓冲)。
- 至少有一个颜色附件(Attachment)
- 所有的附件都必须是完整的(保留了内存)。
- 每个缓冲都应该有同的样本
如果你不知道什么是样本也不用担心,我们会在后面的教程中讲到。
如果你不知道什么是样本,不要担心,我们将在[之后的](11 Anti Aliasing.md)教程中讲到。
从上面的需求中你可以看到,我们需要为帧缓冲创建一些附件(Attachment),还需要把这些附件附加到帧缓冲上。当我们做完所有上面提到的条件的时候我们可以用 `glCheckFramebufferStatus` 带上 `GL_FRAMEBUFFER` 这个参数来检查是否真的成功做到了。然后检查当前绑定的帧缓冲,返回了这些规范中的哪个值。如果返回的是 `GL_FRAMEBUFFER_COMPLETE`就对了:
从上面的条件中可以知道,我们需要为帧缓冲创建一些附件,并将附件附加到帧缓冲上。在完成所有的条件之后,我们可以以<var>GL_FRAMEBUFFER</var>为参数调用<fun>glCheckFramebufferStatus</fun>,检查帧缓冲是否完整。它将会检测当前绑定的帧缓冲,返回规范中[这些](https://www.khronos.org/registry/OpenGL-Refpages/gl4/html/glCheckFramebufferStatus.xhtml)值的其中之一。如果返回的是<var>GL_FRAMEBUFFER_COMPLETE</var>,帧缓冲就是完整的了。
```c++
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
// Execute victory dance
// 执行胜利的舞蹈
```
所有渲染操作将渲染到当前绑定帧缓冲的附加缓冲中,由于我们的帧缓冲不是默认帧缓冲,渲染命令对窗口的视输出不会产生任何影响。出于这个原因,它被称为离屏渲染off-screen rendering),就是渲染到一个另外的缓冲中。为了让所有的渲染操作主窗口产生影响我们必须通过绑定为0来使默认帧缓冲被激活
后所有渲染操作将渲染到当前绑定帧缓冲的附件中。由于我们的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视输出任何影响。出于这个原因,渲染到一个不同的帧缓冲被叫做<def>离屏渲染</def>(Off-screen Rendering)。要保证所有的渲染操作主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到`0`。
```c++
glBindFramebuffer(GL_FRAMEBUFFER, 0);
```
当我们做完所有帧缓冲操作,不要忘记删除帧缓冲对象:
在完成所有帧缓冲操作之后,不要忘记删除这个帧缓冲对象:
```c++
glDeleteFramebuffers(1, &fbo);
```
现在在执行完成检测前,我们需要把一个或更多的附件附加到帧缓冲上。一个附件就是一个内存地址,这个内存地址里面包含一个为帧缓冲准备的缓冲,可以个图像。当创建一个附件的时候我们有两种方式可以采用:纹理或渲染缓冲renderbuffer)对象
在完整性检查执行之前,我们需要给帧缓冲附加一个附件。<def>附件</def>是一个内存位置,它能够作为帧缓冲的一个缓冲,可以将它想象为一个图像。当创建一个附件的时候我们有两个选项:纹理或渲染缓冲对象(Renderbuffer Object)
### 纹理附件
当把一个纹理附加到帧缓冲的时候,所有渲染命令会写入到纹理,就它是一个普通的颜色/深度或模板缓冲一样。使用纹理的好处是,所有渲染操作的结果会被储存一个纹理图像,这样我们就可以简单的在着色器中使用
当把一个纹理附加到帧缓冲的时候,所有渲染指令将会写入到这个纹理,就它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果会被储存一个纹理图像中,我们之后可以在着色器中很方便地使用
创建一个帧缓冲的纹理和创建普通纹理差不多:
为帧缓冲创建一个纹理和创建一个普通纹理差不多:
```c++
GLuint texture;
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
```
这里主要的区别是我们把纹理的维度设置为屏幕大小(尽管不是必须的),我们还传递NULL作为纹理的data参数。对于这个纹理,我们分配内存,而不去填充它。纹理填充会在渲染到帧缓冲的时候去做。同样,要注意我们不关心环绕方式或者Mipmap因为在大多数时候都不会需要它们
主要的区别就是,我们将维度设置为屏幕大小(尽管不是必须的),并且我们给纹理的`data`参数传递了`NULL`。对于这个纹理,我们仅仅分配内存而没有填充它。填充这个纹理将会在我们渲染到帧缓冲之后来进行。同样注意我们不关心环绕方式或多级渐远纹理,我们在大多数情况下都不会需要它们。
如果你打算把整个屏幕渲染到一个或大或小的纹理上,你需要用新的纹理的尺寸作为参数再次调用`glViewport`(要在渲染到你的帧缓冲之前做好),否则只有一小部分纹理或屏幕能够绘制到纹理上。
!!! important
现在我们已经创建了一个纹理,最后一件要做的事情是把它附加到帧缓冲上:
如果你想将你的屏幕渲染到一个更小或更大的纹理上,你需要(在渲染到你的帧缓冲之前)再次调用<fun>glViewport</fun>,使用纹理的新维度作为参数,否则只有一小部分的纹理或屏幕会被渲染到这个纹理上。
现在我们已经创建好一个纹理了,要做的最后一件事就是将它附加到帧缓冲上了:
```c++
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D, texture, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
```
`glFramebufferTexture2D`函数需要传入下列参数:
<fun>glFrameBufferTexture2D</fun>有以下的参数:
* target我们所创建的帧缓冲类型的目标(绘制、读取或两者有)
* attachment我们附加的附件类型。现在我们附加的是一个颜色附件。需要注意最后的那个0是暗示我们可以附加1个以上颜色附件。我们会在后面的教程中到。
* textarget你希望附加的纹理类型
* texture:附加的实际纹理。
* levelMipmap level。我们设置为0。
- `target`:帧缓冲的目标(绘制、读取或两者有)
- `attachment`:我们想要附加的附件类型。当前我们正在附加一个颜色附件。注意最后的`0`意味着我们可以附加多个颜色附件。我们将在之后的教程中到。
- `textarget`:你希望附加的纹理类型
- `texture`:要附加的纹理本身
- `level`:多级渐远纹理的级别。我们将它保留为0。
除颜色附件外,我们还可以附加一个深度和一个模板纹理到帧缓冲对象上。为了附加一个深度缓冲,我们可以知道那个`GL_DEPTH_ATTACHMENT`作为附件类型。记住这时纹理格式和内部格式类型internalformat)就成了 `GL_DEPTH_COMPONENT`去反应深度缓冲的储格式。附加一个模板缓冲,你要使用 `GL_STENCIL_ATTACHMENT`作为第二个参数,把纹理格式定为 `GL_STENCIL_INDEX`
颜色附件外,我们还可以附加一个深度和模板缓冲纹理到帧缓冲对象中。要附加深度缓冲的话,我们将附件类型设置为<var>GL_DEPTH_ATTACHMENT</var>。注意纹理的<def>格式</def>(Format)和<def>内部格式</def>(Internalformat)类型将变为<var>GL_DEPTH_COMPONENT</var>,来反映深度缓冲的储格式。附加模板缓冲的话,你要将第二个参数设置为<var>GL_STENCIL_ATTACHMENT</var>,并将纹理格式定为<var>GL_STENCIL_INDEX</var>
也可以同时附加一个深度缓冲和一个模板缓冲为一个单独的纹理。这样纹理的每32位数值包含24位的深度信息和8位的模板信息。为了把一个深度和模板缓冲附加一个单独纹理上,我们使用`GL_DEPTH_STENCIL_ATTACHMENT`类型配置纹理格式以包含深度和模板值的结合物。下面是一个附加了深度和模板缓冲为单一纹理的例子
也可以深度缓冲和模板缓冲附加为一个单独的纹理。纹理的每32位数值包含24位的深度信息和8位的模板信息。要将深度和模板缓冲附加一个纹理的话,我们使用<var>GL_DEPTH_STENCIL_ATTACHMENT</var>类型,并配置纹理格式,让它包含合并的深度和模板值。将一个深度和模板缓冲附加为一个纹理到帧缓冲的例子可以在下面找到
```c++
glTexImage2D( GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL );
glTexImage2D(
GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0,
GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
```
### 缓冲对象附件
### 渲染缓冲对象附件
在介绍了帧缓冲的可行附件类型——纹理后OpenGL引进了渲染缓冲对象(Renderbuffer objects),所以在过去那些美好时光里纹理是附件的唯一可用的类型。和纹理图像一样,渲染缓冲对象是一个缓冲,它可以是一堆字节、整数、像素或者其他东西。渲染缓冲对象的一大优点是,它以OpenGL原生渲染格式储存它的数据,因此在离屏渲染到帧缓冲的时候,这些数据就相当于被优化过的
<def>渲染缓冲对象</def>(Renderbuffer Object)是在纹理之后引入到OpenGL中作为一个可用的帧缓冲附件类型的所以在过去纹理是唯一可用的附件。和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生渲染格式,它是为离屏渲染到帧缓冲优化过的。
渲染缓冲对象将所有渲染数据直接储存到它的缓冲里,而不会进行针对特定纹理格式的任何转换,这样它们就成了一种快速可写的存储介质。然而,渲染缓冲对象通常是只写的,不能修改它们(就像获取纹理,不能写入纹理一样)。可以用`glReadPixels`函数去读取,函数返回一个当前绑定的帧缓冲的特定像素区域,而不是直接返回附件本身
渲染缓冲对象直接将所有渲染数据储存到它的缓冲中,不会做任何针对纹理格式的转换,让它变为一个更快的可写储存介质。然而,渲染缓冲对象通常是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用<fun>glReadPixels</fun>来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素
因为它的数据已经是原生格式了,写入或把它们的数据简单地到其缓冲的时候非常快。当使用渲染缓冲对象时,像切换缓冲这种操作变得异常高速。我们在每个渲染迭代末尾使用的那个`glfwSwapBuffers`函数,同样以渲染缓冲对象实现:我们简单地写入一个渲染缓冲图像,最后交换到另一个。渲染缓冲对象对这种操作来说很完美。
因为它的数据已经是原生格式了,写入或者复制它的数据到其缓冲中时是非常快的。所以,交换缓冲这样的操作在使用渲染缓冲对象时会非常快。我们在每个渲染迭代最后使用的<fun>glfwSwapBuffers</fun>,也可以通过渲染缓冲对象实现:只需要写入一个渲染缓冲图像,并在最后交换到另一个渲染缓冲就可以了。渲染缓冲对象对这种操作非常完美。
创建一个渲染缓冲对象和创建帧缓冲代码差不多
创建一个渲染缓冲对象的代码和帧缓冲代码很类似
```c++
GLuint rbo;
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
```
相似地,我们打算把渲染缓冲对象绑定,这样所有后续渲染缓冲操作都会影响当前的渲染缓冲对象
类似,我们需要绑定这个渲染缓冲对象,让之后所有的渲染缓冲操作影响当前的<var>rbo</var>
```c++
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
```
由于渲染缓冲对象通常是只写的,它们经常作为深度和模板附件来使用,由于大多数时候,我们不需要从深度和模板缓冲中读取数据,但仍关心深度和模板测试。我们就需要有深度和模板值提供给测试,但不需要对这些值进行采样sample,所以深度缓冲对象是完全符合的。当我们不从这些缓冲中采样的时候,渲染缓冲对象通常很合适,因为它们等于是被优化过的
由于渲染缓冲对象通常是只写的,它们经常用于深度和模板附件,因为大部分时间我们不需要从深度和模板缓冲中读取值,只关心深度和模板测试。我们**需要**深度和模板值用于测试,但不需要对它们进行**采样**,所以渲染缓冲对象非常适合它们。当我们不需要从这些缓冲中采样的时候,通常都会选择渲染缓冲对象,因为它会更优化一点
调用`glRenderbufferStorage`函数可以创建一个深度和模板渲染缓冲对象:
创建一个深度和模板渲染缓冲对象可以通过调用<fun>glRenderbufferStorage</fun>函数来完成
```c
```c++
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
```
创建一个渲染缓冲对象与创建纹理对象似,不同之处在于这个对象是专门被设计用于图像的,而不是通用目的的数据缓冲,比如纹理。这里我们选择`GL_DEPTH24_STENCIL8`作为内部格式,它同时代表24位的深度和8位的模板缓冲。
创建一个渲染缓冲对象纹理对象似,不同的是这个对象是专门被设计作为图像使用的,而不是纹理那样的通用数据缓冲(General Purpose Data Buffer)。这里我们选择<var>GL_DEPTH24_STENCIL8</var>作为内部格式,它封装了24位的深度和8位的模板缓冲。
最后一件还要做的事情是把帧缓冲对象附加上
最后一件事就是附加这个渲染缓冲对象:
```c
```c++
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
```
在帧缓冲项目中,渲染缓冲对象可以提供一些优化,但更重要的是知道何时使用渲染缓冲对象,何时使用纹理。通常的规则是,如果你永远都不需要从特定的缓冲中进行采样,渲染缓冲对象对特定缓冲是更明智的选择。如果哪天需要从比如颜色或深度值这样的特定缓冲采样数据的话,你最好还是使用纹理附件。从执行效率角度考虑,它不会对效率有太大影响。
渲染缓冲对象能为你的帧缓冲对象提供一些优化,但知道什么时候使用渲染缓冲对象,什么时候使用纹理是很重要的。通常的规则是,如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。如果需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。性能方面它不会产生非常大的影响
### 渲染到纹理
## 渲染到纹理
现在我们知道了(一些)帧缓冲如何工作的,是时候把它们用起来了。我们会把场景渲染到一个颜色纹理上,这个纹理附加到一个我们创建的帧缓冲上,然后把纹理绘制到一个简单的四边形上,这个四边形铺满整个屏幕。输出的图像看似和没用帧缓冲一样,但这次,它其实是直接打印到了一个单独的四边形上。为什么很有用呢?下一部分我们会看到原因。
既然我们已经知道帧缓冲(大概)是怎么工作的,是时候实践它们了。我们将会将场景渲染到一个附加到帧缓冲对象上的颜色纹理中,之后将在一个横跨整个屏幕的四边形上绘制这个纹理。这样视觉输出和没使用帧缓冲时是完全一样,但这次打印到了一个四边形上。为什么很有用呢?我们会在下一部分中知道原因。
第一件要做的事情是创建一个帧缓冲对象,并绑定它,这比较明了
首先要创建一个帧缓冲对象,并绑定它,这些都很直观
```c++
GLuint framebuffer;
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
```
下一步我们创建一个纹理图像,这是我们将附加到帧缓冲的颜色附件。我们纹理的尺寸设置为窗口的宽度和高度,并保持数据未初始化
接下来我们需要创建一个纹理图像,我们将它作为一个颜色附件附加到帧缓冲。我们纹理的维度设置为窗口的宽度和高度,并且不初始化它的数据
```c++
// Generate texture
GLuint texColorBuffer;
// 生成纹理
unsigned int texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
@@ -167,196 +172,200 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
// Attach it to currently bound framebuffer object
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);
// 将它附加到当前绑定的帧缓冲对象
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);
```
我们同样打算要让OpenGL确定可以进行深度测试(模板测试,如果你用的话)所以我们必须还要确保向帧缓冲中添加一个深度(和模板)附件。由于我们只采样颜色缓冲,并不采样其他缓冲,我们可以创建一个渲染缓冲对象来达到这个目的。记住,当你不打算从指定缓冲采样的的时候,它们是一个不错的选择
我们还希望OpenGL能够进行深度测试(如果你需要的话还有模板测试),所以我们还需要添加一个深度(和模板)附件到帧缓冲中。由于我们只希望采样颜色缓冲,而不是其它的缓冲,我们可以为它们创建一个渲染缓冲对象。还记得当我们不需要采样缓冲的时候,渲染缓冲对象是更好的选择吗?
创建一个渲染缓冲对象不太难。唯一一件要记住的事情是,我们正在创建的是一个渲染缓冲对象的深度和模板附件。我们它的内部给事设置为`GL_DEPTH24_STENCIL8`,对我们的目的来说这个精度已经足够了。
创建一个渲染缓冲对象不是非常复杂。我们需要记住的唯一事情是,我们将它创建为一个深度**和**模板附件渲染缓冲对象。我们它的**内部**格式设置为<var>GL_DEPTH24_STENCIL8</var>,对我们来说这个精度已经足够了。
```c++
GLuint rbo;
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
```
我们为渲染缓冲对象分配了足够的内存空间以后,我们可以解绑渲染缓冲。
我们为渲染缓冲对象分配了足够的内存后,我们可以解绑这个渲染缓冲。
着,在做好帧缓冲之前,还有最后一步,我们渲染缓冲对象附加到帧缓冲的深度模板附件上:
下来,作为完成帧缓冲之前最后一步,我们渲染缓冲对象附加到帧缓冲的深度**和**模板附件上:
```c++
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
```
然后我们要检查帧缓冲是否真的做好了,如果没有,我们打印一个错误息。
最后,我们希望检查帧缓冲是否是完整的,如果不是,我们打印错误息。
```c++
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << endl;
std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
```
还要保证解绑帧缓冲,这样我们不会意外渲染到错误的帧缓冲上。
记得要解绑帧缓冲,保证我们不会不小心渲染到错误的帧缓冲上。
现在帧缓冲做好了,我们要做的全部就是渲染到帧缓冲上,而不是绑定到帧缓冲对象的默认缓冲。余下所有命令会影响当前绑定的帧缓冲。所有深度和模板操作同样会从当前绑定的帧缓冲的深度和模板附件中读取,当然,得是在它们可用的情况下。如果你遗漏了比如深度缓冲,所有深度测试就不会工作,因为当前绑定的帧缓冲里没有深度缓冲。
现在这个帧缓冲就完整了,我们只需要绑定这个帧缓冲对象,让渲染到帧缓冲的缓冲中而不是默认的帧缓冲中。之后的渲染指令将会影响当前绑定的帧缓冲。所有深度和模板操作会从当前绑定的帧缓冲的深度和模板附件中(如果有的话)读取。如果你忽略了深度缓冲,那么所有深度测试操作将不再工作,因为当前绑定的帧缓冲中不存在深度缓冲。
所以,为把场景绘制到一个单独的纹理,我们必须以下步骤来做
所以,要想绘制场景到一个纹理,我们需要采取以下步骤:
1. 使用新的绑定为激活帧缓冲的帧缓冲,往常样渲染场景
2. 绑定默认帧缓冲
3. 绘制一个四边形,让它平铺到整个屏幕上,用新的帧缓冲的颜色缓冲作为的纹理。
1. 将新的帧缓冲绑定为激活的帧缓冲,往常样渲染场景
2. 绑定默认帧缓冲
3. 绘制一个横跨整个屏幕的四边形,将帧缓冲的颜色缓冲作为的纹理。
我们使用在深度测试教程中同一个场景进行绘制,但这次使用老气横秋的[箱子纹理](../img/04/05/container.jpg)。
我们将会绘制[深度测试](01 Depth testing.md)小节中的场景,但这次使用的是旧的[箱子](../img/04/05/container.jpg)纹理
为了绘制四边形我们将会创建的着色器。我们不打算引入任何花哨的变换矩阵,因为我们提供已经是标准化设备坐标的[顶点坐标](http://learnopengl.com/code_viewer.php?code=advanced/framebuffers_quad_vertices),所以我们可以直接它们为顶点着色器的输出。顶点着色器看起来像这样:
为了绘制这个四边形我们将会创建一套简单的着色器。我们将不会包含任何花哨的矩阵变换,因为我们提供是标准化设备坐标的[顶点坐标](https://learnopengl.com/code_viewer.php?code=advanced/framebuffers_quad_vertices),所以我们可以直接它们设定为顶点着色器的输出。顶点着色器这样
```c++
#version 330 core
layout (location = 0) in vec2 position;
layout (location = 1) in vec2 texCoords;
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
gl_Position = vec4(position.x, position.y, 0.0f, 1.0f);
TexCoords = texCoords;
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
TexCoords = aTexCoords;
}
```
没有花哨的地方。片段着色器更简洁,因为我们做的唯一一件事是从纹理采样:
没有太复杂的东西。片段着色器会更加基础,我们做的唯一一件事是从纹理采样:
```c++
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
out vec4 color;
uniform sampler2D screenTexture;
void main()
{
color = texture(screenTexture, TexCoords);
{
FragColor = texture(screenTexture, TexCoords);
}
```
接着需要你为屏幕上的四边形创建配置一个VAO。渲染迭代中帧缓冲处理会有下面的结构:
接着就靠你来为屏幕四边形创建配置一个VAO了。帧缓冲的一个渲染迭代将会有以下的结构:
```c++
// First pass
// 第一处理阶段(Pass)
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // We're not using stencil buffer now
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 我们现在不使用模板缓冲
glEnable(GL_DEPTH_TEST);
DrawScene();
// Second pass
glBindFramebuffer(GL_FRAMEBUFFER, 0); // back to default
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
DrawScene();
// 第二处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 返回默认
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
screenShader.Use();
screenShader.use();
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
glDrawArrays(GL_TRIANGLES, 0, 6);
```
只有很少的事情要说明。第一,由于我们用的每个帧缓冲都有自己的一系列缓冲,我们打算使用`glClear`设置的合适的位bits来清空这些缓冲。第二,当渲染四边形的时候,我们关闭深度测试,因为我们不关系深度测试,我们绘制的是一个简单的四边形;当我们绘制普通场景时我们必须再次开启深度测试。
要注意一些事情。第一,由于我们使用的每个帧缓冲都有自己一套缓冲,我们希望设置合适的位,调用<fun>glClear</fun>,清除这些缓冲。第二,当绘制四边形时,我们将禁用深度测试,因为我们是在绘制一个简单的四边形,并不需要关系深度测试。在绘制普通场景的时候我们将会重新启用深度测试。
这里的确有很多地方会做错,所以如果你没有获得任何输出,尝试排查任何可能出现错误的地方,再次阅读教程中相关章节。如果每件事都做对了就一定能成功,你将会得到这样的输出:
有很多步骤都可能会出错,所以如果你没有得到输出的话,尝试调试程序,并重新阅读本节的相关部分。如果所有的东西都能够正常工作,你将会得到下面这样的视觉输出:
![](../img/04/05/framebuffers_screen_texture.png)
展示了和深度测试教程中一样的输出结果,但这次是渲染一个简单的四边形上。如果我们以线框方式显示的话,那么显然,我们只是绘制了一个默认帧缓冲中单调的四边形。
展示的是视觉输出,它和[深度测试](01 Depth testing.md)中是完全一样的,但这次是渲染一个简单的四边形上。如果我们使用线框模式渲染场景,就会变得很明显,我们在默认的帧缓冲中只绘制了一个简单的四边形。
你可以[从这里得到应用的源码](http://learnopengl.com/code_viewer.php?code=advanced/framebuffers_screen_texture)
然而这有什么好处呢?好处就是我们现在可以自由的获取已经渲染场景中的任何像素,然后把它当作一个纹理图像了,我们可以在片段着色器中创建一些有意思的效果。所有这些有意思的效果统称为后处理特效。
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/5.1.framebuffers/framebuffers.cpp)找到程序的源代码
所以这个有什么用处呢?因为我们能够以一个纹理图像的方式访问已渲染场景中的每个像素,我们可以在片段着色器中创建出非常有趣的效果。这些有趣效果统称为<def>后期处理</def>(Post-processing)效果。
# 后期处理
现在,整个场景渲染到了一个单独的纹理上,我们可以创建一些有趣的效果,只要简单操纵纹理数据就能做到。这部分,我们会向你展示一些流行的后期处理(Post-processing)特效,以及怎样添加一些创造性去创建你自己的效。
既然整个场景都被渲染到了一个纹理上,我们可以简单地通过修改纹理数据创建一些非常有意思的效果。在这一部分,我们会向你展示一些流行的后期处理效果,并告诉你改如何使用创造力创建你自己的效
让我们先从最简单的后期处理效果开始。
### 反相
我们已经取得了渲染输出的每个颜色,所以在片段着色器返回这些颜色的反(Inversion)并不难。我们得到屏幕纹理颜色然后用1.0减去它:
我们现在能够访问渲染输出的每个颜色,所以在(译注:屏幕的)片段着色器返回这些颜色的反(Inversion)并不是很难。我们将会从屏幕纹理中取颜色然后用1.0减去它,对它进行反相
```c++
void main()
{
color = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}
```
虽然反相是一相对简单的后处理特效,但是已经很有趣了:
尽管反相是一相对简单的后处理效果,它已经能创造一些奇怪的效果了:
![image description](../img/04/05/framebuffers_inverse.png)
![](../img/04/05/framebuffers_inverse.png)
整个场景现在的颜色都反转了,只需在着色器中写一行代码就能做到,酷吧?
在片段着色器中仅仅使用一行代码就能让整个场景的颜色都反相了。很酷吧?
### 灰度
另一个有意思的效果是移除所有除了黑白灰以外的颜色作用,是整个图像成为黑白的。实现它的简单的方式是获得所有颜色元素,然后将它们平均化:
一个很有趣的效果是移除场景中除了黑白灰以外所有的颜色,让整个图像灰度化(Grayscale)。很简单的实现方式是,取所有颜色分量,将它们平均化:
```c++
void main()
{
color = texture(screenTexture, TexCoords);
float average = (color.r + color.g + color.b) / 3.0;
color = vec4(average, average, average, 1.0);
FragColor = texture(screenTexture, TexCoords);
float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
FragColor = vec4(average, average, average, 1.0);
}
```
这已经创造出很赞的效果了,但是人眼趋向于对绿色更敏感,对蓝色感知比较弱,所以为了获得更精确的符合人体物理的结果,我们需要使用加权通道:
这已经能创造很好的结果了,但人眼会对绿色更加敏感一些,而对蓝色不那么敏感,所以为了获取物理上更精确的效果,我们需要使用加权的(Weighted)通道:
```c++
void main()
{
color = texture(screenTexture, TexCoords);
float average = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
color = vec4(average, average, average, 1.0);
FragColor = texture(screenTexture, TexCoords);
float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
FragColor = vec4(average, average, average, 1.0);
}
```
![](../img/04/05/framebuffers_grayscale.png)
## Kernel effects
你可能不会立刻发现有什么差别,但在更复杂的场景中,这样的加权灰度效果会更真实一点。
在单独纹理图像上进行后处理的另一个好处是我们可以从纹理的其他部分进行采样。比如我们可以从当前纹理值的周围采样多个纹理值。创造性地把它们结合起来就能创造出有趣的效果了。
## 核效果
kernel是一个长得有点像一个小矩阵的数值数组它中间的值中心可以映射到一个像素上这个像素和这个像素周围的值再乘以kernel最后再把结果相加就能得到一个值。所以我们基本上就是给当前纹理坐标加上一个它四周的偏移量然后基于kernel把它们结合起来。下面是一个kernel的例子
在一个纹理图像上做后期处理的另外一个好处是,我们可以从纹理的其它地方采样颜色值。比如说我们可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。我们可以结合它们创建出很有意思的效果。
<def>核</def>(Kernel)(或卷积矩阵(Convolution Matrix))是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。所以,基本上我们是在对当前像素周围的纹理坐标添加一个小的偏移量,并根据核将结果合并。下面是核的一个例子:
$$
\begin{bmatrix}2 & 2 & 2 \\ 2 & -15 & 2 \\ 2 & 2 & 2 \end{bmatrix}
$$
这个kernel表示一个像素周围八个像素乘以2它自己乘以-15。这个例子基本上就是把周围像素乘上2中间像素乘以一个比较大的负数来进行平衡
这个核取了8个周围像素值将它们乘以2而把当前的像素乘以-15。这个核的例子将周围像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果
!!! Important
你在网上找到的kernel的例子大多数都是所有值加起来等于1如果加起来不等于1就意味着这个纹理值比原来更大或者更了。
你在网上找到的大部分核将所有的权重加起来之后都应该会等于1如果它们加起来不等于1,这就意味着最终的纹理颜色将会比原纹理值更亮或者更了。
kernel对于后处理来说非常管用因为用起来简单网上能找到很多实例为了能用上kernel我们还得改改片段着色器。这里假设每个kernel都是3×3实际上大多数都是3×3
核是后期处理一个非常有用的工具,它们使用和实验起来都很简单网上能找到很多例子。我们需要稍微修改一下片段着色器让它能够支持核。我们假设使用的核都是3x3核实际上大部分核都是
```c++
const float offset = 1.0 / 300;
const float offset = 1.0 / 300.0;
void main()
{
vec2 offsets[9] = vec2[](
vec2(-offset, offset), // top-left
vec2(0.0f, offset), // top-center
vec2(offset, offset), // top-right
vec2(-offset, 0.0f), // center-left
vec2(0.0f, 0.0f), // center-center
vec2(offset, 0.0f), // center-right
vec2(-offset, -offset), // bottom-left
vec2(0.0f, -offset), // bottom-center
vec2(offset, -offset) // bottom-right
vec2(-offset, offset), // 左上
vec2( 0.0f, offset), // 正上
vec2( offset, offset), // 右上
vec2(-offset, 0.0f), //
vec2( 0.0f, 0.0f), //
vec2( offset, 0.0f), //
vec2(-offset, -offset), // 左下
vec2( 0.0f, -offset), // 正下
vec2( offset, -offset) // 右下
);
float kernel[9] = float[](
@@ -364,7 +373,7 @@ void main()
-1, 9, -1,
-1, -1, -1
);
vec3 sampleTex[9];
for(int i = 0; i < 9; i++)
{
@@ -373,28 +382,28 @@ void main()
vec3 col = vec3(0.0);
for(int i = 0; i < 9; i++)
col += sampleTex[i] * kernel[i];
color = vec4(col, 1.0);
FragColor = vec4(col, 1.0);
}
```
在片段着色器中我们先为每个四周的纹理坐标创建一个9个vec2偏移量的数组。偏移量是一个简单的常数你可以设置为自己喜欢的。接着我们定义kernel这里应该是一个锐化kernel它通过一种有趣的方式从所有周边的像素采样每个颜色值进行锐化。最后,在采样的时候我们每个偏移量加到当前纹理坐标上,然后用加在一起的kernel的值乘以这些纹理值
在片段着色器中我们先为周的纹理坐标创建一个9个`vec2`偏移量的数组。偏移量是一个常量,你可以按照你的喜好自定义它。之后我们定义一个核,在这个例子中是一个<def>锐化</def>(Sharpen)核,它会采样周围的所有像素,锐化每个颜色值。最后,在采样我们每个偏移量加到当前纹理坐标上,获取需要采样的纹理,之后将这些纹理值乘以加权的核值,并将它们加到一起
这个锐化的kernel看起来这样:
这个锐化看起来这样
![](../img/04/05/framebuffers_sharpen.png)
创建有趣的效果就好像你的玩家吞了某种麻醉剂产生的幻觉一样
创建一些很有趣的效果,比如说你的玩家打了麻醉剂所感受到的效果
### 模糊
创建模糊(Blur)效果的kernel定义如下
创建<def>模糊</def>(Blur)效果的核是这样的
$$
\begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix} / 16
$$
由于所有数值加起来的总和为16,简单返回结合起来的采样颜色非常亮的,所以我们必须将kernel的每个值除以16.最终的kernel数组会是这样的:
由于所有值的和是16所以直接返回合并的采样颜色将产生非常亮的颜色,所以我们需要将核的每个值除以16最终的数组会是
```c++
float kernel[9] = float[](
@@ -404,29 +413,28 @@ float kernel[9] = float[](
);
```
通过在像素着色器中改变kernel的float数组,我们完全改变了之后的后处理效果.现在看起来会像是这样:
通过在片段着色器中改变核的<fun>float</fun>数组我们完全改变了后处理效果。它现在看起来是这样子的:
![](../img/04/05/framebuffers_blur.png)
这样的模糊效果具有创建许多有趣效果的潜力.例如,我们可以随着时间的变化改变模糊量,创建出类似于某人喝醉酒的效果,或者,当我们的主角摘掉眼镜的时候增加模糊.模糊也能为我们在后面的教程中提供都颜色值进行平滑处理的能力.
这样的模糊效果创造了很多的可能性。我们可以随着时间修改模糊的量,创造出玩家醉酒的效果或者在主角没带眼镜的时候增加模糊模糊也能够让我们来平滑颜色值,我们将在之后教程中使用到。
你可以看到我们一旦拥有了这个kernel的实现以后,创建一个后处理特效就不再是一件难事.最后,我们再来讨论一个流行的特效,以结束本节内容.
你可以看到,只要我们有了这个核的实现,创建炫酷的后期处理特效是非常容易的事。我们再来看最后一个流行的效果来结束本节的讨论。
### 边检测
### 边检测
下面的边检测(Edge-detection)kernel与锐化kernel类似:
下面的<def>边缘检测</def>(Edge-detection)核和锐化核非常相似:
$$
\begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix}
$$
这个kernel将所有的边提高亮度,而对其他部分进行暗化处理,当我们关心一副图像的边的时候,它非常有用.
这个核高亮了所有的边缘,而暗化了其它部分,在我们关心图像的边的时候非常有用的。
![](../img/04/05/framebuffers_edge_detection.png)
在一些像Photoshop这样的软件中使用这些kernel作为图像操作工具/过滤器一点都不奇怪.因为掀开可以具有很强的行处理能力,我们以实时进行针对每个像素图像操作便相对容易,图像编辑工具因而更经常使用显卡来进行图像处理
你可能不会奇怪,像是Photoshop这样的图像修改工具/滤镜使用的也是这样的核。因为显卡处理片段的时候有着极强的行处理能力,我们可以很轻松地在实时的情况下逐像素图像进行处理。所以图像编辑工具在图像处理的时候会更倾向于使用显卡
## 练习
!!! note "译注"
* 你可以使用帧缓冲来创建一个后视镜吗?做到它,你必须绘制场景两次:一次正常绘制,另一次摄像机旋转180度后绘制.尝试在你的显示器顶端创建一个小四边形,在上面应用后视镜的镜面纹理:[解决方案](http://learnopengl.com/code_viewer.php?code=advanced/framebuffers-exercise1),[视觉效果](../img/04/05/framebuffers_mirror.png)
* 自己随意调整一下kernel值,创建出你自己后处理特效.尝试在网上搜索其他有趣的kernel.
注意核在对屏幕纹理的边缘进行采样的时候由于还会对中心像素周围的8个像素进行采样其实会取到纹理之外的像素。由于环绕方式默认是<var>GL_REPEAT</var>,所以在没有设置的情况下取到的是屏幕另一边的像素,而另一边的像素本不应该对中心像素产生影响,这就可能会在屏幕边缘产生很奇怪的条纹。为了消除这一问题,我们可以将屏幕纹理的环绕方式都设置为<var>GL_CLAMP_TO_EDGE</var>。这样子在取到纹理外的像素时,就能够重复边缘的像素来更精确地估计最终的值了。

View File

@@ -3,65 +3,62 @@
原文 | [Cubemaps](http://learnopengl.com/#!Advanced-OpenGL/Cubemaps)
---|---
作者 | JoeyDeVries
翻译 | [Django](http://bullteacher.com/)
校对 | [Geequlim](http://geequlim.com)
翻译 | Meow J
校对 | 暂未校对
我们之前一直使用的是2D纹理还有更多的纹理类型我们没有探索过本教程中我们讨论的纹理类型是将多个纹理组合起来映射到一个单一纹理,它就是**立方体贴图(Cube Map)**
我们已经使用2D纹理很长时间了但除此之外仍有更多的纹理类型等着我们探索。在本节中我们讨论的是将多个纹理组合起来映射到一张纹理上的一种纹理类型:<def>立方体贴图</def>(Cube Map)。
基本上说立方体贴图包含6个2D纹理每个2D纹理是一个立方体cube的一个面也就是说它是一个有贴图的立方体。你可能会奇怪这样立方体有什么用?为什么费事地把6个独立纹理结合为一个单独的纹理只使用6个各自独立的不行吗这是因为立方体贴图有自己特有的属性,可以使用方向向量对它们索引采样。想象一下,我们有一个1×1×1的单位立方体有个以原点为起点的方向向量在它的中心。
从立方体贴图上使用橘黄色向量采样一个纹理值看起来和下图有点像:
简单来说,立方体贴图就是一个包含6个2D纹理的纹理每个2D纹理都组成了立方体的一个面:一个有纹理的立方体。你可能会奇怪这样一个立方体有什么用途呢?为什么要把6张纹理合并到一张纹理中而不是直接使用6个单独的纹理呢立方体贴图有一个非常有用的特性,可以通过一个方向向量来进行索引/采样。假设我们有一个1x1x1的单位立方体方向向量的原点位于它的中心。使用一个橘黄色的方向向量来从立方体贴图上采样一个纹理值会像是这样:
![](../img/04/06/cubemaps_sampling.png)
!!! Important
方向向量的大小无关紧要。一旦提供了方向OpenGL就会获取方向向量触碰到立方体表面上的相应的纹理像素texel这样就返回了正确的纹理采样值。
方向向量的大小并不重要只要提供了方向OpenGL就会获取方向向量最终所击中的纹素并返回对应的采样纹理值。
如果我们假设将这样的立方体贴图应用到一个立方体上,采样立方体贴图所使用的方向向量将和立方体(插值的)顶点位置非常相像。这样子,只要立方体的中心位于原点,我们就能使用立方体的实际位置向量来对立方体贴图进行采样了。接下来,我们可以将所有顶点的纹理坐标当做是立方体的顶点位置。最终得到的结果就是可以访问立方体贴图上正确<def></def>(Face)纹理的一个纹理坐标。
方向向量触碰到立方体表面的一点也就是立方体贴图的纹理位置,这意味着只要立方体的中心位于原点上,我们就可以使用立方体的位置向量来对立方体贴图进行采样。然后我们就可以获取所有顶点的纹理坐标,就和立方体上的顶点位置一样。所获得的结果是一个纹理坐标,通过这个纹理坐标就能获取到立方体贴图上正确的纹理。
## 创建立方体贴图
## 创建一个立方体贴图
立方体贴图和其他纹理一样,所以要创建一个立方体贴图,在进行任何纹理操作之前,需要生成一个纹理,激活相应纹理单元然后绑定到合适的纹理目标上。这次要绑定到 `GL_TEXTURE_CUBE_MAP`纹理类型:
立方体贴图是和其它纹理一样的,所以如果想创建一个立方体贴图的话,我们需要生成一个纹理,并将其绑定到纹理目标上,之后再做其它的纹理操作。这次要绑定到<var>GL_TEXTURE_CUBE_MAP</var>
```c++
GLuint textureID;
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
```
由于立方体贴图包含6个纹理立方体的每个面一个纹理,我们必须调用`glTexImage2D`函数6次函数的参数和前面教程讲的相似。然而这次我们必须把纹理目标target参数设置为立方体贴图特定的面,这是告诉OpenGL我们创建的纹理是对应立方体哪个面的。因此我们便需要立方体贴图的每个面调用一次 `glTexImage2D`
因为立方体贴图包含6个纹理每个面一个我们需要调用<fun>glTexImage2D</fun>函数6次参数和之前教程中很类似。但这一次我们将纹理目标(**target**)参数设置为立方体贴图的一个特定的面告诉OpenGL我们在对立方体贴图的哪一个面创建纹理。这就意味着我们需要立方体贴图的每个面调用一次<fun>glTexImage2D</fun>
由于立方体贴图有6个面OpenGL提供了6个不同的纹理目标,来应对立方体贴图的个面。
由于我们有6个面OpenGL给我们提供了6个特殊的纹理目标,专门对应立方体贴图的个面。
纹理目标Texture target | 方位
---|---
GL_TEXTURE_CUBE_MAP_POSITIVE_X | 右
GL_TEXTURE_CUBE_MAP_NEGATIVE_X | 左
GL_TEXTURE_CUBE_MAP_POSITIVE_Y | 上
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y | 下
GL_TEXTURE_CUBE_MAP_POSITIVE_Z | 后
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z | 前
纹理目标 | 方位
---|---
`GL_TEXTURE_CUBE_MAP_POSITIVE_X` | 右
`GL_TEXTURE_CUBE_MAP_NEGATIVE_X` | 左
`GL_TEXTURE_CUBE_MAP_POSITIVE_Y` | 上
`GL_TEXTURE_CUBE_MAP_NEGATIVE_Y` | 下
`GL_TEXTURE_CUBE_MAP_POSITIVE_Z` | 后
`GL_TEXTURE_CUBE_MAP_NEGATIVE_Z` | 前
很多OpenGL其他枚举一样对应的int值都是连续增加所以我们有一个纹理位置的数组或vector就能以 `GL_TEXTURE_CUBE_MAP_POSITIVE_X`为起始来对它们进行遍历,每次迭代枚举值加 `1`,这样循环所有的纹理目标效率较高
和OpenGL的很多枚举(Enum)一样,它们背后的<fun>int</fun>值是线性递增的,所以如果我们有一个纹理位置的数组或vector我们就可以从<var>GL_TEXTURE_CUBE_MAP_POSITIVE_X</var>开始遍历它们在每个迭代中对枚举值加1遍历了整个纹理目标
```c++
int width,height;
unsigned char* image;
for(GLuint i = 0; i < textures_faces.size(); i++)
int width, height, nrChannels;
unsigned char *data;
for(unsigned int i = 0; i < textures_faces.size(); i++)
{
image = SOIL_load_image(textures_faces[i], &width, &height, 0, SOIL_LOAD_RGB);
data = stbi_load(textures_faces[i].c_str(), &width, &height, &nrChannels, 0);
glTexImage2D(
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
}
```
我们有个vector叫`textures_faces`,它包含立方体贴图所各个纹理的文件路径,并且以上表所列的顺序排列。将为每个当前绑定的cubemp的每个面生成一个纹理。
我们有一个叫做<var>textures_faces</var>的<fun>vector</fun>,它包含立方体贴图所需的所有纹理路径,并以表中的顺序排列。将为当前绑定的立方体贴图中的每个面生成一个纹理。
由于立方体贴图和其纹理没什么不同,我们也要定义它的环绕方式和过滤方式:
因为立方体贴图和其纹理没什么不同,我们也需要设定它的环绕和过滤方式:
```c++
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
@@ -71,105 +68,109 @@ glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
```
别被 `GL_TEXTURE_WRAP_R`吓到它只是简单的设置了纹理的R坐标R坐标对应于纹理的第三个维度(就像位置的z一样)。我们把放置方式设置为 `GL_CLAMP_TO_EDGE` ,由于纹理坐标在两个面之间,所以可能不能触及哪个面(由于硬件限制),因此使用 `GL_CLAMP_TO_EDGE` 后OpenGL会返回它们的边界的值尽管我们可能在两个两个面中间进行的采样
不要被<var>GL_TEXTURE_WRAP_R</var>吓到,它仅仅是为纹理的**R**坐标设置了环绕方式,它对应的是纹理的第三个维度(位置的**z**一样)。我们将环绕方式设置为<var>GL_CLAMP_TO_EDGE</var>,这是因为正好处于两个面之间的纹理坐标可能不能击中一个面(由于一些硬件限制),所以通过使用<var>GL_CLAMP_TO_EDGE</var>OpenGL将在我们对两个面之间采样的时候永远返回它们的边界值
在绘制物体之前,将使用立方体贴图,而在渲染前我们要激活应的纹理单元并绑定立方体贴图这和普通的2D纹理没什么区别。
在绘制使用立方体贴图的物体之前,我们要激活应的纹理单元并绑定立方体贴图这和普通的2D纹理没什么区别。
在片段着色器中,我们也必须使用一个不同的采样器——**samplerCube**,用它来从`texture`函数中采样,但这次使用的是一个`vec3`方向向量,取代`vec2`。下面是一个片段着色器使用立方体贴图的例子
在片段着色器中,我们使用一个不同类型的采样器`samplerCube`,我们将使用<fun>texture</fun>函数使用它进行采样,但这次我们将使用一个`vec3`方向向量而不是`vec2`。使用立方体贴图的片段着色器会像是这样的
```c++
in vec3 textureDir; // 用一个三维方向向量来表示立方体贴图纹理坐标
uniform samplerCube cubemap; // 立方体贴图纹理采样器
in vec3 textureDir; // 代表3D纹理坐标的方向向量
uniform samplerCube cubemap; // 立方体贴图的纹理采样器
void main()
{
color = texture(cubemap, textureDir);
{
FragColor = texture(cubemap, textureDir);
}
```
看起来不错,但是何必这么做呢?因为恰巧使用立方体贴图可以简单的实现很多有意思的技术。其中之一便是著名的**天空盒(Skybox)**
看起来很棒,但为什么要用它呢?恰巧有一些很有意思的技术,使用立方体贴图来实现的话会简单多了。其中一个技术就是创建一个<def>天空盒</def>(Skybox)。
# 天空盒
天空盒(Skybox)是一个包整个场景的立方体,它由6个图像构成一个环绕的环境给玩家一种他所在的场景比实际的要大得多的幻觉。比如有些在视频游戏中使用天空盒的图像是群山、白云或者满天繁星。比如下面的夜空繁星的图像就来自《上古卷轴
天空盒是一个包含了整个场景的(大)立方体,它包含周围环境的6个图像让玩家以为他处在一个比实际大得多的环境当中。游戏中使用天空盒的例子有群山、白云或星空。下面这张截图中展示的是星空的天空盒,它来自于『上古卷轴3』
![](../img/04/06/cubemaps_morrowind.jpg)
现在可能已经猜到立方体贴图完全满足天空盒的求:我们有一个立方体,它有6个面每个面需要一个贴图。上图中使用了几个夜空的图片给予玩家一种置身广袤宇宙的感觉,实际上,他还是在一个小盒子中。
你可能现在已经猜到了,立方体贴图能完美满足天空盒的求:我们有一个6面的立方体,每个面需要一个纹理。在上面的图片中,他们使用了夜空的几张图片,让玩家产生其位于广袤宇宙中的错觉,实际上他只是在一个小小的盒子中。
网上有很多这样的天空盒资源。[这个网站](http://www.custommapmakers.org/skyboxes.php)就提供了很多。这些天空盒图像通常有下面的样式:
你可以在网上找到很多这样的天空盒资源。比如说这个[网站](http://www.custommapmakers.org/skyboxes.php)就提供了很多天空盒。天空盒图像通常有以下的形式:
![](../img/04/06/cubemaps_skybox.png)
如果你把这6个面折叠到一个立方体,你机会获得模拟一个巨大的风景的立方体。有些资源所提供的天空盒比如这个例子6个图是连在一起的你必须手工它们切割出来不过大多数情况它们都是6单独的纹理图像。
如果你将这六个面折一个立方体,你就会得到一个完全贴图的立方体,模拟一个巨大的场景。一些资源可能会提供了这样格式的天空盒,你必须手动提取六个面的图像,但在大部分情况它们都是6单独的纹理图像。
这个细致(高精度)的天空盒就是我们将在场景中使用的那个,你可以[在这里下载](../img/04/06/skybox.rar)。
之后我们将在场景中使用这个(高质量的)天空盒,它可以在[这里](../data/skybox.rar)下载到
## 加载天空盒
由于天空盒实际上就是一个立方体贴图,加载天空盒和之前我们加载立方体贴图的没什么大的不同。为了加载天空盒我们将使用下面的函数,它接一个包含6个纹理文件路径的vector
因为天空盒本身就是一个立方体贴图,加载天空盒和之前加载立方体贴图时并没有什么不同。为了加载天空盒我们将使用下面的函数,它接一个包含6个纹理路径的<fun>vector</fun>
```c++
GLuint loadCubemap(vector<const GLchar*> faces)
unsigned int loadCubemap(vector<std::string> faces)
{
GLuint textureID;
unsigned int textureID;
glGenTextures(1, &textureID);
glActiveTexture(GL_TEXTURE0);
int width,height;
unsigned char* image;
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
for(GLuint i = 0; i < faces.size(); i++)
int width, height, nrChannels;
for (unsigned int i = 0; i < faces.size(); i++)
{
image = SOIL_load_image(faces[i], &width, &height, 0, SOIL_LOAD_RGB);
glTexImage2D(
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0,
GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image
);
unsigned char *data = stbi_load(faces[i].c_str(), &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
stbi_image_free(data);
}
else
{
std::cout << "Cubemap texture failed to load at path: " << faces[i] << std::endl;
stbi_image_free(data);
}
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_CUBE_MAP, 0);
return textureID;
}
```
这个函数没什么特别之处。这就是我们前面已经见过的立方体贴图代码,只不过放进了一个管理的函数中。
函数本身应该很熟悉了。它基本就是上一部分中立方体贴图代码,只不过合并到了一个便于管理的函数中。
后,在我们调用这个函数之前,我们将合适的纹理路径加载到一个vector之中顺序还是按照立方体贴图枚举的特定顺序
后,在调用这个函数之前,我们需要将合适的纹理路径按照立方体贴图枚举指定的顺序加载到一个vector中。
```c++
vector<const GLchar*> faces;
faces.push_back("right.jpg");
faces.push_back("left.jpg");
faces.push_back("top.jpg");
faces.push_back("bottom.jpg");
faces.push_back("back.jpg");
faces.push_back("front.jpg");
GLuint cubemapTexture = loadCubemap(faces);
vector<std::string> faces;
{
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"back.jpg",
"front.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);
```
现在我们已经用`cubemapTexture`作为id把天空盒加载为立方体贴图。我们现在可以它绑定到一个立方体来替换不完美的`clear color`,在前面的所有教程中这个东西做背景已经很久了
现在我们就将这个天空盒加载为一个立方体贴图了它的id是<var>cubemapTexture</var>。我们可以它绑定到一个立方体中,替换掉用了很长时间的难看的纯色背景
## 显示天空盒
因为天空盒绘制在一个立方体上,我们需要另一个VAO、VBO以及一组全新的顶点,和任何其他物体一样。你可以[从这里获得顶点数据](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps_skybox_data)。
由于天空盒绘制在一个立方体上的,和其它物体一样我们需要另一个VAO、VBO以及新的一组顶点。你可以在[这里](https://learnopengl.com/code_viewer.php?code=advanced/cubemaps_skybox_data)找到它的顶点数据
立方体贴图用于给3D立方体帖上纹理可以用立方体的位置作为纹理坐标进行采样。当一个立方体的中心位于原点(000)的时候,它的每一个位置向量也就是以原点为起点的方向向量。这个方向向量就是我们要得到的立方体某个位置的相应纹理值。出于这个理由,我们只需要提供位置向量,而无需纹理坐标。为了渲染天空盒,我们需要一组新着色器,它们不会太复杂。因为我们只有一个顶点属性,顶点着色器非常简单:
用于贴图3D立方体的立方体贴图可以使用立方体的位置作为纹理坐标采样。当立方体于原点(0, 0, 0)时,它的每一个位置向量都是从原点出发的方向向量。这个方向向量正是获取立方体上特定位置的纹理值所需要的。正是因为这个,我们只需要提供位置向量而不用纹理坐标了。
要渲染天空盒的话,我们需要一组新的着色器,它们都不是很复杂。因为我们只有一个顶点属性,顶点着色器非常简单:
```c++
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
@@ -177,117 +178,121 @@ uniform mat4 view;
void main()
{
gl_Position = projection * view * vec4(position, 1.0);
TexCoords = position;
TexCoords = aPos;
gl_Position = projection * view * vec4(aPos, 1.0);
}
```
注意,顶点着色器有意思的地方在于我们输入的位置向量作为输出给片段着色器的纹理坐标。片段着色器就会把它们作为输入采样samplerCube
注意,顶点着色器中很有意思的部分是,我们输入的位置向量作为输出给片段着色器的纹理坐标。片段着色器会将它作为输入采样`samplerCube`
```c++
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
out vec4 color;
uniform samplerCube skybox;
void main()
{
color = texture(skybox, TexCoords);
{
FragColor = texture(skybox, TexCoords);
}
```
片段着色器比较明了,我们顶点属性的位置向量作为纹理的方向向量,使用它从立方体贴图采样纹理值。渲染天空盒现在很简单,我们有了一个立方体贴图纹理,我们简单绑定立方体贴图纹理,天空盒就自动地用天空盒的立方体贴图填充了。为了绘制天空盒,我们将把它作为场景中第一个绘制的物体并且关闭深度写入。这样天空盒才能成为所有其他物体的背景来绘制出来。
片段着色器非常直观。我们顶点属性的位置向量作为纹理的方向向量,使用它从立方体贴图采样纹理值。
有了立方体贴图纹理,渲染天空盒现在就非常简单了,我们只需要绑定立方体贴图纹理,<var>skybox</var>采样器就会自动填充上天空盒立方体贴图了。绘制天空盒时,我们需要将它变为场景中的第一个渲染的物体,并且禁用深度写入。这样子天空盒就会永远被绘制在其它物体的背后了。
```c++
glDepthMask(GL_FALSE);
skyboxShader.Use();
// ... Set view and projection matrix
skyboxShader.use();
// ... 设置观察和投影矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glBindVertexArray(0);
glDepthMask(GL_TRUE);
// ... Draw rest of the scene
// ... 绘制剩下的场景
```
如果你运行程序就会陷入困境,我们希望天空盒以玩家为中心,这样论玩家移动了多远,天空盒都不会变近,这样就产生一种四周的环境真的非常大的印象。当前的视图矩阵对所有天空盒的位置进行了转转缩放和平移变换,所以玩家移动,立方体贴图也会跟着移动!我们打算移除视图矩阵的平移部分这样移动就影响不到天空盒的位置向量了。在基础光照教程里我们提到过我们可以只用4X4矩阵的3×3部分去除平移。我们可以简单地将矩阵转为33矩阵再转回来就能达到目标
如果你运行一下的话你就会发现出现了一些问题。我们希望天空盒以玩家为中心,这样论玩家移动了多远,天空盒都不会变近,让玩家产生周围环境非常大的印象。然而,当前的观察矩阵会旋转、缩放和位移来变换天空盒的所有位置,所以玩家移动的时候,立方体贴图也会移动!我们希望移除观察矩阵中的位移部分,让移动不会影响天空盒的位置向量。
你可能还记得在[基础光照](../02 Lighting/02 Basic Lighting.md)小节中我们通过取4x4矩阵左上角的3x3矩阵来移除变换矩阵的位移部分。我们可以将观察矩阵转换为3x3矩阵移除位移再将其转换回4x4矩阵来达到类似的效果。
```c++
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
```
移除所有平移,但保留所有旋转,因此用户仍然能够向四面八方看。由于有了天空盒,场景即可变得巨大了。如果你添加些物体然后自由在其中游荡一会儿你会发现场景的真实度有了极大提升。最后的效果看起来像这样:
移除任何的位移,但保留旋转变换,让玩家仍然能够环顾场景。
有了天空盒,最终的效果就是一个看起来巨大的场景了。如果你在箱子周围转一转,你就能立刻感受到距离感,极大地提升了场景的真实度。最终的结果看起来是这样的:
![](../img/04/06/cubemaps_skybox_result.png)
[这里有全部源码](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps_skybox),你可以对比一下你写的。
尝试用不同的天空盒实验,看看它们对场景有多大影响。
试一试不同的天空盒,看看它们是怎样对场景的观感产生巨大影响的。
## 优化
现在我们在渲染场景中的其物体之前渲染了天空盒。这么做没错,但是不怎么高效。如果我们先渲染天空盒,那么我们就是在为每一个屏幕上的像素运行片段着色器,即使天空盒只有部分在显示着fragment可以使用前置深度测试early depth testing)简单地被丢弃,这样就节省我们宝贵的带宽。
目前我们是首先渲染天空盒,之后再渲染场景中的其物体。这样子能够工作,但不是非常高效。如果我们先渲染天空盒,我们就会对屏幕上的每一个像素运行一遍片段着色器,即便只有一小部分的天空盒最终是可见的。可以使用<def>提前深度测试</def>(Early Depth Testing)轻松丢弃掉的片段能够节省我们很多宝贵的带宽。
所以最后渲染天空盒就能够给我们带来轻微的性能提升。采用这种方式,深度缓冲被全部物体的深度值完全填充,所以我们只需要渲染通过前置深度测试的那部分天空的片段就行了,而且能显著减少片段着色器的调用。问题是天空盒是个1×1×1的立方体极有可能会渲染失败,因为极有可能通不过深度测试。简单地不用深度测试渲染它也不是解决方案,这是因为天空盒会在之后覆盖所有的场景中其他物体。我们需要耍个花招让深度缓冲相信天空盒的深度缓冲有着最大深度值1.0如此只要有个物体存在深度测试就会失败,看似物体就在它前面了
所以,我们将会最后渲染天空盒,以获得轻微的性能提升。这样子的话,深度缓冲就会填充满所有物体的深度值了,我们只需要在提前深度测试通过的地方渲染天空的片段就可以了,很大程度上减少片段着色器的调用。问题是天空盒只是一个1x1x1的立方体它很可能会不通过大部分的深度测试,导致渲染失败。不用深度测试来进行渲染不是解决方案,因为天空盒将会复写场景中的其它物体。我们需要欺骗深度缓冲,让它认为天空盒有着最大深度值1.0只要它前面有一个物体深度测试就会失败。
在坐标系教程中我们说过,透视除法perspective division是在顶点着色器运行之后执行的,把`gl_Position`的xyz坐标除以w元素。我们深度测试教程了解到除法结果的z元素等于顶点的深度值。用这信息,我们可以输出位置的z元素设置为它的w元素这样就会导致z元素等于1.0了因为当透视除法应用后它的z元素转换为w/w = 1.0
[坐标系统](../01 Getting started/08 Coordinate Systems.md)小节中我们说过,**透视除法**是在顶点着色器运行之后执行的,将<var>gl_Position</var>的`xyz`坐标除以w分量。我们又从[深度测试](01 Depth testing.md)小节中知道,相除结果的z分量等于顶点的深度值。使用这信息,我们可以输出位置的z分量等于它的w分量让z分量永远等于1.0这样子的话当透视除法执行之后z分量会变为`w / w = 1.0`。
```c++
void main()
{
vec4 pos = projection * view * vec4(position, 1.0);
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
TexCoords = position;
}
```
最终标准化设备坐标就总会有个与1.0相等的z值1.0就是深度值的最大值。只有在没有任何物体可见的情况下天空盒才会被渲染(只有通过深度测试才渲染,否则假如有任何物体存在,就不会被渲染,只去渲染物体)。
最终的**标准化设备坐标**将永远会有一个等于1.0的z值:最大的深度值。结果就是天空盒只会在没有可见物体的地方渲染(只有这样才能通过深度测试,其它所有的东西都在天空盒前面)。
我们必须改变一下深度方程,把它设置为`GL_LEQUAL`,原来默认的是`GL_LESS`。深度缓冲会为天空盒1.0这个值填充深度缓冲,所以我们需要保证天空盒是使用小于等于深度缓冲来通过深度测试的,而不是小于。
我们还要改变一下深度函数,将它从默认的<var>GL_LESS</var>改为<var>GL_LEQUAL</var>。深度缓冲将会填充上天空盒1.0,所以我们需要保证天空盒在值小于等于深度缓冲而不是小于时通过深度测试
你可以在这里找到优化过的版本的[源码](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps_skybox_optimized)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/6.1.cubemaps_skybox/cubemaps_skybox.cpp)找到优化后的源代码
# 环境映射
我们现在有了一个把整个环境映射到一个单独纹理对象,我们利用这个信息能做的不仅天空盒。使用带有场景环境的立方体贴图,我们可以物体有一个反射折射属性。这样使用环境立方体贴图的技术叫做**环境贴图技术**,其中最重要的两个是**反射(reflection)**和**折射(refraction)**
我们现在整个环境映射到一个纹理对象上了,能利用这个信息的不仅仅只有天空盒。通过使用环境的立方体贴图,我们可以物体反射折射属性。这样使用环境立方体贴图的技术叫做<def>环境映射</def>(Environment Mapping),其中最流行的两个是<def>反射</def>(Reflection)和<def>折射</def>(Refraction)。
## 反射
凡是是一个物体(或物体的部分)反射(Reflect)他周围环境的属性,比如物体的颜色多少有些等于它周围的环境,这要基于观察者的角度。例如一个镜子是一个反射物体:它会基于观察者的角度泛着它周围的环境。
反射这个属性表现为物体(或物体的部分)<def>反射</def>它周围环境,即根据观察者的视角,物体的颜色或多或少等于它的环境镜子是一个反射物体:它会根据观察者的视角反射它周围的环境。
反射的基本思路不难。下图展示了我们如何计算反射向量,然后使用这个向量去从一个立方体贴图中采样:
反射的原理并不难。下面这张图展示了我们如何计算反射向量,并如何使用这个向量来从立方体贴图中采样:
![](../img/04/06/cubemaps_reflection_theory.png)
我们基于观察方向向量I和物体的法线向量N计算反射向量R。我们可以使用GLSL内建函数reflect来计算这个反射向量。最后向量R作为一个方向向量对立方体贴图进行索引/采样,返回一个环境的颜色值。最后的效果看起来就像物体反射了天空盒。
我们根据观察方向向量\(\color{gray}{\bar{I}}\)和物体的法向量\(\color{red}{\bar{N}}\),来计算反射向量\(\color{green}{\bar{R}}\)。我们可以使用GLSL内建的<fun>reflect</fun>函数来计算这个反射向量。最终的\(\color{green}{\bar{R}}\)向量将会作为索引/采样立方体贴图的方向向量,返回环境的颜色值。最终的结果是物体看起来反射了天空盒。
因为我们在场景中已经设置了一个天空盒,创建反射就不难了。我们改变一下箱子使用的那个片段着色器,箱子一个反射性:
因为我们已经在场景中配置好天空盒,创建反射效果并不会很难。我们将会改变箱子的片段着色器,箱子反射性:
```c++
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 Position;
out vec4 color;
uniform vec3 cameraPos;
uniform samplerCube skybox;
void main()
{
{
vec3 I = normalize(Position - cameraPos);
vec3 R = reflect(I, normalize(Normal));
color = texture(skybox, R);
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
```
我们先计算观察/摄像机方向向量I然后使用它来计算反射向量R接着我们用R从天空盒立方体贴图采样。注意的是我们有了片段的插值NormalPosition变量所以我们需要修正顶点着色器适应它
我们先计算观察/摄像机方向向量`I`,并使用它来计算反射向量`R`,之后我们将使用`R`来从天空盒立方体贴图采样。注意,我们现在又有了片段的插值<var>Normal</var>和<var>Position</var>变量,所以我们需要更新一下顶点着色器。
```c++
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
out vec3 Normal;
out vec3 Position;
@@ -298,97 +303,90 @@ uniform mat4 projection;
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
Normal = mat3(transpose(inverse(model))) * normal;
Position = vec3(model * vec4(position, 1.0f));
Normal = mat3(transpose(inverse(model))) * aNormal;
Position = vec3(model * vec4(aPos, 1.0));
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
```
我们用了法线向量,所以我们打算使用一个**法线矩阵(normal matrix)**变换它们。`Position`输出向量是一个世界空间位置向量。顶点着色器输出的`Position`用来在片段着色器计算观察方向向量。
我们现在使用了一个法向量,所以我们将再次使用法线矩阵(Normal Matrix)变换它们。<var>Position</var>输出向量是一个世界空间位置向量。顶点着色器的这个<var>Position</var>输出将用来在片段着色器计算观察方向向量。
因为我们使用法线,你还得更新顶点数据,更新属性指针。还要确保设置`cameraPos`的uniform。
因为我们使用法线,你还需要更新一下[顶点数据](https://learnopengl.com/code_viewer.php?code=lighting/basic_lighting_vertex_data),并更新属性指针。还要记得去设置<var>cameraPos</var>这个uniform。
然后在渲染箱子前我们还得绑定立方体贴图纹理:
接下来,我们在渲染箱子之前先绑定立方体贴图纹理:
```c++
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glBindVertexArray(0);
```
编译运行你的代码,你得到一个镜子一样的箱子。箱子完美地反射了周围的天空盒:
编译运行代码,你将会得到一个像是镜子一样的箱子。周围的天空盒被完美地反射在箱子上。
![](../img/04/06/cubemaps_reflection.png)
你可以[从这里找到全部源代码](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps_reflection)
你可以在[这里](https://learnopengl.com/code_viewer_gh.php?code=src/4.advanced_opengl/6.2.cubemaps_environment_mapping/cubemaps_environment_mapping.cpp)找到完整的源代码
当反射应用整个物体之上的时候,物体看上去就像有一个像钢和铬这种高反射材质。如果我们加载[模型教程](../03 Model Loading/03 Model.md)中的纳米铠甲模型,我们就会获得一个铬金属制铠甲
当反射应用到一整个物体上(像是箱子)时,这个物体看起来就像是钢或者铬这样的高反射材质。如果我们加载[模型加载](../03 Model Loading/03 Model.md)小节中的纳米模型,我们会得到一种整个套装都是使用铬做成的效果
![](../img/04/06/cubemaps_reflection_nanosuit.png)
看起来挺惊艳,但现实中大多数模型都不完全反射。我们可以引进反射贴图reflection map来使模型有另一层细节。和diffuse、specular贴图一样我们可以从反射贴图上采样来决定fragment的反射率。使用反射贴图我们可以决定模型的哪部分有反射能力,以及强度是多少。本节的练习中,由你来我们早期创建的模型加载器引入反射贴图,这回极大的提升纳米模型的细节。
看起来非常棒,但现实中大部分的模型都不具有完全反射。我们可以引入<def>反射贴图</def>(Reflection Map),来给模型更多的细节。与漫反射和镜面光贴图一样,反射贴图也是可以采样的纹理图像,它决定这片段的反射性。通过使用反射贴图我们可以知道模型的哪部分该以什么强度显示反射。在本节的练习中,由你来我们之前创建的模型加载器引入反射贴图,显著提升纳米模型的细节。
## 折射
环境映射的另一形式叫做折射(Refraction),它和反射差不多。折射是光线通过特定材质对光线方向的改变。我们通常看到像水一样的表面,光线不是直接通过的,而是让光线弯曲了一点。它看起来像你把半只手伸进水里的效果。
环境映射的另一形式是<def>折射</def>,它和反射很相似。折射是光线由于传播介质的改变而产生的方向变化。在常见的类水表面上所产生的现象就是折射,光线不是直直地传播,而是弯曲了一点。将你的半只胳膊伸进水里,观察出来的就是这种效果。
折射遵守[斯涅尔定律](http://en.wikipedia.org/wiki/Snell%27s_law),使用环境贴图看起来像这样:
折射是通过[斯涅尔定律](https://en.wikipedia.org/wiki/Snell%27s_law)(Snell's Law)来描述的,使用环境贴图的话看起来像这样:
![](../img/04/06/cubemaps_refraction_theory.png)
我们有个观察向量I,一个法线向量N这次折射向量是R。就像你所看到的那样,观察向量的方向轻微弯曲。弯的向量R随后用来从立方体贴图采样。
同样,我们有个观察向量\(\color{gray}{\bar{I}}\),一个法向量\(\color{red}{\bar{N}}\),而这次折射向量\(\color{green}{\bar{R}}\)。可以看到,观察向量的方向轻微弯曲。弯折后的向量\(\color{green}{\bar{R}}\)将会用来从立方体贴图采样。
折射可以通过GLSL的内建函数refract来实现,除此之外还需要一个法线向量一个观察方向和一个两种材质之间的折射指数
折射可以使用GLSL的内建<fun>refract</fun>函数来轻松实现,它需要一个法向量一个观察方向和两个材质之间的<def>折射率</def>(Refractive Index)
折射指数决定了一个材质光线曲的数量,每个材质都有自己的折射指数。下表是常见的折射指数
折射决定了材质光线曲的程度,每个材质都有自己的折射率。一些最常见的折射率可以在下表中找到
材质 | 折射指数
材质 | 折射
---|---
空气 | 1.00
水 | 1.33
冰 | 1.309
玻璃 | 1.52
石 | 2.42
石 | 2.42
我们使用这些折射指数来计算光线通过两个材质的比。在我们的例子中,光线/视线从空气进入玻璃(如果我们假设箱子是玻璃的)所以比率是1.001.52 = 0.658。
我们使用这些折射来计算光传播的两种材质的比。在我们的例子中,光线/视线从**空气**进入**玻璃**(如果我们假设箱子是玻璃的)所以比值为\(\frac{1.00}{1.52} = 0.658\)
我们已经绑定了立方体贴图,提供了点数据设置了摄像机位置的uniform。现在只需要改变片段着色器:
我们已经绑定了立方体贴图,提供了点数据和法线,并设置了摄像机位置的uniform。唯一要修改的就是片段着色器:
```c++
void main()
{
{
float ratio = 1.00 / 1.52;
vec3 I = normalize(Position - cameraPos);
vec3 R = refract(I, normalize(Normal), ratio);
color = texture(skybox, R);
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
```
通过改变折射指数你可以创建完全不同的视觉效果。编译运行应用,结果不是有趣,因为我们只用了一个普通箱子,这不能显示折射的效果,看起来像个放大镜。使用同一个着色器,纳米服模型却可以展示出我们期待的效果:玻璃物体。
通过改变折射率,你可以创建完全不同的视觉效果。编译程序并运行,但结果不是有趣,因为我们只使用了一个简单的箱子,它不太能显示折射的效果,现在看起来只是有点像一个放大镜。对纳米装使用相同的着色器却能够展现出了我们期待的效果:一个类玻璃物体。
![](../img/04/06/cubemaps_refraction.png)
你可以想象一下,如果将光线、反射、折射和顶点移动合理的结合起来就能创造出漂亮的水的图像。一定要注意,出于物理精确的考虑当光线离开物体的时候还要再次进行折射现在我们简单的使用了单边(一次)折射,大多数目的都可以得到满足
你可以想象出有了光照、反射、折射和顶点移动的正确组合,你可以创建出非常漂亮的水。注意,如果要想获得物理精确的结果,我们还需要在光线离开物体的时候再次折射现在我们使用的只是单面折射(Single-side Refraction),但它对大部分场合都是没问题的
## 动态环境贴图
现在我们已经使用了静态图像组合天空盒,看起来不错,但没有考虑到物体可能移动的实际场景。我们到现在还没注意到这点,因为我们目前还只使用了一个物体。如果我们有个镜子一样的物体,周围有多个物体,只有天空盒在镜子中可见,和场景中只有这一个物体一样。
使用帧缓冲可以为提到的物体的所有6个不同角度创建一个场景的纹理把它们每次渲染迭代储存为一个立方体贴图。之后我们可以使用这个动态生成的立方体贴图来创建真实的反射和折射表面这样就能包含所有其他物体了。这种方法叫做动态环境映射(Dynamic Environment Mapping),因为我们动态地创建了一个物体的以其四周为参考的立方体贴图,并把它用作环境贴图。
它看起效果很好但是有一个劣势使用环境贴图我们必须为每个物体渲染场景6次这需要非常大的开销。现代应用尝试尽量使用天空盒子凡可能预编译立方体贴图就创建少量动态环境贴图。动态环境映射是个非常棒的技术要想在不降低执行效率的情况下实现它就需要很多巧妙的技巧。
现在我们使用的都是静态图像组合来作为天空盒,看起来不错,但没有在场景中包括可移动的物体。我们一直都没有注意到这点,因为我们只使用了一个物体。如果我们有个镜子一样的物体,周围有多个物体,镜子中可见的只有天空盒,看起来就像它是场景中唯一一个物体一样。
通过使用帧缓冲我们能够为物体的6个不同角度创建出场景的纹理并在每个渲染迭代中将它们储存到一个立方体贴图中。之后我们就可以使用这个动态生成的立方体贴图来创建出更真实的包含其它物体的反射和折射表面了。这就叫做<def>动态环境映射</def>(Dynamic Environment Mapping),因为我们动态创建了物体周围的立方体贴图,并将其用作环境贴图。
虽然它看起来很棒但它有一个很大的缺点我们需要为使用环境贴图的物体渲染场景6次这是对程序是非常大的性能开销。现代的程序通常会尽可能使用天空盒并在可能的时候使用预编译的立方体贴图只要它们能产生一点动态环境贴图的效果。虽然动态环境贴图是一个很棒的技术但是要想在不降低性能的情况下让它工作还是需要非常多的技巧的。
## 练习
- 尝试在之前模型加载小节的模型加载器中引反射贴图你可以在[这里](../img/04/06/nanosuit_reflection.zip)找到升级过的纳米装模型,反射贴图也包含在里面。这其中有几点要注意:
- Assimp并不支持反射贴图,我们可以使用环境贴图的方式将反射贴图从`aiTextureType_AMBIENT`类型中来加载反射贴图的材质
-匆忙地使用反射贴图来作为镜面反射贴图,反射贴图并没有很好的映射在模型上:)。
- 由于加载模型已经占用了3个纹理单元,因此你要绑定天空盒到第4个纹理单元上这样才能在同一个着色器内从天空盒纹理中取样。
- 你可以在此获取解决方案的[源代码](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps-exercise1),这其中还包括升级过的[Model](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps-exercise1-model)和[Mesh](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps-exercise1-mesh)类,还有用来绘制反射贴图的[顶点着色器](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps-exercise1-vertex)和[片段着色器](http://learnopengl.com/code_viewer.php?code=advanced/cubemaps-exercise1-fragment)。
如果你一切都做对了,那你应该看到和下图类似的效果:
![](../img/04/06/cubemaps_reflection_map.png)
- 尝试在我们之前在[模型加载](../03 Model Loading/01 Assimp.md)小节中创建的模型加载器中引反射贴图你可以在[这里](../data/nanosuit_reflection.zip)找到升级后有反射贴图的纳米装模型。仍有几点要注意
- Assimp在大多数格式中都不太喜欢反射贴图,所以我们需要欺骗一下它,将反射贴图储存为**漫反射贴图**。你可以在加载材质的时候将反射贴图的纹理类型设置为<var>aiTextureType_AMBIENT</var>
-偷懒直接使用镜面光纹理图像来创建了反射贴图,所以反射贴图在模型的某些地方不能准确地映射:)。
- 由于模型加载器本身就已经在着色器中占用了3个纹理单元了,你需要将天空盒绑定到第4个纹理单元上因为我们要从同一个着色器中对天空盒样。
- 如果你都做对了,它会看起来像[这样](../img/04/06/cubemaps_reflection_map.png)。

View File

@@ -1,10 +1,10 @@
# 球
| 原文 | [Ball](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Ball) |
| ---- | ---------------------------------------- |
| 作者 | JoeydeVries |
| 翻译 | [aillieo](https://github.com/aillieo) |
| 校对 | 暂无 |
原文 | [Ball](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Ball)
---|---
作者 | JoeyDeVries
翻译 | [aillieo](https://github.com/aillieo)
校对 | 暂未校对
此时我们已经有了一个包含有很多砖块和玩家的一个挡板的关卡。与经典的Breakout内容相比还差一个球。游戏的目的是让球撞击所有的砖块直到所有的可销毁砖块都被销毁但同时也要满足条件球不能碰触屏幕的下边缘。

View File

@@ -1,25 +1,24 @@
# 碰撞检测
| 原文 | [Collision detection](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Collision-detection) |
| ---- | ---------------------------------------- |
| 作者 | JoeydeVries |
| 翻译 | [aillieo](https://github.com/aillieo) |
| 校对 | 暂无 |
原文 | [Collision detection](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Collision-detection)
---|---
作者 | JoeyDeVries
翻译 | [aillieo](https://github.com/aillieo)
校对 | 暂未校对
当试图判断两个物体之间是否有碰撞发生时,我们通常不使用物体本身的数据,因为这些物体常常会很复杂,这将导致碰撞检测变得很复杂。正因这一点,使用**重叠**在物体上的更简单的外形(通常有较简单明确的数学定义)来进行碰撞检测成为常用的方法。我们基于这些简单的外形来检测碰撞,这样代码会变得更简单且节省了很多性能。这些<def>碰撞外形</def>例如圆、球体、长方形和立方体等,与拥有上百个三角形的网格相比简单了很多。
虽然它们确实提供了更简单更高效的碰撞检测算法,但这些简单的碰撞外形拥有一个共同的缺点,这些外形通常无法完全包裹物体。产生的影响就是当检测到碰撞时,实际的物体并没有真正的碰撞。必须记住的是这些外形仅仅是真实外形的近似。
## AABB - AABB 碰撞
AABB代表的是<def>与坐标轴对齐的边界框(bounding box)</def>,边界框是指与场景基础坐标轴2D中的是x和y轴对齐的长方形的碰撞外形。与坐标轴对齐意味着这个长方形没有经过旋转并且它的边线和场景中基础坐标轴平行例如左右边线和y轴平行。这些边界框总是和场景的坐标轴平行这使得所有的计算都变得更简单。下边是我们用一个AABB包裹一个球对象物体
AABB代表的是<def>轴对齐碰撞箱</def>(Axis-aligned Bounding Box),碰撞箱是指与场景基础坐标轴2D中的是x和y轴对齐的长方形的碰撞外形。与坐标轴对齐意味着这个长方形没有经过旋转并且它的边线和场景中基础坐标轴平行例如左右边线和y轴平行。这些碰撞箱总是和场景的坐标轴平行这使得所有的计算都变得更简单。下边是我们用一个AABB包裹一个球对象物体
![](../../../img/06/Breakout/05/02/collisions_ball_aabb.png)
Breakout中几乎所有的物体都是基于长方形的物体因此很理所应当地使用与坐标系对齐的边界框来进行碰撞检测。这就是我们接下来要做的。
Breakout中几乎所有的物体都是基于长方形的物体因此很理所应当地使用轴对齐碰撞箱来进行碰撞检测。这就是我们接下来要做的。
有多种方式来定义与坐标轴对齐的边界框。其中一种定义AABB的方式是获取左上角点和右下角点的位置。我们定义的<fun>GameObject</fun>类已经包含了一个左上角点位置它的Position vector并且我们可以通过把左上角点的矢量加上它的尺寸<fun>Position</fun> + <fun>Size</fun>)很容易地计算出右下角点。每个<fun>GameObject</fun>都包含一个AABB我们可以高效地使用它们碰撞。
有多种方式来定义与坐标轴对齐的碰撞箱。其中一种定义AABB的方式是获取左上角点和右下角点的位置。我们定义的<fun>GameObject</fun>类已经包含了一个左上角点位置它的Position vector并且我们可以通过把左上角点的矢量加上它的尺寸<fun>Position</fun> + <fun>Size</fun>)很容易地计算出右下角点。每个<fun>GameObject</fun>都包含一个AABB我们可以高效地使用它们碰撞。
那么我们如何判断碰撞呢当两个碰撞外形进入对方的区域时就会发生碰撞例如定义了第一个物体的碰撞外形以某种形式进入了第二个物体的碰撞外形。对于AABB来说很容易判断因为它们是与坐标轴对齐的对于每个轴我们要检测两个物体的边界在此轴向是否有重叠。因此我们只是简单地检查两个物体的水平边界是否重合以及垂直边界是否重合。如果水平边界**和**垂直边界都有重叠那么我们就检测到一次碰撞。
@@ -27,7 +26,6 @@ Breakout中几乎所有的物体都是基于长方形的物体因此很理所
将这一概念转化为代码也是很直白的。我们对两个轴都检测是否重叠,如果都重叠就返回碰撞:
```c++
GLboolean CheckCollision(GameObject &one, GameObject &two) // AABB - AABB collision
{
@@ -46,7 +44,6 @@ GLboolean CheckCollision(GameObject &one, GameObject &two) // AABB - AABB collis
为更好地组织碰撞的代码,我们在<fun>Game</fun>类中加入一个额外的函数:
```c++
class Game
{
@@ -77,7 +74,6 @@ void Game::DoCollisions()
接下来我们需要更新<fun>Game</fun>的<fun>Update</fun>函数:
```c++
void Game::Update(GLfloat dt)
{
@@ -104,40 +100,31 @@ void Game::Update(GLfloat dt)
![](../../../img/06/Breakout/05/02/collisions_circle.png)
这意味着我们不得不修改检测算法因为当前的算法只适用于两个AABB的碰撞。检测圆和AABB碰撞的算法会稍稍复杂关键点如下我们会找到AABB上距离圆最近的一个点如果圆到这一点的距离小于它的半径那么就产生了碰撞。
难点在于获取AABB上的最近点\(\bar{P}\)。下图展示了对于任意的AABB和圆我们如何计算该点
![](../../../img/06/Breakout/05/02/collisions_aabb_circle.png)
首先我们要获取球心\(\bar{C}\)与AABB中心\(\bar{B}\)的矢量差\(\bar{D}\)。接下来用AABB的半边长(half-extents)\(w\)和\(\bar{h}\)来<def>限制(clamp)</def>矢量\(\bar{D}\)。长方形的半边长是指长方形的中心到它的边的距离简单的说就是它的尺寸除以2。这一过程返回的是一个总是位于AABB的边上的位置矢量除非圆心在AABB内部
!!! Important
限制运算把一个值**限制**在给定范围内,并返回限制后的值。通常可以表示为:
限制运算把一个值**限制**在给定范围内,并返回限制后的值。通常可以表示为:
```
float clamp(float value, float min, float max) {
return std::max(min, std::min(max, value));
}
```
例如,值<var>42.0f</var>被限制到<var>6.0f</var>和<var>3.0f</var>之间会得到<var>6.0f</var>;而<var>4.20f</var>会被限制为<var>4.20f</var>。
限制一个2D的矢量表示将其<var>x</var>和<var>y</var>分量都限制在给定的范围内。
例如,值<var>42.0f</var>被限制到<var>6.0f</var>和<var>3.0f</var>之间会得到<var>6.0f</var>;而<var>4.20f</var>会被限制为<var>4.20f</var>。
限制一个2D的矢量表示将其<var>x</var>和<var>y</var>分量都限制在给定的范围内。
这个限制后矢量\(\bar{P}\)就是AABB上距离圆最近的点。接下来我们需要做的就是计算一个新的差矢量\(\bar{D'}\),它是圆心\(\bar{C}\)和\(\bar{P}\)的差矢量。
![](../../../img/06/Breakout/05/02/collisions_aabb_circle_radius_compare.png)
既然我们已经有了矢量\(\bar{D'}\),我们就可以比较它的长度和圆的半径以判断是否发生了碰撞。
这一过程通过下边的代码来表示:
```c++
@@ -154,7 +141,7 @@ GLboolean CheckCollision(BallObject &one, GameObject &two) // AABB - Circle coll
// 获取两个中心的差矢量
glm::vec2 difference = center - aabb_center;
glm::vec2 clamped = glm::clamp(difference, -aabb_half_extents, aabb_half_extents);
// AABB_center加上clamped这样就得到了边界框上距离圆最近的点closest
// AABB_center加上clamped这样就得到了碰撞箱上距离圆最近的点closest
glm::vec2 closest = aabb_center + clamped;
// 获得圆心center和最近点closest的矢量并判断是否 length <= radius
difference = closest - center;
@@ -162,7 +149,6 @@ GLboolean CheckCollision(BallObject &one, GameObject &two) // AABB - Circle coll
}
```
我们创建了<fun>CheckCollision</fun>的一个重载函数用于专门处理一个<fun>BallObject</fun>和一个<fun>GameObject</fun>的情况。因为我们并没有在对象中保存碰撞外形的信息因此我们必须为其计算首先计算球心然后是AABB的半边长及中心。
使用这些碰撞外形的参数,我们计算出<var>difference</var>\(\bar{D}\)然后得到限制后的值<var>clamped</var>并与AABB中心相加得到<var>closest</var>\(\bar{P}\)。然后计算出<var>center</var><var>closest</var>的矢量差\(\bar{D'}\)并返回两个外形是否碰撞。
@@ -171,5 +157,4 @@ GLboolean CheckCollision(BallObject &one, GameObject &two) // AABB - Circle coll
<video src="../../../../img/06/Breakout/05/02/collisions_circle.mp4" controls="controls"></video>
看起来生效了,但仍缺少一些东西。我们准确地检测了所有碰撞,但碰撞并没有对球产生任何反作用。我们需要在碰撞时产生一些**反作用**,例如当碰撞发生时,更新球的位置和/或速度。这将是[下一个](./03 Collision resolution.md)教程的主题。

View File

@@ -1,48 +1,38 @@
# 碰撞处理
| 原文 | [Collision resolution](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Collision-resolution) |
| ---- | ---------------------------------------- |
| 作者 | JoeydeVries |
| 翻译 | [aillieo](https://github.com/aillieo) |
| 校对 | 暂无 |
原文 | [Collision resolution](https://learnopengl.com/#!In-Practice/2D-Game/Collisions/Collision-resolution)
---|---
作者 | JoeyDeVries
翻译 | [aillieo](https://github.com/aillieo)
校对 | 暂未校对
上个教程的最后,我们得到了一种有效的碰撞检测方案。但是球对检测到的碰撞不会有反作用;它仅仅是径直穿过所有的砖块。我们希望球会从撞击到的砖块**反弹**。此教程将讨论如何使用AABB-圆碰撞方案实现这项称为<def>碰撞处理 (collision resolution)</def>的功能。
上个教程的最后,我们得到了一种有效的碰撞检测方案。但是球对检测到的碰撞不会有反作用;它仅仅是径直穿过所有的砖块。我们希望球会从撞击到的砖块**反弹**。此教程将讨论如何使用AABB-圆碰撞方案实现这项称为<def>碰撞处理</def>(Collision Resolution)的功能。
当碰撞发生时,我们希望出现两个现象:重新定位球,以免它进入另一个物体,其次是改变球的速度方向,使它看起来像是物体的反弹。
### 碰撞重定位
为了把球对象定位到碰撞的AABB的外部我们必须明确球侵入碰撞框的距离。为此我们要回顾上一节教程中的示意图
![](../../../img/06/Breakout/05/03/collisions_aabb_circle_resolution.png)
此时球少量进入了AABB所以检测到了碰撞。我们现在希望将球从移出AABB的外形使其仅仅碰触到AABB像是没有碰撞一样。为了确定需要将球从AABB中移出多少距离我们需要找回矢量\(\bar{R}\)它代表的是侵入AABB的程度。为得到\(\bar{R}\)我们用球的半径减去\(\bar{V}\)。矢量\(\bar{V}\)是最近点\(\bar{P}\)和球心\(\bar{C}\)的差矢量。
有了\(\bar{R}\)之后我们将球的位置偏移\(\bar{R}\)就将球直接放置在与AABB紧邻的位置此时球已经被重定位到合适的位置。
### 碰撞方向
下一步我们需要确定碰撞之后如何更新球的速度。对于Breakout我们使用以下规则来改变球的速度
1. 如果球撞击AABB的右侧或左侧它的水平速度<var>x</var>)将会反转。
2. 如果球撞击AABB的上侧或下侧它的垂直速度<var>y</var>)将会反转。
但是如何判断球撞击AABB的方向呢解决这一问题有很多种方法其中之一是对于每个砖块使用4个AABB而不是1个AABB并把它们放置到砖块的每个边上。使用这种方法我们可以确定被碰撞的是哪个AABB和哪个边。但是有一种使用点乘(dot product)的更简单的方法。
您或许还记得[变换](../../../01 Getting started/07 Transformations.md)教程中点乘可以得到两个正交化的矢量的夹角。如果我们定义指向北、南、西和东的四个矢量,然后计算它们和给定矢量的夹角会怎么样?由这四个方向矢量和给定的矢量点乘积的结果中的最高值(点乘积的最大值为`1.0f`,代表`0`度角)即是矢量的方向。
这一过程如下代码所示:
```c++
Direction VectorDirection(glm::vec2 target)
{
@@ -67,7 +57,6 @@ Direction VectorDirection(glm::vec2 target)
}
```
此函数比较了<var>target</var>矢量和<var>compass</var>数组中各方向矢量。<var>compass</var>数组中与<var>target</var>角度最接近的矢量,即是返回给函数调用者的<var>Direction</var>。这里的<var>Direction</var>是一个<fun>Game</fun>类的头文件中定义的枚举类型:
```c++
@@ -79,16 +68,12 @@ enum Direction {
};
```
既然我们已经知道了如何获得\(\bar{R}\)以及如何判断球撞击AABB的方向我们开始编写碰撞处理的代码。
### AABB - 圆碰撞检测
为了计算碰撞处理所需的数值我们要从碰撞的函数中获取更多的信息而不只只是一个<var>true</var>或<var>false</var>,因此我们要返回一个包含更多信息的<def>tuple</def>,这些信息即是碰撞发生时的方向及差矢量(\(\bar{R}\))。你可以在头文件<var><tuple></var>中找到<var>tuple</var>。
为了更好组织代码我们把碰撞相关的数据使用typedef定义为<fun>Collision</fun>
```c++
@@ -110,7 +95,6 @@ Collision CheckCollision(BallObject &one, GameObject &two) // AABB - AABB 碰撞
<fun>Game</fun>类的<fun>DoCollision</fun>函数现在不仅仅只检测是否出现了碰撞,而且在碰撞发生时会有适当的动作。此函数现在会计算碰撞侵入的程度(如本教程一开始计时的示意图中所示)并且基于碰撞方向使球的位置矢量与其相加或相减。
```c++
void Game::DoCollisions()
{
@@ -155,18 +139,14 @@ void Game::DoCollisions()
不要被函数的复杂度给吓到因为它仅仅是我们目前为止的概念的直接转化。首先我们会检测碰撞如果发生了碰撞且砖块不是实心的那么就销毁砖块。然后我们从tuple中获取到了碰撞的方向<var>dir</var>以及表示\(\bar{V}\)的差矢量<var>diff_vector</var>,最终完成碰撞处理。
我们首先检查碰撞方向是水平还是垂直,并据此反转速度。如果是水平方向,我们从<var>diff_vector</var>的x分量计算侵入量RR并根据碰撞方向用球的位置矢量加上或减去它。垂直方向的碰撞也是如此但是我们要操作各矢量的y分量。
现在运行你的应用程序,应该会向你展示一套奏效的碰撞方案,但可能会很难真正看到它的效果,因为一旦球碰撞到了一个砖块就会弹向底部并永远丢失。我们可以通过处理玩家挡板的碰撞来修复这一问题。
## 玩家 - 球碰撞
球和玩家之间的碰撞与我们之前讨论的碰撞稍有不同,因为这里应当基于撞击挡板的点与(挡板)中心的距离来改变球的水平速度。撞击点距离挡板的中心点越远,则水平方向的速度就会越大。
```c++
void Game::DoCollisions()
{
@@ -186,28 +166,22 @@ void Game::DoCollisions()
Ball->Velocity = glm::normalize(Ball->Velocity) * glm::length(oldVelocity);
}
}
```
在我们完成了球和各砖块的碰撞检测之后我们来检测球和玩家挡板是否发生碰撞。如果有碰撞并且球不是被固定在挡板上我们要计算球的中心与挡板中心的距离和挡板的半边长的百分比。之后球的水平速度会依据它撞击挡板的点到挡板中心的距离来更新。除了更新水平速度之外我们还需要反转它的y方向速度。
注意旧的速度被存储为<var>oldVelocity</var>。之所以要存储旧的速度是因为我们只更新球的速度矢量中水平方向的速度并保持它的y速度不变。这将意味着矢量的长度会持续变化其产生的影响是如果球撞击到挡板的边缘则会比撞击到挡板中心有更大(也因此更强)的速度矢量。为此新的速度矢量会正交化然后乘以旧速度矢量的长度。这样一来,球的力量和速度将总是一一致的,无论它撞击到挡板的哪个地方。
### 粘板
无论你有没有注意到,但当运行代码时,球和玩家挡板的碰撞处理仍旧有一个大问题。以下的视频清楚地展示了将会出现的现象:
<video src="../../../../img/06/Breakout/05/03/collisions_sticky_paddle.mp4" controls="controls"></video>
这种问题称为<def>粘板问题(sticky paddle issue)</def>出现的原因是玩家挡板以较高的速度移向球导致球的中心进入玩家挡板。由于我们没有考虑球的中心在AABB内部的情况游戏会持续试图对所有的碰撞做出响应当球最终脱离时已经对`y`向速度翻转了多次,以至于无法确定球在脱离后是向上还是向下运动。
这种问题称为<def>粘板问题</def>(Sticky Paddle Issue)出现的原因是玩家挡板以较高的速度移向球导致球的中心进入玩家挡板。由于我们没有考虑球的中心在AABB内部的情况游戏会持续试图对所有的碰撞做出响应当球最终脱离时已经对`y`向速度翻转了多次,以至于无法确定球在脱离后是向上还是向下运动。
我们可以引入一个小的特殊处理来很容易地修复这种行为,这个处理之所以成为可能是基于我们可以假设碰撞总是发生在挡板顶部的事实。我们总是简单地返回正的<var>y</var>速度而不是反转<var>y</var>速度,这样当它被卡住时也可以立即脱离。
```c++
//Ball->Velocity.y = -Ball->Velocity.y;
Ball->Velocity.y = -1 * abs(Ball->Velocity.y);
@@ -215,12 +189,10 @@ Ball->Velocity.y = -1 * abs(Ball->Velocity.y);
如果你足够仔细就会觉得这一影响仍然是可以被注意到的,但是我个人将此方法当作一种可接受的折衷处理。
### 底部边界
与经典的Breakout内容相比唯一缺少的就是失败条件了失败会重置关卡和玩家。在<fun>Game</fun>类的<fun>Update</fun>函数中,我们要检查球是否接触到了底部边界,如果接触到就重置游戏。
```c++
void Game::Update(GLfloat dt)
{
@@ -233,7 +205,6 @@ void Game::Update(GLfloat dt)
}
```
<fun>ResetLevel</fun><fun>ResetPlayer</fun>函数直接重新加载关卡并重置对象的各变量值为原始的值。现在游戏看起来应该是这样的:
<video src="../../../../img/06/Breakout/05/03/collisions_complete.mp4" controls="controls"></video>
@@ -244,23 +215,14 @@ void Game::Update(GLfloat dt)
在视频游戏的发展过程中碰撞检测是一个困难的话题甚至可能是最大的挑战。大多数的碰撞检测和处理方案是和物理引擎合并在一起的正如多数现代的游戏中看到的那样。我们在Breakout游戏中使用的碰撞方案是一个非常简单的方案并且是专门给这类游戏所专用的。
需要强调的是这类碰撞检测和处理方式是不完美的。它只能计算每帧内可能发生的碰撞并且只能计算在该时间步时物体所在的各位置;这意味着如果一个物体拥有一个很大的速度以致于在一帧内穿过了另一个物体,它将看起来像是从来没有与另一个物体碰撞过。因此如果出现掉帧或出现了足够高的速度,这一碰撞检测方案将无法应对。
(我们使用的碰撞方案)仍然会出现这几个问题:
- 如果球运动得足够快,它可能在一帧内完整地穿过一个物体,而不会检测到碰撞。
- 如果球在一帧内同时撞击了一个以上的物体,它将会检测到两次碰撞并两次反转速度;这样不改变它的原始速度。
- 撞击到砖块的角时会在错误的方向反转速度,这是因为它在一帧内穿过的距离会引发<fun>VectorDirection</fun>返回水平方向还是垂直方向的差别。
但是,本教程目的在于教会读者们图形学和游戏开发的基础知识。因此,这里的碰撞方案可以服务于此目的;它更容易理解且在正常的场景中可以较好地运作。需要记住的是存在有更好的(更复杂)碰撞方案,在几乎所有的场景中都可以很好地运作(包括可移动的物体)如<def>分离轴定理</def>(Separating Axis Theorem)。
但是,本教程目的在于教会读者们图形学和游戏开发的基础知识。因此,这里的碰撞方案可以服务于此目的;它更容易理解且在正常的场景中可以较好地运作。需要记住的是存在有更好的(更复杂)碰撞方案,在几乎所有的场景中都可以很好地运作(包括可移动的物体)如<def>分离轴定理(separating axis theorem)</def>
值得庆幸的是,有大量实用并且常常很高效的物理引擎(使用时间步无关的碰撞方案)可供您在游戏中使用。如果您希望在这一系统中有更深入的探索或需要更高级的物理系统又不理解其中的数学机理,[Box2D](http://box2d.org/about/)是一个实现了物理系统和碰撞检测的可以用在您的应用程序中的完美的2D物理库。
值得庆幸的是,有大量实用并且常常很高效的物理引擎(使用时间步无关的碰撞方案)可供您在游戏中使用。如果您希望在这一系统中有更深入的探索或需要更高级的物理系统又不理解其中的数学机理,[Box2D](http://box2d.org/about/)是一个实现了物理系统和碰撞检测的可以用在您的应用程序中的完美的2D物理库

BIN
docs/data/nanosuit.rar Normal file

Binary file not shown.

BIN
docs/img/02/04/emissive.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -19,7 +19,6 @@
- Primitive Type基元类型
- Option选项
- Modern现代
## 01-02
- Abstract抽象
@@ -110,11 +109,12 @@
- Wrapping环绕方式
- Clamp约束
- Nearest Neighbor Filtering临近过滤
- Texture PixelTexel纹理像素
- Texture PixelTexel纹理像素,纹素
- (Bi)linear Filtering线性过滤
- Magnify放大
- Minify缩小
- Mipmap多级渐远纹理
- Level (Mipmap):级别
- Channel通道
- Sampler采样器
- Texture Unit纹理单元
@@ -213,6 +213,159 @@
- Light Property光照属性
- Scatter散射
## 02-04
- Map贴图
- Diffuse Map漫反射贴图
- Opaque Type不透明类型
- Specular Map镜面光贴图
- Normal Map法线贴图
- Bump Map凹凸贴图
- Reflection Map反射贴图
- Emission Map放射光贴图
- Emission Value发光值
## 02-05
- Cast投射
- Light Caster投光物
- Directional Light定向光
- Point Light点光源
- Spotlight聚光
- Positional Light Source位置光源
- Fixed-functionality (OpenGL):固定函数式
- Attenuation衰减
- Cutoff Angle切光角
- Flashlight手电筒
## 03-01
- Model模型
- Import导入
- 3D Modeling Tool3D建模工具
- uv-mappingUV映射
- AssimpAssimp
- Asset资产
- Node节点
- Mesh网格
- Face
## 03-02
- Sequential连续
- Byte Offset字节偏移量
- Translate转化
- Concatenate拼接
## 03-03
- vector模板类vector
- Post-processing后期处理
- Recursive Fuction递归函数
- Exit Condition退出条件
- Local Path本地路径
- Relative Path相对路径
- Absolute Path绝对路径
- CrytekCrytek
- Crysis孤岛危机
- Nanosuit纳米装
## 04-01
- Depth Buffer深度缓冲
- Depth Value深度值
- z-bufferz缓冲
- Stencil Testing模板测试
- Early Depth Testing提前深度测试
- Discard丢弃
- Read-only只读的
- Enable启用
- Disable禁用
- Depth Mask深度掩码
- Depth Function深度函数
- Near平截头体近平面
- Far平截头体远平面
- Linear Depth Buffer线性深度缓冲
- Z-fighting深度冲突
- Coplanar共面
- Anti z-fighting抗深度冲突
## 04-02
- Stencil Buffer模板缓冲
- Stencil Value模板值
- Bitmask位掩码
- Stencil Mask模板掩码
- Stencil Test Function模板测试函数
- Reference Value参考值
- Object Outlining物体轮廓
- Write写入
- Gaussian Blur高斯模糊
- Filter后期处理滤镜
- Shadow Volume阴影体积
## 04-03
- Blending混合
- Transparency透明
- Solid Color纯色
- Alpha Value透明度Alpha值
- Source Color Vector源颜色向量
- Destination Color Vector目标颜色向量
- Source Factor Value源因子值
- Destination Factor Value目标因子值
- Blend Equation混合方程
- Reverse Iterator反向迭代器
- Order Independent Transparency次序无关透明度
## 04-04
- Side
- Face Culling面剔除
- Front Facing面向
- Back Facing背向
- Front Face正向面
- Back Face背向面
- Winding Order环绕顺序
- Front-facing Triangle正向三角形
- Back-facing Triangle背向三角形
- Cull剔除
## 04-05
- Framebuffer帧缓冲
- Framebuffer Object帧缓冲对象
- Complete缓冲完整
- Off-screen Rendering离屏渲染
- Renderbuffer Object渲染缓冲对象
- Attach附加
- Format格式
- Internalformat内部格式
- General Purpose Data Buffer通用数据缓冲
- Pass处理阶段
- Inversion反相
- Grayscale灰度
- Weighted加权
- Kernel
- Convolution Matrix卷积矩阵
- Weight权重
- Sharpen锐化
- Blur模糊
- Edge-detection边缘检测
## 04-06
- Cube Map立方体贴图
- Skybox天空盒
- Environment Mapping环境映射
- Reflection反射
- Refraction折射
- Reflection Map反射贴图
- Snell's Law斯涅尔定律
- Refractive Index折射率
- Single-side Refraction单面折射
- Dynamic Environment Mapping动态环境映射
## 06-01
- Debugging调试

View File

@@ -92,7 +92,7 @@ markdown_extensions:
- mdx_math
extra_javascript:
- 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML'
- 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS_HTML'
extra_css:
- css/style.css

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -16,11 +16,13 @@
校对 | [校对]
```
- 在Markdown文件中如需插入图片或者代码请与正文空一行以方便阅读及解析,例如:
- 在Markdown文件中如需插入图片或者代码请与正文空**一行**以方便阅读及解析,每一个段落之间也应该空**一行**例如:
```markdown
[text]
[text]
[img]
[text]
@@ -29,7 +31,8 @@
## 标点符号
- 一般情况下请使用中文的标点符号
- 书名号请使用`「``」`
- 号请使用`「``」`
- 书名号请使用`『``』`
- 当标注翻译的原文时,括号请使用英文的括号(英文与周围空太多会有点难看),但其它情况下请用中文的括号,如
OpenGL规范严格规定了每个函数该如何执行以及它们的输出值。至于内部具体每个函数是如何实现(Implement)的将由OpenGL库的开发者自行决定这里开发者是指编写OpenGL库的人。因为OpenGL规范并没有规定实现的细节具体的OpenGL库允许使用不同的实现只要其功能和结果与规范相匹配亦即作为用户不会感受到功能上的差异
@@ -86,12 +89,12 @@
## 专有名词
- 专有名词需要在第一次出现的之后用括号标注原文
- 原文单词按照标题大写规则大写首字母
- 原文单词按照标题大写规则**大写首字母**
- 翻译
- 首先,请与本工程中大部分的翻译保持一致
- 如果工程中找不到已有的翻译,请参考本文档最下面的词汇表寻找翻译
- 如果还是找不到,可以自己创造一个翻译,或者直接写英文
- Additional resources译作附加资源”,“Exercises译作练习”,“Solution译作参考解答
- Additional resources译作附加资源」,「Exercises译作练习」,「Solution译作参考解答
## 特殊元素
@@ -134,7 +137,7 @@ $$
[text]
```
- 原文中的红色方框请用`!!! attention`标注,绿色方框请用`!!! important`标注,注意文本前有Tab格式如下
- 原文中的红色方框请用`!!! attention`标注,绿色方框请用`!!! important`标注,注意文本前有**一个**Tab格式如下
红色方框:
@@ -152,12 +155,26 @@ $$
[text]
```
- 如果方框中有代码块,请将代码复制进来之后前面加**两个**Tab记得将代码块和正文中间空一行空行不用加任何空格和Tab
```
!!! important
[text]
[code]
[text]
```
## 特殊词汇标记
- 定义:`<def>Definition</def>`
- 函数及类名:`<fun>Program logic</fun>`
- 变量:`<var>Variables</var>`
- 标记的时候记得不要把中文翻译标记进去了
- 下划线:`<u>Underline</u>`
- **定义**在标记的时候仅仅标记中文翻译,**不要**标记括号中的原文,原则是只将最少的文字标记出来
- 函数和变量在标记的时候记得不要把中文翻译标记进去了
- 原文中的斜体如果译成中文一律用加粗表示(中文并不存在斜体),如果仍留为英文,请在单词两侧都添加空格与中文分开再改为斜体,粗体也用粗体表示,注意不要自己违反原文添加粗体
## 专业词汇对照表

View File

@@ -15,6 +15,7 @@
"""
from PIL import Image
from os.path import splitext
file_name = input("Filename: ")
r, g, b, a = map(int, input("Color(R G B A): ").split())
@@ -32,4 +33,4 @@ for x in range(0, img.size[0]):
if pixel[x, y] == (r, g, b, 255):
img.putpixel((x, y), (0, 0, 0, 0))
img.save('noBG.png')
img.save(splitext(file_name)[0] + '_noBG.png')