Go で作ったゲームを Android/iOS 対応させた話 (1)の続き。
これを書いている最中に Ebiten 製「いのべーしょん」の Android 版がリリースされました。パチパチパチ。ダウンロードはこちらから:
日記
2016-05-18
ゲームループ周りの整理
単なるリファクタリング。
Travis CI 上で JavaScript バージョンのテストを諦める
いつのまにやら gl モジュールが使えなくなってしまった。 node で Ebiten 使う人なんて皆無だと思われるのでコメントアウトして放置。
Travis CI テストといえば、 GLFW 版でも Travis 上でうまく動かないんだよな。 X のエラーが出てしまう。いつか直さないといけない。
2016-05-19
mobile モジュールの作成
とうとうモバイル用の API を追加した。今回は gomobile build
(アプリケーションを作る) ではなく gomobile bind
(共有ライブラリを作る) のがターゲットである。よって Go だけで完結したアプリではなく、 Go の API を Java/Objective-C 用に公開することになる。 Go API が他言語向けにどのようにエクスポートされるかについては、 gobind
コマンドのページを参考のこと。大雑把には、 int
や string
などのプリミティブな型、 byte のスライス、およびそれのみで構成される関数、構造体やインターフェイスが対象となる。チャネルや関数ポインタ、それで構成される関数などは対象にならない。エクスポート対象にならない関数などを定義しても、 gomobile bind
によってガン無視されるだけである。
今回そのモバイル用 API として、 Ebiten に mobile
モジュールというのを新設した。 gomobile bind
で作成したライブラリの公開 API としての使用を想定している。実際には、これらの API は gomobile bind
で指定したモジュール (= ゲーム) 側でいちいち Proxy してやる必要がある。たとえば mobile
モジュールで Update
という関数を定義してあったとして、ゲーム側でそれを呼ぶだけの Update
という関数を別途定義してやる必要がある。実際いのべーしょんではそのような Proxy 的なコードがいくつかある。
API としては入力等を除くと次の 2 つが基本である:
-
Start
: ゲームを開始する。 Android のGLSurfaceView
のonLayout
や iOS のGLKView
のviewDidLayout
/viewDidLayoutSubviews
で呼ばれることを想定している。なおいずれもプロセスが生きている間に複数回呼ばれる可能性があるため、Start
が複数回呼ばれてしまわないように (もしくは呼ばれても大丈夫なように) 工夫する必要がある。今回はIsRunning
というメソッドを追加して呼び出し側が条件分岐するようにした。 -
Update
: ゲームを更新する。Android のRenderer
のonFrame
や iOS のGLKView
のglkView:drawInRect:
で呼ばれることを想定している。過去の名前はRender
だった。
前述のとおりメインループが Go 側ではなく Java/Objective-C 側にあるため、毎フレーム Update
を呼ばなければならない。また後述するが、入力に関しても別途 API を生やす必要がある。
2016-05-23
タッチイベントのハンドラー追加
mobile
モジュールの API にタッチ関係の API を追加した。いずれも Android ならば onTouchEvent
で呼ばれることを想定している。とりあえずタッチイベントがないとゲームが始まらないので、 API を生やすのは一時的な措置のつもりであったが、 Go 側でタッチ情報を取る方法がどうにもないらしく、仕方がなく現在に至る。 gomobile bind
ではなく gomobile build
ならば、このような余計な API は一切必要ないのだが…。
構造体導入による API 削減 (後に撤廃)
グローバル関数がたくさんある API より、構造体 1 個とそれにメソッドが生えていたほうが色々楽じゃないか、という発想で、 EventDispatcher
という構造体を導入した。
これは後に撤廃した。ゲーム側では gomobile bind
でエクスポートするために当然 type EventDispatcher mobile.EventDispatcher
のように再定義し直す (もしくは interface を定義する) 必要があるが、 Objective-C 向け gomobile bind
ではこのように構造体型を定義してもなぜか export されないためである。 Android/Java 用ではうまくいったのだが。おそらくこの挙動はバグなのだろう。そもそも API がたくさんあるのを構造体のメソッドにしてしまうのは、ある種の甘えというか、その前に関数を出来る限り減らすべきではないかと考えを改めたため、 EventDispatcher
は撤廃することにした。
2016-05-24
mobile
モジュールに Pause
と Resume
関数の追加 (後に撤廃)
コンテキストがロストしたり復活した時に Android/iOS 側から呼んでもらう関数を追加した。後に、これらの関数は不要であることに気づく。外部から情報を伝達せずとも、毎フレーム適当なテクスチャについて glIsTexture
が真を返すかどうかで、コンテキストが生きているのかどうか分かるのでは、と気づいた。そして実際にそれがうまくいったのだった。外部に晒す API は少なければ少ないほどよい。
x/mobile/exp/audio/al
の利用を試みるが断念
Android で音を鳴らすために、 OpenAL を利用するライブラリ golang.org/x/mobile/exp/audio/al
を使おうとしたのだが、失敗した。 al: cannot load libopenal.so
というエラーが出てどうしてもなくせなかった。同じバグは過去に報告はあったものの、 Issue 報告者はこのバグがすでに直ったと言っており、自分の環境でこの問題がなぜ起きるのか不明なままだ。この Issue では別の問題が議論中であり、このモジュールはまだ Android では当分安定して使えるものではなさそうだ。
そもそも Ebiten ではオーディオのドライバ部分は PCM が鳴らせる実装であれば良くて、 OpenAL のような 3D オーディオ機能は不要である。
以上から、 golang.org/x/mobile/exp/audio/al
ではなく、 Android 向けに独自のオーディオ実装を行うことにした。
余談だが、OpenAL は Android にはデフォルトに入っておらず、共有ライブラリを動的に落としてくる形になるのだが、その際 OpenAL のライブラリは LGPL で、ライセンスを明記する必要がある。golang.org/x/mobile/exp/audio
にも以下のように明記されている:
When compiled for Android, this package uses OpenAL Soft as a backend. Please add its license file to the open source notices of your application. OpenAL Soft's license file could be found at http://repo.or.cz/w/openal-soft.git/blob/HEAD:/COPYING.
2016-05-25
Android オーディオ実装
Ebiten における音の実装は、プラットフォーム非依存なレイヤーとプラットフォーム依存なレイヤー (ドライバ) に大まかに分かれている。プラットフォーム非依存のレイヤーがよしなにミキシングなどを担当し、ドライバレイヤーは単に PCM を鳴らす実装をつくればよいようにできている。今回は Android ドライバを実装するのだが、前述の要件より、 AudioTrack を使えば十分だ。今回は Go から JNI を利用して、 AudioTrack を叩く実装をした。
Go から JNI を利用するにはどうしたらよいか。単に cgo を利用するだけで良さそうなのだが、実は C が書けるだけではなくて肝心の「現在の JavaVM*
」を取ってこなければならない。 Android では 実は JavaVM*
型の変数は x/mobile/internal/mobileinit/ctx_android.go
に定義されている。 JNI を利用する際にはこの値をとってこないといけない。モジュールが internal なので他モジュールからは触れられなさそうだ。とおもいきや、変数自体は C の変数であり、 external linkage である (他ファイルから参照できる)。今回は ctx_android.go
の実装をコピーし、 RunOnJVM
関数を作成した。これで無事 JNI を叩く準備が整った。
あとは単純に AudioTrack を叩くだけだ。 JNI は今回が初めてだったが、型チェックがものすごく貧弱になるので、なるべく書きたくないなあと思った。
なおこの時点ではまだ実装にバグが有り、音がなるに至っていない。
オーディオ初期化の遅延 (init 関数の罠の回避)
gomobile bind
は共有ライブラリが作られるのだが、エントリーポイントとなる main
関数はない。 Go には init
関数というのがあり、これらは main
よりも前に呼ばれる。では main
のない共有ライブラリで init
関数があった場合、これらはいつ呼ばれるだろうか。正解は「外部からそのライブラリの関数どれでも、一番最初に呼び出した時」である。つまりちょっとでも共有ライブラリの関数に外部から触れると、その関数呼び出し前に init 関数が呼び出される。 init 関数以外の関数は全部 init 関数が呼ばれ済みであることを期待している、と考えると至極当然に思える。逆に共有ライブラリの関数を誰も呼んでないと、 init
関数が呼ばれることはない。
さてここで困ったことが起きた。 init
関数内で Ebiten のオーディオ関係の初期化を行っていたのだが、内部でドライバの初期化も行っており、前述のとおり Go から JNI を叩いているので、この時点で JavaVM*
型の current_vm
変数が初期化されている必要がある。しかし今回 init
が呼ばれるタイミングでは、この変数はまだ未初期化状態のようなのだ。 current_vm
は後から値を設定しているので、そういった事態が起きても不思議ではない。
解決法としては単純で、 init
でそういった処理はせずに、必要になるギリギリまでオーディオ初期化を単に遅延させただけである。
今回このバグの原因究明に若干苦労した。なぜなら「呼びだそうとしている関数の前に init
関数が呼ばれる」ということに気づかないと、ありえもしないところで落ちているように見えるからである。
2016-05-26
オーディオのミス修正
Android でやっと音が鳴るようになった。原因は凡ミスの積み重ね。 JNI つらい。
2016-05-28
マルチタッチ対応
いままで Ebiten は 1 個のタッチにしか対応していなかったのをマルチタッチにするように修正。アクションゲームとかだと、マルチタッチないととてもゲームにならないので、必須である。
これで一通り「いのべーしょん」に必要な機能はそろった。身内に「5 月末までにいのべーしょんを Android で動かせるようにするぞ」という公約をしていたのだが、ギリギリ達成したのだった。しかしこの時点ではまだ Nexus 5x で悲しいことに 30FPS ほどしか出ず、目標の 60FPS を達成するまでひたすらチューニングを続けることになる。まだまだ続く。
ライセンス
この記事のライセンスは CC BY 4.0 とします。