WebGL2-实时-3D-图形指南-全-
WebGL2 实时 3D 图形指南(全)
原文:
zh.annas-archive.org/md5/3ea1d675edee104977ae6a3aa2711874译者:飞龙
前言
WebGL 是一种强大的网络技术,它将硬件加速的 3D 图形带到了浏览器中,而无需用户安装额外的软件。鉴于 WebGL 基于 OpenGL 并将 3D 图形编程引入网络开发,即使是经验丰富的网络开发者也可能感到陌生。另一方面,对于在传统计算机图形方面有经验的人来说,使用 JavaScript 构建 3D 应用程序需要一些适应。一种常见的观点是,JavaScript 的速度不如其他在计算机图形中使用的传统语言快;尽管在比较 CPU 密集型算法时这是一个担忧,但在比较 GPU 密集型工作时则不是问题。这正是 WebGL 发挥作用的地方!WebGL 提供的强大功能,加上浏览器无处不在的普及性和易用性,使这项技术在推动网络沉浸式体验的未来方面处于独特且吸引人的位置。
本书包含许多示例,展示了尽管 WebGL 外观不友好,但它仍然易于学习。每一章都针对 3D 图形编程的一个重要方面,并提供了其实施的不同替代方案。这些主题总是与练习相关联,允许读者将这些概念付诸实践。
《使用 WebGL 2 的实时 3D 图形》 提供了学习 WebGL 2 的清晰路线图。虽然 WebGL1 基于 OpenGL ES 2.0 规范,但 WebGL 2 是从 OpenGL ES 3.0 演变而来,这保证了许多 WebGL1 扩展和新特性的可用性。每一章都从本章的学习目标总结开始,接着详细描述每个主题。本书提供了丰富示例、最新的对一系列基本 WebGL 主题的介绍,包括绘制、颜色、纹理、变换、帧缓冲区、光照、表面、几何形状等。每一章都包含了大量有用且实用的示例,展示了这些主题在 WebGL 场景中的实现。随着每一章的阅读,你的 3D 图形编程技能将得到提升。本书将成为你值得信赖的伴侣,其中包含了开发引人入胜的 3D 网络应用程序所需的 JavaScript 和 WebGL 2 的信息。
本书面向对象
本书是为对在网络上构建 3D 应用程序感兴趣的开发者所写。对 JavaScript 和线性代数有基本的了解是理想的,但不是强制性的。不期望有先前的 WebGL 知识。
本书涵盖内容
第一章,入门,介绍了 HTML5 canvas 元素,并描述了如何为它获取 WebGL 2 上下文。之后,它讨论了 WebGL 应用程序的基本结构。虚拟汽车展厅应用程序被展示为 WebGL 功能的演示。此应用程序还展示了 WebGL 应用程序的不同组件。
第二章,渲染,介绍了 WebGL API 用于定义、处理和渲染对象。本章还演示了如何使用 AJAX 和 JSON 进行异步几何加载。
第三章,灯光,介绍了 ESSL,WebGL 的着色语言。本章展示了如何使用 ESSL 着色器实现 WebGL 场景的照明策略。着色和反射光照模型背后的理论被涵盖,并通过各种示例付诸实践。
第四章,相机,说明了在 WebGL 中使用矩阵代数创建和操作相机的方法。这里还描述了在 WebGL 场景中使用的透视矩阵和法线矩阵。本章还展示了如何将这些矩阵传递给 ESSL 着色器,以便它们可以应用于每个顶点。本章包含几个示例,展示了如何在 WebGL 中设置相机。
第五章,动画,扩展了矩阵的使用,以在场景元素上执行几何变换(移动、旋转、缩放)。本章讨论了矩阵栈的概念。展示了如何使用矩阵栈为场景中的每个对象维护独立的变换。本章还描述了使用矩阵栈和 JavaScript 计时器的几种动画技术。每种技术都提供了实际演示。
第六章,颜色、深度测试和 Alpha 混合,详细介绍了在 ESSL 着色器中使用颜色的方法。本章展示了如何在 WebGL 场景中定义和操作多个光源。它还解释了深度测试和 Alpha 混合的概念,并展示了如何使用这些功能创建半透明对象。本章包含几个实际练习,将这些概念付诸实践。
第七章,纹理,展示了如何在 WebGL 场景中创建、管理和映射纹理。本章介绍了纹理坐标和纹理映射的概念。本章讨论了通过实际示例展示的不同映射技术。本章还展示了如何使用多个纹理和立方体贴图。
第八章,拾取,描述了拾取的简单实现,这是描述用户与场景中对象选择和交互的技术术语。本章中描述的方法计算鼠标点击坐标,并确定用户是否点击了在 canvas 中渲染的任何对象。解决方案的架构通过几个回调钩子展示,这些钩子可以用于实现特定逻辑的应用。还给出了几个拾取的示例。
第九章,整合一切,将本书中讨论的概念联系起来。本章回顾了演示的架构,并重新审视和扩展了在第一章入门中概述的虚拟汽车展厅应用。以虚拟汽车展厅为案例研究,本章展示了如何将 Blender 模型导入 WebGL 场景,以及如何创建支持 Blender 中使用的材质的 ESSL 着色器。
第十章,高级技术,提供了一些高级技术的示例,包括后处理效果、点精灵、法线贴图和光线追踪。每种技术都附有实际示例。阅读本章后,你将能够独立掌握更多高级技术。
第十一章,WebGL 2 亮点,概述了 WebGL 2 规范的一些主要特性和更新。本章还提供了将基于 WebGL1 的应用程序转换为 WebGL 2 的迁移策略。
第十二章,未来之旅,通过关于技术、概念、工具和社区的推荐,总结了使用 WebGL 2 的实时 3D 图形。读者可以利用这些推荐在掌握实时 3D 图形的旅程中受益。
为了充分利用本书
你需要一个支持 WebGL 2 的浏览器。除了 Microsoft Internet Explorer 之外,所有主要的浏览器供应商都支持 WebGL 2:
-
Firefox 51 或更高版本
-
Google Chrome 56 或更高版本
-
Chrome for Android 64 或更高版本
可以在这里找到支持 WebGL 的浏览器更新列表:www.khronos.org/webgl/wiki/Getting_a_WebGL_Implementation。
你还需要一个能够识别和突出显示 JavaScript 语法的源代码编辑器。
你还需要一个 Web 服务器,如 Apache、Lighttpd 或 Python,以加载远程资源。
下载示例代码文件
你可以从www.packt.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持标签。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书中的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Real-Time-3D-Graphics-with-WebGL-2。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,这些代码包可在github.com/PacktPublishing/找到。查看它们吧!
本地运行示例
如果你没有网络服务器,我们建议你从以下选项中安装一个轻量级网络服务器:
-
Serve:
github.com/zeit/serve -
Lighttpd:
www.lighttpd.net -
Python 服务器:
developer.mozilla.org/en-US/docs/Learn/Common_questions/set_up_a_local_testing_server
话虽如此,为了在你的机器上本地运行示例,请确保从examples目录的根目录运行你的服务器,因为common目录是跨章节的共享依赖项。
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。你可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788629690_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入等。以下是一个示例:“在你的编辑器中打开ch01_01_demo.html文件。”
代码块按以下方式设置:
<html>
<head>
<title>Real-Time 3D Graphics with WebGL 2</title>
<style type="text/css">
canvas {
border: 5px dotted blue;
}
</style>
</head>
<body>
<canvas id="webgl-canvas" width="800" height="600">
Your browser does not support the HTML5 canvas element.
</canvas>
</body>
</html>
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
<canvas id="webgl-canvas" width="800" height="600">
Your browser does not support the HTML5 canvas element.
</canvas>
任何命令行输入或输出都按以下方式编写:
$ mkdir webgl-demo
$ cd webgl-demo
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项如下所示。
小贴士和技巧如下所示。
部分
在本书中,你会发现一些频繁出现的标题(行动时间、刚刚发生了什么?和尝试一下)。
为了清楚地说明如何完成一个程序或任务,我们按照以下方式使用这些部分:
行动时间
-
动作 1
-
动作 2
-
动作 3
指令通常需要一些额外的解释以确保它们有意义,因此它们后面跟着这些部分:
刚刚发生了什么?
本节解释了你刚刚完成的任务或指令的工作原理。
尝试一下
这些是实际挑战,它们为你提供了如何使用所学内容进行实验的想法。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至customercare@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packt.com。
第一章:入门指南
曾经有一段时间,大部分的网页都是由静态内容组成的——唯一的图形就是嵌入的图片。然而,随着时间的推移,应用需求变得更加雄心勃勃,并开始遇到限制。随着高度交互式应用逐渐成为用户体验的重要组成部分,最终对完全可编程的图形应用程序编程接口(API)的需求变得足够大,以解决这些需求。2006 年,一位美国塞尔维亚软件工程师 Vladimir Vukicevic 开始为当时即将推出的 HTML <canvas> 元素开发一个 OpenGL 原型,他称之为 Canvas 3D。2011 年 3 月,他的工作将导致 OpenGL 背后的非营利组织 Kronos Group 创建WebGL,这是一个规范,允许互联网浏览器访问图形处理单元(GPU)。
所有浏览器引擎合作创建了 WebGL,这是在网络上渲染 3D 图形的标准。它基于 OpenGL 嵌入式系统(ES),这是一个针对嵌入式系统的跨平台图形 API。这是一个正确的起点,因为它使得在所有浏览器中轻松实现相同的 API 成为可能,特别是由于大多数浏览器引擎都在支持 OpenGL 的系统上运行。
WebGL 最初基于 OpenGL ES 2.0,这是 OpenGL 规范的一个版本,用于像苹果的 iPhone 和 iPad 这样的设备。但随着规范的演变,它变得独立,目标是提供跨各种操作系统和设备的可移植性。基于网络的实时渲染的想法为基于网络的 3D 环境开辟了一个全新的可能性世界。由于网络浏览器的普遍性,这些以及其他类型的 3D 应用现在可以在桌面和移动设备上,如智能手机和平板电脑上渲染。根据 Khronos Group 的说法,Web 开发者能够直接从 JavaScript 中访问 OpenGL 类图形,并且可以自由地将 3D 与其他 HTML 内容混合,这将促进 Web 游戏、教育和培训应用的新一波创新。
尽管 WebGL 在成熟并稳定发布后得到了广泛采用——集成在 Firefox、Chrome、Opera、IE11 和 Android 的移动网络浏览器中——但苹果仍然缺乏官方的 WebGL 支持。无论是 OS X Safari 还是 Safari Mobile 都不支持 WebGL。实际上,直到 2014 年 6 月的全球开发者大会(WWDC),苹果才宣布 OS X Yosemite 和 iOS 8 将内置 WebGL 支持。这成为了网络 3D 图形的一个转折点。随着所有主要浏览器的官方支持,整个 3D 图形范围——以原生速度——可以被发送到数十亿台桌面和移动设备。WebGL 释放了图形处理器的力量,为开放平台上的开发者提供了能力,使得可以在网络上构建具有控制台品质的应用程序。
在本章中,我们将进行以下操作:
-
理解运行 WebGL 所需的系统要求。
-
涵盖 WebGL 应用程序的高级组件。
-
设置绘图区域(
canvas)。 -
测试您的浏览器 WebGL 功能。
-
理解 WebGL 作为状态机的角色。
-
修改影响您场景的 WebGL 变量。
-
加载并检查一个完全功能的场景。
系统要求
WebGL 是一个基于网络的 3D 图形 API。因此,无需安装。虽然 WebGL 1 基于 OpenGL ES 2.0 规范,但 WebGL 2 基于 OpenGL ES 3.0,这保证了许多 WebGL 1 扩展和新特性的可用性。
WebGL 2 与 WebGL 1 的比较
由于本书涵盖 WebGL 2,所有 WebGL 和 WebGL 2 术语均指 WebGL 2(OpenGL ES 3.0)规范。任何对 WebGL 1(OpenGL ES 2.0)的引用都将明确指出。
截至 2016 年 1 月 27 日,Firefox 和 Chrome 默认提供 WebGL 2。如果您使用以下任一网络浏览器,您将自动获得对 WebGL 2 的访问权限:
-
Firefox 51 或更高版本
-
Google Chrome 56 或更高版本
-
Chrome for Android 64 或更高版本
有关支持 WebGL 的网络浏览器的最新列表,请访问 Khronos Group 网页:www.khronos.org/webgl/wiki/Getting_a_WebGL_Implementation。
或者访问知名的 CanIUse.com 资源:caniuse.com/#search=WebGL 2。
现代标准
由于我们将使用现代浏览器来运行 WebGL 2,因此本书将全面使用 HTML5、CSS3 和 JavaScript ES6。有关这些现代标准的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web。
在本书出版时,WebGL 2 仍然是一个处于变动中的规范。规范的一些部分被认为是稳定的,并在现代浏览器中实现了;其他部分应被视为实验性的,并且已经以不同程度的程度部分实现。因此,您应该熟悉标准化过程以及每个新属性的实施级别。话虽如此,WebGL 2 几乎与 WebGL 1 完全向后兼容。所有向后不兼容的例外情况记录在以下链接中:www.khronos.org/registry/webgl/specs/latest/2.0/#BACKWARDS_INCOMPATIBILITY。
迁移到 WebGL 2
如果您有 WebGL 1 的经验或对迁移策略到 WebGL 2 感兴趣,您可以参考第十一章,WebGL 2 突出特点,其中突出了 WebGL 1 和 WebGL 2 之间的关键差异。
最后,你需要确保你的计算机有一张经过批准的显卡。为了快速验证你的 WebGL 2 当前配置,请访问以下链接:get.webgl.org/WebGL 2/。
WebGL 渲染
WebGL 是一个 3D 图形库,它使现代网络浏览器能够以标准且高效的方式渲染 3D 场景。根据维基百科,渲染是通过计算机程序从模型生成图像的过程。由于这是一个由计算机执行的过程,因此有不同方式来生成这样的图像。在讨论渲染时,有三个主要区分:基于软件和硬件的渲染、基于服务器和客户端的渲染,以及保留模式与即时模式的渲染。正如我们将看到的,WebGL 在 Web 上提供了一个独特的硬件和客户端基于即时模式 API 的渲染方法。
基于软件和硬件的渲染
我们应该做的第一个区分是是否使用了任何特殊的图形硬件。一方面,我们可以谈论基于软件的渲染,在这种情况下,渲染 3D 场景所需的所有计算都是使用计算机的中央处理器(CPU)完成的。另一方面,正如 WebGL 的情况一样,我们使用基于硬件的渲染这个术语,用于有 GPU 执行 3D 图形计算的场景。从技术角度来看,基于硬件的渲染比基于软件的渲染更有效率,因为前者涉及专门的硬件处理必要的操作。相比之下,由于缺乏硬件依赖,基于软件的渲染解决方案可能更常见。
基于服务器和客户端的渲染
需要做的第二个区分是渲染过程是在本地还是远程进行。当需要渲染的图像过于复杂时,渲染最有可能在远程进行。例如,对于 3D 动画电影,需要使用配备大量硬件资源的专用服务器来渲染复杂的场景,我们称之为基于服务器的渲染。与此相反的方法发生在本地渲染时。我们称之为基于客户端的渲染。WebGL 提供了一种基于客户端的渲染方法:3D 场景中的元素通常从服务器下载。然而,获取图像所需的处理都是在客户端的图形硬件上本地完成的。尽管这不是一个独特的解决方案,但与其他技术(如 Java 3D、Flash 和 Unity Web Player 插件)相比,WebGL 具有几个优点:
-
JavaScript 编程:JavaScript 是一种对网页开发者和浏览器都自然易用的语言。使用 JavaScript 可以让你访问 DOM 的所有部分,并轻松地将 WebGL 应用程序与其他 JavaScript 库(如 jQuery、React 和 Angular)集成。
-
自动内存管理:WebGL 与其他技术(如 OpenGL,其中内存分配和释放是手动处理的)不同,遵循 JavaScript 变量作用域和自动内存管理的规则。这极大地简化了编程,同时减少了代码体积。最终,这种简化使得理解应用程序逻辑变得更加容易。
-
普及性:具有 JavaScript 功能的网页浏览器默认安装在智能手机和平板设备上。这意味着您可以在广泛的桌面和移动设备生态系统中利用 WebGL。
-
性能:WebGL 应用程序的性能与等效的独立应用程序(有一些例外)相当。这是由于 WebGL 能够访问本地图形硬件的能力。直到最近,许多 3D 网页渲染技术都使用基于软件的渲染。
-
零编译:WebGL 使用 JavaScript 编写;因此,在网页浏览器上执行之前不需要编译代码。这使得您能够实时进行更改并看到这些更改如何影响您的 3D 网页应用程序。然而,当我们介绍着色器程序时,我们将了解到需要一些编译。但是,这发生在您的图形硬件上,而不是在您的浏览器上。
保留模式和即时模式渲染
需要做的第三个区分是 WebGL 是一个为网页设计的即时模式 3D 渲染 API。图形 API 可以分为保留模式 API 和即时模式 API。
保留模式渲染
保留模式 API 是声明式的。应用程序通过原始元素,如形状和线条,构建场景,然后图形库在内存中维护场景模型。要更改渲染内容,应用程序会发出更新场景的命令,例如添加或删除形状;库随后负责管理和重新绘制场景:

即时模式渲染
即时模式 API 是过程式的。即时模式渲染要求应用程序直接管理渲染。在这种情况下,图形库不维护场景模型。每次绘制新帧时,应用程序都会发出所有必要的绘图命令来描述整个场景,而不管实际的变化。这种方法为应用程序程序提供了最大程度的控制和灵活性:

保留模式与即时模式渲染
保留模式渲染可能更容易使用,因为 API 会为你做更多的工作,例如初始化、状态维护和清理。然而,它通常不太灵活,因为 API 强制使用自己的特定场景模型;它也可能有更高的内存需求,因为它需要提供一个通用的场景模型。另一方面,WebGL 提供的即时模式渲染则更加灵活,可以实现有针对性的优化。
WebGL 应用程序中的元素
WebGL,像其他 3D 图形库一样,包含许多常见的 3D 元素。这些基本元素将按章节顺序在本书中进行介绍。
一些这些常见元素包括以下内容:
-
canvas:它是我们的场景渲染的占位符。它是一个标准的 HTML5 元素,因此可以使用 文档对象模型 (DOM) 访问。 -
对象:这些是构成场景的 3D 实体。这些实体由三角形组成。在接下来的章节中,我们将看到 WebGL 如何使用 缓冲区 处理和渲染几何形状。
-
灯光:在 3D 世界中没有灯光,任何东西都无法被看到。在后面的章节中,我们将了解到 WebGL 使用 着色器 来在场景中建模灯光。我们将看到 3D 对象如何根据物理定律反射或吸收光线。我们还将讨论不同的光照模型来可视化我们的对象。
-
相机:
canvas作为 3D 世界的视口。我们通过它看到并探索 3D 场景。在接下来的章节中,我们将了解产生视图透视所需的不同矩阵运算。我们将了解这些运算如何被建模为相机。
本章将介绍我们列表中的第一个元素:canvas。接下来的部分将帮助我们了解如何创建 canvas 元素以及如何设置 WebGL 上下文。
行动时间:创建 HTML5 Canvas 元素
canvas 是网页中的一个矩形元素,你的 3D 场景将在其中渲染。让我们创建一个网页并添加一个 HTML5 canvas 元素:
- 使用你喜欢的编辑器,创建一个包含以下代码的网页:
<html>
<head>
<title>Real-Time 3D Graphics with WebGL2</title>
<link rel="shortcut icon" type="image/png"
href="/common/images/favicon.png" />
<style type="text/css">
canvas {
border: 5px dotted blue;
}
</style>
</head>
<body>
<canvas id="webgl-canvas" width="800" height="600">
Your browser does not support the HTML5 canvas element.
</canvas>
</body>
</html>
-
将文件保存为
ch01_01_canvas.html。 -
使用支持的浏览器打开它。
-
你应该看到以下截图类似的内容:

刚才发生了什么?
我们创建了一个包含 canvas 元素的基本网页。这个 canvas 将包含我们的 3D 应用程序。让我们快速浏览一下这个例子中展示的相关元素。
定义 CSS 样式
这是确定 canvas 样式的代码片段:
<style type="text/css">
canvas {
border: 5px dotted blue;
}
</style>
这段代码不是构建 WebGL 应用程序的基本部分。鉴于 canvas 元素最初是空的,一个蓝色的虚线边框是验证 canvas 位置的一种简单方法。
理解 Canvas 属性
在我们之前的例子中,有三个属性:
-
id:这是 DOM 中的canvas标识符。 -
width和height:这两个属性决定了我们的canvas元素的大小。当这两个属性缺失时,Firefox、Chrome 和 WebKit 将默认使用300px宽度和150px高度。
如果不支持 Canvas 怎么办?
如果你屏幕上出现以下消息,Your browser does not support the HTML5 canvas element(这是<canvas>标签之间的消息),你需要确保你使用的是之前描述的支持的 Web 浏览器之一。
如果你使用 Firefox 并且仍然看到这个消息,你可能需要检查 WebGL 是否已启用(默认情况下是启用的)。要做到这一点,请转到 Firefox 并在地址栏中输入about:config。然后,查找webgl.disabled属性。如果它设置为true,将其更改为false。当你重新启动 Firefox 并加载ch01_01_canvas.html时,你应该能够看到canvas元素的虚线边框。
在极少数情况下,如果你仍然看不到canvas,可能是因为你的浏览器已经将你的 GPU 列入了黑名单。如果是这种情况,请使用具有适当硬件的系统。
行动时间:访问 WebGL 上下文
WebGL 上下文是一个句柄(更严格地说是一个 JavaScript 对象),通过它可以访问 WebGL 函数和属性。这些可用的功能构成了 WebGL API。
我们将创建一个 JavaScript 函数,用于检查是否可以获取 WebGL 上下文。与其他需要下载到项目中的技术不同,WebGL 已经存在于你的浏览器中。换句话说,如果你使用的是支持的浏览器之一,你不需要安装或包含任何库。
让我们修改之前的示例,添加一个 JavaScript 函数来检查浏览器中的 WebGL 可用性。这个函数将在页面加载时被调用。为此,我们将使用标准的 DOMonload事件:
-
在你喜欢的文本编辑器中打开
ch01_01_canvas.html文件。 -
在
<style>标签的关闭标签下面添加以下代码:
<script type="text/javascript">
'use strict';
function init() {
const canvas = document.getElementById('webgl-canvas');
// Ensure we have a canvas
if (!canvas) {
console.error('Sorry! No HTML5 Canvas was found on
this page');
return;
}
const gl = canvas.getContext('webgl2');
// Ensure we have a context
const message = gl
? 'Hooray! You got a WebGL2 context'
: 'Sorry! WebGL is not available';
alert(message);
}
// Call init once the document has loaded
window.onload = init;
</script>
-
将文件保存为
ch01_02_context.html。 -
使用支持 WebGL 2 的浏览器打开
ch01_02_context.html文件。 -
如果你可以运行 WebGL 2,你将看到一个类似于以下对话框的界面:

严格模式
通过'use strict';声明的严格模式是一种特性,它允许你将程序或函数置于一个“严格”的操作环境中。这种严格环境阻止某些操作被执行,并抛出更多的异常。更多信息请访问以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode。
发生了什么事?
通过分配一个 JavaScript 变量(gl),我们获得了对 WebGL 上下文的引用。让我们回到代码中,检查允许访问 WebGL 的代码:
const gl = canvas.getContext('webgl2');
canvas.getContext方法让我们访问 WebGL。当使用2D作为上下文名称时,getContext也提供了对 HTML5 2D 图形库的访问。HTML5 2D 图形 API 完全独立于 WebGL,并且超出了本书的范围。
状态机
一个 WebGL 上下文可以被理解为一个状态机:一旦你修改了属性,这些修改将保持直到后续的修改。在任何时候,你都可以查询这些属性的当前状态以确定你的 WebGL 上下文的当前状态。让我们用一个例子来分析这种行为。
实践时间:设置 WebGL 上下文属性
在这个例子中,我们将学习如何修改我们用于清除canvas元素的色彩:
- 使用你喜欢的文本编辑器打开
ch01_03_attributes.html文件:
<html>
<head>
<title>Real-Time 3D Graphics with WebGL2</title>
<link rel="shortcut icon" type="image/png"
href="/common/images/favicon.png" />
<style>
canvas {
border: 5px dotted blue;
}
</style>
<script type="text/javascript">
'use strict';
let gl;
function updateClearColor(...color) {
// The ES6 spread operator (...) allows for us to
// use elements of an array as arguments to a function
gl.clearColor(...color);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.viewport(0, 0, 0, 0);
}
function checkKey(event) {
switch (event.keyCode) {
// number 1 => green
case 49: {
updateClearColor(0.2, 0.8, 0.2, 1.0);
break;
}
// number 2 => blue
case 50: {
updateClearColor(0.2, 0.2, 0.8, 1.0);
break;
}
// number 3 => random color
case 51: {
updateClearColor(Math.random(), Math.random(),
Math.random(), 1.0);
break;
}
// number 4 => get color
case 52: {
const color = gl.getParameter(gl.COLOR_CLEAR_VALUE);
// Don't let the following line confuse you.
// It basically rounds up the numbers to one
// decimal cipher for visualization purposes.
// TIP: Given that WebGL's color space ranges
// from 0 to 1 you can multiply these values by 255
// to display in their RGB values.
alert(`clearColor = (
${color[0].toFixed(1)},
${color[1].toFixed(1)},
${color[2].toFixed(1)}
)`);
window.focus();
break;
}
}
}
function init() {
const canvas = document.getElementById('webgl-canvas');
if (!canvas) {
console.error('Sorry! No HTML5 Canvas was found on this page');
return;
}
gl = canvas.getContext('webgl2');
const message = gl
? 'Hooray! You got a WebGL2 context'
: 'Sorry! WebGL is not available';
alert(message);
// Call checkKey whenever a key is pressed
window.onkeydown = checkKey;
}
window.onload = init;
</script>
</head>
<body>
<canvas id="webgl-canvas" width="800" height="600">
Your browser does not support the HTML5 canvas element.
</canvas>
</body>
</html>
- 你会看到这个文件与我们的前一个例子类似。然而,这里有一些新的代码结构,我们将简要解释。这个文件包含三个 JavaScript 函数:
| 函数 | 描述 |
|---|---|
updateClearColor |
更新clearColor并设置canvas元素的清除颜色,这是 WebGL 上下文的一个属性。如前所述,WebGL 作为一个状态机工作。因此,它将保持这个颜色,直到使用gl.clearColor WebGL 函数(参见checkKey源代码)来改变它。 |
checkKey |
这是一个附加到窗口onkeydown事件的辅助函数。它捕获键盘输入并根据输入的键执行代码。 |
init |
这个函数在文档的onload事件中被调用。它获取一个 WebGL 上下文并将其设置为全局的gl变量。 |
-
在你的浏览器中打开
ch01_03_attributes.html文件。 -
按1。你会看到
canvas的颜色变成了绿色。如果你想查询使用的确切颜色,请按4。 -
canvas元素将保持绿色,直到我们通过调用gl.clearColor来改变它。让我们通过按 2 来改变它。如果你查看源代码,这将把canvas清除颜色改为蓝色。如果你想了解确切的颜色,请按 4。 -
你可以按3来将清除颜色设置为随机颜色。和之前一样,你可以通过按4来获取颜色:*

刚才发生了什么?
在这个例子中,我们看到了我们可以通过调用clearColor函数来改变 WebGL 用于清除canvas元素的色彩。相应地,我们使用getParameter(gl.COLOR_CLEAR_VALUE)来获取当前canvas清除色彩的值。
在整本书中,我们将遇到类似的构造,其中特定的函数会设置 WebGL 上下文的属性,而getParameter函数会在使用相应的参数(在我们的例子中是COLOR_CLEAR_VALUE)时检索这些属性的当前值。
使用上下文访问 WebGL API
重要的是要注意,所有 WebGL 函数都是通过 WebGL 上下文访问的。在我们的示例中,上下文是由gl变量持有的。因此,对 WebGL API 的任何调用都将使用这个变量执行。
加载 3D 场景
到目前为止,我们已经看到了如何设置canvas元素以及如何获取 WebGL 上下文;下一步是讨论对象、灯光和相机。但为什么要等待看到 WebGL 能做什么呢?在本节中,我们将快速浏览一下本书将要构建的最终 WebGL 应用程序的简化版本。
虚拟汽车展厅
通过这本书,我们将使用 WebGL 开发一个虚拟汽车展厅应用程序。在这个阶段,我们将一个简单的场景加载到canvas元素中。这个场景将包含一辆车、一些灯光和一个相机。
行动时间:可视化 3D 展厅
读完这本书后,您将能够创建像我们接下来将要玩的那样引人入胜的 3D 场景。这个场景展示了本书虚拟汽车展厅中的一辆汽车:
-
在您的浏览器中打开
ch01_04_showroom.html文件。 -
您将看到一个包含汽车的 WebGL 场景,如下面的截图所示。在接下来的章节中,我们将介绍几何渲染,并了解如何加载和渲染各种 3D 模型:

-
使用滑块来交互式地更新为这个场景定义的四个光源。每个光源有两个元素:漫反射元素和镜面元素。我们已经在第三章,灯光,中完全介绍了 3D 场景中的灯光。
-
在
canvas上点击并拖动以旋转汽车并从不同的角度可视化它。您可以通过按住Alt键并在canvas上拖动鼠标来缩放。您还可以使用箭头键围绕汽车旋转相机。在使用箭头键之前,请确保点击canvas以使其获得焦点。在第四章,相机中,我们将讨论如何在 WebGL 中创建和操作我们自己的自定义相机。 -
使用颜色选择器小部件来改变汽车的颜色。本书稍后将对场景中颜色的使用进行详细讨论。
发生了什么?
我们已经使用 WebGL 在浏览器中加载了一个简单的场景。这个场景由以下内容组成:
-
通过一个
canvas元素,我们看到了场景。 -
一系列多边形网格(对象),它们构成了汽车:车顶、窗户、车灯、保险杠、车门、车轮、尾翼、保险杠等。
-
灯光源,否则一切都将显得漆黑。
-
一个相机,它决定了我们在 3D 世界中的视点。这个相机是交互式的,视点可以根据用户输入而改变。例如,我们使用了各种键和鼠标来移动相机。
场景中还有许多其他元素,如纹理、颜色和特殊的光效(如镜面反射)。不要慌张!我们将在这本书的整个过程中解释每个元素。这里的关键是要识别出我们之前讨论的四个基本元素都存在于场景中。因此,您可以自由地检查源代码,以了解接下来会发生什么。
架构更新
随着我们进入各个章节,我们将遇到一些常见功能(例如,设计模式、实用函数、辅助函数和数据结构),我们可以在此基础上构建。这不仅将帮助我们编写DRY(Don't Repeat Yourself,不要重复自己)的代码,而且还将提供一个有用的架构,以支持本书结束时的高级 3D WebGL 应用程序。
DRY(Don't Repeat Yourself,不要重复自己)是一种软件开发原则,其主要目的是减少代码的重复。WET(Write Everything Twice,一切都要写两次)是一个俏皮的缩写,表示相反的意思——不遵循 DRY 原则的代码。
让我们介绍一些将在未来章节中使用的更改:
-
在您的编辑器中打开
common/js/utils.js以查看以下代码。 -
我们将使用
utils包含许多实用函数,以帮助我们构建我们的 3D 应用程序。utils中的两个方法,getCanvas和getGLContent,与本章早期实现的方法类似:
'use strict';
// A set of utility functions for /common operations across our
// application
const utils = {
// Find and return a DOM element given an ID
getCanvas(id) {
const canvas = document.getElementById(id);
if (!canvas) {
console.error(`There is no canvas with id ${id} on this
page.`);
return null;
}
return canvas;
},
// Given a canvas element, return the WebGL2 context
getGLContext(canvas) {
return canvas.getContext('webgl2') || console.error('WebGL2 is
not available in your browser.');
}
};
-
getCanvas返回具有提供的id作为参数的canvas元素。 -
getGLContext为给定的canvas元素返回一个 WebGL 2 上下文。 -
在您的编辑器中打开
ch01_05_attributes-final.html以查看以下更改。 -
我们在文档的
<head>中包含了<link rel="stylesheet" href="/common/lib/normalize.css">,以重置浏览器之间的许多不一致性。这是一个外部库,帮助我们跨浏览器标准化 CSS 样式。 -
我们已经包含了
<script type="text/javascript" src="img/utils.js"></script>。 -
滚动到
init函数,其中进行了必要的更改以使用utils.getCanvas和utils.getGLContext函数:
function init() {
const canvas = utils.getCanvas('webgl-canvas');
gl = utils.getGLContext(canvas);
window.onkeydown = checkKey;
}
- 在浏览器中打开
ch01_05_attributes-final.html以查看这些更改的实际效果。
示例代码结构 所有示例代码都按照常见功能位于目录根目录(common/)中,而每个章节的示例则分类在章节目录下(例如,ch01/、ch02/ 和 ch03/)。也就是说,为了在浏览器中查看这些示例,您需要在目录根目录启动一个服务器,以加载每个示例所需的所有资源。请参阅本书的前言以获取更多详细信息。
概述
让我们总结一下本章所学的内容:
-
我们介绍了 WebGL 的历史以及它是如何实现的。
-
我们学习了常见元素——
canvas、对象、灯光和相机——它们通常存在于 WebGL 应用程序中。 -
我们学习了如何将 HTML5
canvas元素添加到我们的网页中,以及如何设置其id、width和height。 -
我们实现了获取 WebGL 上下文的代码。
-
我们介绍了 WebGL 作为状态机的工作原理,并且因此我们可以使用
getParameter函数查询其任何变量。 -
我们提前看到了这本书结束时我们将构建的交互式 3D 应用程序。
在下一章中,我们将学习如何定义、加载并将对象渲染到 WebGL 场景中。
第二章:渲染
在上一章中,我们介绍了 WebGL 的历史及其演变。我们讨论了 3D 应用中的基本元素以及如何设置 WebGL 上下文。在本章中,我们将研究 WebGL 中几何实体的定义。
WebGL 按照“分而治之”的方法渲染对象。复杂的多边形被分解成三角形、线和点原语。然后,每个几何原语由 GPU 并行处理,以创建最终场景。
在本章中,你将:
-
理解 WebGL 如何定义和处理几何信息
-
讨论与几何操作相关的相关 API 方法
-
检查为什么以及如何使用JavaScript 对象表示法(JSON)来定义、存储和加载复杂几何形状
-
继续分析 WebGL 作为状态机,描述与几何操作相关的可设置和检索的属性
-
尝试创建和加载不同的几何模型
WebGL 渲染管线
虽然 WebGL 通常被认为是一个全面的 3D API,但实际上它只是一个光栅化引擎。它根据你提供的代码绘制点、线和三角形。要让 WebGL 执行其他操作,你需要提供代码来使用点、线和三角形来完成你的任务。
WebGL 在你的计算机的 GPU 上运行。因此,你需要提供在该 GPU 上运行的代码。代码应以成对函数的形式提供。这两个函数被称为顶点着色器和片段着色器,它们各自用一种非常严格类型化的 C/C++语言编写,称为 GLSL(GL 着色语言)。它们一起被称为程序。
GLSL 是 OpenGL 着色语言的缩写。GLSL 是一种类似于 C/C++的高级编程语言,用于图形卡的多部分。使用 GLSL,你可以编写简短的程序,称为着色器,这些程序在 GPU 上执行。更多信息,请参阅 en.wikipedia.org/wiki/OpenGL_Shading_Language。
顶点着色器的工作是计算顶点属性。基于各种位置,该函数输出可用于光栅化各种原语(包括点、线和三角形)的值。在光栅化这些原语时,它调用第二个用户提供的函数,称为片段着色器。片段着色器的工作是为当前正在绘制的原语的每个像素计算颜色。
几乎所有的 WebGL API 都是关于为这些成对的函数设置状态以执行。对于你想绘制的每一件事,你需要通过调用gl.drawArrays或gl.drawElements来设置状态以运行这些函数,这将执行你的着色器在 GPU 上。
在进一步讨论之前,让我们看看 WebGL 的渲染管道是什么样的。在随后的章节中,我们将更详细地讨论管道。以下是一个简化的 WebGL 渲染管道的图表:

让我们花一点时间来描述每个元素。
顶点缓冲区对象 (VBOs)
VBOs包含用于描述要渲染的几何形状的数据。定义 3D 对象顶点的顶点坐标通常以 VBO 的形式存储和处理。此外,还有一些数据元素,如顶点法线、颜色和纹理坐标,可以建模为 VBOs。
索引缓冲区对象 (IBOs)
虽然VBOs包含描述几何形状的顶点,但IBOs包含关于顶点之间关系的详细信息,当渲染管道构建绘图类型原语时使用。它使用顶点缓冲区中每个顶点的索引作为值。
顶点着色器
顶点着色器在每一个顶点上被调用。着色器操作每个顶点的数据,例如顶点坐标、法线、颜色和纹理坐标。这些数据由顶点着色器内的属性表示。每个属性指向一个 VBO,从那里读取顶点数据。
片段着色器
每组三个顶点定义一个三角形。该三角形表面的每个元素都需要分配一个颜色。没有这个步骤,我们的表面就不会有颜色。每个表面元素被称为片段。由于我们处理的是将在屏幕上显示的表面,这些元素更常见地被称为像素。
片段着色器的主要目标是计算单个像素的颜色。以下图表说明了这个概念:

帧缓冲区
一个二维缓冲区包含由片段着色器处理过的片段。一旦所有片段都被处理,就会形成一个 2D 图像并在屏幕上显示。帧缓冲区是渲染管道的最终目的地。
属性
属性是用于顶点着色器的输入变量。属性用于指定如何从缓冲区中提取数据并将其提供给顶点着色器。例如,您可以将位置存储在缓冲区中,每个位置为三个 32 位浮点数。您会告诉特定的属性从哪个缓冲区中提取位置,它应该提取什么类型的数据(3 分量,32 位浮点数),缓冲区中位置开始的偏移量,以及从一个位置到下一个位置的字节数。由于顶点着色器在每一个顶点上被调用,因此每次调用顶点着色器时,属性都会不同。
全局变量
统一变量是可供顶点着色器和片元着色器使用的输入变量。与属性不同,统一变量在渲染周期中是恒定的。例如,光的位置通常被建模为一个统一变量。统一变量实际上是你在执行着色器程序之前设置的全球变量。
纹理
纹理是可以在你的着色器程序中访问的数据数组。图像数据是将图像放入纹理中最常见的事情,但纹理只是数据,也可以很容易地包含除了描述图像的颜色数组之外的其他内容。
插值变量
插值变量用于从顶点着色器传递数据到片元着色器。根据渲染的内容——点、线或三角形——顶点着色器设置的插值变量的值将在执行片元着色器时进行插值。
现在,让我们来探讨创建简单几何对象的原则。
WebGL 中的渲染
WebGL 以标准方式处理几何形状,独立于表面的复杂性和点的数量。有两种数据类型是表示任何 3D 对象几何形状的基本类型:顶点和索引。
顶点
顶点是定义 3D 对象角落的点。每个顶点由三个浮点数表示,对应于顶点的x、y和z坐标。与它的表亲 OpenGL 不同,WebGL 不提供 API 方法将独立的顶点传递到渲染管线;因此,我们所有的顶点都需要写入一个JavaScript 数组,然后可以使用这个数组来构建 WebGL 顶点缓冲区。
索引
索引是给定 3D 场景中顶点的数字标签。索引允许我们告诉 WebGL 如何连接顶点以产生表面。与顶点一样,索引存储在 JavaScript 数组中,然后通过 WebGL 索引缓冲区传递给 WebGL 的渲染管线。
VBOs 与 IBOs
有两种类型的 WebGL 缓冲区用于描述和处理几何形状。包含顶点数据的缓冲区被称为VBOs,而包含索引数据的缓冲区被称为IBOs。
在本节中,我们将使用以下步骤在 WebGL 中渲染一个对象:
-
使用 JavaScript 数组定义几何形状
-
创建相应的 WebGL 缓冲区
-
将顶点着色器属性指向上一步骤中的 VBO 以存储顶点坐标
-
使用 IBO 渲染几何形状
使用 JavaScript 数组定义几何形状
为了练习使用前面的步骤,让我们使用梯形来查看我们如何定义其顶点和索引。我们需要两个 JavaScript 数组——一个用于顶点,一个用于索引:

如您从前面的插图中所见,我们已经按顺序将坐标放置在顶点数组中,然后指出了这些坐标如何在索引数组中用于绘制梯形。因此,第一个三角形是由索引为0、2和1的顶点形成的;第二个是由索引为1、2和3的顶点形成的;最后,第三个是由索引为2、4和3的顶点形成的。我们将对所有可能的几何形状遵循相同的程序。
索引数组顺序
索引数组中的三角形通常(但不一定)按逆时针顺序定义。选择一种方法并保持一致对于帮助您确定几何原语的前后侧面非常重要。一致性很重要,因为程序可能使用顺时针/逆时针顺序来确定面是向前还是向后,以便进行剪切和渲染。
剪切
在计算机图形学中,背面剪切(back-face culling)确定一个图形对象的多边形是否可见。它是图形管道中的一个步骤,用于测试多边形中的点在投影到屏幕上时是顺时针还是逆时针顺序。有关更多信息,请访问en.wikipedia.org/wiki/Back-face_culling[.]。
创建 WebGL 缓冲区
现在我们已经了解了如何使用顶点和索引定义一个几何形状,让我们渲染一个正方形。一旦我们创建了定义我们几何形状的顶点和索引的 JavaScript 数组,下一步就是创建相应的缓冲区。在这种情况下,我们在x-y平面上有一个简单的正方形,z 值设置为0:
const vertices = [
-0.5, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0
];
const positionBuffer = gl.createBuffer();
剪裁空间坐标
这些顶点是在剪裁空间坐标中定义的,因为 WebGL 只处理剪裁空间坐标。剪裁空间坐标总是从-1到+1,无论canvas的大小如何。在后面的章节中,我们将更详细地介绍坐标,并学习如何在不同坐标系之间进行转换。
在第一章“入门”中,您可能还记得学习到 WebGL 作为一个状态机运行。现在,当positionBuffer成为当前绑定的 WebGL 缓冲区时,任何后续的缓冲区操作都将在这个缓冲区上执行,直到它被解绑,或者通过绑定调用将另一个缓冲区设置为当前缓冲区。我们可以使用以下指令绑定缓冲区:
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
第一个参数是我们正在创建的缓冲区类型。对于这个参数,我们有两种选择:
-
gl.ARRAY_BUFFER:顶点数据 -
gl.ELEMENT_ARRAY_BUFFER:索引数据
在前面的例子中,我们创建了顶点坐标的缓冲区;因此,我们使用ARRAY_BUFFER。对于索引,使用ELEMENT_ARRAY_BUFFER类型。
边界缓冲区操作
WebGL 将始终访问当前绑定的缓冲区以查找数据。这意味着在调用任何其他几何处理操作之前,我们需要确保已经绑定了缓冲区。如果没有绑定缓冲区,您将获得 INVALID_OPERATION 错误。
记住,drawArrays 使用 VBOs. 一旦我们绑定了缓冲区,我们需要传递其内容。我们通过 bufferData 函数来完成此操作:
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
在此示例中,vertices 变量是一个包含顶点坐标的正常 JavaScript 数组。WebGL 不接受 JavaScript 数组作为 bufferData 方法的参数。相反,WebGL 需要 JavaScript 类型化数组,以便以原生二进制形式处理缓冲区数据,目的是加快几何处理性能。
WebGL 使用的类型化数组包括 Int8Array、Uint8Array、Int16Array、Uint16Array、Int32Array、Uint32Array、Float32Array 和 Float64Array。
重要的是要注意,顶点坐标可以是浮点数,但索引始终是整数。因此,在这本书中,我们将使用 Float32Array 作为 VBOs,使用 Uint16Array 作为 IBOs。这两种类型代表了在 WebGL 中每个渲染调用可以使用的最大类型化数组。其他类型可能存在于你的浏览器中,也可能不存在,因为在这个书籍出版时,这个规范尚未最终确定。
由于 WebGL 对索引的支持限制为 16 位整数,索引数组只能有 65,535 个元素长。如果你有一个需要更多索引的几何形状,你需要使用多个渲染调用。关于渲染调用的更多内容将在本章后面介绍。
JavaScript 类型化数组
可以在 www.khronos.org/registry/typedarray/specs/latest/ 找到关于类型化数组的规范。
最后,解除缓冲区绑定是一个好的实践。我们可以通过调用以下指令来实现:
gl.bindBuffer(gl.ARRAY_BUFFER, null);
我们将重复此处描述的相同调用,用于我们将要使用的每个 WebGL 缓冲区(VBO 或 IBO)。
让我们通过一个示例回顾一下我们刚刚学到的内容。我们将查看 ch02_01_square.html 的一个示例,以了解正方形的 VBO 和 IBO 的定义:
// Set up the buffers for the square
function initBuffers() {
/*
V0 V3
(-0.5, 0.5, 0) (0.5, 0.5, 0)
X---------------------X
| |
| |
| (0, 0) |
| |
| |
X---------------------X
V1 V2
(-0.5, -0.5, 0) (0.5, -0.5, 0)
*/
const vertices = [
-0.5, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0
];
// Indices defined in counter-clockwise order
indices = [0, 1, 2, 0, 2, 3];
// Setting up the VBO
squareVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, squareVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Setting up the IBO
squareIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, squareIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Clean
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
如果你想看到这个场景的实际效果,请在你的浏览器中打开 ch02_01_square.html 文件。
总结一下,对于每个缓冲区,我们想要执行以下操作:
-
创建一个新的缓冲区
-
绑定它使其成为当前缓冲区
-
使用类型化数组之一传递缓冲区数据
-
解除缓冲区绑定
操作 WebGL 缓冲区
操作 WebGL 缓冲区的操作总结如下表所示:
| 方法 | 描述 |
|---|---|
createBuffer() |
创建一个新的缓冲区。 |
deleteBuffer(buffer) |
删除提供的缓冲区。 |
| bindBuffer(target, buffer) | 绑定一个缓冲区对象。target 的有效值如下:
-
ARRAY_BUFFER(用于顶点) -
ELEMENT_ARRAY_BUFFER(用于索引)
|
| bufferData(target, data, type) | 提供缓冲区数据。target 的可接受值如下:
-
ARRAY_BUFFER(用于顶点) -
ELEMENT_ARRAY_BUFFER(用于索引)
如前所述,WebGL 只接受 JavaScript 类型数组作为数据。参数 type 是 WebGL 的性能提示。type 的可接受值如下:
-
STATIC_DRAW:缓冲区中的数据将不会更改(指定一次,使用多次) -
DYNAMIC_DRAW:数据将频繁更改(指定多次,使用多次) -
STREAM_DRAW:数据将在每个渲染周期中更改(指定一次,使用一次)
|
将属性关联到 VBO
一旦我们创建了 VBO,我们需要将这些缓冲区关联到顶点着色器属性。每个顶点着色器属性将根据建立的对应关系,仅引用一个 and* 仅一个 缓冲区,如图所示:

我们可以通过以下步骤实现这一点:
-
绑定 VBO
-
将属性指向当前绑定的 VBO
-
启用属性
-
解绑
让我们看看第一步。
绑定 VBO
我们已经知道如何做到这一点:
gl.bindBuffer(gl.ARRAY_BUFFER, myBuffer);
其中 myBuffer 是我们想要映射的缓冲区。
将属性指向当前绑定的 VBO
WebGL API 的主要部分是设置状态以向我们的 GLSL 程序提供数据。在这种情况下,我们的 GLSL 程序的唯一输入是 aVertexPosition,它是一个属性。在 第三章,灯光,我们将学习如何定义和引用顶点和片段着色器属性。现在,让我们假设我们有一个 aVertexPosition 属性,它描述了着色器中的顶点坐标。
允许将属性指向当前绑定的 VBO 的 WebGL 函数是 vertexAttribPointer。以下是其签名:
gl.vertexAttribPointer(index, size, type, normalize, stride, offset);
让我们逐个描述每个参数:
-
索引:我们将要映射到当前绑定缓冲区的属性的索引。
-
大小:表示当前绑定缓冲区中每个顶点存储的值的数量。
-
类型:指定当前缓冲区中存储的值的类型。它可以是以下常量之一:
FIXED、BYTE、UNSIGNED_BYTE、FLOAT、SHORT或UNSIGNED_SHORT。 -
归一化:此参数可以设置为
true或false。它处理超出本入门指南范围的数值转换。对于我们的目的,我们将此参数设置为false。 -
步长:如果步长为
0,则表示元素在缓冲区中按顺序存储。 -
偏移量:从缓冲区中读取对应属性值的起始位置。通常设置为
0,表示将从缓冲区的第一个元素开始读取值。
缓冲指针
vertexAttribPointer定义了一个从当前绑定的缓冲区中读取信息的指针。记住,如果没有当前绑定的 VBO,将会生成一个错误。
启用属性
最后,我们需要激活顶点着色器属性。按照我们的例子,我们只需要添加gl.enableVertexAttribArray(positionAttributeLocation);。
以下图表总结了映射过程:

解除 VBO 的绑定
作为一条经验法则,我们在使用完缓冲区后应该解除它们的绑定。我们可以通过以下方式做到:
gl.bindBuffer(gl.ARRAY_BUFFER, null);.
渲染
一旦我们定义了我们的 VBOs 并将它们映射到相应的顶点着色器属性,我们就可以准备渲染了!为此,我们可以使用两个 API 函数之一:drawArrays或drawElements。
绘图函数
drawArrays和drawElements函数用于向帧缓冲区写入。drawArrays使用缓冲区中定义的顶点数据的顺序来创建几何体。相比之下,drawElements使用索引来访问顶点数据缓冲区并创建几何体。drawArrays和drawElements都将仅使用已启用的数组。这些是映射到活动顶点着色器属性的顶点缓冲区对象。
在我们的例子中,包含顶点坐标的缓冲区是唯一的已启用数组。然而,在更一般的情况下,我们可能会有几个可用的已启用数组。
例如,我们可以有包含顶点颜色、顶点法线、纹理坐标以及应用程序所需的任何其他每顶点数据的数组。在这种情况下,每个数组都会映射到一个活动的顶点着色器属性。
使用多个 VBO
在第三章,光线,我们将学习如何使用顶点法线缓冲区和顶点坐标来为我们的几何体创建光照模型。在这种情况下,我们将有两个活动的数组:顶点坐标和顶点法线。
使用 drawArrays
当没有索引信息时,我们将调用drawArrays。在大多数情况下,当几何体足够简单,定义索引是过度时,drawArrays会被使用——例如,当我们想要渲染一个三角形或矩形时。在这种情况下,WebGL 将按照在 VBO 中定义的顶点坐标的顺序创建几何体。如果你有连续的三角形(就像我们在梯形示例中做的那样),你将不得不在 VBO 中重复这些坐标。
如果你需要重复许多顶点来创建几何体,drawArrays不是最佳方法,因为顶点数据复制的越多,对顶点着色器的调用就越多。这可能会降低整体性能,因为相同的顶点必须通过管道多次,每次它们在相应的 VBO 中重复时都要通过一次:

drawArrays 的签名如下:
gl.drawArrays(mode, first, count)
其中:
-
mode:表示我们将要渲染的原始类型。mode的可能值有gl.POINTS、gl.LINE_STRIP、gl.LINE_LOOP、gl.LINES、gl.TRIANGLE_STRIP、gl.TRIANGLE_FAN和gl.TRIANGLES。 -
first:指定启用的数组中的起始元素。 -
count:指定要渲染的元素数量。
WebGL drawArrays 规范
当调用 drawArrays 时,它使用每个启用的数组中的连续 count 个元素来构建一系列几何原始形状,从元素 first 开始。mode 指定构建哪些类型的原始形状以及数组元素如何构建这些原始形状。
使用 drawElements
与未定义 IBO 的前一种情况不同,drawElements 允许我们使用 IBO 来告诉 WebGL 如何渲染几何形状。记住,drawArrays 使用 VBOs,这意味着顶点着色器将处理 VBO 中出现的重复顶点多次。另一方面,drawElements 使用索引。因此,顶点只处理一次,并且可以在 IBO 中定义的次数内重复使用。这个特性减少了 GPU 上的内存和处理需求。
让我们重新回顾以下图表:

当我们使用 drawElements 时,我们需要至少两个缓冲区:一个 VBO 和一个 IBO。由于顶点着色器在每个顶点上执行,渲染管线使用 IBO 将几何形状组装成三角形。
使用 drawElements 绑定 IBO
当使用 drawElements 时,你需要确保相应的 IBO 当前已绑定。
drawElements 的签名如下:
gl.drawElements(mode, count, type, offset)
其中:
-
mode:表示我们将要渲染的原始类型。mode的可能值有POINTS、LINE_STRIP、LINE_LOOP、LINES、TRIANGLE_STRIP、TRIANGLE_FAN和TRIANGLES。 -
count:指定要渲染的元素数量。 -
type:指定索引中值的类型。必须是UNSIGNED_BYTE或UNSIGNED_SHORT,因为我们正在处理索引(整数)。 -
offset:指示缓冲区中哪个元素将是渲染的起点。通常它是第一个元素(零值)。
WebGL drawElements 规范
当调用 drawElements 时,它从启用的数组中从偏移量开始使用连续的 count 个元素来构建一系列几何原始形状。mode 指定构建哪些类型的原始形状以及数组元素如何构建这些原始形状。如果启用了多个数组,则每个数组都会被使用。
将一切整合在一起
由于你可能一直在等待看到所有这些是如何一起工作的,让我们回顾一个简单的 WebGL 程序,该程序渲染一个正方形。
行动时间:渲染正方形
按照以下步骤操作:
-
在代码编辑器(最好是支持语法高亮的编辑器)中打开
ch02_01_square.html文件。 -
使用以下图表的帮助检查此文件的结构:

- 网页包含以下内容:
-
<script id="vertex-shader" type="x-shader/x-vertex">脚本包含了顶点着色器代码。 -
<script id="fragment-shader" type="x-shader/x-fragment">脚本包含了片段着色器代码。我们暂时不会关注这两个脚本,因为它们将是下一章的主要学习内容。现在,只需注意我们有一个片段着色器和顶点着色器。 -
我们网页上的下一个脚本,
<script type="text/javascript">,包含了我们需要的所有 JavaScript WebGL 代码。这个脚本分为以下函数:
// Global variables that are set and used
// across the application
let gl,
program,
squareVertexBuffer,
squareIndexBuffer,
indices;
- 我们列出了一些我们在整个应用程序中使用的全局变量:
// Given an id, extract the content's of a shader script
// from the DOM and return the compiled shader
function getShader(id) {
const script = document.getElementById(id);
const shaderString = script.text.trim();
// Assign shader depending on the type of shader
let shader;
if (script.type === 'x-shader/x-vertex') {
shader = gl.createShader(gl.VERTEX_SHADER);
}
else if (script.type === 'x-shader/x-fragment') {
shader = gl.createShader(gl.FRAGMENT_SHADER);
}
else {
return null;
}
// Compile the shader using the supplied shader code
gl.shaderSource(shader, shaderString);
gl.compileShader(shader);
// Ensure the shader is valid
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
return null;
}
return shader;
}
getShader函数从 HTML 网页中提取具有给定id的着色器的内容:
// Create a program with the appropriate vertex and fragment shaders
function initProgram() {
const vertexShader = getShader('vertex-shader');
const fragmentShader = getShader('fragment-shader');
// Create a program
program = gl.createProgram();
// Attach the shaders to this program
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Could not initialize shaders');
}
// Use this program instance
gl.useProgram(program);
// We attach the location of these shader values to the program
// instance for easy access later in the code
program.aVertexPosition = gl.getAttribLocation(program, 'aVertexPosition');
}
initProgram函数获取网页中存在的顶点着色器和片段着色器的引用(即我们之前讨论的前两个脚本),并将它们传递给 GPU 进行编译。最后,我们将aVertexPosition属性的地址附加到program对象上,以便以后可以轻松引用。查找attribute和uniform位置是昂贵的;因此,此类操作应在初始化期间发生一次。我们将在后面的章节中介绍这些技术:
// Set up the buffers for the square
function initBuffers() {
/*
V0 V3
(-0.5, 0.5, 0) (0.5, 0.5, 0)
X---------------------X
| |
| |
| (0, 0) |
| |
| |
X---------------------X
V1 V2
(-0.5, -0.5, 0) (0.5, -0.5, 0)
*/
const vertices = [
-0.5, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0
];
// Indices defined in counter-clockwise order
indices = [0, 1, 2, 0, 2, 3];
// Setting up the VBO
squareVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, squareVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Setting up the IBO
squareIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, squareIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Clean
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
initBuffers函数包含了创建和初始化缓冲区的 API 调用,正如我们在本章前面讨论的那样。在这个例子中,我们创建一个 VBO 来存储正方形的坐标,以及一个 IBO 来存储正方形的索引:
// We call draw to render to our canvas
function draw() {
// Clear the scene
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// Use the buffers we've constructed
gl.bindBuffer(gl.ARRAY_BUFFER, squareVertexBuffer);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
gl.enableVertexAttribArray(program.aVertexPosition);
// Bind IBO
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, squareIndexBuffer);
// Draw to the scene using triangle primitives
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT,
0);
// Clean
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
draw函数将 VBO 映射到相应的顶点缓冲区属性program.aVertexPosition,并通过调用enableVertexAttribArray来启用它。然后它绑定 IBO 并调用drawElements函数。我们将在后面的章节中更详细地介绍这些内容:
// Entry point to our application
function init() {
// Retrieve the canvas
const canvas = utils.getCanvas('webgl-canvas');
// Set the canvas to the size of the screen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Retrieve a WebGL context
gl = utils.getGLContext(canvas);
// Set the clear color to be black
gl.clearColor(0, 0, 0, 1);
// Call the functions in an appropriate order
initProgram();
initBuffers();
draw();
}
init是整个应用程序的入口点。当页面加载完成后,通过window.onload = init调用init。需要注意的是,init内部函数调用的顺序对于设置和渲染几何形状非常重要。我们还设置了画布的尺寸以适应整个窗口的大小(全屏)。如前所述,在draw函数中,我们使用canvas.width和canvas.height作为绘图尺寸的真相。
- 在你偏好的 HTML5 浏览器(Firefox、Safari、Chrome 或 Opera)中打开
ch02_01_square.html文件,你应该会看到以下内容:

- 打开
ch02_01_square.html的代码,并向下滚动到initBuffers函数。请注意函数内部出现的注释中的图示。这个图示描述了顶点和索引的排列方式。你应该会看到以下内容:
/*
V0 V3
(-0.5, 0.5, 0) (0.5, 0.5, 0)
X---------------------X
| |
| |
| (0, 0) |
| |
| |
X---------------------X
V1 V2
(-0.5, -0.5, 0) (0.5, -0.5, 0)
*/
- 尝试修改现有的缓冲区,将正方形变成五边形。你会怎么做?
更新几何定义
修改顶点缓冲区数组和索引数组,以便生成的图形是五边形而不是正方形。为此,你需要向顶点数组中添加一个顶点,并在索引数组中定义一个额外的三角形。
- 将文件另存为不同的名称,并在你偏好的 HTML5 浏览器中打开它以进行测试。
刚才发生了什么?
你已经了解了符合 WebGL 应用程序的不同代码元素。initBuffers函数已经被仔细检查并修改,以渲染不同的几何形状。
尝试一下:更改正方形颜色
前往片段着色器并更改你的几何形状的颜色。
四分量颜色向量
格式是(红色,绿色,蓝色,alpha)。Alpha 现在始终为1.0,前三个参数是范围在0.0到1.0之间的浮点数。
记得在浏览器中打开它之前,在文本编辑器中更改文件后保存文件。
尝试一下:使用 drawArrays 进行渲染
我们的正方形是通过drawElements通过顶点和索引定义的。现在尝试使用drawArrays渲染相同的正方形。
提示
由于你不使用索引与drawArrays一起使用,因此你不需要 IBO。所以,你需要复制顶点来构建这个几何形状。
提示为了参考,你可以在这个练习的ch02_02_square-arrays.html中找到源代码。
顶点数组对象
顶点数组对象(VAOs)允许你将一组缓冲区的所有顶点/索引绑定信息存储在单个易于管理的对象中。也就是说,属性的状态、每个属性使用的缓冲区以及如何从这些缓冲区中提取数据都收集到 VAO 中。尽管我们可以通过使用扩展在 WebGL 1 中实现 VAOs,但它们在 WebGL 2 中默认可用。
这是一个应该始终使用的重要功能,因为它可以显著减少渲染时间。当不使用 VAOs 时,所有属性数据都在全局 WebGL 状态中,这意味着调用如gl.vertexAttribPointer、gl.enableVertexAttribArray和gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffer)等函数会操作全局状态。这会导致性能损失,因为在任何绘制调用之前,我们需要设置所有顶点属性并设置ELEMENT_ARRAY_BUFFER,其中使用了索引数据。另一方面,使用 VAOs 时,我们会在应用程序初始化期间设置所有属性,并在渲染时简单地绑定数据,从而获得更好的性能。
让我们看看我们如何从这里开始使用 VAOs!
行动时间:使用 VAO 渲染正方形
让我们重构一个使用 VAOs 的先前示例:
-
在你的编辑器中打开
ch02_01_square.html。 -
首先,我们更新全局变量:
// Global variables that are set and used
// across the application
let gl,
program,
squareVAO,
squareIndexBuffer,
indices;
-
我们已经将
squareVertexBuffer替换为squareVAO,因为我们不再需要直接引用顶点缓冲区。 -
接下来,我们按照以下方式更新
initBuffers函数:
// Set up the buffers for the square
function initBuffers() {
/*
V0 V3
(-0.5, 0.5, 0) (0.5, 0.5, 0)
X---------------------X
| |
| |
| (0, 0) |
| |
| |
X---------------------X
V1 V2
(-0.5, -0.5, 0) (0.5, -0.5, 0)
*/
const vertices = [
-0.5, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0
];
// Indices defined in counter-clockwise order
indices = [0, 1, 2, 0, 2, 3];
// Create VAO instance
squareVAO = gl.createVertexArray();
// Bind it so we can work on it
gl.bindVertexArray(squareVAO);
const squareVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, squareVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Provide instructions for VAO to use data later in draw
gl.enableVertexAttribArray(program.aVertexPosition);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
// Setting up the IBO
squareIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, squareIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
-
我们使用
gl.createVertexArray();创建一个新的 VAO 实例,并将其分配给squareVAO。 -
然后,我们使用
gl.bindVertexArray(squareVAO);绑定squareVAO,这样所有的属性设置都将应用于那一组属性状态。 -
在配置了
squareVertexBuffer之后,我们指导当前绑定的 VAO(即squareVAO)如何根据aVertexPosition的指令提取数据。这些指令与之前位于draw函数中的指令相同;但现在,它们在初始化时只发生一次。 -
最后,我们需要在我们的
draw函数中使用这个 VAO:
// We call draw to render to our canvas
function draw() {
// Clear the scene
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// Bind the VAO
gl.bindVertexArray(squareVAO);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, squareIndexBuffer);
// Draw to the scene using triangle primitives
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT,
0);
// Clean
gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
-
更新的
draw函数要简单得多!我们只需绑定 VAO(即squareVAO),并允许它在initBuffers中处理我们提供的指令。 -
最后,使用后解除缓冲区和 VAO 的绑定是一个好习惯,通过提供
null值来实现。 -
保存文件并在浏览器中打开它。你应该会看到使用 VAO 渲染的相同正方形:

- 这个练习的源代码可以在
ch02_03_square-vao.html中找到。
由于我们目前正在渲染单个几何体,使用 VAO 可能看起来是不必要的复杂。这是一个合理的评估!然而,随着我们应用程序的复杂性增加,使用 VAO 成为了一个基础特性。
行动时间:渲染模式
让我们回顾一下drawElements函数的签名:
gl.drawElements(mode, count, type, offset)
第一个参数确定我们要渲染的图元类型。在下一节中,我们将通过示例看到不同的渲染模式。
按照以下步骤操作:
-
在浏览器中打开
ch02_04_rendering-modes.html文件。这个例子与上一节的结构相同。 -
在你的编辑器中打开
ch02_04_rendering-modes.html文件,并滚动到initBuffers函数:
function initBuffers() {
const vertices = [
-0.5, -0.5, 0,
-0.25, 0.5, 0,
0.0, -0.5, 0,
0.25, 0.5, 0,
0.5, -0.5, 0
];
indices = [0, 1, 2, 0, 2, 3, 2, 3, 4];
// Create VAO
trapezoidVAO = gl.createVertexArray();
// Bind VAO
gl.bindVertexArray(trapezoidVAO);
const trapezoidVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, trapezoidVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Provide instructions to VAO
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
gl.enableVertexAttribArray(program.aVertexPosition);
trapezoidIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, trapezoidIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
-
这里,你会看到我们在绘制一个梯形。然而,在屏幕上,你会看到两个三角形!稍后,我们将看到这是如何发生的。
-
在页面顶部,有一个设置控制器,允许你选择 WebGL 提供的不同渲染模式:

let gl,
canvas,
program,
indices,
trapezoidVAO,
trapezoidIndexBuffer,
// Global variable that captures the current rendering mode type
renderingMode = 'TRIANGLES';
-
当你从设置中选择任何选项时,你正在改变代码顶部定义的
renderingMode变量的值(如果你想看到它的定义位置,请向上滚动)。设置控制器设置的代码位于initControls函数中。我们将在稍后介绍这个功能。 -
要查看每个选项如何修改渲染,请滚动到
draw函数:
function draw() {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// Bind VAO
gl.bindVertexArray(trapezoidVAO);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, trapezoidIndexBuffer);
// Depending on the rendering mode type, we will draw differently
switch (renderingMode) {
case 'TRIANGLES': {
indices = [0, 1, 2, 2, 3, 4];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.TRIANGLES, indices.length,
gl.UNSIGNED_SHORT,
0);
break;
}
case 'LINES': {
indices = [1, 3, 0, 4, 1, 2, 2, 3];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.LINES, indices.length, gl.UNSIGNED_SHORT,
0);
break;
}
case 'POINTS': {
indices = [1, 2, 3];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.POINTS, indices.length, gl.UNSIGNED_SHORT,
0);
break;
}
case 'LINE_LOOP': {
indices = [2, 3, 4, 1, 0];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.LINE_LOOP, indices.length,
gl.UNSIGNED_SHORT, 0);
break;
}
case 'LINE_STRIP': {
indices = [2, 3, 4, 1, 0];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.LINE_STRIP, indices.length,
gl.UNSIGNED_SHORT, 0);
break;
}
case 'TRIANGLE_STRIP': {
indices = [0, 1, 2, 3, 4];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.TRIANGLE_STRIP, indices.length,
gl.UNSIGNED_SHORT, 0);
break;
}
case 'TRIANGLE_FAN': {
indices = [0, 1, 2, 3, 4];
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(indices), gl.STATIC_DRAW);
gl.drawElements(gl.TRIANGLE_FAN, indices.length,
gl.UNSIGNED_SHORT, 0);
break;
}
}
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
- 你会看到在绑定 IBO
trapezoidIndexBuffer的以下指令之后:
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, trapezoidIndexBuffer);
-
你还有一个 switch 语句,其中有一些代码会根据
renderingMode变量的值执行。 -
对于每种模式,我们定义 JavaScript 数组索引的内容。然后,我们通过使用
bufferData函数将这个数组传递给当前绑定的缓冲区,即trapezoidIndexBuffer。最后,我们调用drawElements函数。 -
让我们看看每种模式的作用:
| 模式 | 描述 |
|---|---|
TRIANGLES |
当您使用TRIANGLES模式时,WebGL 将使用您在 IBO 中定义的前三个索引来构建第一个三角形,接下来三个来构建第二个三角形,依此类推。在这个例子中,我们正在绘制两个三角形,这可以通过检查填充 IBO 的 JavaScript 索引数组来验证:indices = [0, 1, 2, 2, 3, 4];。 |
LINES |
LINES模式将指示 WebGL 根据 IBO 中定义的连续索引对绘制线条,通过获取相应顶点的坐标。例如,indices = [1, 3, 0, 4, 1, 2, 2, 3];将绘制四条线:从顶点编号1到顶点编号3,从顶点编号0到顶点编号4,从顶点编号1到顶点编号2,以及从顶点编号2到顶点编号3。 |
POINTS |
当我们使用POINTS模式时,WebGL 不会生成表面。相反,它将使用索引数组渲染我们定义的顶点。在这个例子中,我们将只使用indices = [1, 2, 3];渲染顶点编号1、2和3。 |
LINE_LOOP |
LINE_LOOP 绘制一个闭合循环,将 IBO 中定义的顶点连接到下一个顶点。在我们的情况下,它将是indices = [2, 3, 4, 1, 0];。 |
LINE_STRIP |
LINE_STRIP 与LINE_LOOP类似。区别在于 WebGL 不会将最后一个顶点连接到第一个顶点(不是闭合循环)。indicesJavaScript 数组将是indices = [2, 3, 4, 1, 0];。 |
TRIANGLE_STRIP |
TRIANGLE_STRIP 绘制连接的三角形。在第一个三个顶点之后指定每个顶点。在我们的例子中,顶点编号0、1和2创建了一个新的三角形。如果我们有indices = [0, 1, 2, 3, 4];,那么我们将生成三角形(0, 1, 2)、(1, 2, 3)和(2, 3, 4)。 |
TRIANGLE_FAN |
TRIANGLE_FAN 以类似于TRIANGLE_STRIP的方式创建三角形。然而,在 IBO 中定义的第一个顶点被用作扇形的起点(连续三角形之间的唯一共享顶点)。在我们的例子中,indices = [0, 1, 2, 3, 4];将创建三角形(0, 1, 2)和(0, 3, 4)。 |
- 下面的图示可以帮助可视化这些不同的渲染模式。但说到底,通过更改设置的下拉值并查看各种结果,最容易看到这些模式的效果:

- 让我们通过编辑
ch02_04_rendering-modes.html来做出一些更改,以便当您选择TRIANGLES选项时,渲染梯形而不是两个三角形。
提示
您需要在indices数组中添加一个额外的三角形。
-
保存文件并在浏览器中测试它。
-
编辑网页,以便使用
LINES选项绘制字母M。
提示
您需要在indices数组中定义四条线。
-
就像之前一样,保存您的更改并在浏览器中测试它们。
-
使用
LINE_LOOP模式,只绘制梯形的边界。
发生了什么?
这个简单的练习帮助我们看到了 WebGL 支持的不同渲染模式。这些不同的模式决定了如何解释顶点和索引数据以渲染对象。
WebGL 作为状态机:缓冲区操作
当处理getParameter、getBufferParameter和isBuffer函数的缓冲区时,关于渲染管线状态的新信息对我们变得可用。
与第一章 入门 类似,我们将使用getParameter(parameter),其中parameter可以有以下值:
-
ARRAY_BUFFER_BINDING: 获取当前绑定的 VBO 的引用 -
ELEMENT_ARRAY_BUFFER_BINDING: 获取当前绑定的 IBO 的引用
我们还可以使用getBufferParameter(type, parameter)查询当前绑定的 VBO 和 IBO 的大小和用途,其中type可以有以下值:
-
ARRAY_BUFFER: 用于引用当前绑定的 VBO -
ELEMENT_ARRAY_BUFFER: 用于引用当前绑定的 IBO
并且parameter可以有以下值:
-
BUFFER_SIZE: 返回请求的缓冲区大小 -
BUFFER_USAGE: 返回请求的缓冲区的使用情况
绑定缓冲区
当你使用getParameter和getBufferParameter检查当前绑定的 VBO 和/或 IBO 的状态时,你的 VBO 和/或 IBO 需要被绑定。
最后,isBuffer(object)如果对象是 WebGL 缓冲区,将返回true,如果缓冲区无效,则返回false并出现错误。与getParameter和getBufferParameter不同,isBuffer不需要绑定任何 VBO 或 IBO。
行动时间:查询缓冲区状态
按照给定的步骤进行:
- 在你的浏览器中打开
ch02_05_state-machine.html文件。你应该看到以下内容:

- 在你的编辑器中打开
ch02_05_state-machine.html,并滚动到initBuffers方法:
function initBuffers() {
const vertices = [
1.5, 0, 0,
-1.5, 1, 0,
-1.5, 0.809017, 0.587785,
-1.5, 0.309017, 0.951057,
-1.5, -0.309017, 0.951057,
-1.5, -0.809017, 0.587785,
-1.5, -1, 0,
-1.5, -0.809017, -0.587785,
-1.5, -0.309017, -0.951057,
-1.5, 0.309017, -0.951057,
-1.5, 0.809017, -0.587785
];
indices = [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 5,
0, 5, 6,
0, 6, 7,
0, 7, 8,
0, 8, 9,
0, 9, 10,
0, 10, 1
];
// Create VAO
coneVAO = gl.createVertexArray();
// Bind VAO
gl.bindVertexArray(coneVAO);
const coneVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, coneVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Configure instructions
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
gl.enableVertexAttribArray(program.aVertexPosition);
coneIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, coneIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Set the global variables based on the parameter type
if (coneVertexBuffer ===
gl.getParameter(gl.ARRAY_BUFFER_BINDING)) {
vboName = 'coneVertexBuffer';
}
if (coneIndexBuffer ===
gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)) {
iboName = 'coneIndexBuffer';
}
vboSize = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
vboUsage = gl.getBufferParameter(gl.ARRAY_BUFFER,
gl.BUFFER_USAGE);
iboSize = gl.getBufferParameter(gl.ELEMENT_ARRAY_BUFFER,
gl.BUFFER_SIZE);
iboUsage = gl.getBufferParameter(gl.ELEMENT_ARRAY_BUFFER,
gl.BUFFER_USAGE);
try {
isVerticesVbo = gl.isBuffer(vertices);
}
catch (e) {
isVerticesVbo = false;
}
isConeVertexBufferVbo = gl.isBuffer(coneVertexBuffer);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
-
注意我们如何使用本节中讨论的方法来检索和显示缓冲区当前状态的信息。
-
当我们使用
updateInfo时,initBuffers函数查询的信息显示在网页的设置部分。 -
在网页的设置部分,你会看到以下结果:

-
复制以下行,
gl.bindBuffer(gl.ARRAY_BUFFER, null);,并将其粘贴到initBuffers函数中以下行之前:coneIndexBuffer = gl.createBuffer();。 -
当你在浏览器中再次打开页面时会发生什么?
-
你认为这种行为为什么会发生?
究竟发生了什么?
你已经了解到,当前绑定的缓冲区是 WebGL 中的一个状态变量。缓冲区绑定直到你通过再次调用 bindBuffer 并将相应的类型(ARRAY_BUFFER 或 ELEMENT_ARRAY_BUFFER)作为第一个参数,以及 null 作为第二个参数(即没有要绑定的缓冲区)来解绑它。你也已经了解到,你只能查询当前绑定的缓冲区的状态。因此,如果你想查询不同的缓冲区,你需要先绑定它。
尝试一下:添加一个验证
修改文件,以便你可以验证并显示在屏幕上索引数组和 coneIndexBuffer 是否是 WebGL 缓冲区。
提示
为了显示值,你将不得不修改 HTML 体内的表格,并相应地修改 updateInfo 函数。
高级几何加载技术
到目前为止,我们渲染了非常简单的对象。现在,让我们研究如何从文件中加载几何形状(顶点和索引)而不是每次调用 initBuffers 时都声明顶点和索引。为此,我们将使用 AJAX 对网络服务器进行异步调用。我们将从网络服务器检索包含我们几何形状的文件,然后使用内置的 JSON 解析器将文件的上下文转换为 JavaScript 对象。在我们的例子中,这些对象将是顶点和索引数组。
JavaScript 对象表示法(JSON)简介
JSON 代表 JavaScript 对象表示法。它是一种轻量级、基于文本的开放格式,用于数据交换。JSON 通常用作 XML 的替代品。
JSON 的强大之处在于它是语言无关的。这意味着有许多语言的解析器可以读取和解释 JSON 对象。此外,JSON 是 JavaScript 对象字面量表示法的子集。因此,我们可以使用 JSON 定义 JavaScript 对象。
定义基于 JSON 的 3D 模型
例如,假设我们有一个包含两个数组:顶点 vertices 和索引 indices 的 model 对象。假设这些数组包含圆锥示例(ch02_06_cone.html)中描述的信息,如下所示:
function initBuffers() {
const vertices = [
1.5, 0, 0,
-1.5, 1, 0,
-1.5, 0.809017, 0.587785,
-1.5, 0.309017, 0.951057,
-1.5, -0.309017, 0.951057,
-1.5, -0.809017, 0.587785,
-1.5, -1, 0,
-1.5, -0.809017, -0.587785,
-1.5, -0.309017, -0.951057,
-1.5, 0.309017, -0.951057,
-1.5, 0.809017, -0.587785
];
indices = [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 5,
0, 5, 6,
0, 6, 7,
0, 7, 8,
0, 8, 9,
0, 9, 10,
0, 10, 1
];
// ...
}
根据 JSON 语法,我们会将这些两个数组表示为一个对象,如下所示:
{
"vertices": [
1.5, 0, 0,
-1.5, 1, 0,
-1.5, 0.809017, 0.587785,
-1.5, 0.309017, 0.951057,
-1.5, -0.309017, 0.951057,
-1.5, -0.809017, 0.587785,
-1.5, -1, 0,
-1.5, -0.809017, -0.587785,
-1.5, -0.309017, -0.951057,
-1.5, 0.309017, -0.951057,
-1.5, 0.809017, -0.587785
],
"indices": [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 5,
0, 5, 6,
0, 6, 7,
0, 7, 8,
0, 8, 9,
0, 9, 10,
0, 10, 1
]
}
基于此示例,我们可以推断出以下语法规则:
-
JSON 对象的范围由大括号 (
{}) 定义。 -
JSON 对象中的属性由逗号 (
,) 分隔。 -
最后一个属性后没有逗号。
-
JSON 对象的每个属性由两部分组成:一个 键 和一个 值。
-
属性的名称用引号 (
"") 括起来。 -
每个属性键与其对应的值之间用冒号 (
:) 分隔。 -
数组的属性定义方式与你在 JavaScript 中定义它们的方式相同。
行动时间:编码和解码 JSON
大多数现代网络浏览器支持原生的 JSON 编码和解码。让我们来看看这个对象内部可用的方法:
| 方法 | 描述 |
|---|---|
JSON.stringify(object) |
我们使用 JSON.stringify 将 JavaScript 对象转换为 JSON 格式的文本。 |
JSON.parse(string) |
我们使用 JSON.parse 将文本转换为 JavaScript 对象。 |
让我们通过创建一个简单的模型——一个 3D 线——来学习如何使用 JSON 符号进行编码和解码。在这里,我们将关注我们如何进行 JSON 编码和解码。按照以下步骤进行:
- 在您的浏览器中打开交互式 JavaScript 控制台。使用以下表格进行帮助:
| 浏览器 | 快捷键(PC/Mac) |
|---|---|
| Firefox | Ctrl + Shift + K/Command + Alt + K |
| Safari | Ctrl + Shift + C/Command + Alt + C |
| Chrome | Ctrl + Shift + J/Command + Alt +* J* |
- 通过输入以下内容创建一个 JSON 对象:
const model = { vertices: [0, 0, 0, 1, 1, 1], indices: [0, 1] };
- 通过编写以下内容来验证模型是否为对象:
typeof(model); // outputs "object"
JavaScript 类型检查
由于 JavaScript 中的许多东西都是 对象,建议你在类型检查上更加严谨。我们在这里仅使用 typeof 进行演示。此外,还有许多实用库,如 Lodash (lodash.com),它扩展了 JavaScript 功能,提供了这些操作以及更多。
- 让我们打印模型属性。在控制台中输入以下内容(每行输入后按 Enter):
model.vertices // outputs the vertices
model.indices // outputs the indices
- 让我们创建一个 JSON 文本:
const text = JSON.stringify(model);
alert(text);
-
当你输入
text.vertices时会发生什么? -
如您所见,您会收到一条消息,表明
text.vertices是undefined。这是因为文本不是一个 JavaScript 对象,而是一个按照 JSON 符号编写的string,用来描述一个object。它里面的所有内容都是文本,因此它没有任何字段。 -
让我们将 JSON 文本转换回对象。输入以下内容:
const model2 = JSON.parse(text);
typeof(model2); // outputs "object"
model2.vertices; // outputs vertices
刚才发生了什么?
我们已经学会了如何编码和解码 JSON 对象。这些练习是相关的,因为我们将使用相同的过程来定义从外部文件加载的几何形状。在下一节中,我们将看到如何从网络服务器下载用 JSON 指定的几何模型。
使用 AJAX 进行异步加载
以下图表总结了网页浏览器使用 AJAX 异步加载文件的过程:

让我们更仔细地分析一下:
-
请求文件:指示您想要加载的文件的路径。请记住,这个文件包含我们将从网络服务器加载的几何形状,而不是直接将 JavaScript 数组(顶点和索引)编码到网页中。
-
AJAX 请求:我们需要编写一个执行 AJAX 请求的函数。让我们称这个函数为
load。代码如下:
// Given a path to a file, load the assets asynchronously
function load(filePath) {
// We return the promise so that, if needed, you can know when
// `load` has resolved
return fetch(filePath)
// Convert to a valid json
.then(res => res.json())
// Handle the parsed JSON data
.then(data => {
// Handle data
})
.catch(error => {
// Handle error
});
}
使用 Fetch 的 AJAX 请求我们正在利用现代浏览器提供的 AJAX API,即 fetch,来获取资源。它基于 Promise 的实现非常方便。要了解更多关于 fetch 的信息,请访问 developer.mozilla.org/en-US/docs/Web/API/Fetch_API。
目前,让我们假设这个函数将执行 AJAX 请求。
-
检索文件:网络服务器将接收并处理我们的请求作为一个常规 HTTP 请求。实际上,服务器并不知道这个请求是异步的(对于浏览器来说它是异步的,因为它不会等待答案)。服务器将寻找我们的文件并生成一个响应,无论它是否找到请求。
-
异步响应:一旦将响应发送到浏览器,
fetchPromise 就会解决,并调用提供的回调。这个回调对应于then请求方法。如果请求成功,我们调用then回调;如果失败,我们调用catch回调。 -
处理加载的模型:在数据接收并解析后,我们为从服务器检索的文件附加一个新的回调来处理。请注意,在前面的代码段中,我们使用了基于 Promise 的 JSON 解析器在传递给下一个函数之前将文件转换为 JavaScript 对象。
load函数的代码如下:
// Given a path to a file, load the assets asynchronously
function load(filePath) {
// We return the promise so that, if needed, you can know when
// `load` has resolved
return fetch(filePath)
// Convert to a valid json
.then(res => res.json())
// Handle the parsed JSON data
.then(data => {
model = data;
// Create VAO
vao = gl.createVertexArray();
// Bind VAO
gl.bindVertexArray(coneVAO);
const modelVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, modelVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new
Float32Array(model.vertices), gl.STATIC_DRAW);
// Configure instructions
gl.enableVertexAttribArray(program.aVertexPosition);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
modelIndexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, modelIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new
Uint16Array(model.indices), gl.STATIC_DRAW);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
})
// Display into the console if there are any errors
.catch(console.error);
}
如果您仔细观察,您会发现这个函数与我们之前看到的一个函数非常相似:initBuffers函数。这是合理的,因为我们不能在从服务器检索几何数据之前初始化缓冲区。就像initBuffers一样,我们配置我们的 VAO、VBO 和 IBO,并将我们的模型对象中包含的信息传递给它们。
设置网络服务器
现在我们从服务器获取资源,我们需要通过服务器来提供服务。如果您没有网络服务器,我们建议您从以下选项中安装一个轻量级网络服务器:
-
Serve:
github.com/zeit/serve -
Lighttpd:
www.lighttpd.net -
Python 服务器:
developer.mozilla.org/en-US/docs/Learn/Common_questions/set_up_a_local_testing_server
主机示例尽管任何网络服务器都可以提供这些示例,但serve提供了简单性和强大的功能。话虽如此,请确保您从示例目录的根目录运行您的服务器,因为common目录是跨章节的共享依赖项。
解决网络服务器需求的问题
如果您使用 Firefox 并且不想安装网络服务器,您可以在about:config中将strict_origin_policy更改为 false。
如果您使用 Chrome 并且不想安装网络服务器,请确保您从命令行使用以下修饰符运行它:
--allow-file-access-from-files
让我们使用 AJAX 和 JSON 从我们的网络服务器加载一个圆锥体。
动手时间:使用 AJAX 加载圆锥体
按照以下步骤操作:
- 确保您的网络服务器正在运行,并使用您的网络服务器访问
ch02_07_ajax-cone.html文件。
网络服务器地址
如果你知道你正在使用网络服务器,地址栏中的 URL 以 localhost/ 或 127.0.0.1/ 开头而不是 file://,那么你就知道了。
- 包含本章代码的文件夹应该看起来像这样:

-
点击
ch02_07_ajax-cone.html。 -
示例将在你的浏览器中加载,你会看到类似这样的:

-
请审查
load函数以更好地理解 AJAX 和 JSON 在应用程序中的使用。 -
全局
model变量是如何使用的? (检查源代码。) -
检查当你更改
common/models/geometries/cone.json文件中的颜色并重新加载页面时会发生什么。 -
修改
common/models/geometries/cone.json文件中圆锥的坐标并重新加载页面。在这里,你可以验证 WebGL 是否从文件中读取和渲染坐标。如果你在文件中修改它们,屏幕上的几何形状将更新。
刚才发生了什么?
你学习了如何使用 AJAX 和 JSON 从远程位置(网络服务器)加载几何形状,而不是在网页内部指定这些几何形状(使用 JavaScript 数组)。
尝试加载一辆日产 GTR
按照以下步骤操作:
-
使用您的网络服务器打开
ch02_08_ajax-car.html文件。 -
你应该会看到类似这样的:

-
我们选择
LINES模型而不是TRIANGLES模型的原因是易于可视化汽车的结构。 -
找到选择渲染模式的行,并确保你理解代码的作用。
-
前往
draw函数。 -
在
drawElements指令中,将模式从gl.LINES更改为gl.TRIANGLES。 -
在网络浏览器中刷新页面。
-
你看到了什么?你能猜出为什么视觉效果不同吗?你的推理是什么?
灯光
照明帮助我们更清晰地可视化复杂几何形状。没有灯光,我们所有的体积看起来都是不透明的,当从 LINES 切换到 TRIANGLES 时,很难区分它们的各个部分。
架构更新
让我们介绍一些有用的函数,我们可以重构它们以在后续章节中使用:
-
在你的编辑器中打开
common/js/utils.js,查看以下更改。 -
我们在
utils.js中添加了两个额外的函数,autoResizeCanvas和getShader,它们看起来与我们本章早期实现的代码非常相似:
'use strict';
// A set of utility functions for /common operations across our
// application
const utils = {
// Find and return a DOM element given an ID
getCanvas(id) {
// ...
},
// Given a canvas element, return the WebGL2 context
getGLContext(canvas) {
// ...
},
// Given a canvas element, expand it to the size of the window
// and ensure that it automatically resizes as the window changes
autoResizeCanvas(canvas) {
const expandFullScreen = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
expandFullScreen();
window.addEventListener('resize', expandFullScreen);
},
// Given a WebGL context and an id for a shader script,
// return a compiled shader
getShader(gl, id) {
const script = document.getElementById(id);
if (!script) {
return null;
}
const shaderString = script.text.trim();
let shader;
if (script.type === 'x-shader/x-vertex') {
shader = gl.createShader(gl.VERTEX_SHADER);
}
else if (script.type === 'x-shader/x-fragment') {
shader = gl.createShader(gl.FRAGMENT_SHADER);
}
else {
return null;
}
gl.shaderSource(shader, shaderString);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
return null;
}
return shader;
}
};
-
autoResizeCanvas方法接受一个canvas元素,并通过监听浏览器调整大小事件动态调整其大小以全屏显示。 -
getShader函数接受一个gl实例和一个id脚本来编译并返回着色器源代码。内部,getShader读取脚本的源代码并将其存储在局部变量中。然后,它通过使用 WebGL 的createShader函数创建一个新的着色器。之后,它将源代码添加到其中使用shaderSource函数。最后,它将尝试使用compileShader函数编译着色器。 -
在您的编辑器中打开
ch02_09_ajax-car-final.html以查看以下更改。 -
滚动到
init函数,那里进行了必要的修改以使用utils.autoResizeCanvas方法:
function init() {
const canvas = utils.getCanvas('webgl-canvas');
// Handle automatic resizing
utils.autoResizeCanvas(canvas);
// Retrieve a valid WebGL2 context
gl = utils.getGLContext(canvas);
gl.clearColor(0, 0, 0, 1);
gl.enable(gl.DEPTH_TEST);
initProgram();
// We are no longer blocking the render until `load` has
// resolved, as we're not returning a Promise.
load();
render();
}
- 滚动到
ch02_09_ajax-car-final.html文件中的initProgram函数,那里进行了必要的修改以使用utils.getShader方法:
function initProgram() {
// Retrieve shaders based on the shader script IDs
const vertexShader = utils.getShader(gl, 'vertex-shader');
const fragmentShader = utils.getShader(gl, 'fragment-shader');
program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Could not initialize shaders');
}
gl.useProgram(program);
program.aVertexPosition = gl.getAttribLocation(program,
'aVertexPosition');
program.uProjectionMatrix = gl.getUniformLocation(program,
'uProjectionMatrix');
program.uModelViewMatrix = gl.getUniformLocation(program,
'uModelViewMatrix');
}
- 在浏览器中打开
ch02_09_ajax-car-final.html以查看这些更改的效果。
摘要
让我们总结一下本章所学的内容:
-
WebGL API 本身只是一个光栅化器,从概念上讲相当简单。
-
WebGL 的渲染管线描述了如何使用 WebGL 缓冲区,并将它们以属性的形式传递给顶点着色器进行处理。顶点着色器在 GPU 中并行化顶点处理。顶点定义了将要渲染的几何体的表面。表面上的每个元素都称为片段。这些片段由片段着色器处理。
-
片段处理也在 GPU 中并行进行。当所有片段都处理完毕后,帧缓冲区,一个二维数组,包含随后在屏幕上显示的图像。
-
WebGL 实际上是一个相当简单的 API。它的任务是执行两个用户提供的函数,一个顶点着色器和片段着色器,并绘制三角形、线条或点。虽然进行 3D 操作可能会更复杂,但这种复杂性是由程序员通过更复杂的着色器添加的。
-
WebGL 渲染几何体的详细过程。请记住,有两种 WebGL 缓冲区用于处理几何渲染:VBOs 和 IBOs.
-
WebGL 作为一个状态机工作。因此,指向缓冲区的属性是可用的,它们的值取决于当前绑定的缓冲区。
-
JSON 和 AJAX 是两种与 WebGL 集成良好的 JavaScript 技术,使我们能够加载大型和复杂的资源。
在下一章中,我们将学习更多关于着色器的内容,并使用它们通过在 WebGL JavaScript API 和属性、统一变量以及插值变量之间传递信息来实现 WebGL 场景中的光源。
第三章:光源
在上一章中,我们介绍了 WebGL 的渲染管线,定义了几何形状,将数据传递给 GPU,绘制类型,并利用 AJAX 异步加载外部资源。虽然我们简要介绍了着色器及其在创建 WebGL 应用中的作用,但我们将在本章中更详细地介绍,并利用顶点和片段着色器为我们的场景创建光照模型。
着色器允许我们定义一个数学模型,该模型控制我们的场景如何被照明。为了学习如何实现着色器,我们将研究不同的算法,并查看它们的示例应用。对线性代数的基本了解将非常有用,可以帮助你理解本章的内容。我们将使用一个 JavaScript 库来处理大多数的向量和矩阵运算,因此你不需要担心数学运算。尽管如此,你的整体成功取决于对我们将要讨论的线性代数运算的强大概念理解。
在本章中,我们将:
-
了解光源、法线和材质。
-
学习着色与光照之间的区别。
-
使用 Goraud 和 Phong 着色方法。
-
使用 Lambertian 和 Phong 光照模型。
-
定义和使用统一变量、属性和变量。
-
使用 ESSL,WebGL 的着色语言。
-
讨论与着色器相关的相关 WebGL API 方法。
-
继续分析 WebGL 作为状态机,并描述与着色器相关的可设置和从状态机检索的属性。
光源、法线和材质
在现实世界中,我们看到物体是因为它们反射光线。物体的照明取决于其相对于光源的位置、表面朝向以及其材料组成。在本章中,我们将学习如何在 WebGL 中结合这三个元素来模拟不同的照明方案:

位置光源与方向光源的比较
光源可以是位置的或方向的。当光源的位置会影响场景的照明时,称为位置光源。例如,房间内的灯是一个位置光源。远离灯的物体将接收到很少的光线,甚至可能看起来模糊。相比之下,方向光是指无论其位置如何都会产生相同发光效果的光。例如,太阳光将照亮地面场景中的所有物体,无论它们与太阳的距离如何。这是因为太阳非常遥远,当光线与物体的表面相交时,所有光线都被认为是平行的。方向光照假设光线从同一方向均匀地照射过来:

位置光线通过空间中的一个点来建模,而方向光线则通过表示其方向的向量来建模。出于简化数学运算的目的,通常使用归一化向量,因为这样可以简化数学运算。此外,计算方向光通常比计算位置光更简单,计算成本也更低。
法线
法线是与我们要照亮的表面垂直的向量。法线代表表面的方向,因此对于模拟光源与物体之间的相互作用至关重要。鉴于每个顶点都有一个相关的法线向量,我们可以使用叉积来计算法线。
叉积根据定义,向量A和B的叉积将是一个垂直于向量A和B的向量。
让我们分解一下。如果我们有一个由顶点p0、p1和p2构成的三角形,我们可以定义v1向量为p1 - p0,v2向量为p2 - p0。然后,通过计算v1 x v2的叉积来获得法线。从图形上看,这个过程类似于以下内容:

然后,我们对每个三角形上的每个顶点重复相同的计算。但是,对于被多个三角形共享的顶点怎么办?每个共享顶点的法线将接收每个出现该顶点的三角形的一个贡献。
例如,假设p1顶点被#1和#2三角形共享,并且我们已经计算了#1三角形顶点的法线。然后,我们需要通过将#2三角形上p1的法线计算结果相加来更新p1的法线。这是一个向量求和。从图形上看,这类似于以下内容:

与光线类似,法线通常被归一化以简化数学运算。
材质
在 WebGL 中,可以通过包括颜色和纹理在内的多个参数来模拟物体的材质。材质颜色通常在 RGB(红色、绿色、蓝色)空间中建模为三元组。另一方面,纹理对应于映射到物体表面的图像。这个过程通常被称为纹理映射。我们将在后面的章节中介绍纹理映射。
在管线中使用光线、法线和材料
在第二章“渲染”中,我们讨论了 WebGL 缓冲区、属性和统一变量作为着色器的输入变量,以及变量用于在顶点着色器和片段着色器之间传递信息。现在让我们回顾一下管线,看看光线、法线和材料是如何融入其中的:

法线是在每个顶点的基础上定义的;因此,法线被建模为一个 VBO,并使用属性映射,如前图所示。请注意,属性不能直接传递给片段着色器。为了将信息从顶点着色器传递到片段着色器,我们必须使用插值变量。
灯光和材质作为统一变量传递。统一变量对顶点着色器和片段着色器都是可用的。这为我们计算光照模型提供了很大的灵活性,因为我们可以在顶点级别(顶点着色器)或片段级别(片段着色器)计算光的反射。
程序
记住,顶点着色器和片段着色器一起被称为程序。
并行性和属性与统一变量之间的区别
在属性和统一变量之间有一个重要的区别。当调用绘制命令(使用drawArrays或drawElements)时,GPU 将并行启动多个顶点着色器的副本。每个副本将接收一组不同的属性。这些属性是从映射到相应属性的 VBO 中抽取的。
另一方面,所有顶点着色器的副本都将接收相同的统一变量——这就是为什么叫统一变量的原因。换句话说,统一变量可以看作是每个绘制调用中的常量:

一旦将灯光、法线和材质传递给程序,下一步就是确定我们将实现哪些着色和光照模型。让我们来调查这涉及到什么。
着色方法和光反射模型
虽然术语着色和光照经常模糊地互换使用,但它们指的是两个不同的概念。
着色指的是为了获得场景中每个片段的最终颜色所执行的插值类型。稍后,我们将解释着色类型如何决定最终颜色是在顶点着色器还是片段着色器中计算。
一旦建立了着色模型,光照模型就决定了如何将法线、材质和灯光结合起来以产生最终的颜色。由于光照模型的方程使用了光的反射物理原理,因此光照模型也被称为反射模型。
着色/插值方法
在本节中,我们将分析两种基本的插值方法:Goraud和Phong着色。
Goraud 插值
Goraud插值方法在顶点着色器中计算最终颜色。使用顶点法线执行此计算。然后,使用插值变量,将顶点的最终颜色传递到片段着色器。由于渲染管道提供的自动插值变量,每个片段将具有一个颜色,它是通过插值包围三角形的颜色来得到的。
插值变量
渲染管道中变元的插值是自动的。不需要编程。
Phong 插值
Phong方法在片段着色器中计算最终颜色。为此,每个顶点法线通过一个变元从顶点着色器传递到片段着色器。由于管道中包含的变元的插值机制,每个片段将有自己的法线。然后,使用片段法线在片段着色器中计算最终颜色。
以下图表总结了两种插值模型:

着色方法不指定每个片段的最终颜色是如何计算的。它只指定要使用的地方(顶点着色器或片段着色器)以及要使用的插值类型(顶点颜色或顶点法线)。
Goraud 与 Phong 着色
我们现在明白,Goraud 着色在顶点着色器内部执行计算,并利用内置渲染管道的插值功能。另一方面,Phong 着色在片段着色器内部执行所有计算——也就是说,对每个片段(或像素)进行计算。考虑到这两个细节,你能猜出这两种着色技术的优势和劣势吗?
Goraud 着色被认为更快,因为执行的计算是按顶点计算的,而 Phong 着色是按片段计算的。性能上的速度确实是以准确或更逼真的插值为代价的。这在光线强度在两个顶点之间非线性衰减的情况下最为明显。在本章的后面部分,我们将更详细地介绍这两种技术。
光反射模型
正如我们之前提到的,光照模型与着色/插值模型是独立的。着色模型只决定最终颜色的计算位置。现在,我们来谈谈如何执行这些计算。
拉姆伯特反射模型
拉姆伯特反射在计算机图形学中常用作漫反射的模型,这种反射是入射光束在许多角度上反射,而不是像镜面反射那样只在一个角度上反射:

这个光照模型基于余弦辐射定律,或拉姆伯特辐射定律。它是以约翰·海因里希·拉姆伯特的名字命名的,他的著作《光度量学》于 1760 年出版。
拉姆伯特反射通常计算为表面法线(根据使用的插值方法,是顶点法线或片段法线)与光方向向量的负值的点积。然后,这个数值乘以材料和光源的颜色。
光方向向量光方向向量是从表面开始并结束在光源位置上的向量。它本质上是将光的位置映射到几何表面上的向量。

其中:
 是最终的漫反射颜色, 是光的漫反射颜色, 是材料的漫反射颜色。
也就是说,我们将使用以下方法推导出最终的漫反射颜色:
如果 *L* 和 *N* 是归一化的,那么:
Phong 反射模型
Phong 反射模型描述了表面如何通过三种反射类型的总和来反射光:环境、漫反射和镜面反射。它是由 Bui Tuong Phong 开发的,他在 1973 年的博士论文中发表了它:

让我们逐个介绍这些概念。
环境
环境项考虑场景中存在的散射光。这个项与任何光源无关,并且对所有片段都是相同的。
漫反射
漫反射项对应于漫反射。通常使用朗伯模型(Lambertian model)来表示这一部分。
镜面
镜面项提供类似镜面的反射。从概念上讲,当我们在与反射光方向向量相等的角度观察物体时,镜面反射达到最大。
镜面项通过两个向量的点积来建模,即视点向量(eye vector)和反射光方向向量。视点向量从片段开始,终止于视图位置(相机)。反射光方向向量是通过在表面法线向量上反射光方向向量得到的。当这个点积等于 1(通过使用归一化向量)时,我们的相机将捕捉到最大的镜面反射。
点积随后被一个表示表面光泽度的数字指数化。之后,结果乘以光和材料的镜面反射分量:

其中:
 是最终的镜面反射颜色, 是光的镜面反射颜色, 是材料的镜面反射颜色,n 是光泽度因子。
也就是说,我们将使用以下方法推导出最终的镜面反射颜色:
如果 *R* 和 *E* 是归一化的,那么:
重要的是要注意,当 *R* 和 *E* 方向相同时,镜面反射达到最大。
一旦我们有了环境、漫反射和镜面反射项,我们将它们相加以找到片段的最终颜色,这为我们提供了 Phong 反射模型。
现在,是时候学习一种语言了,它将使我们能够在顶点和片段着色器中实现着色和光照策略。这种语言被称为 ESSL。
OpenGL ES 着色语言(ESSL)
OpenGL ES 着色语言(ESSL)是我们编写着色器所使用的语言。它的语法和语义与 C/C++ 非常相似。然而,它有类型和内置函数,使得操作向量和矩阵更加容易。在本节中,我们将介绍 ESSL 的基础知识,以便我们可以立即开始使用它。
GLSL 和 ESSL 开发者通常将 WebGL 中使用的着色语言称为 GLSL。然而,从技术上讲它是 ESSL。WebGL2 建立在 OpenGL ES 3.0 规范之上,因此使用 ESSL,它是 GLSL(OpenGL 的着色语言)的一个子集。
本节总结了官方 GLSL ES 规范。您可以在www.khronos.org/registry/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf找到完整的参考。
存储限定符
变量声明可以在类型之前指定存储限定符:
-
attribute: 从缓冲区中提取的数据,作为顶点着色器和 WebGL 应用程序之间用于每个顶点数据的链接。此存储限定符仅在顶点着色器内有效。 -
uniform: 值在处理的对象之间不改变。统一变量构成了着色器和 WebGL 应用程序之间的链接。统一变量在顶点着色器和片段着色器中都是合法的。如果一个统一变量由顶点着色器和片段着色器共享,相应的声明必须匹配。统一变量的值对于单个绘制调用中的所有顶点都是相同的。 -
varying: 这是顶点着色器和片段着色器之间用于插值数据的链接。根据定义,插值变量必须由顶点着色器和片段着色器共享。插值变量的声明需要在顶点着色器和片段着色器之间匹配。 -
const: 编译时常量,或只读的函数参数。它们可以在 ESSL 程序的代码中任何地方使用。
类型
这里是一个不完整的常见 ESSL 类型列表:
-
void: 用于不返回值的函数或空参数列表 -
bool: 一个条件类型,取值为真或假 -
int: 一个有符号整数 -
float: 一个单浮点标量 -
vec2: 一个两分量浮点向量 -
vec3: 一个三分量浮点向量 -
vec4: 一个四分量浮点向量 -
bvec2: 一个两分量布尔向量 -
bvec3: 一个三分量布尔向量 -
bvec4: 一个四分量布尔向量 -
ivec2: 一个两分量整数向量 -
ivec3: 一个三分量整数向量 -
ivec4: 一个四分量整数向量 -
mat2: 一个 2×2 浮点矩阵 -
mat3: 一个 3×3 浮点矩阵 -
mat4: 一个 4×4 浮点矩阵 -
sampler2D: 访问 2D 纹理的句柄 -
sampler3D: 访问 3D 纹理的句柄 -
samplerCube: 访问立方映射纹理的句柄 -
struct: 用于根据标准类型声明自定义数据结构
ESSL
OpenGL ES 3.0 着色语言提供了许多其他类型和功能。以下是一个有用的指南,涵盖了其许多核心功能:www.khronos.org/files/opengles3-quick-reference-card.pdf。
输入变量将有一个限定符后跟一个类型。例如,我们将声明我们的 uLightColor 变量如下:
uniform vec4 uLightColor;
这意味着 uLightColor 变量是一个具有四个分量的 uniform 向量。
GLSL 和 ESSL 命名约定规定我们在着色器变量前加上其类型的前缀。这使得着色器代码清晰易读。例如,对于一个给定的颜色均匀量,你会将变量命名为 uLightColor。对于一个位置变化量,vNormal。对于一个法线属性,aVertexNormal。
向量分量
我们可以通过其索引来引用 ESSL 向量的每个分量。例如,uLightColor[3] 将引用向量的第四个元素(基于零的向量)。然而,我们也可以通过字母来引用每个分量,如下表所示:
{ x, y, z, w } |
当访问表示点或向量的向量时很有用。 |
|---|---|
{ r, g, b, a } |
当访问表示颜色的向量时很有用。 |
{ s, t, p, q } |
当访问表示纹理坐标的向量时很有用。 |
例如,如果我们想将 uLightColor 变量的 alpha 通道(第四个分量)设置为 1.0,我们可以通过以下任何一种格式来实现:
uLightColor[3] = 1.0;
uLightColor.w = 1.0;
uLightColor.a = 1.0;
uLightColor.q = 1.0;
在所有这些情况下,我们都在引用相同的第四个分量。然而,鉴于 uLightColor 表示颜色,使用 r、g、b、a 表示法更为合理。
还可以使用向量分量表示法来引用向量内部的子集。例如(摘自 GLSL ES 规范):
vec4 v4;
v4.rgba; // is a vec4 and the same as just using v4
v4.rgb; // is a vec3
v4.b; // is a float
v4.xy; // is a vec2
v4.xgba; // is illegal - the component names do not come from the same set
运算符和函数
GLSL 和 ESSL 的主要优势之一是强大的内置数学运算符。ESSL 提供了许多有用的运算符和函数,简化了向量和矩阵操作。根据规范,算术二元运算符(加+、减-、乘*、除/)作用于整数和浮点类型表达式,包括向量和矩阵。两个操作数必须是相同类型,或者一个是标量浮点数,另一个是浮点向量或矩阵,或者一个是标量整数,另一个是整数向量。此外,对于乘法*,一个可以是向量,另一个是与向量具有相同维度的矩阵。这些结果与它们操作的表达式具有相同的基本类型(整数或浮点)。如果一个操作数是标量,另一个是向量或矩阵,则标量逐分量应用于向量或矩阵,最终结果与向量或矩阵具有相同类型。需要注意的是,除以零不会引发异常,但会导致一个未指定的值。让我们看看这些操作的几个示例:
-
-x:x向量的负值。它产生与原向量方向完全相反的向量。 -
x + y:x和y向量的和。这两个向量需要具有相同数量的分量。 -
x - y:x和y向量的减法。这两个向量需要具有相同数量的分量。 -
x * y: 如果x和y都是向量,则此运算符产生逐元素乘法。应用于两个矩阵的乘法返回线性代数矩阵乘法,而不是逐元素乘法。 -
matrixCompMult(matX, matY): 矩阵的逐元素乘法。它们需要具有相同的维度(mat2、mat3或mat4)。 -
x / y: 除法运算符的行为与乘法运算符类似。 -
dot(x, y): 返回两个向量的点积(标量)。它们需要具有相同的维度。 -
cross(vecX, vecY): 返回两个向量的叉积(向量)。它们都必须是vec3。 -
normalize(x): 返回一个与原向量方向相同但长度为1的向量。 -
reflect(t, n): 沿着n向量反射t向量。
着色器提供了许多其他函数,包括三角函数和指数函数。在开发不同的光照模型时,我们将根据需要引用它们。
让我们看看一个具有以下属性的场景的着色器 ESSL 代码的快速示例:
-
高洛德着色法:我们将插值顶点颜色以获得片段颜色。因此,我们需要一个
varying来将顶点颜色信息从顶点着色器传递到片段着色器。 -
朗伯反射模型:我们考虑了单一光源与场景之间的漫反射交互。这意味着我们将使用统一变量来定义光属性,即材料属性。我们将遵循朗伯辐射定律来计算每个顶点的最终颜色。
首先,让我们分析一下属性、统一变量和可变变量将是什么。
顶点属性
我们将在顶点着色器中定义两个属性。每个顶点将具有以下代码:
in vec3 aVertexPosition;
in vec3 aVertexNormal;
属性
记住,属性仅在顶点着色器内部可用。
如果你好奇为什么使用in而不是attribute限定符,我们将在稍后进行解释。在in关键字之后,我们找到变量的类型。在这种情况下,它是vec3,因为每个顶点位置由三个元素(x、y、z)确定。同样,法线也是由三个元素(x、y、z)确定的。请注意,位置是三维空间中的一个点,它告诉我们顶点的位置,而法线是一个向量,它提供了关于通过该顶点的表面的方向的信息。
统一变量
统一变量对顶点着色器和片段着色器都可用。虽然属性在每次顶点着色器被调用时都会有所不同,但统一变量在整个渲染周期内是恒定的——即在drawArrays或drawElements WebGL 调用期间。
并行处理
并行处理
我们可以使用统一变量来传递有关光源(如漫反射颜色和方向)和材料(漫反射颜色)的信息:
uniform vec3 uLightDirection; // incoming light source direction
uniform vec4 uLightDiffuse; // light diffuse component
uniform vec4 uMaterialDiffuse; // material diffuse color
再次,uniform关键字告诉我们这些变量是统一变量,而vec3和vec4 ESSL 类型告诉我们这些变量有三个或四个分量。对于颜色,这些分量是红色、蓝色、绿色和 alpha 通道(RGBA),而对于光方向,这些分量是定义光源在场景中指向的向量的x、y和z坐标。
可变变量
如前所述,可变变量允许顶点着色器将信息传递给片段着色器。例如,如果我们想将顶点颜色从顶点着色器传递到片段着色器,我们首先更新我们的顶点着色器:
#version 300 es
out vec4 vVertexColor;
void main(void) {
vVertexColor = vec4(1.0, 1.0, 1.0, 1.0);
}
我们将在片段着色器内部如下引用该可变变量:
in vec4 vVertexColor;
请记住,存储限定符、可变变量的声明需要在顶点和片段着色器之间匹配。
输入和输出变量
这些关键词描述了输入和输出的方向。正如在属性和可变声明中看到的那样,当我们使用in时,该变量被提供给着色器。当我们使用out时,着色器暴露该变量。让我们看看这些关键词如何在 WebGL 的早期版本中的顶点和片段着色器中使用。
将属性更改为in
在 WebGL 1 使用 ESSL 100 中,你可能会有这样的代码:
attribute vec4 aVertexPosition;
attribute vec3 aVertexNormal;
在 WebGL 2 使用 ESSL 300 中,这变成了以下内容:
in vec4 aVertexPosition;
in vec3 aVertexNormal;
将插值变量改为输入/输出
在 WebGL 1 使用 ESSL 100 中,你需要在顶点着色器和片段着色器中声明一个插值变量,如下所示:
varying vec4 vVertexPosition;
varying vec3 vVertexNormal;
在 WebGL 2 使用 ESSL 300 中,在顶点着色器中,插值变量变成了这样:
out vec4 vVertexPosition;
out vec3 vVertexNormal;
在片段着色器中,它们变成了这样:
in vec4 vVertexPosition;
in vec3 vVertexNormal;
现在,让我们将属性、统一变量和插值变量插入到代码中,看看顶点着色器和片段着色器是什么样的。
顶点着色器
让我们来看一个示例顶点着色器:
#version 300 es
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
uniform vec3 uLightDirection;
uniform vec3 uLightDiffuse;
uniform vec3 uMaterialDiffuse;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec4 vVertexColor;
void main(void) {
vec3 normal = normalize(vec3(uNormalMatrix * vec4(aVertexNormal, 1.0)));
vec3 lightDirection = normalize(uLightDirection);
float LambertianTerm = dot(normal, -lightDirection);
vVertexColor = vec4(uMaterialDiffuse * uLightDiffuse * LambertianTerm,
1.0);
gl_Position = uProjectionMatrix * uModelViewMatrix *
vec4(aVertexPosition, 1.0);
}
初步检查后,我们可以识别出我们将使用的属性、统一变量和插值变量,以及我们稍后将要讨论的一些矩阵。我们还可以看到顶点着色器有一个 main 函数,它不接受参数,而是返回 void。在内部,我们可以看到一些 ESSL 函数,例如 normalize 和 dot,以及一些算术运算符。
#version 300 es
这行字符串必须是你的着色器的第一行。不允许在此之前有任何注释或空白行!#version 300 es 告诉 WebGL 你想要使用 WebGL 2 的着色器语言(GLSL ES 3.00)。如果它不是作为第一行编写的,着色器语言将默认为 WebGL 1.0 的 GLSL ES 1.00,它具有较少的功能。
还有三个我们尚未讨论的统一变量:
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
我们可以看到这三个统一变量是 4x4 矩阵。这些矩阵在顶点着色器中是必需的,用于计算移动相机时顶点和法线的位置。这里有几个涉及使用这些矩阵的操作:
vec3 normal = normalize(vec3(uNormalMatrix * vec4(aVertexNormal, 1.0)));
上一行代码计算了 变换后的法线:
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
这行代码计算了 变换后的顶点位置。gl_Position 是一个特殊的输出变量,用于存储变换后的顶点位置。
我们将在 第四章 中回到这些操作,相机。现在,我们应该承认这些统一变量和操作涉及相机和世界变换(旋转、缩放和平移)。
返回到主函数的代码,我们可以清楚地看到正在实现朗伯反射模型。通过将归一化法线和光方向向量的点积获得,然后乘以光和材料的漫反射分量。最后,将此结果传递到 vVertexColor 插值变量中,以便在片段着色器中使用,如下所示:
vVertexColor = vec4(uMaterialDiffuse * uLightDiffuse * LambertianTerm, 1.0);
此外,由于我们在顶点着色器中计算颜色,然后自动对每个三角形的片段进行插值,我们使用了 Goraud 插值方法。
片段着色器
片段着色器非常简单。前三条线定义了着色器的精度。根据 ESSL 规范,这是强制性的。同样,对于顶点着色器,我们定义我们的输入;在这种情况下,只是一个插值变量,然后我们有主函数:
#version 300 es
// Fragment shaders don't have a default precision so we need
// to pick one. mediump is a good default. It means "medium precision"
precision mediump float;
in vec4 vVertexColor;
// we need to declare an output for the fragment shader
out vec4 fragColor;
void main() {
fragColor = vVertexColor;
}
我们只需要将 vVertexColor 插值变量赋值给 fragColor 输出变量。
不再需要 gl_FragColor
在 WebGL 1 中,你的片段着色器会将gl_FragColor特殊变量设置为计算着色器的输出:gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);。
在 WebGL 2 中,ESSL 300 强制你声明自己的输出变量,然后设置它。你可以选择任何你想要的名称,但名称不能以 gl_ 开头。
记住,vVertexColor变化变量的值将不同于在顶点着色器中计算出的值,因为 WebGL 会通过取对应计算颜色周围的顶点颜色来插值它,以对应于相应的片段(像素)。
编写 ESSL 程序
现在,让我们花一点时间回顾一下整体情况。ESSL 允许我们实现一种照明策略,前提是我们定义了一种着色方法和一个光照反射模型。在本节中,我们将以球体作为我们想要照亮的对象,我们将看到照明策略的选择如何改变场景:

我们将看到两种 Goraud 插值场景:一个带有朗伯反射,另一个带有 Phong 反射。对于 Phong 插值,我们只会看到一个案例;在 Phong 着色下,朗伯反射模型与将环境光和镜面光分量设置为 0 的 Phong 反射模型没有区别。
Goraud 着色与朗伯反射
朗伯反射模型只考虑漫反射材料和漫反射光属性之间的相互作用。简而言之,我们按照以下方式分配最终颜色:
aVertexColor = Id;
以下值是可见的:
Id = lightDiffuseProperty * materialDiffuseProperty * lambertCoefficient;
在 Goraud 着色下,朗伯系数是通过计算顶点法向量和光照方向向量的点积获得的。在找到点积之前,这两个向量都被归一化。
让我们看看提供的示例ch03_01_goraud_lambert.html中的顶点着色器和片段着色器:

这里是顶点着色器:
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
uniform vec3 uLightDirection;
uniform vec3 uLightDiffuse;
uniform vec3 uMaterialDiffuse;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec4 vVertexColor;
void main(void) {
// Calculate the normal vector
vec3 N = normalize(vec3(uNormalMatrix * vec4(aVertexNormal, 1.0)));
// Normalized light direction
vec3 L = normalize(uLightDirection);
// Dot product of the normal product and negative light direction vector
float lambertTerm = dot(N, -L);
// Calculating the diffuse color based on the Lambertian reflection model
vec3 Id = uMaterialDiffuse * uLightDiffuse * lambertTerm;
vVertexColor = vec4(Id, 1.0);
// Setting the vertex position
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
这里是片段着色器:
#version 300 es
precision mediump float;
// Expect the interpolated value fro, the vertex shader
in vec4 vVertexColor;
// Return the final color as fragColor
out vec4 fragColor;
void main(void) {
// Simply set the value passed in from the vertex shader
fragColor = vVertexColor;
}
我们可以看到,在顶点着色器中处理过的最终顶点颜色被传递到一个变化变量中,传递到片段着色器。记住,到达片段着色器的值不是我们在顶点着色器中计算出的原始值。片段着色器通过插值vVertexColor变量为相应的片段生成一个最终颜色。这种插值考虑了包围当前片段的顶点。
行动时间:实时更新统一变量
让我们看看如何交互式地更新着色器统一变量的一个例子:
- 在你的浏览器中打开
ch03_01_goraud_lambert.html文件:

- 注意,在这个例子中,控件部件位于页面的右上角。如果你对它是如何工作的感到好奇,你可以检查示例代码中的
initControls函数。
设置小部件设置小部件是使用DatGui创建的,这是一个开源库。虽然我们不会介绍直观的 DatGui API,但阅读提供的示例中的文档和代码,了解它是如何工作的可能很有用。更多信息,你可以查看github.com/dataarts/dat.gui.
- 平移 X, Y, Z:这些控制光的方向。通过改变这些滑块,你将修改
uLightDirection统一变量:

-
球体颜色:这会改变代表球体漫反射颜色的
uMaterialDiffuse统一变量。在这里,你使用颜色选择小部件,这允许你尝试不同的颜色。initControls函数中的Sphere Color的onChange接收来自小部件的更新并更新uMaterialDiffuse统一变量。 -
光漫反射颜色:这会改变代表光源漫反射颜色的
uLightDiffuse统一变量。没有理由说光的颜色必须是白色的。我们通过将滑块值分配给uLightDiffuse的 RGB 分量,同时将 alpha 通道设置为1.0来实现这一点。我们在灯光设置的onChange函数内部这样做,该函数接收滑块的更新。 -
尝试不同的光源位置、漫反射材料和光属性设置。
刚才发生了什么?
我们已经看到了一个使用 Goraud 插值和 Lambertian 反射模型照亮的简单场景的例子。我们还看到了改变 Lambertian 照明模型统一值时的即时效果。
尝试一下:移动光源
我们之前提到,我们使用矩阵在场景中移动相机。我们也可以使用矩阵来移动光源。为此,执行以下步骤:
- 在你的编辑器中打开
ch03_02_moving-light.html。顶点着色器与之前的漫反射模型示例非常相似。然而,多了一行:
vec3 light = vec3(uModelViewMatrix * vec4(uLightDirection, 0.0));
- 在这里,我们正在变换
uLightDirection向量并将其赋值给light变量。注意,uLightDirection统一变量是一个有三个分量(vec3)的向量,而uModelViewMatrix是一个 4x4 矩阵。为了完成乘法,我们需要将这个统一变量转换成一个四分量向量(vec4)。我们通过以下结构实现这一点:
vec4(uLightDirection, 0.0);
-
uModelViewMatrix矩阵包含模型-视图变换矩阵。我们将在第四章“相机”中看到所有这些是如何工作的。现在,只需说这个矩阵允许我们更新顶点的位置,在这个例子中,还有光源的位置。 -
再看看顶点着色器。在这个例子中,我们正在旋转球体和光源。每次调用
draw函数时,我们都会在 y 轴上稍微旋转一下modelViewMatrix矩阵:
mat4.rotate(modelViewMatrix, modelViewMatrix, angle * Math.PI / 180, [0, 1, 0]);
- 如果你更仔细地检查代码,你会注意到
modelViewMatrix矩阵被映射到uModelViewMatrix常量:
gl.uniformMatrix4fv(program.uModelViewMatrix, false, modelViewMatrix);
- 在浏览器中运行示例。你会看到一个球体和一个光源在 y 轴上旋转:

- 在
initLights函数中查找并更改灯光方向,使灯光指向负 z 轴方向:
gl.uniform3f(program.uLightDirection, 0, -1, -1);
-
保存文件并再次运行。发生了什么?更改灯光方向常量,使其指向
[-1, 0, 0]。保存文件并在浏览器中再次运行。发生了什么?你应该看到改变这些值会操纵灯光的方向。 -
通过改变
uLightDirection常量将灯光恢复到 45 度角,使其返回初始值:
gl.uniform3f(program.uLightDirection, 0, -1, -1);
- 前往
draw并找到以下行:
mat4.rotate(modelViewMatrix, modelViewMatrix, angle * Math.PI / 180, [0, 1, 0]);
- 改成这个:
mat4.rotate(modelViewMatrix, modelViewMatrix, angle * Math.PI / 180, [1, 0, 0]);
- 保存文件并在浏览器中再次打开它。发生了什么?你应该注意到光线沿着不同的轴移动。
刚才发生了什么?
如你所见,传递给 mat4.rotate 的第三个参数决定了旋转的轴。第一个分量对应于 x 轴,第二个对应于 y 轴,第三个对应于 z 轴。
Goraud 着色与 Phong 反射
与 Lambertian 反射模型不同,Phong 反射模型考虑了三个属性:环境色、漫反射色和镜面色,并最终产生更逼真的反射。遵循我们在上一节中使用的相同类比,考虑以下示例:
finalVertexColor = Ia + Id + Is;
在哪里:
Ia = lightAmbient * materialAmbient;
Id = lightDiffuse * materialDiffuse * lambertCoefficient;
Is = lightSpecular * materialSpecular * specularCoefficient;
注意:
-
由于我们使用 Goraud 插值,我们仍然使用顶点法线来计算漫反射项。当使用 Phong 插值时,我们将使用片段法线,这将会改变。
-
光和材料都有三个属性:环境色、漫反射色和镜面色。
-
在这些方程中,我们可以看到
Ia、Id和Is分别从它们各自的光和材料属性中接收贡献。
根据我们对 Phong 反射模型的知识,让我们看看如何在 ESSL 中计算镜面系数:
float specular = pow(max(dot(lightReflection, eyeVector), 0.0), shininess);
在哪里:
-
eyeVector是视向量或相机向量 -
lightReflection是反射光向量 -
shininess是镜面指数因子或光泽度 -
lightReflection的计算方式为lightReflection = reflect(lightDirection, normal); -
normal是顶点法线,而lightDirection是我们用来计算 Lambert 系数的灯光方向
让我们看看顶点和片段着色器的 ESSL 实现。这是顶点着色器:
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
uniform vec3 uLightDirection;
uniform vec4 uLightAmbient;
uniform vec4 uLightDiffuse;
uniform vec4 uMaterialDiffuse;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec4 vVertexColor;
void main(void) {
vec3 N = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
vec3 light = vec3(uModelViewMatrix * vec4(uLightDirection, 0.0));
vec3 L = normalize(light);
float lambertTerm = dot(N,-L);
vec4 Ia = uMaterialDiffuse * uLightAmbient;
vec4 Id = uMaterialDiffuse * uLightDiffuse * lambertTerm;
vVertexColor = vec4(vec3(Ia + Id), 1.0);
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
当我们的物体几何形状是凹面或物体位于光源和我们的视点之间时,我们可以获得 Lambert 项的负点积。在两种情况下,光方向向量和法线将形成一个钝角,产生负点积,如下面的图所示:

因此,我们使用 ESSL 内置的 clamp 函数来限制点积在正范围内。如果我们得到一个负的点积,clamp 函数将 lambert 项设置为 0,相应的漫射贡献将被丢弃,从而生成正确的结果。
由于我们仍在使用 Goraud 插值,片段着色器与之前相同:
#version 300 es
precision mediump float;
in vec4 vVertexColor;
out vec4 fragColor;
void main(void) {
fragColor = vVertexColor;
}
在下一节中,我们将探索场景,看看当我们把被 clamp 到[0,1]范围的负 Lambert 系数应用到场景中时,它看起来会是什么样子。
行动时间:Goraud 着色
让我们来看一个实现 Goraud 着色的示例:
- 在您的浏览器中打开
ch03_03_goraud_phong.html文件。您将看到以下截图类似的内容:

-
界面看起来比漫射光照示例要复杂一些。让我们在这里稍作停留,解释一下这些小部件:
-
光的颜色(光漫射项):如本章开头所述,我们可以有一个例子,其中我们的光不是白色的。我们在这里包含了一个颜色选择器小部件,以便您可以尝试不同的光颜色组合。
-
环境光项:光的环境属性。在这个例子中,这是一个灰度值:
r = g = b。 -
镜面反射项:光的镜面反射属性。这是一个灰度值:
r = g = b。 -
X,Y,Z 坐标:定义光的方向的坐标。
-
球体颜色(材质漫射项):材质的漫射属性。我们包含了一个颜色选择器,以便您可以尝试不同的
r、g和b通道的组合。 -
材质环境项:材质的环境属性。我们仅仅为了这个目的而包含它。但正如您在漫射示例中可能已经注意到的,这个向量并不总是被使用。
-
材质镜面反射项:材质的镜面反射属性。这是一个灰度值:r = g = b。
-
亮度:Goraud 模型的镜面反射指数因子。
-
背景颜色(
gl.clearColor):这个小部件仅仅允许我们更改背景颜色。
-
-
Phong 反射模型中的镜面反射取决于亮度、材质的镜面反射属性和光的镜面反射属性。当材质的镜面反射属性接近
0时,材质失去其镜面反射属性。使用提供的小部件检查这种行为:-
当材质的镜面反射低而亮度高时会发生什么?
-
当材质的镜面反射高而亮度低时会发生什么?
-
使用这些小部件,尝试不同的光和材质属性组合。
-
刚才发生了什么?
-
我们看到了 Phong 光照模型的不同参数是如何相互作用的。
-
我们修改了光的方向、光的属性和材质,以观察 Phong 光照模型的不同行为。
-
与朗伯反射模型不同,Goraud 光照模型有两个额外的项:环境光和镜面分量。我们看到了这些参数如何影响场景。
就像朗伯反射模型一样,Phong 反射模型在顶点着色器中获取顶点颜色。这个颜色在片段着色器中插值以获得最终的像素颜色。这是因为,在这两种情况下,我们都在使用 Goraud 插值。现在,让我们将繁重的处理移到片段着色器,并研究我们如何实现 Phong 插值方法。
Phong 着色
与 Goraud 插值不同,在那里我们为每个顶点计算了最终颜色,Phong 插值计算每个片段的最终颜色。这意味着 Phong 模型中环境、漫反射和镜面项的计算是在片段着色器中而不是在顶点着色器中进行的。正如你可以想象的那样,这比在前两个场景中使用 Goraud 插值进行简单插值计算要复杂得多。然而,我们得到了一个看起来更逼真的场景。
在这次翻译之后,你可能想知道顶点着色器还剩下什么要做。嗯,在这种情况下,我们将创建变量,这将使我们能够在片段着色器中完成所有计算。例如,顶点法线是一个很好的选择。
而之前我们有一个每个顶点的法线,现在我们需要为每个像素生成一个法线,以便我们可以计算每个片段的朗伯系数。我们通过插值传递给片段着色器的法线来实现这一点。尽管如此,代码非常简单。我们只需要知道如何在顶点着色器中创建一个存储我们正在处理的顶点法线的变量,并在片段着色器中获取插值值(归功于 ESSL)。就是这样!从概念上讲,这体现在以下图中:

现在,让我们看看 Phong 着色下的顶点着色器:
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec3 vNormal;
out vec3 vEyeVector;
void main(void) {
vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
vNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
vEyeVector = -vec3(vertex.xyz);
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
与我们使用 Goraud 插值的例子不同,顶点着色器看起来非常简单。没有最终的色彩计算,我们使用两个变量将信息传递到片段着色器。现在片段着色器将看起来如下:
#version 300 es
precision mediump float;
uniform float uShininess;
uniform vec3 uLightDirection;
uniform vec4 uLightAmbient;
uniform vec4 uLightDiffuse;
uniform vec4 uLightSpecular;
uniform vec4 uMaterialAmbient;
uniform vec4 uMaterialDiffuse;
uniform vec4 uMaterialSpecular;
in vec3 vNormal;
in vec3 vEyeVector;
out vec4 fragColor;
void main(void) {
vec3 L = normalize(uLightDirection);
vec3 N = normalize(vNormal);
float lambertTerm = dot(N, -L);
vec4 Ia = uLightAmbient * uMaterialAmbient;
vec4 Id = vec4(0.0, 0.0, 0.0, 1.0);
vec4 Is = vec4(0.0, 0.0, 0.0, 1.0);
if (lambertTerm > 0.0) {
Id = uLightDiffuse * uMaterialDiffuse * lambertTerm;
vec3 E = normalize(vEyeVector);
vec3 R = reflect(L, N);
float specular = pow( max(dot(R, E), 0.0), uShininess);
Is = uLightSpecular * uMaterialSpecular * specular;
}
fragColor = vec4(vec3(Ia + Id + Is), 1.0);
}
当我们将向量作为变量传递时,它们在插值步骤中可能会反归一化。因此,你可能已经注意到,在片段着色器中,vNormal和vEyeVector都被再次归一化了。
正如我们之前提到的,在 Phong 光照下,朗伯反射模型可以看作是一个 Phong 反射模型,其中环境光和镜面分量被设置为0。因此,在下一节中,我们将只介绍一般情况,我们将看到当使用 Phong 着色和 Phong 光照结合时球面场景看起来是什么样子。
行动时间:使用 Phong 光照的 Phong 着色
让我们来看一个使用 Phong 着色实现光照的例子:
- 在您的浏览器中打开
ch03_04_sphere_Phong.html文件。页面将类似于以下截图:

- 界面与 Goraud 示例的界面非常相似。正如之前所描述的,很明显 Phong 着色与 Phong 光照相结合可以产生更加逼真的场景。通过实验控制部件来查看这种新的光照模型的即时效果。
发生了什么?
我们已经看到了 Phong 着色和 Phong 光照的实际应用。我们探索了顶点着色器和片段着色器的源代码。我们还修改了模型的不同参数,并观察了这些变化对场景的即时影响。
回到 WebGL
是时候回到我们的 JavaScript 代码了,但现在我们需要考虑如何弥合 JavaScript 代码和 ESSL 代码之间的差距。首先,我们需要看看我们如何使用 WebGL 上下文创建一个程序。请记住,我们将顶点着色器和片段着色器都称为程序。其次,我们需要知道如何初始化属性和统一变量。
让我们看看我们迄今为止开发的 Web 应用程序的结构:

每个应用程序都嵌入在网页中的顶点着色器和片段着色器。此外,还有一个脚本部分,我们在这里编写所有的 WebGL 代码。最后,我们有 HTML 代码定义页面组件,如标题、小部件的位置以及canvas的位置。
在 JavaScript 代码中,我们在网页的onload事件上调用init函数。这是我们应用程序的入口点。init首先做的事情是为initProgram中的canvas获取一个 WebGL 上下文,然后调用一系列初始化程序、WebGL 缓冲区和灯光的函数。最后,它进入一个渲染循环,每次循环结束时调用draw函数。
在本节中,我们将更详细地查看initProgram和initLights函数。initProgram允许我们创建和编译一个 ESSL 程序,而initLights允许我们初始化并将值传递给程序中定义的统一变量。在initLights中,我们将定义光的位置、方向和颜色分量(环境、漫射和镜面)以及材料属性的默认值。
创建一个程序
首先,在一个编辑器中打开ch03_05_wall.html。让我们一步一步地看看initProgram:
function initProgram() {
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(0.9, 0.9, 0.9, 1);
gl.clearDepth(100);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
const vertexShader = utils.getShader(gl, 'vertex-shader');
const fragmentShader = utils.getShader(gl, 'fragment-shader');
program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Could not initialize shaders');
}
gl.useProgram(program);
program.aVertexPosition = gl.getAttribLocation(program,
'aVertexPosition');
program.aVertexNormal = gl.getAttribLocation(program, 'aVertexNormal');
program.uProjectionMatrix = gl.getUniformLocation(program,
'uProjectionMatrix');
program.uModelViewMatrix = gl.getUniformLocation(program,
'uModelViewMatrix');
program.uNormalMatrix = gl.getUniformLocation(program,
'uNormalMatrix');
program.uLightDirection = gl.getUniformLocation(program,
'uLightDirection');
program.uLightAmbient = gl.getUniformLocation(program, 'uLightAmbient');
program.uLightDiffuse = gl.getUniformLocation(program, 'uLightDiffuse');
program.uMaterialDiffuse = gl.getUniformLocation(program,
'uMaterialDiffuse');
}
首先,我们检索一个 WebGL 上下文,就像我们在前面的章节中看到的那样。然后,我们使用utils.getShader实用函数检索顶点着色器和片段着色器的内容:
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(0.9, 0.9, 0.9, 1);
gl.clearDepth(100);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
const vertexShader = utils.getShader(gl, 'vertex-shader');
const fragmentShader = utils.getShader(gl, 'fragment-shader');
程序的创建发生在以下几行:
program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
alert('Could not initialize shaders');
}
gl.useProgram(program);
在这里,我们使用了 WebGL 上下文提供的几个函数。以下表格显示了这些函数:
| WebGL 函数 | 描述 |
|---|---|
createProgram() |
创建一个新的程序(程序)。 |
attachShader(program, shader) |
将着色器附加到当前程序。 |
linkProgram(program) |
创建传递给 GPU 的顶点和片段着色器的可执行版本。 |
getProgramParameter(program, parameter) |
这是 WebGL 状态机查询机制的一部分。它允许你查询程序参数。我们使用这个函数来验证程序是否已成功链接。 |
useProgram(program) |
如果程序包含有效的代码(即,它已成功链接),则将程序加载到 GPU 上。 |
最后,我们在 JavaScript 变量和程序属性以及统一变量之间创建了一个映射:
program.aVertexPosition = gl.getAttribLocation(program, 'aVertexPosition');
program.aVertexNormal = gl.getAttribLocation(program, 'aVertexNormal');
program.uProjectionMatrix = gl.getUniformLocation(program, 'uProjectionMatrix');
program.uModelViewMatrix = gl.getUniformLocation(program, 'uModelViewMatrix');
program.uNormalMatrix = gl.getUniformLocation(program, 'uNormalMatrix');
program.uLightDirection = gl.getUniformLocation(program, 'uLightDirection');
program.uLightAmbient = gl.getUniformLocation(program, 'uLightAmbient');
program.uLightDiffuse = gl.getUniformLocation(program, 'uLightDiffuse');
program.uMaterialDiffuse = gl.getUniformLocation(program, 'uMaterialDiffuse');
而不是在这里创建几个 JavaScript 变量(每个程序属性或统一变量一个),我们正在将属性附加到program对象上。这与 WebGL 无关。这只是将所有我们的 JavaScript 变量作为程序对象的一部分的一个方便步骤。
WebGL 程序由于我们将许多重要变量附加到我们的 WebGL 程序中,你可能想知道为什么我们不将其附加到 WebGL 上下文而不是程序。在我们的示例中,我们使用单个程序,因为我们的示例很小。随着 WebGL 应用程序的增长,你可能会发现你有几个程序,你通过gl.useProgram函数在应用程序中切换。
所有这些信息都与initProgram相关。在这里,我们使用了以下 WebGL API 函数:
| WebGL 函数 | 描述 |
|---|---|
getAttribLocation(program, name) |
这个函数接收当前的程序对象和一个包含需要检索的属性名称的字符串。然后,该函数返回相应的属性的引用。 |
getUniformLocation(program, name) |
这个函数接收当前的程序对象和一个包含需要检索的统一变量名称的字符串。然后,该函数返回相应的统一变量的引用。 |
使用这种映射,我们可以从 JavaScript 代码中初始化统一变量和属性,正如我们将在下一节中看到的。
WebGL 2 的另一个新增功能是从顶点着色器获取项目位置的越来越优化的方法。在我们的示例中,我们使用getAttribLocation和getUniformLocation来获取这些项目的位置。如果你检查它们的返回值,你会看到它们返回整数。
整数仅仅是数字 0, 1, 2, 3, 4, 5, ...(等等)。
习惯上,对于大型 3D 应用程序,你可以利用经过测试的设计模式和数据结构来组织你的代码,这可能包括以预定的或程序化的顺序组织着色器资源。
一个例子是利用 layout 限定符 来查找资源位置。这里有一个来自 第二章,渲染 的简化示例,其中我们使用 getAttribLocation 查找并启用了 aVertexPosition 和 aVertexColor:
const vertexPosition = gl.getAttribLocation(program, 'aVertexPosition');
gl.enableVertexAttribArray(vertexPosition);
const colorLocation = gl.getAttribLocation(program, 'aVertexColor');
gl.enableVertexAttribArray(colorLocation);
下面是相关的顶点着色器:
#version 300 es
in vec4 aVertexPosition;
in vec3 aVertexColor;
out vec3 vVertexColor;
void main() {
vVertexColor = aVertexColor;
gl_Position = aVertexPosition;
}
这些将变成以下内容:
const vertexPosition = 0;
gl.enableVertexAttribArray(vertexPosition);
const colorLocation = 1;
gl.enableVertexAttribArray(colorLocation);
下面是更新的顶点着色器:
#version 300 es
layout (location=0) in vec4 aVertexPosition;
layout (location=1) in vec3 aVertexColor;
out vec3 vVertexColor;
void main() {
vVertexColor = aVertexColor;
gl_Position = aVertexPosition;
}
如您所见,这是一个细微的变化,我们使用顶点着色器内的索引来定义位置,并简单地使用这些索引启用项目。
性能影响每次我们需要从 JavaScript 上下文查找或设置着色器值时,都会带来性能成本。正因为如此,我们应该始终小心地执行此类操作频率。
虽然布局限定符是最佳的,但鉴于它更易读且开销更小,我们将继续在本书中利用传统的变量和定义查找。
布局限定符有关布局和其他限定符的更多信息,请访问 www.khronos.org/opengl/wiki/Layout_Qualifier_(GLSL)。
初始化属性和统一变量
一旦我们编译并安装了程序,下一步就是初始化属性和变量。我们将使用 initLights 函数初始化我们的统一变量:
function initLights() {
gl.uniform3fv(program.uLightDirection, [0, 0, -1]);
gl.uniform4fv(program.uLightAmbient, [0.01, 0.01, 0.01, 1]);
gl.uniform4fv(program.uLightDiffuse, [0.5, 0.5, 0.5, 1]);
gl.uniform4f(program.uMaterialDiffuse, 0.1, 0.5, 0.8, 1);
}
在这个例子中,你可以看到我们正在使用通过 getUniformLocation 获得的引用(我们在 initProgram 中这样做)。
这些是 WebGL API 提供的用于设置和获取统一值的函数:
| WebGL 函数 | 描述 |
|---|---|
uniform[1234][fi] |
指定统一变量的 1-4 个 float 或 int 值。 |
uniform[1234][fi]v |
将统一变量的值指定为 1-4 个 float 或 int 值的数组。 |
getUniform(program) |
通过 getUniformLocation 获取之前获得的引用来检索统一变量的内容。 |
在 第二章,渲染 中,我们了解到初始化和使用属性需要四个步骤。回想一下,我们做了以下操作:
-
绑定 VBO。
-
将属性指向当前绑定的 VBO。
-
启用属性。
-
解绑 VBO。
这里关键的一步是步骤 2。我们通过以下指令来完成:
gl.vertexAttribPointer(index, size, type, normalize, stride, offset);
如果你查看 ch03_05_wall.html 示例,你将看到我们在 draw 函数中这样做:
gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, normalsBuffer);
gl.vertexAttribPointer(program.aVertexNormal, 3, gl.FLOAT, false, 0, 0);
桥接 WebGL 和 ESSL 之间的差距
现在很有用,可以通过将 ch03_05_wall.html 中的代码取出来并进行一些修改,来测试我们如何将 ESSL 程序与 WebGL 代码集成。
想象一下由部分 A、B 和 C 组成的墙壁,而你手持手电筒面对部分 B(正面视角)。直观上,你知道部分 A 和部分 C 会比部分 B 暗。这一事实可以通过从部分 B 的中心颜色开始,随着我们远离中心而逐渐变暗周围像素的颜色来模拟:

让我们总结一下我们需要覆盖的代码:
-
包含顶点和片段着色器的 ESSL 程序。对于墙壁,我们将选择 Goraud 着色,并使用漫反射/朗伯反射模型。
-
initProgram函数。我们需要确保我们映射了在 ESSL 代码中定义的所有属性和 uniforms,包括法线:
program.aVertexNormal= gl.getAttribLocation(program, 'aVertexNormal');
initBuffers函数。在这里,我们需要创建我们的几何形状。我们可以用定义六个三角形的八个顶点来表示墙壁,就像之前图中所示的那样。在initBuffers中,我们将应用之前章节中学到的知识来设置适当的 VAOs 和缓冲区。这次,我们需要设置一个额外的缓冲区:包含法线信息的 VBO。设置法线 VBO 的代码如下所示:
function initBuffers() {
const vertices = [
-20, -8, 20, // 0
-10, -8, 0, // 1
10, -8, 0, // 2
20, -8, 20, // 3
-20, 8, 20, // 4
-10, 8, 0, // 5
10, 8, 0, // 6
20, 8, 20 // 7
];
indices = [
0, 5, 4,
1, 5, 0,
1, 6, 5,
2, 6, 1,
2, 7, 6,
3, 7, 2
];
// Create VAO
vao = gl.createVertexArray();
// Bind Vao
gl.bindVertexArray(vao);
const normals = utils.calculateNormals(vertices, indices);
const verticesBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
gl.STATIC_DRAW);
// Configure instructions
gl.enableVertexAttribArray(program.aVertexPosition);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT,
false, 0, 0);
const normalsBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, normalsBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(normals),
gl.STATIC_DRAW);
// Configure instructions
gl.enableVertexAttribArray(program.aVertexNormal);
gl.vertexAttribPointer(program.aVertexNormal, 3, gl.FLOAT, false,
0, 0);
indicesBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices),
gl.STATIC_DRAW);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
-
为了计算法线,我们使用
calculateNormals(vertices, indices)辅助函数。你可以在common/js/utils.js文件中找到这个方法。 -
initLights:我们已经讨论过这个函数,并且知道如何操作。 -
在
draw函数中,我们需要进行一个微小但重要的更改。我们需要确保在调用drawElements之前绑定 VBOs。执行此操作的代码如下所示:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
mat4.perspective(projectionMatrix, 45, gl.canvas.width /
gl.canvas.height, 0.1, 10000);
mat4.identity(modelViewMatrix);
mat4.translate(modelViewMatrix, modelViewMatrix, [0, 0, -40]);
gl.uniformMatrix4fv(program.uModelViewMatrix, false,
modelViewMatrix);
gl.uniformMatrix4fv(program.uProjectionMatrix, false,
projectionMatrix);
mat4.copy(normalMatrix, modelViewMatrix);
mat4.invert(normalMatrix, normalMatrix);
mat4.transpose(normalMatrix, normalMatrix);
gl.uniformMatrix4fv(program.uNormalMatrix, false, normalMatrix);
try {
// Bind VAO
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
gl.drawElements(gl.TRIANGLES, indices.length,
gl.UNSIGNED_SHORT, 0);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
catch (error) {
console.error(error);
}
}
在下一节中,我们将探讨我们刚刚描述的用于构建和照亮墙壁的功能。
行动时间:处理墙壁
让我们通过一个示例来展示前面概念的实际应用:
- 在你的浏览器中打开
ch03_05_wall.html文件。你会看到以下截图类似的内容:

-
在代码编辑器中打开
ch03_05_wall.html文件。 -
前往顶点着色器。确保你识别出那里声明的属性、uniforms 和 varyings。
-
前往片段着色器。注意这里没有属性,因为属性仅限于顶点着色器。
顶点和片段着色器
你可以在带有适当 ID 名称的脚本标签内找到这些着色器。例如,顶点着色器可以在<script id="vertex-shader" type="x-shader/x-vertex">内找到。
-
前往
init函数。确保我们在那里调用initProgram和initLights。 -
前往
initProgram。确保你理解程序是如何构建的,以及我们是如何获得属性和 uniforms 的引用的。 -
前往
initLights。更新 uniforms 的值,如下所示:
function initLights() {
gl.uniform3fv(program.uLightDirection, [0, 0, -1]);
gl.uniform4fv(program.uLightAmbient, [0.01, 0.01, 0.01, 1]);
gl.uniform4fv(program.uLightDiffuse, [0.5, 0.5, 0.5, 1]);
gl.uniform4f(program.uMaterialDiffuse, 0.1, 0.5, 0.8, 1);
}
-
注意,其中一项更新是将
uniform4f更改为uniform4fv,用于uMaterialDiffuseuniform。 -
保存文件。
-
在浏览器中再次打开它(或重新加载)。发生了什么?
-
让我们做一些更有趣的事情。我们将创建一个键监听器,以便每次我们按下键时,光线方向都会改变。
-
在
initLights函数之后,编写以下代码:
function processKey(ev) {
const lightDirection = gl.getUniform(program, program.uLightDirection);
const incrementValue = 10;
switch (ev.keyCode) {
// left arrow
case 37: {
azimuth -= incrementValue;
break;
}
// up arrow
case 38: {
elevation += incrementValue;
break;
}
// right arrow
case 39: {
azimuth += incrementValue;
break;
}
// down arrow
case 40: {
elevation -= incrementValue;
break;
}
}
azimuth %= 360;
elevation %= 360;
const theta = elevation * Math.PI / 180;
const phi = azimuth * Math.PI / 180;
// Spherical to cartesian coordinate transformation
lightDirection[0] = Math.cos(theta) * Math.sin(phi);
lightDirection[1] = Math.sin(theta);
lightDirection[2] = Math.cos(theta) * -Math.cos(phi);
gl.uniform3fv(program.uLightDirection, lightDirection);
}
-
这个函数处理箭头键并相应地改变光线方向。这里涉及到一点三角学(
Math.cos,Math.sin),但我们只是将角度(方位角和仰角)转换为笛卡尔坐标。 -
请注意,我们通过以下函数获取当前的光线方向:
const lightDirection = gl.getUniform(program, program.uLightDirection);
- 在处理完按键操作后,我们可以使用以下代码保存更新后的光线方向:
gl.uniform3fv(program.uLightDirection, lightDirection);
- 保存工作并重新加载网页:

-
使用箭头键来改变光线方向。
-
如果你在开发这个练习过程中遇到任何问题或只是想验证最终结果,请检查
ch03_06_wall_final.html文件,其中包含完成的练习。
发生了什么?
在这个练习中,我们创建了一个键盘监听器,允许我们更新光线的方向,以便我们可以将其在墙上移动并观察它如何响应表面法线。我们还看到了如何声明和使用顶点着色器和片段着色器的输入变量。我们通过回顾initProgram函数学习了如何构建程序。我们还学习了在initLights函数中初始化统一变量的方法。最后,我们研究了getUniform函数来检索统一变量的当前值。尽管我们没有完全涵盖示例,但这个练习的目的是让你熟悉顶点着色器和片段着色器,以便你可以实现各种光照和反射模型。
关于光线更多内容:位置性光线
在完成这一章之前,让我们回顾一下光线的话题。到目前为止,为了我们示例的目的,我们假设我们的光源距离场景无限远。这个假设允许我们将光线建模为相互平行。一个例子是阳光。这些光线是方向性光线。现在,我们将考虑一个光源相对靠近它需要照亮的对象的情况。例如,想象一下台灯照亮你正在阅读的文件。这些光线是位置性光线:

正如我们之前所经历的,当处理方向性光线时,只需要一个变量。这就是我们在uLightDirection统一变量中表示的光线方向。
相比之下,当处理位置性光线时,我们需要知道光线的位置。我们可以通过一个名为uLightPosition的统一变量来表示它。正如使用位置性光线时的情况一样,这里的射线不是平行的;因此,我们需要单独计算每条光线。我们将通过一个名为vLightRay的可变变量来完成这项工作。
在下一节中,我们将研究位置光源如何与场景交互。
行动时间:位置光源的实际应用
让我们来看一个位置光源在实际应用中的例子:
- 在您的浏览器中打开
ch03_07_positional_lighting.html文件。页面看起来将与以下截图类似:

-
本练习的接口非常简单。您可以使用控件小部件与场景进行交互。与之前的练习不同,平移 X、Y和Z滑块在这里不代表光的方向。相反,它们允许我们设置光源位置。试试看吧。
-
为了清晰起见,场景中添加了一个表示光源位置的球体,以可视化光源,但这通常不是必需的。
-
当光源位于圆锥表面与球体表面相比时会发生什么?
-
当光源位于球体内部时会发生什么?
-
让我们通过检查源代码中的顶点着色器来了解我们计算光线的的方式。光线的计算在以下两行代码中执行:
vec4 light = uModelViewMatrix * vec4(uLightPosition, 1.0);
vLightRay = vertex.xyz - light.xyz;
- 第一行允许我们通过将模型视图矩阵乘以
uLightPosition统一变量来获得变换后的光源位置。如果您回顾顶点着色器中的代码,您会注意到我们还将此矩阵用于计算变换后的顶点和法线。我们将在第四章中讨论这些矩阵操作,相机。现在,我们只需假设这是在移动相机时获取变换后的顶点、法线和光源位置所必需的。为了测试这一点,修改这一行,从方程中删除矩阵,使其看起来像以下这样:
vec4 light = vec4(uLightPosition, 1.0);
-
保存文件并在浏览器中运行它。不变换光源位置会有什么效果?您可以看到的是,相机在移动,但光源位置没有更新!
-
我们可以看到光线被计算为从变换后的光源位置(光源)到顶点位置的向量。
多亏了 ESSL 提供的插值变量,我们在片段着色器中自动获得每个像素的所有光线:

刚才发生了什么?
我们研究了方向光源和位置光源之间的区别。我们还调查了当相机移动时,模型视图矩阵对正确计算位置光源的重要性。最后,我们建模了获取每顶点光线的程序。
虚拟展厅示例
在本章中,我们包括了我们在第二章中看到的 Nissan GTR 练习示例,渲染。这次,我们使用位置光源照亮场景的 Phong 光照模型。您可以在ch03_08_showroom.html中找到这个示例:

在这里,你可以尝试不同的光照位置。特别注意由于汽车的光泽特性和光照的亮度,你将获得漂亮的镜面反射。
架构更新
让我们介绍一些有用的函数,这些函数我们可以重构以在后续章节中使用:
- 我们已经看到了如何使用着色器创建和编译程序,我们也介绍了如何加载和引用属性和统一变量。让我们包含一个模块,它通过一个更简单的 API 抽象出这种低级功能:
<script type="text/javascript" src="img/Program.js"></script>
-
就像我们之前做的那样,我们将把这个脚本标签包含在 HTML 文档的
<head>部分。确保在包含其他模块脚本之后包含它,因为它们可能使用我们已覆盖的库和早期模块。 -
让我们更新
ch03_08_showroom.html中的initProgram函数,以便我们可以使用这个模块:
function initProgram() {
const canvas = document.getElementById('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(...clearColor, 1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
program = new Program(gl, 'vertex-shader', 'fragment-shader');
const attributes = [
'aVertexPosition',
'aVertexNormal'
];
const uniforms = [
'uProjectionMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uLightAmbient',
'uLightPosition',
'uMaterialSpecular',
'uMaterialDiffuse',
'uShininess'
];
program.load(attributes, uniforms);
}
-
创建程序、编译着色器以及将统一变量和属性附加到我们的程序的重任都由我们来完成。
-
让我们检查
Program类的源代码。大部分操作应该对你来说都很熟悉:
'use strict';
/*
* Program constructor that takes a WebGL context and script tag IDs
* to extract vertex and fragment shader source code from the page
*/
class Program {
constructor(gl, vertexShaderId, fragmentShaderId) {
this.gl = gl;
this.program = gl.createProgram();
if (!(vertexShaderId && fragmentShaderId)) {
return console.error('No shader IDs were provided');
}
gl.attachShader(this.program, utils.getShader(gl,
vertexShaderId));
gl.attachShader(this.program, utils.getShader(gl,
fragmentShaderId));
gl.linkProgram(this.program);
if (!this.gl.getProgramParameter(this.program,
this.gl.LINK_STATUS)) {
return console.error('Could not initialize shaders.');
}
this.useProgram();
}
// Sets the WebGL context to use current program
useProgram() {
this.gl.useProgram(this.program);
}
// Load up the given attributes and uniforms from the given values
load(attributes, uniforms) {
this.useProgram();
this.setAttributeLocations(attributes);
this.setUniformLocations(uniforms);
}
// Set references to attributes onto the program instance
setAttributeLocations(attributes) {
attributes.forEach(attribute => {
this[attribute] = this.gl.getAttribLocation(this.program,
attribute);
});
}
// Set references to uniforms onto the program instance
setUniformLocations(uniforms) {
uniforms.forEach(uniform => {
this[uniform] = this.gl.getUniformLocation(this.program,
uniform);
});
}
// Get the uniform location from the program
getUniform(uniformLocation) {
return this.gl.getUniform(this.program, uniformLocation);
}
}
-
我们通过传递对
gl上下文、顶点和片段着色器id的引用来初始化Program。 -
我们通过向
program实例提供属性和统一变量的数组来加载和引用attributes和uniforms程序。 -
其他方法是我们将在后续章节中使用的辅助函数。
-
你可以在
ch03_09_showroom-final.html中找到一个这些更改的例子。 -
你可能注意到了在本章中使用的两个额外的实用方法:
normalizeColor和denormalizeColor。这两个方法只是将颜色从范围[0-255]归一化到[0-1]或从[0-1]反归一化到[0-255]:
const utils = {
// Normalize colors from 0-255 to 0-1
normalizeColor(color) {
return color.map(c => c / 255);
},
// De-normalize colors from 0-1 to 0-255
denormalizeColor(color) {
return color.map(c => c * 255);
},
// ...
};
摘要
让我们总结一下本章我们学到了什么:
-
我们详细学习了光源、材料和法线是什么,以及这些元素如何相互作用来照亮 WebGL 场景。
-
我们介绍了着色方法和光照模型之间的区别。
-
我们研究了 Goraud 和 Phong 着色方法以及 Lambertian 和 Phong 光照模型的基础知识。通过几个示例,我们还介绍了如何使用 ESSL 在代码中实现这些着色和光照模型,以及如何通过属性和统一变量在 WebGL 代码和 ESSL 代码之间进行通信。
-
我们可以使用顶点着色器和片段着色器来为我们的 3D 场景定义光照模型。
-
我们通过 WebGL 2 更新的着色语言提供的最新和最先进的技术,对这些操作进行了很多探讨。
在下一章中,我们将扩展在 ESSL 中使用矩阵的内容,这样我们就可以学习如何使用它们在 3D 场景中表示和移动我们的视点。
第四章:相机
在上一章中,我们介绍了顶点着色器、片段着色器和 ESSL 来定义我们的 3D 场景中的光照模型。在本章中,我们将利用这些概念来深入了解我们在源代码中看到的矩阵。这些矩阵代表变换,当应用于我们的场景时,使我们能够显示和移动物体。在一种情况下,我们已经使用它们来设置相机距离以查看场景中的所有对象,在另一种情况下,我们使用它们来旋转我们的 3D 汽车模型。
尽管我们在 3D 应用程序中有一个相机,但在 WebGL API 中没有相机对象——只有矩阵。这是因为使用矩阵而不是相机对象给 WebGL 提供了表示复杂投影和动画的灵活性。在本章中,我们将学习这些矩阵变换的含义以及我们如何使用它们来定义和操作虚拟相机。
在本章中,我们将探讨以下主题:
-
理解场景从 3D 世界到 2D 屏幕所经历的变换。
-
学习仿射变换。
-
将矩阵映射到 ESSL 统一变量。
-
使用模型视图和投影矩阵。
-
重视正交矩阵的价值。
-
创建一个相机并使用它来在 3D 场景中移动。
WebGL 没有相机
为什么在 3D 计算机图形技术中没有相机?好吧,让我们重新措辞:WebGL 没有可以操作的相机对象。然而,我们可以假设我们在canvas上渲染的内容就是我们的相机捕捉到的。在本章中,我们将解决如何在 WebGL 中表示相机的问题。简短的答案是,我们需要 4x4 矩阵。
每次我们移动我们的相机时,我们都需要根据新的相机位置更新物体。为此,我们需要系统地处理每个顶点并应用一个变换,以产生新的观察位置。同样,我们还需要确保物体法线和光线方向在相机移动后仍然一致。总之,我们需要分析两种不同类型的变换:顶点(点)和法线(向量)。
顶点变换
在 WebGL 场景中的物体在我们看到它们在屏幕上之前会经历不同的变换。每个变换都由一个 4x4 矩阵编码。我们如何将具有三个分量(x, y, z)的顶点乘以一个 4x4 矩阵?简短的答案是,我们需要通过一个维度增加我们元组的基数。每个顶点将随后有一个第四个分量,称为齐次坐标。让我们看看它们是什么以及为什么它们是有用的。
齐次坐标
齐次坐标是任何计算机图形程序的关键组成部分。这些坐标使得能够将仿射变换(如旋转、缩放、剪切和平移)和投影变换表示为 4x4 矩阵。
在齐次坐标中,顶点有四个分量:x、y、z和w。前三个分量是顶点在欧几里得空间中的坐标。第四个是透视分量。四元组(x, y, z, w)带我们进入一个新的空间:射影空间**。
齐次坐标使得解决一个线性方程组成为可能,其中每个方程代表一条与系统中所有其他线平行的线。记住,在欧几里得空间中,这样的系统没有解,因为没有交点。然而,在射影空间中,这个系统有一个解——这些线将在无穷远处相交。这个事实由透视分量具有0值来表示。这个想法的一个很好的类比是火车轨道的图像:当你从远处看时,平行线会在消失点相交:

从齐次坐标转换到非齐次、传统的欧几里得坐标很容易。你只需要将坐标除以w:
因此,如果你要从欧几里得空间转换到射影空间,你需要添加第四个分量,即w,并将其设置为1:
实际上,这正是我们在本书的前三章中一直在做的事情!让我们回到上一章中讨论的一个着色器:Phong 顶点着色器。代码如下:
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec3 vVertexNormal;
out vec3 vEyeVector;
void main(void) {
// Transformed vertex position
vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
// Transformed normal position
vVertexNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 0.0));
// Eye vector
vEyeVector = -vec3(vertex.xyz);
// Final vertex position
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
请注意,对于包含我们几何体顶点的aVertexPosition属性,我们从一个三元组创建一个四元组。我们使用 ESSL 构造,vec4()来完成这个操作。ESSL 知道aVertexPosition是一个vec3,因此我们只需要第四个分量来创建一个vec4。
坐标变换
要从齐次坐标转换到欧几里得坐标,我们除以w**。
要从欧几里得坐标转换到齐次坐标,我们添加w = 1。
w = 0的齐次坐标代表一个无穷远点。
关于齐次坐标还有一点需要注意:虽然顶点有一个齐次坐标,w = 1,但向量有一个齐次坐标,w = 0。这是因为,在 Phong 顶点着色器中,处理法线的线看起来是这样的:
vVertexNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 0.0));
为了编码顶点变换,我们将使用齐次坐标,除非有其他说明。现在,让我们看看我们的几何体在屏幕上显示之前所经历的不同变换。
模型变换
我们从对象坐标系开始分析。这是顶点坐标被指定的空间。如果我们想要平移或移动对象,我们使用一个编码这些变换的矩阵。这个矩阵被称为模型矩阵。一旦我们将对象的顶点乘以模型矩阵,我们就得到了新的顶点坐标。这些新的顶点将确定对象在我们 3D 世界中的位置。
在对象坐标系中,每个对象都可以自由定义其原点的位置,并指定其顶点相对于这个原点的位置。在世界坐标系中,所有对象共享同一个原点。世界坐标使我们能够知道对象相对于彼此的位置。正是通过模型变换,我们确定对象在 3D 世界中的位置:

视图变换
下一个变换,即视图变换,将坐标系的原点移动到视图原点。视图原点是我们的 眼睛 或 相机 相对于世界原点的位置。换句话说,视图变换通过视图坐标切换世界坐标。这种变换编码在 视图矩阵 中。我们将这个矩阵与模型变换得到的顶点坐标相乘。这个操作的结果是一组新的顶点坐标,其原点是视图原点。我们的相机将在这个坐标系中操作。

我们将在本章的后面回到这个话题!
投影变换
下一个操作称为 投影变换。这个操作确定将渲染多少观看空间以及它将如何映射到计算机屏幕上。这个区域被称为 几何体 ,它由六个平面(近平面、远平面、顶面、底面、右面和左面)定义,如下面的图所示:

这六个平面编码在 投影矩阵 中。在应用变换后,任何位于几何体之外的顶点将被 裁剪掉 并从进一步处理中丢弃。因此,几何体 定义 裁剪坐标,而编码几何体的投影矩阵 产生 裁剪坐标。
几何体的形状和范围决定了从 3D 观看空间到 2D 屏幕的投影类型。如果远平面和近平面的维度相同,则几何体将决定一个 正交 投影。否则,它将是一个 perspective 投影,如下面的图所示:

到目前为止,我们仍在使用齐次坐标,因此裁剪坐标有四个分量: x, y, z,和 w。裁剪是通过比较 x, y,和 z 分量与齐次坐标 w 来完成的。如果其中任何一个大于 +w,或小于 -w,则该顶点位于几何体之外,将被丢弃。
透视除法
一旦确定了将渲染多少观看空间,几何体就被映射到 near plane 以产生一个 2D 图像。近平面是将在您的计算机屏幕上渲染的内容。
不同的操作系统和显示设备可能有机制在屏幕上表示 2D 信息。为了对所有可能的情况提供鲁棒性,WebGL 和 OpenGL ES 提供了一个独立于任何特定硬件的中间坐标系。这个空间被称为归一化设备坐标(NDC)。
通过除以w分量来获得归一化设备坐标。这就是为什么这一步被称为透视除法。此外,请记住,当我们除以齐次坐标时,我们是从射影空间(4 个分量)到欧几里得空间(3 个分量),因此 NDC 只有三个分量。在 NDC 空间中,x和y坐标代表你的顶点在归一化 2D 屏幕上的位置,而 z 坐标编码深度信息,这是物体相对于近平面和远平面的相对位置。尽管我们现在在 2D 屏幕上工作,但我们仍然保留深度信息。这将允许 WebGL 根据物体与最近平面的距离来确定如何显示重叠的物体。当使用归一化设备坐标时,深度编码在 z 分量中。
透视除法将视锥体变换为中心在原点的立方体,最小坐标为[-1, -1, -1],最大坐标为[1, 1, 1]。同时,z 轴的方向被反转,如下面的图所示:

视口变换
最后,NDC 被映射到视口坐标。这一步将这些坐标映射到屏幕上的可用空间。在 WebGL 中,这个空间由 HTML5 canvas提供,如下面的图所示:

与之前的情况不同,视口变换不是由矩阵变换生成的。在这种情况下,我们使用 WebGL 的视口函数。我们将在本章后面更多地了解这个函数。现在,是时候看看这些变换如何影响法线了。
法线变换
每当顶点被变换时,法线向量也应该被变换,以便它们指向正确的方向。我们可以考虑使用变换顶点的模型视图矩阵来做这件事,但这种方法是有问题的:模型视图矩阵不会总是保持法线的垂直性,如下面的图所示:

这种问题发生在模型视图矩阵中存在单向(一个轴)缩放变换或剪切变换时。在我们的例子中,我们有一个在 y 轴上进行了缩放变换的三角形。正如你所见,经过这种变换后,N'法线不再垂直。我们该如何解决这个问题?
计算法线矩阵
如果你不想了解我们如何计算法线矩阵,只想得到答案,请随时跳到本节的末尾。否则,请留下来看看一些线性代数的实际应用!
让我们从垂直性的数学定义开始。如果两个向量的点积为 0,则这两个向量是垂直的。在我们的例子中,这将如下所示:
这里,S 是表面向量,可以计算为两个顶点的差,如本节开头所示的图表所示。
设 M 为模型视图矩阵。我们可以使用 M 如下变换 *S*:
这是因为 *S* 是两个顶点的差。我们使用 *M* 来将顶点变换到视图中。
我们想要找到一个矩阵,*K*,它允许我们以类似的方式变换法线。对于 *N* 法线,我们想要以下内容:
为了在获得 *N'* 和 *S'* 后使场景保持一致,这两个向量需要保持原始向量 *N* 和 *S* 的垂直性。如下所示:
代入 *N'* 和 *S'*:
点积也可以写成通过转置第一个向量来保持这种关系的向量乘法:
乘积的转置是转置的逆序乘积:
将内部项分组:
现在,记住  所以 (再次,点积可以写成向量乘法)。这意味着在之前的方程中,() 需要是单位矩阵,*I*,因此 *N* 和 *S* 的原始垂直条件保持不变:
应用一点代数:
 |
在两边乘以 *M* 的逆矩阵。 |
|---|---|
 |
因为 . |
 |
在两边进行转置。 |
 |
*K* 的双重转置是原始矩阵 *K*。 |
结论:
-
K是保持法向量与物体表面垂直的正确矩阵变换。我们称*K*为法线矩阵。 -
K是通过转置模型视图矩阵的逆矩阵 (*M*,在这个例子中)得到的。 -
我们需要使用
K来乘以法向量,以便在变换过程中保持它们与表面的垂直性。
WebGL 实现
现在,让我们看看如何在 WebGL 中实现顶点和法线变换。以下图表显示了我们迄今为止学到的理论,以及理论步骤与 WebGL 实现之间的关系:

在 WebGL 中,我们将应用于物体坐标以获得视口坐标的五个变换组合成三个矩阵和一个 WebGL 方法:
-
模型视图矩阵 将 模型 和 视图 变换组合在一个单一的矩阵中。当我们用这个矩阵乘以我们的顶点时,我们最终得到视图坐标。
-
法线矩阵 是通过反转和转置模型视图矩阵获得的。这个矩阵应用于法线向量,以确保它们继续垂直于表面。这在例如光照的情况下非常重要。
-
投影矩阵 将 投影变换 和 透视除法 组合在一起,因此我们最终得到归一化设备坐标。
最后,我们使用 gl.viewport 操作将 NDC 转换为视口坐标:
gl.viewport(minX, minY, width, height);
视口坐标起源于 HTML5 canvas 的左下角。
JavaScript 矩阵
WebGL JavaScript API 不提供自己的方法来执行矩阵操作。WebGL 只提供了一种将矩阵传递给着色器(作为 uniforms)的方式。因此,我们需要使用一个 JavaScript 库,使我们能够用 JavaScript 操作矩阵。在这本书中,我们使用了 glMatrix 来执行所有矩阵操作。然而,还有其他在线库可以为你做这件事。
glMatrix
在这本书的所有矩阵操作中,我们使用了 glMatrix。你可以在 github.com/toji/gl-matrix. 找到关于这个库的更多信息。
这里有一些你可以使用 glMatrix 执行的操作:
| 操作 | 语法 | 描述 |
|---|---|---|
| 创建 | const m = mat4.create(); |
创建 m 矩阵。 |
| 单位矩阵 | mat4.identity(m); |
将 m 设置为秩为 4 的单位矩阵。 |
| 复制 | mat4.copy(target, origin); |
将矩阵 origin 复制到矩阵 target 上。 |
| 转置 | mat4.transpose(target, m); |
将 m 矩阵转置到矩阵 target 上。 |
| 反转 | mat4.invert(target, m); |
将矩阵 m 反转到矩阵 target 上。 |
| 旋转 | mat4.rotate(target, m, r, a); |
将 m 矩阵绕 a 轴(这是一个包含三个元素的数组,[x, y, z])旋转 r 弧度到矩阵 target 上。 |
需要注意的是,glMatrix 提供了许多其他函数来执行其他线性代数操作。要获取完整列表,请访问 glmatrix.net/docs/。
将 JavaScript 矩阵映射到 ESSL Uniforms
由于模型视图矩阵和透视矩阵在单个渲染步骤中不会改变,因此它们作为 uniforms 传递给着色器。例如,如果我们正在将平移应用于场景中的对象,我们就必须在新坐标下绘制整个对象,这些新坐标由平移给出。在新的位置绘制整个对象是通过恰好一个渲染步骤实现的。
然而,在调用drawArrays或drawElements来调用渲染步骤之前,我们需要确保着色器有我们矩阵的更新版本。我们已经知道如何为其他 uniform,如光和颜色属性,这样做。将 JavaScript 矩阵映射到 uniform 的方法与以下类似:
- 使用以下代码获取 uniform 的 JavaScript 引用:
const reference = getUniformLocation(program, uniformName);
- 使用以下代码使用
reference将矩阵传递到着色器:
// Matrix is the JavaScript matrix variable
gl.uniformMatrix4fv(reference, transpose, matrix);
对于其他 uniform,ESSL 支持二维、三维和四维矩阵:uniformMatrix[234]fv(reference, transpose, matrix)。这将加载 2x2、3x3 或 4x4 矩阵(对应于命令名称中的 2、3 或 4),并将浮点数矩阵加载到由reference引用的 uniform 中。reference的类型是WebGLUniformLocation。出于实际目的,它是一个整数。根据规范,转置值必须设置为false。矩阵 uniform 始终为浮点类型(f)。矩阵作为4、9或16元素向量(v)传递,并且始终以列主序指定。矩阵参数也可以是Float32Array类型。这是 JavaScript 的一种类型化数组。这些数组被包含在语言中,以提供对原始二进制数据的访问和处理,从而提高效率。
在 ESSL 中处理矩阵
让我们回顾一下在第三章中介绍的 Phong 顶点着色器,光线。请记住矩阵被定义为 uniform mat4。
在这个着色器中,我们定义了三个矩阵:
-
uModelViewMatrix:模型-视图矩阵 -
uProjectionMatrix:投影矩阵 -
uNormalMatrix:法线矩阵
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec3 vVertexNormal;
out vec3 vEyeVector;
void main(void) {
// Transformed vertex position
vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
// Transformed normal position
vVertexNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 0.0));
// Eye vector
vEyeVector = -vec3(vertex.xyz);
// Final vertex position
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
在 ESSL 中,矩阵的乘法很简单;也就是说,你不需要逐元素相乘。ESSL 知道你正在处理矩阵,所以它会为你执行乘法:
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
这个着色器的最后一行将一个值赋给了预定义的gl_Position变量。这将包含由着色器正在处理的顶点的裁剪坐标。我们需要记住着色器是并行工作的:每个顶点都由一个顶点着色器的实例处理。
要获得给定顶点的裁剪坐标,我们首先需要将模型-视图矩阵与投影矩阵相乘。为了实现这一点,我们需要从右向左乘,因为矩阵乘法不是交换的,顺序很重要。
此外,请注意,我们需要通过包括齐次坐标来增强aVertexPosition属性。这是因为我们已经在欧几里得空间中定义了我们的几何形状。幸运的是,ESSL 允许我们通过简单地添加缺失的组件并在现场创建一个vec4来实现这一点。我们需要这样做,因为模型-视图矩阵和投影矩阵都是用齐次坐标描述的(4 行 4 列)。
现在我们已经看到了如何在着色器中将 JavaScript 矩阵映射到 ESSL 统一变量中,让我们谈谈如何操作这三个矩阵:模型-视图矩阵、法线矩阵和投影矩阵。
模型-视图矩阵
模型-视图矩阵允许我们在场景中执行 仿射变换。仿射是一个数学术语,描述了不改变经历这种变换的对象结构的变换。在我们的 3D 世界场景中,这种变换包括旋转、缩放、反射剪切和平移。幸运的是,我们不需要了解如何用矩阵表示这样的变换。我们只需要使用许多可在线获得的 JavaScript 矩阵库之一(例如 glMatrix)。
仿射变换
你可以在en.wikipedia.org/wiki/Affine_transformation上找到更多关于变换矩阵如何工作的信息。
如果你只想对场景或场景中的对象应用变换,理解模型-视图矩阵的结构并不能帮助你。为此,只需使用一个库,例如 glMatrix,来代表你进行变换。然而,当你在尝试调试你的 3D 应用程序时,这个矩阵的结构可能是有价值的。让我们看看模型-视图矩阵是如何构建的。
世界的空间编码
默认情况下,当你渲染一个场景时,你从世界原点沿 z 轴的负方向观看它。如下所示,z 轴从屏幕中出来(这意味着你正在查看负 z 轴):

从屏幕中心向右,你将拥有正 x 轴,从屏幕中心向上,你将拥有正 y 轴。这是初始配置,也是仿射变换的参考。
在这个配置中,模型-视图矩阵是四阶的单位矩阵。
模型-视图矩阵的前三行包含影响世界的旋转和平移信息。
旋转矩阵
前三行与前三列的交集定义了 3x3 的旋转矩阵。这个矩阵包含关于围绕标准轴旋转的信息。在初始配置中,这对应于以下:
[m1, m2, m3] = [1, 0, 0] = x 轴
[m5, m6, m7] = [0, 1, 0] = y 轴
[m9, m10, m11] = [0, 0, 1] = z 轴
平移向量
前三行与最后一列的交集定义了一个三组件的平移向量。这个向量指示原点和世界移动了多少。在初始配置中,这对应于以下:
[m13, m14, m15] = [0, 0, 0] = 原点(无平移)
神秘的第四行
第四行没有特殊含义。
-
m4、m8和m12元素始终为0。 -
m16元素(齐次坐标)始终为1。
如本章开头所述,WebGL 中没有摄像机。然而,我们需要操作摄像机的所有信息(主要是旋转和平移)都可以从模型视图矩阵本身提取出来。
摄像机矩阵
假设,暂时地,我们在 WebGL 中确实有一个摄像机。摄像机应该能够旋转和平移来探索这个 3D 世界。正如我们在上一节中看到的,一个 4x4 矩阵可以编码旋转和平移。因此,你应该使用这样一个矩阵来表示我们的假设摄像机。
假设我们的摄像机位于世界原点,并且它朝向负 z 轴方向对准。这是一个好的起点;我们已经在 WebGL 中知道了这种配置所代表的变换(四秩单位矩阵)。
为了分析,让我们将问题分解为两个子问题:摄像机平移和摄像机旋转。我们将为每个子问题提供一个实际演示。
摄像机平移
让我们将摄像机移动到世界坐标中的[0, 0, 4]。这意味着从原点沿正 z 轴移动四个单位。记住,在这个时候,我们还不知道如何移动摄像机。我们只知道如何移动世界(使用模型视图矩阵)。如果我们应用:
mat4.translate(modelViewMatrix, modelViewMatrix, [0, 0, 4]);
在这种情况下,世界将在正 z 轴上平移4个单位,由于摄像机位置没有改变,它将位于[0, 0, -4],这与我们想要的结果正好相反。
现在,假设我们应用了相反方向的平移:
mat4.translate(modelViewMatrix, modelViewMatrix, [0, 0, -4]);
在这种情况下,世界将在负 z 轴上移动4个单位,然后摄像机将位于新世界坐标系中的[0, 0, 4]位置。
在下一节中,我们将探讨世界空间和摄像机空间中的平移。
行动时间:世界空间与摄像机空间的平移
让我们通过一个示例来展示这些差异在实际操作中的表现:
- 在浏览器中打开
ch04_01_model-view-translation.html:

-
从远处看,我们正在观察位于世界原点的圆锥体的正 z 轴。有三个滑块,分别允许你在
x、y和z轴上平移世界或摄像机。默认情况下激活的是世界空间。 -
通过查看屏幕上的世界矩阵,你能说出世界原点在哪里吗?是
[0, 0, 0]吗?
提示
检查我们在模型视图矩阵中定义平移的位置。
-
我们可以将
canvas视为摄像机看到的图像。如果世界的中心在[0, -2, -50],那么摄像机在哪里? -
如果我们想靠近圆锥,我们需要将世界中心移向相机。我们知道相机在世界正 z 轴上很远,所以平移将发生在 z 轴上。鉴于我们处于世界坐标中,我们需要增加还是减少 z 轴滑块?去测试你的答案。
-
切换到相机坐标。这个矩阵的平移分量是什么?如果你想将相机移近圆锥,你需要做什么?最终的平移看起来像什么?你能得出什么结论?
-
尝试在 x 轴和 y 轴上移动相机。检查相应的变换在模型视图矩阵中会是什么样子。
发生了什么?
我们看到相机平移是模型视图矩阵平移的逆。我们还学习了在变换矩阵中找到平移信息的位置。
相机旋转
同样,如果我们想将相机向右旋转 45 度,这相当于将世界向左旋转 45 度。使用 glMatrix 实现这一点,我们可以编写以下代码:
mat4.rotate(modelViewMatrix, modelViewMatrix, 45 * Math.PI/180, [0, 1, 0]);
与我们之前探索平移的章节类似,在 时间行动:世界空间与相机空间中的旋转 部分中,我们将实验世界和相机空间中的旋转。让我们看看这个行为是如何实现的!
时间行动:世界空间与相机空间中的旋转
让我们通过一个例子来展示不同空间中的不同旋转:
- 在你的浏览器中打开
ch04_02_model-view-rotation.html:

-
就像我们在上一个例子中所做的那样,我们将看到以下内容:
-
圆锥位于世界原点
-
相机位于世界坐标
[0, 2, 50] -
三个滑块,允许我们旋转世界或相机
-
一个矩阵,我们可以看到不同旋转的结果
-
-
让我们看看应用旋转后轴会发生什么。在 世界 坐标选择下,围绕 x 轴旋转世界
90度。模型视图矩阵看起来像什么? -
让我们看看在 x 轴周围旋转
90度后,轴会落在何处:-
通过查看第一列,我们可以看到 x 轴没有变化。它仍然是
[1, 0, 0]。这很有道理,因为我们围绕这个轴旋转。 -
矩阵的第二列指示旋转后 y 轴的位置。在这种情况下,我们从
[0, 1, 0],即原始配置,变到[0, 0, 1],这是从屏幕中伸出的轴。这是初始配置中的 z 轴。这很有道理,因为我们现在是从上方向下看圆锥。 -
矩阵的第三列指示了 z 轴的新位置。它从
[0, 0, 1]变化,正如我们所知,这是标准空间配置(没有变换)中的 z 轴,到[0, -1, 0],这是原始配置中 y 轴的负部分。这很有道理,因为我们围绕 x 轴旋转:
-

-
正如我们刚刚看到的,理解旋转矩阵(模型视图矩阵的左上角 3x3 部分)很简单:前三个列始终告诉我们轴在哪里。
-
在以下变换中,坐标轴在哪里?请看下面的图:

-
通过使用滑块来达到你认为产生此矩阵的旋转来检查你的答案。
-
让我们看看在摄像机空间中旋转是如何工作的,通过改变坐标和选择。
-
通过增加滑块位置来增加 x 轴旋转的角度。你注意到什么?
-
使用滑块,在摄像机空间中尝试不同的旋转。
-
旋转是交换律的吗?也就是说,如果你在 x 轴上旋转
5度,在 z 轴上旋转90度,与先在 z 轴上旋转90度然后在 x 轴上旋转5度相比,你会得到相同的结果吗? -
返回到世界空间。请记住,当你处于世界空间时,你需要反转旋转以获得相同的姿态,例如,如果你在 x 轴上应用
5度,在 z 轴上应用90度,验证当你应用-5度在 x 轴上和-90度在 z 轴上时,你获得相同的结果。
刚才发生了什么?
我们刚刚了解到摄像机矩阵的旋转是模型视图矩阵旋转的逆。我们还学习了如何通过分析旋转矩阵(相应变换矩阵的左上角 3x3 部分)来识别我们的世界或摄像机的方向。
尝试:结合旋转和变换
让我们看看我们如何将旋转和变换结合起来:
ch04_03_model-view.html文件包含了旋转和变换的组合。当你打开它时,你的浏览器将显示如下内容:

- 尝试在世界和摄像机空间中不同的旋转和变换配置。
摄像机矩阵是模型视图矩阵的逆
这两种情况帮助我们理解摄像机矩阵是模型视图矩阵的完全相反。在线性代数中,这种性质被称为矩阵的逆。
矩阵的逆是当它与原始矩阵相乘时,我们得到单位矩阵。换句话说,如果M是模型视图矩阵,C是摄像机矩阵,我们得到以下结果:
我们可以使用glMatrix编写如下内容来创建摄像机矩阵:
const cameraMatrix = mat4.create();
mat4.invert(cameraMatrix, modelViewMatrix);
思考 WebGL 中的矩阵乘法
在继续前进之前,我们应该注意,在 WebGL 中,矩阵操作是按照它们应用于顶点的相反顺序编写的。这是对新手 3D 图形开发者来说经常令人困惑的重要注意事项。
让我们暂时假设你正在编写旋转/移动世界的代码;也就是说,你围绕原点旋转你的顶点,然后移动离开。最终的变换将看起来像这样:
在这里,R 是编码纯旋转的 4x4 矩阵;T 是编码纯平移的 4x4 矩阵,而 v 对应于场景中存在的顶点(在齐次坐标中)。
现在,你应该已经注意到,我们首先应用于顶点的变换是平移,然后是旋转。顶点需要首先乘以左侧的矩阵。在这种情况下,该矩阵是 T。然后,结果需要乘以 R。
这一事实反映在操作顺序中(这里,modelViewMatrix 是模型-视图矩阵):
mat4.identity(modelViewMatrix);
mat4.translate(modelViewMatrix, modelViewMatrix, position);
mat4.rotateX(modelViewMatrix, modelViewMatrix, rotation[0] * Math.PI / 180);
mat4.rotateY(modelViewMatrix, modelViewMatrix, rotation[1] * Math.PI / 180);
mat4.rotateZ(modelViewMatrix, modelViewMatrix, rotation[2] * Math.PI / 180);
如果我们在相机坐标系中工作,并且想要应用之前的相同变换,我们首先需要应用一些线性代数:
 |
模型-视图 M 矩阵是旋转和平移相乘的结果。 |
|---|---|
 |
我们知道相机矩阵是模型-视图矩阵的逆。 |
 |
通过代入。 |
 |
矩阵乘积的逆是逆矩阵的逆序乘积。 |
幸运的是,在本章的示例中,当我们使用相机坐标系时,我们已经计算了全局变量 position 和 rotation 中的逆平移和逆旋转。因此,我们可以在代码中写出如下内容(这里,cameraMatrix 是相机矩阵):
mat4.identity(cameraMatrix);
mat4.rotateX(cameraMatrix, cameraMatrix, rotation[0] * Math.PI / 180);
mat4.rotateY(cameraMatrix, cameraMatrix, rotation[1] * Math.PI / 180);
mat4.rotateZ(cameraMatrix, cameraMatrix, rotation[2] * Math.PI / 180);
mat4.translate(cameraMatrix, cameraMatrix, position);
基本相机类型
在本章中,我们将讨论以下两种相机类型:
-
环绕相机
-
追踪相机
环绕相机
到目前为止,我们已经学习了如何在世界或相机坐标系中生成旋转和平移。然而,在两种情况下,我们总是在世界中心周围生成旋转。当我们围绕一个 3D 对象,如我们的汽车模型旋转时,这可能很理想。在那个例子中,你将对象放在世界中心,然后从不同的角度(旋转)检查对象,之后你可以移动(平移)以查看结果。我们将此类相机称为环绕相机。
追踪相机
如果我们回到第一人称射击游戏的例子,我们需要有一个相机,当我们想要检查是否有敌人在我们上方时,它可以向上看。我们还应该能够左右旋转(旋转)并沿着相机指向的方向移动(平移)。这种类型的相机可以称为第一人称相机。当游戏跟随主要角色时,也使用相同的类型。因此,它通常被称为追踪相机。
要实现第一人称相机,我们需要在相机轴上设置旋转,而不是使用世界原点。
围绕其位置旋转相机
在乘矩阵时,乘法的顺序是相关的。比如说,我们有两个 4x4 矩阵。让R是第一个矩阵,假设这个矩阵编码了纯旋转;让T是第二个矩阵,假设T编码了纯平移。现在:
换句话说,操作的顺序会影响结果。围绕原点旋转然后远离它(环绕相机)与先平移原点然后围绕它旋转(跟踪相机)是不同的!你的成功取决于理解这个关键的区别。
为了将相机位置设置为旋转的中心,我们需要颠倒操作调用的顺序。这相当于从环绕相机转换为跟踪相机。
在视线方向上平移相机
使用环绕相机时,相机将始终朝向世界中心。因此,我们将始终使用 z 轴来移动到和离开我们正在检查的对象。然而,使用跟踪相机时,由于旋转发生在相机位置,我们最终可以看向世界中的任何位置(如果你想要四处移动和探索,这是理想的)。因此,我们需要知道相机在世界坐标中指向的方向(相机轴)。我们将在下一部分看到如何获得这个方向。
相机模型
就像它的对应矩阵,模型视图矩阵一样,相机矩阵编码了相机方向的信息。正如我们可以在以下图中看到的那样,左上角的 3x3 矩阵对应于相机轴:
-
第一列对应于相机的 x 轴。我们将它称为
RightVector。 -
第二列是相机的 y 轴。这将是
UpVector。 -
第三列决定了相机可以前后移动的向量。这是相机的 z 轴,我们将它称为
CameraAxis。
因为相机矩阵是模型视图矩阵的逆矩阵,所以相机矩阵中包含的左上角 3x3 旋转矩阵给出了相机轴在世界空间中的方向。这是一个优点,因为它意味着我们只需查看这个 3x3 旋转矩阵的列就可以知道我们相机在世界空间中的方向(我们现在知道每一列代表什么):

在下一节中,我们将玩环绕和跟踪相机,看看我们如何使用鼠标手势和滑块来改变相机位置。此外,我们还将查看结果的模型视图矩阵的图形表示。在这个练习中,我们将整合旋转和平移,并观察它们在两种基本类型的相机下如何表现。
行动时间:探索展厅
让我们来看一个涵盖各种相机类型的例子:
- 在浏览器中打开
ch04_04_camera-types.html文件。你会看到以下类似的内容:

-
使用 Trackingmode 中的滑块在世界各地环游。酷吧?
-
将相机类型更改为 Orbitingmode,并执行相同的操作。
-
请确认,除了滑块控制外,在 Tracking 和 Orbiting 模式下,你还可以使用鼠标和键盘在世界各地移动。
-
在这个练习中,我们使用两个新的类实现了相机:
-
Camera:用于操作相机。 -
Controls:将相机连接到canvas。现在canvas将接收鼠标和键盘事件并将它们传递给相机。
-
-
如果你好奇,你可以在
common/js目录中查看这两个类的源代码。我们已经将本章中解释的概念应用到这两个类的构建中。 -
到目前为止,我们在世界的中心看到了一个圆锥体。随着我们的探索,让我们将其更改为更有趣的东西。在源代码编辑器中打开
ch04_04_camera-types.html文件。 -
前往
load函数。让我们将汽车添加到场景中。将此函数的内容重写为以下内容:
function load() {
scene.add(new Floor(2000, 100));
scene.add(new Axis(2000));
scene.loadByParts('/common/models/nissan-gtr/part', 178);
}
-
你会看到我们增加了轴和地板的大小,这样我们才能看到它们。我们需要这样做,因为汽车模型比原始的圆锥体大得多。
-
为了正确地看到汽车,我们需要采取几个步骤。我们需要确保我们有一个足够大的视场。前往
updateTransforms函数并更新此行:
mat4.perspective(projectionMatrix, 45, canvas.width / canvas.height, 0.1, 1000);
用这个替换:
mat4.perspective(projectionMatrix, 45, canvas.width / canvas.height, 0.1, 5000);
- 将相机类型更改为,以便在加载页面时,我们默认有一个环绕相机。在
configure函数中更改此行:
camera = new Camera(Camera.TRACKING_TYPE);
用这个替换:
camera = new Camera(Camera.ORBITING_TYPE);
-
我们还必须考虑相机位置。对于像这样的大型物体,我们需要远离世界的中心。为此,我们需要将
camera.goHome的默认位置从[0, 2, 50]更改为[0, 25, 300]。 -
让我们修改场景的照明,使其更适合我们正在显示的模型。在
configure函数中更新以下内容:
gl.uniform3fv(program.uLightPosition, [0, 120, 120]);
gl.uniform4fv(program.uLightAmbient, [0.2, 0.2, 0.2, 1]);
gl.uniform4fv(program.uLightDiffuse, [1, 1, 1, 1]);
用这个替换:
gl.uniform4fv(program.uLightAmbient, [0.1, 0.1, 0.1, 1]);
gl.uniform3fv(program.uLightPosition, [0, 0, 2120]);
gl.uniform4fv(program.uLightDiffuse, [0.7, 0.7, 0.7, 1]);
- 使用不同的名称保存文件,然后在浏览器中加载这个新文件。你应该会看到以下截图类似的内容:

-
使用鼠标、键盘和/或滑块来探索新场景。
-
使用环绕模式从不同的角度探索汽车。
-
看看当你移动场景时,Camera 矩阵是如何更新的。
-
你可以通过打开
ch04_05_car.html文件来查看最终练习的样子。
发生了什么?
我们在我们的场景中添加了鼠标和键盘交互。我们还尝试了两种基本的相机类型:跟踪和环绕相机。最后,我们修改了场景的设置以可视化复杂模型。
尝试更新光源位置
正如我们所看到的,通过移动相机,我们正在对世界应用逆变换。如果我们不更新光照位置,无论对世界应用了什么最终变换,光源都将位于相同的静态点。
当我们在场景中移动或探索对象时,这非常方便。我们总能看到光是否位于与相机相同的轴上。这是本章练习的情况。尽管如此,我们也可以模拟当相机移动与光源独立的情况。为此,我们需要在移动相机时计算新的光照位置。
首先,我们计算光的方向。我们可以通过简单地计算目标和原点之间的差向量来完成。假设光源位于[0, 2, 50]。如果我们想将光源指向原点,我们计算[0, 0, 0] - [0, 2, 50]向量(目标-原点)。当目标为原点时,这个向量具有正确的光方向。如果我们有一个需要照明的不同目标,我们重复相同的程序。在这种情况下,我们只需使用目标的坐标,并从中减去光源的位置。
由于我们正在将光源指向原点,我们可以通过反转光照位置来找到光的方向。正如你可能已经注意到的,我们在本章节的早期在顶点着色器中这样做:
vec3 L = normalize(-uLightPosition);
由于light是一个向量,如果我们想更新光的方向,我们需要使用本章前面讨论过的法线矩阵,在任何世界变换下更新这个向量。这一步在顶点着色器中是可选的:
if (uFixedLight) {
L = vec3(uNormalMatrix * vec4(L, 0.0));
}
在之前的代码片段中,light被扩展为四个分量,因此我们可以使用 ESSL 提供的直接乘法。(记住,uNormalMatrix是一个 4x4 矩阵,因此它转换的向量需要是四维的。)请记住,正如本章节开头所解释的,向量的齐次坐标始终设置为0,而顶点的齐次坐标设置为1。
-
在乘法之后,我们将结果减少到三个分量,然后再将结果赋值回
light。 -
你可以通过使用
ch04_05_car.html文件中提供的“静态光照位置”按钮来测试更新光照位置的效果。 -
我们将跟踪此按钮状态的全局变量与
uUpdateLight统一变量连接。 -
编辑
ch04_05_car.html并设置光照位置到不同的位置。为此,编辑configure函数。前往以下位置:
gl.uniform3fv(program.uLightPosition, [0, 0, 2120]);
-
尝试不同的光照位置:
-
[2120, 0, 0] -
[0, 2120, 0] -
`[100, 100, 100]`
-
-
对于每个选项,保存文件,并尝试更新和不更新光照位置(使用“静态光照位置”按钮):

- 为了更好的可视化,使用环绕相机。
投影矩阵
在本章的开头,我们了解到投影矩阵结合了投影变换和透视除法。这两个步骤将 3D 场景转换为立方体,然后通过视口变换将其映射到 2D canvas。
在实践中,投影矩阵决定了摄像机捕获的图像的几何形状。在现实世界的摄像机中,摄像机的镜头将决定最终图像的扭曲程度。在 WebGL 世界中,我们使用投影矩阵来模拟这种效果。此外,与在现实世界中我们的图像总是受到透视影响不同,在 WebGL 中,我们可以选择不同的表示(如正交投影)。
视野
投影矩阵决定了摄像机的视野(FOV),即摄像机将捕捉多少 3D 空间。视野是一个以度为单位给出的度量,该术语与视场角一词可以互换使用:

透视或正交投影
透视投影将更多空间分配给靠近摄像机的细节,而不是远离摄像机的细节。换句话说,靠近摄像机的几何形状看起来比远离摄像机的几何形状更大。这是我们眼睛看到现实世界的方式。透视投影使我们能够评估距离,因为它给我们的大脑提供了一个深度线索。
相比之下,正交投影使用平行线;这意味着线条看起来大小相同,无论它们与摄像机的距离如何。因此,使用正交投影时,深度线索会丢失。
虽然透视投影提供了更真实的场景视图,但在工程中通常使用正交投影作为一种产生明确传达尺寸的对象规范的手段。每条单位长度(厘米、米)的线条在图纸上的任何地方看起来长度都相同。这使得绘图员只需标注部分线条,并让读者知道图纸上的其他相同长度的线条在现实中也是相同长度的。图纸上的每条平行线在物体中也是平行的。
如果你正在观察一个包含建筑物的更大场景,那么正交渲染可以给出建筑物之间距离及其相对尺寸的确切测量。
在透视模式下,由于透视缩短,相同实际长度的线条看起来会不同。这使判断相对尺寸和物体大小变得困难。
使用glMatrix,我们可以通过调用mat4.perspective或mat4.ortho分别设置透视或正交投影。这些方法的签名如下:
| 函数 | 描述 |
|---|
|
mat4.perspective(
dest,
fovy,
aspect,
near,
far
);
| 生成具有给定边界的透视投影矩阵。参数:
-
dest: 将写入的mat4视锥矩阵 -
fovy: 垂直视野 -
aspect:宽高比,通常是width / height视窗 -
near,far:视锥体的近点和远点边界
|
|
mat4.ortho(
dest,
left,
right,
bottom,
top,
near,
far
);
| 生成具有给定边界的正交投影矩阵:参数:
-
dest:将矩阵写入的mat4视锥体 -
left,right:视锥体的左侧和右侧边界 -
bottom,top:视锥体的底部和顶部边界 -
near,far:视锥体的近点和远点边界
|
在“行动时间:正交和透视投影”部分,我们将测试视野和透视投影如何影响我们的相机捕捉到的图像。我们将对旋转和跟踪相机进行透视和正交投影的实验。
行动时间:正交和透视投影
让我们看看一个涵盖不同投影类型的例子:
-
在您的浏览器中打开
ch04_06_projection-modes.html文件。 -
这个练习与上一个非常相似。然而,在投影模式中有两个新的选项:透视和正交投影。如你所见,透视默认是激活的。
-
将相机类型更改为“Orbiting”。
-
将投影模式更改为正交。
-
探索场景。注意正交投影缺乏深度提示:

- 切换到透视模式:

- 探索源代码。转到
updateTransforms函数:
function updateTransforms() {
const { width, height } = canvas;
if (projectionMode === PERSPECTIVE_PROJECTION) {
mat4.perspective(
projectionMatrix,
fov,
width / height,
10,
5000
);
}
else {
mat4.ortho(projectionMatrix,
-width / fov,
width / fov,
-height / fov,
height / fov,
-5000,
5000
);
}
}
-
看看我们用来设置投影视图的参数。
-
注意,当你增加视野(
fov)时,你的相机将捕捉到更多的 3D 空间。把这想象成现实世界相机的镜头。使用广角镜头,你可以捕捉到更多的空间,但代价是当物体移动到你的视窗边界时,它们会变形。
发生了什么?
我们尝试了不同的投影矩阵配置,并看到了这些配置如何在场景中产生不同的结果。
尝试一下:整合模型视图和投影变换
回想一下,一旦我们将模型视图变换应用于顶点,下一步就是将视图坐标转换为 NDC 坐标:

我们通过在顶点着色器中使用 ESSL 进行简单乘法来完成这个操作:
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition,1.0);
预定义变量gl_Position存储场景中每个对象的每个顶点的 NDC 坐标。
在之前的乘法中,我们将着色器属性aVertexPosition扩展为 4 个分量的顶点,因为我们的矩阵是 4x4 的。与法线不同,顶点有一个等于一的齐次坐标(w=1)。
在这一步之后,WebGL 会将计算出的裁剪坐标转换为归一化设备坐标,然后通过 WebGL 的viewport函数将其转换为canvas坐标。让我们看看当我们改变这种映射时会发生什么:
-
在您的源代码编辑器中打开
ch04_06_projection-modes.html文件。 -
前往
draw函数。这是每次我们与场景交互时(通过使用鼠标、键盘或页面上的小部件)调用的渲染函数。 -
找到以下行:
gl.viewport(0, 0, canvas.width, canvas.height);
- 尝试以下三个操作:
const width = canvas.width,
height = canvas.height,
halfWidth = width / 2,
halfHeight = height / 2;
// First
gl.viewport(0, 0, halfWidth, halfHeight);
// Second
gl.viewport(halfWidth, halfHeight, width, height);
// Third
gl.viewport(50, 50, width - 100, height - 100);
-
对于每个选项,保存文件并在浏览器中打开它。
-
你看到了什么?请注意,你可以像以前一样与场景进行交互。
WebGL 示例的结构
我们改进了本章中代码示例的结构。随着我们的 WebGL 应用程序复杂性的增加,拥有一个良好、可维护且清晰的架构是明智的。我们将这一部分留到了本章的末尾,以便你在处理练习时可以作为参考。
就像之前的练习一样,我们的入口点是init函数,它在页面加载时被调用。我们在文档的head中包含了几个scripts,它们指向各种组件以构建我们的 3D 应用程序。
支持对象
我们已经创建了以下组件,每个组件都在common/js目录下的单独文件中:
-
Program.js:使用着色器定义创建程序。提供 JavaScript 变量(program.*)与程序属性和统一变量的映射。 -
Scene.js:维护要渲染的对象列表。包含 AJAX/JSON 功能,用于检索远程对象。它还允许我们将本地对象添加到场景中。 -
Floor.js:在 X-Z 平面上定义一个网格。此对象添加到scene中,以便有对地板及其属性的引用 -
Axis.js:表示世界空间中的轴。当添加到scene中时,我们将有一个对原点的引用。 -
Camera.js:创建一个相机实例,通过简单的接口来操作本章中涵盖的各种矩阵和操作。 -
EventEmitter.js:一个简单的发布/订阅事件发射器,用于解耦我们的 WebGL 应用程序中的各种组件。我们可以在不相关的功能之间传递硬引用的情况下,利用发布/订阅模式来发射和监听动作。 -
Clock.js:一个简单的类,它抽象了requestAnimationFrameAPI,以便整个 WebGL 应用程序从单一的真实来源(如clock)更新。
requestAnimationFrame``window.requestAnimationFrame()方法告诉浏览器你希望执行动画,并请求浏览器在下次重绘之前调用指定的函数来更新动画。这将请求在浏览器执行下次重绘之前调用你的动画函数。
-
Controls.js:提供捕获各种canvasDOM 事件的能力,以驱动交互。 -
utils.js:我们在前面的章节中介绍过的实用函数。
虽然我们有足够的基礎来理解每个组件的工作原理,但我们将在第九章,整合一切中涵盖每个组件。也就是说,如果你迫不及待,可以自由地检查源代码,以了解接下来会发生什么。
生命周期函数
以下函数定义了WebGLApp应用程序的生命周期。
配置函数
configure函数设置我们的gl上下文的一些参数,例如清除canvas的颜色。在配置必要的状态之后。
加载函数
load函数设置要添加到我们的scene中的对象。例如,通过调用add方法将两个本地创建的对象floor和axis添加到scene中。之后,使用scene.load方法通过 AJAX 调用加载远程对象。
绘制函数
draw函数调用updateTransforms来计算新位置(即当我们移动时)的矩阵,然后遍历scene中的对象以渲染它们。在这个循环内部,它为每个要渲染的对象调用setMatrixUniforms。
矩阵处理函数
在您的编辑器中打开ch04_02_model-view-rotation.html。以下是一些初始化、更新并将矩阵传递给着色器的函数。
initTransforms
如您所见,模型视图矩阵、相机矩阵、投影矩阵和法线矩阵在这里设置:
function initTransforms() {
mat4.identity(modelViewMatrix);
mat4.translate(modelViewMatrix, modelViewMatrix, home);
mat4.identity(cameraMatrix);
mat4.invert(cameraMatrix, modelViewMatrix);
mat4.identity(projectionMatrix);
mat4.identity(normalMatrix);
mat4.copy(normalMatrix, modelViewMatrix);
mat4.invert(normalMatrix, normalMatrix);
mat4.transpose(normalMatrix, normalMatrix);
}
updateTransforms
在updateTransforms中,我们使用全局变量的位置和旋转的内容来更新矩阵。这当然是以下内容:
function updateTransforms() {
mat4.perspective(projectionMatrix, 45,canvas.width /gl.canvas.height,
0.1, 1000);
if (coordinates === WORLD_COORDINATES) {
mat4.identity(modelViewMatrix);
mat4.translate(modelViewMatrix, modelViewMatrix, position);
mat4.rotateX(modelViewMatrix, modelViewMatrix, rotation[0] * Math.PI /
180);
mat4.rotateY(modelViewMatrix, modelViewMatrix, rotation[1] * Math.PI /
180);
mat4.rotateZ(modelViewMatrix, modelViewMatrix, rotation[2] * Math.PI /
180);
}
else {
mat4.identity(cameraMatrix);
mat4.translate(cameraMatrix, cameraMatrix, position);
mat4.rotateX(cameraMatrix, cameraMatrix, rotation[0] * Math.PI / 180);
mat4.rotateY(cameraMatrix, cameraMatrix, rotation[1] * Math.PI / 180);
mat4.rotateZ(cameraMatrix, cameraMatrix, rotation[2] * Math.PI / 180);
}
}
setMatrixUniforms
此函数执行以下映射:
function setMatrixUniforms() {
if (coordinates === WORLD_COORDINATES) {
mat4.invert(cameraMatrix, modelViewMatrix);
gl.uniformMatrix4fv(program.uModelViewMatrix, false, modelViewMatrix);
}
else {
mat4.invert(modelViewMatrix, cameraMatrix);
}
gl.uniformMatrix4fv(program.uProjectionMatrix, false, projectionMatrix);
gl.uniformMatrix4fv(program.uModelViewMatrix, false, modelViewMatrix);
mat4.transpose(normalMatrix, cameraMatrix);
gl.uniformMatrix4fv(program.uNormalMatrix, false, normalMatrix);
}
概述
让我们总结一下本章所学的内容:
-
WebGL 中没有相机对象。然而,我们可以使用模型视图矩阵构建一个。
-
3D 对象经过几个变换才能在 2D 屏幕上显示。这些变换表示为 4x4 矩阵。
-
场景变换是仿射的。仿射变换由一个线性变换后跟一个平移组成。WebGL 将仿射变换组合到三个矩阵中:模型视图矩阵、投影矩阵和法线矩阵,以及一个 WebGL 操作:
gl.viewport()。 -
仿射变换应用于投影空间,因此可以用 4x4 矩阵表示。要在投影空间中工作,顶点需要增加一个额外的项,即
w,称为透视坐标。四元组(x, y, z, w)称为齐次坐标。齐次坐标允许通过使透视坐标w = 0来表示在无穷远处相交的直线。向量始终有一个齐次坐标,w = 0,而点有一个齐次坐标,即w = 1(除非它们在无穷远处,在这种情况下w = 0)。 -
默认情况下,WebGL 场景从 z 轴负方向的世界原点进行查看。这可以通过更改模型视图矩阵来改变。
-
摄像矩阵是模型视图矩阵的逆矩阵。摄像和世界操作是相反的。有两种基本的摄像机类型:轨道和跟踪。
-
当对象进行仿射变换时,法线会得到特殊处理。法线通过法线矩阵进行变换,该矩阵可以从模型视图矩阵中获得。
-
投影矩阵使我们能够确定两种基本的投影模式:正交投影和透视投影。
在下一章中,我们将学习如何区分全局和局部变换。我们将研究那些全局变换,正如我们在这里所讨论的,以及那些针对我们 3D 场景中单个对象的局部变换。
第五章:动画
在上一章中,我们介绍了矩阵、变换和相机。到目前为止,我们只讨论了静态场景,其中所有交互都是由移动相机完成的。在这些交互中,对 3D 场景中的所有对象应用了相机变换;因此,我们称之为全局变换。然而,3D 场景中的对象可以有自己的动作。例如,在赛车游戏中,每辆车都有自己的速度和轨迹。在第一人称射击游戏中,敌人可以躲在障碍物后面,前来战斗,或者简单地逃跑。一般来说,这些动作中的每一个都被建模为一个附加到场景中相应演员的矩阵变换。这些被称为局部变换。
在本章中,你将:
-
了解全局变换和局部变换之间的区别。
-
了解矩阵栈以及如何使用它们进行动画。
-
使用 JavaScript 计时器进行基于时间的动画。
-
了解参数曲线。
-
了解插值。
-
探索各种插值技术。
WebGL 矩阵命名约定
在我们继续之前,让我们花一点时间快速总结一下关于矩阵命名的约定。正如我们所见,WebGL 是一个简单的 API,几乎所有内容(除了少数预定义的名称,如gl_Position)都是由程序员你定义的。话虽如此,确实存在一些常见的和半常见的命名约定。这在矩阵方面尤其如此。以下是我们已经覆盖的一些重要约定,以及我们将很快介绍的一些新约定:
-
世界矩阵: 有时被称为模型矩阵,这是一个矩阵,它将模型的顶点移动到世界空间。
-
相机矩阵: 这个矩阵将相机定位在世界中。你也可以将其视为相机的世界矩阵。
-
视图矩阵: 这个矩阵将世界中的其他所有内容移动到相机前面。正如我们所见,这是相机矩阵的逆矩阵。
-
投影矩阵: 这是将空间的一个截锥体转换为裁剪空间的矩阵。你也可以将其视为你的矩阵数学库的透视或正交函数返回的矩阵。
-
局部矩阵: 这个矩阵用于场景图中,在图上的任何特定节点上使用的矩阵在与其他任何节点相乘之前使用。
场景图: 这是一个数据结构,通常由基于向量的图形编辑应用程序和现代计算机游戏使用,它安排了图形场景的逻辑和通常空间表示。场景图是一个图或树结构中的节点集合。有关更多信息,请访问en.wikipedia.org/wiki/Scene_graph。
矩阵栈
矩阵栈提供了一种方法,可以在我们的场景中对单个对象应用局部变换,同时保留全局变换。
矩阵栈在每个渲染周期(每次调用我们的render函数)中工作,因为每个渲染周期都需要计算场景矩阵以响应相机移动。我们在将矩阵传递给着色程序(作为attributes)之前,首先更新场景中每个对象的模型视图矩阵。我们分三步进行这一操作:
-
一旦计算出了全局模型视图矩阵(如相机变换),我们就将其保存(推入)栈中。这允许我们在应用局部变换后恢复原始矩阵。
-
计算场景中每个对象更新的模型视图矩阵。这个更新包括将原始模型视图矩阵乘以一个表示场景中每个对象旋转、平移和/或缩放的矩阵。更新的模型视图矩阵传递给程序,相应的对象随后出现在其局部变换指示的位置。
-
从栈中恢复原始矩阵,然后对下一个需要渲染的对象重复步骤一至三。
以下图显示了单个对象的三步过程:

动画 3D 场景
动画场景不过是将适当的局部变换应用到场景中的对象上。例如,如果我们想移动一个圆锥和一个球,每个对象都将有一个相应的局部变换来描述其位置、方向和比例。在上一节中,我们了解到矩阵栈允许我们保留原始模型视图变换,这样我们就可以为每个对象应用正确的局部变换。
现在我们知道了如何使用局部变换和矩阵栈来移动对象,我们应该考虑何时应用这些变换。
如果我们每次调用render函数时都计算应用于示例中的圆锥和球的位置,这将意味着动画速率将取决于我们的渲染周期速度。缓慢的渲染周期会产生不流畅的动画,而过快的渲染周期则会在物体之间跳跃时产生不自然的过渡效果。
因此,使动画独立于渲染周期是很重要的。我们可以使用以下几种解决方案来实现这一目标:requestAnimationFrame函数和 JavaScript 计时器。
requestAnimationFrame函数
requestAnimationFrame函数在所有支持 WebGL 的浏览器中都是可用的。利用这个函数的一个优点是,它被设计成仅在浏览器/标签窗口处于焦点时调用渲染函数(无论我们指定的是哪个函数)。否则,不会进行调用。这可以节省宝贵的 CPU、GPU 和内存资源。通过使用requestAnimationFrame函数,我们可以获得一个与硬件能力同步的渲染周期,并且当窗口失去焦点时,它会自动暂停。
requestAnimationFrame
要检查浏览器中requestAnimationFrame的状态,请访问caniuse.com/#search=requestanimationframe。
JavaScript 计时器
话虽如此,requestAnimationFrame不是一个魔法函数,它是一个完整的黑盒。重要的是要记住,在它不可用或我们想要定制动画体验的情况下,我们可以实现自己的。为此,我们将使用两个 JavaScript 计时器来隔离渲染速率和动画速率。
与requestAnimationFrame函数不同,JavaScript 计时器即使在页面未获得焦点时也会在后台继续运行。这不是最佳性能,因为计算机资源被分配到了一个不可见的场景。为了模仿requestAnimationFrame的一些智能行为,我们可以使用 JavaScript 窗口对象的onblur和onfocus事件。
让我们看看我们能做什么:
| 动作(做什么) | 目标(为什么) | 方法(如何) |
|---|---|---|
| 暂停渲染 | 在窗口获得焦点之前停止渲染。 | 在window.onblur函数中调用clearInterval清除计时器。 |
| 减慢渲染 | 减少资源消耗,但确保 3D 场景即使在未查看时也能继续演变。 | 我们可以在window.onblur函数中清除当前计时器调用clearInterval,并创建一个新的计时器,具有更宽松的间隔(更高的值)。 |
| 恢复渲染 | 当浏览器窗口恢复焦点时,以全速激活 3D 场景。 | 在window.onfocus函数中启动一个新的计时器,以原始渲染速率。 |
通过减少 JavaScript 计时器速率或清除计时器,我们可以更有效地处理硬件资源。
控制渲染周期
这种低级功能的一个例子可以在common/js/Clock.js文件中看到。使用这个通用时钟,你可以看到onblur和onfocus事件是如何被用来控制时钟滴答(渲染周期)的,正如我们之前所描述的。
时间策略
如果你之前在 JavaScript 中编写过动画,你可能已经使用了setInterval或setTimeout来调用你的绘图函数。
使用这两种方法绘制的问题在于它们与浏览器的渲染周期没有关系。也就是说,它们没有同步到浏览器将要绘制新帧的时间,这可能导致动画与用户的机器不同步。例如,如果你使用setInterval或setTimeout并假设每秒60帧,而用户的机器实际上运行的是不同的帧率,你将与他们的机器不同步。
尽管requestAnimationFrame在所有启用 WebGL 的浏览器上都是可用的,但出于教育目的,我们将利用我们自己的动画 JavaScript 计时器。在生产环境中,建议您利用浏览器的优化版本。
在本节中,我们将创建一个 JavaScript 计时器,允许我们控制动画。正如我们之前提到的,我们将实现一个 JavaScript 计时策略,它提供计算机渲染帧的速度和动画速度之间的独立性。我们将把这个属性称为动画速率。
在继续前进之前,我们必须解决与计时器一起工作的一个注意事项:JavaScript 不是多线程语言。这意味着如果有几个异步事件同时发生(阻塞事件),浏览器将排队等待后续执行。每个浏览器都有不同的机制来处理阻塞事件队列。
在开发动画计时器时,有两种阻塞事件处理的替代方案。
动画策略
第一个替代方案是在计时器回调内部计算经过的时间。伪代码如下:
const animationRate = 30; // 30 ms
let initialTime, elapsedTime;
function animate(deltaT) {
// calculate object positions based on deltaT
}
function onFrame() {
const currentTime = new Date().getTime();
elapsedTime = currentTime - initialTime;
if (elapsedTime < animationRate) return; // come back later
animate(elapsedTime);
initialTime = currentTime;
}
function startAnimation() {
setInterval(onFrame, animationRate / 1000);
}
通过这样做,我们保证动画时间与计时器回调实际执行频率无关。如果有大的延迟(由于其他阻塞事件),这种方法可能会导致丢帧。这意味着在我们的场景中,对象的位姿将立即移动到根据经过的时间(连续动画计时器回调之间)它们应该所处的当前位置,然后忽略中间位置。屏幕上的运动可能会跳跃,但在实时应用中,丢失的动画帧通常是可以接受的损失。一个例子是物体在给定时间段内从点A移动到点B。然而,如果我们使用这种策略在 3D 射击游戏中射击目标,我们可能会迅速遇到问题。想象一下,你正在尝试射击一个有延迟的目标。接下来你知道的,目标已经不在那里了!由于在这种情况下我们需要计算碰撞,我们无法承受丢失帧。这是因为碰撞可能发生在我们丢弃的任何帧中,而没有进行分析。以下策略解决了这个问题。
模拟策略
有一些应用程序,如射击游戏示例,需要所有中间帧以确保结果的完整性。这些应用程序包括与碰撞检测、物理模拟或游戏人工智能一起工作。对于游戏,我们需要以恒定的速率更新对象的位姿。我们通过在计时器回调内部直接计算对象的下一个位置来实现这一点:
const animationRate = 30; // 30 ms
const deltaPosition = 0.1;
function animate(deltaP) {
// Calculate object positions based on deltaP
}
function onFrame() {
animate(deltaPosition);
}
function startAnimation() {
setInterval(onFrame, animationRate / 1000);
}
这可能会导致冻结帧,当存在大量阻塞事件时会发生,因为对象的位姿不会及时更新。
综合方法:动画和模拟
通常来说,浏览器可以有效地处理阻塞事件,并且在大多数情况下,无论选择哪种策略,性能都会相似。因此,决定在计时器回调中计算经过的时间或下一个位置将取决于你的特定应用。
尽管如此,有些情况下,结合动画和模拟策略是有益的。我们可以创建一个计时器回调,该回调计算经过的时间,并根据每帧所需的次数更新动画。伪代码如下:
const animationRate = 30; // 30 ms
const deltaPosition = 0.1;
let initialTime, elapsedTime;
function animate(delta) {
// Calculate object positions based on delta
}
function onFrame() {
const currentTime = new Date().getTime();
elapsedTime = currentTime - initialTime;
if (elapsedTime < animationRate) return; // come back later!
let steps = Math.floor(elapsedTime / animationRate);
while (steps > 0) {
animate(deltaPosition);
steps -= 1;
}
initialTime = currentTime;
}
function startAnimation() {
initialTime = new Date().getTime();
setInterval(onFrame, animationRate / 1000);
}
上述代码片段表明,动画将以固定的速率更新,无论帧之间经过多少时间。如果应用以60 Hz 运行,动画将每帧更新一次;如果应用以30 Hz 运行,动画将每帧更新一次;如果应用以15 Hz 运行,动画将每帧更新两次。如果动画始终通过固定量向前移动,它将保持更加稳定和确定。
下面的序列显示了在组合方法中每个函数在调用堆栈中的职责:
-
render:-
启动计时器
-
设置动画速率
-
计时器回调是
onFrame函数
-
-
onFrame:-
计算自上次调用以来的经过时间。
-
如果经过的时间小于动画速率,则它将不进行进一步处理而返回。否则,它计算动画需要更新的帧数。
-
通过调用
animate函数更新动画。
-
-
animate:-
通过固定增量更新对象位置。在这个例子中,每次调用
animate时,球体都会更新0.1单位。 -
它调用
draw来更新屏幕上的对象。这是可选的,因为渲染循环会定期调用draw。
-
-
draw:- 使用在
animate中计算的新位置创建局部变换,并将其应用于相应的对象。
- 使用在
代码看起来像这样:
transforms.calculateModelView();
transforms.push();
if (object.alias === 'sphere') {
const sphereTransform = transforms.modelViewMatrix;
mat4.translate(sphereTransform, sphereTransform, [0, 0, spherePosition]);
}
else if (object.alias === 'cone') {
const coneTransform = transforms.modelViewMatrix;
mat4.translate(coneTransform, coneTransform, [conePosition, 0, 0]);
}
transforms.setMatrixUniforms();
transforms.pop();
如果动画步骤实际上计算的时间比固定步骤长,这种方法可能会引起问题。如果发生这种情况,你应该简化你的动画代码或为你的应用程序发布推荐的最低系统规格。
Web Workers:JavaScript 中的多线程
虽然这超出了本书的范围,但如果你对性能有严格要求,应该考虑使用Web Workers。这样做将确保特定的更新循环始终以一致的速率触发。
Web Workers 是一个允许 Web 应用程序在主页面并行运行脚本的背景进程 API。这允许以消息传递作为协调机制进行类似线程的操作。
Web Workers
你可以在dev.w3.org/html5/workers/找到 Web Workers 规范。
建筑更新
让我们回顾一下本书中开发的示例结构。
应用程序审查
init 函数定义了三个函数钩子,用于控制应用程序的生命周期。正如我们在前面的章节中提到的,我们通过调用 init 函数来创建我们的应用程序。然后,我们调用 configure、load 和 render 函数的钩子。请注意,init 函数是应用程序的入口点,并且它将自动使用网页的 onload 事件调用。
添加对矩阵栈的支持
我们还添加了一个新的脚本:Transforms.js。此文件包含 Transforms 类,它封装了矩阵处理操作,包括 push 和 pop 矩阵栈操作。Transforms 类取代了 initTransforms、updateTransforms 和 setMatrixUniforms 函数背后的功能。
你可以在 common/js/Transforms.js 中找到 SceneTransforms 的源代码。
连接矩阵栈和 JavaScript 定时器
在以下部分,我们将研究一个简单的场景,其中我们动画化了圆锥和球体。我们将使用矩阵栈来实现局部变换,并使用 JavaScript 定时器来实现动画序列。
行动时间:简单动画
让我们看看一个涵盖简单动画技术的例子:
- 在你的浏览器中打开
ch05_01_simple-animation.html:

-
移动相机(左键单击 + 拖动)并观察对象(球体和圆锥)如何独立移动(局部变换)以及相机(全局变换)。
-
你也可以推拉相机(左键单击 + Alt + 拖动)。
-
将相机类型更改为跟踪。如果你因为任何原因失去了方向,请点击“返回主页”。
-
让我们检查源代码,看看我们是如何实现这个例子的。在代码编辑器中打开
ch05_01_simple-animation.html。 -
看看
render、onFrame和animate函数。我们在这里使用哪种计时策略? -
spherePosition和conePosition全局变量分别包含球体和圆锥的位置。向上滚动到draw函数。在主循环中,每个对象场景渲染时,根据当前渲染的对象计算不同的局部变换。代码如下:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
transforms.updatePerspective();
try {
gl.uniform1i(program.uUpdateLight, fixedLight);
scene.traverse(object => {
transforms.calculateModelView();
transforms.push();
if (object.alias === 'sphere') {
const sphereTransform = transforms.modelViewMatrix;
mat4.translate(sphereTransform, sphereTransform, [0, 0,
spherePosition]);
}
else if (object.alias === 'cone') {
const coneTransform = transforms.modelViewMatrix;
mat4.translate(coneTransform, coneTransform, [conePosition,
0, 0]);
}
transforms.setMatrixUniforms();
transforms.pop();
gl.uniform4fv(program.uMaterialDiffuse, object.diffuse);
gl.uniform4fv(program.uMaterialSpecular, object.specular);
gl.uniform4fv(program.uMaterialAmbient, object.ambient);
gl.uniform1i(program.uWireframe, object.wireframe);
// Bind VAO
gl.bindVertexArray(object.vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, object.ibo);
if (object.wireframe) {
gl.drawElements(gl.LINES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
else {
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
});
}
catch (error) {
console.error(error);
}
}
-
使用变换对象(它是
Transforms的一个实例),我们通过调用transforms.calculateModelView()获取全局模型视图矩阵。通过调用push方法将其推入矩阵栈。现在我们可以应用我们想要的任何变换,因为我们知道我们可以检索全局变换,因为它对于列表中的下一个对象是可用的。我们在代码片段的末尾通过调用pop方法这样做。在push和pop调用之间,我们确定当前正在渲染的对象,并使用spherePosition或conePosition全局变量将平移应用于当前的模型视图矩阵。通过这样做,我们创建了一个局部变换。 -
再次查看前面的代码。正如你在本练习开始时看到的,圆锥在 x 轴上移动,而球体在 z 轴上移动。你需要改变什么来使圆锥在 y 轴上动画化?通过修改此代码、保存网页并在你的网络浏览器中再次打开它来测试你的假设。
-
让我们回到
animate函数。我们应该在这里修改什么来使物体移动得更快?
提示:查看这个函数使用的全局变量。
刚才发生了什么?
在这个练习中,我们看到了两个物体的简单动画。我们检查了源代码,以了解使动画成为可能的函数调用栈。
尝试模拟掉帧和冻结帧
让我们看看我们如何控制渲染速率:
- 使用你的浏览器打开
ch05_02_dropping-frames.html文件。在这里,你会看到我们在上一节中分析的相同场景。你会注意到动画不流畅,因为我们正在模拟掉帧:

-
查看你编辑器中的源代码。
-
滚动到
onFrame函数。你可以看到我们增加了一个新的变量:simulationRate。在onFrame函数中,这个变量计算当时间流逝约为300 ms(animationRate)时需要执行多少个模拟步骤。鉴于simulationRate是30 ms,这将产生总共10个模拟步骤。如果出现意外延迟且流逝的时间显著更高,这些步骤可能会增加。这是我们期望的行为。 -
尝试不同的
animationRate和simulationRate变量的值来回答以下问题:-
我们如何解决掉帧问题?
-
我们如何模拟冻结帧?
-
模拟冻结帧时,
animationRate和simulationRate变量之间的关系是什么?
-
参数曲线
在许多情况下,我们不知道物体在特定时间点的确切位置,但我们知道描述其运动的方程。这些方程被称为参数曲线;它们是参数化的,因为位置取决于一个参数——例如,时间。
参数曲线有很多例子。例如,游戏中射出的弹丸、下山的车或弹跳的球。在每种情况下,都有描述这些物体在理想条件下运动的方程。以下图显示了描述自由落体运动的参数方程:

哪里:
-
: 在处的重力 -
: 初始速度 -
: 初始位置 -
: 时间 -
: 位置
我们将使用参数曲线来动画化 WebGL 场景中的对象。在这个例子中,我们将模拟一组弹跳球。这个练习的完整源代码可以在 ch05_03_bouncing-balls.html 中找到。
初始化步骤
我们将创建一个全局变量来存储(模拟)时间。我们还将创建全局变量来调节动画:
let
gl, scene, program, camera, transforms,
elapsedTime, initialTime,
fixedLight = false,
balls = [],
sceneTime = 0,
animationRate = 15,
gravity = 9.8,
ballsCount = 50;
load 函数更新为使用相同的几何形状(相同的 JSON 文件)加载一堆球,但我们将其多次添加到 scene 对象中。代码如下:
function load() {
scene.add(new Floor(80, 2));
for (let i = 0; i < ballsCount; i++) {
balls.push(new BouncingBall());
scene.load('/common/models/geometries/ball.json', `ball${i}`);
}
}
ES6 模板字符串
注意,我们还填充了一个名为 balls[] 的数组。我们这样做是为了在全局时间每次变化时存储球的位置。我们将在下一个 行动时间 部分深入讨论弹跳球模拟。目前,值得一提的是,我们是在 load 函数中加载几何形状并使用初始球位置初始化球数组的。
设置动画计时器
render 和 onFrame 函数与之前的示例完全相同:
function onFrame() {
elapsedTime = (new Date).getTime() - initialTime;
if (elapsedTime < animationRate) return;
let steps = Math.floor(elapsedTime / animationRate);
while (steps > 0) {
animate();
steps -= 1;
}
initialTime = (new Date).getTime();
}
function render() {
initialTime = (new Date).getTime();
setInterval(onFrame, animationRate / 1000);
}
运行动画
animate 函数将 sceneTime 变量传递给球数组中每个球的 update 方法。然后,sceneTime 通过一个固定量更新。代码如下:
function animate() {
balls.forEach(ball => ball.update(sceneTime));
sceneTime += 33 / 1000;
draw();
}
再次强调,参数曲线非常有帮助,因为它们不需要我们事先知道我们想要移动的每个对象的位置。我们只需应用一个参数方程,它根据当前时间给出位置。这发生在每个球在其更新方法内部。
在当前位置绘制每个球
在 draw 函数中,我们使用矩阵栈在为每个球应用局部变换之前保存 Model-View 矩阵的状态。代码如下:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
transforms.updatePerspective();
try {
gl.uniform1i(program.uUpdateLight, fixedLight);
scene.traverse(object => {
transforms.calculateModelView();
transforms.push();
if (~object.alias.indexOf('ball')) {
const index = parseInt(object.alias.substring(4, 8));
const ballTransform = transforms.modelViewMatrix;
mat4.translate(ballTransform, ballTransform, balls[index].position);
object.diffuse = balls[index].color;
}
transforms.setMatrixUniforms();
transforms.pop();
gl.uniform4fv(program.uMaterialDiffuse, object.diffuse);
gl.uniform4fv(program.uMaterialSpecular, object.specular);
gl.uniform4fv(program.uMaterialAmbient, object.ambient);
gl.uniform1i(program.uWireframe, object.wireframe);
gl.uniform1i(program.uPerVertexColor, object.perVertexColor);
// Bind
gl.bindVertexArray(object.vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, object.ibo);
if (object.wireframe) {
gl.drawElements(gl.LINES, object.indices.length, gl.UNSIGNED_SHORT,
0);
}
else {
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
});
}
catch (error) {
console.error(error);
}
}
这里的技巧是使用组成球别名的数字来查找 balls 数组中相应的球位置。例如,如果正在渲染的球具有 ball32 别名,那么此代码将查找 balls 数组中索引为 32 的球当前位置。这种 ball 别名与其在球数组中的位置之间的一对一对应关系是在 load 函数中建立的。
在下面的 行动时间 部分,我们将看到弹跳球动画的实际效果。我们还将讨论一些代码细节。
行动时间:弹跳球
让我们看看一个示例,了解我们如何在我们的场景中动画化多个对象:
- 在你的浏览器中打开
ch05_03_bouncing-balls.html:

-
默认情况下激活了环绕相机。移动相机,你会看到所有对象如何调整到全局变换(相机)并继续根据它们的局部变换弹跳。
-
让我们更详细地解释我们是如何跟踪每个球的。
-
定义适当的全局变量和常量:
let
gl, scene, program, camera, transforms,
elapsedTime, initialTime,
fixedLight = false,
balls = [],
sceneTime = 0,
animationRate = 15,
gravity = 9.8,
ballsCount = 50;
- 初始化
balls数组。我们在load函数中使用for循环来实现这一点:
function load() {
scene.add(new Floor(80, 2));
for (let i = 0; i < ballsCount; i++) {
balls.push(new BouncingBall());
scene.load('/common/models/geometries/ball.json', `ball${i}`);
}
}
BouncingBall类为balls数组中的每个球初始化模拟变量。其中一个属性是位置,我们随机选择。你可以通过使用generatePosition函数看到我们是如何做到这一点的:
function generatePosition() {
return [
Math.floor(Math.random() * 50) - Math.floor(Math.random() *
50),
Math.floor(Math.random() * 30) + 50,
Math.floor(Math.random() * 50)
];
}
-
在将新球添加到
balls数组后,我们在scene实例中添加一个新的球对象(几何形状)。请注意,我们创建的别名包括球对象在balls数组中的当前索引。例如,如果我们向数组中添加第 32 个球,相应的几何形状在scene中将具有ball32的别名。 -
我们在这里添加到场景中的唯一其他对象是
Floor。我们在之前的练习中使用了这个对象。你可以在common/js/Floor.js中找到Floor类的代码。 -
让我们谈谈
draw函数。在这里,我们遍历scene的元素并检索每个对象的别名。如果别名包含单词ball,我们知道这个别名对应于球数组中的index。我们可能在这里使用关联数组来使其看起来更美观,但这样做实际上并没有改变我们的目标。这里的主要目的是确保我们可以将每个球的模拟变量与scene中相应的对象(几何形状)关联起来。 -
注意,对于
scene中的每个对象(球几何形状),我们从balls数组中的相应BouncingBall实例中提取当前位置和颜色。 -
通过使用矩阵堆栈来处理局部变换,如前所述,修改每个球当前的模型-视图矩阵。在我们的例子中,我们希望每个球的动画独立于相机变换和彼此之间的变换。
-
到目前为止,我们已经描述了如何创建弹跳球(
load)以及它们是如何渲染的(draw)。这些函数中的任何一个都没有修改球当前的当前位置。我们通过使用BouncingBall.update来实现这一点。此代码使用动画时间(全局变量sceneTime)来计算弹跳球的位置。由于每个BouncingBall都有自己的模拟参数,因此当提供sceneTime时,我们可以为每个给定位置计算位置。简而言之,球的位置是时间的函数,因此它属于由参数曲线描述的运动类别。 -
BouncingBall.update方法在animate函数内部被调用。正如我们之前看到的,这个函数由动画计时器在计时器到达时调用。在这个函数内部,你可以看到模拟变量是如何更新以反映该球在模拟中的当前状态的。
刚才发生了什么?
我们已经学习了如何使用矩阵栈策略处理多个对象局部变换,同时保持全局变换。在弹跳球示例中,我们使用了一个独立于渲染计时器的动画计时器进行动画。最后,我们看到了弹跳球update方法如何展示参数曲线的工作原理。
优化策略
如果你稍微调整一下,将全局常量ballsCount的值从50增加到500,你将开始注意到帧率下降:

在前面的屏幕截图中,渲染速度大约在每秒30帧左右。根据你的电脑配置,draw函数的平均执行时间可能会高于动画计时器回调的调用频率。这会导致帧丢失。为了纠正这个问题,我们需要使draw函数运行得更快。让我们看看一些实现这一目标的策略。
批处理性能优化
WebGL 2 增加了一些有趣的功能,例如几何实例化。这个功能允许我们使用实例化和单个render调用渲染具有不同着色器属性的相同网格实例。尽管实例化有限制,因为它基于相同的网格,但它仍然是在必须多次绘制相同网格时提高性能的绝佳方法,特别是如果与着色器结合使用。虽然这个功能在 WebGL 2 中提供,但我们将为了教育目的构建自己的几何优化技术。我们将在第十一章 WebGL 2 Highlights 中介绍 WebGL 2 的几何实例化功能。
我们如何在不用 WebGL 2 几何实例化 API 的情况下优化场景?我们可以使用几何缓存作为优化场景中大量相似对象动画的一种方式。这在弹跳球示例中就是这种情况。每个弹跳球都有不同的位置和颜色。这些特征对于每个球来说都是独特且独立的。然而,所有的球都共享相同的几何形状。
在load函数中,对于ch05_03_bouncing-balls.html,我们为每个球创建了50个顶点缓冲对象(VBO)。此外,相同的几何形状被加载了50次,并且在每次渲染循环(draw函数)中,都会绑定一个不同的 VBO,尽管所有球的几何形状都是相同的!
在ch05_04_bouncing-balls-optimized.html中,我们修改了load和draw函数以处理几何缓存。首先,几何形状只加载一次(load函数):
function load() {
scene.add(new Floor(80, 2));
scene.add(new Axis(82));
scene.load('/common/models/geometries/ball.json', 'ball');
}
第二,当具有 'ball' 别名的对象是渲染循环中的当前对象(draw 函数)时,会调用 drawBalls 代理函数。这个函数设置了一些对所有弹跳球都通用的统一变量(这样我们就不必每次都为每个球传递它们到 program)。之后,调用 drawBall 函数。这个函数将设置每个球独特的元素。在我们的例子中,我们设置了与球颜色和模型视图矩阵相对应的 program 统一变量,由于局部变换(球的位置),这个矩阵对每个球来说是唯一的:

在顶点着色器中执行平移
如果你查看 ch05_04_bouncing-balls-optimized.html 中的代码,你会看到我们额外采取了一步来缓存模型视图矩阵。
这背后的基本思想是将原始矩阵一次性传输到 GPU(全局),然后在顶点着色器内部为每个球(局部)执行平移。这种改变由于顶点着色器的并行性质,显著提高了性能。
这是我们的操作步骤,一步一步来:
-
创建一个新的统一变量,告诉顶点着色器是否应该执行平移(
uTranslate)。 -
创建一个新的统一变量,包含每个球的位置(
uTranslation)。 -
将这两个新统一变量映射到 JavaScript 变量(我们在
configure函数中这样做):
// Create program variable that maps the uniform uTranslation
gl.uniform3fv(program.uTranslation, [0, 0, 0]);
// Create program variable that maps the uniform uTranslate
gl.uniform1i(program.uTranslate, false);
- 在顶点着色器内部执行平移。这部分可能是最棘手的部分,因为它需要一点 ESSL 编程:
// Transformed vertex position
vec3 vecPosition = aVertexPosition;
if (uTranslate) {
vecPosition += uTranslation;
}
-
在这个代码片段中,我们定义了
vecPosition,一个vec3类型的变量。如果uTranslate统一变量为true(意味着我们正在尝试渲染一个弹跳球),则使用向量加法更新vecPosition。这是通过以下方式实现的: -
确保在存在平移的情况下,变换后的顶点携带平移,所以以下行看起来像这样:
vec4 vertex = uModelViewMatrix * vec4(vecPosition, 1.0);
- 在
drawBall中,将当前球的位置作为uTranslation统一变量的内容传递:
gl.uniform3fv(program.uTranslation, ball.position);
- 在
drawBalls中,将uTranslate统一变量设置为true:
gl.uniform1i(program.uTranslate, true);
- 在
draw中,使用以下代码行一次性为所有球传递模型视图矩阵:
transforms.setMatrixUniforms();
- 将全局变量
ballsCount从50增加到500,并观察应用程序如何继续以合理的性能运行,无论场景复杂度如何增加。执行时间的改进在下面的屏幕截图中显示:

- 经过这些优化后,示例以流畅的
60帧每秒运行。优化的源代码可在ch05_bouncing-balls-optimized.html中找到。
插值
插值大大简化了 3D 对象的动画。与参数曲线不同,不需要将对象的位置定义为时间的函数。当使用插值时,我们只需要定义控制点或节点。控制点集描述了特定动画对象将遵循的路径。有众多插值方法;然而,始终从基础开始是一个好主意。
线性插值
此方法要求我们定义对象位置的开始点和结束点,以及插值步数的数量。对象将在由起点和终点确定的直线上移动:

多项式插值
此方法允许我们确定我们想要的任意数量的控制点。对象将从起点移动到终点,并穿过中间的每一个控制点:

当使用多项式时,越来越多的控制点可能会在由该技术描述的对象路径上产生不希望的振荡。这被称为Runge 现象。以下图表展示了使用11个控制点描述的多项式的一个控制点的移动结果:

B-Splines
此方法类似于多项式插值,不同之处在于控制点位于对象路径之外。换句话说,当对象移动时,它不会穿过控制点。一般来说,这种方法在计算机图形学中很常见,因为节点允许生成比多项式等价物更平滑的路径,同时所需的节点更少。B 样条对 Runge 现象也有更好的响应:

在以下行动时间部分,我们将看到在实践中引入的三种不同的插值技术:线性、多项式和B 样条。
行动时间:插值
让我们通过一个示例来展示各种插值技术:
- 使用浏览器打开
ch05_05_interpolation.html。你应该看到以下类似的内容:

- 在编辑器中检查代码。几乎所有函数都与之前相同,只是新增了一个名为
interpolate的函数。此函数以线性方式插值位置:
function interpolate() {
const [X0, Y0, Z0] = initialPosition;
const [X1, Y1, Z1] = finalPosition;
const dX = (X1 - X0) / incrementSteps;
const dY = (Y1 - Y0) / incrementSteps;
const dZ = (Z1 - Z0) / incrementSteps;
for (let i = 0; i < incrementSteps; i++) {
position.push([X0 + (dX * i), Y0 + (dY * i), Z0 + (dZ * i)]);
}
}
- 在浏览器中打开
ch05_06_interpolation-final.html。你应该看到以下类似的内容:

-
如果尚未选择,请选择线性插值。
-
使用提供的滑块移动起点和终点。
-
改变插值步数的数量。当你减少步数时,动画会发生什么变化?
-
线性插值的代码已经在
doLinearInterpolation函数中实现。 -
选择多项式插值。在这个例子中,我们实现了拉格朗日插值法。你可以在
doLagrangeInterpolation函数中看到源代码。 -
屏幕上出现了三个新的控制点(旗帜)。使用网页上提供的滑块,你可以改变这些控制点的位置。你还可以更改插值步数的数量:

-
你可能也注意到,每当球体接近一个旗帜(除起点和终点外),旗帜会改变颜色。为了做到这一点,我们编写了辅助的
close函数。我们在draw例程中使用此函数来确定旗帜的颜色。如果球体的当前位置(由position[sceneTime]确定)接近旗帜位置之一,相应的旗帜会改变颜色。当球体远离旗帜时,旗帜会变回原来的颜色。 -
修改源代码,使得每个旗帜都保持激活状态;也就是说,在球体经过后,使用新的颜色激活,直到动画循环回到开始。这发生在
sceneTime等于incrementSteps时(参见animate函数)。 -
选择B-Spline插值。注意球体在初始配置中并没有触及任何中间旗帜。你能测试出任何配置,使得球体至少穿过两个旗帜吗?
刚才发生了什么?
我们学习了如何使用插值来描述我们 3D 世界中物体的运动。我们还创建了非常简单的脚本,用于检测物体接近并相应地改变场景(在这个例子中改变旗帜颜色)。对接近的反应是游戏设计中的关键元素!
概述
让我们总结一下本章学到的内容:
-
我们看到了如何利用矩阵栈来在应用局部变换的同时保留全局变换。
-
我们介绍了 WebGL 中对象动画背后的基本概念。更具体地说,我们学习了局部和全局变换之间的区别。
-
我们学习了
requestAnimationFrame浏览器和用 JavaScript 计时器构建的动画版本。 -
一个与渲染周期无关的动画计时器通过确保场景中的时间独立于屏幕上渲染速度的快慢,提供了很大的灵活性。
-
我们区分了针对各种问题解决方法的动画和模拟策略。
-
我们研究了插值方法和它们的各种方法。
在下一章中,我们将在一个 WebGL 场景中玩转颜色和混合。我们将研究物体和光颜色之间的相互作用,并学习如何创建半透明物体。
第六章:颜色、深度测试和 alpha 混合
在上一章中,我们介绍了全局与局部变换、矩阵栈、动画计时器和各种插值技术。在本章中,我们首先检查颜色在 WebGL 和 ESSL 中的结构和处理方式。我们将讨论在对象、光源和场景中使用颜色。然后,我们将看到 WebGL 如何利用深度缓冲区来处理当一个对象在另一个对象前面时,遮挡另一个对象的情况。最后,我们将介绍 alpha 混合,它允许我们在一个对象遮挡另一个对象时组合对象的颜色,同时也允许我们创建半透明对象。
在本章中,我们将涵盖以下主题:
-
在对象中使用颜色。
-
为光源分配颜色。
-
在 ESSL 程序中处理多个光源。
-
学习如何使用深度测试和 z 缓冲区。
-
学习如何使用混合函数和方程。
-
使用面剔除创建透明对象。
在 WebGL 中使用颜色
WebGL 为 RGB 模型提供了第四个属性。这个属性被称为alpha 通道。扩展后的模型被称为RGBA模型,其中 A 代表 alpha。alpha 通道包含一个介于0.0到1.0之间的值,就像其他三个通道(红色、绿色和蓝色)一样。以下图表显示了 RGBA 颜色空间。在水平轴上,你可以看到通过组合R、G和B通道可以获得的不同颜色。垂直轴对应于 alpha 通道:

alpha 通道携带有关颜色的额外信息。这些信息会影响颜色在屏幕上的渲染方式。在大多数情况下,alpha 值将表示颜色包含的不透明度。完全不透明的颜色将具有1.0的 alpha 值,而完全透明的颜色将具有0.0的 alpha 值。这是一般情况,但正如我们将看到的,当我们获得半透明颜色时,我们需要考虑其他因素。
透明与半透明
例如,玻璃对所有可见光都是透明的。半透明对象允许一些光通过它们。像磨砂玻璃和一些塑料这样的材料被称为半透明。当光线击中半透明材料时,只有一部分光线通过它们。
我们在我们的 WebGL 3D 场景中到处使用颜色:
-
对象:3D 对象可以通过为每个像素(片段)选择一个颜色或根据材料的漫反射属性选择对象将具有的颜色来进行着色。
-
光源:尽管我们一直在使用白光,但我们没有理由不能有环境或漫反射属性包含其他颜色的光源。
-
场景:我们场景的背景有一个颜色,我们可以通过调用
gl.clearColor来改变它。此外,正如我们稍后将会看到的,需要对对象的颜色执行特殊操作以实现半透明效果。
对象中颜色的使用
如前几章所述,像素的最终颜色是通过设置一个输出 ESSL 变量在片段着色器中分配的。如果一个对象中的所有片段具有相同的颜色,我们可以说该对象具有恒定颜色。否则,该对象通常被认为是具有顶点颜色。
恒定着色
要获得恒定颜色,我们将所需颜色存储在一个传递到片段着色器的统一变量中。这个统一变量通常被称为对象的漫反射材料属性。我们还可以结合对象法线和光源信息来获取朗伯系数。我们可以使用朗伯系数按比例改变反射颜色,这取决于光线击中物体的角度。
如以下图所示,当我们不使用关于法线的信息来获取朗伯系数时,我们会失去深度感知。请注意,我们正在使用扩散光照模型:

顶点着色
在医疗和工程可视化应用中,我们常常会发现与我们要渲染的模型顶点相关的颜色图。这些图根据每个顶点的标量值分配颜色。这个想法的一个例子包括温度图表,它将蓝色表示为冷温度,将红色表示为热温度,并叠加在地图上。
要实现顶点着色,我们需要在顶点着色器中定义一个存储顶点颜色的属性:
in vec4 aVertexColor;
下一步是将 aVertexColor 属性分配给一个变量,以便它可以传递到片段着色器。记住,变量是自动插值的。因此,每个片段的颜色将是其贡献顶点的加权结果。
如果我们想让我们的颜色图对光照条件敏感,我们可以将每个顶点颜色乘以光的漫反射分量。然后将结果分配给将要传递结果到片段着色器的变量。
以下图展示了这种情况下的两种不同可能性:在左侧,顶点颜色乘以光漫射项,没有来自光位置的加权;在右侧,朗伯系数生成预期的阴影,并提供了光源相对位置的信息:

在这里,我们正在使用一个映射到 aVertexColor 顶点着色器属性的顶点缓冲区对象。我们已经在 第二章,渲染 中学习了如何映射 VBO。
片段着色
我们也可以为我们渲染的对象的每个像素分配一个随机颜色。尽管 ESSL 没有预构建的随机函数,但我们可以使用算法来生成伪随机数。但话说回来,这种技术的目的和用途超出了本书的范围。
行动时间:给立方体上色
让我们来看一个简单的着色几何形状的例子:
- 使用您的浏览器打开
ch06_01_cube.html文件。您将看到一个类似于以下页面的页面:

-
在这个练习中,我们将比较恒定颜色与顶点颜色。让我们谈谈页面的小部件:
-
Lambert:当选中时,它将在最终颜色计算中包含 Lambert 系数。
-
按顶点:之前解释过的两种着色方法:按顶点或恒定。
-
复杂立方体:加载一个 JSON 对象,其中顶点被重复以获得每个顶点的多个法线和多个颜色。我们将在稍后解释这是如何工作的。
-
Alpha 值:此滑块映射到顶点着色器中的
uAlpha浮点统一变量。uAlpha设置顶点颜色的 alpha 值。
-
-
通过点击“Lambert”禁用 Lambert 系数的使用。通过点击并拖动来旋转立方体。注意当 Lambert 系数不包括在最终颜色计算中时,深度感知的损失。Lambert 按钮映射到
uUseLambert布尔统一变量。计算 Lambert 系数的代码可以在页面中包含的顶点着色器中找到:
float lambertTerm = 1.0;
if (uUseLambert) {
vec3 normal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
vec3 lightDirection = normalize(-uLightPosition);
lambertTerm = max(dot(normal, -lightDirection), 0.20);
}
- 如果
uUseLambert统一变量为false,则lambertTerm保持为1.0,不影响这里的最终漫反射项:
Id = uLightDiffuse * uMaterialDiffuse * lambertTerm;
-
否则,
Id将包含 Lambert 系数。 -
禁用 Lambert 后,点击“按顶点”按钮。旋转立方体以查看 ESSL 如何插值顶点颜色。允许我们从恒定漫反射颜色切换到顶点颜色的顶点着色器键代码片段使用
uUseVertexColors布尔统一变量和aVertexColor属性。此片段在此处显示:
if (uUseVertexColor) {
Id = uLightDiffuse * aVertexColor * lambertTerm;
}
else {
Id = uLightDiffuse * uMaterialDiffuse * lambertTerm;
}
- 查看
common/models/geometries/cube-simple.json文件。在那里,立方体的八个顶点在顶点数组中定义,并且对于每个顶点都有一个标量数组中的元素。正如您可能预期的,这些元素中的每一个都对应于相应的顶点颜色,如下所示:

- 确保 Lambert 按钮未激活,然后点击“复杂立方体”按钮。通过在相应的 JSON 文件
common/models/geometries/cube-complex.json中的顶点数组中重复顶点,我们可以实现独立的面着色。以下图表解释了cube-complex.json中顶点的组织方式。请注意,由于我们使用着色器属性定义颜色,我们需要重复每个颜色四次,因为每个面有四个顶点。这一想法在以下图表中展示:

-
激活 Lambert 按钮以查看 Lambert 系数如何影响物体的颜色。尝试不同的按钮配置以查看会发生什么。
-
让我们快速探索将 alpha 通道更改为小于
1.0的值的效应。您看到了什么?请注意,对象并没有变得透明,而是开始失去颜色。要获得透明度,我们需要激活混合。我们将在本章的后面深入讨论混合。现在,在configure函数的源代码中取消注释这些行:
// gl.disable(gl.DEPTH_TEST);
// gl.enable(gl.BLEND);
// gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
- 保存页面并在您的网络浏览器中重新加载。如果您选择顶点着色,复杂立方体并降低 alpha 值到
0.5,您将看到以下截图类似的内容:

发生了什么?
我们研究了两种不同的对象着色方法:常量着色和顶点着色。在两种情况下,每个片段的最终颜色都是由片段着色器中的out限定符导出的颜色变量来指定的。
我们看到,通过激活 Lambert 系数的计算,我们获得了感知深度信息。
我们还看到,在我们的对象中重复顶点使我们能够获得不同的着色效果。例如,我们可以通过面而不是顶点来着色一个对象。
光照中的颜色使用
颜色是光照属性。在第三章中,我们学习了光照属性的数量取决于为场景选择的照明-反射模型。例如,使用 Lambertian 反射模型,我们只需要建模一个着色器统一变量:光照漫反射属性/颜色。相比之下,如果选择 Phong 反射模型,每个光源都需要有三个属性:环境光、漫反射和镜面反射颜色。
位置光
当着色器需要知道光照的位置时,通常将光照位置建模为一个统一变量。因此,具有位置光的光照模型将具有四个统一变量:环境光、漫反射、镜面反射和位置。
对于方向光,第四个统一变量是光的方向。更多信息,请参阅第三章,灯光。
我们已经看到,每个光照属性都由一个映射到着色器中vec4统一变量的 JavaScript 四元素数组表示。
作为快速提醒,我们应该使用哪些 WebGL 方法来检索和设置统一变量?在我们的情况下,我们用来将光照传递给着色器的两种方法如下:
-
getUniformLocation:在程序中定位统一变量并返回一个我们可以用来设置值的索引。 -
uniform4fv:由于光照分量是 RGBA,我们需要传递一个四元素浮点向量。
可扩展性问题
由于我们希望在场景中使用多个灯光,我们需要定义和映射所选光照模型的适当数量的统一变量。如果我们每个灯光有四个属性(环境、漫反射、镜面反射和位置),我们需要为每个灯光定义四个统一变量。如果我们想有三个灯光,我们需要编写、使用和映射十二个统一变量!在问题变得无法控制之前,我们需要解决这个问题。
我们可以使用多少统一变量?
OpenGL 着色语言 ES 规范定义了我们允许使用的统一变量的数量。
第 4.3.4 节 统一变量
对于每种着色器类型,可用于统一变量的存储量有一个实现相关的限制。如果超过这个限制,将导致编译时或链接时错误。
要找出你的 WebGL 实现的限制,你可以使用gl.getParameter函数和这些常量查询 WebGL:
gl.MAX_VERTEX_UNIFORM_VECTORS
gl.MAX_FRAGMENT_UNIFORM_VECTORS
实现限制由您的浏览器提供,并且很大程度上取决于您的图形硬件。话虽如此,即使您的机器可能有足够的变量空间,这也并不意味着问题已经解决。我们仍然需要定义和映射每个统一变量,这通常会导致脆弱和冗长的代码,正如我们将在后面的练习中看到的。
简化问题
为了简化问题,我们可以假设所有灯光的环境分量是相同的。这将减少统一变量的数量——每个灯光少一个统一变量。然而,这并不是一个适用于更一般情况的扩展解决方案,在这种情况下,我们不能假设环境光恒定。
在我们深入探讨具有多个灯光的场景之前,让我们更新我们的架构以涵盖我们已解决的一些概念。
架构更新
随着我们通过这本书的进展,我们继续在适当的地方改进我们的架构,以反映我们所学的知识。在这个场合,我们将改进传递统一变量到程序的方式,并添加支持处理大量统一变量以定义多个灯光。
添加对光对象的支持
让我们详细地介绍这些变化。我们创建了一个新的 JavaScript 模块Lights.js,它包含两个对象:
-
Light:将光属性(位置、漫反射、镜面反射等)聚合在一个单一实体中。 -
LightsManager:包含场景中的灯光。这允许我们通过index或name检索每个灯光。
LightsManager还包含getArray方法,通过类型将属性数组扁平化:
getArray(type) {
return this.list.reduce((result, light) => {
result = result.concat(light[type]);
return result;
}, []);
}
这将在我们稍后使用统一数组时很有用。
改进传递统一变量到程序的方式
我们还改进了传递统一变量的方式。在configure中,我们可以看到我们是如何将属性和统一变量传递给program.load,而不是像在介绍章节中那样手动将它们附加到实例上。
configure 函数是加载程序的适当位置。我们还将创建 JavaScript 变量和统一变量之间的动态映射。考虑到这一点,我们已经更新了 program.load 方法,使其接收两个数组:
-
attributes:一个数组,包含我们将要在 JavaScript 和 ESSL 之间映射的属性名称。 -
uniforms:一个数组,包含我们将要在 JavaScript 和 ESSL 之间映射的统一变量名称。
函数的实现现在如下所示:
// Load up the given attributes and uniforms from the given values
load(attributes, uniforms) {
this.useProgram();
this.setAttributeLocations(attributes);
this.setUniformLocations(uniforms);
}
最后两行对应于两个新函数,setAttributeLocations 和 setUniformLocations:
// Set references to attributes onto the program instance
setAttributeLocations(attributes) {
attributes.forEach(attribute => {
this[attribute] = this.gl.getAttribLocation(this.program, attribute);
});
}
// Set references to uniforms onto the program instance
setUniformLocations(uniforms) {
uniforms.forEach(uniform => {
this[uniform] = this.gl.getUniformLocation(this.program, uniform);
});
}
如您所见,这些函数分别查找属性和统一变量列表,然后将位置作为属性附加到 Program 实例上。
简而言之,如果我们将 uLightPosition 统一变量名称包含在传递给 program.load 的 uniforms 列表中,那么我们就会有一个 program.uLightPosition 属性,它将包含相应统一变量的位置!真不错!
一旦我们在 configure 函数中加载了程序,我们可以立即使用以下代码初始化统一变量的值:
gl.uniform3fv(program.uLightPosition, value);
动手实践:向场景添加蓝色光源
我们已经准备好查看本章的第一个示例。我们将在一个具有三个光源的每片段光照的场景中工作。
每个光源都有一个位置和漫反射颜色属性。这意味着每个光源都有两个统一变量。执行以下步骤:
-
为了保持简单,我们假设所有三个光源的漫反射颜色相同。我们还去除了镜面反射属性。在您的浏览器中打开
ch06_02_wall_initial.html文件。 -
您将看到如下截图所示的场景,其中两个光源(红色和绿色)照亮了一面黑色墙壁:

-
使用您的代码编辑器打开
ch06_02_wall-initial.html文件。我们将更新顶点着色器、片段着色器、JavaScript 代码和 HTML 代码以添加蓝色光源。 -
更新顶点着色器:转到可以看到这两个统一变量的顶点着色器:
uniform vec3 uPositionRedLight;
uniform vec3 uPositionGreenLight;
- 让我们在那里添加第三个统一变量:
uniform vec3 uPositionBlueLight;
- 我们还需要定义一个变量来携带插值后的光线方向到片段着色器。记住,这里我们使用的是每片段光照。检查变量在哪里定义:
out vec3 vRedRay;
out vec3 vGreenRay;
- 然后,在那里添加第三个变量:
out vec3 vBlueRay;
- 让我们看看顶点着色器的主体。我们需要根据场景中的位置更新每个光源的位置。我们通过编写以下代码来实现:
vec4 blueLightPosition = uModelViewMatrix * vec4(uPositionBlueLight, 1.0);
-
注意,其他两个光源的位置也在被计算。
-
让我们计算从我们的蓝色光源到当前顶点的光线。我们通过编写以下代码来完成:
vBlueRay = vertex.xyz - blueLightPosition.xyz;
-
这就是我们需要在顶点着色器中修改的所有内容。
-
到目前为止,我们已经包含了一个新的光源位置,并在顶点着色器中计算了光线。这些光线将由片段着色器进行插值。
-
让我们通过包含我们新的蓝色光源源来计算墙上颜色的变化。滚动到片段着色器并添加一个新的统一变量——蓝色漫反射属性。查找在
main函数之前声明的以下统一变量:
uniform vec4 uDiffuseRedLight;
uniform vec4 uDiffuseGreenLight;
- 插入以下行:
uniform vec4 uDiffuseBlueLight;
- 为了计算蓝色光源对最终颜色的贡献,我们需要获取在顶点着色器中定义的先前光线。这个变量在片段着色器中可用。您还需要在
main函数之前声明它。查找以下:
in vec3 vRedRay;
in vec3 vGreenRay;
- 在其下方插入以下代码:
in vec3 vBlueRay;
- 假设所有光源的环境分量都是相同的。这在代码中通过只有一个
uLightAmbient变量来反映。环境项Ia是uLightAmbient和墙壁材料环境属性的乘积:
// ambient Term
vec4 Ia = uLightAmbient * uMaterialAmbient;
-
如果
uLightAmbient设置为(1.0, 1.0, 1.0, 1.0)且uMaterialAmbient设置为(0.1, 0.1, 0.1, 1.0),则结果环境项Ia将非常小。这意味着在这个场景中环境光的影响将很低。相比之下,漫反射分量对每个光源都是不同的。 -
让我们添加蓝色漫反射项的效果。在片段着色器的主函数中,查找以下代码:
// diffuse Term
vec4 Id1 = vec4(0.0, 0.0, 0.0, 1.0);
vec4 Id2 = vec4(0.0, 0.0, 0.0, 1.0);
- 在其下方立即添加以下行:
vec4 Id3 = vec4(0.0, 0.0, 0.0, 1.0);
- 滚动到以下:
float lambertTermOne = dot(N, -normalize(vRedRay));
float lambertTermTwo = dot(N, -normalize(vGreenRay));
- 在其下方添加以下行:
float lambertTermThree = dot(N, -normalize(vBlueRay));
- 滚动到以下:
if (lambertTermOne > uCutOff) {
Id1 = uDiffuseRedLight * uMaterialDiffuse * lambertTermOne;
}
if (lambertTermTwo > uCutOff) {
Id2 = uDiffuseGreenLight * uMaterialDiffuse * lambertTermTwo;
}
- 在其后插入以下代码:
if (lambertTermThree > uCutOff) {
Id3 = uDiffuseBlueLight * uMaterialDiffuse * lambertTermTwo;
}
- 更新
fragColor以包含Id3:
fragColor = vec4(vec3(Ia + Id1 + Id2 + Id3), 1.0);
- 这就是在片段着色器中需要做的所有事情。让我们继续到我们的 JavaScript 代码。到目前为止,我们已经编写了处理着色器中一个额外光源所需的代码。让我们看看我们如何从 JavaScript 端创建蓝色光源并将其映射到着色器中。滚动到
configure函数并查找以下代码:
const redLight = new Light('redLight');
redLight.setPosition(redLightPosition);
redLight.setDiffuse([1, 0, 0, 1]);
const greenLight = new Light('greenLight');
greenLight.setPosition(greenLightPosition);
greenLight.setDiffuse([0, 1, 0, 1]);
- 插入以下代码:
const blue = new Light('blueLight');
blue.setPosition([-2.5, 3, 3]);
blue.setDiffuse([0.0, 0.0, 1.0, 1.0]);
- 滚动到定义
uniforms列表的位置。如前所述,这种新机制使得获取统一变量的位置更容易。添加我们用于蓝色光源的两个新统一变量:uDiffuseBlueLight和uPositionBlueLight。列表应如下所示:
const uniforms = [
'uProjectionMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uMaterialDiffuse',
'uMaterialAmbient',
'uLightAmbient',
'uDiffuseRedLight',
'uDiffuseGreenLight',
'uDiffuseBlueLight', 'uPositionRedLight',
'uPositionGreenLight',
'uPositionBlueLight', 'uWireframe',
'uLightSource',
'uCutOff'
];
- 让我们将新定义的光源的位置和漫反射值传递给
program。在加载program的行之后找到以下行,并做出必要的更改:
gl.uniform3fv(program.uPositionRedLight, redLight.position);
gl.uniform3fv(program.uPositionGreenLight, greenLight.position);
gl.uniform3fv(program.uPositionBlueLight, blueLight.position);
gl.uniform4fv(program.uDiffuseRedLight, redLight.diffuse);
gl.uniform4fv(program.uDiffuseGreenLight, greenLight.diffuse);
gl.uniform4fv(program.uDiffuseBlueLight, blueLight.diffuse);
统一数组
每个光源使用一个统一的编码会使代码变得相当冗长。稍后我们将介绍如何使用统一数组来简化代码。
- 让我们更新
load函数。我们需要一个新的球体来表示蓝色光源,就像场景中已经有了两个球体一样:一个用于红色光源,另一个用于绿色光源。添加以下行:
scene.load('/common/models/geometries/sphere3.json', 'blueLight');
- 正如我们在
load函数中看到的,我们正在加载相同的几何体(球体)三次。为了区分代表光源的球体,我们正在使用球体的局部变换(最初以原点为中心)。滚动到render函数并找到以下代码行:
const modelViewMatrix = transforms.modelViewMatrix;
if (object.alias === 'redLight') {
mat4.translate(
modelViewMatrix, modelViewMatrix,
program.getUniform(program.uPositionRedLight)
);
object.diffuse = program.getUniform(program.uDiffuseRedLight);
gl.uniform1i(program.uLightSource, true);
}
if (object.alias === 'greenLight') {
mat4.translate(
modelViewMatrix, modelViewMatrix,
program.getUniform(program.uPositionGreenLight)
);
object.diffuse = program.getUniform(program.uDiffuseGreenLight);
gl.uniform1i(program.uLightSource, true);
}
- 添加以下代码:
if (object.alias === 'blueLight') {
mat4.translate(
modelViewMatrix, modelViewMatrix,
program.getUniform(program.uPositionBlueLight)
);
object.diffuse = program.getUniform(program.uDiffuseBlueLight);
gl.uniform1i(program.uLightSource, true);
}
- 就这样!将页面保存为不同的名称并在浏览器中测试它:

- 如果你没有得到预期的结果,请返回并检查步骤。你可以在
ch06_03_wall-final.html文件中找到完成的练习。
发生了什么?
我们通过添加一个额外的灯光:一个蓝色灯光,修改了我们的示例场景。我们更新了以下内容:
-
顶点着色器
-
片段着色器
-
configure函数 -
load函数 -
draw函数
正如你所见,一次处理一个光属性并不非常高效。在本章的后面部分,我们将研究一种更有效的方法来处理 WebGL 场景中的灯光。
尝试一下:添加交互性
我们将向我们的控件小部件添加一个额外的滑块,以交互式地改变我们刚刚添加的蓝色灯光的位置。
我们将使用 dat.GUI,为每个蓝色光坐标创建一个。
dat.GUI
你可以在 GitHub 上找到有关 dat.GUI 的更多信息:github.com/dataarts/dat.gui。
-
创建三个滑块:一个用于蓝色光的
X坐标,一个用于Y坐标,第三个用于Z坐标。 -
最终的 GUI 应该包括新的蓝色光滑块,它们应该看起来像以下这样:

-
使用页面上的滑块来指导你的工作。
-
你可以在
ch06_03_wall-final.html文件中找到完成的练习。
使用均匀数组处理多个灯光
正如我们所见,使用单独的均匀量处理光属性使代码冗长且难以维护。幸运的是,ESSL 提供了我们可以用来解决处理多个灯光问题的几个机制。其中之一是 均匀数组。
这种技术通过在着色器中引入可枚举的向量数组,使我们能够处理多个灯光。这允许我们通过在着色器中迭代灯光数组来计算光贡献。我们仍然需要在 JavaScript 中定义每个灯光,但由于我们不是为每个灯光属性定义一个均匀量,因此将映射到 ESSL 变得更简单。让我们看看这个技术是如何工作的。我们只需要在我们的代码中进行两个简单的更改。
均匀数组声明
首先,我们需要在我们的 ESSL 着色器内部声明光均匀数组。例如,包含三个灯光的光位置看起来是这样的:
uniform vec3 uPositionLight[3];
重要的是要注意,ESSL 不支持动态初始化均匀数组。我们可以尝试以下方法:
uniform int numLights;
uniform vec3 uPositionLight[numLights]; // will not work
如果是这样,着色器将无法编译,你将获得以下错误:
ERROR: 0:12 — constant expression required
ERROR: 0:12 — array size must be a constant integer expression
然而,这个结构是有效的:
const int numLights = 3;
uniform vec3 uPositionLight[numLights]; // will work
我们为每个灯光属性声明一个统一的数组,无论我们将有多少灯光。因此,如果我们想传递关于五个灯光的漫反射和镜面反射组件的信息,例如,我们需要声明两个统一的数组,如下所示:
uniform vec4 uDiffuseLight[5];
uniform vec4 uSpecularLight[5];
JavaScript 数组映射
接下来,我们需要将包含灯光属性信息的 JavaScript 变量映射到程序中。例如,我们可能想要映射这三个灯光位置:
const lightPosition1 = [0, 7, 3];
const lightPosition2 = [2.5, 3, 3];
const lightPosition3 = [-2.5, 3, 3];
如果是这样,我们需要检索统一数组的位置(就像在其他任何情况下一样):
const location = gl.getUniformLocation(program, 'uPositionLight');
唯一的不同之处在于我们将这些位置映射为一个连接的扁平数组:
gl.uniform3fv(location, [0, 7, 3, 2.5, 3, 3, -2.5, 3, 3]);
这里有两个重要的点:
-
传递给
getUniformLocation的统一变量的名称与之前相同。当使用getUniformLocation定位统一变量时,uPositionLight现在是一个数组这一事实并没有改变任何事情。 -
我们传递给统一的 JavaScript 数组是一个扁平数组。如果你按照以下方式编写,映射将不会工作:
gl.uniform3fv(location, [
[0, 7, 3],
[2.5, 3, 3],
[-2.5, 3, 3]
]);
因此,如果你为每个灯光有一个变量,你应该在将它们传递给着色器之前确保将它们连接起来。
动手时间:向场景添加白光
让我们看看如何向场景中添加一个新灯光的例子:
-
在浏览器中打开
ch06_04_wall-light-arrays.html文件。这个场景看起来与ch06_03_wall-final.html完全一样;然而,代码要简单得多,因为我们现在正在使用统一数组。让我们看看使用统一数组是如何改变我们的代码的。 -
在你的代码编辑器中打开
ch06_04_wall-light-arrays.html文件。让我们看看顶点着色器。注意使用常量整数表达式const int numLights = 3;来声明着色器将处理的灯光数量。 -
在那里,你还可以看到正在使用统一数组来操作灯光位置。请注意,我们正在使用一个可变数组将光线(针对每个灯光)传递到片段着色器中:
for(int i = 0; i < numLights; i++) {
vec4 lightPosition = uModelViewMatrix * vec4(uLightPosition[i],
1.0);
vLightRay[i] = vertex.xyz - lightPosition.xyz;
}
- 这段代码计算每个灯光的一个可变光线。回想一下,
ch06_03_wall-final.html文件中的相同代码看起来如下:
vec4 redLightPosition = uModelViewMatrix * vec4(uPositionRedLight,
1.0);
vec4 greenLightPosition = uModelViewMatrix *
vec4(uPositionGreenLight, 1.0);
vec4 blueLightPosition = uModelViewMatrix *
vec4(uPositionBlueLight, 1.0);
vRedRay = vertex.xyz - redLightPosition.xyz;
vGreenRay = vertex.xyz - greenLightPosition.xyz;
vBlueRay = vertex.xyz - blueLightPosition.xyz;
-
一旦比较了这两个片段,使用统一数组(和可变数组)的优势应该就很明显了。
-
片段着色器也使用统一数组。在这种情况下,片段着色器遍历灯光的漫反射属性来计算每个灯光对最终颜色在墙面上的贡献:
for(int i = 0; i < numLights; i++) {
L = normalize(vLightRay[i]);
lambertTerm = dot(N, -L);
if (lambertTerm > uCutOff) {
finalColor += uLightDiffuse[i] * uMaterialDiffuse * lambertTerm;
}
}
-
为了简洁起见,我们不会涵盖
ch06_03_wall-final.html练习中的冗长版本,但你应该亲自查看并与这个版本进行比较。 -
在
configure函数中,通过省略其他不必要的灯光属性,包含统一名称的 JavaScript 数组的大小已经显著减小:
const uniforms = [
'uPerspectiveMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uMaterialDiffuse',
'uMaterialAmbient',
'uLightAmbient',
'uLightDiffuse',
'uLightPosition',
'uWireframe',
'uLightSource',
'uCutOff'
];
-
由于
LightsManager类的getArray方法,JavaScript 灯光和统一数组之间的映射现在更简单了。正如我们之前所描述的,getArray方法将灯光的数据连接成一个扁平数组。 -
load和render函数看起来完全一样。如果我们想添加一个新光源,我们仍然需要使用load函数加载一个新的球体(在我们的场景中代表光源),我们仍然需要在render函数中将球体转换到适当的位置。 -
让我们看看添加新光源需要多少工作量。转到
configure函数并创建一个新的光源对象,如下所示:
const whiteLight = new Light('whiteLight');
whiteLight.setPosition([0, 10, 2]);
whiteLight.setDiffuse([1.0, 1.0, 1.0, 1.0]);
- 将
whiteLight添加到lights实例中:
lights.add(whiteLight);
- 移动到
load函数并添加以下行:
scene.load('/common/models/geometries/sphere3.json', 'whiteLight');
- 就像在之前的 Time for Action 部分,将以下内容添加到
render函数中:
if (object.alias === 'whiteLight') {
const whiteLight = lights.get(object.alias);
mat4.translate(modelViewMatrix, modelViewMatrix,
whiteLight.position);
object.diffuse = whiteLight.diffuse;
gl.uniform1i(program.uLightSource, true);
}
- 使用不同的名称保存网页,并使用您的浏览器打开它。我们还在
ch06_05_wall-light-arrays-final.html中包含了完成的练习,包括一些对保持灯光配置更声明式的微小改进。以下图表显示了最终结果:

这就是你需要做的全部!如果你想要使用控件部件控制白色光源的属性,你需要编写相应的代码。
Time for Action: Directional Point Lights
在 第三章,灯光 中,我们比较了方向光和位置光:

在点光源中,对于物体表面的每一个点,我们计算从光源到表面该点的方向。然后我们做与方向光相同的事情。记住,我们取了表面法线(表面面向的方向)和光方向的点积。如果两个方向匹配,这将给我们一个值为 1 的值,这意味着片段应该完全照亮,如果两个方向垂直,则为 0,如果它们相反,则为 -1。我们直接使用那个值来乘以表面的颜色,这样就得到了光照。
在本节中,我们将结合方向光和位置光。我们将创建第三种光源:一种方向点光源,通常称为聚光灯。这种光具有位置和方向属性。我们已经准备好这样做,因为我们的着色器可以轻松处理具有多个属性的光源:

创建这些光源的技巧是从每个顶点的法线中减去光方向向量。得到的向量将创建一个不同的朗伯系数,该系数将反射到由光源产生的锥体中:
- 在浏览器中打开
ch06_06_wall-spot-light.html。如您所见,现在有三个光源现在都有一个方向:

-
在您的源代码编辑器中打开
ch06_06_wall-spot-light.html。 -
要创建一个光锥,我们需要为每个片段获取一个朗伯系数。正如我们在之前的例子中所做的那样,我们通过计算反转光线的点积和插值后的法线来获取这些系数。到目前为止,我们一直使用一个变量来完成这个任务:
vNormal。 -
到目前为止,一个变量已经足够,因为我们不需要更新法线,无论场景中有多少灯光。然而,要创建方向性点光源,我们必须更新法线,因为每个光源的方向将创建不同的法线。因此,我们用变量数组替换
vNormal:
out vec3 vNormal[numLights];
- 减去光线方向从法线的行发生在
for循环内部。这是因为我们为场景中的每个光源都这样做,因为每个光源都有自己的光线方向:
for(int i = 0; i < numLights; i++) {
vec4 positionLight = uModelViewMatrix * vec4(uLightPosition[i],
1.0);
vec3 directionLight = vec3(uNormalMatrix *
vec4(uLightDirection[i], 1.0));
vNormal[i] = normal - directionLight;
vLightRay[i] = vertex.xyz - positionLight.xyz;
}
-
在这里,光线方向通过法线矩阵变换,而光线位置通过模型-视图矩阵变换。
-
在片段着色器中,我们计算朗伯系数:每个光源和片段一个。关键的区别在于片段着色器中的这一行:
N = normalize(vNormal[i]);
-
在这里,我们为每个光源获取插值后的更新后的法线。
-
让我们通过限制允许的朗伯系数来创建一个截止值。在片段着色器中,至少有两种不同的方法来获取一个光锥。第一种方法是将朗伯系数限制在高于
uCutOff统一变量(截止值)以上。让我们看看片段着色器:
if (lambertTerm > uCutOff) {
finalColor += uLightDiffuse[i] * uMaterialDiffuse;
}
- 朗伯系数是反射光线与表面法线之间角度的余弦值。如果光线垂直于表面,我们将获得最高的朗伯系数,并且当我们远离中心时,朗伯系数将按照余弦函数变化,直到光线完全平行于表面。这将在法线和光线之间产生
90度的余弦值。这产生了一个为零的朗伯系数:

-
如果还没有这样做,请在您的浏览器中打开
ch06_06_wall-spot-light.html。使用页面上的截止滑块。注意这如何通过使光锥变宽或变窄来影响光锥。在调整滑块后,你可能注意到这些灯光看起来不太真实。原因是最终的颜色与您获得的朗伯系数无关:只要朗伯系数高于设置的截止值,您就会从三个光源中获得完整的漫反射贡献。 -
为了细化结果,请使用您的源代码编辑器打开网页。然后,转到片段着色器,并将计算最终颜色的行中的朗伯系数相乘:
finalColor += uLightDiffuse[i] * uMaterialDiffuse * lambertTerm;
- 使用不同的名称保存网页(这样你可以保留原始版本),然后在你的网络浏览器中加载它。你会注意到,随着你离开墙上每个光源反射的中心,光颜色会衰减。这可能看起来更好,但有一个更简单的方法来创建更逼真的光截止值:

- 让我们使用指数衰减因子来创建一个截止值。在片段着色器中找到以下代码:
if (lambertTerm > uCutOff) {
finalColor += uLightDiffuse[i] * uMaterialDiffuse * lambertTerm;
}
- 用以下内容替换它:
finalColor += uLightDiffuse[i] * uMaterialDiffuse * pow(lambertTerm, 10.0 * uCutOff);
-
注意我们已经移除了
if条件。这次,衰减因子是pow(lambertTerm, 10.0 * uCutOff);。 -
这种修改是有效的,因为因子以指数方式衰减最终颜色。如果 Lambert 系数接近零,最终颜色将被严重衰减:

- 使用不同的名称保存网页并在你的浏览器中加载它。改进是显著的:

我们在这里包括了完成的练习:
-
ch06_07_wall-spot-light-proportional.html -
ch06_08_wall-spot-light-exponential.html
发生了什么?
我们已经学习了如何实现方向性点光源。我们还讨论了改善光照效果的衰减因子。
场景中颜色的使用
现在是时候讨论透明度和 alpha 混合了。如前所述,alpha 通道可以携带关于物体颜色不透明度的信息。然而,正如我们在立方体示例中看到的,除非激活 alpha 混合,否则无法获得半透明物体。当场景中有多个物体时,事情会变得稍微复杂一些。为了管理这些困难,我们需要学习如何操作以使场景中的半透明和不透明物体保持一致。
透明度
渲染透明物体的第一种方法是使用多边形 stippling。这种技术包括丢弃一些片段,以便你可以透过物体看到。想象一下在你的物体表面打一些小孔。
OpenGL 通过glPolygonStipple函数支持多边形 stippling。这个函数在 WebGL 中不可用。你可以尝试通过在片段着色器中使用 ESSL discard 命令丢弃一些片段来复制这个功能。
更常见的是,我们可以使用 alpha 通道信息来获得半透明物体。然而,正如我们在立方体示例中看到的,修改 alpha 值并不会自动产生透明度。
创建透明度相当于改变我们已写入帧缓冲区的片段。例如,考虑一个场景,其中有一个不透明物体前面的半透明物体(从我们的摄像机视角看)。为了正确渲染场景,我们需要能够通过半透明物体看到不透明物体。因此,远物体和近物体之间的重叠片段需要以某种方式组合,以创建透明效果。
当场景中只有一个半透明物体时,这个想法同样适用。唯一的区别是,远片段对应于物体的背面,而近片段对应于物体的正面。在这种情况下,为了产生透明效果,需要将远片段和近片段组合。
为了正确渲染透明表面,我们需要了解两个重要的 WebGL 概念:深度测试和alpha 混合。
更新渲染管线
深度测试和 alpha 混合是片段经过片段着色器处理后的两个可选阶段。如果深度测试未激活,所有片段将自动可用于 alpha 混合。如果启用深度测试,那些未通过测试的片段将被管线自动丢弃,并且将不再可用于任何其他操作。这意味着丢弃的片段将不会被渲染。这种行为类似于使用 ESSL 丢弃命令。
以下图显示了深度测试和 alpha 混合执行的顺序:

现在,让我们看看深度测试是什么以及为什么它与 alpha 混合相关。
深度测试
每个由片段着色器处理的片段都携带一个相关的深度值。尽管片段是二维的,因为它们是在屏幕上渲染的,但深度值保留了片段与摄像机(屏幕)的距离信息。深度值存储在名为深度缓冲区或z 缓冲区的特殊 WebGL 缓冲区中。z来自x和y值对应于片段的屏幕坐标,而z值测量垂直于屏幕的距离。
在片段被片段着色器计算之后,它将可用于深度测试。这仅在启用深度测试的情况下才会发生。假设gl是包含我们的 WebGL 上下文的 JavaScript 变量,我们可以通过编写以下代码来启用深度测试:
gl.enable(gl.DEPTH_TEST);
深度测试考虑了片段的深度值,并将其与已存储在深度缓冲区中的相同片段坐标的深度值进行比较。深度测试确定该片段是否被接受在渲染管线中进行进一步处理。
只有通过深度测试的片段将被处理。任何未通过深度测试的片段将被丢弃。
在正常情况下,当启用深度测试时,只有那些深度值低于深度缓冲区中相应片段的片段将被接受。
深度测试与渲染顺序是可交换的操作。这意味着无论哪个对象先被渲染,只要启用了深度测试,我们总是会得到一个一致的场景。
让我们用一个例子来说明这一点。以下图包含一个圆锥体和一个球体。使用以下代码禁用深度测试:
gl.disable(gl.DEPTH_TEST);
首先渲染球体。正如预期的那样,当渲染圆锥体时,与圆锥体重叠的圆锥体片段不会被丢弃。这是因为重叠片段之间没有深度测试:

现在,让我们启用深度测试并渲染相同的场景。首先渲染球体。由于所有与球体重叠的圆锥体片段的深度值更高(它们离摄像机更远),这些片段未能通过深度测试并被丢弃,从而创建了一个一致的场景。
深度函数
在某些应用中,我们可能希望改变深度测试的默认行为,该行为会丢弃深度值高于深度缓冲区中相应片段的片段。为此,WebGL 提供了gl.depthFunc(function)方法。
此方法只有一个参数,即要使用的function:
| 参数 | 描述 |
|---|---|
gl.NEVER |
深度测试始终失败。 |
gl.LESS |
只有深度值低于深度缓冲区中当前片段的片段将通过测试。 |
gl.LEQUAL |
深度值小于或等于深度缓冲区中相应当前片段的片段将通过测试。 |
gl.EQUAL |
只有与深度缓冲区中当前片段具有相同深度的片段将通过测试。 |
gl.NOTEQUAL |
只有那些与深度缓冲区中的片段深度值不同的片段将通过测试。 |
gl.GEQUAL |
深度值大于或等于的片段将通过测试。 |
gl.GREATER |
只有深度值更大的片段将通过测试。 |
gl.ALWAYS |
深度测试始终通过。 |
WebGL 中默认禁用深度测试。当启用时,如果没有设置深度函数,则默认选择gl.LESS函数。
Alpha 混合
只有当片段通过了深度测试时,才可用于 alpha 混合。默认情况下,深度测试是禁用的,这使得所有片段都可用于 alpha 混合。
使用以下代码行启用 alpha 混合:
gl.enable(gl.BLEND);
对于每个可用的片段,alpha 混合操作通过适当的片段坐标从帧缓冲区中读取颜色,并根据片段着色器中先前计算的颜色与帧缓冲区中的颜色之间的线性插值创建一个新的颜色。
Alpha 混合
WebGL 中默认禁用 alpha 混合。
混合函数
在启用混合之后,下一步是定义一个混合函数。这个函数将确定从对象(源)中提取的片段颜色如何与帧缓冲区中现有的片段颜色(目标)结合。
我们将源和目标颜色组合如下:
color = S * sW + D * dW;
更精确地说:
-
S: 源颜色(vec4) -
D: 目标颜色(vec4) -
sW: 源缩放因子 -
dW: 目标缩放因子 -
S.rgb: 源颜色的 RGB 分量 -
S.a: 源颜色的 alpha 分量 -
D.rgb: 目标颜色的 RGB 分量 -
D.a: 目标颜色的 alpha 分量
需要注意的是,渲染顺序将决定源片段和目标片段。参考上一节的示例,如果首先渲染球体,那么它将成为混合操作的目标,因为当锥体被渲染时,球体片段已经被存储在帧缓冲区中。换句话说,从渲染顺序的角度来看,alpha 混合是一个非交换的操作:

独立混合函数
还可以独立于 alpha 通道确定 RGB 通道如何组合。为此,我们使用gl.blendFuncSeparate函数。
我们以这种方式定义两个独立的函数:
color = S.rgb * sW.rgb + D.rgb * dW.rgb;
alpha = S.a * sW.a + D.a * dW.a;
更精确地说:
-
sW.rgb: 源 RGB 分量 -
dW.rgb: 目标 RGB 的缩放因子(仅 RGB) -
sW.a: 源 alpha 值的缩放因子 -
dW.a: 目标 alpha 值的缩放因子
然后,我们可能得到以下内容:
color = S.rgb * S.a + D.rbg * (1.0 - S.a);
alpha = S.a * 1.0 + D.a * 0.0;
这将被转换为以下代码:
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ZERO);
gl.blendFuncSeparate函数的参数与gl.blendFunc相同。你可以在本节的后面找到更多关于这些函数的信息。
混合方程
我们可能遇到不想使用缩放或加法操作来插值源和目标片段颜色的情况。例如,我们可能想要从其中一个中减去另一个。在这种情况下,WebGL 提供了gl.blendEquation函数。这个函数接收一个参数,该参数确定对缩放后的源和目标片段颜色进行的操作。例如,gl.blendEquation(gl.FUNC_ADD)的计算如下:
color = S * sW + D * dW;
并且,gl.blendEquation(gl.FUNC_SUBTRACT)对应以下内容:
color = S * sW - D * dW;
还有一个第三种选择,gl.blendEquation(gl.FUNC_REVERSE_SUBTRACT),对应以下内容:
color = D* dw - S * sW;
如预期的那样,你可以分别为 RGB 通道和 alpha 通道定义混合方程。为此,我们使用gl.blendEquationSeparate函数。
混合颜色
WebGL 提供了gl.CONSTANT_COLOR和gl.ONE_MINUS_CONSTANT_COLOR缩放因子。这些缩放因子可以与gl.blendFunc和gl.blendFuncSeparate一起使用。然而,我们首先需要建立混合颜色。我们通过调用gl.blendColor来实现这一点。
WebGL Alpha-Blending API
以下表格总结了与执行 alpha 混合操作相关的 WebGL 函数:
| WebGL 函数 | 描述 |
|---|---|
| `gl.enable | disable(gl.BLEND)` |
| gl.blendFunc(sW, dW) | 指定像素算术。sW 和 dW 的有效值如下:
-
ZERO -
ONE -
SRC_COLOR -
DST_COLOR -
SRC_ALPHA -
DST_ALPHA -
CONSTANT_COLOR -
CONSTANT_ALPHA -
ONE_MINUS_SRC_ALPHA -
ONE_MINUS_DST_ALPHA -
ONE_MINUS_SRC_COLOR -
ONE_MINUS_DST_COLOR -
ONE_MINUS_CONSTANT_COLOR -
ONE_MINUS_CONSTANT_ALPHA
此外,sW 还可以是 SRC_ALPHA_SATURATE。|
gl.blendFuncSeparate(sW_rgb, dW_rgb, sW_a, dW_a) |
分别指定 RGB 和 alpha 组件的像素算术。 |
|---|
| gl.blendEquation(mode) | 指定用于 RGB 混合方程和 alpha 混合方程的方程。mode 的有效值如下:
-
gl.FUNC_ADD -
gl.FUNC_SUBTRACT -
gl.FUNC_REVERSE_SUBTRACT
|
gl.blendEquationSeparate(modeRGB, modeAlpha) |
分别设置 RGB 混合方程和 alpha 混合方程。 |
|---|---|
gl.blendColor(red, green, blue, alpha) |
设置混合颜色。 |
| gl.getParameter(name) | 就像其他 WebGL 变量一样,可以使用 gl.getParameter 查询混合参数。相关的参数如下:
-
gl.BLEND -
gl.BLEND_COLOR -
gl.BLEND_DST_RGB -
gl.BLEND_SRC_RGB -
gl.BLEND_DST_ALPHA -
gl.BLEND_SRC_ALPHA -
gl.BLEND_EQUATION_RGB -
gl.BLEND_EQUATION_ALPHA
|
颜色混合模式
根据对 sW 和 dW 的参数选择,我们可以创建不同的混合模式。在本节中,我们将了解如何创建加法、减法、乘法和插值混合模式。所有混合模式都源自之前的公式:
color = S * (sW) + D * dW;
混合函数
加法混合简单地将源片段和目标片段的颜色相加,创建一个更亮的图像。我们通过以下方式获得加法混合:
gl.blendFunc(gl.ONE, gl.ONE);
这将源片段和目标片段的权重 sW 和 dW 分配为 1。颜色输出将如下所示:
color = S * 1.0 + D * 1.0;
color = S + D;
由于每个颜色通道都在 [0, 1] 范围内,混合将限制所有超过 1 的值。当所有通道都是 1 时,这会导致白色。
减法混合
同样,我们可以通过以下方式获得减法混合:
gl.blendEquation(gl.FUNC_SUBTRACT);
gl.blendFunc(gl.ONE, gl.ONE);
这将改变混合方程如下:
color = S * 1.0 - D * 1.0;
color = S - D;
所有负值都将设置为 0。当所有通道都是负值时,结果是黑色。
乘法混合
我们通过以下方式获得乘法混合:
gl.blendFunc(gl.DST_COLOR, gl.ZERO);
这将在混合方程中反映为以下内容:
color = S * D + D * 0.0;
color = S * D;
结果将始终是较暗的混合。
插值混合
如果我们将 sW 设置为 S.a 并将 dW 设置为 1 - S.a,那么我们得到以下内容:
color = S * S.a + D *(1 - S.a);
这将通过使用源 alpha 颜色 S.a 作为缩放因子,在源颜色和目标颜色之间创建线性插值。在代码中,这被翻译为以下内容:
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
插值混合允许我们在目标片段通过深度测试的情况下创建透明效果。正如预期的那样,这要求对象从后向前渲染。
在下一节中,我们将在一个由圆锥体和球体组成的简单场景上尝试不同的混合模式。
行动时间:混合工作台
让我们通过一个示例来展示这些不同的混合函数的实际应用:
- 在您的浏览器中打开
ch06_09_blending.html文件。您将看到一个类似于以下截图的界面:

-
此接口具有大多数允许您配置 alpha 混合的参数。默认设置是源
gl.SRC_ALPHA和目标gl.ONE_MINUS_SRC_ALPHA。这些是插值混合的参数。您需要使用哪个滑块来更改插值混合的缩放因子?为什么? -
将球体 alpha 滑块更改为
0.5。您将在球体表面看到一些类似阴影的碎片。这是因为球体的背面现在可见。要去除背面,请点击背面剔除。 -
点击重置按钮。
-
禁用 Lambert 项和地板按钮。
-
启用背面剔除按钮。
-
让我们实现乘法混合。源和目标需要有什么值?
-
点击并拖动
canvas。检查乘法混合在对象重叠处创建的暗区。 -
使用提供的下拉菜单将混合函数更改为
gl.FUNC_SUBTRACT。 -
将源更改为
gl.ONE并将目标更改为gl.ONE。 -
这是哪种混合模式?点击并拖动
canvas以检查重叠区域的显示。 -
尝试不同的参数配置。请记住,您也可以更改混合函数。如果您决定使用恒定颜色或恒定 alpha,请使用颜色小部件和相应的滑块来修改这些参数的值。
刚才发生了什么?
您已经通过一个简单的练习看到了加法、乘法、减法和插值混合模式的工作方式。
您已经看到,gl.SRC_ALPHA和gl.ONE_MINUS_SRC_ALPHA的组合产生了透明效果。
创建透明对象
我们已经了解到,为了创建透明效果,我们需要:
-
启用 alpha 混合并选择插值混合函数
-
从后向前渲染对象的表面
当没有东西可以与之混合时,我们如何创建透明对象?换句话说,如果只有一个对象,我们如何使其透明?一个解决方案是使用面剔除。
面剔除允许我们仅渲染对象的背面或正面。我们在上一节中使用了这种技术,当时我们通过启用背面剔除按钮只渲染正面。
让我们使用本章前面提到的颜色立方体。我们将使其变得透明。为了达到这个效果,我们将执行以下操作:
-
启用 alpha 混合并使用插值混合模式。
-
启用面剔除。
-
通过剔除正面来渲染背面。
-
通过剔除背面来渲染正面。
与管道中的其他选项类似,剔除默认是禁用的。我们通过调用以下代码来启用它:
gl.enable(gl.FACE_CULLING);
要仅渲染物体的背面,我们在调用 drawArrays 或 drawElements 之前,需要调用 gl.cullFace(gl.FRONT)。
同样,要仅渲染正面,我们在绘制调用之前使用 gl.cullFace(gl.BACK)。
以下图表总结了创建具有 alpha 混合和面剔除的透明物体所需的步骤:

在下一节中,我们将看到透明立方体的实际效果,并查看使其成为可能的代码。
行动时间:剔除
让我们通过一个示例来展示剔除操作:
-
在你的浏览器中打开
ch06_10_culling.html文件。 -
你会看到界面与混合工作台练习相似。然而,在上行中,你会看到这三个选项:
-
Alpha 混合:启用或禁用 alpha 混合。
-
渲染正面:如果处于活动状态,则渲染正面。
-
渲染背面:如果处于活动状态,则渲染背面。
-
-
记住,为了混合工作,物体需要从后向前渲染。因此,立方体的背面首先被渲染。这反映在
draw函数中:
if (showBackFace) {
gl.cullFace(gl.FRONT);
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
if (showFrontFace) {
gl.cullFace(gl.BACK);
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
-
返回到网页,注意插值混合函数如何产生预期的透明效果。将出现在按钮选项下的 alpha 值滑块移动以调整插值混合的缩放因子。
-
审查插值混合函数。在这种情况下,目标面是背面(先渲染的)而源面是正面。如果源 alpha 值为
1,根据该函数你会得到什么?通过将 alpha 滑块移动到零来测试结果。 -
让我们仅可视化背面。为此,禁用渲染正面按钮。使用 alpha 值滑块增加 alpha 值。你的屏幕应该看起来像这样:

-
点击并拖动
canvas上的立方体。注意每次你移动相机时背面是如何计算的。 -
再次点击渲染正面以激活它。更改混合函数以获得减法混合。
-
尝试使用本练习中提供的控件使用不同的混合配置。
发生了什么?
我们已经看到面剔除和 alpha 混合的插值模式如何帮助我们正确混合半透明物体的面。
现在,让我们看看如何在屏幕上有两个物体时实现透明度。在这种情况下,我们有一个想要使其透明的墙。在其后面是一个圆锥体。
行动时间:创建透明墙
让我们通过一个示例来展示如何使一个物体透明:
- 在浏览器中打开
ch06_11_transparency-initial.html。我们有两个完全不透明的对象:一个圆锥体在墙壁后面。点击并拖动canvas将相机移动到墙壁后面,以看到圆锥体,如下面的截图所示:

-
使用提供的滑块更改墙壁的 alpha 值。
-
如您所见,修改 alpha 值不会产生任何透明度。这是因为 alpha 混合没有被启用。让我们编辑源代码以包含 alpha 混合。在您的源代码编辑器中打开
ch06_11_transparency-initial.html文件。滚动到configure函数并找到以下行:
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
- 在它们下面,添加以下行:
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
-
将您的更改保存为
ch06_12_transparency-final.html,并在您的网络浏览器中加载此页面。 -
如预期的那样,当您使用相应的滑块修改墙壁的 alpha 值时,墙壁的透明度会发生变化。
-
记住,为了使透明度有效,对象需要从前到后进行渲染。让我们看一下源代码。在您的源代码编辑器中打开
ch06_12_transparency-final.html。 -
圆锥体是场景中最远的对象。因此,它首先被加载。您可以通过查看
load函数来验证这一点:
function load() {
scene.add(new Floor(80, 20));
scene.load('/common/models/ch6/cone.json', 'cone');
scene.load('/common/models/ch6/wall.json', 'wall', {
diffuse: [0.5, 0.5, 0.2, 1.0],
ambient: [0.2, 0.2, 0.2, 1.0]
});
}
- 它在
scene.objects列表中的索引较低。在render函数中,对象按照它们在scene.objects列表中出现的顺序进行渲染:
scene.traverse(object => {
// ...
});
-
如果我们将场景旋转,使得圆锥体靠近相机而墙壁远离,会发生什么?
-
在您的浏览器中打开
ch06_12_transparency-final.html,并旋转场景,使得圆锥体出现在墙壁前面。在圆锥体的 alpha 值降低的同时,保持墙壁的 alpha 值在1.0。 -
如您所见,混合是不一致的。这与 alpha 混合无关,因为在
ch06_12_transparency-final.html中,混合是启用的。这与渲染顺序有关。点击“墙壁优先”按钮。现在场景应该是一致的:

-
“圆锥体优先”和“墙壁优先”按钮使用了我们在
Scene类中包含的一些新函数来改变渲染顺序。这些函数是renderSooner和renderFirst。 -
总的来说,我们已经将这些功能添加到
Scene对象中,以处理渲染顺序:-
renderSooner(objectName): 将objectName对象在Scene.objects列表中向上移动一个位置。 -
renderLater(objectName): 将objectName对象在Scene.objects列表中向下移动一个位置。 -
renderFirst(objectName): 将objectName对象移动到列表的第一个位置(索引 0)。 -
renderLast(objectName): 将objectName对象移动到列表的最后一个位置。 -
renderOrder(): 列出Scene.objects列表中的对象,按它们渲染的顺序排列。这是它们在列表中存储的相同顺序。对于任何两个给定的对象,索引较低的对象将首先渲染。
-
-
您可以使用浏览器中的 JavaScript 控制台中的这些函数,并查看它们对场景的影响。
发生了什么?
我们分析了一个简单的场景,其中我们实现了 alpha 混合。之后,我们分析了渲染顺序在创建一致透明度中的重要性。最后,我们介绍了Scene对象的新方法,这些方法控制着渲染顺序。
摘要
让我们总结一下本章所学的内容:
-
我们学习了如何广泛地使用颜色,包括在对象、灯光和场景中。具体来说,我们了解到一个对象可以按顶点、片段或具有恒定颜色进行着色。
-
我们回顾了灯光和光照模型的多种方法。
-
我们介绍了如何创建不同颜色的灯光,并利用方向光和点光源的概念来创建聚光灯。通过在我们的场景中引入多个光源,我们更新了我们的建筑模式,并使用统一数组来减少在 JavaScript 和 ESSL 之间创建和映射统一变量的复杂性。
-
我们了解到,适当的半透明度不仅仅需要在我们颜色向量中使用 alpha 值。正因为如此,我们探索了各种混合行为、渲染序列和 WebGL 函数来创建半透明对象。
-
我们学习了面剔除如何帮助在场景中存在多个半透明对象时产生更好的结果。
在下一章中,我们将学习如何利用纹理来帮助我们渲染场景中的图像。
第七章:纹理
在上一章中,我们介绍了颜色、多个光源以及关于深度和 alpha 测试的各种混合技术的重要概念。到目前为止,我们已经通过几何、顶点颜色和光照为场景添加了细节;但通常,这还不足以达到我们想要的结果。如果我们能够在不需要额外几何的情况下“绘制”额外的细节到场景中,那岂不是很好?我们可以做到!这需要我们使用一种称为纹理映射的技术。在本章中,我们将探讨如何使用纹理使场景更加详细。
在本章中,你将完成以下任务:
-
学习如何创建纹理。
-
学习如何在渲染时使用纹理。
-
学习关于过滤和包裹模式以及它们如何影响纹理的使用。
-
学习如何使用多纹理。
-
学习关于立方贴图。
什么是纹理映射?
纹理映射简单来说是一种在渲染几何体时通过在表面上显示图像来添加细节的方法。考虑以下截图:

仅使用我们迄今为止学到的技术,构建这样一个相对简单的场景将会非常困难。仅 WebGL 标志就需要仔细由许多三角形原语构建。虽然这是一个可能的方法,但对于稍微复杂一些的场景,额外的几何构造将是不切实际的。
幸运的是,纹理映射使这样的要求变得极其简单。所需的一切只是一个适当文件格式的图像,一个额外的网格顶点属性,以及对我们着色器代码的一些修改。
创建和上传纹理
与传统的原生 OpenGL 应用程序不同,浏览器以“颠倒”的方式加载纹理。因此,许多 WebGL 应用程序将纹理设置为使用 Y 坐标翻转加载。这可以通过一个单独的调用完成:
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
反转纹理
纹理可以手动翻转,也可以通过 WebGL 翻转。我们将使用 WebGL 逐行编程地翻转它们。
创建纹理的过程类似于创建顶点或索引缓冲区。我们首先创建纹理对象,如下所示:
const texture = gl.createTexture();
纹理,就像缓冲区一样,在我们可以操作它们之前必须绑定:
gl.bindTexture(gl.TEXTURE_2D, texture);
第一个参数表示我们正在绑定的纹理类型,或纹理目标。现在,我们将专注于 2D 纹理,用 gl.TEXTURE_2D 表示。在本章的后面部分将介绍更多目标。
一旦我们绑定了纹理,我们就可以提供图像数据。最简单的方法是将 DOM 图像传递给 texImage2D 函数,如下面的代码片段所示:
const image = document.getElementById('texture-image');
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
在前面的代码片段中,我们使用 ID 为 texture-image 的元素从我们的页面中选择了一个图像元素作为源纹理。这是上传纹理,因为图像将被存储在 GPU 的内存中,以便在渲染期间快速访问。源可以是任何可以在网页上显示的图像格式,例如 JPEG、PNG、GIF 和 BMP 文件。
纹理的图像源作为texImage2D调用的最后一个参数传入。当texImage2D与图像一起调用时,WebGL 将自动确定提供的纹理的尺寸。其余的参数指导 WebGL 有关图像包含的信息类型以及如何存储它。大多数情况下,您只需要担心更改第三个和第四个参数,这些参数也可以是gl.RGB,表示您的纹理没有 alpha(透明度)通道。
除了图像之外,我们还需要指导 WebGL 在渲染时如何过滤纹理。我们将在稍后讨论过滤的含义以及不同的过滤模式做什么。同时,让我们使用最简单的一个来开始:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
就像缓冲区一样,当您完成使用纹理后,解绑纹理是一个好的实践。您可以通过绑定null作为活动纹理来完成此操作:
gl.bindTexture(gl.TEXTURE_2D, null);
当然,在许多情况下,您可能不希望将场景中的所有纹理都嵌入到您的网页中,因此通常更方便在 JavaScript 中创建元素并加载它,而不将其添加到文档中。将这些放在一起,我们得到一个简单的函数,可以加载我们提供的任何图像 URL 作为纹理:
const texture = gl.createTexture();
const image = new Image();
image.src = 'texture-file.png';
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,
image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.bindTexture(gl.TEXTURE_2D, null);
};
异步加载
以这种方式加载图像时,有一个小问题。图像加载是异步的,这意味着您的程序不会停止等待图像加载完成后再继续执行。那么,如果在图像数据尚未填充之前尝试使用纹理会发生什么?您的场景仍然会渲染,但您采样到的任何纹理值都将为黑色。
简而言之,创建纹理遵循与使用缓冲区相同的模式。对于每个我们创建的纹理,我们希望执行以下操作:
-
创建一个新的纹理
-
绑定它以使其成为当前纹理
-
传递纹理内容,通常是来自图像
-
设置过滤器模式或其他纹理参数
-
解绑纹理
如果我们达到不再需要纹理的点,我们可以通过使用deleteTexture来删除它并释放相关的内存:
gl.deleteTexture(texture);
在此之后,纹理就不再有效。任何尝试使用它的操作都将像传递了null一样响应。
使用纹理坐标
在我们将纹理应用到表面之前,我们需要确定纹理的哪一部分映射到表面的哪一部分。我们通过另一个称为纹理坐标的顶点属性来完成这项工作。
纹理坐标是描述与该顶点相对应的纹理位置的二维浮点向量。您可能会认为这个向量应该是图像上的实际像素位置;相反,WebGL 将所有纹理坐标强制转换为0到1的范围,其中(0, 0)代表纹理的左上角,(1, 1)代表纹理的右下角,如下面的图像所示:

这意味着,为了将顶点映射到任何纹理的中心,你需要给它一个纹理坐标 (0.5, 0.5)。这个坐标系对矩形纹理同样适用。
这一开始可能看起来有些奇怪;毕竟,确定特定点的像素坐标比确定图像高度和宽度中点的百分比要容易。话虽如此,WebGL 使用的坐标系确实有其好处。
例如,我们可以构建一个由高分辨率纹理组成的 WebGL 应用程序。然后,在某个后续时刻,我们可能会收到反馈,指出纹理加载时间过长或应用程序导致设备渲染缓慢。因此,我们可能会决定为这些情况提供较低分辨率的纹理选项。
如果你的纹理坐标是以像素为单位的,你现在必须修改应用程序中使用的每个网格,以确保纹理坐标正确地匹配到新的、较小的纹理。然而,当使用 WebGL 的归一化 0 到 1 坐标范围时,较小的纹理可以使用与较大纹理完全相同的坐标,并且仍然可以正确显示。
确定网格的纹理坐标通常是创建 3D 资源的一个棘手部分,尤其是对于复杂的网格。
多边形网格
多边形网格是一个顶点、边和面的集合,它定义了 3D 计算机图形和实体建模中多面体对象的形状。
幸运的是,大多数 3D 建模工具都配备了出色的纹理布局和纹理坐标生成工具——这个过程被称为展开。
纹理坐标
正如顶点位置组件通常用 (x, y, z) 表示一样,纹理坐标也有一个常见的符号表示。不幸的是,这种表示在所有 3D 软件应用中并不一致。OpenGL 和 WebGL 分别将这些坐标称为 s 和 t,分别对应 x 和 y 组件。然而,DirectX 和许多流行的建模软件包将它们称为 u 和 v。因此,你经常会看到人们将纹理坐标称为“UVs”,将展开称为“UV 映射”。
为了与 WebGL 的使用保持一致,我们将在这本书的剩余部分使用 st。
在着色器中使用纹理
纹理坐标以与任何其他顶点属性相同的方式暴露给着色器代码。我们希望在顶点着色器中包含一个两个元素的向量属性,它将映射到我们的纹理坐标:
in vec2 aVertexTextureCoords;
此外,我们还想在片段着色器中添加一个新的统一变量,使用我们之前未见过的类型:sampler2D。sampler2D 统一变量允许我们在着色器中访问纹理数据:
uniform sampler2D uSampler;
在过去,当我们使用统一变量时,我们会将其设置为在着色器中想要它们具有的值,例如光颜色。采样器的工作方式略有不同。以下代码显示了如何将纹理与特定的采样器统一变量关联起来:
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(program.uSampler, 0);
那么,这里发生了什么?首先,我们使用gl.activeTexture更改活动纹理索引。WebGL 支持同时使用多个纹理(我们将在本章后面讨论),因此指定我们正在使用的纹理索引是一个好习惯,尽管在本程序期间它不会改变。接下来,我们绑定我们希望使用的纹理,将其与当前活动纹理TEXTURE0关联。最后,我们通过gl.uniform1i提供的纹理单元告诉采样器统一变量它应该与哪个纹理关联。在这里,我们给出0以指示采样器应使用TEXTURE0。
我们现在可以使用我们的纹理在片段着色器中了!使用纹理的最简单方法是将它的值作为片段颜色返回,如下所示:
texture(uSampler, vTextureCoord);
texture函数接受我们希望查询的采样器统一变量和查找的坐标,并返回在那些坐标处的纹理图像颜色作为vec4。如果图像没有 alpha 通道,vec4仍然会被返回,其 alpha 组件始终设置为1。
动手实践:给立方体贴图
让我们来看一个例子,我们将纹理映射添加到一个立方体上:
- 在您的编辑器中打开
ch07_01_textured-cube.html文件。如果在浏览器中打开,您应该会看到一个类似于以下截图的场景:

- 让我们加载纹理图像。在脚本块的顶部,添加一个新的变量来保存纹理:
let texture;
- 在
configure函数的底部,添加以下代码,它创建纹理对象,加载图像,并将图像设置为纹理数据。在这种情况下,我们将使用带有 WebGL 标志的 PNG 图像作为我们的纹理:
texture = gl.createTexture();
const image = new Image();
image.src = '/common/images/webgl.png';
image.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER,
gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
gl.NEAREST);
gl.bindTexture(gl.TEXTURE_2D, null);
};
- 在
render函数中的vertexColors绑定块之后,添加以下代码以将纹理绑定到着色器采样器统一变量:
if (object.textureCoords) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(program.uSampler, 0);
}
- 现在我们需要将纹理特定的代码添加到着色器中。在顶点着色器中,将以下属性和变量添加到变量声明中:
in vec2 aVertexTextureCoords;
out vec2 vTextureCoords;
- 在顶点着色器的
main函数的末尾,确保将纹理坐标属性复制到变量中,以便片段着色器可以访问它:
vTextureCoords = aVertexTextureCoords;
- 片段着色器还需要两个新的变量声明——采样器统一变量和来自顶点着色器的变量:
uniform sampler2D uSampler;
in vec2 vTextureCoords;
-
我们还必须记住在
configure函数中向attributes列表添加aVertexTextureCoords并将uSampler添加到uniforms列表中,以便可以从我们的 JavaScript 绑定代码访问新变量。 -
要访问纹理颜色,我们调用
texture函数,传入采样器和纹理坐标。由于我们希望纹理表面保留光照,我们将光照颜色和纹理颜色相乘,得到以下行来计算片段颜色:
fragColor = vColor * texture(uSampler, vTextureCoords);
- 现在在浏览器中打开文件,您应该会看到这样的场景:

- 如果你遇到某个步骤有困难并且需要参考,完整的代码可以在
ch07_02_textured-cube-final.html中找到。
发生了什么?
我们刚刚从文件中加载了一个纹理,将其上传到 GPU,并在立方体几何体上渲染它,并将其与已经计算出的光照信息混合。
本章剩余的示例将为了简洁和清晰省略光照计算,但如果需要,可以应用到所有这些示例中。
尝试不同的纹理
尝试使用你自己的图片,看看你是否能将其显示为纹理。如果你提供一个矩形图像而不是正方形图像会发生什么?
纹理过滤模式
到目前为止,我们已经看到了如何在片段着色器中使用纹理来采样图像数据,但我们只在一个有限的上下文中使用了它们。当你开始更仔细地研究纹理时,会出现一些有趣的问题。
例如,如果你从上一个演示中放大立方体,你会看到纹理开始出现走样:

当我们放大时,可以看到 WebGL 标志周围出现了锯齿边缘。当纹理在屏幕上非常小的时候,类似的问题也会变得明显。将这些瑕疵孤立于单个对象中,它们很容易被忽视,但在复杂场景中可能会非常分散注意力。我们最初为什么能看到这些瑕疵?
从上一章,你应该记得顶点颜色是如何插值,以便片段着色器提供平滑的颜色渐变。纹理坐标以完全相同的方式进行插值,结果坐标被提供给片段着色器,并用于从纹理中采样颜色值。在理想情况下,纹理会在屏幕上以 1:1 的比例显示,这意味着纹理的每个像素(称为 纹理元素)将占据屏幕上的一个像素。在这种情况下,将不会有任何瑕疵:

像素与纹理元素有时,纹理中的像素被称为 纹理元素。像素是图像元素的简称。纹理元素是纹理元素的简称。
然而,3D 应用程序的现实情况是,纹理几乎从不以它们的原始分辨率显示。我们根据纹理的分辨率是否低于或高于它占据的屏幕空间将其称为 放大 和 缩小:

当纹理被放大或缩小的时候,可能会对纹理采样器应该返回什么颜色存在一些模糊性。例如,考虑以下样本点与略微放大的纹理的示意图:

显然,你想要左上角或中间的样本点返回什么颜色,但中间的这些 texel 呢?它们应该返回什么颜色?答案取决于你的过滤器模式。纹理过滤允许我们控制纹理的采样方式,并达到我们想要的外观。
设置纹理的过滤器模式非常直接,当我们讨论创建纹理时已经看到了一个例子:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
与大多数 WebGL 调用一样,texParameteri 操作于当前绑定的纹理,并且必须为每个创建的纹理设置。这也意味着不同的纹理可以有不同的过滤器,这在尝试实现特定效果时可能很有用。
在这个例子中,我们将放大过滤器(TEXTURE_MAG_FILTER)和缩小过滤器(TEXTURE_MIN_FILTER)都设置为 NEAREST。对于第三个参数可以传递几种模式,了解它们对场景产生的视觉影响最好的方式是看到各种过滤器模式的效果。
当我们讨论不同的参数时,让我们在你的浏览器中查看过滤器的一个演示。
行动时间:尝试不同的过滤器模式
让我们来看一个例子,看看不同的过滤器模式是如何工作的:
- 使用你的浏览器打开
ch07_03_texture-filters.html文件:

-
控制包括一个滑块来调整盒子与观察者之间的距离,而按钮则修改放大和缩小过滤器。
-
尝试不同的模式来观察它们对纹理产生的影响。放大过滤器在立方体的纹理渲染大于其源图像大小时生效;缩小过滤器当它更远时生效。务必旋转立方体,以观察以每个模式从角度观看纹理时的样子。
发生了什么?
我们学习了如何创建和加载纹理到我们的 3D 场景中。我们还介绍了将纹理映射到对象上的各种技术,以及一个交互式示例来展示这些功能。
让我们深入探讨每种过滤器模式,并讨论它们是如何工作的。
NEAREST
使用 NEAREST 过滤器的纹理始终返回最近的样本点的 texel 的颜色。使用此模式,纹理在近距离观看时看起来会显得块状和像素化,这可以用于创建“复古”图形。NEAREST 可以用于 MIN 和 MAG 过滤器:

LINEAR
LINEAR 过滤返回与采样点最近的四个像素的加权平均值。当近距离观察纹理时,这提供了平滑的纹理元素颜色混合——这通常是更期望的效果。这也意味着图形硬件必须读取每个片段的四倍像素,因此,它比 NEAREST 慢,但现代图形硬件如此之快,这几乎从未成为问题。LINEAR 可以用于 MIN 和 MAG 过滤器。这种过滤模式也称为 双线性过滤:

回到我们在本章前面展示的近距离示例图像,如果我们使用了 LINEAR 过滤,它看起来会是这样:

Mipmapping
在我们讨论仅适用于 TEXTURE_MIN_FILTER 的剩余过滤模式之前,我们需要介绍一个新概念:Mipmapping。
当采样缩小后的纹理时会出现问题。在使用 LINEAR 过滤且采样点相距甚远的情况下,我们可能会完全错过纹理的一些细节。随着视角的变化,我们错过的纹理碎片也会变化,这会导致闪烁效果。你可以在演示中将 MIN 过滤设置为 NEAREST 或 LINEAR,然后缩小并旋转立方体来观察这一效果:

为了避免这种情况,显卡可以利用 Mipmap 链。
Mipmaps 是纹理的缩小副本,每个副本的大小正好是前一个副本的一半。如果你将一个纹理及其所有 Mipmaps 按顺序展示,它看起来会是这样:

优点是,在渲染时,图形硬件可以选择与屏幕上纹理大小最接近的纹理副本,并从中采样。这减少了跳过的纹理元素数量以及伴随它们的抖动伪影。然而,只有在使用适当的纹理过滤器时才会使用 Mipmapping。
NEAREST_MIPMAP_NEAREST
此过滤器将选择与屏幕上纹理大小最接近的 Mipmap,并使用 NEAREST 算法从中采样。
LINEAR_MIPMAP_NEAREST
此过滤器选择与屏幕上纹理大小最接近的 Mipmap,并使用 LINEAR 算法从中采样。
NEAREST_MIPMAP_LINEAR
此过滤器选择两个与屏幕上纹理大小最接近的 Mipmap,并使用 NEAREST 算法从这两个 Mipmap 中采样。返回的颜色是这两个样本的加权平均值。
LINEAR_MIPMAP_LINEAR
此过滤器选择两个与屏幕上纹理大小最接近的 Mipmap,并使用 LINEAR 算法从这两个 Mipmap 中采样。返回的颜色是这两个样本的加权平均值。这种模式也称为 三线性过滤:

在*_MIPMAP_*过滤模式中,NEAREST_MIPMAP_NEAREST是最快且质量最低的,而LINEAR_MIPMAP_LINEAR将提供最佳质量但性能最低。其他两种模式在质量/速度尺度上介于两者之间。在大多数情况下,性能权衡将足够小,以至于通常使用LINEAR_MIPMAP_LINEAR。
生成米普图
WebGL 不会自动为每个纹理创建米普图;因此,如果我们想使用*_MIPMAP_*过滤模式之一,我们必须首先为纹理创建米普图。幸运的是,所有这些只需要一个函数调用:
gl.generateMipmap(gl.TEXTURE_2D);
generateMipmap必须在用texImage2D填充纹理之后调用,并将自动为图像创建完整的米普图链。
或者,如果你想手动提供米普图,你可以在调用texImage2D时指定你提供的是米普图级别而不是源纹理,通过传递除0以外的数字作为第二个参数:
gl.texImage2D(gl.TEXTURE_2D, 1, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mipmapImage);
在这里,我们正在手动创建第一个米普图级别,其高度和宽度是正常纹理的一半。第二个级别将是正常纹理尺寸的四分之一,以此类推。
这对于一些高级效果或使用不能与generateMipmap一起使用的压缩纹理时可能很有用。
如果你熟悉 WebGL 1,你会记得它的限制,即维度不是 2 的幂(不是 1、2、4、8、16、32、64、128、256、512等等)的纹理不能使用米普图,也不能重复。在 WebGL 2 中,这些限制已经不存在了。
非二进制幂(NPOT)
为了在 WebGL 1 中使用米普图,米普图需要满足一些维度限制。具体来说,纹理的宽度和高度都必须是2 的幂(POT)。也就是说,宽度和高度可以是pow(2, n)像素,其中n是任何整数。例如,16px、32px、64px、128px、256px、512px、1024px等等。此外,只要两者都是 2 的幂,宽度和高度不必相同。例如,一个512x128的纹理仍然可以进行米普图处理。NPOT 纹理仍然可以与 WebGL 1 一起使用,但仅限于使用NEAREST和LINEAR过滤器。
那么,为什么对两种纹理进行功率限制呢?回想一下,米普链是由尺寸是前一个级别一半的纹理组成的。当维度是 2 的幂时,这总会产生整数,这意味着像素数永远不需要四舍五入,因此产生干净且快速的缩放算法。
从此点之后的所有纹理代码示例中,我们将使用一个简单的纹理类,该类可以干净地封装纹理的下载、创建和设置。使用该类创建的任何纹理都将自动为其生成米普图,并设置为使用LINEAR进行放大过滤器,以及使用LINEAR_MIPMAP_LINEAR进行缩小过滤器。
纹理包裹
在前面的部分中,我们使用了 texParameteri 来设置纹理的过滤器模式,但正如您从通用函数名称中预期的那样,这并不是它能做的全部。我们可以操纵的另一种纹理行为是纹理包裹模式。
纹理包裹描述了当纹理坐标超出 0 和 1 范围时采样器的行为。
包裹模式可以独立地为 S 和 T 坐标设置,因此更改包裹模式通常需要两次调用:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
在这里,我们将当前绑定的纹理的 S 和 T 包裹模式都设置为 CLAMP_TO_EDGE,其效果我们将在下面看到。
与纹理过滤器一样,通过一个例子演示不同包裹模式的效果,然后讨论结果是最容易的。请再次打开您的浏览器进行另一个演示。
行动时间:尝试不同的包裹模式
让我们通过一个例子来看看不同的包裹模式是如何起作用的:
- 使用您的浏览器打开
ch07_04_texture-wrapping.html文件:

-
在前面的屏幕截图中显示的立方体具有从
-1到2的纹理坐标,这迫使纹理包裹模式应用于纹理的中心瓷砖以外的所有内容。 -
尝试调整控件以查看不同的包裹模式对纹理的影响。
发生了什么?
我们尝试了各种纹理插值和米普映射技术的方法,以及展示这些功能的交互式示例。
现在,让我们调查每种包裹模式,并讨论它们是如何工作的。
CLAMP_TO_EDGE
这种包裹模式将任何大于 1 的纹理坐标向下舍入到 1;任何小于 0 的坐标向上舍入到 0,将值“夹”在 0-1 范围内。从视觉上看,这会在坐标超出 0-1 范围后无限期地重复纹理的边界像素。请注意,这是唯一与非幂次方纹理兼容的包裹模式:

REPEAT
这是默认的包裹模式,也是您可能最常使用的模式。用数学术语来说,这种包裹模式简单地忽略了纹理坐标的整数部分。这会产生纹理在您移动到 0-1 范围之外时重复的视觉效果。这对于显示具有自然重复图案的表面非常有用,例如瓷砖地板或砖墙:

MIRRORED_REPEAT
这种模式的算法稍微复杂一些。如果坐标的整数部分是偶数,纹理坐标将与使用 REPEAT 时的相同。如果坐标的整数部分是奇数,则结果坐标是坐标的分数部分的 1 减去。这导致纹理在重复时“翻转”,每隔一次重复都是镜像图像:

如我们之前提到的,这些模式可以混合使用。例如,考虑以下代码片段:
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
这将对样本纹理产生以下效果:

Samplers 与 Textures 的比较
惊奇地想知道为什么着色器统一变量被称为 samplers 而不是 textures 吗?纹理只是存储在 GPU 上的图像数据,而 sampler 包含了查找纹理信息所需的所有信息,包括过滤器和包裹模式。
使用多个纹理
到目前为止,我们一直通过使用单个纹理来进行所有渲染。然而,有时我们可能希望多个纹理共同作用于一个片段以创建更复杂的效果。在这种情况下,我们可以使用 WebGL 在单个绘制调用中访问多个纹理的能力,这通常被称为 多纹理。
我们之前简要介绍了多纹理,现在让我们再次回顾一下。当我们谈论将纹理作为 sampler 统一变量暴露给着色器时,我们使用了以下代码:
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
第一行,gl.activeTexture,是利用多纹理的关键。我们使用它来告诉 WebGL 状态机在后续的纹理函数中将使用哪个纹理。在这种情况下,我们传递了 gl.TEXTURE0,这意味着任何后续的纹理调用(如 gl.bindTexture)都将改变第一个纹理单元的状态。如果我们想将不同的纹理附加到第二个纹理单元,我们将使用 gl.TEXTURE1。
不同设备将支持不同数量的纹理单元,但 WebGL 规定兼容的硬件必须始终支持至少两个纹理单元。我们可以使用以下函数调用来找出当前设备支持多少个纹理单元:
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS);
WebGL 为 gl.TEXTURE0 到 gl.TEXTURE31 提供了显式的枚举。可能更方便的是以程序方式指定纹理单元或需要引用 31 以上的纹理单元。在这种情况下,您始终可以用 gl.TEXTURE0 + i 替换 gl.TEXTUREi,如下例所示:
gl.TEXTURE0 + 2 === gl.TEXTURE2;
在着色器中访问多个纹理就像声明多个 sampler 一样简单:
uniform sampler2D uSampler;
uniform sampler2D uOtherSampler;
在设置绘制调用时,通过提供纹理单元到 gl.uniform1i 来告诉着色器哪个纹理与哪个 sampler 相关联。将两个纹理绑定到上面 sampler 的代码可能看起来像这样:
// bind the first texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(program.uSampler, 0);
// bind the second texture
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, otherTexture);
gl.uniform1i(program.uOtherSampler, 1);
现在我们有两个纹理可供我们的片段着色器使用,但我们想对它们做什么?
例如,我们将实现一个简单的多纹理效果,将另一个纹理叠加到简单的纹理立方体上,以模拟静态光照。
使用多纹理的时间:使用多纹理
让我们来看一个多纹理应用的例子:
-
使用你的编辑器打开
ch07_05_multi-texture.html文件。 -
在脚本块的顶部添加另一个纹理变量:
let texture2;
- 在
configure函数的底部,添加加载第二个纹理的代码。我们使用一个类来简化这个过程,所以新的代码如下:
texture2 = new Texture();
texture2.setImage('/common/images/light.png');
- 我们使用的纹理是一个模拟聚光灯的白色径向渐变纹理:

- 在
render函数中,在绑定第一个纹理的代码下方,添加以下代码以将新纹理暴露给着色器:
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texture2.tex);
gl.uniform1i(program.uSampler2, 1);
- 我们需要在片段着色器中添加新的采样器统一变量:
uniform sampler2D uSampler2;
-
不要忘记在
configure函数的统一变量列表中添加相应的字符串。 -
我们添加了采样新纹理值并将其与第一个纹理混合的代码。由于我们希望第二个纹理模拟光,所以我们像在第一个纹理示例中的每个顶点光照一样将两个值相乘:
fragColor = texture(uSampler2, vTextureCoords) * texture(uSampler, vTextureCoords);
-
注意我们正在重用相同的纹理坐标来处理两个纹理。这更方便,但如果需要,可以提供第二个纹理坐标属性,或者我们可以从顶点位置或其他标准计算一个新的纹理坐标。
-
当你在浏览器中打开文件时,你应该看到如下场景:

- 你可以在
ch07_06_multi-texture-final.html中看到完成的示例。
刚才发生了什么?
我们在render调用中添加了第二个纹理,并将其与第一个纹理混合,以创建一个新的效果,在这种情况下,模拟了一个简单的静态聚光灯。
重要的是要意识到从纹理中采样的颜色被当作着色器中的任何其他颜色一样处理——也就是说,作为一个通用的四维向量。因此,我们可以像结合顶点和光照颜色,或者任何其他颜色操作一样结合纹理。
尝试使用乘法之外的混合
乘法是在着色器中混合颜色最常见的方式之一,但实际上你结合颜色值的方式并没有限制。尝试在片段着色器中实验不同的算法,看看它对输出的影响。当你用加法代替乘法时会发生什么?如果你使用一个纹理的红色通道,而另一个纹理的蓝色和绿色呢?尝试以下算法并看看结果:
fragColor = vec4(texture(uSampler2, vTextureCoords).rgb - texture(uSampler, vTextureCoords).rgb, 1.0);
结果如下:

尝试使用多维纹理
正如您可能已经注意到的,维护多个纹理的挑战与我们在第六章,“颜色、深度测试和 Alpha 混合”中管理多个灯光所面临的挑战相似。话虽如此,WebGL 是否提供了与统一数组类似的功能来管理多个纹理?是的,当然!我们可以利用 WebGL 2 提供的两种不同的解决方案来管理多维纹理:3D 纹理和纹理数组。
尽管我们将在第十一章,“WebGL 2 亮点”中讨论这些功能,但考虑这些功能如何有助于减少复杂性、提高代码可维护性以及增加可使用的纹理数量可能是有用的。
立方图
在本章前面,我们提到了 2D 纹理和立方图,用于使用图像创建复杂效果。我们讨论了纹理,但立方图究竟是什么,我们如何使用它们?
立方图,正如其名,是一个纹理的立方体。创建了六个单独的纹理,每个纹理分配给立方体的一个不同面。图形硬件可以通过使用 3D 纹理坐标将它们作为一个单一实体进行采样。
立方体的面通过它们面对的轴以及它们位于该轴的正面还是负面来识别:

到目前为止,我们通过指定TEXTURE_2D纹理目标来操作纹理。立方图引入了一些新的纹理目标,这些目标表明我们正在处理立方图。这些目标还表明我们正在操作立方图的哪个面:
-
TEXTURE_CUBE_MAP -
TEXTURE_CUBE_MAP_POSITIVE_X -
TEXTURE_CUBE_MAP_NEGATIVE_X -
TEXTURE_CUBE_MAP_POSITIVE_Y -
TEXTURE_CUBE_MAP_NEGATIVE_Y -
TEXTURE_CUBE_MAP_POSITIVE_Z -
TEXTURE_CUBE_MAP_NEGATIVE_Z
这些目标共同被称为gl.TEXTURE_CUBE_MAP_*目标。您需要使用哪个取决于您调用的函数。
立方图就像普通纹理一样创建,但绑定和属性操作使用TEXTURE_CUBE_MAP目标,如下所示:
const cubeTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeTexture);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
当上传纹理的图像数据时,您需要指定您正在操作的侧面,如下所示:
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, positiveXImage);
gl.texImage2D(gl.TEXTURE_CUBE_MAP_NEGATIVE_X, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, negativeXImage);
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_Y, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, positiveYImage);
// ...
将立方图纹理暴露给着色器的方式与普通纹理相同,只是使用立方图目标:
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeTexture);
gl.uniform1i(program.uCubeSampler, 0);
然而,着色器内的统一类型是针对立方图的特定类型:
uniform samplerCube uCubeSampler;
从立方图中采样时,您也使用一个针对立方图特定的函数:
texture(uCubeSampler, vCubeTextureCoords);
您提供的 3D 坐标被图形硬件归一化成一个单位向量,该向量指定了从“立方体”中心的方向。沿着该向量绘制一条射线,它与立方体面的交点就是纹理被采样的位置:

行动时间:尝试使用立方图
让我们通过一个例子来看看立方图的实际应用:
-
在你的浏览器中打开
ch07_07_cubemap.html文件。这又包含了一个简单的纹理立方体示例,我们将在其上构建立方体贴图示例。我们想使用立方体贴图来创建一个看起来像反射的表面。 -
创建立方体贴图比我们之前加载的纹理要复杂一些,所以这次我们将使用一个函数来简化单个立方体贴面的异步加载。这个函数叫做
loadCubemapFace,并且已经添加到了文件中。在configure函数的底部,添加以下代码,用于创建和加载立方体贴图面:
cubeTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeTexture);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_POSITIVE_X, cubeTexture, '/common/images/cubemap/positive_x.png');
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_NEGATIVE_X, cubeTexture, '/common/images/cubemap/negative_x.png');
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_POSITIVE_Y, cubeTexture, '/common/images/cubemap/positive_y.png');
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, cubeTexture, '/common/images/cubemap/negative_y.png');
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_POSITIVE_Z, cubeTexture, '/common/images/cubemap/positive_z.png');
loadCubemapFace(gl, gl.TEXTURE_CUBE_MAP_NEGATIVE_Z, cubeTexture, '/common/images/cubemap/negative_z.png');
- 在
render函数中,添加代码将立方体贴图绑定到适当的采样器:
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeTexture);
gl.uniform1i(program.uCubeSampler, 1);
- 现在转向着色器,我们想在顶点着色器中添加一个新的变量:
out vec3 vVertexNormal;
- 我们将使用顶点法线而不是专门的纹理坐标来进行立方体贴图采样,这将给我们带来我们想要的镜像效果。不幸的是,立方体每个面上的实际法线都直接指向外。如果我们使用它们,我们只会从立方体贴图中得到每个面的单色。在这种情况下,我们可以“作弊”并使用顶点位置作为法线(对于大多数模型,使用法线是合适的):
vVertexNormal = (uNormalMatrix * vec4(-aVertexPosition, 1.0)).xyz;
- 我们需要在片段着色器中定义以下变量:
in vec3 vVertexNormal;
- 我们还需要在片段着色器中添加新的采样器统一变量。确保在
configure函数中的uniforms列表中也包含这个变量:
uniform samplerCube uCubeSampler;
- 然后,在片段着色器的
main函数中,添加代码来实际采样立方体贴图并将其与基本纹理混合:
fragColor = texture(uSampler, vTextureCoords) * texture(uCubeSampler, vVertexNormal);
- 现在我们应该能够在浏览器中重新加载文件并看到以下截图所示的场景:

- 完成的示例可以在
ch07_08_cubemap-final.html中找到。
发生了什么?
当你旋转立方体时,你会注意到立方体贴图上显示的场景并没有随之旋转,从而在立方体贴图面上产生了一个“镜像”效果。这是因为在分配vVertexNormal变量时,法线与法线矩阵相乘,将法线放置在世界空间中。
使用立方体贴图进行反射表面是一个常见的技巧,但立方体贴图不仅仅有这个用途。其他常见的用途包括 Skybox 和高级光照模型。
SkyboxA 立方体贴图是一种用于创建背景的方法,使计算机和视频游戏关卡看起来比实际更大。当使用立方体贴图时,关卡被一个长方体包围。天空、远处的山脉、远处的建筑和其他不可达的对象被投影到立方体的面上(使用称为立方体贴图的技术),从而产生遥远的三维环境错觉。Skydome 采用相同的概念,但使用球体或半球体而不是立方体。更多信息,请查看以下链接:en.wikipedia.org/wiki/Skybox_(video_games)。
尝试:闪亮的标志
在这个例子中,我们创建了一个反射的“镜像”立方体。但如果我们只想让标志具有反射效果呢?我们如何将立方体贴图限制只显示在纹理的红色部分?
概述
让我们总结一下本章所学的内容:
-
如何使用纹理为我们的场景添加新的细节层次。
-
如何创建和管理纹理对象,以及如何将 HTML 图像用作纹理。
-
我们涵盖了纹理坐标和用于各种渲染技术的 mipmap 能力。
-
我们检查了各种过滤模式以及它们如何影响纹理的外观和使用,以及可用的纹理包裹模式以及它们如何改变纹理坐标的解释方式。
-
我们学习了如何在单个绘制调用中使用多个纹理,以及如何在着色器中将它们组合。
-
我们学习了如何创建和渲染立方体贴图,并看到了它们如何用于模拟反射表面。
在下一章中,我们将通过使用一种称为拾取的巧妙技术来查看如何选择和交互我们的 WebGL 场景中的对象。
第八章:拾取
在上一章中,我们介绍了如何使用纹理来为我们的 3D 应用程序添加更多细节。在本章中,我们将学习如何通过一种称为拾取的技术与我们的 WebGL 应用程序进行交互。拾取指的是在 3D 场景中选择对象的能力。最常用的拾取设备是计算机鼠标。然而,拾取也可以使用其他人机界面进行,例如触觉屏幕和触觉设备。在本章中,我们将学习如何在 WebGL 中实现拾取。
在本章中,您将:
-
学习如何使用鼠标在 WebGL 场景中选择对象。
-
创建和使用离屏帧缓冲区。
-
学习渲染缓冲区是什么以及它们是如何被帧缓冲区使用的。
-
从帧缓冲区读取像素。
-
使用颜色标签根据颜色进行对象选择。
拾取
几乎任何 3D 计算机图形应用程序都需要提供用户与场景交互的机制。例如,在游戏中,您可能想要指向您的目标并对其执行操作。或者在 CAD 系统中,您可能想要能够选择场景中的对象并修改其属性。在本章中,我们将学习在 WebGL 中实现这些类型交互的基础知识。
首先,我们应该指出,我们可以通过从摄像机位置(也称为眼睛位置)向场景投射一条光线(向量)来选择对象,并计算沿其路径的对象。这被称为光线投射,涉及到检测场景中光线与对象表面的交点。
光线投射
光线投射是使用光线-表面交点测试来解决计算机图形和计算几何中各种问题的方法。这个术语最早在 1982 年由 Scott Roth 在计算机图形学中使用,用来描述渲染构造实体几何模型的方法。如果您想了解更多信息,请查看en.wikipedia.org/wiki/Ray_casting。
话虽如此,在本章中,我们将基于离屏帧缓冲区中的对象颜色实现拾取,因为这是一种更简单、更基础的技术,有助于您了解如何在 3D 应用程序中与对象交互。如果您对光线投射感兴趣,您将在第十章“高级技术”中找到一个专门介绍这一技术的部分。
拾取背后的基本思想是为场景中的每个对象分配一个不同的标识符,并将场景渲染到离屏帧缓冲区中。我们将首先通过唯一颜色来识别对象。当用户点击canvas时,我们转到离屏帧缓冲区并读取点击动作位置的像素颜色。由于我们在离屏缓冲区中为每个对象分配了唯一颜色,我们可以使用这种颜色来识别被选中的对象并对其执行操作。以下图表说明了这个概念:

一个可能有助于解释拾取的有趣例子是 90 年代的流行任天堂游戏《鸭子射击》,玩家使用物理塑料枪控制器来射击鸭子:

你能猜到游戏是如何确定玩家是否击中了一只鸭子吗?没错,是拾取!当玩家指向一只鸭子并拉动扳机时,NES 中的计算机将屏幕变黑,枪中的 Zapper 二极管开始接收信号。然后,计算机在目标周围闪烁一个实心的白色方块。Zapper 中的光电二极管检测到光强度的变化,并告诉计算机它指向了一个发光的目标方块——换句话说,你应该得分,因为你击中了目标。当然,当你玩游戏时,你不会注意到屏幕变黑和目标闪烁,因为这一切都在一秒钟内发生。非常聪明,对吧?
让我们分解在 WebGL 中实现我们自己的拾取形式的步骤。
设置离屏帧缓冲区
如第二章中所示,渲染,帧缓冲区是 WebGL 中的最终渲染目的地。屏幕上渲染的结果是帧缓冲区的内容。假设gl是我们的 WebGL 上下文,每次调用gl.drawArrays、gl.drawElements和gl.clear都会改变帧缓冲区的内容。
我们不仅可以渲染到默认帧缓冲区,还可以渲染到屏幕外的场景——我们称之为离屏帧缓冲区。这是实现拾取的第一步。为了做到这一点,我们需要设置一个新的帧缓冲区,并告诉 WebGL 我们想要使用它而不是默认的。让我们看看我们如何做到这一点。
为了设置帧缓冲区,我们需要为至少两件事创建存储:颜色和深度信息。我们需要存储帧缓冲区中渲染的每个片段的颜色,以便我们可以创建一个图像。此外,我们需要深度信息来确保场景中重叠的对象看起来是一致的。如果没有深度信息,那么在两个重叠对象的情况下,我们就无法判断哪个对象在前,哪个对象在后。
为了存储颜色,我们将使用 WebGL 纹理;为了存储深度信息,我们将使用渲染缓冲区。
创建纹理以存储颜色
在阅读了第七章纹理之后,创建纹理的代码应该相当直接:
const canvas = document.getElementById('webgl-canvas');
const { width, height } = canvas;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
唯一的区别是我们没有图像绑定到纹理上,因此当我们调用gl.texImage2D时,最后一个参数是null。这是因为我们在为存储离屏帧缓冲区的颜色分配空间。
需要注意的是,纹理的width和height被设置为canvas的大小。这是因为我们想要确保离屏帧缓冲区与我们的 3D 场景的尺寸相似。
创建渲染缓冲区以存储深度信息
渲染缓冲区用于为帧缓冲区中使用的单个缓冲区提供存储。深度缓冲区(z 缓冲区)是渲染缓冲区的一个例子。它始终附加到屏幕帧缓冲区,这是 WebGL 中的默认渲染目标。
创建渲染缓冲区的代码如下:
const renderbuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
第一行代码创建渲染缓冲区。类似于其他 WebGL 缓冲区,在我们可以操作它之前,需要绑定渲染缓冲区。第三行代码确定渲染缓冲区的存储大小。
请注意,存储的大小与纹理相同。类似于之前的情况,我们需要确保对于帧缓冲区中的每个片段(像素),我们都有一个颜色(存储在纹理中)和一个深度值(存储在渲染缓冲区中)。
为离屏渲染创建帧缓冲区
我们需要创建一个帧缓冲区,并附加我们在上一个示例中创建的纹理和渲染缓冲区。让我们看看这在代码中是如何工作的。
首先,我们需要创建一个新的帧缓冲区:
const framebuffer = gl.createFramebuffer();
与 VBO 操作类似,我们通过将帧缓冲区设置为当前绑定的帧缓冲区来告诉 WebGL 我们将要操作这个帧缓冲区。我们通过以下指令来完成:
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
在绑定帧缓冲区后,通过调用以下方法附加纹理:
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
然后,使用以下方式将渲染缓冲区附加到已绑定的帧缓冲区:
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, renderbuffer);
最后,我们使用以下代码以通常的方式清理已绑定的缓冲区:
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
当之前创建的帧缓冲区解除绑定时,WebGL 状态机会回到渲染到默认屏幕帧缓冲区。
场景中每个对象分配一种颜色
为了简化问题,我们将根据对象的原始颜色选择一个对象。也就是说,我们丢弃了闪亮的反射或阴影,并以均匀的颜色渲染对象。这很重要,因为要基于颜色选择对象,我们需要确保颜色对每个对象是恒定的,并且每个对象都有不同的独特颜色。
我们通过告诉片段着色器只使用材料的漫反射属性来设置 ESSL 输出颜色变量,从而实现恒定着色。我们假设每个对象都有一个独特的漫反射属性。
在可能存在共享相同漫反射颜色的对象的情况下,我们可以创建一个新的 ESSL 统一变量来存储选择颜色,并使其对每个渲染到离屏帧缓冲区的对象都是唯一的。这样,当对象在屏幕上渲染时,它们看起来是相同的,但每次它们渲染到离屏帧缓冲区时,它们的颜色都是唯一的。在本章的后面部分,我们将实现这一策略,以及其他用于唯一识别对象的方案。
现在,让我们假设场景中的对象具有独特的漫反射颜色,如下面的图所示:

让我们看看如何使用我们刚刚设置的帧缓冲区来离屏渲染场景。
渲染到离屏帧缓冲区
为了使用离屏帧缓冲区进行对象选择,我们需要确保两个帧缓冲区同步。如果屏幕帧缓冲区和离屏帧缓冲区不同步,我们可能会错过关键数据,这可能会使我们的选择策略不一致。
一致性的缺乏将限制从离屏帧缓冲区读取颜色并用于识别场景中对象的能力。
为了确保缓冲区同步,我们将创建一个自定义的render函数。这个函数调用draw函数两次。首先,当离屏缓冲区绑定时,然后当屏幕默认帧缓冲区绑定时。代码如下:
function render() {
// off-screen rendering
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// we set the uniform to true because of an offscreen render
gl.uniform1i(program.uOffscreen, true);
draw();
// on-screen rendering
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// we set the uniform to false because of the default render
gl.uniform1i(program.uOffscreen, false);
draw();
}
我们告诉我们的 ESSL 程序在将渲染到离屏帧缓冲区时只使用漫反射颜色,使用uOffscreen统一变量。片段着色器包含以下代码:
void main(void) {
if (uOffscreen) {
fragColor = uMaterialDiffuse;
return;
}
// ...
}
下面的图显示了render函数的行为:

因此,每次场景更新时,都会调用render函数而不是调用draw函数。
我们在init函数中修改这一点:
function init() {
configure();
load();
// instead of calling 'draw', we are now calling 'render'
clock.on('tick', render);
}
这样,scene将使用render函数而不是原始的draw函数定期更新。
点击画布
下一步是从离屏帧缓冲区捕获并读取用户点击的鼠标坐标。我们可以使用网页中的canvas元素的标准的onmouseup事件:
const canvas = document.getElementById('webgl-canvas');
canvas.onmouseup = event => {
// capture coordinates from the `event`
};
由于给定的event返回的是从左上角开始的鼠标坐标(clientX和clientY),而不是相对于canvas的坐标,我们需要利用 DOM 层次结构来了解围绕canvas元素的总偏移量。
我们可以在canvas.onmouseup函数内的代码片段中这样做:
let top = 0,
left = 0;
while (canvas && canvas.tagName !== 'BODY') {
top += canvas.offsetTop;
left += canvas.offsetLeft;
canvas = canvas.offsetParent;
}
下面的图显示了我们是如何使用偏移计算来获得点击的canvas坐标的:

const x = ev.clientX - (a + b + c);
const y = canvasHeight - (ev.clientY - (d + e + f));
此外,我们还应该考虑任何可能的页面偏移。页面偏移是滚动的结果,它会影响坐标的计算。我们希望每次都能获得相同的canvas坐标,无论是否滚动。为此,我们在计算点击的canvas坐标之前添加以下两行代码:
left += window.pageXOffset;
top -= window.pageYOffset;
然后,我们计算canvas坐标:
x = ev.clientX - left;
y = canvas.height - (ev.clientY - top);
记住,与浏览器窗口不同,canvas坐标(以及为此目的的帧缓冲区坐标)从左下角开始。
从离屏帧缓冲区读取像素
我们现在可以转到离屏缓冲区并从适当的坐标读取颜色:

WebGL 允许我们使用readPixels函数从帧缓冲区读取。像往常一样,在我们的上下文中使用gl作为 WebGL 上下文变量:
| 函数 | 描述 |
|---|---|
gl.readPixels(x, y, width, height, format, type, pixels) |
-
x和y:起始坐标。 -
width和height:从帧缓冲区读取的像素范围。在我们的例子中,我们只读取一个像素(用户点击的位置),所以这将会是1,1。 -
format:支持gl.RGBA格式。 -
type:支持gl.UNSIGNED_BYTE类型。 -
pixels: 一个将包含查询帧缓冲区结果的类型化数组。它需要足够的空间来存储结果,这取决于查询的范围(x,y,width,height)。它支持Uint8Array类型。
|
记住,WebGL 作为一个状态机工作;因此,许多操作都依赖于其状态的有效性。在这种情况下,我们需要确保我们想要从中读取的离屏帧缓冲区是当前绑定的。为此,我们使用bindFramebuffer来绑定它。将所有这些放在一起,代码看起来是这样的:
// read one pixel
const readout = new Uint8Array(1 * 1 * 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.readPixels(coords.x, coords.y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, readout);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
在这里,读取数组的尺寸是1 * 1 * 4。这意味着它有一个像素的宽度乘以一个像素的高度乘以四个通道,因为格式是 RGBA。您不需要以这种方式指定大小;这样做是为了演示当我们只检索一个像素时,为什么大小是4。
寻找命中
现在,我们将检查从离屏帧缓冲区获取的颜色是否与场景中的任何对象匹配。记住,在这里我们使用颜色作为对象标签。如果颜色与其中一个对象匹配,那么我们称之为命中。如果不匹配,我们称之为未命中。
在寻找命中点时,我们比较每个对象的漫反射颜色与从离屏帧缓冲区获得的标签。然而,还有一个需要考虑的额外步骤:每个颜色通道的返回范围是[0, 255],而对象的漫反射颜色在[0, 1]范围内。在我们检查任何可能的命中之前,我们需要更新这一点。我们可以使用比较函数来完成:
function compare(readout, color) {
return (
Math.abs(Math.round(color[0] * 255) - readout[0]) <= 1 &&
Math.abs(Math.round(color[1] * 255) - readout[1]) <= 1 &&
Math.abs(Math.round(color[2] * 255) - readout[2]) <= 1
);
}
在前面的代码中,我们将漫反射属性缩放到[0, 255]范围内,然后逐个比较每个通道。我们不需要比较 alpha 通道。如果我们有两个颜色相同但 alpha 通道不同的对象,我们可以在比较中使用 alpha 通道,但这种情况在我们的例子中并不适用。
此外,需要注意的是,比较并不精确,因为我们正在处理[0, 1]范围内的十进制值。因此,我们通过假设在重新缩放颜色并减去读取(对象标签)后我们有一个命中,引入了一个调整因子——差异小于一。
然后,我们只需遍历scene中的对象列表,并使用以下代码检查是否有命中或未命中:
let pickedObject;
scene.traverse(object => {
if (compare(readout, object.diffuse)) {
// Returning any value from the 'scene.traverse' method breaks the loop
return pickedObject = object;
}
});
此代码遍历scene中的每个对象,并在我们有命中时将pickedObject分配给匹配的对象。
处理命中
处理碰撞是一个很大的概念,这很大程度上取决于您所使用的应用程序类型。例如,如果您的应用程序是一个 CAD 系统,您可能希望检索所选对象的属性,以便您可以编辑或删除它。相反,如果您正在开发一个游戏,选择一个对象可能涉及将其设置为角色接下来应该与之战斗的目标。正如您所预期的,这部分需要适应各种用途。话虽如此,我们很快就会提供一个实际例子,您可以在我们的场景中拖放对象。但首先,我们需要回顾一下我们应用程序的一些架构更新。
架构更新
我们已经用render函数替换了draw函数,正如本章前面所描述的。
现在有一个新的类:Picker。这个类的源代码可以从common/js/Picker.js获取。这个类封装了离屏帧缓冲区以及创建、配置和从中读取所需的代码。我们还更新了Controls类,以便在用户点击canvas时通知选择器。
现在,让我们看看选择操作的实际应用!
行动时间:选择操作
让我们来看一个这个技术实际应用的例子:
- 使用您的浏览器打开
ch08_01_picking.html文件。您将看到一个类似于以下的屏幕:

-
在这里,您有一组对象,每个对象都有一个独特的漫反射颜色属性。与之前的示例一样,您可以围绕场景移动相机。请注意,立方体有一个纹理,而扁平的圆盘是半透明的。正如您所期望的,
draw函数中的代码处理纹理、坐标以及透明度,因此看起来比之前复杂一些(您可以在源代码中查看)。这是一个更现实的draw函数。在实际应用中,您将不得不处理这些情况。 -
点击球体并在场景中拖动它。请注意,对象变得半透明。此外,请注意位移沿着相机的轴线发生。为了使这一点更加明显,请前往您的网页浏览器的
控制台并输入以下内容:
camera.setElevation(0);
- 当您通过在场景内点击来恢复时钟时,您将看到相机更新其位置到零度仰角,如下面的截图所示:

JavaScript 控制台
Firefox:工具 | 网页开发者 | 网页控制台
Safari:开发 | 显示网页检查器
Chrome:工具 | JavaScript 控制台
-
当您从这一视角点击并拖动场景中的对象时,您会看到它们根据摄像机的轴线改变位置。在这种情况下,摄像机的向上轴线与场景的 y 轴线对齐。如果您上下移动一个对象,您会看到它们在
y坐标上的位置发生变化。如果您更改摄像机位置(通过点击背景并拖动鼠标),然后移动不同的对象,您会看到对象根据摄像机的新 y 轴线移动。 -
尝试不同的摄像机角度,看看会发生什么。
-
让我们看看离屏帧缓冲区看起来像什么。点击“显示拾取图像”按钮。在这里,我们指示片段着色器使用每个对象的漫反射属性来着色片段。您也可以在这种方式下旋转场景并拾取对象。如果您想回到原始着色方法,再次点击“显示拾取图像”以取消激活它。
-
要重置场景,请点击重置场景。
刚才发生了什么?
我们已经看到了一个拾取操作的示例。源代码使用了我们在架构更新部分之前描述的Picker类。让我们更仔细地看看它。
拾取架构
如您可能已经注意到的,每个拾取状态都与一个回调函数相关联。以下过程概述了当用户在canvas上点击鼠标、拖动它并释放时,Picker类中发生的情况:
| 状态 | 回调 |
|---|---|
Picker搜索命中项 |
hitPropertyCallback(object): 此回调通知拾取器使用哪个对象属性来与从离屏帧缓冲区检索到的颜色进行比较。 |
| 用户在拾取模式下拖动鼠标 | moveCallback(dx, dy): 当拾取模式被激活(通过至少拾取一个对象)时,此回调允许我们移动拾取列表(命中项)中的对象。此列表由Picker类内部维护。 |
| 从拾取列表中移除命中项 | addHitCallback(object): 如果我们点击一个对象,而这个对象不在拾取列表中,拾取器将通过触发此回调来通知应用程序。 |
| 将命中项添加到拾取列表 | removeHitCallback(object): 如果我们点击一个对象,而这个对象已经在拾取列表中,拾取器将将其从列表中移除,然后通过触发此回调来通知应用程序。 |
| 结束拾取模式 | processHitsCallback(hits): 如果用户在未按下Shift键的情况下释放鼠标按钮,拾取模式结束,并且应用程序将通过触发此回调来得到通知。如果按下Shift键,则拾取模式继续,拾取器等待新的点击以继续寻找命中项。 |
实现独特的对象标签
我们之前提到,如果场景中的两个或多个对象具有相同的漫反射颜色,基于漫反射属性进行选择可能会很困难。如果情况如此,并且你选择了其中之一,你将如何确定基于其颜色选择的是哪一个?在下面的行动时间部分,我们将实现独特的对象标签。对象将使用这些颜色标签而不是漫反射颜色在离屏帧缓冲区中渲染。场景仍然将使用非独特的漫反射颜色在屏幕上渲染。
行动时间:独特的对象标签
本节分为两部分。在第一部分,你将开发代码以生成包含圆锥体和圆柱体的随机场景。每个对象将被分配一个独特的对象标签,该标签将用于在离屏渲染缓冲区中着色对象。在第二部分,我们将配置选择器以使用独特的标签。让我们开始吧:
-
在你的浏览器中打开
ch08_02_picking-initial.html文件。这是一个只显示地板对象的场景。我们将创建一个包含多个对象(可以是球体或圆柱体)的场景。 -
在源代码编辑器中打开
ch08_02_picking-initial.html。 -
我们将编写代码,以便场景中的每个对象都可以具有以下属性:
-
随机分配的位置
-
独特的对象标签颜色
-
非独特的漫反射颜色
-
确定对象大小的比例因子
-
-
我们已经提供了空函数,你将在本节中实现它们。
-
让我们编写
positionGenerator函数。向下滚动到它并添加以下代码:
function positionGenerator() {
const
flagX = Math.floor(Math.random() * 10),
flagZ = Math.floor(Math.random() * 10);
let x = Math.floor(Math.random() * 60),
z = Math.floor(Math.random() * 60);
if (flagX >= 5) {
x = -x;
}
if (flagZ >= 5) {
z = -z;
}
return [x, 0, z];
}
-
在这里,我们使用
Math.random函数生成场景中对象的x和z坐标。由于Math.random始终返回一个正数,我们使用flagX和flagZ变量在x-z平面(地板)上随机分布对象。此外,因为我们希望所有对象都在x-z平面上,所以在return语句中将y分量始终设置为0。 -
让我们编写一个独特的对象标签生成函数。滚动到空的
objectLabelGenerator函数并添加以下代码:
const colorset = {};
function objectLabelGenerator() {
const
color = [Math.random(), Math.random(), Math.random(), 1],
key = color.toString();
if (key in colorset) {
return objectLabelGenerator();
}
else {
colorset[key] = true;
return color;
}
}
-
我们使用
Math.random函数创建一个随机颜色。如果key变量已经是colorset对象的属性,那么我们将递归调用objectLabelGenerator函数以获取新值;否则,我们将key作为colorset的属性,然后return相应的颜色。注意,JavaScript 对象作为集合的处理方式如何使我们能够解决任何可能的关键冲突。 -
编写
diffuseColorGenerator函数。我们将使用此函数为对象分配漫反射属性:
function diffuseColorGenerator(index) {
const color = (index % 30 / 60) + 0.2;
return [color, color, color, 1];
}
-
此函数表示我们想要生成非唯一颜色的情形。
index参数表示我们正在分配漫反射颜色的scene.objects列表中对象的索引。在此函数中,我们创建了一个灰度颜色,因为return语句中的r、g和b组件都具有相同的color值。 -
diffuseColorGenerator函数将在每个30个索引处创建碰撞。索引除以30的余数将在序列中创建循环:
0 % 30 = 0
1 % 30 = 1
...
29 % 30 = 29
30 % 30 = 0
31 % 30 = 1
...
-
由于此结果被除以
60,结果将是一个在[0, 0.5]范围内的数字。然后,我们添加0.2以确保color的最小值是0.2。这样,在屏幕渲染期间对象看起来不会太暗(如果计算的漫反射颜色是0,它们将是黑色的)。 -
我们将要编写的最后一个辅助函数是
scaleGenerator函数:
function scaleGenerator() {
const scale = Math.random() + 0.3;
return [scale, scale, scale];
}
-
此函数将允许我们拥有不同大小的对象。
0.3被添加以控制场景中任何对象的最小缩放因子。 -
让我们在场景中加载
100个对象。到本节结束时,你将能够测试它们中的任何一个的拾取! -
前往
load函数并编辑它,使其看起来像这样:
function load() {
scene.add(new Floor(80, 20));
for (let i = 0; i < 100; i++) {
const objectType = Math.floor(Math.random() * 2);
const options = {
position: positionGenerator(),
scale: scaleGenerator(),
diffuse: diffuseColorGenerator(i),
pcolor: objectLabelGenerator()
};
switch (objectType) {
case 1:
return scene.load('/common/models/ch8/sphere.json',
`ball_${i}`, options);
case 0:
return scene.load('/common/models/ch8/cylinder.json',
`cylinder_${i}`, options);
}
}
}
-
选择颜色由
pcolor属性表示。此属性作为属性列表传递给scene.load函数。一旦对象被加载(使用JSON/Ajax),load将使用此属性列表并将它们添加为对象属性。 -
本练习中的着色器已经为你设置好了。与唯一对象标签对应的
pcolor属性映射到uPickingColor统一变量,而uOffscreen统一变量确定是否在片段着色器中使用:
uniform vec4 uPickingColor;
void main(void) {
if (uOffscreen) {
fragColor = uPickingColor;
return;
}
else {
// on-screen rendering
}
}
- 如前所述,我们通过使用以下
render函数来保持离屏和屏幕缓冲区同步:
function render() {
// Off-screen rendering
gl.bindFramebuffer(gl.FRAMEBUFFER, picker.framebuffer);
gl.uniform1i(program.uOffscreen, true);
draw();
// On-screen rendering
gl.uniform1i(program.uOffscreen, showPickingImage);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
draw();
}
-
将你的工作保存为
ch08_03_picking-no-picker.html。 -
在你的浏览器中打开
ch08_03_picking-no-picker.html。 -
点击“显示拾取图像”。会发生什么?
-
场景正在被渲染到离屏和默认的屏幕帧缓冲区。然而,我们尚未配置
Picker回调。 -
在你的源代码编辑器中打开
ch08_03_picking-no-picker.html。 -
滚动到
configure函数。拾取器已经为你设置好了:
picker = new Picker(canvas, {
hitPropertyCallback: hitProperty,
addHitCallback: addHit,
removeHitCallback: removeHit,
processHitsCallback: processHits,
moveCallback: movePickedObjects
});
-
此代码片段将网页中的函数映射到拾取回调钩子。这些回调根据拾取状态被调用。
-
我们现在将实现必要的回调。同样,我们提供了空函数,你需要编写代码。
-
让我们创建
hitProperty函数。滚动到空的hitProperty函数并添加以下代码:
function hitProperty(obj) {
return obj.pcolor;
}
-
我们正在返回
pcolor属性,以便与从离屏帧缓冲区读取的颜色进行比较。如果这些颜色匹配,那么我们就有一个碰撞。 -
编写
addHit和removeHit函数。我们想要创建在拾取期间将漫反射颜色更改为拾取颜色的效果。我们需要一个额外的属性来临时保存原始漫反射颜色,以便我们可以在稍后恢复它:
function addHit(obj) {
obj.previous = obj.diffuse.slice(0);
obj.diffuse = obj.pcolor;
}
addHit函数将当前漫反射颜色存储在一个名为previous的辅助属性中。然后,它将漫反射颜色更改为pcolor,即对象拾取标签:
function removeHit(obj) {
obj.diffuse = obj.previous.slice(0);
}
-
removeHit函数会恢复漫反射颜色。 -
现在,让我们编写
processHits的代码:
function processHits(hits) {
hits.forEach(hit => hit.diffuse = hit.previous);
}
-
请记住,
processHits在退出拾取模式时被调用。此函数将接收一个参数:picker检测到的hits。hits列表中的每个元素都是scene中的一个对象。在这种情况下,我们希望将漫反射颜色还给击中点。为此,我们使用在addHit函数中设置的previous属性。 -
我们需要实现的最后一个拾取回调函数是
movePickedObjects函数:
function movePickedObjects(dx, dy) {
const hits = picker.getHits();
if (!hits) return;
const factor = Math.max(
Math.max(camera.position[0], camera.position[1]),
camera.position[2]
) / 2000;
hits.forEach(hit => {
const scaleX = vec3.create();
const scaleY = vec3.create();
if (controls.alt) {
vec3.scale(scaleY, camera.normal, dy * factor);
}
else {
vec3.scale(scaleY, camera.up, -dy * factor);
vec3.scale(scaleX, camera.right, dx * factor);
}
vec3.add(hit.position, hit.position, scaleY);
vec3.add(hit.position, hit.position, scaleX);
});
}
-
此函数允许我们交互式地移动
hits列表中的对象。此回调函数接收的参数如下:-
dx:当鼠标在canvas上拖动时从鼠标获得的水平方向位移 -
dy:当鼠标在canvas上拖动时从鼠标获得的垂直方向位移
-
-
让我们分析一下代码。首先,我们从拾取实例中检索所有击中点:
const hits = picker.getHits();
- 如果没有击中点,函数将立即返回:
if (!hits) return;
- 我们计算一个将在以后使用的加权因子(调整因子):
const factor = Math.max(
Math.max(camera.position[0], camera.position[1]), camera.position[2]
) / 2000;
- 我们创建一个循环来遍历击中点列表,以便我们可以更新每个对象的位置:
hits.forEach(hit => {
const scaleX = vec3.create();
const scaleY = vec3.create();
// ...
});
-
对于每个击中点,
scaleX和scaleY变量都会被初始化。 -
Alt键正在被用来执行推拉(沿着相机的法线路径移动相机)。在这种情况下,当用户按下Alt键以提供一致的用户体验时,我们希望沿着相机的法线方向移动拾取列表中的对象。
-
要沿着相机的法线移动击中点,我们使用
dy(上下)位移,如下所示:
if (controls.alt) {
vec3.scale(scaleY, camera.normal, dy * factor);
}
-
这创建了一个
camera.normal的缩放版本,并将其存储到scaleY变量中。请注意,vec3.scale是glMatrix库中可用的一种操作。 -
如果用户没有按下Alt键,则我们使用
dx(左右)和dy(上下)在相机平面内移动击中点。在这里,我们使用相机的向上和向右向量来计算scaleX和scaleY参数:
else {
vec3.scale(scaleY, camera.up, -dy * factor);
vec3.scale(scaleX, camera.right, dx * factor);
}
- 我们更新击中点的位置:
vec3.add(hit.position, hit.position, scaleY);
vec3.add(hit.position, hit.position, scaleX);
-
将页面保存为
ch08_04_picking-final.html,并使用您的浏览器打开它。 -
您将看到如下截图所示的场景:

-
多次点击“重置场景”,并验证每次都得到一个新的场景。
-
在这个场景中,所有对象的颜色非常相似。然而,每个对象都有一个独特的拾取颜色。为了验证这一点,点击“显示拾取图像”按钮。您将在屏幕上看到在离屏缓冲区中渲染的内容:

- 让我们验证我们对选择器回调所做的更改。让我们先选择一个对象。正如你所看到的,对象的漫反射颜色变成了选择颜色(这是你在
addHit函数中实现的变化):

-
当鼠标释放时,对象会恢复到原始颜色。这是在
processHits函数中实现的变化。 -
当鼠标按钮压在对象上时,你可以拖动它。当这样做完成后,
movePickedObjects将被调用。 -
在选择对象时按下Shift键将告诉选择器不要退出选择模式。这样,你可以一次选择并移动多个对象:

-
如果你选择了一个对象并且没有按下Shift键,或者你的下一次点击没有产生任何击中(换句话说,点击其他任何地方),你将退出选择模式。
-
如果你在这个练习中遇到任何问题或遗漏了某个步骤,我们已经在
ch08_03_picking-no-picker.html和ch08_04_picking-final.html文件中包含了完整的练习。
发生了什么?
我们做了以下几步:
-
创建了属性选择颜色。这个属性对于场景中的每个对象都是唯一的,并允许我们根据它实现选择。
-
修改了片段着色器以使用选择颜色属性,通过包含一个新的统一变量
uPickingColor并将此统一变量映射到pcolor对象属性。 -
学习了不同的选择状态。我们还学习了如何修改
Picker回调以执行特定的应用程序逻辑,例如从场景中移除被选择的对象。
尝试:清除场景
重新编写processHits函数以从场景中移除被击中的球体。如果用户已经从场景中移除了所有的球体,显示完成此任务所需的时间信息。
提示
如果别名以ball_开头,在processHits函数中使用scene.remove(objectName)退出选择模式。
提示
一旦从场景中移除击中,重新访问scene.objects列表,确保没有对象的别名以ball_开头。
提示
使用 JavaScript 计时器来测量和显示完成任务所需的时间。
尝试:使用替代标识符进行选择
你会如何在不使用颜色的情况下唯一标识对象?由于我们正在利用离屏帧缓冲区,我们可以用唯一的 ID 而不是颜色来标识每个对象,因为我们的离屏渲染的视觉效果并不重要。继续实现一个基于 ID 的策略来标识每个对象。
将索引打包到 RGBA 通道中
你可以将对象1视为索引(或颜色)[0, 0, 0, 1],对象2为[0, 0, 0, 2],以此类推,直到对象1020(即255 * 4)为[255, 255, 255, 255]。
由于我们的 RGBA 通道绑定在[0-255]的范围内,我们如何将更多的 ID 打包到基于四位的向量中呢?
不同的数制系统
你可能会首先想到利用小数而不是整数。这是一个可行的解决方案,尤其是在你考虑到 ESSL 中浮点数的精度之后。另一个可行的解决方案是使用基于255的数制而不是我们传统的基10。采用这种方法,你可以唯一地识别超过 40 亿个对象,而不需要小数。
尝试:解耦 WebGL 组件
尽管我们可以通过使用更可扩展的架构将Controls和Picker解耦来实现它们,但出于教育目的,我们选择了更简单的实现方式。
话虽如此,你将如何重新构建本章中的示例,目标是解耦类以最小化组件之间的相互依赖?
提示
一种方法是将之前讨论的 pub/sub 模式用于我们的Clock。也就是说,每个组件都可以扩展EventEmitter类——类似于Clock——来发布其他组件可能订阅的事件。
摘要
让我们总结一下在本章中学到的内容:
-
我们研究了帧缓冲区和渲染缓冲区之间的区别。渲染缓冲区是一个特殊的缓冲区,它附加到帧缓冲区上。
-
我们了解到 WebGL 提供了创建与默认屏幕帧缓冲区不同的离屏帧缓冲区的机制。
-
我们讨论了帧缓冲区至少需要一个纹理来存储颜色,以及一个渲染缓冲区来存储深度信息。
-
我们讨论了如何将用户点击坐标转换为
canvas坐标,以便我们可以将它们映射到离屏帧缓冲区中的值。 -
我们讨论了
Picker架构。选择可以有不同的状态,每个状态都与一个回调函数相关联。Picker 回调允许自定义应用程序逻辑确定选择进行时会发生什么。 -
我们学习了如何在 WebGL 中实现基于颜色的选择。仅基于漫反射颜色进行选择是有限的,因为可能存在多个对象具有相同漫反射颜色的情况。
-
我们了解到,为每个对象分配一个独特的颜色属性以执行选择是更好的选择。这个属性被称为颜色/对象标签的选择。
-
我们讨论了将唯一的 ID 而不是唯一的颜色编码到基于四位的向量 RGBA 中。
在下一章中,我们将把之前章节中涵盖的所有概念结合起来,构建一个 3D 虚拟汽车展厅。此外,我们还将了解如何将来自 3D 编辑工具 Blender 的汽车模型导入 WebGL 应用程序。
第九章:整合所有内容
在上一章中,我们介绍了帧缓冲区、渲染缓冲区以及使用拾取与 3D 应用程序交互所需的步骤。在本章中,我们将结合我们迄今为止所学到的所有概念来构建一个 3D 虚拟汽车展厅。在开发这个演示应用程序的过程中,我们将使用模型、光源、相机、动画、颜色、纹理等。我们还将学习如何将这些元素与一个简单而有效的图形用户界面集成。
在本章中,你将学习以下内容:
-
整合本书中我们开发的所有架构
-
使用我们的架构创建一个 3D 虚拟汽车展厅应用程序
-
将 Blender 中的汽车模型导入 WebGL 场景
-
设置多个光源
-
创建健壮的着色器以处理多种材质
-
了解 OBJ 和 MTL 文件格式
-
编程相机以飞越场景
创建 WebGL 应用程序
到目前为止,我们已经涵盖了创建 WebGL 应用程序所需的基本主题。这些主题在我们这本书中通过迭代构建的框架中得到了实现。
在第一章“入门”,我们介绍了 WebGL 以及如何在浏览器中使用它。我们了解到 WebGL 上下文的行为像一个状态机。因此,我们可以使用gl.getParameter查询不同的状态变量。
然后,我们研究了 WebGL 场景中的对象是如何由顶点定义的。我们看到了如何使用索引来标记顶点,以便 WebGL 渲染管线可以快速光栅化以渲染对象。我们研究了操作缓冲区的函数以及渲染原语的两个主要函数:drawArrays(无索引)和drawElements(有索引)。我们还学习了如何使用 JSON 表示几何形状,以及如何从网络服务器下载模型。
接下来,我们研究了如何照亮我们的 3D 场景。我们学习了法线向量、光的反射物理以及实现光照所需的 3D 数学。我们还学习了如何使用 ESSL 中的着色器实现不同的光照模型。
然后,由于 WebGL 没有相机,我们实现了自己的自定义相机。我们研究了相机矩阵,并展示了它实际上是模型-视图矩阵的逆。换句话说,世界空间中的旋转、平移和缩放在相机空间中产生逆操作。
在讨论了相机和矩阵之后,我们介绍了动画的基础知识。我们讨论了动画的有用技术,例如使用push和pop操作表示局部和全局变换的矩阵栈,并分析了如何建立一个独立于渲染周期的动画周期。我们的动画涵盖了不同类型的插值技术,并通过展示各种动画风格的示例。
然后,我们研究了使用 WebGL 进行颜色表示以及如何在对象、灯光和整个场景中使用颜色。在这个过程中,我们还研究了混合以及创建半透明和透明效果。在颜色和混合之后,我们讨论了纹理,以为我们的场景添加更多细节。然后,我们看到了用户如何通过拾取与我们的 3D 应用程序交互。
在本章中,我们将利用所有这些概念来创建一个令人印象深刻的 3D 应用程序。合乎逻辑的是,我们将使用我们迄今为止开发的所有组件。让我们快速回顾一下。
建筑评论
以下组件存在于本书中构建的架构中:
-
Axis.js: 代表场景中心的辅助对象,带有视觉辅助工具。 -
Camera.js: 包含两种我们开发的相机表示:环绕和跟踪。 -
Clock.js: 基于 requestAnimationFrame 的计时器,用于从单一真相源同步我们的整个应用程序。 -
Controls.js: 监听 HTML5canvas上的鼠标和键盘事件。它解释这些事件并将它们转换为相机动作。 -
EventEmitter.js: 一个简单的类,提供了一种 pub-sub 方法来管理我们应用程序中组件之间的交互。 -
Floor.js: 类似于矩形网格的辅助对象,为场景提供地板参考。 -
Light.js: 简化了场景中灯光的创建和管理。 -
Picker.js: 提供基于颜色的对象拾取。 -
Program.js: 组合处理程序、着色器和 JavaScript 值与 ESSL 统一变量之间映射的函数。 -
Scene.js: 包含 WebGL 将要渲染的对象列表。 -
Texture.js: 用于创建和管理 WebGL 纹理的类。 -
Transforms.js: 包含本书中讨论的矩阵,即模型-视图矩阵、相机矩阵、u 投影矩阵和法线矩阵。它通过push和pop操作实现了矩阵堆栈。 -
utils.js: 包含辅助函数,例如getGLContext,它有助于为给定的 HTML5canvas创建 WebGL 上下文。 -
应用钩子函数,如下所示:
-
init: 此函数初始化应用程序,并且仅在文档通过window.onload = init;加载时调用。 -
configure: 此函数创建和配置依赖项,例如程序、相机、灯光等。 -
load: 此函数通过调用scene.load从网络服务器请求对象。我们还可以通过调用scene.add添加本地生成的几何形状(例如Floor)。 -
draw: 当渲染计时器响起时调用此函数。在这里,我们从scene检索对象,并通过确保它们的位置(例如,使用矩阵堆栈应用局部变换)和它们的属性(例如,将相应的统一变量传递给program)来适当地渲染它们。
-
现在,让我们将这些概念结合起来,创建一个 3D 虚拟汽车展厅。
行动时间:3D 虚拟汽车展厅
利用我们迄今为止开发的 WebGL 技能和基础设施代码,我们将创建一个可视化不同 3D 汽车模型的程序。最终结果将看起来像这样:

首先,我们将定义我们应用程序的图形用户界面(GUI)。然后,我们将通过创建一个canvas元素并获取一个 WebGL 上下文来添加 WebGL 支持。在获取有效的 WebGL 上下文后,我们将使用 ESSL 定义和实现顶点和片段着色器。然后,我们将实现三个钩子到我们应用程序生命周期的函数:configure、load和draw。
在我们开始之前,让我们考虑一下我们虚拟展厅应用的一些基本原理。
模型的复杂性
实际应用通常比概念验证(Proof of Concept,PoC)演示要复杂得多。这一点在 3D 应用中尤其如此,因为 3D 资产,如模型,比简单的球体、圆锥体和其他原始几何图形要复杂得多。大型 3D 应用中的模型往往具有大量顶点和复杂的配置,以提供用户期望的细节和真实感。除了这些模型的纯几何表示外,它们通常还包含几个纹理。正如预期的那样,使用 JSON 文件手动创建几何形状和纹理是相当令人畏惧的。
幸运的是,我们可以使用各种经过行业验证的 3D 设计软件来创建和将模型导入 WebGL 场景。对于我们的 3D 虚拟汽车展厅,我们将使用用Blender创建的模型,这是一个广泛使用的开源 3D 工具。
Blender
Blender 是一个开源的 3D 计算机图形软件,允许你创建动画、游戏和其他交互式应用程序。Blender 提供了众多功能,以便你可以创建复杂模型。你可以查看 Blender 的官方网站获取更多信息:www.blender.org。
我们将使用 Blender 将汽车模型导入我们的 WebGL 场景。首先,我们将模型导出为一种称为OBJ的中间文件格式,然后解析成可消费的 JSON 文件。我们将在稍后详细介绍这些概念。
着色器质量
由于我们将使用复杂的模型,如汽车,我们需要开发能够渲染我们模型不同材质的着色器。这应该相对简单,因为我们开发的着色器已经处理了材质的漫反射、镜面反射和环境反射组件。在 Blender 中,我们将选择在生成 OBJ 文件时导出材质的选项。然后,Blender 将生成一个名为材质模板库(Material Template Library,MTL)的第二个文件。为了获得最佳效果,我们的着色器将使用 Phong 着色和 Phong 光照,并支持多个光源。
网络延迟和带宽消耗
当涉及到具有大量 3D 资源的 WebGL 应用时,我们通常从网络服务器下载几何形状和纹理。正如预期的那样,这可能会花费一些时间,具体取决于网络连接的质量和需要传输的数据量。然而,有几种策略可以优化这个过程,例如压缩和 3D 资源优化,这些将在后面的章节中介绍。我们将使用 AJAX 在后台下载这些大型资源,为用户提供良好的用户体验。
考虑到这些因素,让我们开始吧。
设计我们的 GUI
我们将为我们的应用定义一个非常简单的布局。首先,我们将定义我们的 HTML 文档并包含所有必要的依赖项:
<html>
<head>
<title>Real-Time 3D Graphics with WebGL 2</title>
<link rel="shortcut icon" type="image/png"
href="/common/images/favicon.png" />
<!-- libraries -->
<link rel="stylesheet" href="/common/lib/normalize.css">
<script type="text/javascript" src="img/dat.gui.js"></script>
<script type="text/javascript" src="img/gl-matrix.js"></script>
<!-- modules -->
<script type="text/javascript" src="img/utils.js"></script>
<script type="text/javascript" src="img/EventEmitter.js"></script>
<script type="text/javascript" src="img/Camera.js"></script>
<script type="text/javascript" src="img/Clock.js"></script>
<script type="text/javascript" src="img/Controls.js"></script>
<script type="text/javascript" src="img/Floor.js"></script>
<script type="text/javascript" src="img/Light.js"></script>
<script type="text/javascript" src="img/Program.js"></script>
<script type="text/javascript" src="img/Scene.js"></script>
<script type="text/javascript" src="img/Texture.js"></script>
<script type="text/javascript" src="img/Transforms.js"></script>
</head>
<body>
</body>
</html>
如您所见,我们已经包含了以下对于我们的应用所必需的库:
-
normalize.css:一组使浏览器更一致地渲染所有元素的样式 -
dat.gui.js:一个用于在 JavaScript 中更改变量的轻量级图形用户界面 -
gl-matrix.js:一个用于高性能应用的 JavaScript 矩阵和向量库
现在我们已经包含了所需的库,我们将包含本书中涵盖的各种组件。
添加 canvas 支持
现在我们已经有了我们应用的框架,让我们添加我们 WebGL 应用所需的canvas:
<canvas id="webgl-canvas">
Your browser does not support the HTML5 canvas element.
</canvas>
带有webgl-canvas ID 的canvas元素位于我们的 HTML 文档的body之间。
添加着色器脚本
接下来,让我们通过以下代码将我们应用所需的两个着色器包含进来:
<script id="vertex-shader" type="x-shader/x-vertex">
#version 300 es
precision mediump float;
void main(void) {}
</script>
<script id="fragment-shader" type="x-shader/x-fragment">
#version 300 es
precision mediump float;
void main(void) {}
</script>
这些scripts被放置在我们的文档的head中。
添加 WebGL 支持
现在我们已经有了我们应用的基本模板,让我们初始化我们的 WebGL 应用:
<script type="text/javascript">
'use strict';
let gl, program, scene, clock;
function configure() {
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(0.9, 0.9, 0.9, 1);
gl.clearDepth(1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
program = new Program(gl, 'vertex-shader', 'fragment-shader');
scene = new Scene(gl, program);
clock = new Clock();
}
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
function init() {
configure();
clock.on('tick', draw);
}
window.onload = init;
</script>
这个script标签放在着色器脚本之后,以确保我们可以在需要时引用它们。
让我们详细介绍一下这段代码:
let gl, program, scene, clock;
我们需要定义将在整个应用中设置和使用的一些全局变量。就像我们之前的所有练习一样,我们需要定义应用的入口点。我们通过以下代码来完成这项工作:
function init() {
configure();
clock.on('tick', draw);
}
window.onload = init;
init函数在文档通过window.onload加载后调用。在init函数中,我们通过调用configure和使用clock实例在每次tick(即每次requestAnimationFrame调用)上调用draw来设置我们的应用。这意味着每次requestAnimationFrame调用都会绘制一次。
function configure() {
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(0.9, 0.9, 0.9, 1);
gl.clearDepth(1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
program = new Program(gl, 'vertex-shader', 'fragment-shader');
scene = new Scene(gl, program);
clock = new Clock();
}
我们初始化并设置具有 ID webgl-canvas 的 canvas。然后,我们将canvas实例传递给我们的实用函数,以实现全屏和自动调整大小功能。这个函数很有用,因为它会自动更新canvas的大小以适应可用的窗口空间,而不需要硬编码canvas的大小。然后,我们使用提供的着色器初始化并设置gl、scene、clock和program。最后,我们使用基本配置设置gl上下文,例如清除颜色、深度测试和混合函数:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
draw函数很简单,因为它只是设置视口并清除canvas。你可以在本书的ch09_scaffolding.html文件中找到此源代码。
现在,如果你在你的浏览器中运行ch09_scaffolding.html,你会看到canvas根据浏览器的大小进行缩放,如下所示:

实现着色器
使用我们的着色器,我们将实现冯·卡门着色和冯·卡门反射模型。记住,冯·卡门着色插值顶点法线并为每个片段创建一个法线——处理发生在片段着色器中。冯·卡门反射模型描述了光照为对象与光源的漫反射、反射和镜面反射的加和。
为了与材质模板库(MTL)格式保持一致,我们将遵循一些典型约定来设置指向材质属性的统一名称:
| 材质 统一 | 描述 |
|---|---|
uKa |
环境属性。 |
uKd |
漫反射属性。 |
uKs |
镜面属性。 |
uNi |
光学密度。我们不会使用此功能,但你将在 MTL 文件中看到它。 |
uNs |
镜面指数。高指数会导致紧密、集中的高光。Ns值通常在0到1000之间。 |
uD |
透明度(alpha 通道)。 |
| uIllum | 确定渲染对象的照明模型。与之前章节中所有对象使用一个模型不同,我们让对象描述它们的反射属性。
根据 MTL 文件格式规范,illum可以是以下任何一种:
-
颜色开启和环境关闭。
-
颜色开启和环境开启。
-
高亮开启。
-
反射开启和光线追踪开启。
-
透明度:玻璃开启,反射:光线追踪开启。
-
反射:菲涅耳开启和光线追踪开启。
-
透明度:折射开启,反射:菲涅耳关闭和光线追踪开启。
-
透明度:折射开启,反射:菲涅耳开启和光线追踪开启。
-
反射开启和光线追踪关闭。
-
透明度:玻璃开启,反射:光线追踪关闭。
-
在不可见表面上投射阴影。
|
Wavefront .obj文件**
关于 OBJ 和 MTL 文件规范的更多信息,请参阅以下链接:en.wikipedia.org/wiki/Wavefront_.obj_file。
我们的着色器将通过使用前面章节中描述的统一数组来支持多个光源。光源的数量由顶点和片段着色器中的常量定义:
const int numLights = 4;
我们将使用以下统一数组来处理光源:
| 灯光 统一数组 | 描述 |
|---|---|
uLa[numLights] |
环境属性。 |
uLd[numLights] |
漫反射属性。 |
uLs[numLights] |
镜面属性。 |
源代码
如果你希望探索本章着色器的源代码,可以参考ch09_02_showroom.html。
这里是顶点着色器:
<script id="vertex-shader" type="x-shader/x-vertex">
#version 300 es
precision mediump float;
const int numLights = 4;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
uniform vec3 uLightPosition[numLights];
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec3 vNormal;
out vec3 vLightRay[numLights];
out vec3 vEye[numLights];
void main(void) {
vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
vec4 lightPosition = vec4(0.0);
for(int i= 0; i < numLights; i++) {
lightPosition = vec4(uLightPosition[i], 1.0);
vLightRay[i] = vertex.xyz - lightPosition.xyz;
vEye[i] = -vec3(vertex.xyz);
}
vNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
}
</script>
伴随相应的片段着色器:
<script id="fragment-shader" type="x-shader/x-fragment">
#version 300 es
precision mediump float;
const int numLights = 4;
uniform vec3 uLd[numLights];
uniform vec3 uLs[numLights];
uniform vec3 uLightPosition[numLights];
uniform vec3 uKa;
uniform vec3 uKd;
uniform vec3 uKs;
uniform float uNs;
uniform float uD;
uniform int uIllum;
uniform bool uWireframe;
in vec3 vNormal;
in vec3 vLightRay[numLights];
in vec3 vEye[numLights];
out vec4 fragColor;
void main(void) {
if (uWireframe || uIllum == 0) {
fragColor = vec4(uKd, uD);
return;
}
vec3 color = vec3(0.0);
vec3 light = vec3(0.0);
vec3 eye = vec3(0.0);
vec3 reflection = vec3(0.0);
vec3 normal = normalize(vNormal);
if (uIllum == 1) {
for (int i = 0; i < numLights; i++) {
light = normalize(vLightRay[i]);
normal = normalize(vNormal);
color += (uLd[i] * uKd * clamp(dot(normal, -light), 0.0, 1.0));
}
}
if (uIllum == 2) {
for (int i = 0; i < numLights; i++) {
eye = normalize(vEye[i]);
light = normalize(vLightRay[i]);
reflection = reflect(light, normal);
color += (uLd[i] * uKd * clamp(dot(normal, -light), 0.0, 1.0));
color += (uLs[i] * uKs * pow(max(dot(reflection, eye), 0.0), uNs) *
4.0);
}
}
fragColor = vec4(color, uD);
}
</script>
如预期的那样,顶点和片段着色器借鉴了本书中早期章节中的概念,除了uIllum。如前所述,illum属性决定了正在渲染的对象的照明模型。我们可以默认使用一个更简单的片段着色器(例如uIllum == 2),但为了教育目的提供了一个简单示例。
接下来,我们将配置三个主要函数,这些函数钩入我们的 WebGL 应用程序的生命周期。这些是configure、load和render函数。
设置场景
我们可以通过定义应用程序的一些全局变量并为configure函数编写代码来设置场景。让我们逐行分析:
let gl, program, scene, clock, camera, transforms, lights,
floor, selectedCar, lightPositions, carModelData,
clearColor = [0.9, 0.9, 0.9, 1];
function configure() {
// ...
}
在这个阶段,我们想要设置一些 WebGL 属性,例如清除颜色和深度测试。然后,我们需要创建一个相机并设置其初始位置和方向。我们还需要创建一个相机控制实例,以便在场景交互期间更新相机的位置。最后,我们需要定义将映射到着色器的 JavaScript 变量。
为了完成这些任务,我们将使用我们的架构中的Camera.js、Controls.js、Program.js和Transforms.js。
配置 WebGL 属性
我们需要初始化和配置我们的canvas和gl实例:
function configure() {
canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
// ...
}
然后,我们需要初始化scene、clock和program:
clock = new Clock();
program = new Program(gl, 'vertex-shader', 'fragment-shader');
scene = new Scene(gl, program);
这些核心组件被全局定义,这样我们就可以在整个应用程序中引用它们。
最后,我们需要设置背景颜色和深度测试属性,如下所示:
gl.clearColor(...clearColor);
gl.clearDepth(1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
设置相机
为了保持简单,camera变量将是全局的,这样我们就可以从 GUI 控件中访问它:
camera = new Camera(Camera.ORBITING_TYPE);
创建相机控制
我们需要实例化一个Controls实例,该实例将鼠标手势绑定到camera动作。第一个参数是我们控制的camera,第二个参数是我们canvas的引用:
new Controls(camera, canvas);
场景变换
一旦我们有了camera,我们就可以使用它来创建一个新的Tranforms实例,如下所示:
transforms = new Transforms(gl, program, camera, canvas);
transforms变量也被声明为全局变量,这样我们就可以在draw函数中使用它来检索当前的矩阵变换并将它们传递给着色器。
创建光源
我们将使用我们的框架中的Light类创建四个光源,配置如下:

首先,我们实例化一个LightsManager实例来管理我们的光源:
lights = new LightsManager();
然后,我们为每个光源创建四个光位置,并对每个位置进行迭代,以唯一地定位每个光源:
lightPositions = {
farLeft: [-1000, 1000, -1000],
farRight: [1000, 1000, -1000],
nearLeft: [-1000, 1000, 1000],
nearRight: [1000, 1000, 1000]
};
Object.keys(lightPositions).forEach(key => {
const light = new Light(key);
light.setPosition(lightPositions[key]);
light.setDiffuse([0.4, 0.4, 0.4]);
light.setSpecular([0.8, 0.8, 0.8]);
lights.add(light)
});
由于每个光源都具有相同的漫反射、环境光和镜面反射属性,我们只需使用lightPositions数据设置动态位置。
映射程序属性和统一变量
接下来,在configure函数内部,我们将 JavaScript 值映射到着色器内的属性和统一变量。
使用之前提到的program实例,我们将设置映射属性和统一变量到着色器的值。代码如下所示:
const attributes = [
'aVertexPosition',
'aVertexNormal',
'aVertexColor'
];
const uniforms = [
'uProjectionMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uLightPosition',
'uWireframe',
'uLd',
'uLs',
'uKa',
'uKd',
'uKs',
'uNs',
'uD',
'uIllum'
];
program.load(attributes, uniforms);
在创建着色器时,请确保着色器属性和统一变量正确映射到 JavaScript 值。这一映射步骤使我们能够轻松地引用属性和统一变量。查看Program.js中的setAttributeLocations和setUniformLocations方法,这些方法由program.load调用。
统一初始化
在映射变量之后,我们可以初始化着色器统一变量,例如灯光:
gl.uniform3fv(program.uLightPosition, lights.getArray('position'));
gl.uniform3fv(program.uLd, lights.getArray('diffuse'));
gl.uniform3fv(program.uLs, lights.getArray('specular'));
默认材质属性如下:
gl.uniform3fv(program.uKa, [1, 1, 1]);
gl.uniform3fv(program.uKd, [1, 1, 1]);
gl.uniform3fv(program.uKs, [1, 1, 1]);
gl.uniform1f(program.uNs, 1);
最后,我们将创建一个floor实例,稍后我们将使用它。我们还将构建描述稍后要加载的汽车模型的数据库:
floor = new Floor(200, 2);
carModelData = {
'BMW i8': {
paintAlias: 'BMW',
partsCount: 25,
path: '/common/models/bmw-i8/part'
}
};
虽然我们在这里只描述了一个汽车模型,但我们将利用这种数据格式,以便我们可以在本章的后面添加其他汽车模型。
这是最终的configure函数,您可以在ch09_02_showroom.html源代码中找到:
function configure() {
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(...clearColor);
gl.clearDepth(1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
program = new Program(gl, 'vertex-shader', 'fragment-shader');
const attributes = [
'aVertexPosition',
'aVertexNormal',
'aVertexColor'
];
const uniforms = [
'uProjectionMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uLightPosition',
'uWireframe',
'uLd',
'uLs',
'uKa',
'uKd',
'uKs',
'uNs',
'uD',
'uIllum'
];
program.load(attributes, uniforms);
scene = new Scene(gl, program);
clock = new Clock();
camera = new Camera(Camera.ORBITING_TYPE);
new Controls(camera, canvas);
transforms = new Transforms(gl, program, camera, canvas);
lights = new LightsManager();
lightPositions = {
farLeft: [-1000, 1000, -1000],
farRight: [1000, 1000, -1000],
nearLeft: [-1000, 1000, 1000],
nearRight: [1000, 1000, 1000]
};
Object.keys(lightPositions).forEach(key => {
const light = new Light(key);
light.setPosition(lightPositions[key]);
light.setDiffuse([0.4, 0.4, 0.4]);
light.setSpecular([0.8, 0.8, 0.8]);
lights.add(light)
});
gl.uniform3fv(program.uLightPosition, lights.getArray('position'));
gl.uniform3fv(program.uLd, lights.getArray('diffuse'));
gl.uniform3fv(program.uLs, lights.getArray('specular'));
gl.uniform3fv(program.uKa, [1, 1, 1]);
gl.uniform3fv(program.uKd, [1, 1, 1]);
gl.uniform3fv(program.uKs, [1, 1, 1]);
gl.uniform1f(program.uNs, 1);
floor = new Floor(200, 2);
carModelData = {
'BMW i8': {
paintAlias: 'BMW',
partsCount: 25,
path: '/common/models/bmw-i8/part'
}
};
}
我们已经完成了场景的设置。接下来,我们将实现load函数。
加载汽车
在load函数内部,我们将下载一些背景资产,以便我们可以将它们加载到我们的应用程序中。
当描述汽车的 JSON 文件可用时,我们只需使用scene实例来加载这些文件。请注意,通常不会有现成的 JSON 文件。在这种情况下,有专门的设计工具,如 Blender,它可以显著帮助创建和转换可消费的模型。
话虽如此,我们将使用在blendswap.org.上可用的预构建模型。所有这些模型都是公开可用的,并且免费使用和分发。在我们能够使用这些模型之前,我们需要将它们导出为中间文件格式,从而我们可以从中提取几何形状和材质属性以创建适当的 JSON 文件。我们将使用的文件格式是Wavefront OBJ。
导出 Blender 模型
本练习的所有资产都包含在这本书的源代码中。但是,如果您想通过转换模型的步骤,以下是步骤。对于这个练习,我们将使用 Blender(v2.6)。
Blender 如果您没有 Blender,您可以从www.blender.org/download/下载适用于您的操作系统版本。
一旦将汽车模型导入到 Blender 中,您需要将其导出为 OBJ 文件。为此,请转到文件|导出|Wavefront (.obj),如下面的截图所示:

在导出 OBJ面板中,确保以下选项处于激活状态:
-
应用修改器:这将写入场景中由数学运算产生的顶点,而不是直接建模。如果您不检查此选项,模型可能在 WebGL 场景中看起来不完整。
-
写入材质:Blender 将创建匹配的材质模板库(MTL)文件。我们将在下一节中详细介绍这一点。
-
三角化面:Blender 将写入索引作为三角形。这对于 WebGL 渲染是理想的。
-
实体作为 OBJ 对象:此配置将识别 Blender 场景中的每个对象为 OBJ 文件中的对象。
-
材质组:如果 Blender 场景中的对象有多个材质,例如,一个可以由铝和橡胶制成的汽车轮胎,那么该对象将被细分为组,每个组对应 OBJ 文件中的一个材质。
OBJ 文件。然后,点击导出。一旦检查了这些导出参数,请选择目录和文件名:
理解 OBJ 格式
OBJ 文件中有几种类型的定义。让我们通过一个简单的示例逐行介绍它们。我们将剖析一个名为square.obj的样本文件,我们将从名为square.blend的 Blender 文件中导出它。此文件表示一个分成两部分的正方形,一部分涂成红色,另一部分涂成蓝色,如图所示:

当我们将 Blender 模型导出为 OBJ 格式时,生成的文件通常以注释开始:
# Blender v2.62 (sub 0) OBJ
File: 'squares.blend'
# www.blender.org
这些是注释,并且它们在行首用哈希#符号表示。
接下来,我们通常会找到一行引用此 OBJ 文件使用的材质模板库。此行将以关键字mtllib开头,后面跟材质文件的名称:
mtllib square.mtl
在 OBJ 文件中,几何体可以通过几种方式组合成实体。我们可以找到以前缀o开头的行,后面跟对象名称,或者以前缀g开头的行,后面跟组名称:
o squares_mesh
在对象声明之后,接下来的行将引用顶点v,可选地引用顶点法线vn和纹理坐标vt。需要注意的是,在 OBJ 格式中,对象中的所有组共享顶点。也就是说,在定义组时,不会找到引用顶点的行,因为假设在定义对象时已经定义了所有顶点数据:
v 1.0 0.0 -2.0
v 1.0 0.0 0.0
v -1.0 0.0 0.0
v -1.0 0.0 -2.0
v 0.0 0.0 0.0
v 0.0 0.0 -2.0
vn 0.0 1.0 0.0
在我们的例子中,我们指示 Blender 导出组材质。这意味着具有不同材质属性集的对象的每个部分都将作为组出现在 OBJ 文件中。在这个例子中,我们定义了一个具有两个组(squares_mesh_blue和squares_mesh_red)和两个相应的材质(蓝色和红色)的对象:
g squares_mesh_blue
如果使用了材质,则在组声明之后的行将是该组使用的材质。在这种情况下,只需要材质的名称。假设该材质的属性定义在 OBJ 文件开头声明的 MTL 文件中:
usemtl blue
以前缀s开头的行指的是多边形上的平滑着色。虽然在这里提到了,但在解析 OBJ 文件到 JSON 文件时,我们不会使用这个定义:
s off
以f开头的行指的是面。有不同方式来表示面。让我们看看它们。
顶点
f i1 i2 i3...
在这个配置中,每个面元素对应一个顶点索引。根据每个面的索引数,你可能会有三角形、矩形或多边形面。然而,我们已经指示 Blender 使用三角形面来创建 OBJ 文件。否则,我们需要在调用drawElements之前将多边形分解成三角形。
顶点/纹理坐标
f i1/t1 i2/t2 i3/t3...
在这种组合中,每个顶点索引似乎都跟着一个正斜杠和纹理坐标索引。通常在用vt在对象级别定义纹理坐标时,你会找到这种组合。
顶点/纹理坐标/法线
f i1/t1/n1 i2/t2/n2 i3/t3/n3...
这里是一个正常的索引,它已被添加为配置中的第三个元素。如果纹理坐标和顶点法线都在对象级别定义,你很可能会在组级别看到这种配置。
顶点//法线
也可能存在法线已定义但纹理坐标未定义的情况。在这种情况下,面配置的第二部分缺失:
f i1//n1 i2//n2 i3//n3...
对于square.obj来说,它看起来是这样的:
f 6//1 4//1 3//1
f 6//1 3//1 5//1
注意,面是通过索引定义的。在我们的例子中,我们定义了一个分成两部分的正方形。在这里,我们可以看到所有顶点共享相同的法线,该法线已用索引1标识。
文件中剩余的行代表红色组:
g squares_mesh_red
usemtl red
f 1//1 6//1 5//1
f 1//1 5//1 2//1
正如我们之前提到的,属于同一对象的组共享索引。
解析 OBJ 文件
在将我们的汽车导出为 OBJ 格式后,下一步是将 OBJ 文件解析成我们可以加载到场景中的 JSON 文件。我们已经在common/models/obj-parser.py中包含了为此步骤开发的解析器。这个解析器有以下特点:
- 它是用 Python 编写的(对于 OBJ 解析器来说相当常见),可以通过以下格式在命令行中调用:
obj-parser.py arg1 arg2
- 其中
arg1是解析的 OBJ 文件名,arg2是 MTL 文件名。两种情况下都需要文件扩展名。例如:
obj-parser.py square.obj square.mtl
-
它为每个 OBJ 组创建一个 JSON 文件。
-
它会在(如果已定义)材质模板库中搜索每个组的材质属性,并将它们添加到相应的 JSON 文件中。
-
它将为每个组计算适当的索引。请记住,OBJ 组共享索引。由于我们为每个组创建一个独立的 WebGL 对象,每个对象都需要从
0开始的索引。解析器会为你处理这一点。
Python
如果你系统上没有安装 Python,你可以从www.python.org/或 https://anaconda.org/anaconda/python获取。
以下图表总结了从 Blender 场景创建 JSON 文件所需的程序:

将汽车加载到我们的 WebGL 场景中
现在我们已经将汽车存储为 JSON 文件,它们就可以在我们的 WebGL 场景中使用。首先,我们必须让用户选择要可视化的汽车。也就是说,默认加载一辆汽车仍然是一个好主意。为此,我们将在load函数内部编写以下代码:
function goHome() {
camera.goHome([0, 0.5, 10]);
camera.setFocus([0, 0, 0]);
camera.setAzimuth(25);
camera.setElevation(-11);
}
function load() {
goHome();
loadCar('BMW i8');
}
我们调用辅助函数goHome,将camera位置设置为我们场景中的特定点。这是因为我们稍后将其用作重置camera位置的方式。然后,我们调用loadCar,这是我们提供要加载的汽车的key(例如,BMW i8)的地方,我们从configure中定义的carModelData中提供。让我们看看loadCar的样子:
function loadCar(model) {
scene.objects = [];
scene.add(floor);
const { path, partsCount } = carModelData[model];
scene.loadByParts(path, partsCount);
selectedCar = model;
}
此函数清除我们scene中的所有对象,添加已经创建的floor实例,并从carModelData对象中提取必要的数据,例如模型的path和要加载的部分数量。
渲染
让我们退一步,评估整体情况。我们之前提到,在我们的架构中,我们定义了三个主要函数,这些函数定义了我们的 WebGL 应用程序的生命周期。这些函数是configure、load和draw。
到目前为止,我们已经通过编写configure函数的代码来设置场景。之后,我们创建了我们的 JSON 汽车并通过编写load函数的代码来加载它们。现在,我们将实现第三个函数:draw函数。
代码相当标准,几乎与我们在前几章中编写的draw函数完全相同。以下代码演示了,我们设置了将要绘制的区域,并清除该区域。然后,我们检查摄像机的视角并处理scene中的每个对象。
一个重要的考虑因素是我们需要确保我们正确地将 JSON 对象中定义的材料属性映射到适当的着色器 uniform。
让我们开始实现draw函数:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
transforms.updatePerspective();
// ...
}
首先,我们设置我们的视口并清除场景,然后通过使用我们在configure中初始化的transforms实例应用透视更新。
然后,我们转向场景中的对象:
try {
scene.traverse(object => {
if (!object.visible) return;
transforms.calculateModelView();
transforms.push();
transforms.setMatrixUniforms();
transforms.pop();
gl.uniform3fv(program.uKa, object.Ka);
gl.uniform3fv(program.uKd, object.Kd);
gl.uniform3fv(program.uKs, object.Ks);
gl.uniform1f(program.uNs, object.Ns);
gl.uniform1f(program.uD, object.d);
gl.uniform1i(program.uIllum, object.illum);
// Bind
gl.bindVertexArray(object.vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, object.ibo);
if (object.wireframe) {
gl.uniform1i(program.uWireframe, 1);
gl.drawElements(gl.LINES, object.indices.length, gl.UNSIGNED_SHORT,
0);
}
else {
gl.uniform1i(program.uWireframe, 0);
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
});
}
catch (error) {
console.error(error);
}
查看之前章节中定义的 uniforms 列表可能会有所帮助。我们需要确保所有的着色器 uniform 都与对象属性配对。
以下图表显示了draw函数内部发生的流程:

每个汽车部件都是一个不同的 JSON 文件。draw 函数遍历 scene 内的所有这些部件。对于每个部件,材质属性作为 uniform 传递给着色器,几何形状作为属性(从相应的 VBO 中读取数据)传递。最后,执行绘制调用(drawElements)。结果看起来像这样:

这是可以在 ch09_02_showroom.html 中找到的最终 JavaScript 源代码:
<html>
<head>
<title>Real-Time 3D Graphics with WebGL2</title>
<link rel="shortcut icon" type="image/png"
href="/common/images/favicon.png" />
<!-- libraries -->
<link rel="stylesheet" href="/common/lib/normalize.css">
<script type="text/javascript" src="img/dat.gui.js"></script>
<script type="text/javascript" src="img/gl-matrix.js"></script>
<!-- modules -->
<script type="text/javascript" src="img/utils.js"></script>
<script type="text/javascript" src="img/EventEmitter.js"></script>
<script type="text/javascript" src="img/Camera.js"></script>
<script type="text/javascript" src="img/Clock.js"></script>
<script type="text/javascript" src="img/Controls.js"></script>
<script type="text/javascript" src="img/Floor.js"></script>
<script type="text/javascript" src="img/Light.js"></script>
<script type="text/javascript" src="img/Program.js"></script>
<script type="text/javascript" src="img/Scene.js"></script>
<script type="text/javascript" src="img/Texture.js"></script>
<script type="text/javascript" src="img/Transforms.js"></script>
以下代码是用于顶点着色器的:
<script id="vertex-shader" type="x-shader/x-vertex">
#version 300 es
precision mediump float;
const int numLights = 4;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
uniform vec3 uLightPosition[numLights];
in vec3 aVertexPosition;
in vec3 aVertexNormal;
out vec3 vNormal;
out vec3 vLightRay[numLights];
out vec3 vEye[numLights];
void main(void) {
vec4 vertex = uModelViewMatrix * vec4(aVertexPosition, 1.0);
vec4 lightPosition = vec4(0.0);
for(int i= 0; i < numLights; i++) {
lightPosition = vec4(uLightPosition[i], 1.0);
vLightRay[i] = vertex.xyz - lightPosition.xyz;
vEye[i] = -vec3(vertex.xyz);
}
vNormal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
gl_Position = uProjectionMatrix * uModelViewMatrix *
vec4(aVertexPosition, 1.0);
}
</script>
以下代码是用于片段着色器的:
<script id="fragment-shader" type="x-shader/x-fragment">
#version 300 es
precision mediump float;
const int numLights = 4;
uniform vec3 uLd[numLights];
uniform vec3 uLs[numLights];
uniform vec3 uLightPosition[numLights];
uniform vec3 uKa;
uniform vec3 uKd;
uniform vec3 uKs;
uniform float uNs;
uniform float uD;
uniform int uIllum;
uniform bool uWireframe;
in vec3 vNormal;
in vec3 vLightRay[numLights];
in vec3 vEye[numLights];
out vec4 fragColor;
void main(void) {
if (uWireframe || uIllum == 0) {
fragColor = vec4(uKd, uD);
return;
}
vec3 color = vec3(0.0);
vec3 light = vec3(0.0);
vec3 eye = vec3(0.0);
vec3 reflection = vec3(0.0);
vec3 normal = normalize(vNormal);
if (uIllum == 1) {
for (int i = 0; i < numLights; i++) {
light = normalize(vLightRay[i]);
normal = normalize(vNormal);
color += (uLd[i] * uKd * clamp(dot(normal, -light), 0.0, 1.0));
}
}
if (uIllum == 2) {
for (int i = 0; i < numLights; i++) {
eye = normalize(vEye[i]);
light = normalize(vLightRay[i]);
reflection = reflect(light, normal);
color += (uLd[i] * uKd * clamp(dot(normal, -light), 0.0, 1.0));
color += (uLs[i] * uKs * pow(max(dot(reflection, eye), 0.0), uNs)
* 4.0);
}
}
fragColor = vec4(color, uD);
}
</script>
以下是带有适当全局变量定义的应用程序代码:
<script type="text/javascript">
'use strict';
let gl, program, scene, clock, camera, transforms, lights,
floor, selectedCar, lightPositions, carModelData,
clearColor = [0.9, 0.9, 0.9, 1];
以下是为配置步骤:
function configure() {
const canvas = utils.getCanvas('webgl-canvas');
utils.autoResizeCanvas(canvas);
gl = utils.getGLContext(canvas);
gl.clearColor(...clearColor);
gl.clearDepth(1);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
program = new Program(gl, 'vertex-shader', 'fragment-shader');
const attributes = [
'aVertexPosition',
'aVertexNormal',
'aVertexColor'
];
const uniforms = [
'uProjectionMatrix',
'uModelViewMatrix',
'uNormalMatrix',
'uLightPosition',
'uWireframe',
'uLd',
'uLs',
'uKa',
'uKd',
'uKs',
'uNs',
'uD',
'uIllum'
];
program.load(attributes, uniforms);
scene = new Scene(gl, program);
clock = new Clock();
camera = new Camera(Camera.ORBITING_TYPE);
new Controls(camera, canvas);
transforms = new Transforms(gl, program, camera, canvas);
lights = new LightsManager();
lightPositions = {
farLeft: [-1000, 1000, -1000],
farRight: [1000, 1000, -1000],
nearLeft: [-1000, 1000, 1000],
nearRight: [1000, 1000, 1000]
};
Object.keys(lightPositions).forEach(key => {
const light = new Light(key);
light.setPosition(lightPositions[key]);
light.setDiffuse([0.4, 0.4, 0.4]);
light.setSpecular([0.8, 0.8, 0.8]);
lights.add(light)
});
gl.uniform3fv(program.uLightPosition, lights.getArray('position'));
gl.uniform3fv(program.uLd, lights.getArray('diffuse'));
gl.uniform3fv(program.uLs, lights.getArray('specular'));
gl.uniform3fv(program.uKa, [1, 1, 1]);
gl.uniform3fv(program.uKd, [1, 1, 1]);
gl.uniform3fv(program.uKs, [1, 1, 1]);
gl.uniform1f(program.uNs, 1);
floor = new Floor(200, 2);
carModelData = {
'BMW i8': {
paintAlias: 'BMW',
partsCount: 25,
path: '/common/models/bmw-i8/part'
}
};
}
function goHome() {
camera.goHome([0, 0.5, 5]);
camera.setFocus([0, 0, 0]);
camera.setAzimuth(25);
camera.setElevation(-10);
}
以下代码用于加载所需的资源:
function loadCar(model) {
scene.objects = [];
scene.add(floor);
const { path, partsCount } = carModelData[model];
scene.loadByParts(path, partsCount);
selectedCar = model;
}
function load() {
goHome();
loadCar('BMW i8');
}
以下代码说明了我们绘制场景的位置:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
transforms.updatePerspective();
try {
scene.traverse(object => {
if (!object.visible) return;
transforms.calculateModelView();
transforms.push();
transforms.setMatrixUniforms();
transforms.pop();
gl.uniform3fv(program.uKa, object.Ka);
gl.uniform3fv(program.uKd, object.Kd);
gl.uniform3fv(program.uKs, object.Ks);
gl.uniform1f(program.uNs, object.Ns);
gl.uniform1f(program.uD, object.d);
gl.uniform1i(program.uIllum, object.illum);
// Bind
gl.bindVertexArray(object.vao);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, object.ibo);
if (object.wireframe) {
gl.uniform1i(program.uWireframe, 1);
gl.drawElements(gl.LINES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
else {
gl.uniform1i(program.uWireframe, 0);
gl.drawElements(gl.TRIANGLES, object.indices.length,
gl.UNSIGNED_SHORT, 0);
}
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
});
}
catch (error) {
console.error(error);
}
}
文档加载后,我们使用以下代码初始化应用程序:
function init() {
configure();
load();
clock.on('tick', draw);
}
window.onload = init;
</script>
</head>
<body>
<canvas id="webgl-canvas">
Your browser does not support the HTML5 canvas element.
</canvas>
</body>
</html>
发生了什么?
我们已经介绍了一个演示,它使用了本书中讨论的许多元素。我们使用了之前章节中开发的代码基础设施,并实现了三个主要功能:configure、load 和 draw。正如我们所见,这些函数定义了我们的应用程序的生命周期。
试试看:交互式控件
让我们利用 dat.GUI 为我们的应用程序添加更多交互性和定制功能。请尝试添加以下功能:
- 创建一个下拉菜单,以便您可以从
common/models/目录中提供的以下汽车模型中选择:bmw-i8、audi-r8、ford-mustang和lamborghini-gallardo。
提示 您可以利用 carModelData 声明性地描述汽车模型,并使用已创建的 loadCar 函数以及适当的信息。
- 创建一个颜色选择器来改变加载的汽车的颜色。
提示 通过检查汽车数据文件,您将找到各种指示器,这些指示器表示哪些部件是车身面板。这些在 carModelData 中描述为 paintAlias,可以用来改变场景中每个单独项目的 Kd 属性。
- 创建一个滑块来改变所选汽车的亮度。
提示 您可以再次使用 paintAlias 并更新场景中每个单独项目的 Ks 属性。
以下功能已在 ch09_03_showroom-controls.html 中实现,包括每个单独的灯光、背景颜色、地板可见性等控件:

utils.configureControls
utils.configureControls 方法是在 dat.GUI 接口之上简单抽象的一个方法,用于减少重复并提供描述我们的控件小部件的更声明性方式。您可以直接使用 dat.GUI 或在此基础上构建这个简单的辅助函数。
奖励
你做到了!这有多么酷?!作为奖励,我们在ch10目录下的源代码中为你提供了一些额外的示例。这些奖励示例以虚拟汽车展厅为基础,展示了更多高级功能,供你在未来构建引人入胜的 3D 体验时使用。享受吧!
摘要
让我们总结一下本章所学的内容:
-
我们回顾了本书中开发的概念、架构和代码。
-
我们构建了一个 3D 虚拟汽车展厅应用,展示了所有这些元素是如何结合在一起的。
-
我们了解到设计复杂模型需要专门的工具,例如 Blender。
-
我们介绍了大多数当前 3D 图形格式都需要定义顶点、索引、法线和纹理坐标。
-
我们学习了如何从 Blender 模型中获取所需元素,并将它们解析成我们可以加载到 WebGL 场景中的 JSON 文件。
-
我们学习了如何添加控件小部件以提供定制功能。
在下一章中,我们将提前了解一些在 3D 计算机图形系统中常用的高级技术,包括游戏、模拟和其他 3D 应用。在讨论了这些主题之后,我们还将学习如何在 WebGL 中实现它们。
第十章:高级技术
在本书的前几章中,我们介绍了许多计算机图形学的基础概念,这些概念最终为我们提供了构建 3D 虚拟汽车展台所需的知识和技能。这意味着,到目前为止,你已经拥有了创建丰富的 3D 应用程序所需的全部信息,使用 WebGL。然而,我们只是刚刚触及了 WebGL 的表面!创意地使用着色器、纹理和顶点属性可以产生惊人的效果。在这些最后的章节中,我们将介绍一些高级的 WebGL 概念,这些概念应该会激发你进一步探索的欲望。
在本章中,我们将涵盖以下内容:
-
学习各种后处理效果
-
使用点精灵实现粒子系统
-
理解如何使用正常贴图
-
实现如何使用光线追踪
后处理
后处理是通过使用一个改变最终图像的着色器重新渲染场景图像的过程。你可以将其想象成对场景进行截图(理想情况下每秒60+帧),然后在你的首选图像编辑器中打开它,并应用各种过滤器。当然,区别在于我们可以在实时中这样做!
一些简单的后处理效果示例包括以下:
-
灰度效果
-
褐色调
-
反转颜色
-
胶片颗粒效果
-
模糊效果
-
波浪/眩晕效果
创建这些效果的基本技术相对简单:创建一个与canvas尺寸相同的帧缓冲区,并在draw循环开始时将整个场景渲染到该缓冲区。然后,使用构成帧缓冲区颜色附件的纹理将一个四边形渲染到默认帧缓冲区。在四边形渲染过程中使用的着色器包含了后处理效果。该着色器可以将渲染场景的颜色值转换为四边形,以产生所需的视觉效果。
让我们更详细地研究这个过程的各个步骤。
创建帧缓冲区
我们将用于创建帧缓冲区的代码几乎与我们在第八章,“拾取”中创建的相同。然而,有一些关键的区别值得注意:
const { width, height } = canvas;
// 1\. Init Color Texture
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
// 2\. Init Renderbuffer
const renderbuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
// 3\. Init Framebuffer
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, renderbuffer);
// 4\. Clean up
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
我们使用canvas的width和height来确定我们的缓冲区大小,而不是使用用于拾取器的任意值。因为拾取器缓冲区的内容不是用于屏幕渲染,所以我们不必过多担心分辨率。然而,对于后处理缓冲区,如果输出与canvas的尺寸匹配,我们将获得最佳结果。
由于纹理将与canvas的大小完全相同,并且由于我们将其渲染为全屏四边形,我们创造了一个情况,即纹理将在屏幕上以1:1的比例显示。这意味着不需要应用任何过滤器,并且我们可以使用NEAREST过滤器而不会出现视觉伪影。此外,在需要扭曲纹理坐标的后处理情况下(例如波浪效果),我们会从使用LINEAR过滤器中受益。我们还需要使用CLAMP_TO_EDGE的包裹模式。话虽如此,代码几乎与用于帧缓冲区创建的Picker相同。
创建几何形状
虽然我们可以从文件中加载四边形,但几何形状足够简单,我们可以直接将其包含在代码中。所需的所有内容只是顶点位置和纹理坐标:
// 1\. Define the geometry for the full-screen quad
const vertices = [
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1
];
const textureCoords = [
0, 0,
1, 0,
0, 1,
0, 1,
1, 0,
1, 1
];
// 2\. Create and bind VAO
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// 3\. Init the buffers
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices),
// Configure instructions for VAO
gl.STATIC_DRAW);gl.enableVertexAttribArray(program.aVertexPosition);
gl.vertexAttribPointer(program.aVertexPosition, 3, gl.FLOAT, false, 0, 0);
const textureBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoords), gl.STATIC_DRAW);
// Configure instructions for VAO
gl.enableVertexAttribArray(program.aVertexTextureCoords);
gl.vertexAttribPointer(program.aVertexTextureCoords, 2, gl.FLOAT, false, 0, 0);
// 4\. Clean up
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
设置着色器
后处理绘制的顶点着色器相当简单:
#version 300 es
precision mediump float;
in vec2 aVertexPosition;
in vec2 aVertexTextureCoords;
out vec2 vTextureCoords;
void main(void) {
vTextureCoords = aVertexTextureCoords;
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}
注意,与迄今为止我们使用的其他顶点着色器不同,这个着色器没有使用任何矩阵。这是因为我们在上一步中声明的顶点已经预变换。
回想一下第四章中提到的,我们通过将顶点位置乘以投影矩阵来检索归一化设备坐标。在这里,坐标将所有位置映射到每个轴上的[-1, 1]范围,这代表了整个视口。然而,在这种情况下,我们的顶点位置已经映射到[-1, 1]范围;因此,不需要变换,因为当渲染时,它们将完美映射到视口边界。
片段着色器是大多数有趣操作发生的地方。每个后处理效果都会有不同的片段着色器。让我们以一个简单的灰度效果为例:
#version 300 es
precision mediump float;
uniform sampler2D uSampler;
in vec2 vTextureCoords;
out vec4 fragColor;
void main(void) {
vec4 frameColor = texture(uSampler, vTextureCoords);
float luminance = frameColor.r * 0.3 + frameColor.g * 0.59 + frameColor.b
* 0.11;
fragColor = vec4(luminance, luminance, luminance, frameColor.a);
}
在前面的代码中,我们采样场景渲染的原色(通过uSampler可用)并输出一个加权平均的红、绿、蓝通道的颜色。结果是原始场景的简单灰度版本:

建筑更新
我们添加了一个新的类,PostProcess,以帮助进行后处理效果。此代码位于common/js/PostProcess.js文件中。此类将创建适当的帧缓冲区和四边形几何形状,编译后处理着色器,并设置绘制场景到四边形的渲染。
让我们通过一个示例来看看这个组件是如何工作的!
行动时间:后处理效果
让我们看看一些后处理效果的实际应用:
- 在您的浏览器中打开
ch10_01_post-process.html文件,如下所示:

-
控制下拉菜单允许您在不同的采样效果之间切换。尝试它们以了解它们对场景的影响。我们已经看到了灰度效果,所以让我们单独检查其余的过滤器。
-
反转效果,类似于灰度效果,它只修改颜色输出,反转每个颜色通道:
#version 300 es
precision mediump float;
uniform sampler2D uSampler;
in vec2 vTextureCoords;
out vec4 fragColor;
void main(void) {
vec4 frameColor = texture(uSampler, vTextureCoords);
fragColor = vec4(vec3(1.0) - frameColor.rgb, frameColor.a);
}

- 波浪效果操纵纹理坐标,使场景旋转和摇摆。在这个效果中,我们还提供了当前时间,以便随着时间的变化,扭曲也会改变:
#version 300 es
precision mediump float;
const float speed = 15.0;
const float magnitude = 0.015;
uniform sampler2D uSampler;
uniform float uTime;
in vec2 vTextureCoords;
out vec4 fragColor;
void main(void) {
vec2 wavyCoord;
wavyCoord.s = vTextureCoords.s + sin(uTime + vTextureCoords.t *
speed) * magnitude;
wavyCoord.t = vTextureCoords.t + cos(uTime + vTextureCoords.s *
speed) * magnitude;
fragColor = texture(uSampler, wavyCoord);
}

- 模糊效果在当前像素周围采样几个像素,并使用加权混合来产生一个片段输出,该输出是其邻居的平均值。这给场景带来了一种模糊感。一个新的统一变量
uInverseTextureSize提供了视口宽度和高度的倒数。我们使用这些值来准确地在纹理中定位单个像素。例如,vTextureCoords.x + 2 * uInverseTextureSize.x将正好在原始纹理坐标的左侧 2 个像素处:
#version 300 es
precision mediump float;
uniform sampler2D uSampler;
uniform vec2 uInverseTextureSize;
in vec2 vTextureCoords;
out vec4 fragColor;
vec4 offsetLookup(float xOff, float yOff) {
return texture(
uSampler,
vec2(
vTextureCoords.x + xOff * uInverseTextureSize.x,
vTextureCoords.y + yOff * uInverseTextureSize.y
)
);
}
void main(void) {
vec4 frameColor = offsetLookup(-4.0, 0.0) * 0.05;
frameColor += offsetLookup(-3.0, 0.0) * 0.09;
frameColor += offsetLookup(-2.0, 0.0) * 0.12;
frameColor += offsetLookup(-1.0, 0.0) * 0.15;
frameColor += offsetLookup(0.0, 0.0) * 0.16;
frameColor += offsetLookup(1.0, 0.0) * 0.15;
frameColor += offsetLookup(2.0, 0.0) * 0.12;
frameColor += offsetLookup(3.0, 0.0) * 0.09;
frameColor += offsetLookup(4.0, 0.0) * 0.05;
fragColor = frameColor;
}

- 我们的最后一个例子是胶片颗粒效果。这个效果使用噪点纹理来创建颗粒场景,模拟使用老式相机的效果。这个例子很重要,因为它展示了在渲染时除了帧缓冲区外还使用了第二个纹理:
#version 300 es
precision mediump float;
const float grainIntensity = 0.1;
const float scrollSpeed = 4000.0;
uniform sampler2D uSampler;
uniform sampler2D uNoiseSampler;
uniform vec2 uInverseTextureSize;
uniform float uTime;
in vec2 vTextureCoords;
out vec4 fragColor;
void main(void) {
vec4 frameColor = texture(uSampler, vTextureCoords);
vec4 grain = texture(
uNoiseSampler,
vTextureCoords * 2.0 + uTime * scrollSpeed *
uInverseTextureSize
);
fragColor = frameColor - (grain * grainIntensity);
}

刚才发生了什么?
所有这些效果都是通过在输出到屏幕之前操纵渲染的图像来实现的。由于这些效果处理的几何量很小,因此它们是高效的,无论场景的复杂程度如何。然而,随着canvas的大小或后处理着色器的复杂性的增加,性能可能会受到影响。
尝试一下:趣味屋镜面效果
要创建一个在视口中心附近拉伸图像并在边缘挤压的效果,需要什么?
点精灵
粒子效果是许多 3D 应用程序和游戏中常用的技术。粒子效果是对通过渲染粒子群(以点、纹理四边形或重复的几何形状显示)创建的任何特殊效果的通称,通常在单个粒子上作用一些简单的物理模拟。它们可以用来模拟烟雾、火焰、子弹、爆炸、水、火花以及许多其他难以用单个几何模型表示的效果。
渲染粒子的一种非常有效的方法是使用点精灵。在这本书中,我们一直在渲染三角形原语,但如果你使用POINTS原语类型渲染顶点,那么每个顶点将作为屏幕上的单个像素渲染。点精灵是POINTS原语渲染的扩展,其中每个点都提供了一个大小并在着色器中进行纹理化。
通过在顶点着色器中设置gl_PointSize值来创建点精灵。它可以设置为常量值或从着色器输入计算出的值。如果设置为大于一的数字,点将以一个始终面向屏幕的四边形渲染(也称为广告牌)。四边形以原始点为中心,其宽度和高度等于以像素为单位的gl_PointSize:

当点精灵渲染时,它还会为四边形生成纹理坐标,覆盖从左上角到右下角的简单0-1范围:

纹理坐标可以通过内置的vec2 gl_PointCoord在片段着色器中访问。结合这些属性,我们得到一个简单的点精灵顶点着色器,看起来像这样:
#version 300 es
precision mediump float;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform float uPointSize;
in vec4 aParticle;
out float vLifespan;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aParticle.xyz,
1.0);
vLifespan = aParticle.w;
gl_PointSize = uPointSize * vLifespan;
}
相应的片段着色器看起来像这样:
#version 300 es
precision mediump float;
uniform sampler2D uSampler;
in float vLifespan;
out vec4 fragColor;
void main(void) {
vec4 texColor = texture(uSampler, gl_PointCoord);
fragColor = vec4(texColor.rgb, texColor.a * vLifespan);
}
以下是一个适当的绘制命令的示例:
gl.drawArrays(gl.POINTS, 0, vertexCount);
这将顶点缓冲区中的每个点渲染为16x16纹理。
行动时间:火花喷泉
让我们看看如何使用点精灵创建一个火花喷泉:
- 在您的浏览器中打开
ch10_02_point-sprites.html文件:

-
此示例展示了使用点精灵的简单火花喷泉效果。您可以通过使用滑块调整粒子的尺寸和寿命。
-
粒子模拟是通过维护一个由位置、速度和寿命组成的粒子列表来完成的。在每一帧中,我们遍历列表并根据速度移动粒子;我们还应用重力并减少剩余寿命。一旦粒子的寿命达到
0,它就会重置到原点,并带有随机速度和更新的寿命。 -
在粒子的模拟迭代中,粒子位置和寿命被复制到一个数组中,然后用于更新顶点缓冲区。这个顶点缓冲区就是用于在屏幕上生成精灵的渲染内容。
-
让我们实验一下控制模拟的其他一些值,看看它们如何影响场景。在您的编辑器中打开
ch10_02_point-sprites.html。 -
首先,在
configure函数的底部定位到对configureParticles的调用。作为参数传递的数字,最初设置为1024,决定了创建多少个粒子。尝试将其更改为一个较低或较高的值,以查看它对粒子系统的影响。但请注意,极端高的值(例如,数百万)可能会导致性能问题。 -
接下来,找到
resetParticle函数。每当创建或重置粒子时都会调用此函数。这里有几个值可以显著影响场景的渲染方式:
function resetParticle(particle) {
particle.position = [0, 0, 0];
particle.velocity = [
Math.random() * 20 - 10,
Math.random() * 20,
Math.random() * 20 - 10,
];
particle.lifespan = Math.random() * particleLifespan;
particle.remainingLife = particle.lifespan;
}
-
particle.position是粒子的x、y、z起始坐标。最初,所有点都从世界原点(0, 0, 0)开始,但这也可能被设置为任何值。通常希望粒子从另一个物体的位置开始,以产生物体产生粒子的印象。你也可以随机化位置,使粒子看起来在给定区域内。 -
particle.velocity是粒子的初始速度。在这里,你可以看到它已经被随机化,这样粒子在远离原点时就会分散开来。随机方向移动的粒子看起来更像爆炸或喷雾,而那些朝同一方向移动的粒子则给人一种稳定流的感觉。在这种情况下,y值被设计为始终为正,而x和z值可以是正也可以是负。尝试增加或减少这些速度值或从其中一个分量中移除随机元素,看看会发生什么。 -
最后,
particle.lifespan决定了粒子在重置之前显示的时间长度。这使用控制器的值同时进行随机化,以提供视觉多样性。如果你从粒子寿命中移除随机元素,所有粒子将同时到期并重置,导致类似烟花般的爆发粒子。 -
接下来,找到
updateParticles函数。这个函数每帧调用一次,用于更新所有粒子的位置和速度,在将新值推送到顶点缓冲区之前。值得注意的是,在操纵模拟行为方面,重力是在函数中途应用的:
function updateParticles(elapsed) {
// Loop through all the particles in the array
particles.forEach((particle, i) => {
// Track the particles lifespan
particle.remainingLife -= elapsed;
if (particle.remainingLife <= 0) {
// Once the particle expires, reset it to the origin with a
// new velocity
resetParticle(particle);
}
// Update the particle position
particle.position[0] += particle.velocity[0] * elapsed;
particle.position[1] += particle.velocity[1] * elapsed;
particle.position[2] += particle.velocity[2] * elapsed;
// Apply gravity to the velocity
particle.velocity[1] -= 9.8 * elapsed;
if (particle.position[1] < 0) {
// Allow particles to bounce off the floor
particle.velocity[1] *= -0.75;
particle.position[1] = 0;
}
// Update the corresponding values in the array
const index = i * 4;
particleArray[index] = particle.position[0];
particleArray[index + 1] = particle.position[1];
particleArray[index + 2] = particle.position[2];
particleArray[index + 3] = particle.remainingLife /
particle.lifespan;
});
// Once we are done looping through all the particles, update the
// buffer once
gl.bindBuffer(gl.ARRAY_BUFFER, particleBuffer);
gl.bufferData(gl.ARRAY_BUFFER, particleArray, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}
-
这里的
9.8是对y分量随时间施加的加速度。换句话说,这就是重力。我们可以完全移除这个计算来创建一个粒子沿其原始轨迹无限期漂浮的环境。我们可以增加这个值使粒子快速下落(赋予它们沉重的外观),或者我们可以改变减速施加的分量,以便我们可以改变重力的方向。例如,从velocity[0]中减去会使粒子侧向下落。 -
这也是我们应用简单的碰撞响应与地板的地方。任何
y位置小于0(在地板下方)的粒子,其速度会被反转并减小。这给了我们一个真实的弹跳运动。我们可以通过减小乘数(即,0.25而不是0.75)来使粒子不那么弹跳,或者通过简单地将y速度设置为0来完全消除弹跳。此外,我们可以通过移除对y < 0的检查来移除地板,这将允许粒子无限期地落下。 -
值得注意的是,我们可以用不同的纹理实现不同的效果。尝试在
configure函数中将spriteTexture的路径更改,看看使用不同图像时的效果。
发生了什么?
我们已经看到如何使用点精灵高效地渲染粒子效果。我们也看到了我们可以操纵粒子模拟以实现各种效果的不同方式。
尝试一下:气泡!
此处的粒子系统可以用来模拟向上漂浮的气泡或烟雾,就像弹跳的火花一样容易。你将如何改变模拟,使粒子漂浮而不是下落?
法线贴图
在实时 3D 应用中,法线贴图是一种非常强大且流行的技术。法线贴图通过在纹理图中存储表面法线,从而在低多边形模型上创建高度详细几何形状的错觉,然后可以用来计算对象的光照。这种方法在现代游戏中特别受欢迎,因为它允许开发者在高性能和场景细节之间取得平衡。
通常,光照是通过使用正在渲染的三角形的表面法线来计算的,这意味着整个多边形将被作为一个连续、平滑的表面来照亮:

使用法线贴图时,表面法线被编码在纹理中的法线所取代,这些法线给出了粗糙或凹凸表面的外观。请注意,使用法线贴图时,实际几何形状并未改变——只有光照方式发生了变化。如果你从侧面看一个法线贴图的多边形,它仍然看起来是完全平坦的:

用于存储法线的纹理称为法线图,它通常与一个特定的漫反射纹理配对,以补充法线图试图模拟的表面。例如,这里有一些石板的漫反射纹理和相应的法线图:

你可以看到法线贴图包含与漫反射纹理相似的图案。两者结合,给人一种石头表面粗糙且有凹凸纹理,而灰缝则是凹进去的印象。
映射技术 虽然法线贴图是一种高效地为资产添加更多细节的强大技术,但还有许多其他遵循相同推理路线的映射技术。您可以在以下链接中了解一些其他可用的技术:en.wikipedia.org/wiki/Category:Texture_mapping。
法线图包含自定义格式的颜色信息,可以在运行时由着色器解释为片段法线。片段法线本质上与顶点法线相同:它是一个指向表面的三分量向量。法线纹理将法线向量的三个分量编码到纹理的 texel 颜色的三个通道中。红色代表x轴,绿色代表y轴,蓝色代表z轴。
编码的法线通常存储在切线空间中,而不是世界空间或对象空间。切线空间是面纹理坐标的坐标系。法线图通常呈蓝色,因为它们所表示的法线通常指向表面外部,因此具有更大的z分量。
行动时间:法线图的实际应用
让我们通过一个示例来展示法线图的实际应用:
- 在浏览器中打开
ch10_03_normal-map.html文件:

-
旋转立方体以查看法线图对光照立方体产生的影响。请记住,立方体的轮廓并没有改变。让我们看看这个效果是如何实现的。
-
首先,我们需要向我们的顶点缓冲区中添加一个新的属性。计算光照的切线空间坐标需要三个向量:法线、切线和偏切线:

-
我们已经介绍了法线,现在让我们研究其他两个向量。切线代表相对于多边形表面的纹理的向上(正
y)向量。偏切线代表相对于多边形表面的纹理的向左(正x)向量。 -
我们只需要提供三个向量中的两个作为顶点属性。传统上,法线和切线就足够了,因为第三个向量在顶点着色器中是通过其他两个向量的叉积计算得出的。
-
3D 建模软件通常可以为你生成切线。然而,如果没有提供,它们可以从顶点位置和纹理坐标中计算出来,类似于计算顶点法线:
切线生成算法
我们在这里不会介绍这个算法,但为了参考,它已经在common/js/utils.js中作为calculateTangents实现,并在scene.add中使用。
const tangentBufferObject = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, tangentBufferObject);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(utils.calculateTangents(
object.vertices,
object.textureCoords,
object.indices
)),
gl.STATIC_DRAW
);
- 在顶点着色器中,在
ch10_03_normal-map.html的顶部,切线需要通过法线矩阵进行变换。两个变换后的向量可以用来计算第三个向量:
// Transformed normal position
vec3 normal = vec3(uNormalMatrix * vec4(aVertexNormal, 1.0));
vec3 tangent = vec3(uNormalMatrix * vec4(aVertexTangent, 1.0));
vec3 bitangent = cross(normal, tangent);
- 这三个向量可以用来创建一个矩阵,将向量转换到切线空间:
mat3 tbnMatrix = mat3(
tangent.x, bitangent.x, normal.x,
tangent.y, bitangent.y, normal.y,
tangent.z, bitangent.z, normal.z
);
- 与之前在顶点着色器中应用光照不同,大部分光照计算需要在片段着色器中进行,以便我们可以结合纹理中的法线。也就是说,我们在将光方向传递到片段着色器之前,将其转换为切线空间:
// Light direction, from light position to vertex
vec3 lightDirection = uLightPosition - vertex.xyz;
vTangentEyeDir = eyeDirection * tbnMatrix;
- 在片段着色器中,我们首先从法线贴图纹理中提取切线空间法线。由于纹理 texel 不存储负值,法线分量必须编码以从
[-1, 1]映射到[0, 1]范围。因此,在着色器中使用之前,它们必须被解包到正确的范围。执行此操作的算法可以用 ESSL 轻松表达:
// Unpack tangent-space normal from texture
vec3 normal = normalize(2.0 * (texture(uNormalSampler, vTextureCoords).rgb - 0.5));
- 光照的计算几乎与顶点光照模型相同,这是通过使用纹理法线和切线空间光照方向来完成的:
// Normalize the light direction and determine how much light is hitting this point
vec3 lightDirection = normalize(vTangentLightDir);
float lambertTerm = max(dot(normal, lightDirection), 0.20);
// Calculate Specular level
vec3 eyeDirection = normalize(vTangentEyeDir);
vec3 reflectDir = reflect(-lightDirection, normal);
float Is = pow(clamp(dot(reflectDir, eyeDirection), 0.0, 1.0), 8.0);
// Combine lighting and material colors
vec4 Ia = uLightAmbient * uMaterialAmbient;
vec4 Id = uLightDiffuse * uMaterialDiffuse * texture(uSampler, vTextureCoords) * lambertTerm;
fragColor = Ia + Id + Is;
- 为了强调法线映射效果,代码示例还包括了镜面项的计算。
刚才发生了什么?
我们已经看到,我们可以使用编码到纹理中的法线信息,在不添加额外几何形状的情况下为我们的光照模型添加新的复杂度。
光线追踪在片段着色器中
一种常见的(尽管有些不切实际)技术,用于展示着色器的强大功能,是使用它们来光线追踪场景。到目前为止,我们的渲染都是通过多边形光栅化完成的,这是 WebGL 所采用的基于三角形的渲染的技术术语。光线追踪是一种替代渲染技术,它追踪光线在场景中与数学定义的几何体交互时的路径。
与传统的多边形渲染相比,光线追踪具有几个优势。主要优势包括由于更精确的光照模型而创建更逼真的场景,该模型可以轻松地考虑反射和反射光照等因素。尽管如此,光线追踪通常比多边形渲染慢得多,这也是它不常用于实时应用的原因。
通过创建一系列从相机位置开始并穿过视口中的每个像素的光线(由一个起点和方向表示),可以实现对场景的光线追踪。然后,这些光线与场景中的每个对象进行测试,以确定是否存在任何交点。如果发生交点,则返回与光线起点最近的交点,从而确定渲染像素的颜色:

尽管有许多算法可以用来确定交点的颜色——从简单的漫反射光照到从其他物体反射出来的光线的多次弹跳以模拟反射——但我们将保持我们的示例简单。重要的是要注意,渲染的场景将完全是着色器代码的产物。
行动时间:检查光线追踪场景
让我们通过一个示例来展示光线追踪的强大功能:
- 在您的浏览器中打开
ch10_04_ray-tracing.html文件。您应该会看到一个场景,其中有一个简单的被光照、上下摆动的球体,就像以下截图所示:

- 为了触发着色器,我们需要一种绘制全屏四边形的方法。幸运的是,我们在这个章节早期的一些后处理示例中有一个类可以帮助我们做到这一点。由于我们没有要处理的场景,我们可以省略大部分渲染代码并简化 JavaScript 的
draw函数:
function draw() {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Checks to see if the framebuffer needs to be re-sized to match
// the canvas
post.validateSize();
post.bind();
// Render the fullscreen quad
post.draw();
}
-
就这些。我们场景的其余部分将内置到片段着色器中。
-
我们着色器的核心有两个函数:一个用于确定光线是否与球体相交,另一个用于确定球面上某点的法线。我们使用球体,因为它们通常是射影最简单的几何形状,而且它们也是用多边形难以准确表示的几何形状:
// ro is the ray origin.
// rd is the ray direction.
// s is the sphere
float sphereIntersection(vec3 ro, vec3 rd, vec4 s) {
// Transform the ray into object space
vec3 oro = ro - s.xyz;
float a = dot(rd, rd);
float b = 2.0 * dot(oro, rd);
// w is the sphere radius
float c = dot(oro, oro) - s.w * s.w;
float d = b * b - 4.0 * a * c;
// No intersection
if (d < 0.0) return d;
return (-b - sqrt(d)) / 2.0;
}
vec3 sphereNormaml(vec3 pt, vec4 s) {
return (pt - s.xyz) / s.w;
}
- 接下来,我们将使用这两个函数来确定光线是否与球体相交(如果相交的话),以及在该点球体的法线和颜色。为了简化问题,球体信息被硬编码为全局变量,但它们也可以很容易地从 JavaScript 中的 uniform 提供:
vec4 sphere = vec4(1.0);
vec3 sphereColor = vec3(0.9, 0.8, 0.6);
float maxDistance = 1024.0;
float intersect(vec3 ro, vec3 rd, out vec3 norm, out vec3 color) {
float distance = maxDistance;
// If we wanted multiple objects in the scene you would loop
// through them here and return the normal and color with the
// closest intersection point (lowest distance).
float intersectionDistance = sphereIntersection(ro, rd, sphere);
if (intersectionDistance > 0.0 && intersectionDistance <
distance) {
distance = intersectionDistance;
// Point of intersection
vec3 pt = ro + distance * rd;
// Get normal for that point
norm = sphereNormaml(pt, sphere);
// Get color for the sphere
color = sphereColor;
}
return distance;
}
- 现在我们可以使用光线来确定一个点的法线和颜色,我们需要生成用于投射的光线。我们可以通过确定当前片段代表的像素,然后创建一个从相机位置通过该像素的光线来实现。为此,我们将利用
PostProcess类提供给着色器的uInverseTextureSizeuniform:
// Pixel coordinate of the fragment being rendered
vec2 uv = gl_FragCoord.xy * uInverseTextureSize;
float aspectRatio = uInverseTextureSize.y / uInverseTextureSize.x;
// Cast a ray out from the eye position into the scene
vec3 ro = eyePos;
// The ray we cast is tilted slightly downward to give a better
// view of the scene
vec3 rd = normalize(vec3(-0.5 + uv * vec2(aspectRatio, 1.0), -1.0));
- 使用我们刚刚生成的光线,我们调用
intersect函数来获取关于球体交点的信息。然后,我们应用我们一直在使用的相同的漫反射光照计算!为了简化问题,我们这里使用的是方向性光照,但很容易将光照模型更新为点光源或聚光灯:
// Default color if we don't intersect with anything
vec3 rayColor = backgroundColor;
// See if the ray intersects with any objects.
// Provides the normal of the nearest intersection point and color
vec3 objectNormal, objectColor;
float t = intersect(ro, rd, objectNormal, objectColor);
if (t < maxDistance) {
// Diffuse factor
float diffuse = clamp(dot(objectNormal, lightDirection), 0.0,
1.0);
rayColor = objectColor * diffuse + ambient;
}
fragColor = vec4(rayColor, 1.0);
- 到目前为止,我们的例子是一个静态的照明球体。我们如何给场景添加一点运动,以便更好地了解场景的渲染速度以及光照如何与球体相互作用?我们通过在着色器开始时使用
uTimeuniform 修改x和z坐标,给球体添加一个简单的循环圆周运动来实现这一点:
// Wiggle the sphere back and forth a bit
sphere.x = 1.5 * sin(uTime);
sphere.z = 0.5 * cos(uTime * 3.0);
刚才发生了什么?
我们介绍了如何在片段着色器中完全构建一个 3D 场景,包括光照等。当然,这是一个简单的场景,但也是一个几乎不可能使用基于多边形的渲染来渲染的场景。这是因为完美的球体只能用三角形来近似。
着色器玩具 现在你已经看到了如何完全在片段着色器中构建 3D 场景,你会发现ShaderToy.com上的演示既美丽又富有启发性。
尝试:多个球体
在我们的例子中,我们通过只渲染一个单独的球体来保持简单。但话说回来,渲染多个球体所需的所有组件都已经就位!你将如何渲染一个具有不同颜色和运动的多个球体的场景?
提示 需要编辑的主要着色器函数是 intersect。
摘要
让我们总结一下在本章中学到的内容:
-
我们介绍了多种高级技术,以创建更视觉复杂和吸引人的场景。
-
我们通过利用帧缓冲区学习了如何应用后处理效果。
-
我们使用点精灵渲染了粒子效果。
-
我们通过使用法线贴图创造了复杂几何形状的错觉。
-
最后,我们通过光线投射在片段着色器中完全渲染了一个场景。
这些高级效果只是 WebGL 可实现效果广阔天地的一瞥。鉴于着色器的强大和灵活性,可能性是无限的!
在下一章中,我们将介绍 WebGL 1(OpenGL ES 2.0)和 WebGL 2(OpenGL ES 3.0)之间的主要区别,以及迁移到 WebGL 2 的计划。
第十一章:WebGL 2 突出特点
在本书中,我们使用 WebGL 2 覆盖了计算机图形学的基础,这是所有现代浏览器都内置的基于网络的 3D 图形 API。我们了解到 WebGL 1 基于 OpenGL ES 2.0,而 WebGL 2 基于 OpenGL ES 3.0,这保证了 WebGL 1 中作为 可选 扩展提供的许多特性,以及许多其他强大的方法。尽管我们使用 WebGL 2 学习了广泛的计算机图形学主题,但几乎所有这些知识和技能都可以转移到其他图形 API 上。因此,让我们花一点时间来介绍 WebGL 2 相比 WebGL 1 提供的关键特性,以及从 WebGL 1 迁移到 WebGL 2 的策略。
在本章中,我们将涵盖以下内容:
-
对 WebGL 2 API 的更深入探讨
-
WebGL 2 核心规范的新增内容
-
从 WebGL 1 迁移 3D 应用到 WebGL 2 的策略
WebGL 2 的新特性是什么?
截至 2016 年 1 月 27 日,WebGL 2 在 Firefox 和 Chrome 中默认可用。这意味着只要您使用以下浏览器之一,您将自动获得对 WebGL 2 的访问权限,而无需任何额外的依赖项:
-
Firefox 51 或更高版本
-
Google Chrome 56 或更高版本
-
Chrome for Android 64 或更高版本
WebGL 2 支持
要获取支持 WebGL 2 的浏览器列表的最新版本,请通过以下链接访问 Khronos Group 网站:www.khronos.org/WebGL/wiki/Getting_a_WebGL_Implementation。或者,您也可以访问知名的 CanIUse.com 资源:caniuse.com/#search=WebGL 2。
如 第一章 所述,入门指南,WebGL 1 基于 OpenGL ES 2.0;因此,它不暴露查询计时器、计算着色器、统一缓冲区等特性。话虽如此,随着 WebGL 2(基于 OpenGL ES 3.0),我们能够访问更多 GPU 特性,如实例化和多个渲染目标。鉴于 WebGL 2 相比 WebGL 1 是一个相当大的升级,让我们突出其一些重要特性。
顶点数组对象
如 第二章 中所述,渲染,我们可以通过使用 OES_vertex_array_object 扩展在 WebGL 1 中实现 顶点数组对象。话虽如此,它们在 WebGL 2 中默认可用。这是一个重要的特性,应该始终使用,因为它可以显著减少渲染时间。当不使用顶点数组对象时,所有属性数据都在全局 WebGL 状态中,这意味着调用如 gl.vertexAttribPointer、gl.enableVertexAttribArray 和 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffer) 等函数会操作全局状态。这会导致性能损失,因为在任何绘制调用之前,我们需要设置所有顶点属性并设置用于索引数据的 ELEMENT_ARRAY_BUFFER。另一方面,使用顶点数组对象时,我们会在应用程序初始化期间设置所有属性,并在渲染期间简单地绑定数据,从而获得更好的性能。
这与 DirectX 中的 IDirect3DVertexDeclaration9/ID3D11InputLayout 接口非常相似。
| WebGL 1 带扩展 | WebGL 2 |
|---|---|
createVertexArrayOES |
createVertexArray |
deleteVertexArrayOES |
deleteVertexArray |
isVertexArrayOES |
isVertexArray |
bindVertexArrayOES |
bindVertexArray |
例如:
// Create a VAO instance
var vertexArray = gl.createVertexArray();
// Bind the VAO
gl.bindVertexArray(vertexArray);
// Set vertex array states
// Set with GLSL layout qualifier
const vertexPositionLocation = 0;
// Enable the attribute
gl.enableVertexAttribArray(vertexPositionLocation);
// Bind Buffer
gl.bindBuffer(gl.ARRAY_BUFFER, vertexPositionBuffer);
// ...
// Configure instructions for VAO
gl.vertexAttribPointer(vertexPositionLocation, 2, gl.FLOAT, false, 0, 0);
// Clean
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// ...
// Render
gl.bindVertexArray(vertexArray);
gl.drawArrays(gl.TRIANGLES, 0, 6);
更广泛的纹理格式范围
虽然 WebGL 1 有一组有限的纹理格式,但 WebGL 2 提供了更大的一组,其中一些列在这里:
RGBA32I |
RG8 |
RGB16UI |
|---|---|---|
RGBA32UI |
RG8I |
RGB8_SNORM |
RGBA16I |
RG8UI |
RGB8I |
RGBA16UI |
R32I |
RGB8UI |
RGBA8 |
R32UI |
SRGB8 |
RGBA8I |
R16I |
R11F_G11F_B10F |
RGBA8UI |
R16UI |
RGB9_E5 |
SRGB8_ALPHA8 |
R8 |
RG32F |
RGB10_A2 |
R8I |
RG16F |
RGB10_A2UI |
R8UI |
RG8_SNORM |
RGBA4 |
RGBA32F |
R32F |
RGB5_A1 |
RGBA16F |
R16F |
RGB8 |
RGBA8_SNORM |
R8_SNORM |
RGB565 |
RGB32F |
DEPTH_COMPONENT32F |
RG32I |
RGB32I |
DEPTH_COMPONENT24 |
RG32UI |
RGB32UI |
DEPTH_COMPONENT16 |
RG16I |
RGB16F |
|
RG16UI |
RGB16I |
3D 文本纹理
3D 纹理 是一种纹理,其中每个米普级别包含单个三维图像。3D 纹理本质上只是一个堆叠的 2D 纹理,可以在着色器中使用 x、y 和 z 坐标进行采样。这种功能使我们能够在单个对象中拥有多个 2D 纹理,以便着色器可以无缝选择每个对象使用的图像。
这对于可视化体数据(如医学扫描)、3D 效果(如烟雾)、存储查找表等非常有用。
纹理数组
纹理数组,类似于 3D 纹理,是一个减少复杂性、提高代码可维护性以及增加可使用纹理数量的优秀特性。通过确保纹理数组中所有纹理切片的大小相同,着色器可以访问许多纹理,同时占用更小的空间。
实例渲染
在 WebGL 2 中,实例化或实例渲染默认可用**。实例渲染是一种连续多次执行相同的绘制命令的方法,每次产生略微不同的结果。这对于使用非常少的 API 调用渲染大量几何体来说是一个非常有效的方法。
实例化是某些类型几何体性能的极大提升,特别是那些实例数量多但顶点数量不多的对象。好的例子是草地和毛发。实例化避免了每个对象单独 API 调用的开销,同时通过避免为每个单独的实例存储几何数据来最小化内存成本。
这里有一个快速示例:
gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 2);
非 2 的幂次纹理支持
正如我们在第七章中看到的,纹理和mipmaps是一种强大的特性,其中预计算的、优化的图像序列,每个图像都是同一图像的逐级降低分辨率的表示,允许进行更优化的渲染。虽然在 WebGL 1 中,mipmap 中每个图像或级别的宽度和高度都是前一级别的一半的 2 的幂,但在 WebGL 2 中,这个限制被移除了。也就是说,非 2 的幂次纹理与 2 的幂次纹理的工作方式相同。
片段深度
在 WebGL 2 中,我们可以手动设置自己的自定义值到深度缓冲区(z 缓冲区)。这个特性允许你在片段着色器中操作片段的深度。这可能会很昂贵,因为它迫使 GPU 绕过大量的正常片段丢弃行为,但也可以实现一些在没有极其高多边形几何体的情况下难以实现的效果。
着色器中的纹理大小
在 WebGL 2 中,你可以使用textureSize在 ESSL 着色器中查找任何纹理的大小。在 WebGL 1 中,你需要创建一个 uniform 并将数据手动传递到着色器中。
例如:
vec2 size = textureSize(sampler, lod);
同步对象
在 WebGL 1 中,从 JavaScript 到 GPU 再到屏幕的路径对开发者来说相当不透明。也就是说,你发送绘制命令,在未来的某个未定义的时刻,结果会显示在屏幕上。在 WebGL 2 中,同步对象允许开发者获得更多关于 GPU 何时完成工作的洞察。使用gl.fenceSync,你可以在 GPU 命令流中的某个位置放置一个标记,然后稍后调用gl.clientWaitSync来暂停 JavaScript 执行,直到 GPU 完成所有命令直到栅栏。显然,对于想要快速渲染的应用程序来说,阻塞执行是不理想的,但这对获取准确的基准测试非常有帮助。这也可能在未来用于在工作者之间同步。
直接纹理查找
通常方便将大量数据存储在纹理中。这在 WebGL 1 中是可能的,但你只能使用纹理坐标在 0.0 到 1.0 的范围内访问纹理。在 WebGL 2 中,访问这类数据要容易得多,因为你可以轻松地使用像素/纹理坐标从纹理中查找值。
例如:
vec4 values = texelFetch(sampler, ivec2Position, lod);
灵活的着色器循环
在 WebGL 1 中,着色器中的循环必须使用一个常量整数表达式。然而,由于 WebGL 2 基于 OpenGL ES 3.0,这个限制不再存在。
着色器矩阵函数
由于 WebGL 2 的着色语言比 WebGL 1 的功能更丰富,我们现在有更多矩阵数学运算可供使用。例如,如果需要矩阵的 inverse 或 transpose,我们需要将其作为统一变量传递。然而,在 WebGL 2 中,inverse 和 transpose 等函数是直接内置于着色器中的。
常见压缩纹理
在 WebGL 1 中,存在各种硬件依赖的压缩纹理格式。例如,S3TC 和 PVTC 格式分别仅适用于桌面和 iOS。然而,在 WebGL 2 中,以下格式由于硬件无关性而更加灵活:
-
COMPRESSED_R11_EAC RED -
COMPRESSED_SIGNED_R11_EAC RED -
COMPRESSED_RG11_EAC RG -
COMPRESSED_SIGNED_RG11_EAC RG -
COMPRESSED_RGB8_ETC2 RGB -
COMPRESSED_SRGB8_ETC2 RGB -
COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 RGBA -
COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 RGBA -
COMPRESSED_RGBA8_ETC2_EAC RGBA -
COMPRESSED_SRGB8_ALPHA8_ETC2_EAC
统一缓冲对象
设置着色程序统一变量是几乎任何 WebGL/OpenGL 绘制循环的重要组成部分。这可能会使你的绘制调用相当频繁,因为它们会进行数百或数千次的 gl.uniform 调用。
在 WebGL 1 中,如果我们有需要更新的 n 个统一变量,那么就需要使用 n 次适当的统一变量方法调用——这可能会相当慢。然而,在 WebGL 2 中,我们可以使用 统一缓冲对象,这允许我们从单个缓冲区中指定大量统一变量。这大大提高了性能,因为我们可以在 WebGL 之外使用 JavaScript 类型的数组来操作缓冲区中的统一变量,并通过单次调用更新一组统一变量。此外,统一缓冲区可以同时绑定到多个程序,因此可以一次性更新全局数据(如投影或视图矩阵),所有使用它们的程序将自动看到更改后的值。
异构统一缓冲对象 需要注意的是,在给定的应用程序中,你可以利用一系列不同的统一缓冲对象来满足你的应用需求。
整数纹理和属性
当在 WebGL 1 中,纹理和属性无论其原始类型如何,都表示为浮点值,而在 WebGL 2 中,纹理和属性提供整数表示。
变换反馈
WebGL 2 提供的一个强大技术是顶点着色器可以将它们的输出写回缓冲区。这在需要利用 GPU 的计算能力执行复杂计算并能在我们的应用程序中读取它们的情况下非常有用。
样本对象
虽然 WebGL 1 中所有纹理参数都是 每个纹理,但在 WebGL 2 中,我们可以选择使用 样本对象。通过使用样本,我们可以将所有纹理参数移动到样本中,允许单个纹理以不同的方式采样。
在 WebGL 1 中,纹理图像数据和采样信息(告诉 GPU 如何读取图像数据)都存储在纹理对象中。当我们想要从同一个纹理中两次以不同的方法(比如,线性过滤与最近邻过滤)读取时,可能会很痛苦,因为我们需要有两个纹理对象。通过使用样本对象,我们可以将这些两个概念分开。我们可以有一个纹理对象和两个不同的样本对象。这将导致我们的引擎组织纹理的方式发生变化。
这里有一个例子:
const samplerA = gl.createSampler();
gl.samplerParameteri(samplerA, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_NEAREST);
gl.samplerParameteri(samplerA, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.samplerParameteri(samplerA, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.samplerParameteri(samplerA, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const samplerB = gl.createSampler();
gl.samplerParameteri(samplerB, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.samplerParameteri(samplerB, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.samplerParameteri(samplerB, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
gl.samplerParameteri(samplerB, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
// ...
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.bindSampler(0, samplerA);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.bindSampler(1, samplerB);
深度纹理
WebGL 1 的一个主要缺点是缺乏对 深度纹理 的支持。在 WebGL 2 中,它们默认可用。
标准导数
虽然 WebGL 1 中你需要计算法线并将其传递给着色器,但在 WebGL 2 中,你可以通过使用默认可用的更大数学运算集在着色器内计算它们。
UNSIGNED_INT 索引
在 WebGL 2 中,索引几何体没有实际的大小限制,因为我们可以使用 32 位 int 作为索引。
混合方程 MIN / MAX
在 WebGL 中,你可以通过这些附加函数轻松地获取两个颜色在混合时的 MIN 或 MAX。
多重渲染目标(MRT)
在 WebGL 2 中,你可以从着色器一次绘制到多个缓冲区。这对于各种延迟渲染技术来说非常强大。这对许多开发者来说是一个“大事件”,因为它使得许多已经成为现代实时 3D 核心部分的现代延迟渲染技术对 WebGL 变得实用。
顶点着色器中的纹理访问
虽然 WebGL 1 中在顶点着色器内访问纹理是可能的,但你可能需要计算你可以访问多少个纹理,这可能是零。在 WebGL 2 中,纹理访问更加流畅,并且纹理访问计数至少需要是 16。
多样本渲染缓冲区
虽然 WebGL 1 中我们只能使用 GPU 内置的多样本系统来抗锯齿我们的 canvas,但在 WebGL 2 中,支持我们自己的自定义多样本处理。
查询对象
查询对象为开发者提供了另一种更明确的方式来窥视 GPU 的内部工作原理。查询封装了一组 GL 命令,以便 GPU 异步报告某些统计信息。例如,遮挡查询是这样进行的:在一系列绘制调用周围执行gl.ANY_SAMPLES_PASSED查询将允许你检测是否有任何几何体通过了深度测试。如果没有,你知道该对象是不可见的,并且可以选择不在未来的帧中绘制该几何体,直到发生某些事件(对象移动、相机移动等)表明该几何体可能再次变得可见。
应该注意的是,这些查询是异步的,这意味着查询的结果可能在查询最初发出后的许多帧之后才准备好!这使得它们使用起来很棘手,但在适当的情况下可能值得。
这里有一个例子:
gl.beginQuery(gl.ANY_SAMPLES_PASSED, query);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 2);
gl.endQuery(gl.ANY_SAMPLES_PASSED);
//...
(function tick() {
if (!gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE)) {
// A query's result is never available in the same frame
// the query was issued. Try in the next frame.
requestAnimationFrame(tick);
return;
}
var samplesPassed = gl.getQueryParameter(query, gl.QUERY_RESULT);
gl.deleteQuery(query);
})();
纹理 LOD
纹理 LOD参数用于确定从哪个 mipmap 中获取。这允许进行 mipmap 流式传输,即只加载当前所需的 mipmap 级别。这对于 WebGL 环境非常有用,因为纹理是通过网络下载的。
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_LOD, 0.0);
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAX_LOD, 10.0);
着色器纹理 LOD
着色器纹理 LOD偏差控制使得基于物理渲染的哑光环境效果中的 mipmap 级别控制更加简单。现在作为 WebGL 2 核心的一部分,lodBias可以作为可选参数传递给纹理。
常用的浮点纹理
在 WebGL 1 中,浮点纹理是可选的,但在 WebGL 2 中,它们默认可用。
迁移到 WebGL 2
正如我们之前所描述的,WebGL 2 几乎与 WebGL 1 完全向后兼容。
向后兼容性
所有向后兼容性的例外情况都记录在以下链接中:www.khronos.org/registry/WebGL/specs/latest/2.0/#BACKWARDS_INCOMPATIBILITY。
话虽如此,让我们来了解一下将 WebGL 1 应用程序迁移到 WebGL 2 的一些关键组件。
获取上下文
在 WebGL 1 中,你会用类似以下的方式获取 WebGL 上下文:
const names = ['WebGL', 'experimental-WebGL', 'webkit-3d', 'moz-WebGL'];
for (let i = 0; i < names.length; ++i) {
try {
const context = canvas.getContext(names[i]);
// work with context
} catch (e) {
console.log('Error attaining WebGL context', e);
}
}
在 WebGL 2 中,你只需用一行代码获取上下文,如下所示:
const context = canvas.getContext('WebGL 2');
扩展
在 WebGL 中,许多可选扩展对于更高级的功能是必需的,但在 WebGL 2 中,你可以移除其中大部分扩展,因为它们默认可用。以下是一些包括在内:
-
深度纹理:
www.khronos.org/registry/WebGL/extensions/WebGL_depth_texture -
浮点纹理:
-
顶点数组对象:
www.khronos.org/registry/WebGL/extensions/OES_vertex_array_object -
标准导数:
www.khronos.org/registry/WebGL/extensions/OES_standard_derivatives -
实例绘制:
www.khronos.org/registry/WebGL/extensions/ANGLE_instanced_arrays -
UNSIGNED_INT索引:www.khronos.org/registry/WebGL/extensions/OES_element_index_uint -
设置
gl_FragDepth:www.khronos.org/registry/WebGL/extensions/EXT_frag_depth -
混合方程
MIN/MAX:www.khronos.org/registry/WebGL/extensions/EXT_blend_minmax -
直接纹理 LOD 访问:
www.khronos.org/registry/WebGL/extensions/EXT_shader_texture_lod -
多个绘制缓冲区:
www.khronos.org/registry/WebGL/extensions/WebGL_draw_buffers -
顶点着色器中的纹理访问
着色器更新
虽然 WebGL 2 的着色语言,基于 GLSL 300,与 WebGL 1 的着色语言向后兼容,但我们需要进行一些更改以确保我们的着色器能够编译。现在让我们来探讨这些更改。
着色器定义
在 WebGL 2 的着色器中,我们必须在所有着色器前添加以下代码行:#version 300 es。需要注意的是,这必须是着色器的第一行,否则着色器将无法编译。
属性定义
由于属性作为输入提供给着色器,在 GLSL 300 ES 中,attribute修饰符被移除。例如,使用 WebGL 的 GLSL 100,你可能会有以下代码:
attribute vec3 aVertexNormal;
attribute vec4 aVertexPosition;
在 GLSL 300 ES 中,这将如下所示:
in vec3 aVertexNormal;
in vec4 aVertexPosition;
变量定义
在 GLSL 100 中,变量通常在顶点着色器和片段着色器中定义,但在 GLSL 300 ES 中,varying修饰符已被移除。也就是说,根据值是作为输入提供还是作为输出返回,变量修饰符会更新为相应的in和out修饰符。例如,考虑以下 GLSL 100 中的代码:
// inside of the vertex shader
varying vec2 vTexcoord;
varying vec3 vNormal;
// inside of the fragment shader
varying vec2 vTexcoord;
varying vec3 vNormal;
这将变为以下 GLSL 300 ES 中的代码:
// inside of the vertex shader
out vec2 vTexcoord;
out vec3 vNormal;
// inside of the fragment shader
in vec2 vTexcoord;
in vec3 vNormal;
不再使用gl_FragColor
在 GLSL 100 中,你最终会通过在片段着色器中设置gl_FragColor来渲染像素的颜色,但在 GLSL 300 ES 中,你只需从你的片段着色器中暴露一个值。例如,考虑以下 GLSL 100 中的代码:
void main(void) {
gl_FragColor = vec4(1.0, 0.2, 0.3, 1.0);
}
这将通过设置一个定义的输出变量来更新,如下所示:
out vec4 fragColor;
void main(void) {
fragColor = vec4(1.0, 0.2, 0.3, 1.0);
}
重要的是要注意,尽管我们声明了一个名为 fragColor 的变量,但由于歧义,你可以选择任何不以 gl_ 前缀开始的名称。在这本书中,我们定义了这个自定义变量为 fragColor。
自动纹理类型检测
在 GLSL 100 中,你会通过使用适当的方法,如 texture2D,从纹理中获取颜色,但在 GLSL 300 ES 中,着色器会根据使用的采样器类型自动检测类型。例如,考虑以下 GLSL 100 中的内容:
uniform sampler2D uSome2DTexture;
uniform samplerCube uSomeCubeTexture;
void main(void) {
vec4 color1 = texture2D(uSome2DTexture, ...);
vec4 color2 = textureCube(uSomeCubeTexture, ...);
}
这将在 GLSL 300 ES 中更新为以下内容:
uniform sampler2D uSome2DTexture;
uniform samplerCube uSomeCubeTexture;
void main(void) {
vec4 color1 = texture(uSome2DTexture, ...);
vec4 color2 = texture(uSomeCubeTexture, ...);
}
非 2 的幂纹理支持
如第七章中所示,在 WebGL 1 中,对于不符合 2 的幂 限制的纹理,不存在米柏(mipmap)。然而,在 WebGL 2 中,非 2 的幂纹理与 2 的幂纹理工作方式完全相同。
浮点帧缓冲区附加
在 WebGL 1 中,需要一种奇怪的技巧来检查是否支持渲染到浮点纹理,而在 WebGL 2 中,这涉及到通过标准方法进行简单的检查。
顶点数组对象
虽然使用顶点数组对象不是 必需的 要求,但在迁移过程中使用它是一个高度 推荐 的特性。通过使用顶点数组对象,你可以改善你代码的整体结构以及你应用程序的性能。
摘要
让我们总结一下本章所学的内容:
-
我们涵盖了 WebGL 2 规范中仅有的许多核心方法。
-
我们了解了一些 WebGL 1 和 WebGL 2 之间的关键区别。
-
我们讨论了将 WebGL 1 应用程序转换为 WebGL 2 的迁移策略。
我们几乎完成了!你能相信吗?接下来,在最后一章,前方之旅,我们将通过列出概念、资源和其他有用的信息来结束这本书,这些信息既鼓舞人心又赋权,帮助你继续沿着掌握实时计算机图形学的道路前进。
第十二章:前路漫漫
在这本书中,我们已经涵盖了构建使用 WebGL 2 的交互式 3D 网络应用程序所需的基础概念、技术和资源。现在,你正在成为计算机图形专家的道路上,本章的资源专门用于帮助你完成这段旅程。
在本章的结尾,你将执行以下操作:
-
涵盖不同大小和功能的 WebGL 库。
-
探讨测试 WebGL 应用程序的战略。
-
了解 3D 重建。
-
探索基于物理的渲染的力量。
-
认识各种图形社区。
WebGL 库
在我们深入探讨各种 WebGL 库之前,我们首先应该定义什么是软件库。尽管 库 和 框架 经常被互换使用,但在计算机科学中它们指的是 不同 的概念。一个软件库包含定义好的代码、配置、文档、类、脚本等,以便开发者可以将它们包含在自己的程序中,以增强他们的产品。例如,在开发需要大量数学运算的程序时,开发者可以包含一个合适的软件库(例如,glMatrix),以减少自己编写这些运算的需求。
话虽如此,正如你可能已经注意到的,我们以这种方式构建了我们的 3D 应用程序,使得类、实用程序和整体架构最终可以被转换成一个库。这个过程是故意为之,这样我们就可以孤立地学习概念,并编写最终可以构成功能丰富的 WebGL 库的代码,该库可以被其他应用程序使用。
话虽如此,了解何时何地使用库是很重要的,所以让我们了解一下不同大小和功能的几个 WebGL 库。
小型库
这里有一些小型、非规定性的 WebGL 库的例子,它们提供了许多辅助工具、实用程序和 WebGL 低级 API 的抽象层。
TWGL
TWGL (github.com/greggman/twgl.js) 是一个旨在“使使用 WebGL API 更简洁”的开放源代码 WebGL 库。例如,以下是一个简单的 TWGL 演示,它展示了其易于理解、但低级的 API 在 WebGL 之上:
const
canvas = document.getElementById('webgl-canvas'),
gl = canvas.getContext('webgl'),
program = twgl.createProgramInfo(gl, ['vertex-shader', 'fragment-shader']),
arrays = {
position: [
-1, -1, 0,
1, -1, 0,
-1, 1, 0,
-1, 1, 0,
1, -1, 0,
1, 1, 0
],
},
bufferInfo = twgl.createBufferInfoFromArrays(gl, arrays);
function draw(time) {
const { width, height } = gl.canvas;
twgl.resizeCanvasToDisplaySize(gl.canvas);
gl.viewport(0, 0, width, height);
const uniforms = {
time: time * 0.001,
resolution: [width, height],
};
gl.useProgram(program.program);
twgl.setBuffersAndAttributes(gl, program, bufferInfo);
twgl.setUniforms(program, uniforms);
twgl.drawBufferInfo(gl, bufferInfo);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
你可以在他们的 GitHub 页面上看到实时演示,它类似于以下内容:

Regl
Regl (github.com/regl-project/regl) 是一个具有函数式风格的开放源代码 WebGL 库。正如其文档所解释的,Regl 通过“尽可能多地移除共享状态来简化 WebGL 编程”。为此,它用两个基本抽象——资源 和 命令 来替换 WebGL API。以下代码片段展示了函数式 Regl API:
const regl = require('regl')();
const vertexShader = `
precision mediump float;
attribute vec2 position;
void main(void) {
gl_Position = vec4(position, 0, 1);
}
`;
const fragmentShader = `
precision mediump float;
uniform vec4 color;
void main(void) {
gl_FragColor = color;
}
`;
const drawTriangle = regl({
vert: vertexShader,
frag: fragmentShader,
attributes: {
position: regl.buffer([
[-2, -2],
[4, -2],
[4, 4]
])
},
uniforms: {
color: regl.prop('color')
},
count: 3
});
regl.frame(({ time }) => {
regl.clear({
color: [1, 1, 1, 1],
depth: 1
});
drawTriangle({
color: [
Math.cos(time * 0.001),
Math.sin(time * 0.0008),
Math.cos(time * 0.003),
1
]
});
});
你可以在他们的 GitHub 页面上看到实时演示,它类似于以下内容:

StackGL
StackGL (stack.gl) 是一个有趣的 WebGL 应用程序构建方法的开源项目。它不是一个捆绑为单个库的单一库,而是一个由许多小型、精简模块组成的生态系统,灵感来源于 Unix 哲学。
Unix 哲学 Unix 思维模式是一种编写简约、模块化软件的哲学方法,通常用口号“只做一件事,做好它!”来表达。更多信息,请访问以下网址:en.wikipedia.org/wiki/Unix_philosophy。
与许多 3D 引擎不同,StackGL 强调编写精简、模块化的代码,专注于编写着色器代码。因此,请务必访问他们的网站,因为它包含了广泛的文档和演示,这将帮助您掌握这种方法。
功能丰富的库
虽然小型、精简和模块化的 WebGL 库很有用,但它们可能不足以满足复杂应用程序的需求。以下是一些功能丰富的 WebGL 库,它们提供了一系列的功能和特性。
Three.js
Three.js (github.com/mrdoob/three.js) 是一个开源库,为许多网页上的 WebGL 应用程序提供动力。它旨在创建一个易于使用、轻量级的 3D 库,具有多个渲染器,针对 2D canvas、WebGL、SVG 和 CSS3D。以下是一个展示 Three.js API 简单性的旋转立方体演示:
let
renderer,
scene,
camera,
mesh,
width = window.innerWidth,
height = window.innerHeight;
function init() {
camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
scene = new THREE.Scene();
const mesh = new THREE.Mesh(
// geometry
new THREE.BoxGeometry(0.2, 0.2, 0.2),
// material
new THREE.MeshNormalMaterial()
);
scene.add(mesh);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
document.body.appendChild(renderer.domElement);
}
function render() {
requestAnimationFrame(render);
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.02;
renderer.render(scene, camera);
}
init();
render();
你可以在他们的 GitHub 页面上看到实时演示,如下所示:

Babylon.js
Babylon.js (github.com/mrdoob/three.js) 是一个在微软内部诞生的开源 WebGL 库。它是一个功能强大的库,最近完全用 TypeScript 重写。
TypeScriptTypeScript 是由微软开发的开源语言。它是一种强大的语言,是 JavaScript 的严格语法超集,并为 JavaScript 添加了可选的静态类型。更多信息,请访问github.com/Microsoft/TypeScript。
虽然选择 Babylon.js 不需要使用 TypeScript,但如果您或您的团队更喜欢 TypeScript 提供的功能,这可以成为与其他库相比的一个主要优势。以下是一个展示简单 Babylon.js API 的有趣的 JavaScript 演示:
const canvas = document.getElementById('webgl-canvas');
const engine = new BABYLON.Engine(
canvas,
true,
{
preserveDrawingBuffer: true,
stencil: true
}
);
function initScene() {
const scene = new BABYLON.Scene(engine);
const camera = new BABYLON.FreeCamera('camera', new BABYLON.Vector3(0, 5,
-10), scene);
camera.setTarget(BABYLON.Vector3.Zero());
camera.attachControl(canvas, false);
const ground = BABYLON.Mesh.CreateGround('ground', 6, 6, 2, scene,
false);
const sphere = BABYLON.Mesh.CreateSphere('sphere', 16, 2, scene, false,
BABYLON.Mesh.FRONTSIDE);
sphere.position.y = 1;
const light = new BABYLON.HemisphericLight('light', new
BABYLON.Vector3(0, 1, 0), scene);
return scene;
}
const scene = initScene();
engine.runRenderLoop(() => scene.render());
你可以在他们的 GitHub 页面上看到实时演示,如下所示:

A-Frame
A-Frame (github.com/aframevr/aframe) 是一个用于构建虚拟现实(VR)体验的开源网页框架。它主要由 Mozilla 和 WebVR 社区维护。尽管其他 WebGL 库,如 Three.js 和 Babylon.js,提供了 VR 支持,但 A-Frame 完全是为了构建网页上的 VR 应用程序而设计的。
A-Frame 核心 虽然 A-Frame 是一个全新的项目,但它建立在 Three.js 游戏引擎之上。
这里有一个展示 A-Frame 声明性 API 的演示:
<!DOCTYPE html>
<html>
<head>
<title>Hello, WebVR! - A-Frame</title>
<meta name="description" content="Hello, WebVR! - A-Frame">
<script src="img/aframe.min.js"></script>
</head>
<body>
<a-scene>
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow>
</a-box>
<a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E" shadow>
</a-sphere>
<a-cylinder position="1 0.75 -3" radius="0.5" height="1.5"
color="#FFC65D" shadow></a-cylinder>
<a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4"
color="#7BC8A4" shadow></a-plane>
<a-sky color="#ECECEC"></a-sky>
</a-scene>
</body>
</html>
你可以在他们的 GitHub 页面上看到实时演示,它类似于以下内容:

游戏引擎
构建复杂 3D 应用的另一种方法是使用成熟的游戏引擎。游戏引擎是一个软件开发环境,旨在让人们能够构建复杂的 3D 应用。尽管开发者使用 3D 引擎为游戏机、移动设备和个人电脑创建游戏,但它们也可以用来构建交互式 Web 应用。在构建复杂的 WebGL 应用时,你可以使用两个强大的游戏引擎:Unity 和 PlayCanvas。
Unity
Unity (unity3D.com) 是由 Unity Technologies 开发的便携式游戏引擎,提供跨平台功能。它最初于 2005 年 6 月在苹果公司的全球开发者大会上宣布并发布,作为一款仅限 OS X 的游戏引擎。多年来,它已成为跨平台提供一些最知名游戏的主要游戏引擎。尽管 Unity 优先考虑原生输出而非基于 Web 的输出,但它确实提供了 WebGL 支持:

PlayCanvas
PlayCanvas (playcanvas.com) 是一个开源的 3D 游戏引擎,同时也提供专有云托管创作平台。尽管其他游戏引擎,如 Unity,也提供 WebGL 支持,但 PlayCanvas 是从头开始为 Web 构建的。此外,PlayCanvas 提供出色的开发体验,因为它拥有许多强大的功能,如可视化工作区、完整的 WebGL 2 支持、多台计算机同时编辑等:

测试 WebGL 2 应用
如果你在这本书中打开了浏览器的调试工具,你可能已经注意到你看到的canvas是一个完整的“黑盒”。也就是说,你不能像检查网页上的 DOM 元素那样检查它的任何元素。如果你来自传统的 Web 开发背景,这可能会显得像一个大问题,因为我们习惯于利用 DOM 来帮助我们查询元素以测试我们的应用。那么,我们如何确保我们的 WebGL 应用的品质和稳定性呢?
视觉回归测试
在开发周期中,对各种应用状态的图像进行比较是测试 WebGL 应用的常见方法。这种技术通常被称为视觉回归测试,通过捕获网页/UI 的屏幕截图并与原始图像(历史基线截图或实时网站的参考图像)进行比较,执行前端或用户界面回归测试:

在前面的屏幕截图中,您可以看到通过最终的 Diff 输出,基线和更改是如何不同的。这种技术可以是一个确保您的 WebGL 应用程序继续按预期行为的有效方法。
可视回归测试工具
应用内省测试
另一种方法是通过自定义 API 公开你的 WebGL 元素来模拟 DOM API。例如,如果我们想通过 ID 查询 DOM 元素,我们会这样做:document.getElementById('element-id')。我们也可以通过 jQuery 的更简单 API 通过$('#element-id')实现同样的操作。
jQuery
要查看此方法的实现,请参考Three Musketeers(github.com/webgl/three-musketeer),一个开源库,可以将其包含在任何 Three.js 应用程序中,只需一行代码。通过包含 three-musketeers,我们可以在场景中的元素上运行各种查询,类似于网页中的 DOM 元素。以下是一些示例查询以供进一步说明:
$$$.debug();
$$$ 是 three-musketeers 实例的别名。debug 方法启用可视化调试模式:
$$$
.find('Cube_1')
.exists();
// returns true
find 方法在场景中搜索具有 ID Cube_1 的项目。通过调用 exists,它返回一个布尔值,表示是否存在:
$$$
.findAll((node) => node.geometry.type === 'BoxGeometry');
与 find 类似,findAll 返回一个项目数组。在这种情况下,我们不是在搜索一个唯一的 ID,而是在寻找所有匹配 BoxGeometry 类型的几何体:
$$$
.find('Cube_1')
.click();
我们找到具有唯一 ID Cube_1 的几何体,并在适当的坐标上触发鼠标点击事件:
window.addEventListener('click', (event) => {
const intersectedItems = $$$.pickFromEvent(event);
console.log(intersectedItems);
});
这是一个非常有助于调试的简单技术。每次我们在网页上点击,我们都会记录所有相交的几何体,给定鼠标点击的 2D 坐标映射到我们的 3D 场景中。
更多信息,请务必查看 GitHub 上的three-musketeers(github.com/webgl/three-musketeers)或其文档(webgl.github.io/three-musketeers)。
3D 重建
在整本书中,我们要么构建自己的几何形状,要么导入在 3D 建模工具(如 Maya 或 Blender)中创建的模型。尽管这些是构建 3D 资产常用的方法,但它们需要人工劳动来创建。有没有其他获取几何形状的技术呢?当然有!3D 重建是从图像中创建 3D 模型的过程。这是从 3D 场景中获得 2D 图像的逆过程。以下是一个完全通过航空摄影和称为摄影测量的技术生成的 3D 模型的例子:

摄影测量
摄影测量是通过对照片进行空间测量的科学。这是一种强大的技术,可以恢复表面点的精确位置。更多信息,请访问en.wikipedia.org/wiki/Photogrammetry。
基于物理的渲染
在第三章《光线》中,我们学习了如何通过模拟光线来照亮我们的场景。我们通过利用各种着色和光线反射技术来实现,这些技术主要使用两个主要组件:镜面反射和漫反射。尽管我们在计算机图形学中已经使用镜面反射和漫反射来建模材料很长时间了,但这些技术产生的结果并不非常逼真。例如,改变材料的镜面反射率并不会改变漫反射:

之前的截图表明,仅改变镜面强度和镜面硬度这两个参数只会改变反射的白色部分。蓝色的漫反射根本不会改变——这并不是我们物理世界的运作方式!因此,在追求更逼真效果的应用中,艺术家将负责手动调整每个材料的这些值,直到它“看起来正确”,这充其量是一种低效的方法。必须有一种更好的方法!
进入基于物理的渲染(PBR),这是一种通过更客观、可测量和科学的真实表面属性来验证我们的材料描述的方法。最明显的属性之一是能量守恒:粗糙的表面会漫反射光线,而更光滑/更金属的表面会直接反射光线,尽管它们都是从同一个光源中反射出来的。因此,在所有条件相同的情况下,规则遵循着随着材料变得越亮,漫反射成分应该越暗:

当然,基于物理的渲染不仅仅是能量守恒;然而,这是一个清楚地展示基于物理系统特性的例子。通过保持反射模型与材料在现实生活中的工作方式相似,我们减少了主观手动调整的需求,并产生了在各种光照条件下看起来逼真的真实世界材料。
社区
计算机图形学是一个充满复杂、美丽和启发性的概念领域。参与致力于这一学科的社区是学习、分享和启发他人的最佳方式之一。以下是一些最受欢迎的社区的非详尽列表:
-
Chrome Experiments (
experiments.withgoogle.com) 是一个基于网络浏览器的实验、互动程序和艺术项目的在线展示厅。 -
WebGL.com (
WebGL.com) 是 WebGL 开发者的领先社区,包括演示、教程、新闻等。 -
SketchFab (
sketchfab.com) 是一个发布、分享、发现、购买和销售 3D、VR 和 AR 内容的平台。它提供了一个基于 WebGL 和 WebVR 技术的查看器,允许用户在网页上显示 3D 模型。 -
ShaderToy (
www.shadertoy.com) 是一个跨浏览器的在线社区和工具,用于通过 WebGL 创建和分享着色器,用于在网页浏览器中学习和教授 3D 计算机图形学。 -
CGTrader (
www.cgtrader.com/3D-models) 是一个在线平台,允许设计师和建模工作室上传他们的 3D 模型,并与社区分享或出售。 -
TurboSquid (
www.turbosquid.com) 是一家数字媒体公司,向包括计算机游戏、建筑和交互式培训在内的多个行业销售用于 3D 图形的库存 3D 模型。 -
Poly (
poly.google.com) 是由 Google 创建的一个网站,用户可以浏览、分发和下载 3D 对象。它包含一个免费库,包含数千个可用于虚拟现实和增强现实应用的 3D 对象。
摘要
感谢您花时间阅读这本书。通过涵盖广泛的主题——如渲染、着色器、3D 数学、光照、相机、纹理等——并指导您构建引人入胜的 3D 应用程序,我们希望它已经实现了帮助您使用 WebGL 2 学习交互式 3D 计算机图形学的目标。
“故事不会结束,”他说,“它们只是变成了新的开始。”
– Lindsay Eagar,《蜜蜂时刻》
考虑到这一点,请确保保持联系并分享您的工作——我们期待看到您所构建的内容!如果您有任何问题或反馈,请参阅本书的序言以获取联系方式。


浙公网安备 33010602011771号