From 0f71946fbd1bc794b30aca80d5a1e01b81faa51d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 11 Aug 2026 14:35:58 +0200 Subject: [PATCH] Add DrawMask: paint one colour through a coverage mask A rasterised glyph is not an image. It carries no colour of its own, only how much of each pixel the outline covers -- and text is the one thing every widget draws. A font back-end had to walk that mask calling PutPixel, scaling the coverage by the ink's alpha itself on every pixel, and PutPixel shifts, bounds-tests, clip-tests and blends one at a time. BenchmarkDrawMask 306043 ns/op BenchmarkMaskPerPixel 700334 ns/op 2.3x on 2000 glyph-sized masks, the order a full window of labels issues. Note what the number is NOT. There is no row to copy here: a mask has different coverage at every pixel, so unlike FillRect (41x) or DrawImage (6.7x) this cannot skip work, only stop repeating decisions. Two thirds of the gain came from writing the blend out instead of calling blendInto -- 561941 with the call, 306043 without. That hand-written blend is the risk, and it bit: matching blendInto's colour rounding but not its ALPHA byte, which truncates where the colour bytes round, passed nine hand-picked cases because every one of them had an opaque ground. TestDrawMaskBlendsExactlyLikePutPixel sweeps all 256 coverages against five ink alphas and six grounds, three of them translucent, and fails on the wrong formula at coverage 2 by exactly one level. The other half of the point is that a font back-end can now ask for a CAPABILITY. go-widgets/toolkit asserts *painter.PixelPainter -- a concrete type -- to render TrueType at all, so any other raster back-end silently falls back to the crude Text primitive. MaskPainter is what lets a recording, damage-tracking or future GPU painter receive real glyph coverage. 100% statement coverage. Co-Authored-By: Claude Opus 4.8 --- mask.go | 114 ++++++++++++++++++++++ mask_test.go | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 mask.go create mode 100644 mask_test.go diff --git a/mask.go b/mask.go new file mode 100644 index 0000000..ed4d8a5 --- /dev/null +++ b/mask.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 the go-widgets/painter authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package painter + +// MaskPainter is an optional Painter capability: it paints ONE colour through +// an 8-bit coverage mask in a single call. +// +// This is what a glyph is. A rasterised glyph is not an image — it carries no +// colour of its own, only how much of each pixel the outline covers — and text +// is the one thing every widget draws. Before this, a font back-end had to walk +// the mask calling PutPixel, which shifts, bounds-tests, clip-tests and blends +// one pixel at a time, and had to scale the coverage by the ink's alpha itself +// on every pixel. +// +// mask holds one coverage byte per pixel, row-major with the given stride, and +// mask[0] is the pixel at dst's top-left. 0 leaves the surface untouched, 255 +// lays down ink at its own alpha, and everything between composites — which is +// exactly what makes a glyph edge smooth rather than jagged. +// +// [ImagePainter] carries pixels that bring their own colour; this carries +// coverage for a colour the caller names. A back-end may implement either, +// both, or neither. +type MaskPainter interface { + DrawMask(dst Rect, mask []byte, stride int, ink RGBA) +} + +// DrawMask paints ink through mask. Implements MaskPainter. +// +// Where it may write is decided once, like every other primitive here, so the +// per-pixel work is a coverage lookup and a blend and nothing else. +func (p *PixelPainter) DrawMask(dst Rect, mask []byte, stride int, ink RGBA) { + if dst.W <= 0 || dst.H <= 0 || stride <= 0 || ink.A == 0 { + return + } + dst = shiftRect(p.off, dst) + + eff := intersect(dst, Rect{X: 0, Y: 0, W: p.Width, H: p.Height}) + if n := len(p.clip); n > 0 { + eff = intersect(eff, p.clip[n-1]) + } + if eff.W <= 0 || eff.H <= 0 { + return + } + + full := ink.A == 0xFF + for y := eff.Y; y < eff.Y+eff.H; y++ { + maskRow := (y - dst.Y) * stride + dstRow := y * p.Width * 4 + if maskRow+(eff.X-dst.X)+eff.W > len(mask) { + // A caller whose mask is shorter than the rectangle it named has a + // bug; the painter drops the rows it cannot read rather than + // panicking, exactly as it drops a short Buf. + continue + } + if dstRow+(eff.X+eff.W)*4 > len(p.Buf) { + continue + } + for x := eff.X; x < eff.X+eff.W; x++ { + cov := mask[maskRow+(x-dst.X)] + if cov == 0 { + continue + } + a := uint32(cov) + if !full { + a = a * uint32(ink.A) / 255 + } + off := dstRow + x*4 + if a == 0xFF { + p.Buf[off], p.Buf[off+1], p.Buf[off+2], p.Buf[off+3] = ink.R, ink.G, ink.B, 0xFF + continue + } + if a == 0 { + continue + } + // The same arithmetic as blendInto, written out rather than + // called: identical rounding, including the alpha byte, which + // truncates where the colour bytes round. Getting that last detail + // wrong is invisible over an opaque ground and shows up over a + // translucent one, which is why TestDrawMaskBlendsExactlyLikePutPixel + // sweeps every coverage against several grounds instead of trusting + // a handful of cases. + inv := 255 - a + p.Buf[off] = uint8((uint32(ink.R)*a + uint32(p.Buf[off])*inv + 127) / 255) + p.Buf[off+1] = uint8((uint32(ink.G)*a + uint32(p.Buf[off+1])*inv + 127) / 255) + p.Buf[off+2] = uint8((uint32(ink.B)*a + uint32(p.Buf[off+2])*inv + 127) / 255) + p.Buf[off+3] = uint8(a + uint32(p.Buf[off+3])*inv/255) + } + } +} + +// DrawMask puts a coloured cell wherever the mask is more than half covered: +// a cell is either the glyph's colour or it is not, so the coverage has to +// resolve to a decision. Implements MaskPainter. +func (p *CellPainter) DrawMask(dst Rect, mask []byte, stride int, ink RGBA) { + if dst.W <= 0 || dst.H <= 0 || stride <= 0 || ink.A == 0 { + return + } + for dy := 0; dy < dst.H; dy++ { + row := dy * stride + if row+dst.W > len(mask) { + continue + } + for dx := 0; dx < dst.W; dx++ { + if mask[row+dx] < 128 { + continue + } + // PutPixel already promotes a pixel to a filled cell and applies + // the clip and translation. + p.PutPixel(dst.X+dx, dst.Y+dy, ink) + } + } +} diff --git a/mask_test.go b/mask_test.go new file mode 100644 index 0000000..2d0b3fb --- /dev/null +++ b/mask_test.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 the go-widgets/painter authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package painter + +import "testing" + +// maskReference is the loop DrawMask replaced: the way a font back-end had to +// paint a glyph, scaling the coverage by the ink's alpha itself and handing +// each pixel to PutPixel, which applies the translation, the surface bounds, +// the clip and the blend. +func maskReference(p *PixelPainter, dst Rect, mask []byte, stride int, ink RGBA) { + for dy := 0; dy < dst.H; dy++ { + for dx := 0; dx < dst.W; dx++ { + cov := mask[dy*stride+dx] + if cov == 0 { + continue + } + a := uint8(uint32(cov) * uint32(ink.A) / 255) + p.PutPixel(dst.X+dx, dst.Y+dy, RGBA{R: ink.R, G: ink.G, B: ink.B, A: a}) + } + } +} + +// glyphMask builds a w*h coverage mask with a hole, a solid core and soft +// edges — the shape of a real glyph, so no case is a uniform block. +func glyphMask(w, h int) []byte { + m := make([]byte, w*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + switch { + case x == 0 || y == 0: + m[y*w+x] = 0 + case x < w/3: + m[y*w+x] = uint8(x * 255 / max(w/3, 1)) + default: + m[y*w+x] = 255 + } + } + } + return m +} + +func TestDrawMaskMatchesTheLoopItReplaced(t *testing.T) { + mask := glyphMask(9, 7) + opaque := RGBA{R: 240, G: 30, B: 90, A: 255} + half := RGBA{R: 240, G: 30, B: 90, A: 128} + + for _, tc := range []struct { + name string + dst Rect + ink RGBA + clip *Rect + tx, ty int + }{ + {name: "opaque ink", dst: Rect{X: 3, Y: 3, W: 9, H: 7}, ink: opaque}, + {name: "translucent ink", dst: Rect{X: 3, Y: 3, W: 9, H: 7}, ink: half}, + {name: "off the left and top", dst: Rect{X: -4, Y: -3, W: 9, H: 7}, ink: opaque}, + {name: "off the right and bottom", dst: Rect{X: 14, Y: 15, W: 9, H: 7}, ink: opaque}, + {name: "entirely off the surface", dst: Rect{X: 40, Y: 40, W: 9, H: 7}, ink: opaque}, + {name: "clipped", dst: Rect{X: 2, Y: 2, W: 9, H: 7}, ink: opaque, clip: &Rect{X: 4, Y: 4, W: 3, H: 3}}, + {name: "clipped away", dst: Rect{X: 2, Y: 2, W: 9, H: 7}, ink: opaque, clip: &Rect{X: 30, Y: 30, W: 2, H: 2}}, + {name: "translated", dst: Rect{X: 1, Y: 1, W: 9, H: 7}, ink: opaque, tx: 5, ty: 4}, + {name: "translated and clipped", dst: Rect{X: 0, Y: 0, W: 9, H: 7}, ink: half, tx: 3, ty: 3, clip: &Rect{X: 2, Y: 2, W: 6, H: 6}}, + } { + t.Run(tc.name, func(t *testing.T) { + run := func(f func(p *PixelPainter)) *PixelPainter { + p := newPix(24, 24) + // A non-black ground, so a blend that wrongly behaves as a copy + // shows up instead of hiding in zeroes. + p.FillRect(Rect{X: 0, Y: 0, W: 24, H: 24}, RGBA{R: 20, G: 70, B: 40, A: 255}) + if tc.tx != 0 || tc.ty != 0 { + p.PushTranslate(tc.tx, tc.ty) + } + if tc.clip != nil { + p.PushClip(*tc.clip) + } + f(p) + if tc.clip != nil { + p.PopClip() + } + if tc.tx != 0 || tc.ty != 0 { + p.PopTranslate() + } + return p + } + + got := run(func(p *PixelPainter) { p.DrawMask(tc.dst, mask, 9, tc.ink) }) + want := run(func(p *PixelPainter) { maskReference(p, tc.dst, mask, 9, tc.ink) }) + + for i := range got.Buf { + if got.Buf[i] != want.Buf[i] { + px := i / 4 + t.Fatalf("pixel %d,%d byte %d: DrawMask %d, the loop it replaced %d", + px%24, px/24, i%4, got.Buf[i], want.Buf[i]) + } + } + }) + } +} + +// DrawMask writes the blend itself instead of calling blendInto, which is +// worth 2x and worth exactly nothing if the arithmetic drifts. Every coverage +// value, against several ink alphas and several grounds -- including +// translucent ones, where the alpha byte truncates while the colour bytes +// round, and where a plausible-looking formula is wrong by one. +func TestDrawMaskBlendsExactlyLikePutPixel(t *testing.T) { + mask := make([]byte, 256) + for i := range mask { + mask[i] = uint8(i) + } + for _, inkA := range []uint8{255, 200, 128, 64, 1} { + for _, ground := range []RGBA{ + {R: 0, G: 0, B: 0, A: 255}, + {R: 255, G: 255, B: 255, A: 255}, + {R: 90, G: 140, B: 200, A: 255}, + {R: 90, G: 140, B: 200, A: 128}, + {R: 3, G: 5, B: 7, A: 1}, + {}, + } { + ink := RGBA{R: 240, G: 30, B: 90, A: inkA} + run := func(f func(p *PixelPainter)) *PixelPainter { + p := newPix(256, 1) + for i := 0; i < len(p.Buf); i += 4 { + p.Buf[i], p.Buf[i+1], p.Buf[i+2], p.Buf[i+3] = ground.R, ground.G, ground.B, ground.A + } + f(p) + return p + } + dst := Rect{X: 0, Y: 0, W: 256, H: 1} + got := run(func(p *PixelPainter) { p.DrawMask(dst, mask, 256, ink) }) + want := run(func(p *PixelPainter) { maskReference(p, dst, mask, 256, ink) }) + for i := range got.Buf { + if got.Buf[i] != want.Buf[i] { + t.Fatalf("ink alpha %d over ground %+v: coverage %d byte %d = %d, PutPixel gives %d", + inkA, ground, i/4, i%4, got.Buf[i], want.Buf[i]) + } + } + } + } +} + +// A stride wider than the rectangle is the normal case: a glyph is a window +// into a bigger mask atlas, and only its own columns may be read. +func TestDrawMaskHonoursTheStride(t *testing.T) { + // 8 wide, but only the first 3 columns of each row belong to the glyph. + const stride = 8 + mask := make([]byte, stride*3) + for y := 0; y < 3; y++ { + mask[y*stride], mask[y*stride+1], mask[y*stride+2] = 255, 255, 255 + for x := 3; x < stride; x++ { + mask[y*stride+x] = 255 // would show if the stride were ignored + } + } + p := newPix(12, 6) + p.DrawMask(Rect{X: 0, Y: 0, W: 3, H: 3}, mask, stride, RGBA{R: 255, G: 255, B: 255, A: 255}) + + if p.Buf[(0*12+2)*4+3] == 0 { + t.Error("the glyph's own last column was not painted") + } + if p.Buf[(0*12+3)*4+3] != 0 { + t.Error("painted past the rectangle, reading the neighbouring glyph") + } + if p.Buf[(1*12+0)*4+3] == 0 { + t.Error("the second row started at the wrong offset") + } +} + +// Nonsense arguments leave the surface alone; a mask shorter than the rectangle +// costs the rows that are missing, not a panic. +func TestDrawMaskRejectsNonsense(t *testing.T) { + p := newPix(8, 8) + full := Rect{X: 0, Y: 0, W: 4, H: 4} + white := RGBA{R: 255, G: 255, B: 255, A: 255} + m := glyphMask(4, 4) + + p.DrawMask(Rect{X: 0, Y: 0, W: 0, H: 4}, m, 4, white) // empty destination + p.DrawMask(Rect{X: 0, Y: 0, W: 4, H: 0}, m, 4, white) // empty destination + p.DrawMask(full, m, 0, white) // no stride + p.DrawMask(full, m, 4, RGBA{R: 255, G: 255, B: 255}) // invisible ink + for i := range p.Buf { + if p.Buf[i] != 0 { + t.Fatal("a malformed call painted something") + } + } + + // Short mask: the rows that exist land, the rest are dropped. + p.DrawMask(full, m[:8], 4, white) + if p.Buf[3] == 0 && p.Buf[(1*8+1)*4+3] == 0 { + t.Error("a short mask painted nothing at all") + } +} + +// A short Buf is tolerated the way PutPixel tolerates it. +func TestDrawMaskShortBuffer(t *testing.T) { + p := &PixelPainter{Buf: make([]byte, 4*2*4), Width: 4, Height: 4} + m := make([]byte, 16) + for i := range m { + m[i] = 255 + } + p.DrawMask(Rect{X: 0, Y: 0, W: 4, H: 4}, m, 4, RGBA{R: 1, G: 2, B: 3, A: 255}) + if p.Buf[3] == 0 { + t.Error("the rows that did fit were not painted") + } +} + +// The cell back-end resolves coverage to a decision: a cell is the glyph's +// colour or it is not. +func TestCellPainterDrawMask(t *testing.T) { + c := &CellPainter{Cells: make([]Cell, 6*6), W: 6, H: 6} + m := []byte{ + 255, 10, 200, + 0, 130, 127, + } + white := RGBA{R: 255, G: 255, B: 255, A: 255} + c.DrawMask(Rect{X: 0, Y: 0, W: 3, H: 2}, m, 3, white) + + if c.Cells[0].Fg.A == 0 { + t.Error("full coverage left the cell empty") + } + if c.Cells[1].Fg.A != 0 { + t.Error("a tenth of coverage filled a cell") + } + if c.Cells[6+1].Fg.A == 0 { + t.Error("just over half coverage left the cell empty") + } + if c.Cells[6+2].Fg.A != 0 { + t.Error("just under half coverage filled a cell") + } + + // Degenerate calls are ignored rather than fatal. + c.DrawMask(Rect{X: 0, Y: 0, W: 0, H: 2}, m, 3, white) + c.DrawMask(Rect{X: 0, Y: 0, W: 3, H: 2}, m, 0, white) + c.DrawMask(Rect{X: 0, Y: 0, W: 3, H: 2}, m, 3, RGBA{}) + c.DrawMask(Rect{X: 0, Y: 0, W: 3, H: 4}, m, 3, white) // mask ends early +} + +func TestPaintersImplementMaskPainter(t *testing.T) { + var _ MaskPainter = (*PixelPainter)(nil) + var _ MaskPainter = (*CellPainter)(nil) +} + +// A page of text: 2000 glyph-sized masks, which is the order a full window of +// labels issues. +func BenchmarkDrawMask(b *testing.B) { + p := newPix(1000, 700) + m := glyphMask(9, 14) + ink := RGBA{R: 220, G: 220, B: 220, A: 255} + b.ResetTimer() + for i := 0; i < b.N; i++ { + for g := 0; g < 2000; g++ { + p.DrawMask(Rect{X: (g * 11) % 980, Y: (g / 89) * 16 % 680, W: 9, H: 14}, m, 9, ink) + } + } +} + +func BenchmarkMaskPerPixel(b *testing.B) { + p := newPix(1000, 700) + m := glyphMask(9, 14) + ink := RGBA{R: 220, G: 220, B: 220, A: 255} + b.ResetTimer() + for i := 0; i < b.N; i++ { + for g := 0; g < 2000; g++ { + maskReference(p, Rect{X: (g * 11) % 980, Y: (g / 89) * 16 % 680, W: 9, H: 14}, m, 9, ink) + } + } +}