和我一起学 Three.js【初级篇】:2. 掌握几何体

1,313 阅读14分钟

💡 本篇文章共 5090 字,最近更新于 2023 年 04 月 19 日。

0. 系列文章合集

本系列第 6,7,8 章节支持在我的个人公众号「李斌的技术博客」内付费观看,将在全平台文章「点赞数」+「评论数」 >= 500(第 6 章), 1000(第 7,8 章) 时分别解锁发布。

  1. 《和我一起学 Three.js【初级篇】:0. 总论》
  2. 《和我一起学 Three.js【初级篇】:1. 搭建 3D 场景》
  3. 📍 您当前在这里《和我一起学 Three.js【初级篇】:2. 掌握几何体》
  4. 《和我一起学 Three.js【初级篇】:3. 掌握摄影机》
  5. 《和我一起学 Three.js【初级篇】:4. 掌握纹理》
  6. 《和我一起学 Three.js【初级篇】:5. 掌握材质》
  7. 《和我一起学 Three.js【初级篇】:6. 掌握光照》
  8. 《和我一起学 Three.js【初级篇】:7. 掌握阴影》
  9. 《和我一起学 Three.js【初级篇】:8. 融会贯通》

1. 什么是几何体(Geometry)

在 Three.js 的世界中,几何体(Geometry)由顶点(vertices),线,面组成,被用来定义物体的「形状」和「大小」

如果您想要在 3D 世界中「创造」某个物体,您需要首先确定这个物体「长什么样」?然后您就可以通过以下三种方式,创造出该物体:

  1. 使用 Three.js 提供的几何体对象
  2. 使用 Three.js 提供的 API 创建自定义几何体例如创建粒子动画);
  3. 通过 3D 软件导入模型

🛎 在本篇文章中,我们将只介绍前两种创建几何体的方式,在后续很多场景中,您会经常通过这两种方式实现您想要的效果。

2. 几何体与 3D 物体的关系

我们在上一篇文章提到过,在 Three.js 中创建一个「3D 物体」必须通过实例化一个「网格对象(Mesh)」实现。几何体(Geometry),材质(Material)和网格对象(Mesh)三者的关系像是一个金字塔。

「几何体」描述物体的形状和大小,「材质」描述物体的外观和质地,「网格对象」则将两者合并在一起,并提供使物体移动,旋转的能力。因此,要想当好 Web 3D 世界的造物主,您需要对这三个概念非常熟悉。

3. 学习几何体

您需要了解,Three.js 提供的所有现成的几何体,都继承自 BufferGeometry 对象,并且该对象也是 Three.js 为我们提供的创建自定义几何体的 API。因此,要学习几何体,我们要先从该对象说起。

3.1 BufferGeometry 对象

顾名思义,BufferGeometry 对象和「缓冲」相关,具体而言,该对象能够将几何体的相关数据(如顶点,UV,法线等)存入 GPU 的缓冲区(即显存),从而极大的提高 GPU 渲染性能与内存使用效率。

📄 在计算机中,CPU 通常拥有更高的时钟速度和更大的缓存容量,但是并行计算的能力较差,难以处理大量的数据。而 GPU 的并行计算能力特别强,因此能够同时处理大量数据,但是缓存容量较小,时钟速度也较低。而针对 3D 渲染场景,大量的顶点数据需要在一次渲染中同时传递给 GPU 处理,因此对于这些数据的读写效率就成为了 GPU 渲染性能的瓶颈。因此,将顶点数据存储在缓冲区是现代 3D 渲染引擎中不可或缺的技术手段。

我们之前提到过,GPU 绘制几何体的过程,实际上是一个「读点」,「连线」和「结面」的过程。这里「读点」的「点」,指的就是几何体的「顶点」,它来源自开发者的输入,输入到哪里呢?在 Three.js 中,输入至 BufferGeometry 对象。

3.2 通过 BufferGeometry 对象绘制自定义几何体

为了绘制几何体,我们需要设定几何体的「顶点(vertices)」,每个顶点都至少由 3 个数字组成,表示其在空间直角坐标系内的位置。在一个颇具规模的 3D 世界中,我们有一定数量的几何体,意味着我们有大量的顶点数据需要 GPU 解析计算。因此,我们需要一种高效的数据结构存储顶点数据,在 JavaScript 世界中,我们通常使用 Float32Array 这一类型数组。

