1package vulkan
2
3import (
4 "image"
5 "image/color"
6 "slices"
7 "testing"
8 "unsafe"
9
10 "github.com/bnema/neferwl/internal/ports"
11 vk "github.com/bnema/purego-vulkan/vulkan"
12)
13
14func TestInstanceDrawLayout(t *testing.T) {
15 if unsafe.Sizeof(drawParameters{}) != 112 || unsafe.Sizeof(instanceDraw{}) != 128 || unsafe.Offsetof(instanceDraw{}.resource) != 112 {
16 t.Fatal("draw layout differs from compose.vert std430 layout")
17 }
18}
19
20func TestComposeBatchPaintOrder(t *testing.T) {
21 a, b := &shmCopy{}, &shmCopy{}
22 ds := []draw{{}, {shm: a}, {}, {shm: b}, {shm: a}}
23 for i := range ds {
24 ds[i].pc.rect[0] = int32(i)
25 }
26 var batch composeBatch
27 for _, dr := range ds {
28 if !batch.add(dr, 2, 20) {
29 t.Fatal("unexpected split")
30 }
31 }
32 if batch.used != 2 {
33 t.Fatalf("resources = %d, want 2", batch.used)
34 }
35 for i, want := range []uint32{0, 0, 0, 1, 0} {
36 if batch.instances[i].resource[0] != want || batch.instances[i].pc.rect[0] != int32(i) {
37 t.Fatalf("draw %d reordered or assigned wrong resource: %+v", i, batch.instances[i])
38 }
39 }
40 if batch.add(draw{shm: &shmCopy{}}, 2, 20) {
41 t.Fatal("accepted resource beyond descriptor capacity")
42 }
43 if batch.add(draw{}, 2, len(ds)) {
44 t.Fatal("accepted draw beyond buffer capacity")
45 }
46 batch = composeBatch{}
47 for _, dr := range ds[:3] {
48 if !batch.add(dr, 1, 20) {
49 t.Fatal("solid split a single-resource run")
50 }
51 }
52 if batch.add(ds[3], 1, 20) {
53 t.Fatal("single-resource batch accepted another resource")
54 }
55}
56
57func TestComposeResourceLimit(t *testing.T) {
58 limits := vk.PhysicalDeviceLimits{
59 MaxPerStageDescriptorSamplers: 128, MaxDescriptorSetSamplers: 128,
60 MaxPerStageDescriptorSampledImages: 128, MaxDescriptorSetSampledImages: 128,
61 MaxPerStageDescriptorStorageBuffers: 64, MaxDescriptorSetStorageBuffers: 65,
62 MaxPerStageResources: 193,
63 }
64 if got := composeResourceLimit(limits); got != 64 {
65 t.Fatalf("limit = %d, want 64", got)
66 }
67 limits.MaxPerStageResources = 192
68 if got := composeResourceLimit(limits); got != 63 {
69 t.Fatalf("color attachment not counted: %d, want 63", got)
70 }
71 limits.MaxDescriptorSetStorageBuffers = 5
72 if got := composeResourceLimit(limits); got != 4 {
73 t.Fatalf("instance buffer not counted: %d, want 4", got)
74 }
75 limits.MaxPerStageDescriptorSamplers = 4
76 if got := composeResourceLimit(limits); got != 2 {
77 t.Fatalf("two image planes not counted: %d, want 2", got)
78 }
79 limits.MaxDescriptorSetStorageBuffers = 0
80 if got := composeResourceLimit(limits); got != 0 {
81 t.Fatalf("zero limit underflowed: %d", got)
82 }
83}
84
85// Both shader variants preserve ordered blending across batch boundaries,
86// resource changes, and more batches than the slot's initial slice capacity.
87func TestComposeInstancedBlendAndBatches(t *testing.T) {
88 for _, tc := range []struct {
89 name string
90 single bool
91 resources uint32
92 }{
93 {"indexed", false, 3},
94 {"single resource", true, 1},
95 } {
96 t.Run(tc.name, func(t *testing.T) {
97 r, err := New(16, 16)
98 if err != nil {
99 t.Skipf("Vulkan unavailable: %v", err)
100 }
101 defer r.Close()
102 if !tc.single && !r.compose.indexing {
103 t.Skip("nonuniform descriptor indexing unavailable")
104 }
105 // Rebuild only the composition pipeline/layout before rendering,
106 // to exercise bounded tables and the feature-free shader variant.
107 r.destroyComposer()
108 r.compose.resources, r.compose.indexing = tc.resources, !tc.single
109 if err := r.createComposer(); err != nil {
110 t.Fatal(err)
111 }
112 scene := ports.Scene{Background: "#000000"}
113 contents := make(map[ports.WindowID]ports.SurfaceContent)
114 var reference [3]float64
115 for i := range 7 {
116 id := ports.WindowID(i + 1)
117 pixel := [4]byte{byte(12 + i*15), byte(90 - i*9), byte(20 + i*12), 128}
118 for ch := range 3 {
119 reference[ch] = float64(pixel[2-ch]) + reference[ch]*(127.0/255)
120 }
121 c := *shmContent(t, 16, 16, 64, fill(16, 16, pixel))
122 c.ID, c.Seq, c.Version = id, 1, 1
123 contents[id] = c
124 scene.Layers = append(scene.Layers, ports.SceneLayer{ID: id, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 16}})
125 }
126 for range 4 {
127 if err := render(r, scene, contents); err != nil {
128 t.Fatal(err)
129 }
130 if got := readPixels(t, r).RGBAAt(8, 8); !near(got, color.RGBA{R: uint8(reference[0] + 0.5), G: uint8(reference[1] + 0.5), B: uint8(reference[2] + 0.5), A: 255}, 2) {
131 t.Fatalf("ordered translucent overlays = %v", got)
132 }
133 }
134 wantBatches := (7 + int(tc.resources) - 1) / int(tc.resources)
135 for i := range r.slots {
136 if len(r.slots[i].batches) != wantBatches {
137 t.Fatalf("slot %d batches = %d, want %d", i, len(r.slots[i].batches), wantBatches)
138 }
139 }
140 // Replace a resource: both slots must refresh their descriptors.
141 oldKeys := [frameSlots][maxComposeResources]resourceKey{}
142 for i := range r.slots {
143 oldKeys[i] = r.slots[i].batches[wantBatches-1].cached
144 }
145 replacement := solidContent(t, 8, 8, color.RGBA{B: 255, A: 255})
146 replacement.LogicalW, replacement.LogicalH = 16, 16
147 replacement.ID, replacement.Seq, replacement.Version = 99, 2, 2
148 contents[7] = replacement
149 for range 4 {
150 if err := render(r, scene, contents); err != nil {
151 t.Fatal(err)
152 }
153 if got := readPixels(t, r).RGBAAt(8, 8); got != (color.RGBA{B: 255, A: 255}) {
154 t.Fatalf("replaced resource = %v", got)
155 }
156 }
157 for i := range r.slots {
158 if r.slots[i].batches[wantBatches-1].cached == oldKeys[i] {
159 t.Fatalf("slot %d retained old resource descriptors", i)
160 }
161 }
162 })
163 }
164}
165
166func TestComposePreparedReuseAndGrowth(t *testing.T) {
167 r, err := New(16, 16)
168 if err != nil {
169 t.Skipf("Vulkan unavailable: %v", err)
170 }
171 defer r.Close()
172 slot := &r.slots[0]
173 ds := []draw{r.fillDraw(image.Rect(0, 0, 16, 16), [3]uint8{255})}
174 if _, err := r.prepareCompose(slot, ds); err != nil {
175 t.Fatal(err)
176 }
177 batch := slot.batches[0]
178 initialSize := batch.gpu.size
179 large := slices.Repeat(ds, 100)
180 if _, err := r.prepareCompose(slot, large); err != nil {
181 t.Fatal(err)
182 }
183 if batch.gpu.size <= initialSize || batch.drawBuffer.Buffer != batch.gpu.buffer || batch.count != 100 {
184 t.Fatal("grown draw buffer not rebound")
185 }
186 if allocs := testing.AllocsPerRun(50, func() {
187 if _, err := r.prepareCompose(slot, large); err != nil {
188 t.Fatal(err)
189 }
190 }); allocs != 0 {
191 t.Fatalf("steady compose upload: %.1f allocations, want zero", allocs)
192 }
193 if batches, err := r.prepareCompose(slot, nil); err != nil || len(batches) != 0 {
194 t.Fatal("empty pass returned batches")
195 }
196 // Lower the draw range to force a split independently of descriptors.
197 r.maxRange = 2 * int(unsafe.Sizeof(instanceDraw{}))
198 if batches, err := r.prepareCompose(slot, ds); err != nil || len(batches) != 1 {
199 t.Fatal("single draw did not fit")
200 }
201 if batches, err := r.prepareCompose(slot, slices.Repeat(ds, 5)); err != nil || len(batches) != 3 || batches[0].count != 2 || batches[2].count != 1 {
202 t.Fatal("draw-buffer range did not split into 2,2,1")
203 }
204 // Shrink the scene and age its unused batches beyond the frame TTL.
205 r.frame += importTTL + 1
206 if batches, err := r.prepareCompose(slot, ds); err != nil || len(batches) != 1 || len(slot.batches) != 1 {
207 t.Fatal("unused batches retained beyond frame TTL")
208 }
209}
210
211// Zero handles: release, freeShmCopy and trimCompose make no Vulkan call.
212func TestComposeReleaseInvalidatesCachedDescriptors(t *testing.T) {
213 im, shm := &imported{fd: -1}, &shmCopy{}
214 other := resourceKey{shm: &shmCopy{}}
215 for _, tc := range []struct {
216 name string
217 key resourceKey
218 release func(*Renderer)
219 }{
220 {"dmabuf", resourceKey{im: im}, func(r *Renderer) { r.release(im) }},
221 {"shm", resourceKey{shm: shm}, func(r *Renderer) { r.freeShmCopy(shm) }},
222 } {
223 t.Run(tc.name, func(t *testing.T) {
224 r := &Renderer{}
225 for i := range r.slots {
226 hit := &composeBatch{valid: true}
227 hit.keys[0], hit.cached[0], hit.cached[1] = tc.key, tc.key, other
228 miss := &composeBatch{valid: true}
229 miss.cached[0] = other
230 r.slots[i].batches = []*composeBatch{hit, miss}
231 }
232 tc.release(r)
233 for i := range r.slots {
234 hit, miss := r.slots[i].batches[0], r.slots[i].batches[1]
235 if hit.valid || hit.cached[0] != (resourceKey{}) || hit.keys[0] != (resourceKey{}) || hit.cached[1] != other {
236 t.Fatalf("slot %d: released resource kept its descriptor cache", i)
237 }
238 if !miss.valid || miss.cached[0] != other {
239 t.Fatalf("slot %d: unrelated batch invalidated", i)
240 }
241 }
242 })
243 }
244}
245
246func TestTrimComposeKeepsInFlightAndRecentBatches(t *testing.T) {
247 const frame = 100
248 for _, tc := range []struct {
249 name string
250 busy bool
251 cutoff uint64
252 last []uint64 // per batch, in slot order
253 want int
254 }{
255 {"idle stale", false, 50, []uint64{10, 20}, 0},
256 {"busy stale", true, 50, []uint64{10, 20}, 2},
257 {"newer than cutoff", false, 50, []uint64{10, 60}, 2},
258 {"current frame", false, frame, []uint64{10, frame}, 2},
259 {"only trailing stale", false, 50, []uint64{60, 10}, 1},
260 } {
261 t.Run(tc.name, func(t *testing.T) {
262 r := &Renderer{frame: frame}
263 s := &r.slots[0]
264 s.busy = tc.busy
265 for _, last := range tc.last {
266 s.batches = append(s.batches, &composeBatch{last: last})
267 }
268 r.trimCompose(tc.cutoff)
269 if len(s.batches) != tc.want {
270 t.Fatalf("kept %d batches, want %d", len(s.batches), tc.want)
271 }
272 })
273 }
274}