diff --git a/text/v2/atlas.go b/text/v2/atlas.go new file mode 100644 index 000000000..719a0f4ea --- /dev/null +++ b/text/v2/atlas.go @@ -0,0 +1,297 @@ +// Copyright 2024 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package text + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/internal/packing" +) + +type glyphAtlas struct { + page *packing.Page + image *ebiten.Image +} + +type glyphImage struct { + atlas *glyphAtlas + node *packing.Node + img *ebiten.Image +} + +func (i *glyphImage) Image() *ebiten.Image { + return i.img +} + +func newGlyphAtlas() *glyphAtlas { + return &glyphAtlas{ + // Note: 128x128 is arbitrary, maybe a better value can be inferred + // from the font size or something + page: packing.NewPage(128, 128, 1024), // TODO: not 1024 + image: ebiten.NewImage(128, 128), + } +} + +func (g *glyphAtlas) NewImage(w, h int) *glyphImage { + n := g.page.Alloc(w, h) + pw, ph := g.page.Size() + if pw > g.image.Bounds().Dx() || ph > g.image.Bounds().Dy() { + newImage := ebiten.NewImage(pw, ph) + newImage.DrawImage(g.image, nil) + g.image = newImage + } + + return &glyphImage{ + atlas: g, + node: n, + img: g.image.SubImage(n.Region()).(*ebiten.Image), + } +} + +func (g *glyphAtlas) Free(img *glyphImage) { + g.page.Free(img.node) +} + +type drawRange struct { + atlas *glyphAtlas + end int +} + +// drawList stores triangle versions of DrawImage calls when +// all images are sub-images of an atlas. +// Temporary vertices and indices can be re-used after calling +// Flush, so it is more efficient to keep a reference to a drawList +// instead of creating a new one every frame. +type drawList struct { + ranges []drawRange + vx []ebiten.Vertex + ix []uint16 +} + +// drawCommand is the equivalent of the regular DrawImageOptions +// but only including options that will not break batching. +// Filter, Address, Blend and AntiAlias are determined at Flush() +type drawCommand struct { + Image *glyphImage + + ColorScale ebiten.ColorScale + GeoM ebiten.GeoM +} + +var rectIndices = [6]uint16{0, 1, 2, 1, 2, 3} + +type point struct { + X, Y float32 +} + +func pt(x, y float64) point { + return point{ + X: float32(x), + Y: float32(y), + } +} + +type rectOpts struct { + Dsts [4]point + SrcX0, SrcY0 float32 + SrcX1, SrcY1 float32 + R, G, B, A float32 +} + +// adjustDestinationPixel is the original ebitengine implementation found here: +// https://github.com/hajimehoshi/ebiten/blob/v2.8.0-alpha.1/internal/graphics/vertex.go#L102-L126 +func adjustDestinationPixel(x float32) float32 { + // Avoid the center of the pixel, which is problematic (#929, #1171). + // Instead, align the vertices with about 1/3 pixels. + // + // The intention here is roughly this code: + // + // float32(math.Floor((float64(x)+1.0/6.0)*3) / 3) + // + // The actual implementation is more optimized than the above implementation. + ix := float32(int(x)) + if x < 0 && x != ix { + ix -= 1 + } + frac := x - ix + switch { + case frac < 3.0/16.0: + return ix + case frac < 8.0/16.0: + return ix + 5.0/16.0 + case frac < 13.0/16.0: + return ix + 11.0/16.0 + default: + return ix + 16.0/16.0 + } +} + +func appendRectVerticesIndices(vertices []ebiten.Vertex, indices []uint16, index int, opts *rectOpts) ([]ebiten.Vertex, []uint16) { + sx0, sy0, sx1, sy1 := opts.SrcX0, opts.SrcY0, opts.SrcX1, opts.SrcY1 + r, g, b, a := opts.R, opts.G, opts.B, opts.A + vertices = append(vertices, + ebiten.Vertex{ + DstX: adjustDestinationPixel(opts.Dsts[0].X), + DstY: adjustDestinationPixel(opts.Dsts[0].Y), + SrcX: sx0, + SrcY: sy0, + ColorR: r, + ColorG: g, + ColorB: b, + ColorA: a, + }, + ebiten.Vertex{ + DstX: adjustDestinationPixel(opts.Dsts[1].X), + DstY: adjustDestinationPixel(opts.Dsts[1].Y), + SrcX: sx1, + SrcY: sy0, + ColorR: r, + ColorG: g, + ColorB: b, + ColorA: a, + }, + ebiten.Vertex{ + DstX: adjustDestinationPixel(opts.Dsts[2].X), + DstY: adjustDestinationPixel(opts.Dsts[2].Y), + SrcX: sx0, + SrcY: sy1, + ColorR: r, + ColorG: g, + ColorB: b, + ColorA: a, + }, + ebiten.Vertex{ + DstX: adjustDestinationPixel(opts.Dsts[3].X), + DstY: adjustDestinationPixel(opts.Dsts[3].Y), + SrcX: sx1, + SrcY: sy1, + ColorR: r, + ColorG: g, + ColorB: b, + ColorA: a, + }, + ) + + indiceCursor := uint16(index * 4) + indices = append(indices, + rectIndices[0]+indiceCursor, + rectIndices[1]+indiceCursor, + rectIndices[2]+indiceCursor, + rectIndices[3]+indiceCursor, + rectIndices[4]+indiceCursor, + rectIndices[5]+indiceCursor, + ) + + return vertices, indices +} + +// Add adds DrawImage commands to the DrawList, images from multiple +// atlases can be added but they will break the previous batch bound to +// a different atlas, requiring an additional draw call internally. +// So, it is better to have the maximum of consecutive DrawCommand images +// sharing the same atlas. +func (dl *drawList) Add(commands ...*drawCommand) { + if len(commands) == 0 { + return + } + + var batch *drawRange + + if len(dl.ranges) > 0 { + batch = &dl.ranges[len(dl.ranges)-1] + } else { + dl.ranges = append(dl.ranges, drawRange{ + atlas: commands[0].Image.atlas, + }) + batch = &dl.ranges[0] + } + // Add vertices and indices + opts := &rectOpts{} + for _, cmd := range commands { + if cmd.Image.atlas != batch.atlas { + dl.ranges = append(dl.ranges, drawRange{ + atlas: cmd.Image.atlas, + }) + batch = &dl.ranges[len(dl.ranges)-1] + } + + // Dst attributes + bounds := cmd.Image.node.Region() + opts.Dsts[0] = pt(cmd.GeoM.Apply(0, 0)) + opts.Dsts[1] = pt(cmd.GeoM.Apply( + float64(bounds.Dx()), 0, + )) + opts.Dsts[2] = pt(cmd.GeoM.Apply( + 0, float64(bounds.Dy()), + )) + opts.Dsts[3] = pt(cmd.GeoM.Apply( + float64(bounds.Dx()), float64(bounds.Dy()), + )) + + // Color and source attributes + opts.R = cmd.ColorScale.R() + opts.G = cmd.ColorScale.G() + opts.B = cmd.ColorScale.B() + opts.A = cmd.ColorScale.A() + opts.SrcX0 = float32(bounds.Min.X) + opts.SrcY0 = float32(bounds.Min.Y) + opts.SrcX1 = float32(bounds.Max.X) + opts.SrcY1 = float32(bounds.Max.Y) + + dl.vx, dl.ix = appendRectVerticesIndices( + dl.vx, dl.ix, batch.end, opts, + ) + + batch.end++ + } +} + +// DrawOptions are additional options that will be applied to +// all draw commands from the draw list when calling Flush(). +type drawOptions struct { + ColorScaleMode ebiten.ColorScaleMode + Blend ebiten.Blend + Filter ebiten.Filter + Address ebiten.Address + AntiAlias bool +} + +// Flush executes all the draw commands as the smallest possible +// amount of draw calls, and then clears the list for next uses. +func (dl *drawList) Flush(dst *ebiten.Image, opts *drawOptions) { + var topts *ebiten.DrawTrianglesOptions + if opts != nil { + topts = &ebiten.DrawTrianglesOptions{ + ColorScaleMode: opts.ColorScaleMode, + Blend: opts.Blend, + Filter: opts.Filter, + Address: opts.Address, + AntiAlias: opts.AntiAlias, + } + } + index := 0 + for _, r := range dl.ranges { + dst.DrawTriangles( + dl.vx[index*4:(index+r.end)*4], + dl.ix[index*6:(index+r.end)*6], + r.atlas.image, + topts, + ) + index += r.end + } + // Clear buffers + dl.ranges = dl.ranges[:0] + dl.vx = dl.vx[:0] + dl.ix = dl.ix[:0] +} diff --git a/text/v2/glyph.go b/text/v2/glyph.go index cdf03df26..f32b0ce99 100644 --- a/text/v2/glyph.go +++ b/text/v2/glyph.go @@ -18,7 +18,6 @@ import ( "math" "sync" - "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/internal/hook" ) @@ -38,17 +37,18 @@ func init() { } type glyphImageCacheEntry struct { - image *ebiten.Image + image *glyphImage atime int64 } type glyphImageCache[Key comparable] struct { + atlas *glyphAtlas cache map[Key]*glyphImageCacheEntry atime int64 m sync.Mutex } -func (g *glyphImageCache[Key]) getOrCreate(face Face, key Key, create func() *ebiten.Image) *ebiten.Image { +func (g *glyphImageCache[Key]) getOrCreate(face Face, key Key, create func(a *glyphAtlas) *glyphImage) *glyphImage { g.m.Lock() defer g.m.Unlock() @@ -61,10 +61,11 @@ func (g *glyphImageCache[Key]) getOrCreate(face Face, key Key, create func() *eb } if g.cache == nil { + g.atlas = newGlyphAtlas() g.cache = map[Key]*glyphImageCacheEntry{} } - img := create() + img := create(g.atlas) e = &glyphImageCacheEntry{ image: img, } @@ -91,6 +92,7 @@ func (g *glyphImageCache[Key]) getOrCreate(face Face, key Key, create func() *eb continue } delete(g.cache, key) + g.atlas.Free(e.image) } } } diff --git a/text/v2/gotext.go b/text/v2/gotext.go index 7b42576dd..8dd971f93 100644 --- a/text/v2/gotext.go +++ b/text/v2/gotext.go @@ -310,11 +310,16 @@ func (g *GoTextFace) appendGlyphsForLine(glyphs []Glyph, line string, indexOffse })) // Append a glyph even if img is nil. // This is necessary to return index information for control characters. + var ebitenImage *ebiten.Image + if img != nil { + ebitenImage = img.Image() + } glyphs = append(glyphs, Glyph{ + img: img, StartIndexInBytes: indexOffset + glyph.startIndex, EndIndexInBytes: indexOffset + glyph.endIndex, GID: uint32(glyph.shapingGlyph.GlyphID), - Image: img, + Image: ebitenImage, X: float64(imgX), Y: float64(imgY), }) @@ -327,7 +332,7 @@ func (g *GoTextFace) appendGlyphsForLine(glyphs []Glyph, line string, indexOffse return glyphs } -func (g *GoTextFace) glyphImage(glyph glyph, origin fixed.Point26_6) (*ebiten.Image, int, int) { +func (g *GoTextFace) glyphImage(glyph glyph, origin fixed.Point26_6) (*glyphImage, int, int) { if g.direction().isHorizontal() { origin.X = adjustGranularity(origin.X, g) origin.Y &^= ((1 << 6) - 1) @@ -347,8 +352,8 @@ func (g *GoTextFace) glyphImage(glyph glyph, origin fixed.Point26_6) (*ebiten.Im yoffset: subpixelOffset.Y, variations: g.ensureVariationsString(), } - img := g.Source.getOrCreateGlyphImage(g, key, func() *ebiten.Image { - return segmentsToImage(glyph.scaledSegments, subpixelOffset, b) + img := g.Source.getOrCreateGlyphImage(g, key, func(a *glyphAtlas) *glyphImage { + return segmentsToImage(a, glyph.scaledSegments, subpixelOffset, b) }) imgX := (origin.X + b.Min.X).Floor() diff --git a/text/v2/gotextfacesource.go b/text/v2/gotextfacesource.go index 3e84adde6..002e34e26 100644 --- a/text/v2/gotextfacesource.go +++ b/text/v2/gotextfacesource.go @@ -26,8 +26,6 @@ import ( "github.com/go-text/typesetting/opentype/loader" "github.com/go-text/typesetting/shaping" "golang.org/x/image/math/fixed" - - "github.com/hajimehoshi/ebiten/v2" ) type goTextOutputCacheKey struct { @@ -282,7 +280,7 @@ func (g *GoTextFaceSource) scale(size float64) float64 { return size / float64(g.f.Upem()) } -func (g *GoTextFaceSource) getOrCreateGlyphImage(goTextFace *GoTextFace, key goTextGlyphImageCacheKey, create func() *ebiten.Image) *ebiten.Image { +func (g *GoTextFaceSource) getOrCreateGlyphImage(goTextFace *GoTextFace, key goTextGlyphImageCacheKey, create func(a *glyphAtlas) *glyphImage) *glyphImage { if g.glyphImageCache == nil { g.glyphImageCache = map[float64]*glyphImageCache[goTextGlyphImageCacheKey]{} } diff --git a/text/v2/gotextseg.go b/text/v2/gotextseg.go index 4875bb4ba..62605242c 100644 --- a/text/v2/gotextseg.go +++ b/text/v2/gotextseg.go @@ -19,11 +19,11 @@ import ( "image/draw" "math" - "github.com/go-text/typesetting/opentype/api" - "golang.org/x/image/math/fixed" gvector "golang.org/x/image/vector" - "github.com/hajimehoshi/ebiten/v2" + "github.com/go-text/typesetting/opentype/api" + "golang.org/x/image/math/fixed" + "github.com/hajimehoshi/ebiten/v2/vector" ) @@ -75,7 +75,7 @@ func segmentsToBounds(segs []api.Segment) fixed.Rectangle26_6 { } } -func segmentsToImage(segs []api.Segment, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *ebiten.Image { +func segmentsToImage(a *glyphAtlas, segs []api.Segment, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *glyphImage { if len(segs) == 0 { return nil } @@ -122,7 +122,10 @@ func segmentsToImage(segs []api.Segment, subpixelOffset fixed.Point26_6, glyphBo dst := image.NewRGBA(image.Rect(0, 0, w, h)) rast.Draw(dst, dst.Bounds(), image.Opaque, image.Point{}) - return ebiten.NewImageFromImage(dst) + img := a.NewImage(w, h) + img.Image().WritePixels(dst.Pix) + + return img } func appendVectorPathFromSegments(path *vector.Path, segs []api.Segment, x, y float32) { diff --git a/text/v2/gox.go b/text/v2/gox.go index 625215d82..f8cefa228 100644 --- a/text/v2/gox.go +++ b/text/v2/gox.go @@ -21,7 +21,6 @@ import ( "golang.org/x/image/font" "golang.org/x/image/math/fixed" - "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" ) @@ -119,9 +118,10 @@ func (s *GoXFace) appendGlyphsForLine(glyphs []Glyph, line string, indexOffset i // Append a glyph even if img is nil. // This is necessary to return index information for control characters. glyphs = append(glyphs, Glyph{ + img: img, StartIndexInBytes: indexOffset + i, EndIndexInBytes: indexOffset + i + size, - Image: img, + Image: img.Image(), X: float64(imgX), Y: float64(imgY), }) @@ -132,7 +132,7 @@ func (s *GoXFace) appendGlyphsForLine(glyphs []Glyph, line string, indexOffset i return glyphs } -func (s *GoXFace) glyphImage(r rune, origin fixed.Point26_6) (*ebiten.Image, int, int, fixed.Int26_6) { +func (s *GoXFace) glyphImage(r rune, origin fixed.Point26_6) (*glyphImage, int, int, fixed.Int26_6) { // Assume that GoXFace's direction is always horizontal. origin.X = adjustGranularity(origin.X, s) origin.Y &^= ((1 << 6) - 1) @@ -146,15 +146,15 @@ func (s *GoXFace) glyphImage(r rune, origin fixed.Point26_6) (*ebiten.Image, int rune: r, xoffset: subpixelOffset.X, } - img := s.glyphImageCache.getOrCreate(s, key, func() *ebiten.Image { - return s.glyphImageImpl(r, subpixelOffset, b) + img := s.glyphImageCache.getOrCreate(s, key, func(a *glyphAtlas) *glyphImage { + return s.glyphImageImpl(a, r, subpixelOffset, b) }) imgX := (origin.X + b.Min.X).Floor() imgY := (origin.Y + b.Min.Y).Floor() return img, imgX, imgY, a } -func (s *GoXFace) glyphImageImpl(r rune, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *ebiten.Image { +func (s *GoXFace) glyphImageImpl(a *glyphAtlas, r rune, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *glyphImage { w, h := (glyphBounds.Max.X - glyphBounds.Min.X).Ceil(), (glyphBounds.Max.Y - glyphBounds.Min.Y).Ceil() if w == 0 || h == 0 { return nil @@ -178,7 +178,10 @@ func (s *GoXFace) glyphImageImpl(r rune, subpixelOffset fixed.Point26_6, glyphBo } d.DrawString(string(r)) - return ebiten.NewImageFromImage(rgba) + img := a.NewImage(w, h) + img.Image().WritePixels(rgba.Pix) + + return img } // direction implements Face. diff --git a/text/v2/layout.go b/text/v2/layout.go index fe874e01a..c08dec2a4 100644 --- a/text/v2/layout.go +++ b/text/v2/layout.go @@ -111,15 +111,24 @@ func Draw(dst *ebiten.Image, text string, face Face, options *DrawOptions) { geoM := drawOp.GeoM + dl := &drawList{} + dc := &drawCommand{} for _, g := range AppendGlyphs(nil, text, face, &layoutOp) { if g.Image == nil { continue } - drawOp.GeoM.Reset() - drawOp.GeoM.Translate(g.X, g.Y) - drawOp.GeoM.Concat(geoM) - dst.DrawImage(g.Image, &drawOp) + dc.GeoM.Reset() + dc.GeoM.Translate(g.X, g.Y) + dc.GeoM.Concat(geoM) + dc.ColorScale = drawOp.ColorScale + dc.Image = g.img + dl.Add(dc) } + dl.Flush(dst, &drawOptions{ + Blend: drawOp.Blend, + Filter: drawOp.Filter, + ColorScaleMode: ebiten.ColorScaleModePremultipliedAlpha, + }) } // AppendGlyphs appends glyphs to the given slice and returns a slice. diff --git a/text/v2/text.go b/text/v2/text.go index 97d996e62..df1a21f8d 100644 --- a/text/v2/text.go +++ b/text/v2/text.go @@ -115,6 +115,11 @@ func adjustGranularity(x fixed.Int26_6, face Face) fixed.Int26_6 { // Glyph represents one glyph to render. type Glyph struct { + // Image is a rasterized glyph image. + // Image is a grayscale image i.e. RGBA values are the same. + // Image should be used as a render source and should not be modified. + img *glyphImage + // StartIndexInBytes is the start index in bytes for the given string at AppendGlyphs. StartIndexInBytes int diff --git a/text/v2/text_test.go b/text/v2/text_test.go index 46acf1106..8b2b47da3 100644 --- a/text/v2/text_test.go +++ b/text/v2/text_test.go @@ -15,6 +15,7 @@ package text_test import ( + "bytes" "image" "image/color" "regexp" @@ -23,6 +24,7 @@ import ( "github.com/hajimehoshi/bitmapfont/v3" "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/math/fixed" "github.com/hajimehoshi/ebiten/v2" @@ -371,3 +373,23 @@ func TestDrawOptionsNotModified(t *testing.T) { t.Errorf("got: %v, want: %v", got, want) } } + +func BenchmarkDrawText(b *testing.B) { + var txt string + for i := 0; i < 32; i++ { + txt += "The quick brown fox jumps over the lazy dog.\n" + } + screen := ebiten.NewImage(16, 16) + source, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF)) + if err != nil { + b.Fatal(err) + } + f := &text.GoTextFace{ + Source: source, + Size: 10, + } + op := &text.DrawOptions{} + for i := 0; i < b.N; i++ { + text.Draw(screen, txt, f, op) + } +}