3.2.1 关于 Float32Array

Float32Array 是 JavaScript 提供的一种类型数组,用来存储 32 位浮点数(即表示 3.4028235 * 10 ^ 38 ~ 1.17549435 * 10 ^ -38 之间的任意数)。其函数签名为:

const array = new Float32Array(length | <Array>);

其中,length 参数表示数组的长度,它和普通数组的区别主要有如下两点:

  1. 数组中的每个元素的类型固定为采用 IEEE 754 标准表示的 32 位浮点数
  2. 它在实现方式上是一个「真正的数组」,即元素占据连续的内存空间

因此它是一种非常高效的数据结构,同时,由于其能够表示的数字范围较大,且可以表示的精度为小数点后 6 到 7 位,因此也非常适合用来存储顶点位置等数据。

3.2.2 自定义几何体

创建一个自定义几何体需要以下三个步骤:

  1. 使用数组定义几何体的顶点(使用 Float32Array 数据类型);
  2. 数组转换为一个 BufferAttribute 对象;
  3. 设置几何体的属性,并填充对应的值;

代码如下:

const geometry = new THREE.BufferGeometry()
const vertices = new Float32Array([
  -1.0, -1.0, 1.0,
   1.0, -1.0, 1.0,
   1.0,  1.0, 1.0,
   1.0,  1.0, 1.0,
  -1.0,  1.0, 1.0,
  -1.0, -1.0, 1.0,
]); // ①
const positionAttribute = new THREE.BufferAttribute(vertices, 3); // ②

geometry.setAttribute("position", positionAttribute) // ③

还记得我们上一章讲过的空间直角坐标系吗?在上面的示例代码中,我们定义了 6 个顶点,分别指定了每个顶点在 xyz 轴上的坐标。 还记得我们说过 BufferGeometry 对象的厉害之处在于能够利用 GPU 缓存提升渲染性能吗?这实际上是 ② 处的 BufferAttribute 函数做到的,它用于将 JavaScript 数组中的数据转换为二进制数据,并存储至 GPU 缓存。该函数接收三个参数:

  1. array:一个类型数组(TypedArray),用于存储数据;
  2. itemSize:元素大小,通常情况下指的是每个顶点需要占用的字节数,例如,每个顶点由三个浮点数(x,y,z)组成,则元素大小为 3,如果每个顶点还有两个浮点数(u,v)表示纹理坐标,则元素大小为 5;
  3. normalized:表示是否归一化,如果值为 true 表示数字会被始终限制在 0 到 1 范围内(这主要是方便数据之间的比较和计算,我们目前不用关注它);

至此,我们替换掉上一篇文章中的 geometry 变量,并开启材质的 wireframe: true 配置,可以得到如下的效果:

恭喜您 💐 !我们刚刚完成了第一个自定义的 3D 图像!

🤔 3.2.3 思考题

  1. 不知道您是否发现,我们刚才创建了 6 个顶点的坐标,但是得到的却是一个矩形图像,请您思考为什么是 6 个顶点而不是 4 个?
  2. 当您关闭 wireframe 配置时,您会发现矩形旋转至一定角度后会突然消失,这是为什么?又该如何解决?

欢迎在评论区留言和我讨论 👋!

3.3 Three.js 提供的几何体

在明白如何通过 BufferGeometry 对象绘制自定义图形后,我们就容易理解 Three.js 所提供的几何体对象不过是基于 BufferGeometry 对象之上的一种封装,因此,我们学习这些几何体的思路是:

  1. 了解 Three.js 提供了哪些现成的几何体:这有助于我们在需要创建物体时,能快速找到对应的几何体;
  2. 了解几何体的共性配置:这有利于我们开发时有一个基本的思路,具体到某个几何体的运用,可以再查询文档寻找解答;

3.3.1 Three.js 提供的几何体

Three.js 目前一共提供了 21 种基础的几何体供开发者使用,我当然不会为您罗列每种几何体的具体属性,您大可以通过官网查询您感兴趣的几何体,在本章节中,您只需要知道在 Three.js 中「都有哪些」几何体可以使用即可。

