以下の内容はhttps://en.bioerrorlog.work/entry/ebitengine-new-imageより取得しました。


Don't Call NewImage Inside Update/Draw | Ebitengine

You should not call NewImage inside Update or Draw.

Introduction

Recently I've been writing a game with Ebitengine. After letting the game run for a while, I ran into a problem where the screen started stuttering and slowing down.

I spent a long time wandering around wondering where the issue was, and finally figured it out.

It was a simple mistake caused by some sloppy early code, but I'm writing about it here in case it helps someone.

※ Environment: ebitengine v2.6.1

Note: This article was translated from my original post.

Don't Call NewImage Frequently

The Problem

First, here's the problematic code.

Originally, I had some simple code that drew a rectangle like this:

rect := ebiten.NewImage(width, height)
rect.Fill(color)
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(x), float64(y))
screen.DrawImage(rect, op)

Since writing this over and over was annoying, I extracted it into a helper function.

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)
}

Calling this custom DrawRect function inside Draw without thinking caused a serious problem.

The culprit:

ebiten.NewImage(width, height)

According to NewImage documentation:

NewImage should be called only when necessary. For example, you should avoid to call NewImage every Update or Draw call.

When you think about it, any function named New shouldn't be called too often, but since this was quick prototype code, I didn't notice.

Running the game with this code caused heavy stuttering after several dozen seconds.


After finding the issue, the next step was to reproduce it with a test.

Go has benchmarking tests that measure function performance.

There are many possible scenarios for testing this DrawRect. For simplicity, I used a basic test that repeatedly draws the same rectangle (since that matches how I'm using it in my game).

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})
    }
}

Now run the benchmark:

go test -bench=BenchmarkDrawRect -benchmem -benchtime=10s ./...

Result:

1057 ns/op            1315 B/op         13 allocs/op

Units:

  • ns/op: nanoseconds per operation
  • B/op: memory usage per operation (bytes)
  • allocs/op: memory allocations per operation

It's hard to judge this standalone, but it gives us a baseline for comparison.

Fix #1: Cache the Images (A Quick Hack)

Let's fix the issue.

Removing NewImage from inside DrawRect would break the function's interface, so I didn't want that.

Pre-generating specific patterns also reduces flexibility.

So instead, I added a simple image cache:

var (
    rectImageCache = sync.Map{}
)

func DrawRect(screen *ebiten.Image, x, y, width, height int, clr color.Color) {
    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)
}

This is a symptomatic treatment approach, where if the drawings are limited to certain types, the cache should take effect and provide some degree of improvement.

Now let's run the benchmark test from earlier.

511.5 ns/op           459 B/op          6 allocs/op

When compared to before the modification, we can see that there is some degree of improvement in scenarios where the cache is effective.

# Before
1057 ns/op            1315 B/op         13 allocs/op
# Cached
511.5 ns/op           459 B/op          6 allocs/op

When actually running the game, the phenomenon of it becoming choppy/stuttering midway through has been resolved.

Fix #2: Wait, There's DrawFilledRect

At this point I wondered if Ebitengine already had a rectangle drawing function.

I didn't remember seeing one long ago, but checking again, the vector package now has DrawFilledRect.

It seems it was added in Ebitengine v2.5.

So I replaced the inside of my custom DrawRect with vector.DrawFilledRect and benchmarked again:

# Before
1057 ns/op            1315 B/op         13 allocs/op
# Cached
511.5 ns/op           459 B/op          6 allocs/op
# vector.DrawFilledRect
671.7 ns/op           771 B/op         15 allocs/op

Slightly heavier than caching, but using the official function works well and fixes the stuttering.

Conclusion

Lesson: Read the documentation!

While investigating this, I also realized I don't really understand the fundamentals like "what is rendering?" or "what does a game engine actually do?". Digging deeper into that might be fun.

Time is never enough, is it?

[Related Articles]

en.bioerrorlog.work

References




以上の内容はhttps://en.bioerrorlog.work/entry/ebitengine-new-imageより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14