undefined

bokuweb.me

Go Conference 2019 Autumn「GoでつくるGameBoyエミュレータ」を発表してきた

表題の通りGo Conference 2019 Autumnで発表させていただきました。運営・スタッフの方々、スピーカーの方々、スポンサーの方々、発表を聞きに来てくださった方々、懇親会でお話させていただいた方々ありがとうございました。非常に楽しかったです。

今回は発表資料の補足や、質問いただいた内容の回答、補足などを記事としてまとめておこうと思い記事にしてみることにしました。

発表資料

発表資料は以下です。

speakerdeck.com

補足

発表がかなり駆け足になってしまったのと、資料だけではよくわからない箇所があると思うので補足を以下にあげていきます。

DEMO

このページから遊ぶことができると思います。

bokuweb.github.io

さぼってページに載せていないんですがキーマップは以下です。

keyboard game pad
← button
↑ button
↓ button
→ button
Z A button
X B button
Enter Start button
Backspace Select button

スマートフォンの場合は画面上のGameBoyの各ボタンがタッチできるようになっています。 デフォルトでプレイできるのはtobutobugirlという2017年製のオープンソースGameBoyソフトです。

github.com

スペック

f:id:bokuweb:20191106015520p:plain

ブロック図

LR35902に色々詰まっているのが印象的です。なので基板上で目につく大物部品はLR35902RAM2個くらいじゃないでしょうか。ファミコンの場合CPUPPU(ピクチャープロセッシングユニット)が別パッケージになっていたため、CPUからPPUの持つVRAMにアクセスするのがとても面倒でしたが、その点が解消されており、物理的にもソフト的にもシンプルになっています。

f:id:bokuweb:20191106015541p:plain

その辺は以下の記事でも触れているので参照してみてください。

blog.bokuweb.me

レジスタ

Fはフラグを管理する特殊なレジスタです。 また基本8bitレジスタですがBCをくっつけてBCの16bitレジスタとして扱うことが可能です。

f:id:bokuweb:20191106015612p:plain

f:id:bokuweb:20191106015621p:plain

CPUの基本動作

乱暴な言い方をするとCPUはずっとこのステップを繰り返しているだけです。実際には割り込みの処理などがありますが、割り込み処理も各ステップ実行時に割り込みフラグをチェックして割り込みがかかっていたら指定番地へジャンプするただけなので、難しい処理ではありません。

f:id:bokuweb:20191106015645p:plain

f:id:bokuweb:20191106015712p:plain

フェッチ

CPUは次に実行すべき命令が何かを知る必要があります。なので次に実行すべき命令の番地を記録しておくプログラムカウンタ(以下PC)の指し先からリードを行います。これはカートリッジ内のROMかもしれませんし、どこかのRAMかもしれません。

f:id:bokuweb:20191106015730p:plain

リードを行った後はPCをインクリメントし、次の命令orデータを指すようにします。

この例はPC0x1000番地を指しており、LD B, 0xA5というBレジスタに即値0xA5を格納する命令が入っている様子です。この命令のコードは0x06なのでPCの指し先には0x06がその次の番地にはデータとして0xA5が格納されています。

f:id:bokuweb:20191106023444p:plain

デコード

CPUは読んできたコードが何か調べる必要があります。今回の例で言うと0x06が何者かを調べる必要があります。自分は命令コードを渡すと命令の情報が引き出せる配列を用意しました。

f:id:bokuweb:20191106015819p:plain

f:id:bokuweb:20191106015837p:plain

今回は情報として、オペランドのサイズ、実行サイクル数、命令実行関数を引き出せるようにしています。 オペランドのサイズはこのCPUの場合0~2です。オペランドのサイズに応じてフェッチするようにしています。

f:id:bokuweb:20191106102540p:plain

発表時点でforでいいところ、なぜかswitchで書かれており、@Linda_ppが指摘してくれました。ありがとうございました。

実行

