KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

カテゴリ: Android

皆さんは Google Play Games Services (GPGS) を使っていますか?
おそらくスマートフォンゲームを制作している企業の開発者の方々は基本的な機能への対応を済ませていることと思います。

今回は、GPGS の Real-time Multiplayer という機能のリアルタイム性を検証すべく、2 端末間でメッセージを往復させた時にかかる時間について調べました。

まず、GPGS がどういうものか概要を見ていきましょう。

Google Play Games Services とは

Google Play Games Services では Google+ ログインによりプレーヤーを認証することで以下の様な機能が提供されています。

Leaderboards
全世界でのランキングやソーシャル内でのランキングが見ることができます。
Achievements(実績)
プレーヤーは条件を達成することで実績を得て、それに応じた経験値を得ることができます。
Saved Games(クラウド保存)
ゲームのセーブデータを Google のサーバーに保存でき、それらは複数端末で同期されます。
Real-time Multiplayer
最大8人の部屋を作りその中でリアルタイム通信をすることができます。
Turn-based Multiplayer
最大8人の部屋を作りその中でターン制ゲームをすることができます。
Events and Quests
プレーヤーの進行度合いを集計し統計解析することができます。

上記の機能は Google Play のデベロッパーコンソールでゲームを追加することで使うことができます。
GitHub にサンプルコードが用意されています。
https://github.com/playgameservices/android-basic-samples

Real-time Multiplayer

Real-time Multiplayer を使うことで、自分でサーバを用意しなくても複数人でのリアルタイム通信を実現できます。ここでのリアルタイム通信網は、基本的には P2P ネットワークで構成されます。ファイアーウォールを越えられない場合などは Google のサーバーが中継をします。

プレーヤーが Real-time Multiplayer を使ったゲームを開始するときの手順は下記の通りです。

  • ゲーム画面から Google+ ログイン
  • ゲーム画面から自動選択(ランダムマッチ)をするか、友達の招待をするかを選択
  • GPGS の画面が起動
    • 自動選択の場合は選択されるまで待機
    • 友達を招待する場合はどの友達を招待するかを選択してそれが許諾されるのを待機
  • お互いのプレーヤーの準備ができると、ゲーム画面に戻ってゲーム開始

検証実験

では、本記事の本題である Real-time Multiplayer を使って 2 端末間でメッセージを往復させた時にかかる時間の測定について以下にまとめます。

検証方法

使用回線

  • au LTE – docomo LTE(国内通信)
  • au LTE – AT&T LTE(日米間通信)

メッセージングプロトコル

  • Reliable messaging [max:1400 bytes]
    • 遅いが欠落なし
    • おそらく TCP
    • 1メッセージ当たりの最大データ量 1400 bytes
  • Unreliable messaging [max:1168 bytes]
    • 速いが欠落あり
    • おそらく UDP
    • 1メッセージ当たりの最大データ量 1168 bytes

それぞれのメッセージングプロトコルに対してデータ量を 10 bytes から 100 bytes ごとに最大まで、各データ量につき 10 回計測しました。

おまけ(上限調査)
Reliable messaging では上限とされている 1400 bytes 以上のデータを送信できたため、送信データ量を 5000 bytes ずつ増やしながら例外が吐かれるまで計測しました。
各データ量につき 1 回計測しました。

検証結果

検証結果1


国内通信、日米間通信

検証結果2


おまけ(上限調査)

詳細な計測数値は別表に示します。

まとめ

今回、Google Play Games Services の Real-time Multiplayer について、信頼性の高い Reliable messaging と信頼性の低い Unreliable messaging それぞれの通信速度を、送信データ量を変えながら計測しました。メッセージのデータ量による通信時間変化は、規格として定められている範囲では特に大きな差はありませんでした。
通信時間はおおよそ下記のとおりとなりました。

Reliable Unreliable
国内 約 750ms 約 120ms
日米間 約 700ms 約 230ms

Unreliable(内部ではおそらく UDP が使われている)では距離の差が通信時間に表れました。
一方 Reliable(内部ではおそらく TCP が使われている)は、コネクションの確立等で Unreliable よりも距離の影響を受けそうですが、今回の計測では日本国内でも日米間でもほぼ変わらないという結果となりました。
GPGS Real-time Multiplayer の通信では、Google のサーバーでユーザーのマッチング処理をした後、できれば P2P でできれなければ Google のサーバーを経由で通信します。なので、地理的距離が近い場合でも遠い Google のサーバーに接続している可能性があります。
ただし、今回はパケットキャプチャを行わなかったので正確な原因は分かりません。

使いにくい点は多々あるかとは思いますが、自身でサーバーを用意することなく Android 開発者アカウントがあれば手軽にリアルタイム通信を使えることは大きなメリットかと思います。興味のある方はぜひ使ってみてください。

以上、Google Play Games Services Real-time Multiplayer の通信時間計測レポートでした。

別表 検証結果

Reliable(国内)

データ量 [byte] 10 100 200 300 400 500 600 700 800 900 1000 1100 1200 1300 1400
1 回目 [ms] 640 836 862 698 724 701 703 694 730 707 719 722 745 734 724
2 回目 [ms] 920 713 774 714 703 687 725 716 712 712 720 681 822 758 747
3 回目 [ms] 715 680 659 888 903 691 725 725 725 821 758 716 741 739 727
4 回目 [ms] 726 891 702 706 696 716 711 707 704 696 721 756 783 869 700
5 回目 [ms] 693 714 737 691 773 792 697 819 836 716 716 765 742 708 691
6 回目 [ms] 770 733 690 825 729 777 789 757 722 707 737 721 732 708 694
7 回目 [ms] 705 771 714 710 742 700 738 718 706 731 739 755 733 722 704
8 回目 [ms] 747 804 681 783 766 784 700 838 739 689 760 889 726 722 750
9 回目 [ms] 707 664 713 694 858 723 707 711 766 773 838 746 723 726 709
10 回目 [ms] 706 776 683 682 708 706 762 736 743 735 854 728 824 697 720
平均 [ms] 732.9 758.2 721.5 739.1 760.2 727.7 725.7 742.1 738.3 728.7 756.2 747.9 757.1 738.3 716.6

Unreliable(国内)

