:從ECS架構(gòu)到物理碰撞系統(tǒng)實現(xiàn))
1. 項目概述為什么選擇C構(gòu)建游戲引擎如果你問一個干了十年游戲開發(fā)的老兵用什么語言做引擎最“硬核”十有八九會告訴你C。這可不是什么情懷而是實打?qū)嵉男阅堋⒖刂屏蜕鷳B(tài)決定的。我最近剛帶著團隊從零擼了一個輕量級的2D游戲引擎核心目標就是驗證一套清晰、高效的架構(gòu)并實現(xiàn)一套可靠的物理碰撞系統(tǒng)。整個過程下來感觸最深的就是用C寫引擎就像親手打造一把瑞士軍刀每一個零件你都知道怎么用為什么這么用出了問題也能立刻找到癥結(jié)。這個項目標題“C游戲開發(fā)實戰(zhàn)從引擎架構(gòu)到物理碰撞”精準地概括了從宏觀設(shè)計到微觀實現(xiàn)的兩個核心層次。引擎架構(gòu)決定了你的游戲世界如何被組織、更新和渲染是骨骼和神經(jīng)系統(tǒng)而物理碰撞則是這個世界互動規(guī)則的具體體現(xiàn)是肌肉和觸覺。兩者結(jié)合才能創(chuàng)造出一個既有秩序又有生動交互的虛擬世界。市面上很多教程要么只講高大上的架構(gòu)圖要么只教你怎么調(diào)用某個物理庫的函數(shù)很少把“為什么這么設(shè)計架構(gòu)”和“碰撞檢測具體怎么算出來的”串起來講透。我這篇分享就想填補這個缺口結(jié)合我們實際趟過的坑把從頂層模塊劃分到底層向量運算、碰撞響應(yīng)的完整鏈條給你捋清楚。適合誰來讀呢首先是有一定C基礎(chǔ)對游戲開發(fā)有濃厚興趣不滿足于只使用Unity、Unreal等成熟引擎想深入了解背后機制的朋友。其次是那些在中小型團隊面臨需要自研或深度定制引擎場景的開發(fā)者。當(dāng)然如果你正在學(xué)習(xí)數(shù)據(jù)結(jié)構(gòu)、設(shè)計模式想看看它們在一個真實、復(fù)雜的系統(tǒng)中如何應(yīng)用這里也有很多活生生的例子。2. 引擎核心架構(gòu)設(shè)計與模塊解耦引擎不是一個大泥球而應(yīng)該像一臺精密的鐘表各個齒輪模塊各司其職通過清晰的接口咬合在一起。我們的設(shè)計目標是高內(nèi)聚、低耦合、易擴展。經(jīng)過幾輪迭代最終穩(wěn)定下來的核心架構(gòu)主要包含以下幾個層次。2.1 應(yīng)用層與平臺抽象層最上層是應(yīng)用層也就是你的具體游戲邏輯。它不應(yīng)該關(guān)心窗口是用GLFW還是SDL創(chuàng)建的也不應(yīng)該直接調(diào)用OpenGL的API。它的眼里只有“精靈”、“場景”、“事件”這些游戲概念。這就引出了其下的平臺抽象層。這一層的核心職責(zé)是“屏蔽差異”。我們抽象出了幾個關(guān)鍵接口窗口管理創(chuàng)建、銷毀窗口處理尺寸變化。輸入系統(tǒng)將鍵盤、鼠標、手柄的原生事件統(tǒng)一封裝成引擎內(nèi)部的事件對象。圖形上下文初始化OpenGL或Vulkan等圖形API管理渲染狀態(tài)。文件系統(tǒng)提供統(tǒng)一的資源圖片、聲音、配置文件加載接口無論資源在磁盤、內(nèi)存還是網(wǎng)絡(luò)包中。這么做的最大好處是可移植性。當(dāng)我們需要從Windows移植到macOS甚至某個嵌入式平臺時只需要實現(xiàn)一套新的平臺抽象層實現(xiàn)上層的游戲邏輯代碼幾乎無需改動。我們在項目中使用了GLFW處理窗口和輸入用Glad加載OpenGL函數(shù)指針它們就被封裝在這一層。2.2 核心系統(tǒng)層ECS架構(gòu)的取舍與實現(xiàn)這是引擎的“大腦”。關(guān)于如何組織游戲?qū)ο髽I(yè)界主要有兩種范式傳統(tǒng)的面向?qū)ο罄^承層次和現(xiàn)在越來越流行的實體-組件-系統(tǒng)架構(gòu)。我們最終選擇了ECS但不是那種極致的、緩存友好的“純ECS”而是一種更務(wù)實、更易于理解的“輕量級ECS”。為什么選擇ECS傳統(tǒng)的繼承樹比如GameObject-RenderableObject-Sprite在項目后期很容易變得僵化。一個既需要渲染、又需要物理模擬、還需要播放音效的對象它的類會繼承自多個基類導(dǎo)致“鉆石繼承”等復(fù)雜問題且難以動態(tài)增減能力。我們的ECS實現(xiàn)如下實體就是一個唯一的ID例如一個整數(shù)它本身沒有任何數(shù)據(jù)或行為僅僅是一個標識符。組件是純粹的數(shù)據(jù)結(jié)構(gòu)。例如TransformComponent位置、旋轉(zhuǎn)、縮放、SpriteComponent紋理、顏色、UV坐標、RigidbodyComponent質(zhì)量、速度、碰撞形狀。系統(tǒng)是純粹的邏輯處理器。它遍歷所有擁有特定組件組合的實體并對它們執(zhí)行操作。例如RenderSystem遍歷所有擁有TransformComponent和SpriteComponent的實體將它們繪制到屏幕上PhysicsSystem則處理擁有TransformComponent和RigidbodyComponent的實體。我們實現(xiàn)了一個簡單的Registry注冊表來管理實體和組件。組件以std::vector或std::unordered_map存儲雖然不是完全連續(xù)的但對于我們目標規(guī)模的2D游戲來說性能完全足夠且代碼清晰易懂。實操心得ECS的“度”完全照搬《守望先鋒》那種為了極致性能的ECS對于大多數(shù)中小項目是過度設(shè)計。我們的“輕量ECS”在清晰度和靈活性上取得了很好的平衡。關(guān)鍵在于將“數(shù)據(jù)”組件和“邏輯”系統(tǒng)徹底分離這個思想比具體的實現(xiàn)方式更重要。2.3 資源管理與渲染管線資源管理看似簡單實則坑很多。核心原則是避免重復(fù)加載和統(tǒng)一生命周期管理。我們實現(xiàn)了一個AssetManager單例內(nèi)部使用std::unordered_mapstd::string, std::shared_ptrAsset來緩存所有加載過的紋理、著色器、音效等。通過std::shared_ptr進行引用計數(shù)當(dāng)沒有任何游戲?qū)ο笠靡粋€資源時它會在合適的時機被釋放。渲染管線是引擎的“視覺輸出流水線”。我們的2D渲染相對簡單但依然遵循標準流程清屏清除顏色緩沖和深度緩沖。設(shè)置全局狀態(tài)如混合模式Blending、深度測試對于2.5D游戲。批次渲染這是2D渲染性能的關(guān)鍵。我們將所有使用同一張紋理或圖集的精靈的渲染數(shù)據(jù)頂點、矩陣收集起來通過一次Draw Call提交給GPU。這需要自己維護一個動態(tài)頂點緩沖區(qū)VBO和索引緩沖區(qū)IBO。后處理可選步驟如全屏模糊、顏色校正等通過渲染到幀緩沖區(qū)Framebuffer再繪制到屏幕來實現(xiàn)。我們?yōu)榫`渲染編寫了簡單的著色器Shader頂點著色器主要負責(zé)將本地頂點坐標通過模型矩陣來自TransformComponent變換到世界空間再通過投影矩陣變換到裁剪空間片段著色器主要負責(zé)紋理采樣和顏色混合。3. 物理引擎核心碰撞檢測的數(shù)學(xué)與實現(xiàn)物理引擎是讓游戲世界“活”起來的關(guān)鍵。我們決定不集成龐大的Box2D或Bullet而是自己實現(xiàn)一套基礎(chǔ)的2D碰撞檢測與響應(yīng)系統(tǒng)目的是為了徹底理解其原理。這套系統(tǒng)主要包含碰撞檢測和碰撞響應(yīng)兩部分。3.1 基礎(chǔ)幾何與碰撞體表示一切始于數(shù)學(xué)。我們定義了Vec2二維向量類重載了加減乘除、點積、叉積、歸一化等基本運算。這是所有物理計算的基礎(chǔ)。碰撞體我們用組件ColliderComponent來表示并設(shè)計了不同的形狀圓形碰撞體存儲圓心位置和半徑。計算最簡單。軸對齊包圍盒存儲左上角和右下角坐標。對于不旋轉(zhuǎn)的物體效率極高。定向包圍盒存儲中心點、半寬高和旋轉(zhuǎn)角度。更精確計算更復(fù)雜。凸多邊形存儲頂點列表。最通用也最復(fù)雜。在項目初期我們主要使用AABB和圓形后期為了支持旋轉(zhuǎn)的物體引入了OBB。3.2 分離軸定理多邊形碰撞檢測的萬能鑰匙對于AABB和圓形碰撞檢測有直接的公式。但對于OBB和多邊形我們使用了經(jīng)典的分離軸定理。SAT的原理很直觀如果能找到一條直線軸使得兩個多邊形的投影在此軸上完全不重疊那么這兩個多邊形就一定沒有碰撞。這條直線就是“分離軸”。對于凸多邊形我們只需要檢查每個多邊形的每條邊的法線方向作為潛在的分離軸即可。實現(xiàn)步驟獲取兩個多邊形A和B的所有邊。對每條邊計算其法線向量垂直于邊。將多邊形A和B的所有頂點分別投影到這條法線軸上得到兩個投影區(qū)間[minA, maxA]和[minB, maxB]。檢查這兩個區(qū)間是否重疊。如果不重疊則發(fā)現(xiàn)分離軸立即返回“無碰撞”。如果檢查了所有潛在的分離軸區(qū)間都有重疊則判定為碰撞。在碰撞發(fā)生時SAT還能順便計算出最小穿透向量即為了讓兩個物體分開需要施加的最小位移方向和深度。這個向量是后續(xù)碰撞響應(yīng)的關(guān)鍵輸入。注意事項浮點數(shù)精度在比較投影區(qū)間是否重疊時直接使用或可能會因為浮點數(shù)精度問題導(dǎo)致誤判。我們引入了一個很小的容差值epsilon如1e-7判斷條件改為if (maxA minB - epsilon || maxB minA - epsilon)提高了穩(wěn)定性。3.3 碰撞響應(yīng)基于沖量的分辨率檢測到碰撞后下一步是讓物體做出合理的反應(yīng)比如彈開、滑動或停止。我們采用了基于沖量的動力學(xué)響應(yīng)這比簡單地移動位置更符合物理規(guī)律能很好地處理多個物體連續(xù)碰撞的情況。核心公式是沖量公式J -(1 e) * (V_rel · N) / (1/mA 1/mB)其中J是沖量的大小標量。e是恢復(fù)系數(shù)彈性0為完全非彈性1為完全彈性。V_rel是兩物體在碰撞點處的相對速度。N是碰撞法線從A指向B的單位向量。mA,mB是兩物體的質(zhì)量。得到?jīng)_量J后我們可以計算出沖量向量impulse J * N。然后分別更新兩個物體的線速度velocityA - impulse / mA velocityB impulse / mB // 注意方向B受到的是反向沖量對于有旋轉(zhuǎn)的剛體還需要考慮角速度的變化計算會涉及轉(zhuǎn)動慣量和碰撞點到質(zhì)心的向量這里為了簡化先不展開。這種方法的優(yōu)點是能量和動量守恒處理得比較好能模擬出真實的碰撞反彈效果。我們在此基礎(chǔ)上還加入了簡單的靜摩擦和動摩擦模型讓物體在斜坡上能停住或滑下。4. 架構(gòu)與物理的整合PhysicsSystem的工作流設(shè)計好了架構(gòu)實現(xiàn)了碰撞算法如何將它們優(yōu)雅地結(jié)合起來這就是PhysicsSystem的職責(zé)。它在每一幀的游戲循環(huán)中在InputSystem之后、RenderSystem之前被調(diào)用。它的工作流程是一個典型的“更新-檢測-響應(yīng)”循環(huán)積分階段遍歷所有擁有RigidbodyComponent和TransformComponent的實體。根據(jù)受到的力重力、推力等和上一幀的速度使用歐拉積分或更精確的Verlet積分計算出新的速度和位置并更新TransformComponent。// 簡化版歐拉積分 void PhysicsSystem::integrate(float dt) { for (auto entity : entities_with_rigidbody) { auto rb entity.getRigidbodyComponent(); auto transform entity.getTransformComponent(); // F m * a - a F / m glm::vec2 acceleration rb.force * rb.invMass; rb.velocity acceleration * dt; transform.position rb.velocity * dt; rb.force glm::vec2(0, 0); // 清空力 } }廣義檢測階段這不是簡單的兩兩檢測O(n2)復(fù)雜度太可怕。我們使用了空間分割技術(shù)來優(yōu)化。對于2D場景我們采用了均勻網(wǎng)格。將世界空間劃分為固定大小的單元格每個物體根據(jù)其AABB被放入一個或多個單元格中。這樣碰撞檢測只需要檢查同一單元格或相鄰單元格內(nèi)的物體對復(fù)雜度大幅降低。窄相位檢測與響應(yīng)階段對廣義檢測篩選出的潛在碰撞對進行精確的SAT檢測或其他形狀檢測。如果發(fā)生碰撞則計算碰撞法線和穿透深度并調(diào)用基于沖量的響應(yīng)邏輯修正物體的速度和位置有時需要做一個小的位置修正來避免物體嵌入。約束求解可選處理像關(guān)節(jié)、繩子之類的約束關(guān)系。我們項目初期沒有實現(xiàn)復(fù)雜的約束但預(yù)留了接口。將物理系統(tǒng)模塊化后游戲邏輯應(yīng)用層只需要給物體添加RigidbodyComponent和ColliderComponent并設(shè)置質(zhì)量、彈性等參數(shù)剩下的全部由PhysicsSystem自動完成。這種設(shè)計使得游戲玩法邏輯非常干凈。5. 性能優(yōu)化與調(diào)試技巧實錄自己動手實現(xiàn)物理引擎性能是繞不開的坎。以下是我們在項目中遇到并解決的一些典型問題。5.1 空間分割的粒度選擇使用均勻網(wǎng)格時網(wǎng)格單元格的大小需要仔細權(quán)衡。如果格子太大每個格子里物體太多優(yōu)化效果不明顯如果格子太小一個物體會占據(jù)太多格子插入和查詢的成本增加。經(jīng)驗法則網(wǎng)格大小最好與場景中典型物體的平均尺寸相當(dāng)。我們通過統(tǒng)計所有碰撞體的平均寬度和高度來動態(tài)設(shè)置初始網(wǎng)格大小并提供了一個運行時調(diào)整的參數(shù)。動態(tài)網(wǎng)格 vs 靜態(tài)網(wǎng)格我們的世界是固定的所以使用了靜態(tài)網(wǎng)格。如果世界很大或者物體移動范圍廣可以考慮四叉樹或動態(tài)網(wǎng)格。5.2 幀率與物理步長的解耦游戲渲染幀率FPS可能是波動的但物理模擬需要一個穩(wěn)定的時間步長如固定的60Hz否則會出現(xiàn)“子彈時間”或“快進”效應(yīng)導(dǎo)致物理不穩(wěn)定。 我們采用了固定時間步長與插值渲染的策略物理更新在一個獨立的循環(huán)中以固定的deltaTime如1.0/60.0秒進行。如果一幀真實時間過去了0.1秒物理系統(tǒng)可能會更新6次0.1 / (1/60) 6。渲染時物體的位置不再是當(dāng)前物理狀態(tài)的位置而是根據(jù)上一物理幀和當(dāng)前物理幀的狀態(tài)進行線性插值得到的位置。這樣即使物理更新頻率和渲染頻率不同畫面也能保持平滑。5.3 常見的碰撞異常與排查物體抖動或高速穿過原因通常是因為物體速度太快一幀移動的距離超過了其自身尺寸導(dǎo)致從“未碰撞”直接穿越到“已穿過”錯過了碰撞檢測。解決使用連續(xù)碰撞檢測。在窄相位檢測中不僅檢測靜態(tài)位置還檢測從上一幀位置到當(dāng)前幀位置之間的運動線段或 swept shape是否與對方發(fā)生碰撞。計算會更復(fù)雜但對于子彈、高速移動的玩家角色是必要的。堆疊物體不穩(wěn)定原因多個物體堆疊時一幀內(nèi)可能會發(fā)生多次碰撞如果響應(yīng)順序不當(dāng)會導(dǎo)致能量異常增加物體“彈飛”。解決使用迭代求解。在一幀內(nèi)對檢測到的所有碰撞對多次如3-5次應(yīng)用沖量響應(yīng)讓碰撞能量和穿透深度逐步、均勻地分解掉?;蛘呤褂酶呒壍奈恢眯拚椒ㄈ珥樞驔_量。性能熱點排查工具我們大量使用了std::chrono在代碼中打點測量每個系統(tǒng)特別是PhysicsSystem的耗時。發(fā)現(xiàn)SAT檢測是瓶頸之一。優(yōu)化對SAT進行了微優(yōu)化比如提前計算并緩存多邊形的邊和法線對圓形和AABB碰撞使用更快的專用函數(shù)而不是通用的SAT在GPU允許的情況下甚至可以嘗試將一些簡單的碰撞檢測如大批量子彈放到計算著色器中。調(diào)試物理引擎時可視化是神器。我們實現(xiàn)了一個簡單的調(diào)試繪制模式可以開關(guān)。在這個模式下RenderSystem會額外繪制出所有碰撞體的形狀輪廓線框、碰撞法線、速度向量等。一眼就能看出碰撞檢測是否準確響應(yīng)方向是否正確。6. 項目構(gòu)建、依賴管理與現(xiàn)代C實踐一個可維護的C項目離不開好的工程實踐。我們放棄了單一的main.cpp采用了更現(xiàn)代的方式。6.1 使用CMake進行跨平臺構(gòu)建CMake現(xiàn)在是C項目構(gòu)建的事實標準。我們的CMakeLists.txt主要做了以下幾件事聲明項目最低版本和C標準我們用了C17。使用add_subdirectory引入第三方庫如GLFW、Glad或者使用find_package查找系統(tǒng)已安裝的庫。將引擎源碼編譯為靜態(tài)庫add_library(Engine STATIC ...)。將游戲示例代碼編譯為可執(zhí)行文件并鏈接引擎庫和第三方庫。處理不同平臺Windows/macOS/Linux的特定鏈接庫和編譯選項。這樣無論是在Visual Studio、Xcode還是CLion中都能一鍵生成項目文件并編譯。6.2 第三方庫的選擇與管理我們盡量避免重復(fù)造輪子明智地選擇第三方庫GLFW處理窗口、上下文和輸入。輕量級API簡潔。Glad用于加載OpenGL函數(shù)指針。比GLEW更輕便可以自定義需要加載的擴展。GLM數(shù)學(xué)庫。提供向量、矩陣等運算完美匹配GLSL語法不可或缺。spdlog日志庫。異步日志性能好格式美觀。entt一個高性能的ECS庫。在項目后期我們評估了entt它的性能和靈活性遠超我們的簡易實現(xiàn)。對于新項目我強烈建議直接使用entt而非自己造輪子。我們使用Git子模塊git submodule來管理這些第三方庫確保團隊每個成員都能獲取到完全一致的版本。6.3 現(xiàn)代C特性的應(yīng)用在項目中我們有意識地使用現(xiàn)代C特性來提升代碼安全性和表達力智能指針資源管理全部使用std::unique_ptr和std::shared_ptr基本杜絕了內(nèi)存泄漏。移動語義在AssetManager返回資源、Registry創(chuàng)建實體時使用移動構(gòu)造來避免不必要的拷貝。Lambda表達式與算法在系統(tǒng)遍歷實體時大量使用std::for_each配合lambda代碼更緊湊。類型安全使用enum class代替舊式枚舉為實體ID和組件類型ID定義強類型。最后這個自研引擎項目帶給我的遠不止一套可運行的代碼。它更像一次深度的“解剖”練習(xí)讓我對游戲引擎這個黑盒子里到底發(fā)生了什么有了肌肉記憶般的理解。當(dāng)你再使用Unity或Unreal時你看待Rigidbody、Collider、System這些組件的視角會完全不同你能更精準地預(yù)判性能瓶頸更高效地進行調(diào)試。如果你也有興趣深入游戲開發(fā)的內(nèi)核我建議你從一個小而美的目標開始比如“用C和OpenGL渲染一個可以跳跳跳的方塊并讓它和地面碰撞”親手實現(xiàn)一遍這個過程你收獲的會比讀十篇教程都多。