和我一起学 Three.js【初级篇】:1. 搭建 3D 场景

2,038 阅读17分钟

💡 本篇文章共 5572 字,最近更新于 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. 理解 3D 场景是如何被渲染的

在上一章中,我们介绍过 Three.js 基于 WebGL 向开发者暴露了更加友好的 API。让开发者可以更加便捷地在浏览器中渲染 3D 场景。这意味着绘制 3D 场景的逻辑实际上是被 WebGL 完成的。

为了绘制 3D 场景我们需要了解多深 WebGL ?」这是一些对 Three.js 刚刚产生兴趣的学习者经常会问的问题,对此,我的看法是:目前您不需要了解太多。您仅需要了解 WebGL 是一种能令开发者在 <canvas> 标签内绘制 3D 图形的 JavaScript API 即可,并且这主要都是通过 GPU 完成的。

您应该很容易理解,所谓的「3D 场景」实际上只是通过「透视」与「光影」,利用了人的视觉错觉所营造的一种假象。

您可能有能力使用 CSS3 提供的 API 绘制出一些简单的 3D 场景,并好奇为什么我们需要使用 Three.js?答案是我们期待更复杂,更具备交互性的 3D 场景,而这需要计算机更加复杂的计算!

当我们切换至计算机内部,我们会发现,想要绘制一个逼真的 3D 场景,需要完成以下工作:

  1. 在一个二维坐标系中打点,这些点将会连成线,由线结成面并最终由面组成体(这里我们所提到的「面」,在计算机看来就是一个个小的「三角形」);
  2. 通过「摄影机所在的位置(即人的观察方向)」和「光源的位置与类型」计算出每个小三角形应该被如何绘制和着色(这个过程称为光栅化);
    1. 摄像机位置 -> 物体的透视;
    2. 光源的位置和类型 -> 物体的阴影和投影;

我们绘制 3D 场景的过程,就是通过 JavaScript 代码以及 Three.js API 提供点的坐标,设置摄影机与光源的位置,然后调用 WebGL,让 GPU 完成「连线」,「涂面」工作的这样一个过程。

如果我们只是想要实现一个静态的 3D 场景,通过 CSS3 或 Canvas 2D 技术实际上也能实现,但假如我们想要完成一个交互性强的复杂场景,例如,摄像头在不断移动,或是光源在不断变化,你可以想象计算机需要实时计算多少次各个小三角形的瞬时状态。

换句话说,3D 场景中交互是否顺畅取决于两个要素:

  1. GPU 的运算效率有多快或算力有多强(硬件标准);
  2. 促使 GPU 执行操作的算法有多高效(软件标准)—— 我们将其称为「着色器」;

您也许可以由此理解这样两件事:

  • 为什么想要流畅体验巫师次时代版本的最高画质需要一片先进地显卡;
  • 为什么游戏公司要求开发者熟练掌握 C 或 C++ 语言;

2. 搭建 3D 场景的基本要素

在理解了计算机绘制 3D 场景背后的逻辑后,我们可以来到应用层看看在 Three.js 世界,绘制一个 3D 场景需要哪些基本要素。 为了让问题尽量简单化,让我们先不考虑光照和阴影的部分,仅仅从透视的角度思考这个问题,我们实际上需要以下 3 个基本要素:

  1. 一个容纳我们 3D 物体的容器,我们称其为「场景(Scene)」;
  2. 一些 3D 物体,在 Three.js 中,每个物体又由两部分组成:
    1. 物体的「形状」:某种类型的几何体;
    2. 物体的「材质」:描述物体外观信息,例如颜色,纹理以及改如何反应光线;
  3. 一个或多个确定的视角:我们将使用「摄影机(Camera)」实现;

有了以上三个要素,仅仅基于透视效果,我们就有能力在屏幕中绘制一些逼真的 3D 图形!下面让我们看看如何通过代码实现:

2.1 引入 Three.js

您有很多方式可以引入 Three.js,例如 npm 包形式引入,CDN 引入或直接使用官网提供的脚本(我们当下采用的方式):

虽然您正在下载的大约 344 MB 的压缩文件看起来有点吓人,但我们真正需要使用的 build/three.min.js 文件只有大约 599 KB。我们需要将其以脚本引入的方式嵌入 HTML 文档:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hello Web 3D World!</title>
  </head>
  <body>
    <h1>Hello Web 3D world!</h1>
    <canvas id="webgl"></canvas> // 注意这里我们添加了一个 id 为 webgl 的 canvas 标签
    <script src="./three.js-master/build/three.min.js"></script>
    <script src="./index.js"></script>
  </body>