データ量 [byte] 10 100 200 300 400 500 600 700 800 900 1000 1100 1168
1 回目 [ms] 249 105 105 128 117 114 291 130 107 150 118 136 142
2 回目 [ms] 102 89 84 94 108 107 103 113 85 143 130 131 169
3 回目 [ms] 78 110 110 127 96 213 127 116 155 123 107 164 99
4 回目 [ms] 94 98 81 80 111 109 116 195 96 115 131 134 72
5 回目 [ms] 126 93 106 79 97 114 122 112 110 133 128 112 96
6 回目 [ms] 104 109 84 98 112 111 110 124 125 140 151 120 94
7 回目 [ms] 86 130 109 93 113 129 116 111 113 139 126 135 116
8 回目 [ms] 100 109 97 110 127 132 109 108 123 111 126 115 86
9 回目 [ms] 94 81 90 100 119 129 125 109 115 100 117 111 120
10 回目 [ms] 86 80 83 110 124 177 123 151 146 128 122 139 107
平均 [ms] 100.4 94.9 101.9 112.4 133.5 134.2 126.9 117.5 128.2 125.6 125.6 129.7 110.1

Reliable(日米)

データ量 [byte] 10 100 200 300 400 500 600 700 800 900 1000 1100 1200 1300 1400
1 回目 [ms] 601 651 635 648 678 719 703 753 709 583 702 709 773 707 660
2 回目 [ms] 588 654 663 677 680 679 603 606 639 696 665 745 741 709 834
3 回目 [ms] 711 651 671 689 708 673 731 593 700 663 574 711 643 749 891
4 回目 [ms] 639 650 675 651 744 696 619 692 807 684 767 721 687 706 692
5 回目 [ms] 713 652 677 660 656 628 606 658 716 644 656 703 727 662 723
6 回目 [ms] 620 684 661 671 741 872 655 590 734 656 625 754 668 805 723
7 回目 [ms] 625 620 696 688 650 638 708 710 606 648 754 576 678 682 693
8 回目 [ms] 630 681 671 649 668 719 621 614 849 725 625 705 647 722 687
9 回目 [ms] 712 678 627 644 659 659 830 746 643 693 753 686 699 683 749
10 回目 [ms] 597 588 669 652 782 658 628 661 804 648 606 662 669 752 658
平均 [ms] 643.6 650.9 664.5 662.9 696.6 694.1 670.4 662.3 720.7 664 672.7 697.2 693.2 717.7 731

Unreliable(日米)

データ量 [byte] 10 100 200 300 400 500 600 700 800 900 1000 1100 1168
1 回目 [ms] 236 219 212 217 206 242 240 246 234 226 256 286 273
2 回目 [ms] 189 240 234 214 234 189 241 200 226 244 238 243 258
3 回目 [ms] 238 191 215 196 207 210 188 216 216 278 219 244 272
4 回目 [ms] 204 203 228 209 222 250 231 227 189 245 246 249 258
5 回目 [ms] 190 198 189 202 186 250 235 190 240 281 266 232 299
6 回目 [ms] 190 194 245 192 199 202 210 250 259 263 276 255 247
7 回目 [ms] 196 245 177 205 265 194 190 249 240 223 257 276 230
8 回目 [ms] 234 186 209 196 201 216 240 231 239 252 212 281 259
9 回目 [ms] 190 236 248 304 196 208 222 220 231 198 277 291 253
10 回目 [ms] 208 222 214 220 201 179 201 226 233 217 257 381 229
平均 [ms] 213.4 217.1 215.5 211.7 214 219.8 225.5 230.7 242.7 250.4 250.4 273.8 257.8

Reliable(上限調査)

データ量 [byte] 5000 10000 15000 20000 25000 30000 35000 40000 45000 50000 55000 60000 65000 70000 75000 80000 85000 90000 95000 100000 105000 110000 115000 120000 125000 130000 135000 140000 145000 150000
計測時間 [ms] 713 1012 1018 1187 1381 1473 1679 1666 1759 1772 2056 2067 2103 2297 2544 2589 2350 2820 2897 2455 2574 2742 2981 2835 3189 3104 3338 2903 3161 3183

データ量 [byte] 155000 160000 165000 170000 175000 180000 185000 190000 195000 200000 200000 205000 210000 215000 220000 225000 230000 235000 240000 245000 250000 255000 260000 265000 270000 275000 280000 285000 290000 295000
計測時間 [ms] 3455 3360 3417 3448 3719 3561 3807 3823 3843 3979 5285 4780 4444 4242 4269 4592 4748 4546 4864 4729 4165 4785 4713 4511 4887 4712 4796 4736 5795 4907

データ量 [byte] 300000 305000 310000 315000 320000 325000 330000 335000 340000 345000 350000 355000 360000 365000 370000 375000 380000 385000 390000 395000 400000 405000 410000 415000 420000 425000 430000 435000 440000 445000
計測時間 [ms] 5066 5412 5170 5720 5593 5316 5819 5838 6311 6648 5678 6015 6304 5979 8921 5989 5811 6122 6216 6326 6606 6305 6233 7926 7991 6561 9879 7096 6791 7075

データ量 [byte] 450000 455000 460000 465000 470000 475000 480000 485000 490000 495000 500000 505000 510000 515000
計測時間 [ms] 7523 6933 9748 6916 7174 6760 7049 7158 7512 7119 7131 7070 7295 7856

Yuta Watanabe, @kakkun61

連載目次

はじめに

@tenntennです。

前回の記事では,spriteパッケージといくつかのイベントについて扱いました。前回の記事を読んでいただいた方は,Android上で画像の描画と移動,回転,拡大/縮小を行うコードをGoだけで書けるようになったのではないかと思います。

なお,前回の記事を執筆後にevent/configevent/sizeに変更されました。確かにサイズに関する情報しかconfig.Eventには含まれていなかったので,納得できる名前変更です。

また,GoチームによってGo Mobileの情報が徐々に整理されてきました。GoのWikiに導入の記事が追加され,リポジトリにもgomobile bindを使ったSDKアプリケーションのサンプルが追加されました。

さて,今回は前回扱わなかった,event/touchevent/lifecycleについて扱おうと思います。この記事を読めば,Go Mobileでタッチイベントを使った簡単なゲームなら実装できるようになるでしょう。

