feat(vulkan): compose rotated outputs
- commit
- f77b8dcf6845701524c970ee4cc7b51618aca06a
- author
- brice · · 2026-10-03 18:42 UTC
- parent
- 343f5a5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +166 -10
M internal/adapters/vulkan/damage_test.go +15 -6
| @@ -27,16 +27,25 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin | |||
| 27 | 27 | // (render, skip, render). | |
| 28 | 28 | func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |
| 29 | 29 | for _, tr := range []ports.BufferTransform{0, 1, 6} { | |
| 30 | - | t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) }) | |
| 30 | + | t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr, 0) }) | |
| 31 | + | } | |
| 32 | + | // A rotated or flipped output composes the scene into its target. | |
| 33 | + | for _, out := range []ports.BufferTransform{1, 6} { | |
| 34 | + | t.Run(fmt.Sprintf("output transform %d", out), func(t *testing.T) { damageMatchesFullRedraw(t, 0, out) }) | |
| 31 | 35 | } | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 34 | 38 | // damageMatchesFullRedraw checks partial frames of a window whose buffer | |
| 35 | - | // carries transform tr against full redraws. | |
| 36 | - | func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |
| 39 | + | // carries transform tr against full redraws, on an output with transform | |
| 40 | + | // out (its scene is 64×48; a rotated output's target is 48×64). | |
| 41 | + | func damageMatchesFullRedraw(t *testing.T, tr, out ports.BufferTransform) { | |
| 37 | 42 | const w, h = 32, 32 | |
| 43 | + | tw, th := 64, 48 | |
| 44 | + | if out.Rotated() { | |
| 45 | + | tw, th = th, tw | |
| 46 | + | } | |
| 38 | 47 | newR := func() *Renderer { | |
| 39 | - | r, err := New(64, 48) | |
| 48 | + | r, err := New(tw, th) | |
| 40 | 49 | if err != nil { | |
| 41 | 50 | t.Skipf("Vulkan unavailable: %v", err) | |
| 42 | 51 | } | |
| @@ -63,7 +72,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||
| 63 | 72 | base := shmContent(t, w, h, w*4, pixels) | |
| 64 | 73 | base.ID, base.Transform = 1, tr | |
| 65 | 74 | f := base.SHM.File | |
| 66 | - | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} | |
| 75 | + | scene := ports.Scene{Seq: 5, Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} | |
| 67 | 76 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | |
| 68 | 77 | c := damagedContent(t, f, *base, 1, pixels, hist) | |
| 69 | 78 | step := func(i int, c ports.SurfaceContent) { | |
| @@ -73,7 +82,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||
| 73 | 82 | if err := render(damaged, scene, contents); err != nil { | |
| 74 | 83 | t.Fatal(err) | |
| 75 | 84 | } | |
| 76 | - | if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil { | |
| 85 | + | if err := render(full, ports.Scene{Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: scene.Background, Windows: scene.Windows}, contents); err != nil { | |
| 77 | 86 | t.Fatal(err) | |
| 78 | 87 | } | |
| 79 | 88 | if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) { | |
| @@ -27,16 +27,25 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin | |||||
| 27 | // (render, skip, render). | 27 | // (render, skip, render). | ||
| 28 | func TestRendererDamageMatchesFullRedraw(t *testing.T) { | 28 | func TestRendererDamageMatchesFullRedraw(t *testing.T) { | ||
| 29 | for _, tr := range []ports.BufferTransform{0, 1, 6} { | 29 | for _, tr := range []ports.BufferTransform{0, 1, 6} { | ||
| 30 | - | t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) }) | 30 | + | t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr, 0) }) |
| 31 | + | } | |||
| 32 | + | // A rotated or flipped output composes the scene into its target. | |||
| 33 | + | for _, out := range []ports.BufferTransform{1, 6} { | |||
| 34 | + | t.Run(fmt.Sprintf("output transform %d", out), func(t *testing.T) { damageMatchesFullRedraw(t, 0, out) }) | |||
| 31 | } | 35 | } | ||
| 32 | } | 36 | } | ||
| 33 | 37 | ||||
| 34 | // damageMatchesFullRedraw checks partial frames of a window whose buffer | 38 | // damageMatchesFullRedraw checks partial frames of a window whose buffer | ||
| 35 | - | // carries transform tr against full redraws. | 39 | + | // carries transform tr against full redraws, on an output with transform |
| 36 | - | func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | 40 | + | // out (its scene is 64×48; a rotated output's target is 48×64). |
| 41 | + | func damageMatchesFullRedraw(t *testing.T, tr, out ports.BufferTransform) { | |||
| 37 | const w, h = 32, 32 | 42 | const w, h = 32, 32 | ||
| 43 | + | tw, th := 64, 48 | |||
| 44 | + | if out.Rotated() { | |||
| 45 | + | tw, th = th, tw | |||
| 46 | + | } | |||
| 38 | newR := func() *Renderer { | 47 | newR := func() *Renderer { | ||
| 39 | - | r, err := New(64, 48) | 48 | + | r, err := New(tw, th) |
| 40 | if err != nil { | 49 | if err != nil { | ||
| 41 | t.Skipf("Vulkan unavailable: %v", err) | 50 | t.Skipf("Vulkan unavailable: %v", err) | ||
| 42 | } | 51 | } | ||
| @@ -63,7 +72,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||||
| 63 | base := shmContent(t, w, h, w*4, pixels) | 72 | base := shmContent(t, w, h, w*4, pixels) | ||
| 64 | base.ID, base.Transform = 1, tr | 73 | base.ID, base.Transform = 1, tr | ||
| 65 | f := base.SHM.File | 74 | f := base.SHM.File | ||
| 66 | - | scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} | 75 | + | scene := ports.Scene{Seq: 5, Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}} |
| 67 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | 76 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | ||
| 68 | c := damagedContent(t, f, *base, 1, pixels, hist) | 77 | c := damagedContent(t, f, *base, 1, pixels, hist) | ||
| 69 | step := func(i int, c ports.SurfaceContent) { | 78 | step := func(i int, c ports.SurfaceContent) { | ||
| @@ -73,7 +82,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||||
| 73 | if err := render(damaged, scene, contents); err != nil { | 82 | if err := render(damaged, scene, contents); err != nil { | ||
| 74 | t.Fatal(err) | 83 | t.Fatal(err) | ||
| 75 | } | 84 | } | ||
| 76 | - | if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil { | 85 | + | if err := render(full, ports.Scene{Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: scene.Background, Windows: scene.Windows}, contents); err != nil { |
| 77 | t.Fatal(err) | 86 | t.Fatal(err) | ||
| 78 | } | 87 | } | ||
| 79 | if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) { | 88 | if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) { | ||
M internal/adapters/vulkan/frame.go +8 -2
| @@ -176,7 +176,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 176 | 176 | if hdr { | |
| 177 | 177 | tg = &r.hdrOwn | |
| 178 | 178 | } | |
| 179 | - | dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | |
| 179 | + | // Bounds, damage and redrawn pixels are in scene space: the target's | |
| 180 | + | // size, swapped for a rotated output. orient maps the draws to the target | |
| 181 | + | // below. A transform change always comes with a new scene Seq, so a held | |
| 182 | + | // target is never reused across transforms. | |
| 183 | + | sw, sh := r.sceneSize(s.Transform) | |
| 184 | + | dmg := r.frameDamage(tg, s, image.Rect(0, 0, sw, sh)) | |
| 180 | 185 | ds := r.draws(s, contents, dmg) | |
| 181 | 186 | dmg.finish() | |
| 182 | 187 | r.dropPools() | |
| @@ -192,8 +197,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 192 | 197 | r.scratchDraws = ds | |
| 193 | 198 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | |
| 194 | 199 | } else { | |
| 195 | - | r.redrawn += r.width * r.height | |
| 200 | + | r.redrawn += sw * sh | |
| 196 | 201 | } | |
| 202 | + | r.orient(ds, s.Transform) | |
| 197 | 203 | d := r.dd | |
| 198 | 204 | cmd := slot.cmd | |
| 199 | 205 | if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil { | |
| @@ -176,7 +176,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 176 | if hdr { | 176 | if hdr { | ||
| 177 | tg = &r.hdrOwn | 177 | tg = &r.hdrOwn | ||
| 178 | } | 178 | } | ||
| 179 | - | dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | 179 | + | // Bounds, damage and redrawn pixels are in scene space: the target's |
| 180 | + | // size, swapped for a rotated output. orient maps the draws to the target | |||
| 181 | + | // below. A transform change always comes with a new scene Seq, so a held | |||
| 182 | + | // target is never reused across transforms. | |||
| 183 | + | sw, sh := r.sceneSize(s.Transform) | |||
| 184 | + | dmg := r.frameDamage(tg, s, image.Rect(0, 0, sw, sh)) | |||
| 180 | ds := r.draws(s, contents, dmg) | 185 | ds := r.draws(s, contents, dmg) | ||
| 181 | dmg.finish() | 186 | dmg.finish() | ||
| 182 | r.dropPools() | 187 | r.dropPools() | ||
| @@ -192,8 +197,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 192 | r.scratchDraws = ds | 197 | r.scratchDraws = ds | ||
| 193 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | 198 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | ||
| 194 | } else { | 199 | } else { | ||
| 195 | - | r.redrawn += r.width * r.height | 200 | + | r.redrawn += sw * sh |
| 196 | } | 201 | } | ||
| 202 | + | r.orient(ds, s.Transform) | |||
| 197 | d := r.dd | 203 | d := r.dd | ||
| 198 | cmd := slot.cmd | 204 | cmd := slot.cmd | ||
| 199 | if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil { | 205 | if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil { | ||
A internal/adapters/vulkan/orient.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // sceneSize is the size of a scene in scene-physical pixels: the target's, | |
| 10 | + | // swapped when the output transform t rotates by 90 or 270 degrees. | |
| 11 | + | func (r *Renderer) sceneSize(t ports.BufferTransform) (int, int) { | |
| 12 | + | if t.Rotated() { | |
| 13 | + | return r.height, r.width | |
| 14 | + | } | |
| 15 | + | return r.width, r.height | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // orient maps draws built in scene space to the target, in place and without | |
| 19 | + | // allocating: the target holds the scene transformed by t, like a client | |
| 20 | + | // buffer holds its surface under wl_surface.set_buffer_transform. Each rect | |
| 21 | + | // goes through t.RectToBuffer (integer rects stay integer under these eight | |
| 22 | + | // isometries, so the rounding is exact) and each source map is composed with | |
| 23 | + | // the affine target-to-scene map, the inverse transform. The vertex shader's | |
| 24 | + | // target size (misc[2:4]) stays the target's. | |
| 25 | + | // | |
| 26 | + | // Pixel centres map to pixel centres under these isometries, so a draw | |
| 27 | + | // flagged flagExact still reads texelFetch(floor(src)) on the right texel. | |
| 28 | + | // | |
| 29 | + | // It runs once per frame, after damage clipping, and before any consumer of | |
| 30 | + | // the target: HDR composition (hdrOwn is target-sized and recordHDR reads the | |
| 31 | + | // already-oriented target) and the capture and Pixels paths stay unchanged. | |
| 32 | + | func (r *Renderer) orient(ds []draw, t ports.BufferTransform) { | |
| 33 | + | if t == 0 { | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | sw, sh := r.sceneSize(t) | |
| 37 | + | inv := t.Invert() | |
| 38 | + | tw, th := float64(r.width), float64(r.height) | |
| 39 | + | // scene = f0 + x*fx + y*fy for a target point (x, y). | |
| 40 | + | f0x, f0y := inv.ToBuffer(0, 0, tw, th) | |
| 41 | + | fxx, fxy := inv.ToBuffer(1, 0, tw, th) | |
| 42 | + | fyx, fyy := inv.ToBuffer(0, 1, tw, th) | |
| 43 | + | fxx, fxy = fxx-f0x, fxy-f0y | |
| 44 | + | fyx, fyy = fyx-f0x, fyy-f0y | |
| 45 | + | for i := range ds { | |
| 46 | + | pc := &ds[i].pc | |
| 47 | + | rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh) | |
| 48 | + | pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)} | |
| 49 | + | if pc.misc[0] == modeSolid { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | m0x, m0y := float64(pc.mapv[0]), float64(pc.mapv[1]) | |
| 53 | + | mxx, mxy := float64(pc.mapv[2]), float64(pc.mapv[3]) | |
| 54 | + | myx, myy := float64(pc.mapy[0]), float64(pc.mapy[1]) | |
| 55 | + | pc.mapv = [4]float32{ | |
| 56 | + | float32(m0x + f0x*mxx + f0y*myx), | |
| 57 | + | float32(m0y + f0x*mxy + f0y*myy), | |
| 58 | + | float32(fxx*mxx + fxy*myx), | |
| 59 | + | float32(fxx*mxy + fxy*myy), | |
| 60 | + | } | |
| 61 | + | pc.mapy[0] = float32(fyx*mxx + fyy*myx) | |
| 62 | + | pc.mapy[1] = float32(fyx*mxy + fyy*myy) | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // sceneSize is the size of a scene in scene-physical pixels: the target's, | |||
| 10 | + | // swapped when the output transform t rotates by 90 or 270 degrees. | |||
| 11 | + | func (r *Renderer) sceneSize(t ports.BufferTransform) (int, int) { | |||
| 12 | + | if t.Rotated() { | |||
| 13 | + | return r.height, r.width | |||
| 14 | + | } | |||
| 15 | + | return r.width, r.height | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // orient maps draws built in scene space to the target, in place and without | |||
| 19 | + | // allocating: the target holds the scene transformed by t, like a client | |||
| 20 | + | // buffer holds its surface under wl_surface.set_buffer_transform. Each rect | |||
| 21 | + | // goes through t.RectToBuffer (integer rects stay integer under these eight | |||
| 22 | + | // isometries, so the rounding is exact) and each source map is composed with | |||
| 23 | + | // the affine target-to-scene map, the inverse transform. The vertex shader's | |||
| 24 | + | // target size (misc[2:4]) stays the target's. | |||
| 25 | + | // | |||
| 26 | + | // Pixel centres map to pixel centres under these isometries, so a draw | |||
| 27 | + | // flagged flagExact still reads texelFetch(floor(src)) on the right texel. | |||
| 28 | + | // | |||
| 29 | + | // It runs once per frame, after damage clipping, and before any consumer of | |||
| 30 | + | // the target: HDR composition (hdrOwn is target-sized and recordHDR reads the | |||
| 31 | + | // already-oriented target) and the capture and Pixels paths stay unchanged. | |||
| 32 | + | func (r *Renderer) orient(ds []draw, t ports.BufferTransform) { | |||
| 33 | + | if t == 0 { | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | sw, sh := r.sceneSize(t) | |||
| 37 | + | inv := t.Invert() | |||
| 38 | + | tw, th := float64(r.width), float64(r.height) | |||
| 39 | + | // scene = f0 + x*fx + y*fy for a target point (x, y). | |||
| 40 | + | f0x, f0y := inv.ToBuffer(0, 0, tw, th) | |||
| 41 | + | fxx, fxy := inv.ToBuffer(1, 0, tw, th) | |||
| 42 | + | fyx, fyy := inv.ToBuffer(0, 1, tw, th) | |||
| 43 | + | fxx, fxy = fxx-f0x, fxy-f0y | |||
| 44 | + | fyx, fyy = fyx-f0x, fyy-f0y | |||
| 45 | + | for i := range ds { | |||
| 46 | + | pc := &ds[i].pc | |||
| 47 | + | rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh) | |||
| 48 | + | pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)} | |||
| 49 | + | if pc.misc[0] == modeSolid { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | m0x, m0y := float64(pc.mapv[0]), float64(pc.mapv[1]) | |||
| 53 | + | mxx, mxy := float64(pc.mapv[2]), float64(pc.mapv[3]) | |||
| 54 | + | myx, myy := float64(pc.mapy[0]), float64(pc.mapy[1]) | |||
| 55 | + | pc.mapv = [4]float32{ | |||
| 56 | + | float32(m0x + f0x*mxx + f0y*myx), | |||
| 57 | + | float32(m0y + f0x*mxy + f0y*myy), | |||
| 58 | + | float32(fxx*mxx + fxy*myx), | |||
| 59 | + | float32(fxx*mxy + fxy*myy), | |||
| 60 | + | } | |||
| 61 | + | pc.mapy[0] = float32(fyx*mxx + fyy*myx) | |||
| 62 | + | pc.mapy[1] = float32(fyx*mxy + fyy*myy) | |||
| 63 | + | } | |||
| 64 | + | } | |||
A internal/adapters/vulkan/output_transform_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image/color" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The target of a rotated output holds the scene transformed by ToBuffer: | |
| 12 | + | // every scene pixel of a window lands on the target pixel ToBuffer names, | |
| 13 | + | // for all eight transforms, and the rest of the target stays background. | |
| 14 | + | func TestRendererOutputTransform(t *testing.T) { | |
| 15 | + | const tw, th = 6, 4 | |
| 16 | + | r, err := New(tw, th) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 19 | + | } | |
| 20 | + | defer r.Close() | |
| 21 | + | red, green := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255} | |
| 22 | + | px := []byte{0, 0, 255, 255, 0, 255, 0, 255} // BGRA: red, green | |
| 23 | + | win := ports.Rect{X: 1, Y: 0, W: 2, H: 1} | |
| 24 | + | for tr := ports.BufferTransform(0); tr < 8; tr++ { | |
| 25 | + | sw, sh := r.sceneSize(tr) | |
| 26 | + | c := shmContent(t, 2, 1, 8, px) | |
| 27 | + | scene := ports.Scene{Transform: tr, Scale: 1, OutputWidth: sw, OutputHeight: sh, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: win}}} | |
| 28 | + | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | img := readPixels(t, r) | |
| 32 | + | want := map[[2]int]color.RGBA{} | |
| 33 | + | for i, col := range []color.RGBA{red, green} { | |
| 34 | + | bx, by := tr.ToBuffer(float64(win.X+i)+0.5, 0.5, float64(sw), float64(sh)) | |
| 35 | + | want[[2]int{int(math.Floor(bx)), int(math.Floor(by))}] = col | |
| 36 | + | } | |
| 37 | + | for y := range th { | |
| 38 | + | for x := range tw { | |
| 39 | + | w, ok := want[[2]int{x, y}] | |
| 40 | + | if !ok { | |
| 41 | + | w = color.RGBA{0, 0, 0, 255} | |
| 42 | + | } | |
| 43 | + | if got := img.RGBAAt(x, y); !near(got, w, 2) { | |
| 44 | + | t.Errorf("transform %d: target (%d,%d) = %v, want %v", tr, x, y, got, w) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // A rotated output keeps the steady-state frame free of extra allocations: | |
| 52 | + | // orient works in place. | |
| 53 | + | func TestRenderSteadyStateAllocationsRotated(t *testing.T) { | |
| 54 | + | r, err := New(32, 24) | |
| 55 | + | if err != nil { | |
| 56 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 57 | + | } | |
| 58 | + | defer r.Close() | |
| 59 | + | scene := ports.Scene{Seq: 1, Transform: 1, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 24, H: 32}}}} | |
| 60 | + | content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |
| 61 | + | content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1 | |
| 62 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: content} | |
| 63 | + | frame := func() { | |
| 64 | + | if err := render(r, scene, contents); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | frame() | |
| 69 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 13 { | |
| 70 | + | t.Errorf("rotated steady-state Render: %.1f allocs/frame, want <=13", allocs) | |
| 71 | + | } else { | |
| 72 | + | t.Logf("rotated steady-state Render: %.1f allocs/frame", allocs) | |
| 73 | + | } | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image/color" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The target of a rotated output holds the scene transformed by ToBuffer: | |||
| 12 | + | // every scene pixel of a window lands on the target pixel ToBuffer names, | |||
| 13 | + | // for all eight transforms, and the rest of the target stays background. | |||
| 14 | + | func TestRendererOutputTransform(t *testing.T) { | |||
| 15 | + | const tw, th = 6, 4 | |||
| 16 | + | r, err := New(tw, th) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 19 | + | } | |||
| 20 | + | defer r.Close() | |||
| 21 | + | red, green := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255} | |||
| 22 | + | px := []byte{0, 0, 255, 255, 0, 255, 0, 255} // BGRA: red, green | |||
| 23 | + | win := ports.Rect{X: 1, Y: 0, W: 2, H: 1} | |||
| 24 | + | for tr := ports.BufferTransform(0); tr < 8; tr++ { | |||
| 25 | + | sw, sh := r.sceneSize(tr) | |||
| 26 | + | c := shmContent(t, 2, 1, 8, px) | |||
| 27 | + | scene := ports.Scene{Transform: tr, Scale: 1, OutputWidth: sw, OutputHeight: sh, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: win}}} | |||
| 28 | + | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | img := readPixels(t, r) | |||
| 32 | + | want := map[[2]int]color.RGBA{} | |||
| 33 | + | for i, col := range []color.RGBA{red, green} { | |||
| 34 | + | bx, by := tr.ToBuffer(float64(win.X+i)+0.5, 0.5, float64(sw), float64(sh)) | |||
| 35 | + | want[[2]int{int(math.Floor(bx)), int(math.Floor(by))}] = col | |||
| 36 | + | } | |||
| 37 | + | for y := range th { | |||
| 38 | + | for x := range tw { | |||
| 39 | + | w, ok := want[[2]int{x, y}] | |||
| 40 | + | if !ok { | |||
| 41 | + | w = color.RGBA{0, 0, 0, 255} | |||
| 42 | + | } | |||
| 43 | + | if got := img.RGBAAt(x, y); !near(got, w, 2) { | |||
| 44 | + | t.Errorf("transform %d: target (%d,%d) = %v, want %v", tr, x, y, got, w) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // A rotated output keeps the steady-state frame free of extra allocations: | |||
| 52 | + | // orient works in place. | |||
| 53 | + | func TestRenderSteadyStateAllocationsRotated(t *testing.T) { | |||
| 54 | + | r, err := New(32, 24) | |||
| 55 | + | if err != nil { | |||
| 56 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 57 | + | } | |||
| 58 | + | defer r.Close() | |||
| 59 | + | scene := ports.Scene{Seq: 1, Transform: 1, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 24, H: 32}}}} | |||
| 60 | + | content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |||
| 61 | + | content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1 | |||
| 62 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: content} | |||
| 63 | + | frame := func() { | |||
| 64 | + | if err := render(r, scene, contents); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | frame() | |||
| 69 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 13 { | |||
| 70 | + | t.Errorf("rotated steady-state Render: %.1f allocs/frame, want <=13", allocs) | |||
| 71 | + | } else { | |||
| 72 | + | t.Logf("rotated steady-state Render: %.1f allocs/frame", allocs) | |||
| 73 | + | } | |||
| 74 | + | } | |||
M internal/adapters/vulkan/scene.go +5 -2
| @@ -15,7 +15,7 @@ type sceneWalk struct { | |||
| 15 | 15 | s ports.Scene | |
| 16 | 16 | contents map[ports.WindowID]ports.SurfaceContent | |
| 17 | 17 | dmg *damageRegion | |
| 18 | - | // scale maps the scene's logical pixels to the target's physical ones. | |
| 18 | + | // scale maps the scene's logical pixels to scene-physical ones. | |
| 19 | 19 | scale float64 | |
| 20 | 20 | // zoom shrinks the surfaces of the window being placed: an overview | |
| 21 | 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | |
| @@ -30,7 +30,10 @@ type sceneWalk struct { | |||
| 30 | 30 | // draws walks the scene into quads in paint order. | |
| 31 | 31 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | |
| 32 | 32 | clear(r.scratchDraws) // release references from longer earlier scenes | |
| 33 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | |
| 33 | + | // The walk stays in scene-physical space (the target's size, swapped for | |
| 34 | + | // a rotated output); orient maps the draws to the target afterwards. | |
| 35 | + | sw, sh := r.sceneSize(s.Transform) | |
| 36 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]} | |
| 34 | 37 | r.scratchCovers = r.scratchCovers[:0] | |
| 35 | 38 | if w.scale <= 0 { | |
| 36 | 39 | w.scale = 1 | |
| @@ -15,7 +15,7 @@ type sceneWalk struct { | |||||
| 15 | s ports.Scene | 15 | s ports.Scene | ||
| 16 | contents map[ports.WindowID]ports.SurfaceContent | 16 | contents map[ports.WindowID]ports.SurfaceContent | ||
| 17 | dmg *damageRegion | 17 | dmg *damageRegion | ||
| 18 | - | // scale maps the scene's logical pixels to the target's physical ones. | 18 | + | // scale maps the scene's logical pixels to scene-physical ones. |
| 19 | scale float64 | 19 | scale float64 | ||
| 20 | // zoom shrinks the surfaces of the window being placed: an overview | 20 | // zoom shrinks the surfaces of the window being placed: an overview | ||
| 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | ||
| @@ -30,7 +30,10 @@ type sceneWalk struct { | |||||
| 30 | // draws walks the scene into quads in paint order. | 30 | // draws walks the scene into quads in paint order. | ||
| 31 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | 31 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | ||
| 32 | clear(r.scratchDraws) // release references from longer earlier scenes | 32 | clear(r.scratchDraws) // release references from longer earlier scenes | ||
| 33 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | 33 | + | // The walk stays in scene-physical space (the target's size, swapped for |
| 34 | + | // a rotated output); orient maps the draws to the target afterwards. | |||
| 35 | + | sw, sh := r.sceneSize(s.Transform) | |||
| 36 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]} | |||
| 34 | r.scratchCovers = r.scratchCovers[:0] | 37 | r.scratchCovers = r.scratchCovers[:0] | ||
| 35 | if w.scale <= 0 { | 38 | if w.scale <= 0 { | ||
| 36 | w.scale = 1 | 39 | w.scale = 1 | ||