一隻腳踏入 Three.js

AJie發表於2019-04-14

前言

正所謂:無折騰,不前端。不搞 WebGL,和鹹魚有啥區別!

用官方的說法:Three.js - Javascript 3D library。

我們今天就來一起熟悉一下 Three.js 的設計理念與思想。

笛卡爾右手座標系

在做 3D,我們首先得要了解其基本準則:三維座標系。

我們都知道在 CSS3 的三維空間中是左手座標系。(如果不瞭解的可以閱讀我之前寫的一篇文章《CSS3 之 3D 變換》

但是在 Three.js 中,我們的空間是基於右手笛卡爾座標系的而展現的。如下:

一隻腳踏入 Three.js

一隻腳踏入 Three.js

瞭解了座標系之後,我們就能在這片三維空間中建立我們想要的場景了。

建立場景

想要使用三維空間,首先就必須開闢一個三維空間這一容器。而開闢一個三維空間只需要例項化 THREE.Scene 這一物件就可以了。

var scene = new THREE.Scene();
複製程式碼

場景是你可以放置物體、相機和燈光的三維空間,如同宇宙一般,沒有邊界,也沒有光亮,有的是無盡的黑暗。

一個場景中的元件可以的大致分為三類:攝像機、光源、物件。

我們在瞭解 Thee.js 中的元件之前,先看一張照片:

一隻腳踏入 Three.js

這是一張拍攝商品的工作室照片。這張照片就基本可以說明我們 Three.js 的 3D 設計模式:我們在有了一個空間之後,我們需要將我們是拍攝物件放進去。有了物件之後我們還需要設定至少一個光源,這樣我們才能看到我們的拍攝物件。最後,我們呈現在客戶眼前的是一系列由相機拍攝出的照片連續播放產生的動畫,相機的引數、位置和角度直接影響著我們所拍到的圖片。

拍攝物件

在使用拍攝物件之前我們先說明一下用 Three.js 建立拍攝物件的設計模式:

首先 Three.js 將任何拍攝物件解構為一個個小三角形。無論是二維圖形還是三維圖形,都可以用三角形作為結構最小單位。而結構出來的就是我們拍攝物件的一個網格。

如下呈現的是二維平面的網格結構:

一隻腳踏入 Three.js

如下展示的是三維球體網格結構:

一隻腳踏入 Three.js

可以看到在 Three.js 中三角形是最小分割單位。這就是網格結構。

當然有網格結構還是不夠的。就像人體一樣,因為網格結構就像是骨架,在其外表還需要材質。材質就是物體的皮膚,決定著幾何體的外表。

幾何體模型(Geometry)

在 Three.js 中,為我們預設了很多幾何體的網格結構:

  • 二維:

    • THREE.PlaneGeometry(平面)

      這個幾何體在前文已經展示過了。

    • THREE.CircleGeometry(圓)

      一隻腳踏入 Three.js

    • THREE.RingGeometry(環)

      一隻腳踏入 Three.js

  • 三維

    • THREE.BoxGeometry(長方體)

      一隻腳踏入 Three.js

    • THREE.SphereGeometry(球體)

      這個幾何體在前文已經展示過了。

    • THREE.CylinderGeometry(圓柱體)

      一隻腳踏入 Three.js

    • THREE.Torus(圓環)

      一隻腳踏入 Three.js

以上所舉的只是內建幾何體的一部分。我們在使用這些集合體的時候,我們只需要例項化相應幾何體物件即可。

具體我們以例項化一個正方體為例:

var geometry = new THREE.BoxGeometry(4, 4, 4);
複製程式碼

這裡我們先宣告並且例項化了一個 BoxGeometry(長方體)物件。在建立物件的時候我們分別設定了長、寬、高各為 4。

這樣一個正方體就建立好了。但是有了這麼一個網格框架是遠遠不夠的。下一步就是給他新增材質。

材質(Material)

在材質元件中,Three.js 也為我們預設了幾種材質物件,我們這裡簡單的介紹兩種最常用的:

  1. MeshBasicMaterial

    這一材質,是 Three.js 的基礎材質。用於給幾何體網格賦予一種簡單的顏色或是顯示幾何體的網格結構。(即便在沒有光源的情況下也可以顯示。)

  2. MeshLambertMaterial

    這是一種考慮光照影響的材質。用於建立暗淡的,不光亮的物體。

值得注意的是,在同一個網格結構中我們可以多種材質進行疊加。

這裡我們先後使用 MeshBasicMaterial 和 MeshLambertMaterial 為我們前文所創造的正方體準備兩個不同的材質:

var geometryMeshBasicMaterial = new THREE.MeshBasicMaterial({
  color: 0xff0000,
  wireframe: true
});
var geometryMeshLambertMaterial = new THREE.MeshLambertMaterial({
  color: 0x242424
});
複製程式碼

其中 wireframe 屬性當設定為 true 的時候,將會將材質渲染為線寬。相框可以變相的理解為網格線。比如說一個正方體的線框如下:

一隻腳踏入 Three.js

網格(Mesh)

當我們擁有了幾何體網格模型和材質之後我們就需要將兩者結合起來建立我們正在的拍攝物件。

這裡我們介紹兩個不同的拍攝物件構造方法:

  • new THREE.Mesh(geometry, material)
  • THREE.SceneUtils.createMultiMaterialObject(geometry,[materials...])

這兩種都是建立拍攝物件的方法,且第一個引數都是幾何體模型(Geometry),唯一不同在於第二個引數。前者只能用一種材質建立拍攝物件,後者可以使用多種材質進行建立(傳入一個包含多種材質的陣列)。

這裡我們將建立一個多材質拍攝物件。

var cube = THREE.SceneUtils.createMultiMaterialObject(geometry, [
  geometryMeshBasicMaterial,
  geometryMeshLambertMaterial
]);
複製程式碼

現在我們已經有一個拍攝物件了,這時候我們需要將我們的物件新增到場景中,就像我們在拍攝商品一樣,得要把我們的商品放在拍攝空間之中。

在 Three.js 中,向場景中新增物件可以直接通過場景物件呼叫 add 方法實現。具體實現如下:

scene.add(cube);
複製程式碼

我們向 add()方法內傳入我們要新增的物件,可以是一個,也可以多個,用逗號隔開。

光源

和現實生活中的邏輯是一樣的,物體本身是不會發光的。如果沒有太陽這一光源,地球將陷入無盡的黑暗,啥也瞅不著。所以我們也要向我們的場景中新增光源物件。

在 Three.js 中,光源分為了好幾種,接下來將簡單的介紹其中用的比較多的幾種。

  1. THREE.AmbientLight

    這是一種基本光源,該光源將會疊加到場景現有物體的顏色上。

    該光源沒有特定的來源方向,且不會產生陰影。

    我們經常在使用了其他光源的同時使用它,是為了弱化陰影或給場景新增一些額外的顏色。

  2. THREE.SpotLight

    一隻腳踏入 Three.js

    這種光源有聚光的效果,類似檯燈、手電筒、舞臺聚光燈。

    這種光源可以投射陰影。

  3. THREE.DirectionalLight

    這種光源也稱為無限光,類似太陽光。

    這種光源發出的光線可以看作是平行的。

    這種光源也可投射陰影。

在我們的例子中,我們將用 SpotLight 來建立我們的燈光。

首先我們要和之前常見拍攝物件一樣,先例項化一個 SpotLight 物件,並且以一個十六進位制的顏色值作為傳參,作為我們燈光的顏色。

var spotLight = new THREE.SpotLight(0xffffff);
spotLight.position.set(0, 20, 20);
spotLight.intensity = 5;
scene.add(spotLight);
複製程式碼

在擁有光源物件之後,我們將呼叫 position.set()方法設定在三維空間中的位置。

intensity 屬性用於設定光源照射的強度,預設值為 1。

最後我們也得將光源放進我們的場景空間之中。這樣我們的場景就有了一個 SpotLight 光源了。

攝像機

在 THREE.js 中有兩種相機:

  • THREE.PerspectiveCamera(透視相機)

    符合近大遠小的常理。用接近真實世界的視角來渲染場景。

    一隻腳踏入 Three.js

  • THREE.OrthographicCamera(正交相機)

    提供了一個偽三維效果。

    一隻腳踏入 Three.js

可以看的出來:透視相機更貼近我們現實生活中人眼所觀察到的世界,而正交相機渲染的結果和物件相距相機距離的遠近沒有影響。

這裡我將著重介紹一下 PerspectiveCamera:

我們先來看一張圖:

一隻腳踏入 Three.js

對於一個透視相機來說,我們需要設定以下幾個引數:

  • fov(視場)是豎直方向上的張角(是角度制而非弧度制)
  • aspect(長寬比)是照相機水平方向和豎直方向長度的比值
  • near(近面距離)相機到視景體最近的距離
  • far(遠面距離)相機到視景體最遠的距離
  • zoom(變焦)

這裡我們也將建立一個我們自己的透視相機。

var camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.x = 5;
camera.position.y = 10;
camera.position.z = 10;
camera.lookAt(cube.position);
複製程式碼

首先我們在例項化透視相機物件的時候,向其內部傳遞了幾個引數:豎直方向上的張角為 75 度,長寬比與視窗相同,相機到視景體最近、最遠的距離分別為 0.1 和 1000。

最後我們讓相機通過呼叫 lookAt()方法,看向我們之前建立的拍攝物件 cube 的位置上。(預設狀態下,相機將指向三維座標系的原點。)

渲染器(Renderer)

在有了以上的這些物件之後,我們離成功之差區區幾步了。

在看到這一部分的標題的時候,你可能會問:什麼是渲染器?

通俗地說:我們用相機拍到的是底片,還不是真正的相片。如果你還對老式相機有所印象,這一點將不難理解。

當我們拿著一臺老式相機(還需要膠捲的那種)我們每拍一張都將得到一張底片。我們想要拿到正真的相片還需要帶著底片,前往照相館去洗出來。這時候老闆會問你你要洗多大的相片,然後依據你的需求洗出你想要的相片。

可以說這就是渲染器的作用——洗相片。還記得我們之前在設定相機的引數的時候,我們並沒有設定相機的寬高,而是隻指定了相機的長寬比。這就像我們的底片一樣,雖然小,但是卻顯示了我們相片的基本長寬比。

我們建立渲染器的方法和建立 THREE 中的其他物件一樣,都需要先將物件例項化。

Three.js 為我們提供了好幾種不同的渲染器這裡我們將使用 THREE.WebGLRenderer 渲染器作為例子。

var renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.render(scene, camera);
複製程式碼
  • 我們通過呼叫 setSize() 方法設定渲染的長寬。
  • 渲染器 renderer 的 domElement 元素,表示渲染器中的畫布,所有的渲染都是畫在 domElement 上的,所以這裡的 appendChild 表示將這個 domElement 掛接在 body 下面,這樣渲染的結果就能夠在頁面中顯示了。
  • render()方法中傳遞我們的場景和相機,相當於傳遞了一張由相機拍攝場景得到的一張底片,它將將影象渲染到我們的畫布中。

這時候你將得到一個如下形狀:

一隻腳踏入 Three.js

這裡我們為了方便觀察,新增了座標系物件。

與一般物件一樣,我們通過例項化該物件,並向其內傳遞一個軸長引數,最後新增進我們的場景之中。

var axes = new THREE.AxisHelper(7);
scene.add(axes);
複製程式碼

這裡我們的座標系軸長設定為 7。

這時候你會發現這張圖片還是靜態的,3D 的特性還沒有完全發揮出來。

動畫(Animation)

在講解動畫之前我們需要科普幾個知識點,實際上扯遠了一點,不過會有助於我們去理解動畫的渲染,提高效能。

理解 Event Loop

非同步執行的執行機制如下:

  1. 所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。
  2. 主執行緒之外,還存在一個“任務佇列”(task queue)。只要滿足非同步任務的執行條件,就在“任務佇列”之中放置一個事件
  3. 一旦“執行棧”中的所有同步任務執行完畢,系統就會讀取“任務佇列”,看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。

主執行緒不斷重複上面的第三步。主執行緒從“任務佇列”中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為 Event Loop(事件迴圈)。只要主執行緒空了,就會去讀取“任務佇列”,這就是 JavaScript 的執行機制。這個過程會迴圈反覆。

動畫原理

動畫實際上是由一些列的圖片在一定時間內,以一定的頻率播放而產生的錯覺。

眼睛的一個重要特性是視覺惰性,即光象一旦在視網膜上形成,視覺將會對這個光象的感覺維持一個有限的時間,這種生理現象叫做視覺暫留性。對於中等亮度的光刺激,視覺暫留時間約為 0.1 至 0.4 秒。

為了讓動畫連貫的、平滑的方式進行過渡,一般我們以 60 幀每秒甚至更高的速率渲染動畫。

為什麼不用 setInterval() 實現動畫?

  • setInterval()的執行時間並不是確定的。在 Javascript 中, setInterval()任務被放進了非同步佇列中,只有當主執行緒上的任務執行完以後,才會去檢查該佇列裡的任務是否需要開始執行,因此 setInterval()的實際執行時間一般要比其設定的時間晚一些。
  • setInterval()只能設定一個固定的時間間隔,這個時間不一定和螢幕的重新整理時間相同。

以上兩種情況都會導致 setInterval()的執行步調和螢幕的重新整理步調不一致,從而引起丟幀現象。 那為什麼步調不一致就會引起丟幀呢?

首先要明白,setInterval()的執行只是在記憶體中對影象屬性進行改變,這個變化必須要等到螢幕下次重新整理時才會被更新到螢幕上。如果兩者的步調不一致,就可能會導致中間某一幀的操作被跨越過去,而直接更新下一幀的影象。假設螢幕每隔 16.7ms 重新整理一次(60 幀),而 setInterval()每隔 10ms 設定影象向左移動 1px, 就會出現如下繪製過程:

  • 第 0ms: 螢幕未重新整理,等待中,setInterval()也未執行,等待中;
  • 第 10ms: 螢幕未重新整理,等待中,setInterval()開始執行並設定影象屬性 left=1px;
  • 第 16.7ms: 螢幕開始重新整理,螢幕上的影象向左移動了1px, setInterval()未執行,繼續等待中;
  • 第 20ms: 螢幕未重新整理,等待中,setInterval()開始執行並設定 left=2px;
  • 第 30ms: 螢幕未重新整理,等待中,setInterval()開始執行並設定 left=3px;
  • 第 33.4ms:螢幕開始重新整理,螢幕上的影象向左移動了3px, setInterval()未執行,繼續等待中;

從上面的繪製過程中可以看出,螢幕沒有更新 left=2px 的那一幀畫面,影象直接從 1px 的位置跳到了 3px 的的位置,這就是丟幀現象,這種現象就會引起動畫卡頓。

requestAnimationFrame()

requestAnimationFrame()的優勢

與 setInterval()相比,requestAnimationFrame()最大的優勢是**由系統來決定回撥函式的執行時機。**具體一點講,如果螢幕重新整理率是 60 幀,那麼回撥函式就每 16.7ms 被執行一次,如果重新整理率是 75Hz,那麼這個時間間隔就變成了 1000/75=13.3ms,換句話說就是,requestAnimationFrame()的步伐跟著系統的重新整理步伐走。它能保證回撥函式在螢幕每一次的重新整理間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。

除此之外,requestAnimationFrame()還有以下兩個優勢:

  • CPU 節能:使用 setInterval()實現的動畫,當頁面被隱藏或最小化時,setInterval()仍然在後臺執行動畫任務,由於此時頁面處於不可見或不可用狀態,重新整理動畫是沒有意義的,完全是浪費 CPU 資源。而 requestAnimationFrame()則完全不同,當頁面處理未啟用的狀態下,該頁面的螢幕重新整理任務也會被系統暫停,因此跟著系統步伐走的 requestAnimationFrame()也會停止渲染,當頁面被啟用時,動畫就從上次停留的地方繼續執行,有效節省了 CPU 開銷。

  • 函式節流:在高頻率事件(resize,scroll 等)中,為了防止在一個重新整理間隔內發生多次函式執行,使用 requestAnimationFrame()可保證每個重新整理間隔內,函式只被執行一次,這樣既能保證流暢性,也能更好的節省函式執行的開銷。一個重新整理間隔內函式執行多次時沒有意義的,因為顯示器每 16.7ms 重新整理一次,多次繪製並不會在螢幕上體現出來。

requestAnimationFrame()的工作原理:

先來看看 Chrome 原始碼:

int Document::requestAnimationFrame(PassRefPtr<RequestAnimationFrameCallback> callback)
{
  if (!m_scriptedAnimationController) {
    m_scriptedAnimationController = ScriptedAnimationController::create(this);
    // We need to make sure that we don't start up the animation controller on a background tab, for example.
      if (!page())
        m_scriptedAnimationController->suspend();
  }

  return m_scriptedAnimationController->registerCallback(callback);
}
複製程式碼

仔細看看就覺得底層實現意外地簡單,生成一個 ScriptedAnimationController 的例項用於存放註冊事件,然後註冊這個 callback。

requestAnimationFrame 的實現原理就很明顯了:

  • 註冊回撥函式
  • 瀏覽器按一定幀率更新時會觸發 觸發所有註冊過的 callback

這裡的工作機制可以理解為所有權的轉移,把觸發幀更新的時間所有權交給瀏覽器核心,與瀏覽器的更新保持同步。這樣做既可以避免瀏覽器更新與動畫幀更新的不同步,又可以給予瀏覽器足夠大的優化空間。

用 requestAnimationFrame()建立動畫

我們需要建立一個迴圈渲染函式,並且進行呼叫:

// a render loop
function render() {
  requestAnimationFrame(render);

  // Update Properties

  // render the scene
  renderer.render(scene, camera);
}
複製程式碼

我們在函式體內部進行相應的屬性更新並渲染,並且讓瀏覽器來控制動畫幀的更新。

製作動畫

這裡我們將通過 requestAnimationFrame() 來建立我們的動畫效果。讓瀏覽器來控制動畫幀的更新最大的提高我們的效能。

var animate = function() {
  requestAnimationFrame(animate);
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
};
animate();
複製程式碼

我們在 animate()方法中,通過 requestAnimationFrame(animate)來使瀏覽器在每次更新頁面的時候呼叫 animate 方法。且每呼叫一次,正方體的屬性就作出相應的改變:每一次呼叫都比上一次 X 軸、Y 軸各旋轉 0.01 弧度,並且將其渲染到畫布上。

這樣我們的動畫就產生了:

一隻腳踏入 Three.js

THREE.Color 物件

這裡我在補充說明一下 Three.js 內建的顏色物件。

通常情況下,我們可以使用十六進位制的字串("#000000")或十六進位制值(0x000000)來建立指定顏色物件。我們也可以用 RGB 顏色值來建立(0.2, 0.3, 0.4),但值得注意的是其每個值的範圍為 0 到 1。

例如:

var color = new THREE.Color(0x000000);
複製程式碼

在建立顏色物件之後,我們可以對用其自身的一些方法,這裡就不詳細介紹了:

函式名 描述
set(value) 將當前顏色設定為指定的十六進位制值。這個值可以是字串、數值或是已有的 THREE.Color 例項。
setHex(value) 將當前顏色設定為指定的十六進位制數字值。
setRGB(r,g,b) 根據提供的 RGB 值設定顏色。引數範圍從 0 到 1。
setHSL(h,s,l) 根據提供的 HSL 值設定顏色。引數範圍從 0 到 1。
setStyle(style) 根據 css 設定顏色的方式來設定顏色。例如:可以使用 "rgb(25, 0, 0)"、"#ff0000"、"#ff" 或 "red"。
copy(color) 從提供的顏色物件複製顏色值到當前物件。
getHex() 以十六進位制值形式從顏色物件中獲取顏色值:435241。
getHexString() 以十六進位制字串形式從顏色物件中獲取顏色值:"0c0c0c"。
getStyle() 以 css 值的形式從顏色物件中獲取顏色值:"rgb(112, 0, 0)"
getHSL(optionalTarget) 以 HSL 值的形式從顏色物件中獲取顏色值。如果提供了 optionTarget 物件, Three.js 將把 h、s 和 l 屬性設定到該物件。
toArray 返回三個元素的陣列:[r,g,b]。
clone() 複製當前顏色。

總結

可以這麼說:

Three.js 的一切都建立在 Scene 物件之上。有了場景這一空間之後,我們就可以往裡面新增我們要展示的拍攝物件了。當然有了拍攝物件之後我們還需要一個光源,讓我們看的見我們的物件。這時候我們還需要一個相機,用以拍攝我們的拍攝物件。當然我們實際還需要靠我們的渲染器將實際影象繪製在畫布上。

通過不斷變換物件的屬性,並且不斷地繪製我們的場景,這就產生了動畫!

附原始碼

<html>
  <head>
    <title>Cube</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
      }

      canvas {
        width: 100%;
        height: 100%;
      }
    </style>
  </head>

  <body>
    <script src="https://cdn.bootcss.com/three.js/r83/three.min.js"></script>
    <script>
      var scene = new THREE.Scene();

      var axes = new THREE.AxisHelper(7);
      scene.add(axes);

      var geometry = new THREE.BoxGeometry(4, 4, 4);
      var geometryMeshBasicMaterial = new THREE.MeshBasicMaterial({
        color: 0xff0000,
        wireframe: true
      });
      var geometryMeshLambertMaterial = new THREE.MeshLambertMaterial({
        color: 0x242424
      });
      var cube = THREE.SceneUtils.createMultiMaterialObject(geometry, [
        geometryMeshBasicMaterial,
        geometryMeshLambertMaterial
      ]);
      scene.add(cube);

      var spotLight = new THREE.SpotLight(0xffffff);
      spotLight.position.set(0, 20, 20);
      spotLight.intensity = 5;
      scene.add(spotLight);

      var camera = new THREE.PerspectiveCamera(
        75,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      );
      camera.position.x = 5;
      camera.position.y = 10;
      camera.position.z = 10;
      camera.lookAt(cube.position);

      var renderer = new THREE.WebGLRenderer();
      renderer.setSize(window.innerWidth, window.innerHeight);
      document.body.appendChild(renderer.domElement);

      var animate = function() {
        requestAnimationFrame(animate);
        cube.rotation.x += 0.01;
        cube.rotation.y += 0.01;
        renderer.render(scene, camera);
      };
      animate();
    </script>
  </body>
</html>
複製程式碼

-EFO-


筆者專門在 github 上建立了一個倉庫,用於記錄平時學習全棧開發中的技巧、難點、易錯點,歡迎大家點選下方連結瀏覽。如果覺得還不錯,就請給個小星星吧!?


2019/04/14

AJie

相關文章