1

示例代码托管在:http://www.github.com/dashnowords/blogs

博客园地址:《大史住在大前端》原创博文目录

华为云社区地址:【你要的前端打怪升级指南】

[TOC]

原文地址: https://threejsfundamentals.org/threejs/lessons/threejs-scenegraph.html

笔者按:别关键词保持原英文单词,理解起来会更方便。原文中有许多内嵌的支持在线编辑的示例代码,可点击上面链接直接体验。

本文是three.js系列博文的一篇,第一篇文章是【three.js基础知识】,如果你还没有阅读过,可以从这一篇开始,页面顶部可以切换为中文或英文。

three.js中最核心的部分可能就是scene graph(或称为场景节点图)。3D引擎中的scene graph是一个表示继承关系的节点图谱,图谱中的每个节点都表示了一个本地坐标空间。

scene graph1

这样说可能比较抽象,我们来举例说明一下。一个典型的例子就是模拟银河系中的太阳,地球和月亮。

solar system

地球轨迹是绕着太阳的,月球的轨迹是绕着地球的。月亮绕着地球做圆周运动,从月球的视角来观察时,它是在地球的”本地坐标空间“中进行旋转的,然而如果相对于太阳的“本地坐标空间”来看,月球的运动轨迹就会变成非常复杂的螺旋线。(原文中下图是javascript代码实现的动画)

换个角度来思考,当你住在地球上时,并不需要考虑地球的自转或者绕着太阳公转,无论你是行走,开车,游泳,跑步还是做什么,地球相对于你来说就和静止的没什么差别,你的所有行为在地球的”本地坐标空间“中进行的,尽管这个坐标空间本身相对于太阳而言以1000英里每小时的速度自转,并以67000英里每小时的速度公转着。你的位置相对于银河系而言,就如同上例中的月亮一样,但你通常只需要关心自己相对于地球“本地坐标空间”的行为就可以了。

我们一步一步来。假设现在我们想制作一个包含太阳,地球和月亮的图谱。从太阳开始绘制,首先要做的就是生成一个球体,然后将其放置在坐标原点。我们希望使用三者之间的相对关系来展示scene graph的用法。当然真实的太阳,月亮和地球是在物理作用的影响下才表现出这样的运动特性的,但这并不是本例所关心的,我们只需要模拟出运动轨迹即可。

// an array of objects whose rotation to update
const objects = [];
 
// use just one sphere for everything
const radius = 1;
const widthSegments = 6;
const heightSegments = 6;
const sphereGeometry = new THREE.SphereBufferGeometry(
    radius, widthSegments, heightSegments);
 
const sunMaterial = new THREE.MeshPhongMaterial({emissive: 0xFFFF00});
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);  // make the sun large
scene.add(sunMesh);
objects.push(sunMesh);

我们使用了地面风格的球体,每个方向上仅将球面分为6个子区域,这样就比较容易观察它们的旋转。本例中创建的模型网格都将复用这个球形的几何体,将太阳模型的放大倍数设为5即可。同时使用Phong Material材质,并将emissive属性设置为黄色(emissive属性表示没有光照时表面需要呈现的基本色,当有光照射到物体表面后,光的颜色会与该色进行叠加)。

我们在场景的中心放置一个简单的点光源,稍后再对其进行定制,但本例中会先使用一个简单的点光源对象来模拟从一个点发射出的光。

{
  const color = 0xFFFFFF;
  const intensity = 3;
  const light = new THREE.PointLight(color, intensity);
  scene.add(light);
}

为方便理解,我们将场景的相机直接放在原点位置并向下看,最简单的方式就是调用lookAt方法,lookAt方法将会将相机的朝向调整为从它当前位置指向lookAt方法接受的参数所在的位置,就像它的表面意思一样。在此之前,我们还需要确定哪个方向是相机的top方向或者说对于相机而言是正方向,在大多数场景中正Y方向方向是一个不错的选择,但因为在本例中我们是自顶向下俯视整个系统的,所以就需要告诉相机将正Z方向设置为相机的正方向。

