fix(vkdevice): upload new images in frames without new glyphs
Image textures were copied only when the frame also uploaded glyphs, so an image added in a frame whose text was already cached, or in a view without text, sampled an uninitialized texture and drew nothing. Record the transfers whenever a texture, a glyph or the first frame needs them.
- commit
- 05a3458e4fec06978219d34b559c2f38e71d7d58
- author
- brice · · 2026-10-09 10:49 UTC
- parent
- e0abc49
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +49 -3
M internal/presentation/vkdevice/render.go +8 -3
| @@ -166,9 +166,14 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 166 | 166 | return err | |
| 167 | 167 | } | |
| 168 | 168 | f.atlasRecorded = !f.device.atlas.initialized | |
| 169 | - | var staging vulkan.Buffer | |
| 170 | - | if len(uploads) != 0 { | |
| 171 | - | staging = f.glyphStaging.Buffer | |
| 169 | + | // Glyph uploads, a new image texture or the first frame each need | |
| 170 | + | // transfers before rendering: an image added without new glyphs must | |
| 171 | + | // still be uploaded. | |
| 172 | + | if len(uploads) != 0 || len(f.imageCopies) != 0 || !f.device.atlas.initialized || !f.device.images.fallbackReady { | |
| 173 | + | var staging vulkan.Buffer | |
| 174 | + | if len(uploads) != 0 { | |
| 175 | + | staging = f.glyphStaging.Buffer | |
| 176 | + | } | |
| 172 | 177 | f.device.atlas.recordUploads(f.Commands, staging, uploads) | |
| 173 | 178 | if !f.device.images.fallbackReady { | |
| 174 | 179 | f.recordFallback() | |
| @@ -166,9 +166,14 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||||
| 166 | return err | 166 | return err | ||
| 167 | } | 167 | } | ||
| 168 | f.atlasRecorded = !f.device.atlas.initialized | 168 | f.atlasRecorded = !f.device.atlas.initialized | ||
| 169 | - | var staging vulkan.Buffer | 169 | + | // Glyph uploads, a new image texture or the first frame each need |
| 170 | - | if len(uploads) != 0 { | 170 | + | // transfers before rendering: an image added without new glyphs must |
| 171 | - | staging = f.glyphStaging.Buffer | 171 | + | // still be uploaded. |
| 172 | + | if len(uploads) != 0 || len(f.imageCopies) != 0 || !f.device.atlas.initialized || !f.device.images.fallbackReady { | |||
| 173 | + | var staging vulkan.Buffer | |||
| 174 | + | if len(uploads) != 0 { | |||
| 175 | + | staging = f.glyphStaging.Buffer | |||
| 176 | + | } | |||
| 172 | f.device.atlas.recordUploads(f.Commands, staging, uploads) | 177 | f.device.atlas.recordUploads(f.Commands, staging, uploads) | ||
| 173 | if !f.device.images.fallbackReady { | 178 | if !f.device.images.fallbackReady { | ||
| 174 | f.recordFallback() | 179 | f.recordFallback() | ||
A internal/ui/renderer_image_gpu_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package ui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "image" | |
| 7 | + | "image/color" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // An image in a frame that uploads no glyph is still copied to the GPU: | |
| 12 | + | // before the fix it sampled an uninitialized texture and drew nothing. | |
| 13 | + | func TestRendererGPUImageWithoutText(t *testing.T) { | |
| 14 | + | r, tg := gpuRenderer(t) | |
| 15 | + | src := image.NewNRGBA(image.Rect(0, 0, 40, 30)) | |
| 16 | + | for y := range 30 { | |
| 17 | + | for x := range 40 { | |
| 18 | + | src.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 6), G: uint8(y * 8), B: 0x80, A: 0xff}) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | view := func(f *Frame, _ *struct{}) { | |
| 22 | + | f.Root().Stack().Box(Inline("overflow:hidden")).Rect(0, 0, 40, 30).Image(src, Inline("width:100%;height:100%")) | |
| 23 | + | } | |
| 24 | + | r.Resize(40, 30, 1) | |
| 25 | + | var out Output | |
| 26 | + | var m struct{} | |
| 27 | + | ok, err := r.Render(&out, &m, view) | |
| 28 | + | if err != nil || !ok { | |
| 29 | + | t.Fatalf("render: ok=%v err=%v", ok, err) | |
| 30 | + | } | |
| 31 | + | img, err := tg.Readback(out.Buffer) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | for _, p := range []image.Point{{0, 0}, {20, 15}, {39, 29}} { | |
| 36 | + | want, got := src.NRGBAAt(p.X, p.Y), img.NRGBAAt(p.X, p.Y) | |
| 37 | + | if got.R != want.R || got.G != want.G || got.B != want.B { | |
| 38 | + | t.Fatalf("pixel %v = %+v, want %+v", p, got, want) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package ui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "image" | |||
| 7 | + | "image/color" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // An image in a frame that uploads no glyph is still copied to the GPU: | |||
| 12 | + | // before the fix it sampled an uninitialized texture and drew nothing. | |||
| 13 | + | func TestRendererGPUImageWithoutText(t *testing.T) { | |||
| 14 | + | r, tg := gpuRenderer(t) | |||
| 15 | + | src := image.NewNRGBA(image.Rect(0, 0, 40, 30)) | |||
| 16 | + | for y := range 30 { | |||
| 17 | + | for x := range 40 { | |||
| 18 | + | src.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 6), G: uint8(y * 8), B: 0x80, A: 0xff}) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | view := func(f *Frame, _ *struct{}) { | |||
| 22 | + | f.Root().Stack().Box(Inline("overflow:hidden")).Rect(0, 0, 40, 30).Image(src, Inline("width:100%;height:100%")) | |||
| 23 | + | } | |||
| 24 | + | r.Resize(40, 30, 1) | |||
| 25 | + | var out Output | |||
| 26 | + | var m struct{} | |||
| 27 | + | ok, err := r.Render(&out, &m, view) | |||
| 28 | + | if err != nil || !ok { | |||
| 29 | + | t.Fatalf("render: ok=%v err=%v", ok, err) | |||
| 30 | + | } | |||
| 31 | + | img, err := tg.Readback(out.Buffer) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | for _, p := range []image.Point{{0, 0}, {20, 15}, {39, 29}} { | |||
| 36 | + | want, got := src.NRGBAAt(p.X, p.Y), img.NRGBAAt(p.X, p.Y) | |||
| 37 | + | if got.R != want.R || got.G != want.G || got.B != want.B { | |||
| 38 | + | t.Fatalf("pixel %v = %+v, want %+v", p, got, want) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||