この記事は,2015年8月16日時点の情報を基に執筆しております。これからもしばらくは,event/configのような破壊的な変更が加わる可能性は十分ありますので,ご注意ください。なお,この記事を執筆当時の最新のコミットはbb4db21edcf86f8e35d2401be92605acd79c8d10です。そのため,記事で参照するGo Mobileのリポジトリのリンクは,このコミットを基準にしております。また,この記事にあるソースコードの一部はGo Mobileのリポジトリから引用したものです。

タッチイベント

第1回目の記事で動かしてみたexample/basicを覚えていますでしょうか?このサンプルでは,画面をタッチするとタッチした位置に,緑の三角形が描画されていたと思います。

example/basicをAndroidで動かしてみた

Go Mobileでは,画面をタッチするとtouch.Eventが送られてきます。このサンプルでは,送られてきたtouch.Eventをハンドルしてタッチした位置を取得し,その位置に三角形を描画しています。

前回の記事で説明した通り,イベントをハンドルするには,app.AppEventsメソッドで取得されるチャネルからイベントを受信します。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            var sz size.Event
            switch e := app.Filter(e).(type) {
                case size.Event:
                    sz = e
                case touch.Event:
                    fmt.Println(e.X, e.Y)
            }
        }
    })
}

タッチイベントを表すtouch.Eventは以下のように定義されています。

// Event is a touch event.
type Event struct {
    // X and Y are the touch location, in pixels.
    X, Y float32

    // Sequence is the sequence number. The same number is shared by all events
    // in a sequence. A sequence begins with a single TypeBegin, is followed by
    // zero or more TypeMoves, and ends with a single TypeEnd. A Sequence
    // distinguishes concurrent sequences but its value is subsequently reused.
    Sequence Sequence

    // Type is the touch type.
    Type Type
}

XYはご想像の通り、タッチした座標です。これらの値はピクセル単位なので,spriteパッケージで使われるgeom.Pt単位にするには,size.Event.PixelsPerPtを基に計算してやる必要があります。

Sequenceは,一連のタッチイベントに付けられるシーケンス番号です。指を同時に画面にタッチすると、タッチイベントが同時に複数発生します。そのため,シーケンス番号は、それらを区別するために利用します。

Typeには,タッチイベントの種類を表す値が入っています。タッチイベントの種類は,TypeBeginTypeMoveTypeEndの3種類が存在し,以下のように定義されています。

const (
    // TypeBegin is a user first touching the device.
    //
    // On Android, this is a AMOTION_EVENT_ACTION_DOWN.
    // On iOS, this is a call to touchesBegan.
    TypeBegin Type = iota

    // TypeMove is a user dragging across the device.
    //
    // A TypeMove is delivered between a TypeBegin and TypeEnd.
    //
    // On Android, this is a AMOTION_EVENT_ACTION_MOVE.
    // On iOS, this is a call to touchesMoved.
    TypeMove

    // TypeEnd is a user no longer touching the device.
    //
    // On Android, this is a AMOTION_EVENT_ACTION_UP.
    // On iOS, this is a call to touchesEnded.
    TypeEnd
)

1本の指を画面に置いてから,離すまでの間に複数のタッチイベントが発生します。それらの一連のタッチイベントは同じシーケンス番号が振られ,指がどの状態にあるかはTypeで取得します。画面に指を置いた瞬間にTypeTypeBeginのタッチイベントが発生し,指を動かす度にTypeTypeMoveのイベントが発生します。画面から指を離すと,TypeTypeEndのイベントが発生し,一連のシーケンスのタッチイベントが終了します。

なお,シーケンス番号は常に一意である訳ではなく,そのアプリの起動中に何度も同じシーケンス番号のイベントが送られてきます。現在の実装では,画面に指を置いた順にシーケンス番号を振られ,指を離すとそのシーケンス番号は開放され、再度別の指を置くと同じシーケンス番号が使いまわされます。つまり,新しくシーケンス番号を振る場合は,現在どの指にも振られていない0以上のもっとも小さい数を振るように実装されています。

タッチイベントは基礎的な情報しか提供していませんが,これらをうまく使うことでドラッグやフリックなどを実装することができます。
Webフロントエンドのタッチイベントのライブラリなどを参考にして、高レベルなタッチイベントを行うライブラリを実装しても面白いでしょう。

ライフサイクル

Go Mobileにおいて,アプリのライフサイクルはlifecycle.Eventで表わされます。Go Mobileでは,複数のプラットフォームを一元的に扱うためOnStartOnStopなどのAndroidのActivityのライフサイクルとは一致していません。

lifecycle.Eventは以下のように定義されています。

// Event is a lifecycle change from an old stage to a new stage.
type Event struct {
    From, To Stage
}

ライフサイクル中のある状態をStageという型で表現し,lifecycle.Eventは,とあるStageから別のStageに移ることを表しています。Stageには,StageDeadStageAliveStageVisibleStageFocusedの4つがあり,以下のように定義されています。

const (
    // StageDead is the zero stage. No lifecycle change crosses this stage,
    // but:
    //  - A positive change from this stage is the very first lifecycle change.
    //  - A negative change to this stage is the very last lifecycle change.
    StageDead Stage = iota

    // StageAlive means that the app is alive.
    //  - A positive cross means that the app has been created.
    //  - A negative cross means that the app is being destroyed.
    // Each cross, either from or to StageDead, will occur only once.
    // On Android, these correspond to onCreate and onDestroy.
    StageAlive

    // StageVisible means that the app window is visible.
    //  - A positive cross means that the app window has become visible.
    //  - A negative cross means that the app window has become invisible.
    // On Android, these correspond to onStart and onStop.
    // On Desktop, an app window can become invisible if e.g. it is minimized,
    // unmapped, or not on a visible workspace.
    StageVisible

    // StageFocused means that the app window has the focus.
    //  - A positive cross means that the app window has gained the focus.
    //  - A negative cross means that the app window has lost the focus.
    // On Android, these correspond to onResume and onFreeze.
    StageFocused
)

ステージはStageDeadから始まり,アプリをサスペンドしたり,レジュームしたりすると変わります。各ステージがAndroidのライフサイクルのどの部分にあたるのかは、上記のStageの定義にコメントとして書いてあります。

