Edited at

Go で作ったゲームを Android/iOS 対応させた話 (1)

More than 1 year has passed since last update.

この記事は、拙作 Go 製 2D ゲームライブラリ Ebiten を Android および iOS に対応させた顛末をまとめたものです。まだ完全な対応の完了はしていないため、逐一編集していく可能性があります。 git log のコミットを見て思い出しながら書いた日記形式で綴ります。あくまでも思い出しながらなので、不正確な点もあります。

Ebiten は Go でできたレトロ風な 2D ゲームライブラリで、デスクトップやブラウザで動かすことができます。描画は、幾何行列および色行列による変換機能、オフスクリーンレンダリング機能があります。入力はキーボード、マウス、ゲームパッドに対応。音楽は単純な PCM や OGG を再生することができます。

今回はおめが氏作成の「いのべーしょん」というゲームをGo に移植したものを、 Android および iOS で動かすというのを目標しました。結論から言うと、両方で動きました。よかったですね。 go-inovation リポジトリに Android Studio プロジェクトおよび Xcode プロジェクトが用意してございます。 Chrome か Firefox ならばブラウザでも遊べます


Go におけるモバイル対応

Go は gomobile というコマンドで Android および iOS 対応のバイナリを生成することができます。 gomobile には gomobile build というすぐ実行できるアプリケーションを生成するコマンドと、 gomobile bind という、 Android/iOS で利用するための共有ライブラリを生成するコマンドがあります。最初は前者を利用しようとしたのですが、途中で後者に鞍替えしました。 gomobile build は Go だけでアプリが即作れて非常に便利なのですが、 OpenGL のビュー一個しかないアプリしか作れず、例えば広告を入れたりとかそういう工夫をしたい時に事実上不可能であり、実用性に欠けると判断しました。 gomobile bind だと Android/iOS 側にメインループがあり、 Pure Go で書けず複雑になるという欠点があるのですが、その代わりアプリ開発者は何でも出来るという柔軟性があります。

描画に関して、 Ebiten は OpenGL を使用しており、 Android および iOS でも OpenGL (ES 2) が当然動作するので、プラットフォーム固有のもの要件は一見無いように思えます。しかしながらコンパイルして一発で画面が表示されたと言うとそんなことはなく、色々修正する必要がありました。


前提


  • Mac OS X、 Linux、 Windows は対応済み。

  • 一部 Web ブラウザにも対応済み。これは GopherJS によるもので、 Go プログラムを JavaScript に変換することで対応させている。


    • モバイルに対応させるのであれば、 WebView を使って JavaScript に変換したものを動かす、という方法も検討していた。パフォーマンスがかなり劣化するので、やるとしても最終手段である。



  • 「いのべーしょん」は Go に移植済み。すでにデスクトップ等では動いている。


日記


2016-04-23


モバイル対応作業開始

1.3 という名前のブランチを切り、そのブランチ上でバージョン v1.3.0-rc1 をリリース。このバージョンは Mac とブラウザに加えて Windows と Linux に対応させた stable バージョンである。 1.3 ブランチには致命的な bug fix しか入れない。

同時に master ブランチのバージョンを v1.4.0-alpha に変更。ここから Android/iOS 対応を始める。がんばる。まずは iOS よりは手堅そうな Android からだ。 Android のほうが手堅いのはなぜかというと、 Go におけるモバイル対応は experimental なのだが、 iOS については特に「Support is not complete」と明記されているからである。


2016-05-06


Android Studio のお勉強

ゴールデンウィーク中何をやっていたのかというと、 Android Studio を初めて起動し、 Android アプリをどう作るかを勉強していた。 Android Studio の記録は Git リポジトリには残っていないため、その間のログはごっそり抜けてしまっている。

実質生まれて初めての Android プログラミングである。


2016-05-07


OpenGL レイヤーの準備

OpenGL レイヤーは golang.org/x/mobile/gl を用いる。実はすでにある程度は用意してあったので、若干の修正だけでコンパイルが通った。なぜすでに用意してあったかというと、この golang.org/x/mobile/gl はモバイルだけではなく Mac や Windows にも対応しているという触れ込みだったため、 OpenGL レイヤーをこのライブラリで統一しようと試みた時期があったからである。実際には採用に至らなかったが、それは Windows で GLFW と一緒に動かすのが不可能であったからである。

ちなみにコンパイルが通っただけで、実際に絵は出ていない。Git のログには現れないが、絵がでない原因を探る試行錯誤がここから始まっている。

