3D 物理世界 - Three.js 與 Cannon.js 介紹與使用
在日常的繁忙業務中,專案更多會偏向於 2D 或 2.5D 的視覺風格,因為 2D 專案無論在設計或是開發的週期都會更短,但 2D 缺少 3D 那樣的空間感、真實感,正好最近參與了 3D 專案的開發,過程中也遇到了不少問題,通過這篇文章將關於 Three.js、Cannon.js、模型、工具等基礎知識和問題分享給大家。
開始 3D 專案之前,首先從選擇 3D 框架開始,老牌引擎 Three.js 和微軟的 Babylon.js 都不錯,針對自己的專案需求選擇一款即可,這次我主要針對更熟悉的 Three.js 來講。
Three.js 基礎概念
使用 Three.js 前,首先要理解以下幾個核心概念:
Sence 場景
在 Three.js 中首先需要建立一個三維空間,我們稱之為場景。
場景可以想象成是一個容器,裡面存放著所有渲染的物體和使用的光源。
let scene = new THREE.Scene()
Axes 座標軸
Three.js 採用的是右手座標系,拇指、食指、中指分別表示 X、Y、Z 軸的方向。
Camera 攝像機
攝像機就相當於我們的雙眼,決定了能夠在場景中的所見所得。
Three.js 中提供以下幾種攝像機型別,最為常用的是 PerspectiveCamera 透視攝像機 ,其他了解下即可。
- ArrayCamera 陣列攝像機
- 一個 ArrayCamera 會包含多個子攝像機,通過這一組子攝像機渲染出實際效果,適用於 VR 場景。
- CubeCamera 立方攝像機
- 建立六個 PerspectiveCamera(透視攝像機),適用於鏡面場景。
- StereoCamera 立體相機
- 雙透視攝像機適用於 3D 影片、視差效果。
OrthographicCamera 正交攝像機
OrthographicCamera(正交攝像機)定義了一個矩形可視區域,物體只有在這個區域內才是可見的,另外物體無論距離攝像機是遠或事近,物體都會被渲染成一個大小,所以這種攝像機型別適用於 2.5D 場景(例如斜 45 度遊戲)。
PerspectiveCamera 透視攝像機
最為常用的攝像機型別,模擬人眼的視覺,根據物體距離攝像機的距離,近大遠小。預設情況下,攝像機的初始位置 X、Y、Z 都為 0,攝像機方向是從正 Z 軸向負 Z 軸看去。通過 Near
和 Far
定義最近和最遠的可視距離, Fov
定義可視的角度。
Mesh 網格
有了場景和攝像頭就可以看到 3D 場景中的物體,場景中的我們最為常用的物體稱為網格。
網格由兩部分組成:幾何體和材質
Geometry 幾何體
記錄了渲染一個 3D 物體所需要的基本資料:Face 面、Vertex 頂點等資訊。
例如下面這個網格是由三角形組成,組成三角形的點稱為頂點,組成的三角形稱為面。
Material 材質
材質就像是物體的面板,決定了幾何體的外表。
外表的定義可以讓一個物體看起來是否有鏡面金屬感、暗淡、純色、或是透明與否等效果。
Light 光源
光源相當於在密閉空間裡的一盞燈,對於場景是必不可少的
在 Three.js 常用的有這幾種光源:
AmbientLight 環境光源
屬於基礎光源,為場景中的所有物體提供一個基礎亮度。
DirectionalLight 平行光源
效果類似太陽光,發出的光源都是平行的。
HemisphereLight 半球光源
只有圓球的半邊會發出光源。
PointLight 點光源
一個點向四周發出光源,一般用於燈泡。
SpotLight 聚光燈光源
一個圓錐體的燈光。
Shadow 陰影
另外要注意並不是每一種光源都能產生陰影,目前只有三種光源可以:
- DirectionalLight 平行光源
- PointLight 點光源
- SpotLight 聚光燈光源
另外如果要開啟模型的陰影的話,模型是由多個 Mesh 組成的,只開啟父的 Mesh 的陰影是不行的,還需要遍歷父 Mesh 下所有的子 Mesh 為其開啟投射陰影 castShadow
和接收投射陰影 receiveShadow
。
// 遍歷子 Mesh 開啟陰影 object.traverse(function(child){ if (child instanceof THREE.Mesh) { child.castShadow = true child.receiveShadow = true } })
glTF 模型格式
前面提到 Three.js 引擎支援的格式非常的多,我們最為常見的格式有 .obj
+ .mtl
+ .jpg/.png
,但使用這種模型格式存在一個問題, .obj
是靜態模型,不支援動畫資料儲存,無法使用模型的動畫,所以我建議使用 glTF 這種模型格式。
glTF 模型格式介紹
傳統的 3D 模型格式的設計理念更多是針對本地離線使用,所以這類 3D 模型格式沒有針對下載速度或載入速度進行優化,檔案大小往往會非常的大,隨著 Web 端的興起,對檔案大小更為敏感的今天,我們該嘗試別的模型格式了。
glTF 是由 Khronos Group 開發的 3D 模型檔案格式,該格式的特點是最大程度的減少了 3D 模型檔案的大小,提高了傳輸、載入以及解析 3D 模型檔案的效率,並且它可擴充套件,可互操作。
第一版 glTF 1.0 於 2015 年 10 月 19 日釋出,2017 年 6 月 5 日的 Web 3D 2017 大會發布了最終版本 glTF 2.0。
glTF 模型格式檔案組成
模型檔案 .gltf
包含場景中節點層次結構、攝像機、網格、材質以及動畫等描述資訊。
二進位制檔案 .bin
包含幾何、動畫的資料以及其他基於緩衝區的資料, .bin
檔案可以直接載入到 GPU 的緩衝區中從而不需要額外的解析,因此能夠高效傳輸和快速載入。
材質貼圖檔案 .png/.jpg
3D 模型做凹凸貼圖或普通貼圖上所使用到檔案。
glTF 模型格式匯出
官方在 .gltf
格式匯出上提供了多種建模軟體的匯出外掛,比如有:
- 3DS Max Exporter
- Maya Exporter
- Blender glTF 2.0 Exporter
正巧我們常用的 C4D 建模軟體官方沒有提供 C4D 的匯出外掛,所以我們使用 C4D 匯出後再匯入 Blender,通過 Blender 作為中轉站匯出 glTF 格式檔案。
但由於兩個建模軟體之間的材質並不能相通,匯出後的模型檔案材質效果表現不佳,這是因為 Blender 有自己的一套材質流程系統,例如有 glTF Metallic Roughness
和 glTF Specular Glossiness
,需在此基礎之上重新貼材質後匯出解決。
另外注意的一點 Blender 的座標系與 Three.js 是不同的,Blender 會將 Z 和 Y 對調位置,在匯出時要選擇 Convert Z up to Y up
進行對調。
Three.js 使用 glTF 模型
Three.js 中使用 glTF 格式需額外引入 ofollow,noindex">GLTFLoader.js 載入器。
var gltfLoader = new THREE.gltfLoader() gltfLoader.load('./assets/box.gltf', function(sence){ var object = scene.gltf // 模型物件 scene.add(object) // 將模型新增到場景中 })
glTF 模型動畫
Animation Clip 動畫片段
前面提到 glTF 模型格式支援動畫,模型動畫可以使用 Blender 建模軟體製作,通過 Blender 提供的時間軸編輯變形動畫或者骨骼動畫,每個動畫可以編輯為一個 Action 動作,匯出後使用 GLTFLoader 載入到 Three.js 中,可以拿到一個 animations
陣列, animations
裡包含了模型的每個動畫 Action 動作。
let gltfLoader = new THREE.gltfLoader() let mixer = null gltfLoader.load('./assets/box.gltf', function(sence){ let object = scene.gltf let animations = sence.animations // 動畫資料 if (animations && animations.length) { mixer = new THREE.AnimationMixer(object) // 對動畫進行控制 for (let i = 0; i < animations.length; i++) { mixer.clipAction(animations[i]).play() // 播放所有動畫 } } scene.add(object) }) function update(){ let delta = clock.getDelta(mixer) mixer.update(delta) // 更新動畫片段 }
Tween 動畫
對模型實現淡入淡出、縮放、位移、旋轉等動畫推薦使用 GSAP 來實現更為簡便。
let tween = new TimelineMax() tween .to(box.scale, 1, { // 從 1 縮放至 2,花費 1 秒 x: 2, y: 2, z: 2, ease: Power0.easeInOut, // 速度曲線 onStart: function(){ // 監聽動畫開始 }, onUpdate: function(){ // 監聽動畫過程 }, onComplete: function(){ // 監聽動畫結束 } }) .to(box.position, 1, { // 縮放結束後,位移 x 至 10,花費 1 秒 x: 10, y: 0, z: 0 })
Draco 3D 模型壓縮工具
Draco 是一個用於壓縮、解壓縮 3D 幾何網格和 點雲 的開源庫,為改善 3D 圖形儲存和傳輸而設計。
使用該工具可以對 glTF 格式進一步的壓縮,會將 glTF 格式轉為 .glb
格式,並且 .bin
壓縮效果拔群,但是在 Three.js 中使用 .glb
格式需要引入額外的解析庫,解析庫檔案包括 draco_decoder.js
(791KB)、 draco_decoder.wasm
(323 KB)、 draco_wasm_wrapper.js
(64.3 KB)。所以更推薦當模型檔案數量多,且檔案較大時使用,否則得不償失。
壓縮使用 glTF Pipeline 工具,需要將三個種類的檔案放在一起,執行命令列進行轉換。
$ npm install -g gltf-pipeline // 安裝 gltf-pipeline 工具 $ gltf-pipeline -i model.gltf -o model.glb // 指定某個 .gltf 檔案轉為 .glb 格式
Three.js 使用 .glb
格式引入 Draco 解碼庫
// 例項化 loader let loader = new THREE.GLTFLoader() // Draco 解碼庫 THREE.DRACOLoader.setDecoderPath('/examples/js/libs/draco') loader.setDRACOLoader(new THREE.DRACOLoader()) // 載入 glTF 模型 loader.load('models/gltf/box.gltf', function(gltf){ scene.add(gltf.scene) })
Cannon.js 3D 物理引擎
目前在 Github 上搜索到的 3D 物理引擎庫有 Cannon.js、Oimo.js、Ammo.js、Energy.js、Physijs 等等,大部分都已許久沒有更新迭代了(長達好幾年),專案的 Star 數量和 Issues 數量也不多,我們該如何選擇?
Cannon.js、Oimo.js 和 Energy.js 作為 Babylon.js 的內建物理引擎,我們試著從這三個下手。
Energy.js :使用 C++ 編寫轉 JavaScript 的 3D 物理引擎,原始碼不可讀,目前 Github 比較冷清。
Oimo.js :一款輕量級的 3D 物理引擎,檔案大小 153 KB。
Cannon.js :完全使用 JavaScript 編寫的優秀 3D 物理引擎,包含簡單的碰撞檢測、各種形狀的摩擦力、彈力、約束等功能。
從綜合性來看,我更偏向於 Cannon.js ,所以下面主要講講 Cannon.js。
Cannon.js 的特性
以下是 Cannon.js 的特性,基本可以滿足大部分的 3D 物理開發場景。
使用 Cannon.js
我們以官方的一個平面加球剛體的例子來快速上手 Cannon.js。 線上例子
1、初始化物理世界
使用 Cannon.js 前需要建立 CANNON.World 物件,CANNON.World 物件是負責管理物件和模擬物理的中心。
建立完 CANNON.World 物件後,接著設定物理世界的重力,這裡設定了負 Y 軸為 10 m/s²。
let world = new CANNON.World() world.gravity.set(0, -10, 0)
Cannon.js 提供了 Broadphase、NaiveBroadphase 兩種碰撞檢測階段,預設是 NaiveBroadphase。
world.broadphase = new CANNON.NaiveBroadphase()
2、建立動態球體
建立 Body 分三個步驟:
- 建立形狀
- 為形狀新增剛體
- 將剛體新增到世界
let sphereShape = new CANNON.Sphere(1) // Step 1 let sphereBody = new CANNON.Body({ // Step 2 mass: 5, position: new CANNON.Vec3(0, 10, 0), shape: sphereShape }) world.add(sphereBody) // Step 3
第一步建立了半徑為 1 的球形,第二步建立球的剛體,如果剛體的 mass 屬性設定為 0,剛體則會處於靜止狀態,靜止的物體不會和其他靜止的物體發生碰撞,我們這裡為球的剛體設定了 5kg,球會處於動態的狀態,會受的重力的影響而移動,會與其他物體發生碰撞。
3、建立靜態平面和動態球體
// 平面 Body let groundShape = new CANNON.Plane() let groundBody = new CANNON.Body({ mass: 0, shape: groundShape }) // setFromAxisAngle 旋轉 X 軸 -90 度 groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -1.5707963267948966) world.add(groundBody)
建立平面形狀,接著是剛體,這裡設定了平面剛體的 mass 為 0,保證剛體處於靜止狀態。預設情況下平面的方向是朝向 Z 方向的(豎立著),可以通過 Body.quaternion.setFromAxisAngle
對平面進行旋轉。
4、建立平面和球的網格
前面建立的剛體在場景中並沒有實際的視覺效果,這一步建立平面、球的網格。
// 平面網格 let groundGeometry = new THREE.PlaneGeometry(20, 20, 32) let groundMaterial = new THREE.MeshStandardMaterial({ color: 0x7f7f7f, side: THREE.DoubleSide }) let ground = new THREE.Mesh(groundGeometry, groundMaterial) scene.add(ground) // 球網格 let sphereGeometry = new THREE.SphereGeometry(1, 32, 32) let sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xffff00 }) let sphere = new THREE.Mesh(sphereGeometry, sphereMaterial) scene.add(sphere)
5、模擬世界
接著我們為物理世界開啟持續更新,並且將建立的球剛體與球網格關聯起來。
function update(){ requestAnimationFrame(update) world.step(1 / 60) if (sphere) { sphere.position.copy(sphereBody.position) sphere.quaternion.copy(sphereBody.quaternion) } }
通過這幾步,一個簡單的物理場景就完成了,另外更多官方例子可以 點選這裡 ,可以檢視到 Cannon.js 各個約束、摩擦力、模擬汽車等特性的例子。
其他:
1、自定義物理材質需關聯
還是上面的例子,現在場景中剛體的物理特性都為預設的,我希望球的恢復係數高一點,即掉落時彈跳的更高。首先需要通過 CANNON.Material
例項物理材質,剛體使用該物理材質,最後通過 CANNON.ContactMaterial
來定義兩個剛體相遇後會發生什麼。
// 平面 let ground_cm = new CANNON.Material() // Step 1 : 例項 CANNON.Material let groundBody = new CANNON.Body({ ... material: groundMaterial // Step 2 : 使用該物理材質 ... }) // 球 let sphere_cm = new CANNON.Material() let sphereBody = new CANNON.Body({ ... material: sphere_cm ... }) let sphere_ground = new CANNON.ContactMaterial(ground_cm, sphere_cm, { // Step 3 : 定義兩個剛體相遇後會發生什麼 friction: 1, restitution: 0.4 }) world.addContactMaterial(sphere_ground) // Step 4 : 新增到世界中
2、剛體新增位移動畫時需取消速度值
比如我使用 GSAP 庫對某個剛體進行 Y 軸向上移動,在 update 階段需要將剛體的重力加速度設定為 0,否則動畫結束後剛體會出現向下砸的效果。
let tween = new TimelineMax() tween.to(boxBody.position, 2, { x: 0, y: 10, z: 0, update: function(){ // 歸 0 設定 boxBody.velocity.setZero() boxBody.initVelocity.setZero() boxBody.angularVelocity.setZero() boxBody.initAngularVelocity.setZero() } })
3、只檢測碰撞,不發生物理效果
允許只檢測是否碰撞,實際不發生物理效果,需要為剛體新增以下屬性:
boxBody.collisionResponse = false
4、縮放剛體
如果剛體需要縮放,則需要為剛體新增此屬性,來更新剛體大小。
boxBody.updateMassProperties() let tween = new TimelineMax() tween.to(sphereBody.shapes[0], 2, { radius: 0.2 // 縮放至 0.2 })
點選互動
在 3D 的世界中不能像我們在 DOM 中為一個節點繫結點選事件那麼容易,在 Three.js 中提供了 THREE.Raycaster
方法處理點選互動,使用滑鼠或者手指點選螢幕時,會將二維座標進行轉換,發射一條射線判斷與哪個物體發生了碰撞,由此得知點選了哪個物體。 點選這裡官方例子
let raycaster = new THREE.Raycaster() let mouse = new THREE.Vector2() function onTouchEnd(ev){ // 點選獲取螢幕座標 var event = ev.changedTouches[0] mouse.x = (event.clientX / window.innerWidth) * 2 - 1 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 raycaster.setFromCamera(mouse, camera) let intersects = raycaster.intersectObjects(scene, true) for (let i = 0; i < intersects.length; i++) { console.log(intersects[i]) // 與射線發生碰撞的物體 } }
效能方面
模型精細程度
在 Web 端由於效能的限制,在開發過程中要儘量避免做一些損耗效能較大的事情。
首先是模型的精細程度,在保證效果的前提下,儘量降低模型面的數量,也就是說採用低模模型,一些模型的凹凸褶皺感也可以通過凹凸貼圖的方式去實現,越是複雜的模型在實時渲染的過程中就越佔用手機效能。
光源與陰影
另外一方面光源、陰影也是佔效能,尤其是陰影。光源一般會使用平行光或者聚光燈,這種光源照射在物體上更為真實,使用半球光會稍微提升幀數,但效果略差些,陰影效果前面提到過要遍歷每一個子 Mesh 接收產生陰影 castShadow
和接收陰影 receiveShadow
,這相當耗費效能,開啟後對陰影的精細程度以及陰影型別進行引數優化,在 Android 系統性能不太好,iOS 系統基本能保證流暢執行,所以建議根據裝置系統優化。
var n = navigator.userAgent if (/iPad|iPhone|iPod/.test(n) && !window.MSStream) { } // 針對 iOS 系統使用陰影
抗鋸齒與畫素比
抗鋸齒是讓模型的邊緣效果更加圓滑不粗糙,也會佔用一些效能,預設是關閉的,視情況開啟。
renderer.antialias = true // 開啟抗鋸齒
另外畫素比 setPixelRatio
,移動端由於 Retina 屏的緣故,一般會設定為 2,所以使用 window.devicePixelRatio
獲取實際裝置畫素比動態設定的話,部分大屏手機的畫素比有 3 的情況,所有會因為畫素比過高造成效能問題。
renderer.setPixelRatio(2) // 推薦 renderer.setPixelRatio(window.devicePixelRatio) // 不推薦
工具推薦
最後推薦一些在開發過程中常用的工具:
OrbitControls 軌道控制器
OrbitControls 是用於除錯 Camera 的方法,例項化後可以通過滑鼠拖拽來旋轉 Camera 鏡頭的角度,滑鼠滾輪可以控制 Camera 鏡頭的遠近距離,旋轉和遠近都會基於場景的中心點,在除錯預覽則會輕鬆許多。
new THREE.OrbitControls(camera, renderer.domElement)
glTF Viewer 模型快速預覽工具
在設計師建模完成匯出後,設計師並不知道在 Three.js 最終會呈現一個什麼效果或者開發者也想快速的檢視模型是否存在問題,glTF 官方貼心的提供了一款快速預覽的工具,提供了兩個版本: Web 版本 和 Desktop 版本 。
將 .gltf
、 .bin
、 .jpg/.png
檔案拖拽到工具中,可以除錯預覽到模型的動畫、變形目標、背景、線框模式、自動旋轉、光源等功能。
Helper 相關除錯模式
Camera Helper 攝像機除錯模式
開啟 Camera Helper 除錯模式後,可以直觀的看到 Camera 的 Fov
、 Nera
、 Far
的引數效果。
let camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000) let helper = new THREE.CameraHelper(camera) scene.add(helper)
Light Helper 光源除錯模式
聚光燈開啟 Light Helper 除錯模式後,可以直觀的看到 distance
、 angle
的引數效果。
let light = new THREE.DirectionalLight(0xffffff) let helper = new THREE.DirectionalLightHelper(0xffffff) scene.add(helper)
AxesHelper 座標軸除錯模式
AxesHelper 是在場景的中心點,新增一個座標軸(紅色:X 軸、綠色:Y 軸、藍色:Z 軸),方便辨別方向。
let axesHelper = new THREE.AxesHelper(10) scene.add(axesHelper)
Cannon.js 3D 物理引擎除錯模式
Cannon.js 3D 物理引擎提供的除錯模式需引入 Debug renderer for Three.js ,可以將建立的物理盒子、球、平面等顯示線框,便於在使用過程中實時檢視效果。
let cannonDebugRenderer = new THREE.CannonDebugRenderer(scene, world) function render(){ requestAnimationFrame(render) cannonDebugRenderer.update() // Update the debug renderer }
dat.GUI 圖形使用者介面除錯工具
在開發過程中,常常需要對引數變數進行微調,針對這個 Three.js 提供了 dat.GUI ,dat.GUI 是一個輕量級的圖形使用者介面除錯工具,使用後在右上角會出現一個 GUI 視覺化引數配置區域,通過修改數值來實時檢視結果。
let opts = { x: 0, y: 0, scale: 1 } let gui = new dat.GUI() gui.add(opts, 'x', -3, 3) gui.add(opts, 'y', -3, 3) gui.add(opts, 'scale', 1, 3) function loop(){ cube.position.x = opt.x cube.position.y = opt.y cube.scale.set(opts.scale, opts.scale, opts.scale) requestAnimationFrame() }
Stats 除錯工具
Stats 工具 可以實時檢視:
FPS:最後一秒的幀數,越大越流暢
MS:渲染一幀需要的時間(毫秒),越低越好
MB:佔用的記憶體資訊
CUSTOM:自定義面板
var stats = new Stats() stats.showPanel(1) document.body.appendChild(stats.dom) function animate(){ requestAnimationFrame(animate) } requestAnimationFrame(animate)