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

[CHIP-8 小東西] 實作虛擬硬體(螢幕顯示)

在完成 CHIP-8 的系統初始化與內建字元集後,接下來就要開始建立真正能顯示畫面的螢幕模組啦!這一階段會先使用布林二維陣列保存 64x32 的像素狀態,再加入像素設定、讀取功能。最後,透過 SDL 將開啟的像素依照視窗倍率放大並繪製到畫面上,讓模擬器首次具備完整的螢幕資料儲存與顯示能力。


螢幕資料結構

為了在模擬器中表示 CHIP-8 的螢幕,我們需要建立一個新的標頭檔 chip8_screen.h

由於 CHIP-8 是單色顯示,螢幕上的像素點只有「開啟」與「關閉」兩種狀態,因此我們可以引入 <stdbool.h>,並在螢幕結構體中宣告一個布林二維陣列來代表像素。

陣列的寬度與高度,則直接引用設定檔中定義的 CHIP8_WIDTHCHIP8_HEIGHT

struct chip8_screen 
{
    bool pixels[CHIP8_HEIGHT][CHIP8_WIDTH];
};

這裡將高度放在第一個索引、寬度放在第二個索引,因此後續存取像素時會使用:screen->pixels[y][x]

建立完成後,記得將螢幕結構加入主要的 chip8 結構體中:

struct chip8
{
	...
    struct chip8_screen screen;
};

實作像素存取與邊界防護

設定與讀取函式

建立好資料結構後,我們在 chip8_screen.c 中實作兩個核心函式:

  • 設定像素chip8_screen_set 接收 X 與 Y 座標,並將陣列中對應位置的像素設為開啟狀態,也就是 true
  • 檢查狀態chip8_screen_is_set 回傳指定 X 與 Y 座標的像素狀態,讓主程式判斷該位置是否有像素亮起。
#include "chip8_screen.h"
#include <assert.h>

void chip8_screen_set(struct chip8_screen* screen, int x, int y) 
{
    screen->pixels[y][x] = true;
}

bool chip8_screen_is_set(struct chip8_screen* screen, int x, int y) 
{
    return screen->pixels[y][x];
}

邊界檢查

和先前的記憶體與鍵盤模組相同,我們引入 <assert.h>,並建立一個名為 chip8_screen_check_bounds 的靜態函式。

static void chip8_screen_check_bounds(int x, int y) 
{
    assert(x >= 0 && x < CHIP8_WIDTH && y >= 0 && y < CHIP8_HEIGHT);
}

這個函式會檢查傳入的座標是否位於螢幕範圍內:

  • X 必須大於或等於 0,且小於 CHIP8_WIDTH
  • Y 必須大於或等於 0,且小於 CHIP8_HEIGHT

接著,將邊界檢查加入像素設定與讀取函式中,確保所有像素存取都位於合法範圍內。

#include "chip8_screen.h"
#include <assert.h>

void chip8_screen_set(struct chip8_screen* screen, int x, int y) 
{
    chip8_screen_check_bounds(x, y);  // 加入檢查
    screen->pixels[y][x] = true;
}

bool chip8_screen_is_set(struct chip8_screen* screen, int x, int y) 
{
    chip8_screen_check_bounds(x, y);  // 加入檢查
    return screen->pixels[y][x];
}

主程式整合與 SDL 渲染機制

完成螢幕模組並將其新增至編譯清單後,我們就可以回到主程式,改寫原本的 SDL 渲染邏輯。

每次準備繪製新的畫面時,都需要先清除上一幀的內容。因此,背景清除與繪製顏色設定應放在主迴圈內,但位於雙重像素迴圈之外。

基本流程如下:
清除上一幀畫面 -> 設定像素繪製顏色 -> 遍歷 CHIP-8 螢幕像素 -> 繪製開啟的像素 -> 呈現完整畫面

雙重迴圈走訪陣列

在主迴圈中,我們使用兩個 for 迴圈走訪整個螢幕陣列。
X 座標的範圍為 0CHIP8_WIDTH - 1,Y 座標的範圍則為 0CHIP8_HEIGHT - 1
在迴圈內呼叫 chip8_screen_is_set。如果該位置的像素處於開啟狀態,就執行對應的繪製操作。

for (int x = 0; x < CHIP8_WIDTH; x++)
{
	for (int y = 0; y < CHIP8_HEIGHT; y++)
	{   
		if (chip8_screen_is_set(&chip8.screen, x, y))
		{
			...
		}
	}
}

視窗放大倍率(Multiplier)的應用

CHIP-8 的原始解析度只有 64x32。如果直接按照一比一的比例呈現在現代桌面螢幕上,畫面會非常小,幾乎無法辨識。

因此,我們需要使用先前設定的視窗放大倍率(Multiplier),將每個 CHIP-8 像素繪製成較大的矩形。

當放大倍率為 10 時,CHIP-8 的一個像素會被繪製成 10x10 的矩形,原始 X 與 Y 座標都需要乘上放大倍率,64x32 的顯示區域會被放大為 640x320

完整邏輯如下:

for (int x = 0; x < CHIP8_WIDTH; x++)
{
	for (int y = 0; y < CHIP8_HEIGHT; y++)
	{   
		if (chip8_screen_is_set(&chip8.screen, x, y))
		{
			SDL_Rect r;
			r.x = x * CHIP8_WINDOW_MULTIPLIER;
			r.y = y * CHIP8_WINDOW_MULTIPLIER;
			r.w = CHIP8_WINDOW_MULTIPLIER;
			r.h = CHIP8_WINDOW_MULTIPLIER;

			SDL_RenderFillRect(renderer, &r);
		}
	}
}

將清除畫面與呈現畫面的流程一併加入後,渲染部分大致如下:

...

SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 0);

for (int x = 0; x < CHIP8_WIDTH; x++)
{
	...
}

SDL_RenderPresent(renderer);

...

每次主迴圈更新畫面時,都會先清除上一幀,再根據 chip8.screen 中保存的像素狀態重新繪製畫面。

系統測試

為了驗證實作是否正確,我們可以在主程式中初始化 CHIP-8 實例後,手動呼叫 chip8_screen_set,將座標 (40, 20) 的像素設為開啟狀態。

int main(int argc, char** argv) 
{
	...
    chip8_screen_set(&chip8.screen, 40, 20);
    ...
}

當放大倍率為 10 時,這個像素會被繪製在視窗座標 (400, 200),並顯示為一個 10x10 的矩形。

重新編譯並執行程式後,如果能在視窗中的正確位置看到放大後的像素方塊,就表示螢幕儲存陣列、像素存取函式與 SDL 渲染機制都已經成功整合,可以準備進入下一個階段了!


References

讓我知道你在想什麼!