ebiten: add RecyclableSubImage and restrict Recycle to recyclable images

The pool was previously used by both NewImage and SubImage, but only
the sub-image churn case benefits from pooling. Any image could be
recycled, including originals — a documented footgun.

Add RecyclableSubImage that explicitly creates pooled sub-images, and
restrict Recycle to only work on images created by RecyclableSubImage.

Closes #3423

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hajime Hoshi
2026-03-24 01:32:36 +09:00
parent 210bf4b3ee
commit 439a0ff7b1
2 changed files with 118 additions and 18 deletions
+49 -18
View File
@@ -71,6 +71,10 @@ type Image struct {
// atime needs to be an atomic value since a sub-image atime can be accessed from its original image.
atime atomic.Int64
// recyclable reports whether the image was created via [Image.RecyclableSubImage]
// and can be returned to the pool via [Image.Recycle].
recyclable bool
// usageCallbacks are callbacks that are invoked when the image is used.
// usageCallbacks is valid only when the image is not a sub-image.
usageCallbacks map[int64]usageCallback
@@ -86,7 +90,7 @@ type Image struct {
}
// theImagePool is a global pool of Image structs to reduce allocations.
// Both [NewImage] and [Image.SubImage] draw from this pool; [Image.Recycle] returns to it.
// [Image.RecyclableSubImage] draws from this pool; [Image.Recycle] returns to it.
var theImagePool = sync.Pool{
New: func() any { return &Image{} },
}
@@ -1152,7 +1156,7 @@ func (i *Image) SubImage(r image.Rectangle) image.Image {
}
}
img := theImagePool.Get().(*Image)
img := &Image{}
img.image = i.image
img.bounds = r
img.original = i
@@ -1167,6 +1171,42 @@ func (i *Image) SubImage(r image.Rectangle) image.Image {
return img
}
// RecyclableSubImage returns a sub-image of the image from a global pool.
// The returned sub-image can be returned to the pool by calling [Image.Recycle].
//
// RecyclableSubImage is useful when you need to create many sub-images with different bounds,
// and want to avoid repeated allocations.
//
// Unlike [Image.SubImage], the returned sub-image is not cached internally.
// The caller is responsible for managing the lifecycle of the returned image.
//
// If the image is disposed, RecyclableSubImage panics.
func (i *Image) RecyclableSubImage(r image.Rectangle) *Image {
i.copyCheck()
if i.isDisposed() {
panic("ebiten: the image is already disposed")
}
if i.isSubImage() {
return i.original.RecyclableSubImage(r.Intersect(i.Bounds()))
}
r = r.Intersect(i.Bounds())
// Need to check Empty explicitly. See the standard image package implementations.
if r.Empty() {
r = image.Rectangle{}
}
img := theImagePool.Get().(*Image)
img.image = i.image
img.bounds = r
img.original = i
img.addr = img
img.recyclable = true
return img
}
// Bounds returns the bounds of the image.
//
// Bounds implements the standard image.Image's Bounds.
@@ -1359,26 +1399,16 @@ func (i *Image) Deallocate() {
// Recycle puts the Image struct back into a global pool for reuse, reducing allocations.
// After Recycle is called, the image must not be used; the behavior is undefined.
//
// In most cases, you don't have to call Recycle.
// Recycle is useful when you need to create many sub-images with different bounds,
// and want to avoid repeated allocations.
//
// Be careful when calling Recycle on a sub-image obtained from [Image.SubImage].
// If the sub-image's bounds cover the original image's bounds, [Image.SubImage] may return
// the original image itself, and calling Recycle on it would invalidate the original.
// Recycle can only be called on images created by [Image.RecyclableSubImage].
// Calling Recycle on any other image causes a panic.
func (i *Image) Recycle() {
i.copyCheck()
if i.inUsageCallbacks.Load() {
panic("ebiten: Recycle cannot be called from within a usage callback")
if !i.recyclable {
panic("ebiten: Recycle can only be called on an image created by RecyclableSubImage")
}
i.Deallocate()
// Clear all fields to release references and reset state.
if i.isSubImage() {
i.original.subImageCacheM.Lock()
delete(i.original.subImageCache, i.bounds)
i.original.subImageCacheM.Unlock()
}
i.addr = nil
i.image = nil
i.original = nil
i.bounds = image.Rectangle{}
@@ -1389,6 +1419,7 @@ func (i *Image) Recycle() {
i.subImageGCLastTick = 0
i.atime.Store(0)
clear(i.usageCallbacks)
i.recyclable = false
theImagePool.Put(i)
}
@@ -1488,7 +1519,7 @@ func newImage(bounds image.Rectangle, imageType atlas.ImageType) *Image {
panic(fmt.Sprintf("ebiten: height at NewImage must be positive but %d", height))
}
i := theImagePool.Get().(*Image)
i := &Image{}
i.image = ui.Get().NewImage(width, height, imageType)
i.bounds = bounds
i.addr = i
+69
View File
@@ -4867,3 +4867,72 @@ func TestSubImageRaceConditionWithSubImage(t *testing.T) {
}
wg.Wait()
}
func TestRecyclableSubImage(t *testing.T) {
img := ebiten.NewImage(16, 16)
img.Fill(color.White)
sub := img.RecyclableSubImage(image.Rect(0, 0, 8, 8))
if got := sub.Bounds(); got != image.Rect(0, 0, 8, 8) {
t.Errorf("Bounds(): got %v, want %v", got, image.Rect(0, 0, 8, 8))
}
want := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
if got := sub.At(0, 0); got != want {
t.Errorf("At(0,0): got %v, want %v", got, want)
}
// Recycle should not panic on a recyclable sub-image.
sub.Recycle()
}
func TestRecyclableSubImageFromSubImage(t *testing.T) {
img := ebiten.NewImage(16, 16)
img.Fill(color.White)
sub := img.SubImage(image.Rect(0, 0, 12, 12)).(*ebiten.Image)
rsub := sub.RecyclableSubImage(image.Rect(0, 0, 8, 8))
if got := rsub.Bounds(); got != image.Rect(0, 0, 8, 8) {
t.Errorf("Bounds(): got %v, want %v", got, image.Rect(0, 0, 8, 8))
}
want := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
if got := rsub.At(0, 0); got != want {
t.Errorf("At(0,0): got %v, want %v", got, want)
}
rsub.Recycle()
}
func TestRecycleOnNewImage(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("Recycle on NewImage must panic but did not")
}
}()
img := ebiten.NewImage(16, 16)
img.Recycle()
}
func TestRecycleOnSubImage(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("Recycle on SubImage must panic but did not")
}
}()
img := ebiten.NewImage(16, 16)
sub := img.SubImage(image.Rect(0, 0, 8, 8)).(*ebiten.Image)
sub.Recycle()
}
func TestUseAfterRecycle(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("using a recycled image must panic but did not")
}
}()
img := ebiten.NewImage(16, 16)
sub := img.RecyclableSubImage(image.Rect(0, 0, 8, 8))
sub.Recycle()
sub.Fill(color.White)
}