我的顯示卡崩潰了 47 次,螢幕上才出現第一個三角形。
三個月的時間裡,我用 C++ 從零開始,基於 DirectX 9 和 Win32 建立了一個遊戲引擎——沒有 Unity,沒有 Unreal,也沒有中間件。只有我、Windows API,以及大量的段落錯誤。
這個故事講述了我如何透過建立一個簡單的打磚塊遊戲克隆版,學到了比多年使用 Unity 更多的遊戲開發、圖形程式設計和軟體架構方面的知識。
多年來,我一直用 Unity 開發遊戲。我拖放遊戲物件,附加腳本,點擊執行,然後看著我的遊戲活靈活現地執行起來。那感覺很神奇——直到它不再神奇為止。
一些問題開始在我腦海中盤旋:
Unity 究竟是如何渲染我的精靈的?從GameObject.transform.position = newPos到螢幕上的像素之間發生了什麼?
為什麼人們會抱怨虛幻引擎的性能?如果它已經「優化」過了,為什麼開發者仍然會遇到問題?
為什麼《坎巴拉太空計畫》的物理引擎有這麼多bug?它用的是Unity引擎──Unity不是應該自動處理物理效果嗎?
我意識到自己在使用功能強大的工具,卻不了解它們背後的工作原理。我就像一個廚師在使用微波爐,卻不知道熱量是如何真正烹飪食物的。
然後我的大學教授給我們佈置了一個作業:用 C++ 建立一個底層遊戲引擎。
沒有使用 Unity,沒有使用任何函式庫,只有 C++、DirectX 9 和 Win32 API。
這是我一窺幕後真相的機會。
如果你從未玩過打磚塊遊戲:你需要控制螢幕底部的擋板,彈跳小球來消除頂部的磚塊。概念簡單,實作起來卻很複雜。
我的引擎特點:
使用 DirectX 9 的自訂渲染管線
固定時間步長遊戲循環(目標幀率為 60 FPS)
AABB 和掃描碰撞檢測(無隧道效應!)
狀態管理系統(選單 → 等級 1 → 等級 2 → 等級 3 → 遊戲結束)
音響系統集成
精靈動畫系統
物理模擬(速度、加速度、碰撞響應)
結果:一個完全可玩的打磚塊遊戲克隆版,以 60 FPS 的幀率執行,約 3500 行 C++ 程式碼。
技術棧:
語言:C++17
圖形 API:DirectX 9(雖然是舊版本,但非常適合學習基礎)
視窗:Win32 API
音訊:Windows 多媒體擴充
整合開發環境:Visual Studio 2022
我最大的教訓之一是:好的建築設計決定著專案的成敗。
我費了好大勁才弄清楚這一點(下文「挑戰」部分會詳細介紹),但這是我最終確定的結構:

遊戲課程(編曲家)
擁有所有管理器(渲染器、輸入、實體、聲音)
管理遊戲狀態轉換(選單↔關卡1↔遊戲結束)
執行主遊戲循環
我的視窗(平台圖層)
封裝了 Win32 視窗建立和訊息處理功能
處理作業系統層級的事件(關閉、最小化、調整大小)
為什麼要分離?平台程式碼應該隔離——這樣以後移植到 Linux/Mac 會更容易。
渲染器(圖形圖層)
初始化 DirectX 9 設備
管理紋理和精靈
提供簡潔的 API: LoadTexture() 、 DrawSprite() 、 BeginFrame()
關鍵洞察:遊戲邏輯從未直接操作 DirectX。
輸入管理器(使用者輸入)
使用 DirectInput 輪詢鍵盤狀態
將原始輸入抽象化為對遊戲有意義的查詢: IsKeyDown(DIK_LEFT)
為什麼?遊戲程式碼並不關心DirectInput——它只需要“左”或“右”的輸入。
PhysicsManager(碰撞與運動)
AABB碰撞檢測
掃描AABB演算法適用於快速移動物體(防止隧道效應)
以補償為手段解決衝突
**學習:**檢測和解析度要分開(我一開始並不知道這一點!)
SoundManager(音訊)
載入並播放音效
處理循環播放的背景音樂。
音量控制
IGameState(狀態模式)
所有遊戲狀態的介面:選單、關卡1、關卡2、遊戲結束、你贏
每個狀態都實作了: OnEnter() 、 Update() 、 Render() 、 OnExit()
這就是我的「頓悟」時刻——下文將詳細闡述。
cpp
while (window.ProcessMessages()) {
// 1. Calculate delta time (frame-independent movement)
float dt = CalculateDeltaTime();
// 2. Update input state
inputManager.Update();
// 3. Update current game state
// (Menu, Level, GameOver, etc.)
gameState->Update(dt, inputManager, physicsManager, soundManager);
// 4. Render everything
renderer.BeginFrame();
gameState->Render(renderer);
renderer.EndFrame();
}
為什麼採用這種結構?
模組化:每個系統只有一個功能
可測試性:無需渲染即可測試物理效果
可維護性:渲染出現錯誤?只需查看渲染器類別即可。
可擴展性:新增新的遊戲狀態?只需實作 IGameState 即可。
DirectX 9 的名聲不太好:它很老舊(發佈於 2002 年),指令冗長,而且容錯率很低。但這正是它非常適合學習的原因——你必須理解每一個步驟。
要讓視窗顯示任何內容,需要五個主要步驟:
cpp
IDirect3D9* m_direct3D9 = Direct3DCreate9(D3D_SDK_VERSION);
if (!m_direct3D9) {
// Failed to create—probably missing DirectX runtime
return false;
}
這樣就建立了主要的Direct3D物件。可以把它理解為「連接到圖形驅動程式」。
cpp
D3DDISPLAYMODE displayMode;
m_direct3D9->GetAdapterDisplayMode(D3DADAPTER_DEFAULT, &displayMode);
我們需要知道:解析度是多少?顏色格式是什麼?這可以告訴我們顯示器支援哪些功能。
cpp
D3DPRESENT_PARAMETERS m_d3dPP = {};
m_d3dPP.Windowed = TRUE; // Windowed mode (not fullscreen)
m_d3dPP.BackBufferWidth = width; // 800 pixels
m_d3dPP.BackBufferHeight = height; // 600 pixels
m_d3dPP.BackBufferFormat = D3DFMT_UNKNOWN; // Match desktop format
m_d3dPP.BackBufferCount = 1; // Double buffering
m_d3dPP.SwapEffect = D3DSWAPEFFECT_DISCARD; // Throw away old frames
m_d3dPP.EnableAutoDepthStencil = TRUE; // We need depth testing
m_d3dPP.AutoDepthStencilFormat = D3DFMT_D16; // 16-bit depth buffer
而現代 API(Vulkan、DX12)在這方面就變得更複雜了。你實際上是在告訴 GPU:“我希望視窗的後緩衝區是這樣配置的。”
cpp
HRESULT hr = m_direct3D9->CreateDevice(
D3DADAPTER_DEFAULT, // Use default GPU
D3DDEVTYPE_HAL, // Hardware acceleration
hWnd, // Window handle
D3DCREATE_HARDWARE_VERTEXPROCESSING, // Use GPU for vertex math
&m_d3dPP,
&m_d3dDevice
);
我在這裡崩潰了47次。參數錯誤?崩潰。格式不支援?崩潰。缺少深度緩衝區?崩潰。
回退策略:如果硬體頂點處理失敗(較舊的GPU),則回退到軟體頂點處理:
cpp
if (FAILED(hr)) {
// Try again with CPU-based vertex processing
hr = m_direct3D9->CreateDevice(..., D3DCREATE_SOFTWARE_VERTEXPROCESSING, ...);
}
cpp
ID3DXSprite* m_spriteBrush;
D3DXCreateSprite(m_d3dDevice, &m_spriteBrush);
DirectX 9 的ID3DXSprite是一個 2D 遊戲的輔助函數。它可以批量繪製精靈並處理變換。
初始化完成後,每一幀都遵循以下模式:
cpp
void Renderer::BeginFrame() {
// Clear the screen to black
m_d3dDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,
D3DCOLOR_XRGB(0, 0, 0), 1.0f, 0);
m_d3dDevice->BeginScene(); // Start recording draw calls
m_spriteBrush->Begin(D3DXSPRITE_ALPHABLEND); // Enable alpha blending for sprites
}
void Renderer::DrawSprite(const SpriteInstance& sprite) {
// Apply transformations (position, rotation, scale)
D3DXMATRIX transform = CalculateTransform(sprite);
m_spriteBrush->SetTransform(&transform);
// Draw the texture
m_spriteBrush->Draw(sprite.texture, &sourceRect, nullptr, nullptr, sprite.color);
}
void Renderer::EndFrame() {
m_spriteBrush->End(); // Finish sprite batch
m_d3dDevice->EndScene(); // Stop recording
m_d3dDevice->Present(...); // Flip backbuffer to screen (VSYNC happens here)
}
關鍵概念:雙緩衝
我們先將影像繪製到「後緩衝區」(螢幕外),然後使用Present()將其與螢幕的前緩衝區交換。這樣可以防止畫面撕裂(看到只繪製了一半的畫面)。
效能提示:每次DrawSprite()函數的開銷都比較大。在實際的遊戲引擎中,通常會將數百個精靈批量繪製,以減少繪製次數。但對於打磚塊遊戲(最多只有大約 50 個磚塊)來說,這無關緊要。
問題:
我犯了一個典型的初學者錯誤:沒有設計就開始寫程式了。
我的第一次嘗試是這樣的:
cpp
class Game {
Renderer renderer;
InputManager input;
// OH NO—game logic mixed into Game class!
Paddle paddle;
Ball ball;
Brick bricks[50];
void Update() {
// Handle input
if (input.IsKeyDown(LEFT)) paddle.x -= 5;
// Update physics
ball.x += ball.velocityX;
// Check collisions
for (auto& brick : bricks) {
if (CollidesWith(ball, brick)) {
brick.alive = false;
}
}
// ...300 more lines of spaghetti code
}
};
一切都很順利——直到我需要加入菜單螢幕。
我突然意識到:如何在選單和關卡1之間切換?
我的程式碼沒有「狀態」的概念。所有內容都硬編碼到一個巨大的Update()函數中。新增選單意味著:
將所有內容包裹在if (currentState == PLAYING)中
選單和遊戲玩法的輸入處理方式重複。
管理哪些物件存在
簡直一團糟。我才寫了兩週,就得全部重寫。
解決方案:狀態模式
我向我的講師(以及 ChatGPT)尋求建議。答案是:狀態模式。
cpp
// Interface that all game states implement
class IGameState {
public:
virtual void OnEnter(GameServices& services) = 0;
virtual void Update(float dt, ...) = 0;
virtual void Render(Renderer& renderer) = 0;
virtual void OnExit(GameServices& services) = 0;
};
現在每個螢幕都是獨立的類別:
cpp
class MenuState : public IGameState { /* menu logic */ };
class Level1 : public IGameState { /* level 1 logic */ };
class GameOverState : public IGameState { /* game over logic */ };
Game類別只是將狀態委託給目前狀態:
cpp
class Game {
std::unique_ptr<IGameState> currentState;
void Update(float dt) {
currentState->Update(dt, ...); // Let the state handle it
}
void ChangeState(std::unique_ptr<IGameState> newState) {
if (currentState) currentState->OnExit(...);
currentState = std::move(newState);
if (currentState) currentState->OnEnter(...);
}
};
我學到了什麼:
先設計後編碼(尤其對於超過 1000 行程式碼的專案)
關注點分離使程式碼更靈活
重構雖然痛苦,但比第一次就做對更有收穫。
狀態模式無所不在——React 元件、遊戲引擎,甚至作業系統都在使用它。光是這一個教訓就值回三個月的學習時間了。
問題:
我的第一個碰撞偵測程式是這樣的:
cpp
if (OverlapsAABB(ball, brick)) {
brick.alive = false;
ball.velocityY = -ball.velocityY; // Bounce
}
以 60 幀/秒的速度執行時效果很好……直到球移動速度過快為止。
高速運動時,球會穿過磚塊-在兩格畫面之間完全穿過磚塊:
Frame 1: Ball is here → [ ]
↓
Frame 2: Ball is here [ ] ← Ball skipped the brick!
小球移動了 50 像素,但磚塊只有 32 像素寬。到下一幀時,小球已經越過了磚塊,因此重疊檢查返回 false。
第一個失敗的解決方案:較小的時間步長
我嘗試將物理更新頻率從每秒 60 次提高到每秒 120 次。這有幫助,但並沒有解決問題——在非常高的速度下,仍然會發生隧道效應。
真正的解決方案:掃描AABB
我需要持續的碰撞檢測——不僅要檢查“它們現在是否重疊?”,還要檢查“它們在本幀運動過程中是否會在任何時候重疊? ”
這被稱為掃描AABB (或稱射線掃描框)。我不再檢查球的當前位置,而是將球的運動視為一條射線:
cpp
bool SweepAABB(
Vector3 ballPos, Vector2 ballSize,
Vector3 displacement, // Where the ball will move this frame
Vector3 brickPos, Vector2 brickSize,
float& timeOfImpact, // When in [0,1] does collision happen?
Vector3& hitNormal // Which side did we hit?
) {
// Calculate when the ball's edges cross the brick's edges
float xEntryTime = ...; // Math for X-axis entry
float yEntryTime = ...; // Math for Y-axis entry
float overallEntry = max(xEntryTime, yEntryTime);
if (overallEntry < 0 || overallEntry > 1) {
return false; // No collision this frame
}
timeOfImpact = overallEntry;
return true;
}
現在我的碰撞循環看起來像這樣:
cpp
Vector3 displacement = ball.velocity * dt;
float toi;
Vector3 normal;
if (SweepAABB(ball, displacement, brick, toi, normal)) {
// Move ball to exactly the collision point
ball.position += displacement * toi;
// Bounce
if (normal.x != 0) ball.velocity.x = -ball.velocity.x;
if (normal.y != 0) ball.velocity.y = -ball.velocity.y;
brick.alive = false;
}
結果:即使每秒 1000 像素,也不會再出現隧道效應。
我學到了什麼:
離散碰撞偵測(重疊檢查)在高速行駛時失效
連續碰撞偵測(掃描/射線偵測)對於快速移動的物體至關重要。
這就是為什麼遊戲中的子彈使用射線投射而不是重疊檢測的原因。
數學計算很痛苦(需要進行大量的最小值/最大值比較),但理解這個概念改變了我對遊戲中物理現象的看法。
混亂:
我一開始以為「碰撞偵測」和「碰撞解決」是一回事。其實它們不是。
偵測結果= “這兩個物體是否相撞?”
解決方案= “好吧,現在我們該怎麼辦?”
我第一次嘗試是將它們混合在一起的:
cpp
if (OverlapsAABB(ball, paddle)) {
ball.velocityY = -ball.velocityY; // This is resolution!
}
這導致了程式錯誤:
球會「黏」在球拍上。
一幀內多次碰撞會相互抵消。
重疊的物體會振動
解決方案:分階段進行
cpp
// Phase 1: Detection (PhysicsManager)
bool hit = SweepAABB(ball, paddle, timeOfImpact, normal);
// Phase 2: Resolution (also PhysicsManager, but separate function)
if (hit) {
// Move ball to contact point
ball.position += displacement * timeOfImpact;
// Apply restitution (bounciness)
ball.velocity = Reflect(ball.velocity, normal) * restitution;
}
我學到了什麼:
物理引擎的檢測和反應是獨立的系統。
這種分離方式可以適應複雜的場景(多重重疊、傳送帶、單向平台)。
YouTube教學經常忽略這一區別——它們適用於簡單情況,但不適用於複雜遊戲。
如果我從頭開始重新建造它:
先設計,後編碼(花2-3天時間繪製架構圖)
使用現代圖形 API (DirectX 11 或 OpenGL 4.5)而不是 DX9。
- DX9 is fine for learning, but dated (no compute shaders, limited pipeline control)
為實體引擎編寫單元測試(我手動測試時發現了很多本來可以自動化的bug)
使用實體元件系統 (ECS)取代基於繼承的遊戲物件
更早加入了除錯疊加層(幀率計數器、碰撞可視化功能幫我節省了數小時的除錯時間)
從第一天起就進行效能分析(直到第 8 週我才開始衡量效能——浪費時間優化了錯誤的東西)
從零開始建立遊戲引擎非常困難——遠比 Unity 教程中展示的遊戲開發更困難。
但這正是關鍵所在。
我的收穫:
深入理解渲染管線
物理模擬的實務知識
欣賞 Unity/Unreal 引擎所抽象化的東西
有信心除錯底層問題
脫穎而出的作品集作品
接下來會發生什麼事:
將此引擎移植到DirectX 11 (現代管線,計算著色器)
建立一個體素引擎(類似 Minecraft,使用 Three.js 進行 Web 開發)
體驗Vulkan (圖形 API 的終極選擇)
連結:
原文發表於Hashnode
如果你在考慮自己製造引擎:
去做吧。不是為了取代 Unity,而是為了理解Unity。
你會遇到很多困難。你會在凌晨兩點除錯那些晦澀難懂的錯誤。你會質疑自己為什麼不直接用 Godot。
但是,當你第一次看到那個球彈跳起來——由你的程式碼編譯,由你的管道渲染,與你的物理引擎碰撞——你就會明白為什麼人們說「重新發明輪子是學習輪子如何運作的最佳方法」。
有問題或建議嗎?請在評論區留言! 👇