diff --git a/gameforui.go b/gameforui.go index fd1bafc19..dd7c63ca8 100644 --- a/gameforui.go +++ b/gameforui.go @@ -15,16 +15,12 @@ package ebiten import ( - "fmt" - "math" - - "github.com/hajimehoshi/ebiten/v2/internal/graphicsdriver" + "github.com/hajimehoshi/ebiten/v2/internal/ui" ) type gameForUI struct { game Game offscreen *Image - screen *Image } func newGameForUI(game Game) *gameForUI { @@ -33,90 +29,23 @@ func newGameForUI(game Game) *gameForUI { } } -func (c *gameForUI) Layout(outsideWidth, outsideHeight float64, deviceScaleFactor float64) (int, int) { - ow, oh := c.game.Layout(int(outsideWidth), int(outsideHeight)) - if ow <= 0 || oh <= 0 { - panic("ebiten: Layout must return positive numbers") - } - - sw, sh := int(outsideWidth*deviceScaleFactor), int(outsideHeight*deviceScaleFactor) - if c.screen != nil { - if w, h := c.screen.Size(); w != sw || h != sh { - c.screen.Dispose() - c.screen = nil - } - } - if c.screen == nil { - c.screen = newScreenFramebufferImage(sw, sh) - } - +func (c *gameForUI) NewOffscreenImage(width, height int) *ui.Image { if c.offscreen != nil { - if w, h := c.offscreen.Size(); w != ow || h != oh { - c.offscreen.Dispose() - c.offscreen = nil - } + c.offscreen.Dispose() + c.offscreen = nil } - if c.offscreen == nil { - c.offscreen = NewImage(ow, oh) + c.offscreen = NewImage(width, height) + return c.offscreen.image +} - // Keep the offscreen an independent image from an atlas (#1938). - // The shader program for the screen is special and doesn't work well with an image on an atlas. - // An image on an atlas is surrounded by a transparent edge, - // and the shader program unexpectedly picks the pixel on the edges. - c.offscreen.image.SetIndependent(true) - } - - return ow, oh +func (c *gameForUI) Layout(outsideWidth, outsideHeight int) (int, int) { + return c.game.Layout(outsideWidth, outsideHeight) } func (c *gameForUI) Update() error { return c.game.Update() } -func (c *gameForUI) Draw(screenScale float64, offsetX, offsetY float64, needsClearingScreen bool, framebufferYDirection graphicsdriver.YDirection, clearScreenEveryFrame, filterEnabled bool) { - c.offscreen.image.SetVolatile(clearScreenEveryFrame) - - // Even though updateCount == 0, the offscreen is cleared and Draw is called. - // Draw should not update the game state and then the screen should not be updated without Update, but - // users might want to process something at Draw with the time intervals of FPS. - if clearScreenEveryFrame { - c.offscreen.Clear() - } +func (c *gameForUI) Draw() { c.game.Draw(c.offscreen) - - if needsClearingScreen { - // This clear is needed for fullscreen mode or some mobile platforms (#622). - c.screen.Clear() - } - - op := &DrawImageOptions{} - - s := screenScale - switch framebufferYDirection { - case graphicsdriver.Upward: - op.GeoM.Scale(s, -s) - _, h := c.offscreen.Size() - op.GeoM.Translate(0, float64(h)*s) - case graphicsdriver.Downward: - op.GeoM.Scale(s, s) - default: - panic(fmt.Sprintf("ebiten: invalid v-direction: %d", framebufferYDirection)) - } - - op.GeoM.Translate(offsetX, offsetY) - op.CompositeMode = CompositeModeCopy - - switch { - case !filterEnabled: - op.Filter = FilterNearest - case math.Floor(s) == s: - op.Filter = FilterNearest - case s > 1: - op.Filter = filterScreen - default: - // filterScreen works with >=1 scale, but does not well with <1 scale. - // Use regular FilterLinear instead so far (#669). - op.Filter = FilterLinear - } - c.screen.DrawImage(c.offscreen, op) } diff --git a/graphics.go b/graphics.go index 7e3d43861..a9d402afa 100644 --- a/graphics.go +++ b/graphics.go @@ -27,11 +27,6 @@ const ( // FilterLinear represents linear filter FilterLinear Filter = Filter(graphicsdriver.FilterLinear) - - // filterScreen represents a special filter for screen. Inner usage only. - // - // Some parameters like a color matrix or color vertex values can be ignored when filterScreen is used. - filterScreen Filter = Filter(graphicsdriver.FilterScreen) ) // CompositeMode represents Porter-Duff composition mode. diff --git a/image.go b/image.go index 1a9de8461..1b3894128 100644 --- a/image.go +++ b/image.go @@ -66,39 +66,22 @@ func (i *Image) Clear() { i.Fill(color.Transparent) } -var ( - emptyImage = NewImage(3, 3) - emptySubImage = emptyImage.SubImage(image.Rect(1, 1, 2, 2)).(*Image) -) - -func init() { - w, h := emptyImage.Size() - pix := make([]byte, 4*w*h) - for i := range pix { - pix[i] = 0xff - } - // As emptyImage is used at Fill, use ReplacePixels instead. - emptyImage.ReplacePixels(pix) -} - // Fill fills the image with a solid color. // // When the image is disposed, Fill does nothing. func (i *Image) Fill(clr color.Color) { - // Use the original size to cover the entire region (#1691). - // DrawImage automatically clips the rendering region. - orig := i - if i.isSubImage() { - orig = i.original + i.copyCheck() + + var crf, cgf, cbf, caf float32 + cr, cg, cb, ca := clr.RGBA() + if ca != 0 { + crf = float32(cr) / float32(ca) + cgf = float32(cg) / float32(ca) + cbf = float32(cb) / float32(ca) + caf = float32(ca) / 0xffff } - w, h := orig.Size() - - op := &DrawImageOptions{} - op.GeoM.Scale(float64(w), float64(h)) - op.ColorM.ScaleWithColor(clr) - op.CompositeMode = CompositeModeCopy - - i.DrawImage(emptySubImage, op) + b := i.Bounds() + i.image.Fill(crf, cgf, cbf, caf, b.Min.X, b.Min.Y, b.Dx(), b.Dy()) } func canSkipMipmap(geom GeoM, filter graphicsdriver.Filter) bool { @@ -845,12 +828,3 @@ func NewImageFromImage(source image.Image) *Image { i.ReplacePixels(imageToBytes(source)) return i } - -func newScreenFramebufferImage(width, height int) *Image { - i := &Image{ - image: ui.NewScreenFramebufferImage(width, height), - bounds: image.Rect(0, 0, width, height), - } - i.addr = i - return i -} diff --git a/internal/ui/context.go b/internal/ui/context.go index 3ee9f599c..9fd3f0ed5 100644 --- a/internal/ui/context.go +++ b/internal/ui/context.go @@ -15,10 +15,12 @@ package ui import ( + "fmt" "math" "sync" "sync/atomic" + "github.com/hajimehoshi/ebiten/v2/internal/affine" "github.com/hajimehoshi/ebiten/v2/internal/buffered" "github.com/hajimehoshi/ebiten/v2/internal/clock" "github.com/hajimehoshi/ebiten/v2/internal/debug" @@ -30,9 +32,10 @@ import ( const DefaultTPS = 60 type Game interface { - Layout(outsideWidth, outsideHeight float64, deviceScaleFactor float64) (int, int) + NewOffscreenImage(width, height int) *Image + Layout(outsideWidth, outsideHeight int) (int, int) Update() error - Draw(screenScale float64, offsetX, offsetY float64, needsClearingScreen bool, framebufferYDirection graphicsdriver.YDirection, screenClearedEveryFrame, filterEnabled bool) + Draw() } type context struct { @@ -40,11 +43,12 @@ type context struct { updateCalled bool + offscreen *Image + screen *Image + // The following members must be protected by the mutex m. outsideWidth float64 outsideHeight float64 - screenWidth int - screenHeight int m sync.Mutex } @@ -116,12 +120,11 @@ func (c *context) updateFrameImpl(graphicsDriver graphicsdriver.Graphics, update if err := theGlobalState.error(); err != nil { return err } - Get().resetForTick() + theUI.resetForTick() } // Draw the game. - screenScale, offsetX, offsetY := c.screenScaleAndOffsets(deviceScaleFactor) - c.game.Draw(screenScale, offsetX, offsetY, graphicsDriver.NeedsClearingScreen(), graphicsDriver.FramebufferYDirection(), theGlobalState.isScreenClearedEveryFrame(), theGlobalState.isScreenFilterEnabled()) + c.drawGame(graphicsDriver) // All the vertices data are consumed at the end of the frame, and the data backend can be // available after that. Until then, lock the vertices backend. @@ -133,20 +136,119 @@ func (c *context) updateFrameImpl(graphicsDriver graphicsdriver.Graphics, update }) } +func (c *context) drawGame(graphicsDriver graphicsdriver.Graphics) { + c.offscreen.mipmap.SetVolatile(theGlobalState.isScreenClearedEveryFrame()) + + // Even though updateCount == 0, the offscreen is cleared and Draw is called. + // Draw should not update the game state and then the screen should not be updated without Update, but + // users might want to process something at Draw with the time intervals of FPS. + if theGlobalState.isScreenClearedEveryFrame() { + c.offscreen.clear() + } + c.game.Draw() + + if graphicsDriver.NeedsClearingScreen() { + // This clear is needed for fullscreen mode or some mobile platforms (#622). + c.screen.clear() + } + + ga := 1.0 + gd := 1.0 + gtx := 0.0 + gty := 0.0 + + screenScale, offsetX, offsetY := c.screenScaleAndOffsets() + s := screenScale + switch y := graphicsDriver.FramebufferYDirection(); y { + case graphicsdriver.Upward: + ga *= s + gd *= -s + gty += float64(c.offscreen.height) * s + case graphicsdriver.Downward: + ga *= s + gd *= s + default: + panic(fmt.Sprintf("ui: invalid y-direction: %d", y)) + } + + gtx += offsetX + gty += offsetY + + var filter graphicsdriver.Filter + switch { + case !theGlobalState.isScreenFilterEnabled(): + filter = graphicsdriver.FilterNearest + case math.Floor(s) == s: + filter = graphicsdriver.FilterNearest + case s > 1: + filter = graphicsdriver.FilterScreen + default: + // FilterScreen works with >=1 scale, but does not well with <1 scale. + // Use regular FilterLinear instead so far (#669). + filter = graphicsdriver.FilterLinear + } + + dstRegion := graphicsdriver.Region{ + X: 0, + Y: 0, + Width: float32(c.screen.width), + Height: float32(c.screen.height), + } + + vs := graphics.QuadVertices( + 0, 0, float32(c.offscreen.width), float32(c.offscreen.height), + float32(ga), 0, 0, float32(gd), float32(gtx), float32(gty), + 1, 1, 1, 1) + is := graphics.QuadIndices() + + srcs := [graphics.ShaderImageNum]*Image{c.offscreen} + c.screen.DrawTriangles(srcs, vs, is, affine.ColorMIdentity{}, graphicsdriver.CompositeModeCopy, filter, graphicsdriver.AddressUnsafe, dstRegion, graphicsdriver.Region{}, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, false, true) +} + func (c *context) layoutGame(outsideWidth, outsideHeight float64, deviceScaleFactor float64) (int, int) { c.m.Lock() defer c.m.Unlock() c.outsideWidth = outsideWidth c.outsideHeight = outsideHeight - w, h := c.game.Layout(outsideWidth, outsideHeight, deviceScaleFactor) - c.screenWidth = w - c.screenHeight = h - return w, h + + ow, oh := c.game.Layout(int(outsideWidth), int(outsideHeight)) + if ow <= 0 || oh <= 0 { + panic("ui: Layout must return positive numbers") + } + + sw, sh := int(outsideWidth*deviceScaleFactor), int(outsideHeight*deviceScaleFactor) + if c.screen != nil { + if c.screen.width != sw || c.screen.height != sh { + c.screen.MarkDisposed() + c.screen = nil + } + } + if c.screen == nil { + c.screen = newScreenFramebufferImage(sw, sh) + } + + if c.offscreen != nil { + if c.offscreen.width != ow || c.offscreen.height != oh { + c.offscreen.MarkDisposed() + c.offscreen = nil + } + } + if c.offscreen == nil { + c.offscreen = c.game.NewOffscreenImage(ow, oh) + + // Keep the offscreen an independent image from an atlas (#1938). + // The shader program for the screen is special and doesn't work well with an image on an atlas. + // An image on an atlas is surrounded by a transparent edge, + // and the shader program unexpectedly picks the pixel on the edges. + c.offscreen.mipmap.SetIndependent(true) + } + + return ow, oh } func (c *context) adjustPosition(x, y float64, deviceScaleFactor float64) (float64, float64) { - s, ox, oy := c.screenScaleAndOffsets(deviceScaleFactor) + s, ox, oy := c.screenScaleAndOffsets() // The scale 0 indicates that the screen is not initialized yet. // As any cursor values don't make sense, just return NaN. if s == 0 { @@ -155,21 +257,21 @@ func (c *context) adjustPosition(x, y float64, deviceScaleFactor float64) (float return (x*deviceScaleFactor - ox) / s, (y*deviceScaleFactor - oy) / s } -func (c *context) screenScaleAndOffsets(deviceScaleFactor float64) (float64, float64, float64) { +func (c *context) screenScaleAndOffsets() (float64, float64, float64) { c.m.Lock() defer c.m.Unlock() - if c.screenWidth == 0 || c.screenHeight == 0 { + if c.screen == nil { return 0, 0, 0 } - scaleX := c.outsideWidth / float64(c.screenWidth) * deviceScaleFactor - scaleY := c.outsideHeight / float64(c.screenHeight) * deviceScaleFactor + scaleX := float64(c.screen.width) / float64(c.offscreen.width) + scaleY := float64(c.screen.height) / float64(c.offscreen.height) scale := math.Min(scaleX, scaleY) - width := float64(c.screenWidth) * scale - height := float64(c.screenHeight) * scale - x := (c.outsideWidth*deviceScaleFactor - width) / 2 - y := (c.outsideHeight*deviceScaleFactor - height) / 2 + width := float64(c.offscreen.width) * scale + height := float64(c.offscreen.height) * scale + x := (float64(c.screen.width) - width) / 2 + y := (float64(c.screen.height) - height) / 2 return scale, x, y } @@ -257,7 +359,7 @@ func FPSMode() FPSModeType { func SetFPSMode(fpsMode FPSModeType) { theGlobalState.setFPSMode(fpsMode) - Get().SetFPSMode(fpsMode) + theUI.SetFPSMode(fpsMode) } func MaxTPS() int { diff --git a/internal/ui/image.go b/internal/ui/image.go index 516415ce2..a4521fa55 100644 --- a/internal/ui/image.go +++ b/internal/ui/image.go @@ -31,17 +31,23 @@ func SetPanicOnErrorOnReadingPixelsForTesting(value bool) { type Image struct { mipmap *mipmap.Mipmap + width int + height int } func NewImage(width, height int) *Image { return &Image{ mipmap: mipmap.New(width, height), + width: width, + height: height, } } -func NewScreenFramebufferImage(width, height int) *Image { +func newScreenFramebufferImage(width, height int) *Image { return &Image{ mipmap: mipmap.NewScreenFramebufferMipmap(width, height), + width: width, + height: height, } } @@ -92,14 +98,42 @@ func (i *Image) DumpScreenshot(name string, blackbg bool) error { return theUI.dumpScreenshot(i.mipmap, name, blackbg) } -func (i *Image) SetIndependent(independent bool) { - i.mipmap.SetIndependent(independent) -} - -func (i *Image) SetVolatile(volatile bool) { - i.mipmap.SetVolatile(volatile) -} - func DumpImages(dir string) error { return theUI.dumpImages(dir) } + +var ( + emptyImage = NewImage(3, 3) +) + +func init() { + pix := make([]byte, 4*emptyImage.width*emptyImage.height) + for i := range pix { + pix[i] = 0xff + } + // As emptyImage is used at Fill, use ReplacePixels instead. + emptyImage.ReplacePixels(pix, 0, 0, emptyImage.width, emptyImage.height) +} + +func (i *Image) clear() { + i.Fill(0, 0, 0, 0, 0, 0, i.width, i.height) +} + +func (i *Image) Fill(r, g, b, a float32, x, y, width, height int) { + dstRegion := graphicsdriver.Region{ + X: float32(x), + Y: float32(y), + Width: float32(width), + Height: float32(height), + } + + vs := graphics.QuadVertices( + 1, 1, float32(emptyImage.width-1), float32(emptyImage.height-1), + float32(i.width), 0, 0, float32(i.height), 0, 0, + r, g, b, a) + is := graphics.QuadIndices() + + srcs := [graphics.ShaderImageNum]*Image{emptyImage} + + i.DrawTriangles(srcs, vs, is, affine.ColorMIdentity{}, graphicsdriver.CompositeModeCopy, graphicsdriver.FilterNearest, graphicsdriver.AddressUnsafe, dstRegion, graphicsdriver.Region{}, [graphics.ShaderImageNum - 1][2]float32{}, nil, nil, false, true) +}