detached 05a3458

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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,
166166 return err
167167 }
168168 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+ }
172177 f.device.atlas.recordUploads(f.Commands, staging, uploads)
173178 if !f.device.images.fallbackReady {
174179 f.recordFallback()
@@ -166,9 +166,14 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
166 return err166 return err
167 }167 }
168 f.atlasRecorded = !f.device.atlas.initialized168 f.atlasRecorded = !f.device.atlas.initialized
169- var staging vulkan.Buffer169+ // 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.Buffer171+ // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close