表にまとめると以下のようになります。なお,「順方向」や「逆方向」は,そのステージ「へ」遷移した(順方向)か,そのステージ「から」遷移した(逆方向)かを表しています。詳細は後述のCrossesメソッドの説明で行います。

ステージ名 意味 対応するAndroidのライフサイクル
StageDead 初期状態 なし
StageAlive アプリの起動している 正方向:onCreate,逆方向:onDestroy
StageVisible ウィンドウの表示されている 正方向:onStart,逆方向:onStop
StageFocused ウィンドウが選択されている 正方向:onResume,逆方向:onFreeze

執筆当時はMacとAndroidでステージの遷移の仕方が違っていました。
たとえば,Androidではステージは以下の表のように変化します。

操作 ステージの遷移
アプリを起動する StageDead->StageFocused
ホームボタンを押す StageFocused->StageAlive
アプリに戻る StageAlive->StageFocused

一方,Macの場合は以下のように遷移します。

操作 ステージの遷移
アプリを起動する StageDead->StageAlive->StageVisible->StageFocused
別のウィンドウを選択 StageFocused->StageVisible
アプリのウィンドウを選択 StageVisible->StageFocused
アプリのウィンドウを閉じる StageFocused->StageAlive->StageVisible
アプリを終了(ウィンドウを閉じてる場合) StageVisible->StageDead
アプリを終了(ウィンドウを開いている場合) StageFocused->StageDead->StageAlive

このようにAndroidの場合はStageVisibleになることはなく,Go Mobileのソースコードを見てもイベントを発生させてる箇所は見つけられませんでした。

以下の図のように,ステージは基本的にStageDead->StageAlive->StageVisible->StageFocusedの順(定数の小さい順)に遷移することを前提としています。
lifecycle.Event.Crossesメソッドを使うと,引数で渡したステージを順方向(定数の小さい順)に通り過ぎてるか(CrossOn),逆方向(定数の大きい順)に通り過ぎているか(CrossOff)が取得できます。
たとえば,図の左側のようにイベントがlifecycle.Event{From: StageAlive, To: StageFocused}の場合,Crosses(StageVisible)CrossOnを返します。

ライフサイクル

少し分かりづらいですね。Crossesメソッドの実装を見る方が分かりやすいかもしれません。なお,Stage型は単なるuint32のエイリアス型であるため,大小比較ができることに注意しましょう。

// Crosses returns whether the transition from From to To crosses the stage s:
//  - It returns CrossOn if it does, and the lifecycle change is positive.
//  - It returns CrossOff if it does, and the lifecycle change is negative.
//  - Otherwise, it returns CrossNone.
// See the documentation for Stage for more discussion of positive and negative
// crosses.
func (e Event) Crosses(s Stage) Cross {
    switch {
    case e.From < s && e.To >= s:
        return CrossOn
    case e.From >= s && e.To < s:
        return CrossOff
    }
    return CrossNone
}

Crossesメソッドを使うことで,プラットフォームによって,とあるステージに遷移しない場合でも,そのステージを「通過」したかどうかが分かります。

たとえば,以下のようにCrossesをメソッドを呼び出した場合,たとえStageVisibleに遷移してなくても,あたかも遷移したかどうかを判断しているように処理ができます。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            switch e := app.Filter(e).(type) {
            case lifecycle.Event:
                switch e.Crosses(lifecycle.StageVisible) {
                case lifecycle.CrossOn:
                    // 画面が表示された場合の処理
                case lifecycle.CrossOff:
                    // 画面が表示されなくなった場合の処理
                }
            }
        }
    })
}

Crossesメソッドは一見無駄なように見えますが,Go Mobileでは,複数のプラットフォームの実装を用意する必要があり,Crossesメソッドは,その違いを吸収することができる,よく考えられたメソッドです。

今回は,タッチイベントとライフサイクルについて説明しました。タッチイベントと前回説明したspriteパッケージを使えば簡単なゲームを作ることができるでしょう。また,ライフサイクルイベントを使えば,アプリがサスペンドされたことを検出して,ゲームをポーズさせたりすることができ,より完成度のゲームを作ることができるようになると思います。

次回は,音の再生と各種センサーを扱う方法について説明する予定です。

連載目次

はじめに

@tenntennです。

前回の記事では,Go Mobileのインストールを行い,MacとAndroid上でサンプルを動かしてみました。

前回動かしたexample/basicというサンプルのソースコードを見ると,glというプリフィックスがついたOpenGLの関数を使っていたり, シェーダーを書いたりとOpenGLを直接ガリガリと使っていることが分かります。 しかし,筆者はOpenGLについてあまり詳しくありません。 そこでこの記事では,OpenGLの関数群を直接使うのは最低限に抑え, spriteパッケージを使って,画像の描画や回転,拡大/縮小などを行う方法について説明します。
また,spriteパッケージを扱う上で,Go Mobileのイベントモデルを理解する必要があります。
そのため,この記事ではGo Mobileのイベントモデルとspriteパッケージに深く関係しているペイントイベントについても説明したいと思います。

なお,この記事は2015年7月26日時点の情報を基に執筆しております。 Go 1.5がリリースされる2015年8月には,パッケージ名や使用方法などが変更されている可能性がありますので, あらかじめご了承ください。
また,spriteパッケージは執筆当時,expディレクトリ以下にあり実験的な実装であることが分かります。
そのため,正式版になるころには破壊的な変更が入るおそれがあります。

サンプルを動かしてみよう

まずはサンプルを動かしてみて何ができるのか実際に見てみましょう。 spriteパッケージのサンプルは,Go Mobileのリポジトリのexample/sprite以下で提供されています。
それでは,gomobile buildコマンドを使ってapkファイルを作成しましょう。

$ cd ~/Desktop # どこでもよい
$ gomobile build golang.org/x/mobile/example/sprite

できたapkファイルをAndroid端末にインストールして動かしてみます。

$ adb install sprite.apk

いかがでしょうか?以下のように,Gopherが何かの本を献身的に燃やしているアニメーションが描画されたと思います。

example/spriteをAndroid上で動かしてみた

Gopherは何かの本の山から何冊か本を積んで焼却炉に向かい,本を燃やしてまた本の山に戻っていきます。 Gopherの画像は,手前の焼却炉に近づくたびに大きくなり,奥の何かの本の山に近づくたびに小さくなっています。 このことから,このサンプルでは,画像の描画,移動,拡大/縮小が行われていることが分かります。
この記事では,このサンプルを例に使って画像の描画および移動,拡大/縮小を行う方法について説明を行います。

