示例代碼托管在: "http://www.github.com/dashnowords/blogs" 博客園地址: "《大史住在大前端》原創博文目錄" 華為雲社區地址: "【你要的前端打怪升級指南】" [TOC] 原文地址: " https://threejsfundamentals.org/thr ...
示例代碼托管在:http://www.github.com/dashnowords/blogs
博客園地址:《大史住在大前端》原創博文目錄
華為雲社區地址:【你要的前端打怪升級指南】
目錄
原文地址: https://threejsfundamentals.org/threejs/lessons/threejs-scenegraph.html
筆者按:別關鍵詞保持原英文單詞,理解起來會更方便。原文中有許多內嵌的支持線上編輯的示例代碼,可點擊上面鏈接直接體驗。
本文是three.js
系列博文的一篇,第一篇文章是【three.js基礎知識】,如果你還沒有閱讀過,可以從這一篇開始,頁面頂部可以切換為中文或英文。
three.js
中最核心的部分可能就是scene graph
(或稱為場景節點圖)。3D引擎中的scene graph
是一個表示繼承關係的節點圖譜,圖譜中的每個節點都表示了一個本地坐標空間。
這樣說可能比較抽象,我們來舉例說明一下。一個典型的例子就是模擬銀河系中的太陽,地球和月亮。
地球軌跡是繞著太陽的,月球的軌跡是繞著地球的。月亮繞著地球做圓周運動,從月球的視角來觀察時,它是在地球的”本地坐標空間“中進行旋轉的,然而如果相對於太陽的“本地坐標空間”來看,月球的運動軌跡就會變成非常複雜的螺旋線。(原文中下圖是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
中加入一個新的空節點,然後將太陽和地球都變成它的子節點,如下所示:
我們新創建了一個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
時,這些就會變得非常容易。