diff --git a/bitplane_test.go b/bitplane_test.go new file mode 100644 index 0000000..32c7a6e --- /dev/null +++ b/bitplane_test.go @@ -0,0 +1,225 @@ +package stilus + +import ( + "image" + "math/rand/v2" + "slices" + "testing" +) + +// nearestRef is Sampler.Sample for nearest sampling as it was before the +// column and row caches: every pixel mapped through floats on its own. +func nearestRef(s *Sampler, y, x int, dst []uint32) { + p, m := s.p, &s.m + fy := float64(y) + 0.5 + u0 := m[2]*fy + m[4] + m[0]*0.5 + v0 := m[3]*fy + m[5] + m[1]*0.5 + for i := range dst { + fx := float64(x + i) + dst[i] = p.At(clampIndex(u0+m[0]*fx, p.W), clampIndex(v0+m[1]*fx, p.H)) + } +} + +func randomPlanes(rng *rand.Rand) []Plane { + pal := new(Palette) + for i := range pal { + pal[i] = pack(uint8(rng.IntN(256)), uint8(rng.IntN(256)), uint8(rng.IntN(256)), 255) + } + rgba := Plane{Kind: PlaneRGBA, W: 19, H: 11, Stride: 21, Pix32: make([]uint32, 21*11)} + for i := range rgba.Pix32 { + rgba.Pix32[i] = rng.Uint32() | 0xff<>3]>>(7-uint(k)&7)&1] + } + if !slices.Equal(got[:n], want[:n]) { + t.Fatalf("off %d n %d: %x, want %x", off, n, got[:n], want[:n]) + } + } + } +} + +// TestMagnifiedBitsDrawn draws magnified one-bit images, opaque and at +// constant alpha, through shapes with antialiased edges and a clip, and +// compares them with the same shader hidden behind an interface (no +// expanded rows read directly, no shading into the destination). +func TestMagnifiedBitsDrawn(t *testing.T) { + rng := rand.New(rand.NewPCG(38, 39)) + bits := randomPlanes(rng)[2] + tex := NewTexture(bits) + r := image.Rect(0, 0, 160, 96) + var ring Path + ring.Ellipse(80, 48, 70, 40) + ring.Ellipse(80, 48, 20, 12) + var shapes [2]Path + shapes[0].Rect(0, 0, 160, 96) + shapes[1].Rect(3.5, 2.25, 150.5, 80.6) + for _, m := range []Matrix{Scale(3.3, 8.3).Mul(Translate(1.5, 0.2)), Scale(-2.1, 2.1).Mul(Translate(-70, 3))} { + for _, alpha := range []uint8{255, 140} { + for si := range shapes { + for _, masked := range []bool{false, true} { + backdrop := randomRGBA(rng, r) + draw := func(direct bool) *image.RGBA { + dst := image.NewRGBA(r) + copy(dst.Pix, backdrop.Pix) + var is ImageShader + if !is.SetImage(tex, m, false, alpha) || !is.col.rows { + t.Fatalf("%v: not sampled through expanded rows", m) + } + var sh Shader = &is + if !direct { + sh = struct{ Shader }{sh} + } + c := NewCanvas(dst) + if masked { + c.ClipPath(&ring, Identity, EvenOdd) + } + c.Fill(&shapes[si], Identity, NonZero, &Paint{Shader: sh}) + return dst + } + if !slices.Equal(draw(true).Pix, draw(false).Pix) { + t.Fatalf("%v alpha %d shape %d mask %v: direct differs from shaded", m, alpha, si, masked) + } + } + } + } + } +} + +func TestMagnifiedBitsDoNotAllocate(t *testing.T) { + rng := rand.New(rand.NewPCG(40, 41)) + tex := NewTexture(randomPlanes(rng)[2]) + dst := image.NewRGBA(image.Rect(0, 0, 200, 100)) + c := NewCanvas(dst) + var p Path + p.Rect(0, 0, 200, 100) + var is ImageShader + paint := &Paint{Shader: &is} + fill := func() { + is.Reset() + is.SetImage(tex, Scale(4.4, 11.1), false, 255) + c.Fill(&p, Identity, NonZero, paint) + } + fill() + if a := testing.AllocsPerRun(10, fill); a != 0 { + t.Errorf("%v allocations per fill", a) + } +} + +// BenchmarkMagnifiedBits draws one-bit planes magnified as the scan pages +// of cera's corpus draw them. +func BenchmarkMagnifiedBits(b *testing.B) { + for _, bc := range []struct { + name string + w, h int + dw float64 + dh float64 + }{ + {"2.1x", 399, 400, 831, 833}, + {"8.3x", 81, 26, 675, 217}, + {"7.9x", 132, 14, 1042, 110}, + {"1x", 800, 600, 800, 600}, + } { + b.Run(bc.name, func(b *testing.B) { + rng := rand.New(rand.NewPCG(1, 2)) + pal := &Palette{pack(255, 255, 255, 255), pack(0, 0, 0, 255)} + stride := (bc.w + 7) / 8 + p := Plane{Kind: PlaneBits, W: bc.w, H: bc.h, Stride: stride, Pix8: make([]uint8, stride*bc.h), Pal: pal} + for i := range p.Pix8 { + p.Pix8[i] = uint8(rng.IntN(256)) + } + tex := NewTexture(p) + dst := image.NewRGBA(image.Rect(0, 0, int(bc.dw)+2, int(bc.dh)+2)) + c := NewCanvas(dst) + var path Path + path.Rect(0.5, 0.5, float32(bc.dw+0.5), float32(bc.dh+0.5)) + m := Scale(bc.dw/float64(bc.w), bc.dh/float64(bc.h)).Mul(Translate(0.5, 0.5)) + var is ImageShader + paint := &Paint{Shader: &is} + b.SetBytes(int64(4 * bc.dw * bc.dh)) + for b.Loop() { + is.Reset() + is.SetImage(tex, m, false, 255) + c.Fill(&path, Identity, NonZero, paint) + } + }) + } +} diff --git a/imageshader.go b/imageshader.go index f427978..6e1ddfe 100644 --- a/imageshader.go +++ b/imageshader.go @@ -7,7 +7,10 @@ import "math" // texture: so an image goes through the clip stack and gets antialiased // edges like any fill, and a band or tile samples only its own pixels. -// Sampler reads a texture at device pixels. +// Sampler reads a texture at device pixels. It caches what nearest +// sampling along the device axes shares between pixels (the texel column +// of every device column, the expanded rows of a magnified texture), so +// it is not safe for concurrent use; use one per worker. type Sampler struct { p *Plane // m maps device space to the pixel space of p (x right, y down, @@ -28,8 +31,31 @@ type Sampler struct { // opaque says that every pixel of p has alpha 255, and so every // sample. opaque bool + // cached says that p is sampled at the nearest pixel along the device + // axes with u independent of y (m[1] = m[2] = 0), so that the texel + // column of a device column is the same on every row and is read from + // nc; rows says that rows also repeat (|m[3]| < 1, magnified), so that + // the expanded rows are kept too. + cached, rows bool + nc nearestCache } +// nearestCache holds, for a nearest axis-aligned sampler, the texel +// columns of a range of device columns and the colours of one texel row +// at them. Both are computed by the same expressions as Sample's per-pixel +// path, so a pixel gets the same texel whether or not it is cached. +type nearestCache struct { + ok bool // cols is valid + u0 float64 // the u0 of Sample that cols was computed for + x0 int // the device column of cols[0] and row[0] + cols []int32 + j int // the texel row expanded into row, -1 none + row []uint32 +} + +// maxCacheCols bounds the device columns a nearestCache spans. +const maxCacheCols = 1 << 16 + // Setup chooses the mip level of t for drawing it with toDevice, which // maps its base pixel space to device space, and reports whether t can be // drawn so (toDevice is invertible). Magnified textures are sampled at @@ -79,9 +105,103 @@ func (s *Sampler) setup(t *Texture, toDevice Matrix, smooth, wrap bool) bool { } s.levels = s.p.Kind == PlaneIndex && t.alpha s.opaque = t.isOpaque() + s.cached = !wrap && !s.bilinear && inv[1] == 0 && inv[2] == 0 && + !(s.unit && s.p.Kind != PlaneIndex) && s.p.W <= math.MaxInt32 + s.rows = s.cached && math.Abs(inv[3]) < 1 + s.nc.ok, s.nc.j = false, -1 return true } +// origin returns the texture coordinates of the centre of pixel 0 of +// device row y. It is the one place they are computed, so that cached and +// sampled pixels round alike. +func (s *Sampler) origin(y int) (u0, v0 float64) { + m := &s.m + fy := float64(y) + 0.5 + return m[2]*fy + m[4] + m[0]*0.5, m[3]*fy + m[5] + m[1]*0.5 +} + +// nearestCol returns the texel column of device column x for a nearest +// sampler whose row starts at u0. +func (s *Sampler) nearestCol(u0 float64, x int) int { + return clampIndex(u0+s.m[0]*float64(x), s.p.W) +} + +// colsFor returns the texel columns of device columns [x, x+n) for a row +// starting at u0, from the cache, which it extends or rebuilds as needed; +// or false if they span too many columns to cache. +func (s *Sampler) colsFor(u0 float64, x, n int) ([]int32, bool) { + c := &s.nc + same := c.ok && u0 == c.u0 + if same && x >= c.x0 && x+n <= c.x0+len(c.cols) { + return c.cols[x-c.x0:][:n], true + } + if n > maxCacheCols { + return nil, false + } + lo, hi := x, x+n + if same && min(lo, c.x0) >= max(hi, c.x0+len(c.cols))-maxCacheCols { + // Spans of one row usually adjoin: keep what is cached. + lo, hi = min(lo, c.x0), max(hi, c.x0+len(c.cols)) + } + if cap(c.cols) < hi-lo { + c.cols = make([]int32, hi-lo, hi-lo+(hi-lo)/2) + } + c.cols = c.cols[:hi-lo] + for i := range c.cols { + c.cols[i] = int32(s.nearestCol(u0, lo+i)) + } + c.ok, c.u0, c.x0, c.j = true, u0, lo, -1 + return c.cols[x-lo:][:n], true +} + +// nearestRow returns the colours of pixels [x, x+n) of a device row that +// reads texel row j and starts at u0, from the row cache of a sampler +// with rows set; or false. +func (s *Sampler) nearestRow(j int, u0 float64, x, n int) ([]uint32, bool) { + if _, ok := s.colsFor(u0, x, n); !ok { + return nil, false + } + c := &s.nc + if c.j != j { + if cap(c.row) < len(c.cols) { + c.row = make([]uint32, len(c.cols), cap(c.cols)) + } + c.row = c.row[:len(c.cols)] + s.expand(c.row, j, c.cols) + c.j = j + } + return c.row[x-c.x0:][:n], true +} + +// expand sets dst[i] to texel (cols[i], j). +func (s *Sampler) expand(dst []uint32, j int, cols []int32) { + p := s.p + dst = dst[:len(cols)] + switch p.Kind { + case PlaneRGBA: + row := p.Pix32[j*p.Stride:][:p.W] + for i, k := range cols { + dst[i] = row[k] + } + case PlaneIndex: + row, pal := p.Pix8[j*p.Stride:][:p.W], p.Pal + for i, k := range cols { + dst[i] = pal[row[k]] + } + default: + row, pal := p.Pix8[j*p.Stride:][:(p.W+7)/8], p.Pal + for i, k := range cols { + dst[i] = pal[row[k>>3]>>(7-uint(k)&7)&1] + } + } +} + +// keep returns a zero sampler that holds the buffers of s. +func (s *Sampler) keep() Sampler { + return Sampler{nc: nearestCache{cols: s.nc.cols[:0], row: s.nc.row[:0], j: -1}} +} + // Release drops the sampler's reference to its texture. func (s *Sampler) Release() { s.p = nil } @@ -96,14 +216,25 @@ func (s *Sampler) Sample(y, x int, dst []uint32) { return } p, m := s.p, &s.m - fy := float64(y) + 0.5 - u0 := m[2]*fy + m[4] + m[0]*0.5 - v0 := m[3]*fy + m[5] + m[1]*0.5 + u0, v0 := s.origin(y) du, dv := m[0], m[1] if !s.bilinear { if dv == 0 { // Axis-aligned: the row is the same for the whole span. j := clampIndex(v0, p.H) + if s.cached { + // Columns found once for all rows, and magnified + // rows expanded once for all the device rows on them. + if s.rows { + if row, ok := s.nearestRow(j, u0, x, len(dst)); ok { + copy(dst, row) + return + } + } else if cols, ok := s.colsFor(u0, x, len(dst)); ok { + s.expand(dst, j, cols) + return + } + } switch p.Kind { case PlaneRGBA: row := p.Pix32[j*p.Stride:][:p.W] @@ -112,17 +243,21 @@ func (s *Sampler) Sample(y, x int, dst []uint32) { return } for i := range dst { - dst[i] = row[clampIndex(u0+du*float64(x+i), p.W)] + dst[i] = row[s.nearestCol(u0, x+i)] } case PlaneIndex: row, pal := p.Pix8[j*p.Stride:][:p.W], p.Pal for i := range dst { - dst[i] = pal[row[clampIndex(u0+du*float64(x+i), p.W)]] + dst[i] = pal[row[s.nearestCol(u0, x+i)]] } default: row, pal := p.Pix8[j*p.Stride:][:(p.W+7)/8], p.Pal + if s.unit { + sampleUnitBits(dst, row, p.W, pal, x+int(m[4])) + return + } for i := range dst { - k := clampIndex(u0+du*float64(x+i), p.W) + k := s.nearestCol(u0, x+i) dst[i] = pal[row[k>>3]>>(7-uint(k)&7)&1] } } @@ -130,7 +265,7 @@ func (s *Sampler) Sample(y, x int, dst []uint32) { } for i := range dst { fx := float64(x + i) - dst[i] = p.At(clampIndex(u0+du*fx, p.W), clampIndex(v0+dv*fx, p.H)) + dst[i] = p.At(s.nearestCol(u0, x+i), clampIndex(v0+dv*fx, p.H)) } return } @@ -223,6 +358,49 @@ func sampleUnit(dst, row []uint32, off int) { } } +// sampleUnitBits is sampleUnit for a one-bit row of w pixels in the +// colours pal[0] and pal[1]: dst[i] is the pixel off+i, clamped to the +// row. Whole bytes of one colour are filled without reading their bits. +func sampleUnitBits(dst []uint32, row []uint8, w int, pal *Palette, off int) { + bit := func(k int) uint32 { return pal[row[k>>3]>>(7-uint(k)&7)&1] } + i := 0 + for ; i < len(dst) && off+i < 0; i++ { + dst[i] = bit(0) + } + end := len(dst) + if off < w { + end = min(end, w-off) + } else { + end = i + } + for ; i < end && (off+i)&7 != 0; i++ { + dst[i] = bit(off + i) + } + c0, c1 := pal[0], pal[1] + for ; i+8 <= end; i += 8 { + d := dst[i : i+8 : i+8] + switch b := row[(off+i)>>3]; b { + case 0: + d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7] = c0, c0, c0, c0, c0, c0, c0, c0 + case 0xff: + d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7] = c1, c1, c1, c1, c1, c1, c1, c1 + default: + for k := range d { + d[k] = pal[b>>(7-uint(k))&1] + } + } + } + for ; i < end; i++ { + dst[i] = bit(off + i) + } + if i < len(dst) { + c := bit(w - 1) + for ; i < len(dst); i++ { + dst[i] = c + } + } +} + // clampIndex returns the pixel of [0, n) that coordinate u falls in, // clamped to the edges (NaN gives 0). func clampIndex(u float64, n int) int { @@ -279,7 +457,7 @@ type ImageShader struct { // Reset clears the shader, keeping its buffers, and drops its textures. func (s *ImageShader) Reset() { - *s = ImageShader{buf: s.buf, stencil: s.stencil} + *s = ImageShader{buf: s.buf, stencil: s.stencil, col: s.col.keep(), mask: s.mask.keep()} } // SetColor paints the premultiplied colour c (PackRGBA layout) instead of a @@ -323,10 +501,19 @@ func (s *ImageShader) SetMaskWrap(t *Texture, toDevice Matrix, smooth bool) bool // srcRow implements rowSource: an RGBA texture moved by whole pixels, // without a mask, is its own pixels times alpha where the span lies within // the texture, or within one period of a repeating one (elsewhere, Sample -// repeats the edge pixels or the period). +// repeats the edge pixels or the period). A magnified texture sampled at +// the nearest pixel along the device axes is its expanded row. func (s *ImageShader) srcRow(y, x, n int) ([]uint32, uint32, bool) { c := &s.col - if !s.hasCol || s.hasMask || !c.unit || c.p.Kind != PlaneRGBA { + if !s.hasCol || s.hasMask { + return nil, 0, false + } + if c.rows { + u0, v0 := c.origin(y) + row, ok := c.nearestRow(clampIndex(v0, c.p.H), u0, x, n) + return row, s.alpha, ok + } + if !c.unit || c.p.Kind != PlaneRGBA { return nil, 0, false } p, m := c.p, &c.m