從零開始手敲次世代遊戲引擎(四十一)

大家新年好。

接從零開始手敲次世代遊戲引擎(卌),我們繼續嘗試編寫我們自己的物理引擎。

物理引擎的基本演算法其實並不是很複雜(像諸如柔性碰撞、流體模擬等,雖然推導過程很複雜,但是根據推導結果進行編程並不是很複雜)。對於工程來說,難點有以下兩個:

  1. 如何提高運算的效率
  2. 如何進行高效的調試

另外,遊戲當中的物理往往也需要進行藝術加工,完全真實的模擬結果往往並不是作品需要的。所以用於遊戲的物理模擬庫,需要上得廳堂下得廚房,亦真亦幻。這是遊戲當中的物理模擬比較特別的地方。

本篇我們先來看看可視化調試方面的事情。

如前面很多地方反覆提到的,遊戲是一個軟實時系統。雖然當代計算機硬體的計算能力已經十分強大,但是由於畫面解像度的提升以及幀率要求的提高,能夠用於單幀的計算力仍然是十分有限的。

物理引擎在遊戲當中的運用相對較晚,因此在諸如圖形渲染等「重型」任務已經對CPU和GPU進行了強壓的情況下,能夠留給物理引擎的計算力就更為捉襟見肘。在這種情況下,我們必須對計算對象進行簡化。這就是為什麼我們需要在代碼里實現一些基本的幾何體。這些基本的幾何體將取代實際的場景物體進行物理方面的演算。

然而這種取代是以犧牲精度為代價的。雖然對於遊戲來說,在大多數時候這是可以接受的,然而它很多時候也會帶來嚴重的問題。比如大家熟悉的U廠的A作品,經常出現穿越牆面或者地板等情況,這往往就是由於物理引擎的精度丟失所造成的。

當發生這種情況的時候,由於我們眼睛所見的渲染結果與物理模擬所使用的模型有很大出入,所以很難發現問題到底是在哪裡。只有將實際參與物理模擬的物理模型也描繪在畫面上,才能更好的進行調試。

本篇我們就實現了一個基本的碰撞盒的調試級別的描繪,如下:

https://www.zhihu.com/video/948199203151757312

為了實現這樣的描繪,我們需要完成如下的工作:

  1. 我們需要在我們的RHI層級實現這些基本圖形的描繪介面
  2. 我們需要在我們的物理引擎當中調用這些基本圖形繪製介面
  3. 我們需要實現一個對這樣的調試描繪進行管理的模塊

調試用圖形介面

那麼首先來看RHI層級。在GraphicsManager.hpp當中,添加如下介面:

#ifdef DEBUG virtual void DrawLine(const Vector3f &from, const Vector3f &to, const Vector3f &color); virtual void DrawBox(const Vector3f &bbMin, const Vector3f &bbMax, const Vector3f &color); virtual void ClearDebugBuffers();#endif

這些介面的定義也是參考了Bullet對於調試介面的要求,以便也能為Bullet使用。具體參考參考引用1

因為是調試用的介面,我們將它們放在DEBUG宏所標出的塊當中。這樣可以減少一點兒Release版本的體積。

接下來我們需要在OpenGL/DX12/Vulkan等各種具體的RHI當中實現這些介面。通過很多篇的豬突猛進,目前我們DX12的進度有些拖後,Vulkan則還沒有開始。這些我準備另外起支線追上來,所以這裡我們只給出OpenGL的實現:

