1package vulkan
2
3import (
4 "image"
5 "image/color"
6 "math"
7 "slices"
8 "testing"
9
10 "github.com/bnema/neferwl/internal/ports"
11 vk "github.com/bnema/purego-vulkan/vulkan"
12)
13
14func hdrTestRenderer(t *testing.T) *Renderer {
15 t.Helper()
16 r, err := New(64, 16)
17 if err != nil {
18 t.Skipf("Vulkan unavailable: %v", err)
19 }
20 t.Cleanup(r.Close)
21 r.SetHDR(203)
22 r.SetHDRReadback(true)
23 if r.physical == 0 {
24 t.Skip("no exportable GPU")
25 }
26 r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
27 if len(r.hdrMods) == 0 {
28 t.Skip("no HDR transfer-src modifier")
29 }
30 bufs, err := r.ExportTargets(1, r.hdrMods, false)
31 if err != nil {
32 t.Skipf("no HDR target: %v", err)
33 }
34 t.Cleanup(func() { bufs[0].Planes[0].File.Close() })
35 return r
36}
37
38// pqTargetWords reads a test-only PQ target back (production targets are
39// not transfer-src), staging through a capture slot's buffer.
40func pqTargetWords(t *testing.T, r *Renderer) []uint32 {
41 t.Helper()
42 var words []uint32
43 if err := r.readHDRTarget(func(w []uint32) { words = slices.Clone(w) }); err != nil {
44 t.Fatal(err)
45 }
46 return words
47}
48
49func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
50 t.Helper()
51 pixel := pqTargetWords(t, r)[y*r.width+x]
52 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
53}
54
55// hdrPQScene is a 64×16 XR30 udmabuf client holding 1000-nit neutral, red
56// and blue BT.2020 thirds (x/20), drawn full-output.
57func hdrPQScene(t *testing.T, r *Renderer) (ports.Scene, map[ports.WindowID]ports.SurfaceContent, [3][3]float64) {
58 t.Helper()
59 format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
60 if !slices.Contains(r.DMABuf().Formats, format) {
61 t.Skip("linear XR30 import unavailable")
62 }
63 nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
64 f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
65 v := nits[min(x/20, 2)]
66 b, g, red := uint32(math.Round(pqEncode(v[2])*1023)), uint32(math.Round(pqEncode(v[1])*1023)), uint32(math.Round(pqEncode(v[0])*1023))
67 p := b | g<<10 | red<<20
68 return [4]byte{byte(p), byte(p >> 8), byte(p >> 16), byte(p >> 24)}
69 })
70 buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
71 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}}
72 scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
73 return scene, map[ports.WindowID]ports.SurfaceContent{1: c}, nits
74}
75
76// HDRPixels returns the exported PQ target's codes scaled to 16 bits, and
77// nothing on an SDR renderer.
78func TestHDRPixels(t *testing.T) {
79 r := hdrTestRenderer(t)
80 if r.HDRPixels() != nil {
81 t.Fatal("HDRPixels before any frame")
82 }
83 scene, contents, nits := hdrPQScene(t, r)
84 if err := render(r, scene, contents); err != nil {
85 t.Fatal(err)
86 }
87 img := r.HDRPixels()
88 if img == nil {
89 t.Fatal("HDRPixels nil after an HDR frame")
90 }
91 if img.Bounds() != image.Rect(0, 0, 64, 16) {
92 t.Fatalf("bounds %v", img.Bounds())
93 }
94 for i, v := range nits {
95 x := i*20 + 5
96 got := img.RGBA64At(x, 8)
97 raw := hdrTargetAt(t, r, x, 8) // code/1023, the same words
98 for ch, c := range []uint16{got.R, got.G, got.B} {
99 code := uint32(math.Round(raw[ch] * 1023))
100 if want := uint16(code<<6 | code>>4); c != want {
101 t.Errorf("sample %d channel %d: %#04x want %#04x", i, ch, c, want)
102 }
103 if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
104 t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
105 }
106 }
107 if got.A != 0xffff {
108 t.Errorf("sample %d alpha %#04x", i, got.A)
109 }
110 }
111 // An HDR virtual output without SetHDRReadback has no readable target.
112 plain, err := New(64, 16)
113 if err != nil {
114 t.Skipf("Vulkan unavailable: %v", err)
115 }
116 defer plain.Close()
117 plain.SetHDR(203)
118 plain.SetVirtualOutput(true)
119 bufs, err := plain.ExportTargets(1, nil, false)
120 if err != nil {
121 t.Skipf("no HDR target: %v", err)
122 }
123 defer bufs[0].Planes[0].File.Close()
124 if err := render(plain, ports.Scene{Background: "#ffffff"}, nil); err != nil {
125 t.Fatal(err)
126 }
127 if plain.HDRPixels() != nil {
128 t.Fatal("HDRPixels without SetHDRReadback")
129 }
130 sdr, err := New(64, 4)
131 if err != nil {
132 t.Skipf("Vulkan unavailable: %v", err)
133 }
134 defer sdr.Close()
135 if err := render(sdr, ports.Scene{Background: "#ffffff"}, nil); err != nil {
136 t.Fatal(err)
137 }
138 if sdr.HDRPixels() != nil {
139 t.Fatal("HDRPixels on an SDR renderer")
140 }
141}
142
143// HDRPixels reads the target the last HDR frame drew into, not the one
144// selected for the next frame, and never a target exported after it.
145func TestHDRPixelsReadsRenderedTarget(t *testing.T) {
146 r := hdrTestRenderer(t)
147 scene, contents, nits := hdrPQScene(t, r)
148 if err := render(r, scene, contents); err != nil {
149 t.Fatal(err)
150 }
151 // New targets replace the one the frame drew into: nothing to read.
152 bufs, err := r.ExportTargets(2, r.hdrMods, false)
153 if err != nil {
154 t.Skipf("no two HDR targets: %v", err)
155 }
156 for _, b := range bufs {
157 defer b.Planes[0].File.Close()
158 }
159 if r.HDRPixels() != nil {
160 t.Fatal("HDRPixels from targets no frame drew into")
161 }
162 r.UseTarget(0)
163 if err := render(r, scene, contents); err != nil {
164 t.Fatal(err)
165 }
166 r.UseTarget(1)
167 img := r.HDRPixels()
168 if img == nil {
169 t.Fatal("HDRPixels nil after an HDR frame")
170 }
171 for i, v := range nits {
172 got := img.RGBA64At(i*20+5, 8)
173 for ch, c := range []uint16{got.R, got.G, got.B} {
174 if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
175 t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
176 }
177 }
178 }
179}
180
181func TestHDRWindowedComposition(t *testing.T) {
182 r := hdrTestRenderer(t)
183 scene, contents, nits := hdrPQScene(t, r)
184 if err := render(r, scene, contents); err != nil {
185 t.Fatal(err)
186 }
187 for i, v := range nits {
188 got := hdrTargetAt(t, r, i*20+5, 8)
189 want := [3]float64{
190 pqEncode(v[0]),
191 pqEncode(v[1]),
192 pqEncode(v[2]),
193 }
194 for ch := range got {
195 if math.Abs(got[ch]-want[ch]) > .012 {
196 t.Errorf("sample %d channel %d got %.4f want %.4f", i, ch, got[ch], want[ch])
197 }
198 }
199 }
200 neutral := hdrTargetAt(t, r, 5, 8)
201 if math.Abs(neutral[0]-pqEncode(1000)) > .008 {
202 t.Fatalf("1000 nits clipped: %v", neutral)
203 }
204 // SDR capture tone-maps: the 1000-nit white lands on the shoulder
205 // (bright, not saturated) and the primaries keep their hue.
206 want := captureSDR([3]float64{1000.0 / 203, 1000.0 / 203, 1000.0 / 203})
207 if px := readPixels(t, r).RGBAAt(5, 8); !near(px, color.RGBA{want[0], want[1], want[2], 255}, 2) {
208 t.Fatalf("tone-mapped capture pixel %v, want %v", px, want)
209 }
210 t.Run("async capture", func(t *testing.T) {
211 cf := captureWait(t, r)
212 defer r.EndCapture(cf)
213 dst := make([]byte, 8)
214 if err := cf.Read(image.Rect(5, 8, 7, 9), dst, 8); err != nil {
215 t.Fatal(err)
216 }
217 if !near(color.RGBA{dst[2], dst[1], dst[0], 255}, color.RGBA{want[0], want[1], want[2], 255}, 2) || dst[3] != 255 {
218 t.Fatalf("capture %v, want %v", dst, want)
219 }
220 })
221 // The BT.2020 red (negative BT.709 green and blue) matches the
222 // reference gamut reduction and shoulder, not a per-channel clip.
223 red := readPixels(t, r).RGBAAt(25, 8)
224 lin := [3]float64{1.660491, -0.124550, -0.018151}
225 for i := range lin {
226 lin[i] *= 1000.0 / 203
227 }
228 wantRed := captureSDR(lin)
229 if !near(red, color.RGBA{wantRed[0], wantRed[1], wantRed[2], 255}, 3) || red.G != 0 {
230 t.Fatalf("bright BT.2020 red captured as %v, want %v", red, wantRed)
231 }
232 before := r.redrawn
233 if err := render(r, scene, contents); err != nil {
234 t.Fatal(err)
235 }
236 if r.redrawn-before >= r.width*r.height {
237 t.Fatal("unchanged HDR frame redrew full image")
238 }
239 if got := hdrTargetAt(t, r, 5, 8); math.Abs(got[0]-pqEncode(1000)) > .008 {
240 t.Fatalf("partial redraw lost highlights: %v", got)
241 }
242}
243
244func TestHDRLinearBlend(t *testing.T) {
245 r := hdrTestRenderer(t)
246 // Two translucent SDR overlays over black: half white then half black.
247 white := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{128, 128, 128, 128}))
248 black := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{0, 0, 0, 128}))
249 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}}}}
250 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *white, 2: *black}); err != nil {
251 t.Fatal(err)
252 }
253 got := hdrTargetAt(t, r, 5, 5)
254 want := pqEncode(.25 * 203)
255 for i, v := range got {
256 if math.Abs(v-want) > .012 {
257 t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
258 }
259 }
260}
261
262// The focus pulse on an HDR output applies the SDR lift to sRGB content
263// before decoding: sRGB 64 at 0.1 shows as sRGB 83.1 would. A dark input
264// separates this from a lift in linear light or none at all.
265func TestHDRPulse(t *testing.T) {
266 r := hdrTestRenderer(t)
267 c := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{64, 64, 64, 255}))
268 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, FocusEffect: 0.1}}}
269 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
270 t.Fatal(err)
271 }
272 got := hdrTargetAt(t, r, 5, 5)
273 want := pqEncode(srgbToLinear((64+0.1*(255-64))/255) * 203)
274 for i, v := range got {
275 if math.Abs(v-want) > .006 {
276 t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
277 }
278 }
279}
280
281func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
282 r := hdrTestRenderer(t)
283 const background = "#804020"
284 const border = "#4080c0"
285 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}}}}
286 // The window body is a solid fill; the separator is another solid draw.
287 content := solidContent(t, 28, 12, color.RGBA{128, 64, 32, 255})
288 content.ID, content.Seq = 1, 1
289 check := func(label string) {
290 t.Helper()
291 for _, point := range []struct {
292 x, y int
293 hex string
294 }{{2, 2, background}, {16, 4, border}, {24, 8, background}} {
295 got := hdrTargetAt(t, r, point.x, point.y)
296 col := parseColor(point.hex)
297 var linear [3]float64
298 for ch, b := range col {
299 v := float64(b) / 255
300 if v > .04045 {
301 v = math.Pow((v+.055)/1.055, 2.4)
302 } else {
303 v /= 12.92
304 }
305 linear[ch] = v
306 }
307 matrix := [3][3]float64{{.627404, .329283, .043313}, {.069097, .919540, .011362}, {.016391, .088013, .895595}}
308 for ch := range got {
309 want := pqEncode((matrix[ch][0]*linear[0] + matrix[ch][1]*linear[1] + matrix[ch][2]*linear[2]) * 203)
310 if math.Abs(got[ch]-want) > .012 {
311 t.Errorf("%s (%d,%d) channel %d got %.4f want %.4f", label, point.x, point.y, ch, got[ch], want)
312 }
313 }
314 }
315 }
316 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: content}); err != nil {
317 t.Fatal(err)
318 }
319 check("full")
320 before := r.redrawn
321 // A changed window sequence damages only its rect; the background is
322 // repainted there via a solid draw instead of the attachment clear.
323 c := content
324 c.Seq = 2
325 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
326 t.Fatal(err)
327 }
328 if r.redrawn-before >= r.width*r.height {
329 t.Fatal("expected partial redraw")
330 }
331 check("partial")
332}