NewImageをUpdate/Draw内で呼んではいけない。
はじめに
最近Ebitengineでゲームを書いています。 ゲームをしばらく流していると、画面がカクカクに処理落ちする現象に遭遇しました。
どこが原因なんだろう..?と長いこと右往左往してる中で、ようやく原因を特定しました。
初期に書いた雑コードが原因の凡ミスだったのですが、せっかくなのでこの経緯を供養します。 何かおかしなこと書いていたらぜひコメントください。
※ 作業環境: ebitengine v2.6.1
手役をそだてるローグライクデッキ構築なシステムを模索中
— BioErrorLog (@bioerrorlog) July 13, 2024
考えるのが楽しいゲームをつくりたい#スーパーゲ制デー #ebitengine pic.twitter.com/ci2wpwNbxK
NewImageを頻繁に呼んではいけない
問題
まず問題のコードを見ていきます。
最初、このようにして素朴に四角形を描画するコードがありました。
rect := ebiten.NewImage(width, height) rect.Fill(color) op := &ebiten.DrawImageOptions{} op.GeoM.Translate(float64(x), float64(y)) screen.DrawImage(rect, op)
何行もコードを書くのは面倒なので、雑に一つの関数に抽出することにしました。
func DrawRect(screen *ebiten.Image, x, y, width, height int, color color.Color) { rect := ebiten.NewImage(width, height) rect.Fill(color) op := &ebiten.DrawImageOptions{} op.GeoM.Translate(float64(x), float64(y)) screen.DrawImage(rect, op) }
はい、この自前関数DrawRect
を無邪気にDrawの中で呼び出してしまうと、問題が発生します。
原因はこの部分:
ebiten.NewImage(width, height)
NewImageのドキュメントから引用すると、
NewImage should be called only when necessary. For example, you should avoid to call NewImage every Update or Draw call.
「NewImageは必要なときにのみ呼び出すべきです。例えば、毎回のUpdateやDrawの呼び出しごとにNewImageを呼び出すことは避けてください。」
そもそもNew
で始まる関数名なのだからそう頻繁に呼び出すものではなかろう、と察しそうなものですが、まず動かしてみようとざっと書いた時のコードなので気付きませんでした。
この問題のコードを含んだ状態でゲームを実行すると、数十秒くらいでカクカクになります。
さて、問題を発見したらテストで再現しよう、ということでテストコードを書いていきます。
Goにはベンチマークテストなるものがあり、関数のパフォーマンスを計測できます。
この自前のDrawRect
関数をテストしようと思うと色々なシナリオが考えられますが、今回は同じ仕様の四角形を連続描画するごく簡単なテストにしました (実際に今書いているゲームの中では、ある程度限られた仕様の四角形を描画する用途でのみ使っているため)。
func BenchmarkDrawRect(b *testing.B) { screen := ebiten.NewImage(640, 480) for i := 0; i < b.N; i++ { DrawRect(screen, 0, 0, 300, 20, color.RGBA{255, 0, 0, 255}) } }
ではこのベンチマークテストを実行してみます。
go test -bench=BenchmarkDrawRect -benchmem -benchtime=10s ./...
結果がこちら:
1057 ns/op 1315 B/op 13 allocs/op
単位はそれぞれ、
- ns/op: nanoseconds per operation 操作の平均時間(ナノ秒)
- B/op: bytes per operation 操作あたりのメモリ使用量(バイト)
- allocs/op: allocations per operation 操作あたりのメモリ割り当て回数
です。
正直この結果単体で見てもあんまりピンときませんが、コード修正時の比較対象として拠り所にしていきます。
修正その1: 雑にキャッシュする
ではこの問題を修正していきます。
まずは問題のNewImage部分をこのDrawRect関数の責務から外してしまうことを考えましたが、そうすると関数呼び出し側とのインターフェースが大きく崩れてしまうのでやめました。
また、ある程度のパターンをあらかじめNewImageしておきそこから指定する、というのも関数呼び出し側の自由度が大きく下がるので今回はなしです。
そこで、下記のようにNewImage部分を雑にキャッシュしておく、という戦略をとってみます。
var ( rectImageCache = sync.Map{} ) func DrawRect(screen *ebiten.Image, x, y, width, height int, clr color.Color) { // Imageを毎回Newすると性能問題が発生するため、キャッシュする cacheKey := fmt.Sprintf("%d_%d_%v", width, height, clr) img, ok := rectImageCache.Load(cacheKey) if !ok { // キャッシュにない場合、新しいイメージを作成してキャッシュに保存 newImg := ebiten.NewImage(width, height) newImg.Fill(clr) rectImageCache.Store(cacheKey, newImg) img = newImg } op := &ebiten.DrawImageOptions{} op.GeoM.Translate(float64(x), float64(y)) screen.DrawImage(img.(*ebiten.Image), op) }
これで限られた種類の描画であれば、キャッシュが効いてある程度改善するのでは、という対症療法です。
では先ほどのベンチマークテストを実行してみます。
511.5 ns/op 459 B/op 6 allocs/op
改修前と比較してみると、キャッシュが効くシナリオではある程度の改善が見られることが伺えます。
# 修正前 1057 ns/op 1315 B/op 13 allocs/op # 修正: キャッシュ 511.5 ns/op 459 B/op 6 allocs/op
実際にゲームを実行してみても、途中でカクカクになってしまう現象は解消されました。
修正その2: DrawFilledRect があるやんけ
ここまで書いたところで、そもそもebitengineに四角形を描画する関数が用意されてたりしないだろうか、と思い当たります。
昔調べた時は特に無かった覚えがあるのですが、今改めて調べてみると、vectorパッケージにDrawFilledRectという関数が用意されていました。
どうやらEbitengine v2.5の時に追加された模様です。
なんだ、もうこいつでいいじゃないか...という話なのですが、せっかくここまでの経緯があるので、同じく自前DrawRect
の中身をebitengineのvector.DrawFilledRect
に置き換えて、ベンチマークテストを実行してみます。
# 修正前 1057 ns/op 1315 B/op 13 allocs/op # 修正: キャッシュ 511.5 ns/op 459 B/op 6 allocs/op # 修正: vector.DrawFilledRect 671.7 ns/op 771 B/op 15 allocs/op
キャッシュで誤魔化した時に比べると多少数字は重くなっていますが、実際にゲームを実行した時の処理落ち問題はこのやり方で問題なく解消しています。
おわりに
教訓:ドキュメントをちゃんと読もう!
あと、調べていくうちに、そもそも描画って何なんだ、ゲームエンジンって何をしてるんだ?という根っこの部分を全然理解していないことを理解しました。 この辺りを深掘りしていくのも面白そうです。
人生の時間が足りないお年頃ですね。
[関連記事]