KLabGames Tech Blog

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

カテゴリ: Go

この記事は KLab Advent Calendar 2015 の18日目の記事です。

Go 言語のランタイムは CPU プロファイル以外にも幾つかのプロファイルを標準で実装していますが、あまり Web 上に実用的な情報がありません。
そこでこの記事では、実際に Go で長期間稼働するサーバーを開発する上で役に立った CPU 以外のプロファイル機能を紹介することにします。

スタックダンプ

/debug/pprof/goroutine で、現時点での全 goroutine のダンプを取得できます。
これは go tool pprof でも利用できますが、実際に使うときはそれよりも curl や wget などを使って /debug/pprof/goroutine?debug=1 をプレインテキスト形式で保存します。

例えば、次のプログラムのスタックダンプを取得してみます。

package main

import (
    "net/http"
    _ "net/http/pprof"
    "time"
)

func NewCounter() <-chan int {
    c := make(chan int, 1)
    go func() {
        for i := 1; ; i++ {
            c <- i
        }
    }()
    return c
}

func worker() {
    counter := NewCounter()
    for c := range counter {
        if c == 47 {
            return
        }
    }
}

func main() {
    go http.ListenAndServe(":6000", nil)

    for {
        go worker()
        time.Sleep(time.Millisecond * 20)
    }
}

スタックダンプは次のようになります。

goroutine profile: total 221
1 @ 0x5af768 0x5af543 0x5aae84 0x4958fe 0x495b10 0x4739ca 0x47522d 0x475c9e 0x47348e 0x45d981
#   0x5af768    runtime/pprof.writeRuntimeProfile+0xb8  /usr/local/go/src/runtime/pprof/pprof.go:545
#   0x5af543    runtime/pprof.writeGoroutine+0x93   /usr/local/go/src/runtime/pprof/pprof.go:507
#   0x5aae84    runtime/pprof.(*Profile).WriteTo+0xd4   /usr/local/go/src/runtime/pprof/pprof.go:236
#   0x4958fe    net/http/pprof.handler.ServeHTTP+0x37e  /usr/local/go/src/net/http/pprof/pprof.go:199
#   0x495b10    net/http/pprof.Index+0x200      /usr/local/go/src/net/http/pprof/pprof.go:211
#   0x4739ca    net/http.HandlerFunc.ServeHTTP+0x3a /usr/local/go/src/net/http/server.go:1422
#   0x47522d    net/http.(*ServeMux).ServeHTTP+0x17d    /usr/local/go/src/net/http/server.go:1699
#   0x475c9e    net/http.serverHandler.ServeHTTP+0x19e  /usr/local/go/src/net/http/server.go:1862
#   0x47348e    net/http.(*conn).serve+0xbee        /usr/local/go/src/net/http/server.go:1361

1 @ 0x42d2b3 0x42d374 0x447a19 0x401151 0x42ced0 0x45d981
#   0x447a19    time.Sleep+0xf9     /usr/local/go/src/runtime/time.go:59
#   0x401151    main.main+0x71      /home/inada-n/work/techblog/20151218_inada-n/stack-sample.go:33
#   0x42ced0    runtime.main+0x2b0  /usr/local/go/src/runtime/proc.go:111
...

最初の行で、全部で 221 の goroutine が動いていることが判ります。この中には go のランタイムが利用している goroutine も含まれています。

次の行は、スタックトレースと、そのスタックトレースを持つ goroutine の数を示しています。 @ マーク以降がスタックトレースで、 @ マークの手前はそのスタックトレースを持つ goroutine の数になります。
Go のプログラムは数十万の goroutine を利用することがありますが、このように同じスタックトレースをまとめてカウントすることで人間の扱えるサイズのテキストファイルになります。

以降の # で始まる行は、スタックトレースの各アドレスが示す関数と、どのソースコードの何行目だったかをデバッグ情報を元に示しています。

さて、このスタックダンプの続きを見て行きましょう。省略すると実際にはとてつも無く大きいファイルなのかもしれないという印象を持たれるかもしれないので、あえて全部のせています。

1 @ 0x45d981

1 @ 0x42d2b3 0x42d374 0x42d0b8 0x45d981
#   0x42d2b3    runtime.gopark+0x163        /usr/local/go/src/runtime/proc.go:186
#   0x42d374    runtime.goparkunlock+0x54   /usr/local/go/src/runtime/proc.go:191
#   0x42d0b8    runtime.forcegchelper+0xb8  /usr/local/go/src/runtime/proc.go:152

1 @ 0x42d2b3 0x42d374 0x41cf91 0x45d981
#   0x42d2b3    runtime.gopark+0x163        /usr/local/go/src/runtime/proc.go:186
#   0x42d374    runtime.goparkunlock+0x54   /usr/local/go/src/runtime/proc.go:191
#   0x41cf91    runtime.bgsweep+0xb1        /usr/local/go/src/runtime/mgcsweep.go:51

1 @ 0x42d2b3 0x42d374 0x414cda 0x45d981
#   0x42d2b3    runtime.gopark+0x163        /usr/local/go/src/runtime/proc.go:186
#   0x42d374    runtime.goparkunlock+0x54   /usr/local/go/src/runtime/proc.go:191
#   0x414cda    runtime.runfinq+0xaa        /usr/local/go/src/runtime/mfinal.go:154

213 @ 0x42d2b3 0x42d374 0x403570 0x402ca3 0x4011a8 0x45d981
#   0x4011a8    main.NewCounter.func1+0x48  /home/inada-n/work/techblog/20151218_inada-n/stack-sample.go:13

1 @ 0x42d2b3 0x4273de 0x4268a0 0x4f1b3a 0x4f1ba6 0x4f572c 0x512aad 0x476b71 0x475f73 0x475e36 0x4764cf 0x45d981
#   0x4268a0    net.runtime_pollWait+0x60           /usr/local/go/src/runtime/netpoll.go:157
#   0x4f1b3a    net.(*pollDesc).Wait+0x3a           /usr/local/go/src/net/fd_poll_runtime.go:73
#   0x4f1ba6    net.(*pollDesc).WaitRead+0x36           /usr/local/go/src/net/fd_poll_runtime.go:78
#   0x4f572c    net.(*netFD).accept+0x27c           /usr/local/go/src/net/fd_unix.go:408
#   0x512aad    net.(*TCPListener).AcceptTCP+0x4d       /usr/local/go/src/net/tcpsock_posix.go:254
#   0x476b71    net/http.tcpKeepAliveListener.Accept+0x41   /usr/local/go/src/net/http/server.go:2135
#   0x475f73    net/http.(*Server).Serve+0xb3           /usr/local/go/src/net/http/server.go:1887
#   0x475e36    net/http.(*Server).ListenAndServe+0x136     /usr/local/go/src/net/http/server.go:1877
#   0x4764cf    net/http.ListenAndServe+0x8f            /usr/local/go/src/net/http/server.go:1967

1 @ 0x40df9e 0x44807e 0x45d981
#   0x40df9e    runtime.notetsleepg+0x4e    /usr/local/go/src/runtime/lock_futex.go:203
#   0x44807e    runtime.timerproc+0xde      /usr/local/go/src/runtime/time.go:209

これを眺めて見ると、明らかにカウントが大きいスタックトレースがありますね。

213 @ 0x42d2b3 0x42d374 0x403570 0x402ca3 0x4011a8 0x45d981
#   0x4011a8    main.NewCounter.func1+0x48  /home/inada-n/work/techblog/20151218_inada-n/stack-sample.go:13

この部分のソースコードを見てみましょう。

func NewCounter() <-chan int {
    c := make(chan int, 1)
    go func() {
        for i := 1; ; i++ {
            c <- i  // ここ!
        }
    }()
    return c
}

これで、 Go での並列プログラミング初心者がやりがちな goroutine リークが発見できました。

Note: Go の GC は他の大抵のプログラミング言語と同様に、スタックとグローバル変数をルートとして参照されているオブジェクト以外を回収します。
channel も close されているかどうかにかかわらず、その条件を満たしていれば回収されます。逆にいえば、そのチャンネルを参照している goroutine が生き残っていれば、たとえその goroutine が送信待ちしていて受信者がいない状況であっても回収されず、その送信待ち goroutine はリークしてしまいます。

このように goroutine リークを見つける以外にも、アプリケーションのレイテンシが落ちている状態でスタックダンプを取れば何を待っている goroutine が多いのかを判別するためにスタックダンプを利用することができます。

実際にスタックダンプを活用するには、問題が起こってからではなく開発中から時々スタックダンプを観察し、ライブラリがどのように goroutine を利用しているのか、平常時はどの goroutine がどれだけあるのかなどを把握しておくとよいでしょう。

なお、 /debug/pprof/goroutine?debug=2 とすることで、同じスタックトレースをまとめない完全なスタックダンプを取得することができます。
こちらには各 goroutine がどこで起動されたかや、各関数の引数の値 (ただし文字列や構造体はポインタになるので中身までは見えない) が表示されるので、より精密なデバッグに利用できます。

メモリープロファイル (ヒーププロファイル)

メモリープロファイルは前回のGC実行時点での、Goのアロケータが取得したプロファイルを取得します。こちらは普通に go tool pprof http://localhost:6000/debug/pprof/heap で取得します。

メモリープロファイルには、 inuse_space (デフォルト), inuse_objects, alloc_space, alloc_objects の4種類の情報があります。

名前の前半部分で、 inuse は前回のGC実行で回収されなかった、使用中のメモリーのことを指し、 alloc は生存しているものも回収されたものも含めてすべてのアロケートされたメモリーを指します。
名前の後半部分は、 space はメモリーのサイズを、 objects はオブジェクトの数 (一度にアロケートされるメモリーの塊をオブジェクトと呼びます) を示します。例えば 1MB のメモリを2回確保すれば、 space は 2MB, objects は 2 になります。

どのプロファイルを利用するかは go tool pprof のオプションで指定します。例えば inuse_objects を利用する場合は go tool pprof -inuse_objects URL のようにします。

また、メモリープロファイルを利用するときには、 go tool pprof-base オプションを覚えておきましょう。 -base 前回のプロファイル を指定することで、2つのプロファイル結果の差分を調査することができます。

