From 5fa94ff67b851fe4dd72332fffe6a23ce4a9beda Mon Sep 17 00:00:00 2001 From: Egon Elbre Date: Wed, 15 Nov 2023 12:02:08 +0200 Subject: [PATCH] op/paint: add nearest neighbor scaling This adds support for nearest neighbor filtering, which can be useful in quite a few scenarios. img := paint.NewImageOp(m) img.Filter = paint.FilterNearest img.Add(gtx.Ops) Fixes: https://todo.sr.ht/~eliasnaur/gio/414 Signed-off-by: Egon Elbre --- gpu/gpu.go | 35 ++++++++- .../refs/TestImageRGBA_ScaleLinear.png | Bin 0 -> 2986 bytes .../refs/TestImageRGBA_ScaleNearest.png | Bin 0 -> 379 bytes gpu/internal/rendertest/render_test.go | 67 ++++++++++++++++++ internal/ops/ops.go | 2 +- op/paint/paint.go | 13 ++++ 6 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 gpu/internal/rendertest/refs/TestImageRGBA_ScaleLinear.png create mode 100644 gpu/internal/rendertest/refs/TestImageRGBA_ScaleNearest.png diff --git a/gpu/gpu.go b/gpu/gpu.go index 5cd17f78..fc6511cb 100644 --- a/gpu/gpu.go +++ b/gpu/gpu.go @@ -186,10 +186,16 @@ type material struct { uvTrans f32.Affine2D } +const ( + filterLinear = 0 + filterNearest = 1 +) + // imageOpData is the shadow of paint.ImageOp. type imageOpData struct { src *image.RGBA handle interface{} + filter byte } type linearGradientOpData struct { @@ -207,6 +213,7 @@ func decodeImageOp(data []byte, refs []interface{}) imageOpData { return imageOpData{ src: refs[0].(*image.RGBA), handle: handle, + filter: data[1], } } @@ -454,19 +461,41 @@ func (g *gpu) Profile() string { } func (r *renderer) texHandle(cache *resourceCache, data imageOpData) driver.Texture { + type cachekey struct { + filter byte + handle any + } + key := cachekey{ + filter: data.filter, + handle: data.handle, + } + var tex *texture - t, exists := cache.get(data.handle) + t, exists := cache.get(key) if !exists { t = &texture{ src: data.src, } - cache.put(data.handle, t) + cache.put(key, t) } tex = t.(*texture) if tex.tex != nil { return tex.tex } - handle, err := r.ctx.NewTexture(driver.TextureFormatSRGBA, data.src.Bounds().Dx(), data.src.Bounds().Dy(), driver.FilterLinearMipmapLinear, driver.FilterLinear, driver.BufferBindingTexture) + + var minFilter, magFilter driver.TextureFilter + switch data.filter { + case filterLinear: + minFilter, magFilter = driver.FilterLinearMipmapLinear, driver.FilterLinear + case filterNearest: + minFilter, magFilter = driver.FilterNearest, driver.FilterNearest + } + + handle, err := r.ctx.NewTexture(driver.TextureFormatSRGBA, + data.src.Bounds().Dx(), data.src.Bounds().Dy(), + minFilter, magFilter, + driver.BufferBindingTexture, + ) if err != nil { panic(err) } diff --git a/gpu/internal/rendertest/refs/TestImageRGBA_ScaleLinear.png b/gpu/internal/rendertest/refs/TestImageRGBA_ScaleLinear.png new file mode 100644 index 0000000000000000000000000000000000000000..bc0cba29a8097b5d1029dafd6727101cb1029c69 GIT binary patch literal 2986 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 literal 0 HcmV?d00001 diff --git a/gpu/internal/rendertest/refs/TestImageRGBA_ScaleNearest.png b/gpu/internal/rendertest/refs/TestImageRGBA_ScaleNearest.png new file mode 100644 index 0000000000000000000000000000000000000000..ae3ce430313fcd2f2e2938206f4717076ff9c55e GIT binary patch literal 379 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^V07|yaSW-L^XBeB&I1Yptbx2@ z^UnDS%E?Wt6s?i?wkNxP_t|iLi_b3@>%$q=FhnqJU`k*;z||nipv@3Qe}T0d_5Ujv oERQQ#!aZ<~K=Qjj1H=FSzin7rjf8G41O_UDr>mdKI;Vst0Hcg#RR910 literal 0 HcmV?d00001 diff --git a/gpu/internal/rendertest/render_test.go b/gpu/internal/rendertest/render_test.go index 00900204..fc5c7fa8 100644 --- a/gpu/internal/rendertest/render_test.go +++ b/gpu/internal/rendertest/render_test.go @@ -359,6 +359,73 @@ func TestImageRGBA(t *testing.T) { }) } +func TestImageRGBA_ScaleLinear(t *testing.T) { + run(t, func(o *op.Ops) { + w := newWindow(t, 128, 128) + defer clip.Rect{Max: image.Pt(128, 128)}.Push(o).Pop() + op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(64, 64))).Add(o) + + im := image.NewRGBA(image.Rect(0, 0, 2, 2)) + im.Set(0, 0, colornames.Red) + im.Set(1, 0, colornames.Green) + im.Set(0, 1, colornames.White) + im.Set(1, 1, colornames.Black) + + op := paint.NewImageOp(im) + op.Filter = paint.FilterLinear + op.Add(o) + + paint.PaintOp{}.Add(o) + + if err := w.Frame(o); err != nil { + t.Error(err) + } + }, func(r result) { + r.expect(0, 0, colornames.Red) + r.expect(8, 8, colornames.Red) + + // TODO: this currently seems to do srgb scaling + // instead of linear rgb scaling, + r.expect(64-4, 0, color.RGBA{R: 197, G: 87, B: 0, A: 255}) + r.expect(64+4, 0, color.RGBA{R: 175, G: 98, B: 0, A: 255}) + + r.expect(127, 0, colornames.Green) + r.expect(127-8, 8, colornames.Green) + }) +} + +func TestImageRGBA_ScaleNearest(t *testing.T) { + run(t, func(o *op.Ops) { + w := newWindow(t, 128, 128) + op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(64, 64))).Add(o) + + im := image.NewRGBA(image.Rect(0, 0, 2, 2)) + im.Set(0, 0, colornames.Red) + im.Set(1, 0, colornames.Green) + im.Set(0, 1, colornames.White) + im.Set(1, 1, colornames.Black) + + op := paint.NewImageOp(im) + op.Filter = paint.FilterNearest + op.Add(o) + + paint.PaintOp{}.Add(o) + + if err := w.Frame(o); err != nil { + t.Error(err) + } + }, func(r result) { + r.expect(0, 0, colornames.Red) + r.expect(8, 8, colornames.Red) + + r.expect(64-4, 0, colornames.Red) + r.expect(64+4, 0, colornames.Green) + + r.expect(127, 0, colornames.Green) + r.expect(127-8, 8, colornames.Green) + }) +} + func TestGapsInPath(t *testing.T) { ops := new(op.Ops) var p clip.Path diff --git a/internal/ops/ops.go b/internal/ops/ops.go index 0ba83a71..adac510f 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -142,7 +142,7 @@ const ( TypePushOpacityLen = 1 + 4 TypePopOpacityLen = 1 TypeRedrawLen = 1 + 8 - TypeImageLen = 1 + TypeImageLen = 1 + 1 TypePaintLen = 1 TypeColorLen = 1 + 4 TypeLinearGradientLen = 1 + 8*2 + 4*2 diff --git a/op/paint/paint.go b/op/paint/paint.go index 59583ed7..1ccb3d93 100644 --- a/op/paint/paint.go +++ b/op/paint/paint.go @@ -15,8 +15,20 @@ import ( "gioui.org/op/clip" ) +// ImageFilter is the scaling filter for images. +type ImageFilter byte + +const ( + // FilterLinear uses linear interpolation for scaling. + FilterLinear ImageFilter = iota + // FilterNearest uses nearest neighbor interpolation for scaling. + FilterNearest +) + // ImageOp sets the brush to an image. type ImageOp struct { + Filter ImageFilter + uniform bool color color.NRGBA src *image.RGBA @@ -103,6 +115,7 @@ func (i ImageOp) Add(o *op.Ops) { } data := ops.Write2(&o.Internal, ops.TypeImageLen, i.src, i.handle) data[0] = byte(ops.TypeImage) + data[1] = byte(i.Filter) } func (c ColorOp) Add(o *op.Ops) {