推荐:使用 NSDT场景设计器 快速搭建 3D场景。

很多东西汇集在一起构成一个美丽的 3D 场景,例如光照、材质、模型、纹理、相机设置、后期处理、粒子效果、交互性等等,但无论我们创建什么样的场景,没有比这更多的了 比组成它的乐曲的排列和运动更重要。

要创建建筑效果图,我们必须成为建筑师和室内装饰师。 我们必须考虑建筑物和里面房间的比例,巧妙地放置家具和灯具。 在自然场景中,无论是一朵花的特写,还是广阔的山景,我们都需要以自然而令人信服的方式安排树木和岩石,或叶子和花瓣。 也许一群入侵的机器人会扫过大地,眼睛闪闪发光,手臂和脚摆动,齐声前进,火箭冲向天空,在它们着陆的地方造成巨大的爆炸——在这种情况下,我们必须同时成为机器人设计师和弹道学专家 .

即使是纯粹的抽象场景也需要了解如何在 3D 空间中四处移动对象。

最后,我们还必须成为导演,将摄影机摆好位置,对每个镜头进行艺术构图。 创建 3D 场景时,唯一的限制是您的想象力 - 以及您的技术知识深度。

在 3D 空间中四处移动对象是学习 three.js 的一项基本技能。 我们将这项技能分为两部分:首先,我们将探索用于描述 3D 空间的坐标系,然后我们将探索称为变换的数学运算,用于在坐标系内移动对象。

在此过程中,我们会遇到几个数学对象,例如场景图,一种用于描述构成场景的对象层次结构的结构,向量,用于描述 3D 空间中的位置(以及许多其他事物) ,以及不少于两种描述旋转的方式:欧拉角和四元数。 我们将通过向您介绍变换矩阵来结束本章,变换矩阵用于存储对象的完整变换状态。

1、平移、旋转和缩放

每当我们在 3D 空间中四处移动对象时,我们都会使用称为变换的数学运算来进行此操作。 我们已经看到了两种转换:平移,存储在对象的 .position 属性中,旋转,存储在 .rotation 属性中。 与存储在 .scale 属性中的缩放一起,这些构成了我们将用来在场景中四处移动对象的三个基本变换。 我们有时会使用它们的首字母 TRS 来指代变换、旋转和缩放。

我们可以使用 scene.add 添加到场景中的每个对象都具有这些属性,包括网格、灯光和相机,而材质和几何体则没有。 我们之前使用 .position 来设置相机的位置:

camera.position.set(0, 0, 10);

…以及设置有向光的位置:

light.position.set(10, 10, 10);

之前我们也使用 .rotation 来更好地查看我们的立方体:

cube.rotation.set(-0.5, -0.1, 0.8);

到目前为止,我们唯一没有遇到的根本性转变是 .scale。

2、Object3D 基类

不是为每种类型的对象多次重新定义 .position、.rotation 和 .scale 属性,而是在 Object3D 基类上定义一次这些属性,然后可以添加到场景中的所有其他类都派生自该基类 . 这包括网格、相机、灯光、点、线、助手,甚至场景本身。 我们将非正式地将派生自 Object3D 的类称为场景对象。

Object3D 除了这三个之外还有很多属性和方法,被每个场景对象继承。 这意味着定位和设置相机或网格的工作方式与设置灯光或场景的方式大致相同。 然后根据需要将其他属性添加到场景对象,因此灯光获得颜色和强度设置,场景获得背景颜色,网格获得材质和几何形状,等等。

3、场景图

回想一下我们是如何将网格添加到场景中的:

scene.add(mesh);

.add方法也是在Object3D上定义的,和.position、.rotation、.scale一样被场景类继承。 所有其他派生类也继承此方法,为我们提供 light.add、mesh.add、camera.add 等。 这意味着我们可以将对象相互添加以创建树结构,场景位于顶部。 这种树结构被称为场景图。

当我们将一个对象添加到另一个对象时,我们将一个对象称为父对象,将另一个对象称为子对象。

parent.add(child);

场景是顶级父级。 上图中的场景有三个孩子:一个灯光和两个网格。 其中一个网格也有两个孩子。 但是,每个对象(顶级场景除外)只有一个父对象。

场景图中的每个对象(顶级场景除外)只有一个父对象,并且可以有任意数量的子对象。

当我们渲染场景时:

renderer.render(scene, camera);

…渲染器遍历场景图,从场景开始,并使用每个对象相对于其父对象的位置、旋转和比例来确定将其绘制在何处。

4、访问场景的子对象

可以使用 .children 数组访问场景对象的所有子对象:

scene.add(mesh);

// the children array contains the mesh we added
scene.children; // -> [mesh]

// now, add a light:
scene.add(light);