あとは実行するだけです。この例はレジスタB0xA5に格納するだけなので一行で済んでしまいます。「なんだ簡単じゃないか」と思われた方もいるかと思いますが、まさにそのとおりでこのCPUは乗算・除算命令もなくデータの移動、加算、減算、ジャンプなどの単純な命令をちまちま実装していくだけで完成します。

f:id:bokuweb:20191106015939p:plain

ただ、数が多いのでその点はちょっと大変です。このCPUでいうと命令数は500弱くらいでしょうか。最初の数命令は動き始めると楽しいんですが、加算や減算をひたすら追加していくルーチンワークになると非常にだるくなります。

CPUを実行した際の返り値としてCPUの実行サイクル数を返しています。この値を元にGPUをどれだけ稼働させるかを決定するので、この値はCPU-GPU間の同期をとるための重要な値となります。

f:id:bokuweb:20191106020003p:plain

メモリマップ

色がついている箇所がカセット内のメモリ領域ですね。swichableと書いてあるのは、この領域はバンク方式をとっているので、ある特定の処理を行うことでその領域がごそっと切り替わることになります。ある特定の処理というのはROM領域バンク番号を書き込むなどの気持ち悪い処理なんですが、よく取られていた方法のようです。

f:id:bokuweb:20191106020046p:plain

省略されていますが上位のほうには割り込みLCDタイマーなどのペリフェラルに関する領域が取られています。

あと個人的に自分が気をつけていることですが、メモリマップの情報はCPUが知ることがないように気をつけています。CPU自身はデバイスの詳細を知る必要がないはずで、CPU自身がメモリマップの情報を知ってしまうとCPUのてスタビリティが下がるので別のモジュールに切り出してCPUにインジェクトできるような構成を取っています。

自分は単体テストは以下のように書いてみました。

func setupCPU(offset types.Word, data []byte) (*CPU, *mocks.MockBus) {
    b := mocks.MockBus{}
    b.SetMemory(offset, data)
    irq := interrupt.NewInterrupt()
    l := logger.NewLogger(logger.LogLevel("Debug"))
    return NewCPU(l, &b, irq), &b
}

func TestLDn_nn(t *testing.T) {
    assert := assert.New(t)
    cpu, _ := setupCPU(0, []byte{0x01, 0xDE, 0xAD})
    cpu.PC = 0x00
    cpu.Step()
    assert.Equal(byte(0xAD))
    assert.Equal(byte(0xDE))
}

この部分をコードに落とすには基本的にはアドレスの範囲に応じてアクセス先のデバイスを変更する単純な処理になります。たとえばリードであれば以下のような感じです。

f:id:bokuweb:20191106022515p:plain

GPUの描画タイミング

画面の右端まで描画を行ったら次ライン描画までのブランク期間HBlankという期間があります。この期間を含めると1ラインあたり456クロックとなります。

f:id:bokuweb:20191106020247p:plain

また、表示領域を描画し終わってから次の描画を始めるまでのブランク期間VBlankという期間が10ライン分あります。

f:id:bokuweb:20191106020303p:plain

これらを考慮すると1フレームあたり70,224クロックで完了する計算になります。 この数字は重要な数字でCPUと同期をとるために必要となります。

GPUの基本動作

GPUは稼働するクロック数を入力し、それらをカウントアップ。クロックが1ライン分の456クロック積算されるごとに描画処理を行うような作りとしました。

f:id:bokuweb:20191106020323p:plain

具体的には以下のタイミングで各処理を行うようにしました。

  • 表示領域内
  • 表示領域描画完了
  • VBlank完了
表示領域内

表示領域内、すなわちg.ly < 144の場合。g.lyというのは0xFF44番地に配置されているLCD Y座標レジスタです。CPUはこのレジスタを読むことで現在GPUが液晶の何ライン目を描画しているのかを知ることができます。

表示領域内であれば背景を1ライン分組み立てるようにしています。

f:id:bokuweb:20191106020345p:plain

表示領域描画完了

表示領域描画完了時にはスプライトをまとめて描画しています。スプライトというのは所謂マリオやクリボーなどのキャラクター画像で、背景の上にキャラクターを最大40個配置することが可能です。

f:id:bokuweb:20191106020420p:plain

