diff --git a/budget_test.go b/budget_test.go new file mode 100644 index 0000000..1474087 --- /dev/null +++ b/budget_test.go @@ -0,0 +1,249 @@ +package stilus + +import ( + "errors" + "image" + "image/color" + "sync" + "testing" +) + +// mipLevelBytes returns the bytes the levels of t below its base take +// once made, each level and each distinct wrap level once. +func mipLevelBytes(t *Texture) int { + n := 0 + for k := 1; k <= t.Levels(); k++ { + p := t.Level(k) + n += p.Bytes() + if w := t.wrapLevel(k); w != p { + n += w.Bytes() + } + } + return n +} + +// MipBytes counts the levels as made: rounded up, a one-pixel axis kept, +// and the wrap levels that differ from them. +func TestMipBytesCoversLevels(t *testing.T) { + var colours Palette + for i := range colours { + colours[i] = PackRGBA(color.RGBA{uint8(i), 0, uint8(255 - i), 255}) + } + planes := map[string]func(w, h int) Plane{ + "rgba": func(w, h int) Plane { + return Plane{Kind: PlaneRGBA, W: w, H: h, Stride: w, Pix32: make([]uint32, w*h)} + }, + "gray": func(w, h int) Plane { + return Plane{Kind: PlaneIndex, W: w, H: h, Stride: w, Pix8: make([]uint8, w*h), Pal: GrayPalette} + }, + "alpha": func(w, h int) Plane { + return Plane{Kind: PlaneIndex, W: w, H: h, Stride: w, Pix8: make([]uint8, w*h), Pal: AlphaPalette} + }, + "indexed": func(w, h int) Plane { + return Plane{Kind: PlaneIndex, W: w, H: h, Stride: w, Pix8: make([]uint8, w*h), Pal: &colours} + }, + } + sizes := [][2]int{{4096, 1}, {1, 4096}, {3, 3}, {256, 256}, {5, 7}, {100, 3}, {1, 1}, {2, 1}, {1000, 600}, {17, 4096}} + for name, mk := range planes { + for _, sz := range sizes { + tex := NewTexture(mk(sz[0], sz[1])) + est := tex.MipBytes() + if got := mipLevelBytes(tex); est != got { + t.Errorf("%s %dx%d: MipBytes %d, levels take %d", name, sz[0], sz[1], est, got) + } + } + } + // The cases of the report: the levels of a thin texture take about W·H, + // and its wrap levels as much again. + for _, c := range []struct{ w, h, want int }{ + {4096, 1, 2 * 16376}, {1, 4096, 2 * 16376}, {256, 256, 87380}, + } { + tex := NewTexture(planes["rgba"](c.w, c.h)) + if got := tex.MipBytes(); got != c.want { + t.Errorf("%dx%d: MipBytes %d, want %d", c.w, c.h, got, c.want) + } + } +} + +// A glyph whose mask alone exceeds MaxBytes is drawn but not kept. +func TestGlyphCacheOversizedEntry(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + c := NewCanvas(img) + var outline Path + outline.Rect(0, 0, 1, 1) + gc := GlyphCache{MaxBytes: 100} + paint := &Paint{Color: color.RGBA{A: 255}} + gc.FillGlyph(c, 1, 1, &outline, Scale(20, 20), paint) + if gc.bytes > gc.MaxBytes || len(gc.ents) != 0 { + t.Fatalf("%d bytes in %d entries cached, budget %d", gc.bytes, len(gc.ents), gc.MaxBytes) + } + if img.RGBAAt(10, 10).A != 255 || img.RGBAAt(25, 25).A != 0 { + t.Fatalf("glyph not drawn: alpha %d, %d", img.RGBAAt(10, 10).A, img.RGBAAt(25, 25).A) + } + // Drawn again, the same bytes, and still nothing kept. + want := append([]uint8(nil), img.Pix...) + gc.FillGlyph(c, 1, 1, &outline, Scale(20, 20), paint) + if gc.bytes > gc.MaxBytes || string(img.Pix) != string(want) { + t.Fatalf("second draw: %d bytes cached, pixels changed %v", gc.bytes, string(img.Pix) != string(want)) + } +} + +// A reused evicted mask counts its whole allocation. +func TestGlyphCacheReusedMaskAccounting(t *testing.T) { + gc := GlyphCache{MaxBytes: 1 << 20} + big := &image.Alpha{Pix: make([]uint8, 512), Stride: 16, Rect: image.Rect(0, 0, 16, 32)} + gc.recycle(big) + if gc.freeBytes != 512 { + t.Fatalf("free %d bytes", gc.freeBytes) + } + m := gc.newMask(image.Rect(0, 0, 2, 2)) + if len(m.Pix) != 4 || cap(m.Pix) != 512 || gc.freeBytes != 0 { + t.Fatalf("mask len %d cap %d, free %d", len(m.Pix), cap(m.Pix), gc.freeBytes) + } + if !gc.store(glyphKey{gid: 1}, m) { + t.Fatal("not stored") + } + if gc.bytes != 64+512 { + t.Fatalf("entry counted %d bytes, want %d", gc.bytes, 64+512) + } + gc.evict() + if gc.bytes != 0 || gc.freeBytes != 512 { + t.Fatalf("after eviction: %d bytes cached, %d free", gc.bytes, gc.freeBytes) + } +} + +// Lowering MaxBytes brings the cache within it at the next store. +func TestGlyphCacheBudgetReduction(t *testing.T) { + var g Path + g.MoveTo(0.1, 0) + g.LineTo(0.6, 0) + g.CubicTo(0.9, 0.3, 0.7, 0.8, 0.3, 0.7) + g.Close() + c := NewCanvas(image.NewRGBA(image.Rect(0, 0, 400, 400))) + paint := &Paint{Color: rgba(20, 20, 20, 255)} + gc := GlyphCache{MaxBytes: 1 << 20} + draw := func(id int32) { + gc.FillGlyph(c, 1, id, &g, Matrix{16, 0, 0, -16, float64(id%20) * 18, float64(id/20)*18 + 16}, paint) + } + for id := range int32(200) { + draw(id) + } + full := gc.bytes + gc.MaxBytes = full / 4 + draw(210) + if gc.bytes > gc.MaxBytes || gc.freeBytes > gc.MaxBytes/8 { + t.Fatalf("%d bytes cached (was %d), %d free, budget %d", gc.bytes, full, gc.freeBytes, gc.MaxBytes) + } + n := 0 + for i := range gc.ents { + n += gc.ents[i].size() + } + if n != gc.bytes { + t.Fatalf("entries take %d bytes, counted %d", n, gc.bytes) + } +} + +// zigzag returns a closed path of n non-horizontal edges. +func zigzag(n int) *Path { + var p Path + p.MoveTo(0, 0) + for i := 1; i < n; i++ { + p.LineTo(float32(i)*0.1, float32(i%2)*50) + } + p.Close() + return &p +} + +// A shape stops recording at its budget, before the canvas edge budget +// is consulted, and every draw reports the truncation. +func TestShapeRecordBudget(t *testing.T) { + p := zigzag(1000) + var s Shape + s.MaxBytes = 100 * shapeEdgeCost + s.SetFill(p, Identity, NonZero) + + c := NewCanvas(image.NewRGBA(image.Rect(0, 0, 128, 64))) + c.FillShape(&s, white) + if n := len(s.forms[0].edges); n != 100 { + t.Fatalf("%d edges recorded, budget 100", n) + } + if !errors.Is(c.Err(), ErrEdgeBudget) { + t.Fatalf("FillShape: err %v", c.Err()) + } + var u Union + u.Shape(&s) + c2 := NewCanvas(image.NewRGBA(image.Rect(0, 0, 128, 64))) + c2.FillUnion(&u, white) + if !errors.Is(c2.Err(), ErrEdgeBudget) { + t.Fatalf("FillUnion: err %v", c2.Err()) + } + + // Within its budget a shape draws as before. + s.MaxBytes = 0 + s.SetFill(p, Identity, NonZero) + c3 := NewCanvas(image.NewRGBA(image.Rect(0, 0, 128, 64))) + c3.FillShape(&s, white) + if c3.Err() != nil || len(s.forms[0].edges) != 1000 { + t.Fatalf("err %v, %d edges", c3.Err(), len(s.forms[0].edges)) + } +} + +// A stroke's strips and edges count against the budget of each form, and +// recording stops early. +func TestShapeStrokeBudget(t *testing.T) { + p := zigzag(2000) + st := &StrokeStyle{Width: 2, Join: MiterJoin} + var s Shape + s.MaxBytes = 50 * shapeStripCost + s.SetStroke(p, Identity, st) + for i, paint := range []*Paint{white, {Color: rgba(0, 0, 0, 128)}} { + c := NewCanvas(image.NewRGBA(image.Rect(0, 0, 256, 64))) + c.FillShape(&s, paint) + if !errors.Is(c.Err(), ErrEdgeBudget) { + t.Fatalf("draw %d: err %v", i, c.Err()) + } + } + for i := range s.forms { + f := &s.forms[i] + if !f.made.Load() { + continue + } + used := len(f.edges)*shapeEdgeCost + len(f.strips)*shapeStripCost + if used > s.MaxBytes || !f.overflow { + t.Fatalf("form %d: %d bytes recorded, overflow %v, budget %d", i, used, f.overflow, s.MaxBytes) + } + } +} + +// Canvases with different clips and edge budgets making a shared shape +// at once get the same form. +func TestShapeBudgetConcurrent(t *testing.T) { + p := zigzag(5000) + for range 20 { + var s Shape + s.MaxBytes = 300 * shapeEdgeCost + s.SetFill(p, Identity, NonZero) + var wg sync.WaitGroup + errs := make([]error, 8) + for w := range errs { + wg.Add(1) + go func() { + defer wg.Done() + c := NewCanvas(image.NewRGBA(image.Rect(0, 0, 512, 64))) + c.r.MaxEdges = 4 + w*1000 + c.ClipRect(Rect{X0: float64(w * 60), Y0: 0, X1: float64(w*60 + 8), Y1: 64}, Identity) + c.FillShape(&s, white) + errs[w] = c.Err() + }() + } + wg.Wait() + if n := len(s.forms[0].edges); n != 300 { + t.Fatalf("%d edges recorded, budget 300", n) + } + for w, err := range errs { + if !errors.Is(err, ErrEdgeBudget) { + t.Fatalf("canvas %d: err %v", w, err) + } + } + } +} diff --git a/glyphcache.go b/glyphcache.go index ecd7f6f..7128406 100644 --- a/glyphcache.go +++ b/glyphcache.go @@ -48,7 +48,11 @@ type glyphKey struct { // GlyphCache is a cache of glyph coverage masks. The zero value is ready // to use. type GlyphCache struct { - // MaxBytes bounds the masks kept; 0 means DefaultGlyphCacheBytes. + // MaxBytes bounds the masks kept, counted by their allocations plus + // 64 bytes an entry; 0 means DefaultGlyphCacheBytes. A mask larger + // than the budget is drawn but not kept. Evicted masks kept for reuse + // take at most an eighth more; the rasterizer's scratch, sized by the + // largest mask made, and the map are not counted. MaxBytes int masks map[glyphKey]int32 // index into ents @@ -140,7 +144,10 @@ func (gc *GlyphCache) FillGlyph(c *Canvas, font uint64, glyph int32, outline *Pa } mask = gc.rasterize(outline, mm, bb) gc.misses++ - gc.store(key, mask) + if !gc.store(key, mask) { + // Larger than the whole budget: drawn, not kept. + defer gc.recycle(mask) + } } if mask == nil { return @@ -339,26 +346,32 @@ type glyphEnt struct { used bool // drawn since the eviction scan last passed it } -// size returns the bytes an entry counts against MaxBytes. +// size returns the bytes an entry counts against MaxBytes: its mask's +// whole allocation, which may be a larger evicted mask reused. func (e *glyphEnt) size() int { n := 64 // key and map overhead if e.mask != nil { - n += len(e.mask.Pix) + n += cap(e.mask.Pix) } return n } -// store caches mask, evicting entries until it fits the budget. +// store caches mask, evicting entries until it fits the budget, and +// reports whether it did: an entry larger than the whole budget is not +// kept, and evicts nothing. // // A full cache evicts entries chosen at random, sparing those drawn since // they were last considered: frequent glyphs stay, and a set of glyphs // drawn over and over that is a little larger than the budget keeps most // of its masks, where emptying the cache, or evicting the least recently // used mask, would have every glyph rasterized anew on each pass. -func (gc *GlyphCache) store(key glyphKey, mask *image.Alpha) { +func (gc *GlyphCache) store(key glyphKey, mask *image.Alpha) bool { e := glyphEnt{key: key, mask: mask} n := e.size() limit := gc.budget() + if n > limit { + return false + } if gc.masks == nil { gc.masks = make(map[glyphKey]int32, 256) } @@ -368,6 +381,7 @@ func (gc *GlyphCache) store(key glyphKey, mask *image.Alpha) { gc.masks[key] = int32(len(gc.ents)) gc.ents = append(gc.ents, e) gc.bytes += n + return true } // evict removes one entry: the first not drawn since it was last diff --git a/perf3_bench_test.go b/perf3_bench_test.go index fbb43a1..647a8a4 100644 --- a/perf3_bench_test.go +++ b/perf3_bench_test.go @@ -435,7 +435,7 @@ func TestGlyphCacheEviction(t *testing.T) { id := int32(rng.IntN(40)) all.FillGlyph(cw, 1, id, &g, m, paint) small.FillGlyph(cg, 1, id, &g, m, paint) - if small.bytes > small.MaxBytes && len(small.ents) > 1 { + if small.bytes > small.MaxBytes { t.Fatalf("glyph %d: %d bytes cached, budget %d", k, small.bytes, small.MaxBytes) } n := 0 diff --git a/shape.go b/shape.go index 92c78fe..4c8cd12 100644 --- a/shape.go +++ b/shape.go @@ -6,6 +6,7 @@ import ( "slices" "sync" "sync/atomic" + "unsafe" ) // Shape is a path prepared for drawing under one transform: its device @@ -30,7 +31,21 @@ import ( // stroke's analytic rows (by a few levels of coverage) where the clip // cuts it. Drawn in bands, a shape stays within 3/255 of the shape drawn // whole; Stroke, per band, does not. +// +// The geometry a form records is bounded by MaxBytes, whatever the clip +// of the canvas that makes it: recording stops at the budget, before the +// records and their bins are allocated, and every FillShape and FillUnion +// drawing the truncated form reports ErrEdgeBudget. The bound belongs to +// the shape, so canvases with different clips and edge budgets draw the +// same truncated form. type Shape struct { + // MaxBytes bounds the memory of each form a shape makes: its edges, + // its strips and their row bins. A fill makes one form, a stroke up + // to two. Zero means DefaultShapeBytes. It takes effect at the next + // SetFill or SetStroke. + MaxBytes int + + limit int // MaxBytes, as of SetFill or SetStroke kind shapeKind rule FillRule bb Rect // device box of the path's points @@ -58,6 +73,19 @@ type Shape struct { forms [2]shapeForm } +// DefaultShapeBytes is the default per-form budget of a Shape: enough +// for DefaultMaxEdges edges. +const DefaultShapeBytes = DefaultMaxEdges * shapeEdgeCost + +// What a record counts against a Shape's budget: its own size and its +// share of the row bins (at most four ids, its two rows while binning, +// and a bin start). +const ( + shapeBinCost = 4*4 + 2*4 + 4 + shapeEdgeCost = int(unsafe.Sizeof(shapeEdge{})) + shapeBinCost + shapeStripCost = int(unsafe.Sizeof(shapeStrip{})) + shapeBinCost +) + type shapeKind uint8 const ( @@ -75,6 +103,7 @@ type shapeForm struct { strips []shapeStrip eb, sb rowBins truncated bool // the stroker exceeded its dash budget + overflow bool // the records exceeded the shape's budget } // shapeEdge is a line as passed to Rasterizer.AddLine, limited to rows @@ -95,7 +124,7 @@ type shapeStrip struct { func (f *shapeForm) reset() { f.edges, f.strips = f.edges[:0], f.strips[:0] - f.truncated = false + f.truncated, f.overflow = false, false f.made.Store(false) } @@ -208,13 +237,27 @@ type shapeRec struct { f *shapeForm lo, hi int32 neg bool + left int // bytes of the budget left + full bool // a record did not fit: recording has stopped } -func (r *shapeRec) start(f *shapeForm) { +func (r *shapeRec) start(f *shapeForm, budget int) { r.f, r.neg = f, false + r.left, r.full = budget, false r.unlimitRows() } +// take charges n bytes to the budget, reporting false, and stopping the +// recording, if they do not fit. +func (r *shapeRec) take(n int) bool { + if r.full || r.left < n { + r.full = true + return false + } + r.left -= n + return true +} + // rowOf clamps a finite device y to the rows a shape records. func rowOf(y float64) int32 { return int32(min(max(y, -1<<30), 1<<30)) } @@ -228,7 +271,7 @@ func (r *shapeRec) AddLine(x0, y0, x1, y1 float64) { } r0 := max(rowOf(math.Floor(min(y0, y1))), r.lo) r1 := min(rowOf(math.Ceil(max(y0, y1))), r.hi) - if r0 >= r1 { + if r0 >= r1 || !r.take(shapeEdgeCost) { return } r.f.edges = append(r.f.edges, shapeEdge{x0, y0, x1, y1, r0, r1, r.lo, r.hi}) @@ -239,7 +282,7 @@ func (r *shapeRec) middle(ax, ay, bx, by, dx, dy float64, y0, y1 int) { return } s := shapeStrip{y0: int32(y0), y1: int32(y1), neg: r.neg} - if s.rc.set(ax, ay, bx, by, dx, dy) { + if s.rc.set(ax, ay, bx, by, dx, dy) && r.take(shapeStripCost) { r.f.strips = append(r.f.strips, s) } } @@ -310,6 +353,9 @@ func (r *shapeRec) addPath(p *Path, m Matrix) { r.f.edges = r.f.edges[:0] return } + if r.full { + return + } pi += numPoints[v] } if open { @@ -383,6 +429,7 @@ func (s *Shape) SetFill(p *Path, m Matrix, rule FillRule) bool { return true // AddPath culls it } s.kind, s.rule, s.m = shapeFill, rule, m + s.setLimit() s.copyPath(p) s.bb = m.transformRect(p.Bounds()) if r, ok := p.asRect(); ok && m.axisAligned() { @@ -394,6 +441,13 @@ func (s *Shape) SetFill(p *Path, m Matrix, rule FillRule) bool { return true } +func (s *Shape) setLimit() { + s.limit = s.MaxBytes + if s.limit <= 0 { + s.limit = DefaultShapeBytes + } +} + // pixelRect returns r as integer pixels if its sides lie on pixel borders. func pixelRect(r Rect) (image.Rectangle, bool) { for _, v := range [4]float64{r.X0, r.Y0, r.X1, r.Y1} { @@ -441,6 +495,7 @@ func (s *Shape) SetStroke(p *Path, m Matrix, st *StrokeStyle) bool { return true // Stroke draws nothing } s.kind, s.m = shapeStroke, m + s.setLimit() s.copyPath(p) s.st = *st s.dash = append(s.dash[:0], st.Dash...) @@ -469,7 +524,8 @@ func (s *Shape) make(f *shapeForm, i int, c *Canvas) { return } rec := &c.rec - rec.start(f) + rec.start(f, s.limit) + c.s.halt = &rec.full switch { case s.kind == shapeFill: rec.addPath(&s.path, s.m) @@ -480,6 +536,8 @@ func (s *Shape) make(f *shapeForm, i int, c *Canvas) { c.s.stroke(rec, &s.path, s.m, &s.st, s.pr) f.truncated = c.s.Truncated() } + c.s.halt = nil + f.overflow = rec.full f.bin(&c.ids) rec.f = nil f.made.Store(true) @@ -512,7 +570,11 @@ func (c *Canvas) FillShape(s *Shape, paint *Paint) { return } if !c.r.culledBox(s.bb) { - c.addEdges(s.form(0, c), cs.bounds) + f := s.form(0, c) + c.addEdges(f, cs.bounds) + if f.overflow { + c.setErr(ErrEdgeBudget) + } } c.checkBudget() c.r.Rasterize(s.rule, c.chain(cs, c.paint(paint))) @@ -545,6 +607,9 @@ func (c *Canvas) FillShape(s *Shape, paint *Paint) { // strokeDone rasterizes the edges of a stroke drawn from form f. func (c *Canvas) strokeDone(f *shapeForm, b Blitter) { c.checkBudget() + if f.overflow { + c.setErr(ErrEdgeBudget) + } if f.truncated { c.setErr(ErrDashBudget) } diff --git a/stroke.go b/stroke.go index 67bdf7a..23e5bdc 100644 --- a/stroke.go +++ b/stroke.go @@ -89,6 +89,9 @@ type Stroker struct { truncated bool coverage float64 + // halt, when set, stops the stroke at the next segment once true: a + // sink that records under a budget sets it when full. + halt *bool sink LineSink dev bool // hairline: geometry in device space, one pixel wide @@ -263,7 +266,7 @@ func (s *Stroker) run(p *Path, m Matrix, st *StrokeStyle, pr strokePrep) { flush := func() { // A single point is a dot only as a closed subpath or when its // segments all led back to it; a lone MoveTo paints nothing. - if n := len(s.poly); n > 2 || (n == 2 && (closed || drawn)) { + if n := len(s.poly); (n > 2 || (n == 2 && (closed || drawn))) && (s.halt == nil || !*s.halt) { if dashed { s.dash(s.poly, closed) } else { @@ -282,7 +285,7 @@ func (s *Stroker) run(p *Path, m Matrix, st *StrokeStyle, pr strokePrep) { verbs := p.Verbs for vi := 0; vi < len(verbs); vi++ { v := verbs[vi] - if int(v) >= len(numPoints) || pi+numPoints[v] > len(pts) { + if int(v) >= len(numPoints) || pi+numPoints[v] > len(pts) || (s.halt != nil && *s.halt) { break } switch v { diff --git a/texture.go b/texture.go index 4d05fdc..4060ef5 100644 --- a/texture.go +++ b/texture.go @@ -1,6 +1,7 @@ package stilus import ( + "math" "math/bits" "sync" "sync/atomic" @@ -183,13 +184,40 @@ func (t *Texture) Levels() int { return min(bits.Len(uint(n)), maxMip) } -// MipBytes estimates the memory all levels of t can take. +// MipBytes returns the sample bytes all levels below the base can take +// once made: those of Level and, for a texture sampled as repeating, the +// levels of wrapLevel that are not those of Level. It leaves out the base +// and the transient scratch of downsampling. func (t *Texture) MipBytes() int { - px := t.base.W * t.base.H / 3 + w, h := t.base.W, t.base.H + px := 0 + for k := 1; k <= t.Levels(); k++ { + f := 1<>k, (h+f)>>k)) + if w&f != 0 || h&f != 0 { + px = satAdd(px, satMul(wrapSize(w, k), wrapSize(h, k))) + } + } if t.gray || t.alpha { return px } - return 4 * px + return satMul(4, px) +} + +// satAdd and satMul return a+b and a*b for non-negative a and b, or the +// largest int if that overflows. +func satAdd(a, b int) int { + if a > math.MaxInt-b { + return math.MaxInt + } + return a + b +} + +func satMul(a, b int) int { + if a != 0 && b > math.MaxInt/a { + return math.MaxInt + } + return a * b } // Level returns mip level k (0 is the base), making it if needed. diff --git a/union.go b/union.go index 4ad04b9..436268c 100644 --- a/union.go +++ b/union.go @@ -198,7 +198,7 @@ func (c *Canvas) FillUnion(u *Union, paint *Paint) { r := &c.r r.Reset() c.ustrip.r = r - sep, trunc := false, false + sep, trunc, over := false, false, false for i := range u.ops { op := &u.ops[i] if op.sep { @@ -225,7 +225,9 @@ func (c *Canvas) FillUnion(u *Union, paint *Paint) { if r.culledBox(s.bb) { continue } - c.addEdges(s.form(0, c), cs.bounds) + f := s.form(0, c) + c.addEdges(f, cs.bounds) + over = over || f.overflow case strokeMisses(s.bb, s.pad, cs.bounds): continue default: @@ -233,6 +235,7 @@ func (c *Canvas) FillUnion(u *Union, paint *Paint) { c.addEdges(f, cs.bounds) c.addAccStrips(f, cs.bounds) trunc = trunc || f.truncated + over = over || f.overflow } } if op.neg { @@ -240,6 +243,9 @@ func (c *Canvas) FillUnion(u *Union, paint *Paint) { } } c.checkBudget() + if over { + c.setErr(ErrEdgeBudget) + } if trunc { c.setErr(ErrDashBudget) }