示例代码托管在:http://www.github.com/dashnowords/blogsjavascript
博客园地址:《大史住在大前端》原创博文目录html
华为云社区地址:【你要的前端打怪升级指南】前端
原文地址: https://threejsfundamentals.org/threejs/lessons/threejs-scenegraph.html java
笔者按:别关键词保持原英文单词,理解起来会更方便。原文中有许多内嵌的支持在线编辑的示例代码,可点击上面连接直接体验。node
本文是three.js
系列博文的一篇,第一篇文章是【three.js基础知识】,若是你尚未阅读过,能够从这一篇开始,页面顶部能够切换为中文或英文。git
three.js
中最核心的部分可能就是scene graph
(或称为场景节点图)。3D引擎中的scene graph
是一个表示继承关系的节点图谱,图谱中的每一个节点都表示了一个本地坐标空间。github
这样说可能比较抽象,咱们来举例说明一下。一个典型的例子就是模拟银河系中的太阳,地球和月亮。canvas
地球轨迹是绕着太阳的,月球的轨迹是绕着地球的。月亮绕着地球作圆周运动,从月球的视角来观察时,它是在地球的”本地坐标空间“中进行旋转的,然而若是相对于太阳的“本地坐标空间”来看,月球的运动轨迹就会变成很是复杂的螺旋线。(原文中下图是javascript代码实现的动画)数组
换个角度来思考,当你住在地球上时,并不须要考虑地球的自转或者绕着太阳公转,不管你是行走,开车,游泳,跑步仍是作什么,地球相对于你来讲就和静止的没什么差异,你的全部行为在地球的”本地坐标空间“中进行的,尽管这个坐标空间自己相对于太阳而言以1000英里每小时的速度自转,并以67000英里每小时的速度公转着。你的位置相对于银河系而言,就如同上例中的月亮同样,但你一般只须要关心本身相对于地球“本地坐标空间”的行为就能够了。less
咱们一步一步来。假设如今咱们想制做一个包含太阳,地球和月亮的图谱。从太阳开始绘制,首先要作的就是生成一个球体,而后将其放置在坐标原点。咱们但愿使用三者之间的相对关系来展现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
中加入一个新的空节点,而后将太阳和地球都变成它的子节点,以下所示:
咱们新建立了一个Object3D
对象。它能够像Mesh
的实例同样直接被添加场景结构图scene graph
,但不一样的是它没有材质或者几何体,它仅仅用来表示一个本地的坐标空间。这样一来,新的场景结构图就变成了:
这样,地球模型和太阳模型都变成了这个虚拟节点solarSystem
的子节点。如今,当这三个节点都进行转动时,地球再也不是太阳的子节点,因此也就不会被放大,正如咱们指望的那样。
点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码
如今看起来就好不少了,地球比太阳小,而且一边自转,一边绕太阳公转,依据一样的模式,能够生成月亮的模型:
咱们在此添加一个不可见的虚拟节点,这个Object3D
的实例叫作earthOrbit
,而后将地球模型和月亮模型都添加为它的子节点,场景结构图以下所示:
点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码
你能够看到月球沿着某种螺旋线在进行运动,但咱们并不须要手动去计算它的轨迹,而只须要配置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。下面为每一个节点来添加GridHelper
和AxesHelper
。咱们给每一个节点添加一个标记,并将代码调整为下面的形式:
makeAxisGrid
方法用来生成包含轴线和网格的辅助线AxisGridHelper
,正如前文所述,dat.GUI
会根据属性名自动生成UI,咱们但愿获得一个checkbox
,这样就能够很方便地改变bool
类型的属性值。可是,咱们想使用同一个属性同时控制坐标轴和网格线的隐藏/展现,因此就封装了一个新的辅助类,并在对应属性的getter
和setter
中分别操做AxesHelper
和GridHelper
,对于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; } }
另外须要注意的是,咱们将AxesHelper
的RenderOrder
设置为2,而将GridHelper
设置为1,这样坐标轴辅助线就会在网格以后绘制,不然,坐标轴辅助线可能就会被网格线给挡住。
点击在线示例可直接查看,原文中此处有支持在线编辑的示例代码
当你打开solarSystem
的开关后,就能够很容易看到地球模型的中心距离公转中心的距离是10个单位,也能够看到地球相对于太阳系的本地坐标空间是什么样子。相似的,当你打开earthOrbit
,就能够看到月球距离地球是2个距离单位,以及earthOrbit
的本地坐标空间是什么样子。
再看一些例子,好比一个汽车模型的scene graph
结构多是这样:
当你移动车身时,全部的轮子都会和它一块儿移动。当你但愿车身有颠簸的效果(而轮子没有),就须要创建一个新的虚拟节点,将车身和轮子分别做为它的子节点。
再好比游戏中的人物,它的scene graph
多是下面这样:
能够看到人物的场景结构图变得很是复杂,而这仍是简化模型,若是你须要模拟人每一个指头(至少须要28个节点)或者每一个脚指头(须要另外28个节点),再加上脸,下巴,眼睛等等,模型就太复杂了。咱们来创建一个相对简单点的模型结构——一个包含6个轮子和炮管的坦克模型,这个坦克会沿着某个路径来运动,场景中还有一个跳动的小球,坦克会始终瞄准这个球,对应的scene graph
以下所示,绿色的节点表示实体模型,蓝色的表示Object3D
虚拟节点,金色的表示场景灯光,紫色的表示不一样的相机,以及一个没有添加到场景结构图中的相机:
下面来看看代码实现:
对于坦克瞄准的目标而言,须要一个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
时,这些就会变得很是容易。