Three-js-学习指南第四版-全-

Three.js 学习指南第四版(全)

原文:zh.annas-archive.org/md5/687f1c3fc4f47ab3cb1cd09fefdee309

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去几年中,Three.js 已经成为创建令人惊叹的 3D WebGL 内容的行业标准。在本版中,我们将探讨 Three.js 的所有功能,并提供有关如何将 Three.js 与 Blender、React、TypeScript 和最新的物理引擎集成的额外内容。

在这本书中,您将学习如何直接在浏览器中使用 WebGL 和现代浏览器的全部潜力创建和动画化沉浸式 3D 场景。

本书从 Three.js 中使用的基本概念和构建块开始,并通过大量的示例和代码示例帮助您详细探索这些基本主题。您还将学习如何使用纹理和材质创建看起来逼真的 3D 对象。除了手动创建这些对象外,我们还将解释如何从外部源加载现有模型。接下来,您将了解如何轻松地使用 Three.js 内置的相机控制来控制相机,这将使您能够在您创建的 3D 场景中飞行或四处走动。后面的章节将向您展示如何使用 HTML5 视频和 canvas 元素作为 3D 对象的材质并动画化您的模型。您将学习如何使用基于变形和骨骼的动画,然后了解如何将物理效果,如重力和碰撞检测,添加到场景中。最后,我们将解释如何将 Blender 与 Three.js 结合使用,如何将 Three.js 与 React 和 TypeScript 集成,以及您如何使用 Three.js 创建 VR 和 AR 场景。

到这本书的结尾,您将获得使用 Three.js 创建 3D 动画图形所需的所有技能。

本书面向的对象

这本书是为希望学习如何自信地使用 Three.js 库的 JavaScript 开发者而编写的。

本书涵盖的内容

第一章使用 Three.js 创建您的第一个 3D 场景,介绍了 Three.js 库,解释了如何设置您的开发环境,并展示了如何创建您的第一个应用程序。

第二章构成 Three.js 应用程序的基本组件,解释了 Three.js 背后的核心概念,并介绍了每个 Three.js 应用程序所需的必备组件。

第三章在 Three.js 中使用光源,介绍了您可以在 Three.js 中使用以向您的 Three.js 场景添加光源的所有可用光源。

第四章使用 Three.js 材质,概述了在创建使用 Three.js 可视化的对象时可以使用的不同材质。

第五章学习使用几何体,解释了 Three.js 提供的哪些基本几何体以及如何使用和自定义它们。

第六章探索高级几何体,详细解释了 Three.js 提供的高级几何体。

第七章, 点和精灵,描述了如何在屏幕上创建和操作大量点和精灵,以及如何更改这些点的外观。

第八章, 创建和加载高级网格和几何体,提供了如何组合和合并对象,以及如何从外部来源加载现有模型的示例和说明。

第九章, 动画和移动相机,讨论了 Three.js 提供的不同相机控制,并解释了动画在 Three.js 中的工作方式以及如何从外部来源加载动画。

第十章, 加载和使用纹理,概述了 Three.js 中可用的不同类型纹理,以及您如何使用它们来配置 Three.js 材质。

第十一章, 渲染后处理,介绍了如何将模糊、发光和着色等后渲染效果添加到场景中。

第十二章, 向场景添加物理和声音,介绍了 Rapier 物理库,它允许您模拟现实世界中的对象交互。本章还展示了如何向场景添加位置声音。

第十三章, 使用 Blender 和 Three.js,提供了如何将 Blender 与 Three.js 集成、交换模型以及使用 Blender 烘焙特定纹理的信息。

第十四章, Three.js 与 React、TypeScript 和 Web-XR 一起使用,展示了如何将 Three.js 与 React 和 TypeScript 结合使用,并概述了 Three.js 对 Web-XR 标准的支持,以创建 VR 和 AR 场景。

要充分利用这本书

在开始阅读这本书之前,您不需要了解很多。唯一的要求是您需要对 JavaScript 有一些基本知识。本书提供了如何设置您的开发环境、安装任何额外的工具和库以及运行示例的说明。

本书涵盖的软件 操作系统要求
Three.js r147 Windows、macOS 或 Linux

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Learn-Three.js-Fourth-edition。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“您可以自己执行这些步骤,或者查看three-ts文件夹,并直接在那里运行yarn install以跳过此设置。”

代码块设置如下:

import './style.css'
import { initThreeJsScene } from './threeCanvas'
const mainElement = document.querySelector<HTMLDivElement>('#app')
if (mainElement) {
  initThreeJsScene(mainElement)
}

任何命令行输入或输出都应如下编写:

$ git --version
git version 2.30.1 (Apple Git-130)

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“为此,我们需要从运行|编辑****配置菜单创建一个新的调试配置。”

小贴士或重要注意事项

它看起来像这样。

联系我们

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

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

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过电子邮件发送至 copyright@packt.com 并提供材料的链接。

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

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在每本 Packt 书籍都免费提供该书的 DRM 免费 PDF 版本。

在任何设备、任何地方阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

好处不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取这些好处:

  1. 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781803233871

  1. 提交您的购买证明

  2. 那就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。

第一部分:入门

在第一部分,我们将介绍 Three.js 的基本概念,并确保您拥有启动所需的所有信息。我们将从设置您的开发环境开始,在接下来的章节中,我们将探讨构成 Three.js 的核心概念。

在本部分,包含以下章节:

  • 第一章, 使用 Three.js 创建您的第一个 3D 场景

  • 第二章, 组成 Three.js 应用的基本组件

  • 第三章, 在 Three.js 中使用光源

第一章:使用 Three.js 创建你的第一个 3D 场景

在近年来,现代浏览器获得了可以直接从JavaScript访问的强大功能。你可以轻松地使用HTML5 标签添加视频和音频,并通过使用HTML5 Canvas创建交互式组件。与现代浏览器一起,HTML5 还支持WebGL。使用 WebGL,你可以直接利用你的图形卡的处理器资源,创建高性能的 2D 和 3D 计算机图形。直接从 JavaScript 使用 WebGL 创建和动画化 3D 场景是一个非常复杂、冗长且容易出错的流程。Three.js是一个库,它使这个过程变得容易得多。以下列表显示了使用 Three.js 可以轻松完成的一些事情:

  • 创建简单和复杂的 3D 几何形状,并在任何浏览器中渲染它们

  • 在 3D 场景中动画化和移动对象

  • 将纹理和材质应用到你的对象上

  • 使用不同的光源照亮场景

  • 使用 3D 建模软件中的模型,并将生成的模型导出到这些程序中

  • 为你的 3D 场景添加高级后处理效果

  • 创建并使用自定义着色器

  • 创建、可视化和动画点云

  • 创建虚拟现实VR)和增强现实AR)场景

通过几行 JavaScript(或TypeScript,正如我们将在本书后面看到的),你可以创建任何东西,从简单的 3D 模型到逼真的场景,所有这些都在浏览器中以实时渲染。例如,图 1.1展示了使用 Three.js 可以完成的事情(您可以通过在浏览器中打开threejs.org/examples/webgl_animation_keyframes.html来亲自查看动画):

图 1.1 – Three.js 渲染和动画场景

图 1.1 – Three.js 渲染和动画场景

在本章中,我们将直接进入 Three.js,创建几个示例,展示 Three.js 的工作原理,并让你可以用来玩耍和了解 Three.js。我们不会深入所有技术细节;你将在接下来的章节中了解这些。到本章结束时,你将能够创建一个场景,并运行和探索本书中的所有示例。

我们将从这个简短的 Three.js 介绍开始,然后快速进入前几个示例和代码示例。在我们开始之前,让我们快速查看最重要的浏览器及其对 WebGL(和 WebGPU)的支持情况。

注意

目前,所有桌面上的现代浏览器以及移动设备上的浏览器都支持 WebGL。IE 的旧版本(11 版本之前的版本)将无法运行基于 WebGL 的应用程序。在移动设备上,大多数浏览器支持 WebGL。因此,使用 WebGL,你可以在桌面和移动设备上创建运行良好的交互式 3D 可视化。

在本书中,我们将专注于 Three.js 提供的基于 WebGL 的渲染器。然而,也存在一个基于 CSS 3D 的渲染器,它提供了一个简单的 API 来创建基于 CSS 3D 的 3D 场景。使用基于 CSS 3D 的方法的一个大优点是,这个标准在所有移动和桌面浏览器上都得到支持,并允许你在 3D 空间中渲染 HTML 元素。我们不会深入探讨这个浏览器的细节,但将在第七章点和精灵示例中展示一个例子。

除了 WebGL 之外,一个名为 WebGPU 的新标准正在开发中,它将提供比 WebGL 更好的性能,并在未来成为新的标准。当你使用 Three.js 时,你不必担心这个变化。Three.js 已经部分支持 WebGPU,随着该标准的成熟,Three.js 对该标准的支持也将成熟。因此,你用 Three.js 创建的一切也将与 WebGPU 无缝工作。

在本章中,你将直接创建一个 3D 场景,并能够在桌面或移动设备上运行它。我们将解释 Three.js 的核心概念,如果有更高级的主题,我们将在哪一章中更详细地解释这些内容。在本章中,我们将创建两个不同的场景。第一个场景将展示在 Three.js 中渲染的基本几何形状,如图下所示:

图 1.2 – 默认几何形状渲染

图 1.2 – 默认几何形状渲染

之后,我们还会快速展示如何加载外部模型,以及创建看起来逼真的场景是多么容易。第二个示例的结果将如下所示:

图 1.3 – 渲染外部加载的模型

图 1.3 – 渲染外部加载的模型

在你开始这些示例之前,在接下来的几个部分中,我们将探讨你需要的工具,以及如何下载本书中展示的示例。

在本章中,我们将涵盖以下主题:

  • 使用 Three.js 的要求

  • 下载本书中使用的源代码和示例

  • 测试和实验示例

  • 渲染和查看 3D 对象

  • 介绍几个用于统计和场景控制的辅助库

技术要求

Three.js 是一个 JavaScript ,因此你只需要一个文本编辑器和支持渲染结果的浏览器之一来创建 Three.js WebGL 应用程序。我推荐以下文本编辑器,我在过去几年中为各种项目广泛使用过:

  • Visual Studio Code:这个来自微软的免费编辑器在所有主要平台上运行,提供了基于类型、函数定义和导入库的出色语法高亮和智能完成。它提供了一个非常干净的界面,非常适合 JavaScript 项目开发。您可以从这里下载:code.visualstudio.com/。如果您不想下载此编辑器,也可以直接导航到vscode.dev/,它将在您的浏览器中直接启动一个编辑器,您可以通过它连接到 GitHub 仓库或访问本地文件系统上的目录。

  • WebStorm:来自JetBrains的此编辑器提供了出色的 JavaScript 编辑支持。它支持代码补全、自动部署和 JavaScript 调试,直接从编辑器中进行。除此之外,WebStorm 对 GitHub(和其他版本控制系统)有出色的支持。您可以从www.jetbrains.com/webstorm/下载试用版。

  • Notepad++:Notepad++是一个通用编辑器,支持多种编程语言的代码高亮显示。它可以轻松地布局和格式化 JavaScript。请注意,Notepad++仅适用于 Windows。您可以从notepad-plus-plus.org/下载 Notepad++。

  • Sublime Text Editor:Sublime 是一个优秀的编辑器,提供了非常好的 JavaScript 编辑支持。除此之外,它还提供了许多非常有用的选择(如多行选择)和编辑选项,一旦您习惯了它们,就能提供一个真正优秀的 JavaScript 编辑环境。Sublime 可以免费测试,并可以从www.sublimetext.com/下载。

即使您不使用这些编辑器,也有很多编辑器可供选择,包括开源和商业的,您可以使用它们来编辑 JavaScript 并创建您的 Three.js 项目,因为您需要的只是文本编辑的能力。一个您可能想查看的项目是 AWS Cloud9 (c9.io)。这是一个基于云的 JavaScript 编辑器,可以连接到 GitHub 账户。这样,您可以直接访问本书的所有源代码和示例,并对其进行实验。

注意

除了这些基于文本的编辑器,您可以使用它们来编辑和实验本书的源代码,Three.js 目前还提供了一个在线编辑器。

使用这个编辑器,您可以在threejs.org/editor/找到它,通过图形化方式创建 Three.js 场景。

我建议选择 Visual Studio Code。这是一个非常轻量级的编辑器,对 JavaScript 有很好的支持,并且有几个其他扩展,使编写 JavaScript 应用程序变得更加容易。

之前我提到,大多数现代网络浏览器都支持 WebGL,并且可以用来运行 Three.js 示例。我通常在 Firefox 中运行我的代码。原因是,Firefox 通常对 WebGL 有最好的支持和性能,并且它有一个出色的 JavaScript 调试器。使用这个调试器,如图下所示,你可以通过使用断点和控制台输出等方式快速定位问题:

图 1.4 – Firefox 调试器

图 1.4 – Firefox 调试器

注意

本书中的所有示例都可以像在 Firefox 中一样在 Chrome 中运行。所以,如果你选择的是这个浏览器,当然可以使用它。

在本书中,我将为您提供调试器使用和其他调试技巧的指导。现在就介绍到这里;让我们获取源代码,并从第一个场景开始。

获取源代码

本书的所有代码都可在 GitHub 上找到 (github.com/PacktPublishing/Learn-Three.js-Fourth-edition)。GitHub 是一个托管 Git 仓库的网站。你可以使用这些来存储、访问和版本控制源代码。你可以通过以下几种方式获取源代码:你可以做以下任何一种:

  • 克隆 Git 仓库。这意味着你使用 git 命令行工具获取本书源代码的最新版本。

  • 从 GitHub 下载并解压存档,其中包含所有内容。

在接下来的两个小节中,我们将更详细地探讨这些选项。

使用 git 克隆仓库

获取所有示例的一种方法是通过 git 命令行工具克隆此仓库。为此,你需要为你的操作系统下载一个 Git 客户端。如果你有一个最新的操作系统,你可能已经安装了 Git。你可以在终端中快速检查这一点,如下所示:

$ git --version
git version 2.30.1 (Apple Git-130)

如果命令尚未安装,你可以从这里获取客户端和安装说明:git-scm.com。安装 Git 后,你可以使用 git 命令行工具克隆本书的仓库。打开命令提示符并转到你想下载源代码的目录。在该目录中,运行以下命令:

$ git clone https://github.com/PacktPublishing/Learn-Three.js-Fourth-edition.
git clone git@github.com:PacktPublishing/Learn-Three.js-Fourth-edition.git
Cloning into 'learning-threejs-fourth'...
remote: Enumerating objects: 96, done.
remote: Counting objects: 100% (96/96), done.
remote: Compressing objects: 100% (85/85), done.
fetch-pack: unexpected disconnect while reading sideband packet
...

完成此操作后,所有源代码都将下载到 learning-threejs-fourth 目录中。从该目录,你可以运行本书中解释的所有示例。

下载和解压存档

如果你不想使用 git 直接从 GitHub 下载源代码,你也可以下载一个存档。在浏览器中打开 github.com/PacktPublishing/Learn-Three.js-Fourth-edition,点击右侧的 Code 按钮。这将给你一个选项,通过点击 Download ZIP 选项来下载所有源代码的单一 ZIP 文件:

图 1.5 – 从 GitHub 下载存档

图 1.5 – 从 GitHub 下载存档

将其解压到您选择的目录后,所有示例都将可用。

注意

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

现在你已经下载或克隆了源代码,让我们快速检查一切是否正常工作,并熟悉目录结构。

测试和实验示例

代码和示例按章节组织,并且,我们将提供一个简单的集成服务器,您可以使用它来访问所有示例。要启动并运行此服务器,我们需要安装 Node.jsnpm。这些工具用于管理 JavaScript 包和构建 JavaScript 应用程序,并使我们的 Three.js 代码模块化以及集成现有的 JavaScript 库变得更加容易。

要安装这两个工具,请访问 nodejs.org/en/download/ 并选择适合您操作系统的适当安装程序。安装完成后,打开一个终端并检查一切是否正常。在我的机器上,以下版本正在使用:

$ npm --version
8.3.1
$ node --version
v16.14.0

一旦安装了这些工具,我们需要在构建和访问示例之前执行几个步骤来获取所有外部需要的依赖项:

  1. 首先,我们需要下载示例中使用的所有外部库。例如,Three.js 是我们需要下载的依赖项之一。

要下载所有依赖项,请在下载或提取所有示例的目录中运行以下命令:

$ npm install
added 570 packages, and audited 571 packages in 21s

前面的命令将开始下载所有必需的 JavaScript 库并将这些存储在 node_modules 文件夹中。

  1. 接下来,我们需要构建示例。这样做会将我们的源代码和外部库合并成一个文件,我们可以在浏览器中查看。

要使用 npm 构建示例,请使用以下命令:

$ npm run build
> ltjs-fourth@1.0.0 build
> webpack build
...

注意,您只需运行前面的两个命令一次。

  1. 有了这些,所有示例都将构建完成,并准备好供你探索。要打开这些示例,你需要一个网络服务器。要启动服务器,只需运行以下命令:

    $ npm run serve
    
    > ltjs-fourth@1.0.0 serve
    
    > webpack serve –open
    
    <i> [webpack-dev-server] Project is running at:
    
    <i> [webpack-dev-server] Loopback: http://localhost:8080/
    
    <i> [webpack-dev-server] On Your Network (Ipv4): http://192.168.68.144:8080/
    
    <i> [webpack-dev-server] On Your Network (Ipv6): http://[fe80::1]:8080/
    

到目前为止,你可能已经注意到 npm 已经打开了您的默认浏览器,并显示了 http://localhost:8080 的内容(如果这不是这种情况,只需打开您选择的浏览器并导航到 http://localhost:8080)。您将看到一个所有章节的概述。在每个这些子文件夹中,您将找到该章节中解释的示例:

图 1.6 – 所有章节和示例概述

图 1.6 – 所有章节和示例概述

这个服务器的一个非常有趣的特点是,我们现在可以看到我们对源代码所做的更改立即反映在浏览器中。如果你是通过运行npm run serve来启动服务器的,请打开你从下载的源中在编辑器中打开的chapter-01/geometries.js示例,并更改一些内容;你会在保存更改后看到,在浏览器中这些更改也是同时发生的。这使得测试更改和微调颜色和灯光变得容易得多。如果你在你的代码编辑器中打开chapter-01/geometries.js文件,并在你的浏览器中打开http://localhost:8080/chapter-01/geometries.html示例,你就可以看到这个功能是如何工作的。在你的编辑器中,更改立方体的颜色。为此,找到以下代码:

initScene(props)(({ scene, camera, renderer, orbitControls }) => {
  const geometry = new THREE.BoxGeometry();
  const cubeMaterial = new THREE.MeshPhongMaterial({
    color: 0xFF0000,
  });

更改为以下内容:

initScene(props)(({ scene, camera, renderer, orbitControls }) => {
  const geometry = new THREE.BoxGeometry();
  const cubeMaterial = new THREE.MeshPhongMaterial({
    color: 0x0000FF,
  });

现在,当你保存文件时,你会立即看到浏览器中立方体的颜色发生了变化,而无需刷新浏览器或做其他任何事情。

注意

本书中所使用的设置是众多可用于开发 Web 应用的多种方法之一。或者,你也可以直接在 HTML 文件中包含 Three.js(以及其他库),或者使用与 Three.js 网站上的示例相同的import-maps方法。所有这些方法都有其优缺点。对于本书,我们选择了一种方法,这使得在浏览器中实验源代码变得容易,并且与这些类型的应用程序通常的构建方式非常相似。

要了解所有这些是如何协同工作的,一个很好的起点是查看我们在浏览器中打开的 HTML 文件。

探索 Three.js 应用程序的 HTML 结构

在本节中,我们将查看geometries.html文件的源代码。你可以通过在浏览器中查看源代码或从与本书源代码下载相同位置的dist/chapter-1文件夹中打开文件来完成此操作:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body {
      margin: 0;
    }
  </style>
  <script defer src="../js/vendors-node_modules_three_
    build_three_module_js.js"></script>
  <script defer src="../js/vendors-node_modules_lil-gui_
    dist_lil-gui_esm_js.js"></script>
  <script defer src="../js/vendors-node_modules_three_
    examples_jsm_controls_OrbitControls_js.js"></script>
  <script defer src="img/geometries.js"></script>
</head>
<body>
</body>
</html>

这段代码是在你运行npm run build步骤时生成的。这将把所有你使用的源代码和外部库组合成单独的源文件(称为包),并将它们添加到这个页面中。所以,你不需要自己这样做。前三个<script>标签指的是我们使用的任何外部库。在本书的后面部分,我们将介绍其他库,如<style><body><style>用于禁用页面上的任何边距,这样我们就可以使用完整的浏览器视口来显示我们的 3D 场景。此外,我们将通过编程方式将 3D 场景添加到空的<body>元素中,我们将在下一节中解释。

如果你确实想在这里添加自定义的 HTML 元素,当然可以。在下载的代码根目录下,你会找到一个template.html文件,该文件在构建过程中被用来为示例创建单独的 HTML 文件。你添加到那里的任何内容都将被添加到所有示例中。我们不会深入探讨其工作原理,因为这超出了本书的范围。然而,如果你想了解更多关于其工作原理的信息,以下是一些关于webpack(我们用于此)的优质资源:

  • webpack 入门指南:webpack.js.org/guides/getting-started/。这个网站包含一个教程,解释了为什么我们需要 webpack 进行 JavaScript 开发,以及基本概念是如何工作的。

  • 关于HTML webpack 插件的信息:github.com/jantimon/html-webpack-plugin。在这里,你可以找到关于我们使用的 webpack 插件的信息,该插件用于将源代码组合成你在运行npm run build并在之后运行npm run serve后打开浏览器时看到的单独 HTML 页面。

注意,我们不必显式初始化场景或调用 JavaScript。每次我们打开这个页面,并且geometries.js文件被加载时,该文件中的 JavaScript 就会运行并创建我们的 3D 场景。

现在我们已经设置了基本结构,我们可以创建并渲染我们的第一个场景。

渲染和查看 3D 对象

在本节中,你将创建你的第一个场景,这是一个看起来像这样的简单 3D 场景:

图 1.7 – 包含两个标准几何形状的第一个场景

图 1.7 – 包含两个标准几何形状的第一个场景

在前面的屏幕截图中,你可以看到两个旋转的对象。这些对象被称为网格。网格描述了对象的几何形状——即其形状——并包含有关对象材料的信息。网格决定了形状如何通过如颜色等特性在屏幕上显示,或者对象是闪亮的还是透明的。

在前面的屏幕截图中,我们可以识别出这三个网格:

对象 描述
平面 这是一个二维矩形,作为地面区域。在图 1.7 中,你可以看到它,因为它显示了两个网格的阴影。我们将创建一个非常大的矩形,这样你就看不到任何边缘。
立方体 这是一个三维立方体,如图 1.7 左边的所示。它以红色渲染。
环面结 这是图 1.7 右边可以看到的环面结。这个以绿色渲染。

图 1.8 – 场景中对象的概述

要将所有这些显示在屏幕上,我们需要执行几个步骤,我们将在接下来的章节中解释。

设置场景

每个 Three.js 应用程序至少需要一个相机、一个场景和一个渲染器。场景是包含所有对象(网格、相机和灯光)的容器,相机确定渲染时显示场景的哪个部分,渲染器负责在屏幕上创建输出,考虑到场景中网格、相机和灯光的所有信息。

我们将要讨论的所有代码都可以在 chapter-1/getting-started.js 文件中找到。这个文件的基本结构如下:

import * as THREE from "three";
import Stats from 'three/examples/jsm/libs/stats.module'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
// create a scene
...
// setup camera
...
// setup the renderer and attach to canvas
...
// add lights
...
// create a cube and torus knot and add them to the scene
...
// create a very large ground plane
...
// add orbitcontrols to pan around the scene using the
   mouse
...
// add statistics to monitor the framerate
...
// render the scene

如果你回顾前面的步骤,可能会注意到这些步骤对于你创建的每个场景都是相同的。由于这本书中有许多示例展示了 Three.js 的不同功能,我们将把这段代码提取到几个辅助文件中。我们将在本章末尾展示如何做到这一点。现在,我们将逐步介绍不同的步骤,并介绍 Three.js 场景的基本组件。

首先,我们必须创建一个 THREE.Scene。这是一个基本的容器,将包含所有的网格、灯光和相机,并具有一些简单的属性,我们将在下一章中更深入地探讨:

// basic scene setup
const scene = new THREE.Scene();
scene.backgroundColor = 0xffffff;
scene.fog = new THREE.Fog(0xffffff, 0.0025, 50);

在这里,我们将创建一个容器对象,用于存放所有我们的对象,将此场景的背景颜色设置为白色(0xffffff),并在此场景中启用雾效。启用雾效后,远离相机的对象将逐渐被雾隐藏。

下一步是创建相机和渲染器:

// setup camera and basic renderer
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.x = -3;
camera.position.z = 8;
camera.position.y = 2;
// setup the renderer and attach to canvas
const renderer = new THREE.WebGLRenderer({ antialias: true
  });
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0xffffff);
document.body.appendChild(renderer.domElement);

在前面的代码中,我们创建了一个 PerspectiveCamera,它决定了场景的哪个部分被渲染。不要过于担心这一点,因为我们将在 第三章 中详细讨论这些参数,在 Three.js 中处理光源。我们还把相机定位在指定的 x-、y-、z- 坐标上。默认情况下,相机将朝向场景的中心(即 000),所以我们不需要为此做任何改变。

在这个代码片段中,我们还创建了一个 WebGLRenderer,我们将使用它来渲染相机在场景中的视图。现在忽略其他属性;我们将在下一章中详细介绍 WebGLRenderer 的细节,以及如何调整颜色和与阴影一起工作。一个值得注意的有趣部分是 document.body.appendChild(renderer.domElement)。这一步将一个 HTML canvas 元素添加到页面中,显示渲染器的输出。你可以在浏览器中检查页面时看到这一点:

图 1.9 – 由 Three.js 添加的画布

图 1.9 – 由 Three.js 添加的画布

到目前为止,我们有一个空的 THREE.Scene、一个 THREE.PerspectiveCamera 和一个 THREE.WebGLRenderer。如果我们向场景添加一些对象,我们就可以在屏幕上显示一些输出。不过,在我们这样做之前,我们还会添加一些额外的组件:

  • OrbitControls:这将允许你使用鼠标旋转和平移场景

  • 灯光:这允许我们使用一些更高级的材质,投射阴影,并使我们的场景看起来更好

在下一节中,我们将首先添加灯光。

添加灯光

如果场景中没有灯光,大多数材质将被渲染为黑色。所以,为了看到我们的网格(并获得阴影),我们需要在场景中添加一些灯光。在这种情况下,我们将添加两个灯光:

  • THREE.AmbientLight:这只是影响一切物体强度和颜色相同的一个简单灯光。

  • THREE.DirectionalLight:这是一种光线彼此平行的光源。这正是我们体验太阳光的方式。

以下代码片段展示了如何做到这一点:

// add lights
scene.add(new THREE.AmbientLight(0x666666))
const dirLight = new THREE.DirectionalLight(0xaaaaaa)
dirLight.position.set(5, 12, 8)
dirLight.castShadow = true
// and some more shadow related properties

再次,这些灯光可以通过各种方式配置,具体细节我们将在第三章中解释。到目前为止,我们已经准备好了所有渲染场景的组件,所以让我们添加网格。

添加网格

在下面的代码片段中,我们在场景中创建了三个网格:

// create a cube and torus knot and add them to the scene
const cubeGeometry = new THREE.BoxGeometry();
const cubeMaterial = new THREE.MeshPhongMaterial({ color:
  0x0000FF });
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.x = -1;
cube.castShadow = true;
scene.add(cube);
const torusKnotGeometry = new THREE.TorusKnotBufferGeometry(0.5, 0.2, 100, 100);
const torusKnotMat = new THREE.MeshStandardMaterial({
  color: 0x00ff88,
  roughness: 0.1,
});
const torusKnotMesh = new THREE.Mesh(torusKnotGeometry, torusKnotMat);
torusKnotMesh.castShadow = true;
torusKnotMesh.position.x = 2;
scene.add(torusKnotMesh);
// create a very large ground plane
const groundGeometry = new THREE.PlaneBufferGeometry(10000,
  10000)
const groundMaterial = new THREE.MeshLambertMaterial({
  color: 0xffffff
})
const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial)
groundMesh.position.set(0, -2, 0)
groundMesh.rotation.set(Math.PI / -2, 0, 0)
groundMesh.receiveShadow = true
scene.add(groundMesh)
);

在这里,我们创建了一个立方体、一个环面结和地面。所有这些网格都遵循同样的理念:

  1. 我们创建形状——即物体的几何形状:一个THREE.BoxGeometry,一个THREE.TorusKnotBufferGeometry和一个THREE.PlaneBufferGeometry

  2. 我们创建材质。在这种情况下,我们为立方体使用THREE.MeshPhongMaterial,为环面结使用THREE.MeshStandardMaterial,为地面使用THREE.MeshLambertMaterial。立方体的颜色是蓝色,环面结的颜色是绿色的,地面的颜色是白色的。在第四章中,我们将探讨所有这些材质,它们最适合在哪里使用,以及如何配置它们。

  3. 我们告诉 Three.js 立方体和环面结可以投射阴影,而地面将接收阴影。

  4. 最后,从形状和材质中创建一个THREE.Mesh,定位网格,并将其添加到场景中。

到目前为止,我们只需要调用renderer.render(scene, camera)。你将在屏幕上看到结果:

图 1.10 – 几何形状渲染器 – 静态

图 1.10 – 几何形状渲染器 – 静态

如果你已经有了源文件(chapter-01/getting-started.js),请在你的编辑器中打开它;现在也是尝试一些设置的好时机。通过改变torusKnot.position.xtorusKnot.position.ytorusKnot.position.z设置,你可以将环面结在场景中移动(更改将在你在编辑器中保存文件后生效)。你还可以通过改变材质的color属性轻松地更改网格的颜色。

添加动画循环

在这一点上,场景非常静态。你不能移动相机,也没有任何东西在移动。如果我们想对场景进行动画处理,我们首先需要找到一种方法在特定的时间间隔重新渲染场景。在 HTML5 和相关 JavaScript API 出现之前,我们通过使用setInterval(function,interval)函数来完成这个任务。使用setInterval,我们可以指定一个函数,例如,每 100 毫秒被调用一次。这个函数的问题在于它没有考虑到浏览器中的情况。如果你在浏览另一个标签页,这个函数仍然会每隔几毫秒被触发。除此之外,当屏幕重绘时,setInterval并不同步。这可能导致 CPU 使用率更高,闪烁,以及整体性能较差。

幸运的是,现代浏览器通过requestAnimationFrame函数提供了解决方案。

介绍 requestAnimationFrame

使用requestAnimationFrame,你可以指定一个在特定间隔被调用的函数。然而,你并不定义这个间隔。这个间隔由浏览器定义。你需要在提供的函数中完成任何需要的绘图,浏览器将确保尽可能平滑和高效地绘制。使用它很简单。我们只需添加以下代码:

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

在前面的animate函数中,我们再次调用了requestAnimationFrame,以保持动画的持续。我们唯一需要更改的代码是,在创建完整的场景之后,我们不是调用renderer.render,而是调用一次animate()函数来启动动画。如果你运行这个示例,与上一个示例相比,你不会看到任何变化,因为我们没有在这个animate()函数中做任何更改。然而,在我们向这个函数添加更多功能之前,我们将介绍一个小型的辅助库stats.js,它提供了关于动画运行帧率的详细信息。这个库与 Three.js 的作者相同,它渲染一个小型图表,显示场景渲染速率的信息。

要添加这些统计数据,我们只需要导入正确的模块并将其添加到我们的页面中:

import Stats from 'three/examples/jsm/libs/stats.module'
const stats = Stats()
document.body.appendChild(stats.dom)

如果你保持这个状态,你会在屏幕左上角看到一个漂亮的统计计数器,但什么都不会发生。原因是我们需要告诉这个元素我们是否处于requestAnimationFrame循环中。为此,我们只需要在我们的animate函数中添加以下内容:

function animate() {
  requestAnimationFrame(animate);
  stats.update();
  renderer.render(scene, camera);
}
animate();

如果你打开chapter-1/getting-started.html示例,你会看到屏幕左上角显示了一个每秒帧数FPS)计数器:

图 1.11 – FPS 统计信息

图 1.11 – FPS 统计信息

chapter-1/getting-started.html示例中,你可以看到环面结和立方体正在它们的轴上移动。在下一节中,我们将解释如何通过扩展animate()函数来完成这个操作。

网格动画

配置了requestAnimationFrame和统计信息后,我们就有了放置动画代码的地方。我们只需要将以下内容添加到animate()函数中:

cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
cube.rotation.z += 0.01;
torusKnotMesh.rotation.x -= 0.01;
torusKnotMesh.rotation.y += 0.01;
torusKnotMesh.rotation.z -= 0.01;

这看起来很简单,对吧?我们每次调用animate()函数时,都会将每个轴的旋转属性增加 0.01,这表现为网格在所有轴上平滑旋转。如果我们改变位置而不是围绕轴旋转,我们可以移动网格:

let step = 0;
animate() {
  ...
  step += 0.04;
  cube.position.x = 4*(Math.cos(step));
  cube.position.y = 4*Math.abs(Math.sin(step));
  ...
}

对于立方体,我们已经在场景中改变了rotation属性;现在,我们还将改变其position属性。我们希望立方体在场景中的一个点到另一个点之间以平滑的曲线弹跳。为此,我们需要改变其在X轴和y轴上的位置。Math.cosMath.sin函数帮助我们使用step变量创建平滑的轨迹。这里我不会详细介绍它是如何工作的。现在,你需要知道的是,step+=0.04定义了弹跳球体的速度。如果你想自己启用这个功能,请打开chapter-1/geometries.js文件,并取消注释animate()函数中的部分。一旦这样做,你将在屏幕上看到类似这样的效果,其中蓝色的立方体在场景中舞动:

图 1.12 – 跳跃的蓝色立方体

图 1.12 – 跳跃的蓝色立方体

启用轨道控制

如果你尝试用鼠标移动场景,不会发生太多变化。这是因为我们给相机添加了一个固定位置,并且在动画循环中没有更新其位置。当然,我们可以像处理立方体的位置一样做这件事,但 Three.js 自带了几个控制,允许你轻松地在场景中平移并移动相机。在这个例子中,我们将介绍THREE.OrbitControls。有了这些控制,你可以使用鼠标在场景中移动相机并查看不同的对象。为了使这个功能正常工作,我们只需要创建这些控制的新实例,将其附加到相机上,并在动画循环中调用update函数:

const orbitControls = new OrbitControls(camera, renderer.
  domElement)
// and the controller has a whole range of other properties we can set
function animate() {
  ...
  orbitControls.update();
}

现在,你可以使用鼠标在场景中导航。这已经在chapter-1/getting-started.html示例中启用:

图 1.13 – 使用轨道控制进行缩放

图 1.13 – 使用轨道控制进行缩放

在结束本节之前,我们将向我们的基本场景添加一个额外的元素。当与 3D 场景、动画、颜色和属性一起工作时,通常需要一些实验来获得正确的颜色、动画速度或材质属性。如果你有一个简单的GUI,可以让你实时更改这些属性,那就太容易了。幸运的是,你确实有!

使用 lil-gui 来控制属性并简化实验

在上一个示例中,我们为环面结和立方体添加了一点点动画。现在,我们将创建一个简单的 UI 元素,使我们能够控制旋转的速度和移动。为此,我们将使用来自lil-gui.georgealways.com/lil-gui库。这个库允许我们快速创建一个简单的控制 UI,使实验场景变得更加容易。它可以按照以下方式添加:

import GUI from "lil-gui";
...
const gui = new GUI();
const props = {
  cubeSpeed: 0.01,
  torusSpeed: 0.01,
};
gui.add(props, 'cubeSpeed', -0.2, 0.2, 0.01)
gui.add(props, 'torusSpeed', -0.2, 0.2, 0.01)
function animate() {
  ...
  cube.rotation.x += props.cubeSpeed;
  cube.rotation.y += props.cubeSpeed;
  cube.rotation.z += props.cubeSpeed;
  torusKnotMesh.rotation.x -= props.torusSpeed;
  torusKnotMesh.rotation.y += props.torusSpeed;
  torusKnotMesh.rotation.z -= props.torusSpeed;
  ...
}

在前面的代码片段中,我们创建了一个新的控制元素(new GUI)并配置了两个控制:cubeSpeedtorusSpeed。在每次动画步骤中,我们只需查找当前值并使用这些值来旋转网格。现在,我们可以实验这些属性,而无需在浏览器和编辑器之间切换。你将在本书的大多数示例中看到这个 UI,我们提供它,以便你可以轻松地玩转材料、灯光和其他 Three.js 对象提供的不同选项。在下面的屏幕截图中,你可以看到用于控制场景的控件,位于屏幕的右上角:

图 1.14 – 使用控件修改场景属性

图 1.14 – 使用控件修改场景属性

在我们进入本章的最后部分之前,这里有一个关于我们迄今为止所展示内容的快速说明。你可以想象,大多数场景都需要相当相同的设置。它们都需要一些灯光、一个相机、一个场景,也许还有一个地面。为了避免在每个示例中添加所有这些,我们将大多数这些常见元素外部化到一个辅助库集中。这样,我们可以保持示例干净整洁,只展示与该示例相关的代码。如果你对如何设置感兴趣,可以查看bootstrap文件夹中的文件,它将这种方法整合在一起。

在上一个示例中,我们在场景中渲染了一些简单的网格,并直接定位它们。有时,确定物体的位置或旋转角度可能很困难。Three.js 提供了几个不同的辅助工具,它们为你提供了有关场景的额外信息。在下一节中,我们将探讨这些辅助函数中的几个。

辅助对象和实用函数

在我们进入下一章之前,我们将快速介绍几个辅助函数和对象。这些辅助工具使定位物体和查看场景中的情况变得更加容易。要看到这个动作的实际效果,最简单的方法是在浏览器中打开chapter-01/porsche.html示例:

图 1.15 – 带辅助工具的保时捷示例

图 1.15 – 带辅助工具的保时捷示例

在屏幕的右侧,菜单的底部,你会看到控制区域中的三个按钮:切换 AxesHelper切换 GridHelper切换 PolarGridHelper。当你点击任何一个按钮时,Three.js 会在屏幕上添加一个覆盖层,这可以帮助你定位和定位网格,确定所需的旋转,并检查你对象的尺寸。例如,当我们切换AxesHelper时,我们将在场景中看到x-y-z-轴:

图 1.16 – 启用 AxesHelper 的保时捷示例

图 1.16 – 启用 AxesHelper 的保时捷示例

注意,在这个例子中,你可以看到一个更广泛的控制用户界面,你还可以控制WebGLRenderer的各个方面。

摘要

这就是第一章的全部内容。在本章中,你学习了如何设置你的开发环境,如何获取代码,以及如何开始使用本书提供的示例。然后,你学习了要使用 Three.js 渲染场景,你必须创建一个THREE.Scene对象,并添加一个相机、光源以及你想要渲染的对象。我们还展示了你如何通过添加动画来扩展这个基本场景。最后,我们添加了几个辅助库。我们使用了lil-GUI,它允许你快速创建控制用户界面,我们还添加了一个帧率计数器,它通过反馈帧率和其他指标来显示你的场景是如何渲染的。

所有这些项目都将帮助你理解即将到来的章节中的示例,并使你更容易尝试更高级的示例,并开始根据你的喜好修改它们。如果在接下来的几章中你进行实验时,东西坏了或者没有达到你预期的结果,请记住我们在本章中展示的内容:使用 JavaScript 控制台获取更多信息,添加调试语句,使用 Three.js 提供的辅助工具,或者添加自定义控制元素。

在下一章中,我们将扩展这里展示的基本设置,你将学习更多关于你可以用在 Three.js 中的最重要的构建块。

第二章:构成 Three.js 应用程序的基本组件

在上一章中,您学习了 Three.js 的基础知识。我们查看了一些示例,并创建了您的第一个完整的 Three.js 应用程序。在本章中,我们将更深入地探讨 Three.js,并解释构成 Three.js 应用程序的基本组件。

到本章结束时,您将学会如何使用每个 Three.js 应用程序中使用的标准组件,并应该能够使用这些标准组件创建简单的场景。您还应该能够舒适地使用使用更高级对象的 Three.js 应用程序,因为 Three.js 对简单和高级组件使用的方法是相同的。

在本章中,我们将涵盖以下主题:

  • 创建场景

  • 几何形状和网格之间的关系

  • 使用不同摄像机渲染不同场景

我们首先将探讨如何创建场景并添加对象。

创建场景

第一章,“使用 Three.js 创建您的第一个 3D 场景”,您创建了THREE.Scene,因此您已经了解了一些 Three.js 的基本知识。我们了解到,为了让场景显示任何内容,我们需要四种不同类型的对象:

  • THREE.Scene在屏幕上渲染。

  • 灯光:这些对材料的外观有影响,并在创建阴影效果时使用(在第三章,“在 Three.js 中处理光源”中详细讨论)。

  • 网格:这些是从摄像机视角渲染的主要对象。这些对象包含构成几何形状(例如,球体或立方体)的顶点和面,并包含一个材料,该材料定义了几何形状的外观。

  • 渲染器:它使用摄像机和场景中的信息在屏幕上绘制(渲染)输出。

THREE.Scene是您想要渲染的灯光和网格的主要容器。THREE.Scene本身并没有太多选项和功能。

THREE.Scene 是一种结构,有时也被称为场景图。场景图可以包含图形场景所需的所有必要信息。在 Three.js 中,这意味着一个 THREE.Scene 包含了所有必要的渲染对象。值得注意的是,场景图,正如其名称所暗示的,不仅仅是一个对象的数组;场景图由树结构中的一组节点组成。正如我们将在 第八章 中看到,创建和加载高级网格和几何体,Three.js 提供了你可以用来创建不同网格或灯光组的对象。你主要使用的对象,你可以用它来创建场景图,是 THREE.Group。正如其名称所暗示的,这个对象允许你将对象分组在一起。THREE.MeshTHREE.Scene 也都扩展自 Three.js 中的另一个基类 THREE.Object3D,它提供了一套标准函数来添加和修改子对象。THREE.MeshTHREE.Scene 都也扩展自 THREE.Object3D,因此你也可以使用它们来创建嵌套结构。但按照惯例,并且从语义上讲更正确,使用 THREE.Group 来构建场景图。

场景的基本功能

探索场景功能最好的方式是查看一个例子。在本章的源代码中,你可以找到 chapter-2/basic-scene.html 示例。我们将使用这个例子来解释场景具有的各种功能和选项。当我们在这个例子中打开浏览器时,输出将类似于下一张截图所示(请记住,你可以使用鼠标移动、缩放和绕渲染场景平移):

图 2.1 – 基本场景设置

图 2.1 – 基本场景设置

前面的图看起来像我们在 第一章 中看到的例子,使用 Three.js 创建您的第一个 3D 场景。尽管场景看起来相当空旷,但它已经包含了一些对象:

  • 我们有 THREE.Mesh,它代表你可以看到的地面区域

  • 我们使用 THREE.PerspectiveCamera 来确定我们正在看什么

  • 我们添加了 THREE.AmbientLightTHREE.DirectionalLight 来提供照明

这个示例的源代码可以在 basic-scene.js 中找到,并且我们可以使用来自 bootstrap/bootstrap.jsbootstrap/floor.jsbootstrap/lighting.js 的代码,因为这是一个我们在整本书中使用的通用场景设置。所有这些文件中发生的事情可以简化如下代码:

// create a camera
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
// create a renderer
const renderer = new THREE.WebGLRenderer({ antialias: true
  });
// create a scene
const scene = new THREE.Scene();
// create the lights
scene.add(new THREE.AmbientLight(0x666666));
scene.add(THREE.DirectionalLight(0xaaaaaa));
// create the floor
const geo = new THREE.BoxBufferGeometry(10, 0.25, 10, 10,
  10, 10);
const mat = new THREE.MeshStandardMaterial({ color:
  0xffffff,});
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);

如前述代码所示,我们创建了 THREE.WebGLRendererTHREE.PerspectiveCamera,因为我们始终需要这些。接下来,我们创建了一个 THREE.Scene 并添加了我们想要使用的所有对象。在这种情况下,我们添加了两个灯光和一个网格。现在,我们已经拥有了启动渲染循环的所有组件,正如我们在 第一章 中看到的,使用 Three.js 创建您的第一个 3D 场景

在我们更深入地查看THREE.Scene对象之前,我们首先解释一下在演示中你可以做什么,然后看看代码。在浏览器中打开chapter-2/basic-scene.html示例,查看右上角的Controls菜单,如下截图所示:

图 2.2 – 基本场景设置,使用 Cubemap 背景

图 2.2 – 基本场景设置,使用 Cubemap 背景

添加和移除对象

使用这些THREE.Scene。我们将首先看看你如何向场景中添加和移除THREE.Mesh对象。以下代码显示了当你点击addCube按钮时调用的函数:

const addCube = (scene) => {
  const color = randomColor();
  const pos = randomVector({
    xRange: { fromX: -4, toX: 4 },
    yRange: { fromY: -3, toY: 3 },
    zRange: { fromZ: -4, toZ: 4 },
  });
  const rotation = randomVector({
    xRange: { fromX: 0, toX: Math.PI * 2 },
    yRange: { fromY: 0, toY: Math.PI * 2 },
    zRange: { fromZ: 0, toZ: Math.PI * 2 },
  });
  const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
  const cubeMaterial = new THREE.MeshStandardMaterial({
    color: color,
    roughness: 0.1,
    metalness: 0.9,
  });
  const cube = new THREE.Mesh(geometry, cubeMaterial);
  cube.position.copy(pos);
  cube.rotation.setFromVector3(rotation);
  cube.castShadow = true;
  scene.add(cube);
};

让我们详细理解前面的代码:

  • 首先,我们为即将添加的立方体确定了一些随机设置:一个随机颜色(通过调用randomColor()辅助函数)、一个随机位置和一个随机旋转。后两个是通过调用randomVector()随机生成的。

  • 接下来,我们创建我们想要添加到场景中的几何体:一个立方体。我们只需为这个立方体创建一个新的THREE.BoxGeometry,定义一个材质(在这个例子中是THREE.MeshStandardMaterial),然后将这两个结合起来形成THREE.Mesh。我们使用随机变量来设置立方体的位置和旋转。

  • 最后,通过调用scene.add(cube),这个THREE.Mesh可以被添加到场景中。

在前面的代码中,我们引入了一个新元素,那就是我们使用name属性也给立方体起了一个名字。名称设置为cube-,后面加上场景中当前对象的数量(scene.children.length)。名称对于调试非常有用,也可以用来直接从你的场景中访问对象。如果你使用THREE.Scene.getObjectByName(name)函数,你可以直接检索一个特定的对象,例如,改变它的位置,而不必将 JavaScript 对象设置为全局变量。

也可能存在这样的情况,你想要从一个THREE.Scene中移除一个现有的对象。由于THREE.Scene通过children属性公开了其所有子对象,我们可以使用以下简单的代码来移除最后添加的子对象:

const removeCube = (scene) => {
  scene.children.pop();
};

Three.js 还为THREE.Scene提供了其他有用的函数,这些函数与处理场景的子对象相关:

  • add:我们已经看到了这个函数,它将提供的对象添加到场景中。如果它之前被添加到不同的THREE.Object3D中,它将从那个对象中移除。

  • Attach:这与add类似,但如果你使用它,应用到此对象的任何旋转或平移都将被保留。

  • getObjectById:当你将对象添加到场景中时,它会获得一个 ID。第一个获得1,第二个获得2,依此类推。使用这个函数,你可以根据这个 ID 获取一个子对象。

  • getObjectByName:这个函数根据对象的name属性返回一个对象。你可以为对象设置一个名称——这与由 Three.js 分配的id属性形成对比。

  • Remove:这将从这个场景中移除此对象。

  • Clear:这将从场景中移除所有子对象。

注意,前面的函数实际上是从 THREE.Scene 扩展的基对象:THREE.Object3D

在整本书中,如果我们想要操作场景的子对象(或者在 THREE.Group 中,我们将在后面探讨),我们会使用这些函数。

除了添加和删除对象的功能外,THREE.Scene 还提供了一些其他设置。我们将首先查看的是添加雾效。

添加雾效

fog 属性允许您为整个场景添加雾效;物体离相机越远,就越会被隐藏在视线之外。这可以在下面的屏幕截图中看到:

图 2.3 – 使用雾隐藏对象

图 2.3 – 使用雾隐藏对象

要最好地看到添加的雾效,请使用鼠标进行缩放,您将看到立方体受到雾效的影响。在 Three.js 中启用雾效非常简单。只需在定义了您的场景之后添加以下代码行:

scene.fog = new THREE.Fog( 0xffffff, 1, 20 );

在这里,我们定义了白色雾 (0xffffff)。其他两个属性可以用来调整雾的出现方式。1 值设置了 near 属性,而 20 值设置了 far 属性。有了这些属性,您可以确定雾开始的位置以及它变得多密集。使用 THREE.Fog 对象,雾是线性增加的。在 chapter-02/basic-scene.html 示例中,您可以通过使用屏幕右侧的菜单来修改这些属性,以查看这些设置如何影响您在屏幕上看到的内容。

Three.js 还提供了一个替代的雾实现,THREE.FogExp2

scene.fog = new THREE.FogExp2( 0xffffff, 0.01 );

这次,我们没有指定近点和远点,只是颜色 (0xffffff) 和雾的密度 (0.01)。通常,最好对这些属性进行一些实验,以获得您想要的效果。

场景的另一个有趣特性是您可以配置背景。

改变背景

我们已经看到,我们可以通过设置 WebGLRendererclearColor 来改变背景颜色,如下所示:renderer.setClearColor(backgroundColor)。您也可以使用 THREE.Scene 对象来改变背景。为此,您有三个选项:

  • 选项 1:您可以使用纯色。

  • 选项 2:您可以使用一个纹理,这基本上是一个图像,被拉伸以填充整个屏幕。(关于纹理的更多信息,请参阅 第十章加载和操作纹理。)

  • 选项 3:您可以使用环境贴图。这也是一种纹理,但它完全包围了相机,并在您改变相机方向时移动。

注意,这设置了我们要渲染的 HTML 画布的背景颜色,而不是 HTML 页面的背景颜色。如果您想要一个透明的画布,需要将渲染器的 alpha 属性设置为 true

new THREE.WebGLRenderer({ alpha: true }}

在右侧的chapter-02/basic-scene.html菜单中,有一个下拉菜单显示了所有这些不同的设置。如果你从backGround下拉菜单中选择Texture选项,你会看到以下内容:

图 2.4 – 使用纹理作为背景

图 2.4 – 使用纹理作为背景

我们将在第十章加载和使用纹理中更详细地介绍纹理和立方体贴图。但现在,我们将快速查看如何配置这些以及场景的简单背景颜色(此代码的来源可以在controls/scene-controls.js中找到):

// remove any background by setting the background to null
scene.background = null;
// if you want a simple color, just set the background to a
  color
scene.background = new THREE.Color(0x44ff44);
// a texture can be loaded with a THREE.TextureLoader
const textureLoader = new THREE.TextureLoader();
textureLoader.load(
  "/assets/textures/wood/abstract-antique-
    backdrop-164005.jpg",
  (loaded) => {
    scene.background = loaded;
  }
// a cubemap can also be loaded with a THREE.TextureLoader
     textureLoader.load("/assets/equi.jpeg", (loaded) => {
  loaded.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = loaded;
});

从前面的代码中可以看出,你可以将nullTHREE.ColorTHREE.Texture分配给场景的background属性。加载纹理或立方体贴图是异步进行的,因此,我们必须等待THREE.TextureLoader加载图像数据后,才能将其分配给背景。在立方体贴图的情况下,我们需要额外一步,并告诉 Three.js 我们加载了什么类型的纹理。当我们将深入探讨纹理的工作原理时,我们将在第十章加载和使用纹理中详细介绍。

如果你回顾一下下面代码段的开头,你会看到我们是如何创建添加到场景中的立方体的:

const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const cubeMaterial = new THREE.MeshStandardMaterial({
  color: color,
  roughness: 0.1,
  metalness: 0.9,
});
const cube = new THREE.Mesh(geometry, cubeMaterial);

在前面的代码中,我们创建了一个几何体并指定了一个材质。THREE.Scene对象还提供了一种强制场景中网格使用相同材质的方法。在下一节中,我们将探讨这是如何工作的。

更新场景中所有材料

THREE.Scene有两个属性会影响场景中网格的材质。第一个是overrideMaterial属性。首先,让我们演示一下它是如何工作的。在chapter-02/basic-scene.html页面上,你可以点击THREE.MeshNormalNormal材质:

图 2.5 – 使用 MeshNormalMaterial 覆盖网格材料

图 2.5 – 使用 MeshNormalMaterial 覆盖网格材料

如前图所示,现在所有对象(包括地面)现在都使用相同的材质 – 在这种情况下,THREE.MeshNormalMaterial。这种材质根据网格相对于相机的方向(其法线向量)为网格的每个面着色。这可以通过在代码中简单地调用scene.overrideMaterial = new THREE.MeshNormalMaterial();来实现。

除了将完整的材质应用到场景中,Three.js 还提供了一种方法来设置每个网格材质的环境贴图属性为相同的值。环境贴图模拟网格所在的环境(例如,一个房间、户外或洞穴)。环境贴图可用于在网格上创建反射,使其看起来更真实。

在上一节关于背景的章节中,我们已经看到了如何加载环境贴图。如果我们想让所有材料都使用环境贴图以获得更动态的反射和阴影,我们可以将加载的环境贴图分配给场景的 environment 属性:

textureLoader.load("/assets/equi.jpeg", (loaded) => {
  loaded.mapping = THREE.EquirectangularReflectionMapping;
  scene.environment = loaded;
});

最好的方式是通过切换 chapter-02/basic-scene.html 示例来演示前面的代码。如果您现在将镜头拉近到立方体上,您可以看到它们的表面反射了部分环境,并且不再是纯色:

图 2.6 – 将环境贴图设置到场景中的所有网格上

图 2.6 – 将环境贴图设置到场景中的所有网格上

现在我们已经讨论了所有要渲染的对象的基本容器,在下一节中,我们将更详细地探讨可以添加到场景中的对象(THREE.Mesh 结合 THREE.Geometry 和材质)。

几何形状和网格之间的关系

在迄今为止的每个示例中,您都看到了几何形状和网格的使用。例如,为了创建一个球体并将其添加到场景中,我们使用了以下代码:

const sphereGeometry = new THREE.SphereGeometry(4, 20, 20);
const sphereMaterial = new THREE.MeshBasicMaterial({color:
  0x7777ff);
const sphere = new THREE.Mesh(sphereGeometry,
  sphereMaterial);
scene.add(sphere);

我们定义了几何形状(THREE.SphereGeometry),这是物体的形状,以及其材质(THREE.MeshBasicMaterial),然后我们将这两个结合在一个网格(THREE.Mesh)中,可以将其添加到场景中。在本节中,我们将更详细地探讨几何形状和网格。我们将从几何形状开始。

几何形状的属性和函数

Three.js 自带一套大量的几何形状,您可以直接在 3D 场景中使用。只需添加一个材质,创建一个网格,您就基本完成了。以下截图来自 chapter-2/geometries 示例,展示了 Three.js 中可用的几个标准几何形状:

图 2.7 – 场景中可用的一些基本几何形状

图 2.7 – 场景中可用的一些基本几何形状

第五章学习与几何形状一起工作,和 第六章探索高级几何形状 中,我们将探索 Three.js 提供的所有基本和高级几何形状。现在,我们将更详细地看看几何形状实际上是什么。

在 Three.js 中,以及在大多数其他 3D 库中,几何形状基本上是在三维空间中点的集合,也称为顶点(一个单独的点称为顶点),以及连接这些点的多个面。以立方体为例:

  • 一个立方体有八个角。每个角都可以定义为 x-y-z- 坐标。因此,每个立方体在三维空间中有八个点。

  • 一个立方体有六个面,每个角都有一个顶点。在 Three.js 中,一个面始终由三个顶点组成,这三个顶点形成一个三角形(三角形有三个边)。因此,在立方体的例子中,每个面由两个三角形组成,以形成一个完整的面。这种外观的例子可以在 *图 2**.7 中看到,通过观察红色的立方体。

当你使用 Three.js 提供的几何体之一时,你不必自己定义所有的顶点和面。对于一个立方体,你只需要定义宽度、高度和深度。Three.js 使用这些信息并创建一个具有八个顶点且位置正确的几何体,并且具有正确的面数(立方体的情况下是 12 个面——每边两个三角形)。尽管你通常使用 Three.js 提供的几何体或自动生成它们,但你仍然可以使用顶点和面完全手动创建几何体,尽管这可能会很快变得复杂,如以下代码行所示:

const v = [
    [1, 3, 1],
    [1, 3, -1],
    [1, -1, 1],
    [1, -1, -1],
    [-1, 3, -1],
    [-1, 3, 1],
    [-1, -1, -1],
    [-1, -1, 1]]
const faces = new Float32Array([
  ...v[0], ...v[2], ...v[1],
  ...v[2], ...v[3], ...v[1],
  ...v[4], ...v[6], ...v[5],
  ...v[6], ...v[7], ...v[5],
  ...v[4], ...v[5], ...v[1],
  ...v[5], ...v[0], ...v[1],
  ...v[7], ...v[6], ...v[2],
  ...v[6], ...v[3], ...v[2],
  ...v[5], ...v[7], ...v[0],
  ...v[7], ...v[2], ...v[0],
  ...v[1], ...v[3], ...v[4],
  ...v[3], ...v[6], ...v[4]
]);
const bufferGeometry = new THREE.BufferGeometry();
bufferGeometry.setAttribute("position", new THREE.BufferAttribute(faces, 3));
bufferGeometry.computeVertexNormals();

上述代码展示了如何创建一个简单的立方体。我们在v数组中定义了组成这个立方体的点(即顶点)。从这些顶点中,我们可以创建下一个面。在 Three.js 中,我们需要在一个大的Float32Array中提供所有的faces信息。正如我们提到的,一个面由三个顶点组成。因此,对于每个面,我们需要定义九个值:每个顶点的xyz。由于每个面有三个顶点,所以我们有九个值。为了使阅读更简单,我们使用 JavaScript 中的...(展开)操作符将每个顶点的单独值添加到数组中。因此,...v[0], ...v[2], ...v[1]将在数组中产生以下值:1, 3, 1, 1, -1, 1, 1, 3, 1

注意,你必须注意用于定义面的顶点的顺序。它们定义的顺序决定了 Three.js 认为它是一个正面面(面向摄像机的面)还是一个背面面。如果你创建面,你应该使用顺时针顺序来创建正面面,如果你想创建背面面,则应使用逆时针顺序。

在我们的例子中,我们使用了一些顶点来定义立方体的六个面,每个面有两个三角形。在 Three.js 的早期版本中,你也可以使用四边形而不是三角形。四边形使用四个顶点而不是三个来定义面。使用四边形或三角形哪个更好,在 3D 建模世界中是一个热烈的争论。基本上,在建模期间,通常更倾向于使用四边形,因为它们比三角形更容易增强和光滑。然而,对于渲染和游戏引擎来说,处理三角形通常更容易,因为每个形状都可以非常高效地使用三角形进行渲染。

使用这些顶点和面,我们现在可以创建一个新的THREE.BufferGeometry实例,并将顶点分配给position属性。最后一步是在我们创建的几何体上调用computeVertexNormals()。当我们调用这个函数时,Three.js 会确定每个顶点和面的法向量。这是 Three.js 用来根据场景中的各种灯光(如果你使用THREE.MeshNormalMaterial,可以很容易地可视化)来确定如何着色面的信息。

使用此几何形状,我们现在可以创建一个网格,就像我们之前看到的那样。我们创建了一个示例,你可以用它来调整顶点的位置,这同时也显示了单个面。在我们的chapter-2/custom-geometry示例中,你可以改变立方体的所有顶点的位置,并查看面如何反应。这如下面的截图所示:

图 2.8 – 移动顶点以改变形状

图 2.8 – 移动顶点以改变形状

这个示例,与我们的所有其他示例具有相同的设置,有一个渲染循环。每次你更改下拉控制框中的一个属性时,立方体都会根据一个顶点的更改位置进行渲染。这不是一件现成就能做到的事情。出于性能考虑,Three.js 假设网格的几何形状在其生命周期内不会改变。对于大多数几何形状和用例,这是一个非常合理的假设。然而,如果你更改了底层数组(在这种情况下,是const faces = new Float32Array([...])数组),我们需要告诉 Three.js 有变化。你可以通过将相关属性的needsUpdate属性设置为true来实现这一点。这看起来可能如下所示:

mesh.geometry.attributes.position.needsUpdate = true;
mesh.geometry.computeVertexNormals();

注意,在更新顶点的情况下,重新计算法向量也是一个好主意,以确保材料也能正确渲染。关于法向量是什么以及为什么它很重要的更多信息将在第十章加载和操作 纹理中解释。

chapter-2/custom-geometry菜单中有一个按钮我们尚未处理。在右侧的菜单中,有一个clone()函数,正如其名所示,我们可以复制几何形状,例如,用它来创建具有不同材料的不同网格。在相同的示例chapter-2/custom-geometry中,你可以在控制 GUI 的顶部看到一个clone按钮,如下面的截图所示:

图 2.9 – 复制几何形状

图 2.9 – 复制几何形状

如果你点击此按钮,将根据当前几何形状创建一个副本;然后,将创建一个新的具有不同材料的新对象,最后将该对象添加到场景中。此代码如下:

const cloneGeometry = (scene) => {
  const clonedGeometry = bufferGeometry.clone();
  const backingArray = clonedGeometry.getAttribute
    ("position").array;
  // change the position of the x vertices so it is placed
  // next to the original object
  for (const i in backingArray) {
    if ((i + 1) % 3 === 0) {
      backingArray[i] = backingArray[i] + 3;
    }
  }
  clonedGeometry.getAttribute("position").needsUpdate =
    true;
  const cloned = meshFromGeometry(clonedGeometry);
  cloned.name = "clonedGeometry";
  const p = scene.getObjectByName("clonedGeometry");
  if (p) scene.remove(p);
  scene.add(cloned);
};

如前述代码所示,我们使用clone()函数来复制bufferGeometry。一旦复制,我们确保更新每个顶点的x值,以便副本位于与原始不同的位置(我们也可以使用translateX,我们将在本章下一节中解释)。接下来,我们创建一个THREE.Mesh,如果存在则移除复制的网格,并添加新的副本。为了创建新的网格,我们使用一个名为meshFromGeometry的自定义函数。作为一个快速旁白,让我们看看它是如何实现的:

const meshFromGeometry = (geometry) => {
  var materials = [
    new THREE.MeshBasicMaterial({ color: 0xff0000,
      wireframe: true }),
    new THREE.MeshLambertMaterial({
      opacity: 0.1,
      color: 0xff0044,
      transparent: true,
    }),
  ];
  var mesh = createMultiMaterialObject(geometry, materials);
  mesh.name = "customGeometry";
  mesh.children.forEach(function (e) {
    e.castShadow = true;
  });
  return mesh;
};

如果你回顾一下这个例子,你可以看到一个透明的立方体和组成我们几何体的线条(边)。为了做到这一点,我们创建了一个多材质网格。这意味着我们告诉 Three.js 在单个网格中使用两种不同的材质。为此,Three.js 提供了一个名为createMultiMaterialObject的便捷辅助函数,它做了名字暗示的事情。根据一个几何体和一组材质列表,它创建了一个我们可以添加到场景中的对象。但是,当你使用createMultiMaterialObject的返回结果时,有一件事你需要知道。你得到的不只是一个网格;它是一个THREE.Group,一个容器对象,在这个例子中,它包含我们提供的每个材质的单独的THREE.Mesh。因此,当渲染网格时,它看起来像一个单一的对象,但实际上是由多个THREE.Mesh对象叠加渲染而成的。这也意味着,如果我们想要有阴影,我们需要为组内的每个网格启用阴影(这正是我们在前面的代码片段中所做的)。

在前面的代码中,我们使用了THREE.SceneUtils对象的createMultiMaterialObject来为我们创建的几何体添加一个线框。Three.js 还提供了一个使用THREE.WireframeGeometry添加线框的替代方法。假设你有一个名为geom的几何体,你可以从它创建一个线框几何体:const wireframe = new THREE.WireframeGeometry(geom);。接下来,你可以使用Three.LineSegments对象绘制这个几何体的线条,首先创建一个const line = new THREE.LineSegments(wireframe)对象,然后将其添加到场景中:scene.add(line)。由于这个辅助函数内部只是一个THREE.Line对象,你可以设置线框的外观。例如,要设置线框线的宽度,使用line.material.linewidth = 2;

我们已经对THREE.Mesh对象进行了一些了解。在下一节中,我们将更深入地探讨你可以用它做什么。

网格的函数和属性

我们已经了解到,要创建一个网格,我们需要一个几何体和一个或多个材质。一旦我们有了网格,我们将其添加到场景中,它就会被渲染。有一些属性可以帮助我们改变网格在场景中的位置和显示方式。在我们的第一个例子中,我们将查看以下属性和函数:

  • position:这个属性决定了对象相对于其父对象的位置。通常,对象的父对象是一个THREE.Scene对象或一个THREE.Group对象。

  • rotation:使用这个属性,你可以设置对象围绕其自身的任何轴的旋转。Three.js 还提供了围绕单个轴旋转的特定函数:rotateX()rotateY()rotateZ()

  • scale:这个属性允许你围绕对象的x-y-z-轴进行缩放。

  • translateX() / translateY()translateZ():这个属性通过指定量沿着相应的轴移动对象。

  • lookAt():此属性将对象指向空间中的特定向量。这是手动设置旋转的替代方法。

  • visible:此属性确定此网格是否应该被渲染。

  • castShadow:此属性确定当网格被光线击中时是否投射阴影。默认情况下,网格不投射阴影。

当我们旋转一个对象时,我们是在围绕一个轴旋转。在 3D 场景中,有多个空间,你可以围绕其轴旋转。rotateN() 函数在 局部 空间中旋转对象。这意味着对象围绕其父对象的轴旋转。因此,当您将对象添加到场景中时,rotateN() 函数将围绕场景的主轴旋转该对象。当它是嵌套组的一部分时,这些函数将围绕其父对象的轴旋转对象,这通常是您所期望的行为。Three.js 还有一个特定的 rotateOnWorldAxis,它允许您无论对象的实际父对象是什么,都可以围绕主 THREE.Scene 的轴旋转对象。最后,您还可以通过调用 rotateOnAxis 函数强制对象围绕其自身的轴(这称为 对象 空间)旋转。

和往常一样,我们为您准备了一个示例,让您可以对这些属性进行操作。如果您在浏览器中打开 chapter-2/mesh-properties,您会看到一个下拉菜单,您可以在其中更改所有这些属性,并直接看到以下截图所示的结果:

图 2.10 – 网格属性

图 2.10 – 网格属性

让我带您了解这些属性;我将从 position 属性开始。

使用位置属性设置网格的位置

我们已经多次看到这个属性,所以让我们快速了解一下。使用此属性,您设置对象相对于其父对象的 x-y-z- 坐标。我们将在 第五章 学习与几何体一起工作 中回到这一点,当我们查看分组对象时。我们可以以三种不同的方式设置对象的位置属性。我们可以直接设置每个坐标:

cube.position.x = 10;
cube.position.y = 3;
cube.position.z = 1;

然而,我们也可以一次性设置所有这些属性,如下所示:

cube.position.set(10,3,1);

还有第三个选项。position 属性是一个 THREE.Vector3 对象。这意味着我们也可以这样做来设置此对象:

cube.position = new THREE.Vector3(10,3,1)

接下来是列表中的 rotation 属性。您已经在这里和 第一章 使用 Three.js 创建您的第一个 3D 场景 中看到过这个属性被使用过几次。

使用旋转属性定义网格的旋转

使用此属性,您可以为对象设置围绕其轴之一的旋转。您可以以与我们设置位置相同的方式设置此值。完整的旋转,如您可能从数学课中记得的,是 。您可以在 Three.js 中以几种不同的方式配置此属性:

cube.rotation.x = 0.5*Math.PI;
cube.rotation.set(0.5*Math.PI, 0, 0);
cube.rotation = new THREE.Vector3(0.5*Math.PI,0,0);

如果你想要使用度数(从 0 到 360),我们必须将这些转换为弧度。这可以很容易地按照以下方式完成:

const degrees = 45;
const inRadians = degrees * (Math.PI / 180);

在前面的代码块中,我们亲自进行了转换。Three.js 还提供了MathUtils类,它提供了很多有用的转换,包括一个与前面代码块中做同样事情的转换。你可以使用chapter-2/mesh-properties示例来玩转这个属性。

我们列表中的下一个属性是我们之前没有讨论过的:scale。这个名字基本上概括了你可以用这个属性做什么。你可以沿着特定的轴缩放对象。如果你将缩放设置为小于一的值,对象将会缩小,如下面的截图所示:

图 2.11 – 使用缩放缩小网格

图 2.11 – 使用缩放缩小网格

当你使用大于一的值时,对象将会变大,如下面的截图所示:

图 2.12 – 使用缩放放大网格

图 2.12 – 使用缩放来放大网格

我们将要讨论的网格的下一个部分是translate属性。

使用translate属性改变位置

使用translate,你还可以改变对象的位置,但不是定义你想要对象所在的绝对位置,而是定义对象相对于当前位置应该移动的距离。例如,我们有一个添加到场景中的球体,其位置已被设置为(1, 2, 3)。接下来,我们沿着对象的x轴进行平移:translateX(4)。它的位置现在将是(5, 2, 3)。如果我们想将对象恢复到原始位置,我们使用translateX(-4)。在chapter-2/mesh-properties示例中,有一个名为translate的菜单标签,用于xyz的值,并点击translate按钮。你会看到对象根据这三个值移动到新的位置。

我们将要讨论的最后两个属性是用来完全移除对象的,通过将visible属性设置为false,并禁用该对象是否投射阴影,通过将castShadow属性设置为false。当你点击这些按钮时,你会看到立方体变得不可见和可见,并且你可以禁用它投射阴影。

关于网格、几何体以及你可以用这些对象做什么的更多信息,请查看第五章学习与几何体一起工作,以及第七章点和精灵

到目前为止,我们已经了解了 THREE.Scene,这是包含我们想要渲染的所有对象的主要对象,并且我们已经详细探讨了 THREE.Mesh 是什么,以及如何创建一个 THREE.Mesh 并将其放置在场景中。在之前的章节中,我们已经使用摄像机来确定你想要渲染的 THREE.Scene 的哪一部分,但还没有详细解释如何配置摄像机。在下一节中,我们将深入探讨这些细节。

使用不同摄像机渲染不同场景

在 Three.js 中有两种不同的摄像机类型:正交摄像机和透视摄像机。请注意,Three.js 还提供了一些非常具体的摄像机,用于创建可以使用 3D 眼镜或 VR 设备查看的场景。在这本书中,我们不会详细介绍这些摄像机,因为它们的工作方式与本章中解释的摄像机完全相同。如果你对这些摄像机感兴趣,Three.js 提供了一些标准示例:

)

)

)

如果你正在寻找简单的 VR 摄像机,可以使用 THREE.StereoCamera 创建渲染为左右并排的 3D 场景(标准立体效果),使用平行屏障(如 3DS 提供),或者提供一种将不同视图渲染为不同颜色的立体效果。或者,Three.js 对 WebVR 标准有一些实验性支持,该标准被许多浏览器支持(更多信息,请参阅 webvr.info/developers/)。要使用此功能,不需要做太多改变。你只需设置 renderer.vr.enabled = true,Three.js 将处理其余部分。Three.js 网站上有几个示例演示了此属性以及 Three.js 对 WebVR 的其他一些支持功能:threejs.org/examples/

目前,我们将专注于标准透视和正交摄像机。解释这些摄像机之间差异的最好方法是通过查看一些示例。

正交摄像机与透视摄像机的比较

在本章的示例中,你可以找到一个名为 chapter2/cameras 的演示。当你打开这个示例时,你会看到以下内容:

图 2.13 – 透视摄像机视图

图 2.13 – 透视摄像机视图

上述截图被称为透视视图,是最自然的视图。如图所示,立方体离摄像机越远,渲染得越小。如果我们将摄像机更改为 Three.js 支持的另一种类型,即正射摄像机,你会看到相同场景的以下视图:

图 2.14 – 正射摄像机视图

图 2.14 – 正射摄像机视图

使用正射摄像机时,所有立方体都以相同的大小渲染;物体与摄像机之间的距离无关。这通常用于 2D 游戏,例如 CivilizationSimCity 4 的旧版本:

图 2.15 – SimCity 4 中的正射投影使用

图 2.15 – SimCity 4 中的正射投影使用

透视摄像机属性

让我们先更仔细地看看 THREE.PerspectiveCamera。在示例中,你可以设置一些属性,这些属性定义了通过摄像机镜头可以看到的内容:

  • fov: 视场角FOV)是从摄像机位置可以看到的场景部分。例如,人类几乎有 180 度的视场角,而一些鸟甚至有完整的 360 度视场角。但由于正常的计算机屏幕并没有完全填满我们的视野,因此通常选择较小的值。一般来说,对于游戏,选择 60 到 90 度的视场角。良好 默认值: 50

  • aspect: 这是我们在渲染输出区域中渲染的区域的水平尺寸和垂直尺寸之间的宽高比。在我们的例子中,因为我们使用整个窗口,所以我们只使用那个比例。宽高比决定了水平视场角(FOV)和垂直视场角之间的差异。良好 默认值: window.innerWidth / window.innerHeight

  • near: near 属性定义了 Three.js 应该将场景渲染得多靠近摄像机。通常,我们将此设置为一个非常小的值,以便直接从摄像机的位置渲染一切。良好 默认值: 0.1

  • far: far 属性定义了摄像机可以从其位置看到多远。如果我们设置得太低,场景的一部分可能不会被渲染,如果我们设置得太高,在某些情况下,它可能会影响渲染性能。良好 默认值: 100

  • zoom: zoom 属性允许你放大和缩小场景。当你使用小于 1 的数字时,你将场景缩小,如果你使用大于 1 的数字,你将场景放大。注意,如果你指定一个负值,场景将被渲染为颠倒的。良好 默认值: 1

以下图表给出了这些属性如何共同工作以确定你所看到的内容的概述:

图 2.16 – 透视摄像机的属性

图 2.16 – 透视摄像机的属性

相机的fov属性决定了水平视野。基于aspect属性,垂直视野被确定。near属性用于确定近平面的位置,而far属性则决定了远平面的位置。近平面和远平面之间的区域将被渲染如下:

图 2.17 – 远近裁剪渲染网格

图 2.17 – 远近裁剪渲染网格

正射投影相机属性

要配置正射投影相机,我们需要使用其他属性。正射投影对使用何种宽高比或场景的视野范围不感兴趣,因为所有对象都以相同的大小渲染。当你定义一个正射投影相机时,你定义了需要渲染的立方体区域。正射投影相机的属性反映了这一点,如下所示:

  • left:在 Three.js 文档中,left属性被描述为相机的视锥体左平面。你应该将其视为将要渲染的左侧边界。如果你将此值设置为-100,你将看不到任何在左侧位置超过这个值的对象。

  • rightright属性的工作方式与left属性类似,但这次是在屏幕的另一侧。任何在右侧更远的位置都不会被渲染。

  • top:这是要渲染的顶部位置。

  • bottom:这是要渲染的底部位置。

  • near:从这个点开始,根据相机的位置,场景将被渲染。

  • far:到这个点,根据相机的位置,场景将被渲染。

  • zoom:这个属性允许你放大或缩小场景。当你使用小于1的数字时,你会缩小场景;如果你使用大于1的数字,你会放大场景。注意,如果你指定一个负值,场景将被渲染为颠倒的。默认值是1

所有这些属性可以总结如下图所示:

图 2.18 – 正射投影相机的属性

图 2.18 – 正射投影相机的属性

就像透视相机一样,你可以精确地定义你想要渲染的场景区域:

图 2.19 – 使用正射投影相机的裁剪区域

图 2.19 – 使用正射投影相机的裁剪区域

在上一节中,我们解释了 Three.js 支持的不同类型的相机。你已经学习了如何配置它们,以及如何使用它们的属性来渲染场景的不同部分。我们还没有展示的是如何控制相机观察场景的哪个部分。我们将在下一节中解释这一点。

观察特定点

到目前为止,你已经看到了如何创建相机以及各种参数的含义。在第一章 使用 Three.js 创建您的第一个 3D 场景 中,你也看到了你需要将相机放置在场景中的某个位置,并且从该相机看到的视图会被渲染。通常,相机指向场景的中心:位置 (0, 0, 0)。然而,我们可以很容易地更改相机所看的方向,如下所示:

camera.lookAt(new THREE.Vector3(x, y, z));

chapter2/cameras示例中,你也可以指定相机要看的坐标。请注意,当你更改OrthographicCamera设置中的lookAt时,立方体仍然保持相同的大小。

图 2.20 – 更改了正交相机的属性

图 2.20 – 更改了正交相机的lookAt属性

当你使用lookAt函数时,你将相机指向一个特定的位置。你也可以使用这个函数使相机围绕场景中的物体移动。由于每个THREE.Mesh对象都有一个位置,它是一个THREE.Vector3对象,因此你可以使用lookAt函数指向场景中的特定网格。你需要做的只是使用这个:camera.lookAt(mesh.position)。如果你在渲染循环中调用这个函数,你将使相机跟随物体在场景中移动。

调试相机所看的对象

当配置相机时,有一个可以让你尝试不同设置的菜单会非常有帮助。有时,你可能想确切地看到相机将要渲染的区域。Three.js 允许你通过可视化相机的视锥体(相机所显示的区域)来实现这一点。为此,我们只需在场景中添加一个额外的相机并添加一个相机辅助器。要看到这个效果,请打开chapter-2/debug-camera.html示例:

图 2.21 – 显示相机的视锥体

图 2.21 – 显示相机的视锥体

在前面的图中,你可以看到透视相机的视锥体的轮廓。如果你在菜单中更改属性,你会看到视锥体也会随之改变。这个视锥体是通过添加以下内容可视化的:

const helper = new THREE.CameraHelper(camera);
scene.add(helper);
// in the render loop
helper.update();

我们还添加了一个switchCamera按钮,允许你在场景外部观察的相机和场景中的主相机之间切换。这为获取正确的相机设置提供了一种很好的方法:

图 2.22 – 切换相机

图 2.22 – 切换相机

在 Three.js 中切换相机非常简单。你需要做的只是告诉 Three.js 你想要通过不同的相机渲染场景。

摘要

我们在本章的第二部分介绍了许多内容。我们展示了 THREE.Scene 的功能和属性,并解释了如何使用这些属性来配置你的主场景。我们还向你展示了如何创建几何体。你可以从零开始使用 THREE.Buffergeometry 对象创建它们,或者使用 Three.js 提供的任何内置几何体。最后,我们向你展示了如何配置 Three.js 提供的两个主要摄像机。THREE.PerspectiveCamera 使用现实世界的透视来渲染场景,而 THREE.OrthographicCamera 提供了在游戏中经常看到的假 3D 效果。我们还涵盖了在 Three.js 中几何体是如何工作的,你现在可以轻松地创建自己的几何体,无论是从 Three.js 提供的标准几何体中创建,还是通过手工制作。

在下一章中,我们将探讨 Three.js 中可用的各种光源。你将学习不同光源的行为方式,如何创建和配置它们,以及它们如何影响不同的材质。

第三章:在 Three.js 中使用光源

第一章使用 Three.js 创建您的第一个 3D 场景中,你学习了 Three.js 的基础知识,而在第二章构成 Three.js 应用程序的基本组件中,我们更深入地探讨了场景中最重要的一部分:几何体、网格和摄像机。你可能已经注意到,在那个章节中我们跳过了对灯光的详细探讨,尽管它们是每个 Three.js 场景的重要组成部分。没有灯光,我们将看不到任何渲染效果(除非我们使用基本或线框材质)。由于 Three.js 包含了多种不同的光源,每种光源都有特定的用途,我们将利用本章来解释灯光的各个方面细节,并为即将到来的关于材质使用的章节做准备。到本章结束时,你将了解可用的灯光之间的区别,并能够为你的场景选择和配置正确的灯光。

注意

WebGL 本身并不具备内置的光照支持。如果没有 Three.js,你将不得不编写特定的 WebGL 着色器程序来模拟这些类型的光,这相当困难。可以从developer.mozilla.org/en-US/docs/Web/WebGL/Lighting_in_WebGL找到关于从头开始模拟 WebGL 中光照的良好介绍。

在本章中,我们将涵盖以下主题:

  • Three.js 中的不同类型的光照

  • 使用基本光源

  • 使用特殊光源

与所有章节一样,我们提供了大量的示例,你可以使用这些示例来实验灯光的行为。本章中展示的示例可以在提供的源代码的chapter-03文件夹中找到。

Three.js 中提供了哪些光照类型?

Three.js 中提供了几种不同的光源,它们都具有特定的行为和用法。在本章中,我们将讨论以下一组光源:

  • THREE.AmbientLight:这是一种基本光源,其颜色被添加到场景中对象的当前颜色上。

  • THREE.PointLight:这是一个空间中的单一点,光线从这个点向所有方向扩散。这种光可以用来创建阴影。

  • THREE.SpotLight:这种光源具有类似台灯、天花板上的聚光灯或火炬的锥形效果。这种光可以投射阴影。

  • THREE.DirectionalLight:这也被称为无限光。从这个光源发出的光线看起来是平行的,类似于太阳的光线。这种光也可以用来创建阴影。

  • THREE.HemisphereLight:这是一种特殊的光源,可以通过模拟反射表面和微弱照亮的苍穹来创建更自然的外观户外光照。这种光源也不提供任何与阴影相关的功能。

  • THREE.RectAreaLight:使用这个光源,你可以在空间中指定一个区域,光从这个区域发出。THREE.RectAreaLight 不会产生任何阴影。

  • THREE.LightProbe:这是一种特殊类型的光源,根据使用的环境贴图,创建一个动态的环境光源来照亮场景。

  • THREE.LensFlare:这并不是一个光源,但使用 THREE.LensFlare,你可以为场景中的灯光添加镜头光晕效果。

本章分为两个主要部分。首先,我们将查看基本灯光:THREE.AmbientLightTHREE.PointLightTHREE.SpotLightTHREE.DirectionalLight。所有这些灯光都扩展了基本的 THREE.Light 对象,它提供了共享的功能。这里提到的灯光是简单的灯光,设置简单,可以用来重现大多数所需的灯光场景。在第二部分,我们将查看一些特殊用途的灯光和效果:THREE.HemisphereLightTHREE.RectAreaLightTHREE.LightProbeTHREE.LensFlare。你可能只有在非常特定的情况下才需要这些灯光。

与基本灯光一起工作

我们将从最基本的灯光开始:THREE.AmbientLight

THREE.AmbientLight

当你创建一个 THREE.AmbientLight 时,颜色是全局应用的。这种光没有特定的方向来源,THREE.AmbientLight 不会产生任何阴影。通常情况下,你不会将 THREE.AmbientLight 作为场景中唯一的灯光源,因为它以相同的方式将颜色应用到场景中的所有对象上,而不考虑网格的形状。你通常将它与其他灯光源一起使用,例如 THREE.SpotLightTHREE.DirectionalLight,以柔化阴影或为场景添加一些额外的颜色。理解这一点最简单的方法是查看 chapter-03 文件夹中的 ambient-light.html 示例。在这个示例中,你得到一个简单的用户界面,可以用来修改场景中可用的 THREE.AmbientLight 对象。

在以下屏幕截图中,你可以看到我们使用了一个简单的瀑布模型,并使使用的 THREE.AmbientLight 对象的 colorintensity 属性可配置。在这个第一张屏幕截图中,你可以看到当我们把灯光的颜色设置为红色时会发生什么:

图 3.1 – 环境光设置为红色

图 3.1 – 环境光设置为红色

如你所见,现在场景中的每个元素都添加了红色到其原始颜色上。如果我们把颜色改为蓝色,我们会得到类似这样的效果:

图 3.2 – 环境光设置为蓝色

图 3.2 – 环境光设置为蓝色

如此截图所示,蓝色被应用于所有物体,并在整个场景上产生光芒。当你使用这种灯光时,你应该记住的是,你应该非常保守地指定颜色。如果你指定的颜色太亮,你很快就会得到一个完全过饱和的图像。除了颜色之外,我们还可以设置灯光的 intensity 属性。这个属性决定了 THREE.AmbientLight 对场景中颜色的影响程度。如果我们将其调低,只有少量的颜色被应用于场景中的物体。如果我们将其调高,我们的场景就会变得非常明亮:

图 3.3 – 环境光设置为高强度的红色

图 3.3 – 环境光设置为高强度的红色

现在我们已经看到了它的作用,让我们看看如何创建和使用一个 THREE.AmbientLight。以下代码行展示了如何创建一个 THREE.AmbientLight

const color = new THREE.Color(0xffffff);
const light = new THREE.AmbientLight(color);
scene.add(light);

创建一个 THREE.AmbientLight 非常简单,只需要几个步骤。THREE.AmbientLight 没有位置,是全局应用的,所以我们只需要指定颜色并将此灯光添加到场景中。可选地,我们也可以在这个构造函数中提供一个额外的值来指定这种灯光的强度。由于我们没有在这里指定它,它使用默认强度 1

注意,在前面的代码片段中,我们向 THREE.AmbientLight 的构造函数传递了一个显式的 THREE.Color 对象。我们也可以将颜色作为字符串传递 – 例如,"rgb(255, 0, 0)""hsl(0, 100%, 50%)" – 或者作为数字,就像我们在前面的章节中所做的那样:0xff0000。更多关于这方面的信息可以在 使用 THREE.Color 对象 部分找到。

在我们讨论 THREE.PointLightTHREE.SpotLightTHREE.DirectionalLight 之前,首先让我们强调它们的主要区别 – 那就是它们如何发光。以下图表显示了这三个灯光源如何发光:

图 3.4 – 不同光源如何发光

图 3.4 – 不同光源如何发光

你可以从这张图中看到以下内容:

  • THREE.PointLight 从一个特定的点向所有方向发出光

  • THREE.SpotLight 从一个特定的点以锥形形状发出光

  • THREE.DirectionalLight 不是从一个单独的点发出光,而是从二维平面发出光束,其中光束相互平行

我们将在接下来的几节中更详细地探讨这些灯光源。让我们从 THREE.SpotLight 开始。

THREE.SpotLight

THREE.SpotLight 是你经常会用到的灯光之一(特别是如果你想使用阴影)。THREE.SpotLight 是一种具有锥形效果的灯光源。你可以将其与手电筒或灯笼进行比较。这种灯光源有一个方向和一个产生光的角度。以下截图显示了 THREE.SpotLight 的样子(spotlight.html):

图 3.5 – 聚光灯照亮场景

图 3.5 – 聚光灯照亮场景

以下表格列出了您可以使用来微调 THREE.SpotLight 的所有属性。首先,我们将查看特定于光的行为的属性:

名称 描述
Angle 确定从光源发出的光束的宽度。宽度以弧度为单位测量,默认值为 Math.PI/3
castShadow 如果设置为 true,则应用此属性的灯光将创建阴影。有关如何配置阴影的详细信息,请参阅以下表格。
Color 表示光的颜色。
decay 表示随着您远离光源,光强度减弱的量。decay 值为 2 会产生更逼真的光,默认值为 1。此属性仅在将 physicallyCorrectLights 属性设置为 WebGLRenderer 时有效。
distance 当此属性设置为非 0 值时,光强度将从光源位置处的设置强度线性减少到指定距离处的 0
intensity 表示光照射的强度。属性的默认值是 1
penumbra 表示聚光灯硬币边缘的百分比,该边缘被平滑(模糊)到 0。它取值范围在 01 之间,默认值为 0
power 表示在物理正确模式下渲染时光的 power(通过在 WebGLRenderer 上设置 physicallyCorrectLights 属性来启用)。此属性以流明为单位测量,默认值为 4*Math.PI
position 表示光在 THREE.Scene 中的位置。
target 对于 THREE.SpotLight,光的方向很重要。使用 target 属性,您可以指定 THREE.SpotLight 指向场景中的特定对象或位置。请注意,此属性需要一个 THREE.Object3D 对象(例如,THREE.Mesh)。这与我们在 第二章 中看到的相机形成对比,这些相机在 lookAt 函数中使用 THREE.Vector3
visible 如果此属性设置为 true(默认值),则灯光开启,如果设置为 false,则灯光关闭。

图 3.6 – THREE.SpotLight 对象的属性

当您为 THREE.SpotLight 启用阴影时,您可以控制阴影的渲染方式。您可以通过 THREE.SpotLight 的阴影属性来控制,它可以包括以下内容:

名称 描述
shadow.bias 将投射的阴影移向或远离投射阴影的对象。您可以使用此属性来解决在处理非常薄的对象时出现的某些奇怪效果。如果您在模型上看到奇怪的阴影效果,此属性的较小值(例如,0.01)通常可以解决问题。此属性的默认值是 0
shadow.camera.far 确定从多远距离的光源处创建阴影。默认值是 5000。注意,您还可以设置 THREE.PerspectiveCamera 提供的所有其他属性,我们在 第二章 中展示了这些属性。
shadow.camera.fov 确定用于创建阴影的视野大小(参见 第二章 中的 使用不同相机为不同场景 部分)。默认值是 50
shadow.camera.near 确定从多远距离的光源处创建阴影。默认值是 50
shadow.mapSize.widthshadow.mapSize.height 确定用于创建阴影的像素数量。当阴影边缘参差不齐或看起来不光滑时,应增加这些值。渲染场景后,这些值不能更改。两者的默认值都是 512
shadow.radius 当此值设置大于 1 时,阴影的边缘将变得模糊。如果 THREE.WebGlRenderershadowMap.type 属性设置为 THREE.BasicShadowMap,则此值将不会产生任何效果。

图 3.7 – THREE.SpotLight 对象的阴影属性

创建 THREE.SpotLight 非常简单。只需指定颜色,设置您想要的属性,并将其添加到场景中,如下所示:

const spotLight = new THREE.SpotLight("#ffffff")
spotLight.penumbra = 0.4;
spotLight.position.set(10, 14, 5);
spotLight.castShadow = true;
spotLight.intensity = 1;
spotLight.shadow.camera.near = 10;
spotLight.shadow.camera.far = 25;
spotLight.shadow.mapSize.width = 2048;
spotLight.shadow.mapSize.height = 2048;
spotLight.shadow.bias = -0.01;
scene.add(spotLight.target);

在这里,我们创建了一个 THREE.SpotLight 实例,并设置了各种属性以配置灯光。我们还明确地将 castShadow 属性设置为 true,因为我们想要阴影。我们还需要将 THREE.SpotLight 指向某个地方,这通过 target 属性来完成。在我们可以使用此属性之前,我们首先需要将灯光的默认 target 添加到场景中,如下所示:

scene.add(spotLight.target);

默认情况下,目标将被设置为 (0, 0, 0)。在本节的示例中,您可以更改 target 属性的位置,并看到灯光会跟随该对象的位置:

图 3.8 – 聚光灯指向目标

图 3.8 – 聚光灯指向目标

注意,您还可以将灯光的目标设置为场景中的对象。在这种情况下,灯光的方向将指向该对象。如果指向的对象移动,灯光将始终指向该对象。

在本节开头表中,我们展示了可以用来控制 THREE.SpotLight 发出光的方式的一些属性。distanceangle 属性定义了光锥的形状。angle 属性定义了锥体的宽度,而 distance 属性则设置了锥体的长度。以下图表解释了这两个值如何定义将接收 THREE.SpotLight 光照的区域:

图 3.9 – 聚光灯角度和距离

图 3.9 – 聚光灯角度和距离

通常情况下,你不需要设置这些值,因为它们都带有合理的默认值,但你可以使用这些属性,例如,创建一个具有非常窄的光束或快速降低光强度的THREE.SpotLight实例。你可以用来改变THREE.SpotLight产生光的方式的最后一个属性是penumbra属性。使用这个属性,你可以设置从什么位置开始光锥边缘的光强度开始降低。在下面的屏幕截图中,你可以看到penumbra属性的作用结果。我们有一个非常明亮的光源(高强度),当它达到锥形边缘时,强度迅速降低:

图 3.10 – 具有硬边缘晕影的聚光灯

图 3.10 – 具有硬边缘晕影的聚光灯

有时候,仅通过查看渲染的场景,可能很难确定灯光的正确设置。你可能出于性能原因想要微调被照亮的区域,或者尝试将光源移动到非常具体的位置。这可以通过使用THREE.SpotLightHelper来实现:

const spotLightHelper = new THREE.SpotLightHelper
  (spotLight);
scene.add(spotLightHelper)
// in the render loop
spotLightHelper.update();

使用前面的代码,你可以得到一个轮廓,显示了聚光灯的细节,这有助于调试和正确定位和配置你的光源:

图 3.11– 启用辅助器的聚光灯

图 3.11– 启用辅助器的聚光灯

在继续到下一个光源之前,我们将快速查看THREE.SpotLight对象可用的与阴影相关的属性。你已经了解到,我们可以通过将THREE.SpotLight实例的castShadow属性设置为true来获取阴影。你也知道THREE.Mesh对象有两个与阴影相关的属性。你为应该产生阴影的对象设置castShadow属性,而对于应该显示阴影的对象,你使用receiveShadow属性。Three.js 还允许你非常精细地控制阴影的渲染方式。这是通过本节开头表格中解释的几个属性来实现的。通过shadow.camera.nearshadow.camera.farshadow.camera.fov,你可以控制这个光在哪里以及如何产生阴影。对于THREE.SpotLight实例,你不能直接设置shadow.camera.fov。这个属性基于THREE.SpotLightangle属性。这和我们在第二章中解释的透视相机的视野一样工作。看到这个效果的最简单方法是通过添加一个THREE.CameraHelper;你可以通过勾选菜单的shadow-helper复选框并调整相机设置来实现。正如你在下面的屏幕截图中可以看到的,勾选这个复选框会显示用于确定这个光源阴影的区域:

图 3.12 – 启用阴影辅助器的聚光灯

图 3.12 – 启用阴影辅助器的聚光灯

当调试与阴影相关的问题时,添加 THREE.CameraHelper 是有用的。为此,只需添加以下几行:

const shadowCameraHelper = new THREE.CameraHelper
  (spotLight.shadow.camera);
scene.add(shadowCameraHelper);
// in the render loop
shadowCameraHelper.update();

我将以一些建议结束本节,以防你遇到与阴影相关的问题。

如果阴影看起来像块状,你可以增加 shadow.mapSize.widthshadow.mapSize.Height 属性,并确保用于计算阴影的区域紧密包裹你的对象。你可以使用 shadow.camera.nearshadow.camera.farshadow.camera.fov 属性来配置这个区域。

记住,你不仅要告诉光产生阴影,还要通过设置 castShadowreceiveShadow 属性来告诉每个几何体它是否会接收和/或产生阴影。

阴影偏差

如果你场景中使用了细长的物体,渲染阴影时可能会看到奇怪的伪影。你可以使用 shadow.bias 属性来稍微偏移阴影,这通常可以解决这些问题。

如果你想要更柔和的阴影,你可以在 THREE.WebGLRenderer 上设置不同的 shadowMapType 值。默认情况下,此属性设置为 THREE.PCFShadowMap;如果你将此属性设置为 PCFSoftShadowMap,你会得到更柔和的阴影。

现在,让我们来看看列表中的下一个光源:THREE.PointLight

THREE.PointLight

THREE.PointLight 是一种从单个点向所有方向发射光的光源。点光源的一个好例子是射入夜空的信号弹或篝火。就像所有灯光一样,我们有一个特定的例子你可以用来玩转 THREE.PointLight。如果你查看 chapter-03 文件夹中的 point-light.html,你可以找到一个例子,其中 THREE.PointLight 被用于我们用于其他灯光的相同场景:

图 3.13 – 启用辅助工具的 PointLight

图 3.13 – 启用辅助工具的 PointLight

如前一张截图所示,此光向所有方向发射。就像我们之前看到的聚光灯一样,此光也有一个辅助工具,你可以以相同的方式使用它。你可以将其视为场景中心的线框:

const pointLightHelper = new THREE.PointLightHelper
  (pointLight);
scene.add(pointLightHelper)
// in the render loop
pointLightHelper.update();

THREE.PointLightTHREE.SpotLight 共享一些属性,你可以使用这些属性来配置此光的行为:

名称 描述
color 该光源发出的光的颜色。
distance 表示光照射的距离。默认值是 0,这意味着光的强度不会根据距离而降低。
intensity 表示光照射的强度。默认值为 1
position 表示光在 THREE.Scene 中的位置。
visible 确定光是否开启或关闭。如果此属性设置为 true(默认值),则此光开启,如果设置为 false,则光关闭。
decay 表示随着您远离光源,灯光强度减弱的程度。decay 值为 2 会产生更逼真的灯光,默认值为 1。此属性仅在将 physicallyCorrectLights 属性设置为 WebGLRenderer 时有效。
power 指的是在以物理正确模式渲染时灯光的功率(通过在 WebGLRenderer 上设置 physicallyCorrectLights 属性来启用此功能)。此属性以流明为单位测量,默认值为 4*Math.PIPower 也与 intensity 属性直接相关(*power = intensity ** )。

图 3.14 – THREE.PointLight 对象的属性

除了这些属性外,THREE.PointLight 对象的阴影可以像 THREE.SpotLight 的阴影一样进行配置。在接下来的几个示例和屏幕截图中,我们将展示这些属性如何作用于 THREE.PointLight。首先,让我们看看如何创建一个 THREE.PointLight

const pointLight = new THREE.PointLight();
scene.add(pointLight);

这里没有特别之处——我们只是定义了灯光并将其添加到场景中;当然,您也可以设置我们刚刚展示的任何属性。THREE.SpotLight 对象的两个主要属性是 distanceintensity。使用 distance,您可以指定灯光发出多远后衰减到 0。例如,在下面的屏幕截图中,我们将 distance 属性设置为低值,并将 intensity 属性略微增加以模拟树木之间的篝火:

图 3.15 – 距离低且强度高的 PointLight

图 3.15 – 距离低且强度高的 PointLight

在此示例中无法设置 power 和衰减属性;如果您想模拟现实世界场景,这些属性非常有用。一个很好的例子可以在 Three.js 网站上找到:threejs.org/examples/#webgl_lights_physical

THREE.PointLight 也使用一个相机来确定绘制阴影的位置,因此您可以使用 THREE.CameraHelper 来显示被该相机覆盖的部分。此外,THREE.PointLight 提供了一个辅助工具,THREE.PointLightHelper,以显示 THREE.PointLight 照明的位置。启用两者后,您将获得以下非常有用的调试信息:

图 3.16 – 启用辅助功能的 PointLight

图 3.16 – 启用辅助功能的 PointLight

如果您仔细观察之前的屏幕截图(图 3.16),您可能会注意到阴影是在阴影相机显示区域之外创建的。这是因为阴影辅助工具只显示从点光源位置投射下来的阴影。您可以将 THREE.PointLight 视为一个立方体,其中每个面都发出光线并可以投射阴影。在这种情况下,THREE.ShadowCameraHelper 只显示向下投射的阴影。

我们将要讨论的最后一种基本灯光是 THREE.DirectionalLight

THREE.DirectionalLight

这种光源可以被认为是非常远的灯光。它发出的所有光束都是相互平行的。一个很好的例子是太阳。太阳非常遥远,以至于我们接收到的地球上的光线(几乎)是相互平行的。THREE.DirectionalLightTHREE.SpotLight(我们之前看到的)之间的主要区别是,这种光不会像 THREE.SpotLight 那样随着距离光源的增大而减弱(你可以通过 distanceexponent 参数进行微调)。由 THREE.DirectionalLight 照亮的整个区域接收相同强度的光线。要看到这个效果,请查看以下 directional-light.html 示例:

图 3.17 – 模拟日落的定向光

图 3.17 – 模拟日落的定向光

正如你所见,使用 THREE.DirectionalLight 模拟,例如日落,是非常容易的。正如与 THREE.SpotLight 一样,你可以设置这个光的一些属性。例如,你可以设置光的 intensity 属性以及它投射阴影的方式。THREE.DirectionalLight 有很多属性与 THREE.SpotLight 相同:positiontargetintensitycastShadowshadow.camera.nearshadow.camera.farshadow.mapSize.widthshadow.mapSize.widthshadowBias。有关这些属性的更多信息,你可以查看关于 THREE.SpotLight 的前述部分。

如果你回顾一下 THREE.SpotLight 的示例,你会看到我们必须定义应用阴影的光锥。由于 THREE.DirectionalLight 的所有光线都是相互平行的,所以我们没有需要应用阴影的光锥;相反,我们有一个长方体区域(在内部用 THREE.OrthographicCamera 表示),正如你在以下屏幕截图中所见,我们启用了阴影辅助工具:

图 3.18 – 显示长方体阴影区域的定向光

图 3.18 – 显示长方体阴影区域的定向光

所有落在这个立方体内的物体都可以从光源投射和接收阴影。正如对于 THREE.SpotLight,你定义的围绕物体的这个区域越紧密,你的阴影看起来就越好。使用以下属性定义这个立方体:

directionalLight.castShadow = true;
directionalLight.shadow.camera.near = 2;
directionalLight.shadow.camera.far = 80;
directionalLight.shadow.camera.left = -30;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30;

你可以将其与我们配置正交相机的方式进行比较,在 第二章使用不同相机为不同场景 部分中。

正如我们在这个部分中已经看到的,光源使用颜色。到目前为止,我们只是使用十六进制字符串配置了颜色,但 THREE.Color 对象提供了很多不同的选项来创建初始颜色对象。在接下来的部分中,我们将探索 THREE.Color 对象提供的功能。

使用 THREE.Color 对象

在 Three.js 中,当您需要提供颜色(例如,用于材质、光源等)时,您可以传递一个 THREE.Color 对象;否则,Three.js 将根据传入的字符串值创建一个,正如我们在 THREE.AmbientLight 中所看到的。Three.js 在解析 THREE.Color 构造函数的输入时非常灵活。您可以通过以下方式创建一个 THREE.Color 对象:

  • new THREE.Color("#ababab") 将根据传入的 CSS 颜色字符串创建一个颜色。

  • new THREE.Color(0xababab) 将根据传入的十六进制值创建颜色。如果您知道十六进制值,这通常是最佳方法。

  • new THREE.Color("rgb(255, 0, 0)")new THREE.Color("rgb(100%, 0%, 0%)"

  • new THREE.Color( 'skyblue' )

  • new THREE.Color("hsl(0, 100%, 50%)"

  • new THREE.Color( 1, 0, 0 )

如果您想在构造后更改颜色,您必须创建一个新的 THREE.Color 对象或修改 THREE.Color 对象的内部属性。THREE.Color 对象附带了一组大量的属性和函数。第一组函数允许您设置 THREE.Color 对象的颜色:

  • set(value): 将颜色的值设置为提供的十六进制值。此十六进制值可以是字符串、数字或现有的 THREE.Color 实例。

  • setHex(value): 将颜色的值设置为提供的数值十六进制值。

  • setRGB(r,g,b): 根据提供的 RGB 值设置颜色的值。值范围从 01

  • setHSL(h,s,l): 根据提供的 HSL 值设置此颜色。值范围从 0 到 1。有关 HSL 如何用于配置颜色的良好解释,请参阅 en.wikibooks.org/wiki/Color_Models:_RGB,_HSV,_HSL

  • setStyle(style): 根据 CSS 指定颜色的方式设置颜色的值。例如,您可以使用 rgb(255,0,0)#ff0000#f00 或甚至 red

如果您已经有一个现有的 THREE.Color 实例并想使用该颜色,您可以使用以下函数:

  • copy(color): 从提供的 THREE.Color 实例复制颜色值到该颜色。

  • copySRGBToLinear(color): 根据提供的 THREE.Color 实例设置此对象的颜色。颜色首先从 sRGB 颜色空间转换为线性颜色空间。sRGB 颜色空间使用指数刻度而不是线性刻度。有关 sRGB 颜色空间的更多信息,请参阅此处:www.w3.org/Graphics/Color/sRGB.html

  • copyLinearToSRGB(color): 根据提供的 THREE.Color 实例设置此对象的颜色。颜色首先从线性颜色空间转换为 sRGB 颜色空间。

  • convertSGRBToLinear(): 将当前颜色从 sRGB 颜色空间转换为线性颜色空间。

  • convertLinearToSGRB(): 将当前颜色从线性颜色空间转换为 sRGB 颜色空间。

如果你需要有关当前配置颜色的信息,THREE.Color对象还提供了一些辅助函数:

  • getHex(): 返回从该颜色对象作为数字的值:435241

  • getHexString(): 返回从该颜色对象作为十六进制字符串的值:0c0c0c

  • getStyle(): 返回从该颜色对象作为基于 CSS 的值:rgb(112,0,0)

  • getHSL(target): 返回从该颜色对象作为 HSL 值({ h: 0, s: 0, l: 0 })。如果你提供可选的target对象,Three.js 将设置该对象的hsl属性。

Three.js 还提供了通过修改单个颜色组件来更改当前颜色的函数。这在此处展示:

  • offsetHSL(h, s, l): 将提供的hsl值添加到当前颜色的hsl值上。

  • add(color): 将提供的颜色的rgb值添加到当前颜色中。

  • addColors(color1, color2): 将color1color2相加,并将当前颜色的值设置为结果。

  • addScalar(s): 将一个值添加到当前颜色的 RGB 组件中。请注意,内部值使用01的范围。

  • multiply(color): 将当前 RGB 值与THREE.Color的 RGB 值相乘。

  • multiplyScalar(s): 将当前 RGB 值与提供的值相乘。请记住,内部值范围在01之间。

  • lerp(color, alpha): 找到介于当前对象颜色和提供的color属性之间的颜色。alpha属性定义了结果颜色在当前颜色和提供颜色之间的距离。

最后,还有一些基本的辅助方法可用:

  • equals(color): 如果提供的THREE.Color实例的 RGB 值与当前颜色的值匹配,则返回true

  • fromArray(array): 与setRGB具有相同的功能,但现在,RGB 值可以作为数字数组提供。

  • toArray: 返回一个包含三个元素的数组:[r, g, b]

  • clone: 创建颜色的精确副本

在前面的列表中,你可以看到有许多方法可以更改当前颜色。许多这些函数在 Three.js 内部使用,但它们也提供了一个简单的方法来轻松更改光源和材质的颜色,而不必创建和分配新的THREE.Color对象。

到目前为止,我们已经了解了 Three.js 提供的基本光源以及阴影的工作原理。在大多数情况下,你将使用这些光源的组合来创建场景。Three.js 还提供了一些特殊光源,用于特定的使用场景。我们将在下一节中探讨这些内容。

使用特殊光源

在本节关于特殊灯光的内容中,我们将讨论 Three.js 提供的三个附加灯光。首先,我们将讨论 THREE.HemisphereLight,它有助于为户外场景创建更自然的照明。然后,我们将查看 THREE.RectAreaLight,它从大面积而不是单一点发射光线。接下来,我们将探讨如何使用 LightProbe 根据立方体贴图应用光线,最后,我们将向您展示如何为场景添加镜头光晕效果。

我们将要查看的第一个特殊灯光是 THREE.HemisphereLight

THREE.HemisphereLight

使用 THREE.HemisphereLight,我们可以创建看起来更自然的户外照明。如果没有这种灯光,我们可以通过创建 THREE.DirectionalLight 来模拟户外环境,该灯光模拟太阳光,并可能添加另一个 THREE.AmbientLight 来为场景提供一些通用颜色。然而,这样做看起来并不自然。当你身处户外时,并非所有光线都直接来自上方:很多光线被大气散射,并由地面和其他物体反射。Three.js 中的 THREE.HemisphereLight 就是为此场景而设计的。这是一种获得更自然户外照明的方法。要查看示例,请参考以下图中的 hemisphere-light.html

图 3.19 – 半球光

图 3.19 – 半球光

如果你仔细观察这张截图,你会看到半球的光地颜色在球体的底部显示得更明显,而天空颜色(通过 color 属性设置)在场景顶部可见。在这个示例中,你可以设置这些颜色及其强度。创建半球光与创建其他任何灯光一样简单:

const hemiLight = new THREE.HemisphereLight(0x0000ff,
  0x00ff00, 0.6); hemiLight.position.set(0, 500, 0);
scene.add(hemiLight);

你只需指定从天空接收到的颜色、从地面接收到的颜色以及这些灯光的强度。如果你想稍后更改这些值,你可以通过以下属性访问它们:

属性 描述
groundColor 表示从地面发出的颜色
color 表示从天空发出的颜色
intensity 表示光线照射的强度

图 3.20 – THREE.HemisphereLight 对象的属性

由于 HemisphereLight 类似于 THREE.AmbientLight 对象,它只为场景中的所有对象添加颜色,因此它不能投射阴影。我们之前看到的灯光更为传统。下一个属性允许您模拟来自矩形光源的光线 – 例如,窗户或电脑屏幕。

THREE.RectAreaLight

使用 THREE.RectAreaLight,我们可以定义一个发射光线的矩形区域。在我们查看详细内容之前,让我们先看看我们想要达到的结果(rectarea-light.html 打开此示例);以下截图显示了一些 THREE.RectAreaLight 对象:

图 3.21 – 矩形区域灯光在其完整表面发射

图 3.21 – 横向发射完整表面的矩形区域光

在这个屏幕截图中,你可以看到我们定义了三个 THREE.RectAreaLight 对象,每个都有自己的颜色。你可以看到这些光如何影响整个区域,当你移动它们或改变它们的位置时,你可以看到场景中不同的物体是如何受到影响的。

我们还没有探讨不同的材质以及光是如何影响它们的。我们将在下一章中这样做,第四章使用 Three.js 材质。一个 THREE.RectAreaLight 只能与 THREE.MeshStandardMaterialTHREE.MeshPhysicalMaterial 一起使用。关于这些材质的更多信息将在 第四章 中介绍。

要使用 THREE.RectAreaLight,我们需要做一些额外的步骤。首先,我们需要加载和初始化 RectAreaLightUniformsLib;以下是这个光源需要的额外低级 WebGL 脚本集:

import { RectAreaLightUniformsLib } from "three/examples
  /jsm/lights/RectAreaLightUniformsLib.js";
...
RectAreaLightUniformsLib.init();

接下来,我们可以像创建任何其他光源一样创建 THREE.AreaLight 对象:

const rectLight1 = new THREE.RectAreaLight
  (0xff0000, 5, 2, 5);
rectLight1.position.set(-3, 0, 5);
scene.add(rectLight1);

如果你查看这个对象的构造函数,你会看到它需要四个属性。第一个是光的颜色,第二个是强度,最后两个定义了这个光区域的面积大小。请注意,如果你想可视化这些光,就像我们在示例中所做的那样,你必须自己创建一个与你的 THREE.RectAreaLight 相同位置、旋转和大小的矩形。

这个光源可以用来创建一些很好的效果,但可能需要一些实验来得到你想要的效果。再次强调,在这个示例中,你有一个位于右侧的菜单,你可以使用它来尝试不同的设置。

在 Three.js 的最新版本中,增加了一个名为 THREE.LightProbe 的新光源。这个光源类似于 THREE.AmbientLight,但考虑到了 WebGLRenderer 的立方体贴图。这是我们本章要讨论的最后一个光源。

THREE.LightProbe

在上一章中,我们简要地讨论了什么是立方体贴图。使用立方体贴图,你可以在环境中展示你的模型。在上一章中,我们使用立方体贴图创建了一个随着摄像机视角旋转的背景:

图 3.22 – 来自第二章的立方体贴图示例

图 3.22 – 来自 第二章 的立方体贴图示例

正如我们将在下一章中看到的,我们可以使用立方体贴图的信息在我们的材质上显示反射。然而,通常这些环境贴图不会为你的场景贡献任何光。但是,使用 THREE.LightProbe,我们可以从立方体贴图中提取光照级别信息,并使用它来照亮我们的模型。所以,你得到的效果有点像 THREE.AmbientLight,但它根据场景中物体的位置和立方体贴图的信息影响物体。

解释这个的最简单方法就是看一个例子。在你的浏览器中打开light-probe.html;你会看到以下场景:

图 3.23 – 洞穴中的模型与 LightProbe

图 3.23 – 洞穴中的模型与 LightProbe

在前面的例子中,我们有一个位于洞穴环境中的模型。如果你旋转相机,可以看到根据环境光线,我们的模型被轻微地不同地照亮。在上一个截图,我们正在查看物体的背面,它在洞穴中更深处,所以模型的那一侧较暗。如果我们完全旋转相机,将洞穴的入口设置在我们的背后,我们会看到模型变得更亮,接收到的光线更多:

图 3.24 – 洞穴中的模型接收更多光线的 LightProbe

图 3.24 – 洞穴中的模型接收更多光线的 LightProbe

这是一个非常巧妙的技巧,可以让你的物体看起来更逼真,不那么平面,并且使用THREE.LightProbe,你的模型将非均匀地接收光线,这看起来要好得多。

设置THREE.LightProbe需要更多的工作,但只需要在你创建场景时做一次。只要你不改变环境,你就不需要重新计算THREE.LightProbe对象的值:

Import { LightProbeGenerator } from "three/examples/
  jsm/lights//LightProbeGenerator";
...
const loadCubeMap = (renderer, scene) => {
  const base = "drachenfels";
  const ext = "png";
  const urls = [
    "/assets/panorama/" + base + "/posx." + ext,
    "/assets/panorama/" + base + "/negx." + ext,
    "/assets/panorama/" + base + "/posy." + ext,
    "/assets/panorama/" + base + "/negy." + ext,
    "/assets/panorama/" + base + "/posz." + ext,
    "/assets/panorama/" + base + "/negz." + ext,
  ];
  new THREE.CubeTextureLoader().load(urls, function
    (cubeTexture) {
    cubeTexture.encoding = THREE.sRGBEncoding;
    scene.background = cubeTexture;
    const lp = LightProbeGenerator.fromCubeTexture
      (cubeTexture);
    lp.intensity = 15;
    scene.add(lp);
  });
};

在前面的代码片段中,我们做了两件主要的事情。首先,我们使用THREE.CubeTextureLoader来加载一个立方体贴图。正如我们将在下一章中看到的,立方体贴图由六个图像组成,代表一个立方体的六个面,这些面组合起来将构成我们的环境。一旦加载完成,我们将它设置为场景的背景(注意,这对于THREE.LightProbe工作不是必需的)。

现在我们有了这个立方体贴图,我们可以从中生成一个THREE.LightProbe。这是通过将cubeTexture传递给一个LightProbeGenerator来完成的。结果是得到一个THREE.LightProbe,我们将其添加到场景中,就像添加任何其他光源一样。就像THREE.AmbientLight一样,你可以通过设置intensity属性来控制这个光对网格照明的贡献程度。

注意

Three.js 还提供另一种LightProbeTHREE.HemisphereLightProbe。这个与普通的THREE.HemisphereLight几乎一样工作,但内部使用LightProbe

本章的最后一个对象不是一个光源,但它在电影中经常看到的相机技巧中玩了一个花招:THREE.LensFlare

THREE.LensFlare

你可能已经对镜头眩光很熟悉了。例如,当你直接拍摄太阳或其他明亮的光源时,它们会出现。在大多数情况下,你希望避免这种情况,但在游戏和 3D 生成的图像中,它提供了一个使场景看起来更逼真的效果。Three.js 也支持镜头眩光,并使其非常容易添加到场景中。在本节最后,我们将向场景添加一个镜头眩光,并创建以下屏幕截图所示的输出;你可以通过打开lens-flare.html来亲自查看:

图 3.25 – 当你直视光线时会出现镜头眩光

图 3.25 – 当你直视光线时会出现镜头眩光

我们可以通过实例化LensFlare对象并添加LensFlareElement对象来创建一个镜头眩光:

import {
  Lensflare,
  LensflareElement,
} from "three/examples/jsm/objects/Lensflare";
const textureLoader = new THREE.TextureLoader()
const textureFlare0 = textureLoader.load
  ('/assets/textures/lens-flares/lensflare0.png')
const textureFlare1 = textureLoader.load
  ('/assets/textures/lens-flares/lensflare3.png')
const lensFlare = new LensFlare();
lensFlare.addElement(new LensflareElement
  (textureFlare0, 512, 0));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 60, 0.6));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 70, 0.7));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 120, 0.9));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 70, 1.0));
pointLight.add(lensFlare);

LensFlare元素只是我们LensFlareElement对象的容器,而LensFlareElement是当你看光源时看到的那个效果。然后,我们将LensFlare添加到光源上,任务就完成了。如果你查看代码,你会看到我们为每个LensFlareElement传递了几个属性。这些属性决定了LensFlareElement的外观以及它在屏幕上的渲染位置。要使用这个元素,我们可以应用以下构造函数参数:

属性 描述
texture 一个纹理是一个确定眩光形状的图片。
size 我们可以指定眩光应该有多大。size表示像素大小。如果你指定-1,则使用纹理本身的大小。
distance 表示从光源(0)到相机(1)的距离。使用这个参数来定位镜头眩光在正确的位置。
color 表示眩光的颜色。

图 3.26 – THREE.LensFlareElement 对象的属性

首先,让我们更仔细地看看第一个LensFlareElement

const textureLoader = new THREE.TextureLoader();
const textureFlare0 = textureLoader.load(
  "/assets/textures/lens-flares/lensflare0.png"
);
lensFlare.addElement(new LensflareElement
  (textureFlare0, 512, 0));

第一个参数,texture,是一个显示眩光形状和一些基本色彩的照片。我们使用THREE.TextureLoader来加载这个图片,我们只需添加texture的位置:

图 3.27 – 示例中使用的镜头眩光

图 3.27 – 示例中使用的镜头眩光

第二个参数是这个眩光的大小。由于这是我们自己在光源处看到的眩光,我们将它做得相当大:本例中为512像素。接下来,我们需要设置这个眩光的distance属性。你在这里设置的是光源与相机中心的相对距离。如果我们设置一个距离为0,纹理将显示在光源的位置,如果我们设置它为1,它将显示在相机的位置。在本例中,我们将其直接放置在光源处。

现在,如果你回顾一下其他LightFlareElement对象的位置,你会看到我们将它们定位在从01的间隔中,这导致了当你打开lens-flare.html示例时你看到的效果:

const textureFlare1 = textureLoader.load(
  "/assets/textures/lens-flares/lensflare3.png"
);
lensFlare.addElement(new LensflareElement
  (textureFlare1, 60, 0.6));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 70, 0.7));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 120, 0.9));
lensFlare.addElement(new LensflareElement
  (textureFlare1, 70, 1.0));

有了这些,我们已经讨论了 Three.js 提供的各种光照选项。

摘要

在本章中,我们介绍了关于 Three.js 中可用的不同类型光源的大量信息。你了解到配置光源、颜色和阴影并不是一门精确的科学。为了得到正确的结果,你应该尝试不同的设置,并使用lil.GUI控件来微调你的配置。不同的光源表现不同,正如我们将在第四章中看到的,材质对光源的反应也不同。

THREE.AmbientLight颜色被添加到场景中的每一个颜色上,通常用于平滑硬色和阴影。THREE.PointLight向所有方向发射光线,并且可以投射阴影。THREE.SpotLight是一种类似手电筒的光源。它具有锥形形状,可以配置为随距离渐变,并且可以投射阴影。我们还探讨了THREE.DirectionalLight。这种光可以与远处的光源相比,例如太阳,其光线是平行的,强度不会随着距离配置目标越来越远而减弱,并且也可以投射阴影。

除了标准光源外,我们还探讨了几个更专业的光源。为了获得更自然的户外效果,你可以使用THREE.HemisphereLight,它考虑了地面和天空的反射。THREE.RectAreaLight不是从一个点发光,而是从大面积发射光线。我们还通过使用THREE.LightProbe展示了更高级的环境光照,它使用环境贴图中的信息来确定物体是如何被照亮的。最后,我们展示了如何使用THREE.LenseFlare对象添加摄影镜头光晕效果。

在到目前为止的章节中,我们已经介绍了几种不同的材质,在本章中,你看到并不是所有材质对可用光源的反应都是相同的。在第四章中,我们将概述 Three.js 中可用的材质。

第二部分:使用 Three.js 核心组件

在这部分,我们将深入探讨 Three.js 提供的不同材质以及你可以用来创建自己场景的不同几何形状。除了几何形状外,我们还将探讨 Three.js 如何支持点和精灵,你可以使用这些点或精灵,例如,来创建雨和烟雾效果。

在这部分,有以下章节:

  • 第四章使用 Three.js 材质

  • 第五章学习使用几何形状

  • 第六章探索高级几何形状

  • 第七章, 点和精灵

第四章:与 Three.js 材质一起工作

第三章在 Three.js 中使用光源中,我们简要地讨论了材质。你了解到,一个材质与一个THREE.Geometry实例一起形成一个THREE.Mesh对象。材质就像物体的皮肤,定义了几何体的外观。例如,皮肤定义了几何体是否看起来像金属、透明或显示为线框。然后,生成的THREE.Mesh对象可以被添加到场景中,由 Three.js 进行渲染。

到目前为止,我们还没有详细地查看材质。在本章中,我们将深入了解 Three.js 提供的所有材质,并学习如何使用这些材质创建看起来好的 3D 对象。本章中我们将探讨的材质如下所示:

  • MeshBasicMaterial:这是一种基本的材质,你可以用它给你的几何体一个简单的颜色或显示你的几何体的线框。这种材质不受灯光的影响。

  • MeshDepthMaterial:这是一种使用从相机到网格的距离来确定如何着色网格的材质。

  • MeshNormalMaterial:这是一种简单的材质,它根据面的法向量来确定颜色。

  • MeshLambertMaterial:这是一种考虑光照的材质,用于创建看起来不反光的物体。

  • MeshPhongMaterial:这是一种也考虑光照的材质,可以用来创建反光的物体。

  • MeshStandardMaterial:这是一种使用基于物理的渲染来渲染对象的材质。在基于物理的渲染中,使用一个物理正确的模型来确定光线如何与表面相互作用。这允许你创建更准确和看起来更逼真的对象。

  • MeshPhysicalMaterial:这是MeshStandardMaterial的一个扩展,它允许对反射有更多的控制。

  • MeshToonMaterial:这是MeshPhongMaterial的一个扩展,试图使物体看起来像是手绘的。

  • ShadowMaterial:这是一种可以接收阴影的特定材质,但除此之外,它被渲染为透明。

  • ShaderMaterial:这种材质允许你指定着色器程序,以直接控制顶点的位置和像素的颜色。

  • LineBasicMaterial:这是一种可以用于THREE.Line几何体的材质,用于创建彩色线条。

  • LineDashMaterial:这与LineBasicMaterial相同,但此材质还允许你创建虚线效果。

在 Three.js 的源代码中,你还可以找到THREE.SpriteMaterialTHREE.PointsMaterial。这些是在为单个点着色时可以使用的材质。在本章中我们不会讨论这些,但将在第七章点和精灵中探讨它们。

材质具有几个共同的属性,所以在我们查看第一个材质THREE.MeshBasicMaterial之前,我们将查看所有材质共有的属性。

理解常见的材质属性

您可以快速查看所有材质之间共享哪些属性。Three.js 提供了一个材质基类 THREE.Material,列出了所有这些常见属性。我们将这些常见材质属性分为以下三个类别:

  • 基本属性:这些是您最常使用的属性。使用这些属性,您可以控制对象的透明度、是否可见以及如何引用(通过 ID 或自定义名称)。

  • 混合属性:每个对象都有一组混合属性。这些属性定义了材质中每个点的颜色如何与后面的颜色结合。

  • 高级属性:几个高级属性控制低级 WebGL 上下文如何渲染对象。在大多数情况下,您不需要处理这些属性。

注意,在本章中,我们将跳过大多数与纹理和贴图相关的属性。大多数材质允许您使用图像作为纹理(例如,类似木材或石头的纹理)。在第十章 加载和使用纹理中,我们将深入了解各种可用的纹理和贴图选项。一些材质还具有与动画相关的特定属性(例如,skinningmorpNormalsmorphTargets);我们也将跳过这些属性。这些将在第九章 动画和移动摄像机中讨论。clipIntersectionclippingPlanesclipShadows 属性将在第六章 探索高级几何体中讨论。

我们将从列表中显示的第一个集合开始:基本属性。

基本属性

这里列出了 THREE.Material 对象的基本属性(您将在 THREE.MeshBasicMaterial 部分中看到这些属性的实际应用):

  • id:这用于识别材质,并在创建材质时分配。对于第一个材质,它从 0 开始,并为每个创建的额外材质增加 1

  • uuid:这是一个唯一生成的 ID,并在内部使用。

  • name:您可以使用此属性为材质分配一个名称。这可以用于调试目的。

  • opacity:这定义了对象的透明度。请与 transparent 属性一起使用。此属性的取值范围从 01

  • transparent: 如果设置为 true,Three.js 将使用设置的透明度渲染此对象。如果设置为 false,则对象不会透明,只是颜色较浅。如果您使用的是使用 alpha(透明度)通道的纹理,此属性也应设置为 true

  • visible:这定义了此材质是否可见。如果您将其设置为 false,则您将不会在场景中看到该对象。

  • side: 通过这个属性,你可以定义材质应用于几何体的哪一侧。默认值是 THREE.Frontside,它将材质应用于对象的前面(外部)。你也可以将其设置为 THREE.BackSide,这样它就会应用于背面(内部),或者设置为 THREE.DoubleSide,这样它就会应用于两面。

  • needsUpdate: 当 Three.js 创建一个材质时,它会将其转换为一系列 WebGL 指令。当你想要你在材质中做的更改也导致 WebGL 指令的更新时,你可以将此属性设置为 true

  • colorWrite: 如果设置为 false,则此材质的颜色将不会显示(实际上,你会创建不可见对象,这些对象会遮挡它们后面的对象)。

  • flatShading: 这决定了是否使用平面着色来渲染此材质。在平面着色中,组成对象的各个三角形分别渲染,并不会合并成一个平滑的表面。

  • lights: 这是一个布尔值,用于确定此材质是否受灯光影响。默认值是 true

  • premultipliedAlpha: 这改变了对象透明度渲染的方式。默认值是 false

  • dithering: 这将对渲染材质应用抖动效果。这可以用来避免条纹。默认值是 false

  • shadowSide: 这就像 side 属性一样,但确定哪个面的哪一侧投射阴影。如果没有设置,它将遵循 side 属性设置的值。

  • vertexColors: 通过这个属性,你可以定义应用于每个顶点的单独颜色。如果设置为 true,则渲染时使用设置在顶点的任何颜色,而如果设置为 false,则顶点的颜色不使用。

  • fog: 这个属性确定此材质是否受全局雾设置的影响。这没有在动作中显示,但如果设置为 false,我们看到的全局雾(在第二章构成 Three.js 场景的基本组件)将被禁用。

对于每个材质,你还可以设置几个混合属性。

混合属性

材质有几个通用的混合相关属性。混合决定了我们渲染的颜色如何与它们后面的颜色交互。当我们讨论组合材质时,我们会稍微涉及这个主题。混合属性如下:

  • blending: 这决定了此对象上的材质如何与背景混合。正常模式是 THREE.NormalBlending,它只显示顶层。

  • blendSrc: 除了使用标准混合模式外,你还可以通过设置 blendsrcblenddstblendequation 来创建自定义混合模式。此属性定义了对象(源)如何与背景(目标)混合。默认的 THREE.SrcAlphaFactor 设置使用 alpha(透明度)通道进行混合。

  • blendSrcAlpha: 这是 blendSrc 的透明度。默认值是 null

  • blendDst:此属性定义了在混合中如何使用背景(目标)。默认值为THREE.OneMinusSrcAlphaFactor,这意味着此属性也使用源 alpha 通道进行混合,但使用1(源的 alpha 通道)作为值。

  • blendDstAlpha:这是blendDst的透明度。默认值为null

  • blendEquation:这定义了如何使用blendsrcblenddst值。默认值是相加(AddEquation)。使用这三个属性,您可以创建自己的自定义混合模式。

最后一批属性主要用于内部,并控制 WebGL 渲染场景的具体方式。

高级属性

我们不会深入这些属性的具体细节。这些属性与 WebGL 的内部工作方式有关。如果您想了解更多关于这些属性的信息,OpenGL 规范是一个好的起点。您可以在www.khronos.org/opengl/wiki找到此规范。以下列表提供了这些高级属性的简要描述:

  • depthTest:这是一个高级 WebGL 属性。使用此属性,您可以启用或禁用GL_DEPTH_TEST参数。此参数控制是否使用像素的深度来确定新像素的值。通常,您不需要更改此参数。更多详细信息可以在我们之前提到的 OpenGL 规范中找到。

  • depthWrite:这是另一个内部属性。此属性可以用来确定此材质是否影响 WebGL 深度缓冲区。如果您使用一个对象作为 2D 叠加(例如,一个中心),应将此属性设置为false。不过,通常您不需要更改此属性。

  • depthFunc:此函数比较像素的深度。这对应于 WebGL 规范中的glDepthFunc

  • polygonOffsetpolygonOffsetFactorpolygonOffsetUnits:通过这些属性,您可以控制POLYGON_OFFSET_FILL WebGL 功能。这些属性通常不需要。如果您想详细了解它们的功能,可以查看 OpenGL 规范。

  • Alphatest:此值可以设置为特定值(01)。当像素的 alpha 值小于此值时,它将不会被绘制。您可以使用此属性来移除一些与透明度相关的伪影。您可以将此材质的精度设置为以下 WebGL 值之一:highpmediumplowp

现在,让我们看看所有可用的材质,以便您可以看到这些属性对渲染输出的影响。

从简单的材质开始

在本节中,我们将探讨几种简单的材质:MeshBasicMaterialMeshDepthMaterialMeshNormalMaterial

在我们探讨这些材质的属性之前,这里有一个关于如何传递属性以配置材质的快速说明。有两种选项:

  • 您可以将参数对象作为构造函数中的参数传递,如下所示:

    const material = new THREE.MeshBasicMaterial({
    
      color: 0xff0000,
    
      name: 'material-1',
    
      opacity: 0.5,
    
      transparency: true,
    
      ...
    
    })
    
  • 或者,您可以创建一个实例并单独设置属性,如下所示:

    const material = new THREE.MeshBasicMaterial();
    
    material.color = new THREE.Color(0xff0000);
    
    material.name = 'material-1'; material.opacity = 0.5;
    
    material.transparency = true;
    

通常,如果我们知道创建材质时所有属性的值,使用构造函数是最好的方式。这两种风格中使用的参数格式相同。唯一的例外是color属性。在第一种风格中,我们可以直接传入十六进制值,Three.js 将自行创建一个THREE.Color对象。在第二种风格中,我们必须显式创建一个THREE.Color对象。在这本书中,我们将使用这两种风格。

现在,让我们看看简单材质中的第一个:THREE.MeshBasicMaterial

THREE.MeshBasicMaterial

MeshBasicMaterial是一个非常简单的材质,它不会考虑场景中可用的灯光。具有这种材质的网格将被渲染为简单的、平面的多边形,您还可以选择显示几何体的线框。除了我们之前看到的关于这种材质的常见属性外,我们还可以设置以下属性(我们还将忽略用于纹理的属性,因为我们将讨论这些属性):

  • color:这个属性允许您设置材质的颜色。

  • wireframe:这允许您将材质渲染为线框。这对于调试目的非常有用。

  • vertexColors:当设置为true时,这将考虑渲染模型时单个顶点的颜色。

在前面的章节中,我们看到了如何创建材质并将它们分配给对象。对于THREE.MeshBasicMaterial,我们可以这样做:

const meshMaterial = new THREE.MeshBasicMaterial({color:
  0x7777ff});

这将创建一个新的THREE.MeshBasicMaterial并初始化color属性为0x7777ff(这是紫色)。

我们添加了一个示例,您可以使用它来尝试THREE.MeshBasicMaterial的属性以及我们在前几节中讨论的基本属性。如果您打开chapter-04文件夹中的basic-mesh-material.html示例,您会在屏幕上看到一个简单的网格,以及在场景右侧的一组属性,您可以使用这些属性来更改模型,添加简单的纹理,并立即更改任何材质属性以查看效果:

图 4.1 – 基本材质示例的起始屏幕

图 4.1 – 基本材质示例的起始屏幕

您可以在这张屏幕截图中看到的是一个基本的简单灰色球体。我们之前提到过THREE.MeshBasicMaterial不会对场景中的灯光做出反应,所以您看不到任何深度;所有面都是相同的颜色。尽管如此,您仍然可以创建看起来很棒的模型。例如,如果您通过在envMaps下拉菜单中选择reflection属性来启用反射,设置场景的背景,并将模型更改为torus模型,您已经可以创建看起来很棒的模型:

图 4.2 – 带环境图的环面结

图 4.2 – 带环境图的环面结

wireframe属性非常适合查看THREE.Mesh的底层几何形状,并且非常适合调试:

图 4.3 – 显示其线框的模型

图 4.3 – 显示其线框的模型

我们接下来想要更仔细地研究的是vertexColors属性。如果您启用此属性,则模型的单个顶点的颜色将用于渲染模型。如果您从菜单中的模型下拉列表中选择vertexColor,您将看到一个具有着色顶点的模型。要查看这一点,最简单的方法是同时启用线框:

图 4.4 – 显示线框和顶点颜色的模型

图 4.4 – 显示线框和顶点颜色的模型

顶点颜色可以用来用不同的颜色为网格的不同部分着色,而无需使用纹理或多个材料。

在这个示例中,您还可以通过查看图 4.4中的菜单的THREE.Material部分来尝试调整我们在本章开头讨论的标准材料属性。

THREE.MeshDepthMaterial

列表中的下一个材料是THREE.MeshDepthMaterial。使用这种材料,对象的视觉效果不是由灯光或特定的材料属性定义的,而是由对象到摄像机的距离定义的。例如,您可以将其与其他材料结合使用,以轻松创建渐变效果。这种材料唯一的附加属性是我们之前在THREE.MeshBasicMaterial中看到的一个:wireframe属性。

为了演示这种材料,我们创建了一个示例,您可以通过打开mesh-depth-material示例来查看:

图 4.5 – 网格深度材料

图 4.5 – 网格深度材料

在这个示例中,您可以通过点击菜单中的相关按钮来添加和移除立方体。您会看到靠近摄像机的立方体渲染得非常亮,而远离摄像机的立方体渲染得较暗。在这个示例中,您可以通过调整Perspective Camera设置的farnear属性来了解这是如何工作的。通过调整摄像机的farnear属性,您可以改变场景中所有立方体的亮度。

通常,您不会将此材料作为网格的唯一材料使用;相反,您会将其与不同的材料结合使用。我们将在下一节中看到这是如何工作的。

材料组合

如果您回顾一下THREE.MeshDepthMaterial的属性,您会看到没有选项可以设置立方体的颜色。所有这些都是由材料的默认属性为您决定的。然而,Three.js 提供了组合材料以创建新效果的选择(这也是混合作用的地方)。以下代码显示了我们可以如何组合材料:

import * as SceneUtils from 'three/examples/jsm/
  utils/SceneUtils'
const material1 = new THREE.MeshDepthMaterial()
const material2 = new THREE.MeshBasicMaterial({ color:
  0xffff00 })
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
const cube = SceneUtils.createMultiMaterialObject(geometry,
  [material2, material1])

首先,我们创建了两种材质。对于THREE.MeshDepthMaterial,我们没有做任何特殊的事情;对于THREE.MeshBasicMaterial,我们只是设置了颜色。这段代码的最后一行也是很重要的一行。当我们使用SceneUtils.createMultiMaterialObject()函数创建网格时,几何体被复制,并返回两个相同的网格组。

我们得到了以下使用THREE.MeshDepthMaterial的亮度和THREE.MeshBasicMaterial的颜色组合的绿色立方体。你可以在浏览器中打开chapter-4文件夹中的combining-materials.html示例来查看这是如何工作的:

图 4.6 – 材料组合

图 4.6 – 材料组合

当你第一次打开这个示例时,你只会看到实体对象,没有任何THREE.MeshDepthMaterial的效果。为了组合颜色,我们还需要指定这些颜色如何混合。在图 4.6*的右侧菜单中,你可以使用blending属性来指定这一点。在这个示例中,我们使用了THREE.AdditiveBlending模式,这意味着颜色会被相加,最终的颜色会显示出来。这个示例是探索不同混合选项并观察它们如何影响材质最终颜色的绝佳方式。

下一个材质也是我们不会对渲染中使用的颜色产生任何影响的一个。

THREE.MeshNormalMaterial

理解这种材质如何渲染的最简单方法首先是查看一个示例。打开chapter-4文件夹中的mesh-normal-material.html示例,并启用flatShading

图 4.7 – 网格法线材质

图 4.7 – 网格法线材质

如你所见,网格的每个面都以略不同的颜色渲染。这是因为每个面的颜色基于从面指向外的法线。而这个面的法线是基于构成面的各个顶点的法线向量。法线向量垂直于顶点的面。法线向量在 Three.js 的许多不同部分中使用。它用于确定光线反射,帮助将纹理映射到 3D 模型,并提供有关如何照亮、着色和着色像素表面的信息。幸运的是,Three.js 处理这些向量的计算并在内部使用它们,所以你不需要自己计算或处理它们。

Three.js 提供了一个辅助工具来可视化这个法线,你可以在菜单中启用vertexHelpers属性来显示它:

图 4.8 – 网格法线辅助工具

图 4.8 – 网格法线辅助工具

通过几行代码自己添加这个辅助工具:

Import { VertexNormalsHelper } from 'three/examples/jsm/
  helpers/VertexNormalsHelper'
...
const helper = new VertexNormalsHelper(mesh, 0.1, 0xff0000)
helper.name = 'VertexNormalHelper'
scene.add(helper)

VertexNormalsHelper接受三个参数。第一个是THREE.Mesh,你想看到辅助工具的网格,第二个是箭头的长度,最后一个是颜色。

让我们以此为例,看看shading属性。使用shading属性,我们可以告诉 Three.js 如何渲染我们的对象。如果你使用THREE.FlatShading,每个面将按原样渲染(如前一张截图所示),或者你可以使用THREE.SmoothShading,它将平滑我们的对象的面。例如,如果我们使用THREE.SmoothShading渲染相同的球体,结果将看起来像这样:

图 4.9 – 网格法线平滑着色

图 4.9 – 网格法线平滑着色

我们已经完成了简单材料的学习,但在继续之前,让我们看看一个额外的主题。在下一节中,我们将探讨如何为几何体的特定面使用不同的材料。

单个网格的多个材料

在创建THREE.Mesh时,到目前为止,我们使用的是单一材料。也可以为几何体的每个面定义特定的材料。例如,如果你有一个有 12 个面的立方体(记住,Three.js 使用三角形),你可以为立方体的每个面分配不同的材料(例如,使用不同的颜色)。这样做很简单,如下面的代码片段所示:

const mat1 = new THREE.MeshBasicMaterial({ color: 0x777777
  })
const mat2 = new THREE.MeshBasicMaterial({ color: 0xff0000
  })
const mat3 = new THREE.MeshBasicMaterial({ color: 0x00ff00
  })
const mat4 = new THREE.MeshBasicMaterial({ color: 0x0000ff
  })
const mat5 = new THREE.MeshBasicMaterial({ color: 0x66aaff
  })
const mat6 = new THREE.MeshBasicMaterial({ color: 0xffaa66
  })
const matArray = [mat1, mat2, mat3, mat4, mat5, mat6]
const cubeGeom = new THREE.BoxGeometry(1, 1, 1, 10, 10, 10)
const cubeMesh = new THREE.Mesh(cubeGeom, material)

我们创建了一个名为matArray的数组来存储所有材料,并使用该数组创建THREE.Mesh。你可能注意到,尽管我们有 12 个面,但我们只创建了六个材料。要理解这是如何工作的,我们必须看看 Three.js 是如何将材料分配给面的。Three.js 使用groups属性来做这件事。要亲自查看,请打开multi-material.js的源代码,并添加debugger语句,如下所示:

  const group = new THREE.Group()
  for (let x = 0; x < 3; x++) {
    for (let y = 0; y < 3; y++) {
      for (let z = 0; z < 3; z++) {
        const cubeMesh = sampleCube([mat1, mat2, mat3,
          mat4, mat5, mat6], 0.95)
        cubeMesh.position.set(x - 1.5, y - 1.5, z - 1.5)
        group.add(cubeMesh)
        debugger
      }
    }
  }

这将导致浏览器停止执行,并允许你从浏览器控制台检查所有对象:

图 4.10 – 使用断点语句停止执行

图 4.10 – 使用断点语句停止执行

在浏览器中,如果你打开cubeMesh,我们可以使用console.log(cubeMesh)

图 4.11 – 打印出有关对象的信息

图 4.11 – 打印出有关对象的信息

如果你进一步查看cubeMeshgeometry属性,你会看到groups。这个属性是一个包含六个元素的数组,其中每个元素包含属于该组的顶点范围,以及一个额外的属性materialIndex,它指定了应该为该组顶点使用传入的哪种材料:

[{ "start": 0,    "count": 600, "materialIndex": 0 },
 { "start": 600,  "count": 600, "materialIndex": 1 },
 { "start": 1200, "count": 600, "materialIndex": 2 },
 { "start": 1800, "count": 600, "materialIndex": 3 },
 { "start": 2400, "count": 600, "materialIndex": 4 },
 { "start": 3000, "count": 600, "materialIndex": 5 }]

因此,如果你从头开始创建自己的对象,并想将不同的材料应用于不同的顶点组,你必须确保正确设置groups属性。对于由 Three.js 创建的对象,你不需要手动做这件事,因为 Three.js 已经做了。

使用这种方法,创建有趣的模型非常简单。例如,我们可以轻松地创建一个简单的 3D 魔方,正如你在multi-materials.html示例中看到的那样:

图 4.12 – 六种不同材料的多材料

图 4.12 – 六种不同材料的多材料

我们还添加了对应用于每个面的材料的控制,以便进行实验。创建这个立方体与我们之前在单个网格的多种材料部分看到的方法没有太大区别:

const group = new THREE.Group()
const mat1 = new THREE.MeshBasicMaterial({ color: 0x777777
  })
const mat2 = new THREE.MeshBasicMaterial({ color: 0xff0000
  })
const mat3 = new THREE.MeshBasicMaterial({ color: 0x00ff00
  })
const mat4 = new THREE.MeshBasicMaterial({ color: 0x0000ff
  })
const mat5 = new THREE.MeshBasicMaterial({ color: 0x66aaff
  })
const mat6 = new THREE.MeshBasicMaterial({ color: 0xffaa66
  })
for (let x = 0; x < 3; x++) {
  for (let y = 0; y < 3; y++) {
    for (let z = 0; z < 3; z++) {
      const cubeMesh = sampleCube([mat1, mat2, mat3, mat4,
        mat5, mat6], 0.95)
      cubeMesh.position.set(x - 1.5, y - 1.5, z - 1.5)
      group.add(cubeMesh)
    }
  }
}

在这段代码中,首先,我们创建THREE.Group,它将包含所有的单个立方体(组);接下来,我们为立方体的每一面创建材料。然后,我们创建三个循环以确保创建正确的立方体数量。在这个循环中,我们创建每个单个立方体,分配材料,定位它们,并将它们添加到组中。你应该记住的是,立方体的位置相对于这个组的位置。如果我们移动或旋转组,所有的立方体都会随之移动和旋转。有关如何使用组的更多信息,请参阅第八章创建和加载高级网格 和几何体

这样,我们就完成了关于基本材料和如何组合它们的这一节。在下一节中,我们将探讨更多高级材料。

高级材料

在本节中,我们将探讨 Three.js 提供的高级材料。我们将探讨以下材料:

  • THREE.MeshLambertMaterial:一种用于看起来粗糙的材料

  • THREE.MeshPhongMaterial:一种用于看起来光滑的材料

  • THREE.MeshToonMaterial:以卡通风格渲染网格

  • THREE.ShadowMaterial:一种只显示在其上投射的阴影的材料;材料本身是透明的

  • THREE.MeshStandardMaterial:一种多功能的材料,可以用来表示许多不同类型的表面

  • THREE.MeshPhysicalMaterial:类似于THREE.MeshStandardMaterial,但提供了更多类似真实世界表面的属性

  • THREE.ShaderMaterial:一种可以自己定义如何渲染对象,通过编写自己的着色器的材料

我们将从THREE.MeshLambertMaterial开始。

THREE.MeshLambertMaterial

这种材料可以用来创建看起来平淡无奇、非闪亮的表面。这是一个非常易于使用的材料,它对场景中的光源做出反应。这个材料可以使用我们之前已经看到的基本属性进行配置,因此我们不会深入探讨这些属性的细节;相反,我们将专注于特定于这种材料的属性。这仅剩下以下属性:

  • color:这是材料颜色。

  • emissive:这是材料发出的颜色。它不作为光源,但这是一个不受其他光照影响的实色。默认为黑色。你可以使用这个属性来创建看起来像发光物体的对象。

  • emissiveIntensity:对象看起来发光的强度。

创建此对象的方法与我们之前看到的其他材质相同:

const material = new THREE.MeshLambertMaterial({color:
  0x7777ff});

以下是一个此材质的示例,请查看 mesh-lambert-material.html 示例:

图 4.13 – 网格 Lambert 材质

图 4.13 – 网格 Lambert 材质

此截图显示了一个白色的环面结,带有非常淡的红光发射。THREE.LambertMaterial 的一个有趣特性是它也支持线框属性,因此您可以渲染一个对场景中的灯光做出反应的线框:

图 4.14 – 具有线框的网格 Lambert 材质

图 4.14 – 具有线框的网格 Lambert 材质

下一个材质的工作方式几乎与上一个相同,但可以用来创建具有光泽的对象。

THREE.MeshPhongMaterial

使用 THREE.MeshPhongMaterial,我们可以创建一个具有光泽的材质。可用于此的属性与非光泽的 THREE.MeshLambertMaterial 对象的属性几乎相同。在旧版本中,这是唯一可以用来制作具有光泽、塑料或金属感对象的材质。随着 Three.js 的新版本,如果您想要更多控制,您还可以使用 THREE.MeshStandardMaterialTHREE.MeshPhysicalMaterial。在查看 THREE.MeshPhongMaterial 之后,我们将讨论这两种材质。

我们将再次跳过基本属性,专注于此材质的特定属性。此材质的属性在此列出:

  • emissive:这是此材质发出的颜色。它不作为光源,但这是一个不受其他光照影响的实色。默认为黑色。

  • emissiveIntensity:对象看起来发光的强度。

  • specular:此属性定义了材质的光泽度和发光颜色。如果将其设置为与 color 属性相同的颜色,则得到更具金属感的材质。如果设置为灰色,则结果为更具塑料感的材质。

  • shininess:此属性定义了镜面高光的光泽度。shininess 的默认值为 30。此值越高,对象的光泽度就越高。

初始化一个 THREE.MeshPhongMaterial 材质的方式与我们已经看到的所有其他材质相同,如下代码行所示:

const meshMaterial = new THREE.MeshPhongMaterial({color:
  0x7777ff});

为了给您提供最佳的比较,我们将继续使用与 THREE.MeshLambertMaterial 和本章中其他材质相同的模型。您可以使用控制 GUI 来玩转此材质。例如,以下设置创建了一个具有塑料感的材质。您可以在 mesh-phong-material.html 中找到此示例:

图 4.15 – 具有高光泽度的网格 Phong 材质

图 4.15 – 具有高光泽度的网格 Phong 材质

如此截图所示,与 THREE.MeshLambertMaterial 相比,该对象的光泽度和塑料感更强。

THREE.MeshToonMaterial

并非 Three.js 提供的所有材质都实用。例如,THREE.MeshToonMaterial 允许您以类似卡通的风格渲染对象(参见 mesh-toon-material.html 示例):

Figure 4.16 – The fox model rendered with MeshToonMaterial

Figure 4.16 – The fox model rendered with MeshToonMaterial

如您所见,它看起来有点像我们之前看到的 THREE.MeshBasicMaterial,但这种材质会响应场景中的灯光并支持阴影。它只是将颜色组合在一起以创建类似卡通的效果。

如果您想要更逼真的材质,THREE.MeshStandardMaterial 是一个好的选择。

THREE.MeshStandardMaterial

THREE.MeshStandardMaterial 是一种采用物理方法来确定场景中光照反应的材质。它非常适合用于具有光泽和金属质感的材质,并提供了一些您可以用来配置此材质的属性:

  • metalness: 此属性决定了材质的金属程度。非金属材质应使用 0 的值,而金属材质应使用接近 1 的值。默认值为 0.5

  • roughness: 您也可以设置材质的粗糙程度。这决定了光线照射到该材质时的扩散情况。默认值为 0.5。值为 0 时呈现类似镜面的反射,而值为 1 则会扩散所有光线。

除了这些属性外,您还可以使用 coloremissive 属性,以及来自 THREE.Material 的属性,来改变此材质。如以下截图所示,我们可以通过调整 metalnessroughness 参数来模拟一种刷漆金属的外观:

Figure 4.17 – Creating a brushed metal effect with MeshStandardMaterial

Figure 4.17 – Creating a brushed metal effect with MeshStandardMaterial

Three.js 提供了一种材质,提供了更多设置以渲染看起来更真实的对象:THREE.MeshPhysicalMaterial

THREE.MeshPhysicalMaterial

THREE.MeshStandardMaterial 非常接近的材质是 THREE.MeshPhysicalMaterial。使用此材质,您可以更好地控制材质的反射性。除了我们之前看到的 THREE.MeshPhysicalMaterial 的属性外,此材质还提供了以下属性,以帮助您控制材质的外观:

  • clearCoat: 表示材料上涂层层的值。此值越高,涂层的应用越多,clearCoatRoughness 参数的效果越明显。此值范围从 01,默认值为 0

  • clearCoatRoughness: 材料涂层的粗糙程度。越粗糙,光线扩散越多。这通常与 clearCoat 属性一起使用。此值范围从 01,默认值为 0

正如我们看到的其他材质一样,很难推理出你应该为特定需求使用的值。通常,添加一个简单的 UI(如我们在示例中所做)并调整值以找到最能反映你需求的组合是最好的选择。你可以通过查看 mesh-physical-material.html 示例来查看这个示例的实际效果:

图 4.18 – 使用清漆控制反射的网格物理材质

图 4.18 – 使用清漆控制反射的网格物理材质

大多数高级材质都能投射和接收阴影。我们将快速查看的下一个材质与大多数材质略有不同。这种材质不会渲染对象本身,而只显示阴影。

THREE.ShadowMaterial

THREE.ShadowMaterial 是一种特殊的材质,它没有任何属性。你不能设置颜色或光泽,或任何其他东西。这个材质唯一能做的就是渲染网格接收到的阴影。以下截图应该可以解释这一点:

图 4.19 – 仅渲染网格接收到的阴影的阴影材质

图 4.19 – 仅渲染网格接收到的阴影的阴影材质

在这里,我们只能看到对象接收到的阴影,没有其他东西。这种材质可以,例如,与你的其他材质结合使用,而无需确定如何接收阴影。

我们将要探索的最后一种高级材质是 THREE.ShaderMaterial

使用 THREE.ShaderMaterial 自定义着色器

THREE.ShaderMaterial 是 Three.js 中最灵活和复杂的材质之一。使用这种材质,你可以传递自己的自定义着色器,这些着色器直接在 WebGL 上下文中运行。着色器将 Three.js 的 JavaScript 网格转换为屏幕上的像素。使用这些自定义着色器,你可以精确地定义你的对象应该如何渲染,以及如何覆盖或修改 Three.js 的默认设置。在本节中,我们不会过多地介绍如何编写自定义着色器;相反,我们只会展示几个示例。

正如我们已经看到的,THREE.ShaderMaterial 有几个你可以设置的属性。使用 THREE.ShaderMaterial,Three.js 会将所有关于这些属性的详细信息传递给你的自定义着色器,但你仍然需要处理这些信息以创建颜色和顶点位置。以下是将传递到着色器中的 THREE.Material 属性,你可以自己进行解释:

  • wireframe: 这会将材质渲染为线框。这对于调试目的非常有用。

  • shading: 这定义了阴影的应用方式。可能的值有 THREE.SmoothShadingTHREE.FlatShading。这个属性在这个材料的示例中没有被启用。例如,可以查看 THREE. MeshNormalMaterial 部分。

  • vertexColors: 您可以使用此属性为每个顶点定义要应用的颜色。查看THREE.LineBasicMaterial部分中的LineBasicMaterial示例,在那里我们使用此属性为线的各个部分着色。

  • fog: 这决定了这个材料是否受全局雾设置的影响。这不会在动作中显示。如果设置为false,我们在第二章中看到的全局雾不会影响这个对象的渲染方式。

除了传递给着色器的这些属性外,THREE.ShaderMaterial还提供了一些特定的属性,您可以使用它们将额外的信息传递到您的自定义着色器中。再次强调,我们不会过多地详细介绍如何编写自己的着色器,因为这本身就可以是一本专著,所以我们只介绍基础知识:

  • fragmentShader: 这个着色器定义了传入的每个像素的颜色。在这里,您需要传入您的片段着色器程序的字面值字符串。

  • vertexShader: 这个着色器允许您改变传入的每个顶点的位置。在这里,您需要传入您的顶点着色器程序的字面值字符串。

  • uniforms: 这允许您将信息发送到您的着色器。相同的信息被发送到每个顶点和片段。

  • defines: 将自定义键值对转换为#define代码片段。使用这些片段,您可以在着色器程序中设置一些额外的全局变量或定义您自己的自定义全局常量。

  • attributes: 这些属性可以在每个顶点和片段之间变化。它们通常用于传递位置和法线相关的数据。如果您想使用这个属性,您需要为几何形状的所有顶点提供信息。

  • lights: 这决定了是否应将光数据传递到着色器中。默认为false

在我们查看示例之前,我们将快速解释THREE.ShaderMaterial最重要的部分。要使用这种材料,我们必须传递两个不同的着色器:

  • vertexShader: 这是在几何形状的每个顶点上运行的。您可以使用这个着色器通过移动顶点的位置来变换几何形状。

  • fragmentShader: 这是在几何形状的每个片段上运行的。在fragmentShader中,我们返回应该显示给这个特定片段的颜色。

对于本章中讨论的所有材料,Three.js 提供了fragmentShadervertexShader,因此您不必担心它们,也不必明确传递它们。

在本节中,我们将查看一个简单的示例,该示例使用一个非常简单的vertexShader程序,该程序改变简单THREE.PlainGeometry的顶点的xy坐标,以及一个fragmentShader程序,该程序根据一些输入改变颜色。

接下来,你可以看到我们的vertexShader的完整代码。请注意,编写着色器不是在 JavaScript 中完成的。你使用一种类似于 C 的语言编写着色器,称为 GLSL(WebGL 支持 OpenGL ES Shading Language 1.0 —— 关于 GLSL 的更多信息,请参阅www.khronos.org/webgl/)。我们的简单着色器代码看起来像这样:

uniform float time;
void main(){
  vec3 posChanged=position;
  posChanged.x=posChanged.x*(abs(sin(time*2.)));
  posChanged.y=posChanged.y*(abs(cos(time*1.)));
  posChanged.z=posChanged.z*(abs(sin(time*.5)));
  gl_Position=projectionMatrix*modelViewMatrix*vec4
    (posChanged,1.);
}

在这里,我们不会过多地深入细节,只关注这段代码最重要的部分。为了从 JavaScript 与着色器通信,我们使用一种称为 uniforms 的东西。在这个例子中,我们使用uniform float time;语句传入一个外部值。

基于此值,我们更改传入顶点的xyz坐标(该顶点作为位置变量传入):

  posChanged.x=posChanged.x*(abs(sin(time*2.)));
  posChanged.y=posChanged.y*(abs(cos(time*1.)));
  posChanged.z=posChanged.z*(abs(sin(time*.5)));

posChanged向量现在包含根据传入的时间变量计算出的这个顶点的新的坐标。我们需要执行的最后一个步骤是将这个新位置传回渲染器,对于 Three.js 来说,这总是这样做的:

  gl_Position=projectionMatrix*modelViewMatrix*vec4
    (posChanged,1.);

gl_Position变量是一个特殊变量,用于返回最终位置。这个程序作为字符串值传递给THREE.ShaderMaterialvertexShader属性。对于fragmentShader,我们做类似的事情。我们创建了一个非常简单的片段着色器,它根据传入的timeuniform 翻转颜色:

uniform float time;
void main(){
  float c1=mod(time,.5);
  float c2=mod(time,.7);
  float c3=mod(time,.9);
  gl_FragColor=vec4(c1,c2,c3,1.);
}

fragmentShader中,我们确定传入的片段(一个像素)的颜色。真实的着色器程序考虑了很多因素,比如灯光、顶点在面上的位置、法线等等。然而,在这个例子中,我们只是确定颜色的rgb值,并在gl_FragColor中返回它,然后它会在最终渲染的网格上显示出来。

现在,我们需要将几何体、材质和两个着色器粘合在一起。在 Three.js 中,我们可以这样做:

const geometry = new THREE.PlaneGeometry(10, 10, 100, 100)
const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 1.0 }
  },
  vertexShader: vs_simple,
  fragmentShader: fs_simple
})
const mesh = new THREE.Mesh(geometry, material)

在这里,我们定义了timeuniform,它将包含在着色器中可用的值,并将vertexShaderfragmentShader定义为我们要使用的字符串。我们唯一需要做的是确保在渲染循环中更改timeuniform,然后就可以了:

// in the renderloop
material.uniforms.time.value += 0.005

在本章的示例中,我们添加了一些简单的着色器来进行实验。如果你打开chapter-4文件夹中的shader-material-vertex.html示例,你会看到结果:

图 4.20 – 显示两个示例着色程序的平面着色器材质

图 4.20 – 显示两个示例着色程序的平面着色器材质

在下拉菜单中,你还可以找到一些其他的着色器。例如,fs_night_sky片段着色器显示了一个星空夜空(基于www.shadertoy.com/view/Nlffzj的着色器)。当与vs_ripple结合使用时,你会得到一个非常漂亮的视觉效果,完全在 GPU 上运行,如图所示:

图 4.21 – 使用星空片段着色器的涟漪效果

图 4.21 – 使用星夜片段着色器的涟漪效果

有可能将现有材质组合起来,并使用你自己的着色器重用它们的片段和顶点着色器。这样,例如,你可以通过一些自定义效果扩展 THREE.MeshStandardMaterial。然而,在 plain Three.js 中这样做相当困难,并且容易出错。幸运的是,有一个开源项目为我们提供了一个自定义材质,这使得包装现有材质并添加我们自己的自定义着色器变得非常容易。在下一节中,我们将快速看一下它是如何工作的。

使用 CustomShaderMaterial 自定义现有着色器

THREE.CustomShader 不包含在默认的 Three.js 分发中,但由于我们使用 yarn,安装它非常简单(这就是你在从 第一章使用 Three.js 创建你的第一个 3D 场景) 运行相关命令时所做的事情)。如果你想了解更多关于这个模块的信息,你可以查看 github.com/FarazzShaikh/THREE-CustomShaderMaterial,在那里你可以找到文档和额外的示例。

在我们展示一些示例之前,先快速看一下代码。使用 THREE.CustomShader 与使用其他材质相同:

const material = new CustomShaderMaterial({
  baseMaterial: THREE.MeshStandardMaterial,
  vertexShader: ...,
  fragmentShader: ...,
  uniforms: {
    time: { value: 0.2 },
    resolution: { value: new THREE.Vector2() }
  },
  flatShading: true,
  color: 0xffffff
})

如你所见,它有点像是普通材质和 THREE.ShaderMaterial 的结合。主要需要关注的是 baseMaterial 属性。在这里,你可以添加任何标准 Three.js 材质。除了 vertexShaderfragmentShaderuniforms 之外,任何你添加的额外属性都会应用到这个 baseMaterial 上。vertexshaderfragmentShaderuniforms 属性的工作方式与我们在 THREE.ShaderMaterial 中看到的方式相同。

默认情况下,我们需要对我们的着色器本身做一些小的修改。回想一下 使用 THREE.ShaderMaterial 自定义你的着色器 部分,在那里我们使用了 gl_Positiongl_FragColor 来设置顶点的最终位置和片段的颜色。使用这种材质,我们使用 csm_Position 作为最终位置,csm_DiffuseColor 作为颜色。还有一些其他的输出变量你可以使用,这些变量在这里有更详细的解释:github.com/FarazzShaikh/THREE-CustomShaderMaterial#output-variables

如果你打开 custom-shader-material 示例,你会看到我们的简单着色器如何与 Three.js 的默认材质一起使用:

图 4.22 – 使用环境图和 MeshStandardMaterials 作为基础的涟漪效果

图 4.22 – 使用环境图和 MeshStandardMaterials 作为基础的涟漪效果

此方法为您提供了一个相对简单的方式来创建自定义着色器,而无需从头开始。您只需重用默认着色器中的灯光和阴影效果,并扩展它们以包含您所需的自定义功能。

到目前为止,我们已查看与网格一起工作的材料。Three.js 还提供了可以与线几何体一起使用的材料。在下一节中,我们将探讨这些材料。

可用于线几何体的材料

我们将要查看的最后几种材料只能用于一个特定的网格:THREE.Line。正如其名称所暗示的,这仅仅是一条线,只由线组成,不包含任何面。Three.js 提供了两种不同的材料,您可以在 THREE.Line 几何体上使用,如下所示:

  • THREE.LineBasicMaterial:这是一种基本的线材料,允许您设置 colorvertexColors 属性。

  • THREE.LineDashedMaterial:它具有与 THREE.LineBasicMaterial 相同的属性,但允许您通过指定 dashspacing 大小来创建虚线效果。

我们将首先查看基本变体;之后,我们将查看虚线变体。

THREE.LineBasicMaterial

可用于 THREE.Line 几何体的材料非常简单。它继承了 THREE.Material 的所有属性,但以下是该材料最重要的属性:

  • color:这决定了线的颜色。如果您指定了 vertexColors,则此属性将被忽略。以下代码片段展示了如何进行此操作。

  • vertexColors:您可以通过将此属性设置为 THREE.VertexColors 值来为每个顶点提供特定的颜色。

在我们查看 THREE.LineBasicMaterial 的示例之前,让我们快速看一下如何从一组顶点创建一个 THREE.Line 网格,并将其与 THREE.LineMaterial 结合起来以创建网格,如下所示:

const points = gosper(4, 50)
const lineGeometry = new THREE.BufferGeometry().
  setFromPoints(points)
const colors = new Float32Array(points.length * 3)
points.forEach((e, i) => {
  const color = new THREE.Color(0xffffff)
  color.setHSL(e.x / 100 + 0.2, (e.y * 20) / 300, 0.8)
  colors[i * 3] = color.r
  colors[i * 3 + 1] = color.g
  colors[i * 3 + 2] = color.b
})
lineGeometry.setAttribute('color', new THREE.
  BufferAttribute(colors, 3, true))
const material = new THREE.LineBasicMaterial(0xff0000);
const mesh = new THREE.Line(lineGeometry, material)
mesh.computeLineDistances()

以下代码片段的第一部分 const points = gosper(4, 60) 被用作示例,以获取一组 xyz 坐标。此函数返回一个 Gosper 曲线(更多信息,请参阅 mathworld.wolfram.com/Peano-GosperCurve.html),这是一个简单的算法,用于填充 2D 空间。接下来我们要做的是创建一个 THREE.BufferGeometry 实例,并调用 setFromPoints 函数来添加生成的点。对于每个坐标,我们还会计算一个颜色值,并将其用于设置几何体的 color 属性。注意此代码片段末尾的 mesh.computeLineDistances。当您想使用 THREE.LineDashedMaterial 时,这将是必需的。

现在我们有了我们的几何体,我们可以创建 THREE.LineBasicMaterial 并将其与几何体一起使用来创建一个 THREE.Line 网格。您可以在 line-basic-material.html 示例中看到结果。以下截图显示了此示例:

图 4.23 – 基本线材料

图 4.23 – 基本线材料

这是一个使用THREE.LineBasicMaterial创建的线几何体。如果我们启用vertexColors属性,我们会看到各个线段都有颜色:

图 4.24 – 带顶点颜色的基本线材料

图 4.24 – 带顶点颜色的基本线材料

本章接下来要讨论的最后一种材料与THREE.LineBasicMaterial只有细微的差别。使用THREE.LineDashedMaterial,我们不仅可以给线着色,还可以给这些线添加空间。

THREE.LineDashedMaterial

这种材料具有与THREE.LineBasicMaterial相同的属性,以及三个额外的属性,可以用来定义虚线的宽度和虚线之间的间隙宽度:

  • scale: 这会缩放dashSizegapSize。如果缩放小于1,则dashSizegapSize增加,而如果缩放大于1,则dashSizegapSize减小。

  • dashSize: 这是虚线的长度。

  • gapSize: 这是间隙的长度。

这种材料几乎与THREE.LineBasicMaterial完全相同。唯一的区别是您必须调用computeLineDistances()(用于确定构成线的顶点之间的距离)。如果不这样做,间隙将不会正确显示。这种材料的示例可以在line-dashed-material.html中找到,看起来是这样的:

图 4.25 – 使用线虚线材料的高斯网格

图 4.25 – 使用线虚线材料的高斯网格

关于用于线的材料这一部分就到这里。您已经看到,Three.js 只为线几何体提供了一些特定的材料,但使用这些材料,特别是与vertexColors结合使用,您应该能够以任何您想要的方式样式化线几何体。

摘要

Three.js 提供了许多您可以用来为几何体着色的材料。这些材料从非常简单的(THREE.MeshBasicMaterial)到复杂的(THREE.ShaderMaterial),其中您可以提供自己的vertexShaderfragmentShader程序。材料共享许多基本属性。如果您知道如何使用一种材料,您可能也知道如何使用其他材料。请注意,并非所有材料都对场景中的灯光做出反应。如果您需要一个考虑光照效果的材质,通常只需使用THREE.MeshStandardMaterial。如果您需要更多控制,也可以查看THREE.MeshPhysicalMaterialTHREE.MeshPhongMaterialTHREE.MeshLamberMaterial。仅从代码中确定某些材质属性的效果非常困难。通常,一个好的方法是使用控制 GUI 方法来实验这些属性,就像我们在本章中展示的那样。

此外,请记住,大多数材料的属性都可以在运行时进行修改。尽管如此(例如,side),有些属性在运行时是无法修改的。如果你更改了这样的值,你需要将needsUpdate属性设置为true。关于在运行时可以和不可以更改的完整概述,请参阅以下页面:threejs.org/docs/#manual/en/introduction/How-to-update-things

在本章和上一章中,我们讨论了几何体。我们在示例中使用它们,并探索了其中的一些。在下一章中,你将了解有关几何体的所有内容以及如何与它们一起工作。

第五章:学习与几何体一起工作

在前面的章节中,您学习了如何使用 Three.js。现在您知道如何创建基本场景,添加光照,并为您的网格配置材质。在 第二章构成 Three.js 场景的基本组件,我们简要介绍了(但并未深入探讨)Three.js 提供的可用几何体以及您可以使用它们创建 3D 对象的细节。在本章和 第六章探索高级几何体 中,我们将向您介绍 Three.js 提供的所有内置几何体(除了我们在 第四章使用 Three.js 材质 中讨论的 THREE.Line)。

在本章中,我们将探讨以下几何体:

  • THREE.CircleGeometry

  • THREE.RingGeometry

  • THREE.PlaneGeometry

  • THREE.ShapeGeometry

  • THREE.BoxGeometry

  • THREE.SphereGeometry

  • THREE.CylinderGeometry

  • THREE.ConeGeometry

  • THREE.TorusGeometry

  • THREE.TorusKnotGeometry

  • THREE.PolyhedronGeometry

  • THREE.IcosahedronGeometry

  • THREE.OctahedronGeometry

  • THREE.TetraHedronGeometry

  • THREE.DodecahedronGeometry

在我们探讨 Three.js 提供的几何体之前,我们首先深入了解一下 Three.js 如何作为 THREE.BufferGeometry 内部表示几何体。在某些文档中,您可能仍然会遇到 THREE.Geometry 作为所有几何体的基础对象。在较新版本中,这已被完全替换为 THREE.BufferGeometry,它通常提供更好的性能,因为它可以轻松地将数据传递到 GPU。然而,它比旧的 THREE.Geometry 稍微难以使用。

使用 THREE.BufferGeometry,几何体的所有属性都通过一组属性来识别。属性基本上是一个包含有关顶点位置信息的附加元数据的数组。属性还用于存储有关顶点的额外信息——例如,其颜色。要使用属性来定义顶点和面,您可以使用 THREE.BufferGeometry 的以下两个属性:

  • attributesattributes 属性用于存储可以直接传递到 GPU 的信息。例如,为了定义一个形状,您定义一个 Float32Array,其中每个三个值定义一个顶点的位置。

然后将每个三个顶点解释为一个面。这可以在 THREE.BufferGeometry 中定义如下:geometry.setAttribute( 'position', new THREE.BufferAttribute( arrayOfVertices, 3 ) );

  • index:默认情况下,不需要显式定义面(每三个连续的位置被解释为一个单独的面),但使用 index 属性,我们可以显式定义哪些顶点一起形成一个面:geometry.setIndex( indicesArray );

对于本章中使用的几何形状,你不需要考虑这些内部属性,因为 Three.js 在你构建几何形状时会正确设置它们。但是,如果你要从头开始创建一个几何形状,你需要使用前面列表中显示的属性。

在 Three.js 中,我们有几个几何形状可以生成 2D 网格,以及更多用于创建 3D 网格的几何形状。在本章中,我们将讨论以下主题:

  • 2D 几何形状

  • 3D 几何形状

2D 几何形状

2D 对象看起来像平面对象,正如其名称所暗示的,只有两个维度。在本节中,我们将首先查看 2D 几何形状:THREE.CircleGeometryTHREE.RingGeometryTHREE.PlaneGeometryTHREE.ShapeGeometry

THREE.PlaneGeometry

一个 THREE.PlaneGeometry 对象可以用来创建一个非常简单的 2D 矩形。例如,查看本章源代码中的 plane-geometry.html 示例。以下截图展示了使用 THREE.PlaneGeometry 创建的矩形:

图 5.1 – 平面几何

图 5.1 – 平面几何

在本章的示例中,我们添加了一个控制 GUI,你可以使用它来控制几何形状的属性(在这种情况下,widthheightwidthSegmentsheightSegments),还可以更改材质(及其属性),禁用阴影,并隐藏地面。例如,如果你想看到这个形状的各个面,你可以通过禁用地面并启用所选材质的 wireframe 属性轻松显示它们:

图 5.2 – 作为线框的平面几何

图 5.2 – 作为线框的平面几何

创建一个 THREE.PlaneGeometry 对象非常简单,如下所示:

new THREE.PlaneGeometry(width, height, widthSegments, heightSegments)

在本例中,对于 THREE.PlaneGeometry,你可以更改这些属性,并直接看到它们对生成的 3D 对象产生的影响。以下列表展示了这些属性的说明:

  • width:这是矩形的宽度。

  • height:这是矩形的长度。

  • widthSegments:这是宽度应该分割成的段数。默认为 1。

  • heightSegments:这是高度应该分割成的段数。默认为 1。

如你所见,这并不是一个非常复杂的几何形状。你只需指定大小,然后就可以完成了。如果你想创建更多的面(例如,当你想创建一个棋盘图案时),你可以使用 widthSegmentsheightSegments 属性将几何形状分割成更小的面。

注意

如果你想在创建几何形状之后访问其属性,你不能只说 plane.width。要访问几何形状的属性,你必须使用对象的 parameters 属性。因此,要获取本节中创建的平面对象的 width 属性,你必须使用 plane.parameters.width

THREE.CircleGeometry

你可能已经能猜到THREE.CircleGeometry创建的是什么了。使用这种几何形状,你可以创建一个非常简单的 2D 圆(或圆的一部分)。首先,让我们看看这个几何形状的示例,circle-geometry.html

在以下屏幕截图中,你可以找到一个示例,其中我们创建了一个具有thetaLength值的THREE.CircleGeometry对象,这个值小于2 * PI

图 5.3 – 2D 圆几何

图 5.3 – 2D 圆几何

在这个示例中,你可以看到并控制由THREE.CircleGeometry创建的网格。2 * PI代表弧度为完整的圆。如果你更愿意使用度数而不是弧度,它们之间的转换非常简单。

以下两个函数可以帮助你将弧度转换为度数,如下所示:

const deg2rad = (degrees) => (degrees * Math.PI) / 180
const rad2deg = (radians) => (radians * 180) / Math.PI

当你创建THREE.CircleGeometry时,你可以指定一些属性来定义圆的外观,如下所示:

  • radius: 圆的半径定义了它的大小。半径是从圆心到其边缘的距离。默认值是 50。

  • segments: 这个属性定义了用于创建圆的面数。最小值是 3,如果没有指定,这个值默认为 8。数值越高,圆越平滑。

  • thetaStart: 这个属性定义了绘制圆的起始位置。这个值可以从 0 到2 * PI,默认值是 0。

  • thetaLength: 这个属性定义了圆的完整程度。如果没有指定,默认值为2 * PI(一个完整的圆)。例如,如果你为这个值指定0.5 * PI,你将得到一个四分之一的圆。使用这个属性与thetaStart属性一起定义圆的形状。

你可以通过只指定radiussegments来创建一个完整的圆:

new THREE.CircleGeometry(3, 12)

如果你想要从这个几何形状创建半圆,你可以使用类似以下的方法:

new THREE.CircleGeometry(3, 12, 0, Math.PI);

这创建了一个半径为3的圆,被分成12个段。圆从默认的0开始,只画了一半,因为我们指定了thetaLengthMath.PI,这是半圆。

在继续下一个几何形状之前,这里有一个关于 Three.js 在创建这些 2D 形状(THREE.PlaneGeometryTHREE.CircleGeometryTHREE.RingGeometryTHREE.ShapeGeometry)时使用的方向的快速说明:Three.js 将这些对象创建为直立状态,因此它们沿着x-y平面对齐。这非常合乎逻辑,因为它们是 2D 形状。然而,通常,特别是使用THREE.PlaneGeometry时,你希望网格平放在地面上(x-z平面),作为某种地面区域,你可以在这个区域上定位其余的对象。要创建一个水平方向的 2D 对象而不是垂直方向的,最简单的方法是将网格绕其x-轴旋转四分之一圈(-PI/2),如下所示:

mesh.rotation.x =- Math.PI/2;

关于THREE.CircleGeometry的所有内容到此结束。下一个几何形状,THREE.RingGeometry,看起来与THREE.CircleGeometry非常相似。

THREE.RingGeometry

使用THREE.RingGeometry,你可以创建一个 2D 对象,它不仅非常类似于THREE.CircleGeometry,还允许你在中心定义一个孔(见ring-geometry.html):

图 5.4 – 2D 环形几何

图 5.4 – 2D 环形几何

在创建THREE.RingGeometry对象时,你可以使用以下属性:

  • innerRadius: 圆的内半径定义了中心孔的大小。如果此属性设置为0,则不会显示孔。默认值为 0。

  • outerRadius: 圆的外半径定义了其大小。半径是从圆心到其边缘的距离。默认值为 50。

  • thetaSegments: 这是指用于创建圆的斜线段的数量。值越大,环形越平滑。默认值为 8。

  • phiSegments: 这是沿环形长度所需的段数。默认值为 8。这实际上并不影响圆的平滑度,但增加了面的数量。

  • thetaStart: 这定义了开始绘制圆的位置。此值可以从 0 到2 * PI,默认值为 0。

  • thetaLength: 这定义了圆的完整程度。如果没有指定,默认为2 * PI(一个完整的圆)。例如,如果您为此值指定0.5 * PI,则将得到四分之一的圆。使用此属性与thetaStart属性一起定义环的形状。

以下截图显示了thetaStartthetaLength如何与THREE.RingGeometry一起工作:

图 5.5 – 不同 theta 值的 2D 环形几何

图 5.5 – 不同 theta 值的 2D 环形几何

在下一节中,我们将探讨 2D 形状中的最后一个:THREE.ShapeGeometry

THREE.ShapeGeometry

THREE.PlaneGeometryTHREE.CircleGeometry在自定义外观方面有限。如果您想创建自定义 2D 形状,可以使用THREE.ShapeGeometry

使用THREE.ShapeGeometry,你可以调用一些函数来创建自己的形状。你可以将此功能与 HTML 画布元素和 SVG 中可用的<path/>元素功能进行比较。让我们从一个示例开始,然后我们将向您展示如何使用各种函数来绘制自己的形状。shape-geometry.html示例可以在本章的源代码中找到。

以下截图显示了此示例:

图 5.6 – 自定义形状几何

图 5.6 – 自定义形状几何

在这个例子中,你可以看到一个自定义创建的 2D 形状。在描述属性之前,首先,让我们看看创建这个形状所使用的代码。在我们创建 THREE.ShapeGeometry 对象之前,我们首先必须创建一个 THREE.Shape 对象。你可以通过查看之前的截图来追踪这些步骤,我们从右下角开始。这是创建 THREE.Shape 对象的方法:

const drawShape = () => {
  // create a basic shape
  const shape = new THREE.Shape()
  // startpoint
  // straight line upwards
  shape.lineTo(10, 40)
  // the top of the figure, curve to the right
  shape.bezierCurveTo(15, 25, 25, 25, 30, 40)
  // spline back down
  shape.splineThru([new THREE.Vector2(32, 30), new
    THREE.Vector2(28, 20), new THREE.Vector2(30, 10)])
  // add 'eye' hole one
  const hole1 = new THREE.Path()
  hole1.absellipse(16, 24, 2, 3, 0, Math.PI * 2, true)
  shape.holes.push(hole1)
  // add 'eye hole 2'
  const hole2 = new THREE.Path()
  hole2.absellipse(23, 24, 2, 3, 0, Math.PI * 2, true)
  shape.holes.push(hole2)
  // add 'mouth'
  const hole3 = new THREE.Path()
  hole3.absarc(20, 16, 2, 0, Math.PI, true)
  shape.holes.push(hole3)
  return shape
}

在这段代码中,你可以看到我们使用线条、曲线和样条创建了这个形状的轮廓。之后,我们通过使用 THREE.Shapeholes 属性在这个形状上打了许多孔。

然而,在本节中,我们讨论的是 THREE.ShapeGeometry 而不是 THREE.Shape – 要从 THREE.Shape 创建一个几何体,我们需要将 THREE.Shape(在我们的例子中,由 drawShape() 函数返回)作为 THREE.ShapeGeometry 的参数传入。你也可以传入一个 THREE.Shape 对象的数组,但在我们的例子中,我们只使用了一个对象:

new THREE.ShapeGeometry(drawShape())

这个函数的结果是一个可以用来创建网格的几何体。除了你想要转换为 THREE.ShapeGeometry 的形状之外,你还可以将多个额外的选项对象作为第二个参数传入:

  • curveSegments: 这个属性决定了从形状创建的曲线的平滑度。默认值是 12。

  • material: 这是用于为指定形状创建的面的 materialIndex 属性。因此,如果你传入多个材质,你可以指定应该将哪些材质应用于创建的形状的面。

  • UVGenerator: 当你使用材质与纹理一起使用时,UV 映射决定了纹理的哪一部分用于特定的面。使用 UVGenerator 属性,你可以传入自己的对象,这将为传入的形状创建 UV 设置。有关 UV 设置的更多信息,请参阅第十章加载和使用纹理。如果没有指定,则使用 THREE.ExtrudeGeometry.WorldUVGenerator

THREE.ShapeGeometry 的最重要部分是 THREE.Shape,你使用它来创建形状,所以让我们看看你可以用来创建 THREE.Shape 的绘图函数列表:

  • moveTo(x,y): 将绘图位置移动到指定的 x-y-c 坐标。

  • lineTo(x,y): 从当前位置(例如,由 moveTo 函数设置)绘制一条线到提供的 x-y-c 坐标。

  • quadraticCurveTo(aCPx, aCPy, x, y): 有两种不同的方式来指定曲线。你可以使用 quadraticCurveTo 函数,或者使用 bezierCurveTo 函数。这两个函数之间的区别在于如何指定曲线的曲率。

对于二次曲线,我们需要指定一个额外的点(使用 aCPxaCPy 参数),曲线仅基于该点以及当然,指定的端点(来自 xy 参数)。对于三次曲线(由 bezierCurveTo 函数使用),你指定两个额外的点来定义曲线。起点是路径的当前位置。

以下图表解释了这两种选项之间的区别:

图 5.7 – 二次贝塞尔和三次贝塞尔

图 5.7 – 二次贝塞尔和三次贝塞尔

  • bezierCurveTo(aCPx1, aCPy1, aCPx2, aCPy2, x, y): 这将根据提供的参数绘制一条曲线。有关解释,请参阅前面的列表条目。曲线是根据定义曲线的两个坐标(aCPx1aCPy1aCPx2aCPy2)以及端点坐标(xy)绘制的。起点是路径的当前位置。

  • splineThru(pts): 此函数通过提供的坐标集(pts)绘制一条流畅的线条。此参数应为一个 THREE.Vector2 对象的数组。起点是路径的当前位置。

  • arc(aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise): 这将绘制一个圆(或圆的一部分)。圆从路径的当前位置开始。在这里,aXaY 被用作相对于当前位置的偏移量。请注意,aRadius 设置圆的大小,而 aStartAngleaEndAngle 定义绘制圆的哪一部分。布尔值 aClockwise 属性确定圆是按顺时针还是逆时针绘制。

  • absArc(aX, aY, aRadius, aStartAngle, aEndAngle, AClockwise): 请参阅 arc 属性的描述。位置是绝对值,而不是相对于当前位置。

  • ellipse(aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise): 请参阅 arc 属性的描述。作为补充,使用 ellipse 函数,我们可以分别设置 x 半径和 y 半径。

  • absEllipse(aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise): 请参阅 ellipse 属性的描述。位置是绝对值,而不是相对于当前位置。

  • fromPoints(vectors): 如果你向此函数传递一个 THREE.Vector2(或 THREE.Vector3)对象的数组,Three.js 将使用提供的向量创建一条路径。

  • holes: holes 属性包含一个 THREE.Shape 对象的数组。数组中的每个对象都被渲染为一个孔。一个很好的例子是我们在本节开头看到的例子。在那个代码片段中,我们向这个数组添加了三个 THREE.Shape 对象:一个用于左眼,一个用于右眼,还有一个用于我们主要的 THREE.Shape 对象的嘴巴。

就像许多示例一样,为了理解各种属性如何影响最终形状,最简单的方法就是只启用材质上的 wireframe 属性,并调整设置。例如,以下截图显示了当您为 curveSegments 使用低值时会发生什么:

图 5.8 – 形状几何的线框图

图 5.8 – 形状几何的线框图

如您所见,形状失去了它漂亮、圆润的边缘,但在过程中使用了更少的面。这就是二维形状的全部内容。以下几节将展示并解释基本三维形状。

三维几何体

在本节关于基本三维几何体的内容中,我们将从我们已经见过几次的几何体开始:THREE.BoxGeometry

THREE.BoxGeometry

THREE.BoxGeometry 是一个非常简单的三维几何体,允许您通过指定其 widthheightdepth 属性来创建一个盒子。我们添加了一个示例,box-geometry.html,您可以在其中调整这些属性。以下截图显示了此几何体:

图 5.9 – 基本三维箱体几何

图 5.9 – 基本三维箱体几何

如您在此示例中看到的,通过更改 THREE.BoxGeometrywidthheightdepth 属性,您可以控制生成的网格的大小。当您创建一个新的立方体时,这三个属性也是必需的,如下所示:

new THREE.BoxGeometry(10,10,10);

在示例中,您还可以看到一些可以在立方体上定义的其他属性。以下列表解释了所有属性:

  • width:这是立方体的宽度。这是立方体顶点沿 x 轴的长度。

  • height:这是立方体的高度。这是立方体顶点沿 y 轴的长度。

  • depth:这是立方体的深度。这是立方体顶点沿 z 轴的长度。

  • widthSegments:这是我们将立方体的一个面沿 x 轴分割成多少段。默认值是 1。您定义的段数越多,一个面的面数就越多。如果此属性和下一个两个属性设置为 1,立方体的每个面将只有 2 个面。如果此属性设置为 2,面将被分割成 2 段,从而产生 4 个面。

  • heightSegments:这是我们将立方体的一个面沿 y 轴分割成多少段。默认值是 1。

  • depthSegments:这是我们将立方体的一个面沿 z 轴分割成多少段。默认值是 1。

通过增加各种段属性,您将立方体的六个主要面分割成更小的面。如果您想使用 THREE.MeshFaceMaterial 在立方体的某些部分设置特定的材质属性,这很有用。

THREE.BoxGeometry 是一个非常简单的几何体。另一个简单的是 THREE.SphereGeometry

THREE.SphereGeometry

使用 THREE.SphereGeometry,您可以创建一个三维球体。让我们直接进入示例,sphere-geometry.html

图 5.10 – 简单 3D 球体几何

图 5.10 – 简单 3D 球体几何

在上一个截图中,我们向您展示了一个基于 THREE.SphereGeometry 创建的半开放球体。这种几何形状非常灵活,可以用来创建各种与球体相关的几何形状。然而,一个基本的 THREE.SphereGeometry 实例可以像这样轻松创建:new THREE.SphereGeometry()

以下属性可以用来调整最终网格的外观:

  • radius:这用于设置球体的半径。这定义了最终网格的大小。默认值是 50。

  • widthSegments:这是用于垂直方向的段数。段数越多,表面越光滑。默认值是 8,最小值是 3。

  • heightSegments:这是用于水平方向的段数。段数越多,球体的表面越光滑。默认值是 6,最小值是 2。

  • phiStart:这决定了在球体的 x- 轴上开始绘制球体的位置。这可以从 0 到 2 * PI 范围内变化。默认值是 0

  • phiLength:这决定了从 phiStart 开始绘制球体的距离。2 * PI 将绘制一个完整的球体,而 0.5 * PI 将绘制一个开放的四分之一球体。默认值是 2 * PI

  • thetaStart:这决定了在球体的 x- 轴上开始绘制球体的位置。这可以从 0 到 2 * PI 范围内变化,默认值是 0。

  • thetaLength:这决定了从 thetaStart 开始绘制球体的距离。2 * PI 值表示一个完整的球体,而 PI 将只绘制球体的一半。默认值是 2 * PI

radiuswidthSegmentsheightSegments 属性应该是清晰的。我们已经在其他示例中看到过这类属性。phiStartphiLengththetaStartthetaLength 属性没有例子可能比较难以理解。幸运的是,你可以在 sphere-geometry.html 示例的菜单中实验这些属性,并创建如这些有趣的几何形状:

图 5.11 – 具有不同 phi 和 theta 属性的球体几何

图 5.11 – 具有不同 phi 和 theta 属性的球体几何

列表中的下一个是 THREE.CylinderGeometry

THREE.CylinderGeometry

使用这种几何形状,我们可以创建圆柱和圆柱形物体。至于其他所有几何形状,我们也有一个示例(cylinder-geometry.html),让你可以实验这种几何形状的特性,其截图如下:

图 5.12 – 3D 圆柱体几何

图 5.12 – 3D 圆柱体几何

当您创建THREE.CylinderGeometry时,没有强制性的参数,因此您只需调用new THREE.CylinderGeometry()即可创建圆柱。您可以通过传递一系列属性,如前例所示,来改变这个圆柱的外观。这些属性将在以下列表中解释:

  • radiusTop: 这设置了圆柱顶部的尺寸。默认值为 20。

  • radiusBottom: 这设置了圆柱底部的尺寸。默认值为 20。

  • height: 这个属性设置了圆柱的高度。默认高度为 100。

  • radialSegments: 这决定了圆柱半径方向上的分段数。默认值为 8。更多的分段意味着圆柱更加光滑。

  • heightSegments: 这决定了圆柱高度方向上的分段数。默认值为 1。更多的分段意味着更多的面。

  • openEnded: 这决定了网格在顶部和底部是否闭合。默认值为false

  • thetaStart: 这决定了沿着圆柱的x-轴开始绘制圆柱的位置。这可以从 0 到2 * PI,默认值为 0。

  • thetaLength: 这决定了从thetaStart开始绘制圆柱的距离。2 * PI值是一个完整的圆柱,而PI将只绘制圆柱的一半。默认值为2 * PI

这些都是非常基本的属性,您可以使用它们来配置圆柱。然而,有一个有趣的地方,那就是当您为顶部(或底部)使用负radius值时。如果您这样做,可以使用这种几何形状创建一个沙漏形状,如下面的截图所示:

图 5.13 – 3D 圆柱几何作为沙漏

图 5.13 – 3D 圆柱几何作为沙漏

这里需要注意的一点是,在这种情况下,上半部分是翻转的。如果您使用未配置THREE.DoubleSide的材料,您将看不到上半部分。

下一个几何形状是THREE.ConeGeometry,它提供了与THREE.CylinderGeometry基本相同的功能,但顶部半径固定为零。

THREE.ConeGeometry

THREE.ConeGeometry几乎与THREE.CylinderGeometry相同。它使用所有相同的属性,但它只允许您设置半径,而不是单独的radiusTopradiusBottom值:

图 5.14 – 简单 3D 圆锥几何

图 5.14 – 简单 3D 圆锥几何

以下属性可以在THREE.ConeGeometry上设置:

  • radius: 这设置了圆柱底部的尺寸。默认值为20

  • height: 这个属性设置了圆柱的高度。默认高度为 100。

  • radialSegments: 这决定了圆柱半径方向上的分段数。默认值为 8。更多的分段意味着圆柱更加光滑。

  • heightSegments: 这决定了沿着圆柱高度方向的分段数。默认值是 1。更多的分段意味着更多的面。

  • openEnded: 这决定了网格是否在顶部和底部封闭。默认值是false

  • thetaStart: 这决定了沿着圆柱的x-轴开始绘制的位置。这可以是从 0 到2 * PI的范围,默认值是 0。

  • thetaLength: 这决定了从thetaStart开始绘制圆柱的距离。2 * PI值是一个完整的圆柱,而PI将只绘制圆柱的一半。默认值是2 * PI

下一个几何形状,THREE.TorusGeometry,允许您创建类似甜甜圈的形状对象。

THREE.TorusGeometry

环面是一个看起来像甜甜圈的基本形状。以下截图,您可以通过打开torus-geometry.html示例来获取,显示了THREE.TorusGeometry的实际应用:

图 5.15 – 3D 环面几何形状

图 5.15 – 3D 环面几何形状

就像大多数简单几何形状一样,创建THREE.TorusGeometry时没有强制性的参数。以下列表提到了您在创建此几何形状时可以指定的参数:

  • radius: 这设置了完整环面的大小。默认值是 100。

  • tube: 这设置了管状体的半径(即实际的甜甜圈)。此属性的默认值是 40。

  • radialSegments: 这决定了沿着环面长度方向要使用的分段数。默认值是 8。请参见示例中更改此值的效果。

  • tubularSegments: 这决定了沿着环面宽度方向要使用的分段数。默认值是 6。请参见示例中更改此值的效果。

  • arc: 通过这个属性,您可以控制环面是否绘制一个完整的圆。此值的默认值是2 * PI(一个完整的圆)。

这些大多数都是非常基本的属性,您已经见过。然而,arc属性却非常有趣。通过这个属性,您可以定义甜甜圈是画一个完整的圆还是只有部分圆。通过实验这个属性,您可以创建非常有趣的网格,如下面一个将arc设置为小于2 * PI的值的例子:

图 5.16 – 具有较小圆弧属性的 3D 环面几何形状

图 5.16 – 具有较小圆弧属性的 3D 环面几何形状

THREE.TorusGeometry是一个非常直接的几何形状。在下一节中,我们将查看一个几乎与其名称相同但不太直接的几何形状:THREE.TorusKnotGeometry

THREE.TorusKnotGeometry

使用THREE.TorusKnotGeometry,您可以创建环面结。环面结是一种特殊的结,看起来像一根管子绕自身缠绕了几次。最好的解释方式是查看torus-knot-geometry.html示例。以下截图显示了此几何形状:

图 5.17 – 环面结几何形状

图 5.17 – 环面结几何形状

如果你打开这个示例并调整 pq 属性,你可以创建各种美丽的几何形状。p 属性定义了结围绕其轴旋转的频率,而 q 定义了结围绕其内部旋转的程度。

如果这听起来有点模糊,不要担心。你不需要理解这些属性就能创建出美丽的结,例如以下截图所示(对细节感兴趣的人,Wolfram 在 mathworld.wolfram.com/TorusKnot.html 上有一篇关于这个主题的好文章):

图 5.18 – 不同 p 和 q 值的环面结几何形状

图 5.18 – 不同 p 和 q 值的环面结几何形状

通过这个几何形状的示例,你可以调整以下属性并查看 pq 的各种组合对这个几何形状的影响:

  • radius:这个属性设置了完整环面的尺寸。默认值是 100。

  • tube:这个属性设置了管(实际甜甜圈)的半径。此属性的默认值是 40。

  • radialSegments:这个属性决定了在环面结的长度方向上要使用的分段数。默认值是 64。在演示中查看更改此值的效果。

  • tubularSegments:这个属性决定了在环面结的宽度方向上要使用的分段数。默认值是 8。在演示中查看更改此值的效果。

  • p:这个属性定义了结的形状,默认值是 2。

  • q:这个属性定义了结的形状,默认值是 3。

  • heightScale:使用这个属性,你可以拉伸环面结。默认值是 1。

列表中的下一个几何形状是基本几何形状中的最后一个:THREE.PolyhedronGeometry

THREE.PolyhedronGeometry

使用这种几何形状,你可以轻松地创建多面体。多面体是一种只有平面面和直线边的几何形状。然而,通常你不会直接使用 THREE.PolyhedronGeometry。Three.js 提供了一系列特定的多面体,你可以使用这些多面体而不必指定 THREE.PolyhedronGeometry 的顶点和面。我们将在本节后面讨论这些多面体。

如果你确实想直接使用 THREE.PolyhedronGeometry,你必须指定顶点和面(就像我们在 第三章在 Three.js 中使用光源) 中为立方体所做的那样)。例如,我们可以创建一个简单的四面体(也参见本章中关于 THREE.TetrahedronGeometry 的部分),如下所示:

const vertices = [
 1,  1,  1,
-1, -1,  1,
-1,  1, -1,
 1, -1, -1
];
const indices = [
2,  1,  0,
0,  3,  2,
1,  3,  0,
2,  3,  1
];
new THREE.PolyhedronBufferGeometry(vertices, indices, radius, detail)

要构建 THREE.PolyhedronGeometry,我们需要传入 verticesindicesradiusdetail 属性。生成的 THREE.PolyhedronGeometry 对象在 polyhedron-geometry.html 示例中展示:

图 5.19 – 自定义多面体

图 5.19 – 自定义多面体

当你创建一个多面体时,你可以传入以下四个属性:

  • vertices: 这些是多面体组成的点。

  • indices: 这些是需要从顶点创建的面。

  • radius: 这是多面体的大小。默认值为 1。

  • detail: 使用这个属性,你可以为多面体添加额外的细节。如果你将其设置为 1,多面体中的每个三角形将被分割成 4 个更小的三角形。如果你将其设置为 2,那 4 个更小的三角形将再次各自分割成 4 个更小的三角形,依此类推。

以下截图显示了相同的自定义网格,但现在具有更高的细节级别:

图 5.20 – 具有更高细节级别的自定义多面体

图 5.20 – 具有更高细节级别的自定义多面体

在本节的开始,我们提到 Three.js 默认提供了一些多面体。在接下来的小节中,我们将快速展示这些多面体。所有这些多面体类型都可以通过查看 polyhedron-geometry.html 示例来查看。

THREE.IcosahedronGeometry

THREE.IcosahedronGeometry 创建了一个由 12 个顶点构成的 20 个相同三角形面的多面体。在创建这个多面体时,你需要指定的只有半径和细节级别。此截图显示了使用 THREE.IcosahedronGeometry 创建的多面体:

图 5.21 – 3D 二十面体几何

图 5.21 – 3D 二十面体几何

我们将要查看的下一个多面体是 THREE.TetrahedronGeometry

THREE.TetrahedronGeometry

四面体是多面体中最简单的一种。这个多面体只包含由四个顶点创建的四个三角形面。你可以像使用 Three.js 提供的其他多面体一样创建 THREE.TetrahedronGeometry,只需指定半径和细节级别。以下截图显示了使用 THREE.TetrahedronGeometry 创建的四面体:

图 5.22 – 3D 四面体几何

图 5.22 – 3D 四面体几何

接下来,我们将看到具有八个面的多面体:八面体。

THREE.OctahedronGeometry

Three.js 还提供了一个八面体的实现。正如其名所示,这个多面体有八个面。这些面是由六个顶点创建的。以下截图显示了此几何形状:

图 5.23 – 3D 八面体几何

图 5.23 – 3D 八面体几何

我们将要查看的最后一个多面体是十二面体。

THREE.DodecahedronGeometry

Three.js 提供的最后一个多面体几何是 THREE.DodecahedronGeometry。这个多面体有 12 个面。以下截图显示了此几何形状:

图 5.24 – 3D 十二面体几何

图 5.24 – 3D 十二面体几何

如你所见,Three.js 提供了一系列的 3D 几何形状,从直接有用的形状,如球体和立方体,到在实践中可能不太有用的形状,如多面体集合。无论如何,这些 3D 几何形状将为你创建和实验材料、几何形状和 3D 场景提供一个良好的起点。

摘要

在本章中,我们讨论了 Three.js 所能提供的所有标准几何形状。正如你所见,有很多几何形状可以直接使用。为了最好地学习如何使用这些几何形状,请进行实验。使用本章中的示例来了解你可以用来自定义从 Three.js 获取的标准几何形状集的属性。

对于二维形状,重要的是要记住它们放置在x-y平面上。如果你想水平放置二维形状,你需要将网格绕x-轴旋转-0.5 * PI。最后,注意如果你正在旋转二维形状,或者一个开放的 3D 形状(例如,圆柱或管),记得将材质设置为THREE.DoubleSide。如果不这样做,你的几何体的内部或背面将不会显示。

在本章中,我们专注于简单、直接的网格。Three.js 还提供了创建复杂几何形状的方法,我们将在第六章中介绍。

第六章:探索高级几何形状

第五章《学习与几何形状一起工作》中,我们向您展示了 Three.js 提供的所有基本几何形状。除了这些基本几何形状之外,Three.js 还提供了一套更高级和专业的对象。

在本章中,我们将向您展示这些高级几何形状:

  • 如何使用THREE.ConvexGeometryTHREE.LatheGeometryTHREE.BoxLineGeometryTHREE.RoundeBoxGeometryTHREE.TeapotGeometryTHREE.TubeGeometry等高级几何形状。

  • 如何使用THREE.ExtrudeGeometry从 2D 形状创建 3D 形状。我们将从一个 2D SVG 图像创建一个 3D 形状,并将从 2D Three.js 形状中拉伸以创建新颖的 3D 形状。

  • 如果您想自己创建自定义形状,您可以继续玩我们之前章节中讨论的那些。然而,Three.js 还提供了一个THREE.ParametricGeometry对象。使用参数化几何,您可以使用可以更改以影响几何形状形状的参数来创建几何形状。

  • 我们还将展示如何使用THREE.TextGeometry创建 3D 文字效果,并展示如何使用 Troika 库在场景中添加 2D 文字标签。

  • 此外,我们还将向您展示如何使用两个辅助几何形状,THREE.WireframeGeometryTHREE.EdgesGeometry。这些辅助工具允许您查看其他几何形状的更多细节。

我们将从列表中的第一个开始,THREE.ConvexGeometry

学习高级几何形状

在本节中,我们将探讨几个高级 Three.js 几何形状。我们将从THREE.ConvexGeometry开始,您可以使用它来创建凸包。

THREE.ConvexGeometry

使用THREE.ConvexGeometry,我们可以从一组点中创建一个凸包。凸包是包含所有这些点的最小形状。理解这一点最简单的方法是查看一个示例。如果您打开convex-geometry.html示例,您将看到一组随机点的凸包。以下截图显示了该几何形状:

图 6.1 – 包含所有点的凸包

图 6.1 – 包含所有点的凸包

在此示例中,我们生成一组随机点,并根据这些点创建THREE.ConvexGeometry。在示例中,您可以使用1来查看用于创建此几何形状的点。为了这个示例,这些点被创建为小的THREE.SphereGeometry对象。

要创建THREE.ConvexGeometry,我们需要一组点。以下代码片段显示了我们是如何做到这一点的:

const generatePoints = () => {
  const spGroup = new THREE.Object3D()
  spGroup.name = 'spGroup'
  const points = []
  for (let i = 0; i < 20; i++) {
    const randomX = -5 + Math.round(Math.random() * 10)
    const randomY = -5 + Math.round(Math.random() * 10)
    const randomZ = -5 + Math.round(Math.random() * 10)
    points.push(new THREE.Vector3(randomX, randomY, randomZ))
  }
  const material = new THREE.MeshBasicMaterial({ color:
    0xff0000, transparent: false })
  points.forEach(function (point) {
    const spGeom = new THREE.SphereGeometry(0.04)
    const spMesh = new THREE.Mesh(spGeom, material)
    spMesh.position.copy(point)
    spGroup.add(spMesh)
  })
  return {
    spGroup,
    points
  }
}

如此代码片段所示,我们创建了 20 个随机点(THREE.Vector3),并将它们推入一个数组中。接下来,我们遍历这个数组,创建 THREE.SphereGeometry,并将位置设置为这些点中的一个(position.copy(point))。所有点都被添加到一个组中,这样我们就可以在重绘时轻松地替换它们。一旦你有了这组点,从它们创建 THREE.ConvexGeometry 就非常简单,如下面的代码片段所示:

const convexGeometry = new THREE.ConvexGeometry(points);

一个包含顶点(THREE.Vector3 类型)的数组是 THREE.ConvexGeometry 唯一的参数。请注意,如果你想渲染一个平滑的 THREE.ConvexGeometry,你应该调用 computeVertexNormals,正如我们在 第二章组成 Three.js 应用程序的基本组件 中所解释的。

下一个复杂的几何形状是 THREE.LatheGeometry,它可以用来创建类似花瓶的形状。

THREE.LatheGeometry

THREE.LatheGeometry 允许你从一组点创建形状,这些点共同形成一个曲线。如果你看 *图 6.2**,你可以看到我们创建了许多点(红色圆点),Three.js 使用这些点来创建 THREE.LatheGeometry。再次强调,了解 THREE.LatheGeometry 的最佳方式是查看一个示例。这个几何形状在 lathe-geometry.html 中显示。以下是从示例中截取的屏幕截图,显示了此几何形状:

图 6.2 – 用于类似花瓶网格的车床

图 6.2 – 用于类似花瓶网格的车床

在前面的截图上,你可以看到创建这个几何形状所用的点,它们以一组小红球的形式呈现。这些点的位置被传递到 THREE.LatheGeometry 中,同时还有定义几何形状形状的参数。在我们查看所有参数之前,让我们看看创建单个点所用的代码以及 THREE.LatheGeometry 如何使用这些点:

const generatePoints = () => {
  ...
  const points = []
  const height = 0.4
  const count = 25
  for (let i = 0; i < count; i++) {
    points.push(new THREE.Vector3((Math.sin(i * 0.4) +
      Math.cos(i * 0.4)) * height + 3, i / 6, 0))
  }
  ...
}
// use the same points to create a LatheGeometry
const latheGeometry = new THREE.LatheGeometry (points,
  segments, phiStart, phiLength);
latheMesh = createMesh(latheGeometry);
scene.add(latheMesh);
}

在这段 JavaScript 代码中,我们可以看到我们生成了 25 个点,其 x 坐标基于正弦和余弦函数的组合,而 y 坐标基于 icount 变量。这在前面的截图中的红色圆点可视化为样条线。基于这些点,我们可以创建 THREE.LatheGeometry。除了顶点数组之外,THREE.LatheGeometry 还接受一些其他参数。以下列表解释了这些属性:

  • points:这些是构成生成钟形/花瓶形状的样条线的点。

  • segments:这些是在创建形状时使用的段数。这个数字越高,最终形状就越圆滑。默认值是 12

  • phiStart:这决定了在生成形状时从圆的哪个位置开始。这可以从 02*PI。默认值是 0

  • phiLength:这定义了形状生成的完整程度。例如,四分之一形状将是0.5*PI。默认值是完整的 360 度或2*PI。这个形状将从phiStart属性的起始位置开始。

第五章中,我们已经看到了BoxGeometry。Three.js 还提供了另外两个类似盒子的几何体,我们将在下一节讨论。

BoxLineGeometry

如果您只想显示轮廓,可以使用THREE.BoxLineGeometry。这个几何体与THREE.BoxGeometry完全一样,但它不是渲染一个实体对象,而是使用线条渲染盒子,如下所示(来自box-line-geometry.html):

图 6.3 – 使用线条渲染的盒子

图 6.3 – 使用线条渲染的盒子

您使用这个几何体的方式与THREE.BoxGeometry相同,但不是创建THREE.Mesh,我们需要创建THREE.LineSegments,使用可用的线特定材料之一:

import { BoxLineGeometry } from 'three/examples/jsm/
  geometries/BoxLineGeometry'
const material = new THREE.LineBasicMaterial({ color:
  0x000000 }),
const geometry = new BoxLineGeometry(width, height, depth,
  widthSegments, heightSegments, depthSegments)
const lines = new THREE.LineSegments(geometry, material)
scene.add(lines)

关于您可以传递给这个几何体的属性的说明,请参阅第五章中的THREE.BoxGeometry部分。

Three.js 还提供了一个稍微高级一点的THREE.BoxGeometry,您可以使用它来获得漂亮的圆角。您可以使用RoundedBoxGeometry来实现这一点。

THREE.RoundedBoxGeometry

这个几何体使用与THREE.BoxGeometry相同的属性,但它还允许您指定圆角应该有多圆。在rounded-box-geometry示例中,您可以查看它看起来如何:

图 6.4 – 带有圆角的盒子

图 6.4 – 带有圆角的盒子

对于这个几何体,我们可以通过指定widthheightdepth来指定盒子的尺寸。除了这些属性之外,这个几何体还提供了两个额外的属性:

  • radius:这是圆角的大小。这个值越高,圆角就越圆。

  • segments:这个属性定义了圆角将有多详细。如果这个值设置得较低,Three.js 将使用较少的顶点来定义圆角。

在我们展示如何从二维对象创建三维几何体之前,我们将查看 Three.js 提供的最终几何体,TeapotGeometry

TeapotGeometry

TeapotGeometry是一种可以用来渲染茶壶的几何体。这个茶壶是 3D 渲染的标准参考模型,自 1975 年以来一直被使用。关于这个模型历史的更多信息可以在这里找到:www.computerhistory.org/revolution/computer-graphics-music-and-art/15/206

使用这个模型的工作方式与迄今为止我们看到的所有其他模型完全相同:

import { TeapotGeometry } from 'three/examples/jsm/
  geometries/TeapotGeometry'
...
const geom = new TeapotGeometry(size, segments, bottom,
  lid, body, fitLid, blinn)

您指定特定的属性然后创建几何体,将其分配给THREE.Mesh。根据属性,结果看起来像这样(在teapot-geometry.html示例中):

图 6.5 – 犹他茶壶

图 6.5 – 犹他茶壶

要配置此几何形状,您可以使用以下属性:

  • size: 这是茶壶的大小。

  • segments: 这定义了用于创建茶壶线框的段数。您使用的段数越多,茶壶看起来就越平滑。

  • bottom: 如果设置为true,茶壶的底部将被渲染。如果为false,底部将不会被渲染,这可以在茶壶位于表面且不需要渲染其底部时使用。

  • lid: 如果设置为true,茶壶的盖子将被渲染。如果为false,盖子将不会被渲染。

  • body: 如果设置为true,茶壶的主体将被渲染。如果为false,主体将不会被渲染。

  • fitLid: 如果设置为true,盖子将正好适合茶壶。如果为false,盖子和茶壶主体之间将有一个小间隙。

  • blinn: 这定义了是否使用与茶壶基于的原始 1975 年模型的相同纵横比。

在接下来的几节中,我们将探讨一种通过从 2D 形状中提取 3D 几何形状来创建几何形状的替代方法。

通过挤出 2D 形状创建几何形状

Three.js 提供了一种方法,我们可以通过挤出 2D 形状到 3D 形状。通过挤出,我们指的是沿着其z轴拉伸 2D 形状以将其转换为 3D。例如,如果我们挤出THREE.CircleGeometry,我们得到一个看起来像圆柱体的形状,如果我们挤出THREE.PlaneGeometry,我们得到一个类似立方体的形状。挤出形状最灵活的方法是使用THREE.ExtrudeGeometry

THREE.ExtrudeGeometry

使用THREE.ExtrudeGeometry,您可以从 2D 形状创建 3D 对象。在我们深入探讨这个几何形状的细节之前,让我们先看看一个例子,extrude-geometry.html。以下是从例子中截取的屏幕截图,显示了此几何形状:

图 6.6 – 从 2D 形状创建 3D 几何形状

图 6.6 – 从 2D 形状创建 3D 几何形状

在这个例子中,我们使用了在第五章中“2D 几何形状”部分创建的 2D 形状,并使用THREE.ExtrudeGeometry将其转换为 3D。正如您在前面的屏幕截图中看到的,形状沿着z轴被挤出,从而形成了一个 3D 形状。创建THREE.ExtrudeGeometry的代码非常简单:

const geometry = new THREE.ExtrudeGeometry(drawShape(), {
    curveSegments,
    steps,
    depth,
    bevelEnabled,
    bevelThickness,
    bevelSize,
    bevelOffset,
    bevelSegments,
    amount
  })

在此代码中,我们使用drawShape()函数创建了形状,就像我们在第五章中所做的那样。这个形状与一组属性一起传递给THREE.ExtrudeGeometry构造函数。通过这些属性,您可以精确地定义形状应该如何被挤出。以下列表解释了您可以传递给THREE.ExtrudeGeometry的选项:

  • shapes: 为了挤出几何形状,需要一个或多个形状(THREE.Shape 对象)。请参阅第五章,了解如何创建此类形状。

  • depth: 这决定了形状应该被拉伸多远(深度)。默认值是 100。

  • bevelThickness: 这决定了斜面的深度。斜面是前后面和拉伸之间的圆角。此值定义斜面进入形状的深度。默认值是 6

  • bevelSize: 这决定了斜面的高度。这被添加到形状的正常高度上。默认值是 bevelThickness - 2

  • bevelSegments: 这定义了斜面将使用的段数。使用的段数越多,斜面看起来越平滑。默认值是 3。注意,如果你添加更多段,你也在增加顶点数,这可能会对性能产生不利影响。

  • bevelEnabled: 如果设置为 true,则添加斜面。默认值是 true

  • bevelOffset: 这是斜面开始处的形状轮廓距离。默认值是 0

  • curveSegments: 这决定了在拉伸形状的曲线时将使用多少个段。使用的段数越多,曲线看起来越平滑。默认值是 12

  • steps: 这定义了形状在拉伸深度上将被分割成多少个段。默认值是 1,这意味着它在其深度上只有一个段,没有不必要的额外顶点。

  • extrudePath: 这是形状应该拉伸的路径(THREE.CurvePath)。如果没有指定,形状将沿着 z 轴拉伸。注意,如果你有一个弯曲的路径,你还需要确保为 steps 属性设置一个更高的值,以便它可以准确地跟随曲线。

  • uvGenerator: 当你使用材质与纹理一起使用时,UV 映射决定了纹理的哪个部分用于特定的面。通过 uvGenerator 属性,你可以传入自己的对象,这将为传入的形状创建面的 UV 设置。有关 UV 设置的更多信息,请参阅 第十章加载和使用纹理。如果没有指定,则使用 THREE.ExtrudeGeometry.WorldUVGenerator

如果你想要为面和侧面使用不同的材质,你可以向 THREE.Mesh 传递一个材质数组。传入的第一个材质将应用于面,第二个材质将用于侧面。你可以通过 extrude-geometry.html 示例中的菜单来实验这些选项。在这个例子中,我们沿着其 z 轴拉伸了形状。正如你在本节前面列出的选项中可以看到,你也可以使用 extrudePath 选项沿着路径拉伸形状。在下面的几何体 THREE.TubeGeometry 中,我们将这样做。

THREE.TubeGeometry

THREE.TubeGeometry创建了一个沿着 3D 样条曲线拉伸的管状体。您使用多个顶点指定路径,THREE.TubeGeometry将创建管状体。您可以在本章的源代码中找到一个可以实验的示例(tube-geometry.html)。以下截图显示了此示例:

图 6.7 – 基于随机 3D 顶点的 TubeGeometry

图 6.7 – 基于随机 3D 顶点的 TubeGeometry

正如您在这个示例中所看到的,我们生成了一些随机点,并使用这些点来绘制管状体。通过菜单中的控件,我们可以定义管状体的外观。创建管状体所需的代码非常简单,如下所示:

const points = ... // array of THREE.Vector3 objects
const tubeGeometry = new TubeGeometry(
  new THREE.CatmullRomCurve3(points),
  tubularSegments,
  radius,
  radiusSegments,
  closed
)

我们首先需要获取一组顶点(points变量),这些顶点是THREE.Vector3类型,就像我们为THREE.ConvexGeometryTHREE.LatheGeometry所做的那样。然而,在我们能够使用这些点来创建管状体之前,我们首先需要将这些点转换为THREE.Curve。换句话说,我们需要定义一条通过我们定义的点的平滑曲线。我们可以通过将顶点数组传递给THREE.CatmullRomCurve3的构造函数,或者任何由 Three.js 提供的其他Curve实现来实现这一点。有了这条曲线和其他参数(我们将在本节中解释),我们可以创建管状体并将其添加到场景中。

在这个例子中,我们使用了THREE.CatmullRomCurve3。Three.js 提供了一些其他曲线,您也可以使用,它们接受略微不同的参数,但它们可以用来创建不同的曲线实现。开箱即用,Three.js 提供了以下曲线:ArcCurveCatmullRomCurve3CubicBezierCurveCubicBezierCurve3EllipseCurveLineCurveLineCurve3QuadraticBezierCurveQuadraticBezierCurve3SplineCurve

THREE.TubeGeometry除了曲线之外还接受一些其他参数。以下列出了THREE.TubeGeometry的所有参数:

  • path:这是THREE.SplineCurve3,它描述了管状体应该遵循的路径。

  • tubularSegments:这些是构建管状体所使用的段数。默认值为64。路径越长,您应该指定的段数就越多。

  • radius:这是管状体的半径。默认值为1

  • radiusSegments:这是沿管状体长度使用的段数。默认值为8。您使用的越多,管状体看起来就越圆。

  • closed:如果设置为true,管状体的起始和结束将连接起来。默认值为false

本章我们将展示的最后一个是拉伸示例,它实际上并不是一种不同的几何类型,但我们将使用THREE.ExtrudeGeometry从 SVG 图像创建拉伸体。

什么是 SVG?

SVG 是一种基于 XML 的标准,可以用于在网络上创建基于矢量的 2D 图像。这是一个由所有现代浏览器支持的开源标准。然而,直接使用 SVG 并从 JavaScript 中操作它并不是非常直接。幸运的是,有几个开源 JavaScript 库使得处理 SVG 变得容易得多。Paper.jsSnap.jsD3.jsRaphael.js 是其中一些最好的。如果你需要一个图形编辑器,你还可以使用开源的 Inkscape 产品。

从 SVG 元素拉伸 3D 形状

当我们在 第五章 中讨论 THREE.ShapeGeometry 时,我们提到 SVG 遵循几乎相同的绘图形状方法。在本节中,我们将探讨如何使用 SVG 图像与 THREE.SVGLoader 一起拉伸 SVG 图像。我们将使用蝙蝠侠标志作为示例:

图 6.8 – 蝙蝠侠 SVG 基础图像

图 6.8 – 蝙蝠侠 SVG 基础图像

首先,让我们看看原始 SVG 代码的样子(你还可以在查看 assets/svg/batman.svg 文件的源代码时自己查看):

<svg version="1.0" xmlns="http://www.w3.org/2000/svg"   xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1152px" height="1152px" xml:space="preserve">
  <g>
    <path   id="batman-path" style="fill:rgb(0,0,0);" d="M 261.135 114.535 C 254.906 116.662 247.491 118.825 244.659 119.344 C
    229.433 122.131 177.907 142.565 151.973 156.101 C   111.417
    177.269 78.9808 203.399 49.2992 238.815 C 41.0479   248.66
    26.5057 277.248 21.0148 294.418 C 14.873 313.624    15.3588
    357.341 21.9304 376.806 C 29.244 398.469 39.6107    416.935
    52.0865 430.524 C 58.2431 437.23 63.3085 443.321    63.3431
    444.06 ... 261.135 114.535 "/>
  </g>
</svg>

除非你是 SVG 大师,否则这对你来说可能意义不大。不过,基本上,你在这里看到的是一组绘图指令。例如,C 277.987 119.348 279.673 116.786 279.673 115.867 告诉浏览器绘制一个三次贝塞尔曲线,而 L 489.242 111.787 告诉我们应该绘制到那个特定位置。幸运的是,我们不需要自己编写代码来解释这些,可以使用 THREE.SVGLoader,如下面的代码所示:

// returns a promise
const batmanShapesPromise = new SVGLoader().loadAsync('/assets/svg/batman.svg')
// when promise resolves the svg will contain the shapes
batmanShapes.then((svg) => {
  const shapes = SVGLoader.createShapes(svg.paths[0])
  // based on the shapes we can create an extrude geometry
    as we've seen earlier
  const geometry = new THREE.ExtrudeGeometry(shapes, {
    curveSegments,
    steps,
    depth,
    bevelEnabled,
    bevelThickness,
    bevelSize,
    bevelOffset,
    bevelSegments,
    amount
  })
  ...
}

在这个代码片段中,你可以看到我们使用 SVGLoader 来加载 SVG 文件。我们在这里使用 loadAsync,它将返回一个 JavaScript Promise。当这个 Promise 解决时,我们可以访问加载的 svg 数据。这些数据可以包含一个 path 元素列表,每个元素代表原始 SVG 的 path 元素。在我们的例子中,我们只有一个,所以我们使用 svg.paths[0] 并将其传递给 SVGLoader.createShapes 以将其转换为 THREE.Shape 对象的数组。现在我们有了这些形状,我们可以使用之前当我们拉伸自定义创建的 2D 几何形状时使用的方法,并使用 THREE.ExtrudeGeometry 从加载的 2D SVG 形状创建 3D 模型。

最终结果可以在浏览器中打开 extrude-svg.html 示例时看到:

图 6.9 – 从 2D SVG 图像拉伸创建的 3D 蝙蝠侠标志

图 6.9 – 从 2D SVG 图像拉伸创建的 3D 蝙蝠侠标志

在本节中,我们将讨论的最后一种几何形状是 THREE.ParametricGeometry。使用这种几何形状,你可以指定几个函数,这些函数用于程序化地创建几何形状。

THREE.ParametricGeometry

使用 THREE.ParametricGeometry,你可以根据一个方程创建一个几何体。在我们自己的例子深入之前,一个好的开始是查看 Three.js 已经提供的示例。当你下载 Three.js 分发版时,你会得到 examples/js/ParametricGeometries.js 文件。在这个文件中,你可以找到一些你可以与 THREE.ParametricGeometry 一起使用的方程示例。

最基本的例子是创建平面的函数:

        plane: function ( width, height ) {
            return function ( u, v, target ) {
                const x = u * width;
                const y = 0;
                const z = v * height;
                target.set( x, y, z );
            };
        },

这个函数是由 THREE.ParametricGeometry 调用的。uv 值将在 01 之间变化,并且将被多次调用,涵盖从 01 的所有值。在这个例子中,u 值用于确定向量的 x 坐标,而 v 值用于确定 z 坐标。运行后,你将得到一个宽度为 width,深度为 depth 的基本平面。

在我们的例子中,我们做了类似的事情。然而,我们不是创建一个平面,而是创建了一个波状图案,正如你在 parametric-geometry.html 示例中看到的那样。以下截图显示了此示例:

图 6.10 – 使用参数化几何创建的波状平面

图 6.10 – 使用参数化几何创建的波状平面

为了创建这个形状,我们将以下函数传递给了 THREE.ParametricGeometry

const radialWave = (u, v, optionalTarget) => {
  var result = optionalTarget || new THREE.Vector3()
  var r = 20
  var x = Math.sin(u) * r
  var z = Math.sin(v / 2) * 2 * r + -10
  var y = Math.sin(u * 4 * Math.PI) + Math.cos(v * 2 *
    Math.PI)
  return result.set(x, y, z)
}
const geom = new THREE.ParametricGeometry(radialWave, 120,
  120);

正如你在本例中看到的,通过几行代码,我们可以创建一些非常有趣的几何体。在本例中,你还可以看到我们可以传递给 THREE.ParametricGeometry 的参数:

  • function:这是一个函数,它根据提供的 uv 值定义每个顶点的位置

  • slices:这定义了 u 值应该被分成多少部分

  • stacks:这定义了 v 值应该被分成多少部分

通过改变函数,我们可以轻松地使用完全相同的方法渲染一个完全不同的对象:

图 6.11 – 使用参数化几何渲染的克莱因瓶

图 6.11 – 使用参数化几何渲染的克莱因瓶

在继续本章下一部分之前,关于如何使用 slicesstacks 属性的最后一点说明。我们提到 uv 属性被传递到提供的函数参数中,并且这两个属性的值范围从 01。通过 slicesstacks 属性,我们可以定义传递的函数被调用的频率。例如,如果我们把 slices 设置为 5stacks 设置为 4,函数将使用以下值被调用:

u:0/5, v:0/4
u:1/5, v:0/4
u:2/5, v:0/4
u:3/5, v:0/4
u:4/5, v:0/4
u:5/5, v:0/4
u:0/5, v:1/4
u:1/5, v:1/4
...
u:5/5, v:3/4
u:5/5, v:4/4

因此,这些值越高,你可以指定更多的顶点,你创建的几何体将越平滑。你可以使用 parametric-geometry.html 示例右侧的菜单来查看这种效果。

对于更多示例,你可以查看 Three.js 分发版中的 examples/js/ParametricGeometries.js 文件。这个文件包含创建以下几何体的函数:

  • 克莱因瓶

  • 平面

  • 平面莫比乌斯带

  • 3D 莫比乌斯带

  • 管道

  • 环面结

  • 球体

  • 平面

有时候,你需要看到更多关于你的几何体的细节,而你并不太关心材质以及网格如何渲染。如果你想查看顶点和面,或者仅仅是轮廓,Three.js 提供了一些几何体可以帮助你实现这一点(除了启用你用于网格的材质的 wireframe 属性)。我们将在下一节中探讨这些内容。

可以用于调试的几何体

Three.js 默认提供了两个辅助几何体,这使得查看几何体的细节或仅仅是轮廓变得更加容易:

  • THREE.EdgesGeometry 提供了一个只渲染几何体边的几何体

  • THREE.WireFrameGeometry,它只渲染几何体而不显示任何面

首先,让我们看看 THREE.EdgesGeometry

THREE.EdgesGeometry

使用 THREE.EdgesGeometry,你包裹一个现有的几何体,然后通过只显示边而不是单独的顶点和面来渲染。一个例子可以在 edges-geometry.html 示例中看到:

图 6.12 – 只显示边而不显示单独面的 EdgesGeometry

图 6.12 – 只显示边而不显示单独面的 EdgesGeometry

在前面的截图中,你可以看到 RoundedBoxGeometry 的轮廓被显示出来,我们只看到了边。由于 RoundedBoxGeometry 有平滑的角落,这些角落在使用 THREE.EdgesGeometry 时会被显示出来。

要使用这个几何体,你只需将现有的几何体像这样包裹起来:

const baseGeometry = new RoundedBoxGeometry(3, 3, 3, 10, 0.4)
const edgesGeometry = THREE.EdgesGeometry(baseGeometry, 1.5)
}

THREE.EdgesGeometry 只接受一个属性 thresholdAngle。通过这个属性,你可以确定何时这个几何体绘制边。在 edges-geometry.html 中,你可以控制这个属性以查看效果。

如果你有一个现有的几何体并且想查看线框,你可以配置一个材质来显示这个线框:

const material = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true })

Three.js 还提供了一种使用 THREE.WireFrameGeometry 的不同方式。

THREE.WireFrameGeometry

这个几何体模拟了当你将材质的 wireframe 属性设置为 true 时看到的行为:

图 6.13 – 显示几何体所有单独面的线框几何体

图 6.13 – 展示几何体所有单独面的线框几何体

使用这个材质的方式与使用 THREE.EdgesGeometry 相同:

const baseGeometry = new THREE.TorusKnotBufferGeometry(3, 1, 100, 20, 6, 9)
const wireframeGeometry = new THREE.WireframeGeometry(baseGeometry)

这个几何体不接收任何额外的属性。

本章的最后部分将处理创建 3D 文本对象。我们将展示两种不同的方法,一种使用 THREE.Text 对象,另一种则使用外部库。

创建 3D 文本网格

在本节中,我们将快速查看如何创建 3D 文本。首先,我们将查看如何使用 Three.js 提供的字体渲染文本,以及如何使用你自己的字体。然后,我们将展示一个使用外部库 Troika (github.com/protectwise/troika) 的快速示例,该库使得创建标签和 2D 文本元素并将其添加到场景中变得非常容易。

渲染文本

在 Three.js 中渲染文本非常简单。你只需要定义你想要使用的字体,并使用我们在讨论 THREE.ExtrudeGeometry 时看到的相同拉伸属性。以下截图显示了 text-geometry.html 示例,展示了如何在 Three.js 中渲染文本:

图 6.14 – 在 Three.js 中渲染文本

图 6.14 – 在 Three.js 中渲染文本

创建此 3D 文本所需的代码如下:

import { FontLoader } from 'three/examples/jsm/
  loaders/FontLoader'
import { TextGeometry } from 'three/examples/jsm/
  geometries/TextGeometry'
...
new FontLoader()
  .loadAsync('/assets/fonts/helvetiker_regular.typeface.json')
  .then((font) => {
      const textGeom =  new TextGeometry('Some Text', {
          font,
          size,
          height,
          curveSegments,
          bevelEnabled,
          bevelThickness,
          bevelSize,
          bevelOffset,
          bevelSegments,
          amount
    })
    ...
  )

在此代码片段中,你可以看到我们首先必须加载字体。为此,Three.js 提供了 FontLoader(),我们提供要加载的字体名称,就像我们在 SVGLoader 中做的那样,我们得到一个 JavaScript Promise。一旦该 Promise 解决,我们使用加载的字体来创建 TextGeometry

我们可以传递给 THREE.TextGeometry 的选项与我们可以传递给 THREE.ExtrudeGeometry 的选项相同:

  • font:用于文本的加载字体。

  • size:这是文本的大小。默认值是 100

  • height:这是拉伸的长度(深度)。默认值是 50

  • curveSegments:这定义了在拉伸形状的曲线时使用的段数。段数越多,曲线看起来越平滑。默认值是 4

  • bevelEnabled:如果设置为 true,则添加斜面。默认值是 false

  • bevelThickness:这是斜面的深度。斜面是前后面和拉伸之间的圆角。默认值是 10

  • bevelSize:这是斜面的高度。默认值是 8

  • bevelSegments:这定义了斜面将使用的段数。段数越多,斜面看起来越平滑。默认值是 3

  • bevelOffset:这是斜面开始处的形状轮廓的距离。默认值是 0

由于 THREE.TextGeometry 也是 THREE.ExtrudeGeometry,因此如果你想要为材质的前面和侧面使用不同的材质,则适用相同的方法。如果你在创建 THREE.Mesh 时传入一个包含两种材质的数组,Three.js 将将第一种材质应用于文本的前面和背面,第二种材质应用于侧面。

使用此几何形状也可以使用其他字体,但首先需要将它们转换为 JSON – 如何进行此操作将在下一节中展示。

添加自定义字体

Three.js 提供了一些字体,你可以在场景中使用这些字体。这些字体基于 TypeFace.js 库提供的字体。TypeFace.js 是一个可以将 TrueType 和 OpenType 字体转换为 JavaScript 的库。生成的 JavaScript 文件或 JSON 文件可以包含在你的页面中,然后该字体就可以在 Three.js 中使用。在旧版本中,使用了 JavaScript 文件,但在后来的 Three.js 版本中,Three.js 转向使用 JSON 文件。

要将现有的 OpenType 或 TrueType 字体转换为字体支持的格式,你可以使用网页 gero3.github.io/facetype.js/

图 6.15 – 将字体转换为字体支持的格式

图 6.15 – 将字体转换为字体支持的格式

在这个页面上,你可以上传一个字体,它将为你转换为 JSON。请注意,这并不适用于所有类型的字体。字体越简单(直线越多),在 Three.js 中使用时正确渲染的机会就越大。生成的文件看起来像这样,其中每个字符(或符号)都被描述:

{"glyphs":{"¦":{"x_min":359,"x_max":474,"ha":836,"o":"m 474 971 l 474 457 l
359 457 l 359 971 l 474 971 m 474 277 l 474 -237 l 359 -237 l 359 277 l 474
277 "},"Ž":{"x_min":106,"x_max":793,"ha":836,"o":"m 121 1013 l 778 1013 l
778 908 l 249 115 l 793 115 l 793 0 l 106 0 l 106 104 l 620 898 l 121 898 l
121 1013 m 353 1109 l 211 1289 l 305 1289 l 417 1168 l 530 1289 l 625 1289
l 482 1109 l 353 1109 "},"Á":{"x_min":25,"x_max":811,"ha":836,"o":"m 417
892 l 27 ....

一旦你有了 JSON 文件,你可以使用 FontLoader(如我们在 渲染文本 部分之前所展示的)来加载这个字体,并将其分配给可以传递给 TextGeometry 的选项中的 font 属性。

在本章的最后一个例子中,我们将探讨使用 Three.js 创建文本的另一种方法。

使用 Troika 库创建文本

如果你想要为场景的某些部分创建标签或 2D 文本标记,有一个替代选项是使用 THREE.Text 几何体。你还可以使用一个名为 Troika 的外部库:github.com/protectwise/troika

这是一个相当大的库,提供了许多功能来为你的场景添加交互性。对于这个例子,我们只关注该库的文本模块。我们将创建的示例在 troika-text.html 示例中展示:

图 6.16 – Troika 文本用于 2D 标签

图 6.16 – 2D 标签的 Troika 文本

要使用这个库,我们首先必须安装它(如果你遵循了 第一章使用 Three.js 创建你的第一个 3D 场景 的说明,你现在已经可以使用这个库了):$ yarn add troika-three-text。一旦安装,我们就可以导入它并像使用 Three.js 提供的其他模块一样使用它:

import { Text } from 'troika-three-text'
const troikaText = new Text()
troikaText.text = 'Text rendering with Troika!\nGreat for
  2D labels'
troikaText.fontSize = 2
troikaText.position.x = -3
troikaText.color = 0xff00ff
troikaText.sync()
scene.add(troikaText)

在前面的代码片段中,我们展示了如何使用 Troika 创建一个简单的文本元素。你只需要调用 Text() 构造函数并设置属性。然而,需要注意的是,每次你在 Text() 对象中更改属性时,都必须调用 troikaText.sync()。这将确保更改也应用于屏幕上渲染的模型。

摘要

在本章中,我们看到了很多内容。我们介绍了几种高级几何形状,并展示了如何使用 Three.js 创建和渲染文本元素。我们展示了如何使用高级几何形状,如THREE.ConvexGeometryTHREE.TubeGeometryTHREE.LatheGeometry来创建非常漂亮的形状,以及如何通过实验这些几何形状来获得你想要的结果。一个非常棒的功能是,我们还可以使用THREE.ExtrudeGeometry将现有的 SVG 路径转换为 Three.js。

我们还快速查看了几种非常有用的调试几何形状。THREE.EdgesGeometry仅显示另一个几何形状的边,而THREE.WireframeGeometry可以用来显示某些其他几何形状的线框。

最后,如果你想创建 3D 文本,Three.js 提供了TextGeometry,你可以传入你想要使用的字体。Three.js 自带了一些字体,但你也可以创建自己的字体。然而,请记住,复杂的字体通常无法正确转换。使用TextGeometry的替代方案是使用 Troika 库,它使得创建 2D 文本标签并将其放置在场景中的任何位置变得非常容易。

到目前为止,我们查看的是实体(或线框)几何形状,其中顶点相互连接以形成面。在下一章中,我们将探讨一种使用称为粒子或点的东西来可视化几何形状的替代方法。使用粒子,我们不渲染完整的几何形状——我们只是将单个顶点作为空间中的点进行渲染。这允许你创建看起来很棒且性能良好的 3D 效果。

第七章:点和精灵

在前面的章节中,我们讨论了 Three.js 提供的最重要概念、对象和 API。在本章中,我们将探讨我们至今为止跳过的唯一概念:点和精灵。使用 THREE.Points(有时也称为精灵),可以非常容易地创建许多始终面向相机的矩形,您可以使用它们来模拟雨、雪、烟雾和其他有趣的效果。例如,您可以将单个几何体渲染为点集,并分别控制这些点。在本章中,我们将探索 Three.js 提供的各个点和精灵相关功能。

更具体地说,在本章中,我们将探讨以下主题:

  • 使用 THREE.SpriteMaterialTHREE.PointsMaterial 创建和样式化粒子

  • 使用 THREE.Points 创建一组点

  • 使用画布来单独样式化每个点

  • 使用纹理来样式化单个点

  • 动画 THREE.Points 对象

  • 从现有几何体创建 THREE.Points 对象

关于本章中使用的某些名称的简要说明

在 Three.js 的新版本中,与点相关的对象名称已经更改了几次。THREE.Points 对象之前被称为 THREE.PointCloud,在更早的版本中被称为 THREE.ParticleSystemTHREE.Sprite 之前被称为 THREE.Particle,材质也经历了多次名称更改。因此,如果您在网上看到使用这些旧名称的示例,请记住,它们讨论的是相同的概念。

让我们从探索粒子是什么以及如何创建一个粒子开始。

理解点和精灵

与大多数新概念一样,我们将从一个示例开始。在本章的源代码中,您将找到一个名为 sprite.html 的示例。打开此示例后,您将看到一个简约的场景,包含一个简单的彩色正方形:

图 7.1 – 单个渲染的精灵

图 7.1 – 单个渲染的精灵

您可以使用鼠标旋转场景。您会注意到的一件事是,无论您如何看这个正方形,它总是看起来一样。例如,下面的截图显示了从不同位置查看同一场景的视图:

图 7.2 – 单个渲染的精灵将始终面向相机

图 7.2 – 单个渲染的精灵将始终面向相机

如您所见,精灵仍然朝向相机倾斜,您无法看到其背后。您可以将精灵想象成一个始终面向相机的二维平面。如果您创建的精灵没有任何属性,它们将被渲染成小而白色的二维正方形。要创建精灵,我们只需要提供一个材质:

const material = new THREE.SpriteMaterial({ size: 0.1,
  color: 0xff0000 })
const sprite = new THREE.Sprite(material)
sprite.position.copy(new THREE.Vector3(1,1,1))

您可以使用 THREE.SpriteMaterial 配置精灵的外观:

  • color: 这是精灵的颜色。默认颜色是白色。

  • sizeAttenuation:如果设置为 false,精灵将具有相同的大小,无论其相对于摄像机的位置有多远。如果设置为 true,大小基于与摄像机的距离。默认值是 true。注意,这仅在使用 THREE.PerspectiveCamera 时才有影响。对于 THREE.OrthographicCamera,如果设置为 false,它始终起作用。

  • map:使用这个属性,你可以将纹理应用到精灵上。例如,你可以使它们看起来像雪花。这个属性在这个示例中没有展示,但在本章的 使用纹理来样式化粒子 部分中有解释。

  • opacity:这个属性与 transparent 属性一起设置精灵的不透明度。默认值是 1(完全不透明)。

  • transparent:如果设置为 true,精灵将以 opacity 属性设置的透明度渲染。默认值是 false

  • blending:这是渲染精灵时要使用的混合模式。

注意,THREE.SpriteMaterial 是从基础 THREE.Material 对象扩展而来的,因此该对象的所有属性也可以用于 THREE.SpriteMaterial

在我们继续探讨更有趣的 THREE.Points 对象之前,让我们更仔细地看看 THREE.Sprite 对象。一个 THREE.Sprite 对象就像 THREE.Mesh 一样,是从 THREE.Object3D 对象扩展而来的。这意味着你从 THREE.Mesh 所了解的大部分属性和函数都可以用于 THREE.Sprite。你可以使用 position 属性设置其位置,使用 scale 属性缩放它,并使用 translate 属性沿着其轴移动它。

使用 THREE.Sprite,你可以非常容易地创建一组对象并在场景中移动它们。当你处理少量对象时,这效果很好,但当你想要处理大量 THREE.Sprite 对象时,你会很快遇到性能问题。这是因为每个对象都需要由 Three.js 分别管理。Three.js 提供了一种使用 THREE.Points 对象处理大量精灵的替代方法。使用 THREE.Points,Three.js 不需要管理许多单独的 THREE.Sprite 对象,只需管理 THREE.Points 实例即可。这将允许 Three.js 优化绘制精灵的方式,从而获得更好的性能。以下截图显示了使用 THREE.Points 对象渲染的几个精灵:

图 7.3 – 从 THREE.BufferGeometry 渲染的多个点

图 7.3 – 从 THREE.BufferGeometry 渲染的多个点

要创建一个 THREE.Points 对象,我们需要提供它一个 THREE.BufferGeometry。对于之前的截图,我们可以创建一个 THREE.BufferGeometry,如下所示:

const createPoints = () => {
  const points = []
  for (let x = -15; x < 15; x++) {
    for (let y = -10; y < 10; y++) {
      let point = new THREE.Vector3(x / 4, y / 4, 0)
      points.push(point)
    }
  }
  const colors = new Float32Array(points.length * 3)
  points.forEach((e, i) => {
    const c = new THREE.Color(Math.random() * 0xffffff)
    colors[i * 3] = c.r
    colors[i * 3 + 1] = c.g
    colors[i * 3 + 2] = c.b
  })
  const geom = new THREE.BufferGeometry().setFromPoints(points)
  geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true))
  return geom
}
const material = new THREE.PointsMaterial({ size: 0.1,
  vertexColors: true, color: 0xffffff })
const points = new THREE.Points(createPoint(), material)

如您从这段代码片段中看到的,首先,我们创建了一个 THREE.Vector3 对象数组 – 每个对象对应我们想要创建精灵的位置。此外,我们在 THREE.BufferGeometry 上设置了 color 属性,该属性用于为每个精灵着色。使用 THREE.BufferGeometry 和一个 THREE.PointsMaterial 实例,我们可以创建 THREE.Points 对象。THREE.PointsMaterial 的属性几乎与 THREE.SpriteMaterial 的属性相同:

  • color: 这是点的颜色。默认颜色是 0xffffff

  • sizeAttenuation 如果设置为 false,所有点将具有相同的大小,无论它们距离相机有多远。如果设置为 true,大小将基于距离相机的距离。默认值是 true

  • map: 使用这个属性,你可以将纹理应用到点上。例如,你可以使它们看起来像雪花。这个属性在这个例子中没有展示,但会在本章后面的“使用纹理样式化粒子”部分进行解释。

  • opacity: 这与 transparent 属性一起设置精灵的不透明度。默认值是 1(无不透明度)。

  • transparent: 如果设置为 true,精灵将以 opacity 属性设置的不透明度渲染。默认值是 false

  • blending: 这是渲染精灵时要使用的混合模式。

  • vertexColors: 通常,THREE.Points 中的所有点都有相同的颜色。如果将此属性设置为 true,并且几何体的颜色缓冲区属性已设置,则每个点将从该数组中获取颜色。默认值是 false

和往常一样,您可以通过每个例子右侧的菜单来调整这些属性。

到目前为止,我们只以小方块的形式渲染了粒子,这是默认行为。然而,还有两种额外的样式化粒子的方式,我们将在下一节展示。

使用纹理样式化粒子

在本节中,我们将探讨以下两种改变精灵外观的方式:

  • 使用 HTML 画布绘制图像并显示每个精灵

  • 加载一个外部图像文件来定义每个精灵的外观

让我们先自己绘制图像。

在画布上绘制图像

THREE.PointsMaterial 的属性中,我们提到了 map 属性。使用 map 属性,我们可以为单个点加载纹理。使用 Three.js,这个纹理也可以是 HTML5 画布的输出。在我们查看代码之前,让我们看一个例子(canvastexture.js):

图 7.4 – 使用基于画布的纹理创建精灵

图 7.4 – 使用基于画布的纹理创建精灵

在这里,你可以看到屏幕上有一大群类似《吃豆人》的幽灵。这使用了我们在之前理解点和精灵部分看到的方法。不过,这次我们不是显示一个简单的正方形,而是一个图像。为了创建这个纹理,我们可以使用以下代码:

const createGhostTexture = () => {
  const canvas = document.createElement('canvas')
  canvas.width = 32
  canvas.height = 32
  const ctx = canvas.getContext('2d')
  // the body
  ctx.translate(-81, -84)
  ctx.fillStyle = 'orange'
  ctx.beginPath()
  ctx.moveTo(83, 116)
  ctx.lineTo(83, 102)
  ctx.bezierCurveTo(83, 94, 89, 88, 97, 88)
  // some code removed for clarity
  ctx.fill()
  // the eyes
  ctx.fillStyle = 'white'
  ctx.beginPath()
  ctx.moveTo(91, 96)
  ctx.bezierCurveTo(88, 96, 87, 99, 87, 101)
  ctx.bezierCurveTo(87, 103, 88, 106, 91, 106)
  // some code removed for clarity
  ctx.fill()
  // the pupils
  ctx.fillStyle = 'blue'
  ctx.beginPath()
  ctx.arc(101, 102, 2, 0, Math.PI * 2, true)
  ctx.fill()
  ctx.beginPath()
  ctx.arc(89, 102, 2, 0, Math.PI * 2, true)
  ctx.fill()
  const texture = new THREE.Texture(canvas)
  texture.needsUpdate = true
  return texture
}

如你所见,首先,我们创建一个 HTML 画布,然后开始使用各种ctx.函数在上面绘制。最后,我们通过调用new THREE.Texture(canvas)将这个画布转换为一个THREE.Texture,这样就得到了我们可以用于精灵的纹理。记得将texture.needsUpdate设置为true,这将触发 Three.js 将实际的画布数据加载到纹理中。

现在我们已经得到了一个纹理,我们可以用它来创建一个THREE.PointsMaterial,就像我们在理解点和精灵部分做的那样:

const createPoints = () => {
  const points = []
  const range = 15
  for (let i = 0; i < 15000; i++) {
    let particle = new THREE.Vector3(
      Math.random() * range - range / 2,
      Math.random() * range - range / 2,
      Math.random() * range - range / 2
    )
    points.push(particle)
  }
  const colors = new Float32Array(points.length * 3)
  points.forEach((e, i) => {
    const c = new THREE.Color(Math.random() * 0xffffff)
    colors[i * 3] = c.r
    colors[i * 3 + 1] = c.g
    colors[i * 3 + 2] = c.b
  })
  const geom = new THREE.BufferGeometry().setFromPoints(points)
  geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true))
  return geom
}
const material = new THREE.PointsMaterial({ size: 0.1,
  vertexColors: true, color: 0xffffff, map:
    createGhostTexture() })
const points = new THREE.Points(createPoint(), material)

如你所见,我们为这个示例创建了15000个点,并将它们随机地放置在指定的范围内。你可能注意到,即使你打开了transparency,一些精灵似乎重叠在其他精灵上。这是因为 Three.js 不是根据精灵的 z-index 来排序精灵的,所以在渲染时,它不能正确地确定哪个在另一个之前。你可以通过两种方法来解决这个问题:你可以关闭depthWrite,或者你可以调整alphaTest属性(从 0.5 开始是一个好的起点)。

如果你放大,你会看到 15,000 个单独的精灵:

图 7.5 – 同时显示 15,000 个精灵

图 7.5 – 同时显示 15,000 个精灵

奇妙的是,即使有 100 万个点,一切仍然渲染得非常平滑(当然,这取决于你运行这些示例的硬件):

图 7.6 – 同时显示 1 百万个精灵

图 7.6 – 同时显示 1 百万个精灵

在下一节中,我们将从外部图像加载一些纹理,并使用这些纹理而不是自己绘制纹理。

使用纹理来样式化粒子

在画布上绘制图像部分的示例中,我们看到了如何使用 HTML 画布来样式化THREE.Points。由于你可以绘制任何你想要的东西,甚至可以加载外部图像,你可以使用这种方法为粒子系统添加各种样式。然而,有一个更直接的方法来使用图像来样式化你的粒子:你可以使用THREE.TextureLoader().load()函数将图像加载为一个THREE.Texture对象。这个THREE.Texture对象然后可以被分配给材质的map属性。

在本节中,我们将展示两个示例,并解释如何创建它们。这两个示例都使用图像作为粒子的纹理。在第一个示例中,我们将创建一个雨的模拟(rain.html):

图 7.7 – 模拟雨滴下落

图 7.7 – 模拟雨滴下落

我们需要做的第一件事是获取一个代表我们雨滴的纹理。你可以在 assets/textures/particles 文件夹中找到一些示例。在接下来的章节中,我们将解释纹理的所有细节和要求。现在,你需要知道的是,纹理应该是正方形的,最好是 2 的幂(例如,64 x 64、128 x 128 或 256 x 256)。对于这个例子,我们将使用这个纹理:

图 7.8 – 雨滴纹理

图 7.8 – 雨滴纹理

这个纹理是一个简单的透明图像,显示了雨滴的形状和颜色。在我们能够在 THREE.PointsMaterial 中使用这个纹理之前,我们需要加载它。这可以通过以下行代码完成:

const texture = new THREE.TextureLoader().load("../../assets/textures/particles/raindrop-3t.png");

使用这一行代码,Three.js 将加载纹理,我们可以在我们的材质中使用它。对于这个例子,我们定义了材质如下:

const material = new THREE.PointsMaterial({
    size: 0.1,
    vertexColors: false,
    color: 0xffffff,
    map: texture,
    transparent: true,
    opacity: 0.8,
    alphaTest: 0.01
  }),

在本章中,我们讨论了所有这些属性。这里要理解的主要是 map 属性指向我们使用 THREE.TextureLoader.load 函数加载的纹理。请注意,我们再次使用了 alphaTest 属性,以确保当两个精灵在彼此前面移动时没有奇怪的纹理。

这就处理了 THREE.Points 对象的样式。当你打开这个例子时,你还会看到点本身在移动。这样做非常简单。每个点都是一个顶点,它构成了创建 THREE.Points 对象所使用的几何形状。让我们看看我们如何为这个 THREE.Points 对象添加点:

const count = 25000
const range = 20
const createPoints = () => {
  const points = []
  for (let i = 0; i < count; i++) {
    let particle = new THREE.Vector3(
      Math.random() * range - range / 2,
      Math.random() * range - range / 2,
      Math.random() * range - range / 1.5
    )
    points.push(particle)
  }
  const velocityArray = new Float32Array(count * 2)
  for (let i = 0; i < count * 2; i += 2) {
    velocityArray[i] = ((Math.random() - 0.5) / 5) * 0.1
    velocityArray[i + 1] = (Math.random() / 5) * 0.1 + 0.01
  }
  const geom = new THREE.BufferGeometry().setFromPoints(points)
  geom.setAttribute('velocity', new THREE.BufferAttribute(velocityArray, 2))
  return geom
}
const points = new THREE.Points(geom, material);

这与我们在本章中看到的先前的例子并没有太大的不同。在这里,我们为每个粒子添加了一个名为 velocity 的属性。这个属性由两个值组成:velocityXvelocityY。第一个值定义了粒子(雨滴)如何水平移动,而第二个值定义了雨滴下落的速度。现在每个雨滴都有自己的速度,我们可以在渲染循环中移动单个粒子:

const positionArray = points.geometry.attributes.position.array
const velocityArray = points.geometry.attributes.velocity.array
for (let i = 0; i < points.geometry.attributes.position.count; i++) {
  const velocityX = velocityArray[i * 2]
  const velocityY = velocityArray[i * 2 + 1]
  positionArray[i * 3] += velocityX
  positionArray[i * 3 + 1] -= velocityY
  if (positionArray[i * 3] <= -(range / 2) || positionArray[i * 3] >= range / 2)
    positionArray[i * 3] = positionArray[i * 3] * -1
  if (positionArray[i * 3 + 1] <= -(range / 2) || positionArray[i * 3 + 1] >= range / 2)
    positionArray[i * 3 + 1] = positionArray[i * 3 + 1] * -1
}
points.geometry.attributes.position.needsUpdate = true

在这段代码中,我们从创建 THREE.Points 所使用的几何形状中获取所有顶点(粒子)。对于每个粒子,我们取 velocityXvelocityY 并使用它们来改变粒子的当前位置。然后,我们确保粒子保持在定义的范围内。如果 v.y 位置下降到 0 以下,我们将雨滴重新添加到顶部,如果 v.x 位置达到任何边缘,我们将通过反转水平速度使其弹回。最后,我们需要告诉 Three.js 我们在 bufferGeometry 中更改了一些内容,这样它下次渲染时就知道正确的值了。

让我们看看另一个例子。这次,我们不会制作雨,而是制作雪。此外,我们不会只使用一个纹理——我们将使用三张单独的图片(来自 Three.js 的示例)。让我们首先看看结果(snow.html):

图 7.9 – 基于多个纹理的雪景

图 7.9 – 基于多个纹理的雪景

在前面的屏幕截图中,如果您仔细观察,可以看到我们并没有只使用单个图像作为纹理,而是使用了具有透明背景的多个图像。您可能想知道我们是如何做到这一点的。如您所记得,我们只能为THREE.Points对象使用单个材质。如果我们想使用多个材质,我们只需创建多个THREE.Points实例,如下所示:

const texture1 = new THREE.TextureLoader().load
  ('/assets/textures/particles/snowflake4_t.png')
const texture2 = new THREE.TextureLoader().load
  ('/assets/textures/particles/snowflake2_t.png')
const texture3 = new  THREE.TextureLoader().load
  ('/assets/textures/particles/snowflake3_t.png')
const baseProps = {
  size: 0.1,
  color: 0xffffff,
  transparent: true,
  opacity: 0.5,
  blending: THREE.AdditiveBlending,
  depthTest: false,
  alphaTest: 0.01
}
const material1 = new THREE.PointsMaterial({
  ...baseProps,
  map: texture1
})
const material2 = new THREE.PointsMaterial({
  ...baseProps,
  map: texture2
})
const material3 = new THREE.PointsMaterial({
  ...baseProps,
  map: texture3
})
const points1 = new THREE.Points(createPoints(), material1)
const points2 = new THREE.Points(createPoints(), material2)
const points3 = new THREE.Points(createPoints(), material3)

在这个代码片段中,您可以看到我们创建了三个不同的THREE.Points实例,每个实例都有自己的材质。为了移动雪花,我们使用了与雨相同的方法,因此这里不展示createPoint和渲染循环的细节。这里需要注意的是,可以有一个单一的THREE.Points实例,其中各个精灵有不同的纹理。然而,这需要自定义的fragment-shader和您自己的THREE.ShaderMaterial实例。

在我们进入下一节之前,请注意,使用THREE.Points是向现有场景添加视觉效果的好方法。例如,我们在前面的例子中看到的雪可以迅速将一个标准场景变成雪景:

图 7.10 – 与立方体贴图一起

图 7.10 – THREE.Points与立方体贴图一起

我们可以使用精灵的另一种方式是在现有场景的上方创建一个简单的 2D抬头显示HUD)。我们将在下一节中探讨如何做到这一点。

使用精灵图

在本章的开头,我们使用了一个THREE.Sprite对象来渲染单个点。这些精灵被定位在 3D 世界的某个位置,它们的大小基于与相机的距离(这有时也被称为THREE.Sprite对象:我们将向您展示如何使用额外的THREE.OrthographicCamera实例和一个额外的THREE.Scene来使用THREE.Sprite创建一个类似于 HUD 的层,用于您的 3D 内容。我们还将向您展示如何使用精灵图来选择THREE.Sprite对象的图像。

作为例子,我们将创建一个简单的THREE.Sprite对象,它在屏幕上从左到右移动。在背景中,我们将渲染一个带有相机的 3D 场景,您可以通过移动相机来展示THREE.Sprite对象是独立于相机移动的。以下屏幕截图显示了我们将为第一个示例创建的内容(spritemap.html):

图 7.11 – 使用两个场景和相机创建 HUD

图 7.11 – 使用两个场景和相机创建 HUD

如果您在浏览器中打开这个示例,您会看到一个类似 Pac-Man 幽灵的精灵在屏幕上移动,每当它碰到右边时,它的颜色和形状都会改变。我们首先要做的是看看我们如何创建THREE.OrthographicCamera和单独的场景来渲染这个THREE.Sprite

const sceneOrtho = new THREE.Scene()
sceneOrtho.backgroundColor = new THREE.Color(0x000000)
const cameraOrtho = new THREE.OrthographicCamera(0, window.innerWidth, window.innerHeight, 0, -10, 10)

接下来,让我们看看如何构建 THREE.Sprite 对象以及加载精灵可以采取的各种形状:

const getTexture = () => {
  const texture = new THREE.TextureLoader().load
   ('/assets/textures/particles/sprite-sheet.png')
  return texture
}
const createSprite = (size, transparent, opacity, spriteNumber) => {
  const spriteMaterial = new THREE.SpriteMaterial({
    opacity: opacity,
    color: 0xffffff,
    transparent: transparent,
    map: getTexture()
  })
  // we have 1 row, with five sprites
  spriteMaterial.map.offset = new THREE.Vector2(0.2 * spriteNumber, 0)
  spriteMaterial.map.repeat = new THREE.Vector2(1 / 5, 1)
  // make sure the object is always rendered at the front
  spriteMaterial.depthTest = false
  const sprite = new THREE.Sprite(spriteMaterial)
  sprite.scale.set(size, size, size)
  sprite.position.set(100, 50, -10)
  sprite.velocityX = 5
  sprite.name = 'Sprite'
  sceneOrtho.add(sprite)
}

getTexture() 函数中,我们加载一个纹理。然而,我们不是为每个幽灵加载五张不同的图片,而是加载一个包含所有精灵的单个纹理(也称为精灵图)。作为纹理的图像看起来像这样:

图 7.12 – 输入精灵图

图 7.12 – 输入精灵图

使用 map.offsetmap.repeat 属性,我们可以选择屏幕上要显示的正确精灵。使用 map.offset 属性,我们确定加载的纹理的 x 轴u)和 y 轴v)的偏移量。这些属性的缩放范围从 01。在我们的例子中,如果我们想选择第三个幽灵,我们必须将 u 偏移量(x 轴)设置为 0.4,因为我们只有一行,所以我们不需要改变 v 偏移量(y 轴)。如果我们只设置这个属性,纹理会在屏幕上显示压缩在一起的第三个、第四个和第五个幽灵。如果我们只想显示一个幽灵,我们需要放大。我们可以通过将 map.repeat 属性的 u 值设置为 1/5 来做到这一点。这意味着我们只放大(仅针对 x 轴)以只显示纹理的 20%,这正好是一个幽灵。

最后,我们需要更新 render 函数:

  renderer.render(scene, camera)
  renderer.autoClear = false
  renderer.render(sceneOrtho, cameraOrtho)

首先,我们使用普通相机和两个网格渲染场景;然后,我们渲染包含我们的精灵的场景。在渲染循环中,我们还会切换一些属性,以便当精灵碰到右侧墙壁时显示下一个精灵并改变精灵的方向(代码未显示)。

到目前为止,在本章中,我们主要关注从头开始创建精灵和点云。然而,一个有趣的选择是从现有的几何体创建 THREE.Points

从现有几何体创建 THREE.Points

如您所回忆的,THREE.Points 根据提供的 THREE.BufferGeometry 中的顶点渲染每个点。这意味着如果我们提供一个复杂的几何体(例如,环面结或管),我们可以根据该特定几何体的顶点创建 THREE.Points。在本章的最后一节中,我们将创建一个环面结,就像我们在 第六章 中看到的那样,探索高级几何体,并将其渲染为 THREE.Points 对象。

我们在 第六章 中解释了环面结,所以这里我们不会过多详细说明。下面的屏幕截图显示了示例(points-from-geom.html):

图 7.13 – 以点形式渲染的环面结,带有小动画

图 7.13 – 以点形式渲染的环面结,带有小动画

如您从前面的屏幕截图中所见,用于生成环面结的每个顶点都用作一个点。我们可以这样设置:

const texture = new THREE.TextureLoader().load('/assets/textures/particles/glow.png')
const geometry = new THREE.TorusKnotGeometry(2, 0.5, 100, 30, 2, 3)
const material = new THREE.PointsMaterial({
    size: 0.2,
    vertexColors: false,
    color: 0xffffff,
    map: texture,
    depthWrite: false,
    opacity: 0.1,
    transparent: true,
    blending: THREE.AdditiveBlending
  })
const points = new THREE.Points(geometry, material)

如你所见,我们只是创建了一个几何形状,并将其用作THREE.Points对象的输入。这样,我们可以将每个几何形状渲染为点对象。

注意

如果你使用 Three.js 模型加载器(例如,glTF 模型)加载外部模型,你通常会得到一个对象层次结构——通常分组在THREE.GroupTHREE.Object3D对象中。在这些情况下,你必须将每个组中的每个几何形状转换为THREE.Points对象。

摘要

本章到此结束。我们解释了精灵和点是什么,以及如何使用可用的材质来样式化这些对象。在本章中,你看到了如何直接使用THREE.Sprite,以及如果你想要创建大量的粒子,你应该使用一个THREE.Points对象。使用THREE.Points,所有元素共享相同的材质,你可以为单个粒子更改的唯一属性是其颜色,通过将材质的vertexColors属性设置为true并在THREE.BufferGeometrycolors数组中提供一个颜色值来实现,该数组用于创建THREE.Points。我们还展示了如何通过改变它们的位置来轻松地动画化粒子。这对于单个THREE.Sprite实例以及用于创建THREE.Points对象的几何形状的顶点都是一样的。

到目前为止,我们已经创建了基于 Three.js 提供的几何形状的网格。这对于简单的模型,如球体和立方体,效果很好,但当你想要创建复杂的 3D 模型时,这并不是最佳方法。对于这些模型,你通常会使用 3D 建模应用程序,例如 Blender 或 3D Studio Max。在下一章中,你将学习如何加载和显示由这样的 3D 建模应用程序创建的模型。

第三部分:粒子云,加载和动画模型

在本第三部分,我们将向你展示如何从外部模型加载数据以及 Three.js 如何支持动画。我们还将深入了解 Three.js 支持的纹理类型以及如何使用它们来增强你的模型。

在这部分,有以下章节:

  • 第八章创建和加载高级网格和几何形状

  • 第九章动画和移动相机

  • 第十章加载和使用纹理

第八章:创建和加载高级网格和几何体

在本章中,我们将探讨几种不同的方法来创建和加载高级和复杂的几何体和网格。在第5 章“学习与几何体一起工作”和第6 章“探索高级几何体”中,我们向您展示了如何使用 Three.js 的内置对象创建一些高级几何体。在本章中,我们将使用以下两种方法来创建高级几何体和网格:

  • 几何分组和合并

  • 从外部资源加载几何体

我们从“分组和合并”方法开始。使用这种方法,我们使用标准的 Three.js 分组(THREE.Group)和BufferGeometryUtils.mergeBufferGeometries()函数来创建新的对象。

几何分组和合并

在本节中,我们将探讨 Three.js 的两个基本功能:将对象分组在一起以及将多个几何体合并成一个单一的几何体。我们将从分组对象开始。

将对象分组在一起

在一些前面的章节中,您已经看到了在处理多个材质时如何分组对象。当您使用多个材质从几何体创建网格时,Three.js 会创建一个组。您的几何体的多个副本被添加到这个组中,每个副本都有其特定的材质。这个组被返回,所以它看起来像是一个使用多个材质的网格。然而,实际上,它是一个包含多个网格的组。

创建组非常简单。你创建的每个网格都可以包含子元素,可以使用add函数添加。将子对象添加到组中的效果是,你可以移动、缩放、旋转和平移父对象,所有子对象也会受到影响。当使用组时,你仍然可以引用、修改和定位单个几何体。你需要记住的唯一一点是,所有位置、旋转和平移都是相对于父对象进行的。

让我们看看以下截图中的示例(grouping.html):

图 8.1 – 使用 THREE.Group 对象将对象分组在一起

图 8.1 – 使用 THREE.Group 对象将对象分组在一起

在这个示例中,您可以看到大量被作为一个单一组添加到场景中的立方体。在我们查看控件和使用组的效果之前,让我们快速看看我们是如何创建这个网格的:

  const size = 1
  const amount = 5000
  const range = 20
  const group = new THREE.Group()
  const mat = new THREE.MeshNormalMaterial()
  mat.blending = THREE.NormalBlending
  mat.opacity = 0.1
  mat.transparent = true
  for (let i = 0; i < amount; i++) {
    const x = Math.random() * range - range / 2
    const y = Math.random() * range - range / 2
    const z = Math.random() * range - range / 2
    const g = new THREE.BoxGeometry(size, size, size)
    const m = new THREE.Mesh(g, mat)
    m.position.set(x, y, z)
    group.add(m)
  }

在这个代码片段中,你可以看到我们创建了一个 THREE.Group 实例。这个对象几乎与 THREE.Object3D 相同,它是 THREE.MeshTHREE.Scene 的基类,但本身不包含任何内容或导致任何渲染。在这个示例中,我们使用 add 函数将大量立方体添加到这个场景中。对于这个示例,我们添加了你可以用来改变网格位置的控件。每次你使用这个菜单改变一个属性时,THREE.Group 对象的相关属性也会改变。例如,在下一个示例中,你可以看到当我们缩放这个 THREE.Group 对象时,所有嵌套的立方体也会相应缩放:

图 8.2 – 缩放一个组

图 8.2 – 缩放一个组

如果你想要对 THREE.Group 对象进行更多实验,一个很好的练习是修改示例,使得 THREE.Group 实例本身在 x 轴上旋转,而单个立方体在其 y 轴上旋转。

使用 THREE.Group 的性能影响

在我们进入下一节,探讨合并之前,先快速说明一下性能问题。当你使用 THREE.Group 时,这个组内所有的单个网格都被当作独立对象处理,Three.js 需要管理并渲染这些对象。如果你场景中有大量对象,你会注意到性能的明显下降。如果你查看图 8.2 的左上角,你可以看到,当屏幕上有 5,000 个立方体时,我们大约得到 56 帧每秒FPS)。还不错,但通常我们会以大约 120 FPS 的速度运行。

Three.js 提供了一种额外的方法,我们可以仍然控制单个网格,但获得更好的性能。这是通过 THREE.InstancedMesh 实现的。如果你想要渲染大量具有相同几何形状但具有不同变换(例如,旋转、缩放、颜色或任何其他矩阵变换)的对象,这个对象工作得非常好。

我们创建了一个名为 instanced-mesh.html 的示例,展示了这是如何工作的。在这个示例中,我们渲染了 250,000 个立方体,但性能依然出色:

图 8.3 – 使用 InstancedMesh 对象进行分组

图 8.3 – 使用实例网格对象进行分组

要与 THREE.InstancedMesh 对象一起工作,我们创建它的方式与创建 THREE.Group 实例的方式类似:

  const size = 1
  const amount = 250000
  const range = 20
  const mat = new THREE.MeshNormalMaterial()
  mat.opacity = 0.1
  mat.transparent = true
  mat.blending = THREE.NormalBlending
  const g = new THREE.BoxGeometry(size, size, size)
  const mesh = new THREE.InstancedMesh(g, mat, amount)
  for (let i = 0; i < amount; i++) {
    const x = Math.random() * range - range / 2
    const y = Math.random() * range - range / 2
    const z = Math.random() * range - range / 2
    const matrix = new THREE.Matrix4()
    matrix.makeTranslation(x, y, z)
    mesh.setMatrixAt(i, matrix)
  }

与创建 THREE.InstancedMesh 对象相比,与 THREE.Group 的主要区别在于,我们需要事先定义我们想要使用哪种材质和几何形状,以及我们想要创建多少个这种几何形状的实例。为了定位或旋转我们的一个实例,我们需要提供一个使用 THREE.Matrix4 实例的变换。幸运的是,我们不需要深入研究矩阵背后的数学,因为 Three.js 在 THREE.Matrix4 实例上为我们提供了一些辅助函数来定义旋转、平移以及其他一些变换。在这个例子中,我们只是将每个实例随机定位。

因此,如果你正在处理少量网格(或使用不同几何形状的网格),如果你想将它们组合在一起,你应该使用 THREE.Group 对象。如果你处理的是大量共享几何和材质的网格,你可以使用 THREE.InstancedMesh 对象或 THREE.InstancedBufferGeometry 对象以获得极大的性能提升。

在下一节中,我们将探讨合并,你将结合多个单独的几何形状,最终得到一个单一的 THREE.Geometry 对象。

几何形状合并

在大多数情况下,使用组可以让你轻松地操作和管理大量网格。然而,当你处理一个非常大的对象数量时,性能将成为一个问题,因为 Three.js 必须单独处理组的所有子对象。使用 BufferGeometryUtils.mergeBufferGeometries,你可以将几何形状合并在一起,创建一个组合的几何形状,这样 Three.js 就只需要管理这个单一的几何形状。在 图 8.4 中,你可以看到这是如何工作的以及它对性能的影响。如果你打开 merging.html 示例,你会再次看到一个场景,其中包含相同的一组随机分布的半透明立方体,我们将它们合并成了一个单一的 THREE.BufferGeometry 对象:

图 8.4 – 500,000 个几何形状合并成一个单一的几何形状

图 8.4 – 500,000 个几何形状合并成一个单一的几何形状

如你所见,我们可以轻松渲染 50,000 个立方体而不会出现任何性能下降。为此,我们使用了以下几行代码:

  const size = 1
  const amount = 500000
  const range = 20
  const mat = new THREE.MeshNormalMaterial()
  mat.blending = THREE.NormalBlending
  mat.opacity = 0.1
  mat.transparent = true
  const geoms = []
  for (let i = 0; i < amount; i++) {
    const x = Math.random() * range - range / 2
    const y = Math.random() * range - range / 2
    const z = Math.random() * range - range / 2
    const g = new THREE.BoxGeometry(size, size, size)
    g.translate(x, y, z)
    geoms.push(g)
  }
  const merged = BufferGeometryUtils.
    mergeBufferGeometries(geoms)
  const mesh = new THREE.Mesh(merged, mat)

在这个代码片段中,我们创建了大量 THREE.BoxGeometry 对象,我们使用 BufferGeometryUtils.mergeBufferGeometries(geoms) 函数将它们合并在一起。结果是单个大几何形状,我们可以将其添加到场景中。最大的缺点是,由于它们都被合并成了一个单一的几何形状,你将失去对单个立方体的控制。如果你想移动、旋转或缩放单个立方体,你无法做到(除非你搜索正确的面和顶点并将它们单独定位)。

通过构造实体几何创建新的几何形状

除了在本章中看到的方式合并几何形状外,我们还可以使用 three-bvh-csg (github.com/gkjohnson/three-bvh-csg) 和 Three.csg (github.com/looeee/threejs-csg) 创建几何形状。

使用分组和合并方法,您可以使用 Three.js 提供的基本几何形状创建大型且复杂的几何形状。如果您想创建更高级的几何形状,那么使用 Three.js 提供的程序化方法并不总是最佳和最简单的方法。幸运的是,Three.js 提供了其他一些创建几何形状的选项。在下一节中,我们将探讨如何从外部资源加载几何形状和网格。

从外部资源加载几何形状

Three.js 可以读取大量 3D 文件格式,并导入这些文件中定义的几何形状和网格。这里需要注意的是,这些格式并非所有功能都始终得到支持。因此,有时可能会出现纹理问题,或者材质可能设置不正确。用于交换模型和纹理的新事实标准是 glTF,因此如果您想加载外部创建的模型,通常将那些模型导出为 glTF 格式会在 Three.js 中获得最佳结果。

在本节中,我们将更深入地探讨一些 Three.js 支持的格式,但不会向您展示所有加载器。以下列表显示了 Three.js 支持的格式概述:

  • AMF: AMF 是另一种 3D 打印标准,但现在已经不再处于积极开发状态。有关此标准的更多信息,请参阅以下 维基百科 页面:www.sculpteo.com/en/glossary/amf-definition/.

  • 3DM: 3DM 是 Rhinoceros 所使用的格式,这是一个用于创建 3D 模型的工具。有关 Rhinoceros 的更多信息请在此处查看:www.rhino3d.com/.

  • 3MF: 3MF 是 3D 打印中使用的标准之一。有关此格式的信息可以在 3MF 联盟 主页上找到:3mf.io.

  • COLLAborative Design Activity (COLLADA): COLLADA 是一种基于 XML 格式定义数字资产的格式。这是一个广泛使用的格式,几乎所有 3D 应用程序和渲染引擎都支持它。

  • Draco: Draco 是一种高效存储几何形状和点云的文件格式。它指定了这些元素的最佳压缩和解压缩方式。有关 Draco 如何工作的详细信息可以在其 GitHub 页面上找到:github.com/google/draco.

  • GCode: GCode 是与 3D 打印机或CNC机器通信的标准方式。当模型打印时,3D 打印机可以通过发送 GCode 命令来控制。此标准的详细信息在以下论文中描述:www.nist.gov/publications/nist-rs274ngc-interpreter-version-3?pub_id=823374

  • .glb扩展名和以.gltf扩展名的基于文本的格式。更多关于此标准的信息请参阅:www.khronos.org/gltf/

  • Industry Foundation Classes (IFC): 这是一个由建筑信息模型BIM)工具使用的开放文件格式。它包含了一个建筑模型以及大量关于所用材料的附加信息。更多关于此标准的信息请参阅:www.buildingsmart.org/standards/bsi-standards/industry-foundation-classes/

  • JSON: Three.js 拥有自己的 JSON 格式,您可以使用它声明性地定义几何形状或场景。尽管这不是一个官方格式,但它非常易于使用,当您想要重用复杂的几何形状或场景时非常有用。

  • KMZ: 这是用于 Google Earth 上 3D 资产的格式。更多信息请参阅:developers.google.com/kml/documentation/kmzarchives

  • LDraw: LDraw 是一个开放标准,您可以使用它创建虚拟乐高模型和场景。更多信息请参阅 LDraw 主页:ldraw.org

  • LWO: 这是 LightWave 3D 使用的文件格式。更多关于 LightWave 3D 的信息请参阅:www.lightwave3d.com/

  • NRRD: NRRD 是一种用于可视化体积数据的文件格式。例如,它可以用于渲染 CT 扫描。大量信息和示例可以在以下网站找到:teem.sourceforge.net/nrrd/

  • OBJExporter,如果您想从 Three.js 导出模型到 OBJ 格式。

  • PCD: 这是一个用于描述点云的开放格式。更多信息请参阅:pointclouds.org/documentation/tutorials/pcd_file_format.html

  • PDB: 这是一个非常专业的格式,由蛋白质数据银行PDB)创建,用于指定蛋白质的外观。Three.js 可以加载并可视化指定在此格式中的蛋白质。

  • Polygon File Format (PLY): 这是最常用于存储 3D 扫描器信息的格式。

  • 打包原始 WebGL 模型 (PRWM): 这是一个专注于高效存储和解析 3D 几何形状的格式。有关此标准和如何使用它的更多信息,请参阅此处:github.com/kchapelier/PRWM.

  • STLExporter.js,如果您想从 Three.js 导出模型到 STL。

  • THREE.Path元素,您可以使用它进行挤出或 2D 渲染。

  • 3DS: 这是 Autodesk 3DS 格式。更多信息请参阅www.autodesk.com/.

  • TILT: TILT 是 Tilt Brush 使用的格式,Tilt Brush 是一个 VR 工具,允许您在 VR 中绘画。更多信息请参阅此处:www.tiltbrush.com/.

  • VOX: 这是 MagicaVoxel 使用的格式,MagicaVoxel 是一个免费工具,可用于创建体素艺术。更多信息可以在 MagicaVoxel 的主页上找到:ephtracy.github.io/.

  • 虚拟现实建模语言 (VRML): 这是一个基于文本的格式,允许您指定 3D 对象和世界。它已被 X3D 文件格式取代。Three.js 不支持加载X3D模型,但这些模型可以轻松转换为其他格式。更多信息请参阅www.x3dom.org/?page_id=532#.

  • 可视化工具包 (VTK): 这是用于定义和指定顶点和面的文件格式。有两种格式可供选择:一种二进制格式和基于文本的ASCII格式。Three.js 仅支持基于 ASCII 的格式。

  • XYZ: 这是一个用于描述 3D 空间中点的非常简单的文件格式。更多信息请参阅此处:people.math.sc.edu/Burkardt/data/xyz/xyz.html.

第九章,“动画和移动相机”中,当我们研究动画时,我们将重新审视这些格式(并查看一些额外的格式)。

如您从列表中看到的,Three.js 支持非常多的 3D 文件格式。我们不会描述所有这些格式,只描述其中最有趣的一些。我们将从 JSON 加载器开始,因为它提供了一种存储和检索您自己创建的场景的好方法。

在 Three.js JSON 格式中保存和加载

您可以在 Three.js 中使用 JSON 格式进行两种不同的场景。您可以使用它来保存和加载单个THREE.Object3D对象(这意味着您也可以使用它来导出THREE.Scene对象)。

为了演示保存和加载,我们基于 THREE.TorusKnotGeometry 创建了一个简单的示例。使用这个示例,你可以创建一个环面结,就像我们在 第五章* 中所做的那样,并且,使用 保存/加载 菜单中的 保存 按钮,你可以保存当前的几何形状。对于这个示例,我们使用 HTML5 本地存储 API 进行保存。这个 API 允许我们轻松地在客户端浏览器中存储持久信息,并在稍后时间检索它(即使在浏览器关闭并重新启动之后):

图 8.5 – 显示加载的网格和当前网格

图 8.5 – 显示加载的网格和当前网格

在前面的屏幕截图中,你可以看到两个网格——红色的是我们加载的,黄色的是原始的。如果你自己打开这个示例并点击 保存 按钮,网格的当前状态将被存储。现在,你可以刷新浏览器并点击 加载,保存的状态将以红色显示。

从 Three.js 中导出 JSON 非常简单,不需要你包含任何额外的库。你需要做的只是将 THREE.Mesh 作为 JSON 导出,并将其存储在浏览器的 localstorage 中,如下所示:

const asJson = mesh.toJSON()
localStorage.setItem('json', JSON.stringify(asJson))

在保存之前,我们首先使用 JSON.stringify 函数将 toJSON 函数的结果(一个 JavaScript 对象)转换成字符串。要使用 HTML5 本地存储 API 保存这些信息,我们只需调用 localStorage.setItem 函数。第一个参数是键值(json),我们稍后可以用它来检索我们作为第二个参数传递的信息。

这个 JSON 字符串看起来是这样的:

{
  "metadata": {
    "version": 4.5,
    "type": "Object",
    "generator": "Object3D.toJSON"
  },
  "geometries": [
    {
      "uuid": "15a98944-91a8-45e0-b974-0d505fcd12a8",
      "type": "TorusKnotGeometry",
      "radius": 1,
      "tube": 0.1,
      "tubularSegments": 200,
      "radialSegments": 10,
      "p": 6,
      "q": 7
    }
  ],
  "materials": [
    {
      "uuid": "38e11bca-36f1-4b91-b3a5-0b2104c58029",
      "type": "MeshStandardMaterial",
      "color": 16770655,
      // left out some material properties    
      "stencilFuncMask": 255,
      "stencilFail": 7680,
      "stencilZFail": 7680,
      "stencilZPass": 7680
    }
  ],
  "object": {
    "uuid": "373db2c3-496d-461d-9e7e-48f4d58a507d",
    "type": "Mesh",
    "castShadow": true,
    "layers": 1,
    "matrix": [
      0.5,
      ...
      1
    ],
    "geometry": "15a98944-91a8-45e0-b974-0d505fcd12a8",
    "material": "38e11bca-36f1-4b91-b3a5-0b2104c58029"
  }
}

如你所见,Three.js 保存了关于 THREE.Mesh 对象的所有信息。将 THREE.Mesh 加载回 Three.js 也只需要几行代码,如下所示:

const fromStorage = localStorage.getItem('json')
if (fromStorage) {
  const structure = JSON.parse(fromStorage)
  const loader = new THREE.ObjectLoader()
  const mesh = loader.parse(structure)
  mesh.material.color = new THREE.Color(0xff0000)
  scene.add(mesh)
}

在这里,我们首先使用保存时的名称(在这个例子中是 json)从本地存储中获取 JSON。为此,我们使用 HTML5 本地存储 API 提供的 localStorage.getItem 函数。接下来,我们需要将字符串转换回 JavaScript 对象(JSON.parse),并将 JSON 对象转换回 THREE.Mesh。Three.js 提供了一个名为 THREE.ObjectLoader 的辅助对象,你可以使用它将 JSON 转换为 THREE.Mesh。在这个例子中,我们使用了加载器的 parse 方法来直接解析 JSON 字符串。加载器还提供了一个加载函数,你可以传递包含 JSON 定义的文件的 URL。

如你所见,我们只保存了一个 THREE.Mesh 对象,因此我们失去了其他所有信息。如果你想保存完整的场景,包括灯光和相机,你可以使用相同的方法导出场景:

const asJson = scene.toJSON()
localStorage.setItem('scene', JSON.stringify(asJson))

这种结果是一个完整的场景描述,以 JSON 格式呈现:

图 8.6 – 将场景导出为 JSON

图 8.6 – 将场景导出为 JSON

这可以通过与我们之前展示的 THREE.Mesh 对象相同的方式进行加载。当你在 Three.js 中独立工作并存储当前场景和对象为 JSON 时,这非常有用,但这并不是一个可以轻松与其他工具和程序交换或创建的格式。在下一节中,我们将更深入地探讨 Three.js 支持的一些 3D 格式。

从 3D 文件格式导入

在本章开头,我们列出了一些 Three.js 支持的格式。在本节中,我们将快速浏览这些格式的几个示例。

OBJ 和 MTL 格式

OBJ 和 MTL 是配套格式,通常一起使用。OBJ 文件定义几何形状,MTL 文件定义使用的材质。OBJ 和 MTL 都是文本格式。OBJ 文件的一部分看起来像这样:

v -0.032442 0.010796    0.025935
v -0.028519 0.013697    0.026201
v -0.029086 0.014533    0.021409
usemtl Material 
s   1   
f   2731    2735 2736 2732
f   2732    2736 3043 3044

MTL 文件定义材质,如下所示:

newmtl Material
Ns  56.862745   
Ka  0.000000    0.000000    0.000000
Kd  0.360725    0.227524    0.127497
Ks  0.010000    0.010000    0.010000
Ni  1.000000        
d 1.000000
illum 2

OBJ 和 MTL 格式在 Three.js 中得到了很好的支持,因此如果你想要交换 3D 模型,这是一个不错的选择。Three.js 有两个不同的加载器你可以使用。如果你只想加载几何形状,你使用 OBJLoader。我们使用了这个加载器作为我们的示例(load-obj.html)。以下截图显示了此示例:

图 8.7 – 仅定义几何形状的 OBJ 模型

图 8.7 – 仅定义几何形状的 OBJ 模型

从外部文件加载 OBJ 模型的操作如下:

import { OBJLoader } from 'three/examples/jsm/
  loaders/OBJLoader'
new OBJLoader().loadAsync('/assets/models/
  baymax/Bigmax_White_OBJ.obj').then((model) => {
  model.scale.set(0.05, 0.05, 0.05)
  model.translateY(-1)
  visitChildren(model, (child) => {
    child.receiveShadow = true
    child.castShadow = true
  })
  return model
})

在此代码中,我们使用 OBJLoader 从 URL 异步加载模型。这返回一个 JavaScript promise,当解析时,将包含网格。一旦模型加载完成,我们进行一些微调,并确保模型可以投射阴影并接收阴影。除了 loadAsync,每个加载器还提供了一个 load 函数,它不使用 promises,而是使用回调。这段代码将看起来像这样:

const model = new OBJLoader().load('/assets/models/baymax
  /Bigmax_White_OBJ.obj', (model) => {
  model.scale.set(0.05, 0.05, 0.05)
  model.translateY(-1)
  visitChildren(model, (child) => {
    child.receiveShadow = true
    child.castShadow = true
  })
  // do something with the model
  scene.add(model)
})

在本章中,我们将使用基于 PromiseloadAsync 方法,因为它避免了嵌套回调,并使这些调用更容易串联。下一个示例(oad-obj-mtl.html)使用 OBJLoaderMTLLoader 一起加载模型并直接分配材质。以下截图显示了此示例:

图 8.8 – 带有模型和材质的 OBJ.MTL 模型

图 8.8 – 带有模型和材质的 OBJ.MTL 模型

除了 OBJ 文件外,使用 MTL 文件遵循本节前面看到的相同原则:

const model = mtlLoader.loadAsync('/assets/models/butterfly/
  butterfly.mtl').then((materials) => {
  objLoader.setMaterials(materials)
  return objLoader.loadAsync('/assets/models/butterfly/
    butterfly.obj').then((model) => {
    model.scale.set(30, 30, 30)
    visitChildren(model, (child) => {
      // if there are already normals, we can't merge 
        vertices
      child.geometry.deleteAttribute('normal')
      child.geometry = BufferGeometryUtils.
        mergeVertices(child.geometry)
      child.geometry.computeVertexNormals()
      child.material.opacity = 0.1
      child.castShadow = true
    })
    const wing1 = model.children[4]
    const wing2 = model.children[5]
    [0, 2, 4, 6].forEach(function (i) { 
      model.children[i].rotation.z = 0.3 * Math.PI })
    [1, 3, 5, 7].forEach(function (i) { 
      model.children[i].rotation.z = -0.3 * Math.PI })
    wing1.material.opacity = 0.9
    wing1.material.transparent = true
    wing1.material.alphaTest = 0.1
    wing1.material.side = THREE.DoubleSide
    wing2.material.opacity = 0.9
    wing2.material.depthTest = false
    wing2.material.transparent = true
    wing2.material.alphaTest = 0.1
    wing2.material.side = THREE.DoubleSide
    return model
  })
})

在查看代码之前,首先要提到的是,如果你收到一个OBJ文件、一个MTL文件和所需的纹理文件,你必须检查MTL文件如何引用纹理。这些应该相对于MTL文件进行引用,而不是绝对路径。代码本身与我们之前看到的THREE.ObjLoader代码没有太大区别。我们首先使用THREE.MTLLoader对象加载MTL文件,并通过setMaterials函数将加载的材质设置在THREE.ObjLoader中。

我们用作示例的模型很复杂。因此,我们在回调中设置了一些特定的属性来修复多个渲染问题,如下所示:

  • 我们需要合并模型中的顶点,以便将其渲染为平滑的模型。为此,我们首先需要从加载的模型中移除已定义的normal向量,以便我们可以使用BufferGeometryUtils.mergeVerticescomputeVertexNormals函数来为 Three.js 提供正确渲染模型所需的信息。

  • 源文件中的不透明度设置不正确,导致翅膀不可见。因此,为了修复这个问题,我们自行设置了opacitytransparent属性。

  • 默认情况下,Three.js 只渲染对象的单侧。由于我们从两个侧面观察翅膀,我们需要将side属性设置为THREE.DoubleSide值。

  • 当翅膀需要叠加渲染时,它们产生了一些不希望出现的伪影。我们通过设置alphaTest属性来解决这个问题。

但正如您所看到的,您可以直接将复杂模型加载到 Three.js 中,并在浏览器中实时渲染它们。尽管如此,您可能需要调整各种材质属性。

加载 gltf 模型

我们已经提到,glTF 是导入 Three.js 数据时使用的一个非常好的格式。为了展示导入和显示甚至复杂场景的简单性,我们添加了一个示例,其中我们只是从sketchfab.com/3d-models/sea-house-bc4782005e9646fb9e6e18df61bfd28d取了一个模型:

图 8.9 – 使用 Three.js 从 glTF 加载的复杂 3D 场景

图 8.9 – 使用 Three.js 加载的复杂 3D 场景

如您从之前的屏幕截图中所见,这不仅仅是一个简单的场景,而是一个复杂的场景,包含许多模型、纹理、阴影和其他元素。要在 Three.js 中实现这一点,我们只需做以下操作:

const loader = new GLTFLoader()
return loader.loadAsync('/assets/models/sea_house/
  scene.gltf').then((structure) => {
  structure.scene.scale.setScalar(0.2, 0.2, 0.2)
  visitChildren(structure.scene, (child) => {
    if (child.material) {
      child.material.depthWrite = true
    }
  })
  scene.add(structure.scene)
})

你已经熟悉了异步加载器,我们唯一需要修复的是确保材质的depthWrite属性设置正确(这似乎是一些 glTF 模型中常见的问题)。就这样了——它就是那么简单。glTF 还允许我们定义动画,我们将在下一章中更详细地探讨这一点。

展示完整的乐高模型

除了定义顶点、材质、灯光等 3D 模型的文件格式外,还有各种文件格式不明确定义几何形状,但具有更具体的用途。在本节中,我们将探讨的LDrawLoader加载器是为了在 3D 中渲染 LEGO 模型而创建的。使用此加载器的方式与我们之前已经看到几次的方式相同:

loader.loadAsync('/assets/models/lego/10174-1-ImperialAT-ST-UCS.mpd_Packed.mpd').'/assets/models/lego/10174-1-ImperialAT-ST-UCS.mpd_Packed.mpd'.then((model) => {
  model.scale.set(0.015, 0.015, 0.015)
  model.rotateZ(Math.PI)
  model.rotateY(Math.PI)
  model.translateY(1)
  visitChildren(model, (child) => {
    child.castShadow = true
    child.receiveShadow = true
  })
  scene.add(model))
})

结果看起来非常棒:

图 8.10 – LEGO 帝国 AT-ST 模型

图 8.10 – LEGO 帝国 AT-ST 模型

如您所见,它显示了乐高套装的完整结构。有许多不同的模型可供您使用:

图 8.11 – LEGO X-Wing 战斗机

图 8.11 – LEGO X-Wing 战斗机

如果您想探索更多模型,您可以从 LDraw 仓库下载它们:omr.ldraw.org/

加载基于体素的模型

创建 3D 模型的另一种有趣的方法是使用体素。这允许您使用小立方体构建模型,并使用 Three.js 进行渲染。例如,您可以使用这样的工具在 Minecraft 之外创建 Minecraft 结构,并在稍后将其导入 Minecraft。一个用于实验体素的免费工具是 MagicaVoxel (ephtracy.github.io/)。此工具允许您创建如上所示的体素模型:

图 8.12 – 使用 MagicaVoxel 创建的示例模型

图 8.12 – 使用 MagicaVoxel 创建的示例模型

有趣的是,您可以使用VOXLoader加载器轻松地将这些模型导入 Three.js,如下所示:

new VOXLoader().loadAsync('/assets/models/vox/monu9.vox').then((chunks) => {
  const group = new THREE.Group()
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i]
    const mesh = new VOXMesh(chunk)
    mesh.castShadow = true
    mesh.receiveShadow = true
    group.add(mesh)
  }
  group.scale.setScalar(0.1)
  scene.add(group)
})

models文件夹中,您可以找到一些体素模型。以下截图显示了使用 Three.js 加载后的样子:

图 8.13 – 使用 Three.js 加载 Vox 模型

图 8.13 – 使用 Three.js 加载 Vox 模型

下一个加载器是另一个非常具体的加载器。我们将探讨如何从 PDB 格式渲染蛋白质。

从 PDB 显示蛋白质

PDB 网站 (www.rcsb.org) 包含有关许多不同分子和蛋白质的详细信息。除了对这些蛋白质的解释外,它还提供了下载这些分子 PDB 格式的结构的方法。Three.js 为 PDB 格式的文件提供了加载器。在本节中,我们将给出一个示例,说明您如何解析 PDB 文件并使用 Three.js 进行可视化。

包含此加载器后,我们将创建以下分子描述的 3D 模型(请参阅load-pdb.html示例):

图 8.14 – 使用 Three.js 和 PDBLoader 可视化蛋白质

图 8.14 – 使用 Three.js 和 PDBLoader 可视化蛋白质

加载 PDB 文件的方式与之前的格式相同,如下所示:

PDBLoader().loadAsync('/assets/models/molecules/caffeine.pdb').then((geometries) => {
  const group = new THREE.Object3D()
  // create the atoms
  const geometryAtoms = geometries.geometryAtoms
  for (let i = 0; i < geometryAtoms.attributes.
    position.count; i++) {
    let startPosition = new THREE.Vector3()
    startPosition.x = geometryAtoms.attributes.
      position.getX(i)
    startPosition.y = geometryAtoms.attributes.
      position.getY(i)
    startPosition.z = geometryAtoms.attributes.position.getZ(i)
    let color = new THREE.Color()
    color.r = geometryAtoms.attributes.color.getX(i)
    color.g = geometryAtoms.attributes.color.getY(i)
    color.b = geometryAtoms.attributes.color.getZ(i)
    let material = new THREE.MeshPhongMaterial({
      color: color
    })
    let sphere = new THREE.SphereGeometry(0.2)
    let mesh = new THREE.Mesh(sphere, material)
    mesh.position.copy(startPosition)
    group.add(mesh)
  }
  // create the bindings
  const geometryBonds = geometries.geometryBonds
  for (let j = 0; j < 
    geometryBonds.attributes.position.count; j += 2) {
    let startPosition = new THREE.Vector3()
    startPosition.x = geometryBonds.attributes.
      position.getX(j)
    startPosition.y = geometryBonds.attributes.position.
      getY(j)
    startPosition.z = geometryBonds.attributes.position.
      getZ(j)
    let endPosition = new THREE.Vector3()
    endPosition.x = geometryBonds.attributes.position.
      getX(j + 1)
    endPosition.y = geometryBonds.attributes.position.
      getY(j + 1)
    endPosition.z = geometryBonds.attributes.position.
      getZ(j + 1)
    // use the start and end to create a curve, and use the 
      curve to draw
    // a tube, which connects the atoms
    let path = new THREE.CatmullRomCurve3([startPosition, 
      endPosition])
    let tube = new THREE.TubeGeometry(path, 1, 0.04)
    let material = new THREE.MeshPhongMaterial({
      color: 0xcccccc
    })
    let mesh = new THREE.Mesh(tube, material)
    group.add(mesh)
  }
  group.scale.set(0.5, 0.5, 0.5)
  scene.add(group)
})

如此示例代码所示,我们实例化了一个 THREE.PDBLoader 对象,并传入我们想要加载的模型文件,一旦模型加载完成,我们就对其进行处理。在这种情况下,模型包含两个属性:geometryAtomsgeometryBondsgeometryAtoms 中的位置属性包含单个原子的位置,而颜色属性可以用来为单个原子着色。对于原子之间的连接,使用 geometryBonds

根据位置和颜色,我们创建一个 THREE.Mesh 对象并将其添加到一个组中:

    let sphere = new THREE.SphereGeometry(0.2)
    let mesh = new THREE.Mesh(sphere, material)
    mesh.position.copy(startPosition)
    group.add(mesh)

关于原子之间的连接,我们采用相同的方法。我们获取连接的起始和结束位置,并使用这些位置来绘制连接:

let path = new THREE.CatmullRomCurve3([startPosition, 
  endPosition])
let tube = new THREE.TubeGeometry(path, 1, 0.04)
let material = new THREE.MeshPhongMaterial({
  color: 0xcccccc
})
let mesh = new THREE.Mesh(tube, material)
group.add(mesh)

对于连接,我们首先使用 THREE.CatmullRomCurve3 创建一个 3D 路径。此路径用作 THREE.TubeGeometry 的输入,并用于在原子之间创建连接。所有连接和原子都添加到一个组中,然后将该组添加到场景中。您可以从 PDB 下载许多模型。例如,以下截图显示了钻石的结构:

图 8.15 – 钻石的结构

图 8.15 – 钻石的结构

在下一节中,我们将探讨 Three.js 对 PLY 模型的支持,该支持可用于加载点云数据。

从 PLY 模型加载点云

与 PLY 格式一起工作并不比其他格式复杂多少。您包含加载器并处理加载的模型。然而,对于最后一个示例,我们将做一些不同的事情。我们不会将模型作为网格渲染,而是将使用此模型的信息来创建一个粒子系统(请参阅以下截图中的 load-ply.html 示例):

图 8.16 – 从 PLY 模型加载的点云

图 8.16 – 从 PLY 模型加载的点云

渲染前面截图的 JavaScript 代码实际上非常简单;它看起来像这样:

const texture = new THREE.TextureLoader().load('/assets
  /textures/particles/glow.png')
const material = new THREE.PointsMaterial({
  size: 0.15,
  vertexColors: false,
  color: 0xffffff,
  map: texture,
  depthWrite: false,
  opacity: 0.1,
  transparent: true,
  blending: THREE.AdditiveBlending
})
return new PLYLoader().loadAsync('/assets/
  models/carcloud/carcloud.ply').then((model) => {
  const points = new THREE.Points(model, material)
  points.scale.set(0.7, 0.7, 0.7)
  scene.add(points)
})

如您所见,我们使用 THREE.PLYLoader 来加载模型,并将此几何形状作为 THREE.Points 的输入。我们使用的材质与我们在 第七章 的最后一个示例中使用的材质相同,即 点和精灵。如您所见,使用 Three.js,结合来自不同来源的模型并以不同方式渲染它们非常容易,所有这些只需几行代码即可完成。

其他加载器

在本章的开头,在 从外部资源加载几何形状 部分中,我们向您展示了一个由 Three.js 提供的所有不同加载器的列表。我们在 chapter-8 的源代码中提供了所有这些示例:

图 8.17 – 显示所有加载器示例的目录

图 8.17 – 显示所有加载器示例的目录

所有这些加载器的源代码遵循我们在这章中解释的加载器相同的模式。只需加载模型,确定你想显示加载模型的哪一部分,确保缩放和位置正确,然后将其添加到场景中。

摘要

在 Three.js 中使用外部模型并不困难,特别是对于简单的模型——你只需要进行几个简单的步骤。

当与外部模型一起工作,或者通过分组和合并创建它们时,有一些事情需要牢记。首先,你需要记住的是,当你对对象进行分组时,它们仍然作为单独的对象可用。应用于父对象的变化也会影响子对象,但你仍然可以单独变换子对象。除了分组之外,你还可以合并几何体。采用这种方法,你会失去单独的几何体,而得到一个单一的新几何体。这在处理需要渲染成千上万的几何体且遇到性能问题时特别有用。如果你想要控制大量相同几何体的网格,最终的方法是使用THREE.InstancedMesh对象或THREE.InstancedBufferGeometry对象,这允许你定位和变换单独的网格,同时仍然获得良好的性能。

Three.js 支持大量外部格式。当使用这些格式加载器时,查看源代码并添加console.log语句以确定加载数据的真实外观是个好主意。这将帮助你理解需要采取的步骤以获取正确的网格并将其设置为正确的位置和比例。通常,当模型显示不正确时,这是由于其材质设置引起的。可能是使用了不兼容的纹理格式,不透明度定义不正确,或者格式包含指向纹理图像的错误链接。通常,使用测试材质来确定模型本身是否正确加载,并将加载的材质记录到 JavaScript 控制台以检查意外值是个好主意。

如果你想要重用你自己的场景或模型,你可以通过调用asJson函数简单地导出它们,然后使用ObjectLoader再次加载它们。

本章以及前几章中你使用的模型大多是静态模型。它们没有动画,不会移动,也不会改变形状。在第九章中,你将学习如何使你的模型动起来,使其栩栩如生。除了动画之外,下一章还将解释 Three.js 提供的各种相机控制。通过相机控制,你可以移动、平移和旋转相机,使其围绕场景移动。

第九章:动画和移动相机

在前面的章节中,我们看到了一些简单的动画,但并没有什么太复杂的。在第一章使用 Three.js 创建你的第一个 3D 场景中,我们介绍了基本的渲染循环,在随后的章节中,我们使用它来旋转一些简单的对象并展示了一些其他基本的动画概念。

在本章中,我们将更详细地探讨 Three.js 如何支持动画。我们将探讨以下四个主题:

  • 基本动画

  • 与相机一起工作

  • 形变和骨骼动画

  • 使用外部模式创建动画

我们将首先介绍动画背后的基本概念。

基本动画

在我们查看示例之前,让我们快速回顾一下在第一章中展示的内容,关于渲染循环。为了支持动画,我们需要告诉 Three.js 每隔一段时间渲染场景。为此,我们使用标准的 HTML5 requestAnimationFrame功能,如下所示:

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

使用这段代码,我们只需要在初始化场景后调用一次render()函数。在render()函数本身中,我们使用requestAnimationFrame来安排下一次渲染。这样,浏览器将确保render()函数在正确的间隔(通常每秒大约 60 次或 120 次)被调用。在requestAnimationFrame被添加到浏览器之前,使用的是setInterval(function, interval)setTimeout(function, interval)。这些会在每个设定的时间间隔调用指定的函数。

这种方法的缺点在于它没有考虑到其他正在发生的事情。即使你的动画没有显示或者是在隐藏的标签页中,它仍然会被调用并且仍在使用资源。另一个问题是,这些函数在每次被调用时都会更新屏幕,而不是在浏览器认为最佳的时间,这导致 CPU 使用率更高。使用requestAnimationFrame,我们不是告诉浏览器何时需要更新屏幕;而是请求浏览器在最适合的时候运行提供的函数。通常,这会导致大约 60 或 120 FPS(取决于你的硬件)的帧率。使用requestAnimationFrame,你的动画将运行得更平滑,并且对 CPU 和 GPU 更友好,你不必担心时间问题。

在下一节中,我们将从创建一个简单的动画开始。

简单动画

使用这种方法,我们可以通过改变对象的rotationscalepositionmaterial、顶点、面以及你能想象到的任何其他内容来非常容易地动画化对象。在下一个渲染循环中,Three.js 将渲染更改后的属性。一个基于我们已经在第七章点和精灵中看到的简单示例,可以在01-basic-animations.html中找到。以下截图显示了此示例:

图 9.1 – 更改属性后的动画

图 9.1 – 更改属性后的动画

这个渲染循环非常简单。首先,我们在userData对象上初始化各种属性,这是一个存储在THREE.Mesh本身中的自定义数据位置,然后使用我们在userData对象上定义的数据更新这些属性。在动画循环中,只需根据这些属性更改旋转、位置和缩放,Three.js 会处理其余部分。以下是我们的操作方法:

const geometry = new THREE.TorusKnotGeometry(2, 0.5, 150, 50, 3, 4)
const material = new THREE.PointsMaterial({
  size: 0.1,
  vertexColors: false,
  color: 0xffffff,
  map: texture,
  depthWrite: false,
  opacity: 0.1,
  transparent: true,
  blending: THREE.AdditiveBlending
})
const points = new THREE.Points(geometry, material)
points.userData.rotationSpeed = 0
points.userData.scalingSpeed = 0
points.userData.bouncingSpeed = 0
points.userData.currentStep = 0
points.userData.scalingStep = 0
// in the render loop
function render() {
  const rotationSpeed = points.userData.rotationSpeed
  const scalingSpeed = points.userData.scalingSpeed
  const bouncingSpeed = points.userData.bouncingSpeed
  const currentStep = points.userData.currentStep
  const scalingStep = points.userData.scalingStep
  points.rotation.x += rotationSpeed
  points.rotation.y += rotationSpeed
  points.rotation.z += rotationSpeed
  points.userData.currentStep = currentStep + bouncingSpeed
  points.position.x = Math.cos(points.userData.currentStep)
  points.position.y = Math.abs(Math.sin
   (points.userData.currentStep)) * 2
  points.userData.scalingStep = scalingStep + scalingSpeed
  var scaleX = Math.abs(Math.sin(scalingStep * 3 + 0.5 * 
    Math.PI))
  var scaleY = Math.abs(Math.cos(scalingStep * 2))
  var scaleZ = Math.abs(Math.sin(scalingStep * 4 + 0.5 * 
    Math.PI))
  points.scale.set(scaleX, scaleY, scaleZ)
}

这里没有什么特别之处,但它很好地展示了我们将在这本书中讨论的基本动画背后的概念。我们只是更改scalerotationposition属性,Three.js 会处理其余部分。

在下一节中,我们将快速跳转一下。除了动画之外,当你使用 Three.js 在更复杂的场景中工作时,你很快就会遇到的一个重要方面是使用鼠标在屏幕上选择对象的能力。

选择和移动对象

尽管这与动画没有直接关系,但由于我们将在本章中查看相机和动画,了解如何选择和移动对象是本章所解释主题的一个很好的补充。在这里,我们将向您展示如何完成以下操作:

  • 使用鼠标从场景中选择一个对象

  • 使用鼠标在场景中拖动一个对象

我们将首先查看选择对象所需的步骤。

选择对象

首先,打开selecting-objects.html示例,你会看到以下内容:

图 9.2 – 可以用鼠标选择的随机放置的立方体

图 9.2 – 可以用鼠标选择的随机放置的立方体

当你在场景中移动鼠标时,你会看到每当你的鼠标击中一个对象时,该对象就会被突出显示。你可以通过使用THREE.Raycaster轻松创建这个效果。Raycaster 会查看你的当前相机,并从相机位置向鼠标位置发射一条射线。基于此,它可以根据鼠标的位置计算出被击中的对象。为了完成这个任务,我们需要采取以下步骤:

  • 创建一个跟踪鼠标指向位置的对象

  • 每当我们移动鼠标时,更新那个对象

  • 在渲染循环中,使用这些更新信息来查看我们指向的 Three.js 对象

这在下面的代码片段中显示:

// initially set the position to -1, -1
let pointer = {
  x: -1,
  y: -1
}
// when the mouse moves update the point
document.addEventListener('mousemove', (event) => {
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1
})
// an array containing all the cubes in the scene
const cubes = ...
// use in the render loop to determine the object to highlight
const raycaster = new THREE.Raycaster()
function render() {
  raycaster.setFromCamera(pointer, camera)
  const cubes = scene.getObjectByName('group').children
  const intersects = raycaster.intersectObjects(cubes)
  // do something with the intersected objects
}

在这里,我们使用THREE.Raycaster来确定哪些对象与相机点的鼠标位置相交。结果(在先前的示例中为intersects)包含所有与我们的鼠标相交的立方体,因为射线是从相机的位置发射到相机范围的末尾。在这个数组中的第一个是我们在悬停的对象,这个数组中的其他值(如果有)指向第一个网格后面的对象。THREE.Raycaster还提供了关于你击中对象的确切位置的其他信息:

图 9.3 – Raycaster 提供的信息

图 9.3 – 来自射线投射器的附加信息

在这里,我们点击了face对象。faceIndex指向所选网格的表面。distance值是从相机到点击对象的距离,而point是点击在网格上的确切位置。最后,我们有uv值,当使用纹理时,它决定了点击的点在 2D 纹理上的位置(范围从 0 到 1;有关uv的更多信息,请参阅第十章加载和操作 纹理)。

拖动对象

除了选择对象外,一个常见的需求是能够拖动和移动对象。Three.js 也为此提供了默认支持。如果你在浏览器中打开dragging-objects.html示例,你会看到一个类似于图 9.2所示的场景。这次,当你点击一个对象时,你可以将其拖动到场景中:

图 9.4 – 使用鼠标在场景中拖动对象

图 9.4 – 使用鼠标在场景中拖动对象

为了支持拖动对象,Three.js 使用一种称为DragControls的东西。它处理所有事情,并在拖动开始和停止时提供方便的回调。完成此操作的代码如下:

const orbit = new OrbitControls(camera, renderer.domElement)
orbit.update()
const controls = new DragControls(cubes, camera, renderer.domElement)
controls.addEventListener('dragstart', function (event) {
  orbit.enabled = false
  event.object.material.emissive.set(0x33333)
})
controls.addEventListener('dragend', function (event) {
  orbit.enabled = true
  event.object.material.emissive.set(0x000000)
})

如此简单。在这里,我们添加了DragControls并传递了可以拖动的元素(在我们的例子中,是所有随机放置的立方体)。然后,我们添加了两个事件监听器。第一个,dragstart,在我们开始拖动立方体时被调用,而dragend在我们停止拖动对象时被调用。在这个例子中,当我们开始拖动时,我们禁用了OrbitControls(它允许我们使用鼠标在场景周围查看)并更改了所选对象的颜色。一旦我们停止拖动,我们将对象的颜色改回并再次启用OrbitControls

还有一种更高级的DragControls版本,称为TransformControls。我们不会深入探讨这个控制器的细节,但它允许你使用简单的 UI 来变换网格的属性。当你打开浏览器中的transform-controls-html时,你可以找到一个此控制器的示例:

图 9.5 – 变换控制器允许你改变网格的属性

图 9.5 – 变换控制器允许你改变网格的属性

如果你点击这个控制器的各个部分,你可以轻松地改变立方体的形状:

图 9.6 – 使用变换控制器修改的形状

图 9.6 – 使用变换控制器修改的形状

在本章的最后一个例子中,我们将向你展示如何使用一个替代方法来修改对象的属性(正如我们在本章的第一个例子中所见),即使用缓动库。

使用 Tween.js 进行动画

Tween.js 是一个你可以从 github.com/sole/tween.js/ 下载的小型 JavaScript 库,你可以用它轻松地定义两个值之间属性的过渡。从开始值到结束值之间的所有中间点都会为你计算。这个过程被称为将网格的 x 位置从 10 移动到 3,如下所示:

const tween = new TWEEN.Tween({x: 10}).to({x: 3}, 10000)
.easing(TWEEN.Easing.Elastic.InOut)
.onUpdate( function () {
  // update the mesh
})

或者,你可以创建一个单独的对象并将其传递到你想要与之工作的网格中:

  const tweenData = {
    x: 10
  }
  new TWEEN.Tween(tweenData)
    .to({ x: 3 }, 10000)
    .yoyo(true)
    .repeat(Infinity)
    .easing(TWEEN.Easing.Bounce.InOut)
    .start()
  mesh.userData.tweenData = tweenData    

在这个例子中,我们创建了 TWEEN.Tween。这个缓动将确保 x 属性在 10,000 毫秒内从 10 变化到 3。Tween.js 还允许你定义这个属性随时间如何变化。这可以通过线性、二次或其他任何可能性(参见 sole.github.io/tween.js/examples/03_graphs.html 以获取完整概述)来实现。值通过名为 easing() 的过程随时间变化。这个库还提供了额外的控制方式来决定如何进行缓动。例如,我们可以设置缓动应该重复的频率(repeat(10))以及我们是否想要一个 yoyo 效果(在这个例子中,这意味着我们从 10 变化到 3,然后再回到 10)。

将这个库与 Three.js 一起使用非常简单。如果你打开 tween-animations.html 示例,你将看到 Tween.js 库在行动中的表现。以下截图显示了示例的静态图像:

图 9.7 – 在动作中途缓动一个点系统

图 9.7 – 在动作中途缓动一个点系统

我们将使用 Tween.js 库通过特定的 easing() 来移动这个点到一个单一的位置,这在某个时刻看起来如下:

图 9.8 – 当所有内容合并成一个点时缓动一个点

图 9.8 – 当所有内容合并成一个点时缓动一个点

在这个例子中,我们从一个点云(第七章)中提取了一个点,并创建了一个动画,其中所有点都缓慢地移动到中心。这些粒子的位置是通过使用 Tween.js 库创建的缓动来设置的,如下所示:

const geometry = new THREE.TorusKnotGeometry(2, 0.5, 150, 50, 3, 4)
geometry.setAttribute('originalPos', geometry.attributes['position'].clone())
const material = new THREE.PointsMaterial(..)
const points = new THREE.Points(geometry, material)
const tweenData = {
  pos: 1
}
new TWEEN.Tween(tweenData)
  .to({ pos: 3 }, 10000)
  .yoyo(true)
  .repeat(Infinity)
  .easing(TWEEN.Easing.Bounce.InOut)
  .start()
points.userData.tweenData = tweenData    
// in the render loop
const originalPosArray = points.geometry.attributes.originalPos.array
const positionArray = points.geometry.attributes.position.array
TWEEN.update()
 for (let i = 0; i < points.geometry.attributes.position.count; i++) {
  positionArray[i * 3] = originalPosArray[i * 3] * points.userData.tweenData.pos
  positionArray[i * 3 + 1] = originalPosArray[i * 3 + 1] * points.userData.tweenData.pos
  positionArray[i * 3 + 2] = originalPosArray[i * 3 + 2] * points.userData.tweenData.pos
}
points.geometry.attributes.position.needsUpdate = true

通过这段代码,我们创建了一个缓动,它将值从 1 变化到 0 再变回来。要使用缓动中的值,我们有两种不同的选择:我们可以使用这个库提供的 onUpdate 函数来调用一个函数,该函数带有更新的值,每当缓动更新时(这是通过调用 TWEEN.update() 来完成的),或者我们可以直接访问更新的值。在这个例子中,我们使用了后者方法。

在我们查看需要在 render 函数中进行的更改之前,我们必须在加载模型后执行一个额外的步骤。我们想要在原始值和零之间进行缓动。为此,我们需要将顶点的原始位置存储在某个地方。我们可以通过复制起始位置数组来完成此操作:

geometry.setAttribute('originalPos', geometry.attributes['position'].clone())

现在,无论何时我们想要访问原始位置,我们都可以查看几何体的 originalPos 属性。现在,我们可以直接使用 tween 的值来计算每个顶点的新位置。我们可以在渲染循环中这样做:

const originalPosArray = points.geometry.attributes.originalPos.array
const positionArray = points.geometry.attributes.position.array
for (let i = 0; i < points.geometry.attributes.position.count; i++) {
  positionArray[i * 3] = originalPosArray[i * 3] * points.userData.tweenData.pos
  positionArray[i * 3 + 1] = originalPosArray[i * 3 + 1] * points.userData.tweenData.pos
  positionArray[i * 3 + 2] = originalPosArray[i * 3 + 2] * points.userData.tweenData.pos
}
points.geometry.attributes.position.needsUpdate = true

在这些步骤到位后, tween 库将负责处理屏幕上各个点的位置。正如你所见,使用这个库比手动管理过渡要容易得多。除了动画和改变对象,我们还可以通过移动相机来动画化场景。在前面的章节中,我们通过手动更新相机位置做了几次这样的操作。Three.js 也提供了几种更新相机位置的方法。

与相机一起工作

Three.js 提供了几个相机控制,你可以使用它们在场景中控制相机。这些控制位于 Three.js 分发中,可以在 examples/js/controls 目录中找到。在本节中,我们将更详细地查看以下控制:

  • ArcballControls:一个提供透明覆盖层的扩展控制,你可以用它轻松地移动相机。

  • FirstPersonControls:这些是像第一人称射击游戏中的控制方式。你可以用键盘移动,用鼠标环顾四周。

  • FlyControls:这些是类似飞行模拟器的控制。你可以用键盘和鼠标移动和操控。

  • OrbitControls:这模拟了一个围绕特定场景运行的卫星。这允许你使用鼠标和键盘移动。

  • PointerLockControls:这些与第一人称控制类似,但它们还会锁定鼠标指针到屏幕上,这使得它们成为简单游戏的一个很好的选择。

  • TrackBallControls:这些是最常用的控制,允许你使用鼠标(或轨迹球)轻松地移动、平移和缩放场景。

除了使用这些相机控制,你也可以通过设置其位置并使用 lookAt() 函数改变其指向来自行移动相机。

我们首先来看一下 ArcballControls

ArcballControls

解释 ArcballControls 的工作方式最简单的方法是查看一个示例。如果你打开 arcball-controls.html 示例,你会看到一个简单的场景,就像这样:

图 9.9 – 使用 ArcballControls 探索场景

图 9.9 – 使用 ArcballControls 探索场景

如果你仔细观察这张截图,你会看到两条透明的线条穿过场景。这些是由 ArcballControls 提供的线条,你可以使用它们来旋转和平移场景。这些线条被称为 gizmos。左键用于旋转场景,右键可以用来平移,滚动鼠标滚轮可以放大。

除了这些标准功能外,此控件还允许您聚焦于显示的网格的特定部分。如果您在场景上双击,相机将聚焦于该部分场景。要使用此控件,我们只需要实例化它,并传入 camera 属性、由渲染器使用的 domElement 属性以及我们正在查看的 scene 属性:

import { ArcballControls } from 'three/examples/jsm/controls/ArcballControls'
const controls = new ArcballControls(camera, renderer.domElement, scene)
controls.update()

此控件非常灵活,可以通过一系列属性进行配置。大多数这些属性可以通过使用此示例右侧的菜单在此示例中进行探索。对于这个特定的控件,我们将更深入地探讨此对象提供的属性和方法,因为它是一个多功能的控件,当您希望为用户提供一种良好的方式来探索您的场景时,它是一个很好的选择。让我们概述一下此控件提供的属性和方法。首先,让我们看看属性:

  • adjustNearFar: 如果设置为 true,则此控件在放大时将更改相机的 nearfar 属性

  • camera: 创建此控件时使用的相机

  • cursorZoom: 如果设置为 true,当放大时,缩放将聚焦于光标的位置

  • dampingFactor: 如果 enableAnimations 设置为 true,则此值将确定动作后动画停止的速度

  • domElement: 此元素用于列出鼠标事件

  • enabled: 确定此控件是否启用

  • enableRotate, enableZoom, enablePan, enableGrid, enableAnimations: 这些属性启用和禁用此控件提供的功能

  • focusAnimationTime: 当我们双击并聚焦于场景的一部分时,此属性确定聚焦动画的持续时间

  • maxDistance/minDistance: 对于 PerspectiveCamera,我们可以缩放的最远和最近距离

  • maxZoom/minZoom: 对于 OrthographicCamera,我们可以缩放的最远和最近距离

  • scaleFactor: 我们缩放和缩小的速度

  • scene: 构造函数中传入的场景

  • radiusFactor: “辅助工具”相对于屏幕宽度和高度的大小

  • wMax: 我们允许场景旋转的速度

此控件还提供了一些方法来进一步交互或配置它:

  • activateGizmos(bool): 如果为 true,则突出显示辅助工具

  • copyState(), pasteState(): 允许您将控件的当前状态以 JSON 格式复制到剪贴板

  • saveState(), reset(): 内部保存当前状态,并使用 reset() 应用保存的状态

  • dispose(): 从场景中移除此控件的全部部分,并清理任何监听器和动画

  • setGizomsVisible(bool): 指定是否显示或隐藏辅助工具

  • setTbRadius(radiusFactor): 更新 radiusFactor 属性并重新绘制辅助工具

  • setMouseAction(operation, mouse, key): 确定哪个鼠标键提供哪个动作

  • unsetMouseAction(mouse, key): 清除已分配的鼠标动作

  • update(): 当相机属性改变时,调用此函数以应用这些新设置到该控制。

  • getRayCaster(): 提供对 rayCaster 的访问,这些控制内部使用。

ArcballControls 是 Three.js 中一个非常有用且相对较新的功能,它允许使用鼠标对场景进行高级控制。如果你在寻找一个更简单的方法,可以使用 TrackBallControls

TrackBallControls

使用 TrackBallControls 的方法与我们在 ArcballControls 中看到的方法相同:

import { TrackBallControls } from 'three/examples/jsm/
  controls/TrackBallControls'
const controls = new TrackBallControls(camera, renderer.
  domElement)

这次,我们只需要传递来自渲染器的 cameradomeElement 属性。为了使轨道球控制工作,我们还需要添加一个 THREE.Clock 并更新渲染循环,如下所示:

const clock = new THREE.Clock()
function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
  controls.update(clock.getDelta())
}

在前面的代码片段中,我们可以看到一个新 Three.js 对象,THREE.ClockTHREE.Clock 对象可以用来计算特定调用或渲染循环完成所花费的经过时间。你可以通过调用 clock.getDelta() 函数来实现这一点。此函数将返回从上一次调用 getDelta() 到这次调用的经过时间。为了更新相机的位置,我们可以调用 TrackBallControls.update() 函数。在这个函数中,我们需要提供自上次调用此更新函数以来经过的时间。为此,我们可以使用 THREE.Clock 对象的 getDelta() 函数。你可能想知道为什么我们不直接将帧率(1/60 秒)传递给更新函数。原因是,使用 requestAnimationFrame,我们期望 60 FPS,但这并不是保证的。根据所有各种外部因素,帧率可能会变化。为了确保相机平稳地旋转和旋转,我们需要传递确切的经过时间。

你可以在 trackball-controls-camera.html 中找到一个使用此功能的示例。以下截图显示了该示例的静态图像:

图 9.10 – 使用 TrackBallControls 控制场景

图 9.10 – 使用 TrackBallControls 控制场景

你可以通过以下方式控制相机:

  • 左键点击并移动:绕场景旋转和翻滚相机。

  • 滚轮:放大和缩小。

  • 中键点击并移动:放大和缩小。

  • 右键点击并移动:在场景中平移。

你可以使用一些属性来微调相机的行为。例如,你可以使用 rotateSpeed 属性设置相机旋转的速度,并通过将 noZoom 属性设置为 true 来禁用缩放。在本章中,我们不会详细介绍每个属性的作用,因为它们基本上是自我解释的。要了解完整的概述,请查看 TrackBallControls.js 文件的源代码,其中列出了这些属性。

FlyControls

我们接下来要查看的控制是 FlyControls。使用 FlyControls,你可以使用在飞行模拟器中找到的控制来在场景中飞行。你可以在 fly-controls-camera.html 中找到一个示例。以下截图显示了该示例的静态图像:

图 9.11 – 使用 FlyControls 在场景中飞行

图 9.11 – 使用 FlyControls 在场景中飞行

启用 FlyControls 与其他控制的工作方式相同:

import { FlyControls } from 'three/examples/jsm/controls/FlyControls'
const controls = new FlyControls(camera, renderer.domElement)
const clock = new THREE.Clock()
function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
  controls.update(clock.getDelta())
}

FlyControls 将相机和渲染器的 domElement 作为参数,并要求你在渲染循环中调用带有经过时间的 update() 函数。你可以用以下方式使用 THREE.FlyControls 来控制相机:

  • 左键和中间鼠标按钮:开始向前移动

  • 右鼠标按钮:向后移动

  • 鼠标移动:四处查看

  • W:开始向前移动

  • S:向后移动

  • A:向左移动

  • D:向右移动

  • R:向上移动

  • F:向下移动

  • 左、右、上、下箭头:分别向左、右、上、下查看

  • G:向左翻滚

  • E:向右翻滚

我们接下来要查看的控制是 THREE.FirstPersonControls

FirstPersonControls

如其名所示,FirstPersonControls 允许你像在第一人称射击游戏中一样控制相机。鼠标用于四处查看,键盘用于移动。你可以在 07-first-person-camera.html 中找到一个示例。以下截图显示了该示例的静态图像:

图 9.12 – 使用第一人称控制探索场景

图 9.12 – 使用第一人称控制探索场景

创建这些控制遵循的原则与之前我们看到的其他控制相同:

Import { FirstPersonControls } from 'three/examples/jsm/
  controls/FirstPersonControls'
const controls = new FirstPersonControls(camera, renderer.domElement)
const clock = new THREE.Clock()
function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
  controls.update(clock.getDelta())
}

这个控制提供的功能相当直接:

  • 鼠标移动:四处查看

  • 左、右、上、下箭头:分别向左、右、前、后移动

  • W:向前移动

  • A:向左移动

  • S:向后移动

  • D:向右移动

  • R:向上移动

  • F:向下移动

  • Q:停止所有移动

对于最后一个控制,我们将从第一人称视角转向太空视角。

OrbitControls

OrbitControls 控制是一种很好的方法,可以在场景中心旋转和平移对象。这也是我们在其他章节中使用的方法,为你提供了一个简单的方式来探索提供的示例中的模型。

使用 orbit-controls-orbit-camera.html,我们提供了一个示例,展示了这个控制是如何工作的。以下截图显示了该示例的静态图像:

图 9.13 – OrbitControls 属性

图 9.13 – OrbitControls 属性

使用 OrbitControls 与使用其他控制一样简单。包含正确的 JavaScript 文件,使用相机设置控制,并再次使用 THREE.Clock 来更新控制:

import { OrbitControls } from 'three/examples/jsm/
  controls/OrbitControls'
const controls = new OrbitControls(camera, renderer.
  domElement)
const clock = new THREE.Clock()
function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
  controls.update(clock.getDelta())
}

OrbitControls 的控制主要依赖于鼠标操作,如下所示列表:

  • 左键点击并移动:围绕场景中心旋转相机

  • 滚轮或中间鼠标点击并移动:放大和缩小

  • 右键点击并移动:在场景中平移

关于相机及其移动就到这里。在本节中,我们看到了许多允许你通过改变相机属性轻松交互和移动场景的控制。在下一节中,我们将探讨更高级的动画方法:形变和蒙皮。

形变和骨骼动画

当你在外部程序(例如 Blender)中创建动画时,通常有两个主要选项来定义动画:

  • 形变目标:使用形变目标,你定义网格的变形版本——即关键位置。对于这个变形目标,存储了所有顶点的位置。要动画化形状,你只需将所有顶点从一个位置移动到另一个关键位置,并重复此过程。以下截图显示了用于展示面部表情的各种形变目标(此截图由 Blender 基金会提供):

图 9.14 – 使用形变目标设置动画

图 9.14 – 使用形变目标设置动画

  • 骨骼动画:另一种选择是使用骨骼动画。在骨骼动画中,你定义网格的骨骼(即骨骼),并将顶点附着到特定的骨骼上。现在,当你移动一个骨骼时,任何连接的骨骼也会相应地移动,并且附着的顶点会根据骨骼的位置、运动和缩放进行移动和变形。以下截图,再次由 Blender 基金会提供,展示了如何使用骨骼来移动和变形对象:

图 9.15 – 使用骨骼设置动画

图 9.15 – 使用骨骼设置动画

Three.js 支持这两种模式,但在处理基于骨骼/骨骼的动画时,可能会遇到导出良好的问题。为了获得最佳结果,你应该将你的模型导出或转换为 glTF 格式,该格式正成为交换模型、动画和场景的默认格式,并且得到了 Three.js 的良好支持。

在本节中,我们将探讨这两种选项,并查看 Three.js 支持的几种外部格式,在这些格式中可以定义动画。

基于形变目标的动画

形变目标是定义动画最直接的方式。你为每个重要位置(也称为关键帧)定义所有顶点,并告诉 Three.js 将顶点从一个位置移动到另一个位置。

我们将通过两个例子向你展示如何使用变形目标。在第一个例子中,我们将让 Three.js 处理各种关键帧(或我们接下来将称之为变形目标)之间的过渡,而在第二个例子中,我们将手动完成这个操作。请记住,我们只是在 Three.js 动画的表面略作探讨。正如你将在本节中看到的那样,Three.js 对动画控制有出色的支持,支持动画同步,并提供从一种动画平滑过渡到另一种动画的方法,这足以写一本书来专门讨论这个主题。因此,在接下来的几个部分中,我们将为你提供 Three.js 动画的基础知识,这应该为你提供足够的信息来开始并探索更复杂的话题。

使用混合器和变形目标进行动画

在我们深入例子之前,首先,我们将探讨你可以使用 Three.js 进行动画的三种核心类。在本章的后面部分,我们将向你展示这些对象提供的所有函数和属性:

  • THREE.AnimationClip:当你加载包含动画的模型时,你可以在response对象中查找通常称为animations的字段。这个字段将包含一个THREE.AnimationClip对象的列表。请注意,根据加载器,动画可能定义在Mesh上、Scene上,或者完全独立提供。一个THREE.AnimationClip通常包含加载的模型可以执行的一定动画的数据。例如,如果你加载了一只鸟的模型,一个THREE.AnimationClip将包含拍打翅膀所需的信息,另一个可能包含张嘴和闭嘴的信息。

  • THREE.AnimationMixerTHREE.AnimationMixer用于控制多个THREE.AnimationClip对象。它确保动画的时间正确,并使得同步动画或从一种动画干净地过渡到另一种动画成为可能。

  • THREE.AnimationAction:尽管THREE.AnimationMixer本身并没有暴露大量的函数来控制动画,但这通过THREE.AnimationAction对象来完成,当你将一个THREE.AnimationClip添加到THREE.AnimationMixer时,会返回这些对象(尽管你可以通过THREE.AnimationMixer提供的函数在稍后获取它们)。

此外,还有一个AnimationObjectGroup,你可以使用它来提供动画状态,不仅限于单个Mesh,还可以是一组对象。

在以下示例中,你可以控制使用模型中的THREE.AnimationClip创建的THREE.AnimationMixerTHREE.AnimationAction。在这个例子中,THREE.AnimationClip对象将模型变形为一个立方体,然后变为一个圆柱体。

对于这个第一个变形例子,理解基于变形目标的动画工作原理的最简单方法是通过打开morph-targets.html示例。以下截图显示了该示例的静态图像:

图 9.16 – 使用变形目标进行动画

图 9.16 – 使用变形目标进行动画

在这个例子中,我们有一个简单的模型(猴子的头部),可以使用变形目标将其转换为立方体或圆柱体。你可以通过移动 cubeTargetconeTarget 滑块轻松测试这一点,你会看到头部被变形为不同的形状。例如,当 cubeTarget0.5 时,你会看到我们正在将猴子的初始头部变形为立方体的中途。一旦它达到 1,初始几何形状就完全变形了:

图 9.17 – 同样的模型,但现在将 cubeTarget 设置为 1

图 9.17 – 同样的模型,但现在将 cubeTarget 设置为 1

这就是变形动画的基本原理。你有几个可以控制的 morphTargets(影响),根据它们的值(从 0 到 1),顶点移动到期望的位置。使用变形目标的动画使用这种方法。它只是定义了在哪个时间某些顶点位置应该发生。当运行动画时,Three.js 将确保将正确的值传递给 Mesh 实例的 morphTargets 属性。

要运行预定义的动画,你可以打开此示例的 AnimationMixer 菜单,然后点击 播放。你会看到头部首先变成一个立方体,然后变成一个圆柱体,最后再变回头部形状。

在 Three.js 中设置完成此操作的必要组件可以使用以下代码片段完成。首先,我们必须加载模型。在这个例子中,我们将此示例从 Blender 导出为 glTF,因此我们的 animations 在顶层。我们只需将这些添加到一个变量中,我们可以在代码的其他部分访问它。我们也可以将此设置为网格的属性或添加到 Meshuserdata 属性中:

let animations = []
const loadModel = () => {
  const loader = new GLTFLoader()
  return loader.loadAsync('/assets/models/blender-morph-targets/morph-targets.gltf').then((container) => {
    animations = container.animations
    return container.scene
  })
}

现在我们已经从加载的模型中获得了动画,我们可以设置特定的 Three.js 组件,以便我们可以播放它们:

const mixer = new THREE.AnimationMixer(mesh)
const action = mixer.clipAction(animations[0])
action.play()

我们需要采取的最后一步是确保每次渲染时都能显示网格的正确形状,那就是在渲染循环中添加一行代码:

// in render loop
mixer.update(clock.getDelta())

在这里,我们再次使用了 THREE.Clock 来确定现在和上一个渲染循环之间经过的时间,并调用了 mixer.update()。这个信息被混音器用来确定它应该将顶点转换到下一个变形目标(关键帧)有多远。

THREE.AnimationMixerTHREE.AnimationClip 提供了其他一些你可以用来控制动画或创建新的 THREE.AnimationClip 对象的功能。你可以通过使用本节示例右侧的菜单来实验它们。我们将从 THREE.AnimationClip 开始:

  • duration:此轨道的持续时间(以秒为单位)。

  • name:此剪辑的名称。

  • tracks:用于跟踪模型某些属性如何动画化的内部属性。

  • uuid: 此剪辑的唯一 ID。这是自动分配的。

  • clone(): 创建此剪辑的副本。

  • optimize(): 优化 THREE.AnimationClip

  • resetDuration(): 确定此剪辑的正确持续时间。

  • toJson(): 将此剪辑转换为 JSON 对象。

  • trim(): 将所有内部轨道修剪到在此剪辑上设置的持续时间。

  • validate(): 进行一些基本的验证,以查看此剪辑是否有效。

  • CreateClipsFromMorphTargetSequences( name, morphTargetSequences,fps, noLoop): 根据一组形变目标序列创建一系列 THREE.AnimationClip 实例。

  • CreateFromMorpTargetSequences( name, morphTargetSequence,fps,noLoop): 从一系列形变目标中创建单个 THREE.AnimationClip

  • findByName(objectOrClipArray, name): 通过名称搜索 THREE.AnimationClip

  • parsetoJson: 允许你分别将 Three.AnimationClip 作为 JSON 恢复和保存。

  • parseAnimation(animation, bones): 将 THREE.AnimationClip 转换为 JSON。

一旦你得到了 THREE.AnimationClip,你可以将其传递给 THREE.AnimationMixer 对象,该对象提供以下功能:

  • AnimationMixer(rootObject): 此对象的构造函数。此构造函数接受一个 THREE.Object3D 参数(例如,一个 THREE.MeshTHREE.GroupTHREE.Mesh)。

  • time: 此混音器的全局时间。它从 0 开始,在创建此混音器的时间。

  • timeScale: 可以用来加快或减慢由此混音器管理的所有动画。如果此属性的值设置为 0,则所有动画都将被有效地暂停。

  • clipAction(animationClip, optionalRoot): 创建一个 THREE.AnimationAction,可以用来控制传入的 THREE.AnimationClip。如果动画剪辑是为与 AnimationMixer 构造函数中提供的对象不同的对象,你也可以传入它。

  • existingAction(animationClip, optionalRoot): 这返回了 THREE.AnimationAction 属性,可以用来控制传入的 THREE.AnimationClip。再次强调,如果 THREE.AnimationClip 是针对不同的 rootObject,你也可以传入它。

当你获取 THREE.AnimationClip 时,你可以用它来控制动画:

  • clampWhenFinished: 当设置为 true 时,当动画到达最后一帧时,这将导致动画暂停。默认为 false

  • enabled: 当设置为 false 时,这将禁用当前动作,使其不影响模型。当动作重新启用时,动画将从上次停止的地方继续。

  • loop: 这是此动作的循环模式(可以使用 setLoop 函数设置)。可以设置为以下几种:

    • THREE.LoopOnce: 只播放一次剪辑

    • THREE.LoopRepeat: 根据设置的重复次数重复剪辑

    • THREE.LoopPingPong: 根据重复次数播放剪辑,但会在正向和反向之间交替播放

  • paused: 将此属性设置为 true 将暂停此剪辑的执行。

  • repetitions: 动画将重复的次数。这由 loop 属性使用。默认值为 Infinity

  • time: 此动作已运行的时间。这是从 0 到剪辑持续时间的包装。

  • timeScale: 这可以用来加快或减慢此动画。如果此属性的值设置为 0,则此动画将实际上暂停。

  • weight: 这指定了动画对模型的影响程度,范围从 01。当设置为 0 时,您将看不到模型因该动画而产生的任何变换,而当设置为 1 时,您将看到该动画的全部效果。

  • zeroSlopeAtEnd: 当设置为 true(默认值)时,这将确保在单独的剪辑之间有一个平滑的过渡。

  • zeroSlopeAtStart: 当设置为 true(默认值)时,这将确保在单独的剪辑之间有一个平滑的过渡。

  • crossFadeFrom(fadeOutAction, durationInSeconds, warpBoolean): 这会导致此动作淡入,同时 fadeOutAction 淡出。总的淡入淡出时间为 durationInSeconds。这允许在动画之间进行平滑过渡。当 warpBoolean 设置为 true 时,它将对时间尺度应用额外的平滑处理。

  • crossFadeTo(fadeInAction, durationInSeconds, warpBoolean): 与 crossFadeFrom 相同,但这次淡入提供的动作,并淡出此动作。

  • fadeIn(durationInSeconds): 在指定的时间间隔内,将 weight 属性从 0 慢慢增加到 1

  • fadeOut(durationInSeconds): 在指定的时间间隔内,将 weight 属性从 0 慢慢减少到 1

  • getEffectiveTimeScale(): 返回基于当前运行的扭曲的有效时间尺度。

  • getEffectiveWeight(): 返回基于当前运行的淡入淡出的有效权重。

  • getClip(): 返回此动作管理的 THREE.AnimationClip 属性。

  • getMixer(): 返回播放此动作的混音器。

  • getRoot(): 获取由此动作控制的基本对象。

  • halt(durationInSeconds): 在 durationInSeconds 内逐渐将 timeScale 减少到 0

  • isRunning(): 检查动画是否当前正在运行。

  • isScheduled(): 检查此动作是否当前在混音器中活动。

  • play(): 开始运行此动作(开始动画)。

  • reset(): 重置此动作。这将导致将 paused 设置为 falseenabled 设置为 true,以及 time 设置为 0

  • setDuration(durationInSeconds): 设置单个循环的持续时间。这将改变 timeScale,以便整个动画可以在 durationInSeconds 内播放。

  • setEffectiveTimeScale(timeScale): 将 timeScale 设置为提供的值。

  • setEffectiveWeight(): 将 weight 设置为提供的值。

  • setLoop(loopMode, repetitions): 设置 loopMode 和重复次数。请参阅 loop 属性以了解选项及其效果。

  • startAt(startTimeInSeconds): 延迟startTimeInSeconds秒开始动画。

  • stop(): 停止此动作,并应用reset

  • stopFading(): 停止任何计划中的淡入淡出。

  • stopWarping(): 停止任何计划中的扭曲。

  • syncWith(otherAction): 将此动作与传入的动作同步。这将设置此动作的timetimeScale值与传入的动作相同。

  • warp(startTimeScale, endTimeScale, durationInSeconds): 在指定的durationInSeconds内将timeScale属性从startTimeScale更改为endTimeScale

除了您可以使用的所有控制动画的函数和属性之外,THREE.AnimationMixer还提供了两个事件,您可以通过在 mixer 上调用addEventListener来监听这些事件。当单个循环完成时,会发送"loop"事件,当整个动作完成时,会发送"finished"事件。

使用骨骼和皮肤进行动画

正如我们在使用 mixer 和形态目标进行动画部分所看到的,形态动画非常简单。Three.js 知道所有的目标顶点位置,只需要将每个顶点从当前位置过渡到下一个位置。对于骨骼和皮肤,这会变得稍微复杂一些。当您使用骨骼进行动画时,您移动骨骼,Three.js 必须确定如何相应地转换附着的皮肤(一组顶点)。对于此示例,我们将使用从 Blender 导出到 Three.js 格式的模型(models/blender-skeleton文件夹中的lpp-rigging.gltf)。这是一个包含一组骨骼的人体模型。通过移动骨骼,我们可以对整个模型进行动画。首先,让我们看看我们是如何加载模型的:

let animations = []
const loadModel = () => {
  const loader = new GLTFLoader()
  return loader.loadAsync('/assets/models/blender-
    skeleton/lpp-rigging.gltf').then((container) => {
    container.scene.translateY(-2)
    applyShadowsAndDepthWrite(container.scene)
    animations = container.animations
    return container.scene
  })
}

我们已经将模型导出为 glTF 格式,因为 Three.js 对 glTF 的支持很好。加载用于骨骼动画的模型与其他模型没有太大区别。我们只需指定模型文件并像其他 glTF 文件一样加载它。对于 glTF,动画位于加载的对象的单独属性中,所以我们只需将其分配给animations变量以便于访问。

在此示例中,我们添加了一个控制台日志,显示了加载后THREE.Mesh的外观:

图 9.18 – 骨骼结构反映在对象的层次结构中

图 9.18 – 骨骼结构反映在对象的层次结构中

在这里,您可以看到网格由骨骼和网格的树组成。这也意味着如果您移动一个骨骼,相关的网格将与其一起移动。

以下截图显示了此示例的静态图像:

图 9.19 – 手动改变手臂和腿骨的旋转

图 9.19 – 手动改变手臂和腿骨的旋转

此场景也包含一个动画,您可以通过勾选animationIsPlaying复选框来触发它。这将覆盖手动设置的骨骼的位置和旋转,使骨骼上下跳动:

图 9.20 – 播放骨骼动画

图 9.20 – 播放骨骼动画

要设置这个动画,我们必须遵循之前看到的相同步骤:

const mixer = new THREE.AnimationMixer(mesh)
const action = mixer.clipAction(animations[0])
action.play()

如您所见,使用骨骼与使用固定形态目标一样简单。在这个例子中,我们只调整了骨骼的旋转;您也可以移动位置或改变比例。在下一节中,我们将探讨从外部模型加载动画。

使用外部模型创建动画

第八章 创建和加载高级网格和几何体 中,我们探讨了几个 Three.js 支持的 3D 格式。其中一些格式也支持动画。在本章中,我们将探讨以下示例:

  • COLLADA 模型:COLLADA 格式支持动画。在这个例子中,我们将从一个 COLLADA 文件中加载一个动画,并使用 Three.js 进行渲染。

  • MD2 模型:MD2 模型是一种在较老的 Quake 引擎中使用的简单格式。尽管格式有些过时,但它仍然是一个非常好的用于存储角色动画的格式。

  • glTF 模型GL 传输格式glTF)是一种专门设计用于存储 3D 场景和模型的格式。它专注于最小化资产的大小,并试图在解包模型时尽可能高效。

  • FBX 模型:FBX 是由 Mixamo 工具生成的格式,可在 www.mixamo.com 找到。使用 Mixamo,您可以轻松地为模型绑定和动画,而不需要大量的建模经验。

  • BVH 模型:与其它加载器相比,BiovisionBVH)格式略有不同。使用这个加载器,您不需要与骨骼或一系列动画一起加载几何形状。使用这种格式(由 Autodesk MotionBuilder 使用),您只需加载一个骨骼,您可以将它可视化,甚至将其附加到您的几何形状上。

我们将从一个 glTF 模型开始,因为这种格式正成为不同工具和库之间交换模型的标准。

使用 gltfLoader

最近越来越受到关注的格式是 glTF 格式。这种格式,您可以在 github.com/KhronosGroup/glTF 找到非常详尽的解释,专注于优化大小和资源使用。使用 glTFLoader 与使用其他加载器类似:

import { GLTFLoader } from 'three/examples
  /jsm/loaders/GLTFLoader'
...
return loader.loadAsync('/assets/models/truffle_man/scene.gltf').
  then((container) => {
  container.scene.scale.setScalar(4)
  container.scene.translateY(-2)
  scene.add(container.scene)

  const mixer = new THREE.AnimationMixer( container.scene ); 
  const animationClip = container.animations[0];
  const clipAction = mixer.clipAction( animationClip ).
play(); 
})

这个加载器也加载了一个完整的场景,因此您可以将所有内容添加到组中,或者选择子元素。对于这个例子,您可以通过打开 load-gltf.js 来查看结果:

图 9.21 – 使用 glTF 加载的动画

图 9.21 – 使用 glTF 加载的动画

对于下一个例子,我们将使用 FBX 模型。

使用 fbxLoader 可视化捕捉到的动作模型

Autodesk FBX 格式已经存在一段时间了,并且非常易于使用。网上有一个非常好的资源,您可以在这里找到许多可以下载的动画:www.mixamo.com/。该网站提供了 2,500 个您可以使用和定制的动画:

图 9.22 – 从 mixamo 加载动画

图 9.22 – 从 mixamo 加载动画

下载动画后,使用 Three.js 处理它很容易:

import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
...
loader.loadAsync('/assets/models/salsa/salsa.fbx').then((mesh) => {
  mesh.translateX(-0.8)
  mesh.translateY(-1.9)
  mesh.scale.set(0.03, 0.03, 0.03)
  scene.add(mesh)
  const mixer = new THREE.AnimationMixer(mesh)
  const clips = mesh.animations
  const clip = THREE.AnimationClip.findByName(clips, 
    'mixamo.com')   
})

如您在 load-fbx.html 中所见,生成的动画看起来很棒:

图 9.23 – 使用 fbx 加载的动画

图 9.23 – 使用 fbx 加载的动画

FBX 和 glTF 是现代格式,被广泛使用,是交换模型和动画的好方法。还有一些旧的格式。一个有趣的是老式 FPS 游戏 Quake 使用的格式:MD2。

从 Quake 模型加载动画

MD2 格式是为了建模 1996 年一款伟大的游戏 Quake 中的角色而创建的。尽管较新的引擎使用不同的格式,但您仍然可以在 MD2 格式中找到许多有趣的模型。使用 MD2 文件与使用我们迄今为止看到的其他文件略有不同。当您加载 MD2 模型时,您会得到一个几何形状,因此您必须确保同时创建一个材质并将其分配给皮肤:

let animations = []
const loader = new MD2Loader()
loader.loadAsync('/assets/models/ogre/ogro.md2').then
  ((object) => {
  const mat = new THREE.MeshStandardMaterial({
    color: 0xffffff,
    metalness: 0,
    map: new THREE.TextureLoader().load
      ('/assets/models/ogre/skins/skin.jpg')
  })
  animations = object.animations
  const mesh = new THREE.Mesh(object, mat)
  // add to scene, and you can animate it as we've seen 
    already
})

一旦您有了这个 Mesh,设置动画的方式与之前相同。这个动画的结果可以在这里看到(load-md2.html):

图 9.24 – 加载的 Quake 怪物

图 9.24 – 加载的 Quake 怪物

接下来是 COLLADA。

从 COLLADA 模型加载动画

虽然正常的 COLLADA 模型未压缩(并且它们可以变得相当大),但 Three.js 中还有一个 KMZLoader。这是一个压缩的 COLLADA 模型,所以如果您遇到 KMZLoader 而不是 ColladaLoader

图 9.25 – 加载的 COLLADA 模型

图 9.25 – 加载的 COLLADA 模型

对于最终的加载器,我们将查看 BVHLoader

使用 BVHLoader 可视化骨骼

BVHLoader 是一种与我们迄今为止看到的不同的加载器。这个加载器不返回带有动画的网格或几何形状;相反,它返回一个骨骼和一个动画。一个例子可以在 load-bvh.html 中看到:

图 9.26 – 加载的 BVH 骨骼

图 9.26 – 加载的 BVH 骨骼

为了可视化这一点,我们可以使用 THREE.SkeletonHelper,如下所示。使用 THREE.SkeletonHelper,我们可以可视化网格的骨骼。BVH 模型仅包含骨骼信息,我们可以像这样可视化:

const loader = new BVHLoader()
let animation = undefined
loader.loadAsync('/assets/models//amelia-dance/DanceNightClub7_t1.bvh').then((result) => {
  const skeletonHelper = new THREE.SkeletonHelper
    (result.skeleton.bones[0])
  skeletonHelper.skeleton = result.skeleton
  const boneContainer = new THREE.Group()
  boneContainer.add(result.skeleton.bones[0])
  animation = result.clip
  const group = new THREE.Group()
  group.add(skeletonHelper)
  group.add(boneContainer)
  group.scale.setScalar(0.2)
  group.translateY(-1.6)
  group.translateX(-3)
  // Now we can animate the group just like we did for the 
    other examples
})

在 Three.js 的旧版本中,支持其他类型的动画文件格式。其中大多数已经过时,并随后从 Three.js 发行版中删除。如果您偶然发现了一种您想要展示动画的不同格式,您可以查看旧的 Three.js 版本,并可能重新使用那里的加载器。

摘要

在本章中,我们探讨了您可以为场景添加动画的不同方法。我们首先介绍了一些基本的动画技巧,然后转向摄像机运动和控制,最后通过使用形态目标以及骨骼/骨骼动画来查看如何对模型进行动画处理。

当您已经设置了渲染循环后,添加简单的动画变得非常容易。只需更改网格的一个属性;在下一个渲染步骤中,Three.js 将渲染更新后的网格。对于更复杂的动画,您通常会使用外部程序进行建模,并通过 Three.js 提供的加载器之一加载它们。

在前面的章节中,我们探讨了我们可以用来为对象着色的各种材质。例如,我们看到了如何更改这些材质的颜色、光泽度和透明度。然而,我们尚未详细讨论如何使用外部图像(也称为纹理)与这些材质结合使用。通过纹理,我们可以轻松创建看起来像是用木材、金属、石头等材料制成的对象。在第十章中,我们将探讨纹理的所有不同方面以及它们在 Three.js 中的使用方式。

第十章:加载和使用纹理

第四章 使用 Three.js 材质 中,我们向您介绍了 Three.js 中可用的各种材质。然而,我们没有讨论将纹理应用于创建网格时使用的材质。在本章中,我们将探讨这个主题。具体来说,我们将讨论以下主题:

  • 在 Three.js 中加载纹理并将其应用于网格

  • 使用凹凸、法线和位移图来为网格应用深度和细节

  • 使用光照图和环境遮挡图创建假阴影

  • 使用光泽度、金属度和粗糙度图来设置网格特定部分的光泽度

  • 为物体的部分透明度应用 alpha 图

  • 使用环境图向材质添加详细的反射

  • 使用 HTML5 Canvas 和视频元素作为纹理的输入

让我们从最基本的例子开始,我们将向您展示如何加载和应用纹理。

在材质中使用纹理

在 Three.js 中,纹理可以使用不同的方式。您可以使用它们来定义网格的颜色,但您也可以使用它们来定义光泽度、凹凸和反射。我们将首先查看一个非常基础的例子,其中我们将使用纹理来定义网格各个像素的颜色。这通常被称为颜色图或漫反射图。

加载纹理并将其应用于网格

纹理最基本的使用方式是将其设置为材质上的映射。当您使用这种材质创建网格时,网格将根据提供的纹理进行着色。加载纹理并将其应用于网格可以按以下方式完成:

const textureLoader = new THREE.TextureLoader(); 
const texture = textureLoader.load
  ('/assets/textures/ground/ground_0036_color_1k.jpg')

在此代码示例中,我们使用THREE.TextureLoader的一个实例从特定位置加载一个图像文件。使用此加载器,您可以将 PNG、GIF 或 JPEG 图像作为纹理的输入(在本章的后面部分,我们将向您展示如何加载其他纹理格式)。请注意,纹理是异步加载的:如果纹理很大,并且在纹理完全加载之前渲染场景,您将短暂地看到没有应用纹理的网格。如果您想等待纹理加载完成,可以向textureLoader.load()函数提供一个回调:

Const textureLoader = new THREE.TextureLoader(); 
const texture = textureLoader.load
  ('/assets/textures/ground/ground_0036_color_1k.jpg',
            onLoadFunction, 
            onProgressFunction,
            onErrorFunction)

如您所见,load函数接受三个额外的函数作为参数:onLoadFunction在纹理加载时被调用,onProgressFunction可用于跟踪纹理加载的进度,而onErrorFunction在加载或解析纹理时出错时被调用。现在纹理已经加载,我们可以将其添加到网格中:

const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.map = texture

注意,加载器还提供了一个loadAsync函数,它返回一个Promise,就像我们在上一章加载模型时看到的那样。

你几乎可以使用任何你想要的图像作为纹理。然而,使用边长为 2 的幂的平方纹理将获得最佳结果。例如,256 x 256、512 x 512、1,024 x 1,024 等尺寸工作得最好。如果纹理不是 2 的幂,Three.js 将把图像缩小到最接近的 2 的幂值。

我们将在本章的示例中使用的其中一个纹理看起来是这样的:

图 10.1 – 砖墙的颜色纹理

图 10.1 – 砖墙的颜色纹理

纹理的像素(也称为纹理像素)通常不会一对一地映射到面的像素上。如果相机非常靠近,我们需要放大纹理,如果我们缩小了视图,我们可能需要缩小纹理。为此,WebGL 和 Three.js 提供了一些不同的选项来调整图像大小。这是通过magFilterminFilter属性来完成的:

  • THREE.NearestFilter: 这个过滤器使用它能够找到的最近纹理像素的颜色。当用于放大时,这会导致块状感,而当用于缩小,结果会丢失很多细节。

  • THREE.LinearFilter: 这个过滤器更高级;它使用四个相邻的纹理像素的颜色值来确定正确的颜色。在缩小操作中,你仍然会丢失很多细节,但放大将会更加平滑,且块状感更少。

除了这些基本值之外,我们还可以使用MIP 贴图。MIP 贴图是一组纹理图像,每个图像的大小是前一个图像的一半。这些图像在加载纹理时创建,允许进行更平滑的过滤。因此,当你有一个平方纹理(作为 2 的幂)时,你可以使用一些额外的策略来获得更好的过滤效果。可以使用以下值来设置属性:

  • THREE.NearestMipMapNearestFilter: 这个属性选择最佳的 MIP 贴图来映射所需的分辨率,并应用最近邻滤波原理,这是我们之前列表中讨论过的。放大时仍然会有块状感,但缩小看起来要好得多。

  • THREE.NearestMipMapLinearFilter: 这个属性不仅选择单个 MIP 贴图,还选择两个最近的 MIP 贴图级别。在这两个级别上,都应用最近邻滤波器,以获得两个中间结果。这两个结果通过线性滤波器处理以获得最终结果。

  • THREE.LinearMipMapNearestFilter: 这个属性选择最佳的 MIP 贴图来映射所需的分辨率,并应用线性滤波原理,这在我们之前的列表中已经讨论过。

  • THREE.LinearMipMapLinearFilter: 这个属性不仅选择单个 MIP 贴图,还选择两个最近的 MIP 贴图级别。在这两个级别上,都应用线性滤波器,以获得两个中间结果。这两个结果通过线性滤波器处理以获得最终结果。

如果你没有明确指定magFilterminFilter属性,Three.js 将使用THREE.LinearFilter作为magFilter属性的默认值,并将THREE.LinearMipMapLinearFilter作为minFilter属性的默认值。

在我们的例子中,我们将仅使用默认的纹理属性。一个这样的基本纹理作为材料映射的例子可以在texture-basics.html中找到。以下截图显示了此示例:

图 10.2 – 带有简单木纹的模型

图 10.2 – 带有简单木纹的模型

在这个例子中,你可以更改模型并从右侧菜单中选择几个纹理。你还可以更改默认的材料属性,以查看材料在结合颜色图的情况下如何受到不同设置的影响。

在这个例子中,你可以看到纹理很好地包裹在形状周围。当你使用 Three.js 创建几何体时,它会确保任何使用的纹理都正确应用。这是通过称为 UV 映射的方法完成的。通过 UV 映射,我们可以告诉渲染器纹理的哪个部分应该应用到特定的面上。我们将在第十三章“使用 Blender 和 Three.js”的详细内容中介绍 UV 映射,我们将向你展示如何轻松使用 Blender 为 Three.js 创建自定义 UV 映射。

除了我们可以使用THREE.TextureLoader加载的标准图像格式之外,Three.js 还提供了一些自定义加载器,你可以使用这些加载器加载不同格式的纹理。如果你有特定的图像格式,你可以查看 Three.js 发行版的loaders文件夹(github.com/mrdoob/three.js/tree/dev/examples/jsm/loaders),以查看该图像格式是否可以直接由 Three.js 加载,或者你是否需要手动转换它。

除了这些普通图像之外,Three.js 还支持 HDR 图像。

将 HDR 图像作为纹理加载

HDR 图像捕捉了比标准图像更高的亮度级别,可以更接近我们用肉眼看到的效果。Three.js 支持EXRRGBE格式。如果你有一个 HDR 图像,你可以通过在THREE.WebGLRenderer中设置以下属性来微调 Three.js 渲染 HDR 图像的方式,因为 HDR 图像包含比显示器上显示的更多亮度信息。

  • toneMapping:此属性定义了如何将 HDR 图像的颜色映射到显示。Three.js 提供了以下选项:THREE.NoToneMappingTHREE.LinearToneMappingTHREE.ReinhardToneMappingTHREE.Uncharted2ToneMappingTHREE.CineonToneMapping。默认为THREE.LinearToneMapping

  • toneMappingExposure:这是toneMapping的曝光级别。这可以用来微调渲染纹理的颜色。

  • toneMappingWhitePoint:这是用于 toneMapping 的白点。这也可以用来微调渲染纹理的颜色。

如果你想要加载一个 EXR 或 RGBE 图像并将其用作纹理,你可以使用 THREE.EXRLoaderTHREE.RGBELoader。这与我们所看到的 THREE.TextureLoader 的方式相同:

const loader = new THREE.EXRLoader(); 
exrTextureLoader.load('/assets/textures/exr/Rec709.exr')
...
const hdrTextureLoader = new THREE.RGBELoader(); 
hdrTextureLoader.load('/assets/textures/hdr/
  dani_cathedral_oBBC.hdr')

texture-basics.html 示例中,我们向你展示了如何使用纹理将颜色应用到网格上。在下一节中,我们将探讨如何通过将假高度信息应用到网格上来使用纹理使模型看起来更加详细。

使用凹凸贴图向网格提供额外细节

凹凸贴图用于向材质添加更多深度。你可以通过打开 texture-bump-map.html 来看到这个效果:

图 10.3 – 带凹凸贴图的模型

图 10.3 – 带凹凸贴图的模型

在这个例子中,你可以看到模型看起来更加详细,似乎有更多的深度。这是通过在材质上设置一个额外的纹理,即所谓的凹凸贴图来实现的:

const exrLoader = new EXRLoader()
const colorMap = exrLoader.load('/assets/textures/brick-wall/brick_wall_001_diffuse_2k.exr', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const bumpMap = new THREE.TextureLoader().load(
  '/assets/textures/brick-wall/brick_wall_001_displacement_2k.png',
  (texture) => {
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.RepeatWrapping
    texture.repeat.set(4, 4)
  }
)
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.map = colorMap
material.bumpMap = bumpMap

在此代码中,你可以看到,除了设置映射属性外,我们还设置了 bumpMap 属性到一个纹理。此外,通过前一个示例中的菜单,我们可以使用 bumpScale 属性设置凹凸的高度(或如果设置为负值,则为深度)。本例中使用的纹理在此处显示:

图 10.4 – 用于凹凸贴图的纹理

图 10.4 – 用于凹凸贴图的纹理

凹凸贴图是一个灰度图像,但你也可以使用彩色图像。像素的强度定义了凹凸的高度。凹凸贴图只包含像素的相对高度。它没有说明斜坡的方向。因此,使用凹凸贴图可以达到的细节水平和深度感知是有限的。对于更详细的信息,你可以使用法线贴图。

使用法线贴图实现更详细的凹凸和皱纹

在法线贴图中,高度(位移)没有存储,但每个像素的法线方向被存储。不深入细节的话,使用法线贴图,你可以创建看起来非常详细的模型,而这些模型只使用了少量顶点和面。例如,看看 texture-normal-map.html 示例:

图 10.5 – 使用法线贴图的模型

图 10.5 – 使用法线贴图的模型

在前面的屏幕截图中,你可以看到一个看起来非常详细的模型。当模型移动时,你可以看到纹理正在响应它接收到的光线。这提供了一个看起来非常逼真的模型,并且只需要一个非常简单的模型和几个纹理。以下代码片段展示了如何在 Three.js 中使用法线贴图:

const colorMap = new THREE.TextureLoader().load('/assets/textures/red-bricks/red_bricks_04_diff_1k.jpg', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const normalMap = new THREE.TextureLoader().load(
  '/assets/textures/red-bricks/red_bricks_04_nor_gl_1k.jpg',
  (texture) => {
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.RepeatWrapping
    texture.repeat.set(4, 4)
  }
)
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.map = colorMap
material.normalMap = normalMap

这涉及到与我们用于凹凸贴图相同的方法。不过,这次我们将 normalMap 属性设置为正常纹理。我们还可以通过设置 normalScale 属性(mat.normalScale.set(1,1))来定义凹凸的明显程度。使用这个属性,你可以沿着 XY 轴进行缩放。然而,最好的方法是将这些值保持相同。在这个例子中,你可以尝试调整这些值。

下一个图展示了我们使用的正常贴图的样子:

图 10.6 – 正常纹理

图 10.6 – 正常纹理

然而,正常贴图的问题在于它们并不容易创建。你需要使用专门的工具,如 Blender 或 Photoshop。这些程序可以使用高分辨率的渲染或纹理作为输入,并从中创建正常贴图。

使用正常或凹凸贴图时,你不会改变模型的形状;所有的顶点都保持在相同的位置。这些贴图只是使用场景中的灯光来创建假深度和细节。然而,Three.js 提供了第三种方法,你可以使用它通过贴图添加细节到模型中,这种方法会改变顶点的位置。这是通过位移图实现的。

使用位移图来改变顶点的位置

Three.js 还提供了一个纹理,你可以用它来改变模型顶点的位置。虽然凹凸贴图和正常贴图可以产生深度错觉,但使用位移图时,我们根据纹理信息改变模型的形状。我们可以像使用其他贴图一样使用位移图:

const colorMap = new THREE.TextureLoader().load('/assets/textures/displacement
  /w_c.jpg', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
})
const displacementMap = new THREE.TextureLoader().load('/assets/textures/displacement
  /w_d.png', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
})
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.map = colorMap
material.displacementMap = displacementMap

在前面的代码片段中,我们加载了一个位移图,其外观如下:

图 10.7 – 位移图

图 10.7 – 位移图

颜色越亮,顶点的位移就越大。当你运行 texture-displacement.html 示例时,你会看到位移贴图的结果是一个模型,其形状基于贴图中的信息发生变化:

图 10.8 – 使用位移图建模

图 10.8 – 使用位移图建模

除了设置 displacementMap 纹理外,我们还可以使用 displacementScaledisplacementOffset 来控制位移的明显程度。关于使用位移图还有一点要提,那就是它只有在你的网格包含大量顶点时才会得到良好的效果。如果不是这样,位移看起来就不会像提供的纹理,因为顶点太少,无法表示所需的位移。

使用环境遮蔽贴图添加微妙的阴影

在前面的章节中,你学习了如何在 Three.js 中使用阴影。如果你设置了正确网格的 castShadowreceiveShadow 属性,添加了一些灯光,并正确配置了灯光的阴影相机,Three.js 将渲染阴影。

然而,渲染阴影是一个相对昂贵的操作,它会在每个渲染循环中重复进行。如果你有移动的灯光或对象,这是必要的,但通常,一些灯光或模型是固定的,所以如果我们能计算一次阴影并重复使用它们,那就太好了。为了实现这一点,Three.js 提供了两种不同的图:环境光遮蔽图和光照图。在本节中,我们将探讨环境光遮蔽图,在下一节中,我们将探讨光照图。

环境光遮蔽是一种技术,用于确定模型每个部分在场景中的环境光照中暴露的程度。在 Blender 等工具中,环境光通常通过半球光或方向光,如太阳光来模拟。虽然模型的大部分区域都会接收到一些这种环境光照,但并非所有部分都会接收到相同的光照。例如,如果你建模一个人,头顶会比手臂底部接收到更多的环境光照。这种光照差异——即阴影——可以被渲染(烘焙,如以下截图所示)成纹理,然后我们可以将这个纹理应用到我们的模型上,以给它们添加阴影,而不必每次都计算阴影:

图 10.9 – 在 Blender 中烘焙的环境光遮蔽图

图 10.9 – 在 Blender 中烘焙的环境光遮蔽图

一旦你有了环境光遮蔽图,你可以将其分配给材质的aoMap属性,Three.js 将考虑这个信息在应用和计算场景中灯光应该应用到模型特定部分的程度。以下代码片段显示了如何设置aoMap属性:

const aoMap = new THREE.TextureLoader().load('/assets/gltf/material_
  ball_in_3d-coat/aoMap.png')
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.aoMap = aoMap
material.aoMap.flipY = false

就像其他类型的纹理图一样,我们只需使用THREE.TextureLoader来加载纹理并将其分配给材质的正确属性。并且像许多其他图一样,我们也可以通过设置aoMapIntensity属性来调整地图对模型光照的影响程度。在这个例子中,你还可以看到我们需要将aoMapflipY属性设置为 false。有时,外部程序以 Three.js 期望的纹理略有不同的方式存储材质。使用这个属性,我们可以翻转纹理的方向。这通常是在与模型一起工作时通过试错法注意到的事情。

要使环境遮挡贴图生效,我们通常需要额外一步。我们已经提到了 UV 映射(存储在 uv 属性中)。这些定义了纹理的哪一部分被映射到模型的特定面。对于环境遮挡贴图,以及以下示例中的光照贴图,Three.js 使用一组独立的 UV 映射(存储在 uv2 属性中),因为通常其他纹理需要以不同于阴影和光照贴图纹理的方式应用。在我们的示例中,我们只是复制了模型的 UV 映射;记住,当我们使用 aoMap 属性或 lightMap 属性时,Three.js 将使用 uv2 属性的值,而不是 uv 属性的值。如果加载的模型中没有这个属性,通常只需复制 uv 映射属性即可,因为我们没有对环境遮挡贴图进行任何优化,这可能需要不同的 UV 集合:

const k = mesh.geometry
const uv1 = k.getAttribute('uv')
const uv2 = uv1.clone()
k.setAttribute('uv2', uv2)

我们将提供两个示例,展示我们如何使用环境遮挡贴图。在第一个示例中,我们展示了应用了 aoMap图 10.9 中的模型(texture-ao-map-model.html):

图 10.10 – 在 Blender 中烘焙的环境遮挡贴图然后应用于模型

图 10.10 – 在 Blender 中烘焙的环境遮挡贴图然后应用于模型

您可以使用右侧的菜单设置 aoMapIntensity。此值越高,您将看到从加载的 aoMap 纹理中产生的阴影就越多。如您所见,拥有环境遮挡贴图非常有用,因为它为模型提供了丰富的细节,并使其看起来更加逼真。在本章中我们已经看到的一些纹理也提供了额外的 aoMap,您可以使用它。如果您打开 texture-ao-map.html,您将得到一个简单的砖块状纹理,但这次添加了 aoMap

图 10.11 – 环境遮挡贴图与颜色和法线贴图结合

图 10.11 – 环境遮挡贴图与颜色和法线贴图结合

当环境遮挡贴图改变模型某些部分接收到的光照量时,Three.js 还支持 lightmap,它通过指定一个映射来为模型的某些部分添加额外的光照,从而产生相反的效果(大约是这样)。

使用光照贴图创建假光照

在本节中,我们将使用光照贴图。光照贴图是一种包含场景中灯光对模型影响程度信息的纹理。换句话说,灯光的效果被烘焙到纹理中。光照贴图在 3D 软件中烘焙,如 Blender,并包含模型每个部分的光照值:

图 10.12 – 在 Blender 中烘焙的光照贴图

图 10.12 – 在 Blender 中烘焙的光照贴图

在本例中我们将使用的光照贴图如图图 10.12所示。编辑窗口的右侧显示了地平面的烘焙光照贴图。你可以看到整个地平面都被白色光照亮,而其中一部分接收到的光线较少,因为场景中还有一个模型。使用光照贴图的代码与使用环境遮挡贴图的代码类似:

Const textureLoader = new THREE.TextureLoader()
const colorMap = textureLoader.load('/assets/textures/wood/
  abstract-antique-backdrop-164005.jpg')
const lightMap = textureLoader.load('/assets/gltf/
  material_ball_in_3d-coat/lightMap.png')
const material = new THREE.MeshBasicMaterial({ color: 
  0xffffff })
material.map = colorMap
material.lightMap = lightMap
material.lightMap.flipY = false

一次又一次,我们需要为 Three.js 提供一组额外的uv值,称为uv2(代码中未显示),并且我们必须使用THREE.TextureLoader来加载纹理——在这种情况下,使用了一个简单的纹理来表示地板的颜色和 Blender 为这个示例创建的光照贴图。结果如下(texture-light-map.html):

图 10.13 – 使用光照贴图创建假阴影

图 10.13 – 使用光照贴图创建假阴影

如果你查看前面的示例,你会看到光照贴图的信息被用来创建一个非常漂亮的阴影,这个阴影看起来像是模型投射的。重要的是要记住,烘焙阴影、灯光和环境遮挡在静态场景和静态物体中效果很好。一旦物体或光源发生变化或开始移动,你就必须实时计算阴影。

金属度和粗糙度贴图

当讨论 Three.js 中可用的材质时,我们提到一个好的默认材质是THREE.MeshStandardMaterial。你可以使用它来创建闪亮的金属材质,也可以应用粗糙度,使网格看起来更像木材或塑料。通过使用材质的金属度和粗糙度属性,我们可以配置材质以表示我们想要的材质。除了这两个属性之外,你还可以通过使用纹理来配置这些属性。因此,如果我们有一个粗糙的物体,并且我们想要指定该物体的某个部分是闪亮的,我们可以设置THREE.MeshStandardMaterialmetalnessMap属性,如果我们想要表明网格的某些部分应该被视为刮痕或更粗糙,我们可以设置roughnessMap属性。当你使用这些贴图时,模型特定部分的纹理值将被乘以粗糙度属性或金属度属性,这决定了该特定像素应该如何渲染。首先,我们将查看texture-metalness-map.html中的金属度属性:

图 10.14 – 应用到模型上的金属度纹理

图 10.14 – 应用到模型上的金属度纹理

在本例中,我们提前了一步,并且还使用了一个环境贴图,这允许我们在对象上渲染来自环境的反射。具有高金属度的对象反射更多,而具有高粗糙度的对象则更多地散射反射。对于此模型,我们使用了 metalnessMap;您可以看到,当纹理中的 metalness 属性值高时,物体本身是闪亮的,而当 metalness 属性值低时,某些部分是粗糙的。当查看 roughnessMap 时,我们可以看到几乎相同但相反的情况:

图 10.15 – 应用到模型上的粗糙度纹理

图 10.15 – 应用到模型上的粗糙度纹理

如您所见,基于提供的纹理,模型的某些部分比其他部分更粗糙或更刮痕。对于 metalnessMap,材料的值乘以材料的 metalness 属性;对于 roughnessMap,同样适用,但在此情况下,值乘以 roughness 属性。

加载这些纹理并将它们设置为材质可以这样做:

const metalnessTexture = new THREE.TextureLoader().load(
  '/assets/textures/engraved/Engraved_Metal_003_ROUGH.jpg',
  (texture) => {
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.RepeatWrapping
    texture.repeat.set(4, 4)
  }
)
const material = new THREE.MeshStandardMaterial({ color: 
  0xffffff })
material.metalnessMap = metalnessTexture
...
const roughnessTexture = new THREE.TextureLoader().load(
  '/assets/textures/marble/marble_0008_roughness_2k.jpg',
  (texture) => {
    texture.wrapS = THREE.RepeatWrapping
    texture.wrapT = THREE.RepeatWrapping
    texture.repeat.set(2, 2)
  }
)
const material = new THREE.MeshStandardMaterial({ color: 
  0xffffff })
material.roughnessMap = roughnessTexture

接下来是 Alpha 映射。使用 Alpha 映射,我们可以使用纹理来改变模型部分的不透明度。

使用 Alpha 映射创建透明模型

Alpha 映射是一种控制表面不透明度的方法。如果映射的值为黑色,则该模型的部分将完全透明,如果为白色,则将完全不透明。在我们查看纹理及其应用方法之前,我们首先来看一个示例(texture-alpha-map.html):

图 10.16 – 用于提供部分透明度的 Alpha 映射

图 10.16 – 用于提供部分透明度的 Alpha 映射

在这个示例中,我们渲染了一个立方体并设置了材质的 alphaMap 属性。如果您打开这个示例,请确保将材质的 transparency 属性设置为 true。您可能会注意到,您只能看到立方体的正面部分,与前面的截图不同,您可以通过立方体看到另一面。原因是,默认情况下,使用的材质的侧面属性设置为 THREE.FrontSide。要渲染通常隐藏的侧面,我们必须将材质的侧面属性设置为 THREE.DoubleSide;您将看到立方体被渲染成前面的截图所示的样子。

在本例中我们使用的纹理是一个非常简单的:

图 10.17 – 用于创建透明模型的纹理

图 10.17 – 用于创建透明模型的纹理

要加载它,我们必须使用与其他纹理相同的方法:

const alphaMap = new THREE.TextureLoader().load('/assets/
  textures/alpha/partial-transparency.png', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.alphaMap = alphaMap
material.transparent = true

在这个代码片段中,你还可以看到我们设置了纹理的wrapSwrapTrepeat属性。我们将在本章后面更详细地解释这些属性,但可以使用这些属性来确定我们希望在网格上重复纹理的频率。如果设置为(1, 1),则整个纹理在应用于网格时不会重复;如果设置为更高的值,则纹理会缩小并重复多次。在这种情况下,我们在两个方向上重复了四次。

为发光模型使用发射贴图

发射贴图是一种可以用来使模型的某些部分发光的纹理,就像发射属性对整个模型所做的那样。就像发射属性一样,使用发射贴图并不意味着这个物体正在发光——它只是使应用了此纹理的模型部分看起来像在发光。通过查看一个例子可以更容易地理解这一点。如果你在浏览器中打开texture-emissive-map.html示例,你会看到一个类似火山的物体:

图 10.18 – 使用发射贴图的类似火山物体

图 10.18 – 使用发射贴图的类似火山物体

当你仔细观察时,你可能会发现,尽管物体看起来在发光,但物体本身并不发光。这意味着你可以用这个方法来增强物体,但物体本身并不对场景的照明做出贡献。在这个例子中,我们使用了一个看起来如下所示的发射贴图:

图 10.19 – 火山纹理

图 10.19 – 火山纹理

要加载和使用发射贴图,我们可以使用THREE.TextureLoader加载一个并将其分配给emissiveMap属性(以及一些其他贴图以显示图 10.18中的模型):

const emissiveMap = new   THREE.TextureLoader().load
  ('/assets/textures/lava/lava.png', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const roughnessMap = new THREE.TextureLoader().load
  ('/assets/textures/lava/lava-smoothness.png', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const normalMap = new THREE.TextureLoader().load
  ('/assets/textures/lava/lava-normals.png', (texture) => {
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(4, 4)
})
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.normalMap = normalMap
material.roughnessMap = roughnessMap
material.emissiveMap = emissiveMap
material.emissive = new THREE.Color(0xffffff)
material.color = new THREE.Color(0x000000)

由于emissiveMap中的颜色与发射属性相调制,请确保将材质的emissive属性设置为非黑色。

使用反射贴图确定闪亮度

在之前的例子中,我们主要使用了THREE.MeshStandardMaterial,以及该材质支持的不同贴图。如果你需要材质,THREE.MeshStandardMaterial通常是你的最佳选择,因为它可以轻松配置以表示大量不同类型的真实世界材质。在 Three.js 的旧版本中,你必须使用THREE.MeshPhongMaterial来创建闪亮的材质,使用THREE.MeshLambertMaterial来创建非闪亮的材质。本节中使用的反射贴图只能与THREE.MeshPhongMaterial一起使用。使用反射贴图,你可以定义模型的哪些部分应该是闪亮的,以及哪些部分应该是粗糙的(类似于我们之前看到的metalnessMaproughnessMap)。在texture-specular-map.html示例中,我们渲染了地球,并使用反射贴图使海洋比陆地更闪亮:

图 10.20 – 显示反射海洋的反射贴图

图 10.20 – 显示反射海洋的镜面图

通过使用右上角的菜单,你可以调整镜面颜色和亮度。正如你所看到的,这两个属性会影响海洋反射光的方式,但它们不会改变陆地部分的亮度。这是因为我们使用了以下镜面图:

图 10.21 – 镜面图纹理

图 10.21 – 镜面图纹理

在这个图中,黑色表示图中这些部分的亮度为 0%,而白色部分亮度为 100%。

要使用镜面图,我们必须使用THREE.TextureLoader来加载图并将其分配给THREE.MathPhongMaterialspecularMap属性:

const colorMap = new THREE.TextureLoader().load
  ('/assets/textures/specular/Earth.png')
const specularMap = new THREE.TextureLoader().load
  ('/assets/textures/specular/EarthSpec.png')
const normalMap = new THREE.TextureLoader().load
  ('/assets/textures/specular/EarthNormal.png')
const material = new THREE.MeshPhongMaterial({ color: 
  0xffffff })
material.map = colorMap
material.specularMap = specularMap
material.normalMap = normalMap

通过镜面图,我们已经讨论了你可以用来为你的模型添加深度、颜色、透明度或额外光照效果的大多数基本纹理。在接下来的两个部分中,我们将探讨另一种类型的图,这将允许你为你的模型添加环境反射。

使用环境图创建伪造的反射

计算环境反射非常耗费 CPU 资源,通常需要使用光线追踪方法。如果你想在 Three.js 中使用反射,你仍然可以这样做,但你需要伪造它。你可以通过创建一个包含对象所在环境的纹理并将其应用到特定对象上来实现这一点。首先,我们将向您展示我们期望的结果(参见texture-environment-map.html,如下截图所示):

图 10.22 – 显示汽车内部的纹理图

图 10.22 – 显示汽车内部的纹理图

在前面的截图中,你可以看到球体反射了环境。如果你移动鼠标,你也会看到反射与相机角度相对应,考虑到你所看到的环境。要创建这个示例,请执行以下步骤:

  1. 创建一个CubeTexture对象。CubeTexture是一组可以应用于立方体每个面的六个纹理。

  2. 设置天空盒。当我们有一个CubeTexture时,我们可以将其设置为场景的背景。如果我们这样做,我们实际上创建了一个非常大的盒子,其中放置了摄像机和对象,这样当我们移动摄像机时,场景的背景也会正确地改变。或者,我们也可以创建一个非常大的立方体,应用CubeTexture,并将其添加到场景中。

  3. CubeTexture对象设置为材质的cubeMap属性的纹理。用于模拟环境的同一个CubeTexture对象应作为网格的纹理使用。Three.js 将确保它看起来像环境的反射。

创建一个CubeTexture相当简单,一旦你有了源材质。你需要的是六张图片,它们共同组成一个完整的环境。因此,你需要以下图片:

  • 向上看(posz

  • 向后看(negz

  • 向上看(posy

  • 向下看(negy

  • 向右看(posx

  • 向左看(negx

Three.js 会将这些拼接在一起以创建一个无缝的环境贴图。有几个网站可以下载全景图像,但它们通常是以球形等距圆环形格式,如下所示:

图 10.23 – 等距圆环形格式立方体贴图

图 10.23 – 等距圆环形格式立方体贴图

你可以使用两种方式使用这些类型的贴图。首先,你可以将其转换为包含六个单独文件的立方体贴图格式。你可以使用以下网站在线转换:jaxry.github.io/panorama-to-cubemap/

或者,你可以使用不同的方法将这种纹理加载到 Three.js 中,我们将在本节稍后展示。

要从六个单独的文件中加载CubeTexture,我们可以使用THREE.CubeTextureLoader,如下所示:

const cubeMapFlowers = new THREE.CubeTextureLoader().load([
  '/assets/textures/cubemap/flowers/right.png',
  '/assets/textures/cubemap/flowers/left.png',
  '/assets/textures/cubemap/flowers/top.png',
  '/assets/textures/cubemap/flowers/bottom.png',
  '/assets/textures/cubemap/flowers/front.png',
  '/assets/textures/cubemap/flowers/back.png'
])
const material = new THREE.MeshPhongMaterial({ color: 
  0x777777 }
material.envMap = cubeMapFlowers
material.mapping = THREE.CubeReflectionMapping

在这里,你可以看到我们已经从几幅不同的图像中加载了一个cubeMap。一旦加载,我们将纹理分配给材料的envMap属性。最后,我们必须通知 Three.js 我们想要使用哪种类型的映射。如果你使用THREE.CubeTextureLoader加载纹理,你可以使用THREE.CubeReflectionMappingTHREE.CubeRefractionMapping。第一个将使你的对象根据加载的cubeMap显示反射,而第二个将使你的模型变成一个更透明的类似玻璃的对象,它稍微折射光线,再次基于cubeMap中的信息。

我们也可以将这个cubeMap设置为场景的背景,如下所示:

scene.background = cubeMapFlowers

当你只有一张图片时,过程并没有太大不同:

const cubeMapEqui = new THREE.TextureLoader().load
  ('/assets/equi.jpeg')
const material = new THREE.MeshPhongMaterial({ color: 
  0x777777 }
material.envMap = cubeMapEqui
material.mapping = THREE.EquirectangularReflectionMapping
scene.background = cubeMapFlowers

这次,我们使用了正常的纹理加载器,但通过指定不同的mapping,我们可以通知 Three.js 如何渲染这个纹理。当使用这种方法时,你可以将映射设置为THREE.EquirectangularRefractionMappingTHREE.EquirectangularReflectionMapping

这两种方法的结果都是一个看起来我们站在一个宽敞的户外环境中的场景,其中网格反射环境。侧边菜单允许你设置材料的属性:

图 10.24 – 使用折射创建类似玻璃的对象

图 10.24 – 使用折射创建类似玻璃的对象

除了反射之外,Three.js 还允许你使用cubeMap对象进行折射(类似玻璃的对象)。以下截图展示了这一点(你可以通过使用右侧菜单来亲自测试):

图 10.25 – 使用折射创建类似玻璃的对象

图 10.25 – 使用折射创建类似玻璃的对象

要得到这种效果,我们只需要将cubeMap的映射属性设置为THREE.CubeRefractionMapping(默认是反射,也可以通过指定THREE.CubeReflectionMapping手动设置):

 cubeMap.mapping = THREE.CubeRefractionMapping

在这个例子中,我们为网格使用了静态环境贴图。换句话说,我们只看到了环境的反射,而没有看到环境中的其他网格。在下面的屏幕截图中,你可以看到,通过一点工作,我们也可以显示其他物体的反射:

图 10.26 – 使用 cubeCamera 创建动态反射

图 10.26 – 使用 cubeCamera 创建动态反射

为了显示场景中其他物体的反射,我们需要使用一些其他的 Three.js 组件。其中第一个是一个额外的相机,称为THREE.CubeCamera

const cubeRenderTarget = new THREE.WebGLCubeRenderTarget
  (128, {
  generateMipmaps: true,
  minFilter: THREE.LinearMipmapLinearFilter
})
const cubeCamera = new THREE.CubeCamera(0.1, 10, cubeRenderTarget)
cubeCamera.position.copy(mesh.position); 
scene.add(cubeCamera);

我们将使用THREE.CubeCamera来捕捉场景中所有物体渲染后的快照,并使用它来设置一个cubeMap。前两个参数定义了相机的近处和远处属性。因此,在这种情况下,相机只渲染它从 0.1 到 1.0 可以看到的内容。最后一个属性是我们想要渲染纹理的目标。为此,我们创建了一个THREE.WebGLCubeRenderTarget的实例。第一个参数是渲染目标的大小。值越高,反射看起来越详细。其他两个属性用于确定在缩放时纹理如何放大和缩小。

你需要确保将这个相机放置在你想要显示动态反射的THREE.Mesh的确切位置。在这个例子中,我们复制了网格的位置,以便相机正确定位。

现在我们已经正确设置了CubeCamera,我们需要确保CubeCamera所看到的内容被应用到我们示例中的立方体上。为此,我们必须将envMap属性设置为cubeCamera.renderTarget

cubeMaterial.envMap = cubeRenderTarget.texture;

现在,我们必须确保cubeCamera渲染场景,这样我们就可以将输出用作立方体的输入。为此,我们必须更新渲染循环如下(或者如果场景没有变化,我们只需调用一次):

const render = () => {
...
mesh.visible = false; 
cubeCamera.update(renderer, scene); 
mesh.visible = true;
requestAnimationFrame(render); 
renderer.render(scene, camera);
....
}

如你所见,首先,我们禁用了mesh的可见性。我们这样做是因为我们只想看到其他物体的反射。接下来,我们通过调用update函数使用cubeCamera渲染场景。之后,我们再次使mesh可见并正常渲染场景。结果是,在mesh的反射中,你可以看到我们添加的立方体。对于这个例子,每次你点击updateCubeCamera按钮时,网格的envMap属性都会被更新。

重复包装

当你将纹理应用到由 Three.js 创建的几何体上时,Three.js 会尽可能地优化纹理的应用。例如,对于立方体,这意味着每个面都会显示完整的纹理,而对于球体,完整的纹理会被包裹在球体上。然而,有些情况下你可能不希望纹理在整个面或整个几何体上扩散,而是希望纹理重复自身。Three.js 提供了允许你控制这一点的功能。一个可以让你玩转重复属性的例子在texture-repeat-mapping.html中提供。以下截图展示了这个例子:

图 10.27 – 球体上的重复包裹

图 10.27 – 球体上的重复包裹

在此属性产生预期效果之前,你需要确保将纹理的包裹设置为THREE.RepeatWrapping,如下面的代码片段所示:

mesh.material.map.wrapS = THREE.RepeatWrapping; 
mesh.material.map.wrapT = THREE.RepeatWrapping;

wrapS属性定义了纹理沿其X轴如何包裹,而wrapT属性定义了纹理应沿其Y轴如何包裹。Three.js 为此提供了三种选项,如下所示:

  • THREE.RepeatWrapping允许纹理重复自身

  • THREE.MirroredRepeatWrapping允许纹理重复自身,但每次重复都是镜像的

  • THREE.ClampToEdgeWrapping是一个默认设置,其中纹理不会整体重复;只有边缘的像素会被重复

在这个例子中,你可以玩转各种重复设置以及wrapSwrapT选项。一旦选择了包裹类型,我们就可以设置repeat属性,如下面的代码片段所示:

mesh.material.map.repeat.set(repeatX, repeatY);

repeatX变量定义了纹理沿其X轴重复的频率,而repeatY变量定义了沿Y轴的相同频率。如果这些值设置为 1,纹理不会重复;如果它们设置为更高的值,你会看到纹理开始重复。你也可以使用小于 1 的值。在这种情况下,你会放大纹理。如果你将重复值设置为负值,纹理将被镜像。

当你更改repeat属性时,Three.js 会自动更新纹理并使用这个新设置进行渲染。如果你从THREE.RepeatWrapping更改为THREE.ClampToEdgeWrapping,你必须显式地使用mesh.material.map.needsUpdate = true;来更新纹理:

图 10.28 – 球体上的边缘包裹

图 10.28 – 球体上的边缘包裹

到目前为止,我们只为纹理使用了静态图像。然而,Three.js 也有使用 HTML5 画布作为纹理的选项。

将渲染输出到画布并用作纹理

在本节中,我们将查看两个不同的示例。首先,我们将查看如何使用 canvas 创建一个简单的纹理并将其应用于网格;之后,我们将更进一步,创建一个可以用作凹凸映射的 canvas,使用随机生成的图案。

使用 canvas 作为颜色映射

在这个第一个例子中,我们将渲染一个分形到 HTML Canvas 元素上,并将其用作我们的网格的颜色映射。以下截图显示了此示例(texture-canvas-as-color-map.html):

图 10.29 – 使用 HTML canvas 作为纹理

图 10.29 – 使用 HTML canvas 作为纹理

首先,我们将查看渲染分形所需的代码:

import Mandelbrot from 'mandelbrot-canvas'
...
const div = document.createElement('div')
div.id = 'mandelbrot'
div.style = 'position: absolute'
document.body.append(div)
const mandelbrot = new Mandelbrot(document.getElementById('mandelbrot'), {
  height: 300,
  width: 300,
  magnification: 100
})
mandelbrot.render()

我们不会过多地深入细节,但这个库需要一个 div 元素作为输入,并在其中创建一个 canvas 元素。前面的代码将渲染分形,正如您在前面的截图中所看到的。接下来,我们需要将这个 canvas 分配给我们的材质的 map 属性:

const material = new THREE.MeshPhongMaterial({
  color: 0xffffff,
  map: new THREE.Texture(document.querySelector
    ('#mandelbrot canvas'))
})
material.map.needsUpdate = true

在这里,我们只是创建一个新的 THREE.Texture 并传入 canvas 元素的引用。我们唯一需要做的是将 material.map.needsUpdate 设置为 true,这将触发 Three.js 从 canvas 元素获取最新信息,此时我们将看到它应用于网格。

我们当然可以使用这个相同的概念来处理我们迄今为止看到的所有不同类型的映射。在下一个示例中,我们将使用 canvas 作为凹凸映射。

使用 canvas 作为凹凸映射

如您在本章前面所见,我们可以使用凹凸映射给我们的模型添加高度。在这个映射中,像素的强度越高,皱纹就越高。由于凹凸映射只是一个简单的黑白图像,没有什么阻止我们在 canvas 上创建它,并使用该 canvas 作为凹凸映射的输入。

在以下示例中,我们将使用 canvas 生成基于 Perlin 噪声的灰度图像,并将其用作我们应用于立方体的凹凸映射的输入。请参阅 texture-canvas-as-bump-map.html 示例。以下截图显示了此示例:

图 10.30 – 使用 HTML canvas 作为凹凸映射

图 10.30 – 使用 HTML canvas 作为凹凸映射

这个方法基本上和我们在之前的 canvas 示例中看到的方法相同。我们需要创建一个 canvas 元素,并在其中填充一些噪声。要做到这一点,我们必须使用 Perlin 噪声。Perlin 噪声生成一个非常自然的外观纹理,正如您在前面的截图中所看到的。有关 Perlin 噪声和其他噪声生成器的更多信息,请参阅此处:thebookofshaders.com/11/。完成此操作的代码如下所示:

import generator from 'perlin'
var canvas = document.createElement('canvas')
canvas.className = 'myClass'
const size = 512
canvas.style = 'position:absolute;'
canvas.width = size
canvas.height = size
document.body.append(canvas)
const ctx = canvas.getContext('2d')
for (var x = 0; x < size; x++) {
  for (var y = 0; y < size; y++) {
    var base = new THREE.Color(0xffffff)
    var value = (generator.noise.perlin2(x / 8, y / 8) + 1) / 2
    base.multiplyScalar(value)
    ctx.fillStyle = '#' + base.getHexString()
    ctx.fillRect(x, y, 1, 1)
  }
}

我们使用generator.noise.perlin2函数根据canvas元素的xy坐标创建一个 0 到 1 之间的值。这个值用于在canvas元素上绘制单个像素。对所有像素执行此操作将创建你可以在前一个截图的左上角看到的随机地图。这个地图然后可以用作凹凸贴图:

const material = new THREE.MeshPhongMaterial({
  color: 0xffffff,
  bumpMap: new THREE.Texture(canvas)
})
material.bumpMap.needsUpdate = true

使用 THREE.DataTexture 创建动态纹理

在这个例子中,我们使用 HTML canvas元素渲染了 Perlin 噪声。Three.js 还提供了一个动态创建纹理的替代方法:你可以创建一个THREE.DataTexture纹理,其中你可以传递一个Uint8Array,你可以直接设置 RGB 值。有关如何使用THREE.DataTexture的更多信息,请参阅此处:threejs.org/docs/#api/en/textures/DataTexture

我们用于纹理的最终输入是另一个 HTML 元素:HTML5 视频元素。

使用视频输出作为纹理

如果你阅读了关于将渲染到 canvas 的前一节,你可能已经考虑过将视频渲染到 canvas 上,并使用它作为纹理的输入。这是其中一种方法,但 Three.js 已经直接支持使用 HTML5 视频元素(通过 WebGL)。查看texture-canvas-as-video-map.html

图 10.31 – 使用 HTML 视频作为纹理

图 10.31 – 使用 HTML 视频作为纹理

将视频作为纹理的输入非常简单,就像使用 canvas 元素一样。首先,我们需要一个视频元素来播放视频:

const videoString = `
<video
  id="video"
  src="img/Big_Buck_Bunny_small.ogv"
  controls="true"
</video>
`
const div = document.createElement('div')
div.style = 'position: absolute'
document.body.append(div)
div.innerHTML = videoString

通过直接将 HTML 字符串设置到div元素的innerHTML属性中,创建了一个基本的 HTML5 video元素。虽然这对于测试来说效果很好,但框架和库通常提供更好的选项。接下来,我们可以配置 Three.js 使用视频作为纹理的输入,如下所示:

const video = document.getElementById('video')
const texture = new THREE.VideoTexture(video)
const material = new THREE.MeshStandardMaterial({
  color: 0xffffff,
  map: texture
})

结果可以在texture-canvas-as-video-map.html示例中看到。

摘要

这样,我们就完成了关于纹理的这一章。正如你所看到的,Three.js 中有许多纹理可供使用,每种纹理都有不同的用途。你可以使用任何 PNG、JPG、GIF、TGA、DDS、PVR、TGA、KTX、EXR 或 RGBE 格式的图像作为纹理。加载这些图像是异步进行的,所以记得在加载纹理时使用渲染循环或添加回调。有了不同类型的纹理,你可以从低多边形模型创建出外观出色的对象。

使用 Three.js,创建动态纹理也很容易,可以使用 HTML5 canvas元素或video元素——只需定义一个以这些元素为输入的纹理,并在需要更新纹理时将needsUpdate属性设置为true

随着本章的结束,我们基本上已经涵盖了 Three.js 的所有重要概念。然而,我们还没有探讨 Three.js 提供的一个有趣特性:后处理。通过后处理,你可以在场景渲染后添加效果。例如,你可以模糊或着色你的场景,或者使用扫描线添加类似电视的效果。在第十一章渲染后处理中,我们将探讨后处理以及如何将其应用于你的场景。

第四部分:后处理、物理和声音

在本节的最后一部分,我们将探讨一些更高级的主题。我们将解释如何设置一个后处理管道,它可以用来向最终渲染的场景添加不同类型的特效。我们还将介绍 Rapier 物理引擎,并解释如何将 Three.js 和 Blender 结合使用。本部分结束时,我们将提供关于如何将 Three.js 与 React、TypeScript 和 Web-XR 标准结合使用的信息。

在本部分,有以下章节:

  • 第十一章渲染后处理

  • 第十二章为你的场景添加物理和声音

  • 第十三章与 Blender 和 Three.js 一起工作

  • 第十四章Three.js 与 React、TypeScript 和 Web-XR 结合使用

第十一章:渲染后处理

在本章中,我们将探讨 Three.js 的一个主要特性,我们之前尚未涉及:渲染后处理。通过渲染后处理,你可以在场景渲染后添加额外的效果。例如,你可以添加一个使场景看起来像是在旧电视上显示的效果,或者你可以添加模糊和光晕效果。

本章我们将讨论的主要内容包括以下几项:

  • 为后处理设置 Three.js

  • 由 Three.js 提供的某些基本后处理步骤,例如BloomPassFilmPass

  • 使用遮罩将效果应用于场景的一部分

  • 使用ShaderPass添加更多基本的后处理效果,例如棕褐色滤镜、镜像效果和色彩调整

  • 使用ShaderPass实现各种模糊效果和更高级的过滤器

  • 通过编写简单的着色器创建自定义的后处理效果

第一章,“使用 Three.js 创建你的第一个 3D 场景”,在“介绍 requestAnimationFrame”部分,我们设置了一个渲染循环,我们在整本书中使用了这个循环来渲染和动画化我们的场景。为了后处理,我们需要对这个设置进行一些修改,以便允许 Three.js 对最终渲染进行后处理。在第一部分,我们将探讨如何做到这一点。

为后处理设置 Three.js

要为后处理设置 Three.js,我们必须对我们的当前设置进行一些修改,如下所示:

  1. 创建EffectComposer,它可以用来添加后处理步骤。

  2. 配置EffectComposer以便它可以渲染我们的场景并应用任何额外的后处理步骤。

  3. 在渲染循环中,使用EffectComposer来渲染场景,应用配置的后处理步骤,并显示输出。

和往常一样,我们将展示一个你可以用来实验和适应你自己的目的的示例。本章的第一个示例可以从basic-setup.html访问。你可以使用右上角的菜单来修改此示例中使用的后处理步骤的属性。在此示例中,我们将渲染来自第九章,“动画和移动摄像机”,并添加 RGB 偏移效果,如下所示:

图 11.1 – 使用后处理步骤渲染

图 11.1 – 使用后处理步骤渲染

此效果是通过使用ShaderPass以及EffectComposer在场景渲染后添加的。在屏幕右侧的菜单中,你可以配置此效果,并启用DotScreenShader效果。

在接下来的几节中,我们将解释上一列表中的各个步骤。

创建 THREE.EffectComposer

要让EffectComposer工作,我们首先需要可以与之一起使用的效果。Three.js 附带了许多效果和着色器,您可以直接使用。在本章中,我们将展示其中大部分,但为了全面了解,请查看以下 GitHub 上的两个目录:

)

)

要在您的场景中使用这些效果,您需要导入它们:

import { EffectComposer } from 
  'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 
  'three/examples/jsm/postprocessing/RenderPass.js'
import { ShaderPass } from 
  'three/examples/jsm/postprocessing/ShaderPass.js'
import { BloomPass } from 
  'three/examples/jsm/postprocessing/BloomPass.js'
import { GlitchPass } from 
  'three/examples/jsm/postprocessing/GlitchPass.js'
import { RGBShiftShader } from 
  'three/examples/jsm/shaders/RGBShiftShader.js'
import { DotScreenShader } from 
  'three/examples/jsm/shaders/DotScreenShader.js'
import { CopyShader } from 
  'three/examples/jsm/shaders/CopyShader.js'

在前面的代码块中,我们导入了主要的EffectComposer以及不同数量的后处理流程和着色器,我们可以与这个EffectComposer一起使用。一旦我们有了这些,设置EffectComposer就像这样:

const composer = new EffectComposer(renderer)

如您所见,效果合成器接受的唯一参数是renderer。接下来,我们将向这个合成器添加各种流程。

配置 THREE.EffectComposer 进行后处理

每个流程都是按照它被添加到THREE.EffectComposer中的顺序执行的。我们首先添加的是RenderPass。这个流程使用提供的相机渲染场景,但还没有将其输出到屏幕上:

const renderPass = new RenderPass(scene, camera); 
composer.addPass(renderPass);

使用addPass函数,我们将RenderPass添加到EffectComposer。下一步是添加另一个流程,它将使用RenderPass的结果作为输入,应用其转换,并将结果输出到屏幕。并非所有可用的流程都允许这样做,但我们在本例中使用的流程可以:

const effect1 = new ShaderPass(DotScreenShader)
effect1.uniforms['scale'].value = 10
effect1.enabled = false
const effect2 = new ShaderPass(RGBShiftShader)
effect2.uniforms['amount'].value = 0.015
effect2.enabled = false
const composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))
composer.addPass(effect1)
composer.addPass(effect2)

在这个例子中,我们向composer添加了两个效果。首先,场景使用RenderPass进行渲染,然后应用DotScreenShader,最后应用RGBShiftShader

现在我们需要做的就是更新渲染循环,以便使用EffectComposer而不是通过正常的WebGLRenderer进行渲染。

更新渲染循环

我们只需要对我们的渲染循环进行小小的修改,使用合成器而不是THREE.WebGLRenderer

const render = () => { 
requestAnimationFrame(render); 
composer.render();
}

我们所做的唯一修改是移除renderer.render(scene, camera)并将其替换为composer.render()。这将调用EffectComposer上的渲染函数,它反过来使用传入的THREE.WebGLRenderer,结果是我们在屏幕上看到输出:

图 11.2 – 使用多个后处理流程渲染的图像

图 11.2 – 使用多个后处理流程渲染的图像

在应用渲染流程后使用控件

您仍然可以使用正常的控件在场景中移动。本章中您将看到的全部效果都是在场景渲染后应用的。使用这个基本设置,我们将在下一节中查看可用的后处理流程。

后处理流程

Three.js 附带了一些可以直接与THREE.EffectComposer一起使用的后处理流程。

使用简单的 GUI 进行实验

本章中展示的大多数着色器和通道都可以配置。当你想要应用一个自己的时,通常最简单的方法是添加一个简单的用户界面,允许你调整属性。这样,你可以看到针对特定场景的良好设置是什么。

以下列表显示了 Three.js 中可用的所有后处理通道:

  • AdaptiveToneMappingPass: 这个渲染通道根据场景中可用的光量调整场景的亮度。

  • BloomPass: 这是一个效果,使得较亮区域渗透到较暗区域。这模拟了相机被极其明亮的光线淹没的效果。

  • BokehPass: 这会给场景添加一个散景效果。有了散景效果,场景的前景是清晰的,而其余部分则模糊。

  • ClearPass: 这个溢出通道清除当前的纹理缓冲区。

  • CubeTexturePass: 这可以用来在场景中渲染天空盒。

  • DotScreenPass: 这会在屏幕上应用一层黑色点,代表原始图像。

  • FilmPass: 这通过应用扫描线和扭曲来模拟电视屏幕。

  • GlitchPass: 这在随机的时间间隔内在屏幕上显示电子故障。

  • HalfTonePass: 这会给场景添加半色调效果。有了半色调效果,场景被渲染为各种大小的一组彩色符号(圆形、方形等)。

  • LUTPass: 使用 LUTPass,你可以在渲染后对场景应用颜色校正步骤(本章中未展示)。

  • MaskPass: 这允许你将掩码应用于当前图像。后续通道仅应用于掩码区域。

  • OutlinePass: 这会渲染场景中物体的轮廓。

  • RenderPass: 这根据提供的场景和相机渲染场景。

  • SAOPass: 这提供运行时环境遮挡。

  • SMAAPass: 这会给场景添加抗锯齿效果。

  • SSAARenderPass: 这给场景添加抗锯齿。

  • SSAOPass: 这提供了一种执行运行时环境遮挡的替代方法。

  • SSRPass: 这个通道允许你创建反射物体。

  • SavePass: 当这个通道执行时,它会复制当前渲染步骤,你可以稍后使用。在实际应用中,这个通道并不那么有用,我们不会在我们的任何示例中使用它。

  • ShaderPass: 这允许你为高级或定制的后处理通道传递自定义着色器。

  • TAARenderPass: 这会给场景添加抗锯齿效果。

  • TexturePass: 这会将合成器的当前状态存储在一个纹理中,你可以将其用作其他 EffectComposer 实例的输入。

  • UnrealBloomPass: 这与 THREE.BloomPass 相同,但效果类似于在 Unreal 3D 引擎中使用的效果。

让我们从一些简单的通道开始。

简单的后处理通道

对于简单的通道,我们将查看 FilmPassBloomPassDotScreenPass 可以做什么。对于这些通道,有一个示例(multi-passes.html)允许您实验这些通道并查看它们如何以不同的方式影响原始输出。以下截图显示了示例:

图 11.3 – 应用到场景中的三个简单通道

图 11.3 – 应用到场景中的三个简单通道

在此示例中,您可以同时看到四个场景,并且在每个场景中,都添加了不同的后处理通道。左上角显示的是 BloomPass,右下角显示的是 DotScreenPass,左下角显示的是 FilmPass。右上角显示的是原始渲染。

在此示例中,我们还使用了 THREE.ShaderPassTHREE.TexturePass 来重用原始渲染的输出作为其他三个场景的输入。这样,我们只需要渲染场景一次。因此,在我们查看单个通道之前,让我们看看这两个通道,如下所示:

const effectCopy = new ShaderPass(CopyShader)
const renderedSceneComposer = new EffectComposer(renderer)
renderedSceneComposer.addPass(new RenderPass(scene, 
  camera))
renderedSceneComposer.addPass(new ShaderPass
  (GammaCorrectionShader))
renderedSceneComposer.addPass(effectCopy)
renderedSceneComposer.renderToScreen = false
const texturePass = new TexturePass
  (renderedSceneComposer.renderTarget2.texture)

在此代码片段中,我们设置了 EffectComposer,它将输出默认场景(右上角的一个)。此合成器有三个通道:

  • RenderPass:此通道渲染场景。

  • 带有 GammaCorrectionShaderShaderPass:确保输出的颜色是正确的。如果在应用效果后,场景的颜色看起来不正确,此着色器将对其进行纠正。

  • 带有 CopyShaderShaderPass:渲染输出(如果我们将 renderToScreen 属性设置为 true,则不会对屏幕进行任何进一步的后处理)。

如果您查看示例,您可以看到我们展示了相同的场景四次,但每次都应用了不同的效果。我们也可以通过使用 RenderPass 四次从头开始渲染场景,但这会有些浪费,因为我们可以直接重用第一个合成器的输出。为此,我们创建 TexturePass 并传入 composer.renderTarget2.texture 的值。此属性包含作为纹理渲染的场景,我们可以将其传递到 TexturePass。现在,我们可以使用 texturePass 变量作为其他合成器的输入,而无需从头开始渲染场景。让我们首先看看 FilmPass 以及我们如何使用 TexturePass 的结果作为输入。

使用 THREE.FilmPass 创建类似电视的效果

要创建 FilmPass,我们使用以下代码片段:

const filmpass = new FilmPass()
const filmpassComposer = new EffectComposer(renderer)
filmpassComposer.addPass(texturePass)
filmpassComposer.addPass(filmpass)

使用 TexturePass 的唯一步骤是将它作为我们合成器中的第一个通道添加。接下来,我们只需添加 FilmPass,效果就会应用。FilmPass 可以接受四个额外的参数,如下所示列表:

  • noiseIntensity:此属性允许您控制场景看起来有多粗糙。

  • scanlinesIntensity: FilmPass 为场景添加了若干扫描线(见 scanLinesCount)。使用此属性,您可以定义这些扫描线显示的突出程度。

  • scanLinesCount:可以通过此属性控制显示的扫描线数量。

  • grayscale:如果将其设置为 true,输出将被转换为灰度。

实际上,您有两种方法可以传递这些参数。在这个例子中,我们将它们作为构造函数的参数传递,但您也可以直接设置它们,如下所示:

effectFilm.uniforms.grayscale.value = controls.grayscale; 
effectFilm.uniforms.nIntensity.value = controls.
  noiseIntensity; 
effectFilm.uniforms.sIntensity.value = controls.
  scanlinesIntensity; 
effectFilm.uniforms.sCount.value = controls.scanlinesCount;

在这种方法中,我们使用 uniforms 属性,它直接与 WebGL 通信。在 使用 THREE.ShaderPass 创建自定义效果 部分,当我们讨论创建自定义着色器时,我们将更深入地了解 uniforms;现在,您只需要知道,通过这种方式,您可以更新后处理传递和着色器的配置,并直接查看结果。

此次传递的结果显示在以下图中:

图 11.4 – FilmPass 提供的电影效果

图 11.4 – FilmPass 提供的电影效果

下一个效果是 bloom 效果,您可以在 图 11.3 的左上角看到。

使用 THREE.BloomPass 为场景添加 bloom 效果

您在左上角看到的效果被称为 bloom 效果。当您应用 bloom 效果时,场景中的明亮区域将被突出显示并渗透到较暗的区域。创建 BloomPass 的代码如下:

const bloomPass = new BloomPass()
const effectCopy = new ShaderPass(CopyShader)
bloomPassComposer = new EffectComposer(renderer)
bloomPassComposer.addPass(texturePass)
bloomPassComposer.addPass(bloomPass)
bloomPassComposer.addPass(effectCopy)

如果您将其与 EffectComposer 进行比较,后者我们与 FilmPass 一起使用,您会注意到我们添加了一个额外的传递,effectCopy。这一步不会添加任何特殊效果,只是将最后一步的输出复制到屏幕上。我们需要添加这一步,因为 BloomPass 不会直接渲染到屏幕上。

以下表格列出了您可以在 BloomPass 上设置的属性:

  • strength:这是 bloom 效果的强度。这个值越高,越亮的区域越亮,并且它们将更多地渗透到较暗的区域。

  • kernelSize:这是内核的大小。这是在单步中模糊的区域的大小。如果您将其设置得更高,将包括更多像素以确定特定点的效果。

  • sigma:使用 sigma 属性,您可以控制 bloom 效果的锐度。值越高,bloom 效果看起来越模糊。

  • resolutionresolution 属性定义了 bloom 效果创建的精确度。如果您将其设置得太低,结果看起来会像块状。

要更好地理解这些属性,最好的方法是通过使用上述示例 multi-passes.html 来实验它们。以下截图显示了具有高 sigma 大小和高强度的 bloom 效果:

图 11.5 – 使用 BloomPass 的 bloom 效果

图 11.5 – 使用 BloomPass 的 bloom 效果

我们接下来要查看的下一个简单效果是 DotScreenPass 效果。

将场景输出为点集

使用 DotScreenPass 与使用 BloomPass 非常相似。我们刚刚看到了 BloomPass 的作用。现在让我们看看 DotScreenPass 的代码:

const dotScreenPass = new DotScreenPass()
const dotScreenPassComposer = new EffectComposer(renderer)
dotScreenPassComposer.addPass(texturePass)
dotScreenPassComposer.addPass(dotScreenPass)

使用这个效果,我们不需要 effectCopy 将结果输出到屏幕。

DotScreenPass 也可以配置多个属性,如下所示:

  • center:通过 center 属性,你可以微调点的偏移方式。

  • angle:点以某种方式对齐。通过 angle 属性,你可以改变这种对齐方式。

  • scale:通过这个,我们可以设置要使用的点的尺寸。缩放值越低,点就越大。

对其他着色器适用的内容也适用于这个着色器。通过实验,更容易获得正确的设置,如下面的图所示:

图 11.6 – 使用 DotScreenPass 的点阵效果

图 11.6 – 使用 DotScreenPass 的点阵效果

在我们继续下一组简单的着色器之前,我们首先看看我们是如何在同一屏幕上渲染多个场景的。

在同一屏幕上显示多个渲染器的输出

本节不会详细介绍如何使用后处理效果,但会解释如何在同一屏幕上获取所有四个 EffectComposer 实例的输出。首先,让我们看看这个示例中使用的渲染循环:

const width = window.innerWidth || 2
const height = window.innerHeight || 2
const halfWidth = width / 2
const halfHeight = height / 2
const render = () => {
  renderer.autoClear = false
  renderer.clear()
  renderedSceneComposer.render()
  renderer.setViewport(0, 0, halfWidth, halfHeight)
  filmpassComposer.render()
  renderer.setViewport(halfWidth, 0, halfWidth, halfHeight)
  dotScreenPassComposer.render()
  renderer.setViewport(0, halfHeight, halfWidth, 
    halfHeight)
  bloomPassComposer.render()
  renderer.setViewport(halfWidth, halfHeight, halfWidth, 
    halfHeight)
  copyComposer.render()
  requestAnimationFrame(() => render())
}

首先要注意的是,我们将 renderer.autoClear 属性设置为 false,然后在渲染循环中显式调用 clear() 函数。如果我们每次在作曲家上调用 render() 函数时都不这样做,屏幕上之前渲染的部分将被清除。使用这种方法,我们只在渲染循环的开始清除一切。

为了避免所有我们的作曲家都在同一空间中渲染,我们将渲染器(我们的作曲家使用的)的 viewport 函数设置为屏幕的不同部分。这个函数接受四个参数:xywidthheight。正如你在代码示例中所看到的,我们使用这个函数将屏幕分成四个区域,并让作曲家将渲染输出到它们各自的部分。请注意,如果你愿意,你也可以使用这种方法与多个 scenecameraWebGLRenderer 实例一起使用。在这种设置下,渲染循环将渲染四个 EffectComposer 对象的各自部分。让我们快速看看另外几个通过:

额外的简单通过

如果你打开浏览器中的 multi-passes-2.html 示例,你会看到一些额外的通过动作:

图 11.7 – 另一组四个通过

图 11.7 – 另一组四个通过

我们在这里不会过多地详细介绍,因为这些通过与前面章节中的通过配置方式相同。在这个例子中,你可以看到以下内容:

  • 在左下角,你可以看到 OutlinePass。轮廓通过可以用来为 THREE.Mesh 对象绘制轮廓。

  • 在右下角,显示了 GlitchPass。正如其名所示,这个通道提供了一个技术渲染故障效果。

  • 在左上角,显示了 UnrealBloom 效果。

  • 在右上角,使用 HalftonePass 将渲染转换为一系列点。

与本章中的所有示例一样,你可以通过使用右侧的菜单来配置这些通道的各个属性。

要正确查看 OutlinePass,你可以将场景背景设置为黑色并稍微放大一些:

图 11.8 – 显示场景轮廓的轮廓通道

图 11.8 – 显示场景轮廓的轮廓通道

到目前为止,我们看到了简单的效果,在下一节中,我们将看看如何使用蒙版将效果应用于屏幕的一部分。

这可能听起来很复杂,但实际上完成起来非常简单。首先,让我们看看 masks.html 示例中我们想要达到的结果。以下截图显示了这些步骤的结果:

在前面的示例中,我们将后处理通道应用于整个屏幕。然而,Three.js 也有能力仅将通道应用于特定区域。在本节中,我们将执行以下步骤:

  1. 我们需要做的第一件事是设置我们将要渲染的各种场景:

  2. 创建 EffectComposer,将这些三个场景渲染成一张单独的图片。

  3. 创建一个包含类似火星的球体的场景。

  4. 这可能听起来很复杂,但实际上完成起来非常简单。首先,让我们看看 masks.html 示例中我们想要达到的结果。以下截图显示了这些步骤的结果:

  5. 将着色效果应用于渲染为火星的球体。

  6. 创建一个作为背景图像的场景。

图 11.9 – 使用蒙版将效果应用于屏幕的一部分

将棕褐色效果应用于渲染为地球的球体。

图 11.9 – 使用蒙版将效果应用于屏幕的一部分

创建一个包含类似地球的球体的场景。

const sceneEarth = new THREE.Scene()
const sceneMars = new THREE.Scene()
const sceneBG = new THREE.Scene()

要创建地球和火星球体,我们只需创建具有正确材质和纹理的球体并将它们添加到特定的场景中。对于背景场景,我们加载一个纹理并将其设置为 sceneBG 的背景。这在上面的代码中显示(addEarthaddMars 只是辅助函数,用于使代码更清晰;它们从 THREE.SphereGeometry 创建一个简单的 THREE.Mesh,创建一些灯光并将它们全部添加到 THREE.Scene):

sceneBG.background = new THREE.TextureLoader().load
 ('/assets/textures/bg/starry-deep-outer-space-galaxy.jpg')
const earthAndLight = addEarth(sceneEarth)
sceneEarth.translateX(-16)
sceneEarth.scale.set(1.2, 1.2, 1.2)
const marsAndLight = addMars(sceneMars)
sceneMars.translateX(12)
sceneMars.translateY(6)
sceneMars.scale.set(0.2, 0.2, 0.2)

在这个例子中,我们使用场景的 background 属性添加星空背景。还有另一种创建背景的方法。我们可以使用 THREE.OrthographicCamera。使用 THREE.OrthographicCamera,渲染对象的尺寸在它靠近或远离相机时不会改变,因此,通过将 THREE.PlaneGeometry 对象直接放置在 THREE.OrthographicCamera 前面,我们也可以创建一个背景。

现在我们已经拥有了三个场景,我们可以开始设置我们的通道和 EffectComposer。让我们先看看完整的通道链,然后我们将查看单个通道:

var composer = new EffectComposer(renderer)
composer.renderTarget1.stencilBuffer = true
composer.renderTarget2.stencilBuffer = true
composer.addPass(bgRenderPass)
composer.addPass(earthRenderPass)
composer.addPass(marsRenderPass)
composer.addPass(marsMask)
composer.addPass(effectColorify)
composer.addPass(clearMask)
composer.addPass(earthMask)
composer.addPass(effectSepia)
composer.addPass(clearMask)
composer.addPass(effectCopy)

要与遮罩一起工作,我们需要以稍微不同的方式创建 EffectComposer。我们需要将内部使用的渲染目标的 stencilBuffer 属性设置为 true。一个模板缓冲区,一种特殊的缓冲区,用于限制渲染区域。因此,通过启用模板缓冲区,我们可以使用我们的遮罩。让我们看看添加的前三个过程。这三个过程按照以下方式渲染背景、地球场景和火星场景:

const bgRenderPass = new RenderPass(sceneBG, camera)
const earthRenderPass = new RenderPass(sceneEarth, camera)
earthRenderPass.clear = false
const marsRenderPass = new RenderPass(sceneMars, camera)
marsRenderPass.clear = false

这里没有什么新的内容,只是我们将其中两个过程的 clear 属性设置为 false。如果我们不这样做,我们只能看到 marsRenderPass 渲染的输出,因为它会在开始渲染之前清除一切。

如果你回顾一下 EffectComposer 的代码,接下来的三个过程是 marsMaskeffectColorifyclearMask。首先,我们将看看这三个过程是如何定义的:

const marsMask = new MaskPass(sceneMars, camera)
const effectColorify = new ShaderPass(ColorifyShader)
effectColorify.uniforms['color'].value.setRGB(0.5, 0.5, 1)
const clearMask = new ClearMaskPass()

这三个过程中的第一个是 MaskPass。当创建一个 MaskPass 对象时,你传递一个场景和一个相机,就像你为 RenderPass 做的那样。一个 MaskPass 对象将内部渲染这个场景,但不会在屏幕上显示,而是使用渲染的内部场景来创建一个遮罩。当一个 MaskPass 对象被添加到 EffectComposer 中时,所有后续的过程将只应用于由 MaskPass 定义的遮罩,直到遇到 ClearMaskPass 步骤。在这个例子中,这意味着添加蓝色光芒的 effectColorify 过程只应用于在 sceneMars 中渲染的物体。

我们使用相同的方法将棕褐色滤镜应用到地球物体上。我们首先基于地球场景创建一个遮罩,并在 EffectComposer 中使用这个遮罩。在使用 MaskPass 之后,我们添加我们想要应用的效果(在这种情况下是 effectSepia),完成之后,我们添加 ClearMaskPass 来再次移除遮罩。

对于这个特定的 EffectComposer 的最后一步,我们已经见过。我们需要将最终结果复制到屏幕上,为此我们再次使用 effectCopy 过程。使用这种设置,我们可以将我们希望成为整个屏幕一部分的效果应用上。但请注意,如果火星场景和地球场景重叠,这些效果将只应用于渲染图像的一部分。

这两个效果都将应用到屏幕的相应部分:

图 11.10 – 当遮罩重叠时,应用两个效果

图 11.10 – 当遮罩重叠时,应用两个效果

在使用 MaskPass 时有一个有趣的额外属性,那就是 inverse 属性。如果这个属性设置为 true,遮罩将被反转。换句话说,效果将应用于除了传递给 MaskPass 的场景之外的所有内容。这在上面的屏幕截图中有显示,我们将 earthMaskinverse 属性设置为 true

图 11.11 – 当遮罩重叠时,应用两个效果

图 11.11 – 当遮罩重叠时,应用两个效果

在我们讨论 ShaderPass 之前,我们将查看两个提供更高级效果的过程:BokehPassSSAOPass

高级过程 – 模糊效果

使用 BokehPass,您可以为场景添加模糊效果。在模糊效果中,场景中只有一部分是清晰的,其余部分看起来是模糊的。要查看此效果的实际应用,您可以打开 bokeh.html 示例:

图 11.12 – 未聚焦的模糊效果

图 11.12 – 未聚焦的模糊效果

当您打开它时,最初,整个场景看起来是模糊的。通过 aperture 属性来确定应该聚焦的区域的大小。通过调整焦点,可以使前景中的立方体组清晰,如下所示:

图 11.13 – 模糊效果聚焦于第一组立方体

图 11.13 – 模糊效果聚焦于第一组立方体

或者,如果我们进一步滑动焦点,我们还可以聚焦于红色立方体:

图 11.14 – 模糊效果聚焦于第二组立方体

图 11.14 – 模糊效果聚焦于第二组立方体

如果我们将焦点进一步滑动,我们还可以聚焦于场景远端的绿色立方体组:

图 11.15 – 模糊效果聚焦于第三组立方体

图 11.15 – 模糊效果聚焦于第三组立方体

BokehPass 可以像我们之前看到的其他过程一样使用:

const params = { 
   focus: 10,
   aspect: camera.aspect, 
   aperture: 0.0002,
   maxblur: 1
};
const renderPass = new RenderPass(scene, camera);
const bokehPass = new BokehPass(scene, camera, params) 
bokehPass.renderToScreen = true;
const composer = new EffectComposer(renderer); 
composer.addPass(renderPass); 
composer.addPass(bokehPass);

实现所需的结果可能需要调整一些属性。

高级过程 – 环境遮挡

第十章 加载和使用纹理 中,我们讨论了在材质上使用预烘焙的 aoMap,也可以在 EffectComposer 上使用过程来获得相同的效果。如果您打开 ambient-occlusion.html 示例,您将看到使用 SSAOPass 的结果:

图 11.16 – 应用了 AO 过滤器的场景

图 11.16 – 应用了景深效果

没有应用环境遮挡滤镜的类似场景看起来非常平坦,如下所示:

图 11.17 – 没有应用 AO 过滤器的相同场景

图 11.17 – 没有应用 AO 过滤器的相同场景

但是请注意,如果您使用此功能,您必须注意应用程序的整体性能,因为这是一个非常占用 GPU 的过程。

到目前为止,我们一直使用 Three.js 提供的标准过程来实现效果。Three.js 还提供了 THREE.ShaderPass,它可以用于自定义效果,并附带大量您可以使用的着色器,您可以进行实验。

使用 THREE.ShaderPass 实现自定义效果

使用 THREE.ShaderPass,我们可以通过传递自定义着色器来为场景应用大量额外的效果。Three.js 附带了一套可以与 THREE.ShaderPass 一起使用的着色器。它们将在本节中列出。我们将本节分为三个部分。

第一组涉及简单的着色器。所有这些着色器都可以通过打开shaderpass-simple.html示例来查看和配置:

  • BleachBypassShader: 这创建了一个漂白绕过效果。使用此效果,将在图像上应用类似银色的叠加。

  • BlendShader: 这不是一个作为单个后期处理步骤应用的着色器,但它允许你混合两个纹理。例如,你可以使用这个着色器将一个场景的渲染平滑地融合到另一个场景中(在shaderpass-simple.html中未显示)。

  • BrightnessContrastShader: 这允许你改变图像的亮度和对比度。

  • ColorifyShader: 这在屏幕上应用了一个颜色叠加。我们已经在遮罩示例中见过这个着色器。

  • ColorCorrectionShader: 使用这个着色器,你可以改变颜色分布。

  • GammaCorrectionShader: 这对渲染的场景应用伽玛校正。它使用一个固定的伽玛因子 2。请注意,你也可以通过使用gammaFactorgammaInputgammaOutput属性,在THREE.WebGLRenderer上直接设置伽玛校正。

  • HueSaturationShader: 这允许你改变颜色的色调和饱和度。

  • KaleidoShader: 这为场景添加了一个万花筒效果,该效果在场景中心提供径向反射。

  • LuminosityShaderLuminostyHighPassShader: 这提供了亮度效果,其中场景的亮度被显示出来。

  • MirrorShader: 这为屏幕的一部分创建了一个镜像效果。

  • PixelShader: 这创建了一个像素化效果。

  • RGBShiftShader: 这个着色器将颜色的红、绿和蓝分量分离。

  • SepiaShader: 这在屏幕上创建了一种类似棕褐色效果。

  • SobelOperatorShader: 这提供了边缘检测。

  • VignetteShader: 这应用了一个晕影效果。此效果在图像中心显示暗色边缘。

接下来,我们将查看提供一些模糊相关效果的着色器。这些效果可以通过shaderpass-blurs.html示例进行实验:

  • HorizontalBlurShaderVerticalBlurShader: 这些着色器将模糊效果应用到整个场景上。

  • HorizontalTiltShiftShaderVerticalTiltShiftShader: 这些着色器重新创建了一个倾斜移位效果。通过倾斜移位效果,可以确保只有图像的一部分是清晰的,从而创建出类似微缩景观的场景。

  • FocusShader: 这是一个简单的着色器,它产生一个边缘模糊的中心区域。

最后,还有一些我们不会详细查看的着色器;我们只是简单地列出它们,以示完整性。这些着色器主要在内部使用,由另一个着色器或我们在本章开头讨论的着色器通道使用:

  • THREE.FXAAShader: 这个着色器在后期处理阶段应用抗锯齿效果。如果你在渲染时应用抗锯齿太昂贵,请使用此着色器。

  • THREE.ConvolutionShader: 该着色器在BloomPass渲染通道内部使用。

  • THREE.DepthLimitedBlurShader: 该着色器在SAOPass用于环境光遮蔽。

  • THREE.HalftoneShader: 该着色器在HalftonePass内部使用。

  • THREE.SAOShader: 该着色器以着色器形式提供环境光遮蔽。

  • THREE.SSAOShader: 该着色器以着色器形式提供环境光遮蔽的替代方法。

  • THREE.SMAAShader: 该着色器为渲染场景提供抗锯齿。

  • THREE.ToneMapShader: 该着色器在AdaptiveToneMappingPass内部使用。

  • UnpackDepthRGBAShader: 该着色器可用于将 RGBA 纹理中编码的深度值可视化为颜色。

如果您查看 Three.js 分发的Shaders目录,可能会注意到我们在这章中没有列出的一些其他着色器。这些着色器 – FresnelShaderOceanShaderParallaxShaderWaterRefractionShader – 不是用于后期处理的着色器,但它们应该与我们在第四章中讨论的THREE.ShaderMaterial对象一起使用,使用 Three.js 材质

我们将从几个简单的着色器开始。

简单着色器

为了实验基本着色器,我们创建了一个示例,您可以在其中尝试大多数着色器并直接在场景中看到效果。您可以在shaders.html中找到此示例。以下截图显示了其中一些效果。

BrightnessContrastShader效果如下:

图 11.18 – BrightnessContractShader 效果

图 11.18 – BrightnessContractShader 效果

SobelOperatorShader效果检测轮廓:

图 11.19 – SobelOperatorShader 效果

图 11.19 – SobelOperatorShader 效果

您可以使用KaleidoShader创建万花筒效果:

图 11.20 – KaleidoShader 效果

图 11.20 – KaleidoShader 效果

您可以使用MirrorShader镜像场景的一部分:

图 11.21 – MirrorShader 效果

图 11.21 – MirrorShader 效果

RGBShiftShader效果如下:

图 11.22 – RGBShiftShader 效果

图 11.22 – RGBShiftShader 效果

您可以使用LuminosityHighPassShader在场景中调整亮度:

图 11.23 – LuminosityHighPassShader 效果

图 11.23 – LuminosityHighPassShader 效果

要查看其他效果,请使用右侧菜单查看它们的功能以及如何配置。Three.js 还提供了一些专门用于添加模糊效果的着色器。这些着色器将在下一节中展示。

模糊着色器

在本节中,我们不会深入代码;我们只会展示各种模糊着色器的结果。你可以通过使用 shaders-blur.html 示例来实验这些着色器。展示的前两个着色器是 HorizontalBlurShaderVerticalBlurShader

图 11.24 – 水平模糊着色器和垂直模糊着色器

图 11.24 – 水平模糊着色器和垂直模糊着色器

另一种类似模糊的效果由 HorizontalTiltShiftShaderVerticalTiltShiftShader 提供。这种着色器不会模糊整个场景,而只是一个小区域。这产生了一种称为倾斜移位的效果。这通常用于从普通照片中创建类似微缩模型的效果。以下截图显示了此效果:

图 11.25 – 水平倾斜移位着色器和垂直倾斜移位着色器

图 11.25 – 水平倾斜移位着色器和垂直倾斜移位着色器

最后一种类似模糊的效果由 FocusShader 提供:

图 11.26 – 聚焦着色器

图 11.26 – 聚焦着色器

到目前为止,我们使用了 Three.js 提供的着色器。然而,你也可以为 THREE.EffectComposer 编写自己的着色器。

创建自定义后处理着色器

在本节中,你将学习如何创建一个可以在后处理中使用的自定义着色器。我们将创建两个不同的着色器。第一个将当前图像转换为灰度图像,第二个将通过减少可用的颜色数量将图像转换为 8 位图像。

顶点和片段着色器

创建顶点和片段着色器是一个非常广泛的主题。在本节中,我们只会触及这些着色器可以做什么以及它们是如何工作的表面。对于更深入的信息,你可以在 www.khronos.org/webgl/ 找到 WebGL 规范。另一个充满示例的资源是 Shadertoy,可在 www.shadertoy.comThe Book of Shaders thebookofshaders.com/ 找到。

自定义灰度着色器

要为 Three.js(以及其他 WebGL 库)创建自定义着色器,你必须创建两个组件:一个顶点着色器和一个片段着色器。顶点着色器可以用来改变单个顶点的位置,而片段着色器可以用来确定单个像素的颜色。对于后处理着色器,我们只需要实现一个片段着色器,并且可以保留 Three.js 提供的默认顶点着色器。

在查看代码之前,一个重要的问题是 GPU 支持多个着色器管线。这意味着顶点着色器可以在多个顶点上同时并行运行,片段着色器也是如此。

让我们先看看应用于我们的图像以产生灰度效果的着色器的完整源代码(custom-shader.js):

export const CustomGrayScaleShader = {
  uniforms: {
    tDiffuse: { type: 't', value: null },
    rPower: { type: 'f', value: 0.2126 },
    gPower: { type: 'f', value: 0.7152 },
    bPower: { type: 'f', value: 0.0722 }
  },
  // 0.2126 R + 0.7152 G + 0.0722 B
  // vertexshader is always the same for postprocessing 
     steps
  vertexShader: [
    'varying vec2 vUv;',
    'void main() {',
    'vUv = uv;',
    'gl_Position = projectionMatrix * modelViewMatrix * 
      vec4( position, 1.0 );',
    '}'
  ].join('\n'),
  fragmentShader: [
    // pass in our custom uniforms
    'uniform float rPower;',
    'uniform float gPower;',
    'uniform float bPower;',
    // pass in the image/texture we'll be modifying
    'uniform sampler2D tDiffuse;',
    // used to determine the correct texel we're working on
    'varying vec2 vUv;',
    // executed, in parallel, for each pixel
    'void main() {',
    // get the pixel from the texture we're working with 
       (called a texel)
    'vec4 texel = texture2D( tDiffuse, vUv );',
    // calculate the new color
    'float gray = texel.r*rPower + texel.g*gPower + 
      texel.b*bPower;',
    // return this new color
    'gl_FragColor = vec4( vec3(gray), texel.w );',
    '}'
  ].join('\n')
}

定义着色器的另一种方法

第四章中,我们展示了如何将着色器定义在独立的单独文件中。在 Three.js 中,大多数着色器遵循前面代码片段中看到的结构。这两种方法都可以用来定义着色器的代码。

如前述代码块所示,这并不是 JavaScript。当你编写着色器时,你使用的是OpenGL 着色语言GLSL),它看起来很像 C 编程语言。有关 GLSL 的更多信息,请参阅www.khronos.org/opengles/sdk/docs/manglsl/

首先,让我们看看顶点着色器:

  vertexShader: [
    'varying vec2 vUv;',
    'void main() {',
    'vUv = uv;',
    'gl_Position = projectionMatrix * modelViewMatrix * 
      vec4( position, 1.0 );',
    '}'
  ].join('\n'),

对于后期处理,这个着色器实际上并不需要做任何事情。前面的代码是 Three.js 实现顶点着色器的标准方式。它使用projectionMatrix,这是从相机来的投影,以及modelViewMatrix,它将对象的位置映射到世界位置,以确定在屏幕上渲染顶点的位置。对于后期处理,这段代码中唯一有趣的事情是uv值,它指示从纹理中读取哪个 texel,通过varying vec2 vUv变量传递到片段着色器。

这可以用来在片段着色器中修改像素。现在,让我们来看看片段着色器,看看代码在做什么。我们将从以下变量声明开始:

  'uniform float rPower;',
  'uniform float gPower;',
  'uniform float bPower;',
  'uniform sampler2D tDiffuse;',
  'varying vec2 vUv;',

在这里,我们可以看到四个uniform属性的实例。uniform属性的实例具有从 JavaScript 传递到着色器的值,这些值对于每个处理的片段都是相同的。在这种情况下,我们传递了三个由类型float(用于确定要包含在最终灰度图像中的颜色的比例)标识的浮点数,以及一个纹理(tDiffuse),由类型tDiffuse标识。这个纹理包含来自EffectComposer实例的前一个传递中的图像。Three.js 确保当使用tDiffuse作为其名称时,这个纹理会被传递到这个着色器。我们也可以自己设置uniform属性的其它实例。在我们能够从 JavaScript 中使用这些 uniform 之前,我们必须定义我们想要暴露给 JavaScript 的uniform属性。这是在着色器文件顶部按照以下方式完成的:

uniforms: {
"tDiffuse": { type: "t", value: null },
"rPower":   { type: "f", value: 0.2126 },
"gPower":   { type: "f", value: 0.7152 },
"bPower":   { type: "f", value: 0.0722 }
},

到目前为止,我们可以从 Three.js 接收配置参数,这将提供当前渲染的输出。让我们看看将每个像素转换为灰度像素的代码:

"void main() {",
"vec4 texel = texture2D( tDiffuse, vUv );",
"float gray = texel.r*rPower + texel.g*gPower + 
  texel.b*bPower;", "gl_FragColor = vec4( vec3(gray), 
    texel.w );"

这里发生的情况是我们从传入的纹理中获取正确的像素。我们通过使用texture2D函数来完成这个操作,其中我们传入当前图像(tDiffuse)和我们想要分析的像素位置(vUv)。结果是包含颜色和不透明度(texel.w)的纹理像素(texel,纹理中的像素)。接下来,我们使用这个 texel 的rgb属性来计算一个灰度值。这个灰度值被设置为gl_FragColor变量,最终显示在屏幕上。并且,这样我们就有了自己的自定义着色器。这个着色器是以我们在本章中已经看到几次的方式使用的。首先,我们只需要设置EffectComposer,如下所示:

const effectCopy = new ShaderPass(CopyShader)
effectCopy.renderToScreen = true
const grayScaleShader = new ShaderPass
  (CustomGrayScaleShader)
const gammaCorrectionShader = new ShaderPass
  (GammaCorrectionShader)
const composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))
composer.addPass(grayScaleShader)
composer.addPass(gammaCorrectionShader)
composer.addPass(effectCopy)

我们在渲染循环中调用composer.render()。如果我们想在运行时更改这个着色器的属性,我们只需更新我们定义的uniforms属性,如下所示:

shaderPass.uniforms.rPower.value = ...; 
shaderPass.uniforms.gPower.value = ...; 
shaderPass.uniforms.bPower.value = ...;

结果可以在custom-shaders-scene.html中看到。以下截图显示了此示例:

图 11.27 – 自定义灰度过滤

图 11.27 – 自定义灰度过滤

让我们创建另一个自定义着色器。这次,我们将 24 位输出减少到更低的位计数。

创建自定义位着色器

通常,颜色以 24 位值表示,这给我们大约 1600 万种不同的颜色。在计算机的早期阶段,这是不可能的,颜色通常以 8 位或 16 位颜色表示。使用这个着色器,我们将自动将 24 位输出转换为 4 位颜色深度(或任何你想要的)。

由于顶点着色器与我们的前一个示例相同,我们将跳过顶点着色器,直接列出uniforms属性的定义:

uniforms: {
  "tDiffuse": { type: "t", value: null },
  "bitSize":    { type: "i", value: 4 }
}

fragmentShader代码如下:

  fragmentShader: [
    'uniform int bitSize;',
    'uniform sampler2D tDiffuse;',
    'varying vec2 vUv;',
    'void main() {',
    'vec4 texel = texture2D( tDiffuse, vUv );',
    'float n = pow(float(bitSize),2.0);',
    'float newR = floor(texel.r*n)/n;',
    'float newG = floor(texel.g*n)/n;',
    'float newB = floor(texel.b*n)/n;',
    'gl_FragColor = vec4( vec3(newR,newG,newB), 1.0);',
    '}'
  ].join('\n')

我们定义了两个uniform属性的实例,可以用来配置这个着色器。第一个是 Three.js 用来传入当前屏幕的,第二个是我们定义的整数(type:"i"),作为我们想要渲染结果的颜色深度。代码本身非常直接:

  1. 首先,我们根据传入的像素位置vUvtDiffuse纹理中获取texel

  2. 我们根据bitSize属性计算我们可以拥有的颜色数量,通过计算2bitSize次方(pow(float(bitSize),2.0))。

  3. 接下来,我们通过将此值乘以n,四舍五入(floor(texel.r*n)),然后再除以n来计算texel颜色的新值。

  4. 结果被设置为gl_FragColor(红色、绿色和蓝色值以及不透明度)并在屏幕上显示。

你可以在我们之前的自定义着色器相同的示例custom-shaders-scene.html中查看这个自定义着色器的结果。以下截图显示了此示例,我们将位大小设置为 4。这意味着模型只以 16 种颜色渲染:

图 11.28 – 自定义位过滤

图 11.28 – 自定义位过滤

本章关于后处理的介绍到此结束。

摘要

我们在本章中讨论了许多不同的后处理选项。正如你所见,创建EffectComposer并将多个步骤链接起来实际上非常简单。你只需记住几点。并非所有步骤都会在屏幕上产生输出。如果你想输出到屏幕,你可以始终使用带有CopyShaderShaderPass。你将步骤添加到作曲家的顺序很重要。效果将按照这个顺序应用。如果你想重用特定EffectComposer实例的结果,你可以通过使用TexturePass来实现。当你有多个RenderPass在你的EffectComposer中时,确保将clear属性设置为false。如果不这样做,你将只能看到最后一个RenderPass步骤的输出。如果你想只将效果应用到特定的对象上,你可以使用MaskPass。当你完成遮罩后,使用ClearMaskPass清除遮罩。除了 Three.js 提供的标准步骤外,还有许多标准着色器可用。你可以将这些与ShaderPass一起使用。使用 Three.js 的标准方法创建自定义后处理着色器非常简单。你只需创建一个片段着色器。

我们现在已经涵盖了关于 Three.js 核心的几乎所有知识。在第十二章 向场景添加物理和声音中,我们将探讨一个名为 Rapier.js 的库,你可以使用它来扩展 Three.js 以添加物理效果,以便应用碰撞、重力和约束。

第十二章:为您的场景添加物理和声音

在本章中,我们将探讨 Rapier,这是另一个可以用来扩展 Three.js 基本功能的库。Rapier 是一个库,允许您将物理引入您的 3D 场景。当我们提到物理时,我们指的是您的对象受到重力的影响——它们可以相互碰撞,可以通过施加冲量来移动,并且可以通过不同类型的关节来限制它们的运动。除了物理之外,我们还将探讨 Three.js 如何帮助您向场景添加空间声音。

在本章中,我们将讨论以下主题:

  • 创建一个 Rapier 场景,其中您的对象受到重力影响并可以相互碰撞

  • 展示如何更改场景中对象的摩擦力和恢复力(弹性)

  • 解释 Rapier 支持的形状及其使用方法

  • 展示如何通过组合简单形状来创建复合形状

  • 展示如何通过高度场模拟复杂形状

  • 通过使用关节将对象连接到其他对象来限制对象的运动

  • 向场景添加声音源,其声音大小和方向基于与摄像机的距离

可用的物理引擎

有许多不同的开源 JavaScript 物理引擎可供选择。尽管大多数引擎都没有处于活跃开发状态。然而,Rapier 正处于活跃开发中。Rapier 是用 Rust 编写的,并且交叉编译成 JavaScript,因此您可以在浏览器中使用它。如果您选择使用其他库,本章中的信息仍然有用,因为大多数库都使用本章中展示的相同方法。因此,尽管实现和使用的类和函数可能不同,但本章中展示的概念和设置在大多数情况下都将适用于您选择的任何物理库。

使用 Rapier 创建基本的 Three.js 场景

要开始,我们创建了一个非常基本的场景,其中有一个立方体掉落并击中一个平面。您可以通过查看physics-setup.html示例来查看此示例:

图 12.1 – 简单的 Rapier 物理

图 12.1 – 简单的 Rapier 物理

当您打开此示例时,您会看到立方体缓慢掉落,击中灰色水平平面的角落,并从其上弹跳。我们可以通过更新立方体的位置和旋转以及编程其反应来实现这一点。然而,这相当困难,因为我们需要确切知道它何时击中,在哪里击中,以及立方体在击中后应该如何旋转。使用 Rapier,我们只需配置物理世界,Rapier 就会精确计算场景中对象的运动情况。

在我们可以配置模型使用 Rapier 引擎之前,我们需要在我们的项目中安装 Rapier(我们已安装,所以如果您在本章提供的示例中进行实验,您不需要这样做):

$ yarn add @dimforge/rapier3d

一旦添加,我们需要将 Rapier 导入到我们的项目中。这与我们之前看到的正常导入略有不同,因为 Rapier 需要加载额外的 WebAssembly 资源。这是必要的,因为 Rapier 库是用 Rust 语言开发的,并编译成 WebAssembly,以便也可以在网络上使用。要使用 Rapier,我们需要像这样包装我们的脚本:

import * as THREE from 'three'
import { RigidBodyType } from '@dimforge/rapier3d'
// maybe other imports
import('@dimforge/rapier3d').then((RAPIER) => {
  // the code
}

最后这个导入语句将异步加载 Rapier 库,并在所有数据加载和解析完成后调用回调。在代码的其余部分,你只需调用 RAPIER 对象即可访问 Rapier 特定的功能。

要使用 Rapier 设置一个场景,我们需要做一些事情:

  1. 创建一个 Rapier World。这定义了我们正在模拟的物理世界,并允许我们定义将应用于该世界中物体的重力。

  2. 对于你想要用 Rapier 模拟的每个对象,你必须定义一个 RigidBodyDesc。这定义了场景中一个对象的位置和旋转(以及一些其他属性)。通过将这个描述添加到 World 实例中,你将得到一个 RigidBody

  3. 接下来,你可以通过创建一个 ColliderDesc 对象来告诉 Rapier 你要添加的对象的形状。这将告诉 Rapier 你的对象是一个立方体、球体、圆锥体或其他形状;它有多大;它与其他物体之间的摩擦力有多大;以及它的弹性如何。这个描述将与之前创建的 RigidBody 结合,以创建一个 Collider 实例。

  4. 在我们的动画循环中,我们现在可以调用 world.step(),这将使 Rapier 计算它所了解的所有 RigidBody 对象的新位置和旋转。

在线 Rapier 文档

在这本书中,我们将查看 Rapier 的各种属性。我们不会探索 Rapier 提供的全部功能,因为那可以填满一本书。有关 Rapier 的更多信息,请参阅此处:rapier.rs/docs/

让我们一步步走过这些步骤,看看你是如何将它们与你已经熟悉的 Three.js 对象结合起来的。

设置世界和创建描述

我们需要做的第一件事是创建我们正在模拟的 World

const gravity = { x: 0.0, y: -9.81, z: 0.0 }
const world = new RAPIER.World(gravity)

这是一段简单的代码,我们创建了一个在 y 轴上有 -9.81 重力的 World。这与地球上的重力相似。

接下来,让我们定义我们在示例中看到的 Three.js 对象:一个下落的立方体和它撞击的地板:

const floor = new THREE.Mesh(
  new THREE.BoxGeometry(5, 0.25, 5),
  new THREE.MeshStandardMaterial({color: 0xdddddd})
)
floor.position.set(2.5, 0, 2.5)
const sampleMesh = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshNormalMaterial()
)
sampleMesh.position.set(0, 4, 0)
scene.add(floor)
scene.add(sampleMesh)

这里没有什么新的。我们只是定义了两个 THREE.Mesh 对象,并将 sampleMesh 实例,即立方体,放置在 floor 表面角落上方。接下来,我们需要创建 RigidBodyDescColliderDesc 对象,这些对象代表 Rapier 世界中的 THREE.Mesh 对象。我们将从简单的开始,即地板:

const floorBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Fixed)
const floorBody = world.createRigidBody(floorBodyDesc)
floorBody.setTranslation({ x: 2.5, y: 0, z: 2.5 })
const floorColliderDesc = RAPIER.ColliderDesc.cuboid
  (2.5, 0.125, 2.5)
world.createCollider(floorColliderDesc, floorBody)

在这里,首先,我们创建一个带有单个参数的 RigidBodyDesc,即 RigidBodyType.Fixed。一个固定刚体意味着 Rapier 不允许改变这个对象的位置或旋转,因此这个对象不会受到重力的影响,也不会在另一个对象撞击时移动。通过调用 world.createRigidBody,我们将它添加到 Rapier 所知的 world 中,这样 Rapier 就可以在进行计算时考虑这个对象。然后,我们使用 setTranslationRigidBody 放置在与我们的 Three.js 地板相同的位置。setTranslation 函数有一个可选的额外参数,称为 wakeUp。如果 RigidBody 正在睡眠(如果它长时间没有移动,可能会发生这种情况),将 true 传递给 wakeUp 属性确保 Rapier 在确定所有已知对象的新位置时将考虑 RigidBody

我们仍然需要定义这个对象的外形,以便 Rapier 可以判断它何时与另一个对象发生碰撞。为此,我们使用 Rapier.ColliderDesc.cuboid 函数来指定形状。对于 cuboid 函数,Rapier 预期形状由半宽、半高和半深度定义。最后一步是将这个碰撞器添加到世界中,并将其连接到地板。为此,我们使用 world.createCollider 函数。

到目前为止,我们在 Rapier 世界中定义了 floor,这与我们的 Three.js 场景中的地板相对应。现在,我们定义将同样下落的立方体:

Const rigidBodyDesc = new RAPIER.RigidBodyDesc
(RigidBodyType.Dynamic)
    .setTranslation(0, 4, 0)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.cuboid
  (0.5, 0.5, 0.5)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setRestitution(1)

这段代码与上一个类似——我们只是创建了与我们的 Three.js 场景中的对象相对应的 Rapier 相关对象。这里的主要变化是我们使用了 RigidBodyType.Dynamic 实例。这意味着这个对象可以完全由 Rapier 管理。Rapier 可以改变它的位置或平移。

Rapier 提供的附加刚体类型

除了 DynamicFixed 刚体类型之外,Rapier 还提供了一个 KinematicPositionBased 类型,用于管理对象的位置,或者一个 KinematicVelocityBased 类型,用于我们自己管理对象的速度。更多相关信息请参阅此处:rapier.rs/docs/user_guides/javascript/rigid_bodies

渲染场景和模拟世界

剩下的工作是将 Three.js 对象渲染出来,模拟世界,并确保 Rapier 管理的对象的位置与 Three.js 网格的位置相对应:

  const animate = (renderer, scene, camera) => {
    // basic animation loop
    requestAnimationFrame(() => animate(renderer, scene,
      camera))
    renderer.render(scene, camera)
    world.step()
    // copy over the position from Rapier to Three.js
    const rigidBodyPosition = rigidBody.translation()
    sampleMesh.position.set(
      rigidBodyPosition.x,
      rigidBodyPosition.y,
      rigidBodyPosition.z)
    // copy over the rotation from Rapier to Three.js
    const rigidBodyRotation = rigidBody.rotation()
    sampleMesh.rotation.setFromQuaternion(
      new THREE.Quaternion(rigidBodyRotation.x,
        rigidBodyRotation.y, rigidBodyRotation.z,
          rigidBodyRotation.w)
    )
  }

在我们的渲染循环中,我们有正常的 Three.js 元素来确保我们使用requestAnimationFrame在每一步渲染。除此之外,我们调用world.step()函数来触发 Rapier 中的计算。这将更新它所知道的所有物体的位置和旋转。接下来,我们需要确保这些新计算出的位置也通过 Three.js 对象反映出来。为此,我们只需获取 Rapier 世界中一个物体的当前位置(rigidBody.translation())并将 Three.js 网格的位置设置为该函数的结果。对于旋转,我们同样这样做,首先在rigidBody上调用rotation(),然后将该旋转应用到我们的 Three.js 网格上。Rapier 使用四元数来定义旋转,因此在我们将旋转应用到 Three.js 网格之前,我们需要进行这种转换。

这就是您需要做的全部。以下各节中的所有示例都使用这种方法:

  • 设置 Three.js 场景

  • 在 Rapier 世界中设置一组类似的对象

  • 确保在每个step之后,Three.js 场景和 Rapier 世界的位置和旋转再次对齐

在下一节中,我们将扩展这个示例,并展示更多关于在 Rapier 世界中物体如何相互作用的细节。

在 Rapier 中模拟多米诺

以下示例建立在我们在设置世界和创建描述部分查看的相同核心概念之上。您可以通过打开dominos.html示例来查看此示例:

图 12.2 – 没有重力时静止的多米诺

图 12.2 – 没有重力时静止的多米诺

在这里,您可以看到我们创建了一个简单的地板,许多多米诺就放置在这个地板上。如果您仔细观察,您会看到这些多米诺的第一个实例略微倾斜。如果您通过右侧菜单启用y轴上的重力,您会看到第一个多米诺倒下,撞到下一个,以此类推,直到所有多米诺都被撞倒:

图 12.3 – 第一个多米诺被推倒后多米诺倒下

图 12.3 – 第一个多米诺被推倒后多米诺倒下

使用 Rapier 创建这个示例非常直接。我们只需创建代表多米诺的 Three.js 对象,创建相关的 Rapier RigidBodyCollider元素,并确保 Rapier 对象的更改通过 Three.js 对象反映出来。

首先,让我们快速看一下我们是如何创建 Three.js 多米诺的:

const createDominos = () => {
    const getPoints = () => {
      const points = []
      const r = 2.8; const cX = 0; const cY = 0
      let circleOffset = 0
      for (let i = 0; i < 1200; i += 6 + circleOffset) {
        circleOffset = 1.5 * (i / 360)
        const x = (r / 1440) * (1440 - i) * Math.cos(i *
          (Math.PI / 180)) + cX
        const z = (r / 1440) * (1440 - i) * Math.sin(i *
          (Math.PI / 180)) + cY
        const y = 0
        points.push(new THREE.Vector3(x, y, z))
      }
      return points
    }
    const stones = new Group()
    stones.name = 'dominos'
    const points = getPoints()
    points.forEach((point, index) => {
      const colors = [0x66ff00, 0x6600ff]
      const stoneGeom = new THREE.BoxGeometry
        (0.05, 0.5, 0.2)
      const stone = new THREE.Mesh(
        stoneGeom,
        new THREE.MeshStandardMaterial({color: colors[index
        % colors.length], transparent: true, opacity: 0.8})
      )
      stone.position.copy(point)
      stone.lookAt(new THREE.Vector3(0, 0, 0))
      stones.add(stone)
    })
    return stones
  }

在此代码片段中,我们使用getPoints函数确定多米诺的位置。此函数返回一个THREE.Vector3对象的列表,代表单个石头的位置。每个石头都沿着从中心向外螺旋排列。接下来,这些points被用来在相同的位置创建多个THREE.BoxGeometry对象。为了确保多米诺的方向正确,我们使用lookAt函数使它们“看向”圆的中心。所有多米诺都被添加到一个THREE.Group对象中,然后我们将其添加到一个THREE.Scene实例中(这在代码片段中没有显示)。

现在我们有了我们的THREE.Mesh对象集,我们可以创建相应的 Rapier 对象集:

const rapierDomino = (mesh) => {
  const stonePosition = mesh.position
  const stoneRotationQuaternion = new THREE.Quaternion().
    setFromEuler(mesh.rotation)
  const dominoBodyDescription = new RAPIER.RigidBodyDesc
    (RigidBodyType.Dynamic)
    .setTranslation(stonePosition.x, stonePosition.y,
      stonePosition.z)
    .setRotation(stoneRotationQuaternion))
    .setCanSleep(false)
    .setCcdEnabled(false)
  const dominoRigidBody = world.createRigidBody
    (dominoBodyDescription)
  const geometryParameters = mesh.geometry.parameters
  const dominoColliderDesc = RAPIER.ColliderDesc.cuboid(
    geometryParameters.width / 2,
    geometryParameters.height / 2,
    geometryParameters.depth / 2
  )
  const dominoCollider = world.createCollider
    (dominoColliderDesc, dominoRigidBody)
  mesh.userData.rigidBody = dominoRigidBody
  mesh.userData.collider = dominoCollider
}

这段代码将与设置世界和创建描述部分中的代码相似。在这里,我们获取传入的THREE.Mesh实例的位置和旋转,并使用这些信息来创建相关的 Rapier 对象。为了确保我们可以在渲染循环中访问dominoColliderdominoRigidBody实例,我们将它们添加到传入网格的userData属性中。

此处的最后一步是在渲染循环中更新THREE.Mesh对象:

  const animate = (renderer, scene, camera) => {
    requestAnimationFrame(() => animate(renderer, scene,
      camera))
    renderer.render(scene, camera)
    world.step()
    const dominosGroup = scene.getObjectByName('dominos')
    dominosGroup.children.forEach((domino) => {
      const dominoRigidBody = domino.userData.rigidBody
      const position = dominoRigidBody.translation()
      const rotation = dominoRigidBody.rotation()
      domino.position.set(position.x, position.y,
        position.z)
      domino.rotation.setFromQuaternion(new
        THREE.Quaternion(rotation.x, rotation.y,
          rotation.z, rotation.w))
    })
  }

在每个循环中,我们告诉 Rapier 计算世界的下一个状态(world.step),并且对于每个多米诺(它们是名为dominosTHREE.Groupchildren),我们根据存储在该网格userdata信息中的RigidBody对象更新THREE.Mesh实例的位置和旋转。

在我们继续探讨碰撞体提供的重要属性之前,我们将快速查看重力如何影响这个场景。当你打开这个示例时,借助右侧的菜单,你可以改变世界的重力。你可以使用这个功能来实验多米诺对不同的重力设置的反应。例如,以下示例显示了所有多米诺都倒下后,我们增加了沿x轴和z轴的重力的情况:

图 12.4 – 不同重力设置的多米诺

图 12.4 – 不同重力设置的多米诺

在下一节中,我们将展示设置摩擦和恢复系数对 Rapier 对象的影响。

处理恢复和摩擦

在下一个示例中,我们将更详细地查看 Rapier 提供的Collider恢复摩擦属性。

恢复是定义物体在与另一个物体碰撞后保持多少能量的属性。你可以将其视为弹性。网球具有高恢复性,而砖块具有低恢复性。

摩擦定义了一个物体在另一个物体上滑动时的容易程度。具有高摩擦的物体在另一个物体上移动时会迅速减速,而具有低摩擦的物体可以轻易滑动。例如,冰具有低摩擦,而砂纸具有高摩擦。

我们可以在构建 RAPIER.ColliderDesc 对象时设置这些属性,或者在我们已经使用 (world.createCollider(...) 函数创建碰撞体之后设置它。在我们查看代码之前,我们先看看这个例子。对于 colliders-properties.html 示例,你会看到一个大的盒子,你可以将形状投入其中:

图 12.5 – 可将形状投入的空盒子

图 12.5 – 可将形状投入的空盒子

使用右侧的菜单,你可以投入球体和立方体形状,并设置添加对象的摩擦和恢复系数。对于第一个场景,我们将添加大量具有高摩擦的立方体。

图 12.6 – 高摩擦的立方体盒子

图 12.6 – 高摩擦的立方体盒子

你在这里看到的是,尽管盒子在其轴周围移动,但立方体几乎不移动。这是因为立方体本身具有非常高的摩擦。如果你用低摩擦尝试,你会看到盒子会在盒子的底部滑动。

要设置摩擦,你只需这样做:

const rigidBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.ball(0.2)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setFriction(0.5)

Rapier 提供了一种控制摩擦的额外方法,那就是通过使用 setFrictionCombineRule 函数来设置组合规则。这告诉 Rapier 如何组合两个发生碰撞的物体的摩擦(在我们的例子中,是盒子的底部和立方体)。使用 Rapier,你可以将其设置为以下值:

  • CoefficientCombineRule.Average:使用两个系数的平均值

  • CoefficientCombineRule.Min:使用两个系数中的最小值

  • CoefficientCombineRule.Multiply:使用两个系数的乘积

  • CoefficientCombineRule.Max:使用两个系数中的最大值

要探索 restitution 的工作原理,我们可以使用这个相同的例子(colliders-properties.html):

图 12.7 – 高恢复系数的球体盒子

图 12.7 – 高恢复系数的球体盒子

在这里,我们增加了球体的恢复系数。结果是,当添加球体或它们撞击墙壁时,它们现在会在盒子中弹跳。要设置恢复系数,你使用与摩擦相同的方法:

const rigidBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.ball(0.2)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setRestitution(0.9)

Rapier 还允许你设置如何计算相互碰撞的物体的 restitution 属性。你可以使用相同的值,但这次,你使用 setRestitutionCombineRule 函数。

Collider 有一些额外的属性,你可以使用它们来微调碰撞体如何与 Rapier 的世界视图交互,以及当物体发生碰撞时会发生什么。Rapier 本身提供了非常好的文档。特别是对于碰撞体,你可以在以下位置找到该文档:rapier.rs/docs/user_guides/javascript/colliders#restitution

Rapier 支持的形状

Rapier 提供了一些你可以用来包裹你的几何形状的形状。在本节中,我们将带你了解所有可用的 Rapier 形状,并通过一个示例演示这些网格。请注意,要使用这些形状,你需要调用 RAPIER.ColliderDesc.roundCuboidRAPIER.ColliderDesc.ball 等等。

Rapier 提供了 3D 形状和 2D 形状。我们只会查看 Rapier 提供的 3D 形状:

  • 球体: 一个球形状,通过设置球体的半径来配置

  • 胶囊体: 一个胶囊形状,由胶囊的半高和半径定义

  • 立方体: 通过传递形状的半宽、半高和半深度来定义的一个简单的立方体形状

  • 高度场: 高度场是一个形状,其中每个提供的值定义了一个 3D 平面的高度

  • 圆柱体: 一个由圆柱的半高和半径定义的圆柱形状

  • 圆锥体: 一个由圆柱底部的半高和半径定义的圆锥形状

  • 凸包: 凸包是包含所有传入点的最小形状

  • 凸多边形网格: 凸多边形网格也接受一定数量的点,但假设这些点已经形成一个凸包,因此 Rapier 不会进行任何计算来确定较小的形状

除了这些形状之外,Rapier 还为这些形状中的几个提供了额外的圆形变体:roundCuboidroundCylinderroundConeroundConvexHullroundConvexMesh

我们提供了一个另一个示例,你可以看到这些形状的外观以及它们在相互碰撞时的交互。打开 shapes.html 示例来查看这一功能:

图 12.8 – 在高度场对象上方的形状

图 12.8 – 在高度场对象上方的形状

当你打开这个示例时,你会看到一个空的 heightfield 对象。通过右侧的菜单,你可以添加不同的形状,它们会相互碰撞,并与 heightfield 实例碰撞。再次强调,你可以为添加的对象设置特定的 restitutionfriction 值。由于我们已经在前面的章节中解释了如何在 Rapier 中添加形状并确保 Three.js 中的相应形状得到更新,所以我们不会在这里详细说明如何从之前的列表中创建形状。对于代码,请查看本章源代码中的 shapes.js 文件。

在我们进入关节部分之前,有一个最后的注意事项——当我们想要描绘简单的形状(例如,球体或立方体)时,Rapier 定义这种模型的方式和 Three.js 定义的方式几乎相同。因此,当这种类型的物体与另一个物体碰撞时,它看起来是正确的。当我们有更复杂的形状时,例如在这个例子中的高度图实例,Three.js 解释和插值这些点到高度图实例的方式以及 Rapier 这样做的方式可能会有细微的差异。你可以通过查看shapes.html示例,添加很多不同的形状,然后查看高度场的底部来亲自看到这一点:

图 12.9 – 高度场的底部

图 12.9 – 高度场的底部

你在这里可以看到的是,我们可以看到不同物体的微小部分从高度图中突出出来。原因是 Rapier 确定高度图的确切形状的方式与 Three.js 不同。换句话说,Rapier 认为高度图看起来略不同于 Three.js。因此,当它确定特定形状在碰撞时的位置时,可能会导致这样的小细节。然而,通过调整大小或创建更简单的物体,这可以很容易地避免。

到目前为止,我们已经探讨了重力和碰撞。Rapier 还提供了一种限制刚体运动和旋转的方法。我们将通过使用关节来解释 Rapier 是如何做到这一点的。

使用关节限制物体的运动

到目前为止,我们已经看到了一些基本物理现象。我们看到了各种形状如何响应重力、摩擦和恢复力,以及这如何影响碰撞。Rapier 还提供了高级结构,允许您限制物体的运动。在 Rapier 中,这些物体被称为关节。以下列表概述了 Rapier 中可用的关节:

  • 固定关节:固定关节确保两个物体相对于彼此不移动。这意味着这两个物体之间的距离和旋转始终相同。

  • 球面关节:球面关节确保两个物体之间的距离保持不变。然而,这些物体可以在所有三个轴向上围绕彼此移动。

  • 旋转关节:使用这种关节,两个物体之间的距离保持不变,并且它们可以在一个轴上旋转——例如,方向盘,它只能围绕一个轴旋转。

  • 滑动关节:与旋转关节类似,但这次,物体之间的旋转是固定的,物体可以在一个轴上移动。这会产生滑动效果——例如,如电梯向上移动。

在接下来的章节中,我们将探讨这些关节,并在示例中看到它们的作用。

使用固定关节连接两个物体

最简单的关节是固定关节。使用这个关节,你可以连接两个对象,并且它们将保持在创建关节时指定的相同距离和方向。

这在 fixed-joint.html 示例中显示:

图 12.10 – 连接两个关节的固定关节

图 12.10 – 连接两个关节的固定关节

正如你在本例中可以看到的,两个立方体作为一个整体移动。这是因为它们通过固定关节连接在一起。为了设置这个,我们首先必须创建两个 RigidBody 对象和两个 Collider 对象,就像我们在前面的章节中已经看到的那样。接下来我们需要做的是连接这两个对象。为此,我们首先需要定义 JointData

  let params = RAPIER.JointData.fixed(
    { x: 0.0, y: 0.0, z: 0.0 },
    { w: 1.0, x: 0.0, y: 0.0, z: 0.0 },
    { x: 2.0, y: 0.0, z: 0.0 },
    { w: 1.0, x: 0.0, y: 0.0, z: 0.0 }
  )

这意味着我们将第一个物体放置在 { x: 0.0, y: 0.0, z: 0.0 }(其中心)的位置与第二个物体连接,第二个物体位于 { x: 2.0, y: 0.0, z: 0.0 },其中第一个物体使用四元数 { w: 1.0, x: 0.0, y: 0.0, z: 0.0 } 进行旋转,第二个物体以相同的量进行旋转 – { w: 1.0, x: 0.0, y: 0.0, z: 0.0 }。我们现在唯一需要做的是告诉 Rapier world 这个关节以及它应用于哪些 RigidBody 对象:

world.createImpulseJoint(params, rigidBody1, rigidBody2,
  true)

这里最后一个属性定义了 RigidBody 是否因为此关节而唤醒。当 RigidBody 几秒钟没有移动时,它可以被置于睡眠状态。对于关节来说,通常最好将其设置为 true,因为这确保了如果我们将关节附加到其中一个 RigidBody 对象,而该对象处于睡眠状态,则 RigidBody 将被唤醒。

另一种看到这个关节作用的好方法是使用以下参数:

  let params = RAPIER.JointData.fixed(
    { x: 0.0, y: 0.0, z: 0.0 },
    { w: 1.0, x: 0.0, y: 0.0, z: 0.0 },
    { x: 2.0, y: 2.0, z: 2.0 },
    { w: 0.3, x: 1, y: 1, z: 1 }
  )

这将导致两个立方体卡在场景中心的地上:

图 12.11 – 连接两个立方体的固定关节

图 12.11 – 连接两个立方体的固定关节

接下来在我们的列表中是球形关节。

使用球形关节连接物体

球形关节允许两个物体在彼此周围自由移动,同时保持这些物体之间的相同距离。这可以用于布娃娃效果,或者就像我们在本例中所做的那样,创建一个链 (sphere-joint.html):

图 12.12 – 通过球形关节连接的多个球体

图 12.12 – 通过球形关节连接的多个球体

正如你在本例中可以看到的,我们已经连接了许多球体来创建一串球体。当这些球体撞击中间的圆柱体时,它们会绕着圆柱体滚动并慢慢滑离这个圆柱体。你可以看到,虽然这些球体之间的方向根据它们的碰撞而改变,但球体之间的绝对距离保持不变。因此,为了设置这个示例,我们创建了许多带有 RigidBodyCollider 的球体,类似于前面的示例。对于每一对球体,我们还创建了一个类似的关节:

  const createChain = (beads) => {
    for (let i = 1; i < beads.length; i++) {
      const previousBead = beads[i - 1].userData.rigidBody
      const thisBead = beads[i].userData.rigidBody
      const positionPrevious = beads[i - 1].position
      const positionNext = beads[i].position
      const xOffset = Math.abs(positionNext.x –
        positionPrevious.x)
      const params = RAPIER.JointData.spherical(
        { x: 0, y: 0, z: 0 },
        { x: xOffset, y: 0, z: 0 }
        )
      world.createImpulseJoint(params, thisBead,
        previousBead, true)
    }
  }

您可以看到我们使用RAPIER.JointData.spherical创建了一个关节。这里的参数定义了第一个对象的位置,{ x: 0, y: 0, z: 0 },以及第二个对象的相对位置,{ x: xOffset, y: 0, z: 0 }。我们对所有对象都这样做,并使用world.createImpulseJoint(params, thisBead, previousBead, true)将关节添加到 Rapier 世界中。

结果是我们得到了一个通过这些球形关节连接的球体链。

下一个关节,转动关节,允许我们通过指定一个对象相对于另一个对象可以围绕其旋转的单轴来限制两个对象的运动。

使用转动关节限制旋转

使用转动关节,很容易创建齿轮、轮子和风扇等围绕单轴旋转的结构。最容易解释这一点的方法是查看revolute-joint.html示例:

图 12.13 – 立方体在掉落在旋转条上之前

图 12.13 – 立方体在掉落在旋转条上之前

在*图 12**.13 中,您可以看到一个紫色立方体悬浮在绿色条形上方。当您在y方向上启用重力时,立方体会掉在绿色条形上。这个绿色条形的中心通过一个转动关节与中间的固定立方体连接。结果是,这个绿色条形现在会因紫色立方体的重量而慢慢旋转:

图 12.14 – 条形对一端重量的反应

图 12.14 – 条形对一端重量的反应

为了使转动关节工作,我们再次需要两个刚体。灰色立方体的 Rapier 部分定义如下:

const bodyDesc = new RAPIER.RigidBodyDesc(RigidBodyType.Fixed)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5)
const collider = world.createCollider(colliderDesc, body)

这意味着,无论对它施加什么力,这个RigidBody都将始终处于相同的位置。绿色条形的定义如下:

Const bodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
  .setCanSleep(false)
  .setTranslation(-1, 0, 0)
  .setAngularDamping(0.1)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid(0.25, 0.05,
  2)
const collider = world.createCollider(colliderDesc, body)

这里没有什么特别之处,但我们引入了一个新的属性angularDamping。有了角阻尼,Rapier 会逐渐减小RigidBody的旋转速度。在我们的例子中,这意味着条形将在一段时间后慢慢停止旋转。

我们正在下落的盒子看起来是这样的:

Const bodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
  .setCanSleep(false)
  .setTranslation(-1, 1, 1)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid
  (0.1, 0.1, 0.1)
const collider = world.createCollider(colliderDesc, body)

因此,到目前为止,我们已经定义了RigidBody。现在,我们可以将固定的盒子与绿色的条形连接起来:

const params = RAPIER.JointData.revolute(
  { x: 0.0, y: 0, z: 0 },
  { x: 1.0, y: 0, z: 0 },
  { x: 1, y: 0, z: 0 }
)
let joint = world.createImpulseJoint(params, fixedCubeBody,
  greenBarBody, true)

前两个参数确定两个刚体连接的位置(遵循与固定关节相同的思想)。最后一个参数定义了两个刚体相对于彼此可以旋转的向量。由于我们的第一个RigidBody是固定的,只有绿色的条形可以旋转。

Rapier 支持的最后一个关节类型是滑动关节。

使用滑动关节限制运动到一个单轴

滑动关节将对象的运动限制到一个单轴。以下示例(prismatic.html)演示了这一点,其中红色立方体的运动被限制到一个单轴:

图 12.15 – 红色立方体被限制在一个轴上

图 12.15 – 红色立方体被限制在一个轴上

在本例中,我们使用之前示例中的旋转关节将一个立方体投向绿色杆。这将导致绿色杆围绕其y-轴在中心旋转并击中红棕色的立方体。这个立方体仅限于沿单轴移动,您会看到它沿着该轴移动。

为了创建本例中的关节,我们使用了以下代码片段:

const prismaticParams = RAPIER.JointData.prismatic(
  { x: 0.0, y: 0.0, z: 0 },
  { x: 0.0, y: 0.0, z: 3 },
  { x: 1, y: 0, z: 0 }
)
prismaticParams.limits = [-2, 2]
prismaticParams.limitsEnabled = true
world.createImpulseJoint(prismaticParams, fixedCubeBody,
  redCubeBody, true)

我们再次定义fixedCubeBody的位置({ x: 0.0, y: 0.0, z: 0 }),这定义了我们相对于其移动的对象。然后,我们定义我们立方体的位置 - { x: 0.0, y: 0.0, z: 3 }。最后,我们定义允许我们的对象移动的轴。在这种情况下,我们定义了{ x: 1, y: 0, z: 0 },这意味着它允许沿其x-轴移动。

使用关节电机在允许的轴上移动物体

球形、旋转和滑动关节也支持一种称为电机的功能。使用电机,您可以在允许的轴上移动刚体。我们在这几个示例中没有展示这一点,但通过使用电机,您可以添加自动旋转的齿轮或创建一个使用电机通过旋转关节移动轮子的汽车。有关电机的更多信息,请参阅 Rapier 文档的相关部分:rapier.rs/docs/user_guides/javascript/joints#joint-motors

如我们在使用 Rapier 创建基本的 Three.js 场景部分中提到的,我们只是触及了 Rapier 可能性的表面。Rapier 是一个功能丰富的库,具有许多允许微调的特性,并且应该为可能需要物理引擎的大多数情况提供支持。该库正在积极开发中,在线文档非常好。

通过本章中的示例和在线文档,您应该能够将 Rapier 集成到自己的场景中,即使对于本章未解释的功能也是如此。

我们主要探讨了 3D 模型及其在 Three.js 中的渲染方法。然而,Three.js 也提供了对 3D 声音的支持。在下一节中,我们将向您展示如何向 Three.js 场景添加方向性声音的示例。

向场景添加声音源

到目前为止,我们已经讨论了几个相关主题,我们已经拥有了创建美丽场景、游戏和其他 3D 可视化的大量元素。然而,我们尚未展示如何将声音添加到您的 Three.js 场景中。在本节中,我们将探讨两个允许您向场景添加声音源的 Three.js 对象。这尤其有趣,因为这些声音源会响应摄像头的位置:

  • 声源与摄像头之间的距离决定了声源的音量

  • 摄像头左侧和右侧的位置分别决定了左侧扬声器与右侧扬声器的音量

最好的解释方式是看到这个动作的实际效果。在你的浏览器中打开audio.html示例,你会看到一个来自第九章的场景,动画和移动 相机

图 12.16 – 带有音频元素的场景

图 12.16 – 带有音频元素的场景

这个例子使用了我们在第九章中看到的第一人称控制,因此你可以使用箭头键与鼠标结合来在场景中移动。由于浏览器不再支持自动启动音频,首先,点击右侧菜单中的enableSounds按钮来打开声音。当你这样做时,你会听到附近有水声——你将能够听到一些远处的牛和羊。

水声来自你起始位置后面的水车,羊群的声音来自右侧的羊群,牛的声音集中在拉犁的两头牛身上。如果你使用控制台在场景中移动,你会注意到声音会根据你的位置而改变——离羊群越近,你听到的声音就越好,当你向左移动时,牛的声音就会更响。这被称为位置音频,其中使用音量和方向来确定如何播放声音。

完成这个任务只需要很少的代码。我们首先需要做的是定义一个THREE.AudioListener对象并将其添加到THREE.PerspectiveCamera

const listener = new THREE.AudioListener(); camera.add(listener1);

接下来,我们需要创建一个THREE.Mesh(或THREE.Object3D)实例,并将一个THREE.PositionalAudio对象添加到该网格中。这将确定这个特定声音的源位置:

const mesh1 = new THREE.Mesh(new THREE.BoxGeometry(1, 1,
  1), new THREE.MeshNormalMaterial({ visible: false }))
mesh1.position.set(-4, -2, 10)
scene.add(mesh1)
const posSound1 = new THREE.PositionalAudio(listener)
const audioLoader = new THREE.AudioLoader()
audioLoader.load('/assets/sounds/water.mp3', function
  (buffer) {
posSound1.setBuffer(buffer)
posSound1.setRefDistance(1)
posSound1.setRolloffFactor(3)
posSound1.setLoop(true)
mesh1.add(posSound3)

如此代码片段所示,我们首先创建一个标准的THREE.Mesh实例。接下来,我们创建一个THREE.PositionalAudio对象,并将其连接到我们之前创建的THREE.AudioListener对象。最后,我们添加音频并配置一些属性,这些属性定义了声音的播放方式和行为:

  • setRefDistance:这决定了声音在哪个距离上会降低音量。

  • setLoop:默认情况下,声音只播放一次。通过将此属性设置为true,声音会循环播放。

  • setRolloffFactor:这决定了当你远离声音源时音量下降的速度。

内部,Three.js 使用 Web Audio API(webaudio.github.io/web-audio-api/)来播放声音并确定正确的音量。并非所有浏览器都支持此规范。目前最好的支持来自 Chrome 和 Firefox。

摘要

在本章中,我们探讨了如何通过添加物理引擎来扩展 Three.js 的基本 3D 功能。为此,我们使用了 Rapier 库,该库允许您为场景和对象添加重力,使对象能够相互交互并在碰撞时弹跳,以及使用关节来限制对象之间的相对运动。

除了这些,我们还向您展示了 Three.js 如何支持 3D 声音。我们创建了一个场景,您可以使用 THREE.PositionalAudioTHREE.AudioListener 对象添加位置声音。

尽管我们现在已经涵盖了 Three.js 提供的所有核心功能,但还有两个章节专门用于探索一些您可以与 Three.js 一起使用的外部工具和库。在下一章中,我们将深入探讨 Blender,看看我们如何利用 Blender 的功能,例如烘焙阴影、编辑 UV 映射,以及在 Blender 和 Three.js 之间交换模型。

第十三章:使用 Blender 和 Three.js 一起工作

在本章中,我们将更深入地探讨如何使用 Blender 和 Three.js 一起工作。我们将在本章中解释以下概念:

  • 从 Three.js 导出并导入到 Blender 中:我们将创建一个简单场景,从 Three.js 中导出它,然后在 Blender 中加载和渲染。

  • 从 Blender 导出静态场景并导入到 Three.js 中:在这里,我们将创建一个场景在 Blender 中,将其导出为 Three.js,并在 Three.js 中渲染。

  • 从 Blender 导出动画并导入到 Three.js 中:Blender 允许我们创建动画,我们将创建一个简单动画,并在 Three.js 中加载和显示。

  • 在 Blender 中烘焙光照贴图和环境遮挡贴图:Blender 允许我们烘焙不同类型的贴图,这些贴图可以在 Three.js 中使用。

  • 在 Blender 中进行自定义 UV 建模:通过 UV 建模,我们确定纹理如何应用于几何体。Blender 提供了许多工具来简化这一过程。我们将探讨如何使用 Blender 的 UV 建模功能,并将结果用于 Three.js。

在我们开始本章之前,请确保安装 Blender,以便你可以跟随。你可以从以下链接下载适用于你的操作系统的安装程序:www.blender.org/download/。本章中显示的 Blender 截图是使用 Blender 的 macOS 版本拍摄的,但 Windows 和 Linux 的版本看起来相同。

让我们开始我们的第一个主题,在这个主题中,我们在 Three.js 中创建一个场景,将其导出为中间格式,最后将其导入到 Blender 中。

从 Three.js 导出并导入到 Blender 中

对于这个例子,我们将只使用我们在 第六章 中看到的参数化几何体进行简单示例重用,探索高级几何体。如果你在浏览器中打开 export-to-blender.html,你可以创建一些参数化几何体。在右侧菜单的底部,我们添加了一个 exportScene 按钮:

图 13.1 – 我们将要导出的简单场景

图 13.1 – 我们将要导出的简单场景

当你点击该按钮时,模型将以 GLTF 格式保存并下载到你的电脑上。要使用 Three.js 导出模型,我们可以使用 GLTFexporter 如下所示:

const exporter = new GLTFExporter()
const options = {
  trs: false,
  onlyVisible: true,
  binary: false
}
exporter.parse(
  scene,
  (result) => {
    const output = JSON.stringify(result, null, 2)
    save(new Blob([output], { type: 'text/plain' }),
      'out.gltf')
  },
  (error) => {
    console.log('An error happened during parsing of the
      scene', error)
  },
  options
)

在这里,你可以看到我们创建了一个 GLTFExporter,我们可以用它来导出 THREE.Scene。我们可以以 glTF 二进制格式或 JSON 格式导出场景。对于这个例子,我们以 JSON 格式导出。glTF 格式是一个复杂的格式,虽然 GLTFExporter 支持构成 Three.js 场景的许多对象,但你仍然可能会遇到导出失败的问题。更新到 Three.js 的最新版本通常是最佳解决方案,因为该组件正在不断进行工作。

一旦我们得到我们的 output,我们可以触发浏览器的 download 功能,它将保存到你的本地机器上:

const save = (blob, filename) => {
  const link = document.createElement('a')
  link.style.display = 'none'
  document.body.appendChild(link)
  link.href = URL.createObjectURL(blob)
  link.download = filename
  link.click()
}

结果是一个 glTF 文件,其前几行看起来像这样:

{
  "asset": {
    "version": "2.0",
    "generator": "THREE.GLTFExporter"
  },
  "scenes": [
    {
      "nodes": [
        0,
        1,
        2,
        3
      ]
    }
  ],
  "scene": 0,
  "nodes": 
    {},
...

现在我们已经得到了包含我们场景的 glTF 文件,我们可以将其导入 Blender。因此,打开 Blender,你会看到一个默认场景,其中有一个单个小立方体。通过选择它并按 x 键来删除立方体。一旦删除,我们就有一个空场景,我们将在这里加载导出的场景。

在顶部的 文件 菜单中选择 导入 | glTF 2.0,你将看到一个文件浏览器。导航到你下载模型的位置,选择文件,然后点击 导入 glTF 2.0。这将打开文件,并显示类似以下内容:

![图 13.2 – 在 Blender 中导入的 Three.js 场景图 13.2 – 在 Blender 中导入的 Three.js 场景如你所见,Blender 已经导入了我们的完整场景,我们在 Three.js 中定义的 THREE.Mesh 现在在 Blender 中可用。在 Blender 中,我们现在可以像使用任何其他网格一样使用它。然而,对于这个例子,让我们保持简单,只用 Blender 的 Cycles 渲染器渲染这个场景。为此,点击右侧菜单中的 渲染属性(看起来像相机的图标)并选择 渲染引擎Cycles图 13.3 – 在 Blender 中使用 Cycles 渲染引擎进行渲染

图 13.3 – 在 Blender 中使用 Cycles 渲染引擎进行渲染

接下来,我们需要正确放置相机,所以使用鼠标在场景中移动,直到你得到一个满意的视图,然后按 Ctrl + Alt + numpad 0 来对齐相机。此时,你将得到类似以下内容:

图 13.4 – 显示相机看到的区域和将要渲染的内容

图 13.4 – 显示相机看到的区域和将要渲染的内容

现在,我们可以通过按 F12 来渲染场景。这将启动 Cycles 渲染引擎,你将看到模型在 Blender 中被渲染:

图 13.5 – 在 Blender 中从导出的 Three.js 模型渲染的最终图像

图 13.5 – 在 Blender 中从导出的 Three.js 模型渲染的最终图像

正如你所见,使用 glTF 作为 Three.js 和 Blender 之间交换模型和场景的格式非常简单。只需使用 GLTFExporter,将模型导入 Blender,你就可以在你的模型上使用 Blender 提供的所有功能。

当然,反过来操作也同样简单,我们将在下一节中向你展示。

从 Blender 导出静态场景并将其导入 Three.js

从 Blender 导出模型与导入它们一样简单。在 Three.js 的旧版本中,你可以使用一个特定的 Blender 插件来导出为 Three.js 特定的 JSON 格式。然而,在后来的版本中,glTF 在 Three.js 中已成为与其他工具交换模型的标准。因此,为了与 Blender 一起使用,我们只需做以下操作:

  1. 在 Blender 中创建一个模型。

  2. 将模型导出为 glTF 文件。

  3. 在 Blender 中导入 glTF 文件并将其添加到场景中。

让我们在 Blender 中首先创建一个简单的模型。我们将使用 Blender 默认使用的模型,该模型可以通过从菜单中选择 添加 | 网格 | 猴子对象模式 中添加。点击 猴子 以选择它:

图 13.6 – 在 Blender 中创建要导出的模型

图 13.6 – 在 Blender 中创建要导出的模型

选中模型后,在上部菜单中选择 文件->导出->glTF 2.0

图 13.7 – 选择 glTF 导出

图 13.7 – 选择 glTF 导出

对于这个例子,我们只导出网格。请注意,当你从 Blender 导出时,始终要检查 应用修改器 复选框。这将确保在导出网格之前应用了 Blender 中使用的任何高级生成器或修改器。

图 13.8 – 将模型导出为 glTF 文件

图 13.8 – 将模型导出为 glTF 文件

文件导出后,我们可以在 Three.js 中使用 GLTFImporter 加载它:

  const loader = new GLTFLoader()
  return loader.loadAsync('/assets/gltf/
    blender-export/monkey.glb').then((structure) => {
    return structure.scene
  })

最终结果是 Blender 中的确切模型,但在 Three.js 中可视化(见 import-from-blender.html 示例):

图 13.9 – 在 Three.js 中可视化的 Blender 模型

图 13.9 – 在 Three.js 中可视化的 Blender 模型

注意,这不仅仅限于网格 – 使用 glTF,我们还可以以相同的方式导出灯光、相机和纹理。

从 Blender 导出动画并将其导入到 Three.js

从 Blender 导出动画的方式基本上与导出静态场景的方式相同。因此,对于这个例子,我们将创建一个简单的动画,再次以 glTF 格式导出,并将其加载到 Three.js 场景中。为此,我们将创建一个简单的场景,在其中渲染一个掉落并破碎成块的立方体。为此,我们首先需要一个地板和一个立方体。因此,创建一个平面和一个稍微高于这个平面的立方体:

图 13.10 – 一个空的 Blender 项目

图 13.10 – 一个空的 Blender 项目

在这里,我们只是将立方体向上移动了一点(按 G 键以抓取立方体)并添加了一个平面(添加 | 网格 | 平面),然后我们将这个平面缩放以使其变大。现在,我们可以向场景添加物理效果。在 第十二章 中,向场景添加物理和声音,我们介绍了刚体的概念。Blender 使用相同的方法。选择立方体并使用 对象 | 刚体 | 添加活动,然后选择平面并像这样添加其刚体:对象 | 刚体 | 添加被动。此时,当我们(通过使用 空格键)在 Blender 中播放动画时,你会看到立方体掉落:

图 13.11 – 立方体掉落的一半动画

图 13.11 – 立方体掉落的一半动画

要创建破碎块效果,我们需要启用 单元格裂缝 插件,并勾选复选框以启用插件:

图 13.12 – 启用单元格裂缝插件

图 13.12 – 启用单元格裂缝插件

在我们将立方体分割成更小的部分之前,让我们给模型添加一些顶点,以便 Blender 有一个很好的顶点数量,它可以用来分割模型。为此,在 编辑模式 中选择立方体(通过使用 Tab 键)并从顶部菜单中选择 | 细分。这样做两次,你会得到类似这样的效果:

图 13.13 – 显示具有多个细分部分的立方体

图 13.13 – 显示具有多个细分部分的立方体

Tab 键返回到 对象模式,选中立方体后,打开 单元格裂缝 窗口,然后转到 对象 | 快速效果 | 单元格裂缝

图 13.14 – 配置裂缝

图 13.14 – 配置裂缝

你可以调整这些设置以获得不同类型的裂缝。在 图 13.3 中配置的设置,你会得到类似这样的效果:

图 13.15 – 显示裂缝的立方体

图 13.15 – 显示裂缝的立方体

接下来,选择原始立方体并按 x 键删除它。这将只留下裂缝部分,我们将对它们进行动画处理。为此,选择立方体中的所有单元格,并再次使用 对象 | 刚体 | 添加活动。完成后,按 空格键,你会看到立方体正在下落并在撞击时破碎。

图 13.16 – 立方体掉落后的样子

图 13.16 – 立方体掉落后的样子

到这一点,我们的动画基本上已经准备好了。现在,我们需要导出这个动画,以便我们可以将其加载到 Three.js 中并从那里重新播放它。在这样做之前,请确保将动画的结束(屏幕的右下角)设置为帧 80,因为导出完整的 250 帧并不那么有用。此外,我们需要告诉 Blender 将物理引擎的信息转换为一系列关键帧。这是因为我们不能导出物理引擎本身,所以我们必须烘焙所有网格的位置和旋转,以便我们可以导出它们。为此,再次选择所有单元格,并使用 对象 | 刚体 | 烘焙到关键帧。你可以选择默认设置并点击 导出 glTF2.0 按钮,以获得以下屏幕:

图 13.17 – 动画导出设置

图 13.17 – 动画导出设置

到这一点,我们将为每个单元格创建一个动画,它跟踪单个网格的旋转和位置。有了这些信息,我们可以在 Three.js 中加载场景并设置动画混音器以进行播放:

const mixers = []
const modelAsync = () => {
  const loader = new GLTFLoader()
  return loader.loadAsync('/assets/models/
     blender-cells/fracture.glb').then((structure) => {
    console.log(structure)
    // setup the ground plane
    const planeMesh = structure.scene.
      getObjectByName('Plane')
    planeMesh.material.side = THREE.DoubleSide
    planeMesh.material.color = new THREE.Color(0xff5555)
    // setup the material for the pieces
    const materialPieces = new THREE.MeshStandardMaterial({ color: 0xffcc33 })
    structure.animations.forEach((animation) => {
      const meshName = animation.name.substring
     (0, animation.name.indexOf('Action')).replace('.', '')
      const mesh = structure.scene.
        getObjectByName(meshName)
      mesh.material = materialPieces
      const mixer = new THREE.AnimationMixer(mesh)
      const action = mixer.clipAction(animation)
      action.play()
      mixers.push(mixer)
    })
    applyShadowsAndDepthWrite(structure.scene)
    return structure.scene
  })
}

在渲染循环中,我们需要为每个动画更新混音器:

const clock = new THREE.Clock()
const onRender = () => {
  const delta = clock.getDelta()
  mixers.forEach((mixer) => {
    mixer.update(delta)
  })
}

结果看起来是这样的:

图 13.18 – 在 Three.js 中的爆炸立方体

图 13.18 – 在 Three.js 中的爆炸立方体

我们在这里向您展示的相同原理可以应用于 Blender 支持的不同类型的动画。需要记住的主要一点是,Three.js 不会理解 Blender 使用的物理引擎或其他高级动画模型。因此,当您导出动画时,请确保烘焙动画,以便您可以使用标准的 Three.js 工具回放这些基于关键帧的动画。

在下一节中,我们将更详细地探讨如何使用 Blender 烘焙不同类型的纹理(贴图),然后将其加载到 Three.js 中。我们已经在第十章中看到了实际效果,加载和操作纹理,但在这个章节中,我们将向您展示如何使用 Blender 来烘焙这些贴图。

在 Blender 中烘焙光照贴图和环境遮挡贴图

对于这个场景,我们将回顾第十章中的示例,其中我们使用了 Blender 中的光照贴图。这个光照贴图提供了良好的光照效果,而无需在 Three.js 中实时计算。为此,我们将采取以下步骤:

  1. 在 Blender 中设置一个简单的场景,包含几个模型。

  2. 在 Blender 中设置灯光和模型。

  3. 在 Blender 中将光照烘焙到纹理中。

  4. 导出场景。

  5. 在 Three.js 中渲染一切。

在接下来的章节中,我们将详细讨论每个步骤。

在 Blender 中设置场景

对于这个示例,我们将创建一个简单的场景,并在其中烘焙一些灯光。开始一个新项目,选择默认的立方体并按e然后按z沿z轴挤出以获得一个简单的形状,如下所示:

图 13.19 – 创建一个简单的房间结构

图 13.19 – 创建一个简单的房间结构

一旦你有了这个模型,回到对象模式(使用Tab),在房间中放置几个网格以获得类似以下所示的效果:

图 13.20 – 带有网格的完整房间

图 13.20 – 带有网格的完整房间

目前没有特别之处——只是一个没有灯光的简单房间。在我们添加灯光之前,先改变一下物体的颜色。因此,在 Blender 中,转到材质属性,为每个网格创建一个新的材质,并设置颜色。结果将类似于以下这样:

图 13.21 – 向场景中的不同物体添加颜色

图 13.21 – 向场景中的不同物体添加颜色

接下来,我们将添加一些漂亮的灯光。

向场景添加灯光

对于这个场景的光照,我们将添加基于 HDRI 的良好光照。使用 HDRI 光照,我们不是只有一个光源,而是提供一个将被用作场景光源的图像。对于这个例子,我们从这里下载了一个 HDRI 图像:polyhaven.com/a/thatch_chapel

图 13.22 – 从 Poly Haven 下载 HDRI

图 13.22 – 从 Poly Haven 下载 HDRI

下载后,我们有一个大图像文件,我们可以在 Blender 中使用。为此,从属性编辑器面板打开世界选项卡,选择表面下拉菜单,并选择背景。在此之下,您将找到颜色选项,点击此选项,并选择环境纹理

图 13.23 – 向世界中添加环境纹理

图 13.23 – 向世界中添加环境纹理

接下来,点击打开,浏览到您下载图片的位置,并选择该位置。此时,我们只需渲染场景并查看 HDRI 贴图提供的照明:

图 13.24 – 渲染场景以检查 HDRI 光照

图 13.24 – 渲染场景以检查 HDRI 光照

如您所见,场景已经相当不错,我们无需放置单独的灯光。现在,墙上有一些漂亮的柔和阴影,物体似乎从多个角度被照亮,物体看起来很漂亮。为了将灯光信息作为静态光照贴图使用,我们需要将光照烘焙到纹理上,并将该纹理映射到 Three.js 中的物体上。

烘焙光照纹理

要烘焙光照,首先,我们需要创建一个纹理来存储这些信息。选择立方体(或您想要烘焙光照的任何其他对象)。转到着色视图,在屏幕底部的节点编辑器中,添加一个新的图像纹理项:添加 | 纹理 | 图像纹理。默认值应该适合使用:

图 13.25 – 向纹理图像中添加烘焙光照贴图

图 13.25 – 向纹理图像中添加烘焙光照贴图

接下来,点击您刚刚添加的节点上的新建按钮,并选择纹理的大小和名称:

图 13.26 – 添加用于纹理图像的新图像

图 13.26 – 添加用于纹理图像的新图像

现在,转到属性编辑器面板的渲染选项卡,并设置以下属性:

  • 渲染引擎Cycles

  • 512 或渲染光照贴图将花费非常长的时间。

  • 烘焙菜单中,从烘焙类型菜单中选择漫反射,在影响部分,选择直接间接。这将仅渲染环境光照的影响。

现在,您可以点击烘焙,Blender 将为所选对象渲染光照贴图到纹理中:

图 13.27 – 立方体的渲染光照贴图

图 13.27 – 立方体的渲染光照贴图

就这样。如图 13.29 所示,在左下角的图像查看器中可以看到,我们现在已经得到了一个看起来很不错的立方体渲染光照贴图。您可以通过点击图像查看器中的汉堡菜单将此图像导出为独立的纹理:

图 13.28 – 将光照贴图导出到外部文件

图 13.28 – 将光照贴图导出到外部文件

您现在可以为其他网格重复此操作。但在为盒子做这个之前,我们首先需要快速修复 UV 映射。我们需要这样做,因为我们拉伸了一些顶点以创建类似房间的结构,Blender 需要知道如何正确映射它们。不深入细节,我们可以让 Blender 提出一个创建 UV 映射的建议。点击顶部的UV 编辑菜单,选择平面,进入编辑模式,然后从UV菜单中选择UV | 展开 | 智能展开

图 13.29 – 修复房间网格的 UV

图 13.29 – 修复房间网格的 UV

这将确保为房间的所有侧面生成光照贴图。现在,为所有网格重复此操作,您将拥有这个特定场景的光照贴图。一旦导出所有光照贴图,我们就可以导出场景本身,然后使用这些创建的光照贴图在 Three.js 中渲染:

图 13.30 – 所创建的所有光照贴图

图 13.30 – 所创建的所有光照贴图

现在我们已经烘焙了所有贴图,下一步是从 Blender 导出所有内容,并将场景和贴图导入到 Three.js 中。

导出场景并将其导入 Blender

我们已经在从 Blender 导出静态场景并将其导入 Three.js部分中看到了如何从 Blender 导出场景以在 Three.js 中使用,所以我们将重复这些相同的步骤。点击文件 | 导出 | glTF 2.0。我们可以使用默认设置,因为我们没有动画,所以可以禁用动画复选框。导出后,我们可以将场景导入到 Three.js。如果我们不应用纹理(并使用我们自己的默认灯光),场景将看起来像这样:

图 13.31 – 没有光照贴图的默认灯光渲染的 Three.js 场景

图 13.31 – 没有光照贴图的默认灯光渲染的 Three.js 场景

我们已经在第十章中看到了如何加载和应用光照贴图。以下代码片段展示了如何加载从 Blender 导出的所有光照贴图纹理:

const cubeLightMap = new THREE.TextureLoader().load
  ('/assets/models/blender-lightmaps/cube-light-map.png')
const cylinderLightMap = new THREE.TextureLoader().load
('/assets/models/blender-lightmaps/cylinder-light-map.png')
const roomLightMap = new THREE.TextureLoader().load
  ('/assets/models/blender-lightmaps/room-light-map.png')
const torusLightMap = new THREE.TextureLoader().load
  ('/assets/models/blender-lightmaps/torus-light-map.png')
const addLightMap = (mesh, lightMap) => {
  const uv1 = mesh.geometry.getAttribute('uv')
  const uv2 = uv1.clone()
  mesh.geometry.setAttribute('uv2', uv2)
  mesh.material.lightMap = lightMap
  lightMap.flipY = false
}
const modelAsync = () => {
  const loader = new GLTFLoader()
  return loader.loadAsync('/assets/models/blender-
    lightmaps/light-map.glb').then((structure) => {
    const cubeMesh = structure.scene.
      getObjectByName('Cube')
    const cylinderMesh = structure.scene.
      getObjectByName('Cylinder')
    const torusMesh = structure.scene.
      getObjectByName('Torus')
    const roomMesh = structure.scene.
      getObjectByName('Plane')
    addLightMap(cubeMesh, cubeLightMap)
    addLightMap(cylinderMesh, cylinderLightMap)
    addLightMap(torusMesh, torusLightMap)
    addLightMap(roomMesh, roomLightMap)
    return structure.scene
  })
}

现在,当我们查看相同的场景(import-from-blender-lightmap.html)时,我们有一个非常好的光照场景,尽管我们没有提供任何光源,而是使用了 Blender 中的烘焙灯光:

图 13.32 – 应用 Blender 烘焙的光照贴图的相同场景

图 13.32 – 应用 Blender 烘焙的光照贴图的相同场景

如果我们导出光照贴图,我们隐式地也得到了关于阴影的信息,因为在那些位置,光线当然会更少。我们还可以从 Blender 中获得更详细的阴影贴图。例如,我们可以生成环境遮蔽贴图,这样我们就不需要在运行时创建它们。

在 Blender 中烘焙环境遮蔽贴图

如果我们回到我们已有的场景,我们也可以烘焙环境遮蔽贴图。这个方法与烘焙光照贴图的方法相同:

  1. 设置场景。

  2. 添加所有产生阴影的光源和对象。

  3. 确保你在着色器编辑器中有一个空的图像纹理,我们可以将阴影烘焙到这个纹理上。

  4. 选择相关的烘焙选项并将阴影渲染到图像中。

由于前三个步骤与光照贴图相同,我们将跳过这些步骤,看看渲染阴影贴图所需的渲染设置:

图 13.33 – 烘焙环境遮蔽贴图的渲染设置

图 13.33 – 烘焙环境遮蔽贴图的渲染设置

如你所见,你只需要更改烘焙类型下拉菜单为环境遮蔽。现在,你可以选择要烘焙这些阴影的网格并点击烘焙按钮。对于房间网格,结果看起来像这样:

图 13.34 – 作为纹理的环境遮蔽贴图

图 13.34 – 作为纹理的环境遮蔽贴图

Blender 提供了一些其他烘焙类型,你可以使用它们来获得看起来很好的纹理(特别是对于场景的静态部分),这可以大大提高渲染性能。

对于本节关于 Blender 的内容,我们还将探讨一个主题,那就是如何使用 Blender 更改纹理的 UV 贴图。

在 Blender 中进行自定义 UV 建模

在本节中,我们将从一个新的空 Blender 场景开始,并使用默认的立方体进行实验。为了获得 UV 贴图工作原理的良好概述,你可以使用一种称为 UV 网格的东西,它看起来像这样:

图 13.35 – 一个示例 UV 纹理

图 13.35 – 一个示例 UV 纹理

当你将此作为默认立方体的纹理应用时,你会看到网格的各个顶点如何映射到纹理上的特定位置。为了使用这个,我们首先需要定义这个纹理。你可以轻松地从屏幕右侧的属性视图中的材质属性中进行此操作。点击基础颜色属性前的黄色点并选择图像纹理。这允许你浏览并选择用作纹理的图像:

图 13.36 – 在 Blender 中向网格添加纹理

图 13.36 – 在 Blender 中向网格添加纹理

你已经在主视图中看到这个纹理是如何应用到立方体上的。如果我们将这个网格及其材质导出到 Three.js 并渲染它,我们会看到完全相同的映射,因为 Three.js 将使用 Blender 定义的 UV 映射 (import-from-blender-uv-map-1.html):

图 13.37 – 在 Three.js 中渲染的带有 UV 网格的盒子

图 13.37 – 在 Three.js 中渲染的带有 UV 网格的盒子

现在,让我们切换回 Blender,并打开 UV 编辑 选项卡。在屏幕右侧的 编辑模式(使用 Tab 键)中选择四个面向前方的顶点。当你选择这些顶点后,你会在屏幕左侧看到这四个顶点的位置。

图 13.38 – UV 编辑器显示组成立方体前表面的四个像素的映射

图 13.38 – UV 编辑器显示组成立方体前表面的四个像素的映射

在 UV 编辑器中,你现在可以抓取 (g) 顶点并将它们移动到纹理上的不同位置。例如,你可以将它们移动到纹理的边缘,如下所示:

图 13.39 – 一侧映射到完整纹理

图 13.39 – 一侧映射到完整纹理

移动顶点将导致一个看起来像这样的立方体:

图 13.40 – 带有自定义 UV 映射的 Blender 立方体渲染

图 13.40 – 带有自定义 UV 映射的 Blender 立方体渲染

当然,当我们导出并导入这个最小模型时,这也在 Three.js 中直接显示:

图 13.41 – 带有自定义 UV 映射的立方体的 Three.js 视图

图 13.41 – 立方体的 Three.js 视图,带有自定义 UV 映射

使用这种方法,可以非常容易地定义你的网格的哪些部分应该映射到纹理的哪个部分。

摘要

在本章中,我们探讨了如何与 Blender 和 Three.js 协同工作。我们展示了如何使用 glTF 格式作为标准格式在 Three.js 和 Blender 之间交换数据。这对于网格、动画和大多数纹理来说效果很好。然而,对于高级纹理属性,你可能需要在 Three.js 或 Blender 中进行一些微调。我们还展示了如何在 Blender 中烘焙特定的纹理,如光照贴图和环境遮挡贴图,并在 Three.js 中使用它们。这允许你在 Blender 中一次性渲染这些信息,将其导入到 Three.js 中,并创建出优秀的阴影、灯光和环境遮挡,而无需 Three.js 通常必须进行的复杂计算。请注意,当然,这仅适用于光照静态、几何体和网格不移动或改变形状的场景。通常,你可以用这个方法来处理场景中的静态部分。最后,我们简要地探讨了 UV 贴图的工作原理,即顶点被映射到纹理上的一个位置,以及如何使用 Blender 来玩转这种映射。再次强调,通过使用 glTF 作为交换格式,Blender 中的所有信息都可以轻松地用于 Three.js。

我们现在即将结束这本书的阅读。在最后一章,我们将探讨两个额外的主题——如何将 Three.js 与 React.js 结合使用,以及我们将快速浏览 Three.js 对 VR 和 AR 的支持。

第十四章:Three.js 与 React、TypeScript 和 Web-XR 一起使用

在本章的最后,我们将深入探讨两个额外的主题。首先,我们将探讨如何将 Three.js 与 TypeScript 和 React 结合使用。本章的第二部分将展示一些示例,说明您如何将 3D 场景与 Web-XR 集成。使用 Web-XR,您可以增强场景以与 VR 和 AR 技术一起工作。

更具体地说,我们将向您展示以下示例:

  • 使用 TypeScript 与 Three.js:对于第一个示例,我们将向您展示如何创建一个结合了 Three.js 和 TypeScript 的简单项目。我们将创建一个非常简单的应用程序,类似于我们在前几章中已经看到的示例,并展示您如何使用 TypeScript 与 Three.js 一起创建场景。

  • 使用 TypeScript 和 React 与 Three.js:React 是一个非常流行的 Web 开发框架,通常与 TypeScript 一起使用。在本节中,我们将创建一个简单的 Three.js 项目,该项目使用 React.js 和 TypeScript。

  • React-three-fiber。使用这个库,我们可以使用一组 React 组件声明性地配置 Three.js。这个库提供了 React 和 Three.js 之间出色的集成,使得在 React 应用程序中使用 Three.js 变得简单直接。

  • Three.js 和 VR:本节将向您展示如何以 VR 方式查看您的 3D 场景。

  • Three.js 和 AR:本节将解释如何创建一个简单的 3D 场景,您可以在其中添加 Three.js 网格。

让我们从本章的第一个示例开始,将 Three.js 与 TypeScript 集成。

使用 TypeScript 与 Three.js

TypeScript 提供了一种类型化的语言,它可以编译成 JavaScript。这意味着您可以使用它来创建您的网站,并且它在浏览器中运行得就像正常的 JavaScript 一样。设置 TypeScript 项目有许多不同的方法,但最简单的方法是由 Vite 提供(vitejs.dev/)。Vite 提供了一个集成的构建环境,可以看作是 webpack(我们用于正常章节示例)的替代品。

我们需要做的第一件事是创建一个新的 Vite 项目。您可以自己执行这些步骤,或者您可以直接在three-ts文件夹中运行 yarn install 以跳过此设置。要获取一个空的 TypeScript 项目,我们只需在控制台中运行以下代码:

$ yarn create vite three-ts --template vanilla-ts
yarn create v1.22.17
warning package.json: No license field
[1/4]   Resolving packages...
[2/4]   Fetching packages...
[3/4]   Linking dependencies...
[4/4]   Building fresh packages...
warning Your current version of Yarn is out of date. The latest version is "1.22.19", while you're on "1.22.17".
info To upgrade, run the following command:
$ curl --compressed -o- -L https://yarnpkg.com/install.sh | bash
success Installed "create-vite@3.2.1" with binaries:
      - create-vite
      - cva
[######################################################################] 70/70
Scaffolding project in /Users/jos/dev/git/personal/ltjs4-all/three-ts...

接下来,切换到目录(three-ts)并运行 yarn install

$ yarn install
yarn install v1.22.17
warning ../package.json: No license field
info No lockfile found.
[1/4]   Resolving packages...
[2/4]   Fetching packages...
[3/4]   Linking dependencies...
[4/4]   Building fresh packages...
success Saved lockfile.
  Done in 3.31s.

到目前为止,我们有一个空的 Vite 项目,您可以通过运行 yarn vite 来启动它。

$  three-ts git:(main)  yarn vite
yarn run v1.22.17
warning ../package.json: No license field
$ /Users/jos/dev/git/personal/ltjs4-all/three-ts/node_modules/.bin/vite
  VITE v3.2.3  ready in 193 ms
    Local:   http://127.0.0.1:5173/
    Network: use --host to expose

如果您将浏览器指向 http://127.0.0.1:5173/,您将看到 Vite 的起始页面,并且您将有一个配置好的 TypeScript 项目:

图 14.1 – 使用 Vite 的空 TypeScript 项目

图 14.1 – 使用 Vite 的空 TypeScript 项目

接下来,我们必须添加 Three.js 库,之后我们可以添加一些 TypeScript 代码来初始化 Three.js。要添加 Three.js,我们需要添加以下两个 node 模块:

$ yarn add three
$ yarn add -D @types/three

第一个添加了 Three.js 库,而第二个添加了 Three.js 库的types描述。这些types在编辑器中使用,以便在 IDE(例如 Visual Studio Code)中与 Three.js 和 TypeScript 一起工作时获得一些不错的代码补全。到目前为止,我们已经准备好将 Three.js 添加到这个项目中,并开始使用 TypeScript 开发 Three.js 应用程序。要添加 TypeScript,我们首先需要快速查看应用程序是如何初始化的。为此,你可以查看public.html文件,它看起来像这样:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width,
      initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="img/main.ts"></script>
  </body>
</html>

在前面的代码中,正如你在最后的script行中可以看到的,这个 HTML 页面加载了src/main/ts文件。打开此文件并将其内容更改为以下内容:

import './style.css'
import { initThreeJsScene } from './threeCanvas'
const mainElement = document.querySelector
  <HTMLDivElement>('#app')
if (mainElement) {
  initThreeJsScene(mainElement)
}

这里编写的代码将尝试找到主要的#app节点。如果找到该节点,它将把该节点传递给定义在threeCanvas.ts文件中的initThreeJsScene函数。此文件包含初始化 Three.js 场景的代码:

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/
  controls/OrbitControls'
export const width = 500
export const height = 500
export const initThreeJsScene = (node: HTMLDivElement) => {
  const scene = new THREE.Scene()
  const camera = new THREE.PerspectiveCamera(75, height /
    width, 0.1, 1000)
  const renderer = new THREE.WebGLRenderer()
  renderer.setClearColor(0xffffff)
  renderer.setSize(height, width)
  node.appendChild(renderer.domElement)
  camera.position.z = 5
  const geometry = new THREE.BoxGeometry()
  const material = new THREE.MeshNormalMaterial()
  const cube = new THREE.Mesh(geometry, material)
  const controls = new OrbitControls(camera, node)
  scene.add(cube)
  const animate = () => {
    controls.update()
    requestAnimationFrame(animate)
    cube.rotation.x += 0.01
    cube.rotation.y += 0.01
    renderer.render(scene, camera)
  }
  animate()
}

这将看起来与我们在前几章中创建的初始简单场景的代码相似。主要的变化是,在这里,我们可以使用 TypeScript 提供的所有功能。Vite 将处理将 JavaScript 进行转换,因此你不需要做任何事情就可以在浏览器中看到结果:

图 14.2 – 带有 Three.js 的简单 TypeScript 项目

图 14.2 – 带有 Three.js 的简单 TypeScript 项目

现在我们已经介绍了 Three.js 和 TypeScript,让我们更进一步,看看我们如何将它们与 React 集成。

使用 TypeScript 和 React 与 Three.js

从零开始创建 React 应用程序有不同的方法(例如,Vite 也支持这一点),但最常见的方法是使用命令行中的yarn create react-app lts-tf --template TypeScript命令。就像 Vite 一样,这将创建一个新的项目。对于这个例子,我们在lts-tf目录中创建了此项目。一旦创建,我们必须像为 Vite 做的那样添加 Three.js 库:

$ yarn create react-app lts-tf --template TypeScript
...
$ cd lts-tf
$ yarn add three
$ yarn add -D @types/three
$ yarn install

这应该设置一个简单的 React TypeScript 应用程序,添加正确的 Three.js 库,并安装所有其他必需的模块。下一步是快速检查这一切是否正常工作。运行yarn start命令:

$ yarn start
Compiled successfully!
You can now view lts-tf in the browser.
  Local:            http://localhost:3000
  On Your Network:  http://192.168.68.112:3000
Note that the development build is not optimized.
To create a production build, use yarn build.
webpack compiled successfully
Files successfully emitted, waiting for typecheck results...
Issues checking in progress...
No issues found.

打开你的浏览器到http://localhost:3000,你会看到一个简单的 React 启动屏幕:

图 14.3 – 带有 Three.js 的简单 TypeScript 项目

图 14.3 – 带有 Three.js 的简单 TypeScript 项目

在这个屏幕上,我们可以看到我们需要编辑app.tsx文件,所以我们将更新它,类似于我们在使用 TypeScript 与 Three.js部分看到的纯 TypeScript 示例,但这次作为一个 React 组件:

import './App.css'
import { ThreeCanvas } from './ThreeCanvas'
function App() {
  return (
    <div className="App">
      <ThreeCanvas></ThreeCanvas>
    </div>
  )
}
export default App

如您所见,这里我们定义了一个名为 ThreeCanvas 的自定义组件,现在它在应用程序启动时立即加载。Three.js 初始化代码由 ThreeCanvas 元素提供,您可以在 ThreeCanvas.tsx 文件中找到它。这个文件的大部分内容与我们在 使用 TypeScript 的 Three.js 部分中描述的 initThreeJsScene 函数类似,但为了完整性,我们将在这里包含整个文件:

import { useCallback, useState } from 'react'
import * as THREE from 'three'
const initThreeJsScene = (node: HTMLDivElement) => {
  const scene = new THREE.Scene()
  const camera = new THREE.PerspectiveCamera(75, 500 / 500,
    0.1, 1000)
  const renderer = new THREE.WebGLRenderer()
  renderer.setClearColor(0xffffff)
  renderer.setSize(500, 500)
  node.appendChild(renderer.domElement)
  camera.position.z = 5
  const geometry = new THREE.BoxGeometry()
  const material = new THREE.MeshNormalMaterial()
  const cube = new THREE.Mesh(geometry, material)
  scene.add(cube)
  const animate = () => {
    requestAnimationFrame(animate)
    cube.rotation.x += 0.01
    cube.rotation.y += 0.01
    renderer.render(scene, camera)
  }
  animate()
}
export const ThreeCanvas = () => {
  const [initialized, setInitialized] = useState(false)
  const threeDivRef = useCallback(
    (node: HTMLDivElement | null) => {
      if (node !== null && !initialized) {
        initThreeJsScene(node)
        setInitialized(true)
      }
    },
    [initialized]
  )
  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        height: '100vh'
      }}
      ref={threeDivRef}
    ></div>
  )
}

initThreeJsScene 中,您可以找到使用 TypeScript 初始化简单 Three.js 场景的标准代码。为了将这个 Three.js 场景连接到 React,我们可以使用来自 ThreeCanvas 功能 React 组件的代码。我们在这里想要做的是在 div 元素附加到其父节点时初始化 Three.js 场景。为此,我们可以使用 useCallback 函数。这个函数将在节点第一次附加到父节点时被调用,即使父节点的一个属性发生变化,它也不会重新运行。在我们的情况下,我们还将添加另一个 isInitialized 状态,以确保即使在开发服务器重新加载应用程序的部分时,我们也只初始化我们的 Three.js 场景一次。

useRef 或 useCallback

你可能会想在这里使用 useRef。在 https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node 有一个很好的解释,说明了为什么在这种情况下,你应该使用 useCallback 而不是 useRef 来避免不必要的重新渲染。

在前面的设置完成后,我们现在可以看到结果:

图 14.4 – 使用 TypeScript 和 React 的 Three.js

图 14.4 – 使用 TypeScript 和 React 的 Three.js

在上一个示例中,我们创建了一个简单的 React 和 Three.js 之间的集成。虽然这可行,但用程序描述 Three.js 场景感觉有点奇怪,因为在 React 中,通常使用组件声明性地声明应用程序。我们可以像处理 ThreeCanvas 组件那样包装现有的 Three.js 组件,但这会很快变得复杂。幸运的是,不过,Three.js fibers 项目已经为我们完成了所有这些艰苦的工作:https://docs.pmnd.rs/react-three-fiber/getting-started/introduction。在下一节中,我们将探讨如何借助这个项目轻松地将 Three.js 和 React 集成在一起。

使用 React Three Fiber 与 Three.js 和 React 集成

在前面的示例中,我们自行设置了 React 和 Three.js 之间的集成。虽然这可行,但这种方法并没有紧密地与 React 的工作方式集成。为了这些框架之间良好的集成,我们可以使用 React Three Fiber。我们将再次从设置项目开始。

为了做到这一点,请运行以下命令:

$ yarn create react-app lts-r3f
$ cd lts-3rf
$ yarn install
$ yarn add three
$ yarn add @react-three/fiber

这将安装我们需要的所有依赖项并设置一个新的 React 项目。要启动lts-r3f目录中的此项目,运行yarn start,这将启动一个服务器。打开屏幕上显示的 URL(http://localhost:3000);你会看到以下屏幕,这是我们之前看到的,显示了一个空的 React 项目:

图 14.5 – 启动一个简单的 JavaScript React 应用程序

图 14.5 – 启动一个简单的 JavaScript React 应用程序

当屏幕开始扩展这个示例时,我们需要编辑app.jsx文件。我们将创建一个新的组件,该组件将包含我们的 Three.js 场景:

import './App.css'
import { Canvas } from '@react-three/fiber'
import { Scene } from './Scene'
function App() {
  return (
    <Canvas>
      <Scene />
    </Canvas>
  )
}
export default App

在这里,我们已经可以看到 Three Fiber 组件中的第一个——Canvas元素。Canvas元素创建一个Canvas div,并且是所有由这个库提供的其他 Three.js 组件的父容器。由于我们将Scene作为子组件添加到这个Canvas组件中,我们可以在我们的自定义组件中定义完整的 Three.js 场景。接下来,我们将创建这个Scene组件:

import React from 'react'
export const Scene = () => {
  return (
    <>
      <ambientLight intensity={0.1} />
      <directionalLight color="white" intensity={0.2}
       position={[0, 0, 5]} />
      <mesh
        rotation={[0.3, 0.6, 0.3]}>
        <boxGeometry args={[2, 5, 1]} />
        <meshStandardMaterial color={color}
          opacity={opacity} transparent={true} />
      </mesh>
    </>
  )
}

我们这里有一个非常简单的 Three.js 场景,它看起来与我们在本书中之前看到的类似。这个场景包含以下对象:

  • <ambientLight>:一个Three.AmbientLight对象的实例。

  • <directionalLight>:一个Three.DirectionalLight对象的实例。

  • <mesh>:这代表一个Three.Mesh。正如我们所知,一个Three.Mesh包含一个几何体和一个材质,这些作为此元素的子元素定义。在这个示例中,我们还设置了此网格的旋转。

  • <boxGeometry>:这与Three.BoxGeometry类似,我们通过args属性传递构造函数参数。

  • <meshStandardMaterial>:这创建了一个THREE.MeshStandardMaterial的实例,并在这个材质上配置了一些属性。

现在,当你打开浏览器到localhost:3000时,你会看到一个 Three.js 场景:

图 14.6 – 使用 React Three Fiber 渲染的 Three.js 场景

图 14.6 – 使用 React Three Fiber 渲染的 Three.js 场景

在这个示例中,我们只展示了 React Three Fiber 提供的一些小元素。所有由 Three.js 提供的对象都可以按照我们刚刚展示的方式进行配置。只需将它们作为元素添加,配置它们,它们就会在屏幕上显示。除了轻松显示这些元素之外,所有这些元素都表现得像正常的 React 组件。因此,每当父元素的属性发生变化时,所有元素都会重新渲染(或更新)。

除了 React Three Fiber 提供的元素之外,还有一整套由@react-three/drei提供的附加组件。你可以在github.com/pmndrs/drei找到这些组件及其描述:

图 14.7 – 来自@react-three/drei 的附加组件

图 14.7 – 来自@react-three/drei 的附加组件

对于下一个示例,我们将使用这个库提供的一些组件,因此我们需要将以下内容添加到我们的项目中:

$ yarn add @react-three/drei

现在,我们将扩展我们的示例到这一点:

import React, { useState } from 'react'
import './App.css'
import { OrbitControls, Sky } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
export const Scene = () => {
  // run on each render of react
  // const size = useThree((state) => state.size)
  const mesh = React.useRef()
  const [color, setColor] = useState('red')
  const [opacity, setOpacity] = useState(1)
  const [isRotating, setIsRotating] = useState(false)
  // run on each rerender of
  useFrame(({ clock }, delta, xrFrame) => {
    if (isRotating) mesh.current.rotation.x += 0.01
  })
  return (
    <>
      <Sky distance={450000} sunPosition={[0, 1, 0]}
        inclination={0} azimuth={0.25} />
      <ambientLight intensity={0.1} />
      <directionalLight color="white" intensity={0.2}
        position={[0, 0, 5]} />
      <OrbitControls></OrbitControls>
      <mesh
        ref={mesh}
        rotation={[0.3, 0.6, 0.3]}
        onClick={() => setColor('yellow')}
        onPointerEnter={() => {
          setOpacity(0.5)
          setIsRotating(true)
        }}
        onPointerLeave={() => {
          setOpacity(1)
          setIsRotating(false)
        }}
      >
        <boxGeometry args={[2, 5, 1]} />
        <meshStandardMaterial color={color}
          opacity={opacity} transparent={true} />
      </mesh>
    </>
  )
}

在查看浏览器中的结果之前,让我们先探索一下代码。首先,我们将查看我们添加到组件中的新元素:

  • <OrbitControls>:这是由drei库提供的。这将在场景中添加一个THREE.OrbitControls元素。这与我们在早期章节中使用的OrbitControls相同。正如你所看到的,只需添加元素就足够了;不需要额外的配置。

  • <Sky>:此元素为场景提供了一个漂亮的背景天空。

我们还添加了几个标准的 React Hooks:

  const mesh = React.useRef()
  const [color, setColor] = useState('red')
  const [opacity, setOpacity] = useState(1)
  const [isRotating, setIsRotating] = useState(false)

在这里,我们定义了一个Ref,我们将其连接到网格(<mesh ref={mesh}) ..>)。我们使用这个Ref是为了在渲染循环中稍后访问 Three.js 组件。我们还使用了三次useState来跟踪材质的coloropacity状态值,以及查看mesh属性是否正在旋转。这两个 Hooks 中的第一个用于我们在网格上定义的事件:

      <mesh
        onClick={() => setColor('yellow')}
        onPointerEnter={() => {
          setOpacity(0.5)
          setIsRotating(true)
        }}
        onPointerLeave={() => {
          setOpacity(1)
          setIsRotating(false)
        }}>

使用这些事件处理器,我们可以非常容易地将鼠标与网格集成。不需要RayCaster对象——只需添加事件监听器即可完成。在这种情况下,当鼠标指针进入我们的网格时,我们改变opacity状态值和isRotation标志。当鼠标离开我们的网格时,我们将opacity状态值恢复,并将isRotation标志再次设置为false。最后,当我们点击网格时,我们将颜色更改为黄色

coloropacity状态值可以直接在meshStandardMaterial中使用,如下所示:

<meshStandardMaterial color={color} opacity={opacity}
  transparent={true} />

现在,当触发相关事件时,透明度和颜色将自动更新。对于旋转,我们想使用 Three.js 渲染循环。为此,React Three Fiber 提供了一个额外的钩子:

  useFrame(({ clock }, delta, xrFrame) => {
    if (isRotating) mesh.current.rotation.x += 0.01
  })

useFrame在 Three.js 中每次有渲染循环时都会被调用。在这种情况下,我们检查isRotating状态,如果我们应该旋转,我们使用之前定义的useRef引用来获取对底层 Three.js 组件的访问权限,并简单地增加其旋转。这一切都非常简单方便。浏览器中的结果如下所示:

图 14.8 – 使用 React 和 React Three Fiber 效果的场景

图 14.8 – 使用 React 和 React Three Fiber 效果的场景

React Three Fiber 和drei库几乎提供了你在正常 Three.js 库中拥有的所有功能(以及一些不可用的功能)。如果你使用 React 并且需要集成 Three.js,这是使用 Three.js 的一个很好的方法。即使你并不一定在构建 React 应用程序,React Three Fiber 提供的声明式定义场景、组件和交互的方式也非常直观。React Three Fiber 为任何你想要创建的 Three.js 可视化提供了一个很好的替代方案。

在接下来的两节中,我们将探讨如何通过 AR 和 VR 功能扩展您的 3D 场景。我们将首先查看如何在场景中启用 VR。

Three.js 和 VR

在我们查看所需的代码更改之前,我们将向浏览器添加一个扩展,以便我们可以模拟 VR 头戴式设备和 VR 控制。这样,您可以在不需要物理头戴式设备和物理控制器的情况下测试您的场景。为此,我们将安装 WebXR API 模拟器。此插件适用于 Firefox 和 Chrome:

)

)

按照您特定浏览器的说明。安装后,我们可以使用此示例进行测试:immersive-web.github.io/webxr-samples/immersive-vr-session.html

打开此示例,打开您的开发者控制台,并点击WebXR标签。现在,您将看到如下内容:

图 14.9 – 带有 WebXR API 扩展的 Firefox 浏览器

图 14.9 – 带有 WebXR API 扩展的 Firefox 浏览器

在扩展中,您将看到一个虚拟头戴式设备和一些虚拟 VR 控制。通过点击头戴式设备,您可以模拟真实 VR 头戴式设备的运动;同样适用于控制。如果您点击进入 VR按钮,现在您可以简单地测试您的 VR 场景,而无需实际的 VR 头戴式设备:

图 14.10 – 模拟 VR 头戴式设备

图 14.10 – 模拟 VR 头戴式设备

现在我们已经有一个(虚拟的)头戴式设备可以玩耍了,让我们将我们之前的一个场景转换成一个 VR 场景,在那里我们可以跟踪头部运动并为一些虚拟的 VR 控制添加功能。为此,我们创建了第八章“创建和加载高级网格和几何体”中“First Person Controls”示例的副本。您可以通过打开chapter-14源中的vr.html示例来打开此示例:

图 14.11 – 基于第九章示例的空 VR 场景

图 14.11 – 基于第九章示例的空 VR 场景

为了使您的场景准备好 VR,我们需要采取几个步骤。首先,我们需要告诉 Three.js 我们将启用 Web-XR 功能。这可以这样完成:

renderer.xr.enabled = true

下一步是添加一个简单的按钮,我们可以点击它进入 VR 模式。Three.js 为此提供了一个现成的组件,我们可以这样使用:

import { VRButton } from 'three/examples/jsm/webxr/VRButton'
document.body.appendChild(VRButton.createButton(renderer))

这将创建出在图 14.11 底部可以看到的按钮。

最后,我们需要更新我们的渲染循环。如您从第一章,“使用 Three.js 创建您的第一个 3D 场景”中可能记得,我们使用requestAnimationFrame来控制渲染循环。当与 VR 一起工作时,我们需要稍作改变,如下所示:

animate()
function animate() {
  renderer.setAnimationLoop(animate)
  renderer.render(scene, camera)
  if (onRender) onRender(clock, controls, camera, scene)
}

在这里,我们使用了renderer.setAnimationLoop而不是requestAnimationFrame。此时,我们的场景已经转换为 VR,一旦我们点击按钮,我们就进入 VR 模式并可以环顾我们的场景:

图 14.12 – 使用浏览器扩展进入 VR 模式并旋转相机

图 14.12 – 使用浏览器扩展进入 VR 模式并旋转相机

之前的截图是在您进入 VR 时显示的。现在,您可以通过在 Web-XR 扩展中的 VR 设备上点击并移动它来轻松地移动相机。这些步骤基本上就是将任何 Three.js 场景转换为 VR 场景所需的所有步骤。

如果您仔细观察图 14**.12,您可能会注意到我们还展示了一些手持 VR 设备。我们还没有展示如何添加这些设备。为此,Three.js 也提供了一些不错的辅助组件:

import { XRControllerModelFactory } from
  'three/examples/jsm/webxr/XRControllerModelFactory'
const controllerModelFactory = new
  XRControllerModelFactory()
const controllerGrip1 = renderer.xr.getControllerGrip(0)
controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1))
scene.add(controllerGrip1)
const controllerGrip2 = renderer.xr.getControllerGrip(1)
controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2))
scene.add(controllerGrip2)

通过前面的代码,我们要求 Three.js 获取有关附加控制器的信息,创建一个模型,并将它们添加到场景中。如果您使用 WebXR API 模拟器,您可以移动控件,它们也会在场景中移动。

Three.js 提供了大量示例,展示了您如何使用这些控件拖动对象、在场景中选择对象以及通过控件添加其他交互性。对于这个简单的示例,我们添加了在您点击select按钮时在第一个控制器的位置(右侧的那个)添加一个立方体的选项:

图 14.13 – 在 VR 场景中添加立方体

图 14.13 – 在 VR 场景中添加立方体

我们可以通过简单地给控制器添加事件监听器来实现这一点,如下所示:

const controller = renderer.xr.getController(0)
controller.addEventListener('selectstart', () => {
  console.log('start', controller)
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(0.1,
    0.1, 0.1), new THREE.MeshNormalMaterial())
  mesh.position.copy(controller.position)
  scene.add(mesh)
})
controller.addEventListener('selectend', () => {
  console.log('end', controller)
})

在这段简单的代码中,您可以看到我们给控制器添加了两个事件监听器。当selectstart事件被触发时,我们在控制器的位置添加一个新的立方体。而当selectend事件被触发时,我们只是在控制台记录一些信息。通过 JavaScript 可以访问其他几个事件。有关在 VR 会话中可用的 API 的更多信息,您可以查看以下文档:developer.mozilla.org/en-US/docs/Web/API/XRSession

)

对于最后一部分,我们将快速浏览如何将 Three.js 与 AR 结合使用。

Three.js 和 AR

虽然在许多设备和浏览器上,Three.js 对 VR 的支持良好,但对于 Web-AR 来说并非如此。在 Android 设备上,支持相当不错,但在 iOS 设备上,效果并不理想。苹果公司目前正在努力将此功能添加到 Safari 中,因此一旦实现,原生 AR 也应该在 iOS 上工作。检查哪些浏览器支持此功能的一个好方法是查看caniuse.com/webxr,它提供了所有主要浏览器支持的最新概述。

因此,要测试原生 AR 示例,你需要在 Android 设备上查看,或者使用我们在Three.js 和 VR 部分使用的相同模拟器。

让我们创建一个标准场景,你可以将其用作 AR 实验的起点。我们首先需要做的是告诉 Three.js 我们想要使用 XR:

const renderer = new THREE.WebGLRenderer({ antialias: true,
  alpha: true })
renderer.xr.enabled = true

注意,我们需要将alpha属性设置为true;否则,我们不会看到任何来自摄像头的透传。接下来,就像我们对 VR 所做的那样,我们需要通过调用rendered.xr.enabled在渲染器上启用 AR/VR。

要进入 AR 模式,Three.js 还提供了一个我们可以使用的按钮:

Import { ARButton } from 'three/examples/jsm/
  webxr/ARButton'
document.body.appendChild(ARButton.createButton(renderer))

最后,我们只需将requestAnimationFrame更改为setAnimationLoop

animate()
function animate() {
  renderer.setAnimationLoop(animate)
  renderer.render(scene, camera)
}

这就是全部内容。如果你打开ar.html示例并通过 WebXR 插件(需要选择Samsung Galaxy S8+ (AR)设备)查看此示例,你会看到类似这样的内容:

图 14.14 – 使用设备的原生 AR 功能在 Three.js 中查看 AR 场景

图 14.14 – 使用设备的原生 AR 功能在 Three.js 中查看 AR 场景

在此屏幕截图中,你可以看到一个模拟的 AR 环境,我们可以看到我们渲染的两个对象。如果你移动模拟的手机,你会注意到渲染的对象相对于手机摄像头的位置是固定的。

此处的示例非常简单,但它展示了如何设置最小 AR 场景的基本方法。Web-XR 提供了许多与 AR 相关的其他功能,例如检测平面和碰撞测试。然而,涵盖这些内容略超出了本书的范围。有关 Web-XR 和此 API 公开的原生 AR 功能的信息,你可以查看以下规范:developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API/Fundamentals

摘要

在本章中,我们探讨了与 Three.js 相关的一些技术。我们向您展示了将 Three.js 与 TypeScript 和 React 集成的不同方法,我们还展示了如何创建一些基本的 AR 和 VR 场景。

通过使用 Three.js 的 TypeScript 绑定,你可以轻松地从你的 TypeScript 项目中访问所有 Three.js 功能。通过 React Three Fiber 库,将 Three.js 与 React 集成也变得简单易行。

在 Three.js 中使用 VR 和 AR 也非常简单。只需向主渲染器添加几个属性,您就可以快速将任何场景转换为 VR 或 AR 场景。记得使用浏览器插件来轻松测试您的场景,而无需实际 VR 和 AR 设备。

有了这些,我们就来到了这本书的结尾。希望您喜欢阅读它并尝试使用示例。祝您实验愉快!

posted @ 2025-10-25 10:29  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报