#ifdef DEBUGvoid OpenGLGraphicsManager::DrawLine(const Vector3f &from, const Vector3f &to, const Vector3f &color){ GLfloat vertices[6]; vertices[0] = from.x; vertices[1] = from.y; vertices[2] = from.z; vertices[3] = to.x; vertices[4] = to.y; vertices[5] = to.z; GLuint vao; glGenVertexArrays(1, &vao); // Bind the vertex array object to store all the buffers and vertex attributes we create here. glBindVertexArray(vao); GLuint buffer_id; // Generate an ID for the vertex buffer. glGenBuffers(1, &buffer_id); // Bind the vertex buffer and load the vertex (position and color) data into the vertex buffer. glBindBuffer(GL_ARRAY_BUFFER, buffer_id); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0); m_Buffers.push_back(buffer_id); DebugDrawBatchContext& dbc = *(new DebugDrawBatchContext); dbc.vao = vao; dbc.mode = GL_LINES; dbc.count = 2; dbc.color = color; m_DebugDrawBatchContext.push_back(std::move(dbc));}void OpenGLGraphicsManager::DrawBox(const Vector3f &bbMin, const Vector3f &bbMax, const Vector3f &color){ GLfloat vertices[12 * 2 * 3]; // top vertices[0] = bbMax.x; vertices[1] = bbMax.y; vertices[2] = bbMax.z; vertices[3] = bbMax.x; vertices[4] = bbMin.y; vertices[5] = bbMax.z; vertices[6] = bbMax.x; vertices[7] = bbMin.y; vertices[8] = bbMax.z; vertices[9] = bbMin.x; vertices[10] = bbMin.y; vertices[11] = bbMax.z; vertices[12] = bbMin.x; vertices[13] = bbMin.y; vertices[14] = bbMax.z; vertices[15] = bbMin.x; vertices[16] = bbMax.y; vertices[17] = bbMax.z; vertices[18] = bbMin.x; vertices[19] = bbMax.y; vertices[20] = bbMax.z; vertices[21] = bbMax.x; vertices[22] = bbMax.y; vertices[23] = bbMax.z; // bottom vertices[24] = bbMax.x; vertices[25] = bbMax.y; vertices[26] = bbMin.z; vertices[27] = bbMax.x; vertices[28] = bbMin.y; vertices[29] = bbMin.z; vertices[30] = bbMax.x; vertices[31] = bbMin.y; vertices[32] = bbMin.z; vertices[33] = bbMin.x; vertices[34] = bbMin.y; vertices[35] = bbMin.z; vertices[36] = bbMin.x; vertices[37] = bbMin.y; vertices[38] = bbMin.z; vertices[39] = bbMin.x; vertices[40] = bbMax.y; vertices[41] = bbMin.z; vertices[42] = bbMin.x; vertices[43] = bbMax.y; vertices[44] = bbMin.z; vertices[45] = bbMax.x; vertices[46] = bbMax.y; vertices[47] = bbMin.z; // side 1 vertices[48] = bbMax.x; vertices[49] = bbMax.y; vertices[50] = bbMax.z; vertices[51] = bbMax.x; vertices[52] = bbMax.y; vertices[53] = bbMin.z; // side 2 vertices[54] = bbMin.x; vertices[55] = bbMax.y; vertices[56] = bbMax.z; vertices[57] = bbMin.x; vertices[58] = bbMax.y; vertices[59] = bbMin.z; // side 3 vertices[60] = bbMin.x; vertices[61] = bbMin.y; vertices[62] = bbMax.z; vertices[63] = bbMin.x; vertices[64] = bbMin.y; vertices[65] = bbMin.z; // side 4 vertices[66] = bbMax.x; vertices[67] = bbMin.y; vertices[68] = bbMax.z; vertices[69] = bbMax.x; vertices[70] = bbMin.y; vertices[71] = bbMin.z; GLuint vao; glGenVertexArrays(1, &vao); // Bind the vertex array object to store all the buffers and vertex attributes we create here. glBindVertexArray(vao); GLuint buffer_id; // Generate an ID for the vertex buffer. glGenBuffers(1, &buffer_id); // Bind the vertex buffer and load the vertex (position and color) data into the vertex buffer. glBindBuffer(GL_ARRAY_BUFFER, buffer_id); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0); m_Buffers.push_back(buffer_id); DebugDrawBatchContext& dbc = *(new DebugDrawBatchContext); dbc.vao = vao; dbc.mode = GL_LINES; dbc.count = 24; dbc.color = color; m_DebugDrawBatchContext.push_back(std::move(dbc));}void OpenGLGraphicsManager::ClearDebugBuffers(){ for (auto dbc : m_DebugDrawBatchContext) { glDeleteVertexArrays(1, &dbc.vao); } m_DebugDrawBatchContext.clear(); for (auto buf : m_DebugBuffers) { glDeleteBuffers(1, &buf); }

m_DebugBuffers.clear();}#endif

