示例代碼託管在: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
時,這些就會變得很是容易。