文章だとわかりにくいですが、以下のGPUデータの可視化デモを見てもらえると分かりやすいかもしれません。

bokuweb.github.io

本来スプライトも各ライン毎に描画していくべきなのですが、エミュレータの場合はさぼって表示領域描画完了のタイミングにまとめて全てのスプライトを描画してしまっています。

「ではなぜ背景も同じようにまとめて描画しないのか」という疑問があると思うのですがライン毎にスクロール量が変更される可能性があるためです。

具体例として、スーパーマリオランドでマリオが画面右側へ進んでいくとどんどん画面がスクロールしていくと思うのですが、スコアやタイマを表示しているヘッダ(と勝手に呼びます)はスクロールせずに固定したままになっています。これはヘッダ部分をスクロール量0で描画した後にスクロール量を変更しているためです。

よって、表示領域描画完了時にまとめて背景を描画するような処理にするとこのような挙動が再現できなくなってしまいます。

また資料では割愛していますが、このタイミングでVBlank期間に入ることを通知するための割り込み処理が必要となります。この割り込みが何故必要かと言うと、描画中にVRAMを変更すると画像が乱れる可能性があるためで、基本的にはVRAMVBkank中に触ることになているためです。

VBlank完了

g.lyを先頭の0に戻したり、VBlank割り込みフラグを解除したりします。

f:id:bokuweb:20191106020545p:plain

背景の描画

8Byteで8x8サイズのスプライトが1枚分作れます。16Byteで8x8サイズのスプライトを2枚作り足し合わせることで、色情報が3bitのタイルが表現できます。

f:id:bokuweb:20191106020631p:plain

タイルデータ

タイルデータを格納する領域は決められており、VRAM内の0x8000~0x97FF番地に格納されることになっています。格納された順にタイル番号がふられます。

f:id:bokuweb:20191106020758p:plain

タイルマップ

背景を作るにはタイルマップと呼ばれる領域、具体的には0x9800~0x9BFFに先のタイル番号を敷き詰めていくことになります。タイルマップ32x32タイルすなわち256X256px分の領域が確保されており、その一部が表示領域として切り取られLCDに表示されます。

f:id:bokuweb:20191106020812p:plain

なぜ余分な領域があるかというと、スムーズなスクロールに対応するためだと思います。当時のスペックだとVRAMへのアクセスはかなり遅い上に、先述したように書き込みできるタイミングは限られてしまいます。

VBlank中に多くの背景を書き換えることは不可能なため、256X256pxの領域から表示分160X144pxを切り取って表示しているものと思われます。

これは以下のツイートのタイルマップを見ると分かりやすいかもしれません。

また、発表では割愛しましたがタイルマップは二面分の領域が確保されており、どちらを選択することはレジスタを編集することで切り替えることが可能です。

スクロール

表示領域を256x256pxの領域から切り出せるということを説明しましたが、どの領域を切り出すかをScrollX(0xFF43番地)ScrollY(0xFF42番地)で設定することができます。 f:id:bokuweb:20191106020845p:plain

図は一番シンプルな例ですが、前述したようにスクロール量は描画の途中でも編集が可能なため、この図でいうところの青枠の切り取り領域はかならずしも1つの四角形になるとは限りません。

背景の描画

背景の描画データの組み立ては泥臭くやるしかなくて基本的には以下の手順です。

  • 現在の描画座標から、タイルの座標を割り出す
  • タイルの座標から該当するタイルマップのアドレスを算出する
  • タイルマップからタイル番号を読み出す
  • タイル番号からタイルデータを引いてくる
  • 画像データを組み立ててバッファに格納

f:id:bokuweb:20191106020919p:plain

エミュレータの基本動作

ここまででCPUGPUの基礎はできているので、これらの同期をとってエミュレータとして動作させる必要があります。

f:id:bokuweb:20191106021002p:plain

基本的なステップは以下です。

  1. CPUを1命令実行する
  2. CPUでの命令実行にかかったクロック分GPUを稼働する
  3. 積算クロック数が1フレーム分に達したら画像データを取得して描画する

