1package vulkan
2
3import (
4 "image"
5 "image/color"
6 "math"
7 "slices"
8 "testing"
9 "unsafe"
10
11 "github.com/bnema/neferwl/internal/ports"
12 vk "github.com/bnema/purego-vulkan/vulkan"
13)
14
15func hdrTestRenderer(t *testing.T) *Renderer {
16 t.Helper()
17 r, err := New(64, 16)
18 if err != nil {
19 t.Skipf("Vulkan unavailable: %v", err)
20 }
21 t.Cleanup(r.Close)
22 r.SetHDR(203)
23 r.hdrReadback = true
24 if r.physical == 0 {
25 t.Skip("no exportable GPU")
26 }
27 r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
28 if len(r.hdrMods) == 0 {
29 t.Skip("no HDR transfer-src modifier")
30 }
31 bufs, err := r.ExportTargets(1, r.hdrMods)
32 if err != nil {
33 t.Skipf("no HDR target: %v", err)
34 }
35 t.Cleanup(func() { bufs[0].Planes[0].File.Close() })
36 return r
37}
38
39// pqTargetWords reads a test-only PQ target back (production targets are
40// not transfer-src), staging through a capture slot's buffer.
41func pqTargetWords(t *testing.T, r *Renderer) []uint32 {
42 t.Helper()
43 if err := r.waitFrame(r.submitted); err != nil {
44 t.Fatal(err)
45 }
46 if err := r.createCaptureSlots(); err != nil {
47 t.Fatal(err)
48 }
49 slot := r.captures[0]
50 if slot.leased {
51 t.Fatal("capture slot 0 leased")
52 }
53 target := r.targets[0]
54 err := r.oneShot(func(cmd vk.CommandBuffer) {
55 d := r.dd
56 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
57 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
58 region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
59 d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, ®ion)
60 hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
61 d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
62 b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
63 b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
64 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
65 d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
66 })
67 if err != nil {
68 t.Fatal(err)
69 }
70 return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)
71}
72
73func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
74 t.Helper()
75 pixel := pqTargetWords(t, r)[y*r.width+x]
76 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
77}
78
79func TestHDRWindowedComposition(t *testing.T) {
80 r := hdrTestRenderer(t)
81 format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
82 if !slices.Contains(r.DMABuf().Formats, format) {
83 t.Skip("linear XR30 import unavailable")
84 }
85 // 1000-nit neutral, red and blue BT.2020 client pixels.
86 nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
87 f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
88 v := nits[min(x/20, 2)]
89 b, g, red := uint32(math.Round(pqEncode(v[2])*1023)), uint32(math.Round(pqEncode(v[1])*1023)), uint32(math.Round(pqEncode(v[0])*1023))
90 p := b | g<<10 | red<<20
91 return [4]byte{byte(p), byte(p >> 8), byte(p >> 16), byte(p >> 24)}
92 })
93 buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
94 c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}}
95 scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
96 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
97 if err := render(r, scene, contents); err != nil {
98 t.Fatal(err)
99 }
100 for i, v := range nits {
101 got := hdrTargetAt(t, r, i*20+5, 8)
102 want := [3]float64{
103 pqEncode(v[0]),
104 pqEncode(v[1]),
105 pqEncode(v[2]),
106 }
107 for ch := range got {
108 if math.Abs(got[ch]-want[ch]) > .012 {
109 t.Errorf("sample %d channel %d got %.4f want %.4f", i, ch, got[ch], want[ch])
110 }
111 }
112 }
113 neutral := hdrTargetAt(t, r, 5, 8)
114 if math.Abs(neutral[0]-pqEncode(1000)) > .008 {
115 t.Fatalf("1000 nits clipped: %v", neutral)
116 }
117 // SDR capture tone-maps: the 1000-nit white lands on the shoulder
118 // (bright, not saturated) and the primaries keep their hue.
119 want := captureSDR([3]float64{1000.0 / 203, 1000.0 / 203, 1000.0 / 203})
120 if px := readPixels(t, r).RGBAAt(5, 8); !near(px, color.RGBA{want[0], want[1], want[2], 255}, 2) {
121 t.Fatalf("tone-mapped capture pixel %v, want %v", px, want)
122 }
123 t.Run("async capture", func(t *testing.T) {
124 cf := captureWait(t, r)
125 defer r.EndCapture(cf)
126 dst := make([]byte, 8)
127 if err := cf.Read(image.Rect(5, 8, 7, 9), dst, 8); err != nil {
128 t.Fatal(err)
129 }
130 if !near(color.RGBA{dst[2], dst[1], dst[0], 255}, color.RGBA{want[0], want[1], want[2], 255}, 2) || dst[3] != 255 {
131 t.Fatalf("capture %v, want %v", dst, want)
132 }
133 })
134 // The BT.2020 red (negative BT.709 green and blue) matches the
135 // reference gamut reduction and shoulder, not a per-channel clip.
136 red := readPixels(t, r).RGBAAt(25, 8)
137 lin := [3]float64{1.660491, -0.124550, -0.018151}
138 for i := range lin {
139 lin[i] *= 1000.0 / 203
140 }
141 wantRed := captureSDR(lin)
142 if !near(red, color.RGBA{wantRed[0], wantRed[1], wantRed[2], 255}, 3) || red.G != 0 {
143 t.Fatalf("bright BT.2020 red captured as %v, want %v", red, wantRed)
144 }
145 before := r.redrawn
146 if err := render(r, scene, contents); err != nil {
147 t.Fatal(err)
148 }
149 if r.redrawn-before >= r.width*r.height {
150 t.Fatal("unchanged HDR frame redrew full image")
151 }
152 if got := hdrTargetAt(t, r, 5, 8); math.Abs(got[0]-pqEncode(1000)) > .008 {
153 t.Fatalf("partial redraw lost highlights: %v", got)
154 }
155}
156
157func TestHDRLinearBlend(t *testing.T) {
158 r := hdrTestRenderer(t)
159 // Two translucent SDR overlays over black: half white then half black.
160 white := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{128, 128, 128, 128}))
161 black := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{0, 0, 0, 128}))
162 scene := ports.Scene{Background: "#000000", Layers: []ports.SceneLayer{{ID: 1, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 64, H: 16}}, {ID: 2, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 64, H: 16}}}}
163 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *white, 2: *black}); err != nil {
164 t.Fatal(err)
165 }
166 got := hdrTargetAt(t, r, 5, 5)
167 want := pqEncode(.25 * 203)
168 for i, v := range got {
169 if math.Abs(v-want) > .012 {
170 t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
171 }
172 }
173}
174
175// The focus pulse on an HDR output applies the SDR lift to sRGB content
176// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
177// separates this from a lift in linear light or none at all.
178func TestHDRPulse(t *testing.T) {
179 r := hdrTestRenderer(t)
180 c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
181 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
182 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
183 t.Fatal(err)
184 }
185 got := hdrTargetAt(t, r, 5, 5)
186 want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
187 for i, v := range got {
188 if math.Abs(v-want) > .006 {
189 t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
190 }
191 }
192}
193
194func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
195 r := hdrTestRenderer(t)
196 const background = "#804020"
197 const border = "#4080c0"
198 scene := ports.Scene{Seq: 1, Background: background, Border: ports.Border{Active: border}, Separators: []ports.Separator{{Rect: ports.Rect{X: 16, Y: 2, W: 2, H: 12}, Active: true}}, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 20, Y: 2, W: 28, H: 12}}}}
199 // The window body is a solid fill; the separator is another solid draw.
200 content := solidContent(t, 28, 12, color.RGBA{128, 64, 32, 255})
201 content.ID, content.Seq = 1, 1
202 check := func(label string) {
203 t.Helper()
204 for _, point := range []struct {
205 x, y int
206 hex string
207 }{{2, 2, background}, {16, 4, border}, {24, 8, background}} {
208 got := hdrTargetAt(t, r, point.x, point.y)
209 col := parseColor(point.hex)
210 var linear [3]float64
211 for ch, b := range col {
212 v := float64(b) / 255
213 if v > .04045 {
214 v = math.Pow((v+.055)/1.055, 2.4)
215 } else {
216 v /= 12.92
217 }
218 linear[ch] = v
219 }
220 matrix := [3][3]float64{{.627404, .329283, .043313}, {.069097, .919540, .011362}, {.016391, .088013, .895595}}
221 for ch := range got {
222 want := pqEncode((matrix[ch][0]*linear[0] + matrix[ch][1]*linear[1] + matrix[ch][2]*linear[2]) * 203)
223 if math.Abs(got[ch]-want) > .012 {
224 t.Errorf("%s (%d,%d) channel %d got %.4f want %.4f", label, point.x, point.y, ch, got[ch], want)
225 }
226 }
227 }
228 }
229 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: content}); err != nil {
230 t.Fatal(err)
231 }
232 check("full")
233 before := r.redrawn
234 // A changed window sequence damages only its rect; the background is
235 // repainted there via a solid draw instead of the attachment clear.
236 c := content
237 c.Seq = 2
238 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
239 t.Fatal(err)
240 }
241 if r.redrawn-before >= r.width*r.height {
242 t.Fatal("expected partial redraw")
243 }
244 check("partial")
245}