feat/xdg-foreign fdbb95e

hdr_composition_test.go RawHistoryFolder

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, &region)
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}

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