OpenGL4-着色器语言秘籍-全-

OpenGL4 着色器语言秘籍(全)

原文:zh.annas-archive.org/md5/84b6fa0426270517b604884136c03c3c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

OpenGL 着色语言(GLSL)现在是使用 OpenGL 编程的基本和关键部分。它通过使以前固定功能的图形管道可编程,为我们提供了前所未有的灵活性和强大的功能。使用 GLSL,我们可以利用图形处理单元(GPU)实现高级和复杂的渲染技术,甚至进行任意计算。由于细分着色器和计算着色器等着色阶段,使用 GLSL 4.x,程序员可以利用 GPU 做比以前更多的事情。

在这本书中,我们涵盖了 GLSL 编程的全谱系。从使用顶点和片段着色器进行着色的基础开始,我们带你从简单到高级技术。从纹理、阴影和图像处理,到噪声和粒子系统,我们提供了实用的例子,以给你在项目中利用 GLSL 所需的工具。我们还涵盖了如何使用几何着色器、细分着色器以及 GLSL 的最新补充:计算着色器。有了这些,你可以利用 GPU 执行超越仅着色的各种任务。使用几何和细分着色器,我们可以创建额外的几何形状或修改几何形状,而使用计算着色器,我们可以在 GPU 上执行任意计算。

对于那些刚开始接触 GLSL 的人来说,最好按顺序阅读这本书,从第一章,“GLSL 入门”开始。这些食谱会引导你从基础到高级技术。对于对 GLSL 有更多经验的人,你可能觉得挑选特定的食谱并直接跳到那里会更好。大多数食谱都是独立的,但有些可能会引用其他食谱。每章的介绍都提供了关于主题的重要一般信息,所以你可能还想阅读这些内容。

GLSL 4.x 使使用 OpenGL 编程更加有趣和有益。我真诚地希望你觉得这本书有用,并且在你自己的项目中使用这些食谱作为起点。我希望你觉得在 OpenGL 和 GLSL 中的编程和我一样愉快,并且这些技术能激发你创造美丽的图形。

这本书面向的对象

这本书的主要重点是 OpenGL 着色语言(GLSL)。因此,我们不花时间讨论使用 OpenGL 进行编程的基础。在这本书中,我假设读者在 OpenGL 编程方面有一些经验,并理解 3D 渲染概念,如模型坐标、视图坐标、裁剪坐标、透视变换以及其他相关变换。然而,没有假设任何着色器编程的经验,所以如果你是 GLSL 的新手,这是一个很好的起点。

如果你是一名希望学习 GLSL 编程的 OpenGL 程序员,那么这本书就是为你准备的。即使你有一些着色器编程经验,你也可能会发现这本书中的食谱非常有价值。我们涵盖了从简单到高级的各种技术,并使用了一些最新的 OpenGL 特性(如计算着色器和细分着色器)。因此,那些希望学习如何使用这些新特性的经验丰富的 GLSL 程序员也可能发现这本书很有用。

简而言之,这本书是为那些理解 OpenGL 3D 图形基础并希望学习 GLSL 或利用现代 GLSL 4.x 中的一些最新特性的程序员而编写的。

本书涵盖的内容

第一章,GLSL 入门,解释了在 OpenGL 程序中编译、链接和使用 GLSL 着色器的步骤。它还涵盖了如何保存和加载着色器二进制文件,以及如何使用 SPIR-V 与 OpenGL 一起使用。它还涵盖了使用 GLM 库进行数学支持。

第二章,与 GLSL 程序一起工作,介绍了着色器程序和 OpenGL 程序之间通信的基本知识。它涵盖了如何使用属性和统一变量向着色器发送数据,如何枚举变量,如何处理着色器管线,并提供了一个着色器程序类的示例。

第三章,GLSL 着色器基础,通过顶点着色介绍了 GLSL 编程的基础。在本章中,你可以看到基本着色技术,如 Phong 模型、双面着色和平滑着色。它还涵盖了基本 GLSL 概念,如函数和子程序。

第四章,光照与着色,继续介绍基本着色技术,重点关注片段着色器。它介绍了 Blinn-Phong 模型、聚光灯、片段着色、卡通着色、雾效以及基于物理的反射模型等技术。

第五章,使用纹理,提供了一系列涉及在 GLSL 着色器中使用纹理的食谱。纹理除了简单地“粘贴”图像到表面之外,还可以用于多种目的。在本章中,我们介绍了使用一个或多个 2D 纹理的基本应用,以及包括 alpha 图、法线图、视差贴图、立方体贴图、投影纹理、纹理渲染和基于扩散图像的照明在内的各种其他技术。我们还涵盖了采样器对象,这是一个相对较新的特性,它将采样参数与纹理对象本身解耦。

第六章,图像处理和屏幕空间技术,解释了渲染图像后处理的一些常见技术以及一些其他屏幕空间技术。图像后处理正成为现代游戏引擎和其他渲染管线中至关重要的一个部分。本章讨论了如何实现一些更常见的后处理技术,如色调映射、辉光、模糊、伽玛校正和边缘检测。我们还涵盖了屏幕空间渲染技术,如延迟着色、多重采样抗锯齿、屏幕空间环境遮挡和顺序无关透明度。

第七章,使用几何和细分着色器,介绍了如何使用这些强大的着色阶段的技术。阅读本章后,你应该会对它们的基本功能感到舒适,并了解如何使用它们。我们涵盖了如几何着色器生成的点精灵、轮廓线、基于深度的细分、贝塞尔曲面等技术。

第八章,阴影,介绍了产生实时阴影的基本技术。本章包括两种最常见阴影技术的配方:阴影贴图和阴影体积。我们涵盖了抗锯齿阴影贴图的常见技术,以及如何使用几何着色器帮助生成阴影体积。

第九章,在着色器中使用噪声,介绍了使用 Perlin 噪声创建各种效果的方法。第一个配方展示了如何使用 GLM(一个强大的数学库)创建包含噪声数据的各种纹理。然后我们转向使用噪声纹理创建木纹、云彩、破碎、油漆和静态等效果的配方。

第十章,粒子系统和动画,专注于创建粒子系统的技术。我们看到了如何创建一个粒子系统来模拟火焰、烟雾和水。我们还利用 OpenGL 中的变换反馈功能,将粒子更新移动到 GPU 上,以获得额外的效率。

第十一章,使用计算着色器,介绍了利用 OpenGL 最新特性之一——计算着色器的一些技术。计算着色器为我们提供了在 OpenGL 中进行通用计算的能力。在本章中,我们讨论了如何使用计算着色器进行粒子模拟、布料模拟、边缘检测以及生成程序化分形纹理。阅读本章后,读者应该对如何使用计算着色器进行任意计算任务有很好的感觉。

为了充分利用这本书

本书中的食谱使用了 OpenGL 4.x 中一些最新和最出色的功能。因此,为了实现这些功能,您需要支持至少 OpenGL 4.6 的图形硬件(显卡或集成 GPU)和驱动程序。然而,许多食谱也可以在早期版本上运行。如果您不确定您的设置支持哪个版本的 OpenGL,有许多实用程序可以帮助确定这些信息。一个选项是 Realtech VR 的 GLview,可在以下网址找到:www.realtech-vr.com/glview/

如果您正在运行 Windows 或 Linux,大多数现代硬件都提供了可用的驱动程序。然而,如果您正在使用 macOS,不幸的是,您将卡在 OpenGL 4.1 上。苹果公司已正式弃用 OpenGL,因此不会有任何官方更新。但是,大量这些食谱将在 OpenGL 4.1 下运行(有时需要一些小的调整)。

一旦您已验证您拥有所需的 OpenGL 驱动程序,您还需要以下内容:

  • C++编译器。在 Linux 上,GNU 编译器集合(gcc、g++等)可能已经可用,如果没有,则应通过您的发行版的软件包管理器提供。在 Windows 上,Microsoft Visual Studio 将工作得很好,但如果您没有副本,那么 MinGW 编译器(可在mingw.org/找到)是一个不错的选择。

  • GLFW 库版本 3.0 或更高版本,可在www.glfw.org/找到。此库提供 OpenGL 上下文创建、窗口支持和用户输入事件支持。

  • GLM 库版本 0.9.6 或更高版本,可在glm.g-truc.net/找到。此库提供矩阵、向量、常见变换、噪声函数等数学支持类。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误表”。

  4. 在搜索框中输入书籍名称,并按照屏幕上的说明操作。

一旦文件下载完成,请确保您使用最新版本解压缩或提取文件夹,以下是一些选项:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/OpenGL-4-Shading-Language-Cookbook-Third-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。

书籍。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789342253_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“着色器将通过内置变量如gl_Vertexgl_Normal访问这些值。”

代码块设置如下:

void main() 
{ 
  Color = VertexColor; 

  gl_Position = vec4(VertexPosition,1.0); 
}

任何命令行输入或输出都如下所示:

Active attributes:
    1    VertexColor (vec3)
    0    VertexPosition (vec3)

粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“现在我们已经设置了缓冲区对象,我们将它们绑定在一起形成一个顶点数组对象VAO)。”

警告或重要提示看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。

勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packt.com/submit-errata,选择您的书,点击勘误提交表链接,并输入详细信息。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论?潜在的读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需更多关于 Packt 的信息,请访问packt.com

第一章:开始使用 GLSL

在本章中,我们将涵盖以下内容:

  • 使用加载库访问最新的 OpenGL 功能

  • 使用 GLM 进行数学计算

  • 确定 GLSL 和 OpenGL 版本

  • 编译着色器

  • 链接着色器程序

  • 保存和加载着色器二进制文件

  • 加载 SPIR-V 着色器程序

简介

OpenGL 着色语言GLSL)的 4.0 版本为对创建现代、交互式和图形程序感兴趣的程序员带来了前所未有的力量和灵活性。它通过提供一个简单而强大的语言和 API,以直接的方式利用现代图形处理单元GPU)的力量。当然,使用 GLSL 的第一步是创建一个利用 OpenGL API 的程序。GLSL 程序不能独立存在;它们必须成为更大的 OpenGL 程序的一部分。在本章中,我们将提供一些入门的技巧和技术。我们将介绍如何加载、编译、链接和导出 GLSL 着色器程序。首先,让我们从一些背景知识开始。

GLSL

GLSL(OpenGL 着色语言)是 OpenGL API 的基本和不可或缺的部分。使用 OpenGL API 编写的每个程序都会在内部使用一个或多个 GLSL 程序。这些“小程序”被称为着色器程序。一个着色器程序通常由几个称为着色器的组件组成。每个着色器在 OpenGL 管道的不同阶段执行。每个着色器都在 GPU 上运行,正如其名称所暗示的,它们(通常是)实现与光照和着色效果相关的算法。然而,着色器能够做的远不止着色。它们可以执行动画、生成额外的几何形状、对几何形状进行细分,甚至执行通用计算。

研究领域称为通用计算在图形处理单元上GPGPU)关注的是利用 GPU(通常使用如 CUDA 或 OpenCL 之类的专用 API)执行通用计算,如流体动力学、分子动力学和密码学。随着 OpenGL 4.3 中引入的计算着色器,我们现在可以在 OpenGL 中执行 GPGPU。有关使用计算着色器的详细信息,请参阅第十一章,使用计算着色器

着色器程序是为在 GPU 上直接执行而设计的,并且是并行执行的。例如,片段着色器可能为每个像素执行一次,每次执行都是同时进行的。显卡上的处理器数量决定了可以同时执行的数量。这使得着色器程序非常高效,并为程序员提供了一个简单的 API 来实现高度并行的计算。

着色器程序是OpenGL 管道的基本部分。在 OpenGL 版本 2.0 之前,着色算法被硬编码到管道中,并且只有有限的配置能力。当我们想要实现自定义效果时,我们使用各种技巧来强制固定功能管道比它实际更灵活。随着 GLSL 的出现,我们现在能够用我们用 GLSL 编写的自己的程序来替换这个硬编码的功能,从而给我们带来大量的额外灵活性和能力。有关这个可编程管道的更多详细信息,请参阅第三章的介绍,GLSL 着色器的基础

实际上,OpenGL 版本 3.2 及以上不仅提供了这种能力,而且它们要求每个 OpenGL 程序都包含着色器程序。旧的固定功能管道已被弃用,转而采用新的可编程管道,其关键部分是使用 GLSL 编写的着色器程序。

配置文件 - 核心与兼容性

OpenGL 版本 3.0 引入了弃用模型,允许逐步从 OpenGL 规范中删除函数。函数或特性可以被标记为已弃用,这意味着它们预计将在 OpenGL 的未来版本中被移除。例如,使用glBegin/glEnd进行即时模式渲染在版本 3.0 中被标记为已弃用,并在版本 3.1 中被移除。

为了保持向后兼容性,OpenGL 3.2 引入了兼容性配置文件。一个为特定版本的 OpenGL(移除了旧功能)编写代码的程序员会使用核心配置文件。那些希望与旧功能保持兼容的人可以使用兼容性配置文件。

可能有些令人困惑的是,还有一个向前兼容上下文的概念,它与核心/兼容配置文件的概念略有区别。一个被认为是向前兼容的上下文基本上表明所有已弃用的功能都已移除。换句话说,如果一个上下文是向前兼容的,它只包括核心中的函数,但不包括那些被标记为已弃用的。一些 Windows API 提供了选择与配置文件一起的向前兼容状态的能力。

选择核心或兼容配置文件步骤取决于 Windows 系统的 API。例如,使用 GLFW,可以通过以下代码选择一个向前兼容的 4.6 核心配置文件:

glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); 
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 6); 
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); 
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); 

GLFWwindow *window = glfwCreateWindow(800, 600, "Title", nullptr, nullptr);

本书中的所有程序都设计为与向前兼容的 OpenGL 4.6 核心配置文件兼容。然而,许多程序也可以用于较旧版本或甚至兼容配置文件。

使用加载库访问最新的 OpenGL 功能

在 Windows 上,OpenGL 的应用程序二进制接口ABI)被冻结在 OpenGL 版本 1.1。不幸的是,对于 Windows 开发者来说,这意味着无法直接链接到 OpenGL 新版本中提供的函数。相反,必须通过在运行时获取函数指针来访问 OpenGL 函数。获取函数指针并不困难,但需要一些繁琐的工作,并且有使代码杂乱无章的倾向。此外,Windows 通常附带一个符合 OpenGL 1.1 标准的标准 OpenGL gl.h文件。

OpenGL 维基百科指出,微软没有计划更新他们编译器附带的gl.hopengl32.lib。幸运的是,其他人已经提供了通过透明地提供所需函数指针,同时在头文件中公开所需功能来为我们管理所有这些的库。这样的库被称为OpenGL 加载库(或OpenGL 函数加载器),有多个这样的库可用。其中最古老的一个是OpenGL 扩展包装器GLEW)。然而,GLEW 有几个问题。首先,它提供了一个包含 OpenGL 所有版本中所有内容的单个大头文件。可能更倾向于有一个更精简的头文件,只包含我们可能使用的函数。其次,GLEW 作为需要单独编译并链接到我们的项目的库进行分发。我发现有一个加载器,只需添加源文件并将其直接编译到我们的可执行文件中,就可以将其包含到项目中,避免了支持另一个链接时依赖的需要。

在这个菜谱中,我们将使用一个名为 GLAD 的加载器生成器,它可以从github.com/Dav1dde/glad获取。这个非常灵活且高效的库可以生成只包含所需功能的头文件,并且只生成几个文件(一个源文件和一些头文件),我们可以直接将其添加到我们的项目中。

准备工作

要使用 GLAD,您可以通过pip下载并安装它(或从github.com/Dav1dde/glad),或者您可以使用这里提供的网络服务:glad.dav1d.de/。如果您选择安装它,您将需要 Python。安装很简单,GitHub 页面上有详细的描述。

如何做这件事...

第一步是生成您选择的 OpenGL 版本和配置的头文件和源文件。在这个例子中,我们将为 OpenGL 4.6 核心配置生成文件。然后我们可以将这些文件复制到我们的项目中,并直接与我们的代码一起编译:

  1. 要生成头文件和源文件,请运行以下命令:
glad --generator=c --out-path=GL --profile=core --api=gl=4.6
  1. 上一步将生成输出到名为GL的目录中。将会有两个目录:GL/includeGL/src。你可以直接将 GL 目录移动到你的项目中,或者将单个文件移动到适当的位置。在构建时包含GL/src/glad.c,并将GL/include放入你的include路径中。在程序代码中,每当需要访问 OpenGL 函数时,请包含glad/glad.h。请注意,这完全替换了gl.h,因此不需要包含它。

  2. 为了初始化函数指针,你需要确保调用一个执行此操作的函数。所需的函数是gladLoadGL()。在创建 GL 上下文之后(通常在初始化函数中),在调用任何 OpenGL 函数之前,使用以下代码:

if(!gladLoadGL()) {
  std::cerr << "Unable to load OpenGL functions!" << std::endl;
  exit(EXIT_FAILURE);
}

就这些了!

它是如何工作的...

第 1 步的命令生成了一些头文件和源文件。头文件提供了所有选定 OpenGL 函数的原型,将它们重新定义为函数指针,并定义了所有 OpenGL 常量。源文件提供了函数指针的初始化代码以及一些其他实用函数。我们可以在需要 OpenGL 函数原型的任何地方包含glad/glad.h头文件,因此所有函数入口点在编译时都可用。在运行时,gladLoadGL()调用将初始化所有可用的函数指针。

一些函数指针可能无法成功初始化。这可能发生在你的驱动程序不支持请求的 OpenGL 版本时。如果发生这种情况,调用这些函数将失败。

GLAD 的可用命令行参数在 GitHub 网站上进行了全面文档记录,并且可以通过glad -h访问。可以选择任何 OpenGL 版本,选择核心/兼容性配置文件,包含所需的扩展,以及/或创建调试回调。

还有更多...

GLAD 提供了一个位于glad.dav1d.de/的 Web 服务,这使得在不安装 GLAD 的情况下生成加载器源文件和头文件变得容易。只需访问 URL,选择所需的配置,加载器文件就会被生成并下载。

参见

  • 示例代码中的ingredients/scenerunner.h文件

  • GLEW,一个较老但流行的加载器和扩展管理器,可以从glew.sourceforge.net获取

使用 GLM 进行数学运算

数学是计算机图形学的核心。在早期版本中,OpenGL 通过标准矩阵栈(GL_MODELVIEWGL_PROJECTION)提供了管理坐标变换和投影的支持。然而,在现代核心 OpenGL 版本中,所有支持矩阵栈的功能都已删除。因此,我们必须提供自己的支持来处理通常的变换和投影矩阵,然后将它们传递到我们的着色器中。当然,我们可以编写自己的矩阵和向量类来管理这些,但有些人可能更喜欢使用现成的、健壮的库。

有这样一个库,就是由 Christophe Riccio 编写的OpenGL MathematicsGLM)。其设计基于 GLSL 规范,因此语法对使用 GLSL 的人来说很熟悉。此外,它还提供了包含类似于一些被广泛怀念的 OpenGL 实用函数的功能的扩展,例如glOrthoglRotategluLookAt

准备工作

由于 GLM 是一个仅包含头文件的库,安装很简单。从glm.g-truc.net下载最新的 GLM 发行版。然后,解压缩存档文件,并将包含在其中的glm目录复制到编译器的包含路径中的任何位置。

如何做到这一点...

要使用 GLM 库,需要包含核心头文件以及任何扩展的头文件。对于这个例子,我们将包含矩阵变换扩展:

#include <glm/glm.hpp> 
#include <glm/gtc/matrix_transform.hpp> 

GLM 类在glm命名空间中可用。以下是如何使用其中一些类的示例:

glm::vec4 position = glm::vec4( 1.0f, 0.0f, 0.0f, 1.0f ); 
glm::mat4 view = glm::lookAt( 
        glm::vec3(0.0f, 0.0f, 5.0f),
        glm::vec3(0.0f, 0.0f, 0.0f),
        glm::vec3(0.0f, 1.0f, 0.0f) 
   ); 
glm::mat4 model(1.0f);   // The identity matrix 
model = glm::rotate( model, 90.0f, glm::vec3(0.0f,1.0f,0.0) ); 
glm::mat4 mv = view * model; 
glm::vec4 transformed = mv * position; 

它是如何工作的...

GLM 库是一个仅包含头文件的库。所有的实现都包含在头文件中。它不需要单独编译,也不需要将你的程序链接到它。只需将头文件放在你的包含路径中即可!

之前的例子首先创建了一个vec4(一个四分量向量),它代表一个位置。然后,它使用glm::lookAt函数创建了一个 4 x 4 的视图矩阵。这与旧的gluLookAt函数类似。在这里,我们将摄像机的位置设置为(0, 0, 5),朝向原点,向上方向沿着正y轴。然后,我们继续创建模型矩阵,首先将单位矩阵存储在model变量中(通过单参数构造函数),然后使用glm::rotate函数乘以一个旋转矩阵。

这里的乘法是由glm::rotate函数隐式完成的。它将其第一个参数乘以由函数生成的旋转矩阵(在右侧)。第二个参数是旋转角度(以度为单位),第三个参数是旋转轴。由于在此语句之前,model是单位矩阵,所以最终结果是model变成了绕y轴旋转 90 度的旋转矩阵。

最后,我们通过将viewmodel变量相乘来创建我们的模型视图矩阵(mv),然后使用组合矩阵来变换位置。请注意,乘法运算符已被重载以按预期的方式工作。

顺序在这里很重要。通常,模型矩阵表示从对象空间到世界空间的转换,视图矩阵是从世界空间到相机空间的转换。因此,为了得到一个从对象空间到相机空间的转换的单个矩阵,我们希望模型矩阵首先应用。因此,模型矩阵被乘在视图矩阵的右侧。

还有更多...

不建议使用以下命令导入所有 GLM 命名空间:

using namespace glm; 

这很可能会引起许多命名空间冲突。因此,最好根据需要使用using语句逐个导入符号。例如:

#include <glm/glm.hpp> 
using glm::vec3; 
using glm::mat4; 

使用 GLM 类型作为 OpenGL 的输入

GLM 支持直接使用 OpenGL 的其中一个 OpenGL 向量函数(带有v后缀)将 GLM 类型传递给 OpenGL。例如,要将名为projmat4传递给 OpenGL,我们可以使用以下代码:

glm::mat4 proj = glm::perspective( viewAngle, aspect, nearDist, farDist ); 
glUniformMatrix4fv(location, 1, GL_FALSE, &proj[0][0]); 

或者,而不是使用和号运算符,我们可以使用glm::value_ptr函数来获取 GLM 类型内容的指针:

glUniformMatrix4fv(location, 1, GL_FALSE, glm::value_ptr(proj));

后者版本需要包含头文件glm/gtc/type_ptr.hpp。使用value_ptr可以说是更干净的方法,并且适用于任何 GLM 类型。

参见

  • Qt SDK 包括许多用于向量/矩阵数学的类,如果你已经在使用 Qt,这也是一个不错的选择。

  • GLM 网站(glm.g-truc.net)有额外的文档和示例

确定 GLSL 和 OpenGL 版本

为了支持广泛系统,能够查询当前驱动程序支持的 OpenGL 和 GLSL 版本是至关重要的。这样做相当简单,涉及两个主要函数:glGetStringglGetIntegerv

注意,这些函数必须在 OpenGL 上下文创建之后调用。

如何实现...

以下代码将版本信息打印到stdout

const GLubyte *renderer = glGetString( GL_RENDERER ); 
const GLubyte *vendor = glGetString( GL_VENDOR ); 
const GLubyte *version = glGetString( GL_VERSION ); 
const GLubyte *glslVersion = 
       glGetString( GL_SHADING_LANGUAGE_VERSION ); 

GLint major, minor; 
glGetIntegerv(GL_MAJOR_VERSION, &major); 
glGetIntegerv(GL_MINOR_VERSION, &minor); 

printf("GL Vendor            : %s\n", vendor); 
printf("GL Renderer          : %s\n", renderer); 
printf("GL Version (string)  : %s\n", version); 
printf("GL Version (integer) : %d.%d\n", major, minor); 
printf("GLSL Version         : %s\n", glslVersion);

它是如何工作的...

注意,有两种不同的方式来检索 OpenGL 版本:使用glGetStringglGetIntegerv。前者可以提供可读的输出,但由于需要解析字符串,可能不适合程序化检查版本。glGetString(GL_VERSION)提供的字符串应该始终以点分隔的主版本和次版本开始,然而,次版本可能后面跟着供应商特定的构建号。此外,字符串的其余部分可以包含额外的供应商特定信息,也可能包括所选配置文件的信息(参见本章的简介部分)。重要的是要注意,使用glGetIntegerv查询版本信息需要 OpenGL 3.0 或更高版本。

对于GL_VENDORGL_RENDERER的查询提供了关于 OpenGL 驱动程序额外的信息。glGetString(GL_VENDOR)调用返回负责 OpenGL 实现的公司的名称。glGetString(GL_RENDERER)调用提供渲染器的名称,这是特定于某个硬件平台(例如 ATI Radeon HD 5600 系列)的。请注意,这两个值在版本之间不会变化,因此可以用来确定当前平台。

在本书的上下文中,对我们来说更重要的是对glGetString(GL_SHADING_LANGUAGE_VERSION)的调用,它提供了支持的 GLSL 版本号。这个字符串应该以点分隔的主版本号和次版本号开始,但与GL_VERSION查询类似,可能包含其他供应商特定的信息。

更多内容...

查询当前 OpenGL 实现的受支持扩展通常很有用。扩展名称是索引的,并且可以通过索引单独查询。我们使用glGetStringi变体来完成这个操作。例如,要获取存储在索引i处的扩展名称,我们使用glGetStringi(GL_EXTENSIONS, i)。要打印所有扩展的列表,我们可以使用以下代码:

GLint nExtensions; 
glGetIntegerv(GL_NUM_EXTENSIONS, &nExtensions); 

for( int i = 0; i < nExtensions; i++ ) 
      printf("%s\n", glGetStringi( GL_EXTENSIONS, i ) );

编译着色器

要开始,我们需要知道如何编译我们的 GLSL 着色器。GLSL 编译器直接构建在 OpenGL 库中,着色器只能在运行中的 OpenGL 程序上下文中编译。

OpenGL 4.1 增加了将编译的着色器程序保存到文件的能力,使 OpenGL 程序能够通过加载预编译的着色器程序来避免着色器编译的开销(请参阅保存和加载着色器二进制文件食谱)。OpenGL 4.6 增加了加载编译为(或用)SPIR-V 的着色器程序的能力,SPIR-V 是定义着色器的中间语言。请参阅本章后面的加载 SPIR-V 着色器食谱。

编译着色器涉及创建一个着色器对象,向着色器对象提供源代码(作为字符串或字符串集),并要求着色器对象编译代码。这个过程大致可以用以下图表表示:

准备工作

要编译一个着色器,我们需要一个基本示例来工作。让我们从以下简单的顶点着色器开始。将其保存为名为basic.vert.glsl的文件:

#version 460
in vec3 VertexPosition; 
in vec3 VertexColor; 

out vec3 Color; 

void main() 
{ 
   Color = VertexColor; 
   gl_Position = vec4( VertexPosition, 1.0 ); 
}

如果您对这段代码的功能感到好奇,它作为一个“传递”着色器工作。它接受VertexPositionVertexColor输入属性,并通过gl_PositionColor输出变量将它们传递给片段着色器。

接下来,我们需要使用支持 OpenGL 的窗口工具包构建一个 OpenGL 程序的基本外壳。跨平台工具包的例子包括 GLFW、GLUT、FLTK、Qt 和 wxWidgets。在整个文本中,我将假设您可以使用您喜欢的工具包创建一个基本的 OpenGL 程序。几乎所有的工具包都有一个初始化函数的钩子、一个窗口调整大小回调(在调整窗口大小时调用)和一个绘制回调(在每次窗口刷新时调用)。为了本食谱的目的,我们需要一个创建并初始化 OpenGL 上下文的程序;它不需要做任何事情,只需显示一个空的 OpenGL 窗口。请注意,您还需要加载 OpenGL 函数指针(请参阅使用加载库访问最新的 OpenGL 功能食谱)。

最后,将着色器源代码加载到std::string(或字符数组)中。以下示例假设shaderCode变量是一个包含着色器源代码的std::string

如何操作...

要编译一个着色器,请按照以下步骤操作:

  1. 创建着色器对象:
GLuint vertShader = glCreateShader( GL_VERTEX_SHADER ); 
if( 0 == vertShader ) { 
  std::cerr << "Error creating vertex shader." << std::endl;
  exit(EXIT_FAILURE); 
} 
  1. 将源代码复制到着色器对象中:
std::string shaderCode = loadShaderAsString("basic.vert.glsl"); 
const GLchar * codeArray[] = { shaderCode.c_str() }; 
glShaderSource( vertShader, 1, codeArray, NULL ); 
  1. 编译着色器:
glCompileShader( vertShader );
  1. 验证编译状态:
GLint result; 
glGetShaderiv( vertShader, GL_COMPILE_STATUS, &result ); 
if( GL_FALSE == result ) { 
  std::cerr << "Vertex shader compilation failed!" << std::endl;

  // Get and print the info log
  GLint logLen; 
  glGetShaderiv(vertShader, GL_INFO_LOG_LENGTH, &logLen); 
  if( logLen > 0 ) { 
    std::string log(logLen, ' '); 
    GLsizei written; 
    glGetShaderInfoLog(vertShader, logLen, &written, &log[0]); 
    std::cerr << "Shader log: " << std::endl << log;
  } 
} 

它是如何工作的...

第一步是使用glCreateShader函数创建着色器对象。参数是着色器的类型,可以是以下之一:GL_VERTEX_SHADERGL_FRAGMENT_SHADERGL_GEOMETRY_SHADERGL_TESS_EVALUATION_SHADERGL_TESS_CONTROL_SHADER,或者(自版本 4.3 起)GL_COMPUTE_SHADER。在这种情况下,由于我们正在编译顶点着色器,我们使用GL_VERTEX_SHADER。这个函数返回用于引用顶点着色器对象的值,有时称为对象句柄。我们将该值存储在vertShader变量中。如果在创建着色器对象时发生错误,此函数将返回 0,因此我们检查该错误,如果发生,我们打印一条适当的消息并终止。

在创建着色器对象之后,我们使用glShaderSource函数将源代码加载到着色器对象中。这个函数被设计用来接受一个字符串数组(而不是单个字符串),以便支持一次性编译多个源代码(文件、字符串)的选项。因此,在我们调用glShaderSource之前,我们将源代码的指针放入一个名为sourceArray的数组中。

glShaderSource的第一个参数是着色器对象的句柄。第二个是数组中包含的源代码字符串的数量。第三个参数是指向源代码字符串数组的指针。最后一个参数是一个GLint值数组,包含前一个参数中每个源代码字符串的长度。

在前面的代码中,我们传递了一个NULL值,这表示每个源代码字符串由一个空字符终止。如果我们的源代码字符串没有空终止,那么这个参数必须是一个有效的数组。请注意,一旦这个函数返回,源代码已经被复制到 OpenGL 内部内存中,因此可以释放存储源代码的内存。

下一步是编译着色器的源代码。我们通过简单地调用glCompileShader并传递要编译的着色器的句柄来完成这个操作。当然,根据源代码的正确性,编译可能会失败,所以下一步是检查编译是否成功。

我们可以通过调用 glGetShaderiv 来查询编译状态,这是一个用于查询着色器对象属性的函数。在这种情况下,我们感兴趣的是编译状态,所以我们使用 GL_COMPILE_STATUS 作为第二个参数。第一个参数当然是着色器对象的句柄,第三个参数是一个指向整数的指针,其中将存储状态。该函数在第三个参数中提供一个值为 GL_TRUEGL_FALSE 的值,指示编译是否成功。

如果编译状态为 GL_FALSE,我们可以查询着色器日志,这将提供关于失败原因的额外详细信息。我们通过再次调用 glGetShaderiv 并传入值 GL_INFO_LOG_LENGTH 来查询日志的长度。这将在 logLen 变量中提供日志的长度。请注意,这包括空终止字符。然后我们为日志分配空间,并通过调用 glGetShaderInfoLog 来检索日志。第一个参数是着色器对象的句柄,第二个参数是用于存储日志的字符缓冲区的大小,第三个参数是一个指向整数的指针,其中将存储实际写入的字符数(不包括空终止字符),第四个参数是用于存储日志本身的字符缓冲区的指针。一旦检索到日志,我们就将其打印到 stderr 并释放其内存空间。

更多内容...

之前的示例仅演示了如何编译顶点着色器。还有几种其他类型的着色器,包括片段、几何和细分着色器。每种着色器类型的编译技术几乎相同。唯一的显著区别是 glCreateShader 的参数。

还需要注意的是,着色器编译只是第一步。类似于 C++ 这样的语言,我们需要链接程序。虽然着色器程序可以只包含一个着色器,但对于许多用例,我们必须编译两个或更多着色器,然后这些着色器必须链接成一个着色器程序对象。我们将在下一个配方中看到链接的步骤。

删除着色器对象

当不再需要时,可以通过调用 glDeleteShader 来删除着色器对象。这将释放着色器使用的内存并使句柄无效。请注意,如果着色器对象已经附加到程序对象(参考 链接着色器程序 配方),则它不会立即被删除,而是在从程序对象断开连接时标记为删除。

参见

  • 示例代码中的 chapter01/scenebasic.cpp 文件

  • 链接着色器程序 的配方

链接着色器程序

在我们编译了我们的着色器之后,在我们实际上可以将它们安装到 OpenGL 管道之前,我们需要将它们链接成一个着色器程序。链接步骤包括将一个着色器的输入变量与另一个着色器的输出变量之间的连接,以及将着色器的输入/输出变量与 OpenGL 环境中的适当位置之间的连接。

链接涉及与编译着色器时涉及的步骤相似的步骤。我们将每个着色器对象附加到一个新的着色器程序对象上,然后告诉着色器程序对象进行链接(确保在链接之前着色器对象已经编译):

准备工作

对于这个配方,我们假设你已经编译了两个着色器对象,其句柄存储在 vertShaderfragShader 变量中。

对于本章中的这个和其他几个配方,我们将使用以下源代码作为片段着色器:

#version 460 

in vec3 Color; 
out vec4 FragColor; 

void main() { 
  FragColor = vec4(Color, 1.0); 
} 

对于顶点着色器,我们将使用前一个配方中的源代码,编译着色器

如何操作...

在我们的 OpenGL 初始化函数中,并在 vertShaderfragShader 指向的着色器对象编译之后,执行以下步骤:

  1. 使用以下代码创建程序对象:
GLuint programHandle = glCreateProgram(); 
if( 0 == programHandle ) 
{ 
  std::cerr << "Error creating program object." << std::endl; 
  exit(EXIT_FAILURE); 
} 
  1. 按以下方式将着色器附加到程序对象上:
glAttachShader( programHandle, vertShader ); 
glAttachShader( programHandle, fragShader ); 
  1. 链接程序:
glLinkProgram( programHandle );
  1. 验证链接状态:
GLint status; 
glGetProgramiv( programHandle, GL_LINK_STATUS, &status ); 
if( GL_FALSE == status ) {
  std::cerr << "Failed to link shader program!" << std::endl;
  GLint logLen; 
  glGetProgramiv(programHandle, GL_INFO_LOG_LENGTH, &logLen); 
  if( logLen > 0 ) { 
    std::string(logLen, ' ');
    GLsizei written;
    glGetProgramInfoLog(programHandle, logLen, &written, &log[0]); 
    std::cerr << "Program log: " << std::endl << log;
  } 
} 
  1. 如果链接成功,我们可以使用 glUseProgram 将程序安装到 OpenGL 管道中:
else
  glUseProgram( programHandle );

无论链接是否成功,清理我们的着色器对象都是一个好主意。一旦程序链接,它们就不再需要了:

// Detach and delete shader objects
glDetachShader(programHandle, vertShader);
glDetachShader(programHandle, fragShader);
glDeleteShader(vertShader);
glDeleteShader(fragShader);

它是如何工作的...

我们首先调用 glCreateProgram 来创建一个空的程序对象。这个函数返回程序对象的句柄,我们将其存储在一个名为 programHandle 的变量中。如果程序创建出现错误,该函数将返回 0。我们检查这一点,如果发生错误,我们打印一条错误消息并退出。

接下来,我们使用 glAttachShader 将每个着色器附加到程序对象上。第一个参数是程序对象的句柄,第二个是要附加的着色器对象的句柄。

然后,我们通过调用 glLinkProgram 并提供程序对象的句柄作为唯一参数来链接程序。与编译类似,我们检查链接的成功或失败,并进行后续查询。

我们通过调用 glGetProgramiv 来检查链接状态。类似于 glGetShaderivglGetProgramiv 允许我们查询着色器程序的各种属性。在这种情况下,我们通过提供 GL_LINK_STATUS 作为第二个参数来请求链接状态。状态被返回到第三个参数指向的位置,在这个例子中命名为 status

链接状态为GL_TRUEGL_FALSE,表示链接成功或失败。如果状态值为GL_FALSE,我们将检索并显示程序信息日志,其中应包含额外的信息和错误消息。程序日志是通过调用glGetProgramInfoLog检索的。第一个参数是程序对象的句柄,第二个是包含日志的缓冲区大小,第三个是指向一个GLsizei变量的指针,其中将存储写入缓冲区的字节数(不包括空终止符),第四个是指向将存储日志的缓冲区的指针。缓冲区可以根据调用glGetProgramiv时返回的大小来分配,参数为GL_INFO_LOG_LENGTHlog中提供的字符串将被正确地空终止。

最后,如果链接成功,我们通过调用glUseProgram将程序安装到 OpenGL 管道中,提供程序句柄作为参数。

无论链接是否成功,将着色器对象分离并删除都是一个好主意。然而,如果着色器对象可能需要链接另一个程序,你应该将其从该程序中分离出来,并跳过删除操作,直到稍后。

通过编译、链接并将本食谱中的简单片元着色器和前一个食谱中的顶点着色器安装到 OpenGL 管道中,我们得到了一个完整的 OpenGL 管道,并准备好开始渲染。绘制一个三角形并为Color属性提供不同的值,可以得到一个多色三角形的图像,其中顶点是红色、绿色和蓝色,三角形内部三种颜色被插值,导致颜色在整个三角形中混合:

图片

关于如何渲染三角形的详细信息,请参阅第二章,使用 GLSL 程序

更多...

你可以在单个 OpenGL 程序中使用多个着色器程序。可以通过调用glUseProgram来选择所需的程序,在 OpenGL 管道中交换它们。

着色器输入/输出变量

你可能已经注意到,Color变量被用来从顶点着色器向片元着色器发送数据。在顶点着色器中有一个输出变量(out vec3),在片元着色器中有一个输入变量(in vec3),它们具有相同的名称。片元着色器接收到的值是从每个顶点相应的输出变量的值插值得到的(因此产生了前面图像中的混合颜色)。这种插值是在执行片元阶段之前由硬件光栅化器自动完成的。

当链接着色器程序时,OpenGL 会在顶点着色器和片段着色器(以及其他方面)之间建立输入和输出变量的连接。如果一个顶点着色器的输出变量与片段着色器的输入变量具有相同的名称和类型,OpenGL 将自动将它们链接在一起。

可以通过使用布局限定符来连接(链接)名称或类型不同的变量。使用布局限定符,我们可以为每个变量指定特定的位置。例如,假设我在我的顶点着色器中使用了这组输出变量:

layout (location=0) out vec4 VertColor;
layout (location=1) out vec3 VertNormal;

我可以在片段着色器中使用这些变量:

layout (location=0) in vec3 Color;
layout (location=1) in vec3 Normal;

尽管这些变量具有不同的名称(以及对于Color,类型),但由于它们被分配了相同的地址,它们将在程序链接时由链接器连接。在这个例子中,VertColor将被链接到Color,而VertNormal将被链接到Normal。这使得事情更加方便。我们不需要为输入/输出变量使用相同的名称,这给了我们使用在每个着色器阶段可能更具描述性的名称的灵活性。更重要的是,它是称为独立着色器对象的更大框架的一部分。可以在使用程序管道菜谱中找到独立着色器对象的完整示例。

实际上,当编译到 SPIR-V 时,使用布局限定符来指定变量位置是必需的(请参阅加载 SPIR-V 着色器程序菜谱)。

删除着色器程序

如果不再需要程序,可以通过调用glDeleteProgram从 OpenGL 内存中删除它,将程序句柄作为唯一参数提供。这将使句柄无效并释放程序使用的内存。请注意,如果程序对象当前正在使用中,它将不会立即被删除,而是在不再使用时被标记为删除。

此外,删除着色器程序会断开附加到程序上的着色器对象,但不会删除它们,除非这些着色器对象已经被之前的glDeleteShader调用标记为删除。因此,正如之前提到的,在程序链接后立即断开并删除它们是一个好主意,以避免意外泄漏着色器对象。

参见

  • 示例代码中的chapter01/scenebasic.cpp文件

  • 编译着色器菜谱

  • 使用程序管道菜谱

  • 加载 SPIR-V 着色器程序菜谱

保存和加载着色器二进制文件

OpenGL 4.1 引入了glGetProgramBinaryglProgramBinary函数,允许我们保存和加载编译后的着色器程序二进制文件。请注意,此功能仍然非常依赖于 OpenGL 驱动程序,并且支持范围有限。例如,macOS 上的 Intel 驱动程序不支持任何二进制格式。

不幸的是,苹果公司在 macOS Mojave 中已经弃用了 OpenGL。

在这个菜谱中,我们将概述保存和加载编译后的着色器程序所需的步骤。

准备工作

我们将假设一个着色器程序已经成功编译,并且其 ID 存储在 program 变量中。

如何做...

要保存着色器二进制文件,首先验证驱动程序至少支持一种着色器二进制格式:

GLint formats = 0;
glGetIntegerv(GL_NUM_PROGRAM_BINARY_FORMATS, &formats);
if( formats < 1 ) {
  std::cerr << "Driver does not support any binary formats." << std::endl;
  exit(EXIT_FAILURE);
}

然后,假设至少有一个二进制格式可用,使用 glGetProgramBinary 来检索编译后的着色器代码并将其写入文件:

// Get the binary length
GLint length = 0;
glGetProgramiv(program, GL_PROGRAM_BINARY_LENGTH, &length);

// Retrieve the binary code
std::vector<GLubyte> buffer(length);
GLenum format = 0;
glGetProgramBinary(program, length, NULL, &format, buffer.data());

// Write the binary to a file.
std::string fName("shader.bin");
std::cout << "Writing to " << fName << ", binary format = " << format << std::endl;
std::ofstream out(fName.c_str(), std::ios::binary);
out.write( reinterpret_cast<char *>(buffer.data()), length );
out.close();

要加载和使用着色器二进制文件,从存储中检索编译后的程序,并使用 glProgramBinary 将其加载到 OpenGL 上下文中:

GLuint program = glCreateProgram();

// Load binary from file
std::ifstream inputStream("shader.bin", std::ios::binary);
std::istreambuf_iterator<char> startIt(inputStream), endIt;
std::vector<char> buffer(startIt, endIt);  // Load file
inputStream.close();

// Install shader binary
glProgramBinary(program, format, buffer.data(), buffer.size() );

// Check for success/failure
GLint status;
glGetprogramiv(program, GL_LINK_STATUS, &status);
if( GL_FALSE == status ) {
  // Handle failure ...
}

它是如何工作的...

驱动程序可以支持零个或多个二进制格式。使用 GL_NUM_PROGRAM_BINARY_FORMATS 常量调用 glGetIntegerv 会查询驱动程序以查看有多少可用。如果这个数字是零,OpenGL 驱动程序不支持读取或写入着色器二进制文件。如果值为一或更多,我们就可以开始了。

如果至少有一个二进制格式可用,我们可以使用 glGetProgramBinary 来检索前面显示的编译后的着色器代码。该函数将把使用的二进制格式写入第四个参数指向的位置。在前面的示例中,数据存储在名为 buffer 的向量中。

要加载着色器二进制文件,我们可以使用 glProgramBinary。此函数将加载之前保存的着色器二进制文件。它需要将二进制格式作为第二个参数传递。然后我们可以检查 GL_LINK_STATUS 以验证它是否加载无误。

参见

  • 示例代码中的 chapter01/scenebasic.cpp 文件

  • 加载 SPIR-V 着色器程序 的配方

加载 SPIR-V 着色器程序

标准、可移植中间表示 - V (SPIR-V) 是由 Khronos Group 设计和标准化的一个中间语言,用于着色器。它旨在成为多种不同语言的编译器目标。在 Vulkan API 中,着色器在加载之前必须编译为 SPIR-V。SPIR-V 的目的是为开发者提供自由,让他们可以使用任何他们想要的编程语言(只要它可以编译为 SPIR-V)来开发他们的着色器,并避免需要 OpenGL(或 Vulkan)实现为多种语言提供编译器的需求。

SPIR-V 着色器二进制文件的支持被添加到 OpenGL 核心版本 4.6 中,但也可以通过 ARB_gl_spirv 扩展在较早的 OpenGL 版本中使用。

目前,Khronos Group 为编译 GLSL 到 SPIR-V 提供了一个参考编译器。它可在 GitHub 上找到,网址为 github.com/KhronosGroup/glslang

在本配方中,我们将介绍将 GLSL 着色器对预编译到 SPIR-V 并将其加载到 OpenGL 程序中的步骤。

准备工作

github.com/KhronosGroup/glslang 下载并编译 OpenGL 着色器验证器。请确保 glslangValidator 二进制文件已添加到您的 PATH 命令行中。在本例中,我们将使用位于 basic.vert.glslbasic.frag.glsl 文件中的着色器对。

注意,你需要在着色器中为所有输入/输出变量使用显式位置。有关详细信息,请参阅链接着色器程序配方。

所有用于输入/输出接口(输入/输出变量)的变量都必须分配一个位置。

如何做...

首先,使用glslangValidator工具将着色器对编译成 SPIR-V:

glslangValidator -G -o basic.vert.spv basic.vert.glsl
glslangValidator -G -o basic.frag.spv basic.frag.glsl

如果成功,这将生成basic.vert.spvbasic.frag.spv SPIR-V 输出文件。

要将你的 SPIR-V 着色器加载到 OpenGL 程序中,使用glShaderBinaryglSpecializeShader。使用glShaderBinary时,使用GL_SHADER_BINARY_FORMAT_SPIR_V作为二进制格式:

GLuint vertShader = glCreateShader(GL_VERTEX_SHADER);

// Load the shader into a std::vector
std::ifstream inStream("basic.vert.spv", std::ios::binary);
std::istreambuf_iterator<char> startIt(inStream), endIt;
std::vector<char> buffer(startIt, endIt);
inStream.close();

// Load using glShaderBinary
glShaderBinary(1, &vertShader, GL_SHADER_BINARY_FORMAT_SPIR_V, buffer.data(), buffer.size());

// Specialize the shader (specify the entry point)
glSpecializeShader( vertShader, "main", 0, 0, 0);

// Check for success/failure
GLint status;
glGetShaderiv(vertShader, GL_COMPILE_STATUS, &status);
if( GL_FALSE == status ) {
  // Loading failed...
}

对于片段着色器的过程几乎完全相同;只需在第一行使用GL_FRAGMENT_SHADER而不是GL_VERTEX_SHADER

最后,我们创建程序对象,附加着色器,并链接。这个过程与链接着色器程序配方中所示的过程相同,因此我们在这里不会重复它。

它是如何工作的...

glShaderBinary函数为我们提供了加载已编译为 SPIR-V 格式的着色器的功能。这部分相当直接。

可能有点令人困惑的函数是glSpecializeShader。在着色器阶段可以链接之前,我们必须调用此函数。这个调用是必需的,因为单个 SPIR-V 文件可以有多个入口点,SPIR-V 文件可以有专门化常量,这些是用户在编译成本地代码之前可以提供的参数。

至少,我们需要定义我们的着色器的入口点。由于源语言是 GLSL,入口点是main。我们通过第二个参数指定入口点(s)。对于 GLSL,我们简单地使用main常量字符串。最后三个参数可以用来定义专门化常量。三个中的第一个是常量的数量,下一个是指向常量索引数组的指针,最后是指向常量值数组的指针。

专门化 SPIR-V 着色器的过程类似于编译 GLSL 着色器。在调用glSpecializeShader之前,或者如果专门化失败,编译状态将是GL_FALSE。如果专门化成功,编译状态将是GL_TRUE。与 GLSL 着色器一样,我们可以查询着色器信息日志以获取详细的错误消息(参见编译着色器配方)。

更多...

SPIR-V 似乎将成为 Vulkan/OpenGL 空间中着色器编程的未来。然而,GLSL 并不会在短时间内消失。GLSL 编译器仍然与 OpenGL 一起提供,目前没有任何迹象表明它们将被移除或弃用。OpenGL 规范仍然将 GLSL 视为主要的着色语言。

然而,如果你想在 SPIR-V 早期加入,或者你对转向 Vulkan 感兴趣,那么现在在 OpenGL 中开始使用 SPIR-V 可能对你来说是有价值的。幸运的是,至少在 OpenGL 的最近版本中,这是可能的。

SPIR-V 的未来非常光明。已经有一个(大部分完成)针对 SPIR-V 的 HLSL 编译器,并且很可能很快会有其他语言被开发出来。这是着色器编程的激动人心时刻!

参见

  • 示例代码中的 chapter01/scenebasic.cpp 文件

  • 编译着色器 菜单

  • 链接着色器程序 菜单

第二章:与 GLSL 程序一起工作

在本章中,我们将介绍以下食谱:

  • 使用顶点属性和顶点缓冲区对象向着色器发送数据

  • 获取活动顶点输入属性和位置的列表

  • 使用统一变量向着色器发送数据

  • 获取活动统一变量的列表

  • 使用统一块和统一缓冲区对象

  • 使用程序管线

  • 获取调试信息

  • 构建一个 C++着色器程序类

简介

在第一章“GLSL 入门”中,我们介绍了编译、链接和导出着色器程序的基础知识。在本章中,我们将介绍着色器程序与主机 OpenGL 程序之间通信的技术。更具体地说,我们将主要关注输入。着色器程序的输入通常通过属性和统一变量来实现。在本章中,我们将看到这些类型变量使用的几个示例。我们还将介绍混合和匹配着色器程序阶段的食谱,以及创建 C++着色器程序对象的食谱。

本章不会涉及着色器输出。显然,着色器程序将它们的输出发送到默认帧缓冲区,但还有几种其他技术可以接收着色器输出。例如,使用自定义帧缓冲区对象允许我们将着色器输出存储到纹理或其他缓冲区。一种称为变换反馈的技术允许将顶点着色器输出存储到任意缓冲区。你将在本书后面的部分看到这些输出技术的许多示例。

使用顶点属性和顶点缓冲区对象向着色器发送数据

顶点着色器对每个顶点调用一次。其主要任务是处理与顶点相关的数据,并将其(以及可能的其他信息)传递到管道的下一阶段。为了给我们的顶点着色器提供一些可以操作的数据,我们必须有一种方式向着色器提供(每个顶点的)输入。通常,这包括顶点位置、法向量以及纹理坐标(以及其他内容)。在 OpenGL 的早期版本(3.0 之前),每个顶点信息在管道中都有一个特定的通道。它通过glVertexglTexCoordglNormal等函数提供给着色器(或者使用客户端顶点数组中的glVertexPointerglTexCoordPointerglNormalPointer)。然后着色器通过内置变量如gl_Vertexgl_Normal访问这些值。这种功能在 OpenGL 3.0 中被弃用,并在之后的版本中删除。相反,现在必须使用通用顶点属性来提供顶点信息,通常与(顶点)缓冲对象一起使用。程序员现在可以自由定义一组任意的顶点属性,将其作为输入提供给顶点着色器。例如,为了实现法线贴图,程序员可能会决定应该提供位置、法向量和切向量作为每个顶点的附加信息。在 OpenGL 4 中,很容易将这些定义为输入属性集。这为我们提供了很大的灵活性,可以以任何适合我们应用的方式定义顶点信息,但对于习惯于旧方法的人来说可能需要一些适应。

在顶点着色器中,使用in GLSL 限定符定义了每个顶点的输入属性。例如,为了定义一个名为VertexColor的三分量向量输入属性,我们使用以下代码:

in vec3 VertexColor; 

当然,这个属性的数据必须由 OpenGL 程序提供。为此,我们使用顶点缓冲对象。缓冲对象包含输入属性的值。在主要的 OpenGL 程序中,我们建立缓冲区和输入属性之间的连接,并定义如何遍历数据。然后,在渲染时,OpenGL 从缓冲区中为每个顶点着色器的调用提取输入属性的数据。

对于这个示例,我们将绘制一个三角形。我们的顶点属性将是位置和颜色。我们将使用片段着色器将每个顶点的颜色在三角形上混合,以产生一个类似于以下所示图像的效果。三角形的顶点是红色、绿色和蓝色,三角形的内部是这三种颜色的混合。这些颜色可能在打印的文本中不可见,但阴影的变化应该表明了混合:

图片

准备工作

我们将从一个空的 OpenGL 程序开始,并使用以下着色器:

顶点着色器(basic.vert.glsl):

#version 460 

layout (location=0) in vec3 VertexPosition; 
layout (location=1) in vec3 VertexColor; 

out vec3 Color; 

void main() 
{ 
  Color = VertexColor; 

  gl_Position = vec4(VertexPosition,1.0); 
}

属性是顶点着色器的输入变量。在前面的代码中,有两个输入属性:VertexPositionVertexColor。它们使用in GLSL 关键字指定。不用担心layout前缀,我们稍后会讨论这一点。我们的主 OpenGL 程序需要为每个顶点提供这两个属性的数据。我们将通过将我们的多边形数据映射到这些变量来实现这一点。

它还有一个输出变量,名为Color,该变量被发送到片段着色器。在这种情况下,Color只是VertexColor的一个未更改的副本。此外,请注意,VertexPosition属性只是简单地扩展并传递给内置的gl_Position输出变量以进行进一步处理。

片段着色器(basic.frag.glsl):

#version 460 

in vec3 Color;
out vec4 FragColor; 

void main() {
  FragColor = vec4(Color, 1.0); 
} 

该着色器只有一个输入变量,即Color。它链接到顶点着色器中的相应输出变量,并将包含一个基于顶点值的插值值。我们只需简单地扩展并复制此颜色到FragColor输出变量(有关片段着色器输出变量的更多信息,请参阅后续配方)。

编写代码来编译和链接这些着色器到着色器程序中(请参阅编译着色器链接着色器程序配方)。在以下代码中,我将假设着色器程序的句柄为programHandle

如何做到这一点...

使用以下步骤设置缓冲区对象并渲染三角形:

  1. 创建一个全局(或私有实例)变量来保存我们的顶点数组对象句柄:
GLuint vaoHandle;
  1. 在初始化函数中,我们为每个属性创建并填充顶点缓冲区对象:
float positionData[] = { 
      -0.8f, -0.8f, 0.0f, 
      0.8f, -0.8f, 0.0f, 
      0.0f,  0.8f, 0.0f }; 
float colorData[] = { 
      1.0f, 0.0f, 0.0f, 
      0.0f, 1.0f, 0.0f, 
      0.0f, 0.0f, 1.0f }; 

// Create and populate the buffer objects 
GLuint vboHandles[2]; 
glGenBuffers(2, vboHandles); 
GLuint positionBufferHandle = vboHandles[0]; 
GLuint colorBufferHandle = vboHandles[1]; 

// Populate the position buffer 
glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle); 
glBufferData(GL_ARRAY_BUFFER, 9 * sizeof(float), 
       positionData, GL_STATIC_DRAW); 

// Populate the color buffer 
glBindBuffer(GL_ARRAY_BUFFER, colorBufferHandle); 
glBufferData(GL_ARRAY_BUFFER, 9 * sizeof(float), colorData, 
       GL_STATIC_DRAW);
  1. 创建并定义一个顶点数组对象,该对象定义了缓冲区与输入属性之间的关系(有关另一种有效于 OpenGL 4.3 及以后版本的方法,请参阅更多内容...):
// Create and set-up the vertex array object 
glGenVertexArrays( 1, &vaoHandle ); 
glBindVertexArray(vaoHandle); 

// Enable the vertex attribute arrays 
glEnableVertexAttribArray(0);  // Vertex position 
glEnableVertexAttribArray(1);  // Vertex color 

// Map index 0 to the position buffer 
glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle); 
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL); 

// Map index 1 to the color buffer 
glBindBuffer(GL_ARRAY_BUFFER, colorBufferHandle); 
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, NULL);
  1. 在渲染函数中,绑定到顶点数组对象并调用glDrawArrays以启动渲染:
glBindVertexArray(vaoHandle); 
glDrawArrays(GL_TRIANGLES, 0, 3 ); 

它是如何工作的...

顶点属性是我们顶点着色器的输入变量。在给定的顶点着色器中,我们的两个属性是VertexPositionVertexColor。主 OpenGL 程序通过将每个(活动)输入变量与一个通用属性索引关联来引用顶点属性。这些通用索引是介于0GL_MAX_VERTEX_ATTRIBS - 1之间的简单整数。我们可以使用layout限定符来指定这些索引与属性之间的关系。例如,在我们的顶点着色器中,我们使用layout限定符将VertexPosition分配给属性索引0,将VertexColor分配给属性索引1

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexColor; 

我们通过引用相应的通用顶点属性索引来在我们的 OpenGL 代码中引用顶点属性。

并非严格必要显式指定属性变量和通用属性索引之间的映射,因为 OpenGL 在程序链接时会自动将活动顶点属性映射到通用索引。然后我们可以查询这些映射并确定与着色器输入变量对应的索引。然而,显式指定映射可能更清晰,正如我们在本例中所做的那样。

第一步涉及设置一对缓冲区对象以存储我们的位置和颜色数据。与大多数 OpenGL 对象一样,我们首先创建对象,并通过调用glGenBuffers获取两个缓冲区的句柄。然后,我们将每个句柄分配给单独的描述性变量,以使以下代码更清晰。

对于每个缓冲区对象,我们首先通过调用glBindBuffer将缓冲区绑定到GL_ARRAY_BUFFER绑定点。glBindBuffer的第一个参数是目标绑定点。在这种情况下,由于数据本质上是一个通用数组,我们使用GL_ARRAY_BUFFER。其他类型的目标(如GL_UNIFORM_BUFFERGL_ELEMENT_ARRAY_BUFFER)的示例将在后续示例中看到。

一旦我们的缓冲区对象被绑定,我们可以通过调用glBufferData来填充缓冲区,其中包含我们的顶点/颜色数据。此函数的第二个和第三个参数是数组的尺寸以及包含数据的数组的指针。让我们关注第一个和最后一个参数。第一个参数指示目标缓冲区对象。第三个参数提供的数据被复制到绑定到此绑定点的缓冲区中。最后一个参数提供了一个 OpenGL 如何使用数据的提示,以便它可以确定如何最好地在内部管理缓冲区。有关此参数的完整详细信息,请参阅 OpenGL 文档。在我们的情况下,数据只指定一次,不会修改,并且将被多次用于绘图操作,因此这种使用模式最适合GL_STATIC_DRAW值。

现在我们已经设置了缓冲区对象,我们将它们绑定在一起形成一个顶点数组对象VAO)。VAO 包含关于我们缓冲区中的数据与输入顶点属性之间连接的信息。我们使用glGenVertexArrays函数创建一个 VAO。这为我们提供了对新对象的句柄,我们将其存储在vaoHandle(全局)变量中。然后,通过调用glEnableVertexAttribArray启用通用顶点属性索引01。这样做表示将访问和使用属性值进行渲染。

下一步是建立缓冲区对象和通用顶点属性索引之间的连接:

// Map index 0 to the position buffer 
glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle); 
glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, 0, NULL ); 

首先,我们将缓冲对象绑定到GL_ARRAY_BUFFER绑定点,然后调用glVertexAttribPointer,这告诉 OpenGL 数据应该使用哪个通用索引,缓冲对象中存储的数据格式,以及它在绑定到GL_ARRAY_BUFFER绑定点的缓冲对象中的位置。

第一个参数是通用属性索引。第二个参数是每个顶点属性组件的数量(1、2、3 或 4)。在这种情况下,我们提供三维数据,因此我们希望每个顶点有三个组件。第三个参数是缓冲区中每个组件的数据类型。第四个是一个布尔值,指定数据是否应该自动归一化(对于有符号整数值映射到[-1, 1]或对于无符号整数值映射到[0, 1])。第五个参数是步长,表示连续属性之间的字节偏移量。由于我们的数据是紧密打包的,我们可以使用零值。最后一个参数是一个指针,它不被视为指针!相反,其值被解释为从缓冲区开始到缓冲区中第一个属性的字节偏移量。在这种情况下,在第一个元素之前,两个缓冲区中都没有额外的数据,所以我们使用零值。

glVertexAttribPointer函数将(在 VAO 的状态中)存储当前绑定到GL_ARRAY_BUFFER绑定点的缓冲区的指针。当另一个缓冲区绑定到该绑定点时,它不会改变指针的值。

VAO 存储了与缓冲对象和通用顶点属性之间的关系相关的所有 OpenGL 状态,以及缓冲对象中数据格式的信息。这使得我们可以在渲染时快速返回所有这些状态。

VAO(顶点数组对象)是一个极其重要的概念,但理解起来可能有些棘手。重要的是要记住,VAO 的状态主要与启用的属性及其与缓冲对象的关系相关联。它不一定跟踪缓冲区绑定。例如,它不会记住绑定到GL_ARRAY_BUFFER绑定点的具体内容。我们只绑定到这个点是为了通过glVertexAttribPointer设置指针。

一旦我们设置了 VAO(这是一个一次性操作),我们就可以发出绘制命令来渲染我们的对象。在我们的渲染函数中,我们使用glClear清除颜色缓冲区,绑定到顶点数组对象,并调用glDrawArrays来绘制我们的三角形。glDrawArrays函数通过遍历每个启用的属性数组缓冲区来初始化原语渲染,并将数据传递到我们的顶点着色器。第一个参数是渲染模式(在这种情况下,我们正在绘制三角形),第二个参数是启用数组中的起始索引,第三个参数是要渲染的索引数(对于单个三角形是三个顶点)。

总结一下,我们遵循了以下步骤:

  1. 确保在顶点着色器中使用 layout 限定符指定每个属性的通用顶点属性索引。

  2. 为每个属性创建和填充缓冲区对象。

  3. 通过在适当的缓冲区绑定时调用 glVertexAttribPointer 来创建和定义顶点数组对象。

  4. 在渲染时,绑定到顶点数组对象并调用 glDrawArrays 或其他适当的渲染函数(例如,glDrawElements)。

还有更多...

在下一节中,我们将讨论一些关于先前技术的细节、扩展和替代方案。

分离属性格式。

使用 OpenGL 4.3,我们有另一种(可以说是更好的)指定顶点数组对象状态(属性格式、启用的属性和缓冲区)的方法。在先前的例子中,glVertexAttribPointer 函数做了两件重要的事情。首先,它间接指定了包含属性数据的缓冲区是当前绑定到 GL_ARRAY_BUFFER 的缓冲区。其次,它指定了该数据的格式(类型、偏移量、步长等)。

将这两个关注点分别放入它们自己的函数中可能更清晰。这正是 OpenGL 4.3 中实现的方式。例如,为了实现先前 如何做... 部分的第 3 步相同的功能,我们会使用以下代码:

glGenVertexArrays(1, &vaoHandle); 
glBindVertexArray(vaoHandle); 
glEnableVertexAttribArray(0); 
glEnableVertexAttribArray(1); 

glBindVertexBuffer(0, positionBufferHandle, 0, sizeof(GLfloat)*3); 
glBindVertexBuffer(1, colorBufferHandle, 0, sizeof(GLfloat)*3); 

glVertexAttribFormat(0, 3, GL_FLOAT, GL_FALSE, 0); 
glVertexAttribBinding(0, 0); 
glVertexAttribFormat(1, 3, GL_FLOAT, GL_FALSE, 0); 
glVertexAttribBinding(1, 1); 

上一段代码的前四行与第一个例子中的完全相同。我们创建并绑定到 VAO,然后启用属性 01。接下来,我们使用 glBindVertexBuffer 将我们的两个缓冲区绑定到顶点缓冲区绑定点内的两个不同索引。注意,我们不再使用 GL_ARRAY_BUFFER 绑定点。相反,我们现在有一个专门用于顶点缓冲区的新绑定点。这个绑定点有几个索引(通常从 0 到 15),因此我们可以将多个缓冲区绑定到这个点。glBindVertexBuffer 的第一个参数指定了顶点缓冲区绑定点内的索引。在这里,我们将位置缓冲区绑定到索引 0,将颜色缓冲区绑定到索引 1

顶点缓冲区绑定点内的索引不必与属性位置相同。

glBindVertexBuffer 的其他参数如下。第二个参数是要绑定的缓冲区,第三个参数是从缓冲区开始到数据开始的偏移量,第四个参数是步长,即缓冲区内连续元素之间的距离。与 glVertexAttribPointer 不同,我们在这里不能使用 0 值来表示紧密打包的数据,因为 OpenGL 没有更多信息无法确定数据的大小,因此我们需要在这里明确指定它。

接下来,我们调用 glVertexAttribFormat 来指定属性数据的格式。注意,这次,这已经与存储数据的缓冲区解耦。我们只是指定了期望此属性应具有的格式。参数与 glVertexAttribPointer 的前四个参数相同。

glVertexAttribBinding函数指定了绑定到顶点缓冲区绑定点的缓冲区与属性之间的关系。第一个参数是属性位置,第二个参数是顶点缓冲区绑定点内的索引。在这个例子中,它们是相同的,但它们不需要是相同的。

还要注意,顶点缓冲区绑定点的缓冲区绑定(由glBindVertexBuffer指定)是 VAO 状态的一部分,与绑定到GL_ARRAY_BUFFER不同。

这个版本可以说是更清晰、更容易理解。它去除了 VAO 中管理的不可见指针的混淆之处,并通过glVertexAttribBinding使属性与缓冲区之间的关系更加清晰。此外,它还分离了实际上不需要组合的关心点。

片段着色器输出

你可能已经注意到,我在片段着色器中忽略了关于FragColor输出变量的任何说明。这个变量接收每个片段(像素)的最终输出颜色。像顶点输入变量一样,这个变量需要与一个适当的位置关联。当然,我们通常希望它与后台颜色缓冲区链接,默认情况下(在双缓冲系统中)是“颜色编号”零。(可以通过使用glDrawBuffers来改变颜色编号与渲染缓冲区之间的关系。)在这个程序中,我们依赖于链接器会自动将我们的唯一片段输出变量链接到颜色编号零。为了明确这样做,我们可以在片段着色器中使用布局限定符:

layout (location = 0) out vec4 FragColor;

我们可以为片段着色器定义多个输出变量,从而使我们能够渲染到多个输出缓冲区。这对于像延迟渲染(见第六章,图像处理和屏幕空间技术)这样的专用算法非常有用。

不使用布局限定符指定属性索引

如果你不想在顶点着色器代码中添加layout限定符(或者你使用的是不支持它们的 OpenGL 版本),你可以在 OpenGL 程序中定义属性索引。我们可以通过在链接着色器程序之前调用glBindAttribLocation来实现这一点。例如,我们会在链接步骤之前向主 OpenGL 程序添加以下代码:

glBindAttribLocation(programHandle, 0, "VertexPosition"); 
glBindAttribLocation(programHandle, 1, "VertexColor"); 

这将指示链接器,VertexPosition应对应于通用属性索引0,而VertexColor对应于索引1

类似地,我们可以在不使用布局限定符的情况下指定片段着色器输出变量的颜色编号。我们通过在链接着色器程序之前调用glBindFragDataLocation来实现这一点:

glBindFragDataLocation(programHandle, 0, "FragColor"); 

这将告诉链接器将FragColor输出变量绑定到颜色编号0

使用元素数组

通常情况下,我们需要以非线性方式遍历我们的顶点数组。换句话说,我们可能想要在数据中“跳转”而不是仅仅从开始到结束移动。例如,我们可能想要绘制一个立方体,其中顶点数据仅由八个位置组成(立方体的角)。为了绘制立方体,我们需要绘制 12 个三角形(每个面两个),每个三角形由三个顶点组成。所有所需的位置数据都在原始的八个位置中,但为了绘制所有三角形,我们需要跳转并至少使用每个位置三次。

为了在顶点数组中跳转,我们可以利用元素数组。元素数组是另一个缓冲区,它定义了遍历顶点数组时使用的索引。有关使用元素数组的详细信息,请参阅 OpenGL 文档中的glDrawElements函数(www.opengl.org/sdk/docs/man)。

交错数组

在这个例子中,我们使用了两个缓冲区(一个用于颜色,一个用于位置)。相反,我们也可以只使用一个缓冲区并将所有数据合并。一般来说,可以将多个属性的数据合并到一个缓冲区中。多个属性的数据可以在数组中交错,这样给定顶点的所有数据都将在缓冲区中分组在一起。这样做只需要仔细使用glVertexAttribPointerglBindVertexBufferstride参数。请参阅完整文档以获取详细信息(www.opengl.org/sdk/docs/man)。

关于何时使用交错数组以及何时使用单独数组的决定高度依赖于具体情况。由于数据是同时访问且在内存中更接近(所谓的引用局部性),交错数组可能会带来更好的结果,从而实现更好的缓存性能。

参见

  • 示例代码中的chapter02/scenebasic_attrib.cpp文件

获取活动顶点输入属性和位置的列表

如前所述,顶点着色器中的输入变量在程序链接时与通用的顶点属性索引相链接。如果我们需要指定关系,我们可以在着色器中使用布局限定符,或者在链接之前调用glBindAttribLocation

然而,可能更倾向于让链接器自动创建映射,并在程序链接完成后查询它们。在这个例子中,我们将看到一个简单的示例,该示例打印出所有活动属性及其索引。

准备工作

从一个编译并链接了着色器对的 OpenGL 程序开始。您可以使用前一个示例中的着色器。

如前所述,我们将假设着色器程序的句柄存储在一个名为programHandle的变量中。

如何实现...

在链接和启用着色器程序后,使用以下代码来显示活动属性列表:

  1. 首先查询活动属性的数目:
GLint numAttribs; 
glGetProgramInterfaceiv(programHandle, GL_PROGRAM_INPUT,
        GL_ACTIVE_RESOURCES, &numAttribs);
  1. 遍历每个属性,查询名称长度、类型和属性位置,并将结果打印到标准输出:
GLenum properties[] = {GL_NAME_LENGTH, GL_TYPE, GL_LOCATION};

std::cout << "Active attributes" << std::endl; 
for( int i = 0; i < numAttribs; ++i ) { 
  GLint results[3]; 
  glGetProgramResourceiv(programHhandle, GL_PROGRAM_INPUT,
        i, 3, properties, 3, NULL, results); 

  GLint nameBufSize = results[0] + 1; 
  char * name = new char[nameBufSize]; 
  glGetProgramResourceName(programHandle, 
       GL_PROGRAM_INPUT, i, nameBufSize, NULL, name);  
  printf("%-5d %s (%s)n", results[2], name, 
  getTypeString(results[1])); 
  delete [] name; 
}

它是如何工作的...

在步骤 1 中,我们通过调用glGetProgramInterfaceiv来查询活动属性的数目。第一个参数是程序对象的句柄,第二个参数(GL_PROGRAM_INPUT)表示我们正在查询程序输入变量(顶点属性)的信息。第三个参数(GL_ACTIVE_RESOURCES)表示我们想要得到活动资源的数目。结果存储在最后一个参数numAttribs指向的位置。

现在我们已经得到了属性的数目,接下来我们查询每个属性的信息。属性的索引从0numAttribs-1。我们遍历这些索引,并对每个索引调用glGetProgramResourceiv来获取名称长度、类型和位置。我们通过一个名为propertiesGLenum值数组来指定我们想要接收的信息。第一个参数是程序对象的句柄,第二个是我们要查询的资源(GL_PROGRAM_INPUT)。第三个是属性的索引,第四个是properties数组中的值数,这是第五个参数。properties数组包含GLenum值,这些值指定了我们想要接收的具体属性。在这个例子中,数组包含GL_NAME_LENGTHGL_TYPEGL_LOCATION,这表示我们想要得到属性名称的长度、属性的数据类型以及它的位置。第六个参数是接收结果的缓冲区的大小;第七个参数是一个指向整数的指针,该整数将接收写入的结果数目。如果该参数是NULL,则不提供任何信息。最后,最后一个参数是一个指向GLint数组的指针,该数组将接收结果。properties数组中的每个项对应于results数组中的相同索引。

接下来,我们通过分配一个缓冲区来存储名称并调用glGetProgramResourceName来检索属性名称。results数组的第一元素包含名称的长度,因此我们分配一个大小为该数组的数组,并额外分配一个字符以确保安全。OpenGL 文档说明,从glGetProgramResourceiv返回的大小包括空终止符,但为了确保这一点,我们留出一些额外的空间。在我的测试中,我发现这对于最新的 NVIDIA 驱动程序是必要的。

最后,通过调用glGetProgramResourceName获取名称,然后将信息打印到屏幕上。我们打印属性的位置、名称和类型。位置在results数组的第三个元素中,类型在第二个元素中。注意getTypeString函数的使用。这是一个简单的自定义函数,它只返回数据类型的字符串表示。数据类型由 OpenGL 定义的常量表示,例如GL_FLOATGL_FLOAT_VEC2GL_FLOAT_VEC3getTypeString函数仅包含一个大的switch语句,返回与参数值对应的人类可读字符串(请参阅本书示例代码中glslprogram.cpp的源代码)。

当在先前的配方中的着色器上运行先前代码的输出如下所示:

    Active attributes:
    1    VertexColor (vec3)
    0    VertexPosition (vec3)

更多内容...

应注意,为了使顶点着色器输入变量被视为活动状态,它必须在顶点着色器中使用。换句话说,如果 GLSL 链接器确定变量在程序执行期间可能被访问,则该变量被视为活动状态。如果一个变量在着色器中声明但未使用,则前述代码不会显示该变量,因为它不被视为活动状态,并且实际上被 OpenGL 忽略。

前述代码仅适用于 OpenGL 4.3 及更高版本。或者,您可以使用glGetProgramivglGetActiveAttribglGetAttribLocation函数实现类似的结果。

参见

  • 示例代码中的chapter02/scenebasic_attrib.cpp文件

  • 编译着色器的配方

  • 链接着色器程序的配方

  • 使用顶点属性和顶点缓冲对象向着色器发送数据的配方

使用统一变量向着色器发送数据

顶点属性提供了一种向着色器提供输入的方法;第二种技术是统一变量。统一变量旨在用于与每个顶点属性相比相对不经常变化的数据。实际上,使用统一变量设置每个顶点的属性是不可能的。例如,统一变量非常适合用于建模、视图和投影变换的矩阵。

在着色器内部,统一变量是只读的。它们的值只能通过 OpenGL API 从外部更改。然而,它们可以在声明时在着色器内部初始化,通过将它们分配给一个常量值。

统一变量可以出现在着色器程序中的任何着色器中,并且始终用作输入变量。它们可以在程序中的一个或多个着色器中声明,但如果一个变量在多个着色器中声明,其类型必须在所有着色器中相同。换句话说,统一变量在整个着色器程序中保存在一个共享的统一命名空间中。

在这个食谱中,我们将绘制本章之前食谱中相同的三角形;然而,这次,我们将使用统一矩阵变量旋转三角形:

图片

准备工作

我们将使用以下顶点着色器:

#version 430 

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexColor; 

out vec3 Color; 

uniform mat4 RotationMatrix; 

void main() { 
  Color = VertexColor; 
  gl_Position = RotationMatrix * vec4(VertexPosition,1.0); 
} 

注意,RotationMatrix变量是使用统一限定符声明的。我们将通过 OpenGL 程序提供这个变量的数据。RotationMatrix变量也用于在将VertexPosition分配给默认输出位置变量gl_Position之前对其进行变换。

我们将使用与之前食谱相同的片段着色器:

#version 460 

in vec3 Color; 

layout (location = 0) out vec4 FragColor; 

void main() { 
  FragColor = vec4(Color, 1.0); 
} 

在主 OpenGL 代码中,我们确定旋转矩阵并将其发送到着色器的统一变量。为了创建我们的旋转矩阵,我们将使用 GLM 库(参见使用 GLM 进行数学食谱)。在主 OpenGL 代码中,添加以下包含语句:

#include <glm/glm.hpp> 
#include <glm/gtc/matrix_transform.hpp> 

我们还假设已经编写了编译和链接着色器以及创建颜色三角形顶点数组对象的代码。我们假设顶点数组对象的句柄是vaoHandle,程序对象的句柄是programHandle

如何实现...

在渲染方法中,使用以下代码:

glClear(GL_COLOR_BUFFER_BIT); 

glm::mat4 rotationMatrix = glm::rotate(glm::mat4(1.0f), angle, 
       glm::vec3(0.0f,0.0f,1.0f));
GLuint location = glGetUniformLocation(programHandle,
       "RotationMatrix"); 

if( location >= 0 ) { 
  glUniformMatrix4fv(location, 1, GL_FALSE, glm::value_ptr(rotationMatrix)); 
} 

glBindVertexArray(vaoHandle); 
glDrawArrays(GL_TRIANGLES, 0, 3 ); 

它是如何工作的...

设置统一变量值的步骤包括找到变量的位置,并使用glUniform函数之一将该值分配到该位置。

在这个例子中,我们首先清除颜色缓冲区,然后使用 GLM 创建一个旋转矩阵。接下来,通过调用glGetUniformLocation查询统一变量的位置。这个函数接收着色程序对象的句柄和统一变量的名称,并返回其位置。如果统一变量不是一个活动的统一变量,该函数返回-1

在每一帧中查询统一变量的位置是不高效的。一个更高效的方法是在着色器编译阶段缓存位置,并在这里使用它。

然后,我们使用glUniformMatrix4fv将值分配给统一变量的位置。第一个参数是统一变量的位置。第二个是正在分配的矩阵数量(注意,统一变量可能是一个数组)。第三个是一个布尔值,指示在加载到统一变量时矩阵是否应该被转置。对于 GLM 矩阵,不需要转置,所以我们在这里使用GL_FALSE。如果您使用数组实现矩阵,并且数据是按行主序排列的,您可能需要为这个参数使用GL_TRUE。最后一个参数是指向统一变量数据的指针。

更多内容...

当然,统一变量可以是任何有效的 GLSL 类型,包括数组或结构体等复杂类型。OpenGL 提供了一个带有常用后缀的 glUniform 函数,适用于每种类型。例如,要将值分配给 vec3 类型的变量,可以使用 glUniform3fglUniform3fv

对于数组,可以使用以 v 结尾的函数在数组内部初始化多个值。请注意,如果需要,可以使用 [] 操作符查询统一数组的特定元素的位置。例如,要查询 MyArray 的第二个元素的位置:

GLuint location = 
   glGetUniformLocation( programHandle, "MyArray[1]" );

对于结构体,结构体的成员必须单独初始化。与数组一样,可以使用类似以下方式查询结构体成员的位置:

GLuint location = 
   glGetUniformLocation( programHandle, "MyMatrices.Rotation" );

当结构变量是 MyMatrices 且结构体成员是 Rotation 时。

参见:

  • 示例代码中的 chapter02/scenebasic_uniform.cpp 文件

  • 编译着色器 配方

  • 链接着色器程序 配方

  • 使用顶点属性和顶点缓冲对象发送数据到着色器 的配方

获取活动统一变量列表

虽然查询单个统一变量位置的过程很简单,但可能存在一些情况下生成所有活动统一变量的列表是有用的。例如,有人可能会选择创建一组变量来存储每个统一变量的位置,并在程序链接后分配它们的值。这将避免在设置统一变量值时查询统一变量位置的需要,从而创建略微更高效的代码。

列出统一变量的过程与列出属性的过程非常相似(参见 获取活动顶点输入属性和位置列表 配方),因此本配方将引导读者回顾先前的配方以获取详细解释。

准备工作

从一个基本的 OpenGL 程序开始,该程序编译并链接一个着色器程序。在接下来的配方中,我们将假设程序句柄存储在一个名为 programHandle 的变量中。

如何操作...

在链接并启用着色器程序后,使用以下代码来显示活动统一变量的列表:

  1. 首先查询活动统一变量的数量:
GLint numUniforms = 0; 
glGetProgramInterfaceiv( handle, GL_UNIFORM, 
   GL_ACTIVE_RESOURCES, &numUniforms);
  1. 遍历每个统一索引并查询名称长度、类型、位置和块索引:
GLenum properties[] = {GL_NAME_LENGTH, GL_TYPE, 
   GL_LOCATION, GL_BLOCK_INDEX}; 

std::cout << "Active uniforms" << std::endl; 
for( int i = 0; i < numUniforms; ++i ) { 
  GLint results[4]; 
  glGetProgramResourceiv(handle, GL_UNIFORM, i, 4, 
       properties, 4, NULL, results); 
  if( results[3] != -1 )  
        continue;    // Skip uniforms in blocks  
  GLint nameBufSize = results[0] + 1; 
  char * name = new char[nameBufSize]; 
  glGetProgramResourceName(handle, GL_UNIFORM, i, 
       nameBufSize, NULL, name);
  printf("%-5d %s (%s)n", results[2], name, 
       getTypeString(results[1])); 
  delete [] name; 
} 

它是如何工作的...

该过程与 获取活动顶点输入属性和位置列表 配方中显示的过程非常相似。我将专注于主要差异。

第一也是最明显的是,我们在glGetProgramResourceivglGetProgramInterfaceiv中查询的接口使用GL_UNIFORM而不是GL_PROGRAM_INPUT。其次,我们查询块索引(在properties数组中使用GL_BLOCK_INDEX)。这样做的原因是某些统一变量包含在统一块中(见使用统一块和统一缓冲对象配方)。对于这个例子,我们只想了解不在块中的统一变量的信息。如果统一变量不在块中,块索引将是-1,所以我们跳过任何没有块索引为-1的统一变量。

再次,我们使用getTypeString函数将类型值转换为人类可读的字符串(见示例代码)。

当从这个先前的配方中的着色器程序运行时,我们看到了以下输出:

    Active uniforms:
    0    RotationMatrix (mat4)

还有更多...

与顶点属性一样,除非 GLSL 链接器确定它将在着色器中使用,否则统一变量不被视为活动状态。

之前的代码仅适用于 OpenGL 4.3 及以后版本。或者,您可以使用glGetProgramivglGetActiveUniformglGetUniformLocationglGetActiveUniformName函数实现类似的结果。

参见

  • 示例代码中的chapter02/scenebasic_uniform.cpp文件

  • 使用统一变量将数据发送到着色器配方

使用统一块和统一缓冲对象

如果你的程序涉及多个使用相同统一变量的着色器程序,必须为每个程序分别管理变量。统一位置在程序链接时生成,因此统一位置可能会从一个程序变化到下一个程序。这些统一变量的数据可能需要重新生成并应用到新的位置。

统一块被设计用来简化程序间统一数据的共享。使用统一块,可以创建一个缓冲对象来存储所有统一变量的值,并将缓冲绑定到统一块上。当改变程序时,只需将相同的缓冲对象重新绑定到新程序中相应的块。

统一块简单地说是在称为统一块的语法结构内定义的一组统一变量。例如,在这个配方中,我们将使用以下统一块:

uniform BlobSettings { 
  vec4 InnerColor; 
  vec4 OuterColor; 
  float RadiusInner; 
  float RadiusOuter; 
}; 

这定义了一个名为BlobSettings的块,其中包含四个统一变量。使用这种类型的块定义,块内的变量仍然是全局作用域的一部分,不需要用块名称限定。

用于存储统一变量数据的缓冲对象通常被称为统一缓冲对象。我们将看到统一缓冲对象只是一个绑定到特定位置的缓冲对象。

对于这个菜谱,我们将使用一个简单的示例来演示统一缓冲对象和统一块的使用。我们将绘制一个带有纹理坐标的四边形(两个三角形),并使用我们的片段着色器用模糊圆圈填充四边形。圆圈在中心是实色,但在边缘逐渐过渡到背景色,如图所示:

图片

准备工作

从一个绘制两个三角形以形成一个四边形的 OpenGL 程序开始。在顶点属性位置 0 提供位置,在顶点属性位置 1 提供纹理坐标(每个方向从 0 到 1)(参见 使用顶点属性和顶点缓冲对象向着色器发送数据 菜谱)。

我们将使用以下顶点着色器:

#version 430 

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexTexCoord; 

out vec3 TexCoord; 

void main() { 
  TexCoord = VertexTexCoord; 
  gl_Position = vec4(VertexPosition,1.0); 
} 

片段着色器包含统一块,并负责绘制我们的

模糊圆圈:

#version 430 

in vec3 TexCoord; 
layout (location = 0) out vec4 FragColor; 

layout (binding = 0) uniform BlobSettings { 
  vec4 InnerColor; 
  vec4 OuterColor; 
  float RadiusInner; 
  float RadiusOuter; 
}; 

void main() { 
  float dx = TexCoord.x - 0.5; 
  float dy = TexCoord.y - 0.5; 
  float dist = sqrt(dx * dx + dy * dy); 
  FragColor =
    mix( InnerColor, OuterColor,
       smoothstep( RadiusInner, RadiusOuter, dist )); 
} 

注意名为 BlobSettings 的统一块。该块内的变量定义了模糊圆圈的参数。OuterColor 变量定义了圆圈外的颜色。InnerColor 是圆圈内的颜色。RadiusInner 是定义圆圈中实色部分(模糊边缘内部)的半径,以及圆心到模糊边界内边缘的距离。RadiusOuter 是圆的模糊边界的边缘(当颜色等于 OuterColor 时)。

主函数内的代码计算纹理坐标到位于 (0.5, 0.5) 的四边形中心的距离。然后使用该距离通过 smoothstep 函数计算颜色。当第三个参数的值位于前两个参数的值之间时,该函数提供一个在 0.0 和 1.0 之间平滑变化的值。否则,它返回 0.01.0,具体取决于 dist 是否小于第一个或大于第二个。然后使用 mix 函数根据 smoothstep 函数返回的值在 InnerColorOuterColor 之间进行线性插值。

如何做到这一点...

在 OpenGL 程序中,在链接着色器程序后,使用以下步骤将数据分配给片段着色器中的统一块:

  1. 使用 glGetUniformBlockIndex 获取统一块的索引:
GLuint blockIndex = glGetUniformBlockIndex(programHandle, 
   "BlobSettings");
  1. 为缓冲区分配空间以包含统一块的数据。我们使用 glGetActiveUniformBlockiv 获取大小:
GLint blockSize; 
glGetActiveUniformBlockiv(programHandle, blockIndex,
        GL_UNIFORM_BLOCK_DATA_SIZE, &blockSize); 

GLubyte * blockBuffer; 
blockBuffer = (GLubyte *) malloc(blockSize); 
  1. 查询块中每个变量的偏移量。为此,我们首先找到块中每个变量的索引:
const GLchar *names[] = { "InnerColor", "OuterColor",
       "RadiusInner", "RadiusOuter" }; 
GLuint indices[4]; 
glGetUniformIndices(programHandle, 4, names, indices); 

GLint offset[4]; 
glGetActiveUniformsiv(programHandle, 4, indices, 
       GL_UNIFORM_OFFSET, offset);
  1. 将数据放置在缓冲区中适当的偏移量:
// Store data within the buffer at the appropriate offsets 
GLfloat outerColor[] = {0.0f, 0.0f, 0.0f, 0.0f}; 
GLfloat innerColor[] = {1.0f, 1.0f, 0.75f, 1.0f}; 
GLfloat innerRadius = 0.25f, outerRadius = 0.45f; 

memcpy(blockBuffer + offset[0], innerColor, 
       4 * sizeof(GLfloat));
memcpy(blockBuffer + offset[1], outerColor, 
       4 * sizeof(GLfloat));
memcpy(blockBuffer + offset[2], &innerRadius, 
       sizeof(GLfloat));
memcpy(blockBuffer + offset[3], &outerRadius, 
       sizeof(GLfloat));
  1. 创建缓冲对象并将数据复制到其中:
GLuint uboHandle; 
glGenBuffers( 1, &uboHandle ); 
glBindBuffer( GL_UNIFORM_BUFFER, uboHandle ); 
glBufferData( GL_UNIFORM_BUFFER, blockSize, blockBuffer, 
       GL_DYNAMIC_DRAW );
  1. 将缓冲对象绑定到由片段着色器中的绑定布局限定符指定的索引处的统一缓冲区绑定点(0):
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uboHandle); 

它是如何工作的...

哎呀!这似乎是很多工作!然而,真正的优势在于使用多个程序时,相同的缓冲区对象可以用于每个程序。让我们逐个步骤地看一下。

首先,我们通过调用glGetUniformBlockIndex来获取均匀块的索引,然后通过调用glGetActiveUniformBlockiv来查询块的大小。在获取大小之后,我们分配一个名为blockBuffer的临时缓冲区来存储我们块的数据。

均匀块内数据布局是实现的依赖,实现可能使用不同的填充和/或字节对齐。因此,为了准确地布局我们的数据,我们需要查询块中每个变量的偏移量。这是通过两个步骤完成的。首先,我们通过调用glGetUniformIndices查询块中每个变量的索引。这个函数接受一个names变量数组(第三个参数)并返回变量在indices数组(第四个参数)中的索引。然后,我们使用这些索引通过调用glGetActiveUniformsiv查询偏移量。当第四个参数是GL_UNIFORM_OFFSET时,这个函数返回指向第五个参数所指向的数组的每个变量的偏移量。这个函数也可以用来查询大小和类型,然而,在这种情况下我们选择不这样做,以保持代码简单(尽管不那么通用)。

下一步是填充我们的临时缓冲区blockBuffer,以包含均匀数据的适当偏移量。在这里,我们使用标准库函数memcpy来完成这个任务。

现在临时缓冲区已经填充了具有适当布局的数据,我们可以创建我们的缓冲区对象并将数据复制到缓冲区对象中。我们调用glGenBuffers来生成缓冲区句柄,然后通过调用glBindBuffer将缓冲区绑定到GL_UNIFORM_BUFFER绑定点。在调用glBufferData时,在缓冲区对象内分配空间并复制数据。我们在这里使用GL_DYNAMIC_DRAW作为使用提示,因为均匀数据在渲染过程中可能会被频繁更改。当然,这完全取决于具体情况。

最后,我们通过调用glBindBufferBase将缓冲区对象与均匀块关联起来。这个函数绑定到缓冲区绑定点内的一个索引。某些绑定点也被称为索引缓冲区目标。这意味着目标实际上是一个目标数组,而glBindBufferBase允许我们在数组中绑定到一个索引。在这种情况下,我们将其绑定到片段着色器中布局限定符中指定的索引:layout (binding = 0)(见准备就绪)。这两个索引必须匹配。

你可能想知道为什么我们使用 glBindBufferglBindBufferBaseGL_UNIFORM_BUFFER。这些不是在两个不同上下文中使用的相同绑定点吗?答案是 GL_UNIFORM_BUFFER 点可以在每个函数中使用,但含义略有不同。使用 glBindBuffer 时,我们绑定到一个可以用于填充或修改缓冲区的点,但不能用作着色器的数据源。当我们使用 glBindBufferBase 时,我们绑定到一个位置索引,该索引可以直接由着色器使用。当然,这有点令人困惑。

还有更多...

如果需要在稍后的某个时间改变统一块的数据,可以使用 glBufferSubData 调用来替换缓冲区内的全部或部分数据。如果你这样做,别忘了首先将缓冲区绑定到通用绑定点 GL_UNIFORM_BUFFER

使用统一块实例名称

统一块可以有一个可选的实例名称。例如,对于我们的 BlobSettings 块,我们可能使用了实例名称 Blob,如下所示:

uniform BlobSettings { 
  vec4 InnerColor; 
  vec4 OuterColor; 
  float RadiusInner; 
  float RadiusOuter; 
} Blob;

在这种情况下,块内的变量被放置在一个由实例名称限定的命名空间中。因此,我们的着色器代码需要使用实例名称作为前缀来引用它们。例如:

FragColor =
    mix( Blob.InnerColor, Blob.OuterColor,
        smoothstep( Blob.RadiusInner, Blob.RadiusOuter, dist ) 
  ); 

此外,当查询变量索引时,我们还需要在 OpenGL 代码中对变量名称(使用 BlobSettings 块名称)进行限定:

const GLchar *names[] = { "BlobSettings.InnerColor",  
      "BlobSettings.OuterColor", "BlobSettings. RadiusInner", 
      "BlobSettings.RadiusOuter" }; 
GLuint indices[4]; 
glGetUniformIndices(programHandle, 4, names, indices); 

使用统一块布局限定符

由于统一缓冲区对象内部的数据布局是依赖于实现的,这要求我们查询变量偏移量。然而,可以通过请求 OpenGL 使用标准布局 std140 来避免这种情况。这是通过在声明统一块时使用布局限定符来实现的。例如:

layout( std140 ) uniform BlobSettings { 

}; 

std140 布局在 OpenGL 规范文档中有详细描述(可在 www.opengl.org 获取)。

布局限定符的其他选项,适用于统一块布局,包括 packedsharedpacked 限定符简单地表示实现可以自由优化内存,以任何它认为必要的方式(基于变量使用或其他标准)。使用 packed 限定符时,我们仍然需要查询每个变量的偏移量。shared 限定符保证在多个程序和程序阶段之间,只要统一块声明没有改变,布局将保持一致。如果你计划在多个程序和/或程序阶段之间使用相同的缓冲区对象,使用 shared 选项是一个好主意。

值得一提的还有另外两个布局限定符:row_majorcolumn_major。这些限定符定义了在统一块中的矩阵类型变量内部数据排序。

一个块可以使用多个(非冲突的)限定符。例如,为了定义一个同时具有 row_majorshared 限定符的块,我们会使用以下语法:

layout( row_major, shared ) uniform BlobSettings { 
   // ...
}; 

参见

  • 示例代码中的chapter02/scenebasic_uniformblock.cpp文件

  • 使用统一变量将数据发送到着色器的配方

使用程序管线

程序管线对象作为可分离着色器对象扩展的一部分被引入,并在 OpenGL 4.1 版本中成为核心功能。它们允许程序员混合和匹配来自多个可分离着色器程序的着色器阶段。为了了解这是如何工作的以及为什么它可能有用,让我们通过一个假设的例子来探讨。

假设我们有一个顶点着色器和两个片段着色器。假设顶点着色器中的代码将能够与两个片段着色器正确地工作。我可以简单地创建两个不同的着色器程序,重用包含顶点着色器的 OpenGL 着色器对象。然而,如果顶点着色器有很多统一变量,那么每次我在两个着色器程序之间切换时,我可能需要重置一些或所有这些统一变量。这是因为统一变量是着色器程序状态的一部分,所以在一个程序中对统一变量的更改不会传递到另一个程序,即使两个程序共享同一个着色器对象。统一信息存储在着色器程序对象中,而不是着色器对象中。

着色器程序对象包括活动统一变量的值;这些信息不会存储在着色器对象中。

使用可分离的着色器对象,我们可以创建包含一个或多个着色器阶段的着色器程序。在扩展之前,我们被要求至少包含一个顶点着色器和片段着色器。这样的程序被称为可分离的,因为它们不一定与特定的其他阶段链接。它们可以在不同时间分离并链接到不同的阶段。可分离程序可以只包含一个阶段(或根据需要包含更多)。

使用程序管线,我们可以创建混合和匹配可分离程序阶段的管线。这使我们能够在切换其他阶段时避免丢失给定着色器阶段的统一变量状态。例如,在前面的场景中,我们有一个顶点着色器(着色器 A)和两个片段着色器(B 和 C),我们可以创建三个着色器程序,每个程序包含一个单一阶段。然后,我们可以创建两个管线。第一个管线将使用程序 A 中的顶点着色器和程序 B 中的片段着色器,第二个管线将使用程序 A 中的顶点着色器和程序 C 中的片段着色器。我们可以在两个管线之间切换,而不会丢失着色器阶段 A 中的任何统一变量状态,因为我们实际上并没有切换着色器程序——我们在两个管线中使用了同一个着色器程序(包含阶段 A)。

准备工作

对于这个配方,我们将继续使用前面的例子。我们需要一个单一的顶点着色器和两个兼容的片段着色器。让我们假设文件名是separable.vert.glslseparable1.frag.glslseparable2.frag.glsl

可分离的着色器要求你在使用其任何成员(例如gl_Position)时重新声明内置的gl_PerVertex输出块。由于你几乎总是需要使用其成员之一,你很可能需要将以下内容添加到你的顶点着色器中:

out gl_PerVertex {
  vec4 gl_Position;
  float gl_PointSize;
  float gl_ClipDistance[];
};

如何做...

首先通过将着色器文件加载到std::string中开始:

std::string vertCode  = loadShaderCode("separable.vert.glsl");
std::string fragCode1 = loadShaderCode("separable1.frag.glsl");
std::string fragCode2 = loadShaderCode("separable2.frag.glsl");

接下来,我们将使用glCreateShaderProgramv为每个创建一个着色器程序:

GLuint programs[3];
const GLchar * codePtrs = {vertCode.c_str(), fragCode1.c_str(),  
  fragCode2.c_str()};
programs[0] = glCreateShaderProgramv(GL_VERTEX_SHADER, 1, codePtrs);
programs[1] = glCreateShaderProgramv(GL_FRAGMENT_SHADER, 1, codePtrs + 1);
programs[2] = glCreateShaderProgramv(GL_FRAGMENT_SHADER, 1, codePtrs + 2);

// Check for errors...

现在,我们将创建两个程序管线。第一个将使用顶点着色器和第一个片段着色器,第二个将使用顶点着色器和第二个片段着色器:

GLuint pipelines[2];
glCreateProgramPipelines(2, pipelines);
// First pipeline
glUseProgramStages(pipelines[0], GL_VERTEX_SHADER_BIT, programs[0]);
glUseProgramStages(pipelines[0], GL_FRAGMENT_SHADER_BIT, programs[1]);
// Second pipeline
glUseProgramStages(pipelines[1], GL_VERTEX_SHADER_BIT, programs[0]);
glUseProgramStages(pipelines[1], GL_FRAGMENT_SHADER_BIT, programs[2]);

在可分离的着色器中设置统一变量时,建议使用glProgramUniform而不是glUniform。在使用可分离的着色器和程序管线时,确定哪个着色器阶段受glUniform函数的影响可能有点繁琐和棘手。glProgramUniform函数允许我们直接指定目标程序。例如,在这里,我们将设置顶点着色器程序(由两个管线共享)中的统一变量:

GLint location = glGetUniformLocation(programs[0], uniformName);
glProgramUniform3f(programs[0], location, 0, 1, 0);

为了渲染,我们首先需要确保当前没有绑定任何程序。如果有程序通过glUseProgram绑定,它将忽略任何程序管线:

glUseProgram(0);

现在,我们可以使用我们之前设置的管线:

glBindProgramPipeline(pipelines[0]);
// Draw...
glBindProgramPipeline(pipelines[1]);
// Draw...

它是如何工作的...

glCreateShaderProgramv函数提供了一个简单的方法来创建一个由单个阶段组成的可分离着色器程序。我们传递着色器阶段和代码字符串到创建着色器对象的函数,编译它,创建一个可分离的程序,附加着色器,并链接程序,然后返回新着色器程序的名字。我们应在每次调用后立即检查错误。所有错误都会在程序信息日志中。链接着色器程序配方详细说明了如何检查错误。

一旦我们有了着色器程序,我们就创建管线。我们使用glCreateProgramPipelines创建了两个管线对象。然后,我们使用glUseProgramStages为每个管线设置阶段。glUseProgramStages的第一个参数是管线名称,第二个参数是一个位字符串,指示要使用的程序阶段,最后一个参数是程序名称。第二个参数的位字符串可以由GL_VERTEX_SHADER_BITGL_FRAGMENT_SHADER_BIT等组合而成。使用按位或运算符(|)组合位。

如前所述,当使用程序管线时,使用glProgramUniform而不是glUniform来设置统一变量是一个好主意。由于管线可能并且通常涉及多个程序,使用glUniform时很难确定受影响的程序。有一个名为glActiveShaderProgram的函数可以用来指定受glUniform调用影响的程序,或者你可以简单地使用glUseProgram。然而,没有必要为此烦恼,因为glProgramUniform使得它清晰简单。使用glProgramUniform,我们直接将目标程序作为第一个参数指定。

在使用管线渲染之前,确保没有程序当前通过glUseProgram绑定到 GL 上下文是很重要的。如果有,它将代替管线使用。因此,在渲染之前,你可能想要调用glUseProgram(0)以确保。

最后,我们使用glBindProgramPipeline在渲染之前启用我们的管线之一。在这个例子中,第一次绘制将使用顶点着色器和第一个片段着色器。第二次绘制将使用顶点着色器和第二个片段着色器。

更多内容...

在前面的例子中,我们使用了glCreateShaderProgramv来创建每个单阶段程序。然而,你也可以使用更熟悉的glCreateProgram来做同样的事情。实际上,如果你想创建一个包含多个阶段(比如,顶点着色器和几何着色器)的程序,你需要使用glCreateProgram。然而,由于我们想要与着色器管线一起使用它,因此使用glProgramParameteri将其指定为可分离程序是很重要的。以下是一个使用glCreateProgram创建单阶段程序的示例,假设vertShader是之前编译的顶点着色器对象的名称:

GLuint program = glCreateProgram();
glProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);

glAttachShader(program, vertShader);

glLinkProgram(program);
// Check for errors...

在链接之前,你可以附加多个着色器。

程序管线使得混合和匹配着色器阶段变得容易,同时保持统一状态。然而,增加的复杂性可能对于许多情况来说并不值得。如果你的着色器很复杂,有很多统一状态,并且你需要经常切换管线的一部分,这可能是一个好的替代方案。

参见

  • 示例代码中的chapter02/sceneseparable.cpp文件

  • 链接着色器程序菜谱

获取调试信息

在 OpenGL 的最近版本之前,获取调试信息传统的方式是调用glGetError。不幸的是,这是一个极其繁琐的方法来调试程序。如果函数被调用之前某个时刻发生了错误,glGetError函数会返回一个错误代码。

这意味着如果我们正在追踪一个错误,我们本质上需要在调用 OpenGL 函数的每个函数调用后调用glGetError,或者进行类似于二分查找的过程,在代码块前后调用它,然后将两个调用逐渐靠近,直到我们确定错误的来源。多么痛苦啊!

幸运的是,从 OpenGL 4.3 开始,我们现在支持一种更现代的调试方法。现在,我们可以注册一个调试回调函数,该函数将在发生错误或生成其他信息性消息时执行。不仅如此,我们还可以发送自己的自定义消息,由相同的回调函数处理,并且我们可以使用各种标准来过滤消息。

准备工作

创建一个具有调试上下文的 OpenGL 程序。虽然获取调试上下文不是强制性的,但我们可能无法获得像使用调试上下文时那样有信息性的消息。要使用 GLFW 创建启用调试的 OpenGL 上下文,请在创建窗口之前使用以下函数调用:

glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GL_TRUE); 

默认情况下,OpenGL 调试上下文将启用调试消息。然而,如果您需要显式启用调试消息,请使用以下调用:

glEnable(GL_DEBUG_OUTPUT); 

如何做到这一点...

使用以下步骤:

  1. 创建一个回调函数以接收调试消息。该函数必须符合 OpenGL 文档中描述的特定原型。在这个例子中,我们将使用以下原型:
void debugCallback(GLenum source, GLenum type, GLuint id, 
       GLenum severity, GLsizei length, 
       const GLchar * message, const void * param) { 

    // Convert GLenum parameters to strings 
  printf("%s:%s%s: %sn", sourceStr, typeStr, 
       severityStr, id, message); 
}
  1. 使用 glDebugMessageCallback 将我们的回调注册到 OpenGL:
glDebugMessageCallback( debugCallback, nullptr ); 
  1. 启用所有消息、所有来源、所有级别和所有 ID:
glDebugMessageControl(GL_DONT_CARE, GL_DONT_CARE, 
       GL_DONT_CARE, 0, NULL, GL_TRUE);

它是如何工作的...

debugCallback 回调函数有几个参数,其中最重要的是调试消息本身(第六个参数,message)。在这个例子中,我们只是将消息打印到标准输出,但我们可以将其发送到日志文件或其他目的地。

debugCallback 的前四个参数描述了消息的来源、类型、ID 号和严重性。ID 号是针对消息的特定无符号整数。源、类型和严重性参数的可能值在以下表中描述。

源参数可以具有以下任何值:

来源 由谁生成
GL_DEBUG_SOURCE_API 调用 OpenGL API
GL_DEBUG_SOURCE_WINDOW_SYSTEM 调用 Windows 系统 API
GL_DEBUG_SOURCE_THIRD_PARTY 与 OpenGL 相关联的应用程序
GL_DEBUG_SOURCE_APPLICATION 应用程序本身
GL_DEBUG_SOURCE_OTHER 其他来源

类型参数可以具有以下任何值:

类型 描述
GL_DEBUG_TYPE_ERROR OpenGL API 的错误
GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR 已弃用的行为
GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR 未定义的行为
GL_DEBUG_TYPE_PORTABILITY 一些功能不可移植
GL_DEBUG_TYPE_PERFORMANCE 可能的性能问题
GL_DEBUG_TYPE_MARKER 一个注释
GL_DEBUG_TYPE_PUSH_GROUP 与调试组推送相关的消息
GL_DEBUG_TYPE_POP_GROUP 与调试组弹出相关的消息
GL_DEBUG_TYPE_OTHER 其他消息

严重性参数可以具有以下值:

严重性 含义
GL_DEBUG_SEVERITY_HIGH 错误或危险行为
GL_DEBUG_SEVERITY_MEDIUM 主要性能警告、其他警告或使用已弃用功能
GL_DEBUG_SEVERITY_LOW 冗余状态更改,不重要的未定义行为
GL_DEBUG_SEVERITY_NOTIFICATION 这是一个通知,而不是错误或性能问题

length 参数是消息字符串的长度,不包括空终止符。最后一个参数 param 是用户定义的指针。我们可以使用它来指向可能对回调函数有帮助的一些自定义对象。此参数可以使用 glDebugMessageCallback 的第二个参数设置。

debugCallback 中,我们将每个 GLenum 参数转换为字符串。由于空间限制,我这里没有展示所有代码,但可以在本书的示例代码中找到。然后我们将所有信息打印到标准输出。

glDebugMessageCallback 的调用将我们的回调函数注册到 OpenGL 调试系统中。第一个参数是我们回调函数的指针,第二个参数(在这个例子中为 nullptr)可以是我们想要传递到回调中的任何对象的指针。此指针作为每个对 debugCallback 的调用中的最后一个参数传递。

最后,对 glDebugMessageControl 的调用确定我们的消息过滤器。此函数可以用来选择性地打开或关闭任何组合的消息源、类型、ID 或严重性。在这个例子中,我们打开了所有内容。

还有更多...

OpenGL 还提供了对命名调试组栈的支持。这意味着我们可以记住所有调试消息过滤器设置在栈上,并在一些更改后返回。这可能很有用,例如,如果我们需要在某些代码部分过滤某些类型的消息,而在其他部分我们想要不同的消息集。

涉及的函数是 glPushDebugGroupglPopDebugGroup。对 glPushDebugGroup 的调用会生成一个类型为 GL_DEBUG_TYPE_PUSH_GROUP 的调试消息,并保留我们的调试过滤器在栈上的当前状态。然后我们可以使用 glDebugMessageControl 来更改我们的过滤器,并使用 glPopDebugGroup 返回到原始状态。同样,glPopDebugGroup 函数会生成一个类型为 GL_DEBUG_TYPE_POP_GROUP 的调试消息。

构建一个 C++ 着色器程序类

如果你使用 C++,创建类来封装一些 OpenGL 对象可以非常方便。一个主要的例子是着色器程序对象。在这个菜谱中,我们将探讨一个 C++ 类的设计,该类可以用来管理着色器程序。

准备工作

对于这个,没有太多要准备的;你只需要一个支持 C++ 的构建环境。此外,我将假设你正在使用 GLM 来支持矩阵和向量;如果不是,只需省略涉及 GLM 类的函数。

如何操作...

首先,我们将使用自定义异常类来处理编译或链接过程中可能发生的错误:

class GLSLProgramException : public std::runtime_error { 
public: 
  GLSLProgramException( const string & msg ) : 
       std::runtime_error(msg) { } 
};

我们将使用 enum 来表示各种着色器类型:

namespace GLSLShader { 
  enum GLSLShaderType { 
        VERTEX = GL_VERTEX_SHADER,  
        FRAGMENT = GL_FRAGMENT_SHADER,  
        GEOMETRY = GL_GEOMETRY_SHADER,  
        TESS_CONTROL = GL_TESS_CONTROL_SHADER,
        TESS_EVALUATION = GL_TESS_EVALUATION_SHADER,  
        COMPUTE = GL_COMPUTE_SHADER 
  }; 
}; 

该程序类本身具有以下接口:

class GLSLProgram  { 
private: 
  int  handle; 
  bool linked; 
  std::map<string, int> uniformLocations;
  GLint getUniformLocation(const char *);

  // A few other helper functions 

public: 
  GLSLProgram();
  ~GLSLProgram();

  // Make it non-copyable
  GLSLProgram(const GLSLProgram &) = delete;
  GLSLProgram & operator=(const GLSLProgram &) = delete; 

  void compileShader( const char * filename );  
  void compileShader( const char * filename, 
  GLSLShader::GLSLShaderType type );
  void compileShader( const string & source, 
       GLSLShader::GLSLShaderType type,
       const char * filename = nullptr );
  void link();
  void use();
  void validate();

  int    getHandle(); 
  bool   isLinked(); 

  void   bindAttribLocation( GLuint location, const char * name);  
  void   bindFragDataLocation( GLuint location, const char * name );  
  void   setUniform(const char *name, float x, float y, float z); 
  void   setUniform(const char *name, const glm::vec3 & v); 
  void   setUniform(const char *name, const glm::vec4 & v); 
  void   setUniform(const char *name, const glm::mat4 & m); 
  void   setUniform(const char *name, const glm::mat3 & m); 
  void   setUniform(const char *name, float val ); 
  void   setUniform(const char *name, int val ); 
  void   setUniform(const char *name, bool val );

  void findUniformLocations();
  // ... 
};

代码下载提示

本文本中所有菜谱的完整源代码可在 GitHub 上找到,地址为 github.com/PacktPublishing/OpenGL-4-Shading-Language-Cookbook-Third-Edition

您也可以从您在 www.packtpub.com 的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。

这些函数的实现中涉及的技术在本章前面的菜谱中有详细说明。由于篇幅限制,这里不包括代码(可在本书的 GitHub 仓库中找到),但下一节我们将讨论一些设计决策。

工作原理...

存储在 GLSLProgram 对象中的状态包括 OpenGL 着色器程序对象的句柄 (handle),一个表示程序是否成功链接的 bool 变量 (linked),以及 map,用于存储在发现时存储的 uniform 位置 (uniformLocations)。

如果编译失败,compileShader 重载将抛出 GLSLProgramException。第一个版本根据文件扩展名确定着色器的类型。在第二个版本中,调用者提供着色器类型,第三个版本用于编译着色器,从字符串中获取着色器的源代码。如果字符串是从文件中获取的,可以提供文件名作为第三个参数,这有助于提供更好的错误消息。

当发生错误时,GLSLProgramException 错误消息将包含着色器日志或程序日志的内容。

getUniformLocation 私有函数被 setUniform 函数用来查找统一变量的位置。它首先检查 uniformLocations 映射,如果找不到位置,则查询 OpenGL 以获取位置,并在返回之前将结果存储在映射中。fileExists 函数被 compileShaderFromFile 用来检查文件是否存在。

构造函数简单地初始化 linkedfalsehandle0。当第一个着色器被编译时,将通过调用 glCreateProgram 来初始化 handle 变量。

link 函数简单地尝试通过调用 glLinkProgram 链接程序。然后它检查链接状态,如果成功,将 linked 变量设置为 true 并返回 true。否则,它获取程序日志(通过调用 glGetProgramInfoLog),将结果存储在 GLSLProgramException 中,并抛出异常。如果链接成功,它调用 findUniformLocations,获取所有活动统一变量的列表并将它们的地址存储在名为 uniformLocations 的映射表中,以它们的名称作为键。无论链接是否成功,它都会在返回或抛出异常之前断开并删除所有着色器对象。在这之后,它还会断开并删除着色器对象,因为它们不再需要。

use 函数如果程序已经成功链接,则简单地调用 glUseProgram,否则不执行任何操作。

getHandleisLinked 函数是简单的 getter 函数,它们返回 OpenGL 程序对象的 handlelinked 变量的值。

bindAttribLocationbindFragDataLocation 函数是 glBindAttribLocationglBindFragDataLocation 的包装器。请注意,这些函数应该在程序链接之前调用。

setUniform 重载函数是围绕适当的 glUniform 函数的简单包装器。如前所述,当程序链接时,统一位置会被查询并存储,因此每个 setUniform 函数都会检查映射表以获取缓存的统一位置。

析构函数负责删除程序对象。

最后,printActiveUniformsprintActiveUniformBlocksprintActiveAttribs 函数对于调试目的很有用。它们只是简单地显示活动 uniforms/attributes 列表到标准输出。

以下是一个使用 GLSLProgram 类的简单示例:

GLSLProgram prog; 

try { 
  prog.compileShader("myshader.vert.glsl"); 
  prog.compileShader("myshader.frag.glsl"); 
  prog.link(); 
  prog.validate(); 
  prog.use(); 
} catch( GLSLProgramException &e ) { 
  cerr << e.what() << endl; 
  exit(EXIT_FAILURE); 
} 

prog.setUniform("ModelViewMatrix", matrix); 
prog.setUniform("LightPosition", 1.0f, 1.0f, 1.0f); 

参见

第三章:GLSL 着色器的基础

在本章中,我们将涵盖以下食谱:

  • 使用单个点光源进行漫反射和顶点着色

  • 实现 Phong 反射模型

  • 在着色器中使用函数

  • 实现双面着色

  • 实现平面着色

  • 使用子程序选择着色器功能

  • 抛弃片段以创建带有孔洞的外观

简介

着色器最初是在 OpenGL 的 2.0 版本中添加的,这为之前固定功能的 OpenGL 管道引入了可编程性。着色器赋予我们实现自定义渲染算法的能力,并在这些技术的实现上提供了更大的灵活性。有了着色器,我们可以在 GPU 上直接运行自定义代码,这为我们利用现代 GPU 提供的并行度提供了机会。

着色器使用OpenGL 着色语言GLSL)实现。GLSL 在语法上类似于 C,这应该会使有经验的 OpenGL 程序员更容易学习。由于本文的性质,我不会在这里提供一个详尽的 GLSL 介绍。相反,如果你是 GLSL 的新手,阅读这些食谱应该能帮助你通过示例学习这门语言。如果你已经熟悉 GLSL,但没有 4.x 版本的经验,你将看到如何通过利用新的 API 来实现这些技术。然而,在我们深入 GLSL 编程之前,让我们快速看一下顶点着色器和片段着色器如何在 OpenGL 管道中定位。

顶点和片段着色器

在 OpenGL 4.3 及以上版本中,有六个着色器阶段/类型:顶点、几何、曲面细分控制、曲面细分评估、片段和计算。在本章中,我们将仅关注顶点和片段阶段。在第七章 使用几何和曲面细分着色器 中,我将提供一些使用几何和曲面细分着色器的食谱,而在第十一章 使用计算着色器 中,我将专门关注计算着色器。

着色器是现代 OpenGL 管道的基本部分。以下块图显示了仅安装了顶点和片段着色器的简化 OpenGL 管道视图:

图片

顶点数据通过管线发送,并通过着色器输入变量到达顶点着色器。顶点着色器的输入变量对应于顶点属性(参考第二章中的使用顶点属性和顶点缓冲对象向着色器发送数据配方,使用 GLSL 程序工作)。一般来说,着色器通过程序员定义的输入变量接收输入,这些变量的数据要么来自主 OpenGL 应用程序,要么来自之前的管线阶段(其他着色器)。例如,片段着色器的输入变量可能来自顶点着色器的输出变量。数据也可以通过统一变量(参考第二章中的使用统一变量向着色器发送数据配方,使用 GLSL 程序工作)提供给任何着色器阶段。这些用于比顶点属性更少变化的信息(例如,矩阵、光位置和其他设置)。以下图显示了有两个活动着色器(顶点和片段)时输入和输出变量之间关系的简化视图:

图片

顶点着色器为每个顶点执行一次,并行执行。在顶点着色器完成执行之前,必须将对应顶点位置的数据转换成裁剪空间坐标,并分配给输出变量 gl_Position。顶点着色器可以使用着色器输出变量将其他信息发送到管线中。例如,顶点着色器也可能计算与顶点相关的颜色。这种颜色将通过适当的输出变量传递到后续阶段。

在顶点着色器和片段着色器之间,顶点被组装成原语,进行裁剪,并应用视口变换(以及其他操作)。然后进行光栅化过程,并填充多边形(如果需要)。片段着色器为渲染的多边形的每个片段执行一次(通常并行)。从顶点着色器提供的数据默认情况下以透视正确的方式进行插值,并通过着色器输入变量提供给片段着色器。片段着色器确定像素的适当颜色,并通过输出变量将其发送到帧缓冲区。深度信息自动处理,但可以根据需要由片段着色器修改。

首先学习基础知识

可编程着色器为我们提供了巨大的力量和灵活性。一个不错的起点是学习如何实现一个简单的、常见的反射模型,称为Phong 反射模型。这是一个很好的基础。

在本章中,我们将探讨实现 Phong 模型的基本技术。我们将对其进行一些简单的修改,包括双面渲染和平滑着色。在这个过程中,我们还将看到一些其他 GLSL 特性的示例,如函数、子程序和discard关键字。

使用单个点光源进行漫反射和顶点着色

在学习完整的 Phong 反射模型之前,我们将从仅一个部分开始:漫反射。这是一个简单的反射模型,它假设表面表现出纯漫反射。也就是说,表面看起来以相同的方式向所有方向散射光线,而不管方向如何。

入射光撞击表面并略微穿透后向所有方向重新辐射。当然,入射光在散射之前会与表面相互作用,导致某些波长的光被完全或部分吸收,而其他波长的光被散射。一个典型的漫反射表面例子是涂有哑光漆的表面。该表面看起来很暗淡,没有任何光泽。

以下图像显示了使用漫反射着色渲染的环面:

图片

漫反射的数学模型涉及两个向量:从表面点到光源的方向(s),以及表面点的法向量(n)。这两个向量在以下图中表示:

图片

单位面积上撞击表面的入射光(或辐射度)的量取决于表面相对于光源的朝向。该情况下的物理学告诉我们,当光线沿着法向量方向到达时,单位面积上的辐射量最大,而当光线垂直于法向量时为零。在两者之间,它与光线方向与法向量之间角度的余弦值成正比。因此,由于点积与两个向量之间角度的余弦值成正比,我们可以将撞击表面的辐射量表示为光强度与sn的点积的乘积,如下所示:

图片

L[d]是光源的强度,向量sn被假定为归一化的。

两个单位向量的点积等于它们之间角度的余弦值。

如前所述,一些入射光在重新发射之前被吸收。我们可以通过使用反射系数(K[d])来模拟这种相互作用,它表示散射的入射光的比例。这有时被称为漫反射率或漫反射系数。漫反射率成为一个缩放因子,因此出射光的强度可以表示如下:

图片

因为这个模型只依赖于指向光源的方向和表面的法线,而不依赖于指向观察者的方向,所以我们有一个表示均匀(全向)散射的模型。

在这个配方中,我们将在顶点着色器中的每个顶点上评估这个方程,并在面上插值得到的颜色。我们将使用统一变量为 K[d]L[d] 项以及光源位置。

在这个和接下来的配方中,光强度和材料反射率系数由三组件(RGB)向量表示。因此,这些方程应被视为逐分量操作,分别应用于三个分量。幸运的是,GLSL 将使这一点几乎透明,因为必要的运算符在向量变量上逐分量操作。

准备工作

从一个提供顶点位置在属性位置 0 和顶点法线在属性位置 1 的 OpenGL 应用程序开始(请参阅第二章,使用 GLSL 程序工作中的使用顶点属性和顶点缓冲对象向着色器发送数据配方)。OpenGL 应用程序还应通过统一变量提供标准变换矩阵(投影、模型视图和法线)。

光源位置(在相机坐标系中),KdLd 也应由 OpenGL 应用程序通过统一变量提供。注意,KdLdvec3 类型。我们可以使用 vec3 来存储 RGB 颜色以及一个向量或点。

如何操作...

要创建一个实现漫反射着色的着色器对,请按照以下步骤操作:

  1. 顶点着色器计算漫反射方程,并通过输出变量 LightIntensity 将结果发送到片段着色器:
layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 

out vec3 LightIntensity; 

uniform vec4 LightPosition;// Light position in camera coords. 
uniform vec3 Kd;           // Diffuse reflectivity 
uniform vec3 Ld;           // Light source intensity 

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP;             // Projection * ModelView 

void main() 
{ 
    // Convert normal and position to eye coords 
    vec3 tnorm = normalize( NormalMatrix * VertexNormal); 
    vec4 camCoords = ModelViewMatrix * 
                     vec4(VertexPosition,1.0)); 
    vec3 s = normalize(vec3(LightPosition - camCoords)); 

    // The diffuse shading equation 
    LightIntensity = Ld * Kd * max( dot( s, tnorm ), 0.0 ); 

    // Convert position to clip coordinates and pass along 
    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 片段着色器简单地将颜色应用到片段上:
in vec3 LightIntensity; 
layout( location = 0 ) out vec4 FragColor; 

void main() { 
    FragColor = vec4(LightIntensity, 1.0); 
}
  1. 在 OpenGL 应用程序中编译和链接这两个着色器,并在渲染之前安装着色器程序。有关编译、链接和安装着色器的详细信息,请参阅第一章,开始使用 GLSL

它是如何工作的...

在这个例子中,顶点着色器完成了所有的工作。漫反射是在相机坐标系中通过首先使用法线矩阵变换法线向量,归一化,并将结果存储在 tnorm 中来计算的。请注意,如果您的法线向量已经归一化且法线矩阵不执行任何缩放,则这里的归一化可能不是必需的。

正交矩阵是模型视图矩阵左上角 3x3 部分的逆转。我们使用逆转是因为法向量与顶点位置变换的方式不同。关于正交矩阵的更详细讨论以及原因,请参阅任何计算机图形学入门教材(一个不错的选择是 Hearn 和 Baker 合著的《OpenGL 计算机图形学》)。如果你的模型视图矩阵不包含任何非均匀缩放,那么可以使用模型视图矩阵左上角的 3x3 部分来代替正交矩阵以变换你的法向量。然而,如果你的模型视图矩阵包含(均匀)缩放,那么在变换后你仍然需要(重新)归一化你的法向量。

下一步是将顶点位置转换为相机坐标,通过使用模型视图矩阵变换它。然后,我们通过从顶点位置减去光位置来计算指向光源的方向,并将结果存储在s中。

接下来,我们使用之前描述的方程计算散射光强度,并将结果存储在输出变量LightIntensity中。注意这里max函数的使用。如果点积小于零,则法向量与光方向之间的角度大于 90 度。这意味着入射光是从表面内部来的。由于这种情况意味着没有辐射到达表面,点积会产生负值,所以我们使用0.0的值。然而,你可能决定你想正确地照亮表面的两侧,在这种情况下,当光击中表面的背面时,需要反转法向量(参考本章中的实现双面着色配方)。

最后,我们通过乘以模型视图投影矩阵(即*投影 * 视图 * 模型)将顶点位置转换为裁剪空间坐标,并将结果存储在内置输出变量gl_Position中。

OpenGL 管道的后续阶段期望顶点位置以裁剪空间坐标的形式提供在输出变量gl_Position中。这个变量并不直接对应于片元着色器中的任何输入变量,但它被 OpenGL 管道在后续的原始装配、裁剪和光栅化阶段使用。我们始终提供一个有效的值对于这个变量是很重要的。

由于LightIntensity是顶点着色器的输出变量,其值在面之间插值,并传递到片元着色器。然后,片元着色器简单地将该值赋给输出片元。

还有更多...

漫射着色是一种仅模拟非常有限范围的表面技术的技巧。它最适合用于具有哑光外观的表面。此外,使用之前的技术,暗区可能看起来有点太暗。实际上,那些没有直接照亮的区域是完全黑色的。在真实场景中,通常有一些光线在房间内反射,从而照亮这些表面。在接下来的菜谱中,我们将探讨模拟更多表面类型的方法,并为表面上的暗部提供一些光线。

参见

  • 示例代码中的chapter03/scenediffuse.cpp文件

  • 第二章中的使用统一变量将数据发送到着色器菜谱,使用 GLSL 程序

  • 第一章中的编译着色器菜谱,开始使用 GLSL

  • 第一章中的链接着色器程序菜谱,开始使用 GLSL

  • 第二章中的使用顶点属性和顶点缓冲对象将数据发送到着色器菜谱,使用 GLSL 程序

实现 Phong 反射模型

在本菜谱中,我们将实现著名的 Phong 反射模型。OpenGL 固定功能管道的默认着色技术非常类似于这里所展示的。它将光与表面的相互作用建模为三个组件的组合:环境、漫射和镜面。环境组件旨在模拟经过多次反射的光,它看起来似乎从所有方向均匀地发出。漫射组件在前一个菜谱中已讨论过,代表全向反射。镜面组件模拟表面的光泽度,并代表围绕一个优选方向的光滑反射。将这三个组件组合在一起可以模拟一个很好的(但有限的)表面类型多样性。这种着色模型被称为Phong 反射模型(或Phong 着色模型),以图形研究员 Bui Tuong Phong 的名字命名。

以下图像展示了使用 Phong 着色模型渲染的环面示例:

图片

Phong 模型被实现为三个组件的总和:环境、漫射和镜面。环境组件代表照亮所有表面并均匀反射到所有方向的光。它用于帮助照亮场景中的一些较暗区域。由于它不依赖于光线的入射或出射方向,它可以简单地通过将光源强度(L[a])乘以表面反射率(K[a])来模拟:

图片

扩散分量模拟了一个粗糙表面,该表面向所有方向散射光线(参见本章中的单点光源的扩散和顶点着色配方)。扩散贡献由以下方程给出:

图片

镜面分量用于模拟表面的光泽。当表面具有光泽时,光线从表面反射,并在某些优选方向上散射。我们这样模拟,使得反射光在完美(镜面)反射的方向上最强。该情况下的物理学告诉我们,对于完美反射,入射角等于反射角,并且矢量与表面法线共面,如下面的图所示:

图片

在前面的图中,r 代表与入射光矢量(-s)相对应的纯反射方向,而 n 是表面法线。我们可以通过以下方程计算 r

图片

为了模拟镜面反射,我们需要计算以下(归一化)矢量:指向光源的方向(s)、完美反射的矢量(r)、指向观察者的矢量(v)和表面法线(n)。这些矢量在以下图中表示:

图片

我们希望当观察者与矢量 r 对齐时,反射最大,而当观察者远离与 r 对齐时,反射迅速减弱。这可以通过使用 vr 之间角度的余弦值提高到某个幂(f)来模拟:

图片

(回忆一下,点积与涉及矢量之间的角度的余弦值成正比。)功率越大,当 vr 之间的角度增大时,值向零下降的速度越快。同样,与其他分量一样,我们也引入了镜面光强度项(L[s])和反射率项(K[s])。通常将 K[s] 项设置为某种灰度值(例如,(0.8, 0.8, 0.8)),因为光泽反射(通常)与波长无关。

镜面分量创建典型的光泽表面的镜面高光(亮斑)。方程中 f 的幂越大,镜面高光越小,表面越光滑。f 的值通常选择在 1 到 200 之间。

通过简单地将这三个项相加,我们得到以下着色方程:

图片

在以下代码中,我们将在顶点着色器中评估此方程,并在多边形上插值颜色。

准备工作

在 OpenGL 应用程序中,将顶点位置提供在位置 0,将顶点法线提供在位置 1。光的位置和我们的光照方程的其他可配置项是顶点着色器中的统一变量,它们的值必须从 OpenGL 应用程序中设置。

如何实现...

要创建一个实现 Phong 反射模型的着色器对,请按照以下步骤操作:

  1. 顶点着色器在顶点位置计算 Phong 反射模型,并将结果发送到片段着色器:
layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 

out vec3 LightIntensity; 

uniform struct LightInfo {
  vec4 Position; // Light position in eye coords.
  vec3 La;       // Ambient light intensity
  vec3 Ld;       // Diffuse light intensity
  vec3 Ls;       // Specular light intensity
} Light;

uniform struct MaterialInfo {
  vec3 Ka;      // Ambient reflectivity
  vec3 Kd;      // Diffuse reflectivity
  vec3 Ks;      // Specular reflectivity
  float Shininess; // Specular shininess factor
} Material;

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP; 

void main() { 
  vec3 n = normalize( NormalMatrix * VertexNormal);
  vec4 camCoords = ModelViewMatrix * vec4(VertexPosition,1.0);

  vec3 ambient = Light.La * Material.Ka;
  vec3 s = normalize(vec3(Light.Position - camCoords));
  float sDotN = max( dot(s,n), 0.0 );
  vec3 diffuse = Light.Ld * Material.Kd * sDotN;
  vec3 spec = vec3(0.0);
  if( sDotN > 0.0 ) {
    vec3 v = normalize(-camCoords.xyz);
    vec3 r = reflect( -s, n );
    spec = Light.Ls * Material.Ks *
            pow( max( dot(r,v), 0.0 ), Material.Shininess );
  }

  LightIntensity = ambient + diffuse + spec;
  gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 片段着色器只是将颜色应用到片段上:
in vec3 LightIntensity; 
layout( location = 0 ) out vec4 FragColor; 

void main() { 
    FragColor = vec4(LightIntensity, 1.0); 
} 
  1. 在 OpenGL 应用程序中编译和链接两个着色器,并在渲染之前安装着色器程序。

它是如何工作的...

顶点着色器在眼坐标中计算着色方程。它首先将顶点法线转换到相机坐标系并归一化,然后将结果存储在n中。顶点位置也转换到相机坐标系并存储在camCoords中。

计算并存储环境分量在变量ambient中。

接下来,我们计算指向光源的归一化方向(s)。这是通过从相机坐标系中的顶点位置减去光的位置并归一化结果来完成的。

接下来计算sn的点积。与前面的配方一样,我们使用内置函数max将值的范围限制在零和一之间。结果存储在名为sDotN的变量中,并用于计算漫反射分量。漫反射分量的结果存储在变量diffuse中。

在计算镜面分量之前,我们检查sDotN的值。如果sDotN为零,则没有光线到达表面,因此没有必要计算镜面分量,因为它的值必须是零。否则,如果sDotN大于零,我们使用前面提出的方程计算镜面分量。

如果我们在计算镜面分量之前没有检查sDotN,那么可能在背对光源的面出现一些镜面高光。这显然是一个不现实且不希望的结果。另一种解决这个问题的方法是将镜面和漫反射分量都乘以sDotN(而不是像我们现在这样只乘以漫反射分量)。这实际上更符合物理规律,但不是传统 Phong 模型的一部分。

观众的方向(v)是位置(归一化)的否定,因为在相机坐标系中,观众位于原点。

我们通过调用 GLSL 内置函数reflect来计算纯反射的方向,该函数将第一个参数关于第二个参数反射。我们不需要归一化结果,因为涉及的这两个向量已经归一化了。

在计算镜面分量时,我们使用内置函数max将点积的值限制在零和一之间,而函数pow将点积提升到Shininess指数的幂(对应于我们光照方程中的f)。

然后将三个分量的和存储在输出变量LightIntensity中。此值将与顶点相关联并传递到管道中。在到达片段着色器之前,其值将以透视正确的方式在多边形的面上进行插值。

最后,顶点着色器将位置转换为裁剪坐标,并将结果分配给内置输出变量gl_Position(参考本章中的“使用单个点光源进行漫反射和逐顶点着色”配方)。

片段着色器简单地将LightIntensity插值后的值应用到输出片段上,通过将其存储在着色器输出变量FragColor中。

还有更多...

Phong 反射模型工作得相当好,但也有一些缺点。James Blinn 对该模型进行的一小改动在实践中更常用。Blinn-Phong 模型用所谓的半程向量替换了纯反射向量,并产生了更真实的光泽高光。该模型在第四章的“Blinn-Phong 反射模型”配方中进行了讨论,位于“光照与着色”部分。

使用非局部观察者

我们可以通过使用所谓的非局部观察者来避免计算指向观察者的向量(v)所需的额外归一化。我们不是计算指向原点的方向,而是简单地为所有顶点使用常量向量(0,0,1)。当然,这并不准确,但在实践中,视觉结果非常相似,通常在视觉上无法区分,这为我们节省了一次归一化。

逐顶点与逐片段

由于着色方程是在顶点着色器内计算的,我们将其称为逐顶点着色。逐顶点着色也称为Gouraud 着色。这种方法的缺点之一是镜面高光可能会扭曲或丢失,因为着色方程并没有在多边形面上的每个点进行评估。

例如,一个应该在多边形中间出现的镜面高光,当使用逐顶点着色时可能根本不会出现,这是因为着色方程仅在镜面分量接近零的顶点处计算。在第四章的“使用逐片段着色以增强真实感”配方中,我们将在“光照与着色”部分查看将着色计算移动到片段着色器所需的更改,以产生更真实的结果。

方向性光源

如果我们假设存在一个方向性光源,我们也可以避免为每个顶点计算一个光方向(s)。一个方向性光源没有位置,只有方向。我们不需要为每个顶点计算指向源的方向,而是使用一个常量向量,它表示指向远程光源的方向。这是一种模拟来自远距离光源(如阳光)照明的良好方法。我们将在第四章的使用方向性光源进行着色配方中看到这个例子,光照与着色

距离衰减

你可能会认为这个着色模型缺少一个重要的组件。它没有考虑到光源距离的影响。事实上,已知从源发出的辐射强度与源距离的平方成反比。那么为什么不在我们的模型中包含这个呢?

虽然这样做相当简单,但视觉结果往往不尽如人意。它往往会夸大距离效果,并创建出看起来不真实的效果。记住,我们的方程只是对涉及的物理的近似,并不是一个真正现实的模型,所以基于严格的物理定律添加的项产生不真实的结果并不令人惊讶。

在 OpenGL 固定功能管道中,可以使用glLight函数打开距离衰减。如果需要,可以简单地添加几个统一变量到我们的着色器中,以产生相同的效果。

参见

  • 示例代码中的chapter03/scenephong.cpp文件

  • 第四章中使用方向性光源进行着色配方,光照与着色

  • 第四章中使用每片段着色提高真实感配方,光照与着色

  • 第四章中使用 Blinn-Phong 模型配方,光照与着色

在着色器中使用函数

GLSL 支持与 C 函数语法相似的函数。然而,调用约定有所不同。在下面的例子中,我们将使用函数重新审视 Phong 着色器,以帮助提供主要步骤的抽象。

准备工作

与之前的配方一样,在属性位置 0 提供顶点位置,在属性位置 1 提供顶点法线。所有 Phong 系数的统一变量应从 OpenGL 端设置,以及光的位置和标准矩阵。

如何操作...

顶点着色器几乎与之前的配方相同,除了 Phong 模型在函数内评估,我们添加另一个函数将位置和法线转换为相机坐标:

// Uniform variables and attributes omitted...
 void getCamSpace( out vec3 norm, out vec3 position ) {
    norm = normalize( NormalMatrix * VertexNormal);
    position = (ModelViewMatrix * vec4(VertexPosition,1.0)).xyz;
}

vec3 phongModel( vec3 position, vec3 n ) { 
  vec3 ambient = Light.La * Material.Ka;
  vec3 s = normalize( Light.Position.xyz - position );
  float sDotN = max( dot(s,n), 0.0 );
  vec3 diffuse = Light.Ld * Material.Kd * sDotN;
  vec3 spec = vec3(0.0);
  if( sDotN > 0.0 ) {
    vec3 v = normalize(-position.xyz);
    vec3 r = reflect( -s, n );
    spec = Light.Ls * Material.Ks *
            pow( max( dot(r,v), 0.0 ), Material.Shininess );
  }

  return ambient + diffuse + spec;
}

void main() {
    // Get the position and normal in camera space
    vec3 camNorm, camPosition;
    getCamSpace(camNorm, camPosition);

    // Evaluate the reflection model
    LightIntensity = phongModel( camPosition, camNorm );

    gl_Position = MVP * vec4(VertexPosition,1.0);
} 

片段着色器与之前的配方没有变化。

它是如何工作的...

在 GLSL 函数中,参数评估策略是按值返回调用(也称为按值复制-恢复按值结果)。参数变量可以用inoutinout进行限定。对应于输入参数(用ininout限定的参数)在调用时复制到参数变量中,输出参数(用outinout限定的参数)在函数返回前复制回相应的参数。如果一个参数变量没有这三个限定符中的任何一个,则默认限定符是in

我们在顶点着色器中创建了两个函数。第一个,命名为getCamSpace,将顶点位置和顶点法线转换成相机坐标,并通过输出参数返回它们。在main函数中,我们创建了两个未初始化的变量(camNormcamPosition)来存储结果,然后以变量作为函数参数调用该函数。函数将结果存储到参数变量(nposition)中,这些变量在函数返回前被复制到参数中。

第二个函数phongModel仅使用输入参数。该函数接收眼睛空间的位置和法线,并计算 Phong 反射模型的结果。结果由函数返回并存储在着色器输出变量LightIntensity中。

更多...

由于从输出参数变量读取没有意义,输出参数只应在函数内部进行写入。它们的值是未定义的。

在函数内部,允许写入仅输入参数(用in限定)。函数的参数副本被修改,并且更改不会反映在参数中。

const限定符

可以将const限定符与仅输入参数(不是outinout)一起使用。此限定符使输入参数为只读,因此在函数内部不能对其进行写入。

函数重载

通过创建具有相同名称但参数数量和/或类型不同的多个函数,可以重载函数。与许多语言一样,两个重载的函数可能不仅仅在返回类型上有所不同。

将数组或结构传递给函数

应注意,当将数组或结构传递给函数时,它们是按值传递的。如果传递大数组或结构,可能会进行大量的复制操作,这可能不是所希望的。将这些变量声明在全局作用域中会是一个更好的选择。

参见

  • 示例代码中的chapter03/shader/function.vert.glslchapter03/shader/function.frag.glsl文件

  • 实现 Phong 反射模型的配方

实现双面着色

当渲染一个完全封闭的网格时,多边形的背面会被隐藏。然而,如果一个网格包含孔洞,背面可能会变得可见。在这种情况下,由于法向量指向错误的方向,多边形可能会被错误着色。为了正确着色这些背面,需要反转法向量并基于反转后的法向量计算光照方程。

以下图像显示了一个去掉盖子的茶壶。在左侧,使用 Phong 模型。在右侧,Phong 模型增加了本配方中讨论的双面渲染技术:

图片

在这个配方中,我们将查看一个示例,该示例使用前面配方中讨论的 Phong 模型,并增加了正确着色背面面的能力。

准备工作

顶点位置应提供在属性位置 0,顶点法向量在属性位置 1。与前面的示例一样,必须通过统一变量将光照参数提供给着色器。

如何做...

要实现一个使用具有双面光照的 Phong 反射模型的着色器对,请按照以下步骤操作:

  1. 顶点着色器与前面的配方类似,但它计算了两次 Phong 方程。首先,不改变法向量,然后再次反转法向量。结果分别存储在输出变量FrontColorBackColor中:
// Uniforms and attributes...
out vec3 FrontColor;
out vec3 BackColor;

vec3 phongModel( vec3 position, vec3 n ) { 
    // The Phong model calculations go here...
} 

void main() {
    vec3 tnorm = normalize( NormalMatrix * VertexNormal);
    vec3 camCoords = (ModelViewMatrix * 
    vec4(VertexPosition,1.0)).xyz;

    FrontColor = phongModel( camCoords, tnorm );
    BackColor = phongModel( camCoords, -tnorm );

    gl_Position = MVP * vec4(VertexPosition,1.0);
}
  1. 片段着色器根据内置变量gl_FrontFacing的值选择使用哪种颜色:
in vec3 FrontColor; 
in vec3 BackColor; 

layout( location = 0 ) out vec4 FragColor; 

void main() { 
    if( gl_FrontFacing ) { 
        FragColor = vec4(FrontColor, 1.0); 
    } else { 
        FragColor = vec4(BackColor, 1.0); 
    } 
} 

工作原理...

在顶点着色器中,我们使用顶点法向量和反转后的版本来计算光照方程,并将每个颜色传递给片段着色器。片段着色器根据面的方向选择并应用适当的颜色。

反射模型的评估被放置在一个名为phongModel的函数中。该函数被调用两次,首先使用法向量(转换为相机坐标),然后使用反转后的法向量。组合结果分别存储在FrontColorBackColor中。

着色模型的一些方面(如环境分量)与法向量的方向无关。可以通过重写代码来优化此代码,以便冗余计算只进行一次。然而,在这个配方中,为了使事情清晰易读,我们两次计算整个着色模型。

在片段着色器中,我们根据内置变量 gl_FrontFacing 的值来确定应用哪种颜色。这是一个布尔值,表示片段是否是正面或背面多边形的一部分。请注意,这种判断是基于多边形的方向,而不是法向量。(如果一个多边形的顶点按逆时针顺序指定,从多边形的前面看,则称该多边形为逆时针方向。)默认情况下,在渲染时,如果顶点的顺序在屏幕上以逆时针顺序出现,则表示这是一个正面多边形;然而,我们可以通过从 OpenGL 程序中调用 glFrontFace 来改变这一点。

还有更多...

在顶点着色器中,我们通过法向量的方向来确定多边形的正面,而在片段着色器中,判断是基于多边形的方向。为了正确工作,必须适当地为 glFrontFace 设置确定的表面定义法向量。

对于这个配方的一个替代选择是在顶点着色器中首先确定正在着色的表面是正面还是背面,然后只将单个结果发送到片段着色器。一种方法是通过计算指向摄像机的向量(在摄像机坐标中的原点)与法向量的点积。如果点积为负,则表示法向量必须是指向观察者的,这意味着观察者看到的是表面的背面。

在这种情况下,我们需要反转法向量。具体来说,我们可以将顶点着色器中的主函数更改如下:

void main() {
  vec3 tnorm = normalize( NormalMatrix * VertexNormal);
  vec3 camCoords = (ModelViewMatrix * vec4(VertexPosition,1.0)).xyz;
  vec3 v = normalize(-camCoords.xyz);

  float vDotN = dot(v, tnorm);

  if( vDotN >= 0 ) {
    Color = phongModel(camCoords, tnorm);
  } else {
    Color = phongModel(camCoords, -tnorm);
  }
  gl_Position = MVP * vec4(VertexPosition,1.0);
}

在这种情况下,我们只需要一个输出变量发送到片段着色器(在前面代码中的 Color),片段着色器只需将颜色应用到片段上。在这个版本中,在片段着色器中不需要检查 gl_FrontFacing 的值。

在这个版本中,用来确定是否为正面多边形的唯一因素是法向量。不使用多边形方向。如果一个多边形的顶点法线不平行(对于弯曲形状通常是这种情况),那么可能某些顶点被处理为正面,而其他顶点被处理为背面。这可能导致在表面颜色混合时产生不希望出现的伪影。最好是像现在常见的做法一样,在片段着色器中计算整个反射模型。有关逐片段着色的详细信息,请参阅第四章使用逐片段着色提高真实感的配方,光照与着色

使用双面渲染进行调试

有时,通过视觉确定哪些面是正面和哪些是背面(基于环绕顺序)可能很有用。例如,当处理任意网格时,多边形可能没有使用适当的环绕顺序指定。作为另一个例子,当以程序方式开发网格时,有时确定哪些面具有适当的环绕顺序可能有助于调试。我们可以轻松调整我们的片段着色器,通过将纯色与所有背面(或正面)混合来帮助我们解决这类问题。例如,我们可以在片段着色器中的else子句中更改如下:

FragColor = mix( vec4(BackColor,1.0), 
                vec4(1.0,0.0,0.0,1.0), 0.7 );

这将混合一个纯红色与所有背面,使它们突出,如下面的图像所示。在图像中,背面与 70%的红色混合,如前面的代码所示:

图片

参见

  • 示例代码中的chapter03/scenetwoside.cpp文件

  • 实现 Phong 反射模型配方

  • 在第四章(343fbd70-0012-4449-afe6-a724b330b441.xhtml)光照和着色中,如何实现使用每个片段着色提高现实感

实现平面着色

每个顶点着色涉及在每个顶点计算着色模型,并将结果(颜色)与该顶点关联。然后,颜色在多边形的面上进行插值,以产生平滑的着色效果。这也被称为Gouraud 着色。在 OpenGL 的早期版本中,这种带有颜色插值的每个顶点着色是默认的着色技术。

有时,为了使每个多边形使用单一颜色,以便在整个多边形面上没有颜色变化,从而使每个多边形看起来是平面的,这可能是有用的。这在物体形状需要这种技术的情况下可能很有用,例如,因为面确实打算看起来是平面的,或者有助于在复杂网格中可视化多边形的定位。每个多边形使用单一颜色通常称为平面着色

以下图像显示了使用 Phong 反射模型渲染的网格。在左侧,使用 Gouraud 着色。在右侧,使用平面着色:

图片

在 OpenGL 的早期版本中,通过调用glShadeModel函数并使用参数GL_FLAT来启用平面着色,在这种情况下,每个多边形的最后一个顶点的计算颜色在整个面上使用。

在 OpenGL 4 中,平面着色通过着色器输入/输出变量的可用插值限定符得到简化。

如何做...

要将 Phong 反射模型修改为使用平面着色,请执行以下步骤:

  1. 使用与前面提供的 Phong 示例相同的顶点着色器。按照以下方式更改输出变量LightIntensity
flat out vec3 LightIntensity; 
  1. 将片段着色器中的相应变量更改为使用flat限定符:
flat in vec3 LightIntensity; 
  1. 在 OpenGL 应用程序中编译和链接两个着色器,并在渲染之前安装着色程序。

它是如何工作的...

通过使用 flat 修饰符来指定顶点输出变量(及其对应的片段输入变量),启用平面着色。此修饰符表示在值到达片段着色器之前不应进行插值。提供给片段着色器的值将是与多边形的第一个或最后一个顶点的顶点着色器调用结果相对应的值。这个顶点被称为 触发顶点,可以使用 OpenGL 函数 glProvokingVertex 进行配置。例如,以下调用:

glProvokingVertex(GL_FIRST_VERTEX_CONVENTION); 

表示第一个顶点应作为平面着色变量的值。GL_LAST_VERTEX_CONVENTION 参数表示应使用最后一个顶点。默认值是 GL_LAST_VERTEX_CONVENTION

参见

  • 示例代码中的 chapter03/sceneflat.cpp 文件

  • 实现 Phong 反射模型 的配方

使用子例程选择着色器功能

在 GLSL 中,子例程是一种将函数调用绑定到一组可能的函数定义之一(基于变量的值)的机制。在许多方面,它类似于 C 中的函数指针。统一变量作为指针,用于调用函数。该变量的值可以从 OpenGL 端设置,从而将其绑定到几个可能的定义之一。子例程的函数定义不需要具有相同的名称,但必须具有相同数量和类型的参数以及相同的返回类型。

因此,子例程提供了一种在运行时选择替代实现的方法,而无需交换着色程序和/或重新编译,或使用与统一变量一起的 if 语句。例如,可以编写一个着色器,提供几个用于场景中不同对象的着色算法。在渲染场景时,而不是交换着色程序或使用条件语句,我们可以简单地更改子例程的统一变量,以在每个对象渲染时选择适当的着色算法。

由于着色程序中的性能至关重要,避免条件语句或着色程序交换可能很有价值。使用子例程,我们可以实现条件语句或着色程序交换的功能,而无需计算开销。然而,现代驱动程序在处理条件语句方面做得很好,因此子例程相对于条件语句的优势并不总是明确的。根据条件,基于统一变量的条件语句可以与子例程一样高效。

在这个例子中,我们将通过渲染茶壶两次来演示子例程的使用。第一个茶壶将使用前面描述的完整 Phong 反射模型进行渲染。第二个茶壶将只使用漫反射着色。将使用子例程统一变量来在两种着色技术之间进行选择。

SPIR-V 不支持子例程。因此,可能应该避免使用它们。由于 SPIR-V 显然是 OpenGL 中着色器的未来,子例程应被视为已弃用。

在以下图像中,我们可以看到一个使用子例程创建的渲染示例。左边的茶壶使用完整的 Phong 反射模型进行渲染,而右边的茶壶仅使用漫反射着色进行渲染。子例程用于在着色器功能之间切换:

准备工作

与之前的食谱一样,在属性位置 0 提供顶点位置,在属性位置 1 提供顶点法线。所有 Phong 系数的统一变量应从 OpenGL 端设置,以及光的位置和标准矩阵。

我们假设在 OpenGL 应用程序中,programHandle变量包含着色器程序对象的句柄。

如何做到这一点...

要创建一个使用子例程在纯漫反射和 Phong 之间切换的着色器程序,请执行以下步骤:

  1. 使用子例程统一变量设置顶点着色器,并声明两个子例程类型的函数:
subroutine vec3 shadeModelType( vec3 position, vec3 normal); 
subroutine uniform shadeModelType shadeModel; 

out vec3 LightIntensity;

// Uniform variables and attributes here...

subroutine( shadeModelType ) 
vec3 phongModel( vec3 position, vec3 norm ) { 
    // The Phong reflection model calculations go here...
} 

subroutine( shadeModelType ) 
vec3 diffuseOnly( vec3 position, vec3 norm ) { 
   // Compute diffuse shading only..
} 

void main() {
   // Compute camPosition and camNorm ...

    // Evaluate the shading equation, calling one of 
    // the functions: diffuseOnly or phongModel. 
    LightIntensity = shadeModel(camPosition, camNorm); 

    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 片段着色器与《Phong 反射模型》食谱中的相同。

  2. 在 OpenGL 应用程序中,编译和链接着色器到着色器程序中,并将程序安装到 OpenGL 管道中。

  3. 在 OpenGL 应用程序的渲染函数中,使用以下代码:

GLuint phongIndex = 
   glGetSubroutineIndex(programHandle, 
                       GL_VERTEX_SHADER,"phongModel"); 
GLuint diffuseIndex = 
    glGetSubroutineIndex(programHandle, 
                       GL_VERTEX_SHADER, "diffuseOnly"); 

glUniformSubroutinesuiv( GL_VERTEX_SHADER, 1, &phongIndex); 
... // Render the left teapot 

glUniformSubroutinesuiv( GL_VERTEX_SHADER, 1, &diffuseIndex); 
... // Render the right teapot 

它是如何工作的...

在此示例中,子例程是在顶点着色器中定义的。第一步是声明子例程类型,如下所示:

subroutine vec3 shadeModelType( vec3 position, vec3 normal); 

这定义了一个名为shadeModelType的新子例程类型。其语法与函数原型非常相似,因为它定义了一个名称、一个参数列表和一个返回类型。与函数原型一样,参数名称是可选的。

在创建新的子例程类型后,我们声明一个名为shadeModel的该类型统一变量:

subroutine uniform shadeModelType shadeModel; 

此变量作为我们的函数指针,并将分配给 OpenGL 应用程序中的两个可能函数之一。

我们通过在函数定义前加上子例程限定符来声明两个函数是子例程的一部分:

subroutine ( shadeModelType ) 

这表示该函数与子例程类型匹配,因此其头文件必须与子例程类型定义中的头文件相匹配。我们使用此前缀来定义phongModeldiffuseOnly函数。diffuseOnly函数计算漫反射着色方程,而phongModel函数计算完整的 Phong 反射方程。

我们通过在主函数中利用子例程统一的shadeModel来调用这两个子例程函数之一:

LightIntensity = shadeModel( eyePosition, eyeNorm );

再次,这个调用将绑定到两个函数之一,具体取决于子例程统一变量shadeModel的值,我们将在 OpenGL 应用程序中设置它。

在 OpenGL 应用程序的渲染函数中,我们通过以下两个步骤给子例程统一变量赋值:

  1. 首先,我们使用glGetSubroutineIndex查询每个子例程函数的索引。第一个参数是程序句柄。第二个是着色器阶段。在这种情况下,子例程是在顶点着色器中定义的,所以我们在这里使用GL_VERTEX_SHADER。第三个参数是子例程的名称。我们单独查询每个函数,并将索引存储在变量phongIndexdiffuseIndex中。

  2. 第二,我们选择合适的子例程函数。为此,我们需要通过调用glUniformSubroutinesuiv来设置子例程的统一变量shadeModel的值。这个函数是为了一次性设置多个子例程统一变量而设计的。在我们的情况下,当然我们只设置了一个统一变量。第一个参数是着色器阶段(GL_VERTEX_SHADER),第二个是设置的统一变量数量,第三个是指向包含子例程函数索引数组的指针。由于我们只设置了一个统一变量,我们只需提供包含索引的GLuint变量的地址,而不是一个真正的值数组。当然,如果设置了多个统一变量,我们会使用一个数组。一般来说,提供的第三个参数的值数组以以下方式分配给子例程统一变量。数组的第 i 个元素分配给索引为 i 的子例程统一变量。由于我们只提供了一个值,我们正在设置索引为零的子例程统一变量。

你可能会想,“我们怎么知道我们的子例程统一变量位于索引零?我们在调用glUniformSubroutinesuiv之前没有查询索引!”这个代码之所以能工作,是因为我们依赖于 OpenGL 将始终从零开始连续编号子例程的索引。如果我们有多个子例程统一变量,我们可以(并且应该)使用glGetSubroutineUniformLocation查询它们的索引,然后适当地排序我们的数组。

glUniformSubroutinesuiv要求我们一次性设置所有子例程统一变量,在一个调用中。这样 OpenGL 就可以在单个爆发中验证它们。

还有更多...

不幸的是,当通过调用glUseProgram或其他技术从管道中解绑(切换)着色器程序时,子例程绑定会被重置。这要求我们每次激活着色器程序时都调用glUniformSubroutinesuiv

在着色器中定义的子例程函数可以匹配多个子例程类型。子例程限定符可以包含由逗号分隔的子例程类型列表。例如,如果一个子例程匹配类型type1type2,我们可以使用以下限定符:

subroutine( type1, type2 ) 

这将允许我们使用不同类型的子例程统一变量来引用相同的子例程函数。

参见

  • 示例代码中的chapter03/scenesubroutine.cpp文件

  • 菲涅耳反射模型的配方

  • 使用单个点光源的漫反射和顶点着色配方

丢弃片段以创建带有孔洞的外观

片段着色器可以利用discard关键字来丢弃片段。使用此关键字会导致片段着色器停止执行,而不会将任何内容(包括深度)写入输出缓冲区。这提供了一种在不使用混合的情况下在多边形中创建孔的方法。实际上,由于片段被完全丢弃,因此不依赖于对象绘制的顺序,从而节省了我们可能需要进行的任何深度排序的麻烦。

在这个配方中,我们将绘制一个茶壶,并使用discard关键字根据纹理坐标选择性地移除片段。结果将看起来像以下图像:

图片

准备工作

顶点位置、法线和纹理坐标必须从 OpenGL 应用程序提供给顶点着色器。位置应提供在位置 0,法线在位置 1,纹理坐标在位置 2。与之前的示例一样,光照参数必须通过适当的统一变量从 OpenGL 应用程序设置。

如何做到这一点...

要创建一个基于正方形晶格(如图像所示)丢弃片段的着色程序:

  1. 在顶点着色器中,我们使用双面光照,并包含纹理坐标:
layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 
layout (location = 2) in vec2 VertexTexCoord; 

out vec3 FrontColor; 
out vec3 BackColor; 
out vec2 TexCoord; 

// Other uniform variables here...
// getCamSpace and phongModel functions...

void main() { 
    TexCoord = VertexTexCoord;

    // Get the position and normal in camera space 
    vec3 camNorm, camPosition;
    getCamSpace(camNorm, camPosition); 

    FrontColor = phongModel( camPosition, eyeNorm ); 
    BackColor = phongModel( camPosition, -eyeNorm ); 

    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 在片段着色器中,使用discard关键字根据一定条件丢弃片段:
in vec3 FrontColor; 
in vec3 BackColor; 
in vec2 TexCoord; 

layout( location = 0 ) out vec4 FragColor; 

void main() { 
    const float scale = 15.0; 
    bvec2 toDiscard = greaterThan( fract(TexCoord * scale), 
                                   vec2(0.2,0.2) );
    if( all(toDiscard) ) 
        discard; 

    if( gl_FrontFacing ) 
        FragColor = vec4(FrontColor, 1.0); 
    else 
        FragColor = vec4(BackColor, 1.0); 
} 
  1. 在 OpenGL 应用程序中编译和链接这两个着色器,并在渲染之前安装着色程序。

它是如何工作的...

由于我们将丢弃茶壶的一些部分,我们将能够透过茶壶看到另一侧。这将导致一些多边形的背面变得可见。因此,我们需要为每个面的两侧适当地计算光照方程。我们将使用之前在双面着色配方中介绍过的相同技术。

顶点着色器基本上与双面着色配方相同,主要区别在于增加了纹理坐标。为了管理纹理坐标,我们有一个额外的输入变量,VertexTexCoord,它对应于属性位置 2。这个输入变量的值直接通过输出变量TexCoord传递给片段着色器,且不进行任何改变。波恩反射模型被计算两次,一次使用给定的法向量,将结果存储在FrontColor中,再次使用反转的法向量,将结果存储在BackColor中。

在片段着色器中,我们通过一种简单技术计算是否应该丢弃片段,该技术旨在产生前一幅图像中显示的类似晶格的图案。我们首先将纹理坐标乘以任意缩放因子scale。这对应于每个单位(缩放)纹理坐标中的晶格矩形数量。然后,我们使用内置函数fract计算缩放纹理坐标每个分量的分数部分。每个分量使用内置的greaterThan函数与 0.2 进行比较,并将结果存储在布尔向量toDiscard中。greaterThan函数逐分量比较两个向量,并将布尔结果存储在返回值的相应分量中。

如果向量toDiscard的两个分量都为真,则该片段位于每个晶格框架的内部,因此我们希望丢弃这个片段。我们可以使用内置函数all来帮助进行此检查。如果参数向量的所有分量都为真,则all函数将返回 true。如果函数返回 true,则执行discard语句以拒绝该片段。

else分支中,我们根据多边形的朝向对片段进行着色,正如之前介绍的实现双面着色配方中所述。

参见

  • 示例代码中的chapter03/scenediscard.cpp配方

  • 本章中实现的双面着色配方

第四章:照明和着色

在本章中,我们将介绍以下食谱:

  • 使用多个位置光源进行着色

  • 使用方向光源进行着色

  • 使用逐片段着色提高真实感

  • Blinn-Phong 反射模型

  • 模拟聚光灯

  • 创建卡通着色效果

  • 模拟雾

  • 基于物理的反射模型

简介

在第三章 GLSL 着色器的基础 中,我们介绍了一些实现前固定功能管线产生的着色效果的技术。我们还探讨了 GLSL 的一些基本特性,如函数和子程序。在本章中,我们将超越这些入门特性,了解如何产生聚光灯、雾和卡通风格着色等着色效果。我们将介绍如何使用多个光源以及如何通过称为逐片段着色的技术来提高结果的真实感。

我们还将介绍非常流行且重要的 Blinn-Phong 反射模型和方向光源。

最后,我们将介绍如何通过配置早期深度测试优化来微调深度测试。

使用多个位置光源进行着色

当使用多个光源进行着色时,我们需要评估每个光源的反射模型,并将结果相加以确定表面位置反射的总光强度。自然的选择是创建统一数组来存储每个光源的位置和强度。我们将使用结构数组,这样我们就可以在单个统一变量中存储多个光源的值。

以下图像显示了使用五种不同颜色的光源渲染的“猪”网格。注意多个镜面高光:

图片

准备工作

使用顶点位置在属性位置零和法线在位置一设置你的 OpenGL 程序。

如何做到这一点...

要创建使用 Blinn-Phong 反射模型和多个光源进行渲染的着色器程序,请按照以下步骤操作:

在顶点着色器中,我们将使用与之前食谱中类似的结构,但我们将使用结构数组来存储光源。此外,我们只为每个光源存储两个强度值。第一个是环境强度,第二个用于漫反射和镜面反射。phongModel函数更新为使用数组中的一个值来使用光源信息:

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 

out vec3 Color; 

uniform struct LightInfo {
  vec4 Position; // Light position in eye coords.
  vec3 La;       // Ambient light intesity
  vec3 L;        // Diffuse and specular light intensity
} lights[5];

// Material and matrix uniforms omitted...

vec3 phongModel( int light, vec3 position, vec3 n ) { 
  vec3 ambient = lights[light].La * Material.Ka;
  vec3 s = normalize( lights[light].Position.xyz - position );
  float sDotN = max( dot(s,n), 0.0 );
  vec3 diffuse = Material.Kd * sDotN;
  vec3 spec = vec3(0.0);
  if( sDotN > 0.0 ) {
    vec3 v = normalize(-position.xyz);
    vec3 r = reflect( -s, n );
    spec = Material.Ks *
            pow( max( dot(r,v), 0.0 ), Material.Shininess );
  }

  return ambient + lights[light].L * (diffuse + spec);
}
void main() {
  vec3 camNorm = normalize( NormalMatrix * VertexNormal);
  vec3 camPosition = 
       ModelViewMatrix * vec4(VertexPosition,1.0)).xyz;

  // Evaluate the lighting equation, for each light
  Color = vec3(0.0);
  for( int i = 0; i < 5; i++ )
      Color += phongModel( i, camPosition, camNorm );

  gl_Position = MVP * vec4(VertexPosition,1.0);
}

片段着色器简单地将颜色应用到片段上,就像之前的食谱中一样。

在 OpenGL 应用程序中,在顶点着色器中设置lights数组的值。对于每个光源,使用以下类似代码。此示例使用第二章中描述的 C++着色器程序类使用 GLSL 程序prog是一个GLSLProgram对象):

prog.setUniform("lights[0].L", glm::vec3(0.0f,0.8f,0.8f) ); 
prog.setUniform("lights[0].La", glm::vec3(0.0f,0.2f,0.2f) );
prog.setUniform("lights[0].Position", position );

它是如何工作的...

在顶点着色器中,光照参数存储在统一数组 lights 中。数组的每个元素都是一个类型为 LightInfo 的结构体。本例使用五个光源。漫反射/镜面反射光强度存储在 L 字段中,环境光强度存储在 La 字段中,位置存储在相机坐标的 Position 字段中。

其余的统一变量基本上与第三章《GLSL 着色器基础》中介绍的 Phong 模型着色器相同。

phongModel 函数负责计算给定光源的阴影方程。光源的索引作为第一个参数,light 提供。方程基于该索引处的 lights 数组中的值进行计算。在这个例子中,我们没有为漫反射和镜面反射组件使用单独的光强度。

main 函数中,使用 for 循环计算每个光源的阴影方程,并将结果累加到输出变量 Color 中。

片段着色器简单地将插值后的颜色应用到片段上。

参见

  • 示例代码中的 chapter04/scenemultilight.cpp 文件

  • 第三章《GLSL 着色器基础》中的 实现 Phong 反射模型 菜谱

  • 使用方向性光源进行阴影处理 菜谱

使用方向性光源进行阴影处理

阴影方程的一个核心组件是从表面位置指向光源的向量(在之前的例子中为 s)。对于非常远的光源,这个向量在物体表面上几乎没有变化。事实上,对于非常遥远的光源,这个向量在表面的所有点上几乎是相同的(另一种思考方式是光线几乎是平行的)。这种模型适用于遥远但强大的光源,如太阳。这种光源通常被称为方向性光源,因为它没有特定的位置,只有方向。

当然,我们忽略了在现实中,光的强度随着与光源距离的平方而减少的事实。然而,对于方向性光源,忽略这一方面并不罕见。

如果我们使用方向性光源,场景中所有点的光源方向是相同的。因此,我们可以提高阴影计算的效率,因为我们不再需要为表面上的每个位置重新计算光源方向。

当然,位置光源和方向光源之间有视觉上的差异。以下图像显示了使用位置光源(左)和方向光源(右)渲染的环面。在左边的图像中,光源位于环面附近。由于所有光线都是平行的,方向光源覆盖了环面的大部分表面:

图片

在 OpenGL 的早期版本中,光位置的四分量用于确定是否将光源视为方向光源。第四分量为零表示光源是方向性的,位置应被视为指向源的方向(一个向量)。否则,位置被视为光源的实际位置。在本例中,我们将模拟相同的功能。

准备工作

将你的 OpenGL 程序设置好,顶点位置在属性位置零,顶点法线在位置一。

如何做...

要创建一个使用方向光源实现 Phong 反射模型的着色器程序,我们将使用与上一个配方相同的顶点着色器,只是使用单个光源。在phongModel函数中,用以下内容替换s向量的计算:

vec3 s;
if( Light.Position.w == 0.0 )
  s = normalize( Light.Position.xyz );
else
  s = normalize( Light.Position.xyz - position );

它是如何工作的...

在顶点着色器中,统一变量Light.Position的第四坐标用于确定是否将光源视为方向光源。在负责计算着色方程的phongModel函数内部,根据Light.Position的第四坐标是否为零来确定向量s的值。如果值为零,则将Light.Position归一化并用作指向光源的方向。否则,将Light.Position视为眼坐标中的位置,我们通过从顶点位置减去Light.Position并归一化结果来计算指向光源的方向。

更多内容...

使用方向光源时,由于不需要为每个顶点重新计算光方向,因此可以略微提高效率。这节省了一个减法操作,虽然这是一个小的收益,但如果有多个光源或按片段计算光照时,这种收益可能会累积。

参见

  • 示例代码中的chapter04/scenedirectional.cpp文件

  • 在第三章的实现 Phong 反射模型配方中

  • 本章的使用每片段着色以增强真实感配方

使用每片段着色以增强真实感

当着色方程在顶点着色器内被评估(正如我们在之前的食谱中所做的那样),我们最终会得到与每个顶点相关联的颜色。然后,该颜色在面上进行插值,片元着色器将插值后的颜色分配给输出片元。如前所述,这种技术称为Gouraud 着色。Gouraud 着色(就像所有着色技术一样)是一种近似,当例如顶点的反射特性与多边形的中心相差甚远时,可能会导致一些不太理想的结果。例如,一个明亮的镜面高光可能位于多边形的中心,而不是其顶点。仅仅在顶点评估着色方程将防止镜面高光出现在渲染结果中。当使用 Gouraud 着色时,由于颜色插值可能不会匹配整个面上的反射模型值,还可能出现其他不理想的伪影,例如多边形的边缘。

为了提高我们结果的确切性,我们可以将着色方程的计算从顶点着色器移动到片元着色器。我们不是在多边形上插值颜色,而是插值位置和法线向量,并使用这些值在每个片元上评估着色方程。这种技术称为Phong 着色Phong 插值。Phong 着色的结果要精确得多,并且提供了更令人满意的结果,但仍然可能有一些不理想的伪影出现。

以下图像显示了 Gouraud 和 Phong 着色的区别。左侧的场景使用 Gouraud(每顶点)着色渲染,右侧的场景使用 Phong(每片元)着色渲染。在茶壶下面是一个部分平面,使用单个四边形绘制。注意茶壶上的镜面高光以及茶壶下面平面的颜色变化:

在这个例子中,我们将通过从顶点着色器传递位置和法线到片元着色器,并在片元着色器内评估 Phong 反射模型来实现 Phong 着色。

准备工作

将程序设置为你 OpenGL 应用程序中的顶点位置在属性位置零,法线在位置一。你的 OpenGL 应用程序还必须提供统一变量的值,如前所述。

如何实现...

要创建一个可以使用 Phong 反射模型实现每片元(或 Phong)着色的着色器程序,请按照以下步骤操作:

  1. 顶点着色器简单地转换位置和法线到相机坐标,并将它们传递给片元着色器:
layout (location = 0) in vec3 VertexPosition;
layout (location = 1) in vec3 VertexNormal;

out vec3 Position;
out vec3 Normal;

uniform mat4 ModelViewMatrix, NormalMatrix, ProjectionMatrix, MVP;

void main() {
  Normal = normalize( NormalMatrix * VertexNormal);
  Position = ( ModelViewMatrix * vec4(VertexPosition,1.0) ).xyz;
  gl_Position = MVP * vec4(VertexPosition,1.0);
}
  1. 片元着色器使用从顶点着色器传递的值评估 Phong 反射模型:
in vec3 Position; 
in vec3 Normal; 
// Uniform variables...

layout( location = 0 ) out vec4 FragColor; 

vec3 phongModel( vec3 position, vec3 n ) { 
   // Compute and return Phong reflection model
} 

void main() { 
  FragColor = vec4(phongModel(Position, normalize(Normal)), 1);
} 

它是如何工作的...

顶点着色器有两个输出变量:PositionNormal。在main函数中,我们通过法线矩阵变换将顶点法线转换为相机坐标,并将转换后的值存储在Normal中。同样,通过模型视图矩阵变换顶点位置,将其转换为眼睛坐标,并将转换后的值存储在Position中。PositionNormal的值会自动插值,并通过相应的输入变量提供给片段着色器。然后,片段着色器使用提供的值计算 Phong 反射模型。在这里,我们重新归一化Normal向量,因为插值过程可能会创建非单位长度的向量。

最后,结果存储在输出变量FragColor中。

还有更多...

在片段着色器内评估着色方程会产生更精确的渲染。然而,我们付出的代价是在多边形的每个像素上评估着色模型,而不是在每个顶点上。好消息是,随着现代图形卡的处理能力,可能足以并行评估多边形的所有片段。这实际上可以提供几乎等效的性能,无论是按片段还是按顶点着色。

参见

  • 示例代码中的chapter04/sceneperfragment.cpp配方

  • 在第三章的《GLSL 着色器基础》中,实现 Phong 反射模型的配方

Blinn-Phong 反射模型

如在第三章的《GLSL 着色器基础》中,实现 Phong 反射模型的配方所述,方程中的镜面项涉及纯反射向量(r)和观察者方向(v)的点积:

为了评估前面的方程,我们需要找到纯反射向量(r),这是向量向光源(s)的反射,关于法向量(n)的反射:

图片

此方程使用 GLSL 函数reflect实现。

我们可以通过利用以下观察来避免计算r。当vr对齐时,法向量(n)必须在vs之间。让我们定义中点向量(h)为位于vs之间的向量,其中h在加法后归一化:

图片

下图显示了中点向量和其他向量之间的相对位置:

图片

我们可以将方程中镜面成分的点积替换为hn的点积:

图片

计算h所需的操作比计算r要少,因此我们预计使用中点向量可以带来一些效率提升。当所有向量共面时,中点向量与法线向量之间的角度与纯反射向量(r)与观察者方向向量(v)之间的角度成正比。因此,我们预计视觉结果将相似,尽管不完全相同。

这种对 Phong 反射模型的小幅修改是由 James Blinn 提出的,他曾在 NASA 的喷气推进实验室JPL)工作。由于他的修改版本在镜面项中使用了中点向量,因此被称为Blinn-Phong 模型

有趣的是,尽管 Blinn-Phong 模型看起来有些ad hoc,但它产生的结果比 Phong 模型更接近物理测量。有关详情,请参阅这篇论文:people.csail.mit.edu/wojciech/BRDFValidation/index.html

准备工作

首先,利用在配方使用片段着色提高真实感中展示的相同着色器程序,并按照那里的描述设置你的 OpenGL 程序。

如何做到...

使用与配方使用片段着色提高真实感中相同的着色器对,将片段着色器中的phongModel函数替换为以下内容:

vec3 blinnPhong( vec3 position, vec3 n ) { 
  vec3 ambient = Light.La * Material.Ka;
  vec3 s = normalize( Light.Position.xyz - position );
  float sDotN = max( dot(s,n), 0.0 );
  vec3 diffuse = Material.Kd * sDotN;
  vec3 spec = vec3(0.0);
  if( sDotN > 0.0 ) {
    vec3 v = normalize(-position.xyz);
    vec3 h = normalize( v + s );
    spec = Material.Ks *
            pow( max( dot(h,n), 0.0 ), Material.Shininess );
  }
  return ambient + Light.L * (diffuse + spec);
}

它是如何工作的...

我们通过将指向观察者的方向(v)和指向光源的方向(s)相加,并对结果进行归一化来计算中点向量。然后,将中点向量的值存储在h中。然后,将镜面计算修改为使用h与法线向量(n)的点积。其余的计算保持不变。

还有更多...

以下截图显示了使用 Blinn-Phong 模型(右侧)渲染的茶壶,与使用第三章中实现 Phong 反射模型配方提供的方程式进行相同渲染的结果(左侧)。中点向量产生更大的镜面高光。如果需要,我们可以通过增加Material.Shininess指数的值来补偿镜面高光大小的差异:

参考以下内容

  • 示例代码中的chapter04/shader/blinnphong.vert.glslchapter04/shader/blinnphong.frag.glsl文件

  • 使用片段着色提高真实感配方

模拟聚光灯

固定功能管线能够定义光源为聚光灯。在这种配置下,光源被认为是只在一个锥形范围内辐射光线的光源,其顶点位于光源处。此外,光线被衰减,使得它在锥形轴上最大,向边缘逐渐减小。这使得我们能够创建具有类似真实聚光灯视觉效果的灯光源。

以下截图显示了一个茶壶和一个环面使用单个聚光灯渲染的效果。注意聚光灯的强度从中心向边缘略微减弱:

图片 1

在这个配方中,我们将使用着色器来实现聚光灯效果,类似于固定功能管线产生的效果:

图片 2

聚光灯的锥形由聚光灯方向(d,在先前的图像中),截止角(c,在先前的图像中)和位置(P,在先前的图像中)定义。聚光灯的强度被认为是沿着锥形轴最强,随着向边缘移动而减小。

准备工作

从配方中的相同顶点着色器开始,使用每片段着色提高真实感。您的 OpenGL 程序必须设置该顶点着色器以及以下片段着色器中定义的所有统一变量的值。

如何实现...

要创建使用聚光灯的 ADS 着色模型的着色器程序,请在片段着色器中使用以下代码:

in vec3 Position; 
in vec3 Normal; 

uniform struct SpotLightInfo {
    vec3 Position;  // Position in cam coords
    vec3 L;         // Diffuse/spec intensity
    vec3 La;        // Amb intensity
    vec3 Direction; // Direction of the spotlight in cam coords.
    float Exponent; // Angular attenuation exponent
    float Cutoff;   // Cutoff angle (between 0 and pi/2)
} Spot;

// Material uniforms...

layout( location = 0 ) out vec4 FragColor; 

vec3 blinnPhongSpot( vec3 position, vec3 n ) { 
  vec3 ambient = Spot.La * Material.Ka, 
    diffuse = vec3(0), spec = vec3(0);
  vec3 s = normalize( Spot.Position - position );
  float cosAng = dot(-s, normalize(Spot.Direction));
  float angle = acos( cosAng );
  float spotScale = 0.0;
  if(angle < Spot.Cutoff ) {
    spotScale = pow( cosAng, Spot.Exponent );
    float sDotN = max( dot(s,n), 0.0 );
    diffuse = Material.Kd * sDotN;
    if( sDotN > 0.0 ) {
      vec3 v = normalize(-position.xyz);
      vec3 h = normalize( v + s );
      spec = Material.Ks *
        pow( max( dot(h,n), 0.0 ), Material.Shininess );
    }
  }
  return ambient + spotScale * Spot.L * (diffuse + spec);
}

void main() {
  FragColor = vec4(blinnPhongSpot(Position, normalize(Normal)), 1);
}

工作原理...

SpotLightInfo 结构定义了聚光灯的所有配置选项。我们声明一个名为 Spot 的单个统一变量来存储我们的聚光灯数据。Position 字段定义了聚光灯在视眼坐标系中的位置。L 字段是聚光灯的强度(漫反射和镜面反射),而 La 是环境强度。Direction 字段将包含聚光灯指向的方向,这定义了聚光灯锥的中心轴。此向量应在相机坐标系中指定。在 OpenGL 程序中,它应以与法向量相同的方式通过法线矩阵进行变换。我们可以在着色器中这样做;然而,在着色器中,法线矩阵将针对正在渲染的对象进行指定。这可能不是聚光灯方向适当的变换。

Exponent字段定义了在计算聚光灯的角衰减时使用的指数。聚光灯的强度与从光源到表面位置的向量(变量s的负值)与聚光灯方向之间的角度余弦成正比。然后将余弦项提升到变量Exponent的幂。此变量的值越大,聚光灯的强度衰减越快。这与镜面着色项中的指数类似。

Cutoff字段定义了聚光灯光锥中心轴与外边缘之间的角度。我们以弧度为单位指定此角度。

blinnPhongSpot函数计算 Blinn-Phong 反射模型,使用聚光灯作为光源。第一行计算环境光照分量并将其存储在ambient变量中。第二行计算从表面位置到聚光灯位置的向量(s)。接下来,我们计算从聚光灯到表面点的方向(-s)与聚光灯方向的点积,并将结果存储在cosAng中。然后计算它们之间的角度并将其存储在变量angle中。变量spotScale将用于缩放聚光灯的漫反射/镜面强度值。它最初设置为 0。

我们然后将angle变量的值与Spot.Cutoff变量的值进行比较。如果angle大于零且小于Spot.Cutoff,则表面点位于聚光灯的锥体内。否则,表面点仅接收环境光,因此我们跳过其余部分并仅返回环境分量。

如果angle小于Spot.Cutoff,我们通过将-sspotDir的点积提升到Spot.Exponent的幂来计算spotScale值。spotScale的值用于缩放光强度,使得光在锥体的中心最大,并向边缘移动时逐渐减小。最后,像往常一样计算 Blinn-Phong 反射模型。

参见

  • 示例代码中的Chapter04/scenespot.cpp文件

  • The Using per-fragment shading for improved realism recipe

  • The Blinn-Phong reflection model recipe

创建卡通着色效果

卡通着色(也称为赛璐珞着色)是一种非真实感渲染技术,旨在模仿手绘动画中常用的着色风格。有许多不同的技术被用来产生这种效果。在这个配方中,我们将使用一个非常简单的技术,它涉及到对环境光和漫反射着色模型的轻微修改。

基本效果是在大面积的恒定颜色之间有尖锐的过渡。这模拟了艺术家使用笔或刷的笔触来着色物体的方式。以下图像显示了使用卡通着色渲染的茶壶和环面示例:

这里介绍的技术只涉及计算典型 ADS 着色模型的周围和漫反射分量,并对漫反射分量的余弦项进行量化。换句话说,通常在漫反射项中使用的点积值被限制在固定数量的可能值中。以下表格说明了四个级别的概念:

s 和 n 之间角度的余弦值 使用的值
介于 1 和 0.75 之间 0.75
介于 0.75 和 0.5 之间 0.5
介于 0.5 和 0.25 之间 0.25
介于 0.25 和 0.0 之间 0.0

在前一个表格中,s 是指向光源的向量,n 是表面的法向量。通过这种方式限制余弦项的值,着色在从一级到另一级之间显示出强烈的间断(参见前一个图像),模拟手绘细胞动画的笔触。

准备工作

使用片段着色提高真实感 菜单中的相同顶点着色器开始。您的 OpenGL 程序必须设置该顶点着色器中定义的所有统一变量的值,以及这里描述的片段着色器代码。

如何做到这一点...

要创建产生卡通着色效果的着色器程序,请使用以下片段着色器:

in vec3 Position;
in vec3 Normal;

uniform struct LightInfo {
  vec4 Position; // Light position in eye coords.
  vec3 La;       // Ambient light intesity
  vec3 L;        // Diffuse and specular light intensity
} Light;

uniform struct MaterialInfo {
  vec3 Ka; // Ambient reflectivity
  vec3 Kd; // Diffuse reflectivity
} Material;

const int levels = 3;
const float scaleFactor = 1.0 / levels;

layout( location = 0 ) out vec4 FragColor;

vec3 toonShade( ) {
    vec3 n = normalize( Normal );
    vec3 s = normalize( Light.Position.xyz - Position );
    vec3 ambient = Light.La * Material.Ka;
    float sDotN = max( dot( s, n ), 0.0 );
    vec3 diffuse = Material.Kd * floor( sDotN * levels ) * scaleFactor;

    return ambient + Light.L * diffuse;
}

void main() {
    FragColor = vec4(toonShade(), 1.0);
}

它是如何工作的...

常量变量 levels 定义了在漫反射计算中将使用多少个不同的值。这也可以定义为统一变量,以便从主 OpenGL 应用程序中进行配置。我们将使用此变量来量化漫反射计算中余弦项的值。

toonShade 函数是此着色器中最重要的一部分。我们首先计算 s,即指向光源的向量。接下来,我们通过评估 sNormal 的点积来计算漫反射分量的余弦项。下一行以以下方式量化该值。由于两个向量都是归一化的,并且我们使用 max 函数去除了负值,所以我们确信余弦值的范围在零到一之间。通过将此值乘以 levels 并取 floor,结果将是一个介于 0levels -1 之间的整数。当我们除以 levels(通过乘以 scaleFactor)时,我们将这些整数值缩放到零到一之间。结果是可以在零和一之间均匀分布的 levels 个可能值之一。然后将此结果乘以 Material.Kd,即漫反射反射率项。

最后,我们将漫反射和周围分量组合起来,以获得片段的最终颜色。

更多内容...

在量化余弦项时,我们本可以使用 ceil 而不是 floor。这样做将简单地将每个可能值向上移动一个级别。这将使着色级别稍微亮一些。

在大多数细胞动画中常见的典型卡通风格包括围绕轮廓和形状其他边缘的黑轮廓。这里提出的着色模型不会产生这些黑轮廓。有几种产生它们的技术,我们将在本书稍后部分查看其中一种。

参见

  • 示例代码中的 chapter04/scenetoon.cpp 文件

  • 使用片段着色提高真实感 的配方

  • Blinn-Phong 反射模型 的配方

  • 在第六章 Chapter 6,图像处理和屏幕空间技术 中,使用几何着色器绘制轮廓线 的配方

模拟雾

通过将每个片段的颜色与一个恒定的雾色混合,可以简单地实现雾效。雾色的影响程度由与摄像机的距离决定。我们可以使用距离和雾色量之间的线性关系,或者使用指数等非线性关系。

以下图像显示了使用与距离成线性关系的雾色混合产生的雾效渲染的四个茶壶:

图片

要定义这种线性关系,我们可以使用以下方程:

图片

在前面的方程中,d[min] 是雾最少的(没有雾贡献)眼睛距离,d[max] 是雾色掩盖场景中所有其他颜色的距离。变量 z 代表眼睛的距离。值 f 是雾因子。雾因子为零表示 100% 雾,因子为一表示没有雾。由于雾通常在更长的距离上看起来最浓,因此当 |z| 等于 d[max] 时,雾因子最小,当 |z| 等于 d[min] 时,雾因子最大。

由于雾是通过片段着色器应用的,因此效果只会在渲染的对象上可见。它不会出现在场景中的任何 空间(背景)中。为了帮助使雾效保持一致,您应使用与最大雾色相匹配的背景色。

准备工作

使用片段着色提高真实感 配方中的相同顶点着色器开始。您的 OpenGL 程序必须设置该顶点着色器以及以下部分中显示的片段着色器中定义的所有统一变量的值。

如何做到...

要创建一个产生类似雾效的着色器,请使用以下代码作为片段着色器:

in vec3 Position; 
in vec3 Normal; 
// Light and material uniforms ...
uniform struct FogInfo {
  float MaxDist;
  float MinDist;
  vec3 Color;
} Fog;

layout( location = 0 ) out vec4 FragColor;

vec3 blinnPhong( vec3 position, vec3 n ) { 
  // Blinn-Phong reflection model ...
}

void main() {
    float dist = abs( Position.z );
    float fogFactor = (Fog.MaxDist - dist) /
                      (Fog.MaxDist - Fog.MinDist);
    fogFactor = clamp( fogFactor, 0.0, 1.0 );
    vec3 shadeColor = blinnPhong(Position, normalize(Normal));
    vec3 color = mix( Fog.Color, shadeColor, fogFactor );
    FragColor = vec4(color, 1.0);
}

它是如何工作的...

在这个着色器中,blinnPhong 函数与 Blinn-Phong 反射模型 配方中使用的完全相同。*处理雾效的部分位于 main 函数中。

均匀变量Fog包含定义雾的广度和颜色的参数。MinDist字段是眼睛到雾的起始点的距离,而MaxDist是雾最大时的距离。Color字段是雾的颜色。

dist变量用于存储表面点到眼睛位置的距离。位置z坐标被用作实际距离的估计。fogFactor变量使用前面的方程计算。由于dist可能不在Fog.MinDistFog.MaxDist之间,我们将fogFactor的值夹在零和一之间。

然后我们调用blinnPhong函数来评估反射模型。这个结果存储在shadeColor变量中。

最后,我们根据fogFactor的值将shadeColorFog.Color混合在一起,并将结果用作片段颜色。

更多内容...

在这个配方中,我们使用了雾颜色与眼睛距离之间的线性关系。另一种选择是使用指数关系。例如,以下方程可以用来:

在上述方程中,d代表雾的密度。较大的值会创建更浓的雾。我们还可以将指数平方,以创建一个稍微不同的关系(随着距离的增加,雾的增长更快)。

从眼睛计算距离

在前面的代码中,我们使用了z坐标的绝对值作为从摄像机到距离的估计。这可能会在某些情况下使雾看起来有点不真实。为了计算更精确的距离,我们可以替换以下行:

float dist = abs( Position.z ); 

以下内容:

float dist = length( Position.xyz ); 

当然,后一种版本需要开平方,因此在实践中可能会稍微慢一些。

参见

  • 示例代码中的chapter04/scenefog.cpp文件

  • Blinn-Phong 反射模型的配方

基于物理的反射模型

基于物理的渲染PBR是一个总称,它包括利用基于物理的光和反射模型的工具和技术。这个术语本身定义得相当宽松,但可以大致描述为一个着色/反射模型,它试图尽可能准确地模拟光与物质相互作用的物理过程。这个术语对不同的人可能意味着不同的事情,但就我们的目的而言,我们主要感兴趣的是它与 Phong 和 Blinn-Phong 反射模型的区别。

Blinn-Phong 模型是一种基于观察的反射经验模型。PBR 模型也可以被视为一种经验模型,但通常来说,它在表示交互的物理方面更为详细和准确。Blinn-Phong 模型使用了一些并非基于物理的参数,但能产生有效结果。例如,将光强度分为三个(或两个)单独的值在物理上并不准确(只有一个光源)。然而,它为艺术家提供了许多“可调”参数来工作,使他们能够达到期望的外观。

近年来,由于 PBR 技术减少了可调参数的数量,并在广泛的材料上提供更一致的结果,因此受到了青睐。艺术家们发现,之前的模型(非 PBR)往往难以“正确”实现。当场景由多种材料组成时,参数可能需要大量的“调整”才能保持一致。基于 PBR 的技术试图更准确地表示物理,这往往使得在广泛的照明设置下看起来更一致,从而减少了艺术家所需的微调量。

在这个配方中,我们将实现一个基于点光源的基本 PBR 反射模型。然而,在我们开始之前,让我们回顾一下数学。在以下方程中,我们将使用向量nlvh,它们如下定义:

  • n: 表面法线

  • l: 表示入射光方向的向量

  • v: 指向观察者(摄像机)的方向

  • h: 位于lv之间的向量(如在 Blinn-Phong 模型中)

描述光线从表面散射的流行数学模型被称为反射方程(渲染方程的特殊情况),其形式如下:

图片

这个积分可能看起来有点吓人,但基本上,它意味着以下内容。从表面(L[o])向观察者(v)发出的辐射量等于 BRDF(f)乘以入射辐射量(L[i])的积分(可以认为是加权求和)。积分是在表面上方的半球内进行的,对于半球内的所有入射光方向(l),通过余弦因子(n · l)加权。这个余弦因子是一个权重,本质上代表了情况的几何形状。光线越直接地击中表面,其权重就越高。关于这个方程的更完整推导可以在几篇文献中找到。在这个配方中,我们将这个积分简化为一个简单的总和,假设唯一的入射辐射源是点光源。这当然是一个巨大的简化,我们将在稍后考虑一些更精确评估积分的技术。

对于我们来说,最重要的项是 BRDF 项(f),它代表双向反射分布函数。它表示从表面点反射的光照度比例,给定入射方向(l)和出射方向(v)。它的值是一个光谱值(R,G,B),其分量范围从 0 到 1。在这个配方中,我们将 BRDF 建模为两部分之和:散射 BRDF 和镜面 BRDF:

散射 BRDF 代表稍微吸收到表面然后重新辐射的光线。通常,建模这个项时,辐射光线没有特定的方向。它在所有出射方向上均匀辐射。这也被称为朗伯反射率。由于它不依赖于入射或出射方向,朗伯 BRDF 只是一个常数:

c[diff]项代表散射辐射的光线比例。它通常被认为是物体的散射颜色。

镜面项代表表面反射率。光线直接从物体表面反射,没有被吸收。这有时也被称为光泽反射率。建模这种反射率的一种常见方法是基于微 facet 理论。该理论是为了描述从一般非光学平坦表面的反射而开发的。它将表面建模为由光学平坦(镜子)的小面组成,这些面以各种方向取向。只有那些正确取向以反射向观察者的面才能对 BRDF 做出贡献。

我们将这个 BRDF 表示为三个项和一个校正因子(分母)的乘积:

我不会详细介绍这些项的细节。更多信息,请参阅以下也见部分。相反,我将简要描述每一个。F项代表菲涅耳反射,从光学平坦表面反射的光线比例。菲涅耳反射率取决于法线与入射光线方向(入射角)之间的角度。然而,由于我们使用微 facet 理论,贡献的微 facet 表面是那些其法线向量与中点向量(h)平行的表面。因此,我们使用lh之间的角度而不是ln之间的角度。

菲涅耳反射率也取决于表面的折射率。然而,我们将使用一个不同的参数进行近似。这被称为Schlick 近似

与使用折射率指数不同,这个近似使用的是 F[0],材料的特征镜面反射率。换句话说,就是入射角为零度时的反射率。这个术语很有用,因为它可以用作镜面“颜色”,这对艺术家来说更加直观和自然。

为了进一步理解这个 F[0] 术语,让我们考虑常见材料的值。结果证明,材料的光学特性与其电学特性密切相关。因此,将材料分为三类是有帮助的:介电体(绝缘体)、金属(导体)和半导体。我们的模型将忽略第三类,并专注于前两类。金属通常不表现出任何漫反射,因为任何折射到表面的光线都会被自由电子完全吸收。金属的 F[0] 值比介电体大得多。实际上,介电体的 F[0] 值非常低,通常在 0.05(对于所有 RGB 成分)的范围内。这导致我们采用以下技术。

我们将颜色与材料关联起来。如果材料是金属,则没有漫反射,因此我们将 c[diff] 设置为 (0,0,0),并将颜色作为 Fresnel 项中 F[0] 的值。如果材料是介电体,我们将 F[0] 设置为某个小值(我们将使用 (0.04, 0.04, 0.04)),并将颜色作为 c[dif*f] 的值。本质上,我们为金属和介电体使用两种略有不同的模型,根据需要在这两种模型之间切换。我们不是使用相同的模型来表示金属和非金属,并调整参数来表示每个,而是将它们分为两个不同的类别,每个类别都有一个略有不同的 BRDF 模型。这个流行术语被称为金属度工作流程

接下来,让我们考虑镜面 BRDF 中的 D 项。这是微观几何法线分布函数(或微观面分布函数)。它描述了微观表面方向的统计分布。它有一个标量值,并给出在方向 h 上的微观面法线的相对浓度。这个术语对镜面高光的大小和形状有很强的影响。这个函数有多个选择,近年来基于物理测量已经开发出几个。我们将使用图形研究人员 Trowbridge 和 Reitz 中的一个流行的函数,一个独立的研究团队也将其命名为 GGX

图片

在这个方程中,α 是表示表面粗糙度的术语。遵循他人的做法,我们将使用粗糙度参数 r,并将 α 设置为

最后,我们将考虑镜面 BRDF 中的 G 项。这是几何函数,描述了具有给定法线的微表面从光方向(l)和视方向(v)都能被看到的可能性。其值是一个介于 0 和 1 之间的标量。这对于能量守恒至关重要。我们将使用以下模型来表示 G

其中:

常数 k 是一个与粗糙度成正比的值。再次遵循他人的做法(见以下 另见),我们将使用以下来表示 k

将所有这些放在一起,我们现在已经为我们的 BRDF 提供了一个完整的表示。在跳入代码之前,让我们回顾一下反射率方程。这是我们讨论的第一个方程,包含对表面上方半球所有方向的积分。在交互式应用程序中尝试评估这个积分将非常昂贵,所以我们通过假设所有入射光都直接来自点光源来简化它。如果我们这样做,积分将简化为以下求和:

其中 N 是点光源的数量,L[i] 是由于第 i 个光源接收到的表面照明,l[i] 是指向第 i 个光源的方向。由于光的强度随距离增加而减小,我们将使用平方反比关系。然而,这里可以使用其他模型:

I[i] 是光源的强度,d[i] 是从表面点到光源的距离。

现在我们有一个完整的基于微面的模型,可以应用于金属表面和介电质。正如我们之前所讨论的,我们将根据我们是在处理金属还是介电质来稍微修改 BRDF。这个 BRDF 的参数数量相对较小。以下参数将定义一个材料:

  • 表面粗糙度(r),一个介于 0 和 1 之间的值

  • 材料是否为金属(布尔值)

  • 一种颜色,被解释为介电质的漫反射颜色,或金属的特定镜面反射率(F[0])。

这些参数相当直观易懂,与之前配方中 Blinn-Phong 模型中的许多参数相比。只有一个颜色,粗糙度比镜面指数更直观。

准备工作

我们将通过从 Blinn-Phong 配方中的着色器对开始设置我们的着色器,但我们将更改片段着色器。让我们为光和材料信息设置一些统一变量。

对于光源,我们只需要一个位置和一个强度:

uniform struct LightInfo {
  vec4 Position; // Light position in cam. coords.
  vec3 L;        // Intensity
} Light[3];

对于材料,我们需要之前提到的三个值:

uniform struct MaterialInfo {
  float Rough;  // Roughness
  bool Metal;   // Metallic (true) or dielectric (false)
  vec3 Color;   // Diffuse color for dielectrics, f0 for metallic
} Material;

如何做到这一点...

我们将为镜面 BRDF 中的三个术语定义一个函数。使用以下步骤:

  1. 使用 Schlick 近似定义一个用于菲涅耳项的函数:
vec3 schlickFresnel( float lDotH ) {
  vec3 f0 = vec3(0.04);  // Dielectrics
  if( Material.Metal ) {
    f0 = Material.Color;
  }
  return f0 + (1 - f0) * pow(1.0 - lDotH, 5);
}
  1. 定义一个用于几何项 G 的函数:
float geomSmith( float dotProd ) {
  float k = (Material.Rough + 1.0) * (Material.Rough + 1.0) / 8.0;
  float denom = dotProd * (1 - k) + k;
  return 1.0 / denom;
}
  1. 基于 GGX/Trowbridge-Reitz 的法线分布函数 D
float ggxDistribution( float nDotH ) {
  float alpha2 = Material.Rough * Material.Rough * Material.Rough * Material.Rough;
  float d = (nDotH * nDotH) * (alpha2 - 1) + 1;
  return alpha2 / (PI * d * d);
}
  1. 我们现在将定义一个函数,用于计算单个光源的整个模型:
vec3 microfacetModel( int lightIdx, vec3 position, vec3 n ) { 
  vec3 diffuseBrdf = vec3(0.0); // Metallic
  if( !Material.Metal ) {
    diffuseBrdf = Material.Color;
  }

  vec3 l = vec3(0.0), 
    lightI = Light[lightIdx].L;
  if( Light[lightIdx].Position.w == 0.0 ) {  // Directional light
    l = normalize(Light[lightIdx].Position.xyz);
  } else {            // Positional light
    l = Light[lightIdx].Position.xyz - position;
    float dist = length(l);
    l = normalize(l);
    lightI /= (dist * dist);
  }

  vec3 v = normalize( -position );
  vec3 h = normalize( v + l );
  float nDotH = dot( n, h );
  float lDotH = dot( l, h );
  float nDotL = max( dot( n, l ), 0.0 );
  float nDotV = dot( n, v );
  vec3 specBrdf = 0.25 * ggxDistribution(nDotH) * 
        schlickFresnel(lDotH) * geomSmith(nDotL) * geomSmith(nDotV);

  return (diffuseBrdf + PI * specBrdf) * lightI * nDotL;
}
  1. 我们通过求和光源、应用伽马校正并写出结果来将这些内容全部整合在一起:
void main() {
  vec3 sum = vec3(0), n = normalize(Normal);
  for( int i = 0; i < 3; i++ ) {
    sum += microfacetModel(i, Position, n);
  }

  // Gamma 
  sum = pow( sum, vec3(1.0/2.2) );

  FragColor = vec4(sum, 1);
}

它是如何工作的...

schlickFresnel 函数计算 F 的值。如果材料是金属,则 F[0] 的值取自 Material.Color 的值。否则,我们简单地使用 (0.04, 0.04, 0.04)。由于大多数介电体具有相似的较小的 F[0] 值,这对于常见的介电体来说是一个相对较好的近似。

geomSmithggxDistribution 函数是之前描述的方程的直接实现。然而,在 geomSmith 中,我们省略了分子。这是因为它将与整体镜面 BRDF 的分母相抵消。

microfacetModel 函数计算 BRDF。如果材料是金属的,则将 diffuse 项设置为 0,否则设置为材料颜色的值。注意,我们在这里省略了 π 因子。这是因为它将与整体求和(最后一个求和方程)中的 π 项相抵消,因此在这里不需要包括它。

接下来,我们根据是否是方向光或位置光来确定 L[i] 项(lightI)和向量 l。如果是方向光,lightI 就是 Light[lightIdx].L 的值,否则,它将按光源距离的平方倒数进行缩放。

然后,我们使用之前定义的函数计算镜面 BRDF (specBrdf)。注意(如前所述),我们省略了 BRDF 的分母(除了 0.25 的因子),因为这些两个点积与 G[1] 函数的分子相抵消。

此函数的最终结果是总 BRDF 乘以光强度,乘以 nl 的点积。我们只乘以镜面 BRDF 乘以 π,因为我们省略了漫反射 BRDF 中的 π 项。

一些简单材料的结果显示在下图中:

后排展示了从左到右粗糙度逐渐增加的介电(非金属)材料。前排展示了五种具有不同 F[0] 值的金属材料。

更多内容...

而不是将 Material.Metal 作为布尔值,可以选择将其设置为介于 0 和 1 之间的连续值。实际上,这正是某些实现所做的那样。然后,该值将用于在两种模型(金属和介电)之间进行插值。然而,这使得参数对于艺术家来说不太直观,你可能会发现额外的可配置性并不那么有用。

参见

  • 示例代码中的 chapter04/scenepbr.cpp 文件

  • 对 PBR 模型背后的数学原理以及来自 SIGGRAPH 2013 会议的其他信息的出色解释,请参阅blog.selfshadow.com/publications/s2013-shading-course/

  • 这本精彩的书籍:《基于物理的渲染》(Physically Based Rendering),作者为 Pharr, Jakob, 和 Humphreys,目前为第三版

第五章:使用纹理

在本章中,我们将涵盖以下食谱:

  • 应用 2D 纹理

  • 应用多个纹理

  • 使用 alpha 贴图丢弃像素

  • 使用法线贴图

  • 视差贴图

  • 带自阴影的陡峭视差贴图

  • 使用立方体贴图模拟反射

  • 使用立方体贴图模拟折射

  • 应用投影纹理

  • 将渲染输出到纹理

  • 使用采样器对象

  • 基于漫反射图像的照明

简介

纹理是实时渲染,尤其是 OpenGL 中一个重要且基本的部分。在着色器中使用纹理可以打开一个巨大的可能性范围。除了将纹理用作颜色信息源之外,它们还可以用于深度信息、着色参数、位移贴图、法线向量和其他顶点数据等。这个列表几乎是无穷无尽的。纹理是 OpenGL 程序中用于高级效果的最广泛使用的工具之一,这种情况在不久的将来不太可能改变。

在 OpenGL 4 中,我们现在可以通过缓冲区纹理、着色器存储缓冲区对象和图像纹理(图像加载/存储)来读取和写入内存。这进一步模糊了纹理的确切定义。一般来说,我们可能只是将其视为一个可能包含图像的数据缓冲区。

OpenGL 4.2 引入了不可变存储纹理。尽管这个术语可能意味着什么,但不可变存储纹理并不是不能改变的纹理。相反,术语不可变指的是一旦纹理被分配,存储就不能改变。也就是说,大小、格式和层数是固定的,但纹理内容本身可以修改。不可变一词指的是内存的分配,而不是内存的内容。在绝大多数情况下,不可变存储纹理更可取,因为可以避免许多运行时(绘制时)的一致性检查,并且我们可以包含一定程度的“类型安全”,因为我们不能意外地改变纹理的分配。在这本书的整个过程中,我们将专门使用不可变存储纹理。

不可变存储纹理是通过glTexStorage*函数分配的。如果你对纹理有经验,你可能习惯于使用glTexImage*函数,这些函数仍然受支持,但创建可变存储纹理。

在本章中,我们将探讨一些基本的和高级的纹理技术。我们将从基本的颜色纹理应用开始,然后过渡到使用纹理作为法线贴图和环境贴图。通过环境贴图,我们可以模拟反射和折射等现象。我们将看到一个将纹理投影到场景中物体上的例子,这类似于幻灯片投影仪投影图像的方式。最后,我们将通过一个使用帧缓冲对象FBOs)直接渲染到纹理的例子来结束本章。

应用 2D 纹理

在 GLSL 中,将纹理应用于表面涉及访问纹理内存以检索与纹理坐标相关联的颜色,然后将该颜色应用于输出片段。将颜色应用于输出片段可能涉及将颜色与着色模型产生的颜色混合,直接应用颜色,使用反射模型中的颜色,或某些其他混合过程。在 GLSL 中,通过sampler变量访问纹理。sampler 变量是纹理单元的句柄。它通常在着色器内声明为 uniform 变量,并在主 OpenGL 应用程序中初始化,以指向适当的纹理单元。

在本食谱中,我们将查看一个简单示例,涉及将 2D 纹理应用于表面,如图所示。我们将使用纹理颜色作为 Blinn-Phong 反射模型中的漫反射(和环境)反射率项。以下图像显示了将砖纹纹理应用于立方体的结果。纹理显示在右侧,渲染结果显示在左侧:

图片

准备工作

设置你的 OpenGL 应用程序以提供顶点位置在属性位置 0,顶点法线在属性位置 1,以及纹理坐标在属性位置 2。Blinn-Phong 反射模型的参数再次在着色器内声明为 uniform 变量,并且必须从 OpenGL 程序中初始化。将着色器的句柄通过名为programHandle的变量使其可用。

如何实现...

要使用 2D 纹理渲染简单形状,请按照以下步骤操作:

  1. 我们将定义一个简单的(静态)函数用于加载和初始化纹理:
GLuint Texture::loadTexture( const std::string & fName ) {
  int width, height;
  unsigned char * data = Texture::loadPixels(fName, width, height);
  GLuint tex = 0;
  if( data != nullptr ) {
    glGenTextures(1, &tex);
    glBindTexture(GL_TEXTURE_2D, tex);
    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 
              width, height, GL_RGBA, GL_UNSIGNED_BYTE, data);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, 
    GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
    GL_NEAREST);

    Texture::deletePixels(data);
  }
  return tex;
}
  1. 在 OpenGL 应用程序的初始化中,使用以下代码加载纹理,将其绑定到纹理单元0,并将 uniform 变量Tex1设置为该纹理单元:
GLuint tid = Texture::loadTexture("brick1.png");

glActiveTexture(GL_TEXTURE0); 
glBindTexture(GL_TEXTURE_2D, tid);

// Set the Tex1 sampler uniform to refer to texture unit 0 
int loc = glGetUniformLocation(programHandle, "Tex1"); 
glUniform1i(loc, 0); 
  1. 顶点着色器将纹理坐标传递给片段着色器:
layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 
layout (location = 2) in vec2 VertexTexCoord; 

out vec3 Position; 
out vec3 Normal; 
out vec2 TexCoord; 

// Other uniforms...

void main() 
{ 
  TexCoord = VertexTexCoord; 
  // Assign other output variables here... 
}
  1. 片段着色器查找纹理值并将其应用于 Blinn-Phong 模型中的漫反射反射率:
in vec3 Position; 
in vec3 Normal; 
in vec2 TexCoord; 

// The texture sampler object
uniform sampler2D Tex1; 

// Light/material uniforms...

void blinnPhong( vec3 pos, vec3 n ) { 
  vec3 texColor = texture(Tex1, TexCoord).rgb;
  vec3 ambient = Light.La * texColor;
  // ...
  vec3 diffuse = texColor * sDotN;
  // Compute spec...
  return ambient + Light.L * (diffuse + spec);   
} 
void main() { 
  FragColor = vec4( blinnPhong(Position, normalize(Normal) ), 1 ); 
} 

它是如何工作的...

第一段代码定义了一个简单的函数,该函数从文件中加载纹理,将纹理数据复制到 OpenGL 内存中,并设置magmin过滤器。它返回纹理 ID。第一步,加载纹理图像文件,是通过调用另一个方法(Texture::loadPixels)完成的,该方法使用与示例代码一起提供的图像加载器。加载器来自头文件stb_image.h,可在 GitHub 上找到(github.com/nothings/stb)。它读取图像并将像素数据存储在一个无符号字节的数组中,顺序为 RGBA。图像的宽度和高度通过最后两个参数返回。我们保留对图像数据的指针,简单地命名为data

接下来的两行涉及通过调用 glGenTextures 创建一个新的纹理对象。新纹理对象的句柄存储在 tex 变量中。

要加载和配置纹理对象,我们执行以下操作。

  1. 我们调用 glBindTexture 将新纹理对象绑定到 GL_TEXTURE_2D 目标。

  2. 一旦纹理绑定到该目标,我们使用 glTexStorage2D 为纹理分配不可变存储。

  3. 之后,我们使用 glTexSubImage2D 将该纹理的数据复制到纹理对象中。此函数的最后一个参数是图像原始数据的指针。

  4. 下一步涉及使用 glTexParameteri 为纹理对象设置放大和缩小过滤器。对于这个例子,我们将使用 GL_LINEAR 作为前者,GL_NEAREST 作为后者。

纹理过滤器设置确定在从纹理返回颜色之前是否进行插值。此设置可以强烈影响结果的质量。在这个例子中,GL_LINEAR 表示它将返回最接近纹理坐标的四个纹素的平均加权值。有关其他过滤选项的详细信息,请参阅 OpenGL 文档中的 glTexParameterwww.opengl.org/wiki/GLAPI/glTexParameter

接下来,我们删除由 data 指向的纹理数据。没有必要保留它,因为它已经通过 glTexSubImage2D 复制到纹理内存中。为此,我们调用 Texture::deletePixels 函数。(内部调用 stb_image 库提供的函数 stbi_image_free。)然后,我们返回新纹理对象的 ID。

在接下来的代码段中,我们调用 Texture::loadTexture 函数来加载纹理,然后将其绑定到纹理单元 0。为此,首先我们调用 glActiveTexture 将当前活动纹理单元设置为 GL_TEXTURE0(第一个纹理单元,也称为 **纹理 **通道)。随后的纹理状态调用将作用于纹理单元零。然后,我们使用 glBindTexture 将新纹理绑定到该单元。最后,我们将 GLSL 程序中的统一变量 Tex1 设置为零。这是我们采样变量。注意,它在片段着色器中声明,类型为 sampler2D。将其值设置为零表示 OpenGL 系统该变量应引用纹理单元零(与之前使用 glActiveTexture 选择的是同一个)。

顶点着色器与前面例子中使用的非常相似,除了增加了纹理坐标输入变量 VertexTexCoord,它绑定到属性位置 2。它的值简单地通过将其分配给着色器输出变量 TexCoord 传递给片段着色器。

片段着色器与之前章节中使用的着色器也非常相似。主要的变化是Tex1统一变量和blinnPhong函数。Tex1是一个sampler2D变量,由 OpenGL 程序分配以引用纹理单元零。在blinnPhong函数中,我们使用该变量以及纹理坐标(TexCoord)来访问纹理。我们通过调用内置函数texture来实现这一点。这是一个通用函数,用于访问各种不同的纹理。第一个参数是一个采样变量,指示要访问哪个纹理单元,第二个参数是用于访问纹理的纹理坐标。返回值是一个包含通过纹理访问得到的颜色的vec4。我们只选择前三个组件(.rgb)并将它们存储在texColor中。然后,我们将texColor用作 Blinn-Phong 模型中的环境光和漫反射反射率项。

当使用纹理同时进行环境光和漫反射反射率时,设置环境光强度为小值很重要,以避免过度曝光

还有更多...

在决定如何将纹理颜色与其他与片段相关的颜色组合时,有几种选择。在这个例子中,我们使用了纹理颜色作为环境光和漫反射反射率,但可以选择直接使用纹理颜色,或者以某种方式将其与反射模型混合。选项无穷无尽——选择权在你!

在 GLSL 中指定采样器绑定

截至 OpenGL 4.2,我们现在能够在 GLSL 中指定采样器绑定的默认值(采样器统一变量的值)。在之前的示例中,我们使用以下代码从 OpenGL 端设置统一变量的值:

int loc = glGetUniformLocation(programHandle, "Tex1"); 
glUniform1i(loc, 0);

相反,如果我们使用 OpenGL 4.2,我们可以在着色器中使用 layout 限定符来指定默认值,如下面的语句所示:

layout (binding=0) uniform sampler2D Tex1;

这简化了 OpenGL 端的代码,并使我们需要担心的事情减少了一项。本书附带示例代码使用此技术来指定Tex1的值,因此请查看那里以获取更完整的示例。我们还将在此后的食谱中使用此 layout 限定符。

参见

  • 示例代码中的chapter05/scenetexture.cpp文件

  • 有关通过顶点属性将数据发送到着色器的更多信息,请参阅第二章中的使用顶点属性和顶点缓冲对象发送数据到着色器食谱,与 GLSL 程序一起工作

  • 在第四章的使用每片段着色提高真实感食谱中,光照和着色

应用多个纹理

将多个纹理应用于表面可以用来创建各种效果。基础层纹理可能代表干净的表面,而第二层可以提供额外的细节,如阴影、瑕疵、粗糙度或损坏。在许多游戏中,所谓的光照图作为额外的纹理层应用,以提供关于光照暴露的信息,从而有效地产生阴影和着色,而无需显式计算反射模型。这类纹理有时被称为预烘焙光照。在这个菜谱中,我们将通过应用两层纹理来展示这种多纹理技术。基础层将是一个完全不透明的砖图像,而第二层将是部分透明的。不透明部分看起来像是长在砖块上的苔藓。

以下图像显示了多个纹理的示例。左侧的纹理应用于右侧的立方体。基础层是砖纹理,而苔藓纹理则覆盖在其上。苔藓纹理的透明部分揭示了下面的砖纹理:

图片

准备工作

我们将从上一道菜谱中开发的着色器开始,即应用 2D 纹理,以及那里描述的Texture::loadTexture函数。

如何操作...

  1. 在你的 OpenGL 程序初始化部分,以与上一道菜谱应用 2D 纹理中指示的相同方式,将两个图像加载到纹理内存中。确保砖纹理被加载到纹理单元 0,而苔藓纹理加载到纹理单元 1:
 GLuint brick = Texture::loadTexture("brick1.jpg");
 GLuint moss = Texture::loadTexture("moss.png");

// Load brick texture file into channel 0
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, brick);
// Load moss texture file into channel 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, moss); 
  1. 从菜谱应用 2D 纹理中的片段着色器开始,将采样变量Tex1的声明替换为以下代码:
layout(binding=0) uniform sampler2D BrickTex; 
layout(binding=1) uniform sampler2D MossTex;
  1. blinnPhong函数中,从两个纹理中获取样本并将它们混合在一起。然后,将混合后的颜色应用到环境光和漫反射反射率上:
vec4 brickTexColor = texture( BrickTex, TexCoord );
vec4 mossTexColor = texture( MossTex, TexCoord );
vec3 col = mix(brickTexColor.rgb, mossTexColor.rgb, mossTexColor.a);
vec3 ambient = Light.La * col;
// ...
vec3 diffuse = col * sDotN;

工作原理...

将两个纹理加载到 OpenGL 程序中的前述代码与上一道菜谱应用 2D 纹理中的代码非常相似。主要区别在于我们将每个纹理加载到不同的纹理单元。当加载砖纹理时,我们设置 OpenGL 状态,使得活动纹理单元为单元零:

glActiveTexture(GL_TEXTURE0); 

当加载第二个纹理时,我们将 OpenGL 状态设置为纹理单元 1:

glActiveTexture(GL_TEXTURE1); 

在片段着色器中,我们使用与适当纹理单元对应的布局限定符来指定每个采样器变量的纹理绑定。我们使用相应的统一变量访问两个纹理,并将结果存储在brickTexColormossTexColor中。这两个颜色通过内置函数mix混合在一起。mix函数的第三个参数是混合两种颜色时使用的百分比。我们使用苔藓纹理的 alpha 值作为该参数。这导致结果基于苔藓纹理中 alpha 的值进行线性插值。对于那些熟悉 OpenGL 混合函数的人来说,这等同于以下混合函数:

glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA ); 

在这种情况下,苔藓的颜色将是源颜色,砖的颜色将是目标颜色。最后,我们将mix函数的结果用作 Blinn-Phong 反射模型中的环境光和漫反射反射率。

更多内容...

在这个例子中,我们使用第二个纹理的 alpha 值混合了两种纹理颜色。这只是混合纹理颜色众多选项之一。这里有许多不同的选择,你的选择将取决于可用的纹理数据和期望的效果。一种流行的技术是使用额外的顶点属性来增强纹理之间的混合量。这个额外的顶点属性将允许我们在整个模型中改变混合因子。例如,我们可以通过定义另一个顶点属性来控制苔藓纹理和基础纹理之间的混合量,从而改变表面上苔藓的生长量。零值可能对应于没有苔藓,而一值将仅基于纹理的 alpha 值进行混合。

参见

  • 示例代码中的chapter05/scenemultitex.cpp文件

  • 应用 2D 纹理的配方

使用 alpha 图丢弃像素

为了创建具有孔洞的物体效果,我们可以使用包含有关物体透明部分信息的适当 alpha 通道的纹理。然而,这要求我们将深度缓冲区设置为只读,并从后向前渲染所有多边形以避免混合问题。我们需要根据相机位置对多边形进行排序,然后按正确顺序渲染它们。多么痛苦!使用 GLSL 着色器,我们可以通过使用discard关键字在纹理贴图的 alpha 值低于某个值时完全丢弃片段来避免所有这些问题。通过完全丢弃片段,我们不需要修改深度缓冲区,因为当丢弃时,它们根本不会与深度缓冲区进行评估。我们不需要对多边形进行深度排序,因为没有混合。

右侧的以下图像显示了一个茶壶,其碎片基于左侧的纹理被丢弃。片段着色器丢弃与具有低于一定阈值 alpha 值的 texels 对应的碎片:

如果我们创建一个具有 alpha 通道的纹理图,我们可以使用 alpha 通道的值来确定是否应该丢弃碎片。如果 alpha 值低于某个值,则丢弃该像素。

由于这将允许观众看到物体内部,可能使一些背面可见,因此我们在渲染物体时需要使用双面光照。

准备工作

从上一个配方应用 2D 纹理开始,使用相同的着色器对和设置。将物体的基础纹理加载到纹理单元 0,并将你的 alpha 贴图加载到纹理单元 1。

如何操作...

要根据纹理的 alpha 数据丢弃碎片,请按照以下步骤操作:

  1. 使用与应用 2D 纹理配方相同的顶点和片段着色器。然而,对片段着色器进行以下修改。

  2. sampler2D统一变量替换为以下内容:

layout(binding=0) uniform sampler2D BaseTex; 
layout(binding=1) uniform sampler2D AlphaTex;
  1. blinnPhong函数中,使用BaseTex查找环境光和漫反射反射率的值。

  2. main函数的内容替换为以下代码:

void main() {
    vec4 alphaMap = texture( AlphaTex, TexCoord );

    if(alphaMap.a < 0.15 )
        discard;
    else {
        if( gl_FrontFacing ) {
            FragColor = vec4( 
            blinnPhong(Position,normalize(Normal)), 1.0 );
        } else {
            FragColor = vec4( blinnPhong(Position,normalize(-
            Normal)), 1.0 );
        }
    }
}

它是如何工作的...

在片段着色器的main函数中,我们访问 alpha 贴图纹理并将结果存储在alphaMap中。如果alphaMap的 alpha 分量小于某个值(在这个示例中为0.15),则使用discard关键字丢弃该碎片。

否则,我们使用适当方向化的法线向量计算 Blinn-Phong 光照模型,具体取决于碎片是否为正面碎片。

还有更多...

这种技术相当简单直接,是传统混合技术的良好替代方案。这是一种在物体上制造孔洞或呈现衰变外观的绝佳方法。如果你的 alpha 贴图在整个贴图中 alpha 值有逐渐变化(例如,alpha 值平滑变化的 alpha 贴图),则可以用来动画化物体的衰变。我们可以将 alpha 阈值(前一个示例中的0.15)从 0.0 变到 1.0,以创建物体逐渐衰变至无的动画效果。

参见

  • 示例代码中的chapter05/sceneaphatest.cpp文件

  • 应用多个纹理配方

使用法线贴图

正常映射是一种“伪造”表面中实际在表面几何形状中不存在的变异的技术。它对于生成具有凹凸、凹痕、粗糙度或皱纹的表面非常有用,而实际上并没有提供足够的位置信息(顶点)来完全定义这些变形。底层表面实际上是平滑的,但通过使用纹理(正常贴图)来改变法向量,使其看起来粗糙。这项技术与凹凸贴图或位移贴图密切相关。使用正常贴图,我们根据存储在纹理中的信息修改法向量。这创建了一个凹凸表面的外观,而没有实际上提供凹凸的几何形状。

正常贴图是一种纹理,其中纹理中存储的数据被解释为法向量而不是颜色。法向量通常编码到正常贴图的 RGB 信息中,这样红色通道包含x坐标,绿色通道包含y坐标,蓝色通道包含z坐标。然后可以将正常贴图用作纹理,在这种情况下,纹理值影响反射模型中使用的法向量,而不是表面的颜色。这可以用来使表面看起来包含实际在网格几何形状中不存在的变异(凹凸或皱纹)。

以下图像显示了带有和不带有正常贴图的 ogre 网格(由 Keenan Crane 提供)。左上角显示了 ogre 的基本颜色纹理。在这个例子中,我们使用这个纹理作为 Phong 反射模型中的漫反射反射率。右上角显示了带有颜色纹理和默认法向量的 ogre。左下角是正常贴图纹理。右下角显示了带有颜色纹理和正常贴图的 ogre。注意正常贴图提供的皱纹中的额外细节:

图片

正常贴图可以通过多种方式生成。许多 3D 建模程序,如 Maya、Blender 或 3D Studio Max,都可以生成正常贴图。正常贴图也可以直接从灰度高度图纹理生成。NVIDIA 为 Adobe Photoshop 提供了一个插件,提供了这项功能(见developer.nvidia.com/object/photoshop_dds_plugins.html)。

正常贴图被解释为切线空间(也称为对象局部坐标系)中的向量。在切线坐标系中,原点位于表面点,表面法线与z轴(0, 0, 1)对齐。因此,xy轴与表面相切。以下图像显示了表面两个不同位置上的切线框架的示例:

图片

使用这种坐标系统的优点在于,存储在法线图中的法线向量可以被视为对真实法线的扰动,并且与对象坐标系无关。这避免了需要转换法线、添加扰动法线以及重新归一化的需求。相反,我们可以在反射模型中直接使用法线图中的值,而不需要进行任何修改。

为了使所有这些工作正常,我们需要在切线空间中评估反射模型。为此,我们在顶点着色器中将反射模型中使用的向量转换为切线空间,然后将它们传递到片段着色器中,在那里将评估反射模型。为了定义从相机(眼睛)坐标系到切线空间坐标系的变换,我们需要三个归一化、共正交的向量(在眼睛坐标系中定义),它们定义了切线空间系统。z 轴由法线向量 (n) 定义,x 轴由称为切线向量 (t) 的向量定义,而 y 轴通常称为法线向量 (b)。一个在相机坐标系中定义的点,P,可以以下述方式转换为切线空间:

在前面的方程中,S 是切线空间中的点,而 P 是相机坐标中的点。为了在顶点着色器中应用这种变换,OpenGL 程序必须提供定义对象局部系统的至少三个向量,以及顶点位置。通常情况下,提供法线向量 (n) 和切线向量 (t)。如果提供了切线向量,可以通过切线向量和法线向量的叉积来计算法线向量。

切线向量有时会被包含在网格数据结构中的附加数据中。如果切线数据不可用,我们可以通过从表面纹理坐标的变化中推导出切线向量来近似切线向量(参见任意网格的切线空间基向量计算,Eric Lengyel,Terathon Software 3D 图形库,2001,在www.terathon.com/code/tangent.html)。

必须注意,切线向量在表面上应该是一致定义的。换句话说,切线向量的方向不应该从一个顶点到其相邻顶点有太大的变化。否则,可能会导致难看的着色伪影。

在以下示例中,我们将在顶点着色器中读取顶点位置、法线向量、切线向量和纹理坐标。我们将转换位置、法线和切线到相机空间,然后计算副法线向量(在相机空间中)。接下来,我们将计算观察方向(v)和指向光源的方向(s),然后将它们转换到切线空间。我们将传递切线空间vs向量以及(未更改的)纹理坐标到片段着色器,在那里我们将使用切线空间向量和从法线贴图中检索到的法线向量评估 Blinn-Phong 反射模型。

准备工作

设置你的 OpenGL 程序以提供位置在属性位置0,法线在属性位置1,纹理坐标在位置2,以及切线向量在位置3。在此示例中,切线向量的第四个坐标应包含切线坐标系的手性-1+1)。此值将乘以叉积的结果。

将法线贴图加载到纹理单元一,并将颜色纹理加载到纹理单元零。

如何实现...

使用法线贴图渲染图像时,请使用以下着色器:

  1. 在顶点着色器中,找到对象局部坐标系(切线空间)并将所有内容转换到该空间。将切线空间光方向和视方向传递给片段着色器:
layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 
layout (location = 2) in vec2 VertexTexCoord; 
layout (location = 3) in vec4 VertexTangent; 

out vec3 LightDir; 
out vec2 TexCoord; 
out vec3 ViewDir; 

// Other uniform variables...

void main() { 
  // Transform normal and tangent to eye space 
  vec3 norm = normalize(NormalMatrix * VertexNormal); 
  vec3 tang = normalize(NormalMatrix * VertexTangent.xyz); 
  // Compute the binormal 
  vec3 binormal = normalize( cross( norm, tang ) ) * 
  VertexTangent.w; 
  // Matrix for transformation to tangent space 
  mat3 toObjectLocal = mat3( 
      tang.x, binormal.x, norm.x, 
      tang.y, binormal.y, norm.y, 
      tang.z, binormal.z, norm.z ) ; 
  // Get the position in eye coordinates 
  vec3 pos = vec3( ModelViewMatrix *  
                     vec4(VertexPosition,1.0) ); 

  // Transform light dir. and view dir. to tangent space 
  LightDir = toObjectLocal * (Light.Position.xyz - pos); 
  ViewDir = toObjectLocal * normalize(-pos); 

  // Pass along the texture coordinate 
  TexCoord = VertexTexCoord; 

  gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 在片段着色器中,更新blinnPhong函数以使用从纹理中获取的法线,并使用光和视方向输入变量:
in vec3 LightDir; 
in vec2 TexCoord; 
in vec3 ViewDir; 

layout(binding=0) uniform sampler2D ColorTex; 
layout(binding=1) uniform sampler2D NormalMapTex; 

// Other uniform variables...

layout( location = 0 ) out vec4 FragColor; 

vec3 blinnPhong( vec3 n ) {
  // Similar to previous examples, except 
  // using normalize(LightDir) and normalize(ViewDir)...
}

void main() { 
    // Lookup the normal from the normal map
    vec3 norm = texture(NormalMapTex, TexCoord).xyz;
    norm = 2.0 * norm - 1.0;
    FragColor = vec4( blinnPhong(norm), 1.0 );
}

它是如何工作的...

顶点着色器首先通过乘以法线矩阵(并重新归一化)将顶点法线和切线向量转换为眼坐标。然后计算法线和切线向量的叉积作为副法线向量。结果乘以顶点切线向量的w坐标,这决定了切线空间坐标系统的手性。其值将是-1+1

接下来,我们创建一个转换矩阵,用于将眼坐标转换为切线空间,并将矩阵存储在toObjectLocal中。位置被转换为眼空间并存储在pos中,我们通过从光位置减去pos来计算光方向。结果乘以toObjectLocal以将其转换为切线空间,并将最终结果存储在输出变量LightDir中。此值是切线空间中光源的方向,并将由反射模型中的片段着色器使用。

类似地,计算视方向并将其转换为切线空间,通过归一化pos并乘以toObjectLocal。结果存储在输出变量ViewDir中。

纹理坐标通过仅将其分配给输出变量TexCoord而未更改地传递给片段着色器。

在片段着色器中,光方向和视图方向的切线空间值通过变量LightDirViewDir接收。blinnPhong函数与之前配方中使用的方法略有不同。唯一的参数是法线向量。该函数计算 Blinn-Phong 反射模型,从纹理ColorTex中获取漫反射率值,并使用LightDirViewDir作为光和视图方向,而不是计算它们。

在主函数中,从正常贴图纹理中检索法线向量并将其存储在变量normal中。由于纹理存储的值范围从零到一,而法线向量的分量范围从-1 到+1,我们需要将值重新缩放到该范围。我们通过乘以2.0然后减去1.0来实现这一点。

对于某些正常贴图,z坐标永远不会是负数,因为在切线空间中,这会对应于指向表面的法线。在这种情况下,我们可以假设z的范围从 0 到 1,并使用该范围的完整通道分辨率。然而,对于z坐标没有标准约定。

最后,调用blinnPhong函数,并传递normal参数。blinnPhong函数使用LightDirViewDirn评估反射模型,所有这些都在切线空间中定义。结果通过将其分配给FragColor应用于输出片段。

参见

  • 示例代码中的chapter05/scenenormalmap.cpp文件

  • 应用多个纹理配方

视差贴图

正常贴图是一种在不增加额外几何形状的情况下引入表面细节的绝佳方法。然而,它们也有一些局限性。例如,当观察者的位置改变时,正常贴图不会提供视差效果,并且不支持自遮挡。视差贴图是一种技术,最初于 2001 年提出,它通过基于高度图修改纹理坐标来模拟视差和自遮挡效果。它需要正常贴图高度图。高度图(也称为凹凸贴图)是一种灰度图像,其中每个纹理单元有一个代表该纹理单元表面高度的单一标量值。我们可以将 0 到 1 之间的任何高度视为真实表面,然后使用高度图中的值作为从该点开始的偏移量。在这个配方中,我们将1.0作为真实表面,因此高度图值为0.0表示距离真实表面下方1.0的距离(见以下图像)。

为了模拟视差,我们希望偏移纹理坐标的量取决于指向观察者(摄像机)的方向。在更陡的角度处,视差效果更强,因此我们希望在法线和视向量(指向摄像机的向量)之间的角度较大时,偏移量更强。此外,我们希望将纹理坐标偏移到与视向量相同的方向。与法线贴图配方类似,我们将使用切线空间。

如我们之前讨论的,在切线空间中,法向量与 z 轴相同。如果 e 是切线空间中指向摄像机的向量,我们将使用指向相反方向的向量(v = -e)。首先,让我们考虑标准法线贴图的情况。观察者感知到点 P 处的颜色和法线,但他们应该看到点 Q 处的颜色和法线:

因此,我们希望偏移纹理坐标的量与前面图中 Δx 成正比,以便观察者看到点 Q 的阴影,而不是点 P 的阴影。您也可以为 y-z 截面绘制类似的图,结果几乎相同。

因此,我们需要以某种方式近似 Δx。考虑右三角形,如图所示:

d 的值是点 Q(在真实表面下方)的深度,换句话说:d = 1 - h[q],其中 h[q] 是点 Q 处的凹凸贴图的高度。根据相似三角形的规则,我们可以写出以下公式:

y 应用相同的分析,我们得到以下一对偏移量:

不幸的是,我们前面方程中没有 d 的值,因为我们不知道 Q 的值。也没有快速找到它的方法;我们需要通过高度图(我们将在下一个菜谱中这样做)追踪一条射线。因此,现在我们只是通过使用 P 处的高度(深度)(1 - h[p])来近似 d。这是一个粗略的估计,但如果假设高度图没有很多真正的高频变化,在实践中它工作得相当好。

因此,对于给定表面点偏移纹理坐标(P),我们有以下方程:

在前面的方程中,S 是一个缩放因子,可以用来限制效果的幅度并将其缩放到纹理空间。它通常是一个非常小的值(介于 0 和 0.05 之间),可能需要针对特定表面进行调整。

以下图像显示了与基本法线贴图相比的效果。在左侧,使用简单法线贴图渲染的单个四边形,在右侧是使用法线贴图和视差贴图的相同几何形状:

这个效果确实相当微妙,这个例子中也有一些不希望出现的伪影,但整体效果是清晰的。注意,这两张图片使用了相同的几何形状、相机位置和纹理图。如果你专注于远处的砖块(离观察者最远),你可以看到一些遮挡的模拟,并且整体效果在右边更真实。

准备工作

对于视差映射,我们需要三个纹理:一个高度图纹理、一个法线图纹理和一个颜色纹理。我们可以将高度图和法线图合并到一个纹理中,将高度值存储在 alpha 通道中,将法线存储在 R、G 和 B 中。这是一个常见的技巧,可以显著节省磁盘和内存空间。在这个配方中,我们将它们视为单独的纹理。

我们还需要一个具有切线向量的网格,这样我们就可以转换到切线空间。有关切线空间更多信息,请参阅之前的配方。

如何做到...

我们可以使用与之前配方中相同的顶点着色器,即使用法线图。顶点着色器变换视图方向和光方向,并将它们传递给片段着色器。它还传递纹理坐标。

片段着色器使用切线空间视图方向和当前纹理坐标处的高度图值来偏移纹理坐标。然后它使用新的纹理坐标值像往常一样进行着色:

in vec3 LightDir;  // Tangent space
in vec2 TexCoord;
in vec3 ViewDir;  // Tangent space

layout(binding=0) uniform sampler2D ColorTex;
layout(binding=1) uniform sampler2D NormalMapTex;
layout(binding=2) uniform sampler2D HeightMapTex;

// Light and material uniforms 

layout( location = 0 ) out vec4 FragColor;

vec3 blinnPhong( ) {
  vec3 v = normalize(ViewDir);
  vec3 s = normalize(LightDir);

  const float bumpFactor = 0.015; 
  float height = 1 - texture(HeightMapTex, TexCoord).r;
  vec2 delta = v.xy * height * bumpFactor / v.z;
  vec2 tc = TexCoord.xy - delta;

  vec3 n = texture(NormalMapTex, tc).xyz;
  n.xy = 2.0 * n.xy - 1.0;
  n = normalize(n);

  float sDotN = max( dot(s,n), 0.0 );

  vec3 texColor = texture(ColorTex, tc).rgb;
  vec3 ambient = Light.La * texColor;
  vec3 diffuse = texColor * sDotN;
  vec3 spec = vec3(0.0);
  if( sDotN > 0.0 ) { 
    vec3 h = normalize( v + s );
    spec = Material.Ks *
            pow( max( dot(h,n), 0.0 ), Material.Shininess );
  }
  return ambient + Light.L * (diffuse + spec);
}

它是如何工作的...

在片段着色器中的blinnPhong方法中,我们首先计算纹理坐标的偏移量(delta变量)。bumpFactor常量通常在 0 和 0.05 之间。在这种情况下,我们使用0.015,但你需要根据你特定的法线/高度图调整这个值。我们通过 delta 的值偏移纹理坐标。我们在这里减去而不是加上,因为ViewDir实际上是指向观察者的,所以我们需要朝相反的方向偏移。注意,我们还在前面分析中讨论了反转高度值。使用偏移的纹理坐标(tc),我们使用 Blinn-Phong 模型和法线图以及颜色纹理的数据来计算着色。

更多...

视差映射产生微妙但令人愉悦的效果。然而,它确实存在一些不希望出现的伪影,例如所谓的纹理游动,并且在与具有陡峭的凹凸或高频凹凸的凹凸图一起使用时表现不佳。一种性能更好的视差映射改进称为倾斜视差映射,将在下一个配方中讨论。

参见

  • 示例代码中的chapter05/sceneparallax.cpp文件

倾斜视差映射与自阴影

这个配方基于之前的配方,即视差映射,所以如果你还没有这样做,你可能想在阅读这个配方之前回顾一下那个配方。

陡峭的视差映射是一种技术,首次由 Morgan McGuire 和 Max McGuire 在 2005 年发表。它改进了视差映射,以更高的片段着色器工作量为代价,产生了更好的结果。尽管有额外的成本,但该算法仍然非常适合现代 GPU 的实时渲染。

这种技术涉及通过高度图进行离散步骤追踪视线光线,直到找到碰撞,以便更精确地确定纹理坐标的适当偏移。让我们回顾一下之前食谱中的图表,但这次,我们将高度图分解为 n 个离散级别(由虚线表示):

图片

和之前一样,我们的目标是偏移纹理坐标,以便根据凹凸表面而不是真实表面进行着色。点 P 是正在渲染的多边形上的表面点。我们从点 P 开始,依次追踪视图向量到每个级别,直到找到一个位于或低于凹凸表面的点。在以下图像中,我们将经过三次迭代找到点 Q

和之前的食谱一样,我们可以使用相似三角形推导出单次迭代的 xy 的变化(参见 视差映射 食谱)。

图片

和之前一样,使用相似三角形(参见 视差映射 食谱)我们可以推导出单次迭代的 xy 的变化。

使用这个方程,我们可以逐步通过高度级别,从 P 点开始,沿着视图向量远离相机。我们继续直到找到一个位于或低于高度图表面的点。然后我们使用该点的纹理坐标进行着色。本质上,我们正在片段着色器中实现一个非常简单的光线追踪器。

结果令人印象深刻。以下图像显示了同一表面的三个版本,以进行比较。左侧是应用了法线图的表面。中间图像是使用视差映射渲染的同一表面。右手边的图像是使用陡峭的视差映射生成的。所有三幅图像都使用了相同的法线图、高度图、几何形状和相机位置。它们都被渲染为一个四边形(两个三角形)。注意陡峭的视差如何显示每个砖块的变高。每个砖块的高度始终包含在高度图中,但视差映射技术并没有使其明显:

图片

你可能已经注意到右边的图像也包括了阴影。一些砖块投射阴影到其他砖块上。这是通过在先前的技术基础上简单添加实现的。一旦我们找到点 Q,我们就向光源方向发射另一条光线。如果那条光线与表面碰撞,那么该点处于阴影中,我们只使用环境光照进行着色。否则,我们正常着色该点。以下图表说明了这个想法:

图片

在前面的图中,点 Q 处于阴影中,而点 T 则不是。在每种情况下,我们沿着指向光源的方向(s)追踪光线。我们在每个离散高度级别上评估高度图。在点 Q 的情况下,我们找到一个位于凹凸表面的点下方,但对于点 T,所有点都位于其上方。

光线追踪过程几乎与之前描述的用于视图向量的过程相同。我们从点 Q 开始,沿着光线向光源移动。如果我们找到一个位于表面的点,那么该点被光源遮挡。否则,该点将正常着色。我们可以使用用于追踪视图向量的相同方程,将视图向量替换为指向光源的向量。

准备工作

对于此算法,我们需要一个高度图、一个法线图和一个颜色图。我们还需要在网格中具有切线向量,以便我们可以转换到切线空间。

如何实现...

顶点着色器与 Parallax mapping 菜单中使用的相同。

在片段着色器中,我们将过程分为两个函数:findOffsetisOccluded。第一个函数追踪视图向量以确定纹理坐标偏移。第二个函数追踪光线向量以确定该点是否处于阴影中:

in vec3 LightDir;  // Tangent space
in vec2 TexCoord;
in vec3 ViewDir;   // Tangent space

layout(binding=0) uniform sampler2D ColorTex;
layout(binding=1) uniform sampler2D NormalMapTex;
layout(binding=2) uniform sampler2D HeightMapTex;

// Material and light uniforms...

layout( location = 0 ) out vec4 FragColor;

const float bumpScale = 0.03;

vec2 findOffset(vec3 v, out float height) {
  const int nSteps = int(mix(60, 10, abs(v.z)));
  float htStep = 1.0 / nSteps;
  vec2 deltaT = (v.xy * bumpScale) / (nSteps * v.z);
  float ht = 1.0;
  vec2 tc = TexCoord.xy;
  height = texture(HeightMapTex, tc).r;
  while( height < ht ) {
    ht -= htStep;
    tc -= deltaT;
    height = texture(HeightMapTex, tc).r;
  }
  return tc;
}

bool isOccluded(float height, vec2 tc, vec3 s) {
  // Shadow ray cast
  const int nShadowSteps = int(mix(60,10,s.z));
  float htStep = 1.0 / nShadowSteps;
  vec2 deltaT = (s.xy * bumpScale) / ( nShadowSteps * s.z );
  float ht = height + htStep * 0.5;
  while( height < ht && ht < 1.0 ) {
    ht += htStep;
    tc += deltaT;
    height = texture(HeightMapTex, tc).r;
  }

  return ht < 1.0;
}

vec3 blinnPhong( ) { 
  vec3 v = normalize(ViewDir);
  vec3 s = normalize( LightDir );

  float height = 1.0;
  vec2 tc = findOffset(v, height);

  vec3 texColor = texture(ColorTex, tc).rgb;
  vec3 n = texture(NormalMapTex, tc).xyz;
  n.xy = 2.0 * n.xy - 1.0;
  n = normalize(n);

  float sDotN = max( dot(s,n), 0.0 );
  vec3 diffuse = vec3(0.0), 
      ambient = Light.La * texColor,
      spec = vec3(0.0);

  if( sDotN > 0.0 && !isOccluded(height, tc, s) ) {
    diffuse = texColor * sDotN;
    vec3 h = normalize( v + s );
    spec = Material.Ks *
            pow( max( dot(h,n), 0.0 ), Material.Shininess );
  }

  return ambient + Light.L * (diffuse + spec);
}

工作原理...

findOffset 函数用于确定在着色时使用的纹理坐标。我们传入指向观察者的向量 toward(我们取反方向以远离眼睛),函数返回纹理坐标。它还通过输出参数 height 返回该位置的高度值。第一行确定离散高度级别数 (nSteps)。我们通过插值使用视图向量的 z 坐标值来选择一个介于 10 和 60 之间的数字。如果 z 坐标值较小,那么视图向量相对于高度级别几乎是垂直的。当视图向量接近垂直时,我们可以使用较少的步数,因为光线在级别之间的相对水平距离较短。然而,当向量接近水平时,我们需要更多的步数,因为光线在从一个级别移动到下一个级别时需要穿越更大的水平距离。deltaT 变量是我们从一个高度级别移动到下一个高度级别时在纹理空间中移动的量。这是之前列出的方程中的第二个项。

光线追踪过程通过以下循环进行。ht 变量跟踪高度级别。我们将其初始化为 1.0height 变量将是当前位置的高度图值。tc 变量将跟踪我们在纹理空间中的移动,最初位于片段的纹理坐标 (TexCoord)。我们在 tc 处查找高度图中的值,然后进入循环。

循环会一直持续到高度图中的值(height)小于离散高度级别(ht)的值。在循环内部,我们改变ht以向下移动一个级别,并通过deltaT更新纹理坐标。请注意,我们减去deltaT是因为我们正在远离观察者。然后,我们在新的纹理坐标处查找高度图(height)的值并重复。

当循环结束时,tc应该有偏移纹理坐标的值,而height是高度图中该位置的值。我们返回tc,循环结束时height的值也通过输出参数返回给调用者。

注意,当我们查看面的背面时,这个循环是不正确的。然而,循环最终会在某个点上终止,因为我们总是减少ht,并且假设高度图纹理在 0 和 1 之间。如果背面可见,我们需要修改这一点以正确地跟随射线或反转法线。

isOccluded函数返回光源是否在该点被高度图遮挡。它与findOffset函数非常相似。我们传递由findOffset先前确定的height,相应的纹理坐标(tc)以及指向光源的方向(s)。类似于findOffset,我们在提供的高度和纹理坐标处沿着s的方向行进射线。请注意,我们开始循环时ht的值稍微偏离了那里的凹凸图值(ht= height + htStep * 0.1)。这是为了避免所谓的阴影痤疮效应。如果我们不偏移它,有时当射线与它开始的表面碰撞时,我们可能会得到假阳性,产生斑驳的阴影。

函数的其余部分包含一个与findOffset中的循环非常相似的循环。然而,我们向上通过高度级别,并且我们小心地停止当ht的值达到或超过 1.0 时。循环结束时,我们不需要heighttc的值;我们只需要知道循环是否由于第一个条件而停止。如果ht < 1.0,那么我们在超过高度图范围之前退出循环,这表明我们在射线上找到了一个高度更大的点。因此,该点必须在阴影中,所以我们返回 true。否则,光源没有被遮挡,所以我们返回 false。

blinnPhong函数调用findOffset来确定适当的纹理坐标。然后,它在那个位置的法线贴图和颜色贴图中查找值。接下来,它使用这些值评估 Blinn-Phong 反射模型。然而,它使用isOccluded函数来确定是否应该包含漫反射和镜面反射成分。如果sDotN的值小于或等于零,我们也不会评估这些成分,这意味着光线在(或与)面成切线,这是由着色法线确定的。

参见

  • 示例代码中的chapter05/sceneparallax.cpp文件

使用立方体贴图模拟反射

纹理可以用来模拟具有纯反射成分的表面(如铬一样的镜面)。为了做到这一点,我们需要一个代表反射物体周围环境的纹理。然后,这个纹理可以以代表其反射外观的方式映射到物体的表面上。这种通用技术被称为环境映射

通常,环境映射涉及创建一个代表环境的纹理,并将其映射到物体的表面上。它通常用于模拟反射或折射的效果。

立方体贴图是环境映射中使用的纹理的更常见类型之一。立方体贴图是一组六个单独的图像,代表环境投影到立方体的六个面上。这六个图像代表从位于立方体中心的观察者视角看环境。以下图像显示了立方体贴图的一个示例。图像被排列成好像立方体被展开并平铺开来。中间的四张图像将组成立方体的侧面,顶部和底部的图像对应于立方体的顶部和底部:

OpenGL 提供了对立方体贴图纹理的内置支持(使用GL_TEXTURE_CUBE_MAP目标)。纹理通过三维纹理坐标(s, t, r)访问。纹理坐标被解释为从立方体中心出发的方向向量。由该向量与立方体中心定义的线被延伸以与立方体的一个面相交。然后,在交点位置访问对应于该面的图像。

实际上,用于访问立方体贴图的三维纹理坐标和用于访问单个面图像的两个维纹理坐标之间的转换有些复杂。它可能不太直观,令人困惑。OpenGL 规范文档中可以找到一个非常易于理解的解释(www.khronos.org/registry/OpenGL/index_gl.php)。然而,好消息是,如果你小心地在立方体贴图中定位纹理,可以忽略转换的细节,并将纹理坐标可视化为前面描述的三维向量。

在这个例子中,我们将演示使用立方体贴图来模拟反射表面。我们还将使用立方体贴图来绘制反射对象周围的环境(有时称为天空盒)。

准备工作

首先,准备六个立方体贴图图像。在这个例子中,图像将遵循以下命名约定。有一个基本名称(存储在baseFileName变量中),后面跟着一个下划线,然后是六个可能的后缀之一(posxnegxposynegyposznegz),最后是文件扩展名。后缀posxposy等表示通过面中心的轴(正x、正y等)。

确保它们都是正方形图像(最好是边长为二的幂的尺寸),并且它们都是相同的大小。

设置你的 OpenGL 程序,以便在属性位置 0 提供顶点位置,在属性位置 1 提供顶点法线。

此顶点着色器需要将模型矩阵(将对象坐标转换为世界坐标的矩阵)与视图矩阵分开,并作为单独的统一变量提供给着色器。你的 OpenGL 程序应在统一变量ModelMatrix中提供模型矩阵。

顶点着色器还需要世界坐标中摄像机的位置。确保你的 OpenGL 程序将统一变量WorldCameraPosition设置为适当的值。

如何做到...

要根据立方体贴图渲染具有反射效果的图像,并渲染立方体贴图本身,执行以下步骤:

  1. 我们首先定义一个函数,该函数将六个立方体贴图图像加载到单个纹理目标中:
GLuint Texture::loadCubeMap(const std::string &baseName, 
const std::string &extension) {
    GLuint texID;
    glGenTextures(1, &texID);
    glBindTexture(GL_TEXTURE_CUBE_MAP, texID);

    const char * suffixes[] = { "posx", "negx", "posy", 
    "negy", "posz", "negz" };
    GLint w, h;

    // Load the first one to get width/height
    std::string texName = baseName + "_" + suffixes[0] + extension;
    GLubyte * data = Texture::loadPixels(texName, w, h, false);

    // Allocate immutable storage for the whole cube map texture
    glTexStorage2D(GL_TEXTURE_CUBE_MAP, 1, GL_RGBA8, w, h);
    glTexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X, 
        0, 0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, data);
    stbi_image_free(data);

    // Load the other 5 cube-map faces
    for( int i = 1; i < 6; i++ ) {
        std::string texName = baseName + "_" + suffixes[i] + 
        extension;
        data = Texture::loadPixels(texName, w, h, false);
        glTexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 
            0, 0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, data);
        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_NEAREST);
    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);

    return texID;
}
  1. 使用以下代码进行顶点着色器:

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 
layout (location = 2) in vec2 VertexTexCoord; 

out vec3 ReflectDir;      // The direction of the reflected ray 

uniform vec3 WorldCameraPosition; 
uniform mat4 ModelViewMatrix; 
uniform mat4 ModelMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP; 

void main() {
  // Compute the reflected direction in world coords. 
  vec3 worldPos = vec3(ModelMatrix *  
                             vec4(VertexPosition,1.0) ); 
  vec3 worldNorm = vec3(ModelMatrix *  
                              vec4(VertexNormal, 0.0)); 
  vec3 worldView = normalize( WorldCameraPosition - worldPos );
  ReflectDir = reflect(-worldView, worldNorm ); 

  gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 使用以下代码进行片段着色器:
in vec3 ReflectDir;   // The direction of the reflected ray 

// The cube map 
layout(binding=0) uniform samplerCube CubeMapTex; 

uniform float ReflectFactor; // Amount of reflection 
uniform vec4 MaterialColor;  // Color of the object's "Tint"  

layout( location = 0 ) out vec4 FragColor; 

void main() { 
  // Access the cube map texture 
  vec4 cubeMapColor = texture(CubeMapTex, ReflectDir); 
  FragColor = mix(MaterialColor, CubeMapColor, ReflectFactor);
} 
  1. 在 OpenGL 程序的渲染部分,绘制一个以原点为中心的立方体,并将立方体贴图应用于立方体。你可以使用归一化位置作为纹理坐标。为此天空盒使用单独的着色器。请参阅示例代码以获取详细信息。

  2. 切换到前面的着色器,并在场景中绘制对象(们)。

它是如何工作的...

在 OpenGL 中,立方体贴图纹理由六个单独的图像组成。为了完全初始化立方体贴图纹理,我们需要绑定到立方体贴图纹理,然后将每个图像单独加载到该纹理中的六个“槽位”中。在前面的代码(在Texture::loadCubeMap函数中),我们首先使用glActiveTexture绑定到纹理单元零。然后,通过调用glGenTextures创建一个新的纹理对象,将其句柄存储在变量texID中,并使用glBindTexture将该纹理对象绑定到GL_TEXTURE_CUBE_MAP目标。我们首先加载第一个图像以确定图像的尺寸,然后在一个循环中加载其他图像。下面的循环加载每个纹理文件,并使用glTexSubImage2D将纹理数据复制到 OpenGL 内存中。请注意,该函数的第一个参数是纹理目标,对应于GL_TEXTURE_CUBE_MAP_POSITIVE_X + i。OpenGL 定义了一系列连续的常量,对应于立方体的六个面,因此我们可以只需将一个整数加到第一个面的常量值上。循环完成后,立方体贴图纹理应该已经完全初始化,包含六个图像。

在此之后,我们设置立方体贴图纹理环境。我们使用线性过滤,并将纹理包裹模式设置为所有三个纹理坐标分量的GL_CLAMP_TO_EDGE。这通常效果很好,避免了在立方体边缘之间出现边界颜色的可能性。

更好的选择是使用无缝立方体贴图纹理(自 OpenGL 3.2 以来可用)。启用它们很简单,只需调用:glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS)

在顶点着色器中,主要目标是计算反射方向,并将其传递到片段着色器中用于访问立方体贴图。输出变量ReflectDir将存储此结果。我们可以通过在法线向量周围反射指向观察者的向量来计算反射方向(在世界坐标中)。

我们选择在世界坐标中计算反射方向,因为如果我们使用眼睛坐标,那么当相机在场景内移动时,反射不会改变。

在主函数的else分支中,我们首先将位置转换为世界坐标,并将它们存储在worldPos中。然后,我们对法线做同样的处理,将结果存储在worldNorm中。请注意,ModelMatrix用于转换顶点法线。在进行这一操作时,为了防止模型矩阵的平移分量影响法线,法线的第四个坐标必须使用0.0的值。此外,模型矩阵不得包含任何非均匀缩放分量;否则,法线向量将被错误地转换。

观察者的方向在世界坐标中计算并存储在worldView中。

最后,我们将worldView关于法线进行反射,并将结果存储在输出变量ReflectDir中。片段着色器将使用这个方向来访问立方体贴图纹理,并将相应的颜色应用到片段上。可以将其想象为从观察者的眼睛开始,击中表面,从表面反射,并击中立方体贴图的光线。光线击中立方体贴图时看到的颜色就是我们需要的物体颜色。

在绘制天空盒时,我们使用顶点位置作为反射方向。为什么?好吧,当渲染天空盒时,我们希望天空盒上的位置与立方体贴图中的等效位置相对应(天空盒实际上只是立方体贴图的一种渲染)。因此,如果我们想访问与原点为中心的立方体上的位置相对应的立方体贴图上的位置,我们需要一个指向该位置的向量。我们需要的是该点的位置减去原点(即(0,0,0))。因此,我们只需要顶点的位置。

天空盒可以以观察者位于天空盒中心,天空盒随着观察者移动的方式进行渲染(因此观察者始终位于天空盒中心)。在这个例子中我们没有这样做;然而,我们可以通过使用视图矩阵的旋转分量(而不是平移分量)来变换天空盒来实现这一点。

在片段着色器中,我们直接使用ReflectDir的值来访问立方体贴图纹理:

vec4 cubeMapColor = texture(CubeMapTex, ReflectDir) 

我们将天空盒颜色与一些材质颜色混合。这允许我们对物体提供一些轻微的色调。色调的量由变量ReflectFactor调整。1.0 的值对应于零色调(全部反射),而 0.0 的值对应于无反射。以下图像显示了使用不同ReflectFactor值渲染的茶壶。左边的茶壶使用反射系数为 0.5,而右边的茶壶使用值为 0.85。基本材质颜色为灰色(使用的立方体贴图是罗马圣彼得大教堂的图像。©Paul Debevec):

还有更多...

关于这种技术有两个重要点需要注意。首先,物体只会反射环境贴图。它们不会反射场景中任何其他物体的图像。为了做到这一点,我们需要从每个物体的视角生成环境贴图,通过在物体中心以六个坐标方向中的每个方向渲染场景六次来实现。然后,我们可以为适当的物体的反射使用适当的环境贴图。当然,如果任何物体相对于彼此移动,我们需要重新生成环境贴图。所有这些努力可能在一个交互式应用程序中是过于限制性的。

第二点涉及移动物体上出现的反射。在这些着色器中,我们计算反射方向并将其视为从环境贴图中心发出的向量。这意味着无论物体位于何处,反射都会看起来像物体位于环境的中心。换句话说,环境被处理成仿佛它距离无限远。GPU Gems一书的第十九章,由 Randima Fernando 所著,Addison-Wesley Professional,2009 年出版,对此问题有很好的讨论,并提供了一些可能的解决方案来定位反射。

参见

  • 示例代码中的chapter05/scenereflectcube.cpp文件

  • 应用 2D 纹理配方

  • 由 Randima Fernando 所著,Addison-Wesley Professional,2009 年出版的《GPU Gems》一书的第十九章

使用立方体贴图模拟折射

透明物体会导致光线在物体与其周围环境之间的界面处略微弯曲。这种现象称为折射。在渲染透明物体时,我们通过使用环境贴图并将环境映射到物体上来模拟这种效果。换句话说,我们可以追踪从观察者到物体(过程中弯曲)再到环境的射线。然后,我们可以使用这个射线交点作为物体的颜色。

与前一个配方一样,我们将使用立方体贴图来模拟环境。我们将从观察者的位置追踪光线,穿过物体,并最终与立方体贴图相交。

折射过程由斯涅尔定律描述,它定义了入射角和折射角之间的关系:

斯涅尔定律描述了入射角(a[i])为入射光线与表面法线之间的角度,折射角(a[t])为透射光线与延长法线之间的角度。入射光线通过的介质和包含透射光线的介质分别由折射率(图中n[1]n[2])描述。两个折射率之间的比率定义了光线在界面处弯曲的程度。

从斯涅尔定律出发,经过一些数学努力,我们可以推导出一个透射向量的公式,给定折射率的比率、法线向量和入射向量:

然而,实际上并没有必要这样做,因为 GLSL 提供了一个内置函数来计算这个透射向量,称为refract。我们将在本例中使用这个函数。

对于透明物体,通常不是所有光线都通过表面透射。一些光线会被反射。在这个例子中,我们将以非常简单的方式模拟这一点,并在本配方末尾讨论更精确的表示。

准备工作

设置你的 OpenGL 程序以在属性位置 0 提供顶点位置,在属性位置 1 提供顶点法线。与前面的配方一样,我们需要在统一变量 ModelMatrix 中提供模型矩阵。

使用前面配方中所示的技术加载立方体贴图。将其放置在纹理单元零。

将统一变量 WorldCameraPosition 设置为观察者在世界坐标中的位置。将统一变量 Material.Eta 的值设置为环境折射率 n[1] 与材料折射率 n[2] 之间的比率 (n[1]/n[2])。将统一变量 Material.ReflectionFactor 的值设置为在界面处反射的光线比例(可能是一个小值)。

与前面的示例一样,如果你想要绘制环境,绘制一个围绕场景的大立方体,并使用一个单独的着色器将纹理应用到立方体上。请参阅示例代码以获取详细信息。

如何操作...

要渲染具有反射和折射的对象,执行以下步骤:

  1. 在顶点着色器中使用以下代码:

layout (location = 0) in vec3 VertexPosition; 
layout (location = 1) in vec3 VertexNormal; 

out vec3 ReflectDir;  // Reflected direction 
out vec3 RefractDir;  // Transmitted direction 

struct MaterialInfo { 
   float Eta;       // Ratio of indices of refraction 
   float ReflectionFactor; // Percentage of reflected light 
}; 
uniform MaterialInfo Material; 

uniform vec3 WorldCameraPosition; 
uniform mat4 ModelViewMatrix; 
uniform mat4 ModelMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP; 

void main() { 
   vec3 worldPos = vec3( ModelMatrix *  
                              vec4(VertexPosition,1.0) ); 
   vec3 worldNorm = vec3(ModelMatrix *  
                              vec4(VertexNormal, 0.0)); 
   vec3 worldView = normalize( WorldCameraPosition -  
                                    worldPos ); 

   ReflectDir = reflect(-worldView, worldNorm ); 
   RefractDir = refract(-worldView, worldNorm,  
                             Material.Eta );
    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 在片段着色器中使用以下代码:
in vec3 ReflectDir; 
in vec3 RefractDir; 

layout(binding=0) uniform samplerCube CubeMapTex; 
struct MaterialInfo { 
  float Eta;  // Ratio of indices of refraction 
  float ReflectionFactor; // Percentage of reflected light 
}; 
uniform MaterialInfo Material; 

layout( location = 0 ) out vec4 FragColor; 

void main() { 
  // Access the cube map texture 
  vec4 reflectColor = texture(CubeMapTex, ReflectDir); 
  vec4 refractColor = texture(CubeMapTex, RefractDir); 

  FragColor = mix(refractColor, reflectColor,  
                     Material.ReflectionFactor); 
}

的工作原理...

这两个着色器与前面配方中的着色器非常相似。

顶点着色器计算世界坐标中的位置、法线和视图方向 (worldPosworldNormworldView)。然后使用 reflect 函数计算反射方向,并将结果存储在输出变量 ReflectDir 中。使用内置函数 refract(需要折射率比率 Material.Eta)计算透射方向。此函数利用斯涅尔定律计算透射向量的方向,然后将其存储在输出变量 RefractDir 中。

在片段着色器中,我们使用两个向量 ReflectDirRefractDir 来访问立方体贴图纹理。通过反射光线检索到的颜色存储在 reflectColor 中,通过透射光线检索到的颜色存储在 refractColor 中。然后根据 Material.ReflectionFactor 的值将这两种颜色混合在一起。结果是反射光线的颜色和透射光线的颜色的混合。

以下图像展示了使用 10%反射和 90%折射(立方体贴图 © Paul Debevec)渲染的茶壶:

还有更多...

这种技术与前面配方中“更多...”部分讨论的缺点相同,使用立方体贴图模拟反射

和大多数实时技术一样,这只是一个对实际情况中物理现象的简化。关于这项技术,有许多方面可以改进以提供更逼真的结果。

菲涅耳方程

实际上,反射光量的多少取决于入射光的入射角度。例如,当我们从岸边观察湖面时,大部分光线被反射,因此很容易在湖面上看到周围环境的倒影。然而,当我们在湖面上漂浮并直接向下看时,反射较少,更容易看到水面以下的东西。这种现象由菲涅耳方程(Augustin-Jean Fresnel 之后)描述。

菲涅耳方程描述了反射光量作为入射角度、光的偏振和折射率比函数。如果我们忽略偏振,很容易将菲涅耳方程纳入前面的着色器中。在 Randi J Rost 的《OpenGL 着色语言》第三版第十四章中可以找到非常好的解释,该书由 Addison-Wesley Professional 于 2009 年出版。

色差

白光当然由许多不同的单个波长(或颜色)组成。光线的折射程度实际上是波长依赖的。这导致在材料界面处可以观察到颜色光谱。最著名的例子是由棱镜产生的彩虹。

我们可以通过为光线的红色、绿色和蓝色分量使用不同的 Eta 值来模拟这种效果。我们会存储三个不同的 Eta 值,计算三个不同的反射方向(红色、绿色和蓝色),并使用这三个方向在立方体贴图中查找颜色。我们从第一个颜色中提取红色分量,从第二个颜色中提取绿色分量,从第三个颜色中提取蓝色分量,并将这三个分量组合在一起以创建片段的最终颜色。

通过物体的两侧折射

重要的是要注意,我们通过仅模拟光与物体一个边界的相互作用来简化了问题。在现实中,光在进入透明物体时会弯曲一次,在离开另一侧时再次弯曲。然而,这种简化通常不会导致看起来不真实的结果。在实时图形中,我们通常更关注结果看起来是否美观,而不是物理模型是否准确。

参见

  • 示例代码中的 chapter05/scenerefractcube.cpp 文件

  • 使用立方体贴图模拟反射 的配方

应用投影纹理

我们可以将纹理应用到场景中的对象上,就像纹理是从场景内部某个地方的想象中的“幻灯片投影仪”投影出来的。这种技术通常被称为投影纹理映射,并产生非常棒的效果。

以下图像展示了投影纹理映射的一个示例。左侧的花纹纹理(由 Stan Shebs 通过维基共享)被投影到茶壶和平面下:

图片

要将纹理投影到表面上,我们只需要确定基于表面位置和投影源(幻灯片投影仪)的相对位置的纹理坐标。一个简单的方法是将投影仪想象成一个位于场景内部的相机。就像我们定义 OpenGL 相机一样,我们定义一个以投影仪位置为中心的坐标系和一个视图矩阵V),它将坐标转换为投影仪的坐标系。接下来,我们将定义一个透视投影矩阵P),它将投影仪坐标系中的视锥体转换为大小为二的立方体,中心位于原点。将这两者结合起来,并添加一个用于重新缩放和将体积平移到大小为 1 且中心位于(0.5, 0.5, 0.5)的额外矩阵,我们得到以下变换矩阵:

图片

这里的目标基本上是将视锥体转换为 0 到 1 之间的范围,在xy方向上。前面的矩阵可以做到这一点!它将位于投影仪视锥体内的世界坐标转换为 0 到 1 的范围(齐次),然后可以用来访问纹理。请注意,坐标是齐次的,在使用之前需要除以 w 坐标。

想要了解更多关于这种技术数学原理的细节,请查看以下由 NVIDIA 的 Cass Everitt 撰写的白皮书:www.nvidia.com/object/Projective_Texture_Mapping.html

在这个例子中,我们将使用投影纹理映射将单个纹理应用到场景中。

准备中

设置你的 OpenGL 应用程序,以在属性位置 0 提供顶点位置,在属性位置 1 提供法线。OpenGL 应用程序还必须为 Phong 反射模型提供材质和光照属性(参见以下章节中给出的片段着色器)。确保在统一变量ModelMatrix中提供模型矩阵(用于转换为世界坐标)。

如何操作...

要将投影纹理应用到场景中,请按照以下步骤操作:

  1. 在 OpenGL 应用程序中,将纹理加载到纹理单元零。当纹理对象绑定到GL_TEXTURE_2D目标时,使用以下代码设置纹理的设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, 
                 GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,  
                GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,  
                GL_CLAMP_TO_BORDER); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,   
                GL_CLAMP_TO_BORDER); 
  1. 在 OpenGL 应用程序中,设置幻灯片投影机的变换矩阵,并将其分配给统一变量ProjectorMatrix。使用以下代码来完成此操作。请注意,此代码使用了在第一章,开始使用 GLSL中讨论的 GLM 库:
vec3 projPos, projAt, projUp;
// Set the above 3 to appropriate values...
mat4 projView = glm::lookAt(projPos, projAt, projUp);
mat4 projProj = glm::perspective(glm::radians(30.0f), 1.0f, 0.2f, 1000.0f);
mat4 bias = glm::translate(mat4(1.0f), vec3(0.5f));
bias = glm::scale(bias, vec3(0.5f));
prog.setUniform("ProjectorMatrix", bias * projProj * projView);
  1. 使用以下代码进行顶点着色器:
layout (location = 0) in vec3 VertexPosition;
layout (location = 1) in vec3 VertexNormal;

out vec3 EyeNormal;   // Normal in eye coordinates
out vec4 EyePosition; // Position in eye coordinates
out vec4 ProjTexCoord;

uniform mat4 ProjectorMatrix;

uniform mat4 ModelViewMatrix;
uniform mat4 ModelMatrix;
uniform mat3 NormalMatrix;
uniform mat4 MVP;

void main() {
  vec4 pos4 = vec4(VertexPosition,1.0);

  EyeNormal = normalize(NormalMatrix * VertexNormal);
  EyePosition = ModelViewMatrix * pos4;
  ProjTexCoord = ProjectorMatrix * (ModelMatrix * pos4);
  gl_Position = MVP * pos4;
}
  1. 使用以下代码进行片段着色器:
in vec3 EyeNormal;       // Normal in eye coordinates 
in vec4 EyePosition;     // Position in eye coordinates 
in vec4 ProjTexCoord; 

layout(binding=0) uniform sampler2D ProjectorTex; 

// Light and material uniforms...

layout( location = 0 ) out vec4 FragColor; 

vec3 blinnPhong( vec3 pos, vec3 norm ) { 
  // Blinn-Phong model...
} 

void main() { 
  vec3 color = blinnPhong(EyePosition.xyz, normalize(EyeNormal));

  vec3 projTexColor = vec3(0.0);
  if( ProjTexCoord.z > 0.0 )
    projTexColor = textureProj( ProjectorTex, ProjTexCoord ).rgb;

  FragColor = vec4(color + projTexColor * 0.5, 1);
} 

它是如何工作的...

当将纹理加载到 OpenGL 应用程序中时,我们确保将st方向的包裹模式设置为GL_CLAMP_TO_BORDER。我们这样做是因为如果纹理坐标超出了零到一的范围内,我们不希望投影纹理有任何贡献。使用这种模式,使用默认的边框颜色,当纹理坐标超出 0 到 1(包括 0 和 1)的范围时,纹理将返回(0,0,0,0)。

在 OpenGL 应用程序中设置幻灯片投影机的变换矩阵。我们首先使用 GLM 函数glm::lookAt为投影机生成一个视图矩阵。在这个例子中,我们将投影机定位在(5, 5, 5),朝向点(-2, -4,0),使用向上向量为(0, 1, 0)。此函数与gluLookAt函数类似。它返回一个矩阵,用于将坐标系统转换为(5, 5, 5),并根据第二个和第三个参数进行定位。

接下来,我们使用glm::perspective创建投影矩阵,以及缩放/平移矩阵M(如本食谱的介绍中所示)。这两个矩阵分别存储在projProjprojScaleTrans中。最终的矩阵是projScaleTransprojProjprojView的乘积,存储在m中,并分配给统一变量ProjectorTex

在顶点着色器中,我们有三个输出变量:EyeNormalEyePositionProjTexCoord。前两个是眼坐标中的顶点法线和顶点位置。我们适当地变换输入变量,并在main函数中将结果分配给输出变量。

我们通过首先将位置变换到世界坐标(通过乘以ModelMatrix)来计算ProjTexCoord,然后应用投影机的变换。

在片段着色器中,在main函数中,我们首先计算反射模型,并将结果存储在变量color中。下一步是从纹理中查找颜色。然而,首先检查ProjTexCoordz坐标。如果这是负数,则该位置在投影机后面,因此我们避免进行纹理查找。否则,我们使用textureProj查找纹理值,并将其存储在projTexColor中。

textureProj 函数是为了访问经过投影的坐标纹理而设计的。在访问纹理之前,它会将第二个参数的坐标除以其最后一个坐标。在我们的例子中,这正是我们想要的。我们之前提到,经过投影仪矩阵变换后,我们将剩下齐次坐标,因此我们需要在访问纹理之前除以 w 坐标。textureProj 函数将为我们完成这一点。

最后,我们将投影纹理的颜色添加到 Phong 模型的基础颜色中。我们将投影纹理颜色稍微缩放,以便它不会过于强烈。

还有更多...

这里介绍的技术有一个很大的缺点。目前还没有阴影支持,因此投影的纹理会直接穿透场景中的任何物体,并出现在它们后面的物体上(相对于投影仪而言)。在后面的食谱中,我们将探讨一些处理阴影的技术示例,这有助于解决这个问题。

参见

  • 示例代码中的 chapter05/sceneprojtex.cpp 文件

  • 第三章中的 Blinn-Phong 反射模型 食谱,GLSL 着色器基础

  • 应用 2D 纹理 食谱

将渲染输出到纹理

有时候,在程序执行过程中实时生成纹理是有意义的。这个纹理可能是由某些内部算法(所谓的 过程纹理)生成的图案,或者它可能代表场景的另一个部分。

后者案例的一个例子可能是一个视频屏幕,人们可以看到 世界 的另一部分,也许是通过另一个房间的安全摄像头。视频屏幕可以随着另一个房间中物体的移动而不断更新,通过重新渲染安全摄像头视图到应用于视频屏幕的纹理中!

在以下图像中,出现在立方体上的纹理是通过将牛渲染到内部纹理中,然后将其应用于立方体的面来生成的:

在 OpenGL 中,通过引入 帧缓冲区对象FBO)大大简化了直接将渲染输出到纹理的过程。我们可以创建一个单独的渲染目标缓冲区(FBO),将我们的纹理附加到该 FBO 上,并以与渲染到默认帧缓冲区相同的方式渲染到 FBO 上。所需的所有操作只是交换 FBO,完成后再将其交换出来。

基本上,渲染过程涉及以下步骤:

  1. 绑定到 FBO

  2. 渲染纹理

  3. 从 FBO(返回默认帧缓冲区)解绑

  4. 使用纹理渲染场景

实际上,在 GLSL 着色器方面,为了使用这种纹理,我们不需要做太多工作。实际上,着色器会将其视为任何其他纹理。然而,我们将讨论一些关于片段输出变量的重要点。

在这个例子中,我们将介绍创建 FBO 及其后备纹理所需的步骤,以及如何设置一个与纹理一起工作的着色器。

准备工作

对于这个例子,我们将使用之前配方中的着色器,即应用 2D 纹理,进行一些小的修改。按照该配方中的描述设置你的 OpenGL 程序。我们将对着色器做的唯一修改是将sampler2D变量的名称从Tex1改为Texture

如何做到...

要在纹理上渲染并应用该纹理到第二个渲染通道的场景中,请按照以下步骤操作:

  1. 在主 OpenGL 程序中,使用以下代码设置帧缓冲区对象:
GLuint fboHandle;  // The handle to the FBO 

// Generate and bind the framebuffer 
glGenFramebuffers(1, &fboHandle); 
glBindFramebuffer(GL_FRAMEBUFFER, fboHandle); 

// Create the texture object 
GLuint renderTex; 
glGenTextures(1, &renderTex); 
glActiveTexture(GL_TEXTURE0);  // Use texture unit 0 
glBindTexture(GL_TEXTURE_2D, renderTex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 512, 512); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,  
                GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,  
                GL_LINEAR); 

// Bind the texture to the FBO 
glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,  
                       GL_TEXTURE_2D, renderTex, 0); 

// Create the depth buffer 
GLuint depthBuf; 
glGenRenderbuffers(1, &depthBuf); 
glBindRenderbuffer(GL_RENDERBUFFER, depthBuf); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT,  
                      512, 512); 

// Bind the depth buffer to the FBO 
glFramebufferRenderbuffer(GL_FRAMEBUFFER,  
                          GL_DEPTH_ATTACHMENT, 
                          GL_RENDERBUFFER, depthBuf); 

// Set the target for the fragment shader outputs 
GLenum drawBufs[] = {GL_COLOR_ATTACHMENT0}; 
glDrawBuffers(1, drawBufs); 

// Unbind the framebuffer, and revert to default 
glBindFramebuffer(GL_FRAMEBUFFER, 0); 
  1. 在 OpenGL 程序中的渲染函数内,绑定到帧缓冲区,绘制要渲染到纹理中的场景,然后从该帧缓冲区解绑,并绘制立方体:
// Bind to texture's FBO 
glBindFramebuffer(GL_FRAMEBUFFER, fboHandle); 
glViewport(0,0,512,512);  // Viewport for the texture

// Use the texture for the cow here 
int loc = glGetUniformLocation(programHandle, "Texture"); 
glUniform1i(loc, 1);

// Setup the projection matrix and view matrix 
// for the scene to be rendered to the texture here. 
// (Don't forget to match aspect ratio of the viewport.) 

renderTextureScene(); 

// Unbind texture's FBO (back to default FB) 
glBindFramebuffer(GL_FRAMEBUFFER, 0); 
glViewport(0,0,width,height);  // Viewport for main window 

// Use the texture that is linked to the FBO 
int loc = glGetUniformLocation(programHandle, "Texture"); 
glUniform1i(loc, 0); 

// Reset projection and view matrices here...
renderScene();

它是如何工作的...

让我们先看看创建帧缓冲区对象的代码(步骤 1)。我们的 FBO 将是 512 像素的平方,因为我们打算将其用作纹理。我们首先使用glGenFramebuffers生成 FBO,并使用glBindFramebuffer将其绑定到GL_FRAMEBUFFER目标。接下来,我们创建一个将要渲染的纹理对象,并使用glActiveTexture选择纹理单元零。其余部分与创建任何其他纹理非常相似。我们使用glTexStorage2D为纹理分配空间。我们不需要将任何数据复制到该空间(使用glTexSubImage2D),因为我们将在渲染到 FBO 时稍后写入该内存。

接下来,我们通过调用函数glFramebufferTexture2D将纹理链接到 FBO。此函数将一个纹理对象附加到当前绑定的帧缓冲区对象的附件点上。第一个参数(GL_FRAMEBUFFER)表示纹理将被附加到当前绑定到GL_FRAMEBUFFER目标的 FBO。第二个参数是附件点。帧缓冲区对象有几个颜色缓冲区的附件点,一个用于深度缓冲区,还有一些其他附件点。这使我们能够从我们的片段着色器中针对多个颜色缓冲区。我们将在稍后了解更多关于这一点。我们使用GL_COLOR_ATTACHMENT0来表示此纹理链接到 FBO 的颜色附件 0。第三个参数(GL_TEXTURE_2D)是纹理目标,第四个(renderTex)是我们纹理的句柄。最后一个参数(0)是要附加到 FBO 的纹理的 mip 级别。在这种情况下,我们只有一个级别,所以我们使用零值。

由于我们想要在 FBO 上渲染并使用深度测试,我们还需要附加一个深度缓冲区。接下来的几行代码创建了深度缓冲区。glGenRenderbuffer函数创建了一个renderbuffer对象,而glRenderbufferStoragerenderbuffer对象分配空间。glRenderbufferStorage的第二个参数表示缓冲区的内部格式,由于我们将其用作深度缓冲区,我们使用特殊的格式GL_DEPTH_COMPONENT

接下来,使用glFramebufferRenderbuffer将深度缓冲区附加到 FBO 的GL_DEPTH_ATTACHMENT附加点。

使用glDrawBuffers将着色器的输出变量分配给 FBO 的附加点。glDrawBuffers的第二个参数是一个数组,指示要关联到输出变量的 FBO 缓冲区。数组的第i个元素对应于位置i的片段着色器输出变量。在我们的例子中,我们只有一个着色器输出变量(FragColor)在位置零。此语句将该输出变量与GL_COLOR_ATTACHMENT0关联。

第一步的最后一条语句解除了 FBO 的绑定,使其恢复到默认帧缓冲区。

在最后一步(在渲染函数内),我们绑定到 FBO,使用单元一的纹理,并渲染纹理。请注意,我们需要小心地设置视口(glViewport)以及视图和投影矩阵,以适应我们的 FBO。由于我们的 FBO 是 512 x 512,我们使用glViewport(0,0,512,512)。类似的更改也应应用于视图和投影矩阵,以匹配视口的纵横比,并设置要渲染到 FBO 的场景。

一旦我们将渲染到纹理上,我们就从 FBO 解绑,重置视口、视图和投影矩阵,使用 FBO 的纹理(纹理单元 0),然后绘制立方体!

还有更多...

由于 FBO 有多个颜色附加点,我们可以从我们的片段着色器中获得多个输出目标。请注意,到目前为止,我们所有的片段着色器都只有一个输出变量分配到位置零。因此,我们设置了我们的 FBO,使其纹理对应于颜色附加点零。在后面的章节中,我们将查看使用多个这些附加点作为延迟着色等事物的示例。

参见

  • 示例代码中的chapter05/scenerendertotex.cpp文件

  • 应用 2D 纹理的配方

使用采样对象

采样器对象是在 OpenGL 3.3 中引入的,并提供了一种方便的方式来指定 GLSL 采样器变量的采样参数。指定纹理参数的传统方法是通过glTexParameter来指定,通常在定义纹理时进行。这些参数定义了相关纹理的采样状态(采样模式、环绕和钳位规则等)。这实际上将纹理及其采样状态合并为一个单一对象。如果我们想要以多种方式(例如,带有和没有线性过滤)从单个纹理中进行采样,我们有两个选择。我们可能需要修改纹理的采样状态,或者使用相同纹理的两个副本。

此外,我们可能希望为多个纹理使用相同的纹理采样参数集。根据我们到目前为止所看到的,没有简单的方法来实现这一点。使用采样器对象,我们可以一次性指定参数,并在多个纹理对象之间共享它们。

采样器对象将采样状态与纹理对象分离。我们可以创建定义特定采样状态的采样器对象,并将其应用于多个纹理,或者将不同的采样器对象绑定到同一纹理。单个采样器对象可以绑定到多个纹理,这允许我们一次性定义特定的采样状态,并在多个纹理对象之间共享。

采样器对象是在 OpenGL 端定义的(而不是在 GLSL 中),这使得它对 GLSL 来说实际上是透明的。

在这个配方中,我们将定义两个采样器对象,并将它们应用于单个纹理。以下图像显示了结果。相同的纹理被应用于两个平面。在左侧,我们使用为最近邻过滤设置的采样器对象,而在右侧,我们使用相同的纹理,并使用为线性过滤设置的采样器对象:

准备工作

我们将使用与配方应用 2D 纹理中相同的着色器。着色器代码将完全不变,但我们将使用采样器对象来改变采样变量Tex1的状态。

如何实现...

要设置纹理对象和采样器对象,执行以下步骤:

  1. 按照常规方式创建并填充纹理对象,但这次我们不会使用glTexParameter设置任何采样状态:
GLuint texID; 
glGenTextures(1, &texID); 
glBindTexture(GL_TEXTURE_2D, texID); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, w, h); 
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_RGBA, 
   GL_UNSIGNED_BYTE, data);
  1. 将纹理绑定到纹理单元 0,这是着色器使用的单元:
glActiveTexture(GL_TEXTURE0); 
glBindTexture(GL_TEXTURE_2D, texID); 
  1. 接下来,我们创建两个采样器对象,并将它们的 ID 分配给不同的变量

    为了清晰起见:

GLuint samplers[2]; 
glGenSamplers(2, samplers); 
linearSampler = samplers[0]; 
nearestSampler = samplers[1]; 
  1. 为线性插值设置linearSampler
glSamplerParameteri(linearSampler, GL_TEXTURE_MAG_FILTER,  
                    GL_LINEAR); 
glSamplerParameteri(linearSampler, GL_TEXTURE_MIN_FILTER,  
                    GL_LINEAR); 
  1. 为最近邻采样设置nearestSampler
glSamplerParameteri(nearestSampler, GL_TEXTURE_MAG_FILTER,  
                    GL_NEAREST); 
glSamplerParameteri(nearestSampler, GL_TEXTURE_MIN_FILTER,  
                    GL_NEAREST); 
  1. 在渲染时,当需要时绑定到每个采样器对象:
glBindSampler(0, nearestSampler); 
// Render objects that use nearest-neighbor sampling 
glBindSampler(0, linearSampler); 
// Render objects that use linear sampling

它是如何工作的...

样本对象易于使用,并使得在相同的纹理之间切换不同的采样参数或为不同的纹理使用相同的采样参数变得简单。在步骤 1 和 2 中,我们创建了一个纹理并将其绑定到纹理单元 0。通常,我们会在这里使用glTexParameteri设置采样参数,但在这个例子中,我们将使用glSamplerParameter在样本对象中设置它们。在步骤 3 中,我们创建了样本对象并将它们的 ID 分配给一些变量。在步骤 4 和 5 中,我们使用glSamplerParameter设置适当的采样参数。这个函数几乎与glTexParameter完全相同,只是第一个参数是样本对象的 ID 而不是纹理目标。这为两个样本对象(linearSampler为线性,nearestSampler为最近)定义了采样状态。

最后,我们在渲染之前通过使用glBindSampler将样本对象绑定到适当的纹理单元来使用样本对象。在步骤 6 中,我们首先将nearestSampler绑定到纹理单元 0,渲染一些对象,然后将linearSampler绑定到纹理单元 0,并再次渲染一些对象。这里的结果是,通过在渲染过程中将不同的样本对象绑定到纹理单元,相同的纹理使用了不同的采样参数。

参见

  • 示例代码中的chapter05/scenesamplerobj.cpp文件

  • 应用 2D 纹理的配方

漫反射基于图像的照明

基于图像的照明是一种涉及使用图像作为光源的技术。该图像表示场景环境的全方位视图。使用基于图像的照明,图像本身被处理为一个完全环绕场景的高度详细的光源。场景中的物体通过图像的内容被照亮,这使得拥有一个非常复杂的照明环境或模拟真实世界场景成为可能。通常,这些图像是通过特殊的相机或特殊的摄影技术制作的,并记录在高动态范围内。这里展示了一个这样的图像示例(图像由南加州大学创意技术研究所和保罗·德贝韦克提供):

图片

这些图像可以提供为立方图(六个图像的集合),或者某些其他类型的环境图,如等角全景图(之前展示的类型)。两者之间的转换是直接的。

由于等角图中的每个 texel 代表一个方向,立方图也是如此。要从一个转换到另一个,我们只需要将一个图中的方向转换为另一个图中的方向。

在这个配方中,我们将通过使用图像作为漫反射的光源的过程。这里的大部分工作都涉及创建漫反射卷积图。漫反射卷积是将环境图转换成可以直接用于计算漫反射的形式的转换。在以下图像中,左侧显示原始环境图,右侧是漫反射卷积(图片由南加州大学创意技术研究所和保罗·德贝维ック提供):

图片

为了理解漫反射卷积图,让我们回顾一下反射方程(在第三章“基于物理的反射模型”中,GLSL 着色器的基础中提出):

图片

这个方程表示在表面上方半球面上所有入射光 (l) 的积分。前一个方程中的 f 项是双向反射分布函数BRDF)。它表示在给定的入射 (l) 和出射 (v) 方向条件下,从表面点反射的光的分数。如果我们只考虑漫反射(朗伯),我们可以使用以下常数项作为 BRDF:

图片

这给出了反射方程的以下内容:

图片

由于 BRDF 只是一个常数,它可以被因式分解到积分外面。注意,这个方程中没有依赖于出射方向 (v) 的东西。这导致我们得到以下见解。我们之前讨论的环境图表示了给定方向的入射辐射量,这是前一个方程中的 Li 项。我们可以使用蒙特卡洛估计器来估计给定 n 值的该积分的值:

图片

在前一个方程中,l[j] 代表从表面上方半球面(围绕 n)均匀采样的伪随机方向,N 是样本数量。 的常数因子来自均匀样本的概率密度函数作为立体角函数。

在半球面上均匀采样方向并不像你想象的那样简单。常见的做法是在一个系统中采样方向,其中 z 轴与向量 n 对齐,然后将样本转换成世界坐标。然而,我们必须小心地均匀选择方向。例如,如果我们只是为 xy 选取介于 -1 和 1 之间的随机值,为 z 和 0 选取 1 和 0,然后进行归一化,那么这将给出有偏或“聚集”在 z 轴周围的定向,并且在半球面上不是均匀的。为了得到均匀的定向,我们可以使用以下公式:

图片

ξ[1]ξ[2] 是范围 [0, 1] 内的均匀伪随机值。关于这一点的推导,请参阅 基于物理的渲染 第三版,第十三章,蒙特卡洛积分

现在我们有了估计给定 n 值的积分的方法,我们可以以下述方式卷积原始环境图。我们将创建一个新的环境图(漫反射卷积图),其中每个纹理像素代表世界坐标中 n 的一个方向。纹理像素的值将是先前积分的估计值(除了 c[diff] 项),通过从原始环境图中取多个随机样本(l[j]) 来获得。我们可以 离线 进行此操作并预计算这个漫反射卷积。这是一个相对较慢的过程,但我们不需要很多细节。漫反射卷积通常变化相当平滑,因此我们可以使用较小的分辨率而不会牺牲太多质量。

我承认在这里省略了一些数学内容。关于图形中蒙特卡洛积分的非常好的介绍,请参阅 Pharr、Jakob 和 Humphreys 编著的 基于物理的渲染 第三版,第十三章,蒙特卡洛积分

一旦我们有了预计算的漫反射卷积,我们就可以将其用作 查找表 来给出我们的漫反射积分(再次,没有 c[diff]) 的值,使用法线向量。我们可以将检索到的值乘以我们的材料的漫反射颜色 c[diff],以获得出射辐射度。换句话说,漫反射卷积表示给定 n 值的 出射 辐射度,而不是入射辐射度。

准备工作

这里的准备工作大部分涉及环境图的卷积。以下伪代码概述了这一过程:

nSamples = 1000
foreach texel t in output map
  n = direction towards t
  rad = 0
  for i = 1 to nSamples
     li = uniform random direction in the 
          hemisphere around n in world coords
     L = read from environment map at li
     nDotL = dot( n, li )
     rad += L * nDotL
  set texel t to (2 / nSamples) * rad

如何操作...

要渲染具有漫反射图像光照的场景,过程相当简单。我们只需使用法线向量从我们的漫反射贴图中读取。

顶点着色器只是将位置和法线转换为世界坐标,并将它们传递下去:

// Input attributes...

out vec3 Position; // world coords
out vec3 Normal;   // world coords.
out vec2 TexCoord;

// Matrix uniforms ...

void main() {
    TexCoord = VertexTexCoord;
    Position = (ModelMatrix * vec4(VertexPosition,1)).xyz;
    Normal = normalize( ModelMatrix * vec4(VertexNormal,0) ).xyz;
    gl_Position = MVP * vec4(VertexPosition,1.0);
}

片段着色器随后使用漫反射卷积图来确定积分的值,将其乘以从纹理图中取出的颜色,并应用伽玛校正:

const float PI = 3.14159265358979323846;

in vec3 Position;
in vec3 Normal;
in vec2 TexCoord;

layout(binding=0) uniform samplerCube DiffConvTex;
layout(binding=1) uniform sampler2D ColorTex;

layout( location = 0 ) out vec4 FragColor;

const float gamma = 2.2;

void main() {
    vec3 n = normalize(Normal);

    // Look up reflected light from diffuse cube map
    vec3 light = texture(DiffConvTex, n).rgb;
    vec3 color = texture(ColorTex, TexCoord).rgb;
    color = pow(color, vec3(gamma));   // decode
    color *= light;

    color = pow( color, vec3(1.0/gamma));  // gamma encode

    FragColor = vec4( color, 1 );
}

本食谱引言中展示的环境图的结果如下截图所示:

图片

工作原理...

一旦我们创建了扩散卷积,这项技术就没有太多内容了。我们只需简单地查找卷积映射 DiffConvTex 中的值,并将其与表面的基础颜色相乘。在这个例子中,表面的基础颜色是从第二个纹理映射 (ColorTex) 中获取的。我们在将基础颜色纹理与环境映射相乘之前,对其应用伽玛解码,将其移动到线性颜色空间。这假设纹理存储在 sRGB 或已经过伽玛编码。最终值在显示前进行伽玛编码。环境映射中的值位于线性颜色空间,因此我们需要在组合之前将所有内容移动到线性空间。有关伽玛编码/解码的更多细节,请参阅第六章中的使用伽玛校正来提高图像质量配方,图像处理和屏幕空间技术

还有更多...

还很乐意将镜面/光泽组件包含在这个模型中。这里没有涉及这一点。此外,还应该包括适当的光滑反射。包括镜面组件有点困难,因为它还取决于观察者的方向。有创建镜面卷积的技术,我将参考以下来源。这些通常涉及几个简化假设,以便在实时中实现。

参考内容

  • 示例代码中的 chapter05/scenediffibl.cpp 文件

  • 《基于物理的渲染》,第三版,作者 Pharr, Jakob, 和 Humphreys

  • 第六章中的使用伽玛校正来提高图像质量配方,图像处理和屏幕空间技术

  • 有关基于图像照明的镜面贡献的详细信息,请参阅blog.selfshadow.com/publications/s2013-shading-course/

第六章:图像处理和屏幕空间技术

在本章中,我们将涵盖以下食谱:

  • 应用边缘检测过滤器

  • 应用高斯模糊过滤器

  • 使用色调映射实现 HDR 照明

  • 创建辉光效果

  • 使用伽玛校正提高图像质量

  • 使用多采样抗锯齿

  • 使用延迟着色

  • 屏幕空间环境遮挡

  • 配置深度测试

  • 实现无序透明度

简介

在本章中,我们将专注于直接与帧缓冲区中的像素工作的技术。这些技术通常涉及多个遍历。初始遍历生成像素数据,后续遍历应用效果或进一步处理这些像素。为了实现这一点,我们经常利用 OpenGL 提供的直接渲染到纹理或纹理集的能力(参考第五章的渲染到纹理食谱,使用纹理)。

能够将渲染输出到纹理,结合片段着色器的强大功能,开辟了巨大的可能性。我们可以在输出之前在片段着色器中应用额外的过程来实现图像处理技术,如亮度、对比度、饱和度和锐度。我们可以应用卷积过滤器,如边缘检测、平滑(模糊)或锐化。我们将在边缘检测食谱中更详细地了解卷积过滤器。

一组相关的技术涉及将额外的信息渲染到纹理中,这些信息超出了传统的颜色信息,然后在后续遍历中进一步处理这些信息以生成最终的渲染图像。这些技术属于通常被称为延迟着色的通用类别。

在本章中,我们将查看前面提到的每种技术的示例。我们将从边缘检测、模糊和辉光卷积过滤器的示例开始。然后,我们将转向重要的伽玛校正和多采样抗锯齿主题。最后,我们将以延迟着色的完整示例结束。

本章中的大多数食谱都涉及多个遍历。为了应用一个作用于最终渲染图像像素的过滤器,我们首先将场景渲染到一个中间缓冲区(一个纹理)。然后,在最终遍历中,我们通过绘制一个全屏四边形并将纹理渲染到屏幕上,在这个过程中应用过滤器。你将在接下来的食谱中看到这个主题的几个变体。

应用边缘检测过滤器

边缘检测是一种图像处理技术,它识别图像亮度发生显著变化的区域。它提供了一种检测物体边界和表面拓扑变化的方法。它在计算机视觉、图像处理、图像分析和图像模式识别领域有应用。它还可以用于创建一些视觉上有趣的效果。例如,它可以使 3D 场景看起来类似于 2D 铅笔素描,如下面的图像所示。为了创建此图像,一个茶壶和一个环面被正常渲染,然后在第二次遍历中应用了边缘检测滤波器:

图片

我们在这里使用的边缘检测滤波器涉及使用卷积滤波器,或卷积核(也称为滤波器核)。卷积滤波器是一个矩阵,它定义了如何通过用附近像素的值与一组预定的权重之间的乘积之和来替换像素。作为一个简单的例子,考虑以下卷积滤波器:

图片

3 x 3 滤波器以灰色阴影显示,叠加在一个假设的像素网格上。粗体数字代表滤波器核(权重)的值,非粗体值是像素值。像素的值可以代表灰度强度或 RGB 组件中的一个值。将滤波器应用于灰色区域的中心像素涉及将相应的单元格相乘并求和结果。结果将是中心像素的新值(25)。在这种情况下,该值将是(17 + 19 + 2 * 25 + 31 + 33),或 150。

当然,为了应用卷积滤波器,我们需要访问原始图像的像素以及一个单独的缓冲区来存储滤波器的结果。在这里,我们将通过使用两遍算法来实现这一点。在第一遍中,我们将图像渲染到纹理中,然后在第二遍中,我们将通过从纹理中读取并发送过滤后的结果到屏幕来应用滤波器。

边缘检测中最简单的基于卷积的技术之一是所谓的Sobel 算子。Sobel 算子旨在近似每个像素处的图像强度梯度。它是通过应用两个 3 x 3 滤波器来做到这一点的。这两个滤波器的结果是梯度的垂直和水平分量。然后我们可以使用梯度的幅度作为我们的边缘触发器。当梯度的幅度超过某个阈值时,我们假设该像素位于边缘上。

Sobel 算子使用的 3 x 3 滤波器核在以下方程中显示:

图片

如果应用 S[x] 的结果是 s[x],应用 S[y] 的结果是 s[y],那么梯度的近似大小由以下方程给出:

图片

如果g的值高于某个阈值,我们认为该像素是边缘像素,并在结果图像中突出显示它。

在本例中,我们将实现此滤波器作为两遍算法的第二遍。在第一遍中,我们将使用适当的照明模型渲染场景,但将结果发送到纹理。在第二遍中,我们将整个纹理作为填充屏幕的四边形渲染,并将滤波器应用于纹理。

准备工作

设置一个与主窗口具有相同维度的帧缓冲区对象(参考第五章,使用纹理中的将渲染输出到纹理配方),将 FBO 的第一个颜色附加连接到纹理单元零中的纹理对象。在第一遍中,我们将直接渲染到这个纹理。确保此纹理的magmin过滤器设置为GL_NEAREST。我们不希望此算法有任何插值。

在顶点属性零中提供顶点信息,在顶点属性一中提供法线,在顶点属性二中提供纹理坐标。

以下统一变量需要从 OpenGL 应用程序中设置:

  • Width:用于设置屏幕窗口的宽度(以像素为单位)

  • Height:用于设置屏幕窗口的高度(以像素为单位)

  • EdgeThreshold:这是被认为在边缘上的g平方的最小值

  • RenderTex:这是与 FBO 关联的纹理

与着色模型关联的任何其他统一变量也应从 OpenGL 应用程序中设置。

如何操作...

要创建一个应用 Sobel 边缘检测滤波器的着色器程序,请执行以下步骤:

  1. 顶点着色器仅将位置和法线转换为相机坐标,并将它们传递给片段着色器。

  2. 片段着色器在第一遍中应用反射模型,在第二遍中应用边缘检测滤波器:

in vec3 Position; 
in vec3 Normal; 

uniform int Pass; // Pass number

// The texture containing the results of the first pass 
layout( binding=0 ) uniform sampler2D RenderTex; 

uniform float EdgeThreshold; // The squared threshold 

// Light/material uniforms...

layout( location = 0 ) out vec4 FragColor; 
const vec3 lum = vec3(0.2126, 0.7152, 0.0722); 

vec3 blinnPhong( vec3 pos, vec3 norm ) {
 // ... 
} 

// Approximates the brightness of a RGB value. 
float luminance( vec3 color ) { 
 return dot(lum, color);
} 
vec4 pass1() { 
 return vec4(blinnPhong( Position, normalize(Normal) ),1.0); 
} 

vec4 pass2() { 
 ivec2 pix = ivec2(gl_FragCoord.xy); 
 float s00 = luminance( 
    texelFetchOffset(RenderTex, pix, 0, 
            ivec2(-1,1)).rgb); 
 float s10 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(-1,0)).rgb); 
 float s20 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(-1,-1)).rgb); 
 float s01 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(0,1)).rgb); 
 float s21 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(0,-1)).rgb); 
 float s02 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(1,1)).rgb); 
 float s12 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(1,0)).rgb); 
 float s22 = luminance( 
       texelFetchOffset(RenderTex, pix, 0, 
                ivec2(1,-1)).rgb); 

 float sx = s00 + 2 * s10 + s20 - (s02 + 2 * s12 + s22); 
 float sy = s00 + 2 * s01 + s02 - (s20 + 2 * s21 + s22); 

 float g = sx * sx + sy * sy; 

 if( g > EdgeThreshold ) return vec4(1.0); 
 else return vec4(0.0,0.0,0.0,1.0); 
} 

void main() { 
  if( Pass == 1 ) FragColor = pass1();
  if( Pass == 2 ) FragColor = pass2(); 
}

在你的 OpenGL 应用程序的渲染函数中,对于遍数#1,遵循以下步骤:

  1. 选择 FBO,并清除颜色/深度缓冲区

  2. Pass统一变量设置为1

  3. 设置模型、视图和投影矩阵,并绘制场景

对于遍数#2,执行以下步骤:

  1. 取消选择 FBO(恢复到默认帧缓冲区)并清除颜色/深度缓冲区

  2. Pass统一变量设置为2

  3. 将模型、视图和投影矩阵设置为单位矩阵

  4. 绘制一个填充屏幕的单个四边形(或两个三角形)(在xy方向上为-1 到+1),每个维度的纹理坐标从 0 到 1。

工作原理...

第一遍渲染场景的所有几何形状并将输出发送到纹理。我们选择pass1函数,该函数简单地计算并应用 Blinn-Phong 反射模型(参考第三章,GLSL 着色器基础)。

在第二次遍历中,我们选择pass2函数,并仅渲染一个覆盖整个屏幕的单个四边形。这样做是为了在图像中的每个像素上调用一次片段着色器。在pass2函数中,我们检索包含第一次遍历结果的纹理的八个相邻像素的值,并通过调用luminance函数计算它们的亮度。然后应用水平和垂直 Sobel 滤波器,并将结果存储在sxsy中。

luminance函数通过计算强度的加权总和来确定 RGB 值的亮度。权重来自 ITU-R 建议书 Rec. 709。有关此内容的更多详细信息,请参阅维基百科上的亮度条目。

然后,我们计算梯度幅度的平方值(为了避免开方)并将结果存储在g中。如果g的值大于EdgeThreshold,我们认为该像素位于边缘,并输出一个白色像素。否则,我们输出一个纯黑色像素。

还有更多...

Sobel 算子相对简单,并且对强度的高频变化较为敏感。快速查看维基百科将引导你找到许多其他可能更精确的边缘检测技术。在渲染和边缘检测遍历之间添加一个模糊遍历也有可能减少高频变化量。模糊遍历将平滑高频波动,并可能改善边缘检测遍历的结果。

优化技术

这里讨论的技术需要执行八个纹理获取操作。纹理访问可能会有些慢,减少访问次数可以显著提高速度。由 Randima Fernando 编著的《GPU Gems:实时图形编程技巧、提示和技巧》(Addison-Wesley Professional 2004 年出版)的第二十四章对如何通过使用所谓的辅助纹理来减少滤波操作中的纹理获取次数进行了出色的讨论。

参见

  • 示例代码中的chapter06/sceneedge.cpp文件

  • D. Ziou 和 S. Tabbone(1998),边缘检测技术:概述,《国际计算机视觉杂志》,第 24 卷,第 3 期

  • Frei-Chen 边缘检测器: rastergrid.com/blog/2011/01/frei-chen-edge-detector/

  • 第五章中的将渲染输出到纹理配方,《使用纹理》

应用高斯模糊过滤器

模糊过滤器在许多不同的情况下都很有用,目标是在图像中减少噪声量。如前所述,在边缘检测之前应用模糊过滤器可以通过减少图像中的高频波动来提高结果。任何模糊过滤器的基本思想是使用加权总和混合像素及其附近像素的颜色。权重通常随着与像素的距离(在 2D 屏幕空间中)的增加而减小,因此远离像素的像素对模糊像素的贡献小于靠近像素的像素。

高斯模糊使用二维高斯函数来加权附近像素的贡献:

方差项是高斯函数的方差,决定了高斯曲线的宽度。高斯函数在(0,0)处达到最大值,这对应于被模糊像素的位置及其值,随着 xy 的增加而减小。以下图表显示了方差平方值为 4.0 的两维高斯函数:

以下图像显示了高斯模糊操作前后图像的一部分(左侧为之前,右侧为之后):

要应用高斯模糊,对于每个像素,我们需要计算该像素处高斯函数值的加权总和,即所有图像中像素的加权总和(其中每个像素的 xy 坐标基于一个位于被模糊像素处的原点)。这个总和的结果是像素的新值。然而,到目前为止的算法有两个问题:

  • 由于这是一个 O(n²) 过程(其中 n 是图像中像素的数量),它可能对于实时应用来说太慢了

  • 权重必须加起来等于一,以避免改变整体亮度

    该图像

由于我们在离散位置采样高斯函数,而没有在整个(无限)函数范围内求和,因此权重几乎肯定不会加起来等于一。

我们可以通过限制给定像素(而不是整个图像)模糊的像素数量,以及通过归一化高斯函数的值来解决上述两个问题。在这个例子中,我们将使用一个 9 x 9 的高斯模糊过滤器。也就是说,我们只计算被模糊像素周围 81 个像素的贡献。

这样的技术需要在片段着色器中进行 81 次纹理提取,而片段着色器是针对每个像素执行一次的。对于 800 x 600 大小的图像,总的纹理提取次数将是 800 * 600 * 81 = 38,880,000。这看起来很多,不是吗?好消息是,我们可以通过在两次传递中执行高斯模糊来显著减少纹理提取的次数。

二维高斯函数可以分解为两个一维高斯函数的乘积:

图片

其中一维高斯函数由以下方程给出:

图片

所以如果 C[ij] 是像素位置 (i, j) 处的像素颜色,我们需要计算的总和由以下方程给出:

图片

这可以通过以下事实重写:二维高斯是两个一维高斯的乘积:

图片

这意味着我们可以通过两次遍历来计算高斯模糊。在第一次遍历中,我们可以计算前一个方程中 j(垂直和)的总和,并将结果存储在一个临时纹理中。在第二次遍历中,我们使用前一次遍历的结果来计算 i(水平和)的总和。

现在,在我们查看代码之前,有一个重要的问题需要解决。正如我们之前提到的,高斯权重必须加起来等于一,才能成为真正的加权平均值。因此,我们需要归一化我们的高斯权重,如下方程所示:

图片

前一个方程中 k 的值只是原始高斯权重的总和:

图片

呼!我们已经将 O(n^([2])) 问题简化为 O(n) 的问题。好的,有了这个,让我们继续看代码。

我们将使用三次遍历和两个纹理来实现这项技术。在第一次遍历中,我们将整个场景渲染到纹理中。然后,在第二次遍历中,我们将第一次(垂直)求和应用于第一次遍历的纹理,并将结果存储在另一个纹理中。最后,在第三次遍历中,我们将水平求和应用于第二次遍历的纹理,并将结果发送到默认帧缓冲区。

准备工作

设置两个帧缓冲对象(参考第五章中的渲染到纹理配方,使用纹理),以及两个相应的纹理。第一个 FBO 应该有一个深度缓冲区,因为它将用于第一次遍历。第二个 FBO 不需要深度缓冲区,因为在第二次和第三次遍历中,我们只渲染一个填充整个屏幕的四边形,以便为每个像素执行一次片段着色器。

与前面的配方一样,我们将使用一个统一变量来选择每个遍历的功能。OpenGL 程序还应设置以下统一变量:

  • Width: 这用于设置屏幕的宽度(以像素为单位)

  • Height: 这用于设置屏幕的高度(以像素为单位)

  • Weight[]: 这是一个归一化高斯权重的数组

  • Texture0: 这是为了将其设置为纹理单元零

  • PixOffset[]: 这是一个从被模糊的像素偏移的数组

如何做到这一点...

在片段着色器中,我们在第一次遍历中应用 Blinn-Phong 反射模型。在第二次遍历中,我们计算垂直和。在第三次,我们计算水平和:

in vec3 Position; // Vertex position 
in vec3 Normal;  // Vertex normal 

uniform int Pass; // Pass number
layout(binding=0) uniform sampler2D Texture0; 

// Light/material uniforms ....
layout( location = 0 ) out vec4 FragColor; 

uniform int PixOffset[5] = int[](0,1,2,3,4); 
uniform float Weight[5]; 

vec3 blinnPhong( vec3 pos, vec3 norm ) { 
  // ... 
} 

vec4 pass1() {
 return vec4(blinnPhong( Position, normalize(Normal) ),1.0); 
} 

vec4 pass2() { 
 ivec2 pix = ivec2(gl_FragCoord.xy); 
 vec4 sum = texelFetch(Texture0, pix, 0) * Weight[0]; 
 for( int i = 1; i < 5; i++ ) 
 { 
  sum += texelFetchOffset( Texture0, pix, 0, 
        ivec2(0,PixOffset[i])) * Weight[i]; 
  sum += texelFetchOffset( Texture0, pix, 0, 
        ivec2(0,-PixOffset[i])) * Weight[i]; 
 } 
 return sum; 
} 

vec4 pass3() { 
 ivec2 pix = ivec2(gl_FragCoord.xy); 
 vec4 sum = texelFetch(Texture0, pix, 0) * Weight[0]; 
 for( int i = 1; i < 5; i++ ) 
 { 
  sum += texelFetchOffset( Texture0, pix, 0, 
        ivec2(PixOffset[i],0)) * Weight[i]; 
  sum += texelFetchOffset( Texture0, pix, 0, 
        ivec2(-PixOffset[i],0)) * Weight[i]; 
 } 
 return sum; 
} 

void main() 
{ 
 if( Pass == 1 ) FragColor = pass1();
 else if( Pass == 2 ) FragColor = pass2();
 else if( Pass == 3 ) FragColor = pass3();
} 

在 OpenGL 应用程序中,计算在统一变量PixOffset中找到的偏移量的高斯权重,并将结果存储在Weight数组中。你可以使用以下代码来完成此操作:

char uniName[20]; 
float weights[5], sum, sigma2 = 4.0f; 

// Compute and sum the weights 
weights[0] = gauss(0,sigma2); // The 1-D Gaussian function 
sum = weights[0]; 
for( int i = 1; i < 5; i++ ) { 
 weights[i] = gauss(i, sigma2); 
 sum += 2 * weights[i]; 
} 

// Normalize the weights and set the uniform 
for( int i = 0; i < 5; i++ ) { 
 snprintf(uniName, 20, "Weight[%d]", i); 
 prog.setUniform(uniName, weights[i] / sum); 
} 

在主渲染函数中,为第 1 次传递实现以下步骤:

  1. 选择渲染帧缓冲区,启用深度测试,并清除颜色/深度缓冲区

  2. Pass设置为1

  3. 绘制场景

使用以下步骤进行第 2 次传递:

  1. 选择中间帧缓冲区,禁用深度测试,并清除颜色缓冲区

  2. Pass设置为2

  3. 将视图、投影和模型矩阵设置为单位矩阵

  4. 将第 1 次传递的纹理绑定到纹理单元 0

  5. 绘制全屏四边形

使用以下步骤进行第 3 次传递:

  1. 取消选择帧缓冲区(恢复默认),并清除颜色缓冲区

  2. Pass设置为3

  3. 将第 2 次传递的纹理绑定到纹理单元 0

  4. 绘制全屏四边形

它是如何工作的...

在计算高斯权重的先前代码(代码段 3)中,名为gauss的函数计算一维高斯函数,其中第一个参数是x的值,第二个参数是 sigma 的平方。请注意,我们只需要计算正偏移,因为高斯关于零是对称的。由于我们只计算正偏移,我们需要仔细计算权重的总和。我们将所有非零值加倍,因为它们将被两次使用(正负偏移)。

第一次传递(函数pass1)使用 Blinn-Phong 反射模型将场景渲染到纹理中。

第二次传递(函数pass2)应用高斯模糊操作的加权垂直总和,并将结果存储在另一个纹理中。我们从第一次传递中创建的纹理中读取像素,在垂直方向上偏移PixOffset数组中的值。我们使用Weight数组中的权重进行求和。(dy项是纹理坐标中 texel 的高度。)我们在两个方向上同时求和,每个垂直方向上 4 个像素的距离。

第三次传递(pass3)与第二次传递非常相似。我们使用第二次传递的纹理来累积加权水平总和。通过这样做,我们将第二次传递产生的总和纳入我们的整体加权总和,如前所述。因此,我们在目标像素周围 9x9 像素区域内创建一个总和。对于这次传递,输出颜色将发送到默认帧缓冲区以生成最终结果。

还有更多...

当然,我们也可以通过增加WeightPixOffset数组的大小并重新计算权重,以及/或者使用不同的sigma2值来改变高斯形状,来调整更大范围的 texels 的模糊。

参见

  • 示例代码中的chapter06/sceneblur.cpp文件

  • 双边滤波:people.csail.mit.edu/sparis/bf_course/

  • 第五章中的将渲染输出到纹理配方,使用纹理

  • 本章中的应用边缘检测滤波器配方

使用色调映射实现 HDR 照明

当为大多数输出设备(显示器或电视)渲染时,设备仅支持每个颜色组件的典型颜色精度为 8 位,或者每个像素 24 位。因此,对于给定的颜色组件,我们限制在 0 到 255 之间的强度范围内。OpenGL 内部使用浮点值表示颜色强度,提供广泛的值和精度范围。在渲染之前,这些值最终通过将浮点范围[0.0, 1.0]映射到无符号字节的范围[0, 255]来转换为 8 位值。

然而,真实场景的亮度范围要宽得多。例如,场景中可见的光源或它们的直接反射可能比光源照亮的物体亮数百到数千倍。当我们使用每个通道 8 位,或浮点范围[0.0, -1.0]工作时,我们无法表示这个强度范围。如果我们决定使用更大的浮点值范围,我们可以更好地内部表示这些强度,但最终我们仍然需要将其压缩到 8 位范围。

使用更大动态范围计算光照/着色的过程通常被称为高动态范围渲染HDR 渲染)。摄影师非常熟悉这个概念。当摄影师想要捕捉比单次曝光通常可能捕捉到的更大范围的强度时,他/她可能会拍摄几张不同曝光的照片来捕捉更广泛的值。这个称为高动态范围成像HDR 成像)的概念在本质上与 HDR 渲染的概念非常相似。现在,包含 HDR 的后处理流程被认为是任何游戏引擎的基本组成部分。

色调映射是将广泛的动态范围值压缩到适合输出设备的较小范围的过程。在计算机图形学中,通常,色调映射是将某些任意值范围映射到 8 位范围。目标是保持图像的暗部和亮部,以便两者都可见,且没有任何部分是完全褪色的。

例如,包含明亮光源的场景可能会导致我们的着色模型产生大于 1.0 的强度。如果我们直接将其发送到输出设备,任何大于 1.0 的部分都会被限制为 255,并显示为白色。结果可能是一张大部分为白色的图像,类似于曝光过度的照片。

或者,如果我们把强度线性压缩到 [0, 255] 范围内,较暗的部分可能会太暗或完全看不见。使用色调映射,我们希望保持光源的亮度,同时也保持暗部区域的细节。

当涉及到色调映射和 HDR 渲染/成像时,这个描述只是触及了表面。对于更多细节,我推荐阅读 Reinhard 等人所著的《高动态范围成像》一书。

将一个动态范围映射到更小范围的数学函数称为色调映射算子TMO)。这些通常有两种类型,局部算子和全局算子。局部算子通过使用给定像素的当前值以及可能的一些邻近像素的值来确定该像素的新值。全局算子需要有关整个图像的一些信息才能工作。例如,它可能需要知道图像中所有像素的整体平均亮度。其他全局算子使用整个图像上亮度值的直方图来帮助微调映射。

在这个配方中,我们将使用书中《实时渲染》中描述的简单全局算子。此算子使用图像中所有像素的对数平均亮度。对数平均是通过取亮度的对数并平均这些值,然后转换回来得到的,如下方程所示:

Lw(x, y) 处像素的亮度。包含 0.0001 项是为了避免对黑色像素取对数。然后,这个对数平均被用作色调映射算子的一个部分,如下所示:

这个方程中的 a 项是关键。它以类似相机曝光级别的方式起作用。a 的典型值范围从 0.18 到 0.72。由于这个色调映射算子对暗部和亮部的值压缩得有点过多,我们将使用一个修改过的方程,它不会对暗部值压缩得那么厉害,并包括一个最大亮度 (L[white]),这是一个可配置的值,有助于减少一些极端明亮的像素:

这是我们在本例中将要使用的色调映射算子。我们将场景渲染到一个高分辨率缓冲区中,计算对数平均亮度,然后在第二次遍历中应用之前的色调映射算子。

然而,在我们开始实现之前,还有一个细节需要处理。之前的方程都处理亮度。从一个 RGB 值开始,我们可以计算它的亮度,但一旦我们修改了亮度,我们如何修改 RGB 分量以反映新的亮度而不改变色调(或色度)呢?

色度是感知到的颜色,与该颜色的亮度无关。例如,灰色和白色是同一颜色的两种亮度级别。

解决方案涉及切换颜色空间。如果我们把场景转换到一个将亮度与色度分离的颜色空间,那么我们可以独立地改变亮度值。CIE XYZ颜色空间正好符合我们的需求。CIE XYZ 颜色空间被设计成其Y分量描述颜色的亮度,而色度可以通过两个导出参数(xy)来确定。导出的颜色空间被称为CIE xyY空间,这正是我们所寻找的。Y分量包含亮度,而xy分量包含色度。通过转换为CIE xyY空间,我们已经将亮度从色度中分离出来,允许我们改变亮度而不影响感知到的颜色。

因此,这个过程涉及将 RGB 转换为 CIE XYZ,然后转换为 CIE xyY,修改亮度,并逆过程回到 RGB。从 RGB 到 CIE XYZ(反之亦然)可以描述为一个转换矩阵(请参阅代码或另请参阅部分以获取矩阵)。

从 XYZ 到 xyY 的转换涉及以下内容:

最后,使用以下方程将 xyY 转换回 XYZ:

以下图像展示了此色调映射算子的结果示例。左侧图像显示了未进行任何色调映射的场景渲染。阴影是故意使用三个强烈的光源计算出的,具有广泛的动态范围。由于任何大于 1.0 的值都会被限制在最大强度,因此场景看起来过曝。右侧的图像使用了相同的场景和相同的阴影,但应用了之前的色调映射算子。注意球体和茶壶上过曝区域的镜面高光恢复:

准备工作

涉及的步骤如下:

  1. 将场景渲染到高分辨率纹理中。

  2. 计算对数平均亮度(在 CPU 上)。

  3. 渲染一个填充屏幕的四边形以执行每个屏幕像素的片段着色器。在片段着色器中,从步骤 1 中创建的纹理中读取,应用色调映射算子,并将结果发送到屏幕。

为了设置环境,创建一个高分辨率纹理(使用GL_RGB32F或类似格式),并将其附加到一个具有深度附加的帧缓冲区。设置你的片段着色器,使用统一变量来选择通道。顶点着色器可以简单地传递眼睛坐标中的位置和法线。

如何做到这一点...

为了实现 HDR 色调映射,我们将执行以下步骤:

  1. 在第一次遍历中,我们只想将场景渲染到高分辨率纹理中。绑定到已附加纹理的帧缓冲区,并正常渲染场景。

  2. 计算纹理中像素的对数平均亮度。为此,我们将从纹理中提取数据,并在 CPU 端循环遍历像素。我们这样做是为了简单起见;一个 GPU 实现,可能使用计算着色器,会更快:

int size = width * height;
std::vector<GLfloat> texData(size*3);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTex);
glGetTexImage(GL_TEXTURE_2D, 0, GL_RGB, GL_FLOAT, texData.data());
float sum = 0.0f;
for( int i = 0; i < size; i++ ) {
 float lum = computeLum(texData[i*3+0], texData[i*3+1], texData[i*3+2]);
 sum += logf( lum + 0.00001f );
}
float logAve = expf( sum / size ); 
  1. 使用logAve设置AveLum统一变量。切换回默认帧缓冲区,并绘制一个填充屏幕的四边形。在片段着色器中,将色调映射算子应用于第一步生成的纹理值:
// Retrieve high-res color from texture 
vec4 color = texture( HdrTex, TexCoord ); 

// Convert to XYZ 
vec3 xyzCol = rgb2xyz * vec3(color); 

// Convert to xyY 
float xyzSum = xyzCol.x + xyzCol.y + xyzCol.z; 
vec3 xyYCol = vec3(0.0); 
if( xyzSum > 0.0 ) // Avoid divide by zero 
  xyYCol = vec3( xyzCol.x / xyzSum, 
         xyzCol.y / xyzSum, xyzCol.y); 

// Apply the tone mapping operation to the luminance 
// (xyYCol.z or xyzCol.y) 
float L = (Exposure * xyYCol.z) / AveLum; 
L = (L * ( 1 + L / (White * White) )) / ( 1 + L ); 

// Using the new luminance, convert back to XYZ 
if( xyYCol.y > 0.0 ) { 
 xyzCol.x = (L * xyYCol.x) / (xyYCol.y); 
 xyzCol.y = L; 
 xyzCol.z = (L * (1 - xyYCol.x - xyYCol.y))/xyYCol.y; 
} 

// Convert back to RGB and send to output buffer 
FragColor = vec4( xyz2rgb * xyzCol, 1.0); 

它是如何工作的...

在第一步中,我们将场景渲染到 HDR 纹理中。在第二步中,通过从纹理中检索像素并在 CPU(OpenGL 端)上进行计算来计算对数平均亮度。

在第三步中,我们渲染一个单独的填充屏幕四边形以执行每个屏幕像素的片段着色器。在片段着色器中,我们从纹理中检索 HDR 值并应用色调映射算子。在这个计算中有两个可调变量。Exposure变量对应于色调映射算子中的a项,而White变量对应于L[white]。对于前面的图像,我们分别使用了0.350.928的值。

还有更多...

色调映射并不是一门精确的科学。通常,这是一个不断尝试参数直到找到既有效又美观的过程。

我们可以通过在 GPU 上使用计算着色器(参考第十一章,使用计算着色器)或某些其他巧妙的技术来提高先前技术的效率。例如,我们可以将对数写入纹理,然后迭代地将整个帧下采样到 1 x 1 纹理。最终结果将可用在该单个像素中。然而,有了计算着色器的灵活性,我们可以进一步优化这个过程。

另请参阅

  • 示例代码中的chapter06/scenetonemap.cpp文件。

  • 布鲁斯·贾斯汀·林德布卢姆提供了一项有用的网络资源,用于颜色空间之间的转换。它包括将 RGB 转换为 XYZ 所需的各种转换矩阵。访问:www.brucelindbloom.com/index.html?Eqn_XYZ_to_RGB.html

  • 在第五章,使用纹理中的渲染到纹理配方。

创建辉光效果

辉光是一种视觉效果,图像的明亮部分似乎有边缘延伸到图像的较暗部分。这种效果基于相机和人类视觉系统感知高对比度区域的方式。由于所谓的空气盘(由通过孔的光产生的衍射图案),明亮的光源会渗透到图像的其他区域。

下图展示了动画电影《大象之梦》(© 2006,Blender 基金会 / 荷兰媒体艺术学院 / www.elephantsdream.org)中的溢出效果。门后的明亮白色光线渗透到图像的较暗部分:

在人工 CG 渲染中产生这种效果需要确定图像中哪些部分足够明亮,提取这些部分,模糊,并与原始图像重新组合。通常,溢出效果与 HDR 渲染相关联。使用 HDR 渲染时,我们可以为每个像素表示更广泛的强度范围(而不产生量化伪影)。由于可以表示更广泛的亮度值,因此与 HDR 渲染结合使用时,溢出效果更准确。

尽管 HDR(高动态范围)可以产生更高质量的结果,但在使用标准(非 HDR)颜色值时,仍然可能出现溢出效果。结果可能不会那么有效,但涉及的原则对两种情况都是相似的。

在以下示例中,我们将使用五次迭代实现溢出效果,包括四个主要步骤:

  1. 在第一次迭代中,我们将场景渲染到 HDR 纹理中。

  2. 第二次迭代将提取图像中比某个阈值值更亮的部分。我们将此称为亮通道滤波器。在应用此滤波器时,我们还将下采样到较低分辨率的缓冲区。我们这样做是因为当我们使用线性采样器从这个缓冲区读取时,我们将获得图像的额外模糊。

  3. 第三和第四次迭代将对明亮部分应用高斯模糊(参考本章中的应用高斯模糊滤波器配方)。

  4. 在第五次迭代中,我们将应用色调映射并将色调映射结果添加到模糊的亮通道滤波器结果中。

下图总结了这一过程。左上角的图像显示了渲染到 HDR 缓冲区的场景,其中一些颜色超出色域,导致图像的大部分区域过曝。亮通道滤波器产生一个更小的(大约是原始大小的四分之一或八分之一)图像,其中只有对应于高于阈值的亮度的像素。在这些示例中,像素显示为白色,因为它们的值大于一。对下采样图像应用两次高斯模糊,并对原始图像应用色调映射。最终图像是通过将色调映射图像与模糊的亮通道滤波器图像组合而成的。在采样后者时,我们使用线性滤波器以获得额外的模糊。最终结果显示在底部。

注意球体和背墙上的明亮高光处的溢出效果:

准备工作

对于这个配方,我们需要两个帧缓冲区对象,每个都与一个纹理相关联。第一个将用于原始 HDR 渲染,第二个将用于高斯模糊操作的两次传递。在片段着色器中,我们将通过变量HdrTex访问原始渲染,而高斯模糊的两个阶段将通过BlurTex访问。

常量变量LumThresh是第二次传递中使用的最小亮度值。任何大于该值的像素将在后续传递中被提取并模糊。

使用一个顶点着色器,该着色器通过眼坐标传递位置和法线。

如何实现...

要生成辉光效果,请执行以下步骤:

  1. 在第一次传递中,将场景渲染到具有高分辨率背板纹理的帧缓冲区中。

  2. 在第二次传递中,切换到一个包含高分辨率纹理的帧缓冲区,该纹理的大小小于完整渲染的大小。在示例代码中,我们使用一个大小为原始大小八分之一的纹理。绘制一个全屏四边形以启动每个像素的片段着色器,并在片段着色器中从高分辨率纹理中采样,并仅写入大于LumThresh的值。否则,将像素着色为黑色:

vec4 val = texture(HdrTex, TexCoord); 
if( luminance(val.rgb) > LumThresh ) 
  FragColor = val; 
else 
  FragColor = vec4(0.0); 
  1. 在第三次和第四次传递中,将高斯模糊应用于第二次传递的结果。这可以通过一个帧缓冲区和两个纹理来完成。在它们之间进行乒乓操作,从一个读取并写入另一个。有关详细信息,请参阅本章中的应用高斯模糊过滤器配方。

  2. 在第五次和最后一次传递中,从第四次传递产生的纹理切换到线性过滤。切换到默认帧缓冲区(屏幕)。将使用色调映射实现 HDR 光照配方中的色调映射操作应用于原始图像纹理(HdrTex),并将结果与步骤 3 中的模糊纹理组合。线性过滤和放大应提供额外的模糊:

// Retrieve high-res color from texture 
vec4 color = texture( HdrTex, TexCoord ); 

// Apply tone mapping to color, result is toneMapColor 
... 

///////// Combine with blurred texture ////////// 
vec4 blurTex = texture(BlurTex1, TexCoord); 

FragColor = toneMapColor + blurTex;

它是如何工作的...

由于空间限制,这里没有显示完整的片段着色器代码。完整的代码可以从 GitHub 仓库中获取。片段着色器使用五个方法实现,每个方法对应一个传递。第一次传递将场景正常渲染到 HDR 纹理中。在此传递期间,活动的帧缓冲区对象是与HdrTex对应的纹理所关联的,因此输出直接发送到该纹理。

第二次传递从HdrTex读取,并仅写入亮度值高于阈值LumThresh的像素。对于亮度(亮度)值低于LumThresh的像素,值为(0,0,0,0)。输出进入第二个帧缓冲区,其中包含一个大小大得多的纹理(原始大小的八分之一)。

第三和第四次迭代应用基本的高斯模糊操作(参见图章中的应用高斯模糊滤波器配方)。在这些迭代中,我们在BlurTex1BlurTex2之间进行乒乓操作,因此我们必须小心地将适当的纹理交换到帧缓冲区中。

在第五次迭代中,我们切换回默认帧缓冲区,并从HdrTexBlurTex1中读取。BlurTex1包含第四步的最终模糊结果,而HdrTex包含原始渲染。我们对HdrTex的结果应用色调映射并添加到BlurTex1。在从BlurTex1中提取时,我们应用线性过滤器,从而获得额外的模糊效果。

还有更多...

注意,我们已将色调映射运算符应用于原始渲染图像,但未应用于模糊亮通道滤波图像。有人可以选择将 TMO 应用于模糊图像,但在实践中,这通常不是必要的。我们应该记住,如果过度使用,光晕效果也可能在视觉上分散注意力。适可而止。

参见

  • 示例代码中的chapter06/scenehdrbloom.cpp文件

  • HDR meets Black & White 2 by Francesco Caruzzi in Shader X6

  • 第五章中的将渲染到纹理配方,使用纹理

  • 本章中的应用边缘检测滤波器配方

使用伽玛校正来提高图像质量

许多关于 OpenGL 和 3D 图形的书籍通常会忽视伽玛校正的主题。光照和着色计算被执行,结果未经修改直接发送到输出缓冲区。然而,当我们这样做时,我们可能产生与预期不完全一致的结果。这可能是因为计算机显示器(无论是老式的 CRT 还是较新的 LCD)对像素强度的响应是非线性的。例如,没有伽玛校正,灰度值为 0.5 不会像值为 1.0 那样亮一半。相反,它看起来会比应有的更暗。

下图中较低的曲线显示了典型显示器(伽玛为2.2)的响应曲线。x轴是强度,y轴是感知强度。虚线表示一系列线性强度。上曲线表示应用于线性值的伽玛校正。下曲线表示典型显示器的响应。在具有类似响应曲线的屏幕上,0.5的灰度值看起来会有0.218的值:

典型显示器的非线性响应通常可以用一个简单的幂函数来建模。感知强度(P)与像素强度(I)的幂成正比,这个幂通常称为伽玛:

根据显示设备的不同,γ的值通常在 2.0 到 2.4 之间。通常需要某种类型的显示器校准来确定精确的值。

为了补偿这种非线性响应,我们可以在将结果发送到输出帧缓冲区之前应用伽玛校正。伽玛校正涉及将像素强度提升到一种幂,以补偿显示器对非线性的响应,从而实现看起来线性的感知结果。将线性空间值提升到1/γ的幂将起到作用:

在渲染时,我们可以进行所有的光照和着色计算,忽略显示器响应曲线的非线性。这有时被称为在线性空间中工作。当最终结果要写入输出帧缓冲区时,我们可以在写入之前通过将像素提升到 1/γ的幂来应用伽玛校正。这是一个重要的步骤,将有助于改善渲染结果的外观。

例如,考虑以下图像。左边的图像是在不考虑伽玛的情况下渲染的网格。反射模型被计算,结果直接发送到帧缓冲区。右边的图像是在输出之前对颜色应用伽玛校正的相同网格:

明显的区别是,左边的图像看起来比右边的图像暗得多。然而,更重要的区别是面部从亮到暗的变化。虽然阴影终止器的过渡似乎比以前更强,但光照区域内的变化不那么极端。

应用伽玛校正是一项重要的技术,可以有效提高光照模型的结果。

如何操作...

将伽玛校正添加到 OpenGL 程序可以像执行以下步骤一样简单:

  1. 设置一个名为Gamma的统一变量,并将其设置为适合你系统的适当值。

  2. 在片段着色器中使用以下代码或类似代码:

vec3 color = lightingModel( ... ); 
FragColor = vec4( pow( color, vec3(1.0/Gamma) ), 1.0 ); 

如果你的着色器涉及纹理数据,必须小心确保纹理数据没有被预先进行伽玛校正,以免你两次应用伽玛校正(参考本食谱的更多内容...部分)。

它是如何工作的...

由光照/着色模型确定的颜色被计算并存储在变量color中。我们将其视为在线性空间中计算颜色。在着色模型的计算过程中没有考虑显示器的响应(假设我们没有访问任何可能已经进行伽玛校正的纹理数据)。

要应用校正,在片段着色器中,我们将像素颜色提升到1.0 / Gamma的幂,并将结果应用到输出变量FragColor。当然,伽玛的倒数可以在片段着色器外部计算,以避免除法操作。

我们不对 alpha 分量应用伽玛校正,因为这通常是不希望的。

还有更多...

伽玛校正的应用在一般情况下是个好主意;然而,必须小心确保在正确的空间内进行计算。例如,纹理可能是照片或其他在将数据存储在图像文件之前应用伽玛校正的图像应用产生的图像。

因此,如果我们将纹理作为我们的光照模型的一部分并在其上应用伽玛校正,那么我们将对纹理数据实际上应用了两次伽玛校正。相反,我们需要小心地“解码”纹理数据,通过在将纹理数据用于我们的光照模型之前将其提升到伽玛的幂。

在《GPU Gems 3》一书中,由 Hubert Nguyen 编辑的“第二十四章,线性的重要性”中,对这些以及其他关于伽玛校正的问题进行了非常详细的讨论,这本书由 Addison-Wesley Professional 于 2007 年出版,强烈推荐作为补充阅读材料。

参见

  • 示例代码中的chapter06/scenegamma.cpp文件

使用多采样抗锯齿

抗锯齿是一种技术,用于消除或减少在高分辨率或连续信息以较低分辨率呈现时存在的走样伪影的视觉影响。在实时图形中,走样通常会在多边形边缘的锯齿状外观或具有高度变化的纹理的视觉失真中显现出来。

以下图像显示了物体边缘的走样伪影示例。在左侧,我们可以看到边缘看起来是锯齿状的。这是因为每个像素被确定要么完全位于多边形内部,要么完全位于多边形外部。如果像素被确定在内部,它会被着色,否则则不会。当然,这并不完全准确。一些像素直接位于多边形的边缘。像素覆盖的屏幕区域中,一些实际上位于多边形内部,而一些位于外部。如果我们根据像素面积在多边形内部的部分修改像素的着色,可能会得到更好的结果。结果可能是着色表面的颜色与多边形外部的颜色的混合,其中像素覆盖的区域决定了比例。你可能认为这样做可能会非常昂贵。这可能确实如此;然而,我们可以通过每个像素使用多个样本来近似结果。

多采样抗锯齿涉及对每个像素进行多次采样并将这些样本的结果组合起来以确定像素的最终值。这些样本位于像素范围内的各个点。其中大部分样本将落在多边形内部,但对于靠近多边形边缘的像素,一些样本将落在多边形外部。片段着色器通常像往常一样为每个像素执行一次。例如,对于 4x 多采样抗锯齿MSAA),光栅化发生的频率是四倍。对于每个像素,片段着色器执行一次,结果根据有多少个样本落在多边形内进行缩放。

右侧的以下图像显示了使用多采样抗锯齿时的结果。内嵌图像是环面内部边缘的一个放大部分。在左侧,环面没有使用 MSAA 进行渲染。右侧的图像显示了启用 MSAA 后的结果:

OpenGL 已经支持多采样一段时间了,使用起来几乎是透明的。这只是一个开启或关闭的问题。它是通过使用额外的缓冲区来存储在处理过程中产生的亚像素样本来工作的。然后,将这些样本组合起来以产生片段的最终颜色。几乎所有这些都是自动的,程序员几乎无法对结果进行微调。然而,在本食谱的末尾,我们将讨论可能影响结果的插值限定符。

在这个食谱中,我们将看到在 OpenGL 应用程序中启用多采样抗锯齿所需的代码。

准备工作

启用多采样的技术不幸地依赖于窗口系统 API。在这个例子中,我们将演示如何使用 GLFW 来实现。步骤在 GLUT 或其他支持 OpenGL 的 API 中将是相似的。

如何操作...

为了确保创建并可用多采样缓冲区,请按照以下步骤操作:

  1. 在创建你的 OpenGL 窗口时,你需要选择支持 MSAA 的 OpenGL 上下文。以下是在 GLFW 中这样做的方法:
glfwWindowHint(GLFW_SAMPLES, 8); 
... // Other settings 
window = glfwCreateWindow( WIN_WIDTH, WIN_HEIGHT, 
            "Window title", NULL, NULL ); 
  1. 要确定是否可用多采样缓冲区以及每像素实际使用多少个样本,你可以使用以下代码(或类似代码):
GLint bufs, samples; 
glGetIntegerv(GL_SAMPLE_BUFFERS, &bufs); 
glGetIntegerv(GL_SAMPLES, &samples); 
printf("MSAA: buffers = %d samples = %dn", bufs, samples); 
  1. 要启用多采样,请使用以下代码:
glEnable(GL_MULTISAMPLE); 
  1. 要禁用多采样,请使用以下代码:
glDisable(GL_MULTISAMPLE); 

它是如何工作的...

正如我们刚才提到的,创建具有多采样缓冲区的 OpenGL 上下文的技术取决于与窗口系统交互所使用的 API。前面的示例演示了如何使用 GLFW 来实现这一点。一旦创建了 OpenGL 上下文,就可以通过简单地使用前面示例中显示的glEnable调用来启用多采样。

请保持关注,因为在下一节中,我们将讨论在启用多采样抗锯齿时围绕着色器变量插值的一个微妙问题。

更多内容...

在 GLSL 中有两个插值限定符,允许程序员微调多采样的一些方面。它们是samplecentroid

在我们能够深入了解samplecentroid如何工作之前,我们需要一些背景知识。让我们考虑在没有多采样的情况下处理多边形边的方式。一个片段被判定为在多边形内部还是外部,是通过确定该像素中心的位置来实现的。如果中心位于多边形内,则该像素被着色,否则不被着色。以下图像展示了这种行为。它显示了没有 MSAA 的多边形边缘附近的像素。线条代表多边形的边缘。灰色像素被认为是多边形内部。白色像素在多边形外部,并且不被着色。点代表像素中心:

图像

对于插值变量(片段着色器的输入变量)的值,是相对于每个片段的中心进行插值的,这始终位于多边形内。

当启用多采样抗锯齿时,每个片段在其范围内的多个位置计算多个样本。如果其中任何一个样本位于多边形内,则至少为该像素执行一次着色器(但不一定是每个样本)。

作为视觉示例,以下图像展示了多边形边缘附近的像素。点代表样本。深色样本位于多边形内,白色样本位于多边形外。如果任何样本位于多边形内,则对该像素执行片段着色器(通常只执行一次)。请注意,对于某些像素,像素中心位于多边形外。因此,在 MSAA 的情况下,片段着色器可能在多边形的边缘附近稍微执行得更频繁:

图像

现在,这里有一个重要的观点。片段着色器的输入变量值通常是插值到像素中心,而不是任何特定样本的位置。换句话说,片段着色器使用的值是通过插值到片段中心的位置来确定的,这个位置可能位于多边形外部!如果我们依赖于片段着色器的输入变量严格插值在顶点值之间(而不是该范围之外),那么这可能会导致意外的结果。

例如,考虑以下片段着色器的一部分:

in vec2 TexCoord; 

layout( location = 0 ) out vec4 FragColor; 

void main() { 
 vec3 yellow = vec3(1.0,1.0,0.0); 
 vec3 color = vec3(0.0);  // black 
 if( TexCoord.s > 1.0 ) 
  color = yellow; 
 FragColor = vec4( color , 1.0 ); 
}

此着色器设计为将多边形着色为黑色,除非纹理坐标的s分量大于一。在这种情况下,片段将获得黄色。如果我们渲染一个每个方向纹理坐标范围从零到一的方形,我们可能会得到以下左侧图像所示的结果。以下图像显示了多边形边缘的放大图像,其中s纹理坐标约为1.0。两个图像都是使用前面的着色器渲染的。右侧图像是使用centroid修饰符创建的(关于这一点将在本章后面详细说明):

图片

左侧图像显示边缘的一些像素颜色较浅(如果图像是全彩的,则为黄色)。这是由于纹理坐标被插值到像素中心,而不是任何特定的样本位置。边缘的一些片段中心位于多边形外部,因此最终得到的纹理坐标大于一!

我们可以要求 OpenGL 通过插值到像素内且在多边形内的某个位置来计算输入变量的值。我们可以通过使用centroid修饰符来实现,如下面的代码所示:

centroid in vec2 TexCoord; 

(修饰符还需要与顶点着色器中相应的输出变量一起使用。)当与前面的着色器一起使用centroid时,我们得到右侧显示的先前图像。

通常,当我们知道输入变量的插值不应超出这些变量在顶点处的值时,我们应该使用centroidsample

sample修饰符强制 OpenGL 将着色器的输入变量插值到样本本身的实际位置:

sample in vec2 TexCoord;

这当然要求对于每个样本执行一次片段着色器。这将产生最准确的结果,但性能损失可能不值得,尤其是如果由centroid(或默认值)产生的视觉结果已经足够好的情况下。

参见

  • 示例代码中的chapter06/scenemsaa.cpp文件

使用延迟着色

延迟着色是一种涉及将(或推迟)光照/着色步骤到第二次遍历的技术。我们这样做(以及其他原因)是为了避免对像素进行多次着色。基本思想如下:

  1. 在第一次遍历中,我们渲染场景,但不是通过评估反射模型来确定片段颜色,而是简单地将所有几何信息(位置、法线、纹理坐标、反射率等)存储在一个中间缓冲区集中,统称为g 缓冲区(g 代表几何)。

  2. 在第二次遍历中,我们简单地从 g 缓冲区读取,评估反射模型,并为每个像素生成最终颜色。

当使用延迟着色时,我们避免评估最终不会可见的片段的反射模型。例如,考虑一个位于两个多边形重叠区域中的像素。片段着色器可能为覆盖该像素的每个多边形执行一次;然而,只有两次执行中的一次的结果将成为该像素的最终颜色(假设没有启用混合)。评估其中一个片段的反射模型所花费的周期实际上是浪费的。使用延迟着色,反射模型的评估被推迟到所有几何体都已被处理,并且每个像素位置都已知可见几何体。因此,反射模型仅在屏幕上的每个像素上评估一次。这使我们能够以更高效的方式进行光照。例如,我们可以使用甚至数百个光源,因为我们只为每个屏幕像素评估一次光照。

延迟着色相对简单易懂,因此可以帮助实现复杂的光照/反射模型。

在这个菜谱中,我们将通过一个简单的延迟着色示例。我们将在我们的 g-buffer 中存储以下信息:位置、法线和漫反射颜色(漫反射反射率)。在第二次遍历中,我们将简单地使用存储在 g 缓冲区中的数据评估漫反射光照模型。

这个菜谱旨在作为延迟着色的起点。如果我们要在更实质的(现实世界)应用程序中使用延迟着色,我们可能需要在我们的 g 缓冲区中添加更多组件。扩展此示例以使用更复杂的光照/着色模型应该是直接明了的。

准备工作

g 缓冲区将包含三个纹理来存储位置、法线和漫反射颜色。有三个统一变量对应于这三个纹理:PositionTexNormalTexColorTex;这些纹理应分别分配到纹理单元012。同样,顶点着色器假定位置信息由顶点属性0提供,法线由属性1提供,纹理坐标由属性2提供。

片段着色器有几个与光照和材质属性相关的统一变量,这些变量必须从 OpenGL 程序中设置。具体来说,LightMaterial结构体适用于这里使用的着色模型。

你需要一个名为deferredFBO(类型为GLuint)的变量来存储 FBO 的句柄。

如何实现...

要创建包含我们的 g-buffer(s)的帧缓冲对象,请使用以下代码:

void createGBufTex(GLenum texUnit, GLenum format, 
          GLuint &texid ) { 
  glActiveTexture(texUnit); 
  glGenTextures(1, &texid); 
  glBindTexture(GL_TEXTURE_2D, texid); 
  glTexStorage2D(GL_TEXTURE_2D,1,format,width,height); 
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 
          GL_NEAREST); 
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, 
          GL_NEAREST); 
} 
... 
GLuint depthBuf, posTex, normTex, colorTex; 

// Create and bind the FBO 
glGenFramebuffers(1, &deferredFBO); 
glBindFramebuffer(GL_FRAMEBUFFER, deferredFBO); 

// The depth buffer 
glGenRenderbuffers(1, &depthBuf); 
glBindRenderbuffer(GL_RENDERBUFFER, depthBuf); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, 
           width, height); 

// The position, normal and color buffers 
createGBufTex(GL_TEXTURE0, GL_RGB32F, posTex); // Position 
createGBufTex(GL_TEXTURE1, GL_RGB32F, normTex); // Normal 
createGBufTex(GL_TEXTURE2, GL_RGB8, colorTex); // Color 

// Attach the images to the framebuffer 
glFramebufferRenderbuffer(GL_FRAMEBUFFER, 
     GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthBuf); 
glFramebufferTexture2D(GL_FRAMEBUFFER, 
     GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, posTex, 0); 
glFramebufferTexture2D(GL_FRAMEBUFFER, 
     GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, normTex, 0); 
glFramebufferTexture2D(GL_FRAMEBUFFER, 
     GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, colorTex, 0); 

GLenumdrawBuffers[] = {GL_NONE, GL_COLOR_ATTACHMENT0, 
     GL_COLOR_ATTACHMENT1,GL_COLOR_ATTACHMENT2}; 
glDrawBuffers(4, drawBuffers); :

在第一次遍历中,片段着色器将写入 G 缓冲区。在第二次遍历中,它将从这些缓冲区读取并应用着色模型。

in vec3 Position;
in vec3 Normal;
in vec2 TexCoord;

layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec3 PositionData;
layout (location = 2) out vec3 NormalData;
layout (location = 3) out vec3 ColorData;

// The g-buffer textures 
layout(binding = 0) uniform sampler2D PositionTex; 
layout(binding = 1) uniform sampler2D NormalTex; 
layout(binding = 2) uniform sampler2D ColorTex; 

uniform int Pass; // Pass number

// Material/light uniforms...

vec3 diffuseModel( vec3 pos, vec3 norm, vec3 diff ) { 
 vec3 s = normalize( vec3(Light.Position) - pos);
 float sDotN = max( dot(s,norm), 0.0 );
 return Light.L * diff * sDotN;
}

void pass1() { 
  // Store position, norm, and diffuse color in g-buffer 
  PositionData = Position; 
  NormalData = Normal; 
  ColorData = Material.Kd; 
} 

void pass2() { 
  // Retrieve position, normal and color information from 
  // the g-buffer textures 
  vec3 pos = vec3( texture( PositionTex, TexCoord ) ); 
  vec3 norm = vec3( texture( NormalTex, TexCoord ) ); 
  vec3 diffColor = vec3( texture(ColorTex, TexCoord) ); 

  FragColor=vec4(diffuseModel(pos,norm,diffColor), 1.0); 
} 

void main() { 
 if( Pass == 1 ) pass1();
 else if( Pass==2 ) pass2();
} 

在 OpenGL 应用程序的render函数中,对于遍历#1,使用以下步骤:

  1. 绑定到帧缓冲对象deferredFBO

  2. 清除颜色/深度缓冲区,将Pass设置为1,并启用深度测试(如果需要)

  3. 正常渲染场景

使用以下步骤进行第 2 次传递:

  1. 恢复到默认的 FBO(绑定到帧缓冲区 0)

  2. 清除颜色缓冲区,将Pass设置为2,并禁用深度测试(如果需要)

  3. 使用纹理坐标在每个方向上从零到一的屏幕填充四边形(或两个三角形)进行渲染

它是如何工作的...

在设置 g-buffer 的 FBO 时,我们使用具有内部格式GL_RGB32F的纹理来存储位置和法线分量。由于我们存储的是几何信息,而不是简单的颜色信息,因此需要使用更高分辨率的纹理(即每个像素更多的位)。漫反射率的缓冲区仅使用GL_RGB8,因为我们不需要为这些值提供额外的分辨率。

然后将这三个纹理通过glFramebufferTexture2D附加到帧缓冲区的颜色附件012。接着,通过调用glDrawBuffers将它们连接到片段着色器的输出变量:

glDrawBuffers(4, drawBuffers); 

数组drawBuffers表示帧缓冲区组件与片段着色器输出变量位置之间的关系。数组中的第i项对应于第i个输出变量位置。此调用将颜色附件012分别设置为输出变量位置123。(注意,片段着色器中相应的变量是PositionDataNormalDataColorData。)

在第 2 次传递过程中,转换和传递法线和位置不是严格必要的,因为它们在片段着色器中根本不会被使用。然而,为了保持简单,这个优化没有被包括在内。向顶点着色器中添加一个子例程来关闭第 2 次传递中的转换是件简单的事情。(当然,我们需要设置gl_Position。)

在片段着色器中,功能取决于变量Pass的值。它将根据其值调用pass1pass2。在pass1函数中,我们将PositionNormalMaterial.Kd的值存储在适当的输出变量中,实际上是将它们存储在我们刚刚提到的纹理中。

pass2函数中,从纹理中检索位置、法线和颜色值,并用于评估漫反射光照模型。然后将结果存储在输出变量FragColor中。在这个传递过程中,FragColor应该绑定到默认帧缓冲区,因此这个传递的结果将显示在屏幕上。

还有更多...

在图形社区中,延迟着色的相对优缺点是某些争论的来源。延迟着色并不适用于所有情况。它很大程度上取决于你应用程序的具体要求,并且在决定是否使用延迟着色之前,需要仔细评估其利弊。

在 OpenGL 的较新版本中,可以通过使用GL_TEXTURE_2D_MULTISAMPLE来实现具有延迟着色的多样本抗锯齿。

另一个考虑因素是,延迟着色在混合/透明度方面做得不是很好。实际上,使用我们之前看到的基本实现进行混合是不可能的。通过在 g 缓冲区中存储额外的分层几何信息,使用具有深度剥离的额外缓冲区可以帮助解决这个问题。

延迟着色的一个显著优点是,可以保留第一次遍历中的深度信息,并在着色遍历期间将其作为纹理访问。能够将整个深度缓冲区作为纹理访问可以启用诸如景深(深度模糊)、屏幕空间环境光遮蔽、体积粒子以及其他类似技术等算法。

想要了解更多关于延迟着色的信息,请参阅由 Matt Pharr 和 Randima Fernando 编辑的《GPU Gems 2》(Addison-Wesley Professional 2005)的第九章以及由 Hubert Nguyen 编辑的《GPU Gems 3》(Addison-Wesley Professional 2007)的第十九章。这两章结合在一起,对延迟着色的优缺点进行了出色的讨论,并说明了如何在应用程序中决定是否使用它。

参见

  • 示例代码中的chapter06/scenedeferred.cpp文件

  • 第五章中的渲染到纹理配方,使用纹理

屏幕空间环境光遮蔽

环境光遮蔽是一种基于假设表面从所有方向接收均匀光照的渲染技术。由于附近有遮挡一些光线的物体,一些表面位置会比其他位置接收到的光更少。如果一个表面点附近有大量的局部几何形状,那么一些这种环境光照将被阻挡,导致该点变暗。

以下图像展示了这个示例(使用 Blender 生成):

图片

这张图像仅使用环境光遮蔽渲染,没有光源。注意结果在具有局部几何形状遮挡环境光照的区域看起来像阴影。结果对眼睛来说非常令人愉悦,并为图像增添了大量的真实感。

环境光遮蔽是通过从以表面点为中心的上半球测试表面点的可见性来计算的。考虑以下图中AB两个点:

图片

A位于表面的一个角落附近,而点B位于一个平坦区域。箭头表示可见性测试的方向。点B上方的半球内所有方向都是未被遮挡的,这意味着光线不会与任何几何形状相交。然而,在点A上方的半球内,大约有一半的方向被遮挡(带有虚线的箭头)。因此,A应该接收到的光照较少,看起来比点B更暗。

实质上,环境光遮蔽可以归结为以下过程。在表面点周围的半球上尽可能多地采样。测试每个方向的可视性(遮挡)。未被遮挡的射线比例给出了该点的环境光遮蔽因子。

这个过程通常需要大量的样本才能产生可接受的结果。对于复杂场景中的每个网格顶点来说,在实时情况下这样做是不切实际的。然而,对于静态场景,结果可以预先计算并存储在纹理中。如果几何形状可以移动,我们需要一些与场景复杂度无关的近似方法。

屏幕空间环境光遮蔽(或 SSAO)是一类算法的名称,这些算法试图通过使用屏幕空间信息实时近似环境光遮蔽。换句话说,使用 SSAO,我们将在场景渲染后,使用存储在深度缓冲区和/或几何缓冲区中的数据,在后期处理中计算环境光遮蔽。SSAO 与延迟着色(参见配方 使用延迟着色)自然结合,但也已与前向(非延迟)渲染器一起实现。

在这个配方中,我们将作为延迟渲染过程的一部分实现 SSAO。我们将计算每个屏幕空间像素的环境光遮蔽因子,而不是在场景中每个物体的表面上,忽略任何从相机遮挡的几何形状。在延迟着色渲染器的第一次遍历后,我们在我们的 g-buffers(参见配方 使用延迟着色)中具有每个屏幕像素的可见表面位置的位置、法线和颜色信息。对于每个像素,我们将使用位置和法线向量定义表面点上的半球。然后,我们将在该半球内随机选择位置(样本)并测试每个位置的可见性:

图片

上一张图表示了表面点 P 的遮挡测试。实心圆和空心圆是在 P 上方的半球内随机选择的样本点,沿法向量中心分布。空心圆未通过可见性测试,而实心圆通过了测试。

为了准确测试可见性,我们需要从表面点向所有样本点发射射线,并检查每条射线是否与表面相交。然而,我们可以避免这个过程。而不是追踪射线,我们将通过以下方式定义一个点从表面点可见:如果该点从相机可见,我们将假设它也从表面点可见。这在某些情况下可能不准确,但对于各种典型场景来说是一个很好的近似。

实际上,我们将使用一个额外的近似。我们不会从相机到表面点追踪射线;相反,我们只需比较被测试点和表面点在相机空间中相同 (x,y) 位置的 z 坐标。这引入了另一小部分错误,但在实践中并不足以引起反感。以下图解说明了这个概念:

对于我们在半球内测试的每个样本,我们找到在相机坐标中相同 (x,y) 位置上相机可见表面上的对应位置。这个位置简单地是样本在 (x,y) 位置的 g-缓冲区中的值。然后我们比较样本和表面点的 z 坐标。如果表面点的 z 坐标大于样本的 z 坐标(记住,我们在相机坐标中,所以所有 z 坐标都将为负),那么我们认为样本被表面遮挡。

如前图所示,这是眼射线可能发现的近似。前面的内容是过程的基本概述,没有更多内容。此算法归结为在每个点的半球上测试多个随机样本。

可见样本的比例是环境遮挡因子。当然,还有许多细节需要解决。让我们从过程概述开始。我们将使用四个遍历来实现此算法。

  1. 第一遍将数据渲染到 g-缓冲区:相机空间位置、法向量和基础颜色。

  2. 第二遍计算每个屏幕像素的环境遮挡因子。

  3. 第三遍是对环境遮挡数据的简单模糊,以消除高频伪影。

  4. 最终遍历是光照遍历。评估反射模型,整合环境遮挡。

最后三遍使用屏幕空间技术,这意味着我们通过仅渲染一个填充整个屏幕的四边形,为屏幕上的每个像素调用一次片段着色器。场景的实际几何图形仅在第一遍渲染。这里有趣的部分主要发生在第二遍。在那里,我们需要在每个点的表面上方生成多个随机点。在着色器内生成随机数由于多种原因而具有挑战性,这里我们不会深入探讨。因此,我们不会尝试生成随机数,而是在围绕 z 轴中心的半球中预先生成一组随机点。我们将称之为我们的随机核。我们将通过将点转换到相机空间,使核的 z 轴与表面点的法向量对齐,在每个点重用此核。为了增加更多的随机性,我们还将随机量旋转核绕法向量。

我们将在以下章节中详细说明步骤。

准备中

首先,让我们构建我们的随机核。我们需要一组以原点为中心的正 z 半球内的点。我们将使用半径为 1.0 的半球,这样我们就可以根据需要将其缩放到任何大小:

int kernSize = 64;
std::vector<float> kern(3 * kernSize);
for (int i = 0; i < kernSize; i++) {
 glm::vec3 randDir = rand.uniformHemisphere();
 float scale = ((float)(i * i)) / (kernSize * kernSize);
 randDir *= glm::mix(0.1f, 1.0f, scale);

 kern[i * 3 + 0] = randDir.x;
 kern[i * 3 + 1] = randDir.y;
 kern[i * 3 + 2] = randDir.z;
}

uniformHemisphere 函数以均匀的方式在半球表面选择一个随机点。如何实现这一点的细节在之前的配方中已有介绍(见 基于扩散图像的照明)。为了在半球内得到一个点,我们将点通过变量 scale 进行缩放。这个值将在 0 到 1 之间变化,并且是非线性的。它会在靠近原点的地方产生更多的点,而当我们远离原点时,点会减少。我们这样做是因为我们希望给靠近表面点的物体赋予稍微更多的权重。

我们将核点的值分配到名为 SampleKernel 的着色器中的均匀变量(数组)。

如前所述,我们希望为每个表面点重用这个核,但带有随机的旋转。为了做到这一点,我们将构建一个包含随机旋转向量的小纹理。每个向量将在 x-y 平面上是一个单位向量:

int size = 4;
std::vector<GLfloat> randDirections(3 * size * size);
for (int i = 0; i < size * size; i++) {
 glm::vec3 v = rand.uniformCircle();
 randDirections[i * 3 + 0] = v.x;
 randDirections[i * 3 + 1] = v.y;
 randDirections[i * 3 + 2] = v.z;
}
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGB16F, size, size);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, size, size, GL_RGB, GL_FLOAT, randDirections.data());
// ...

uniformCircle 函数在 x-y 平面的单位圆上给出一个随机点。在这里我们使用一个 4 x 4 的纹理,但您可以使用更大的尺寸。我们将把这个纹理平铺在整个屏幕上,并将其提供给着色器(均匀变量 RandTex)。

您可能会想,4 x 4 的纹理太小,无法提供足够的随机性。是的,它会产生高频模式,但模糊遍历将有助于平滑这种噪声。

在这个例子中,我们将使用单个着色器和单个帧缓冲区。当然,如果您愿意,您可以使用多个。我们需要用于相机空间位置、相机空间法线、基础颜色和环境遮挡的帧缓冲区纹理。AO 缓冲区可以是一个单通道纹理(例如,格式 R_16F)。我们还需要一个额外的 AO 纹理用于模糊遍历。我们将根据需要将每个纹理交换到帧缓冲区中。

如何实现...

  1. 在第一次遍历中,将场景渲染到几何缓冲区(有关详细信息,请参阅 使用延迟着色)。

  2. 在第二次遍历中,我们将使用这个片段着色器代码来计算 AO 因子。为此,我们首先计算一个将核点转换为相机空间的矩阵。在这样做的时候,我们使用 RandTex 中的一个向量来旋转核。这个过程与计算法线映射中的切线空间矩阵类似。有关更多信息,请参阅 使用法线图:

// Create the random tangent space matrix
vec3 randDir = normalize( texture(RandTex, TexCoord.xy * randScale).xyz );
vec3 n = normalize( texture(NormalTex, TexCoord).xyz );
vec3 biTang = cross( n, randDir );
// If n and randDir are parallel, n is in x-y plane
if( length(biTang) < 0.0001 ) 
  biTang = cross( n, vec3(0,0,1));
biTang = normalize(biTang);
vec3 tang = cross(biTang, n);
mat3 toCamSpace = mat3(tang, biTang, n);
  1. 然后,我们通过遍历所有核点,将它们转换到相机坐标,然后在相同的 (x,y) 位置找到表面点并比较 z 值来计算环境遮挡因子。我们将结果写入 AO 缓冲区:
float occlusionSum = 0.0;
vec3 camPos = texture(PositionTex, TexCoord).xyz;
for( int i = 0; i < kernelSize; i++ ) {
 vec3 samplePos = camPos + Radius * (toCamSpace * SampleKernel[i]);

 // Project point to texture space
 vec4 p = ProjectionMatrix * vec4(samplePos,1);
 p *= 1.0 / p.w;
 p.xyz = p.xyz * 0.5 + 0.5;

 // Camera space z-coordinate of surface at the x,y position
 float surfaceZ = texture(PositionTex, p.xy).z;
 float dz = surfaceZ - camPos.z;

 // Count points that ARE occluded within the hemisphere
 if( dz >= 0.0 && dz <= Radius && surfaceZ > samplePos.z ) 
  occlusionSum += 1.0;
}

AoData = 1.0 - occlusionSum / kernelSize;
  1. 在第三次遍历中,我们进行简单的模糊处理,使用九个最近像素的无权平均值。我们从上一次遍历中写入的纹理中读取,并将结果写入我们的第二个 AO 缓冲区纹理:
ivec2 pix = ivec2( gl_FragCoord.xy );
float sum = 0.0;
for( int x = -1; x <= 1; ++x ) {
 for( int y = -1; y <= 1; y++ ) {
  sum += texelFetchOffset( AoTex, pix, 0, ivec2(x,y) ).r;
 }
}
AoData = sum / 9.0;
  1. 第四次遍历使用前一次遍历的环境遮挡值应用反射模型。我们将环境部分按 AO 缓冲区中的值(提高到四次方)进行缩放(以略微夸张效果):
vec3 pos = texture( PositionTex, TexCoord ).xyz;
vec3 norm = texture( NormalTex, TexCoord ).xyz;
vec3 diffColor = texture(ColorTex, TexCoord).rgb;
float aoVal = texture( AoTex, TexCoord).r;

aoVal = pow(aoVal, 4);
vec3 ambient = Light.La * diff * aoVal;
vec3 s = normalize( vec3(Light.Position) - pos);
float sDotN = max( dot(s,norm), 0.0 );
vec3 col = ambient + Light.L * diff * sDotN;

col = pow(col, vec3(1.0/2.2)); // Gamma

FragColor = vec4(col, 1.0);

它是如何工作的...

在第二次遍历中,我们计算环境遮挡因子。为此,第一步是找到将我们的核转换为相机空间的矩阵。我们希望一个矩阵将核的z轴转换为表面点的法向量,并使用RandTex中的向量应用随机旋转。矩阵的列是定义屏幕空间中切向坐标系统的三个正交归一化向量。由于我们希望核的z轴转换为法向量,所以这三个向量中的第三个就是法向量本身。其他两个(tangbiTang)通过使用叉积来确定。要找到biTang,我们取法向量(n)和从纹理中检索的随机旋转向量(randDir)的叉积。只要这两个向量不平行,这将给我们一个垂直于nrandDir的向量。然而,存在两个向量可能平行的微小可能性。如果是这样,法向量位于相机空间的 x-y 平面内(因为纹理中的所有旋转向量都在 x-y 平面内)。因此,在这种情况下,我们通过取nz轴的叉积来计算biTang。接下来,我们对biTang进行归一化。

注意,我们在访问随机纹理以获取随机旋转向量时缩放纹理坐标。我们这样做是因为纹理的大小小于屏幕大小,我们希望将其平铺以填充屏幕,使得一个纹理元素匹配屏幕像素的大小。

现在我们有了两个正交归一化向量,我们可以通过它们的叉积来计算第三个向量。向量tangbiTangn构成了切向空间坐标系轴。将切向系统转换为相机空间的矩阵(toCamSpace)有这三个向量作为其列。

现在我们有了toCamSpace矩阵,我们可以遍历 64 个核点并测试每个点。请注意,我们实际上并不是将这些点当作点来处理。相反,它们被视为定义从表面点偏移的向量。因此,一个采样点由以下行确定:

vec3 samplePos = camPos + Radius * (toCamSpace * SampleKernel[i]);

在这里,我们从样本核中取一个向量,将其转换为相机空间,按Radius缩放,并将其添加到表面点的位置。缩放因子(Radius)是一个重要的项,它定义了点周围半球的大小。它是一个相机空间值,可能需要根据不同场景进行调整。在示例代码中,使用了0.55的值。

下一步是在样本点的 (x,y) 位置找到可见表面。要做到这一点,我们需要在样本位置查找 g-buffer 中该位置的位置值。我们需要与该位置相对应的纹理坐标。为了找到它,我们首先将点投影到裁剪空间,除以齐次 w 坐标,并缩放/平移到纹理空间。使用该值,我们然后访问位置 g-buffer 并检索该位置表面位置的 z 坐标(surfaceZ)。在这里我们不需要 xy 坐标,因为它们与样本的相同。

现在我们有了投影到样本附近表面上的 z 坐标(sampleZ),我们计算它与 原始 表面点(正在着色的点,pos)的 z 坐标之间的差值(dz)。如果这个值小于零或大于 Radius,那么我们知道在样本位置上的表面投影点在半球之外。在这种情况下,我们假设样本未被遮挡。如果不是这种情况,我们假设投影点在半球内,并比较 z 值。如果 surfaceZ 大于 samplePos.z,我们知道样本点在表面后面。

这可能看起来有些奇怪,但请记住,我们在这里是在相机坐标系下工作。所有的 z 坐标都将为负值。这些不是深度值——它们是相机空间的 z 坐标。

如果我们确定该点是遮挡的,我们将 1.0 添加到 occlusionSum。循环结束后 occlusionSum 中的最终结果将是被遮挡的总点数。由于我们想要的是相反的——未被遮挡的点数比例——我们在写入输出变量 AoData 之前从平均值中减去一个。

以下图像(左侧)显示了这一遍的结果。注意,如果你仔细看,你可以看到一些由于在整个图像中重复使用随机旋转向量而产生的高频网格状伪影。这通过模糊遍历(右侧图像)被平滑处理:

第三遍只是每个纹理素附近九个纹理素的简单平均值。

第四遍应用反射模型。在这个例子中,我们只计算 Blinn-Phong 模型的漫反射和环境分量,通过模糊的环境遮挡值(aoVal)缩放环境项。在这个例子中,它被提升到 4.0 的幂,使其稍微暗一些并增强效果。

以下图像显示了没有环境遮挡(左侧)和有环境遮挡(右侧)的场景渲染效果。为了展示效果,环境项被显著增加:

参见

  • 示例代码中的 chapter06/scenessao.cpp 文件

  • 本章中 使用延迟着色 的配方

配置深度测试

GLSL 4 提供了配置如何执行深度测试的能力。这使我们能够对如何以及何时测试片段与深度缓冲区进行更多控制。

许多 OpenGL 实现自动提供一种称为早期深度测试或早期片段测试的优化。使用这种优化,深度测试在片段着色器执行之前执行。由于深度测试失败的片段不会出现在屏幕(或帧缓冲区)上,因此对这些片段执行片段着色器根本没有必要,我们可以通过避免执行来节省一些时间。

然而,OpenGL 规范指出,深度测试必须看起来是在片段着色器之后执行的*。这意味着如果实现希望使用早期深度测试优化,它必须小心。实现必须确保如果片段着色器中的任何内容可能会改变深度测试的结果,那么它应该避免使用早期深度测试。

例如,一个片段着色器可以通过写入输出变量gl_FragDepth来改变片段的深度。如果这样做,那么早期深度测试将无法执行,因为当然,在片段着色器执行之前,片段的最终深度是未知的。然而,GLSL 提供了通知管道大致如何修改深度的方法,这样实现可以确定何时可能可以使用早期深度测试。

另一种可能性是片段着色器可能会使用discard关键字有条件地丢弃片段。如果存在任何可能丢弃片段的情况,某些实现可能不会执行早期深度测试。

也有某些情况下我们希望依赖早期深度测试。例如,如果片段着色器写入除帧缓冲区以外的内存(使用图像加载/存储、着色器存储缓冲区或其他非一致内存写入),我们可能不希望对深度测试失败的片段执行片段着色器。这将帮助我们避免为失败的片段写入数据。GLSL 提供了一种强制早期深度测试优化的技术。

如何做到这一点...

要请求 OpenGL 管道始终执行早期深度测试优化,请在您的片段着色器中使用以下布局限定符:

layout(early_fragment_tests) in; 

如果你的片段着色器将修改片段的深度,但你还想尽可能利用早期深度测试,请在片段着色器中gl_FragDepth的声明中使用以下布局限定符:

layout (depth_*) out float gl_FragDepth; 

在这里,depth_*是以下之一:depth_anydepth_greaterdepth_lessdepth_unchanged

它是如何工作的...

以下语句强制 OpenGL 实现始终执行早期深度测试:

layout(early_fragment_tests) in; 

我们必须记住,如果我们尝试通过写入gl_FragDepth在着色器中的任何地方修改深度,所写入的值将被忽略。

如果你的片段着色器需要修改深度值,那么我们无法强制执行早期片段测试。然而,我们可以帮助管线确定何时还可以应用早期测试。我们通过使用之前展示的gl_FragDepth的布局限定符来实现这一点。这为值的修改设定了一些限制。然后 OpenGL 实现可以确定是否可以跳过片段着色器。如果可以确定深度不会以改变测试结果的方式改变,实现仍然可以使用优化。

输出变量gl_FragDepth的布局限定符会具体告诉 OpenGL 实现片段着色器内深度可能如何改变。限定符depth_any表示它可能以任何方式改变。这是默认值。

其他限定符描述了值相对于gl_FragCoord.z可能如何改变:

  • depth_greater:这个片段着色器承诺只会增加深度。

  • depth_less:这个片段着色器承诺只会减少深度。

  • depth_unchanged:这个片段着色器承诺不会改变深度。如果它写入gl_FragDepth,值将等于gl_FragCoord.z

如果你使用这些限定符之一,但随后以不兼容的方式修改深度,结果是不确定的。例如,如果你使用depth_greater声明gl_FragDepth,但减少了片段的深度,代码将编译并执行,但你不应期望看到准确的结果。

如果你的片段着色器写入gl_FragDepth,那么它必须确保在所有情况下都写入一个值。换句话说,无论代码中采取哪个分支,它都必须写入一个值。

参见

  • 实现无序透明度的配方

实现无序透明度

在像 OpenGL 这样的管线架构中,准确实现透明度可能是一个困难的效果。一般技术是首先绘制不透明对象,启用深度缓冲区,然后使深度缓冲区只读(使用glDepthMask),禁用深度测试,并绘制透明几何体。然而,必须小心确保透明几何体是从后向前绘制的。也就是说,距离观察者更远的对象应该在距离观察者更近的对象之前绘制。这需要在渲染之前进行某种深度排序。

以下图像展示了一个示例,其中包含一些均匀放置在其中的半透明立方体的小型半透明球体。在右侧,物体以任意顺序渲染,使用标准的 OpenGL 混合。结果看起来不正确,因为物体以不正确的顺序混合。最后绘制的立方体似乎位于球体之上,球体看起来杂乱无章,尤其是在块的中心。在左侧,场景使用正确的顺序绘制,因此物体相对于深度看起来是正确定位的,整体看起来更真实:

图片

顺序无关透明度OIT)意味着我们可以以任何顺序绘制物体,仍然可以得到准确的结果。深度排序在某个其他级别完成,可能在片段着色器内部,这样程序员在渲染之前就不需要排序物体。有各种技术来完成这项工作;其中最常见的技术是保留每个像素的颜色列表,按深度排序,然后在片段着色器中将它们混合在一起。在这个配方中,我们将使用这项技术来实现 OIT,利用 OpenGL 4.3 的一些最新特性。

着色器存储缓冲区对象SSBO)和图像加载/存储是 OpenGL 的一些最新特性,分别于 4.3 和 4.2 版本中引入。它们允许从着色器内部进行任意的读写访问。在此之前,着色器在可访问的数据方面非常有限。它们可以从各种位置读取(纹理、统一变量等),但写入非常有限。着色器只能写入受控的、隔离的位置,例如片段着色器输出和变换反馈缓冲区。这有一个非常好的原因。由于着色器可以并行执行,并且似乎以任意顺序执行,因此很难确保着色器实例之间的数据一致性。一个着色器实例写入的数据可能对另一个着色器实例不可见,无论该实例是否在另一个实例之后执行。尽管如此,仍然有很好的理由想要读取和写入共享位置。随着 SSBO 和图像加载/存储的出现,这种能力现在对我们来说已经可用。我们可以创建具有对任何着色器实例读写访问权限的缓冲区和纹理(称为图像)。这对于计算着色器尤为重要,这是第十一章的主题,使用计算着色器。然而,这种力量是有代价的。程序员现在必须非常小心,以避免伴随写入共享内存的内存一致性错误。此外,程序员必须意识到着色器调用之间同步带来的性能问题。

对于有关内存一致性和着色器问题的更深入讨论,请参阅《OpenGL 编程指南》第 8 版第十一章。该章节还包括 OIT 的另一个类似实现。

在这个示例中,我们将使用 SSBOs(存储在存储器中的缓冲区对象)和图像加载/存储来实现无顺序透明度。我们将使用两次遍历。在第一次遍历中,我们将渲染场景几何形状,并为每个像素存储一个片段的链表。第一次遍历后,每个像素将有一个对应的链表,包含写入该像素的所有片段,包括它们的深度和颜色。在第二次遍历中,我们将绘制一个全屏四边形,以调用每个像素的片段着色器。在片段着色器中,我们将提取该像素的链表,按深度(从大到小)排序片段,并按此顺序混合颜色。最终的颜色将被发送到输出设备。

那就是基本思路,让我们深入细节。我们需要三个在片段着色器实例之间共享的内存对象:

  1. 一个原子计数器:这只是一个无符号整数,我们将用它来跟踪

    我们链表缓冲区的大小。将其视为缓冲区中第一个未使用槽位的索引。

  2. 与屏幕尺寸相对应的头指针纹理:纹理将存储每个 texel 中的单个无符号整数。该值是对应像素链表头部的索引。

  3. 包含所有链表的缓冲区:缓冲区中的每个项目都对应一个片段,包含一个结构体,其中包含片段的颜色和深度,以及一个整数,它是链表中下一个片段的索引。

为了理解所有这些是如何协同工作的,让我们考虑一个简单的例子。假设我们的屏幕宽度为三个像素,高度为三个像素。我们将有一个与屏幕尺寸相同的头指针纹理,并将所有 texels 初始化为表示链表末尾的特殊值(一个空列表)。在以下图中,该值显示为x,但在实践中,我们将使用0xffffffff。计数器的初始值为零,链表缓冲区分配了特定的大小,但最初被视为空。我们的内存初始状态如下所示:

图片

现在假设在位置(0,1)渲染了一个深度为 0.75 的片段。片段着色器将执行以下步骤:

  1. 增加原子计数器。新值将是 1,但我们将使用之前的值(0)作为链表中新节点的索引。

  2. 使用计数器的上一个值(0)更新(0,1)处的头指针纹理。这是该像素链表新头部的索引。保留存储在该处的上一个值(x),因为在下一步中我们需要它。

  3. 在计数器的上一个值对应的位置向链表缓冲区添加一个新值(0)。在这里存储片段的颜色及其深度。在下一个组件中存储我们在步骤 2 中保留的(0,1)处的头指针纹理的先前值。在这种情况下,它是表示列表结束的特殊值。

处理此片段后,内存布局如下:

图片

现在,假设在(0,1)处渲染另一个片段,深度为 0.5。片段着色器将执行与之前相同的步骤,导致以下内存布局:

图片

现在,我们有一个从索引 1 开始到索引 0 结束的两个元素链表。假设现在我们有三个更多的片段,顺序如下:一个在(1,1)处的片段,深度为 0.2,一个在(0,1)处的片段,深度为 0.3,以及一个在(1,1)处的片段,深度为 0.4。对每个片段执行相同的步骤,我们得到以下结果:

图片

在(0,1)处的链表由片段{3, 1, 0}组成,而在(1,1)处的链表包含片段{4, 2}。

现在,我们必须记住,由于 GPU 高度并行的特性,片段可以以几乎任何顺序渲染。例如,来自两个不同多边形的片段可能以与多边形绘制指令发出时相反的顺序通过管道。作为程序员,我们不应期望片段有任何特定的顺序。实际上,来自片段着色器不同实例的指令可能会以任意方式交织。我们唯一可以确定的是,着色器特定实例中的语句将按顺序执行。因此,我们需要确信任何交织的前三个步骤仍然会导致一致的状态。例如,假设实例一执行步骤 1 和 2,然后另一个实例(另一个片段,可能在相同的片段坐标)在第一个实例执行步骤 3 之前执行步骤 1、2 和 3。结果仍然一致吗?我想你可以自己说服自己,即使在这个过程中链表会在短时间内被破坏。尝试通过其他交织并说服自己我们没问题。

不仅着色器不同实例中的语句可以相互交织,构成语句的子指令也可以交织。例如,增量操作的子指令包括加载、增加和存储。更重要的是,它们实际上可以同时执行。因此,如果我们不小心,可能会出现讨厌的内存一致性问题。为了避免这种情况,我们需要仔细使用 GLSL 对原子操作的支持。

近期版本的 OpenGL(4.2 和 4.3)引入了使这个算法成为可能所需的工具。OpenGL 4.2 引入了原子计数器和在纹理内部任意位置读写的能力(称为图像加载/存储)。OpenGL 4.3 引入了着色器存储缓冲区对象。在这个例子中,我们将使用这三个功能,以及与之相关的各种原子操作和内存屏障。

准备工作

这里需要做很多设置,所以我们将通过一些代码片段进行一些详细说明。首先,我们将为我们的原子计数器设置一个缓冲区:

GLuint counterBuffer;  
glGenBuffers(1, &counterBuffer); 
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counterBuffer); 
glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), NULL, 
       GL_DYNAMIC_DRAW); 

接下来,我们将创建一个用于链表存储的缓冲区:

GLuint llBuf; 
glGenBuffers(1, &llBuf); 
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, llBuf); 
glBufferData(GL_SHADER_STORAGE_BUFFER, maxNodes * nodeSize, NULL, 
       GL_DYNAMIC_DRAW); 

之前代码中的nodeSize是片段着色器中使用的struct NodeType的大小(在代码的后半部分)。这是基于std430布局计算的。有关std430布局的详细信息,请参阅 OpenGL 规范文档。对于这个例子,nodeSize5 * sizeof(GLfloat) + sizeof(GLuint)

我们还需要创建一个用于存储列表头部指针的纹理。我们将使用 32 位无符号整数,并将其绑定到图像单元 0:

glGenTextures(1, &headPtrTex); 
glBindTexture(GL_TEXTURE_2D, headPtrTex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R32UI, width, height); 
glBindImageTexture(0, headPtrTex, 0, GL_FALSE, 0, GL_READ_WRITE, 
          GL_R32UI); 

在我们渲染每一帧之后,我们需要通过将所有 texels 设置为值0xffffffff来清除纹理。为了帮助完成这个任务,我们将创建一个与纹理大小相同的缓冲区,并将每个值设置为我们的清除值:

vector<GLuint> headPtrClear(width * height, 0xffffffff); 
GLuint clearBuf; 
glGenBuffers(1, &clearBuf); 
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, clearBuf); 
glBufferData(GL_PIXEL_UNPACK_BUFFER, 
       headPtrClear.size()*sizeof(GLuint), 
       &headPtrClear[0], GL_STATIC_COPY); 

那就是我们所需要的所有缓冲区。请注意,我们已经将头部指针纹理绑定到图像单元 0,将原子计数器缓冲区绑定到GL_ATOMIC_COUNTER_BUFFER绑定点的索引 0(glBindBufferBase),以及将链表存储缓冲区绑定到GL_SHADER_STORAGE_BUFFER绑定点的索引 0。我们稍后会回过来提到这一点。使用一个传递型顶点着色器,将位置和法线以眼坐标的形式发送出去。

如何做到这一点...

在所有缓冲区设置完毕后,我们需要两个渲染遍历。在第一次遍历之前,我们希望将我们的缓冲区清空到默认值(即空列表),并将我们的原子计数器缓冲区重置为零:

glBindBuffer(GL_PIXEL_UNPACK_BUFFER, clearBuf); 
glBindTexture(GL_TEXTURE_2D, headPtrTex); 
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, 
        GL_RED_INTEGER, GL_UNSIGNED_INT, NULL); 
GLuint zero = 0; 
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counterBuffer); 
glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), &zero);

在第一次遍历中,我们将渲染整个场景几何体。通常,我们应该首先渲染所有不透明几何体并将结果存储在纹理中。然而,为了保持示例简单和专注,我们将跳过这一步。相反,我们只渲染透明几何体。在渲染透明几何体时,我们需要确保将深度缓冲区设置为只读模式(使用glDepthMask)。在片段着色器中,我们将每个片段添加到适当的链表中:

layout (early_fragment_tests) in; 

#define MAX_FRAGMENTS 75 

in vec3 Position; 
in vec3 Normal; 

struct NodeType { 
 vec4 color; 
 float depth; 
 uint next; 
}; 

layout(binding=0, r32ui) uniform uimage2D headPointers; 
layout(binding=0, offset=0) uniform atomic_uint 
                     nextNodeCounter; 
layout(binding=0, std430) buffer linkedLists { 
 NodeType nodes[]; 
}; 
uniform uint MaxNodes; 

subroutine void RenderPassType(); 
subroutine uniform RenderPassType RenderPass; 

... 

subroutine(RenderPassType) 
void pass1() 
{ 
 // Get the index of the next empty slot in the buffer 
 uint nodeIdx = atomicCounterIncrement(nextNodeCounter); 

 // Is there space left in the buffer? 
 if( nodeIdx < MaxNodes ) { 
  // Update the head pointer image 
  uint prevHead = imageAtomicExchange(headPointers, 
              ivec2(gl_FragCoord.xy), nodeIdx); 

  // Set the color and depth of this new node to the color 
  // and depth of the fragment. The next pointer points to the 
  // previous head of the list. 
  nodes[nodeIdx].color = vec4(shadeFragment(), Kd.a); 
  nodes[nodeIdx].depth = gl_FragCoord.z; 
  nodes[nodeIdx].next = prevHead; 
 } 
} 

在渲染第二次遍历之前,我们需要确保所有数据都已写入我们的缓冲区。为了确保这一点,我们可以使用内存屏障:

glMemoryBarrier( GL_ALL_BARRIER_BITS ); 

在第二次遍历中,我们不渲染场景几何体,只渲染一个填充整个屏幕的四边形,以便为每个屏幕像素调用片段着色器。在片段着色器中,我们首先将片段的链表复制到一个临时数组中:

struct NodeType frags[MAX_FRAGMENTS]; 
int count = 0; 

// Get the index of the head of the list 
uint n = imageLoad(headPointers, ivec2(gl_FragCoord.xy)).r; 

// Copy the linked list for this fragment into an array 
while( n != 0xffffffff && count < MAX_FRAGMENTS) { 
 frags[count] = nodes[n]; 
 n = frags[count].next; 
 count++; 
} 

然后,我们使用插入排序对片段进行排序:

// Sort the array by depth (largest to smallest). 
for( uint i = 1; i < count; i++ ) 
{ 
 struct NodeType toInsert = frags[i]; 
 uint j = i; 
 while( j > 0 && toInsert.depth > frags[j-1].depth ) { 
  frags[j] = frags[j-1]; 
  j--; 
 } 
 frags[j] = toInsert; 
}

最后,我们手动合并片段,并将结果发送到输出变量:

// Traverse the array, and blend the colors. 
vec4 color = vec4(0.5, 0.5, 0.5, 1.0); // Background color 
for( int i = 0; i < count; i++ ) { 
 color = mix( color, frags[i].color, frags[i].color.a); 
} 

// Output the final color 
FragColor = color; 

它是如何工作的...

在第一遍之前清除我们的缓冲区,我们将 clearBuf 绑定到 GL_PIXEL_UNPACK_BUFFER 绑定点,并调用 glTexSubImage2D 将数据从 clearBuf 复制到头部指针纹理。请注意,当将非零缓冲区绑定到 GL_PIXEL_UNPACK_BUFFER 时,glTexSubImage2D 将最后一个参数视为绑定到那里的缓冲区中的偏移量。因此,这将从 clearBuf 开始复制到 headPtrTex。清除原子计数器很简单,但使用 glBindBufferBase 可能会有些令人困惑。如果有多个缓冲区绑定到绑定点(在不同的索引处),glBufferSubData 如何知道要针对哪个缓冲区?实际上,当我们使用 glBindBufferBase 绑定缓冲区时,它也被绑定到通用绑定点。

在第一遍的片段着色器中,我们开始使用布局规范启用早期片段测试优化:

layout (early_fragment_tests) in; 

这很重要,因为如果任何片段被不透明的几何形状遮挡,我们不希望将它们添加到链表中。如果早期片段测试优化未启用,片段着色器可能会为将失败深度测试的片段执行,因此这些片段将被添加到链表中。前面的语句确保片段着色器不会为这些片段执行。

struct NodeType 的定义指定了存储在我们链表缓冲区中的数据类型。我们需要存储颜色、深度以及指向链表下一个节点的指针。

接下来的三个语句声明了与我们的链表存储相关的对象。

  1. 第一个,headPointers,是存储每个链表头部位置的图像对象。布局限定符表示它位于图像单元 0(参考本食谱的 准备就绪 部分),数据类型为 r32ui(红色,32 位无符号整数)。

  2. 第二个对象是我们的原子计数器 nextNodeCounter。布局限定符表示在 GL_ATOMIC_COUTER_BUFFER 绑定点内的索引(参考本食谱的 准备就绪 部分)以及在该位置缓冲区内的偏移量。由于我们缓冲区中只有一个值,偏移量为 0,但在一般情况下,你可能有多个原子计数器位于单个缓冲区中。

  3. 第三是我们的链表存储缓冲区 linkedLists。这是一个着色器存储缓冲区对象。对象内部数据的组织方式在这里用大括号定义。在这种情况下,我们只有一个 NodeType 结构体的数组。数组的边界可以留空,其大小由我们创建的底层缓冲区对象限制。布局限定符定义了绑定和内存布局。第一个,绑定,表示缓冲区位于 GL_SHADER_STORAGE_BUFFER 绑定点内的索引 0。第二个,std430,表示缓冲区内部内存的组织方式。这在我们要从 OpenGL 端读取数据时尤为重要。如前所述,这已在 OpenGL 规范文档中记录。

在第一次遍历的片段着色器中的第一步是使用 atomicCounterIncrement 增加我们的原子计数器。这将以这种方式增加计数器,如果另一个着色器实例同时尝试增加计数器,则不存在内存一致性问题的可能性。

原子操作是一种与其他线程隔离的操作,可以被视为一个单一且不可中断的操作。其他线程不能与原子操作交织。在向着色器中的共享数据写入时,始终使用原子操作是一个好主意。

atomicCounterIncrement 的返回值是计数器的先前值。它是我们链表缓冲区中的下一个未使用位置。我们将使用此值作为存储此片段的位置,因此我们将其存储在一个名为 nodeIdx 的变量中。它也将成为链表的新头节点,因此下一步是更新 headPointers 图像在此像素位置 gl_FragCoord.xy 的值。我们通过另一个原子操作 imageAtomicExchange 来这样做。这会将第二个参数指定的图像位置中的值替换为第三个参数的值。返回值是图像在该位置的先前值。这是我们的链表先前头节点。我们保留这个值在 prevHead 中,因为我们想将新头节点链接到那个节点,从而恢复链表与我们的新头部节点的一致性。

最后,我们使用片段的颜色和深度更新 nodeIdx 处的节点,并将 next 值设置为列表的先前头节点 (prevHead)。这完成了将此片段插入链表头部的操作。

在第一次遍历完成后,在继续之前,我们需要确保所有更改都已写入我们的着色器存储缓冲区和图像对象。唯一保证这一点的办法是使用内存屏障。对glMemoryBarrier的调用将为我们处理这个问题。glMemoryBarrier的参数是屏障的类型。我们可以微调屏障的类型,以特别针对我们想要读取的数据类型。但是,为了安全起见,以及为了简单起见,我们将使用GL_ALL_BARRIER_BITS,这确保了所有可能的数据都已写入。

在第二次遍历中,我们首先将片段的链接列表复制到一个临时数组中。我们首先使用imageLoadheadPointers图像中获取列表的头部位置。然后我们使用while循环遍历链接列表,将数据复制到array数组frags中。

接下来,我们使用插入排序算法按深度从大到小对数组进行排序。插入排序在小数组上表现良好,因此应该是一个相当高效的选择。

最后,我们按顺序组合所有片段,使用mix函数根据 alpha 通道的值将它们混合在一起。最终结果存储在输出变量FragColor中。

还有更多...

如前所述,我们跳过了所有涉及不透明几何的内容。一般来说,人们可能希望首先渲染任何不透明几何,启用深度缓冲区,并将渲染的片段存储在纹理中。然后,在渲染透明几何时,将禁用写入深度缓冲区,并构建之前显示的链接列表。最后,你可以在混合链接列表时使用不透明纹理的值作为背景颜色。

这是本书中第一个利用从着色器读取和写入任意(共享)存储的例子。这种能力为我们提供了更多的灵活性,但这也带来了代价。如前所述,我们必须非常小心以避免内存一致性和一致性问题的出现。为此,我们可以使用原子操作和内存屏障,而此例只是触及了表面。在第十一章,使用计算着色器中,当我们查看计算着色器时,还有更多内容要介绍。我建议您阅读OpenGL 编程指南中的内存章节,以获取比这里提供更多的详细信息。

参考资料还包括

  • 示例代码中的chapter06/sceneoit.cpp文件

  • 第十一章,使用计算着色器

  • OpenGL 开发食谱由 Muhammad Mobeen Movania 编写,在第六章基于 GPU 的 Alpha 混合和全局照明中有几个食谱。

第七章:使用几何和镶嵌着色器

本章我们将涵盖:

  • 使用几何着色器的点精灵

  • 在着色网格上绘制线框

  • 使用几何着色器绘制轮廓线

  • 镶嵌曲线

  • 镶嵌二维四边形

  • 镶嵌三维表面

  • 根据深度进行镶嵌

简介

镶嵌和几何着色器为程序员提供了在着色器管道中修改几何形状的额外方式。几何着色器可以以非常精确和用户可控的方式添加、修改或删除几何形状。镶嵌着色器也可以配置为自动以不同程度(细节级别)细分几何形状,通过 GPU 可能创建出极其密集的几何形状。

在本章中,我们将探讨在不同情境下几何和镶嵌着色器的几个示例。然而,在我们深入到食谱之前,让我们先研究一下所有这些是如何相互关联的。

着色器管道扩展

以下图展示了当着色器程序包含几何和镶嵌着色器时的着色器管道的简化视图:

图片

着色器管道的镶嵌部分包括两个阶段:镶嵌控制着色器TCS)和镶嵌评估着色器TES)。几何着色器位于镶嵌阶段之后,片段着色器之前。镶嵌着色器和几何着色器是可选的;然而,当着色器程序包含镶嵌或几何着色器时,必须包括顶点着色器。

除了顶点着色器之外的所有着色器都是可选的。当使用几何着色器时,没有要求你必须包括镶嵌着色器,反之亦然。

几何着色器

几何着色器GS)设计为对每个原语执行一次。它能够访问原语的所有顶点,以及与每个顶点相关联的任何输入变量的值。换句话说,如果前一个阶段(如顶点着色器)提供了一个输出变量,几何着色器可以访问原语中所有顶点的该变量的值。因此,几何着色器内的输入变量始终是数组。

几何着色器可以输出零个、一个或多个原语。这些原语不必与几何着色器接收到的原语种类相同。

然而,GS 只能输出一种原语类型。例如,一个 GS 可以接收一个三角形,并输出多个线段作为线带,或者 GS 可以接收一个三角形,并输出零个或多个三角形作为三角形带。

这使得 GS 能够以许多不同的方式行动。GS 可能负责基于某些标准(如基于遮挡的可见性)裁剪(移除)几何形状。它可能生成额外的几何形状以增强正在渲染的对象的形状。GS 可以简单地计算有关原语的一些额外信息并将原语传递不变,或者 GS 可以生成与输入几何形状完全不同的原语。

GS 的功能集中在两个内置函数上:EmitVertexEndPrimitive。这两个函数允许 GS 将多个顶点和原语发送到管道。GS 定义特定顶点的输出变量,然后调用EmitVertex。之后,GS 可以继续重新定义下一个顶点的输出变量,再次调用EmitVertex,依此类推。在发出原语的所有顶点后,GS 可以调用EndPrimitive来让 OpenGL 系统知道原语的所有顶点都已发出。当 GS 完成执行时,EndPrimitive函数会隐式调用。如果 GS 根本不调用EmitVertex,则输入原语实际上被丢弃(它不会被渲染)。

在以下食谱中,我们将检查几个几何着色器的示例。在使用几何着色器的点精灵食谱中,我们将看到一个输入原语类型与输出类型完全不同的例子。在在着色网格上绘制线框食谱中,我们将传递未改变的几何形状,但也会产生一些有关原语的信息,以帮助绘制线框线。在使用几何着色器绘制轮廓线食谱中,我们将看到一个 GS 传递输入原语,但同时也生成额外的原语。

纹理填充着色器

当纹理填充着色器处于活动状态时,我们只能渲染一种原语:补丁 (GL_PATCHES)。在纹理填充着色器活动时渲染任何其他类型的原语(如三角形或线条)是错误的。补丁原语是一个任意的几何形状(或任何信息),它完全由程序员定义。它除了在 TCS 和 TES 中的解释之外,没有其他几何解释。补丁原语内的顶点数也是可配置的。每个补丁的最大顶点数取决于实现,可以通过以下命令查询:

glGetIntegerv(GL_MAX_PATCH_VERTICES, &maxVerts);

我们可以使用以下函数定义每个补丁的顶点数:

glPatchParameteri( GL_PATCH_VERTICES, numPatchVerts ); 

这种应用非常常见的情况是,当补丁原语由一组控制点组成,这些控制点定义了一个插值表面或曲线(如贝塞尔曲线或表面)。然而,没有理由说明补丁原语内的信息不能用于其他目的。

补丁原语实际上永远不会被渲染;相反,它被用作 TCS 和 TES 的附加信息。真正进入管道进一步处理的原语是由镶嵌原语生成器TPG)创建的,它位于 TCS 和 TES 之间。将镶嵌原语生成器想象为一个可配置的引擎,它根据一组标准镶嵌算法生成原语。TCS 和 TES 可以访问整个输入补丁,但它们有根本不同的职责。TCS 负责:

  • 设置 TPG

  • 定义 TPG 如何生成原语(生成多少以及使用什么算法)

  • 生成每个顶点的输出属性。

TES 负责确定由 TPG 生成的原语中每个顶点的位置(以及任何其他信息)。例如,TCS 可能会告诉 TPG 生成由 100 个线段组成的线段条,TES 负责确定这 100 个线段中每个顶点的位置。TES 可能会利用整个补丁原语中的信息来完成这项工作。

TCS 在每个补丁的每个顶点处执行一次,但可以访问其相关补丁的所有顶点。它可以计算有关补丁的附加信息,并通过输出变量将其传递给 TES。然而,TCS 最重要的任务是告诉 TPG 应该生成多少原语。它通过定义通过gl_TessLevelInnergl_TessLevelOuter数组进行镶嵌级别的设置来完成此操作。这些数组定义了 TPG 生成的镶嵌的粒度。

TPG 根据特定的算法(四边形、等值线或三角形)生成原语。每种算法以略微不同的方式生成原语,我们将在本章的食谱中看到等值线和四边形的示例。生成的原语每个顶点都与参数空间中的一个位置(u, v, w)相关联。这个位置的这个坐标是一个可以从零到一的数字。这个坐标可以用来评估顶点的位置,通常是通过插值补丁原语顶点来完成。

原语生成算法以略微不同的方式生成顶点(以及相关的参数坐标)。四边形和等值线的镶嵌算法仅使用前两个参数坐标:uv。以下图示说明了由四个顶点组成的输入和输出补丁的过程。在图中,TPG 使用四边形镶嵌算法,内嵌和外嵌镶嵌级别设置为四:

图片

输入补丁中的顶点数不必与输出补丁中的顶点数相同,尽管在本章的所有示例中情况都是如此。

TES(镶嵌着色器)对于 TPG 生成的每个参数空间顶点执行一次。有些奇怪的是,TES 实际上是定义 TPG 使用的算法的着色器。它是通过其输入布局限定符来做到这一点的。如前所述,其主要责任是确定顶点的位置(可能还有其他信息,如法向量纹理坐标)。通常,TES 使用 TPG 提供的参数坐标(u,v)以及所有输入补丁顶点的位置来完成这一任务。例如,当绘制曲线时,补丁可能由四个顶点组成,这些顶点是曲线的控制点。然后 TPG 会生成 101 个顶点来创建一个线带(如果镶嵌级别设置为 100),每个顶点可能有一个在零和一之间适当范围的 u 坐标。然后 TES 将使用那个 u 坐标以及四个补丁顶点的位置来确定与着色器执行相关的顶点的位置。

如果这一切看起来很复杂,可以从 曲线镶嵌 菜单开始,然后逐步学习以下菜单。

曲线镶嵌 菜单中,我们将通过一个基本示例来了解如何使用镶嵌着色器绘制具有四个控制点的贝塞尔曲线。在 2D 四边形镶嵌 菜单中,我们将通过渲染一个简单的四边形并可视化由 TPG 产生的三角形来理解四边形镶嵌算法的工作原理。在 3D 表面镶嵌 菜单中,我们将使用四边形镶嵌来渲染 3D 贝塞尔表面。最后,在 基于深度的镶嵌 菜单中,我们将看到镶嵌着色器如何使实现细节级别LOD)算法变得容易。

使用几何着色器的点精灵

点精灵是简单的四边形(通常是纹理映射的),它们被对齐,使得它们始终面向相机。它们在 3D(参考第九章,在着色器中使用噪声)或 2D 游戏中对粒子系统非常有用。点精灵由 OpenGL 应用程序指定为单点原语,通过GL_POINTS渲染模式。这简化了过程,因为四边形本身及其纹理坐标是自动确定的。应用程序的 OpenGL 端可以有效地将它们视为点原语,避免计算四边形顶点的位置。

以下图像显示了一组点精灵。每个精灵都被渲染为一个点原语。四边形和纹理坐标在几何着色器中自动生成并面向相机:

图片

OpenGL 已经在其GL_POINTS渲染模式中内置了对点精灵的支持。当使用此模式渲染点原语时,点被渲染为屏幕空间正方形,其直径(边长)由glPointSize函数定义。此外,OpenGL 将为正方形的片段自动生成纹理坐标。这些坐标在每个方向(水平和垂直)上从零到一,可以通过片段着色器中的gl_PointCoord内置变量访问。

有多种方法可以微调 OpenGL 中点精灵的渲染。可以使用glPointParameter函数定义自动生成的纹理坐标的原点。同一组函数也可以用来调整 OpenGL 在启用多采样时定义点 alpha 值的方式。

内置的点精灵支持不允许程序员旋转屏幕空间正方形,或定义它们为不同的形状,例如矩形或三角形。然而,可以通过创造性地使用纹理和纹理坐标的变换来实现类似的效果。例如,我们可以使用旋转矩阵变换纹理坐标,以创建旋转物体的外观,尽管几何形状本身并没有实际旋转。此外,点精灵的大小是屏幕空间大小。换句话说,如果我们要获得透视效果(精灵随着距离的增加而变小),点的大小必须与点精灵的深度调整一致。

如果这些(以及可能的其他)问题使得默认的点精灵支持过于受限,我们可以使用几何着色器来生成我们的点精灵。实际上,这种技术是使用几何着色器生成不同于接收到的不同类型原语的一个很好的例子。这里的基本思想是,几何着色器将接收点原语(在相机坐标系中)并将输出一个以点为中心且面向相机的四边形。几何着色器还将自动为四边形生成纹理坐标。

如果需要,我们可以生成其他形状,例如六边形,或者我们可以在它们从几何着色器输出之前旋转四边形。可能性是无限的。

在直接进入代码之前,让我们先看看一些数学知识。在几何着色器中,我们需要生成一个以点为中心且与相机坐标系(眼睛坐标系)对齐的四边形的顶点。

给定相机坐标系中的点位置(P),我们可以通过简单地沿与相机坐标系 x-y 平面平行的平面平移P来生成四边形的顶点,如图所示:

图片

几何着色器将接收点位置(相机坐标),并以带有纹理坐标的三角形带形式输出四边形。然后片段着色器将仅将纹理应用到四边形上。

准备工作

对于这个例子,我们需要渲染多个点原语。位置可以通过属性位置0发送。对于这个例子,不需要提供法线向量或纹理坐标。

在着色器中定义了以下统一变量,并在 OpenGL 程序中需要设置:

  • Size2:这应该是精灵正方形宽度的一半

  • SpriteTex:这是包含点精灵纹理的纹理单元

如同往常,标准变换矩阵的统一变量也在着色器中定义,并在 OpenGL 程序中需要设置。

如何实现...

要创建一个可以用来将点原语渲染为四边形的着色器程序,请按照以下步骤操作:

  1. 顶点着色器将位置转换为相机坐标并分配给输出变量gl_Position。请注意,我们还没有将其转换为裁剪坐标:
layout (location = 0) in vec3 VertexPosition;
uniform mat4 ModelViewMatrix;

void main(){
    gl_Position = ModelViewMatrix * vec4(VertexPosition,1.0);
}
  1. 几何着色器以三角形带的形式发出两个三角形。我们使用gl_in变量从顶点着色器(相机坐标)访问位置:
layout( points ) in; 
layout( triangle_strip, max_vertices = 4 ) out; 

uniform float Size2;   // Half the width of the quad 

uniform mat4 ProjectionMatrix; 

out vec2 TexCoord; 

void main() {
    mat4 m = ProjectionMatrix;  // Reassign for brevity 

    gl_Position = m * (vec4(-Size2,-Size2,0.0,0.0) +  
                       gl_in[0].gl_Position); 
    TexCoord = vec2(0.0,0.0); 
    EmitVertex(); 

    gl_Position = m * (vec4(Size2,-Size2,0.0,0.0) +  
                       gl_in[0].gl_Position); 
    TexCoord = vec2(1.0,0.0); 
    EmitVertex(); 

    gl_Position = m * (vec4(-Size2,Size2,0.0,0.0) +  
                       gl_in[0].gl_Position); 
    TexCoord = vec2(0.0,1.0); 
    EmitVertex(); 

    gl_Position = m * (vec4(Size2,Size2,0.0,0.0) +  
                       gl_in[0].gl_Position); 
    TexCoord = vec2(1.0,1.0); 
    EmitVertex(); 

    EndPrimitive(); 
} 
  1. 片段着色器应用纹理:
in vec2 TexCoord;  // From the geometry shader 

uniform sampler2D SpriteTex; 

layout( location = 0 ) out vec4 FragColor; 

void main() {
    FragColor = texture(SpriteTex, TexCoord); 
} 
  1. 在 OpenGL 渲染函数中,渲染一组点原语。

它是如何工作的...

顶点着色器几乎是最简单的。它通过乘以模型视图矩阵将点的位置转换为相机坐标,并将结果分配给内置输出变量gl_Position

在几何着色器中,我们首先定义这个几何着色器期望接收的原语类型。第一个布局语句指示这个几何着色器将接收点原语:

layout( points ) in; 

下一个布局语句指示了由这个几何着色器产生的原语类型,以及将输出的最大顶点数:

layout( triangle_strip, max_vertices = 4 ) out; 

在这种情况下,我们希望为每个接收到的点生成一个四边形,因此我们指出输出将是一个最多包含四个顶点的三角形带。

输入原语通过内置输入变量gl_in可用给几何着色器。请注意,它是一个结构体的数组。你可能想知道为什么这是一个数组,因为点原语仅由一个位置定义。

嗯,一般来说,几何着色器可以接收三角形、线或点(以及可能的相邻信息)。因此,可用的值数量可能不止一个。如果输入是三角形,几何着色器将能够访问三个输入值(与每个顶点相关联)。实际上,当使用triangles_adjacency时,它可以访问多达六个值(关于这一点将在后面的菜谱中详细介绍)。

gl_in变量是一个结构体数组。每个结构体包含以下字段:gl_Positiongl_PointSizegl_ClipDistance[]。在这个例子中,我们只对gl_Position感兴趣。然而,其他字段可以在顶点着色器中设置,以向几何着色器提供额外信息。

在几何着色器的main函数中,我们以下这种方式生成四边形(作为一个三角形带)。对于三角形带的每个顶点,我们执行以下步骤:

  1. 计算顶点的属性(在这种情况下是位置和纹理坐标),并将它们的值分配给适当的输出变量(gl_PositionTexCoord)。请注意,位置也由投影矩阵变换。我们这样做是因为gl_Position变量必须以裁剪坐标的形式提供给管道的后续阶段。

  2. 通过调用内置的EmitVertex()函数来发射顶点(将其发送到管道)。

一旦我们为输出原语发射了所有顶点,我们就调用EndPrimitive()来最终化原语并将其发送出去。

在这种情况下,严格来说没有必要调用EndPrimitive(),因为当几何着色器完成后会隐式调用它。然而,就像关闭文件一样,这是一个好的实践。

片段着色器也非常简单。它只是使用几何着色器提供的(插值)纹理坐标将纹理应用到片段上。

还有更多...

这个例子相当直接,旨在作为几何着色器的一个温和的介绍。我们可以通过允许四边形旋转或以不同的方向定位来扩展这个例子。我们还可以使用纹理在片段着色器中丢弃片段,以创建任意形状的点精灵。几何着色器的力量为提供了大量的可能性!

参见

  • 示例代码中的chapter07/scenepointsprite.cpp文件

在着色网格上绘制线框

之前的方法展示了如何使用几何着色器产生与接收到的原语不同的原语种类。几何着色器也可以用来向后续阶段提供额外信息。它们非常适合这样做,因为它们可以一次性访问原语的所有顶点,并且可以根据整个原语而不是单个顶点进行计算。

这个例子涉及一个不修改三角形的几何着色器。它基本上以不变的方式传递原语。然而,它计算了三角形的一些额外信息,这些信息将被片段着色器用来突出显示多边形的边缘。这里的基想法是直接在着色网格上绘制每个多边形的边缘。

以下图像展示了这种技术的示例。通过在几何着色器内部计算的信息,网格边在着色表面之上被绘制出来:

图片

在阴影表面之上生成线框结构有许多技术。这项技术源自于 2007 年 NVIDIA 发布的一篇白皮书。我们利用几何着色器在一次遍历中生成线框和阴影表面。我们还对生成的网格线提供了一些简单的抗锯齿处理,效果相当不错(参考前面的图像)。

要在阴影网格上渲染线框,我们将计算每个片段到最近三角形边缘的距离。当片段距离边缘一定距离内时,它将被着色并与边缘颜色混合。否则,片段将正常着色。

要计算片段到边缘的距离,我们使用以下技术。在几何着色器中,我们计算每个顶点到对边(也称为 三角形高度)的最小距离。在以下图中,所需的距离是 hahbhc

图片

我们可以使用三角形的内角来计算这些高度,这些内角可以通过余弦定理确定。例如,要找到 ha,我们使用顶点 C 处的内角(β):

图片

其他高度可以以类似的方式计算。(注意,β 可能大于 90 度,在这种情况下,我们希望得到 180-β的正弦值。然而,180-β的正弦值与β的正弦值相同。)一旦我们计算了这些三角形高度,我们就可以在几何着色器中创建一个输出向量(一个 边距 向量)以在三角形上插值。这个向量的分量代表从片段到三角形每个边缘的距离。

x 分量表示从边缘 a 的距离,y 分量是距离边缘 b 的距离,而 z 分量是距离边缘 c 的距离。如果我们在这三个顶点处为这些分量分配正确的值,硬件将自动为我们插值它们,以在每个片段处提供适当的距离。在顶点 A 处,这个向量的值应该是(ha,0,0),因为顶点 A 距离边缘 aha,并且直接位于边缘 bc 上。同样,顶点 B 的值是(0,hb,0),顶点 C 的值是(0,0,hc)。当这三个值在三角形上插值时,我们应该得到从片段到三个边缘的距离。我们将在屏幕空间中计算所有这些。也就是说,在计算高度之前,我们将在几何着色器中将顶点转换到屏幕空间。

由于我们在屏幕空间中工作,没有必要(并且这是不正确的)以透视正确的方式插值值。因此,我们需要小心地告诉硬件以线性方式插值。在片段着色器中,我们只需要找到三个距离中的最小值,如果这个距离小于线宽,我们就将片段颜色与线条颜色混合。然而,我们还想在这个过程中应用一点抗锯齿。为此,我们将使用 GLSL 的smoothstep函数来淡出线条的边缘。我们将在线条边缘的两像素范围内缩放线条的强度。距离真实边缘一像素或更近的像素将获得 100%的线条颜色,而距离边缘一像素或更远的像素将获得 0%的线条颜色。在两者之间,我们将使用smoothstep函数创建平滑过渡。当然,线条的边缘本身是一个可配置的距离(我们将称之为Line.Width),从多边形的边缘开始。

准备工作

对于此示例,需要典型的设置。顶点位置和法线应分别提供在属性零和一,你需要为你的着色模型提供适当的参数。像往常一样,标准矩阵被定义为统一变量,应在 OpenGL 应用程序中设置。然而,请注意,这次我们还需要视口矩阵(ViewportMatrix统一变量)以便将其转换为屏幕空间。有一些与网格线相关的统一变量需要设置:

  • Line.Width: 这应该是网格线的宽度的一半

  • Line.Color: 这是网格线的颜色

如何操作...

要创建一个利用几何着色器在着色表面之上生成线框的着色器程序,请按照以下步骤操作:

  1. 使用以下代码作为顶点着色器:
layout (location = 0 ) in vec3 VertexPosition; 
layout (location = 1 ) in vec3 VertexNormal; 

out vec3 VNormal; 
out vec3 VPosition; 

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP; 

void main() {
    VNormal = normalize( NormalMatrix * VertexNormal); 
    VPosition = vec3(ModelViewMatrix *  
                     vec4(VertexPosition,1.0)); 
    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 使用以下代码作为几何着色器:
layout( triangles ) in; 
layout( triangle_strip, max_vertices = 3 ) out; 

out vec3 GNormal; 
out vec3 GPosition; 
noperspective out vec3 GEdgeDistance; 

in vec3 VNormal[]; 
in vec3 VPosition[]; 

uniform mat4 ViewportMatrix;  // Viewport matrix 

void main() {
    // Transform each vertex into viewport space 
    vec3 p0 = vec3(ViewportMatrix * (gl_in[0].gl_Position /  
                             gl_in[0].gl_Position.w)); 
    vec3 p1 = vec3(ViewportMatrix * (gl_in[1].gl_Position /  
                             gl_in[1].gl_Position.w)); 
    vec3 p2 = vec3(ViewportMatrix * (gl_in[2].gl_Position /  
                            gl_in[2].gl_Position.w)); 

    // Find the altitudes (ha, hb and hc) 
    float a = length(p1 - p2); 
    float b = length(p2 - p0); 
    float c = length(p1 - p0); 
    float alpha = acos( (b*b + c*c - a*a) / (2.0*b*c) ); 
    float beta = acos( (a*a + c*c - b*b) / (2.0*a*c) ); 
    float ha = abs( c * sin( beta ) ); 
    float hb = abs( c * sin( alpha ) ); 
    float hc = abs( b * sin( alpha ) ); 

    // Send the triangle along with the edge distances 
    GEdgeDistance = vec3( ha, 0, 0 ); 
    GNormal = VNormal[0]; 
    GPosition = VPosition[0]; 
    gl_Position = gl_in[0].gl_Position; 
    EmitVertex(); 

    GEdgeDistance = vec3( 0, hb, 0 ); 
    GNormal = VNormal[1]; 
    GPosition = VPosition[1]; 
    gl_Position = gl_in[1].gl_Position; 
    EmitVertex(); 

    GEdgeDistance = vec3( 0, 0, hc ); 
    GNormal = VNormal[2]; 
    GPosition = VPosition[2]; 
    gl_Position = gl_in[2].gl_Position; 
    EmitVertex(); 

    EndPrimitive(); 
} 
  1. 使用以下代码作为片段着色器:
// *** Insert appropriate uniforms for the Blinn-Phong model *** 

// The mesh line settings 
uniform struct LineInfo { 
  float Width; 
  vec4 Color; 
} Line; 

in vec3 GPosition; 
in vec3 GNormal; 
noperspective in vec3 GEdgeDistance; 

layout( location = 0 ) out vec4 FragColor; 
vec3 blinnPhong( vec3 pos, vec3 norm ) {
   // ...
}

void main() { 
    // The shaded surface color. 
    vec4 color=vec4(blinnPhong(GPosition, GNormal), 1.0); 

    // Find the smallest distance 
    float d = min( GEdgeDistance.x, GEdgeDistance.y ); 
    d = min( d, GEdgeDistance.z ); 

    // Determine the mix factor with the line color 
    float mixVal = smoothstep( Line.Width - 1, 
                               Line.Width + 1, d ); 

    // Mix the surface color with the line color 
    FragColor = mix( Line.Color, color, mixVal ); 
} 

它是如何工作的...

顶点着色器相当简单。它将法线和位置转换为相机坐标后,传递给几何着色器。内置的gl_Position变量获取剪辑坐标中的位置。我们将在几何着色器中使用这个值来确定屏幕空间坐标。在几何着色器中,我们首先定义此着色器的输入和输出原语类型:

layout( triangles ) in; 
layout( triangle_strip, max_vertices = 3 ) out; 

我们实际上并没有改变三角形的几何形状,因此输入和输出类型基本上是相同的。我们将输出与输入接收到的完全相同的三角形。几何着色器的输出变量是GNormalGPositionGEdgeDistance。前两个只是通过不变传递的正常值和位置值。第三个是存储到三角形每条边的距离的向量(如前所述)。注意,它使用noperspective修饰符定义:

noperspective out vec3 GEdgeDistance;

noperspective修饰符表示这些值将进行线性插值,而不是默认的透视正确插值。如前所述,这些距离是在屏幕空间中,因此以非线性方式插值是不正确的。在main函数中,我们首先通过乘以视口矩阵将三角形的三个顶点的位置从裁剪坐标转换为屏幕空间坐标。(注意,由于裁剪坐标是齐次的,可能需要除以w坐标,以便将其转换回真正的笛卡尔坐标。)

接下来,我们使用余弦定理计算三个高度——hahbhc。一旦我们得到了这三个高度,我们就为第一个顶点适当地设置GEdgeDistance,保持GNormalGPositiongl_Position不变,并通过调用EmitVertex()来发射第一个顶点。这完成了顶点的处理,并发射了顶点位置以及所有每个顶点的输出变量。然后我们以类似的方式处理三角形的另外两个顶点,通过调用EndPrimitive()来完成多边形的处理。在片段着色器中,我们首先评估基本的着色模型,并将结果颜色存储在color中。在这个管道阶段,GEdgeDistance变量的三个分量应该包含从该片段到三角形三个边的距离。我们感兴趣的是最小距离,因此我们找到这三个分量的最小值,并将其存储在d变量中。然后使用smoothstep函数来确定混合线颜色与着色颜色的比例(mixVal):

float mixVal = smoothstep( Line.Width - 1, 
                           Line.Width + 1, d ); 

如果距离小于Line.Width - 1,则smoothstep将返回0的值,如果大于Line.Width + 1,则返回1。对于d值在两者之间的情况,我们将得到平滑的过渡。这给我们一个当在内部时为0的值,当在外部时为1的值,并且在边缘周围的两个像素区域内,我们将得到 0 到 1 之间的平滑变化。因此,我们可以使用这个结果直接与线颜色混合。最后,通过使用mixVal作为插值参数,通过混合着色颜色与线颜色来确定片段颜色。

还有更多...

这种技术产生的结果非常漂亮,并且相对较少的缺点。然而,它确实有一些与屏幕空间中大的三角形(延伸到视图体积之外)相关的问题。如果w坐标很小或为零,视口空间中的位置可以接近无穷大,产生一些难看的伪影。这发生在顶点在或接近相机空间的x-y平面时。

然而,这是一个很好的例子,说明了几何着色器可以用于除了修改实际几何以外的任务。在这种情况下,我们只是使用几何着色器来计算在管道中发送原始数据时的额外信息。这个着色器可以插入并应用于任何网格,而无需对应用程序的 OpenGL 部分进行任何修改。它在调试网格问题或实现网格建模程序时非常有用。实现此效果的其他常见技术通常涉及通过应用多边形偏移(通过glPolygonOffset函数)以避免z-fighting,在轮廓线和下面的着色表面之间发生。这种技术并不总是有效的,因为修改后的深度值可能并不总是正确,或者如期望的那样,并且可能很难找到多边形偏移值的最佳点。有关技术的良好概述,请参阅 T Akenine-Moller、E Haines 和 N Hoffman 所著的《实时渲染》第三版中的第 11.4.2 节

参见...

  • 示例代码中的chapter07/sceneshadewire.cpp文件。

  • 这种技术最初发表在 2007 年 NVIDIA 的一份白皮书中(Solid WireframeNVIDIA Whitepaper WP-03014-001_v01,可在developer.nvidia.com找到)。该白皮书被列为 Direct3D 的示例,但当然,我们在这里提供的是 OpenGL 的实现。

  • 第八章中,阴影使用阴影体积和几何着色器创建阴影菜谱[43239816-a842-483f-9eca-284f919d0bd6.xhtml]。

使用几何着色器绘制轮廓线

当需要卡通或手绘效果时,我们通常想在模型的边缘以及脊或褶皱(轮廓线)周围绘制黑色轮廓。在这个菜谱中,我们将讨论一种使用几何着色器来实现这一点的技术,以生成轮廓线的额外几何形状。几何着色器将通过生成与构成对象轮廓的边缘对齐的小而瘦的四边形来近似这些线条。以下图像显示了由几何着色器生成的黑色轮廓线的 ogre 网格。

这些线由与某些网格边缘对齐的小四边形组成:

本食谱中展示的技术基于 Philip Rideout 在博客文章中发布的技术:prideout.net/blog/?p=54。他的实现使用两个遍历(基础几何和轮廓),并包括许多优化,如抗锯齿和自定义深度测试(使用 g 缓冲区)。为了保持简单,因为我们的主要目标是展示几何着色器的功能,我们将使用单遍历实现该技术,不使用抗锯齿或自定义深度测试。如果您有兴趣添加这些附加功能,请参阅 Philip 出色的博客文章。几何着色器最重要的特性之一是它允许我们提供除正在渲染的基本形状之外的其他顶点信息。当 OpenGL 中引入几何着色器时,还引入了几个额外的原始形状渲染模式。这些相邻模式允许将额外的顶点数据与每个原始形状关联。通常,这些附加信息与网格中附近的原始形状相关,但这不是必需的(如果需要,我们可以实际上将附加信息用于其他目的)。以下列表包括相邻模式及其简要描述:

  • GL_LINES_ADJACENCY:此模式定义了带有相邻顶点的线条(每条线段四个顶点)

  • GL_LINE_STRIP_ADJACENCY:此模式定义了一个带有相邻顶点的线带(对于n条线,提供了n+3个顶点)

  • GL_TRIANGLES_ADJACENCY:此模式定义了三角形以及相邻三角形的顶点(每个基本形状六个顶点)

  • GL_TRIANGLE_STRIP_ADJACENCY:此模式定义了一个三角形带以及相邻三角形的顶点(对于n个三角形,提供了2(n+2)个顶点)

关于这些模式的详细信息,请查看官方 OpenGL 文档。在本食谱中,我们将使用GL_TRIANGLES_ADJACENCY模式来提供关于我们的网格中相邻三角形的详细信息。使用此模式,我们为每个基本形状提供六个顶点。以下图示说明了这些顶点的位置:

在前面的图中,实线代表三角形本身,虚线代表相邻的三角形。第一个、第三个和第五个顶点(024)构成了三角形本身。第二个、第四个和第六个是构成相邻三角形的顶点。

网格数据通常不以这种形式提供,因此我们需要预处理我们的网格以包含额外的顶点信息。通常,这仅意味着将元素索引数组扩展两倍。位置、法线和纹理坐标数组可以保持不变。

当一个网格带有相邻信息渲染时,几何着色器可以访问与特定三角形相关联的所有六个顶点。然后我们可以使用相邻三角形来确定三角形边是否是物体轮廓的一部分。基本假设是,如果一个三角形面向前方且相应的相邻三角形不面向前方,则边是轮廓边缘。

我们可以在几何着色器中通过计算三角形法向量(使用叉积)来确定三角形是否面向前方。如果我们正在眼坐标(或裁剪坐标)内工作,法向量的Z坐标对于面向前方的三角形将是正的。因此,我们只需要计算法向量的Z坐标,这应该可以节省几个周期。对于一个顶点为ABC的三角形,法向量的Z坐标由以下方程给出:

一旦我们确定哪些是轮廓边缘,几何着色器将生成与轮廓边缘对齐的额外瘦四边形。这些四边形合在一起将构成所需的暗线(参考前面的图)。在生成所有轮廓四边形后,几何着色器将输出原始三角形。

为了在单次遍历中渲染网格,并对基础网格进行适当的着色,而对轮廓线不进行着色,我们将使用一个额外的输出变量。这个变量将让片段着色器知道我们是在渲染基础网格还是在渲染轮廓边缘。

准备工作

设置你的网格数据,以便包含相邻信息。正如刚才提到的,这可能需要扩展元素索引数组以包含附加信息。这可以通过遍历你的网格并寻找共享边缘来实现。由于空间限制,我们不会在这里详细介绍,但之前提到的博客文章中提供了一些关于如何实现这一点的信息。此外,本例的源代码包含了一种简单(尽管效率不高)的技术。本例中重要的统一变量如下:

  • EdgeWidth:这是轮廓边缘在裁剪(归一化设备)坐标中的宽度

  • PctExtend:这是扩展四边形超出边缘的百分比

  • LineColor:这是轮廓边缘线的颜色

如同往常,还有适当的着色模型统一变量和标准矩阵。

如何做到这一点...

要创建一个利用几何着色器渲染轮廓边缘的着色器程序,请按照以下步骤操作:

  1. 使用以下代码进行顶点着色器:
layout (location = 0 ) in vec3 VertexPosition; 
layout (location = 1 ) in vec3 VertexNormal; 

out vec3 VNormal; 
out vec3 VPosition; 

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 ProjectionMatrix; 
uniform mat4 MVP; 
void main() {
    VNormal = normalize( NormalMatrix * VertexNormal); 
    VPosition = vec3(ModelViewMatrix *  
                     vec4(VertexPosition,1.0)); 
    gl_Position = MVP * vec4(VertexPosition,1.0); 
} 
  1. 使用以下代码进行几何着色器:
layout( triangles_adjacency ) in; 
layout( triangle_strip, max_vertices = 15 ) out; 

out vec3 GNormal; 
out vec3 GPosition; 

// Which output primitives are silhouette edges 
flat out bool GIsEdge; 

in vec3 VNormal[];   // Normal in camera coords. 
in vec3 VPosition[]; // Position in camera coords. 

uniform float EdgeWidth;  // Width of sil. edge in clip cds. 
uniform float PctExtend;  // Percentage to extend quad 

bool isFrontFacing( vec3 a, vec3 b, vec3 c ) {
    return ((a.x * b.y - b.x * a.y) +  
            (b.x * c.y - c.x * b.y) + 
            (c.x * a.y - a.x * c.y)) > 0; 
} 
void emitEdgeQuad( vec3 e0, vec3 e1 ) {
    vec2 ext = PctExtend * (e1.xy - e0.xy); 
    vec2 v = normalize(e1.xy - e0.xy); 
    vec2 n = vec2(-v.y, v.x) * EdgeWidth; 

    // Emit the quad 
    GIsEdge = true;   // This is part of the sil. edge 

    gl_Position = vec4( e0.xy - ext, e0.z, 1.0 );  
    EmitVertex(); 
    gl_Position = vec4( e0.xy - n - ext, e0.z, 1.0 );  
    EmitVertex(); 
    gl_Position = vec4( e1.xy + ext, e1.z, 1.0 );  
    EmitVertex(); 
    gl_Position = vec4( e1.xy - n + ext, e1.z, 1.0 ); 
    EmitVertex(); 

    EndPrimitive(); 
} 

void main() {
    vec3 p0 = gl_in[0].gl_Position.xyz /  
              gl_in[0].gl_Position.w; 
    vec3 p1 = gl_in[1].gl_Position.xyz /  
              gl_in[1].gl_Position.w; 
    vec3 p2 = gl_in[2].gl_Position.xyz /  
              gl_in[2].gl_Position.w; 
    vec3 p3 = gl_in[3].gl_Position.xyz /  
              gl_in[3].gl_Position.w; 
    vec3 p4 = gl_in[4].gl_Position.xyz /  
              gl_in[4].gl_Position.w; 
    vec3 p5 = gl_in[5].gl_Position.xyz /  
              gl_in[5].gl_Position.w; 

    if( isFrontFacing(p0, p2, p4) ) { 
        if( ! isFrontFacing(p0,p1,p2) )  
                    emitEdgeQuad(p0,p2); 
        if( ! isFrontFacing(p2,p3,p4) )  
                    emitEdgeQuad(p2,p4); 
        if( ! isFrontFacing(p4,p5,p0) )  
                    emitEdgeQuad(p4,p0); 
    } 

    // Output the original triangle 
    GIsEdge = false; // Triangle is not part of an edge. 

    GNormal = VNormal[0]; 
    GPosition = VPosition[0]; 
    gl_Position = gl_in[0].gl_Position; 
    EmitVertex(); 
    GNormal = VNormal[2]; 
    GPosition = VPosition[2]; 
    gl_Position = gl_in[2].gl_Position; 
    EmitVertex();
    GNormal = VNormal[4]; 
    GPosition = VPosition[4]; 
    gl_Position = gl_in[4].gl_Position; 
    EmitVertex(); 

    EndPrimitive(); 
}
  1. 使用以下代码进行片段着色器:
//*** Light and material uniforms... **** 

uniform vec4 LineColor;  // The sil. edge color 

in vec3 GPosition;  // Position in camera coords 
in vec3 GNormal;    // Normal in camera coords. 

flat in bool GIsEdge; // Whether or not we're drawing an edge 

layout( location = 0 ) out vec4 FragColor; 

vec3 toonShade( ) {
   // *** toon shading algorithm from Chapter 4 *** 
} 

void main() {
    // If we're drawing an edge, use constant color,  
    // otherwise, shade the poly. 
    if( GIsEdge ) { 
        FragColor = LineColor; 
    } else { 
        FragColor = vec4( toonShade(), 1.0 ); 
    }
} 

它是如何工作的...

顶点着色器是一个简单的 passthrough 着色器。它将顶点位置和法线转换为相机坐标,并通过 VPositionVNormal 将它们发送出去。这些将在片段着色器中进行着色,并将由几何着色器传递(或忽略)。位置也通过模型视图投影矩阵转换成裁剪坐标(或归一化设备坐标),然后分配给内置的 gl_Position

几何着色器首先使用布局指令定义输入和输出原语类型:

layout( triangles_adjacency ) in; 
layout( triangle_strip, max_vertices = 15 ) out;

这表示输入原语类型是具有相邻信息的三角形,输出类型是三角形带。此几何着色器将生成一个三角形(原始三角形)和最多一个与每条边对应的四边形。这对应于最多可以生成的 15 个顶点,我们在输出布局指令中指示了最大值。

GIsEdge 输出变量用于指示片段着色器该多边形是否为边四边形。片段着色器将使用此值来确定是否着色该多边形。无需插值此值,因为它是一个布尔值,插值并不完全有意义,所以我们使用 flat 限定符。

main 函数中的前几行取六个顶点(在裁剪坐标中)的位置,并将其除以第四个坐标,以便将其从齐次表示转换为真正的笛卡尔值。如果我们使用透视投影,这是必要的,但对于正交投影则不是必要的。

接下来,我们确定由点 024 定义的三角形是否为正面三角形。isFrontFacing 函数返回由其三个参数定义的三角形是否为正面三角形,使用之前描述的方程。如果主三角形是正面三角形,则只有在相邻三角形不是正面三角形时,我们才会发出轮廓边四边形。

emitEdgeQuad 函数生成一个与由 e0e1 点定义的边对齐的四边形。它首先计算 ext,这是从 e0e1 的向量,并按 PctExtend 缩放(为了稍微延长边四边形)。我们延长边四边形是为了覆盖四边形之间可能出现的间隙(我们将在 还有更多... 中进一步讨论)。

注意,我们在这里省略了 z 坐标。因为点是在裁剪坐标中定义的,我们打算生成一个与 x-y 平面(面向相机)对齐的四边形,所以我们想通过在 x-y 平面内平移来计算顶点的位置。因此,我们现在可以忽略 z 坐标。我们将在每个顶点的最终位置中使用其值不变。

接下来,变量 v 被赋值为从 e0e1 的归一化向量。变量 n 获得一个垂直于 v 的向量(在二维中,这可以通过交换 xy 坐标并取新 x 坐标的相反数来实现)。这只是在二维中的逆时针 90 度旋转。我们通过 EdgeWidth 缩放 n 向量,因为我们希望向量的长度与四边形的宽度相同。extn 向量将用于确定四边形的顶点,如下面的图所示:

四边形的四个角由 e0 - exte0 - n - exte1 + exte1 - n + ext 给出。下两个顶点的 z 坐标与 e0z 坐标相同,上两个顶点的 z 坐标与 e1z 坐标相同。

然后我们通过将 GIsEdge 设置为 true 来完成 emitEdgeQuad 函数,以便让片段着色器知道我们正在渲染轮廓边缘,然后发出四边形的四个顶点。函数通过调用 EndPrimitive 来终止四边形三角形带的处理。

main 函数中,在生成轮廓边缘之后,我们继续发出原始三角形,保持不变。顶点 024VNormalVPositiongl_Position 传递给片段着色器时没有任何修改。每个顶点通过调用 EmitVertex 发出,并通过 EndPrimitive 完成原语。

在片段着色器中,我们要么着色片段(使用卡通着色算法),要么简单地给片段一个恒定颜色。GIsEdge 输入变量将指示选择哪个选项。如果 GIsEdgetrue,那么我们正在渲染轮廓边缘,因此片段被赋予线条颜色。否则,我们正在渲染网格多边形,因此我们使用第四章中描述的卡通着色技术来着色片段,即 光照和着色

还有更多...

前述技术的一个问题是,由于连续边缘四边形之间的间隙,可能会出现 羽化 现象:

前面的图显示了轮廓边缘的羽化。多边形之间的间隙可以用三角形填充,但在我们的例子中,我们只是简单地扩展每个四边形的长度来填充间隙。当然,如果四边形扩展得太远,这可能会引起伪影,但在实践中,我的经验是它们并没有非常分散注意力。

第二个问题与深度测试有关。如果一个边缘多边形扩展到网格的另一个区域,它可能会因为深度测试而被裁剪。以下是一个例子:

边缘多边形应垂直延伸到前一个图像的中间,但由于它位于附近的网格部分之后而被裁剪。可以通过在渲染轮廓边缘时使用自定义深度测试来解决此问题。有关此技术的详细信息,请参阅前面提到的 Philip Rideout 的博客文章。在渲染边缘时关闭深度测试也可能可行,但要小心不要渲染模型另一侧的任何边缘。

参见

曲线镶嵌

在本配方中,我们将通过绘制立方贝塞尔曲线来查看镶嵌着色器的基础。贝塞尔曲线是由四个控制点定义的参数曲线。控制点定义了曲线的整体形状。四个点中的第一个和最后一个定义了曲线的开始和结束,中间的点引导曲线的形状,但不必直接位于曲线本身上。曲线是通过使用一组混合函数插值四个控制点来定义的。混合函数定义了每个控制点对曲线给定位置的贡献程度。对于贝塞尔曲线,混合函数被称为伯恩斯坦多项式

在前一个方程中,第一项是二项式系数函数(如下所示),n 是多项式的次数,i 是多项式的编号,t 是参数参数:

贝塞尔曲线的一般参数形式是伯恩斯坦多项式与控制点(P[i])的乘积之和:

在本例中,我们将绘制一个立方贝塞尔曲线,这涉及到四个控制点(n = 3):

立方贝塞尔多项式如下:

如本章引言所述,OpenGL 中的镶嵌功能涉及两个着色器阶段。它们是镶嵌控制着色器(TCS)和镶嵌评估着色器(TES)。在这个例子中,我们将在 TCS 中定义我们的贝塞尔曲线的线段数量(通过定义外镶嵌级别),并在 TES 中评估每个特定顶点位置处的贝塞尔曲线。以下图像显示了本例三个不同镶嵌级别的输出。左图使用三个线段(级别 3),中间使用级别 5,右图使用镶嵌级别 30。小方块是控制点:

贝塞尔曲线的控制点作为由四个顶点组成的补丁原语发送到管线中。补丁原语是程序员定义的原语类型。基本上,它是一组可以用于程序员选择的任何事物的顶点集。TCS 对补丁内的每个顶点执行一次,TES 根据 TPG 生成的顶点数量,以变量次数执行。镶嵌阶段的最终输出是一组原语。在我们的例子中,它将是一条线段。

TCS 的一部分工作是定义镶嵌级别。非常粗略地说,镶嵌级别与将要生成的顶点数量相关。在我们的例子中,TCS 将生成一条线段,所以镶嵌级别是线段中的线段数量。为这条线段生成的每个顶点都将与一个在零到一之间变化的镶嵌坐标相关联。我们将称之为 u 坐标,它将对应于前面贝塞尔曲线方程中的参数 t

我们到目前为止所看到的内容并不是整个故事。实际上,TCS 将触发生成一组称为等值线的线段。这个等值线集中的每个顶点都将有一个 u 和一个 v 坐标。u 坐标将在给定的等值线上从零变到一,而 v 对于每个等值线将是常数。uv 的不同值与两个单独的镶嵌级别相关联,所谓的 级别。然而,对于这个例子,我们只会生成一条线段,所以第二个镶嵌级别(对于 v)将始终为 1。

在 TES 中,主要任务是确定与此次着色器执行相关的顶点的位置。我们可以访问与顶点相关的 uv 坐标,我们还可以(只读)访问补丁中所有顶点。然后我们可以通过使用参数方程,以 u 作为参数坐标(前面方程中的 t),来确定顶点的适当位置。

准备工作

以下是为本例重要的统一变量:

  • NumSegments:这是要生成的线段数量。

  • NumStrips:这是要生成的等值线的数量。对于这个例子,这个值应该设置为1

  • LineColor:这是结果线条的颜色。

在主 OpenGL 应用程序中设置统一变量。总共有四个着色器需要编译和链接。它们是顶点、片段、细分控制以及细分评估着色器。

如何做到这一点...

要创建一个从四个控制点组成的补丁生成贝塞尔曲线的着色器程序,请按照以下步骤操作:

  1. 使用以下代码作为顶点着色器。请注意,我们将顶点位置原封不动地发送到 TCS:
layout (location = 0 ) in vec2 VertexPosition; 

void main() {
    gl_Position = vec4(VertexPosition, 0.0, 1.0); 
} 
  1. 使用以下代码作为细分控制着色器:
layout( vertices=4 ) out; 

uniform int NumSegments; 
uniform int NumStrips; 

void main() {
    // Pass along the vertex position unmodified 
    gl_out[gl_InvocationID].gl_Position =  
              gl_in[gl_InvocationID].gl_Position; 
    // Define the tessellation levels 
    gl_TessLevelOuter[0] = float(NumStrips); 
    gl_TessLevelOuter[1] = float(NumSegments); 
} 
  1. 使用以下代码作为细分评估着色器:
layout( isolines ) in; 
uniform mat4 MVP;  // projection * view * model 

void main() {
    // The tessellation u coordinate 
    float u = gl_TessCoord.x; 

    // The patch vertices (control points) 
    vec3 p0 = gl_in[0].gl_Position.xyz; 
    vec3 p1 = gl_in[1].gl_Position.xyz; 
    vec3 p2 = gl_in[2].gl_Position.xyz; 
    vec3 p3 = gl_in[3].gl_Position.xyz; 

    float u1 = (1.0 - u); 
    float u2 = u * u; 

    // Bernstein polynomials evaluated at u 
    float b3 = u2 * u; 
    float b2 = 3.0 * u2 * u1; 
    float b1 = 3.0 * u * u1 * u1; 
    float b0 = u1 * u1 * u1; 

    // Cubic Bezier interpolation 
    vec3 p = p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3; 

    gl_Position = MVP * vec4(p, 1.0); 

} 
  1. 使用以下代码作为片段着色器:
uniform vec4 LineColor; 

layout ( location = 0 ) out vec4 FragColor; 

void main() {
    FragColor = LineColor; 
} 
  1. 在 OpenGL 应用程序中定义每个补丁的顶点数是很重要的。您可以使用glPatchParameter函数来完成此操作:
glPatchParameteri( GL_PATCH_VERTICES, 4); 
  1. 在 OpenGL 应用程序的render函数中将四个控制点作为补丁原语渲染:
glDrawArrays(GL_PATCHES, 0, 4); 

它是如何工作的...

顶点着色器只是一个传递着色器。它将顶点位置原封不动地传递到下一个阶段。

细分控制着色器首先定义输出补丁中的顶点数:

layout (vertices = 4) out; 

注意,这不同于由细分过程产生的顶点数量。在这种情况下,补丁是我们的四个控制点,所以我们使用4这个值。

TCS 中的主方法将输入位置(补丁顶点的位置)原封不动地传递到输出位置。gl_outgl_in数组包含与补丁中每个顶点相关的输入和输出信息。请注意,我们在这些数组中分配和读取gl_InvocationIDgl_InvocationID变量定义了 TCS 负责的输出补丁顶点。TCS 可以访问gl_in数组中的所有内容,但应该只写入对应于gl_InvocationIDgl_out中的位置。其他索引将由 TCS 的其他调用写入。

接下来,TCS 通过将值分配给gl_TessLevelOuter数组来设置细分级别。请注意,gl_TessLevelOuter的值是浮点数而不是整数。它们将被四舍五入到最接近的整数,并由 OpenGL 系统自动夹断。

数组的第一个元素定义了将生成的等值线的数量。每条等值线对于v的值都是恒定的。在这个例子中,gl_TessLevelOuter[0]的值应该为1,因为我们只想创建一条曲线。第二个定义了将在线条中产生的线段数量。条纹中的每个顶点都将有一个从零到一的参数u坐标的值。

在 TES 中,我们首先使用layout声明来定义输入原语类型:

layout (isolines) in; 

这表示细分原语生成器执行的细分类型。其他可能性包括 quadstriangles

在 TES 的 main 函数中,gl_TessCoord 变量包含此调用中细分图的 uv 坐标。由于我们只在一维上进行细分,我们只需要 u 坐标,它对应于 gl_TessCoordx 坐标。

下一步是访问四个控制点的位置(我们补丁原语中的所有点)。这些位置在 gl_in 数组中可用。

立方贝塞尔多项式在 u 处评估,并存储在 b0b1b2b3 中。接下来,我们使用贝塞尔曲线方程计算插值位置。最终位置转换为裁剪坐标,并分配给 gl_Position 输出变量。

片段着色器简单地应用 LineColor 到片段上。

还有更多...

关于细分着色器还有很多可以说的,但这个例子旨在提供一个简单的介绍,所以我们将留到后面的食谱中。接下来,我们将探讨二维表面上的细分。

参见

  • 示例代码中的 chapter07/scenebezcurve.cpp 文件

细分 2D 四边形

理解 OpenGL 的硬件细分的一个最好的方法是通过可视化 2D 四边形的细分。当使用线性插值时,产生的三角形与细分原语生成的细分坐标(u,v)直接相关。绘制几个具有不同内部和外部细分级别的四边形,并研究产生的三角形,这可以非常有帮助。我们将在本食谱中做 exactly that。

当使用四边形细分时,细分原语生成器根据六个参数将 (u,v) 参数空间细分为多个细分。这些是 uv 的内部细分级别(内部级别 0 和内部级别 1),以及沿着两个边的外部细分级别(外部级别 0 到 3)。这些决定了参数空间边沿和内部的细分数量。让我们分别看看这些:

  • 外层级别 0 (OL0): 这是沿着 v 方向的细分数量,其中 u = 0

  • 外层级别 1 (OL1): 这是沿着 u 方向的细分数量,其中 v = 0

  • 外层级别 2 (OL2): 这是沿着 v 方向的细分数量,其中 u = 1

  • 外层级别 3 (OL3): 这是沿着 u 方向的细分数量,其中 v = 1

  • 内部级别 0 (IL0): 这是对于所有内部 v 值沿着 u 方向的细分数量

  • 内部级别 1 (IL1): 这是对于所有内部 u 值沿着 v 方向的细分数量

以下图表表示细分级别与受影响的参数空间区域之间的关系。外层级别定义了边缘上的细分次数,内层级别定义了内部的细分次数:

之前描述的六个细分级别可以通过gl_TessLevelOutergl_TessLevelInner数组进行配置。例如,gl_TessLevelInner[0]对应于IL0gl_TessLevelOuter[2]对应于OL2,依此类推。

如果我们绘制一个由单个四边形(四个顶点)组成的补丁原语,并使用线性插值,生成的三角形可以帮助我们理解 OpenGL 如何进行四边形细分。以下图表显示了不同细分级别的结果:

![]

当我们使用线性插值时,产生的三角形代表参数(u, v)空间的视觉表示。x轴对应于u坐标,y轴对应于v坐标。三角形的顶点是细分原语生成的(u,v)坐标。细分次数可以在三角形的网格中清楚地看到。例如,当外层级别设置为2,内层级别设置为8时,可以看到外边缘有两个细分,但在四边形内部,u 和 v 被细分为八个区间。

在深入代码之前,让我们讨论线性插值。如果四边形的四个角如图所示,那么四边形内的任何点都可以通过相对于uv参数进行线性插值来确定:

我们将让细分原语生成具有适当参数坐标的一组顶点,我们将通过使用前面的方程式插值四边形的角来确定相应的位置。

准备工作

外部和内部细分级别将由InnerOuter统一变量确定。为了显示三角形,我们将使用几何着色器。

设置你的 OpenGL 应用程序以渲染一个由四个顶点组成的补丁原语,顺时针方向,如图中所示。

如何做到这一点...

要创建一个着色器程序,该程序将使用四个顶点的补丁进行四边形细分来生成一组三角形,请按照以下步骤操作:

  1. 使用以下代码作为顶点着色器:
layout (location = 0 ) in vec2 VertexPosition; 

void main() {
    gl_Position = vec4(VertexPosition, 0.0, 1.0); 
}
  1. 使用以下代码作为细分控制着色器:
layout( vertices=4 ) out; 

uniform int Outer; 
uniform int Inner; 
void main() {
    // Pass along the vertex position unmodified 
    gl_out[gl_InvocationID].gl_Position =  
               gl_in[gl_InvocationID].gl_Position; 

    gl_TessLevelOuter[0] = float(Outer); 
    gl_TessLevelOuter[1] = float(Outer); 
    gl_TessLevelOuter[2] = float(Outer); 
    gl_TessLevelOuter[3] = float(Outer); 

    gl_TessLevelInner[0] = float(Inner); 
    gl_TessLevelInner[1] = float(Inner); 
}
  1. 使用以下代码作为细分评估着色器:
layout( quads, equal_spacing, ccw ) in; 

uniform mat4 MVP; 

void main() {
    float u = gl_TessCoord.x; 
    float v = gl_TessCoord.y; 

    vec4 p0 = gl_in[0].gl_Position; 
    vec4 p1 = gl_in[1].gl_Position; 
    vec4 p2 = gl_in[2].gl_Position; 
    vec4 p3 = gl_in[3].gl_Position; 

    // Linear interpolation 
    gl_Position = 
        p0 * (1-u) * (1-v) + 
        p1 * u * (1-v) + 
        p3 * v * (1-u) + 
        p2 * u * v; 

    // Transform to clip coordinates 
    gl_Position = MVP * gl_Position; 
} 
  1. 使用来自在着色网格上绘制线框菜谱的几何着色器

  2. 使用以下代码作为片段着色器:

uniform float LineWidth; 
uniform vec4 LineColor; 
uniform vec4 QuadColor; 

noperspective in vec3 GEdgeDistance;  // From geom. shader 

layout ( location = 0 ) out vec4 FragColor; 

float edgeMix() {
   // ** insert code here to determine how much of the edge 
   // color to include (see recipe "Drawing a wireframe on 
   // top of a shaded mesh").  ** 
} 

void main() {
    float mixVal = edgeMix(); 

    FragColor = mix( QuadColor, LineColor, mixVal );
} 
  1. 在你的主 OpenGL 程序的render函数中,定义补丁内的顶点数:
glPatchParameteri(GL_PATCH_VERTICES, 4); 
  1. 以逆时针顺序渲染补丁为四个 2D 顶点

它是如何工作的...

顶点着色器将位置原封不动地传递给 TCS。

TCS 使用布局指令定义了补丁中的顶点数:

layout (vertices=4) out; 

main函数中,它将顶点的位置原封不动地传递下去,并设置内部和外部素片化级别。所有四个外部素片化级别都设置为Outer的值,两个内部素片化级别都设置为Inner

在素片评估着色器中,我们使用输入布局指令定义素片化模式和其它素片化参数:

layout ( quads, equal_spacing, ccw ) in; 

quads参数指示着素片生成器应该使用四边形素片化来对参数空间进行素片化。equal_spacing参数表示素片化应该执行,使得所有细分部分具有相等的长度。最后一个参数ccw表示素片应该以逆时针方向生成。

TES 中的main函数首先通过访问gl_TessCoord变量来检索此顶点的参数坐标。然后我们继续从gl_in数组中读取补丁中的四个顶点的位置。我们将它们存储在临时变量中,以便在插值计算中使用。

内置的gl_Position输出变量随后使用前面的方程式获取插值点的值。最后,我们通过乘以模型视图投影矩阵将位置转换为裁剪坐标。

在片段着色器中,我们给所有片段一个颜色,这个颜色可能混合了线条颜色,以便突出边缘。

参见

  • 示例代码中的chapter07/scenequadtess.cpp文件

  • 在着色网格上绘制线框图的配方

素片化 3D 表面

作为素片化 3D 表面的一个例子,让我们再次渲染(是的,又是)茶壶多面体。结果证明,茶壶的数据集实际上被定义为 4 x 4 个控制点的集合,适合进行三次贝塞尔插值。因此,绘制茶壶实际上归结为绘制一组三次贝塞尔曲面。

当然,这听起来像是素片着色器的完美工作!我们将渲染每个 16 个顶点的补丁作为补丁原语,使用四边形素片化来细分参数空间,并在素片评估着色器中实现贝塞尔插值。

以下图像显示了所需输出的一个示例。左边的茶壶使用了内部和外部素片化级别 2,中间的使用了级别 4,右边的茶壶使用了素片化级别 16。素片评估着色器计算贝塞尔曲面插值:

图片

首先,让我们看看立方贝塞尔曲面插值是如何工作的。如果我们的曲面由一组 16 个控制点(以 4 x 4 网格排列)P[ij]定义,其中ij的范围从 0 到 3,则参数化贝塞尔曲面由以下方程给出:

前述方程中的B实例是立方伯恩斯坦多项式(参考先前的配方,Tessellating a 2D quad)。

我们还需要计算每个插值位置的法向量。为此,我们必须计算前述方程的偏导数的叉积:

贝塞尔曲面的偏导数简化为伯恩斯坦多项式的偏导数:

我们将在 TES 内计算偏导数并计算叉积以确定每个细分顶点的曲面法线。

准备工作

设置你的着色器,使用一个顶点着色器,它简单地传递顶点位置而不进行任何修改(你可以使用与Tessellating a 2D quad配方中使用的相同的顶点着色器)。创建一个片段着色器,实现你选择的着色模型。片段着色器应该接收TENormalTEPosition输入变量,它们将是相机坐标中的法线和位置。

TessLevel统一变量应赋予所需细分级别的值。所有内部和外部级别都将设置为该值。

如何操作...

要创建一个从 16 个控制点输入的贝塞尔补丁的程序,请按照以下步骤操作:

  1. 使用Tessellating a 2D quad配方中的顶点着色器。

  2. 使用以下代码进行细分控制着色器:

layout( vertices=16 ) out; 

uniform int TessLevel; 

void main() {
    // Pass along the vertex position unmodified 
    gl_out[gl_InvocationID].gl_Position =  
                 gl_in[gl_InvocationID].gl_Position; 

    gl_TessLevelOuter[0] = float(TessLevel); 
    gl_TessLevelOuter[1] = float(TessLevel); 
    gl_TessLevelOuter[2] = float(TessLevel); 
    gl_TessLevelOuter[3] = float(TessLevel); 

    gl_TessLevelInner[0] = float(TessLevel); 
    gl_TessLevelInner[1] = float(TessLevel); 
}
  1. 使用以下代码进行细分评估着色器:
layout( quads ) in; 
out vec3 TENormal;   // Vertex normal in camera coords. 
out vec4 TEPosition; // Vertex position in camera coords 

uniform mat4 MVP; 
uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 

void basisFunctions(out float[4] b, out float[4] db, float t) {
    float t1 = (1.0 - t); 
    float t12 = t1 * t1; 

    // Bernstein polynomials 
    b[0] = t12 * t1; 
    b[1] = 3.0 * t12 * t; 
    b[2] = 3.0 * t1 * t * t; 
    b[3] = t * t * t; 

    // Derivatives 
    db[0] = -3.0 * t1 * t1; 
    db[1] = -6.0 * t * t1 + 3.0 * t12; 
    db[2] = -3.0 * t * t + 6.0 * t * t1; 
    db[3] = 3.0 * t * t; 
} 

void main() {
    float u = gl_TessCoord.x; 
    float v = gl_TessCoord.y; 

    // The sixteen control points 
    vec4 p00 = gl_in[0].gl_Position; 
    vec4 p01 = gl_in[1].gl_Position; 
    vec4 p02 = gl_in[2].gl_Position; 
    vec4 p03 = gl_in[3].gl_Position; 
    vec4 p10 = gl_in[4].gl_Position; 
    vec4 p11 = gl_in[5].gl_Position; 
    vec4 p12 = gl_in[6].gl_Position; 
    vec4 p13 = gl_in[7].gl_Position; 
    vec4 p20 = gl_in[8].gl_Position; 
    vec4 p21 = gl_in[9].gl_Position; 
    vec4 p22 = gl_in[10].gl_Position; 
    vec4 p23 = gl_in[11].gl_Position; 
    vec4 p30 = gl_in[12].gl_Position; 
    vec4 p31 = gl_in[13].gl_Position; 
    vec4 p32 = gl_in[14].gl_Position; 
    vec4 p33 = gl_in[15].gl_Position; 
    // Compute basis functions 
    float bu[4], bv[4];   // Basis functions for u and v 
    float dbu[4], dbv[4]; // Derivitives for u and v 
    basisFunctions(bu, dbu, u); 
    basisFunctions(bv, dbv, v); 

    // Bezier interpolation 
    TEPosition = 
     p00*bu[0]*bv[0] + p01*bu[0]*bv[1] + p02*bu[0]*bv[2] +  
     p03*bu[0]*bv[3] + 
     p10*bu[1]*bv[0] + p11*bu[1]*bv[1] + p12*bu[1]*bv[2] +  
     p13*bu[1]*bv[3] + 
     p20*bu[2]*bv[0] + p21*bu[2]*bv[1] + p22*bu[2]*bv[2] +  
     p23*bu[2]*bv[3] + 
     p30*bu[3]*bv[0] + p31*bu[3]*bv[1] + p32*bu[3]*bv[2] +  
     p33*bu[3]*bv[3]; 

    // The partial derivatives 
    vec4 du = 
     p00*dbu[0]*bv[0]+p01*dbu[0]*bv[1]+p02*dbu[0]*bv[2]+  
     p03*dbu[0]*bv[3]+ 
     p10*dbu[1]*bv[0]+p11*dbu[1]*bv[1]+p12*dbu[1]*bv[2]+  
     p13*dbu[1]*bv[3]+ 
     p20*dbu[2]*bv[0]+p21*dbu[2]*bv[1]+p22*dbu[2]*bv[2]+  
     p23*dbu[2]*bv[3]+ 
     p30*dbu[3]*bv[0]+p31*dbu[3]*bv[1]+p32*dbu[3]*bv[2]+  
     p33*dbu[3]*bv[3]; 

    vec4 dv = 
     p00*bu[0]*dbv[0]+p01*bu[0]*dbv[1]+p02*bu[0]*dbv[2]+  
     p03*bu[0]*dbv[3]+ 
     p10*bu[1]*dbv[0]+p11*bu[1]*dbv[1]+p12*bu[1]*dbv[2]+  
     p13*bu[1]*dbv[3]+ 
     p20*bu[2]*dbv[0]+p21*bu[2]*dbv[1]+p22*bu[2]*dbv[2]+  
     p23*bu[2]*dbv[3]+ 
     p30*bu[3]*dbv[0]+p31*bu[3]*dbv[1]+p32*bu[3]*dbv[2]+  
     p33*bu[3]*dbv[3]; 

    // The normal is the cross product of the partials 
    vec3 n = normalize( cross(du.xyz, dv.xyz) ); 

    // Transform to clip coordinates 
    gl_Position = MVP * TEPosition; 

    // Convert to camera coordinates 
    TEPosition = ModelViewMatrix * TEPosition; 
    TENormal = normalize(NormalMatrix * n); 
}
  1. 在片段着色器中实现你喜欢的着色模型,利用 TES 的输出变量。

  2. 将贝塞尔控制点渲染为 16 顶点的补丁原语。别忘了在 OpenGL 应用程序中设置每个补丁的顶点数:

glPatchParameteri(GL_PATCH_VERTICES, 16); 

它是如何工作的...

细分控制着色器首先使用布局指令定义补丁中的顶点数:

layout( vertices=16 ) out; 

然后,它简单地设置细分级别为TessLevel的值。它传递顶点位置,不进行任何修改。

细分评估着色器首先使用布局指令来指示要使用的细分类型。由于我们正在细分一个 4 x 4 的贝塞尔曲面补丁,四边形细分最为合理。

basisFunctions函数评估给定t参数值的伯恩斯坦多项式及其导数。结果返回在bdb输出参数中。

main函数中,我们首先将镶嵌坐标分配给uv变量,并将所有 16 个补丁顶点重新分配给具有较短名称的变量(以缩短稍后出现的代码)。

然后我们调用basisFunctions来计算伯恩斯坦多项式及其在uv处的导数,并将结果存储在budbubvdbv中。

下一步是评估前面方程中关于位置(TEPosition)、关于u的偏导数(du)和关于v的偏导数(dv)的总和。我们通过计算dudv的叉积来计算法向量。

最后,我们将位置(TEPosition)转换为裁剪坐标,并将结果分配给

gl_Position。我们还在将其传递到片段着色器之前将其转换为摄像机坐标。

通过乘以NormalMatrix,将法向量转换为摄像机坐标,并将结果归一化,通过TENormal传递到片段着色器。

参见

  • 示例代码中的chapter07/scenetessteapot.cpp文件

  • 2D 四边形的镶嵌配方

基于深度的镶嵌

镶嵌着色器最伟大的事情之一就是实现细节级别LOD)算法的简单性。LOD 是计算机图形学中的一个通用术语,指的是根据观察者(或其他因素)与对象之间的距离增加/减少对象几何复杂性的过程。当对象远离摄像机时,需要更少的几何细节来表示形状,因为对象的总体尺寸变得更小。然而,当对象靠近摄像机时,对象在屏幕上占据的面积越来越大,需要更多的几何细节来保持所需的外观(平滑度或其他几何失真)。

以下图像显示了使用基于摄像机距离的镶嵌级别渲染的几个茶壶。每个茶壶在 OpenGL 端使用完全相同的代码进行渲染。TCS 自动根据深度变化镶嵌级别:

当使用镶嵌着色器时,镶嵌级别决定了对象的几何复杂性。由于镶嵌级别可以在镶嵌控制着色器中设置,因此根据与摄像机的距离变化镶嵌级别是一件简单的事情。

在本例中,我们将线性地(相对于距离)在最小级别和最大级别之间变化细分级别。我们将计算相机到距离作为相机坐标中z坐标的绝对值(当然,这并不是真正的距离,但应该适用于本例的目的)。然后根据该值计算细分级别。我们还将定义两个额外的值(作为统一变量):MinDepthMaxDepth。距离相机比MinDepth更近的对象将获得最大的细分级别,而距离相机比MaxDepth更远的所有对象将获得最小的细分级别。介于两者之间的对象的细分级别将进行线性插值。

准备工作

此程序几乎与“对 3D 表面进行细分”菜谱中的程序相同。唯一的区别在于 TCS。我们将移除TessLevel统一变量,并添加一些新的变量,如下所述:

  • MinTessLevel:这是期望的最低细分级别

  • MaxTessLevel:这是期望的最高细分级别

  • MinDepth:这是从相机到最小距离,此时细分级别最大

  • MaxDepth:这是从相机到最大距离,此时细分级别最小

按照在“对 3D 表面进行细分”菜谱中指示的,将您的对象渲染为 16 顶点补丁原语。

如何操作...

要创建一个根据深度变化细分级别的着色器程序,请按照以下步骤操作:

  1. 使用来自“对 3D 表面进行细分”菜谱的顶点着色器和细分评估着色器。

  2. 使用以下代码进行细分控制着色器:

layout( vertices=16 ) out; 

uniform int MinTessLevel; 
uniform int MaxTessLevel; 
uniform float MaxDepth; 
uniform float MinDepth; 

uniform mat4 ModelViewMatrix; 

void main() {
    // Position in camera coordinates 
    vec4 p = ModelViewMatrix *  
                   gl_in[gl_InvocationID].gl_Position; 

    // "Distance" from camera scaled between 0 and 1 
    float depth = clamp( (abs(p.z) - MinDepth) /  
                         (MaxDepth - MinDepth), 0.0, 1.0 ); 

    // Interpolate between min/max tess levels 
    float tessLevel =  
          mix(MaxTessLevel, MinTessLevel, depth); 

    gl_TessLevelOuter[0] = float(tessLevel); 
    gl_TessLevelOuter[1] = float(tessLevel); 
    gl_TessLevelOuter[2] = float(tessLevel); 
    gl_TessLevelOuter[3] = float(tessLevel); 

    gl_TessLevelInner[0] = float(tessLevel); 
    gl_TessLevelInner[1] = float(tessLevel); 

    gl_out[gl_InvocationID].gl_Position =  
                  gl_in[gl_InvocationID].gl_Position; 
} 
  1. 与先前的菜谱一样,在片段着色器中实现您喜欢的着色模型。

它是如何工作的...

TCS 将位置转换为相机坐标,并将结果存储在p变量中。然后z坐标的绝对值被缩放并夹紧,以确保结果在零和一之间。如果z坐标等于MaxDepth,则深度值将为1.0;如果等于MinDepth,则深度将为0.0。如果zMinDepthMaxDepth之间,则深度将获得介于零和一之间的值。如果z超出该范围,它将被clamp函数夹紧到0.01.0

然后使用mix函数根据depth值在MaxTessLevelMinTessLevel之间进行线性插值。结果(tessLevel)用于设置内部和外部细分级别。

还有更多...

这个例子有一个稍微微妙的地方。回想一下,TCS(三角形细分计算)在补丁中的每个输出顶点处执行一次。因此,如果我们正在渲染三次贝塞尔曲面,这个 TCS 将为每个补丁执行 16 次。每次执行时,depth的值都会略有不同,因为它基于顶点的z坐标进行评估。你可能想知道,16 种可能的不同细分级别中哪一种会被使用?在参数空间中插值细分级别是没有意义的。这是怎么回事?

gl_TessLevelInnergl_TessLevelOuter输出数组是每个补丁的输出变量。这意味着每个补丁只使用一个值,类似于平面限定符对片段着色器输入变量的作用。OpenGL 规范似乎表明,TCS 的每次调用中的任何值都可能最终被使用。

我们还应该注意,如果共享边的补丁的细分级别不同,那么可能会出现裂缝或其他视觉伪影。因此,我们应该确保相邻的补丁使用相同的细分级别。

参见

第八章:阴影

在本章中,我们将涵盖以下配方:

  • 使用阴影贴图渲染阴影

  • 使用 PCF 抗锯齿阴影边缘

  • 使用随机采样创建柔和的阴影边缘

  • 使用阴影体积和几何着色器创建阴影

简介

阴影为场景增添了大量的真实感。没有阴影,很容易误判物体的相对位置,并且光照可能看起来不真实,因为光线似乎可以直接穿过物体。

阴影是真实场景的重要视觉线索,但在交互式应用程序中高效地产生阴影可能具有挑战性。在实时图形中创建阴影最流行的技术之一是阴影贴图算法(也称为深度阴影)。在本章中,我们将探讨围绕阴影贴图算法的几个配方。我们将从基本算法开始,并在第一个配方中详细讨论它。然后,我们将探讨几种改进基本算法产生的阴影外观的技术。

我们还将探讨一种称为阴影体积的阴影的替代技术。阴影体积产生几乎完美的硬边缘阴影,但不适合创建具有柔和边缘的阴影。

使用阴影贴图渲染阴影

产生阴影最常见且最受欢迎的技术被称为阴影贴图。在其基本形式中,算法涉及两个遍历。在第一次遍历中,场景从光源的角度渲染。这次遍历的深度信息被保存到一个称为阴影贴图的纹理中。这个贴图将帮助提供从光源视角看物体可见性的信息。换句话说,阴影贴图存储了从光源到其可以看到的任何物体的距离(实际上是伪深度)。任何比贴图中存储的相应深度更接近光源的东西都会被照亮;其他所有东西都必须处于阴影中。

在第二次遍历中,场景以正常方式渲染,但每个片段的深度(从光源的角度看)首先与阴影贴图进行测试,以确定该片段是否处于阴影中。然后根据测试结果对片段进行不同的着色。如果片段处于阴影中,则仅使用环境光照进行着色;否则,以正常方式着色。

以下图像显示了使用基本阴影贴图技术产生的阴影示例:

图片

让我们详细查看算法的每个步骤。

第一步是创建阴影图。我们设置视图矩阵,使得我们渲染的场景就像相机位于光源的位置,并且朝向产生阴影的对象。我们设置投影矩阵,使得视图视锥体包围所有可能产生阴影的对象以及阴影将出现的地方。然后我们正常渲染场景,并将深度缓冲区中的信息存储在纹理中。这个纹理被称为阴影图(或简单地称为深度图)。我们可以将其(大致上)视为从光源到各种表面位置的距离集合。

技术上,这些是深度值,而不是距离。深度值不是一个真正的距离(从原点),但在深度测试的目的上可以大致如此处理。

以下图表示了基本阴影映射设置的示例。左侧图显示了光线的位置及其相关的透视视锥体。右侧图显示了相应的阴影图。阴影图中的灰度强度对应于深度值(越暗越近):

图片

一旦我们创建了阴影图并将该图存储在纹理中,我们再次从相机的视角渲染场景。这次,我们使用一个片段着色器,根据与阴影图的深度测试结果来着色每个片段。首先将片段的位置转换到光源的坐标系中,并使用光源的投影矩阵进行投影。然后结果进行偏置(为了获得有效的纹理坐标)并测试与阴影图。如果片段的深度大于阴影图中存储的深度,那么在片段和光源之间必须有一些表面。因此,该片段处于阴影中,并且仅使用环境光照进行着色。否则,片段必须对光源有清晰的视角,因此它被正常着色。

这里关键的部分是将片段的 3D 坐标转换为适合在阴影图中查找的坐标。由于阴影图只是一个 2D 纹理,我们需要坐标范围从零到一的点,这些点位于光线的视锥体内。光线视图矩阵将世界坐标中的点转换到光线的坐标系中。光线的投影矩阵将视锥体内的点转换到齐次裁剪坐标

这些被称为裁剪坐标,因为当位置在这些坐标中定义时,内置的裁剪功能就会发生。透视(或正交)视锥体内的点通过投影矩阵变换到以原点为中心、每边长度为二的(齐次)空间。这个空间被称为 规范视体积。术语 齐次 意味着在它们被第四个坐标除之前,这些坐标不一定被认为是真正的笛卡尔位置。有关齐次坐标的详细信息,请参阅您喜欢的计算机图形学教科书。

裁剪坐标中位置的 xy 分量大致是我们需要访问阴影图的部分。z 坐标包含我们可以用于与阴影图比较的深度信息。然而,在我们能够使用这些值之前,我们需要做两件事。首先,我们需要对它们进行偏差,使它们范围从零到一(而不是从 -1 到 1),其次,我们需要应用 透视除法(关于这一点稍后讨论)。

要将裁剪坐标的值转换为适合与阴影图一起使用的范围,我们需要将 xyz 坐标范围从零到一(对于光线视锥体内的点)。存储在 OpenGL 深度缓冲区(以及我们的阴影图中)的深度只是一个介于零到一之间的固定或浮点值(通常是)。零值对应于透视视锥体的近平面,一值对应于远平面上的点。因此,如果我们想用我们的 z 坐标与这个深度缓冲区进行准确比较,我们需要适当地缩放和转换它。

在裁剪坐标(经过透视除法后)中,z 坐标范围从 -1 到 1。是视口变换(以及其他事情)将深度转换为零到一的范围。顺便提一下,如果需要,我们可以通过 glDepthRange 函数配置视口变换,以便使用其他范围(例如 0 到 100)的深度值。

当然,xy 分量也需要在零到一之间进行偏差,因为这是纹理访问的适当范围。

我们可以使用以下 偏差 矩阵来改变我们的裁剪坐标:

这个矩阵将缩放和转换我们的坐标,使得 xyz 分量在经过透视除法后范围从 0 到 1。现在,将偏差矩阵与光视图 (V[l]) 和投影 (P[l]) 矩阵结合起来,我们得到以下方程,用于将世界坐标 (W) 中的位置转换为可用于阴影图访问的齐次位置 (Q):

最后,在我们能够直接使用 Q 的值之前,我们需要除以第四个 (w) 分量。这一步有时被称为 透视除法。这会将位置从齐次值转换为真正的笛卡尔位置,并且在使用透视投影矩阵时始终是必需的。

透视除法在光栅化之前由 OpenGL 管道自动执行。然而,由于我们正在处理一个未由管道变换的值,我们需要手动执行除法。

在以下方程中,我们将定义一个包含模型矩阵 (M) 的阴影矩阵 (S),这样我们就可以直接从对象坐标 (C) 转换(注意 W = MC,因为模型矩阵将对象坐标作为世界坐标):

图片

在这里,S 是阴影矩阵,是模型矩阵与所有先前矩阵的乘积:

图片

在这个菜谱中,为了保持简单和清晰,我们将只介绍基本的阴影映射算法,而不包括任何通常的改进。我们将在接下来的菜谱中在此基础上构建。在我们进入代码之前,我们应该注意,结果可能不会令人满意。这是因为基本的阴影映射算法存在显著的走样伪影。尽管如此,当与许多抗走样技术之一结合使用时,它仍然是一种有效的技术。我们将在接下来的菜谱中查看一些这些技术。

准备工作

位置应在顶点属性零中提供,法线在顶点属性一中提供。应声明并分配 ADS 着色模型的常量变量,以及标准变换矩阵的常量。ShadowMatrix 变量应设置为从对象坐标转换为阴影映射坐标的矩阵(前述方程中的 S)。

常量变量 ShadowMap 是阴影映射纹理的句柄,应分配给纹理单元零。

如何做...

要创建一个使用阴影映射技术创建阴影的 OpenGL 应用程序,请执行以下步骤。我们将首先设置一个 帧缓冲对象 (FBO) 来包含阴影映射纹理,然后继续到所需的着色器代码:

  1. 在主 OpenGL 程序中,设置一个仅包含深度缓冲区的 FBO。声明一个名为 shadowFBOGLuint 变量来存储此 framebuffer 的句柄。深度缓冲区存储应是一个纹理对象。您可以使用以下类似代码来完成此操作:
GLfloat border[]={1.0f,0.0f,0.0f,0.0f}; 

//The shadowmap texture 
GLuint depthTex; 
glGenTextures(1,&depthTex); 
glBindTexture(GL_TEXTURE_2D,depthTex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_DEPTH_COMPONENT24, 
               shadowMapWidth, shadowMapHeight); 
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, 
                GL_NEAREST); 
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, 
                GL_NEAREST); 
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S, 
                GL_CLAMP_TO_BORDER); 
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T, 
                GL_CLAMP_TO_BORDER); 
glTexParameterfv(GL_TEXTURE_2D,GL_TEXTURE_BORDER_COLOR, 
                 border); 

glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_MODE, 
                GL_COMPARE_REF_TO_TEXTURE); 
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_COMPARE_FUNC, 
                GL_LESS); 

//Assign the shadow map to texture unit 0 
glActiveTexture(GL_TEXTURE0); 
glBindTexture(GL_TEXTURE_2D,depthTex); 

//Create and set up the FBO 
glGenFramebuffers(1,&shadowFBO); 
glBindFramebuffer(GL_FRAMEBUFFER,shadowFBO); 
glFramebufferTexture2D(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT, 
                       GL_TEXTURE_2D,depthTex,0); 
GLenum drawBuffers[]={GL_NONE}; 
glDrawBuffers(1,drawBuffers); 
// Revert to the default framebuffer for now 
glBindFramebuffer(GL_FRAMEBUFFER,0); 
  1. 使用以下代码作为顶点着色器:
layout (location=0) in vec3 VertexPosition; 
layout (location=1) in vec3 VertexNormal; 

out vec3 Normal;     
out vec3 Position;    

// Coordinate to be used for shadow map lookup 
out vec4 ShadowCoord; 

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 MVP; 
uniform mat4 ShadowMatrix; 

void main() {
    Position = (ModelViewMatrix *  
                vec4(VertexPosition,1.0)).xyz; 
    Normal = normalize( NormalMatrix * VertexNormal ); 

    // ShadowMatrix converts from modeling coordinates 
    // to shadow map coordinates. 
    ShadowCoord =ShadowMatrix * vec4(VertexPosition,1.0); 

    gl_Position = MVP * vec4(VertexPosition,1.0); 
}
  1. 使用以下代码作为片段着色器:
// Declare any uniforms needed for your shading model 
uniform sampler2DShadow ShadowMap; 

in vec3 Position; 
in vec3 Normal; 
in vec4 ShadowCoord; 

layout (location = 0) out vec4 FragColor; 

vec3 diffAndSpec() {
   // Compute only the diffuse and specular components of 
   // the shading model. 
} 

subroutine void RenderPassType(); 
subroutine uniform RenderPassType RenderPass; 

subroutine (RenderPassType) 
void shadeWithShadow() {
  vec3 ambient = ...; // compute ambient component here 
  vec3 diffSpec = diffAndSpec(); 

  // Do the shadow-map look-up 
  float shadow = textureProj(ShadowMap, ShadowCoord); 

  // If the fragment is in shadow, use ambient light only. 
  FragColor = vec4(diffSpec * shadow + ambient, 1.0); 
} 
subroutine (RenderPassType) 
void recordDepth() {
    // Do nothing, depth will be written automatically 
} 

void main() { 
  // This will call either shadeWithShadow or recordDepth 
  RenderPass(); 
} 

在主 OpenGL 程序中渲染时,执行以下步骤。对于第一遍:

  1. 设置视口、视图和投影矩阵,使其适用于光源。

  2. 绑定包含阴影图的framebuffershadowFBO)。

  3. 清除深度缓冲区。

  4. 选择子例程recordDepth函数。

  5. 启用正面剔除。

  6. 绘制场景。

对于第二次传递:

  1. 选择适合场景的视口、视图和投影矩阵。

  2. 绑定到默认的 framebuffer。

  3. 禁用剔除(或切换到背面剔除)。

  4. 选择子例程函数shadeWithShadow

  5. 绘制场景。

它是如何工作的...

上述代码的第一部分演示了如何为我们的阴影图纹理创建一个 FBO。该 FBO 仅包含一个连接到其深度缓冲区附加的纹理。代码的前几行创建了阴影图纹理。纹理是通过使用具有GL_DEPTH_COMPONENT24内部格式的glTexStorage2D函数分配的。

我们在这里使用GL_NEAREST作为GL_TEXTURE_MAG_FILTERGL_TEXTURE_MIN_FILTER,尽管也可以使用GL_LINEAR,并且可能提供稍微更好的视觉效果。我们在这里使用GL_NEAREST是为了能够清楚地看到锯齿伪影,并且性能会略有提高。

接下来,将GL_TEXTURE_WRAP_*模式设置为GL_CLAMP_TO_BORDER。当一个片段被发现完全位于阴影图(光线的视锥体之外)之外时,该片段的纹理坐标将大于一或小于零。当这种情况发生时,我们需要确保这些点不被视为处于阴影中。当使用GL_CLAMP_TO_BORDER时,从纹理查找返回的值(对于 0-1 范围之外的坐标)将是边界值。默认的边界值是(0,0,0,0)。当纹理包含深度分量时,第一个分量被视为深度值。在这里,零值将不起作用,因为零深度对应于近平面上的点。因此,所有位于光视锥体之外的点都将被视为处于阴影中!相反,我们使用glTexParameterfv函数将边界颜色设置为(1,0,0,0),这对应于最大可能的深度。

下两个对glTexParameteri的调用影响特定于深度纹理的设置。第一个调用将GL_TEXTURE_COMPARE_MODE设置为GL_COMPARE_REF_TO_TEXTURE。当此设置启用时,纹理访问的结果是比较的结果,而不是从纹理中检索的颜色值。纹理坐标的第三个分量(p分量)与纹理中位置(s,t)的值进行比较。比较的结果作为一个单精度浮点值返回。所使用的比较函数由GL_TEXTURE_COMPARE_FUNC的值确定,该值在下一行设置。在这种情况下,我们将其设置为GL_LESS,这意味着如果纹理坐标的p值小于存储在(s,t)的值,则结果将是1.0。(其他选项包括GL_LEQUALGL_ALWAYSGL_GEQUAL等。)

接下来的几行创建并设置了 FBO。使用 glFramebufferTexture2D 函数将阴影贴图纹理附加到 FBO 上作为深度附加。有关 FBO 的更多详细信息,请参阅第五章 渲染到纹理 的配方,使用纹理

顶点着色器相当简单。它将顶点位置和法线到相机坐标转换,并通过输出变量 PositionNormal 将它们传递到片段着色器。顶点位置也使用 ShadowMatrix 转换为阴影贴图坐标。这就是我们在上一节中提到的矩阵 S。它将位置从对象坐标转换为阴影坐标。结果通过 ShadowCoord 输出变量发送到片段着色器。

与往常一样,位置也被转换为裁剪坐标并分配给内置的 gl_Position 输出变量。

在片段着色器中,我们为每个遍历提供不同的功能。在主函数中,我们调用 RenderPass,这是一个子例程均匀函数,它将调用 recordDepthshadeWithShadow。对于第一次遍历(阴影贴图生成),执行 recordDepth 子例程函数。这个函数什么都不做!这是因为我们只需要将深度写入深度缓冲区。OpenGL 会自动完成这项工作(假设顶点着色器正确设置了 gl_Position),因此片段着色器没有需要执行的操作。

在第二次遍历期间,执行 shadeWithShadow 函数。我们计算着色模型的漫反射分量并将其结果存储在 ambient 变量中。然后,我们计算漫反射和镜面分量并将它们存储在 diffuseAndSpec 变量中。

下一步是阴影映射算法的关键。我们使用内置的 textureProj 纹理访问函数来访问 ShadowMap 阴影贴图纹理。在用纹理坐标访问纹理之前,textureProj 函数会将纹理坐标的前三个分量除以第四个分量。记住,这正是将齐次位置(ShadowCoord)转换为真实笛卡尔位置所需要的。

在进行这个视角分割之后,textureProj 函数将使用结果来访问纹理。由于这个纹理的采样器类型是 sampler2DShadow,它被视为包含深度值的纹理,并且它不是从纹理中返回一个值,而是返回一个比较的结果。ShadowCoord 的前两个分量用于访问纹理内的深度值。然后,该值与 ShadowCoord 的第三个分量的值进行比较。

当使用类型为 sampler2DShadow 的采样器时,我们需要使用 vec3 作为查找坐标,因为我们需要一个 2D 位置和一个深度。

GL_NEAREST是插值模式(正如我们案例中那样)时,结果将是1.00.0。由于我们将比较函数设置为GL_LESS,这将返回1.0,但仅当ShadowCoord的第三个分量的值小于在采样位置的深度纹理中的值时。然后将此结果存储在shadow变量中。最后,我们将一个值赋给输出变量FragColor。阴影图比较的结果(shadow)乘以漫反射和镜面反射分量,然后将结果添加到环境分量。如果shadow0.0,这意味着比较失败,意味着在片段和光源之间有东西。因此,片段只被环境光着色。否则,shadow1.0,片段被所有三个着色分量着色。

在渲染阴影图时,请注意我们剪裁了正面。这是为了避免当正面被包含在阴影图中时可能发生的 z 冲突。请注意,这仅在我们网格完全封闭的情况下有效。如果背面暴露,您可能需要使用另一种技术(使用glPolygonOffset)来避免这种情况。我将在下一节中详细谈谈这一点。

还有更多...

阴影映射技术存在许多具有挑战性的问题。让我们看看其中最紧迫的几个。

混叠

如前所述,此算法在阴影边缘经常出现严重的混叠伪影。这是因为当进行深度比较时,阴影图实际上被投影到场景中。如果投影导致图放大,就会出现混叠伪影。

以下图像显示了阴影边缘的混叠:

最简单的解决方案是简单地增加阴影图的大小。然而,由于内存、CPU 速度或其他限制,这可能不可行。有许多技术可以提高阴影映射算法产生的阴影质量,例如分辨率匹配阴影图、级联阴影图、方差阴影图、透视阴影图等。在接下来的菜谱中,我们将探讨一些帮助您软化并抗锯齿阴影边缘的方法。

仅对阴影图渲染背面

在创建阴影图时,我们只渲染背面。这是因为如果我们渲染正面,某些面上的点将与阴影图的深度几乎相同,这可能导致应该完全照亮的面之间光与影的波动。以下图像显示了这种效果的一个示例:

由于导致此问题的面中大多数是面向光源的面,我们通过仅在阴影图传递期间渲染背面来避免大部分问题。当然,这只有在你的网格完全封闭的情况下才能正确工作。如果不是这样,可以使用glPolygonOffset来帮助解决这个问题,通过将几何体的深度从阴影图中的深度偏移。实际上,即使在生成阴影图时仅渲染背面,类似的艺术效果也可能出现在远离光源的面上(阴影图中的背面,但摄像机视角的正面)。因此,在生成阴影图时,经常结合正面剔除和glPolygonOffset

参见

  • 示例代码中的chapter08/sceneshadowmap.cpp文件

  • 渲染到纹理配方在第五章,使用纹理

  • 使用 PCF 抗走样阴影边缘配方

  • 使用随机采样创建软阴影边缘配方

使用 PCF 抗走样阴影边缘

处理阴影边缘走样的一种简单且常见的技术被称为百分比更近过滤PCF)。这个名字来源于在片段周围采样区域并确定该区域中靠近光源(在阴影中)的部分所占的百分比。然后,这个百分比被用来缩放片段接收的着色量(漫反射和镜面反射)。整体效果是模糊阴影的边缘。

基本技术最早由 Reeves 等人于 1987 年发表在一篇论文中(SIGGRAPH 会议录,第 21 卷,第 4 期,1987 年 7 月)。该概念涉及将片段的扩展范围转换到阴影空间,在该区域内采样几个位置,并计算比片段深度更近的百分比。然后,该结果被用来衰减着色。如果增加此滤波区域的大小,它可以产生模糊阴影边缘的效果。

PCF 算法的一种常见变体仅涉及在阴影图中采样附近固定数量的纹理元素。这些纹理元素中靠近光源的比例被用来衰减阴影的着色。这会产生模糊阴影边缘的效果。虽然结果可能不是物理上准确的,但人眼看起来并不令人反感。

以下图像展示了使用 PCF(右)和不使用 PCF(左)渲染的阴影。注意,右图中的阴影边缘更模糊,走样现象不太明显:

在这个配方中,我们将使用后一种技术,并在阴影图中片段位置周围采样固定数量的纹理元素。我们将计算结果的平均值,并使用该结果来缩放漫反射和镜面反射分量。

我们将利用 OpenGL 对 PCF 的内建支持,通过在深度纹理上使用线性过滤来实现。当使用这种纹理进行线性过滤时,硬件可以自动采样四个附近的纹理元素(执行四个深度比较)并平均结果(这些细节的实现取决于具体实现)。因此,当启用线性过滤时,textureProj函数的结果可以在 0.0 和 1.0 之间。

我们还将利用纹理访问偏移的内建函数。OpenGL 提供了textureProjOffset纹理访问函数,它有一个第三个参数(偏移量),在查找/比较之前添加到纹理元素坐标中。

准备工作

从之前使用阴影图渲染阴影配方中提供的着色器和 FBO 开始。我们只需对那里展示的代码进行一些小的修改。

如何实现...

要将 PCF 技术添加到阴影映射算法中,请按照以下步骤操作:

  1. 在设置阴影图 FBO 时,确保使用线性过滤在深度纹理上。用以下代码替换相应的行:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,  
                GL_LINEAR); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,      
                GL_LINEAR); 
  1. 在片段着色器中的shadeWithShadow函数中,使用以下代码:
subroutine (RenderPassType) 
void shadeWithShadow() {
  vec3 ambient = vec3(0.2); 
  vec3 diffSpec = diffAndSpec(); 

  // The sum of the comparisons with nearby texels 
  float sum = 0; 

  // Sum contributions from texels around ShadowCoord 
  sum += textureProjOffset(ShadowMap, ShadowCoord,  
                           ivec2(-1,-1)); 
  sum += textureProjOffset(ShadowMap, ShadowCoord,  
                           ivec2(-1,1)); 
  sum += textureProjOffset(ShadowMap, ShadowCoord,  
                           ivec2(1,1)); 
  sum += textureProjOffset(ShadowMap, ShadowCoord,  
                           ivec2(1,-1)); 
  float shadow = sum * 0.25; 

  FragColor = vec4(ambient + diffSpec * shadow,1.0); 
} 

它是如何工作的...

第一步是在阴影图纹理上启用线性过滤。当启用时,OpenGL 驱动程序可以在纹理内重复四个附近的纹理元素的深度比较。四个比较的结果将被平均并返回。

在片段着色器中,我们使用textureProjOffset函数来采样围绕ShadowCoord最近的纹理元素(对角线方向)的四个纹理元素。第三个参数是偏移量。在查找发生之前,它被添加到纹理元素的坐标(而不是纹理坐标)中。

由于启用了线性过滤,每次查找都会采样额外的四个纹理元素,总共 16 个纹理元素。然后将结果平均并存储在变量 shadow 中。

如前所述,shadow的值用于衰减光照模型的漫反射和镜面反射分量。

还有更多...

Pixar 的 Fabio Pellacini 撰写了一份关于 PCF 技术的优秀调查报告,可以在由 Randima Fernando 编辑的《GPU Gems》的第十一章阴影图抗锯齿中找到,Addison-Wesley Professional,2004 年。如果需要更多细节,我强烈推荐阅读这一章,虽然篇幅不长,但信息丰富。

由于其简单性和效率,PCF 技术是抗锯齿由阴影映射产生的阴影边缘的极其常见的方法。由于它具有模糊边缘的效果,它也可以用来模拟柔和的阴影。然而,必须随着模糊边缘(半影)的大小增加样本数量,以避免某些伪影。这当然可能成为计算上的障碍。在下一个配方中,我们将探讨一种通过随机采样更大区域来产生柔和阴影的技术。

半影是阴影区域中只有部分光源被遮挡的部分。

参见

  • 示例代码中的chapter08/scenepcf.cpp文件

  • 使用阴影图渲染阴影的食谱

使用随机采样创建软阴影边缘

基本的阴影映射算法与 PCF 结合可以产生具有软边缘的阴影。然而,如果我们希望具有较大宽度(以近似真实软阴影)的模糊边缘,则需要大量的样本。此外,当着色位于大阴影中心或完全在阴影之外的片段时,会有大量的无效工作量。对于这些片段,所有附近的阴影图纹理单元都将评估为相同的值。因此,访问和平均这些纹理单元的工作基本上是无效的。

本食谱中介绍的技术基于由 Matt Pharr 和 Randima Fernando 编辑的GPU Gems 2中出版的一章,Addison-Wesley Professional,2005 年。(第十七章由 Yury Uralsky 撰写。)它提供了一种方法,可以解决上述两个问题,以创建具有各种宽度的软边缘阴影,同时避免在阴影内部和外部区域不必要的纹理访问。

基本思想如下:

  • 我们不是使用一组固定的偏移量在片段位置(阴影图空间)周围采样纹理单元,而是使用随机的圆形偏移模式。

  • 此外,我们首先对圆的边缘进行采样,以确定片段是否位于完全在阴影内部或外部的区域

以下图表是可能的阴影图样本集的可视化。十字准线的中心是片段在阴影图中的位置,每个x是一个样本。样本在围绕片段位置的圆形网格内随机分布(每个网格单元一个样本):

图片

此外,我们通过一组预计算的样本模式来改变样本位置。我们在渲染之前计算随机样本偏移并将其存储在纹理中。然后,在片段着色器中,首先通过访问偏移纹理来获取一组偏移量,并使用这些偏移量来改变片段在阴影图中的位置。结果以类似于基本 PCF 算法的方式平均在一起。以下图表显示了使用 PCF 算法(左)和本食谱中描述的随机采样技术(右)产生的阴影之间的差异:

图片

我们将偏移量存储在一个三维纹理中(n x n x d)。前两个维度的大小是任意的,第三个维度包含偏移量。每个(s,t)位置包含一个大小为d的随机偏移量列表,这些偏移量打包成一个 RGBA 颜色。纹理中的每个 RGBA 颜色包含两个 2D 偏移量。RG通道包含第一个偏移量,而BA通道包含第二个。因此,每个(s,t)位置包含总共2d*个偏移量。

例如,位置(1, 1, 3)包含位置(1,1)的第六个和第七个偏移量。给定(s,t)处的所有值构成一个完整的偏移量集。我们将根据片段的屏幕坐标旋转纹理。偏移纹理中的位置将由屏幕坐标除以纹理大小的余数确定。例如,如果片段的坐标是(10.0,10.0)且纹理的大小是(4,4),那么我们使用位于偏移纹理位置(2,2)的偏移量集。

准备工作

使用阴影图渲染阴影配方中提供的代码开始。需要设置三个额外的统一变量。它们如下:

  • OffsetTexSize:这给出了偏移纹理的宽度、高度和深度。注意,深度与每个片段的样本数除以二相同。

  • OffsetTex:这是包含偏移纹理的纹理单元的句柄。

  • Radius:这是以像素为单位的模糊半径除以阴影图纹理的大小(假设是正方形阴影图)。这可以被认为是阴影的柔和度。

如何操作...

要修改阴影映射算法并使用这种随机采样技术,请执行以下步骤。我们将在主 OpenGL 程序中构建偏移纹理,并在片段着色器中使用它:

  1. 在主 OpenGL 程序中使用以下代码创建偏移纹理。这只需要在程序初始化期间执行一次:
void buildOffsetTex(int size, int samplesU, int samplesV) {
  int samples = samplesU * samplesV; 
  int bufSize = size * size * samples * 2; 
  float *data = new float[bufSize]; 

  for( int i = 0; i< size; i++ ) { 
    for(int j = 0; j < size; j++ ) { 
      for( int k = 0; k < samples; k += 2 ) { 
        int x1,y1,x2,y2; 
        x1 = k % (samplesU); 
        y1 = (samples - 1 - k) / samplesU; 
        x2 = (k+1) % samplesU; 
        y2 = (samples - 1 - k - 1) / samplesU; 

        glm::vec4 v; 
        // Center on grid and jitter 
        v.x = (x1 + 0.5f) + jitter(); 
        v.y = (y1 + 0.5f) + jitter(); 
        v.z = (x2 + 0.5f) + jitter(); 
        v.w = (y2 + 0.5f) + jitter(); 
        // Scale between 0 and 1 
        v.x /= samplesU; 
        v.y /= samplesV; 
        v.z /= samplesU; 
        v.w /= samplesV; 
        // Warp to disk 
        int cell = ((k/2) * size * size + j *  
                    size + i) * 4; 
        data[cell+0] = glm::sqrt(v.y) * glm::cos(glm::two_pi<float>
        ()*v.x); 
        data[cell+1] = glm::sqrt(v.y) * glm::sin(glm::two_pi<float>
        ()*v.x); 
        data[cell+2] = glm::sqrt(v.w) * glm::cos(glm::two_pi<float>
        ()*v.z); 
        data[cell+3] = glm::sqrt(v.w) * glm::sin(glm::two_pi<float>
        ()*v.z); 
      } 
    } 
  } 

  glActiveTexture(GL_TEXTURE1); 
  GLuint texID; 
  glGenTextures(1, &texID); 

  glBindTexture(GL_TEXTURE_3D, texID); 
  glTexStorage3D(GL_TEXTURE_3D, 1, GL_RGBA32F, size, size, 
                 samples/2); 
  glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, size, size,  
                 samples/2, GL_RGBA, GL_FLOAT, data); 
  glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER,  
                    GL_NEAREST); 
  glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER,  
                    GL_NEAREST); 

  delete [] data; 
} 

// Return random float between -0.5 and 0.5 
float jitter() { 
  static std::default_random_engine generator;
  static std::uniform_real_distribution<float> distrib(-0.5f, 0.5f); 
  return distrib(generator); 
}
  1. 将以下统一变量添加到片段着色器中:
uniform sampler3D OffsetTex; 
uniform vec3 OffsetTexSize; // (width, height, depth) 
uniform float Radius;
  1. 在片段着色器中使用以下代码为shadeWithShadow函数:
subroutine (RenderPassType) 
void shadeWithShadow() {
  vec3 ambient = vec3(0.2); 
  vec3 diffSpec = diffAndSpec(); 

  ivec3 offsetCoord; 
  offsetCoord.xy = ivec2( mod( gl_FragCoord.xy,  
                         OffsetTexSize.xy ) ); 

  float sum = 0.0; 
  int samplesDiv2 = int(OffsetTexSize.z); 
  vec4 sc = ShadowCoord; 

  for( int i = 0 ; i< 4; i++ ) { 
    offsetCoord.z = i; 
    vec4 offsets = texelFetch(OffsetTex,offsetCoord,0) *  
                       Radius * ShadowCoord.w; 

    sc.xy = ShadowCoord.xy + offsets.xy; 
    sum += textureProj(ShadowMap, sc); 
    sc.xy = ShadowCoord.xy + offsets.zw; 
    sum += textureProj(ShadowMap, sc); 
  } 
  float shadow = sum / 8.0; 

  if( shadow != 1.0 && shadow != 0.0 ) { 
    for( int i = 4; i< samplesDiv2; i++ ) { 
      offsetCoord.z = i; 
      vec4 offsets =  
        texelFetch(OffsetTex, offsetCoord,0) * 
                   Radius * ShadowCoord.w; 

      sc.xy = ShadowCoord.xy + offsets.xy; 
      sum += textureProj(ShadowMap, sc); 
      sc.xy = ShadowCoord.xy + offsets.zw; 
      sum += textureProj(ShadowMap, sc); 
    } 
    shadow = sum / float(samplesDiv2 * 2.0); 
  } 
  FragColor = vec4(diffSpec * shadow + ambient, 1.0); 
}

工作原理...

buildOffsetTex函数创建我们的随机偏移量的三维纹理。第一个参数texSize定义了纹理的宽度和高度。为了创建前面的图像,我使用了8的值。第二个和第三个参数samplesUsamplesV定义了uv方向上的样本数。我分别使用了48,总共 32 个样本。uv方向是任意轴,用于定义偏移量的网格。为了理解这一点,请看以下图表:

图片

偏移量最初被定义为位于大小为samplesU x samplesV(前图中为 4 x 4)的网格中心。偏移量的坐标被缩放,使得整个网格适合单位立方体(边长1),原点位于左下角。然后,每个样本从其位置随机抖动到网格单元内的随机位置。最后,抖动的偏移量被扭曲,使其围绕原点并位于右侧所示的圆形网格内。

最后一步可以通过使用v坐标作为原点的距离,以及使用u坐标作为从 0 到 360 度缩放的角来实现。以下方程应该可以做到这一点:

图片

在这里,w是扭曲坐标。我们剩下的是一组围绕原点的偏移量,其最大距离为 1.0。此外,我们还生成数据,使得第一个样本是围绕圆的外边缘的样本,向内移动到中心。这将帮助我们避免在完全在阴影内部或外部工作时进行过多的采样。

当然,我们也以这种方式打包样本,使得单个 texel 包含两个样本。这并非绝对必要,但这样做是为了节省内存空间。然而,这确实使得代码变得更加复杂。

在片段着色器中,我们首先分别计算阴影模型的漫反射和镜面反射分量之外的漫反射分量。我们根据片段的屏幕坐标(gl_FragCoord)访问偏移量纹理。我们这样做是通过取片段位置和偏移量纹理大小的模。结果是存储在offsetCoord的前两个分量中。这将为我们提供每个附近像素的不同偏移量集。offsetCoord的第三个分量将用于访问一对样本。样本的数量是纹理深度除以二。这存储在samplesDiv2中。我们使用texelFetch函数访问样本。这个函数允许我们使用整数 texel 坐标而不是通常的 0-1 范围内的归一化纹理坐标来访问 texel。

偏移量被检索并乘以Radius以及ShadowCoordw分量。乘以Radius只是简单地缩放偏移量,使其范围从0.0Radius。通常,我们希望半径代表纹理空间中的一个小区域,因此像5/width(其中width是阴影图的宽度)这样的值是合适的。我们乘以w分量,因为ShadowCoord仍然是一个齐次坐标,我们的目标是使用偏移量来平移ShadowCoord。为了正确地做到这一点,我们需要将偏移量乘以w分量。另一种思考方式是,当进行透视除法时,w分量将被消除。

接下来,我们使用偏移来转换ShadowCoord并访问阴影图,使用textureProj进行深度比较。我们对存储在 texel 中的两个样本都这样做,一次用于偏移的前两个分量,再次用于后两个分量。结果被添加到sum中。

第一个循环对前八个样本重复此操作。如果前八个样本都是0.01.0,则我们假设所有样本都将相同(样本区域完全在或不在阴影中)。在这种情况下,我们跳过其余样本的评估。否则,我们评估后续样本并计算整体平均值。最后,得到的平均值(阴影)用于衰减光照模型的漫反射和镜面反射分量。

还有更多...

使用包含一组随机偏移的小纹理可以帮助比使用具有恒定偏移集的标准 PCF 技术更好地模糊阴影边缘。然而,由于纹理是有限的,偏移每几个像素就会重复,因此在阴影边缘仍可能出现重复图案的伪影。我们可以通过在片段着色器中也使用偏移的随机旋转来改进这一点,或者简单地计算着色器内的随机偏移。

还应注意的是,这种边缘模糊可能并不适用于所有阴影边缘。例如,直接相邻于遮挡物、即产生阴影的边缘不应被模糊。这些可能并不总是可见的,但在某些情况下可能会变得可见,例如当遮挡物是一个窄物体时。这种效果是使物体看起来像是在表面上方悬浮。不幸的是,对此并没有简单的解决方案。

参见

  • 示例代码中的chapter08/scenejitter.cpp文件

  • 使用阴影图渲染阴影的配方

使用阴影体积和几何着色器创建阴影

正如我们在前面的配方中发现的,阴影图的主要问题之一是走样。这个问题本质上归结为这样一个事实:我们在渲染场景时,对阴影图(图)的采样频率(分辨率)与我们使用的频率不同。为了最小化走样,我们可以模糊阴影边缘(如前述配方所示),或者尝试以更接近投影屏幕空间中相应分辨率的频率采样阴影图。有许多技术有助于后者;有关更多详细信息,我推荐阅读《实时阴影》这本书。

用于阴影生成的另一种技术称为阴影体积。阴影体积方法完全避免了困扰阴影图的走样问题。使用阴影体积,你可以得到像素完美的硬阴影,而没有阴影图中的走样伪影。以下图像显示了使用阴影体积技术生成的阴影场景:

阴影体积技术通过使用模板缓冲区来屏蔽阴影区域来工作。我们通过绘制实际阴影体积的边界(更多内容将在下文中介绍)来实现这一点。阴影体积是光源被物体遮挡的空间区域。例如,以下图表显示了三角形(左侧)和球体(右侧)的阴影体积表示:

图片

阴影体积的边界由通过将物体的边缘延伸到光源形成的四边形组成。对于一个单独的三角形,边界将包括从每个边缘延伸出的三个四边形,以及每个端点的三角形盖。一个盖是三角形本身,另一个放置在离光源一定距离的地方。对于由许多三角形组成的物体,例如前面的球体,体积可以通过所谓的轮廓边缘来定义。这些是位于或接近阴影体积与被光照物体部分之间的边。一般来说,轮廓边缘与一个面向光源的三角形和另一个背向光源的三角形相邻。要绘制阴影体积,需要找到所有的轮廓边缘并为每个边缘绘制扩展的四边形。体积的盖可以通过创建一个包含所有轮廓边缘点的闭合多边形(或三角形扇)来确定,同样在体积的远端也是如此。

阴影体积技术的工作方式如下。想象一条从相机位置发出并穿过近平面上的一个像素的射线。假设我们跟随那条射线并跟踪一个计数器,每次它进入阴影体积时计数器增加,每次它离开阴影体积时计数器减少。如果我们停止计数直到碰到表面,那么如果我们的计数非零,该表面的点被遮挡(在阴影中),否则,该表面被光源照亮。以下图表展示了这个想法的一个例子:

图片

大约水平的线代表一个正在接收阴影的表面。数字代表每个相机射线的计数器。例如,最右侧的射线值为+1,因为这个值是因为射线在从相机到表面的过程中进入了两个体积并离开了一个体积:1 + 1 - 1 = 1。最右侧的射线在表面上的值为0,因为它进入了并离开了两个阴影体积:1 + 1 - 1 - 1 = 0

理论上听起来不错,但我们如何在 OpenGL 中追踪光线?好消息是,我们不必这样做。模板缓冲区正好提供了我们所需要的东西。使用模板缓冲区,我们可以根据是否将正面或背面渲染到每个像素中来增加/减少每个像素的计数器。因此,我们可以绘制所有阴影体积的边界,然后对于每个像素,当正面渲染到该像素时增加模板缓冲区的计数器,当它是背面时减少。

关键在于意识到渲染图像中的每个像素代表一个视线(如前图所示)。因此,对于给定的像素,模板缓冲区中的值就是我们实际通过该像素追踪光线时得到的值。深度测试有助于在达到表面时停止追踪。

这只是一个关于阴影体积的快速介绍;完整的讨论超出了本书的范围。更多细节,可以参考 Eisemann 等人所著的实时阴影

在这个配方中,我们将使用几何着色器绘制阴影体积。而不是在 CPU 端计算阴影体积,我们将正常渲染几何体,并让几何着色器生成阴影体积。在第七章的使用几何和细分着色器配方中,我们看到了几何着色器如何为每个三角形提供相邻信息。有了相邻信息,我们可以确定三角形是否有轮廓边缘。如果一个三角形面向光源,而相邻的三角形背向光源,那么共享的边可以被认为是轮廓边缘,并用于创建阴影体积的多边形。

整个过程分为三个步骤。具体如下:

  1. 正常渲染场景,但将着色后的颜色写入两个独立的缓冲区。我们将存储环境分量在一个缓冲区中,而将漫反射和镜面分量在另一个缓冲区中。

  2. 设置模板缓冲区,使得模板测试始终通过,正面导致增加,背面导致减少。使深度缓冲区为只读,并仅渲染投射阴影的对象。在这个过程中,几何着色器将生成阴影体积,并且只有阴影体积将被渲染到片段着色器中。

  3. 设置模板缓冲区,使得当值为零时测试成功。绘制一个填充整个屏幕的四边形,并在模板测试成功时将第一步中两个缓冲区的值合并。

这只是一个高级视图,其中有很多细节。让我们在下一节中逐一介绍。

准备工作

我们将首先创建我们的缓冲区。我们将使用一个具有深度附加组件和两个颜色附加组件的帧缓冲对象。环境分量可以存储在渲染缓冲区中(而不是纹理),因为我们将其快速复制(复制)到默认帧缓冲区,而不是作为纹理读取它。漫反射+镜面反射分量将存储在纹理中。

创建环境缓冲区(ambBuf)、深度缓冲区(depthBuf)和纹理(diffSpecTex),然后设置 FBO:

glGenFramebuffers(1, &colorDepthFBO); 
glBindFramebuffer(GL_FRAMEBUFFER, colorDepthFBO); 
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,  
                          GL_RENDERBUFFER, depthBuf); 
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,  
                          GL_RENDERBUFFER, ambBuf); 
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1,  
                       GL_TEXTURE_2D, diffSpecTex, 0); 

设置绘制缓冲区,以便我们可以写入颜色附加组件:

GLenum drawBuffers[] = {GL_COLOR_ATTACHMENT0,  
                        GL_COLOR_ATTACHMENT1}; 
glDrawBuffers(2, drawBuffers); 

如何实现...

对于第一次遍历,启用我们之前设置的帧缓冲对象,并正常渲染场景。在片段着色器中,将环境分量和漫反射+镜面反射分量发送到不同的输出:

layout( location = 0 ) out vec4 Ambient; 
layout( location = 1 ) out vec4 DiffSpec; 

void shade( ) {
  // Compute the shading model, and separate out the ambient 
  // component. 
  Ambient = ...;   // Ambient 
  DiffSpec = ...;  // Diffuse + specular 
} 
void main() { shade(); } 

在第二次遍历中,我们将渲染我们的阴影体积。我们希望设置模板缓冲区,使得测试总是成功,并且正面导致增加,背面导致减少:

glClear(GL_STENCIL_BUFFER_BIT); 
glEnable(GL_STENCIL_TEST); 
glStencilFunc(GL_ALWAYS, 0, 0xffff); 
glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_KEEP, GL_INCR_WRAP); 
glStencilOpSeparate(GL_BACK, GL_KEEP, GL_KEEP, GL_DECR_WRAP);

在这个遍历中,我们还想使用第一次遍历的深度缓冲区,但我们想使用默认的帧缓冲区,因此我们需要从第一次遍历中使用的 FBO 复制深度缓冲区。我们还将复制颜色数据,其中应包含环境分量:

glBindFramebuffer(GL_READ_FRAMEBUFFER, colorDepthFBO);  
glBindFramebuffer(GL_DRAW_FRAMEBUFFER,0); 
glBlitFramebuffer(0,0,width,height,0,0,width,height, 
           GL_DEPTH_BUFFER_BIT|GL_COLOR_BUFFER_BIT, GL_NEAREST); 

我们不希望在这次遍历中写入深度缓冲区或颜色缓冲区,因为我们的唯一目标是更新模板缓冲区,所以我们将禁用对这些缓冲区的写入:

glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);  
glDepthMask(GL_FALSE); 

接下来,我们使用相邻信息渲染产生阴影的对象。在几何着色器中,我们确定轮廓边缘,并只输出定义阴影体积边界的四边形:

layout( triangles_adjacency ) in; 
layout( triangle_strip, max_vertices = 18 ) out; 

in vec3 VPosition[]; 
in vec3 VNormal[]; 

uniform vec4 LightPosition;  // Light position (eye coords) 
uniform mat4 ProjMatrix;     // Proj. matrix (infinite far plane) 

bool facesLight( vec3 a, vec3 b, vec3 c ) {
  vec3 n = cross( b - a, c - a ); 
  vec3 da = LightPosition.xyz - a; 
  vec3 db = LightPosition.xyz - b; 
  vec3 dc = LightPosition.xyz - c; 
  return dot(n, da) > 0 || dot(n, db) > 0 || dot(n, dc) > 0;  
} 

void emitEdgeQuad( vec3 a, vec3 b ) { 
  gl_Position = ProjMatrix * vec4(a, 1); 
  EmitVertex();   
  gl_Position = ProjMatrix * vec4(a - LightPosition.xyz, 0); 
  EmitVertex(); 
  gl_Position = ProjMatrix * vec4(b, 1); 
  EmitVertex(); 
  gl_Position = ProjMatrix * vec4(b - LightPosition.xyz, 0); 
  EmitVertex(); 
  EndPrimitive(); 
} 

void main() {
  if( facesLight(VPosition[0], VPosition[2], VPosition[4]) ) { 
    if( ! facesLight(VPosition[0],VPosition[1],VPosition[2]) )  
       emitEdgeQuad(VPosition[0],VPosition[2]); 
    if( ! facesLight(VPosition[2],VPosition[3],VPosition[4]) )  
       emitEdgeQuad(VPosition[2],VPosition[4]); 
    if( ! facesLight(VPosition[4],VPosition[5],VPosition[0]) )  
       emitEdgeQuad(VPosition[4],VPosition[0]); 
  } 
} 

在第三次遍历中,我们将设置我们的模板缓冲区,使得测试仅在缓冲区中的值等于零时通过:

glStencilFunc(GL_EQUAL, 0, 0xffff); 
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); 

我们希望启用混合,以便当模板测试成功时,我们的环境分量与漫反射+镜面反射结合:

glEnable(GL_BLEND);           
glBlendFunc(GL_ONE,GL_ONE); 

在这次遍历中,我们只绘制一个填充屏幕的四边形,并输出漫反射+镜面反射值。如果模板测试成功,该值将与环境分量结合,该分量已经在缓冲区中(我们之前使用glBlitFramebuffer复制过来):

layout(binding = 0) uniform sampler2D DiffSpecTex; 
layout(location = 0) out vec4 FragColor; 

void main() { 
  vec4 diffSpec = texelFetch(DiffSpecTex, ivec2(gl_FragCoord), 0); 
  FragColor = vec4(diffSpec.xyz, 1); 
} 

它是如何工作的...

第一次遍历相当直接。我们正常绘制整个场景,除了我们将环境颜色与漫反射和镜面反射颜色分开,并将结果发送到不同的缓冲区。

第二次遍历是算法的核心。在这里,我们只渲染产生阴影的对象,并让几何着色器生成阴影体积。多亏了几何着色器,我们实际上并没有渲染产生阴影的对象,而只是渲染了阴影体积。然而,在这个遍历之前,我们需要做一些设置。我们使用glStencilOpSeparate设置模板测试,以便在渲染前表面时递增,在渲染后表面时递减,并且使用glStencilFunc配置模板测试始终成功。我们还使用glBlitFramebuffer将深度缓冲区和(环境)颜色缓冲区从第一次遍历中使用的 FBO 复制过来。由于我们只想渲染不被几何体遮挡的阴影体积,我们使用glDepthMask使深度缓冲区只读。最后,我们使用glColorMask禁用颜色缓冲区的写入,因为我们不希望在这个遍历中错误地覆盖任何内容。

几何着色器负责生成轮廓阴影体积。由于我们正在使用邻接信息进行渲染(参见第七章中关于使用几何和细分着色器的配方“使用几何着色器绘制轮廓线”,[fab663d4-e210-417c-aa3b-2c4c307ec913.xhtml],使用几何和细分着色器),几何着色器可以访问定义当前正在渲染的三角形及其三个相邻三角形的六个顶点。顶点编号从 0 到 5,在本例中通过名为VPosition的输入数组提供。顶点 0、2 和 4 定义当前三角形,其余的顶点定义相邻的三角形,如下面的图所示:

图片

几何着色器首先测试主三角形(024)是否面向光源。我们通过计算三角形的法线(n)和每个顶点到光源的向量来完成。然后,我们计算 n 与每个光源方向向量(dadb,和dc)的点积。如果这三个中的任何一个为正,则三角形面向光源。如果我们发现三角形(024)面向光源,那么我们就以相同的方式测试相邻的每个三角形。如果一个相邻的三角形不面向光源,那么它们之间的边就是轮廓边,可以用作阴影体积的面的边。

我们在emitEdgeQuad函数中创建一个阴影体积面。点ab定义了轮廓边缘,阴影体积面的一个边缘。面的其他两个顶点通过从光源延伸ab来确定。在这里,我们使用一个由齐次坐标启用的数学技巧。我们通过在扩展顶点的w坐标中使用零来将面延伸到无穷远。这实际上定义了一个齐次向量,有时称为无穷远点。xyz坐标定义了一个指向光源方向上的向量,而w值被设置为0。最终结果是,我们得到了一个延伸到无穷远、远离光源的四边形。

这只会在我们使用一个可以考虑到以这种方式定义的点的修改后的投影矩阵时才能正常工作。本质上,我们想要一个远平面设置为无穷远的投影矩阵。GLM 通过infinitePerspective函数提供了这样的投影矩阵。

我们在这里不需要担心绘制阴影体积的端盖。我们不需要远端的端盖,因为我们已经使用了之前描述的齐次技巧,物体本身将作为近端的端盖。

在第三次也是最后一次遍历中,我们使用glStencilFunc将模板测试重置为当模板缓冲区中的值等于零时通过。在这里,当模板测试成功时,我们希望将环境光与漫反射+镜面反射颜色相加,因此我们启用混合,并将源和目标混合函数设置为GL_ONE。我们只渲染一个填充整个屏幕的四边形,并输出包含我们的漫反射+镜面反射颜色的纹理中的值。模板测试将负责丢弃处于阴影中的片段,OpenGL 的混合支持将输出与通过测试的片段的环境光混合。(记住,我们之前使用glBlitFramebuffer复制了环境光。)

还有更多...

这里描述的技术通常被称为Z-pass 技术。它有一个致命的缺陷。如果相机位于阴影体积内,这种技术就会失效,因为模板缓冲区中的计数至少会错一位。一个常见的解决方案是基本上反转问题,并从无穷远处向视点追踪一条射线。这被称为Z-fail 技术Carmack 的逆向

这里的失败通过指的是在深度测试通过或失败时我们是否进行计数。

使用 Z-fail 时必须小心,因为绘制阴影体积的端盖非常重要。然而,这种技术与 Z-pass 非常相似。不是在深度测试通过时递增/递减,而是在深度测试失败时递增/递减。这实际上追踪了一条从无穷远处向视点回溯的射线。

我还应该指出,前面的代码不足以处理退化三角形(边几乎平行的三角形)或非封闭网格。在这种情况下可能需要小心处理。例如,为了更好地处理退化三角形,我们可以使用另一种确定三角形法向量的技术。我们还可以添加额外的代码来处理网格的边,或者简单地始终使用封闭网格。

参见

  • 示例代码中的chapter08/sceneshadowvolume.cpp文件

  • 在第七章的使用几何着色器绘制轮廓线**配方中,使用几何和细分着色器*

第九章:在着色器中使用噪声

在本章中,我们将涵盖以下食谱:

  • 使用 GLM 创建噪声纹理

  • 创建无缝噪声纹理

  • 创建类似云的效果

  • 创建木纹效果

  • 创建碎片化效果

  • 创建喷溅效果

  • 创建生锈金属效果

  • 创建夜视效果

简介

使用着色器创建看起来平滑的表面很容易,但这并不总是我们想要的目标。如果我们想要创建看起来逼真的对象,我们需要模拟真实表面的不完美。这包括刮痕、锈迹、凹痕和侵蚀。令人惊讶的是,使表面看起来真的经历了这些自然过程是多么具有挑战性。同样,我们有时想要尽可能真实地表示自然表面,如木纹或自然现象,如云,而不给人以它们是合成的或表现出重复模式或结构的印象。

自然界中的大多数效果或模式都表现出一定程度的随机性和非线性。因此,你可能想象我们可以通过简单地使用随机数据来生成它们。然而,如伪随机数生成器生成的随机数据在计算机图形学中并不很有用。有两个主要原因:

  • 首先,我们需要可重复的数据,这样对象在动画的每一帧中都会以相同的方式渲染。(我们可以通过为每一帧使用适当的种子值来实现这一点,但这只解决了问题的一半。)

  • 第二,为了模拟大多数这些自然现象,我们实际上需要连续的数据,但仍然具有随机的外观。连续数据更准确地表示了许多这些自然材料和现象。纯随机数据不具有这种连续性属性。每个值与前面的值没有依赖关系。

多亏了 Ken Perlin 的开创性工作,我们有了噪声(在计算机图形学中的应用)的概念。他的工作将噪声定义为具有以下某些特性的函数:

  • 这是一个连续函数

  • 它是可重复的(从相同的输入生成相同的输出)

  • 它可以定义任何数量的维度

  • 它没有任何规则模式,给人一种随机的外观

这样的噪声函数是计算机图形学中的一个宝贵工具,它可以用来创建一系列无穷无尽的有趣效果。例如,在本章中,我们将使用噪声来创建云、木材、碎片化以及其他效果。

Perlin 噪声是由 Ken Perlin 最初定义的噪声函数(见mrl.nyu.edu/~perlin/doc/oscar.html)。关于 Perlin 噪声背后的详细讨论超出了本书的范围。

要在着色器中使用 Perlin 噪声,我们有以下三个主要选择:

  • 我们可以使用内置的 GLSL 噪声函数

  • 我们可以创建自己的 GLSL 噪声函数

  • 我们可以使用纹理图来存储预计算的噪声数据

在撰写本书时,GLSL 噪声函数尚未在部分商业 OpenGL 驱动程序中实现,因此不能保证可用,所以我决定在本章中不使用它们。由于创建自己的噪声函数超出了本书的范围,并且因为列表中的第三个选项在现代硬件上提供了最佳性能,本章中的配方将使用第三种方法(使用预计算的噪声纹理)。

许多书籍使用 3D 噪声纹理而不是 2D 纹理,以提供另一个维度,该维度可供着色器使用。为了保持简单,并专注于使用表面纹理坐标,我选择在本章的配方中使用 2D 噪声纹理。如果需要,应该可以轻松地将这些配方扩展到使用 3D 噪声源。

我们将从两个配方开始,展示如何使用 GLM 生成噪声纹理。然后,我们将继续展示几个使用噪声纹理来产生自然和人工效果(如木纹、云、电干扰、飞溅和侵蚀)的例子。

本章中的配方旨在为您提供一个实验的起点。它们绝对不是实现任何这些效果的最终方法。一个

计算机图形学最令人兴奋的元素之一就是其创造性。尝试调整这些配方中的着色器以产生类似的结果,然后尝试创建你自己的效果。最重要的是,享受乐趣!

有关此主题的更多信息,请参阅 Ken Musgrave 等人所著的《纹理和建模:过程方法》一书。

使用 GLM 创建噪声纹理

要创建一个用作噪声源的纹理,我们需要一种生成噪声值的方法。从头开始实现一个合适的噪声生成器可能是一项相当艰巨的任务。幸运的是,GLM 提供了一些简单易用的噪声生成函数。

在这个配方中,我们将使用 GLM 生成一个使用Perlin噪声生成器创建的 2D 噪声值纹理。GLM 可以通过glm::perlin函数生成 2D、3D 和 4D Perlin 噪声。

使用 Perlin 噪声是一种常见的做法,通过将噪声函数的值与增加的频率和减少的振幅相加。每个频率通常被称为八度(频率的两倍)。例如,在以下图像中,我们展示了在四个不同八度下采样的 2D Perlin 噪声函数的结果。采样频率从左到右增加。

以下图像中最左侧的图像是在基本频率下采样的函数,而每个右侧的图像都显示了在左侧图像频率的两倍下采样的函数:

图片

用数学术语来说,如果我们的相干 2D Perlin 噪声函数是 P(x, y),那么每个前面的图像代表以下方程:

图片

这里,i = 0, 1, 2, 和 3 从左到右。

如前所述,常见的做法是将倍频相加以获得最终结果。我们将每个倍频添加到前面的方程中,并按某个因子降低振幅。因此,对于 N 个倍频,我们有以下和:

图片

ab 是可调常数。以下图像显示了 2, 3, 和 4 个倍频(从左到右)的和,其中 a = 1 和 b = 2:

图片

包含更高倍频的噪声总和将比只包含更低倍频的噪声具有更多的高频变化。然而,有可能快速达到超过用于存储噪声数据的缓冲区分辨率的频率,因此必须小心不要进行不必要的计算。

实际上,这既是艺术也是科学。前面的方程可以作为起点;请随意调整它,直到得到期望的效果。

我们将在单个 2D 纹理中存储四个噪声值。我们将使用第一个分量(红色通道)存储一个倍频的 Perlin 噪声,绿色通道存储两个倍频,蓝色通道存储三个倍频,alpha 通道存储四个倍频。

准备工作

确保你已经安装了 GLM 库并将其放置在包含路径中。

如何实现...

要使用 GLM 创建 2D 噪声纹理,请执行以下步骤:

  1. 包含包含噪声函数的 GLM 头文件:
#include <glm/gtc/noise.hpp> 
  1. 使用前面的方程生成噪声数据:
GLubyte *data = new GLubyte[ width * height * 4 ]; 

float xFactor = 1.0f / (width - 1); 
float yFactor = 1.0f / (height - 1); 

for( int row = 0; row < height; row++ ) { 
  for( int col = 0 ; col < width; col++ ) { 
    float x = xFactor * col; 
    float y = yFactor * row; 
    float sum = 0.0f; 
    float freq = a; 
    float scale = b; 

    // Compute the sum for each octave 
    for( int oct = 0; oct < 4; oct++ ) { 
      glm::vec2 p(x * freq, y * freq); 
      float val = glm::perlin(p) / scale; 
      sum += val; 
      float result = (sum + 1.0f)/ 2.0f; 

      // Store in texture buffer 
      data[((row * width + col) * 4) + oct] =  
                   (GLubyte) ( result * 255.0f ); 
      freq *= 2.0f;   // Double the frequency 
      scale *= b;     // Next power of b 
    } 
  } 
} 
  1. 将数据加载到 OpenGL 纹理中:
GLuint texID; 
glGenTextures(1, &texID); 

glBindTexture(GL_TEXTURE_2D, texID); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height); 
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,width,height,
   GL_RGBA,GL_UNSIGNED_BYTE,data); 

delete [] data; 

它是如何工作的...

GLM 库通过 glm::perlin 函数提供 2D、3D 和 4D 相干噪声。它返回大约在-1 和 1 之间的浮点数。我们首先分配一个名为 data 的缓冲区来存储生成的噪声值。

接下来,我们遍历每个 texel 并计算 xy 坐标(归一化)。然后,我们遍历倍频。在这里,我们计算前面方程的和,将第一个项存储在第一个分量中,前两个项在第二个分量中,依此类推。该值被缩放到 0 到 1 的范围内,然后乘以 255 并转换为字节。

下几行代码应该很熟悉。使用 glTexStorage2D 分配纹理内存,并使用 glTexSubImage2D 将数据加载到 GPU 内存中。

最后,删除名为 data 的数组,因为它不再需要。

更多内容...

与使用无符号字节值相比,我们可以通过使用浮点纹理在我们的噪声数据中获得更高的分辨率。如果效果需要高度精细的细节,这可能会提供更好的结果。为了实现这一点,需要相对较少的代码更改。只需使用内部格式 GL_RGBA32F 而不是 GL_RGBA,使用 GL_FLOAT 类型,并且在将噪声值存储在数组中时不要乘以 255。

GLM 还通过glm::perlin函数的重载提供了周期性的 Perlin 噪声。这使得创建无缝拼接的噪声纹理变得容易。我们将在下一个配方中看到如何使用它。

参见

  • 示例代码中的chapter09/noisetex.cpp文件

  • 关于连贯噪声的一般信息,请参阅 Mike Bailey 和 Steve Cunningham 合著的《图形着色器》一书

  • 第五章中的应用 2D 纹理配方,使用纹理

  • 创建无缝噪声纹理的配方

创建无缝噪声纹理

拥有一个拼接良好的噪声纹理特别有用。如果我们仅仅创建一个噪声纹理作为噪声值的有限切片,那么这些值将不会在纹理的边界处平滑地环绕。如果纹理坐标超出零到一的范围内,这可能会导致渲染表面出现硬边(拼接线)。

幸运的是,GLM 提供了一个周期性的 Perlin 噪声变体,可以用来创建无缝噪声纹理。

以下图像显示了常规(左)和周期性(右)四频带 Perlin 噪声的示例。注意,在左边的图像中,拼接线清晰可见,而在右边的图像中则被隐藏:

图片

在这个例子中,我们将修改前一个配方的代码以生成无缝噪声纹理。

准备工作

对于这个配方,我们将从上一个使用 GLM 创建噪声纹理配方中的代码开始。

如何操作...

按照以下方式修改前一个配方中的代码。

在最内层循环中,我们不会调用glm::perlin,而是调用提供周期性 Perlin 噪声的重载。你需要替换以下语句:

float val = glm::perlin(p) / scale; 

用以下内容替换它:

float val = 0.0f; 
if( periodic ) { 
  val = glm::perlin(p, glm::vec2(freq)) / scale; 
} else { 
  val = glm::perlin(p) / scale; 
} 

工作原理...

glm::perlin的第二个参数决定了噪声值在xy方向上的周期。我们使用freq作为周期,因为我们为每个频带在每个范围内从0freq采样噪声。

参见

  • 示例代码中的chapter09/noisetex.cpp文件

  • 使用 GLM 创建噪声纹理配方

创建类似云的效果

要创建一个类似天空带有云的纹理,我们可以使用噪声值作为天空颜色和云颜色之间的混合因子。由于云通常具有大规模结构,使用低频噪声是有意义的。然而,大规模结构通常具有更高的频率变化,因此可能需要一些高频噪声的贡献。

以下图像显示了本配方中技术生成云的示例:

图片

为了创建这种效果,我们取噪声值的余弦值,并将结果用作云颜色之间的混合因子。

准备工作

设置你的程序以生成无缝噪声纹理,并通过NoiseTex均匀采样器变量将其提供给着色器。

片段着色器中有两种制服可以从 OpenGL 程序中分配:

  • SkyColor:背景天空颜色

  • CloudColor:云彩的颜色

如何做到这一点...

要构建一个使用噪声纹理创建类似云彩效果的着色器程序,请执行以下步骤:

  1. 设置您的顶点着色器,通过TexCoord变量将纹理坐标传递给片段着色器。

  2. 使用以下代码作为片段着色器:

#define PI 3.14159265 

layout( binding=0 ) uniform sampler2D NoiseTex; 

uniform vec4 SkyColor = vec4( 0.3, 0.3, 0.9, 1.0 ); 
uniform vec4 CloudColor = vec4( 1.0, 1.0, 1.0, 1.0 ); 

in vec2 TexCoord; 

layout ( location = 0 ) out vec4 FragColor; 

void main() {
  vec4 noise = texture(NoiseTex, TexCoord); 
  float t = (cos( noise.g * PI ) + 1.0) / 2.0; 
  vec4 color = mix( SkyColor, CloudColor, t ); 
  FragColor = vec4( color.rgb , 1.0 ); 
} 

它是如何工作的...

我们首先从噪声纹理(noise变量)中检索噪声值。绿色通道包含两个八度噪声,所以我们使用该通道中存储的值(noise.g)。您可以随意尝试其他通道,并确定哪种看起来更适合您。

我们使用余弦函数来使云彩和天空颜色之间的过渡更加尖锐。噪声值将在零和一之间,该值的余弦将在-1 和 1 之间,所以我们添加 1.0 并除以 2.0。存储在t中的结果应该再次在零和一之间。如果没有这个余弦变换,云彩看起来在天空上分布得有点太散。然而,如果这是期望的效果,可以移除余弦并直接使用噪声值。

接下来,我们使用t的值混合天空颜色和云彩颜色。结果被用作最终输出片段颜色。

更多内容...

如果您想要更少的云彩和更多的天空,您可以在使用t值混合云彩和天空颜色之前将其值进行平移和钳位。例如,您可以使用以下代码:

float t = (cos( noise.g * PI ) + 1.0 ) / 2.0; 
t = clamp( t - 0.25, 0.0, 1.0 ); 

这导致余弦项向下(向负值)移动,而clamp函数将所有负值设置为零。这会增加天空的量,并减少云彩的大小和强度。

参见

创建木纹效果

要创建木纹效果,我们可以首先创建一个具有完美圆柱形生长环的虚拟“树干”。然后,我们将切下一块树干,并使用噪声纹理中的噪声扰动生长环。

以下图显示了我们的虚拟树干。它与y轴对齐,并向所有方向无限延伸。生长环与y轴的整数距离对齐。每个环都被赋予较深的颜色,环之间是较浅的颜色。每个生长环跨越整数距离周围的狭窄距离:

图片

要进行“切片”,我们只需根据纹理坐标定义原木空间的二维区域。最初,纹理坐标定义一个坐标范围从零到一的方形区域。我们假设该区域与 x-y 平面对齐,因此 s 坐标对应于 xt 坐标对应于 y,而 z 的值为零。然后我们可以以任何适合我们的方式变换这个区域,以创建任意二维切片。在定义切片后,我们将根据到 y 轴的距离确定颜色。然而,在这样做之前,我们将基于噪声纹理中的一个值扰动这个距离。结果看起来与真实木材相似。以下图像显示了此示例:

准备工作

设置你的程序以生成噪声纹理,并通过 NoiseTex 常量变量使其对着色器可用。片段着色器中有三个可以从 OpenGL 程序分配的常量。它们如下所示:

  • LightWoodColor:最浅的木色

  • DarkWoodColor:最深的木色

  • Slice:一个定义虚拟“原木”切片的矩阵,并将默认由纹理坐标定义的区域变换为其他任意矩形区域

如何做到这一点...

要创建一个使用噪声纹理生成木纹效果的着色器程序,请执行以下步骤:

  1. 设置你的顶点着色器,通过 TexCoord 变量将纹理坐标传递给片段着色器。

  2. 使用以下代码进行片段着色器:

layout(binding=0) uniform sampler2D NoiseTex; 

uniform vec4 DarkWoodColor = vec4( 0.8, 0.5, 0.1, 1.0 ); 
uniform vec4 LightWoodColor = vec4( 1.0, 0.75, 0.25, 1.0 ); 
uniform mat4 Slice; 

in vec2 TexCoord; 

layout ( location = 0 ) out vec4 FragColor; 

void main() {
  // Transform the texture coordinates to define the 
  // "slice" of the log. 
  vec4 cyl = Slice * vec4( TexCoord.st, 0.0, 1.0 ); 

  // The distance from the log's y axis. 
  float dist = length(cyl.xz); 

  // Perturb the distance using the noise texture 
  vec4 noise = texture(NoiseTex, TexCoord); 
  dist += noise.b; 

  // Determine the color as a mixture of the light and  
  // dark wood colors. 
  float t = 1.0 - abs( fract( dist ) * 2.0 - 1.0 ); 
  t = smoothstep( 0.2, 0.5, t ); 
  vec4 color = mix( DarkWoodColor, LightWoodColor, t ); 

  FragColor = vec4( color.rgb , 1.0 ); 
}

它是如何工作的...

片段着色器中 main 函数的第一行将纹理坐标扩展为具有零 z 坐标的 3D(齐次)值(s, t, 0, 1),然后通过 Slice 矩阵变换该值。这个矩阵可以缩放、平移和/或旋转纹理坐标,以定义虚拟 原木 的二维区域。

一种可视化这个方法是将切片想象成一个嵌入在原木中的二维单位正方形,其左下角位于原点。然后使用矩阵将这个正方形在原木内进行变换,以定义一个穿过原木的切片。例如,我可能只将正方形平移(-0.5, -0.5, -0.5)并在 xy 方向上缩放 20 倍,以得到穿过原木中间的切片。

接下来,使用内置的 length 函数(length(cyl.xz))确定到 y 轴的距离。这将用来确定我们距离生长环有多近。如果我们处于生长环之间,颜色将是浅木色;当我们接近生长环时,颜色将变深。然而,在确定颜色之前,我们使用以下代码行通过噪声纹理中的一个值稍微扰动这个距离:

dist += noise.b; 

下一步只是一个基于我们离整数有多近来确定颜色的数值技巧。我们首先取距离的分数部分(fract(dist)),乘以二,减去一,然后取绝对值。由于fract(dist)是一个介于零和一之间的值,乘以二,减去一,然后取绝对值将得到一个同样介于零和一之间的值。然而,这个值将在dist为 0.0 时为 1.0,当dist为 0.5 时为 0.0,当dist为 1.0 时又回到 1.0(一个v形状)。

我们然后通过从一减去v来反转它,并将结果存储在t中。接下来,我们使用smoothstep函数在亮色和暗色之间创建一个相对尖锐的过渡。换句话说,我们希望在t小于 0.2 时得到暗色,在t大于 0.5 时得到亮色,并在两者之间有一个平滑的过渡。结果用于通过 GLSL 的mix函数混合亮色和暗色。

smoothstep(a, b, x)函数的工作方式如下。当x <= a时返回0.0,当x >= b时返回1.0,当xab之间时使用 Hermite 插值在 0 和 1 之间。

所有这些的结果是在整数距离周围出现一条狭窄的深色带,中间是浅色,并且颜色过渡快速而平滑。最后,我们只需将最终颜色应用到碎片上。

更多内容...

一对书匹配的木板是从同一根原木上切割出来然后粘合在一起的一对。结果是更大的木板,从一侧到另一侧的纹理具有对称性。我们可以通过镜像纹理坐标来近似这种效果。例如,我们可以用以下代码替换前面main函数的第一行:

vec2 tc = TexCoord; 
if( tc.s > 0.5 ) tc.s = 1.0 - tc.s; 
vec4 cyl = Slice * vec4( tc, 0.0, 1.0 ); 

以下图像显示了结果示例:

图片

参见

  • 示例代码中的chapter09/scenewood.cpp文件

  • 使用 GLM 创建噪声纹理的配方

创建分解效果

使用 GLSL 的discard关键字与噪声结合来模拟侵蚀或衰变是直接的。我们可以简单地丢弃与噪声值高于或低于某个阈值对应的碎片。以下图像显示了具有这种效果的茶壶。

当与纹理坐标对应的噪声值超出某个阈值范围时,丢弃碎片:

图片

准备工作

设置你的 OpenGL 程序以向着色器提供位置、法线和纹理坐标。确保将纹理坐标传递到片段着色器。设置实现所选着色模型的任何 uniforms。

创建一个无缝噪声纹理(见创建无缝噪声纹理),并将其放置在适当的纹理通道中。

以下 uniforms 在片段着色器中定义,并且应该通过 OpenGL 程序设置:

  • NoiseTex:噪声纹理

  • LowThreshold: 如果噪声值低于此值,则片段将被丢弃

  • HighThreshold: 如果噪声值高于此值,则片段将被丢弃

如何操作...

要创建一个提供分解效果的着色器程序,请执行以下步骤:

  1. 创建一个顶点着色器,通过TexCoord输出变量将纹理坐标发送到片段着色器。它还应该通过PositionNormal变量将位置和法线传递给片段着色器。

  2. 使用以下代码作为片段着色器:

// Insert uniforms needed for the Phong shading model 

layout(binding=0) uniform sampler2D NoiseTex; 

in vec4 Position; 
in vec3 Normal; 
in vec2 TexCoord; 

uniform float LowThreshold; 
uniform float HighThreshold; 

layout ( location = 0 ) out vec4 FragColor; 

vec3 phongModel() { 
  // Compute Phong shading model... 
} 
void main() 
{ 
  // Get the noise value at TexCoord 
  vec4 noise = texture( NoiseTex, TexCoord ); 

  // If the value is outside the threshold, discard 
  if( noise.a < LowThreshold || noise.a > HighThreshold) 
    discard; 

  // Color the fragment using the shading model 
  vec3 color = phongModel(); 
  FragColor = vec4( color , 1.0 ); 
} 

它是如何工作的...

片段着色器首先从噪声纹理(NoiseTex)中检索噪声值,并将结果存储在noise变量中。我们希望噪声具有大量高频波动,所以我们选择四倍频噪声,它存储在 alpha 通道(noise.a)中。

如果噪声值低于LowThreshold或高于HighThreshold,则丢弃片段。由于discard关键字会导致着色器执行停止,如果丢弃片段,则着色器的其余部分将不会执行。

由于它可能影响早期深度测试,丢弃操作可能会对性能产生影响。

最后,我们计算着色模型并将结果应用于片段。

参见

  • 示例代码中的chapter09/scenedecay.cpp文件

  • 创建无缝噪声纹理配方

创建喷溅效果

使用高频噪声,很容易在物体表面创建随机喷溅油漆的效果。以下图像显示了此效果的示例:

图片

我们使用噪声纹理来改变物体的颜色,在基本颜色和喷漆颜色之间有一个尖锐的过渡。我们将使用基本颜色或喷漆颜色作为着色模型的漫反射反射率。如果噪声值高于某个阈值,我们将使用喷漆颜色;否则,我们将使用物体的基本颜色。

准备工作

从使用 Phong 着色模型(或您喜欢的任何模型)的基本渲染设置开始。包括纹理坐标并将它们传递给片段着色器。

有几个统一变量定义了喷溅油漆的参数:

  • PaintColor: 喷溅油漆的颜色

  • Threshold: 喷溅出现的最小噪声值

创建一个高频噪声的噪声纹理。

通过NoiseTex统一采样器变量使噪声纹理对片段着色器可用。

如何操作...

要创建一个生成喷溅效果的着色器程序,请执行以下步骤:

  1. 创建一个顶点着色器,通过TexCoord输出变量将纹理坐标发送到片段着色器。它还应该通过PositionNormal变量将位置和法线传递给片段着色器。

  2. 使用以下代码作为片段着色器:

// Uniforms for the Phong shading model 
...

// The noise texture 
layout(binding=0) uniform sampler2D NoiseTex; 
// Input from the vertex shader 
in vec4 Position; 
in vec3 Normal; 
in vec2 TexCoord; 

// The paint-spatter uniforms 
uniform vec3 PaintColor = vec3(1.0); 
uniform float Threshold = 0.65; 

layout ( location = 0 ) out vec4 FragColor; 

vec3 phongModel(vec3 kd) { 
  // Evaluate the Phong shading model 
} 

void main() { 
  vec4 noise = texture( NoiseTex, TexCoord ); 
  vec3 color = Material.Kd; 
  if( noise.g> Threshold ) color = PaintColor; 
  FragColor = vec4( phongModel(color) , 1.0 ); 
} 

它是如何工作的...

片段着色器的主要功能是从NoiseTex中检索噪声值并将其存储在noise变量中。接下来的两行将变量color设置为基本漫反射率(Material.Kd)或PaintColor,具体取决于噪声值是否大于阈值值(Threshold)。这将导致两种颜色之间的急剧过渡,喷溅的大小将与噪声的频率相关。

最后,使用color作为漫反射率评估 Phong 着色模型。结果应用于片段。

还有更多...

使用 GLM 创建噪声纹理配方中所示,使用较低频率的噪声会导致喷溅更大且分布更广。较低的阈值也会增加大小,但不会扩散到表面,但随着阈值的降低,它开始看起来更均匀,更像随机喷溅。

参见

  • 示例代码中的chapter09/scenepaint.cpp文件

  • 创建无缝噪声纹理配方

创建锈蚀金属效果

此配方结合了一个噪声纹理和第五章中介绍的反射效果,使用纹理来创建简单的锈蚀金属效果。

这种技术与之前的配方创建喷溅效果非常相似。我们将使用我们的噪声纹理来调制茶壶的反射。如果噪声值高于某个阈值,我们将使用锈色,否则,我们将使用反射颜色。

准备工作

我们将第五章中使用纹理的配方中描述的使用立方体贴图模拟反射技术与噪声纹理相结合。从该配方中的着色器开始。

如何做到这一点...

在片段着色器中,我们将访问我们的噪声纹理,如果值低于阈值值Threshold,我们将使用反射颜色(来自立方体贴图),否则,我们将使用锈色:

in vec3 ReflectDir;
in vec2 TexCoord;

uniform samplerCube CubeMapTex;
uniform sampler2D NoiseTex;

uniform float ReflectFactor;
uniform vec4 MaterialColor;

layout( location = 0 ) out vec4 FragColor;

uniform float Threshold = 0.58;

void main() {
    // Access the noise texture
    float noise = texture( NoiseTex, TexCoord ).a;
    float scale = floor( noise + (1 - Threshold) );

    // Access the cube map texture
    vec3 cubeMapColor = texture(CubeMapTex, ReflectDir).rgb;

    // Gamma correct
    cubeMapColor = pow(cubeMapColor, vec3(1.0/2.2));

    vec3 rustColor = mix( MaterialColor.rgb, vec3(0.01), noise.a );

    FragColor = vec4( mix( cubeMapColor, rustColor, scale), 1);
}

它是如何工作的...

我们首先访问噪声纹理,并将其值存储在变量noise中。变量scale将存储一个零或一的值。我们使用floor函数将noise的值小于Threshold时设置为零,否则设置为 一。

接下来,我们访问立方体贴图以获取反射颜色并应用伽玛校正。

我们通过将MaterialColor与深色(几乎为黑色)混合来计算rustColor,使用噪声纹理作为比例。这应该会给锈色带来一些额外的变化。

最后,我们使用scalecubeMapColorrustColor混合,并将结果应用于片段。由于scale的值将是零或一,因此我们将得到反射颜色和锈色之间的尖锐过渡。

参见

  • 示例代码中的chapter09/scenerust.cpp文件

创建夜视效果

噪声可以用来模拟静态或其他类型的电子干扰效果。这个配方是一个有趣的例子。我们将通过添加一些噪声来模拟信号中的随机静态,以创建夜视镜的外观。为了好玩,我们还将以经典的 双筒望远镜 视图勾勒场景。以下图片展示了这个例子:

图片

我们将作为第二次遍历将夜视效果应用于渲染的场景。第一次遍历将场景渲染到纹理中(见第五章,使用纹理),第二次遍历将应用夜视效果。

准备中

为第一次遍历创建一个 帧缓冲对象FBO)。将纹理附加到 FBO 的第一个颜色附加项。有关如何操作的更多信息,请参阅第五章,使用纹理

创建并分配所需的任何着色模型统一变量。设置片段着色器中定义的以下统一变量:

  • Width: 视口宽度(以像素为单位)

  • Height: 视口高度(以像素为单位)

  • Radius: 双筒望远镜效果中每个圆的半径(以像素为单位)

  • RenderTex: 包含第一次遍历渲染的纹理

  • NoiseTex: 噪声纹理

  • RenderPass: 用于选择每个遍历功能性的子程序统一变量

创建一个具有高频噪声的噪声纹理,并通过 NoiseTex 使其可用于着色器。将纹理与通过 RenderTex 可用的 FBO 关联。

如何做到这一点...

要创建生成夜视效果的着色器程序,执行以下步骤:

  1. 设置你的顶点着色器,通过 PositionNormalTexCoord 变量分别传递位置、法线和纹理坐标。

  2. 使用以下代码作为片段着色器:

in vec3 Position; 
in vec3 Normal; 
in vec2 TexCoord; 

uniform int Width; 
uniform int Height; 
uniform float Radius; 
layout(binding=0) uniform sampler2D RenderTex; 
layout(binding=1) uniform sampler2D NoiseTex; 

subroutine vec4 RenderPassType(); 
subroutine uniform RenderPassType RenderPass; 

// Define any uniforms needed for the shading model. 

layout( location = 0 ) out vec4 FragColor; 

vec3 phongModel( vec3 pos, vec3 norm ) {
  // Compute the Phong shading model 
} 

// Returns the relative luminance of the color value 
float luminance( vec3 color ) { 
  return dot( color.rgb, vec3(0.2126, 0.7152, 0.0722) ); 
} 

subroutine (RenderPassType) 
vec4 pass1() {
  return vec4(phongModel( Position, Normal ),1.0); 
} 

subroutine( RenderPassType ) 
vec4 pass2() {
  vec4 noise = texture(NoiseTex, TexCoord); 
  vec4 color = texture(RenderTex, TexCoord); 
  float green = luminance( color.rgb ); 

  float dist1 = length(gl_FragCoord.xy - 
       vec2(Width*0.25, Height*0.5));  
       float dist2 = length(gl_FragCoord.xy - 
       vec2(3.0*Width*0.25, Height*0.5)); 
  if( dist1 > Radius && dist2 > Radius ) green = 0.0; 

  return vec4(0.0, green * clamp(noise.a + 0.25, 0.0, 1.0),
       0.0 ,1.0); 
} 

void main() {
  // This will call either pass1() or pass2() 
  FragColor = RenderPass(); 
} 
  1. 在你的 OpenGL 程序的渲染函数中,执行以下步骤:

    1. 绑定到用于将场景渲染到纹理的 FBO。

    2. 通过 RenderPass 选择片段着色器中的 pass1 子程序函数。

    3. 渲染场景。

    4. 绑定到默认的 FBO。

    5. 通过 RenderPass 选择片段着色器中的 pass2 子程序函数。

    6. 使用纹理坐标(每个方向的范围为 0 到 1)绘制一个填充视口的单个四边形。

它是如何工作的...

片段着色器被分成两个子程序函数,每个遍历一个。在 pass1 函数中,我们只是将 Phong 着色模型应用于片段。结果写入 FBO,其中包含用于第二次遍历的纹理。

在第二次遍历中,执行 pass2 函数。我们首先检索一个噪声值(noise),以及来自第一次遍历的渲染纹理中的颜色(color)。然后,我们计算颜色的 luminance 值并将其存储在 green 变量中。这最终将被用作最终颜色的绿色分量。

我们在这里使用相同的纹理坐标,假设噪声纹理与渲染纹理大小相同。使用较小的噪声纹理并在表面上进行平铺会更节省空间。

下一步涉及确定片段是否位于双目镜头内。我们计算到左侧镜头中心的距离(dist1),它位于视口从上到下的中间位置,从左到右的四分之一处。右侧镜头位于相同的垂直位置,但从左到右的三分之四处。右侧镜头中心的距离存储在dist2中。如果dist1dist2都大于虚拟镜头的半径,则将green设置为0

最后,我们返回最终颜色,它只有green分量;其他两个设置为零。green的值乘以噪声值,以便向图像添加一些噪声,以模拟信号中的随机干扰。我们将噪声值加0.25并将其夹在零和一之间,以使整体图像变亮。我发现如果噪声值没有以这种方式偏置,它看起来会稍微暗一些。

还有更多...

如果噪声在动画的每一帧中变化,以模拟不断变化的干扰,这将使这个着色器更加有效。我们可以通过以时间依赖的方式修改用于访问噪声纹理的纹理坐标来大致实现这一点。请参阅以下参见部分中提到的博客文章以获取示例。

参见

  • 示例代码中的chapter09/scenenightvision.cpp文件

  • 第五章中的将渲染输出到纹理配方,使用纹理

  • 使用 GLM 创建噪声纹理配方

  • 这个配方灵感来源于 Wojciech Toman 的一篇博客文章(现已不再公开可用)

第十章:粒子系统与动画

在本章中,我们将涵盖以下配方:

  • 使用顶点位移动画表面

  • 创建粒子喷泉

  • 使用变换反馈创建粒子系统

  • 使用实例网格创建粒子系统

  • 使用粒子模拟火焰

  • 使用粒子模拟烟雾

简介

着色器为我们提供了利用现代图形处理器提供的巨大并行性的能力。由于它们能够变换顶点位置,因此可以直接在着色器内部实现动画。如果动画算法可以在着色器内部适当地并行化执行,这可以提高效率。

如果着色器要帮助动画,它不仅必须计算位置,而且通常还必须输出更新后的位置以供下一帧使用。着色器最初并不是设计用来写入任意缓冲区的(当然,除了帧缓冲区)。然而,随着最近版本的推出,OpenGL 已经提供了一系列技术来实现这一功能,包括着色器存储缓冲区对象和图像加载/存储。截至 OpenGL 3.0,我们还可以将顶点或几何着色器输出变量的值发送到任意缓冲区(或缓冲区)。这个功能被称为变换反馈,对于粒子系统特别有用。

在本章中,我们将探讨几个着色器内动画的示例,主要关注粒子系统。第一个示例,通过顶点位移进行动画,通过基于时间依赖函数变换对象的顶点位置来演示动画。在创建粒子喷泉配方中,我们将创建一个在恒定加速度下的简单粒子系统。在使用变换反馈创建粒子系统配方中,有一个示例说明了如何在粒子系统中使用 OpenGL 的变换反馈功能。使用实例粒子创建粒子系统配方展示了如何使用实例渲染来动画化许多复杂对象。

最后两个配方演示了一些用于模拟复杂、真实现象(如烟雾和火焰)的粒子系统。

使用顶点位移动画表面

利用着色器进行动画的一个简单方法是在顶点着色器内部根据某个时间依赖函数变换顶点。OpenGL 应用程序提供静态几何体,顶点着色器使用当前时间(作为统一变量提供)修改几何体。这将从 CPU 将顶点位置的计算移动到 GPU,并利用图形驱动程序提供的任何并行性。

在这个例子中,我们将通过根据正弦波转换划分四边形的顶点来创建一个波动的表面。我们将通过管道发送一组三角形,这些三角形构成了x-z平面上的一个平坦表面。在顶点着色器中,我们将根据时间依赖的正弦函数转换每个顶点的y坐标,并计算变换顶点的法向量。以下图像显示了期望的结果(你必须想象波浪是从左到右穿过表面的):

或者,我们可以使用噪声纹理根据随机函数动画顶点(构成表面的顶点)。(有关噪声纹理的详细信息,请参阅第九章[5e6b75a0-9f0c-4798-bc37-b5d34b53ef4a.xhtml],在着色器中使用噪声。)

在我们深入代码之前,让我们看看我们将需要的数学知识。

我们将根据当前时间和建模的x坐标将表面的y坐标作为函数进行转换。为此,我们将使用基本的平面波动方程,如下面的图示所示:

A是波的振幅(波峰的高度),lambda(λ)是波长(相邻波峰之间的距离),v是波的速度。前面的图示展示了当t = 0且波长等于一时波的例子。我们将通过 uniform 变量配置这些系数。

为了以适当的着色渲染表面,我们还需要变换位置的法向量。我们可以通过前一个函数的(偏)导数来计算法向量。结果是以下方程:

当然,在我们在着色模型中使用它之前,这个向量应该是归一化的。

准备工作

将你的 OpenGL 应用程序设置成在x-z平面上渲染一个平坦的、划分成多边形的表面。如果你使用大量的三角形,结果会看起来更好。同时,使用你喜欢的任何方法跟踪动画时间。通过 uniform 变量Time将当前时间传递给顶点着色器。

另外一些重要的 uniform 变量是先前波动方程的系数:

  • K:它是波数(2π/λ

  • Velocity:它是波的速度

  • Amp:它是波的振幅

设置你的程序以提供适当的 uniform 变量以供你选择的着色模型使用。

如何操作...

在顶点着色器中,我们转换顶点的y坐标:

layout (location = 0) in vec3 VertexPosition; 

out vec4 Position; 
out vec3 Normal; 

uniform float Time;  // The animation time 

// Wave parameters 
uniform float K;        // Wavenumber 
uniform float Velocity; // Wave's velocity 
uniform float Amp;      // Wave's amplitude 

uniform mat4 ModelViewMatrix; 
uniform mat3 NormalMatrix; 
uniform mat4 MVP; 

void main() {
  vec4 pos = vec4(VertexPosition,1.0); 

  // Translate the y coordinate 
  float u = K * (pos.x - Velocity * Time); 
  pos.y = Amp * sin( u ); 

  // Compute the normal vector 
  vec3 n = vec3(0.0); 
  n.xy = normalize(vec2(-K * Amp *cos( u ), 1.0)); 

  // Send position and normal (in camera cords) to frag. 
  Position = ModelViewMatrix * pos; 
  Normal = NormalMatrix * n; 

  // The position in clip coordinates 
  gl_Position = MVP * pos; 
} 

创建一个片段着色器,它根据PositionNormal变量以及你选择的任何着色模型计算片段颜色。

它是如何工作的...

顶点着色器获取顶点的位置并使用之前讨论的波动方程更新y坐标。在第一个三个语句之后,变量pos只是VertexPosition输入变量的一个副本,带有修改后的y坐标。

我们然后使用前一个方程计算法线向量,将结果归一化,并将其存储在n变量中。由于波实际上是二维波(它不依赖于z),法线向量的z分量将为零。

最后,我们在将位置转换为相机坐标后,将新的位置和法线传递给片段着色器。像往常一样,我们也将位置传递到内置的gl_Position变量中,以裁剪坐标形式。

更多内容...

在顶点着色器中修改顶点位置是一种简单的方法,可以将一些计算从 CPU 卸载到 GPU 上。这还消除了在修改位置时需要在 GPU 内存和主内存之间传输顶点缓冲区的可能需求。

主要缺点是更新的位置在 CPU 端不可用。例如,它们可能需要用于额外的处理(如碰撞检测)。然而,有几种方法可以将这些数据返回到 CPU。一种技术可能是巧妙地使用 FBO 来从片段着色器接收更新的位置。在后面的菜谱中,我们将探讨另一种利用较新的 OpenGL 功能变换反馈的技术。

参见

  • 示例代码中的chapter10/scenewave.cpp

创建粒子喷泉

在计算机图形学中,粒子系统是一组用于模拟各种模糊系统(如烟雾、液体喷雾、火焰、爆炸或其他类似现象)的对象。每个粒子被认为是一个具有位置但没有大小的点对象。它们可以渲染为点精灵(使用GL_POINTS原语模式),或者作为对齐的相机四边形或三角形。每个粒子都有一个生命周期:它诞生,根据一组规则进行动画处理,然后死亡。粒子随后可以被复活并再次经历整个过程。在这个例子中,粒子不会与其他粒子交互,但某些系统,如流体模拟,需要粒子进行交互。一种常见的技术是将粒子渲染为一个单独的、纹理化的、面向相机的四边形,具有透明度。

在粒子的生命周期内,它会根据一组规则进行动画处理。这些规则包括定义受恒定加速度(如重力场)影响的粒子运动的基运动方程。此外,我们可能还会考虑风、摩擦或其他因素。粒子在其生命周期内也可能改变形状或透明度。一旦粒子达到一定年龄(或位置),它就被认为是死亡的,可以被回收并再次使用。

在这个例子中,我们将实现一个相对简单的粒子系统,其外观类似于喷泉。为了简单起见,这个例子中的粒子将不会回收。一旦它们达到生命周期的终点,我们将以完全透明的方式绘制它们,使它们实际上不可见。这给了喷泉一个有限的生命周期,就像它只有有限的材料供应一样。在后面的菜谱中,我们将看到一些回收粒子的方法来改进这个系统。

下面的图像显示了一系列图像——来自这个简单粒子系统输出的几个连续帧:

为了动画粒子,我们将使用恒定加速度下物体的标准运动学方程:

之前的方程式描述了时间t时粒子的位置。P[0]是初始位置,v[0]是初始速度,a是加速度。

我们将定义所有粒子的初始位置为原点(0,0,0)。初始速度将在一个值范围内随机确定。每个粒子将在一个略微不同的时间被创建,因此我们之前方程式中使用的时间将相对于粒子的起始时间。

由于所有粒子的初始位置相同,我们不需要将其作为输入属性提供给着色器。相反,我们只需提供另外两个顶点属性:初始速度和起始时间(粒子的出生时间)。在粒子出生时间之前,我们将将其完全透明地渲染。在其生命周期内,粒子的位置将使用之前的方程式,其中t的值相对于粒子的起始时间(Time - StartTime)。

为了渲染我们的粒子,我们将使用一种称为实例化的技术,并结合一个简单的技巧来生成屏幕对齐的四边形。使用这种技术,我们实际上不需要为四边形本身提供任何顶点缓冲区!相反,我们将只为每个粒子调用六次顶点着色器来生成两个三角形(一个四边形)。在顶点着色器中,我们将计算顶点的位置,作为粒子位置的偏移量。如果我们这样做在屏幕空间中,我们可以轻松地创建一个屏幕对齐的四边形。我们需要提供包含粒子初始速度和出生时间的输入属性。

这种技术利用顶点着色器来完成所有粒子的动画工作。与在 CPU 上计算位置相比,我们获得了巨大的效率提升。GPU 可以并行执行顶点着色器,并一次性处理多个粒子。

这种技术的核心涉及使用glDrawArraysInstanced函数。这个函数与熟悉的glDrawArrays类似,但它不是只绘制一次,而是重复绘制。glDrawArrays会一次遍历顶点缓冲区,而glDrawArraysInstanced会根据指定的次数进行遍历。此外,在遍历缓冲区的同时,我们还可以配置何时移动到缓冲区的下一个元素(如何快速遍历)。通常,我们会在每次调用顶点着色器时移动到下一个元素(本质上每个顶点一次)。然而,在实例绘制中,我们并不总是希望这样。我们可能希望有多个(有时是数百个)调用以获得相同的输入值。

例如,我们粒子系统中的每个粒子都有六个顶点(两个三角形)。对于这六个顶点中的每一个,我们希望它们具有相同的速度、(粒子)位置和其他每个粒子的参数。实现这一点的关键是glVertexAttribDivisor函数,它使得指定给定属性索引提升频率成为可能。0的除数值表示索引在每个顶点处提升一次。大于零的值(n > 0)表示在绘制形状的 n 个实例之后索引提升一次。

例如,假设我们有两个属性(A 和 B),我们将属性 A 的除数设置为零,将属性 B 的除数设置为 1。然后,我们执行以下操作:

glDrawArraysInstanced( GL_TRIANGLES, 0, 3, 3);

前三个参数与glDrawArrays相同。第四个参数是实例数。因此,这个调用将绘制三个三角形原型的实例(总共九个顶点),属性 A 和 B 的值将来自此处显示的相应缓冲区中的索引:

属性 顶点索引
A 0,1,2,0,1,2,0,1,2
B 0,0,0,1,1,1,2,2,2

注意,将属性 B 的顶点属性除数设置为 1 会导致索引在每个实例处提升一次,而不是在每个顶点处提升一次。实际上,在这个配方中,我们将所有属性的除数都设置为 1!我们将计算每个粒子的顶点位置,作为粒子位置的偏移量。

你可能会想知道,如果所有粒子的顶点属性值都相同,如何在顶点着色器中区分一个顶点与另一个顶点,以确定所需的偏移量。解决方案是通过内置变量gl_VertexID。更多内容将在后面介绍。

我们将渲染每个粒子为一个纹理点四边形,由两个三角形组成。我们将随着粒子的年龄线性增加粒子的透明度,使粒子在动画过程中看起来逐渐消失。

准备工作

我们将创建两个缓冲区(或一个单一的交错缓冲区)来存储我们的输入属性。第一个缓冲区将存储每个粒子的初始速度。我们将从可能的向量范围内随机选择值。为了创建前一个图像中的粒子锥体,我们将从锥体内的向量集中随机选择。我们将通过应用旋转矩阵(emitterBasis)将锥体向某个方向倾斜。以下代码是这样做的一种方法:

glm::mat3 emitterBasis = ...; // Rotation matrix 
auto nParticles = 10000;
glGenBuffers(1, &initVel);
glBindBuffer(GL_ARRAY_BUFFER, initVel);
glBufferData(GL_ARRAY_BUFFER, 
    nParticles * sizeof(float) * 3, nullptr, GL_STATIC_DRAW);

glm::vec3 v(0);
float velocity, theta, phi; 
std::vector<GLfloat> data(nParticles * 3); 
for( uint32_t i = 0; i < nParticles; i++ ) { 
  // Pick the direction of the velocity 
  theta = glm::mix(0.0f, glm::pi<float>() / 20.0f, randFloat()); 
  phi = glm::mix(0.0f, glm::two_pi<float>(), randFloat()); 

  v.x = sinf(theta) * cosf(phi); 
  v.y = cosf(theta); 
  v.z = sinf(theta) * sinf(phi); 

  // Scale to set the magnitude of the velocity (speed) 
  velocity = glm::mix(1.25f,1.5f,randFloat()); 
  v = glm::normalize(emitterBasis * v) * velocity; 

  data[3*i]   = v.x; 
  data[3*i+1] = v.y; 
  data[3*i+2] = v.z; 
} 
glBindBuffer(GL_ARRAY_BUFFER, initVel); 
glBufferSubData(GL_ARRAY_BUFFER, 0,  
                nParticles * 3 * sizeof(float), data.data()); 

在前面的代码中,randFloat函数返回一个介于零和一之间的随机值。我们通过使用 GLM 的mix函数(GLM 的mix函数与相应的 GLSL 函数作用相同——它在第一个两个参数的值之间执行线性插值)在可能值的范围内选择随机数。在这里,我们选择一个介于零和一之间的随机float值,并使用该值在范围的端点之间进行插值。

要从我们的锥体中选择向量,我们使用球坐标。theta的值决定了锥体中心和向量之间的角度。phi的值定义了给定theta值时围绕y轴的可能方向。关于球坐标的更多信息,请拿起你最喜欢的数学书。

选择一个方向后,向量会被缩放到 1.25 和 1.5 之间的幅度。这似乎是达到预期效果的良好范围。速度向量的幅度是粒子的整体速度,我们可以调整这个范围以获得更广泛的速度或更快/更慢的粒子。

循环中的最后三行将向量分配到向量data的适当位置。循环之后,我们将数据复制到由initVel引用的缓冲区中。设置此缓冲区以提供顶点属性零的数据。

在第二个缓冲区中,我们将存储每个粒子的起始时间。这将为每个顶点(粒子)提供仅一个浮点数。在这个例子中,我们将以固定的速率连续创建每个粒子。以下代码将设置一个缓冲区,其中每个粒子在之前的粒子之后固定秒数被创建:

glGenBuffers(1, &startTime);
glBindBuffer(GL_ARRAY_BUFFER, startTime);
glBufferData(GL_ARRAY_BUFFER, nParticles * sizeof(float), 
   nullptr, GL_STATIC_DRAW);

float rate = particleLifetime / nParticles;  
for( uint32_t i = 0; i < nParticles; i++ ) { 
  data[i] = rate * i;
} 
glBindBuffer(GL_ARRAY_BUFFER, startTime); 
glBufferSubData(GL_ARRAY_BUFFER, 0, nParticles * sizeof(float), data.data()); 

此代码简单地创建了一个以零开始并按rate递增的浮点数数组。然后,该数组被复制到由startTime引用的缓冲区中。设置此缓冲区作为顶点属性一的输入。

在继续之前,我们将两个属性的分母都设置为 1。这确保了粒子的所有顶点都将获得相同的属性值:

glVertexAttribDivisor(0,1);
glVertexAttribDivisor(1,1);

在绑定顶点数组对象VAO)时执行前面的命令。分母信息存储在 VAO 中。请参阅示例代码以获取详细信息。

顶点着色器有几个统一变量,用于控制模拟。在 OpenGL 程序中设置以下统一变量:

  • ParticleTex:粒子的纹理

  • Time:动画开始以来经过的时间量

  • 重力:代表前一个方程中加速度一半的矢量

  • ParticleLifetime:定义粒子在被创建后存活的时间

  • ParticleSize:粒子的尺寸

  • EmitterPos:粒子发射器的位置

由于我们希望粒子部分透明,我们使用以下语句启用 alpha 混合:

glEnable(GL_BLEND); 
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

如何做到这一点...

在顶点着色器代码中,我们通过在相机坐标中偏移粒子位置来创建粒子。注意使用gl_VertexID来识别四边形的顶点:

layout (location = 0) in vec3 VertexInitVel;    // Particle initial velocity
layout (location = 1) in float VertexBirthTime; // Particle birth time

out float Transp;  // Transparency of the particle
out vec2 TexCoord; // Texture coordinate

uniform float Time; // Animation time
uniform vec3 Gravity; // Gravity vector in world coords
uniform float ParticleLifetime; // Max particle lifetime
uniform float ParticleSize; // Particle size
uniform vec3 EmitterPos;    // Emitter position in world coordinates

// Transformation matrices
uniform mat4 MV, Proj;

// Offsets to the position in camera coordinates for each vertex of the
// particle's quad
const vec3 offsets[] = vec3[](
    vec3(-0.5,-0.5,0), vec3(0.5,-0.5,0), vec3(0.5,0.5,0),
    vec3(-0.5,-0.5,0), vec3(0.5,0.5,0), vec3(-0.5,0.5,0) );
// Texture coordinates for each vertex of the particle's quad
const vec2 texCoords[] = vec2[](
     vec2(0,0), vec2(1,0), vec2(1,1), 
     vec2(0,0), vec2(1,1), vec2(0,1)); 

void main() {
    vec3 cameraPos; // Position in camera coordinates
    float t = Time - VertexBirthTime;
    if( t >= 0 && t < ParticleLifetime ) {
        vec3 pos = EmitterPos + VertexInitVel * t + Gravity * t * t;
        cameraPos = (MV * vec4(pos,1)).xyz + (offsets[gl_VertexID] * 
        ParticleSize);
        Transp = mix( 1, 0, t / ParticleLifetime );
    } else {
        // Particle doesn't "exist", draw fully transparent
        cameraPos = vec3(0);
        Transp = 0.0;
    }

    TexCoord = texCoords[gl_VertexID];
    gl_Position = Proj * vec4(cameraPos, 1);
} 

在片段着色器中,我们只是应用纹理并缩放粒子的 alpha 值:

in float Transp; 
in vec2 TexCoord;
uniform sampler2D ParticleTex; 

layout ( location = 0 ) out vec4 FragColor; 

void main() {
  FragColor = texture(ParticleTex, TexCoord); 
  FragColor.a *= Transp;
} 

为了渲染我们的粒子,我们使用glDepthMask使深度缓冲区只读,并对每个粒子使用六个顶点发出glDrawArraysInstanced调用:

glDepthMask(GL_FALSE);
glBindVertexArray(particles);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, nParticles);
glBindVertexArray(0);
glDepthMask(GL_TRUE);

它是如何工作的...

顶点着色器接收粒子的初始速度(VertexInitVel)和起始时间(VertexBirthTime)作为其两个输入属性。Time变量存储自动画开始以来经过的时间量。Transp输出变量是粒子的整体透明度。

在顶点着色器的主函数中,我们首先确定粒子的年龄(t),即当前模拟时间减去粒子的出生时间。下面的if语句确定粒子是否已经存活。如果粒子的年龄大于零,则粒子是存活的,否则,粒子尚未出生。在后一种情况下,位置被设置为相机的原点,粒子被完全透明地渲染。如果粒子的年龄大于其寿命,我们也会做同样的事情。

如果粒子是存活的,则使用之前描述的动力学方程来确定粒子的位置(pos)。cameraPos顶点位置是通过使用offsets数组偏移粒子的位置来确定的。我们将位置转换到相机坐标(使用MV),并使用gl_VertexID作为索引添加当前顶点的偏移。

gl_VertexID是 GLSL 中的一个内置变量,它承担当前实例顶点的索引。在这种情况下,由于我们每个粒子使用六个顶点,gl_VertexID将在 0 到 5 之间。

通过在相机坐标中应用偏移,我们获得了粒子系统中通常期望的质量。粒子的四边形将始终面向相机。这种称为板面渲染的效果,使粒子看起来是实心形状而不是仅仅的平面四边形。

我们通过ParticleSize缩放偏移值来设置粒子的大小。透明度是通过根据粒子的年龄进行线性插值来确定的:

Transp = mix( 1, 0, t / ParticleLifetime );

当粒子出生时它是完全不透明的,并且随着它的老化线性地变得透明。Transp的值在出生时为1.0,在粒子寿命结束时为0.0

在片段着色器中,我们使用纹理查找的结果来给片段上色。在完成之前,我们将最终颜色的 alpha 值乘以变量Transp,以便根据粒子的年龄(在顶点着色器中确定)来调整粒子的整体透明度。

更多...

这个例子旨在为基于 GPU 的粒子系统提供一个相当温和的介绍。有许多事情可以做以增强该系统的功能和灵活性。例如,我们可以改变粒子在其生命周期中的旋转,以产生不同的效果。

该配方中技术的最大缺点之一是粒子无法轻易回收。当一个粒子死亡时,它只是简单地以透明的方式渲染。如果能重用每个死亡的粒子来创建一个看似连续的粒子流那就太好了。此外,如果粒子能够适当地响应变化的加速度或系统的修改(例如,风或源头的移动)将非常有用。然而,由于我们正在着色器中执行模拟,因此我们受到写入内存方式的限制,所以我们无法做到这一点。我们需要根据当前涉及的力逐步更新位置(即模拟)。

为了实现前面的目标,我们需要一种方法将顶点着色器的输出(粒子的更新位置)反馈到下一帧顶点着色器的输入中。当然,如果我们不在着色器内进行模拟,这将很简单,因为我们可以在渲染之前直接更新原型的位置。然而,由于我们在顶点着色器内执行工作,我们在写入内存的方式上受到限制。

在下面的配方中,我们将看到一个如何使用 OpenGL 中称为变换反馈的功能来实现上述功能的例子。我们可以指定某些输出变量被发送到缓冲区,这些缓冲区可以在后续的渲染过程中作为输入读取。

参见

  • 示例代码中的chapter10/scene_particles.cpp文件

  • 使用顶点位移动画表面的配方

  • 使用变换反馈创建粒子系统的配方

使用变换反馈创建粒子系统

变换反馈提供了一种将顶点(或几何)着色器的输出捕获到缓冲区的方法,以便在后续的传递中使用。最初在 OpenGL 3.0 版本中引入,这个特性特别适合粒子系统,因为除此之外,它还使我们能够进行离散模拟。我们可以在顶点着色器内更新粒子的位置,并在后续的传递(或相同的传递)中渲染该更新位置。然后,更新的位置可以像输入一样用于下一帧动画。

在这个例子中,我们将实现与上一个配方(创建粒子喷泉)相同的粒子系统,这次我们将使用变换反馈。我们不会使用描述粒子在整个时间内的运动的方程,而是将逐步更新粒子位置,根据渲染每个帧时涉及的力来求解运动方程。

常用的技术是使用欧拉法,该方法基于较早时间点的位置、速度和加速度来近似时间t的位置和速度:

图片

在前一个方程中,下标代表时间步长(或动画帧),P表示粒子位置,v表示粒子速度。这些方程将帧n + 1的位置和速度描述为前一个帧(n)中位置和速度的函数。变量h代表时间步长大小,即帧之间经过的时间量。项a[n]代表瞬时加速度。对于我们的模拟,这将是一个常数,但在一般情况下,它可能是一个取决于环境(风、碰撞、粒子间相互作用等)的值。

欧拉法实际上是数值积分牛顿运动方程。这是实现这一目标的最简单技术之一。然而,它是一种一阶技术,这意味着它可能会引入相当大的误差。更精确的技术包括Verlet 积分Runge-Kutta 积分。由于我们的粒子模拟旨在看起来很好,且物理精度不是特别重要,因此欧拉法应该足够了。

为了使我们的模拟工作,我们将使用一种有时被称为缓冲区乒乓的技术。我们维护两组顶点缓冲区,并在每一帧交换它们的使用。例如,我们使用缓冲区A提供位置和速度作为顶点着色器的输入。顶点着色器使用欧拉法更新位置和速度,并通过变换反馈将结果发送到缓冲区B。然后,在第二次遍历中,我们使用缓冲区B渲染粒子:

图片

在下一个动画帧中,我们重复相同的过程,交换两个缓冲区。

通常,变换反馈允许我们定义一组要写入指定缓冲区(或缓冲区集)的着色器输出变量。涉及几个步骤,但基本思路如下。在着色器程序链接之前,我们使用 glTransformFeedbackVaryings 函数定义缓冲区与着色器输出变量之间的关系。在渲染过程中,我们启动一个变换反馈过程。我们将适当的缓冲区绑定到变换反馈绑定点。(如果需要,我们可以禁用光栅化,这样就不会渲染粒子。)我们使用 glBeginTransformFeedback 函数启用变换反馈,然后绘制点原语。顶点着色器的输出将存储在适当的缓冲区中。然后我们通过调用 glEndTransformFeedback 禁用变换反馈。

准备工作

创建并分配三对缓冲区。第一对用于粒子位置,第二对用于粒子速度,第三对用于每个粒子的 年龄。为了清晰起见,我们将每对中的第一个缓冲区称为 A 缓冲区,第二个称为 B 缓冲区。

创建两个顶点数组。第一个顶点数组应将 A 位置缓冲区与第一个顶点属性(属性索引 0)链接,A 速度缓冲区与顶点属性一链接,以及 A 年龄缓冲区与顶点属性二链接。

第二个顶点数组应使用 B 缓冲区以相同的方式设置。两个顶点数组的句柄将通过名为 particleArrayGLuint 数组访问。

使用适当的初始值初始化 A 缓冲区。例如,所有位置可以设置为原点,速度和起始时间可以像在先前的 创建粒子喷泉 菜谱中描述的那样初始化。初始速度缓冲区可以简单地是速度缓冲区的副本。

当使用变换反馈时,我们通过将缓冲区绑定到 GL_TRANSFORM_FEEDBACK_BUFFER 目标下的索引绑定点来定义将接收顶点着色器输出数据的缓冲区。索引对应于由 glTransformFeedbackVaryings 定义的顶点着色器输出变量的索引。

为了简化问题,我们将使用变换反馈对象。使用以下代码为每套缓冲区设置两个变换反馈对象:

GLuint feedback[2];  // Transform feedback objects 
GLuint posBuf[2];    // Position buffers (A and B) 
GLuint velBuf[2];    // Velocity buffers (A and B) 
GLuint age[2];       // Age buffers (A and B) 

// Create and allocate buffers A and B for posBuf, velBuf, and age

// Fill in the first age buffer
std::vector<GLfloat> tempData(nParticles);
float rate = particleLifetime / nParticles;
for( int i = 0; i < nParticles; i++ ) {
    tempData[i] = rate * (i - nParticles);
}
glBindBuffer(GL_ARRAY_BUFFER, age[0]);
glBufferSubData(GL_ARRAY_BUFFER, 0, nParticles * sizeof(float),
 tempData.data()); 
// Setup the feedback objects 
glGenTransformFeedbacks(2, feedback); 

// Transform feedback 0 
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback[0]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,0,posBuf[0]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,1,velBuf[0]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,2,age[0]); 

// Transform feedback 1 
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback[1]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,0,posBuf[1]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,1,velBuf[1]); 
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER,2,age[1]);

与顶点数组对象类似,变换反馈对象存储到GL_TRANSFORM_FEEDBACK_BUFFER绑定点的缓冲区绑定,以便可以在稍后快速重置。在之前的代码中,我们创建了两个变换反馈对象,并将它们的句柄存储在名为feedback的数组中。对于第一个对象,我们将posBuf[0]绑定到索引0velBuf[0]绑定到索引1startTime[0]绑定到索引2的绑定点(缓冲区集 A)。这些绑定通过glTransformFeedbackVaryings(或通过布局限定符;参见以下更多内容...部分)与着色器输出变量连接。每个的最后一个参数是缓冲区的句柄。对于第二个对象,我们使用缓冲区集 B 执行相同操作。一旦设置好,我们就可以通过绑定到一个或另一个变换反馈对象来定义接收顶点着色器输出的缓冲区集。

年龄缓冲区的初始值都是负值。绝对值表示粒子“出生”之前的时间长度。当粒子的年龄达到零时,粒子就会出生。

我们还需要一种方式来指定每个粒子的初始速度。一个简单的解决方案是使用随机速度的纹理,并在需要随机值时查询该纹理。我们将使用内置的gl_VertexID变量来访问每个粒子纹理中的唯一位置。创建一个浮点值的一维纹理,并用随机初始速度填充它(此处省略代码,但可在示例代码中找到)。

重要的统一变量如下:

  • 粒子纹理: 应用于点精灵的纹理

  • 随机纹理: 包含随机初始速度的纹理

  • 时间: 模拟时间

  • DeltaT: 定义动画帧之间的经过时间

  • 加速度: 加速度

  • 粒子寿命: 粒子存在的时间长度,在此之后它将被回收

  • 发射器: 粒子发射器在世界坐标中的位置

  • 发射器基: 用于指向发射器的旋转矩阵

  • 粒子大小: 粒子的大小

如何做到这一点...

在顶点着色器中,我们有支持两次遍历的代码:更新遍历,其中更新粒子的位置、年龄和速度,以及渲染遍历,其中绘制粒子:

const float PI = 3.14159265359;
layout (location = 0) in vec3 VertexPosition;
layout (location = 1) in vec3 VertexVelocity;
layout (location = 2) in float VertexAge;

// Render pass
uniform int Pass;

// Output to transform feedback buffers (pass 1)
out vec3 Position;
out vec3 Velocity;
out float Age;

// Out to fragment shader (pass 2)
out float Transp; // Transparency
out vec2 TexCoord; // Texture coordinate 

// Uniform variables here... (omitted)

vec3 randomInitialVelocity() {
  // Access the texture containing random velocities using gl_VertexID...
}

void update() {
  if( VertexAge < 0 || VertexAge > ParticleLifetime ) {
    // Recycle particle (or particle isn't born yet)
    Position = Emitter;
    Velocity = randomInitialVelocity();
    if( VertexAge < 0 ) Age = VertexAge + DeltaT;
    else Age = (VertexAge - ParticleLifetime) + DeltaT;
 } else {
    // The particle is alive, update.
    Position = VertexPosition + VertexVelocity * DeltaT;
    Velocity = VertexVelocity + Accel * DeltaT;
    Age = VertexAge + DeltaT;
  }
}

void render() {
  Transp = 0.0;
  vec3 posCam = vec3(0.0);
  if(VertexAge >= 0.0) {
    posCam = (MV * vec4(VertexPosition,1)).xyz + offsets[gl_VertexID] * 
    ParticleSize;
    Transp = clamp(1.0 - VertexAge / ParticleLifetime, 0, 1);
  }
  TexCoord = texCoords[gl_VertexID];
  gl_Position = Proj * vec4(posCam,1);
}

void main() {
 if( Pass == 1 ) update();
 else render();
}

片段着色器代码简单且与上一个示例相同。

在编译着色器程序后,但在链接之前,使用以下代码设置顶点着色器输出变量与输出缓冲区之间的连接:

const char * outputNames[] = { "Position", "Velocity", "Age" };
glTransformFeedbackVaryings(progHandle, 3, outputNames, GL_SEPARATE_ATTRIBS);

在 OpenGL 渲染函数中,我们将使用两次遍历。第一次遍历将粒子位置发送到顶点着色器进行更新,并使用变换反馈捕获结果。顶点着色器的输入将来自缓冲区 A,输出将存储在缓冲区 B 中。在此遍历期间,我们将启用GL_RASTERIZER_DISCARD,以便实际上不会将任何内容渲染到帧缓冲区:

// Update pass
prog.setUniform("Pass", 1);

glEnable(GL_RASTERIZER_DISCARD);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback[drawBuf]);
glBeginTransformFeedback(GL_POINTS);

glBindVertexArray(particleArray[1-drawBuf]);
glVertexAttribDivisor(0,0);
glVertexAttribDivisor(1,0);
glVertexAttribDivisor(2,0);
glDrawArrays(GL_POINTS, 0, nParticles);
glBindVertexArray(0);

glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);

注意,我们将所有粒子缓冲区的除数设置为零,并在这里使用 glDrawArrays。这里不需要使用实例化,因为我们实际上并没有渲染粒子。

在第二次传递中,我们使用第一次传递收集到的输出,使用 glDrawArraysInstanced 渲染粒子:

// Render pass
prog.setUniform("Pass", 2);

glDepthMask(GL_FALSE);
glBindVertexArray(particleArray[drawBuf]);
glVertexAttribDivisor(0,1);
glVertexAttribDivisor(1,1);
glVertexAttribDivisor(2,1);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, nParticles);
glBindVertexArray(0);
glDepthMask(GL_TRUE);

最后,我们交换缓冲区:

drawBuf = 1 - drawBuf; 

它是如何工作的...

这里有很多东西需要整理。让我们从顶点着色器开始。

顶点着色器分为两个主要函数(updaterender)。update 函数在第一次传递期间使用,并使用欧拉方法更新粒子的位置和速度。render 函数在第二次传递期间使用。它根据粒子的年龄计算透明度,并将位置和透明度传递到片段着色器。

顶点着色器有三个输出变量,在第一次传递期间使用:PositionVelocityAge。它们用于写入反馈缓冲区。

update 函数使用欧拉方法更新粒子的位置和速度,除非粒子尚未存活,或者已经过了它的生命周期。如果粒子的年龄大于粒子的生命周期,我们通过将位置重置为发射器位置、通过减去 ParticleLifetime 更新粒子的年龄,并将速度设置为一个新的随机速度(由 randomInitialVelocity 函数确定)来回收粒子。注意,如果粒子尚未第一次出生(年龄小于零),我们也会做同样的事情,只是通过 DeltaT 更新年龄。

render 函数相当直接。它通过在相机坐标中偏移粒子的位置来绘制四边形,这与之前的食谱非常相似。VertexAge 变量用于确定粒子的透明度,并将结果分配给 Transp 输出变量。它将顶点位置转换为裁剪坐标,并将结果放入内置的 gl_Position 输出变量中。

片段着色器仅在第二次传递期间使用。在第一次传递期间被禁用。它根据 ParticleTex 纹理和从顶点着色器(Transp)传递的透明度来着色片段。

下一个代码段放置在链接着色程序之前,并负责设置着色器输出变量与反馈缓冲区(绑定到GL_TRANSFORM_FEEDBACK_BUFFER绑定点的索引)之间的对应关系。glTransformFeedbackVaryings函数接受三个参数。第一个是着色程序对象的句柄。第二个是提供的输出变量名称的数量。第三个是输出变量名称的数组。此列表中名称的顺序对应于反馈缓冲区的索引。在这种情况下,Position对应于索引零,Velocity对应于索引一,Age对应于索引二。检查创建我们的反馈缓冲区对象的先前代码(glBindBufferBase调用)以验证这一点。

可以使用glTransformFeedbackVaryings将数据发送到交错缓冲区(而不是为每个变量分别使用单独的缓冲区)。请查看 OpenGL 文档以获取详细信息。

下面的代码段描述了如何在主 OpenGL 程序中实现渲染函数。在这个例子中,有两个重要的 GLuint 数组:feedbackparticleArray。它们各自的大小为两个,包含两个反馈缓冲区对象的句柄以及两个顶点数组对象。drawBuf变量只是一个整数,用于在两组缓冲区之间交替。在任何给定帧中,drawBuf将是零或一。

第一遍的代码将Pass统一变量设置为1,以在顶点着色器内启用更新功能。接下来的调用glEnable(GL_RASTERIZER_DISCARD)关闭光栅化,以确保在此遍历期间不进行渲染。调用glBindTransformFeedback选择与drawBuf变量对应的缓冲区集作为变换反馈输出的目标。

在绘制点(从而触发我们的顶点着色器)之前,我们调用glBeginTransformFeedback来启用变换反馈。参数是管道中将发送的原始类型。在这种情况下,我们使用GL_POINTS,尽管我们实际上会绘制三角形,因为我们实际上并没有绘制任何原始图形。这个遍历只是用来更新粒子,所以没有必要为每个粒子调用着色器超过一次。这也表明为什么我们需要在这个遍历中将我们的属性除数设置为零。在这个遍历中,我们不使用实例化,所以我们只想为每个粒子调用一次顶点着色器。我们通过调用glDrawArrays来实现这一点。

顶点着色器的输出将发送到绑定到GL_TRANSFORM_FEEDBACK_BUFFER绑定点的缓冲区,直到调用glEndTransformFeedback。在这种情况下,我们绑定了对应于1 - drawBuf的顶点数组(如果drawBuf是 0,我们使用 1,反之亦然)。

在更新遍历的末尾,我们通过glEnable(GL_RASTERIZER_DISCARD)重新启用光栅化,并继续到渲染遍历。

渲染过程很简单;我们只需将Pass设置为2,并从对应于drawBuf的顶点数组中绘制粒子。该顶点数组对象包含在上一过程中写入的缓冲区集合。

在这里,我们使用实例化的方式与前面菜谱中描述的相同,因此将所有属性的除数都设置回一。

最后,在渲染过程结束时,通过将drawBuf设置为1 - drawBuf来交换我们的缓冲区。

还有更多...

使用变换反馈是捕获顶点着色器输出的有效方法。然而,有一些利用 OpenGL 中引入的最近功能的方法。例如,可以使用图像加载/存储或着色器存储缓冲区对象。这些是可写缓冲区,可以提供给着色器。而不是使用变换反馈,顶点着色器可以直接将其结果写入缓冲区。这可能使您能够在单个过程中完成所有操作。我们在第十一章中使用计算着色器 Chapter 11,使用计算着色器中使用了这些,因此请在那里查找它们的使用示例。

使用布局限定符

OpenGL 4.4 引入了布局限定符,这使得在着色器中直接指定着色器输出变量与反馈缓冲区之间的关系成为可能,而不是使用glTransformFeedbackVaryings。可以为每个要用于变换反馈的输出变量指定xfb_bufferxfb_stridexfb_offset布局限定符。

查询变换反馈结果

在变换反馈过程中确定写入了多少原语通常很有用。例如,如果几何着色器处于活动状态,写入的原语数量可能不同于通过管道发送的原语数量。

OpenGL 提供了一种使用查询对象查询此信息的方法。要这样做,首先创建一个查询对象:

GLuint query; 
glGenQueries(1, &query); 

然后,在开始变换反馈过程之前,使用以下命令开始计数过程:

glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, query); 

在变换反馈过程结束后,调用glEndQuery停止计数:

glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN); 

然后,我们可以使用以下代码获取原语的数量:

GLuintprimWritten; 
glGetQueryObjectuiv(query, GL_QUERY_RESULT, &primWritten); 
printf("Primitives written: %dn", primWritten); 

参见

  • 示例代码中的chapter10/sceneparticlesinstanced.cpp文件

  • 创建粒子喷泉菜谱

使用实例网格创建粒子系统

为了给粒子系统中的每个粒子提供更多的几何细节,我们可以绘制整个网格而不是单个四边形。实例渲染是绘制特定对象多个副本的一种方便且高效的方式。OpenGL 通过glDrawArraysInstancedglDrawElementsInstanced函数提供了对实例渲染的支持。

在本例中,我们将修改前一个菜谱中引入的粒子系统。我们不会绘制单个四边形,而是在每个粒子的位置渲染一个更复杂的对象。以下图像显示了每个粒子被渲染为着色环面的示例:

在之前的配方中,我们介绍了实例渲染的基础知识,所以在阅读这一部分之前,您可能需要回顾一下。为了绘制完整的网格,我们将使用相同的基本技术,但进行一些小的修改。

我们还将添加另一个属性来控制每个粒子的旋转,以便每个粒子可以独立地以随机的旋转速度旋转。

准备工作

我们将按照使用变换反馈创建粒子系统配方中描述的方式启动粒子系统。我们只会对基本系统进行一些修改。

与之前的三对缓冲区不同,这次我们将使用四个。我们需要为粒子的位置、速度、年龄和旋转设置缓冲区。旋转缓冲区将使用vec2类型存储旋转速度和旋转角度。x分量是旋转速度,y分量是角度。所有形状都将围绕同一轴旋转。如果需要,您可以扩展以支持每粒子的旋转轴。

按照之前的配方设置其他缓冲区。

由于我们正在绘制完整的网格,我们需要为网格的每个顶点的位置和法线设置属性。这些属性将具有除数为零,而每粒子的属性将具有除数为一。在更新过程中,我们将忽略网格顶点和法线属性,专注于每粒子的属性。在渲染过程中,我们将使用所有属性。

总结一下,我们需要六个属性:

  • 属性 0 和 1:网格顶点位置和网格顶点法线(除数 = 0)

  • 属性 3-6:每粒子的属性——粒子位置、速度、年龄和旋转(渲染时除数 = 1,更新时除数 = 0

如果需要,属性 2 可以用于纹理坐标。

我们需要为每粒子的属性成对设置缓冲区,但我们需要为我们的网格数据只设置一个缓冲区,因此我们将共享网格缓冲区与两个顶点数组对象。有关详细信息,请参阅示例代码。

如何实现...

顶点着色器属性包括每粒子的值和网格值:

// Mesh attributes
layout (location = 0) in vec3 VertexPosition;
layout (location = 1) in vec3 VertexNormal;

// Per-particle attributes
layout (location = 3) in vec3 ParticlePosition;
layout (location = 4) in vec3 ParticleVelocity;
layout (location = 5) in float ParticleAge;
layout (location = 6) in vec2 ParticleRotation;

我们包括变换反馈的输出变量,用于更新传递期间,以及用于渲染传递期间的片段着色器:

// To transform feedback
out vec3 Position;
out vec3 Velocity;
out float Age;
out vec2 Rotation;

// To fragment shader
out vec3 fPosition;
out vec3 fNormal;

update函数(顶点着色器)与之前配方中使用的类似,但是在这里我们还会更新粒子的旋转:

void update() {
    if( ParticleAge < 0 || ParticleAge > ParticleLifetime ) {
        // The particle is past it's lifetime, recycle.
        Position = Emitter;
        Velocity = randomInitialVelocity();
        Rotation = vec2( 0.0, randomInitialRotationalVelocity() );
        if( ParticleAge < 0 ) Age = ParticleAge + DeltaT;
        else Age = (ParticleAge - ParticleLifetime) + DeltaT;
    } else {
        // The particle is alive, update.
        Position = ParticlePosition + ParticleVelocity * DeltaT;
        Velocity = ParticleVelocity + Accel * DeltaT;
        Rotation.x = mod( ParticleRotation.x + ParticleRotation.y 
        * DeltaT, 2.0 * PI );
        Rotation.y = ParticleRotation.y;
        Age = ParticleAge + DeltaT;
    }
}

render函数(在顶点着色器中)使用由粒子的旋转和位置属性构建的矩阵应用旋转和变换:

void render() {
    float cs = cos(ParticleRotation.x);
    float sn = sin(ParticleRotation.x);
    mat4 rotationAndTranslation = mat4(
        1, 0, 0, 0,
        0, cs, sn, 0,
        0, -sn, cs, 0,
        ParticlePosition.x, ParticlePosition.y, ParticlePosition.z, 1
    );
    mat4 m = MV * rotationAndTranslation;
    fPosition = (m * vec4(VertexPosition, 1)).xyz;
    fNormal = (m * vec4(VertexNormal, 0)).xyz;
    gl_Position = Proj * vec4(fPosition, 1.0);
}

片段着色器应用如 Blinn-Phong 之类的着色模型。代码在此省略。

当调用变换反馈传递(更新传递)时,我们禁用网格属性,并将粒子属性的除数设置为零。我们使用glDrawArrays为每个粒子调用顶点着色器:

glEnable(GL_RASTERIZER_DISCARD);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback[drawBuf]);
glBeginTransformFeedback(GL_POINTS);
glBindVertexArray(particleArray[1-drawBuf]);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glVertexAttribDivisor(3,0);
glVertexAttribDivisor(4,0);
glVertexAttribDivisor(5,0);
glVertexAttribDivisor(6,0);
glDrawArrays(GL_POINTS, 0, nParticles);
glBindVertexArray(0);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);; 

要绘制粒子,我们重新启用网格属性,将每个粒子的属性除数设置为 1,并使用 glDrawElementsInstanced 绘制 nParticles 次的环面:

glBindVertexArray(particleArray[drawBuf]);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glVertexAttribDivisor(3,1);
glVertexAttribDivisor(4,1);
glVertexAttribDivisor(5,1);
glVertexAttribDivisor(6,1);
glDrawElementsInstanced(GL_TRIANGLES, torus.getNumVerts(), 
   GL_UNSIGNED_INT, 0, nParticles);

它是如何工作的...

回想一下,传递给顶点着色器的第一个两个输入属性不是实例化的,这意味着它们在每个顶点(以及每个实例)上都会更新(并重复)。最后四个(属性 3-6)是实例化属性,并且只在每个实例上更新。因此,效果是网格实例的所有顶点都通过相同的矩阵变换,确保它作为一个单独的粒子起作用。

还有更多...

OpenGL 为顶点着色器提供了一个内置变量,名为 gl_InstanceID。这只是一个计数器,并为每个渲染的实例具有不同的值。第一个实例将具有 ID 为零,第二个将具有 ID 为一,依此类推。这可以作为为每个实例索引适当纹理数据的方法。另一种可能性是使用实例的 ID 作为生成该实例一些随机数据的方法。例如,我们可以使用实例 ID(或某些散列)作为伪随机数生成例程的种子,为每个实例获取唯一的随机流。

参见

  • 示例代码中的 chapter10/sceneparticlesinstanced.cpp 文件

  • 创建粒子喷泉 菜单

  • 使用变换反馈创建粒子系统 菜单

使用粒子模拟火焰

要创建一个大致模拟火焰的效果,我们只需要对我们的基本粒子系统进行一些修改。由于火焰是一种仅略微受重力影响的物质,我们不必担心向下的重力加速度。实际上,我们将使用轻微的向上加速度,使粒子在火焰顶部附近扩散。我们还将扩散粒子的初始位置,以便火焰的底部不是一个单独的点。当然,我们需要使用具有与火焰相关的红色和橙色颜色的粒子纹理。

以下图像显示了正在运行的粒子系统的示例:

用于粒子的纹理看起来像是火焰颜色的轻微 污点。它在这里没有显示,因为它在印刷中不太明显。

准备工作

从本章前面提供的 使用变换反馈创建粒子系统 菜单开始:

  1. 将统一变量 Accel 设置为一个小向上的值,例如 (0.0, 0.1, 0.0)。

  2. ParticleLifetime 统一变量设置为大约 3 秒。

  3. 创建并加载一个具有火焰颜色纹理的粒子。将其绑定到第一个纹理通道,并将统一变量 ParticleTex 设置为 0

  4. 使用大约 0.5 的粒子大小。这是本菜谱中使用的纹理的好大小,但您可能需要根据粒子的数量和纹理使用不同的尺寸。

如何做...

我们将使用填充随机值的纹理(每个颗粒两个值)。第一个值将用于生成初始速度,第二个值用于生成初始位置。对于初始位置,我们不是使用发射器的位置为所有颗粒,而是使用随机的 x 位置进行偏移。在生成初始速度时,我们将 xz 分量设置为零,并从随机纹理中获取 y 分量。

这与所选加速度相结合,使得每个颗粒只在 y(垂直)方向上移动:

vec3 randomInitialVelocity() {
    float velocity = mix(0.1, 0.5, texelFetch(RandomTex, 2 * 
    gl_VertexID, 0).r );
    return EmitterBasis * vec3(0, velocity, 0);
}

vec3 randomInitialPosition() {
    float offset = mix(-2.0, 2.0, texelFetch(RandomTex, 2 *
    gl_VertexID + 1, 0).r);
    return Emitter + vec3(offset, 0, 0);
} 

在片段着色器中,我们根据颗粒的年龄与黑色按比例混合颜色。这给出了火焰上升时变成烟雾的效果:

FragColor = texture(ParticleTex, TexCoord);
// Mix with black as it gets older, to simulate a bit of smoke
FragColor = vec4(mix( vec3(0,0,0), FragColor.xyz, Transp ), FragColor.a);
FragColor.a *= Transp;

它是如何工作的...

我们将所有颗粒的初始位置的 x 坐标随机分布在 -2.0 和 2.0 之间,并将初始速度的 y 坐标设置为 0.1 和 0.5 之间。由于加速度只有 y 分量,颗粒将只在 y 方向上沿直线移动。位置的位置的 xz 分量应始终保持在零。这样,当回收颗粒时,我们只需将 y 坐标重置为零,就可以重新启动颗粒到其初始位置。

还有更多...

当然,如果你想要一个在不同方向上移动的火焰,可能被风吹动,你需要使用不同的加速度值。

参见

  • 示例代码中的 chapter10/scenefire.cpp 文件

  • 使用变换反馈创建粒子系统的配方

使用颗粒模拟烟雾

烟雾由许多小颗粒组成,这些颗粒从源头飘散,并在移动过程中扩散开来。我们可以通过使用小的向上加速度(或恒定速度)来模拟浮力效果,但模拟每个小烟雾颗粒的扩散可能过于昂贵。相反,我们可以通过使模拟的颗粒随时间改变大小(增长)来模拟许多小颗粒的扩散。

下图显示了结果的一个示例:

每个颗粒的纹理是一种非常淡的灰色或黑色颜色的 污点

要使颗粒随时间增长,我们只需增加我们的四边形的尺寸。

准备工作

使用变换反馈创建粒子系统 的配方中提供的基粒子系统开始:

  1. 将统一变量 Accel 设置为一个小向上的值,如(0.0,0.1,0.0)。

  2. ParticleLifetime 统一变量设置为大约 10 秒。

  3. 创建并加载一个看起来像浅灰色污点的颗粒纹理。将其绑定到纹理单元零,并将统一变量 ParticleTex 设置为 0

  4. MinParticleSizeMaxParticleSize 统一变量分别设置为 0.12.5

如何操作...

  1. 在顶点着色器中,添加以下统一变量:
uniform float MinParticleSize = 0.1; 
uniform float MaxParticleSize = 2.5; 
  1. 此外,在顶点着色器中,在render函数中,我们将根据粒子的年龄更新粒子的大小:
void render() {
    Transp = 0.0;
    vec3 posCam = vec3(0.0);
    if( VertexAge >= 0.0 ) {
        float agePct = VertexAge / ParticleLifetime;
        Transp = clamp(1.0 - agePct, 0, 1);
        posCam =
            (MV * vec4(VertexPosition,1)).xyz +
            offsets[gl_VertexID] *
            mix(MinParticleSize, MaxParticleSize, agePct);
    }
    TexCoord = texCoords[gl_VertexID];
    gl_Position = Proj * vec4(posCam,1);
} 

它是如何工作的...

render函数根据粒子的年龄,将粒子偏移量按MinParticleSizeMaxParticleSize之间的一个值进行缩放。这导致粒子的大小随着它们在系统中的演变而增长。

参见

  • 示例代码中的chapter10/scenesmoke.cpp文件

  • 使用变换反馈创建粒子系统的配方

第十一章:使用计算着色器

在本章中,我们将介绍以下食谱:

  • 使用计算着色器实现粒子模拟

  • 使用计算着色器创建分形纹理

  • 使用计算着色器进行布料模拟

  • 使用计算着色器实现边缘检测滤波器

简介

计算着色器是在 OpenGL 4.3 版本中引入的。计算着色器是一个可以用于任意计算的着色器阶段。它提供了利用 GPU 及其固有的并行性来执行通用计算任务的能力,这些任务可能之前是在 CPU 上串行实现的。计算着色器对于与渲染无直接关系的任务最有用,例如物理模拟。

虽然 OpenCL 和 CUDA 等 API 已经可用于在 GPU 上执行通用计算,但它们与 OpenGL 完全独立。计算着色器直接集成在 OpenGL 中,因此更适合与图形渲染更紧密相关的通用计算任务。

计算着色器在相同的意义上不是传统着色器阶段,如片段着色器或顶点着色器。它不会在响应渲染命令时执行。实际上,当计算着色器与顶点、片段或其他着色器阶段链接时,在执行绘图命令时它是无反应的。执行计算着色器的唯一方法是使用 OpenGL 的glDispatchComputeglDispatchComputeIndirect命令。

计算着色器没有任何直接的用户定义输入,也没有任何输出。着色器通过使用图像访问函数(如图像加载/存储操作)直接从内存中获取其工作,或者通过着色器存储缓冲区对象。同样,它通过写入相同或其他的对象来提供其结果。计算着色器的唯一非用户定义输入是一组变量,这些变量决定了着色器调用在其执行空间中的位置。

计算着色器的调用次数完全由用户定义。它与渲染的顶点或片段的数量没有任何关联。我们通过定义工作组的数量以及每个工作组内的调用次数来指定调用次数。

计算空间和工作组

计算着色器的调用次数由用户定义的计算空间控制。这个空间被划分为多个工作组。然后,每个工作组被分解为多个调用。我们将这个概念视为全局计算空间(所有着色器调用)和局部工作组空间(特定工作组内的调用)。计算空间可以定义为一维、二维或三维空间。

技术上,它始终被定义为三维空间,但任何三个维度都可以定义为大小为 1(1),这实际上消除了该维度。

例如,一个具有五个工作组和每个工作组三次调用的单维计算空间可以表示为以下图示。较粗的线条代表工作组,较细的线条代表每个工作组内的调用:

在这种情况下,我们有 5 * 3 = 15 着色器调用。灰色着色的调用在工作组 2 中,并且在该工作组内是调用 1(调用从 0 开始索引)。我们也可以通过从零开始索引总调用数来用全局索引 7 引用该调用。全局索引确定了一个调用在全局计算空间中的位置,而不仅仅是工作组内。

它是通过将工作组数量(2)与每个工作组的调用索引数(3)相乘,再加上局部调用索引(1),即 2 * 3 + 1 = 7 得出的。全局索引简单地是全局计算空间中每个调用的索引,从左侧的零开始计数。

以下图示展示了二维计算空间的表示,其中空间被划分为 20 个工作组,x 方向上有 4 个,y 方向上有 5 个。然后每个工作组被划分为九次调用,x 方向上三次,y 方向上三次:

被灰色着色的单元格代表工作组(2, 0)内的调用(0, 1)。在这个例子中,计算着色器调用的总数是 20 * 9 = 180。这个着色调用的全局索引是(6, 1)。与一维情况一样,我们可以将这个索引视为一个全局计算空间(没有工作组),并且可以通过每个工作组的调用数乘以工作组索引,再加上局部调用索引来计算(对于每个维度)。对于 x 维度,这将等于 3 * 2 + 0 = 6,而对于 y 维度则是 3 * 0 + 1 = 1

同样的想法可以简单地扩展到三维计算空间。一般来说,我们根据要处理的数据选择维度。例如,如果我正在处理粒子物理学的物理,我可能只有一个粒子列表要处理,因此单维计算空间可能是有意义的。另一方面,如果我正在处理布料模拟,数据将具有网格结构,因此二维计算空间是合适的。

工作组总数和局部着色器调用的数量是有限制的。这些可以通过使用GL_MAX_COMPUTE_WORK_GROUP_COUNTGL_MAX_COMPUTE_WORK_GROUP_SIZEGL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS参数(通过glGetInteger*查询)来获取。

工作组的执行顺序以及因此的单独着色器调用的顺序是不确定的,系统可以以任何顺序执行它们。因此,我们不应该依赖于工作组的任何特定顺序。特定工作组内的局部调用将并行执行(如果可能)。因此,调用之间的任何通信都应该非常小心。工作组内的调用可以通过共享局部数据通信,但调用(通常)不应与其他工作组的调用通信,除非考虑到涉及的各种陷阱,如死锁和数据竞争。实际上,这些问题也可能出现在工作组内的局部共享数据中,因此必须小心避免这些问题。一般来说,出于效率的考虑,最好只在工作组内尝试通信。与任何类型的并行编程一样,“这里可能有龙。”

OpenGL 提供了一些原子操作和内存屏障,可以帮助调用之间的通信。我们将在接下来的食谱中看到一些示例。

执行计算着色器

当我们执行计算着色器时,我们定义计算空间。工作组的数量由glDispatchCompute的参数确定。例如,为了使用二维计算空间执行计算着色器,其中x维度有4个工作组,y维度有5个工作组(与前面的图匹配),我们将使用以下调用:

glDispatchCompute( 4, 5, 1 ); 

在 OpenGL 方面,每个工作组中局部调用的数量没有指定。相反,它由计算着色器本身中的布局指定符指定。例如,在这里,我们指定每个工作组有九个局部调用,x方向上3个,y方向上3个:

layout (local_size_x = 3, local_size_y = 3) in; 

z维度的大小可以省略(默认为1)。

当计算着色器的特定调用正在执行时,它通常需要确定自己在全局计算空间中的位置。GLSL 提供了一些内置输入变量来帮助实现这一点。其中大部分列在下面的表格中:

变量 类型 含义
gl_WorkGroupSize uvec3 每个维度中每个工作组的调用数——与布局指定符中定义的相同。
gl_NumWorkGroups uvec3 每个维度中工作组的总数。
gl_WorkGroupID uvec3 对于这个着色器调用,当前工作组的索引。
gl_LocalInvocationID uvec3 当前调用在当前工作组中的索引。
gl_GlobalInvocationID uvec3 当前调用在全局计算空间中的索引。

前一个表格中的最后一个变量,gl_GlobalInvocationID,是按照以下方式计算的(每个操作都是分量级的):

gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID 

这有助于我们在全局计算空间中定位当前调用(参考前面的示例)。

GLSL 还定义了 gl_LocalInvocationIndex,它是 gl_LocalInvocationID 的扁平化形式。当在线性缓冲区中提供多维数据时,它可能会有所帮助,但在随后的任何示例中都没有使用。

使用计算着色器实现粒子模拟

在这个菜谱中,我们将实现一个简单的粒子模拟。我们将让计算着色器处理物理计算并直接更新粒子位置。然后,我们只需将粒子渲染为点。如果没有计算着色器,我们就需要在 CPU 上通过遍历粒子数组并按顺序更新每个位置来更新位置,或者利用变换反馈,如第九章“使用噪声在着色器中”中 创建使用变换反馈的粒子系统 菜谱所示。

使用顶点着色器进行此类动画有时是不直观的,需要做一些额外的工作(如变换反馈设置)。有了计算着色器,我们可以在 GPU 上并行处理粒子物理,并定制我们的计算空间,以从 GPU 中获得最大的“性价比”。

下图显示了我们的粒子模拟运行,使用了百万个粒子。每个粒子被渲染为一个 1 x 1 的点。粒子部分透明,粒子吸引子被渲染为小的 5 x 5 正方形(几乎看不见):

这些模拟可以创建出美丽、抽象的图形,制作起来也非常有趣。

对于我们的模拟,我们将定义一组吸引子(在这个例子中有两个,但你可以创建更多),我将它们称为黑洞。它们将是唯一影响我们粒子的对象,并且它们将对每个粒子施加与粒子与黑洞之间距离成反比的力。更正式地说,每个粒子的力将由以下方程确定:

N 是黑洞(吸引子)的数量,r[i] 是第 i 个吸引子与粒子之间的向量(由吸引子的位置减去粒子的位置确定),G[i] 是第 i 个吸引子的强度。

要实现模拟,我们计算每个粒子的力,然后通过积分牛顿运动方程来更新位置。对于积分运动方程,存在许多经过充分研究的数值技术。对于这个模拟,简单的欧拉方法就足够了。使用欧拉方法,时间 t + Δt 时粒子的位置由以下方程给出:

P 是粒子的位置,v 是速度,a 是加速度。同样,更新的速度由以下方程确定:

这些方程是从关于时间t的位置函数的泰勒展开中推导出来的。结果取决于时间步长的大小(Δt),当时间步长非常小的时候更准确。

加速度与粒子上的力成正比,因此通过计算粒子上的力(使用前面的方程),我们本质上得到了加速度的值。为了模拟粒子的运动,我们跟踪其位置和速度,确定粒子由于黑洞而产生的力,然后使用方程更新位置和速度。

我们将使用计算着色器来实现这里的物理。由于我们只是处理粒子列表,我们将使用一维计算空间,并且每个工作组大约有 1,000 个粒子。计算着色器的每次调用将负责更新单个粒子的位置。

我们将使用着色器存储缓冲区对象来跟踪位置和速度,并且在渲染粒子本身时,我们可以直接从位置缓冲区渲染。

准备工作

在 OpenGL 方面,我们需要一个粒子位置的缓冲区和速度的缓冲区。创建一个包含粒子初始位置的缓冲区和一个初始速度为零的缓冲区。为了避免数据布局问题,本例中将使用四个分量的位置和速度。例如,要创建位置缓冲区,我们可能做如下操作:

vector<GLfloat> initPos; 

... // Set initial positions 

GLuint bufSize = totalParticles * 4 * sizeof(GLfloat); 

GLuint posBuf; 
glGenBuffers(1, &posBuf); 
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, posBuf); 
glBufferData(GL_SHADER_STORAGE_BUFFER, bufSize, &initPos[0], 
               GL_DYNAMIC_DRAW); 

对于速度数据,使用类似的过程,但将其绑定到GL_SHADER_STORAGE_BUFFER绑定位置的索引一:

glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, velBuf); 

为顶点位置设置一个使用与数据源相同的顶点缓冲区的顶点数组对象。

要渲染点,设置一个仅产生纯色的顶点和片段着色器对。启用混合并设置标准混合函数。

如何实现...

执行以下步骤:

  1. 我们将使用计算着色器来更新粒子的位置:
layout( local_size_x = 1000 ) in; 

uniform float Gravity1 = 1000.0; 
uniform vec3 BlackHolePos1; 
uniform float Gravity2 = 1000.0; 
uniform vec3 BlackHolePos2; 

uniform float ParticleInvMass = 1.0 / 0.1; 
uniform float DeltaT = 0.0005; 

layout(std430, binding=0) buffer Pos { 
  vec4 Position[]; 
}; 
layout(std430, binding=1) buffer Vel { 
  vec4 Velocity[]; 
}; 

void main() { 
  uint idx = gl_GlobalInvocationID.x; 

  vec3 p = Position[idx].xyz; 
  vec3 v = Velocity[idx].xyz; 

  // Force from black hole #1 
  vec3 d = BlackHolePos1 - p; 
  vec3 force = (Gravity1 / length(d)) * normalize(d); 

  // Force from black hole #2 
  d = BlackHolePos2 - p; 
  force += (Gravity2 / length(d)) * normalize(d); 

  // Apply simple Euler integrator 
  vec3 a = force * ParticleInvMass; 
  Position[idx] = vec4( 
        p + v * DeltaT + 0.5 * a * DeltaT * DeltaT, 1.0); 
  Velocity[idx] = vec4( v + a * DeltaT, 0.0); 
} 
  1. 在渲染例程中,调用计算着色器来更新粒子位置:
glDispatchCompute(totalParticles / 1000, 1, 1); 
  1. 然后,确保通过调用内存屏障来将所有数据写入缓冲区:
glMemoryBarrier( GL_SHADER_STORAGE_BARRIER_BIT ); 
  1. 最后,使用位置缓冲区中的数据渲染粒子。

工作原理...

计算着色器首先使用布局指定符定义每个工作组的调用次数:

layout( local_size_x = 1000 ) in; 

这指定了在x维度上每个工作组的1000次调用。你可以选择一个对你所运行的硬件最有意义的值。只需确保适当地调整工作组的数量。每个维度的默认大小为 1,因此我们不需要指定yz方向的大小。

然后,我们有一组定义模拟参数的均匀变量。Gravity1Gravity2 是两个黑洞的强度(在先前的方程中为 G),而 BlackHolePos1BlackHolePos2 是它们的位置。ParticleInvMass 是每个粒子质量的倒数,用于将力转换为加速度。最后,DeltaT 是时间步长的大小,用于在欧拉法中积分运动方程。

接下来声明位置和速度的缓冲区。注意,这里的绑定值与我们初始化缓冲区时在 OpenGL 端使用的值匹配。

在主函数中,我们首先确定这个调用负责的粒子的索引。由于我们正在处理粒子的线性列表,并且粒子的数量

粒子的数量与着色器调用的数量相同,我们想要的是全局调用范围内的索引。这个索引可以通过内置

gl_GlobalInvocationID.x 输入变量。我们在这里使用全局索引,因为我们需要的索引是在整个缓冲区内的索引,而不是工作组内的索引,后者只会引用整个数组的一部分。

接下来,我们从它们的缓冲区中检索位置和速度,并计算每个黑洞产生的力,将总和存储在 force 变量中。然后,我们将力转换为加速度,并使用欧拉法更新粒子的位置和速度。我们写入之前读取的相同位置。由于调用不共享数据,这是安全的。

在渲染例程中,我们调用计算着色器(在“如何做...”部分的步骤 2),定义每个维度的每个工作组的数量。在计算着色器中,我们指定了工作组大小为 1000。由于我们希望每个粒子有一个调用,我们将粒子总数除以 1000 以确定工作组的数量。

最后,在步骤 3 中,在渲染粒子之前,我们需要调用一个内存屏障来确保所有计算着色器的写入都已完全执行。

参见

  • 示例代码中的 chapter11/sceneparticles.cpp 文件。

  • 参考第九章 使用噪声在着色器中,了解其他粒子模拟。这些中的大多数都使用变换反馈实现,但也可以使用计算着色器实现。

使用计算着色器创建分形纹理

曼德布罗集基于以下复数多项式的迭代:

图片

zc是复数。从值z = 0 + 0i开始,我们重复应用迭代,直到达到最大迭代次数或z的值超过指定的最大值。对于给定的c值,如果迭代保持稳定(z的值不增加超过最大值),则该点是曼德布罗特集内部,我们用黑色着色与c相对应的位置。否则,我们根据值超过最大值所需的迭代次数来着色该点。

在以下图像中,曼德布罗特集的图像被作为纹理应用到立方体上:

我们将使用计算着色器来评估曼德布罗特集。由于这是一种基于图像的技术,我们将使用一个二维计算空间,每个像素有一个计算着色器调用。每个调用可以独立工作,并且不需要与其他调用共享任何数据。

准备工作

创建一个纹理来存储我们的分形计算结果。该图像应使用glBindImageTexture绑定到图像纹理单元0

GLuint imgTex; 
glGenTextures(1, &imgTex); 
glActiveTexture(GL_TEXTURE0); 
glBindTexture(GL_TEXTURE_2D, imgTex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 256, 256);  
glBindImageTexture(0, imgTex, 0, GL_FALSE, 0, GL_READ_WRITE,  
                   GL_RGBA8); 

如何做到这一点...

执行以下步骤:

  1. 在计算着色器中,我们首先定义每个工作组的着色器调用次数:
layout( local_size_x = 32, local_size_y = 32 ) in; 
  1. 然后,我们声明输出图像以及其他一些统一变量:
layout( binding = 0, rgba8) uniform image2D ColorImg; 
#define MAX_ITERATIONS 100 
uniform vec4 CompWindow; 
uniform uint Width = 256; 
uniform uint Height = 256; 
  1. 我们定义一个函数来计算复平面上给定位置的迭代次数:
uint mandelbrot( vec2 c ) { 
  vec2 z = vec2(0.0,0.0); 
  uint i = 0; 
  while(i < MAX_ITERATIONS && (z.x*z.x + z.y*z.y) < 4.0) { 
    z = vec2( z.x*z.x-z.y*z.y+c.x, 2 * z.x*z.y + c.y );  
    i++; 
  } 
  return i; 
} 
  1. 在主函数中,我们首先计算复空间中像素的大小:
void main() { 
  float dx = (CompWindow.z - CompWindow.x) / Width;  
  float dy = (CompWindow.w - CompWindow.y) / Height;
  1. 然后,我们确定这次调用的c值:
  vec2 c = vec2(  
      dx * gl_GlobalInvocationID.x + CompWindow.x, 
      dy * gl_GlobalInvocationID.y + CompWindow.y); 
  1. 接下来,我们调用mandelbrot函数并根据迭代次数确定颜色:
  uint i = mandelbrot(c);  
  vec4 color = vec4(0.0,0.5,0.5,1); 
  if( i < MAX_ITERATIONS ) { 
    if( i < 5 )  
         color = vec4(float(i)/5.0,0,0,1); 
    else if( i < 10 )  
         color = vec4((float(i)-5.0)/5.0,1,0,1); 
    else if( i < 15 )  
         color = vec4(1,0,(float(i)-10.0)/5.0,1); 
    else color = vec4(0,0,1,0); 
  } 
  else 
    color = vec4(0,0,0,1); 
  1. 接着,我们将颜色写入输出图像:
  imageStore(ColorImg,  
             ivec2(gl_GlobalInvocationID.xy), color);  
} 
  1. 在 OpenGL 程序的渲染函数中,我们针对每个 texel 执行计算着色器,并调用glMemoryBarrier
glDispatchCompute(256/32, 256/32, 1); 
glMemoryBarrier( GL_SHADER_IMAGE_ACCESS_BARRIER_BIT ); 
  1. 然后,我们渲染场景,将纹理应用到适当的对象上。

它是如何工作的...

在步骤 2 中,ColorImg统一变量是输出图像。它被定义为位于图像纹理单元0(通过binding布局选项)。此外,请注意格式是rgb8,这必须与创建纹理时在glTexStorage2D调用中使用的格式相同。

MAX_ITERATIONS是前面提到的复多项式的最大迭代次数。CompWindow是我们正在工作的复空间区域。窗口的左下角的前两个组件CompWindow.xy是窗口的实部和虚部,而CompWindow.zw是右上角。WidthHeight定义了纹理图像的大小。

mandelbrot函数(步骤 3)接受一个值c作为参数,并重复迭代复函数,直到达到最大迭代次数或z的绝对值大于2。注意,在这里,我们避免计算平方根,只是比较绝对值的平方与4。该函数返回总的迭代次数。

在主函数(第 4 步)中,我们首先计算复窗口内像素的大小(dxdy)。这仅仅是窗口大小除以每个维度的 texel 数量。

计算着色器调用负责位于gl_GlobalInvocationID.xy的 texel。接下来,我们计算与该 texel 对应的复平面上的点。对于x位置(实轴),我们取该方向上 texel 的大小(dx)乘以gl_GlobalInvocationID.x(这给出了窗口左侧边缘的距离),再加上窗口左侧边缘的位置(CompWindow.x)。对于 y 位置(虚轴)也进行类似的计算。

在第 6 步中,我们使用刚刚确定的c值调用mandelbrot函数,并根据返回的迭代次数确定颜色。

在第 7 步,我们使用imageStore将颜色应用到输出图像的gl_GlobalInvocationID.xy位置。

在 OpenGL 渲染函数(第 8 步)中,我们使用足够的调用次数来分配计算着色器,使得每个 texel 有一个调用。glMemoryBarrier调用确保在继续之前所有对输出图像的写入都已完成。

还有更多...

在计算着色器出现之前,我们可能会选择使用片段着色器来完成这项工作。然而,计算着色器为我们提供了更多的灵活性,以定义如何在 GPU 上分配工作。我们还可以通过避免为单个纹理的完整 FBO 开销来提高内存效率。

参见

  • 示例代码中的chapter11/scenemandelbrot.cpp文件

使用计算着色器进行布料模拟

计算着色器非常适合利用 GPU 进行物理模拟。布料模拟是一个很好的例子。在这个菜谱中,我们将使用计算着色器实现一个基于粒子-弹簧的简单布料模拟。以下是一张布料通过五个钉子悬挂的模拟图像(您需要想象它在动画化):

图片

表示布料的一种常见方式是使用粒子-弹簧晶格。布料由一个二维的点质量网格组成,每个点质量通过理想化的弹簧与其八个相邻的质量连接。以下图表示了一个点质量(中心)与其相邻质量连接的情况。线条代表弹簧。深色线条是水平/垂直弹簧,虚线是斜向弹簧:

图片

一个粒子的总力是连接到它的八个弹簧产生的力的总和。单个弹簧的力由以下方程给出:

图片

K 是弹簧的刚度,R 是弹簧的静止长度(弹簧施加零力的长度),而 r 是相邻粒子与粒子之间的矢量(相邻粒子的位置减去粒子的位置)。

与之前的配方类似,过程仅仅是计算每个粒子的总力,然后使用我们喜欢的积分方法积分牛顿运动方程。再次强调,我们将使用欧拉法来演示这个例子。有关欧拉法的详细信息,请参阅之前的使用计算着色器实现粒子模拟配方。

这个粒子-弹簧晶格显然是一个二维结构,因此将其映射到二维计算空间是有意义的。我们将定义矩形工作组,并为每个粒子使用一个着色器调用。每个调用需要读取其八个邻居的位置,计算粒子的力,并更新粒子的位置和速度。

注意,在这种情况下,每个调用都需要读取相邻粒子的位置。这些相邻粒子将由其他着色器调用更新。由于我们不能依赖于着色器调用的任何执行顺序,我们不能直接读写同一个缓冲区。如果我们这样做,我们就无法确定我们是在读取邻居的原始位置还是它们更新的位置。为了避免这个问题,我们将使用成对的缓冲区。对于每个模拟步骤,一个缓冲区将指定为读取,另一个为写入,然后我们将在下一步中交换它们,并重复此过程。

通过仔细使用局部共享内存,可能能够读写同一个缓冲区;然而,仍然存在工作组边缘的粒子问题。它们的邻居位置由另一个工作组管理,再次,我们面临相同的问题。

这个模拟对数值噪声非常敏感,因此我们需要使用一个非常小的积分时间步长。大约 0.000005 的值效果很好。此外,当我们将阻尼力应用于模拟空气阻力时,模拟看起来会更好。模拟空气阻力的一种好方法是添加一个与速度成正比且方向相反的力,如下面的方程所示:

D 是阻尼力的强度,而 v 是粒子的速度。

准备工作

首先为粒子位置和速度设置两个缓冲区。我们将它们绑定到 GL_SHADER_STORAGE_BUFFER 索引绑定点,索引 01 用于位置缓冲区,23 用于速度缓冲区。这些缓冲区中的数据布局很重要。我们将以行主序的顺序从格子的左下角开始布局粒子位置/速度,一直延伸到右上角。

我们还将设置一个顶点数组对象,用于使用粒子位置作为三角形顶点绘制布料。我们可能还需要缓冲区来存储法向量和纹理坐标。为了简洁,我将省略这些内容,但本书的示例代码中包含了它们。

如何做...

执行以下步骤:

  1. 在计算着色器中,我们首先定义每个工作组中的调用次数:
layout( local_size_x = 10, local_size_y = 10 ) in; 
  1. 然后,我们定义一组用于模拟参数的统一变量:
uniform vec3 Gravity = vec3(0,-10,0); 
uniform float ParticleMass = 0.1; 
uniform float ParticleInvMass = 1.0 / 0.1; 
uniform float SpringK = 2000.0; 
uniform float RestLengthHoriz; 
uniform float RestLengthVert; 
uniform float RestLengthDiag; 
uniform float DeltaT = 0.000005; 
uniform float DampingConst = 0.1; 
  1. 接下来,声明位置和速度的着色器存储缓冲区对:
layout(std430, binding=0) buffer PosIn { 
  vec4 PositionIn[]; 
}; 
layout(std430, binding=1) buffer PosOut { 
  vec4 PositionOut[]; 
}; 
layout(std430, binding=2) buffer VelIn { 
  vec4 VelocityIn[]; 
}; 
layout(std430, binding=3) buffer VelOut { 
  vec4 VelocityOut[]; 
}; 
  1. 在主函数中,我们获取此调用负责的粒子的位置:

    负责:

void main() { 
  uvec3 nParticles = gl_NumWorkGroups * gl_WorkGroupSize; 
  uint idx = gl_GlobalInvocationID.y * nParticles.x +  
             gl_GlobalInvocationID.x; 

  vec3 p = vec3(PositionIn[idx]); 
  vec3 v = vec3(VelocityIn[idx]), r; 
  1. 使用重力产生的力初始化我们的力:
  vec3 force = Gravity * ParticleMass; 
  1. 在此之前的粒子上添加力:
  if( gl_GlobalInvocationID.y < nParticles.y - 1 ) { 
    r = PositionIn[idx + nParticles.x].xyz - p; 
    force += normalize(r)*SpringK*(length(r) -  
                                 RestLengthVert); 
  }  
  1. 对以下粒子以及左侧和右侧的粒子重复前面的步骤。然后,添加来自左上方的粒子的力:
  if( gl_GlobalInvocationID.x > 0 &&  
      gl_GlobalInvocationID.y < nParticles.y - 1 ) { 
    r = PositionIn[idx + nParticles.x - 1].xyz - p; 
    force += normalize(r)*SpringK*(length(r) -  
                                 RestLengthDiag); 
  } 
  1. 对其他三个对角连接的粒子重复前面的步骤。然后,添加阻尼力:
  force += -DampingConst * v;
  1. 接下来,我们使用欧拉方法积分运动方程:
  vec3 a = force * ParticleInvMass; 
  PositionOut[idx] = vec4( 
      p + v * DeltaT + 0.5 * a * DeltaT * DeltaT, 1.0); 
  VelocityOut[idx] = vec4( v + a * DeltaT, 0.0); 
  1. 最后,我们将一些顶点固定,以便它们不会移动:
  if( gl_GlobalInvocationID.y == nParticles.y - 1 &&  
      (gl_GlobalInvocationID.x == 0 ||  
       gl_GlobalInvocationID.x == nParticles.x / 4 || 
       gl_GlobalInvocationID.x == nParticles.x * 2 / 4 || 
       gl_GlobalInvocationID.x == nParticles.x * 3 / 4 || 
       gl_GlobalInvocationID.x == nParticles.x - 1)) { 
    PositionOut[idx] = vec4(p, 1.0); 
    VelocityOut[idx] = vec4(0,0,0,0); 
  } 
} 
  1. 在 OpenGL 渲染函数中,我们调用计算着色器,以便每个工作组负责 100 个粒子。由于时间步长非常小,我们需要多次执行此过程(1000 次),每次交换输入和输出缓冲区:
for( int i = 0; i < 1000; i++ ) { 
  glDispatchCompute(nParticles.x/10, nParticles.y/10, 1); 
  glMemoryBarrier( GL_SHADER_STORAGE_BARRIER_BIT ); 

  // Swap buffers 
  readBuf = 1 - readBuf; 

  glBindBufferBase(GL_SHADER_STORAGE_BUFFER,0, 
                    posBufs[readBuf]); 
  glBindBufferBase(GL_SHADER_STORAGE_BUFFER,1, 
                   posBufs[1-readBuf]); 
  glBindBufferBase(GL_SHADER_STORAGE_BUFFER,2, 
                   velBufs[readBuf]); 
  glBindBufferBase(GL_SHADER_STORAGE_BUFFER,3, 
                   velBufs[1-readBuf]); 
} 
  1. 最后,我们使用位置缓冲区中的位置数据渲染布料。

它是如何工作的...

我们在每个工作组中使用 100 个调用,每个维度 10 个。计算着色器中的第一个语句定义了每个工作组中的调用次数:

layout( local_size_x = 10, local_size_y = 10 ) in; 

下面的统一变量定义了力方程中的常数以及每个水平、垂直和对角弹簧的长度。时间步长大小是 DeltaT。接下来声明位置和速度缓冲区。我们定义位置缓冲区在绑定索引 01,速度缓冲区在索引 23

在主函数(步骤 4)中,我们首先确定每个维度的粒子数量。这将与工作组的数量乘以工作组大小相同。接下来,我们确定此调用负责的粒子的索引。由于粒子在缓冲区中按行主序组织,我们通过 y 方向的全局调用 ID 乘以 x 维度的粒子数量,再加上 x 方向的全局调用 ID 来计算索引。

在步骤 5 中,我们使用重力,Gravity 乘以粒子的质量(ParticleMass)来初始化我们的力。请注意,在这里乘以质量实际上并不是必需的,因为所有粒子都有相同的质量。我们只需预先将质量乘入重力常数。

在步骤 6 和 7 中,我们添加了由虚拟弹簧连接的每个相邻粒子对当前粒子的力。对于每根弹簧,我们添加由该弹簧产生的力。然而,我们首先需要检查我们是否位于晶格的边缘。如果是,可能没有相邻的粒子(参见以下图示)。

例如,在上面的代码中,当计算前面弹簧/粒子的力时,我们验证gl_GlobalInvocationID.y是否小于y维度中粒子数量的减一。如果是这样,那么必须有一个粒子位于这个粒子上方。否则,当前粒子位于晶格的顶部边缘,上方没有相邻的粒子。(本质上,gl_GlobalInvocationID包含了粒子在整体晶格中的位置。)我们可以对其他三个水平/垂直方向进行类似的测试。当计算对角连接的粒子的力时,我们需要检查我们是否不在水平和垂直边缘上。例如,在上面的代码中,我们正在寻找位于上方和左方的粒子,因此我们检查gl_GlobalInvocationID.x是否大于零(不在左边边缘),以及gl_GlobalInvocationID.y是否小于 y 方向中粒子数量的减一(不在顶部边缘):

图片

一旦我们验证了相邻粒子的存在,我们就计算连接到该粒子的弹簧产生的力,并将其添加到总力中。我们在缓冲区中按行主序组织粒子。因此,要访问相邻粒子的位置,我们取当前粒子的索引,并添加/减去x方向中粒子数量以垂直移动,以及/或添加/减去一个以水平移动。

在步骤 8 中,我们通过将速度乘以阻尼系数DampingConst来添加模拟空气阻力的阻尼力,并将其添加到总力中。这里的负号确保力与速度方向相反。

在步骤 9 中,我们应用欧拉方法根据力更新位置和速度。我们将力乘以粒子质量的倒数以获得加速度,然后将欧拉积分的结果存储到输出缓冲区中相应的位置。

最后,在步骤 10 中,如果粒子位于布料顶部的五个固定位置之一,我们重置粒子的位置。

在 OpenGL 渲染函数(步骤 11)中,我们多次调用计算着色器,每次调用后切换输入/输出缓冲区。在调用glDispatchCompute之后,我们发出glMemoryBarrier调用以确保在交换缓冲区之前所有着色器写入都已完成。一旦完成,我们就使用着色器存储缓冲区中的位置渲染布料。

还有更多...

对于渲染,拥有法向量是有用的。一个选项是创建另一个计算着色器,在位置更新后重新计算法向量。例如,我们可能执行前面的计算着色器 1,000 次,然后一次调度其他计算着色器来更新法向量,接着渲染布料。

此外,我们可能通过在工作组内使用局部共享数据来获得更好的性能。在前面的实现中,每个粒子的位置最多被读取八次。每次读取在执行时间上可能都是昂贵的。从更接近 GPU 的内存读取会更快。实现这一点的办法是一次性将数据读入局部共享内存,然后从共享内存中进行后续读取。在下一个配方中,我们将看到一个如何这样做的例子。以类似的方式更新这个配方将是直接的。

参见

  • 示例代码中的 chapter11/scenecloth.cpp 文件

  • 使用计算着色器实现边缘检测滤波器 的配方

使用计算着色器实现边缘检测滤波器

在第六章的 应用边缘检测滤波器 配方中,我们看到了如何使用片段着色器实现边缘检测的例子。片段着色器非常适合许多图像处理操作,因为我们可以通过渲染一个填充屏幕的四边形来触发每个像素的片段着色器执行。由于图像处理滤波器通常应用于渲染结果,我们可以将渲染输出到纹理中,然后对每个屏幕像素(通过渲染一个四边形)调用片段着色器,每个片段着色器的调用负责处理单个像素。每次调用可能需要从(渲染的)图像纹理的几个位置读取,并且一个纹理元素可能被不同的调用多次读取。

这在很多情况下都很好用,但片段着色器并不是为图像处理设计的。使用计算着色器,我们可以对着色器调用的分布有更精细的控制,并且我们可以利用局部共享内存来在数据读取上获得更多的效率。

在这个例子中,我们将使用计算着色器重新实现边缘检测滤波器。我们将利用局部(工作组)共享内存来获得额外的速度。由于这种局部内存更接近 GPU,内存访问比直接从着色器存储缓冲区(或纹理)读取要快。

与前面的配方一样,我们将使用 Sobel 算子来实现这个功能,它由两个 3 x 3 的滤波内核组成,如下所示:

关于 Sobel 算子的详细信息,请参阅第六章,图像处理和屏幕空间技术。这里的关键点是,为了计算给定像素的结果,我们需要读取八个相邻像素的值。这意味着每个像素的值需要被提取多达八次(当处理该像素的邻居时)。为了获得一些额外的速度,我们将所需数据复制到本地共享内存中,这样在工作组内,我们可以从共享内存中读取而不是从着色器存储缓冲区中提取。

工作组共享内存通常比纹理或着色器存储内存更快访问。

在本例中,我们将为每个像素使用一个计算着色器调用,并且使用 25 x 25 的 2D 工作组大小。在计算 Sobel 算子之前,我们将相应的像素值复制到工作组的本地共享内存中。对于每个像素,为了计算滤波器,我们需要读取八个相邻像素的值。为了处理工作组边缘的像素,我们需要在我们的本地内存中包含一个额外的像素条,超出工作组的边缘。因此,对于 25 x 25 的工作组大小,我们需要 27 x 27 的存储大小。

准备工作

首先,设置渲染到帧缓冲对象FBO)并附加一个颜色纹理;我们将把原始预过滤图像渲染到这个纹理上。创建第二个纹理以接收边缘检测滤波器的输出。将这个纹理绑定到单元0。我们将使用这个作为计算着色器的输出。使用glBindImageTexture将 FBO 纹理绑定到图像纹理单元0,并将第二个纹理绑定到图像纹理单元1

接下来,设置一个用于直接渲染到 FBO 以及渲染全屏纹理的顶点/片段着色器对。

如何实现...

执行以下步骤:

  1. 在计算着色器中,像往常一样,我们首先定义每个工作组的着色器调用次数:
layout (local_size_x = 25, local_size_y = 25) in; 
  1. 接下来,我们声明用于输入和输出图像以及边缘检测阈值的统一变量。输入图像是从 FBO 渲染的图像,输出图像将是边缘检测滤波器的结果:
uniform float EdgeThreshold = 0.1; 
layout(binding=0, rgba8) uniform image2D InputImg; 
layout(binding=1, rgba8) uniform image2D OutputImg; 
  1. 然后,我们声明我们的工作组共享内存,它是一个大小为 27 x 27 的数组:
shared float 
     localData[gl_WorkGroupSize.x+2][gl_WorkGroupSize.y+2]; 
  1. 我们还定义了一个用于计算像素亮度的函数,称为luminance。由于相同的函数在几个先前的菜谱中使用,这里不需要重复。

  2. 接下来,我们定义一个函数,该函数将 Sobel 滤波器应用于与这个着色器调用对应的像素。它直接从本地共享数据中读取:

void applyFilter() 
{ 
  uvec2 p = gl_LocalInvocationID.xy + uvec2(1,1); 

  float sx = localData[p.x-1][p.y-1] +  
            2*localData[p.x-1][p.y] + 
            localData[p.x-1][p.y+1] - 
           (localData[p.x+1][p.y-1] +  
            2 * localData[p.x+1][p.y] +  
            localData[p.x+1][p.y+1]); 
  float sy = localData[p.x-1][p.y+1] +  
             2*localData[p.x][p.y+1] +  
             localData[p.x+1][p.y+1] -  
            (localData[p.x-1][p.y-1] +  
             2 * localData[p.x][p.y-1] +  
             localData[p.x+1][p.y-1]); 
  float g = sx * sx + sy * sy; 

  if( g > EdgeThreshold ) 
    imageStore(OutputImg,  
       ivec2(gl_GlobalInvocationID.xy), vec4(1.0)); 
  else 
    imageStore(OutputImg,  
       ivec2(gl_GlobalInvocationID.xy), vec4(0,0,0,1)); 
} 
  1. 在主函数中,我们首先将此像素的亮度复制到共享内存数组中:
void main() 
{ 
  localData 
   [gl_LocalInvocationID.x+1][gl_LocalInvocationID.y+1] =  
   luminance(imageLoad(InputImg,  
             ivec2(gl_GlobalInvocationID.xy)).rgb); 
  1. 如果我们在工作组的边缘,我们需要将一个或多个额外的像素复制到共享内存数组中,以便填充边缘周围的像素。因此,我们需要确定我们是否在工作组的边缘(通过检查 gl_LocalInvocationID),然后确定我们负责复制的像素。这并不复杂,但由于我们还需要确定外部像素是否实际存在,所以相当复杂且冗长。例如,如果此工作组位于全局图像的边缘,则一些边缘像素不存在(位于图像之外)。由于其长度,我不会在这里包含那段代码。有关完整详情,请从 GitHub 网站获取此书的代码。

  2. 一旦我们复制了此着色器调用负责的数据,我们需要等待其他调用执行相同的操作,因此在这里我们调用一个屏障。然后,我们调用我们的 applyFilter 函数来计算过滤器并将结果写入输出图像:

  barrier(); 

  // Apply the filter using local memory 
  applyFilter(); 
}
  1. 在 OpenGL 渲染函数中,我们首先将场景渲染到 FBO,然后调度计算着色器,并等待它完成对输出图像的所有写入操作:
glDispatchCompute(width/25, height/25, 1); 
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); 
  1. 最后,我们通过全屏四边形将输出图像渲染到屏幕上。

它是如何工作的...

在步骤 1 中,我们指定每个工作组 625 次着色器调用,每个维度 25 次。根据代码运行的系统,这可能需要更改以更好地匹配可用的硬件。

常量 image2D 变量(步骤 2)是输入和输出图像。注意布局限定符中指示的绑定位置。这些对应于 glBindImageTexture 调用中指定的图像单元。输入图像应包含渲染的场景,对应于绑定到 FBO 的图像纹理。输出图像将接收过滤器的结果。请注意使用 rgb8 作为格式。这必须与使用 glTexStorage2D 创建图像时使用的格式相同。

localData 数组在步骤 3 中声明为具有共享限定符。这是我们工作组的本地共享内存。其大小为 27 x 27,以便包括一个额外的条带,边缘宽度为 1 像素。我们在这里存储工作组中所有像素的亮度,以及宽度为 1 的周围像素条的亮度。

applyFilter 函数(步骤 5)是计算 Sobel 算子,使用的是 localData 中的数据。这相当直接,除了由于边缘额外的条带而需要应用的一个偏移量。负责此调用的像素的亮度位于:

p = gl_LocalInvocationID.xy + uvec2(1,1); 

如果没有额外的像素条带,我们就可以直接使用 gl_LocalInvocationID,但在这里我们需要在每个维度上添加一个偏移量为一的值。

接下来的几个语句只是计算 Sobel 算子,并确定梯度的幅度,存储在g中。这是通过读取八个附近像素的亮度,从共享数组localData中读取来完成的。

applyFilter函数的末尾,我们将结果写入OutputImg。这要么是(1,1,1,1),要么是(0,0,0,1),具体取决于g是否超过阈值。注意,在这里,我们使用gl_GlobalInvocationID作为输出图像中的位置。全局 ID 适用于确定全局图像中的位置,而局部 ID 告诉我们我们在局部工作组中的位置,更适合访问局部共享数组。

在主函数(步骤 6)中,我们计算与此次调用对应的像素(在gl_GlobalInvocationID)的亮度,并将其存储在本地共享内存(localData)中,位置为gl_LocalInvocationID + 1。同样,+ 1是由于边缘像素的额外空间。

下一步(步骤 7)是复制边缘像素。我们只有在此次调用位于工作组边缘时才这样做。此外,我们还需要确定边缘像素实际上是否存在。有关详细信息,请参阅本书附带的代码。

在步骤 8 中,我们调用 GLSL 屏障函数。这同步了工作组内所有着色器调用到代码的这个点,确保所有对局部共享数据的写入都已完成。如果不调用屏障函数,则无法保证所有着色器调用都会完成对localData的写入,因此数据可能是不完整的。移除此调用并观察结果是有趣的(也是富有教育意义的)。

最后,我们调用applyFilter来计算 Sobel 算子并将其写入输出图像。

在 OpenGL 渲染函数中,我们调度计算着色器,以便有足够的工作组来覆盖图像。由于工作组大小为 25 x 25,我们在x维度上调用width/25个工作组,在y维度上调用height/25个工作组。结果是输入/输出图像中的每个像素都有一个着色器调用。

还有更多...

这是一个使用局部共享内存的简单示例。它仅因我们需要处理额外的行/列像素而略显复杂。然而,一般来说,局部共享数据可以用于工作组内调用之间的任何类型的通信。在这种情况下,数据不是用于通信,而是通过减少从图像的全局读取次数来提高效率。

注意,共享内存的大小(有时是严格的)有限。我们可以使用GL_MAX_COMPUTE_SHARED_MEMORY_SIZE(通过glGetInteger*)来查询当前硬件上可用的最大大小。OpenGL 规范要求的最小大小是 32 KB。

参见

  • 示例代码中的chapter11/sceneedge.cpp文件

  • 在第六章的应用边缘检测过滤器菜谱中,图像处理和屏幕空间技术

posted @ 2025-10-23 15:11  绝不原创的飞龙  阅读(45)  评论(0)    收藏  举报