です。注意すべきはCPUの動作クロックとGPUの動作クロックに差異がある点です。LR35902には4.19MHzが入力されていますが、CPUの動作クロックはそれを4分周したものになっています。つまりCPUで2クロックかかる命令を実行した場合、GPUは8クロック分稼働させる必要があります。x4しているのはそのためです。

今回は画像データを返すところまでをNextという関数にまとめておきました。後述しますが、ここで描画処理までをここで行ってしまうと、WasmNativeで動作を切り分け必要がでてくるのと、テストするのが面倒になってしまうのでここではバッファを返すようにしています。

f:id:bokuweb:20191106021031p:plain

あとは60FPSすなわち16ms周期でNextを実行して画像データを取得・描画してやればエミュレータとしては完成です。イメージは以下のような感じです。

f:id:bokuweb:20191106021126p:plain

もちろん、これは実機のタイミングとは大きく異なりますが、エミュレータとしては入力が反映された画像データが16ms周期で取得できれば、辻褄は合うので問題ないです。この方式で不具合がでるゲームはそうそう無い気はしています。

あとはこの画像データを描画するだけです。特に面白いとこはないので発表でもスキップ気味に話ましたが、今回はfaiface/pixelというglfwライブラリを使用しました。

github.com

余談ですが、現在では治っているかもしれませんが、glfwを使用した場合macでは一度windowを移動しないと描画されない問題があり以下のような謎Hackが入っています。この問題のせいで数時間溶けました。

pos := win.GetPos()
win.SetPos(pixel.ZV)
win.SetPos(pos)

Testing

単体テストは前述したようにこつこつ書いていけばいいのですが、正直効率はあまりよくありません。CPUの作りが極めて単純なこともあり、加算命令やデータ移動のテストを書いていると辛くなってきます。

大抵どのようなレトロゲームにも先人たちがテストROMを作ってくれているのでこれを使うのがいいでしょう。

github.com

こんな感じで結果を表示してくれます。

f:id:bokuweb:20191106094641p:plain

たとえばCPU命令の実行結果が正しいかを検証するのは上記のcpu_instrsを使用します。このテストは落ちた場所を通知してくれますし、11パターンあるテストケースを個別に実行することもできます。また親切なことにシリアル出力に結果を吐いてくれるのでGPU周りが未実装でもこのROMは使用できます。

ある程度までCPUが動いたらまずはこのテストをパスすることを目標にするといいかもしれません。