const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 50, 0);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);

在渲染循环中,我们建立一个objects数组,并用下面的方法来让数组中每个对象都旋转起来:

objects.forEach((obj) => {
  obj.rotation.y = time;
});

将太阳模型sunMesh加入到objects数组里,它就会开始转动.

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

接着来加入地球模型。

const earthMaterial = new THREE.MeshPhongMaterial({color: 0x2233FF, emissive: 0x112244});
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
scene.add(earthMesh);
objects.push(earthMesh);

我们生成了一个蓝色的材质,但是给了它一个较小的emissive值,这样就可以和黑色的背景区别开了。我们使用同一个球体几何体sphereGeometry,和蓝色的材质earthMaterial一起来构建地球模型earthMesh。我们将生成的模型加入到场景中,并把它定位到太阳左侧10个单位的地方,因为地球模型也被加入了objects数组,所以它也会转动。

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

但是此时你看到的地球模型并不会绕着太阳转动,而仅仅是自己在转动,如果想让地球围绕太阳公转,可以将其作为太阳模型的子元素:

//原代码
scene.add(earthMesh);
//新代码
sunMesh.add(earthMesh);
点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

这是什么情况?地球的尺寸变得和太阳一样大,而且距离也变得非常远了。你需要将相机镜头从原来的50单位距离后移到150单位距离才能较好地观察这个系统。

在这个例子中,我们将地球模型earthMesh设定为太阳模型sunMesh的子节点。这个sunMesh通过sunMesh.scale.set(5,5,5)这句代码已经放大了5倍。这就意味着在sunMesh的本地坐标空间是5倍大的,同时任何放入这个空间的元素也都会被放大5倍,这就意味着地球会变成原来的5倍大,而原本距离太阳的线性距离也会变成5倍大,此时的场景节点图scene graph是下面这样的:

scene graph

为了修复这个问题,就需要在scene graph中加入一个新的空节点,然后将太阳和地球都变成它的子节点,如下所示:

我们新创建了一个Object3D对象。它可以像Mesh的实例一样直接被添加场景结构图scene graph,但不同的是它没有材质或者几何体,它仅仅用来表示一个本地的坐标空间。这样一来,新的场景结构图就变成了:

scene graph with virtualNode

这样,地球模型和太阳模型都变成了这个虚拟节点solarSystem的子节点。现在,当这三个节点都进行转动时,地球不再是太阳的子节点,所以也就不会被放大,正如我们期望的那样。

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

现在看起来就好很多了,地球比太阳小,并且一边自转,一边绕太阳公转,依据同样的模式,可以生成月亮的模型:

我们在此添加一个不可见的虚拟节点,这个Object3D的实例叫做earthOrbit,然后将地球模型和月亮模型都添加为它的子节点,场景结构图如下所示:

scene graph with moon

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

你可以看到月球沿着某种螺旋线在进行运动,但我们并不需要手动去计算它的轨迹,而只需要配置scene graph就可以达到目的。有时候我们需要一些辅助线以便可以更好地观察scene graph中的实体,three.js中提供了一些有用的工具。例如AxesHelper类,它可以用红绿蓝三种颜色绘制一个本地坐标系的坐标轴,我们将它添加到所有的节点中:

// add an AxesHelper to each node
objects.forEach((node) => {
  const axes = new THREE.AxesHelper();
  axes.material.depthTest = false;
  axes.renderOrder = 1;
  node.add(axes);
});

在这个实例中,我们希望即便坐标轴原点位于球体内部,也需要将它展示出来,为此需要将材质的深度测试属性depthTest设置为false,这意味着渲染时不需要考虑它是否被其他像素挡住。同时我们将renderOrder属性设置为1(默认是0),这样它们就会在所有球体被绘制完后再绘制,否则的话球体被绘制时可能就会挡住辅助线。

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