4種類のプロファイル x 絶対/差分 で合計8種類の組み合わせがありますが、一般的な調査目的で利用するのはごく一部です。これから実際に使い方を解説していきます。

もう1つ注意点があります。メモリープロファイルはデフォルトで有効になっていますが、パフォーマンスへの影響を抑えるために精度が犠牲になっています。 runtime.MemProfileRate という変数を小さくする (単位はバイト、デフォルト値は 512 * 1024) ことで精度を上げることができますが、この変数を変更する前に取得したデータも変更後の値で計算してしまうので、 main 関数の先頭や main パッケージの init 関数など、なるべくアロケートが発生する前に設定するべきです。
この変数を1に設定すれば、理論上完全にメモリ利用を調査することができるはずです。この変数の値がどう利用されているかなどについては次回の記事で紹介します。

メモリー使用量の調査

一番単純なのは、メモリー使用量を削減したいとか、予想以上にメモリーを消費しているからなぜなのか知りたいなどの理由で、メモリー使用量の内訳を調査するケースです。
この場合はデフォルトの inuse_space を絶対値で調査するだけで十分です。CPUプロファイルを調査するときと同じようにすれば、使用中のメモリーを大量に確保しているのがどこかを突き止めることができます。

サンプルコード:

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
    "runtime"
    "time"
)

func genMap() map[string]string {
    m := make(map[string]string)
    for i := 0; i < 1000000; i++ {
        m[fmt.Sprintf("key%d", i)] = fmt.Sprintf("val%d", i)
    }
    return m
}

func genSlice() []string {
    s := []string{}
    for i := 0; i < 1000000; i++ {
        s = append(s, fmt.Sprintf("key%d", i))
        s = append(s, fmt.Sprintf("val%d", i))
    }
    return s
}

func main() {
    runtime.MemProfileRate = 1024
    go http.ListenAndServe(":6000", nil)
    m := genMap()
    s := genSlice()
    for i := 0; i < 10000; i++ {
        time.Sleep(time.Second)
    }
    fmt.Println(m, s)   // GCがsleep中にmを消さないためのおまじない。ここに来る前に C-c で止める
}

go tool pprof の実行例:

$ go tool pprof mem-sample http://[::1]:6000/debug/pprof/heap
Fetching profile from http://[::1]:6000/debug/pprof/heap
Saved profile in /home/inada-n/pprof/pprof.mem-sample.[::1]:6000.inuse_objects.inuse_space.005.pb.gz
Entering interactive mode (type "help" for commands)
(pprof) top
123.11MB of 123.12MB total (  100%)
Dropped 60 nodes (cum <= 0.62MB)
      flat  flat%   sum%        cum   cum%
   69.24MB 56.24% 56.24%    99.36MB 80.70%  main.genMap
   40.89MB 33.21% 89.45%    40.89MB 33.21%  fmt.Sprintf
   12.98MB 10.54%   100%    23.75MB 19.29%  main.genSlice
         0     0%   100%   123.11MB   100%  main.main
         0     0%   100%   123.12MB   100%  runtime.goexit
         0     0%   100%   123.12MB   100%  runtime.main
(pprof) top -cum
123.11MB of 123.12MB total (  100%)
Dropped 60 nodes (cum <= 0.62MB)
      flat  flat%   sum%        cum   cum%
         0     0%     0%   123.12MB   100%  runtime.goexit
         0     0%     0%   123.12MB   100%  runtime.main
         0     0%     0%   123.11MB   100%  main.main
   69.24MB 56.24% 56.24%    99.36MB 80.70%  main.genMap
   40.89MB 33.21% 89.45%    40.89MB 33.21%  fmt.Sprintf
   12.98MB 10.54%   100%    23.75MB 19.29%  main.genSlice
(pprof) list main.main
Total: 123.12MB
ROUTINE ======================== main.main in /home/inada-n/work/techblog/20151218_inada-n/mem-sample.go
         0   123.11MB (flat, cum)   100% of Total
         .          .     26:}
         .          .     27:
         .          .     28:func main() {
         .          .     29:   runtime.MemProfileRate = 1024
         .          .     30:   go http.ListenAndServe(":6000", nil)
         .    99.36MB     31:   m := genMap()
         .    23.75MB     32:   s := genSlice()
         .          .     33:   for i := 0; i < 10000; i++ {
         .          .     34:       time.Sleep(time.Second)
         .          .     35:   }
         .          .     36:   fmt.Println(m, s)
         .          .     37:}

メモリーリークの調査

メモリープロファイラを使うもうひとつのよくある目的は、メモリーリークの調査です。すでにメモリーリークしているプログラムでどこがメモリーリークしているのかを調査したり、本番投入前のプログラムにメモリーリークがないことを確認したい事が多いでしょう。

この場合、時間差で2回プロファイルを取得して、 inuse の差分を調査することでメモリー使用量が増えている箇所を発見することができます。
単にメモリーリークの量を減らしたいのであれば inuse_space で良いですが、メモリーリークを根絶したいのであれば runtime.MemProfileRate をできるだけ小さくして inuse_spaceinuse_objects の両方を観察することで小さいメモリオブジェクトのリークも見逃さないようにできます。

サンプルコード:

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
    "runtime"
    "time"
)

func leaker(c chan bool) []string {
    s := []string{}
    for i := 0; ; i++ {
        select {
        case <-c:
        default:
        }

        s = append(s, fmt.Sprintf("foo%d", i))
        if len(s)%1000 == 0 {
            fmt.Println(len(s))
        }
        time.Sleep(time.Millisecond)
    }
    return s
}

func main() {
    runtime.MemProfileRate = 1
    go http.ListenAndServe(":6000", nil)
    c := make(chan bool)
    go leaker(c)
    for {
        time.Sleep(time.Second)
    }
}

go tool pprof の実行例:

$ go tool pprof mem-sample2 http://localhost:6000/debug/pprof/heap
Fetching profile from http://localhost:6000/debug/pprof/heap
Saved profile in /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.001.pb.gz
Entering interactive mode (type "help" for commands)
(pprof)

$ go tool pprof -base /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.001.pb.gz mem-sample2 http://localhost:6000/debug/pprof/heap
Fetching profile from http://localhost:6000/debug/pprof/heap
Saved profile in /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.003.pb.gz
Entering interactive mode (type "help" for commands)
(pprof) top
3.70MB of 3.44MB total (  100%)
Dropped 118 nodes (cum <= 0.02MB)
      flat  flat%   sum%        cum   cum%
    1.96MB 56.95% 56.95%     3.44MB   100%  main.leaker
    1.75MB 50.81%   100%     1.75MB 50.83%  fmt.Sprintf
         0     0%   100%     3.43MB 99.89%  runtime.goexit