このようにテストROMを使用して検証していくのは便利なのですが、CIでどう回すか。という別問題が出てきます。今回は以下のように指定したROMの指定フレームをpngとして保存するようにし、VisualRegressionTest`を行うようにしました。

f:id:bokuweb:20191106021502p:plain

これには以前作成したreg-viz/reg-cliというツールを使っています。最近@wadackel@Quramyがレポート画面を刷新してくれて使いやすくなっていますのでぜひ使ってみてください。

github.com

本当は発表当日にgo版を用意して「どやっ」って言うつもりだったんですが資料作成に時間を吸われ全然間に合いませんでした。このツールはWebフロントエンドの開発などで使用することを念頭において作っているのでgo版を用意しても喜ぶのは僕ぐらいかもしれませんが...。

閑話休題。前回実行時に生成された画像をcommitしておき、見本画像とすることでただしく描画されているかが検証できます。

開発中はトライ&エラー的にごにょごにょいじることがあると思います。その際に以前は動いていた箇所を意図せず壊してしまったりしてしまいますが、この仕組みによりこれは防げます。

たとえば意図せずキャラクターの描画部分をコメントアウトしてしまったとしましょう。

if g.ly == constants.ScreenHeight {
    // g.buildSprites() ウワ-マチガッテコメントアウトシチャッタヨ
} 

そうすると以下のようにテストに失敗するようにしました。

変更部分もレポートを見ることですぐに分かるようになっています。

Wasmの初期化

goWasm化してブラウザで動かす場合には、JSgo間でグルーコードが必要となります。wasm_exec.jsというのがそれで、これは公式に用意されています。なのでまずこいつを読みこみ必要があります。

f:id:bokuweb:20191106021652p:plain

Wasmを読んでinstantiateする必要があります。注意点としてはwasm_exec.js経由で用意されているimportObjectを食わせる必要がある点です。これによりWasm側からwasm_exec.jsで用意されている関数を呼ぶことができます。

f:id:bokuweb:20191106021709p:plain

wasmfetchinstantiateを行ってくれるinstantiateStreamingというAPIがあるんですが、safariが対応していないのでここではfetchinstantiate個別で行っています。

caniuse.com

ブラウザで動作させる

初期化時に渡したimportObject経由でgo側の関数をJSから呼べるようにします。このときgo側でsyscall/jsimportする必要があります。

import syscall/js

f:id:bokuweb:20191106021804p:plain

ここではブラウザ側のglobalにGBというオブジェクトを生やしています。実態はnewGBという関数になっていて、JSからは以下のように使用できます。

const rom = await fetch("./tobu.gb");
const buf = await rom.arrayBuffer();
const gb = new GB(new Uint8Array(buf));

ここではROMfetchしてGBに渡しています。 go側ではもらったROMデータをもとに各種データを初期化し、ゲームを開始するための準備を行います。

また、ゲームの描画はcanvasで行うためGPUで生成されるデータをブラウザに引き渡す必要があります。これは以下のようにnextという関数を生やしてバッファを返すようにしました。

f:id:bokuweb:20191106021833p:plain * js.typedArrayOfは後述しますがgo1.13では使用できません。

これで以下のようにgb.next()を呼ぶことでJS側でバッファが取得できるようになります。

const gb = new GB(new Uint8Array(buf));
const image = gb.next();

あとはJS側で16ms周期でgb.next()を呼び、返ってくるデータをcanvasに書き込めばブラウザでゲームが動作します。具体的には以下のような感じです。ディスプレイのリフレッシュレートでコールバックを発火してくれるrequestAnimationFrameを使用しています。(ここはサボっていてディスプレイのリフレッシュレートが30Hzだったり120Hzだと良くないことがおこります)

f:id:bokuweb:20191106021909p:plain

ビルド

ビルドは-tags=wasmとすることでnativeと切り分けています。 f:id:bokuweb:20191106021926p:plain

サイズ

サイズは予想通りかなり大きく、さらにはグルーコードもついてきます。 f:id:bokuweb:20191106021944p:plain

パフォーマンス

f:id:bokuweb:20191106021958p:plain

パフォーマンスは発表後に再測定しています。 Rustで書いたファミコンのエミュレータをwasmにした際には1フレーム当たり3ms程度だったので6~7msくらいは出てほしいと思っていましたが、届きませんでした。もちろんファミコンとは1フレームあたりの命令実行数は異なるはずなので参考値でしかありませんが。

もちろん、まだまだ最適化の余地はあるとは思いますが、それでも現状の書き方で6~7msくらいは出てほしいなーというのが率直な感想です。

FrameGraphをざっと見た感じ、分かりやすいボトルネックはなく、全体的に遅いという印象でした。試しにいくつかの関数がどのようにwasmに変換されるのかを見てみましたが、やはりruntimeの分コードが膨らみじわじわ効いて来ているような印象です。

たとえば以下のようなコードでもwasmに変換した際に大きく膨らんでしまうのである程度の速度低下はさけられないと思います。ただ、wasmにはGCをサポートするプロポーザルも出ているので、これにより改善するかもしれませんし、現時点であればtinyGoを試してみるのもいいかもしれません。

f:id:bokuweb:20191106022027p:plain

↑のコードをwasmにすると↓のようなコードになりました。

f:id:bokuweb:20191106022044p:plain

質疑応答

質問いただいた内容と回答を記載しておきます。 漏れや意図が汲み取れていないところがありましたらご指摘ください。

実装時に参考すべき資料はあるか

基本的にはこのpdfを見ればなんとかなると思います。

http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf

Go1.13は試したか

発表は1.12を前提に発表しました。すんなり動かなかったのと、資料作成が間に合ってなかったので後回しにしていましたが、発表後検証し、速度の比較を行ってみました。

browser go1.12.11 go1.13.4
Chrome77 10.43ms 9.61ms
Firefox69 10.38ms 10.48ms
Safari 11.27ms 8.80ms

1フレームにおける平均処理時間なので小さいほうが良いです。Firefoxではあまり変化がありませんが、Chrome, Safariでは良くなっていると言って良さそうです。

go1.12.11 go1.13.4
サイズ(MB) 3.4MB 2.8MB

サイズもかなり小さくなっていました。

注意点としてはgo1.13では資料内で紹介したjs.typedArrayOfが削除され、js.CopyBytesToJSを使用するよう変更されていました。

js.typedArrayOfはバッファを返して来ましたが、js.CopyBytesToJSは引数で渡したバッファにセットするIFになっています。

なのでnextは以下のように変更しました。

this.Set("next", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
    img := emu.Next()
    return js.CopyBytesToJS(args[0], img)
}))

ただ、ここでも問題があって、js.CopyBytesToJSは第一引数がUint8Arrayinstanceかチェックしているんですが、canvasImageDataUint8ClampedArrayなので直接セットできず、今回はwasm_exec.jsをちょっと修正し、本体側にもパッチを投げてみました。

既存のツールを使用してwasmのサイズは小さくできるか

発表時は「そもそもruntimeがでかいわけだし、劇的には削れないだろう」と思い試しておらず、発表後にwasm-stripwasm-optをかけてみましたが、サイズはほぼかわりませんでした。

global変数を使ったり、ヒープを使用しないような記述をすることで速くなるか

速度は改善する方向に向かいそうですが、どんなwasmが吐かれるかみてみないとなんとも言えなそうですし、まだ試せていないです。 同Conference@DQNEOさんの以下の発表を聞いて(とても楽しみにしてたし、楽しかった)自分もやりたくなったので、速度やサイズを改善したwasm用goコンパイラ`作れないかなーなど考えるなどしていた。