spriteパッケージ

spriteパッケージは,2Dグラッフィク向けのシーングラフを提供するパッケージです。 シーングラフを構築し,各ノードにテクスチャを貼り付けたり,座標の指定や回転,拡大/縮小を行うことができます。

詳しい説明は後述しますが,spriteパッケージを使って画像を描画するには,以下のような流れでシーングラフを構築し,描画します。

シーングラフの構築と描画は,描画が必要になった際に発生するペイントイベントをハンドルして行います。
画像ファイルの読み込みとシーングラフの構築は,初めてペイントイベントを受信した際にだけ行います。
なお,画像ファイルはテクスチャという形でロードされます。
シーングラフの構築では,ノードの作成と登録を行い,そのノードにテクスチャの一部分をサブテクスチャとして設定し,そして,ノードのサイズや座標などを決めるアフィン変換行列を設定します。
そして,描画を行う前にそのフレームでのノードの配置を決定し,それから描画を行います。

spriteパッケージの概念図

spriteパッケージのgodocを見ると,どのような機能が提供されているかが分かります。 spriteパッケージ自体は,最低限のインタフェースやシンプルな構造体を提供するだけにとどまっています。 具体的な実装を提供しないことで,OpenGL以外でも描画できるように設計されています。

たとえば,sprite.Engineインタフェースを見てみましょう。

type Engine interface {
    Register(n *Node)
    Unregister(n *Node)

    LoadTexture(a image.Image) (Texture, error)

    SetSubTex(n *Node, x SubTex)
    SetTransform(n *Node, m f32.Affine) // sets transform relative to parent.

    // Render renders the scene arranged at the given time, for the given
    // window configuration (dimensions and resolution).
    Render(scene *Node, t clock.Time, c config.Event)
}

sprite.Engineインタフェースは,ノードの登録やテクスチャのロード,サブテクスチャの設定,シーングラフの描画などの機能を提供します。
このインタフェースのOpenGLを使った実装は,sprite/glspriteパッケージで提供されます。
glsprite.Engine関数を呼び出すことで,sprite.Engineインタフェースを実装した値を取得することができます(サンプルでの該当コードはこちら)。

eng := glsprite.Engine() // engはsprite.Engine型

sprite.Engineインタフェースの実装は,sprite/glspriteパッケージ以外にも,sprite/portableパッケージがあります。
sprite/portableパッケージは,描画にimageパッケージを使います。
そのため,シーングラフを画像として書き出すことができます。
たとえば,著者が作ったサンプルでは,
画面(sprite/glsprite)と画像(sprite/portable)に描画し,
画像にはHTTPサーバ経由でアクセスできるようにしています。

portableを使ったサンプル

シーングラフの構築

シーングラフを構築し,Engine.Renderメソッドを呼び出すことでそのシーングラフを描画することができます。
シーングラフのノードはsprite.Node型として提供されています。

type Node struct {
    Parent, FirstChild, LastChild, PrevSibling, NextSibling *Node

    Arranger Arranger

    // EngineFields contains fields that should only be accessed by Engine
    // implementations. It is exported because such implementations can be
    // in other packages.
    EngineFields struct {
        // TODO: separate TexDirty and TransformDirty bits?
        Dirty  bool
        Index  int32
        SubTex SubTex
    }
}

sprite.Node型の各フィールドは,外部に公開はされていますが, それはsprite/glspriteパッケージやsprite/portableパッケージなどのエンジンの実装パッケージからアクセスするためで基本的には直接代入は行いません。
sprite.Engineインタフェースのメソッドや*sprite.Node型のメソッドを介して設定されます。
なお,例外としてArrangerというフィールド(詳細は後述)は直接設定するようになっています。

それでははじめに,ルートノードを作成して,エンジンに登録してみましょう(サンプルの該当コードはこちら)。

scene := &sprite.Node{}
eng.Register(scene)     // engはsprite.Engine型

登録を解除する際はUnregisterを呼び出せばよさそうですが,執筆当時はglsprite.Engine関数が返すエンジンは,Unregisterメソッドを呼ぶとpanicが発生しました。どうやらUnregisterメソッドはまだ未実装のようです。

つぎに,子ノードを追加してみましょう(サンプルの該当コードはこちら)。

n := &sprite.Node{}
eng.Regsiter(n)
scene.AppendChild(n) // sceneが親ノードになる

これでシーングラフを構築することができるようになりました。

画像の読み込み

シーングラフを構築することはできるようになりましたが, このままEngine.Renderメソッドを呼び出してシーングラフを描画しても何も画像を設定していないため,何も表示されません。
そこでつぎに,テクスチャをロードして,ノードに画像を設定する方法を説明します。

example/spriteを見ると,assetsというディレクトリの中にwaza-gophers.jpegという画像ファイルが1つ入っています。
画像ファイルは1つしかありませんが,example/spriteでは,この画像をテクスチャとしてロードして, Gopherや本の山などを切り出して4つのサブテクスチャとしてシーングラフのノードに設定しています。
まずは,テクスチャをロードしてサブテクスチャに切り出すところまでを説明します。
example/spriteでは,以下のloadTexturesという関数でそれを行っています。 なお,番号は説明のために筆者が追加したものです。

func loadTextures() []sprite.SubTex {
    // (1) アセットファイルを開く
    a, err := asset.Open("waza-gophers.jpeg")
    if err != nil {
        log.Fatal(err)
    }
    defer a.Close()

    // (2) 画像をデコードし,image.Image型として読み込む
    img, _, err := image.Decode(a)
    if err != nil {
        log.Fatal(err)
    }

    // (3) エンジンにテクスチャをロードする
    t, err := eng.LoadTexture(img)
    if err != nil {
        log.Fatal(err)
    }

    // (4) テクスチャをサブテクスチャに切り出す
    return []sprite.SubTex{
        texBooks:   sprite.SubTex{t, image.Rect(4, 71, 132, 182)},
        texFire:    sprite.SubTex{t, image.Rect(330, 56, 440, 155)},
        texGopherR: sprite.SubTex{t, image.Rect(152, 10, 152+140, 10+90)},
        texGopherL: sprite.SubTex{t, image.Rect(162, 120, 162+140, 120+90)},
    }
}

