GoだけでAndroidアプリを作る その2 〜画像の表示とイベント〜

連載目次

はじめに

@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について説明する予定です。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。