表題の通りGo Conference 2019 Autumn
で発表させていただきました。運営・スタッフの方々、スピーカーの方々、スポンサーの方々、発表を聞きに来てくださった方々、懇親会でお話させていただいた方々ありがとうございました。非常に楽しかったです。
今回は発表資料の補足や、質問いただいた内容の回答、補足などを記事としてまとめておこうと思い記事にしてみることにしました。
発表資料
発表資料は以下です。
補足
発表がかなり駆け足になってしまったのと、資料だけではよくわからない箇所があると思うので補足を以下にあげていきます。
DEMO
このページから遊ぶことができると思います。
さぼってページに載せていないんですがキーマップは以下です。
keyboard | game pad |
---|---|
← | ← button |
↑ | ↑ button |
↓ | ↓ button |
→ | → button |
Z | A button |
X | B button |
Enter | Start button |
Backspace | Select button |
スマートフォンの場合は画面上のGameBoy
の各ボタンがタッチできるようになっています。
デフォルトでプレイできるのはtobutobugirl
という2017年製のオープンソースGameBoy
ソフトです。
スペック
ブロック図
LR35902
に色々詰まっているのが印象的です。なので基板上で目につく大物部品はLR35902
とRAM
2個くらいじゃないでしょうか。ファミコンの場合CPU
とPPU(ピクチャープロセッシングユニット)
が別パッケージになっていたため、CPU
からPPU
の持つVRAM
にアクセスするのがとても面倒でしたが、その点が解消されており、物理的にもソフト的にもシンプルになっています。
その辺は以下の記事でも触れているので参照してみてください。
レジスタ
F
はフラグを管理する特殊なレジスタです。
また基本8bitレジスタですがB
とC
をくっつけてBC
の16bitレジスタとして扱うことが可能です。
CPUの基本動作
乱暴な言い方をするとCPUはずっとこのステップを繰り返しているだけです。実際には割り込みの処理などがありますが、割り込み処理も各ステップ実行時に割り込みフラグをチェックして割り込みがかかっていたら指定番地へジャンプするただけなので、難しい処理ではありません。
フェッチ
CPUは次に実行すべき命令が何かを知る必要があります。なので次に実行すべき命令の番地を記録しておくプログラムカウンタ(以下PC)
の指し先からリードを行います。これはカートリッジ内のROM
かもしれませんし、どこかのRAM
かもしれません。
リードを行った後はPC
をインクリメントし、次の命令orデータを指すようにします。
この例はPC
が0x1000
番地を指しており、LD B, 0xA5
というBレジスタ
に即値0xA5
を格納する命令が入っている様子です。この命令のコードは0x06
なのでPC
の指し先には0x06
がその次の番地にはデータとして0xA5
が格納されています。
デコード
CPU
は読んできたコードが何か調べる必要があります。今回の例で言うと0x06
が何者かを調べる必要があります。自分は命令コードを渡すと命令の情報が引き出せる配列を用意しました。
今回は情報として、オペランドのサイズ、実行サイクル数、命令実行関数を引き出せるようにしています。
オペランドのサイズはこのCPU
の場合0~2
です。オペランドのサイズに応じてフェッチするようにしています。
発表時点でfor
でいいところ、なぜかswitch
で書かれており、@Linda_ppが指摘してくれました。ありがとうございました。
実行
あとは実行するだけです。この例はレジスタB
に0xA5
に格納するだけなので一行で済んでしまいます。「なんだ簡単じゃないか」と思われた方もいるかと思いますが、まさにそのとおりでこのCPUは乗算・除算命令もなくデータの移動、加算、減算、ジャンプなどの単純な命令をちまちま実装していくだけで完成します。
ただ、数が多いのでその点はちょっと大変です。このCPU
でいうと命令数は500弱
くらいでしょうか。最初の数命令は動き始めると楽しいんですが、加算や減算をひたすら追加していくルーチンワークになると非常にだるくなります。
CPU
を実行した際の返り値としてCPU
の実行サイクル数を返しています。この値を元にGPU
をどれだけ稼働させるかを決定するので、この値はCPU
-GPU
間の同期をとるための重要な値となります。
メモリマップ
色がついている箇所がカセット内のメモリ領域ですね。swichable
と書いてあるのは、この領域はバンク方式をとっているので、ある特定の処理を行うことでその領域がごそっと切り替わることになります。ある特定の処理というのはROM領域バンク番号を書き込む
などの気持ち悪い処理なんですが、よく取られていた方法のようです。
省略されていますが上位のほうには割り込み
、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)) }
この部分をコードに落とすには基本的にはアドレスの範囲に応じてアクセス先のデバイスを変更する単純な処理になります。たとえばリードであれば以下のような感じです。
GPUの描画タイミング
画面の右端まで描画を行ったら次ライン描画までのブランク期間HBlank
という期間があります。この期間を含めると1ラインあたり456クロック
となります。
また、表示領域を描画し終わってから次の描画を始めるまでのブランク期間VBlank
という期間が10ライン分
あります。
これらを考慮すると1フレームあたり70,224
クロックで完了する計算になります。
この数字は重要な数字でCPU
と同期をとるために必要となります。
GPUの基本動作
GPU
は稼働するクロック数を入力し、それらをカウントアップ。クロックが1ライン分の456クロック
積算されるごとに描画処理を行うような作りとしました。
具体的には以下のタイミングで各処理を行うようにしました。
- 表示領域内
- 表示領域描画完了
- VBlank完了
表示領域内
表示領域内、すなわちg.ly < 144
の場合。g.ly
というのは0xFF44番地
に配置されているLCD Y座標レジスタです。CPUはこのレジスタを読むことで現在GPUが液晶の何ライン目を描画しているのかを知ることができます。
表示領域内であれば背景を1ライン分組み立てるようにしています。
表示領域描画完了
表示領域描画完了時にはスプライトをまとめて描画しています。スプライトというのは所謂マリオやクリボーなどのキャラクター画像で、背景の上にキャラクターを最大40個配置することが可能です。
文章だとわかりにくいですが、以下のGPUデータの可視化デモを見てもらえると分かりやすいかもしれません。
本来スプライトも各ライン毎に描画していくべきなのですが、エミュレータの場合はさぼって表示領域描画完了のタイミングにまとめて全てのスプライトを描画してしまっています。
「ではなぜ背景も同じようにまとめて描画しないのか」という疑問があると思うのですがライン毎にスクロール量が変更される可能性があるためです。
具体例として、スーパーマリオランドでマリオが画面右側へ進んでいくとどんどん画面がスクロールしていくと思うのですが、スコアやタイマを表示しているヘッダ(と勝手に呼びます)はスクロールせずに固定したままになっています。これはヘッダ部分をスクロール量0で描画した後にスクロール量を変更しているためです。
よって、表示領域描画完了時にまとめて背景を描画するような処理にするとこのような挙動が再現できなくなってしまいます。
また資料では割愛していますが、このタイミングでVBlank
期間に入ることを通知するための割り込み処理が必要となります。この割り込みが何故必要かと言うと、描画中にVRAM
を変更すると画像が乱れる可能性があるためで、基本的にはVRAM
はVBkank
中に触ることになているためです。
VBlank完了
g.ly
を先頭の0に戻したり、VBlank
割り込みフラグを解除したりします。
背景の描画
8Byteで8x8
サイズのスプライトが1枚分作れます。16Byteで8x8
サイズのスプライトを2枚作り足し合わせることで、色情報が3bit
のタイルが表現できます。
タイルデータ
タイルデータ
を格納する領域は決められており、VRAM
内の0x8000
~0x97FF
番地に格納されることになっています。格納された順にタイル番号がふられます。
タイルマップ
背景を作るにはタイルマップ
と呼ばれる領域、具体的には0x9800~0x9BFF
に先のタイル番号を敷き詰めていくことになります。タイルマップ
は32x32タイル
すなわち256X256px
分の領域が確保されており、その一部が表示領域として切り取られLCDに表示されます。
なぜ余分な領域があるかというと、スムーズなスクロールに対応するためだと思います。当時のスペックだとVRAM
へのアクセスはかなり遅い上に、先述したように書き込みできるタイミングは限られてしまいます。
VBlank
中に多くの背景を書き換えることは不可能なため、256X256px
の領域から表示分160X144px
を切り取って表示しているものと思われます。
これは以下のツイートのタイルマップを見ると分かりやすいかもしれません。
マリオ、スクロールで背景が書き換えられるのがわかる pic.twitter.com/5sV6S9rWAl
— bokuweb (@bokuweb17) October 10, 2019
また、発表では割愛しましたがタイルマップ
は二面分の領域が確保されており、どちらを選択することはレジスタを編集することで切り替えることが可能です。
スクロール
表示領域を256x256px
の領域から切り出せるということを説明しましたが、どの領域を切り出すかをScrollX(0xFF43番地)
、ScrollY(0xFF42番地)
で設定することができます。
図は一番シンプルな例ですが、前述したようにスクロール量は描画の途中でも編集が可能なため、この図でいうところの青枠の切り取り領域はかならずしも1つの四角形になるとは限りません。
背景の描画
背景の描画データの組み立ては泥臭くやるしかなくて基本的には以下の手順です。
- 現在の描画座標から、タイルの座標を割り出す
- タイルの座標から該当するタイルマップのアドレスを算出する
- タイルマップからタイル番号を読み出す
- タイル番号からタイルデータを引いてくる
- 画像データを組み立ててバッファに格納
エミュレータの基本動作
ここまででCPU
とGPU
の基礎はできているので、これらの同期をとってエミュレータとして動作させる必要があります。
基本的なステップは以下です。
- CPUを1命令実行する
- CPUでの命令実行にかかったクロック分GPUを稼働する
- 積算クロック数が1フレーム分に達したら画像データを取得して描画する
です。注意すべきはCPU
の動作クロックとGPU
の動作クロックに差異がある点です。LR35902
には4.19MHz
が入力されていますが、CPU
の動作クロックはそれを4分周したものになっています。つまりCPU
で2クロックかかる命令を実行した場合、GPU
は8クロック分稼働させる必要があります。x4
しているのはそのためです。
今回は画像データを返すところまでをNext
という関数にまとめておきました。後述しますが、ここで描画処理までをここで行ってしまうと、Wasm
とNative
で動作を切り分け必要がでてくるのと、テストするのが面倒になってしまうのでここではバッファを返すようにしています。
あとは60FPS
すなわち16ms
周期でNext
を実行して画像データを取得・描画してやればエミュレータとしては完成です。イメージは以下のような感じです。
もちろん、これは実機のタイミングとは大きく異なりますが、エミュレータとしては入力が反映された画像データが16ms周期で取得
できれば、辻褄は合うので問題ないです。この方式で不具合がでるゲームはそうそう無い気はしています。
あとはこの画像データを描画するだけです。特に面白いとこはないので発表でもスキップ気味に話ましたが、今回はfaiface/pixel
というglfw
ライブラリを使用しました。
余談ですが、現在では治っているかもしれませんが、glfw
を使用した場合mac
では一度window
を移動しないと描画されない問題があり以下のような謎Hackが入っています。この問題のせいで数時間溶けました。
pos := win.GetPos() win.SetPos(pixel.ZV) win.SetPos(pos)
Testing
単体テストは前述したようにこつこつ書いていけばいいのですが、正直効率はあまりよくありません。CPU
の作りが極めて単純なこともあり、加算命令やデータ移動のテストを書いていると辛くなってきます。
大抵どのようなレトロゲームにも先人たちがテストROMを作ってくれているのでこれを使うのがいいでしょう。
こんな感じで結果を表示してくれます。
たとえばCPU
命令の実行結果が正しいかを検証するのは上記のcpu_instrs
を使用します。このテストは落ちた場所を通知してくれますし、11パターンあるテストケースを個別に実行することもできます。また親切なことにシリアル出力に結果を吐いてくれるのでGPU
周りが未実装でもこのROM
は使用できます。
ある程度までCPU
が動いたらまずはこのテストをパスすることを目標にするといいかもしれません。
このようにテストROMを使用して検証していくのは便利なのですが、CI
でどう回すか。という別問題が出てきます。今回は以下のように指定した
ROMの指定フレームを
pngとして保存するようにし、
VisualRegressionTest`を行うようにしました。
これには以前作成したreg-viz/reg-cli
というツールを使っています。最近@wadackel
と@Quramy
がレポート画面を刷新してくれて使いやすくなっていますのでぜひ使ってみてください。
本当は発表当日にgo
版を用意して「どやっ」って言うつもりだったんですが資料作成に時間を吸われ全然間に合いませんでした。このツールはWebフロントエンド
の開発などで使用することを念頭において作っているのでgo
版を用意しても喜ぶのは僕ぐらいかもしれませんが...。
閑話休題。前回実行時に生成された画像をcommitしておき、見本画像
とすることでただしく描画されているかが検証できます。
開発中はトライ&エラー的にごにょごにょいじることがあると思います。その際に以前は動いていた箇所を意図せず壊してしまったりしてしまいますが、この仕組みによりこれは防げます。
たとえば意図せずキャラクターの描画部分をコメントアウトしてしまったとしましょう。
if g.ly == constants.ScreenHeight { // g.buildSprites() ウワ-マチガッテコメントアウトシチャッタヨ }
そうすると以下のようにテストに失敗するようにしました。
変更部分もレポートを見ることですぐに分かるようになっています。
Wasmの初期化
go
をWasm
化してブラウザで動かす場合には、JS
↔go
間でグルーコードが必要となります。wasm_exec.js
というのがそれで、これは公式に用意されています。なのでまずこいつを読みこみ必要があります。
Wasm
を読んでinstantiate
する必要があります。注意点としてはwasm_exec.js
経由で用意されているimportObject
を食わせる必要がある点です。これによりWasm
側からwasm_exec.js
で用意されている関数を呼ぶことができます。
wasm
のfetch
とinstantiate
を行ってくれるinstantiateStreaming
というAPI
があるんですが、safari
が対応していないのでここではfetch
とinstantiate
個別で行っています。
ブラウザで動作させる
初期化時に渡したimportObject
経由でgo
側の関数をJS
から呼べるようにします。このときgo
側でsyscall/js
をimport
する必要があります。
import syscall/js
ここではブラウザ側のglobalにGB
というオブジェクトを生やしています。実態はnewGB
という関数になっていて、JS
からは以下のように使用できます。
const rom = await fetch("./tobu.gb"); const buf = await rom.arrayBuffer(); const gb = new GB(new Uint8Array(buf));
ここではROM
をfetch
してGB
に渡しています。
go
側ではもらったROM
データをもとに各種データを初期化し、ゲームを開始するための準備を行います。
また、ゲームの描画はcanvas
で行うためGPU
で生成されるデータをブラウザに引き渡す必要があります。これは以下のようにnext
という関数を生やしてバッファを返すようにしました。
*
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
だと良くないことがおこります)
ビルド
ビルドは-tags=wasm
とすることでnative
と切り分けています。
サイズ
サイズは予想通りかなり大きく、さらにはグルーコードもついてきます。
パフォーマンス
パフォーマンスは発表後に再測定しています。
Rust
で書いたファミコンのエミュレータをwasm
にした際には1フレーム当たり3ms
程度だったので6~7ms
くらいは出てほしいと思っていましたが、届きませんでした。もちろんファミコンとは1フレームあたりの命令実行数は異なるはずなので参考値でしかありませんが。
もちろん、まだまだ最適化の余地はあるとは思いますが、それでも現状の書き方で6~7ms
くらいは出てほしいなーというのが率直な感想です。
FrameGraph
をざっと見た感じ、分かりやすいボトルネックはなく、全体的に遅いという印象でした。試しにいくつかの関数がどのようにwasm
に変換されるのかを見てみましたが、やはりruntime
の分コードが膨らみじわじわ効いて来ているような印象です。
たとえば以下のようなコードでもwasm
に変換した際に大きく膨らんでしまうのである程度の速度低下はさけられないと思います。ただ、wasm
にはGC
をサポートするプロポーザルも出ているので、これにより改善するかもしれませんし、現時点であればtinyGo
を試してみるのもいいかもしれません。
↑のコードをwasm
にすると↓のようなコードになりました。
質疑応答
質問いただいた内容と回答を記載しておきます。 漏れや意図が汲み取れていないところがありましたらご指摘ください。
実装時に参考すべき資料はあるか
基本的にはこの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
は第一引数がUint8Array
のinstance
かチェックしているんですが、canvas
のImageData
はUint8ClampedArray
なので直接セットできず、今回はwasm_exec.js
をちょっと修正し、本体側にもパッチを投げてみました。
既存のツールを使用してwasmのサイズは小さくできるか
発表時は「そもそもruntimeがでかいわけだし、劇的には削れないだろう」と思い試しておらず、発表後にwasm-strip
とwasm-opt
をかけてみましたが、サイズはほぼかわりませんでした。
global変数を使ったり、ヒープを使用しないような記述をすることで速くなるか
速度は改善する方向に向かいそうですが、どんなwasm
が吐かれるかみてみないとなんとも言えなそうですし、まだ試せていないです。
同Conference@DQNEOさんの以下の発表を聞いて(とても楽しみにしてたし、楽しかった)自分もやりたくなったので、
速度やサイズを改善したwasm用goコンパイラ`作れないかなーなど考えるなどしていた。
60FPSは出せるか
ネイティブであれば全く問題なく出るのと、wasm
でもJS
側でもgo
側でも工夫できそうなところはまだまだあるので、新しめのデスクトップPCであれば比較的安定して出せそうだと思います。ただ、スマートフォンだとちょっと苦しそうな気もします。
ファミコンより作りやすいと言ったが具体的にどのようなところか
一例ですが、ゲームボーイは256x256px
のから160x144px
を切り出して表示しますが、ファミコンの場合は以下のように表示領域4面分のメモリ領域から1画面分を切り出すんですが、境界面がメモリ的にはまったく連続しておらずバグりやすいという話をしました。
また、冒頭で話たようにゲームボーイはGPU
とCPU
が同じパッケージにいるのでCPU
から直接VRAM
がアクセスできる。という点もハードウェア・ソフトウェアの両面をシンプルにしており、わかりやすくなっている点だと思います。
スプライトと背景の違い
発表では時間の都合上スプライトについては省略しました。軽くここで言及しておきます。 スプライトは背景の上に以下のようにキャラクターなどを描画する機能で最大40個配置することができます。
背景の場合はタイルマップと言われる領域に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
が画面にスプライトを展開します。
以上まとめになります。ありがとうございました。