和我一起学 Three.js【初级篇】:1. 搭建 3D 场景
欢迎关注「前端乱步」公众号,我会在此分享 Web 开发技术,前沿科技与互联网资讯。
0. 系列文章合集
本系列第 6,7 章节支持微信公众号内付费观看,将在全系列文章点赞数+评论数 >= 500, 1000 时分别解锁发布。
- 《0. 总论》
- 您当前在这里 《1. 搭建 3D 场景》
- 《2. 掌握几何体》
- 《3. 掌握摄影机》
- 《4. 掌握纹理》
- 《5. 掌握材质》
- 《6. 掌握光照》
- 《7. 掌握阴影》
- 《8. 融会贯通,神功小成》(将于 2023.4.17 更新,敬请期待)
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 场景,需要完成以下工作:
- 在一个二维坐标系中打点,这些点将会连成线,由线结成面并最终由面组成体(这里我们所提到的「面」,在计算机看来就是一个个小的「三角形」);
- 通过「摄影机所在的位置(即人的观察方向)」和「光源的位置与类型」计算出每个小三角形应该被如何绘制和着色(这个过程称为光栅化);
- 摄像机位置 -> 物体的透视;
- 光源的位置和类型 -> 物体的阴影和投影;
我们绘制 3D 场景的过程,就是通过 JavaScript 代码以及 Three.js API 提供点的坐标,设置摄影机与光源的位置,然后调用 WebGL,让 GPU 完成「连线」,「涂面」工作的这样一个过程。
如果我们只是想要实现一个静态的 3D 场景,通过 CSS3 或 Canvas 2D 技术实际上也能实现,但假如我们想要完成一个交互性强的复杂场景,例如,摄像头在不断移动,或是光源在不断变化,你可以想象计算机需要实时计算多少次各个小三角形的瞬时状态。
换句话说,3D 场景中交互是否顺畅取决于两个要素:
- GPU 的运算效率有多快或算力有多强(硬件标准);
- 促使 GPU 执行操作的算法有多高效(软件标准)—— 我们将其称为「着色器」;
您也许可以由此理解这样两件事:
- 为什么想要流畅体验巫师次时代版本的最高画质需要一片先进地显卡;
- 为什么游戏公司要求开发者熟练掌握 C 或 C++ 语言;
2. 搭建 3D 场景的基本要素
在理解了计算机绘制 3D 场景背后的逻辑后,我们可以来到应用层看看在 Three.js 世界,绘制一个 3D 场景需要哪些基本要素。
为了让问题尽量简单化,让我们先不考虑光照和阴影的部分,仅仅从透视的角度思考这个问题,我们实际上需要以下 3 个基本要素:
- 一个容纳我们 3D 物体的容器,我们称其为「场景(Scene)」;
- 一些 3D 物体,在 Three.js 中,每个物体又由两部分组成:
- 物体的「形状」:某种类型的几何体;
- 物体的「材质」:描述物体外观信息,例如颜色,纹理以及改如何反应光线;
- 一个或多个确定的视角:我们将使用「摄影机(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() 方法添加至场景中。目前为止我们依然在页面中看不到任何东西,这很正常,因为我们还没有完成剩下的两个关键步骤:
- 确定 3D 场景中的观察视角;
- 命令 WebGL 开始渲染;
2.4 设定摄影机
摄影机是不可见的,但是它却决定了物体应该根据透视的原理被如何绘制。我们可以同时放置多个不同类型的摄像头并在其中切换。
下面的代码定义了一个透视摄像头(它最接近人眼看到的效果),并将摄影机添加到场景中:
const camera = new THREE.PerspectiveCamera(75, 800 / 600)
scene.add(camera)
我们的透视摄影机接收两个参数:
- 摄影机的水平视角,又称
fov,如果您拥有一台 VR 头显设备,您应该并不陌生,简单而言,它决定了您水平视野能看多广,当 fov 非常大时,类似您使用广角相机的经验,您能看到的东西会变多,但是边缘的物品会变形; - 屏幕纵横比,即屏幕宽度 / 屏幕高度的值;
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 向量是一个有序的三元组数字(x,y,z)可以用来表示很多东西,例如:
- 3D 空间中的一个点;
- 3D 空间中某个点距原点的方向和距离;
对于 Vector3 类的说明先到此为止,让我们先看看如何改变一个物体的位置,首先,我们需要知道在 Three.js 中的坐标系,这和我们通常所知道的有些不同:
3.1.1 坐标系
在 Three.js 中,三维直角坐标系分为 x,y 和 z 三个轴,它们的关系如下:
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():该方法可以一次性依次设置x,y,z的值;
因此,我们可以通过下面的代码移动我们的立方体:
mesh.position.set(1, 0 ,1)

可以看到,我们的立方体向上移动了 0.5 个单位的距离,并且向前移动了 1 个单位的距离,这让它看起来更大了。
3.2 改变尺寸
scale 对象和 position 对象一样,同样有 x,y,z 三个属性,对它们设置一个正数意味着将其放大或缩小多少倍。默认值为 1。
3.3 旋转
不同于 position 和 scale 对象,rotation 对象继承自 Euler 类。顾名思义,Euler 表示欧拉角。
️ 欧拉角描述了一个旋转变换,它通过以每个轴指定的量和指定的轴顺序在其各个轴上旋转对象。这意味着当旋转一个物体时,旋转的顺序非常重要。
rotation 对象同样提供 x,y,z 属性,但是它们的单位为「弧度」。( 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 实例或三个参数(x,y,z),别忘了 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();
现在,我们获得了一种优雅的方式去执行动画!事情终于开始变得有意思起来了!

5. 思考题
- 不知道您是否注意到,在「添加动画」这一章节,根据使用的方法不同,每次立方体旋转的定义也不一样,不知道您是否能明白为什么会由此不同?
- 当使用
requestAnimationFrameAPI 时,假如上一帧的函数尚未执行完毕,有到了下一帧执行的时机,那么我们的程序逻辑会如何执行?
欢迎在评论区分享您的见解:)
6. 总结
至此,本篇文章介绍了 Web 3D 世界的渲染原理,以及如何通过 Three.js 搭建一个 3D 场景并添加必要组件,在文章的最后,我们甚至还通过动画和变换属性得到了一个不断旋转的立方体!这便是我们在 Web 3D 世界撰写 Hello World 所需的全部工作。
不得不说,这个过程的确有些复杂,但是您已经和我一起迈入了 Web 3D 世界的大门!恭喜您!未来,我们将共同深入今天所谈及的摄影机,物体,材质等各种概念,当您完全掌握这些概念后,您就可以充分发挥您的创作力在 Web 3D 世界创造任何事物,不知道您是否期待那一天的到来?
PS: 希望您妥善保存这一章节我们共同编写的代码,因为在后续的章节中,我们将使用该代码作为样板代码,并不断深化讲解其中的某一部分内容。
和我一起学 Three.js【初级篇】:1. 搭建 3D 场景的更多相关文章
- 【大型网站技术实践】初级篇:搭建MySQL主从复制经典架构
一.业务发展驱动数据发展 随着网站业务的不断发展,用户量的不断增加,数据量成倍地增长,数据库的访问量也呈线性地增长.特别是在用户访问高峰期间,并发访问量突然增大,数据库的负载压力也会增大,如果架构方案 ...
- Java工程师学习指南 初级篇
Java工程师学习指南 初级篇 最近有很多小伙伴来问我,Java小白如何入门,如何安排学习路线,每一步应该怎么走比较好.原本我以为之前的几篇文章已经可以解决大家的问题了,其实不然,因为我之前写的文章都 ...
- Java工程师学习指南(初级篇)
Java工程师学习指南 初级篇 最近有很多小伙伴来问我,Java小白如何入门,如何安排学习路线,每一步应该怎么走比较好.原本我以为之前的几篇文章已经可以解决大家的问题了,其实不然,因为我之前写的文章都 ...
- PHP代码审计基础-初级篇
对于php代码审计我也是从0开始学的,对学习过程进行整理输出沉淀如有不足欢迎提出共勉.对学习能力有较高要求,整个系列主要是在工作中快速精通php代码审计,整个学习周期5天 ,建议花一天时间熟悉php语 ...
- [转帖]APP逆向神器之Frida【Android初级篇】
APP逆向神器之Frida[Android初级篇] https://juejin.im/post/5d25a543e51d455d6d5358ab 说到逆向APP,很多人首先想到的都是反编译,但是单看 ...
- 1. web前端开发分享-css,js入门篇
关注前端这么多年,没有大的成就,就入门期间积累了不少技巧与心得,跟大家分享一下,不一定都适合每个人,毕竟人与人的教育背景与成长环境心理活动都有差别,但就别人的心得再结合自己的特点,然后探索适合自己的学 ...
- codefordream 关于js初级训练
这里的初级训练相对简单,差不多都是以前知识温习. 比如输出“hello world”,直接使用console.log()就行.注释符号,“//”可以注释单行,快捷键 alt+/,"/* ...
- NSIS安装制作基础教程[初级篇], 献给对NSIS有兴趣的初学者
NSIS安装制作基础教程[初级篇], 献给对NSIS有兴趣的初学者 作者: raindy 来源:http://bbs.hanzify.org/index.php?showtopic=30029 时间: ...
- web前端开发分享-css,js入门篇(转)
转自:http://www.cnblogs.com/jikey/p/3600308.html 关注前端这么多年,没有大的成就,就入门期间积累了不少技巧与心得,跟大家分享一下,不一定都适合每个人,毕竟人 ...
- (转)[jQuery]使用jQuery.Validate进行客户端验证(初级篇)——不使用微软验证控件的理由
以前在做项目的时候就有个很大心病,就是微软的验证控件,虽然微软的验证控件可以帮我们完成大部分的验证,验证也很可靠上手也很容易,但是我就是觉得不爽,主要理由有以下几点: 1.拖控件太麻烦,这个是微软控件 ...
随机推荐
- 修改AXI UART D16550 FIFO深度的过程记录
仅限于AXI UART 16550 v. 2.0,其他版本可能存在差异,经过实际测试,可以将fifo深度从默认的16成功修改为32.128和256.参考了两篇帖子中提到的方法,分别是修改AXI UAR ...
- postcss-px-to-viewport适配屏幕大小
1.postcss-px-to-viewport适配的介绍 postcss-px-to-viewport是一个插件,用起来非常方便,安装一下插件,搞个配置文件就可以直接用了. 2.postcss-px ...
- 基于predis高并发情况下实现频率控制的函数
/** * 频率控制函数 * @param string $product 保持唯一 * @param string $key 限制频率的维度 比如uid * @param int $millisec ...
- linux 获取文件名
https://blog.csdn.net/liuyuedechuchu/article/details/123778605
- 【omr】linux配置omr识别项目moonlight环境
最近又做了第n次moonlight的环境配置 moonlight是相对成熟的omr系统 这里记录环境配置的基本步骤 (总的来说主要是用conda新建符合程序要求的python版本 然后装好bazel和 ...
- django orm性能优化
参考: django 分页查询大表,很慢 面试小知识:MySQL索引相关 MySQL 用 limit 为什么会影响性能? 前言 orm性能优化是一件很重要的事,一般万条以上的数据都需要优化处理了. 这 ...
- Jquery ajax参数设置(转)
参数名 类型 描述 url String (默认: 当前页地址) 发送请求的地址. type String (默认: "GET") 请求方式 ("POST" 或 ...
- ARM体系与架构【一】
由于笔试题(摩尔线程笔试题)也出现了相关的题目,所以也顺便为此做一点点小准备. 1.ARM用什么类型的指令集 ARM架构用的是RISC精简指令集. 2.RISV与RISC指令集有什么区别 3.ARM架 ...
- mysql80解决不支持中文的问题
1.查看mysql80字符集 show variables like 'character_set%'; 2.修改server编码格式 在mysql安装目录下找到my-default.ini文件并复制 ...
- Leecode 53.最大子数组和(Java 贪心算法、动态规划两种方法)
想法(没看解析之前想不出来) -----------------看了解析和答案 1.贪心算法,若当前元素的之前和<0,则丢弃当前元素之前的数列 设一个maxSum作为子序列最大和,一个sum ...