余談だが、 golang.org/x/mobile/gl の Windows 実装は ANGLE (OpenGL API を DirectX でエミュレートしたもの) になっており、実行時に ANGLE の DLL を落としてくるという大胆な実装になっていた。 golang.org/x/mobile/gl はデスクトップにおいては golang.org/x/exp/shiny という GUI ライブラリと一緒に動かすことを想定しているようであり、他の GUI ライブラリと一緒に使うことは全く想定していないようであった。


2016-05-08


OpenGL 処理の遅延化

モバイルにおいては、 Ebiten ライブラリを呼ぶ時点では OpenGL コンテキストが存在しない可能性があることが発覚。これは、メインループを Run で発動させる前や init 関数においては OpenGL 関数は一切呼べないことを意味する。 Ebiten において init 関数内で Image オブジェクトを作る (= テクスチャやフレームバッファを作る) ことが常態化してしまっていたため、安易に禁止にはできなかった。結論としては、 Image の生成およびメソッド呼び出しを遅延化し、 OpenGL コンテキストができた時点で今まで遅延化させていた処理を全部実行するというものである。これにより、処理の遅延化が不可能な処理である ImageAt メソッド (指定した座標の色を取ってくる) は init 内などで呼べなくなってしまったが、まあよしとする。破壊的変更なので本当は良くない。

この修正の影響により、 go generate で Ebiten で画像を生成していたコードが動かなくなってしまっていたので修正

さらにこの影響で image_test.go の大半が動かなくなってしまった。一旦動かないテストのコードを消して後で考えることにする。


2016-05-11


Android で絵が出るようになる (OpenGL レイヤーバグ修正)

なぜか Android で画像がでないんだよなあ、と首をひねっていたが、モバイル用 OpenGL レイヤーが古くて、デスクトップ用実装等と sync されていなかったことが原因と発覚。嫌だ、機能が同じなのに重複するコードをたくさん持つからこういうことになるのだ、なんとかしないといけない。現在デスクトップ、 Web、モバイルの OpenGL レイヤーがそれぞれあるが、統合の目処は未だ立っていない。

この修正により、やっとこさ絵が出るようになる。なおこの時点ではまだ gomobile build (apk を直接吐くコマンド) でうごいた感じである。この頃から、 gomobile bind (apk ではなく aar を吐くコマンド) の対応も視野に入れつつコードを書いていく感じだったと記憶している。

このデバッグは本当にしんどくて、何がしんどいかというと、 gomobile、 Ebiten、 Android、どのレイヤーに原因があるのかの切り分けが困難だからである。そもそも OpenGL はデバッグしにくく、正しく動くまで暗中模索状態になることがよくある。それに経験の浅い Android やら gomobile やらがあるので難易度はさらに増す。 golang.org/x/mobile/gl は gldebug というタグを付加してビルドすることでデバッグモード (すべての GL 関数呼び出しのログを吐き出し、毎回 glGetError を呼んでくれる) になるのだが、今回の問題は「ゼロ初期化されたままの値を glBlendFunc にそのまま渡していた」であり、 glBlendFunc は 0 を受け取って「正しく」処理するので、 glGetError で取得できるエラーにはならなかった。今回は OpenGL レイヤーの実装をデスクトップのものと目視で比較した結果、たまたま発見できた。 OpenGL プログラミングに必要な物は、根性である。

なお、音が出るのは当分先の話である。


2016-05-12


デッドロックの修正

これはモバイルとはあまり関係ないのだが、一般的なプログラミングの教訓を得られたのでここに記しておく。

ebiten.Image を生成する関数である NewImageFromImage には、 image.Image インターフェイスを実装したオブジェクトを渡すことが出来る。ところで、 ebiten.Imageimage.Image インターフェイスを実装している。すなわち、 NewImageFromImageebiten.Image を渡すことができてしまう。

Ebiten におけるあらゆる Image の処理は mutex を使っているが、 NewImageFromImage の呼び出しにもロックが掛かる。その関数で渡された ebiten.Image オブジェクトに対してメソッドを呼びだそうとすると、そのメソッド内部でまたロックをかけようとしてしまい、デッドロックになってしまう。

得られた教訓は、「mutex ロック中にインターフェイスにアクセスしてはいけない」である。インターフェイス実装側でどんなコードが呼ばれるのか全く予見できないからだ。

