OpenGL-ES3-0-秘籍-全-

OpenGL ES3.0 秘籍(全)

原文:zh.annas-archive.org/md5/6af3fef9d7eaacad35584099b375188e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

OpenGL ES 3.0 是一个针对嵌入式系统的免费、硬件加速的图形渲染应用程序编程接口。它用于使用现代可编程图形管道可视化 2D 和 3D 图形。“一次编写,到处使用”是 OpenGL ES 背后的真正力量,这使得它成为嵌入式行业标准。OpenGL ES 3.0 是一个跨平台图形库,并为其打开了通往许多其他尖端技术的门户,如并行处理库(OpenCL)和数字图像处理(OpenCV),这些技术与许多其他开源社区解决方案协同工作。

这本书的主要优势在于,它从零开始涵盖了使用 OpenGL ES 3.0 进行实时渲染图形和可视化开发。它让用户了解如何定义 OpenGL ES 3.0 应用程序的框架。这些是一些使这本书与其他市场上可用的书籍截然不同的技术。这本书背后的理念是,通过 Android 和 iOS 作为嵌入式平台,让您深入了解这个新版本的图形 API,并从头开始使用它来实现计算机图形的基本概念和高级概念。这本书涵盖了从现代 3D 图形的基本概念到使用 OpenGL ES 3.0 的高级实时渲染技术的广泛内容。

本书涵盖的内容

第一章, Android/iOS 上的 OpenGL ES 3.0,带您了解如何开发 Android 和 iOS 的 OpenGL ES 3.0 应用程序。本章展示了如何加载和编译着色器程序,除了在 GL 着色语言 3.0 中编写着色器程序的过程之外。

第二章, OpenGL ES 3.0 基础,为您提供了理解 3D 图形并使用 OpenGL ES 3.0 实现它们所需的基本概念的详细描述。我们将使用 GLPI 框架构建原型,并实现带有模型、视图和投影模拟的触摸事件和场景。

第三章, OpenGL ES 3.0 的新特性,帮助您了解 OpenGL ES 3.0 和 GL 着色语言 3.0 中引入的各种新特性。本章告诉您如何使用限定符管理变量属性,以及如何使用几何实例化和原始重启动渲染多个对象。

第四章, 处理网格,教您如何使用 Blender 创建简单的网格,Blender 是一个开源的 3D 建模工具。此外,本章还涵盖了 3D 网格模型的各种方面,这些方面将有助于在 3D 图形中渲染它们。本章还介绍了如何在 OpenGL ES 3.0 应用程序中使用创建的网格模型。

第五章,光与材料,介绍了 3D 图形中光和材料的概念。它还涵盖了某些重要的常见光照技术,例如 Phong 和 Gouraud 着色,这些技术将帮助你在计算机图形中实现看起来逼真的光照模型。

第六章,使用着色器,让你深入了解着色器编程技术。它讨论了可以使用顶点和片段着色器实现的各种技术,揭示了它们的性能。本章帮助你通过程序化着色器编程来玩转片段。

第七章,纹理和映射技术,对纹理进行了探讨,这是 3D 计算机图形学研究中的一个非常有趣的部分。纹理化是一种技术,其中 3D 网格模型的表面被涂上静态图像。本章全部关于图像纹理,并解释了它在 3D 计算机图形领域的各种应用。本章涵盖了大量的映射技术,例如环境、凹凸、位移映射等。

第八章,字体渲染,详细描述了如何构建字体引擎以及使用 Harfbuzz 渲染不同语言和抬头显示(HUD)上的文本。

第九章,后处理和图像效果,展示了基于场景效果和基于图像效果的无限可能性,这些效果在数据可视化和后期效果领域被广泛使用。这包括边缘检测、图像模糊、实时发光、浮雕效果等应用。

第十章,使用场景图进行场景管理,介绍了一种场景图范式,它允许你高效地编程和管理复杂场景。本章将帮助你创建一个小的架构,允许你管理多个场景。每个场景都包含多个光源、相机和模型。

第十一章,抗锯齿技术,告诉你如何实现快速近似抗锯齿(FXAA)、自适应抗锯齿和抗锯齿圆几何形状。

第十二章,实时阴影和粒子系统,展示了如何使用阴影映射来实现阴影,并通过百分比更接近过滤和方差阴影映射技术来改进它。它还讨论了粒子渲染的基础。本章教你使用同步对象和栅栏来实现变换反馈,这些可以帮助你实现高性能、GPU 驱动和实时图形应用。

附录,OpenGL ES 3.0 补充信息,涵盖了我们在 iOS 和 Android 平台上开发 OpenGL ES 3.0 应用程序所需的所有基本要求。本章教您两种使用 Android ADT 和 Android Studio 进行 Android 应用程序开发的方法。这还为您提供了 OpenGL ES 3.0 架构的简单概述。这个概述还有助于您理解各种计算机图形术语的技术术语。

您需要这本书什么

OpenGL ES 3.0 是平台无关的;因此,您可以使用任何平台机器,例如 Windows、Linux 或 Mac,进行您的应用程序开发。

这本书面向谁

如果您是 OpenGL ES 的新手或对 3D 图形有一些经验,那么这本书将极大地帮助您从新手提升到专业水平。本书实现了 90 多个食谱来解决日常挑战,帮助您从初学者过渡到专业人士。

章节

本书包含以下章节:

准备工作

本节告诉我们可以在食谱中期待什么,并描述了如何设置任何软件或食谱所需的任何初步设置。

如何做…

本节描述了“烹饪”食谱时应遵循的步骤。

它是如何工作的…

本节通常包含对上一节发生情况的简要和详细说明。

还有更多…

它包含有关食谱的附加信息,以便让读者对食谱更加关注。

参见

本节可能包含对食谱的参考。

惯例

在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 标签如下所示:“创建一个名为SimpleTextureVertex.glsl的顶点着色器文件。”

代码块设置如下:

glTexImage2D ( target, 0, GL_RGBA,  memData.width,
memData.height,0,GL_RGBA,GL_UNSIGNED_BYTE,memData.bitsraw);

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“使用构建设置 | 搜索路径 | 头文件搜索路径提供 Harfbuzz 项目的头文件路径。”

注意

警告或重要注意事项以如下框中的形式出现。

小贴士

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

读者反馈

我们的读者反馈总是受欢迎的。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。

要发送一般反馈,请简单地发送一封电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及书名。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

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

下载本书的彩色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/5527OT_ColoredImages.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面提供的帮助。

询问

如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。

第一章. Android/iOS 上的 OpenGL ES 3.0

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

  • 使用 OpenGL ES 着色语言 3.0 编程着色器

  • 加载和编译着色器程序

  • 链接着色器程序

  • 检查 OpenGL ES 3.0 中的错误

  • 使用顶点属性将数据发送到着色器

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

  • 编程 OpenGL ES 3.0 的 Hello World 三角形

  • 在 Android 上使用 JNI 与 C/C++ 通信

  • 开发 Android OpenGL ES 3.0 应用程序

  • 开发 iOS OpenGL ES 3.0 应用程序

简介

OpenGL ES 3.0 代表嵌入式系统版 Open Graphics Library 3.0。它是由 Khronos Group 建立的一套标准 API 规范。Khronos Group 是一个由成员和组织组成的协会,专注于为免版税 API 产生开放标准。OpenGL ES 3.0 规范于 2012 年 8 月公开发布。这些规范与 OpenGL ES 2.0 兼容,OpenGL ES 2.0 是嵌入式系统渲染 2D 和 3D 图形的公认事实标准。嵌入式操作系统,如 Android、iOS、BlackBerry、Bada、Windows 以及许多其他操作系统都支持 OpenGL ES。

OpenGL ES 3D API 是 OpenGL 的简化版本,OpenGL 是一个跨平台的桌面环境 3D API,适用于 Linux、各种 UNIX 版本、Mac OS 和 Windows。这个简化版本主要专注于根据嵌入式系统需求提供 3D 图形功能,如低功耗、有限的处理能力和小的内存占用。

OpenGL ES 2.0/3.0 图形库与着色语言兼容,与其前身 1.1 不同。OpenGL ES 1.1 和 OpenGL ES 2.0/3.0 之间的主要区别是图形管线架构。前者的图形管线框架被称为固定功能管线,而后者的则是可编程管线。以下表格中解释了这些框架:

OpenGL ES 版本 架构管线类型 需要着色器
1.1 固定功能管线
2.0 和 3.0 可编程管线

管线是一系列在预定义的固定顺序中发生的事件,从向图形引擎提供输入数据到生成用于渲染帧的输出数据。帧是指图形引擎在屏幕上生成的输出图像。

在固定功能管线架构中,每一帧都是由一组固定的算法、计算和事件序列生成的。你只能指定你想要的内容,但不能指定如何计算。例如,如果你对在你的实心球体模型上应用一些光照着色感兴趣,那么你需要指定光照位置、其强度、材料属性和其他类似属性。固定管线使用这些输入,并负责生成光照着色所需的全部物理和数学。因此,你不必担心,因为“如何”因素已经完全抽象化。固定功能管线的优点是它非常容易理解,编程也很快。

相比之下,在可编程管线架构中,你不仅需要指定你想要实现的目标,还需要说明如何实现它。这个管线还通过着色器提供了非凡的能力。着色器是控制你的场景几何形状和着色外观的特殊程序。例如,为了在实心球体上实现相同的光照着色效果,你必须了解物理和数学的基本知识,以便编程光照着色技术。由于你正在编程光照着色的行为,你可以完全控制它。这为创建无限着色效果打开了无限可能。着色器非常快。它们使用图形处理单元GPU)并行处理模式执行渲染。

现在,问题是如果固定功能管线正在执行所有轻量级物理和数学抽象,那么为什么我们还需要理解它对于可编程管线呢?原因在于,使用固定管线,我们只能执行有限的图形功能,并且它不能有效地用于生成逼真的图形。然而,可编程管线为生成最先进的图形渲染提供了无限的可能性和机会。

本章将介绍在 Android 和 iOS 上使用 OpenGL ES 3.0 进行开发。我们将通过一个简单的示例来渲染屏幕上的三角形,了解 OpenGL ES 3.0 的基本编程。你将逐步学习如何在两个平台上设置和创建你的第一个应用程序。

理解 EGL:OpenGL ES API 在能够有效地在硬件设备上使用之前,需要 EGL 作为先决条件。EGL 提供了 OpenGL ES API 和底层本地窗口系统之间的接口。不同的操作系统供应商有自己的方式来管理绘图表面的创建、与硬件设备的通信以及其他配置来管理渲染上下文。EGL 提供了一种抽象,即底层系统需要以平台无关的方式实现。平台供应商的 SDK 通过他们自己的框架提供了 EGL 的实现。这些可以直接在应用程序中使用,以快速完成开发任务。例如,iOS 通过 EAGL (EAGLContext) 类与 GLkit一起提供 EGL 来创建GLSurface。在 Android 平台上,GLView类通过GLView.EGLContextFactoryGLView.EGLConfigChooser` 提供了 EGL 的接口。

EGL 为 OpenGL ES API 提供了两件重要的事情:

  • 渲染上下文:这存储了渲染目的所必需的数据结构和重要的 OpenGL ES 状态

  • 绘图表面:这提供了渲染原语所需的绘图表面

以下截图显示了 OpenGL ES 3.0 的可编程管道架构:

简介

EGL 在原生窗口系统之上运行,例如 WGL(Windows)、GLX 或 X-Windows(Linux)、或 Mac OS X 的 Quartz。有了 EGL 规范,跨平台开发变得更加容易。

EGL 负责以下任务:

  • 检查可用的配置以创建设备窗口系统的渲染上下文

  • 创建用于绘图的 OpenGL 渲染表面

  • 与其他图形 API(如 OpenVG、OpenAL 等)的兼容性和接口

  • 管理资源,如纹理映射

    注意

    您可以参考以下链接获取有关 EGL 的更多信息 www.khronos.org/egl

使用 OpenGL ES 着色语言 3.0 编程着色器

OpenGL ES 着色语言 3.0(也称为 GLSL)是一种类似于 C 的语言,允许我们为 OpenGL ES 处理管道中的可编程处理器编写着色器。着色器是在 GPU 上并行运行的程序。没有这些程序,编写 OpenGL ES 3.0 程序是不可能的。

OpenGL ES 3.0 支持两种类型的着色器:顶点着色器和片段着色器。每个着色器都有特定的职责。例如,顶点着色器用于处理几何顶点;然而,片段着色器处理像素或片段颜色信息。更特别的是,顶点着色器通过应用 2D/3D 变换来处理顶点信息。顶点着色器的输出传递到光栅化器,在那里生成片段。片段由片段着色器处理,它负责为它们上色。

着色器的执行顺序是固定的;顶点着色器总是首先执行,然后是片段着色器。每个着色器都可以将其处理后的数据与管道中的下一阶段共享。GLSL 支持用户定义的变量,如 C 语言;这些变量用于输入和输出目的。还有内置变量,用于跟踪着色器中的状态,以便在处理这些着色器中的数据时做出决策。例如,片段着色器提供了一个状态,可以测试传入的片段是否属于多边形的正面或背面。

准备工作

在 OpenGL ES 3.0 处理管道中,有两种类型的处理器用于执行顶点着色器和片段着色器可执行文件;它被称为可编程处理单元:

  • 顶点处理器:顶点处理器是一个可编程单元,它对传入的顶点和相关数据进行操作。它使用顶点着色器可执行文件并在顶点处理器上运行它。顶点着色器需要首先进行编程、编译和链接,以生成可执行文件,然后可以在顶点处理器上运行。

  • 片段处理器:这是 OpenGL ES 管道中的另一个可编程单元,它对片段和相关数据进行操作。片段处理器使用片段着色器可执行文件来处理片段或像素数据。片段处理器负责计算片段的颜色。它们不能改变片段的位置。它们也不能访问相邻的片段。然而,它们可以丢弃像素。从这个着色器计算出的颜色值用于更新帧缓冲区内存和纹理内存。

如何做到这一点...

这里是顶点和片段着色器的示例代码:

  1. 编程以下顶点着色器并将其存储到vertexShader字符类型数组变量中:

    #version 300 es             
    in vec4     VertexPosition;     
    in vec4     VertexColor;        
    uniform float  RadianAngle;
    
    out vec4     TriangleColor;     
    mat2 rotation = mat2(cos(RadianAngle),sin(RadianAngle),
                        -sin(RadianAngle),cos(RadianAngle));
    void main() {
      gl_Position = mat4(rotation)*VertexPosition;
      TriangleColor = VertexColor;
    }
    
  2. 编程以下片段着色器并将其存储到另一个名为fragmentShader的字符数组类型变量中:

    #version 300 es         
    precision mediump float;
    in vec4   TriangleColor;  
    out vec4  FragColor;     
    void main() {           
      FragColor = TriangleColor;
    };
    

它是如何工作的...

与大多数语言一样,着色器程序也是从main()函数开始控制的。在两个着色器程序中,第一行#version 300 es指定了 GLES 着色语言版本号,在本例中为 3.0。顶点着色器接收一个每个顶点的输入变量VertexPosition。这个变量的数据类型是vec4,它是 OpenGL ES 着色语言提供的内置数据类型之一。变量开头的in关键字指定它是一个传入变量,并接收一些数据,这些数据超出了我们当前着色器程序的作用域。同样,out关键字指定该变量用于将一些数据值发送到着色器的下一阶段。同样,颜色信息数据在VertexColor中接收。这些颜色信息传递给TriangleColor,它将此信息发送到片段着色器,这是处理管道的下一阶段。

RadianAngle 是一种包含旋转角度的统一类型变量。这个角度用于将旋转矩阵计算到 rotation 中。参考以下 也参见 部分,以获取关于 per-vertex 属性和 uniform 变量的参考信息。

VertexPosition 接收到的输入值通过旋转矩阵相乘,这将旋转我们三角形的几何形状。此值被分配给 gl_Positiongl_Position 是顶点着色器的一个内置变量。这个变量应该以齐次形式写入顶点位置。此值可以被任何固定功能阶段使用,例如原语装配、光栅化、裁剪等。有关固定阶段的更多信息,请参考 附录 中的 固定功能和可编程管道架构 菜谱。

在片段着色器中,精度关键字指定了所有浮点类型(以及聚合,如 mat4vec4)的默认精度为 mediump。此类声明的类型的可接受值需要落在声明的精度指定的范围内。OpenGL ES 着色语言支持三种精度类型:lowpmediumphighp。在片段着色器中指定精度是强制性的。然而,对于顶点,如果未指定精度,则默认为最高精度(highp)。

FragColor 是一个 out 变量,它将每个片段计算出的颜色值发送到下一阶段。它接受 RGBA 颜色格式的值。

还有更多…

如前所述,有三种精度限定符类型,以下表格描述了这些类型:

限定符 描述
highp 这些变量提供最大范围和精度。但它们可能导致某些实现中的操作运行得更慢;通常,顶点具有高精度。
lowp 这些变量通常用于存储高动态范围颜色和低精度几何形状。
mediump 这些变量通常用于存储 8 位颜色值。

这些精度限定符的范围和精度如下所示:

还有更多…

上一张图片来自 www.khronos.org/registry/gles/specs/3.0/GLSL_ES_Specification_3.00.3.pdf 的第 48 页。

Tip

下载示例代码

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

See also

  • 加载和编译着色器程序

  • 使用顶点属性将数据发送到着色器

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

加载和编译着色器程序

在前面的菜谱中创建的着色器程序需要加载并编译成二进制形式。本菜谱将有助于理解加载和编译着色器程序的过程。

准备中

编译和链接着色器是必要的,这样这些程序才能被底层图形硬件/平台(即顶点和片段处理器)理解和执行。

下图提供了创建着色器可执行文件的完整过程的概述。不同的数字标签帮助我们理解构建过程中的流程顺序。构建过程中的每个阶段都标记了负责它的相应 OpenGL ES API。

准备中

如何操作...

为了加载和编译着色器源代码,请按照以下步骤操作:

  1. 创建一个 NativeTemplate.h/NativeTemplate.cpp 文件,并在其中定义一个名为 loadAndCompileShader 的函数。使用以下代码,然后继续下一步以获取有关此函数的详细信息:

    GLuint loadAndCompileShader(GLenum shaderType, const char* sourceCode) {
         // Create the shader
      GLuint shader = glCreateShader(shaderType);
      if ( shader ) {
         // Pass the shader source code
         glShaderSource(shader, 1, &sourceCode, NULL);
    
         // Compile the shader source code
         glCompileShader(shader);
    
         // Check the status of compilation
         GLint compiled = 0;
         glGetShaderiv(shader,GL_COMPILE_STATUS,&compiled);
         if (!compiled) {
    
          // Get the info log for compilation failure
           GLint infoLen = 0;
           glGetShaderiv(shader,GL_INFO_LOG_LENGTH, &infoLen);
           if (infoLen) {
              char* buf = (char*) malloc(infoLen);
              if (buf) {
                glGetShaderInfoLog(shader, infoLen, NULL, buf);
                printf("Could not compile shader %s:" buf);
                free(buf);
              }
    
          // Delete the shader program
              glDeleteShader(shader);
              shader = 0;
           }
        }
      }
      return shader;
    }
    

    此函数负责加载和编译着色器源代码。参数 shaderType 接受需要加载和编译的着色器类型;可以是 GL_VERTEX_SHADERGL_FRAGMENT_SHADERsourceCode 指定了相应着色器的源程序。

  2. 使用 OpenGL ES 3.0 API 的 glCreateShader 创建一个空着色器对象。此着色器对象负责根据指定的 shaderType 参数加载顶点或片段源代码:

    • 语法:

      GLuint glCreateShader(  Glenum shaderType);
      

      如果对象成功创建,此 API 返回非零值。此值用作引用此对象的句柄。如果失败,此函数返回 0shaderType 参数指定要创建的着色器类型。它必须是 GL_VERTEX_SHADERGL_FRAGMENT_SHADER

      // Create the shader object
      GLuint shader = glCreateShader(shaderType);
      

      注意

      与 C++ 中的对象创建是透明的不同,在 OpenGL ES 中,对象是在幕后创建的。您可以根据需要访问、使用和删除对象。所有对象都由一个唯一的标识符识别,可用于编程目的。

      创建的空着色器对象(shader)需要首先与着色器源绑定,以便进行编译。此绑定是通过使用 glShaderSource API 实现的:

      // Load the shader source code
      glShaderSource(shader, 1, &sourceCode, NULL);
      

      此 API 在着色器对象 shader 中设置着色器代码字符串。源字符串简单地复制到着色器对象中;它不会被解析或扫描。

    • 语法:

      void glShaderSource(GLuint shader, GLsizei count, const GLchar * const *string, const GLint *length);
      
      变量 描述
      shader 这是需要绑定的着色器对象的句柄
      count 这是字符串和长度数组中的元素数量
      string 这指定了包含需要加载的源代码的字符串指针数组
      length 这指定了字符串长度的数组

    计数指定了数组中的字符串数量。如果长度数组是 NULL,这意味着所有字符串都是空终止的。如果此数组中的值非零,则指定对应字符串的长度。任何小于 0 的值都假定它是一个空终止的字符串。

  3. 使用 glCompileShader API 编译着色器。它接受一个着色器对象句柄 shader:

           glCompileShader(shader);    // Compile the shader
    
    • 语法:

      void glCompileShader (GLuint shader);
      
      变量 描述
      shader 这是需要编译的着色器对象的句柄
  4. 着色器的编译状态存储为着色器对象的状态。可以使用 glGetShaderiv OpenGL ES API 检索此状态:

         GLint compiled = 0;    // Check compilation status
         glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
    

    glGetShaderiv API 接受着色器句柄和 GL_COMPILE_STATUS 作为参数来检查编译状态。它将状态检索到 params 中。如果最后的编译成功,params 返回 GL_TRUE。否则,它返回 GL_FALSE

    • 语法:

      void glGetShaderiv(GLuint shader, GLenum pname, GLint *params);
      
      变量 描述
      shader 这是需要检查编译状态的着色器对象的句柄。
      pname 这指定了对象的状态参数。接受的符号名称是 GL_SHADER_TYPEGL_DELETE_STATUSGL_COMPILE_STATUSGL_INFO_LOG_LENGTHGL_SHADER_SOURCE_LENGTH
      params 这返回请求的对象参数状态。

      如果编译失败,可以使用 glGetShaderiv API 通过传递 GL_INFO_LOG_LENGTH 作为参数从 OpenGL ES 状态机检索信息日志。infoLen 返回信息日志的长度。如果返回的值是 0,则表示没有信息日志。如果 infoLen 值大于 0,则可以使用 glGetShaderInfoLog 检索信息日志消息:

             if (!compiled) {      // Handle Errors
                GLint infoLen = 0; // Check error string length
                glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLen);
                . . . . .
             }
      
  5. 使用 glGetShaderInfoLog 获取错误报告:

    • 语法:

      void glGetShaderInfoLog(GLuint shader, GLsizei maxLength, GLsizei*length, GLchar* infoLog);
      
      变量 描述
      shader 这是需要获取信息日志的着色器对象的句柄
      maxLength 这是存储返回的信息日志的字符缓冲区的大小
      length 这是信息长度返回的字符串长度
      infoLog 这指定了字符数组
  6. 如果着色器源无法编译,则删除着色器。使用 glDeleteShader API 删除着色器对象。

    • 语法:

      void glDeleteShader(GLuint shader);
      
      变量 描述
      shader 这是需要删除的着色器对象的句柄
  7. 如果着色器编译成功,则返回着色器对象 ID:

    return shader; // Return the shader object ID
    

它是如何工作的...

loadAndCompileShader函数首先创建一个空的着色器对象。这个空对象由shader变量引用。此对象与相应着色器的源代码绑定。源代码通过着色器对象使用glCompileShader API 进行编译。如果编译成功,则成功返回着色器对象句柄。否则,着色器对象返回0,需要显式使用glDeleteShader删除。可以使用glGetShaderivGL_COMPILE_STATUS检查编译状态。

更多内容...

为了区分不同版本的 OpenGL ES 和 GL 着色语言,从您设备的当前驱动程序中获取此信息是有用的。这将有助于使程序健壮且易于管理,避免由于版本升级或安装在较旧版本的 OpenGL ES 和 GLSL 上的应用程序而引起的错误。其他关键信息也可以从当前驱动程序中查询,例如供应商、渲染器和设备驱动程序支持的可用扩展。可以使用glGetString API 查询这些信息。此 API 接受一个符号常量,并以字符串形式返回查询的系统度量值。我们程序中的printGLString包装函数有助于打印设备度量:

static void printGLString(const char *name, GLenum s) {
    printf("GL %s = %s\n", name, (const char *) glGetString(s));
}
// Print the OpenGL ES system metrics
void printOpenGLESInfo(){
   printGLString("Version",    GL_VERSION);
   printGLString("Vendor",    GL_VENDOR);
   printGLString("Renderer",    GL_RENDERER);
   printGLString("Extensions",    GL_EXTENSIONS);
   printGLString("GLSL version",  GL_SHADING_LANGUAGE_VERSION);
}

相关内容

  • 将着色器程序链接

将着色器程序链接

链接是一个将一组(顶点和片段)着色器聚合为一个程序的过程,该程序映射到 OpenGL ES 3.0 图形管道的可编程阶段的全集。着色器使用我们在前面的菜谱中创建的着色器对象进行编译。这些对象用于创建称为程序对象的特殊对象,以将其链接到 OpenGL ES 3.0 管道。在本菜谱中,您将了解着色器链接过程。

如何操作...

以下指令提供了一步一步的过程来链接一个着色器:

  1. NativeTemplate.cpp中创建一个新的函数linkShader。这将是将着色器程序链接到 OpenGL ES 3.0 管道的包装函数。按照以下步骤详细了解此程序:

    GLuint linkShader(GLuint vertShaderID,GLuint fragShaderID){
        if (!vertShaderID || !fragShaderID){ // Fails! return
       return 0; 
     }
    
       // Create an empty program object
        GLuint program = glCreateProgram();
        if (program) {
       // Attach vertex and fragment shader to it
          glAttachShader(program, vertShaderID);
          glAttachShader(program, fragShaderID);
    
       // Link the program
    glLinkProgram(program);
          GLint linkStatus = GL_FALSE;
          glGetProgramiv(program, GL_LINK_STATUS, &linkStatus);
    
          if (linkStatus != GL_TRUE) {
              GLint bufLength = 0;
              glGetProgramiv(program, GL_INFO_LOG_LENGTH,
              &bufLength);
              if (bufLength) {
                 char* buf = (char*) malloc(bufLength);
    if(buf) { glGetProgramInfoLog(program,bufLength,NULL,buf);
              printf("Could not link program:\n%s\n", buf);
                free(buf);
                }
             }
              glDeleteProgram(program);
              program = 0;
          }
      }
      return program;
    }
    
  2. 使用glCreateProgram创建一个程序对象。此 API 使用一个空的程序对象创建,该对象用于链接着色器对象:

    GLuint program = glCreateProgram(); //Create shader program
    
    • 语法:

      GLint glCreateProgram( void);
      
  3. 使用glAttachShader API 将着色器对象附加到程序对象。为了创建可执行程序,必须将着色器附加到程序对象:

     // Attach the vertex and fragment shader
     glAttachShader(program, vertShaderID);
     glAttachShader(program, fragShaderID);
    

    下面是glAttachShader API 的语法:

    • 语法:

      void glAttachShader(GLuint program, GLuint shader);
      
      变量 描述
      program 这指定了将要附加着色器对象(着色器)的程序对象
      shader 这指定了将要附加的程序对象
  4. 为了创建程序可执行文件,着色器必须链接到程序。链接过程是通过 glLinkProgram 执行的。此 API 通过 program 标识符链接程序对象,该标识符必须包含附加的顶点和片段着色器对象:

    glLinkProgram(program); // Link the shader program
    
  5. 可以使用 glGetShaderiv 来检查链接操作的状态。此 API 接受程序和 GL_LINK_STATUS 作为参数。如果程序上的最后一个链接成功,它将返回 GL_TRUE;否则,它将返回 GL_FALSE

    • 语法:

      void glGetProgramiv(GLuint program, GLenum pname, GLint *params);
      
      变量 描述
      program 这指定了要查询的程序对象
      pname 这指定了符号状态参数
      params 这返回请求的程序对象参数状态

      如果返回的链接状态是 GL_FALSE,则必须使用 glDeleteProgram 释放程序对象占用的内存。此 API 撤销了 glCreateProgram 的所有效果。它还使与之关联的句柄无效。

    • 语法:

      void glDeleteProgram(Glint program);
      
      变量 描述
      program 这指定了需要删除的程序句柄

它是如何工作的...

linkShader 包装函数用于链接着色器。它接受两个参数:vertShaderIDfragShaderID。它们是编译后的着色器对象的标识符。createProgram 函数创建程序对象。这是另一个 OpenGL ES 对象,着色器对象通过 glAttachShader 附加到该对象上。如果不再需要,着色器对象可以从程序对象中分离。程序对象负责创建在可编程处理器上运行的可执行程序。在 OpenGL ES 中,程序是 OpenGL ES 3.0 管道中运行的、在顶点和片段处理器上运行的可执行文件。

程序对象是通过 glLinkShader 链接的。如果链接失败,则必须使用 glDeleteProgram 删除程序对象。当程序对象被删除时,它将自动断开与其关联的着色器对象。需要显式删除着色器对象。如果请求删除程序对象,它将仅在当前 OpenGL ES 状态中不被其他渲染上下文使用时被删除。

如果程序的对象链接成功,则将创建一个或多个可执行文件,具体取决于与程序附加的着色器的数量。可执行文件可以在运行时通过 glUseProgram API 的帮助使用。这使得可执行文件成为当前 OpenGL ES 状态的组成部分。

相关内容

  • 检查 OpenGL ES 3.0 中的错误

检查 OpenGL ES 3.0 中的错误

在编程过程中,遇到意外的结果或错误在源代码中是非常常见的。确保程序不生成任何错误非常重要。在这种情况下,您可能希望优雅地处理错误。本节将指导我们跟踪 OpenGL ES 3.0 和 GL 着色语言中的错误。

如何操作...

OpenGL ES 3.0 允许我们使用一个简单的名为 getGlError 的例程来检查错误。以下包装函数会打印出编程过程中发生的所有错误信息:

static void checkGlError(const char* op) {
     for(GLint error = glGetError(); error; error= glGetError()){
        printf("after %s() glError (0x%x)\n", op, error);
     }
}

getGlError 返回一个错误代码。以下表格描述了这些错误:

语法:

GLenum glGetError(void);
错误代码 描述
GL_NO_ERROR 这表示没有发现错误
GL_INVALID_ENUM 这表示 GLenum 参数超出了范围
GL_INVALID_VALUE 这表示数值参数超出了范围
GL_INVALID_OPERATION 这表示在当前状态下操作非法
GL_STACK_OVERFLOW 这表示命令会导致栈溢出
GL_STACK_UNDERFLOW 这表示命令会导致栈下溢
GL_OUT_OF_MEMORY 这表示执行命令时没有足够的内存

这里有一些产生 OpenGL ES 错误的代码示例:

// Gives a GL_INVALID_ENUM error
glEnable(GL_TRIANGLES);

// Gives a GL_INVALID_VALUE
// when attribID >= GL_MAX_VERTEX_ATTRIBS
glEnableVertexAttribArray(attribID);

它是如何工作的...

当 OpenGL ES 检测到错误时,它会将错误记录到错误标志中。每个错误都有一个唯一的数值代码和符号名称。OpenGL ES 不会跟踪每次错误发生的情况。由于性能原因,检测错误可能会降低渲染性能,因此,错误标志不会在调用 glGetError 例程之前设置。如果没有检测到错误,此例程将始终返回 GL_NO_ERRORS。在分布式环境中,可能有多个错误标志,因此,建议在循环中调用 glGetError 例程,因为此例程可以记录多个错误标志。

使用顶点属性向着色器发送数据

在着色器编程中,每个顶点的属性有助于从 OpenGL ES 程序接收每个唯一顶点属性的数据。接收到的数据值不会在顶点之间共享。顶点坐标、法线坐标、纹理坐标、颜色信息等都是顶点属性的例子。顶点属性仅用于顶点着色器,它们不能直接提供给片段着色器。相反,它们通过顶点着色器中的变量进行共享。

通常,着色器在 GPU 上执行,允许使用多核处理器并行处理多个顶点。为了在顶点着色器中处理顶点信息,我们需要一些机制将位于客户端(CPU)上的数据发送到服务器端(GPU)上的着色器。这个配方将有助于理解使用顶点属性与着色器通信的使用方法。

准备工作

GL 着色语言 3.0 中编写着色器 的配方中,顶点着色器包含两个名为 VertexPositionVertexColor 的顶点属性:

// Incoming vertex info from program to vertex shader
in vec4  VertexPosition;
in vec4  VertexColor;

VertexPosition 包含定义我们要在屏幕上绘制的物体形状的三角形的 3D 坐标。VertexColor 包含该几何体每个顶点的颜色信息。

在顶点着色器中,一个非负属性位置 ID 唯一标识每个顶点属性。如果未在顶点着色器程序中指定,则该属性位置在编译时分配。有关指定 ID 的更多信息,请参阅本食谱的另请参阅部分。

基本上,将数据发送到其着色器的逻辑非常简单。这是一个两步的过程:

  • 查询属性: 从着色器中查询顶点属性位置 ID。

  • 将数据附加到属性: 将此 ID 附加到数据。这将创建数据与使用 ID 指定的每个顶点属性之间的桥梁。OpenGL ES 处理管道负责发送数据。

如何操作...

按照以下步骤使用每个顶点属性将数据发送到着色器:

  1. NativeTemplate.cpp中声明两个全局变量以存储查询到的VertexPositionVertexColor的属性位置 ID:

    GLuint positionAttribHandle;
    GLuint colorAttribHandle;
    
  2. 使用glGetAttribLocation API 查询顶点属性位置:

    positionAttribHandle = glGetAttribLocation
    (programID, "VertexPosition");
    colorAttribHandle    = glGetAttribLocation
    (programID, "VertexColor");
    

    此 API 提供了一个方便的方法来从着色器查询属性位置。返回值必须大于或等于0,以确保存在具有给定名称的属性。

    • 语法:

      GLint glGetAttribLocation(GLuint program, const GLchar *name);
      
      变量 描述
      program 这是成功链接的 OpenGL 程序的句柄
      name 这是着色器源程序中顶点属性的名字
  3. 使用glVertexAttribPointer OpenGL ES API 将数据发送到着色器:

    // Send data to shader using queried attrib location
    glVertexAttribPointer(positionAttribHandle, 2, GL_FLOAT,
          GL_FALSE, 0, gTriangleVertices);
    glVertexAttribPointer(colorAttribHandle, 3, GL_FLOAT, GL_FALSE, 0, gTriangleColors);
    

    与几何数据相关联的数据以数组的格式通过glVertexAttribPointer API 使用通用顶点属性传递。

    • 语法:

      void glVertexAttribPointer(GLuint index, GLint size, GLenum type,  GLboolean normalized, GLsizei stride, const GLvoid * pointer);
      
      变量 描述
      index 这是通用顶点属性的索引。
      size 这指定了每个通用顶点属性中组件的数量。该数字必须是1234。初始值是4
      type 这是包含几何信息数组的每个组件的数据类型。
      normalized 这指定了在访问时是否应该规范化(GL_TRUE)或直接转换为定点值(GL_FALSE)的任何定点数据值。
      stride 这用于连续的通用属性;它指定它们之间的偏移量。
      pointer 这些是指向数组数据中第一个属性指针的指针。
  4. 着色器中的通用顶点属性必须通过使用glEnableVertexAttribArray OpenGL ES API 启用:

        // Enable vertex position attribute
        glEnableVertexAttribArray(positionAttribHandle);
        glEnableVertexAttribArray(colorAttribHandle);
    

    启用属性位置非常重要。这允许我们在着色器侧访问数据。默认情况下,顶点属性是禁用的。

    • 语法:

      void glEnableVertexAttribArray(GLuint index);
      
      变量 描述
      index 这是将要启用的通用顶点属性的索引
  5. 同样,可以使用glDisableVertexAttribArray禁用属性。此 API 与glEnableVertexAttribArray具有相同的语法。

  6. 将传入的每个顶点的属性颜色VertexColor存储到输出的属性TriangleColor中,以便将其发送到下一阶段(片段着色器):

    in vec4 VertexColor; // Incoming data from CPU
    . . .
    out vec4 TriangleColor; // Outgoing to next stage
    void main() {
          . . . 
          TriangleColor = VertexColor;
    }
    
  7. 从顶点着色器接收颜色信息并设置片段颜色:

    in vec4   TriangleColor; // Incoming from vertex shader
    out vec4   FragColor;     // The fragment color
    void main() {           
          FragColor = TriangleColor;
    };
    

工作原理...

在顶点着色器中定义的每个顶点属性变量VertexPositionVertexColor是顶点着色器的生命线。这些生命线不断从客户端(OpenGL ES 程序或 CPU)向服务器端(GPU)提供数据信息。每个顶点属性都有一个唯一的属性位置,可以在着色器中使用glGetAttribLocation查询。每个顶点查询的属性位置存储在positionAttribHandlecolorAttribHandle必须使用属性位置与数据绑定,使用glVertexAttribPointer。此 API 在客户端和服务器端之间建立逻辑连接。现在,数据已准备好从我们的数据结构流向着色器。最后一件重要的事情是,为了优化目的,在着色器端启用属性。默认情况下,所有属性都是禁用的。因此,即使为客户端提供了数据,在服务器端也是不可见的。glEnableVertexAttribArray API 允许我们在着色器端启用每个顶点的属性。

相关内容

  • 请参阅第三章中使用限定符管理变量属性的配方,OpenGL ES 3.0 的新特性

使用常量变量将数据发送到着色器

常量变量包含全局的数据值。它们在顶点和片段着色器中由所有顶点和片段共享。通常,一些不特定于每个顶点的信息以常量变量的形式处理。常量变量可以存在于顶点和片段着色器中。

准备工作

OpenGL ES 着色语言 3.0 配方中的着色器编程中编写的顶点着色器包含一个常量变量RadianAngle。该变量用于旋转渲染的三角形:

// Uniform variable for rotating triangle
uniform float  RadianAngle;

此变量将在客户端(CPU)更新,并通过特殊的 OpenGL ES 3.0 API 发送到服务器端(GPU)。类似于每个顶点的属性对于常量变量,我们需要查询和绑定数据,以便使其在着色器中可用。

如何操作...

按照以下步骤使用常量变量将数据发送到着色器:

  1. NativeTemplate.cpp中声明一个全局变量以存储查询到的radianAngle属性位置 ID:

    GLuint radianAngle;
    
  2. 使用glGetUniformLocation API 查询常量变量的位置:

    radianAngle=glGetUniformLocation(programID,"RadianAngle");
    

    此 API 将返回一个大于或等于0的值,以确保存在具有给定名称的常量变量。

    • 语法:

      GLint glGetUniformLocation(GLuint program,const GLchar *name)
      
      变量 描述
      program 这是成功链接的 OpenGL ES 程序的句柄
      name 这是着色器源程序中常量变量的名称
  3. 使用 glUniform1f API 将更新的弧度值发送到着色器:

    float degree = 0; // Global degree variable
    float radian;     // Global radian variable
    
    // Update angle and convert it into radian
    radian = degree++/57.2957795; 
    // Send updated data in the vertex shader uniform 
    glUniform1f(radianAngle, radian);
    

    glUniform API 有许多变体。

    • 语法:

      void glUniform1f(GLint location, GLfloat v0);
      
      变量 描述
      location 这是着色器中均匀变量的索引
      v0 这是需要发送的浮点类型数据值

    注意

    有关其他变体的更多信息,请参阅OpenGL ES 3.0 参考页面

  4. 使用 2D 旋转的一般形式应用于所有传入的顶点坐标:

    . . . . 
    uniform float  RadianAngle;
    mat2 rotation = mat2(cos(RadianAngle),sin(RadianAngle),
                        -sin(RadianAngle),cos(RadianAngle));
    void main() {
      gl_Position = mat4(rotation)*VertexPosition;
      . . . . .
    }
    

工作原理...

在顶点着色器中定义的均匀变量 RadianAngle 用于对传入的每个顶点属性 VertexPosition 应用旋转变换。在客户端,此均匀变量通过 glGetUniformLocation 查询。此 API 返回均匀变量的索引并将其存储在 radianAngle 中。此索引将用于绑定存储在 radian 中的更新数据信息,使用 glUniform1f OpenGL ES 3.0 API。最后,更新后的数据达到顶点着色器可执行文件,其中计算了欧拉旋转的一般形式:

mat2 rotation = mat2(cos(RadianAngle),sin(RadianAngle),
              -sin(RadianAngle),cos(RadianAngle));

旋转变换以 2 x 2 矩阵旋转的形式计算,随后在乘以 VertexPosition 时提升为 4 x 4 矩阵。结果顶点导致三角形在 2D 空间中旋转。

参见

  • 请参阅第三章中的分组均匀变量和创建缓冲区对象配方,OpenGL ES 3.0 的新特性

编程 OpenGL ES 3.0 Hello World 三角形

此配方基本上包含了我们在此章节中之前配方中收集的所有知识。此配方的输出将是一个 NativeTemplate.h/cpp 文件,其中包含 OpenGL ES 3.0 代码,演示了一个旋转的彩色三角形。此配方的输出本身不可执行。它需要一个提供必要 OpenGL ES 3.0 先决条件的主应用程序,以便在设备屏幕上渲染此程序。因此,此配方将在以下两个配方中使用,这两个配方将为 Android 和 iOS 中的 OpenGL ES 3.0 提供主机环境:

  • 开发 Android OpenGL ES 3.0 应用程序

  • 开发 iOS OpenGL ES 3.0 应用程序

此配方将提供设置 OpenGL ES、渲染以及从着色器查询所需属性以渲染我们的 OpenGL ES 3.0 "Hello World 三角形"程序所需的所有必要先决条件。在此程序中,我们将在屏幕上渲染一个简单的彩色三角形。

准备工作

OpenGL ES 需要一个物理尺寸(像素)来定义一个名为视口的 2D 渲染表面。这用于定义 OpenGL ES 帧缓冲区的大小。

在 OpenGL ES 中,缓冲区是内存中的 2D 数组,它表示视口区域中的像素。OpenGL ES 有三种类型的缓冲区:颜色缓冲区、深度缓冲区和模板缓冲区。这些缓冲区统称为帧缓冲区。所有绘图命令都会影响帧缓冲区中的信息。

此配方的生命周期大致分为三个状态:

  • 初始化:着色器被编译并链接以创建程序对象

  • 调整大小:此状态定义了渲染表面的视口大小

  • 渲染:此状态使用着色器程序对象在屏幕上渲染几何图形

在我们的配方中,这些状态由 GraphicsInit()GraphicsResize()GraphicsRender() 函数表示。

如何做到这一点...

按照以下步骤来编程这个配方:

  1. 使用 NativeTemplate.cpp 文件并创建一个 createProgramExec 函数。这是一个高级函数,用于加载、编译和链接着色器程序。此函数在成功执行后将返回程序对象 ID:

    GLuint createProgramExec(const char* vertexSource, const
                                       char* fragmentSource) {
    GLuint vsID = loadAndCompileShader(GL_VERTEX_SHADER,
    vertexSource);
    GLuint fsID = loadAndCompileShader(GL_FRAGMENT_SHADER, 
    fragmentSource);
       return linkShader(vsID, fsID);
    }
    

    访问加载和编译着色器程序以及链接着色器程序的配方,以获取有关 loadAndCompileShaderlinkShader 工作原理的更多信息。

  2. 使用 NativeTemplate.cpp,创建一个 GraphicsInit 函数并通过调用 createProgramExec 创建着色器程序对象:

    GLuint programID; // Global shader program handler
    bool GraphicsInit(){
    
    // Print GLES3.0 system metrics
    printOpenGLESInfo();
    
    // Create program object and cache the ID
    programID = createProgramExec(vertexShader,
    fragmentShader);
        if (!programID) { // Failure !!! return 
           printf("Could not create program."); return false;
        }
        checkGlError("GraphicsInit"); // Check for errors
    }
    
  3. 创建一个新的函数 GraphicsResize。这将设置视口区域:

    // Set viewing window dimensions 
    bool GraphicsResize( int width, int height ){
        glViewport(0, 0, width, height);
    }
    

    视口决定了在 OpenGL ES 表面窗口上执行原语渲染的部分。OpenGL ES 中的视口是通过 glViewPort API 设置的。

    • 语法

      void glViewport( GLint x, GLint y, GLsizei width, GLsizei height);
      
      变量 描述
      x, y 这些代表以像素为单位的视口指定的左下角矩形
      width, height 这指定了视口的宽度和高度(以像素为单位)
  4. 创建包含三角形顶点的全局变量 gTriangleVertices

    GLfloat gTriangleVertices[] = { 
    { 0.0f,  0.5f}, // Vertex 0
    {-0.5f, -0.5f}, // Vertex 1
    { 0.5f, -0.5f}  // Vertex 2
    }; // Triangle vertices
    
  5. 创建 GraphicsRender 渲染器函数。此函数负责渲染场景。在它里面添加以下代码,并执行以下步骤来理解此函数:

    bool GraphicsRender(){
        // Which buffer to clear? – color buffer
        glClear( GL_COLOR_BUFFER_BIT );
    
        // Clear color with black color
        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
    
        // Use shader program and apply 
        glUseProgram( programID );
        radian = degree++/57.2957795;
    
    // Query and send the uniform variable.    
    radianAngle = glGetUniformLocation(programID, "RadianAngle");
        glUniform1f(radianAngle, radian);
    
        // Query 'VertexPosition' from vertex shader
        positionAttribHandle = glGetAttribLocation
                                (programID, "VertexPosition");
        colorAttribHandle    = glGetAttribLocation
                                 (programID, "VertexColor");
    
        // Send data to shader using queried attribute
        glVertexAttribPointer(positionAttribHandle, 2, 
                   GL_FLOAT, GL_FALSE, 0, gTriangleVertices);
        glVertexAttribPointer(colorAttribHandle, 3, 
                  GL_FLOAT, GL_FALSE, 0, gTriangleColors);
    
        // Enable vertex position attribute
        glEnableVertexAttribArray(positionAttribHandle);
        glEnableVertexAttribArray(colorAttribHandle);
    
        // Draw 3 triangle vertices from 0th index
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }
    
  6. 选择每次渲染帧时想要清除的帧缓冲区(颜色、深度和模板)中适当的缓冲区,使用 glClear API。在我们的配方中,我们想要清除颜色缓冲区。glClear API 可以用来选择需要清除的缓冲区。此 API 接受一个位或(OR)参数掩码,可以用来设置任何组合的缓冲区。

    • 语法

      void glClear( GLbitfield mask )
      
      变量 描述
      mask 位或(OR)掩码,每个掩码指向一个特定的缓冲区。这些掩码是 GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BITGL_STENCIL_BUFFER_BIT

      可能的值掩码可以是 GL_COLOR_BUFFER_BIT(颜色缓冲区)、GL_DEPTH_BUFFER_BIT(深度缓冲区)和 GL_STENCIL_BUFFER_BIT(模板缓冲区)的位或。

      glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
      
  7. 使用glClearColor API 以黑色清除颜色缓冲区。这个缓冲区负责存储场景的颜色信息。它接受 RGBA 空间作为参数,范围在 0.0 到 1.0 之间。

  8. 使用着色器程序,并通过glUseProgram API 将其设置为当前渲染状态。glUseProgram API 将指定的程序对象安装为当前渲染状态。顶点着色器的程序可执行文件在可编程顶点处理器上运行。同样,片段着色器的程序可执行文件在可编程片段处理器上运行。

    • 语法:

      void glUseProgram(GLuint program);
      
      变量 描述
      program 这指定了着色器程序的句柄(ID)。
  9. 使用glGetAttribLocation从顶点着色器查询VertexPosition通用顶点属性位置 ID 到positionAttribHandle。这个位置将用于通过glVertexAttribPointer将存储在gTriangleVertices中的三角形顶点数据发送到着色器。按照相同的说明来获取VertexColor的句柄到colorAttributeHandle

    // Query attribute location & send data using them
    positionAttribHandle = glGetAttribLocation
                             (programID, "VertexPosition");
    colorAttribHandle = glGetAttribLocation
                             (programID, "VertexColor");
    glVertexAttribPointer(positionAttribHandle, 2, GL_FLOAT,
    GL_FALSE, 0, gTriangleVertices);
    glVertexAttribPointer(colorAttribHandle, 3, GL_FLOAT, 
                             GL_FALSE, 0, gTriangleColors);
    
  10. 在渲染调用之前,使用positionAttribHandle启用通用顶点属性位置,并渲染三角形几何形状。同样,对于每个顶点的颜色信息,使用colorAttribHandle

    glEnableVertexAttribArray(positionAttribHandle);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    

它是如何工作的...

当应用程序启动时,控制从GraphicsInit开始,其中打印系统度量以确保设备支持 OpenGL ES 3.0。OpenGL ES 可编程管道需要在渲染管道中包含顶点着色器和片段着色器程序的可执行文件。在附加编译后的着色器对象并将它们链接到程序之后,程序对象包含一个或多个可执行文件。在createProgramExec函数中,编译并链接顶点和片段着色器,以生成程序对象。

GraphicsResize函数生成给定维度的视口。OpenGL ES 3.0 内部使用它来维护帧缓冲区。在我们的当前应用程序中,它用于管理颜色缓冲区。有关 OpenGL ES 3.0 中其他可用缓冲区的更多信息,请参阅更多内容…部分。

最后,通过GraphicsRender函数执行场景的渲染,这个函数使用黑色背景清除颜色缓冲区,并在屏幕上渲染三角形。它使用着色器对象程序,并通过glUseProgram API 将其设置为当前渲染状态。

每次渲染一帧时,数据都会通过glVertexAttribPointer从客户端(CPU)发送到服务器端(GPU)上的着色器可执行文件。这个函数使用查询到的通用顶点属性将数据与 OpenGL ES 管道绑定。

更多内容...

在 OpenGL ES 3.0 中,还有其他缓冲区可供使用:

  • 深度缓冲区:当存在更近的像素时,此功能用于防止背景像素的渲染。可以通过 OpenGL ES 3.0 提供的特殊深度规则来控制像素的预防规则。有关更多信息,请参阅第二章,OpenGL ES 3.0 基础

  • 模板缓冲区:模板缓冲区存储每个像素的信息,并用于限制渲染区域。

OpenGL ES API 允许我们分别控制每个缓冲区。根据渲染需求,这些缓冲区可以被启用或禁用。OpenGL ES 可以直接使用这些缓冲区(包括颜色缓冲区)以不同的方式操作。可以通过 OpenGL ES API,如glClearColorglClearDepthfglClearStencil,使用预设值来设置这些缓冲区。

注意

您可以参考www.khronos.org/opengles/sdk/docs/man3/以获取有关glClearDepthfglClearStencilAPI以及所有其他 API 的更多信息。相同的链接可以用来探索 OpenGL ES 3.0 官方 API 规范。

参见

  • 请参阅第二章中的OpenGL ES 3.0 深度测试配方,OpenGL ES 3.0 基础

  • 开发 Android OpenGL ES 3.0 应用程序

  • 开发 iOS OpenGL ES 3.0 应用程序

在 Android 上使用 JNI 与 C/C++通信

Android 应用程序通常是用 Java 开发的。然而,有时可能需要开发 C/C++代码或重用 Android 中的现有 C/C++库。例如,如果您正在寻找跨平台部署的开发,那么选择 C/C++作为开发语言将是一个更好的选择。本书中的代码是用 C/C++编写的,以满足跨平台需求。本配方将提供一个示例,演示如何从 Android Java 应用程序中与 C/C++代码进行通信。您将学习如何使用Java 本地接口JNI)从 Java 调用 C/C++方法。

准备工作

JNI 通过 JNI 接口在 Java 和本地代码之间创建了一座桥梁。Android NDK 提供了所有必要的工具,如库、源文件和编译器,以帮助构建本地代码。据信,与 Java 代码相比,本地代码的开发速度更快。因此,本地开发在内存管理、性能和跨平台开发方面更为优越。

在我们的第一个配方中,您将学习如何在 Android Java 应用程序中编程 C/C++代码。在本配方中,我们将在 Android 框架中创建一个 UI TextView控件,并显示从 C/C++代码发送的字符串消息。Java 通过静态/共享库与 C/C++通信,NDK 使用 JNI,并提供在 Java 环境下开发这些库的方法。

作为 NDK 开发的先决条件,您必须将 Android NDK 添加到 PATH 环境变量中,以便可以从命令行终端直接访问 NDK API。

如何操作...

按照以下步骤创建一个具有 JNI 支持的 Android 应用程序:

  1. 通过访问 新建 | Android 应用程序项目 创建一个新的 Android 应用程序项目。

  2. 应用程序名称 设置为 HelloNativeDev项目名称 设置为 CookbookNativeDev包名称 设置为 com.cookbookgles。您可以根据自己的选择提供名称——没有限制:如何操作...

  3. 接受默认设置并点击 下一步,直到出现 创建活动 页面。从提供的选项中选择 空白活动 并点击 下一步

  4. 在最后的 空白活动 页面上,将 活动名称 更改为 NativeDevActivity,然后点击 完成。这将创建项目解决方案,如图所示:如何操作...

    项目解决方案中包含各种文件和文件夹,每个都有其特定的角色和责任,如前图所示。

  5. 前往 src | com.cookbookgles | NativeDevActivity.java 并将代码替换为以下代码片段。编译并执行程序。这将生成必要的类,这些类将被 JNI 使用:

    package com.cookbookgles;
    
    import android.os.Bundle;
    import android.widget.TextView;
    import android.app.Activity;
    
    public class NativeDevActivity extends Activity {
    
       static {
         //Comment #1
         // "jniNativeDev.dll" in Windows.
         System.loadLibrary("jniNativeDev");
       }
    
         //Comment #2
         // Native method that returns a Java String
         // to be displayed on the TextView
         public native String getMessage();
    
         @Override
         public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
    
         //Comment #3
         // Create a TextView widget.
         TextView textView = new TextView(this);
    
         //Comment #4
         // Retrieve the text from native method
         // getMessage() and set as text to be displayed
         textView.setText(getMessage());
         setContentView(textView);
       }
    }
    
  6. 在项目解决方案中添加一个名为 JNI 的新文件夹。这个文件夹将包含所有的 C/C++ 文件。在 JNI 内部创建另一个新文件夹 include。这个文件夹将用于头文件。分别在 includeJNI 文件夹下添加 HelloCookbookJNI.hHelloCookbookJNI.c。添加以下代码:

    • HelloCookbookJNI.h

      #ifndef _Included_com_cookbook_JNIActivity
      #define _Included_com_cookbook_JNIActivity
      #include <jni.h>
      
      JNIEXPORT jstring JNICALL Java_com_cookbookgles_ NativeDevActivity_getMessage(JNIEnv *, jobject);
      
      #endif
      
    • HelloCookbookJNI.c

      #include "include/HelloCookbookJNI.h"
      
      JNIEXPORT jstring JNICALL Java_com_cookbookgles_ NativeDevActivity_getMessage(JNIEnv *env, jobject thisObj){
          return (*env)->NewStringUTF(env, 
                     "Hello from Cookbook native code.");
      }
      
      

    JNI 函数的语法如下:

    JNIEXPORT <return type> JNICALL <static function name> (JNIEnv *, jobject);
    

    JNI 下的函数名包含它在项目中定义位置的完整层次路径。规则如下:

    • 函数名应该以 Java_ 为前缀。

    • 从包名 (com.cookbookgles) 开始,每个层次文件夹和文件名必须连接起来

    • 每个连接必须包含两个连续名称之间的下划线 (_)。

    例如:

        com.cookbookgles -> NativeDevActivity.java -> getMessage()
    

    函数的名称将如下定义:

    Java_com_cookbookgles_NativeDevActivity_getMessage
    

    完整签名和名称如下:

    JNIEXPORT jstring JNICALL Java_com_cookbookgles_NativeDevActivity_getMessage (JNIEnv *, jobject);
    

    此过程可以使用 javah 工具自动化。有关更多信息,请参阅 更多内容… 部分):

  7. 在 JNI 下添加 Android.mk。添加以下代码:

    // Android.mk
    LOCAL_PATH := $(call my-dir)
    
    include $(CLEAR_VARS)
    
    LOCAL_MODULE    := JNINativeDev
    LOCAL_SRC_FILES := HelloCookbookJNI.c
    
    include $(BUILD_SHARED_LIBRARY)
    

    本地代码构建过程使用 Android.mk 进行文件编译。此 makefile 指示 NDK 编译器列出所有需要编译的文件。它还维护了需要编译的文件顺序。

    • LOCAL_PATH 是一个预定义变量。它将构建系统的路径设置为当前工作目录的路径。换句话说,它用于在开发树中定位源文件。它使用 $(call my-dir) 通过当前目录路径指定。

    • include $(CLEAR_VARS)帮助构建系统删除任何之前存在的变量。它确保不会使用来自其他模块的系统或本地变量。这种在不同 makefile 中多次声明相同变量可能会使构建系统困惑。此命令清除所有本地预定义变量,例如LOCAL_PATHLOCAL_MODULELOCAL_SRC_FILES

    • LOCAL_MODULE是一个系统变量,包含 JNI 导出的库的名称。在原生代码成功编译后,JNI 将生成一个名为LOCAL_MODULE中指定的库。在当前菜谱中,它是JNINativeDev.soLOCAL_SRC_FILE帮助 JNI 编译器理解哪些文件需要编译。

    • include $(BUILD_SHARED_LIBRARY)帮助编译器将库构建为动态形式(例如,Windows 上的.dll或 Linux 上的.so)。这些库也可以使用include $(BUILD_STATIC_LIBRARY)构建为静态形式。这个菜谱使用的是共享库。

  8. 打开命令行终端。转到当前JNI文件夹路径并执行ndk-build。这个命令,在Android.mk的帮助下,编译源文件,并在CookbookNativeDev\libs\armeabi文件夹路径下生成名为JNINativeDev.so的共享库:如何操作...

  9. NativeDevActivity.java内部,在使用之前需要加载库:

    System.loadLibrary("jniNativeDev");
    
  10. 将你的物理 Android 设备连接到系统,并使用Ctrl + F11执行 Android 项目。这将在屏幕上显示以下输出。你可以访问示例代码文件夹simpleJNI中的第一个示例:如何操作...

    注意

    本书中的所有菜谱都使用 Android 设备作为目标。你也可以使用 Android 模拟器。避免使用模拟器的主要原因是 Android 模拟器的支持不完整,性能较慢。

工作原理...

Java 常规代码需要知道如何调用原生 C 代码。这是通过在 Java 文件中声明函数来实现的,每个函数的签名前都带有native关键字。这些函数的定义在 C/C++源文件中定义。这些函数需要在头文件中重新声明,这些头文件必须位于JNI文件夹中。这些声明遵循ndk构建系统理解的特殊语法规则。这些函数最终以共享或静态库的形式提供给 Java。您需要在 Java 代码中调用这个共享/静态库来使用这些导出的函数。

更多内容…

在这个菜谱中,你学习了生成 JNI 函数原生方法签名的约定。在处理大型项目时,有时进行这样的更改可能会很繁琐,因为代码可能非常大。此外,人为错误的可能性也相当高。

或者,可以使用javah 工具来自动化此过程。它生成实现本地方法所需的 C 头文件和源文件。它读取 Java 类文件,并在当前工作目录中创建一个 C 语言头文件。生成的头文件和源文件被 C 程序用于从本地源代码中引用对象的实例变量。关于此工具的详细描述超出了本书的范围。然而,我强烈建议您参考另请参阅部分以获取更多相关信息。

另请参阅

开发 Android OpenGL ES 3.0 应用程序

本菜谱使用前一个菜谱中的 NDK 和 JNI 知识来开发我们的第一个 Android OpenGL ES 3.0 应用程序。我们将使用在编程 OpenGL ES 3.0 Hello World 三角形菜谱中编写的NativeTemplate.h/NativeTemplate.cpp源代码。本菜谱使用 Android 框架来提供必要的服务,以在其中托管 OpenGL ES 程序。

准备工作

对于我们的第一个 Android OpenGL ES 3.0 菜谱,我们建议您在本章中找到示例AndroidHelloWorldTriangle菜谱。将内容导入以快速构建应用程序将非常有帮助。有关导入菜谱的说明,请参阅附录中的在 Android ADT 和 iOS 中打开示例项目菜谱,OpenGL ES 3.0 补充信息

如何操作...

这里是逐步编程我们的第一个 Android OpenGL ES 3.0 应用程序的步骤:

  1. 通过转到新建 | Android 项目创建一个空白活动项目。为应用程序和项目提供合适的名称。例如,将应用程序名称指定为AndroidBlueTriangle项目名称指定为AndroidBlueTriangle,并将包名指定为cookbookgles。Java 中的包名与 C/C++中的命名空间概念等效。

  2. 在最后一页,将活动名称指定为GLESActivity布局名称指定为activity_gles导航类型指定为None

  3. 包资源管理器中,浏览到AndroidBlueTriangle | src | cookbook.gles。在这里,您将找到我们的GLESActivity类。在同一个名为cookbook.gles的包下,添加两个新类,分别命名为GLESViewGLESNativeLib。为了添加一个新类,在包资源管理器中右键单击cookbookgles包,然后转到新建 |

  4. 使用示例菜谱AndroidBlueTriangle并将GLESActivity.javaGLESView.javaGLESNativeLib.java的内容复制/粘贴到项目中的相应文件。在下一节中,你将更好地理解这些文件以及它们包含的类。

  5. 在此项目下添加一个名为 JNI 的新文件夹。在此文件夹内,创建Android.mkApplication.mkNativeTemplate.hNativeTemplate.cppandroid.mk原生代码 makefile 由 JNI 使用,如前一个菜谱中所述。使用HelloWorldAndroid将这两个文件的源内容复制到相应的文件中。

  6. 对于 OpenGL ES 3.0,Android.mk必须包含-lEGL-lGLESv3标志,以便与 EGL 和 OpenGL ES 3.0 库进行链接。此外,由于我们针对运行 Android 版本 18(Jelly Bean)的 Android 设备,Application.mk必须包含APP_PLATFORM:=android-18平台。如何操作...

  7. 打开命令行终端,在jni文件夹内运行ndk-build。在 Eclipse 中,刷新Package Explorer,以便由ndk-build创建的库在项目中更新。以下是执行后的渲染输出:如何操作...

它是如何工作的...

第一个 OpenGL ES 3.0 的 Android 菜谱包含两个 OpenGL ES 类:

  • GLESActivity是 Activity 的扩展版本。Activity 是一个应用程序组件,允许在屏幕上显示各种类型的视图。每个活动都有一个窗口区域,在其中渲染各种类型的视图。为了满足我们的需求,我们需要一个可以渲染 OpenGL ES 的表面。因此,GLESActivity类使用GLESView进行查看。

  • GLESView是我们从GLSurfaceView扩展的自定义类。它提供了一个 OpenGL ES 渲染的表面。它帮助 OpenGL ES 了解各种事件,例如活动状态、是否处于活动或睡眠模式、是否改变了其尺寸等等。GLSurfaceView提供了一些重要的类接口。其中,最重要的三个如下:

    • GLSurfaceView.EGLConfigChooser:这个类负责根据我们的需求选择正确的 EGL 配置。基本上,EGL 是 OpenGL ES API 和渲染上下文之间的接口。为了使用正确的渲染上下文,我们应该知道适合我们需求的 EGL 配置。在这个菜谱中,我们扩展了ConfigChooserGLSurfaceView.EGLconfigChooser

    • GLSurfaceView.EGLContextFactory:渲染上下文在很大程度上取决于设备硬件配置。OpenGL ES API 不会知道或关心创建渲染上下文。您的本地 SDK 提供者负责提供创建它的接口并将其附加到您的本地应用程序系统中。在 Android 上,这是通过 EGLContextFactory 类实现的。这需要 EGL 配置。我们已经看到 EGLConfigChooser 类如何根据我们的要求提供正确的 EGL 配置。您需要使用此配置来创建您的自定义 ContextFactory,这是我们配方中 GLSurfaceView.EGLContextFactory 的扩展版本。

      要创建 OpenGL ES 3.0 上下文,请使用 eglCreateContext 函数。此函数接受一个属性列表,其中第二个项目属于 OpenGL ES 版本,必须是 3.0。请参阅此处提供的示例代码,了解 OpenGL ES 3.0 的支持情况:

      private static double glVersion = 3.0;
      int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, (int) glVersion, EGL10.EGL_NONE };
      EGLContext context = egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT,  attrib_list);
      
    • GLSurfaceView.Renderer:这提供了管理 OpenGL ES 调用来渲染一帧的接口。它循环调用渲染函数。

  • NativeTemplate.cpp:这是包含负责在屏幕上渲染蓝色三角形的 OpenGL ES 命令的原生代码文件。

当 Android OpenGL ES 框架启动活动时,它首先检查设备上的可用 EGL 配置,并选择最适合我们要求的配置。此配置用于创建 OpenGL ES 渲染上下文。最后,通过 GLSurfaceRenderer 执行渲染,其中它通过 GLESNativeLib 类调用原生 OpenGL ES 代码。

OpenGL ES 渲染源代码在 NativeTemplate.cpp 中编写,该文件通过 libglNative.so 静态库暴露给 Android 框架。此库使用 ndk-build 命令从 NDK 编译,并自动存储在 AndroidBlueTriangle | libs | armeabi | libglNative.so 文件夹下。

注意

在编译 NDK 构建后,生成的库以 lib 为前缀。如果 Android.mk 中提到的名称已经以 lib 为前缀,则此前缀将被丢弃。

更多内容...

您可以在 developer.android.com/reference/android/opengl/package-summary.html 上探索官方 Android OpenGL ES 及其框架类。

参见

  • 请参阅 附录 中的 OpenGL ES 3.0 – Android ADT 软件要求 配方,OpenGL ES 3.0 补充信息

  • 在 Android 上使用 JNI 与 C/C++ 通信

开发 iOS OpenGL ES 3.0 应用程序

与 Android 相比,在 iOS 上开发 OpenGL ES 应用程序要简单得多。iOS 7 SDK、Xcode 5.0 及更高版本支持 OpenGL ES 3.0。使用 Xcode 5.0 中的 App Wizard,可以轻松开发 OpenGL ES 3.0 应用程序。

准备工作

确保你的 Xcode IDE 中应有 iOS 7 支持。更多信息,请参阅附录中的OpenGL ES 3.0 – Android ADT 软件要求配方,OpenGL ES 3.0 补充信息。建议在 Xcode 中导入示例配方iOSHelloWorldTriangle。这将有助于快速理解理论。

如何操作...

这里是第一个 iOS OpenGL ES 3.0 应用程序的逐步描述:

注意

开发 OpenGL ES 3.0 应用程序使用 Xcode App Wizard。

  1. 打开 Xcode,转到文件 | 新建 | 项目,选择OpenGL 游戏,然后点击下一步

  2. 根据你的选择提供产品名称组织名称公司标识符。例如,我们分别使用iOSBlueTrianglemacbookCookbook。转到下一页,选择位置,并创建项目。

  3. 从项目导航器中删除ViewController.m。相反,我们将使用自己的文件。转到文件 | 添加文件iOSBlueTriangle。现在,定位本书提供的源代码并打开HelloWorldiOS文件夹。选择ViewController.mmNativeTemplate.cppNativeTemplate.h,并将这些添加到项目中。随意探索这些添加的文件。构建(command + B)并执行(command + R)项目。

  4. Xcode 中 OpenGL ES 的开发确保使用正确的 OpenGL ES 版本。它由 Xcode 构建系统自动通过部署目标解决。如果部署目标是 iOS 7,则使用 OpenGL ES 3.0 库;否则,使用 OpenGL ES 2.0 库。如果源文件中的代码使用固定功能管线编程 API,则表示使用 OpenGL ES 1.1。对于我们当前的配方,请确保您已将部署目标设置为7.0如何操作...

程序会自行处理引用计数。因此,建议您禁用自动引用计数(ARC)来构建程序。否则,编译可能会失败。按照以下步骤禁用 ARC:

  • 点击左侧组织器中的你的项目

  • 在下一列选择你的目标

  • 选择顶部的构建设置选项卡

  • 滚动到Objective-C 自动引用计数(可能在用户定义设置组下列为CLANG_ENABLE_OBJC_ARC)并将其设置为

它是如何工作的...

Xcode 为构建 iOS 7.0 的应用程序提供了一个应用程序向导。OpenGL ES 开发使用 GLKit,该框架是在 iOS 5.0 中引入的。GLKit 是一个 Objective C/C++ 的 OpenGL ES 开发框架。它用于开发适用于可编程管道架构的 3D 图形应用程序。由于我们正在开发一个跨平台工作的可移植应用程序,这个套件可能对我们来说(GLKit 是 Objective C/C++)在那一方向上并不完全有帮助。我们将创建我们自己的自定义图形开发框架,这将有助于跨 Android 和 iOS 的可移植应用程序。我们将使用 GLKit 来构建我们的图形开发框架套件和 iOS 之间的桥梁。我们将在 第二章,OpenGL ES 3.0 基础 中介绍这个框架。

应用程序向导为我们创建了两个类,AppDelegateViewController。这些类在此处描述如下:

  • AppDelegate: 这个类继承自 UIResponder<UIApplicationDelegate>,它定义了响应触摸和运动事件的 UIobject 接口。UIApplicationUIView 也都是 UIResponder 的子类。在 iOS 中,UIApplication 类为底层操作系统提供了一个集中的控制点,以协调应用程序。每个 UIApplication 都必须实现一些 UIApplicationDelegate 的方法,这些方法提供了关于应用程序中发生的键事件的信息。例如,这些关键事件可以是应用程序启动、终止、内存状态和状态转换。

  • ViewController: GLKit 通过 GLKitViewGLKitController 提供了一个标准的 ViewController 类比。ViewController 是从 GLKitController 继承而来的。这两个类共同完成渲染任务。GLKitView 管理应用程序的帧缓冲区对象。当它更新时,它负责将绘制命令渲染到帧缓冲区中。然而,GLKitController 提供了必要的接口来控制帧的节奏和它们的渲染循环:

    //AppDelegate.h
    #import <UIKit/UIKit.h>
    
    @class ViewController;
    @interface AppDelegate : UIResponder <UIApplicationDelegate>
    @property (strong, nonatomic) UIWindow *window;
    @property (strong, nonatomic) ViewController *viewController;
    @end
    

当 iOS 启动一个应用程序时,它创建了一个 UIResponder 的实例,这基本上创建了应用程序对象。这个应用程序对象为应用程序提供了一个在屏幕窗口中的物理空间的服务。这种窗口化是由 UIWindow 对象提供的,该对象将在 UIApplication 的构建过程中创建。这个窗口对象包含要在屏幕上显示的视图。在我们的情况下,这个视图应该是某些 OpenGL 渲染表面,由 GLKitController 提供。当 GLKitController 的类对象被创建时,它会自动创建与之关联的视图。这有助于应用程序提供必要的 OpenGL 渲染表面:

// AppDelegate.m
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.window = [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen]  bounds]] autorelease];
// Override point for customization after application launch.
self.viewController = [[[ViewController alloc] initWithNibName:@"ViewController" bundle:nil] autorelease];
self.window.rootViewController = self.viewController;
[self.window makeKeyAndVisible];
return YES;
}

didFinishLaunchingWithOptions 接口从 UIApplicationDelete 通知应用程序已完成加载的事件状态。在此事件中,我们创建了窗口并设置了 ViewController

当从 GLKitController 扩展子类时,重写 viewDidLoadviewDidUnload 方法非常重要:

//  ViewController.mm
- (void)viewDidLoad
{
  [super viewDidLoad];

  self.context = [[[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3] autorelease];
  if (!self.context) {
    NSLog(@"Failed to create ES context");
  }

  GLKView *view = (GLKView *)self.view;
  view.context = self.context;
  view.drawableDepthFormat = GLKViewDrawableDepthFormat24;

  [self setupGL];
}

viewDidLoad 方法有助于创建渲染上下文并为其设置所有可绘制属性以进行适当的配置。为了创建 OpenGL ES 3.0 渲染上下文,我们使用 initWithAPI。它接受 kEAGLRenderingAPIOpenGLES3 作为参数。此参数确保渲染上下文是为 OpenGL ES 3.0 版本设计的。

我们可以修改渲染上下文属性以配置可绘制帧缓冲对象格式,例如 drawableColorFormatdrawableDepthFormatdrawableStencilFormatdrawableMultisample

此方法也是初始化和其他资源分配的好地方。最后一行是在 Objective C++ 语法中调用 setupGL 函数[self setupGL]。因此,它等同于 C++ 中的 setupGL()

//  ViewController.mm
- (void)setupGL
{
  [EAGLContext setCurrentContext:self.context];
  GLint defaultFBO, defaultRBO;

  glGetIntegerv(GL_FRAMEBUFFER_BINDING &defaultFBO);
  glGetIntegerv(GL_RENDERBUFFER_BINDING, &defaultRBO);
  glBindFramebuffer( GL_FRAMEBUFFER, defaultFBO );
  glBindRenderbuffer( GL_RENDERBUFFER, defaultRBO );

  setupGraphics(self.view.bounds.size.width,
  self.view.bounds.size.height);

}

setupGL 函数使用我们在 viewDidApplication 中创建的上下文设置当前上下文。这对于使 OpenGL ES APIs 工作非常重要。glBindFramebufferglBindRenderbuffer API 帮助其他 API 知道要在哪个目标帧缓冲区上渲染。在 OpenGLES 中,数据在称为帧缓冲区的信息缓冲容器矩形数组中渲染。帧缓冲区由许多其他辅助缓冲区组成,如颜色、深度和模板缓冲区,以在屏幕窗口上完成渲染。有时,可能会出现丢失帧缓冲区或渲染缓冲区的情况。在这种情况下,在调用任何 OpenGL ES 3.0 API 之前,建议使用这两个函数绑定这些缓冲区。

为了渲染我们的应用程序,我们必须重写 drawRect 方法:

//  ViewController.mm
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
    renderFrame();
}

renderFrame 函数包含渲染蓝色三角形的所有必要代码。

参见

  • 请参阅 附录 中的 固定功能管道和可编程管道架构 配方,OpenGL ES 3.0 补充信息

  • 请参阅 附录 中的 OpenGL ES 3.0 – iOS 的软件要求 配方,OpenGL ES 3.0 补充信息

  • 请参阅 使用 GLPI 框架构建原型 的配方,第二章, OpenGL ES 3.0 基础

第二章:OpenGL ES 3.0 基础

在本章中,我们将涵盖以下小节:

  • 使用 GLPI 框架构建原型

  • 实现触摸事件

  • 使用顶点数组渲染原语

  • OpenGL ES 3.0 中的绘图 API

  • 使用顶点缓冲对象进行高效渲染

  • 使用模型、视图和投影类比进行变换

  • 在 GLPI 中理解投影系统

  • OpenGL ES 3.0 中的剔除

  • OpenGL ES 3.0 中的深度测试

简介

本章将详细描述理解 3D 图形并使用 OpenGL ES 3.0 实现它们所需的基本概念。在本章的开始,我们将构建一个迷你便携式 3D 引擎,这将有助于快速创建基于原型的项目。它在 OpenGL ES 3.0 可编程管道中轻松管理代码。您将学习事件处理,以在 Android 和 iOS 平台上管理屏幕表面的手势。这些将有助于实现基于手势的应用程序。

随着我们继续前进,我们将借助模型、视图和投影的类比来讨论 3D 图形中的基本变换。在核心方面,我们将探讨在 OpenGL ES 3.0 中渲染给定几何形状的不同原语类型,并讨论使用 顶点缓冲对象VBO)可能提高渲染性能的优化技术。随着接近尾声,我们将了解几何剔除。它控制着对象的前面或后面的渲染。本章的最后一个小节将实现深度测试,这是计算机图形学中的一个非常重要的方面。

使用 GLPI 框架构建原型

GLPI 是一个 OpenGL ES 平台无关的框架。它是一个高度有用的迷你 3D 引擎,可以快速开发原型应用程序。它为渲染引擎、着色器编译、3D 变换、网格管理、缓冲区管理、纹理等提供了高级实用类。该框架主要支持 Android 和 iOS 平台,并设计为易于移植到其他平台,如 WinCE、Blackberry、Bada 等。在本章中,我们将详细讨论此框架的每个模块。

此框架为 OpenGL ES 3.0 原型应用程序的快速开发提供了专用模块。它包括以下三个主要模块,如图所示:

使用 GLPI 框架构建原型

让我们逐一详细探讨它们:

  • GLPI 模块:此模块是 GLPI 框架的骨干。它包含以下类:

    类别 功能
    程序管理器 此类负责从程序对象创建着色器程序。它维护所有程序对象在一个可管理的单一组件中,其他模块可以在需要时使用。
    着色器管理器 此类负责生成着色器对象。它自动化了为程序管理器加载、编译和生成着色器对象的流程。
    变换 此类提供用于 3D 变换操作的高级 API。它还提供了包装函数来模拟固定功能管道 API,例如变换、模型视图投影矩阵、推和弹出矩阵操作等。
    GLUtils 此类为 GLPI 模块提供辅助函数。
  • 模型模块:此模块将帮助我们为我们的应用程序创建定制模型。Model类本质上代表我们在设备屏幕上感兴趣渲染的任何类型的几何对象。此类提供模型的初始化、状态管理、处理和渲染例程。它还在模型内提供触摸事件处理。

    注意

    渲染模块的Model类代表我们在屏幕上感兴趣渲染的任何类型的 3D 渲染对象。例如,如果我们愿意渲染一个三角形,那么我们应该创建一个Triangle类,这个类必须从Model类派生,并且应该作为子成员添加到Renderer类中。

  • 渲染模块:这是渲染引擎的管理器,它充当底层平台(如 Android 或 iOS)和我们的平台无关图形框架之间的接口。它管理整个图形系统渲染的生命周期。除此之外,我们创建的定制模型也将由它管理。

如何操作...

在上一章中,我们为 Android 和 iOS 平台实现了 Hello World 三角形的配方。现在,你将通过在 GLPI 中重新实现相同的配方来学习使用 GLPI 框架。你可以通过在本章的示例代码中定位GLPIFrameworkIntro来参考此配方的源代码。在以下步骤中,我们将为 Android/iOS 平台设置 GLPI 框架,并学习如何使用它。

按照以下说明为 Android 平台设置 GLPI

  1. 创建Android.mk make 文件。make 文件包括用于编译目的的zlib make 文件的路径。它用于读取/写入压缩文件。zlib文件被编译为共享库并包含在项目中。附加库包括-lEGL -lGLESv3,它提供对 EGL、OpenGL ES 3.0 的支持,以及-llog,它允许记录有助于调试应用程序的日志信息:

    # Get the current local of the working directory
    MY_CUR_LOCAL_PATH := $(call my-dir)
    
    # Initialize variables to store relative directories
    FRAMEWORK_DIR      = ../../../../GLPIFramework
    SCENE_DIR          = ../../Scene
    GLM_SRC_PATH       = $(FRAMEWORK_DIR)/glm
    ZLIB_DIR           = $(FRAMEWORK_DIR)/zlib
    
    # Clear the any garbage variable and include ZLIB
    include $(CLEAR_VARS)
    include $(MY_CUR_LOCAL_PATH)/../../../../GLPIFramework/zlib/Android.mk
    
    LOCAL_PATH := $(MY_CUR_LOCAL_PATH)
    include $(CLEAR_VARS)
    
    # Name of the library
    LOCAL_MODULE     :=   glNative
    
    # Include the library and GLPI framework files
    LOCAL_C_INCLUDES :=   $(GLM_SRC_PATH)/core \
                          $(GLM_SRC_PATH)/gtc \
                          $(GLM_SRC_PATH)/gtx \
                          $(GLM_SRC_PATH)/virtrev \
                          $(ZLIB_DIR) \
                          $(FRAMEWORK_DIR) \
                          $(SCENE_DIR)
    
    # Specify the source files to compile
    LOCAL_SRC_FILES :=    $(FRAMEWORK_DIR)/GLutils.cpp \
                          $(FRAMEWORK_DIR)/Cache.cpp \
                          $(FRAMEWORK_DIR)/ShaderManager.cpp \
       $(FRAMEWORK_DIR)/ProgramManager.cpp \
       $(FRAMEWORK_DIR)/Transform.cpp \
       $(SCENE_DIR)/Model.cpp \
       $(SCENE_DIR)/Renderer.cpp \
       $(SCENE_DIR)/Triangle.cpp \
       ../../NativeTemplate.cpp
    
    # include necessary libraries
    LOCAL_SHARED_LIBRARIES    := zlib
    LOCAL_LDLIBS              :=  -llog -lEGL -lGLESv3
    
    # Build as shared library
    include $(BUILD_SHARED_LIBRARY)
    
  2. 在同一目录下创建一个新的Application.mk make 文件,并将 STL、RTTI 和异常支持添加到你的项目中,如下面的代码行所示。对于 OpenGL ES 3.0,Android 平台的 API 级别必须为 18 或更高:

    APP_PLATFORM    := android-18
    APP_STL         := gnustl_static
    APP_CPPFLAGS    := -frtti –fexceptions
    
  3. 从现在开始,章节包含两个独立的 Android 和 iOS 开发部分。这些部分将通过名为 Android 和 iOS 的文件夹名称来识别。NativeTemplate.hNativeTemplate.cpp(如第一章所述)放置在这些文件夹旁边。如果我们现在查看这些文件,我们会意识到它们比以前更干净,代码也更少。我们已经将这些文件中的代码移动到了 Scene 文件夹中的其他文件中:如何做...

  4. Scene 文件夹包含 ModelRenderer 类,负责生成模型和渲染它们。该文件夹中还存在另一个类,即 Triangle 类。它包含渲染蓝色三角形的代码。确保所有类都包含在 Android.mk 中:如何做...

  5. assets 文件夹下创建一个名为 Shader 的新文件夹,并在该文件夹中创建着色器文件(BlueTriangleVertex.glslBlueTriangleFragment.glsl)。将之前存在于 NativeTemplate.cpp(以字符串形式)中的着色器程序移动到新创建的 Shader 文件夹中。从现在开始,我们将在这个文件夹中管理我们的着色器程序:如何做...

对于 iOS,设置框架的步骤相对简单,如下所示

  1. 将所有 GLPI 框架内容(除了 zlib 库)导入到您的项目中。这个库仅用于 Android 的文件管理。iOS 不需要它。

  2. 在导入的内容中,转到 glm 库并删除 core 文件夹(这个文件夹包含一些可能由于存在多个 main() 条目而导致现有项目出现错误的示例程序)。

  3. 通过转到 Android | Asset | Shader 文件夹(BlueTriangleVertex.glslBlueTriangleFragment.glsl)将着色器文件导入到当前项目中。

  4. main.m 中设置 "FILESYSTEM" 环境变量。这将提供设备中应用程序的当前路径:

    setenv( "FILESYSTEM", argv[ 0 ], 1 );
    

    如何做...

使用 GLPI 框架非常简单。我们必须遵循以下规则集来渲染我们的 3D 几何模型:

  1. 创建一个新的自定义模型类,该类从 Model 类派生。例如,我们在 Triangle.h 中创建了从 Model 类派生的 Triangle 类:

    class Triangle : public Model{
    private:
        // variables for holding attribute values
        GLuint positionAttribHandle,colorAttribHandle;
        GLuint radianAngle;
    
        float degree; // Rotation in degree form
        float radian; // Rotation in radian form
    
    public:
        Triangle(Renderer* parent = 0); // Constructor
        ~Triangle();                    // Destructor
    
        void InitModel();  // Initialize the model here
        void Render();    // Perform the rendering
    };
    
  2. 打开 constant.h 并编辑枚举 ModelType。添加您选择的枚举以识别模型类型。例如,为 TriangleCube 添加了两个枚举。这个枚举将有助于渲染器管理模型对象:

    enum ModelType{
       //! The Triangle Model identifier.
       TriangleType    = 0,
       CubeType        = 1
    };
    
  3. Triangle 的构造函数中定义 ModelType。每个模型都包含渲染对象作为其父对象。它还包含对 ProgramManagerTransform 的引用:

    Triangle::Triangle( Renderer* parent ){
       if (!parent) return;
    
       RenderHandler      = parent;
       ProgramManagerObj  = parent->RendererProgramManager();
       TransformObj       = parent->RendererTransform();
       modelType          = TriangleType;
       degree             = 0;
    }
    
  4. Triangle.cpp 中创建 VERTEX_SHADER_PRGFRAGMENT_SHADER_PRG 宏,以定义 iOS 和 Android 平台上的着色器文件相对路径。这些宏提供了一种平台无关的方式来从项目解决方案中访问着色器源代码文件:

    #ifdef __APPLE__
    #define VERTEX_SHADER_PRG "BlueTriangleVertex.glsl"
    #define FRAGMENT_SHADER_PRG "BlueTriangleFragment.glsl"
    #else
    #define VERTEX_SHADER_PRG "shader/BlueTriangleVertex.glsl"
    #define FRAGMENT_SHADER_PRG "shader/BlueTriangleFragment.glsl"
    #endif
    
  5. 重写 InitModel() 函数。在这里,我们需要编译我们的着色器并将其注册到 ProgramManager 以供将来使用。ProgramManager 以最优方式存储编译后的着色器,以便快速访问查询到的属性。始终为着色器提供一个名称(在我们的例子中,为 Triangle)。ProgramManager 使用它作为句柄,这将有助于从任何类型的模型类中检索着色器:

    void Triangle::InitModel(){
    if(!(program = ProgramManagerObj->Program
     ( ( char* )"Triangle") )){
       program = ProgramManagerObj->ProgramInit
    ( ( char * )"Triangle" );
       ProgramManagerObj->AddProgram( program );
     }
     // Initialize Shader 
     program->VertexShader   = ShaderManager::ShaderInit
                   (VERTEX_SHADER_PRG, GL_VERTEX_SHADER);
     program->FragmentShader = ShaderManager::ShaderInit
                   (FRAGMENT_SHADER_PRG, GL_FRAGMENT_SHADER);
    
     // Allocate the buffer memory for shader source 
     CACHE *m = reserveCache( VERTEX_SHADER_PRG, true );
     if( m ) {
       if(!ShaderManager::ShaderCompile
         (program->VertexShader,(char*)m->buffer, 1)) exit(1);
          mclose( m );
     }
    
     m = reserveCache( FRAGMENT_SHADER_PRG, true );
     if( m ) {
       if(!ShaderManager::ShaderCompile
          (program->FragmentShader,(char*)m->buffer,1))exit(2);
           mclose( m );
     }
     // Link and Use the successfully compiled shader
     if(!ProgramManagerObj->ProgramLink(program,1)) exit(3);
     glUseProgram( program->ProgramID );
    }
    
  6. 重写 Render() 函数。它负责在屏幕表面上渲染彩色三角形。在这个函数中,首先使用着色器程序查询相应的属性。这些属性用于将数据发送到着色器。三角形的每一帧都会旋转 1 度并在着色器中更新:

    void Triangle::Render(){
       // Use the shader program for this render
       glUseProgram( program->ProgramID );
    
        radian = degree++/57.2957795;
    
        // Query and send the uniform variable.
        radianAngle = glGetUniformLocation
                    (program->ProgramID, "RadianAngle");
        glUniform1f(radianAngle, radian);
    
        positionAttribHandle = ProgramManagerObj->
                       ProgramGetVertexAttribLocation
                       (program,(char*)"VertexPosition");
        colorAttribHandle = ProgramManagerObj->
                      ProgramGetVertexAttribLocation
                      (program, (char*)"VertexColor");
    
        // Send the data to the shader    
        glVertexAttribPointer(positionAttribHandle, 2,
              GL_FLOAT, GL_FALSE, 0, gTriangleVertices);
        glVertexAttribPointer(colorAttribHandle, 3, 
              GL_FLOAT, GL_FALSE, 0, gTriangleColors);
    
        // Enable the attribute and draw geometry 
        glEnableVertexAttribArray(positionAttribHandle);
        glEnableVertexAttribArray(colorAttribHandle);
        glDrawArrays(GL_TRIANGLES, 0, 3); 
      }
    
  7. 当不再需要着色器时,销毁它们。对于这个示例,我们将使用析构函数:

      Triangle::~Triangle(){
        // Remove the shader in the destructor
        if (program = ProgramManagerObj->Program
                      ((char*) "Triangle"))
        {   ProgramManagerObj->RemoveProgram(program); }
      }
    

    注意

    我们在类中创建的着色器对渲染引擎中的其他模型是公开可访问的。因此,是否销毁它或将其保留在渲染引擎中完全取决于我们。

  8. Renderer.cpp 文件中,在 clearModels() 函数之后,将 Triangle 模型添加到 Renderer::createModels() 中:

    void Renderer::createModels(){
      clearModels();
      addModel(new Triangle(this )); //Add custom models here
    }
    

    注意

    clearModels() 确保渲染引擎中 Model 对象和着色器之间没有冲突。因此,它提供了一种干净的方法来避免 OpenGL ES 着色器中的任何冗余。

  9. 重写 Render() 函数。这个函数负责使渲染模型出现在屏幕上。

工作原理...

Renderer 类是渲染系统的管理者。在 GLPI 框架中定义的每个自定义模型都作为 Renderer 的注册成员,并通过其独特的模型类型被识别。Renderer 通过 TransformProgramManager 接口等实用程序和辅助类为注册组件提供服务。渲染引擎遍历整个注册模型以定义它们的生命周期。它确保模型的初始化、渲染和销毁在正确的时间以正确的顺序发生。

ProgramManager 负责编译着色器并将其缓存以供以后使用。变换在几何变换操作中起着至关重要的作用。例如,它通过旋转、平移和缩放操作帮助将模型放置在 3D 空间中。

关于 3D 变换内部机制的更多信息,您可以参考附录中的理解 3D 图形中的变换OpenGL ES 3.0 补充信息。本主题涵盖了变换类型、变换矩阵约定、齐次坐标以及变换操作,如平移、缩放和旋转。

更多内容...

Renderer类内部,可以使用setUpProjection()函数调整场景的投影。此函数负责设置视图剪切平面。剪切平面可以定义为截锥体(透视)或长方体(正交)形状。我们将在本章后面的理解 GLPI 中的投影系统配方中进一步讨论投影。

相关内容

  • 参考第一章中的开发 Android OpenGL ES 3.0 应用程序开发 iOS OpenGL ES 3.0 应用程序配方,第一章,Android/iOS 上的 OpenGL ES 3.0

实现触摸事件

当前的智能手机能够通过手势与应用程序交互。这些手势是在触摸敏感设备屏幕表面进行的。当设备检测到这些手势输入时,它会将这些触摸事件报告给相应的事件处理器。应用程序处理器接收这些事件并根据应用程序的要求过滤它们。在本配方中,我们将使用 iOS 和 Android 平台上的 OpenGL ES 3.0 实现触摸事件。您将学习如何以平台无关的方式接收和处理事件。

准备工作

Android 中的GLSurfaceView类和 iOS 中的GLKViewController提供了实现触摸事件所需的 API。这些 API 报告检测到的触摸事件性质,例如用户是否在设备屏幕上点击或移动了他们的手势。这些 API 通过常见的触摸事件接口暴露给 GLPI 框架。这些接口负责报告和传播触摸事件到已注册的成员。已注册成员的基础类(Model)包含所有可以由派生版本处理的触摸事件接口。由于这些是唯一的接口,因此已注册成员需要根据其自定义需求重写它们。

如何操作...

本节将详细描述如何在 Android 和 iOS 平台上设置和实现触摸事件。

首先,在NativeTempleRenderer类中实现通用的接口,这些接口可以以通用的方式接收触摸事件,无论平台实现如何。

  1. Renderer类中声明和定义触摸事件接口。例如,以下代码展示了点击事件实现:

       // Declaration
       void TouchEventDown(float x, float y);
    
       // Definition
       void Renderer::TouchEventDown( float x, float y ){
       for( int i=0; i<RenderMemData.models.size(); i++ ){
           RenderMemData.models.at(i)->TouchEventDown(x, y);
       }
    }
    
  2. NativeTemple.h/.cpp中,从全局声明和定义的包装函数中调用渲染器的触摸事件:

    void TouchEventDown( float x, float y ) // Declaration
    void TouchEventDown( float x, float y ){ // Definition
       Renderer::Instance().TouchEventDown( x, y );
    }
    
  3. 在 Android 平台上,我们需要在NativeTemplate.h/.cpp中定义新的 JNI 本地方法,以便与 Android 框架通信以检索触摸事件。为此,在GLESNativeLib Java 类中为触摸事件定义以下接口:

    public static native void TouchEventStart(float x,float y);
    
  4. NativeTemplate中声明并定义之前声明的触摸事件的 JNI 接口:

    // Declaration of Tap event
    JNIEXPORT void JNICALL 
    Java_cookbook_gles_GLESNativeLib_TouchEventStart 
                             (JNIEnv * env, jobject obj, float x, float y );
    
    // Definition of Tap event
    JNIEXPORT void JNICALL
    Java_cookbook_gles_GLESNativeLib_TouchEventStart
                             (JNIEnv * env, jobject obj, float x, float y )
    {
       TouchEventDown(x ,y);
          }
    
  5. 重复步骤 1 到 4 以实现移动和释放触摸事件。

  6. 覆盖GLSurfaceView类的onTouchEvent()。此函数提供了各种类型的触摸事件。例如,点击、移动、单点/多点触摸等是一些重要的事件。这些事件需要过滤,以便它们可以使用:

    public boolean onTouchEvent( final MotionEvent e ){
      switch( event.getAction() ){
      case MotionEvent.ACTION_DOWN: // Tap event
      GLESNativeLib.TouchEventStart(e.getX(0),e.getY(0));
      break;
    
      case MotionEvent.ACTION_MOVE: // Move event
      GLESNativeLib.TouchEventMove (e.getX(0), e.getY(0)); 
      break;
    
      case MotionEvent.ACTION_UP: // Release event
      GLESNativeLib.TouchEventRelease(e.getX(0),e.getY(0));
      break;
      }
      return true;
    }
    
  7. 在 iOS 平台上,GLKit 的GLKViewController类提供了需要覆盖的触摸函数,以便它们可以在我们的应用程序中使用。例如,看一下以下代码。它实现了类似于 Android 情况的点击、移动和释放事件。每个定义都调用了NativeTemplate.h/.cpp的全局包装函数:

    - (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent *)event{
       UITouch *touch; CGPoint pos;
       for( touch in touches ){
           pos = [ touch locationInView:self.view ];
           TouchEventDown( pos.x, pos.y ); //The global wrapper
       }
    }
    
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{
       UITouch *touch; CGPoint pos;
       for( touch in touches ){
          pos = [ touch locationInView:self.view ];
          TouchEventMove( pos.x, pos.y ); // The global wrapper
       }
    }
    
    - (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event{
       UITouch *touch; CGPoint pos;
       for( touch in touches ){
          pos = [ touch locationInView:self.view ];
          TouchEventRelease(pos.x,pos.y); // The global wrapper
       }
    }
    

它是如何工作的...

当 Android 或 iOS 应用程序从设备接收到触摸事件时,将触摸事件传达给外部世界的责任在于GLSurfaceViewGLKViewController。这些类将触摸事件报告给定义在NativeTemple中的全局包装函数。此文件提供了触摸事件(如点击、移动和释放)的高级跨平台函数。这些函数在内部与Renderer类的相应触摸事件接口接口。Renderer类提供了一个接口,以抽象的方式处理触摸事件,使其在任一平台上都能无缝工作。这些函数或接口通过NativeTemplates全局方法从 Android 或 iOS 平台调用,并传播到所有已注册的模型。例如,以下示例显示了移动事件的处理:

void Renderer::TouchEventMove( float x, float y ){
   for( int i=0; i<RenderMemData.models.size(); i++ ){
      // Handle the Touch events at model levels.
      RenderMemData.models.at(i)->TouchEventMove(x, y);
   }
}

参见

  • OpenGL ES 3.0 中的深度测试

使用顶点数组的渲染原语

在 OpenGL ES 3.0 中,顶点数组是绘制 3D 空间中对象的简单方法。对象通过顶点绘制,顶点按照由渲染原语指定的特定顺序排列。渲染原语表示单个或一组顶点如何组合以绘制几何图形。例如,四个顶点可以表示为一个点、一条线或一个三角形,如下所示:

使用顶点数组的渲染原语

顶点数组是几何数据(如顶点坐标、法线坐标、颜色信息和纹理坐标)以数组形式指定的方式。在本教程中,你将学习如何在 GLPI 框架中编程顶点数组。此外,我们还将演示 OpenGL ES 3.0 中可用的各种渲染原语。

如何做到这一点...

创建一个名为 Primitive 的新类,它从 Model 类派生,并按照以下步骤逐步实现使用顶点数组的渲染原语:

  1. 创建 PrimitiveVertex.glslPrimitiveFragment.glsl,并使用以下代码作为顶点和片段着色器:

    // Source code PrimitiveVertex.glsl
    #version 300 es
    in vec4 VertexPosition, VertexColor;
    out vec4 VarColor;
    uniform mat4 ModelViewProjectMatrix;
    
    void main(){
      gl_Position = ModelViewProjectMatrix * VertexPosition;
      VarColor    = VertexColor;
    }
    
    // Source code PrimitiveFragment.glsl
    #version 300 es
    precision mediump float;
    
    in vec4 VarColor;
    out vec4 FragColor;
    void main() {
      FragColor = vec4(VarColor.x,VarColor.y,VarColor.z,1.0);
    }
    
  2. 创建 10 个顶点,如图所示。然后,将信息存储到顶点数组中,并为每个顶点分配颜色。颜色信息以 RGB 的形式存储在颜色数组中。如何做...

  3. initModel 函数中,编译和链接顶点和片段着色器。在成功创建着色器程序对象后,使用名为 ProgramGetVertexAttribLocation 的 GLPI 包装函数查询顶点属性 VertexPositionVertexColor。此函数内部使用 OpenGL ES 3.0 通用顶点属性查询 API。使用包装 API 可以降低错误发生的概率并提高性能,因为这些查询已进行了优化:

      void Primitives::InitModel(){
       // Shaders are compiled and linked successfully
       // Many line skipped, please refer to the code
       glUseProgram( program->ProgramID ); 
       attribVertex=ProgramManagerObj->ProgramGetVertexAttribLocation        
                   (program, (char*)"VertexPosition");
       attribColor=ProgramManagerObj->ProgramGetVertexAttribLocation
                   (program, (char*)"VertexColor");
      }
    

    ProgramGetVertexAttribLocation 返回通用属性位置 ID。位置 ID 的负值指定在着色器中不存在具有该名称的属性。

    • 语法:

      char ProgramManager::ProgramGetVertexAttribLocation 
                                  (PROGRAM *program, char* name);
      
      变量 描述
      program 这是包含着色器信息的 GLPI 程序对象
      name 这是着色器源程序中属性的名称
  4. 在同一个 initModel 函数内,使用 GLPI 框架的另一个包装 API 查询统一变量:

      mvp = ProgramManagerObj->ProgramGetUniformLocation 
                   (program,(char*)"MODELVIEWPROJECTIONMATRIX");
    

    GLPI 框架中的 ProgramManager 提供了一个高级包装函数 ProgramGetUniformLocation,用于从着色器程序中查询任何统一类型变量。

    • 语法:

      GLint ProgramGetUniformLocation
                        (PROGRAM *program, char* name);
      
      变量 描述
      program 这是包含着色器信息的 GLPI 程序对象
      name 这是着色器源程序中统一对象的名字
  5. 创建一个 RenderPrimitive 函数,并在 Render 函数内部调用它。在这个函数中,将统一变量和每个顶点的属性数据发送到着色器:

      void RenderPrimitives(){
        glDisable(GL_CULL_FACE); // Disable the culling
        glLineWidth(10.0f);      // Set the line width 
    
        glUniformMatrix4fv( mvp, 1, GL_FALSE,( float * )
         TransformObj->TransformGetModelViewProjectionMatrix() );
    
        glVertexAttribPointer(attribVertex, 2, GL_FLOAT, 
        GL_FALSE, 0, vertices);
        glVertexAttribPointer(attribColor, 3, GL_FLOAT, 
        GL_FALSE, 0, colors);
       }
    
  6. 启用顶点和颜色通用属性,并使用 switch case 语句绘制各种原语:

      glEnableVertexAttribArray(attribVertex);
      glEnableVertexAttribArray(attribColor);
      glDrawArrays(primitive, 0, numberOfElement);
    

工作原理...

此配方有两个数组,顶点和颜色,它们包含顶点信息和颜色信息。有 10 个顶点,每个顶点存储一个 XY 分量。颜色信息还包含每个顶点的 10 种不同的颜色。颜色信息以 RGB 颜色空间指定,范围为 0.0 到 1.0。

顶点着色器包含两个每个顶点的属性,VertexPositionVertexColor。这些属性通过属性位置在程序中被唯一识别。此位置是通过 ProgramGetVertexAttribLocation 函数查询的。查询到的属性用于将顶点数组信息绑定到每个顶点的属性。顶点属性数据是通过 glVertexAttribPointer 发送的。

同样,使用名为ProgramGetUniformLocation的单独函数以相同的方式查询统一变量。统一变量是一个 4 x 4 的ModelViewProjection矩阵。因此,数据通过glUniformMatrix4fv发送到着色器。glLineWidth函数用于GL_LINE变体原语,以定义线的宽度为 10 像素。

最后,使用glDrawArrays渲染 OpenGL ES 3.0 原语。通过简单的屏幕点击可以看到各种原语渲染的实际效果。点击时,点击事件将调用Primitive类的TouchEventDown函数,该函数负责更改当前渲染的原语类型:

工作原理...

在计算机 3D 图形中,多边形形状是通过三角形原语进行渲染的。与GL_TRIANGLES相比,GL_TRIANGLE_STRIP更受欢迎,因为指定三角形形状所需的顶点数更少。在后一种情况下,需要从 CPU 发送更多数据到 GPU,因为相邻边共享公共顶点。在前一种情况下,顶点以特殊顺序排列,从而避免了共享边上的重复顶点。因此,它需要更少的数据。确实,在某些情况下,GL_TRIANGLE_STRIP可能更好,因为需要定义的数据更少。然而,这需要根据 3D 模型格式逐个案例考虑。

有许多工具可用于将三角形带形式的几何信息进行转换。例如,nVIDIA 的NvTriStrip库可以从任意 3D 几何中生成三角形带。更多信息,请访问www.nvidia.com/object/nvtristrip_library.html

注意

关于绘图 API 的更多信息,请参考OpenGL ES 3.0 中的绘图 API配方。它演示了glDrawArraysglDrawElements

有更多内容...

本节将重点介绍 OpenGL ES 3.0 中可用的基本渲染原语。原语是用于生成 3D 图形中任何复杂形状的最简单形状。OpenGL ES 3.0 的原语可以分为三种基本类型:点、线和三角形。其余的都是这些类型的变体。

下表描述了 OpenGL ES 3.0 中所有可用的点、线和三角形变体原语:

原语类型 输入顶点 输出形状 描述
GL_POINTS 有更多内容... 有更多内容... 屏幕上的点代表每个顶点。
GL_LINES 有更多内容... 有更多内容... 每对顶点用于渲染它们之间的一条线。我们可以使用glLineWidth() API 来控制线渲染的宽度。
GL_LINE_LOOP 还有更多… 还有更多… 每个顶点与其前一个顶点之间画一条线。最后一个顶点始终与第一个顶点相连,形成一个闭合环。
GL_LINE_STRIP 还有更多… 还有更多… 每个顶点与其前一个顶点之间画一条线。
GL_TRIANGLES 还有更多… 还有更多… 使用三个顶点形成一个填充三角形。
GL_TRIANGLE_STRIP 还有更多… 还有更多… 每个顶点都与前两个顶点形成一个三角形。
GL_TRIANGLE_FAN 还有更多… 还有更多… 每个顶点都与第一个顶点和前一个顶点形成一个三角形。这生成一个类似扇形的图案。

参见

  • 参考第一章中的使用统一变量将数据发送到着色器使用顶点属性将数据发送到着色器食谱,OpenGL ES 3.0 on Android/iOS,第一章

OpenGL ES 3.0 中的绘图 API

OpenGL ES 3.0 提供了两种渲染 API:glDrawArraysglDrawElements。这些 API 允许我们将几何数据以原语的形式渲染到屏幕上。在本食谱中,你将学习这些 API 在编程中的应用,并了解它们之间的区别。

本食谱将通过使用前面提到的两种不同的渲染 API 来渲染一个立方体。这些 API 使用的数据集完全不同。点击屏幕以查看两个 API 之间的区别。

准备工作

glDrawArray API 按顺序读取数组形式的顶点信息,从第一个索引开始,到由 count 指定的总数。glDrawArray API 使用顶点数组数据信息渲染由 mode 参数指定的原语。

语法:

void glDrawArrays( GLenum mode, GLint first, GLsizei count);
变量 描述
mode 这指定了需要渲染的 OpenGL ES 原语类型
first 这是数据数组的起始索引
count 这表示要渲染的总索引数

例如,一个正方形可以渲染为两个三角形的集合:

   GLfloat  square[6][3] = {
      -1.0, -1.0, 1.0, /*Vertex0*/  1.0,-1.0, 1.0,  /*Vertex3*/
      -1.0,  1.0, 1.0, /*Vertex1*/  1.0, -1.0, 1.0, /*Vertex3*/
       1.0,  1.0, 1.0, /*Vertex2*/ -1.0,  1.0, 1.0, /*Vertex1*/
   };
  glDrawArrays(GL_TRIANGLES, 0, 18);

相比之下,glDrawElement API 使用一个类似于使用 C++/Java 访问数组元素的索引来映射每个顶点。与glDrawArray相比,这种方法在渲染时消耗的内存更少,因为在glDrawArray中,每个多余的顶点都需要用其XYZ分量来提及。例如,考虑一个规则立方体几何形状的案例,并计算glDrawElement提供的内存节省量。

语法:

void glDrawElements( GLenum mode, GLsizei count, GLenum type, const GLvoid * indices);
变量 描述
mode 这指定了前面表格中描述的原生类型
count 这指定了要渲染的元素数量
type 这指定了索引的数据类型
indices 这指定了顶点在数组形式中排列的索引顺序

例如,使用此 API 可以如下表示相同的正方形:

GLfloat square[4][3] = {
   -1.0, -1.0, 1.0, /*Vertex0*/  -1.0,  1.0, 1.0, /*Vertex1*/
    1.0,  1.0, 1.0, /*Vertex2*/   1.0, -1.0, 1.0, /*Vertex3*/
};
GLushort squareIndices[] = {0,3,1, 3,2,1};   // 6 indices
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, squareIndices);

如何做...

以下指令将提供逐步过程,以演示 glDrawArraysglDrawElements API 的使用:

  1. 创建一个从 Model 派生的 Cube 类。不需要在顶点和片段着色器中进行任何更改。可以重用之前菜谱中的 Shaders

  2. 定义 glDrawArray API 的顶点和颜色数据集:如何做...

  3. 类似地,定义 glDrawElement API 的数据集:如何做...

  4. InitModel 中,编译并链接着色器。在成功编译后,查询 ModelViewProjectionMatrixVertexPositionVertexColor 并分别将它们存储到 MVPattribVertexattribColor 中。启用顶点和颜色通用属性:

       void Cube::InitModel(){
       . . . . . // Load shaders
       glUseProgram( program->ProgramID );
    
       MVP = ProgramManagerObj->ProgramGetUniformLocation
                    (program, (char*)"ModelViewProjectionMatrix");
       attribVertex=ProgramManagerObj->ProgramGetVertexAttribLocation 
                    (program, (char*)"VertexPosition");
       attribColor = ProgramManagerObj->ProgramGetVertexAttribLocation
                    (program, (char*)"VertexColor");
       // Enable Vertex atrb
    glEnableVertexAttribArray(attribVertex);
       // Enable Color atrb
    glEnableVertexAttribArray(attribColor);
    }
    
  5. 在渲染函数内部,实现以下代码以演示这两个 API 的实际操作:

         glUseProgram( program->ProgramID );
         TransformObj->TransformRotate(k++, 1.0, 1.0, 1.0);
         glUniformMatrix4fv( MVP, 1, GL_FALSE,(float*)TransformObj->
    
          if ( useDrawElementAPI ){ //Toggle the flag by tap event
    glVertexAttribPointer(attribColor, 3, GL_FLOAT, GL_FALSE, 0, cubeColors);
    glVertexAttribPointer(attribVertex, 3, GL_FLOAT, GL_FALSE, 0, cubeVerts);
    glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, cubeIndices);
          }
          else{
             glVertexAttribPointer(attribColor, 3, GL_FLOAT, 
             GL_FALSE, 0, colorBufferData);
             glVertexAttribPointer (attribVertex, 3, GL_FLOAT, 
             GL_FALSE, 0, vertexBuffer);
             glDrawArrays(GL_TRIANGLES, 0, 36);
          }
    

工作原理...

glDrawArray 渲染 API 使用顶点属性,如顶点坐标、颜色信息和纹理坐标,以连续数据数组的形式发送到顶点着色器,其中数据读取不能跳过或跳跃。信息高度冗余,因为相同的顶点在不同的面之间重复写入。在这个菜谱中,vertexBuffercolorBufferData 存储顶点坐标和颜色信息。这些信息通过 attribVertexattribColor 发送到顶点着色器。最后,使用指定原始类型和需要渲染的顶点索引(起始和结束索引)的参数调用 glDrawArray

相比之下,glDrawElement 使用 cubeVertcubeColors,它们包含非冗余的顶点和颜色信息。它使用一个包含顶点信息索引的额外数组。使用这个数组,通过在顶点数组中跳跃来渲染原始数据。与 glDrawArray 不同,后者在连续的顶点数据集上工作,glDrawElement 可以使用最后提供的索引信息从一个顶点跳到另一个顶点。

更多...

在 OpenGL ES 3.0 中,多边形作为一组三角形绘制。每个三角形都有两个面:一个前表面和一个后表面。例如,以下图像表示由顶点 v0、v1、v2 和 v3 组成的正方形几何形状。它由两个三角形组成。顶点绕序(顺时针或逆时针)由 OpenGL ES 3.0 用于确定三角形是前向还是后向。在这种情况下,顶点是逆时针绕序的。默认情况下,OpenGL ES 3.0 将逆时针绕序视为前向。可以通过将 glFrontFace(顺时针)设置为 GL_CWGL_CCW(顺时针)来更改此约定。

顺时针方向始终从用户的可视化点视图指定。OpenGL ES 管道负责此顺时针方向,并从摄像机的视角正确显示它们。例如,当我们指定一个立方体几何体的顶点时,它应该按照默认约定逆时针顺序排列。然而,我们知道,从摄像机的视角看,平行于彼此的面在摄像机视角下具有相反的顺时针方向,如下面的图所示。OpenGL ES 会自动从摄像机的视角生成正确的顺时针方向。

前面和后面面用于几何剔除。有关几何剔除和前后面定义的更多信息,请参阅本章后面的 OpenGL ES 3.0 中的剔除 菜谱。

还有更多...

参见

  • OpenGL ES 3.0 中的绘图 API

  • OpenGL ES 3.0 中的剔除

使用顶点缓冲对象进行高效渲染

顶点信息包括几何坐标、颜色信息、纹理坐标和法向量。这些信息以数组的形式存储,并且始终位于设备的本地内存(RAM,CPU 可访问)中。每当执行渲染命令时,这些信息就会从本地内存复制到 GPU。这些顶点信息通过数据总线发送,其速度比 GPU 的处理速度慢。此外,本地内存上的延迟时间也会增加一点延迟。

VBO 是渲染 3D 对象的一种更快的方式。VBO 充分利用 图形处理器单元 (GPU),并将几何数据存储在 GPU 的内存中,而不是存储在本地 RAM 内存中。这有助于 OpenGL ES 避免每次绘制调用时从本地内存向 GPU 不断发送数据。

VBO 的实现可以分为四个步骤:

  1. 使用 glGenBuffers() 创建一个新的缓冲对象。

  2. 使用 glBindBuffer() 将此缓冲对象绑定到管道。

  3. 使用 glBufferData() 分配内存以存储数据。

  4. 使用 glBufferSubData() 将数据存储/修改到分配的缓冲对象的部分。

如何操作...

按照以下分步流程来实施 VBO 菜谱:

  1. 首先,使用 glGenBuffers API 创建一个顶点缓冲对象。此 API 生成 n 个顶点缓冲对象,其中每个顶点缓冲对象都由此 API 返回的唯一名称或句柄识别。此句柄是一个无符号 int ID,用于对 VBO 执行各种操作。

    • 语法:

      void glGenBuffers(GLsizei n, GLuint* buffers);
      
      变量 描述
      N 这是指需要生成的缓冲对象名称的数量
      buffers 这指定了一个包含缓冲对象的数组,在成功创建后
  2. 使用 glBindBuffer API 将创建的顶点缓冲对象 ID 绑定到底层管道。

    • 语法:

      void glBindBuffer(GLenum target, GLuint buffer);
      
      变量 描述
      target 这指定了需要绑定缓冲区对象名称的符号常量目标。它可以接受GL_ARRAY_BUFFERGL_ELEMENT_ARRAY_BUFFERGL_UNIFORM_BUFFERGL_TRANSFORM_FEEDBACK_BUFFER等。
      buffers 这是使用glGenBuffer创建的缓冲区对象的名称。
  3. 通过指定几何数组(如顶点、颜色、法线等)的大小,使用glBufferData分配和初始化内存。

    • 语法:

      void glBufferData(GLenum target, GLsizeiptr size, const GLvoid * data, GLenum usage);
      
      变量 描述
      target 此参数类似于前面描述的glBindBuffer中定义的内容。
      size 需要分配的缓冲区大小(以字节为单位)。
      data 这是一个指向包含几何信息的数组数据的指针。如果这是NULL,则不会复制任何数据。可以使用glBufferSubData API 稍后复制数据。
      usage 这是预期用于数据存储的模式类型。
  4. usage参数为 OpenGL ES 系统提供有关数据模式的提示,以便在存储或访问数据时能够智能且高效地处理。此参数可以接受以下类型之一:

    类型 含义
    GL_STREAM_DRAW 此类型的顶点缓冲区数据仅渲染少量次数,然后被丢弃
    GL_STATIC_DRAW 这是一种缓冲区数据类型,它被渲染多次,其内容永远不会改变
    GL_DYNAMIC_DRAW 此类型的缓冲区数据被渲染多次,其内容在渲染过程中会发生变化
  5. glBufferData为当前绑定的目标创建具有所需大小的缓冲区数据存储。如果数据参数使用NULL初始化,则缓冲区保持未初始化状态。此 VBO 可以使用glBufferSubData API 稍后初始化。

    • 语法:

      void glBufferSubData(GLenum target, GLintptr offset,GLsizeiptr size, const GLvoid * data);
      
      变量 描述
      target 此参数类似于前面描述的glBindBuffer中定义的内容
      offset 这是缓冲区存储上的索引,指定从哪里开始写入数据
      size 这是需要填充到缓冲区存储中的数据大小(以字节为单位),从偏移位置开始
      data 这是一个指向将被复制到数据存储中的新数据的指针
  6. 以下程序实现了前面讨论的所有 API 的 VBO:

    float size = 24*sizeof(float);
    glGenBuffers(1, &vId);
    
    glBindBuffer(GL_ARRAY_BUFFER, vId );;
    glBufferData(GL_ARRAY_BUFFER,size+size,0,GL_STATIC_DRAW);;
    glBufferSubData(GL_ARRAY_BUFFER, 0, size, cubeVerts);
    glBufferSubData(GL_ARRAY_BUFFER, size,size,cubeColors);
    
    unsigned short indexSize = sizeof( unsigned short )*36;
    glGenBuffers(1, &iId);
    glBindBuffer(GL_ARRAY_BUFFER, iId);
    glBufferData(GL_ARRAY_BUFFER, indexSize,0,GL_STATIC_DRAW);
    glBufferSubData(GL_ARRAY_BUFFER,0,indexSize,cubeIndices);
    /* Once the VBO created and used, reset the array and element buffer array to its original state after use, this is done by binding 0 to array and element buffer*/ 
    glBindBuffer( GL_ARRAY_BUFFER, 0 );
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
    
  7. 最后,通过绑定 VBO 并指定缓冲对象中的偏移量来表示通用属性数据,渲染将按如下方式执行:

    // Specify VBO-ID for send attribute data 
    glBindBuffer( GL_ARRAY_BUFFER, vId );
    glVertexAttribPointer
       (attribVertex, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);glVertexAttribPointer
       (attribColor, 3, GL_FLOAT, GL_FALSE, 0,(void*)size);
    
    // Specify VBO for element index array 
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, iId );
    glDrawElements(GL_TRIANGLES,36,GL_UNSIGNED_SHORT,(void*)0);
    glBindBuffer( GL_ARRAY_BUFFER, 0 );
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
    

它是如何工作的...

glGenBuffers API 创建由第一个参数n指定的多个顶点缓冲对象。如果顶点缓冲对象成功创建,则返回 VBO ID(句柄)数组。

一旦创建了 VBO(顶点缓冲对象),它们需要通过glBindBuffer API 绑定到目标。基本上,目标告诉 VBO 它可以存储哪种类型的顶点数据。这些数据可以是顶点数组或索引数组数据。顶点数组数据包含顶点信息,如位置、颜色、纹理坐标等。然而,索引数组包含顶点索引的顺序信息。因此,目标可以指定为GL_ARRAY_BUFFERGL_ELEMENT_ARRAY_BUFFER

使用glBufferData指定填充到已绑定顶点缓冲对象中的数据大小。我们还需要指定 VBO 将要存储的数据的性质。最后的步骤是将数据填充到缓冲对象中。我们可以使用glBufferSubData来填充顶点数据。VBO 允许我们在同一个缓冲对象中指定多个数组。我们可以依次使用偏移量和大小。确保在用glBindBuffer API 渲染之前,将缓冲对象绑定到当前渲染状态。根据程序要求,可以使用glDeleteBuffers删除 VBO。

注意

如果应用程序使用多个 VBO,那么在模型渲染后将其绑定到0是明智的。这样,原始状态得以保留。

使用模型、视图和投影类比进行变换

在计算机 3D 图形中定义渲染场景,模型、视图和投影是最干净利落的方法。它将场景分解为这三个逻辑概念,帮助我们清晰地可视化场景,在它以纸张或程序形式出现之前。可以说,这是一种模块化的场景可视化方法。

对象:一个对象是由 3D 空间中的一组顶点定义的。每个对象都有自己的原点。例如,一个立方体相对于中心原点包含八个顶点。用于定义对象的顶点称为对象坐标:

使用模型、视图和投影类比进行变换

模型:在 3D 图形中建模是一个变换过程,其中对象被移动到 3D 空间中的任意位置。这个 3D 空间被称为世界空间(也称为模型空间)。例如,我们可以使用我们立方体对象的几个实例,并将它们放置在 3D 空间中,以便它们形成英文字母T

注意

建模可以通过一个 4x4 的矩阵实现,称为模型矩阵。从程序的角度来看,一个单位矩阵,它与变换矩阵相乘,包含缩放、平移和旋转信息。结果是模型矩阵。

观察:用更简单的话来说,我们可以这样说,视图是在三维空间中的一个位置,从该位置需要观察模型。例如,在工程制图中,有三种类型的视图:俯视图、正视图和侧视图。这些是通过在xyz轴上移动相机并朝向观察对象的起点来产生的。观察是一个变换,它应用于世界坐标以产生眼睛坐标。

使用模型、视图和投影类比进行变换

注意

模型-视图类比:我们之前讨论的模型和视图概念是完全可互换的。这意味着我们可以使用模型变换来完成所有视图变换,反之亦然。例如,我们可以通过靠近观察对象或将其放置在观察位置附近来调整对象的缩放。同样,也可以对这个对象执行平移和旋转操作。因此,许多书籍将其表示为模型-视图方法,所以不要对这个术语感到困惑。从数学上讲,模型视图只是通过乘以视图矩阵和模型矩阵而得到的另一个 4x4 矩阵。

投影:投影变换是将场景限制在以截锥体或立方体形式存在的裁剪区域内的过程。这两种形式都有六个裁剪平面,有助于通过裁剪位于这些裁剪平面之外的对象来限制对象。这一阶段通过只考虑截锥体框内的有限对象集来帮助图形系统提高性能。以下图显示了截锥裁剪平面的作用。投影系统上眼睛坐标的结果是裁剪坐标:

使用模型、视图和投影类比进行变换

归一化视图:裁剪坐标用于创建归一化设备坐标,通过除以W来缩小裁剪视图到单位范围,其中 W 是用于创建齐次坐标的常数。

视口变换:这是最终的变换,其中归一化设备坐标被转换为屏幕坐标系(即窗口坐标):

使用模型、视图和投影类比进行变换

前面的图显示了 3D 图形中的顶点处理过程,它从对象坐标系变换到窗口坐标系中物理屏幕上的显示。

准备工作

模型-视图-投影纯粹是一个数学变换概念。这不是 OpenGL ES 3.0 的一部分;完全由最终用户根据自己的方式实现这些变换。本书通过一个名为glm的开源maths库来实现变换,并使用该库的 0.9.4 版本。

注意

OpenGL 数学GLM)是一个基于OpenGL 着色语言GLSL)规范的仅头文件 C++数学库,用于图形软件。您可以从glm.g-truc.net下载此库。

GLM 库的基于变换的功能在 GLPI 框架中封装在一个称为Transform的高级类中。

变换概述

变换是将一个坐标空间转换为另一个坐标空间的过程,例如平移、旋转和缩放。有两种类型的变换:

  • 几何变换:这指定了对象相对于坐标系进行变换的时间。

  • 坐标变换:这指定了坐标系进行变换的时间,而对象保持静止。

在计算机中,这些变换以 4x4 变换矩阵的形式存储。用于 3D 系统的变换矩阵在连续的内存位置中包含 16 个元素。在内存中,多维数组可以有两种表示方式。

  • 行主序:内存位置中的元素按行存储

  • 列主序:内存位置中的元素按列存储准备中

行主序RM)和列主序CM)的矩阵逻辑表示:

Offset 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
RM e1 e5 e9 e13 e2 e6 e10 e14 e3 e7 e11 e15 e4 e8 e12 e16
CM e1 e2 e3 e4 e5 e6 e7 e8 e9 e10 e11 e12 e13 e14 e15 e16

为了修复 OpenGL ES 的管线,矩阵使用的约定是列主序。程序员必须遵守这一约定。然而,在可编程管线中,没有限制使用行主序或列主序约定,因为所有矩阵都由程序员自己管理。坚持使用列矩阵表示法作为约定以避免任何混淆是明智的。

矩阵形式的顶点表示:三维空间中的顶点由三个坐标(x、y 和 z)表示。然而,在现实中,它由四个元组(x、y、z 和 w)表示,而不是三个。第四个元组称为齐次坐标。在 OpenGL ES 中,所有三维坐标和向量都使用齐次坐标。

齐次坐标:在齐次坐标中,一组坐标可以用不同类型的坐标表示。例如,对于 1、2 和 3,不同的齐次表示可以是 5、10、15 和 5 或 4、8、12 和 4,因为它们可以简化为一般形式:

(a, b , c, w) => (a/w, b/w, c/w, w/w) => (a/w, b/w, c/w, 1)

因此,前面的两个坐标可以推断为 5/5、10/5、15/5 和 5/5 或 4/4、8/4、12/4 和 4/4。这在逻辑上等于 1、2、3 和 1。

在固定/可编程管道中的透视除法阶段,使用裁剪坐标的w分量来规范化它们。为了平移的目的,始终使用w分量为 1。因此,任何 3D 顶点(x,y 和 z)表示为(x,y,z,1)。

如何操作...

按照以下步骤,在数学变换操作的帮助下实现模型-视图-投影范式:

注意

关于 3D 变换的内部信息,您可以参考此配方中的更多内容…部分。本节涵盖了变换操作,如平移、缩放和旋转。

  1. 当场景渲染到存储在模型、视图和投影矩阵中的模型-视图-投影信息时。为了使用这些矩阵中的任何一个,请使用TransformSetMatrixMode函数从Transform类。此类允许您根据应用程序的要求设置相关矩阵。此 API 接受一个名为 mode 的参数,它告诉 GLPI 框架当前正在执行哪种操作;此参数的接受值将是MODEL_MATRIX(建模)、VIEW_MATRIX(查看)或PROJECTION_MATRIX(投影)。

    • 语法

      void Transform::TransformSetMatrixMode( unsigned int mode )
      

    注意

    在执行绘图命令之前,您可以以任意顺序操纵这些矩阵。本书遵循先处理投影矩阵的惯例,然后是视图和模型矩阵操作。

    投影信息在Renderer::setupProjection函数中计算。为此,首先需要激活投影矩阵。有关投影系统和该函数下的工作逻辑的更多信息,请参阅理解 GLPI 中的投影系统配方。此函数负责定义投影视锥体的裁剪平面;任何保持在视锥体框内的对象都将可见:

    void Renderer::setUpProjection(){
       Transform*   TransformObj = &RenderMemData.TransformObj;
    
    //Set up the PROJECTION matrix.
       TransformObj->TransformSetMatrixMode( PROJECTION_MATRIX );
       TransformObj->TransformLoadIdentity();
    // Many lines skipped. 
    // For more information refer to next recipe
    }
    

    注意

    当切换当前矩阵时,它可能包含一些垃圾或旧变换值。可以通过将矩阵设置为单位矩阵来清理这些值。这可以通过使用TransformLoadIdentity()函数从Transform类来完成。

  2. Renderer::setupView函数中激活视图矩阵。此函数负责视图信息。例如,在此配方中,观察者距离原点(0.0f0.0f0.0f)为-2-15单位:

    void Renderer::setUpView(){
        Transform*  TransformObj = &RenderMemData.TransformObj;
    //Set up the VIEW matrix.
        TransformObj->TransformSetMatrixMode( VIEW_MATRIX );
        TransformObj->TransformLoadIdentity();
    
    // The viewer is -2 and -15 units away on y and z axis 
        TransformObj->TransformTranslate(0, -2, -15);
    }
    
  3. 现在,我们已经准备好进行渲染;在建模变换被保留的情况下进行渲染。在Renderer::setupModel中激活模型矩阵。从现在起,任何建模变换都将始终应用于模型矩阵,因为它是最最近激活的矩阵:

    void Renderer::setUpModel(){
        Transform*    = &R TransformObj enderMemData.TransformObj;
    //Set up the MODEL matrix.
        TransformObj->TransformSetMatrixMode( MODEL_MATRIX );
        TransformObj->TransformLoadIdentity();
    }
    
  4. 渲染绘图对象;应用于这些对象的变换将影响模型矩阵。

    1. 创建八个简单的 3D 立方体,例如 C1、C2、C3、C4、C5、C6、C7 和 C8,每个立方体的尺寸为 2 x 2 x 2 逻辑单位(长度 x 宽度 x 高度)。请注意,OpenGL ES 中的单位是逻辑单位。

    2. 保持 C1 在原点。将 C2 沿正y轴移动 2 个单位,C3 沿正y轴移动 4 个单位,C4 沿正y轴移动 6 个单位。

    3. 将 C5 沿正y轴移动 6 个单位,沿负x轴移动 2 个单位。将 C6 沿正y轴移动 6 个单位,沿正x轴移动 2 个单位。

    4. 将 C7 沿正y轴移动 6 个单位,沿负x轴移动 6 个单位。

    5. 将 C8 沿正y轴移动 6 个单位,沿正x轴移动 6 个单位:

      void Cube::Render(){ 
      
         static float k = 0;
         Transform* TransformObj = MapRenderHandler->
      RendererTransform();
          // Rotate the whole Geometry along Y-Axis
          TransformObj->TransformRotate(k++, 0, 1, 0);
      
          // Render C1 Box at Vertical 2 Units Up
          TransformObj->TransformPushMatrix();
          TransformObj->TransformTranslate( 0, 2, 0);
          RenderCubeVBO();
          TransformObj->TransformPopMatrix();
      
          // Render C2 Box at Vertical 4 Units Up
          TransformObj->TransformPushMatrix();
          TransformObj->TransformTranslate( 0, 4, 0);
          RenderCubeVBO();
          TransformObj->TransformPopMatrix();
      
          // Similarly, Render C3 to C8 boxes
      }
      

    如何操作...

它是如何工作的...

场景是模型、视图和投影的组合。每个都有特定的职责。模型存储应用于渲染项(如旋转或平移)的建模变换。模型矩阵(MODEL_MATRIX)在setupModel函数中被激活。从现在开始,任何类型的模型渲染变换都应用于模型矩阵。例如,在当前的配方中,对简单的 3D 立方体应用了各种变换(如旋转和平移),以将其渲染到不同的空间位置。当立方体几何体的对象坐标应用于模型变换时,它产生世界坐标。可以使用TransformSetMatrixMode选择所需的矩阵(模型、视图和投影)。

视觉变换是场景构建的中间阶段,负责在 3D 空间中设置视图或相机。换句话说,它告诉你一个场景在 3D 空间中是如何被观察的。在当前的配方中,场景是从距离原点 15 个单位的位置在z轴上和距离y-2个单位的位置观察的。视图变换在setupView函数中执行,并影响视图矩阵(VIEW_MATRIX)。视图矩阵应用于世界坐标以产生视点坐标。

投影系统定义了一个视锥体并跟踪所有落在其中的对象。只有这些对象将被渲染。视锥体或截锥体由六个裁剪平面组成。这些平面在setupProject函数中构建。在这里,对投影矩阵(PROJECTION_MATRIX)进行变换。这个投影矩阵使用视点坐标并将它们转换为裁剪坐标。

以下图表显示了用于变换目的的顶点生命周期的完整过程:

它是如何工作的...

还有更多...

变换操作:主要使用三种类型的变换。这些变换在 OpenGL ES 约定下以数学列主矩阵形式存储。这些变换由一个 4x4 矩阵表示。

  • 平移: 这种平移操作占据了 4x4 变换矩阵或行列格式中的第 13、14 和第 15 个位置,即 [0, 3]、[1, 3] 和 [2, 3]。具有 T 平移(Tx、Ty 和 Tz)的 P 顶点(Vx、Vy 和 Vz)可以表示为一般形式:P' = T.P

    注意

    Transform 类提供了 TransformTranslate API 用于平移操作。

    • 语法:

      void TransformTranslate(float Tx, float Ty, float Tz);
      
      变量 描述
      Tx 这指定了沿 x 轴的逻辑单位中的平移距离
      Ty 这指定了沿 y 轴的逻辑单位中的平移距离
      Tz 这指定了沿 z 轴的逻辑单位中的平移距离
  • 缩放: 矩阵中沿 xyz 分量的缩放分量使用对角线元素表示。P 顶点(Vx、Vy 和 Vz)通过 S 因子(Sx、Sy 和 Sz)缩放可以概括,如下图中所示:更多内容...

    注意

    Transform 类提供了 TransformScale API 用于缩放操作。

    • 语法:

      void TransformScale(float Sx, float Sy, float Sz);
      
      Sx 这表示沿 x 轴缩放
      Sy 这表示沿 y 轴缩放
      Sz 这表示沿 z 轴缩放
  • 旋转: 沿 xyz 轴通过零度进行的这种变换可以用矩阵形式表示,如下图中所示:

    假设,cos (θ) = C 和 sin (θ) = S。

    更多内容...

    注意

    Transform 类提供了 TransformRotate API 用于旋转操作。

    • 语法:

      void TransformRotate(float angle,float Rx,float Ry,float Rz);
      
      变量 描述
      angle 这表示旋转的角度
      Rx 这表示沿 x 轴旋转的角度
      Ry 这表示沿 y 轴旋转的角度
      Rz 这表示沿 z 轴旋转的角度

参见

  • 理解 GLPI 中的投影系统 菜谱在 附录,OpenGL ES 3.0 补充信息

理解 GLPI 中的投影系统

在这个菜谱中,我们将了解在 3D 图形中非常常用的两种投影系统:透视投影系统和正交投影系统:

  • 透视投影系统: 这种投影系统创建的视图类似于我们的眼睛观察物体时的视角。这意味着,相对于远离我们的物体,靠近我们的物体会显得更大。这种投影系统使用一个截锥剪切区域,如图中左侧所示。

    在 GLPI 框架中,可以使用 Transform::TransformSetPerspective() 函数来创建透视视图。

    • 语法:

      void Transform::TransformSetPerspective( float fovy, float aspect_ratio, float clip_start, float clip_end, float screen_orientation )
      
      变量 描述
      fov 这定义了视场
      aspect_ratio 这是渲染的宽高比(宽度/高度)
      clip_startclip_end 这些是近裁剪平面和远裁剪平面
      screen_orientation 这些是场景渲染的垂直或水平方向
  • 正交投影系统:这种投影系统特别用于工程应用,其中近处和远处的物体总是以相同的尺寸出现。因此,正交投影系统保留了几何尺寸。此投影系统使用如图所示的立方体形状的裁剪区域。

    GLPI 框架通过 TransformOrtho() 函数提供正交投影。任何在此裁剪平面范围内渲染的模型都将显示在屏幕上,其余的将被裁剪掉。

    • 语法:

      void Transform::TransformOrtho( float left, float right, float bottom,float top,float clip_start,float clip_end )
      
      变量 描述
      leftright 这些是裁剪平面的左右范围
      bottomtop 这些是裁剪平面的底部和顶部范围
      clip_startclip_end 这些是近裁剪平面和远裁剪平面

下图显示,靠近摄像机的立方体与其他放置在较远距离的立方体相比显得更大。在右侧,显示了由此产生的投影。这种投影清楚地表明,无论立方体与摄像机的距离如何,它们都以相同的尺寸出现:

理解 GLPI 中的投影系统

如何做...

实现透视和正交投影系统的步骤如下:

  1. 为了将特定的投影应用到场景中,我们将在 Renderer 类中使用 setup Projection() 函数。这将是在渲染每一帧之前被调用的第一个函数。使用 TransformSetMatrixMode (PROJECTION_MATRIX) 函数将当前矩阵设置为投影矩阵非常重要。这将确保投影矩阵正在使用中。现在,可以使用 TransformOrtho()TransformSetPerspective() 函数应用正交或透视投影系统。

  2. 在设置投影系统后,将当前矩阵设置为 VIEW_MATRIX 以在 3D 空间中设置摄像机位置非常重要。最后,在渲染对象的模型之前,使用 TransformSetMatrixMode 将当前矩阵设置为 MODEL_MATRIX

    注意

    当切换当前矩阵时,它可能包含一些垃圾或旧变换值。这些值可以通过将矩阵设置为单位矩阵来清理。这可以通过使用 TransformLoadIdentity() 函数来完成。

    void Renderer::setUpProjection(){
     RenderMemData.isPerspective   = true;
     float span                    = 10.0;
    
     //Set up the projection matrix.
     TransformObj->TransformSetMatrixMode( PROJECTION_MATRIX );
     TransformObj->TransformLoadIdentity();
    
     //Set up the Perspective/Orthographic projection.
     if (RenderMemData.isPerspective){
      TransformObj->TransformSetPerspective(60.0f, 1, 1.0, 100,0);
     }
     else{
      TransformObj->TransformOrtho( -span,span,-span,span,span,span);
     }
    
     // Set the camera 10 units away
     TransformObj->TransformSetMatrixMode( VIEW_MATRIX );
     TransformObj->TransformLoadIdentity();
     TransformObj->TransformTranslate(0.0f, 0.0f, -10.0f);
    
     // Make the scene ready to render models
     TransformObj->TransformSetMatrixMode( MODEL_MATRIX );
     TransformObj->TransformLoadIdentity();
    }
    

它是如何工作的...

此配方在透视和正交投影系统中渲染几个线性排列的立方体。可以通过单次点击屏幕来切换投影系统。

此配方首先使用投影矩阵和投影系统定义一个三维空间体积(视锥体或长方体)。这个三维空间体积由六个平面组成,负责显示属于此体积内的对象内容。此三维体积之外的对象将被裁剪掉。视图矩阵负责在三维空间中设置眼睛或摄像机。在我们的配方中,摄像机距离原点 10 个单位。最后,设置模型矩阵以在三维空间中渲染对象。

OpenGL ES 3.0 中的裁剪

裁剪是三维图形中的重要技术。它用于丢弃用户不可见的面。在一个封闭的几何体中,指向摄像机的面会隐藏其后的面,部分或全部。这些面可以通过裁剪技术轻松避免。这是在 OpenGL ES 图形中加快性能的简单方法。有两种类型的面:

  • 前表面:在一个封闭的三维物体中,指向外部的面被认为是前表面

  • 背面:在一个封闭的三维物体中,指向这些面的面被认为是背面

如何操作...

在 OpenGL ES 3.0 中,可以使用 glenable API 并将 GL_CULL_FACE 作为状态标志来启用裁剪。默认情况下,OpenGL ES 3.0 裁剪背面。这可以通过 glCullFace API 来更改。轻触屏幕以在前后裁剪模式之间切换。当设置背面裁剪时,此配方将显示立方体的外部面;否则,当启用正面裁剪时,它将显示内部面:

语法:

void glCullFace(GLenum mode);
变量 描述
mode 这是模式参数,接受符号常量 GL_FRONT(丢弃前表面),GL_BACK(丢弃后表面),以及 GL_FRONT_AND_BACK(不绘制任何面)

根据应用需求,裁剪可以在图形引擎初始化期间或渲染原语之前应用:

void Cube::Render(){
  glEnable( GL_CULL_FACE  ); // Enable the culling
  if (toogle){
    glCullFace( GL_FRONT ); // Culls geometries front face
  }
  else{
    glCullFace ( GL_BACK ); // Culls geometries back face
  }
 . . . . . . .}

下图显示了背面裁剪和正面裁剪:

如何操作...

它是如何工作的...

与人眼不同,计算机通过顶点环绕的顺序来识别物体的前表面和后表面。这些顶点可以有两种排列方式:顺时针和逆时针。在下图中,矩形由两个三角形组成,其顶点按逆时针方向指定:

它是如何工作的...

当使用glEnable API 启用剔除时,数组数据中顶点的排列顺序定义了面中顶点的方向。这种方向在定义前后面时起着重要作用。使用glCullFaces API,OpenGL ES 知道哪些面可以被丢弃。所有满足剔除规则的面都被丢弃。按照惯例,默认的排列方向是逆时针。我们可以通过使用glFrontFace API 并指定参数为GL_CCW(逆时针)或GL_CW(顺时针)来改变这种方向。

OpenGL ES 3.0 中的深度测试

深度测试使我们能够按照从观察者到物体的距离顺序渲染物体。如果没有深度测试,物体的渲染类似于设备屏幕上的画家算法。它将根据先来先画的原则渲染物体。例如,如果有三个不同颜色的三角形按照红色、绿色和蓝色的顺序渲染,那么根据画家算法,它首先绘制红色,然后是绿色,最后是蓝色。结果将在屏幕上以相反的顺序显示,蓝色在最上面,绿色在中间,红色在最下面。这种渲染方式没有考虑三角形物体与摄像机的距离。在现实生活中,靠近摄像机的物体将隐藏其后面的物体。为了处理这种实时场景,我们使用深度测试。它根据物体与摄像机的距离深度来渲染物体,而不是使用绘制顺序(画家算法)。

在深度测试中,每个片段的深度被存储在一个称为深度缓冲区的特殊缓冲区中。与存储颜色信息的颜色缓冲区不同,深度缓冲区存储了从摄像机视图到原始片段的深度信息。深度缓冲区的维度通常与颜色缓冲区相同。深度缓冲区以 16 位、24 位或 32 位浮点值的形式存储深度信息。

除了以正确的深度顺序渲染物体之外,深度缓冲区还有许多其他应用。深度缓冲区最常见的一种用途是使用阴影映射技术产生实时阴影。有关更多信息,请参阅第十一章中关于使用阴影映射创建阴影的配方,抗锯齿技术

OpenGL ES 3.0 中的深度测试

准备工作

对于这个配方,我们将渲染三个物体,并使用切换方式(启用/禁用)应用深度测试,以查看深度测试在渲染场景中的效果。为了切换行为,在屏幕上轻触一次。

如何操作...

在这个菜谱中,三角形对象位于中心,两个立方体围绕三角形对象旋转。在 OpenGL ES 3.0 中,默认禁用深度测试。需要使用带有GL_DEPTH_TEST符号常量的glEnable API 来启用它。一旦启用深度测试,OpenGL ES 就会在幕后创建一个深度缓冲区。这个深度缓冲区在渲染场景时用于预测模型对象出现的正确顺序。确保在用glClear(GL_DEPTH_BUFFER_BIT)渲染每一帧之前清除深度缓冲区:

void Cube::Render(){
   static float k,j,l = 0;
  if (toogle){
      glEnable( GL_DEPTH_TEST );
    }
    else{
        glDisable( GL_DEPTH_TEST );
    }

    // Rotate Both Cube Models
    TransformObj->TransformPushMatrix();
    TransformObj->TransformRotate(k=k+1, 0, 1, 0);

    // Render and Rotate Cube model
    TransformObj->TransformPushMatrix();
        TransformObj->TransformTranslate( 0, 0, -3);
        TransformObj->TransformRotate(j=j+4, 0, 1, 0);
        RenderCubeVBO();
    TransformObj->TransformPopMatrix();

    // Render and Rotate Second Cube model
    TransformObj->TransformPushMatrix();
        TransformObj->TransformTranslate( 0, 0, 3);
        TransformObj->TransformRotate(l=l-2, 0, 1, 0);
        RenderCubeVBO();
    TransformObj->TransformPopMatrix();
    TransformObj->TransformPopMatrix();
}

工作原理...

深度缓冲区是一种包含窗口屏幕上所有片段深度信息的缓冲区。深度缓冲区包含介于 0.0 和 1.0 之间的z(深度)值。深度缓冲区将其内容与从摄像机视图看到的场景中所有对象的z值进行比较。当调用glClear(GL_DEPTH_BUFFER_BIT)函数时,它将所有具有深度值的片段的z值设置为 1.0。像素值为 0.0 的深度缓冲区被认为是离摄像机位置最近的(在近平面),而像素值为 1.0 的片段被认为是距离最远的(在远平面)。当渲染对象时,相关的片段深度将与深度缓冲区中已存在的相应值进行比较。这种比较基于glDepthFunction深度 API。

注意

如果禁用深度测试或不存在深度缓冲区,则深度测试总是通过。

深度值可以通过glDepthFunction API 来控制。此 API 指定传入的深度值将如何与深度缓冲区中已存在的值进行比较。

语法:

Void glDepthFunc(GLenum func);
变量 描述
func 这表示绘制像素的条件

以下表格指定了可以用于通过或失败深度测试的条件检查。以下是符号常量的定义含义:

符号常量 含义
GL_NEVER 从不通过
GL_LESS 如果传入的深度值小于存储的值,则通过
GL_EQUAL 如果传入的深度值等于存储的值,则通过
GL_LEQUAL 如果传入的深度值小于或等于存储的值,则通过
GL_GREATER 如果传入的深度值大于存储的值,则通过
GL_NOTEQUAL 如果传入的深度值不等于存储的值,则通过
GL_GEQUAL 如果传入的深度值大于或等于存储的值,则通过
GL_ALWAYS 总是通过

还有更多...

视图空间中对象的z值可以是视锥体近平面和远平面之间的任何值。因此,我们需要一些转换公式来生成范围在 0.0 和 1.0 之间的z值。以下图像显示了使用线性变换计算视锥体内对象深度的数学公式。

在现实中,用于计算z值的线性变换几乎不被使用,因为它在所有深度上提供的是恒定的精度。然而,对于靠近观察者眼睛的物品,我们需要更高的精度,而对于远离观察者的物品,则需要较低的精度。为此,使用了一个与1/z成比例的非线性函数来计算深度。显然,在第二幅图像中,非线性函数在范围 [1, 20] 内的对象在近平面产生了巨大的精度。相比之下,远处的物体精度较低,这满足了理想的要求:

还有更多...

第三章:OpenGL ES 3.0 的新特性

在本章中,我们将涵盖以下配方:

  • 使用限定符管理变量属性

  • 分组统一变量和创建缓冲区对象

  • 使用顶点数组对象管理 VBO

  • 使用映射读写缓冲区对象

  • 使用几何实例化渲染多个对象

  • 使用原始重启渲染多个原语

简介

OpenGL ES 3.0 于 2012 年 8 月公开发布。它将移动 3D 图形提升到了新的水平。这次发布专注于提供增强的 3D 功能,并提高了在不同移动设备、嵌入式操作系统和平台之间的可移植性。OpenGL ES 3.0 完全向后兼容 OpenGL ES 2.0。这使得应用程序能够逐步增长图形能力和视觉功能。OpenGL ES 3.0 还引入了新的GL 着色语言GLSL)3.0 版本。GLSL 用于编写着色器。新的着色语言也在许多方面扩展了功能,你将在下一节中了解到。

本章将有助于理解 OpenGL ES 3.0 和 GL 着色语言 3.0 引入的新特性。本书在所有配方中使用 OpenGL ES 3.0 与 GLSL 3.0 结合。

OpenGL ES 3.0 的新特性可以大致分为以下五个类别:

  • 几何:这些特性专注于顶点属性规范,如数据存储、数据传输、属性状态、原语组装等。它们如下所述:

    • 变换反馈:这个特性允许我们捕获顶点着色器的输出,为 GPU 提供下一帧渲染的反馈。这样,它避免了 CPU 的干预,并使渲染更高效。

    • 遮挡查询:这允许快速硬件测试以检查一个像素是否将出现在屏幕上,或者是否被另一个像素遮挡。这种检查有助于决定是否跳过某些操作,例如几何处理,因为它被遮挡。

    • 几何实例化:这允许在不需要调用多个渲染 API 的情况下高效地渲染一个对象多次。这在人群模拟、树木渲染等情况下非常有用。

    • 原始重启:这个新特性允许我们使用单个绘图 API 调用渲染多个不连续的原语。索引数组用于将多个原语(同一类型)打包在一个捆绑包中。这个数组包含多个不连续的原语,并有一个特殊的标记,帮助 GPU 一次性渲染不连续的原语。

  • 纹理:OpenGL ES 3.0 为纹理添加了许多新特性。这些特性在此描述:

    • 深度纹理和阴影比较:深度纹理允许将深度缓冲区信息存储到纹理中。这对于使用百分比最近过滤PCF)技术渲染阴影非常有帮助,其中深度信息通过渲染到纹理技术显式地从深度缓冲区存储到纹理中。随后,这些信息用于测试传入的片段,以确定它们是否是阴影的一部分。OpenGL ES 3.0 允许隐式地进行这种比较测试。

    • 无缝立方体贴图:立方体贴图渲染得到了改进,以消除图像边界边缘的伪影。现在,过滤技术考虑相邻面的纹理数据,以在面边缘产生无缝的纹理边界。您可以参考第七章中的使用无缝立方体贴图实现天空盒配方,纹理和映射技术

    • ETC2/EAC 纹理压缩格式:在 OpenGL ES 3.0 之前,OpenGL ES 没有官方支持的标准压缩格式。开发者依赖于不同供应商提供的特定压缩格式,例如 Imagination Technologies 的 PVRTC,Sony Ericsson 的Ericsson Texture CompressionETC),Qualcomm 的 ATC 等。现在,ETC2 和 EAC 纹理压缩格式在 OpenGL ES 3.0 中得到全面支持。请参考第七章中的使用 ETC2 压缩纹理进行高效渲染配方,纹理和映射技术

    • 非 2 的幂次方纹理NPOT):现在,支持具有非 2 的幂次方像素维度的纹理以全环绕模式和米级贴图。在 OpenGL ES 的早期规范中,纹理必须以 2 的幂次方(POT)维度形式存在。因此,需要外部图像工具将 NPOT 转换为 POT 格式。

    • 纹理混色:GLSL 提供了一种访问纹理组件(R、G、B 和 A)的抽象级别,无论它们在物理存储中的顺序如何。

    • 增加的 2D 纹理维度:OpenGL ES 3.0 中 2D 纹理的维度为 2048,这比 OpenGL ES 2.0 大得多。

    • 3D 纹理:OpenGL ES 3.0 支持 3D 纹理目标。3D 纹理在医学成像中得到广泛应用。

    • 2D 纹理数组:这一新特性允许我们将多个 2D 纹理以数组的形式存储。这对于动画目的非常有用。在此之前,使用纹理精灵。

  • 着色器:这些是在现代计算机图形编程中用于控制几何和像素颜色着色的特殊小程序。着色器的特性如下:

    • 程序二进制文件: 顶点和片段着色器被编译并存储在二进制格式中。在 OpenGL ES 2.0 中,这个二进制格式需要在运行时链接到程序。OpenGL ES 3.0 允许通过将此二进制存储到不要求运行时链接的离线二进制格式中来实现优化。这种优化通过避免运行时链接来帮助更快地加载应用程序。

    • 平坦/平滑插值器: 在 OpenGL ES 2.0 中,所有插值器都在原语之间执行线性插值。借助 GLSL 3.0,在 OpenGL ES 3.0 中,插值可以显式声明为具有平坦和光滑着色。

  • 缓冲区对象: 这些允许我们在 GPU 内存上存储顶点数据。新特性扩展了缓冲区对象的功能,使其更高效。以下是新特性:

    • 统一块: 这允许将相关的统一值组合成一个可管理的单个组。这增加了着色器程序的可读性。

    • 布局限定符: 在顶点和片段着色器中定义的属性可以直接绑定到用户定义的位置。这样,就不需要飞行绑定 API 调用。

    • 顶点数组对象: 此功能提供了一种高效的方法来绑定顶点数组和相应的属性。顶点数组对象VAO)用于封装 VBO。当调用 VAO API 时,它有效地切换存储在 VBO 中的状态,而不调用多个 API。这减少了顶点数组状态切换的开销。

    • 统一缓冲区对象: 此功能以高效的方式将统一块存储为缓冲区对象。此统一块对象可以随时绑定。这为一次共享多个程序中的统一数据提供了机会。此外,它允许我们一次性设置多个统一变量。

    • 子范围缓冲区映射: 与从 GPU 到 CPU 侧映射整个缓冲区不同,此机制提供了一种高效的方法来访问 GPU 内存空间中的内存内容范围。有时,目的是仅更新缓冲区的一小部分。因此,映射整个缓冲区是不高效的。在这种情况下,子范围缓冲区映射减少了从 GPU 到 CPU 再到 GPU 的序列化时间。

    • 缓冲区对象复制: 此机制将一个缓冲区对象的数据传输到另一个缓冲区对象,而不涉及 CPU。

    • 同步对象: 这提供了一种在应用程序和 GPU 之间的同步机制。这样,应用程序可以确保 GPU 侧 OpenGL ES 操作的完成。

    • 栅栏: 此功能通知 GPU 等待排队新的 OpenGL ES 操作,直到旧操作在 GPU 上完全执行。

  • 帧缓冲区: 新特性还包括与帧缓冲区离屏渲染相关的增强功能。以下是新特性:

    • 多个渲染目标(MRT):此功能允许我们同时将离屏渲染到多个颜色缓冲区或纹理。这些纹理可以用作其他着色器的输入,或者用于 3D 模型。MRTs 最常用于实现延迟着色。

    • 多采样渲染缓冲区:此功能使应用程序能够执行具有多采样抗锯齿的离屏帧缓冲区渲染。这提高了生成图像的视觉效果,并减少了在屏幕上以对角线方向绘制的线条或尖锐几何边缘中出现的锯齿状效果。

本章将重点介绍几何和缓冲区对象的新特性。随着我们进入即将到来的章节,我们还将介绍着色器、纹理和帧缓冲区的新特性。

注意

您可以在www.khronos.org/registry/gles/specs/3.0/es_spec_3.0.3.pdfwww.khronos.org/opengles/sdk/docs/man3/上探索更多关于 OpenGL ES 3.0 规范和文档。

使用限定符管理变量属性

GLSL 3.0 引入了两个新的限定符:存储和布局。让我们详细看看它们:

  • 存储限定符:这是一个特殊的关键字,用于指定全局或局部变量的存储或行为。它在着色器编程中使用。它使得应用程序和着色器之间建立通信桥梁。它还用于从一个着色器阶段向另一个阶段共享信息。例如,3D 光照技术需要物体的几何信息以便创建逼真的光照着色。这些几何信息在顶点着色器中计算,并传递给片元着色器,在那里这个输入被用来着色几何原语的部分。

    在 GL SL 3.0 中提供了六种存储限定符。它们在以下表中描述:

    限定符 含义
    const 变量的值在编译时不改变。
    in 这是来自前一阶段的复制输入变量,它与当前着色器链接。如果在函数参数中指定,这是一个输入变量。
    centroid in 这是与中心插值器链接的输入类型变量。
    out 这是来自前一阶段的复制输入变量,它与当前着色器链接。如果在函数参数中指定,这是一个输出变量。
    centroid out 这是与中心插值器链接的输出类型变量。
    uniform 变量的值在处理过程中不会改变。这些统一变量在着色器之间共享。
  • 布局限定符:这影响变量的属性,如存储、位置、内存对齐等。此限定符在声明着色器中变量位置时广泛使用。在着色器中声明的每个变量或通用属性都存储在 GPU 上分配的内存位置。此内存位置用于存储数据,作为运行时计算的结果或来自着色器前一个阶段的数据输入。与 C/C++指针不同,着色语言使用位置 ID 来访问变量。位置是变量的 ID(数值),用于将着色语言中存在的变量与应用程序程序连接起来。

准备工作

下表指定了存储和布局限定符的语法。存储限定符在变量的数据类型之前提及。最常用的限定符是 in 和 out。这些存储限定符告诉我们顶点属性是输入变量还是输出变量。

布局限定符为顶点属性分配一个 ID 或位置,以便避免运行时的绑定和查询位置。布局限定符始终在存储限定符之前提及。

限定符 语法
存储 (存储限定符) [数据类型] [变量名]
布局 layout (限定符 1,限定符 2 = value, . . .) [存储限定符]

如何操作...

着色器中的变量以位置 ID 的形式抽象化。每个变量或通用属性都通过其位置 ID 识别,并用于将数据绑定到 OpenGL ES 程序中。这些位置 ID/索引可以使用布局限定符中的location关键字定义。

在我们的第一个配方中,我们将演示存储和布局限定符的使用:

  1. 创建一个顶点着色器LayoutVertex.glsl,如下所示:

    #version 300 es
    layout(location = 0) in vec4 VertexPosition; 
    layout(location = 1) in vec4 VertexColor; 
    out vec4 Color;
    uniform mat4 MODELVIEWPROJECTIONMATRIX;
    
    // Function with two input and one output storage qualifier
    void calculatePosition(in mat4 MVP, in vec4 vp, out vec4 position){
       position = MVP * vp;
    }
    
    void main() 
    {
       vec4 position;
       calculatePosition(MODELVIEWPROJECTIONMATRIX,
                             VertexPosition, position);
       gl_Position  = position;
       Color        = VertexColor;
    }
    
  2. 创建片段着色器LayoutFragment.glsl并修改它,如下所示:

    #version 300 es
    precision mediump float;
    in vec4 Color; //in variable receive from shader
    float blendFactor = 0.8;
    layout(location = 0) out vec4 outColor; 
    // Function with input argument and output as return type
    vec4 addBlend( in vec4 colorOpaque ) 
    {
        return vec4(colorOpaque.x, colorOpaque.y, 
    colorOpaque.z, blendFactor);
    }
    
    void main() {
        outColor = addBlend( Color );
    }
    
  3. 重新使用使用顶点缓冲对象进行高效渲染配方第二章, OpenGL ES 3.0 基础,并在应用程序程序Cube.cpp中根据您的选择定义位置索引。确保在着色器程序中指定相同的索引:

    #define VERTEX_LOCATION 0
    #define COLOR_LOCATION 1
    
  4. 在构造函数中创建 VBO 和 IBO,并启用以下属性:

    glGenBuffers(1, &vId); // Create VBO and bind data
    glGenBuffers(1, &iId); // Create IBO and bind data
    
    // Enable the attribute locations
    glEnableVertexAttribArray(VERTEX_LOCATION);
    glEnableVertexAttribArray(COLOR_LOCATION);
    
  5. 将 VBO 几何数据附加到位置 ID。这将用于从应用程序发送数据到 GPU 着色器处理器。显然,使用布局限定符可以避免对顶点属性的布局查询(glGetAttribLocation):

    void Cube::RenderCube() {
       . . . . . 
       glBindBuffer( GL_ARRAY_BUFFER, vId );
       glVertexAttribPointer(VERTEX_LOCATION, 3, 
       GL_FLOAT, GL_FALSE, 0, (void*)0);
       glVertexAttribPointer(COLOR_LOCATION, 3, GL_FLOAT, 
       GL_FALSE, 0, (void*)size);
       glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, iId );
       glDrawElements(GL_TRIANGLES, 36, 
       GL_UNSIGNED_SHORT, (void*)0);
       . . . . . 
    }
    

它是如何工作的...

OpenGL ES 程序在 Cube.cpp 中定义了两个索引 ID,分别为 VERTEX_LOCATIONCOLOR_LOCATION,分别用于顶点和颜色数据。这些索引将用于在着色器程序中定义属性位置。程序员必须确保在着色器程序中用于属性的布局位置 ID 必须与在 OpenGL ES 程序中使用的 ID 相同。这可以通过使用布局限定符声明变量属性来实现。将 layout 关键字与 location 限定符结合使用,允许用户定义的位置与属性变量关联。如果某些属性变量没有通过用户定义的位置索引指定,则编译器会自动生成并分配它们。

在着色器程序中,VertexPositionVertexColor 分别被分配到与 OpenGL ES 程序中定义的相同的位置索引 01。这两个变量声明为 vec4 类型,并带有存储限定符 in。这表明这两个变量是从 OpenGL ES 程序输入到顶点着色器的。通过在 RenderCube 函数中使用 glVertexAttribPointer API 将数据附加到 VertexPositionVertexColor 的位置索引,将几何数据(顶点和颜色)发送到顶点着色器。需要注意的是,在使用 glEnableVertexAttribArray API 附加之前,必须启用通用属性变量。这个配方在 Cube 构造函数中启用它们。

当顶点着色器接收到 VertexPosition 中的顶点输入数据和统一变量 MODELVIEWPROJECTIONMATRIX 中的变换坐标时,它使用这两个变量作为 calculatePosition 函数的输入参数来计算传入顶点的变换位置。这个计算出的位置作为输出存储限定符返回到主函数中的 position 变量。calculatePosition 函数在本配方中引入,以展示在着色器程序的局部作用域中存储限定符的另一种可能用途。

Color 变量使用 VertexColor 的输入值,并将其传递到下一个阶段,其中片段着色器消耗这个值来为片段分配颜色。为了从顶点着色器将数据发送到片段着色器,两个着色器应使用相同的属性变量名称。顶点着色器的存储限定符必须定义为 out,因为它为片段着色器生成输出数据。相比之下,片段着色器必须指定为 in 存储限定符,因为它接收来自前一阶段的数据。片段着色器展示了从着色器编程函数返回值的另一种使用方式。

还有更多...

在当前教程中,你学习了如何使用布局限定符从着色器程序中绑定 OpenGL ES 中通用属性变量的位置索引。作为替代,也可以使用glBindAttribLocation API 显式绑定位置索引。

语法

void glBindAttribLocation( GLuint program, GLuint index, const GLchar *name );
变量 描述
program 这是程序对象句柄
index 这是通用顶点属性或变量的索引
name 这是顶点着色器属性变量,索引将被绑定

然而,建议鼓励使用布局限定符,因为它不会产生将位置索引附加到着色器程序的 API 调用的开销。在着色器程序中使用布局位置限定符可以避免在 OpenGL ES 程序中运行时绑定属性位置。

参见

  • 参考第一章中的使用顶点属性将数据发送到着色器教程,OpenGL ES 3.0 在 Android/iOS 上,第一章

  • 参考第二章中的使用顶点缓冲对象进行高效渲染教程,OpenGL ES 3.0 基础知识,第二章

分组统一变量和创建缓冲对象

接口块有助于将统一变量分组到一个逻辑组中。这在着色器程序中分组相关变量非常有用。接口块为一次共享多个程序中的统一数据提供了机会。这允许我们一次性设置多个统一变量,这些变量可以多次使用。

统一缓冲对象UBO)是一种用于接口块(包含统一变量)的缓冲对象,类似于 VBO、IBO 等。它在 GPU 内存中存储接口块的内容,以便在运行时快速访问数据。UBO 使用绑定点,作为统一块和统一缓冲之间的中介。在本教程中,我们将创建一个统一块并学习如何编程统一缓冲对象。

本教程演示了接口块的概念。在本教程中,我们创建了一个接口块来存储变换矩阵。此块包含三个统一变量。接口块使用 UBO 功能作为缓冲对象存储。这允许我们将接口块作为 OpenGL ES 缓冲对象存储。

准备工作

创建统一块的语法非常简单。以下表格显示了语法和实现测试用例:

语法 单个统一变量 统一块

|

uniform <block name>{
[Type] <variable name 1>;
[Type] <variable name 2>;
. . .
};

|

uniform mat4 ModelMatrix;
uniform mat4 ViewMatrix;
uniform mat4 ProjectionMatrix;

|

uniform Transformation{
    mat4 ModelMatrix;
    mat4 ViewMatrix;
    mat4 ProjectionMatrix;
};

|

如何实现...

下面是逐步描述,展示了接口块并有助于编程统一块对象:

  1. 重新使用之前的教程,使用限定符管理变量属性,并创建顶点着色器(UniformBlockVertex.glsl),如下所示:

    #version 300 es
    
    layout(location = 0) in vec4 VertexPosition;
    layout(location = 1) in vec4 VertexColor;
    
    out vec4 Color;
    // Uniform Block Declaration
    uniform Transformation{
        mat4 ModelMatrix;
        mat4 ViewMatrix;
        mat4 ProjectionMatrix;
    };
    
    void main()
    {
        gl_Position = ProjectionMatrix * ViewMatrix * 
                      ModelMatrix * VertexPosition;
        Color = VertexColor;
    }
    
  2. 创建片段着色器(UniformBlockFragment.glsl),如下所示:

    #version 300 es
    precision mediump float;
    in vec4 Color;
    layout(location = 0) out vec4 outColor;
    void main() {
      outColor = vec4(Color.x, Color.y, Color.z, 1.0);
    }
    
  3. Cube::InitModel() 函数中,编译给定的着色器(们)并创建程序对象。在尝试创建 UBO 之前,确保程序正在使用中(glUseProgram)。在此配方中,我们在一个单独的类成员函数 CreateUniformBufferObject 中创建了 UBO。按照以下步骤来理解此函数:

    void Cube::CreateUniformBufferObject()
    {
        // Get the index of the uniform block
        char blockIdx = glGetUniformBlockIndex
        (program->ProgramID, "Transformation");
    
        // Query uniform block size
        GLint blockSize;
        glGetActiveUniformBlockiv(program->ProgramID, blockIdx,
        GL_UNIFORM_BLOCK_DATA_SIZE, &blockSize);
    
        // Bind the block index to BindPoint
        GLint bindingPoint = 0;
        glUniformBlockBinding(program->ProgramID, 
        blockIdx, bindingPoint);
    
        // Create Uniform Buffer Object(UBO) Handle
        glGenBuffers(1, &UBO);
        glBindBuffer(GL_UNIFORM_BUFFER, UBO);
        glBufferData(GL_UNIFORM_BUFFER, blockSize, 
        0, GL_DYNAMIC_DRAW);
    
        // Bind the UBO handle to BindPoint
        glBindBufferBase(GL_UNIFORM_BUFFER, bindingPoint, UBO);
    }
    
  4. 使用 glGetUniformBlockIndex API 查询在顶点着色器中定义的统一块索引,并将其存储在 blockIdx 中。此 API 接受程序 ID 和需要查询块索引的统一块名称。

  5. 使用 blockIdx 并借助 glGetActiveUniformBlockiv API 查询 blockSize 变量中的块数据大小。使用 glUniformBlockBinding 将统一块索引绑定到绑定点 bindingPoint

  6. 创建统一缓冲块的对象句柄,并将其绑定到符号常量 GL_UNIFORM_BUFFER,并分配由 blockSize 指定的所需内存。最后,使用 glBindBufferBase 通过绑定点绑定 UBO。

  7. 在渲染函数中,利用缓冲对象内存映射来修改 UBO 的内容:

    void Cube::RenderCube()
    {
       // Bind the UBO
       glBindBuffer( GL_UNIFORM_BUFFER, UBO );
       // Map the buffer block for MVP matrix
       glm::mat4* matrixBuf = (glm::mat4*)glMapBufferRange
       (GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4*)*(3),
    GL_MAP_WRITE_BIT);
    // Assign updated matrix
       matrixBuf[0] = *TransformObj->TransformGetModelMatrix();
       matrixBuf[1] = *TransformObj->TransformGetViewMatrix();
                matrixBuf[2]=*TransformObj->TransformGetProjectionMatrix();
    // UnMap the buffer block
       glUnmapBuffer ( GL_UNIFORM_BUFFER );
    
    // Draw Geometry using VBO..
    . . . .    
    }
    

它是如何工作的...

在顶点着色器中的统一块声明将模型、视图和投影矩阵组合成一个名为 transformation 的逻辑块。当着色器程序被编译时,它为该块分配一个唯一的 ID/索引,称为块索引。在统一块中不允许使用用户定义的位置索引。创建 UBO 需要以下五个步骤:

  1. 使用 glGetUniformBlockIndex API 查询 blockIdx 变量中的 Transformation ID。

  2. 为了为 UBO 分配内存,使用 glGetActiveUniformBlockiv API 查询 Transformation 统一块的大小并将其存储在 blockSize 变量中。

  3. 使用 glUniformBlockBinding API 将 blockIdx(块索引)绑定到 bindingPoint(绑定点)。UBO 使用绑定点的概念在块索引和缓冲对象之间建立连接。两者都必须绑定到绑定点。

  4. 与在 OpenGL ES 中创建的缓冲对象(VBO 和 IBO)类似,同样创建统一缓冲对象。必须使用 GL_UNIFORM_BUFFER 符号常量来确保 UBO 缓冲区与 OpenGL ES 状态机。

  5. 如第 3 步所述,我们需要将 UBO 与已附加到块索引的相应绑定点相关联。使用 glBindBufferBase API 绑定 UBO 和 bindingPoint

UBO 可以通过单个 UBO 绑定调用设置多个值。RenderCube() 绑定 UBO 来设置模型、视图和投影矩阵的统一值。缓冲对象允许使用缓冲区映射技术修改缓冲区元素。

OpenGL ES 3.0 的发布引入了一个新的范围缓冲区映射特性。这个特性允许我们修改缓冲对象的一个子集。与需要将整个缓冲区映射到 CPU 侧的老式缓冲区映射技术不同,这种技术看起来要高效得多。

使用glMapBufferRange API 在客户端映射 UBO 以修改模型、视图和投影矩阵的更新值。确保在修改完成后使用glUnmapBufferAPI取消映射缓冲对象。使用现有的 VBO 渲染代码。

还有更多...

下图描述了 UBO 中绑定点的概念。每个统一块在着色程序中都有一个唯一的索引。这个索引附加到一个绑定点上。同样,UBO 也附加到绑定点上,并提供了一种在不同程序间共享相同数据的方法。

还有更多...

在前面的图中,P1_2P2_1指向同一个绑定点。因此,它们共享相同的数据。

参见

  • 请参阅第二章中的使用顶点缓冲对象进行高效渲染食谱。

  • 使用映射读取和写入缓冲对象

使用顶点数组对象管理 VBO

在第二章中,我们介绍了使用顶点数组和顶点缓冲对象VBO)来加载顶点属性的两个特性。这两个特性允许我们在 OpenGL ES 渲染管道中加载顶点属性。与顶点数组相比,VBO 被认为更高效,因为它们将顶点数据存储在 GPU 内存中。这减少了 CPU 和 GPU 之间数据复制的成本。在本食谱中,我们将了解一个新特性:OpenGL ES 3.0 的顶点数组对象VAO)。这个特性比 VBO 更高效。

当加载顶点属性时,需要在 OpenGL ES 渲染管道中设置一些额外的调用以设置属性状态。例如,在渲染之前,使用glBindBuffer API 绑定缓冲对象,使用glVertexAttribPointer API 分配数据数组,并使用glEnableVertexAttribArray API 启用顶点属性。VAO 将这些状态存储在单个对象中,以消除这些调用造成的开销。

这允许应用程序快速在可用的顶点数组缓冲区之间切换,并设置它们各自的状态。这使得渲染更高效,同时也帮助保持编程代码紧凑且干净。

如何做到这一点...

本食谱演示了使用 VAO 和 VBO 结合进行简单网格几何渲染。对于 VAO 的编程,不需要对着色器进行任何更改。也许可以使用本章之前的食谱。

创建 VAO 的步骤非常简单:

  1. 创建一个Grid类,并在CreateGrid函数中定义几何形状。此函数接受网格的维度和分割。在此函数内部,创建一个 VBO、IBO 和 VAO,如下面的代码所示:

    void Grid::CreateGrid(GLfloat XDim, GLfloat ZDim, int XDiv, int ZDiv)
    {
       // Define geometry using Dimension and divisions
       // Create VBO and IBO for grid geometry
       // Create Vertex Array Object
       // Enable VBO and set attribute parameters
       // Unbind VAO, VBO and IBO 
    }
    
  2. 创建一个 VBO,生成缓冲区,并将顶点信息填充到缓冲对象中:

     // Create VBO ID
     glGenBuffers(1, &vIdGrid);
     glBindBuffer( GL_ARRAY_BUFFER, vIdGrid);
     glBufferData( GL_ARRAY_BUFFER,size,0, GL_STATIC_DRAW);
     glBufferSubData( GL_ARRAY_BUFFER, 0, size,gridVertex);
    
  3. 类似地,创建一个 IBO,并用元素索引填充缓冲区:

     // Create IBO for Grid
     unsigned short indexSize=sizeof(unsigned short)*indexNum;
     glGenBuffers(1, &iIdGrid);
     glBindBuffer( GL_ARRAY_BUFFER, iIdGrid );
     glBufferData(GL_ARRAY_BUFFER,indexSize,0,GL_STATIC_DRAW);
     glBufferSubData(GL_ARRAY_BUFFER,0,indexSize,gridIndices);
    
  4. 使用glGenVertexArrays API 生成 VAO ID。使用glBindVertexArray绑定生成的Vertex_VAO_Id。因此,在 VAO 创建后的代码记录在 VAO 对象的状态向量中。因此,使用 VBO 并将数据绑定到所需的顶点属性以进行渲染:

          // Create Vertex Array Object
          glGenVertexArrays(1, &Vertex_VAO_Id);
          glBindVertexArray(Vertex_VAO_Id);    
          // Create VBO and set attribute parameters
          glBindBuffer( GL_ARRAY_BUFFER, vIdGrid );
          glEnableVertexAttribArray(VERTEX_LOCATION);
          glVertexAttribPointer(VERTEX_LOCATION,3,GL_FLOAT,
          GL_FALSE,0, (void*)0);
          glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, iIdGrid );
    
  5. 一旦正确设置顶点状态和属性,解绑 VAO、VBO 和 IBO:

    glBindVertexArray(0);
    glBindBuffer( GL_ARRAY_BUFFER, 0 );
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
    
  6. 使用Render()函数通过 VAO 渲染几何形状,如下所示:

       // void Grid::Render() 
       // Use shader program and apply transformation    
       . . . . .
       glBindVertexArray(Vertex_VAO_Id); // Bind VAO
       glDrawElements(GL_LINES,((XDivision+1)+(ZDivision+1))*2,
       GL_UNSIGNED_SHORT, (void*)0); }
    

它是如何工作的...

VAO 将顶点数组客户端状态和缓冲区绑定存储在状态向量中。当 VAO ID 被绑定时,后续的操作调用,如绑定 VBO、启用客户端状态和将数据缓冲区附加到通用属性,都存储在 VAO 的状态向量中。这样,当 VAO 被绑定时,状态向量提供了当前设置、配置和顶点数组的客户端状态的完整状态。而不是进行多次调用,这个绑定调用就足以启用顶点数组的配置和状态。

它是如何工作的...

参见

  • 请参考第二章中的使用顶点数组渲染原语配方,OpenGL ES 3.0 基础

使用映射读取和写入缓冲对象

之前的配方介绍了一个新功能,即使用 VAO 访问顶点数组。此对象最小化了在顶点数组及其相应状态之间切换的开销。本配方将进一步教你如何使用缓冲映射更新缓冲对象的 数据。VBO 可以使用glBufferDataglBufferSubData进行更新,如许多配方中所示。这些 API 可以用于将数据上传或下载到设备。相比之下,缓冲映射是更新驻留在 GPU 内存中的缓冲对象的效率较高的方法。

本示例将演示缓冲对象范围映射。在这个示例中,我们将重用立方体几何形状,并将立方体的每个顶点渲染为点原语,而不是三角形原语。立方体的每个顶点都经过编程,在固定的时间间隔后使用缓冲对象范围映射功能随机改变其颜色。

准备工作

在开始逐步描述之前,这里是对缓冲对象范围映射的概述:

  1. 使用glBindBuffer绑定需要映射的缓冲区。

  2. 使用glMapBufferRange API 从驱动器内存空间获取内存位置的指针。

  3. 使用此指针对获取的内存执行任何读写操作。

  4. 使用 glUnmapBuffer API 无效化获取指针。此 API 允许我们将更新后的内存内容发送到 GPU 内存空间。

如何做到...

此配方不需要在顶点和片段着色器中进行任何特殊更改。对于此配方,我们使用了一个新的 GL 着色语言 API,称为 gl_PointSize。此 API 用于指定 GL_POINTS 原始形状的大小。利用 第二章 中的 使用顶点缓冲对象进行高效渲染 配方,并按照以下步骤将范围映射编程到缓冲区对象:

  1. 首先,使用之前的 VAO 配方创建立方体几何的 VAO。

  2. Render() 函数内部编程映射范围缓冲区,如下所示。以下步骤将描述此函数:

    void Cube::RenderCube(){
         if (clock() - last >= CLOCKS_PER_SEC * 0.1){
            // Bind the Buffer Object for vertex Array.
            glBindBuffer( GL_ARRAY_BUFFER, vId );
            // Get the mapped memory pointer.
            GLfloat* colorBuf = (GLfloat* )glMapBufferRange(
     GL_ARRAY_BUFFER, size, size, GL_MAP_WRITE_BIT);
            for(int i=0; i<size/sizeof(GLfloat); i++)
    {  colorBuf[i] = float(rand()%255)/255; }
            last = clock();
            // Invalidate the mapped memory.
            glUnmapBuffer ( GL_ARRAY_BUFFER );
        }
        // Perform Transformation.
       . . . . . . .    
        // Bind the VAO and Render the cube 
        // with Point primitive.
        glBindVertexArray(Vertex_VAO_Id);
        glDrawElements(GL_POINTS,36,GL_UNSIGNED_SHORT,(void*)0);
    }
    
  3. 首先,绑定 VBO 以使用 glBindBuffer API 映射颜色缓冲区数据。将指针映射到颜色数据内存。VBO 中的颜色数据从大小索引开始,也是大小字节长:

    colorBuf = (GLfloat*)glMapBufferRange (GL_ARRAY_BUFFER,
     size, size, GL_MAP_WRITE_BIT);
    

    在成功映射缓冲区对象后,它返回指向内存映射位置的合法指针。如果发生错误,API 将返回 NULL 指针。

    • 语法:

      void *glMapBufferRange(GLenum target, GLintptr offset,
                      GLsizeiptr length, GLbitfield access);
      
      变量 描述
      target 这指定了预期用于内存映射的缓冲区类型,例如,GL_MAP_READ_BITGL_MAP_WRITE_BIT
      offset 这指定了映射对象中感兴趣映射的起始偏移量
      length 这指定了需要映射的缓冲区范围
      access 这是指示所需访问缓冲区范围的符号常量标志组合
  4. 复制映射内存缓冲区中的新颜色值:

    // size/sizeof(GLfloat) gives total number of elements 
    // that needs to be updated with new color, the formula 
    // is- total size of buffer / unit item size 
    for(int i=0; i<size/sizeof(GLfloat); i++){
        colorBuf[i] = float(rand()%255)/255;
    }
    
  5. 解除内存映射缓冲区,以指示 OpenGL ES 渲染管道将此数据传输到 GPU 内存空间:

    glUnmapBuffer ( GL_ARRAY_BUFFER );
    

    UnmapBuffer API 在成功解除当前映射的缓冲区时返回布尔值 TRUE。如果发生某些错误,它返回 FALSE

    • 语法:

      GLboolean glUnmapBuffer(GLenum target);
      
      变量 描述
      target 这指定了需要解除绑定的缓冲区类型
  6. 绑定 VAO 并使用 GL_POINTS 原始形状渲染几何形状。GL_POINTS 原始形状在屏幕上渲染小点。为了增加这些点的尺寸,可以在顶点着色器中使用 gl_PointSize API,如下一步所示:

    glBindVertexArray(Vertex_VAO_Id);
    glDrawElements(GL_POINTS, 36, GL_UNSIGNED_SHORT, (void*)0);
    
  7. 创建 BufferMappingVertex.glsl 如下:

    layout(location = 0) in vec4 VertexPosition;
    layout(location = 1) in vec4 VertexColor;
    uniform mat4 MODELVIEWPROJECTIONMATRIX;
    out vec4 Color;
    void main(){
      gl_Position = MODELVIEWPROJECTIONMATRIX * VertexPosition;
      gl_PointSize= 80.0; // Size of GL_POINTS primitive
      Color       = VertexColor;
    }
    

它是如何工作的...

在 VBO 中,glBufferDataglBufferSubData 使用用户数据并将其复制到设备内存位置的钩子/固定位置。此钩子位置可以被 GPU 访问。用户数据像 memcpy 内部一样复制到这个内存位置。随着数据复制过程的完成,驱动程序开始 直接内存分配DMA),而不干预 CPU 周期。

DMA 的目标目的地取决于(GL_STREAM_DRAW, GL_STREAM_READ, GL_STREAM_COPY, GL_STATIC_DRAW, GL_STATIC_READ, GL_STATIC_COPY, GL_DYNAMIC_DRAW, GL_DYNAMIC_READ, 或 GL_DYNAMIC_COPY) API 的使用提示。

相比之下,glMapBufferRange方法被认为效率更高。API 首先将一个内存位置直接钩接到驱动程序内存空间中。这个固定的内存位置可以通过指向应用程序的指针来访问。这个指针可以直接用来更新上传或下载数据的位置。一旦对映射位置的读写操作完成,可以通过调用glUnMapBuffer使指针无效。这个 API 调用提示 OpenGL ES 管道使用 DMA 调用将更新后的数据推送到 GPU 内存。

工作原理

参考以下内容

  • 请参阅附录中的Swizzling配方,OpenGL ES 3.0 补充信息

  • 请参阅第十二章中的带有同步对象和栅栏的变换反馈粒子系统配方,实时阴影和粒子系统

使用几何实例化渲染多个对象

几何实例化允许我们在单个渲染 API 调用中渲染同一对象的多个实例。这些多个实例在通用属性上有所不同,例如变换矩阵、颜色、缩放等。这个特性对于实现粒子系统、人群模拟、丛林树木渲染等非常有用。与传统方式渲染多个对象相比,这种技术非常高效,因为它只需要一个 API 调用。这减少了将多个渲染调用发送到 OpenGL ES 渲染引擎的 CPU 处理开销。

这个配方演示了使用几何实例化渲染 1000 个立方体的过程。为此,我们将使用 VBO 中的 1000 个矩阵。每个矩阵包含一个变换,用于将立方体放置在 3D 空间中。矩阵信息通过前一个配方中讨论的范围映射缓冲区功能进行更新。这允许我们在运行时动态传递新的变换数据。变换数据包含新的旋转和移动位置。

使用几何实例化渲染多个对象

如何做到这一点...

到目前为止,在我们的配方中,模型视图投影矩阵在顶点着色器中始终被视为统一变量。对于这个配方,我们将使用 VAO 并将模型视图投影矩阵声明为一个通用属性,而不是统一变量。由于矩阵是一个属性,需要一个新的 VBO。这个 VBO 存储在matrixId变量中。RenderCube()使用映射缓冲区来更新变换矩阵数据。

实现几何实例化的步骤如下:

  1. 创建顶点着色器并添加以下代码。对于片段着色器不需要任何更改,它可以被重用:

    #version 300 es
    layout(location = 0) in vec4 VertexPosition;
    layout(location = 1) in vec4 VertexColor;
    layout(location = 2) in mat4 MODELVIEWPROJECTIONMATRIX;
    out vec4 Color;
    void main() {
      gl_Position = MODELVIEWPROJECTIONMATRIX * VertexPosition;
      Color = VertexColor;
    }
    
  2. Cube::InitModel()中,使用现有代码并添加一个新的 VBO 用于矩阵变换。在matrixId中获取生成的缓冲区对象的 ID:

    // Create VBO for transformation matrix
    glGenBuffers(1, &matrixId);
    glBindBuffer (GL_ARRAY_BUFFER, matrixId);
    
  3. 为矩阵变换的 VBO 分配内存。维度变量初始化为 10。它给出了沿轴的立方体数量。因此,沿xyz轴,10 x 10 x 1 0 = 1000 个立方体。缓冲区的总大小将是(GLfloat) * 16(mat4 中的 16 个浮点元素) * 1000(立方体)*:

    glm::mat4 transformMatrix[dimension][dimension][dimension];
    glBufferData(GL_ARRAY_BUFFER, sizeof(transformMatrix) , 0, GL_DYNAMIC_DRAW);
    

    glBufferData使用GL_DYNAMIC_DRAW。这个符号常量指定缓冲区将包含一些动态性质的数据。换句话说,数据需要在缓冲区中更新。这个符号常量帮助图形驱动程序以最佳方式管理缓冲区内存,以实现高性能的图形渲染。

  4. 在同一函数中,在创建 VAO(Vertex_VAO_Id)之后,定义变换矩阵缓冲区对象的通用属性状态和配置。这有助于在 VAO(Vertex_VAO_Id)中保存顶点数组客户端状态和缓冲区绑定。glVertexAttribDivisor从提供的实例总数计算实例 ID。有关更多信息,请参阅本食谱中的更多内容…部分:

    // Create VBO for transformation matrix and set attributes
    glBindBuffer( GL_ARRAY_BUFFER, matrixId );
    glEnableVertexAttribArray(MATRIX1_LOCATION);
    glEnableVertexAttribArray(MATRIX2_LOCATION);
    glEnableVertexAttribArray(MATRIX3_LOCATION);
    glEnableVertexAttribArray(MATRIX4_LOCATION);
    
    glVertexAttribPointer(MATRIX1_LOCATION,4,GL_FLOAT,GL_FALSE,
      sizeof(glm::mat4),(void*)(sizeof(float)*0));
    glVertexAttribPointer(MATRIX2_LOCATION,4,GL_FLOAT,GL_FALSE,
      sizeof(glm::mat4),(void*)(sizeof(float)*4));
    glVertexAttribPointer(MATRIX3_LOCATION,4,GL_FLOAT,GL_FALSE,
      sizeof(glm::mat4), (void*)(sizeof(float)*8));
    glVertexAttribPointer(MATRIX4_LOCATION,4,GL_FLOAT,GL_FALSE,
      sizeof(glm::mat4), (void*)(sizeof(float)*12));
    
    glVertexAttribDivisor(MATRIX1_LOCATION, 1);
    glVertexAttribDivisor(MATRIX2_LOCATION, 1);
    glVertexAttribDivisor(MATRIX3_LOCATION, 1);
    glVertexAttribDivisor(MATRIX4_LOCATION, 1);
    
  5. Cube::RenderCube()函数中,使用范围缓冲区映射将变换缓冲区映射到客户端内存。更新内存中的数据,然后取消映射。使用 VAO 并通过调用几何实例 API glDrawElementsInstanced渲染立方体的立方体。此 API 的最后一个参数指定了给定原语将被渲染的实例数:

    void Cube::RenderCube()
    {
       glBindBuffer( GL_ARRAY_BUFFER, matrixId );
       glm::mat4* matrixBuf = (glm::mat4*)glMapBufferRange
       (GL_ARRAY_BUFFER, 0, sizeof(glm::mat4*)*(dimension    *dimension*dimension), GL_MAP_WRITE_BIT);
       static float l = 0;
       TransformObj->TransformRotate(l++, 1, 1, 1);
       TransformObj->TransformTranslate
       (-distance*dimension/4,-distance*dimension/4, -distance*dimension/4);
       glm::mat4 projectionMatrix = *TransformObj->
       TransformGetProjectionMatrix();
       glm::mat4 modelMatrix = *TransformObj->
       TransformGetModelMatrix();
       glm::mat4 viewMatrix = *TransformObj->
       TransformGetViewMatrix();
       int instance= 0;
       for ( int i = 0; i < dimension; i++ ){
       for ( int j = 0; j < dimension; j++ ){
       for ( int k = 0; k < dimension; k++ ){
       matrixBuf[instance++] = projectionMatrix *
       viewMatrix * glm::translate(modelMatrix, glm::vec3( i*distance , j*distance, k*distance)) * glm::rotate( modelMatrix, l, glm::vec3(1.0, 0.0, 0.0));
                                            }
                                            }
                                            }
    
       glUnmapBuffer ( GL_ARRAY_BUFFER );
    
       glBindVertexArray(Vertex_VAO_Id);
       glDrawElementsInstanced(GL_TRIANGLES,36,
       GL_UNSIGNED_SHORT, (void*)0, dimension*dimension*dimension);
    }
    

它是如何工作的...

应用程序首先编译着色器程序。这使得我们了解在着色器程序中使用的所有通用属性位置。创建一个包含 1000 个矩阵元素的 VBO。每个元素代表一个变换矩阵。此矩阵元素在RenderCube函数中通过每一帧的变换更新新值。

首先使用glEnableVertexAttribArray启用通用属性。使用glVertexAttribPointer将数据数组附加到通用位置。以下图示展示了如何将 OpenGL ES 程序 API 附加到顶点着色器的布局位置以发送数据:

它是如何工作的...

注意,通用属性作为一组四个发送。因此,对于 4 x 4 矩阵,我们需要四个属性位置。属性的开始位置应使用布局限定符在顶点着色器中指定:

layout(location = 2) in mat4 MODELVIEWPROJECTIONMATRIX;

以下图示展示了编译器如何管理属性位置:

它是如何工作的...

与其他位置类似,例如VERTEX_LOCATION (0)COLOR_LOCATION (1),变换矩阵位置(2, 3, 4, 5)也需要启用并附加到数组数据上。

glVertexAttribDivisor API 负责控制 OpenGL ES 从实例数组中推进数据的速率。此 API 的第一个参数指定了需要作为实例数组处理的通用属性。这告诉 OpenGL ES 管道使用此属性进行每个实例渲染。例如,在这个例子中,通用属性2345是实例属性。因此,OpenGL ES 将转换矩阵数组的数据作为实例 ID 消耗。我们将在稍后看到这个实例 ID 是如何计算的。

注意

当在程序中未显式指定除数时,除数的默认值为0。如果除数为0,则属性索引在每个顶点处前进一次。如果除数不为0,则属性在每个除数实例的集合(组)中渲染时前进一次。

语法:

void glVertexAttribDivisor(GLuint index, GLuint divisor);
变量 描述
index 这指定了通用属性布局位置
divisor 这指定了在索引槽更新通用属性之间的实例数量

几何实例化渲染需要 OpenGL ES 3.0 的特殊基于实例的绘图 API,如这里所述的基于数组和索引的几何数据。

语法:

void glDrawElementsInstanced(GLenum mode, GLsizei count, 
GLenum type, const void * indices, GLsizei primcount);
变量 描述
mode 这指定了需要渲染的原始类型
count 这指定了在绘图中考虑的索引数量
type 这由glDrawElementsInstanced使用,指定存储索引的数据类型
indices 这指定了包含索引顺序的数组
primcount 这指定了要渲染的副本数量

在当前配方中,使用了glDrawElementsInstanced API 来渲染相同对象的多个实例。此 API 与另一个名为glVertexAttribDivisor的 API 协同工作。为了更新 VBO 矩阵元素,使用了缓冲区映射,这是一种高效更新缓冲区元素的方法。如果几何数据不是基于索引而是基于数组,则可以使用glDrawArraysInstanced。此 API 接受几乎相同的参数。有关更多信息,请参阅在线OpenGL ES 3.0 参考手册

更多内容...

glVertexAttribDivisor的第二个属性指定了除数。这个除数有助于从实例总数中计算实例 ID。以下图显示了此 API 工作逻辑的简单示例。在这个图中,我们假设有总共五个要渲染的实例,图中包含五个矩阵。当除数为5时,它产生5个实例 ID(01234)。这个实例 ID 将用作转换矩阵数组的索引。同样,当除数为2时,它生成三个实例(012)。当除数为3时,它生成两个实例(01)。

还有更多...

参见

  • 使用顶点数组对象管理 VBO

  • 请参考第七章中的使用 ETC2 压缩纹理进行高效渲染使用无缝立方映射实现 Skybox配方,纹理和映射技术

使用原始重启渲染多个原始形状

OpenGL ES 3.0 引入了一个名为原始重启的新功能,其中可以使用单个 API 渲染多个断开连接的几何原始形状。该功能使用顶点数据或索引数据中的特殊标记将同一绘图类型的不同几何形状连接成一个批次。重启原始功能在 GPU 上执行。因此,它消除了每次绘图调用时的通信开销。这通过避免从 CPU 到 GPU 的多次绘图调用,提供了高性能的图形。

这个配方展示了如何使用原始重启技术,通过两套几何形状来渲染一个立方体,这些几何形状由一个特殊的标记分隔。

使用原始重启渲染多个原始形状

准备工作

重启原始功能用于分隔几何形状的标记是用于指定元素索引或顶点数据数组的特定数据类型的最高值。例如,GLushortGLint的索引值应分别为0x FFFF (65535)和0x FFFFFFFF (4294967295)。

如何做到这一点...

要渲染多个原始形状,请按照以下步骤操作:

  1. 定义立方体的顶点和索引,如下所示:

    立方体顶点 索引

    |

    GLfloat  cubeVerts[][3] = {
      -1, -1, 1 , // V0
      -1, 1, 1 ,  // V1
      1, 1, 1 ,   // V2
      1, -1, 1 ,  // V3
      -1, -1, -1 ,// V4
      -1, 1, -1 , // V5
      1, 1, -1 ,  // V6
      1, -1, -1   // V7
      };
    

    |

    // 36 indices
    GLushort cubeIndices[] = {
        0,3,1, 3,2,1,
        7,4,6, 4,5,6,
        4,0,5, 0,1,5,
        0xFFFF, 3,7,2,
        7,6,2, 1,2,5,
        2,6,5, 3,0,7,
        0,4,7
    };
    

    |

  2. 为了使用原始重启渲染立方体,首先必须启用它,使用glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX)。指定索引的总大小,并包括在几何索引中使用的标记数量:

    //Bind the VBO
    glBindBuffer( GL_ARRAY_BUFFER, vId );
    glVertexAttribPointer(VERTEX_LOCATION, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
    glVertexAttribPointer(COLOR_LOCATION, 3, GL_FLOAT, GL_FALSE, 0, (void*)size);
    
    glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
    glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, iId );
    // Plus 36 + 1 because it has 1 Primitive Restart Index.
    glDrawElements(GL_TRIANGLES, 36+1, GL_UNSIGNED_SHORT, (void*)0);
    glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
    

还有更多...

另一种渲染断开连接的几何原始形状的方法被称为三角形退化。三角形退化是指 GPU 能够根据某些特殊模式识别三角形带或三角形扇索引信息中的断开原始形状的能力。

例如,以下图显示了可以使用glDrawElementglDrawElementsInstanced API 渲染退化三角形的特殊索引模式数据。

还有更多...

两个几何形状之间的退化是通过重复前一个几何形状的最后一个索引和下一个原始形状的第一个索引来实现的。这种退化的规则仅适用于前一个几何形状包含奇数个三角形的情况。幕后,三角形将按照以下顺序绘制:(0, 1, 2),(2, 1, 3),(2, 3, 3),(3, 3, 6),(3, 6, 6),(6, 6, 7),(6, 7, 8),(8, 7, 10)。重复的索引形成一个等效于零的区域,允许 GPU 丢弃这些三角形。这些零面积三角形用粗体字提及。

第二种退化情况是第一种几何图形包含奇数个三角形。例如,以下图像展示了包含三个(奇数)三角形的第一个几何图形。根据此规则,第一个几何图形的最后一个索引重复两次,然后是第二个几何图形的第一个索引。

还有更多...

例如,为退化三角形指定的索引(0, 1, 2, 3, 4, 4, 4, 8, 8, 9, 10, 11)生成以下三角形:(0, 1, 2),(2, 1, 3),(2, 3, 4),(4, 3, 4),(4, 4, 4),(4, 4, 6),(4, 6, 6),(6, 6, 9),(6, 7, 8),(8, 7, 9),(9, 8, 10)。

参见

  • 参考第一章中的使用顶点属性将数据发送到着色器配方,OpenGL ES 3.0 在 Android/iOS 上,第一章

  • 参考第二章中的使用顶点缓冲对象进行高效渲染配方,OpenGL ES 3.0 基础知识,第二章

第四章 处理网格

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

  • 使用 Blender 创建多边形网格

  • 渲染 wavefront OBJ 网格模型

  • 渲染 3Ds 网格模型

简介

在前面的章节中,您学习了 OpenGL ES 的基础知识,以创建 3D 几何对象并将它们放置在 3D 空间中,并且还了解和编程了 OpenGL ES 3.0 的新特性。我们还使用各种属性,如顶点位置和颜色,编程了一个简单的 3D 立方体模型。在 OpenGL ES 中进行过程式几何建模(仅使用代码构建,没有任何外部数据文件参考或工具的帮助)不仅耗时,而且如果几何形状非常复杂,编程起来也可能非常复杂。例如,渲染 3D 汽车模型比简单的 3D 立方体要困难得多。如果用户不够小心,编程几何形状会变得非常繁琐。

处理这种复杂的几何形状的最佳方式是使用计算机辅助设计工具来创建它们;这些工具不仅节省时间,而且可以创建用于可视化的模型。使用这些工具的主要优势是,您可以在不担心背后涉及的数学概念的情况下创建极其复杂的几何形状。模型创建后,您可以在程序中以各种 3D 文件格式导出它们。这些 3D 几何模型也称为网格。

在本章中,您将学习如何使用 Blender 创建简单的网格,Blender 是一个开源的 3D 建模工具。我们将讨论并理解两种非常著名的 3D 网格模型类型,OBJ 和 3Ds,并尝试理解它们的规格。您还将学习如何在您的 OpenGL ES 食谱中编写这些模型的解析器。此外,本章还将涵盖 3D 网格模型的各个方面,这将有助于将它们渲染为 3D 图形。

使用 Blender 创建多边形网格

多边形网格是由顶点、面、法线、颜色或纹理组成的集合,共同定义了一个 3D 模型。这个 3D 模型可以直接用于各种 3D 应用程序,如计算机图形、模拟器、动画电影和 CAD/CAM。在本节中,您将学习如何在 Blender 中创建网格模型,我们将在整个书籍的教程中使用这些模型来演示我们的食谱。

在本章中,我们将使用 Blender 2.68 来开发我们的 3D 模型网格。Blender 是一个免费且开源的 3D 计算机图形工具。您可以使用其他类似软件,如 3Ds Max、Maya、Google Sketch 等。

准备工作

您可以从www.blender.org/download下载 Blender 的最新版本,并按照网站上的说明进行安装。

Blender 是跨平台的,可以在多个流行的计算平台上运行。Blender 允许使用多种几何原语,包括各种多边形网格、细分曲面建模和智能几何编辑工具。它还允许在几何表面上实现各种纹理技术。当 Blender 首次启动时,你将找到如图所示的工具界面:

准备就绪

如何操作...

本节将提供一个逐步过程,介绍如何在 Blender 2.68 中创建网格模型。我们将了解创建这些网格的简单步骤,并将它们导出为wavefront.obj和 3Ds 格式,以便在后面的食谱中用于演示目的。

  1. 当 Blender 首次启动时,画布网格中间将显示一个立方体对象。如果你不打算使用这个立方体,你可以删除它。为了从画布网格中删除一个对象,选择它(通过将光标放在它上面并右键单击它)然后在键盘上点击删除键。或者,你可以选择一个对象并点击XEnter键来删除选定的对象。

  2. 默认情况下,Blender 中有 10 种基本网格模型可用,可以一起使用来创建更复杂的形状。根据 Blender 版本类型,UI 界面可能有所不同。然而,基本功能是相同的。为了添加新的网格模型,导航到菜单,点击添加 | 网格,并选择所需的模型(例如,UV 球体)。在新版本的 Blender(如 2.7.0 及以后版本)中,你可能会在左侧面板的创建选项卡下找到此选项,如下面的截图所示:如何操作...

  3. 你可以从左侧面板更改每个模型的模型属性,如下面的截图所示:如何操作...

    对于每个网格,你可以更改位置并应用旋转。在我们的所有食谱中,我们将使用位置为(0.0,0.0,0.0),这样网格总是出现在画布网格的原点上。

  4. 可以通过选择网格对象并点击Tab按钮在编辑模式中编辑模型。在编辑模式中,可以增强网格的几何形状。例如,网格对象的表面可以被细分成许多更小的表面,以增强表面的平滑度。在编辑模式中,你可以选择细分菜单选项来细分选定的对象表面。以下图像显示了细分的工作原理:如何操作...

  5. 使用文件 | 导出菜单选项导出创建的模型。我们将以 wavefront 和 3Ds 网格格式导出创建的模型。在接下来的章节中,我们将看到这些网格格式在我们的食谱中的应用:如何操作...

  6. 在以 Wavefront(.obj)格式导出时,您可能需要根据您的需求选择以下选项:

    • 包含边:这将边导出为双面面。

    • 三角化面:不是将面作为四边形来写,每个四边形都使用三个三角形来表示。我们必须为我们的网格模型选择此选项。

    • 包含 UVs(可选):写入关于几何表面的纹理坐标信息

    • 包含法线(可选):根据面的平滑设置写入面和顶点法线:

    如何操作...

    注意

    包含法线是可选的;它计算面法线并将其写入文件导出的文件格式。此外,在运行时计算法线将产生一些额外的处理成本。此功能可以通过牺牲大文件和读取此文件时使用的内存来最小化运行时计算。

    或者,您可以使用网格模型内提供的面信息来计算法线。在本食谱的后面部分,您将学习如何使用面信息来计算法线。

  7. 要导出 3Ds 格式,请使用所有默认导出选项。

    注意

    本章创建的网格不包含任何基于纹理的信息。我们将在后面的章节中使用基于纹理的模型。

  8. 在使用 Blender 时,可以通过选择文件 | 加载工厂设置在任何时候将设置恢复到默认工厂设置。

更多...

GLPIFramework文件夹下的Models中可以找到从 Blender 和其他示例模型导出的 Wavefront 和 3Ds 格式的模型。您可以自由探索它们。第五章,光与材质,广泛使用了 Wavefront 模型来演示各种类型的光。

参见

  • 渲染 Wavefront OBJ 网格模型

  • 渲染 3Ds 网格模型

渲染 Wavefront OBJ 网格模型

波文件格式是由 Wavefront 技术公司开发的一种著名的 3D 网格模型格式。它以可读的文本格式包含网格几何信息。

Wavefront 格式主要包含两种类型的文件:.obj.mtl.obj文件负责描述 3D 模型的几何信息,如顶点位置、法线、纹理坐标和面等。.mtl文件是可选的,包含单个网格部分的材质信息,如纹理和着色(漫反射、镜面等)信息。此外,如果.mtl文件包含纹理信息,它将自动导出;否则,您必须设置写入材质。这里的材质指的是物体的颜色或纹理。我们导出的模型不包含任何纹理信息。因此,这些模型只包含.obj文件。

文件格式:由于 wave front 格式是可读的,你可以在任何文本编辑器中打开它并读取它。它使用特殊的关键字来识别特定类型的信息。以下表格将帮助你理解用于 wavefront 文件格式的关键字:

关键字 含义 示例
# #开头的内容被视为注释。 此文件使用 Blender 2.65 创建
v 这是指定 x、y 和 z 坐标的顶点位置。
  • v 1.000000 -1.000000 -1.000000

  • v 1.000000 -1.000000 1.000000

  • v -1.000000 -1.000000 1.000000

|

vt 这指定了 0.0 到 1.0 范围内的纹理坐标。
  • vt 0.0 0.0

  • vt 1.0 0.0

  • vt 1.0 1.0

  • vt 0.0 1.0

|

vn 这表示每个顶点位置的法线。
  • vn 0.0 1.0 0.0

  • vn 0.0 0.0 1.0

|

| f | 这包含面信息。每个面由顶点(v)后跟纹理坐标(u)和顶点法线(n)定义。面信息的语法是[v]/[u]/[n]。 | 面信息各种格式:

  • 顶点坐标:f 1 2 3

  • 顶点和纹理坐标:f 1/1 3/2 4/3

  • 顶点和纹理法线:f 1/1/2 3/2/1 4/3/2

  • 顶点和法线坐标:f 1//2 3//1 4//2

|

以下图像显示了在文本编辑器中打开的示例 wavefront (.obj)文件。此示例仅包含顶点和面信息,这是网格模型渲染到任何 3D 图形可视化工具的最小要求。根据选择的导出选项,可以通过新的关键字看到更多网格属性:

渲染 wavefront OBJ 网格模型

wavefront OBJ 模型的实际功能远远超出了我们在文件格式中指定的。涵盖.obj的所有规范超出了本书的范围。你可以参考paulbourke.net/dataformats/obj以获取完整的规范集。这个配方主要涵盖了规范中最重要和关键的部分,你将学习如何解析几何信息。我们的小型解析器和渲染器将帮助你深入理解网格的概念,并允许你编写任何其他类型的网格文件格式。

注意

你可以在paulbourke.net/dataformats/obj/了解更多关于 wavefront OBJ 文件格式规范的信息。

准备工作

我们在 Blender 中创建的 3D 模型需要导入到项目中。Android 和 iOS 有不同的方式访问它们资源:

  • Android: 在 Android 上,3D 网格模型需要从GLPIFramework/Models复制并存储在内存卡下的sdcard/Models文件夹中

  • iOS: 使用文件 | 将文件添加到[项目名称]GLPIFramework/Models文件夹添加到项目中

类和数据结构OBJMesh类负责解析波前 OBJ 网格模型;它使用必要的数据结构来存储解析的波前 OBJ 信息。此类定义在GLPIframework/WaveFrontOBJ文件夹下的WaveFrontObj.h/.cpp中。以下是此类使用的必要数据结构:

  • 顶点:此结构将存储 3D 几何中每个顶点的信息。它包含每个顶点沿xyz轴的位置坐标,存储在位置变量中。纹理坐标存储在uv变量中。法线坐标存储在法线变量中。每个顶点的切向信息存储在切线中:

    struct Vertex
    {
    public:
       glm::vec3 position; //Store X/Y/Z coordinate.
       glm::vec2 uv;       //Store Tex coordinate.
       glm::vec3 normal;   //Store Normal information.
       glm::vec3 tangent;  //Store Tangent information.
       . . . . .
    };
    
  • 面索引:此结构负责存储与面相关的信息。例如,它存储了所有有助于定义面的顶点、纹理坐标和法线的索引:

    struct FaceIndex
    {
        short vertexIndex; // Face's vertex Index
        short normalIndex; // Face's normal Index
        short uvIndex;     // Face's texCoord Index
         . . . . .
    };
    
  • 网格:网格数据结构负责存储网格几何信息。它包含需要解析的 OBJ 文件的完整路径,存储在fileName变量中。该类以向量数组的形式包含顶点、纹理和法线信息。这些信息分别存储在位置、UV 和法线向量列表变量中。vecFaceIndex以每个顶点形成面的索引形式存储每个面的信息。

    索引存储每个面的顶点索引,并在.obj网格文件中没有法线信息时用于计算法线:

    struct Mesh
    {
        // Obj File name
        char fileName[MAX_FILE_NAME];
    
        // List of Face Indices For vertex, uvs, normal
       std::vector<FaceIndex> vecFaceIndex;
    
        // List of vertices containing interleaved 
     // information forposition, uv, and normals
       std::vector<Vertex>    vertices;
    
        // List of vertices containing positions
       std::vector<glm::vec3> positions;
    
        // List of vertices containing normal
       std::vector<glm::vec3> normals;
    
        // List of vertices containing uvs
       std::vector<glm::vec2> uvs;
    
        //! List of tangents
       std::vector<glm::vec4> tangents;
    
        // List of face indices
       std::vector<unsigned short> indices;
    };
    

如何做到这一点...

本节将提供逐步解析和渲染 OpenGL ES 3.0 中的波前 OBJ 网格模型的过程。让我们按照以下步骤开始:

  1. 创建一个从Model类派生的ObjLoader类。它将继承ObjLoader生命周期所需的Model类的所有成员函数。

    这个类包含成员变量以存储波前模型网格信息。网格模型使用ObjMesh函数的waveFrontObjectModel对象进行解析。此对象调用parseObjMesh函数,该函数接受 3D 波前 OBJ 模型的路径作为参数,这是我们想要加载的:

    OBJMesh waveFrontObjectModel;
    objMeshModel = waveFrontObjectModel.ParseObjModel(fname);
    
  2. ParseObjModel函数进一步调用一系列辅助函数来存储和处理.obj文件中的网格信息。此函数返回Mesh对象指针。此函数接受要加载的文件路径以及另一个参数,指定是否需要计算法线为平面或平滑:

    Mesh* OBJMesh::ParseObjModel(char* path, bool flatShading)
    {
        ParseFileInfo(path);         // Parse's the obj file
        CreateInterleavedArray();    // Interleaved data array
        CalculateNormal(flatShading);// Generate the normal
        if(objMeshModel.uvs.size())
            { CalculateTangents(); } // Generate tangents
        ClearMesh();                 // Release alloc resources
        return &objMeshModel;
    }
    
  3. ParseFileInfo函数读取网格模型的路径以验证其存在。此函数通过读取文件中的每一行来解析文件。每一行开头都有一个关键字,指定它包含的信息类型。#usg关键字被忽略,因为它们在解析器中未使用。#关键字用于在 wavefront 文件中写入注释。从该函数解析的信息收集在网格的对象指针中:

    strcpy(objMeshModel.fileName, path);
    while(!eofReached)
    {
        c = fgetc(pFile);
        switch(c)
        {
            case '#': // Ignore (This is a comment)
            case 'u': // Ignore
            case 's': // Ignore
            case 'g': // Grouping not supported
                while(fgetc(pFile) != '\n'); 
    // Skip till new next line not reached.
                break;
    
    #ifdef __IPHONE_4_0
          case EOF:
    #else
          case (unsigned char)EOF:
    #endif
                eofReached = true;
                break;
    
            case 'v': // Load the vertices.
                c = fgetc(pFile); 
    // The next character will
               // let us know what vertex attribute to load
                ScanVertexNormalAndUV( pFile, c );
                break;
    
            case 'f': 
    // 'f' means it is a face index information 
    // in the form of v/u/n
                ScanFaceIndex( pFile, c );
                break;
        }
    }
    
  4. v关键字开头的行表示顶点属性,这些属性可能包含三种类型的信息:顶点位置(v)、顶点纹理坐标(vt)和顶点法线(vn)。此信息是通过使用ScanVertexNormalAndUV函数读取的。

    此函数解析每一行,并将顶点位置、纹理坐标和顶点法线的信息分别存储在objMeshModel.positionsobjMeshModel.uvsobjMeshModel.normals中:

           bool OBJMesh::ScanVertexNormalAndUV( FILE* pFile, char c )
    {
    float  x, y, z, u, v;
    switch(c)
    {
       case ' ': // Load vertices
          fscanf(pFile,"%f %f %f\n",&x,&y,&z);
          objMeshModel.positions.push_back(glm::vec3(x, y, z));
       break;
       case 'n': // Loading normal coordinate comp. x,y,z
           fscanf(pFile,"%f %f %f\n",&x,&y,&z);
          objMeshModel.normals.push_back(glm::vec3(x, y, z));
          break;
       case 't': // Loading Texture coordinates (UV)
    fscanf(pFile,"%f %f\n",&u,&v);
             objMeshModel.uvs.push_back(glm::vec2(u, v));
    break;
       default:
            return false;
    }
    }
    
  5. 类似地,以f关键字开头并跟有一个空格的行表示面。一个面由三个顶点组成,每个顶点可能包含三个属性:顶点位置、纹理坐标和顶点法线。

    每个面以索引的形式存储信息。这里的索引指的是存储数组中实际元素的索引(在第 5 步中计算)。例如,给定面中顶点位置的索引为二表示objMeshModel.positions向量数组中的第三个顶点元素。

    objMeshModel.vecFaceIndex向量列表中收集面索引信息。此列表包含属于每个面的顶点属性索引的所有面。有关更多信息,请参阅ObjMesh::ScanFaceIndex函数。

  6. 使用在objMeshModel.vecFace中收集到的面索引信息,填充objMeshModel.vertices向量列表。此列表包含用于创建顶点缓冲对象的顶点属性:

        // Allocate enough space to store vertices and indices
    
      objMeshModel.vertices.resize(obMeshModl.vecFacIndex.size());
            objMeshModel.indices.resize(obMeshModl.vecFacIndex.size());
    
           // Get the total number of indices.
            objMeshModel.indexCount = objMeshModel.indices.size();
    
           // Create the interleaved vertex information
      // containing position, uv and normal.
            for(int i = 0; i < objMeshModel.vecFaceIndex.size(); i++)
            {
    //Position information must be available always
    int index = objMeshModel.vecFaceIndex.at(i + 0).vertexIndex;
    objMeshModel.vertices[i].position =
                       objMeshModel.positions.at(index);
    objMeshModel.indices[i] =
                 (GLushort)objMeshModel.vecFaceIndex.at(i).vertexIndex;
    
    // If UV information is available.
    if(objMeshModel.uvs.size()){
    index = objMeshModel.vecFaceIndex.at(i).uvIndex;
       objMeshModel.vertices[i].uv =
     objMeshModel.uvs.at(index);
    }
    
    // If Normal information is available.
    if(objMeshModel.normals.size()){
    index = objMeshModel.vecFaceIndex.at(i ).normalIndex;
    objMeshModel.vertices[i].normal =
    objMeshModel.normals.at(index);
    }
    }
    
  7. 如果 OBJ 文件中缺少法线属性,可以使用OBJMesh::CalculateNormal()来计算。有关更多信息,请参阅本食谱中的更多内容...部分。

  8. 类似地,每个顶点的切线信息是通过使用OBJMesh::CalculateTangents()计算的。你可以参考第五章中的凹凸贴图,以深入了解此函数的工作原理。

  9. 一旦 OBJ 网格信息被解析并存储在网格对象中,使用ClearMesh函数清除所有临时数据结构:

    bool OBJMesh::ClearMesh()
    {
      objMeshModel.positions.clear();  // Clear positions
      objMeshModel.normals.clear();    // Clear normals
      objMeshModel.uvs.clear();        // Clear tex Coords
      objMeshModel.indices.clear();      // Clear indices
      objMeshModel.vecFaceIndex.clear(); // Clear FaceIdx 
      return true;
    }
    
  10. 解析完 OBJ 文件后,在ObjLoader构造函数内创建 VBO:

    // Function ObjLoader::ObjLoader( Renderer* parent )
    ObjLoader::ObjLoader( Renderer* parent )
    {
    . . . . . . . 
    objMeshModel= waveFrontObjectModel.ParseObjModel(fname);
    IndexCount  = waveFrontObjectModel.IndexTotal();
    stride      = (2 * sizeof(glm::vec3) )+ sizeof(glm::vec2);
              offset      = (GLvoid*) (sizeof(glm::vec3) + sizeof(glm::vec2));
    
              // Create the VBO for our obj model vertices.
              GLuint vertexBuffer; glGenBuffers(1, &vertexBuffer);
             glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
          glBufferData(GL_ARRAY_BUFFER, objMeshModel->vertices.size()
                   * sizeof(objMeshModel->vertices[0]),
                   &objMeshModel->vertices[0], GL_STATIC_DRAW);
    
    // Create the Vertex Array Object (VAO)
              glGenVertexArrays(1, &OBJ_VAO_Id);
              glBindVertexArray(OBJ_VAO_Id);
    // Bind VBO, enable attributes and draw geometry
       glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
       glEnableVertexAttribArray(VERTEX_POSITION);
              glEnableVertexAttribArray(NORMAL_POSITION);
              glVertexAttribPointer
          (VERTEX_POSITION, 3, GL_FLOAT, GL_FALSE, stride, 0);
              glVertexAttribPointer
          (NORMAL_POSITION, 3,GL_FLOAT,GL_FALSE,stride,offset);
              glBindVertexArray(0); //Use default VAO
    

它是如何工作的...

OBJMesh类中的ParseObjMesh函数负责解析波前 OBJ 文件并将信息存储在网格的objMeshModel对象变量中。此函数解析文件并识别顶点属性,如顶点位置、纹理坐标和顶点法线。它将这些属性存储在objMeshModel.positionsobjMeshModel.uvsobjMeshModel.normals相应的向量数组中。这些向量数组在本质上连续。因此,可以直接使用索引信息来选择元素。

注意

顶点纹理和顶点法线是可选属性。没有这些属性,几何形状仍然可以生成。只有当模型包含任何纹理时,纹理坐标才会被存储。可以通过在导出选项中选择包含法线来在 OBJ 文件中保存法线。有关.OBJ 网格模型导出选项的更多信息,请参阅本章“使用 Blender 创建多边形网格”配方下的如何做...部分,以获取有关 OBJ 网格模型导出选项的更多信息。

解析顶点属性信息后,需要解析面信息。每个面由三个顶点组成。这些顶点可以包含位置、纹理和法线信息。面中每个顶点属性的信息以索引的形式存储。objMeshModel.vecFaceIndex中的面信息必须存储在向量数组列表中。

objMeshModel.vertices是另一个本质上连续且交错形式的基于向量的数组。数组中的每个记录代表一个顶点元素,它包含顶点位置、纹理坐标和法线属性。OBJMesh::CreateInterleavedArray函数负责生成此数组。交错数组非常推荐,因为它在单个数组中包含不同的属性数据,因此足以存储单个 VBO。然而,如果数据不是交错存储的,每个属性将存储在单独的数组中。对于每个数据数组,都需要单独的 VBO。使用过多的 VBO 会严重影响性能,因为渲染管线会花费更多时间绑定和切换 VBO。

OBJ 模型数据应使用GL_TRIANGLES绘制。所使用的片段着色器将根据传递的信息(例如,纹理坐标、光照信息等)在接下来的章节中提供不同的效果。我们将应用各种顶点和片段着色器到 OBJ 网格上,以产生令人惊叹的实时渲染效果:

如何工作...

还有更多...

法线信息在 3D 对象的光照着色中起着重要作用。我们导出的波前模型不包含任何法线信息。本节将帮助我们使用面信息计算法线。

法线可以通过两种方式计算:

  • 面法线: 这是通过单一的面信息计算得出的。这种类型的法线会导致平面着色。它是通过三角面形成的任意两个边的叉积来计算的。换句话说,它是垂直于由共面顶点生成的表面的。

  • 顶点法线: 这是通过取共享公共顶点的面创建的法线的平均值来计算的:还有更多...

上一张图像显示了面和顶点法线。每个面法线都是使用四个顶点计算得出的,这些顶点形成一个平面表面。在这四个顶点中,可以使用任意三个顶点形成两条边。这两条边的叉积产生一个垂直于平面的向量。归一化这个向量产生一个面法线。

相比之下,每个顶点都显示由围绕每个顶点的四个平面或四个面形成的蓝色线条表示的顶点法线。这四个平面法线的平均值产生一个顶点法线。顶点法线对于生成高度详细且平滑的几何外观非常重要,而无需太多顶点。

平滑着色或平滑着色法线可以使用 ParseObjMesh 计算,第二个参数作为布尔值 true 用于平面着色,布尔值 false 用于平滑着色。内部,这个函数调用 OBJMesh::CalculateNormal,它负责法线的数学计算:

    // Calculates the flat or smooth normal on the fly
Function OBJMesh::CalculateNormal(bool flatShading)
{
if( objMeshModel.normals.size() == 0 ){
    // Make space to store the normal information
objMeshModel.normals.resize(objMeshModel.positions.size());
int index0, index1, index2;
glm::vec3 a, b, c;
for(int i=0; i<objMeshModel.indices.size();i += 3){
    // Use indices to retrieve the vertices
          index0 = objMeshModel.indices.at(i);
          index1 = objMeshModel.indices.at(i+1);
          index2 = objMeshModel.indices.at(i+2);
    // Retrieve each triangles vertex    
          a = objMeshModel.positions.at(index0);
          b = objMeshModel.positions.at(index1);
          c = objMeshModel.positions.at(index2);
    // Calculate the normal triangle face.
         glm::vec3 faceNormal =  glm::cross((b - a), (c - a));

         if ( flatShading ){
    // Calculate normals for flat shading
             objMeshModel.vertices[i].normal += faceNormal;
             objMeshModel.vertices[i+1].normal += faceNormal;
             objMeshModel.vertices[i+2].normal += faceNormal;
          }
          else{
             objMeshModel.normals[index0] += faceNormal;
             objMeshModel.normals[index1] += faceNormal;
             objMeshModel.normals[index2] += faceNormal;
          }
}
        // Calculate normals for smooth shading
        if ( !flatShading ){
        for(int i = 0;i<objMeshModel.vecFaceIndex.size(); i++){
        int index=objMeshModel.vecFaceIndex.at
(i +0).vertexIndex;
        objMeshModel.vertices[i].normal=
                               objMeshModel.normals.at(index);
      }
   }
          // Store the calculated normal in normalized form
for (int j=0;j<objMeshModel.vertices.size(); j++){
objMeshModel.vertices[j].normal = 
glm::normalize (objMeshModel.vertices[j].normal);
}
}    
}

面法线指向多边形面向的方向。然而,顶点法线改变了多边形的梯度。如果我们改变顶点法线的方向,该顶点周围的着色将 http://change.at/" \t "_blank,这个梯度与旋转相同方向的一个平面多边形相同。计算机在多边形上伪造了一个梯度。

以下图像显示了左侧的简单球体在没有光照着色技术的情况下看起来如何。实际上,很难相信它是一个球体网格模型。中间和最右侧的网格模型使用光照着色进行演示。前面的网格模型使用平面光照着色,这是通过面法线实现的,而后面的网格模型以顶点法线渲染相同的网格模型:

还有更多...

参见

  • 请参阅 使用顶点缓冲对象进行高效渲染 菜谱 第二章, OpenGL ES 3.0 基础

  • 请参阅 第五章 中的 Phong 着色 – 每片段着色技术 菜谱,光照和材质

  • 请参阅 第五章 中的 Gouraud 着色 – 每片段着色技术和 Phong 着色 – 每片段着色技术 菜谱,光照和材质

  • 请参阅 第七章 中的 实现凹凸映射 菜谱,纹理和映射技术

  • 参考第三章中的使用顶点数组对象管理 VBO配方,OpenGL ES 3.0 的新特性

渲染 3Ds 网格模型

3Ds 网格格式是计算机图形中广泛使用的知名 3D 网格文件格式。与 wavefront 不同,它不是基于文本的,并以二进制形式存储网格信息。这被广泛用于 Autodesk 3D Studio Max,是一款专业的 3D 图形程序软件,用于创建 3D 动画和模型。

文件格式:本节将概述 3Ds 文件格式。这种网格格式包含以块层次结构形式的信息。块是内存中结构化信息的一部分。其唯一的 ID 识别每个块,其中包含可用于读取或跳过块的大小信息(以字节为单位)。当前块的大小信息始终相对于其起始内存位置;跳过这么多大小将指向下一个块。

下表显示,每个块都由开始字段表示,该字段包含 3Ds 文件中的内存位置。大小字段告诉我们块的大小(以字节为单位),而结束字段指定了块结束的内存位置。结束字段可以通过大小 - 开始 + 1公式计算得出。下一个块信息始终相对于当前块位置:

开始 结束 大小 名称
0 1 2 块 ID
2 5 4 下一个块

3Ds 文件中的每个块 ID 都与它相关的预定义意义。例如,此文件格式的第一个块 ID 始终是0x4d4d。这个块被称为主要或主块 ID。其他重要的块作为子节点存在于这个主要块下,如下面的截图所示:

渲染 3Ds 网格模型

注意

3Ds 的详细规范超出了本书的范围。您可以在www.martinreddy.net/gfx/3d/3DS.spec找到有关此规范更多信息。

准备工作

在本配方中,我们将使用名为lib3ds的第三方库解析 3Ds 文件格式。这是一个开源库,帮助我们解析文件,并以数据结构的形式提供文件数据。这个库是用 ANCI-C 编写的。因此,它可以在各个平台上移植。lib3ds 可以在 GNU 较小通用公共 许可证LGPL)下用于商业应用。可以从code.google.com/p/lib3ds/下载这个库。

在我们的 GLPI 框架中,这个库位于GLPIFramework/3DSParser/lib3ds文件夹下。我们为这个库使用了 1.30 版本。将 3DS 网格模型渲染到应用程序中需要将这些模型存储在设备或模拟器上的某个适当位置。

在 Android 设备上,您可以将 3DS 网格模型文件存储在 sdcard/GLPIFramework/Model 文件夹下。对于 iOS,可以使用 文件 | 将文件添加到 [项目名称] 来将这些模型添加到项目中。

Android:在 Android 平台上,我们需要 makefile 来构建 lib3ds 库。在 GLPIFramework/3DSParser/lib3ds 下添加 Android.mk makefile。编辑此 makefile,如下所示。此库将被编译为共享库,命名为 mylib3ds。您也可以直接在主项目 makefile 中添加源代码,而不是编译共享库:

Android.mk:

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
# Name of the shared library
LOCAL_MODULE    := mylib3ds
LOCAL_SRC_FILES := \
   lib3ds/viewport.c \
   lib3ds/vector.c \
   lib3ds/tracks.c \
   lib3ds/tcb.c \
   lib3ds/shadow.c \
   lib3ds/quat.c \
   lib3ds/node.c \
   lib3ds/mesh.c \
   lib3ds/matrix.c \
   lib3ds/material.c \
   lib3ds/light.c \
   lib3ds/io.c \
   lib3ds/file.c \
   lib3ds/ease.c \
   lib3ds/chunk.c \
   lib3ds/camera.c \
   lib3ds/background.c \
   lib3ds/atmosphere.c
# Included libraries and compile time flags
LOCAL_LDLIBS := -lz
LOCAL_CFLAGS := -I. -g
# Build as shared library
include $(BUILD_SHARED_LIBRARY)

打开位于 JNI 文件夹下项目目录中的 Android.mk makefile,并包含我们之前代码中创建的 lib3dsAndroid.mk 文件路径:

MY_CUR_LOCAL_PATH := $(call my-dir)
FRAMEWORK_DIR   = ../../../../GLPIFramework
LIB3DS_DIR = $(FRAMEWORK_DIR)/3DSParser

include $(CLEAR_VARS)

include$(MY_CUR_LOCAL_PATH)/../GLPIFramework/zlib/Android.mk \
$(MY_CUR_LOCAL_PATH)/../GLPIFramework/3DSParser/Android.mk
# Source file for compilation
LOCAL_SRC_FILES := $(FRAMEWORK_DIR)/GLutils.cpp \
. . . . .
. . . . .
$(SCENE_DIR)/Renderer.cpp \
$(SCENE_DIR)/3DSLoader.cpp \
../../NativeTemplate.cpp
# Include the 3DS library
LOCAL_SHARED_LIBRARIES := zlib mylib3ds

GLESNativeLib.java 文件中,编辑 GLESNativeLib 类并添加我们共享库的引用,以便在运行时进行链接:

public class GLESNativeLib {
static {
   System.loadLibrary("zlib");
   System.loadLibrary("mylib3ds");
   System.loadLibrary("glNative");
}
   . . . . . . Other code
}

iOS:在 iOS 平台上,您需要使用 文件 | 将文件添加到 [项目名称]lib3ds 库源文件添加到您的项目中。

如何操作...

这里是创建 3Ds 网格渲染器类的步骤。此类将负责加载和渲染 3Ds 网格模型:

  1. 对于这个配方,我们从 Scene 类派生了一个新的类,名为 Loader3DS。这个类包含一个 load3dsModel 函数,该函数将用于加载 3Ds 网格模型文件。

  2. 实现如以下代码片段中描述的 load3dsModel 函数。此函数使用 lib3ds 库的 lib3ds_file_load 函数解析 3Ds 模型文件,并返回 Lib3dsFile 指针。Lib3dsFile 结构体包含解析后的 3Ds 网格文件信息:

    Lib3dsFile* Loader3DS::load3dsModel(const char* fileName){
       Lib3dsFile* file = lib3ds_file_load(fileName);
       if (!file) {
       LOGI("*ERROR*\nLoading file %s failed\n", fileName);
          return NULL;
       }
       . . . . . .
       . . . . . . 
       return file;
    }
    
  3. 当 3Ds 网格文件解析成功后,它将加载文件对象类型 Lib3dsFile 中的网格模型的数据(几何属性和材质信息)。此对象包含读取节点所需的所有必要信息。在 3Ds 规范中,节点是一个称为 Lib3dsNode 的特殊数据结构,对应于完整 3D 网格模型的子部分或子模型。例如,一个 car 模型由许多不同的子部分组成,如车身框架、轮胎、车门、引擎等。这些各个部分对应于代表 3D 汽车模型的父节点的子节点。

    根据模型的复杂度,可能会有多个节点。这些节点以层次结构排列。节点使用 lib3ds_node_new_object() 创建,并使用 lib3ds_file_insert_node() 以层次顺序排列:

    if( !file->nodes )
    {
       Lib3dsMesh *mesh;
       Lib3dsNode *node;
       for(mesh=file->meshes; mesh!=NULL; mesh=mesh->next){
          node = lib3ds_node_new_object();
          strcpy(node->name, mesh->name);
          node->parent_id = LIB3DS_NO_PARENT;
          lib3ds_file_insert_node(file, node);
       }
    }
    
  4. 通过递归遍历所有节点来渲染 3Ds 网格模型。调用 RenderNodes() 来渲染每个节点:

    void Loader3DS::Render(Lib3dsFile* file)
    {
       . . . . . . .
       Lib3dsNode *p;
       for (Lib3dsNode* p=file->nodes; p!=0; p=p->next ){
           RenderNodes( file, p );
       }
       . . . . . . .
    }
    
  5. RenderNodes() 是一个递归函数,为每个节点创建 VBO 并渲染它们。每个节点都包含一个指向 Lib3dsMesh 的指针;Lib3dsMesh 是一个包含每个节点几何信息的数据结构:

    void RenderNodes(Lib3dsFile* file,Lib3dsNode *node){
       . . . . . . . 
    // Use appropriate shader
    glUseProgram( program->ProgramID );
    
    Lib3dsNode *tempNode;
    for(tempNode=node->child;tempNode!=0;
    tempNode=tempNode->next)     {
          RenderNodes(file, tempNode);
                                 }
    }
    
  6. Lib3ds库包含一个名为Lib3dsUserData的结构。它允许你向lib3ds添加自定义变量。我们将使用这个结构来存储顶点缓冲对象变量,如顶点:

    // Check the user.p variable if empty is assigned
    MyLib3dsUserData 
    if (!mesh->user.p){
      MyLib3dsUserData* myPObject = new MyLib3dsUserData;
      mesh->user.p = (void*)myPObject;
    }
    
  7. 使用BuildMesh()函数为每个网格构建 VAO。将 VAO、VBO 和 IBO 信息缓存到MyLib3dsUserData对象中:

    void Loader3DS::BuildMesh(Lib3dsMesh *mesh)
    {
     MyLib3dsUserData* userObj=(MyLib3dsUsrData*)mesh->user.p;
    
        // Allocation memory for vertex positions
        meshVert = new float[ mesh->points * 3 ];
       . . . . .
    
        // Allocate memory for texture
        meshTexture = new float[ mesh->texels * 2 ];
        . . . . .
    
        // Allocate memory for normal
        meshNormal = new Lib3dsVector[ 3 * mesh->faces ];
        lib3ds_mesh_calculate_normals(mesh, meshNormal);
    
        // Allocate memory for face information
        faceIndex = new unsigned short[mesh->faces*3];
        . . . . .
    
        // Create the VBO and populate the VBO data 
        glGenBuffers( 1, (GLuint *)&vId );
        glBindBuffer( GL_ARRAY_BUFFER, vId );
        . . . . .
    
        // Create and populate the IBO with index info. 
        glGenBuffers( 1, (GLuint *)&iId );
        glBindBuffer( GL_ARRAY_BUFFER, iId );
        . . . . .
    
        // Create and Bind Vertex Array Object
        glGenVertexArrays(1, &VAOId);
        glBindVertexArray(VAOId);
        . . . . .
    
        // Cache the information in the User data structure
        userObj->vertexId   = vId;
        userObj->indexId    = iId;
        userObj->VAOId      = VAOId;
        . . . . .
    }
    
  8. RenderNodes()中,使用 VAO 信息渲染 3Ds 网格模型:

     MyLib3dsUserData* userObj=(MyLib3dsUserData*)mesh->user.p;
     // If VAO is not created, create using BuildMesh. 
     if ( !userObj->VAOId ) {
           BuildMesh( mesh );
     }
     else {
          // Apply Transformation & set material information
          SetMaterialInfo( mesh );
          //Bind to VAO & draw primitives
          glBindVertexArray(userObj->VAOId);
          glDrawElements(GL_TRIANGLES, userObj->indexNum,
                      GL_UNSIGNED_SHORT, (void*)0);
          glBindVertexArray(0); //Bind to default VAO
     }
    

它是如何工作的...

使用 lib3ds 库的lib3ds_file_load函数解析 3Ds 网格文件。此函数成功填充了包含从 3Ds 文件中解析的所有信息的Lib3dsFile文件指针。使用这个数据变量,我们创建节点,并使用lib3ds_file_insert_node按层次顺序填充和组装这些节点。每个节点代表一个网格,它从节点结构中读取并缓存为顶点数组对象VAO)。每个 VAO 存储顶点缓冲对象VBO)、状态和属性。

RenderNodes是一个递归函数,它为每个节点创建 VAO 和 VBO 并渲染它们。每个节点包含一个指向Lib3dsMesh的指针,它进一步包含一个Lib3dsUserData,我们将使用它来检查相应的节点是否包含 VAO。VAO 通过顶点数组 ID 识别。这些 ID 绑定到运行时并用于渲染几何形状。一旦生成 VAO ID,这些 ID 就存储在每个节点的Lib3dsUserData结构中:

它是如何工作的...

还有更多...

到目前为止,在这个菜谱中,你学习了如何渲染 3Ds 网格模型。我们使用单色进行了渲染。3Ds 文件格式还提供了渲染带有颜色的面的功能。这些信息存储在Lib3dsMeshLib3dsMaterial中。以下代码展示了如何从材质数据结构中读取材质信息,并将其作为统一变量发送到3dsFragmentShader.glsl以应用于面颜色:

void Loader3DS::SetMaterialInfo( Lib3dsMesh *mesh )
{
   Lib3dsMaterial *material = 0;
   if ( mesh->faces ) {
      // Get associated material with the mesh
      material = lib3ds_file_material_by_name
                      (file, mesh->faceL[0].material);
   }

   if( !material ){
       return;
   }

   // Set Ambient, Diffuse and Specular light component
   glUniform4f(UniformKa, material->ambient[0], 
   material->ambient[1], material->ambient[2], 
   material->ambient[3]);
   glUniform4f(UniformKd, material->diffuse[0], 
   material->diffuse[1], material->diffuse[2], 
   material->diffuse[3]);
   glUniform4f(UniformKs,material->specular[0],
   material->specular[1], material->specular[2], 
   material->specular[3]);
   glUniform1f(UniformKsh, material->shininess);
}

颜色信息以材质颜色的形式存储。有关光和材质的更多信息,请参阅第五章,光和材质。以下图像展示了一个带有彩色面的汽车模型,其中网格模型使用三角形、线和点原语进行渲染:

还有更多...

注意

枢轴位置:枢轴位置将网格模型渲染到平移信息中。这有助于网格以正确的位置渲染。没有枢轴定位,每个节点都在原点渲染。这种行为导致所有节点网格都聚集在原点,因为每个模型不知道其相对于其他模型的位置。

参见

  • 请参阅第三章中的使用顶点数组对象管理 VBO菜谱,OpenGL ES 3.0 的新特性

第五章:光与材料

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

  • 实现每顶点环境光分量

  • 实现每顶点的漫反射光分量

  • 实现每顶点的镜面光分量

  • 使用半向量技术优化镜面光

  • Gouraud 着色 - 每顶点着色技术

  • Phong 着色 - 每片段着色技术

  • 实现方向光和点光源

  • 在场景中实现多个光源

  • 实现双面着色

简介

本章将介绍 3D 图形中光和材料的概念。我们将从物理学的角度理解光的概念及其双重性质。我们将讨论不同类型的光分量,如环境光、漫反射光和镜面光,以及它们的实现技术。在本章的后面部分,我们将介绍一些重要的常见照明技术(如 Phong 着色和 Gouraud 着色)。这将帮助我们实现看起来逼真的照明模型。此外,你将了解方向光和位置光之间的区别,并了解如何通过使用半向量技术来优化镜面光。在本章的最后,我们将演示如何在场景中设置多个光源并使用双面着色渲染对象。

光是一种电磁辐射;它存在一个巨大的频率或波长的范围。人眼只能看到电磁波谱中的一部分波长,这部分波长的范围被称为可见光。我们的眼睛将这些可见波长接收为颜色,可见光光谱从 400 nm(紫色)到 700 nm(红色)变化:

简介

光具有重要的特性(如强度、方向、颜色和位置)。在 3D 图形中,我们使用这些重要的光特性来模拟各种光模型。在本章中,我们将使用 OpenGL ES 可编程管道通过着色器编程各种光模型。这将有助于深入了解用于照明目的所需的所有数学和物理学。

在 17 世纪,人们认为颜色是光和黑暗的混合物。在 1672 年,艾萨克·牛顿爵士发表了一系列实验,并为我们提供了对光的现代理解。他成功地证明了白光是由七种不同颜色的混合物组成的:红、橙、黄、绿、蓝、靛和紫。他还提出了光是由粒子或微粒组成的。

在 1802 年很久以后,托马斯·杨通过他的一个实验证明了光的行为像波。他将颜色与波长联系起来,并设法计算了由艾萨克·牛顿爵士发现的七种颜色的近似波长。

光的最终理论是由阿尔伯特·爱因斯坦在 1905 年 3 月提出的。那年,他发表了光的量子理论,其中他提出光作为粒子,并将这些粒子命名为光子。1905 年 6 月,他完成了他的狭义相对论理论,这给他的早期关于光被认为是粒子的提议增加了一个转折。狭义相对论将光视为波。这种矛盾为爱因斯坦提供了足够的证据来提出光的二象性。据他所说,光既表现为粒子又表现为波:

简介

光具有二象性;它可以同时表现为粒子又表现为波。让我们更详细地看看:

  • 光作为粒子:光表现为粒子。这些粒子是能量的小包,与原子的小物理粒子不同。这些能量包具有恒定的速度和没有质量,表现出类似于台球游戏中使用的台球反射特性。当粒子相互碰撞时,它们沿着力的方向传播,并由于障碍物而反射。当光子粒子撞击障碍物时,它们以吸收的形式损失能量。由于持续的反射,这些粒子撞击并减小。由于碰撞,这些粒子从障碍物中获取能量并保持能量守恒定律。

  • 光作为波:光表现为波。它们是具有电和磁性质的电磁波。电磁波不需要任何介质来穿越空间,因为它们能够穿越真空。每个波看起来像正弦波。波的强度用振幅来测量,如图所示。一个完整正弦波的长度称为波长。波长越大,颜色越明显。将光视为 3D 计算机图形中的波可以打开许多可能性,这是光粒子性质所不能实现的。例如,粒子表现出以射线形式传播;它不能模拟衍射和干涉,这是波的重要特性。

在计算机图形模拟中,光波的特性由存储为复数二维数组的波前表示。计算机图形中光的研究本身就是一个庞大的主题;涵盖基于波的照明超出了本章的范围。本章将帮助建模基于粒子的局部光照明建模技术。

光由三种类型的成分组成:环境光(A)、漫反射光(D)和镜面反射光(S)。它们如下所述:

  • 环境光(A):这种光成分从所有方向均匀地发出,并且被它所落到的物体均匀地散射到所有方向;这使得物体表面看起来具有恒定的光强度。

  • 漫反射(D):这个光分量来自光源的特定方向。它以可变强度撞击物体的表面,这取决于朗伯照明定律。换句话说,强度取决于光出现在物体表面的方向和物体表面点的方向。

  • 镜面反射(S):这个光分量也来自特定的方向,并在相机视角或观察者眼睛的方向上反射得最多。它给模型表面带来光泽效果:简介

在计算机图形学中,光和材料都被数学上视为颜色。与物体相关的颜色称为材质,与照明相关的颜色称为光。光和材质的颜色强度用 RGB(红、蓝、绿)分量来指定。物体之所以可见,是因为它们反射了落在它们身上的光。例如,当阳光照在一个绿色的材质颜色球上时,绿色材质吸收了所有其他波长,并反射了光光谱中的绿色部分。因此,它对观众来说看起来是绿色的。从数学上讲,反射或最终的颜色是光和材质颜色强度的乘积:

*Reflected color                   =           Light intensity      *       Material color*
*[R1*R2, G1*G2, B1*B2]                         [R1, G1, B1]                 [R2, G2, B2]*

简介

在现代计算机图形学中,有两种计算光照着色方程式的方法:顶点着色和片段着色。它们如下所述:

  • 顶点光照着色:在这种着色类型中,计算光照颜色的数学方程式是在顶点着色器中制定的。每个顶点颜色在顶点着色器内计算,然后传递到片段着色器。这些顶点颜色随后被插值到几何面,以得到每个片段或像素颜色。由于颜色是在顶点着色器中计算的,因此称为顶点着色。

  • 片段光照着色:这为每个片段在片段着色器内计算光颜色。片段着色的质量比顶点着色好得多。片段着色的性能比顶点着色慢。这是因为处理顶点比处理数千个像素要快。在今天的现代图形中,处理器能够以闪电般的速度执行多个并行操作;因此,对于通用应用需求来说,这可能并不非常昂贵。

    注意

    每个顶点光照着色的一个缺点是,它可能对生成预期的镜面反射光着色没有帮助,因为片段颜色是在每个顶点计算的,并在面之间共享;因此,它不会生成平滑的椭圆形发光表面,而会生成一个平坦的发光表面。

实现顶点环境光分量

环境光在应用到的所有方向上均匀照亮物体的表面。所有面都接收相等数量的光线;因此,在整个物体上观察不到颜色的变化。环境光基本上是两个组件的混合:光的颜色强度和材质。

注意

从数学上讲,这是环境光(L[a])和环境材质(K[a])的乘积。

I[a] = L[a]K[a]

环境光在 phong 和 gouraud 渲染中起着至关重要的作用;这些渲染的漫反射和镜面颜色分量是通过使用照射到物体上的光的方向来计算的。因此,物体的一侧或背面可能会因为光的方向而接收较少或没有光线。在这种情况下,由于产生的黑色光线,这些面可能会看起来不可见;选择正确的环境光和材质颜色将有助于使这些暗面变得可见。

准备工作

本章将使用我们在 第四章 中实现的 Wavefront 3D 网格模型,处理网格。我们将重用该章节中的 ObjLoader 菜谱来实现本章的新菜谱。

如何做...

环境光的逐步实现如下:

  1. 重用前一章中的 ObjLoader 菜谱,创建一个新的顶点着色器文件,名为 AmbientVertex.glsl,并添加以下代码:

    // Geometry vertex position
    layout(location=0) in vec4 VertexPosition;
    uniform mat4 ModelViewProjectionMatrix;  
    
    // Ambient Light and Material information
    uniform vec3 MaterialAmbient, LightAmbient;
    
    // Shared calculated ambient from vertex shader
    out vec4 FinalColor;
    void main(){
       // Calculate the ambient intensity 
       vec3 ambient = MaterialAmbient  * LightAmbient;
       FinalColor   = vec4(ambient, 1.0);
       gl_Position  = ModelViewProjectionMatrix*VertexPosition;
    }
    
  2. 类似地,创建 AmbientFragment.glsl 片段着色器文件如下:

    precision mediump float;
    in vec4 FinalColor;
    layout(location = 0) out vec4 outColor;
    void main() {
    outColor = FinalColor; // Apply ambient intensity
    }
    
  3. ObjLoader 类的 InitModel() 中,编译这些着色器并设置环境光和材质的统一变量参数:

    void ObjLoader::InitModel(){
      // Compile AmbientVertex and AmbientFragment shader.
      Many line skipped here . . . . . 
      // Use the shader program
      glUseProgram( program->ProgramID ); 
    
      // Query uniforms for light and material 
      MaterialAmbient = GetUniform(program,("MaterialAmbient");
      LightAmbient    = GetUniform(program,"LightAmbient");
    
      // Set Red colored material 
      if (MaterialAmbient >= 0)
      { Uniform3f(MaterialAmbient, 1.0f, 0.0f, 0.0f); }
    
      // Set white light 
      if (LightAmbient >= 0)
      { glUniform3f(LightAmbient, 1.0f, 1.0f, 1.0f); }
    
      // Get Model-View-Projection Matrix location
      MVP = GetUniform(program, "ModelViewProjectionMatrix");
    }
    
  4. Render() 函数与之前相同;它使用 VAO 渲染 Wavefront OBJ 模型。

如何工作...

当创建 ObjLoader 类对象时,它会在构造函数中初始化必要的参数。InitModel 函数编译着色器程序并设置任何必要的统一变量;顶点着色器包含两个名为 MaterialAmbientLightAmbient 的统一变量。前者用于定义物体的材质属性的环境颜色属性,后者用于指定光的颜色。

这些变量被发送到顶点着色器,环境颜色阴影被计算为这两个变量的乘积;结果存储在一个新的输出变量中,称为 FinalColor。该变量被发送到片段着色器,并作为最终颜色应用于每个片段。gl_position 是裁剪坐标值,它是顶点位置和 ModelViewProjectionMatrix 的乘积。ModelViewProjectionMatrix 统一变量是投影、视图和模型矩阵的乘积。

如何工作...

参考信息

  • 请参阅 第四章 中 渲染 wavefront OBJ 网格模型 的菜谱。

  • 请参考第三章中的使用顶点数组对象管理 VBO配方,OpenGL ES 3.0 的新特性

  • 请参考第二章中的使用顶点缓冲对象进行高效渲染配方,OpenGL ES 3.0 基础知识

实现每个顶点的漫反射光分量

漫反射光来自特定方向,在撞击物体表面后向各个方向反射。在本节中,我们通过使用 Bui Tuong Phong 于 1973 年开发的 Phong 反射模型来模拟这种行为。该模型提出了一种使用正常表面和入射光方向的光照着色技术。当光击中物体的表面时,其中一部分被反射,其余部分部分吸收。因此,如果给出其中一个分量,我们可以计算吸收或反射的光强度。

注意

总光强度 = 反射光强度 + 吸收光强度

当 100%的光强度落在平面上,并且其中 50%被反射时,很明显有 50%的光强度被吸收或损失在周围环境中。在 3D 图形中,我们只关心反射的光强度,因为我们看到物体是由于光在其表面上的反射。光的漫反射和镜面反射分量基本上使用 Phong 反射模型,这是由于光和表面相互作用来模拟光照技术。

Phong 反射模型使用拉姆伯特余弦定律来演示反射。拉姆伯特余弦定律使用入射光的方向和表面几何形状的方向来计算几何表面上的光强度。

注意

拉姆伯特余弦定律指出,漫反射表面的光照强度与表面法线向量与光方向所成的角的余弦值成正比。

实现每个顶点的漫反射光分量

准备工作

计算漫反射光的一般数学方程是:

I[d] = L[d]Kd

L[d]K[d]是光和材料的漫反射分量;(N.S)是用于计算表面法线(N)和入射光向量(S)之间角度余弦值的点积;这两个向量在计算点积之前必须先归一化。归一化向量是一个长度为 1 的向量;它也称为单位向量。对于这个配方,我们将重复使用我们的第一个配方,即环境光,并进行修改,如下一节所述。

如何操作...

使用以下说明来实现漫反射光分量:

  1. 重复使用上一个配方中的每个顶点环境光分量(环境光配方),并在其中创建一个新的顶点着色器文件,命名为DiffuseVertex.glsl,如下面的代码所示:

    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    
    uniform mat4 ModelViewProjectionMatrix;
    uniform mat4 ModelViewMatrix;
    uniform mat3 NormalMatrix;
    
    // Diffuse Light and Material information
    uniform vec3 MaterialDiffuse, LightDiffuse;
    
    // Position of the light source
    uniform vec3 LightPosition;
    
    out vec4 FinalColor; // Output color to frag. shader
    
    void main(){
       // Calculate normal, eye coord and light vector
       vec3 nNormal   = normalize ( NormalMatrix * Normal );
       vec3 eyeCoord  = vec3 (ModelViewMatrix* VertexPosition);
       vec3 nLight    = normalize( LightPosition - eyeCoord );
    
       // Calculate cosine Normal and light vector
       float cosAngle = max( 0.0, dot( nNormal, nLight ));
       vec3 diffuse = MaterialDiffuse  * LightDiffuse;
       FinalColor   = vec4(cosAngle * diffuse, 1);
       gl_Position = ModelViewProjectionMatrix*VertexPosition;
    }
    
  2. 片段着色器没有变化;我们可以从上一个配方中重用它,除了我们将将其重命名为 DiffuseFragment.glsl

  3. 在着色器编译成功后的 InitModel 中,设置扩散光和材料颜色的配置,并指定光在世界坐标中的位置:

      // ObjLoader::InitModel()
      . . . . 
      glUseProgram( program->ProgramID );
    
      // Query Light and Material uniform for ambient comp.
      MaterialDiffuse  = GetUniform(program, "MaterialDiffuse");
      LightDiffuse     = GetUniform(program, "LightDiffuse");
      LightPosition    = GetUniform(program, "LightPosition");
    
      // Set Red colored diffuse material uniform 
      glm::vec3 color = glm::vec3(1.0, 0.0, 0.0);
      if (MaterialDiffuse >= 0)
          { glUniform3f(MaterialDiffuse,1.0, 0.0, 0.0); }
    
      // Set white diffuse light
      if (LightDiffuse >= 0)
          { glUniform3f(LightDiffuse, 1.0f, 1.0f, 1.0f); }
    
      // Set light position
      glm::vec3 lightPosition(0.0, 0.0, 5.0);
      glUniform3fv(LightPosition,1,(float*)&lightPosition);
    
  4. Render() 函数中,指定法线矩阵、模型视图矩阵和模型视图投影矩阵,以及通用的顶点属性:

       // ObjLoader::Render()   
       mat3 matrix=*(TransformObj->TransformGetModelViewMatrix());
       mat3 normalMat = glm::mat3( glm::vec3(matrix[0]),
                     vec3(matrix[1]), glm::vec3(matrix[2]) );
       glUniformMatrix3fv(NormalMatrix,1,GL_FALSE,
                         (float*)&normalMat );
       glUniformMatrix4fv( MV,1,GL_FALSE,(float*)TransformObj->
                               TransformGetModelViewMatrix() );
       glUniformMatrix4fv( MVP,1,GL_FALSE,(float*)TransformObj->
       TransformGetModelViewProjectionMatrix());
    
       // Bind with Vertex Array Object and Render
       glBindVertexArray(OBJ_VAO_Id);    
       glDrawArrays(GL_TRIANGLES, 0, IndexCount );
    

如何工作...

扩散光顶点着色器使用顶点位置、顶点法线和光位置,通过使用 Phong 反射模型来计算光照着色;每个 VertexPosition 都通过乘以 ModelViewMatrix 转换为眼睛坐标。同样,顶点法线也需要转换为眼睛坐标,以便变换也应用于法线。这是通过将 Normal 乘以 NormalMatrix 来实现的。

注意

与顶点位置不同,顶点法线是通过使用 NormalMatrix 来转换的,而顶点位置是通过使用 ModelView 矩阵转换成眼睛坐标的。法线矩阵是模型视图矩阵的子矩阵,但它的特点是当应用仿射变换时,它保留了几何体的法线。NormalMatrix 是模型视图矩阵左上角 3x3 矩阵的逆转置。

nLight 光向量是通过从 LightPosition 减去 eyeCoord 顶点位置的眼睛坐标来计算的;nLight 方向是从表面到光源的方向。在计算它们之间的余弦角之前,nLightnNormal 必须被归一化,以便找到它们之间的余弦角。

光强度存储为表面法线向量和光向量之间的余弦角。材料和光的颜色信息指定在两个统一变量中,即 MaterialDiffuseLightDiffuse;这两个变量的乘积存储在新的变量中,称为扩散。余弦角是通过计算 nLightnNormal 的点积并存储在 cosAngle 变量中得到的。

注意

光和材料的强度基本上是以 RGB 分量的形式使用的,这些分量总是非负的。R、G 和 B 的每个分量都存储为介于 0.0f1.0f 之间的浮点数。光强度是作为余弦函数计算的,这可能导致介于 -1 和 1 之间的范围值。我们不希望有负的光强度,因为它们没有意义。因此,我们应该只考虑 0.0 和 1.0 范围内的光强度;因此,在最终的光强度中使用 max() 函数。

如何工作...

漫射颜色阴影是漫射和 cosAngle 的乘积,并存储在一个新的输出变量 FinalColor 中。该变量被发送到片段着色器并应用于每个片段。顶点着色器的最后一行通过将顶点位置与模型视图投影矩阵相乘来帮助计算裁剪坐标。

参考以下内容

  • 实现每个顶点的环境光分量

实现每个顶点的镜面光分量

镜面光负责在物体表面产生光泽。与使用入射光线和表面法线来找到光强度的漫射光不同,镜面光使用反射光线和观察者的方向来找到光强度。

准备中

下图说明了将观察者的位置(相机)引入画面以演示镜面光数学计算的情景。光线入射角与表面法线的夹角始终等于与同一法线的反射角。因此,SR 向量都与 N 形成相同的 θ 角。S 向量表示相反方向(-S);这是因为我们感兴趣的是计算R反射向量:

准备中

这种光泽取决于观察者与反射光之间的角度;如果观察者向量与反射向量的夹角小,则表面越亮。

在 Phong 反射模型中,数学上,镜面分量的反射向量(R)的计算如下:

R = 2N (N.S) + (-S)

然而,在 OpenGL ES 着色语言中,我们可以使用 reflect() 函数来计算向量 R

R = reflect( -S, N )

RV 向量之间的 α 角度可以通过这两个向量的点积来计算。V 向量在眼睛坐标系中;与 R 向量方向相同的顶点越接近,表面就会越有光泽。给定 RV,镜面照明可以通过数学方法计算如下:

I[s] = L[s]Ks[G]

在前一个公式中的 G 上标用于光泽因子;其实际意义是在物体表面产生较大或较小的光泽点。其值介于 1 到 200 之间;数值越大,光泽点越小越亮,反之亦然。

如何操作...

重用之前实现的漫射着色器配方,并在着色器和程序代码中根据以下步骤进行必要的更改:

  1. 创建 SpecularVertex.glsl 并使用以下指令进行顶点着色器;片段着色器没有变化。我们可以重用现有的代码:

    #version 300 es
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    
    uniform mat4    ModelViewProjectionMatrix, ModelViewMatrix;
    uniform mat3    NormalMatrix;
    
    // Specular Light and Material information
    uniform vec3 MaterialSpecular, LightSpecular,LightPosition;
       uniform float   ShininessFactor;
    out vec4        FinalColor;
    
    void main() 
    {
          vec3 nNormal = normalize( NormalMatrix * Normal );
          vec3 eyeCoord= vec3( ModelViewMatrix* VertexPosition );
          vec3 nLight  = normalize( LightPosition - eyeCoord);
          vec3 V       = normalize( -eyeCoord);
          vec3 R       = reflect( -nLight, nNormal );
    
          float sIntensity=pow(max(0.0,dot(R,V)),ShininessFactor);
          vec3 specular= MaterialSpecular * LightSpecular;
          FinalColor   = vec4( sIntensity * specular, 1 );
    
          gl_Position  = ModelViewProjectionMatrix*VertexPosition;
    }
    
  2. InitModel, 中加载并编译镜面着色器,并设置镜面光和材质颜色的配置。同时,指定光在世界坐标系中的位置:

        // ObjLoader::InitModel()
        . . . . .  
    
        if (MaterialSpecular >= 0)
              { glUniform3f(MaterialSpecular, 1.0, 0.5, 0.5); }
    
        if (LightSpecular >= 0)
              { glUniform3f(LightSpecular, 1.0, 1.0, 1.0); }
    
        if (ShininessFactor >= 0)
              { glUniform1f(ShininessFactor, 40); }
    
        if (LightPosition >= 0){
           glm::vec3 lightPosition(0.0, 0.0, 10.0);
              glUniform3fv(LightPosition,1,&lightPosition);
        }
    

它是如何工作的...

光滑光顶点着色器以与之前配方中相同的方式计算 nNormaleyeCoordnLight。通过使用 reflect() 函数,通过归一化眼坐标和 R 反射向量来计算观察者或 (V) 相机的方向。RV 的点积通过 max 函数限制在 0.0 和 1.0 的范围内。这个结果用于计算带有 ShininessFactor 的功率函数,该函数负责在表面上产生光泽点;计算结果存储在 sIntensity 中。FinalColor 通过 sIntensityMaterialSpecularLightSpecular 的乘积来计算。这些颜色信息作为输出变量发送到片段着色器,并应用于由顶点形成的原语创建的相应片段:

它是如何工作的...

参见

  • 实现每个顶点的环境光分量

  • 实现每个顶点的漫反射光分量

使用半程向量优化镜面光

在之前的配方中实现的镜面反射照明使用入射光线的反射向量来展示点状照明。这个反射向量是通过 GLSL 中的 reflect() 函数计算的。这个函数的计算稍微有些昂贵。因此,我们不仅可以计算反射和 (R.V) 相机向量之间的点积,还可以计算 (nNormal.H),即我们的表面法线向量和半程向量之间的点积。H 半程向量是相机(观察者)向量与入射光之间的向量。在下面的图中,你可以看到 VS 向量的结果(注意:不是 -S):

使用半程向量优化镜面光

从数学上讲,半程向量是计算如下:

半程向量 (H) = 入射光向量 (S) + 相机向量 (V)

计算半程镜面光的方程是:

H = S + V

I[s] = L[s]K[s] ( N.H )[G]

使用半程向量优化镜面光

如何做...

使用之前的配方,实现 每个顶点的镜面光分量,并在 SpecularVertex.glsl 中进行以下更改。以下代码中的更改以粗体标注。在片段着色器中不需要进行任何更改:

// No change in the global variables
. . . . . .
void main() 
{
   vec3 nNormal = normalize( NormalMatrix * Normal );
   vec3 eyeCoord= vec3( ModelViewMatrix * VertexPosition );
   vec3 nLight  = normalize( LightPosition - eyeCoord);
   vec3 V       = normalize( -eyeCoord);
   vec3 H       = normalize (nLight + V);
   float sIntensity = 0.0;
   sIntensity=pow(max(0.0,dot(H,nNormal)),ShininessFactor);

   vec3 specular   = MaterialSpecular * LightSpecular;
   FinalColor      = vec4( sIntensity * specular, 1 );
   gl_Position     = ModelViewProjectionMatrix * VertexPosition;
 }

它是如何工作的...

在这个技术中,我们使用 nLight 入射光向量和 (V) 相机向量通过相加来找到 (H) 结果向量。这两个向量都必须在眼坐标中;结果半程向量必须归一化,以便生成正确的结果。计算 (nNormal) 法线表面向量与 (H) 半程向量之间的点积,并将其代入前面提到的方程中计算镜面照明:

sIntensity = pow(max(0.0, dot(H, nNormal)), ShininessFactor)

与我们之前实现的先前的镜面技术相比,当前技术被认为更高效。前面的图像显示了两种技术之间的差异。毫无疑问,使用中点向量技术是一种近似,与原始技术相比,生成的结果特征不那么明显。这种近似非常接近现实;因此,如果您不太在意精确的质量,可以使用中点向量来计算表面的光泽度。

注意

记住始终使用(-S)来计算反射向量,并使用(S)来计算(H)中点向量。

参见

  • 实现每顶点的镜面反射光分量

Gouraud 着色 - 每顶点着色技术

本配方实现了包含光的三种成分(即周围(A)、漫反射(D)和镜面(S))的 Phong 反射模型,这些我们在之前的配方中已经探讨过。这种照明技术也称为 ADS 或 Gouraud 着色。Gouraud 着色技术是每顶点着色,因为片段的颜色是在顶点着色器中通过使用每个顶点的位置信息来计算的。

准备工作

本配方结合了我们之前配方中实现的周围(A)、漫反射(D)和镜面(S)照明效果,使用的是 Phong 反射模型技术。从数学上讲,它是周围、漫反射和镜面片段颜色的总和:

Gouraud 着色颜色 = 周围颜色 + 漫反射颜色 + 镜面颜色

准备工作

在实现 Gouraud 着色之前,建议您彻底理解本章中提到的周围、漫反射和镜面照明技术。

如何实现...

Gouraud 着色配方实现将使用当前顶点着色器中名为GouraudShadeVertex.glsl的现有顶点着色器文件,这些文件来自周围、漫反射和镜面配方。本配方使用一个全局函数GouraudShading()来实现 Gouraud 着色技术;片段着色器可以完全重用,因为它不需要任何更改。以下代码片段描述了 Gouraud 着色顶点着色器:

. . . . // global variables, vertex attribute and matrixes.
vec3 GouraudShading()
{
    nNormal   = normalize ( NormalMatrix * Normal );
    eyeCoord  = vec3 ( ModelViewMatrix * VertexPosition );
    nLight    = normalize( LightPosition - eyeCoord );

    // Diffuse Intensity
    cosAngle = max( 0.0, dot( nNormal, nLight ));

    // Specular Intensity
    V       = normalize( -eyeCoord );
    R       = reflect( -nLight, nNormal );
    sIntensity=pow( max(0.0, dot(R, V) ), ShininessFactor);

    // ADS color as result of Material & Light interaction
    ambient = MaterialAmbient  * LightAmbient;//Ambient light
    diffuse = MaterialDiffuse  * LightDiffuse;//Diffuse light
    specular = MaterialSpecular*LightSpecular;//Specular light

    return ambient + (cosAngle*diffuse) + (sIntensity*specular);
}

void main(){
    FinalColor = vec4(GouraudShading(), 1);
    gl_Position = ModelViewProjectionMatrix * VertexPosition;
}

它是如何工作的...

GouraudShading()函数通过添加周围、漫反射和镜面光颜色来计算每个顶点的颜色;结果的颜色信息返回到main()程序。然后顶点着色器将此颜色信息共享给片段着色器。片段着色器通过使用从顶点着色器接收到的颜色信息,通过插值计算每个片段的整个颜色。

注意

OpenGL ES 着色语言中的函数定义与 C 语言类似;它可以返回值并通过值传递参数。它不支持指针或引用通过地址发送信息。有关 GL 着色语言 3.0 中函数定义的更多信息,请参阅 www.khronos.org/files/opengles_shading_language.pdf

本例使用点光实现;点光从不同角度发出光线,当它照射到物体上时与顶点形成不同的角度。

参考信息

  • 实现方向光和点光

phong 着色 – 按片段着色技术

这种着色技术也称为平滑着色。在本例中,我们将实现 phong 着色,它是一种按片段的照明技术。使用按片段技术,与按顶点技术相比,光照着色可以为渲染场景添加更多真实感。我们将比较 Gouraud 着色与 phong 着色,以查看两种技术之间的相对差异。

在 phong 着色中,顶点着色器负责在眼坐标系统中计算法线和顶点位置;然后这些变量传递到片段着色器。顶点法线和顶点位置对每个片段进行插值和归一化,以产生最终的片段颜色。

如何实现...

使用以下步骤实现并查看此技术的实际效果:

  1. 创建 PhongShadeVertex.glsl 并重用之前示例中的大多数变量。参考以下代码。主要区别在于 normalCoordeyeCoord,它们被定义为输出变量。注意:我们不会在顶点着色器中使用光和材质的属性;相反,这些将在片段着色器中使用:

    #version 300 es
    // Vertex information
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    
    // Model View Project Normal Matrix
    uniform mat4 ModelViewProjectionMatrix, ModelViewMatrix;
    uniform mat3 NormalMatrix;
    
    //Out variable shared with Fragment shader
    out vec3 normalCoord, eyeCoord;
    
    void main() {
      normalCoord = NormalMatrix * Normal;
      eyeCoord    = vec3(ModelViewMatrix * VertexPosition);
      gl_Position = ModelViewProjectionMatrix * VertexPosition;
    }
    
  2. 创建 PhongShadeFragment.glsl 片段着色器文件,并将所有光和材质属性变量添加到所需的精度限定符中。我们将使用中等精度限定符;这个精度限定符位于变量声明中的类型之前:

    #version 300 es
    precision mediump float;
    
    // Material & Light property
    uniform vec3 MaterialAmbient,MaterialSpecular,MaterialDiffuse; 
    uniform vec3 LightAmbient, LightSpecular, LightDiffuse;
    uniform float   ShininessFactor;
    
    uniform vec3 LightPosition;
    
    in vec3    normalCoord;
    in vec3    eyeCoord;
    
    layout(location = 0) out vec4 FinalColor;
    
    vec3 normalizeNormal, normalizeEyeCoord, normalizeLightVec, V, R, ambient, diffuse, specular;
    float sIntensity, cosAngle;
    
    vec3 PhongShading()
    {
      normalizeNormal   = normalize(normalCoord);
      normalizeEyeCoord = normalize(eyeCoord);
      normalizeLightVec = normalize(LightPosition-eyeCoord);
    
      // Diffuse Intensity
      cosAngle = max(0.0,
                    dot(normalizeNormal,normalizeLightVec));
    
      // Specular Intensity
      V = -normalizeEyeCoord; // Viewer's vector
      R = reflect(-normalizeLightVec, normalizeNormal);
      sIntensity = pow(max(0.0,dot(R,V)), ShininessFactor);
    
      ambient    = MaterialAmbient  * LightAmbient;
      diffuse    = MaterialDiffuse  * LightDiffuse;
      specular   = MaterialSpecular * LightSpecular;
    
      return ambient+(cosAngle*diffuse)+(sIntensity*specular);
    }
    
    void main() {
      FinalColor = vec4(PhongShading(), 1.0);
    }
    

工作原理...

在 phong 着色中,顶点着色器计算顶点法线(normalCoord)和顶点位置在眼坐标系(eyeCoord)中的值,并将其发送到片段着色器。片段着色器使用这些值并对每个片段的顶点法线和顶点位置进行插值。为了产生准确的结果,插值值必须归一化。计算环境光、漫反射和镜面光的剩余过程与前面讨论的相同。

默认情况下,顶点着色器不需要定义任何精度(它是可选的)。如果在顶点着色器中没有定义精度,则它将使用最高精度。在片段着色器中,需要定义精度限定符(它不是可选的)。

有三种精度限定符,分别是lowpmediumhighp。这些精度限定符可能会影响应用程序的性能;因此,建议根据实现要求使用正确的精度。较低的精度可能有助于提高 FPS 和功耗效率;然而,它可能会降低渲染质量。在我们的案例中,我们将为片段着色器中的所有变量使用mediump精度。

工作原理...

还有更多...

我们已经使用 Wavefront OBJ 网格来演示对 3D 网格模型的光照效果;你可以在第四章处理网格中探索更多关于网格的内容。同一章节描述了使用法向量实现的平面/平滑着色。

可以通过使用ObjMesh类的成员函数ParseObjModel来启用平面/平滑着色实现。这指定第二个参数为布尔值true(平面着色)或false(平滑着色)。两种着色类型的比较结果如图所示:

还有更多...

参见

  • 参考第四章渲染 Wavefront OBJ 网格模型处理网格中的配方

实现方向光和点光

光可以分为三种类型,即点光、方向光和聚光灯。让我们详细看看:

  • 点光或位置光:这种类型的光来自 3D 空间中的一个固定位置。光的位置和它落下的物体的顶点用于计算光的方向。点光向所有方向发射光。每个顶点可以有不同的光方向,这取决于它从光源的位置,如图所示。

  • 方向光:这种类型的光是点光的一种特殊情况。在这里,考虑物体上落下的光的方向是不变的。这意味着所有光线的方向都是平行的。在方向光中,光源被认为是无限远处的模型,它应该落在上面。有时,在 3D 场景渲染过程中假设光方向为平行会更好。如果光源点和模型之间的距离明显较大,这是实现与点光几乎相同效果的最佳方式。

  • 聚光灯:这种类型的光使用光的方向和一个截止角度来形成一个圆锥形的虚拟 3D 空间,如图所示。超出这个形状的光被丢弃,而圆锥内的光形成聚光灯效果:实现方向光和点光

准备工作

有时,光源的位置与物体相当远。在这种情况下,建议使用方向光照来实现光照着色技术。点光源着色技术稍微昂贵一些,因为需要为每个顶点计算光线方向。它与几何中的顶点数量成正比。相比之下,方向光被视为在恒定方向上,假设光线以平行方向传播。与点光源不同,方向光中的光线方向不考虑顶点位置:

光源类型 数学公式 光线方向
光线方向 = 光源位置 - 眼睛位置 可变
方向 光线方向 = 光源位置 常数

如何实现...

此配方将演示点光源与方向光源之间的区别;我们迄今为止已实现的全部配方都使用了点光源。实际上,在本配方的前一节中,我们了解了何时使用哪种光源。以下加粗的指令是在基于 Phong 着色的片段着色器中实现的;如果实现 Gouraud 着色,则需要在顶点着色器中执行类似更改:

  • 点光源或位置光源: 实现点光源需要做一项更改:

    vec3 PhongShading(){
        normalizeNormal   = normalize( normalCoord );
        normalizeEyeCoord = normalize( eyeCoord );
        // Calculate Point Light Direction
        normalizeLightVec = normalize( LightPosition - eyeCoord );
        . . . . . .
        // Calculate ADS Material & Light
        . . . . . .
        return ambient+(cosAngle*diffuse)+(sIntensity*specular);
    }
    
  • 方向光: 类似地,更改加粗标记的方向光语句:

    vec3 PhongShading(){
        normalizeNormal   = normalize( normalCoord );
        normalizeEyeCoord = normalize( eyeCoord );
        // Calculate Direction Light Direction
        normalizeLightVec = normalize( LightPosition );
        . . . . . .
        // Calculate ADS Material & Light
        . . . . . .
        return ambient+(cosAngle*diffuse)+(sIntensity*specular);
    }
    

它是如何工作的...

在点光源中,光向量用于计算相对于每个顶点的眼睛坐标的光的方向向量;这会产生可变的方向向量,这些向量负责每个顶点不同的光照强度。

相比之下,方向光假设所有顶点都在原点(0.0, 0.0, 和 0.0)。因此,每个顶点的方向向量都是平行的。以下图比较了点光源技术和方向光源技术:

它是如何工作的...

场景中实现多个光源

到目前为止,我们所有的配方都是使用单个光源进行演示的。本节将帮助我们实现场景中的多个光源。与只能向场景添加八个光源的固定管线架构不同,可编程管线不对多个光源的数量设置上限。向场景添加多个光源非常简单。这与我们添加一个光源位置以创建每个片段一个颜色的方式相似。现在,我们添加N个光源以生成每个片段N种颜色的平均值:

场景中实现多个光源

从数学上讲,如果光源如L1L2L3分别创建FC1FC2FC3片段颜色,那么这些光源的综合效果将是一个单一的片段颜色,这是所有片段颜色的平均权重结果。

准备工作

这个菜谱的顶点着色器不需要对源代码进行任何特殊修改。因此,我们可以重用相同的顶点着色器(在 Phong 着色菜谱中实现)。这个菜谱需要对片段着色器进行一些修改。

如何操作...

实现多光源菜谱的步骤如下:

  1. 创建一个名为MultiLightFragment.glsl的片段着色器文件,并突出显示,如下面的代码所示:

    // Many line skipped
    . . . . . 
    // Light uniform array of 4 elements containing light 
    // position and diffuse color information.
    uniform vec3    LightPositionArray[4];
    uniform vec3    LightDiffuseArray[4];
    uniform float   ShininessFactor;
    
    vec3 PhongShading( int index )
    {
        normalizeNormal   = normalize( normalCoord );
        normalizeEyeCoord = normalize( eyeCoord );
        normalizeLightVec = normalize
        (LightPositionArray[index] - eyeCoord );
    
        cosAngle = max(0.0,dot(normalizeNormal,normalizeLightVec));
    
        V = -normalizeEyeCoord; // Viewer's vector
        R =reflect(-normalizeLightVec,normalizeNormal);//Reflectivity
        sIntensity = pow( max( 0.0, dot( R, V ) ), ShininessFactor );
    
        ambient   = MaterialAmbient * LightAmbient;
        diffuse = MaterialDiffuse * LightDiffuseArray[index];
        specular  = MaterialSpecular * LightSpecular;
    
        return ambient+(cosAngle*diffuse)+(sIntensity*specular);
    }
    
    void main() {
       vec4 multipleLightColor = vec4( 0.0 );
       for (int i=0; i<4; i++){
          multipleLightColor += vec4(PhongShading(i),1.0);
       }
       FinalColor = multipleLightColor;
    }
    
  2. 对于顶点着色器不需要进行任何更改;然而,主程序指定了四个不同的灯光位置和四个不同的漫反射颜色配置:

       // Inside ObjLoader::InitModel()
       // Compile and  use Multiple Light Shade Program
       glUseProgram( program->ProgramID );
       // Get Material & Light uniform variables from shaders
       float lightpositions[12]={{-10.0,0.0,5.0}, {0.0,10.0,5.0}, {10.0,0.0,5.0},{0.0,-10.0,5.0}};
       glUniform3fv(LightPositionArray,
       sizeof(lightpositions)/sizeof(float), lightpositions);
    
       float lightdiffusecolors[12]={{1.0,0.0,0.0}, {0.0,1.0,0.0},{1.0,0.0,0.0}, {0.0,1.0,0.0} };
       glUniform3fv(LightDiffuseArray, sizeof(lightdiffusecolors)/ 
       sizeof(float), lightdiffusecolors);
    

工作原理...

当前菜谱使用四个灯光来演示场景中的多光源着色。这些灯光位于对象周围(左、右、上、下)。左侧和右侧的灯光使用红色漫反射光颜色,而底部和顶部的灯光设置为绿色漫反射光颜色。

在我们的着色器程序中,灯光的位置和漫反射光颜色以数组的形式定义,分别用LightPositionLightDiffuseArray表示。

工作原理...

GouraudShading()函数被修改为接受一个参数,该参数使用需要处理的灯光位置的索引。主程序循环计算平均片段颜色强度。这个片段颜色返回到主程序。

接近球面表面的灯光位置接收更多的强度;因此我们可以清楚地看到,球面在顶部、底部、左侧和右侧用绿色和红色照亮。球面的前部是绿色和红色的混合,因为球面在正面从所有四个光方向接收到的强度是相等的。

实现双面着色

在第二章中,我们探讨了裁剪技术,这是一种快速提高性能的方法。这项技术避免了渲染面向背面的多边形面;通常不希望裁剪背面(不完全封闭的对象通常使用背面渲染)。有时,用不同的颜色查看这些背面是有意义的。这将有助于定义几何形状的特性,这些特性在面(背面和正面)的同一颜色上可能不可见。

在这个菜谱中,我们将渲染一个具有不同面颜色(内部和外部)的半空心圆柱体。我们首先需要做的是关闭背面裁剪。我们可以通过glDisable (GL_CULL_FACE)来关闭背面裁剪。

为了在正面和背面应用不同的颜色,我们首先需要识别它们。OpenGL ES 着色语言在片段着色器中提供了一个简单的全局变量gl_FrontFacing,它帮助我们识别属于正面面的片段。此 API 返回布尔值true,如果面是正面,反之亦然。

面的法线位置有助于定义其指向的方向。正面面的法线位置始终与背面面的方向相反;我们将使用这个线索用不同的颜色着色正面和背面。

准备工作

多光源着色配方可以重用来实现双面着色。

注意

确保在程序代码中禁用了剔除;否则,双面着色将不会工作。

如何操作...

在顶点着色器中不需要进行任何更改。创建一个名为TwoSideShadingFragment.glsl的片段着色器文件,并按照以下加粗内容进行更改:

vec3 GouraudShading( bool frontSide ){
  normalizeNormal   = normalize ( normalCoord );
  normalizeLightVec = normalize ( LightPosition - eyeCoord );
 if ( frontSide ) // Diffuse Intensity
 { cosAngle=max(0.0, dot(normalizeNormal,normalizeLightVec)); }
 else
 { cosAngle=max(0.0, dot(-normalizeNormal,normalizeLightVec));}

  V = normalize( -eyeCoord );
  R = reflect(-normalizeLightVec, normalizeNormal);
  sIntensity = pow(max(0.0,dot(R,V)), ShininessFactor);
 ambient    = MaterialAmbient  * LightAmbient; // Net Ambient
 specular   = MaterialSpecular * LightSpecular;// Net Specular
 if ( frontSide ) // Front and back face net Diffuse color
 { diffuse=MaterialDiffuse*LightDiffuse; }
 else
 { diffuse=MaterialDiffuseBackFace*LightDiffuse; }

  return ambient + (cosAngle*diffuse) + (sIntensity*specular);
}

void main() {
 if (gl_FrontFacing)
 { FinalColor = vec4(GouraudShading(true), 1.0); }
 else
 { FinalColor = vec4(GouraudShading(false), 1.0); }
}

如何工作...

此配方的原理非常简单;其背后的理念是检查原始片段是否属于正面或背面。如果属于正面,则分配一种类型的颜色编码;否则,选择另一种类型。在片段着色器中,使用gl_FrontFacing检查正面。将片段面的类型作为参数传递给GouraudShading函数。根据正面和背面的布尔值,此函数将生成颜色。我们将使用MaterialDiffuseBackFaceLightDiffuse分别用于背面和正面漫反射光颜色。为了计算背面表面的 Gouraud 着色,我们必须使用负方向法线:

如何工作...

参见

  • 参考第二章中的OpenGL ES 3.0 中的剔除配方,OpenGL ES 3.0 基础

第六章. 与着色器一起工作

在本章中,我们将涵盖以下配方:

  • 实现摇摆和波纹效果

  • 基于对象坐标的程序纹理着色

  • 创建圆形图案并使其旋转

  • 生成砖块图案

  • 生成圆点图案

  • 抛弃片段

  • 基于纹理坐标的程序纹理着色

简介

本章将为你深入理解着色器编程技术。它讨论了可以使用顶点和片段着色器实现的各种技术,揭示了它们的特性。我们将从理解着色器在 OpenGL ES 3.0 可编程管道中的作用开始本章。你还将了解顶点着色器和片段着色器如何在 GPU 多核上处理信息。

你将学习如何通过使用顶点着色器来变形几何形状;这将产生在 3D 网格模型上的摇摆效果。稍作修改后,我们将使用相同的变形概念来实现池塘水波纹效果。此外,我们将了解程序纹理和图像纹理之间的区别。借助模型坐标,我们将实现我们的第一个简单的程序纹理配方。

使用顶点绘制圆形几何体可能渲染成本过高,因为它需要太多的顶点来形成更平滑的边缘;圆形着色器配方演示了一种使用程序纹理高效渲染圆形的方法。砖块着色器配方演示了如何在物体表面渲染砖块图案。利用圆形图案的知识,我们将编写如何在 3D 网格对象上渲染圆点的程序。我们将扩展相同的配方来展示 GL 着色语言的一个有趣特性,该特性允许我们通过丢弃片段在 3D 几何体中产生孔洞。最后,你将学习如何使用纹理坐标来编程程序纹理。有了这些知识,我们将在 3D 立方网格对象上创建一个网格或笼状几何体。

着色器角色和责任:以下图展示了顶点和片段着色器在两个重叠模型中的作用,以在屏幕上产生最终图像;预期的输出标记为标签 1。图形引擎提供了一个矩形形状的模型(四个顶点)和一个三角形形状的模型(三个顶点)。这些模型首先被发送到顶点着色器。顶点和片段着色器程序具有类似于 C 编程语言的语法;程序的主入口点始终从main()函数开始。

顶点着色器在运行时编译和执行;它为几何中的每个顶点调用一次,如下图中标记为23所示。着色器程序在多处理器 GPU 上执行,这允许同时操纵多个顶点。顶点着色器总是在片段着色器之前执行。

顶点着色器主要有两个目标:

  • 计算顶点坐标的变换

  • 计算片段着色器所需的任何顶点计算介绍

片段着色器总是在顶点着色器之后执行。与顶点着色器不同,片段着色器也包含一个main()函数作为其入口点。片段着色器也会为每个单独的片段在运行时进行编译和执行;标签4和标签5显示了片段着色器在每个片段上的执行。

标签为6的图像展示了光栅化过程后的生成片段;像素显示在红色框中。每个片段可能对应也可能不对应原始图元中的单个像素。帧缓冲区中的像素可以由一个或多个片段组成,如下图所示;使用片段着色器,这些生成的片段可以通过程序控制来分配颜色、纹理和其他属性信息;每个片段都与位置、深度和颜色相关联。

片段着色器的主要目标是计算每个片段的颜色信息或根据编程决策丢弃片段。

片段具有执行以下任务的能力:

  • 颜色插值或每个片段的计算

  • 计算纹理坐标

  • 将纹理分配给每个像素

  • 每个像素的法线插值

  • 计算每个像素的光信息

  • 动画效果的计算

着色器的并发执行模型:现代图形引擎架构能够渲染高性能的先进图形。得益于现代图形处理器,它能够以惊人的速度快速并行处理大量数据集,这种能力需要计算大量数据集,只需微秒级的几分之一。图形处理器单元GPUs)是专门为满足这些要求而设计的特殊专用处理器;这些处理器是多核的,可以实现并行处理。

现代图形的一个主要要求是它需要高效的浮点计算和快速的多边形变换操作。GPU 专门优化以满足这些类型的要求;它们提供了一组功能。其中,包括快速三角函数,这在 CPU 架构上被认为是昂贵的。GPU 中的处理器核心数量可以从几百到几千不等。

下图显示了 GPU 上顶点着色器和片段着色器的并发执行:

介绍

GPU 的每个核心都能够运行顶点或片段着色器的一个实例;每个核心首先处理顶点,然后处理片段,如图所示。

实现波动和涟漪效果

这是一个简单而有效的顶点着色器技术,可以在对象的几何形状上产生摆动效果。这个着色器在几何形状上产生类似正弦波的动画效果;此效果在顶点着色器中实现。这个配方还演示了另一种动画技术,可以产生水塘波纹效果。

准备工作

对于这个配方,我们将重用前一章中现有的 Phong 着色器配方。将着色器文件重命名为你选择的名称;对于这个配方,我们将重命名为 WobbleVertex.glslWobbleFragment.glsl。摆动和波纹着色器都是基于顶点着色器的配方。

如何做到这一点...

本节将提供实现顶点着色器以产生摆动效果所需的更改。根据以下代码修改 WobbleVertex.glsl;不需要对片段着色器进行更改:

#version 300 es

// Define amplitude for Wobble Shader
#define AMPLITUDE           1.2

// Geometries vertex and normal information
layout(location = 0) in vec4  VertexPosition;
layout(location = 1) in vec3  Normal;

// Model View Project and Normal matrix
uniform mat4 ModelViewProjectionMatrix, ModelViewMatrix;
uniform mat3 NormalMatrix;

uniform float Time; // Timer

// Output variable for fragment shader
out vec3    nNormal, eyeCoord;

void main(){
    nNormal   = normalize ( NormalMatrix * Normal );
    eyeCoord  = vec3 ( ModelViewMatrix * VertexPosition );
    vec4 VertexCoord = VertexPosition;
    VertexCoord.y += sin(VertexCoord.x+Time)*AMPLITUDE;
    gl_Position = ModelViewProjectionMatrix * VertexCoord;
}

如何工作...

正弦波具有产生平滑重复振荡的数学特性。以下图显示了正弦波;正弦波的振幅定义了波谷或波峰的高度或深度。摆动顶点着色器将每个对象顶点 V (V[x], V[y], 和 V[z]) 的 Y 分量移动以产生摆动效果;位移是通过始终介于 -1.0 和 1.0 之间的正弦函数完成的。

如何工作...

统一 Time 属性变量用于时钟滴答。当这些时钟滴答被输入到 GLSL sin() 函数时,它生成介于 -1.0 到 1.0 之间的值。每个顶点的 Y 分量 (V[y]) 被添加到这个正弦值 (VertexCoord.y += sin(Time)) 以产生跳跃效果动画。尝试这个方程,它会使物体跳跃:

如何工作...

此外,为了产生摆动动画,考虑每个顶点的 X (V[x]) 或 Z (V[z]) 分量,使用 (VertexCoord.y += sin(VertexCoord.x + Time)) 产生类似波浪的振荡动画。将结果的 Y (V[y]) 分量乘以 AMPLITUDE 将影响摆动波动画的高度:

如何工作...

每个顶点的新的 Y (V[y]) 组件,通过乘以 ModelViewProjection 矩阵来计算顶点的裁剪坐标。

如何工作...

还有更多...

摆动着色器配方还展示了池塘水波动画,如本节前面的图像所示。从数学上讲,正弦波作为时间的函数定义为以下:

还有更多...

其中 t 是时间,A 是波的振幅,f 是频率。使用这个公式,可以编程波纹效果如下。相位变化 (φ) 假设为 t

还有更多...

根据以下代码片段中给出的说明修改现有的顶点着色器,粗体部分如下:

#define RIPPLE_AMPLITUDE 0.05
#define FREQUENCY 5.0
#define PI 3.14285714286
void main(){
    nNormal   = normalize ( NormalMatrix * Normal );
    eyeCoord  = vec3 ( ModelViewMatrix * VertexPosition );
    vec4    VertexCoord = VertexPosition;
    float distance = length(VertexCoord);
 VertexCoord.y = sin( 2.0 * PI * distance * FREQUENCY + Time)
 * RIPPLE_AMPLITUDE;
    gl_Position = ModelViewProjectionMatrix * VertexCoord;
}

距离变量用于计算每个变量与其原点的距离;此距离使用 OpenGL ES 着色语言length() API 计算。最后,通过将VertexCoordModelViewProjection矩阵相乘来计算裁剪坐标。

还有更多...

参见

  • 参考第五章中关于Phong 着色 - 每个片段着色技术的配方,光与材料章节

使用对象坐标进行过程纹理着色

在本配方中,你将学习如何借助过程纹理在 3D 几何体的表面上生成纹理图案。基本上,在 2D/3D 网格对象上的纹理可以分成两类:

  • 过程纹理:过程纹理是使用算法数学上生成的一个图像或纹理;此类算法使用 2D/3D 对象的各个属性来创建图像;这种纹理类型高度可控。过程纹理用于创建图案,如云彩、大理石、木材、混合、噪声、musgrave、voronoi 等。

  • 图像纹理:在这种类型的纹理中,一个静态图像被包裹在物体上;由于这是一个光栅图像,因此该图像在仿射缩放变换中可能会发生扭曲。你将在下一章中学习更多关于图像纹理的内容,该章节名为纹理。

在本章中,我们将使用对象和纹理坐标生成多个过程纹理。当前的配方利用对象坐标来演示如何用它来控制 3D 模型上的片段颜色。

对象坐标是一个坐标系,其中定义了物体的原始形状。例如,以下图像中的正方形沿x-z平面有 2 x 2 个单位;原点位于正方形的中间。x轴和z轴将正方形分为围绕原点的四个象限。本配方使用此逻辑将网格的 3D 空间逻辑上分为四个象限,每个象限以不同的颜色着色:

使用对象坐标进行过程纹理着色

如何做...

执行以下步骤以实现此简单配方:

  1. 创建SimpleVertexShader.glsl并将其添加到以下代码片段中:

    #version 300 es
    // Vertex information
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    
    // Model View Project and Normal matrix
    uniform mat4 ModelViewProjectionMatrix, ModelViewMatrix;
    uniform mat3    NormalMatrix;
    
    // output variable to fragment shader
    out vec3 nNormal, eyeCoord, ObjectCoord;
    
    void main() {
        nNormal     = normalize ( NormalMatrix * Normal );
        eyeCoord    = vec3 (ModelViewMatrix * VertexPosition);
        ObjectCoord = VertexPosition.xyz;
        gl_Position = ModelViewProjectionMatrix * VertexPosition;
    }
    
  2. 如下修改SimpleFragmentShader.glsl

    // Reuse the Light and Material properties. . 
    in vec3 eyeCoord;    // Vertex eye coordinate
    in vec3 ObjectCoord; // Vertex object coordinate 
    layout(location = 0) out vec4 outColor;
    
    vec3 PhongShading(){ 
      . . . . . .   // Reuse Phong shading code.
    }
    
    void main() {
        if (objectCoord.x  > 0.0 && objectCoord.z  > 0.0)
            FinalColor = vec4(1.0, 0.0, 0.0, 1.0);
        else if (objectCoord.x  > 0.0  && objectCoord.z  < 0.0)
            FinalColor = vec4(0.0, 01.0, 0.0, 1.0);
        else if (objectCoord.x  < 0.0  && objectCoord.z  > 0.0)
            FinalColor = vec4(0.0, 01.0, 1.0, 1.0);
        else if (objectCoord.x  < 0.0  && objectCoord.z  < 0.0)
            FinalColor = vec4(1.0, 0.0, 1.0, 1.0);
    
        FinalColor = FinalColor  * vec4(PhongShading(), 1.0);
    }
    

    如何做...

前面的图显示了我们的简单过程着色器的结果。模型沿x-z平面分为四个象限,每个象限以不同的颜色显示。通过在屏幕上轻触一次来在这些模型之间切换。

它是如何工作的...

着色器作业从顶点着色器开始,其中对象坐标作为 VertexPosition 变量中的顶点属性由顶点程序接收;该变量包含在定义对象时在局部 3D 空间中的顶点位置。此值存储在 ObjectCoord 中并传递到片段着色器。在片段着色器中,对象坐标值与原点进行比较,以测试它在 x-z 平面上的哪个象限。根据象限的结果,为片段分配颜色。以下图像显示了使用对象坐标将 3D 空间分为四个象限:

它是如何工作的...

还有更多...

着色器程序不提供任何可用于调试目的的打印语句;在着色器中调试坐标值的最简单解决方案之一是将颜色分配给片段。如果你对处理 3D 空间中模型的坐标范围感兴趣,你可以使用条件语句分配各种颜色。例如,如果将之前的片段着色器替换为以下代码,它将根据坐标范围渲染不同颜色的条带:

// Reuse the Light and Material properties. . 
in vec3 eyeCoord;    // Vertex eye coordinate
in vec3 ObjectCoord; // Vertex object coordinate 
layout(location = 0) out vec4 outColor;

vec3 PhongShading(){ 
  . . . . . .// Reuse Phong shading code.
}

void main() {
    //Debuging Shader with Model coordinates
    if (objectCoord.x  > 0.9 )
        FinalColor = vec4(1.0, 0.0, 0.0, 1.0);
    else if (objectCoord.x  > 0.8 )
        FinalColor = vec4(1.0, 1.0, 0.0, 1.0);
    else if (objectCoord.x  > 0.7 )
        FinalColor = vec4(1.0, 0.0, 1.0, 1.0);
    else if (objectCoord.x  > 0.6 )
        FinalColor = vec4(0.60, 0.50, 0.40, 1.0);
    else if (objectCoord.x  > 0.5 )
        FinalColor = vec4(0.30, 0.80, 0.90, 1.0);
    else
        FinalColor = vec4(1.0, 1.0, 1.0, 1.0);

    if (objectCoord.z  > 0.9 )
        FinalColor = vec4(1.0, 0.0, 0.0, 1.0);
    else if (objectCoord.z  > 0.8 )
        FinalColor = vec4(1.0, 1.0, 0.0, 1.0);
    else if (objectCoord.z  > 0.7 )
        FinalColor = vec4(1.0, 0.0, 1.0, 1.0);
    else if (objectCoord.z  > 0.6 )
        FinalColor = vec4(0.60, 0.50, 0.40, 1.0);
    else if (objectCoord.z  > 0.5 )
        FinalColor = vec4(0.30, 0.80, 0.90, 1.0);

    FinalColor = FinalColor  * vec4(PhongShading(), 1.0);
}

还有更多...

每种独特的颜色指定了沿 xz 轴的对象坐标范围。以下图像显示了每种颜色代表沿 x-z 平面从 0.5 到 1.0 单位的 0.1 逻辑单位带:

还有更多...

参见

  • 请参考第七章中的使用 UV 映射应用纹理配方,纹理和映射技术

创建圆形图案并使其旋转

这个程序纹理配方将使用片段坐标来演示使用 gl_FragCoord 的圆形图案。gl_FragCoord 是在片段着色器中可用的一个关键字,它负责存储当前片段在 xy 坐标中的窗口位置;z 坐标存储片段在 [0, 1] 范围内的深度。

这些坐标始终相对于 OpenGL ES 表面窗口;gl_FragCoords 是一个固定功能的结果,其中在顶点处理阶段之后对原语进行插值以生成片段。默认情况下,gl_FragCoord 假设 OpenGL ES 渲染表面窗口的左下角为原点。以下图像显示了不同尺寸的 OpenGL ES 表面窗口中的原点:

创建圆形图案并使其旋转

如何操作...

此菜谱的光照着色技术将与之前的菜谱相似;更多信息,请参阅第五章中的Phong 着色 - 每个片段着色技术菜谱,光和材料。创建CircleVertexShader.glsl顶点着色器并重用之前菜谱中的代码;对于CircleFragmentShader.glsl片段着色器,进行以下更改:

#version 300 es
// Reuse the variables . . . no change
vec3 PhongShading(){
   // Reuse Phong shading code.
   . . . . . .
   return ambient + diffuse + sIntensity * specular;
}

// Model and Dot color
uniform vec3 ModelColor, DotColor;

// Output color for fragment
layout(location = 0) out vec4 FinalColor;

// Size of the logical square
uniform float Side;

// Dot size 25% of Square size
float DotSize   = Side * 0.25;
vec2 Square     = vec2(Side, Side);

void main() {
    vec2 position = mod(gl_FragCoord.xy, Square) - Square*0.5;
    float length = length(position);
    float inside = step(length,DotSize);

    FinalColor = vec4(mix(ModelColor, DotColor, inside), 1.0);
    FinalColor = FinalColor * vec4(GouraudShading(), 1.0);
}

以下图像显示了此菜谱的输出:

如何做...

它是如何工作的...

对于从gl_Fragcoord接收到的每个片段的窗口位置,它通过将左下角坐标移动到想象中的正方形区域中心来处理;这个正方形区域用于在其中绘制一个圆。以下代码负责相对于逻辑正方形中心的每个坐标进行位移,如图所示:

vec2 position = mod(gl_FragCoord.xy, Square) - Square*0.5;

它是如何工作的...

由于位置是相对于想象中的正方形中心的,我们可以使用矢量图形中的标准长度公式来计算每个坐标与中心的距离。这个长度用于渲染具有圆圈颜色的片段,圆圈的颜色是通过DotColor指定的;如果长度小于DotSize,则使用ModelColor渲染身体颜色。

从数学上讲,圆是一个点的轨迹,该点始终与给定点等距;当原点移动到中心时,我们可以在正方形内绘制一个圆,如图所示。落在圆下的坐标可以使用DotColor渲染,以在模型上产生圆圈图案。为了检查长度是否大于或小于DotSize,我们使用了step() GLSL API:

   inside  = step(length,DotSize);

它是如何工作的...

语法:

float step(float edge, float x);
变量 描述
edge 这指定了步骤函数边缘的位置
x 这指定了用于生成步骤函数的值
返回值 此步骤函数如果 x 小于边缘则返回 0.0;否则返回 1.0

更多...

当前实现的圆圈图案非常静态;它不会在圆的图案上显示任何运动。在本节中,我们将应用二维旋转矩阵的一般形式来在欧几里得空间中执行单次旋转;旋转矩阵的一般形式是:

更多...

修改现有的片段着色器菜谱以查看圆形图案的旋转效果;高亮代码负责根据二维方程计算旋转矩阵:

uniform float   RadianAngle;
void main() {
    float cos  = cos(RadianAngle); // Calculate Cos of Theta
    float sin  = sin(RadianAngle); // Calculate Sin of Theta

    mat2 rotation  = mat2(cos, sin, -sin, cos);
    vec2 position  = mod( rotation * gl_FragCoord.xy, Square) 
    - Square*0.5;
    float length  = length(position);
    float inside  = step(length,DotSize);

    FinalColor = vec4(mix(ModelColor, DotColor, inside), 1.0);
    FinalColor = FinalColor * vec4(PhongShading(), 1.0);
}

参见

  • 生成 圆点图案

生成砖块图案

砖块着色器生成给定 3D 网格对象表面的砖块图案;这是过程纹理的另一个非常好的例子。砖块图案由两个组件(砖块和砂浆)组成;这些使用两种不同的颜色表示,如图所示。这些颜色使用 BrickColorMortarColor 作为全局变量在顶点着色器中定义。

砖块的矩形尺寸由砖块和砂浆材料组成;矩形区域的总体尺寸为 0.40 x 0.10 平方单位,其中 90% 的水平尺寸(0.40)保留用于沿 x 轴的砖块尺寸;剩余的 10% 用于沿同一轴的砂浆。同样,砖块的垂直尺寸沿 y 轴为 85%,剩余的 15% 由砂浆的垂直尺寸使用:

生成砖块图案

如何操作...

执行以下步骤以实现砖块着色器:

  1. 创建 BrickVertex.glsl 并重用前一个配方中的顶点着色器代码。

  2. 创建 BrickFragment.glsl 并修改以下代码:

    // Brick uniform parameters
    uniform vec3  BrickColor, MortarColor;
    uniform vec2  RectangularSize, BrickPercent;
    
    // Object coordinates of the mesh
    in vec3    ObjectCoord;
    
    vec3 PhongShading(){ //Reuse code for Phong shading
       . . . . . .
       return ambient + diffuse + specular;
    }
    
    vec3  color;
    vec2  position, useBrick;
    
    void main() {
        position = ObjectCoord.xy / RectangularSize;
    
       // Displace rows alternately after 0.5 decimals
        if (fract(position.y * 0.5) > 0.5){
            position.x += 0.5;
        }
    
        position = fract(position);
        useBrick = step(position, BrickPercent);
        color    = mix(MortarColor, BrickColor, 
                          useBrick.x * useBrick.y);
        FinalColor  = vec4(color * PhongShading(), 1.0);
    }
    
  3. 使用主程序并指定 BrickColorMortarColor 常量变量的颜色:

        BrickColor = ProgramGetUniformLocation(program,"BrickColor");
        MortarColor= ProgramGetUniformLocation(program, "MortarColor");
    
        if (BrickColor >= 0)
           {glUniform3f(BrickColor, 1.0, 0.3, 0.2 );}
        if (MortarColor >= 0)
           {glUniform3f(MortarColor, 0.85, 0.86, 0.84);}
    
  4. 类似地,指定总矩形尺寸和砖块百分比:

        RectangularSize= ProgramGetUniformLocation(program,"RectangularSize");
        BrickPercent = ProgramGetUniformLocation(program,"BrickPercent");
        if (RectangularSize >= 0)
           {glUniform2f(RectangularSize, 0.40, 0.10 );}
        if (BrickPercent >= 0)
           {glUniform2f(BrickPercent, 0.90, 0.85 );}
    

    如何操作...

工作原理...

在片段着色器中,每个传入的对象坐标 ObjectCoord 都被除以 BrickSize;结果位置包含砖块所属的行和列。对于每个交替的行,使用程序中的以下代码片段在水平方向上将砖块的位置向前推进 0.5 个单位:

    if (fract(position.y * 0.5) > 0.5)
    {
        position.x += 0.5;
    }

fract GLSL API 计算位置的小数部分并将其存储在 position 变量中;由于它是一个小数值,它必须在 0.0 和 1.0 之间;我们必须使用这个新值来使用 GLSL step 函数与 BrickPercent 进行比较。步进函数接受两个参数,一个阈值和一个需要与阈值进行比较的参数;如果参数值小于阈值值,则函数返回 0;否则,它将返回 1

position = fract(position);
useBrick = step(position, BrickPercent);
color    = mix(MortarColor,BrickColor,useBrick.x * useBrick.y);

混合函数使用权重参数混合两种颜色;在这个配方中,权重参数是 useBrick.xuseBrick.y 的乘积。结果颜色与 PhongShading() 相乘,产生砖块着色器上的最终光照着色。

更多内容...

在砖块着色器中使用的混合函数负责根据权重值在两个给定值之间执行线性插值。

语法:

genType mix(genType x, genType y, genType a);
变量 描述
x 这指定了插值范围的起点
y 这指定了插值范围的终点
a 这指定了用于在 x 和 y 之间进行插值的值

从数学上讲,mix(x, y, and a) 函数计算 xy 之间的线性插值,使用 a 作为权重。计算出的值如下:x * (1 − a) + y * a

参见

  • 参考第五章 Gouraud 着色 – 每片段着色技术 配方,光与材料

生成波点图案

这个配方是另一种程序纹理,它是我们圆形图案着色器的扩展。在那个配方中,我们研究了在平面表面上产生二维圆形图案的逻辑;这个平面表面是通过片段坐标创建的。在这个配方中,我们将在三维网格对象表面上创建波点图案;这里的主要区别在于,我们不会在方形中产生逻辑圆形,而是在逻辑立方体的 Side x Side x Side 单位内使用内嵌的逻辑球体:

生成波点图案

准备工作

在这个配方中,我们重用了第五章 实现双面着色 配方中的照明技术,光与材料。顶点位置、法线和纹理坐标的通用顶点属性分别用 0、1 和 2 个索引排列。

如何做到这一点...

执行以下步骤以实现波点图案的配方:

  1. 创建 PolkaDotsVertex.glsl 并重用前一个配方中的顶点着色器代码。

  2. 创建 PolkaDotsFragment.glsl;编辑以下片段着色器文件:

    #version 300 es
    precision mediump float;
    layout(location = 0) out vec4 outColor;
    
    in vec3  ObjectCoord;
    
    // Size of the logical cube
    uniform float Side;
    uniform float DotSize;
    vec3 Cube       = vec3(Side, Side, Side);
    vec3 RenderColor= vec3(0.0, 0.0, 0.0);
    
    // Front and Back face Model(mesh)/polka dot color
    uniform vec3 ModelColor, DotColor, BackSideModelColor, BackSideDotColor;
    
    void main() {
    
        float insideSphere, length;
        vec3 position = mod(ObjectCoord, Cube) – Cube*0.5;
        // Note: length() can also be used here
        length = sqrt( (position.x*position.x) +
        (position.y*position.y) + (position.z*position.z) );
        insideSphere = step(length,DotSize);
    
        // Determine color based on front/back shading
     if (gl_FrontFacing){){           
       RenderColor=vec3(mix(ModelColor,DotColor,insideSphere));
           outColor = vec4(RenderColor , 1.0);
        }
        else{
           RenderColor==vec3(mix(BackSideModelColor,BackSideDotColor, insideSphere));
            outColor = vec4(RenderColor, 1.0);
        }
    }
    

    以下图像显示了波点着色器在各个 3D 网格模型上的输出;在这些模型中,空心圆柱展示了双面着色技术,其中内部和外部面以从内部和外部不同颜色的波点渲染:

    如何做到这一点...

它是如何工作的...

波点着色器使用对象坐标在网格模型表面产生波点;这些对象坐标以 ObjectCoord 输入顶点属性变量的形式由顶点着色器共享。使用 Cube 变量对 ObjectCoord 执行模运算,Cube 是一个 vec3Side。这产生了一个逻辑立方体,然后从这个逻辑立方体的半立方维度中减去,以便将原点置于逻辑立方体的中心:

    vec3 position = mod(ObjectCoord, Cube) - Cube/0.5;

计算平移后的 ObjectCoord 与此中心的距离将提供从平移原点到位置向量的长度:

length = sqrt( (position.x*position.x) +
 (position.y*position.y)+(position.z*position.z));

最后,使用 GLSL step 函数将长度与 DotSize 进行比较,以检查它是否在 DotSize 半径的虚球体内部:

    insideSphere = step(length,DotSize);

根据 insideSphere 的结果,颜色值被分配给主体和波点;用于前表面的颜色与用于背面颜色的颜色不同,以便展示双面着色。

参见

  • 参考第五章 实现双面着色 的配方,光与材料

  • 使用对象坐标进行过程纹理着色

  • 创建圆形图案并使它们旋转

丢弃片段

OpenGL ES 着色语言提供了一个重要的功能,即使用 discard 关键字丢弃片段;这个关键字仅在片段着色器中使用,以防止更新帧缓冲区。换句话说,使用这个关键字会丢弃当前片段并停止片段着色器的执行。OpenGL ES 着色语言的这个特性是一个简单而有效的特性,它为产生 3D 几何体的横截面视图、孔或穿孔表面提供了可能性。

准备工作

这个配方重用了上一个配方;这需要在片段着色器中做一些更改以展示丢弃片段的功能。

如何操作...

将圆点着色器的顶点着色器文件和片段着色器文件重命名为 DiscardFragVertex.glslDiscardFragFragment.glsl。打开片段着色器文件并添加以下高亮代码:

#version 300 es
// Many lines skipped . . . 
layout(location = 0) out vec4 FinalColor;
vec3 GouraudShading( bool frontSide )
{
 // Reuse two sides shade recipe PhongShading code here
   return ambient + diffuse + specular;
}

in vec3  ObjectCoord;
uniform float Side, DotSize;
vec3 Square     = vec3(Side, Side, Side);
vec3 RenderColor;

// Front and Back face polka dot color
uniform vec3 ModelColor, DotColor, BackSideModelColor, BackSideDotColor;

// Variable for toggling the use of discard keyword
uniform int toggleDiscardBehaviour;

void main() {

    float insideCircle, length;
    vec3 position = mod(ObjectCoord, Square) - Square/2.0;

   length = sqrt( (position.x*position.x) +
          (position.y*position.y)+(position.z*position.z));
    insideCircle      = step(length,DotSize);

 // The toggleDiscardBehaviour change the behavior
 // automatically after fixed interval time.
 // The timer is controlled from the OpenGL ES program.
 if(toggleDiscardBehaviour == 0){
 if (insideCircle != 0.0)
 discard;
 }
 else{
 if (insideCircle == 0.0)
 discard;
 }

 // Determine final color based on front and back shading
    if (gl_FrontFacing){
     RenderColor = vec3(mix( ModelColor, DotColor, insideCircle));
     FinalColor = vec4(RenderColor * PhongShading(true), 1.0);
    }
    else{
     RenderColor=vec3(mix(BackSideModelColor, 
     BackSideDotColor, insideCircle));
     FinalColor=vec4(RenderColor * PhongShading(false), 1.0);
    }
}

如何操作...

它是如何工作的...

前面的输出显示了在圆点片段着色器程序中应用 discard 关键字。在这个配方中,片段基于 insideCircle 变量进行判断。这个变量检查片段是否落在圆内或圆外。如果落在圆内,则丢弃该片段;这会导致如前图所示的多孔外观。

在相反的情况下,位于圆外的片段会被丢弃,如前图所示。一般来说,不建议广泛使用 discard 关键字,因为它会增加图形管道执行额外操作的开销。根据 Imagination Technologies 的建议,对于 PowerVR 架构,建议在编程实践中限制使用 discard 关键字。尽管这很大程度上取决于应用程序本身,但建议对应用程序进行性能分析,以查看 discard 是否会导致其性能显著下降。

参见

  • 生成圆点图案

  • 使用纹理坐标进行过程纹理着色

使用纹理坐标进行过程纹理着色

纹理坐标控制纹理在模型表面的包裹方式;这些是用于将纹理映射到几何体 3D 表面的 2D 坐标。纹理坐标映射到称为 UV 映射 的不同坐标系中。字母 UV 分别表示沿 x 轴和 y 轴的纹理轴:

使用纹理坐标进行过程纹理着色

前面的最左边的图像显示了一个需要映射到某个任意尺寸蓝色方块的图标。一个图像(无论其尺寸如何)总是在 U 和 V 轴上的 0 到 1 的范围之间进行 UV 映射。因此,左下角的图像始终是(0, 0),左上角的图像是(1, 1);在 OpenGL ES 程序中不需要分配这些值。默认情况下,图形管线会理解这一点。

需要提到的是 2D/3D 模型的纹理坐标;例如,在前面的图像中,蓝色方块被分配了四个 UV 坐标,这显示了图像将如何完全映射到方块的表面:

使用纹理坐标的程序化纹理着色

在前面的图像中,蓝色方块被分配了新的纹理坐标,底部左角和右上角分别为(0, 0)和(0.5, 1);映射后的图像显示在右下角,我们可以清楚地看到新的纹理坐标沿U轴粘贴在图像的一半上。

纹理坐标对于基于图像的纹理是强制性的;然而,对于程序化纹理来说并非必须。程序化纹理中的纹理坐标有其自身的重要性,其应用是无限的。本食谱将演示纹理坐标的一个应用,即在 3D 立方网格模型表面产生类似网格的程序化纹理。有关 UV 贴图及其相关应用的信息,请参阅下一章。

准备工作

对于这个食谱,网格模型必须包含纹理坐标信息。有关如何创建带有纹理坐标的 Wavefront 对象模型的信息,请参阅第八章,与网格一起工作

如何做...

为了实现这个食谱,执行以下步骤:

  1. 创建 GridVertex.glsl 顶点着色器程序并添加以下代码:

    #version 300 es
    // Vertex layout information
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    layout(location = 2) in vec2  TexCoords;
    
    // Model View Projection Normal matrix
    uniform mat4    ModelViewProjectionMatrix, ModelViewMatrix;
    uniform mat3    NormalMatrix;
    
    out vec3    nNormal, eyeCoord;
    out vec2    TextureCoord;
    
    void main()
    {
        nNormal      = normalize ( NormalMatrix * Normal );
        eyeCoord     = vec3 ( ModelViewMatrix * VertexPosition );
        TextureCoord = TexCoords;
    
        gl_Position = ModelViewProjectionMatrix * VertexPosition;
    }
    
  2. 对于编程网格着色器,我们需要在片段着色器中进行修改。创建一个新的片段着色器文件,命名为 GridFragment.glsl,并使用以下代码:

    vec3 PhongShading( bool frontSide ){
       // Reuse the Phong shading code.
       return ambient + diffuse + specular;
    }
    
    in vec2    TextureCoord;
    layout(location = 0) out vec4 FinalColor;
    
       // Scale factor of the texture coord & Grid strip width
       uniform float texCoordMultiplyFactor, stripWidth;  
    
    void main() {
       // multiplicationFactor scales number of stripes
       vec2 t = TextureCoord * texCoordMultiplyFactor;
    
       // The stripWidth is used to define the line width
       if (fract(t.s) < stripWidth  || fract(t.t) < stripWidth ){
       // Front Face coloring
          if (gl_FrontFacing){ 
             FinalColor = vec4(PhongShading(true), 1.0);
          }
          // Back Face coloring
          else{ 
              FinalColor = vec4(GouraudShading(false), 1.0);
          }
       }
          // Throw the fragment
          else{ 
          discard;
       }
    }
    

    如何做...

它是如何工作的...

顶点着色器从 OpenGL ES 程序中的TexCoord顶点属性接收纹理坐标;该属性使用索引2定义为布局位置。纹理坐标通过TextureCoord作为输出变量传递给片段着色器。在这个配方中,我们使用了一个由六个正方形面组成的立方体;这些面的纹理坐标在 UV 映射中定义。这些 UV 映射坐标乘以一个名为texCoordMultiplyFactor的乘数因子变量,产生称为ST坐标的表面映射坐标。请注意,ST 坐标是逻辑坐标,用于创建表面映射计算;在许多地方,它们可以互换使用:

vec2 t = TextureCoord * texCoordMultiplyFactor;

如何工作...

texCoordMultiplyFactor的实际意义是定义水平和垂直方向上的条带数量。条带的宽度由stripWidth变量控制。当坐标转换为 ST 映射时,现在可以假设水平和垂直轴上有 10 条条带,每条条带宽1个单位。使用 GLSL 的frac() API 将stripWidth与 ST 坐标的分数值进行比较;此 API 返回十进制数的分数值。如果分数值大于stripWidth,则将其丢弃;否则,根据片段的前后面对齐,它们被分配颜色:

   if (fract(t.s) < stripWidth  || fract(t.t) < stripWidth ){
   //Front Face coloring  
   if (gl_FrontFacing) 
   { outColor = vec4( GouraudShadingGouraud(true), 1.0); }
   //Back Face coloring 
   else{ 
   { outColor = vec4( GouraudShadingGouraud(false), 1.0); }
   }
   // Throw the fragment 
   else{ 
   discard;
   }

参考信息

  • 参考第五章中关于实现双面着色的配方,光和材料

  • 丢弃片段

  • 参考第七章中关于使用 UV 映射应用纹理的配方,纹理和映射技术

第七章. 纹理和映射技术

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

  • 使用 UV 映射应用纹理

  • 使用 ETC2 压缩纹理格式进行高效渲染

  • 应用多种纹理

  • 使用无缝立方贴图实现 Skybox

  • 使用环境贴图实现反射和折射

  • 使用帧缓冲对象实现渲染到纹理

  • 使用位移贴图实现地形

  • 使用凹凸贴图实现

简介

本章将简要介绍纹理,这是 3D 计算机图形研究中的一个非常有趣的部分。纹理是一种技术,通过该技术,3D 网格模型的表面被涂上静态图像。在我们上一章中,我们描述了过程纹理和图像纹理技术。前者使用特殊算法计算片段的颜色以生成特定图案。另一方面,后者使用静态图像,这些图像被包裹在 3D 网格或几何体上。

本章全部关于图像纹理,解释了其在 3D 计算机图形领域的各种应用。我们将从演示 UV 映射以在二维平面表面上渲染纹理的简单食谱开始本章;从单个纹理开始,你将学习如何将多个纹理应用于 3D 对象。OpenGL ES 3.0 引入了许多新特性。在这些特性中,非 2 的幂(NPOT)纹理支持、ETC2/EAC 纹理压缩支持和无缝立方贴图在本章中通过一些实际食谱进行了详细解释。在章节的后续部分,我们将实现环境贴图食谱以模拟物体表面的反射和折射行为。章节将继续解释一种称为渲染到纹理的有效技术;这允许你将场景渲染到用户定义的纹理缓冲区。此外,我们将讨论位移贴图技术,它可以用来渲染地形;本章的最后一个食谱将讨论凹凸贴图技术,该技术用于使用低多边形网格生成高质量、详细的表面。

使用 UV 映射应用纹理

纹理基本上是计算机内存中由一块内存表示的图像;这块内存包含以红色(R)、绿色(G)、蓝色(B)和 alpha(A)组件形式存在的颜色信息;每个组件都表示为一串位/字节,具体取决于纹理类型的格式。

在这个食谱中,我们将创建一个简单的正方形并对其应用纹理;纹理映射需要以下三个要素:

  1. 首先需要使用纹理对象将图像加载到 OpenGL ES 纹理内存中。

  2. 使用纹理坐标将纹理映射到几何体上。

  3. 使用纹理坐标从纹理中获取相应的颜色,以便将其应用于几何体的表面。

准备工作

GLPI 框架允许使用名为 .png 的高层抽象类加载 可移植网络图形PNG)图像文件,该类是从图像派生出来的;此类加载 .png 图像并在类中存储图像度量,如名称、尺寸、原始位和 OpenGL ES 纹理名称(ID)。内部,此类使用 libpng,这是一个平台无关的库,允许您解析 .png 图像。

如何做到这一点...

以下过程描述了使用 .png 图像纹理渲染几何形状的步骤:

  1. libpng 库位于 GLPLFramework 文件夹下;本书将使用 libpng 的 1.5.13 版本。

    • iOS:在 iOS 上,需要将此库添加到项目中。在 Xcode 中,在您的项目下,您可以通过 文件 | 添加到 <项目名称> 来包含此库。

    • Android:对于 Android,libpng 可以编译为一个名为 GLPipng 的共享库;为此,在 libpng 文件夹中创建 Android.mk 并添加以下代码:

             LOCAL_PATH := $(call my-dir)
             include $(CLEAR_VARS)
      
             LOCAL_MODULE    := GLPipng
             LOCAL_SRC_FILES := png.c pngerror.c pngget.c \
             pngmem.c pngpread.c pngread.c pngrio.c \
             pngrtran.c pngrutil.c pngset.c pngtrans.c \
             pngwio.c pngwrite.c pngwtran.c pngwutil.c
      
             LOCAL_LDLIBS := -lz
             LOCAL_CFLAGS := -I. -g
             include $(BUILD_SHARED_LIBRARY)
      

    此 makefile (<GLPIFramework>/libpng/Android.mk) 需要包含在主项目 makefile (SimpleTexture/Android/JNI/Android.mk) 中,并且必须在主项目的 makefile 中包含以下行,以便编译它:

    include $(MY_CUR_LOCAL_PATH)/../ ../../../GLPIFramework/libpng/Android.mk
    

    生成的名为 GLPipng 的共享库必须添加到项目中,如下代码所示:

    LOCAL_SHARED_LIBRARIES := GLPipng
    
  2. 为了在外部存储上读取或写入文件,您的应用必须获取系统权限:

    注意

    从 Android 4.4 开始,如果您只读取或写入您应用私有的文件,则不需要这些权限。

    <manifest ...><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />...</manifest>
    
  3. 创建一个从 Model 派生的 SimpleTexture 类;在这个类的构造函数中,使用 PngImage 类成员变量 image 加载图像:

    SimpleText::SimpleText( Renderer* parent ){
        . . . .
       modelType          = ImageDemoType;
       char fname[MAX_PATH]= {""};
    
        #ifdef __APPLE__
          GLUtils::extractPath( getenv( "FILESYSTEM" ), fname );
        #else
           strcpy( fname, "/sdcard/Images/" );
        #endif
    
        strcat( fname, "cartoon.png" );
        image = new PngImage();
        image->loadImage(fname);
    }
    
  4. PngImage::loadImage() 负责加载图像并为加载的纹理分配一个唯一的名称,该名称由 OpenGL ES 在系统中唯一识别纹理。

    • 语法

      void PngImage::loadImage(char* fileName, bool generateTexID = true, GLenum target = GL_TEXTURE_2D );
      
      变量 描述
      fileName 这是需要加载的图像文件名称。
      generateTexID 这是一个布尔值,用于决定图像是否需要唯一的名称 ID。如果布尔值为 true,则加载的图像被分配一个唯一的 ID;如果布尔值为 false,则不分配 ID 给图像。此参数的默认值是布尔值 true
      target 这指定了需要绑定纹理的目标。可能的目标是 GL_TEXTURE_2DGL_TEXTURE_3DGL_TEXTURE_2D_ARRAYGL_TEXTURE_CUBE_MAP。此参数的默认值是 GL_TEXTURE_2D
    • 代码PngImage 类的 loadImage 函数的工作代码如下:

      bool PngImage::loadImage(char* fileName, 
                     bool generateTexID, GLenum target ){
      
      // Get the image bits from the png file.
      memData.bitsraw = read_png_file( fileName);
      
         // Generate the texture ID if it is not produced before
         if ( generateTexID ){
              GLuint texID;
              glGenTextures ( 1,&texID );
              memData.texID = texID;
      
            // Depending upon the target type bind the
            // texture using generated texture ID handler 
      
              if (target == GL_TEXTURE_2D){
                  glBindTexture(GL_TEXTURE_2D,texID );
              }
         // Similarly, handle cases like GL_TEXTURE_2D, 
              // GL_TEXTURE_3D, and GL_TEXTURE_2D_ARRAY etc.
          }
      
          // Get the colorType from ligpng for current 
          // image and prepare the texture accordingly
          switch (colorType) {
             case PNG_COLOR_TYPE_RGB_ALPHA: {
                  glTexImage2D ( target,  0, GL_RGBA,
                   memData.width, memData.height, 0, GL_RGBA,
                   GL_UNSIGNED_BYTE,memData.bitsraw);
              break; 
              }
             // Similarly, handle other cases: -
            // PNG_COLOR_TYPE_GRAY,PNG_COLOR_TYPE_RGBetc.
      
          }
      
                   // Release the allocate memory for image bits.
          free(memData.bitsraw);
          memData.bitsraw=NULL;return true;
      }
      
  5. loadImage 函数解析指定的图像文件名并将读取的图像缓冲区存储在 PngImage 类的 bitraw 成员中。

    唯一的纹理名称是通过使用glGenTexture OpenGL ES API 生成的。此 API 根据n指定的数量在纹理中生成一定数量的未使用名称。该名称以无符号整数 ID 的形式存在;生成的 ID 存储在 PngImage 的成员变量texID中。

    • 语法:

      void glGenTextures(GLsizei n, GLuint * textures);
      
      变量 描述
      n 这指定了要生成的纹理名称数量
      textures 这指定了一个未使用的生成纹理名称数组
    • 考虑以下代码:

      GLuint texID;
      glGenTextures   ( 1,&texID );
      memData.texID = texID;
      

    使用glBindTexture将生成的texID绑定到指定的目标;OpenGL ES 3.0 的此 API 指定了管道以及它需要管理的纹理类型。例如,以下代码提到 OpenGL ES 当前状态包含一个 2D 类型的纹理:

             if (target == GL_TEXTURE_2D){
                glBindTexture ( GL_TEXTURE_2D,texID );
              }
    

    此 API 非常重要,必须调用以在纹理上执行任何操作;它将正确的纹理名称绑定到 OpenGL ES,这允许您对其进行任何纹理操作。

    • 语法:

      void glBindTexture(GLenum target, GLuint texture);
      
      变量 描述
      target 这指定了纹理绑定的目标。这必须是GL_TEXTURE_2DGL_TEXTURE_3DGL_TEXTURE_2D_ARRAYGL_TEXTURE_CUBE_MAP之一。
      texture 这指定了一个未使用的生成纹理名称数组。
  6. 使用 OpenGL ES 3.0 的glTexImage2D API 将图像加载到 OpenGL ES 纹理内存中:

    glTexImage2D ( target, 0, GL_RGBA,  memData.width,
    memData.height,0,GL_RGBA,GL_UNSIGNED_BYTE,memData.bitsraw);
    

    描述glTexImage2D API 每个参数的语法如下:

    • 语法:

      void glTexImage2D(GLenum target, GLint level, GLint internalFormat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const GLvoid * data);
      
      变量 描述
      target 这指定了纹理绑定的目标。
      level 这是米级映射的细节级别数。
      internalFormat 这指定了纹理中的组件数。例如,这个配方使用了一个具有四个组件(红色、绿色、蓝色和 alpha)的图像。因此,格式将是GL_RGBA
      width 这指定了纹理的宽度;OpenGL ES 3.0 的新版本支持所有实现中的 2048 个 texels。
      height 这指定了纹理的高度;OpenGL ES 3.0 的新版本支持所有实现中的 2048 个 texels。
      border 此值必须是 0。
      format 这指定了像素数据格式;对于这个配方,它是GL_RGBA
      type 这指定了像素数据的数据类型;在这个配方中,所有使用的组件都是 8 位无符号整数。因此,类型必须是GL_UNSIGNED_BYTE
      data 这是一个指向图像解析数据的指针。
  7. 创建一个名为SimpleTextureVertex.glsl的顶点着色器文件,并添加以下代码;此着色器文件从 OpenGL ES 程序接收顶点和纹理坐标信息;接收到的纹理坐标随后被发送到片段着色器以进行纹理采样:

    #version 300 es
    layout(location = 0) in vec3  VertexPosition;
    layout(location = 1) in vec2  VertexTexCoord;
    out vec2 TexCoord;
    uniform mat4 ModelViewProjectMatrix;
    
    void main( void ) {
      TexCoord = VertexTexCoord;
      gl_Position=ModelViewProjectMatrix*vec4(VertexPosition,1.0);
    }
    

    类似地,创建一个名为 SimpleTexureFragment.glsl 的着色器文件;这个文件负责接收从顶点着色器传来的纹理坐标和纹理图像。纹理以 sampler2D 的形式接收,这是 GLSL 中的一个内置数据类型,用于在着色器中访问纹理。另一个 GLSL API 纹理用于检索片段颜色;这个 API 接受纹理和纹理坐标作为参数:

    #version 300 es
    precision mediump float;
    in vec2 TexCoord;
    uniform sampler2D Tex1;
    layout(location = 0) out vec4 outColor;
    
    void main() {
        outColor = texture(Tex1, TexCoord);
    }
    
  8. 定义正方形的几何顶点和纹理坐标以将纹理映射到几何形状上:

    float quad[12] = { -1.0, -1.0,  0.0, 1.0, -1.0,  0.0,
                       -1.0, 1.0, -0.0, 1.0, 1.0, -0.0 };
    float texCoords[8] = { 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0 };
    

    如何操作...

    注意

    单个纹理始终在 UV 坐标系中以 (0.0, 0.0) 左下角到 (1.0, 1.0) 右上角表示。如果纹理坐标超出这些尺寸范围,则可以应用特殊的包裹规则来控制纹理包裹。有关更多信息,请参阅本配方中的 还有更多… 部分。

  9. OpenGL ES 着色器通过纹理单元访问加载的图像;纹理单元是能够访问图像的硬件部件。每个纹理单元都有一个从 0GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS -1 的 ID。为了使一个纹理单元生效,使用 glActiveTexture。在当前配方中,加载的纹理通过纹理单元 0 (GL_TEXTURE0) 使着色器可访问。将纹理绑定到这个纹理单元:

    glActiveTexture(GL_TEXTURE0); //Make texture unit 0 active.
    glBindTexture(GL_TEXTURE_2D, image->getTextureID());
    

    使用 glUniform1i 将纹理单元 ID 发送到片段着色器。在片段着色器中,Tex1 常量变量接收这个信息;查询这个常量变量的位置以提供纹理单元信息。注意,这里的 0 是纹理单元号,而不是纹理句柄:

       TEX = ProgramManagerObj->ProgramGetUniformLocation
                               ( program, (char *) "Tex1" );
        glUniform1i(TEX, 0);
    
  10. 使用 glTexParameterf 设置纹理的缩小、放大和包裹行为:

       glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
       glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_LINEAR);
       glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);
       glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
    
  11. 使用当前着色器程序并将顶点和纹理坐标信息发送到着色器以渲染几何形状:

          glUseProgram(program->ProgramID);
          glDisable(GL_CULL_FACE); // Disable culling
          glEnable(GL_BLEND);      // Enable blending
          glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);
                                   //Send Vertices
          glEnableVertexAttribArray(VERTEX_POSITION); 
          glEnableVertexAttribArray(TEX_COORD); //Send Tex Coordinate
          glVertexAttribPointer
          TEX_COORD, 2, GL_FLOAT, GL_FALSE, 0, texCoords);
          glVertexAttribPointer
          (VERTEX_POSITION, 3, GL_FLOAT, GL_FALSE, 0, quad);
          glUniformMatrix4fv
          ( MVP, 1, GL_FALSE,( float * )TransformObj->
          TransformGetModelViewProjectionMatrix() );
    

它是如何工作的...

GLPI 框架提供了一个名为 PNGImage 的高级 PNG 图像解析类;它内部使用 libpng 库解析 PNG 文件,并将关键信息存储在本地数据结构中。这个类生成纹理对象,与 OpenGL 状态机绑定,并在其中加载图像缓冲区数据。

OpenGL ES 通过纹理对象支持纹理;这些纹理对象是在 loadImage 函数中使用 glGenTextures API 准备的。这个 API 在幕后生成一个纹理对象,并返回一个唯一的 (texID) 名称 ID。OpenGL ES 是一个状态机;因此,在纹理上应用任何操作之前,需要将其设置为当前纹理;这可以通过 glBindTexture 实现。这个 API 将 texID 绑定到当前的 OpenGL ES 状态作为当前纹理,这使得 OpenGL ES 状态机能够将所有与纹理相关的操作应用于当前纹理对象。

OpenGL ES 以图像缓冲区形式将其纹理加载到其纹理内存中;此信息通过glTexImage2D提供,该函数指定了底层可编程管道中图像的格式。glActiveTexture API 用于将纹理绑定到纹理单元;OpenGL ES 中的纹理单元旨在访问片元着色器中的纹理。在我们的菜谱中,加载的纹理附加到纹理单元0GL_TEXTURE0)。片元使用包含通过纹理单元附加的句柄的统一Sampler2D数据类型。glUniform1i用于将信息发送到片元着色器中的Tex1变量:

   TEX = ProgramManagerObj->ProgramGetUniformLocation
   ( program, (char *) "Tex1" );
   glUniform1i(TEX, 0);

顶点着色器有两个通用属性,即VertexPositionVertexTexCoord,它们接收顶点坐标和纹理坐标。每个顶点的纹理坐标(在顶点着色器中接收)通过TexCoord发送到片元着色器。

片元着色器负责采样纹理;采样是一个使用纹理坐标选择一个期望的texel的过程;这个texel提供了需要应用到原始图形中相应像素的颜色信息。它使用传入的每个顶点的通用属性TexCoord来检索纹理坐标和在采样器 2D 中的纹理句柄。纹理句柄允许您从 OpenGL ES 纹理内存中访问纹理,以便在着色器中执行采样操作。着色语言为采样目的提供了一个纹理;它使用纹理句柄,在这个菜谱中为0,以及TexCoord纹理坐标。

工作原理...

还有更多...

在本节中,我们将讨论 OpenGL ES 3.0 管道中可用的各种内置过滤和包装技术。这些技术通过指定各种符号常量通过glTexParamterfglTexParameteriglTexParameterfglTexParameterivglTexParameterfv来应用。

注意

与纹理不同,坐标具有 UV 坐标系;采样的texel具有 ST 坐标系的传统,其中 S 对应水平轴,T 对应垂直轴。这可以用来定义采样过程中 S 和 T 方向上的过滤和包装行为。

过滤

纹理过滤技术允许您控制纹理质量的外观;有时,在正确的深度,一个texel在屏幕上正好对应一个像素。然而,在其他情况下,将较小的纹理映射到较大的几何体上可能会导致纹理看起来被拉伸(放大)。同样,在相反的情况下,许多texel被几个像素所着色(最小化)。

这种情况被称为最小化处理和放大处理。让我们详细看看:

  • 最小化处理:这种情况发生在几个屏幕像素对应许多 texel 时。

  • 放大:这种情况发生在几个屏幕像素对应少数texel时。

为了处理缩小和放大,OpenGL ES 3.0 提供了以下两种类型的过滤技术:

  • GL_NEAREST:这使用最接近纹理坐标的像素颜色

  • GL_LINEAR:这使用最接近纹理坐标的四个周围像素的加权平均值过滤

OpenGL ES 3.0 提供了GL_TEXTURE_MAG_FILTERGL_TEXTURE_MIN_FILTER作为符号常量,可以在glTexParameterf中用作参数,分别指定放大和缩小时的过滤技术。

包装

一个显而易见的问题是,当纹理映射的范围大于 1.0 时会发生什么;OpenGL ES 3.0 采样允许三种类型的包装模式:

  • GL_REPEAT:这会产生重复图案

  • GL_MIRRORED_REPEAT:这会产生一个相邻纹理镜像的重复图案

  • GL_CLAMP_TO_EDGE:这会产生重复的边缘像素

以下图像使用了 2 x 2 纹理坐标,并演示了包装模式的使用:

包装

MIP 映射

这是一种纹理映射技术,通过减少走样效应来提高视觉输出,并通过减少纹理带宽来提高系统的性能。MIP 映射使用预先计算的版本作为纹理(其中每个纹理的分辨率是前一个纹理的一半)。根据观察者与纹理的距离,在运行时选择合适的纹理。

纹理可以从远距离或近距离的观察者处查看;这会改变纹理的形状和大小,导致纹理出现缩小和放大的伪影。这些伪影可以通过使用之前提到的过滤器来最小化,但只有在纹理大小以半或双倍的比例缩放时,才能产生有效结果。超出这些比例,过滤器可能不会产生令人满意的结果。MIP 映射通过根据观察者与给定纹理的距离选择正确的分辨率来提高质量。它不仅通过最小化缩小/放大的伪影来提高图像质量,而且还通过选择正确的分辨率纹理而不是使用全分辨率图像来提高系统的性能:

MIP 映射

可以使用glGenerateMipmap API 生成 MIP 映射。

语法

void glGenerateMipmap(GLenum target);
变量 描述
target 这指定了纹理 MIP 映射将要生成和绑定的目标类型。目标必须是GL_TEXTURE_2DGL_TEXTURE_3DGL_TEXTURE_2D_ARRAYGL_TEXTURE_CUBE_MAP之一。

生成的 MIP 映射可以使用glTexImage2D API 绑定到特定的深度级别;此 API 的第二个参数可以用来指定细节级别。请参阅当前配方下的“如何做……”部分的步骤 2,以查看glTexImage2D API 的完整描述。

参见

  • 请参考第六章中的使用纹理坐标进行程序纹理着色菜谱,使用着色器

  • 应用多个纹理

  • 使用 ETC2 压缩纹理进行高效渲染

使用 ETC2 压缩纹理进行高效渲染

由于许多原因,压缩纹理比未压缩纹理更受欢迎;主要好处是减少了设备上的内存占用,应用程序的下载大小更小,并且性能有所提高。OpenGL ES 3.0 规范要求所有供应商必须支持 ETC2 和 EAC 纹理压缩格式。在此之前,在 OpenGL ES 2.0 中,纹理压缩不是标准化的,因此出现了各种针对特定硬件的扩展。为了在不同的设备上实现纹理压缩,开发者必须支持各种扩展的程序。

在本菜谱中,我们将演示非常著名的 ETC2,它在不同的纹理压缩方案中都非常受欢迎。ETC 代表Ericson Texture Compression,这是一种有损纹理压缩技术;该方案支持 RGB 和 RGBA 格式。此外,本菜谱还演示了 OpenGL ES 3.0 的新特性,能够加载非 2 的幂NPOT)纹理。

准备工作

ETC2 压缩纹理可以存储在两种文件格式中,即 KTX 和PKMKTX文件格式是一个标准的 Khronos Group 压缩格式,它将多个纹理存储在单个文件中;例如,KTX中的 mipmap 只需要一个文件来包含所有 mipmap 纹理。另一方面,PKM是一个非常简单的文件格式,它将每个压缩纹理存储为单独的文件。因此,在 mipmap 的情况下,将生成多个文件。对于本菜谱,我们将使用PKM文件格式。它由一个头部和一个随后的有效负载组成;以下 c 结构声明描述了头部:

   struct ETC2Header {
     char name[4];                 // "PKM "
     char version[2];              // "20" for ETC2
     unsigned short format;        // Format
     unsigned short paddedWidth;   // Texture width,(big-endian)
     unsigned short paddedHeight;  // Texture height,(big-endian)
     unsigned short origWidth;    // Original width(big-endian)
     unsigned short origHeight;   // Original height(big-endian)
   };

OpenGL ES 3.0 支持使用glCompressedTexImage2D API 进行压缩纹理。

语法:

void glCompressedTexImage2D(GLenum target, GLint level, Glint internalFormat, GLsizei width, GLsizei height, GLint border, GLenum imageSize, const GLvoid * data);

除了internalFormatimageSize,大多数参数与glTexImage2D类似,这在第一道菜谱中已有描述。前者是压缩纹理的格式,后者指定了图像大小,它使用公式进行特别计算。例如,在本菜谱中,internalFormat是一个GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2格式,它是一个 RGBA 格式。imageSize使用公式*ceil(width/4) * ceil(height/4) * 8*计算,其中 width 和 height 是图像的尺寸。

注意

关于内部结构和图像大小计算的更多信息,请参阅www.khronos.org/opengles/sdk/docs/man3/html/glCompressedTexImage2D.xhtml的 OpenGL ES 3.0 参考页面。

如何操作...

执行以下步骤以编程压缩纹理;您可以参考本章的CompressedTexture示例食谱。在本食谱中,我们将在正方形平面上渲染压缩图像:

  1. 本食谱重用了我们的第一个SimpleTexture;顶点或片段着色器没有变化;渲染正方形几何体的代码也已重用。有关更多信息,请参阅使用 UV 映射应用纹理

  2. 为了处理压缩的 PKM 格式图像,GLPI 框架提供了一个名为CompressImage的高级辅助类。该类负责使用loadImage函数加载压缩的 PKM 图像。可以使用以下代码加载压缩图像:

        char fname[MAX_PATH]= {""};
        #ifdef __APPLE__
        GLUtils::extractPath( getenv( "FILESYSTEM" ), fname );
        #else
        strcpy( fname, "/sdcard/Images/" );
        #endif
        strcat( fname, "SmallEarth.pkm" );
       compressImage = new CompressedImage();
       compressImage->loadImage(fname);
    
  3. CompressedImage::loadImage中,打开压缩图像并读取之前提到的 ETC2 头部规范中指定的头部字节:

       FILE *fp = fopen(fileName, "rb");
       if (!fp){ return false; }
       ETC2Header pkmfile;    
       fread(&pkmfile, sizeof(ETC2Header), 1, fp);
    
  4. 将读取的字节转换为 Big Endian 格式:

       pkmfile.format      = swap_uint16(pkmfile.format);
       pkmfile.paddedWidth = swap_uint16(pkmfile.paddedWidth);
       pkmfile.paddedHeight = swap_uint16(pkmfile.paddedHeight);
       pkmfile.origWidth   =  swap_uint16(pkmfile.origWidth);
       pkmfile.origHeight  = swap_uint16(pkmfile.origHeight);
    
  5. 根据本食谱中“准备工作”部分中提到的指定公式计算压缩图像的大小;使用它来读取有效载荷图像缓冲区:

       memData.width   = pkmfile.paddedWidth;  // Texture Width
       memData.height  = pkmfile.paddedHeight; // Texture Height
    
       // This only handles the pkmfile format
       unsigned int imageSize =       ceil(memData.width/4)*ceil(memData.height/4)*8;
       memData.bitsraw = (unsigned char*) malloc(imageSize);
    
       fread(memData.bitsraw, imageSize, 1, fp); //Load Payload
       if (!memData.bitsraw){ return false; }
    
  6. 生成并绑定名为texID的纹理,并使用glCompressedTexImage2D加载压缩纹理图像缓冲区:

       GLuint texID;
       glGenTextures( 1,&texID );
       glBindTexture( GL_TEXTURE_2D,texID );
       glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_COMPRESSED_RGB8
       _PUNCHTHROUGH_ALPHA1_ETC2, memData.width,memData.height,
       0,imageSize, memData.bitsraw);
    

它是如何工作的...

CompressedTexture类有助于加载 PKM 格式 ETC2 压缩纹理图像。PKM 文件格式很简单;头部ETC2Header大小为 16 字节,有效载荷是可变的。头部的前四个字节必须是 PKM,接下来的两个字节必须是20以确保 ETC2 方案。该格式提供了压缩图像的内部格式,接下来的两个字节提供了图像填充的维度,最后两个字节分别代表图像的原始像素维度。内部格式有助于识别正确的公式来计算图像的大小:

imageSize = ceil(memData.width/4) * ceil(memData.height/4) * 8;

最后,使用 OpenGL ES 3.0 API 的glCompressedTexImage2D加载压缩纹理;此 API 还将提供所有压缩内部格式的表格参考,这对于了解图像大小计算公式非常有帮助,如前述代码中所述。有关使用 UV 纹理坐标进行纹理渲染的更多信息,请参阅前面的食谱。

还有更多...

有许多纹理压缩工具可供使用,可用于纹理压缩;其中,著名的工具有 PVRtexTool、Mali GPU 纹理压缩工具等。您可以使用它们将所需的图像压缩成 PKM 格式。

参见

  • 使用 UV 映射应用纹理

应用多个纹理

多纹理允许您在给定的几何体上应用多个纹理,以产生许多有趣的结果;现代图形允许您通过纹理单元将多个纹理应用到几何体上。在本食谱中,您将学习如何利用多个纹理单元来实现多纹理。

准备工作

这个配方与我们的第一个配方类似,即SimpleTexture。唯一的区别是我们将使用多个纹理。我们不会使用 2D 平面几何形状,而是使用 3D 立方体。此外,还需要在片段着色器中进行一些更改。我们将在下一节中讨论这个问题。

如何操作...

本节将讨论为支持多个纹理所做的所有重要更改:

  1. 修改片段着色器以同时支持两个给定的纹理;这两个纹理使用TexFragileTexwood句柄引用:

    #version 300 es
    precision mediump float;
    
    in vec2 TexCoord;
    uniform sampler2D TexFragile; // First Texture
    uniform sampler2D TexWood;    // Second Texture
    
    layout(location = 0) out vec4 Color;
    
    void main() {
       vec4 TextureFragile = texture(TexFragile, TexCoord);
       vec4 TextureWood    = texture(TexWood, TexCoord);  
       Color=mix(TextureWood,TextureFragile,TextureFragile.a);
    }
    
  2. 创建一个名为loadMultiTexture的函数,该函数将负责在MultipleTexture类中加载多个纹理;它必须在加载和编译着色器程序之后调用。在这个函数中,查询TexFragileTexwood均匀采样器变量的位置:

    void MultipleTexture::loadMultiTexture(){
        glUseProgram( program->ProgramID );
        // Query uniform samplers location
        TEX  = ProgramManagerObj->ProgramGetUniformLocation
                        ( program, (char *) "TexFragile" );
        TEX2 = ProgramManagerObj->ProgramGetUniformLocation
                        ( program, (char *) "TexWood" );
    }
    
  3. 激活纹理单元1,并使用 PngImage 类和loadImage函数加载fragile.png图像。这负责创建命名纹理 ID 并将其绑定到当前 OpenGL ES 状态。内部,此 API 使用glGenTexturesglBindTextureglTexImage2D来加载图像;此包装 API 使加载图像变得容易:

         glActiveTexture(GL_TEXTURE1);
          image = new PngImage();
          image->loadImage(creatPath(fname, (char*)"fragile.png"));
    
  4. 设置纹理过滤和包装属性:

       glTexParameterf
       (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
       glTexParameterf
       (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
       glUniform1i(TEX, 1);
    
  5. 使用TexFragileTEX位置,通过glUniform1i API 将纹理单元信息发送到着色器。可以使用纹理单元1访问 Fragile.png 纹理;因此,在glUniform1i API 中将1作为参数发送:

       glUniform1i(TEX, 1); // Attached to texture unit 1
    
  6. 类似地,对于第二个纹理,即 wooden.png,按照从第三步到第五步提到的相同程序进行操作:

       glActiveTexture(GL_TEXTURE2);
       image->loadImage(creatPath(fname, (char*)"woodenBox.png"));
       image2 = new PngImage();
       image2->loadImage(fname);
       glTexParameterf
       (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
       glTexParameterf
       (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
       glUniform1i(TEX2, 2); // Attached to texture unit 2
    

它是如何工作的...

片段着色器使用两个采样器,即TexFragileTexWood;这些用于在着色器中访问纹理图像。它存储纹理单元的句柄;因此,从片段着色器查询它们的地址并将其存储在TEXTEX1中非常重要。使用PngImage::loadImage函数在 OpenGL 纹理内存中加载纹理图像。对于单个或多个纹理,必须激活纹理单元,以便它们在着色器程序中可用;使用glActiveTexture API 激活纹理单元。它接受纹理单元的句柄作为参数。有关纹理单元的更多信息,请参阅下一节。

为第一个纹理对象(fragile.png)激活了纹理单元 1,并使用glUniform1i(TEX1, 1)将其对应的统一变量设置为1。同样,第二个纹理单元(woodenBox.png)被激活,其对应的统一变量TEX1被设置为值2。对于顶点着色器没有特殊的要求,因为它设置了输入位置的裁剪坐标,并与片段着色器共享纹理坐标。片段着色器利用这些纹理坐标从可用的两个纹理中进行纹理采样;采样提供了存储在TextureFragileTextureWood中的两个颜色值;这些颜色通过混合 GLSL API 混合在一起,以产生混合颜色效果;此 API 接受三个输入参数。前两个参数指定需要混合的颜色,而第三个参数指定这些颜色混合的比例。

还有更多...

可以将纹理单元视为包含纹理信息的缓冲区,并且纹理单元的数量是固定的;这个数字非常具体,取决于 OpenGL ES 3.0 的硬件实现。这个数字可以通过使用GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS宏来检查。纹理对象不是直接绑定到着色器程序上的。相反,它们绑定到纹理单元的索引上。

在以下图中,纹理内存显示了 16 个纹理单元。其中,只有三个看起来是空的(以蓝色表示),其余的都被各种纹理图像所占用。纹理单元可以通过它们的索引唯一识别;这些可以直接在着色器程序中访问,从而赋予多纹理的独特能力。纹理单元 1 和 2 在片段着色器中被访问,以产生如图所示所需输出:

还有更多...

使用无缝立方图映射实现天空盒

立方图映射是一种在 3D 图形中使用的纹理技术,用于用给定的一组图像填充场景的背景。这项技术减少了绘制场景所需的对象数量,以使场景看起来更加拥挤(场景看起来更大)。它通常用于游戏,以渲染天空地平线、房间、山脉、昼夜效果、反射和折射。

立方图是通过将六组图像分别包裹在立方体的六个面上来实现的;这些图像在边缘处完美拼接。在立方图映射技术中,观察者或相机始终位于立方体的中心。当相机在三维空间中移动时,立方体也会相对于它移动。这样,相机永远不会接近立方体的任何一面,从而产生一个始终与观察者保持相同距离的地平线幻觉。

准备中

此配方使用六个图像(底部、顶部、左侧、右侧、前侧和后侧)为要映射的立方体每个面命名,如图所示。当这些图像围绕立方体包裹并从内部观看时,会产生天空环境的幻觉。到目前为止,我们已经在我们之前的配方中学习了如何使用 UV 纹理坐标映射将纹理映射到给定的几何形状。然而,OpenGL ES 提供了一种特殊的映射称为立方体贴图;这种映射使得将图像包裹到立方体形状几何形状的工作变得更容易。

在 OpenGL ES 3.0 中创建立方体贴图很简单:

  1. 使用glGenTexture创建一个纹理对象。

  2. 使用带有GL_TEXTURE_CUBE_MAP参数的glBindTexture API 绑定纹理。这将帮助 OpenGL ES 理解它需要存储的纹理类型。

  3. 使用glTexImage2DGL_CUBE_MAP_{POSITIVE, NEGATIVE}_{X, Y, Z}作为目标参数,在 OpenGL ES 纹理内存中加载六个图像:准备中

如何操作...

本节将描述实现此配方所需的实际步骤:

  1. 创建一个名为 Skybox 的类来渲染立方体几何形状;你可以重用第二章中提到的使用顶点缓冲对象进行高效渲染配方。

  2. 实现如以下代码所示的顶点和片段着色器。对于立方体贴图,我们在片段着色器中需要顶点信息。因此,每个传入的顶点信息都需要与片段着色器共享:

    顶点着色器 片段着色器

    |

    //CubeMappingVertex.glsl
    #version 300 es
    
    layout(location = 0) in vec4  VertexPosition;
    uniform mat4 MVP;
    out vec4 Vertex;
    
    void main( void ) {
      Vertex = VertexPosition;
      gl_Position
    
    x       =MVP*VertexPosition;
    }
    

    |

    //CubeMappingFragment.glsl
    #version 300 es
    precision mediump float;
    uniform samplerCube CubeMapTexture;
    in vec4 Vertex;
    layout(location = 0) out vec4 outColor;
    
    void main() {
     outColor = texture(
      CubeMapTexture, Vertex.xyz);
    }
    

    |

  3. Skybox类中创建一个名为createCubeMap的函数,并在着色器加载和编译后调用以下函数:

    void Cube::InitModel(){
       //Load and compile shaders . . . .
       . . . .
       createCubeMap(); // Create the Cube Map
    }
    
    void Skybox::createCubeMap(){
       glActiveTexture(GL_TEXTURE1);
       char fname[MAX_PATH]= {""};
       image = new PngImage();
    
       image->loadImage(creatPath(fname, (char*)"Right.png"),
                     true,  GL_TEXTURE_CUBE_MAP_POSITIVE_X);
       image->loadImage(creatPath(fname, (char*)"Left.png"),  
                      false, GL_TEXTURE_CUBE_MAP_NEGATIVE_X);
       image->loadImage(creatPath(fname, (char*)"Top.png"),
                      false, GL_TEXTURE_CUBE_MAP_POSITIVE_Y);
       image->loadImage(creatPath(fname, (char*)"Bottom.png"),
                      false, GL_TEXTURE_CUBE_MAP_NEGATIVE_Y);
       image->loadImage(creatPath(fname, (char*)"Front.png"),  
                      false, GL_TEXTURE_CUBE_MAP_POSITIVE_Z);
       image->loadImage(creatPath(fname, (char*)"Back.png"),
                      false, GL_TEXTURE_CUBE_MAP_NEGATIVE_Z);
    
       glTexParameterf(GL_TEXTURE_CUBE_MAP,
                      GL_TEXTURE_MAG_FILTER, GL_LINEAR);
       glTexParameterf(GL_TEXTURE_CUBE_MAP,
                      GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    
       // The clamping is important for Skyboxes 
       // due to texel filtering
       glTexParameterf(GL_TEXTURE_CUBE_MAP,
                      GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
       glTexParameterf(GL_TEXTURE_CUBE_MAP,
                      GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
       glTexParameterf(GL_TEXTURE_CUBE_MAP,
                      GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
       uniformTex=ProgramManagerObj->ProgramGetUniformLocation
                      (program,(char*)"CubeMapTexture" );
    
       if (uniformTex >= 0)
          { glUniform1i(uniformTex, 1); }
    }
    
  4. createCubeMap函数中,使纹理单元1激活;这允许你从片段着色器访问立方体贴图纹理:

    glActiveTexture(GL_TEXTURE1);
    
  5. createCubeMap函数首先使用PngImage::loadImage加载六个图像。这个函数将纹理对象创建到 OpenGL ES 纹理内存中。只有第一个图像需要在第二个参数中发送true值;这个参数将告诉函数生成指定的纹理(给纹理对象一个 ID)。其余的图像将使用相同的纹理名称(ID);因此,其余的必须使用false参数发送。如果图像出现在立方体盒子的右手侧角,并且(Right.png)位于正x轴上,那么使用GL_TEXTURE_CUBE_MAP_POSITIVE_X作为第四个参数。同样,对于其他图像,使用适当的参数,如前述代码所示。

  6. 设置线性过滤用于缩小/放大和包装方案。

  7. 从片段着色器查询CubeMapTexture统一采样器的位置,并将纹理单元句柄设置为1

  8. 使用Skybox::Render函数渲染场景:

       void Skybox::Render(){
       glDisable(GL_CULL_FACE); glDisable(GL_DEPTH_TEST);
       glUseProgram( program->ProgramID );
       // Transform as per your scene requirement. . .
       glBindBuffer( GL_ARRAY_BUFFER, vId );
       glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, iId );
       glDrawElements(GL_TRIANGLES,36,GL_UNSIGNED_SHORT,(void*)0);
             }
    

它是如何工作的...

立方体贴图纹理需要六组 2D 图像;这些图像被映射到立方体几何的六个面上。选择一个纹理单元并使其激活。在本例中,其纹理单元为1GL_TEXTURE1)。使用PngImage::loadImage加载图像;此函数在Skybox::InitModel中被调用。在着色器加载后,它接受三个参数。第一个参数指定要加载的图像文件,第二个参数决定是否创建纹理对象。例如,在立方体贴图的情况下,只需要第一张图像来创建纹理对象;其余的图像将共享相同的纹理对象。最后一个参数指定图像属于立方体贴图的哪个面。在此函数中,它使用glGenTexture创建一个纹理对象,并使用glBindTextureGL_TEXTURE_CUBE_MAP参数将其绑定。glTexImage2D API 将为所有纹理分配必要的存储空间;此 API 接受重要的参数,如GL_TEXTURE_CUBE_MAP_POSITIVE_XGL_TEXTURE_CUBE_MAP_NEGATIVE_X等,并帮助 OpenGL ES 知道在哪个表面上应用哪个纹理。将存储在纹理单元1中的立方体贴图纹理共享到片段着色器。

为了渲染立方体,我们重用了第二章, 《OpenGL ES 3.0 基础》中的使用顶点缓冲对象高效渲染配方。渲染过程发生在Render()函数中,立方体被缩放以填满屏幕,并且应该禁用剔除和深度测试。

从着色器的角度来看,立方体顶点在顶点着色器中被接收;这些以位置向量的形式共享到片段着色器中,原点位于(0.0,0.0,0.0)。位置向量实际上与顶点位置相同。片段着色器中的这个顶点位置用于采样目的,其中纹理 API 提供了采样器和顶点位置;它返回片段的相应颜色。

参见

  • 使用环境贴图实现反射和折射

  • 请参考第二章, 《OpenGL ES 3.0 基础》中的使用顶点缓冲对象高效渲染配方。

使用环境贴图实现反射和折射

环境映射是一种简单、有效且高效的技巧,允许你将周围环境效果映射到渲染 3D 对象。环境映射可以使用两种方式:反射和折射。在前一种技术中,渲染的对象被映射到周围环境的反射,显示了对象的周围视图的反射。然而,在后一种情况下,通过折射映射的对象允许你透过对象看。这些环境映射技术需要我们在先前的食谱“无缝立方体贴图天空盒”中编写的立方体贴图。在本食谱中,我们将实现反射和折射的环境映射。

准备中

对于这个食谱,我们可以重用第五章中“实现无缝立方体贴图天空盒”和“渲染 wavefront OBJ 网格模型”的食谱。前者不需要任何特殊更改。然而,我们将为后者编写一个新的着色器。

反射是一种现象,当光/波与其他介质相互作用时,会改变其方向。结果,它从它来的相同介质弹回。光线入射角在弹跳后始终等于反射角,如下面的图所示:

准备中

折射是一种现象,当波/光通过其传播的传输介质时,会改变其方向。这种弯曲的原因是这两种介质的光学密度之间的差异。例如,一根放在水杯中的吸管看起来是弯曲的,因为光在给定介质/材料(如空气和水)中的传播速度不同。这种影响光速的介质或材料的特性称为折射率。介质的折射率告诉我们光在给定介质中的传播速度;它是光在真空中的速度(c)与在该介质中的速度(v)的比值,n=c/v,因此,光的弯曲由其折射率决定。

斯涅尔定律给出了折射率与传播方向之间的关系。数学上,n1.sinθ1 = n2.sinθ2。根据此定律,入射角的正弦与折射角的正弦之比(sinθ1/sinθ2)等于介质折射率之比的倒数(n2/n1)。

如何操作...

在本节中,你将学习逐步编写环境映射以实现反射和折射的程序:

  1. 环境映射所需的环境是通过本章先前的配方中使用的立方体贴图 Skybox 创建的。在 Skybox 内部,简单的 3D 波形,对象被渲染(参考渲染波前 OBJ 网格模型配方第五章, 与网格一起工作)。在createModels函数中添加SkyboxObjLoader模型,并包含所需的头文件:

    #include "ObjLoader.h"
    #include "Skybox.h"
    
    void Renderer::createModels(){
       clearModels();
       addModel( new Skybox ( this ) );
       addModel( new ObjLoader ( this ) );
    }
    

    Skybox 模型负责使用立方体贴图纹理渲染 Skybox 环境;着色器程序不需要更改。立方体贴图纹理存储在纹理单元1

  2. ObjLoader 模型渲染网格对象,并使用包含立方体贴图纹理的纹理单元1来应用反射和折射映射。

  3. 定义新的着色器程序(ReflectionVertex.glsl)用于顶点着色器:

    #version 300 es
    
    // Vertex information
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    uniform vec3    CameraPosition;
    
    // Model View Project matrix
    uniform mat4    MODELVIEWPROJECTIONMATRIX, MODELMATRIX;
    uniform mat3    NormalMatrix;
    
    vec3 worldCoordPosition, worldCoordNormal;
    out vec3 reflectedDirection;
    
    void main( void ) {
       worldCoordPosition = vec3(MODELMATRIX * VertexPosition);
       worldCoordNormal   = normalize(vec3( MODELMATRIX *
       vec4(Normal, 0.0)));
    
       // Make negative normals positive so that the face 
       // of back side will still remain illuminated, 
       // otherwise these will appear complete black 
       // when object is rotated and back side faces
        // the camera.
       if(worldCoordNormal.z < 0.0){
          worldCoordNormal.z = -worldCoordNormal.z;
        }
        worldView = normalize(CameraPosition – worldCoordPosition);
        reflectedDirection = reflect(worldView, worldCoordNormal );
        gl_Position = MODELVIEWPROJECTIONMATRIX * VertexPosition;
    }
    
  4. ReflectionFragment.glsl中使用以下代码反射映射片段着色器:

    #version 300 es
    precision mediump float;
    uniform samplerCube CubeMap;
    in vec3    reflectedDirection;
    
    layout(location = 0) out vec4 outColor;
    void main() {
        outColor = texture(CubeMap, reflectedDirection); }
    

    类似地,对于折射,重用前面的反射着色器,并定义一个名为RefractIndex的折射指数统一浮点变量。此外,用折射 API 替换 GLSL reflect API,并将reflectedDirection重命名为refractedDirection

    uniform float    RefractIndex;
    out vec3 refractedDirection;
    void main() {
      . . . . . .
      refractedDirection =
          -refract(worldView, worldCoordNormal, RefractIndex);
      gl_Position = MODELVIEWPROJECTIONMATRIX * VertexPosition;
    }
    
  5. 创建RefractionFragment.glsl并重用ReflectionFragment.glsl中的代码;所需更改仅是将传入的共享属性reflectedDirection重命名为refractedDirection

  6. ObjLoader::InitModel函数中加载和编译着色器,并初始化反射和折射着色器所需的所有统一变量。将当前纹理CubeMap从纹理单元1设置为它包含立方体贴图纹理。请注意,此纹理单元是从 Skybox 模型类加载的:

    void ObjLoader::InitModel()
    {
       glUseProgram( program->ProgramID );
       char uniformTex = ProgramManagerObj>
       ProgramGetUniformLocation(program, (char*)"CubeMap");
       if (uniformTex >= 0) {
       glUniform1i(uniformTex, 1);
       }
       char Camera = ProgramManagerObj->
       ProgramGetUniformLocation(program, "CameraPosition");
       if (Camera >= 0){
       glm::vec3 cp = RendererHandler->getCameraPosition();
       glUniform3fv(Camera, 1, (float*)&cp);
       }
    
       MVP = ProgramManagerObj->ProgramGetUniformLocation
       ( program, ( char* )"MODELVIEWPROJECTIONMATRIX" );
       M   = ProgramManagerObj->ProgramGetUniformLocation
       ( program, ( char* )"MODELMATRIX" );
       NormalMatrix  = ProgramManagerObj->
       ProgramGetUniformLocation(program, (char*)"NormalMatrix");
       return;
    }
    

工作原理...

反射和折射环境映射的工作模型非常相似;两者都使用立方体贴图纹理来产生反射和折射效果。以下图像展示了这种工作模型的逻辑。在这里,立方体贴图的上视图用绿色矩形表示,所有标记的边缘都是立方体的面。摄像机位置用一个眼睛表示,它朝向放置在立方体贴图 Skybox 内部的球体方向。每个顶点位置从摄像机位置产生一个入射光线,该光线与顶点位置的法向量一起用于计算反射向量。这个反射向量与立方体贴图纹理一起用于查找相应的 texel。例如,在以下图像中,反射后的顶点 v1、v2 和 v3 对应于立方体贴图的右面、后面和左面。同样,折射光线对应于立方体贴图的前面:

工作原理...

反射和折射的位置向量在顶点着色器中计算;这些向量与片段着色器共享,其中使用立方图纹理查找相应的 texel。

现在,我们知道环境映射的工作在更高层次;让我们了解反射环境映射的代码。顶点着色器使用模型矩阵(MODELMATRIX)在全局坐标中计算每个顶点位置(VertexPosition)和法线向量(Normal),并将其分别存储在worldCoordPositionworldCoordNormal中。根据相机位置计算每个向量的入射光线并存储在incidenceRay中。OpenGL ES 着色语言提供了一个高级的reflect() API 来计算反射向量。此 API 接受入射光线、法线向量,并返回反射向量。

语法:

genType reflect(genType I, genType N);
变量 描述
I 这是来自源到目的地的入射光线
N 这是表面的法线
Return 这是通过 *I - 2.0 * dot(N, I) N 给出的反射向量

反射向量通过一个名为反射方向的输出变量与片段着色器共享。片段着色器使用此向量通过texture() API 在立方图中查找相应的 texel。

如何工作...

类似地,折射是通过refract() GLSL API 计算的;与反射 API 不同,它接受一个额外的参数,即材料的折射率,并返回折射向量。

语法:

genType refract(genType I, genType N, float RI);
变量 描述
I 这是来自源到目的地的入射光线
N 这是表面的法线
RI 这是介质的折射率
Return 这是折射向量

折射向量通过refractedDirection与片段着色器共享。为相应的片段计算 texel 颜色。

参见

  • 实现无缝立方映射的天空盒

  • 请参考第四章中的渲染波前 OBJ 网格模型配方,处理网格

使用帧缓冲对象实现渲染到纹理

OpenGL ES 在帧缓冲上渲染场景;这个帧缓冲被称为默认帧缓冲。帧缓冲由各种缓冲区组成,例如颜色、深度和模板缓冲区。帧缓冲对象FBO)允许您创建用户定义的帧缓冲区,可用于在非默认帧缓冲区上渲染场景。在非默认帧缓冲区上渲染的场景可以用作纹理来映射对象。在本配方中,我们将演示渲染到纹理,其中场景被渲染到纹理,并将此纹理映射到二维平面表面;二维平面可以使用触摸手势事件在三维空间中旋转。

如何操作...

使用 FBO 实现渲染到纹理的详细步骤如下。我们将重用第六章中使用着色器生成圆点图案配方:

  1. 创建一个从Model基类派生的DemoFBO类,并添加SimpleTextureObjLoader指针对象;在DemoFBO的构造函数中初始化这些对象。有关依赖配方的信息,请参阅本配方中的也见子节:

    #include "ObjLoader.h"
    #include "SimpleTexture.h"
    class DemoFBO : public Model
    {
     private:
        void InitModel();
        ObjLoader* objModel;
        SimpleText* textureQuad;
        GLuint fboId, rboId, textureId, depthTextureId;
     public:
        DemoFBO( Renderer* parent = 0);
        ~DemoFBO();
        unsigned int generateTexture
    (int width,int height,bool isDepth=false);
        void GenerateFBO(); . . . .
    };
    
  2. 定义generateTexture函数;此函数负责根据传递给它的(isDepth)布尔参数生成颜色或深度纹理:

    unsigned int DemoFBO::generateTexture
    (int width, int height, bool isDepth) {
    unsigned int texId;
    glGenTextures(1, &texId);
        glBindTexture(GL_TEXTURE_2D, texId);
       . . . . Set Minification and Maxification filters
       if (isDepth){
         glTexImage2D( GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F,
             width, height, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);  
       }
       else{
         glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height,
             0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
       }
    
       int error;
       error = glGetError();
       if(error != 0){
          std::cout<<"Error: Fail to generate texture."<<error;
       }
       glBindTexture(GL_TEXTURE_2D,0);
       return texId;
    }
    
  3. 定义GenerateFBO并使用以下代码。此函数负责生成 FBO;它使用帧缓冲区中的颜色缓冲区和深度缓冲区。此配方还包含GenerateFBOWithRenderBuffer备用函数,它使用渲染缓冲区的深度缓冲区来创建 FBO。有关更多信息,请参阅本配方中的更多内容子节:

    void DemoFBO::GenerateFBO(){
       // create a frame buffer object
       glGenFramebuffers(1, &fboId);
       glBindFramebuffer(GL_FRAMEBUFFER, fboId);
    
       textureId = createTexture(TEXTURE_WIDTH,TEXTURE_HEIGHT);
       depthTextureId = createTexture(
       TEXTURE_WIDTH,TEXTURE_HEIGHT, true);
       // attach texture to FBO color attachment point
       glFramebufferTexture2D(
       GL_FRAMEBUFFER,       //1.fbo target: GL_FRAMEBUFFER
       GL_COLOR_ATTACHMENT0, //2.Color attachment point
       GL_TEXTURE_2D,        //3.tex target: GL_TEXTURE_2D
       textureId,            //4.Color texture ID
       0);                   //5.mipmap level: 0(base)
    
       // Attach texture to FBO depth attachment point
       glFramebufferTexture2D(
       GL_FRAMEBUFFER,       //1.fbo target: GL_FRAMEBUFFER
       GL_DEPTH_ATTACHMENT,  //2.Depth attachment point
       GL_TEXTURE_2D,        //3.tex target: GL_TEXTURE_2D
       depthTextureId,       //4.depth texture ID
       0);                   //5.mipmap level: 0(base)
    
       // check FBO status
       GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
       if(status != GL_FRAMEBUFFER_COMPLETE){
       printf("Framebuffer creation fails");
       }
    }
    
  4. 定义InitModel函数并初始化圆点和简单纹理类。此外,使用以下代码生成 FBO:

    void DemoFBO::InitModel(){
       objModel->InitModel();
       textureQuad->InitModel();
       GenerateFBO();
    }
    
  5. Render()函数中,渲染 FBO 纹理中的圆点并将其映射到 2D 平面上:

    void DemoFBO::Render(){// Render to Texture
        int CurrentFbo;
        glGetIntegerv(GL_FRAMEBUFFER_BINDING, &CurrentFbo);
        glBindFramebuffer(GL_FRAMEBUFFER,fboId);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
        objModel->Render();
        glBindFramebuffer(GL_FRAMEBUFFER, CurrentFbo);
        TransformObj->TransformError();
    
        // Render Quad with render buffer mapped.
        glViewport(0, 0, RendererHandler->screenWidthPixel()*2,
                    RendererHandler->screenHeightPixel()*2);
        glClearColor(0.710,0.610,0.30,1.0);
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
        glActiveTexture (GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, textureId);
        textureQuad->Render();
        TransformObj->TransformError();
    }
    

着色器程序可以完全重用,无需任何更改。唯一的例外是我们将着色器从SimpleTexture重命名为 FBO。

工作原理...

渲染管线中所有渲染命令的最终目的地是默认帧缓冲区;OpenGL ES 3.0 提供了使用 FBO 创建附加帧缓冲区的手段。FBO 允许您直接将场景渲染到纹理中,这可以像任何其他纹理一样用于映射目的。它还可以用于场景的后处理。类似于默认帧缓冲区,FBO 也包含颜色、深度和模板缓冲区;这些缓冲区通过(GL_COLOR_ATTACHMENT0..NGL_DEPTH_ATTACHMENTGL_STENCIL_ATTACHMENT)附加点访问,如更多内容部分所示的下图中所示。

首先,就像 OpenGL ES 中的任何其他缓冲对象一样,创建一个 FBO 并使用glGenFramebufferglBindFrameBuffer将其绑定。使用generateTexture函数创建一个空的 256 x 256 颜色和深度缓冲纹理对象,并将句柄分别存储在textureIddepthTextureId中。OpenGL ES 3.0 的 FBO 实现允许一个颜色缓冲区和一个深度缓冲区,可以使用glFramebufferTexture2D API 将其附加到 FBO;更多的颜色缓冲区可能取决于 OpenGL ES 驱动程序的实现。这通过宏MAX_COLOR_ATTACHMENTS定义。

glFramebufferTexture2D API 将创建的颜色和深度缓冲区的句柄附加:

glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,textureId,0);  
glFramebufferTexture2D(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT,GL_TEXTURE_2D,depthTextureId,0);

语法:

void glFramebufferTexture2D(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level);
变量 描述
target 这指定了帧缓冲区目标,应该是GL_FRAMEBUFFERGL_DRAW_FRAMEBUFFERGL_READ_FRAMEBUFFER
attachment 这指定了帧缓冲区目标。对于这个配方,它应该是颜色缓冲区的GL_COLOR_ATTACHMENT0和深度缓冲区的GL_DEPTH_ATTACHMENT
textarget 这指定了 2D 纹理目标,在本例中是GL_TEXTURE_2D
texture 这指定了纹理缓冲区的句柄。在当前配方中,它应该是颜色缓冲区的textureID和深度缓冲区的depthTextureId
level 这指定了 Mipmap 级别。

使用glCheckFramebufferStatus API 检查创建的帧缓冲区的状态;如果帧缓冲区创建成功,此 API 必须返回GL_FRAMEBUFFER_COMPLETE

现在,我们已经有一个带有颜色和深度缓冲区的 FBO;接下来我们需要做的是将场景渲染到这个纹理上。为此,我们需要将渲染命令重定向到我们的 FBO 而不是默认帧缓冲区。我们需要使用glGetIntegerv函数和GL_FRAMEBUFFER_BINDING参数查询默认框架的句柄,并将其存储在currentFbo中;我们将使用这个句柄在渲染到纹理操作完成后恢复默认帧缓冲区。使用glBindFramebuffer函数绑定渲染管线与fboID帧缓冲区对象句柄。使用glViewPortglClearColor API 分别准备视口并清除 FBO 的颜色和深度缓冲区。最后,渲染波点将所有程序纹理图案网格重定向到我们的textureId FBO 颜色纹理对象。渲染完成后,通过使用glBindFramebuffer函数并将句柄绑定到渲染管线中的CurrentFbo来恢复默认帧缓冲区。

第三件重要的事情是使用(textureId)FBO 纹理并将其应用到这个 2D 正方形上;这个过程与我们的第一个配方类似,即简单的纹理;这里唯一的区别是,我们不会使用静态纹理,而是使用 FBO 纹理。因为我们已经切换到默认缓冲区,所以我们需要设置视口并清除颜色和深度缓冲区。使用glActiveTexture函数和GL_TEXTURE0参数将活动纹理单元 ID 设置为0,或者确保这个纹理单元与发送到片段着色器的相同。最后,渲染正方形几何形状,看看渲染到纹理的效果:

如何工作...

确保在不需要时使用glDeleteFramebuffers API 删除 FBO。

还有更多...

当前 FBO 配方使用来自Texture对象的深度缓冲区。作为替代,我们也可以使用渲染缓冲区的深度缓冲区来完成这个目的。渲染缓冲区是一个特殊的 OpenGL ES 对象,与 FBO 一起使用,允许你在屏幕外进行渲染;它将场景直接渲染到渲染缓冲区对象而不是纹理对象。渲染缓冲区只能在其内部格式中存储单个图像。

在以下代码中,我们将看到如何使用渲染缓冲区的深度缓冲区而不是使用纹理对象的深度缓冲区;创建 FBO 对象并与纹理图像的颜色缓冲区附加的过程与上一节中描述的相同:

void DemoFBO::GenerateFBOWithRenderBuffer()
{
    // create a frame buffer object
    glGenFramebuffers(1, &fboId);
    glBindFramebuffer(GL_FRAMEBUFFER, fboId);
    // attach the texture to FBO color attachment point
    textureId = generateTexture(TEXTURE_WIDTH,TEXTURE_HEIGHT);
    glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,
                           GL_TEXTURE_2D,textureId,0);
    // create a renderbuffer object to store depth info
    glGenRenderbuffers(1, &rboId);
    glBindRenderbuffer(GL_RENDERBUFFER, rboId);
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16,
                            TEXTURE_WIDTH,TEXTURE_HEIGHT);

    // attach the renderbuffer to depth attachment point
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
                             GL_RENDERBUFFER, rboId);

    // check FBO status
    GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    if(status != GL_FRAMEBUFFER_COMPLETE)
        {printf("Framebuffer creation fails"); }
}

渲染缓冲区是通过 glGenRenderBuffers 创建的,此 API 在成功创建 渲染 缓冲区对象RBO)时返回非零值。与其它 OpenGL ES 对象一样,在使用之前也需要先绑定,这可以通过 glBindRenderBuffer API 实现。创建的对象是空的。因此,它使用 glRenderbufferStorage API 分配到内存空间;此 API 需要四个参数。第一个参数指定了分配的目标(即 GL_RENDERBUFFER),第二个参数是内部格式渲染缓冲区图像(可能是一个可渲染颜色、深度或模板的格式)。对于此配方,我们将使用深度可渲染格式。最后两个参数用于指定渲染缓冲区的尺寸。

语法:

void glRenderbufferStorage(GLenum target, GLenum internalformat, GLsizei width, GLsizei height);

最后,glFramebufferRenderbuffer API 帮助 RBO 深度缓冲区附加到 FBO 深度附加点。此 API 的第一个参数指定了帧缓冲区目标,在这种情况下应该是 GL_FRAMEBUFFER。第二个参数是 FBO 的附加点;因为我们想要附加到深度附加点,所以它应该是 GL_DEPTH_ATTACHMENT。第三个参数指定了渲染缓冲区目标,必须是 GL_RENDERBUFFER。最后一个参数指定了 rboId 渲染缓冲区对象的句柄。当 RBO 不再需要时,可以使用 glDeleteRenderbuffers 删除它。

语法:

  GLsync glFramebufferRenderbuffer(GLenum target, GLenum
    attachment, GLenum renderbuffertarget, GLuint renderbuffer);

还有更多...

参见

  • 应用 UV 贴图纹理

  • 参考第六章 中 生成圆点图案 的配方,使用着色器

使用位移贴图实现地形

位移贴图技术使用程序纹理或纹理图像修改几何形状的表面。此配方使用名为高度图的纹理图像在二维平面上实现地形表面。高度图是一个灰度图像,其中每个 texel 存储了 0.0 到 1.0 范围内的海拔信息(白色映射到 1.0,黑色映射到 0.0)。二维平面由一组以网格方式排列的顶点表示;此 3D 网格空间中每个顶点的海拔信息是从高度图中读取的。此配方还使用另一个纹理图像,用于将草地纹理映射到生成的地面上,使其更加逼真。

如何实现...

执行以下步骤以实现位移贴图高度场配方:

  1. 创建一个 HeightField 类并在其中声明以下成员变量:

    class HeightField: public Model
    {
    
    public:
       HeightField(Renderer* parent, float rDimension, float
                    cDimension, int Rows = 10, int Columns = 10);
       ~HeightField();
    
        void InitModel();             // Initialize Model class
        void Render();               // Render the Model class
    
    private:
        Image* image;                // Image object
       int imageHeight, imageWidth; // Image texture dimension
        char MVP, TEX;               // uniform attrib locations
        float rot;
    
        GLint NormalMatrix;
        GLuint HeightFieldVAO_Id;     // VAO of Height Field
        GLuint vId, iId;              // VBO and IBO
        int faces;                    // Number of faces
    
        // Size of vertices, texture, faces indexes, color
        int sizeofVertex, sizeofTex, sizeofFace, sizeofColor;
        float *v, *n, *tex;           // temporary buffers
        unsigned short *faceIdx;
    };
    

    定义参数化构造函数;第一个参数指定HeightField类的父类,接下来的两个参数定义地形的维度,最后的两个参数指定用于创建地形平面顶点网格的行和列。

    在这个函数中,加载HeightMap.pnggrass.png纹理分别用于位移映射和纹理映射;这将生成两个纹理对象。我们只对地形的正面感兴趣;面的总数将是行和列的乘积。为总数量的顶点(v)、法线(n)、纹理坐标(tex)分配内存空间,并用它们各自的信息填充它们。使用维度参数计算顶点坐标;假设每个顶点的法线信息是沿y轴的正单位向量。为网格平面中的每个顶点分配纹理坐标。最后,使用这个填充的缓冲区信息生成 VBO 和 IBO:

              HeightField::HeightField(Renderer*parent, float rDimension,
                                 float cDimension, int Rows, int Columns)
    {
        . . . .
        // Load height map image & grass texture file.    
         . . . . . .
    
       // Load HeightMap.png
       imageHeightMap->loadImage(fname); 
    
       // Load grass.png 
          imageGrass->loadImage(fname);    
    
          faces = Rows * Columns; // Front side faces
          v     = new float[3 * (Rows + 1) * (Columns + 1)];
          n     = new float[3 * (Rows + 1) * (Columns + 1)];
        tex   = new float[2 * (Rows + 1) * (Columns + 1)];
    
          faceIdx= new  unsigned short [6 * Rows * Columns];
          sizeofVertex = sizeof(float)*3*(Rows+1)*(Columns+1);
          sizeofTex    = sizeof(float)*2*(Rows+1)*(Columns+1);
        sizeofFace  = sizeof(unsigned short) * 6 * Rows * Columns;
    
       float x2, z2; 
       x2     = rDimension/2.0f;     
       z2     = cDimension/2.0f;
    
       float zFactor, xFactor;    
       zFactor   = cDimension/Columns;
       xFactor   = rDimension/Rows;
    
       float texi, texj;
       texi       = 1.0f/Columns;   
       texj       = 1.0f/ Rows;
    
       float x, z; int vidx = 0, tidx = 0;
    
       // Calculate the Vertices,Normals and TexCoords
          for( int i = 0; i <= Columns; i++ ) {
             z = zFactor * i - z2; // Column
    
             for( int j = 0; j <= Rows; j++ ) {
                 x = xFactor * j - x2; // Row
    
                 // Vertex position
                 v[vidx]      =x;
                 v[vidx+1]   =0.0f;  
                 v[vidx+2]   =z;
    
                // Normals along +Y direction
                n[vidx]      =0.0f; 
                n[vidx+1]    =1.0f;  
                n[vidx+2]    =0.0f;
    
                 // Jump to the next vertex index
                  vidx += 3; 
    
                 // Texture coordinates
                 tex[tidx]   =j*texj; 
                 tex[tidx+1] =i*texi;
    
                 // Jump to the next vertex index
                tidx += 2;
            }
        }
    
       // Calculate the face indices
        unsigned int rowStart, nextRowStart, idx = 0; 
        for( int i = 0; i < Columns; i++ ) {
            rowStart = i * (Rows+1);
            nextRowStart = (i+1) * (Rows+1);
            for( int j = 0; j < Rows; j++ ) {
                faceIdx[idx]    = rowStart + j;
                faceIdx[idx+1]  = nextRowStart + j;
                faceIdx[idx+2]  = nextRowStart + j + 1;
                faceIdx[idx+3]  = rowStart + j;
                faceIdx[idx+4]  = nextRowStart + j + 1;
                faceIdx[idx+5]  = rowStart + j + 1;
                idx += 6;
            }
        }
    
         // Generate and bind the VBO and IBO
        // Create the Vertex Array object for height field
        . . . . . . .
    
       // Refer to:- Managing VBO's with vertex array
       // objects (VAO), OpenGL ES 3.0 New Features
    
         // Bind the VBO and IBO for VAO and
        // Delete temporary buffer
        . . . . . . .
    }
    
  2. initModel函数中,链接和编译顶点着色器和片段着色器。激活纹理单元并将其与高度图和草地纹理对象绑定。高度图纹理由顶点着色器用于读取每个顶点的海拔信息。然而,草地纹理在片段着色器中用于绘制几何表面。顶点着色器使用一个heightFactor统一变量来控制每个顶点的海拔值:

       void HeightField::InitModel(){
    . .Compile and Link shaders
    
       glUseProgram( program->ProgramID );    
       TEX_HEIGHT = ProgramManagerObj->
       ProgramGetUniformLocation(program, "ImageTexture");
       glActiveTexture (GL_TEXTURE0);
       if (imageHeightMap) {
       glBindTexture(GL_TEXTURE_2D, imageHeightMap->getTextureID());
       glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_REPEAT);
    
    TEX_GRASS = ProgramManagerObj->
    ProgramGetUniformLocation(program,"ImageGrassTexture");
    glActiveTexture (GL_TEXTURE1);
    if (imageGrass) {
       glBindTexture(GL_TEXTURE_2D, imageGrass->getTextureID());
    
       glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
       glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
       glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_REPEAT);
       glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_REPEAT);
    }
    
       MVP = ProgramManagerObj->ProgramGetUniformLocation
       ( program, (char*)"ModelViewProjectionMatrix" );
       FACTOR = ProgramManagerObj->ProgramGetUniformLocation
       ( program, (char*)"heightFactor" );
       if ( FACTOR >= 0 ){
       glUniform1f(FACTOR, 3);
       }
    }
    
  3. 创建HeightFldVertex.glsl顶点着色器并添加以下代码。在这个着色器中,使用纹理坐标并从存储在HeightMapTexture中的高度图纹理中读取每个顶点的海拔信息:

    #version 300 es
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 2) in vec2  TexCoords;
    uniform mat4    ModelViewProjectionMatrix;
    
    out vec2    TextureCoord;
    out vec3    vertexColor;
    uniform sampler2D HeightMapTexture;
    uniform float heightFactor;
    void main()
    {
        TextureCoord    = TexCoords;
        vec4 height     = texture(HeightMapTexture, TexCoords);
        if(heightFactor>0){
            height /= heightFactor;
        }else{
           height = 0.333; // Assumption, some arbitrary value
        }
    
        gl_Position = ModelViewProjectionMatrix * vec4(
               VertexPosition.x, height.r, VertexPosition.z, 1.0);
    }
    
  4. 类似地,对于HeightFldFragment.glsl片段着色器,添加以下代码。利用纹理坐标并将ImageGrassTexture纹理单元中的草地纹理映射到地形表面:

    #version 300 es
    precision mediump float;
    
    layout(location = 0) out vec4 FinalColor;
    uniform sampler2D ImageGrassTexture;
    in vec2    TextureCoord;
    
    void main() {
        FinalColor = texture(ImageGrassTexture, TextureCoord);
    }
    
  5. Renderer.cpp中添加HeightField模型,如下所示;该模型在水平和垂直维度上为5个单位,包含50行和列:

    void Renderer::createModels(){
       clearModels();
       addModel( new HeightField( this, 5, 5, 50, 50 ));
    }
    

它是如何工作的...

以下图像显示了位移贴图的工作原理,它渲染了虚拟的地理地形。在这个简单的例子中,我们假设地形平面具有 1 x 1 单位的尺寸,有三行和三列,从而产生一个 3 x 3 的顶点网格。顶点位置是计算得,使得原点始终位于中心;所有顶点的高度默认为 0.0。顶点着色器负责使用灰度高度图纹理计算每个给定顶点的高度。这个纹理是通过HeightMapTexture纹理单元(图像部分A)加载和访问的,高度信息是通过TexCoords纹理坐标(图像部分D)从高度图中读取的,并分配给高度坐标(图像部分B: H0H1...H8)。最后,位移贴图的输出看起来像以下图像中的部分C。这是实际配方的截图,其中地形宽度为 5 x 5,包含 50 x 50 行和列。

在片段着色器中,使用简单的纹理映射技术将草地图像纹理应用到地形几何体的表面上;这使得几何体更加逼真。图像部分DEF显示了片段着色器的输出:

如何工作...

参见

  • 请参考第二章中的使用顶点缓冲对象高效渲染配方,OpenGL ES 3.0 基础知识

  • 请参考第三章中的使用顶点数组对象管理 VBO配方,OpenGL ES 3.0 新特性

实现凹凸贴图

与位移贴图相比,凹凸贴图技术是一种非常高效的技巧。这项技术也用于向几何体的表面添加深度细节或高度。然而,这种深度或高度是虚假的。几何体的顶点在高度上没有发生任何变化。相反,它使用光照来模拟光滑表面上的深度外观。光照使用存储在法线图中的顶点法线信息来添加深度。与存储高度或高度信息的法线图一样,法线图存储法线信息。法线图中的想法是避免为每个三角形面计算法线图;这些可以从纹理中采样。

负责设计网格模型的设计师首先创建一个非常高多边形的网格模型(100,000+),然后从它创建一个法线图,保存在图像文件中。最后,他们将高分辨率模型降低到低多边形网格(介于 3000 到 5000 之间)。在运行时,使用法线图将深度细节应用到低多边形网格上,从而产生与高多边形网格相似的外观。因此,凹凸贴图用于在低多边形网格模型中添加高细节。

在这个菜谱中,我们将实现一个地球球体,它利用法线图来产生凹凸映射效果;这使得球面表面的 3D 深度信息更加明显。

准备工作

为了实现这个菜谱,我们需要两个纹理。第一个纹理包含应用于几何表面的颜色信息。第二个纹理是第一个纹理的法线图。有许多工具可以生成法线图,例如 CrazyBump、GIMP、PixPlant、Photoshop 插件、XNormals 等等。

准备工作

如何做...

实现凹凸映射的逐步说明如下:

  1. 使用ObjLoader::LoadMesh()加载sphere.obj;此函数使用OBJMesh类来加载网格数据。这个菜谱需要从加载的网格中获取切线信息以实现凹凸映射;这由OBJMesh类通过CalculateTangents函数自动计算。有关此函数和数学计算的更多信息,请参阅此菜谱的更多内容…部分。

  2. 加载earthcolor.png地球纹理及其法线(earthnormal.png),在ObjLoader::initModel中创建纹理对象,如前述菜谱所示。将这两个纹理对象分别附加并绑定到纹理单元01,以便它们可用于着色器程序。

  3. 创建BumpVertex.glsl并添加以下代码片段;此代码通过法线(N)和切线(T)的叉积来计算双法线切线(B)。所有这些顶点参数都在切线空间中;这些必须归一化并存储为表示为([Tx, Bx, Nx], [Ty, By, Ny], 和 [Tz, Bz, Nz])的 3x3 切线空间矩阵。这用于将视空间转换为切线空间。在当前情况下,eyecoord被转换为切线空间并与片段着色器共享:

    #version 300 es
    // Vertex information
    layout(location = 0) in vec4  VertexPosition;
    layout(location = 1) in vec3  Normal;
    layout(location = 2) in vec2  TexCoords;
    layout (location = 3) in vec4 VertexTangent;
    
    // Model View Project matrix
    uniform mat4    ModelViewProjectionMatrix, ModelViewMatrix;
    uniform mat3    NormalMatrix;
    uniform mediump vec3 LightPosition;
    out vec2    textureCoord;
    out vec3    eyeCoord;
    out mat3    tangentSpace;
    
    void main(){
        // Transform normal and tangent to eye space
        vec3 norm = normalize(NormalMatrix * Normal);
        vec3 tang = normalize(NormalMatrix * vec3(VertexTangent));
    
        // Compute the binormal
        vec3 binormal = cross( norm, tang );
    
        // Matrix for transformation to tangent space
        tangentSpace = mat3(tang.x, binormal.x, norm.x, tang.y,
             binormal.y, norm.y, tang.z, binormal.z, norm.z );
    
        // Transform view direction to tangent space
        eyeCoord=vec3(ModelViewMatrix*VertexPosition)*tangentSpace;
        textureCoord = TexCoords;
        gl_Position  = ModelViewProjectionMatrix * VertexPosition;
    }
    
  4. 创建BumpFragment.glsl并使用以下代码;片段着色器将光方向从眼坐标转换为切线空间;这在计算漫反射和镜面强度时很有帮助:

    #version 300 es
    precision mediump float;
    
    // Light information
    uniform vec3 LightAmbient,LightSpecular,LightDiffuse, 
                                      LightPosition;
    
    // Material information
    uniform vec3 MaterialAmbient,MaterialSpecular, 
                                         MaterialDiffuse,;
    uniform float ShininessFactor;
    
    in vec2 textureCoord; 
    in vec3 eyeCoord;
    in mat3 tangentSpace;
    layout(location = 0) out vec4 FinalColor;
    
    vec3 normalizeNormal, normalizeEyeCoord, normalizeLightVec;
    
    vec3 V, R, ambient, diffuse, specular;
    
    float sIntensity, cosAngle;
    
    vec3 PhongShading( vec3 norm, vec3 MaterialDiffuse ) {
        normalizeNormal   = normalize( norm ) ;
        normalizeEyeCoord = normalize( eyeCoord);
        normalizeLightVec = normalize( (LightPosition-eyeCoord)
                              *tangentSpace);
    
        // Diffuse Intensity
        cosAngle = max( 0.0, dot(normalizeNormal,
                             normalizeLightVec )); 
    
        // Viewer's vector
        V = -normalizeEyeCoord; 
        R = reflect( -normalizeLightVec, normalizeNormal);
        sIntensity = pow(max(0.0,dot(R,V)),ShininessFactor);
    
        // ADS as result of Material & Light interaction
        ambient = MaterialAmbient * LightAmbient;
        diffuse = MaterialDiffuse * LightDiffuse * cosAngle;
        specular= MaterialSpecular*LightSpecular*sIntensity;
    
        return  ambient + diffuse + specular;
    }
    
    uniform sampler2D ImageTexture, ImageTextureNormal;
    
    void main() {
      //Lookup normal map
       vec4 normalMap = texture(ImageTextureNormal, vec2(1.0-
                       textureCoord.x, textureCoord.y));
    
      //Convert[0,1] -> [-1,1]
       normalMap      =  (2.0*normalMap-1.0); 
      vec4 texColor   = texture(ImageTexture, vec2(1.0 –
                          textureCoord.x, textureCoord.y));
       FinalColor     = vec4( PhongShading(normalMap.xyz,
                          texColor.rgb), 1.0 );
    }
    

它是如何工作的...

凹凸映射需要两个纹理文件。第一个纹理文件包含颜色信息,用于漫反射着色。第二个纹理称为法线图,包含几何形状的法线信息;这些信息对于镜面着色很有帮助。这两个纹理都加载并存储在纹理单元中,以便着色器可以访问。

法线贴图高度依赖于在加载网格时在ObjMesh类中计算出的切线信息。有关切线计算的更多信息,请参阅本食谱中的下一节。计算出的切线存储在网格 VBO 中,并且可供顶点着色器使用,与其他顶点属性不同。在顶点着色器中,这些信息与法线信息结合,有助于计算每个顶点的双切线向量。一旦有了法线(N)、切线(T)和双切线(B)向量,它们就会被归一化并用于创建切线空间矩阵,如图所示:

如何工作...

获得的切线空间矩阵(tangentSpace)与VertexPosition的视口坐标相乘,以产生切线空间视口坐标(eyeCoord)。然后这些坐标与切线空间矩阵和TexCoords纹理坐标一起与片段着色器共享。

在片段着色器中,使用纹理坐标采样图像纹理和法线纹理,并存储在texColornormalMap中。有必要将法线贴图值从范围[0, 1]转换为[-1, 1]。一旦更改,这两个纹理值随后被发送到GouraudShading。在这个函数中,计算每个顶点的光方向并将其与tangentSpace相乘以转换为切线空间。然后使用修改后的normalizeLightVeceyeCoord以与我们在 Gouraud 着色技术中计算相同的方式计算漫反射和镜面反射分量。有关此技术的更多信息,请参阅第五章,光与材料

如何工作...

还有更多...

在法线贴图技术中使用的法线贴图存储了在生成法线贴图时相对于某个默认方向的空间几何体的法线信息。当此纹理映射到几何体并用于渲染目的时,可能会产生不正确的结果,因为几何体的所有面并不都具有与映射的法线贴图相同的方向。因此,法线贴图需要在运行时动态操作,取决于面的方向,这通过切平面来完成。在ObjMesh类中,此切平面是通过OBJMesh::CalculateTangents计算的;切平面由切线(T)和双切线(B)向量组成。

切线是一个在给定点上接触曲面上的向量;在给定点上可能会有太多的切线。因此,选择正确的切线非常重要。因此,我们希望我们的切线空间以这种方式对齐,即X方向对应于纹理坐标的U方向,Y方向对应于纹理坐标的V方向。

考虑一个三角形,其顶点为 P[0]、P[1]和 P[2],相应的纹理坐标为(U[0], V[0])、(U[1], V[1])和(U[2], V[2]),以下图像解释了切线空间的计算(见方程)。这给出了使用 P[0]、P[1]和 P[2]创建的三角形面的未归一化切线(T)和双切线(B)。为了计算给定顶点的切线,取共享此顶点的所有三角形面的平均切线:

还有更多...

在前面的图示说明和其中的方程中,切线信息是在OBJMesh类中计算的,如下面的代码所示:

bool OBJMesh::CalculateTangents(){
    vector<vec3> tan1Accum, tan2Accum; // Accumulated tangents
    objMeshModel.tangents.resize(objMeshModel.positions.size());

    for( uint i = 0; i < objMeshModel.positions.size(); i++ ) {
     tan1Accum.push_back(vec3(0.0f));tan2Accum.push_back(vec3(0.0f));
     objMeshModel.tangents.push_back(vec4(0.0f));
    }

    int index0, index1, index2, index0uv, index1uv, index2uv;

    // Compute the tangent vector
    for( uint i = 0; i < objMeshModel.vecFaceIndex.size(); i += 3 ){
       index0 = objMeshModel.vecFaceIndex.at(i).vertexIndex;
       index1 = objMeshModel.vecFaceIndex.at(i+1).vertexIndex;
       index2 = objMeshModel.vecFaceIndex.at(i+2).vertexIndex;

      const vec3 &p0 = objMeshModel.positions.at(index0);
      const vec3 &p1 = objMeshModel.positions.at(index1);
      const vec3 &p2 = objMeshModel.positions.at(index2);

      index0uv = objMeshModel.vecFaceIndex.at(i).uvIndex;
      index1uv = objMeshModel.vecFaceIndex.at(i+1).uvIndex;
      index2uv = objMeshModel.vecFaceIndex.at(i+2).uvIndex;

      const vec2 &tc1 = objMeshModel.uvs.at(index0uv);
      const vec2 &tc2 = objMeshModel.uvs.at(index1uv);
      const vec2 &tc3 = objMeshModel.uvs.at(index2uv);

      // Using Equation 1
      vec3 q1 = p1 - p0; 
       vec3 q2 = p2 - p0;

      // Using Equation 2
      float s1 = tc2.x-tc1.x, s2 = tc3.x-tc1.x; 
      float t1 = tc2.y-tc1.y, t2 = tc3.y-tc1.y;

   // From Equation 5
      float r = 1.0f / (s1 * t2 - s2 * t1);

      // Using Equation 5
      vec3 tan( (t2*q1.x - t1*q2.x) * r,
                 (t2*q1.y - t1*q2.y) * r,
                 (t2*q1.z - t1*q2.z) * r);  
      vec3 bTan( (s1*q2.x - s2*q1.x) * r,
                 (s1*q2.y - s2*q1.y) * r,
                 (s1*q2.z - s2*q1.z) * r);

        tan1Accum[index0] += tan1; tan1Accum[index1] += tan1;
        tan1Accum[index2] += tan1; tan2Accum[index0] += bTan;
        tan2Accum[index1] += bTan; tan2Accum[index2] += bTan;
    }

    for( uint i = 0; i < objMeshModel.positions.size(); ++i ){
      objMeshModel.tangents[i] = vec4(
                       glm::normalize(tan1Accum[i] ),1.0);
    }

    for(int i = 0; i < objMeshModel.vecFaceIndex.size(); i++){
     int index = objMeshModel.vecFaceIndex.at(i + 0).vertexIndex;
    objMeshModel.vertices[i].tangent=objMeshModel.tangents.at(index);
    }

   // Clear & Return
    tan1Accum.clear();tan2Accum.clear(); 
  return true; 
}

参考以下内容

  • 参考第五章中关于Gouraud 着色 - 每顶点着色技术的配方,光与材质

  • 参考第四章中的渲染 wavefront OBJ 网格模型配方,处理网格

第八章:字体渲染

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

  • 使用 FreeType 项目进行字体渲染

  • 使用 Harfbuzz 渲染不同语言

  • 在抬头显示(HUD)上渲染文本

简介

字体渲染是计算机应用程序的一个基本部分;它帮助用户以可读的形式与系统交互和理解信息。OpenGL ES 不提供内置的字体渲染支持;相反,字体引擎需要由开发者编程。有许多字体渲染技术;本章将介绍最流行的字体渲染技术,即使用FreeType项目和Harfbuzz库进行渲染。前者用于使用字体文件对符号字符或字形进行光栅化;这个库支持不同类型的字体文件格式,如 TTF、BDF、OTF、Windows FNT 等。后者库用于多语言支持。使用这个库,几乎可以渲染所有世界著名的语言脚本。

本章将提供如何构建字体引擎的详细描述;我们将使用 FreeType 实现简单的文本渲染。我们将使用 Harfbuzz 库的能力来打印多语言文本渲染,如阿拉伯语、泰语、泰米尔语、旁遮普语等。最后但同样重要的是,你将学习在屏幕坐标系上渲染文本在抬头显示(HUD)或叠加层上的技术。

使用 FreeType 项目进行字体渲染

在这个食谱中,我们将渲染一个简单的拉丁文本在 3D 空间中。为此,我们可以为每个字符创建一个纹理位图并将其渲染为四边形几何形状(矩形)。然而,创建每个字符位图在内存管理和性能方面可能是昂贵的,因为它需要在纹理内存中加载多个位图。更好的解决方案是创建一个包含所有字符的大纹理,并使用它们的纹理坐标将它们映射到几何四边形上。

使用 FreeType 库渲染字体的过程概述如下:

  1. 初始化 FreeType 库。这会初始化必要的 FreeType 数据结构。

  2. 加载字体面。这会加载字体文件并生成字体样式(字体面)信息。

  3. 指定字体大小。使用指定的字体大小,创建一个足够大的空纹理以包含所有字形。为了使纹理与 OpenGL ES 2.0 向后兼容,选择其纹理大小为 2 的幂。

  4. 访问字体面数据内容。这使用字体面和度量信息在空纹理上创建字形图像,这被称为纹理图集。字形将以行和列的形式绘制,如图下一幅图像所示。

  5. 映射字形。这将在数据结构中存储从纹理图集中每个字形图像的纹理坐标,并将其映射到相应的 charcode。

  6. 渲染文本。字形映射包含所有字符代码,选择所需的字符,并将从纹理着色图中映射到每个字符的四边形几何形状的相应纹理坐标。例如,以下图像显示了从纹理着色图中打印的 Hello World:使用 FreeType 项目的字体渲染

类和数据结构:

以下是对在字体渲染中使用的所有类和相关数据结构的简要描述:

  • FontGenerator: 此类在 FreeType 库的帮助下加载字体文件。它将字体文件中的重要信息存储在相关的数据结构中。它使用 FreeType 库的数据结构构建位图纹理;每个字符/字形的位图信息存储在本地快速访问的映射中:

    • library: 这是 FreeType 库实例的句柄。

    • fontface: 每个字体可能包含一个或多个字体面或字型;它具有特定的重量、样式、紧缩、宽度、倾斜、斜体化、装饰以及设计师或铸造厂。

    • glyphs: 这是字形和字符代码的 STL 映射。

    • atlasTex: 这包含着色图纹理对象的句柄。

  • Glyph: 此数据结构存储与字体文件中存在的字形相关的信息:

    • 度量: 字形度量用于在 2D/3D 空间中定位渲染的字形。

    • texCoordXtexCoordYatlasXatlasY:这些存储纹理着色图中字形的纹理坐标。

    • advanceHorizontaladvanceVertical: 进度信息有助于相对于当前字形放置下一个相邻字符。

  • Font: 此类继承自 FontGenerator,并提供加载字体文件和渲染文本的辅助函数的接口。

  • FontSample: 此类充当字体渲染器的消费者;它用于演示目的渲染示例文本。

以下图表显示了设计类图;Font 类继承自 FontGeneratorModel

使用 FreeType 项目的字体渲染

准备工作

FreeType 项目是一个用于字体光栅化的开源库;它读取字体文件,并能够从存储在文件中的矢量/曲线信息生成位图。这个库是用 ANSI-C 编写的,这使得它在各个平台上都是可移植的。

注意

该库在下载部分免费提供,网址为 www.freetype.org

FreeType 许可证FTL)是最常用的一个。这是一个带有信用条款的 BSD 风格许可,与 GNU 公共许可证(GPL)版本 3 兼容,但不与 GPL 版本 2 兼容。在我们的 GLPI 框架中,我们将使用 2.5.4 版本,它位于 GLPIFramework/Font/FreeType 文件夹下。

构建过程: 以下要点提供了 FreeType 库以及实现此配方所需的其他源文件的构建过程的详细描述:

  • Android 平台:我们需要 makefile 来构建 FreeType 项目库。在GLPIFramework/Font/FreeType下添加Android.mk makefile;编辑此 makefile,如下所示。这将编译为共享库,命名为 GLPift2. 可选地,您也可以直接在主项目 makefile 中添加源代码,而不是编译共享库:

    ifndef USE_FREETYPE
    USE_FREETYPE := 2.4.2
    endif
    ifeq ($(USE_FREETYPE),2.4.2)
    
    LOCAL_PATH:= $(call my-dir)
    include $(CLEAR_VARS)
    
    LOCAL_SRC_FILES:= \
    src/base/ftbbox.c src/base/ftbitmap.c \
    src/base/ftfstype.c src/base/ftglyph.c \
    src/base/ftlcdfil.c src/base/ftstroke.c\
    src/base/fttype1.c src/base/ftxf86.c \
    src/base/ftbase.c src/base/ftsystem.c \
    src/base/ftinit.c src/base/ftgasp.c \
    src/raster/raster.c src/sfnt/sfnt.c \
    src/smooth/smooth.c src/autofit/autofit.c \ src/truetype/truetype.c src/cff/cff.c \ src/psnames/psnames.c src/pshinter/pshinter.c
    
    LOCAL_C_INCLUDES += $(LOCAL_PATH)/builds $(LOCAL_PATH)/include
    
    LOCAL_CFLAGS+=-W –Wall -fPIC –DPIC -O2
    LOCAL_CFLAGS+="-DDARWIN_NO_CARBON" "-DFT2_BUILD_LIBRARY"
    
    LOCAL_MODULE:= libGLPift2
    include $(BUILD_SHARED_LIBRARY)
    

    打开位于JNI文件夹(<源代码路径>/SimpleFont/Android/JNI)下的项目目录中的Android.mk makefile,并包含我们之前代码中创建的 FreeType 库Android.mk文件的路径:

    FONT_PATH= $(FRAMEWORK_DIR)/Font
    $(MY_CUR_LOCAL_PATH)/../../../../GLPIFramework/Font/FreeType/Android.mk
    LOCAL_C_INCLUDES += $(FONT_PATH)/FreeType/include
    LOCAL_SRC_FILES += $(SCENE_DIR)/FontGenerator.cpp \
                       $(SCENE_DIR)/Font.cpp \
                       $(SCENE_DIR)/FontSample.cpp \
                       $(SCENE_DIR)/SimpleTexture.cpp
    LOCAL_SHARED_LIBRARIES += GLPift2
    

    GLESNativeLib.java中,编辑GLESNativeLib类,并添加我们的GLPift2.so共享库的引用以在运行时链接:

    public class GLESNativeLib {
    static {
    System.loadLibrary("GLPift2");
       . . . . . . Other code
    }
    
  • iOS 平台:在 iOS 平台上,我们需要使用构建阶段 | 编译源文件项目属性将相同的 FreeType 项目源文件(在LOCAL_SRC_FILES makefile 变量下提到)添加到您的项目中;点击添加以选择源文件。

    使用构建设置 | 搜索路径 | 头文件搜索路径来为 free type 项目提供包含头文件的路径。对于当前情况,它应该是:

    ../../../../GLPIFramework/Font/FreeType/Include
    

    Apple LLVM <编译器版本> | 预处理器 | 预处理器宏下添加以下预处理器宏:

    FT2_BUILD_LIBRARY=1 DARWIN_NO_CARBON
    

    使用文件 | 将文件添加到<项目名称>添加FontGenerator.h/cppFont.h/cpp项目源文件和FontSample.h/cpp

如何做到这一点...

执行以下步骤以了解实现此菜谱的程序:

  1. 创建FontGenerator类,并将以下代码体添加到其中;重要的数据结构已在上一节中在类和数据结构部分介绍:

    struct Glyph {
    FT_Glyph_Metrics metric; // Glyph metric
       float advanceHorizontal; // Horizontal advance
       float advanceVertical;   // Horizontal advance
       float texCoordX, texCoordY; // Atlas Texture Coords
       float atlasX, atlasY;    // Position in texture Altas
    };
    
    class FontGenerator {
      public:
       FontGenerator ();     // Constructor
       ~FontGenerator ();    // Destructor
       bool errorState ();     // Error check flag
       bool loadFont(const char* filename, int resolution);
    
       GLuint         atlasTex;     // Texture atlas handle  
       std::map<unsigned long, Glyph>  glyphs; // Glyph map
       float          texDimension;
       float          squareSize;   // Glyph square size
       LanguageType   languageType; // Current language
       FT_Face        fontFace;     // typeface information
    
    private:
       bool readFont (const FT_Face& fontFace,
       int resolution, int glyphMargin);
    
       bool getCorrectResolution(const FT_Face& fontFace,
       int resolution, int& newResolution, int& newMargin);
    
       void generateTexFromGlyph (FT_GlyphSlot glyph, GLubyte*
       texture, int atlasX, int atlasY, int texSize,
       int resolution, int marginSize, bool drawBorder);
    
       void setPixel (GLubyte* texture, int offset,
       int size, int x, int y, GLubyte val);
    
       bool                errorStatus;
       FT_Library          library;  // FreeType lib handle
    };
    
  2. 确保源中包含了<ft2build.h>头文件。

  3. 使用FT_Init_FreeType函数在构造函数中初始化 FreeType 库;此构造函数将在从Renderer::createModels函数创建 Font 类的对象时被 Font 类调用。此函数创建 FreeType 库的新实例并设置库句柄:

    FontGenerator::FontGenerator () : errorStatus(false),
     atlasTex(0), texDimension(0), squareSize(0)  {
        if (FT_Init_FreeType(&library)){
     errorStatus = true;return;
     }
    }
    
  4. loadFont函数负责使用 FreeType 的FT_New_Face函数加载字体文件。此函数使用字体文件中可用的字体和样式信息创建一个新的面。例如,Arial Bold 和 Arial Italic 对应于两个不同的面。此函数调用getCorrectResolution函数,该函数将在下一步进行描述:

    bool FontGenerator::loadFont(char* file,int resolution){
        // Generate the face object, return on error
        if(FT_New_Face(library,filename,0 &fontFace))
       { 
       return false;
       }
    
       // Check if current resolution is supported?
       int calculatedResoution; int calculatedMargin;
       if( getCorrectResolution(fontFace, resolution,
       calculatedResoution, calculatedMargin)){
       return readFont(fontFace, calculatedResoution,calculatedMargin);
       }
       return true;
    }
    

    在创建纹理图集之前,使用getCorrectResolution函数检查设备是否支持纹理大小非常重要。可以使用GL_MAX_TEXTURE_SIZE符号标志查询最大纹理大小。如果纹理超过最大支持的限制,此函数将回退到下一个立即较小的可用 2 的幂大小:

    注意

    为此配方生成的纹理图集由 2.0 的幂组成,以便与 OpenGL ES 2.0 版本兼容。

       bool FontGenerator::getCorrectResolution(const FT_Face&
       fontFace, int resolution, int&
       newResolution, int& newGlyphMargin){
    
       int glyphMargin = 0;
       GLint MaxTextureSize;
       glGetIntegerv(GL_MAX_TEXTURE_SIZE, &MaxTextureSize);
    
       while(resolution>0){
       glyphMargin = (int)ceil(resolution*0.1f);       
      const long numGlyphs = fontFace->num_glyphs;    
      const int squareSize = resolution + glyphMargin;
    
      const int numGlyphsPerRow = (int)ceilf(sqrt((double)numGlyphs));
      const int texSize         = (numGlyphsPerRow)*squareSize;
      int realTexSize           = GLUtils::nextPowerOf2(texSize);
    
      if(realTexSize<=MaxTextureSize )
      {    break; }
    
      resolution  = resolution - 5; // Decrease 5 units.
      }
    
      if(resolution > 0){
      newResolution   = resolution;
      newGlyphMargin  = glyphMargin;
      return true;
      }
      else{
      return false;
      }
    }
    

    readFont函数中从 FreeType 库读取字体信息。此函数使用FT_Set_Pixel_Sizes以像素为单位设置字体大小:

    bool FontGenerator::readFont (const FT_Face& fontFace,
    int resolution, int glyphMargin) {
      FT_Set_Pixel_Sizes(fontFace, resolution, resolution);
      const int numGlyphs = fontFace->num_glyphs;
      . . . .
    }
    
  5. fontFace包含有关字体文件中字符总数的信息。使用此信息和提供的字体大小,计算纹理图集的总大小为 2 的幂次维度。分配一个双通道纹理内存并存储在textureData变量中,用于亮度和 alpha:

      // Inside FontGenerator::readFont() function
      squareSize = resolution + glyphMargin;
    
      // Texture size for all glyphs in power of 2
      const int numGlyphsPerRow = ceilf(sqrt(numGlyphs));     
       const int texSize = numGlyphsPerRow*squareSize;
       int realTexSize  = GLUtils::nextPowerOf2(texSize);
    
       // Two channel texture (luminance and alpha)
       GLubyte* textureData = NULL;
       textureData = new GLubyte[realTexSize*realTexSize*2];
    
       // if there exist an old atlas delete it. 
       if (atlasTex){ 
       glDeleteTextures(1,&atlasTex); 
       atlasTex=0; 
    }
    
       glGenTextures(1, &atlasTex);
       glBindTexture(GL_TEXTURE_2D, atlasTex);
       glTexParameteri
       (GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, GL_LINEAR);
       glTexParameteri
       (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
       GLUtils::checkForOpenGLError(__FILE__, __LINE__);
    
  6. 字体面中的每个字形都有一个唯一的索引;面对象包含一个或多个称为字符映射(charmaps)的表,用于将字形索引映射到字符码。例如,A在 ASCII 编码中的字符码为 65。

    遍历字体中所有可用的字形,并使用FT_Load_Glyph加载当前字形图像的信息。此函数将字形图像存储在称为字形槽的特殊对象中。FT_Load_Glyph接受三个参数,处理字体面对象、字形索引和加载标志:

       // Inside FontGenerator::readFont() function
       int texAtlasX  = 0;      int texAtlasY  = 0;
       FT_UInt gindex = 0;   FT_ULong charcode = 0;
    
       for (FT_ULong charcode=FT_Get_First_Char(fontFace,
       &gindex); gindex != 0;charcode=FT_Get_Next_Char
       (fontFace, charcode, &gindex)) {
    
       if(FT_Load_Glyph(fontFace,gindex,FT_LOAD_DEFAULT)){
       LOGE("Error loading glyph with index %i and charcode %i. Skipping.", gindex, charcode);
       continue;
       }
       // Many lines skipped.
       }
    
  7. 字形槽是一个容器,一次只存储一种图像。这可以是位图、轮廓等。可以使用fontFace | glyph访问字形槽对象。使用FT_Render_Glyph API 从字形槽生成位图信息;它接受两个参数,第一个参数是字形槽,第二个参数是渲染模式标志,它指定如何渲染字形图像。

    字形信息被加载到字形数据结构中,并以字符码作为键存储在 STL 映射glyphs中的值:

       // Inside FontGenerator::readFont() function
       // This is part of the glyph loading loop.
       FT_GlyphSlot glyph = fontFace->glyph;
       FT_Render_Glyph(glyph, FT_RENDER_MODE_NORMAL);
    
       // Calculate glyph information
       Glyph glyphInfo;
       glyphInfo.metric     = glyph->metrics;
    
       // Get texture offset in the image
       glyphInfo.atlasX=texAtlasX*squareSize/realTexSize;
       glyphInfo.atlasY=texAtlasY*squareSize/realTexSize;
    
       // Advance stored as fractional pixel format
       // (=1/64 pixel), as per FreeType specs
       glyphInfo.advanceHorizontal=glyph->advance.x/64.0f;
       glyphInfo.advanceVertical=glyph->advance.y/64.0f;
       glyphs[charcode] = glyphInfo;
    
  8. 使用generateTexFromGlyph函数在纹理图集中加载字形位图。此函数将来自字形槽的栅格信息写入纹理数据。所有字符都栅格化后,使用glTexImage2D的帮助将纹理图集加载到 OpenGL ES 纹理对象中,并删除本地纹理图集:

       // Inside FontGenerator::readFont()
    {    
       . . . . .     
       // Copy the bits to the texture atlas
       generateTexFromGlyph(glyph, textureData, texAtlasX,
       texAtlasY, realTexSize, resolution, glyphMargin, false);
    
       texAtlasX++;
       if (texAtlasX >= numGlyphsPerRow){
       texAtlasX=0; 
       texAtlasY++; 
       }
    
       // set texture atlas to OpenGL ES tex object
       glTexImage2D (GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA,
       realTexSize, realTexSize, 0, GL_LUMINANCE_ALPHA,
       GL_UNSIGNED_BYTE, textureData);
    
       // Delete local texture atlas 
       delete[] textureData; 
       GLUtils::checkForOpenGLError(__FILE__, __LINE__);
       texDimension = (squareSize)/(float)realTexSize;
       return true;
    }
    
  9. generateTexFromGlyph函数负责将当前指定的字形加载到字形槽中,并将其加载到由atlasXatlasYtexSize指定的纹理图集中的特定位置。此函数的最后一个参数用于在字符周围绘制边框,这在调试纹理渲染中字符定位时非常有帮助。例如,参见前面的Hello World示例文本图像;它包含每个字符周围的边框:

       void FontGenerator::generateTexFromGlyph (FT_GlyphSlot
       glyph, GLubyte* texture, int atlasX, int atlasY, int
       texSize,int resolution,int marginSize,bool drawBorder){
    
       int squareSize = resolution + marginSize;
       baseOffset=atlasX*squareSize+atlasY*squareSize*texSize;
    
       if (drawBorder) {
       for (int w=0; w<squareSize; w++)
       { setPixel(texture,baseOffset,texSize, w, 0, 255); }
    
       for (int h=1; h<squareSize; h++){
       for (int w=0; w<squareSize; w++){
       setPixel(texture,baseOffset,texSize,w,h,
       (w==0||w==squareSize-1)?255:
       (h==squareSize-1)?255:0);
                                       }
                                       }
       }
    
       const int gr = glyph->bitmap.rows;
       const int gw = glyph->bitmap.width;
       for (int h=0; h<gr; h++) {
       for (int w=0; w<gw; w++) {
       setPixel(texture, baseOffset+marginSize, texSize,
       w, marginSize+h, glyph->bitmap.buffer[w+h*gw]);
       }
       }
    }
    
  10. 创建从ModelFontGenerator派生的Font类:

       class Font : public Model, public FontGenerator {
       public:
       Font(const char* ttfFile, int Size, Renderer* parent,
       LanguageType Language= English);
       ~Font();
       void Render();
       void InitModel();
       void printText (const char* str, GLfloat Red = 1.0f,
       GLfloat Green = 1.0f, GLfloat Blue = 1.0f,
       GLfloat Alpha = 1.0f);
       private:
       void drawGlyph (const Glyph& gi);
       char MVP, TEX, FRAG_COLOR;
    };
    

    创建一个名为fontVertex.glsl的顶点着色器文件,并添加以下代码;此着色器文件从 OpenGL ES 程序接收顶点和纹理坐标信息。接收到的纹理坐标随后被发送到片段着色器,用于纹理采样的目的:

    #version 300 es
    layout(location = 0) in vec3  VertexPosition;
    layout(location = 1) in vec2  VertexTexCoord;
    out vec2 TexCoord;
    uniform mat4 ModelViewProjectMatrix;
    
    void main( void ) {
      TexCoord      = VertexTexCoord;
      gl_Position   =ModelViewProjectMatrix  *
                      vec4(VertexPosition,1.0);
    }
    
  11. 创建 fontfrag.glsl 片段着色器;它包含一个用于纹理输入的 sampler2D 变量和用于文本颜色的 uniform TexColor

    #version 300 es
    precision mediump float;
    
    in vec2 TexCoord;
    uniform sampler2D FontTexture;
    uniform vec4 TextColor;
    layout(location = 0) out vec4 outColor;
    
    void main() {
        vec4 texcol = texture(FontTexture, TexCoord);
        outColor    = vec4(vec3(TextColor.rgb), texcol.a);
    }
    
  12. initModel 函数中加载和编译着色器,并查询顶点着色器属性:

    void Font::InitModel() {
        . . . . . // Other code . . . .
      program->VertexShader  = ShaderManager::ShaderInit
                 (VERTEX_SHADER_PRG, GL_VERTEX_SHADER);
      program->FragmentShader   = ShaderManager::ShaderInit
                 (FRAGMENT_SHADER_PRG, GL_FRAGMENT_SHADER);
      . . . . . // Other code . . . .
    
      MVP = ProgramManagerObj->ProgramGetUniformLocation
                 (program,"ModelViewProjectMatrix");
      TEX = ProgramManagerObj->ProgramGetUniformLocation
                 (program, (char*) "Tex1");
      FRAG_COLOR = ProgramManagerObj->ProgramGetUniformLocation
                 (program, (char*)"TextColor");
    }
    
  13. drawGlyph 函数负责渲染字形。通过映射存储在字形数据结构中的纹理坐标,在逻辑正方形上渲染字形。使用纹理单元 0 初始化纹理采样:

    void Font::drawGlyph(const Glyph& gi) {
        glUseProgram(program->ProgramID);
    
       // Using the glyph metrics to get the glyph info.
        float xmargin = flot(gi.metric.width)/(2.0*64.0);
       float ymargin =float(gi.metric.horiBearingY)/(2.0*64.0);
    
        // Calculate texture coord for glyph rendering
        float texCoords[8] = {
            gi.atlasX, gi.atlasY,
            gi.atlasX + texDimension, gi.atlasY,
            gi.atlasX, gi.atlasY + texDimension,
            gi.atlasX + texDimension, gi.atlasY + texDimension
        };
    
        // 1x1 glyph Quad.
        float quad[12]   = {
            {-0.5f, 0.5f,  0.0f},{ 0.5f, 0.5f,  0.0f},
            {-0.5f, -0.5f, 0.0f},{0.5f, -0.5f, 0.0f }};
    
       for (int i = 0; i<12;){
           quad[i] *= squareSize/2.0;
           quad[i+1] *= squareSize/2.0;
           quad[i+2] *= 0.0;
           i += 3;
       }
    
        // Initialize the texture with texture unit 0
        glUniform1i(TEX, 0);
        TransformObj->TransformPushMatrix();
        TransformObj->TransformTranslate(-xmargin, ymargin,
                                                   0.0f );
        glUniformMatrix4fv(MVP, 1, GL_FALSE, (float*)
        TransformObj->TransformGetModelViewProjectionMatrix());
        TransformObj->TransformPopMatrix();
    
        // Send the vertex and texture info to shader
        glEnableVertexAttribArray(VERTEX_POSITION);
        glEnableVertexAttribArray(TEX_COORD);
        glVertexAttribPointer(VERTEX_POSITION, 3, GL_FLOAT,
             GL_FALSE, 0, quad);
        glVertexAttribPointer(TEX_COORD, 2, GL_FLOAT,
             GL_FALSE, 0, texCoords);
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    }
    
  14. 消息字符串是通过 printText 函数打印的。此函数遍历消息字符串并调用 drawGlyph 函数来渲染其中的每个字符。渲染每个字符后,下一个字形通过存储在对应字符代码的字形数据结构中的水平偏移 advanceHorizontal 信息来前进:

    void Font::printText(char* str, GLfloat Red,
          GLfloat Green, GLfloat Blue, GLfloat Alpha) {
         // Initialize OpenGL ES States
         glDisable(GL_CULL_FACE);
         glDisable(GL_DEPTH_TEST);
         glEnable(GL_BLEND);
         glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    
         // Use font program
        glUseProgram(program->ProgramID);
    
         // Activate Texture unit 0 and assign the altas
         glActiveTexture (GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, atlasTex);
    
        TransformObj->TransformPushMatrix();
        GLfloat color[4] = {Red, Green, Blue, Alpha};
        glUniform4fv(FRAG_COLOR, 1, color);
    
        for (const char* c = str; *c != '\0'; c++) {
            const Glyph& gi = glyphs[((unsigned long) *c)];
            TransformObj->TransformTranslate
              (gi.advanceHorizontal/ 2.0, 0.0, 0.0);
            drawGlyph(gi);
        }
        TransformObj->TransformPopMatrix();
        return;
    }
    

    当前 drawGlyph() 的情况可以通过将多个绘制调用组合成一个来优化。如果所有字形四元组都计算并指定了它们的纹理坐标,并且这些坐标存储在顶点属性缓冲区中,则可以一次性定义和绘制多个字形。我们将把这个优化留给读者作为练习。

  15. 创建一个从 Model 派生的 FontSample 类,并覆盖 Render() 方法以渲染示例文本,如下面的代码所示:

    void FontSample::Render(){
        Font* English = dynamic_cast<Font*>
                    (RendererHandler->getModel(FontEnglish));
        static float angle = 0.0;
        TransformObj->TransformPushMatrix();
        TransformObj->TransformTranslate(-0.50, 0.0, 0.0);
        TransformObj->TransformRotate(angle++, 1.0, 0.0, 0.0);
        English->printText((char*)"Hello World !!!",1,1,0,1);
        TransformObj->TransformPopMatrix();
    }
    
  16. Renderer::createModel 函数中,加载所需字号的字体文件并添加 FontSample 模型。确保字体文件已添加到项目中:

    void Renderer::createModels(){
        clearModels();
        char fname[500]= {""};
      #ifdef __APPLE__
        GLUtils::extractPath( getenv("FILESYSTEM"), fname);
      #else
        strcpy( fname, "/sdcard/GLPIFramework/Font/");
      #endif
        addModel(new Font(strcat(fname,"ACUTATR.TTF"),
           50, this, English) );
        addModel( new FontSample(this) );
    }
    

它是如何工作的...

为了正确使用 FreeType 并在使用过程中避免任何意外惊喜,初始化 FreeType 是必要的;这种初始化是在 Font 类的构造函数中通过使用 FT_Init_FreeType API 来完成的。这确保了库中的所有模块都准备好使用。初始化成功时,此 API 返回 0;否则,它返回一个错误并将句柄设置为 NULL 值。

构造函数还调用了 loadFont 函数;此函数使用 FT_New_Face API 加载字体文件并创建面对象。一个字体文件可能包含一个或多个字体面;面包含字体样式信息。它描述了给定的字体类型和样式。例如,Times New Roman RegularTimes New Roman Italic 对应于两个不同的面。loadFont 函数调用 getCorrectResolution 确保硬件设备支持请求的纹理大小纹理图集分配。最大纹理大小限制可以通过 GL_MAX_TEXTURE_SIZE 查询;如果纹理大小大于支持的限制,则回退到下一个可用的最小大小,并在 calculatedResolutioncalculatedSize 中返回新的更新分辨率和边距大小。

readFont 函数使用 FT_Set_Pixel_Size API 设置字体大小信息。此函数接受三个参数,即字体样式、像素宽度和像素高度。字体文件中字形的总数、像素分辨率和边距大小被用来计算纹理图集的大小,该图集以 2 的幂次方分配并存储在 textureData 中。分配的纹理存储为两个通道信息:一个用于颜色信息,另一个用于 alpha 分量。

使用 FT_Load_Glyph API 遍历并加载库中存在的每个字形。这将在字形槽中加载当前字形,可以通过 fontFace | glyph 获取,并传递给 FT_Render_Glyph,并将位图位写入。这些位使用 generateTexFromGlyph 函数写入 textureData。在纹理中的字形写入是从左到右方向进行的。当字形的数量达到每行的最大字形数时,写入指针被设置为下一行。在 textureData 纹织图集纹理中写入所有字形后,创建一个 OpenGL ES 纹理对象,并使用以下信息设置它:

如何工作...

Font 类提供了纹理渲染的外部接口。这个类首先在 initModel 函数中初始化着色器,类似于其他 GLPI 框架模型。可以使用 printText 函数来渲染文本信息;此函数接受一个文本消息字符串作为第一个参数,以及 RGBA 格式的颜色信息作为接下来的四个参数。打印函数应禁用剔除和深度测试。前者测试被禁用,因为我们想查看字体在背面;否则,当纹理突然消失时,会令用户感到惊讶。后者情况有助于保持渲染在顶部的文本始终可见;我们不希望它被其他对象遮挡。必须使用 glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 混合函数打开 alpha 混合。遍历每个字符打印字符串;从当前字符代码的字符映射中获取相应的字形,并将其传递给 drawGlyph 函数。drawGlyph 函数利用字形结构生成在 2D 或 3D 空间中绘制位图图像所需的信息。每个字形被渲染到一个正方形中,并使用纹理图集的纹理坐标进行映射;字形需要根据字体度量或 2D/3D 空间中的字形度量进行放置。

注意

字形度量包含与特定字形相关的距离信息,有助于在创建文本布局时进行定位。

如何工作...

更多内容...

printText函数渲染简单的文本,其中可以将转换应用于字符串以实现各种效果。我们已经看到每个字符串一次渲染为一个单独的符号。因此,可以对单个符号执行转换动画。以下图像是符号动画的示例,其中符号以圆形排列并沿y轴旋转:

还有更多...

在当前配方中,可以使用animateText函数以动画方式渲染符号。函数定义将在稍后解释;它接受两个额外的参数:半径和旋转,以及printText参数。此函数渲染以圆形排列的符号,并沿y轴旋转。

根据字符串中的字符数量和给定的半径,计算一个轨迹,并将每个字符放置得使其始终面向相机。这样,字母始终面向相机,无论其在y轴上的位置和角度如何:

void Font::animateText(const char* str, GLfloat Red, GLfloat Green,
  GLfloat Blue, GLfloat Alpha,float radius,float rotation){
    // Same code as printText, reuse it
    int num_segments = strlen(str); int index = 0;
    float theta = 0;
    for (const char* c = str; *c != '\0'; c++) {
        TransformObj->TransformPushMatrix();
        TransformObj->TransformRotate(rot , 0.0, 1.0, 0.0);

         // position of character on the locus
        theta = 2.0f * PI_VAL * (index++)/num_segments;
        TransformObj->TransformPushMatrix();
        TransformObj->TransformTranslate
             (radius*cosf(theta), 0.0, radius * sinf(theta));
        const Glyph& gi = glyphs[((unsigned long) *c)];
        TransformObj->TransformRotate(-rot , 0.0, 1.0, 0.0);

       // Draw Glyph
       drawGlyph(gi);
        TransformObj->TransformPopMatrix();
        TransformObj->TransformPopMatrix();
    }
    TransformObj->TransformPopMatrix();
}

参见

  • 使用 Harfbuzz 渲染不同语言

  • 参考第七章中关于使用 UV 映射应用纹理的配方,纹理和映射技术

使用 Harfbuzz 渲染不同语言

FreeType 库执行光栅化操作,其中每个字符都与一个符号索引相关联;这个符号索引映射到位图图像。对于像英语这样的简单脚本,这些信息或多或少是足够的,因为英语的形状不会随着上下文而改变。例如,基于上下文,阿拉伯语有四种不同的形状形式,其中字符的形状可能取决于其自身位置或周围字符。随着 Unicode 的出现,不同语言需要能够创建符号的复杂转换,如替换、定位、双向文本、上下文敏感的形状和连字符。因此,我们需要一些特殊的库来理解语言的上下文,并为我们执行形状任务;这就是 Harfbuzz 发挥作用的地方。

Harfbuzz 是一个文本形状引擎,它管理复杂文本;它使用用户指定的语言脚本和布局方向在给定的 Unicode 文本上执行形状任务。这个库不提供文本布局或渲染。

复杂文本的一些特性如下:

  • 双向性:从左到右和相反方向编写的/显示的文本。阿拉伯语和希伯来语脚本使用从右到左的方向。然而,包括拉丁语在内的大多数其他语言都是从左到右编写的。以下图像显示了双向顺序中英语数字和阿拉伯文本的混合。使用 Harfbuzz 渲染不同语言

  • 形状:字符形状取决于上下文。例如,当阿拉伯字符与相邻字符连接时,其形状会发生变化。以下示例显示了阿拉伯语中的上下文形状。使用 Harfbuzz 渲染不同语言

  • 连字符:连字符是一种特殊字符,它将两个或多个字符组合成一个单一字符。以下是一个阿拉伯连字符的示例。使用 Harfbuzz 渲染不同语言

  • 定位:字符在垂直或水平方向上相对于给定字符进行调整;以下图像展示了泰语中定位的概念。使用 Harfbuzz 渲染不同语言

  • 重新排序:字符的位置取决于上下文。在以下示例中,印地语文本(达拉字母)的最后一个字符在最终输出中位于倒数第二个字符之前。使用 Harfbuzz 渲染不同语言

  • 分割字符:在这种情况下,相同的字符出现在多个位置。使用 Harfbuzz 渲染不同语言

    注意

    图片来源:scripts.sil.org

这个配方将展示不同类型语言的文本渲染,例如阿拉伯语、泰语、旁遮普语、泰米尔语和英语。

类和数据结构

这个配方将介绍一个新的类,该类负责根据指定的语言对文本进行形状处理。

字体形状:这个类是从FontGenerator派生出来的。它继承了从 FreeType 库中必要的所有重要信息,这些信息对于光栅化是必需的。这个类使用Harfbuzz-ng库进行文本形状处理:

使用 Harfbuzz 渲染不同语言

准备工作

Harfbuzz-ng库是一个用 ANSI-C 编写的开源库。这个库在 MIT 许可下免费提供。

注意

该库可以从freedesktop.org/wiki/Software/HarfBuzz/下载。

构建过程:以下步骤提供了对Harfbuzz-ng库和其他实现此配方所需源文件安装过程的详细描述:

  • Android:在 Android 平台上,我们需要 makefile 来构建Harfbuzz-ng库。在GLPIFramework/Font/harfbuzz-ng下添加Android.mk makefile。根据以下代码编辑此 makefile。这将作为一个共享库编译,并命名为GLPiharfbuzz

    LOCAL_SRC_FILES:= \
       src/hb-blob.cc src/hb-buffer-serialize.cc \
    src/hb-buffer.cc src/hb-common.cc \
    src/hb-fallback-shape.cc src/hb-font.cc \
       src/hb-ft.cc src/hb-ot-tag.cc src/hb-set.cc \
    src/hb-shape.cc src/hb-shape-plan.cc \
    src/hb-shaper.cc src/hb-tt-font.cc \
       src/hb-unicode.cc src/hb-warning.cc \
    src/hb-ot-layout.cc src/hb-ot-map.cc \
    src/hb-ot-shape.cc src/hb-ot-shape-complex-arabic.cc\
       src/hb-ot-shape-complex-default.cc \
       src/hb-ot-shape-complex-indic.cc \
       src/hb-ot-shape-complex-indic-table.cc \
       src/hb-ot-shape-complex-myanmar.cc \
       src/hb-ot-shape-complex-sea.cc \
       src/hb-ot-shape-complex-thai.cc \
       src/hb-ot-shape-normalize.cc \
       src/hb-ot-shape-fallback.cc \
    
    LOCAL_CPP_EXTENSION := .cc
    
    LOCAL_C_INCLUDES += $(LOCAL_PATH)/src external/icu4c/common \
                 $(LOCAL_PATH)/src $(LOCAL_PATH)/../freetype/include
    
    LOCAL_CFLAGS := -DHAVE_OT
    LOCAL_MODULE:= GLPiharfbuzz
    LOCAL_STATIC_LIBRARIES := GLPift2
    include $(BUILD_SHARED_LIBRARY)
    

    GLESNativeLib.java中,编辑GLESNativeLib类并添加我们的GLPiharfbuzz.so共享库的引用,以便在运行时链接:

    public class GLESNativeLib {
    static {
     System.loadLibrary("GLPiharfbuzz");
        . . . . . . Other code
    }
    

    在当前项目目录下 (<源代码路径>/Localization/Android/JNI) JNI 文件夹中打开 Android.mk makefile,并包含我们在 harfbuzz 库中创建的 Android.mk makefile 的路径。此外,添加以下源文件以构建此配方:

    FONT_PATH= $(FRAMEWORK_DIR)/Font
    include $(MY_CUR_LOCAL_PATH)/../../../../GLPIFramework/Font/harfbuzz-ng/Android.mk
    
    LOCAL_C_INCLUDES += $(FONT_PATH)/FreeType/include
    LOCAL_C_INCLUDES += $(FONT_PATH)/harfbuzz-ng/src
    
    LOCAL_SRC_FILES += $(SCENE_DIR)/FontGenerator.cpp \
                       $(SCENE_DIR)/FontShaping.cpp \
                       $(SCENE_DIR)/Font.cpp \
                       $(SCENE_DIR)/FontSample.cpp \
                       $(SCENE_DIR)/SimpleTexture.cpp
    LOCAL_SHARED_LIBRARIES += GLPiharfbuzz
    
  • iOS: 在 iOS 平台上,我们需要将相同的 FreeType 项目源文件(在 LOCAL_SRC_FILES makefile 变量下提到)添加到您的项目中,使用 构建阶段 | 编译源文件 项目属性。点击添加以选择源文件。

    使用 构建设置 | 搜索路径 | 头文件搜索路径 提供一个路径以包含 Harfbuzz 项目的头文件。对于当前情况,应该是:

    ../../../../GLPIFramework/Font/harfbuzz-ng/src/
    

    此外,使用 文件 | 将文件添加到 <项目名称> 添加 FontGenerator.h/cppFontShaping.h/cppFont.h/cppFontSample.h/cpp

如何做...

重新使用第一个实现的配方,即 使用 FreeType 项目的字体渲染,然后按照以下步骤编程此配方:

  1. FontGenerator 派生出 FontShaping 类,并添加以下代码。这个类包含两个主要功能:setDirectionAndScriptproduceShape

    class FontShaping : public FontGenerator{
     public:
        FontShaping(){ font = NULL; buffer = NULL; }
        ~FontShaping(){}
        void setDirectionAndScript
             (hb_buffer_t *&buffer, LanguageType languageType);
        bool produceShape(const char* string, vector<FT_UInt >&);
    
     private:
        hb_font_t  *font;
        hb_buffer_t  *buffer;
    };
    
  2. produceShape 函数负责使用 Harfbuzz-ng 库进行文本形状。它接受一个需要形状的字符串作为输入参数,并返回处理形状后的代码点。这些代码点不过是字形索引:

    bool FontShaping::produceShape(const char* str, std::vector< FT_UInt >& codePoints){
        FT_UInt glyph_index = 0;
        hb_glyph_info_t *glyph_info;
        FT_Face     ft_face = fontFace; //handle to face object
        if (!ft_face)
            { return false; }
    
        int num_chars = (int)strlen(str);
        if (!font) { font=hb_ft_font_create(ft_face, NULL); }
    
        /* Create a buffer for harfbuzz to use */
        if (buffer){ hb_buffer_destroy(buffer); buffer=NULL; }
    
        buffer = hb_buffer_create();
    
        // The languageType is an enum containing enum of
        // different supported languages
       setDirectionAndScript(buffer, languageType);
    
        /* Layout the text */
        hb_buffer_add_utf8(buffer, str, num_chars, 0, num_chars);
        hb_shape(font, buffer, NULL, 0);
    
        glyph_count = hb_buffer_get_length(buffer);
        glyph_info  = hb_buffer_get_glyph_infos(buffer, 0);
        for (int i = 0; i < glyph_count; i++) {
            glyph_index = glyph_info[i].codepoint;
            codePoints.push_back(glyph_index);
        }
    
        if (buffer) {hb_buffer_destroy(buffer); buffer=NULL;}
        if (codePoints.size() <=0 ) { return false; }
        return true;
    }
    
  3. Harfbuzz 需要脚本和布局方向提示才能执行文本形状。因此,最终用户必须提供脚本类型和文本布局的方向:

    void FontShaping::setDirectionAndScript
    (hb_buffer_t *&buffer, LanguageType languageType){
        switch( languageType ){
            case Thai:{
                hb_buffer_set_direction(buffer, HB_DIRECTION_LTR);
                hb_buffer_set_script(buffer, HB_SCRIPT_THAI);
            }break;
    
            case Punjabi:{
                hb_buffer_set_direction(buffer, 
                                      HB_DIRECTION_LTR);
                hb_buffer_set_script(buffer, 
                                      HB_SCRIPT_GURMUKHI);
            }break;
    
            case Arabic:{
                hb_buffer_set_direction(buffer, 
                                      HB_DIRECTION_RTL);
                hb_buffer_set_script(buffer, HB_SCRIPT_ARABIC);
            }break;
    
            case Tamil:{
                hb_buffer_set_direction(buffer,
                                      HB_DIRECTION_LTR);
                hb_buffer_set_script(buffer, HB_SCRIPT_TAMIL);
            }break;
    
            default:{
                hb_buffer_set_direction(buffer,
                                      HB_DIRECTION_LTR);
                hb_buffer_set_script(buffer, HB_SCRIPT_COMMON);
            }break;
        }
    }
    
  4. FontGenerator::readFont 函数中,替换以下代码。这将有助于在文本形状后映射 Harfbuzz 生成的代码点:

    for (FT_ULong charcode=FT_Get_First_Char(fontFace, &gindex);
           gindex != 0; charcode=FT_Get_Next_Char
          (fontFace, charcode, &gindex)) { . . }
    

    用此代码替换前面的代码:

    for(int myc = 0; myc < numGlyphs; myc++) {  . . . }
    
  5. 关于在 FontGenerator::readFont 函数中进行的先前代码更改,替换 Font::printText 函数中的以下代码:

        for (const char* c = str; *c != '\0'; c++) {
            const Glyph& gi = glyphs[((unsigned long) *c)];
            TransformObj->TransformTranslate
                 (gi.advanceHorizontal / 2.0, 0.0, 0.0);
            drawGlyph(gi);
        }
    

    用此代码替换前面的代码:

        std::vector< FT_UInt > codePointsPtr;
      int glyph_count = 0;
      if ( !produceShape(str, codePointsPtr, glyph_count) ){
         LOGI("Error in producing font shape");return;}
    
      glyph_count = (int) codePointsPtr.size();
      FT_UInt glyph_index = 0;
      for (int i = 0; i < glyph_count; i++) {
          glyph_index = codePointsPtr.at(i);
          const Glyph& gi = glyphs[glyph_index];
          TransformObj->TransformTranslate
              (gi.advanceHorizontal / 2.0, 0.0, 0.0);
          drawGlyph(gi);
      }
    
  6. Renderer::createModels 函数中,根据支持的语言添加必要的字体文件:

    void Renderer::createModels(){
       clearModels();
       . . . . // Other code . . .
       addModel( new Font(strcat(fname,"ae_Nagham.ttf"),
             50, this, Arabic) );
       addModel( new Font(strcat(fname,"Roboto-Black.ttf"),
             50, this, English) );
       addModel( new Font(strcat(fname,"DroidSansThai.ttf"),
             50, this, Thai) );
       addModel( new Font(strcat(fname,"Uni Ila.Sundaram-03.ttf"),
            50, this, Tamil) );
       addModel(new Font(strcat(fname,"AnmolUni.ttf"),
            50, this, Punjabi) );
        addModel( new FontSample(this) );
    }
    

    如何做...

它是如何工作的...

在当前配方中渲染字体的工作逻辑与上一个配方相同。因此,强烈建议在阅读本节之前先了解第一个配方,本节将仅涵盖文本形状的工作概念。

这个配方介绍了一个名为 FontShaping 的新类,它是由 FontGenerator 派生出来的。从现在起,Font 类将继承自 FontShaping,而不是 FontGeneratorFontShaping 类是文本形状的核心引擎。内部,这个类使用了 Harfbuzz-ng 库。

我们在Font::printText函数中发送 UTF-8 编码作为多语言文本渲染的输入参数。这个函数调用FontShaping::produceShaping,它除了接受 UTF-8 文本外,还接受一个额外的参数,即从该函数返回给调用函数的代码点向量列表。代码点基本上是字体文件中符号的索引。在多语言文本渲染中,我们使用了符号的索引而不是符号映射中的字符代码。

Harfbuzz-ng库使用它自己的临时缓冲区(hb_buffer_t类型)来计算形状信息;这个临时缓冲区是通过hb_buffer_create API 分配的。创建的缓冲区用于在setDirectionAndScript函数中设置文本布局方向(hb_buffer_set_direction)和语言脚本(hb_buffer_set_script)。

使用hb_buffer_add_utf8 API 并提供 UTF8 编码的文本到Harfbuzz库。此外,还需要从 FreeType 获取字体面信息以创建自己的字体(hb_font_t)。这个字体是通过hb_ft_font_create API 创建的。hb_shape API 为输入字符串执行形状任务。它接受hb_font_thb_buffer_t对象作为参数。

在库中完成形状过程后,符号的数量可能会改变。hb_buffer_get_length API 提供了新的符号计数。可以通过hb_buffer_get_glyph_infos API 检索形状信息,该 API 返回包含所有符号代码点的hb_glyph_info_t对象。这些代码点收集在一个向量列表中,并返回到printText函数。确保在过程结束时释放临时缓冲区。

Font::printText中,从向量列表中检索codePoint或符号索引,并以相同的方式渲染(我们在第一个菜谱中描述过)。

参见

  • 使用 FreeType 项目进行字体渲染

在抬头显示(HUD)上渲染文本

在屏幕坐标系上渲染文本是打印文本的一个非常常见的用例。HUD,也称为叠加层,允许你在正常场景之上渲染文本。场景对象的深度不会改变文本的大小。HUD 的例子包括菜单项、状态栏、游戏计分板等。

技术上,HUD 是一个正交视图,其中左、右、上、下四个方向的尺寸设置为场景视口的尺寸。在这个菜谱中,我们将打印旋转 3D 立方体的顶点位置到屏幕坐标。立方体中的所有顶点(近或远)都有相同大小的文本。它不受顶点与相机位置距离的影响:

在抬头显示(HUD)上渲染文本

在当前配方中,我们将重用第二章中的OpenGL ES 3.0 绘图 API配方,OpenGL ES 3.0 基础。这将渲染一个在 3D 空间中旋转的立方体。我们将使用 HUD 机制来显示屏幕坐标中每个顶点的位置。

准备工作

重用上一个配方,使用 Harfbuzz 渲染不同语言,并添加来自另一个配方OpenGL ES 3.0 绘图 API的以下文件,第二章,OpenGL ES 3.0 基础

  1. 打开Cube.hCube.cpp GL ES 程序文件

  2. 打开CubeVertex.glslCubeFragment.glsl GLSL 着色器文件

如何做到这一点...

以下指令将提供一个逐步过程来实现 HUD:

  1. 编辑Cube.h/cpp并定义一个名为GetScreenCoordinates的新方法。这将从立方体顶点的逻辑坐标生成屏幕坐标并将它们收集在screenCoordinateVector向量列表中。对于导入的着色器不需要进行任何更改:

    void Cube::GetScreenCoordinates(){
        // Get Screen Coordinates for cube vertices
        int   viewport_matrix[4];
        float screenCoord[3];
        glGetIntegerv( GL_VIEWPORT, viewport_matrix );
        screenCoordinateVector.clear(); // Clear vector
    
        for(int i=0; i<sizeof(cubeVerts)/(sizeof(GLfloat)*3);i++){
            GLfloat x = cubeVerts[i][0]; // Vertex X coordinate
            GLfloat y = cubeVerts[i][1]; // Vertex Y coordinate
            GLfloat z = cubeVerts[i][2]; // Vertex Z coordinate
    
            int success = TransformObj->TransformProject
                   (x, y, z,
                   TransformObj->TransformGetModelViewMatrix(),
                   TransformObj->TransformGetProjectionMatrix(),
                   viewport_matrix, &screenCoord[0],
                   &screenCoord[1], &screenCoord[2]);
    
           if (!success)
              {memset(screenCoord,0,sizeof(float)*3);continue;}
            int screenX  = screenCoord[0];
            int screenY  = viewport_matrix[3] - screenCoord[1];
            screenCoordinateVector.push_back
                         (glm::vec2(screenX,screenY));
        }
    }
    
  2. 在渲染原语之后,在Cube::Render函数中调用GetScreenCoordinates。对于这个配方,我们将渲染原语从GL_TRIANGLES更改为GL_LINE_LOOP

    void Cube::Render(){
    
      . . . . Other Rendering Code . . . .
       glVertexAttribPointer(attribVertex, 3, GL_FLOAT,
                GL_FALSE, 0, vertexBuffer);
       glDrawArrays(GL_LINE_LOOP, 0, 36);
       GetScreenCoordinates();
    }
    
  3. FontSample.h/cpp中创建一个名为HeadUpDisplay的函数;这个函数将负责设置正确的投影系统和其尺寸以用于抬头显示。HUD 的投影系统必须是正交的,尺寸必须设置为视口尺寸:

    void FontSample::HeadUpDisplay(int width, int height){
        TransformObj->TransformSetMatrixMode( PROJECTION_MATRIX );
    
        TransformObj->TransformLoadIdentity();
        // Left ,Right ,Bottom , Top, Near, Far
        TransformObj->TransformOrtho(0, width, 0, height,-1,1);
    
        TransformObj->TransformSetMatrixMode( VIEW_MATRIX );
        TransformObj->TransformLoadIdentity();
    
        TransformObj->TransformSetMatrixMode( MODEL_MATRIX );
        TransformObj->TransformLoadIdentity();
    }
    
  4. FontSample::Render()中,在渲染任何绘图原语之前调用HeadUpDisplay函数。这将启用 HUD 查看。从Cube类获取向量列表,并使用Font::printText函数渲染顶点位置:

    void FontSample::Render(){
        int viewport_matrix[4];
        glGetIntegerv( GL_VIEWPORT, viewport_matrix );
        HeadUpDisplay(viewport_matrix[2], viewport_matrix[3]);
    
        Font* English = dynamic_cast<Font*>
                       (RendererHandler->getModel(FontEnglish));
        Cube* cubeObject = dynamic_cast<Cube*>
                        (RendererHandler->getModel(CubeType));
        std::vector<glm::vec2>* vertexVector =
                       cubeObject->getScreenCoordinateVertices();
    
        char buffer[500];
        for(int i = 0; i<vertexVector->size(); i++) {
            TransformObj->TransformPushMatrix();
            TransformObj->TransformTranslate
            (vertexVector->at(i).x, vertexVector->at(i).y, 0.0);
            TransformObj->TransformScale(2.0, 2.0, 2.0);
            memset(buffer, 0, 500);
            sprintf(buffer, "Vertex pos: %d,%d", (int)
            vertexVector->at(i).x, (int)vertexVector->at(i).y);
            English->printText(buffer, 1.0, 1.0, 1.0, 1.0f );
            TransformObj->TransformPopMatrix();
        }
    }
    

工作原理...

HUD 的投影系统必须始终处于正交视图中。FontSample::HeadUpDisplay函数使用Transform::TransformOrtho API 将投影矩阵设置为正交视图。它接受八个参数,其中左右和上下必须指定与视口尺寸匹配的正确尺寸。将ModelView设置为单位矩阵:

TransformObj->TransformSetMatrixMode( PROJECTION_MATRIX );
TransformObj->TransformLoadIdentity();
// Left, Right, Bottom, Top, Near, Far
TransformObj->TransformOrtho(0, width, 0, height,-1,1);

在渲染原语之前必须调用HeadUpDisplay函数。对于这个配方,我们从Cube类收集了每个顶点的屏幕坐标,并使用Font::printText函数以及它们各自的屏幕坐标位置来显示它们。顶点的屏幕空间坐标可以使用Transform::TransformProject函数在逻辑坐标系中计算:

工作原理...

参考信息

  • 使用 Harfbuzz 渲染 不同语言

  • 参考第二章理解 GLPI 中的投影系统中的配方

  • 请参考第二章中的OpenGL ES 3.0 绘图 API配方,OpenGL ES 3.0 基础

第九章.后期处理和图像效果

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

  • 使用 Sobel 算子检测场景边缘

  • 使用高斯模糊方程使场景模糊

  • 使用光晕效果实时使场景发光

  • 将场景绘制成卡通着色效果

  • 生成浮雕场景

  • 实现灰度值和 CMYK 转换

  • 实现带有桶形畸变的鱼眼效果

  • 使用程序纹理实现双目视图

  • 旋转图像

  • 使用纹理四边形实现球形幻觉

介绍

本章将展开探讨场景及其基于图像的效果的无限可能性,这些效果在数据可视化和后期效果领域被广泛应用。实际上,物体在三维空间中表现为一组顶点。随着顶点数量的增加,场景的时间复杂度也随之增加。此外,以图像的形式表示物体,其时间复杂度与场景中片段的数量成正比。另外,许多效果只能在图像空间中高效实现,而不是在顶点空间中实现,例如模糊、光晕、云渲染等等。

后期屏幕处理是一个应用于 OpenGL ES 场景的 texel 操作技术,一旦场景渲染完成。更具体地说,场景首先渲染到一个离屏表面,然后应用效果。然后,这个经过处理的离屏纹理被渲染回屏幕表面。

在后期处理中,给定 texel 的结果会受到其周围 texel 的影响。这些技术不能应用于实时场景,因为顶点着色器和片段着色器是局部工作的。这意味着顶点着色器只知道当前顶点,片段着色器只知道当前片段;它们不能使用邻居元素的信息。这种限制可以通过将场景渲染到纹理中来轻松解决,这允许片段着色器读取纹理中存在的任何 texel 信息。在场景渲染到纹理后,对纹理应用基于图像的技术。

基于图像的效果是通过片段着色器应用于图像纹理的。在后期处理实现过程中,渲染的场景会经过多个阶段,具体取决于效果复杂度。在每个阶段,它将处理后的输出保存到纹理中,然后将其作为输入传递到下一个阶段。

后期处理的执行模型可以大致分为四个部分:

  • 创建帧缓冲区:第一阶段需要创建一个离线纹理,将场景渲染到其中。这是通过创建帧缓冲区对象(FBO)来实现的。根据场景的需求,将各种纹理或缓冲区,如颜色、模板和深度,附加到 FBO 上。

  • 将场景渲染到纹理:默认情况下,OpenGL ES 场景渲染到默认帧缓冲区。作为后期处理的前提条件,这种渲染必须通过将 FBO 句柄绑定到当前渲染管线来转移到离线纹理(FBO 纹理)。这确保渲染必须发生在 FBO 纹理而不是默认帧缓冲区上。

  • 应用纹理效果:在场景渲染成纹理之后,它就像记忆中的图像,可以应用各种图像效果。根据后期处理的复杂程度,你可能需要多次遍历来处理所需的效果。在多遍历后期处理中,我们可能需要两个或更多个 FBO 来存储当前遍历的中间处理结果,并在后续或后续遍历中使用。

  • 渲染到默认帧缓冲区:最后,后期处理过的纹理场景被渲染回默认帧缓冲区,这使得场景变得可见。以下图示了一个边缘检测示例,其中展示了后期屏幕处理的各个阶段:介绍

使用 Sobel 算子检测场景边缘

边缘检测是一种图像处理技术,用于检测图像中的边界。它在计算机视觉、数据可视化和表面拓扑学领域得到广泛应用。例如,图像的铅笔素描效果并非什么,而是一种边缘检测算法的应用。本配方将演示使用 Sobel 算子或滤波器进行边缘检测技术。

Sobel 滤波器测量图像梯度的变化,它识别图像中颜色过渡频率较高的区域。这些高过渡区域显示了梯度中的尖锐变化,最终对应于边缘。Sobel 算子使用卷积核来检测图像中的边缘部分。卷积核是一个矩阵,其中包含预定义的权重,这些权重基于卷积矩阵本身中的相邻像素强度和权重来计算当前像素。

Sobel 滤波器使用两个 3 x 3 卷积核进行边缘检测处理;一个作用于当前像素水平方向的相邻像素。同样,另一个作用于垂直方向的相邻像素。以下图像显示了两个卷积核:

使用 Sobel 算子检测场景边缘

现在,我们非常清楚 Sobel 滤波器近似图像的梯度。因此,图像的 RGB 信息必须转换为某种梯度形式,最佳方式是计算图像的亮度或亮度。RGB 颜色代表 R、G、B 方向上的 3D 颜色空间。这些颜色必须使用图像的亮度信息将其带入 1D 梯度空间。图像的亮度由白到黑的梯度颜色表示:

使用索贝尔算子检测场景边缘

准备工作

后处理技术高度依赖于纹理基础和 FBO。因此,作为本章的先决条件,你必须理解这些概念。我们在第七章中很好地介绍了这些概念,纹理和映射技术。有关更多信息,请参阅本配方中的另请参阅子部分。

注意

纹理过滤技术必须设置为GL_NEAREST以检测更多边缘和较暗的显示。与使用四个最近纹理坐标的周围像素加权平均的GL_LINEAR过滤不同,GL_NEAREST过滤使用最接近纹理坐标的像素颜色,因此产生的梯度具有更高的频率变化可能性。

如何操作...

按以下步骤逐一指南来理解编程过程。在阅读本节之前,请确保查阅另请参阅部分以了解依赖关系。本配方重用了纹理中的 FBO 配方,并将类名从DemoFBO更改为EdgeDetection

  1. 在构造函数中,加载SimpleTextureObjLoader类。前者类渲染圆点图案网格,后者类用于渲染 FBO 纹理。

  2. 在本课程中,创建两个变量DefaultFBOFboId,分别用于存储默认帧缓冲区和 FBO 的句柄。再创建两个变量:textureIddepthTextureId,用于存储 FBO 中的颜色纹理和深度纹理的句柄。

  3. initModel()中创建 FBO,根据应用程序要求使用用户定义的尺寸(宽度和高度)。本配方使用与渲染缓冲区相同的尺寸。帧缓冲区在GenerateFBO()函数中创建,该函数创建一个颜色缓冲区和深度缓冲区以存储场景颜色和深度信息:

       void EdgeDetection::GenerateFBO(){
       glGetRenderbufferParameteriv(GL_RENDERBUFFER,GL_RENDERBUFFER_WIDTH, &TEXTURE_WIDTH);
    
       glGetRenderbufferParameteriv(GL_RENDERBUFFER,
       GL_RENDERBUFFER_HEIGHT, &TEXTURE_HEIGHT);
    
       glGenFramebuffers(1, &FboId); // Create FBO
       glBindFramebuffer(GL_FRAMEBUFFER, FboId);
    
       // Create color and depth buffer textureobject
        textureId = generateTexture(
        TEXTURE_WIDTH,TEXTURE_HEIGHT);
        depthTextureId = generateTexture(TEXTURE_WIDTH,TEXTURE_HEIGHT,true);
    
        // attach the texture to FBO color 
        // attachment point
        glFramebufferTexture2D(GL_FRAMEBUFFER,
        GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId, 0);
    
       // attach the texture to FBO color 
       // attachment point
       glFramebufferTexture2D(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D,depthTextureId, 0);
    
       // check FBO status
       GLenum status = glCheckFramebufferStatus(
       GL_FRAMEBUFFER);
       if(status != GL_FRAMEBUFFER_COMPLETE){
       printf("Framebuffer creation fails: %d", status);
                    }
       glBindFramebuffer(GL_FRAMEBUFFER, 0);
             }
    
  4. 使用RenderObj()函数渲染场景。场景使用SetUpPerspectiveProjection()渲染到透视投影系统,该函数在RenderObj()之前调用。在绘制场景之前必须绑定 FBO。这将渲染场景的颜色信息到 FBO 的颜色纹理,并将深度信息渲染到 FBO 的深度纹理。

  5. 设置模型视图矩阵并绘制场景。确保在场景渲染到 FBO 后最后恢复默认帧缓冲区:

    void EdgeDetection::RenderObj(){
        // Get the default Framebuffer
        glGetIntegerv(GL_FRAMEBUFFER_BINDING, &DefaultFBO);
    
        // Bind Framebuffer object
        glBindFramebuffer(GL_FRAMEBUFFER,FboId);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0, 
        GL_TEXTURE_2D, textureId,0);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
        GL_TEXTURE_2D, depthTextureId, 0);
    
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
        objModel->Render();
    
        glBindFramebuffer(GL_FRAMEBUFFER, DefaultFBO);
    }
    
  6. 现在,我们使用SimpleTexture类进行边缘检测,一切准备就绪。此类将使用从 FBO 中保存的纹理并对其应用边缘检测着色器。有关SimpleTexture类如何工作的更多信息,请参阅第七章中的使用 UV 映射应用纹理配方,纹理和映射技术

  7. FBO 纹理被渲染到大小为二的四边形。这个四边形适合完整的视口。这就是为什么正交投影系统也必须定义相同的维度:

      TransformObj->TransformSetMatrixMode( PROJECTION_MATRIX );
      TransformObj->TransformLoadIdentity();
      float span = 1.0;
      TransformObj->TransformOrtho(-span,span,-span,span,-span,span);
    
  8. EdgeDetect() 函数使用 SimpleTexture 类应用 Sobel 滤波器。这将在边缘检测着色器中设置所需的 pixelSize 统一变量:

    void EdgeDetection::EdgeDetect(){
        glDisable(GL_DEPTH_TEST);
        glBindFramebuffer(GL_FRAMEBUFFER, DefaultFBO);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glActiveTexture (GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D,textureId);
    
        program = ProgramManagerObj->Program
    ((char*)"EdgeDetection" );
        glUseProgram( program->ProgramID );
        GLint PIXELSIZE = ProgramManagerObj->ProgramGetUniformLocation
    (program, (char*) "pixelSize");
        glUniform2f(PIXELSIZE, 1.0/TEXTURE_HEIGHT,
    1.0/TEXTURE_WIDTH);
        textureQuad->Render();
    }
    
  9. 实现以下用于边缘检测的 EdgeDetectionFragment.glsl 片段着色器。在顶点着色器中不需要进行任何更改。使用 SimpleTexture::InitModel() 加载此着色器:

    #version 300 es
    precision mediump float;
    in vec2 TexCoord;
    uniform vec2 pixelSize;
    uniform sampler2D Tex1;
    layout(location = 0) out vec4 outColor;
    uniform float GradientThreshold;
    float p00,p10,p20,p01,p21,p02,p12,p22,x,y,px,py,distance;
    vec3 lum = vec3(0.2126, 0.7152, 0.0722);
    void main(){
        x = pixelSize.x; y = pixelSize.y;
        p00 = dot(texture(Tex1, TexCoord+vec2(-x, y)).rgb, lum);
        p10 = dot(texture(Tex1, TexCoord+vec2(-x,0.)).rgb, lum);
        p20 = dot(texture(Tex1, TexCoord+vec2(-x,-y)).rgb, lum);
        p01 = dot(texture(Tex1, TexCoord+vec2(0., y)).rgb, lum);
        p21 = dot(texture(Tex1, TexCoord+vec2(0.,-y)).rgb, lum);
        p02 = dot(texture(Tex1, TexCoord+vec2( x, y)).rgb, lum);
        p12 = dot(texture(Tex1, TexCoord+vec2( x,0.)).rgb, lum);
        p22 = dot(texture(Tex1, TexCoord+vec2( x,-y)).rgb, lum);
    
    // Apply Sobel Operator
    
        px = p00 + 1.0*p10 + p20 - (p02 + 1.0*p12 + p22);
        py = p00 + 1.0*p01 + p02 - (p20 + 1.0*p21 + p22);
        // Check frequency change with given threshold
        if ((distance = px*px+py*py) > GradientThreshold ){
            outColor = vec4(0.0, 0.0, 0.0, 1.0);
        }else{ outColor = vec4(1.0); }
    }
    

它是如何工作的...

边缘检测在 EdgeDetection 类中实现。这个类包含两个 ObjLoaderSimpleTexture 类的对象。前者类渲染 3D 网格,后者在 HUD 上渲染纹理。首先,场景被渲染到一个帧缓冲对象中。这允许你在帧缓冲对象的颜色缓冲区中以纹理形式捕获当前场景。然后,这个纹理被应用到 Sobel 操作符卷积滤波器上,用于检测边缘。最后,使用 SimpleTexture 类的对象将过程纹理渲染回 HUD。

让我们详细理解其工作原理。EdgeDetection 类首先在构造函数中初始化 ObjLoaderSimpleTexture 类对象。在 initModel() 函数中,它调用 GenerateFBO 创建一个与渲染缓冲区具有相同维度的离线渲染缓冲区(FBO)。在渲染函数中,这个 FBO 被附加到绘图管道,以便所有绘图命令都转移到我们的 FBO,而不是默认缓冲区。ObjLoader 类将场景渲染到这个 FBO 的纹理(textureId)。图形管道再次绑定回默认帧缓冲区,以便输出可见于屏幕。现在,SimpleTexture 类通过 EdgeDetectionFragment.glsl 着色器处理剩余的查找场景边缘的工作。这个着色器实现了 Sobel 操作符并接受一个纹理作为输入。这个纹理必须是 FBO 的颜色纹理(textureId)。在片段着色器程序中,每次处理当前片段时,它都会检索围绕它的 3x3 片段矩阵。然后,这个矩阵沿着水平和垂直方向与卷积核相乘,得到 pxpy。这个结果用于计算强度(distance)并与给定的阈值(GradientThreshold)进行比较。如果比较结果更大,则片段被着色为黑色;否则,它被着色为白色:

它是如何工作的...

参见

  • 参考第七章 使用帧缓冲对象实现渲染到纹理 的配方,纹理和映射技术

  • 实现灰度和 CMYK 转换

  • 参考第六章 生成圆点图案 的配方,使用着色器工作

使用高斯模糊方程制作场景模糊

模糊效果是一种图像处理技术,可以使图像变柔和或使其变得模糊。结果,图像看起来更平滑,就像通过半透明的镜子观看一样。它通过减少图像噪声来降低图像的整体锐度。它在许多应用中使用,例如开花效果、景深、模糊玻璃和热雾效果。

这个配方中的模糊效果是通过使用高斯模糊方程实现的。像其他图像处理技术一样,高斯模糊方程也使用卷积滤波器来处理图像像素。卷积滤波器越大,模糊效果越好,越密集。高斯模糊算法的工作原理非常简单。基本上,每个像素的颜色与相邻像素的颜色混合。这种混合是在一个权重系统的基础上进行的。与较远的像素相比,较近的像素被赋予更多的权重。

高斯模糊方程背后的数学

高斯模糊方程利用高斯函数。该方程的数学形式以及该函数在一维和二维空间中的图形表示,如图中左侧所示。这个配方使用该函数的二维形式,其中 σ 是分布的标准差,xy 是当前纹理像素在水平和垂直轴上的纹理像素距离,该卷积滤波器在其上工作。高斯函数在使高频值更平滑方面非常有用:

使用高斯模糊方程制作场景模糊

工作原理

高斯滤波器应用于每个纹理像素。因此,其原始值的变化基于相邻像素。相邻像素的数量取决于卷积核的大小。对于一个 9 x 9 的核,所需的计算量是 9 * 9 = 81。这些可以通过执行两次高斯模糊来减少,其中第一次在水平方向(s 轴)上应用于每个纹理像素,如图中右上角图像的标签(1)所示,第二次在垂直方向(t 轴)上应用,由标签(2)表示。这需要 18 次计算,结果与 81 次计算相同。高斯模糊的最终输出由标签 3 表示。

实现高斯模糊需要五个步骤:

  • 滤波器大小:这取决于许多因素,例如处理时间、图像大小、输出质量等。滤波器越大,处理时间越长,结果越好。在这个配方中,我们将使用 9 x 9 的卷积滤波器。

  • FBO:这创建了两个 FBO,第一个包含颜色和深度信息,第二个只包含颜色信息。

  • 渲染到纹理:这会将场景渲染到第一个 FBO 的颜色纹理中。

  • 水平传递:这使用第一个 FBO 的颜色缓冲区并应用水平高斯模糊传递。

  • 垂直传递:这重用第一个 FBO 的颜色缓冲区并应用垂直传递。

如何做...

此配方使用了边缘检测的第一个配方。我们将类名从EdgeDetection重命名为GaussianBlur。理解所需更改的步骤如下:

  1. 创建一个新的顶点着色器,称为Vertex.glsl,如下代码所示。此顶点着色器将由水平和垂直高斯模糊传递共享:

    #version 300 es
    // Vertex information
    layout(location = 0) in vec3  VertexPosition;
    layout(location = 1) in vec2  VertexTexCoord;
    
    out vec2 TexCoord;
    uniform mat4 ModelViewProjectionMatrix;
    void main( void ) {
        TexCoord = VertexTexCoord;
        vec4 glPos = ModelViewProjectionMatrix *
        vec4(VertexPosition,1.0);
        vec2 Pos = sign(glPos.xy);
        gl_Position = ModelViewProjectionMatrix *
        vec4(VertexPosition,1.0);
    }
    
  2. 创建一个新的片段着色器,称为BlurHorizontal.glsl并添加以下代码:

    #version 300 es
    precision mediump float;
    in vec2 TexCoord; 
    uniform vec2 pixelSize; 
    uniform sampler2D Tex1;
    
    layout(location = 0) out vec4 outColor;
    
    uniform float PixOffset[5];   // Texel distance
    uniform float Weight[5];      // Gaussian weights
    
    void main(){
        vec4 sum = texture(Tex1, TexCoord) * Weight[0];
        for( int i = 1; i < 5; i++ ){ // Loop 4 times
           sum+=texture( Tex1, TexCoord + vec2(PixOffset[i],0.0)
           * pixelSize.x) * Weight[i];
           sum += texture( Tex1, TexCoord - vec2(PixOffset[i],0.0) 
           * pixelSize.x) * Weight[i];
        }
        outColor = sum;
    }
    
  3. 类似地,创建另一个新的片段着色器,称为BlurVertical.glsl

    // Use same code from BlurHorizontal.glsl
    void main(){
        vec4 sum = texture(Tex1, TexCoord) * Weight[0];
        for( int i = 1; i < 5; i++ ){ // Loop 4 times
          sum+=texture( Tex1, TexCoord + vec2(0.0, PixOffset[i])
          * pixelSize.y) * Weight[i];
          sum += texture( Tex1, TexCoord - vec2(0.0, PixOffset[i])
          * pixelSize.y) * Weight[i];}
          outColor = sum;
    }
    
  4. SimpleTexture::InitModel()中编译和链接这些着色器。

  5. 使用GaussianEquation()计算高斯权重。我们假设 sigma (σ) 为 10.0。参数值包含沿水平或垂直方向的 texel 距离,σ是高斯分布的方差或标准差:

    float GaussianBlur::GaussianEquation(float value, float sigma){
    return 1./(2.*PI*sigma)*exp(-(value*value)/(2*sigma));
    }
    
  6. 计算水平和垂直高斯片段着色器的权重,如下代码所示,使用GaussianEquation函数:

       gWeight[0]  = GaussianBlur::GaussianEquation(0, sigma);
        sum         = gWeight[0]; // Weight for centered texel
    
        for(int i = 1; i<FILTER_SIZE; i++){
            gWeight[i] = GaussianBlur::GaussianEquation(i, sigma);
    
           // Why multiplied by 2.0? because each weight
           // is applied in +ve and –ve direction from the 
           // centered texel in the fragment shader.
            sum += 2.0 * gWeight[i];
        }
    
        for(int i = 0; i<FILTER_SIZE; i++){
            gWeight[i] = gWeight[i] / sum;
        }
    
        if (GAUSSIAN_WEIGHT_HOR >= 0){
            glUniform1fv(GAUSSIAN_WEIGHT_HOR, 
            sizeof(gWeight)/sizeof(float), gWeight);
        }
    
        // Similarly, pass the weight to vertical Gaussian 
        // blur fragment shader corresponding weight 
        // variable GAUSSIAN_WEIGHT_VERT
    
        float pixOffset[FILTER_SIZE];
        // Calculate pixel offset 
        for(int i=0; i<FILTER_SIZE; i++){ pixOffset[i] = float(i); }
        if (PIXEL_OFFSET_HOR >= 0){
            glUniform1fv(PIXEL_OFFSET_HOR, sizeof(pixOffset)/
            sizeof(float), pixOffset);
        }
    
  7. Gaussian::InitModel中创建两个 FBO,使用GenerateBlurFBO1(带有颜色和深度纹理)和GenerateBlurFBO2(仅颜色缓冲区)。这些创建两个 FBO,分别使用blurFboId1blurFboId2句柄。第一个 FBO 使用额外的缓冲区用于深度,因为我们希望执行深度测试,以便将正确的图像渲染到该 FBO 的颜色纹理中。

  8. 使用透视投影系统将场景渲染到第一个 FBO(blurFboId1颜色纹理)。这将渲染场景图像到该 FBO 的颜色纹理中:

    void GaussianBlur::Render(){
       // Set up perspective projection
        SetUpPerspectiveProjection();
    
        RenderObj();
        // Set up orthographic project for HUD display
        SetUpOrthoProjection();
        RenderHorizontalBlur();
        RenderVerticalBlur();
    }
    
    void GaussianBlur::RenderObj(){
        // Get the current framebuffer handle
        glGetIntegerv(GL_FRAMEBUFFER_BINDING, &CurrentFbo);
    
        // Bind Framebuffer 1
        glBindFramebuffer(GL_FRAMEBUFFER,blurFboId1);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_-ATTACHMENT0, GL_TEXTURE_2D, textureId,0);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_-ATTACHMENT, GL_TEXTURE_2D, depthTextureId, 0);
    
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
        objModel->Render();
    
        glBindFramebuffer(GL_FRAMEBUFFER, CurrentFbo);
    }
    
  9. 现在,将第二个 FBO(带有blurFboId2句柄)设置为渲染目标,重用第一个 FBO 的颜色纹理(包含场景图像),并将其传递给水平模糊传递(传递 1)的RenderHorizontalBlur()函数。这将产生第二个 FBO 的(textureId2)颜色缓冲区上的水平模糊场景图像。注意,在设置第二个 FBO 之前,投影系统应该是正交的:

    void GaussianBlur::RenderHorizontalBlur(){
        glDisable(GL_DEPTH_TEST);
    
        // Bind Framebuffer 2
        glBindFramebuffer(GL_FRAMEBUFFER,blurFboId2);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glFramebufferTexture2D(GL_FRAMEBUFFER, 
         GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId2, 0);
        glActiveTexture (GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, textureId);
    
        // Apply the shader for horizontal blur pass
        program = textureQuad->ApplyShader(HorizontalBlurShader);
        textureQuad->Render();
        TransformObj->TransformError();
    }
    
  10. 最后,使用默认帧缓冲区并使用第二个 FBO 的RenderVerticalBlur函数在第二个 FBO 的纹理(textureId2)上应用传递 2(垂直模糊):

     void GaussianBlur::RenderVerticalBlur() {
        glDisable(GL_DEPTH_TEST);
    
     // Restore to old framebuffer 
        glBindFramebuffer(GL_FRAMEBUFFER, CurrentFbo);
        glViewport(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT);
        glActiveTexture (GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D,textureId2);
    
        // Apply the shader for horizontal blur pass
        program = textureQuad->ApplyShader(VerticalBlurShader);
        GLint PIXELSIZE = ProgramManagerObj->ProgramGetUniform-Location( program, (char *) "pixelSize" );
        glUniform2f(PIXELSIZE, 1.0/TEXTURE_HEIGHT,1.0/TEXTURE_WIDTH);
    
        textureQuad->Render();
    }
    

它是如何工作的...

高斯模糊的基本思想是通过对其周围的 texel 进行加权平均来创建图像的新 texel。权重是通过高斯分布函数应用的。对于每个 texel,我们需要在中心像素周围创建一个正方形。例如,对于一个特定的 texel,一个由五个 texel 组成的正方形核对中间的 texel 贡献了 25 个 texel 的加权平均。现在,随着核直径的增长,操作变得昂贵,因为它需要读取更多的 texel 来贡献。这种开销不是线性的,因为一个 9 x 9 的核需要读取 81 个 texel,这几乎是之前核的四倍。

现在,高斯模糊可以被优化以读取更少的 texel 而且仍然达到相同的结果。这可以通过将核操作分为两个步骤,即水平和垂直步骤来实现。在前者中,仅使用核的行元素进行加权平均来计算行的中间 texel。同样,对于后者情况,考虑列元素。这样,它只需要读取 18(9 + 9)个像素,而不是 81。

现在,让我们了解这个菜谱的工作原理。高斯模糊应用于两个阶段。每个阶段都针对一维行和列。第一阶段是水平遍历,其中水平方向的 texel 被高斯核考虑。这个阶段被称为第 1 阶段,它使用 BlurHorizontal.glsl 执行。同样,第二阶段的第二个阶段在 BlurVertical.glsl 片段着色器中执行。这两个片段着色器共享一个名为 Vertex.glsl 的公共顶点着色器,这些着色器由 SimpleTexture 类管理。

GaussianBlur 类被初始化时,它创建了两个 FBO。第一个 FBO 需要颜色和深度信息来渲染场景。然而,第二个 FBO 不需要任何深度纹理信息,因为它在第一个 FBO 的颜色纹理上工作,该纹理已经考虑了场景的深度。

场景被渲染到第一个 FBO 的颜色纹理中。这个颜色纹理与 SimpleTexture 类共享,其中第一个步骤(水平模糊)应用于它。在第二个步骤中,使用第二个 FBO,并提供了来自第一个 FBO 的水平模糊颜色纹理(作为输入)。这个纹理(水平模糊)处理垂直模糊着色器,并将处理后的纹理存储在第二个 FBO 的颜色缓冲区中。最后,场景附加到默认帧缓冲区,第二个 FBO 的颜色缓冲区被渲染到屏幕上:

如何工作...

参见

  • 使用 Sobel 算子检测场景边缘

使用光晕效果实时制作场景发光

模糊是一种非常有用的后屏幕处理技术,可以使实时场景发光。使用此效果,场景的某些部分会显得非常明亮,并给人一种在空气中发出散射光的感觉。这项技术在游戏和电影效果中得到了广泛应用。

模糊效果的工作原理非常简单。以下图像展示了当前配方中使用的模型的工作原理图,其中场景被渲染到离线帧缓冲区或纹理(标记1),其纹理作为下一阶段的输入,该阶段检测场景中的明亮部分并将其写入新的纹理(标记2)。然后,这个纹理被传递到水平(标记3)和垂直模糊(标记4),应用高斯模糊效果使其变得模糊并稍微分散。这个输出(标记4)最终与原始渲染的场景(标记1)结合,产生类似发光的效果:

使用模糊效果实时制作场景发光

如何实现...

本配方重用了我们之前关于高斯模糊的配方。我们将类名从GaussianBlur更改为Bloom。以下是实现此配方的步骤:

  1. 创建一个新的片段着色器,称为Bloom.glsl。这个片段着色器需要在SimpleTexture类中编译和链接。这个着色器负责定位场景中的明亮部分:

       in vec2 TexCoord;
       uniform sampler2D Tex1;
       layout(location = 0) out vec4 outColor;
       void main() {
       vec4 val = texture(Tex1, TexCoord);
       float brightness = 0.212*val.r + 0.715*val.g + 0.072*val.b;
       brightness>0.6 ? outColor=vec4(1.) : outColor=vec4(0.); 
    }
    
  2. BlurHorizontal.glsl中不需要进行任何更改。然而,在BlurVertical.glsl中,添加以下代码。这段代码负责将场景中模糊的明亮部分与保存在RenderTex纹理中的原始场景(未更改)混合:

    void main(){
         vec4 scene = texture(RenderTex, TexCoord);
         vec4 sum = texture(Tex1, TexCoord) * Weight[0];
         for( int i = 1; i < 5; i++ ){
         sum+=texture(Tex1,TexCoord+vec2(0.0,PixOffset[i]) 
        *pixelSize.y)*Weight[i];
        sum+=texture(Tex1,TexCoord-vec2(0.0,PixOffset[i]) 
        *pixelSize.y)*Weight[i];
     }
         outColor = sum + scene;
    }
    
  3. Bloom::InitModel中使用GenerateSceneFBO()(使用颜色和深度纹理)、GenerateBloomFBO()(仅使用颜色缓冲区)和GenerateBlurFBO2()(仅使用颜色缓冲区)创建三个 FBO。这些函数将分别创建具有SceneFboBloomFboBlurFbo句柄的三个 FBO。

  4. Bloom::Render()下渲染模糊配方。在这个函数中,使用透视投影系统渲染场景,在正交投影系统下处理纹理,并存储默认帧缓冲区的句柄。

  5. 使用RenderObj()RenderBloom()RenderHorizontalBlur()RenderVerticalBlur()渲染模糊效果的各个阶段。所有这些函数都接受四个参数。第一个参数(BindTexture)指定输入颜色纹理/缓冲区,第二个参数(Framebuffer)指定场景应附加到的帧缓冲区的句柄,第三个参数(ColorBuf),以及第四个参数(DepthBuf)指定场景写入的颜色和深度缓冲区。如果任何参数不是必需的,则发送NULL作为参数:

    void Bloom::Render(){
       // Perspective projection
       SetUpPerspectiveProjection(); 
       glGetIntegerv(GL_FRAMEBUFFER_BINDING, &DefaultFrameBuffer);
    
       // Render scene in first FBO called SceneFBO
       RenderObj(NULL, SceneFbo, SceneTexture, DepthTexture); 
    
       // Orthographic projection
       SetUpOrthoProjection(); 
    
       // Render Bloom pass  
       RenderBloom(SceneTexture, BloomFbo, BloomTexture, NULL);
    
       // Render Horizontal pass
       RenderHorizontalBlur(BloomTexture,
       BlurFbo, BlurTexture, NULL);
       // Render Vertical pass
       RenderVerticalBlur(BlurTexture,
       DefaultFrameBuffer,NULL,NULL);
    }
    
  6. RenderObj()将场景渲染到SceneFbo帧缓冲区中的SceneTextureDepthTexture

  7. 类似地,RenderBloom()使用SceneTexture。现在,将其应用于BlurHorizontal.glsl着色器,这将渲染场景到BlurTexture

  8. 最后,RenderVerticalBlur()使用BlurTextureSceneTexture作为输入,并对其应用BlurVertical.glsl着色器,这将应用垂直模糊通道并将其混合到场景纹理中。

  9. 现在,使用blurFboId2 FBO,并重用第一个 FBO 的纹理,将其传递到通道 1(水平模糊)使用RenderHorizontaBlur()函数。这将存储通道 2 的处理结果在textureId2

  10. 现在,使用默认的帧缓冲区,并将通道 2(垂直模糊)应用于第二个 FBO 的纹理(textureId2)。

它是如何工作的...

花瓣效果的工作原理与之前的菜谱非常相似。相反,增加了一个新的花瓣阶段。首先,场景被渲染到一个非默认的帧缓冲区,称为SceneFBO,其中它被写入到SceneTexture。下一个阶段称为花瓣,也是在离线帧缓冲区(BloomFBO)上执行的。在这里,前一个阶段的纹理被用作输入,并应用于花瓣片段着色器。花瓣着色器将彩色图像转换为亮度,以线性渐变形式存储图像信息。这提供了图像的亮度信息,其中通过将渐变值与所需的阈值进行比较来检测亮度部分。然后,最亮的部分被写入到BloomTexture,并提供给高斯模糊阶段。

在这个阶段,从上一个阶段存储在BloomTexture中的输入使用水平高斯模糊通道进行处理,其中它存储在BlurTexture并应用于垂直通道。在垂直模糊通道期间,模糊的亮度部分与原始场景使用SceneTexture混合。这样,图像与场景上的明亮散射光混合:

如何工作...

参见

  • 使用 Sobel 算子检测场景边缘

  • 使用高斯模糊方程使场景模糊

将场景绘制成卡通着色

在各种不同的着色器中,卡通着色器因其能够产生卡通着色场景而广为人知。卡通着色技术是在片段着色器中实现的。这种着色器的基本基础是颜色的量化。在这里,一系列颜色被表示为单一类型的颜色。从数学上讲,颜色值被限制在连续的值集合(以浮点数表示)到相对较小的离散颜色集合(用整数表示)。除了颜色的量化之外,几何形状的边缘也使用 Sobel 算子进行突出显示。

以下图像显示了当前菜谱的截图,其中可以很容易地看到各种绿色色调中的量化。同时,Sobel 算子渲染了粗黑边:

将场景绘制成卡通着色

准备中

这个配方是我们边缘检测配方的扩展。通过在片段着色器中做非常小的改动,我们可以创建出类似绘画卡通的场景。对于这个配方,建议您彻底理解本章中的第一个配方。这个配方将涵盖我们为实施卡通着色器而添加到现有边缘检测片段着色器中的更改。

如何操作...

我们重用了EdgeDetectionFragment.glsl并将其重命名为ToonShader.glsl

uniform float quantizationFactor;
void main(){
    // Reuse Edge detection recipe fragment shader and
    // Calculate p00, p10, p20,p01, p21, p02, p12, p22 
    px = p00 + 2.0*p10 + p20 - (p02 + 2.0*p12 + p22);
    py = p00 + 2.0*p01 + p02 - (p20 + 2.0*p21 + p22);
    // Check frequency change with given threshold
    if ((distance = px*px+py*py) > GradientThreshold ){
        outColor = vec4(0.0, 0.0, 0.0, 1.0);
    }else{ // Apply the Cartoon shading
    rgb = texture(Tex1,TexCoord).rgb*quantizationFactor;
    rgb += vec3(0.5, 0.5, 0.5);
    ivec3 intrgb = ivec3(rgb);
    rgb = vec3(intrgb)/ quantizationFactor;
    outColor = vec4(rgb,1.0); 
   }
}

它是如何工作的...

在卡通着色中,每个传入的片段首先通过 Sobel 操作来检查它是否属于边缘。如果是,当前片段以黑色边缘颜色渲染;否则,它将以卡通着色效果进行着色。

在卡通着色效果中,每个片段颜色乘以一个quantizationFactor(在本例中为 2.0)。这在图像量化过程中被使用。在计算机图形学中,图像量化是将大量颜色限制为更少颜色的过程。换句话说,它将相似的颜色分组为一种。

获得的颜色分量加上 0.5 以增加产生大于 1.0 的值的可能性。这对于下一步很有帮助,在这一步中,浮点颜色空间被转换为整数类型。在这个过程中,颜色分量的十进制部分被截断。

最后,通过将整数空间颜色分量除以quantizationFactor来抵消quantizationFactor乘法的效果(我们在开始时应用了这一点)。结果值应用于片段。

参见

  • 使用 Sobel 算子检测场景边缘

生成凸起场景

凸起是一种技术,其中场景看起来有凸起或突出,具有一定的 3D 深度。凸起着色器的逻辑与边缘检测技术类似。在这里,检测到的边缘用于根据边缘角度突出图像。

准备工作

对于这个配方,我们将重用本章中实现的任何先前后处理配方。这个配方将直接跳转到着色器部分,假设读者已经理解了后处理的基本逻辑。

如何操作...

创建一个新的片段着色器,命名为EmbossFrag.glsl,如下面的代码所示。对于顶点着色器没有需要更改的地方:

in vec2 TexCoord;
uniform vec2 pixelSize;
uniform sampler2D Tex1;
layout(location = 0) out vec4 outColor;
uniform float EmbossBrightness, ScreenCoordX;

void main(){
   // Apply Emboss shading
   vec3 p00 = texture(Tex1, TexCoord).rgb;
   vec3 p01 = texture(Tex1, TexCoord + vec2(0.0,
 pixelSize.y)).rgb;

// Consecutive texel difference
   vec3 diff = p00 - p01;

// Find the max value among RGB
   float maximum = diff.r;
   if( abs(diff.g) > abs(maximum) ){ 
   maximum = diff.g;
}

if( abs(diff.b) > abs(maximum) ){
    maximum = diff.b;
}

// Choose White, Black, or Emboss color
   float gray = clamp(maximum+EmbossBrightness, 0.0, 1.0);
   outColor = vec4(gray,gray,gray, 1.0);

}

如何工作...

在这个配方中,通过取任意方向上两个连续 texel 之间的差值来检测边缘。这两个结果的差值产生一个新的颜色强度,其中每个分量(RGB)相互比较以找到具有最大幅度的分量(max)。然后使用这个分量在低(0.0)和高(1.0)之间进行 clamp 操作。这个操作产生了三种颜色强度:白色(来自低),黑色(来自高)1.0,和凸起(来自最大分量)。凸起着色器的结果如下所示。

首先,场景被渲染到一个 FBO 中,并在颜色缓冲区中存储。然后,这个颜色缓冲区被发送到 Tex1 变量中的浮雕着色器。p00p01 被表示为两个连续的纹理元素,它们从 Tex1 中采样,用于当前片段的位置。差异被存储在 diff 变量中。diff 变量被检查以找到 RGB 组件中的最大幅度,它被存储在 max 变量中。使用 clamp() 函数将最大值钳位。最终结果被用作当前片段的 RGB 组件:

工作原理...

更多内容...

在本食谱中使用的钳位操作是通过 clamp() GLSL 函数执行的。这个函数接受三个值:原始值、下限和上限范围值。如果原始值位于最小和最大范围之间,则返回原始值;否则,如果值小于最小值,则返回最小范围值,反之亦然。

语法:

void clamp(genType x, genType minVal, genType maxVal);
变量 描述
x 这指定了要约束的值
minVal 这指定了约束 x 的范围的下限
maxVal 这指定了约束 x 的范围的上限

相关内容

  • 实现灰度和 CMYK 转换

实现灰度和 CMYK 转换

灰度或亮度是一个重要的主题,没有讨论其实际实现,数字图像处理就不完整。亮度在图像处理的多种应用中被广泛使用。边缘检测、卡通阴影和浮雕效果是我们在本章中实现的例子,它们都利用了亮度。在本食谱中,你将学习如何将 RGB 颜色空间转换为亮度和 CMYK。

从数值上讲,灰度是黑色和白色之间的线性插值,这取决于颜色深度。8 位深度表示从白色到黑色变化的 256 种不同色调。然而,使用 4 位,只能表示 16 种色调。黑色是可能的最暗色调,这是传输或反射光的总缺失。最亮的可能色调是白色,这是在所有可见光下传输或反射光的总和。中间的灰色色调由三种原色(红色、绿色和蓝色)的相等水平来表示,以传递光,或者由三种原色素(青色、品红色和黄色)的相等量来表示反射光。

注意

ITU-R BT.709 标准提供了以下这些组件的权重:

RGB 亮度值 = 0.2125(红色) + 0.7154(绿色) + 0.0721(蓝色)*

准备工作

从这个配方开始,我们将讨论本章中实现的各种图像处理技术。对于这些配方,我们重用了来自第七章的应用带有 UV 映射的纹理配方,纹理和映射技术。对于当前的图像处理配方,我们只需要在片段着色器中进行更改。继续下一节,了解需要进行的更改以实现灰度和 CMYK 转换。

如何操作...

重复使用之前提到的简单纹理配方,并在片段着色器中进行以下更改以实现灰度和 CMYK 配方:

灰度配方:

in vec2 TexCoord;
uniform sampler2D Tex1;
  layout(location = 0) out vec4 outColor;
  // Luminance weight as per ITU-R BT.709 standard
  const vec3 luminanceWeight = vec3(0.2125, 0.7154, 0.0721);
  void main() {
  vec4 rgb = texture(Tex1, TexCoord); // Take the color sample
  // Multiply RGB with luminance weight
  float luminace = dot(rgb.rgb, luminanceWeight);
  outColor = vec4(luminace, luminace, luminace, rgb.a); 
}

它是如何工作的...

声明一个luminanceWeight变量,该变量包含 RGB 组件的权重,按照 ITU-R BT.709 标准。使用传入的纹理坐标,并在rgb变量中采样相应的 texel。计算亮度权重和 rgb 变量之间的点积,以生成灰度图像(存储在亮度变量中)。当前配方输出的灰度图像显示在以下右侧图像中:

如何工作...

还有更多...

在彩色计算机显示器上,图像以 RGB 颜色空间表示。然而,当使用标准印刷过程发布这些图像时,需要将它们转换为 CMYK 颜色空间。RGB 模型是通过向黑色添加颜色成分来创建的。这是基于发射色。相比之下,CMYK 颜色是透射的。在这里,颜色是通过从白色中减去颜色成分来创建的。在 RGB 到 CMYK 转换中,红色成分变为青色,绿色变为品红色,蓝色变为黄色,黑色变为黑色。出版印刷机使用 CMYK 颜色格式,其中 RGB 空间图像被转换为四个单独的单色图像,这些图像用于创建四个单独的印刷版,用于印刷过程。

可以使用以下公式从 RGB 计算出 CMYK 颜色空间:

还有更多...

然而,这种简单的转换并不能真正达到转换后预期的结果。以下是从 Adobe Photoshop 中得到的近似值,效果非常令人满意。底色去除(ucr)和黑色生成(bg)函数如下所示,其中Sk=0.1K0 = 0.3Kmax = 0.9。这些是公式中使用的常数值:

还有更多...

注意

去除底色ucr)是消除用于产生深中性黑色时叠加的黄色、品红色和青色颜色成分的过程,用全黑墨水来替代它们。这导致墨水用量减少,阴影深度增加。

黑色生成bg)是产生黑色通道或颜色的过程。这会影响颜色通道,当从 RGB 颜色空间转换为 CMYK 颜色空间时进行颜色转换。

下图显示了 CMYK 的彩色版本和四个分离的灰度版本。每个组件的灰度表示显示了每个较暗值所需的油墨量,表明油墨消耗量高:

还有更多...

这里是 RGB 颜色空间到 CMYK 颜色空间转换的片段着色器代码:

   in vec2 TexCoord;
   uniform sampler2D Tex1;
   uniform float ScreenCoordX;
   uniform int caseCYMK;
   layout(location = 0) out vec4 outColor;
   void main() { // Main Entrance
   vec4 rgb  = texture(Tex1, TexCoord);
   vec3 cmy  = vec3(1.0)-rgb.rbg;
   float k   = min(cmy.r, min(cmy.g, cmy.b));

   // fucr (K)= SK*K, SK = 0.1 
   vec3 target  = cmy - 0.1 * k;

   // fbg (K) = 0, when K<K0, K0 =0.3, Kmax =0.9
   // fbg (K) = Kmax*(K-K0)/(1-K0), when K>=K0
   k<0.3 ? k=0.0 : k=0.9*(k-0.3)/0.7; 
   vec4 cmyk = vec4(target, k);
// Since we are interested in the separation of each component
// we subtracted gray scale of each color component from white
   if(caseCYMK == 0){              // CYAN conversion
      outColor = vec4(vec3(1.0 - cmyk.x),rgb.a);
   }else if(caseCYMK == 1){     // MAGENTA conversion
       outColor = vec4(vec3(1.0 - cmyk.y),rgb.a);}
   else if(caseCYMK == 2){     // YELLOW conversion
       outColor = vec4(vec3(1.0 - cmyk.z),rgb.a);}
   else if(caseCYMK == 3){     // BLACK conversion
       outColor = vec4(vec3(1.0 - cmyk.w),rgb.a);}
   else{ outColor = rgb;}      // RGB
}

参见

  • 参考第七章中的使用 UV 映射应用纹理配方,纹理和映射技术,第七章

使用桶形畸变实现鱼眼效果

鱼眼是一种效果,其中场景看起来是球形的。因此,场景中的边缘看起来是弯曲的,并围绕这个虚拟球体的中心弯曲。这种效果使场景看起来像被包裹在一个弯曲的表面上。

桶形畸变技术被用来实现当前效果,它可以应用于片段或顶点。这个配方首先将在片段着色器上实现桶形畸变,然后将其应用于顶点着色器。两者的区别在于:在前者着色器中,几何形状不会畸变。然而,纹理坐标会畸变,从而产生放大镜效果或鱼眼镜头效果。在后一种技术中,几何形状被位移,创造出不同的有趣畸变形状。请注意,这并不是一种后处理技术。

准备工作

对于这个配方,我们可以重用我们的第一个配方,并用当前的桶形畸变片段着色器BarrelDistFishEyeFragment.glsl替换边缘检测逻辑。

如何做到这一点...

修改BarrelDistFishEyeFragment.glsl,如下面的代码所示:

precision mediump float;
in vec2 TexCoord;
uniform sampler2D Tex1;
layout(location = 0) out vec4 outColor;

uniform float BarrelPower;
uniform float ScreenCoordX;

vec2 BarrelDistortion(vec2 p){
    float theta  = atan(p.y, p.x);
    float radius = sqrt(p.x*p.x + p.y*p.y); 
    radius = pow(radius, BarrelPower);
    p.x = radius * cos(theta);
    p.y = radius * sin(theta);
    return (p + 0.5);
}

vec2 xy, uv;
float distance;
void main(){
      if(gl_FragCoord.x > ScreenCoordX){
          // The range of text coordinate is from (0,0)
          // to (1,1). Assuming center of the Texture
          // coordinate system middle of the screen.
          // Shift all coordinate wrt to the new 
          // center. This will be the new position 
          // vector of the displaced coordinate.
          xy = TexCoord - vec2(0.5);

         // Calculate the distance from the center point.
         distance = sqrt(xy.x*xy.x+xy.y*xy.y); 

         float radius = 0.35;
         // Apply the Barrel Distortion if the distance
         // is within the radius. Our radius is half of 
         // the ST dimension.
         uv = (distance < radius?BarrelDistortion(xy):TexCoord);

         if( distance > radius-0.01 && distance < radius+0.01 ){
             outColor = vec4(1.0, 0.0, 0.0,1.0);
         }
         else{
             // Fetch the UV from Texture Sample
             outColor = texture(Tex1, uv);
         }
     }
     else{
        outColor = texture(Tex1, TexCoord);
     }
}

它是如何工作的...

这个配方首先将场景渲染到 FBO 的颜色纹理中,然后与SimpleTexture类共享,并应用于纹理坐标从(0.0, 0.0)到(1.0, 1.0)的四边形几何形状。四边形的顶点和纹理信息被提供给顶点和片段着色器以处理几何和片段信息。桶形畸变技术实现在片段着色器中,其中每个传入的纹理坐标临时转换为极坐标以产生鱼眼效果。

纹理坐标首先在中心(0.5, 0.5)进行转换,并从中心计算这些转换后的纹理坐标的距离。如果转换后的纹理坐标(xy)超出了给定的 0.35 半径阈值,则使用未改变的纹理坐标(TexCoord)从Tex1获取样本;否则,将此坐标(xy)应用于桶形畸变,使用BarrelDistortion函数。以下图像显示了红色圆圈的半径。BarrelDistortion函数首先计算纹理坐标相对于逻辑圆心的长度。通过桶形功率改变获得的长度,从而缩小或扩大长度。以下图像显示了不同桶形功率(1.0、0.5、0.3 和 2.0)得到的不同结果。

然后将此改变后的长度乘以纹理坐标沿 S(水平)和 T(垂直)组件的斜率,这将得到一组新的转换后的纹理坐标。这些纹理坐标被重新转换回它们的原始位置(底部,左侧)。最后,使用重新转换的纹理坐标从输入纹理坐标计算采样纹理:

如何工作...

更多内容...

当桶形畸变应用于几何形状时,它会扭曲几何形状的物理形状。以下图像显示了桶形畸变在不同网格上的应用。您可以使用本章提供的BarrelDistortion_Vtx_Shdr源代码来探索此方法:

更多内容...

此方法的逻辑与之前类似,只是现在它是通过顶点着色器实现的。在这里,我们不需要从中心转换纹理坐标,因为默认情况下,原点始终是笛卡尔坐标系的原点。

在顶点着色器中使用以下代码来应用桶形畸变:

layout(location = 0) in vec4  VertexPosition;
layout(location = 1) in vec3  Normal;
uniform mat4   ModelViewProjectionMatrix, ModelViewMatrix;
uniform mat3    NormalMatrix;
out vec3         normalCoord, eyeCoord, ObjectCoord;
uniform float   BarrelPower;

vec4 BarrelDistortion(vec4 p){
    vec2 v = p.xy / p.w;
    float radius = length(v);
   // Convert to polar coords
    if (radius > 0.0){ 
        float theta = atan(v.y,v.x);
        radius = pow(radius, BarrelPower);
    // Apply distortion
        // Convert back to Cartesian
        v.x = radius * cos(theta); 
        v.y = radius * sin(theta);
        p.xy = v.xy * p.w;
    }
    return p;
}

void main(){
    normalCoord = NormalMatrix * Normal;
    eyeCoord    = vec3 ( ModelViewMatrix * VertexPosition );
    ObjectCoord = VertexPosition.xyz;
    gl_Position = BarrelDistortion(ModelViewProjectionMatrix*
    VertexPosition);
} 

参见

  • 参考第六章中的生成圆点图案配方,使用着色器工作,第六章,使用着色器工作

使用过程纹理实现双眼视图

此方法实现了一个双眼视图效果,其中场景被渲染成好像是从双眼本身可视化的。我们将通过编程过程着色器来实现此效果。或者,在另一种技术中,使用 alpha 映射纹理代替。在此方法中,一个包含双眼视图图像的 alpha 掩码纹理被叠加到场景之上。这样,只有属于非掩码纹理区域的场景部分是可见的。

程序化纹理方法也相对简单。在这里,场景是在片段着色器中编程的,其中使用顶点的纹理坐标创建双重视觉效果。纹理坐标用于在渲染的图像上创建一个逻辑圆形区域。属于这个圆形区域外缘的片段将以不透明颜色(例如黑色)渲染。随着距离缩小到这个圆形区域的中心点,这种不透明度会降低。设备屏幕上的触摸点(单次点击手势)用作圆形区域的中心点;这样,可以使用触摸手势在屏幕上移动镜头。

如何操作...

使用任何现有的图像处理配方,并在片段着色器中替换以下代码。这个片段着色器从 OpenGL ES 程序接受一些输入。图像纹理存储在Tex1中;必须提供触摸点作为中心变量,它将被视为圆的中心。我们还需要horizontalAspectRatioverticalAspectRatio纵横比,以便在不同屏幕分辨率下,圆形保持圆形,不会变成任何椭圆形。最后,我们需要内半径和外半径(LensInnerRadiusLensOuterRadius)来定义圆形区域的宽度。颜色(BorderColor)将用于遮罩绘制:

#version 300 es
precision mediump float;
in vec2 TexCoord; 
uniform sampler2D Tex1;
uniform vec2 center;
uniform float horizontalAspectRatio, verticalAspectRatio;
uniform float LensInnerRadius,LensOuterRadius;
uniform vec4 BorderColor;

layout(location = 0) out vec4 outColor;
void main() {
outColor = texture(Tex1, TexCoord);
   float dx = TexCoord.x-center.x; 
float dy = TexCoord.y-center.y;

dx *= horizontalAspectRatio; 
dy *= verticalAspectRatio;
   float distance = sqrt(dx * dx + dy * dy);
   outColor = mix( outColor, BorderColor,
       smoothstep(LensInnerRadius, LensOuterRadius, distance));
  return;
}

工作原理...

将传入的纹理坐标减去中心位置,并转换为新的逻辑坐标,其中变换后的纹理坐标或位置向量(dx,dy)以中心点(center)为参考存储。此坐标必须在水平和垂直方向上乘以aspectRatio,以消除由于水平和垂直设备屏幕分辨率差异引起的任何形状扭曲。

每个位置向量的距离使用向量长度公式 P (x, y) = √(x² + y²) 计算,并输入到平滑步 GLSL API 中。平滑步 API 接受三个参数(edge1edge2x)。前两个参数是两个外值,第三个是权重。参考以下左侧图像来理解其功能。此 API 根据提供的权重返回两个边缘之间的插值值。平滑步的输出用作权重,输入到另一个名为 mix 的 GLSL API 中。mix API 使用平滑步函数提供的加权值混合边框颜色与当前纹理:

工作原理...

参考内容

  • 请参考第七章中关于使用 UV 映射应用纹理的配方,纹理和映射技术

旋转图像

旋转是一种在动画中非常常见的效果。当应用于渲染的场景或图像时,它扭曲了圆形区域内的外观,并在这些纹理元素围绕圆形区域中心移动时产生径向圆形运动,从而产生漩涡状效果。

在程序上,对于给定的图像,选择一个任意的纹理元素作为中心。从圆心到固定距离定义了圆的轨迹。所有落在该轨迹下的纹理元素都应用于旋转。圆内的纹理元素旋转随着与中心的距离增加而减小,并在圆周边缘消失。以下图像显示了旋转效果的外观:

旋转图像

如何实现...

在片段着色器中使用以下代码实现旋转效果:

in vec2 TexCoord;
uniform sampler2D Tex1;
uniform float ScreenCoordX,twirlRadius,angle,imageHeight, imageWidth;
uniform vec2 center;
float radiusFactor = 3.0;
layout(location = 0) out vec4 outColor;
// Note: the angle is assumed to be in radians to 
// work with trigonometric functions.  
vec4 Twirl(sampler2D tex, vec2 uv, float angle){
    // Get the current texture size of the image
    vec2 texSize = vec2(imageWidth, imageHeight);

    // Change the texCoordinate w.r.t. to the image dimensions
    vec2 tc = (uv * texSize) - center;

    // Calculate the distance of the current transformed
 // texture coordinate from the center.
    float distance = sqrt(tc.x*tc.x + tc.y*tc.y);
    if (distance < twirlRadius+angle*radiusFactor){
        float percent   = (twirlRadius - distance)/twirlRadius;
        float theta     = percent * percent * angle;
        float sinus     = sin(theta);
        float cosine    = cos(theta);
        tc = vec2(dot(tc, vec2(cosine, -sinus)), dot(tc,
 vec2(sinus, cosine)));
    }
 return texture(tex, (tc+center) / texSize);
}

void main() {
if(gl_FragCoord.x > ScreenCoordX)
outColor = Twirl(Tex1, TexCoord, angle); 
else
outColor = texture(Tex1, TexCoord);
}

它是如何工作的...

旋转效果需要一个中心点,围绕该中心点产生漩涡效果,这个中心点由 OpenGL ES 程序中的中心变量提供。此外,我们还需要图像的大小(imageHeightimageWidth),它用于控制图像边界内的动画区域。

每个传入的纹理坐标通过乘以图像大小转换为相应的纹理元素位置,然后相对于中心进行平移。平移后的坐标代表位置向量,用于计算从中心点的距离。如果距离在给定的半径阈值内,则纹理元素将以在度数中指定的任意角度围绕中心旋转。旋转角度随着中心与平移坐标之间的距离减小而增加。

参见

  • 使用程序纹理实现双目视图

纹理四边形球体幻觉

本食谱将演示一种性能高效的技巧,该技巧利用程序纹理来产生真实 3D 对象的幻觉。在 Gouraud 着色中,片段根据光源的方向和几何形状进行光照着色。例如,在第五章中,我们实现了球形模型上的漫反射光,该模型包含非常多的顶点。本食谱技术渲染相同的漫反射球体,但只使用四个顶点。它以这种方式伪造光照着色,使得两者之间的差异难以区分。

性能直接与它渲染到屏幕上的片段数量成正比。例如,单个全屏渲染球体覆盖的表面积相当于在屏幕上覆盖相同表面积的几个小球。

如何实现...

使用以下步骤来实现具有纹理的四边形球体:

  1. 创建一个名为 TextureQuadSphere 的新类,该类从 Model 类派生。

  2. 声明四边形所需的必要顶点信息,这将渲染成球体:

    float vertexColors[12] = { 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0 };
    float texCoords[8]   = { 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 1.f, 1.f };
    float quad[8]       = { -1.f,-1.f,1.f,-1.f,-1.f, 1.f, 1.f,1.f};
    
  3. 添加以下TexQuadSphereVertex.glsl顶点着色器:

    #version 300 es
    uniform mat4 ModelViewProjectMatrix;
    layout(location = 0) in vec3  VertexPosition;
    layout(location = 1) in vec2  VertexTexCoord;
    layout(location = 2) in vec4  VertexColor;
    out vec4 TriangleColor; out vec2 TexCoord;
    
    void main() {
     gl_Position = ModelViewProjectMatrix*vec4(VertexPosition,1.0);
     TriangleColor = VertexColor;
     TexCoord = VertexTexCoord;
    }
    
  4. TexQuadSphereFragment.glsl中不需要进行任何更改:

    #version 300 es
    precision mediump float;
    in vec4 TriangleColor; 
    in vec2 TexCoord;
    uniform float ScreenWidth; 
    uniform float ScreenHeight;
    uniform float ScreenCoordX; 
    uniform float ScreenCoordY;
    out vec4 FragColor;
    vec3 lightDir = normalize(vec3(0.5, 0.5, 1.0));
    
    void main() {
    vec2 resolution = vec2(ScreenWidth, ScreenHeight);
       vec2 center     = vec2(resolution.x/2.0, resolution.y/2.0);
        lightDir = normalize(vec3((ScreenCoordX - center.x)
    /(ScreenWidth*0.5), (ScreenCoordY - center.y)
    /(ScreenHeight*0.5), 1.0));
    
        float radius   = 0.5; // Calculate the sphere radius
     vec2 position  = TexCoord.xy - vec2(0.5, 0.5);
        float z       = sqrt(radius*radius – 
    position.x*position.x - position.y*position.y);
        vec3 normal=normalize(vec3(position.x,position.y,abs(z)));
        if (length(position) > radius) { // Outside
            FragColor = vec4(vec3(0.0,0.0,0.0), 0.0);
        } else { // Inside
            float diffuse = max(0.0, dot(normal, lightDir));
            FragColor = vec4(vec3(diffuse), 1.0);
        }
    }
    

它是如何工作的...

这种技术使用一个具有四个纹理坐标的方形几何体,每个顶点共享纹理坐标。纹理坐标通过TexCoord变量在顶点着色器和片段着色器之间共享。纹理坐标的范围从 0.0 到 1.0。这些坐标减去半维度来计算相对于圆心的位置向量(position)。圆的半径和从圆心到任意位置向量的任意位置被用来计算给定位置的高度。

这个高度与位置坐标一起用来产生一个法向量;这个法向量提供了它与入射光线的角度。这个角度的余弦值用于颜色强度,以产生光在逻辑半球上的漫反射效果。入射光线使用屏幕分辨率和戳坐标xy位置实时计算。

以下图显示了之前描述的工作逻辑的图示。P (x, y, 0.0)代表位置向量(position),C 是中心,Q 是半球上的一个点,它将使用CQ = CP + PQ来计算,如图所示:

它是如何工作的...

参见

  • 请参考第五章中关于实现每个顶点的漫反射光分量的配方,光和材料

第十章. 使用场景图进行场景管理

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

  • 使用场景图实现第一个场景

  • 添加局部和相对变换

  • 在场景图中添加父子支持

  • 使用变换图创建复杂模型

  • 使用光线追踪技术实现拾取

  • 实现二维纹理按钮小部件

  • 使用相机系统导航场景

  • 实现具有多个视图的场景

简介

在我们之前的所有章节中,我们都是以模型为中心的方式编写各种食谱,其中有一个引擎管理器(渲染器)负责为模型执行所有必要的渲染活动。这种方法对于学习目的来说很棒,但在实际应用中,我们需要可扩展性和可管理性,以便轻松处理多个复杂场景。本章将介绍场景图范式,它允许您有效地编程和管理复杂场景。

基于场景图的架构:我们在现有食谱中使用的当前设计包含一个渲染引擎,它与其他辅助类协同工作以渲染程序模型。这种简单的架构非常适合快速原型设计。这一点已经在之前章节的所有食谱中得到了证明。

现代三维图形应用不仅限于在三维空间中渲染少量对象块,真正的挑战是产生一个顶级的图形引擎,以满足所有现代图形要求。这包括优化渲染涉及节点层次结构的复杂场景,包括迷人的着色效果、状态、语义逻辑、细节级别、事件处理、地理空间服务等等。为了满足这些要求,现代三维图形应用使用基于场景图的架构。场景图架构封装了完整三维场景的层次结构,主要包含两个方面:语义和渲染。语义方面就像一个数据库,它管理视觉表示和状态管理。想象一下,它就像一个视觉数据库,告诉图形系统即将出现的场景和未使用的场景,以便它可以与其资源一起释放,以实现更好的优化和内存管理。另一方面,渲染方面处理可绘制实体或模型的生命周期管理,包括初始化、反初始化、处理、控制管理和在屏幕上显示它们。

场景图是一个庞大且不断发展的主题。涵盖其所有(要求)方面超出了本标题的范围。在本章中,我们将创建一个小的架构,允许你管理多个场景;每个场景可以包含多个灯光、相机和模型。可以使用父子关系以及局部和相对变换来创建复杂模型。模型可以动态地应用于预定义的材料,所有这些操作都将在外部图形引擎的单独 C++文件中完成。这将保持场景图层次逻辑在单一位置,以便易于管理。

与现有设计的区别:本章利用我们现有的渲染引擎知识来产生基于场景图的架构。现有设计主要包含渲染器和模型类。前者负责管理模型、创建单个视图和处理事件。另一方面,后者包含灯光、材料,执行事件处理过程,并渲染 3D 对象。

对于实时 3D 应用程序,我们需要扩展我们的设计以满足场景图架构的要求:

  • 层次关系:系统的各个模块可以以层次化的方式排列。例如,Application模块内部包含Renderer模块,并且应用程序以单例模式运行。然而,它可以产生许多线程以运行每个渲染器实例。每个Renderer实例包含一个Scene模块,该模块包含ModelCamera。场景模块可以从不同的相机创建不同的视图,以可视化屏幕上模型的渲染。

  • 父子关系对象:类似类型的对象必须支持父子关系。在父子关系中,父对象自动管理所有子对象。这样,语义和渲染可以以优化的方式进行管理。

  • 变换图:系统中的每个可渲染对象都存储了相对于其父对象的变换。为了理解这一点,让我们以一个简单的 3D 模型汽车为例,该汽车由四个轮胎、四个车门和车身组成。如果我们想在x轴方向上平移这个汽车 2 个单位,那么使用现有设计,我们需要将汽车的九个部分都移动 2 个单位。然而,如果我们将车门和轮胎作为汽车车身的子对象,那么我们就不需要担心移动所有九个部分;只需要移动父部分(车身)就足够了。

  • 多个场景管理:在现有设计中,创建多个场景是不可能的;实际上,所有内容都被绘制为一个单独的场景。

  • 分离语义和渲染:对象的渲染必须与语义松散耦合。渲染输出可能受到许多因素的影响,例如状态变化、用户输入或两者兼有。设计应该足够灵活,以管理状态和事件。

  • 细节级别LOD):LOD 使用对象的计算信息,并揭示它距离摄像机视图或观察者的距离。如果对象位于视锥体之外,则可以在消耗系统关键资源之前忽略它。视锥体视图中的对象,如果远离摄像机,则可以以较低的保真度渲染,可以使用较少的多边形和小纹理。

  • 状态封装:在系统中,每个节点或对象包含一个能够揭示对象本质的状态是很重要的。这样可以通过遍历父子层次结构将几种类似类型的对象组合在一起;这将非常高效地避免随机状态切换,例如,纹理加载和绑定。

本章将带我们通过一种系统的方法来开发场景图:

  • 在场景图中实现第一个场景菜谱 1):本菜谱将构建场景图的基础,其中它将支持场景、模型、灯光和材质模块。建模将在渲染引擎之外的 NativeTemplate.cpp 中完成。

  • 添加局部和相对变换菜谱 2):本菜谱将局部和相对变换概念引入现有的场景图中。局部变换仅适用于可渲染对象内部,而相对变换则从父对象接收并传播到其子对象。

  • 在场景图中添加父子支持菜谱 3):本菜谱将在类似类型的对象之间建立父子关系。

  • 使用变换图创建复杂模型菜谱 4):本菜谱将利用之前的菜谱概念,并演示如何构建复杂的动画模型,例如旋转的风车。

  • 使用光线追踪技术实现拾取菜谱 5):本菜谱将为场景图添加事件支持,并帮助实现基于光线追踪的拾取技术,允许你在场景中选择 3D 对象。

  • 实现 2D 纹理按钮小部件菜谱 6):实现 2D 小部件使用屏幕坐标系。本菜谱包含另一个子菜谱,该子菜谱实现了在按钮小部件上点击。

  • 使用摄像机系统导航场景菜谱 7):本菜谱将实现摄像机对场景的支持。

  • 使用多个视图实现场景菜谱 8):本菜谱使场景图形能够将多个视图渲染到单个场景中。

使用场景图实现第一个场景

让我们从查看现有引擎的块图(左侧)和新的预期场景图设计(右侧)开始。此设计被划分为许多更简单的可重用模块,其中每个模块在图像本身中都是自解释的。Object模块是大多数其他模块的基类。这些模块表现出父子关系。同样,支持事件处理过程的模块必须从Event继承。

使用场景图实现第一个场景

在以下图像中,您可以看到场景图中不同模块之间的层次关系。Renderer是一个包含各种场景的图形引擎。这些场景可以动态地添加到和从渲染引擎中移除。根据需要,场景包含一个或多个相机;它还包含场景需要渲染的模型。

变换在模型-视图-投影类比中管理,其中建模变换在Model模块中执行,而投影和视图变换在Camera模块中计算。正如我们所知,任何可渲染的对象都必须从Model类派生,它表现出父子关系,其中父类完全负责管理其子类的生命周期。系统中的事件按自上而下的方向流动,原生应用程序接收事件并将它们传递给Renderer,然后Renderer进一步将事件传播到场景。场景检测事件属于哪个视图,并将事件发送到所有相应的模型派生类,在那里最终处理:

使用场景图实现第一个场景

准备工作

这个第一个配方将实现介绍部分中先前描述的场景图架构的基本结构。对于这个配方,我们将实现RendererSceneLightMaterial模块。对于Model类,更改非常小。在场景图方法中,由于添加了其他模块,Renderer已经简化。随着我们继续到后续的配方,我们将进一步将复杂性分解为更简单的模块:

准备工作

在下一节中,我们将了解实现第一个场景的逐步过程。这个配方构建了场景图的基类,我们将描述类结构和重要成员函数的定义。

小贴士

在这个配方中,可能无法编写所有函数的定义。我们将建议读者遵循本章示例代码中提供的SG1_withSceneLightMaterial配方来查看完整的源代码。

如何操作...

实现场景图架构的步骤如下:

  1. RendererEx.h 中创建新的 Renderer 类;与旧版本相比,这个新版本有非常少的代码。它管理它包含的所有场景,并负责生命周期,如初始化和渲染:

       class Renderer{
       std::vector <Scene*> scenes; // Scene List   
    
    public:
    void initializeScenes();     // Initialize Engine
    void resize( int w, int h );// resize screen
    void render();             // Render the Scenes
    void addScene(Scene* scene);// Add new scene
       bool removeScene( Scene* scene); // Remove the scene
    };
    
  2. 定义 RendererEx.cpp 的成员函数,如下面的代码所示:

    // When renderer initializes it initiates each Scene
    void Renderer::initializeScenes(){ 
         for( int i=0; i<scenes.size();  i++ )
                scenes.at(i)->initializeScene();
    }
    
    // Resize all the scenes to adapt new window size
    void Renderer::resize( int w, int h ){
      for( int i=0; i<scenes.size();  i++ )
        scenes.at(i)->resize(w, h); 
    }
    
    // Add a new Scene into the rendering engine
    void Renderer::addScene( Scene* scene){
      if(!scene) return;
    
      for( int i=0; i<scenes.size();  i++ ){
          if(scenes.at(i) == scene ){
              return; // If already added return;
          }
      }
    
      scenes.push_back( scene );
      scene->setRenderer(this);
    }
    
    // No longer need a scene, then remove it
    bool Renderer::removeScene(Scene* scene){
      for( int i=0; i<scenes.size();  i++ ){
    if(scenes.at(i) == scene){
    scenes.erase(scenes.begin()+i); 
    return true; 
    }
      }
      return false;
    }
    
    // Render Each Scene
    void Renderer::render(){ 
        glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
    
        for( int i=0; i<scenes.size();  i++ )
            scenes.at(i)->render();
    }
    
  3. Light.h/.cpp 中创建 Light 类并实现它,如下面的代码所示:

    class Light {
      private:
        int lightID;
      public:
        Material material;
        glm::vec4 position;
        GLfloat constantAttenuation, linearAttenuation,
                quadraticAttenuation;
        Light() {}
        Light(Material mt, glm::vec4 p, GLfloat ca = 1.0,
                   GLfloat la = 0.2, GLfloat qa = 0.05) {
            material                = mt;
            position               = p;
            constantAttenuation    = ca;
            linearAttenuation      = la;
            quadraticAttenuation   = qa;
            enabled                = false;
        }
    };
    
  4. 同样,创建 Material.h/.cpp 并实现 Material 类,如下所示:

    class Material{
    public:
       glm::vec4 ambient, diffuse, specular;
       GLfloat shines;
       std::string name;
    MaterialType typeOfMaterial;
       Material(glm::vec4  ambient, glm::vec4 diffuse,
     glm::vec4 specular, GLfloat shiness);
       Material(const Material & p);
    Material & operator = (const Material & p);
    Material(MaterialType type = MaterialNone);
    };
    
  5. 定义一些常见的材料类型。有关更多信息,请参阅本菜谱的示例代码:

    typedef enum {
        MaterialNone,
        MaterialGold,
        MaterialCopper,
    } MaterialType;
    
    // Copper Material
    const vec4 CopperAmbient(0.19f, 0.07f, 0.022f, 1.0f);
    const vec4 CopperDiffuse(0.70f, 0.27f, 0.082f, 1.0f);
    const vec4 CopperSpecular(0.2f, 0.13f, 0.086f, 1.0f);
    const GLfloat   CopperShiness = 2.8f;
    
    // Gold Material
    const vec4 GoldAmbient(0.24f, 0.19f, 0.07f, 1.0f);
    const vec4 GoldDiffuse(0.75f, 0.60f, 0.22f, 1.0f);
    const vec4 GoldSpecular(0.62f,0.55f, 0.36f, 1.0f);
    const GLfloat   GoldShiness=51.2f;
    

    注意

    所有浮点数据类型(GLfloatfloat)变量都应明确声明,并在末尾带有额外的 f 标志。否则,在赋值时,变量将被视为双精度类型,并转换为浮点类型,这将极大地降低性能。

  6. Scene.h 中创建一个场景类。它管理其内部包含的模型。目前,它内部不包含任何相机。我们将在本章后面添加相机。场景为模型提供许多服务,例如管理着色器程序、转换服务、模型的渲染等。每个场景都可以通过一个独特的名称来识别。在渲染每个模型时,场景在 currentModel 中维护当前渲染模型的引用:

    class Scene{
    public:
    Scene(string name="",Renderer* parentObj = NULL);  virtual ~Scene(void);       // Destructor
       void initializeScene();      // Initialize Scene
       inline ProgramManager* SceneProgramManager(){
     return &ProgramManagerObj; }
        inline Transform*  SceneTransform() {
     return &TransformObj;  }
        void render();              // Render the Models
        void initializeModels();    // Initialize Models
        void clearModels();         // Remove models
        void addModel( Model* );   // Add into model list
        void addLight( Light* );   // Add lights
        Renderer* getRenderer();    // Get scene's renderer
        void setUpProjection();      // Set projection
        std::vector<Light*>& getLights(){ return lights; }
    
    private:
        ProgramManager   ProgramManagerObj;
        Transform      TransformObj;
        vector<Model*> models; // Model's List
        vector<Light*> lights; // Light's List
        Renderer* renderManager;  // Scene's Renderer
        Model* currentModel;   // Current Model in use
    };
    
  7. 场景包含多个光源和模型;这些模型和光源是通过在 Scene.cpp 中定义的 addModeladdLight 函数添加到场景中的:

    void Scene::addModel(Model* model){
        if(!model) { return; }
    models.push_back( model );
    model->setSceneHandler(this);
    }
    
    void Scene::addLight( Light* lightObj){
        for(int i =0; i<lights.size(); i++){
            if(lights.at(i) == lightObj) return;
        }
        lights.push_back(lightObj);
    }
    
  8. ModelEx.h 中创建 Model 类。这个 Model 类的新版本包含了材料和父场景对象:

    class Model {
    public:
       Model(Scene* SceneHandler, Model* model,
            ModelType type, string objectName="");  
    
       // Define setter and getter function for Scene
       // and material class. 
    
    // Reuse the older Model class existing methods
    
    protected:
       Scene*  SceneHandler; 
    Material materialObj;
    };
    
  9. 由于 ObjLoader 类也是 Model 类的派生类,它也必须包含它将执行的场景的引用。修改 ObjLoader 构造函数以保留场景引用,并创建两个新函数 (ApplyLightApplyMaterial) 来应用光照和材料信息:

    class ObjLoader : public Model{
    public:
        // Constructor for ObjLoader
        ObjLoader( Scene* parent, Model* model, MeshType
     mesh, ModelType type);
        void ApplyLight();   // Apply scenes light
        void ApplyMaterial();// Object's material
    
        // Rest of the function are same, for more info please
        // refer to SG1_withSceneLightMaterial recipe.
    };
    
  10. 在将网格对象渲染到 ObjLoader::render 方法之前,必须应用新的光照和材料应用方法,如下面的代码所示:

    void ObjLoader::Render(){
        glUseProgram(program->ProgramID);
        ApplyMaterial();
        ApplyLight();
    
        // Apply Transformation.
     // Bind with Vertex Array Object for OBJ
    
        // Draw Geometry
        glDrawArrays(GL_TRIANGLES, 0, IndexCount );
        glBindVertexArray(0);
    }
    
  11. NativeTemplate.cpp 中,在 GraphicsInit 函数中创建一个场景,并向其中添加一个光源和网格对象。通过将这些对象添加到引擎中来执行场景:

    Renderer* engine   = NULL; 
    ObjLoader* Suzzane = NULL; 
    Scene* scene1      = NULL;
    
    bool GraphicsInit(){
      // Create a new Renderer instance 
       engine = new Renderer(); 
    
    // Add a new scene named "Mesh Scene" to engine
       scene1 = new Scene("MeshScene", engine);
    
       // Create a new light and set into the scene
       scene1->addLight(new Light(Material(MaterialWhite)
     ,glm::vec4(0.0, 0.0, 10.0, 1.0)));
    
       // Create Suzzane,added into the scene1.
       Suzzane = new ObjLoader(scene1,NULL,SUZZANE,None);
    Suzzane->SetMaterial(Material(MaterialCopper));
    
       // Add Suzzane into Scene 
    scene1->addModel( Suzzane); 
    
    // Initialize engine 
    engine->initializeScenes(); 
    }
    
  12. 同样,GraphicsRender 函数渲染网格模型并更新场景和相关模块。在本菜谱中,它每秒对网格模型应用各种预定义的材料类型:

    bool GraphicsRender(){
        static int i=0;   static clock_t start = clock();
     // Switch material each second
        if(clock()-start > CLOCKS_PER_SEC){
            start = clock(); 
            (i %=6)++; //Plus one to avoid None type
    
           // Assign a new material   
            Suzzane->SetMaterial(Material(MaterialType(i)));
        }
       engine->render();
    }
    

它是如何工作的...

与早期重载版本相比,场景图模型中的Renderer类高度简化;ScenesRenderer类的容器。场景必须动态创建并添加到渲染引擎中。同样,它可以从引擎中移除,这允许您节省重要的内存资源和 CPU 周期。每个场景都有一个唯一名称,可以用于从引擎中检索场景;场景与灯光和模型具有包含关系。每个场景可以有多个灯光。然而,当前实现仅支持单个灯光;模型从它们各自的场景中检索灯光信息。模型类的实现没有太大变化,除了从现在开始,材质可以使用场景的灯光信息在运行时应用。场景图允许在没有任何开销的情况下从一个场景共享模型到另一个场景,从而使其非常灵活。

注意

为了区分旧版图形引擎中基于场景图架构的RendererModel类,新类文件名后缀为ExRendererEx.h/.cppModelEx.h/.cpp)。

场景图架构允许您在引擎外部创建场景建模和控制逻辑;这是一种更通用和预期的编程方式。此配方使用NativeTemplate.cpp作为建模和渲染的外部文件。在此文件中,初始化场景在GraphicsInit()中完成。首先,创建graphicsEngine渲染引擎对象。此引擎设置为名为scene1Scene对象,Scene的参数化构造函数包含其名称和它所在的渲染引擎的父对象。场景包含一个位于z方向 10 个单位处的白色光源。

Model对象,即Suzzane,是使用ObjLoader的参数化构造函数创建的,并应用于预定义的铜色材质类型。

场景在GraphicsRender()中控制。在此函数中,各种类型的材质在每秒正常间隔后运行时应用,如下所示:

工作原理...

参见

  • 请参阅第二章中的使用 GLPI 框架构建原型配方,OpenGL ES 3.0 基础

添加局部和相对变换

变换可以分为两种类型:

  • 局部变换:此类变换仅适用于对象本身;它不会影响其子对象。例如,如果两个对象之间存在父子关系,那么应用局部缩放变换将不会缩放子对象。

  • 相对变换:此类变换相对于对象的父对象应用。在这里,父对象的变换被传播到子对象,从而影响 3D 空间中的几何顶点位置。例如,在本例中,对父对象的缩放变换将缩放所有子对象及其子对象。

    注意

    如果一个对象没有父对象(称为root对象),则 OpenGL ES 坐标系将被视为其父对象。下一个配方将讨论更多关于父子关系的内容。

此配方将创建两个网格对象(TorusSuzzane)并产生类似于月亮(Suzzane)围绕地球(Torus)公转的效果。月亮不仅围绕地球公转,同时也在围绕自己的轴旋转。

注意

关于 3D 变换内部机制的更多信息,您可以参考第二章中“使用模型、视图和投影类比实现场景”的配方,OpenGL ES 3.0 基础。本主题涵盖了各种类型的变换、变换矩阵约定、齐次坐标以及变换操作,如平移、缩放和旋转。

准备工作

此配方需要第一个配方作为先决条件;建议您理解第一个配方的实现。您可以在本章提供的示例代码中找到当前配方(SG2_withSG1+Transformation)的源代码。

如何操作…

下面是实现现有场景图架构中局部和相对变换的步骤:

  1. ModelEx.h中,向Model类中添加以下成员变量。这些变量负责存储局部和相对变换矩阵。它还有一个变换的原点中心:

         mat4 transformation; mat4 transformationLocal; vec3 center;
    
  2. 前往ModelEx.cpp并实现局部变换函数和相对变换函数:

       // Many line skipped, refer to source for CTOR/DTOR
       void Model::Rotate(float angle,float x,float y,float z){
      transformation = translate( transformation, center);
      transformation=rotate(transformation,angle,vec3(x,y,z));
      transformation = translate( transformation, -center);
    }
    
    void Model::Translate(float x, float y, float z ){
     transformation = translate(transformation,vec3(x,y,z));
    }
    
    void Model::Scale(float x, float y, float z ){    
     transformation = scale(transformation,vec3(x,y,z)); }
    
    void Model::RotateLocal(float ang,float x,float y,float z){
    transformationLocal = rotate(transformationLocal, ang,
                               vec3( x, y, z ) ); }
    
    void Model::TranslateLocal(float x, float y, float z ){
        transformationLocal = translate
    (transformationLocal, vec3( x, y, z ));
    }
    void Model::ScaleLocal(float x, float y, float z ){
              transformationLocal=scale(transformationLocal,vec3(x,y,z));
    }
    void Model::SetCenter(vec3 cntrPoint){center=cntrPoint;}
    vec3 Model::GetCenter(){ return center; }
    
  3. 此步骤非常重要;它提供了在Model函数派生类中应用局部和相对变换的拇指规则。更多信息,请参阅ObjLoader.cppRender()函数的实现,并将以下成员函数添加到相应的变量中:

    ObjLoader::Render(){
      // USE PROGRAM, APPLY MATERIAL AND LIGHT
         // APPLY RELATIVE TRANSFORMATION
          TransformObj->TransformPushMatrix();
          *TransformObj->TransformGetModelMatrix() =
    *TransformObj->TransformGetModelMatrix()
    *transformation;
    
          // APPLY LOCAL TRANSFORMATION
          TransformObj->TransformPushMatrix();
          *TransformObj->TransformGetModelMatrix() =
    *TransformObj->TransformGetModelMatrix()
    *transformationLocal;
                // RENDER GEOMETRY, REUSE CODE
                // POP LOCAL TRANSFORMATION
          TransformObj->TransformPopMatrix(); // Local Level
    
          Model::Render();
       // POP RELATIVE TRANSFORMATION
          TransformObj->TransformPopMatrix();
       }
    
  4. NativeTemplate.cpp中,编辑GraphicsInit()函数,如下所示:

    // GLOBAL VARIABLES
    //  Renderer* graphicsEngine;  ObjLoader* Suzzane;
    //  ObjLoader* Torus; Scene* scene1;
    
      graphicsEngine = new Renderer();
      scene1         = new Scene("MeshScene", graphicsEngine);
      Suzzane        = new ObjLoader(scene1, NULL, SUZZANE, None);
      Torus          = new ObjLoader(scene1, NULL, TORUS, None);
    
    // Set Light and Material
      scene1->addLight(new Light(Material(MaterialWhite),
      glm::vec4(0.0, 0.0, 10.0, 1.0)));
      Suzzane->SetMaterial(Material(MaterialCopper));
      Torus->SetMaterial(Material(MaterialGold));
      Torus->Scale(0.40, 0.40, 0.4);
    
      scene1->addModel( Suzzane ); //Add Suzzane to scene
      scene1->addModel( Torus );   //Add Torus to scene
    
    // Set position in the 3D space.
      Suzzane->SetCenter(glm::vec3 (-3.0, 0.0, 0.0));
      Suzzane->Translate(3.0, 0.0, 0.0);
    
      graphicsEngine->initializeScenes(); //Init Scene
    
  5. 使用相同的文件并编辑GraphicsRender()函数,以在Suzzane上应用相对和局部变换,如下所示:

    bool GraphicsRender() {
        Suzzane->Rotate(1.0, 0.0, 1.0, 0.0);     // Relative
        Suzzane->RotateLocal(6.0, 0.0, 1.0, 0.0);// Local
        graphicsEngine->render();    return true;
    

它是如何工作的…

每个Model对象的变换存储在本地变换和变换 Local 变量中。这些变量存储平移、旋转和缩放信息。前一个变量累积应用于父及其祖先的所有变换;每个父对象将其变换信息传播给其子对象。后一个变量仅存储应用于当前对象的本地变换信息;它永远不会将此变换传递给其子对象。区分相对变换和本地变换的机制需要由开发者在Render函数中实现的Model派生类中实现(参见前一小节中的第三步):

如何工作…

在当前配方中,Torus 将作为父 OpenGL ES 坐标系统中心点的参考。更具体地说,Torus 将渲染到 OpenGL ES 原点,即(0.0, 0.0, 0.0)。模型也被缩小了,看起来像是一个中心点。Suzzane执行两种类型的旋转以演示相对和本地变换。在前一种变换中,Suzzane将被放置在原点 3 个单位之外,并且设置中心为(0.0, 0.0, -3.0),以便它可以围绕新的原点(中心)旋转。然而,在后一种变换中,Suzzane围绕其自身轴旋转。在GraphicsRender函数中,Suzzane在每个帧上本地和相对旋转一度,如图所示:

如何工作…

参见:

  • 在场景图中添加父子关系支持

在场景图中添加父子关系支持

这个配方是场景图架构中的一个非常重要的里程碑;不用说,场景图全部关于层次连接性。在当前的概念中,我们维护相似类型对象之间的父子关系。这个配方包含两个子配方:

  1. 在可渲染对象之间构建简单的父子关系

  2. 理解虚拟父的概念

    注意

    父子关系适用于所有可渲染对象(从Model类派生)和逻辑引擎实体,如场景、渲染器等。在当前的场景图架构中,这种关系是通过Object类实现的。这个类允许你动态地添加/删除子对象;每个对象都可以通过用户定义的名称来识别。

如何实现...

利用本章中我们实现的最后一个配方以及以下步骤来添加父子关系的支持:

  1. 创建 Object.h 并编辑以下代码。这个类的每个对象都有一个名称、一个父对象,以及存储在子列表中的一个或多个子对象。父对象的信息在构造函数中设置。这个类提供了高级功能来检索父或子信息,这些信息可以在运行时添加或删除。每个名称的功能都是不言自明的,用来描述它执行的工作类型:

    class Object{
    public:
        Object(string name="", Object* parentObj=NULL);
        virtual ~Object(){}
        void SetName(string mdlName){ name = mdlName;}
        string GetName() { return name; }
    
        void SetChild(Object* child = 0);
        void RemoveFromParentChildList();
        Object*  GetParent() { return parent; }
        vector<Object*>* GetChildren(){ return &childList; }
    
        void SetVisible(bool flag,bool applyToChildren=false);
        bool GetVisible(){ return isVisible; }
    
    protected:
        string name;          // Model's name
        Object* parent;      // Model's parent
        vector<Object*> childList; // Model's child list
     bool isVisible;      // Is Model Visible
    };
    
  2. 创建 Object.cpp 并定义在头文件中无法内联定义的高级方法。构造函数接受对象的名称和父对象(parentObj);RemoveParent 方法移除对象的父对象,并确保父对象的 childList 中不存在任何子对象:

    Object::Object(std::string objectName, Object* parentObj){
        parent = NULL;          name = objectName;
        SetParent(parentObj);   return;
    }
    
    void Object::RemoveParent()
    { RemoveFromParentChildList(); parent = NULL; }
    
    void Object::SetChild(Object* child){
        for(int i =0; i<childList.size(); i++){ 
    if(child == childList.at(i)) { return; } 
     }
        child->parent = this;
        childList.push_back(child);
    }
    
    void Object::RemoveFromParentChildList(){
       for(int i=0; parent&&i<parent->childList.size(); i++){
            if(this == parent->childList.at(i))
                 { parent->childList.erase
    (parent->childList.begin()+i); return; }
       }
    }
    
  3. 实现 setVisible 并根据最后一个参数 applyToChildren(如果适用)传播子对象的可见性:

    void Model::SetVisible(bool flag, bool applyToChildren){
        isVisible = flag;
        if(applyToChildren){
          for(int i =0; i<childList.size(); i++)
            dynamic_cast<Model*>(childList.at(i))->
    SetVisible( flag, applyToChildren );}
    }
    
  4. Object 类派生出 RendererSceneModel 类。

  5. 在派生的 Model 类版本中,处理对象的可见性,如下代码所示。更多信息,请参考 ObjLoader::Render

    ObjLoader::Render(){
      // REUSE CODE, APPLY RELATIVE TRANSFORMATION
    if(isVisible){
          // APPLY LOCAL TRANSFORMATION
    // RENDER GEOMETRY, REUSE CODE   
       // POP LOCAL TRANSFORMATION
    }
       // POP RELATIVE TRANSFORMATION
       }
    
  6. 实现子模型的渲染:

    void Model::Render(){
        for(int i =0; i<childList.size(); i++)
            dynamic_cast<Model*>(childList.at(i))->Render();
    }
    
  7. 使用 NativeTemplate.cpp 并添加实现父子建模:

    Renderer* graphicsEngine; Scene* scene1;
    ObjLoader *Sphere, *BaseSphere, *Cube[2];
    
    bool GraphicsInit(){
        graphicsEngine = new Renderer();
        scene1 = new Scene("MeshScene", graphicsEngine);
        scene1->addLight(new Light(Material(MaterialWhite)
    ,vec4(0.0,0.0,10.0,1.0)));
        BaseSphere =  new ObjLoader   (scene1,NULL,SPHERE,None);
        BaseSphere->SetMaterial(Material(MaterialGold));
        BaseSphere->ScaleLocal(1.5,1.5,1.5);
        int j = 0;
        for(int i=-1; i<2; i+=2){
          Cube[j] = new ObjLoader(scene1,BaseSphere,CUBE,None);
          Cube[j]->SetMaterial(Material(MaterialCopper));
          Cube[j]->Translate(10.0*i, 0.0, 0.0);
          for(int i=-1; i<2; i+=2){
            Sphere=new ObjLoader(scene1,Cube[j],SPHERE,None);
            Sphere->SetMaterial(Material(MaterialSilver));
            Sphere->Translate(0.0, -5.0*i, 0.0);
          } j++;
        }
        scene1->addModel( BaseSphere);
        graphicsEngine->initializeScenes();
    }
    
    bool GraphicsRender(){
        BaseSphere->Rotate(1.0, 0.0, 1.0, 0.0);
        Cube[0]->Rotate(-1.0, 1.0, 0.0, 0.0);
        Cube[1]->Rotate( 1.0, 1.0, 0.0, 0.0);
        graphicsEngine->render();
    }
    

它是如何工作的...

任何可渲染的实体(Model 及其派生类)或不可渲染的实体(RendererScene 及其派生类)都可以从 Object 类派生出来,以实现父子关系。Object 类在父变量中存储父对象信息,在名为 childList 的向量列表中存储子对象信息;任何其他类对象都可以使用 GetParent()GetChildren() 函数访问父和子信息。

注意

每个父对象都负责照顾其子对象的执行生命周期。例如,父场景将自动逐个加载子场景。同样,Model 加载其子对象并管理它们的初始化,加载所需的着色器,将父对象的转换传播到子对象,并渲染每个子模型。

这个配方包含七个模型(五个球体(一个大的,四个小的),两个立方体),如下左图所示。父子关系如下右图所示,其中黄色球体是两个铜色立方体的父对象,每个立方体都连接有两个银色球体。黄色球体绕 y 轴旋转;这使得所有子元素围绕黄色球体旋转,而立方体则绕 y 轴旋转。同时,它们还绕自己的 x 轴旋转,一个顺时针方向,另一个逆时针方向:

它是如何工作的...

注意

参考使用转换图创建复杂模型的配方。这个配方指导你使用父子关系和局部/相对转换创建风车模型。

还有更多...

看看下面的图片,尝试弄清楚我们如何使用现有的父子关系方法来解决这个问题。

问题陈述

一组半圆(由立方体创建)以同心方式排列,其中每个半圆相对于其相邻半圆旋转方向相反:

还有更多...

在当前情况下,我们有八个同心半圆。将最内层的半圆视为其他半圆的父级,解决它(尝试一下)确实需要动脑筋。

有时,存在一些复杂的父子关系问题可以轻松解决。作为对这个问题的解决方案,我们可以创建八个父级,根据需要应用变换,创建两个父级对象(最内层),并根据旋转方向添加子级。最后,将一个父级顺时针移动,另一个逆时针移动。这个配方使用了前面描述的带有虚拟父级的解决方案。

到目前为止,我们已经看到了所有可渲染实体的父级,这些实体也是可渲染的。这就是虚拟父级概念出现的地方。这允许你创建一个没有几何形状的父级。因此,它不能被渲染,并提供了一个逻辑上的父子关系。Render 方法不渲染任何内容,仅用于应用变换。这里的局部变换没有意义,因为对象的几何形状不存在:

class DummyModel : public Model{
public:
   DummyModel(Scene* SceneHandler, Model* model, ModelType type,
           string objectName = "");  // Constructor
   virtual ~DummyModel(){}      // Destructor
      void Render();           // Render the dummy model.
};

DummyModel::DummyModel(Scene*  parentScene, Model* model, ModelType type,std::string objectName):Model(parentScene, model, type, objectName){}          // DummyModel CTOR.

void DummyModel::Render(){
   SceneHandler->SceneTransform()->TransformPushMatrix();
   ApplyModelsParentsTransformation();//Parent Transformation
       Model::Render(); // Base renderer process the childs
   SceneHandler->SceneTransform()->TransformPopMatrix();
}

参见

  • 使用变换图创建复杂模型

使用变换图创建复杂模型

变换图是一棵语义变换的森林,其中每个节点代表一个模型树。结合所有这些树模型产生了一个复杂的 3D 模型结构。左侧的图像显示了变换的语义模型。右侧的图像显示了由语义变换图中的每个节点表示的树结构:

使用变换图创建复杂模型

注意

变换图广泛使用父子关系。没有它,变换图层次结构很难管理。变换图表示节点层次结构,其中每个子节点包含相对于其父节点的变换信息(平移、缩放和旋转)。

这个配方是前两个配方的混合体。它将使用父子关系和局部和相对变换来生成语义变换图。在这个配方中,你将学习如何使用基本的网格模型(如立方体、圆柱体和球体)创建复杂的风车模型。

如何做...

这个配方不需要对场景图引擎进行任何特殊更改。使用 NativeTemplate.cpp 并编辑 GraphicsInitGraphicsRender,如下面的代码所示:

Renderer*    graphicsEngine;   Scene* scene1;
ObjLoader    *Base,   *Stand, *MotorShaft, *CubePlane;
ObjLoader    *Sphere,  *Torus, *Suzzane;

bool GraphicsInit(){
    graphicsEngine = new Renderer();
    scene1 = new Scene("MeshScene", graphicsEngine);
    scene1->addLight(new Light(
            Material(MaterialWhite),vec4(0.0, 0.0, 10.0, 1.0)));

    Base =  new ObjLoader(scene1, Sphere, CUBE);// Base
    Base->SetMaterial(Material(MaterialSilver));
    Base->SetName(std::string("Base"));
    Base->ScaleLocal(1.5, 0.25, 1.5);

    Stand = new ObjLoader(scene1,Base,SEMI_HOLLOW_CYLINDER);// Stand
    Stand->SetMaterial(Material(MaterialSilver));
    Stand->SetName(std::string("Stand"));
    Stand->Translate(0.0, 4.0, 0.0);
    Stand->ScaleLocal(0.5, 4.0, 0.5);

    MotorShaft = new ObjLoader(scene1,Stand,CUBE); // MotorShaft
    MotorShaft->SetMaterial(Material(MaterialSilver));
    MotorShaft->SetName(std::string("MotorShaft"));
    MotorShaft->Translate(0.0, 4.0, 1.0);
    MotorShaft->ScaleLocal(0.5, 0.5, 2.0);

    Sphere = new ObjLoader(scene1,MotorShaft,SPHERE);// MotorEngine
    Sphere->SetMaterial(Material(MaterialGold));
    Sphere->Translate(0.0, 0.0, 2.0);
    Sphere->SetName(std::string("Sphere"));

    for(int i=0; i<360; i+=360/18){ // 20 Fan Blades
        CubePlane =  new ObjLoader   ( scene1, Sphere, CUBE);
        CubePlane->SetMaterial(Material(MaterialCopper));
        CubePlane->SetName(std::string("FanBlade"));
        CubePlane->Translate(0.0, 2.0, 0.0);
        CubePlane->SetCenter(glm::vec3(0.0, -2.0, 0.0));
        CubePlane->ScaleLocal(0.20, 2.0, 0.20);
        CubePlane->Rotate(i, 0.0, 0.0, 1.0);
    }

    scene1->addModel( Base);
    graphicsEngine->initializeScenes(); return true;
}

bool GraphicsRender(){
    Sphere->Rotate(3.0, 0.0, 0.0, 1.0);
    Base->Rotate(1.0, 0.0, 1.0, 0.0);
        graphicsEngine->render(); return true;
}

它是如何工作的...

风车模型由总共 24 个部分组成:一个底座、一个支架、一个电机轴、一个电机引擎和 20 个风扇叶片。底座、电机轴和风扇叶片由立方网格制成,而支架和电机引擎则分别由圆柱和球体网格制成。所有这些部分必须按照正确的父子顺序排列,并且同时应用正确的放置,使用 3D 空间中的局部和相对变换。一图胜千言,通过查看以下图片,你一定能对如何逐部分编织完整模型有所了解。

让我们了解这个风车的工作原理。在GraphicsInit()中,我们首先需要的是风车的底座,它使用一个完美的立方体(A)创建。这个立方体在局部缩放以产生由(B)表示的形状。接下来,支架由一个圆柱体制成,并平移到原点前四个单位(C),然后进行缩放(D),以便在垂直方向上完美扩展以适应底座。这里的底座是支架的父节点。电机轴也由(E)立方体组成,它平移到四个单位(F)。此模型在局部缩放以获得(G)形状。

注意

我们应用的每个变换都是相对于其父节点。因此,在当前情况下,垂直方向上的四个单位是相对于支架,它是MotorShaft的父节点。

创建一个球体以产生一个MotorEngineH),并将其渲染到+Z方向两个单位(I)。最后一部分是创建风扇叶片。每个风扇叶片由一个立方体(J)组成。这个立方体需要垂直方向上远离父节点中心四个单位进行渲染(K)。然后将平移叶片应用于局部缩放,以创建类似叶片的形状(L)。同样,这个过程重复 20 次以构建完整的风扇(MN)。

最后,一旦创建了几何形状,风车的父节点(底座)就被添加到场景中。为什么其他模型没有被添加到场景中?在之前的菜谱中,我们提到每个父模型都会照顾其子模型。由于底座被添加到场景中,它就会照顾其子元素。同样的规则适用于所有子元素,它们本身也是其他项目的父节点:

如何工作...

风车风扇叶片围绕每个帧的z轴旋转三个度。这种变换可以通过在所有叶片的父节点球体上应用旋转来简单地实现。同样,为了使整个模型围绕y轴旋转,在GraphicsRender()中在底座上应用一度旋转。

参见

  • 在场景图中添加父子支持

  • 添加局部和相对变换

使用光线追踪技术实现拾取

拾取是通过用户输入在场景中 3D 空间选择对象的过程。这是 3D 图形应用中非常常见的需求,您可能对最终用户点击的对象感兴趣。点击点包含屏幕坐标系中的位置,这是视口的参考。此参考点可用于各种拾取技术来检测点击的对象。在本例中,我们将使用一种非常通用的拾取技术,称为“光线拾取”或“光线追踪拾取”。

在此技术中,使用场景中的点击点模拟一条光线。当光线与对象相交时,假设它被点击。光线可以与给定场景中的多个对象相交;可以选择的对象可以根据从视点的距离进行收集和排序,以成为最近的选中对象。在本例中,我们将实现光线追踪拾取技术,该技术在高精度定位 3D 对象方面非常准确。

以下步骤概述了实现光线追踪拾取的过程:

  1. 检测屏幕上的点击点(Sx, Sy)。

  2. 使用(Sx, Sy)并找到近平面(Nx, Ny)和远平面(Fx, Fy)上的未投影坐标。

  3. 从(Nx, Ny)和(Fx, Fy)未投影坐标创建一条光线。

  4. 考虑网格几何形状中的每个三角形并执行光线与三角形的交点测试。可以使用低多边形网格优化此测试。

如何实现...

实现光线追踪拾取的步骤如下:

  1. 在名为Event.h/.cpp的新文件中创建一个从Event派生的接口类GestureEvent。这将提供触摸屏事件所需的接口。希望利用手势优势的类必须从GestureEvent类派生:

    class Event {
     public:
       Event(){          // Define CTOR };
       virtual ~Event(){ // Define DTOR };
    };
    class GestureEvent : public Event {
      public:
       GestureEvent():Event(){  // Define CTOR }
       virtual ~GestureEvent(){ // Define DTOR }
       virtual void TouchEventDown(float x, float y) = 0;
       virtual void TouchEventMove(float x, float y) = 0;    
       virtual void TouchEventRelease(float x, float y) = 0;
    };
    
  2. RendererSceneModel类需要继承GestureEvent以支持触摸事件。在各自的类中包含Event.h头文件:

    class Renderer: public Object, public GestureEvent
    class Scene    : public Object, public GestureEvent
    class Model    : public Object, public GestureEvent
    
  3. Renderer类中将手势事件传播到所有场景:

    void Renderer::TouchEventDown(float x, float y){
        for( int i=0; i<scenes.size();  i++ )
            { scenes.at(i)->TouchEventDown(x, y); } }
    // Similarly, implement TouchEventMove &
    // TouchEventRelease like TouchEventDown.
    
  4. Scene类中实现手势界面,并将接收到的触摸事件从 renderer 传播到以下代码中包含的所有模型:

    void Scene::TouchEventDown(float x, float y){
        for( int i=0; i<models.size(); i++ ){
          models.at(i)->TouchEventDown(x, y); }
    }
    //Similarly, defineTouchEventMove & TouchEventRelease
    
  5. Model中实现手势界面并将其应用于每个子项:

    void Model::TouchEventDown(float x, float y){
        for(int i =0; i<childList.size(); i++){
          dynamic_cast<Model*>
           (childList.at(i))->TouchEventDown(x, y);}
    }
    //Similarly, define TouchEventMove & TouchEventRelease
    
  6. 创建一个名为Ray.h/.cpp的文件并定义Ray类,如下面的代码所示:

    class Ray{
     public:
       vec3 dest, dir; // Destination and Direction
       Ray(){ dest = vec3(); dir = vec3(); }
       Ray(vec3 de, vec3 di){ dest = de; dir = di; }
       Ray(const Ray & r){ dest=r.dest; dir=r.dir; }
       Ray & operator=(const Ray&r)
    {dest=r.dest; dir=r.dir; return *this; }
    };
    
  7. 在基类Model中创建一个名为IntersectWithRay的函数,并在ObjLoader派生类中实现:

    bool ObjLoader::IntersectWithRay(Ray ray0,vec3& intersect){
        vec4 p0, p1, p2;
        // COMPUTE EACH TRIANGLE AND CHECK INTERSECTION
        for(uint i=0; i<objMeshModel->vertices.size(); i+=3){
         p0=vec4(objMeshModel->vertices.at(i).position,1);
         p1=vec4(objMeshModel->vertices.at(i+1).position,1);
         p2=vec4(objMeshModel->vertices.at(i+2).position,1);
         mat4 mat = *TransformObj->TransformGetModelMatrix();
    
         p0 = mat*GetEyeCoordinatesFromRoot() * p0;
         p1 = mat*GetEyeCoordinatesFromRoot() * p1;
         p2 = mat*GetEyeCoordinatesFromRoot() * p2;
    
         if (intersectLineTriangle(ray0.destination,
         ray0.dir, vec3(p0.x,p0.y,p0.z),
         vec3(p1.x,p1.y,p1.z), vec3(p2.x,p2.y,p2.z),
         intersect))
         { return true; }
        }
        return false;
    }
    
  8. 在基类Model中创建一个名为IntersectWithRay的函数,并在当前场景中的派生版本类(如ObjLoader)中实现:

    void ObjLoader::TouchEventDown( float x, float y ){
       GLint vp[4] = { 0, 0, 0, 0 }; //Store's viewport
       glGetIntegerv( GL_VIEWPORT, vp );
       vec4 viewport(vp[0], vp[1],vp[2], vp[3]);
       vec3 win(x, vp[3]-y, 0.0);
       vec3 nearPoint = glm::unProject(win, *TransformObj->
       TransformGetModelViewMatrix(), *TransformObj-> TransformGetProjectionMatrix(), viewport);
       win.z = 1.0; // On the far plane.
       vec3 farPoint = glm::unProject(win,
    *TransformObj->TransformGetModelViewMatrix(), *TransformObj->TransformGetProjectionMatrix(), viewport);
        Ray ray0(nearPoint, farPoint-nearPoint);
        glm::vec3 intersectionPoint;
        if(IntersectWithRay( ray0, intersectionPoint)){
          printf("Intersect with %s", GetName().c_str());
            isPicked = !isPicked;
         }
    
        Model::TouchEventDown(x,y); //Propagate to children
    }
    

工作原理...

GestureEvent类从 Renderer 中的主应用程序接收屏幕坐标系中的点击事件(Sx, Sy),并通过相应的父场景传递给 Model 类。然后,这些坐标用于计算近平面和远平面上的未投影坐标。在本例中,我们使用了glm::unproject API:

语法

void glm::unproject(vec3 const& win, mat4 const& modelView,
mat4 const& proj, vec4 const& viewport);
变量 描述
win 组件 xy 指定屏幕坐标。值为零和一的 z 分别指定近平面和远平面。
modelView 这指定了视图和模型矩阵的乘积。
proj 这指定了当前场景的投影矩阵。
viewport 这指定了视口区域的当前维度。

反投影操作反转了投影计算的操作,其中使用世界坐标来计算屏幕坐标的顺序如下:

Screen Coordinates => Viewport => Projection => ModelView => World coordinates

如何工作...

在近平面(Nx, Ny)和远平面(Fx, Fy)上的未投影坐标用于从近平面向远平面发射一条射线,如图中所示(B)。当这条射线击中一个 3D 对象时,它被认为是已选中。从数学上讲,这种选择是通过取网格多边形和产生的射线的交点来执行的。为了简化,我们在本配方中使用了线和三角形的交点,如图(A)所示。网格迭代每个三角形,并使用从 Model 继承的 IntersectWithRay 函数与射线相交。此函数必须在派生版本中重写,以便执行交点测试。ObjLoader 类重写了此函数,并使用 glm::intersectLineTriangle API 计算射线-三角形交点。此配方将所有选中的 3D 网格对象用环境红色绘制,这些对象与射线相交。为了找到最近的选中对象,对整个选中项进行排序,并从摄像机视图中选择最近的那个。

参见

  • 实现二维纹理按钮小部件

实现二维纹理按钮小部件

OpenGL ES 不提供内置的 UI 组件,例如按钮、单选框、复选框等。通常,这些组件被称为 2D 小部件,并在屏幕坐标系中的 HUD 中布局,其中 z 分量要么为零,要么根本不使用。此配方将指导我们使用 OpenGL ES 在屏幕坐标系上设计和布局 2D 小部件。

此配方包含两个配方:

  • 第一个配方允许你创建一个简单按钮的几何形状。几何坐标是在局部屏幕坐标系中指定的,这在设计布局时非常有用。

  • 第二个配方在配方的 还有更多... 部分实现,其中我们使用了射线选择技术,使按钮可点击。选择按钮将改变其颜色。

下一个图像显示了第一个配方的输出。它包含六个 32 x 32 维度的按钮。每个按钮都按比例因子二进行缩放,最终尺寸为 64 x 64。最左边的按钮(向上按钮)是所有剩余按钮的父按钮。这意味着应用于父按钮的任何属性都将传播到所有子按钮。

如何操作...

下面是实现此配方的步骤:

  1. 创建Button.h/.cpp并定义从Model类派生的Button类:

    class Button : public Model{
    public:
        Button(Scene* parent,Model* model,ModelType type,vec3*
     vertices,vec2* textureCoordinates,char* texture);
        virtual ~Button();      // Destructor for Button class
        void InitModel();    // Initialize our Button class
        void Render();       // Render the Button class
    private:
     char MVP, TEX; Image* image;  char* textureImage;
        vec3 vertices[4]; vec2 texCoordinates[4];  
    };
    
  2. Button类渲染按钮的几何形状。按钮几何形状有四个顶点,在这些顶点上粘贴了纹理,借助纹理坐标:

    void Button::Render(){
        glBindBuffer(GL_ARRAY_BUFFER, 0);
        glUseProgram(program->ProgramID);
    
        glDisable(GL_CULL_FACE); // Disable culling
        glEnable(GL_BLEND);      // Enable blending
        glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);
    
        glActiveTexture (GL_TEXTURE0);
        glUniform1i(TEX, 0);
        if (image)
      {glBindTexture(GL_TEXTURE_2D,image->getTextureID());}
    
        TransformObj->TransformPushMatrix(); //Parent Level
        ApplyModelsParentsTransformation();
    
        if(isVisible){
            TransformObj->TransformPushMatrix(); // Local Level
            ApplyModelsLocalTransformation();
    
            glEnableVertexAttribArray(VERTEX_POSITION);
            glEnableVertexAttribArray(TEX_COORD);
            glVertexAttribPointer(TEX_COORD, 2, GL_FLOAT,
    GL_FALSE, 0, &texCoordinates[0]);
            glVertexAttribPointer(VERTEX_POSITION, 3, GL_FLOAT,
                    GL_FALSE, 0, &vertices[0]);
            glUniformMatrix4fv( MVP, 1, GL_FALSE,TransformObj->
    TransformGetModelViewProjectionMatrix());
            glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // Draw
            TransformObj->TransformPopMatrix();//Local Level
        }
    
        Model::Render();
        TransformObj->TransformPopMatrix(); //Parent Level
    }
    
  3. 使用正交投影系统设置抬头显示:

    void Scene::setUpProjection(){
      TransformObj.TransformSetMatrixMode(PROJECTION_MATRIX );
      TransformObj.TransformLoadIdentity();
      int viewport_matrix[4];
      glGetIntegerv( GL_VIEWPORT, viewport_matrix );
      TransformObj.TransformOrtho( 0, viewport_matrix[2],
     viewport_matrix[3], 0 , -1, 1);
      TransformObj.TransformSetMatrixMode( VIEW_MATRIX );
      TransformObj.TransformLoadIdentity();
      TransformObj.TransformSetMatrixMode( MODEL_MATRIX );
      TransformObj.TransformLoadIdentity(); return;    
    }
    
  4. NativeTemplate.cpp中,编辑GraphicsInit()函数,如下面的代码所示。此函数在屏幕坐标系上布局按钮。这些按钮接受几何顶点和纹理坐标作为输入,这些是可选参数。如果未提供这些参数,按钮的尺寸将与图像大小相等。纹理坐标具有默认值(0.0,0.0)和(1.0,1.0),分别对应左下角和右上角:

    Renderer* graphicsEngine; 
    Scene* scene2;
    Button* buttonUp, *buttonDown, *buttonLeft,
    Button* buttonRight, *buttonForward, *buttonBackward;
    
    bool GraphicsInit(){
       graphicsEngine = new Renderer();    
    vec2 texCoords[4]={ 
    vec2(0.0, 0.0),
    vec2(1.0,0.0), 
    vec2(0.0, 1.0), 
    vec2(1.0,1.0) 
    };
    
    vec3 vertices[4]={ 
    vec3(0.0,0.0,0.0), 
    vec3(400.0,0.,0.),
                              vec3(0.0,400.0,0.0),
    vec3(400.0,400.0,0.0)
    };
    
        scene2      = new Scene("ButtonScene");    
        buttonUp     = new Button(scene2, NULL, None,
    NULL, texCoords, "dir_up.png");
        buttonUp->SetName(std::string("Direction Up"));
        buttonUp->Translate(50.0, 100, 0.0);
        buttonUp->Scale(2.0, 2.0, 2.0);
    
        // MAKE THE buttonUp AS PARENT OF OTHER BUTTONS
        buttonBackward = new Button(scene2, buttonUp,
    None, NULL, texCoords, "dir_down.png");
        buttonBackward->SetName(string("Direction Backward"));
        buttonBackward->Translate(250.0, 0.0, 0.0);
        buttonBackward->SetCenter(vec3(16, 16, 0));
        buttonBackward->Rotate(-135.0, 0.0, 0.0, 1.0);
        // SIMILARLY DEFINE OTHER BUTTONS. . . . .
     // buttonDown, buttonLeft, buttonRight, buttonForward
    
        scene2->addModel(buttonUp); // ADD TO THE SCENE
        graphicsEngine->addScene(scene2);
        graphicsEngine->initializeScenes(); return true;
    }
    

它是如何工作的...

2D 按钮小部件的几何形状使用四个顶点创建,这些顶点在屏幕坐标系中指定。这个几何形状通过几何形状上的纹理坐标使用指定的图像进行纹理化。OpenGL ES 在笛卡尔坐标系中工作,其中原点存在于逻辑坐标系中视口维度的中心。视口也有相同的坐标系,并在像素坐标系中工作,但在这个情况下,原点位于左下角,如以下图像所示。相比之下,2D 小部件是在设备坐标系中设计的,其中原点被认为是左上角(见以下图像)。

现在,抬头显示是一种机制,我们可以通过使用 OpenGL ES 坐标系统和视口来制定设备坐标系统。为此,我们需要使用setUpProjection函数中的正交视图渲染场景的投影系统,其中原点在右上角移动。

按钮类对象在NativeTemplate.cpp中的GraphicsInit()函数中创建,其中创建了六个按钮,每个按钮上都有不同的图像。每个按钮都被赋予一个独特的名称,以便以后在相机配方中使用。以下图像显示了这些按钮在右侧。为了简化在 2D 抬头显示屏幕空间中放置图标的工作,我们将第一个按钮(向上方向)作为其他按钮的父级。这样,我们都可以通过在父级上应用单个操作来调整所有按钮的大小和位置。最后,使用GraphicsRender()将这些按钮渲染到抬头显示中:

它是如何工作的...

更多...

与我们在上一个配方中实现的选择技术不同,这次我们将实现按钮类中的选择技术,这将帮助我们了解哪个按钮被点击,以便用户可以对其执行适当的操作。请参考下一个配方,以了解这些按钮是如何控制场景中相机移动的。

Button类必须从GestureEvent类派生,并实现虚拟手势函数,例如TouchEventDownTouchEventRelease,以便处理手势事件并将它们传播到子成员对象:

class Button : public Model, public GestureEvent
    {//Multiple code skipped};
void Button::TouchEventDown(float x, float y){
    GLint viewport_matrix[4]   = { 0, 0, 0, 0 };
    glGetIntegerv( GL_VIEWPORT, viewport_matrix );
    glm::vec4 viewport(viewport_matrix[0],viewport_matrix[1],
                       viewport_matrix[2],viewport_matrix[3]);
    glm::vec3 win(x, viewport_matrix[3]-y, 0.0);
    mat4 matMV  = *TransformObj->TransformGetModelMatrix();
    mat4 matMVP = *TransformObj->TransformGetModelMatrix();
    glm::vec3 nearPoint = unProject(win, mat, matMVP, viewport);
    win.z = 1.0;
    glm::vec3 farPoint = unProject(win, matMV,matMVP, viewport);
    Ray ray0(nearPoint, farPoint-nearPoint);

    glm::vec3 intersectionPoint;
    if(IntersectWithRay( ray0, intersectionPoint)){
        printf("Intersect with %s", this->GetName().c_str());
        isPicked = !isPicked; clicked = true; return;
    }
    Model::TouchEventDown(x,y);
}

void Button::TouchEventRelease( float x, float y ){
    clicked = false; isPicked = false;
    Model::TouchEventRelease(x,y);
}

这个类必须重写IntersectWithRay函数。在这个函数中,它执行了包含按钮几何形状的两个三角形的线和三角形交点。以下图像显示了当触摸事件发生时按钮颜色的变化。当触摸释放事件触发时,按钮将恢复到原始颜色:

还有更多...

让我们看看以下代码:

bool Button::IntersectWithRay(Ray ray0, vec3& intersectionPoint){
    // CHECK INTERSECTION WITH FIRST TRIANGLE
    mat4 = *TransformObj->TransformGetModelMatrix();
    p0 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[0], 1.0);
    p1 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[1], 1.0);
    p2 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[2], 1.0);
    if ( intersectLineTriangle(ray0.destination, ray0.direction,
         vec3(p0.x,p0.y,p0.z), vec3(p1.x,p1.y,p1.z),
         vec3(p2.x,p2.y,p2.z), intersectionPoint)){
        return true;
    }

    // CHECK INTERSECTION WITH SECOND TRIANGLE
    p0 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[1], 1.0);;
    p1 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[3], 1.0);;
    p2 = mat * GetEyeCoordinatesFromRoot() * vec4(vertices[2], 1.0);;
    if ( intersectLineTriangle(ray0.destination, ray0.direction,
          vec3(p0.x,p0.y,p0.z), vec3(p1.x,p1.y,p1.z),
          vec3(p2.x,p2.y,p2.z), intersectionPoint)){
       return true;
    }
    return false;
}

参见

  • 请参阅第八章中关于抬头显示渲染文本的食谱第八章,字体渲染

使用摄像头系统导航场景

在 3D 图形中,摄像头允许你在 3D 空间中导航;它可以用于在任意轴上进行旋转和位移。在 OpenGL ES 中,没有摄像头这样的东西。这必须通过编程实现。实现摄像头非常简单。实际上,我们不需要任何特定的 OpenGL ES API 来完成这个任务,这完全关乎矩阵的操作。在本食谱中,我们将模拟一个第一人称摄像头。

如何实现...

重新使用上一个实现食谱,并执行以下步骤以实现摄像头系统:

  1. 创建一个Camera.h/.cpp文件,并定义一个从Object派生的Camera类。这个类包含三个单位向量:LeftUpForward,它们分别存储沿xyz轴在 3D 空间中的方向单位向量。Position指定摄像头的位置,而Target指定摄像头视图的位置:

       struct ViewPort{ int x, y, width, height; };
       struct CameraViewParams{ float left, right, bottom, top,
       front, back; float fov, nearPlane, farPlane; };
    
       class Camera : public Object{
       vec3 Forward, Up, Right, Position, Target;
       CameraType type; // Type of cameras
    
       protected:
       int viewport_matrix[4]; ViewPort viewPortParam;
       CameraViewParams cameraViewParameters;
    
       public:
       Camera(string name, Scene* parent = NULL,
       CameraType camType = perspective);
    
       void Viewport (int x, int y, int width, int height);
       virtual void Render ();
       void Rotate(vec3 orientation, float angle);
       void MoveForwards( GLfloat Distance );
       // Similarly,define MoveBackwards, StrafeRightSide etc.
    
       void SetLeft(float val) {cameraViewParameters.left=val;}
       // Similarly,define SetRight, SetBottom, SetTop Etc.
    
       float GetLeft(){ return cameraViewParameters.left; }
       //Similarly, define GetRight, GetBottom, GetTop etc Etc.
       vec3 PositionCamera(){return Position + Forward;}
    };
    
  2. 定义摄像头的旋转,如下代码所示:

    #define DEGREE_TO_RADIAN   M_PI / 180.0f
    #define RADIAN_TO_DEGREE   180.0f / M_PI
    #define COS(Angle) (float)cos(Angle*DEGREE_TO_RADIAN)
    #define SIN(Angle) (float)sin(Angle*DEGREE_TO_RADIAN)
    
    void Camera::Rotate(vec3 orientation, float angle){
      if(orientation.x == 1.0){ //Rotate along X axis
       Forward=normalize(Forward*COS(angle)+Up*SIN(angle));
       Up     = -cross( Forward, Right ); }
    
      if(orientation.y == 1.0){ //Rotate along Y axis
        Forward=normalize(Forward*COS(angle)-Right*SIN(angle));
        Right  = cross( Forward, Up ); }
    
      if(orientation.z == 1.0){ //Rotate along Z axis
       Left = normalize(Right*COS(angle)+Up*SIN(angle));
       Up   = -cross(Forward, Right); }
    }
    
  3. 使用移动函数使摄像头沿着三个轴移动,如下代码所示:

    void Camera::MoveForwards(GLfloat d){   
      Position += Forward*d;
    }
    
    void Camera::StrafeRightSide(GLfloat d){
      Position += Left*d;
    }
    
    void Camera::StrafeUpside(GLfloat d){
      Position += Up*d;
    }
    
    void Camera::MoveBackwards(GLfloat d){
      MoveForwards( -d );
    }
    
    void Camera::StrafeLeftSide(GLfloat d){
      StrafeRightSide(-d);
    }
    
    void Camera::StrafeDownside(GLfloat d){
      StrafeUpside(-d); 
    }
    
  4. 以下代码设置了Camera::Render()中的投影矩阵:

    void Camera::Render(){
      Scene* scene = dynamic_cast<Scene*>(this->GetParent());
      Transform* TransformObj = scene->SceneTransform();
      glViewport( viewPortParam.x, viewPortParam.y,
      viewPortParam.width, viewPortParam.height );
      TransformObj->TransformSetMatrixMode(PROJECTION_MATRIX);
      TransformObj->TransformLoadIdentity();
    
      if ( type == perspective ){
         // Multiple code line skipped
         // Apply perspective view:TransformPerspective
      }else{
         // Multiple code line skipped
         // Apply Orthographic view: TransformOrtho
      }
    }
    
    TransformObj->TransformSetMatrixMode(VIEW_MATRIX);
    TransformObj->TransformLoadIdentity();
    vec3 viewPoint = Position + Forward;
    TransformObj->TransformLookAt(&Position,&viewPoint,&Up);
    
    TransformObj->TransformSetMatrixMode(MODEL_MATRIX);
    TransformObj->TransformLoadIdentity();
    }
    
  5. 结合我们在之前的食谱中创建的两个场景。第一个场景包含风车。第二个场景包含选择按钮。前一个场景将被渲染到透视摄像头。然而,后一个将使用 HUD 摄像头:

    Camera *camera1, *camera2;
    bool GraphicsInit(){
        graphicsEngine = new Renderer();
        scene1   = new Scene("MeshScene", graphicsEngine);
        camera1 = new Camera("Camera1", NULL);
        scene1->addCamera(camera1);
        // Multiple code lines skipped
        graphicsEngine->initializeScenes();
    
        scene2   = new Scene("ButtonScene");
        camera2 = new CameraHUD("Camera2", scene2);
        // Multiple code line skipped
        graphicsEngine->addScene(scene2);
        graphicsEngine->initializeScenes();}
    
    bool GraphicsResize(int width, int height){
       graphicsEngine->resize(width, height);
       camera1->Viewport(0, 0, width, height);
       camera2->Viewport(0, 0, width, height);}
    

它是如何工作的...

摄像头包含三个方向单位向量:前向(0.0, 0.0, 和 -1.0)、右向(1.0, 0.0, 和 0.0)和上向量(0.0, 1.0, 和 0.0)。第一个向量指向摄像头前进的方向。例如,在本例中,摄像头将沿着负z轴方向移动。同样,右向量指定x轴上的移动方向,上向量指定y轴上的移动方向。上向量也可以理解为头部,它指定摄像头是向上(0.0, 1.0, 和 0.0)还是向下(0.0, -1.0, 和 0.0)观看场景。

使用这些向量,相机可以沿三个轴中的任何一个移动。例如,如果你想将相机向前移动五个单位,那么|5| *向前的乘积将使你的相机位于(0.0,0.0,或-5.0)并朝相同方向看,而将相机向右移动四个单位则将相机放置在(4.0,0.0,或-5.0)。再次强调,相机仍然朝向负z方向。在当前配方中,使用诸如MoveForwardsStrafeRightSideStrafeUpSide等函数来转换相机的当前位置。相机的xyz轴方向可以使用Rotate函数来改变。

注意

在任意轴上由前向、右向和上向单位向量指定的相机位移不会影响相机的方向。方向保持不变,相机将继续朝由前向向量指定的相同方向看。只有当相机沿任意轴旋转时,相机的方向才能受到影响。

如何工作...

前面的图像(部分1)显示了在z轴上执行 45 度旋转时,旋转对前向(OC)、右向(OA)和上向(OB)向量的影响。这导致(图像部分2)新的右向(OE)和新的上向(OF)向量。前向向量在z轴上旋转时没有变化:

如何工作...

更多内容...

当前配方为每个场景包含两个相机。第一个相机渲染透视投影系统。第二个相机以正交投影视图渲染场景到抬头显示。我们在上一个配方中实现的Camera类不能用于满足 HUD 相机的要求。因此,我们需要一个新的Camera派生类,称为CameraHUD,来实现 HUD。

以下代码显示了 HUD 相机的实现。重写了Render函数。此函数查询当前视口尺寸并将其映射到正交投影的左右和上下参数,这样原点从中心移动到左上角,与设备屏幕坐标系相同。有关 HUD 的更多信息,请参阅本配方中的“参考以下内容”子节:

class CameraHUD : public Camera{
public:
    CameraHUD(std::string name, Scene* parent = NULL);
    void Render();
    virtual ~CameraHUD();
};

   // Code skipped, see sample for CTOR and DTOR definition.
   void CameraHUD::Render(){ // Render HUD VIEW
    Scene* scene = dynamic_cast<Scene*>(this->GetParent());
    glViewport( viewPortParam.x, viewPortParam.y,
           viewPortParam.width, viewPortParam.height );

    Transform*  TransformObj = scene->SceneTransform();
    TransformObj->TransformSetMatrixMode(PROJECTION_MATRIX);
    TransformObj->TransformLoadIdentity();

    glGetIntegerv( GL_VIEWPORT, viewport_matrix );
    TransformObj->TransformOrtho( viewport_matrix[0],
       viewport_matrix[2], viewport_matrix[3],
        viewport_matrix[1] , -1, 1);
    // Code skipped, Load Model/View Matrix with Identity matrix.

}

参考以下内容

  • 请参阅第八章中的“抬头显示渲染文本”配方,字体渲染

实现具有多个视图的场景

实时 3D 应用中最常见的需求之一是同时将场景渲染到多个视图窗口中。例如,基于 CAD/CAM 的应用程序将场景渲染到四种类型的视图中:透视、正交前视图、侧视图和俯视图。在场景图架构中,通过将场景渲染到两个或更多相机中来实现多个视图。

此食谱扩展了上一个食谱,以支持多个相机,其中每个相机具有不同的视口区域(可能重叠),并且可以具有不同的清除颜色(根据需求)。

如何做到这一点...

实施此食谱的步骤如下:

  1. 从现在开始,屏幕清除颜色和缓冲区清除将在相机视图中应用。从 Renderer::render() 中删除清除代码。定义一个名为 clearColorvec4 类型变量来存储清除颜色信息:

    void Camera::SetClearColor(glm::vec4 color){
        clearColor = color;
    }
    
  2. Camera::render() 中应用清除颜色信息和帧缓冲区。在同一个函数中,确保 glViewPortglScissor 以完全相同的尺寸传递。如果启用了裁剪测试,glScissor() 将生效。它定义了一个在屏幕坐标系中的矩形屏幕空间区域,在此区域之外将不会绘制任何内容:

    void Camera::Render(){
       // Setup Viewport Info
       glViewport( viewPortParam.x, viewPortParam.y,
       viewPortParam.width, viewPortParam.height );
       // Apply scissoring
       glScissor ( viewPortParam.x, viewPortParam.y,
       viewPortParam.width, viewPortParam.height );
    
       glClearColor( clearColor.x, clearColor.y,
       clearColor.z, clearColor.w );
       glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
    
       // Reuse code for Setting up Projection/Model/View
    }
    
  3. NativeTemplate.cpp 中,按照以下方式编辑 GraphicsInit() 函数:

    Camera *camera1, *camera2, *camera3, *camera4;
    bool GraphicsInit(){
        graphicsEngine = new Renderer();
        scene1  = new Scene("MeshScene", graphicsEngine);
        camera1 = new Camera("Camera1", scene1);
        camera2 = new Camera("Camera2", scene1);
        camera3 = new Camera("Camera3", scene1);
        camera4 = new Camera("Camera4", scene1);
        // Multiple code line skipped
    }
    
  4. 使用 GraphicsResize() 并为前面代码中定义的所有四个相机定义视口大小。为每个相机指定清除颜色信息,以便绘制视图的背景:

    bool GraphicsResize( int width, int height ){
       graphicsEngine->resize(width, height);
       // Third Quadrant
       camera1->Viewport(0, 0, width/2, height/2);
       camera1->SetClearColor(glm::vec4(0.0, 0.0, 0.0, 1.0));
       // Second Quadrant
       camera2->Viewport(0, height/2, width/2, height/2);
       camera2->SetClearColor(glm::vec4(1.0, 1.0, 1.0, 1.0));
       // Fourth Quadrant
       camera3->Viewport(width/2, 0, width/2, height/2);
       camera3->SetClearColor(glm::vec4(1.0, 0.0, 1.0, 1.0));
       // First Quadrant
       camera4->Viewport(width/2,height/2,width/2,height/2);
       camera4->SetClearColor(glm::vec4(1.0, 1.0, 0.0, 1.0));}
    

如何工作...

多视图场景具有多个相机将当前场景渲染到屏幕的不同区域。每个不同的区域由相机中指定的视口维度指定。为了在场景图中支持多个视图,我们需要:

  1. 指定视口区域。这将根据指定的视口维度从场景的世界坐标生成屏幕坐标。

  2. 清除颜色。这是每次绘制帧缓冲区时用于清除颜色缓冲区的颜色。

  3. 当指定清除命令时,它将清除整个帧缓冲区。因此,您可能根本看不到不同的视图,因为最后一个相机的清除命令已经清除了帧缓冲区中的现有绘制。可以通过裁剪测试避免这种意外的颜色缓冲区清除。在 OpenGL ES 3.0 中,您可以使用 glScissor 命令裁剪帧缓冲区区域,该命令在屏幕坐标系中定义了一个矩形屏幕空间区域,在此区域之外将不会绘制任何内容。

在使用任何模型、视图和投影矩阵之前,需要指定以下命令以实现多个相机:

glViewport( viewPortParam.x, viewPortParam.y,
          viewPortParam.width, viewPortParam.height );
glScissor ( viewPortParam.x, viewPortParam.y,
         viewPortParam.width, viewPortParam.height );
glClearColor( clearColor.x, clearColor.y,
         clearColor.z, clearColor.w );
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);

如何工作...

还有更多...

只有当裁剪测试启用时,glScissor() 才会工作;glScissor 定义一个矩形并在窗口坐标中调用裁剪框。前两个参数:xy 指定框的左下角。宽度和高度指定框的尺寸。

要启用和禁用裁剪测试,请使用带有GL_SCISSOR_TEST参数的www.khronos.org/opengles/sdk/docs/man/xhtml/glEnable.xmlwww.khronos.org/opengles/sdk/docs/man/xhtml/glDisable.xml调用。此测试最初是禁用的。当裁剪测试被启用时,只有位于裁剪框内的像素可以被绘图命令修改。窗口坐标在帧缓冲区像素的共享角落具有整数值。

语法:

void glScissor(GLint x, GLint y, GLsizei width, GLsizei height);
变量 描述
x, y 这指定了裁剪框的左下角。xy的初始值是(0, 0)。
width, height 这指定了裁剪框的宽度和高度。当一个 GL 上下文首次附加到窗口时,宽度和高度被设置为该窗口的尺寸。

参见

  • 参考第八章中的“在抬头显示上渲染文本”配方,字体渲染

第十一章:抗锯齿技术

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

  • 理解采样率技术

  • 理解后期处理技术

  • 实现快速近似抗锯齿

  • 实现自适应抗锯齿

  • 实现抗锯齿圆几何形状

简介

抗锯齿是计算机图形学中的一种技术,通过最小化锯齿线或阶梯效应来提高屏幕上显示的渲染图像或视频输出的质量。光栅屏幕由成百上千个排列成网格格式的微小正方形像素组成。这些像素在图像光栅化过程中根据几何形状进行采样。基本上,抗锯齿的原因是点采样。这些样本由矩形像素表示,这些像素不足以产生曲线形状。图像中的边缘(圆形而非水平或垂直)负责这种阶梯效应,因为它最终像阶梯一样着色像素。当图像或场景静止时,锯齿问题并不明显,但一旦它们运动起来,锯齿边缘就非常明显。以下图像显示了无限详细等腰直角三角形(A)的渲染。光栅化阶段执行采样并在有限的采样网格上显示它。显然,阶梯效应在斜边(B)上很容易看到。然而,底边和垂直边的边缘与水平和垂直网格像素(C)对齐,因此不会产生锯齿边缘。

然而,一旦三角形旋转,所有边缘都会显示出锯齿效应:

简介

抗锯齿从附近的或背景像素中采样,并将它们与边缘像素的颜色混合,以生成平滑的近似值,从而最小化阶梯效应,使边缘看起来平滑。

抗锯齿可能由其他各种因素引起,例如高光、阴影边界、几何轮廓等,导致颜色频率的快速变化。

抗锯齿技术可以分为两种类型:采样率和后期处理技术。

理解采样率技术

在采样率技术中,通过增加像素中样本率的数量来决定像素的颜色,这是基于样本的。这包括诸如超级采样抗锯齿(SSAA)、多采样抗锯齿(MSAA)、覆盖采样抗锯齿(CSAA)等技术,这些技术通常在 GPU 硬件上驱动。

如何做...

这一节与其他章节中我们遵循的如何做...部分略有不同。在本节中,我们将讨论之前提到的各种采样率技术以及它们之间的程序差异。让我们详细讨论一下。

超采样抗锯齿SSAA):这种技术也被称为全场景抗锯齿FSAA)。在这里,场景首先渲染到更高的分辨率,然后通过取其相邻像素的平均值将其下采样到原始分辨率。例如,如果给定的场景需要渲染到 1920 x 1080 的分辨率,它首先在一个离屏表面上渲染到 3840 x 2160 的高分辨率,然后进行下采样。离屏表面是四倍大的,当缩小到原始分辨率时,每个像素产生 2 x 2 样本。FSAA 的逻辑简单,质量细腻,但它的计算成本非常高,因为它需要每个样本都具有颜色和深度信息。这种技术在早期的显卡中可用,但由于其巨大的计算成本,不再在实时应用中广泛使用。

累积缓冲区AA):这种技术与 FSAA 类似,但在这里使用的缓冲区具有与所需图像相同的分辨率,并且比所需图像具有更多的颜色位。为了产生每个像素的 2 x 2 样本,创建了四个图像缓冲区,其中每个图像视图根据需要沿x或 y 轴移动半个像素。然后,这些图像在 GPU 的累积缓冲区中相加并平均,以产生抗锯齿输出。现代 GPU 硬件没有累积缓冲区。相反,这可以通过片段着色器来完成。在像素着色器中使用的精度必须更高(每通道 10 到 16 位)以存储累积的结果颜色。8 位精度在混合时可能会导致颜色带状伪影。

多重采样抗锯齿MSAA):由于全采样抗锯齿(SSAA)的计算成本较高,因此出现了多重采样抗锯齿。这种技术产生的可接受质量较低,但它节省了大量的计算成本,并且长期以来一直是 GPU 硬件供应商的首选。多重采样在单次计算过程中对给定像素进行多个样本采样。存在各种像素采样方案,如下面的图像所示:

如何做...

样本率可能因颜色频率变化率的不同而不同。例如,阴影和几何边缘的情况变化较大。因此,需要更多的样本来处理更好的结果。着色是从每个片段只计算一次,这使得它比 SSAA 更快。对于每个样本,相应的颜色和深度信息被分别存储。

下图显示了 1x 和 4x 采样方案。在前者的情况下,采样位置不足以与绿色三角形重叠,因此导致白色着色的像素。然而,在后一种情况下,四个采样位置中有两个成功位于几何形状中。因此,插值后的结果颜色位于这两种颜色之间,最右侧的图像显示了 4x 采样方案的色阶条:

如何做...

覆盖采样抗锯齿CSAA):这项技术是比 MSAA 改进的版本。MSAA 为每个样本分别存储颜色和深度信息。然而,这种存储是不必要的,并且可以完全避免。CSAA 技术利用这一缺点,避免了颜色和深度信息的单独存储;它采用基于索引的方法。在这种情况下,每个子像素或样本存储一个指向与之关联的片段着色器的索引。所有片段都存储在一个表格格式中,其中包含颜色和深度信息。每个片段通过其唯一的索引来识别。

理解后处理技术

在这种技术中,场景被渲染到离屏表面,并使用抗锯齿算法进行处理。处理输出在屏幕表面分割。这种类型的抗锯齿包括 AMD 的形态学滤波(MLAA)、快速近似抗锯齿(FXAA)、子像素形态学抗锯齿(SMAA)等。

如何做...

在这里,我们将讨论之前提到的各种后处理技术。

快速近似抗锯齿FXAA):FXAA 是一种后处理过滤技术。此过滤器主要执行两项任务:首先检测边缘,然后对锯齿边缘应用模糊算法。像之前依赖于硬件的技术一样,FXAA 在抗锯齿选项有限的情况下非常有用。FXAA 提供了非常好的性能。与 MSAA 和 SSAA 相比,它更快,因此成为游戏行业的首选选择。这项技术在工作在图像空间中。因此,它可以在任何情况下使用,例如前向渲染图像或延迟渲染图像:

如何做...

前向渲染:这是渲染执行模型的传统路径,其中首先将几何形状输入到顶点着色器,然后是片段着色器。最后,将处理后的视觉效果渲染到目标。整个过程包括四个步骤:

  1. 计算几何形状。

  2. 材料特性,如法线、双向切线等,被定义。

  3. 计算入射光的方向。

  4. 对象表面和光线交互被计算。

延迟渲染:在延迟渲染技术中,前两步与最后两步分开,在渲染管道的离散阶段执行每个步骤。在这里,场景被分为两个遍历。第一个遍历永远不会用于执行任何类型的着色。然而,在这个遍历期间,用于着色的必要信息(位置、法线、材料和深度)被收集到一组纹理中,并在第二个遍历中使用,在第二个遍历中计算直接和间接光照信息以照亮对象。

实现快速近似抗锯齿

在抗锯齿中有两个非常重要的因素:性能和质量。一个好的抗锯齿技术必须快速,并且应该产生可接受的质量结果。FXAA 在这些方面表现非常积极。与 MSAA 相比,它更快,与 SSAA 技术相比,性能开销减少了大约 25%。它以与纹理相同的分辨率工作,这消除了类似于其他技术的额外开销,在这些技术中,纹理被缩放到更高的分辨率然后下采样。

FXAA 作用于图像的特定细节;它系统地检测给定图像中的阶梯效应并将其模糊掉。阶梯效应通过边缘检测算法识别。因此,边缘检测和模糊算法的质量是这里非常重要的因素。一个错误的算法可能会错过重要的边缘或检测到错误的边缘,这可能导致模糊后的质量不令人满意。

准备工作

在这个菜谱中,我们将实现 FXAA 技术。让我们从更高层次理解这个实现。

FXAA 技术首先使用帧缓冲对象FBO)将场景渲染到离屏表面。与其他基于屏幕空间的技术类似,它操作整个场景,FXAA 技术可以在需要抗锯齿的选定区域运行。FXAA 作为一个后处理着色器实现,它根据像素亮度检测渲染场景中的边缘。然后使用它们的梯度对这些检测到的边缘进行平滑处理。这两个处理都在单次遍历中完成。

这个菜谱就像任何其他后处理菜谱一样:

  1. 创建具有所需尺寸的 FBO。

  2. 创建一个场景并将其渲染到离屏 FBO 表面。

  3. 将 FXAA 技术单次应用到 FBO 纹理场景中。

    注意

    在这个菜谱中,我们将描述第三步,在那里我们将实现片段着色器中的 FXAA 算法。有关后屏幕技术的更多信息,请参阅第九章,《后屏幕处理和图像效果》。

如何操作...

以下代码在片段着色器中实现了 FXAA 技术算法;此片段着色器在离屏场景纹理图像上操作:

#version 300 es
precision mediump float;

in vec2             TexCoord;      // Texture coordinates
uniform sampler2D   Tex1;          // FBO texture
uniform float       ScreenCoordX;  // X Screen Coordinate
uniform vec2        FBS;          // Frame Buffer Size
layout(location = 0) out vec4   outColor;

// Calculates the luminosity of a sample.
float FxaaLuma(vec3 rgb) {return rgb.y * (0.587/0.299) + rgb.x;}

void main() {
        float FXAA_SPAN_MAX     = 8.0;
    float FXAA_REDUCE_MUL   = 1.0/8.0;
    float FXAA_REDUCE_MIN   = 1.0/128.0;

    // Sample 4 texels including the middle one.
    // Since the texture is in UV coordinate system, the Y is
    // therefore, North direction is –ve and south is +ve.
    vec3 rgbNW = texture(Tex1,TexCoord+(vec2(-1.,-1.)/FBS)).xyz;
    vec3 rgbNE = texture(Tex1,TexCoord+(vec2(1.,-1.)/FBS)).xyz;
    vec3 rgbSW = texture(Tex1,TexCoord+(vec2(-1.,1.)/FBS)).xyz;
    vec3 rgbSE = texture(Tex1,TexCoord+(vec2(1.,1.)/FBS)).xyz;
    vec3 rgbM  = texture(Tex1,TexCoord).xyz;

    float lumaNW = FxaaLuma(rgbNW);   // Top-Left
    float lumaNE = FxaaLuma(rgbNE);   // Top-Right
    float lumaSW = FxaaLuma(rgbSW);   // Bottom-Left
    float lumaSE = FxaaLuma(rgbSE);   // Bottom-Right
    float lumaM  = FxaaLuma(rgbM);    // Middle

      // Get the edge direction, since the y components are inverted
      // be careful to invert the resultant x
       vec2 dir;
    dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
    dir.y =  ((lumaNW + lumaSW) - (lumaNE + lumaSE));

      // Now, we know which direction to blur, 
      // But far we need to blur in the direction? 
      float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * 
      (0.25 * FXAA_REDUCE_MUL),FXAA_REDUCE_MIN);
      float rcpDirMin = 1.0/(min(abs(dir.x),abs(dir.y))+dirReduce);

      dir = min(vec2( FXAA_SPAN_MAX,  FXAA_SPAN_MAX), max(vec2(-
      FXAA_SPAN_MAX,-FXAA_SPAN_MAX), dir*rcpDirMin))/FBS;

      vec3 rgbA = (1.0/2.0)*(texture(Tex1, TexCoord.xy + dir *
      (1.0/3.0 - 0.5)).xyz + texture(Tex1, TexCoord.xy 
      + dir * (2.0/3.0 - 0.5)).xyz);
      vec3 rgbB = rgbA * (1.0/2.0) + (1.0/4.0) * (texture(Tex1, 
      TexCoord.xy + dir * (0.0/3.0 - 0.5)).xyz + texture
      (Tex1, TexCoord.xy + dir * (3.0/3.0 - 0.5)).xyz);

      float lumaB    = FxaaLuma(rgbB);
      float lumaMin   = min(lumaM, min(min(lumaNW, lumaNE),
      min(lumaSW, lumaSE)));
      float lumaMax    = max(lumaM, max(max(lumaNW, lumaNE), 
      max(lumaSW, lumaSE)));

      if((lumaB < lumaMin) || (lumaB > lumaMax)){
        outColor = vec4(rgbA, 1.0);
      }else{
        outColor = vec4(rgbB, 1.0);
      }
}

它是如何工作的...

FXAA 技术利用了人眼的一个有趣特性,即亮度或颜色亮度;我们对它非常敏感。人眼非常能够注意到亮度最轻微的变化。使用颜色亮度检测边缘与几乎所有类型的走样效果一起工作,例如镜面或几何走样。亮度或灰度图提供了图像中的亮度级别;它在检测图像空间中的亮暗区域时很有帮助。两个样本之间亮度的急剧变化暗示了边缘的存在。

本配方中实现的 FXAA 过滤器在当前 texel 周围进行五次采样,并分析这些采样以检测边缘的存在。以下图像显示了一个斜边受到阶梯效应(A)影响的三角形。其边缘的某一部分经过 FXAA 过滤器处理以执行抗锯齿(B)。此过滤器进行五次采样并将它们转换为用于边缘检测的发光 texel(C)。此信息被模糊算法用于根据相邻样本模糊颜色强度(D):

如何工作...

FBS包含当前离屏表面纹理(FBO)的大小,其倒数给出单位 texel 的尺寸。这个单位 texel 被添加到当前 texel(M)的各个方向(顶部、底部、左侧和右侧),以产生围绕中心 texel(M)的新采样 texel NW(左上)、NE(右上)、SW(左下)和SE(右下)。由于 UV 坐标系统相对于笛卡尔坐标系统具有反转的Y方向,我们需要反转南北方向。因此,你可以看到南北分量的负号:

vec3 rgbNW = texture(Tex1,TexCoord+(vec2(-1.,-1.)/FBS)).xyz;
vec3 rgbNE = texture(Tex1,TexCoord+(vec2( 1.,-1.)/FBS)).xyz;
vec3 rgbSW = texture(Tex1,TexCoord+(vec2(-1., 1.)/FBS)).xyz;
vec3 rgbSE = texture(Tex1,TexCoord+(vec2( 1., 1.)/FBS)).xyz;
vec3 rgbM  = texture(Tex1,TexCoord).xyz;

FXAALuma函数根据下一图像计算 NW、NE、SW、SE 和 M 样本的发光权重;这些权重用于找到模糊的方向。

   float lumaNW = FxaaLuma(rgbNW);    // Top-Left
   float lumaNE = FxaaLuma(rgbNE);     // Top-Right
   float lumaSW = FxaaLuma(rgbSW);     // Bottom-Left
   float lumaSE = FxaaLuma(rgbSE);     // Bottom-Right
   float lumaM  = FxaaLuma(rgbM);      // Middle

以下图像给出了计算边缘方向的公式。如果xy分量的结果是非零幅度,则存在边缘。正如你所见,方向公式确定了沿xy轴的边缘方向分量。现在,使用这些信息,可以在特定方向上进行模糊处理:

如何工作...

你可能已经注意到x的方向被反转(负值)。这是因为用于北和南分量的反转符号在前面代码中提到:

dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE)); //Inverted
dir.y =  ((lumaNW + lumaSW) - (lumaNE + lumaSE));

我们已经得到了方向。现在,我们必须确定在给定方向上应该模糊多远。为了找到距离,我们大致归一化方向向量,使得最小的分量成为单位。为此,可以通过取最小分量方向向量的倒数来计算这个方向向量的模(rcpDirMin)。现在,如果发生除以零的情况,结果将是未定义的。为此,添加了一个 delta 分量。我们称之为减少方向(dirReduce):

float rcpDirMin = 1.0/(min(abs(dir.x),abs(dir.y))+dirReduce);

减少方向计算相当简单;它是FXAA_REDUCE_MUL常量与所有亮度强度的平均值以及FXAA_REDUCE_MIN常量乘积的最大值。这些常量非常依赖于用户的观察。因此,可以将它们定义为 uniforms,以允许进行这些实验:

float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * 
                     (0.25 * FXAA_REDUCE_MUL),FXAA_REDUCE_MIN);

单位方向向量可以计算为dir = dir * rcpDirMin,但这里还有一个问题。如果结果乘积非常大,这将产生远离当前 texel 的 texels。我们当然不希望这样,因为我们只对附近的 texels 感兴趣。因此,我们需要使用以下方法将这个结果方向向量的跨度限制在某个有限范围内。FXAA_SPAN_MAX是一个常量(8.0)。结果除以 FBS 给出了 UV 方向中单位 texel 在纹理空间的方向:

dir = min(vec2( FXAA_SPAN_MAX,  FXAA_SPAN_MAX), max(vec2(-
              FXAA_SPAN_MAX,-FXAA_SPAN_MAX), dir*rcpDirMin))/FBS;

现在,我们有了用于模糊的方向模。为了执行模糊,沿着边缘的相同方向取两个样本。第一个样本rgbA使用前向(dir * (2.0/3.0 - 0.5))和后向(dir * (1.0/3.0 - 0.5))方向(dir)从Tex1纹理中计算两个样本。结果强度减少到一半:

vec3 rgbA = (1.0/2.0)*(texture(Tex1, TexCoord.xy + dir *
            (1.0/3.0 - 0.5)).xyz + texture(Tex1, TexCoord.xy 
             + dir * (2.0/3.0 - 0.5)).xyz);

类似地,另一个样本,即rgbB,也包含两个内部样本,它们分别位于当前 texel 的前向(dir * (3.0/3.0 - 0.5))和后向(dir * (0.0/3.0 - 0.5))方向,各占一半。在这里,结果强度减少到四分之一,并与rgbA的结果混合。由于rgbA的强度已经减少到一半,因此在混合之前进一步减少到四分之一:

   vec3 rgbB = rgbA * (1.0/2.0) + (1.0/4.0) * (texture(Tex1, 
   TexCoord.xy + dir * (0.0/3.0 - 0.5)).xyz + texture
   (Tex1, TexCoord.xy + dir * (3.0/3.0 - 0.5)).xyz);

这两个样本向量(rgbArgbB)用于执行测试,以检查采样纹理是否太远。为此,我们从给定的样本中计算最小和最大亮度,分别存储在lumaMinlumaMax中。同样,计算lumaB的亮度并将其存储在rgbB变量中:

   float lumaB     = FxaaLuma(rgbB);
   float lumaMin   = min(lumaM, min(min(lumaNW, lumaNE),
   min(lumaSW, lumaSE)));
   float lumaMax    = max(lumaM, max(max(lumaNW, lumaNE), 
   max(lumaSW, lumaSE)));

如果rgbB的亮度小于最小亮度或大于最大亮度,显然它超出了我们采样亮度的预期范围。在这种情况下,我们将使用rgbA着色当前片段,它更接近于采样的定向边缘。另一方面,如果亮度范围在预期范围内,则使用rgbB颜色:

if((lumaB < lumaMin) || (lumaB > lumaMax)){
       outColor = vec4(rgbA, 1.0);
   }else{
       outColor = vec4(rgbB, 1.0);
}

更多...

在本节中,我们将讨论使用 FXAA 的优点和缺点:

优点:

  • 与 MSAA 相比,FXAA 更快,但占用的内存更少。

  • 这种技术作为图像空间中的过滤器工作。因此,它很容易集成到着色器中,并且不需要高计算成本。

  • FXAA 平滑了由 alpha 混合纹理和片段着色器效果产生的边缘。它适用于任何技术,例如前向图像或延迟渲染图像。

  • 抗锯齿的成本与渲染场景的成本无关。因此,对包含数百万个顶点和数百个纹理的复杂场景进行抗锯齿的执行时间与包含数百个顶点和少量纹理的简单场景相同。

  • FXAA 技术可以与其他后处理过滤技术结合使用。这将完全消除抗锯齿通过的额外成本。

  • 如果提前知道哪些场景部分将要进行抗锯齿处理,可以使用诸如剪裁测试、视口信息等特性,将 FXAA 应用于所选区域。

缺点:

  • 它需要一个高质量的边缘检测算法;一个低质量的算法可能会错过一些需要抗锯齿的边缘。

  • 同样,一个好的模糊算法需要模糊出正确的结果。

  • 它不处理时间抗锯齿。

    注意

    时间抗锯齿会导致渲染对象跳跃出现,给人一种物体突然跳动的印象,而不是给人一种物体平滑移动的印象。这种行为的背后原因是场景采样的速率;与场景中对象的变换速度相比,采样速率要低得多。为了避免时间抗锯齿效果,场景的采样率必须至少是最快移动对象的两倍。

参见

  • 请参考第六章的“使用纹理坐标进行过程纹理着色”配方,使用着色器

  • 请参考第七章的“使用帧缓冲对象实现渲染到纹理”配方,纹理和映射技术

实现自适应抗锯齿

自适应抗锯齿减轻了在实现进程式着色器过程中产生的锯齿效应。由于进程式着色器是编程来生成动态纹理的,从低频到高频的过渡对程序员来说非常清楚,因为他们是编写它的人。例如,圆点图案的实现使用圆或球体计算逻辑生成点图案。如果片段着色器落在圆内,它将用一种颜色绘制;否则,它使用背景颜色。在这种情况下,程序员非常清楚从一种颜色到另一种颜色的过渡将非常尖锐。这就是自适应抗锯齿发挥作用的地方。它通过在两种颜色之间插值颜色来避免这种尖锐的颜色过渡。这些尖锐的过渡可以通过许多内置的着色语言 API(如 smooth、mix 和 clamp)来使它们更加平滑。

在这个配方中,我们将通过实现抗锯齿的进程式纹理来生成一个动画条纹图案并移除条纹边缘的锯齿效应。

如何实现...

使用以下片段着色器来实现自适应抗锯齿:

#version 300 es
precision mediump float;

// Reuse Phong shading light and material properties.
uniform float  Time;

// Flag to enable and disable Adaptive anti-aliasing
uniform int     EnableAdaptiveAA;

layout(location = 0) out vec4 FinalColor;

vec3 PhongShading{
   // Reuse Phong shading code.
}

in float objectY;
float Frequency = 6.0; // Controls number of stripes

// Reference: OpenGL Shading Language by Randi J Rost
void main() {
    if(gl_FragCoord.x < ScreenCoordX+1.0 
             && gl_FragCoord.x > ScreenCoordX-1.0){
        FinalColor = vec4(1.0, 0.0, 0.0, 1.0);
        return;
    }

    float offset    = Time;

    // GENERATE fractional value 0.0, 0.1, ........, 0.9
    float sawtooth  = fract((objectY+offset) * Frequency);

    // Produce values in the range between [-1, 1]
    float triangle  = 2.0 * sawtooth - 1.0;

    // Produce continuous range from [ 1.0 ... 0.0 ... 1.0 ]
    triangle        = abs(triangle);
    float dp        = length(vec2 (dFdx(objectY+offset),
                                          dFdy(objectY+offset)));
    float edge      = dp * Frequency * 4.0;
    float square    = 0.0;

    // Show the difference between aliased and anti-aliased.
    if (gl_FragCoord.x < ScreenCoordX){
        square      = step(0.5, triangle);
    }
    else{
        square      = smoothstep(0.5-edge, 0.5 + edge, triangle);
    }

    FinalColor = vec4 (vec3 (square)*PhongShading(), 1.0);
}

如何工作...

这个配方实现了动画水平条纹图案。它使用对象坐标的垂直分量来生成这个图案。要生成图案的 3D 网格模型的对象坐标被传递到顶点着色器,在那里它在objectY变量中与片段着色器共享。这些对象坐标的垂直分量与offset变量相加。offset变量是时间的函数。每次渲染新帧时,通过将其从最后的位置移动到某个新位置来通过位移动画条纹图案。这些条纹图案将从顶部到底部方向连续动画。

Frequency变量控制对象上的条纹数量。它与对象坐标相乘以缩放其范围。着色语言的fract() API 产生一个从 0.0 到 0.9 的小数,产生一个类似于锯齿的图案(A)。将这些值乘以 2 并减去 1,我们得到一个限制在-1.0 和 1.0 之间的范围的功能(B)。最后,取这些绝对值产生一个从 1.0 到 1.0 的正连续范围(C),这些值存储在三角形变量中:

如何工作...

使用 GLSL step API 生成的条纹图案。此 API 如果三角形小于 0.5 则返回 0.0,如果大于则返回 1.0,如下面的图所示(D):

如何工作...

step API 生成的输出显示在以下图像中(参考红色线的左侧)。很明显,由于输出值直接从 0.0 切换到 1.0,反之亦然,因此锯齿效应很容易看到。这种锯齿效应可以使用 GLSL 的另一个 API smoothstep 来消除。这个 API 接受两个参数作为输入值,并在两者之间执行插值。它避免了尖锐的过渡,并插值出一个平滑的范围,如前图(E)所示。smoothstep API 中的两个输入参数是对象坐标沿xy分量的偏导数的函数:

工作原理...

参见

  • 请参考第六章中的使用纹理坐标进行程序纹理着色食谱,使用着色器

  • 实现抗锯齿圆几何形状

实现抗锯齿圆几何形状

圆是一个非常常见的几何形状,在各种计算机图形应用中被广泛使用,例如用饼图渲染统计数据、绘制标志牌、动画点图案等等。在本食谱中,我们将借助纹理坐标实现一个抗锯齿圆几何形状,并使用之前食谱中的自适应抗锯齿技术使其更加平滑。

实现抗锯齿圆几何形状的一种方法是在圆的周长上生成一组顶点,其中每两个连续的顶点都连接到中心顶点(原点),形成一个三角形切片。需要多个这样的切片来创建圆的骨架,如图所示。当这些顶点使用三角形原语渲染时,它们会产生一个填充的圆形图案。产生的圆形形状的平滑度高度依赖于沿周长使用的顶点数量。使用更多的顶点可能会降低其性能,因为我们试图在周长上实现更平滑的边缘。

优点:

  • 由于圆的几何形状是通过顶点本身来表示的,因此碰撞检测和拾取测试将非常准确。

缺点:

  • 为了使边缘更加平滑,需要越来越多的顶点。最终,这会带来更多性能开销。

  • 默认情况下,圆的边缘没有抗锯齿。这类几何技术从实现角度来看可能非常复杂。

  • 几何形状尺寸的变化可能会暴露出锯齿边缘:实现抗锯齿圆几何形状

另一种方法是使用程序着色器,借助纹理坐标生成圆形几何形状。需要注意的是,这种技术产生的圆形几何形状实际上并不是真正的圆形;它是一个由四个顶点组成的假几何形状。无论圆的大小如何,它始终使用相同数量的顶点(4 个)来渲染圆形形状。

这种技术的原理非常简单。它使用四个顶点创建一个正方形,并产生一个完美的逻辑圆,该圆内嵌其中。落在该圆内的片段被着色,其余的片段通过 alpha 通道被屏蔽。

通过自适应抗锯齿技术处理,圆的周长或边缘变得更加平滑。在这里,沿着周长的一小部分从内部插值到外部,以产生平滑的渐变。

准备工作

让我们看看这个菜谱的高级实现:

  1. 创建一个具有顶点的四边形,如下面的图像所示。四边形的中心必须位于原点(0.0, 0.0, 0.0)。

  2. 按照以下方式为每个顶点分配一个纹理坐标。根据纹理坐标惯例,原点始终位于四边形的左下角:准备工作

  3. 以逆时针方向指定顶点的环绕顺序(V0 > V1 > V2 > V3)。

  4. 在片段着色器中,将每个纹理坐标与沿 UV 方向的半向量相减。这将使原点从左下角移动到四边形的中心。

  5. 检查每个片段与偏移原点的距离。如果当前片段位于外半径范围内(例如 0.5),则用所需颜色绘制它;否则,将片段与背景颜色进行 alpha 混合。

  6. 对于抗锯齿,取另一个半径称为内半径,其值小于外半径(例如 0.4),并根据从内半径和外半径之间([0.4 0.5])的片段纹理坐标位置计算出的权重来插值颜色值。

如何操作...

理解此菜谱逐步实现的步骤如下:

  1. Circle.h/.cpp 中创建一个名为 Circle 的类。

  2. 在类构造函数中,分别在 verticestexCoords 变量中定义顶点和纹理坐标:

       glm::vec2 texCoords[4] = {
           vec2(0.0f, 0.0f),vec2(0.0f, 1.0f),
            vec2(1.0f, 0.0f), vec2(1.0f, 1.0f)
        };
        memcpy(texCoordinates, texCoords, sizeof(glm::vec2)*4);
    
        glm::vec3 tempVtx[4] = {
            vec3( -0.5f, -0.5f, 0.0f), vec3( -0.5f,  0.5f, 0.0f),
            vec3(  0.5f, -0.5f, 0.0f), vec3(  0.5f,  0.5f, 0.0f)
        };
        memcpy(vertices, tempVtx, sizeof(glm::vec3)*4);
    
  3. 创建一个名为 AACircleVertex.glsl 的顶点着色器文件:

    #version 300 es
    
    // Vertex information
    layout(location = 0) in vec3  VertexPosition;
    layout(location = 1) in vec2  VertexTexCoord;
    
    out vec2 TexCoord;
    
    uniform mat4 ModelViewProjectMatrix;
    
    void main( void ) {
        TexCoord = VertexTexCoord;
        gl_Position = ModelViewProjectMatrix *
                       vec4(VertexPosition,1.0);
    }
    
  4. 类似地,创建 AACircleFragment.glsl 并添加以下代码:

    #version 300 es
    precision mediump float;
    // Texture coordinates
    in vec2 TexCoord;
    
    uniform vec3        PaintColor;     // circle color
    uniform float       InnerRadius;    // inside radius
    uniform float       OuterRadius;    // outside radius
    layout(location = 0) out vec4   outColor;
    
    void main() {
       float weight = 0.0f;
        // Displace the texture coordinate wrt 
        // hypothetical centered origin
        float dx     = TexCoord.x - 0.5;
        float dy     = TexCoord.y - 0.5;
    
        // Calculate the distance of this transformed 
        // texture coordinate from Origin.
        float length = sqrt(dx * dx + dy * dy);
    
        // Calculate the weights
        weight = smoothstep(InnerRadius, OuterRadius, length );
    
        outColor = mix( vec4(PaintColor, 1.0), 
                       vec4(PaintColor, 0.0), weight);
    }
    
  5. NativeTemplate.cpp 中定义场景,如下面的代码所示:

    Renderer*       graphicsEngine; // Graphics Engine
    Scene*          scene;          // Scene object
    Circle*         circle;
    Camera* camera;
    bool GraphicsInit(){
        // Create rendering engine
        graphicsEngine  = new Renderer();
    
        // Create the scene
        scene = new Scene("MeshScene", graphicsEngine);
    
        // Create camera and added to the scene
        camera = new Camera("Camera1", scene);
        camera->SetClearBitFieldMask(GL_COLOR_BUFFER_BIT | 
                                     GL_DEPTH_BUFFER_BIT);
        camera->SetPosition(glm::vec3 (0.00000, 0.0, 2.00000));
        camera->SetTarget(glm::vec3 (0.0, 0.0,0.0));
    
        // Create a new circle shape object    
        circle = new Circle(scene, NULL, None);
        circle->SetName(std::string("My Circle"));
    
        scene->addModel(circle);
        graphicsEngine->initializeScenes();
        return true;
    }
    
    bool GraphicsResize( int width, int height ){
        // Create the view port
        camera->Viewport(0, 0, width, height);
        graphicsEngine->resize(width, height);
        return true;
    }
    
    bool GraphicsRender(){
        // Rotate the circle
        circle->Rotate(1.0, 1.0, 1.0, 1.0);
        graphicsEngine->render();
        return true;
    }
    

它是如何工作的...

这个配方主要包含两个部分:创建圆形和光滑创建圆形的边缘。在第一部分,定义了几何形状以创建基础形状。基础形状由四个顶点组成,以创建一个完美的正方形。这些顶点与顶点着色器共享,以产生视点坐标。每个顶点都包含相关的纹理坐标,这些坐标也被传递到顶点着色器并与片段着色器共享。片段着色器以这种方式控制完美正方形的着色区域,使其看起来像一个完美的圆形。所有这些操作都是通过纹理坐标操作完成的。以下图像显示了映射到正方形几何形状上的输入纹理坐标(A)。如图所示,第一幅图中的原点出现在左下角。这个原点通过从 UV 方向上纹理坐标跨度的一半减去纹理坐标,逻辑上移动到正方形的中心部分(B)。

这样,所有纹理坐标都相对于正方形中心的新的原点发生位移:

 float dx = TexCoord.x - 0.5;
 float dy = TexCoord.y - 0.5;

计算并检查位移纹理坐标的距离,并与圆半径进行比较。如果它小于给定的半径,则意味着它在圆内,需要用PaintColor进行绘制。内部部分将以 alpha 1.0 着色,以看起来是实心的。如果当前片段纹理坐标的距离看起来在给定的半径之外,则用 alpha 0.0 着色。这将使圆的外部部分消失:

如何工作...

这种技术的第二部分通过自适应抗锯齿处理使其边缘变得柔和。为此,使用两个半径(InnerRadiusOuterRadius),如图所示的前一个图像(C)。位于这两个半径带之下的片段将根据从该带中纹理坐标的位置获得的权重插值其颜色值:

weight    = smoothstep( innerRadius, outerRadius, length );
outColor  = mix( vec4(paintColor, 1.0), 
            vec4(paintColor, 0.0), weight);

如何工作...

这种技术有一些优点和缺点:

优点:

  • 这种技术具有很高的性能效率。

  • 这种技术可以产生具有平滑边缘的高质量圆形形状。

  • 边缘的尖锐度可以在运行时进行调整。

  • 圆的边缘可以被渲染。

  • 缩放不会影响图像质量。它可以自适应。

缺点:

  • 这种技术无法以高精度执行碰撞检测或拾取测试。

  • 这种技术可以产生具有平滑边缘的高质量形状。

参见

  • 请参考第六章中关于创建圆形图案并使其旋转的配方,使用着色器

  • 实现自适应抗锯齿

第十二章:实时阴影和粒子系统

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

  • 使用阴影映射创建阴影

  • 使用 PCF 软化阴影边缘

  • 使用方差阴影映射

  • 模拟粒子系统

  • 使用同步对象和栅栏的变换反馈粒子系统

简介

阴影在实时渲染中扮演着重要的角色;它们为渲染场景增添了深度。当使用阴影渲染 3D 对象上的感知光信息时,看起来更加逼真。总的来说,阴影提高了渲染场景的真实感,并在物体之间提供了空间关系。渲染平滑且逼真的阴影是计算机图形学领域的一个研究热点。渲染过程消耗了大量的性能。因此,渲染它的方法必须在质量和性能之间进行平衡的权衡。由于内存和性能的限制,这在嵌入式端变得更加具有挑战性。

在本章中,我们将使用阴影映射实现阴影。从性能的角度来看,这种技术相对便宜,并在嵌入式设备上产生良好的效果。我们将使用另一种称为百分位数最近过滤(PCF)的技术来使这些阴影看起来更加平滑。在另一种称为方差阴影映射的技术中,我们将提高生成实时阴影的性能和质量。

本章还将帮助我们理解粒子渲染的基础。我们将实现两种渲染粒子系统的技术。第一种技术绑定在 CPU 上,粒子在 CPU 端更新和处理,仅为了渲染目的发送到 GPU。第二种技术利用 OpenGL ES 3.0 的新特性——变换反馈。这个特性允许你捕获顶点着色器的输出,并将其反馈回 GPU 进行下一帧的渲染。粒子系统在 GPU 端进行处理和渲染。这样,它避免了 CPU 的干预,使得渲染过程非常高效。

使用阴影映射创建阴影

在这个菜谱中,我们将使用一种简单且广泛接受的阴影技术——阴影映射,为场景添加更多真实感,以产生实时阴影。这种技术被称为阴影映射,因为它使用场景的深度信息,这些信息存储或映射到一个动态创建的深度缓冲区中,以产生实时阴影。

这种技术分为两个步骤:

  • 第一次遍历:在第一次遍历期间,从光源视角渲染场景。在这里,场景是从 3D 空间中光源的位置观看的。这样,就可以清楚地确定哪些物体位于光线的路径上。换句话说,它提供了从光源视角直接可见的物体的信息。场景的深度信息记录在 FBO 纹理中;这个纹理被称为阴影图。当然,如果从光源位置发出的光线穿过一个或多个物体,那么从光源视角看深度更高的物体(位于第一个物体后面)将处于阴影中。这种技术严重依赖于阴影图中捕获的深度信息;它存储了可见物体从光源位置的距离或深度。

  • 第二次遍历:在第二次遍历中,场景从预期的相机位置渲染。在这里,首先将每个片段的深度与存储在阴影图中的深度进行比较。这种比较检查传入的片段是否在光源下。如果片段不在光源下,则用环境阴影颜色着色该片段。

以下图像展示了阴影映射技术产生的阴影渲染效果:

使用阴影映射创建阴影

本节提供了关于如何实现阴影映射的高级概述:

  • 创建相机:这创建了两个相机,一个放置在光源位置,称为光源相机,另一个用于正常场景渲染。

  • 阴影图:这创建了一个带有深度纹理的 FBO,因为我们只对记录深度感兴趣,这里不需要任何颜色缓冲区。深度纹理的尺寸根据应用程序需求由用户定义。在当前配方中,我们使用了与渲染缓冲区相似的尺寸,这等同于视口尺寸。

  • 从光源视角渲染:这会将 FBO 附加为当前帧缓冲区,并从光源视角使用第一遍渲染场景,并将深度信息记录在阴影图中。由于我们只对深度值感兴趣,我们可以在第一遍中避免光栅化过程。

  • 渲染正常场景:这再次渲染场景,但这次是从正常相机视图渲染,并在第二次遍历期间与片段着色器共享生成的阴影图。

  • 顶点变换:在第二次遍历期间,顶点坐标在顶点着色器中变换两次,以产生以下内容:

    • 正常场景的视点坐标:正常场景的 MVP 矩阵用于生成用于gl_position的视点坐标。

    • 从光源视角的视点坐标:使用光源视角的 MVP 矩阵来生成视点坐标,这与存储在阴影图中的坐标完全相同。这些视点坐标被称为阴影坐标。

  • 齐次到纹理坐标:阴影坐标位于归一化坐标系[-1, 1]中。这些坐标被转换为纹理坐标空间[0, 1]。这是通过使用基于预乘的矩阵来完成的,其中单位矩阵按因子 0.5 缩放,并在正方向上以半逻辑尺寸偏移:使用阴影映射创建阴影

  • 深度比较:此转换后的阴影坐标与片段着色器共享,其中当前片段使用textureProj API 确定它是否位于阴影下。

以下图像的左侧显示了从光线视角渲染场景的渲染效果,这产生了右侧图像表示的阴影图。阴影图包含 0.0 到 1.0 的深度信息。接近 0.0 的值表示附近的物体。在灰度图像中,出现较暗的物体更靠近光源:

使用阴影映射创建阴影

准备工作

与之前的配方不同,此配方包含两个用于场景和模型的自定义类,分别称为CustomSceneCustomModel。自定义模型包含其他网格模型,这使得在NativeTemplate.cpp中处理模型渲染变得非常容易。同样,自定义场景类简化了场景的工作,它负责创建阴影图、管理光线和法线视图相机,并以两次遍历的方式执行渲染。

此配方使用 Phong 着色。增加了两个新的统一变量:LightCoordMatrixModelMatrix。前者包含从光线视角的偏置矩阵、投影矩阵和视图矩阵的乘积,而后者包含模型变换。这两个变量的乘积存储在shadowCoord中,并与片段着色器共享。isLightPerspectivePass统一变量告诉片段着色器它是在第一次还是第二次遍历中。片段着色器包含在ShadowMap中的阴影图。

如何做...

实现阴影映射的步骤如下:

  1. 在 Phong 顶点着色器中进行以下更改。在这里,阴影坐标在shadowCoord变量中计算:

    // VERTEX SHADER – PhongVertex.glsl
    // Reuse old code.. many lines skipped.
    // Model View Project matrix
    uniform mat4 LightCoordsMatrix, ModelViewMatrix, NormalMatrix;
    uniform mat4 ModelMatrix;
    
    out vec3 normalCoord, eyeCoord;
    out vec4 shadowCoord;
    
    void main()
    {
        normalCoord = NormalMatrix * Normal;
        eyeCoord    = vec3 ( ModelViewMatrix * VertexPosition );
        shadowCoord = LightCoordsMatrix 
                             * ModelMatrix * VertexPosition;
        gl_Position = ModelViewProjectionMatrix * VertexPosition;
    }
    
  2. 类似地,按照以下方式实现 Phong 片段着色器。在这里,片段根据其与光线和场景视角的位移着色:

    // FRAGMENT SHADER – PhongFragment.glsl
    // Many line skipped contain Material and light properties
    in vec3  normalCoord, eyeCoord;
    in vec4 shadowCoord;
    uniform lowp sampler2DShadow ShadowMap;
    
    layout(location = 0) out vec4 FinalColor;
    vec3 normalizeNormal, normalizeEyeCoord, normalizeLightVec, V, R, ambient, diffuse, specular;
    float sIntensity, cosAngle;
    uniform int isLightPerspectivePass;
    
    vec3 PhongShading(){ /* Reuse existing code */ }
    
    void main() {
        if(isLightPerspectivePass == 1){ return; }
    
        vec3 diffAndSpec = PhongShading();
        float shadow = textureProj(ShadowMap, shadowCoord);
    
        //If the fragment is in shadow, use ambient light
        FinalColor = vec4(diffAndSpec * shadow + ambient, 1.0);
    
        // Correct the Gamma configuration
        FinalColor = pow( FinalColor, vec4(1.0 / 2.2) );
        return;
    }
    
  3. CustomScene函数的构造函数中,创建阴影图缓冲区。为此,使用FrameBufferObjectSurface类创建一个具有深度纹理的 FBO。这是一个高级 FBO 类,封装了 FBO 的创建:

    CustomScene::CustomScene(std::string name, Object* parentObj)
                :Scene(name, parentObj){
       // Create the FBO
       fbo = new FrameBufferObjectSurface(); 
    
        // Generate the FBO ID
       fbo->GenerateFBO();
    
        depthTexture.generateTexture2D(GL_TEXTURE_2D, fbo->
          GetWidth(), fbo->GetHeight(), GL_DEPTH_COMPONENT32F,
          GL_FLOAT, GL_DEPTH_COMPONENT, 0, true, 0, 0,
          GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE,GL_NEAREST,
          GL_NEAREST );
    
       // Attached Depth Buffer
       fbo->AttachTexture(depthTexture, GL_DEPTH_ATTACHMENT);
    
       // Check the status of the FBO
       fbo->CheckFboStatus();
       lightPerspective = camera = NULL;
    }
    
  4. initializeScene()函数中初始化光线和法线视图相机:

    void CustomScene::initializeScene(){
    // Create camera view from lights perspective    lightPerspective = new Camera("lightPerspective", this);
       lightPerspective->SetClearBitFieldMask(GL_DEPTH_BUFFER_BIT);
       lightPerspective->SetPosition
                   (vec3(this->lights.at(0)->position));
        lightPerspective->SetTarget(vec3 (0.0, 0.0,0.0));
        this->addCamera(lightPerspective);
    
        // Create scene's camera view.
        viewersPerspective = new Camera("Camera1", this);
        viewersPerspective->SetClearBitFieldMask
                (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        viewersPerspective->SetPosition(vec3 (25.0, 25.0,25.0));
        viewersPerspective->SetTarget(vec3 (0.0, 0.0,0.0));
        this->addCamera(viewersPerspective);
        Scene::initializeScene(); // Call the base class.
    }
    
  5. 使用光线视角进行第一次遍历,正常进行第二次遍历渲染场景:

    void CustomScene::render(){
        // Set Framebuffer to the FBO    
        fbo->Push(); 
    
        // Render the scene from lights perspective
        lightPerspective->Render();
    
         // Cull the front faces to produce 
        glEnable(GL_CULL_FACE);
        glCullFace(GL_FRONT);
    
        glEnable(GL_POLYGON_OFFSET_FILL);
        glPolygonOffset(2.5f, 20.0f);
    
        for( int i=0; i<models.size();  i++ ){
            currentModel = models.at(i);
            if(!currentModel){ continue; }
    
           // Set LIGHT PASS (PASS ONE) to True
            ((ObjLoader*)currentModel)->SetLightPass(true);
            currentModel->Render();
        }
        fbo->Pop();// Reset to previous framebuffer
    
        // Bind the texture unit 0 to depth texture of FBO
        glActiveTexture (GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, depthTexture.getTextureID());
    
        camera->Render();    // View the scene from camera
        glCullFace(GL_BACK); // Cull objects back face.
        glDisable(GL_POLYGON_OFFSET_FILL);
    
        for( int i=0; i<models.size();  i++ ){
            currentModel = models.at(i);
            if(!currentModel){ continue; }
    
            // PASS TWO => Normal scene rendering
            ((ObjLoader*)currentModel)->SetLightPass(!true);
            currentModel->Render();
        }
    }
    

它是如何工作的...

在阴影映射中,场景在CustomScene类中构建。这个类创建一个离屏表面(FBO)来记录场景的深度信息。在初始化(InitializeScene)阶段,创建了两个摄像机对象(lightPerspectiveviewersPerspective)。前者摄像机放置在全局光线位置,从该位置对场景进行照明,后者摄像机放置在观众的位置。场景使用两次遍历进行渲染:一次是从光线的视角,另一次是从观众的视角。为了使渲染对象了解当前的遍历,使用ObjLoader::setLightPass函数;这个函数确保在这些两个遍历下的对象级别状态。

给定场景首先使用光线的透视遍历进行渲染,其中它绑定到一个包含深度缓冲区(depthTexture)的 FBO。深度缓冲区捕获由放置在光线位置的摄像机生成的视图中的所有渲染对象的 z 级或深度信息。在这个遍历过程中,需要裁剪前表面并启用多边形偏移填充,以避免阴影边缘伪影。有关更多信息,请参阅本食谱末尾的更多内容…部分。在顶点着色器中,在gl_position中计算眼睛坐标位置并捕获到深度缓冲区。这个着色器还包含计算阴影坐标的计算,这些坐标对于第一次遍历不是必需的,可以避免。我们将这视为一种优化,并将其留给读者来实现。由于第一次遍历只捕获深度信息,因此任何片段着色器操作都是不必要的,因此可以避免光栅化;我们使用一个统一变量(isLightPerspectivePass)来绕过片段着色器的渲染。然而,用户也可以使用glEnableGL_RASTERIZER_DISCARD)API。此 API 关闭光栅化过程。有关此 API 工作原理的更多信息,请参阅本章后面的使用同步对象和栅栏的变换反馈粒子系统食谱。

在第二次遍历中,使用观众的摄像机来渲染场景。这个场景以正常方式渲染,背面裁剪和禁用多边形偏移填充。场景从第一次遍历中共享捕获的深度信息到sampler2DShadow ShadowMap统一变量中的片段着色器。

注意

sampler2DShadow是一种特殊类型的采样器。程序中的采样器代表一个特定类型的单个纹理。sampler2DShadow用于表示深度纹理类型,其中包含场景对象的深度信息。正确使用采样器非常重要;使用带有阴影图的普通纹理可能会给出不可预测的结果,因为在这种情况下查找函数是不同的。每个采样器都有一个不同的查找函数,该函数负责根据输入纹理坐标计算结果。

在这个过程中,从光源(已经包含投影和视图信息)归一化的坐标使用本食谱介绍中提到的预乘偏移矩阵转换为纹理坐标空间。这个坐标被输入到textureProj API 中,该 API 执行带有投影的纹理查找。从shadowCoord消耗的纹理坐标是纹理坐标形式。在textureProj API 中,这些坐标被转换为齐次形式,其中shadowCoord.xyz被除以最后一个组件,即shadowCoord.w。在阴影图中,shadowCoord的第三个组件(z)用作深度参考。计算这些值后,纹理查找过程与纹理相同。

如果 z 值大于在给定位置(x,y)存储在阴影图中的值,则认为该物体位于某个表面后面。在这种情况下,它渲染为阴影颜色(环境光);否则,它将以相应的 Phong 着色渲染。

还有更多...

本节将描述阴影映射的一些重要方面和局限性。

阴影图分辨率

生成的阴影质量高度依赖于构建阴影图所使用的纹理的分辨率。分辨率的选择取决于各种因素。例如,低规格的硬件可能内存有限或处理能力慢,选择高分辨率阴影图可能会降低性能。在另一种情况下,需要更高的质量,只有高分辨率阴影图才有意义。以下图像显示了使用各种屏幕分辨率生成的阴影质量:

阴影图分辨率

采样失真影响

阴影映射技术存在采样失真问题,这在本食谱中给出的各种图像中很容易察觉。这种采样失真的原因是物体颜色到环境阴影颜色的急剧过渡。有各种方法可以减少采样失真,例如增加阴影图的分辨率。参见前面的图像。当分辨率变低时,阴影的质量会下降。这里的缺点是,随着采样次数的增加,性能会降低。另一种有效且流行的修复采样失真伪影的技术称为百分比更近过滤PCF)。在这种技术中,通过采样使边缘变软。有关更多信息,请参阅下一食谱使用 PCF 软化阴影边缘

阴影噪点

作为实现当前技术结果的一个非常常见的问题是所谓的阴影痤疮。以下图像显示了痤疮效果的外观。这是当第一次遍历启用后向面剔除时发生的。记录的深度纹理存储了前向面的 z 值,后来当与第二次遍历比较时,产生了深度值的大差异。这些大差异是导致阴影痤疮效果的原因。这可以通过仅渲染后向面来消除,这将导致更准确的深度比较。

因此,第一次必须使用前向面剔除来执行。在第一次遍历中使用前向面剔除形成的深度纹理可能仍然与第二次遍历生成的深度纹理不同或足够接近。因此,这导致渲染伪影,其中面显示出淡入淡出的效果。这种视觉上的不愉快可以通过使用(glEnable( GL_POLYGON_OFFSET_FILL))多边形偏移来消除。这个多边形偏移添加了一个适当的偏移(glPolygonOffset(2.5f, 20.0f)),以强制结果 z 值(在遍历 1 中)足够接近(遍历 2)以减轻问题:

阴影痤疮

参见

  • 请参阅第五章中的Phong 着色 - 每个顶点的着色技术菜谱,光和材料

  • 具有同步对象和栅栏的转换反馈粒子系统

使用 PCF 软化阴影边缘

PCF 代表百分比更接近过滤。这是一种众所周知且简单的技术,用于生成平滑的阴影边缘。在之前菜谱中实现的阴影映射技术,在光和阴影像素之间显示了非常尖锐的过渡,从而产生了走样效应。PCF 技术对这些尖锐的过渡进行平均,从而得到更平滑的阴影。与其他提供纹理过滤能力的纹理不同,这基本上是一种平滑方法,用于确定纹理映射像素的颜色,遗憾的是,这种过滤技术不能应用于阴影映射。相反,对每个像素进行多次比较并将它们平均在一起。

如 PCF 的名称所描述的,它使用当前片段对阴影图进行采样,并将其与周围样本进行比较。规则是给予靠近光源的样本更多的权重。换句话说,它计算靠近照亮表面而不是在阴影中的面积百分比。这就是这项技术得名的原因。

使用 PCF 软化阴影边缘

准备工作

对于这个菜谱,我们重用了阴影映射。以下步骤提供了一个高级概述,说明如何实现这个菜谱:

  • 预过滤阴影图:在使用之前,需要对这个阴影图进行预过滤。因此,应用线性纹理过滤进行纹理缩小和放大。在上一个配方中,这对应于同一节中的第二步。这次,使用GL_LINEAR过滤器创建 2D 深度纹理。

  • 与 PCF 的深度比较:在片段着色器中共享变换后的阴影坐标用于根据过滤器大小产生多个样本;多个样本总是围绕当前片段。计算所有样本的平均结果,并使用此值来调整当前配方中从 phong 着色计算出的漫反射和镜面反射组件的强度。

  • 过滤器大小:内核滤波器维度的选择对抗锯齿边缘的质量有很大影响,但这会以性能为代价。过滤器尺寸越大,质量越好,性能越慢。对于嵌入式平台,处理能力是一个重要的因素。因此,根据我们的需求,当前配方使用 2 x 2 的过滤器(四个样本)可以得到可接受的结果。

如何做到这一点...

由于这项技术基于阴影图,我们建议您重用之前的配方,并在此节中添加一些更改。以下是实现阴影映射源码的步骤:

  1. CustomScene构造函数中,这次创建具有线性过滤的深度纹理;上一个配方使用的是最近邻选项。这种线性过滤以插值方式采样深度值,根据附近深度样本的采样来降低存储值的锐度:

    // Inside CustomScene::CustomScene
    fbo = new FrameBufferObjectSurface();
    fbo->GenerateFBO();
    
    // Generate the depth texture with linear filtering
    depthTexture.generateTexture2D( GL_TEXTURE_2D, 
       fbo->GetWidth(), fbo->GetHeight(),
       GL_DEPTH_COMPONENT32F, GL_FLOAT, GL_DEPTH_COMPONENT, 
       0, true, 0, 0,GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE,
       GL_LINEAR, GL_LINEAR );
    
    // Attached the Depth Buffer to FBO's depth attachment
    fbo->AttachTexture(depthTexture, GL_DEPTH_ATTACHMENT);
    
  2. 取相邻阴影坐标的平均值。在PhongFragment.glsl下的主函数中进行以下更改:

      // Many lines below skipped, please refer to the recipe code 
      void main() { 
      vec3 diff_Spec = PhongShading();
    
      // APPLY the Percentage Closer filtering and use sum 
      // of the contributions from 4 texels around it
      float sum = 0.0;
      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));
    
      ambient    = MaterialAmbient  * LightAmbient;
      // If the fragment is under shadow, use ambient light
      FinalColor = vec4(diff_Spec * sum * 0.25+ ambient, 1.0);
    
      // Correct the Gamma configuration
      FinalColor = pow( FinalColor, vec4(1.0/2.2) );
     }
    

它是如何工作的...

在对每个传入片段的百分位数接近过滤技术中,从过滤区域获得一组样本。这些样本中的每一个都投影到包含参考深度的阴影图上,以从底层查找函数中获得二进制深度结果。阴影图纹理包含来自光源的最接近片段。将这些深度比较组合起来,计算过滤区域中比参考路径更近的 texels 的百分比。这个百分比用于衰减光线。

参见

  • 使用方差阴影映射

使用方差阴影映射

在上一个配方中,我们了解了 PCF 的实现。它产生高质量的柔和阴影。PCF 的问题在于它需要更多的样本来产生更好的结果。此外,像标准纹理一样,无法使用预过滤的米普映射来加速过程。因此,我们必须采样多个 texels 来平均结果,以计算当前 texel 上的光衰减。渲染阴影的整体过程可能会很慢。

通过使用方差阴影映射可以克服 PCF 的这些缺点。这项技术依赖于切比雪夫概率预测,它利用了平均值和方差。平均值可以从阴影映射纹理中简单地获得,而方差(σ²)可以从平均值(E(x))和平均平方值(E(x²))计算得出:

使用方差阴影映射

准备工作

为了实现这个配方,我们将重用我们关于阴影映射的第一个配方。以下指南将帮助您理解方差阴影映射的整体概念:

  1. 创建颜色缓冲区。与通用的阴影映射相比,这个配方使用颜色缓冲区而不是深度缓冲区。因此,现在 FBO 包含的是颜色缓冲区而不是深度缓冲区。

  2. 这是第一遍阶段,场景的深度将被记录在颜色缓冲区中,该缓冲区将存储E(x)E(x²)值。

  3. 计算方差和数量。使用前面的方程式,为第二遍计算方差和数量。

如何操作...

在这个配方中,我们将创建一个新的着色器来记录深度信息。以下是实现阴影映射源码的步骤:

  1. CustomScene类中,定义一个新的Texture变量colorTexture用于颜色缓冲区。在构造函数中,创建一个具有 16 位浮点精度线性过滤的颜色缓冲区。格式类型必须是 RGB 格式:

    // Inside CustomScene::CustomScene
    fbo = new FrameBufferObjectSurface();
    fbo->GenerateFBO();
    
    // Generate the depth texture with linear filtering
    colorTexture.generateTexture2D( GL_TEXTURE_2D, 
       fbo->GetWidth(), fbo->GetHeight(),
       GL_RGB16F, GL_FLOAT, GL_RGB, 
       0, true, 0, 0,GL_CLAMP_TO_EDGE, GL_CLAMP_TO_EDGE,
       GL_LINEAR, GL_LINEAR );
    
    // Attached the Depth Buffer to FBO's depth attachment
    fbo->AttachTexture(colorTexture, GL_COLOR_ATTACHMENT0);
    
  2. 创建一个新的顶点着色器VSMDepthVertex.glsl,并将计算出的顶点位置与片段着色器共享:

    #version 300 es
    layout(location = 0) in vec4  VertexPosition;
    uniform mat4    ModelViewProjectionMatrix;
    out vec4    position;
    
    void main(){
        gl_Position = ModelViewProjectionMatrix * VertexPosition;
        position = gl_Position;
    }
    
  3. 同样,创建一个名为VSMDepthFragment.glsl的片段着色器,并将深度平方信息存储在输出片段的前两个坐标中:

    #version 300 es
    precision mediump float;
    in vec4    position;
    layout(location = 0) out vec4 FinalColor;
    
    void main() {
        float depth = position.z / position.w ;
        //Homogenous to texture coordinate system ([-1,1]) to [0,1] 
        depth = depth * 0.5 + 0.5;
    
        float M1 = depth;           // Moment 1
        float M2 = depth * depth;   // Moment 2
    
        float dx = dFdx(depth);
        float dy = dFdy(depth);
        moment2 += 0.25*(dx*dx+dy*dy) ;
    
        FinalColor = vec4( moment1,moment2, 0.0, 0.0 );
    }
    
  4. 执行第一遍并渲染场景到 FBO。这将使用前面的着色器和颜色缓冲区中的深度值。

  5. 修改现有的PhongFragment.glsl如下。这次,我们将使用 sample 2D 而不是sampler2DShadow,因为我们将使用颜色缓冲区来存储深度信息:

      // Many line below skipped
    
    in vec4    shadowCoord;
    
    uniform sampler2D ShadowMap;
    layout(location = 0) out vec4 FinalColor;
    
    vec3 PhongShading(){ . . . }
    vec4 homogenShadowCoords;
    
    float chebyshevComputeQuantity( float distance){
        // Get the two moments M1 and M2 in moments.x 
        // and moment.y respectively
        vec2 moments = texture(ShadowMap,
                        homogenShadowCoords.xy).rg;
    
        // Current fragment is ahead of the object surface,
        // therefore must be lighted
        if (distance <= moments.x)
            return 1.0 ;
    
        float E_x2 = moments.y;
        float Ex_2 = moments.x * moments.x;
    
        // Computer the variance
        float variance = E_x2 - (Ex_2);
    
        float t = distance - moments.x;
        float pMax = variance / (variance + t*t);
    
        return pMax;
    }
    
    void main() {
        vec3 diff_Spec = PhongShading();
    
        // Calculate the homogenous coordinates
        homogenShadowCoords = shadowCoord/shadowCoord.w;
    
        // Calculate the quantity
        float shadow = chebyshevComputeQuantity(
                              homogenShadowCoords.z);
    
        ambient    = MaterialAmbient  * LightAmbient;
    
        // If the fragment is in shadow, use ambient light only.
        FinalColor = vec4(diff_Spec * shadow + ambient, 1.0);
    
        // Correct the Gamma configuration
        FinalColor = pow( FinalColor, vec4(1.0 / 2.2) );
        return;
    }
    

它是如何工作的...

方差阴影映射通过提供可以线性过滤并可用于支持线性数据的算法和现代图形硬件的深度数据形式,克服了 PCF 的限制。像我们的第一个配方一样,整体算法是相同的,除了现在我们将使用两个分量深度及其平方,并将其存储在 16 位精度颜色缓冲区中。在第一遍中,这个颜色缓冲区存储了在过滤区域深度分布中采样的 M1 和 M2 矩。这种计算发生在VSMDepthFragment.glsl片段着色器中。

在第二次遍历中,颜色缓冲区与phongFragment.glsl片段着色器共享作为样本 2D 均匀量。在执行任何纹理查找之前,将传入的阴影坐标转换为齐次形式。此变换坐标的 z 分量给出了从片段到光线的深度。此深度值用于chebyshevComputeQuantity函数中查找纹理。查找值用于根据之前提到的方程式查找方差,即方程 3。最后,使用方程 5 找到精确的量,这是我们希望计算以执行百分位数接近过滤的量。此函数返回的量或权重值用于根据阴影映射产生阴影。

参见

  • 使用阴影映射创建阴影

  • 使用 PCF 软化阴影边缘

模拟粒子系统

在计算机图形学中,模拟粒子系统是对自然现象的模拟,例如灰尘、烟雾、雨、烟花等。此粒子系统包含大量的小粒子,数量可以从几百到几百万不等。每个单元粒子具有相同的特征,如速度、颜色、寿命等。这些粒子每帧更新一次。在更新过程中,计算并更新粒子的相应特征。因此,它们会移动或看起来改变其颜色。

在这个配方中,我们将实现粒子系统。每个粒子由一个四边形组成,并使用半透明纹理进行纹理化。每个粒子具有特定的颜色,该颜色会随着时间的更新而变化。让我们概述这个配方,以了解粒子系统模拟的实现:

  • 定义粒子属性:这创建了一个数据结构,其中包含顶点的重要属性,包括粒子位置、颜色等。

  • 粒子几何形状:这定义了单个粒子的几何形状。它由四个构成完美正方形的顶点表示,并包含相应的纹理坐标。此粒子对象与视图-投影矩阵结合使用,在 3D 空间中产生多个粒子实例。

  • 初始化:此操作为每个粒子的相应属性分配空间并加载纹理。编译顶点和片段着色器。

  • 更新:此操作在每个帧上更新粒子,计算粒子的新位置和剩余寿命,当较老的粒子死亡时,在每个帧上生成新的粒子

  • 渲染:此操作渲染更新后的粒子:模拟粒子系统

准备中...

此配方使用以下数据结构来管理粒子属性和几何形状:

  • Particle数据结构:

    • pos:这代表当前粒子的位置。

    • vel:这包含粒子的当前速度。

    • life:这代表粒子的剩余生命周期。

    • transform:这包含变换信息。

  • Vertex数据结构:

    • pos:这包含 3D 空间中的顶点位置。

    • texCoord:这是与pos对应的纹理坐标。

  • MeshParticle数据结构:

    • vertices:这包含顶点对象的列表。

    • vertexCount:这表示列表中的顶点数量。

如何实现...

实现粒子系统的步骤如下:

  1. 创建一个名为ParticleSystem的类,它从Model类派生。在构造函数中,加载需要纹理化的粒子四边形表面的纹理图像。所有粒子将共享相同的纹理图像:

        image = new PngImage();
        image->loadImage(fname);
    
  2. 创建ParticleVertex.glsl顶点着色器。此着色器负责使用变换信息更新顶点位置,并将剩余的生命周期和纹理坐标信息与片段着色器共享:

    // ParticleVertex.glsl
    #version 300 es
    
    // Vertex information
    layout(location = 0) in vec3  position;
    layout(location = 1) in vec2  texcoord;
    
    uniform mat4 worldMatrix;
    uniform mat4 viewProjectionMatrix;
    uniform float lifeFactor;
    
    out vec2 texCoord;
    out float life;
    
    void main( void ) {
        texCoord         = texcoord;
        life             = lifeFactor;
        gl_Position      = viewProjectionMatrix*vec4(position, 1.0 );
    }
    
  3. 创建ParticleFragment.glsl片段着色器。此着色器渲染纹理四边形。此外,它使用生命周期来控制粒子的不透明度。当粒子达到其末端时,它会逐渐消失:

    // ParticleFragment.glsl
    
    #version 300 es
    precision mediump float;
    
    uniform sampler2D Tex1;
    in vec2 texCoord;
    in float life;
    
    layout(location = 0) out vec4 outColor;
    
    void main() {
        // directional light
        vec3 lightDir = normalize( vec3( 1.0, 1.0, 1.0 ) );
        // diffuse
        vec4 diffuseColor = vec4( 1, 1.0 - life, 0, 1 );
        vec4 texColor = texture( Tex1, texCoord );
        diffuseColor *= texColor;
    
        // final color
        vec4 color = vec4( 0.0, 0.0, 0.0, 1.0 );
        color.rgb = clamp( diffuseColor.rgb, 0.0, 1.0 );
        color.a = diffuseColor.a * life;
    
        // save it out
        outColor = vec4(texColor.xyz, 1.0);
        outColor = diffuseColor;
    }
    
  4. 在粒子系统的初始化过程中,使用DrawShader()编译和链接着色器程序。同时,使用InitParticles()初始化粒子:

    void ParticleSystem::InitModel(){
        DrawShader();      // Initialize the shader
        InitParticles();   // Initialize the particles
        Model::InitModel();// Call the base class
    }
    
  5. 实现DrawShader函数;此函数编译和链接着色器。它从顶点和片段着色器程序中加载必要的统一变量:

    void ParticleSystem::DrawShader(){
    
       // Load the shader file here, many lines skipped below
       . . . . . .      
    
       // Use the compiled program 
       glUseProgram( program->ProgramID );
    
       // Load the uniform variable from the shader files.
       TEX = GetUniform( program, (char *) "Tex1" );
       worldUniform = GetUniform(program,(char*)"worldMatrix");
       viewProjectionUniform = GetUniform( program, 
       (char *) "viewProjectionMatrix" );
       life = GetUniform( program, (char *) "lifeFactor" );
    
       // Allocate the memory for Particle System. The
       // particle count are contained in the MAX_PARTICLES.
       particles = (Particle*)malloc(sizeof(Particle)*MAX_PARTICLES);
    
       // Start position of each particle (0.0, 0.0, 0.0)
       sourcePosition = glm::vec3(0.0, 0.0, 0.0);
    }
    
  6. InitParticles函数定义粒子的几何形状。不需要为N个粒子创建N个几何形状。我们将创建一个并重复使用它来处理所有粒子。此外,此函数还初始化所有粒子。它为每个粒子提供随机速度,水平方向上从-22单位每微秒,垂直方向上从48

    void ParticleSystem::InitParticles(){
        // define the type of mesh to use for the particles
        particleMesh        = CreateQuadrilateral();
    
       // define the type of mesh to use for the particles
        particleMesh        = CreateQuadrilateral();
    
        float lowestSpeed, highestSpeed, rangeSpeed;
        lowestSpeed = highestSpeed = rangeSpeed = 1.0f;
    
        for( ii = 0; ii < MAX_PARTICLES; ++ii ){
            Particle* p  = &particles[ ii ];
            p->transform = mat4();
            p->pos       = sourcePosition;
            p->life      = -1.0f;
            p->transform = translate(p->transform,p->pos);
            lowestSpeed  = -2.0;
            highestSpeed = 2.0f;
            rangeSpeed   = ( highestSpeed - lowestSpeed ) + 1;
            float f      = (float)(lowestSpeed + (rangeSpeed * 
                                rand() / (RAND_MAX + 1.0f) ) );
            p->vel.x     = f;
            lowestSpeed  = 4.0;
            highestSpeed = 8.0f;
            rangeSpeed   = ( highestSpeed - lowestSpeed ) + 1;
            f            = (float)(lowestSpeed + (rangeSpeed *
                                rand() / (RAND_MAX + 1.0f) ) );
            p->vel.y     = f;
            p->vel.z     = 0;
        }
    }
    
  7. CreateQuadrilateral函数中定义粒子的几何形状:

        MeshParticle* ParticleSystem::CreateQuadrilateral( void )
    {
        // Quadrilateral made of 2 triangle=>[0,1,2] & [0,2,3]
        //  1-------0
        //  |     / |
        //  |   /   |
        //  | /     |
        //  2-------3
    
        // Interleaved square vertices with position & tex 
        const Vertex quadVertices[] ={
        // Triangle 1: Orientation [ 0, 1, 2 ]
        { {  1.0f,  1.0f,  0.0f },  { 1.0f, 1.0f } },
        { { -1.0f,  1.0f,  0.0f },  { 0.0f, 1.0f } },
        { { -1.0f, -1.0f,  0.0f },  { 0.0f, 0.0f } },
    
        // Triangle 2: Orientation [ 0, 2, 3 ]
        { {  1.0f,  1.0f,  0.0f },  { 1.0f, 1.0f } },
        { { -1.0f, -1.0f,  0.0f },  { 0.0f, 0.0f } },
        { {  1.0f, -1.0f,  0.0f },  { 1.0f, 0.0f } },
        };
    
        // Allocate memory for particle geometry datastructure
        const int Count   = 6;
        MeshParticle* quad = ( MeshParticle* )malloc
        ( sizeof( MeshParticle ) );
        quad->vertices     = (Vertex*)malloc(sizeof(Vertex) * Count);
        memcpy( quad->vertices, quadVertices, Count*sizeof(Vertex) );
        quad->vertexCount  = quadVertexCount;
        return quad;
    }
    
  8. Update()函数中,计算当前帧和上一帧之间的相对差异。这次,这个差异被EmitParticles函数用来根据粒子的速度更新给定粒子的新位置:

    void ParticleSystem::Update (){
        static clock_t lastTime = clock();
        clock_t currentTime     = clock();
        float deltaTime  = (currentTime - lastTime) /
                           (float)(CLOCKS_PER_SEC);
        lastTime         = currentTime;
    
        // update attribute for the particle emission 
        EmitParticles( deltaTime );
        return;
    }
    
  9. 实现如以下代码所示的EmitParticles()函数。此函数负责更新粒子。该函数遍历每个粒子并更新其位置和减少其生命周期。当粒子的生命周期变为零或更少时,它被认为是死亡的。在粒子死亡的情况下,新的粒子将被重新生成:

    void ParticleSystem::EmitParticles(float elapsedTime ){
        static float fRotation = 0.0f;
        if(fRotation>360.0){
            fRotation = 0.0;
        }
    
        int spawn   = 0;
    
        for(unsigned ii = 0; ii < MAX_PARTICLES; ++ii ){
            Particle* p = &particles[ ii ];
    
            // Living particles
            if(particle->life > 0.0f){
                unsigned int bIsEven = ( ( ii % 2 ) == 0 ) ? 1 : 0;
                particle->transform  = rotate( particle->transform, 
               (bIsEven) ? fRotation : -fRotation, vec3(0.0,0.0,1.0));
               vec3 vel              = p->vel/100.0f * elapsedTime;
                p->pos               = p->pos + vel;
    
                p->life           -= p->vel.y * elapsedTime;
                p->transform       = translate( p->transform, p->pos);
            }
    
           // Dead particles. Re-spawn more
            else{
                // Re-Spawn a max of 10 particles every frame
                if( spawn++ > 10 ) { continue; }
                particle->pos       = sourcePosition;
                particle->life      = MAX_LIFE;
                particle->transform = mat4();
            }
    
            float fScaleFactor = 1.0+(particle->pos.y * 0.25f);
            p->transform = scale(p->transform, 
               vec3( fScaleFactor, fScaleFactor, fScaleFactor ));
        }
    }
    
  10. 实现RenderParticles()。此函数在渲染之前首先更新粒子:

    void ParticleSystem::RenderParticles(){
        // Set the shader program
        glUseProgram( program->ProgramID );
    
        // All the particles are using the same texture, so it
        // only needs to be set once for all the particles
        glEnable(GL_BLEND);
        glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);
        glActiveTexture( GL_TEXTURE0 );
        if(image){
        glBindTexture( GL_TEXTURE_2D, image->getTextureID() );
        // Apply texture filter, below many lines are skipped...
    
        }
    
        glUniform1i( TEX, 0 );
        mat4 viewProj=*TransformObj-> 
                       TransformGetModelViewProjectionMatrix();
    
        // Loop through the particles
        unsigned int ii = 0;
        for( ii = 0; ii < MAX_PARTICLES; ++ii )
        {
            // Current particle
            Particle* p = &particles[ ii ];
    
            // Pointer to the particle mesh
            MeshParticle* pMesh = particleMesh;
    
            // Only draw the particle if it is alive
            if( p->life > 0.0f ){
               // Set the particle transform uniform
                glUniformMatrix4fv( worldUniform, 1, 
               GL_FALSE, ( const GLfloat* )&p->transform );
    
               // Set view and projection matrices
                glm::mat4 mvp = viewProj * p->transform ;
                glUniformMatrix4fv( viewProjectionUniform, 
                    1, GL_FALSE, ( const GLfloat* )&mvp );
    
                // Send the remaining life span.
                glUniform1f( life, p->life / MAX_LIFE );
    
                // Enable and Set the vertex attributes:-
                // position, texture coords
                glEnableVertexAttribArray( VERTEX_POSITION );
                glEnableVertexAttribArray( TEX_COORD );
                glVertexAttribPointer( VERTEX_POSITION, 3, GL_FLOAT, 
                GL_FALSE, sizeof( Vertex ), &pMesh->vertices->pos );
                glVertexAttribPointer( TEX_COORD, 2, GL_FLOAT, 
                GL_FALSE, sizeof( Vertex ), 
                &pMesh->vertices->texCoord );
    
                glDrawArrays( GL_TRIANGLES, 0, pMesh->vertexCount );
            }
        }
    }
    

它是如何工作的...

ParticleSystem 类管理粒子系统的生命周期。在程序初始化过程中,每个粒子被赋予一个特定的位置、速度、寿命和颜色。系统中的粒子以数组格式存储,形成一个数据池。CPU 负责更新粒子信息,并将更新后的信息发送到 GPU 以在屏幕上渲染。这不是一个非常高效的机制,因为 CPU 在处理粒子并将它们发送到 GPU 上非常忙碌。在下一个菜谱中,你将学习如何使用变换反馈高效地渲染粒子系统。在这里,我们将使用点精灵而不是将它们作为纹理四边形来实现粒子:

工作原理...

在初始化过程中,所有粒子在 X-Y 方向上以随机速度分布,范围分别为 [-2 2][4 8]。粒子的下一个位置通过将当前位置与时间差(相对于粒子上次更新的时间差)和相应速度的乘积相加来更新。蓝色箭头显示了二维空间中速度向量的随机分布。

每次更新时,每个粒子的生命周期都会缩短,最终达到其消亡点,此时粒子不再活跃或可见于屏幕上。死亡的粒子仍然保留在数据池中,可以重新初始化。这样,我们可以有效地重用相同的内存,而不是分配新的内存。在这个菜谱中,我们在渲染时一次性重新启动 10 个粒子。

粒子的大小会随着它们在 Y 方向上的上升而缩放。这个信息是从粒子当前位置的 y 分量中收集的。我们使用一些常数和一些调整来控制缩放,以实现可控的机制。最后,当位置更新并应用变换时,粒子可以发送到 GPU 端进行渲染。

参见

  • 请参阅 使用 UV 映射应用纹理 菜谱 第七章,纹理和映射技术

使用同步对象和栅栏的变换反馈粒子系统

之前的粒子系统示例演示了具有高度 CPU 绑定的操作的粒子动画。通常,顶点的核心参数,如颜色、位置和速度,总是在 CPU 端计算。顶点信息以正向流动。在这里,数据信息始终从 CPU 发送到 GPU,并在后续帧中重复。这种做法会产生延迟,因为必须为从 CPU 到 GPU 的延迟付费。

然而,如果顶点在 GPU 上处理并在下一帧中重用,那将是非常棒的。这就是新 OpenGL ES 3.0 功能变换反馈发挥作用的地方。这是从顶点着色器捕获输出并将其反馈到 GPU 以供下一帧使用的过程。这样,它避免了 CPU 干预,并通过大量的 GPU 并行处理使渲染效率更高。通常,在这个过程中,VBO 缓冲区充当特殊缓冲区,连接到顶点着色器,并在其中收集变换后的原语顶点。此外,我们还可以决定原语是否将继续其常规路线到光栅化器。

在这个配方中,我们将使用变换反馈功能实现粒子系统,其中顶点参数,如速度、寿命、加速度等,在顶点着色器上计算。翻译后的参数存储在 GPU 内存中,并馈送到下一帧迭代。此外,我们将通过使用点精灵而不是四边形来提高效率。

注意

此配方还实现了 OpenGL ES 3.0 的另一个新功能,称为同步对象和栅栏。栅栏是一种机制,通过该机制应用程序通知 GPU 等待直到某个 OpenGL ES 特定操作未完成。这样,可以防止 GPU 将更多操作堆积到命令队列中。栅栏命令可以像任何其他命令一样插入到 GL 命令流中。它需要与等待的同步对象关联。同步对象非常高效,因为它们允许您等待 GL 命令的部分完成。

准备工作

本节提供了使用变换反馈实现粒子系统的高级概述:

  1. 需要两个着色器:UpdateDraw。前者更新或处理粒子的发射数据,后者使用更新后的数据来渲染粒子。

  2. 在初始化过程中,分配两个缓冲区对象来存储粒子数据。这包括位置、大小、速度、颜色和寿命。这些缓冲区将以乒乓方式使用,其中一个缓冲区的输出成为下一个循环或帧中另一个缓冲区的输入,反之亦然。

  3. 在渲染时,使用一个 VBO 作为输入,另一个作为输出,通过将前者绑定为GL_ARRAY_BUFFER,后者绑定为GL_TRANSFORM_FEEDBACK

  4. 通过禁用GL_RASTERIZER_DISCARD禁止绘制片段。

  5. 使用点原语(GL_POINTS)执行更新着色器。每个粒子都表示为一个点。顶点着色器从第一个 VBO 接收输入,并将处理后的数据发送到第二个 VBO,该 VBO 充当变换反馈输出缓冲区。

  6. 这启用了GL_RASTERIZER_DISCARD以丢弃片段绘制。

  7. 这使用第二个 VBO,其中包含处理后的数据,并将其作为GL_ARRAY_BUFFER边界发送到绘制着色器,以渲染粒子。

  8. 最后,一旦渲染了帧,交换两个 VBO。

如何操作...

实现变换反馈食谱的步骤如下:

  1. 使用以下代码创建名为 TFUpdateVert.glsl 的更新顶点着色器。这个着色器定义了用于粒子系统的各种属性;每个属性都分配了特定的位置。这个着色器负责接收属性数据并更新它们。更新后的属性通过输出变量发送到下一个阶段:

    #version 300 es
    #define NUM_PARTICLES           200
    #define ATTRIBUTE_POSITION      0                                 
    #define ATTRIBUTE_VELOCITY      1                                 
    #define ATTRIBUTE_SIZE          2                                 
    #define ATTRIBUTE_CURTIME       3                                 
    #define ATTRIBUTE_LIFETIME      4                                 
    uniform float               time;
    uniform float               emissionRate;
    uniform mediump sampler3D   noiseTex;
    
    layout(location = ATTRIBUTE_POSITION) in vec2   inPosition;
    layout(location = ATTRIBUTE_VELOCITY) in vec2   inVelocity;
    layout(location = ATTRIBUTE_SIZE) in float      inSize;
    layout(location = ATTRIBUTE_CURTIME) in float   inCurrentTime;
    layout(location = ATTRIBUTE_LIFETIME) in float  inLifeTime;
    
    out vec2    position;
    out vec2    velocity;
    out float   size;
    out float   currentTime;
    out float   lifeTime;
    
    float randomValue( inout float seed ){                                                                 
       float vertexId   = float(gl_VertexID) / float(NUM_PARTICLES);
       vec3 texCoord    = vec3( time, vertexId, seed );
       seed             += 0.41;//(.10/float( NUM_PARTICLES ));
       return texture( noiseTex, texCoord ).r;
    }                                                                 
    
    void main(){                                                                 
        float seed      = time;
        float lifetime  = (inCurrentTime - time)*10.0;
        if( lifetime <= 0.0 && randomValue(seed) < emissionRate )
        {
            position       = vec2( 0.0, -1.0 );
            velocity       = vec2( randomValue(seed) * 2.0 - 1.00,
                                  randomValue(seed)  + 3.0 );
            size           = randomValue(seed) * 20.0;
            currentTime    = time;
            lifeTime       = 5.0;
        }
        else{
            position = inPosition; velocity   = inVelocity;
            size      = inSize;  currentTime  = inCurrentTime;
            lifeTime = inLifeTime;
        }
        gl_Position = vec4( position, 0.0, 1.0 );
    }
    
  2. 创建名为 TFUpdateFrag.glsl 的更新片段着色器。这个着色器仅作为片段着色的占位符,以便可以执行着色器的编译。这个着色器永远不会出现,因为在更新期间关闭了光栅化:

    #version 300 es                         
    precision mediump float;                
    layout(location = 0) out vec4 fragColor;
    void main(){                                       
      fragColor = vec4(1.0);                
    }
    
  3. 为渲染阶段创建一个名为 TFDrawVert.glsl 的顶点着色器。这个着色器负责在屏幕上渲染更新的数据:

    #version 300 es                                              
    #define ATTRIBUTE_POSITION      0                             
    #define ATTRIBUTE_VELOCITY      1                             
    #define ATTRIBUTE_SIZE          2                             
    #define ATTRIBUTE_CURTIME       3                             
    #define ATTRIBUTE_LIFETIME      4                             
    
    layout(location = ATTRIBUTE_POSITION) in vec2   inPosition;
    layout(location = ATTRIBUTE_VELOCITY) in vec2   inVelocity;
    layout(location = ATTRIBUTE_SIZE) in float      inSize;
    layout(location = ATTRIBUTE_CURTIME) in float   inCurrentTime;
    layout(location = ATTRIBUTE_LIFETIME) in float  inLifeTime;
    
    uniform float   time;
    uniform vec2    acceleration;
    uniform mat4    ModelViewProjectMatrix;
    
    void main(){                                                             
      float deltaTime = (time - inCurrentTime)/10.0;
      if ( deltaTime <= inLifeTime ){ 
         vec2 velocity = inVelocity + deltaTime * acceleration;
         vec2 position = inPosition + deltaTime * velocity;
         gl_Position   = ModelViewProjectMatrix
                                    *vec4(position, 0.0, 1.0);
         gl_PointSize  = inSize * ( 1.0 - deltaTime / inLifeTime );
      }                                                           
      else{                                                     
         gl_Position    = vec4( -1000, -1000, 0, 0 );
         gl_PointSize   = 0.0;
      }
    }
    
  4. 在渲染到 TFDrawFrag.glsl 时着色片段:

    #version 300 es                                  
    precision mediump float;                         
    layout(location = 0) out vec4 fragColor;         
    uniform vec4 color;
    uniform sampler2D tex;
    
    void main(){                                                
      vec4 texColor = texture( tex, gl_PointCoord );
      fragColor     = texColor * color;
    }
    
  5. Model 基类派生 ParticleSystem.h/.cpp 并实现 EmitShader() 函数。这个函数将编译 TFUpdateVert.glslTFUpdateFrag.glsl 着色器文件:

       void ParticleSystem::EmitShader(){
       program = ProgramManagerObj->ProgramLoad((char*) "TFEmit",
       VERTEX_SHADER_PRG_EMIT, FRAGMENT_SHADER_PRG_EMIT);
    
       glUseProgram( program->ProgramID );
       emitProgramObject = program->ProgramID;
    
       const char *feedbackVaryings[5] = { "position", "velocity", 
       "size", "currentTime", "lifeTime" };
    
       // Set vertex shader outputs as transform feedback
       glTransformFeedbackVaryings ( emitProgramObject, 5,
       feedbackVaryings, GL_INTERLEAVED_ATTRIBS );
    
       // Link program after calling glTransformFeedbackVaryings
       glLinkProgram ( program );
    
       emitTimeLoc = GetUniform(program,"time");
       emitEmissionRateLoc = GetUniform( program, "emissionRate" );
       emitNoiseSamplerLoc = GetUniform(program, "noiseTex" );
    }
    

    着色器编译完成后,使用 glTransformFeedbackVaryings API 指定在变换反馈中要捕获的属性:

    • 语法

      void glTransformFeedbackVaryings(GLuint program, GLsizei count, const char ** varyings, GLenum bufferMode);
      
      变量 描述
      program 这是程序对象的句柄。
      count 这指定了在变换反馈过程中使用的顶点输出变量的数量。
      varying 这是一个零终止字符串数组,指定了用于变换反馈的变量名称。
      bufferMode 这指定了变换反馈激活时顶点输出变量数据捕获的模式。这个变量可以接受两个枚举:GL_INTERLEAVED_ATTRIBSGL_SEPARATE_ATTRIBS。前者指定如何在一个缓冲区中捕获输出变量。然而,后者在每个缓冲区中捕获每个顶点变量的输出。

    我们感兴趣的是在变换反馈中捕获五个顶点输出变量:positionvelocitysizecurrentTimelifeTime

    注意

    glTransformFeedbackVarying 总是在链接程序之前调用。因此,有必要使用 glLinkProgram 链接程序对象。

  6. 在同一文件中实现 DrawShader() 函数。这个函数将编译 TFDrawVert.glslTFDrawFrag.glsl

    void ParticleSystem::DrawShader(){
        program = ProgramManagerObj->ProgramLoad((char*)"TFDraw", 
             VERTEX_SHADER_PRG_DRAW, FRAGMENT_SHADER_PRG_DRAW); 
        glUseProgram( program->ProgramID );
    
        MVP = GetUniform( program,(char*)"ModelViewProjectMatrix");
    
        // Load the shaders and get a linked program object
        drawProgramObject = program->ProgramID;
    
        // Get the uniform locations
        drawTimeLoc   = GetUniform(drawProgramObject,"time");
        drawColorLoc  = GetUniform(drawProgramObject,"color");
        drawAccelerationLoc = GetUniform(program, "acceleration");
        samplerLoc  = GetUniform (program, "tex");
    }
    
  7. ParticleSystem::InitParticles() 中初始化粒子系统。这个函数初始化包含各种粒子属性的粒子对象数组。初始化后,这些对象存储在两个不同的 VBO 缓冲对象粒子 VBO 中。这些缓冲区由变换反馈以乒乓方式更新 VBO 中的元素,如前述代码所述:

       void ParticleSystem::InitParticles(){
    
       time        = 0.0f; 
       curSrcIndex  = 0; 
       textureId    = image->getTextureID();
    
       if(textureId <= 0){ return; }
    
       // Create a 3D noise texture for random values
       noiseTextureId = Create3DNoiseTexture ( 128, 50.0 );
       Particle particleData[ NUM_PARTICLES ];
    
       // Initialize particle data
       for ( int i = 0; i < NUM_PARTICLES; i++ ){
          Particle *particle     = &particleData[i];
          particle->position[0]  = 0.0f; 
          particle->position[1]  = 0.0f;
          particle->velocity[0]  = 0.0f; 
          particle->velocity[1]  = 0.0f;
          particle->size         = 0.0f;  
          particle->curtime      = 0.0f;
            particle->lifetime       = 0.0f;
       }
    
       // Create the particle VBOs
       glGenBuffers ( 2, &particleVBOs[0] );
    
       for ( int i = 0; i < 2; i++ ) {
       glBindBuffer ( GL_ARRAY_BUFFER, particleVBOs[i] );
       glBufferData ( GL_ARRAY_BUFFER, sizeof ( Particle ) * 
       NUM_PARTICLES, particleData, GL_DYNAMIC_COPY );
       }
    }
    
  8. InitModel 中,初始化系统如下:

    void ParticleSystem::InitModel(){
        UpdateShader();
        DrawShader();
        InitParticles();
        Model::InitModel();
        return;
    }
    
  9. Emitparticles() 函数中使用时间和更新粒子系统:

        void ParticleSystem::Update (){
        static clock_t lastTime = clock();
        clock_t currentTime     = clock();
        float deltaTime         = (currentTime - lastTime)/
                                   CLOCKS_PER_SEC*0.10;
        lastTime                = currentTime;
        time                    += deltaTime;
    
        EmitParticles ( deltaTime );
    }
    
  10. Emitparticles() 函数在每帧渲染时翻转两个 VBO 缓冲区。这样,一个 VBO 成为更新着色器的输入(称为源 VBO),而另一个则捕获处理过的输出变量(称为目标 VBO),反之亦然。使用更新的着色器程序,通过 glBindBuffer API 使用 GL_TRANSFORM_FEEDBACKglBindBufferBase 将源 VBO 数据绑定到目标 VBO 并设置为变换反馈缓冲区,以捕获结果。

    在更新阶段,我们只对计算粒子数据感兴趣。因此,我们可以禁用光栅化过程:

        void ParticleSystem::EmitParticles(float deltaTime ){
        //UserData *userData = esContext->userData;
        GLuint srcVBO = particleVBOs[ curSrcIndex ];
        GLuint dstVBO = particleVBOs[ ( curSrcIndex + 1 ) % 2 ];
    
        glUseProgram ( emitProgramObject );
    
        // transform feedback buffer
        SetupVertexAttributes ( srcVBO );
    
        // Set transform feedback buffer
        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, dstVBO);
        glBindBufferBase (GL_TRANSFORM_FEEDBACK_BUFFER, 0, dstVBO);
    
        // Turn off rasterization - we are not drawing
        glEnable(GL_RASTERIZER_DISCARD);
    
        // Set uniforms
        glUniform1f(emitTimeLoc, time);
        glUniform1f(emitEmissionRateLoc, EMISSION_RATE);
    
        // Bind the 3D noise texture
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_3D, noiseTextureId);
        glUniform1i(emitNoiseSamplerLoc, 0);
    
        // Emit particles using transform feedback
        glBeginTransformFeedback(GL_POINTS);
        glDrawArrays(GL_POINTS, 0, NUM_PARTICLES);
        glEndTransformFeedback();
    
        // Ensure transform feedback results are completed
        // before the draw that uses them.
        emitSync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
    
        //Allows fragment drawing
        glDisable ( GL_RASTERIZER_DISCARD ); 
        glUseProgram ( 0 );
        glBindBufferBase ( GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0 );
        glBindBuffer ( GL_ARRAY_BUFFER, 0 );
        glBindTexture ( GL_TEXTURE_3D, 0 );
    
        // Ping pong the buffers
        curSrcIndex = ( curSrcIndex + 1 ) % 2;
    }
    

    变换反馈可以使用以下 API 语法开始和结束:

    void glBeginTransformFeedback(GLenum primitiveMode);
    void glEndTransformFeedback();
    
    变量 描述
    primitiveMode 这指定了需要在附加到变换反馈缓冲区的变换中捕获的原始类型。可接受的参数是 GL_POINTGL_LINESGL_TRIANGLES

    确保顶点输出变量写入变换反馈附加缓冲区非常重要,这样绘图命令才能安全地使用它。通过创建栅栏来实现更新操作和绘图操作之间的一致性要求。变换反馈操作激活后立即创建一个栅栏。这个栅栏与一个同步对象相关联,它在渲染例程中等待,直到变换反馈操作未完成。

  11. RenderParticles() 函数执行绘图任务。它等待同步对象以确保变换反馈操作成功完成。一旦完成,同步对象被删除,并调用绘图 API 使用粒子系统渲染场景:

    void ParticleSystem::RenderParticles(){
        // Make sure that the GL server blocked until
        // transform feedback output is not captured.
        glWaitSync ( emitSync, 0, GL_TIMEOUT_IGNORED );
        glDeleteSync ( emitSync );
        glUseProgram(drawProgramObject);
    
        // Load the VBO and vertex attributes
        SetupVertexAttributes ( particleVBOs[ curSrcIndex ] );
        glUniformMatrix4fv( MVP, 1, GL_FALSE,(float*) 
          TransformObj->TransformGetModelViewProjectionMatrix());
    
        glUniform1f ( drawTimeLoc, time );
        glUniform4f ( drawColorLoc, 1.0f, 1.0f, 1.0f, 1.0f );
        glUniform2f ( drawAccelerationLoc, 0.0f, ACCELERATION );
    
        glEnable ( GL_BLEND );
        glBlendFunc ( GL_SRC_ALPHA, GL_ONE );
    
        // Bind the texture 
        glActiveTexture ( GL_TEXTURE0 );  
        glBindTexture ( GL_TEXTURE_2D, textureId );
    
        // Set the sampler texture unit to 0
        glUniform1i ( samplerLoc, 0 );
        glDrawArrays ( GL_POINTS, 0, NUM_PARTICLES );
    }
    

它是如何工作的...

变换反馈是 OpenGL ES 可编程管道中的一个特殊阶段。它位于顶点着色器之后,如下面的图像所示。当变换反馈被激活时,它将顶点着色器的输出重定向到变换反馈。变换反馈注册了所有需要捕获的顶点输出变量。数据变量在特殊的乒乓 VBO 缓冲区中捕获:

它是如何工作的...

在初始化过程中,创建了两个顶点缓冲对象,并在其中设置了必要的粒子数据。这些 VBOs 被附加到变换反馈中,并且每帧进行交换。这样,一个 VBO 包含输入数据并捕获处理过的变量,反之亦然。

每次执行变换反馈时,都会创建一个相应的栅栏来确认变换反馈的完成。这个栅栏与一个同步对象相关联,它在渲染函数中等待栅栏。当栅栏被信号时,等待结束,并执行渲染命令以渲染粒子系统。

注意

粒子用GL_POINTS表示,其中每个点代表一个微小的正方形。这个命令告诉 GPU 将每个顶点绘制为一个正方形。点的大小可以通过gl_PointSize进行调整。与之前的配方相比,精灵方法将表示四边形所需的顶点数量从四个减少到一个。点精灵是 GPU 内置功能,其中每个点(代表一个正方形)面向相机。这些可以不提供纹理坐标而使用图像进行纹理化,这使得它在粒子渲染中非常高效。

参见

  • 请参考第六章中的使用纹理坐标进行过程纹理着色配方,使用着色器

  • 请参考第三章中的简介部分,OpenGL ES 3.0 新特性

  • 请参考附录中的Swizzling配方,OpenGL ES 3.0 补充信息

附录 A. OpenGL ES 3.0 补充信息

在本附录中,我们将涵盖以下配方:

  • 固定功能管线和可编程管线架构

  • OpenGL ES 3.0 的软件要求 – Android ADT

  • 在 Android Studio 中使用 OpenGL ES 3.0 开发 Hello World 三角形应用程序

  • OpenGL ES 3.0 的软件要求 – iOS

  • 在 Android 和 iOS 中打开示例项目

  • 拉姆伯特余弦定律的应用

  • 计算两个向量之间的余弦值

  • Swizzling

固定功能管线和可编程管线架构

在我们深入研究 OpenGL ES 编程之前,了解底层架构是如何堆叠的非常重要。OpenGL ES 有两种类型的架构:固定管线和可编程管线。本节将为您提供一个这些架构的简单概述;这个概述也将帮助我们掌握计算机图形术语的技术术语。

固定管线架构

以下图像显示了 OpenGL ES 1.1 固定功能管线架构。它还提供了从输入数据发送到渲染引擎以在屏幕上生成图像的事件序列。

固定管线架构

输入指的是渲染引擎绘制屏幕上的对象所需的原始数据和绘图信息。例如,前面的图像显示了三个顶点,并提供了三个颜色数据作为原始数据提供给图形引擎。此外,我们还指定了将此原始数据以三角形形式绘制的引擎。

顶点操作中,对输入顶点坐标进行变换。每个几何输入顶点基于相机视图或对象平移进行变换。

更具体地说,在这个阶段,执行建模变换以将对象坐标转换为世界空间坐标。进一步,这些坐标通过视图变换转换为视点空间坐标。对于所有顶点,根据这些变换计算光信息和纹理坐标。第二章,OpenGL ES 3.0 基础知识,涵盖了我们在“使用模型、视图和投影类比进行变换”配方中使用的所有技术术语。

原始装配从前一阶段的变换坐标中获取所有坐标,并根据输入阶段提供的指定绘制或原始类型(点、线、三角形)信息进行排列。例如,我们提供了三个顶点,并指示引擎将它们渲染为三角形。OpenGL ES 中基本上有三种原始类型可用:点、线和三角形(以及线和三角形的变体)。这三种基本原始类型可以用来渲染任何复杂的几何形状。

裁剪视口剔除阶段,应用投影变换以生成裁剪空间坐标。在这里,位于相机视锥体外的顶点被丢弃。结果顶点坐标经过透视除法处理,生成归一化设备坐标。最后,应用视口变换将归一化设备坐标归一化,形成屏幕空间像素坐标。基于面方向(如指定给图形引擎的方向),进行面剔除。

光栅化是将变换后的屏幕空间原语(点、线和三角形)转换为称为片段的离散元素的过程。每个片段的输出是屏幕坐标和相关属性,如颜色、纹理坐标、深度和模板。

片段处理阶段处理光栅化阶段生成的每个片段。此阶段使用颜色或纹理信息处理片段的外观信息。

每片段操作阶段在屏幕上渲染图像之前执行一些重要的测试。它包括:

  • 像素所有权测试:这是一个测试,用于检查光栅化阶段生成的像素屏幕坐标是否属于 OpenGL ES 上下文。例如,渲染屏幕可能被一些文本消息覆盖或被其他窗口遮挡。

  • 剪刀测试:此阶段确保剪刀矩形区域四个值形成的矩形外的片段在渲染时不应被考虑。

  • 模板和深度测试:此测试检查模板和深度值,以确定片段是否需要被丢弃。例如,如果有两个原语相互遮挡,OpenGL ES 状态将保留顶部的原语片段。然而,属于后面的片段将被丢弃,无论渲染顺序如何。

  • 混合:这是一个生成新颜色信息的过程,使用之前在同一颜色缓冲区位置指定的颜色。

  • 抖动:这项技术使用现有颜色来创建其他颜色的效果。例如,可以使用白色和黑色生成的各种图案来产生各种灰度色调。

程序化管道架构

与固定功能管道不同,可编程管道架构提供了修改图形管道某些阶段的灵活性。OpenGL ES 2.0 和 3.0 遵循可编程管道架构。这些阶段是通过称为着色器的特殊程序进行修改的。以下图像显示了 OpenGL ES 3.0 的可编程管道架构。2.0 的架构也与以下图像相似,只是它不支持一个称为变换反馈的特殊阶段。变换反馈是 OpenGL ES 3.0 中引入的新阶段。这个阶段负责在几何着色阶段之后捕获处理过的顶点数据缓冲区。以下图像中用绿色框表示的这些可编程阶段。开发者需要编写着色器来使用 OpenGL ES 3.0 渲染对象。

可编程管道架构需要至少两个着色器,即顶点着色器和片段着色器,以在屏幕上渲染几何图形。没有这些着色器,渲染是不可能的。

  • 顶点着色器是可编程管道架构中的第一个着色器。它的职责是对顶点坐标进行处理以产生坐标变换。在大多数情况下,它用于从模型、视图和投影信息中计算裁剪坐标。以下是一个顶点着色器的示例:

    #version 300 es
    in vec4 VertexPosition;        
    void main() {                  
      gl_Position = VertexPosition;
    };
    
  • 片段着色器是最后一个在像素级别工作的着色器;它使用光栅化阶段的输出数据,该阶段生成基本片段。这个着色器负责计算屏幕上每个渲染对象的每个片段的颜色。片段着色器还能够将纹理应用于片段着色器。以下是一个片段着色器的示例:

    #version 300 es         
    precision mediump float;
    out vec4 FragColor;     
    void main() {           
      FragColor = vec4(0.0, 0.30, 0.60, 0.0);
    };
    

    可编程管道架构

可编程管道架构需要一种特殊的语言来编写着色器。这种语言被称为 OpenGL ES 着色语言。在这本书中,我们将使用 OpenGL ES 着色语言 3.0 的规范。

OpenGL ES 3.0 的软件要求 – Android ADT

在上一节中,我们已经实现了 OpenGL ES 3.0 中我们的第一个简单程序源代码。我们将使用相同的程序在 Android 和 iOS 平台上渲染输出。本节将涵盖我们在 Android 平台上开发 OpenGL ES 3.0 应用程序所需的所有基本要求。

Android 是一个基于 Linux 的操作系统;因此,其大部分开发和配置都需要基于 UNIX 的工具。本节讨论了在 Android 上开发 OpenGL ES 3.0 的所有先决条件。

Android 支持两种方式开发 OpenGL ES 应用程序:Java 框架 API 和本地开发工具包NDK)。OpenGL ES 3.0 的 Java 框架 API 专注于 Java 代码风格开发。因此,如果您完全使用 Java 代码开发应用程序,您可以在基于 Java 的应用程序框架内构建 OpenGL ES 3.0 代码。相比之下,NDK 使用 C/C++语言构建 OpenGL ES 3.0 应用程序。这对于有兴趣使用 C/C++语言开发 OpenGL ES 应用程序的开发者来说更为合适。额外的优势是,相同的代码可以在支持 C/C++语言的多个平台上使用,例如 iOS、BlackBerry、Windows 等。JNI 作为核心 Java 应用程序框架和 NDK C/C++代码之间的接口。

本书侧重于通过 NDK 进行 OpenGL ES 应用程序的本地开发。我们还将看到使用 NDK 相对于 Java 框架 API 的优势。

准备工作

在开始开发会话之前,您必须确保您的机器(Windows/Linux/Mac)满足以下先决条件。下载以下包,然后进入下一节:

如何操作...

ADT 包:Android 开发者工具(ADT)是 Android 软件开发工具包的组合集。它为我们提供了构建 Android 应用程序所需的所有 API、调试器和测试应用程序。它包含各种其他工具,帮助我们分析应用程序,并提供在模拟器上运行应用程序的仿真支持。

根据您的操作系统下载 ADT 包。下载的包将以 ZIP 格式存在;解压缩它。这将提取一个名为adt-bundle-xxxxx的文件夹。名称取决于操作系统及其版本类型:32/64 位。

此提取的 ADT 包包含以下重要文件夹:

  • Eclipse 文件夹:此文件夹包含 Eclipse IDE,这是一个用于开发 Android 应用程序的集成环境。这个特殊的 Eclipse 允许用户快速设置新的 Android 项目,添加框架包,创建 UI,导出.apk,并提供许多其他功能。

  • SDK 文件夹:此文件夹包含用于开发和调试您的应用程序的工具;支持 Android 平台新特性的工具,示例应用程序,文档,系统映像;以及在新平台发布时可用依赖于 SDK 的工具。有关 SDK 的更多信息,请参阅developer.android.com/sdk/exploring.html

    注意

    为了更好地进行项目管理,请将您的安装保持在中央位置。我们已创建一个名为 Android 的文件夹,并将 ADT 套件提取到该文件夹中。文件夹名称和位置可以根据您的个人喜好设置。

  • JDK: 根据 ADT 的要求,您可能需要更新 Java 开发工具包(Java Development Kit)。JDK 包含用于开发、调试和监控 Java 应用程序的工具。

    前往之前提到的网址,下载 JDK。最低要求是 JDK 6.0。然而,更高版本也必须能够使用。下载安装程序,并在您的计算机上安装它。JDK 自动包含 Java 运行环境JRE),它包含在您的系统上运行 Java 应用程序所需的所有内容。因此,无需安装任何其他软件包。

  • NDK: 本地开发工具包(Native Development Kit)是一组工具,它帮助开发者使用 C/C++ 语言开发 Android 应用程序的部分功能。它提供了一个接口,使 Java 和 C++ 代码能够相互通信。下载最新的 NDK 软件包,并将其解压缩到我们的 Android 文件夹中。

  • 环境变量:请确保您定义了系统环境变量路径以定位您的 NDK、SDK 和平台工具。这将有助于从命令行终端运行可执行文件。此外,我们还需要定义 ANDROID_HOME 以定位 ADT 套件中的 SDK 文件夹。以下示例显示了在 Mac 操作系统下 .bash_profile 文件中定义这些环境变量的方法。同样,这些需要在其他操作系统中根据它们定义环境变量的方式来定义:

    bash_profile
    PATH=/usr/local/bin:/usr/local/sbin:$PATH
    
    ANDROID_NDK=/Users/parmindersingh/Dev/Android/android-ndk-r9c
    ANDROID_HOME=/Users/parmindersingh/Dev/Android/adt-bundle-mac/sdk
    
    PATH=$ANDROID_NDK:$PATH
    PATH=$ANDROID_HOME/tool:$ANDROID_HOME/platform-tools:$PATH
    export ANDROID_HOME
    
  • Android SDK 管理器:在 ADT 套件文件夹中打开 Eclipse IDE,导航到 窗口 | Android SDK 管理器。安装 Android 4.3 及其相关子组件,如以下截图所示:

    注意

    对于 OpenGL ES 3.0,我们需要 Android 4.3(第 18 级 API)或更高版本。

    如何操作...

  • Cygwin: Cygwin 是一个基于 UNIX 的命令行终端应用程序,它允许 Windows 用户编译和调试基于 UNIX 的应用程序。

    1. 从之前章节中提到的网址下载 setup.exe 并执行它。这将打开应用程序的安装界面。在每个窗口上点击默认选择,然后点击 下一步 按钮,直到不再出现需要安装的软件包列表。

    2. 搜索 make 并选择 Devel/make。同样,搜索 shell,选择 Shells/bash,点击“下一步”,然后点击 完成。这将安装一个 Cygwin 终端到您的 Windows 程序列表中。在桌面上创建一个快捷方式以快速启动。请参考以下截图以获取帮助:

    如何操作...

工作原理...

Android SDK 提供了一个美丽的模块化包,其中包含构建 Android 应用程序所需的所有工具。SDK 和平台工具与 SDK 平台结合使用,作为 Android 应用程序开发的骨架。它们提供调试、管理和部署 Android 应用程序的服务。它们管理各种 Android 平台和相关 SDK API。此包还包含一个定制的 Android 开发 Eclipse;它有助于快速构建应用程序的用户界面。IDE 提供特殊工具(如 Android SDK Manager),允许您安装新的 Android 平台和许多其他辅助工具。

Android 支持使用 C/C++语言开发其应用程序的部分功能。这种开发方式通过 NDK 工具得到支持;该工具提供了一个名为 Java Native Interface (JNI)的接口,它有助于建立 Java 框架和本地代码之间的通信。NDK 需要基于 Unix 的命令行终端来构建 C/C++库。这种命令行终端在基于 UNIX 的操作系统内是内置的。在 Windows 上,它由 Cygwin 应用程序提供。开发者通过库(.so/.dll/.a)构建代码并导出本地代码功能。Android 应用程序使用这些库以静态形式或以共享形式将其集成到应用程序中。

使用 OpenGL ES 3.0 在 Android Studio 上开发 Hello World Triangle 应用程序

Android Studio 是另一个用于 Android 应用程序开发的集成开发环境IDE);社区正在迅速迁移到它。与本书中基于 Android ADT 的其他食谱不同,您还可以使用 Android Studio 开发 OpenGL ES 3.0 应用程序。它使用 Gradle 构建系统创建可扩展的应用程序。基于模板的向导有助于快速设计常见组件和布局。这个 IDE 还有许多其他酷炫功能,可以使开发更快、更健壮、更可靠。

前一个食谱,OpenGL ES 3.0 的软件要求 – Android ADT,使用Android 开发工具ADT)和 Eclipse ADT 插件来构建基于 Android 的 OpenGL ES 应用程序。本书中实现的所有食谱都使用基于 ADT 的开发系统来编程 OpenGL ES 3.0 应用程序。然而,我们还想为读者提供一个选项,让他们使用 Android Studio 开发自己的食谱。Android Studio 非常易于使用和设置。与 ADT 不同,它提供了一个丰富的界面和内置的 NDK 构建支持。在这个食谱中,我们将重用基于 Android ADT 的第一个食谱:HelloWorldTriangle,并使用 Android Studio 创建一个新的食谱。

准备工作

按照以下步骤获取和安装 Android Studio:

  1. 前往developer.android.com/sdk/installing/index.html?pkg=studio获取最新的 Android Studio。

  2. 使用 SDK Manager 在developer.android.com/tools/help/sdk-manager.html下载最新的 SDK 工具和平台。

  3. 您可以在developer.android.com/sdk/installing/adding-packages.html学习如何安装 SDK 包。对于 Android OpenGL ES 3.0,任何大于 18 的 API 级别都可以完全正常工作。

  4. 有关 Android Studio 的概述,请参阅developer.android.com/tools/studio/index.html以了解更多信息。

  5. 不要忘记设置 Android SDK 路径;设置程序将自动提示您提供 Android SDK 的目录路径。

如何操作...

按照给定的步骤在 Android Studio 上创建第一个 Android Hello World 应用程序。希望学习之后,您可以根据自己的需求将其他章节的食谱移植过来。

  1. 通过导航到新建 | 新建项目来创建一个新的 Android 应用程序项目。

  2. 应用程序名称设置为HelloWorldTriangle,并将公司域名设置为cookbook.gles,如图所示:如何操作...

  3. 选择目标平台 SDK;我们将使用API 18: Android 4.3 (Jelly Bean)。有关更多信息,请参考以下截图:如何操作...

  4. 创建空白活动,将活动名称更改为GLESActivity,然后点击完成。这将创建项目解决方案,如图所示:如何操作...

  5. 选择当前的java文件夹或包名,然后选择文件 | 新建 | Java 类。添加两个新类,分别命名为GLESViewGLESNativeLib

  6. 使用第一章中的Programming OpenGL ES 3.0 Hello World Triangle食谱,将其JNI文件夹复制到<ProjectLocation>\HelloWorldTriangle\app\src\main位置。此文件夹包含Android.mkApplication.mkNativeTemplate.hNativeTemplate.cpp。以下截图显示了文件夹结构:如何操作...

  7. 类似地,使用第一章中的Programming OpenGL ES 3.0 Hello World Triangle食谱,并将GLESActivity.javaGLESView.javaGLESNativeLib.java的内容重用到本项目的相应文件中。请确保不要替换包名,因为本项目与第一章中的Programming OpenGL ES 3.0 Hello World Triangle食谱有不同的包名。有关更多信息,您可以参考附录样本代码中提供的HelloWorldTriangleAndroidStudio示例食谱。

  8. 前往NativeTemplate.h/.cpp并修正 JNI 接口的声明和定义。将旧包名替换为新包名。以下示例显示了我们在当前食谱中针对新包名在init()函数中做出的更改:

    • 原始声明如下:

      JNIEXPORT void JNICALL Java_cookbook_gles_GLESNativeLib_init(JNIEnv * env, jobject obj, jstring FilePath);
      
    • 新的声明与新的包名如下所示:

      JNIEXPORT void JNICALL Java_gles_cookbook_helloworldtriangle_GLESNativeLib_init(JNIEnv * env, jobject obj, jstring FilePath);
      
  9. 导航到Application.mk并声明用于编译的 SDK 的构建变体和版本。APP_ABI告诉 NDK 编译器为每个可能的目标构建共享库。APP_PLATFORM通知编译器使用指定的平台进行编译。例如,因为我们使用 API 级别 18;因此,对于 OpenGL ES,EGL 和 GLESv3 库将从平台 API 级别 18 引用:

    //Application.mk
    APP_ABI := all
    APP_PLATFORM := android-18
    
  10. 前往<ProjectLocation>\HelloWorldTriangle\app\build.gradle4中的build.gradle并做出以下两个更改:

    • 模块名称:这告诉 Gradle 系统本地代码模块的名称;这必须与Android.mk中指定的模块名称相同:

      // Add the same module name present 
      // in the Android.mk file
      ndk{
          moduleName "glNative"
      }
      
    • NDK 外部构建:这通过使用ndk-build命令手动编译 makefile,正如我们在所有其他 Android 食谱中执行的那样。为此,我们需要通知 Gradle 构建系统不要预构建 NDK。jni.srcDirs告诉构建系统不要使用 Android Studio 中的ndk-build命令。jniLibs.srcDir给出了使用外部 NDK 编译的不同目标的构建库的位置:

      // Indicate the Android Studio not to use 
      // NDK from the IDE We will compile the 
      // project manually from Android.mk file.
      sourceSets.main
              {
                  jni.srcDirs = []
                  jniLibs.srcDir 'src/main/libs'
              }
      

    参考以下截图,了解我们在build.gradle中做出的两个更改:

    如何操作...

  11. 打开命令行终端。导航到当前JNI文件夹路径并执行ndk-build。此命令在<Project>\app\src\main\libs\<targetplatform>文件夹路径下编译源文件并生成共享库,这是通过Android.mk帮助完成的。

  12. 在构建库之后,使用 Android Studio 并点击项目执行按钮以在设备或模拟器上查看输出。以下是在 Hello World 三角形上的输出:如何操作...

工作原理…

本食谱的工作原理与我们在第一章中实现的方式相同,即在 Android/iOS 上使用 OpenGL ES 3.0,除了我们现在将使用 Android Studio 来构建项目。请参考使用 OpenGL ES 3.0 在 Android Studio 上开发 Hello World 三角形应用程序食谱,并查找工作原理…部分。本部分将提供 OpenGL ES 工作所需的必要细节,以及用于 OpenGL ES 3.0 应用程序开发的 Android Java 和本地接口。

参见

  • 参考第一章OpenGL ES 3.0 on Android/iOS中的在 Android 上使用 JNI 与 C/C++通信开发 Android OpenGL ES 3.0 应用程序食谱。

OpenGL ES 3.0 – iOS 的软件要求

OpenGL ES 3.0 的规范在 iOS 7 及更高版本中得到完全支持。iPhone 5s 以及苹果的 A7 GPU 支持 OpenGL ES 3.0 以及更早版本的 OpenGL ES 2.0 和 1.1。苹果 A7 GPU 提供了 OpenGL ES 3.0 所有新特性的可访问性。它还具有更大的渲染资源池。3.0 中的着色器能力在访问纹理资源方面是 OpenGL ES 2.0 的两倍。

准备工作

MAC 为 iOS 应用程序的开发提供了 Xcode IDE,它针对 iPhone、iPad 和 iPod。支持 OpenGL ES 3.0 的最低要求是版本 5.0;5.0 以上的所有 Xcode 版本都支持 iOS 7 构建目标。本书将使用 Xcode 5.2 版本进行其示例食谱。

如何做...

OpenGL ES 3.0 在 Xcode 5.0 及更高版本上由 iOS 7 SDK 支持。Xcode 5.0 版本包含 iOS 7 SDK。如果您是新手用户,您可以通过您的 App Store 应用程序安装它。如果您正在使用较旧的 Xcode 版本,您必须将其更新到至少 5.0 版本。iOS 7 SDK 及更高版本通过 iOS 7 目标设备支持 OpenGL ES 3.0。

它是如何工作的...

在 iOS7 上使用具有强大 GPU 的 OpenGL ES 3.0 可以执行复杂的图形渲染。GPU 能够对屏幕上每个像素的着色器进行高复杂度的计算。OpenGL ES 3.0 是一个基于 C 的 API,无缝集成到 Objective 或 C/C++中。OpenGL ES 规范没有定义窗口层,因为所有操作系统的窗口机制彼此之间非常不同。因此,底层操作系统负责生成渲染上下文以提供窗口层。此外,操作系统还必须提供一个 OpenGL ES 可以渲染的展示层。iOS 提供了 GLKit,通过提供绘制表面来提供展示层。GLKit 是在 iOS 5 中引入的,用于 OpenGL ES 的开发。这是一个用于 OpenGL ES 2.0/3.0 的 3D 图形开发工具包,使用 Objective C/C++编写。这个工具包使得可编程管道架构的编程工作更加容易。更多信息,请参考 Apple 开发者网站developer.apple.com/library/ios/documentation/GLkit/Reference/GLKit_Collection/index.html

更多内容...

GLKit 使用 C/C++ Objective 语言开发。这种语言仅在 Mac 和 iOS 应用程序中得到支持。因此,如果我们希望我们的代码能够在不同平台上移植,我们需要用 C/C++来编程。Objective C 语言在其框架中无缝支持 C/C++语言。

在跨平台中表现良好的游戏引擎实际上使用它们自己的平台无关的框架进行 OpenGL ES 编程。这些框架类似于 GLKit,甚至更强大。在我们的方法中,我们将从头开始使用 C/C++开发自己的引擎,以便为 Android 和 iOS 构建一个可接受的跨平台 3D 图形框架。

参见

苹果为在 iOS 上开发 OpenGL ES 应用程序提供了特殊参考。这些参考涵盖了 OpenGL ES 在 iOS 方面的各个方面。更多信息,请访问developer.apple.com/library/ios/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/Introduction/Introduction.html

在 Android ADT 和 iOS 上打开示例项目

打开 Android 平台的示例源代码

在 Eclipse ID 中,导航到新建 | 项目 | 从现有代码创建 Android 项目。点击下一步并指定包含Android.xml的文件夹路径。打开命令行终端,更改目录路径到JNI文件夹,并在终端中执行ndk-build命令。使用运行或(Ctrl + F11)作为快捷键从 Eclipse 启动应用程序。

注意

项目的路径应该是包含Android.xml的目录名。

打开 iOS 平台的示例源代码

打开配方文件夹,找到<项目名称>.xcodeproj,双击以在 Xcode 编辑器中打开项目。使用产品 | 运行或(Command + R)启动应用程序。

拉姆伯特余弦定律的应用

让我们了解如何从数学上计算余弦角,以便在我们的漫反射光照着色方案中实现拉姆伯特余弦定律。两个向量之间的余弦角可以通过它们之间的点积来计算:

点积

两个向量 P (ai, bj, cz) 和 O (di, ej, fk)的点积可以定义为两个向量的模长乘积以及它们之间的余弦角。

P.Q = |P||Q|cos(θ)………公式 1

其中|P|和|Q|是 P 和 Q 的模,可以计算为:

|P| = √(aa) +(bb) +(cc) 和 |Q| = √(dd) +(ee) +(ff)

或者,它是 x、y 和 z 分量各自分量的乘积:

P.Q = (ai, bj, cz) * (di, ej, fk) => (aidi)+ (biei) +(cifi)*

P.Q = (ad)(ii) + (be)(jj) +(cf)(kk) = ad + be + ef*

P.Q = ad + be + ef………公式 2

将公式 1 和公式 2 相等:

ad + be + ef = |P||Q|cos(θ)

如果 P 和 Q 是单位向量,那么公式 1 可以推导为:

P.Q = cos(θ)………公式 3

我们也可以通过将|P|*|Q|两边除以来找到余弦角:

cos(θ) = ( P.Q )/( |P||Q| )………公式 4*

计算两个向量之间的余弦

这是一个如何计算由平面x-z平面上的0200点形成的两个向量之间的余弦角度的示例,以及位于202040的光源,如图所示:

计算两个向量之间的余弦值

计算 ON 和 OL 向量,如下所示:

   OL = L – O = (20, 20, 40) – (0, 0, 0) => (20-0), (20-0), (40-0) => (20, 20, 40)
ON = N – O = (0, 20, 0) – (0, 0, 0) => (0, 20, 0) 

OL 和 ON 的点积如下:

OL . ON = |OL| * |ON| * cos(θ)

使用方程 1

OL .ON = (20*0) + (20*20) + (40*0) = 400

使用方程 2

|OL|*|ON| = [√ (20*20) + (20*20) + (40*40)] * [ √ (0*0) +(20*20)+(0*0)] = 979.79

将两个方程相等,结果如下所示:

400 = 979.79 * cos(θ);

这里,cos(θ) = 0.40意味着θ是65.90度。

Swizzling

Swizzling 是 GL 着色语言的一个新特性,允许你重新排列向量的组件。例如:

vec4 A (1.0, 2.0, 3.0 , 4.0);

在这里,vec4由 x、y、z 和 w 组件表示。结果如下:

vec4 B = A.xxzz;

现在,B 等价于{1.0, 1.0, 2.0, 2.0}

准备工作

着色语言中 vec2/3/4s 数据类型的组件访问可以被视为向量、颜色、纹理坐标或数组:

形状类型 组件 示例:vec4(1.1, 2.2, 3.3, 4.4 );
向量 {x, y, z, w} float a = v.x; float b= v.y;
颜色 {r, g, b, a} float a = v.r; float b= v.g;
纹理坐标 {s, t, p, q} float a = v.s; float b= v.t;
数组 [0, 1, 2, 3] float a = v[0]; float b= v[1];

如何做到这一点...

Swizzling 是一种通过使用组件名称直接访问组件的机制。例如:

如何做到这一点...

还有更多...

在前面的例子中,swizzling 发生在赋值的右侧。然而,swizzling 也可能发生在赋值的左侧:

还有更多...

posted @ 2025-10-23 15:11  绝不原创的飞龙  阅读(33)  评论(0)    收藏  举报