Compare commits

...

13 Commits

Author SHA1 Message Date
Bertrand Jung
23769b478f
Merge d02960eb57 into 9449e0a8a6 2024-10-25 10:55:39 +04:00
Hajime Hoshi
9449e0a8a6 Revert "text/v2: remove faceWithCache's mutex"
This reverts commit d5c6fd174f.

Reason: CacheGlyphs and Measure should be concurrent safe.
2024-10-25 14:52:02 +09:00
Hajime Hoshi
d5c6fd174f text/v2: remove faceWithCache's mutex
faceWithCache is not exported, and the functions using faceWithCache
are not concurrent safe. Thus, it doesn't make sense to protect
faceWithCache by mutex. Rather, this affects performance.
2024-10-25 13:58:18 +09:00
Hajime Hoshi
47f11a7423 ebiten: bug fix: DefaultDrawFinalScreen should take FinalScreen
Updates #3139
2024-10-25 09:22:52 +09:00
Zyko
d02960eb57 Fixed missing import 2024-09-18 21:28:10 +02:00
Zyko
69b541d64d Main merge 2024-09-18 21:24:33 +02:00
Zyko
1dd96726c4 Add a benchmark + fix sub image allocations 2024-08-15 19:48:36 +02:00
Zyko
30157b5dea Add license header 2024-08-05 20:41:04 +02:00
Zyko
b20692f523 Fixed colorscale mode 2024-08-05 20:33:53 +02:00
Zyko
2eebe55b90 Restore go1.19 2024-08-05 20:27:36 +02:00
Zyko
ec06c68fa3 Re-use internal/packing logic and remove external dep 2024-08-05 20:25:54 +02:00
Zyko
4601cffaba Cleanup 2024-07-27 18:01:06 +02:00
Zyko
5e8d969034 PoC text/v2 glyph atlas 2024-07-27 17:41:53 +02:00
10 changed files with 368 additions and 26 deletions

View File

@ -142,7 +142,7 @@ var (
//
// You can use DefaultDrawFinalScreen when you need the default implementation of [FinalScreenDrawer.DrawFinalScreen]
// in your implementation of [FinalScreenDrawer], for example.
func DefaultDrawFinalScreen(screen *Image, offscreen *Image, geoM GeoM) {
func DefaultDrawFinalScreen(screen FinalScreen, offscreen *Image, geoM GeoM) {
theScreenShaderOnce.Do(func() {
s, err := newShader(builtinshader.ScreenShaderSource, "screen")
if err != nil {

297
text/v2/atlas.go Normal file
View File

@ -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]
}

View File

@ -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)
}
}
}

View File

@ -328,11 +328,16 @@ func (g *GoTextFace) appendGlyphsForLine(glyphs []Glyph, line string, indexOffse
img, imgX, imgY := g.glyphImage(glyph, o)
// 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),
OriginX: fixed26_6ToFloat64(origin.X),
@ -349,7 +354,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)
@ -369,8 +374,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()

View File

@ -24,8 +24,6 @@ import (
"github.com/go-text/typesetting/language"
"github.com/go-text/typesetting/shaping"
"golang.org/x/image/math/fixed"
"github.com/hajimehoshi/ebiten/v2"
)
type goTextOutputCacheKey struct {
@ -283,7 +281,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]{}
}

View File

@ -23,7 +23,6 @@ import (
"golang.org/x/image/math/fixed"
gvector "golang.org/x/image/vector"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
@ -75,7 +74,7 @@ func segmentsToBounds(segs []opentype.Segment) fixed.Rectangle26_6 {
}
}
func segmentsToImage(segs []opentype.Segment, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *ebiten.Image {
func segmentsToImage(a *glyphAtlas, segs []opentype.Segment, subpixelOffset fixed.Point26_6, glyphBounds fixed.Rectangle26_6) *glyphImage {
if len(segs) == 0 {
return nil
}
@ -122,7 +121,10 @@ func segmentsToImage(segs []opentype.Segment, subpixelOffset fixed.Point26_6, gl
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 []opentype.Segment, x, y float32) {

View File

@ -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"
)
@ -139,9 +138,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),
OriginX: fixed26_6ToFloat64(origin.X),
@ -156,7 +156,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)
@ -170,15 +170,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
@ -202,7 +202,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.

View File

@ -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.

View File

@ -121,6 +121,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

View File

@ -27,6 +27,7 @@ import (
"github.com/hajimehoshi/bitmapfont/v3"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
@ -448,3 +449,23 @@ func TestGoXFaceMetrics(t *testing.T) {
})
}
}
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)
}
}