speakerdeck.com

60FPSは出せるか

ネイティブであれば全く問題なく出るのと、wasmでもJS側でもgo側でも工夫できそうなところはまだまだあるので、新しめのデスクトップPCであれば比較的安定して出せそうだと思います。ただ、スマートフォンだとちょっと苦しそうな気もします。

ファミコンより作りやすいと言ったが具体的にどのようなところか

一例ですが、ゲームボーイは256x256pxのから160x144pxを切り出して表示しますが、ファミコンの場合は以下のように表示領域4面分のメモリ領域から1画面分を切り出すんですが、境界面がメモリ的にはまったく連続しておらずバグりやすいという話をしました。

また、冒頭で話たようにゲームボーイはGPUCPUが同じパッケージにいるのでCPUから直接VRAMがアクセスできる。という点もハードウェア・ソフトウェアの両面をシンプルにしており、わかりやすくなっている点だと思います。

スプライトと背景の違い

発表では時間の都合上スプライトについては省略しました。軽くここで言及しておきます。 スプライトは背景の上に以下のようにキャラクターなどを描画する機能で最大40個配置することができます。

f:id:bokuweb:20191106100020p:plain

背景の場合はタイルマップと言われる領域に8x8のタイルを32x32タイル分敷き詰めるという話をしました。 なので(x , y) = (0, 0 )に2番タイル(x , y) = (0, 8)に8番タイル... (x , y) = (8, 8)に1番タイル、のように8の倍数の座標にタイルを埋めていくことになります。

スプライトの場合は背景の上を縦横無尽に動ける必要があるため(x, y) = (17, 23) のような座標にも配置できなければなりません。スプライトは以下のような4バイトのデータで表現されます。

Byte 詳細 
0 Y座標 - 16
1 X座標 - 8
2 タイル番号
3 オプション   

オプションの詳細は割愛しますが、水平、垂直反転したり、背景との表示優先順位を設定できたりします。 このデータをOAM RAMというRAMに格納するとGPUが画面にスプライトを展開します。

以上まとめになります。ありがとうございました。