组织你的场景
到目前为止,在每一章中,我们只使用我们可信赖的多维数据集创建了示例。你不认为是时候我们转向其他形状了吗?或者甚至(喘气!)同时不止一个物体?切换到新几何体很容易,因为我们可以使用three.js核心附带的大约20种几何中的任何一种,我们将在下一章中看到。然而,一旦我们开始向场景中添加大量对象,我们还需要考虑如何在场景的3D空间和代码中组织和跟踪它们。
在本章中,我们将介绍一个名为SphereBufferGeometry
的新几何体,我们将使用它来展示一些可以用来保持场景和代码有效组织的特性:Group
类,用于组织
场景图中的对象的类,.clone
方法,您可以使用它在一行代码中创建现有对象的相同副本。
介绍SphereBufferGeometry
SphereBufferGeometry
几何体最多需要七个参数,都是可选的。我们将重点关注前三个:
半径定义了球体的大小。更有趣的是接下来的两个参数,它们分别指定几何体在其宽度(赤道)和高度周围有多少细节。
BoxBufferGeometry
具有相似的参数,但是它们不太重要,因为它们不会改变盒子的形状。这样做的原因是所有的几何体都是由三角形组成的 —— 你可以在上面的场景中的球体上看到这些轮廓。要创建一个像球体一样的曲面,我们需要使用许多非常小的三角形。
尝试使用不同的值widthSegments
和heightSegments
来查看这些设置如何影响几何体的质量。使用对两种设置都适用的最小值很重要。当您为这些参数使用较大的值时,构建球体的三角形数量会迅速增加。您正在寻找的是质量和性能之间的权衡。如果球体离相机很远或非常小,您可能会使用由极少数三角形组成的低质量几何体,而如果球体是场景的主要焦点(例如地球仪或行星),您可能希望使用更高质量的几何体。
向场景中添加许多对象
稍后,我们将创建21个球形网格并将它们添加到我们的场景中,围绕中心排列成一个圆圈。当然,我们可以将每个球体一个一个地添加到场景中(在以下示例中,为简洁起见,我们跳过了设置球体的位置)。
有点乏味,你不觉得吗?这是使用循环的最佳时机:
这样更好。我们已经从40多行代码减少到了4行。但是,我们必须从两个角度考虑这个问题:简洁的代码和简洁的场景图。从技术上讲,像这样直接将大量对象添加到场景中并没有错。性能或其他方面没有问题。当我们想对球体做一些事情时,问题就会出现。也许我们想一次显示/隐藏它们,或者我们想为它们设置动画(将如下所示)。在这种情况下,我们必须在我们的代码中跟踪它们所有并逐个更改它们,并且要为它们设置动画,我们必须向所有21个球体添加一个.tick
方法。
如果我们有办法把他们当作一个群体来对待会好得多,你不觉得吗?
Group
对象
组在
场景图中占据一个位置并且可以有子对象,但它们本身是不可见的。如果Scene
代表整个宇宙,那么您可以将Group
视为该宇宙中的单个 复合 对象。
当我们移动一个组时,它的所有子对象也会移动。同样,如果我们旋转或缩放一个组,它的所有子项也将被旋转或缩放。但是,子对象也可以独立平移、旋转或缩放。这正是对象在现实世界中的行为方式。例如,汽车由车身、车窗、车轮、发动机等独立部件组成,当您移动汽车时,它们都会随之移动。但是轮子可以独立转动,你可以开门、摇下车窗、转动方向盘等等。
当然,所有这些都适用于 每个 场景对象。每个场景对象都有继承自Object3D
的.add
和.remove
的方法,就像Group
和Scene
本身一样,
每个对象都可以在场景图中占据一个位置并拥有子对象。不同之处在于组是 纯粹的可组织对象。其他场景对象,如网格、灯光、相机等,除了在场景图中占据一席之地外,还有其他用途。但是,组的存在纯粹是为了帮助您操纵其他场景对象。
使用组Groups
与Scene
构造函数一样,Group
构造函数不带任何参数:
将组添加到场景后,该组的任何子对象也将成为场景的一部分:
回到我们的球体,我们将像以前一样在循环中创建球体,但现在我们将它们添加到一个组中,然后我们将该组添加到场景中。
如果我们简单的球体组无法说服您,那么将对象分组的一个典型例子就是机械臂。这个场景中的手臂由至少四个独立移动的部分组成,它们通过层次结构的关节连接,手臂的基座在底部,“手”在顶部。想象一下,如果这些都直接添加到场景中,彼此没有任何联系,我们的任务是为它们设置动画。手臂中的每个关节都要求其前面的关节在移动时保持连接。如果我们必须在各个部分之间没有任何联系的情况下考虑这一点,那么将会涉及很多痛苦的数学运算。但是,当我们在场景图中以父子关系连接各个部分时,逻辑上会遵循层次运动。当我们移动整个组时,整个手臂都会移动。如果我们旋转底座,上部关节会移动,但组和底座不会移动。当我们旋转中间关节时,顶部关节也会旋转,最后,当我们旋转顶部关节时,不会强迫其他任何东西一起移动。
对象之间的这种逻辑连接是在场景图中对对象进行分组变得容易的事情之一。
.clone
方法
在上面我们创建了许多球体的示例中,我们跳过了必须将每个球体移动到新位置的部分。如果我们不这样做,所有球体都将保持在场景的正中心,所有球体都相互重叠。这就是克隆对象有用的地方。我们可以按照我们喜欢的方式设置一个对象,然后我们可以创建一个精确的克隆。这个克隆将具有相同的变换、相同的形状、相同的材质,如果是灯光,它将具有相同的颜色和强度,如果是相机,它将具有相同的视野和纵横比,等等。然后,我们可以对克隆进行任何我们想要的调整。
three.js中几乎所有的对象都有一个.clone
方法,它允许您创建该对象的相同副本。所有场景对象都继承自
Object3D.clone
,而几何体继承自
BufferGeometry.clone
,材质继承自
Material.clone
。
在本章中,我们将专注于克隆网格,其工作原理如下:
如果我们设置mesh
的位置、旋转和缩放,然后克隆它,clonedMesh
将具有与原始对象相同的位置、旋转和缩放。
克隆后,您可以分别调整原始网格和克隆网格上的变换。
clonedMesh
也具有与mesh
相同的几何体和材料。但是,几何体和材质不是克隆的,它们是共享的。如果我们对共享材质进行任何更改,例如,更改其颜色,所有克隆的网格将与原始网格一起更改。如果您对几何体进行任何更改,这同样适用。
但是,您可以给一个克隆一个全新的材料,而原来的材料不会受到影响。
自定义属性不会克隆(如.tick
)
一个重要的最后说明。只会克隆对象的默认属性。如果您像我们用来创建动画的
.tick
方法方法一样创建自定义属性,这些将不会被克隆。您必须在克隆的网格上再次设置任何自定义属性。
创建 meshGroup.js 模块
现在,我们最终将这21个球体添加到我们的场景中。将上一章中的 cube.js 模块重命名为 meshGroup.js,并删除其中的所有内容(在编辑器中,我们已经为您完成了此操作)。在这个新模块中,我们将使用SphereBufferGeometry
、Group
和.clone
创建一堆球体,然后花一些时间对它们进行试验。
首先,设置导入。这些与上一章基本相同,只是我们用SphereBufferGeometry
和Group
替换了BoxBufferGeometry
和TextureLoader
。接下来,创建createMeshGroup
函数,最后在模块底部导出这个函数:
import {
SphereBufferGeometry,
Group,
MathUtils,
Mesh,
MeshStandardMaterial,
} from 'three';
function createMeshGroup() {}
export { createMeshGroup };
创建Group
在函数内部,创建一个新组,然后给它一个.tick
方法:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
group.tick = (delta) => {};
return group;
}
export { createMeshGroup };
这样就完成了新模块的骨架结构。在World中,将createCube
导入切换为createMeshGroup
(同样,已经在编辑器中为您完成了):
import { createCamera } from './components/camera.js';
import { createLights } from './components/lights.js';
import { createMeshGroup } from './components/meshGroup.js';
import { createScene } from './components/scene.js';
import { createControls } from './systems/controls.js';
import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';
在构造函数中进行类似的更改:
constructor(container) {
camera = createCamera();
renderer = createRenderer();
scene = createScene();
loop = new Loop(camera, scene, renderer);
container.append(renderer.domElement);
const controls = createControls(camera, renderer.domElement);
const { ambientLight, mainLight } = createLights();
const meshGroup = createMeshGroup();
loop.updatables.push(controls, meshGroup);
scene.add(ambientLight, mainLight, meshGroup);
const resizer = new Resizer(container, camera, renderer);
}
此时,您的场景将包含一个空组,仅此而已。但是,组是不可见的,因此您只会看到蓝色背景。
创建原型球体
接下来,我们将创建球体并将它们添加到组中。我们将通过创建一个 原型 球体来做到这一点,然后我们将克隆它二十次,总共有21个球体。
首先,创建一个SphereBufferGeometry
给原型网格它的形状。该几何体将由所有球体共享。我们将其radius
设为0.25,并将widthSegments
和heightSegments
设置16:
const geometry = new SphereBufferGeometry(0.25, 16, 16);
将widthSegments
和heightSegments
设置为16可以让我们在质量和性能之间取得不错的平衡,只要我们不要放大得太近。通过这些设置,每个球体将由480个小三角形组成。
接下来,创建一个MeshStandardMaterial
。这里没有什么新东西,除了这次我们
将颜色设置为靛蓝。再一次,这种材料将被所有领域共享。
const material = new MeshStandardMaterial({
color: 'indigo',
});
最后,创建网格,然后将其添加到组中:
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
我们将.clone
这个网格来创建其余的网格,因此命名为protoSphere
。将所有这些放在一起,这是createMeshGroup
迄今为止的功能:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
const geometry = new SphereBufferGeometry(0.25, 16, 16);
const material = new MeshStandardMaterial({
color: 'indigo',
});
// create one prototype sphere
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
group.tick = (delta) => {};
return group;
}
此时,protoSphere
应该出现在场景的中心。
请注意我们在上一章中添加的HemisphereLight
如何与球体的颜色相结合以在整个表面上创建不同的阴影。另外,仔细观察球体的轮廓。你能看出它是由许多短直线组成的吗?如果您使用轨道控件放大然后旋转相机,这应该会变得更加明显。显然,widthSegments
和heightSegments
在等于16时并没有为我们提供全屏球体的足够细节。现在,缩小到原始大小。球体现在应该看起来更好,向我们展示了这个质量水平对于小的或遥远的球体来说是好的。
克隆protoSphere
这个小标题最有可能成为一部俗气的科幻电影中的对白。
随着我们的原型网格设置,我们将克隆它以创建其他网格。
我们将使用 for循环创建20个新球体,并在创建它们时将每个球体添加到组中。通常,要循环二十次,我们会这样做:
但是,稍后,我们将使用一些三角函数将克隆的球体排列成一个圆圈,我们需要i
介于0和1之间的值。因为$\frac{1}{20}=0.05$,我们可以这样写循环:
添加此循环到createMeshGroup
以创建二十个新球体:
...
const protoSphere = new Mesh(geometry, material);
// add the sphere to the group
group.add(protoSphere);
// create twenty clones of the protoSphere
// and add each to the group
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
group.add(sphere);
}
...
现在我们总共有21个球体(原始球体加上20个克隆)。但是,我们还没有移动任何球体,所以它们都在场景中心完全重叠放置,看起来仍然只有一个球体。
将克隆球体放置在一个圆圈中
我们将使用一些三角函数将克隆的球体放置在围绕protoSphere
的圆圈上。这是编写半径为1的圆方程的一种方法,其中$0 \le i \le 1$:
$$ \begin{aligned} x &= \cos(2 \pi i) \cr y &= \sin(2 \pi i) \cr \end{aligned} $$
如果我们输入的值$i$在0和1之间,我们将得到散布在圆周上的点。我们可以
使用内置Math
类在JavaScript中轻松重写这些函数:
接下来,将方程移动到你的for循环中(现在你能明白为什么我们想要i
的值介于0和1之间了吗?):
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 _ Math.PI _ i);
sphere.position.y = Math.sin(2 _ Math.PI _ i);
this.group.add(sphere);
}
完成此操作后,克隆的球体将移动到围绕原始protoSphere
的圆圈。
缩放组
我们创建的圆的半径为1,非常小。我们将把组的规模扩大一倍以使其更大:
// every sphere inside the group will be scaled
group.scale.multiplyScalar(2);
.multiplyScalar
方法
将一个向量的$x$, $y$, 和$z$分量乘以一个数字。当我们将组的规模扩大一倍时,组内的每个对象的大小也会增加一倍。
缩放球体
为了获得一些额外的视觉效果,让我们将克隆的球体从小缩放到大。将以下行添加到循环中:
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 * Math.PI * i);
sphere.position.y = Math.sin(2 * Math.PI * i);
sphere.scale.multiplyScalar(0.01 + i);
group.add(sphere);
}
变量i
在范围$0 \le i \le 1$内,所以在这里,我们将网格从几乎为零缩放到全尺寸。
旋转轮子
最后,更新group.tick
方法设置球体运动。我们将使用与
创建立方体动画相同的方法,除了这次我们在单轴上旋转,所以它是一个简单的旋转运动,就像一个轮子围绕其中心旋转。
const radiansPerSecond = MathUtils.degToRad(30);
// each frame, rotate the entire group of spheres
group.tick = (delta) => {
group.rotation.z -= delta * radiansPerSecond;
};
完成createMeshGroup
函数
With all that in place, here’s the complete createMeshGroup
function:
function createMeshGroup() {
// a group holds other objects
// but cannot be seen itself
const group = new Group();
const geometry = new SphereBufferGeometry(0.25, 16, 16);
const material = new MeshStandardMaterial({
color: 'indigo',
});
const protoSphere = new Mesh(geometry, material);
// add the protoSphere to the group
group.add(protoSphere);
// create twenty clones of the protoSphere
// and add each to the group
for (let i = 0; i < 1; i += 0.05) {
const sphere = protoSphere.clone();
// position the spheres on around a circle
sphere.position.x = Math.cos(2 * Math.PI * i);
sphere.position.y = Math.sin(2 * Math.PI * i);
sphere.scale.multiplyScalar(0.01 + i);
group.add(sphere);
}
// every sphere inside the group will be scaled
group.scale.multiplyScalar(2);
const radiansPerSecond = MathUtils.degToRad(30);
// each frame, rotate the entire group of spheres
group.tick = (delta) => {
group.rotation.z -= delta * radiansPerSecond;
};
return group;
}
实验!
最后,我们有一个可以玩的场景。通过在循环中进行微小的更改,您可以获得有趣的结果。例如,尝试在循环中使用不同的步长来创建更多或更少的球体:
for (let i = 0; i < 1; i += 0.05) {
如果将0.05更改为0.001会发生什么?在您开始注意到帧速率下降之前,该值可以有多小?
或者,改变$z$轴位置会怎么样,以及$x$和$y$轴呢?
sphere.position.x = Math.cos(2 _ Math.PI _ i);
sphere.position.y = Math.sin(2 _ Math.PI _ i);
sphere.position.z = -i \* 5;
您还必须调整相机以获得精确的视图。这听起来像是一个“艰难”的挑战!