はじめに
Part2まででCHIP-8の全命令を実装して、ターミナルに文字で描画できるようになりました。
でもやっぱり...
ウィンドウに描画したい!!
ということで、今回は SDL2 を使ってGUI描画を実装します。
...3日かかりました。こわいですねぇ。
目次
SDL2とは
SDL (Simple DirectMedia Layer) は、グラフィックス、サウンド、入力を扱うためのクロスプラットフォームライブラリです。
ゲーム開発でよく使われていて、以下のような機能があります:
- ウィンドウ作成
- 2D/3D描画
- サウンド再生
- キーボード・マウス・ゲームパッド入力
- タイマー
Rustでは sdl2 クレートで使えます。
環境構築
Cargo.toml
[package]
name = "chip8-emu"
version = "0.1.0"
edition = "2024"
[dependencies]
rand = "0.8"
[dependencies.sdl2]
version = "0.37"
features = ["bundled", "static-link"] # SDL2を自動でビルド
[[bin]]
name = "chip8-gui"
path = "src/gui.rs"
ポイント:bundled feature
bundledを指定すると、SDL2をソースからコンパイルしてくれます。
これがないと、自分でSDL2をインストールしてパスを通す必要があって、Windowsだとかなり面倒...
ウィンドウを作る
定数の定義
// 描画設定
const SCALE: u32 = 15; // 1ピクセルを何倍に拡大するか
const WINDOW_WIDTH: u32 = DISPLAY_WIDTH as u32 * SCALE; // 64 * 15 = 960
const WINDOW_HEIGHT: u32 = DISPLAY_HEIGHT as u32 * SCALE; // 32 * 15 = 480
// カラーテーマ
const COLOR_BG: Color = Color::RGB(0, 0, 0); // 背景色(黒)
const COLOR_FG: Color = Color::RGB(0, 255, 128); // 前景色(緑)
CHIP-8のディスプレイは64×32ピクセルですが、これをそのまま表示すると小さすぎます。
なので 15倍 に拡大して、960×480のウィンドウに描画します。
SDL2の初期化
fn main() -> Result<(), String> {
// SDL2の初期化
let sdl_context = sdl2::init()?;
let video_subsystem = sdl_context.video()?;
let window = video_subsystem
.window("CHIP-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.build()
.map_err(|e| e.to_string())?;
let mut canvas = window.into_canvas().build().map_err(|e| e.to_string())?;
let mut event_pump = sdl_context.event_pump()?;
// ...
}
SDL2は各機能がサブシステムに分かれています:
-
sdl2::init()→ SDLコンテキスト -
.video()→ ビデオサブシステム -
.window()→ ウィンドウ作成 -
.into_canvas()→ 描画用キャンバス -
.event_pump()→ イベント処理
この初期化コードを書くだけで もう1日 かかりました...(ドキュメント読むのに)
ピクセルを描画する
draw_flagの導入
Part2までのコードに draw_flag を追加します:
struct Chip8 {
// ...
draw_flag: bool, // 描画が必要かどうか
}
DRW命令(0xDXYN)とCLS命令(0x00E0)が実行されたときだけ draw_flag = true にします:
(0x0, 0x0, 0xE, 0x0) => {
self.display = [[false; DISPLAY_WIDTH]; DISPLAY_HEIGHT];
self.draw_flag = true; // 描画フラグを立てる
}
(0xD, _, _, _) => {
// スプライト描画処理...
self.draw_flag = true; // 描画フラグを立てる
}
毎フレーム描画するより効率的です。
描画処理
if chip8.draw_flag {
// 背景を黒で塗りつぶす
canvas.set_draw_color(COLOR_BG);
canvas.clear();
// 前景色で点灯ピクセルを描画
canvas.set_draw_color(COLOR_FG);
for (y, row) in chip8.display.iter().enumerate() {
for (x, &pixel) in row.iter().enumerate() {
if pixel {
let rect = Rect::new(
(x as u32 * SCALE) as i32,
(y as u32 * SCALE) as i32,
SCALE,
SCALE,
);
canvas.fill_rect(rect)?;
}
}
}
canvas.present(); // 描画を反映
chip8.draw_flag = false;
}
処理の流れ
-
canvas.clear()で画面を背景色でクリア - display配列をループ
-
pixel == trueならfill_rect()で四角形を描画 -
canvas.present()でバッファを画面に反映
なぜ fill_rect なのか
1ピクセルを1×1の点として描画すると小さすぎるので、SCALE×SCALEの四角形 として描画しています。
CHIP-8の1ピクセル → SDL2の15×15ピクセル
キー入力を処理する
キーマッピング
CHIP-8は16キー(0-9, A-F)を使います。これをキーボードにマッピングします:
CHIP-8のキー配置: キーボード配置:
1 2 3 C 1 2 3 4
4 5 6 D Q W E R
7 8 9 E A S D F
A 0 B F Z X C V
fn keycode_to_chip8(keycode: Keycode) -> Option<u8> {
match keycode {
Keycode::Num1 => Some(0x1),
Keycode::Num2 => Some(0x2),
Keycode::Num3 => Some(0x3),
Keycode::Num4 => Some(0xC),
Keycode::Q => Some(0x4),
Keycode::W => Some(0x5),
Keycode::E => Some(0x6),
Keycode::R => Some(0xD),
Keycode::A => Some(0x7),
Keycode::S => Some(0x8),
Keycode::D => Some(0x9),
Keycode::F => Some(0xE),
Keycode::Z => Some(0xA),
Keycode::X => Some(0x0),
Keycode::C => Some(0xB),
Keycode::V => Some(0xF),
_ => None,
}
}
イベント処理
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => break 'running,
Event::KeyDown {
keycode: Some(Keycode::Space),
..
} => {
chip8.reset();
println!("Reset!");
}
Event::KeyDown {
keycode: Some(keycode),
..
} => {
if let Some(key) = keycode_to_chip8(keycode) {
chip8.key_press(key);
}
}
Event::KeyUp {
keycode: Some(keycode),
..
} => {
if let Some(key) = keycode_to_chip8(keycode) {
chip8.key_release(key);
}
}
_ => {}
}
}
メインループ
タイミング管理
CHIP-8のタイマーは60Hzで動作します。また、描画も60FPSを目標にします。
// エミュレーション速度
const CYCLES_PER_FRAME: u32 = 10; // 1フレームあたりの命令実行数
const TIMER_HZ: u32 = 60;
let frame_duration = Duration::from_micros(1_000_000 / 60); // 60 FPS
let timer_interval = Duration::from_micros(1_000_000 / TIMER_HZ as u64);
let mut last_timer_tick = Instant::now();
メインループの構造
'running: loop {
let frame_start = Instant::now();
// 1. イベント処理
for event in event_pump.poll_iter() {
// ...
}
// 2. CPU実行(1フレームにつき複数サイクル)
for _ in 0..CYCLES_PER_FRAME {
chip8.cycle();
}
// 3. タイマー更新(60Hz)
if last_timer_tick.elapsed() >= timer_interval {
chip8.tick_timers();
last_timer_tick = Instant::now();
}
// 4. 描画
if chip8.draw_flag {
// ...
}
// 5. フレームレート調整
let elapsed = frame_start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
なぜ CYCLES_PER_FRAME が必要なのか
CHIP-8の元々の実行速度は定義されていませんが、一般的なゲームは 500〜1000 Hz くらいで動作することを想定して作られています。
60FPSで描画するなら:
- 1秒 = 60フレーム
- 1フレーム = 約10〜17命令
なので CYCLES_PER_FRAME = 10 にしています。
実行結果
デモプログラム
ROMを指定しないとデモプログラムが動きます:
fn create_demo_program() -> Vec<u8> {
let mut program = Vec::new();
// 00E0 - CLS
program.extend_from_slice(&[0x00, 0xE0]);
// 各文字を描画 (0-F)
let positions = [
(4, 4), (12, 4), (20, 4), (28, 4), // 0 1 2 3
(4, 12), (12, 12), (20, 12), (28, 12), // 4 5 6 7
// ...
];
for (i, &(x, y)) in positions.iter().enumerate() {
// LD V0, i (フォント番号)
program.extend_from_slice(&[0x60, i as u8]);
// LD F, V0
program.extend_from_slice(&[0xF0, 0x29]);
// LD V1, x
program.extend_from_slice(&[0x61, x]);
// LD V2, y
program.extend_from_slice(&[0x62, y]);
// DRW V1, V2, 5
program.extend_from_slice(&[0xD1, 0x25]);
}
// 無限ループ
// ...
}
実行
# デモモード
cargo run --bin chip8-gui
# ROMを指定
cargo run --bin chip8-gui -- path/to/rom.ch8
出力:
🎮 CHIP-8 Emulator with SDL2 GUI
================================
Controls:
1234 → 123C
QWER → 456D
ASDF → 789E
ZXCV → A0BF
ESC → Quit
SPACE → Reset
================================
No ROM specified, running demo...
ROM loaded: 164 bytes
そして、緑色のピクセルで 0〜F の16文字が表示されたウィンドウが開きます!
スクリーンショット
┌─────────────────────────────────────┐
│ │
│ ████ █ ████ ████ │
│ █ █ █ ██ █ │
│ █ █ █ ████ ████ │
│ █ █ █ █ ██ │
│ ████ ████ ████ ████ │
│ │
│ █ █ ████ ████ ████ │
│ █ █ █ █ ██ │
│ ████ ████ ████ ██ │
│ █ █ █ █ ██ │
│ █ ████ ████ ██ │
│ │
│ ████ ████ ████ ████ │
│ █ █ █ █ █ █ █ █ │
│ ████ ████ ████ ████ │
│ █ █ █ █ █ █ █ │
│ █ █ ████ █ █ ████ │
│ │
└─────────────────────────────────────┘
(実際のウィンドウは緑色のピクセルです!)
3日かかった理由
-
SDL2のインストール(1日目)
- Windowsで
SDL2.libを見つけてくれない -
bundledfeatureを発見して解決
- Windowsで
-
座標系の理解(2日目)
- CHIP-8の(x,y)とSDL2の座標系が合わない
- display[y][x] vs display[x][y] で混乱
-
タイミング調整(3日目)
- 描画が速すぎて何も見えない
- フレームレート制御を実装
意外と「ピクセルを打つだけ」じゃなかったですね...
まとめ
今回実装したもの ✅
- SDL2によるウィンドウ描画
- 15倍スケーリング
- キーボードマッピング(16キー)
- 60FPSのメインループ
- タイマー更新(60Hz)
- 描画フラグによる効率化
- リセット機能(Spaceキー)
学んだこと
-
SDL2の
bundledfeatureが便利- 自分でSDL2をインストールしなくていい
- クロスプラットフォームで楽
-
描画は毎フレームやらなくていい
-
draw_flagで必要なときだけ描画 - パフォーマンス向上
-
-
タイミング管理が重要
- CPUサイクル、タイマー、描画は別々に管理
-
DurationとInstantを活用
次回予告
Part4では キー入力とサウンド を本格的に実装します。
FX0A(キー入力待ち)とサウンドタイマーが残っているので...
参考資料
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!
ピクセルを打つのに3日かかった人より、Part4でお会いしましょう!👋