@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が何かの本を献身的に燃やしているアニメーションが描画されたと思います。
Gopherは何かの本の山から何冊か本を積んで焼却炉に向かい,本を燃やしてまた本の山に戻っていきます。
Gopherの画像は,手前の焼却炉に近づくたびに大きくなり,奥の何かの本の山に近づくたびに小さくなっています。
このことから,このサンプルでは,画像の描画,移動,拡大/縮小が行われていることが分かります。
この記事では,このサンプルを例に使って画像の描画および移動,拡大/縮小を行う方法について説明を行います。
sprite
パッケージsprite
パッケージは,2Dグラッフィク向けのシーングラフを提供するパッケージです。
シーングラフを構築し,各ノードにテクスチャを貼り付けたり,座標の指定や回転,拡大/縮小を行うことができます。
詳しい説明は後述しますが,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サーバ経由でアクセスできるようにしています。
シーングラフを構築し,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.Event
とpaint.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.Event
やlifecycle.Event
について説明する予定です。
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。
合わせて読みたい
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。