Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ out := canvas.MustRender(doc)
png.Encode(w, out.ToImage(8))
```

A layer's content can be smaller than the document. Set `Layer.Origin` to where its top-left sits. A layer can also build its content when it is composited by setting `Layer.Load`, so a document of many large layers does not hold them all at once.
A layer's content can be smaller than the document. Set `Layer.Origin` to where its top-left sits. A layer can also build its content when it is composited by setting `Layer.Load`, so a document of many large layers does not hold them all at once. A layer can take an 8-bit `image.Image` through `Layer.Image` or `Layer.LoadImage` instead, which is blended straight into the document without converting it to a float buffer first.

## Documentation

Expand Down
51 changes: 51 additions & 0 deletions blend/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package blend_test

import (
"fmt"
"image"
"image/color"

"github.com/odevine/impasto/blend"
"github.com/odevine/impasto/raster"
Expand Down Expand Up @@ -60,3 +62,52 @@ func ExampleMode() {
// Normal SoftLight Luminosity
// false Mode(invalid)
}

// CompositeNRGBA blends a decoded 8-bit image straight into a buffer, with no
// float copy of the image in between. A transparent pixel leaves the backdrop
// alone, and an opaque one covers it
func ExampleCompositeNRGBA() {
dst := raster.MustNewBuffer(2, 1)
dst.Set(0, 0, 0.5, 0.5, 0.5, 1)
dst.Set(1, 0, 0.5, 0.5, 0.5, 1)

src := image.NewNRGBA(image.Rect(0, 0, 2, 1))
src.SetNRGBA(1, 0, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) // pixel 0 stays transparent

blend.CompositeNRGBA(dst, src, image.Point{}, blend.Normal, 1)
for x := 0; x < 2; x++ {
r, _, _, _ := dst.At(x, 0)
fmt.Printf("pixel %d: %.2f\n", x, r)
}
// Output:
// pixel 0: 0.50
// pixel 1: 1.00
}

// Index records where an image's visible pixels are, so a frame that is mostly
// transparent is blended in proportion to what it draws. Build the index once
// where the decoded image is kept and reuse it. The result is identical to
// CompositeNRGBA
func ExampleIndex() {
frame := image.NewNRGBA(image.Rect(0, 0, 200, 100))
for y := 0; y < 100; y++ {
for x := 0; x < 200; x++ {
if x < 10 || x >= 190 || y < 10 || y >= 90 {
frame.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255})
}
}
}
indexed := blend.Index(frame)

plain := raster.MustNewBuffer(200, 100)
fast := raster.MustNewBuffer(200, 100)
blend.CompositeNRGBA(plain, frame, image.Point{}, blend.Normal, 1)
blend.CompositeIndexed(fast, indexed, image.Point{}, blend.Normal, 1)

same := true
for i := range plain.Pix {
same = same && plain.Pix[i] == fast.Pix[i]
}
fmt.Println("identical:", same)
// Output: identical: true
}
104 changes: 104 additions & 0 deletions blend/image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package blend

import (
"image"

"github.com/odevine/impasto/internal/parallel"
"github.com/odevine/impasto/raster"
)

// srgb8ToLinear maps an 8-bit sRGB code to linear light. It holds the values
// raster's own ingest table holds, since both come from raster.SRGBToLinear
var srgb8ToLinear [256]float32

func init() {
for i := range srgb8ToLinear {
srgb8ToLinear[i] = raster.SRGBToLinear(float32(i) / 255.0)
}
}

// CompositeNRGBA blends an 8-bit straight-alpha sRGB image over the part of dst
// it covers when its top-left sits at origin, clipped to dst, with mode m at
// the given opacity in [0,1]. It touches nothing outside that rectangle.
//
// The result is exactly what converting src with raster.FromImage and passing
// the buffer to CompositeRect gives, without building that buffer. The image is
// never written to, so one decoded image can serve any number of concurrent
// calls. A pixel with no alpha leaves dst alone, and in Normal mode a fully
// opaque pixel at full opacity replaces it, so neither costs any arithmetic.
// Work is split into fixed row bands, as in Composite
func CompositeNRGBA(dst *raster.Buffer, src *image.NRGBA, origin image.Point, m Mode, opacity float32) {
if opacity <= 0 {
return
}
if opacity > 1 {
opacity = 1
}
sb := src.Bounds()
r := image.Rect(origin.X, origin.Y, origin.X+sb.Dx(), origin.Y+sb.Dy()).
Intersect(image.Rect(0, 0, dst.Width, dst.Height))
if r.Empty() {
return
}
parallel.Rows(r.Dy(), func(lo, hi int) {
for y := r.Min.Y + lo; y < r.Min.Y+hi; y++ {
di := (y*dst.Width + r.Min.X) * 4
si := src.PixOffset(sb.Min.X+r.Min.X-origin.X, sb.Min.Y+y-origin.Y)
if m == Normal {
normalRow(dst.Pix[di:di+r.Dx()*4], src.Pix[si:si+r.Dx()*4], opacity)
} else {
modeRow(dst.Pix[di:di+r.Dx()*4], src.Pix[si:si+r.Dx()*4], m, opacity)
}
}
})
}

// normalRow composites one row of straight-alpha 8-bit pixels over dst with
// source-over. The arithmetic is compositeNormal's, applied to the premultiplied
// linear value ingest would have produced
func normalRow(dst []float32, src []uint8, opacity float32) {
for i := 0; i+3 < len(src); i += 4 {
a8 := src[i+3]
if a8 == 0 {
continue
}
if a8 == 255 && opacity == 1 {
dst[i] = srgb8ToLinear[src[i]]
dst[i+1] = srgb8ToLinear[src[i+1]]
dst[i+2] = srgb8ToLinear[src[i+2]]
dst[i+3] = 1
continue
}
a := float32(a8) / 255.0
sr := srgb8ToLinear[src[i]] * a * opacity
sg := srgb8ToLinear[src[i+1]] * a * opacity
sb := srgb8ToLinear[src[i+2]] * a * opacity
sa := a * opacity
inv := 1 - sa
dst[i] = sr + dst[i]*inv
dst[i+1] = sg + dst[i+1]*inv
dst[i+2] = sb + dst[i+2]*inv
dst[i+3] = sa + dst[i+3]*inv
}
}

// modeRow composites one row of straight-alpha 8-bit pixels over dst with any
// mode, through the same BlendPixel the buffer path uses. A pixel with no alpha
// returns the backdrop unchanged, so it is skipped
func modeRow(dst []float32, src []uint8, m Mode, opacity float32) {
for i := 0; i+3 < len(src); i += 4 {
a8 := src[i+3]
if a8 == 0 {
continue
}
a := float32(a8) / 255.0
cs := [4]float32{
srgb8ToLinear[src[i]] * a * opacity,
srgb8ToLinear[src[i+1]] * a * opacity,
srgb8ToLinear[src[i+2]] * a * opacity,
a * opacity,
}
out := BlendPixel([4]float32{dst[i], dst[i+1], dst[i+2], dst[i+3]}, cs, m)
dst[i], dst[i+1], dst[i+2], dst[i+3] = out[0], out[1], out[2], out[3]
}
}
124 changes: 124 additions & 0 deletions blend/image_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package blend

import (
"image"
"testing"

"github.com/odevine/impasto/internal/testimg"
"github.com/odevine/impasto/raster"
)

// backdrop is a buffer with a color and alpha that vary, so a blend has
// something to differ from
func backdrop(w, h int) *raster.Buffer {
buf := raster.MustNewBuffer(w, h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
a := 0.25 + 0.75*float32((x+y)%5)/4
buf.Set(x, y, a*float32(x%7)/6, a*float32(y%5)/4, a*float32((x+y)%3)/2, a)
}
}
return buf
}

func sameBits(t *testing.T, got, want *raster.Buffer, what string) {
t.Helper()
testimg.EqualBits(t, got.Pix, want.Pix, what)
}

func TestCompositeNRGBAMatchesBuffer(t *testing.T) {
const w, h = 48, 36
src := testimg.Patterned(31, 23)
buf, err := raster.FromImage(src)
if err != nil {
t.Fatal(err)
}
origins := []image.Point{{0, 0}, {10, 7}, {-5, -3}, {30, 20}, {-40, 0}, {100, 100}}
for m := Normal; m < numModes; m++ {
for _, opacity := range []float32{1, 0.6, 0.01} {
for _, at := range origins {
want, got := backdrop(w, h), backdrop(w, h)
CompositeRect(want, buf, at, m, opacity)
CompositeNRGBA(got, src, at, m, opacity)
sameBits(t, got, want, m.String())
}
}
}
}

func TestCompositeNRGBAReadsASubImageAtItsOwnBounds(t *testing.T) {
full := testimg.Patterned(40, 30)
sub := full.SubImage(image.Rect(7, 5, 29, 24)).(*image.NRGBA)
buf, err := raster.FromImage(sub)
if err != nil {
t.Fatal(err)
}
for _, m := range []Mode{Normal, Multiply, Hue} {
want, got := backdrop(48, 36), backdrop(48, 36)
CompositeRect(want, buf, image.Pt(6, 4), m, 0.8)
CompositeNRGBA(got, sub, image.Pt(6, 4), m, 0.8)
sameBits(t, got, want, m.String())
}
}

func TestCompositeNRGBATouchesOnlyItsRect(t *testing.T) {
src := testimg.Patterned(10, 8)
want, got := backdrop(30, 30), backdrop(30, 30)
CompositeNRGBA(got, src, image.Pt(12, 9), Normal, 1)
for y := 0; y < 30; y++ {
for x := 0; x < 30; x++ {
if image.Pt(x, y).In(image.Rect(12, 9, 22, 17)) {
continue
}
r1, g1, b1, a1 := got.At(x, y)
r2, g2, b2, a2 := want.At(x, y)
if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 {
t.Fatalf("pixel (%d,%d) outside the image changed", x, y)
}
}
}
}

func TestCompositeNRGBAIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) {
src := testimg.Patterned(10, 8)
before := backdrop(20, 20)
for _, o := range []float32{0, -1} {
got := backdrop(20, 20)
CompositeNRGBA(got, src, image.Point{}, Normal, o)
sameBits(t, got, before, "opacity")
}
want, got := backdrop(20, 20), backdrop(20, 20)
CompositeNRGBA(want, src, image.Point{}, Normal, 1)
CompositeNRGBA(got, src, image.Point{}, Normal, 3)
sameBits(t, got, want, "opacity above one")
}

func TestCompositeNRGBANeverWritesToItsSource(t *testing.T) {
src := testimg.Patterned(16, 16)
pix := append([]uint8(nil), src.Pix...)
CompositeNRGBA(backdrop(20, 20), src, image.Point{}, Overlay, 0.5)
for i := range pix {
if src.Pix[i] != pix[i] {
t.Fatal("the source image was written to")
}
}
}

func BenchmarkCompositeNRGBA(b *testing.B) {
src := testimg.Patterned(2048, 2048)
dst := raster.MustNewBuffer(2048, 2048)
b.SetBytes(2048 * 2048 * 4)
for i := 0; i < b.N; i++ {
CompositeNRGBA(dst, src, image.Point{}, Normal, 1)
}
}

func BenchmarkCompositeRectFromImage(b *testing.B) {
src := testimg.Patterned(2048, 2048)
dst := raster.MustNewBuffer(2048, 2048)
b.SetBytes(2048 * 2048 * 4)
for i := 0; i < b.N; i++ {
buf, _ := raster.FromImage(src)
CompositeRect(dst, buf, image.Point{}, Normal, 1)
}
}
Loading
Loading