From bac34a4474714786759347347081157e23a31b13 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Sat, 11 Jun 2022 23:06:59 +0900 Subject: [PATCH] ebiten: add NewImageWithOptions and NewImageOptions This change adds NewImageWithOptions, that creates a new image with the given options. NewImageWithOptions takes image.Rectangle instead of a width and a height, then a user can create an image with an arbitrary bounds. A left-upper position can be a negative number. NewImageWithOptions can create an unmanged image, that is no longer on an automatic internal texture atlas. A user can have finer controls over the image. This change also adds tests for this function. Updates #2013 Updates #2017 Updates #2124 --- gameforui.go | 4 +- image.go | 236 +++++++++++++++++++++++++++++-------------------- image_test.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++++ shader_test.go | 126 ++++++++++++++++++++++++++ 4 files changed, 499 insertions(+), 99 deletions(-) diff --git a/gameforui.go b/gameforui.go index 1253671c4..f0867262b 100644 --- a/gameforui.go +++ b/gameforui.go @@ -15,6 +15,8 @@ package ebiten import ( + "image" + "github.com/hajimehoshi/ebiten/v2/internal/atlas" "github.com/hajimehoshi/ebiten/v2/internal/ui" ) @@ -45,7 +47,7 @@ func (c *gameForUI) NewOffscreenImage(width, height int) *ui.Image { // A violatile image is also always isolated. imageType = atlas.ImageTypeVolatile } - c.offscreen = newImage(width, height, imageType) + c.offscreen = newImage(image.Rect(0, 0, width, height), imageType) return c.offscreen.image } diff --git a/image.go b/image.go index dc8447a79..d20630f01 100644 --- a/image.go +++ b/image.go @@ -111,6 +111,47 @@ type DrawImageOptions struct { Filter Filter } +// adjustPosition converts the position in the *ebiten.Image coordinate to the *ui.Image coordinate. +func (i *Image) adjustPosition(x, y int) (int, int) { + if i.isSubImage() { + or := i.original.Bounds() + x -= or.Min.X + y -= or.Min.Y + return x, y + } + + r := i.Bounds() + x -= r.Min.X + y -= r.Min.Y + return x, y +} + +// adjustPositionF32 converts the position in the *ebiten.Image coordinate to the *ui.Image coordinate. +func (i *Image) adjustPositionF32(x, y float32) (float32, float32) { + if i.isSubImage() { + or := i.original.Bounds() + x -= float32(or.Min.X) + y -= float32(or.Min.Y) + return x, y + } + + r := i.Bounds() + x -= float32(r.Min.X) + y -= float32(r.Min.Y) + return x, y +} + +func (i *Image) adjustedRegion() graphicsdriver.Region { + b := i.Bounds() + x, y := i.adjustPosition(b.Min.X, b.Min.Y) + return graphicsdriver.Region{ + X: float32(x), + Y: float32(y), + Width: float32(b.Dx()), + Height: float32(b.Dy()), + } +} + // DrawImage draws the given image on the image i. // // DrawImage accepts the options. For details, see the document of @@ -156,37 +197,30 @@ func (i *Image) DrawImage(img *Image, options *DrawImageOptions) { return } - dstBounds := i.Bounds() - dstRegion := graphicsdriver.Region{ - X: float32(dstBounds.Min.X), - Y: float32(dstBounds.Min.Y), - Width: float32(dstBounds.Dx()), - Height: float32(dstBounds.Dy()), - } - // Calculate vertices before locking because the user can do anything in // options.ImageParts interface without deadlock (e.g. Call Image functions). if options == nil { options = &DrawImageOptions{} } - bounds := img.Bounds() mode := graphicsdriver.CompositeMode(options.CompositeMode) filter := graphicsdriver.Filter(options.Filter) + if offsetX, offsetY := i.adjustPosition(0, 0); offsetX != 0 || offsetY != 0 { + options.GeoM.Translate(float64(offsetX), float64(offsetY)) + } a, b, c, d, tx, ty := options.GeoM.elements32() - sx0 := float32(bounds.Min.X) - sy0 := float32(bounds.Min.Y) - sx1 := float32(bounds.Max.X) - sy1 := float32(bounds.Max.Y) + bounds := img.Bounds() + sx0, sy0 := img.adjustPosition(bounds.Min.X, bounds.Min.Y) + sx1, sy1 := img.adjustPosition(bounds.Max.X, bounds.Max.Y) colorm, cr, cg, cb, ca := colorMToScale(options.ColorM.affineColorM()) - vs := graphics.QuadVertices(sx0, sy0, sx1, sy1, a, b, c, d, tx, ty, cr, cg, cb, ca) + vs := graphics.QuadVertices(float32(sx0), float32(sy0), float32(sx1), float32(sy1), a, b, c, d, tx, ty, cr, cg, cb, ca) is := graphics.QuadIndices() srcs := [graphics.ShaderImageNum]*ui.Image{img.image} - i.image.DrawTriangles(srcs, vs, is, colorm, mode, filter, graphicsdriver.AddressUnsafe, dstRegion, graphicsdriver.Region{}, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, false, canSkipMipmap(options.GeoM, filter)) + i.image.DrawTriangles(srcs, vs, is, colorm, mode, filter, graphicsdriver.AddressUnsafe, i.adjustedRegion(), graphicsdriver.Region{}, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, false, canSkipMipmap(options.GeoM, filter)) } // Vertex represents a vertex passed to DrawTriangles. @@ -311,14 +345,6 @@ func (i *Image) DrawTriangles(vertices []Vertex, indices []uint16, img *Image, o } // TODO: Check the maximum value of indices and len(vertices)? - dstBounds := i.Bounds() - dstRegion := graphicsdriver.Region{ - X: float32(dstBounds.Min.X), - Y: float32(dstBounds.Min.Y), - Width: float32(dstBounds.Dx()), - Height: float32(dstBounds.Dy()), - } - if options == nil { options = &DrawTrianglesOptions{} } @@ -328,13 +354,7 @@ func (i *Image) DrawTriangles(vertices []Vertex, indices []uint16, img *Image, o address := graphicsdriver.Address(options.Address) var sr graphicsdriver.Region if address != graphicsdriver.AddressUnsafe { - b := img.Bounds() - sr = graphicsdriver.Region{ - X: float32(b.Min.X), - Y: float32(b.Min.Y), - Width: float32(b.Dx()), - Height: float32(b.Dy()), - } + sr = img.adjustedRegion() } filter := graphicsdriver.Filter(options.Filter) @@ -342,11 +362,14 @@ func (i *Image) DrawTriangles(vertices []Vertex, indices []uint16, img *Image, o colorm, cr, cg, cb, ca := colorMToScale(options.ColorM.affineColorM()) vs := graphics.Vertices(len(vertices)) + dst := i for i, v := range vertices { - vs[i*graphics.VertexFloatNum] = v.DstX - vs[i*graphics.VertexFloatNum+1] = v.DstY - vs[i*graphics.VertexFloatNum+2] = v.SrcX - vs[i*graphics.VertexFloatNum+3] = v.SrcY + dx, dy := dst.adjustPositionF32(v.DstX, v.DstY) + vs[i*graphics.VertexFloatNum] = dx + vs[i*graphics.VertexFloatNum+1] = dy + sx, sy := img.adjustPositionF32(v.SrcX, v.SrcY) + vs[i*graphics.VertexFloatNum+2] = sx + vs[i*graphics.VertexFloatNum+3] = sy vs[i*graphics.VertexFloatNum+4] = v.ColorR * cr vs[i*graphics.VertexFloatNum+5] = v.ColorG * cg vs[i*graphics.VertexFloatNum+6] = v.ColorB * cb @@ -357,7 +380,7 @@ func (i *Image) DrawTriangles(vertices []Vertex, indices []uint16, img *Image, o srcs := [graphics.ShaderImageNum]*ui.Image{img.image} - i.image.DrawTriangles(srcs, vs, is, colorm, mode, filter, address, dstRegion, sr, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, options.FillRule == EvenOdd, false) + i.image.DrawTriangles(srcs, vs, is, colorm, mode, filter, address, i.adjustedRegion(), sr, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, options.FillRule == EvenOdd, false) } // DrawTrianglesShaderOptions represents options for DrawTrianglesShader. @@ -377,7 +400,7 @@ type DrawTrianglesShaderOptions struct { Uniforms map[string]interface{} // Images is a set of the source images. - // All the image must be the same size. + // All the image must be the same bounds. Images [4]*Image // FillRule indicates the rule how an overlapped region is rendered. @@ -427,14 +450,6 @@ func (i *Image) DrawTrianglesShader(vertices []Vertex, indices []uint16, shader } // TODO: Check the maximum value of indices and len(vertices)? - dstBounds := i.Bounds() - dstRegion := graphicsdriver.Region{ - X: float32(dstBounds.Min.X), - Y: float32(dstBounds.Min.Y), - Width: float32(dstBounds.Dx()), - Height: float32(dstBounds.Dy()), - } - if options == nil { options = &DrawTrianglesShaderOptions{} } @@ -442,11 +457,18 @@ func (i *Image) DrawTrianglesShader(vertices []Vertex, indices []uint16, shader mode := graphicsdriver.CompositeMode(options.CompositeMode) vs := graphics.Vertices(len(vertices)) + dst := i + src := options.Images[0] for i, v := range vertices { - vs[i*graphics.VertexFloatNum] = v.DstX - vs[i*graphics.VertexFloatNum+1] = v.DstY - vs[i*graphics.VertexFloatNum+2] = v.SrcX - vs[i*graphics.VertexFloatNum+3] = v.SrcY + dx, dy := dst.adjustPositionF32(v.DstX, v.DstY) + vs[i*graphics.VertexFloatNum] = dx + vs[i*graphics.VertexFloatNum+1] = dy + sx, sy := v.SrcX, v.SrcY + if src != nil { + sx, sy = src.adjustPositionF32(sx, sy) + } + vs[i*graphics.VertexFloatNum+2] = sx + vs[i*graphics.VertexFloatNum+3] = sy vs[i*graphics.VertexFloatNum+4] = v.ColorR vs[i*graphics.VertexFloatNum+5] = v.ColorG vs[i*graphics.VertexFloatNum+6] = v.ColorB @@ -475,22 +497,12 @@ func (i *Image) DrawTrianglesShader(vertices []Vertex, indices []uint16, shader imgs[i] = img.image } - var sx, sy float32 - if options.Images[0] != nil { - b := options.Images[0].Bounds() - sx = float32(b.Min.X) - sy = float32(b.Min.Y) - } - + var sx, sy int var sr graphicsdriver.Region if img := options.Images[0]; img != nil { b := img.Bounds() - sr = graphicsdriver.Region{ - X: float32(b.Min.X), - Y: float32(b.Min.Y), - Width: float32(b.Dx()), - Height: float32(b.Dy()), - } + sx, sy = img.adjustPosition(b.Min.X, b.Min.Y) + sr = img.adjustedRegion() } var offsets [graphics.ShaderImageNum - 1][2]float32 @@ -499,11 +511,14 @@ func (i *Image) DrawTrianglesShader(vertices []Vertex, indices []uint16, shader continue } b := img.Bounds() - offsets[i][0] = -sx + float32(b.Min.X) - offsets[i][1] = -sy + float32(b.Min.Y) + x, y := img.adjustPosition(b.Min.X, b.Min.Y) + // (sx, sy) is the left-upper position of the first image. + // Calculate the direction between the current image's left-upper position and the first one's. + offsets[i][0] = float32(x - sx) + offsets[i][1] = float32(y - sy) } - i.image.DrawTriangles(imgs, vs, is, affine.ColorMIdentity{}, mode, graphicsdriver.FilterNearest, graphicsdriver.AddressUnsafe, dstRegion, sr, offsets, shader.shader, shader.convertUniforms(options.Uniforms), options.FillRule == EvenOdd, false) + i.image.DrawTriangles(imgs, vs, is, affine.ColorMIdentity{}, mode, graphicsdriver.FilterNearest, graphicsdriver.AddressUnsafe, i.adjustedRegion(), sr, offsets, shader.shader, shader.convertUniforms(options.Uniforms), options.FillRule == EvenOdd, false) } // DrawRectShaderOptions represents options for DrawRectShader. @@ -527,7 +542,7 @@ type DrawRectShaderOptions struct { Uniforms map[string]interface{} // Images is a set of the source images. - // All the image must be the same size with the rectangle. + // All the image must be the same bounds. Images [4]*Image } @@ -554,14 +569,6 @@ func (i *Image) DrawRectShader(width, height int, shader *Shader, options *DrawR return } - dstBounds := i.Bounds() - dstRegion := graphicsdriver.Region{ - X: float32(dstBounds.Min.X), - Y: float32(dstBounds.Min.Y), - Width: float32(dstBounds.Dx()), - Height: float32(dstBounds.Dy()), - } - if options == nil { options = &DrawRectShaderOptions{} } @@ -582,39 +589,35 @@ func (i *Image) DrawRectShader(width, height int, shader *Shader, options *DrawR imgs[i] = img.image } - var sx, sy float32 - if options.Images[0] != nil { - b := options.Images[0].Bounds() - sx = float32(b.Min.X) - sy = float32(b.Min.Y) - } - - a, b, c, d, tx, ty := options.GeoM.elements32() - vs := graphics.QuadVertices(sx, sy, sx+float32(width), sy+float32(height), a, b, c, d, tx, ty, 1, 1, 1, 1) - is := graphics.QuadIndices() - + var sx, sy int var sr graphicsdriver.Region if img := options.Images[0]; img != nil { b := img.Bounds() - sr = graphicsdriver.Region{ - X: float32(b.Min.X), - Y: float32(b.Min.Y), - Width: float32(b.Dx()), - Height: float32(b.Dy()), - } + sx, sy = img.adjustPosition(b.Min.X, b.Min.Y) + sr = img.adjustedRegion() } + if offsetX, offsetY := i.adjustPosition(0, 0); offsetX != 0 || offsetY != 0 { + options.GeoM.Translate(float64(offsetX), float64(offsetY)) + } + a, b, c, d, tx, ty := options.GeoM.elements32() + vs := graphics.QuadVertices(float32(sx), float32(sy), float32(sx+width), float32(sy+height), a, b, c, d, tx, ty, 1, 1, 1, 1) + is := graphics.QuadIndices() + var offsets [graphics.ShaderImageNum - 1][2]float32 for i, img := range options.Images[1:] { if img == nil { continue } b := img.Bounds() - offsets[i][0] = -sx + float32(b.Min.X) - offsets[i][1] = -sy + float32(b.Min.Y) + x, y := img.adjustPosition(b.Min.X, b.Min.Y) + // (sx, sy) is the left-upper position of the first image. + // Calculate the direction between the current image's left-upper position and the first one's. + offsets[i][0] = float32(x - sx) + offsets[i][1] = float32(y - sy) } - i.image.DrawTriangles(imgs, vs, is, affine.ColorMIdentity{}, mode, graphicsdriver.FilterNearest, graphicsdriver.AddressUnsafe, dstRegion, sr, offsets, shader.shader, shader.convertUniforms(options.Uniforms), false, canSkipMipmap(options.GeoM, graphicsdriver.FilterNearest)) + i.image.DrawTriangles(imgs, vs, is, affine.ColorMIdentity{}, mode, graphicsdriver.FilterNearest, graphicsdriver.AddressUnsafe, i.adjustedRegion(), sr, offsets, shader.shader, shader.convertUniforms(options.Uniforms), false, canSkipMipmap(options.GeoM, graphicsdriver.FilterNearest)) } // SubImage returns an image representing the portion of the image p visible through r. @@ -706,7 +709,7 @@ func (i *Image) at(x, y int) (r, g, b, a uint8) { if !image.Pt(x, y).In(i.Bounds()) { return 0, 0, 0, 0 } - return i.image.At(x, y) + return i.image.At(i.adjustPosition(x, y)) } // Set sets the color at (x, y). @@ -729,6 +732,7 @@ func (i *Image) Set(x, y int, clr color.Color) { } r, g, b, a := clr.RGBA() + x, y = i.adjustPosition(x, y) i.image.ReplacePixels([]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)}, x, y, 1, 1) } @@ -772,10 +776,11 @@ func (i *Image) ReplacePixels(pixels []byte) { } r := i.Bounds() + x, y := i.adjustPosition(r.Min.X, r.Min.Y) // Do not need to copy pixels here. // * In internal/mipmap, pixels are copied when necessary. // * In internal/atlas, pixels are copied to make its paddings. - i.image.ReplacePixels(pixels, r.Min.X, r.Min.Y, r.Dx(), r.Dy()) + i.image.ReplacePixels(pixels, x, y, r.Dx(), r.Dy()) } // NewImage returns an empty image. @@ -788,22 +793,57 @@ func (i *Image) ReplacePixels(pixels []byte) { // // NewImage panics if RunGame already finishes. func NewImage(width, height int) *Image { - return newImage(width, height, atlas.ImageTypeRegular) + return newImage(image.Rect(0, 0, width, height), atlas.ImageTypeRegular) } -func newImage(width, height int, imageType atlas.ImageType) *Image { +// NewImageOptions represents options for NewImage. +type NewImageOptions struct { + // Unmanaged represents whether the image is unmanaged or not. + // The default (zero) value is false, that means the image is managed. + // + // An unmanged image is never on an internal automatic texture atlas. + // A regular image is a part of an internal texture atlas, and locating them is done automatically in Ebitengine. + // NewUnmanagedImage is useful when you want finer controls over the image for performance and memory reasons. + Unmanaged bool +} + +// NewImageWithOptions returns an empty image with the given bounds and the options. +// +// If width or height is less than 1 or more than device-dependent maximum size, NewImageWithOptions panics. +// +// The rendering origin position is (0, 0) of the given bounds. +// If DrawImage is called on a new image created by NewImageOptions, +// for example, the center of scaling and rotating is (0, 0), that might not be a left-upper position. +// +// NewImageWithOptions should be called only when necessary. +// For example, you should avoid to call NewImageWithOptions every Update or Draw call. +// Reusing the same image by Clear is much more efficient than creating a new image. +// +// NewImageWithOptions panics if RunGame already finishes. +func NewImageWithOptions(bounds image.Rectangle, options *NewImageOptions) *Image { + imageType := atlas.ImageTypeRegular + if options != nil && options.Unmanaged { + imageType = atlas.ImageTypeUnmanaged + } + return newImage(bounds, imageType) +} + +func newImage(bounds image.Rectangle, imageType atlas.ImageType) *Image { if isRunGameEnded() { panic(fmt.Sprintf("ebiten: NewImage cannot be called after RunGame finishes")) } + + width, height := bounds.Dx(), bounds.Dy() if width <= 0 { panic(fmt.Sprintf("ebiten: width at NewImage must be positive but %d", width)) } if height <= 0 { panic(fmt.Sprintf("ebiten: height at NewImage must be positive but %d", height)) } + i := &Image{ image: ui.NewImage(width, height, imageType), - bounds: image.Rect(0, 0, width, height), + bounds: bounds, } i.addr = i return i diff --git a/image_test.go b/image_test.go index e706f8a88..522ce7464 100644 --- a/image_test.go +++ b/image_test.go @@ -2889,3 +2889,235 @@ func TestImageNewImageFromEbitenImage(t *testing.T) { } } } + +func TestImageOptionsUnmanaged(t *testing.T) { + const ( + w = 16 + h = 16 + ) + + pix := make([]byte, 4*w*h) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + idx := 4 * (i + j*w) + pix[idx] = byte(i) + pix[idx+1] = byte(j) + pix[idx+2] = 0 + pix[idx+3] = 0xff + } + } + + op := &ebiten.NewImageOptions{ + Unmanaged: true, + } + img := ebiten.NewImageWithOptions(image.Rect(0, 0, w, h), op) + img.ReplacePixels(pix) + + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + got := img.At(i, j) + want := color.RGBA{byte(i), byte(j), 0, 0xff} + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } +} + +func TestImageOptionsNegativeBoundsReplacePixels(t *testing.T) { + const ( + w = 16 + h = 16 + ) + + pix0 := make([]byte, 4*w*h) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + idx := 4 * (i + j*w) + pix0[idx] = byte(i) + pix0[idx+1] = byte(j) + pix0[idx+2] = 0 + pix0[idx+3] = 0xff + } + } + + const offset = -8 + img := ebiten.NewImageWithOptions(image.Rect(offset, offset, w+offset, h+offset), nil) + img.ReplacePixels(pix0) + + for j := offset; j < h+offset; j++ { + for i := offset; i < w+offset; i++ { + got := img.At(i, j) + want := color.RGBA{byte(i - offset), byte(j - offset), 0, 0xff} + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } + + pix1 := make([]byte, 4*(w/2)*(h/2)) + for j := 0; j < h/2; j++ { + for i := 0; i < w/2; i++ { + idx := 4 * (i + j*w/2) + pix1[idx] = 0 + pix1[idx+1] = 0 + pix1[idx+2] = 0xff + pix1[idx+3] = 0xff + } + } + + const offset2 = -4 + sub := image.Rect(offset2, offset2, w/2+offset2, h/2+offset2) + img.SubImage(sub).(*ebiten.Image).ReplacePixels(pix1) + for j := offset; j < h+offset; j++ { + for i := offset; i < w+offset; i++ { + got := img.At(i, j) + want := color.RGBA{byte(i - offset), byte(j - offset), 0, 0xff} + if image.Pt(i, j).In(sub) { + want = color.RGBA{0, 0, 0xff, 0xff} + } + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } +} + +func TestImageOptionsNegativeBoundsSet(t *testing.T) { + const ( + w = 16 + h = 16 + ) + + pix0 := make([]byte, 4*w*h) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + idx := 4 * (i + j*w) + pix0[idx] = byte(i) + pix0[idx+1] = byte(j) + pix0[idx+2] = 0 + pix0[idx+3] = 0xff + } + } + + const offset = -8 + img := ebiten.NewImageWithOptions(image.Rect(offset, offset, w+offset, h+offset), nil) + img.ReplacePixels(pix0) + img.Set(-1, -2, color.RGBA{0, 0, 0, 0}) + + for j := offset; j < h+offset; j++ { + for i := offset; i < w+offset; i++ { + got := img.At(i, j) + want := color.RGBA{byte(i - offset), byte(j - offset), 0, 0xff} + if i == -1 && j == -2 { + want = color.RGBA{0, 0, 0, 0} + } + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } +} + +func TestImageOptionsNegativeBoundsDrawImage(t *testing.T) { + const ( + w = 16 + h = 16 + offset = -8 + ) + dst := ebiten.NewImageWithOptions(image.Rect(offset, offset, w+offset, h+offset), nil) + src := ebiten.NewImageWithOptions(image.Rect(-1, -1, 1, 1), nil) + pix := make([]byte, 4*2*2) + for i := range pix { + pix[i] = 0xff + } + src.ReplacePixels(pix) + + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(-1, -1) + op.GeoM.Scale(2, 3) + dst.DrawImage(src, op) + for j := offset; j < h+offset; j++ { + for i := offset; i < w+offset; i++ { + got := dst.At(i, j) + var want color.RGBA + if -2 <= i && i < 2 && -3 <= j && j < 3 { + want = color.RGBA{0xff, 0xff, 0xff, 0xff} + } + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } +} + +func TestImageOptionsNegativeBoundsDrawTriangles(t *testing.T) { + const ( + w = 16 + h = 16 + offset = -8 + ) + dst := ebiten.NewImageWithOptions(image.Rect(offset, offset, w+offset, h+offset), nil) + src := ebiten.NewImageWithOptions(image.Rect(-1, -1, 1, 1), nil) + pix := make([]byte, 4*2*2) + for i := range pix { + pix[i] = 0xff + } + src.ReplacePixels(pix) + vs := []ebiten.Vertex{ + { + DstX: -2, + DstY: -3, + SrcX: -1, + SrcY: -1, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: 2, + DstY: -3, + SrcX: 1, + SrcY: -1, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: -2, + DstY: 3, + SrcX: -1, + SrcY: 1, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: 2, + DstY: 3, + SrcX: 1, + SrcY: 1, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + } + is := []uint16{0, 1, 2, 1, 2, 3} + dst.DrawTriangles(vs, is, src, nil) + for j := offset; j < h+offset; j++ { + for i := offset; i < w+offset; i++ { + got := dst.At(i, j) + var want color.RGBA + if -2 <= i && i < 2 && -3 <= j && j < 3 { + want = color.RGBA{0xff, 0xff, 0xff, 0xff} + } + if got != want { + t.Errorf("img.At(%d, %d): got: %v, want: %v", i, j, got, want) + } + } + } +} diff --git a/shader_test.go b/shader_test.go index 0ed7b7205..c9e12124f 100644 --- a/shader_test.go +++ b/shader_test.go @@ -969,3 +969,129 @@ func Fragment(position vec4, texCoord vec2, color vec4) vec4 { } } } + +func TestShaderOptionsNegativeBounds(t *testing.T) { + const w, h = 16, 16 + + s, err := ebiten.NewShader([]byte(`package main + +func Fragment(position vec4, texCoord vec2, color vec4) vec4 { + r := imageSrc0At(texCoord).r + g := imageSrc1At(texCoord).g + return vec4(r, g, 0, 1) +} +`)) + if err != nil { + t.Fatal(err) + } + + const offset0 = -4 + src0 := ebiten.NewImageWithOptions(image.Rect(offset0, offset0, w+offset0, h+offset0), nil) + pix0 := make([]byte, 4*w*h) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + if 2 <= i && i < 10 && 3 <= j && j < 11 { + pix0[4*(j*w+i)] = 0xff + pix0[4*(j*w+i)+1] = 0 + pix0[4*(j*w+i)+2] = 0 + pix0[4*(j*w+i)+3] = 0xff + } + } + } + src0.ReplacePixels(pix0) + src0 = src0.SubImage(image.Rect(2+offset0, 3+offset0, 10+offset0, 11+offset0)).(*ebiten.Image) + + const offset1 = -6 + src1 := ebiten.NewImageWithOptions(image.Rect(offset1, offset1, w+offset1, h+offset1), nil) + pix1 := make([]byte, 4*w*h) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + if 6 <= i && i < 14 && 8 <= j && j < 16 { + pix1[4*(j*w+i)] = 0 + pix1[4*(j*w+i)+1] = 0xff + pix1[4*(j*w+i)+2] = 0 + pix1[4*(j*w+i)+3] = 0xff + } + } + } + src1.ReplacePixels(pix1) + src1 = src1.SubImage(image.Rect(6+offset1, 8+offset1, 14+offset1, 16+offset1)).(*ebiten.Image) + + const offset2 = -2 + testPixels := func(testname string, dst *ebiten.Image) { + for j := offset2; j < h+offset2; j++ { + for i := offset2; i < w+offset2; i++ { + got := dst.At(i, j).(color.RGBA) + var want color.RGBA + if 0 <= i && i < w/2 && 0 <= j && j < h/2 { + want = color.RGBA{0xff, 0xff, 0, 0xff} + } + if got != want { + t.Errorf("%s dst.At(%d, %d): got: %v, want: %v", testname, i, j, got, want) + } + } + } + } + + t.Run("DrawRectShader", func(t *testing.T) { + dst := ebiten.NewImageWithOptions(image.Rect(offset2, offset2, w+offset2, h+offset2), nil) + op := &ebiten.DrawRectShaderOptions{} + op.Images[0] = src0 + op.Images[1] = src1 + dst.DrawRectShader(w/2, h/2, s, op) + testPixels("DrawRectShader", dst) + }) + + t.Run("DrawTrianglesShader", func(t *testing.T) { + dst := ebiten.NewImageWithOptions(image.Rect(offset2, offset2, w+offset2, h+offset2), nil) + vs := []ebiten.Vertex{ + { + DstX: 0, + DstY: 0, + SrcX: 2 + offset0, + SrcY: 3 + offset0, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: w / 2, + DstY: 0, + SrcX: 10 + offset0, + SrcY: 3 + offset0, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: 0, + DstY: h / 2, + SrcX: 2 + offset0, + SrcY: 11 + offset0, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + { + DstX: w / 2, + DstY: h / 2, + SrcX: 10 + offset0, + SrcY: 11 + offset0, + ColorR: 1, + ColorG: 1, + ColorB: 1, + ColorA: 1, + }, + } + is := []uint16{0, 1, 2, 1, 2, 3} + + op := &ebiten.DrawTrianglesShaderOptions{} + op.Images[0] = src0 + op.Images[1] = src1 + dst.DrawTrianglesShader(vs, is, s, op) + testPixels("DrawTrianglesShader", dst) + }) +}