基於babylon.js的3D網頁遊戲從零教程
在很久一段時間 web 端的 3D 遊戲引擎一直是 nothing,但現在卻如雨後春筍。
- Unity (Unity 2018.2 開始已經徹底棄用 js,使用 C#)
- Three.js(比較底層的框架,只是一個渲染器,複雜的遊戲互動需要找合適的外掛)
- PlayCanvas(視覺化編輯器,走設計的 workflow)
- babylon.js (巴比倫 js,是微軟開發和維護的 web 端 3D 引擎)
- CopperCube (視覺化編輯器型別)
- A-frame (VR 開發專用,html 自定義 tag 形式程式設計)
本文介紹使用 babylon.js 的 3D 網頁遊戲開發流程。
1. Get Started
-
3D 場景基本概念
建立一個 3D 場景,不論使用何種框架乃至 3D 建模軟體,基本元素和流程都是一致的:
-
html 中建立 canvas
<canvas id="renderCanvas"></canvas> 複製程式碼
- 初始化 3d 引擎
const canvas = document.getElementById('renderCanvas'); engine = new BABYLON.Engine(canvas, true); // 第二個選項是是否開啟平滑(anti-alias) engine.enableOfflineSupport = false; // 除非你想做離線體驗,這裡可以設為 false 複製程式碼
- 場景
scene = new BABYLON.Scene(engine); 複製程式碼
- 相機
// 最常用的是兩種相機: // UniversalCamera, 可以自由移動和轉向的相機,相容三端 const camera = new BABYLON.UniversalCamera( 'FCamera', new BABYLON.Vector3(0, 0, 0), scene ) camera.attachControl(this.canvas, true) // 以及ArcRotateCamera, 360度“圍觀”一個場景用的相機 // 引數分別是alpha, beta, radius, target 和 scene const camera = new BABYLON.ArcRotateCamera("Camera", 0, 0, 10, new BABYLON.Vector3(0, 0, 0), scene) camera.attachControl(canvas, true) 複製程式碼
- 光源
- 四種光型別
// 點光源 const light1 = new BABYLON.PointLight("pointLight", new BABYLON.Vector3(1, 10, 1), scene) // 方向光 const light2 = new BABYLON.DirectionalLight("DirectionalLight", new BABYLON.Vector3(0, -1, 0), scene) // 聚光燈 const light3 = new BABYLON.SpotLight("spotLight", new BABYLON.Vector3(0, 30, -10), new BABYLON.Vector3(0, -1, 0), Math.PI / 3, 2, scene) // 環境光 const light4 = new BABYLON.HemisphericLight("HemiLight", new BABYLON.Vector3(0, 1, 0), scene) 複製程式碼
a. 聚光燈的引數用於描述一個錐形的光束聚光燈demo
b. 環境光模擬一種四處都被光照射到的環境環境光demo - 光的色彩
// 所有光源都有 diffuse 和 specular // diffuse 代表光的主體顏色 // specular 代表照在物體上高亮部分的顏色 light.diffuse = new BABYLON.Color3(0, 0, 1) light.specular = new BABYLON.Color3(1, 0, 0) // 只有環境光有groundColor,代表地上反射光的顏色 light.groundColor = new BABYLON.Color3(0, 1, 0) 複製程式碼
可以自用使用多個光源達到複合效果,比如一個點光源加一個環境光就是不錯的組合。
- 渲染 loop
engine.runRenderLoop(() => { scene.render() }) 複製程式碼
這段程式碼確保場景的每幀更新渲染
- 基本例子:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Babylonjs 基礎</title> <style> html, body { overflow: hidden; width: 100%; height: 100%; margin: 0; padding: 0; } #renderCanvas { width: 100%; height: 100%; touch-action: none; } </style> <script src="https://cdn.babylonjs.com/babylon.js"></script> <script src="https://preview.babylonjs.com/loaders/babylonjs.loaders.min.js"></script> </head> <body> <canvas id="renderCanvas"></canvas> <script> const canvas = document.getElementById("renderCanvas") const engine = new BABYLON.Engine(canvas, true) engine.enableOfflineSupport = false /******* 建立場景 ******/ const createScene = function () { // 例項化場景 const scene = new BABYLON.Scene(engine) // 建立相機並新增到canvas const camera = new BABYLON.ArcRotateCamera("Camera", Math.PI / 2, Math.PI / 2, 2, new BABYLON.Vector3(0, 0, 5), scene) camera.attachControl(canvas, true) // 新增光 const light1 = new BABYLON.HemisphericLight("light1", new BABYLON.Vector3(1, 1, 0), scene) const light2 = new BABYLON.PointLight("light2", new BABYLON.Vector3(0, 1, -1), scene) // 建立內容,一個球 const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene) return scene } /******* 結束建立場景 ******/ const scene = createScene() // loop engine.runRenderLoop(function () { scene.render() }) // resize window.addEventListener("resize", function () { engine.resize() }) </script> </body> </html> 複製程式碼
注:
<!--基礎Babylonjs包--> <script src="https://cdn.babylonjs.com/babylon.js"></script> <!--loader, 用於載入素材--> <script src="https://preview.babylonjs.com/loaders/babylonjs.loaders.min.js"></script> 複製程式碼
-
npm 包使用 用webpack等打包工具的開發環境,可以使用npm包載入Babylonjs 主要有
babylonjs - 主包
babylonjs-loaders - 所有素材的載入loader
babylonjs-gui - GUI 使用者互動頁面
babylonjs-materials - 一些官方提供的材質
還有
ofollow,noindex">babylonjs-post-process
babylonjs-viewer
載入方式以最常用的主體包和loader包為例:
npm i babylonjs babylonjs-loaders 複製程式碼
import * as BABYLON from 'babylonjs' import 'babylonjs=loaders' BABYLON.SceneLoader.ImportMesh( ... ) 複製程式碼
-
React.js + Babylon.js
詳見官方詳細guide, 或者將內容全寫在 componentDidMount 就可以了。
2. 素材匯入和使用
-
素材獲取
除了粒子等少數元素,場景和物體(包含物體的動畫)都是外部匯入素材。目前最流行的素材統一格式是
.gltf
。 獲取素材比較常用的網站是sketchfab,Poly 和Remix3d。三個都可以直接下載.gltf
格式。 -
素材處理
下載的素材一般由
.gltf
,.bin
和textures
(面板) 檔案組成。個人喜歡.gltf
轉.glb
,將所有檔案合成一個.glb
, 更方便引入。線上轉換網址 glb-packer.glitch.me/ -
素材引入
// .gltf 等檔案全放在一個資料夾,比如 /assets/apple BABYLON.SceneLoader.Append("/assets/apple", "apple.gltf", scene, (newScene) => { ... }) // 單個 .glb 檔案 BABYLON.SceneLoader.ImportMesh("", "", "www.abc.com/apple.glb", scene, (meshes, particleSystems, skeletons) => { ... }) // promise 版本的 BABYLON.SceneLoader.AppendAsync("/assets/apple", "apple.gltf", scene).then(newScene => { ... }) 複製程式碼
Append
和 ImportMesh
基本功能都是載入模型,然後渲染到場景 scene 中,不同在於:
ImportMesh
-
選中和處理素材
Append
例子: LGJ" rel="nofollow,noindex">www.babylonjs-playground.com/#WGZLGJImportMesh
例子: www.babylonjs-playground.com/#JUKXQD
- 要抓取一個素材需要操作的部分和自帶動畫,需要了解素材的構成,最簡單的方式是使用sandbox。比如從 sketchfab 下載素材賽車,解壓後將整個資料夾拖入 sandbox,可看到介面
// 在callback裡 const wheel = newMeshes.find(n => n.id === 'Cylinder.002_0'); // 隱藏輪子 wheel.isVisible = false; // 一般整個素材是 const car = newMeshes[0]; // 可以在scene裡尋找動畫 const anime = scene.animationGroups[0]; // 播放和停止動畫 anime.start(); // 播放 anime.stop(); // 停止 複製程式碼
3. 建立動畫,控制動畫
-
動畫種類
一共有兩類動畫: a. 通過
BABYLON.Animation
建立的動畫片段b. 在每幀播放的
scene.onBeforeRenderObservable.add
函式中指定個物體引數的每幀的變化
a. 簡單的動畫,比如物體不停移動
scene.onBeforeRenderObservable.add() { // 球向z軸每幀0.01移動 ball.position.z += 0.01 // 旋轉 ball.rotation.x += 0.02 // 沿y軸放大 ball.scaling.y += 0.01 } 複製程式碼
使用 onBeforeRenderObservable
即可。 涉及多個物體和屬性的複雜邏輯動畫也適合用此方法,因為可獲取每幀下任何屬性進行方便計算。
b. 片段形的動畫使用 BABYLON.Animation
建立
const ballGrow = new BABYLON.Animation( 'ballGrow', 'scaling', 30, BABYLON.Animation.ANIMATIONTYPE_VECTOR3, BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT ); const ballMove = new BABYLON.Animation( 'ballMove', 'position', 30, BABYLON.Animation.ANIMATIONTYPE_VECTOR3, BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT ); ballGrow.setKeys([ { frame: 0, value: new BABYLON.Vector3(0.12, 0.12, 0.12) }, { frame: 60, value: new BABYLON.Vector3(3, 3, 3) }, { frame: 120, value: new BABYLON.Vector3(100, 100, 100) }, ]); ballMove.setKeys([ { frame: 0, value: new BABYLON.Vector3(0.5, 0.6, 0) }, { frame: 60, value: new BABYLON.Vector3(0, 0, 0) }, ]); scene.beginDirectAnimation(dome, [ballGrow, ballMove], 0, 120, false, 1, () => { console.log('動畫結束'); }); 複製程式碼
此動畫移動並放大物體。API 說明:
// 建立動畫 new Animation(名稱, 變化的屬性, fps, 動畫變數資料型別, 迴圈模式) // 使用動畫 scene.beginDirectAnimation(target, animations, 從哪幀, 到哪幀, 迴圈否?, 播放速度, 結束callback) // 控制動畫 const myAnime = scene.beginDirectAnimation( ... ) myAnime.stop() myAnime.start() myAnime.pause() // 暫停 myAnime.restart() // 重開 myAnime.goToFrame(60) // 到某一幀 // 轉變成promise myAnime.waitAsync().then( ... ) 複製程式碼
基本語法如上,一般 60 幀(frame)是一秒。順帶一提,素材自帶動畫也屬於第二類,都是 Animatable,適用一切上述動畫操作。所有此類動畫可在 scene.animationGroups
讀到。
4. 使用者互動和事件觸發
遊戲最重要的互動部分,一般是由幾組動畫以及觸發這些動畫的使用者互動組成的。
-
互動方式
-
Babylon.js 提供了一系列觀察者 observable,用於監聽事件,其中最常用的是
a.
scene.onBeforeRenderObservable
每幀監聽b.
scene.onPointerObservable
監聽點選/拖拽/手勢/鍵盤等scene.onKeyboardObservable.add(kbInfo => { switch (kbInfo.type) { case BABYLON.KeyboardEventTypes.KEYDOWN: console.log('按鍵: ', kbInfo.event.key); break; case BABYLON.KeyboardEventTypes.KEYUP: console.log('抬起按鍵: ', kbInfo.event.keyCode); break; } }); scene.onPointerObservable.add(pointerInfo => { switch (pointerInfo.type) { case BABYLON.PointerEventTypes.POINTERDOWN: console.log('按下'); break; case BABYLON.PointerEventTypes.POINTERUP: console.log('抬起'); break; case BABYLON.PointerEventTypes.POINTERMOVE: console.log('移動'); break; case BABYLON.PointerEventTypes.POINTERWHEEL: console.log('滾輪'); break; case BABYLON.PointerEventTypes.POINTERTAP: console.log('點選'); break; case BABYLON.PointerEventTypes.POINTERDOUBLETAP: console.log('雙擊'); break; } }); 複製程式碼
observable 例項有以下方法
.add
新增一個 observable.remove
刪除一個 observable.addOnce
新增一個 observable, 並在執行一次後 remove.hasObservers
判斷是否有某個 observable.clear
清除所有的 observable -
第一類動畫的觸發(即在 gameloop 裡執行的動畫)
scene.onBeforeRenderObservable.add() { gameloop() } function gameloop() { ... } 複製程式碼
gameloop 中的渲染邏輯會在每一幀執行一次,所以只需要通過對一個 boolean 變數的改變就能完成觸發事件
let startGame = false // 可以使用原生的,React裡可以直接用onClick document.addEventListener('click', () => { startGame = true }) // 也可以使用Babylonjs 的pointerObservable scene.onPointerObservable.add((info) => { if(info.type === 32) { startGame = true } } function gameloop() { if(startGame){ ball.rotation.x += 0.01 ball.position.y += 0.02 } } 複製程式碼
- 第二類動畫的觸發 (動畫片段)
// 此時不能在 gameloop 裡直接播放動畫 function moveBall() { scene.beginDirectAnimation( ... ) } function gameloop() { if(startGame){ moveBall() } } 複製程式碼
上面的程式碼會造成遊戲開始後每幀都觸發一遍 moveBall()
, 這顯然不是我們希望的。
如果觸發是滑鼠/鍵盤,顯然可以使用
scene.onPointerObservable.add((info) => { if(info.type === 32) { moveBall() } } 複製程式碼
但也有別的觸發情況(比如相機靠近,屬性變化等),此時可以註冊一個 onBeforeRenderObservable
並在觸發條件達成時執行 animation 並 remove observable
const observer = scene.onBeforeRenderObservable.add(() => { if (scene.onBeforeRenderObservable.hasObservers && startGame) { scene.onBeforeRenderObservable.remove(observer); moveBall(); } }); 複製程式碼
5. 如何用滑鼠選取 3D 場景物體?
- 普適的解決方式是rayCaster 給定起始點,方向和長度,我們能畫一條線段,稱之為 ray
// 起始位置 const pos = new BABYLON.Vector3(0, 0, 0); // 方向 const direction = new BABYLON.Vector3(0, 1, 0); const ray = new BABYLON.Ray(pos, direction, 50); 複製程式碼
Babylonjs 提供了方便的 api,檢驗一條 ray 是否觸碰到場景中的物體,以及觸碰到的物體資訊const hitInfo = scene.pickWithRay(ray); console.log(hitInfo); // {hit: true, pickedMesh: { mesh資訊 }} 複製程式碼
由於 ray 是不可見的,有時候不方便除錯, 提供 RayHelper,用於畫出 RayBABYLON.RayHelper.CreateAndShow(ray, scene, new BABYLON.Color3(1, 1, 0.1)); 複製程式碼
- 判斷滑鼠是否點選到物體,有直接方法
scene.onPointerObservable.add((info) => { if(info.pickInfo.hit === true) { console.log(info.pickInfo.pickedMesh) } } 複製程式碼
- 只有特定物體能被選中
將不能選中的 mesh 的 isPickable 屬性設定為 false 即可。注意某些元素本身不是 mesh,如 360 圖元素需要dome._mesh.isPickable = false; 複製程式碼
- 只選中了部分物體咋辦
對於由多個 mesh 組成的素材,這是常常發生的事。需要用名稱、id 判斷並尋找到最上層的父節點。父節點mesh.parent
。
7. 粒子效果
需要專門寫一篇介紹
8. 走過的一些坑和探索的一些解決
- 如何確保動畫勻速:
// engine.getFps() 獲得當前幀數 const fpsFactor = 15 / engine.getFps(); object.rotation.y += fpsFactor / 5; 複製程式碼
- Parent
- 當你想為射擊遊戲建立一個槍管時,希望槍管一直不變的顯示在螢幕右下方,如此demo
這時候需要使用 parent 將槍管 mesh 的 parent 設定為 camera。 - parent還常用於尋找素材的主節點,以及將兩個物體繫結。child 的 position、rotation、scaling 都會隨著 parent 的變動而同步變動。
- 360 圖 babylonjs 提供了現成方法
BABYLON.PhotoDome
const dome = new BABYLON.PhotoDome( "testdome", "./textures/360photo.jpg", { resolution: 32, size: 1000 }, scene ) 複製程式碼
- 物體顯示和隱藏
顯示和隱藏一個物體時,需要注意物體是一個 transformNode
還是 mesh
, 引入的素材往往會用一個 transformNode
作為一堆子 mesh
的 parent,此時使用 isVisible
來顯隱是無用的。
// 隱藏 mesh.isVisible = false // 顯示 mesh.isVisible = true // 隱藏 transformNode.setEnabled(false) // 顯示 transformNode.setEnabled(true) 複製程式碼
9. 專案串聯
討論瞭如何載入素材,動畫和互動,完成一個小遊戲,如何將所有行為有機串聯起來至關重要。
// 使用Promise.all 和 ImportMeshAsync 載入所有素材 Promise.all([loadAsset1(), loadAsset2(), loadAsset3()]).then(() => { createParticles() // 建立粒子 createSomeMeshes() // 建立其他mesh // 進場動畫 SomeEntryAnimation().waitAsync().then(() => { // 開始遊戲 game() }) }) // 遊戲邏輯 const game = () => { // 只執行一遍的動畫, 並在完成時執行gameReady, 確定可以開始 playAnimeOnTrigger(trigger, () => anime(gameReady)) // 其他只執行一次的流程 } const gameReady = () => { // 顯示開始按鈕,可以是html的button,也可以是Babylonjs的GUI(暫不討論) showStartBtn() ... } // 點選start,開始遊戲,每次遊戲執行 const startGame = () => { const gameStarted = true // 一類動畫全寫在gameLoop, registerBeforeRender 和 onBeforeRenderObservable.add 作用相同 scene.registerBeforeRender(gameLoop) // 和時間相關的遊戲邏輯,比如計時,定時播放的動畫 const interval = window.setInterval(gameLogic, 500) // 每次遊戲執行一遍的動畫,動畫本身可以是迴圈和串聯 playAnimeOnTrigger(trigger1, anime1) playAnimeOnTrigger(trigger2, anime2) } // 觸發邏輯, 比如粒子效果,也可以寫在外面,通過 gameStarted 變數判斷 hitEffect() { if(gameStarted) { showParticles() } } const stopGame = () => { const gameStarted = false scene.unregisterBeforeRender(gameLoop) window.clearInterval(interval) ... } // 常用方法:監聽變數,變數變化時執行動畫並結束監聽 const playAnimeOnTrigger = (trigger, anime) => { const observer = scene.onBeforeRenderObservable.add( () => { if (scene.onBeforeRenderObservable.hasObservers && trigger) { scene.onBeforeRenderObservable.remove(observer) anime() } }) } 複製程式碼
個人總結的簡單寫法大致如此。至此,一個簡單的 3D 網頁遊戲就成型了。