// the children array now contains both the mesh and the light
scene.children; // -> [mesh, light];

// now you can access the mesh and light using array indices
scene.children[0]; // -> mesh
scene.children[1]; // -> light

有更复杂的方法来访问特定的子对象,例如 Object3d.getObjectByName 方法。 但是,当你不知道对象的名称或对象没有名称时,直接访问 .children 数组很有用。

5、坐标系:世界空间和局部空间

Three.js使用 3D 笛卡尔坐标系描述 3D 空间。

3D 笛卡尔坐标系使用在点 (0,0,0)(称为原点)处交叉的 X、Y 和 Z 轴表示。 二维坐标系类似,但只有 X 和 Y 轴。

每个 3D 图形系统都使用这样的坐标系,从 Unity 和 Unreal 等游戏引擎,到 Pixar 用来制作电影的软件,再到 3DS Max、Maya 和 Blender 等专业动画和建模软件。 即使是用于在网页上定位对象的语言 CSS,也使用笛卡尔坐标系。 但是,这些系统之间可能存在细微的技术差异,例如轴的标记不同或指向不同的方向。

在使用 three.js 时,我们会遇到几个 2D 和 3D 坐标系。 在这里,我们将介绍其中最重要的两个:世界空间和局部空间。

6、世界空间

我们的场景定义了世界空间坐标系,系统的中心是 X、Y 和 Z 轴相交的点。

还记得之前当我们第一次介绍 Scene 类时,我们称它为“小宇宙”吗? 这个微小的宇宙就是世界空间。

当我们在场景中布置物体时——无论是在房间中放置家具、在森林中放置树木,还是在战场上狂暴的机器人——我们在屏幕上看到的是每个物体在世界空间中的位置。

当我们将一个对象直接添加到场景中然后对其进行平移、旋转或缩放时,该对象将相对于世界空间移动——即相对于场景的中心。

// add a cube to our scene
scene.add(cube);

// move the cube relative to world space
cube.position.x = 5;

这两个语句是等价的,只要对象是场景的直接子对象:

  • 相对于世界空间变换对象。
  • 在场景中四处移动对象。

每当我们尝试在 3D 中可视化一些棘手的东西时,降低维度并考虑 2D 类比会很有用。 所以,让我们考虑一个棋盘。 当我们安排棋子开始新游戏时,我们将它们放在棋盘上的特定位置。 这意味着棋盘是场景,棋子是我们放置在场景中的对象。

接下来,当我们向某人解释为什么我们这样排列棋子时,一侧是白色,另一侧是黑色,第二行是棋子,等等,我们这样做是相对于棋盘本身的。 该板定义了一个坐标系,Y 轴为行,X 轴为列。 这是棋盘的世界空间,我们解释每个棋子相对于这个坐标系的位置。

现在游戏开始了,我们开始移动棋子。 当我们这样做时,我们遵循国际象棋的规则。 当我们在 three.js 场景中移动对象时,我们遵循笛卡尔坐标系的规则。 这里的类比有点不合理,因为棋盘上的每一块棋子都有自己的移动方式,而在笛卡尔坐标系中,平移、旋转和缩放对于任何类型的对象都是一样的。

7、局部空间

现在,考虑其中一个棋子。 如果要求描述一个棋子的形状,你不会描述它相对于棋盘的样子,因为它可以放在棋盘上的任何地方,而且实际上,即使根本不在棋盘上也能保持它的形状。 相反,你将在脑海中创建一个新的坐标系并描述该作品在那里的外观。

就像棋盘上的棋子一样,我们可以添加到场景中的每一个物体也都有一个局部坐标系,物体的形状(几何)就是在这个局部坐标系内描述的。 当我们创建网格或灯光时,我们还创建了一个新的局部坐标系,网格或灯光位于其中心。 这个局部坐标系有 X、Y 和 Z 轴,就像世界空间一样。 对象的局部坐标系称为局部空间(有时也称为对象空间)。

当我们创建一个 2×2×2的 BoxBufferGeometry,然后使用该几何体创建一个网格时,几何体的大小是网格局部空间中每边的两个单位:

const geometry = new BoxBufferGeometry(2, 2, 2);

const mesh = new Mesh(geometry, material);

正如我们将在下面看到的,我们可以使用 .scale 拉伸或缩小网格,并且屏幕上绘制的网格大小将会改变。 但是,当我们缩放网格时,几何体的大小不会改变。 当渲染器来渲染网格时,它会看到它已经缩放,然后以不同的大小绘制网格。

8、每个对象都有一个坐标系

回顾一下:顶级场景定义了世界空间,每个其他对象定义了自己的局部空间。

// creating the scene creates the world space coordinate system
const scene = new Scene();

// mesh A has its own local coordinate system
const meshA = new Mesh();

// mesh B also has its own local coordinate system
const meshB = new Mesh();

通过以上三行代码,我们创建了三个坐标系。 这三个坐标系在数学上没有区别。 我们可以在世界空间中进行的任何数学运算在任何对象的局部空间中都将以相同的方式工作。

人们很容易将坐标系视为复杂的大事物,但是,在 3D 中工作时,你会发现周围有很多坐标系。 每个对象至少有一个,有的有几个。 渲染场景还涉及另一套完整的坐标系,即将对象从 3D 世界空间转换为在屏幕的平面 2D 表面上看起来不错的东西。 每个纹理甚至都有一个 2D 坐标系。 最后,它们并没有那么复杂,而且制造起来非常便宜。

9、使用场景图

使用每个对象的 .add 和 .remove 方法,我们可以创建和操作场景图。

当我们使用 scene.add 将一个对象添加到我们的场景时,我们将这个对象嵌入到场景的坐标系,世界空间中。 当我们四处移动对象时,它会相对于世界空间(或等效地,相对于场景)移动。

当我们将一个对象添加到场景图中更深处的另一个对象时,我们将子对象嵌入到父对象的局部空间中。 当我们移动子对象时,它会相对于父对象的坐标系移动。 坐标系像俄罗斯套娃一样相互嵌套。

让我们看一些代码。 首先,我们将添加一个对象 A 作为场景的子对象:

scene.add(meshA);

现在,场景是 A 的父级,或者等效地,A 是场景的子级。 接下来,我们将移动A:

meshA.position.x = 5;

现在,A 已在世界空间内沿正 X 轴平移五个单位。 每当我们变换一个对象时,我们都是相对于其父坐标系进行的。 接下来,让我们看看当我们添加第二个对象 B 作为 A 的子对象时会发生什么:

meshA.add(meshB);

A 仍然是场景的子对象,所以我们有关系 Scene⟶A⟶B。 所以,A 是场景的子对象,B 是 A 的子对象。或者,等价地,A 现在生活在世界空间中,B 现在生活在 A 的本地空间中。 当我们移动 A 时,它会在世界空间中四处移动,而当我们移动 B 时,它会在 A 的局部空间中四处移动。

接下来,我们将平移 B:

meshB.position.x = 3;

你认为 B 最终会去哪里?

10、我们看到的是世界空间

当我们调用 .render 时,渲染器会计算每个对象的世界空间位置。 为此,它从场景图的底部开始向上工作,结合每个父对象和子对象的变换,计算每个对象相对于世界空间的最终位置。 我们最终在屏幕上看到的是世界空间。 在这里,我们将为 A 和 B 手动执行此计算。请记住,每个对象都从相对于其父对象的 (0,0,0) 开始。

// A starts at (0,0,0) in world space
scene.add(meshA);

// B starts at (0,0,0) in A's local space
meshA.add(meshB);

meshA.position.x = 5;

meshB.position.x = 3;

计算 A 的位置很容易,因为它是场景的直接子对象。 我们将 A 沿 X 轴向右移动了五个单位,因此它的最终位置为 x=5,y=0,z=0,即 (5,0,0)。

当我们移动 A 时,它的局部坐标系也随之移动,在计算 B 的世界空间位置时我们必须考虑到这一点。因为 B 是 A 的子级,这意味着它现在开始于 (5,0,0 ) 相对于世界空间。 接下来,我们将B相对于A沿X轴移动了三个单位,所以B在X轴上的最终位置为5+3=8。 这给了我们 B 在世界空间中的最终位置:(8,0,0)。

11、在坐标系之间移动对象

如果我们将一个对象从一个坐标系移动到另一个坐标系会发生什么? 换句话说,如果我们使用网格 B,在不改变其位置的情况下,将其从 A 中移除并直接添加到场景中,会发生什么情况? 我们可以在一行中完成:

scene.add(meshB);

一个对象只能有一个父对象,因此 B(在本例中为网格 A)的任何先前父对象都将被删除。

以下陈述仍然成立:B 已在其父坐标系内沿正 X 轴平移了三个单位。 然而,B 的父级现在是场景而不是 A,所以现在我们必须重新计算 B 在世界空间而不是 A 的局部空间中的位置,这将为我们提供 (3,0,0)。

这就是坐标系。 在本章的其余部分,我们将更深入地了解三种基本变换中的每一种:平移、旋转和缩放。

12、第一个变换:平移

三个基本变换中最简单的是平移(Translate)。 我们已经在几个示例中使用了它,并且还设置了我们场景中相机和灯光的位置。 我们通过更改对象的 .position 属性来执行平移。 平移对象会将其移动到其直接父级坐标系内的新位置。