(pprof) list main.leaker
Total: 3.44MB
ROUTINE ======================== main.leaker in /home/inada-n/work/techblog/20151218_inada-n/mem-sample2.go
    1.96MB     3.44MB (flat, cum)   100% of Total
         .          .     14:       select {
         .          .     15:       case <-c:
         .          .     16:       default:
         .          .     17:       }
         .          .     18:
    1.96MB     3.70MB     19:       s = append(s, fmt.Sprintf("foo%d", i))
         .          .     20:       if len(s)%1000 == 0 {
      -48B       -48B     21:           fmt.Println(len(s))
         .          .     22:       }
         .  -271.19kB     23:       time.Sleep(time.Millisecond)
         .          .     24:   }
         .          .     25:   return s
         .          .     26:}
         .          .     27:
         .          .     28:func main() {
(pprof)

$ go tool pprof -inuse_objects -base /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.001.pb.gz mem-sample2 http://localhost:6000/debug/pprof/heap
Fetching profile from http://localhost:6000/debug/pprof/heap
http fetch http://localhost:6000/debug/pprof/heap: Get http://localhost:6000/debug/pprof/heap: dial tcp 127.0.0.1:6000: getsockopt: connection refused
~/work/techblog/20151218_inada-n (pprof2)
$ go tool pprof -inuse_objects -base /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.001.pb.gz mem-sample2  /home/inada-n/pprof/pprof.mem-sample2.localhost:6000.inuse_objects.inuse_space.003.pb.gz
Entering interactive mode (type "help" for commands)
(pprof) top  
110088 of 105725 total (  100%)
Dropped 118 nodes (cum <= 528)
      flat  flat%   sum%        cum   cum%
    114445   100%   100%     114448   100%  fmt.Sprintf
         0     0%   100%     105716   100%  runtime.goexit
     -4357  -4.1%   100%     105752   100%  main.leaker
(pprof) list main.leaker
Total: 105725
ROUTINE ======================== main.leaker in /home/inada-n/work/techblog/20151218_inada-n/mem-sample2.go
     -4357     105752 (flat, cum)   100% of Total
         .          .     14:       select {
         .          .     15:       case <-c:
         .          .     16:       default:
         .          .     17:       }
         .          .     18:
     -4354     110094     19:       s = append(s, fmt.Sprintf("foo%d", i))
         .          .     20:       if len(s)%1000 == 0 {
        -3         -3     21:           fmt.Println(len(s))
         .          .     22:       }
         .      -4339     23:       time.Sleep(time.Millisecond)
         .          .     24:   }
         .          .     25:   return s
         .          .     26:}
         .          .     27:
         .          .     28:func main() {
(pprof)

fmt.Sprintf は明確に捕捉できていますが、それ以外のリークしていないはずの部分にも差分があったり、逆にスライスへの append がマイナスになっていたりします。
これはGCが実行されるタイミングの問題かなにかだと諦めていますが、対策として2回だけでなく何度かプロファイルを取得して傾向を見ることにしています。

GCのチューニング

Go の GC は、 Go 1.5 の段階では世代なしの並列な Mark & Sweep で、型を利用してポインタを正確に把握しています。

なのでGCの実行時間が長い時にチューニングするには、 scan が遅いのであればプログラム中の生存しているポインタの数を減らす、アロケートが多くて頻繁にGCが発生しているならアロケートを減らすチューニングが有効です。
(GCのどの部分が遅いのかは GODEBUG 環境変数に gctrace=1 を設定して出力を見ます)

scan を減らす場合は、通常のメモリ使用量の調査を行い、メモリを大量に使っているところからポインタをたくさん持っている箇所を探します。

アロケートを減らす場合は、 alloc_objects を見てアロケートが多い箇所を探すことができます。

紹介しなかったプロファイル

Go のランタイムが提供している他のプロファイルについて簡単に触れておきます。

1つ目は ThreadCreateProfile で、これは goroutine を動かすのに利用しているOSレベルのスレッドを生成することになった箇所のプロファイルです。

Go は通常CPUコア数や設定された数の goroutine を並列に動かそうとOSスレッドを用意しますが、CGOの呼び出しやシステムコールがすぐに戻ってこなかった場合にはさらにOSスレッドを立ち上げます。
ThreadCreateProfile を利用することで、プログラムの中でシステムコールがブロックしている部分を見つけることができるかもしれません。

プログラムが大量のOSスレッドを利用しているけれども、大量のスレッドが必要になるのが一瞬なので普通にスタックダンプをとってもその瞬間が捉えられない場合などに使うと良いでしょう。

もう1つが BlockProfile です。これは Mutex 待ちや channel 待ちなどでブロックする時間をプロファイルできます。
プログラムのレイテンシが時々異常に悪化するけれども、CPU使用率が高いわけではなく、またその瞬間のスタックダンプを取得することも難しいという場合に利用すると良いかもしれません。

しかし、 BlockProfile のプロファイリングはデフォルトで無効になっているので、事前に runtime.SetBlockProfile を設定しておく必要があります。

次回予告

明日は、 pprof の仕組みと、独自のプロファイルの作り方を解説します。

このエントリーは、 KLab Advent Calendar 2015 の17日目の記事です。

pprof は Go の標準ライブラリにあるプロファイラです。

CPUを使用している部分を見つけるだけでなく、CPUを使ってないのにレイテンシが悪化するケースやメモリリークを発見したり、長時間安定して動くサーバープログラムを開発するのにとても便利です。

今日から3日間の連載でこの pprof の使い方や仕組みについて見て行きます。最初は一番基本となる CPU プロファイルの取得方法と、 go tool pprof の使い方を説明します。

Note: Macでの注意点

Mac OS X の Mavericks 以前は、 Go の CPUProfiler が利用している SIGPROF シグナルに問題があり、 CPU プロファイルを取るためにカーネルにパッチを当てる必要がありました。
この問題は El Capitan で解決されています。 El Capitan にアップデートできない人は、 https://github.com/rsc/pprof_mac_fix を利用して自己責任でカーネルパッチを当ててください。

プロファイルの取得

プロファイルの取得方法は大きく分けて2つあります。

  1. runtime/pprof が提供しているAPIを使ってファイルに出力する
  2. net/http/pprof を使ってプロファイル取得用の http サーバーを立てる

基本的には、短時間で実行が終了するプログラムは1番を、常駐型のプログラムは2番を選ぶと良いです。

runtime/pprof を使う場合

Dave Cheney 氏が runtime/pprof を使いやすくするラッパーライブラリ profile を提供しているので、その使い方を紹介しておきます。

まずはインストールします。

go get -u github.com/davecheney/profile

次に README にあるやり方で CPU プロファイルを取得してみます。

// sample1.go
// davecheney/profile の利用例
package main

import (
    "github.com/davecheney/profile"
)

func fib(n int) int {
    if n < 2 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

func main() {
    defer profile.Start(profile.CPUProfile).Stop()

    for i := 0; i < 1000; i++ {
        fib(27)
        fib(28)
        fib(29)
        fib(30)
    }
}

このプログラムを実行してみます。

$ go build sample1.go 
$ ./sample1 
2015/12/14 02:43:01 profile: cpu profiling enabled, /tmp/profile012357424/cpu.pprof
$

この利用方法では、 main 関数を抜けるときにプロファイルを止めて、 tmp ディレクトリ内にサブディレクトリを作り cpu.pprof というファイル名でプロファイル結果を保存します。 Ctrl-C でプログラムを止める場合などは、シグナルを処理して main 関数を正常に抜けるように注意してください。

このプロファイル結果に対して pprof のインタラクティブシェルを立ち上げるには、次のようにします。

$ go tool pprof sample1 /tmp/profile012357424/cpu.pprof 
Entering interactive mode (type "help" for commands)
(pprof) 

net/http/pprof を使う場合

先ほどのプログラムを net/http/pprof 版にしてみます。 net/http/pprof は長時間動くプログラム向けなので、 main 関数の中身を無限ループに書き換えています。

// sample2.go
// net/http/pprof の利用例
package main

import (
    "fmt"
    "net"
    "net/http"
    _ "net/http/pprof"
)

func fib(n int) int {
    if n < 2 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

func main() {
    l, err := net.Listen("tcp", ":0")
    if err != nil {
        panic(err)
    }
    fmt.Printf("Listening on %s\n", l.Addr())
    go http.Serve(l, nil)

    for {
        fib(27)
        fib(28)
        fib(29)
        fib(30)
    }
}

net/http/pprof は import の副作用として http.DefaultServeMux/debug/pprof/* なパスを登録するのでそれを利用します。パッケージが公開している要素を利用していないので、 _ を使わないとコンパイルエラーになります。
":0" を Listen しているのはイディオムで、空きポートを Listen します。実際にはポート番号が割り当てられているので、それを知るために fmt.Printf をしています。

実行するとこのように表示されます。

$ go build sample2.go 
~/t/pprof
$ ./sample2 
Listening on [::]:35664

ターミナルの別のタブを開いて、次のようなコマンドを実行すると30秒間プロファイルを取った後に pprof のインタラクティブシェルが起動します。

$ go tool pprof http://127.0.0.1:35664/debug/pprof/profile
Fetching profile from http://127.0.0.1:35664/debug/pprof/profile
Please wait... (30s)
Saved profile in /home/inada-n/pprof/pprof.127.0.0.1:35664.samples.cpu.001.pb.gz
Entering interactive mode (type "help" for commands)
(pprof) 

http 経由で取得したプロファイルを $HOME/pprof/ 配下に保存しているので、後で起動したいときはそのファイルを指定して起動します。

$ go tool pprof /home/inada-n/pprof/pprof.127.0.0.1:35664.samples.cpu.001.pb.gz
Entering interactive mode (type "help" for commands)
(pprof)

runtime/pprof を使った場合に比べて、以下の違いに注目してください。

  1. go tool pprof コマンドで実行ファイルを省略できる。
  2. プロファイル結果が保存されるファイルに .pb.gz という拡張子が付いている。

http 経由のプロファイル取得は、ネットワーク経由で別のサーバーのプログラムのプロファイルを取る際に手元にプロファイル対象となるバイナリが無いケースに対応できるように、生のプロファイル結果に加えてシンボル情報も取得し、プロファイル結果から関数名がわかるようになっています。

とはいえ、 pprof ツールの機能の中にはシンボル情報だけでなくデバッグ情報が必要なものもあるので、できればバイナリファイルを省略せずに指定したほうが良いでしょう。その場合は次のように、URLやプロファイル結果ファイル名の前にバイナリファイル名を書きます。

$ go tool pprof sample2 /home/inada-n/pprof/pprof.127.0.0.1:35664.samples.cpu.001.pb.gz
Entering interactive mode (type "help" for commands)
(pprof)

pprof コマンドラインツールの使い方

top

一番基本的なコマンドは top です。これは実行時間の多い順に関数を表示します。

(pprof) top
30s of 30.02s total (99.93%)
Dropped 13 nodes (cum <= 0.15s)
      flat  flat%   sum%        cum   cum%
       30s 99.93% 99.93%        30s 99.93%  main.fib
         0     0% 99.93%        30s 99.93%  main.main
         0     0% 99.93%        30s 99.93%  runtime.goexit
         0     0% 99.93%        30s 99.93%  runtime.main

top コマンドに続けて数値を書くと、上位何件を表示するかを指定することができます。

(pprof) top 2
30s of 30.02s total (99.93%)
Dropped 13 nodes (cum <= 0.15s)
Showing top 2 nodes out of 4 (cum >= 30s)
      flat  flat%   sum%        cum   cum%
       30s 99.93% 99.93%        30s 99.93%  main.fib
         0     0% 99.93%        30s 99.93%  main.main

top と 2 の間のスペースは必須ではないので、 top2 のように書くこともできます。

今回のケースでは自明ですが、重い関数自体ではなく、その関数の呼び出し元がどこかが問題になるケースがあります。例えば正規表現が重い事がわかっても、プログラム中に正規表現を使っている箇所が複数あり、どの場所で使ってる正規表現が重いのかがわからない場合などです。こういった場合は、 cumulative (その関数自体だけでなく、その関数から呼びだされた関数の実行時間も合わせた合計時間) でソートする -cum オプションを使います。

(pprof) top -cum
30s of 30.02s total (99.93%)
Dropped 13 nodes (cum <= 0.15s)
      flat  flat%   sum%        cum   cum%
       30s 99.93% 99.93%        30s 99.93%  main.fib
         0     0% 99.93%        30s 99.93%  main.main
         0     0% 99.93%        30s 99.93%  runtime.goexit
         0     0% 99.93%        30s 99.93%  runtime.main
(pprof) 

今回のケースでは、 fibmain が同じサンプル数 (重さ) だったために、 -cum を使っていても fib の方が上に来てしまいました。実際のプログラムでも、サンプル数が足りないとか偏りが激しい場合はこのような事が起こり得ます。

peek

呼び出し元を知る他の方法として、 peek コマンドがあります。このコマンドは引数に関数名のパターンを受け取ります。例えば main. とすれば main パッケージの関数をすべて指定することができます。

(pprof) peek main.
30s of 30.02s total (99.93%)
Dropped 13 nodes (cum <= 0.15s)
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context          
----------------------------------------------------------+-------------
                                               30s   100% |   main.main
       30s 99.93% 99.93%        30s 99.93%                | main.fib
----------------------------------------------------------+-------------
                                               30s   100% |   runtime.main
         0     0% 99.93%        30s 99.93%                | main.main
                                               30s   100% |   main.fib
----------------------------------------------------------+-------------

context カラムが、注目している関数と、その呼び出し元、呼び出し先です。最初の行は main.fib の呼び出し元が main.main だけで、呼び出し先が存在していないことを示しています。次の行は main.main の呼び出し元が runtime.main, 呼び出し先が main.fib のそれぞれ1つずつであることを示しています。今回のサンプルプログラムはシンプルすぎるので意味がありませんが、実際のプログラムではどこからの呼び出しが多いのかなどを知ることができます。

list

もうひとつよく利用するコマンドが list です。このコマンドは引数に関数名のパターンを取ります。

(pprof) list main.
Total: 30.02s
ROUTINE ======================== main.fib in /home/inada-n/t/pprof/sample2.go
       30s     41.66s (flat, cum)   100% of Total
         .          .      7:   "net"
         .          .      8:   "net/http"
         .          .      9:   _ "net/http/pprof"
         .          .     10:)
         .          .     11:
     8.44s      8.44s     12:func fib(n int) int {
     1.19s      1.19s     13:   if n < 2 {
     2.03s      2.03s     14:       return n
         .          .     15:   }
    18.34s        30s     16:   return fib(n-1) + fib(n-2)
         .          .     17:}
         .          .     18:
         .          .     19:func main() {
         .          .     20:   l, err := net.Listen("tcp", ":0")
         .          .     21:   if err != nil {
ROUTINE ======================== main.main in /home/inada-n/t/pprof/sample2.go
         0        30s (flat, cum) 99.93% of Total
         .          .     23:   }
         .          .     24:   fmt.Printf("Listening on %s\n", l.Addr())
         .          .     25:   go http.Serve(l, nil)
         .          .     26:
         .          .     27:   for {
         .      3.17s     28:       fib(27)
         .      5.11s     29:       fib(28)
         .      8.32s     30:       fib(29)
         .     13.40s     31:       fib(30)
         .          .     32:   }
         .          .     33:}
(pprof) 

このように、ソースコードの各行が何秒使ったかをわかりやすく表示してくれます。時間が (flat, cum) で2つ並んでいますが、左側がその関数自体が消費した時間、右側がその行で呼びだしている関数の時間も合わせた消費時間になります。 main.main を見れば、 fib(27), fib(28), fib(29), fib(30) の実行時間が 3, 5, 8, 13 秒とフィボナッチ数列になってるのが判ります。

list コマンドはとても便利ですが、プロファイル結果からソースコードの行を特定するためにデバッグ情報が必要なので、 http 経由でプロファイルするときもバイナリファイルを指定する必要があります。また、ソースを表示するためにデバッグ情報として入っているフルパスを参照しているので、ビルドしたマシンで利用するのが一番楽です。
Mac で Linux 用バイナリをクロスコンパイルして Linux 上で実行している場合は、 Mac から Linux 上の http に対して go tool pprof を実行するか、 Linux 上で一旦プロファイル結果をファイルに書き出して Mac に転送するのが良いでしょう。

また、 list の仲間として disasm という逆アセンブル結果を表示するコマンドもあり、これはバイナリがあればソースコードは必要ありません。

グラフ表示

sample

この png ファイルは次のコマンドで生成しました。

$ go tool pprof -png sample2 ~/pprof/pprof.127.0.0.1\:35664.samples.cpu.001.pb.gz > sample2.png

png の代わりに -svg オプションで svg を使うこともできます。 go-nuts などでプロファイル結果を共有するのによく svg が添付されています。大きいプログラムのプロファイル結果をグラフ化するときは、 -focus=パターン オプションで注目する関数を中央に持ってくると良いでしょう。

次回予告

明日は pprof シリーズ第二弾ということで、 Go が標準で提供している CPU プロファイル以外のプロファイル機能について紹介していきます。

このエントリーは、KLab Advent Calendar 2015の12/14の記事です。

@hhattoです。
私は主にゲームのサーバ開発や開発チームのマネイジメントに従事しています。

今回はexpvarを使ったGo製サーバのモニタリングとその周辺ツールについて書きます。

expvar

開発の初期段階やゲームのコア部分の開発をおこなってる際は、
なかなかインフラやモニタリングツールなどにまで手がまわらない事が多いのではないでしょうか?

expvarを使えば簡単にメトリクスを取得することができます。
http://hostname:port/debug/varsにアクセスすることでメトリクスを
JSONデータで取得することが可能です。

expvarの使い方等は公式ドキュメントや以下の記事が参考になります。

ゴルーチンとCPU使用率を取得するサンプルコード例は以下の通りです。

package main

import (
    "expvar"
    "net/http"
    "os"
    "runtime"
    "time"

    "github.com/shirou/gopsutil/process"
)

var PID int
var (
    svMetrics  = expvar.NewMap("sv")
    Goroutines = new(expvar.Int)
    CpuPercent = new(expvar.Float)
)

func getCpuPercent(pid int) (percent float64) {
    proc, err := process.NewProcess(int32(pid))
    if err == nil {
        percent, err = proc.CPUPercent(time.Second * 4)
        if err != nil {
            return 0.0
        }
    }
    return percent
}

func RunCollector() chan<- bool {
    fin := make(chan bool, 1)
    ticker := time.Tick(time.Second * 5)
    ticker4cpu := time.Tick(time.Second * 1)
    go func() {
        for {
            select {
            case <-fin:
                return
            case <-ticker:
                Goroutines.Set(int64(runtime.NumGoroutine()))
            case <-ticker4cpu:
                CpuPercent.Set(getCpuPercent(PID))
            }
        }
    }()
    return fin
}

func main() {
    PID = os.Getpid()
    svMetrics.Set("Goroutines", Goroutines)
    svMetrics.Set("Cpu", CpuPercent)
    _ = RunCollector()
    http.ListenAndServe(":9999", nil)
}

expvarmon

前述の通りexpvarを使用すれば簡単にメトリクスを取得することが可能になりますが、
HTTPアクセスに対してJSONデータを返すだけですので、そのままでは確認が取りにくいと思います。
それをもっと簡単に確認できれば効率があがるかもしれません。
expvarmonはターミナル上で
expvarをフェッチして表示するコマンドラインツールです。

ツールのインストールと実行例は以下の通りです。

$ go get github.com/divan/expvarmon
$ expvarmon -i=1s -ports 8091,8092,8093,9009 -vars="sv.Goroutines,sv.Cpu,mem:memstats.Alloc,duration:memstats.PauseNs,duration:memstats.PauseTotalNs"

expvarmon
指定した更新間隔でメトリクスをフェッチして見やすく表示してくれます。
ツールを起動し続けておけばサーバがダウンしたかどうかも確認可能です。
(Servicesの名前の左側に🔥 や⛔ アイコンが表示されます。)

expwebmon

expvarmonはそれ自体とても素晴らしいツールなのですが、
もう少しグラフ(チャート)等が綺麗に表示されるとテンションあがるよね、
っという単純な思考からブラウザ経由でモニタできるツールを作ってみました。

expwebmon

使い方

$ go get github.com/hhatto/expwebmon
$ expwebmon -fetchports=8091 -vars="sv.Goroutines,sv.Cpu,memstats.Alloc,memstats.NumGC,memstats.PauseTotalNs"

デフォルトでポート9999番でサーバを起動し、ブラウザ経由でメトリクスを確認することが可能です。

ここでバシッとキラキラしたUIを収めたスクリーンショットをお見せできれば良かったのですが、
なかなかうまくいきませんでした。
現状は以下のようなものになっています。

expwebmon

正直しょぼい!!今後の改善点にしたいと思います。

まとめ

expvarexpvarmon便利だよねという記事でした。

モニタリングツールはOSSプロジェクトや外部サービス等数多ありますが、
Goを使った開発時のメトリクスをサクッと確認できる手段としてexpvarmon (やexpwebmon)を
使ってみてはいかがでしょうか?

expwebmonはもう少しブラッシュアップしたかったのですが、時間切れなので今後も継続して開発してきます。
サイドプロジェクトでのツール開発はゲーム作りにも並ぶくらい楽しい時間でした。

明日の担当は、kakkun61さんです。

このエントリーはKLab Advent Calendarの12日目の記事です。

@tenntennです。

12月6日(日)に渋谷でGo Conference 2015 Winterが開催されました。
私は運営として参加し、当日司会をしておりました。
KLabからもいつものエナジードリンクを提供させて頂き、参加者の皆様に飲んで頂けたかなと思います。

この記事ではGoチームのAndrew Gerrandさんのキーノート"x/mobile gaming"を解説したいと思います。
当日参加できなかった方も、参加したけどちょっと内容が難しかった方にも、"x/mobile gaming"を理解する助けとなればと思います。

なお、この記事で扱うソースコードは、Andrewさんのリポジトリから引用し、一部補足をいれたものです。
また、スクリーンショットに含まれるGopherの画像(を含むスプライト画像)は、リポジトリのREADMEに書いてある通り、Renee Frenchさんによるものです。
この画像は、Creative Commons Attributions 3.0ライセンスで保護されています。

なお、Go MobileはまだExperimentalなプロジェクトです。
今後も破壊的な変更が加わる恐れがあります。
そのためまだプロダクトへの導入は難しいでしょう。

この記事ではOSX Yosemiteで動作確認しています。
しかし、他の環境でも動作するようですので、ぜひ試してみてください。

STEP 0: Go Mobileをインストールする

まずは準備を行っていきたいと思います。

このキーノートは、Andrewさんが作ったdtというツールを使って、1コミットずつ差分を確認しながら実行していくものでした。
最終的には、"Flappy Gopher"というゲームができあがります。
1ステップずつ徐々にゲームが完成していく様子を見ながら、Go Mobileの基礎的な部分を理解できるという、とても良いプレゼンでした。
また、ゲームを作った事のない方にとっては、ゲームの作り方も学べる興味深いものだったかと思います。

まずは、dtをインストールしましょう。

$ go get github.com/adg/dt

$GOPATH/binPATHが通されていれば、dtコマンドを動かすことができます。

$ dt
usage: dt <head-rev>

つぎに、Go Mobileをインストールしましょう。
go getしてgomobile initをすればインストールできます。
Go Mobileの説明や詳細はこちらの記事を参考にすると良いでしょう。
なお、gomobile initは結構時間がかかるので、-vオプションをつけて進捗を確認できるようにしておくと分かりやすいと思います。
もしすでにGo Mobileが入っている方は、念のためgo get -uで更新しておくとよいでしょう。

$ go get golang.org/x/mobile/cmd/gomobile
$ gomobile init -v

さて、Go Mobileが入ったところで、さっそくサンプルを動かしてみましょう。
以下のように通常のGoのプログラムの実行方法と同じように、go runでMac上で動かすことができます。

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

せっかくなので、Androidでも動かしてみましょう。
adbコマンドがMacに入っていることを確認して、以下のコマンドでapkをビルドしてAndroidにインストールします。

$ adb
Android Debug Bridge version 1.0.32

 -a                            - directs adb to listen on all interfaces for a connection
....
$ gomobile install golang.org/x/mobile/example/basic

しばらくすると、Androidの方にbasicという名前のアプリがインストールされているかと思います。

それでは、キーノートで使用された"Flappy Gopher"のリポジトリをgo getしてきましょう。

$ go get github.com/adg/game

一旦動かしてみましょう。

$ cd $GOPATH/src/github.com/adg/game
$ go run *.go

以下のような画面が出てきたはずです。
遊ぶのは最後のステップまで楽しみにとっておきましょう。

実行結果

さぁ、それでは1ステップずつ作っていきましょう。

STEP 1: Basic app boilerplate; display an empty window.

対応するコミット:4a9e64a5e0ae04c9535d3f760e25de93f8eeef87

以下のように、dtに最後のコミットから1つ前(最後のコミットはREADMEの追加のため)のコミット番号を渡します。

$ dt 7936d2

そうすると、プロンプトが出てきます。
選べる選択しは、qrnと数字です。
qdtの終了、rは実行、nは次のコミットへ移動です。
数字を入力するとプロンプトの上に表示されているファイル一覧の差分(1つ前のコミットからの)を確認できます。

Basic app boilerplate; display an empty window.

Changed files:
  [1] main.go

  Choice [qrn1]:

一番上に書いてある「Basic app boilerplate; display an empty window.」は、コミットメッセージです。
このコミットメッセージは最初のコミットのものなので、現在は最初のコミットにいることが分かります。

それでは実行してみましょう。

Basic app boilerplate; display an empty window.

Changed files:
  [1] main.go

  Choice [qrn1]: r

STEP1の実行結果

空のウィンドウですね。
main.goを見ると、アプリの雛形しか書かれてないことが分かります。
main関数内でapp.Mainメソッドを呼び出し、その中でイベントループを回しています。
イベントは、a.Eventsメソッドで取得できるチャネルから送られてきます。
イベントについてはこちらの記事を見て頂けると詳細に説明しています。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            switch e := a.Filter(e).(type) {
            case lifecycle.Event:
                switch e.Crosses(lifecycle.StageVisible) {
                case lifecycle.CrossOn:
                    // App visible.
                case lifecycle.CrossOff:
                    // App no longer visible.
                }
            case paint.Event:
                a.Publish()
            }
        }
    })
}

main.go以外にも、placeholder-sprites.pngというファイルも追加されています。
こちらはスプライト画像で、後のステップでキャラクターや地面などに使われる画像です。
最初のコミットでは、色がベタ塗りしてありますが、最終的にはGopherの画像などになります。

STEP 2: Add OpenGL-based sprite engine.

対応するコミット:43ef6bb2400cff8bbf84d1c4168e7fa7244d19a7

onStart、onStop、onPaintの追加

このステップでは、onStartonStoponPaintの3つの関数が追加されています。

var (
        startTime = time.Now()
        images    *glutil.Images
        eng       sprite.Engine
        scene     *sprite.Node
)

func onStart(glctx gl.Context) {
        images = glutil.NewImages(glctx)
        eng = glsprite.Engine(images)
        scene = &sprite.Node{}
        eng.Register(scene)
        eng.SetTransform(scene, f32.Affine{
                {1, 0, 0},
                {0, 1, 0},
        })
}

func onStop() {
        eng.Release()
        images.Release()
}

func onPaint(glctx gl.Context, sz size.Event) {
        glctx.ClearColor(1, 1, 1, 1)
        glctx.Clear(gl.COLOR_BUFFER_BIT)
        now := clock.Time(time.Since(startTime) * 60 / time.Second)
        eng.Render(scene, now, sz)
}

onStart

onStart関数では、スプライトエンジンの初期化を行っています。
Go Mobileでは、2Dのスプライトエンジンを提供しています。
スプライトエンジンを使えば、簡単に2Dのシーングラフを構築することができます。
spriteパッケージでは、スプライトエンジンを表すEngineインタフェースを提供しています。
具体的な実装はOpenGL以外にも取れるように別のパッケージに任せてあります。
OpenGLの実装は、sprite/glspriteにあります。
なお、スプライトエンジンの詳細についても、こちらの記事で説明してあります。

onStart関数では、以下の手順でスプライトエンジンの初期化を行っています。

  • OpenGLのコンテキストからImagesオブジェクトの作成
  • エンジンオブジェクトの生成
  • シーンのルートノードを作成
  • ルートノードをエンジンオブジェクトに登録
  • ルートの初期位置やスケールを設定する

Imagesオブジェクトは、OpenGLのテクスチャを作るためのオブジェクトです。
内部にシェーダーなどを持ちテクスチャを生成するために使います。

spriteパッケージを使って2Dのシーンを描画するためには、シーングラフを構築していきます。
ここでは、シーングラフのルートノード(scene)を作成し、エンジンオブジェクトに登録しています。
シーングラフのノードには、アフィン変換行列を設定することでノードに設定したテクスチャがどの位置にどのような大きさや角度で描画されるのか決めます。
最終的なテクスチャの描画位置などは、ルートからそのノードまでのアフィン変換行列を掛けあわせた結果が用いられます。

onStop

onStopでは、エンジンオブジェクトとテクスチャを破棄しています。

onPaint

onPaintでは、背景を白く塗りつぶし、シーンのレンダリングを行っています。
Renderメソッドに渡しているnowという変数は、現在のフレームを表しています。
この場合は、60FPSで計算されています。
szについては後ほど説明します。

イベントのハンドリング

このステップでは、各イベントをハンドリングするコードも追加されています。

lifecycle.Event

lifecycle.Eventをハンドリングすることで、画面が表示された場合や表示されなくなった場合に処理をすることができます。
この場合は、lifecycle.StageVisibleというStageに入った(またはそこから出た)かを見ています。
こうすることで、画面が表示され始めた、または表示されなくなったタイミングで処理を行えます。

lifecycle.CrossOnは、そのStageに入ったことを表し、この場合だと画面が表示されたタイミングになります。
画面が表示されると、OpenGLのコンテキストを取得しonStartを呼び出します。
その後、paint.Eventを発生させ、描画を促します。

lifecycle.CrossOffは、そのStageから出たことを表し、この場合だと画面が表示されなくなったタイミングになります。
画面が表示されなくなると、onStopを呼び保持しているリソースを破棄します。
そして、OpenGLのコンテキストも破棄されます。

lifecycle.Eventについては、少し複雑で分かりづらいかと思います。
そのため、こちらの記事を見ていただけると図で説明していますので理解しやすいでしょう。

case lifecycle.Event:
        switch e.Crosses(lifecycle.StageVisible) {
        case lifecycle.CrossOn:
            // App visible.
            glctx, _ = e.DrawContext.(gl.Context)
            onStart(glctx)
            a.Send(paint.Event{})
        case lifecycle.CrossOff:
            // App no longer visible.
            onStop()
            glctx = nil
        }

size.Event

size.Eventは、画面の大きさが変わった場合や画面を回転させた場合に発生するイベントです。
アプリ起動時に1度はpaint.Eventが発生する前に必ず呼ばれます。
szは、サイズ情報が格納されたもので、シーングラフを描画する際に用いられます。

case size.Event:
        sz = e

paint.Event

paint.Eventは描画イベントです。
ここでは、OpenGLのコンテキストがなかったりOSレイヤーから送られてきた描画イベントは無視しています。
a.Send(paint.Event{})のように、自前で発生させた描画イベントの場合は、e.Externalfalseになります。

onPaintメソッドを呼び出すことでシーングラフを構築し描画を行っています。
最終的に画面に出力するためには、Publishメソッドを呼び出す必要があります。

描画が一通り終わったら、再度描画イベントを走らせて次のフレームに移ります。

case paint.Event:
        if glctx == nil || e.External {
            continue
        }
        onPaint(glctx, sz)
        a.Publish()
        a.Send(paint.Event{}) // keep animating

実行結果

それではこのステップの実行結果を見てみましょう。

STEP2の実行結果

背景が白くなりましたね。

STEP 3: Add game.go and Game type to hold game logic.

対応するコミット:0cf230e77f624ee96a0e87dd482e8c35b282f3e1

game.goが追加されています。
このファイルには、ゲームロジックが記述されるGame型が構造体として宣言されています。
ここではまだ、Game構造体は新しいシーングラフを作成するSceneメソッドしか持っていません。
シーングラフの初期化は、onStartから移動されています。

実行してみましょう。

STEP3の実行結果

当然ながら描画部分をいじってないので何も変わりません。

STEP 4: Load textures from a PNG file.

対応するコミット:99aa823b17a8a4f6d2a63ea58bc2023ab86192b5

テクスチャのロード

game.goloadTexturesという関数が追加されています。
この関数は画像ファイルからテクスチャを読み込み、さらにサブテクスチャに分けて返します。

画像の読み込みは、asset.Open関数を使います。
os.Openと似たような関数ですが、モバイルアプリの場合、OSによってアプリごとに割り当てられるファイル領域が異なります。
その差分を吸収するためにasset.Openを使います。
もちろん、正しいパスを渡せばos.Openも使えます。
asset.Openは、mainパッケージがあるディレクトリ直下のassetsというディレクトリの中からファイルを探します。
この場合は、STEP1で追加したplaceholder-sprites.pngという画像を開いています。

asset.Openの返すasset.Fileインタフェースは、Readメソッドを持っているためimageパッケージのデコーダにそのまま渡すことができます。
ここでは、image.Decodeimage.Image型としてメモリ上に展開されます。

image.Image型をスプライトエンジンで扱うために、テクスチャに変換します。
LoadTextureメソッドを使うと渡したimage.Image型のオブジェクトに対応するテクスチャを作成してくれます。

シーングラフのノードには、ロードしたテクスチャをそのまま貼り付けるのではなく、サブテクスチャに分割して貼り付けなければいけません。
そのためloadTextures関数では、ロードしたテクスチャをサブテクスチャに分割し、スライスとして返しています。
ここでは、左端の128x128の青い部分をGopherとして切り出しています。

func loadTextures(eng sprite.Engine) []sprite.SubTex {
    a, err := asset.Open("placeholder-sprites.png")
    if err != nil {
        log.Fatal(err)
    }
    defer a.Close()

    m, _, err := image.Decode(a)
    if err != nil {
        log.Fatal(err)
    }
    t, err := eng.LoadTexture(m)
    if err != nil {
        log.Fatal(err)
    }

    const n = 128
    return []sprite.SubTex{
        texGopher: sprite.SubTex{t, image.Rect(1+0, 0, n-1, n)},
    }
}

Gopherノード

Game型のSceneメソッドに新たにGopherを表すノードが追加されています。
ノードを作成するために、newNodeというヘルパー関数を作っています。
newNode関数では、ノードの作成とエンジンオブジェクトへの登録、ルートノードの子要素への追加を行っています。
ノードの初期化時にArrangerというフィールドを設定しています。
Arrangerフィールドには、Arrangeメソッドを持つsprite.Arrangerインタフェースが設定できます。
このArrangeメソッドは、エンジンオブジェクトのRenderメソッドが呼び出され際に、毎フレーム呼びだされます。
そのため、各フレームごとにノードに設定するサブテクスチャや位置を変えることができます。
なお、簡単のためにここではArrangerインタフェースを実装したarrangerFuncという関数型を作っています。

Gopherノードには、loadTexturesで作成したサブテクスチャのうち、青色の画像の部分が設定されています。
ノードにサブテクスチャを設定するには、SetSubTexメソッドを用います。
ノードの位置は、SetTransformで設定されます。
この場合、画面を16x16で分割した場合に、(1,0)の位置に、16x16の大きさで描画するという設定になっています。

func (g *Game) Scene(eng sprite.Engine) *sprite.Node {
    texs := loadTextures(eng)

    scene := &sprite.Node{}
    eng.Register(scene)
    eng.SetTransform(scene, f32.Affine{
        {1, 0, 0},
        {0, 1, 0},
    })

    newNode := func(fn arrangerFunc) {
        n := &sprite.Node{Arranger: arrangerFunc(fn)}
        eng.Register(n)
        scene.AppendChild(n)
    }

    // The gopher.
    newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGopher])
        eng.SetTransform(n, f32.Affine{
            {tileWidth, 0, tileWidth * gopherTile},
            {0, tileHeight, 0},
        })
    })

    return scene
}

実行結果

それでは実行してみましょう。

STEP4の実行結果

まだGopherではないですが、青い画像が描画されましたね。

STEP 5: Draw the ground.

対応するコミット:a419e647280f8df94e697127de14bd86326807f2

このステップでは地表ノードが追加されています。

地表を表すサブテクスチャは、スプライト画像の左から4番目の紫の部分です。
loadTexturesで返すスライスに追加されています。

return []sprite.SubTex{
        texGopher: sprite.SubTex{t, image.Rect(1+0, 0, n-1, n)},
        texGround: sprite.SubTex{t, image.Rect(1+n*3, 0, n*4-1, n)},
}

Game型のSceneメソッドで地表ノードを作成しています。
画面の左端から順に、g.groundY[i]の高さにノードを描画しています。

// The ground.
for i := range g.groundY {
        i := i
        newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
                eng.SetSubTex(n, texs[texGround])
                eng.SetTransform(n, f32.Affine{
                        {tileWidth, 0, float32(i) * tileWidth},
                        {0, tileHeight, g.groundY[i]},
                })
        })
}

g.groundYは、地表ノードのY座標のスライスです。
新たに追加されたresetメソッドで初期化されています。

func (g *Game) reset() {
        for i := range g.groundY {
                g.groundY[i] = initGroundY
        }
}

実行してみましょう。

STEP5の実行結果

紫色の地表が描画されましたね。

STEP 6: Add a function to update game state for each frame.

対応するコミット:ccd80d34015a36064788476bb89dc8e65b25f518

Game型にUpdateメソッドとcalcFrameメソッドが追加されています。
Updateは最後に更新したフレームから現在のフレームまで、calcFrameメソッドを呼び出します。
calcFrameメソッドは、ここでは何もしませんが、そのフレームでのゲームの状態を計算するために用いられるようです。

func (g *Game) Update(now clock.Time) {
        // Compute game states up to now.
        for ; g.lastCalc < now; g.lastCalc++ {
                g.calcFrame()
        }
}

func (g *Game) calcFrame() {
        // calculate state for next frame
}

Updateメソッドは、main.goonPaint内で呼ばれています。

func onPaint(glctx gl.Context, sz size.Event) {
        glctx.ClearColor(1, 1, 1, 1)
        glctx.Clear(gl.COLOR_BUFFER_BIT)
        now := clock.Time(time.Since(startTime) * 60 / time.Second)
        game.Update(now)
        eng.Render(scene, now, sz)
}

このステップでは、前のステップと実行結果は変わらないので省略します。

STEP 7: Generate new ground tiles.

対応するコミット:6075f612890f3a6667c98b55c37b3317d4fd79c7

このステップでは、ついに画面が動き出します。
前のステップで作ったcalcFrameメソッドの中でcalcScrollメソッドが呼び出されています。
calcScrollメソッドでは、画面を横スクロールさせる処理が行われます。
具体的には、20フレームに1回(60FPSで1秒に3回)、あたらしい地表を作ります。

新しい地表の作成は、newGroundTileメソッドで行います。
nextGroundYメソッドで新しく作られる地表のY座標を計算し、groundYスライスをひとつずつずらすことで右から左へと地表をスクロールしています。

func (g *Game) calcFrame() {
        g.calcScroll()
}

func (g *Game) calcScroll() {
        // Create new ground tiles 3 times a second.
        if g.lastCalc%20 == 0 {
                g.newGroundTile()
        }
}

func (g *Game) newGroundTile() {
        // Compute next ground y-offset.
        next := g.nextGroundY()

        // Shift ground tiles to the left.
        copy(g.groundY[:], g.groundY[1:])
        g.groundY[len(g.groundY)-1] = next
}

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        return prev
}

main.goへの変更は、乱数の初期化だけです。

さて、動かしてみましょう。
今回は動きがあるので、gifファイルを貼っています。
地表を表す紫色のタイルが右から左へとスクロールしている事が分かると思います。

STEP7の実行結果

STEP 8: Draw the earth beneath the ground.

対応するコミット:6a85814a913aaee1fc6857c7b0b88c4b62e0c295

前のステップでは、地表だけを描画してきました。
横スクロールのゲームだと地表だけはなく地中も描画されることが多いと思います。
このステップでは、地表に加え地中も描画するようにSceneメソッドに処理を追加しています。

以下の部分が、地中を描画している部分です。
縦方向は画面一杯(tileHeight * tilesY)の大きさにし、それを地表の真下(g.groundY[i] + tileHeight)までずらすことで地中を作っています。

// The earth beneath.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texEarth])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i) * tileWidth},
                {0, tileHeight * tilesY, g.groundY[i] + tileHeight},
        })
})

地中のテクスチャは以下のようにスプライト画像の右から2番目の水色の部分が使用されています。

texEarth:  sprite.SubTex{t, image.Rect(1+n*4, 0, n*5-1, n)},

それでは、実行してみましょう。
前のステップで横スクロールするようになった地表の下に水色のタイルが描画されていることが分かります。

Gopherになるはずの青色のタイルは浮いたままですね。

STEP8の実行結果

STEP 9: Scroll the ground smoothly.

対応するコミット:491933c77eb328edcc34b7594691ff7edf01f65f

横スクロールするようになったはいいですが、スクロールがガタガタで気になります。
そこでこのステップでは、スクロールをもっとスムーズになるように処理を追加しています。

新しく以下の2つの定数が追加されています。
初速度と加速度です。スクロールがだんだんと加速していくことが予想できます。

const (
...
        initScrollV = 1     // initial scroll velocity
        scrollA     = 0.001 // scroll accelleration
...
)

スムーズなスクロールを実現するために、Game構造体にscrollフィールドを追加してます。
scrollフィールドは、xvというフィールドを持つ構造体です。
読者の方の中には初めて見る書き方かもしれませんが、単純に型を型リテラルで書いているだけです。

type Game struct {
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

描画部分にscroll.xで描画位置を微調整していることが分かります。

// The top of the ground.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGround])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i)*tileWidth - g.scroll.x},
                {0, tileHeight, g.groundY[i]},
        })
})
// The earth beneath.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texEarth])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, float32(i)*tileWidth - g.scroll.x},
                {0, tileHeight * tilesY, g.groundY[i] + tileHeight},
        })
})

scroll.xの更新は、calcScrollで行われています。
単純にscroll.vscrollAを足し、scroll.xに速度scroll.vを足しているだけです。
そして、スクロールした分だけ新しい地面を作っています。

func (g *Game) calcScroll() {
        // Compute velocity.
        g.scroll.v += scrollA

        // Compute offset.
        g.scroll.x += g.scroll.v

        // Create new ground tiles if we need to.
        for g.scroll.x > tileWidth {
                g.newGroundTile()
        }
}

実行してみましょう。

STEP9の実行結果

先ほどよりスクロールがスムーズになっていることが分かるかと思います。
放っておくと、スクロールがどんどん加速していきます。

STEP 10: Apply gravity to the gopher.

対応するコミット:e52508330f11fde4e41f9581629ba5bafcc02614

Gopherもいつまでも浮いてられないと思いますので、そろそろ重力を取り入れましょう。

定数にgravityが追加されています。
この定数がGopherに重力として働きます。

gravity     = 0.1   // gravity

Gopherにも状態を持たせる必要があるため、Game構造体にgopherフィールドが追加されています。
gopherフィールドも構造体になっていて、Y座標をy、速度(縦方向)をvに保持します。

type Game struct {
        gopher struct {
                y float32 // y-offset
                v float32 // velocity
        }
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

GopherのY座標を変数にしたため、SetTransformの部分も変更されています。
g.gopher.yを使用するになっています。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        eng.SetSubTex(n, texs[texGopher])
        eng.SetTransform(n, f32.Affine{
                {tileWidth, 0, tileWidth * gopherTile},
                {0, tileHeight, g.gopher.y},
        })
})

Gopherの座標を計算するためにcalcGopherという関数が追加され、calcFrame内で呼ばれています。
これも前のステップのようにただ速度に重力を足して、Y座標に速度を足しているだけです。

func (g *Game) calcGopher() {
        // Compute velocity.
        g.gopher.v += gravity

        // Compute offset.
        g.gopher.y += g.gopher.v
}

動かしてみましょう。

STEP10の実行結果

あっというまにGopherが落ちていきました。

STEP 11: Detect collision between the gopher and the ground.

対応するコミット:be4ce097afa5401fb90596967cfc2c2b82096ad7

前のステップで、Gopherに速度を持たせたものの、地面にぶつからずそのまま通りすぎてしまいました。
そこでこのステップでは、Gopherと地面の当たり判定の処理を追加しています。

clampToGroundというメソッドが追加され、この中で当たり判定の処理を行っています。
地面にめり込まないようにはみ出そうな場合は、速度を0にし、地表の上にGopherがくるようになっています。

func (g *Game) clampToGround() {
        // Compute the minimum offset of the ground beneath the gopher.
        minY := g.groundY[gopherTile]
        if y := g.groundY[gopherTile+1]; y < minY {
                minY = y
        }

        // Prevent the gopher from falling through the ground.
        maxGopherY := minY - tileHeight
        if g.gopher.y >= maxGopherY {
                g.gopher.v = 0
                g.gopher.y = maxGopherY
        }
}

STEP11の実行結果

STEP 12: Let the gopher jump.

対応するコミット:bbe69ef8e8874637dc48333c19afb3de30c57a22

ここまでGopherはただ横に走るだけでジャンプできませんでした。
このステップではジャンプするようにタッチイベントとキーイベントをハンドリングします。

タッチイベントは、touch.Eventとして送られてきます。
イベントオブジェクトのTypeフィールドに画面に触れたのか離したのかを取ることができます。
触れた場合は、game.Pressメソッドにtrueを渡し、離した場合はgame.Pressメソッドにfalseを渡しています。

キーイベントは、key.Eventとして送られてきます。
イベントオブジェクトのCodeに押されたキーのコードが送られてきます。
この場合は、スペースキー以外は無視しています。
キーイベントの方もDirectionで押したのか離したのかを取得し、押した場合はtrueを離した場合はfalsegame.Pressに渡しています。

for e := range a.Events() {
       switch e := a.Filter(e).(type) {
...
       case touch.Event:
               if down := e.Type == touch.TypeBegin; down || e.Type == touch.TypeEnd {
                       game.Press(down)
               }
       case key.Event:
               if e.Code != key.CodeSpacebar {
                       break
               }
               if down := e.Direction == key.DirPress; down || e.Direction == key.DirRelease {
                       game.Press(down)
               }
       }
}

このステップでは、Pressメソッドが追加されています。
downtrueの場合は速度にjumpVが設定されます。
jumpVは新しく定義された定数で、マイナスの値です。

downfalseの場合は、飛んでいる最中(マイナスの値)の場合は速度を0にします。
すでに落ちている最中の場合は何もしません。

func (g *Game) Press(down bool) {
        if down {
                // Make the gopher jump.
                g.gopher.v = jumpV
        } else {
                // Stop gopher rising on button release.
                if g.gopher.v < 0 {
                        g.gopher.v = 0
                }
        }
}

それでは、実行してみましょう。

STEP12の実行結果

スペースキーを押すとGopherが飛びます!
しかし、連打するとどこか遠くに行ってしまいます。。

STEP 13: Don't let the gopher jump from mid-air.

対応するコミット:f1a037772b41ee0752ad3d2d03630501300cff52

Gopherが空中でジャンプしないようにしましょう。

Game構造体のgopherフィールドの構造体にatRestというフラグが追加されています。

type Game struct {
        gopher struct {
                y      float32 // y-offset
                v      float32 // velocity
                atRest bool    // is the gopher on the ground?
        }
        scroll struct {
                x float32 // x-offset
                v float32 // velocity
        }
        groundY  [tilesX + 3]float32 // ground y-offsets
        lastCalc clock.Time          // when we last calculated a frame
}

このatResttrueの場合は、Press(true)が実行されても反応しないようになっています。

func (g *Game) Press(down bool) {
        if down {
                if g.gopher.atRest {
                        // Gopher may jump from the ground.
                        g.gopher.v = jumpV
                }
        } else {
                // Stop gopher rising on button release.
                if g.gopher.v < 0 {
                        g.gopher.v = 0
                }
        }
}

atRestは当たり判定の際に、地面に触れてない場合はfalseになります。
地面に接触した場合にtrueが設定され、ジャンプができるようになります。

func (g *Game) clampToGround() {
        // Compute the minimum offset of the ground beneath the gopher.
        minY := g.groundY[gopherTile]
        if y := g.groundY[gopherTile+1]; y < minY {
                minY = y
        }

        // Prevent the gopher from falling through the ground.
        maxGopherY := minY - tileHeight
        g.gopher.atRest = false
        if g.gopher.y >= maxGopherY {
                g.gopher.v = 0
                g.gopher.y = maxGopherY
                g.gopher.atRest = true
        }
}

実行結果を見てみましょう。

STEP13の実行結果

gifで見ても分かりづらいですが、地面に触れている時しかジャンプしてないことが分かると思います。
gifでは伝わらないですが、実はスペースを連打しています。

STEP 14: Detect crashes, allow the gopher to die.

対応するコミット:d16bc6c27d061e5fc43dbe36e7441bc50b77da2b

ここまでのGopherはかなり頑丈で壁にぶつかっても死にません。
しかし、このままではゲームにならないので、ぶつかったら死ぬようにしましょう。

gopherdeadというフィールドを追加し、生きている時はfalseが入り、死んだ場合にはtrueが入ります。
この値によってGopherノードに設定するサブテクスチャを変えています。
死んだ場合には、texs[texGopherDead]が設定されるように更新されています。

switch {
case g.gopher.dead:
        eng.SetSubTex(n, texs[texGopherDead])
default:
        eng.SetSubTex(n, texs[texGopher])
}

死んだ時のサブテクスチャには、スプライト画像の左から2番目の赤い部分を用います。

texGopherDead: sprite.SubTex{t, image.Rect(1+n, 0, n*2-1, n)},

死んだ場合には、Pressの中で何も処理を行わないようになっています。

func (g *Game) Press(down bool) {
        if g.gopher.dead {
                // Player can't control a dead gopher.
                return
        }
...

壁にぶつかった場合、Gopherは死にます。
そのため、地面を作る際にGopherにぶつかるかどうかをチェックしています。

func (g *Game) calcScroll() {
        // Compute velocity.
        g.scroll.v += scrollA

        // Compute offset.
        g.scroll.x += g.scroll.v

        // Create new ground tiles if we need to.
        for g.scroll.x > tileWidth {
                g.newGroundTile()

                // Check whether the gopher has crashed.
                // Do this for each new ground tile so that when the scroll
                // velocity is >tileWidth/frame it can't pass through the ground.
                if !g.gopher.dead && g.gopherCrashed() {
                        g.killGopher()
                }
        }
}

calcScrollの中で使われているgopherCrashedkillGopherは新しく追加されたメソッドです。
gopherCrashedメソッドは、右となりの壁とあたっているかチェックするメソッドです。
一方、killGopherはその名通りGopherを死んだ状態にするメソッドです。

func (g *Game) gopherCrashed() bool {
        return g.gopher.y+tileHeight > g.groundY[gopherTile+1]
}

func (g *Game) killGopher() {
        g.gopher.dead = true
}

これで壁にぶつかるとGopherが死んでしまうようになりました。
実行して見てみましょう。

STEP14の実行結果

うまくジャンプして壁を超えた場合は死なずにすみますが、壁にぶつかるとGopherが赤くなって死んでしまうことが分かります。

STEP 15: Bounce gopher when it dies.

対応するコミット:ce04396bedd07e61ddf0e02e7df2827c9b2e9a9d

Gopherが死んだあと動けなくなるので、その後そのままずっと引きずられてしまっています。
これでは可哀想なので、死んだら画面から外してやりましょう。

死んだ時にバウンドさせて、画面の下に落としてやると雰囲気がでますよね。
killGopherの時に速度をジャンプした時と同じように設定してやります。

func (g *Game) killGopher() {
        g.gopher.dead = true
        g.gopher.v = jumpV // Bounce off screen.
}

このままだと、地面に衝突してしまうので、地面を突き抜けるようにclampToGroundを修正します。

func (g *Game) clampToGround() {
        if g.gopher.dead {
                // Allow the gopher to fall through ground when dead.
                return
        }
...

さて、これで横スクロールゲームの雰囲気が出るようになりました。
動かしてみましょう。

STEP15の実行結果

雰囲気が出てきました。

STEP 16: Slow scrolling after the gopher dies.

対応するコミット:c364288ecb583711734f8f8b49ff742bab2c733b

Gopherが死んだあとずっとスクロールが動いてしまっているので止めましょう。
deadScrollAというマイナスの値の定数を導入し、Gopherが死んだらゆっくりスクロールが止まるようにします。

func (g *Game) calcScroll() {
        // Compute velocity.
        if g.gopher.dead {
                // Decrease scroll speed when the gopher dies.
                g.scroll.v += deadScrollA
                if g.scroll.v < 0 {
                        g.scroll.v = 0
                }
        } else {
                // Increase scroll speed.
                g.scroll.v += scrollA
        }
...

実行してみましょう。

STEP16の実行結果

ゆっくりスクロールが止まっていることが分かります。

STEP 17: Restart the game after the gopher dies.

対応するコミット:86ada5b6814f1d5bd41e973377e9a98ff95b775d

ここまでの実装だと一度Gopherが死んでしまうと画面が固まってしまいます。
多くのゲームはタイトル画面に戻ったり、再度ゲームができるように作られています。
このゲームでも再度ゲームができるようにしましょう。

gopherフィールドに、死んだ時間を記録しそこからdeadTimeBeforeReset時間だけたったらリセットするようになっています。

killGopherメソッドに死んだ時間を記録する処理が追加されています。

func (g *Game) killGopher() {
        g.gopher.dead = true
        g.gopher.deadTime = g.lastCalc
        g.gopher.v = jumpV // Bounce off screen.
}

一定時間経つとリセットする処理は、Updateメソッドで行っています。

func (g *Game) Update(now clock.Time) {
        if g.gopher.dead && now-g.gopher.deadTime > deadTimeBeforeReset {
                // Restart if the gopher has been dead for a while.
                g.reset()
        }
...

動かしてみましょう。

STEP17の実行結果

死んだ後に一定時間経つとリセットされていることが分かります。

STEP 18: Let the gopher "flap" while jumping.

対応するコミット:ecb950d50c2feef2d991d5679d539528898a20da

空中でジャンプできないようにしましたが、ゲームなので多少空中で維持できるようにしたいですね。

gopherフィールドの構造体にflappedというフィールドが追加されています。
ジャンプしているときに1度だけ"flap"することができます。
"flap"するとGopherの速度をflapVに設定します。

func (g *Game) Press(down bool) {
        if g.gopher.dead {
                // Player can't control a dead gopher.
                return
        }

        if down {
                switch {
                case g.gopher.atRest:
                        // Gopher may jump from the ground.
                        g.gopher.v = jumpV
                case !g.gopher.flapped:
                        // Gopher may flap once in mid-air.
                        g.gopher.flapped = true
                        g.gopher.v = flapV

flappedclampToGroundで地面に触れた時にfalseに設定されます。

"flap"している間も画像を変えたいため、別途サブテクスチャを設定しています。
使用するサブテクスチャは、スプライト画像の左から3番目の緑色の部分です。

texGopherFlap: sprite.SubTex{t, image.Rect(1+n*2, 0, n*3-1, n)},

実行してみましょう。

STEP18の実行結果

ジャンプした後にちょっとだけ上に上がることができるはずです。

STEP 19: Add small variance ("wobble") to the ground heights.

対応するコミット:7c012ab3592ef88c1c91b7bb327d01b8cf6f93ff

ここまでの地面は平らですが、これだとちょっと不自然ですね。
乱数で多少ガタガタさせましょう。

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        if wobble := rand.Intn(groundWobbleProb) == 0; wobble {
                return prev + (rand.Float32()-0.5)*(tileHeight/3)
        }
        return prev
}

実行してみましょう。

STEP19の実行結果

おっと小さな段差でGopherが死んでしまいましたね。

STEP 20: Let the gopher climb small steps without jumping.

対応するコミット:741d475ec0dc01584ca3e0d5a78570fe2c0e3fb1

Gopherはそんなに弱くはないので、1タイルの1/3くらいの大きさの壁にぶつかっても死なないようにしましょう。
climbGraceは、1タイルの高さ(16)の1/3の大きさで、これくらいの誤差は許容するようにしました。

func (g *Game) gopherCrashed() bool {
        return g.gopher.y+tileHeight-climbGrace > g.groundY[gopherTile+1]
}

また、ガタガタさせる部分もclimbGraceに収まるようにします。

func (g *Game) nextGroundY() float32 {
        prev := g.groundY[len(g.groundY)-1]
        if change := rand.Intn(groundChangeProb) == 0; change {
                return (groundMax-groundMin)*rand.Float32() + groundMin
        }
        if wobble := rand.Intn(groundWobbleProb) == 0; wobble {
                return prev + (rand.Float32()-0.5)*climbGrace
        }
        return prev
}

実行してみましょう。

STEP20の実行結果

ちょっとの段差では、死ななくなりましたね。

STEP 21: Replace placeholder sprites with Renee French illustrations.

対応するコミット:e1b09ed76e81b2fc5c75c1a02de3ec48e8e8a3f6

青い四角に愛着が湧いてきたところだと思いますが、せっかくなので、Renee Frenchさんの描いた可愛らしいGopherに変えましょう。

STEP21の実行結果

一気に本格的なゲームになってきました。

Gopherの背景が透過処理されていないのが気になりますね。
onPaintの先頭に3行を追加すると透過処理がされると思います。

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

STEP 22: Adjust size and position of the gopher.

対応するコミット:34018d0690756829c549696f0efba5a85e7a49a6

ちゃんとした画像に変えてみると、Gopherが小さいことに気づきます。
以下のようにもう少し大きくしてやりましょう。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        switch {
        case g.gopher.dead:
                eng.SetSubTex(n, texs[texGopherDead])
        case g.gopher.v < 0:
                eng.SetSubTex(n, texs[texGopherFlap])
        default:
                eng.SetSubTex(n, texs[texGopher])
        }
        eng.SetTransform(n, f32.Affine{
                {tileWidth * 2, 0, tileWidth*(gopherTile-1) + tileWidth/8},
                {0, tileHeight * 2, g.gopher.y - tileHeight + tileHeight/4},
        })
})

動かしてみましょう。

STEP22の実行結果

見やすくなりましたね。

STEP 23: Use varying ground sprites.

対応するコミット:e8ffdf323bf4934561aa2b17dd42f1a59d64dedf

もう少しクォリティを上げるために、地表のテクスチャを複数用意してランダムに生成するようにしましょう。
Game構造体にgroundTexというフィールドを用意し、座標によってテクスチャを変えてやります。

func (g *Game) newGroundTile() {
        // Compute next ground y-offset.
        next := g.nextGroundY()
        nextTex := randomGroundTexture()

        // Shift ground tiles to the left.
        g.scroll.x -= tileWidth
        copy(g.groundY[:], g.groundY[1:])
        copy(g.groundTex[:], g.groundTex[1:])
        last := len(g.groundY) - 1
        g.groundY[last] = next
        g.groundTex[last] = nextTex
}

ここで使われているrandomGroundTexture関数は単に乱数を返す関数です。

func randomGroundTexture() int {
        return texGround1 + rand.Intn(4)
}

実行してみましょう。

STEP23の実行結果

あまり変化はありませんが、少し自然になったかなと思います。

STEP 24: Animate the gopher.

対応するコミット:79e6d5e2d53eba5c8a43743af9706a8cee8b0f94

Gopherをアニメーションをさせて完成度を高めます。
Arrangerでフレームによって使用するサブテクスチャを変えることでアニメーションを実現します。

// The gopher.
newNode(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
        var x int
        switch {
        case g.gopher.dead:
                x = frame(t, 16, texGopherDead1, texGopherDead2)
        case g.gopher.v < 0:
                x = frame(t, 4, texGopherFlap1, texGopherFlap2)
        case g.gopher.atRest:
                x = frame(t, 4, texGopherRun1, texGopherRun2)
        default:
                x = frame(t, 8, texGopherRun1, texGopherRun2)
        }
        eng.SetSubTex(n, texs[x])
        eng.SetTransform(n, f32.Affine{
                {tileWidth * 2, 0, tileWidth*(gopherTile-1) + tileWidth/8},
                {0, tileHeight * 2, g.gopher.y - tileHeight + tileHeight/4},
        })
})

ここで使用しているframe関数は、指定したフレームで使用するサブテクスチャを返す関数です。
第2引数で渡したdは、1つのサブテクスチャを表示する表示する時間で、小さくすると素早くサブテクスチャが切り替わります。
"flap"している時のアニメーションは他とくらべて早いことが分かります。

// frame returns the frame for the given time t
// when each frame is displayed for duration d.
func frame(t, d clock.Time, frames ...int) int {
        total := int(d) * len(frames)
        return frames[(int(t)%total)/int(d)]
}

動かしてみます。

STEP24の実行結果

Gopherがアニメーションして、非常に可愛らしくなっています。

STEP 25: Make the gopher spin and zoom when it dies.

対応するコミット:7936d213595bd27b7c0ff8e1411627b4eacf5c91

横スクロールの古き良き死に方でも良いですが、もう少し派手にしましょう。
回転しながらズームさせてみます。

animateDeadGopherで死んだ時のアニメーションを定義しています。
f32.Affine型のポインタを受取り、時刻によって少しずつ回転させたり拡大させていることが分かります。

func animateDeadGopher(a *f32.Affine, t clock.Time) {
        dt := float32(t)
        a.Scale(a, 1+dt/20, 1+dt/20)
        a.Translate(a, 0.5, 0.5)
        a.Rotate(a, dt/math.Pi/-8)
        a.Translate(a, -0.5, -0.5)
}

さて、実行してみましょう。

STEP25の実行結果

死に様が派手になりましたね。
これでよりゲームっぽくなったかなと思います。

Androidで実行する

さぁ"Flappy Gopher"が出来上がりました。
gomobileコマンドでapkを作り、Androidにインストールしましょう。

$gomobile install github.com/adg/game

さっそく動かしてみましょう。

Android上での実行結果

おぉ楽しいですね。結構中毒性があります。

まとめ

この記事では、Go Conference 2015 Winterのキーノート "x/mobile gaming"を1コミットずつ解説していきました。
Go MobileはまだまだExperimentalな技術ですが、今後が楽しみな技術です。
実際にゲームが動くところをみると何か作りたくなりますね。

明日の担当は、yasui-sさんの「同時に実行するということ」です。

連載目次

はじめに

@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パッケージを使えば簡単なゲームを作ることができるでしょう。また,ライフサイクルイベントを使えば,アプリがサスペンドされたことを検出して,ゲームをポーズさせたりすることができ,より完成度のゲームを作ることができるようになると思います。

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

↑このページのトップヘ