v0.8.0 446d1e3

hdr_composition_test.go RawHistoryFolder

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}

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 23

Tags 10

↑↓ moveenter openesc close