作者:vivo 網際網路前端團隊- Wei Xing
運營活動新玩法層出不窮,web 3D炙手可熱,本文將一步步帶大家瞭解如何利用Three.js和Blender來打造一個沉浸式web 3D展覽館。
3D展覽館是什麼,先來預覽下效果:
看起來像個3D冒險類手遊,使用者可以操縱螢幕中央的虛擬搖桿,以第一人稱視角在房間內自由移動、看展覽。
首先介紹一個背景,我們的工作內容是做遊戲中心的使用者運營活動,會做些好玩的活動讓使用者參與,並get一些福利。
當時的活動背景是我司一年一度的vivo遊戲節,並且元宇宙是大熱詞。所以做它的原因有幾個:
vivo遊戲節主題
契合元宇宙熱點
新玩法、新體驗
用到的組合方案:Three.js + Blender。
why Three.js
開源的3D框架有很多,但最常用的有兩種:Three.js、Babylon.js,我們只需要從中二選一。分析後發現兩者各有優勢:
考慮到3D展覽館的幾個基本特性:
簡單的小型3D場景,沒有複雜的互動(對鏡頭的要求不高)
投放在移動裝置,需要儘可能小的包體,以提升效能
工期短,需要快速上手及更多的案例參考
Three.js包體更小、有更多參考案例、上手更快,所以雖然Babylon.js有它的優勢,但Three.js更適合這個專案。
why Blender
Blender是一款輕量的開源3D建模軟體,有很多好用的免費外掛,而且Blender能匯出GLTF / GLB模型(後面會對GLTF / GLB模型做簡介),匹配Three.js的使用方式,整體更簡單好用一些。
所以,就是它了。
在進入開發之前,先簡單瞭解Blender和GLTF / GLB模型。
簡單瞭解 Blender
首先,Blender大概長這樣,圖中是設計師交付的3D展覽館稿子。簡單理解為,左側是模型的層次結構,中間是模型的預覽效果,右側是模型的屬性面板。
一般來說,作為開發者我們不需要掌握太多Blender相關知識,只需知道如何看懂模型結構、匯出GLTF / GLB模型以及烘焙的基本原理即可。
GLTF / GLB模型
GLTF(Graphics Language Transmission Format)是一種標準的3D模型檔案格式,它以JSON的形式儲存3D模型資訊,例如模型的層次結構、材質、動畫、紋理等。
模型中依賴的靜態資源,比如圖片,可以通過外部URI的方式來引入,也可以轉成base64直接插入在GLTF檔案中。
它包含兩種形式的字尾,分別是.gltf(JSON/ASCII)和.glb(Binary)。.gltf是以JSON的形式儲存資訊。.glb則是.gltf的擴充套件格式,它以二進位制的形式儲存資訊,因此匯出的模型體積也更小一些。如果我們不需要通過JSON對.gltf模型進行直接修改,建議使用.glb模型,它更小、載入更快。
Blender匯出GLTF / GLB模型
在blender中,可以直接將模型匯出為GLTF / GLB格式,三種選項的差別不再贅述,我們先簡單選擇最高效的.glb格式。
有了模型之後,我們可以開始通過Three.js建立場景,並匯入這個模型了。
為了防止篇幅過長,這裡假設大家已經掌握了Three.js的一些基本語法。文章重點放在如何載入模型,並一步步進行調優和實現最終的3D展覽館效果。
怎麼載入一個模型?
(1)建立一個空場景
首先建立一個空場景scene,後續所有的模型或材質都會被新增到這個場景中。
import * as THREE from 'three'
// 1. 建立場景
const scene = new THREE.Scene();
// 2. 建立鏡頭
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
// 3. 建立Renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
(2)匯入GLTF / GLB模型
通過GLTFLoader匯入.glb模型,並新增到場景中。
import GLTFLoader from 'GLTFLoader'
const loader = new GLTFLoader()
loader.load('path/to/gallery.glb',
gltf => {
scene.add(gltf.scene) // 新增到場景中
}
}
(3)開始渲染
通過requestAnimationFrame來呼叫renderer.render方法,開始實時渲染場景。
function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();
ok,這樣我們就完成了3D模型的匯入,但是發現整個場景一片漆黑。
試試加個環境光。
const ambientLight = new THREE.AmbientLight(0xffffff, 1)
scene.add(ambientLight)
ok,亮起來了,但是效果依然很差,很劣質。
原因是模型中的材質效果、光源、陰影、環境紋理,這些全都丟失了,所以當我們匯入模型時,看到的就是一堆簡陋的純色形狀。
所以我們要一步步將這些丟失東西找回,還原設計稿。
接下來一步步還原設計稿。
(1)加上光源
檢視Blender模型,看到設計稿中新增了一堆點光源、平行光源。
點光源可以理解為房間中的燈泡,光線強弱隨著距離衰減;
平行光源可以理解為太陽的直射光,它和點光源不同,光線強弱不隨著距離衰減。
於是我們也增加一些光源:
// 一些燈光選項
// 如果是平行光則沒有distance、decay選項
const lightOptions = [
{
type: 'point', // 燈光型別:1. point點光源、2. directional平行光源
color: 0xfff0bf, // 燈光顏色
intensity: 0.4, // 燈光強度
distance: 30, // 光照距離
decay: 2, // 衰減速度
position: { // 光源位置
x: 2,
y: 6,
z: 0
}
},
...
]
function createLights() {
pointLightOptions.forEach(option => {
const light = option.type === 'point' ?
new THREE.PointLight(option.color, option.intensity, option.distance, option.decay) :
new THREE.DirectionalLight(option.color, option.intensity)
const position = option.position
light.position.set(position.x, position.y, position.z)
scene.add(light)
})
}
createLights()
可以看到場景比之前好了一些,有了光源後,模型變得立體和真實了,多了一些反色的光澤。
但是我們注意到,畫面中的logo、長椅的兩側都是黑色的,並且旁邊的球體、椅子等都顯得不夠真實。
所以,我們需要進行下一步調整:調整模型材質、增加環境紋理。
(2)調整模型材質,增加環境紋理
先簡單瞭解一下材質和環境紋理。
材質(material)
材質就像物體的面板,我們可以調整面板的光澤、金屬度、粗糙度、透明與否等屬性,讓物體有不同的視覺效果。
一般從blender匯出的模型中,已經包含了一些材質屬性,但是Three.js中的材質屬性和Blender中的屬性並非完全的對映關係,模型在匯入到Three.js後,效果和設計稿會有差異。這時候我們需要手動調整材質的屬性,來達到和設計稿近似的效果。
環境紋理(environment map)
環境紋理就是讓模型對映周圍的環境,讓場景或物體更真實。例如我們要渲染一個立方體,把立方體放進一個屋子裡,這個屋子的環境就會影響立方體的渲染效果。
比如鏡面的物體被貼上環境紋理後,就可以實時反射周圍的環境映象,看起來很real。
設計稿中也是將一個大廳作為了環境紋理,讓場景更真實。
環境紋理分為:球形紋理和立方體形紋理。兩者都可以,這裡我們採用一張大廳的球形紋理作為環境貼圖。
以畫面中的vivo遊戲節logo為例,我們通過調整它的材質和環境紋理,讓它變得更真實。
根據在blender中的命名,找到logo模型
調整logo的表面粗糙度和金屬度
載入並設定環境紋理貼圖
const loader = new GLTFLoader()
loader.load('path/to/gallery.glb',
gltf => {
// 1. 根據Blender中物體的名字,找到logo模型
gltf.scene.traverse(child => {
if (isLogo(child)) {
initLogo(child) // 2. 調整材質
setEnvMap(child) // 3. 設定環境紋理
}
})
scene.add(gltf.scene)
}
}
// 判斷是否為Logo
const isLogo = object.name === 'logo'
function initLogo(object) {
object.material.roughness = 0 // 調整表面粗糙度
object.material.metalness = 1 // 調整金屬度
}
// 載入環境紋理
let envMap
const envmaploader = new THREE.PMREMGenerator(renderer)
const setEnvMap = (object) => {
if(envMap) {
object.material.envMap = envMap.texture
} else {
textureLoader.load('path/to/envMap.jpg',
texture => {
texture.encoding = THREE.sRGBEncoding
envMap = envmaploader.fromCubemap(texture)
object.material.envMap = envMap.texture
})
}
}
經過上面的處理後,可以看到原先黑色的logo有了金屬光澤,並且會反射周圍的環境紋理。
其它物體經過類似的處理後,也變得更真實一些。
現在整個場景更接近了設計稿一些,但場景中少了陰影,顯得很乾癟。
加上陰影。
(3)增加陰影
增加陰影分四步:
對renderer開啟陰影支援:renderer.shadowMap.enabled = true
對光源設定:castShadow = true
對需要投影的物體設定:castShadow = true
對需要被投影的平面或物體(比如地板)設定:receiveShadow = true
// 1. renderer
const renderer = new THREE.WebGLRenderer()
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// 2. light
const light = new THREE.DirectionalLight()
light.castShadow = true;
// 3. object
gltf.scene.traverse(function (child) {
if (child.isMesh) {
child.castShadow = true;
}
});
// 4. floor
floor.receiveShadow = true
新增陰影后,有質的提升,發現整個場景立體了很多,此時還原度已經很高。
如果不考慮效能損耗,這個場景的樣式已經可以投入使用了。(後續會提到效能優化)
小結一下,剛剛做的幾件事:
新增光源
調整模型材質、增加環境紋理
增加陰影
現在3D展覽館場景已經還原的差不多了,接下來要構造一個虛擬移動搖桿,控制第一人稱鏡頭的移動和轉向,實現沉浸式逛展的效果。
要實現通過虛擬移動搖桿控制鏡頭的移動和轉向,我們需要三個東西:
一個移動搖桿(handler)
一個長方體(player):用於承載第一人稱視角
一個鏡頭(camera):之前已經建立過了
有人會問為什麼需要一個player,通過搖桿直接控制鏡頭不就行了嗎?其實player的作用是用於做碰撞檢測,當player遇到凳子、牆壁等障礙物時,需要停止鏡頭移動。直接控制鏡頭,是無法做碰撞檢測的。
所以,實際上鏡頭移動的邏輯是:
使用者操縱搖桿 → 更新player位置和朝向 →從而同步更新camera位置和朝向
(1)建立移動搖桿
移動搖桿的實現原理很簡單,這裡僅做簡述。
核心在於建立一個圓盤,監聽觸控手勢,並根據手勢的方向來實時更新move引數,控制鏡頭的移動和轉向。
const speed = 8 // 移動速度
const turnSpeed = 3 // 轉向速度
// move option,用於調整第一人稱鏡頭的移動和轉向
const move = {
turn: 0, // 旋轉角度
forward: 0 // 前進距離
}
// 建立一個handler,並監聽手勢,調整move option
const handler = new Handler()
handler.onTouchMove = () => { // update move option }
(2)建立player
首先建立一個player物件,它是一個1.2 * 2 * 1的透明長方體。
function createPlayer() {
const box = new THREE.BoxGeometry(1.2, 2, 1)
const mat = new THREE.MeshBasicMaterial({
color: 0x000000,
wireframe: true
})
const mesh = new THREE.Mesh(box, mat)
box.translate(0, 1, 0)
return mesh
}
const player = createPlayer() // 建立player
player.position.set(4.5, 2, 12) // 設定player的初始位置
(3)updatePlayer & updateCamera
每次渲染(render)時,更新player的位置和朝向,並同步更新鏡頭的位置和朝向。
const clock = THREE.clock()
function render() {
const dt = clock.delta() // 獲取每幀之間的時間間隔,根據時間間隔長短來更新player和camera的移動距離和轉向的多少
updatePlayer(dt)
updateCamera(dt)
renderer.render(scene, camera)
window.requestAnimationFrame(render)
}
// 更新player的位置和朝向
function updatePlayer(dt) {
const pos = player.position.clone()
pos.y -= 1.5 // 降低高度,後續用於計算碰撞檢測
const dir = new THREE.Vector3()
player.getWorldDirection(dir)
dir.negate()
if (move.forward < 0) dir.negate()
// 調整鏡頭前進 or 後退
if (move.forward !== 0) {
player.translateZ(move.forward > 0 ? -dt * speed : dt * speed * 0.5)
}
// 調整鏡頭朝向
if (move.turn !== 0) {
player.rotateY(move.turn * 1.2 * dt)
}
}
// 根據player的位置和朝向,同步更新camera的位置和朝向
function updateCamera(dt) {
camera.position.lerp(activeCamera.getWorldPosition(new THREE.Vector3()), 0.08)
const pos = player.position.clone()
pos.y += 2.5
camera.lookAt(pos)
}
注意:render方法中使用clock.delta()來計算每次渲染之間的時間間隔,並使用這個時間間隔來更新player和camera。因為在理想的60影格率情況下,兩幀時間間隔為16.67ms,但實際上該數值會有波動,因此我們要根據實際的渲染時間間隔來更新player和camera,讓鏡頭的移動和轉向幅度更自然一些。
完成上述步驟後,我們就可以通過控制虛擬移動搖桿,來讓鏡頭移動和轉向了。
接下來加入碰撞檢測,對鏡頭移動加點限制。
碰撞檢測的步驟也很簡單:
收集障礙物(colliders)
檢測碰撞(基於THREE.Raycaster)
(1)收集障礙物
模型載入完成後,遍歷所有的child,如果child是一個物體(mesh),則把它加入到障礙物佇列(colliders)中。
const colliders = []
loader.load('path/to/gallery.glb',
gltf => {
gltf.scene.traverse(child => {
// 收集障礙物
if(isMesh(child)) {
colliders.push(child)
}
})
}
})
(2)檢測碰撞
調整剛剛的updatePlayer方法,在其中插入檢測碰撞的邏輯。
碰撞檢測邏輯基於THREE.Raycaster來實現,racaster可以理解為一個射線,當射線穿過了某個物體,我們就認為射線和物體相交了。
我們讓射線的方向和player的朝向保持一致,並且在移動過程中不斷判斷射線前方/後面是否有相交的物體,如果有相交的物體,且和射線頂點距離distance < 2.5則認為遇到了障礙物,不能再繼續前進。
function updatePlayer(dt) {
const pos = player.position.clone()
pos.y -= 1.5 // 降低高度,用於計算collision
const dir = new THREE.Vector3()
// 獲取當前player的朝向
player.getWorldDirection(dir)
dir.negate()
// 如果是向後退,需要對朝向取反
if (move.forward < 0) dir.negate()
// 利用Raycaster判斷player是否和colliders有碰撞行為
const raycaster = new THREE.Raycaster(pos, dir)
let blocked = false
if (colliders.length > 0) {
const intersect = raycaster.intersectObjects(colliders)
if (intersect.length > 0) {
// 如果相交距離<2.5,表示前方或後面有障礙物
if (intersect[0].distance < 2.5) {
blocked = true
}
}
}
// 如果遇到障礙物,則停滯移動
if (!blocked) {
// 調整鏡頭前進 or 後退
if (move.forward !== 0) {
player.translateZ(move.forward > 0 ? -dt * speed : dt * speed * 0.5)
}
}
// 調整鏡頭朝向
if (move.turn !== 0) {
player.rotateY(move.turn * 1.2 * dt)
}
}
這樣鏡頭的移動和碰撞檢測就完成了。
當我們移動到椅子、牆壁等障礙物附近時,鏡頭會停止移動。鏡頭的移動範圍也被我們限制在房間裡,不會穿到房間外部。
3D展覽館的基本功能已經完成了,但還沒有做任何的效能調優。當我們把專案執行在手機上,會發現裝置發熱發燙,影格率很低,低端機型甚至無法執行。
經過分析,實時的光影渲染是罪魁禍首。
頁面中有10+個光源,每個光源都在實時投射陰影(尤其是點光源十分消耗資源,引起卡頓)。但實際,場景中的光源和物體位置都沒有發生改變,這意味著我們不需要計算實時陰影,只需要固定的陰影。
這點可以通過紋理烘焙來實現。並且在行動端,經過紋理烘焙的光影效果實際上要優於裝置計算的實時光影效果。
紋理烘焙(Texture Baking)
紋理烘焙,是指通過將場景效果預渲染到指定紋理上,生成一個模型貼圖。在Blender中,我們可以選中任意物件進行烘焙。
以3D展覽館的地板為例,我們可以通過紋理烘焙,將光影效果直接渲染到貼圖上。
左圖是原本的棋盤格紋理,右圖是結合了光影效果的烘焙貼圖。烘焙完成後,地板上的光影效果就被固定下來了,我們也不需要再做實時的光影渲染。
用同樣的方式,將地板、牆壁、天花板等物體,一一進行烘焙處理,匯出一個新的模型。由於光影效果已經被渲染到貼圖上,我們可以將大部分光源去掉,只保留2-3個必要的點、平行光源和全域性光。再次執行後,發現卡頓、發燙的問題已經不再明顯。並且效果其實比實時渲染更精細一些。
這裡沒有對烘焙做過多介紹,要生成精緻的烘焙結果還需要依賴對UV Map、烘焙引數的瞭解,雖然這些偏向於設計同學的工作,一般由他們來輸出烘焙紋理。但是作為開發者,瞭解了這些後才能和UI更好地溝通和配合。
模型大小約為23M,首次載入模型需要9s左右。(尤其是在做完紋理烘焙後,由於貼圖變得複雜,模型更大了)
以下是幾個優化模型大小的建議:
優先使用.glb而非.gltf格式。.glb是二進位制格式,它比.gltf的JSON格式小25% - 30%左右。
將紋理(Texture)和模型分離,並行載入。23M的模型中,其實只有2.3M為模型大小,其餘都為紋理貼圖。將模型和紋理分開後,可以極大減少模型的載入速度。
使用Draco、gltfpack等工具或一些online compressor來壓縮模型(Blender在匯出gltf模型時,就帶有基於Draco的壓縮選項)。本專案通過該步驟壓縮了50%的模型大小:3M → 1.2M。
壓縮紋理(Texture)。本專案用到了5張的Texture,壓縮後:18M→ 2M。
經過優化,初始模型大小由23M縮小為1.2M,首次載入時間由9s縮短到3s以內。
(左圖為優化前,右圖為優化後)
現在,我們基本完成了整個3D展覽館的開發。雖然有一些細節沒有在文中涉及到,但開發過程大致如此。
(1)瞭解Blender、GLTF / GLB模型
(2)js匯入GLTF / GLB模型
(3)還原設計稿
新增光源
調整模型材質、增加環境紋理
增加陰影
(4)實現虛擬移動搖桿,控制鏡頭移動
(5)增加碰撞檢測
(6)效能調優:
紋理烘培:通過紋理烘焙降低實時光影的效能損耗。
優化包體大小:
- 優先使用.glb而非.gltf格式
- 紋理和模型分離
- 壓縮模型
- 壓縮紋理
一些建議:
設計師在Blender中命名物體、材質時要規範化,避免出現奇怪或沒有標識意義的命名,因為在開發過程中會使用到,容易混淆。
設計師在在Blender中複用材質要謹慎,避免開發在調整某個材質時,影響到其它使用到相同材質的物體(潛在bug)。
模型載入緩慢時,可以增加loading進度條,緩解等待焦慮。Three.js loader支援載入進度查詢。
Three.js在不同版本之間,介面頻繁變更,在使用時注意版本差異,google問題時也要注意介面相容性。
Three.js實現物體發光效果較繁瑣,且消耗效能,設計時可儘量避免使用。
Three.js的鏡頭移動不夠絲滑,注重鏡頭切換流暢性的專案,可以嘗試用Babylon.js。
部分瀏覽器不支援videoTexture(在模型中播放視訊),謹慎設計該型別功能,或做好相容處理。
參考: