3D 沙盒遊戲之人物的點選行走移動

凹凸實驗室發表於2022-04-21

前言

在 3D 遊戲中,都會有一個主人公。我們可以通過點選遊戲中的其他位置,使遊戲主人公向點選處移動。

那當我們想要實現一個“點選地面,人物移動到點選處”的功能,需要什麼前置條件,並且具體怎麼實現呢?本文帶大家一步步實現人物行走移動,同時進行狀態改變的功能。


一、骨骼動畫

骨骼動畫(Skeleton animation 又稱骨架動畫,是一種計算機動畫技術,它將三維模型分為兩部分:用於繪製模型的蒙皮(Skin),以及用於控制動作的骨架。

一般在 3D 遊戲中的主人公,它的跑步、走路、站立的動作,都是模型檔案的自帶骨骼動畫。

骨骼動畫權重
改變骨骼動畫的權重,可以使得動畫間的過渡更為自然。比如體測時,當你到達終點後,會逐漸減慢速度,跑步動作的幅度越來越小,然後變成走路,最後停止。

讓我們看看一個倆動作權重漸變的例子:

這個例子中,從休閒變到走路,休閒動畫的權重從1到0遞減,同時走路動畫的權重從0到1遞增。可以的點選? 這個網站中 > Crossfading > from idle to walk 體驗一下。

在本次 3D 沙盒遊戲中,人物狀態改變,主要是滑鼠點選地面後,人物從休閒狀態轉為跑步狀態,當人物到達目的地後,又變為休閒狀態。我們先來看看這些狀態改變是如何實現的。

首先,我們需要設計師提供一個擁有骨骼動畫的模型,它有兩個骨骼動畫,一個為休閒(idle)狀態,一個為跑步(run)狀態。

1.1 思路


1.2 動畫初始化

先讓我們將骨骼動畫、動畫名稱、權重放到一個物件中儲存起來,

idleAnimConfig = {
  name: string;
  anim: AnimationGroup;
  weight: number;
}

那麼如何判斷是否正在行走呢?就需要一個當前動畫的 flag,初始化時將 idle 設為當前動畫

currentAnimConfig = idleAnimConfig


1.3 動畫權重改變

如圖,我們在人物狀態改變時,需要將當前狀態的動畫權重遞增,另一狀態的動畫權重遞減(注意,權重值需要限制在[0, 1])。讓我們看下虛擬碼,假設 deltaWeight 為正數

changeAnimWeight() {
  // 當前動畫 -> 遞增
  if (currentAnimConfig) {
    setAnimationWeight(currentAnimConfig, deltaWeight)
  }
  // 其他動畫 -> 遞減,如站立動作切換到走路
  if (currentAnimConfig !== idleAnimConfig) {
    setAnimationWeight(idleAnimConfig, -deltaWeight)
  }
  // 其他動畫 -> 遞減,如走路動作切換到站立
  if (currentAnimConfig !== runAnimConfig) {
    setAnimationWeight(runAnimConfig, -deltaWeight)
  }
}

然後在 render 的時候,進行狀態切換

onRender() {
  if (準備到達目的地) {
    setCurAnimation(runAnimConfig)
  } else {
    setCurAnimation(idleAnimConfig)
  }
  changeAnimWeight()
}


1.4 缺少動畫

如果 animationGroup 裡只有一個 run 動畫怎麼辦呢?
答案還是一樣的,只要將 idle 動畫的骨骼動畫設為 null 即可,像這樣:

idleAnimConfig = {
  name: string;
  anim: null;
  weight: number;
}

這麼做即使後來更換了具有兩個動畫的人物模型,也能複用。


動畫狀態切換實現效果




二、行走移動

當我們平常寫動畫時,會用到 rAF 並遞迴呼叫渲染函式,實現一個逐幀渲染動畫。當人物行走在平地上時,也可以利用逐幀移動,來實現一個位移的動畫。例如 Babylon 已經封裝好了 render 的事件 API,只要我們將渲染動畫繫結 render 事件,就可以使用了。

讓我們看看具體思路:


2.1 移動

由上面的思路可以看出,我們移動的時候需要用到幾個變數:

  • 距終點的距離(distance)
  • 移動的方向(direction)

那麼就需要在點選的時候,獲取到這些變數。distance 可以利用矩陣對應座標相加減計算,direction 就是目標位置減初始位置的法向量

directToPath() {
  // 將人物的位置設為初始位置
  initVec = this.player.position
  // 計算初始位置與終點的距離
  distance = Distance(targetVec, initVec)
  // 將終點位置與初始位置相減
  targetVec = targetVec.subtract(initVec)
  // 使用法向量計算出與終點的朝向
  direction = Normalize(targetVec)
  player.lookAt(targetVec)
}
onClick() {
  // ...
  directToPath()
}

在 render 的時候進行位移

onRender() {
  if (distance > READY_ARRIVE) {
    distance -= SPEED
    // 人物朝 direction 方向移動 SPEED 距離
    player.translate(direction, SPEED, Space.WORLD)
  }
}


位移實現效果


2.2 結合動畫

當我們的移動結合模型的骨骼動畫

讓我們看看虛擬碼:

onRender() {
  if (distance > READY_ARRIVE) {
    distance -= SPEED
    // 人物朝 direction 方向移動 SPEED 距離
    player.translate(direction, SPEED, Space.WORLD)
    setCurAnimation(runAnimConfig)
  } else {
    setCurAnimation(idleAnimConfig)
  }
  changeAnimWeight()
}


位移及狀態變化實現效果




三、人物避障

3.1 思路

人物行走避障,實際上就是從起點到終點,在這之中新增了中間點。如圖

所以我們只要記錄下當前起點到終點這個路徑陣列,每次都朝陣列的第N個點行走,就能做到轉向。下面我們來根據思路及虛擬碼進行步驟細化。


(1) 記錄路徑和初始化當前的路徑索引

path = getPath(targetVec)
prePathIdx = 0

(2) 當到達當前中間點時,切換到下一個中間點。當走到最後一個,則停止

onRender() {
  if (distance > READY_ARRIVE) {
    // ...移動及動畫權重切換...
  } else {
    switchPath()
    // ...
  }
  // ...
}
switchPath() {
  prePathIdx += 1
  directToPath()
}
directToPath() {
  const curPath = path[prePathIdx]
  if (!curPath) return
  // ...人物移動及轉向...
}


3.2 接入實際避障演算法

3.1 得知,人物的行走移動要接入避障演算法,需要利用到該演算法提供的路徑規劃陣列。實際應用中,我們只需要把,虛擬碼裡的getPath()方法,換成演算法計算道路的方法即可。

3.2.1 RecastJSPlugin

下面我們使用 Babylon 自帶的 Recast 外掛 ,來具體說明一下如何接入避障演算法。

方法 1

在 recast 中,可以通過 computePath 獲取路徑:

const closestPoint = this.navigationPlugin.getClosestPoint(pickedPoint)
const path = this.navigationPlugin.computePath(
  this._crowd.getAgentPosition(0),
  closestPoint
)

然後利用 3.1 的思路,通過路徑索引切換進行移動。


方法 2

recast 首先會建立一個導航網格,然後通過新增 agent 讓它們約束在這個導航網格中,而這些 agent 的集合,稱為 crowd

並且 recast 自帶了移動的 API —— agentGoto,此時可以不需要再去計算距離和方向,並且也不需要手動切換移動路徑,讓我們看看具體是怎麼做的。

(1) 初始化外掛,並設定 Web Worker 來獲取網格資料以優化效能

initNav() {
  navigationPlugin = new RecastJSPlugin()
  // 設定Web Worker,在裡面獲取網格資料
  navigationPlugin.setWorkerURL(WORKER_URL)
  // 建立導航mesh
  navigationPlugin.createNavMesh([
    ground,
    ...obstacleList, // 障礙物列表mesh
  ], NAV_MESH_CONFIG, (navMeshData) => {
    navigationPlugin.buildFromNavmeshData(navMeshData)
  }
  this.navigationPlugin = navigationPlugin
}

(2) 初始化 crowd(crowd:約束在導航網格中 agent 的集合)

initCrowd() {
  this.crowd = this.navigationPlugin.createCrowd(1, MAX_AGENT_RADIUS, this.scene)
  const transform = new TransformNode('playerTrans')
  this.crowd.addAgent(this.player.position, AGENTS_CONFIG, transform)
}

(3) 點選時利用 agentGoto API進行移動,pickedPoint 為點選點的三維座標,由於 crowd 裡只有一個物件,所以索引是 0

const closestPoint = this.navigationPlugin.getClosestPoint(pickedPoint)
this.crowd.agentGoto(0, closestPoint)

(4) 判斷是否停止,未停止則改變人物朝向

那麼如何改變人物的朝向呢,我們需要下一個中間點的位置,讓人物看向它即可。
所以回到之前初始化的地方,建立一個 navigator

initCrowd() {
  // ...
  const navigator = MeshBuilder.CreateBox('navBall', {
    size: 0.1,
    height: 0.1,
  }, this.scene)
  navigator.isVisible = false
  this.navigator = navigator
  // ...
}

在 render 的時候,人物是否停止,可以通過當前 agent 的移動速度來進行判斷。而改變方向,則是通過將 navigator 移動到下一個 path 的中間點,讓人物看向它。

onRender () {
  // 第一個agent物件的移動速度
  const velocity = this.crowd.getAgentVelocity(0)
  // 移動人物到agent的位置
  this.player.position = this.crowd.getAgentPosition(0)
  // 將navigator的位置移到下一個點
  this.crowd.getAgentNextTargetPathToRef(0, this.navigator.position)
  if (velocity.length() > 0) {
    this.player.lookAt(this.navigator.position)
    // ...
  } else {
    // ...
  }
 // ...
}


4. 避障實現效果

讓我們看看最後的效果


5. 遇到的問題

整個開發過程中,其實也不是非常順利,總結了一些遇到的問題,可以給大家參考一下。

(1) 年獸移動時,有時會“無法剎車”,導致在終點時反覆來回停不下來;
這是因為在這一幀裡,由於年獸的加速度較小,無法使得短時間內將速度降為0。所以只能“走過頭”再“走回來”直到速度降為0之後,停止在終點。
此時,只需要 hack 一下,將 agent 的 maxAcceleration 設為極大,讓其有種勻速行走並立馬停下的感覺。

export const AGENTS_CONFIG: IAgentParameters = {
  maxAcceleration: 1000
  // ...
}


(2) 障礙物的動態新增與移除

如果障礙物在該場景初始化後,位置發生了改變,此時再去銷燬建立一次 navMesh 是很消耗效能的。

於是我們通過查詢文件,看到還有動態新增障礙物的 API。再立馬調了下文件中的Playground,發現是可以用的。但是當我們把障礙物放大了之後,穿模了? 看看這裡

於是在 Babylon 的論壇上提了這個問題,20分鐘後就得到了 reply,這個速度?。

原來是需要調整 NavMeshParametersch / cs / tileSize 引數,對專案做適配。

那如果想要自己實現避障,建立更快的navMesh,我們應該怎麼做呢?可以看看這篇文章:3D 沙盒遊戲之避障踩坑和實現之旅


總結

這篇文章,我們從骨骼動畫的介紹及使用、模型的移動及狀態改變、路徑規劃的適配三個方面,講解了3D沙盒遊戲中實現人物行走移動並進行狀態改變的思路及步驟,希望新人閱讀結束之後,能更快上手這個功能。

當然,本篇文章介紹的實現方式還仍有不足之處,比如移動可以加上加速度,讓動作與移動速度匹配得更自然等。

如果還有什麼合適的建議,也歡迎大家積極留言交流。


參考資料

  1. 骨骼動畫 - 維基百科,自由的百科全書
  2. Grouping Animations | Babylon.js Documentation
  3. Advanced Animation Methods | Babylon.js Documentation
  4. Vector3 | Babylon.js Documentation
  5. Crowd Navigation System | Babylon.js Documentation
  6. Web Workers API
  7. Make crowd agent move at constant speed - Questions - Babylon.js

    歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。

相關文章