🛎 Three.js 的官网非常棒,提供了交互式的文档,您可以通过调整参数立即看到立体图形的变化,请您务必亲自前往尝试!

  1. BoxGeometry:创建立方体;

  1. CapsuleGeometry:创建一个「胶囊」体;

  1. CircleGeometry:创建一个圆形或扇形;

  1. ConeGeometry:创建一个椎体,或部分椎体;

  1. CylinderGeometry:创建一个柱体,或部分柱体;

  1. DodecahedronGeometry:创建一个十二面体;

  1. EdgesGeometry:该对象用于创建一个只有「边」的几何体,与需要使用 Mesh 连接几何体和材质不同,它需要使用一个特殊的对象 LineSegemnts
const geometry = new THREE.BoxGeometry( 100, 100, 100 );
const edges = new THREE.EdgesGeometry( geometry );
const line = new THREE.LineSegments( edges, new THREE.LineBasicMaterial( { color: 0xffffff } ) );
scene.add( line );

官网提供了一个非常酷的 Demo 用于说明该对象的效果:

  1. ExtrudeGeometry:根据路径创建一个受挤压的多边体;

  1. IcosahedronGeometry:创建一个二十面体;

  1. LatheGeometry: 创建一个类似花瓶的形状;

  1. OctahedronGeometry:创建一个八面体;

  1. PlaneGeometry:创建一个平面;

  1. PolyhedronGeometry:和 BufferGeometry 类似,通过接收顶点数据与面数据自定义几何体;

  2. RingGeometry: 创建一个环形,或部分环形;

  1. ShapeGeometry:根据路径创建一个多边形;

  1. SphereGeometry:创建一个球体;

  1. TetrahedronGeometry:创建一个四面体;

  1. TorusGeometry:创建一个环体,或部分环体;

  1. TorusKnotGeometry:创建一个结体;

  1. TubeGeometry:根据路径创建管道;

  1. WireframeGeometry:类似 EdgesGeometry,不过生成的将是线框体;

3.3.2 几何体的共性配置

以 BoxGeometry 为例,几何体的公共属性可以分为两类:

  1. 定义几何体的尺寸(长度,宽度,深度);
  2. 定义不同方向上的分段数:WebGL 只能绘制三角形,分段数决定着一个面上的三角形数量(当分段数为 2 时,意味着一个面由 4 个三角形组成),一个面上的三角形数量越多,意味着这个面越平滑,效果会越好,但同时也意味着 GPU 要进行更多的计算,消耗更多的性能;

例如我们创建一个长宽高为 5 的立方体,将宽度方向上的分段数设置为 3,会得到这样的效果:

4. 复杂的几何体:立体文字

除了之前展示的简单的几何体外,Three.js 还提供了一些更加复杂的几何形状,例如** 「凸包几何体(DecalGeometry)」,「贴花几何体(ParametricGeometry)」,「参数化缓冲几何体(ConvexGeometry)」 和接下来将要介绍的一种比较常用的几何体「立体文字(TextGeometry)**」。

不过在此之前,让我们稍微改进一下我们的脚本引用方式:

4.1 使用 Vite 搭建开发环境

Three.js 并没有将立体文字相关的模块放置在核心包内,这意味着我们之前使用 <script> 标签导入 three.js 脚本,从全局对象 THREE 中获取对象的方法已经行不通了。为此,我们需要搭建一个现代 Web 开发环境!

🛎 我选择使用 Vite 搭建开发环境,因为它几乎不需要任何配置就可以搭建一个能满足我们需要的开发环境,由于本篇文章的主题并不是 Vite,因此您若需要自己探索 Vite 其他您感兴趣的地方!

首先,我们执行如下命令创建一个 Vite 项目:

$ npm create vite@latest hello-three-js -- --template vanilla

项目创建成功后,我们执行下面的命令:

$ cd hello-three-js
$ npm install
$ npm install three # 引入 three.js 库
$ npm run dev

大功告成!当您访问 [http://localhost:5173](http://localhost:5173/) 地址时,您应该可以看到 Vite 的默认启动界面:

然后,您需要在 index.html 文件中创建 <canvas id="webgl"></canvas> 标签,并将之前我们所有的代码粘贴至 main.js 文件中覆盖原有代码。 现在,我们成功拥有了一个现代化的前端开发环境!

📌 在之后的文章中,我们会一直使用该环境编写 Demo 帮助我们深入理解 Three.js。

4.2 创建立体文字

TextGeometry 的使用方法与之前简单的几何体略有不同,因为我们需要声明所需渲染文字的「字体」。这需要使用一个新的对象 fontLoader,该对象并不能从 Three.js 中直接导入,而是要通过下面的方式:

import { FontLoader } from 'three/addons/loaders/FontLoader.js'

使用 fontLoader 的方法如下:

const fontLoader = new FontLoader()

fontLoader.load(
    '<facetype>',
    (font) =>
    {
        console.log('loaded')
    }
)

您可以通过 Facetype.js 将一个字体转换为一个 THREE.Font 对象的实例。Three.js 也在 /examples/fonts 目录下提供了一些可选的字体:

🛎 您需要将对应的字体文件拷贝到您的 fonts/ 目录中。

在字体加载成功后,我们的主角就可以登场了:

import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';

TextGeometry 接收两个参数:

  1. 文本内容
  2. 立体字配置

具体代码如下:

fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        const textGeometry = new TextGeometry(
            'Hello Three.js',
            {
                font: font,
                size: 0.5, // 表示文本大小,即字体高度,默认为 100
                height: 0.2, // 表示文本厚度,默认为 50
                curveSegments: 12, // 表示圆角段数,默认为 12
                bevelEnabled: true,// 表示是否启用斜角,默认为 false
                bevelThickness: 0.01, // 表示斜角的深度,默认为 10
                bevelSize: 0.01, // 表示斜角的高度,默认为 8
                bevelOffset: 0, // 表示斜角相对于文本的偏移量,默认为 0
                bevelSegments: 1 // 表示斜角的段数,默认为 3
            }
        )
        const textMaterial = new THREE.MeshBasicMaterial()
        const text = new THREE.Mesh(textGeometry, textMaterial)
        scene.add(text)
    }
)

现在,我们成功将文字添加至我们的场景中!一次真正的 Hello World!

🛎 对 GPU 而言,渲染文字意味着更大的性能开销,因此应该尽量避免对文字做过多的计算,一般来说,通过调小 curveSegmentsbevelSegements 的值是提升渲染性能的好方法。

4.2.1 🤔 思考题

  1. 之前我们创建 3D 物体时,我们都会让其旋转起来,让场景变得更有趣。您知道如何让我们的字体旋转起来吗?

欢迎在评论区留下您的答案 👋!

4.3 如何居中文字

目前为止,我们成功在场景中创建了立体文字,但很多时候,我们需要将文字居中。要实现这一效果,其原理类似于我们在 CSS 中的一种居中解决方案:

.div {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

我们现将对象的左上点放在屏幕的中心,然后在让对象分别向上,左方向「回收」自身一半的宽高距离。幸运的是,在 Three.js 中,立体文字的左上角天然就位于画布的中心位置,所以我们只需要获得文字的宽高,进行位移。

默认情况下,Three.js 会选择使用球形边界包裹物体,我们需要手动将其转换为盒型边界:

textGeometry.computeBoundingBox()

通过 textGeometry.boundingBox 属性,我们可以获得物体在 xyz 轴上的长度,这里有点特别的是,这三个长度挂载在 minmax 属性上,这意味着我们需要这样居中立体文字:

textGeometry.translate(
    - textGeometry.boundingBox.max.x * 0.5,
    - textGeometry.boundingBox.max.y * 0.5,
    - textGeometry.boundingBox.max.z * 0.5
)

大功告成!

5. 总结

在本篇文章中,我向您详细介绍了 Three.js 中一个关键概念:几何体(Geometry)。并阐明了它和 3D 世界物品的关系。我还通过介绍自定义几何体的用法向您说明了 GPU 绘制几何体的原理,并向您展示了所有 Three.js 支持的一般几何体,以及一个特殊的几何体:立体文字。希望您现在对几何体这个概念已经有了深刻的理解,在下一篇文章中,我会为您介绍「摄影机(Camera)」这个非常重要的概念,它可以使我们可交互的观察几何体。 希望您能保持学习的热度,下一篇见 👋。


👋 欢迎关注「前端乱步」公众号,我会在此分享 Web 开发技术,前沿科技与互联网资讯。