为了完整描述一个物体的位置,我们需要存储三个信息:

  • 对象在 X 轴上的位置,我们称之为 x。
  • 对象在 Y 轴上的位置,我们称之为 y。
  • 对象在 Z 轴上的位置,我们称之为 z。

我们可以将这三个位置写成一个有序的数字列表:(x,y,z)。

所有三个轴上的零都写为 (0,0,0),正如我们之前提到的,该点称为原点。 每个对象都从其父对象坐标系中的原点开始。

沿 X 轴向右一个单位,沿 Y 轴向上两个单位,沿 Z 轴向外三个单位的位置被写为 (1,2,3)。 沿 X 轴向左两个单位、沿 Y 轴向下四个单位、沿 Z 轴向后八个单位的位置被写为 (-2,-4,-8)。

我们称这样的有序数字列表为向量,因为有三个数字,所以它是一个 3D 向量。

13、平移一个对象

我们可以沿 X、Y 和 Z 轴一一平移,或者我们可以使用 position.set 同时沿所有三个轴平移。 两种情况下的最终结果将是相同的。

// translate one axis at a time
mesh.position.x = 1;
mesh.position.y = 2;
mesh.position.z = 3;

// translate all three axes at once
mesh.position.set(1,2,3);

当我们执行平移 (1,2,3) 时,我们正在执行数学运算:

(0,0,0)⟶(1,2,3)

这意味着:从点 (0,0,0) 移动到点 (1,2,3)。

14、平移单位 — 米

当我们执行平移 mesh.position.x = 2 时,我们将对象沿 X 轴向右移动两个 three.js 单位,正如我们之前提到的,我们总是取一个 three.js 单位相等 一米。

15、世界空间中的方向

上面我们提到了将对象在 X 轴上向左或向右移动,在 Y 轴上向上或向下移动,以及在 Z 轴上向内或向外移动。 这些方向是相对于你的屏幕的,并且假设你没有旋转相机。 在这种情况下,以下指示成立:

  • 正 X 轴指向屏幕右侧。
  • 正 Y 轴指向上方,指向屏幕顶部。
  • 正 Z 轴指向屏幕外

然后,当你移动一个对象时:

  • X 轴上的正平移会将对象移动到屏幕右侧。
  • Y 轴上的正平移会将对象向上移动到屏幕顶部
  • Z 轴上的正平移会将对象移向你

当我们在平移中使用负号时,我们颠倒了这些方向:

  • X 轴上的负平移会将对象移动到屏幕左侧
  • Y 轴上的负平移会将对象向下移动到屏幕底部
  • Z 轴上的负平移会将对象移入远离你的位置。

但当然,你可以向任何方向旋转相机,在这种情况下,这些方向将不再适用。 毕竟,你在屏幕上看到的是相机的视点。 然而,能够使用“正常”语言描述世界空间中的方向是很有用的,因此我们将把这个相机位置视为默认视图并继续使用这个术语描述方向,无论相机恰好在哪里。

16、位置存储在 Vector3 类中

Three.js 有一个用于表示 3D 向量的特殊类,称为 Vector3。 这个类有.x, .y 和.z 属性和类似.set 的方法来帮助我们操作它们。 每当我们创建任何场景对象(例如网格)时,都会自动创建一个 Vector3 并存储在 .position 中:

// when we create a mesh ...
const mesh = new Mesh();

// ... internally, three.js creates a Vector3 for us:
mesh.position = new Vector3();

我们也可以自己创建 Vector3 实例:

import { Vector3 } from 'three';

const vector = new Vector3(1, 2, 3);

我们可以直接访问和更新 .x、.y 和 .z 属性,或者我们可以使用 .set 一次更改所有三个属性:

vector.x; // 1
vector.y; // 2
vector.z; // 3

vector.x = 5;

vector.x; // 5

vector.set(7, 7, 7);

vector.x; // 7
vector.y; // 7
vector.z; // 7

与几乎所有 three.js 类一样,我们可以省略参数以使用默认值。 如果我们省略所有三个参数,则创建的 Vector3 将代表原点,所有值为零:

const origin = new Vector3();

origin.x; // 0
origin.y; // 0
origin.z; // 0

mesh.position = new Vector3();
mesh.position.x; // 0
mesh.position.y; // 0
mesh.position.z; // 0

three.js 也有表示 2D 向量和 4D 向量的类,但是,3D 向量是迄今为止我们会遇到的最常见的向量类型。

17、向量是通用的数学对象

向量可以表示各种事物,而不仅仅是平移。 任何可以表示为两个、三个或四个数字的有序列表的数据通常存储在其中一个向量类中。 这些数据类型分为三类:

  • 空间中的一个点。
  • 坐标系内的长度和方向。
  • 没有更深的数学意义的数字列表。

