引言
三維計算機圖形和二維計算機圖形的不同之處在於計算機儲存了幾何資料的三維表示,其用於計算和繪製最終的二維影像。——《3D computer graphics》
隨著 WebGL 標準的快速推進,越來越多團隊嘗試在瀏覽器上推出可互動的 3D 作品。相較於二維場景,它更能為使用者帶來真實和沉浸的體驗。
然而 OpenGL 和 WebGL(基於 OpenGL ES) 都比較複雜,Three.js 則更適合初學者。本文將分享一些 Three.js 的基礎知識,希望能讓你能有所收穫。
當然,分享的知識點也不會面面俱到,想更深入的學習,還得靠大家多看多實踐。另外,為了控制篇幅,本文更傾向於通過案例中的程式碼和註釋進行闡述一些細節。
若想系統學習,筆者認為看書是一個不錯的選擇:
Three.js開發指南(原書第2版) 購買連結>>
儘管由於 Three.js 的不斷迭代,書本上的某些 API 已改變(或棄用),甚至難免還有一些錯誤,但這些並不影響整體的閱讀。
Canvas 2D
如引言中說道,3D 影像在計算機中最終以 2D 影像呈現。因此,渲染模式只是作為一個載體。下面我們用 JavaScript(無依賴) 在 Canvas 2D 渲染一個在正檢視/透檢視中的立方體。
正檢視中的立方體:
透檢視中的立方體:
若要將三維圖形渲染在二維螢幕上,需要將三維座標以某種方式轉為二維座標。但對於更復雜的場景,大量座標的轉換和陰影等耗效能操作無疑需要 Web 提供更高效的渲染模式。
另外,想了解上述兩個案例的實現原理,可檢視譯文:《用 JavaScript 構建一個3D引擎》。
WebGL
WebGL(Web Graphics Library)在 GPU 中執行。因此需要使用能夠在 GPU 上執行的程式碼。這樣的程式碼需要提供成對的方法(其中一個叫頂點著色器, 另一個叫片段著色器),並且使用一種類 C/C++ 的強型別語言 GLSL(OpenGL Shading Language)。 每一對方法組合起來稱為一個 program(著色程式)。
頂點著色器的作用是計算頂點的位置。根據計算出的一系列頂點位置,WebGL 可以對點、線和三角形在內的一些圖元進行光柵化處理。當對這些圖元進行光柵化處理時需要使用片段著色器方法。片段著色器的作用是計算出當前繪製圖元中每個畫素的顏色值。
用 WebGL 繪製一個三角形:
檢視上述案例的程式碼實現後,我們發現繪製一個看似簡單的三角形其實並不簡單,它需要我們學習更多額外的知識。
因此,對於剛入門的開發者來說,直接使用 WebGL 來繪製並拼裝出幾何體是不現實的。但我們可以在瞭解 WebGL 的基礎知識後,再通過 Three.js 這類封裝後的庫來現實我們的需求。
Three.js
開啟 Three.js 官方文件 並閱覽左側的目錄,發現該文件對初學者並不友好。但相對於其他資料,它提供了最新的 API 說明,儘管有些描述並不詳細(甚至需要在懂 WebGL 等其他知識的前提下,才能瞭解某個術語的意思)。下面提供兩個 Three.js 的相關圖片資料,希望它們能讓你對 Three.js 有個整體的認識:
Three.js 文件結構:圖片來自>>
Three.js 核心物件結構和基本的渲染流程:圖片來自>>
Three.js 的基本要素
我們先通過一個簡單但完整的案例來了解 Three.js 的基本使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// 引入 Three.js 庫 <script src="https://unpkg.com/three"></script> function init () { // 獲取瀏覽器視窗的寬高,後續會用 var width = window.innerWidth var height = window.innerHeight // 建立一個場景 var scene = new THREE.Scene() // 建立一個具有透視效果的攝像機 var camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800) // 設定攝像機位置,並將其朝向場景中心 camera.position.x = 10 camera.position.y = 10 camera.position.z = 30 camera.lookAt(scene.position) // 建立一個 WebGL 渲染器,Three.js 還提供 <canvas>, <svg>, CSS3D 渲染器。 var renderer = new THREE.WebGLRenderer() // 設定渲染器的清除顏色(即背景色)和尺寸。 // 若想用 body 作為背景,則可以不設定 clearColor,然後在建立渲染器時設定 alpha: true,即 new THREE.WebGLRenderer({ alpha: true }) renderer.setClearColor(0xffffff) renderer.setSize(width, height) // 建立一個長寬高均為 4 個單位長度的立方體(幾何體) var cubeGeometry = new THREE.BoxGeometry(4, 4, 4) // 建立材質(該材質不受光源影響) var cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) // 建立一個立方體網格(mesh):將材質包裹在幾何體上 var cube = new THREE.Mesh(cubeGeometry, cubeMaterial) // 設定網格的位置 cube.position.x = 0 cube.position.y = -2 cube.position.z = 0 // 將立方體網格加入到場景中 scene.add(cube) // 將渲染器的輸出(此處是 canvas 元素)插入到 body 中 document.body.appendChild(renderer.domElement) // 渲染,即攝像機拍下此刻的場景 renderer.render(scene, camera) } init() |
線上案例:
看完上述案例程式碼後,你可能會產生以下幾個疑問:
- Three.js 的單位是什麼?
- 座標系的位置和指向是?
- 具有透視效果的攝像機的引數含義是?
- Mesh 的作用是?
下面我們逐一回答:
1. Three.js 的單位是什麼?
答:Three.js 基於 OpenGL,那我們從 OpenGL 文件看到這麼一句話:
“The preceding paragraph mentions inches and millimeters – do these really have anything to do with OpenGL? The answer is, in a word, no. The projection and other transformations are inherently unitless. If you want to think of the near and far clipping planes as located at 1.0 and 20.0 meters, inches, kilometers, or leagues, it’s up to you. The only rule is that you have to use a consistent unit of measurement. Then the resulting image is drawn to scale.” ——《OpenGL Programming Guide》
中文:前面段落提及的英寸和毫米真的和 OpenGL 有關係嗎?沒有。投影和其它變換在本質上都是無單位的。如果你想把近距離和遠距離的裁剪平面分別放置在 1.0 和 20.0 米/英寸/千米/裡格,這取決於你。這裡唯一的要求是你必須使用統一的測量單位,然後按比例繪製最終影像。
2. 座標系的位置和指向是?
答:Three.js 的座標系是遵循右手座標系,如下圖:
右手座標系
座標系的原點在畫布中心(canvas.width / 2
, canvas.height / 2
)。我們可以通過 Three.js 提供的 THREE.AxisHelper()
輔助方法將座標系視覺化。
RGB顏色分別代表 XYZ 軸:
另外,補充一點:對於旋轉 cube.rotation
正值是逆時針旋轉,負值是順時針旋轉。
3. 具有透視效果的攝像機的引數含義是?
答: THREE.PerspectiveCamera(fov, aspect, near, far)
具有 4 個引數,具體解釋如下:
引數 | 描述 |
---|---|
fov | fov 表示視場,即攝像機能看到的視野。比如,人類有接近 180 度的視場,而有些鳥類有接近 360 度的視場。但是由於計算機不能完全顯示我們能夠所看到的景象,所以一般會選擇一塊較小的區域。對於遊戲而言,視場大小通常為 60 ~ 90 度。 推薦預設值為:50 |
aspect | 指定渲染結果的橫向尺寸和縱向尺寸的比值。在我們的示例中,由於使用視窗作為輸出介面,所有使用的是視窗的長寬比。 推薦預設值:window.innerWidth / window.innerHeight |
near | 指定從距離攝像機多近的距離開始渲染。 推薦預設值:0.1 |
far | 指定攝像機從它所處的位置開始能看到多遠。若過小,那麼場景中的遠處不會被渲染;若過大,可能會影響效能。 推薦預設值:1000 |
攝像機的 fov
屬性指定了橫向視場。基於 aspect
屬性,縱向視場也就相應確定了。而近面和遠面則指定了視覺化區域的前後邊界,即兩者之間的元素才可能被渲染。
Three.js 還提供了其他 3 種攝像機:CubeCamera、OrthographicCamera、StereoCamera。
其中 OrthographicCamera 是正交投影攝像機,他不具有透視效果,即物體的大小不受遠近距離的影響。
切換正交投影攝像機和透視攝像機:
4. Mesh 的作用是?
答:Mesh 好比一個包裝工,它將『視覺化的材質』粘合在一個『數學世界裡的幾何體』上,形成一個『可新增到場景的物件』。
當然,建立的材質和幾何體可以多次使用(若需要)。而且,包裝工不止一種,還有 Points
(點集)、Line
(線/虛線) 等。
同一個幾何體的多種表現形式:
Three.js 提供的所有物體
從 Three.js 文件目錄的 Geometries
可看到,Three.js 已為我們提供了很多現成的幾何體,但如果對幾何知識不常接觸,可能就很難從它的英文名字聯想到其實際的形狀。下面我們將它們一次性羅列出來:
Three.js 提供的 18 個幾何體:
目前 Three.js 一共提供了 22 個 Geometry,除了 EdgesGeometry
、ExtrudeGeometry
、TextGeometry
、WireframeGeometry
,上面涵蓋 18 個,它們分別是底層的 planeGeometry
和以下 17 種(順序與上述案例一一對應,下同):
BoxGeometry(長方體) | CircleGeometry(圓形) | ConeGeometry(圓錐體) | CylinderGeometry(圓柱體) |
---|---|---|---|
DodecahedronGeometry(十二面體) | IcosahedronGeometry(二十面體) | LatheGeometry(讓任意曲線繞 y 軸旋轉生成一個形狀,如花瓶) | OctahedronGeometry(八面體) |
ParametricGeometry(根據引數生成形狀) | PolyhedronGeometry(多面體) | RingGeometry(環形) | ShapeGeometry(二維形狀) |
SphereGeometry(球體) | TetrahedronGeometry(四面體) | TorusGeometry(圓環體) | TorusKnotGeometry(換面紐結體) |
TubeGeometry(管道) | \ | \ | \ |
剩餘的 TextGeometry、EdgesGeometry、WireframeGeometry、ExtrudeGeometry 我們單獨拿出來解釋:
/ | TextGeometry | / |
---|---|---|
EdgesGeometry | WireframeGeometry | ExtrudeGeometry |
如案例所示,EdgesGeometry 和 WireframeGeometry 更多地可能作為輔助功能去檢視幾何體的邊和線框(三角形圖元)。
ExtrudeGeometry 則是按照指定引數將一個二維圖形沿 z 軸拉伸出一個三維圖形。
TextGeometry 則需要從外部載入特定格式的字型檔案(可在 typeface.js 網站上進行轉換)進行渲染,其內部依然使用 ExtrudeGeometry 對字型進行拉伸,從而形成三維字型。另外,該類字型的本質是一系列類似 SVG 的指令。所以,字型越簡單(如直線越多),就越容易被正確渲染。
以上就是目前 Three.js 提供的幾何體,當然,這些幾何體的形狀也不僅於此,通過改變引數即能生成更多種類的形狀,如 THREE.CircleGeometry
可生成扇形。
另外,通過 console.log
檢視任意一個 geometry
物件可發現,在 Three.js 中的幾何體基本上是三維空間中的點集(即頂點)和這些頂點連線起來的面組成的。以立方體為例(widthSegments、heightSegments、depthSegments 均為 1 時):
- 一個立方體有 8 個頂點,每個頂點通過 x、y 和 z 座標來定義。
- 一個立方體有 6 個面,而每個面都包含兩個由 3 個頂點組成的三角形。
對於 Three.js 提供的幾何體,我們不需要自己定義這些幾何體的頂點和麵,只需提供 API 指定的引數即可(如長方體的長寬高)。當然,你仍然可以通過定義頂點和麵來建立自定義的幾何體。如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
var vertices = [ new THREE.Vector3(1, 3, 1), new THREE.Vector3(1, 3, -1), new THREE.Vector3(1, -1, 1), new THREE.Vector3(1, -1, -1), new THREE.Vector3(-1, 3, -1), new THREE.Vector3(-1, 3, 1), new THREE.Vector3(-1, -1, -1), new THREE.Vector3(-1, -1, 1) ] var faces = [ new THREE.Face3(0, 2, 1), new THREE.Face3(2, 3, 1), new THREE.Face3(4, 6, 5), new THREE.Face3(6, 7, 5), new THREE.Face3(4, 5, 1), new THREE.Face3(5, 0, 1), new THREE.Face3(7, 6, 2), new THREE.Face3(6, 3, 2), new THREE.Face3(5, 7, 0), new THREE.Face3(7, 2, 0), new THREE.Face3(1, 3, 4), new THREE.Face3(3, 6, 4) ] var geometry = new THREE.Geometry() geometry.vertices = vertices geometry.faces = faces geomtry.computeFaceNormals() |
上述程式碼需要注意的點有:
- 建立面時頂點的順序,因為頂點順序決定了某個面是面向攝像機還是背向攝像機。頂點的順序是逆時針則是面向攝像機,反之則是背向攝像機。
- 出於效能的考慮,Three.js 認為幾何體在整個生命週期都不會更改。若出現更改(如某頂點的位置),則需要告訴 geometry 物件的頂點需要更新
geometry.verticesNeedUpdate = true
。更多關於需要主動設定變數來開啟更新的事項,可檢視官方文件的 How to update things。
聲音
我們從文件目錄中竟然發現有 Audio
音訊物件,為什麼 Three.js 不是遊戲引擎,卻帶個音訊元件呢?原來這個音訊也是 3D 的,它會受到攝像機的距離影響:
- 聲源離攝像機的距離決定著聲音的大小。
- 聲源在攝像機左右側的位置分別決定著左右揚聲器聲音的大小。
我們可以到 官方案例 親自體驗一下 Audio
的效果。
常見的外掛
在 Three.js 的官方案例中,你幾乎都能看到左右上角的兩個常駐控制元件,它們分別是:JavaScript 效能監測器 stats.js 和視覺化調參外掛 dat.GUI。
stats.js
stats.js 為開發者提供了易用的效能監測功能,它目前支援四種模式:
- 幀率
- 每幀的渲染時間
- 記憶體佔用量
- 使用者自定義
dat.GUI
dat.GUI 為開發者提供了視覺化調參的皮膚,對引數調整的操作提供了極大的便利。
關於這兩個外掛的使用,請檢視他們的官方文件或 Three.js 官方案例中的程式碼。
其他一些東西
自適應螢幕(視窗)大小
1 2 3 4 5 6 7 8 9 |
window.addEventListener('resize', onResize, false) function onResize () { // 設定透視攝像機的長寬比 camera.aspect = window.innerWidth / window.innerHeight // 攝像機的 position 和 target 是自動更新的,而 fov、aspect、near、far 的修改則需要重新計算投影矩陣(projection matrix) camera.updateProjectionMatrix() // 設定渲染器輸出的 canvas 的大小 renderer.setSize(window.innerWidth, window.innerHeight) } |
陰影
陰影是增強三維場景效果的重要因素,但 Three.js 出於效能考慮,預設關閉陰影。下面我們來看看如何開啟陰影的。
- 渲染器啟用陰影
1renderer.shadowMap.enabled = true - 指定哪個光源能產生陰影
12// 並不是所有型別的光源能產生投影,不能產生投影的光源有:環境光(AmbientLight)、半球光(HemisphereLight)spotLight.castShadow = true - 指定哪個物體能投射陰影,哪個物體能接受陰影(在 CSS 中,我們都會認為只有背景接受陰影,畢竟它們都是平面)
12345// 平面和立方體都能接受陰影plane.receiveShadow = truecube.receiveShadow = true// 球體的陰影可以投射到平面和球體上sphere.castShadow = tru - 更改陰影質量
產生陰影:
1 2 3 4 5 |
// 更改渲染器的投影型別,預設值是 THREE.PCFShadowMap renderer.shadowMap.type = THREE.PCFSoftShadowMap // 更改光源的陰影質量,預設值是 512 spotLight.shadow.mapSize.width = 1024 spotLight.shadow.mapSize.height = 1024 |
霧化效果
霧化效果是指:場景中的物體離攝像機越遠就會變得越模糊。
目前,Three.js 提供兩種霧化效果:
1 2 3 4 5 6 7 8 9 10 11 |
// Fog( hex, near, far ),線性霧化。 // near 表示哪裡開始應用霧化效果(攝像機為 0) // far 表示哪裡的霧化濃度為 1。若某物體在該距離後,則其表現為霧的顏色。當霧的顏色和渲染器的背景色相同時,則表現為消失(實為顏色相同)。 scene.fog = new THREE.Fog( 0xffffff, 0.015, 100 ) // FogExp2( hex, density ),指數霧化 // density 是霧化強度 scene.fog = new THREE.FogExp2( 0xffffff, 0.01 ) // 霧化效果預設是全域性影響的,若某個材質不受霧化效果影響,則可為材質的 fog 屬性設定為 false(預設值 true) var material = new THREE.Material({ fog: false }) |
檢視不同位置的立方體:
Low Poly
其實,對於前端開發來說,能做到用程式碼實現就要儘量不用外部載入的圖片(紋理)來裝飾物體就最好了。對於前面提及的幾何體,其實只要發揮我們的創意,就能將不起眼的它們變得有魅力,如 Low Poly。
聖誕樹:
更多關於 Low Poly 風格的案例和學習資料:
- 聖誕樹:https://www.august.com.au/blog/animating-scenes-with-webgl-three-js/
- 飛行者(The Aviator)小遊戲:https://tympanus.net/codrops/2016/04/26/the-aviator-animating-basic-3d-scene-threejs/
- Yakudoo’s Codepen:https://codepen.io/Yakudoo/
渲染器剔除模式(Face culling)
CSS3 有一個 backface-visibility
屬性,它指定當元素背面朝向使用者時,該元素是否可見。因為元素背面的背景顏色是透明的,所以當其可見時,就會顯示元素正面的映象。
而在 Three.js 中,材質預設只應用在正面(THREE.FrontSide),即當你旋轉物體(或攝像機)檢視物體的背面時,它會因為未被應用材質而變得透明(即效果與 CSS3 backface-visibility: hidden 一樣)。因此,當你想讓物體正反兩面均應用材質,則需要在建立材質時宣告 side 屬性為 THREE.DoubleSide
:
1 2 3 |
var material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide // 其他值:THREE.FrontSide(預設值)、THREE.BackSide }) |
當然,為幾何體正反兩面均應用材質時,會讓渲染器做更多工作,從而影響效能。同理,對於 CSS3,若對動畫效能有更高的追求,則可以嘗試顯示地為 transform
動畫元素設定其背面不可見 backface-visibility: hidden;
,這樣也許能提高效能。
可你是否見過或想到過這樣的一個應用場景:
當你旋轉時,面向使用者的牆都會變得透明,從而實現 360 度檢視房子內部結構的效果。
剔除外部立方體正面:
上述案例會實時剔除外層立方體的正面,從而保證其內部可見。
這裡其實涉及到 OpenGL 的 Face culling 的知識點。出於效能的考慮,Three.js 預設開啟 Face culling 特性,且將剔除模式設定為 CullFaceBack
(預設值),這樣就可剔除對於觀察者不可見的反面 。
因此,當我們將剔除模式設定為 CullFaceFront
(剔除正面) 時,就會發生以上效果。一切看起來都是這麼自然。其實仔細想想,就會發現有點不對勁。
- 假設一個面由正面和反面組成,那現在只剔除正面,那該面的反面不就顯示出來了?
答:其實正面還是反面是相對於觀察者的,而不是說一個面由正面和反面組成。當然你也可以認為一個面是無限扁的,由正反兩面組成,但只有面向觀察者的一面才可見。 - 那現在被顯示出來的面都是反面(相對於觀察者),而這些反面並沒有應用材質(
side: THREE.BackSide
或THREE.DoubleSide
),那它不應該也是不可見的嗎?
答:筆者反覆試驗和查閱資料後,仍然沒得出答案,若你知道原因麻煩告訴我哦。
關於 OpenGL 的 Face culling 更多知識,可閱讀:《Learn OpenGL》。
粒子化
對於粒子化效果,相信大家都不陌生。前段時間的 《騰訊的 UP2017》 就是應用 Three.js 實現粒子化效果的精彩案例。
對於 Three.js,實現粒子效果的方法有兩種:THREE.Sprite( material )
和 THREE.Points( geometry, material )
。而且這兩者都會一直面向攝像機(無論你旋轉攝像機還是設定粒子的 rotation
屬性)。
下面基於 THREE.Sprite
實現一個簡單的 10 x 10 粒子效果(可拖拽旋轉):
當粒子數量較小時,一般不會存在效能問題。但隨著數量的增長,就會很快遇到效能瓶頸。此時,使用 THREE.Points
更為合適。因為 Three.js 不在需要管理大量 THREE.Sprite
物件,而只需管理一個 THREE.Points
物件。
下面我們用 THREE.Points
實現上一個案例的效果:
從上述兩個案例可看到,粒子預設形狀是正方形。若想改變它的形狀,則需要用到紋理。樣式化粒子的紋理一般有兩種方式:載入外部圖片和 Canvas 2D 畫布。
Canvas 2D 畫布:
載入外部圖片:
上一個案例中,我們載入了兩個不同的紋理。由於 THREE.Points
的侷限性(一個材質只能對應一種紋理),若想新增多個紋理,則需要建立相應個數的 THREE.Points
例項,而 THREE.Sprite
在此方面顯得更靈活一些。
上述粒子效果都是我們手動設定各個粒子的具體位置,若想將特定形狀通過粒子效果顯示,則可以直接將該幾何體(geometry)傳入 THREE.Points( geometry, material )
的第一個引數即可。
點選物體
滑鼠作為 PC 端(移動端中的觸控)的主要互動方式,我們經常會通過它來選擇頁面上的元素。而對於 Three.js,它沒有類似 DOM 的層級關係,並且處於三維環境中,那麼我們則需要通過以下方式來判斷某物件是否被選中。
1 2 3 4 5 6 7 8 9 10 11 12 |
function onDocumentMouseDown(event) { var vector = new THREE.Vector3(( event.clientX / window.innerWidth ) * 2 - 1, -( event.clientY / window.innerHeight ) * 2 + 1, 0.5); vector = vector.unproject(camera); var raycaster = new THREE.Raycaster(camera.position, vector.sub(camera.position).normalize()); var intersects = raycaster.intersectObjects([sphere, cylinder, cube]); if (intersects.length > 0) { console.log(intersects[0]); intersects[0].object.material.transparent = true; intersects[0].object.material.opacity = 0.1; } } |
當點選滑鼠時,上述程式碼會發生以下處理:
- 基於螢幕上的點選位置建立一個 THREE.Vector3 向量。
- 使用 vector.unproject 方法將螢幕上的點選位置轉換成 Three.js 場景中的座標。換句話說,就是將螢幕座標轉換成三維場景中的座標。
- 建立 THREE.Raycaster。使用 THREE.Raycaster 可以向場景中發射光線。在下述案例中,從攝像機的位置(camera.position)向場景中滑鼠的點選位置發射光線。
- 使用 raycaster.intersectObjects 方法來判斷指定的物件中哪些被該光線照射到的。
上述最後一步會返回包含了所有被光線照射到的物件資訊的陣列(根據距離攝像機距離,由短到長排序)。陣列的子項的資訊包括有:
1 2 3 4 5 |
distance: 49.90470 face: THREE.Face3 faceIndex: 4 object: THREE.Mesh point: THREE.Vector3 |
點選物體後改變其透明度:
最後
最後,亂七八糟地整理了自己最近學 Three.js 的相關知識,其中難免出現一些自己理解不透徹,甚至是錯誤的觀點,希望大家能積極提出來。當然,筆者也會捉緊學習,不斷完善文章。希望大家多多關注 凹凸實驗室。感謝~?