图形学系列专栏
- 序章 初探图形编程
- 第1章 你的第一个三角形
- 第2章 变换
- 顶点变换
- 视图矩阵 & 帧速率
- 第3章 纹理映射
- 第4章 透明度和深度
- 第5章 裁剪区域和模板缓冲区
- 第6章 场景图
- 第7章 场景管理
- 第8章 索引缓冲区
- 第9章 骨骼动画
- 第10章 后处理
- 第11章 实时光照(一)
- 第12章 实时光照(二)
- 第13章 立方体贴图
- 第14章 阴影贴图
- 第15章 延迟渲染
- 第16章 高级缓冲区
文章目录
- 图形学系列专栏
- 前言
- 深度缓冲区
- 透明度
- 透明度和深度问题
- 示例程序
- Mesh头文件
- Mesh类文件
- Renderer头文件
- Renderer类文件
- 主文件
- 运行程序
- 总结
- 课后作业
前言
在本教程中,你将了解 OpenGL 是如何确定场景中哪些物体在其他物体前面的——这可不像看起来那么容易!还会介绍 Alpha 混合,它能让我们 OpenGL 场景中的物体具有不同程度的透明度。
随着你尝试使用 OpenGL 创建的场景变得更加复杂,你可能会希望某些物体出现在其他物体的前面。你可能还会希望某些物体是部分透明的,比如窗户或者水。要做到这些,你需要了解如何处理屏幕上一个像素的深度,以及如何处理它的 Alpha 值。有时候,这些值之间的相互作用会导致问题,所以你还需要学习一些技巧,让场景按照你的期望表现。
深度缓冲区
在绘制一个 3D 场景时,物体可能会在屏幕上重叠。举个例子,在第一人称射击游戏中,一个敌人藏在你面前的一个箱子后面。如果先画敌人再画箱子,一切看起来都很好——箱子会遮住敌人。但如果先画箱子呢?你正在绘制的后置缓冲区只包含颜色值,所以没有办法确定箱子是在已有的像素前面还是后面,所以敌人就会被画在上面——现在我们的敌人看起来像是藏在箱子前面了!
现在,我们可以通过简单地按照场景中的物体与相机的距离来排序这些物体来解决这个问题,这就是所谓的画家算法,在很多情况下这可能会很有效。但是考虑一下这个情况:如果物体 A 与物体 B 重叠并且被物体 C 覆盖,但是物体 B 又与物体 C 的一部分重叠。那么正确的绘制这些物体的顺序是什么呢?无论你以什么顺序绘制这些物体,其中一个物体的一部分都会被错误地画在另一个物体的前面。
对于这些物体来说没有正确的绘制顺序,它们都部分地相互重叠。
这可以通过使用深度缓冲区来解决。与颜色缓冲区不同,深度缓冲区存储的是从后置缓冲区中每个像素的归一化设备坐标(NDC)空间的 z 轴值派生出来的值——这就是为什么深度缓冲区有时也被称为 z 缓冲区。由于 NDC 空间的中心到边缘的范围是 1.0,深度缓冲区存储的值在 0.0 到 1.0 之间,其中 0.0 表示最接近视点,1.0 表示在投影矩阵的远平面 z 距离内尽可能远的位置。
有了深度缓冲区,OpenGL 在尝试绘制到后置缓冲区时可以进行深度测试——通过计算当前绘制的屏幕片段的眼空间 z 坐标,可以确定该片段是在后置缓冲区中已有的任何像素数据的前面还是后面。如果这个值小于深度缓冲区中当前的值,那么这个片段一定在之前绘制到后置缓冲区的物体的前面,所以新的片段通过深度测试,并被绘制到后置缓冲区和深度缓冲区中。如果它大于现有的深度缓冲区值,它就没有通过深度测试,会被丢弃。现代图形硬件有时可以在运行活动片段着色器之前进行深度测试,所以深度测试也可以提高性能。深度测试的方向通常是可配置的——例如,你可能只希望更远的片段通过深度测试。
存储在深度缓冲区中的值是从正在渲染的顶点的 z 轴值插值得到的。还需要注意的是,这是在经过齐次坐标的 w 分量进行透视除法后计算得到的 z 值。这样的一个副作用是写入深度缓冲区的值不是线性的。由于这种非线性,深度缓冲区中可用的精度会根据距离而变化。一个片段离得越远,存储其深度的精度就越低。通常也只有有限的位数可用于存储深度缓冲区的值,通常是 16 位或 24 位——现代图形硬件通常支持高达 32 位。
这两个因素可能会导致一个被称为“z 冲突”的问题。由于使用的深度位数有限导致精度有限,偶尔会发生两个距离非常近的表面最终得到“相同”的舍入后的深度值。如果相机或表面稍微移动,舍入值会稍微变化,这两个表面就会闪烁,因为它们的深度值不断被舍入为比另一个稍微低一点的值。
透明度
在前面的教程中,你了解了有四个分量的颜色——红色、绿色、蓝色和阿尔法(alpha)。这个 alpha 分量表示颜色的不透明度——从 0(完全透明)到 255(完全不透明)。就像你在教程 1 中看到的颜色插值一样,alpha 也是在每个片段的基础上进行插值的。当有颜色的几何图形被绘制到后置缓冲区时,当前处理的片段的 alpha 值被用来确定最终输出的片段颜色是什么。如果它是 255(或者使用浮点值时是 1.0),那么输出的片段颜色很容易确定——就是片段着色器输出的颜色,因为颜色是完全不透明的。如果它小于 255(但大于 0!),那么结果将与当前片段颜色进行混合,以确定将被写入后置缓冲区的最终颜色。这个混合过程是自动的(你不必在片段着色器中手动执行),并且还包括一些可控制的参数。用于确定最终混合颜色的公式如下:
源颜色和目标颜色分别是当前片段的输出以及该片段位置的当前缓冲区值。源因子和目标因子是可控制的值——它们可以是 1、0、源或目标的 alpha 值,或者是一些依赖于 API 的变化值中的一个。
透明度和深度问题
不幸的是,尽管深度缓冲区允许不透明物体以任意顺序正确绘制,但它在处理透明物体时存在问题。第一个问题是,即使 alpha 混合的片段的 alpha 值为 0,也会导致对深度缓冲区的写入。所以,即使一个完全 alpha 值为 0 的物体被渲染到屏幕上,它也会影响深度缓冲区,可能会产生不良影响。可以暂时禁用对深度缓冲区的写入来避免这个问题,但这样一来,一个部分 alpha 混合的物体可能会被更远的物体覆盖,因为缺乏深度信息。
混合的计算方式也会引发问题。让我们回到我们隐藏的敌人的例子。想象一下,在我们的第一人称射击游戏中,敌人不是藏在一个箱子后面,而是决定藏在一个彩色玻璃窗后面,在渲染方面,我们可以想象它只是一个带有彩色玻璃纹理的四边形,并且有 alpha 值使玻璃透明。如果先画窗户再画敌人,一切都很好——alpha 混合和深度缓冲区都按预期工作,我们的敌人在彩色玻璃窗后面可见。如果我们以相反的顺序绘制会发生什么呢?如果启用了深度缓冲区,我们的敌人就会消失——敌人的每个片段的深度都大于彩色玻璃窗,所以会被丢弃。如果禁用深度缓冲区,敌人会被画在窗户前面,彩色玻璃的效果就被破坏了——彩色玻璃的颜色会在敌人被绘制之前与场景混合,导致颜色不正确。
在图形渲染中使用透明度时,渲染顺序很重要!这个问题的一个常见解决方案是跟踪哪些物体是透明的,并使用两个渲染循环。第一个循环绘制所有不透明的物体,然后第二个循环绘制透明的物体——从后往前排序,以便混合正确进行。这并不完美,因为相交的透明物体不会有正确混合的颜色,但唯一的其他解决方案是按距离对每个透明三角形进行排序——即使这样,任何相交的三角形可能都需要进一步切割,以获得完全无错误的最终渲染。在后面的教程中,你将看到一个如何对可能透明的物体进行排序的例子。
示例程序
为了演示本教程中讨论的内容,我们将编写一个简短的示例程序,该程序同时使用深度和透明度。它会很简单,但应该能让你在处理透明物体以及使用深度缓冲区时可能出现的问题方面获得一些实际经验。
这次没有新的着色器,我们将使用在教程 2 中创建的那些——深度测试和 alpha 混合在着色器中都不需要任何额外的工作!我们还将编写一个新的Mesh类函数,一个用于创建四边形的函数——我们在后面的教程中还会再次使用它!
Mesh头文件
我们需要一个新的公共函数,一个静态函数,它将返回一个完全成形的四边形网格。
...
public:
static Mesh* GenerateQuad();
...
Mesh类文件
这个函数本身与教程 1 中的“GenerateTriangle”类似。注意,这次我们使用三角形带(triangle strips)
作为图元类型。
Mesh* Mesh::GenerateQuad() {
Mesh* m = new Mesh();
m->numVertices = 4;
m->type = GL_TRIANGLE_STRIP;
m->vertices = new Vector3[m->numVertices];
m->textureCoords = new Vector2[m->numVertices];
m->colours = new Vector4[m->numVertices];
m->vertices[0] = Vector3(-1.0f, 1.0f, 0.0f);
m->vertices[1] = Vector3(-1.0f, -1.0f, 0.0f);
m->vertices[2] = Vector3(1.0f, 1.0f, 0.0f);
m->vertices[3] = Vector3(1.0f, -1.0f, 0.0f);
m->textureCoords[0] = Vector2(0.0f, 1.0f);
m->textureCoords[1] = Vector2(0.0f, 0.0f);
m->textureCoords[2] = Vector2(1.0f, 1.0f);
m->textureCoords[3] = Vector2(1.0f, 0.0f);
for(int i = 0; i < 4; ++i) {
m->colours[i] = Vector4(1.0f, 1.0f,1.0f,1.0f);
}
m->BufferData();
return m;
}
Renderer头文件
我们的Renderer类头文件非常普通——在本教程中的新内容是四个新的公共函数,用于控制深度和透明度状态。我们有两个纹理和网格,以及两个向量来保存两个对象的世界空间位置。在那之后,我们有我们的矩阵,以及一些变量来存储我们的深度和透明度的状态。
#pragma once
#include "../nclgl//OGLRenderer.h"
class Renderer : public OGLRenderer
{
public:
Renderer(Window& parent);
~Renderer(void);
void RenderScene() override;
void ToggleObject();
void ToggleDepth();
void ToggleAlphaBlend();
void ToggleBlendMode();
void MoveObject(float by);
protected:
GLuint textures[2];
Mesh* meshes[2];
Shader* shader;
Vector3 positions[2];
bool modifyObject;
bool usingDepth;
bool usingAlpha;
int blendMode;
};
Renderer类文件
本教程中我们的Renderer类的构造函数非常直接。我们实例化两个网格(一个三角形和一个四边形)、两个纹理和两个位置。然后像往常一样创建我们的着色器可执行文件——我们再次使用在纹理教程中创建的着色器,所以着色器的初始化与教程 2 完全相同!我们还必须为各种切换变量设置默认值,并创建一个投影矩阵,在这种情况下,是与教程 2 中相同的透视矩阵。
#include "Renderer.h"
Renderer::Renderer(Window& parent) :OGLRenderer(parent) {
meshes[0] = Mesh::GenerateQuad();
meshes[1] = Mesh::GenerateTriangle();
textures[0] = SOIL_load_OGL_texture(TEXTUREDIR"brick.tga", SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, 0);
textures[1] = SOIL_load_OGL_texture(TEXTUREDIR"stainedglass.tga", SOIL_LOAD_AUTO, SOIL_CREATE_NEW_ID, 0);
if (!textures[0]||!textures[1])
{
return;
}
positions[0] = Vector3(0, 0, -5);
positions[1] = Vector3(0, 0, -5);
shader = new Shader("TexturedVertex.glsl", "TexturedFragment.glsl");
if (!shader-> LoadSuccess())
{
return;
}
usingDepth = false;
usingAlpha = false;
blendMode = 0;
modifyObject = true;
projMatrix = Matrix4::Perspective(1.0f, 100.0f, (float)width / (float)height, 45.0f);
init = true;
}
我们的渲染器的析构函数像往常一样删除在构造函数中创建的所有内容。由于 OpenGL 纹理 ID 在一个数组中,我们可以在一次调用 glDeleteTextures
中删除它们,将我们的纹理数量和指向第一个元素的指针传递给它。
Renderer ::~Renderer(void) {
delete meshes[0];
delete meshes[1];
delete shader;
glDeleteTextures(2, textures);
}
RenderScene
与上一个教程类似。本教程中引入的新功能在其他地方开启和关闭——记住 OpenGL 是一个状态机,所以功能在哪里设置并不重要。我们设置我们的着色器及其矩阵,并将我们的着色器的纹理采样器绑定到纹理单元 0。然后,我们依次在每个网格的位置使用适当的纹理绘制每个网格,在清理并交换我们的缓冲区之前。注意现在我们的glClear
函数有两个值进行按位或运算。因为我们现在有一个深度缓冲区和一个颜色缓冲区,我们也必须每一帧都清除深度缓冲区,使用GL_DEPTH_BUFFER_BIT
符号常量。这将把深度缓冲区中的每个值都设置为 ——这是我们归一化设备坐标空间的最远端。
void Renderer::RenderScene() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
BindShader(shader);
UpdateShaderMatrices();
glUniform1i(glGetUniformLocation(shader->GetProgram(), "diffuseTex"), 0);
glActiveTexture(GL_TEXTURE0);
for (unsigned int i = 0; i < 2; ++i)
{
glUniformMatrix4fv(glGetUniformLocation(shader->GetProgram(), "modelMatrix"), 1, false, (float*)&Matrix4::Translation(positions[i]));
glBindTexture(GL_TEXTURE_2D, textures[i]);
meshes[i]->Draw();
}
}
为了在场景中的两个对象之间切换,我们有一个非常简单的ToggleObject
函数,并且为了移动当前选中的对象,我们有一个MoveObject
函数。我们使用逻辑非运算符来翻转一个布尔值,将其强制转换为int
以用作我们的位置数组的索引,并通过从主循环发送的一个float
来修改相关的位置。
void Renderer::ToggleObject() {
modifyObject = !modifyObject;
}
void Renderer::MoveObject(float by) {
positions[(int)modifyObject].z += by;
}
ToggleBlendMode
利用一个 switch 语句在四种不同的混合函数之间进行选择。为了做到这一点,我们每次调用这个方法时都增加一个值,然后使用取模运算符使blendMode
变量在 0 到 3 的值之间循环。正如你在本教程前面看到的,OpenGL 函数glBlendFunc
设置用于混合透明片段的源和目标混合因子。
void Renderer::ToggleBlendMode() {
blendMode = (blendMode + 1) % 4;
switch (blendMode)
{
case (0): glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break;
case (1): glBlendFunc(GL_SRC_COLOR, GL_ONE_MINUS_SRC_COLOR); break;
case (2): glBlendFunc(GL_ONE, GL_ZERO); break;
case (3): glBlendFunc(GL_SRC_ALPHA, GL_ONE); break;
}
}
ToggleDepth
和ToggleAlphaBlend
应该很容易理解,并且其工作方式与上一个教程中的切换函数类似——我们使用逻辑非运算符来翻转布尔值,然后使用三元运算符在glEnable
和glDisable
之间切换,以切换我们想要的 OpenGL 功能。
void Renderer::ToggleDepth() {
usingDepth = !usingDepth;
usingDepth ? glEnable(GL_DEPTH_TEST) : glDisable(GL_DEPTH_TEST);
}
void Renderer::ToggleAlphaBlend() {
usingAlpha = !usingAlpha;
usingAlpha ? glEnable(GL_BLEND) : glDisable(GL_BLEND);
}
主文件
主文件中没有什么太令人惊讶的地方!只是一些键盘检查来切换我们的深度和 alpha 功能,以及让我们的三角形前后移动。
#include "../nclGL/window.h"
#include "Renderer.h"
int main() {
Window w("Depth and Transparency!", 1280, 720, false);//This is all boring win32 window creation stuff!
if(!w.HasInitialised()) { //This shouldn't happen!
return -1;
}
Renderer renderer(w); //This handles all the boring OGL 3.2 stuff, and sets up our tutorial!
if(!renderer.HasInitialised()) { //This shouldn't happen!
return -1;
}
while(w.UpdateWindow() && !Window::GetKeyboard()->KeyDown(KEYBOARD_ESCAPE)){
if(Window::GetKeyboard()->KeyTriggered(KEYBOARD_1)) {
renderer.ToggleObject();
}
if(Window::GetKeyboard()->KeyTriggered(KEYBOARD_2)) {
renderer.ToggleDepth();
}
if(Window::GetKeyboard()->KeyTriggered(KEYBOARD_3)) {
renderer.ToggleAlphaBlend();
}
if(Window::GetKeyboard()->KeyTriggered(KEYBOARD_4)) {
renderer.ToggleBlendMode();
}
if(Window::GetKeyboard()->KeyDown(KEYBOARD_UP)) {
renderer.MoveObject(0.1f);
}
if(Window::GetKeyboard()->KeyDown(KEYBOARD_DOWN)) {
renderer.MoveObject(-0.1f);
}
renderer.RenderScene();
renderer.SwapBuffers();
}
return 0;
}
运行程序
在成功编译并运行这个程序后,你应该能够使用上下箭头键来前后移动网格,并切换透明度和深度缓冲区的使用。
在运行程序时,默认情况下你将控制三角形。注意,无论你长按向上箭头多久,它似乎永远不会移动到四边形后面。这是因为默认情况下没有进行深度测试,所以第二个渲染的对象,在这种情况下是三角形,将总是绘制在已经绘制的任何东西的上面。现在尝试按数字键 2——如果你将三角形移动得足够远,它将会消失!数字键 2 启用深度测试,所以无论我们以什么顺序绘制三角形,更近的物体总是会出现在更远的物体前面。
如果你长按向后键足够久,三角形将会重新出现,因为它最终会在四边形前面。现在,当三角形在四边形前面时,按数字键 3 来启用 alpha 混合。你应该会看到像本教程开头的图片那样的效果。三角形纹理的彩色玻璃部分的 alpha 值为 128,所以它们是部分透明的。然后你可以使用数字键 4 在不同的 alpha 混合因子之间切换。
总结
你应该能够理解 alpha 混合是如何工作的,以及可以应用于透明度的不同功能。你也应该对深度缓冲区有一个基本的了解,如何启用它,它包含什么,以及在与透明度一起使用深度时可能遇到的陷阱。
在本教程系列的后面,我们将在此基础上进一步探讨一些场景管理,以便更好地管理这些深度问题,并更有效地渲染我们创建的场景。不过,在下一个教程中,我们将看一看另一种类型的缓冲区,即模板缓冲区。
课后作业
进一步的工作:
- 针对深度缓冲区的测试和对其的写入可以分别进行控制。研究一下
glDepthMask
函数。 - 在进行深度测试时,默认行为是通过深度值小于或等于当前深度值的片段。研究
glDepthFunc
函数如何改变这种行为。 - 早期版本的 OpenGL 有一个 alpha 测试功能,如果片段的 alpha 值小于某个特定值,就会完全丢弃该片段——对于跳过 alpha 值为 0 的片段的深度写入很有用!不幸的是,这个功能在 OpenGL 3 核心配置中被移除了。如何在片段着色器中复制这个功能呢?提示:你可以在 GLSL 片段着色器中使用
if
语句,并且有一个名为discard
的片段着色器函数……
欢迎大家踊跃尝试,期待同学们的留言!!