第二类是向量的数学定义,平移属于这一类。 第一类和第三类在技术上不是向量。 然而,在向量类中重用代码是很有用的,所以我们对此视而不见。

18、第二个变换:缩放

缩放对象会使它变大或变小,只要我们在所有三个轴上缩放相同的量即可。 如果我们按不同的量缩放轴,对象将被压扁或拉伸。 因此,缩放是唯一的可以改变对象形状的基本变换。

与.position 一样,.scale 存储在一个Vector3 中,对象的初始比例为(1,1,1):

// when we create a mesh...
const mesh = new Mesh();

// ... internally, three.js creates a Vector3 for us:
mesh.scale = new Vector3(1, 1, 1);

19、比例值是相对于对象的初始大小

由于 .scale 和 .position 都存储在 Vector3 中,因此缩放对象的工作方式与平移它的方式大致相同。 然而,虽然 translation 使用 three.js 单位,但 scale 不使用任何单位。 相反,缩放值与对象的初始大小成正比:1 表示初始大小的 100%,2 表示初始大小的 200%,0.5 表示初始大小的 50%,依此类推。

20、均匀缩放

当我们将所有三个轴缩放相同的量时,对象将扩大或缩小,但保持其比例。 这称为均匀缩放。 默认值为 (1,1,1) 的比例,表示 X 轴、Y 轴和 Z 轴上的比例为 100%:

mesh.scale.set(1, 1, 1);

(2,2,2) 的比例表示 X 轴、Y 轴和 Z 轴上的比例为 200%。 该对象将增长到其初始大小的两倍:

mesh.scale.set(2, 2, 2);

(0.5,0.5,0.5) 的比例表示 X 轴、Y 轴和 Z 轴上的比例为 50%。 该对象将缩小到其初始大小的一半:

mesh.scale.set(0.5, 0.5, 0.5);

21、非均匀缩放

如果我们缩放单个轴,对象将失去其比例并被压扁或拉伸。 这称为非均匀缩放。 如果我们只缩放 X 轴,对象将变得更宽或更窄:

// double the initial width
mesh.scale.x = 2;

// halve the initial width
mesh.scale.x = 0.5;

在 Y 轴上缩放将使对象变高或变矮:

// squash the mesh to one quarter height
mesh.scale.y = 0.25;

// stretch the mesh to a towering one thousand times its initial height
mesh.scale.y = 1000;

最后,如果我们在 Z 轴上缩放,对象的深度将受到影响:

// stretch the object to eight times its initial depth
mesh.scale.z = 8;

// squash the object to one tenth of its initial depth
mesh.scale.z = 0.1;

再一次,我们可以使用 .set 同时在所有三个轴上进行缩放:

mesh.scale.set(2, 0.5, 6);

22、负比例值镜像对象

小于零的比例值除了使对象变小或变大之外,还会镜像对象。 任何单个轴上的比例值为 −1 将镜像对象而不影响大小:

// mirror the mesh across the X-axis
mesh.scale.x = -1;

// mirror the mesh across the Y-axis
mesh.scale.y = -1;

// mirror the mesh across the Z-axis
mesh.scale.z = -1;

小于零和大于 −1 的值将镜像和挤压对象:

// mirror and squash mesh to half width
mesh.scale.x = -0.5;

小于 −1 的值将镜像并拉伸对象:

// mirror and stretch mesh to double height
mesh.scale.y = -2;

23、均匀缩放和镜像

要在保持比例的同时镜像一个对象,请对所有三个轴使用相同的值,但将其中一个设为负值。 例如,要将对象的大小加倍并在 Y 轴上进行镜像,请使用 (2,−2,2) 的比例值:

mesh.scale.set(2, -2, 2);

或者,要将对象缩小到十分之一大小并在 X 轴上镜像,请使用 (−0.1,0.1,0.1) 的比例值:

mesh.scale.set(-0.1, 0.1, 0.1);

24、相机和灯光无法缩放

并非所有对象都可以缩放。 例如,相机和灯光(RectAreaLight 除外)没有尺寸,因此缩放它们没有意义。 更改 camera.scale 或 light.scale 不会有任何效果。

25、第三个变换:旋转

旋转比平移或缩放需要更多的注意。 这有几个原因,但最主要的是轮换顺序。 如果我们在 X 轴、Y 轴和 Z 轴上平移或缩放对象,哪个轴在先并不重要。

这三种平移给出相同的结果:

  • 沿 X 轴平移,然后沿 Y 轴平移,然后沿 Z 轴平移。
  • 沿 Y 轴平移,然后沿 X 轴平移,然后沿 Z 轴平移。
  • 沿 Z 轴平移,然后沿 X 轴平移,然后沿 Y 轴平移。