(1)では,assetsパッケージのOpen関数を使うことでassetsディレクトリ以下にあるwaza-gophers.jepgという画像ファイルを開いています。
なお,os.Openも使えますが,OSごとにファイルパスが異なるため,assets.Openを使うほうがよいでしょう。
また,assetsパッケージにはファイルを開く関数しか無いため,ファイルを作りたい場合はos.Createなどを使うしかないようです。
AndroidではAndroidManifest.xmlファイルにWRITE_EXTERNAL_STORAGE権限を追加すれば,os.Createを使ってdataディレクトリ以下にファイルを作成することができるのを確認できました。

(2)では,image/pngパッケージやimage/jpegパッケージを使って画像をimage.Image型にデコードしています。
透過PNG画像を描画したい場合は,以下のOpenGLの関数をloadTexturesの中で呼んでおくと良いでしょう。

gl.Enable(gl.BLEND)
gl.BlendEquation(gl.FUNC_ADD)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)

(3)で,エンジンにテクスチャをロードしています。 執筆時点ではロードしかできないので,削除はできませんでした。
ゲームなどでシーンが変わって,テクスチャをメモリ上から破棄したい場合は,エンジンごと破棄するしかないようです。
また,エンジンとしてglspriteを使用する場合は,内部で画像サイズが2^n変換されるようです。
なお,テクスチャのロードは最初のペイントイベント(paint.Event)が発生する前に呼び出すとpanic発生します。 ペイントイベントについての詳細は後述します。

(4)では,sprite.SubTexという構造体の配列を返しています。 sprite.SubTex型は,サブテクスチャを表す型で,あるテクスチャの一部分を表しています。 ここでは,テクスチャを指定した座標でサブテクスチャとして4分割にしています。 執筆時点では,テクスチャ画像の作成およびサブテクスチャの分割は手作業でやる必要がありました。 将来的には,go generateを使って渡した画像からテクスチャを作成するツールなどをが出てくることでしょう。なお,一度設定した画像を消したい場合は,空のサブテクスチャ(sprite.SubTex{})を設定すると画像が描画されません。もちろん、設定するサブテクスチャを変える事もできます。

つぎに,切り出したサブテクスチャをノードに設定する部分を見てみます(サンプルの該当コードはこちら)。

eng.SetSubTex(n, texs[texBooks])

Engine.SetSubTex関数を使うと,引数で渡されたノードに対して,サブテクスチャを設定できます。
なお,サブテクスチャを設定するには,ノードがエンジンに登録されている必要があり, 登録していないノードに対して呼び出すとpanicが発生します。 これでノードにサブテクスチャを設定することが出来ました。
しかしこれだけでは,まだノードの大きさが設定されていないため,画面に表示されません。
ノードにアフィン変換行列を設定して,描画する画像サイズを指定しましょう。

画像の移動・回転・拡大/縮小

Engine.SetTransformメソッドを使うことで,ノードにアフィン変換行列を設定することができます。
あるノードの座標や大きさは,親ノードまでの変換結果に,そのノードの変換行列を掛け合わせることで求められます。
ルート(根)となるノードは,サイズは1pt x 1ptで,座標は(0, 0),回転角度は0度を基準として,変換行列を掛けます。
そして,ノードにサブテクスチャが設定されている場合は,ノードの大きさや座標,回転角度を基に描画が行われます。
なお,example/spriteでは,sceneというノードがルートノードにあたります。

また,ptという単位は,フォントで使われるptと同様で,1pt = 1/72 inchとなり,geom.Pt型で定義されています。 1ptが何ピクセルを表すかは端末ごとに違い,geom.Pt.Pxメソッドで取得できます。

それでは,Engine.SetTransformメソッドを使った例を見てみましょう(example/spriteには該当箇所はありません)。

parent := &sprite.Node{}
eng.Register(parent)
eng.SetTransform(parent, f32.Affine{
    {2, 0, 5},
    {0, 2, 5},
})

child := &sprite.Node{}
eng.Register(child)
parent.AddChild(child)
eng.SetSubTex(child, tex)
eng.SetTransform(child, f32.Affine{
    {100, 0, 10},
    {0, 100, 10},
})

この例では,parentノードに設定された変換行列で,2倍の大きさになり,さらに座標は(5, 5)に移動しています。 子のchildノードでは,さらに100倍,座標も(10, 10)だけ移動します。
親の変換行列にさらに子の変換行列を掛けるため,最終的に画像は200pt x 200ptの大きさで,(25, 25)の位置に描画されます。
つまり,親ノードで2倍になっているため,子のノードで移動距離を(10, 10)に設定すると,実際は(20, 20)だけ移動します。

アフィン変換行列を表すf32.Affine型のポインタ型には,いくつか便利なメソッドが用意されています。
変換行列を生で書くのが辛い場合は,*f32.Affine.Scaleメソッドや*f32.Affine.Rotateメソッド,*f32.Affine.Translateメソッドなどを使うと良いでしょう。
メソッドのレシーバがf32.Affine型のポインタとなっているため少々使いづらいですが,直接変換行列を書くよりかは分かりやすいはずです。

イベント

ここまで,いくつかイベントという言葉を使ってきました。 ここでは,Go Mobileのイベントモデルと各イベントについて簡単に説明します。 Go Mobileでは以下のようなイベントを扱うことができます。 なお,かっこ内は対応するイベントの型です。

  • 描画イベント(paint.Event
  • 設定イベント(config.Event
  • ライフサイクルイベント(lifecycle.Event
  • タッチイベント(touch.Event
  • マウスイベント(mouse.Event
  • キーイベント(key.Event

執筆時は,キーイベントやマウスイベントはまだ実装中のようでした。 おそらくモバイル用というよりはPC用のイベントのようです。 キーイベントはAndroidのバックボタンを特定のキーでシミュレートしたりと,デバッグ用途に使えそうです。

Go Mobileでは,app.Main関数を用いて,イベントループを構築します。
通常,app.Main関数はmain関数内で呼び出すことが多いでしょう。 app.Main関数の引数には,func(app.App)型の関数を渡します。

func main() {
    app.Main(func(a app.App) {
        // ...
    })
}

この関数で渡されるapp.Appインタフェースは,以下のように定義されています。

// App is how a GUI mobile application interacts with the OS.
type App interface {
    // Events returns the events channel. It carries events from the system to
    // the app. The type of such events include:
    //  - config.Event
    //  - lifecycle.Event
    //  - mouse.Event
    //  - paint.Event
    //  - touch.Event
    // from the golang.org/x/mobile/event/etc packages. Other packages may
    // define other event types that are carried on this channel.
    Events() <-chan interface{}

    // Send sends an event on the events channel. It does not block.
    Send(event interface{})

    // EndPaint flushes any pending OpenGL commands or buffers to the screen.
    // If EndPaint is called with an old generation number, it is ignored.
    EndPaint(paint.Event)
}

各イベントは,app.App.Eventsメソッドで取得できるチャネルを通じて送られてきます。
一般的には,以下のようにswitch-case文で各イベントをハンドリングします(サンプルでの該当コードはこちら)。
app.Filter関数を通すことにより,各イベントの前処理などをやっているようです。
なお,for文のrangeの後にチャネルを書くとチャネルから送られてくるデータを変数に入れループを回してくれます()。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            var c config.Event
            switch e := app.Filter(e).(type) {
                case config.Event:
                    c = e
                case paint.Event:
                    onPaint(c)
                    a.EndPaint(e)
            }
        }
    })
}

それでは,各イベントについて説明していきたいと思います。 この記事では,config.Eventpaint.Eventについて説明し,他のイベントについては次回以降の記事で説明したいと思います。

config.Eventは,画面サイズが変わった場合などに送られてきます。PCだとウィンドウサイズが変わるごとにイベントが発生しますが,端末だと画面が回転した場合などに呼ばれるようです。
config.Event構造体からは,画面のサイズが取得できます。

// Event holds the dimensions and physical resolution of the app's window.
type Event struct {
    // WidthPx and HeightPx are the window's dimensions in pixels.
    WidthPx, HeightPx int

    // WidthPt and HeightPt are the window's dimensions in points (1/72 of an
    // inch).
    WidthPt, HeightPt geom.Pt

    // PixelsPerPt is the window's physical resolution. It is the number of
    // pixels in a single geom.Pt, from the golang.org/x/mobile/geom package.
    //
    // There are a wide variety of pixel densities in existing phones and
    // tablets, so apps should be written to expect various non-integer
    // PixelsPerPt values. In general, work in geom.Pt.
    PixelsPerPt float32
}

paint.Eventは,描画が必要になった場合に送られてきます。
上述のとおり,テクスチャをロードはpaint.Eventが送られてきた後に行う必要があります。
パフォーマンスのことを考えると,テクスチャのロード以外は事前に済ませておくと良いでしょうが, はじめはひとまず,テクスチャの読み込みおよびシーングラフの構築は初めてpaint.Eventが送られてきたときに行うと良いでしょう(サンプルでの該当コードはこちら)。

シーングラフの描画は,sprite.Engine.Renderメソッドを呼び出すことで行なわれます。
sprite.Engine.Renderメソッドの引数は,シーングラフのルートノード(*sprite.Node),時刻(clock.Time),config.Eventです。
引数に時刻を渡していることから分かるように,sprite.Engine.Renderメソッドはある特定の時刻のシーングラフの状態を描画します。
時刻よって座標などが変化するノードのある時刻の状態を決定するには,ノードにsprite.Arrangerインタフェースを設定している必要があります。

type Arranger interface {
    Arrange(e Engine, n *Node, t clock.Time)
}

sprite.Arrangerインタフェースの定義を見ると,Arrangeというメソッドを持っており,その引数に時刻(clock.Time)を取ることがわかります。sprite.Nodeに設定されたsprite.Arrangerインタフェースは,sprite.Engine.Renderメソッドが呼び出された際に,sprite.Engine.Renderの引数として渡された事項をそのまま渡し,実行されます。

example/spriteにおいても,Gopherの座標を決めるためにsprite.Arrangerインタフェースを設定してます(該当箇所はこちら)。

n.Arranger = arrangerFunc(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
    // TODO: use a tweening library instead of manually arranging.
    t0 := uint32(t) % 120
    if t0 < 60 {
        eng.SetSubTex(n, texs[texGopherR])
    } else {
        eng.SetSubTex(n, texs[texGopherL])
    }

    u := float32(t0) / 120
    u = (1 - f32.Cos(u*2*math.Pi)) / 2

    tx := 18 + u*48
    ty := 36 + u*108
    sx := 36 + u*36
    sy := 36 + u*36
    eng.SetTransform(n, f32.Affine{
        {sx, 0, tx},
        {0, sy, ty},
    })
})

なお,arrangerFuncは以下のように定義されている,sprite.Arrangerインタフェースを実装する型です(サンプルでの該当コードはこちら)。
なお、関数によるインタフェースの実装については,筆者のQiitaの投稿で解説しています。

type arrangerFunc func(e sprite.Engine, n *sprite.Node, t clock.Time)

func (a arrangerFunc) Arrange(e sprite.Engine, n *sprite.Node, t clock.Time) { a(e, n, t) }

// TODO: use a tweening library instead of manually arranging.とコメントあるように,sprite.clockパッケージの中にtween.goというファイルがあり,補間を行う関数が提供されています。

paint.Eventが発生し,その後sprite.Engine.Renderメソッドを呼び出しても実際にはまだ描画はされません。
そのフレームでのすべての描画に関する処理が終わったらapp.App.EndPaintメソッドを呼ぶ必要があります。
EndPaintメソッドが呼び出すと画面など(sprite.portableは画像)に描画結果が出力されます。

今回は,spriteパッケージの簡単な説明とGo Mobileにおけるイベントモデルの説明を行いました。
次回は今回扱わなかったtouch.Eventlifecycle.Eventについて説明する予定です。

連載目次

はじめに

@tenntennです。

もうすぐGoのバージョン1.5がリリースされる予定ですが, みなさまはどの機能に注目しているでしょうか? コンカレントGCだったりshared libraryが作れるようになったりと,Go 1.5は非常に楽しみです。 その中でも私は,Go 1.4で入ったGo Mobileのアップデートに注目しています。

Go Mobileは,Goを使ってモバイルアプリを書くためのツール類を提供するプロジェクトです。 Go 1.5では,iOS向けのアプリがビルドできるようになったり,Androidのサポートが強化されるようです。 masterブランチの最新(2015年7月19日 時点)では,すでにiOS向けのビルドもできるようになっているみたいです。 実際に,GoチームによってIvyというAPLライクな言語のインタプリタがiOSAndroid向けにリリースされています。

私も先日,Go Conference 2015 SummerのLT大会で使用したLTタイマーのGoFunソースコード)を試しに個人でGoogle Play Storeでリリースしてみました。 私が作成したGoFunというアプリは,Javaを使わずすべてGoだけで書いています。 JavaとGoを使った記事はよく見かけるのですが,Goだけで書いている記事はあまり見かけません。 また,Google Play Storeに公開するところまで扱っている記事も見かけません。 そこでこの記事では,複数回に分けてその時に調べたGoだけでAndroidアプリを作ってリリースするまでの手順を説明します。 まず今回は,Go Mobileの簡単な説明とインストール方法を説明したいと思います。

この記事で扱う手順やノウハウは,記事執筆時(2015年7月19日)のものです。 Go Mobileはまだまだ開発途中です。 現在(執筆当時)も毎日のように破壊的な変更がされています。 そのため,ビルド方法やパッケージ名,各ライブラリやツールの使い方が変わる可能性があります。

なお,この記事ではMac OSX Yosemite(10.10.3)とNexus 9(Android 5.1.1)で動作検証をしていますが,他のOSやAndroid端末では動作検証をしてまいません。 Go MobileはLinuxでは動くようですが,Windowsではまだ動かないようです。 Go Mobileのコード上にはWindowsの記述があるので,そのうち対応されるでしょう。

Go Mobileのインストール

最新のGo Momobileを使用するには,Go 1.5が必要です。 Go 1.5のベータ版をダウンロードするかソースコードからビルドしてください。 なお,Go 1.5のビルドには,Go 1.4が必要です。

Go 1.5のインストールが終わったら,次はgomobileコマンドをgo getします。

$ go get golang.org/x/mobile/cmd/gomobile

$GOPATH/bin以下にgomoibleコマンドががインストールされます。 $PATH$GOPATH/binが含まれていない場合は追加しておきましょう。 これでgomobileコマンドが使えるようになったはずです。

$ gomobile -h
Gomobile is a tool for building and running mobile apps written in Go.

To install:
....

つづいて,gomobile initを実行しましょう。 このコマンドは,Go Mobileを使うために必要なものをインストールしてくれます。 どうやら,モバイル端末向けにクロスコンパイルするためのGoツールチェーンやAndroid NDK,OpenAL(libopenal)がインストールされるようです。 執筆当時のバージョンではandroid-ndk-r10d$GOPATH/pkg/gomobile/以下にインストールされるようでした。 なお,実行には結構時間がかかるので,-vをつけて進捗を確認するとよいでしょう。

$ gomobile init -v

Goだけでモバイルアプリを書く

Go Mobileでは,以下の2種類の方法でGoを使ってモバイルアプリを開発することができます。

  • Java(Android)やObjective-C(iOS)からGoで書かれた処理を呼び出す
  • GoでOpenGLやOpenALを使ってアプリを書く

Go Mobileは,AndroidやiOSが提供するAPIのラッパーをすべて用意することを目的としてはいません。 GUIなどは通常のAndroidやiOSのアプリの開発と同じように,JavaやObjective-Cを使い,Goが得意なところはGoに任せるといった具合に使用することを想定しています。 (Swiftから呼び出せるのかは調べてません。すいません。)

一方で,OpenGLやOpenALの関数が呼び出せるようになっています。そのため,ゲームなどOS標準のUIを使わない場合は,これらを使用して開発できるようになっています。 しかしながら,まだ提供されている機能は低レベルな関数が多く,多くのパッケージがexp以下に配置されていることから分かるように,まだまだ実験的に実装されているだけのようです。

この記事では,GoだけでAndroidアプリを作る方法について説明します。 JavaからGoで書かれた処理を呼び出す方法については,いくつか日本語でも記事があるので探してみるとよいでしょう。

なお,Go Mobileのアプリからでも,ほとんどのGoの標準パッケージで提供される機能が使用できるようです。

サンプルを動かしてみる

Go Mobileのリポジトリには,exampleというディレクトリがあります。 この中には,Go Mobileを触れてみるのにちょうど良いサンプルがいくつか入っています。 ここでは最もシンプルなサンプルのexample/basicを使って動かし方を説明していきます。

まずはMac上で動かしてみましょう。 サンプルのあるディレクトリまで行き,go runコマンドで実行します。

$ cd $GOPATH/src/golang.org/x/mobile/example/basic/
$ go run main.go

うまく実行できると下のような赤い背景に緑の三角形がでるはずです。 緑の三角形はドラッグできるので,ぜひ動かしてみてください。

basic

Mac上で動かせることは確認できましたので,次にAndroid上で動かしてみましょう。 gomobile buildコマンドを使うと,モバイルアプリ用にビルドができます。 デフォルトではapkが生成されます。-target iosと指定すると,iOS向けのappファイルがビルドされます。 appからipaを作ればiOSでも動かせるとは思いますが,私の環境では実行できるipaは生成できませんでした。

$ cd ~/Desktop #どこでもよい
$ gomobile build golang.org/x/mobile/example/basic

上記のコマンドを打つと,カレントディレクトリにbasic.apkが生成されているかと思います。 adb installでAndoridにインストールして実行してみましょう。

$ adb install basic.apk

うまくいくと以下のようにAndroid上でアプリが実行できます。

basic_android

上記の例では,gomobile buildを使用しましたが,gomoible installを使えば,ビルド後にadb installを使って自動でAndroidへのインストールするところまでやってくれます。 gomobile installを使用するには,adbコマンドが必要となります。 Android SDKの開発環境を用意して,adbコマンドにパスを通しておきましょう。 なお,gomobile installコマンドはビルドターゲットがandroidでないと動作しませんので,注意してください。

$ gomobile install golang.org/x/mobile/example/basic

いかがだったでしょうか?Goだけで書かれたアプリがAndroid端末上で動くのは感動しますよね。 次回は,サンプルを見ながらGo Mobileで提供されているspriteパッケージについて説明する予定です。

↑このページのトップヘ