在示例中我们可以看到X轴(红色)和Z轴(蓝色),因为我们是俯视整个系统,每个物体都绕着y轴旋转,所以绿色的Y轴看起来不是很明显。当有2个以上的辅助轴重叠在一起时是很难将其区分开的,例如sunMesh节点和solarSystem节点的坐标系其实就是重合的,earthMesh节点和earthOrbit节点的位置也是相同的。这时我们可以增加更多的控制,来打开或关闭节点坐标系的参考线,另外再添加一种新的辅助线形式——GridHelper,它在本地坐标系的X和Z平面构建了2D网格,默认尺寸为10*10。

我们将使用dat.GUI工具,它是一个非常流行的UI库,通常在three.js项目中使用。dat.GUI使用一个配置对象,将属性名和属性值的类型添加后,它将自动生成一个可以动态调整这些参数的UI。下面为每个节点来添加GridHelperAxesHelper。我们给每个节点添加一个标记,并将代码调整为下面的形式:

makeAxisGrid方法用来生成包含轴线和网格的辅助线AxisGridHelper,正如前文所述,dat.GUI会根据属性名自动生成UI,我们希望得到一个checkbox,这样就可以很方便地改变bool类型的属性值。但是,我们想使用同一个属性同时控制坐标轴和网格线的隐藏/展示,所以就封装了一个新的辅助类,并在对应属性的gettersetter中分别操作AxesHelperGridHelper,对于dat.GUI而言,操作的只是一个属性罢了,示例代码如下:

// Turns both axes and grid visible on/off
// dat.GUI requires a property that returns a bool
// to decide to make a checkbox so we make a setter
// and getter for `visible` which we can tell dat.GUI
// to look at.
class AxisGridHelper {
  constructor(node, units = 10) {
    const axes = new THREE.AxesHelper();
    axes.material.depthTest = false;
    axes.renderOrder = 2;  // after the grid
    node.add(axes);
 
    const grid = new THREE.GridHelper(units, units);
    grid.material.depthTest = false;
    grid.renderOrder = 1;
    node.add(grid);
 
    this.grid = grid;
    this.axes = axes;
    this.visible = false;
  }
  get visible() {
    return this._visible;
  }
  set visible(v) {
    this._visible = v;
    this.grid.visible = v;
    this.axes.visible = v;
  }
}

另外需要注意的是,我们将AxesHelperRenderOrder设置为2,而将GridHelper设置为1,这样坐标轴辅助线就会在网格之后绘制,否则,坐标轴辅助线可能就会被网格线给挡住。

点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

当你打开solarSystem的开关后,就可以很容易看到地球模型的中心距离公转中心的距离是10个单位,也可以看到地球相对于太阳系的本地坐标空间是什么样子。类似的,当你打开earthOrbit,就可以看到月球距离地球是2个距离单位,以及earthOrbit的本地坐标空间是什么样子。

再看一些例子,比如一个汽车模型的scene graph结构可能是这样:

car scene graph

当你移动车身时,所有的轮子都会和它一起移动。当你希望车身有颠簸的效果(而轮子没有),就需要建立一个新的虚拟节点,将车身和轮子分别作为它的子节点。

再比如游戏中的人物,它的scene graph可能是下面这样:

human scene graph

可以看到人物的场景结构图变得非常复杂,而这还是简化模型,如果你需要模拟人每个指头(至少需要28个节点)或者每个脚指头(需要另外28个节点),再加上脸,下巴,眼睛等等,模型就太复杂了。我们来建立一个相对简单点的模型结构——一个包含6个轮子和炮管的坦克模型,这个坦克会沿着某个路径来运动,场景中还有一个跳动的小球,坦克会始终瞄准这个球,对应的scene graph如下所示,绿色的节点表示实体模型,蓝色的表示Object3D虚拟节点,金色的表示场景灯光,紫色的表示不同的相机,以及一个没有添加到场景结构图中的相机:

tank scene graph