这三种缩放操作给出相同的结果:

  • 沿 X 轴缩放,然后沿 Y 轴缩放,然后沿 Z 轴缩放。
  • 沿 Y 轴缩放,然后沿 X 轴缩放,然后沿 Z 轴缩放。
  • 沿 Z 轴缩放,然后沿 X 轴缩放,然后沿 Y 轴缩放。

然而,这三种旋转可能不会给出相同的结果:

  • 绕 X 轴旋转,然后绕 Y 轴旋转,然后绕 Z 轴旋转。
  • 绕 Y 轴旋转,然后绕 X 轴旋转,然后绕 Z 轴旋转。
  • 绕 Z 轴旋转,然后绕 X 轴旋转,然后绕 Y 轴旋转。

因此,我们用于 .position 和 .scale 的不起眼的 Vector3 类不足以存储旋转数据。 相反,three.js 没有一个,而是两个用于存储旋转数据的数学类。 我们将在这里查看其中更简单的一个:欧拉角。 幸运的是,它类似于 Vector3 类。

26、表示旋转:欧拉角

欧拉角在 three.js 中使用 Euler 类表示。 与 .position 和 .scale 一样,当我们创建一个新的场景对象时,会自动创建一个 Euler 实例并赋予默认值。

// when we create a mesh...
const mesh = new Mesh();

// ... internally, three.js creates an Euler for us:
mesh.rotation = new Euler();

与 Vector3 一样,有 .x、.y 和 .z 属性以及一个 .set 方法:

mesh.rotation.x = 2;
mesh.rotation.y = 2;
mesh.rotation.z = 2;

mesh.rotation.set(2, 2, 2);

再一次,我们可以自己创建 Euler 实例:

import { Euler } from 'three';

const euler = new Euler(1, 2, 3);

也像 Vector3 一样,我们可以省略参数以使用默认值,同样,所有轴上的默认值为零:

const euler = new Euler();

euler.x; // 0
euler.y; // 0
euler.z; // 0

27、欧拉旋转顺序

默认情况下,three.js 将在对象的局部空间中执行绕 X 轴的旋转,然后绕 Y 轴,最后绕 Z 轴。 我们可以使用 Euler.order 属性更改它。 默认顺序称为“XYZ”,但也可以是“YZX”、“ZXY”、“XZY”、“YXZ”和“ZYX”。

我们不会在这里进一步讨论轮换顺序。 通常,你唯一需要更改顺序的时间是在处理来自另一个应用程序的旋转数据时。 即便如此,这通常由 three.js 加载程序处理。 现在,如果愿意,你可以简单地将 Euler 视为 Vector3。 在你开始创建动画或执行涉及旋转的复杂数学运算之前,这样做不太可能会遇到任何问题。

28、旋转单位是弧度

你可能熟悉使用度数表示旋转。 圆是360∘,直角是90∘,依此类推。 我们之前遇到的透视相机的视野以度为单位指定。

但是,three.js 中的所有其他角度都使用弧度而不是度数指定。 圆的弧度是 2π,直角弧度是π/2。 如果你习惯使用弧度,那就太好了! 至于我们其他人,我们可以使用 .degToRad 实用程序将度数转换为弧度。

import { MathUtils } from 'three';

const rads = MathUtils.degToRad(90); // 1.57079... = π/2

在这里,我们可以看到 90∘等于 1.57079...,或π/2弧度。

29、另一个旋转表示:四元数

上面我们提到 three.js 有两个类来表示旋转。 第二个是 Quaternion 类,我们只是在这里顺便提一下。 与 Euler 一起,每当我们创建一个新的场景对象(例如网格)时,都会为我们创建一个四元数并将其存储在 .quaternion 属性中:

// when we create a mesh
const mesh = new Mesh();

// ... internally, three.js creates an Euler for us:
mesh.rotation = new Euler();

// .. AND a Quaternion:
mesh.quaternion = new Quaternion();

我们可以互换使用四元数和欧拉角。 当我们更改 mesh.rotation 时,mesh.quaternion 属性会自动更新,反之亦然。 这意味着我们可以在适合我们的时候使用欧拉角,并在适合我们的时候切换到四元数。

欧拉角有几个缺点,在创建动画或进行涉及旋转的数学运算时会变得很明显。 特别是,我们不能将两个欧拉角相加(更著名的是,它们还遭受称为万向节锁定的问题)。 四元数没有这些缺点。 另一方面,它们比欧拉角更难使用,所以现在我们将坚持使用更简单的欧拉角。

