diff --git a/image.go b/image.go index 5bcea54ba..5125ee6ab 100644 --- a/image.go +++ b/image.go @@ -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 diff --git a/image_test.go b/image_test.go index fe8704712..1b8466ed9 100644 --- a/image_test.go +++ b/image_test.go @@ -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) +}