🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

我用 C++ 從零開始建立了一個遊戲引擎(以下是我的心得體會)

我的顯示卡崩潰了 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。

這是我一窺幕後真相的機會。

我的作品:Breakout,但一切都是從零開始打造的

如果你從未玩過打磚塊遊戲:你需要控制螢幕底部的擋板,彈跳小球來消除頂部的磚塊。概念簡單,實作起來卻很複雜。

我的引擎特點:

  • 使用 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 年),指令冗長,而且容錯率很低。但這正是它非常適合學習的原因——你必須理解每一個步驟。

初始化:設定 DirectX 9

要讓視窗顯示任何內容,需要五個主要步驟:

1. 建立 Direct3D9 介面

cpp

IDirect3D9* m_direct3D9 = Direct3DCreate9(D3D_SDK_VERSION);
if (!m_direct3D9) {
    // Failed to create—probably missing DirectX runtime
    return false;
}

這樣就建立了主要的Direct3D物件。可以把它理解為「連接到圖形驅動程式」。

2. 查詢顯示功能

cpp

D3DDISPLAYMODE displayMode;
m_direct3D9->GetAdapterDisplayMode(D3DADAPTER_DEFAULT, &displayMode);

我們需要知道:解析度是多少?顏色格式是什麼?這可以告訴我們顯示器支援哪些功能。

3. 配置演示參數

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:“我希望視窗的後緩衝區是這樣配置的。”

4. 建立設備

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, ...);
}

5. 建立精靈渲染器

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 個磚塊)來說,這無關緊要。

挑戰與解決方案:我的失敗之處(以及我從中學到的教訓)

挑戰 1:建築災難(第三週)

問題:

我犯了一個典型的初學者錯誤:沒有設計就開始寫程式了。

我的第一次嘗試是這樣的:

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 元件、遊戲引擎,甚至作業系統都在使用它。光是這一個教訓就值回三個月的學習時間了。


挑戰2:小球穿過磚塊(隧道)

問題:

我的第一個碰撞偵測程式是這樣的:

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 像素,也不會再出現隧道效應。

我學到了什麼:

  • 離散碰撞偵測(重疊檢查)在高速行駛時失效

  • 連續碰撞偵測(掃描/射線偵測)對於快速移動的物體至關重要。

  • 這就是為什麼遊戲中的子彈使用射線投射而不是重疊檢測的原因。

數學計算很痛苦(需要進行大量的最小值/最大值比較),但理解這個概念改變了我對遊戲中物理現象的看法。


挑戰 3:碰撞偵測 ≠ 碰撞解決

混亂:

我一開始以為「碰撞偵測」和「碰撞解決」是一回事。其實它們不是。

  • 偵測結果= “這兩個物體是否相撞?”

  • 解決方案= “好吧,現在我們該怎麼辦?”

我第一次嘗試是將它們混合在一起的:

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教學經常忽略這一區別——它們適用於簡單情況,但不適用於複雜遊戲。

我本來可以做得更好

如果我從頭開始重新建造它:

  1. 先設計,後編碼(花2-3天時間繪製架構圖)

  2. 使用現代圖形 API (DirectX 11 或 OpenGL 4.5)而不是 DX9。

- DX9 is fine for learning, but dated (no compute shaders, limited pipeline control)
  1. 為實體引擎編寫單元測試(我手動測試時發現了很多本來可以自動化的bug)

  2. 使用實體元件系統 (ECS)取代基於繼承的遊戲物件

  3. 更早加入了除錯疊加層(幀率計數器、碰撞可視化功能幫我節省了數小時的除錯時間)

  4. 從第一天起就進行效能分析(直到第 8 週我才開始衡量效能——浪費時間優化了錯誤的東西)


結論與後續步驟

從零開始建立遊戲引擎非常困難——遠比 Unity 教程中展示的遊戲開發更困難。

但這正是關鍵所在。

我的收穫:

  • 深入理解渲染管線

  • 物理模擬的實務知識

  • 欣賞 Unity/Unreal 引擎所抽象化的東西

  • 有信心除錯底層問題

  • 脫穎而出的作品集作品

接下來會發生什麼事:

  • 將此引擎移植到DirectX 11 (現代管線,計算著色器)

  • 建立一個體素引擎(類似 Minecraft,使用 Three.js 進行 Web 開發)

  • 體驗Vulkan (圖形 API 的終極選擇)

連結:


如果你在考慮自己製造引擎:

去做吧。不是為了取代 Unity,而是為了理解Unity。

你會遇到很多困難。你會在凌晨兩點除錯那些晦澀難懂的錯誤。你會質疑自己為什麼不直接用 Godot。

但是,當你第一次看到那個球彈跳起來——由你的程式碼編譯,由你的管道渲染,與你的物理引擎碰撞——你就會明白為什麼人們說「重新發明輪子是學習輪子如何運作的最佳方法」。

有問題或建議嗎?請在評論區留言! 👇


原文出處:https://dev.to/montmont20z/building-a-game-engine-from-scratch-using-c-my-breakout-clone-journey-20d1


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝7   💬6   ❤️2
174
🥈
我愛JS
💬1  
6
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付