现在,记下这两种旋转对象的方法:

  • 使用欧拉角,使用欧拉类表示并存储在 .rotation 属性中。
  • 使用四元数,使用 Quaternion 类表示并存储在 .quaternion 属性中。

30、关于旋转对象的重要事项

尽管我们在本节中强调了一些问题,但旋转对象通常是直观的。 以下是一些需要注意的重要事项:

  • 并非所有对象都可以旋转。 比如我们上一章介绍的DirectionalLight是不能旋转的。 光线从一个位置照射到一个目标,光线的角度是根据目标的位置计算的,而不是 .rotation 属性。
  • three.js 中的角度使用弧度指定,而不是度数。 唯一的例外是 PerspectiveCamera.fov 属性,它使用度数来匹配真实世界的摄影惯例。

31、变换矩阵

我们在本文中介绍了很多内容。 我们介绍了笛卡尔坐标系、世界空间和局部空间、场景图、平移、旋转和缩放以及相关的 .position、.rotation 和 .scale 属性,以及用于存储变换的三个数学类:Vector3、Euler 和四元数。 我们肯定不能塞进其他东西吗?

好吧,还有一件事。 我们不能在不讨论变换矩阵的情况下结束关于变换的一章。 虽然向量和欧拉角对我们人类来说(相对)容易处理,但它们对计算机的处理效率不高。 当我们追求每秒 60 帧的难以捉摸的目标时,我们必须在易用性和效率之间保持平衡。 为此,对象的平移、旋转和缩放被组合到一个称为矩阵的数学对象中。 这是未转换的对象的矩阵的样子。

它有四行四列,所以它是一个 4×4 矩阵,它存储了一个对象的完整变换,这就是我们将其称为变换矩阵的原因。 再一次,有一个 three.js 类来处理这种类型的数学对象,称为 Matrix4。 还有一个用于 3×3 矩阵的类,称为 Matrix3。 当矩阵的主对角线上全为 1,其他地方都为 0 时,如上图所示,我们称其为单位矩阵 I。

与单独的变换相比,矩阵对于 CPU 和 GPU 的处理效率要高得多,并且代表了一种让我们两全其美的折衷方案。 我们人类可以使用更简单的 .position、.rotation 和 .scale 属性,然后,每当我们调用 .render 时,渲染器将更新每个对象的矩阵并将它们用于内部计算。

我们将在这里花一些时间来了解变换矩阵的工作原理,但如果你对数学过敏,那么跳过这一部分(暂时)绝对没问题。 你不需要深入了解矩阵的工作原理即可使用 three.js。 你可以坚持使用 .position、.rotation 和 .scale,让 three.js 处理矩阵。 另一方面,如果你是一位数学奇才,直接使用变换矩阵会打开一个全新的机会范围。

32、局部矩阵

事实上,每个对象都有两个变换矩阵,而不是一个。 第一个是局部矩阵,它包含对象的组合 .position、.rotation 和 .scale。 局部矩阵存储在 Object3D.matrix 属性中。 从 Object3D 继承的每个对象都具有此属性。

// when we create a mesh
const mesh = new Mesh();

// ... internally, three.js creates a Matrix4 for us:
mesh.matrix = new Matrix4();

此时,矩阵将类似于上面的单位矩阵,主对角线上为 1,其他各处为 0。 如果我们改变物体的位置,然后强制矩阵更新:

mesh.position.x = 5;

mesh.updateMatrix();

……现在,网格的局部矩阵将如下所示:

通常,我们不需要手动调用 .updateMatrix,因为渲染器会在渲染之前更新每个对象的矩阵。 但是,在这里,我们希望立即看到矩阵中的变化,因此我们必须强制更新。

如果我们改变所有三个轴上的位置并再次更新矩阵:

mesh.position.x = 2;
mesh.position.y = 4;
mesh.position.z = 6;

mesh.updateMatrix();

...现在我们可以看到平移存储在矩阵最后一列的前三行中。

接下来,让我们对缩放做同样的事情:

mesh.scale.x = 5;
mesh.scale.y = 7;
mesh.scale.z = 9;

mesh.updateMatrix();

……我们会看到比例值存储在主对角线上。

太棒了! 这意味着我们可以编写一个公式来在变换矩阵中存储平移和缩放比例。

mesh.position.x = Tx;
mesh.position.y = Ty;
mesh.position.z = Tz;

mesh.scale.x = Sx;
mesh.scale.y = Sy;
mesh.scale.z = Sz;

…现在转换矩阵如下所示:

最后,让我们看看旋转是如何存储的。 首先,让我们重新设置位置和比例:

mesh.position.set(0, 0, 0);
mesh.scale.set(1, 1, 1);
mesh.updateMatrix();

现在矩阵看起来又像单位矩阵了,主对角线上是 1,其他地方都是 0。 接下来,让我们尝试绕 X 轴旋转 30 度:

mesh.rotation.x = MathUtils.degToRad(30);

mesh.updateMatrix();

...那么矩阵将如下所示:

嗯……很奇怪。 然而,当我们看到以下等式时,这更有意义:

cos(30) = 0.866...
sin(30) = 0.5

所以,这个矩阵实际上是:

不幸的是,这并不像上面的转换和缩放示例那样直观。 但是,我们再次使用它来编写公式。 如果我们将绕 X 轴的旋转写为 Rx,这是绕 X 轴旋转的公式:

最后,绕 Z 轴旋转Rz的转换矩阵是:

33、世界矩阵

正如我们多次提到的,对我们来说重要的是对象在世界空间中的最终位置,因为这是我们在渲染对象后看到的。 为了帮助计算这一点,每个对象都有第二个变换矩阵,即世界矩阵,存储在 Object3D.matrixWorld 中。 这两个矩阵在数学上没有区别。 它们都是 4×4 变换矩阵,当我们创建网格或任何其他场景对象时,局部矩阵和世界矩阵都会自动创建。

// when we create a mesh
const mesh = new Mesh();

// ... internally, three.js creates the local matrix and the world matrix
mesh.matrix = new Matrix4();
mesh.matrixWorld = new Matrix4();

世界矩阵存储对象在世界空间中的位置。 如果对象是场景的直接子对象,则这两个矩阵将相同,但如果对象位于场景图下方的某个位置,则局部矩阵和世界矩阵很可能会不同。

为了帮助我们理解这一点,让我们再次看一下之前的对象 A 和 B:

const scene = new Scene();
const meshA = new Mesh();
const meshB = new Mesh();

// A starts at (0,0,0) in world space
scene.add(meshA);

// B starts at (0,0,0) in A's local space
meshA.add(meshB);

// move A relative to its parent the scene
meshA.position.x = 5;

// move B relative to its parent A
meshB.position.x = 3;

meshA.updateMatrix();
meshA.updateMatrixWorld();

meshB.updateMatrix();
meshB.updateMatrixWorld();

再一次,我们必须强制更新矩阵。 或者,你可以调用 .render 并且场景中所有对象的矩阵将自动更新。

回想一下,我们计算了 A 和 B 在世界空间中的最终位置,发现 A 位于 (5,0,0),而 B 最终位于 (8,0,0)。 让我们来看看这对每个对象的本地和世界矩阵是如何工作的。 首先是 A 的局部矩阵。

正如我们在上面看到的,对象在 X 轴上的位置存储在其局部矩阵顶行的最后一列中。 现在,让我们看看 A 的世界矩阵:

由于 A 是场景的直接子节点,因此局部矩阵和世界矩阵是相同的。 现在,让我们看一下 B。首先,局部矩阵:

最后,这是 B 的世界矩阵:

这一次,本地矩阵和世界矩阵不同,因为 B 不是场景的直接子对象。

34、直接使用矩阵

希望这个简短的介绍已经揭开了矩阵工作原理的一些神秘面纱。 它们并不像看起来那么复杂,相反,它们只是一种存储大量数字的紧凑方式。 然而,记住所有这些数字需要一些练习,并且手动进行涉及矩阵的计算是乏味的。 幸运的是,three.js 带有许多函数,可以让我们轻松地处理矩阵。 有明显的函数,如加法、乘法、减法,以及设置和获取矩阵的平移、旋转或缩放分量的函数,等等。

几乎从来不需要直接使用矩阵,而不是分别设置 .position、.rotation 和 .scale,但它确实允许对对象的变换进行强大的操作。 把它想象成一个超级大国,一旦你的 three.js 技能达到足够的水平,你就会解锁。

当一起使用时,我们在本章中遇到的所有属性 - .position、.rotation、.scale、.quaternion、.matrix 和 .matrixWorld - 都具有巨大的表现力,使你能够像艺术家一样创建场景 画笔。

// when we create a mesh,
// or any other object derived from Object3D
// such as lights, camera, or even the scene itself
const mesh = new Mesh();

// ... internally, three.js creates
// many different objects to help us transform the object
mesh.position = new Vector3();
mesh.scale = new Vector3();
mesh.rotation = new Euler();

mesh.quaternion = new Quaternion();
mesh.matrix = new Matrix4();
mesh.matrixWorld = new Matrix4();

学习如何使用 .position、.rotation 和 .scale 是使用 three.js 所需的一项基本技能。 然而,学习使用 .quaternion 和变换矩阵是一项高级技能,你不需要立即掌握。


原文链接:Transformations, Coordinate Systems, and the Scene Graph

BimAnt翻译整理,转载请标明出处