「Vintage Computer Festival Midwest 11.0」 (由 Dave Ruske 製作), CC BY 2.0

[CHIP-8 小東西] 專案架構規劃與 SDL 圖形渲染

在開發 CHIP-8 emulator 前,我們除了背景知識外,還需要先進行架構規劃以及引用需要的函式庫。除了專案架構應該大同小異外,這次專案引入 Simple DirectMedia Layer,能讓開發者輕鬆建立視窗並將像素繪製到畫面上,以達到顯示遊戲畫面之目的。

以下說明是建立在開發環境作業系統是 Windows 11 之下,其他的系統有些許差異。


專案架構以及目錄

目錄結構

先簡單拉個基礎架構,建立以下資料夾來妥善分類檔案:

  • bin:用來存放最終編譯出來的執行檔二進位檔案。
  • build:存放編譯過程中產生的物件檔(Object Files)。
  • include:放置所有的標頭檔。
  • src:放置 C 語言原始碼 (如 main.c)。
  • lib:用來放置外部函式庫檔案。

看起來跟一般的 C 語言專案差不多吧XDD 所以才說大同小異。

在程式碼架構方面,我們會在 include 內建立一個 chip8.h 檔案,用來定義代表 CHIP-8 狀態的資料結構(包含模擬器的記憶體、暫存器、螢幕像素與鍵盤等)。

CHIP-8 硬體規格與設定檔管理

還記得為了這個專案我們先認識了 CHIP-8 硬體規格嗎?

為了避免將寫死的數值散落在程式碼各處,我們會建立一個 config.h 設定檔統一管理靜態資訊。關於這塊的詳細背景資訊可以依前篇文章《》規格進行設計。

以下用記憶體以及顯示螢幕舉例:

  • 記憶體:CHIP-8 語言最多可存取 4KB 的 RAM,位址範圍從 0x0000xFFF。我們可以在設定檔中定義記憶體大小常數為 4096。
  • 顯示螢幕:CHIP-8 的原生顯示解析度極低,只有 64×32 像素。如果將原生尺寸直接呈現在現代桌面螢幕上,畫面會小到幾乎看不見。為了解決這個問題,我們必須設定一個「視窗放大倍率」(Window Multiplier),例如設為 10。透過將寬高乘以倍率,我們能將視窗放大 10 倍,最終得到一個 640x320 像素的視窗,這就像放大圖片一樣,確保玩家能清楚看到畫面。設定檔中也能順便定義視窗的標題,讓後續修改更乾淨俐落。

諸如這些資料就會被寫在 config.h 內。

SDL 函式庫整合與編譯環境設定

SDL(Simple DirectMedia Layer)是一個強大的圖形函式庫,能讓開發者將像素繪製到畫面上,非常符合模擬器開發的需求。

進入官網下載 SDL 開發者版本(Development Libraries)後,需將函式庫整合進專案。

  1. 將 SDL 的標頭檔 (include) 複製到專案的 include 目錄。
  2. .lib.a 等函式庫檔案複製到專案的 lib 目錄。
  3. 確保動態連結檔 SDL2.dll 放置於 bin 資料夾內,也就是執行檔所在的工作目錄中。

整合完的目錄長類似這樣:

Chip-8-Emulator/
├── Makefile
│
├── bin/
│   ├── SDL2.dll
│   └── main.exe
│
├── include/
│   └── SDL2/
│       ├── SDL.h
│       ├── SDL_main.h
│       ├── SDL_events.h
│       ├── SDL_render.h
│       ├── SDL_rect.h
│       ├── SDL_timer.h
│       ├── SDL_video.h
│       └── ...
│
├── lib/
│   ├── libSDL2.a
│   ├── libSDL2.dll.a
│   ├── libSDL2.la
│   ├── libSDL2_test.a
│   ├── libSDL2_test.la
│   ├── libSDL2main.a
│   └── libSDL2main.la
│
└── src/
    └── main.c

在編譯階段(例如透過 MinGW32 自行撰寫 Makefile),必須透過 -L./lib 告訴編譯器尋找函式庫的路徑,並且使用 -lmingw32 -lSDL2main -lSDL2 的指令來正確連結 SDL 函式庫。

INCLUDES= -I ./include
FLAGS= -g

all:
	gcc ${FLAGS} ${INCLUDES} ./src/main.c -L ./lib -lmingw32 -lSDL2main -lSDL2 -o ./bin/main

