From b210339786735f2925a390eddf7bf70ec228f490 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Tue, 30 Jul 2019 13:20:41 +0900 Subject: [PATCH] graphics: Use 'negative' mipmap when enlarging a too small image This is a hack to render edges correctly. This works only when the filter is nearest. Fixes #611 --- export_test.go | 4 +- image.go | 36 ++++++----- image_test.go | 60 ++++++++++--------- mipmap.go | 160 +++++++++++++++++++++++++++++++++++++++++++------ mipmap_test.go | 5 +- 5 files changed, 196 insertions(+), 69 deletions(-) diff --git a/export_test.go b/export_test.go index b377102db..ec70535fb 100644 --- a/export_test.go +++ b/export_test.go @@ -15,6 +15,6 @@ package ebiten var ( - CopyImage = copyImage - MipmapLevel = mipmapLevel + CopyImage = copyImage + MipmapLevelForDownscale = mipmapLevelForDownscale ) diff --git a/image.go b/image.go index 08e1b0ff9..6292253e1 100644 --- a/image.go +++ b/image.go @@ -249,22 +249,15 @@ func (i *Image) DrawImage(img *Image, options *DrawImageOptions) error { filter = driver.Filter(img.filter) } - a, b, c, d, tx, ty := geom.elements() + if det := geom.det(); det == 0 { + return nil + } else if math.IsNaN(float64(det)) { + return nil + } - level := 0 - if filter == driver.FilterLinear && !img.mipmap.original().IsVolatile() { - det := geom.det() - if det == 0 { - return nil - } - if math.IsNaN(float64(det)) { - return nil - } - level = mipmapLevel(det) - if level < 0 { - panic(fmt.Sprintf("ebiten: level must be >= 0 but %d", level)) - } + level := img.mipmap.mipmapLevel(geom, bounds.Dx(), bounds.Dy(), filter) + if level > 0 { // If the image can be scaled into 0 size, adjust the level. (#839) w, h := bounds.Dx(), bounds.Dy() for level >= 0 { @@ -281,9 +274,13 @@ func (i *Image) DrawImage(img *Image, options *DrawImageOptions) error { return nil } } + if level > 6 { level = 6 } + if level < -6 { + level = -6 + } // TODO: Add (*mipmap).drawImage and move the below code. colorm := options.ColorM.impl @@ -297,6 +294,7 @@ func (i *Image) DrawImage(img *Image, options *DrawImageOptions) error { colorm = nil } + a, b, c, d, tx, ty := geom.elements() if level == 0 { src := img.mipmap.original() vs := vertexSlice(4) @@ -305,11 +303,11 @@ func (i *Image) DrawImage(img *Image, options *DrawImageOptions) error { i.mipmap.original().DrawTriangles(src, vs, is, colorm, mode, filter, driver.AddressClampToZero) } else if src := img.mipmap.level(bounds, level); src != nil { w, h := src.Size() - s := 1 << uint(level) - a *= float32(s) - b *= float32(s) - c *= float32(s) - d *= float32(s) + s := pow2(level) + a *= s + b *= s + c *= s + d *= s vs := vertexSlice(4) graphics.PutQuadVertices(vs, src, 0, 0, w, h, a, b, c, d, tx, ty, cr, cg, cb, ca) is := graphics.QuadIndices() diff --git a/image_test.go b/image_test.go index 0dadcc74a..9cc9a613f 100644 --- a/image_test.go +++ b/image_test.go @@ -940,36 +940,42 @@ func TestImageCopy(t *testing.T) { img1.Fill(color.Transparent) } +// Issue #611, #907 func TestImageStretch(t *testing.T) { - img0, _ := NewImage(16, 17, FilterDefault) + const w = 16 - pix := make([]byte, 4*16*17) - for i := 0; i < 16*16; i++ { - pix[4*i] = 0xff - pix[4*i+3] = 0xff - } - for i := 0; i < 16; i++ { - pix[4*(16*16+i)+1] = 0xff - pix[4*(16*16+i)+3] = 0xff - } - img0.ReplacePixels(pix) + dst, _ := NewImage(w, 4096, FilterDefault) +loop: + for h := 1; h <= 32; h++ { + src, _ := NewImage(w, h+1, FilterDefault) - // TODO: 4096 doesn't pass on MacBook Pro (#611). - const h = 4000 - img1, _ := NewImage(16, h, FilterDefault) - for i := 1; i < h; i++ { - img1.Clear() - op := &DrawImageOptions{} - op.GeoM.Scale(1, float64(i)/16) - img1.DrawImage(img0.SubImage(image.Rect(0, 0, 16, 16)).(*Image), op) - for j := -1; j <= 1; j++ { - got := img1.At(0, i+j).(color.RGBA) - want := color.RGBA{} - if j < 0 { - want = color.RGBA{0xff, 0, 0, 0xff} - } - if got != want { - t.Fatalf("At(%d, %d) (i=%d): got: %#v, want: %#v", 0, i+j, i, got, want) + pix := make([]byte, 4*w*(h+1)) + for i := 0; i < w*h; i++ { + pix[4*i] = 0xff + pix[4*i+3] = 0xff + } + for i := 0; i < w; i++ { + pix[4*(w*h+i)+1] = 0xff + pix[4*(w*h+i)+3] = 0xff + } + src.ReplacePixels(pix) + + _, dh := dst.Size() + for i := 32; i < dh; i += 32 { + dst.Clear() + op := &DrawImageOptions{} + op.GeoM.Scale(1, float64(i)/float64(h)) + dst.DrawImage(src.SubImage(image.Rect(0, 0, w, h)).(*Image), op) + for j := -2; j <= 2; j++ { + got := dst.At(0, i+j).(color.RGBA) + want := color.RGBA{} + if j < 0 { + want = color.RGBA{0xff, 0, 0, 0xff} + } + if got != want { + t.Errorf("At(%d, %d) (height=%d, scale=%d/%d): got: %#v, want: %#v", 0, i+j, h, i, h, got, want) + continue loop + } } } } diff --git a/mipmap.go b/mipmap.go index 39d8b44ed..beedc9d60 100644 --- a/mipmap.go +++ b/mipmap.go @@ -15,6 +15,7 @@ package ebiten import ( + "fmt" "image" "math" @@ -42,8 +43,8 @@ func (m *mipmap) original() *shareable.Image { } func (m *mipmap) level(r image.Rectangle, level int) *shareable.Image { - if level <= 0 { - panic("ebiten: level must be positive at level") + if level == 0 { + panic("ebiten: level must be non-zero at level") } if m.orig.IsVolatile() { @@ -63,30 +64,50 @@ func (m *mipmap) level(r image.Rectangle, level int) *shareable.Image { w, h := size.X, size.Y w2, h2 := w, h - for i := 0; i < level; i++ { - w2 /= 2 - h2 /= 2 - if w == 0 || h == 0 { - imgs[level] = nil - return nil + if level > 0 { + for i := 0; i < level; i++ { + w2 /= 2 + h2 /= 2 + if w == 0 || h == 0 { + imgs[level] = nil + return nil + } + } + } else { + for i := 0; i < -level; i++ { + w2 *= 2 + h2 *= 2 } } - s := shareable.NewImage(w2, h2) + var src *shareable.Image vs := vertexSlice(4) - if level == 1 { + var filter driver.Filter + switch { + case level == 1: src = m.orig graphics.PutQuadVertices(vs, src, r.Min.X, r.Min.Y, r.Max.X, r.Max.Y, 0.5, 0, 0, 0.5, 0, 0, 1, 1, 1, 1) - } else { + filter = driver.FilterLinear + case level > 1: src = m.level(r, level-1) if src == nil { imgs[level] = nil return nil } graphics.PutQuadVertices(vs, src, 0, 0, w, h, 0.5, 0, 0, 0.5, 0, 0, 1, 1, 1, 1) + filter = driver.FilterLinear + case level < 0: + src = m.orig + s := pow2(-level) + graphics.PutQuadVertices(vs, src, r.Min.X, r.Min.Y, r.Max.X, r.Max.Y, s, 0, 0, s, 0, 0, 1, 1, 1, 1) + filter = driver.FilterNearest + default: + panic(fmt.Sprintf("ebiten: invalid level: %d", level)) } is := graphics.QuadIndices() - s.DrawTriangles(src, vs, is, nil, driver.CompositeModeCopy, driver.FilterLinear, driver.AddressClampToZero) + + s := shareable.NewImage(w2, h2) + s.DrawTriangles(src, vs, is, nil, driver.CompositeModeCopy, filter, driver.AddressClampToZero) imgs[level] = s return imgs[level] @@ -123,17 +144,65 @@ func (m *mipmap) resetRestoringState() { // mipmapLevel returns an appropriate mipmap level for the given determinant of a geometry matrix. // -// mipmapLevel returns -1 if det is 0. -// -// mipmapLevel panics if det is NaN. -func mipmapLevel(det float32) int { +// mipmapLevel panics if det is NaN or 0. +func (m *mipmap) mipmapLevel(geom *GeoM, width, height int, filter driver.Filter) int { + det := geom.det() if math.IsNaN(float64(det)) { - panic("graphicsutil: det must be finite") + panic("ebiten: det must be finite at mipmapLevel") } if det == 0 { - return -1 + panic("ebiten: dst must be non zero at mipmapLevel") } + // Use 'negative' mipmap to render edges correctly (#611, #907). + // It looks like 256 is the enlargement factor that causes edge missings to pass the test TestImageStretch. + const tooBigScale = 256 + if sx, sy := geomScaleSize(geom); sx >= tooBigScale || sy >= tooBigScale { + // If the filter is not nearest, the target needs to be rendered with gradiation. Don't use mipmaps. + if filter != driver.FilterNearest { + return 0 + } + + const mipmapMaxSize = 1024 + w, h := width, height + if w >= mipmapMaxSize || h >= mipmapMaxSize { + return 0 + } + + level := 0 + for sx >= tooBigScale || sy >= tooBigScale { + level-- + sx /= 2 + sy /= 2 + w *= 2 + h *= 2 + if w >= mipmapMaxSize || h >= mipmapMaxSize { + break + } + } + return level + } + + if filter != driver.FilterLinear { + return 0 + } + if m.original().IsVolatile() { + return 0 + } + + // This is a separate function for testing. + return mipmapLevelForDownscale(det) +} + +func mipmapLevelForDownscale(det float32) int { + if math.IsNaN(float64(det)) { + panic("ebiten: det must be finite at mipmapLevelForDownscale") + } + if det == 0 { + panic("ebiten: dst must be non zero at mipmapLevelForDownscale") + } + + // TODO: Should this be determined by x/y scales instead of det? d := math.Abs(float64(det)) level := 0 for d < 0.25 { @@ -142,3 +211,58 @@ func mipmapLevel(det float32) int { } return level } + +func pow2(power int) float32 { + if power >= 0 { + x := 1 + return float32(x << uint(power)) + } + + x := float32(1) + for i := 0; i < -power; i++ { + x /= 2 + } + return x +} + +func maxf32(values ...float32) float32 { + max := float32(math.Inf(-1)) + for _, v := range values { + if max < v { + max = v + } + } + return max +} + +func minf32(values ...float32) float32 { + min := float32(math.Inf(1)) + for _, v := range values { + if min > v { + min = v + } + } + return min +} + +func geomScaleSize(geom *GeoM) (sx, sy float32) { + a, b, c, d, _, _ := geom.elements() + // (0, 1) + x0 := 0*a + 1*b + y0 := 0*c + 1*d + + // (1, 0) + x1 := 1*a + 0*b + y1 := 1*c + 0*d + + // (1, 1) + x2 := 1*a + 1*b + y2 := 1*c + 1*d + + maxx := maxf32(0, x0, x1, x2) + maxy := maxf32(0, y0, y1, y2) + minx := minf32(0, x0, x1, x2) + miny := minf32(0, y0, y1, y2) + + return maxx - minx, maxy - miny +} diff --git a/mipmap_test.go b/mipmap_test.go index 64112aa9b..2dc44a236 100644 --- a/mipmap_test.go +++ b/mipmap_test.go @@ -21,13 +21,12 @@ import ( . "github.com/hajimehoshi/ebiten" ) -func TestMipmapLevel(t *testing.T) { +func TestMipmapLevelForDownscale(t *testing.T) { inf := float32(math.Inf(1)) cases := []struct { In float32 Out int }{ - {0, -1}, {1, 0}, {-1, 0}, {2, 0}, @@ -55,7 +54,7 @@ func TestMipmapLevel(t *testing.T) { } for _, c := range cases { - got := MipmapLevel(c.In) + got := MipmapLevelForDownscale(c.In) want := c.Out if got != want { t.Errorf("MipmapLevel(%v): got %v, want %v", c.In, got, want)