Rubyで書いたゲームボーイエミュレータをブラウザ上で動くようにした
はじめに
自作のRuby製ゲームボーイエミュレータ(Ruby Boy)を、WebAssemblyを使ってブラウザ上で動くようにしました!
リポジトリはこちら
Ruby Boyの紹介記事はこちら
この記事
どのようにしてRuby Boyをブラウザ上で動かしているかを説明します。
Rubyプログラムをブラウザ上で動かしたい人も参考になると思います。
システムの概要
Ruby製ファミコンエミュレータのOptcarrotをWasmで動かしているoptcarrot.wasmの実装を参考にしています。
+----------------+ DOM Events +----------------+
| index.html | (Keyboard & ROM) | index.js |
| (Browser) |--------------------------->| (Main Thread) |
| |<---------------------------| |
| | Frame Data (ImageData) | |
+----------------+ +----------------+
| ^
| |
Keyboard | | Frame Data
States | | (ArrayBuffer)
v |
+----------------+ +----------------+
| rubyboy.wasm | Keyboard States | worker.js |
| (GB Emulator) |<---------------------------| (Game Thread) |
| |--------------------------->| |
| | Frame Data (Uint8Array) | |
+----------------+ +----------------+
- index.js: メインスレッド。キーボード入力やROMファイル入力をWorkerに送り、Workerから送られてくるフレームデータでcanvasを更新する
- worker.js: ワーカースレッド。メインスレッドからの入力イベントを処理し、Ruby Boyを実行してフレームデータをメインスレッドへ送る
- rubyboy.wasm: Wasmパッケージ化したRuby Boy。ゲームボーイをエミュレートしてフレームデータを生成する
Ruby BoyのWasm化
Ruby ProgramのWasmパッケージ化は、以下の2つのステップで行います:
- CRubyをWasmにbuildしてruby.wasmを作る
- ruby.wasmにRuby Programをpackして専用のwasmを作る
依存しているgemのインストールはbuild時に事前に行います。図で表すと以下のとおりです。
+--------------+
| gems | build
| + | --------->> ruby.wasm ─┐
| CRuby | |
+--------------+ | pack
+ -------->> ruby_with_program.wasm
+--------------+ |
| Ruby Program | -----------------------┘
+--------------+
このbuildとpackにはruby_wasm gemを使います。
ruby_wasm gemやjs gem(後述)、npm packages、build済みのバイナリなどは以下のリポジトリでまとめて公開されています。
buildする
ruby_wasm gemを使って、依存gemを取り込んだwasmファイルを作ります。
ブラウザ上で動かしたい場合はjs gemも必要なので、合わせてインストールしておきます。
$ bundle add ruby_wasm js
$ bundle exec rbwasm build --ruby-version 3.3 -o ruby-js.wasm
Tips: 必要なgemのみbuildする
rbwasm build
を実行するとGemfile.lock
にある依存しているgemも一緒にbuildされます。rspecやrubocopなどのビルドする必要がないgemを対象から外すためには、RubyWasm::Packager::EXCLUDED_GEMS
に不要なgemを指定したうえで、コマンドを直接実行します。
Ruby Boyの場合はjs gem以外不要なので、以下のようにjs gem以外の全てのgemをEXCLUDED_GEMS
に入れています。
require 'bundler/setup'
require 'ruby_wasm'
require 'ruby_wasm/cli'
# Exclude all gems except the 'js' gem for packaging
command = %w[build --ruby-version 3.3 -o ./docs/ruby-js.wasm]
definition = Bundler.definition
excluded_gems = definition.resolve.materialize(definition.requested_dependencies).map(&:name)
excluded_gems -= %w[js]
RubyWasm::Packager::EXCLUDED_GEMS.concat(excluded_gems)
RubyWasm::CLI.new(stdout: $stdout, stderr: $stderr).run(command)
参考:
packする
上で作ったruby-js.wasmにRuby Boyのコードをpackします。
# ./lib配下にあるRuby Boyのコードをpackする
$ bundle exec rbwasm pack ruby-js.wasm --dir ./lib::/lib -o rubyboy.wasm
rubyboy.wasmが完成したので、ブラウザ上で動かします。
ブラウザ上で動かす
システム構成図(再掲)
+----------------+ DOM Events +----------------+
| index.html | (Keyboard & ROM) | index.js |
| (Browser) |--------------------------->| (Main Thread) |
| |<---------------------------| |
| | Frame Data (ImageData) | |
+----------------+ +----------------+
| ^
| |
Keyboard | | Frame Data
States | | (ArrayBuffer)
v |
+----------------+ +----------------+
| rubyboy.wasm | Keyboard States | worker.js |
| (GB Emulator) |<---------------------------| (Game Thread) |
| |--------------------------->| |
| | Frame Data (Uint8Array) | |
+----------------+ +----------------+
下部のworker.jsとrubyboy.wasmの処理内容について説明します。
VMの初期化
import { DefaultRubyVM } from 'https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.0/dist/browser/+esm';
const response = await fetch('./rubyboy.wasm');
const module = await WebAssembly.compileStreaming(response);
const { vm, wasi } = await DefaultRubyVM(module);
vm.eval(`
require 'js'
require_relative 'lib/executor'
$executor = Executor.new
`);
this.vm = vm;
this.rootDir = wasi.fds[3].dir;
Worker内でrubyboy.wasmをDefaultRubyVMメソッドに渡してVMを作ります。このVM上でRuby Boyのコードを実行します。
wasi.fds[3].dir
はDefaultRubyVMがpreopenしてくれているルートディレクトリを表すMapです。
このMapを使って、VMへのROMデータの送信及びVMからのフレームデータの受信を行います。
ゲーム画面を描画する
sendPixelData() {
this.vm.eval(`$executor.exec(${this.directionKey}, ${this.actionKey})`);
const file = this.rootDir.contents.get('video.data');
const bytes = file.data;
postMessage({ type: 'pixelData', data: bytes.buffer }, [bytes.buffer]);
}
WorkerがVM上でRuby BoyのExecutor#exec
を実行し、/video.dataに書き込まれたフレームデータを読み込んでindex.jsにpostします。index.jsは受信したフレームデータでcanvasを更新します。これを繰り返すことでゲーム画面を描画できます。
Ruby BoyのExecutor#exec
の実装は以下のとおりです。
def exec(direction_key = 0b1111, action_key = 0b1111)
bin = @emulator.step(direction_key, action_key).pack('V*')
File.binwrite('/video.data', bin)
end
execメソッドは現在のボタン入力(方向キー, A, B, Start, Select)を受け取ってゲームボーイをエミュレートし、フレームデータを/video.dataに書き込みます。
Ruby Boyではフレームデータを[0xff555555]
のようなABGR形式のUint32配列で管理しており、これを.pack('V*')
することで[0x55, 0x55, 0x55, 0xff]
のようなcanvas用のRGBA形式のUint8配列に変換しています。
おわりに
自作のRuby製ゲームボーイエミュレータ(Ruby Boy)を、WebAssemblyを使ってブラウザ上で動くようにしました!
ruby.wasmは日々進化しており、Mastodonがブラウザ上で動作するなど熱いプロジェクトです。Rubyプログラムをブラウザ上で動かすのは楽しいので皆様もぜひ。
参考資料
Discussion