在開始介紹Three.js之前我們先來看一些用Three.js開發的網站:
看完以上範例我們可以知道Three.js應該是幫助我們達成一些酷炫的3D效果,但實際上他到底做了什麼呢?
在正式開始說明Three.js之前先讓我們了解一些電腦渲染3D相關的基本知識。
大家如果有在玩遊戲可能都知道越好的畫質往往需要越好的顯示卡才跑得動,而3D渲染為何依賴顯示卡而非CPU呢?
3D渲染需要大量的運算及圖形處理,CPU雖然效能很強,但也僅能一次處理一件事,而GPU是專門設計來處理這些運算及圖形處理,他通常有幾百幾千個以上的處理單元,平行運算的能力大於CPU的多核心,並且在硬體及驅動程式上都有很多優化。
而現今顯示卡渲染大多是使用三角形渲染,簡易的流程可以歸納為:
我們已經知道達成3D渲染需要跟顯卡打交道,但要寫底層的code去控制顯卡是很困難的,因此出現了封裝後的圖形API,讓開發者們能比較輕鬆的使用原生3D渲染。 以下是主流的圖形API:
網頁上的3D渲染發展比較緩慢,早期網頁上無法進行3D渲染因此需安裝Flash,後來Khronos提出了WebGL
,靠著瀏覽器內核對OpenGL ES
(OpenGL for Embedded Systems)做打包及封裝,讓js開發者能調用圖形api。
note: 因為WebGL綁定了OpenGL,無法因應後起的dx, metal, vulkan,因此後來出現了
WebGPU
,一個新一代的web 3D標準。
(GL = Graphics Library)
WebGL允許開發者使用JavaScript編寫3D圖形應用,這些應用可以直接在支援WebGL的瀏覽器上執行,而不需要任何額外的插件或擴展。
WebGL使用HTML5的canvas
元素作為渲染目標,開發者可以將3D圖形渲染到canvas上,並通過JavaScript控制圖形的行為和互動。WebGL支援各種圖形功能,包括頂點和片元著色器(vertex shader和fragment shader)、紋理映射、光照、深度測試等,使開發者能夠創建出逼真的3D場景和效果。
以下是一段使用原生js取得webGL對象的code:
const canvas = document.getElementById("canvas")
const gl = canvas.getContext("webgl")
if (!gl) {
alert("Your browser does not support WebGL")
} else {
console.log(gl)
}
Three.js是3D渲染庫,想直接使用WebGL還是相對困難的,並且需要寫大量的code(光是生成一個簡單的三角形就需要百行左右的code),因此我們需要更深一層的封裝,而three.js幫我們完成了這件事。
介紹完了web 3D的發展,接下來就進入coding的階段吧!
首先讓我們安裝Three.js。
npm install --save three
由Scene
, Camera
, Renderer
這三個核心Class組成。
想像我們在攝影棚拍室內設計的照片,Scene就是這個攝影棚,而我們可以在攝影棚中加入桌椅、燈光等物件,接著設定好相機(Camera)的角度及參數按下快門,並透過Renderer幫我們把成像渲染在canvas上面。
import * as THREE from "three"
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
}
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
75, // FOV(Field of View) 指的是上下垂直角度
viewport.width / viewport.height, // aspect ratio
0.1, // near 小於這個距離的物件將不可見
1000 // far 大於這個距離的物件將不可見
)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(viewport.width, viewport.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
document.body.appendChild(renderer.domElement) // canvas
renderer.render(scene, camera)
以上範例我們用window.innerWidth
及window.innerHeight
達成全螢幕,並且把pixel ratio限制在2,因為有少數裝置的pixel ratio大於2,會造成效能的浪費,再者太高的pixel ratio人眼也很難看出差別。
我們可以在場景中加入幾何物件甚至是3D模型,一個基本的物件(Mesh)是由形狀(Geometry)及材質(Material)構成。
Mesh繼承自Object3D,有position
, scale
及lookAt
等好用的屬性及方法可以使用。
// other code...
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial()
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
// 在renderer.render(scene, camera)之前加入以上的code
此時我們的camera及box都處於整個場景的原點,因此畫面上什麼都看不到,這時我們把camera往後移動一點就能看到新增的方塊了。
這邊需注意Three.js的xyz軸所代表的方向如圖,因此往後移動代表的是往正z軸方向移動。
// 需注意目前的改動都需加在renderer.render()之前
// 可以想像一下我們擺好動作之後再拍照,而不是按下快門後才開始擺pose
// 而renderer.render()就像是這個拍照的動作
camera.position.z = 5
至此我們的畫面應該會長這樣
可以看到畫面中出現了一個白色方框,實際上這是一個正立方體,由於我們目前的視角是從該方塊的正前方觀看,所以乍看之下只是一個2D平面,我們可以稍微轉動一下方塊來驗證他確實是3D的。
// rotation的值是three.js的Euler這個Class,在這Math.PI代表180度
// 改變rotation.y指的是以y軸為軸心旋轉
mesh.rotation.y = 1
有沒有稍微看得出3D的感覺了呢?這邊由於我們的material沒有給任何參數,因此預設是白色的。
Geometry決定3D物件的形狀,由頂點及面(頂點連接而成的三角形)組成,可用於形成mesh或particle。
Three.js有非常多好用的內建geometries可以使用,這邊我們只挑幾個來展示,不同geometry的constructor可能吃不同的參數,詳情可以去官網看demo。
上面示範的code中我們已經使用過BoxGeometry,他可以形成方塊的形狀,constructor前三個參數分別是寬、高、深的尺寸,後三個參數別是寬、高、深的segments
(上述的值預設都是1)。
Plane是平面的意思,這個Class吃的四個參數分別是寬、高、寬segments、高segment。
Segments指的是分割的數量,下圖我特意把wireframe打開來觀察geometry中vertex及triangle的組成,左圖寬高segment皆是1,右圖寬高segment均為2,可以發現寬高都被劃分成了2等份,其中vertex及triangle的數量也隨之提高。
Vertex的位置是可以被改變的,假設我們想要移動某些vertex讓這個平面變得有凹凸不平的效果,我們就會希望有夠多的vertex讓我們移動,因此segments的數量要多少其實是看使用場景,並不是越多越好,因為越多segments同時也意味著GPU有更多的三角形需要渲染,會對效能產生負面的影響。
還有非常多geometry這邊就不一一列舉了,譬如說SphereGeometry能形成球體而TorusGeometry能形成圓環狀。Three.js有一個優點就是官網的demo做得蠻詳細的,建議大家可以在官方Geometry文件瀏覽看看,上面除了有live demo外還可以直接tweak parameters,能幫助我們更快的了解參數的作用。
Material用於為Geometry的每個可見像素添加顏色,其中的算法是用稱為著色器(shader)的程序編寫的,撰寫的語言是GLSL
。而我們也可以不自己寫shaders,直接使用three.js內建的materials。
這邊挑幾個常用的materials來介紹:
我們在create mesh範例使用過MeshBasicMaterial,這個材質呈現的是一種死板扁平的顏色,且不受光線的影響,該材質支援不少常用的基本屬性:
const colorTexture = new THREE.TextureLoader().load('/color.jpg')
const material = new THREE.MeshBasicMaterial({
color: 0x0000ff, // 在constructor中color可以傳字串或hex color如"blue"或"#0000ff",其值會自動被轉為THREE.Color
map: colorTexture, // 可以把圖檔當作是貼圖材質
wireframe: true, // 開啟後將呈現線框模式
transparent: true,
opacity: 0.5, // 若要呈現透明度需如上把transparent打開
side: THREE.DoubleSide // 讓mesh正反面均可見
})
MeshNormalMaterial會把材質呈現為法向量對應的rgb值。在3D空間中,法向量是指垂直於曲面或平面的向量,對於平面來說,法向量是平面上任意一點指向該平面的垂直向量。法向量是渲染和照明運算的重要概念,例如該方向指向光線代表是亮面,反之則是暗面。
該材質可用來debug mesh的光線處理,由於該材質以rgb的混合顏色呈現,看起來酷酷的,因此直接拿來做展示也不錯。
const material = new THREE.MeshNormalMaterial({ flatShading: true })
下圖左是預設flatShading=false的狀態,右圖是打開的狀態。
MeshMatcapMaterial可以在沒有真實光線的場景下,透過讀取matcap texture來達到模擬光線的效果,可以在這個repo中查看一些範例。
const matcapTexture = new THREE.TextureLoader().load('/matcap.png')
const material = new THREE.MeshMatcapMaterial({
matcap: matcapTexture,
});
MeshStandardMaterial是基於物理的渲染(physically based material
, PBR
)的材質,只要運用得當就能呈現出很貼近現實的效果。
這個材質跟上面示範的幾個材質不同,需要有真實的光源才可見,這邊我們先透過內建的Light Class增加一組具有方向性的光源到場景中。
const light = new THREE.DirectionalLight()
light.position.set(5, 5, 5)
scene.add(light)
MeshStandardMaterial除了能控制roughness
及metalness
來改變光線的折射與反射,還能透過各種map來達到更細節、更逼真的設定。
注意到上圖材質表面的紋理及光線效果了嗎?我用了MeshStandardMaterial加上一些textures就能實現這樣的效果,這就是PBR materials的優點,只要google搜尋free pbr material就能找到許多高品質的資源包可以使用喔。
接下來就讓我們來看看圖中的材質是怎麼設定的:
const textureLoader = new THREE.TextureLoader()
textureLoader.setPath("/textures/space-sheep")
const colorTexture = textureLoader.load("/albedo.jpg")
const metalnessTexture = textureLoader.load("/metalness.jpg")
const roughnessTexture = textureLoader.load("/roughness.jpg")
const normalTexture = textureLoader.load("/normal.jpg")
const heightTexture = textureLoader.load("/height.jpg")
const aoTexture = textureLoader.load("/ao.jpg")
const geometry = new THREE.TorusGeometry(2, 0.4, 64, 128)
const material = new THREE.MeshStandardMaterial({
map: colorTexture,
metalnessMap: metalnessTexture,
roughnessMap: roughnessTexture,
aoMap: aoTexture,
normalMap: normalTexture,
displacementMap: heightTexture,
displacementScale: 0.8
})
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
以上我們用到了map
, metalnessMap
, roughnessMap
, aoMap
, normalMap
, displacementMap
這幾個屬性,由於MeshStandardMaterial蠻重要的所以我會一一對這些屬性做介紹:
map
此範例中我使用的map texture如下,其實map的作用很簡單,就是把texture上對應的顏色apply到material上。
metalnessMap & roughnessMap
金屬度跟粗糙度通常是一起搭配使用,我們可以用metalness
及roughness
屬性來改變整個material的質感,也可以用map來為圖中不同區域賦予不同的值。
下圖是此範例中使用的roughnessMap texture。
aoMap
ao的全名是ambient occlusion,指的是環境光遮蔽,aoMap能為材質帶來更細部的陰影效果,可以透過aoMapIntensity
來改變影響的強度。
normalMap
normalMap能為圖片不同區域賦予不同的法向量來強化材質的細節,可以透過normalScale
去控制強度。
displacementMap
displacement map也可稱作height map,他可以影響mesh中各個vertex的位置,圖中白色區域是凸起的區域,而深色則是凹陷區,他能真的讓物體產生立體的效果。可以透過displacementScale
來改變影響的強度。
另外還有alphaMap
這邊沒使用到,他可以對圖片的特定區域增加透明度,記得要使用透明度的話需要同時把transparent
屬性打開喔。
我們會使用window.requestAnimationFrame
來製作動畫,該方法接收一個function,並在螢幕的下一禎
執行該function。
還記得我把renderer.render()
比喻為按下快門拍照嗎?想像拍照->移動物品->拍照->移動物品...這樣的循環來構成幻燈片動畫,當我們在螢幕更新的每一幀都產生一點移動,看起來就會是一個順暢的動畫了,因此我們需要在每一幀都去執行一次render。
function animate() {
renderer.render( scene, camera )
mesh.rotation.x += 0.01
requestAnimationFrame( animate )
}
animate()
通常我們會將此function命名為animate
、update
或tick
,我們可以試著在function內改變物體的屬性例如position
及rotation
。
上面mesh.rotation.x += 0.01
的寫法其實會產生一個問題,用144hz螢幕的人看到的轉速會比60hz的快,若要解決這個問題我們可以使用delta time
或是elapsed time
。
let time = Date.now()
function animate() {
renderer.render( scene, camera )
const currentTime = Date.now()
const deltaTime = currentTime - time
time = currentTime
console.log(deltaTime)
mesh.rotation.y += deltaTime * 0.002
requestAnimationFrame( animate )
}
animate()
以我自己的裝置來說,我console出來的數字會在16上下浮動,因為我的裝置是60hz更新率,意味著每1000毫秒執行60次function,1000除以60就是16.666...。
const clock = new THREE.Clock()
function animate() {
renderer.render( scene, camera )
const elapsedTime = clock.getElapsedTime()
console.log(elapsedTime)
mesh.rotation.y = elapsedTime
requestAnimationFrame( animate )
}
animate()
這邊的elapsedTime
代表經過時間的累積,單位是秒。
具體要使用哪個就看你的使用場景了,這邊可以注意一下在上面的範例中delta time的rotation我是用累加的,而elapsed time是直接使用等於。
透過簡單的操作mesh及camera,以及運用一些三角函數就能做出不錯的視覺效果。
const clock = new THREE.Clock()
function animate() {
renderer.render(scene, camera)
const elapseTime = clock.getElapsedTime()
mesh.rotation.y = Math.sin(elapseTime) * 0.3
mesh.rotation.x = Math.cos(elapseTime) * 0.3
camera.position.x = Math.sin(elapseTime) * 5
camera.lookAt(mesh.position)
requestAnimationFrame(animate)
}
animate()
大家也可以嘗試把動畫融入到網頁內容中,透過scroll event去移動camera之類的,相信一定可以做出不錯的作品!
Three.js還有非常多進階的內容可以探討,譬如說3D模型的運用、客製化shader、效能調校等等。我認為three.js中有一個很重要的元素就是使用GLSL
寫自己的shader
,這部分如果有機會我再來寫篇文章講講吧~