3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ピクセルを打つだけなのに3日かかった話 Part3

Last updated at Posted at 2025-12-09

Part1 | Part2 | Part3


はじめに

Part2まででCHIP-8の全命令を実装して、ターミナルに文字で描画できるようになりました。

でもやっぱり...

ウィンドウに描画したい!!

ということで、今回は SDL2 を使ってGUI描画を実装します。

...3日かかりました。こわいですねぇ。


目次

  1. SDL2とは
  2. 環境構築
  3. ウィンドウを作る
  4. ピクセルを描画する
  5. キー入力を処理する
  6. メインループ
  7. 実行結果

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;
}

処理の流れ

  1. canvas.clear() で画面を背景色でクリア
  2. display配列をループ
  3. pixel == true なら fill_rect() で四角形を描画
  4. 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日かかった理由

  1. SDL2のインストール(1日目)

    • WindowsでSDL2.libを見つけてくれない
    • bundled featureを発見して解決
  2. 座標系の理解(2日目)

    • CHIP-8の(x,y)とSDL2の座標系が合わない
    • display[y][x] vs display[x][y] で混乱
  3. タイミング調整(3日目)

    • 描画が速すぎて何も見えない
    • フレームレート制御を実装

意外と「ピクセルを打つだけ」じゃなかったですね...


まとめ

今回実装したもの ✅

  • SDL2によるウィンドウ描画
  • 15倍スケーリング
  • キーボードマッピング(16キー)
  • 60FPSのメインループ
  • タイマー更新(60Hz)
  • 描画フラグによる効率化
  • リセット機能(Spaceキー)

学んだこと

  1. SDL2のbundledfeatureが便利

    • 自分でSDL2をインストールしなくていい
    • クロスプラットフォームで楽
  2. 描画は毎フレームやらなくていい

    • draw_flagで必要なときだけ描画
    • パフォーマンス向上
  3. タイミング管理が重要

    • CPUサイクル、タイマー、描画は別々に管理
    • DurationInstantを活用

次回予告

Part4では キー入力とサウンド を本格的に実装します。

FX0A(キー入力待ち)とサウンドタイマーが残っているので...


参考資料


この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

ピクセルを打つのに3日かかった人より、Part4でお会いしましょう!👋

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?