實現很單純,用了OpenGL的VAO對象創建並保存一次基本幾何體的繪製命令和上下文,並將其加入到專門用於保存調試繪製VAO對象的隊列當中去。然後在我們的Draw方法當中,在繪製完場景之後,檢查這個調試繪製VAO對象隊列,如果有VAO存在,那麼彈出它完成這些調試信息的繪製。下面是Draw()方法當中響應的代碼(實際位置是在子方法RenderBuffers()當中):

glDrawElements(dbc.mode, dbc.count, dbc.type, 0x00); // 繪製場景 }#ifdef DEBUG // Set the color shader as the current shader program and set the matrices that it will use for rendering. glUseProgram(m_debugShaderProgram); SetPerFrameShaderParameters(m_debugShaderProgram); for (auto dbc : m_DebugDrawBatchContext) { SetPerBatchShaderParameters(m_debugShaderProgram, "lineColor", dbc.color); glBindVertexArray(dbc.vao); glDrawArrays(dbc.mode, 0x00, dbc.count); // 繪製調試信息 }#endif

這裡需要注意的就是,如果我們不是採用將調試信息的繪製過程保存在VAO當中,並在Draw()方法的場景繪製之後一起進行繪製,而是直接在DrawLine/DrawBox函數內使用OpenGL立即模式直接繪製的話,會沒有任何輸出。原因我目前還沒有仔細去調查,但是應該是和OpenGL當中的狀態管理以及渲染管線的同步有關。況且,我們這些調試用繪製函數被定義為公共介面,是由外部模塊直接進行調用的。這就是說很可能會有多線程同步的問題。所以,將繪製過程暫且保存為VAO壓入隊列,然後在統一的繪製方法裡面一起繪製是比較好的做法。而且這種做法與DX12/Vulkan當中先生成繪製指令清單然後提交的方法親和性很好,方便後續擴展。

(OpenGL的立即模式是3.2之前的老模式,產生於顯卡內存極為有限的年代,不能很好利用顯存。現在新寫的應用應該避免。3.2之後推薦的就是VAO/VBO這種模式,可以將數據比較好的同步到顯存上,從而提升性能)

碰撞盒綁定

碰撞盒的綁定其實我們在從零開始手敲次世代遊戲引擎(三十八)和從零開始手敲次世代遊戲引擎(三十九)當中已經進行過了,只不過沒有詳細介紹,而且使用的是第三方的Bullet引擎。所以這裡就詳細介紹一下吧。

首先,碰撞盒的綁定一般都是在DCC工具或者引擎的編輯器里完成的。不過目前我們的引擎還沒有編輯器,目前所能支持的OpenGEX場景描述格式在物理導出方面也還沒有官方的定義,所以我就按照OpenGEX當中的自定義語法,通過直接編輯OpenGEX文件加入了碰撞盒。它看起來大致是這麼一個樣子:

Extension (applic = "MyGameEngine", type = "collision") { string {"box"} float[3] // size { {1.0, 1.0, 1.0} } }

另一方面,寫一個完整好用的物理引擎是需要花很多精力和時間的。作為學習目的,我們可以參考Bullet的實現,寫一個基本的物理引擎就好了。為了方便隨時在兩者之間切換,進行結果的對比,我們需要如同RHI一樣,統一不同庫的介面。之前我們是直接在PhysicsManager.{hpp,cpp}當中集成了Bullet,現在讓我們將其改名為BulletPhysicsManager.{hpp,cpp},並從這裡抽象出和我們引擎之間的介面。因為抽象出來的只是一個介面,不帶有任何狀態,所以我有將其改名為IPhysicsManager.hpp,放在Framework/Interface下面。

#pragma once#include <vector>#include "IRuntimeModule.hpp"#include "SceneManager.hpp"namespace My { class IPhysicsManager : implements IRuntimeModule { public: virtual int Initialize() = 0; virtual void Finalize() = 0; virtual void Tick() = 0; virtual void CreateRigidBody(SceneGeometryNode& node, const SceneObjectGeometry& geometry) = 0; virtual void DeleteRigidBody(SceneGeometryNode& node) = 0; virtual int CreateRigidBodies() = 0; virtual void ClearRigidBodies() = 0; virtual Matrix4X4f GetRigidBodyTransform(void* rigidBody) = 0; virtual void UpdateRigidBodyTransform(SceneGeometryNode& node) = 0; virtual void ApplyCentralForce(void* rigidBody, Vector3f force) = 0; }; extern IPhysicsManager* g_pPhysicsManager;}

然後,如同RHI一樣,在項目的根目錄下新建Physics目錄,其中再建立Bullet目錄,將BulletPhysicsManager.{hpp, cpp}移動到其中,並指定上面的IPhysicsManager為其基類。另外再建立My目錄與Bullet目錄平行,其中新建MyPhysicsManager.{hpp, cpp},同樣從IPhysicsManager進行派生。這樣就保證了不同物理引擎的實現都使用同一個介面與我們的引擎進行交互,可以隨時進行替換比較。

#pragma once#include "IPhysicsManager.hpp"#include "Geometry.hpp"namespace My { class MyPhysicsManager : public IPhysicsManager { public: int Initialize(); void Finalize(); void Tick(); void CreateRigidBody(SceneGeometryNode& node, const SceneObjectGeometry& geometry); void DeleteRigidBody(SceneGeometryNode& node); int CreateRigidBodies(); void ClearRigidBodies(); Matrix4X4f GetRigidBodyTransform(void* rigidBody); void UpdateRigidBodyTransform(SceneGeometryNode& node); void ApplyCentralForce(void* rigidBody, Vector3f force); };}

接下來,在MyPhysicsManager的Tick()事件當中,質詢場景管理模塊SceneManager場景是否有變動。SceneManager內部維護了一個DirtyFlag,就是場景是否發生改變的標誌。在從零開始手敲次世代遊戲引擎(三十八)和從零開始手敲次世代遊戲引擎(三十九)當中我們按下鍵盤R鍵場景會進行重置,就是通過將這個DirtyFlag設置為True實現的。因為渲染模塊(GraphicsManager)和物理模塊都會在各自的Tick()事件當中質詢這個狀態,如果發現場景改變,會進入相關狀態的重新初始化。

物理引擎的(重新)初始化是通過下面的代碼實現的:

if (g_pSceneManager->IsSceneChanged()) { ClearRigidBodies(); CreateRigidBodies(); g_pSceneManager->NotifySceneIsPhysicalSimulationQueued(); }

首先我們需要清除之前綁定的碰撞盒(如果有),然後綁定新的碰撞盒。注意這裡的RigidBodies其實是指剛體,而不是碰撞盒。這是因為我們最終其實需要綁定的並不是碰撞盒,而是剛體。碰撞盒只是剛體的實現上的一部分,它所能解決的是碰撞的檢測。但是在碰撞之後,我們往往需要根據運動學原理改變物體的空間位置狀態,這就是剛體了。(當然如果僅僅是檢測子彈是否打中目標這樣,碰撞盒就足夠了)

剛體的定義如下:

#pragma once#include <memory>#include "Geometry.hpp"#include "MotionState.hpp"namespace My { class RigidBody { public: RigidBody(std::shared_ptr<Geometry> collisionShape, std::shared_ptr<MotionState> state) : m_pCollisionShape(collisionShape), m_pMotionState(state) {} std::shared_ptr<MotionState> GetMotionState() { return m_pMotionState; } std::shared_ptr<Geometry> GetCollisionShape() { return m_pCollisionShape; } private: std::shared_ptr<Geometry> m_pCollisionShape; std::shared_ptr<MotionState> m_pMotionState; };}

可以看到其實包括兩個部分:

  1. 碰撞盒(m_pCollisionShape)
  2. 運動狀態(m_pMotionState)

而運動狀態的定義如下:

#pragma once#include "geommath.hpp"namespace My { class MotionState { public: MotionState(Matrix4X4f transition) : m_Transition(transition) {} void SetTransition(const Matrix4X4f& transition) { m_Transition = transition; } const Matrix4X4f& GetTransition() const { return m_Transition; } private: Matrix4X4f m_Transition; };}

可以看到其實就是一個4X4的矩陣。在3維空間當中,任何一個物體在任何一個時間點上的運動狀態都可以分解為下面這兩個物理量:

  1. 空間位置(位移)
  2. 自旋

一個4X4的矩陣可以包括上面這兩種狀態的任意情況。

圖片來自網路 http://www.c-jump.com/bcc/common/Talk3/Math/Matrices/W01_0100_3d_transformations.htm

實際上,如果細看Bullet的代碼,我們可以發現運動狀態除了需要記錄上述的姿態矩陣之外,還需要記錄對象的質心所在的位置。因為物體的自轉是圍繞其質心進行的,對於密度不均一的物體來說,其質心並不位於其幾何的中心。(而渲染的時候是依據幾何中心的)

這裡我們簡便起見,暫時不考慮這個因素。

完成這些數據結構的準備性工作之後,我們就可以為場景物體綁定物理模型(剛體)了。這部分的工作是在OGEX的Parser當中完成的。

這裡需要特別說明的是,我這裡有個假設,就是在遊戲當中很少有需要獨立於場景物體之外的物理模型。所以為了便於管理,我將物理模型保存在了場景結構當中,並由場景管理模塊,而不是物理模塊進行管理。這樣做的好處是可以大大減少其他模塊(比如二次編程介面)需要直接訪問物理引擎的情況,而讓場景管理模塊成為整個引擎的協作分發平台。這種假設和設計是否對於所有遊戲類型都適用,這個我目前不知道。很有可能存在不適用的情況。這也就是為什麼市面上任何一個引擎都有其比較擅長的遊戲類型和比較苦手的遊戲類型的一個設計方面的原因。

AABB盒的計算

AABB盒在之前的文章當中也已經介紹並且圖示過了。然而AABB盒有一個前提,就是其長寬高各自平行於對應的坐標軸。因此,如果物體發生旋轉,那麼AABB盒的大小很可能是會發生改變的。

對於任意幾何體的任意旋轉,計算其新的AABB盒是複雜的。最為直觀(且準確)的方法需要重新遍歷其所有的頂點,找到各個坐標軸上最大最小的取值範圍,從而確定新的AABB盒。

顯然這樣的計算計算量是龐大的,並不適合遊戲這種實時重複計算的情況。於是,再一次的,我們通過犧牲精度的方法求解一個並不錯但不準確的解。方法就是不是對場景物體本身進行計算,而是將場景物體的AABB碰撞盒旋轉之後計算新的AABB碰撞盒。這樣問題一下子就簡化為長方體(立方體)的選擇計算了。進一步,我們知道長方體(立方體)距離幾何中心最遠的點在於其對角的位置,也就是8個頂點當中的任何一個,所以我們可以通過將它的坐標投影到新的坐標軸上獲取旋轉後的長度。(這裡請回憶起來,在線性代數當中,旋轉其實就是變基。因此我們只需要將某個向量點乘新的基向量,就可以得到在新的基向量張成的三維空間當中的坐標值)

inline void TransformAabb(const Vector3f& halfExtents, float margin, const Matrix4X4f& trans, Vector3f& aabbMinOut, Vector3f& aabbMaxOut) { Vector3f halfExtentsWithMargin = halfExtents + Vector3f(margin,margin,margin); Vector3f center; Vector3f extent; GetOrigin(center, trans); Matrix3X3f basis; Shrink(basis, trans); Absolute(basis, basis); DotProduct3(extent, halfExtentsWithMargin, basis); aabbMinOut = center - extent; aabbMaxOut = center + extent; }

全局調試管理模塊

在一個引擎當中,各模塊都有調試信息的輸出。因此在各自的模塊當中分別進行相關的實現是不明智的。況且如果涉及到諸如移動設備或者主機開發這樣的嵌入式開發領域,就如我們之前Android特別篇當中所展示的,開發環境和運行環境往往並不是同一個。因此調試模塊還要起到在兩個環境當中通信的功能。

所以我們這裡新起一個運行時模塊,命名為DebugManager,來對引擎的調試信息進行統一的管理。在本文對應的代碼當中,我們實現了Debug信息的開關(通過鍵盤上的D按鍵),以及對於Debug信息輸出的驅動。

參考引用:

btIDebugDraw Class Referencebulletphysics.org


推薦閱讀:

[數據結構]表達式樹——手動eval()
怎樣在多台Web伺服器上共享Session
沈向洋:You Are What You Write,大家都要看
C++中關於跨平台中子線程式控制制的一些心得(2):用於線程的同步的Async容器
分治法,動態規劃及貪心演算法區別

TAG:遊戲開發 | 物理引擎 | 編程 |