下面来看看代码实现:

对于坦克瞄准的目标而言,需要一个targetOrbit来实现公转,就像上文中的earthOrbit那样。接下来为targetOrbit添加一个子节点targetElevation,从而提供一个相对于targetOrbit的基础高度。接下来再添加一个targetBob子节点,它可以在targetElevation的局部坐标系中实现上下震动,最后添加一个目标实体,一边让它旋转,一边改变其颜色:

// move target
targetOrbit.rotation.y = time * .27;
targetBob.position.y = Math.sin(time * 2) * 4;
targetMesh.rotation.x = time * 7;
targetMesh.rotation.y = time * 13;
targetMaterial.emissive.setHSL(time * 10 % 1, 1, .25);
targetMaterial.color.setHSL(time * 10 % 1, 1, .25);

对于坦克模型而言,首先需要建立一个tank虚拟节点以便来移动坦克的各个部分。代码中使用SplineCurve来生成路径,它可以通过参数来表示坦克所在的实时位置,0.0表示线条起点,1.0表示线条终点。示例中用它来实现坦克的定位和朝向:

const tankPosition = new THREE.Vector2();
const tankTarget = new THREE.Vector2();
...
// move tank
const tankTime = time * .05;
curve.getPointAt(tankTime % 1, tankPosition);
curve.getPointAt((tankTime + 0.01) % 1, tankTarget);
tank.position.set(tankPosition.x, 0, tankPosition.y);
tank.lookAt(tankTarget.x, 0, tankTarget.y);

坦克顶部的炮管作为tank的子节点是可以随坦克自动移动的,为了使它能够对准目标,我们还需要获得目标在世界坐标系的位置,然后使用Object3D.lookAt来实现瞄准:

const targetPosition = new THREE.Vector3();
...
// face turret at target
targetMesh.getWorldPosition(targetPosition);
turretPivot.lookAt(targetPosition);

这里我们还添加了一个炮管相机turretCamera作为炮管实体turretMesh的子节点,这样相机就可以随着炮管一起抬高或降低或旋转,我们将它也对准目标:

// make the turretCamera look at target
turretCamera.lookAt(targetPosition);

目标物体的结构中还生成了一个targetCameraPivot并添加了一个相机,它可以随着targetBob节点实现小范围跳动的模拟。我们将它对准坦克,这样做的目的是为了让targetCamera这个镜头和目标本身之间有一定的偏移,如果直接将镜头添加为targetBob的子节点,它将会出现在目标物体的内部。

// make the targetCameraPivot look at the tank
tank.getWorldPosition(targetPosition);
targetCameraPivot.lookAt(targetPosition);

最后再让车轮转起来:

wheelMeshes.forEach((obj) => {
  obj.rotation.x = time * 3;
});

对于所有的相机,我们设置一个数组并为其添加一些描述信息,然后在渲染时遍历这些相机,从而达到镜头切换的效果:

const cameras = [
  { cam: camera, desc: 'detached camera', },
  { cam: turretCamera, desc: 'on turret looking at target', },
  { cam: targetCamera, desc: 'near target looking at tank', },
  { cam: tankCamera, desc: 'above back of tank', },
];
 
const infoElem = document.querySelector('#info');

渲染时切镜头:

const camera = cameras[time * .25 % cameras.length | 0];
infoElem.textContent = camera.desc;
点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码

希望本文能让你了解scene graph是如何工作的,并让你学会一些基本的使用方法,关键的技巧就是构建Object3D虚拟节点并将其他节点收纳在一起。乍看之下,为了实现一些自己期望的平移或旋转效果通常都需要复杂的数学计算,例如在月球运动的示例中计算月球在世界坐标系中的位置,或者在坦克示例中通过世界坐标去计算坦克轮子应该绘制在哪里等,但当我们使用scene graph时,这些就会变得非常容易。


大史不说话
81 声望27 粉丝

字节跳动前端 | 《前端跨界开发指南》作者