小試一下是否能正常運作

事件處理與資源管理

視窗建立後如果沒有持續處理操作,程式會立刻結束或是造成視窗沒有回應。因此,我們必須撰寫一個無窮迴圈 while(1) 來維持程式運行,也就是我們現代軟體開發熟悉的 Event Polling。

在迴圈內,我們呼叫 SDL_PollEvent 持續捕捉系統事件(例如滑鼠點擊或鍵盤按壓)。其中最重要的是處理關閉視窗的事件(SDL_QUIT)。

while(1)
{
	SDL_Event event;

	while(SDL_PollEvent(&event))
	{
		if(event.type == SDL_QUIT)
		{
			goto out;
		}
	}
	// 保留後續補充
}

當使用者點擊視窗上的 X 按鈕時,觸發此事件,我們可以使用 C 語言的 goto 語法俐落地跳出迴圈。在程式結束前,務必呼叫 SDL_DestroyWindow 釋放視窗與記憶體資源,確保系統維持穩定。

初始化視窗與繪製圖形

main.c 檔案中,首先呼叫 SDL_Init(SDL_INIT_EVERYTHING) 初始化 SDL 的所有子系統。 接著使用 SDL_CreateWindow 函式,並帶入先前設定的標題與放大後的寬高尺寸(640×320)來建立視窗。

為了在畫面上繪製圖形,需要呼叫 SDL_CreateRenderer 建立渲染器(Renderer)。繪製的流程如下:

  • 清空畫面:使用 SDL_SetRenderDrawColor 將顏色設為黑色(RGB皆為 0),並透過 SDL_RenderClear 塗滿整個視窗背景。
  • 繪製矩形:定義一個 SDL_Rect 指定繪製座標與寬高。將渲染顏色更改為綠色或白色以免圖形融入黑色背景中,接著呼叫 SDL_RenderFillRect 畫出實心矩形。
  • 呈現畫面:最後,必須呼叫 SDL_RenderPresent 將這些修改真實呈現到使用者的視窗上。

主程式類似這樣:

#include <stdio.h>
#include "SDL2/SDL.h"
#include "SDL2/SDL_main.h"

int main(int argc, char** argv) 
{

    SDL_Init(SDL_INIT_EVERYTHING);

    SDL_Window* window = SDL_CreateWindow(
        "Chip8 Window", 
        SDL_WINDOWPOS_UNDEFINED, 
        SDL_WINDOWPOS_UNDEFINED, 
        640, 320, 
        SDL_WINDOW_SHOWN
    );

    SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

    while(1)
    {
        SDL_Event event;

        while(SDL_PollEvent(&event))
        {
            if(event.type == SDL_QUIT)
            {
                goto out;
            }
        }
        
        // 清空畫面(用黑色) -> 繪製矩形 -> 呈現畫面
        SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
        SDL_RenderClear(renderer);
        SDL_SetRenderDrawColor(renderer, 255, 255, 255, 0);
        SDL_Rect r;
        r.x = 0;
        r.y = 0;
        r.w = 40;
        r.h = 40;

        SDL_RenderFillRect(renderer, &r);
        SDL_RenderPresent(renderer);
    }

out:
    SDL_DestroyWindow(window);
    
    return 0;
}

後記

留給自己看的修訂紀錄:

原本在 renderer 初始化那段是 SDL_CreateRenderer(window, -1, SDL_TEXTUREACCESS_TARGET);

然而 SDL_TEXTUREACCESS_TARGET 是建立 Texture 時使用的存取模式,不是 Renderer 的建立旗標。這裡後來改成 SDL_RENDERER_ACCELERATED,雖然最後都只是整數常數,因此編譯器不會報錯,但語意上來說應該放錯地方了。

SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

SDL_TEXTUREACCESS_TARGET 的意思是建立一張可以被當成繪圖目標的 Texture。

例如先把多個圖形畫進 Texture,再一次把整張 Texture 畫到視窗:

SDL_Texture *texture = SDL_CreateTexture(
    renderer,
    SDL_PIXELFORMAT_RGBA8888,
    SDL_TEXTUREACCESS_TARGET,
    640,
    320
);

SDL_SetRenderTarget(renderer, texture);

/* 後續繪圖會畫到 texture,而不是直接畫到視窗。 */
SDL_SetRenderTarget(renderer, NULL);

/* 再將 texture 畫到視窗。 */
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);

References

讓我知道你在想什麼!