基於babylon.js的3D網頁遊戲從零教程

3D 遊戲的 javascript 框架:

在好久一段時間 web 端的 3D 遊戲引擎一直是 nothing,但如今卻如雨後春筍。javascript

  1. Unity (Unity 2018.2 開始已經完全棄用 js,使用 C#)
  2. Three.js(比較底層的框架,只是一個渲染器,複雜的遊戲互動須要找合適的插件)
  3. PlayCanvas(可視化編輯器,走設計的 workflow)
  4. babylon.js (巴比倫 js,是微軟開發和維護的 web 端 3D 引擎)
  5. CopperCube (可視化編輯器類型)
  6. A-frame (VR 開發專用,html 自定義 tag 形式編程)

本文介紹使用 babylon.js 的 3D 網頁遊戲開發流程。css

1. Get Started

  1. 3D 場景基本概念
    建立一個 3D 場景,不論使用何種框架乃至 3D 建模軟件,基本元素和流程都是一致的: html

    blender初始場景

  2. html 中建立 canvasjava

<canvas id="renderCanvas"></canvas>
複製代碼
  1. 初始化 3d 引擎
const canvas = document.getElementById('renderCanvas');
engine = new BABYLON.Engine(canvas, true); // 第二個選項是是否開啓平滑(anti-alias)
engine.enableOfflineSupport = false; // 除非你想作離線體驗,這裏能夠設爲 false
複製代碼
  1. 場景
scene = new BABYLON.Scene(engine);
複製代碼
  1. 相機
// 最經常使用的是兩種相機:
// 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)
複製代碼
  1. 光源
  • 四種光類型
    // 點光源
    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)
    複製代碼

能夠自用使用多個光源達到複合效果,好比一個點光源加一個環境光就是不錯的組合。react

  1. 渲染 loop
engine.runRenderLoop(() => {
    scene.render()
})
複製代碼

這段代碼確保場景的每幀更新渲染webpack

  1. 基本例子:
<!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>
複製代碼

注:web

<!--基礎Babylonjs包-->
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<!--loader, 用於加載素材-->
<script src="https://preview.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
複製代碼
  1. npm 包使用 用webpack等打包工具的開發環境,可使用npm包加載Babylonjs 主要有
    babylonjs - 主包
    babylonjs-loaders - 全部素材的加載loader
    babylonjs-gui - GUI 用戶交互頁面
    babylonjs-materials - 一些官方提供的材質
    還有
    babylonjs-post-process
    babylonjs-procedural-textures
    babylonjs-serializers
    babylonjs-viewer

加載方式以最經常使用的主體包和loader包爲例:npm

npm i babylonjs babylonjs-loaders
複製代碼
import * as BABYLON from 'babylonjs'
import 'babylonjs=loaders'

BABYLON.SceneLoader.ImportMesh( ... )
複製代碼
  1. React.js + Babylon.js
    詳見官方詳細guide, 或者將內容全寫在 componentDidMount 就能夠了。

2. 素材導入和使用

  • 素材獲取
    除了粒子等少數元素,場景和物體(包含物體的動畫)都是外部導入素材。目前最流行的素材統一格式是.gltf。 獲取素材比較經常使用的網站是 sketchfab, PolyRemix3d。三個均可以直接下載 .gltf 格式。編程

  • 素材處理
    下載的素材通常由 .gltf.bintextures (皮膚) 文件組成。我的喜歡 .gltf.glb,將全部文件合成一個 .glb, 更方便引入。線上轉換網址 glb-packer.glitch.me/canvas

  • 素材引入

    // .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 => {
        ...
    })
    複製代碼

    AppendImportMesh 基本功能都是加載模型,而後渲染到場景 scene 中,不一樣在於:

    1. 回調函數的參數,前者是場景,後者是 mesh,粒子和骨架
    2. ImportMesh 第一個參數能夠用於指定引入一部分素材,空字符串會引入所有。
  • 選中和處理素材
    Append 例子: www.babylonjs-playground.com/#WGZLGJ
    ImportMesh 例子: www.babylonjs-playground.com/#JUKXQD

    要抓取一個素材須要操做的部分和自帶動畫,須要瞭解素材的構成,最簡單的方式是使用 sandbox。好比從 sketchfab 下載素材 賽車,解壓後將整個文件夾拖入 sandbox,可看到界面

    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. 用戶交互和事件觸發

遊戲最重要的互動部分,通常是由幾組動畫以及觸發這些動畫的用戶交互組成的。

  • 交互方式
    能夠是html原生的各類事件、React組件的onClick,Babylonjs也提供了本身的事件,使用observable監聽。

  • observable

    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,用於畫出 Ray
    BABYLON.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
  1. 當你想爲射擊遊戲建立一個槍管時,但願槍管一直不變的顯示在屏幕右下方,如此demo
    這時候須要使用 parent 將槍管 mesh 的 parent 設置爲 camera。
  2. parent還經常使用於尋找素材的主節點,以及將兩個物體綁定。child 的 position、rotation、scaling 都會隨着 parent 的變更而同步變更。
  3. 360 圖 babylonjs 提供了現成方法 BABYLON.PhotoDome
const dome = new BABYLON.PhotoDome(
    "testdome",
    "./textures/360photo.jpg",
    {
        resolution: 32,
        size: 1000
    },
    scene
)
複製代碼

360圖的 demo

  1. 物體顯示和隱藏

顯示和隱藏一個物體時,須要注意物體是一個 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 網頁遊戲就成型了。

相關文章
相關標籤/搜索