</html>

当 Three.js 成功加载后,会在全局对象中挂载 THREE 对象,请确保您自己的脚本在 Three.js 加载完成后执行。

2.2 创建场景

场景(Scene)是一个用于装载 3D 物体,摄影机和灯光的「容器」。在 Three.js 中,我们通过实例化 Scene 构造函数的方式创建场景:

const scene = new THREE.Scene()

目前创建的这个场景实例还没什么用,别担心,我们会在后面用到它。

⚠️ 在 Three.js 中,这种实例化的调用方式非常常见!

2.3 添加物体

正如我们之前提到过的,WebGL 通过驱使 GPU 计算三角形的位置,形状与颜色来模拟 3D 物体。因此在定义一个物体时,我们需要依次指定一个物体的「形状」和「材质」,通过一种特殊的类「Mesh」,我将其称为「网格材料」,在 Three.js 中,Mesh 是表示三维物体的基础类,它将接收两个参数:

  • geometry:定义物体的形状;
  • material:定义物体的材质;

现在,让我们创建一个简单的立方体:

const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial()
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

让我来对以上的代码稍作解释: 首先,我们使用 new THREE.BoxGeometry() 方法创建了一个_长宽高为 1 _的白色立方体,这是我们想要的物体形状。您可能会好奇 BoxGeometry 是什么,答案是:它是 Three.js 提供的多个基础的立体物之一。在之后的章节,我们会具体讲解所有 Three.js 提供的立体物。

您可能会好奇,这里提到的「长宽高为 1 」的单位长度是什么? 1 米?1 公里?或者是 1 毫米?答案可能会令您感到惊讶,实际上具体是什么单位取决于您的需要,Three.js 构筑的 3D 世界是一个相对距离的世界,没有绝对的标尺。例如您的主体物是一个房子,高设置为 3,那么相当于 3 的单位是「米」,那么其他物品就要参照此单位进行相应的缩放。

其次,我们使用 new THREE.MeshBasicMaterial() 方法创建了一个基础网状材质的实例,关于材质,您可以理解为是关乎物体「看起来」什么样的一些属性,这里我们使用的 MeshBasicMaterial,是一种基础的材质类型,它不会响应光照,并且通过一种简单的方法着色。关于材质的更多信息,我们同样会在后面的章节中进行详尽的讲解。

接下来,我们将两个实例对象传入 new THREE.Mesh() 方法中,生成最终的我们想要的物体。我们可以通过材质对象上的 wireframe 属性来直观地理解 WebGL 是如何绘制生成一个立体物的:

material.wireframe = true

看到一个个小三角形了吗?我们再来看一个更复杂的立体物它在「线框模式」下的样子:

很惊人不是吗?

最后,别忘了将我们定义的物体通过 scene.add() 方法添加至场景中。目前为止我们依然在页面中看不到任何东西,这很正常,因为我们还没有完成剩下的两个关键步骤:

  1. 确定 3D 场景中的观察视角;
  2. 命令 WebGL 开始渲染;

2.4 设定摄影机

摄影机是不可见的,但是它却决定了物体应该根据透视的原理被如何绘制。我们可以同时放置多个不同类型的摄像头并在其中切换。

下面的代码定义了一个透视摄像头(它最接近人眼看到的效果),并将摄影机添加到场景中:

const camera = new THREE.PerspectiveCamera(75, 800 / 600)
scene.add(camera)

我们的透视摄影机接收两个参数:

  1. 摄影机的水平视角,又称 fov,如果您拥有一台 VR 头显设备,您应该并不陌生,简单而言,它决定了您水平视野能看多广,当 fov 非常大时,类似您使用广角相机的经验,您能看到的东西会变多,但是边缘的物品会变形;
  2. 屏幕纵横比,即屏幕宽度 / 屏幕高度的值;

2.5 开始渲染

最后一步,我们需要告知 WebGL 去驱动 GPU 进行 3D 场景的渲染。这是通过 WebGLRenderer 实现的,也叫做渲染器。

const renderer = new THREE.WebGLRenderer({
    canvas: document.getElementById("webgl")
})
renderer.setSize(800, 600)
renderer.render(scene, camera)

这段代码看起来很简单:首先,我们通过配置一个 canvas 参数(一个 Canvas DOM Node)实例化了一个渲染器对象,然后我们设置了渲染的宽高,最后我们调用 render 方法,并传入我们的场景和摄影机示例。

这就是我们写一个 3D 场景所需的所有基本元素!

您可能会感到奇怪,如果您自始至终跟随我的步骤,您会发现页面中仍然没有出现期待中的立方体,是的,这是因为默认情况下,摄影机和物体都会被放置在场景的正中位置,因此现在相当于您在立方体内观看 3D 世界,为了看到我们的立方体,我们需要采用如下方式调整我们的摄影机位置:

camera.position.x = 1;
camera.position.z = 3;

这样,我们的立方体终于出现在我们的视野里!

3. 变换物体

虽然目前为止,我们已经成功让一个立方体出现在我们的 Canvas 画布中,但这看起来并不有趣,也并不神奇。别忘了,我们学习 Three.js 是为了创建出可交互的 3D 世界,因此我们需要掌握让我们创建的物体动起来的能力,为此,我们需要先学习如何变换物体。

在 Three.js 中,有一个特殊的类:Object3D,它是很多对象的基类,为很多对象提供了一系列属性和方法。这其中就包括了变换物体的三种方式:

  • 移动位置:position
  • 改变尺寸:scale
  • 旋转:rotation

下面我们依次进行简单的介绍:

3.1 移动位置

position 对象继承自 Vector3 类,它表示了 3D 物体的 3D 向量,3D 向量是一个有序的三元组数字(xyz)可以用来表示很多东西,例如:

  • 3D 空间中的一个点;
  • 3D 空间中某个点距原点的方向和距离;

对于 Vector3 类的说明先到此为止,让我们先看看如何改变一个物体的位置,首先,我们需要知道在 Three.js 中的坐标系,这和我们通常所知道的有些不同:

3.1.1 坐标系

在 Three.js 中,三维直角坐标系分为 xyz 三个轴,它们的关系如下:

  • x:表示水平轴上移动位置,向是正值;
  • y:垂直轴上移动位置,向是正值;
  • z:纵深轴上移动位置,向是正值;

我们可以通过 new THREE.AxesHelper() 方法实例化一个坐标轴,它接收一个数字作为坐标轴的长度。请不要忘记使用 scene.add(axesHelper) 方法将坐标轴加入场景中。

3.1.2 移动位置的方法

Vector3 类还提供了很多用于操作或计算物体距离的方法,例如:

  • length():计算物体至 (0, 0, 0) 点的距离;
  • distanceTo():计算物体到另一指定物体的距离(mesh.position.distanceTo(camera.position));
  • normalize():用来计算向量的标准化(即规格化)长度(标准化是将向量调整为单位长度(长度为 1)的过程);

这样做的好处是,可以将向量的长度与某种物理量(例如速度或加速度)进行比较,而不会因为向量的长度不同而导致比较的不准确。例如,如果两个单位长度的向量都表示速度,那么它们的长度就是相同的,因此可以直接比较它们的大小。标准化向量在许多情况下都很有用,因为它们具有单位长度,因此可以直接比较它们的大小和方向。例如,在游戏中,你可能会使用标准化向量来表示角色的速度,这样可以保证所有角色的速度大小都是相同的,只有方向不同。

  • set():该方法可以一次性依次设置 xyz 的值;

因此,我们可以通过下面的代码移动我们的立方体:

mesh.position.set(1, 0 ,1

可以看到,我们的立方体向上移动了 0.5 个单位的距离,并且向前移动了 1 个单位的距离,这让它看起来更大了。

3.2 改变尺寸

scale 对象和 position 对象一样,同样有 xyz 三个属性,对它们设置一个正数意味着将其放大或缩小多少倍。默认值为 1

3.3 旋转

不同于 positionscale 对象,rotation 对象继承自 Euler 类。顾名思义,Euler 表示欧拉角。

⚠️ 欧拉角描述了一个旋转变换,它通过以每个轴指定的量和指定的轴顺序在其各个轴上旋转对象。这意味着当旋转一个物体时,旋转的顺序非常重要。

rotation 对象同样提供 xyz 属性,但是它们的单位为「弧度」。( Math.PI = 180 度) 为了确保物体旋转的顺序符合我们的预期,我们还可以使用 Euler 类提供的 .reorder() 方法,手动指定旋转轴的顺序,例如当我们不做设置时,默认轴的顺序为 X -> Y -> Z

mesh.rotation.set(Math.PI * 0.5, Math.PI * 0.3, Math.PI * 0.6)

但当我们优先设置轴的顺序时,物体的最终位置就会发生变化:

mesh.rotation.reorder("YXZ");
mesh.rotation.set(Math.PI * 0.5, Math.PI * 0.3, Math.PI * 0.6);

3.4 注视物体

所有的 Object3D 对象实例都包含一个 lookAt() 方法,该方法指定方法调用者始终注视另一个物体。

可以使用该方法将相机旋转到一个物体,例如将大炮对准敌人,或是让角色注视某一物体。

该方法接收一个 Vector 实例或三个参数(xyz),别忘了 mesh.position 对象正是一个 Vector 对象的实例,因此我们可以通过下方的代码让摄影机注视我们的立方体:

camera.lookAt(mesh.position);

4. 添加动画

目前为止,我们学会了如何创建 3D 场景,并在场景中添加物体并改变物体的位置和大小。但这依然有些无聊,所以在这一章,我们要让物体「动起来」以增加一些趣味性,这将通过「动画」技巧来实现。 就像在 2D 环境绘制 3D 物体是通过透视和阴影欺骗人的双眼来实现一样,动画效果本质上也是对人眼的欺骗。只要我们将一组连贯的静态图像以一定的速率快速播放,就会形成图像在运动的效果,即为「动画」。我们将每秒钟播放的图片数量称为「帧率(Frame Rates)」。如果您玩游戏,您可能听说过 FPS 这个名词,它是指每秒渲染帧数(Frame Per Second)这个值越大,意味着越流畅的动画效果和交互体验。

帧率一般由显示器决定,但也受到计算机性能的限制,大多数显示器能够以每秒渲染 60 帧的速度播放动画,这意味着每 16 毫秒显示器就要完成一次对图片的渲染。我们知道 JavaScript 运行在单线程上,要想保障 16 毫秒内的任务不被其他耗时任务阻塞,我们需要使用 window.requestAnimationFrame() 方法。

4.1 requestAnimationFrame API

与事件循环机制不同,requestAnimationFrame 所定义的函数的触发时机并非是「执行栈清空时」,而是「浏览器刷新开始时」。因此我们可以通过下面的方式让我们的立方体动起来:

const animate = () => {
  mesh.rotation.y += 0.01;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
};

animate();

这有点像是一个 while (true) 的无限循环。但是使用这个方法还有一个问题,即 requestAnimationFrame API 内定义的函数的执行时机和浏览器刷新频率相一致,当浏览器刷新频率较低时,我们的动画就会执行的很慢。为了解决这个问题,我们可以获取当前帧和上一帧之间的时间,取名为 deltaTime,然后在每次动画中乘以该时间作为补偿,这样我们就可以不关心浏览器的具体刷新频率,在任何设备中保持相同的动画速率。

const animate = () => {
  const currentTime = Date.now();
  const deltaTime = currentTime - time;
  
  time = currentTime;
  mesh.rotation.y += 0.001 * deltaTime; // 注意这里使用 0.001
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
};

animate();

4.2 Clock 对象

Three.js 为我们提供了 Clock 对象来处理时间计算,我们可以直接通过 getElapsedTime() 获得我们的 deltaTime

const animate = () => {
  const elapsedTime = clock.getElapsedTime();

  mesh.rotation.y = elapsedTime;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
};

animate();

现在,我们获得了一种优雅的方式去执行动画!事情终于开始变得有意思起来了!

zxftw-w2mln.gif

5. 🤔 思考题

  1. 不知道您是否注意到,在「添加动画」这一章节,根据使用的方法不同,每次立方体旋转的定义也不一样,不知道您是否能明白为什么会由此不同?
  2. 当使用 requestAnimationFrame API 时,假如上一帧的函数尚未执行完毕,有到了下一帧执行的时机,那么我们的程序逻辑会如何执行?

欢迎在评论区分享您的见解:)

6. 总结

至此,本篇文章介绍了 Web 3D 世界的渲染原理,以及如何通过 Three.js 搭建一个 3D 场景并添加必要组件,在文章的最后,我们甚至还通过动画和变换属性得到了一个不断旋转的立方体!这便是我们在 Web 3D 世界撰写 Hello World 所需的全部工作。

不得不说,这个过程的确有些复杂,但是您已经和我一起迈入了 Web 3D 世界的大门!恭喜您!未来,我们将共同深入今天所谈及的摄影机,物体,材质等各种概念,当您完全掌握这些概念后,您就可以充分发挥您的创作力在 Web 3D 世界创造任何事物,不知道您是否期待那一天的到来?

PS: 希望您妥善保存这一章节我们共同编写的代码,因为在后续的章节中,我们将使用该代码作为样板代码,并不断深化讲解其中的某一部分内容。


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