さらに脱線だが、 Ebiten 内部の実装では goroutine を極力避けて mutex を使用している。これはなぜかというと、 GopherJS が生成する JavaScript は goroutine があると setTimeout などを多用してしまい、 60FPS 保ちたいゲームにとっては支障になりかねないからである。


2016-05-13


go vet によるミスの発見と修正

この頃から Go Report Card というサービスを利用し始める。どうやら go vet によってバイト操作ミスが発覚、発見者である Hans Rødtang 氏が直してくれました。ありがたや。


リソースの埋め込み

Android において os.Open などでファイルアクセスすることは可能なのだが、いかんせんカレントディレクトリがよく分からない。そもそも Web ブラウザに至っては、ファイルという概念がないではないか。デスクトップ、モバイル、ブラウザ全てで同じコードで安全に動くリソース管理は、 go-bindata などでリソースを Go コードとして埋め込んでしまうことだ。これを使うと、あらゆるバイナリを Go の巨大な string リテラルに変換し、 []byte でアクセス可能になる Go コードを生成してくれる。便利なのでぜひどんどん使っていきたい。ということでサンプル用のリソースを go-bindata で置き換え始めたのがこの頃である。


2016-05-14


uintptr 同士の足し算はやってはいけない

go vet は割と細かいところも見ていて、 uintptr 同士の足し算とかも検出して警告する。 uintptr の演算は + または - オペレータの右側が必ず整数定数でなければならない模様。仕方ないのでこのコードを C に移動させた。

あと go vet のエラーに従い色々修正。


2016-05-15


コンテキストロスト対応のための下準備

いままで Ebiten はデスクトップおよびブラウザでしか動いておらず、 OpenGL のコンテキストロストは意図的にやらなければ起きることはまずなかった。そのためコンテキストがロストした場合の対策を怠っていた。ひどい話である。

Android においてはプロセスを切り替えたり電源を落としたりするだけで容易にコンテキストが破棄され作りなおされる。そのため、コンテキストロストについて本格的に対応せざるを得なくなった。そのために OpenGL の初期化関数周りを整理したりしていた。

なおブラウザにおいて意図的にコンテキストロストを起こす API が存在する。勉強になった。


2016-05-16


コンテキストロスト対応のための下準備

シェーダプログラムを削除するための関数追加とか。


image_test.go の復活

2015-05-08 で動かなくなっていたテストを復活させた。 Go 1.4 から存在する TestMain 関数を使うことで、 Ebiten メインループを発動させ、ループ中にテストを実行することで解決した。


2016-05-17


コンテキストロスト対策? ピクセルデータ退避

Android において、プロセスが一旦退避する前 (Activity の onPause が呼ばれる) に glReadPixels を呼んでピクセルデータを退避させ Go の []byte として保存しておく。プロセスが復活するとき (Activity の onResume が呼ばれる) に []byte から改めてテクスチャやフレームバッファを生成する。

なお、この方法ではコンテキストロストに対する対策にまるでなっていない。コンテキストロストはいつ起こるのか予見不可能であり、「コンテキストロスト前には必ず onPause が呼ばれる」という仮定は間違っているからだ。実際この実装だと、電源を切った時に onPause が呼ばれないままビューが作りなおされ、うまく resume できない。この時は自分の知識が足りなかったのである。この問題は後々ちゃんと修正することになる。


Weak Pointer の実現

さて、 Image のピクセルデータを退避するためには、今現在存在する Image オブジェクトを誰かが掌握している必要がある。ここで weak pointer があるとうってつけなのだが、 Go にはそういうものはない。普通のポインタで参照してしまうと、ゲーム側ではすでに使われなくなった Image オブジェクトをいつまでも参照してしまうことになる。ではどうするか。 Image オブジェクトの実装を別の構造体 imageImpl に分け、 ImageimageImpl へのポインタをメンバとして持つ。 Image/imageImpl 生成時に imageImpl 構造体のポインタを Ebiten 側がテーブルに追加して保持しておく。ゲーム実装側 (Ebiten 利用側) は変わらず Image ポインタを利用する。生成時に Image のファイナライザを runtime.SetFinalizer で設定し、ファイナライザの中で Ebiten 内の imageImpl 参照をテーブルから消す。こうすればゲーム側で参照されなくなった Image オブジェクトは破棄され、それと同時に imageImpl ポインタテーブルからも無事抹消されるのである。


思ったよりも長くなってしまったので続きはまた今度。


ライセンス

この記事のライセンスは CC BY 4.0 とします。