detached 5731344

feat(hdr): HDR content from Wayland clients (#11)

* feat(drm): report confirmed HDR10 state with scanout formats

* feat(dmabuf): import packed 10-bit client buffers

* feat(wayland): support color management v1 HDR client descriptions

* feat(wayland): send surface color descriptions to outputs

* feat(vulkan): compose PQ client surfaces

* feat(drm): scan out fullscreen HDR client buffers

* docs(hdr): describe HDR output, HDR clients and GPU support

* fix(drm): withdraw HDR state and scanout formats on power off

* fix(wayland): reject unsupported client color combinations

* test(vulkan): verify colored PQ and 2101010 layouts

* feat(vulkan): compose HDR surfaces in linear fp16

* feat(color): support extended-linear fp16 client surfaces

* feat(wayland): route committed YUV color representation

* feat(wayland): validate NV12 and P010 dma-buf planes

* fix(drm): keep YUV client buffers off hardware planes

* feat(vulkan): import supported planar YUV dma-bufs

* docs(hdr): describe Firefox YUV support and GPU constraints

* feat(vulkan): import shared-plane dma-bufs without disjoint binding

* docs(hdr): describe capability-based video support

* fix(vulkan): require fp16 composition format features for HDR

* test(headless): expose virtual HDR output for protocol tracing

* test(vulkan): allow virtual HDR target without display modifiers

* test(vulkan): reprobe HDR readback target features for virtual output

* fix(headless): report only confirmed virtual HDR state

* fix(vulkan): use compatible P010 plane formats

* fix(vulkan): defer fp16 pipeline until HDR activation

* fix(vulkan): decode HDR solid fills into linear light

* fix(vulkan): wait on distinct YUV plane implicit fences

* fix(drm): exclude YUV formats from scanout offers

* fix(wayland): describe SDR with supported sRGB transfer

* test(wayland): cover representation buffer and queued commits

* fix(vulkan): keep fence descriptor selection on stack

* test(wayland): assert SDR output transfer function

* fix(vulkan): grow SDR readback buffer only on HDR transition

* fix(vulkan): keep the readback buffer when its replacement fails

* perf(vulkan): convert HDR captures through a binary16 lookup table
commit
57313440a58ff8fc2a618009a01c98c90146c98e
author
bnema · · 2026-09-27 09:14 UTC
committer
GitHub · · 2026-09-27 09:14 UTC
parent
c746896
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
49 files · +3199 -142
M cmd/neferwl/main.go +5 -1
@@ -118,6 +118,7 @@ func run() error {
118118 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
119119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
120120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
121+ headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
121122 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
122123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
123124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -133,6 +134,9 @@ func run() error {
133134 fmt.Fprintln(os.Stderr, err)
134135 return err
135136 }
137+ if *headlessHDR && *backend != "headless" {
138+ return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")}
139+ }
136140 if *screenshot != "" && *backend != "headless" {
137141 err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
138142 fmt.Fprintln(os.Stderr, err)
@@ -227,7 +231,7 @@ func run() error {
227231 for _, w := range warnings {
228232 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
229233 }
230- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
234+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
231235 // SIGINT and SIGTERM cancel the context and are clean exits.
232236 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
233237 err = nil
@@ -118,6 +118,7 @@ func run() error {
118 inputPath := flags.String("input", "", "headless input script path (- for stdin)")118 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
121+ headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
121 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")122 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
122 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
123 configFlag := flags.String("config", "", "config path (empty uses XDG default)")124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -133,6 +134,9 @@ func run() error {
133 fmt.Fprintln(os.Stderr, err)134 fmt.Fprintln(os.Stderr, err)
134 return err135 return err
135 }136 }
137+ if *headlessHDR && *backend != "headless" {
138+ return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")}
139+ }
136 if *screenshot != "" && *backend != "headless" {140 if *screenshot != "" && *backend != "headless" {
137 err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}141 err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
138 fmt.Fprintln(os.Stderr, err)142 fmt.Fprintln(os.Stderr, err)
@@ -227,7 +231,7 @@ func run() error {
227 for _, w := range warnings {231 for _, w := range warnings {
228 configLog.Warn().Int("line", w.Line).Msg(w.Msg)232 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
229 }233 }
230- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})234+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
231 // SIGINT and SIGTERM cancel the context and are clean exits.235 // SIGINT and SIGTERM cancel the context and are clean exits.
232 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {236 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
233 err = nil237 err = nil
M docs/config.md +15 -0
@@ -42,6 +42,21 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4242 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
4343 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
4444
45+## HDR
46+
47+`output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits.
48+
49+HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and video players, can present HDR through `wp_color_manager_v1` and `wp_color_representation_v1`. Some apps require HDR to be enabled in their own settings. X11 clients stay SDR.
50+
51+Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app.
52+
53+- Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
54+- Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending.
55+- Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped).
56+- The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
57+
58+HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 composition and 10-bit export formats/features. Unsupported capabilities fall back to SDR. NVIDIA is not supported yet (tested status).
59+
4560 ## Binds
4661
4762 `bind.<keys> = <action>`. Modifiers: `cmd` (see `keyboard.cmd`), `shift`, `ctrl`, `alt`, `super`.
@@ -42,6 +42,21 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
42 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |42 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
43 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |43 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
44 44
45+## HDR
46+
47+`output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits.
48+
49+HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and video players, can present HDR through `wp_color_manager_v1` and `wp_color_representation_v1`. Some apps require HDR to be enabled in their own settings. X11 clients stay SDR.
50+
51+Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app.
52+
53+- Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
54+- Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending.
55+- Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped).
56+- The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
57+
58+HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 composition and 10-bit export formats/features. Unsupported capabilities fall back to SDR. NVIDIA is not supported yet (tested status).
59+
45 ## Binds60 ## Binds
46 61
47 `bind.<keys> = <action>`. Modifiers: `cmd` (see `keyboard.cmd`), `shift`, `ctrl`, `alt`, `super`.62 `bind.<keys> = <action>`. Modifiers: `cmd` (see `keyboard.cmd`), `shift`, `ctrl`, `alt`, `super`.
M internal/adapters/drm/hdr_test.go +1 -1
@@ -53,7 +53,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
5353 }
5454 }
5555 o.hdrOn = true
56- if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" {
56+ if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
5757 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
5858 }
5959 if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {
@@ -53,7 +53,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
53 }53 }
54 }54 }
55 o.hdrOn = true55 o.hdrOn = true
56- if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" {56+ if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
58 }58 }
59 if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {59 if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {
M internal/adapters/drm/output.go +26 -5
@@ -480,6 +480,8 @@ func (o *Output) powerOff() error {
480480 }
481481 o.off, o.vrrOn = true, false
482482 o.log.Info().Str("connector", o.conn.name).Msg("power off")
483+ // An inactive output offers neither HDR nor direct scanout.
484+ o.sendFormats()
483485 return nil
484486 }
485487
@@ -492,8 +494,15 @@ func (o *Output) sendFormats() {
492494 return
493495 }
494496 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
495- if o.scanout && !o.hdrOn {
496- f.Formats = o.scanoutFormats(o.sampled)
497+ if o.hdrOn && !o.off {
498+ f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.MaxLuminance, MaxFrameAverage: o.hdr.MaxFrameAverage, MinLuminance: o.hdr.MinLuminance}
499+ }
500+ if o.scanout && !o.off {
501+ for _, format := range o.scanoutFormats(o.sampled) {
502+ if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdrOn {
503+ f.Formats = append(f.Formats, format)
504+ }
505+ }
497506 }
498507 select {
499508 case o.formats <- f:
@@ -738,10 +747,22 @@ func boolValue(b bool) uint64 {
738747 // composed.
739748 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
740749 reason := "disabled"
741- if o.hdrOn {
742- reason = "hdr"
743- } else if o.scanout {
750+ if o.scanout {
744751 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
752+ if reason == "" && isYUVFormat(c.DMABuf.Format) {
753+ reason = "yuv"
754+ }
755+ if o.hdrOn && reason == "" {
756+ switch {
757+ case !c.Color.IsPQ2020():
758+ reason = "hdr_sdr_content"
759+ case !isTenBit(c.DMABuf.Format):
760+ reason = "hdr_format"
761+ }
762+ } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
763+ // An SDR connector must never show raw PQ values.
764+ reason = "sdr_pq_content"
765+ }
745766 }
746767 if reason == "" {
747768 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
@@ -480,6 +480,8 @@ func (o *Output) powerOff() error {
480 }480 }
481 o.off, o.vrrOn = true, false481 o.off, o.vrrOn = true, false
482 o.log.Info().Str("connector", o.conn.name).Msg("power off")482 o.log.Info().Str("connector", o.conn.name).Msg("power off")
483+ // An inactive output offers neither HDR nor direct scanout.
484+ o.sendFormats()
483 return nil485 return nil
484 }486 }
485 487
@@ -492,8 +494,15 @@ func (o *Output) sendFormats() {
492 return494 return
493 }495 }
494 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}496 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
495- if o.scanout && !o.hdrOn {497+ if o.hdrOn && !o.off {
496- f.Formats = o.scanoutFormats(o.sampled)498+ f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.MaxLuminance, MaxFrameAverage: o.hdr.MaxFrameAverage, MinLuminance: o.hdr.MinLuminance}
499+ }
500+ if o.scanout && !o.off {
501+ for _, format := range o.scanoutFormats(o.sampled) {
502+ if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdrOn {
503+ f.Formats = append(f.Formats, format)
504+ }
505+ }
497 }506 }
498 select {507 select {
499 case o.formats <- f:508 case o.formats <- f:
@@ -738,10 +747,22 @@ func boolValue(b bool) uint64 {
738 // composed.747 // composed.
739 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {748 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
740 reason := "disabled"749 reason := "disabled"
741- if o.hdrOn {750+ if o.scanout {
742- reason = "hdr"
743- } else if o.scanout {
744 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())751 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
752+ if reason == "" && isYUVFormat(c.DMABuf.Format) {
753+ reason = "yuv"
754+ }
755+ if o.hdrOn && reason == "" {
756+ switch {
757+ case !c.Color.IsPQ2020():
758+ reason = "hdr_sdr_content"
759+ case !isTenBit(c.DMABuf.Format):
760+ reason = "hdr_format"
761+ }
762+ } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
763+ // An SDR connector must never show raw PQ values.
764+ reason = "sdr_pq_content"
765+ }
745 }766 }
746 if reason == "" {767 if reason == "" {
747 fb, reason = o.scanoutFB(c.DMABuf, time.Now())768 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
M internal/adapters/drm/overlay.go +1 -1
@@ -74,7 +74,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7474 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
7575 }
7676 c := surfaces[w.ID]
77- if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 {
77+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 {
7878 pick = w
7979 continue
8080 }
@@ -74,7 +74,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
74 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"74 return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
75 }75 }
76 c := surfaces[w.ID]76 c := surfaces[w.ID]
77- if c.DMABuf != nil && c.Opaque && len(c.Children) == 0 {77+ if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 {
78 pick = w78 pick = w
79 continue79 continue
80 }80 }
M internal/adapters/drm/overlay_test.go +5 -0
@@ -45,6 +45,11 @@ func TestOverlayCandidate(t *testing.T) {
4545 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
4646 t.Fatalf("candidate %v %q", w.ID, reason)
4747 }
48+ c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
49+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
50+ t.Fatalf("YUV overlay reason %q", reason)
51+ }
52+ _, c = overlayScene()
4853 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
4954 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
5055 t.Fatalf("reason %q", reason)
@@ -45,6 +45,11 @@ func TestOverlayCandidate(t *testing.T) {
45 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {45 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
46 t.Fatalf("candidate %v %q", w.ID, reason)46 t.Fatalf("candidate %v %q", w.ID, reason)
47 }47 }
48+ c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
49+ if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
50+ t.Fatalf("YUV overlay reason %q", reason)
51+ }
52+ _, c = overlayScene()
48 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})53 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
49 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {54 if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
50 t.Fatalf("reason %q", reason)55 t.Fatalf("reason %q", reason)
M internal/adapters/drm/scanout.go +16 -0
@@ -185,7 +185,23 @@ func (o *Output) scanoutFormats(sampled []ports.DMABufFormat) []ports.DMABufForm
185185 return out
186186 }
187187
188+// isYUVFormat identifies video formats which are always composed, never scanned out.
189+func isYUVFormat(format uint32) bool {
190+ return format == fourccNV12 || format == fourccP010
191+}
192+
193+// isTenBit identifies the packed 2101010 formats supported by the renderer.
194+func isTenBit(format uint32) bool {
195+ switch format {
196+ case fourccXR30, fourccAR30, fourccXB30, fourccAB30:
197+ return true
198+ }
199+ return false
200+}
201+
188202 const (
203+ fourccNV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
204+ fourccP010 = 'P' | '0'<<8 | '1'<<16 | '0'<<24
189205 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
190206 fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
191207 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24
@@ -185,7 +185,23 @@ func (o *Output) scanoutFormats(sampled []ports.DMABufFormat) []ports.DMABufForm
185 return out185 return out
186 }186 }
187 187
188+// isYUVFormat identifies video formats which are always composed, never scanned out.
189+func isYUVFormat(format uint32) bool {
190+ return format == fourccNV12 || format == fourccP010
191+}
192+
193+// isTenBit identifies the packed 2101010 formats supported by the renderer.
194+func isTenBit(format uint32) bool {
195+ switch format {
196+ case fourccXR30, fourccAR30, fourccXB30, fourccAB30:
197+ return true
198+ }
199+ return false
200+}
201+
188 const (202 const (
203+ fourccNV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
204+ fourccP010 = 'P' | '0'<<8 | '1'<<16 | '0'<<24
189 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24205 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
190 fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24206 fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
191 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24207 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24
M internal/adapters/drm/scanout_test.go +78 -2
@@ -107,8 +107,8 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
107107 o, k, _ := testOutput(t)
108108 o.cursor = nil
109109 o.scanout, o.device = on, 9
110- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: tiled}}
111- o.sampled = []ports.DMABufFormat{{Format: fourccARGB, Modifier: tiled}, {Format: fourccARGB, Modifier: 0}}
110+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: tiled}, {Format: fourccNV12, Modifier: tiled}, {Format: fourccP010, Modifier: tiled}}
111+ o.sampled = []ports.DMABufFormat{{Format: fourccARGB, Modifier: tiled}, {Format: fourccARGB, Modifier: 0}, {Format: fourccNV12, Modifier: tiled}, {Format: fourccP010, Modifier: tiled}}
112112 ch := make(chan ports.OutputFormats, 1)
113113 o.formats = ch
114114 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
@@ -125,6 +125,82 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
125125 }
126126 }
127127
128+func TestHDRScanoutDecision(t *testing.T) {
129+ o, k, _ := testOutput(t)
130+ o.hdrOn = true
131+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
132+ scene := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
133+ c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}
134+ check := func(want string) {
135+ t.Helper()
136+ fb, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})
137+ if o.reason != want || (fb != 0) != (want == "") {
138+ t.Fatalf("fb %d reason %q, want %q", fb, o.reason, want)
139+ }
140+ }
141+ check("hdr_sdr_content")
142+ c.Color = ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
143+ c.DMABuf.Format = fourccXRGB
144+ check("hdr_format")
145+ c.DMABuf.Format = fourccP010
146+ check("yuv")
147+ c.DMABuf.Format = fourccXR30
148+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(uint32(77), nil).Once()
149+ check("")
150+ o.hdrOn = false
151+ check("sdr_pq_content")
152+}
153+
154+func TestModesetReportsConfirmedHDR(t *testing.T) {
155+ o, k, _ := testOutput(t)
156+ o.cursor = nil
157+ o.hdrOn = true
158+ o.hdr = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
159+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
160+ o.sampled = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
161+ ch := make(chan ports.OutputFormats, 2)
162+ o.formats = ch
163+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Twice()
164+ k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
165+ if err := o.modeset(); err != nil {
166+ t.Fatal(err)
167+ }
168+ if f := <-ch; f.HDR == nil || f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != 0.005 || len(f.Formats) != 1 || f.Formats[0].Format != fourccXR30 {
169+ t.Fatalf("HDR report: %+v", f)
170+ }
171+ o.hdrOn = false // fallback to SDR
172+ if err := o.modeset(); err != nil {
173+ t.Fatal(err)
174+ }
175+ if f := <-ch; f.HDR != nil || len(f.Formats) != 1 || f.Formats[0].Format != fourccXRGB {
176+ t.Fatalf("SDR fallback report: %+v", f)
177+ }
178+}
179+
180+// Power off withdraws HDR and scanout formats: clients must not target an
181+// inactive output.
182+func TestPowerOffWithdrawsHDRAndFormats(t *testing.T) {
183+ o, _, _ := testOutput(t)
184+ o.cursor = nil
185+ o.hdrOn, o.scanout = true, true
186+ o.hdr = hdrCapability{MaxLuminance: 1000}
187+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
188+ o.sampled = []ports.DMABufFormat{{Format: fourccXR30}}
189+ ch := make(chan ports.OutputFormats, 1)
190+ o.formats = ch
191+ if err := o.powerOff(); err != nil {
192+ t.Fatal(err)
193+ }
194+ select {
195+ case f := <-ch:
196+ if f.HDR != nil || len(f.Formats) != 0 {
197+ t.Fatalf("power-off report: %+v", f)
198+ }
199+ default:
200+ t.Fatal("no report after power off")
201+ }
202+}
203+
128204 // A plane without IN_FORMATS (driver without modifiers) scans out its
129205 // GETPLANE formats, linear.
130206 func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
@@ -107,8 +107,8 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
107 o, k, _ := testOutput(t)107 o, k, _ := testOutput(t)
108 o.cursor = nil108 o.cursor = nil
109 o.scanout, o.device = on, 9109 o.scanout, o.device = on, 9
110- o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: tiled}}110+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB, Modifier: tiled}, {Format: fourccNV12, Modifier: tiled}, {Format: fourccP010, Modifier: tiled}}
111- o.sampled = []ports.DMABufFormat{{Format: fourccARGB, Modifier: tiled}, {Format: fourccARGB, Modifier: 0}}111+ o.sampled = []ports.DMABufFormat{{Format: fourccARGB, Modifier: tiled}, {Format: fourccARGB, Modifier: 0}, {Format: fourccNV12, Modifier: tiled}, {Format: fourccP010, Modifier: tiled}}
112 ch := make(chan ports.OutputFormats, 1)112 ch := make(chan ports.OutputFormats, 1)
113 o.formats = ch113 o.formats = ch
114 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()114 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
@@ -125,6 +125,82 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
125 }125 }
126 }126 }
127 127
128+func TestHDRScanoutDecision(t *testing.T) {
129+ o, k, _ := testOutput(t)
130+ o.hdrOn = true
131+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
132+ scene := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
133+ c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}
134+ check := func(want string) {
135+ t.Helper()
136+ fb, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})
137+ if o.reason != want || (fb != 0) != (want == "") {
138+ t.Fatalf("fb %d reason %q, want %q", fb, o.reason, want)
139+ }
140+ }
141+ check("hdr_sdr_content")
142+ c.Color = ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
143+ c.DMABuf.Format = fourccXRGB
144+ check("hdr_format")
145+ c.DMABuf.Format = fourccP010
146+ check("yuv")
147+ c.DMABuf.Format = fourccXR30
148+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(uint32(77), nil).Once()
149+ check("")
150+ o.hdrOn = false
151+ check("sdr_pq_content")
152+}
153+
154+func TestModesetReportsConfirmedHDR(t *testing.T) {
155+ o, k, _ := testOutput(t)
156+ o.cursor = nil
157+ o.hdrOn = true
158+ o.hdr = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
159+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
160+ o.sampled = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
161+ ch := make(chan ports.OutputFormats, 2)
162+ o.formats = ch
163+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Twice()
164+ k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
165+ if err := o.modeset(); err != nil {
166+ t.Fatal(err)
167+ }
168+ if f := <-ch; f.HDR == nil || f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != 0.005 || len(f.Formats) != 1 || f.Formats[0].Format != fourccXR30 {
169+ t.Fatalf("HDR report: %+v", f)
170+ }
171+ o.hdrOn = false // fallback to SDR
172+ if err := o.modeset(); err != nil {
173+ t.Fatal(err)
174+ }
175+ if f := <-ch; f.HDR != nil || len(f.Formats) != 1 || f.Formats[0].Format != fourccXRGB {
176+ t.Fatalf("SDR fallback report: %+v", f)
177+ }
178+}
179+
180+// Power off withdraws HDR and scanout formats: clients must not target an
181+// inactive output.
182+func TestPowerOffWithdrawsHDRAndFormats(t *testing.T) {
183+ o, _, _ := testOutput(t)
184+ o.cursor = nil
185+ o.hdrOn, o.scanout = true, true
186+ o.hdr = hdrCapability{MaxLuminance: 1000}
187+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
188+ o.sampled = []ports.DMABufFormat{{Format: fourccXR30}}
189+ ch := make(chan ports.OutputFormats, 1)
190+ o.formats = ch
191+ if err := o.powerOff(); err != nil {
192+ t.Fatal(err)
193+ }
194+ select {
195+ case f := <-ch:
196+ if f.HDR != nil || len(f.Formats) != 0 {
197+ t.Fatalf("power-off report: %+v", f)
198+ }
199+ default:
200+ t.Fatal("no report after power off")
201+ }
202+}
203+
128 // A plane without IN_FORMATS (driver without modifiers) scans out its204 // A plane without IN_FORMATS (driver without modifiers) scans out its
129 // GETPLANE formats, linear.205 // GETPLANE formats, linear.
130 func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {206 func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
M internal/adapters/headless/output.go +26 -0
@@ -24,6 +24,8 @@ type Options struct {
2424 LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
2525 Width, Height int
2626 ScreenshotDir string
27+ HDR bool // virtual HDR output for protocol testing only
28+ Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
2729 Log zerowrap.Logger
2830 NewRenderer func(w, h int) (ports.Renderer, error)
2931 // Name and Presented report what the output has read after each
@@ -39,6 +41,30 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
3941 return fmt.Errorf("create renderer: %w", err)
4042 }
4143 defer r.Close()
44+ var confirmed *ports.OutputHDR
45+ if opts.HDR {
46+ r.SetHDR(203)
47+ if bufs, err := r.ExportTargets(1, nil); err != nil {
48+ opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
49+ r.SetHDR(0)
50+ _, _ = r.ExportTargets(0, nil)
51+ } else {
52+ for _, b := range bufs {
53+ for _, p := range b.Planes {
54+ _ = p.File.Close()
55+ }
56+ }
57+ confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}
58+ opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled")
59+ }
60+ if opts.Formats != nil {
61+ select {
62+ case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}:
63+ case <-ctx.Done():
64+ return nil
65+ }
66+ }
67+ }
4268 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
4369 var scene ports.Scene
4470 haveScene, dirty := false, false
@@ -24,6 +24,8 @@ type Options struct {
24 LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)24 LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
25 Width, Height int25 Width, Height int
26 ScreenshotDir string26 ScreenshotDir string
27+ HDR bool // virtual HDR output for protocol testing only
28+ Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
27 Log zerowrap.Logger29 Log zerowrap.Logger
28 NewRenderer func(w, h int) (ports.Renderer, error)30 NewRenderer func(w, h int) (ports.Renderer, error)
29 // Name and Presented report what the output has read after each31 // Name and Presented report what the output has read after each
@@ -39,6 +41,30 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
39 return fmt.Errorf("create renderer: %w", err)41 return fmt.Errorf("create renderer: %w", err)
40 }42 }
41 defer r.Close()43 defer r.Close()
44+ var confirmed *ports.OutputHDR
45+ if opts.HDR {
46+ r.SetHDR(203)
47+ if bufs, err := r.ExportTargets(1, nil); err != nil {
48+ opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
49+ r.SetHDR(0)
50+ _, _ = r.ExportTargets(0, nil)
51+ } else {
52+ for _, b := range bufs {
53+ for _, p := range b.Planes {
54+ _ = p.File.Close()
55+ }
56+ }
57+ confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}
58+ opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled")
59+ }
60+ if opts.Formats != nil {
61+ select {
62+ case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}:
63+ case <-ctx.Done():
64+ return nil
65+ }
66+ }
67+ }
42 surfaces := make(map[ports.WindowID]ports.SurfaceContent)68 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
43 var scene ports.Scene69 var scene ports.Scene
44 haveScene, dirty := false, false70 haveScene, dirty := false, false
M internal/adapters/headless/output_test.go +50 -0
@@ -12,6 +12,7 @@ import (
1212 "testing"
1313 "time"
1414
15+ "github.com/bnema/neferwl/internal/logging"
1516 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1617 "github.com/bnema/neferwl/internal/ports"
1718 "github.com/stretchr/testify/mock"
@@ -248,3 +249,52 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
248249 t.Fatal(err)
249250 }
250251 }
252+
253+// Only the output which successfully prepared HDR reports confirmed HDR.
254+// The fallback must explicitly withdraw it, and close exported descriptors.
255+func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
256+ for _, success := range []bool{true, false} {
257+ t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
258+ r := portsmocks.NewMockRenderer(t)
259+ r.EXPECT().SetHDR(float64(203)).Return().Once()
260+ if success {
261+ f, err := os.CreateTemp(t.TempDir(), "target")
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
266+ t.Cleanup(func() {
267+ if _, err := f.Stat(); err == nil {
268+ t.Error("exported fd not closed")
269+ }
270+ })
271+ } else {
272+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return(nil, errors.New("no compatible target")).Once()
273+ r.EXPECT().SetHDR(float64(0)).Return().Once()
274+ r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
275+ }
276+ r.EXPECT().Close().Return().Once()
277+ formats := make(chan ports.OutputFormats, 1)
278+ ctx, cancel := context.WithCancel(context.Background())
279+ done := make(chan error, 1)
280+ go func() {
281+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
282+ }()
283+ select {
284+ case f := <-formats:
285+ if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
286+ t.Fatalf("report %+v, success=%t", f, success)
287+ }
288+ if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
289+ t.Fatalf("HDR metadata %+v", f.HDR)
290+ }
291+ case <-time.After(2 * time.Second):
292+ t.Fatal("no format report")
293+ }
294+ cancel()
295+ if err := <-done; err != nil {
296+ t.Fatal(err)
297+ }
298+ })
299+ }
300+}
@@ -12,6 +12,7 @@ import (
12 "testing"12 "testing"
13 "time"13 "time"
14 14
15+ "github.com/bnema/neferwl/internal/logging"
15 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"16 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
16 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
17 "github.com/stretchr/testify/mock"18 "github.com/stretchr/testify/mock"
@@ -248,3 +249,52 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
248 t.Fatal(err)249 t.Fatal(err)
249 }250 }
250 }251 }
252+
253+// Only the output which successfully prepared HDR reports confirmed HDR.
254+// The fallback must explicitly withdraw it, and close exported descriptors.
255+func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
256+ for _, success := range []bool{true, false} {
257+ t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
258+ r := portsmocks.NewMockRenderer(t)
259+ r.EXPECT().SetHDR(float64(203)).Return().Once()
260+ if success {
261+ f, err := os.CreateTemp(t.TempDir(), "target")
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
266+ t.Cleanup(func() {
267+ if _, err := f.Stat(); err == nil {
268+ t.Error("exported fd not closed")
269+ }
270+ })
271+ } else {
272+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return(nil, errors.New("no compatible target")).Once()
273+ r.EXPECT().SetHDR(float64(0)).Return().Once()
274+ r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
275+ }
276+ r.EXPECT().Close().Return().Once()
277+ formats := make(chan ports.OutputFormats, 1)
278+ ctx, cancel := context.WithCancel(context.Background())
279+ done := make(chan error, 1)
280+ go func() {
281+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil)
282+ }()
283+ select {
284+ case f := <-formats:
285+ if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
286+ t.Fatalf("report %+v, success=%t", f, success)
287+ }
288+ if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
289+ t.Fatalf("HDR metadata %+v", f.HDR)
290+ }
291+ case <-time.After(2 * time.Second):
292+ t.Fatal("no format report")
293+ }
294+ cancel()
295+ if err := <-done; err != nil {
296+ t.Fatal(err)
297+ }
298+ })
299+ }
300+}
M internal/adapters/vulkan/compose.go +41 -12
@@ -20,6 +20,7 @@ import (
2020
2121 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv
2222 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv
23+//go:generate glslc -O --target-env=vulkan1.3 shaders/compose_hdr.frag -o shaders/compose_hdr.frag.spv
2324 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv
2425 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv
2526
@@ -28,6 +29,8 @@ var (
2829 composeVert []byte
2930 //go:embed shaders/compose.frag.spv
3031 composeFrag []byte
32+ //go:embed shaders/compose_hdr.frag.spv
33+ composeHDRFrag []byte
3134 )
3235
3336 // Draw modes and flags (shaders: modeSolid, modeImage, flag*).
@@ -36,8 +39,12 @@ const (
3639 modeImage = 1
3740 modeBuffer = 2
3841
39- flagOpaque = 1
40- flagExact = 2
42+ flagOpaque = 1
43+ flagExact = 2
44+ flagPQ = 4
45+ flagExtendedLinear = 8
46+ flagYUV = 16
47+ flagP010 = 32
4148 )
4249
4350 // pushConstants is struct Draw of the shaders (std430 push constant block).
@@ -60,10 +67,11 @@ type draw struct {
6067
6168 // composer is the pipeline state and the objects every draw can bind.
6269 type composer struct {
63- setLayout vk.DescriptorSetLayout
64- layout vk.PipelineLayout
65- pipeline vk.Pipeline
66- sampler vk.Sampler
70+ setLayout vk.DescriptorSetLayout
71+ layout vk.PipelineLayout
72+ pipeline vk.Pipeline
73+ hdrPipeline vk.Pipeline
74+ sampler vk.Sampler
6775 // Placeholders for the binding a draw does not use: every binding
6876 // the shader declares must be valid.
6977 dummyImage vk.Image
@@ -79,8 +87,9 @@ func (r *Renderer) createComposer() error {
7987 bindings := []vk.DescriptorSetLayoutBinding{
8088 {Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
8189 {Binding: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
90+ {Binding: 2, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
8291 }
83- sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 2, Bindings: &bindings[0]}
92+ sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: uint32(len(bindings)), Bindings: &bindings[0]}
8493 if err := checked("vkCreateDescriptorSetLayout", d.CreateDescriptorSetLayout(r.device, &sli, nil, &c.setLayout)); err != nil {
8594 return err
8695 }
@@ -100,7 +109,10 @@ func (r *Renderer) createComposer() error {
100109 }
101110
102111 func (r *Renderer) createPipeline() error {
103- return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline)
112+ if err := r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline); err != nil {
113+ return err
114+ }
115+ return nil
104116 }
105117
106118 func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error {
@@ -187,9 +199,9 @@ func (r *Renderer) createDummies() error {
187199
188200 // newSet allocates a descriptor set from its own pool (the bindings lack
189201 // vkFreeDescriptorSets: the pool is destroyed with the object).
190-func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer) (vk.DescriptorPool, vk.DescriptorSet, error) {
202+func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer, chroma ...vk.ImageView) (vk.DescriptorPool, vk.DescriptorSet, error) {
191203 d := r.dd
192- sizes := []vk.DescriptorPoolSize{{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1}, {Type: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1}}
204+ sizes := []vk.DescriptorPoolSize{{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 2}, {Type: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1}}
193205 dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 2, PoolSizes: &sizes[0]}
194206 var pool vk.DescriptorPool
195207 if err := checked("vkCreateDescriptorPool", d.CreateDescriptorPool(r.device, &dpi, nil, &pool)); err != nil {
@@ -202,12 +214,17 @@ func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer) (vk.DescriptorPoo
202214 return 0, 0, err
203215 }
204216 img := vk.DescriptorImageInfo{Sampler: r.compose.sampler, ImageView: view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
217+ uv := img
218+ if len(chroma) > 0 {
219+ uv.ImageView = chroma[0]
220+ }
205221 buf := vk.DescriptorBufferInfo{Buffer: buffer, Range: wholeSize}
206222 writes := []vk.WriteDescriptorSet{
207223 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img},
208224 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 1, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, BufferInfo: &buf},
225+ {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 2, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &uv},
209226 }
210- d.UpdateDescriptorSets(r.device, 2, &writes[0], 0, nil)
227+ d.UpdateDescriptorSets(r.device, uint32(len(writes)), &writes[0], 0, nil)
211228 return pool, set, nil
212229 }
213230
@@ -232,6 +249,9 @@ func (r *Renderer) destroyComposer() {
232249 if c.pipeline != 0 {
233250 d.DestroyPipeline(r.device, c.pipeline, nil)
234251 }
252+ if c.hdrPipeline != 0 {
253+ d.DestroyPipeline(r.device, c.hdrPipeline, nil)
254+ }
235255 if c.layout != 0 {
236256 d.DestroyPipelineLayout(r.device, c.layout, nil)
237257 }
@@ -299,13 +319,22 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, mode uint32
299319 func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]uint8, ds []draw, keep bool) {
300320 d, c := r.dd, &r.compose
301321 clear := vk.ClearValue{math.Float32bits(float32(bg[0]) / 255), math.Float32bits(float32(bg[1]) / 255), math.Float32bits(float32(bg[2]) / 255), math.Float32bits(1)}
322+ if r.hdrNits > 0 {
323+ for i := 0; i < 3; i++ {
324+ clear[i] = math.Float32bits(float32(srgbToLinear(float64(bg[i]) / 255)))
325+ }
326+ }
302327 attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear}
303328 if keep {
304329 attachment.LoadOp = vk.AttachmentLoadOpLoad
305330 }
306331 info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
307332 d.CmdBeginRendering(cmd, &info)
308- d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, c.pipeline)
333+ pipeline := c.pipeline
334+ if r.hdrNits > 0 {
335+ pipeline = c.hdrPipeline
336+ }
337+ d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, pipeline)
309338 var bound vk.DescriptorSet
310339 for i := range ds {
311340 dr := &ds[i]
@@ -20,6 +20,7 @@ import (
20 20
21 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv21 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv
22 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv22 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv
23+//go:generate glslc -O --target-env=vulkan1.3 shaders/compose_hdr.frag -o shaders/compose_hdr.frag.spv
23 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv24 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv
24 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv25 //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv
25 26
@@ -28,6 +29,8 @@ var (
28 composeVert []byte29 composeVert []byte
29 //go:embed shaders/compose.frag.spv30 //go:embed shaders/compose.frag.spv
30 composeFrag []byte31 composeFrag []byte
32+ //go:embed shaders/compose_hdr.frag.spv
33+ composeHDRFrag []byte
31 )34 )
32 35
33 // Draw modes and flags (shaders: modeSolid, modeImage, flag*).36 // Draw modes and flags (shaders: modeSolid, modeImage, flag*).
@@ -36,8 +39,12 @@ const (
36 modeImage = 139 modeImage = 1
37 modeBuffer = 240 modeBuffer = 2
38 41
39- flagOpaque = 142+ flagOpaque = 1
40- flagExact = 243+ flagExact = 2
44+ flagPQ = 4
45+ flagExtendedLinear = 8
46+ flagYUV = 16
47+ flagP010 = 32
41 )48 )
42 49
43 // pushConstants is struct Draw of the shaders (std430 push constant block).50 // pushConstants is struct Draw of the shaders (std430 push constant block).
@@ -60,10 +67,11 @@ type draw struct {
60 67
61 // composer is the pipeline state and the objects every draw can bind.68 // composer is the pipeline state and the objects every draw can bind.
62 type composer struct {69 type composer struct {
63- setLayout vk.DescriptorSetLayout70+ setLayout vk.DescriptorSetLayout
64- layout vk.PipelineLayout71+ layout vk.PipelineLayout
65- pipeline vk.Pipeline72+ pipeline vk.Pipeline
66- sampler vk.Sampler73+ hdrPipeline vk.Pipeline
74+ sampler vk.Sampler
67 // Placeholders for the binding a draw does not use: every binding75 // Placeholders for the binding a draw does not use: every binding
68 // the shader declares must be valid.76 // the shader declares must be valid.
69 dummyImage vk.Image77 dummyImage vk.Image
@@ -79,8 +87,9 @@ func (r *Renderer) createComposer() error {
79 bindings := []vk.DescriptorSetLayoutBinding{87 bindings := []vk.DescriptorSetLayoutBinding{
80 {Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},88 {Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
81 {Binding: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},89 {Binding: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
90+ {Binding: 2, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit},
82 }91 }
83- sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 2, Bindings: &bindings[0]}92+ sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: uint32(len(bindings)), Bindings: &bindings[0]}
84 if err := checked("vkCreateDescriptorSetLayout", d.CreateDescriptorSetLayout(r.device, &sli, nil, &c.setLayout)); err != nil {93 if err := checked("vkCreateDescriptorSetLayout", d.CreateDescriptorSetLayout(r.device, &sli, nil, &c.setLayout)); err != nil {
85 return err94 return err
86 }95 }
@@ -100,7 +109,10 @@ func (r *Renderer) createComposer() error {
100 }109 }
101 110
102 func (r *Renderer) createPipeline() error {111 func (r *Renderer) createPipeline() error {
103- return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline)112+ if err := r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline); err != nil {
113+ return err
114+ }
115+ return nil
104 }116 }
105 117
106 func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error {118 func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error {
@@ -187,9 +199,9 @@ func (r *Renderer) createDummies() error {
187 199
188 // newSet allocates a descriptor set from its own pool (the bindings lack200 // newSet allocates a descriptor set from its own pool (the bindings lack
189 // vkFreeDescriptorSets: the pool is destroyed with the object).201 // vkFreeDescriptorSets: the pool is destroyed with the object).
190-func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer) (vk.DescriptorPool, vk.DescriptorSet, error) {202+func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer, chroma ...vk.ImageView) (vk.DescriptorPool, vk.DescriptorSet, error) {
191 d := r.dd203 d := r.dd
192- sizes := []vk.DescriptorPoolSize{{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1}, {Type: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1}}204+ sizes := []vk.DescriptorPoolSize{{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 2}, {Type: vk.DescriptorTypeStorageBuffer, DescriptorCount: 1}}
193 dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 2, PoolSizes: &sizes[0]}205 dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 2, PoolSizes: &sizes[0]}
194 var pool vk.DescriptorPool206 var pool vk.DescriptorPool
195 if err := checked("vkCreateDescriptorPool", d.CreateDescriptorPool(r.device, &dpi, nil, &pool)); err != nil {207 if err := checked("vkCreateDescriptorPool", d.CreateDescriptorPool(r.device, &dpi, nil, &pool)); err != nil {
@@ -202,12 +214,17 @@ func (r *Renderer) newSet(view vk.ImageView, buffer vk.Buffer) (vk.DescriptorPoo
202 return 0, 0, err214 return 0, 0, err
203 }215 }
204 img := vk.DescriptorImageInfo{Sampler: r.compose.sampler, ImageView: view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}216 img := vk.DescriptorImageInfo{Sampler: r.compose.sampler, ImageView: view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
217+ uv := img
218+ if len(chroma) > 0 {
219+ uv.ImageView = chroma[0]
220+ }
205 buf := vk.DescriptorBufferInfo{Buffer: buffer, Range: wholeSize}221 buf := vk.DescriptorBufferInfo{Buffer: buffer, Range: wholeSize}
206 writes := []vk.WriteDescriptorSet{222 writes := []vk.WriteDescriptorSet{
207 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img},223 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img},
208 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 1, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, BufferInfo: &buf},224 {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 1, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeStorageBuffer, BufferInfo: &buf},
225+ {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 2, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &uv},
209 }226 }
210- d.UpdateDescriptorSets(r.device, 2, &writes[0], 0, nil)227+ d.UpdateDescriptorSets(r.device, uint32(len(writes)), &writes[0], 0, nil)
211 return pool, set, nil228 return pool, set, nil
212 }229 }
213 230
@@ -232,6 +249,9 @@ func (r *Renderer) destroyComposer() {
232 if c.pipeline != 0 {249 if c.pipeline != 0 {
233 d.DestroyPipeline(r.device, c.pipeline, nil)250 d.DestroyPipeline(r.device, c.pipeline, nil)
234 }251 }
252+ if c.hdrPipeline != 0 {
253+ d.DestroyPipeline(r.device, c.hdrPipeline, nil)
254+ }
235 if c.layout != 0 {255 if c.layout != 0 {
236 d.DestroyPipelineLayout(r.device, c.layout, nil)256 d.DestroyPipelineLayout(r.device, c.layout, nil)
237 }257 }
@@ -299,13 +319,22 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, mode uint32
299 func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]uint8, ds []draw, keep bool) {319 func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]uint8, ds []draw, keep bool) {
300 d, c := r.dd, &r.compose320 d, c := r.dd, &r.compose
301 clear := vk.ClearValue{math.Float32bits(float32(bg[0]) / 255), math.Float32bits(float32(bg[1]) / 255), math.Float32bits(float32(bg[2]) / 255), math.Float32bits(1)}321 clear := vk.ClearValue{math.Float32bits(float32(bg[0]) / 255), math.Float32bits(float32(bg[1]) / 255), math.Float32bits(float32(bg[2]) / 255), math.Float32bits(1)}
322+ if r.hdrNits > 0 {
323+ for i := 0; i < 3; i++ {
324+ clear[i] = math.Float32bits(float32(srgbToLinear(float64(bg[i]) / 255)))
325+ }
326+ }
302 attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear}327 attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear}
303 if keep {328 if keep {
304 attachment.LoadOp = vk.AttachmentLoadOpLoad329 attachment.LoadOp = vk.AttachmentLoadOpLoad
305 }330 }
306 info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}331 info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
307 d.CmdBeginRendering(cmd, &info)332 d.CmdBeginRendering(cmd, &info)
308- d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, c.pipeline)333+ pipeline := c.pipeline
334+ if r.hdrNits > 0 {
335+ pipeline = c.hdrPipeline
336+ }
337+ d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, pipeline)
309 var bound vk.DescriptorSet338 var bound vk.DescriptorSet
310 for i := range ds {339 for i := range ds {
311 dr := &ds[i]340 dr := &ds[i]
M internal/adapters/vulkan/dmabuf.go +120 -23
@@ -41,6 +41,22 @@ var dmabufFormats = []struct {
4141 {fourcc('X', 'R', '2', '4'), vk.FormatB8g8r8a8Unorm, true},
4242 {fourcc('A', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, false},
4343 {fourcc('X', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, true},
44+ // Vulkan's A2R10G10B10/A2B10G10R10 packed UNORM formats match
45+ // DRM's little-endian 2101010 layout; do not use an sRGB view for PQ.
46+ {fourcc('A', 'R', '3', '0'), vk.FormatA2r10g10b10UnormPack32, false},
47+ {fourcc('X', 'R', '3', '0'), vk.FormatA2r10g10b10UnormPack32, true},
48+ {fourcc('A', 'B', '3', '0'), vk.FormatA2b10g10r10UnormPack32, false},
49+ {fourcc('X', 'B', '3', '0'), vk.FormatA2b10g10r10UnormPack32, true},
50+ // Little-endian ABGR16161616F is RGBA16F; floating-point values
51+ // are linear electrical signals, not sRGB-encoded.
52+ {fourcc('A', 'B', '4', 'H'), vk.FormatR16g16b16a16Sfloat, false},
53+ {fourcc('X', 'B', '4', 'H'), vk.FormatR16g16b16a16Sfloat, true},
54+ {fourcc('N', 'V', '1', '2'), vk.Format(1000156003), true}, // VK_FORMAT_G8_B8R8_2PLANE_420_UNORM
55+ {fourcc('P', '0', '1', '0'), vk.Format(1000156013), true}, // VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
56+}
57+
58+func isYUVFormat(f uint32) bool {
59+ return f == fourcc('N', 'V', '1', '2') || f == fourcc('P', '0', '1', '0')
4460 }
4561
4662 func fourcc(a, b, c, d byte) uint32 {
@@ -85,8 +101,9 @@ func cstring(b []byte) string {
85101 return string(b)
86102 }
87103
88-// probeDMABuf lists the fourcc/modifier pairs the device imports as
89-// single-plane sampled images with linear filtering, and the render node.
104+// probeDMABuf lists the sampled fourcc/modifier pairs supported by the GPU.
105+// Multi-planar formats require mutable per-plane views; disjoint binding is
106+// optional and only used when the device supports it and planes differ.
90107 func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
91108 var sup ports.DMABufSupport
92109 drm := vk.PhysicalDeviceDrmPropertiesEXT{SType: vk.StructureTypePhysicalDeviceDRMPropertiesEXT}
@@ -106,13 +123,15 @@ func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
106123 list.DrmFormatModifierProperties = &mods[0]
107124 r.id.GetPhysicalDeviceFormatProperties2(physical, f.format, &fp)
108125 for _, m := range mods[:list.DrmFormatModifierCount] {
109- // Multi-plane modifiers (compression metadata) are left to a
110- // later step.
111126 need := vk.FormatFeatureFlags(vk.FormatFeatureSampledImageBit | vk.FormatFeatureSampledImageFilterLinearBit)
112- if m.DrmFormatModifierPlaneCount != 1 || m.DrmFormatModifierTilingFeatures&need != need {
127+ planes := uint32(1)
128+ if isYUVFormat(f.fourcc) {
129+ planes = 2
130+ }
131+ if m.DrmFormatModifierPlaneCount != planes || m.DrmFormatModifierTilingFeatures&need != need {
113132 continue
114133 }
115- if r.importable(physical, f.format, m.DrmFormatModifier) {
134+ if r.importable(physical, f.format, m.DrmFormatModifier, isYUVFormat(f.fourcc), false) {
116135 sup.Formats = append(sup.Formats, ports.DMABufFormat{Format: f.fourcc, Modifier: m.DrmFormatModifier})
117136 }
118137 }
@@ -122,10 +141,45 @@ func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
122141
123142 // importable asks whether an image of that format and modifier can be
124143 // imported from a dmabuf.
125-func (r *Renderer) importable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
144+func (r *Renderer) importable(physical vk.PhysicalDevice, format vk.Format, modifier uint64, yuv, disjoint bool) bool {
126145 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
127146 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
128- info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}
147+ flags := vk.ImageCreateFlags(0)
148+ if yuv {
149+ flags = vk.ImageCreateMutableFormatBit
150+ if disjoint {
151+ flags |= imageDisjoint
152+ // Image-format queries alone do not imply the modifier supports
153+ // disjoint plane bindings. Check its tiling features as well.
154+ list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
155+ properties := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
156+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &properties)
157+ if list.DrmFormatModifierCount == 0 {
158+ return false
159+ }
160+ mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount)
161+ list.DrmFormatModifierProperties = &mods[0]
162+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &properties)
163+ found := false
164+ for _, m := range mods[:list.DrmFormatModifierCount] {
165+ if m.DrmFormatModifier == modifier && m.DrmFormatModifierTilingFeatures&0x00400000 != 0 {
166+ found = true
167+ break
168+ }
169+ }
170+ if !found {
171+ return false
172+ }
173+ }
174+ views := yuvViewFormats(format)
175+ formats := imageFormatList{sType: structureImageFormatList, viewFormatCount: 3, viewFormats: &views[0]}
176+ mod.Next = unsafe.Pointer(&formats)
177+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Flags: flags, Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}
178+ extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
179+ out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
180+ return r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) == vk.Success && extOut.ExternalMemoryProperties.ExternalMemoryFeatures&vk.ExternalMemoryFeatureImportableBit != 0
181+ }
182+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Flags: flags, Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}
129183 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
130184 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
131185 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
@@ -140,12 +194,19 @@ func (r *Renderer) DMABuf() ports.DMABufSupport { return r.dmabuf }
140194 // imported is a client dmabuf bound to a VkImage, with the view and set
141195 // the compose shader samples it through.
142196 type imported struct {
143- image vk.Image
144- memory vk.DeviceMemory
145- view vk.ImageView
146- pool vk.DescriptorPool
147- set vk.DescriptorSet
148- fd int // our duplicate of plane 0, for implicit sync
197+ image vk.Image
198+ memory vk.DeviceMemory
199+ view vk.ImageView
200+ chromaView vk.ImageView
201+ memories [2]vk.DeviceMemory
202+ fds [2]int
203+ yuv bool
204+ // disjoint is available for this format/modifier; required only when
205+ // the two planes refer to different underlying DMA-BUF objects.
206+ disjoint bool
207+ pool vk.DescriptorPool
208+ set vk.DescriptorSet
209+ fd int // our duplicate of plane 0, for implicit sync
149210 // last is the frame that drew it, for eviction.
150211 last uint64
151212 }
@@ -164,9 +225,18 @@ func (r *Renderer) importDMABuf(b *ports.DMABuf) (*imported, error) {
164225 return nil, fmt.Errorf("dmabuf unsupported")
165226 }
166227 format, _, ok := vkFormat(b.Format)
167- if !ok || len(b.Planes) != 1 || b.Width <= 0 || b.Height <= 0 {
228+ if !ok || b.Width <= 0 || b.Height <= 0 {
168229 return nil, fmt.Errorf("dmabuf format %#x with %d planes unsupported", b.Format, len(b.Planes))
169230 }
231+ if isYUVFormat(b.Format) {
232+ if len(b.Planes) != 2 || b.Width%2 != 0 || b.Height%2 != 0 {
233+ return nil, fmt.Errorf("invalid YUV buffer geometry or planes")
234+ }
235+ return r.importYUV(b, format)
236+ }
237+ if len(b.Planes) != 1 {
238+ return nil, fmt.Errorf("RGB buffer needs one plane")
239+ }
170240 fd, err := dupFile(b.Planes[0].File)
171241 if err != nil {
172242 return nil, err
@@ -246,15 +316,30 @@ func (r *Renderer) release(im *imported) {
246316 if im.view != 0 {
247317 r.dd.DestroyImageView(r.device, im.view, nil)
248318 }
319+ if im.chromaView != 0 {
320+ r.dd.DestroyImageView(r.device, im.chromaView, nil)
321+ }
249322 if im.image != 0 {
250323 r.dd.DestroyImage(r.device, im.image, nil)
251324 }
252325 if im.memory != 0 {
253326 r.dd.FreeMemory(r.device, im.memory, nil)
254327 }
328+ for i := range im.memories {
329+ if im.memories[i] != 0 {
330+ r.dd.FreeMemory(r.device, im.memories[i], nil)
331+ }
332+ }
255333 if im.fd >= 0 {
256334 unix.Close(im.fd)
257335 }
336+ if im.yuv {
337+ for _, fd := range im.fds {
338+ if fd >= 0 {
339+ unix.Close(fd)
340+ }
341+ }
342+ }
258343 }
259344
260345 // dropUnused frees imports not drawn for importTTL frames (the client
@@ -279,15 +364,27 @@ type dmaBufSync struct {
279364 const ioctlExportSyncFile = 0xc0086202
280365 const dmaBufSyncRead = 1
281366
282-// readFence returns a semaphore signalled when the client's pending writes
283-// to the buffer finish, or 0 when the kernel cannot export them (older than
284-// 5.20): the buffer is then read as is.
285-func (r *Renderer) readFence(im *imported) vk.Semaphore {
286- arg := dmaBufSync{flags: dmaBufSyncRead, fd: -1}
287- if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(im.fd), ioctlExportSyncFile, uintptr(unsafe.Pointer(&arg))); errno != 0 || arg.fd < 0 {
288- return 0
367+// appendReadFences waits on every distinct backing object's pending writes.
368+// Shared YUV planes have one implicit fence; disjoint planes have two.
369+func (r *Renderer) appendReadFences(waits []vk.Semaphore, im *imported) []vk.Semaphore {
370+ fds := [2]int{im.fd}
371+ count := 1
372+ if im.yuv {
373+ fds = im.fds
374+ if im.disjoint {
375+ count = 2
376+ }
377+ }
378+ for _, fd := range fds[:count] {
379+ arg := dmaBufSync{flags: dmaBufSyncRead, fd: -1}
380+ if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), ioctlExportSyncFile, uintptr(unsafe.Pointer(&arg))); errno != 0 || arg.fd < 0 {
381+ continue // Older kernels cannot export implicit fences.
382+ }
383+ if sem := r.importSyncFD(int(arg.fd)); sem != 0 {
384+ waits = append(waits, sem)
385+ }
289386 }
290- return r.importSyncFD(int(arg.fd))
387+ return waits
291388 }
292389
293390 // importFence makes a wait semaphore of a sync file. The file stays the
@@ -41,6 +41,22 @@ var dmabufFormats = []struct {
41 {fourcc('X', 'R', '2', '4'), vk.FormatB8g8r8a8Unorm, true},41 {fourcc('X', 'R', '2', '4'), vk.FormatB8g8r8a8Unorm, true},
42 {fourcc('A', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, false},42 {fourcc('A', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, false},
43 {fourcc('X', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, true},43 {fourcc('X', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, true},
44+ // Vulkan's A2R10G10B10/A2B10G10R10 packed UNORM formats match
45+ // DRM's little-endian 2101010 layout; do not use an sRGB view for PQ.
46+ {fourcc('A', 'R', '3', '0'), vk.FormatA2r10g10b10UnormPack32, false},
47+ {fourcc('X', 'R', '3', '0'), vk.FormatA2r10g10b10UnormPack32, true},
48+ {fourcc('A', 'B', '3', '0'), vk.FormatA2b10g10r10UnormPack32, false},
49+ {fourcc('X', 'B', '3', '0'), vk.FormatA2b10g10r10UnormPack32, true},
50+ // Little-endian ABGR16161616F is RGBA16F; floating-point values
51+ // are linear electrical signals, not sRGB-encoded.
52+ {fourcc('A', 'B', '4', 'H'), vk.FormatR16g16b16a16Sfloat, false},
53+ {fourcc('X', 'B', '4', 'H'), vk.FormatR16g16b16a16Sfloat, true},
54+ {fourcc('N', 'V', '1', '2'), vk.Format(1000156003), true}, // VK_FORMAT_G8_B8R8_2PLANE_420_UNORM
55+ {fourcc('P', '0', '1', '0'), vk.Format(1000156013), true}, // VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
56+}
57+
58+func isYUVFormat(f uint32) bool {
59+ return f == fourcc('N', 'V', '1', '2') || f == fourcc('P', '0', '1', '0')
44 }60 }
45 61
46 func fourcc(a, b, c, d byte) uint32 {62 func fourcc(a, b, c, d byte) uint32 {
@@ -85,8 +101,9 @@ func cstring(b []byte) string {
85 return string(b)101 return string(b)
86 }102 }
87 103
88-// probeDMABuf lists the fourcc/modifier pairs the device imports as104+// probeDMABuf lists the sampled fourcc/modifier pairs supported by the GPU.
89-// single-plane sampled images with linear filtering, and the render node.105+// Multi-planar formats require mutable per-plane views; disjoint binding is
106+// optional and only used when the device supports it and planes differ.
90 func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {107 func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
91 var sup ports.DMABufSupport108 var sup ports.DMABufSupport
92 drm := vk.PhysicalDeviceDrmPropertiesEXT{SType: vk.StructureTypePhysicalDeviceDRMPropertiesEXT}109 drm := vk.PhysicalDeviceDrmPropertiesEXT{SType: vk.StructureTypePhysicalDeviceDRMPropertiesEXT}
@@ -106,13 +123,15 @@ func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
106 list.DrmFormatModifierProperties = &mods[0]123 list.DrmFormatModifierProperties = &mods[0]
107 r.id.GetPhysicalDeviceFormatProperties2(physical, f.format, &fp)124 r.id.GetPhysicalDeviceFormatProperties2(physical, f.format, &fp)
108 for _, m := range mods[:list.DrmFormatModifierCount] {125 for _, m := range mods[:list.DrmFormatModifierCount] {
109- // Multi-plane modifiers (compression metadata) are left to a
110- // later step.
111 need := vk.FormatFeatureFlags(vk.FormatFeatureSampledImageBit | vk.FormatFeatureSampledImageFilterLinearBit)126 need := vk.FormatFeatureFlags(vk.FormatFeatureSampledImageBit | vk.FormatFeatureSampledImageFilterLinearBit)
112- if m.DrmFormatModifierPlaneCount != 1 || m.DrmFormatModifierTilingFeatures&need != need {127+ planes := uint32(1)
128+ if isYUVFormat(f.fourcc) {
129+ planes = 2
130+ }
131+ if m.DrmFormatModifierPlaneCount != planes || m.DrmFormatModifierTilingFeatures&need != need {
113 continue132 continue
114 }133 }
115- if r.importable(physical, f.format, m.DrmFormatModifier) {134+ if r.importable(physical, f.format, m.DrmFormatModifier, isYUVFormat(f.fourcc), false) {
116 sup.Formats = append(sup.Formats, ports.DMABufFormat{Format: f.fourcc, Modifier: m.DrmFormatModifier})135 sup.Formats = append(sup.Formats, ports.DMABufFormat{Format: f.fourcc, Modifier: m.DrmFormatModifier})
117 }136 }
118 }137 }
@@ -122,10 +141,45 @@ func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport {
122 141
123 // importable asks whether an image of that format and modifier can be142 // importable asks whether an image of that format and modifier can be
124 // imported from a dmabuf.143 // imported from a dmabuf.
125-func (r *Renderer) importable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {144+func (r *Renderer) importable(physical vk.PhysicalDevice, format vk.Format, modifier uint64, yuv, disjoint bool) bool {
126 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}145 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
127 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}146 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
128- info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}147+ flags := vk.ImageCreateFlags(0)
148+ if yuv {
149+ flags = vk.ImageCreateMutableFormatBit
150+ if disjoint {
151+ flags |= imageDisjoint
152+ // Image-format queries alone do not imply the modifier supports
153+ // disjoint plane bindings. Check its tiling features as well.
154+ list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
155+ properties := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
156+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &properties)
157+ if list.DrmFormatModifierCount == 0 {
158+ return false
159+ }
160+ mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount)
161+ list.DrmFormatModifierProperties = &mods[0]
162+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &properties)
163+ found := false
164+ for _, m := range mods[:list.DrmFormatModifierCount] {
165+ if m.DrmFormatModifier == modifier && m.DrmFormatModifierTilingFeatures&0x00400000 != 0 {
166+ found = true
167+ break
168+ }
169+ }
170+ if !found {
171+ return false
172+ }
173+ }
174+ views := yuvViewFormats(format)
175+ formats := imageFormatList{sType: structureImageFormatList, viewFormatCount: 3, viewFormats: &views[0]}
176+ mod.Next = unsafe.Pointer(&formats)
177+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Flags: flags, Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}
178+ extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
179+ out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
180+ return r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) == vk.Success && extOut.ExternalMemoryProperties.ExternalMemoryFeatures&vk.ExternalMemoryFeatureImportableBit != 0
181+ }
182+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Flags: flags, Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit}
129 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}183 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
130 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}184 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
131 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {185 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
@@ -140,12 +194,19 @@ func (r *Renderer) DMABuf() ports.DMABufSupport { return r.dmabuf }
140 // imported is a client dmabuf bound to a VkImage, with the view and set194 // imported is a client dmabuf bound to a VkImage, with the view and set
141 // the compose shader samples it through.195 // the compose shader samples it through.
142 type imported struct {196 type imported struct {
143- image vk.Image197+ image vk.Image
144- memory vk.DeviceMemory198+ memory vk.DeviceMemory
145- view vk.ImageView199+ view vk.ImageView
146- pool vk.DescriptorPool200+ chromaView vk.ImageView
147- set vk.DescriptorSet201+ memories [2]vk.DeviceMemory
148- fd int // our duplicate of plane 0, for implicit sync202+ fds [2]int
203+ yuv bool
204+ // disjoint is available for this format/modifier; required only when
205+ // the two planes refer to different underlying DMA-BUF objects.
206+ disjoint bool
207+ pool vk.DescriptorPool
208+ set vk.DescriptorSet
209+ fd int // our duplicate of plane 0, for implicit sync
149 // last is the frame that drew it, for eviction.210 // last is the frame that drew it, for eviction.
150 last uint64211 last uint64
151 }212 }
@@ -164,9 +225,18 @@ func (r *Renderer) importDMABuf(b *ports.DMABuf) (*imported, error) {
164 return nil, fmt.Errorf("dmabuf unsupported")225 return nil, fmt.Errorf("dmabuf unsupported")
165 }226 }
166 format, _, ok := vkFormat(b.Format)227 format, _, ok := vkFormat(b.Format)
167- if !ok || len(b.Planes) != 1 || b.Width <= 0 || b.Height <= 0 {228+ if !ok || b.Width <= 0 || b.Height <= 0 {
168 return nil, fmt.Errorf("dmabuf format %#x with %d planes unsupported", b.Format, len(b.Planes))229 return nil, fmt.Errorf("dmabuf format %#x with %d planes unsupported", b.Format, len(b.Planes))
169 }230 }
231+ if isYUVFormat(b.Format) {
232+ if len(b.Planes) != 2 || b.Width%2 != 0 || b.Height%2 != 0 {
233+ return nil, fmt.Errorf("invalid YUV buffer geometry or planes")
234+ }
235+ return r.importYUV(b, format)
236+ }
237+ if len(b.Planes) != 1 {
238+ return nil, fmt.Errorf("RGB buffer needs one plane")
239+ }
170 fd, err := dupFile(b.Planes[0].File)240 fd, err := dupFile(b.Planes[0].File)
171 if err != nil {241 if err != nil {
172 return nil, err242 return nil, err
@@ -246,15 +316,30 @@ func (r *Renderer) release(im *imported) {
246 if im.view != 0 {316 if im.view != 0 {
247 r.dd.DestroyImageView(r.device, im.view, nil)317 r.dd.DestroyImageView(r.device, im.view, nil)
248 }318 }
319+ if im.chromaView != 0 {
320+ r.dd.DestroyImageView(r.device, im.chromaView, nil)
321+ }
249 if im.image != 0 {322 if im.image != 0 {
250 r.dd.DestroyImage(r.device, im.image, nil)323 r.dd.DestroyImage(r.device, im.image, nil)
251 }324 }
252 if im.memory != 0 {325 if im.memory != 0 {
253 r.dd.FreeMemory(r.device, im.memory, nil)326 r.dd.FreeMemory(r.device, im.memory, nil)
254 }327 }
328+ for i := range im.memories {
329+ if im.memories[i] != 0 {
330+ r.dd.FreeMemory(r.device, im.memories[i], nil)
331+ }
332+ }
255 if im.fd >= 0 {333 if im.fd >= 0 {
256 unix.Close(im.fd)334 unix.Close(im.fd)
257 }335 }
336+ if im.yuv {
337+ for _, fd := range im.fds {
338+ if fd >= 0 {
339+ unix.Close(fd)
340+ }
341+ }
342+ }
258 }343 }
259 344
260 // dropUnused frees imports not drawn for importTTL frames (the client345 // dropUnused frees imports not drawn for importTTL frames (the client
@@ -279,15 +364,27 @@ type dmaBufSync struct {
279 const ioctlExportSyncFile = 0xc0086202364 const ioctlExportSyncFile = 0xc0086202
280 const dmaBufSyncRead = 1365 const dmaBufSyncRead = 1
281 366
282-// readFence returns a semaphore signalled when the client's pending writes367+// appendReadFences waits on every distinct backing object's pending writes.
283-// to the buffer finish, or 0 when the kernel cannot export them (older than368+// Shared YUV planes have one implicit fence; disjoint planes have two.
284-// 5.20): the buffer is then read as is.369+func (r *Renderer) appendReadFences(waits []vk.Semaphore, im *imported) []vk.Semaphore {
285-func (r *Renderer) readFence(im *imported) vk.Semaphore {370+ fds := [2]int{im.fd}
286- arg := dmaBufSync{flags: dmaBufSyncRead, fd: -1}371+ count := 1
287- if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(im.fd), ioctlExportSyncFile, uintptr(unsafe.Pointer(&arg))); errno != 0 || arg.fd < 0 {372+ if im.yuv {
288- return 0373+ fds = im.fds
374+ if im.disjoint {
375+ count = 2
376+ }
377+ }
378+ for _, fd := range fds[:count] {
379+ arg := dmaBufSync{flags: dmaBufSyncRead, fd: -1}
380+ if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), ioctlExportSyncFile, uintptr(unsafe.Pointer(&arg))); errno != 0 || arg.fd < 0 {
381+ continue // Older kernels cannot export implicit fences.
382+ }
383+ if sem := r.importSyncFD(int(arg.fd)); sem != 0 {
384+ waits = append(waits, sem)
385+ }
289 }386 }
290- return r.importSyncFD(int(arg.fd))387+ return waits
291 }388 }
292 389
293 // importFence makes a wait semaphore of a sync file. The file stays the390 // importFence makes a wait semaphore of a sync file. The file stays the
M internal/adapters/vulkan/dmabuf_test.go +19 -0
@@ -108,6 +108,25 @@ func TestRendererImportsDMABuf(t *testing.T) {
108108 }
109109 }
110110
111+// The HDR swapchain's DRM 2101010 formats use packed UNORM views, not
112+// automatic sRGB decoding. The same imports work in SDR composition.
113+func TestTenBitDMABufFormats(t *testing.T) {
114+ for _, tc := range []struct {
115+ name [4]byte
116+ opaque bool
117+ }{
118+ {[4]byte{'A', 'R', '3', '0'}, false},
119+ {[4]byte{'X', 'R', '3', '0'}, true},
120+ {[4]byte{'A', 'B', '3', '0'}, false},
121+ {[4]byte{'X', 'B', '3', '0'}, true},
122+ } {
123+ format, opaque, ok := vkFormat(fourcc(tc.name[0], tc.name[1], tc.name[2], tc.name[3]))
124+ if !ok || format == 0 || opaque != tc.opaque {
125+ t.Errorf("%q: format %v opaque %t available %t", tc.name, format, opaque, ok)
126+ }
127+ }
128+}
129+
111130 // A subsurface dmabuf is read after its own acquire fence, not the root's.
112131 func TestRendererSubsurfaceAcquire(t *testing.T) {
113132 r, err := New(80, 32)
@@ -108,6 +108,25 @@ func TestRendererImportsDMABuf(t *testing.T) {
108 }108 }
109 }109 }
110 110
111+// The HDR swapchain's DRM 2101010 formats use packed UNORM views, not
112+// automatic sRGB decoding. The same imports work in SDR composition.
113+func TestTenBitDMABufFormats(t *testing.T) {
114+ for _, tc := range []struct {
115+ name [4]byte
116+ opaque bool
117+ }{
118+ {[4]byte{'A', 'R', '3', '0'}, false},
119+ {[4]byte{'X', 'R', '3', '0'}, true},
120+ {[4]byte{'A', 'B', '3', '0'}, false},
121+ {[4]byte{'X', 'B', '3', '0'}, true},
122+ } {
123+ format, opaque, ok := vkFormat(fourcc(tc.name[0], tc.name[1], tc.name[2], tc.name[3]))
124+ if !ok || format == 0 || opaque != tc.opaque {
125+ t.Errorf("%q: format %v opaque %t available %t", tc.name, format, opaque, ok)
126+ }
127+ }
128+}
129+
111 // A subsurface dmabuf is read after its own acquire fence, not the root's.130 // A subsurface dmabuf is read after its own acquire fence, not the root's.
112 func TestRendererSubsurfaceAcquire(t *testing.T) {131 func TestRendererSubsurfaceAcquire(t *testing.T) {
113 r, err := New(80, 32)132 r, err := New(80, 32)
A internal/adapters/vulkan/extended_linear_test.go +113 -0
@@ -0,0 +1,113 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "image/color"
6+ "math"
7+ "os"
8+ "slices"
9+ "testing"
10+ "unsafe"
11+
12+ "github.com/bnema/neferwl/internal/ports"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestExtendedLinearClient(t *testing.T) {
17+ for _, hdr := range []bool{false, true} {
18+ t.Run(map[bool]string{false: "SDR", true: "HDR"}[hdr], func(t *testing.T) {
19+ var r *Renderer
20+ if hdr {
21+ r = hdrTestRenderer(t)
22+ } else {
23+ var err error
24+ r, err = New(64, 16)
25+ if err != nil {
26+ t.Skipf("Vulkan unavailable: %v", err)
27+ }
28+ defer r.Close()
29+ }
30+ format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
31+ if !slices.Contains(r.DMABuf().Formats, format) {
32+ t.Skip("ABGR16161616F linear import unavailable")
33+ }
34+ // 1.0 = 80 nits, 12.5 = 1000 nits, -0.25 is out of gamut.
35+ pixels := make([]byte, 64*16*8)
36+ for y := 0; y < 16; y++ {
37+ for x := 0; x < 64; x++ {
38+ v := uint16(0x3c00)
39+ if x >= 20 && x < 40 {
40+ v = 0x4a40
41+ }
42+ if x >= 40 {
43+ v = 0xb400
44+ }
45+ i := (y*64 + x) * 8
46+ for ch := 0; ch < 3; ch++ {
47+ binary.LittleEndian.PutUint16(pixels[i+ch*2:], v)
48+ }
49+ binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
50+ }
51+ }
52+ mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ defer unix.Close(mem)
57+ if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
58+ t.Fatal(err)
59+ }
60+ if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
61+ t.Fatal(err)
62+ }
63+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
64+ t.Fatal(err)
65+ }
66+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
67+ if err != nil {
68+ t.Skipf("udmabuf unavailable: %v", err)
69+ }
70+ defer dev.Close()
71+ arg := struct {
72+ memfd, flags uint32
73+ offset, size uint64
74+ }{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
75+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
76+ if errno != 0 {
77+ t.Skipf("udmabuf create: %v", errno)
78+ }
79+ f := os.NewFile(fd, "fp16-client")
80+ defer f.Close()
81+ buf := &ports.DMABuf{ID: 164, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
82+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
83+ scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
84+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
85+ t.Fatal(err)
86+ }
87+ if hdr {
88+ for _, tc := range []struct {
89+ x int
90+ nits float64
91+ }{{5, 80}, {25, 1000}, {45, 0}} {
92+ got := hdrTargetAt(t, r, tc.x, 8)
93+ want := pqEncode(tc.nits)
94+ for i, v := range got {
95+ if math.Abs(v-want) > .009 {
96+ t.Errorf("x=%d channel=%d PQ=%.4f want %.4f", tc.x, i, v, want)
97+ }
98+ }
99+ }
100+ } else {
101+ for _, tc := range []struct {
102+ x int
103+ v byte
104+ }{{5, 168}, {25, 255}, {45, 0}} {
105+ got := r.Pixels().RGBAAt(tc.x, 8)
106+ if !near(got, color.RGBA{tc.v, tc.v, tc.v, 255}, 3) {
107+ t.Errorf("x=%d got %v want %d", tc.x, got, tc.v)
108+ }
109+ }
110+ }
111+ })
112+ }
113+}
@@ -0,0 +1,113 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "image/color"
6+ "math"
7+ "os"
8+ "slices"
9+ "testing"
10+ "unsafe"
11+
12+ "github.com/bnema/neferwl/internal/ports"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestExtendedLinearClient(t *testing.T) {
17+ for _, hdr := range []bool{false, true} {
18+ t.Run(map[bool]string{false: "SDR", true: "HDR"}[hdr], func(t *testing.T) {
19+ var r *Renderer
20+ if hdr {
21+ r = hdrTestRenderer(t)
22+ } else {
23+ var err error
24+ r, err = New(64, 16)
25+ if err != nil {
26+ t.Skipf("Vulkan unavailable: %v", err)
27+ }
28+ defer r.Close()
29+ }
30+ format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
31+ if !slices.Contains(r.DMABuf().Formats, format) {
32+ t.Skip("ABGR16161616F linear import unavailable")
33+ }
34+ // 1.0 = 80 nits, 12.5 = 1000 nits, -0.25 is out of gamut.
35+ pixels := make([]byte, 64*16*8)
36+ for y := 0; y < 16; y++ {
37+ for x := 0; x < 64; x++ {
38+ v := uint16(0x3c00)
39+ if x >= 20 && x < 40 {
40+ v = 0x4a40
41+ }
42+ if x >= 40 {
43+ v = 0xb400
44+ }
45+ i := (y*64 + x) * 8
46+ for ch := 0; ch < 3; ch++ {
47+ binary.LittleEndian.PutUint16(pixels[i+ch*2:], v)
48+ }
49+ binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
50+ }
51+ }
52+ mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ defer unix.Close(mem)
57+ if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
58+ t.Fatal(err)
59+ }
60+ if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
61+ t.Fatal(err)
62+ }
63+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
64+ t.Fatal(err)
65+ }
66+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
67+ if err != nil {
68+ t.Skipf("udmabuf unavailable: %v", err)
69+ }
70+ defer dev.Close()
71+ arg := struct {
72+ memfd, flags uint32
73+ offset, size uint64
74+ }{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
75+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
76+ if errno != 0 {
77+ t.Skipf("udmabuf create: %v", errno)
78+ }
79+ f := os.NewFile(fd, "fp16-client")
80+ defer f.Close()
81+ buf := &ports.DMABuf{ID: 164, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
82+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
83+ scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
84+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
85+ t.Fatal(err)
86+ }
87+ if hdr {
88+ for _, tc := range []struct {
89+ x int
90+ nits float64
91+ }{{5, 80}, {25, 1000}, {45, 0}} {
92+ got := hdrTargetAt(t, r, tc.x, 8)
93+ want := pqEncode(tc.nits)
94+ for i, v := range got {
95+ if math.Abs(v-want) > .009 {
96+ t.Errorf("x=%d channel=%d PQ=%.4f want %.4f", tc.x, i, v, want)
97+ }
98+ }
99+ }
100+ } else {
101+ for _, tc := range []struct {
102+ x int
103+ v byte
104+ }{{5, 168}, {25, 255}, {45, 0}} {
105+ got := r.Pixels().RGBAAt(tc.x, 8)
106+ if !near(got, color.RGBA{tc.v, tc.v, tc.v, 255}, 3) {
107+ t.Errorf("x=%d got %v want %d", tc.x, got, tc.v)
108+ }
109+ }
110+ }
111+ })
112+ }
113+}
M internal/adapters/vulkan/frame.go +12 -8
@@ -170,8 +170,11 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
170170 }
171171 tg := r.target()
172172 hdr := r.hdrNits > 0 && len(r.targets) > 0
173+ if r.hdrNits > 0 && !hdr {
174+ return nil, fmt.Errorf("HDR composition requires an exported target")
175+ }
173176 if hdr {
174- tg = &r.own
177+ tg = &r.hdrOwn
175178 }
176179 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
177180 ds := r.draws(s, contents, dmg)
@@ -246,14 +249,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
246249 // acquire fence, else the buffer's implicit fences.
247250 var waits []vk.Semaphore
248251 for _, im := range dmas {
249- sem := vk.Semaphore(0)
250252 if f := acquires[im]; f != nil {
251- sem = r.importFence(f)
253+ if sem := r.importFence(f); sem != 0 {
254+ waits = append(waits, sem)
255+ }
252256 } else {
253- sem = r.readFence(im)
254- }
255- if sem != 0 {
256- waits = append(waits, sem)
257+ waits = r.appendReadFences(waits, im)
257258 }
258259 }
259260 frame := r.frame
@@ -278,7 +279,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
278279 runtime.KeepAlive(waits)
279280 slot.frame, slot.busy, r.submitted = frame, true, frame
280281 if hdr {
281- // recordHDR left the composed image in TRANSFER_SRC for Pixels.
282+ // recordHDR left the composed linear image in TRANSFER_SRC for capture.
282283 tg.layout = vk.ImageLayoutTransferSrcOptimal
283284 r.targets[r.current].layout = vk.ImageLayoutGeneral
284285 } else {
@@ -320,6 +321,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
320321 func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) {
321322 for _, im := range dmas {
322323 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange}
324+ if im.yuv {
325+ b.SubresourceRange.AspectMask = aspectPlane0 | aspectPlane1
326+ }
323327 if acquire {
324328 b.OldLayout, b.NewLayout = vk.ImageLayoutGeneral, vk.ImageLayoutShaderReadOnlyOptimal
325329 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family
@@ -170,8 +170,11 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
170 }170 }
171 tg := r.target()171 tg := r.target()
172 hdr := r.hdrNits > 0 && len(r.targets) > 0172 hdr := r.hdrNits > 0 && len(r.targets) > 0
173+ if r.hdrNits > 0 && !hdr {
174+ return nil, fmt.Errorf("HDR composition requires an exported target")
175+ }
173 if hdr {176 if hdr {
174- tg = &r.own177+ tg = &r.hdrOwn
175 }178 }
176 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))179 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
177 ds := r.draws(s, contents, dmg)180 ds := r.draws(s, contents, dmg)
@@ -246,14 +249,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
246 // acquire fence, else the buffer's implicit fences.249 // acquire fence, else the buffer's implicit fences.
247 var waits []vk.Semaphore250 var waits []vk.Semaphore
248 for _, im := range dmas {251 for _, im := range dmas {
249- sem := vk.Semaphore(0)
250 if f := acquires[im]; f != nil {252 if f := acquires[im]; f != nil {
251- sem = r.importFence(f)253+ if sem := r.importFence(f); sem != 0 {
254+ waits = append(waits, sem)
255+ }
252 } else {256 } else {
253- sem = r.readFence(im)257+ waits = r.appendReadFences(waits, im)
254- }
255- if sem != 0 {
256- waits = append(waits, sem)
257 }258 }
258 }259 }
259 frame := r.frame260 frame := r.frame
@@ -278,7 +279,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
278 runtime.KeepAlive(waits)279 runtime.KeepAlive(waits)
279 slot.frame, slot.busy, r.submitted = frame, true, frame280 slot.frame, slot.busy, r.submitted = frame, true, frame
280 if hdr {281 if hdr {
281- // recordHDR left the composed image in TRANSFER_SRC for Pixels.282+ // recordHDR left the composed linear image in TRANSFER_SRC for capture.
282 tg.layout = vk.ImageLayoutTransferSrcOptimal283 tg.layout = vk.ImageLayoutTransferSrcOptimal
283 r.targets[r.current].layout = vk.ImageLayoutGeneral284 r.targets[r.current].layout = vk.ImageLayoutGeneral
284 } else {285 } else {
@@ -320,6 +321,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
320 func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) {321 func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) {
321 for _, im := range dmas {322 for _, im := range dmas {
322 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange}323 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange}
324+ if im.yuv {
325+ b.SubresourceRange.AspectMask = aspectPlane0 | aspectPlane1
326+ }
323 if acquire {327 if acquire {
324 b.OldLayout, b.NewLayout = vk.ImageLayoutGeneral, vk.ImageLayoutShaderReadOnlyOptimal328 b.OldLayout, b.NewLayout = vk.ImageLayoutGeneral, vk.ImageLayoutShaderReadOnlyOptimal
325 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family329 b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family
M internal/adapters/vulkan/hdr.go +79 -3
@@ -2,6 +2,7 @@ package vulkan
22
33 import (
44 _ "embed"
5+ "fmt"
56 "unsafe"
67
78 vk "github.com/bnema/purego-vulkan/vulkan"
@@ -28,6 +29,21 @@ type hdrPass struct {
2829 func (r *Renderer) SetHDR(nits float64) {
2930 if nits != r.hdrNits {
3031 r.cursorCache = cursorConversion{}
32+ if r.hdrNits > 0 && nits > 0 {
33+ r.hdrOwn.valid = false
34+ }
35+ if (nits > 0) != (r.hdrNits > 0) && r.device != 0 {
36+ _ = checked("vkDeviceWaitIdle", r.dd.DeviceWaitIdle(r.device))
37+ r.idle()
38+ r.hdrOwn.valid = false
39+ r.own.valid = false
40+ for _, t := range r.targets {
41+ t.valid = false
42+ }
43+ if r.last == &r.hdrOwn {
44+ r.last = nil
45+ }
46+ }
3147 }
3248 r.hdrNits = nits
3349 if nits <= 0 {
@@ -35,6 +51,29 @@ func (r *Renderer) SetHDR(nits float64) {
3551 r.hdrError = nil
3652 return
3753 }
54+ if r.hdrOwn.image == 0 {
55+ r.hdrError = r.createHDROwn()
56+ if r.hdrError != nil {
57+ r.freeTarget(&r.hdrOwn)
58+ return
59+ }
60+ }
61+ if r.readbackBytes < 8 {
62+ // SetHDR is called before rendering on the owner goroutine. On
63+ // transitions the device has already been drained above.
64+ r.hdrError = r.createReadback(8)
65+ if r.hdrError != nil {
66+ r.freeTarget(&r.hdrOwn)
67+ return
68+ }
69+ }
70+ if r.compose.hdrPipeline == 0 {
71+ r.hdrError = r.createGraphicsPipeline(composeVert, composeHDRFrag, vk.FormatR16g16b16a16Sfloat, r.compose.layout, true, &r.compose.hdrPipeline)
72+ if r.hdrError != nil {
73+ r.freeTarget(&r.hdrOwn)
74+ return
75+ }
76+ }
3877 if r.hdr.pipeline != 0 {
3978 return
4079 }
@@ -42,7 +81,44 @@ func (r *Renderer) SetHDR(nits float64) {
4281 r.hdrError = r.createHDR()
4382 if r.hdrError != nil {
4483 r.destroyHDR()
84+ r.freeTarget(&r.hdrOwn)
85+ }
86+}
87+
88+// createHDROwn allocates the fp16 linear-light composition image once per
89+// renderer; toggling HDR reuses it after the previous frames complete.
90+func (r *Renderer) createHDROwn() error {
91+ // An HDR compositor needs an fp16 blendable color attachment that can
92+ // also be sampled by the PQ pass and copied only for capture.
93+ if r.physical == 0 {
94+ return fmt.Errorf("HDR fp16 device unavailable")
95+ }
96+ props := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2}
97+ r.id.GetPhysicalDeviceFormatProperties2(r.physical, vk.FormatR16g16b16a16Sfloat, &props)
98+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit | vk.FormatFeatureSampledImageBit | formatFeatureTransferSrc)
99+ if props.FormatProperties.OptimalTilingFeatures&need != need {
100+ return fmt.Errorf("HDR fp16 composition format lacks attachment/blend/sample/readback features")
101+ }
102+ d, t := r.dd, &r.hdrOwn
103+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatR16g16b16a16Sfloat, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
104+ if err := checked("vkCreateImage(HDR composition)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
105+ return err
106+ }
107+ var req vk.MemoryRequirements
108+ d.GetImageMemoryRequirements(r.device, t.image, &req)
109+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyDeviceLocalBit)
110+ if err != nil {
111+ return err
112+ }
113+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
114+ if err := checked("vkAllocateMemory(HDR composition)", d.AllocateMemory(r.device, &alloc, nil, &t.memory)); err != nil {
115+ return err
116+ }
117+ if err := checked("vkBindImageMemory(HDR composition)", d.BindImageMemory(r.device, t.image, t.memory, 0)); err != nil {
118+ return err
45119 }
120+ t.view, err = r.imageView(t.image, vk.FormatR16g16b16a16Sfloat)
121+ return err
46122 }
47123
48124 func (r *Renderer) createHDR() error {
@@ -73,7 +149,7 @@ func (r *Renderer) createHDR() error {
73149 if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil {
74150 return err
75151 }
76- img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
152+ img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.hdrOwn.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
77153 write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}
78154 d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)
79155 return nil
@@ -102,10 +178,10 @@ func (r *Renderer) destroyHDR() {
102178 *h = hdrPass{}
103179 }
104180
105-// recordHDR converts the fully composed SDR image in the same command buffer.
181+// recordHDR converts the fully composed linear HDR image in the same command buffer.
106182 func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
107183 d, h := r.dd, &r.hdr
108- in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.own.image, SubresourceRange: colorRange}
184+ in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
109185 d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
110186 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
111187 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
@@ -2,6 +2,7 @@ package vulkan
2 2
3 import (3 import (
4 _ "embed"4 _ "embed"
5+ "fmt"
5 "unsafe"6 "unsafe"
6 7
7 vk "github.com/bnema/purego-vulkan/vulkan"8 vk "github.com/bnema/purego-vulkan/vulkan"
@@ -28,6 +29,21 @@ type hdrPass struct {
28 func (r *Renderer) SetHDR(nits float64) {29 func (r *Renderer) SetHDR(nits float64) {
29 if nits != r.hdrNits {30 if nits != r.hdrNits {
30 r.cursorCache = cursorConversion{}31 r.cursorCache = cursorConversion{}
32+ if r.hdrNits > 0 && nits > 0 {
33+ r.hdrOwn.valid = false
34+ }
35+ if (nits > 0) != (r.hdrNits > 0) && r.device != 0 {
36+ _ = checked("vkDeviceWaitIdle", r.dd.DeviceWaitIdle(r.device))
37+ r.idle()
38+ r.hdrOwn.valid = false
39+ r.own.valid = false
40+ for _, t := range r.targets {
41+ t.valid = false
42+ }
43+ if r.last == &r.hdrOwn {
44+ r.last = nil
45+ }
46+ }
31 }47 }
32 r.hdrNits = nits48 r.hdrNits = nits
33 if nits <= 0 {49 if nits <= 0 {
@@ -35,6 +51,29 @@ func (r *Renderer) SetHDR(nits float64) {
35 r.hdrError = nil51 r.hdrError = nil
36 return52 return
37 }53 }
54+ if r.hdrOwn.image == 0 {
55+ r.hdrError = r.createHDROwn()
56+ if r.hdrError != nil {
57+ r.freeTarget(&r.hdrOwn)
58+ return
59+ }
60+ }
61+ if r.readbackBytes < 8 {
62+ // SetHDR is called before rendering on the owner goroutine. On
63+ // transitions the device has already been drained above.
64+ r.hdrError = r.createReadback(8)
65+ if r.hdrError != nil {
66+ r.freeTarget(&r.hdrOwn)
67+ return
68+ }
69+ }
70+ if r.compose.hdrPipeline == 0 {
71+ r.hdrError = r.createGraphicsPipeline(composeVert, composeHDRFrag, vk.FormatR16g16b16a16Sfloat, r.compose.layout, true, &r.compose.hdrPipeline)
72+ if r.hdrError != nil {
73+ r.freeTarget(&r.hdrOwn)
74+ return
75+ }
76+ }
38 if r.hdr.pipeline != 0 {77 if r.hdr.pipeline != 0 {
39 return78 return
40 }79 }
@@ -42,7 +81,44 @@ func (r *Renderer) SetHDR(nits float64) {
42 r.hdrError = r.createHDR()81 r.hdrError = r.createHDR()
43 if r.hdrError != nil {82 if r.hdrError != nil {
44 r.destroyHDR()83 r.destroyHDR()
84+ r.freeTarget(&r.hdrOwn)
85+ }
86+}
87+
88+// createHDROwn allocates the fp16 linear-light composition image once per
89+// renderer; toggling HDR reuses it after the previous frames complete.
90+func (r *Renderer) createHDROwn() error {
91+ // An HDR compositor needs an fp16 blendable color attachment that can
92+ // also be sampled by the PQ pass and copied only for capture.
93+ if r.physical == 0 {
94+ return fmt.Errorf("HDR fp16 device unavailable")
95+ }
96+ props := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2}
97+ r.id.GetPhysicalDeviceFormatProperties2(r.physical, vk.FormatR16g16b16a16Sfloat, &props)
98+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit | vk.FormatFeatureSampledImageBit | formatFeatureTransferSrc)
99+ if props.FormatProperties.OptimalTilingFeatures&need != need {
100+ return fmt.Errorf("HDR fp16 composition format lacks attachment/blend/sample/readback features")
101+ }
102+ d, t := r.dd, &r.hdrOwn
103+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatR16g16b16a16Sfloat, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
104+ if err := checked("vkCreateImage(HDR composition)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
105+ return err
106+ }
107+ var req vk.MemoryRequirements
108+ d.GetImageMemoryRequirements(r.device, t.image, &req)
109+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyDeviceLocalBit)
110+ if err != nil {
111+ return err
112+ }
113+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
114+ if err := checked("vkAllocateMemory(HDR composition)", d.AllocateMemory(r.device, &alloc, nil, &t.memory)); err != nil {
115+ return err
116+ }
117+ if err := checked("vkBindImageMemory(HDR composition)", d.BindImageMemory(r.device, t.image, t.memory, 0)); err != nil {
118+ return err
45 }119 }
120+ t.view, err = r.imageView(t.image, vk.FormatR16g16b16a16Sfloat)
121+ return err
46 }122 }
47 123
48 func (r *Renderer) createHDR() error {124 func (r *Renderer) createHDR() error {
@@ -73,7 +149,7 @@ func (r *Renderer) createHDR() error {
73 if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil {149 if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil {
74 return err150 return err
75 }151 }
76- img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}152+ img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.hdrOwn.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
77 write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}153 write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}
78 d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)154 d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)
79 return nil155 return nil
@@ -102,10 +178,10 @@ func (r *Renderer) destroyHDR() {
102 *h = hdrPass{}178 *h = hdrPass{}
103 }179 }
104 180
105-// recordHDR converts the fully composed SDR image in the same command buffer.181+// recordHDR converts the fully composed linear HDR image in the same command buffer.
106 func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {182 func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
107 d, h := r.dd, &r.hdr183 d, h := r.dd, &r.hdr
108- in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.own.image, SubresourceRange: colorRange}184+ in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange}
109 d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)185 d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
110 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}186 b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
111 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)187 d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
A internal/adapters/vulkan/hdr_capture.go +89 -0
@@ -0,0 +1,89 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "sync"
7+ "unsafe"
8+)
9+
10+// halfToSRGB maps every binary16 bit pattern to its clipped sRGB byte.
11+// It is built on the first HDR capture; normal presentation never touches it.
12+var halfToSRGB = sync.OnceValue(func() *[1 << 16]byte {
13+ var t [1 << 16]byte
14+ for i := range t {
15+ t[i] = encodeSRGB8(halfFloat(uint16(i)))
16+ }
17+ return &t
18+})
19+
20+// encodeSRGB8 clips a linear-light value to [0, 1] and encodes it as sRGB.
21+func encodeSRGB8(f float32) byte {
22+ v := float64(f)
23+ if !(v > 0) { // also maps NaN to black
24+ return 0
25+ }
26+ v = math.Min(v, 1)
27+ if v <= .0031308 {
28+ v *= 12.92
29+ } else {
30+ v = 1.055*math.Pow(v, 1/2.4) - .055
31+ }
32+ return byte(math.Round(v * 255))
33+}
34+
35+// hdrRow converts n RGBA16F linear-light pixels to opaque BGRA8 sRGB.
36+func hdrRow(dst, src []byte, n int) {
37+ t := halfToSRGB()
38+ _ = dst[n*4-1]
39+ _ = src[n*8-1]
40+ for x := range n {
41+ s, d := src[x*8:x*8+6], dst[x*4:x*4+4]
42+ d[0] = t[binary.LittleEndian.Uint16(s[4:])]
43+ d[1] = t[binary.LittleEndian.Uint16(s[2:])]
44+ d[2] = t[binary.LittleEndian.Uint16(s[0:])]
45+ d[3] = 255
46+ }
47+}
48+
49+// hdrRegion converts a region of the mapped HDR readback, row by row.
50+// swapRB writes RGBA instead of BGRA.
51+func (r *Renderer) hdrRegion(dst []byte, stride, x0, y0, w, h int, swapRB bool) {
52+ src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*8)
53+ for y := range h {
54+ start := ((y0+y)*r.width + x0) * 8
55+ row := dst[y*stride : y*stride+w*4]
56+ hdrRow(row, src[start:start+w*8], w)
57+ if swapRB {
58+ for i := 0; i < len(row); i += 4 {
59+ row[i], row[i+2] = row[i+2], row[i]
60+ }
61+ }
62+ }
63+}
64+
65+// halfFloat expands an IEEE 754 binary16 component for capture only.
66+func halfFloat(bits uint16) float32 {
67+ sign := uint32(bits&0x8000) << 16
68+ exponent := uint32(bits>>10) & 31
69+ mantissa := uint32(bits & 1023)
70+ var value uint32
71+ switch exponent {
72+ case 0:
73+ if mantissa == 0 {
74+ value = sign
75+ } else {
76+ e := uint32(113)
77+ for mantissa&0x400 == 0 {
78+ mantissa <<= 1
79+ e--
80+ }
81+ value = sign | e<<23 | (mantissa&1023)<<13
82+ }
83+ case 31:
84+ value = sign | 0x7f800000 | mantissa<<13
85+ default:
86+ value = sign | (exponent+112)<<23 | mantissa<<13
87+ }
88+ return math.Float32frombits(value)
89+}
@@ -0,0 +1,89 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "sync"
7+ "unsafe"
8+)
9+
10+// halfToSRGB maps every binary16 bit pattern to its clipped sRGB byte.
11+// It is built on the first HDR capture; normal presentation never touches it.
12+var halfToSRGB = sync.OnceValue(func() *[1 << 16]byte {
13+ var t [1 << 16]byte
14+ for i := range t {
15+ t[i] = encodeSRGB8(halfFloat(uint16(i)))
16+ }
17+ return &t
18+})
19+
20+// encodeSRGB8 clips a linear-light value to [0, 1] and encodes it as sRGB.
21+func encodeSRGB8(f float32) byte {
22+ v := float64(f)
23+ if !(v > 0) { // also maps NaN to black
24+ return 0
25+ }
26+ v = math.Min(v, 1)
27+ if v <= .0031308 {
28+ v *= 12.92
29+ } else {
30+ v = 1.055*math.Pow(v, 1/2.4) - .055
31+ }
32+ return byte(math.Round(v * 255))
33+}
34+
35+// hdrRow converts n RGBA16F linear-light pixels to opaque BGRA8 sRGB.
36+func hdrRow(dst, src []byte, n int) {
37+ t := halfToSRGB()
38+ _ = dst[n*4-1]
39+ _ = src[n*8-1]
40+ for x := range n {
41+ s, d := src[x*8:x*8+6], dst[x*4:x*4+4]
42+ d[0] = t[binary.LittleEndian.Uint16(s[4:])]
43+ d[1] = t[binary.LittleEndian.Uint16(s[2:])]
44+ d[2] = t[binary.LittleEndian.Uint16(s[0:])]
45+ d[3] = 255
46+ }
47+}
48+
49+// hdrRegion converts a region of the mapped HDR readback, row by row.
50+// swapRB writes RGBA instead of BGRA.
51+func (r *Renderer) hdrRegion(dst []byte, stride, x0, y0, w, h int, swapRB bool) {
52+ src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*8)
53+ for y := range h {
54+ start := ((y0+y)*r.width + x0) * 8
55+ row := dst[y*stride : y*stride+w*4]
56+ hdrRow(row, src[start:start+w*8], w)
57+ if swapRB {
58+ for i := 0; i < len(row); i += 4 {
59+ row[i], row[i+2] = row[i+2], row[i]
60+ }
61+ }
62+ }
63+}
64+
65+// halfFloat expands an IEEE 754 binary16 component for capture only.
66+func halfFloat(bits uint16) float32 {
67+ sign := uint32(bits&0x8000) << 16
68+ exponent := uint32(bits>>10) & 31
69+ mantissa := uint32(bits & 1023)
70+ var value uint32
71+ switch exponent {
72+ case 0:
73+ if mantissa == 0 {
74+ value = sign
75+ } else {
76+ e := uint32(113)
77+ for mantissa&0x400 == 0 {
78+ mantissa <<= 1
79+ e--
80+ }
81+ value = sign | e<<23 | (mantissa&1023)<<13
82+ }
83+ case 31:
84+ value = sign | 0x7f800000 | mantissa<<13
85+ default:
86+ value = sign | (exponent+112)<<23 | mantissa<<13
87+ }
88+ return math.Float32frombits(value)
89+}
A internal/adapters/vulkan/hdr_capture_test.go +61 -0
@@ -0,0 +1,61 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "testing"
7+)
8+
9+// referenceSRGB8 is the per-component formula the table must reproduce.
10+func referenceSRGB8(bits uint16) byte {
11+ v := math.Max(0, math.Min(float64(halfFloat(bits)), 1))
12+ if math.IsNaN(v) {
13+ return 0
14+ }
15+ if v <= .0031308 {
16+ v *= 12.92
17+ } else {
18+ v = 1.055*math.Pow(v, 1/2.4) - .055
19+ }
20+ return byte(math.Round(v * 255))
21+}
22+
23+func TestHalfToSRGBMatchesFormula(t *testing.T) {
24+ table := halfToSRGB()
25+ for i := range 1 << 16 {
26+ if got, want := table[i], referenceSRGB8(uint16(i)); got != want {
27+ t.Fatalf("half %#04x: got %d, want %d", i, got, want)
28+ }
29+ }
30+}
31+
32+func TestHdrRowWritesOpaqueBGRA(t *testing.T) {
33+ // Pixel 0: red=1.0, green=0.5, blue=0 (half 0x3c00, 0x3800, 0).
34+ // Pixel 1: negative red, NaN green, +Inf blue, alpha ignored.
35+ src := make([]byte, 16)
36+ for i, v := range []uint16{0x3c00, 0x3800, 0, 0x1234, 0xbc00, 0x7e00, 0x7c00, 0} {
37+ binary.LittleEndian.PutUint16(src[i*2:], v)
38+ }
39+ dst := make([]byte, 8)
40+ hdrRow(dst, src, 2)
41+ want := []byte{0, referenceSRGB8(0x3800), 255, 255, 255, 0, 0, 255}
42+ for i := range want {
43+ if dst[i] != want[i] {
44+ t.Fatalf("byte %d: got %v, want %v", i, dst, want)
45+ }
46+ }
47+}
48+
49+func BenchmarkHdrRow(b *testing.B) {
50+ const n = 5120
51+ src := make([]byte, n*8)
52+ for i := range n * 4 {
53+ binary.LittleEndian.PutUint16(src[i*2:], uint16(i*7919))
54+ }
55+ dst := make([]byte, n*4)
56+ halfToSRGB()
57+ b.SetBytes(n * 8)
58+ for b.Loop() {
59+ hdrRow(dst, src, n)
60+ }
61+}
@@ -0,0 +1,61 @@
1+package vulkan
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "testing"
7+)
8+
9+// referenceSRGB8 is the per-component formula the table must reproduce.
10+func referenceSRGB8(bits uint16) byte {
11+ v := math.Max(0, math.Min(float64(halfFloat(bits)), 1))
12+ if math.IsNaN(v) {
13+ return 0
14+ }
15+ if v <= .0031308 {
16+ v *= 12.92
17+ } else {
18+ v = 1.055*math.Pow(v, 1/2.4) - .055
19+ }
20+ return byte(math.Round(v * 255))
21+}
22+
23+func TestHalfToSRGBMatchesFormula(t *testing.T) {
24+ table := halfToSRGB()
25+ for i := range 1 << 16 {
26+ if got, want := table[i], referenceSRGB8(uint16(i)); got != want {
27+ t.Fatalf("half %#04x: got %d, want %d", i, got, want)
28+ }
29+ }
30+}
31+
32+func TestHdrRowWritesOpaqueBGRA(t *testing.T) {
33+ // Pixel 0: red=1.0, green=0.5, blue=0 (half 0x3c00, 0x3800, 0).
34+ // Pixel 1: negative red, NaN green, +Inf blue, alpha ignored.
35+ src := make([]byte, 16)
36+ for i, v := range []uint16{0x3c00, 0x3800, 0, 0x1234, 0xbc00, 0x7e00, 0x7c00, 0} {
37+ binary.LittleEndian.PutUint16(src[i*2:], v)
38+ }
39+ dst := make([]byte, 8)
40+ hdrRow(dst, src, 2)
41+ want := []byte{0, referenceSRGB8(0x3800), 255, 255, 255, 0, 0, 255}
42+ for i := range want {
43+ if dst[i] != want[i] {
44+ t.Fatalf("byte %d: got %v, want %v", i, dst, want)
45+ }
46+ }
47+}
48+
49+func BenchmarkHdrRow(b *testing.B) {
50+ const n = 5120
51+ src := make([]byte, n*8)
52+ for i := range n * 4 {
53+ binary.LittleEndian.PutUint16(src[i*2:], uint16(i*7919))
54+ }
55+ dst := make([]byte, n*4)
56+ halfToSRGB()
57+ b.SetBytes(n * 8)
58+ for b.Loop() {
59+ hdrRow(dst, src, n)
60+ }
61+}
A internal/adapters/vulkan/hdr_composition_test.go +200 -0
@@ -0,0 +1,200 @@
1+package vulkan
2+
3+import (
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+
15+func 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+ if r.readbackBytes != 4 {
23+ t.Fatalf("SDR readback bytes/pixel = %d", r.readbackBytes)
24+ }
25+ r.SetHDR(203)
26+ if r.hdrError == nil && r.readbackBytes != 8 {
27+ t.Fatalf("HDR readback bytes/pixel = %d", r.readbackBytes)
28+ }
29+ r.hdrReadback = true
30+ if r.physical == 0 {
31+ t.Skip("no exportable GPU")
32+ }
33+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
34+ if len(r.hdrMods) == 0 {
35+ t.Skip("no HDR transfer-src modifier")
36+ }
37+ bufs, err := r.ExportTargets(1, r.hdrMods)
38+ if err != nil {
39+ t.Skipf("no HDR target: %v", err)
40+ }
41+ t.Cleanup(func() { bufs[0].Planes[0].File.Close() })
42+ return r
43+}
44+
45+// Readback of a test-only PQ target (production targets are not transfer-src).
46+func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
47+ t.Helper()
48+ if err := r.waitFrame(r.submitted); err != nil {
49+ t.Fatal(err)
50+ }
51+ target := r.targets[0]
52+ err := r.oneShot(func(cmd vk.CommandBuffer) {
53+ d := r.dd
54+ 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}
55+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
56+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
57+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
58+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
59+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
60+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
61+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
62+ })
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ words := unsafe.Slice((*uint32)(r.mapped), r.width*r.height)
67+ pixel := words[y*r.width+x]
68+ return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
69+}
70+
71+func TestHDRWindowedComposition(t *testing.T) {
72+ r := hdrTestRenderer(t)
73+ format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
74+ if !slices.Contains(r.DMABuf().Formats, format) {
75+ t.Skip("linear XR30 import unavailable")
76+ }
77+ // 1000-nit neutral, red and blue BT.2020 client pixels.
78+ nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
79+ f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
80+ v := nits[min(x/20, 2)]
81+ b, g, red := uint32(math.Round(pqEncode(v[2])*1023)), uint32(math.Round(pqEncode(v[1])*1023)), uint32(math.Round(pqEncode(v[0])*1023))
82+ p := b | g<<10 | red<<20
83+ return [4]byte{byte(p), byte(p >> 8), byte(p >> 16), byte(p >> 24)}
84+ })
85+ buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
86+ 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}}
87+ scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
88+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
89+ if err := render(r, scene, contents); err != nil {
90+ t.Fatal(err)
91+ }
92+ for i, v := range nits {
93+ got := hdrTargetAt(t, r, i*20+5, 8)
94+ want := [3]float64{
95+ pqEncode(v[0]),
96+ pqEncode(v[1]),
97+ pqEncode(v[2]),
98+ }
99+ for ch := range got {
100+ if math.Abs(got[ch]-want[ch]) > .012 {
101+ t.Errorf("sample %d channel %d got %.4f want %.4f", i, ch, got[ch], want[ch])
102+ }
103+ }
104+ }
105+ neutral := hdrTargetAt(t, r, 5, 8)
106+ if math.Abs(neutral[0]-pqEncode(1000)) > .008 {
107+ t.Fatalf("1000 nits clipped: %v", neutral)
108+ }
109+ if px := r.Pixels().RGBAAt(5, 8); !near(px, color.RGBA{255, 255, 255, 255}, 2) {
110+ t.Fatalf("clipped capture pixel %v", px)
111+ }
112+ dst := make([]byte, 8)
113+ if err := r.Capture(image.Rect(5, 8, 7, 9), dst, 8); err != nil {
114+ t.Fatal(err)
115+ }
116+ if dst[0] != 255 || dst[1] != 255 || dst[2] != 255 || dst[3] != 255 {
117+ t.Fatalf("capture %v", dst)
118+ }
119+ before := r.redrawn
120+ if err := render(r, scene, contents); err != nil {
121+ t.Fatal(err)
122+ }
123+ if r.redrawn-before >= r.width*r.height {
124+ t.Fatal("unchanged HDR frame redrew full image")
125+ }
126+ if got := hdrTargetAt(t, r, 5, 8); math.Abs(got[0]-pqEncode(1000)) > .008 {
127+ t.Fatalf("partial redraw lost highlights: %v", got)
128+ }
129+}
130+
131+func TestHDRLinearBlend(t *testing.T) {
132+ r := hdrTestRenderer(t)
133+ // Two translucent SDR overlays over black: half white then half black.
134+ white := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{128, 128, 128, 128}))
135+ black := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{0, 0, 0, 128}))
136+ 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}}}}
137+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *white, 2: *black}); err != nil {
138+ t.Fatal(err)
139+ }
140+ got := hdrTargetAt(t, r, 5, 5)
141+ want := pqEncode(.25 * 203)
142+ for i, v := range got {
143+ if math.Abs(v-want) > .012 {
144+ t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
145+ }
146+ }
147+}
148+
149+func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
150+ r := hdrTestRenderer(t)
151+ const background = "#804020"
152+ const border = "#4080c0"
153+ 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}}}}
154+ // The window body is a solid fill; the separator is another solid draw.
155+ content := solidContent(t, 28, 12, color.RGBA{128, 64, 32, 255})
156+ content.ID, content.Seq = 1, 1
157+ check := func(label string) {
158+ t.Helper()
159+ for _, point := range []struct {
160+ x, y int
161+ hex string
162+ }{{2, 2, background}, {16, 4, border}, {24, 8, background}} {
163+ got := hdrTargetAt(t, r, point.x, point.y)
164+ col := parseColor(point.hex)
165+ var linear [3]float64
166+ for ch, b := range col {
167+ v := float64(b) / 255
168+ if v > .04045 {
169+ v = math.Pow((v+.055)/1.055, 2.4)
170+ } else {
171+ v /= 12.92
172+ }
173+ linear[ch] = v
174+ }
175+ matrix := [3][3]float64{{.627404, .329283, .043313}, {.069097, .919540, .011362}, {.016391, .088013, .895595}}
176+ for ch := range got {
177+ want := pqEncode((matrix[ch][0]*linear[0] + matrix[ch][1]*linear[1] + matrix[ch][2]*linear[2]) * 203)
178+ if math.Abs(got[ch]-want) > .012 {
179+ t.Errorf("%s (%d,%d) channel %d got %.4f want %.4f", label, point.x, point.y, ch, got[ch], want)
180+ }
181+ }
182+ }
183+ }
184+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: content}); err != nil {
185+ t.Fatal(err)
186+ }
187+ check("full")
188+ before := r.redrawn
189+ // A changed window sequence damages only its rect; the background is
190+ // repainted there via a solid draw instead of the attachment clear.
191+ c := content
192+ c.Seq = 2
193+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
194+ t.Fatal(err)
195+ }
196+ if r.redrawn-before >= r.width*r.height {
197+ t.Fatal("expected partial redraw")
198+ }
199+ check("partial")
200+}
@@ -0,0 +1,200 @@
1+package vulkan
2+
3+import (
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+
15+func 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+ if r.readbackBytes != 4 {
23+ t.Fatalf("SDR readback bytes/pixel = %d", r.readbackBytes)
24+ }
25+ r.SetHDR(203)
26+ if r.hdrError == nil && r.readbackBytes != 8 {
27+ t.Fatalf("HDR readback bytes/pixel = %d", r.readbackBytes)
28+ }
29+ r.hdrReadback = true
30+ if r.physical == 0 {
31+ t.Skip("no exportable GPU")
32+ }
33+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
34+ if len(r.hdrMods) == 0 {
35+ t.Skip("no HDR transfer-src modifier")
36+ }
37+ bufs, err := r.ExportTargets(1, r.hdrMods)
38+ if err != nil {
39+ t.Skipf("no HDR target: %v", err)
40+ }
41+ t.Cleanup(func() { bufs[0].Planes[0].File.Close() })
42+ return r
43+}
44+
45+// Readback of a test-only PQ target (production targets are not transfer-src).
46+func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
47+ t.Helper()
48+ if err := r.waitFrame(r.submitted); err != nil {
49+ t.Fatal(err)
50+ }
51+ target := r.targets[0]
52+ err := r.oneShot(func(cmd vk.CommandBuffer) {
53+ d := r.dd
54+ 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}
55+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
56+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
57+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
58+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
59+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
60+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
61+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
62+ })
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ words := unsafe.Slice((*uint32)(r.mapped), r.width*r.height)
67+ pixel := words[y*r.width+x]
68+ return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
69+}
70+
71+func TestHDRWindowedComposition(t *testing.T) {
72+ r := hdrTestRenderer(t)
73+ format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
74+ if !slices.Contains(r.DMABuf().Formats, format) {
75+ t.Skip("linear XR30 import unavailable")
76+ }
77+ // 1000-nit neutral, red and blue BT.2020 client pixels.
78+ nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
79+ f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
80+ v := nits[min(x/20, 2)]
81+ b, g, red := uint32(math.Round(pqEncode(v[2])*1023)), uint32(math.Round(pqEncode(v[1])*1023)), uint32(math.Round(pqEncode(v[0])*1023))
82+ p := b | g<<10 | red<<20
83+ return [4]byte{byte(p), byte(p >> 8), byte(p >> 16), byte(p >> 24)}
84+ })
85+ buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
86+ 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}}
87+ scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
88+ contents := map[ports.WindowID]ports.SurfaceContent{1: c}
89+ if err := render(r, scene, contents); err != nil {
90+ t.Fatal(err)
91+ }
92+ for i, v := range nits {
93+ got := hdrTargetAt(t, r, i*20+5, 8)
94+ want := [3]float64{
95+ pqEncode(v[0]),
96+ pqEncode(v[1]),
97+ pqEncode(v[2]),
98+ }
99+ for ch := range got {
100+ if math.Abs(got[ch]-want[ch]) > .012 {
101+ t.Errorf("sample %d channel %d got %.4f want %.4f", i, ch, got[ch], want[ch])
102+ }
103+ }
104+ }
105+ neutral := hdrTargetAt(t, r, 5, 8)
106+ if math.Abs(neutral[0]-pqEncode(1000)) > .008 {
107+ t.Fatalf("1000 nits clipped: %v", neutral)
108+ }
109+ if px := r.Pixels().RGBAAt(5, 8); !near(px, color.RGBA{255, 255, 255, 255}, 2) {
110+ t.Fatalf("clipped capture pixel %v", px)
111+ }
112+ dst := make([]byte, 8)
113+ if err := r.Capture(image.Rect(5, 8, 7, 9), dst, 8); err != nil {
114+ t.Fatal(err)
115+ }
116+ if dst[0] != 255 || dst[1] != 255 || dst[2] != 255 || dst[3] != 255 {
117+ t.Fatalf("capture %v", dst)
118+ }
119+ before := r.redrawn
120+ if err := render(r, scene, contents); err != nil {
121+ t.Fatal(err)
122+ }
123+ if r.redrawn-before >= r.width*r.height {
124+ t.Fatal("unchanged HDR frame redrew full image")
125+ }
126+ if got := hdrTargetAt(t, r, 5, 8); math.Abs(got[0]-pqEncode(1000)) > .008 {
127+ t.Fatalf("partial redraw lost highlights: %v", got)
128+ }
129+}
130+
131+func TestHDRLinearBlend(t *testing.T) {
132+ r := hdrTestRenderer(t)
133+ // Two translucent SDR overlays over black: half white then half black.
134+ white := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{128, 128, 128, 128}))
135+ black := shmContent(t, 64, 16, 64*4, fill(64, 16, [4]byte{0, 0, 0, 128}))
136+ 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}}}}
137+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *white, 2: *black}); err != nil {
138+ t.Fatal(err)
139+ }
140+ got := hdrTargetAt(t, r, 5, 5)
141+ want := pqEncode(.25 * 203)
142+ for i, v := range got {
143+ if math.Abs(v-want) > .012 {
144+ t.Errorf("channel %d: got %.4f want %.4f", i, v, want)
145+ }
146+ }
147+}
148+
149+func TestHDRSolidColorsMatchPartialRedraw(t *testing.T) {
150+ r := hdrTestRenderer(t)
151+ const background = "#804020"
152+ const border = "#4080c0"
153+ 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}}}}
154+ // The window body is a solid fill; the separator is another solid draw.
155+ content := solidContent(t, 28, 12, color.RGBA{128, 64, 32, 255})
156+ content.ID, content.Seq = 1, 1
157+ check := func(label string) {
158+ t.Helper()
159+ for _, point := range []struct {
160+ x, y int
161+ hex string
162+ }{{2, 2, background}, {16, 4, border}, {24, 8, background}} {
163+ got := hdrTargetAt(t, r, point.x, point.y)
164+ col := parseColor(point.hex)
165+ var linear [3]float64
166+ for ch, b := range col {
167+ v := float64(b) / 255
168+ if v > .04045 {
169+ v = math.Pow((v+.055)/1.055, 2.4)
170+ } else {
171+ v /= 12.92
172+ }
173+ linear[ch] = v
174+ }
175+ matrix := [3][3]float64{{.627404, .329283, .043313}, {.069097, .919540, .011362}, {.016391, .088013, .895595}}
176+ for ch := range got {
177+ want := pqEncode((matrix[ch][0]*linear[0] + matrix[ch][1]*linear[1] + matrix[ch][2]*linear[2]) * 203)
178+ if math.Abs(got[ch]-want) > .012 {
179+ t.Errorf("%s (%d,%d) channel %d got %.4f want %.4f", label, point.x, point.y, ch, got[ch], want)
180+ }
181+ }
182+ }
183+ }
184+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: content}); err != nil {
185+ t.Fatal(err)
186+ }
187+ check("full")
188+ before := r.redrawn
189+ // A changed window sequence damages only its rect; the background is
190+ // repainted there via a solid draw instead of the attachment clear.
191+ c := content
192+ c.Seq = 2
193+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
194+ t.Fatal(err)
195+ }
196+ if r.redrawn-before >= r.width*r.height {
197+ t.Fatal("expected partial redraw")
198+ }
199+ check("partial")
200+}
A internal/adapters/vulkan/pq_client_test.go +102 -0
@@ -0,0 +1,102 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A PQ 2101010 client is decoded before the SDR intermediate, which clips
13+// highlights above reference white. The GPU and udmabuf are optional.
14+func TestPQClientComposition(t *testing.T) {
15+ r, err := New(64, 4)
16+ if err != nil {
17+ t.Skipf("Vulkan unavailable: %v", err)
18+ }
19+ defer r.Close()
20+ type sample struct {
21+ rgb [3]float64
22+ alpha float64
23+ }
24+ // Neutral values test absolute luminance; saturated colors exercise the
25+ // BT.2020 -> BT.709 matrix (and the clipping of out-of-gamut channels).
26+ samples := []sample{
27+ {[3]float64{100, 100, 100}, 1}, {[3]float64{203, 203, 203}, 1}, {[3]float64{1000, 1000, 1000}, 1},
28+ {[3]float64{100, 0, 0}, 1}, {[3]float64{0, 0, 100}, 1}, {[3]float64{0, 100, 0}, .6666667},
29+ }
30+ for _, name := range [][4]byte{{'X', 'R', '3', '0'}, {'X', 'B', '3', '0'}, {'A', 'B', '3', '0'}} {
31+ t.Run(string(name[:]), func(t *testing.T) {
32+ format := ports.DMABufFormat{Format: fourcc(name[0], name[1], name[2], name[3]), Modifier: 0}
33+ if !slices.Contains(r.DMABuf().Formats, format) {
34+ t.Skipf("linear %s unavailable", name)
35+ }
36+ // Store premultiplied PQ codes for alpha buffers. Alpha uses two bits.
37+ f := udmabuf(t, 64, 4, func(x, _ int) [4]byte {
38+ s := samples[min(x/10, len(samples)-1)]
39+ a := uint32(math.Round(s.alpha * 3))
40+ if name[0] == 'X' {
41+ a = 0 // X bits are undefined, and must be ignored.
42+ s.alpha = 1
43+ }
44+ var ch [3]uint32
45+ for i, n := range s.rgb {
46+ ch[i] = uint32(math.Round(pqEncode(n) * s.alpha * 1023))
47+ }
48+ var packed uint32
49+ if name[1] == 'R' {
50+ packed = ch[2] | ch[1]<<10 | ch[0]<<20
51+ } else {
52+ packed = ch[0] | ch[1]<<10 | ch[2]<<20
53+ }
54+ packed |= a << 30
55+ return [4]byte{byte(packed), byte(packed >> 8), byte(packed >> 16), byte(packed >> 24)}
56+ })
57+ buf := &ports.DMABuf{ID: uint64(format.Format), Width: 64, Height: 4, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
58+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 4, Opaque: name[0] == 'X', Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}, DMABuf: buf}
59+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 4}}}}
60+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
61+ t.Fatal(err)
62+ }
63+ for i, s := range samples {
64+ alpha := s.alpha
65+ if name[0] == 'X' {
66+ alpha = 1
67+ }
68+ var nits [3]float64
69+ for j, n := range s.rgb {
70+ sourceAlpha := s.alpha
71+ if name[0] == 'X' {
72+ sourceAlpha = 1
73+ }
74+ code := math.Round(pqEncode(n)*sourceAlpha*1023) / 1023
75+ p := math.Pow(code/sourceAlpha, 32.0/2523)
76+ nits[j] = math.Pow(math.Max(p-3424.0/4096, 0)/(2413.0/128-p*2392.0/128), 16384.0/2610) * 10000
77+ }
78+ linear := [3]float64{
79+ 1.660491*nits[0] - 0.587641*nits[1] - 0.072850*nits[2],
80+ -0.124550*nits[0] + 1.132900*nits[1] - 0.008349*nits[2],
81+ -0.018151*nits[0] - 0.100579*nits[1] + 1.118730*nits[2],
82+ }
83+ want := color.RGBA{A: 255}
84+ channels := []*uint8{&want.R, &want.G, &want.B}
85+ for j, n := range linear {
86+ v := math.Max(0, math.Min(n/203, 1))
87+ if v <= .0031308 {
88+ v *= 12.92
89+ } else {
90+ v = 1.055*math.Pow(v, 1/2.4) - .055
91+ }
92+ // Composition blends premultiplied source onto a black opaque fill.
93+ *channels[j] = uint8(math.Round(v * alpha * 255))
94+ }
95+ got := r.Pixels().RGBAAt(i*10+5, 2)
96+ if !near(got, want, 4) {
97+ t.Errorf("sample %d: got %v want %v", i, got, want)
98+ }
99+ }
100+ })
101+ }
102+}
@@ -0,0 +1,102 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "slices"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// A PQ 2101010 client is decoded before the SDR intermediate, which clips
13+// highlights above reference white. The GPU and udmabuf are optional.
14+func TestPQClientComposition(t *testing.T) {
15+ r, err := New(64, 4)
16+ if err != nil {
17+ t.Skipf("Vulkan unavailable: %v", err)
18+ }
19+ defer r.Close()
20+ type sample struct {
21+ rgb [3]float64
22+ alpha float64
23+ }
24+ // Neutral values test absolute luminance; saturated colors exercise the
25+ // BT.2020 -> BT.709 matrix (and the clipping of out-of-gamut channels).
26+ samples := []sample{
27+ {[3]float64{100, 100, 100}, 1}, {[3]float64{203, 203, 203}, 1}, {[3]float64{1000, 1000, 1000}, 1},
28+ {[3]float64{100, 0, 0}, 1}, {[3]float64{0, 0, 100}, 1}, {[3]float64{0, 100, 0}, .6666667},
29+ }
30+ for _, name := range [][4]byte{{'X', 'R', '3', '0'}, {'X', 'B', '3', '0'}, {'A', 'B', '3', '0'}} {
31+ t.Run(string(name[:]), func(t *testing.T) {
32+ format := ports.DMABufFormat{Format: fourcc(name[0], name[1], name[2], name[3]), Modifier: 0}
33+ if !slices.Contains(r.DMABuf().Formats, format) {
34+ t.Skipf("linear %s unavailable", name)
35+ }
36+ // Store premultiplied PQ codes for alpha buffers. Alpha uses two bits.
37+ f := udmabuf(t, 64, 4, func(x, _ int) [4]byte {
38+ s := samples[min(x/10, len(samples)-1)]
39+ a := uint32(math.Round(s.alpha * 3))
40+ if name[0] == 'X' {
41+ a = 0 // X bits are undefined, and must be ignored.
42+ s.alpha = 1
43+ }
44+ var ch [3]uint32
45+ for i, n := range s.rgb {
46+ ch[i] = uint32(math.Round(pqEncode(n) * s.alpha * 1023))
47+ }
48+ var packed uint32
49+ if name[1] == 'R' {
50+ packed = ch[2] | ch[1]<<10 | ch[0]<<20
51+ } else {
52+ packed = ch[0] | ch[1]<<10 | ch[2]<<20
53+ }
54+ packed |= a << 30
55+ return [4]byte{byte(packed), byte(packed >> 8), byte(packed >> 16), byte(packed >> 24)}
56+ })
57+ buf := &ports.DMABuf{ID: uint64(format.Format), Width: 64, Height: 4, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
58+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 4, Opaque: name[0] == 'X', Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}, DMABuf: buf}
59+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 4}}}}
60+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
61+ t.Fatal(err)
62+ }
63+ for i, s := range samples {
64+ alpha := s.alpha
65+ if name[0] == 'X' {
66+ alpha = 1
67+ }
68+ var nits [3]float64
69+ for j, n := range s.rgb {
70+ sourceAlpha := s.alpha
71+ if name[0] == 'X' {
72+ sourceAlpha = 1
73+ }
74+ code := math.Round(pqEncode(n)*sourceAlpha*1023) / 1023
75+ p := math.Pow(code/sourceAlpha, 32.0/2523)
76+ nits[j] = math.Pow(math.Max(p-3424.0/4096, 0)/(2413.0/128-p*2392.0/128), 16384.0/2610) * 10000
77+ }
78+ linear := [3]float64{
79+ 1.660491*nits[0] - 0.587641*nits[1] - 0.072850*nits[2],
80+ -0.124550*nits[0] + 1.132900*nits[1] - 0.008349*nits[2],
81+ -0.018151*nits[0] - 0.100579*nits[1] + 1.118730*nits[2],
82+ }
83+ want := color.RGBA{A: 255}
84+ channels := []*uint8{&want.R, &want.G, &want.B}
85+ for j, n := range linear {
86+ v := math.Max(0, math.Min(n/203, 1))
87+ if v <= .0031308 {
88+ v *= 12.92
89+ } else {
90+ v = 1.055*math.Pow(v, 1/2.4) - .055
91+ }
92+ // Composition blends premultiplied source onto a black opaque fill.
93+ *channels[j] = uint8(math.Round(v * alpha * 255))
94+ }
95+ got := r.Pixels().RGBAAt(i*10+5, 2)
96+ if !near(got, want, 4) {
97+ t.Errorf("sample %d: got %v want %v", i, got, want)
98+ }
99+ }
100+ })
101+ }
102+}
M internal/adapters/vulkan/renderer.go +80 -32
@@ -22,9 +22,9 @@ type Renderer struct {
2222 device vk.Device
2323 dd *vk.DeviceDispatch
2424 queue vk.Queue
25- // own is the composed image until ExportTargets; in HDR it remains
26- // the SDR composition/readback image while targets carry PQ.
25+ // own is the SDR composition image; HDR uses hdrOwn and PQ targets.
2726 own target
27+ hdrOwn target // linear BT.709 in units of SDR reference white
2828 targets []*target
2929 current int
3030 renderMods []uint64
@@ -39,14 +39,15 @@ type Renderer struct {
3939 cursorCache cursorConversion
4040 // last is the target of the last frame; readback copies it into
4141 // buffer only when Pixels asks (stale until then).
42- last *target
43- readBack bool
44- buffer vk.Buffer
45- bufferMemory vk.DeviceMemory
46- mapped unsafe.Pointer
47- pool vk.CommandPool
48- memory vk.PhysicalDeviceMemoryProperties
49- family uint32
42+ last *target
43+ readBack bool
44+ buffer vk.Buffer
45+ bufferMemory vk.DeviceMemory
46+ readbackBytes int // bytes per pixel allocated for the mapped readback buffer
47+ mapped unsafe.Pointer
48+ pool vk.CommandPool
49+ memory vk.PhysicalDeviceMemoryProperties
50+ family uint32
5051 // Frames in flight (frame.go): frame is the last frame recorded,
5152 // submitted the last submitted, completed the last known finished.
5253 slots [frameSlots]frameSlot
@@ -221,28 +222,7 @@ func New(width, height int) (r *Renderer, err error) {
221222 if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil {
222223 return
223224 }
224- size := vk.DeviceSize(width) * vk.DeviceSize(height) * 4
225- bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
226- if err = checked("vkCreateBuffer", r.dd.CreateBuffer(r.device, &bi, nil, &r.buffer)); err != nil {
227- return
228- }
229- r.dd.GetBufferMemoryRequirements(r.device, r.buffer, &req)
230- // Readback (screenshots) reads this buffer: uncached memory is ~100x slower.
231- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
232- if err != nil {
233- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
234- }
235- if err != nil {
236- return
237- }
238- alloc = vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
239- if err = checked("vkAllocateMemory(buffer)", r.dd.AllocateMemory(r.device, &alloc, nil, &r.bufferMemory)); err != nil {
240- return
241- }
242- if err = checked("vkBindBufferMemory", r.dd.BindBufferMemory(r.device, r.buffer, r.bufferMemory, 0)); err != nil {
243- return
244- }
245- if err = checked("vkMapMemory", r.dd.MapMemory(r.device, r.bufferMemory, 0, size, 0, &r.mapped)); err != nil {
225+ if err = r.createReadback(4); err != nil {
246226 return
247227 }
248228 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}
@@ -262,6 +242,65 @@ func New(width, height int) (r *Renderer, err error) {
262242 return
263243 }
264244
245+// createReadback replaces the host buffer only while the device is idle (at
246+// construction or on an SDR→HDR transition). The current buffer stays in use
247+// until the replacement is mapped, so a failed transition keeps SDR readback.
248+func (r *Renderer) createReadback(bytesPerPixel int) (err error) {
249+ d := r.dd
250+ var (
251+ buffer vk.Buffer
252+ memory vk.DeviceMemory
253+ mapped unsafe.Pointer
254+ )
255+ defer func() {
256+ if err == nil {
257+ return
258+ }
259+ if buffer != 0 {
260+ d.DestroyBuffer(r.device, buffer, nil)
261+ }
262+ if memory != 0 {
263+ d.FreeMemory(r.device, memory, nil)
264+ }
265+ }()
266+ size := vk.DeviceSize(r.width) * vk.DeviceSize(r.height) * vk.DeviceSize(bytesPerPixel)
267+ bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
268+ if err := checked("vkCreateBuffer", d.CreateBuffer(r.device, &bi, nil, &buffer)); err != nil {
269+ return err
270+ }
271+ var req vk.MemoryRequirements
272+ d.GetBufferMemoryRequirements(r.device, buffer, &req)
273+ // Readback (screenshots) reads this buffer: prefer cached host memory.
274+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
275+ if err != nil {
276+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
277+ }
278+ if err != nil {
279+ return err
280+ }
281+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
282+ if err := checked("vkAllocateMemory(buffer)", d.AllocateMemory(r.device, &alloc, nil, &memory)); err != nil {
283+ return err
284+ }
285+ if err := checked("vkBindBufferMemory", d.BindBufferMemory(r.device, buffer, memory, 0)); err != nil {
286+ return err
287+ }
288+ if err := checked("vkMapMemory", d.MapMemory(r.device, memory, 0, size, 0, &mapped)); err != nil {
289+ return err
290+ }
291+ if r.mapped != nil {
292+ d.UnmapMemory(r.device, r.bufferMemory)
293+ }
294+ if r.buffer != 0 {
295+ d.DestroyBuffer(r.device, r.buffer, nil)
296+ }
297+ if r.bufferMemory != 0 {
298+ d.FreeMemory(r.device, r.bufferMemory, nil)
299+ }
300+ r.buffer, r.bufferMemory, r.mapped, r.readbackBytes = buffer, memory, mapped, bytesPerPixel
301+ return nil
302+}
303+
265304 func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) {
266305 for i := uint32(0); i < r.memory.MemoryTypeCount; i++ {
267306 if bits&(uint32(1)<<i) != 0 && r.memory.MemoryTypes[i].PropertyFlags&props == props {
@@ -276,6 +315,10 @@ func (r *Renderer) Pixels() *image.RGBA {
276315 if r.mapped == nil || r.readback() != nil {
277316 return out
278317 }
318+ if r.last == &r.hdrOwn {
319+ r.hdrRegion(out.Pix, out.Stride, 0, 0, r.width, r.height, true)
320+ return out
321+ }
279322 src := unsafe.Slice((*byte)(r.mapped), len(out.Pix))
280323 for i := 0; i < len(src); i += 4 {
281324 // The output is opaque: x-format client buffers leave alpha undefined.
@@ -297,6 +340,10 @@ func (r *Renderer) Capture(region image.Rectangle, dst []byte, stride int) error
297340 if err := r.readback(); err != nil {
298341 return err
299342 }
343+ if r.last == &r.hdrOwn {
344+ r.hdrRegion(dst, stride, region.Min.X, region.Min.Y, w, h, false)
345+ return nil
346+ }
300347 src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)
301348 for y := range h {
302349 start := ((region.Min.Y+y)*r.width + region.Min.X) * 4
@@ -393,6 +440,7 @@ func (r *Renderer) Close() {
393440 r.bufferMemory = 0
394441 }
395442 r.destroyComposer()
443+ r.freeTarget(&r.hdrOwn)
396444 r.freeTarget(&r.own)
397445 d.DestroyDevice(r.device, nil)
398446 r.device = 0
@@ -22,9 +22,9 @@ type Renderer struct {
22 device vk.Device22 device vk.Device
23 dd *vk.DeviceDispatch23 dd *vk.DeviceDispatch
24 queue vk.Queue24 queue vk.Queue
25- // own is the composed image until ExportTargets; in HDR it remains25+ // own is the SDR composition image; HDR uses hdrOwn and PQ targets.
26- // the SDR composition/readback image while targets carry PQ.
27 own target26 own target
27+ hdrOwn target // linear BT.709 in units of SDR reference white
28 targets []*target28 targets []*target
29 current int29 current int
30 renderMods []uint6430 renderMods []uint64
@@ -39,14 +39,15 @@ type Renderer struct {
39 cursorCache cursorConversion39 cursorCache cursorConversion
40 // last is the target of the last frame; readback copies it into40 // last is the target of the last frame; readback copies it into
41 // buffer only when Pixels asks (stale until then).41 // buffer only when Pixels asks (stale until then).
42- last *target42+ last *target
43- readBack bool43+ readBack bool
44- buffer vk.Buffer44+ buffer vk.Buffer
45- bufferMemory vk.DeviceMemory45+ bufferMemory vk.DeviceMemory
46- mapped unsafe.Pointer46+ readbackBytes int // bytes per pixel allocated for the mapped readback buffer
47- pool vk.CommandPool47+ mapped unsafe.Pointer
48- memory vk.PhysicalDeviceMemoryProperties48+ pool vk.CommandPool
49- family uint3249+ memory vk.PhysicalDeviceMemoryProperties
50+ family uint32
50 // Frames in flight (frame.go): frame is the last frame recorded,51 // Frames in flight (frame.go): frame is the last frame recorded,
51 // submitted the last submitted, completed the last known finished.52 // submitted the last submitted, completed the last known finished.
52 slots [frameSlots]frameSlot53 slots [frameSlots]frameSlot
@@ -221,28 +222,7 @@ func New(width, height int) (r *Renderer, err error) {
221 if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil {222 if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil {
222 return223 return
223 }224 }
224- size := vk.DeviceSize(width) * vk.DeviceSize(height) * 4225+ if err = r.createReadback(4); err != nil {
225- bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
226- if err = checked("vkCreateBuffer", r.dd.CreateBuffer(r.device, &bi, nil, &r.buffer)); err != nil {
227- return
228- }
229- r.dd.GetBufferMemoryRequirements(r.device, r.buffer, &req)
230- // Readback (screenshots) reads this buffer: uncached memory is ~100x slower.
231- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
232- if err != nil {
233- kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
234- }
235- if err != nil {
236- return
237- }
238- alloc = vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
239- if err = checked("vkAllocateMemory(buffer)", r.dd.AllocateMemory(r.device, &alloc, nil, &r.bufferMemory)); err != nil {
240- return
241- }
242- if err = checked("vkBindBufferMemory", r.dd.BindBufferMemory(r.device, r.buffer, r.bufferMemory, 0)); err != nil {
243- return
244- }
245- if err = checked("vkMapMemory", r.dd.MapMemory(r.device, r.bufferMemory, 0, size, 0, &r.mapped)); err != nil {
246 return226 return
247 }227 }
248 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}228 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}
@@ -262,6 +242,65 @@ func New(width, height int) (r *Renderer, err error) {
262 return242 return
263 }243 }
264 244
245+// createReadback replaces the host buffer only while the device is idle (at
246+// construction or on an SDR→HDR transition). The current buffer stays in use
247+// until the replacement is mapped, so a failed transition keeps SDR readback.
248+func (r *Renderer) createReadback(bytesPerPixel int) (err error) {
249+ d := r.dd
250+ var (
251+ buffer vk.Buffer
252+ memory vk.DeviceMemory
253+ mapped unsafe.Pointer
254+ )
255+ defer func() {
256+ if err == nil {
257+ return
258+ }
259+ if buffer != 0 {
260+ d.DestroyBuffer(r.device, buffer, nil)
261+ }
262+ if memory != 0 {
263+ d.FreeMemory(r.device, memory, nil)
264+ }
265+ }()
266+ size := vk.DeviceSize(r.width) * vk.DeviceSize(r.height) * vk.DeviceSize(bytesPerPixel)
267+ bi := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: size, Usage: vk.BufferUsageTransferDstBit, SharingMode: vk.SharingModeExclusive}
268+ if err := checked("vkCreateBuffer", d.CreateBuffer(r.device, &bi, nil, &buffer)); err != nil {
269+ return err
270+ }
271+ var req vk.MemoryRequirements
272+ d.GetBufferMemoryRequirements(r.device, buffer, &req)
273+ // Readback (screenshots) reads this buffer: prefer cached host memory.
274+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit|vk.MemoryPropertyHostCachedBit)
275+ if err != nil {
276+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
277+ }
278+ if err != nil {
279+ return err
280+ }
281+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
282+ if err := checked("vkAllocateMemory(buffer)", d.AllocateMemory(r.device, &alloc, nil, &memory)); err != nil {
283+ return err
284+ }
285+ if err := checked("vkBindBufferMemory", d.BindBufferMemory(r.device, buffer, memory, 0)); err != nil {
286+ return err
287+ }
288+ if err := checked("vkMapMemory", d.MapMemory(r.device, memory, 0, size, 0, &mapped)); err != nil {
289+ return err
290+ }
291+ if r.mapped != nil {
292+ d.UnmapMemory(r.device, r.bufferMemory)
293+ }
294+ if r.buffer != 0 {
295+ d.DestroyBuffer(r.device, r.buffer, nil)
296+ }
297+ if r.bufferMemory != 0 {
298+ d.FreeMemory(r.device, r.bufferMemory, nil)
299+ }
300+ r.buffer, r.bufferMemory, r.mapped, r.readbackBytes = buffer, memory, mapped, bytesPerPixel
301+ return nil
302+}
303+
265 func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) {304 func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) {
266 for i := uint32(0); i < r.memory.MemoryTypeCount; i++ {305 for i := uint32(0); i < r.memory.MemoryTypeCount; i++ {
267 if bits&(uint32(1)<<i) != 0 && r.memory.MemoryTypes[i].PropertyFlags&props == props {306 if bits&(uint32(1)<<i) != 0 && r.memory.MemoryTypes[i].PropertyFlags&props == props {
@@ -276,6 +315,10 @@ func (r *Renderer) Pixels() *image.RGBA {
276 if r.mapped == nil || r.readback() != nil {315 if r.mapped == nil || r.readback() != nil {
277 return out316 return out
278 }317 }
318+ if r.last == &r.hdrOwn {
319+ r.hdrRegion(out.Pix, out.Stride, 0, 0, r.width, r.height, true)
320+ return out
321+ }
279 src := unsafe.Slice((*byte)(r.mapped), len(out.Pix))322 src := unsafe.Slice((*byte)(r.mapped), len(out.Pix))
280 for i := 0; i < len(src); i += 4 {323 for i := 0; i < len(src); i += 4 {
281 // The output is opaque: x-format client buffers leave alpha undefined.324 // The output is opaque: x-format client buffers leave alpha undefined.
@@ -297,6 +340,10 @@ func (r *Renderer) Capture(region image.Rectangle, dst []byte, stride int) error
297 if err := r.readback(); err != nil {340 if err := r.readback(); err != nil {
298 return err341 return err
299 }342 }
343+ if r.last == &r.hdrOwn {
344+ r.hdrRegion(dst, stride, region.Min.X, region.Min.Y, w, h, false)
345+ return nil
346+ }
300 src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)347 src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)
301 for y := range h {348 for y := range h {
302 start := ((region.Min.Y+y)*r.width + region.Min.X) * 4349 start := ((region.Min.Y+y)*r.width + region.Min.X) * 4
@@ -393,6 +440,7 @@ func (r *Renderer) Close() {
393 r.bufferMemory = 0440 r.bufferMemory = 0
394 }441 }
395 r.destroyComposer()442 r.destroyComposer()
443+ r.freeTarget(&r.hdrOwn)
396 r.freeTarget(&r.own)444 r.freeTarget(&r.own)
397 d.DestroyDevice(r.device, nil)445 d.DestroyDevice(r.device, nil)
398 r.device = 0446 r.device = 0
M internal/adapters/vulkan/scene.go +31 -0
@@ -250,6 +250,21 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
250250 return
251251 }
252252 dr := r.contentDraw(rect, full, content.Width, content.Height, modeImage, content.Opaque)
253+ r.setContentColor(&dr, content.Color)
254+ if im.yuv {
255+ dr.pc.misc[1] |= flagYUV
256+ if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
257+ dr.pc.misc[1] |= flagP010
258+ }
259+ coeff, ran := content.Color.Coefficients, content.Color.Range
260+ if coeff == 0 {
261+ coeff = 2 /* BT.709 */
262+ }
263+ if ran == 0 {
264+ ran = 2 /* limited */
265+ }
266+ dr.pc.buf[3] = uint32(coeff) | uint32(ran)<<8 | uint32(content.Color.Chroma)<<16
267+ }
253268 dr.set, dr.im = im.set, im
254269 dr.acquire = content.Acquire
255270 w.draws = append(w.draws, dr)
@@ -271,11 +286,27 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
271286 return
272287 }
273288 dr := r.contentDraw(rect, full, content.Width, content.Height, modeBuffer, content.Opaque)
289+ r.setContentColor(&dr, content.Color)
274290 dr.set = c.set
275291 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
276292 w.draws = append(w.draws, dr)
277293 }
278294
295+// setContentColor selects only color encodings the protocol accepts.
296+func (r *Renderer) setContentColor(dr *draw, c ports.SurfaceColor) {
297+ if c.IsPQ2020() {
298+ dr.pc.misc[1] |= flagPQ
299+ } else if c.IsExtendedLinear() {
300+ dr.pc.misc[1] |= flagExtendedLinear
301+ } else {
302+ return
303+ }
304+ dr.pc.color[0] = float32(r.hdrNits)
305+ if dr.pc.color[0] <= 0 {
306+ dr.pc.color[0] = 203
307+ }
308+}
309+
279310 func parseColor(s string) [3]uint8 {
280311 var c [3]uint8
281312 if len(s) != 7 || s[0] != '#' {
@@ -250,6 +250,21 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
250 return250 return
251 }251 }
252 dr := r.contentDraw(rect, full, content.Width, content.Height, modeImage, content.Opaque)252 dr := r.contentDraw(rect, full, content.Width, content.Height, modeImage, content.Opaque)
253+ r.setContentColor(&dr, content.Color)
254+ if im.yuv {
255+ dr.pc.misc[1] |= flagYUV
256+ if content.DMABuf.Format == fourcc('P', '0', '1', '0') {
257+ dr.pc.misc[1] |= flagP010
258+ }
259+ coeff, ran := content.Color.Coefficients, content.Color.Range
260+ if coeff == 0 {
261+ coeff = 2 /* BT.709 */
262+ }
263+ if ran == 0 {
264+ ran = 2 /* limited */
265+ }
266+ dr.pc.buf[3] = uint32(coeff) | uint32(ran)<<8 | uint32(content.Color.Chroma)<<16
267+ }
253 dr.set, dr.im = im.set, im268 dr.set, dr.im = im.set, im
254 dr.acquire = content.Acquire269 dr.acquire = content.Acquire
255 w.draws = append(w.draws, dr)270 w.draws = append(w.draws, dr)
@@ -271,11 +286,27 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon
271 return286 return
272 }287 }
273 dr := r.contentDraw(rect, full, content.Width, content.Height, modeBuffer, content.Opaque)288 dr := r.contentDraw(rect, full, content.Width, content.Height, modeBuffer, content.Opaque)
289+ r.setContentColor(&dr, content.Color)
274 dr.set = c.set290 dr.set = c.set
275 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}291 dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0}
276 w.draws = append(w.draws, dr)292 w.draws = append(w.draws, dr)
277 }293 }
278 294
295+// setContentColor selects only color encodings the protocol accepts.
296+func (r *Renderer) setContentColor(dr *draw, c ports.SurfaceColor) {
297+ if c.IsPQ2020() {
298+ dr.pc.misc[1] |= flagPQ
299+ } else if c.IsExtendedLinear() {
300+ dr.pc.misc[1] |= flagExtendedLinear
301+ } else {
302+ return
303+ }
304+ dr.pc.color[0] = float32(r.hdrNits)
305+ if dr.pc.color[0] <= 0 {
306+ dr.pc.color[0] = 203
307+ }
308+}
309+
279 func parseColor(s string) [3]uint8 {310 func parseColor(s string) [3]uint8 {
280 var c [3]uint8311 var c [3]uint8
281 if len(s) != 7 || s[0] != '#' {312 if len(s) != 7 || s[0] != '#' {
M internal/adapters/vulkan/shaders/compose.frag +58 -0
@@ -15,6 +15,7 @@ layout(push_constant) uniform Draw {
1515
1616 layout(set = 0, binding = 0) uniform sampler2D tex;
1717 layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; };
18+layout(set = 0, binding = 2) uniform sampler2D chromaTex;
1819
1920 layout(location = 0) out vec4 color;
2021
@@ -22,6 +23,53 @@ const uint modeSolid = 0u;
2223 const uint modeImage = 1u;
2324 const uint flagOpaque = 1u;
2425 const uint flagExact = 2u;
26+const uint flagPQ = 4u;
27+const uint flagExtendedLinear = 8u;
28+const uint flagYUV = 16u;
29+const uint flagP010 = 32u;
30+
31+// PQ values are absolute nits. BT.2020 is converted in linear light to
32+// BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
33+// SDR reference white are lost (no tone mapping).
34+vec3 pqToSRGB(vec3 v, float whiteNits) {
35+ vec3 p = pow(clamp(v, 0.0, 1.0), vec3(32.0 / 2523.0));
36+ vec3 nits = pow(max(p - vec3(3424.0 / 4096.0), vec3(0.0)) /
37+ max(vec3(2413.0 / 128.0) - p * (2392.0 / 128.0), vec3(0.00001)), vec3(16384.0 / 2610.0)) * 10000.0;
38+ vec3 rgb = vec3(
39+ dot(nits, vec3(1.660491, -0.587641, -0.072850)),
40+ dot(nits, vec3(-0.124550, 1.132900, -0.008349)),
41+ dot(nits, vec3(-0.018151, -0.100579, 1.118730))
42+ );
43+ rgb = clamp(rgb / whiteNits, 0.0, 1.0);
44+ return mix(rgb * 12.92, 1.055 * pow(rgb, vec3(1.0 / 2.4)) - 0.055, greaterThan(rgb, vec3(0.0031308)));
45+}
46+
47+// H.273 Y'CbCr 4:2:0 reconstruction in electrical (non-linear) light.
48+// Type 0 places chroma at x=even luma and halfway between luma rows.
49+// vec2 coordinates are normalized to the chroma image for linear filtering.
50+vec4 sampleYUV(vec2 src) {
51+ ivec2 size = textureSize(tex, 0);
52+ vec2 lumaUV = src / vec2(size);
53+ float y = texture(tex, lumaUV).r;
54+ vec2 chromaUV = vec2((src.x + 0.5) / float(size.x), src.y / float(size.y));
55+ vec2 cbcr = texture(chromaTex, chromaUV).rg;
56+ bool tenBit = (d.misc.y & flagP010) != 0u;
57+ bool limited = ((d.buf.w >> 8) & 255u) == 2u;
58+ float maxCode = tenBit ? 1023.0 : 255.0;
59+ // Packed 10-bit plane views normalize directly to the 0..1023 code range.
60+ float yOff = limited ? (tenBit ? 64.0 : 16.0) / maxCode : 0.0;
61+ float yScale = limited ? maxCode / (tenBit ? 876.0 : 219.0) : 1.0;
62+ float cScale = limited ? maxCode / (tenBit ? 896.0 : 224.0) : 1.0;
63+ float yy = (y - yOff) * yScale;
64+ vec2 cc = (cbcr - vec2(tenBit ? 512.0/1023.0 : 128.0/255.0)) * cScale;
65+ uint coeff = d.buf.w & 255u;
66+ float kr = coeff == 4u ? 0.299 : coeff == 6u ? 0.2627 : 0.2126;
67+ float kb = coeff == 4u ? 0.114 : coeff == 6u ? 0.0593 : 0.0722;
68+ vec3 rgb = vec3(yy + 2.0*(1.0-kr)*cc.y,
69+ yy - 2.0*kb*(1.0-kb)/(1.0-kr-kb)*cc.x - 2.0*kr*(1.0-kr)/(1.0-kr-kb)*cc.y,
70+ yy + 2.0*(1.0-kb)*cc.x);
71+ return vec4(rgb, 1.0);
72+}
2573
2674 // texel reads buffer pixel p, clamped to the buffer (clamp to edge).
2775 vec4 texel(ivec2 p) {
@@ -36,6 +84,8 @@ void main() {
3684 vec4 c;
3785 if (d.misc.x == modeSolid) {
3886 c = d.color;
87+ } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) {
88+ c = sampleYUV(src);
3989 } else if (d.misc.x == modeImage) {
4090 ivec2 size = textureSize(tex, 0);
4191 c = exact ? texelFetch(tex, clamp(ivec2(floor(src)), ivec2(0), size - 1), 0) : texture(tex, src / vec2(size));
@@ -51,5 +101,13 @@ void main() {
51101 if ((d.misc.y & flagOpaque) != 0u) {
52102 c.a = 1.0;
53103 }
104+ if (c.a > 0.0) {
105+ if ((d.misc.y & flagPQ) != 0u) {
106+ c.rgb = pqToSRGB(c.rgb / c.a, d.color.x) * c.a;
107+ } else if ((d.misc.y & flagExtendedLinear) != 0u) {
108+ vec3 v = clamp(c.rgb / c.a * (80.0 / 203.0), 0.0, 1.0);
109+ c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
110+ }
111+ }
54112 color = c;
55113 }
@@ -15,6 +15,7 @@ layout(push_constant) uniform Draw {
15 15
16 layout(set = 0, binding = 0) uniform sampler2D tex;16 layout(set = 0, binding = 0) uniform sampler2D tex;
17 layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; };17 layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; };
18+layout(set = 0, binding = 2) uniform sampler2D chromaTex;
18 19
19 layout(location = 0) out vec4 color;20 layout(location = 0) out vec4 color;
20 21
@@ -22,6 +23,53 @@ const uint modeSolid = 0u;
22 const uint modeImage = 1u;23 const uint modeImage = 1u;
23 const uint flagOpaque = 1u;24 const uint flagOpaque = 1u;
24 const uint flagExact = 2u;25 const uint flagExact = 2u;
26+const uint flagPQ = 4u;
27+const uint flagExtendedLinear = 8u;
28+const uint flagYUV = 16u;
29+const uint flagP010 = 32u;
30+
31+// PQ values are absolute nits. BT.2020 is converted in linear light to
32+// BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
33+// SDR reference white are lost (no tone mapping).
34+vec3 pqToSRGB(vec3 v, float whiteNits) {
35+ vec3 p = pow(clamp(v, 0.0, 1.0), vec3(32.0 / 2523.0));
36+ vec3 nits = pow(max(p - vec3(3424.0 / 4096.0), vec3(0.0)) /
37+ max(vec3(2413.0 / 128.0) - p * (2392.0 / 128.0), vec3(0.00001)), vec3(16384.0 / 2610.0)) * 10000.0;
38+ vec3 rgb = vec3(
39+ dot(nits, vec3(1.660491, -0.587641, -0.072850)),
40+ dot(nits, vec3(-0.124550, 1.132900, -0.008349)),
41+ dot(nits, vec3(-0.018151, -0.100579, 1.118730))
42+ );
43+ rgb = clamp(rgb / whiteNits, 0.0, 1.0);
44+ return mix(rgb * 12.92, 1.055 * pow(rgb, vec3(1.0 / 2.4)) - 0.055, greaterThan(rgb, vec3(0.0031308)));
45+}
46+
47+// H.273 Y'CbCr 4:2:0 reconstruction in electrical (non-linear) light.
48+// Type 0 places chroma at x=even luma and halfway between luma rows.
49+// vec2 coordinates are normalized to the chroma image for linear filtering.
50+vec4 sampleYUV(vec2 src) {
51+ ivec2 size = textureSize(tex, 0);
52+ vec2 lumaUV = src / vec2(size);
53+ float y = texture(tex, lumaUV).r;
54+ vec2 chromaUV = vec2((src.x + 0.5) / float(size.x), src.y / float(size.y));
55+ vec2 cbcr = texture(chromaTex, chromaUV).rg;
56+ bool tenBit = (d.misc.y & flagP010) != 0u;
57+ bool limited = ((d.buf.w >> 8) & 255u) == 2u;
58+ float maxCode = tenBit ? 1023.0 : 255.0;
59+ // Packed 10-bit plane views normalize directly to the 0..1023 code range.
60+ float yOff = limited ? (tenBit ? 64.0 : 16.0) / maxCode : 0.0;
61+ float yScale = limited ? maxCode / (tenBit ? 876.0 : 219.0) : 1.0;
62+ float cScale = limited ? maxCode / (tenBit ? 896.0 : 224.0) : 1.0;
63+ float yy = (y - yOff) * yScale;
64+ vec2 cc = (cbcr - vec2(tenBit ? 512.0/1023.0 : 128.0/255.0)) * cScale;
65+ uint coeff = d.buf.w & 255u;
66+ float kr = coeff == 4u ? 0.299 : coeff == 6u ? 0.2627 : 0.2126;
67+ float kb = coeff == 4u ? 0.114 : coeff == 6u ? 0.0593 : 0.0722;
68+ vec3 rgb = vec3(yy + 2.0*(1.0-kr)*cc.y,
69+ yy - 2.0*kb*(1.0-kb)/(1.0-kr-kb)*cc.x - 2.0*kr*(1.0-kr)/(1.0-kr-kb)*cc.y,
70+ yy + 2.0*(1.0-kb)*cc.x);
71+ return vec4(rgb, 1.0);
72+}
25 73
26 // texel reads buffer pixel p, clamped to the buffer (clamp to edge).74 // texel reads buffer pixel p, clamped to the buffer (clamp to edge).
27 vec4 texel(ivec2 p) {75 vec4 texel(ivec2 p) {
@@ -36,6 +84,8 @@ void main() {
36 vec4 c;84 vec4 c;
37 if (d.misc.x == modeSolid) {85 if (d.misc.x == modeSolid) {
38 c = d.color;86 c = d.color;
87+ } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) {
88+ c = sampleYUV(src);
39 } else if (d.misc.x == modeImage) {89 } else if (d.misc.x == modeImage) {
40 ivec2 size = textureSize(tex, 0);90 ivec2 size = textureSize(tex, 0);
41 c = exact ? texelFetch(tex, clamp(ivec2(floor(src)), ivec2(0), size - 1), 0) : texture(tex, src / vec2(size));91 c = exact ? texelFetch(tex, clamp(ivec2(floor(src)), ivec2(0), size - 1), 0) : texture(tex, src / vec2(size));
@@ -51,5 +101,13 @@ void main() {
51 if ((d.misc.y & flagOpaque) != 0u) {101 if ((d.misc.y & flagOpaque) != 0u) {
52 c.a = 1.0;102 c.a = 1.0;
53 }103 }
104+ if (c.a > 0.0) {
105+ if ((d.misc.y & flagPQ) != 0u) {
106+ c.rgb = pqToSRGB(c.rgb / c.a, d.color.x) * c.a;
107+ } else if ((d.misc.y & flagExtendedLinear) != 0u) {
108+ vec3 v = clamp(c.rgb / c.a * (80.0 / 203.0), 0.0, 1.0);
109+ c.rgb = mix(v * 12.92, 1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, greaterThan(v, vec3(0.0031308))) * c.a;
110+ }
111+ }
54 color = c;112 color = c;
55 }113 }
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/vulkan/shaders/compose_hdr.frag +111 -0
@@ -0,0 +1,111 @@
1+#version 450
2+// Draws a solid fill, a client dmabuf (sampled image) or a client wl_shm
3+// buffer (B8G8R8A8 words in a storage buffer). Client pixels are
4+// premultiplied, as Wayland buffers are: the pipeline blends ONE,
5+// ONE_MINUS_SRC_ALPHA. Scaled draws filter linearly; 1:1 draws read the
6+// texel under the fragment, pixel exact.
7+
8+layout(push_constant) uniform Draw {
9+ ivec4 rect;
10+ vec4 map;
11+ vec4 color;
12+ uvec4 buf;
13+ uvec4 misc;
14+} d;
15+
16+layout(set = 0, binding = 0) uniform sampler2D tex;
17+layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; };
18+layout(set = 0, binding = 2) uniform sampler2D chromaTex;
19+
20+layout(location = 0) out vec4 color;
21+
22+const uint modeSolid = 0u;
23+const uint modeImage = 1u;
24+const uint flagOpaque = 1u;
25+const uint flagExact = 2u;
26+const uint flagPQ = 4u;
27+const uint flagExtendedLinear = 8u;
28+const uint flagYUV = 16u;
29+const uint flagP010 = 32u;
30+
31+// The HDR composition target holds linear BT.709 in SDR-white units.
32+vec3 decodeSRGB(vec3 v) {
33+ return mix(v / 12.92, pow((v + 0.055) / 1.055, vec3(2.4)), greaterThan(v, vec3(0.04045)));
34+}
35+vec3 pqToLinear709(vec3 v, float whiteNits) {
36+ vec3 p = pow(clamp(v, 0.0, 1.0), vec3(32.0 / 2523.0));
37+ vec3 nits = pow(max(p - vec3(3424.0 / 4096.0), vec3(0.0)) /
38+ max(vec3(2413.0 / 128.0) - p * (2392.0 / 128.0), vec3(0.00001)), vec3(16384.0 / 2610.0)) * 10000.0;
39+ return vec3(
40+ dot(nits, vec3(1.660491, -0.587641, -0.072850)),
41+ dot(nits, vec3(-0.124550, 1.132900, -0.008349)),
42+ dot(nits, vec3(-0.018151, -0.100579, 1.118730))
43+ ) / whiteNits;
44+}
45+
46+// H.273 Y'CbCr 4:2:0 reconstruction in electrical (non-linear) light.
47+// Type 0 places chroma at x=even luma and halfway between luma rows.
48+// vec2 coordinates are normalized to the chroma image for linear filtering.
49+vec4 sampleYUV(vec2 src) {
50+ ivec2 size = textureSize(tex, 0);
51+ vec2 lumaUV = src / vec2(size);
52+ float y = texture(tex, lumaUV).r;
53+ vec2 chromaUV = vec2((src.x + 0.5) / float(size.x), src.y / float(size.y));
54+ vec2 cbcr = texture(chromaTex, chromaUV).rg;
55+ bool tenBit = (d.misc.y & flagP010) != 0u;
56+ bool limited = ((d.buf.w >> 8) & 255u) == 2u;
57+ float maxCode = tenBit ? 1023.0 : 255.0;
58+ // Packed 10-bit plane views normalize directly to the 0..1023 code range.
59+ float yOff = limited ? (tenBit ? 64.0 : 16.0) / maxCode : 0.0;
60+ float yScale = limited ? maxCode / (tenBit ? 876.0 : 219.0) : 1.0;
61+ float cScale = limited ? maxCode / (tenBit ? 896.0 : 224.0) : 1.0;
62+ float yy = (y - yOff) * yScale;
63+ vec2 cc = (cbcr - vec2(tenBit ? 512.0/1023.0 : 128.0/255.0)) * cScale;
64+ uint coeff = d.buf.w & 255u;
65+ float kr = coeff == 4u ? 0.299 : coeff == 6u ? 0.2627 : 0.2126;
66+ float kb = coeff == 4u ? 0.114 : coeff == 6u ? 0.0593 : 0.0722;
67+ vec3 rgb = vec3(yy + 2.0*(1.0-kr)*cc.y,
68+ yy - 2.0*kb*(1.0-kb)/(1.0-kr-kb)*cc.x - 2.0*kr*(1.0-kr)/(1.0-kr-kb)*cc.y,
69+ yy + 2.0*(1.0-kb)*cc.x);
70+ return vec4(rgb, 1.0);
71+}
72+
73+// texel reads buffer pixel p, clamped to the buffer (clamp to edge).
74+vec4 texel(ivec2 p) {
75+ p = clamp(p, ivec2(0), ivec2(d.buf.yz) - 1);
76+ // Little-endian B,G,R,A bytes: unpack yields (B, G, R, A).
77+ return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw;
78+}
79+
80+void main() {
81+ vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;
82+ bool exact = (d.misc.y & flagExact) != 0u;
83+ vec4 c;
84+ if (d.misc.x == modeSolid) {
85+ c = vec4(decodeSRGB(d.color.rgb), d.color.a);
86+ } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) {
87+ c = sampleYUV(src);
88+ } else if (d.misc.x == modeImage) {
89+ ivec2 size = textureSize(tex, 0);
90+ c = exact ? texelFetch(tex, clamp(ivec2(floor(src)), ivec2(0), size - 1), 0) : texture(tex, src / vec2(size));
91+ } else if (exact) {
92+ c = texel(ivec2(floor(src)));
93+ } else {
94+ // Bilinear: four texel reads around the sample point.
95+ vec2 p = src - 0.5;
96+ ivec2 i = ivec2(floor(p));
97+ vec2 f = p - vec2(i);
98+ c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
99+ }
100+ if ((d.misc.y & flagOpaque) != 0u) {
101+ c.a = 1.0;
102+ }
103+ if (c.a > 0.0 && d.misc.x != modeSolid) {
104+ // ext_linear has the protocol's default 80 cd/m² reference white.
105+ // Keep negative and above-white values in fp16 until the HDR pass.
106+ vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(c.rgb / c.a, d.color.x) :
107+ (d.misc.y & flagExtendedLinear) != 0u ? c.rgb / c.a * (80.0 / d.color.x) : decodeSRGB(c.rgb / c.a);
108+ c.rgb = linearRGB * c.a;
109+ }
110+ color = c;
111+}
@@ -0,0 +1,111 @@
1+#version 450
2+// Draws a solid fill, a client dmabuf (sampled image) or a client wl_shm
3+// buffer (B8G8R8A8 words in a storage buffer). Client pixels are
4+// premultiplied, as Wayland buffers are: the pipeline blends ONE,
5+// ONE_MINUS_SRC_ALPHA. Scaled draws filter linearly; 1:1 draws read the
6+// texel under the fragment, pixel exact.
7+
8+layout(push_constant) uniform Draw {
9+ ivec4 rect;
10+ vec4 map;
11+ vec4 color;
12+ uvec4 buf;
13+ uvec4 misc;
14+} d;
15+
16+layout(set = 0, binding = 0) uniform sampler2D tex;
17+layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; };
18+layout(set = 0, binding = 2) uniform sampler2D chromaTex;
19+
20+layout(location = 0) out vec4 color;
21+
22+const uint modeSolid = 0u;
23+const uint modeImage = 1u;
24+const uint flagOpaque = 1u;
25+const uint flagExact = 2u;
26+const uint flagPQ = 4u;
27+const uint flagExtendedLinear = 8u;
28+const uint flagYUV = 16u;
29+const uint flagP010 = 32u;
30+
31+// The HDR composition target holds linear BT.709 in SDR-white units.
32+vec3 decodeSRGB(vec3 v) {
33+ return mix(v / 12.92, pow((v + 0.055) / 1.055, vec3(2.4)), greaterThan(v, vec3(0.04045)));
34+}
35+vec3 pqToLinear709(vec3 v, float whiteNits) {
36+ vec3 p = pow(clamp(v, 0.0, 1.0), vec3(32.0 / 2523.0));
37+ vec3 nits = pow(max(p - vec3(3424.0 / 4096.0), vec3(0.0)) /
38+ max(vec3(2413.0 / 128.0) - p * (2392.0 / 128.0), vec3(0.00001)), vec3(16384.0 / 2610.0)) * 10000.0;
39+ return vec3(
40+ dot(nits, vec3(1.660491, -0.587641, -0.072850)),
41+ dot(nits, vec3(-0.124550, 1.132900, -0.008349)),
42+ dot(nits, vec3(-0.018151, -0.100579, 1.118730))
43+ ) / whiteNits;
44+}
45+
46+// H.273 Y'CbCr 4:2:0 reconstruction in electrical (non-linear) light.
47+// Type 0 places chroma at x=even luma and halfway between luma rows.
48+// vec2 coordinates are normalized to the chroma image for linear filtering.
49+vec4 sampleYUV(vec2 src) {
50+ ivec2 size = textureSize(tex, 0);
51+ vec2 lumaUV = src / vec2(size);
52+ float y = texture(tex, lumaUV).r;
53+ vec2 chromaUV = vec2((src.x + 0.5) / float(size.x), src.y / float(size.y));
54+ vec2 cbcr = texture(chromaTex, chromaUV).rg;
55+ bool tenBit = (d.misc.y & flagP010) != 0u;
56+ bool limited = ((d.buf.w >> 8) & 255u) == 2u;
57+ float maxCode = tenBit ? 1023.0 : 255.0;
58+ // Packed 10-bit plane views normalize directly to the 0..1023 code range.
59+ float yOff = limited ? (tenBit ? 64.0 : 16.0) / maxCode : 0.0;
60+ float yScale = limited ? maxCode / (tenBit ? 876.0 : 219.0) : 1.0;
61+ float cScale = limited ? maxCode / (tenBit ? 896.0 : 224.0) : 1.0;
62+ float yy = (y - yOff) * yScale;
63+ vec2 cc = (cbcr - vec2(tenBit ? 512.0/1023.0 : 128.0/255.0)) * cScale;
64+ uint coeff = d.buf.w & 255u;
65+ float kr = coeff == 4u ? 0.299 : coeff == 6u ? 0.2627 : 0.2126;
66+ float kb = coeff == 4u ? 0.114 : coeff == 6u ? 0.0593 : 0.0722;
67+ vec3 rgb = vec3(yy + 2.0*(1.0-kr)*cc.y,
68+ yy - 2.0*kb*(1.0-kb)/(1.0-kr-kb)*cc.x - 2.0*kr*(1.0-kr)/(1.0-kr-kb)*cc.y,
69+ yy + 2.0*(1.0-kb)*cc.x);
70+ return vec4(rgb, 1.0);
71+}
72+
73+// texel reads buffer pixel p, clamped to the buffer (clamp to edge).
74+vec4 texel(ivec2 p) {
75+ p = clamp(p, ivec2(0), ivec2(d.buf.yz) - 1);
76+ // Little-endian B,G,R,A bytes: unpack yields (B, G, R, A).
77+ return unpackUnorm4x8(pixels[d.buf.x + uint(p.y) * d.buf.y + uint(p.x)]).zyxw;
78+}
79+
80+void main() {
81+ vec2 src = d.map.xy + gl_FragCoord.xy * d.map.zw;
82+ bool exact = (d.misc.y & flagExact) != 0u;
83+ vec4 c;
84+ if (d.misc.x == modeSolid) {
85+ c = vec4(decodeSRGB(d.color.rgb), d.color.a);
86+ } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) {
87+ c = sampleYUV(src);
88+ } else if (d.misc.x == modeImage) {
89+ ivec2 size = textureSize(tex, 0);
90+ c = exact ? texelFetch(tex, clamp(ivec2(floor(src)), ivec2(0), size - 1), 0) : texture(tex, src / vec2(size));
91+ } else if (exact) {
92+ c = texel(ivec2(floor(src)));
93+ } else {
94+ // Bilinear: four texel reads around the sample point.
95+ vec2 p = src - 0.5;
96+ ivec2 i = ivec2(floor(p));
97+ vec2 f = p - vec2(i);
98+ c = mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
99+ }
100+ if ((d.misc.y & flagOpaque) != 0u) {
101+ c.a = 1.0;
102+ }
103+ if (c.a > 0.0 && d.misc.x != modeSolid) {
104+ // ext_linear has the protocol's default 80 cd/m² reference white.
105+ // Keep negative and above-white values in fp16 until the HDR pass.
106+ vec3 linearRGB = (d.misc.y & flagPQ) != 0u ? pqToLinear709(c.rgb / c.a, d.color.x) :
107+ (d.misc.y & flagExtendedLinear) != 0u ? c.rgb / c.a * (80.0 / d.color.x) : decodeSRGB(c.rgb / c.a);
108+ c.rgb = linearRGB * c.a;
109+ }
110+ color = c;
111+}
A internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/hdr.frag +1 -4
@@ -3,16 +3,13 @@ layout(set=0, binding=0) uniform sampler2D imageSDR;
33 layout(push_constant) uniform Brightness { float nits; } pc;
44 layout(location=0) out vec4 color;
55
6-vec3 decodeSRGB(vec3 c) {
7- return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), greaterThan(c, vec3(0.04045)));
8-}
96 vec3 encodePQ(vec3 nits) {
107 vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0));
118 return pow((3424.0 / 4096.0 + 2413.0 / 128.0 * x) / (1.0 + 2392.0 / 128.0 * x), vec3(2523.0 / 32.0));
129 }
1310 void main() {
1411 vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0));
15- vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb);
12+ vec3 rgb = texture(imageSDR, uv).rgb;
1613 vec3 bt2020 = mat3(
1714 0.627404, 0.069097, 0.016391,
1815 0.329283, 0.919540, 0.088013,
@@ -3,16 +3,13 @@ layout(set=0, binding=0) uniform sampler2D imageSDR;
3 layout(push_constant) uniform Brightness { float nits; } pc;3 layout(push_constant) uniform Brightness { float nits; } pc;
4 layout(location=0) out vec4 color;4 layout(location=0) out vec4 color;
5 5
6-vec3 decodeSRGB(vec3 c) {
7- return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), greaterThan(c, vec3(0.04045)));
8-}
9 vec3 encodePQ(vec3 nits) {6 vec3 encodePQ(vec3 nits) {
10 vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0));7 vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0));
11 return pow((3424.0 / 4096.0 + 2413.0 / 128.0 * x) / (1.0 + 2392.0 / 128.0 * x), vec3(2523.0 / 32.0));8 return pow((3424.0 / 4096.0 + 2413.0 / 128.0 * x) / (1.0 + 2392.0 / 128.0 * x), vec3(2523.0 / 32.0));
12 }9 }
13 void main() {10 void main() {
14 vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0));11 vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0));
15- vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb);12+ vec3 rgb = texture(imageSDR, uv).rgb;
16 vec3 bt2020 = mat3(13 vec3 bt2020 = mat3(
17 0.627404, 0.069097, 0.016391,14 0.627404, 0.069097, 0.016391,
18 0.329283, 0.919540, 0.088013,15 0.329283, 0.919540, 0.088013,
M internal/adapters/vulkan/shaders/hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/targets.go +8 -3
@@ -16,8 +16,9 @@ import (
1616 // display accepts and exports their memory as dmabufs, which the output
1717 // turns into KMS framebuffers. Render then composes straight into the
1818 // selected target and the output flips to it; nothing is copied by the
19-// CPU. In HDR the internal image retains the SDR scene, sampled by a
20-// full-screen GPU pass into the 10-bit target. Pixels reads internal SDR.
19+// CPU. In HDR an internal fp16 linear image keeps full-range composition,
20+// sampled by a full-screen GPU pass into the 10-bit target. Capture/Pixels
21+// convert to SDR sRGB only when requested.
2122
2223 // target is an image frames are drawn into.
2324 type target struct {
@@ -45,6 +46,10 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
4546 // the previous ones; n = 0 only drops them (back to the internal image).
4647 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
4748 r.dropTargets()
49+ if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
50+ // Test-only transfer-source targets have different format requirements.
51+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
52+ }
4853 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
4954 return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
5055 }
@@ -104,7 +109,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
104109 available = r.hdrMods
105110 }
106111 for _, m := range available {
107- if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) {
112+ if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) {
108113 out = append(out, m)
109114 }
110115 }
@@ -16,8 +16,9 @@ import (
16 // display accepts and exports their memory as dmabufs, which the output16 // display accepts and exports their memory as dmabufs, which the output
17 // turns into KMS framebuffers. Render then composes straight into the17 // turns into KMS framebuffers. Render then composes straight into the
18 // selected target and the output flips to it; nothing is copied by the18 // selected target and the output flips to it; nothing is copied by the
19-// CPU. In HDR the internal image retains the SDR scene, sampled by a19+// CPU. In HDR an internal fp16 linear image keeps full-range composition,
20-// full-screen GPU pass into the 10-bit target. Pixels reads internal SDR.20+// sampled by a full-screen GPU pass into the 10-bit target. Capture/Pixels
21+// convert to SDR sRGB only when requested.
21 22
22 // target is an image frames are drawn into.23 // target is an image frames are drawn into.
23 type target struct {24 type target struct {
@@ -45,6 +46,10 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
45 // the previous ones; n = 0 only drops them (back to the internal image).46 // the previous ones; n = 0 only drops them (back to the internal image).
46 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {47 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
47 r.dropTargets()48 r.dropTargets()
49+ if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
50+ // Test-only transfer-source targets have different format requirements.
51+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
52+ }
48 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {53 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
49 return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError)54 return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
50 }55 }
@@ -104,7 +109,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
104 available = r.hdrMods109 available = r.hdrMods
105 }110 }
106 for _, m := range available {111 for _, m := range available {
107- if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) {112+ if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) {
108 out = append(out, m)113 out = append(out, m)
109 }114 }
110 }115 }
M internal/adapters/vulkan/targets_test.go +2 -2
@@ -120,8 +120,8 @@ func TestHDRExportTarget(t *testing.T) {
120120 t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before)
121121 }
122122 checkHDRPixel(t, r, [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255})
123- if r.own.layout != vk.ImageLayoutTransferSrcOptimal {
124- t.Fatalf("internal layout after HDR: %v", r.own.layout)
123+ if r.hdrOwn.layout != vk.ImageLayoutTransferSrcOptimal {
124+ t.Fatalf("internal layout after HDR: %v", r.hdrOwn.layout)
125125 }
126126 if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {
127127 t.Fatalf("SDR readback: %v", got)
@@ -120,8 +120,8 @@ func TestHDRExportTarget(t *testing.T) {
120 t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before)120 t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before)
121 }121 }
122 checkHDRPixel(t, r, [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255})122 checkHDRPixel(t, r, [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255})
123- if r.own.layout != vk.ImageLayoutTransferSrcOptimal {123+ if r.hdrOwn.layout != vk.ImageLayoutTransferSrcOptimal {
124- t.Fatalf("internal layout after HDR: %v", r.own.layout)124+ t.Fatalf("internal layout after HDR: %v", r.hdrOwn.layout)
125 }125 }
126 if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {126 if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {
127 t.Fatalf("SDR readback: %v", got)127 t.Fatalf("SDR readback: %v", got)
A internal/adapters/vulkan/yuv.go +213 -0
@@ -0,0 +1,213 @@
1+package vulkan
2+
3+import (
4+ "fmt"
5+ "unsafe"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ vk "github.com/bnema/purego-vulkan/vulkan"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Vulkan 1.1 multi-planar constants and pNext structures not yet generated by
13+// purego-vulkan v0.4.1. Their layouts follow vulkan_core.h.
14+const (
15+ aspectPlane0 vk.ImageAspectFlags = 0x10
16+ aspectPlane1 vk.ImageAspectFlags = 0x20
17+ imageDisjoint vk.ImageCreateFlags = 0x200
18+ structureImagePlaneRequirements vk.StructureType = 1000156003
19+ structureBindImagePlane vk.StructureType = 1000156002
20+ structureImageFormatList vk.StructureType = 1000147000
21+ formatR10X6 vk.Format = 1000156007
22+ formatR10X6G10X6 vk.Format = 1000156008
23+)
24+
25+type imageFormatList struct {
26+ sType vk.StructureType
27+ next unsafe.Pointer
28+ viewFormatCount uint32
29+ viewFormats *vk.Format
30+}
31+
32+// The view formats are required by VK_EXT_image_drm_format_modifier when
33+// VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT is used for per-plane sampling.
34+func yuvViewFormats(format vk.Format) [3]vk.Format {
35+ if format == vk.Format(1000156013) {
36+ return [3]vk.Format{format, formatR10X6, formatR10X6G10X6}
37+ }
38+ return [3]vk.Format{format, vk.FormatR8Unorm, vk.FormatR8g8Unorm}
39+}
40+
41+type imagePlaneRequirements struct {
42+ sType vk.StructureType
43+ next unsafe.Pointer
44+ planeAspect vk.ImageAspectFlags
45+}
46+type bindImagePlane struct {
47+ sType vk.StructureType
48+ next unsafe.Pointer
49+ planeAspect vk.ImageAspectFlags
50+}
51+
52+// importYUV uses one multi-planar VkImage. Planes sharing a DMA-BUF use one
53+// memory binding; different backing objects need disjoint support. Views
54+// always sample the Y and UV planes separately.
55+func (r *Renderer) importYUV(b *ports.DMABuf, format vk.Format) (*imported, error) {
56+ im := &imported{fd: -1, yuv: true, last: r.frame, fds: [2]int{-1, -1}}
57+ disjoint, err := differentBacking(b.Planes)
58+ if err != nil {
59+ return nil, err
60+ }
61+ if disjoint {
62+ // Query this modifier with DISJOINT; an advertised shared-memory
63+ // import does not imply support for distinct plane objects.
64+ if r.physical == 0 || !r.importable(r.physical, format, b.Modifier, true, true) {
65+ return nil, fmt.Errorf("YUV buffer has distinct plane objects without Vulkan disjoint support")
66+ }
67+ }
68+ im.disjoint = disjoint
69+ ok := false
70+ defer func() {
71+ if !ok {
72+ r.release(im)
73+ }
74+ }()
75+ for i, pl := range b.Planes {
76+ fd, err := dupFile(pl.File)
77+ if err != nil {
78+ return nil, err
79+ }
80+ im.fds[i] = fd
81+ }
82+ d := r.dd
83+ layouts := [2]vk.SubresourceLayout{}
84+ for i, pl := range b.Planes {
85+ layouts[i] = vk.SubresourceLayout{Offset: vk.DeviceSize(pl.Offset), RowPitch: vk.DeviceSize(pl.Stride)}
86+ }
87+ views := yuvViewFormats(format)
88+ formats := imageFormatList{sType: structureImageFormatList, viewFormatCount: 3, viewFormats: &views[0]}
89+ explicit := vk.ImageDrmFormatModifierExplicitCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierExplicitCreateInfoEXT, DrmFormatModifier: b.Modifier, DrmFormatModifierPlaneCount: 2, PlaneLayouts: &layouts[0]}
90+ formats.next = unsafe.Pointer(&explicit)
91+ external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&formats), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
92+ flags := vk.ImageCreateFlags(vk.ImageCreateMutableFormatBit)
93+ if disjoint {
94+ flags |= imageDisjoint
95+ }
96+ info := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), Flags: flags, ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(b.Width), Height: uint32(b.Height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
97+ if err := checked("vkCreateImage(YUV dmabuf)", d.CreateImage(r.device, &info, nil, &im.image)); err != nil {
98+ return nil, err
99+ }
100+ if !disjoint {
101+ if err := r.bindSharedYUV(im); err != nil {
102+ return nil, err
103+ }
104+ } else {
105+ aspects := [2]vk.ImageAspectFlags{aspectPlane0, aspectPlane1}
106+ bindings := [2]vk.BindImageMemoryInfo{}
107+ for i, fd := range im.fds {
108+ props := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
109+ if err := checked("vkGetMemoryFdPropertiesKHR(YUV)", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &props)); err != nil {
110+ return nil, err
111+ }
112+ plane := imagePlaneRequirements{sType: structureImagePlaneRequirements, planeAspect: aspects[i]}
113+ request := vk.ImageMemoryRequirementsInfo2{SType: vk.StructureTypeImageMemoryRequirementsInfo2, Next: unsafe.Pointer(&plane), Image: im.image}
114+ req := vk.MemoryRequirements2{SType: vk.StructureTypeMemoryRequirements2}
115+ d.GetImageMemoryRequirements2(r.device, &request, &req)
116+ kind, err := r.findMemoryType(req.MemoryRequirements.MemoryTypeBits&props.MemoryTypeBits, 0)
117+ if err != nil {
118+ return nil, err
119+ }
120+ memFD, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
121+ if err != nil {
122+ return nil, err
123+ }
124+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: im.image}
125+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(memFD)}
126+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.MemoryRequirements.Size, MemoryTypeIndex: kind}
127+ if err := checked("vkAllocateMemory(YUV plane)", d.AllocateMemory(r.device, &alloc, nil, &im.memories[i])); err != nil {
128+ unix.Close(memFD)
129+ return nil, err
130+ }
131+ planeBind := &bindImagePlane{sType: structureBindImagePlane, planeAspect: aspects[i]}
132+ bindings[i] = vk.BindImageMemoryInfo{SType: vk.StructureTypeBindImageMemoryInfo, Next: unsafe.Pointer(planeBind), Image: im.image, Memory: im.memories[i]}
133+ // pNext must remain alive through vkBindImageMemory2, so bind each plane.
134+ if err := checked("vkBindImageMemory2(YUV plane)", d.BindImageMemory2(r.device, 1, &bindings[i])); err != nil {
135+ return nil, err
136+ }
137+ }
138+ }
139+ yFormat, uvFormat := vk.Format(vk.FormatR8Unorm), vk.Format(vk.FormatR8g8Unorm)
140+ if b.Format == fourcc('P', '0', '1', '0') {
141+ yFormat, uvFormat = formatR10X6, formatR10X6G10X6
142+ }
143+ view := func(aspect vk.ImageAspectFlags, f vk.Format) (vk.ImageView, error) {
144+ vi := vk.ImageViewCreateInfo{SType: vk.StructureTypeImageViewCreateInfo, Image: im.image, ViewType: vk.ImageViewType2d, Format: f, SubresourceRange: vk.ImageSubresourceRange{AspectMask: aspect, LevelCount: 1, LayerCount: 1}}
145+ var result vk.ImageView
146+ err := checked("vkCreateImageView(YUV plane)", d.CreateImageView(r.device, &vi, nil, &result))
147+ return result, err
148+ }
149+ if im.view, err = view(aspectPlane0, yFormat); err != nil {
150+ return nil, err
151+ }
152+ if im.chromaView, err = view(aspectPlane1, uvFormat); err != nil {
153+ return nil, err
154+ }
155+ if im.pool, im.set, err = r.newSet(im.view, r.compose.dummyBuffer.buffer, im.chromaView); err != nil {
156+ return nil, err
157+ }
158+ ok = true
159+ r.imports[b.ID] = im
160+ return im, nil
161+}
162+
163+// differentBacking identifies duplicate descriptors of one DMA-BUF by inode.
164+// Compare the opened files themselves, not their integer descriptor values.
165+func differentBacking(planes []ports.DMABufPlane) (bool, error) {
166+ if len(planes) != 2 {
167+ return false, fmt.Errorf("expected two DMA-BUF planes")
168+ }
169+ var a, b unix.Stat_t
170+ for i, p := range planes {
171+ if p.File == nil {
172+ return false, fmt.Errorf("nil DMA-BUF plane")
173+ }
174+ var st *unix.Stat_t
175+ if i == 0 {
176+ st = &a
177+ } else {
178+ st = &b
179+ }
180+ if err := unix.Fstat(int(p.File.Fd()), st); err != nil {
181+ return false, err
182+ }
183+ }
184+ return a.Dev != b.Dev || a.Ino != b.Ino, nil
185+}
186+
187+// bindSharedYUV imports one DMA-BUF memory for both format planes.
188+func (r *Renderer) bindSharedYUV(im *imported) error {
189+ d := r.dd
190+ fd := im.fds[0]
191+ props := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
192+ if err := checked("vkGetMemoryFdPropertiesKHR(YUV shared)", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &props)); err != nil {
193+ return err
194+ }
195+ var req vk.MemoryRequirements
196+ d.GetImageMemoryRequirements(r.device, im.image, &req)
197+ kind, err := r.findMemoryType(req.MemoryTypeBits&props.MemoryTypeBits, 0)
198+ if err != nil {
199+ return err
200+ }
201+ memFD, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
202+ if err != nil {
203+ return err
204+ }
205+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: im.image}
206+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(memFD)}
207+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.Size, MemoryTypeIndex: kind}
208+ if err := checked("vkAllocateMemory(YUV shared)", d.AllocateMemory(r.device, &alloc, nil, &im.memory)); err != nil {
209+ unix.Close(memFD)
210+ return err
211+ }
212+ return checked("vkBindImageMemory(YUV shared)", d.BindImageMemory(r.device, im.image, im.memory, 0))
213+}
@@ -0,0 +1,213 @@
1+package vulkan
2+
3+import (
4+ "fmt"
5+ "unsafe"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ vk "github.com/bnema/purego-vulkan/vulkan"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// Vulkan 1.1 multi-planar constants and pNext structures not yet generated by
13+// purego-vulkan v0.4.1. Their layouts follow vulkan_core.h.
14+const (
15+ aspectPlane0 vk.ImageAspectFlags = 0x10
16+ aspectPlane1 vk.ImageAspectFlags = 0x20
17+ imageDisjoint vk.ImageCreateFlags = 0x200
18+ structureImagePlaneRequirements vk.StructureType = 1000156003
19+ structureBindImagePlane vk.StructureType = 1000156002
20+ structureImageFormatList vk.StructureType = 1000147000
21+ formatR10X6 vk.Format = 1000156007
22+ formatR10X6G10X6 vk.Format = 1000156008
23+)
24+
25+type imageFormatList struct {
26+ sType vk.StructureType
27+ next unsafe.Pointer
28+ viewFormatCount uint32
29+ viewFormats *vk.Format
30+}
31+
32+// The view formats are required by VK_EXT_image_drm_format_modifier when
33+// VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT is used for per-plane sampling.
34+func yuvViewFormats(format vk.Format) [3]vk.Format {
35+ if format == vk.Format(1000156013) {
36+ return [3]vk.Format{format, formatR10X6, formatR10X6G10X6}
37+ }
38+ return [3]vk.Format{format, vk.FormatR8Unorm, vk.FormatR8g8Unorm}
39+}
40+
41+type imagePlaneRequirements struct {
42+ sType vk.StructureType
43+ next unsafe.Pointer
44+ planeAspect vk.ImageAspectFlags
45+}
46+type bindImagePlane struct {
47+ sType vk.StructureType
48+ next unsafe.Pointer
49+ planeAspect vk.ImageAspectFlags
50+}
51+
52+// importYUV uses one multi-planar VkImage. Planes sharing a DMA-BUF use one
53+// memory binding; different backing objects need disjoint support. Views
54+// always sample the Y and UV planes separately.
55+func (r *Renderer) importYUV(b *ports.DMABuf, format vk.Format) (*imported, error) {
56+ im := &imported{fd: -1, yuv: true, last: r.frame, fds: [2]int{-1, -1}}
57+ disjoint, err := differentBacking(b.Planes)
58+ if err != nil {
59+ return nil, err
60+ }
61+ if disjoint {
62+ // Query this modifier with DISJOINT; an advertised shared-memory
63+ // import does not imply support for distinct plane objects.
64+ if r.physical == 0 || !r.importable(r.physical, format, b.Modifier, true, true) {
65+ return nil, fmt.Errorf("YUV buffer has distinct plane objects without Vulkan disjoint support")
66+ }
67+ }
68+ im.disjoint = disjoint
69+ ok := false
70+ defer func() {
71+ if !ok {
72+ r.release(im)
73+ }
74+ }()
75+ for i, pl := range b.Planes {
76+ fd, err := dupFile(pl.File)
77+ if err != nil {
78+ return nil, err
79+ }
80+ im.fds[i] = fd
81+ }
82+ d := r.dd
83+ layouts := [2]vk.SubresourceLayout{}
84+ for i, pl := range b.Planes {
85+ layouts[i] = vk.SubresourceLayout{Offset: vk.DeviceSize(pl.Offset), RowPitch: vk.DeviceSize(pl.Stride)}
86+ }
87+ views := yuvViewFormats(format)
88+ formats := imageFormatList{sType: structureImageFormatList, viewFormatCount: 3, viewFormats: &views[0]}
89+ explicit := vk.ImageDrmFormatModifierExplicitCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierExplicitCreateInfoEXT, DrmFormatModifier: b.Modifier, DrmFormatModifierPlaneCount: 2, PlaneLayouts: &layouts[0]}
90+ formats.next = unsafe.Pointer(&explicit)
91+ external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&formats), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
92+ flags := vk.ImageCreateFlags(vk.ImageCreateMutableFormatBit)
93+ if disjoint {
94+ flags |= imageDisjoint
95+ }
96+ info := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), Flags: flags, ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(b.Width), Height: uint32(b.Height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
97+ if err := checked("vkCreateImage(YUV dmabuf)", d.CreateImage(r.device, &info, nil, &im.image)); err != nil {
98+ return nil, err
99+ }
100+ if !disjoint {
101+ if err := r.bindSharedYUV(im); err != nil {
102+ return nil, err
103+ }
104+ } else {
105+ aspects := [2]vk.ImageAspectFlags{aspectPlane0, aspectPlane1}
106+ bindings := [2]vk.BindImageMemoryInfo{}
107+ for i, fd := range im.fds {
108+ props := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
109+ if err := checked("vkGetMemoryFdPropertiesKHR(YUV)", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &props)); err != nil {
110+ return nil, err
111+ }
112+ plane := imagePlaneRequirements{sType: structureImagePlaneRequirements, planeAspect: aspects[i]}
113+ request := vk.ImageMemoryRequirementsInfo2{SType: vk.StructureTypeImageMemoryRequirementsInfo2, Next: unsafe.Pointer(&plane), Image: im.image}
114+ req := vk.MemoryRequirements2{SType: vk.StructureTypeMemoryRequirements2}
115+ d.GetImageMemoryRequirements2(r.device, &request, &req)
116+ kind, err := r.findMemoryType(req.MemoryRequirements.MemoryTypeBits&props.MemoryTypeBits, 0)
117+ if err != nil {
118+ return nil, err
119+ }
120+ memFD, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
121+ if err != nil {
122+ return nil, err
123+ }
124+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: im.image}
125+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(memFD)}
126+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.MemoryRequirements.Size, MemoryTypeIndex: kind}
127+ if err := checked("vkAllocateMemory(YUV plane)", d.AllocateMemory(r.device, &alloc, nil, &im.memories[i])); err != nil {
128+ unix.Close(memFD)
129+ return nil, err
130+ }
131+ planeBind := &bindImagePlane{sType: structureBindImagePlane, planeAspect: aspects[i]}
132+ bindings[i] = vk.BindImageMemoryInfo{SType: vk.StructureTypeBindImageMemoryInfo, Next: unsafe.Pointer(planeBind), Image: im.image, Memory: im.memories[i]}
133+ // pNext must remain alive through vkBindImageMemory2, so bind each plane.
134+ if err := checked("vkBindImageMemory2(YUV plane)", d.BindImageMemory2(r.device, 1, &bindings[i])); err != nil {
135+ return nil, err
136+ }
137+ }
138+ }
139+ yFormat, uvFormat := vk.Format(vk.FormatR8Unorm), vk.Format(vk.FormatR8g8Unorm)
140+ if b.Format == fourcc('P', '0', '1', '0') {
141+ yFormat, uvFormat = formatR10X6, formatR10X6G10X6
142+ }
143+ view := func(aspect vk.ImageAspectFlags, f vk.Format) (vk.ImageView, error) {
144+ vi := vk.ImageViewCreateInfo{SType: vk.StructureTypeImageViewCreateInfo, Image: im.image, ViewType: vk.ImageViewType2d, Format: f, SubresourceRange: vk.ImageSubresourceRange{AspectMask: aspect, LevelCount: 1, LayerCount: 1}}
145+ var result vk.ImageView
146+ err := checked("vkCreateImageView(YUV plane)", d.CreateImageView(r.device, &vi, nil, &result))
147+ return result, err
148+ }
149+ if im.view, err = view(aspectPlane0, yFormat); err != nil {
150+ return nil, err
151+ }
152+ if im.chromaView, err = view(aspectPlane1, uvFormat); err != nil {
153+ return nil, err
154+ }
155+ if im.pool, im.set, err = r.newSet(im.view, r.compose.dummyBuffer.buffer, im.chromaView); err != nil {
156+ return nil, err
157+ }
158+ ok = true
159+ r.imports[b.ID] = im
160+ return im, nil
161+}
162+
163+// differentBacking identifies duplicate descriptors of one DMA-BUF by inode.
164+// Compare the opened files themselves, not their integer descriptor values.
165+func differentBacking(planes []ports.DMABufPlane) (bool, error) {
166+ if len(planes) != 2 {
167+ return false, fmt.Errorf("expected two DMA-BUF planes")
168+ }
169+ var a, b unix.Stat_t
170+ for i, p := range planes {
171+ if p.File == nil {
172+ return false, fmt.Errorf("nil DMA-BUF plane")
173+ }
174+ var st *unix.Stat_t
175+ if i == 0 {
176+ st = &a
177+ } else {
178+ st = &b
179+ }
180+ if err := unix.Fstat(int(p.File.Fd()), st); err != nil {
181+ return false, err
182+ }
183+ }
184+ return a.Dev != b.Dev || a.Ino != b.Ino, nil
185+}
186+
187+// bindSharedYUV imports one DMA-BUF memory for both format planes.
188+func (r *Renderer) bindSharedYUV(im *imported) error {
189+ d := r.dd
190+ fd := im.fds[0]
191+ props := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
192+ if err := checked("vkGetMemoryFdPropertiesKHR(YUV shared)", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &props)); err != nil {
193+ return err
194+ }
195+ var req vk.MemoryRequirements
196+ d.GetImageMemoryRequirements(r.device, im.image, &req)
197+ kind, err := r.findMemoryType(req.MemoryTypeBits&props.MemoryTypeBits, 0)
198+ if err != nil {
199+ return err
200+ }
201+ memFD, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
202+ if err != nil {
203+ return err
204+ }
205+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: im.image}
206+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(memFD)}
207+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.Size, MemoryTypeIndex: kind}
208+ if err := checked("vkAllocateMemory(YUV shared)", d.AllocateMemory(r.device, &alloc, nil, &im.memory)); err != nil {
209+ unix.Close(memFD)
210+ return err
211+ }
212+ return checked("vkBindImageMemory(YUV shared)", d.BindImageMemory(r.device, im.image, im.memory, 0))
213+}
A internal/adapters/vulkan/yuv_test.go +161 -0
@@ -0,0 +1,161 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "os"
7+ "slices"
8+ "testing"
9+ "unsafe"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Each plane can be backed by a separate DMA-BUF, as with VAAPI exports.
16+func yuvTestBuffer(t *testing.T, p010 bool, y, u, v int) *ports.DMABuf {
17+ t.Helper()
18+ format := fourcc('N', 'V', '1', '2')
19+ if p010 {
20+ format = fourcc('P', '0', '1', '0')
21+ }
22+ planes := make([]ports.DMABufPlane, 2)
23+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
24+ if err != nil {
25+ t.Skipf("udmabuf unavailable: %v", err)
26+ }
27+ defer dev.Close()
28+ // Use a stride supported by the modifier on the device; the test
29+ // populates the exact client-provided row pitch.
30+ stride := uint32(256)
31+
32+ ySize := int(stride) * 16
33+ uvSize := int(stride) * 8
34+ pages := (ySize + uvSize + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize()
35+ mem, err := unix.MemfdCreate("yuv-shared", unix.MFD_ALLOW_SEALING)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ defer unix.Close(mem)
40+ if err := unix.Ftruncate(mem, int64(pages)); err != nil {
41+ t.Fatal(err)
42+ }
43+ data := make([]byte, pages)
44+ for i := 0; i < 2; i++ {
45+ start, end := 0, ySize
46+ values := []int{y}
47+ if i == 1 {
48+ start, end, values = ySize, ySize+uvSize, []int{u, v}
49+ }
50+ for n, ch := start, 0; n < end; ch++ {
51+ val := values[ch%len(values)]
52+ if p010 {
53+ code := uint16(val << 6)
54+ data[n], data[n+1] = byte(code), byte(code>>8)
55+ n += 2
56+ } else {
57+ data[n] = byte(val)
58+ n++
59+ }
60+ }
61+ }
62+ if _, err := unix.Pwrite(mem, data, 0); err != nil {
63+ t.Fatal(err)
64+ }
65+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
66+ t.Fatal(err)
67+ }
68+ arg := struct {
69+ memfd, flags uint32
70+ offset, size uint64
71+ }{memfd: uint32(mem), flags: 1, size: uint64(pages)}
72+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
73+ if errno != 0 {
74+ t.Skipf("udmabuf create: %v", errno)
75+ }
76+ f := os.NewFile(fd, "yuv-shared")
77+ t.Cleanup(func() { f.Close() })
78+ dup, err := unix.Dup(int(f.Fd()))
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ f2 := os.NewFile(uintptr(dup), "yuv-shared-dup")
83+ t.Cleanup(func() { f2.Close() })
84+ planes[0] = ports.DMABufPlane{File: f, Stride: stride}
85+ planes[1] = ports.DMABufPlane{File: f2, Offset: uint32(ySize), Stride: stride}
86+ return &ports.DMABuf{ID: uint64(format) + uint64(y), Width: 64, Height: 16, Format: format, Planes: planes}
87+}
88+
89+func TestYUVComposition(t *testing.T) {
90+ for _, tc := range []struct {
91+ name string
92+ p010, hdr bool
93+ coeff uint8
94+ y, u, v int
95+ rgb [3]float64
96+ }{
97+ {"NV12 BT709 limited white", false, false, 2, 235, 128, 128, [3]float64{1, 1, 1}},
98+ {"NV12 BT709 limited red", false, false, 2, 63, 102, 240, [3]float64{1, 0, 0}},
99+ {"NV12 BT601 limited red", false, false, 4, 81, 90, 240, [3]float64{1, 0, 0}},
100+ {"P010 BT2020 limited white", true, false, 6, 940, 512, 512, [3]float64{1, 1, 1}},
101+ {"P010 BT2020 PQ 1000 nits", true, true, 6, 756, 512, 512, [3]float64{0, 0, 0}},
102+ } {
103+ t.Run(tc.name, func(t *testing.T) {
104+ var r *Renderer
105+ if tc.hdr {
106+ r = hdrTestRenderer(t)
107+ } else {
108+ var err error
109+ r, err = New(64, 16)
110+ if err != nil {
111+ t.Skipf("Vulkan unavailable: %v", err)
112+ }
113+ defer r.Close()
114+ }
115+ b := yuvTestBuffer(t, tc.p010, tc.y, tc.u, tc.v)
116+ if !slices.Contains(r.DMABuf().Formats, ports.DMABufFormat{Format: b.Format}) {
117+ t.Skipf("linear YUV %#x not importable", b.Format)
118+ }
119+ if distinct, err := differentBacking(b.Planes); err != nil || distinct {
120+ t.Fatalf("expected duplicate plane fds, distinct=%t err=%v", distinct, err)
121+ }
122+ if _, err := r.importDMABuf(b); err != nil {
123+ t.Fatalf("YUV import: %v", err)
124+ }
125+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: b, Color: ports.SurfaceColor{Coefficients: tc.coeff, Range: 2, Chroma: 1}}
126+ if tc.hdr {
127+ c.Color.TF, c.Color.Primaries = ports.ColorTFPQ, ports.ColorPrimariesBT2020
128+ }
129+ scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
130+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
131+ t.Fatal(err)
132+ }
133+ if tc.hdr {
134+ // Limited-range P010: PQ code is (Y-64)/876. Round-trip
135+ // through fp16 composition and HDR output without clipping.
136+ code := (float64(tc.y) - 64) / 876
137+ want := pqEncode(pqDecode(code))
138+ got := hdrTargetAt(t, r, 8, 8)
139+ for _, channel := range got {
140+ if math.Abs(channel-want) > .015 {
141+ t.Fatalf("PQ %v want %v", got, want)
142+ }
143+ }
144+ if px := r.Pixels().RGBAAt(8, 8); px != (color.RGBA{255, 255, 255, 255}) {
145+ t.Fatalf("capture %v", px)
146+ }
147+ return
148+ }
149+ got := r.Pixels().RGBAAt(8, 8)
150+ want := color.RGBA{uint8(math.Round(tc.rgb[0] * 255)), uint8(math.Round(tc.rgb[1] * 255)), uint8(math.Round(tc.rgb[2] * 255)), 255}
151+ if !near(got, want, 8) {
152+ t.Errorf("got %v want %v", got, want)
153+ }
154+ })
155+ }
156+}
157+
158+func pqDecode(code float64) float64 {
159+ p := math.Pow(code, 32.0/2523)
160+ return math.Pow(math.Max(p-3424.0/4096, 0)/(2413.0/128-p*2392.0/128), 16384.0/2610) * 10000
161+}
@@ -0,0 +1,161 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "os"
7+ "slices"
8+ "testing"
9+ "unsafe"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Each plane can be backed by a separate DMA-BUF, as with VAAPI exports.
16+func yuvTestBuffer(t *testing.T, p010 bool, y, u, v int) *ports.DMABuf {
17+ t.Helper()
18+ format := fourcc('N', 'V', '1', '2')
19+ if p010 {
20+ format = fourcc('P', '0', '1', '0')
21+ }
22+ planes := make([]ports.DMABufPlane, 2)
23+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
24+ if err != nil {
25+ t.Skipf("udmabuf unavailable: %v", err)
26+ }
27+ defer dev.Close()
28+ // Use a stride supported by the modifier on the device; the test
29+ // populates the exact client-provided row pitch.
30+ stride := uint32(256)
31+
32+ ySize := int(stride) * 16
33+ uvSize := int(stride) * 8
34+ pages := (ySize + uvSize + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize()
35+ mem, err := unix.MemfdCreate("yuv-shared", unix.MFD_ALLOW_SEALING)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ defer unix.Close(mem)
40+ if err := unix.Ftruncate(mem, int64(pages)); err != nil {
41+ t.Fatal(err)
42+ }
43+ data := make([]byte, pages)
44+ for i := 0; i < 2; i++ {
45+ start, end := 0, ySize
46+ values := []int{y}
47+ if i == 1 {
48+ start, end, values = ySize, ySize+uvSize, []int{u, v}
49+ }
50+ for n, ch := start, 0; n < end; ch++ {
51+ val := values[ch%len(values)]
52+ if p010 {
53+ code := uint16(val << 6)
54+ data[n], data[n+1] = byte(code), byte(code>>8)
55+ n += 2
56+ } else {
57+ data[n] = byte(val)
58+ n++
59+ }
60+ }
61+ }
62+ if _, err := unix.Pwrite(mem, data, 0); err != nil {
63+ t.Fatal(err)
64+ }
65+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
66+ t.Fatal(err)
67+ }
68+ arg := struct {
69+ memfd, flags uint32
70+ offset, size uint64
71+ }{memfd: uint32(mem), flags: 1, size: uint64(pages)}
72+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
73+ if errno != 0 {
74+ t.Skipf("udmabuf create: %v", errno)
75+ }
76+ f := os.NewFile(fd, "yuv-shared")
77+ t.Cleanup(func() { f.Close() })
78+ dup, err := unix.Dup(int(f.Fd()))
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ f2 := os.NewFile(uintptr(dup), "yuv-shared-dup")
83+ t.Cleanup(func() { f2.Close() })
84+ planes[0] = ports.DMABufPlane{File: f, Stride: stride}
85+ planes[1] = ports.DMABufPlane{File: f2, Offset: uint32(ySize), Stride: stride}
86+ return &ports.DMABuf{ID: uint64(format) + uint64(y), Width: 64, Height: 16, Format: format, Planes: planes}
87+}
88+
89+func TestYUVComposition(t *testing.T) {
90+ for _, tc := range []struct {
91+ name string
92+ p010, hdr bool
93+ coeff uint8
94+ y, u, v int
95+ rgb [3]float64
96+ }{
97+ {"NV12 BT709 limited white", false, false, 2, 235, 128, 128, [3]float64{1, 1, 1}},
98+ {"NV12 BT709 limited red", false, false, 2, 63, 102, 240, [3]float64{1, 0, 0}},
99+ {"NV12 BT601 limited red", false, false, 4, 81, 90, 240, [3]float64{1, 0, 0}},
100+ {"P010 BT2020 limited white", true, false, 6, 940, 512, 512, [3]float64{1, 1, 1}},
101+ {"P010 BT2020 PQ 1000 nits", true, true, 6, 756, 512, 512, [3]float64{0, 0, 0}},
102+ } {
103+ t.Run(tc.name, func(t *testing.T) {
104+ var r *Renderer
105+ if tc.hdr {
106+ r = hdrTestRenderer(t)
107+ } else {
108+ var err error
109+ r, err = New(64, 16)
110+ if err != nil {
111+ t.Skipf("Vulkan unavailable: %v", err)
112+ }
113+ defer r.Close()
114+ }
115+ b := yuvTestBuffer(t, tc.p010, tc.y, tc.u, tc.v)
116+ if !slices.Contains(r.DMABuf().Formats, ports.DMABufFormat{Format: b.Format}) {
117+ t.Skipf("linear YUV %#x not importable", b.Format)
118+ }
119+ if distinct, err := differentBacking(b.Planes); err != nil || distinct {
120+ t.Fatalf("expected duplicate plane fds, distinct=%t err=%v", distinct, err)
121+ }
122+ if _, err := r.importDMABuf(b); err != nil {
123+ t.Fatalf("YUV import: %v", err)
124+ }
125+ c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: b, Color: ports.SurfaceColor{Coefficients: tc.coeff, Range: 2, Chroma: 1}}
126+ if tc.hdr {
127+ c.Color.TF, c.Color.Primaries = ports.ColorTFPQ, ports.ColorPrimariesBT2020
128+ }
129+ scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
130+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
131+ t.Fatal(err)
132+ }
133+ if tc.hdr {
134+ // Limited-range P010: PQ code is (Y-64)/876. Round-trip
135+ // through fp16 composition and HDR output without clipping.
136+ code := (float64(tc.y) - 64) / 876
137+ want := pqEncode(pqDecode(code))
138+ got := hdrTargetAt(t, r, 8, 8)
139+ for _, channel := range got {
140+ if math.Abs(channel-want) > .015 {
141+ t.Fatalf("PQ %v want %v", got, want)
142+ }
143+ }
144+ if px := r.Pixels().RGBAAt(8, 8); px != (color.RGBA{255, 255, 255, 255}) {
145+ t.Fatalf("capture %v", px)
146+ }
147+ return
148+ }
149+ got := r.Pixels().RGBAAt(8, 8)
150+ want := color.RGBA{uint8(math.Round(tc.rgb[0] * 255)), uint8(math.Round(tc.rgb[1] * 255)), uint8(math.Round(tc.rgb[2] * 255)), 255}
151+ if !near(got, want, 8) {
152+ t.Errorf("got %v want %v", got, want)
153+ }
154+ })
155+ }
156+}
157+
158+func pqDecode(code float64) float64 {
159+ p := math.Pow(code, 32.0/2523)
160+ return math.Pow(math.Max(p-3424.0/4096, 0)/(2413.0/128-p*2392.0/128), 16384.0/2610) * 10000
161+}
A internal/adapters/wayland/colormanagement.go +448 -0
@@ -0,0 +1,448 @@
1+package wayland
2+
3+import (
4+ "math"
5+
6+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// SurfaceColor is the committed color encoding of a client surface. Set is false
12+// when no explicit description was committed; the default is compositor-defined.
13+type SurfaceColor struct {
14+ Set bool
15+ TF, Primaries, MaxCLL, MaxFALL uint32
16+}
17+
18+func (s *surface) ColorDescription() SurfaceColor { return s.color }
19+
20+type colorDescription struct {
21+ SurfaceColor
22+ min, max, white, targetMin, targetMax uint32
23+ cllSet, fallSet bool
24+ identity uint64
25+}
26+
27+const (
28+ srgb = uint32(cm.WpColorManagerV1PrimariesSrgb)
29+ bt2020 = uint32(cm.WpColorManagerV1PrimariesBt2020)
30+ gamma22 = uint32(cm.WpColorManagerV1TransferFunctionGamma22)
31+ pq = uint32(cm.WpColorManagerV1TransferFunctionSt2084Pq)
32+ extLinear = uint32(cm.WpColorManagerV1TransferFunctionExtLinear)
33+)
34+
35+func (s *Server) nextColorID() uint64 { s.colorID++; return s.colorID }
36+func (s *Server) outputColor(o *output) colorDescription {
37+ d := colorDescription{SurfaceColor: SurfaceColor{Set: true, TF: uint32(cm.WpColorManagerV1TransferFunctionSrgb), Primaries: srgb}, min: 2000, max: 80, white: 80, targetMin: 2000, targetMax: 80}
38+ if o != nil {
39+ if h, ok := s.hdrOutputs[o.name()]; ok {
40+ d.TF, d.Primaries, d.white = pq, bt2020, 203
41+ d.min = uint32(math.Max(0, math.Round(h.MinLuminance*10000)))
42+ d.max = d.min/10000 + 10000 // PQ primary volume is fixed by ST 2084.
43+ d.targetMin, d.targetMax = d.min, uint32(math.Max(1, math.Round(h.MaxLuminance)))
44+ d.MaxCLL = d.targetMax
45+ d.MaxFALL = uint32(math.Max(0, math.Round(h.MaxFrameAverage)))
46+ d.cllSet, d.fallSet = true, true
47+ }
48+ }
49+ // Identity changes only when the description changes, not on each query.
50+ if o != nil {
51+ if s.colorIdentity == nil {
52+ s.colorIdentity = map[string]uint64{}
53+ }
54+ d.identity = s.colorIdentity[o.name()]
55+ if d.identity == 0 {
56+ d.identity = s.nextColorID()
57+ s.colorIdentity[o.name()] = d.identity
58+ }
59+ }
60+ return d
61+}
62+func registerColorManagement(d *server.Display, s *Server) error {
63+ return cm.NewWpColorManagerV1Global(d, 2, func(c server.Client, v, id uint32) {
64+ r, err := cm.NewWpColorManagerV1(c, int32(v), id, colorManager{s})
65+ if err != nil {
66+ return
67+ }
68+ r.SendSupportedIntent(uint32(cm.WpColorManagerV1RenderIntentPerceptual))
69+ r.SendSupportedFeature(uint32(cm.WpColorManagerV1FeatureParametric))
70+ for _, tf := range []uint32{pq, extLinear, uint32(cm.WpColorManagerV1TransferFunctionSrgb)} {
71+ r.SendSupportedTfNamed(tf)
72+ }
73+
74+ for _, p := range []uint32{srgb, bt2020} {
75+ r.SendSupportedPrimariesNamed(p)
76+ }
77+ r.SendDone()
78+ })
79+}
80+
81+type colorManager struct{ s *Server }
82+
83+func (colorManager) Destroy(*cm.WpColorManagerV1) {}
84+func (m colorManager) GetOutput(r *cm.WpColorManagerV1, id uint32, out *wayland.Output) {
85+ h := &colorOutput{s: m.s, o: m.s.outputOf(out)}
86+ res, err := cm.NewWpColorManagementOutputV1(r.Client(), r.Version(), id, h)
87+ if err != nil {
88+ return
89+ }
90+ h.res = res
91+ m.s.colorOutputs[h] = struct{}{}
92+ res.OnDestroy = func() { delete(m.s.colorOutputs, h) }
93+}
94+func (m colorManager) GetSurface(r *cm.WpColorManagerV1, id uint32, wl *wayland.Surface) {
95+ var surf *surface
96+ if wl != nil {
97+ surf = m.s.surfaces[wl.Resource]
98+ }
99+ if surf != nil && surf.colorControl != nil {
100+ r.PostError(uint32(cm.WpColorManagerV1ErrorSurfaceExists), "surface already has color management")
101+ return
102+ }
103+ h := &colorSurface{surf: surf}
104+ res, err := cm.NewWpColorManagementSurfaceV1(r.Client(), r.Version(), id, h)
105+ if err != nil {
106+ return
107+ }
108+ if surf != nil {
109+ surf.colorControl = h
110+ res.OnDestroy = func() {
111+ if surf.colorControl == h {
112+ surf.colorControl = nil
113+ surf.pendingColor = SurfaceColor{}
114+ }
115+ }
116+ }
117+}
118+func (m colorManager) GetSurfaceFeedback(r *cm.WpColorManagerV1, id uint32, wl *wayland.Surface) {
119+ var surf *surface
120+ if wl != nil {
121+ surf = m.s.surfaces[wl.Resource]
122+ }
123+ h := &colorFeedback{s: m.s, surf: surf}
124+ res, err := cm.NewWpColorManagementSurfaceFeedbackV1(r.Client(), r.Version(), id, h)
125+ if err != nil {
126+ return
127+ }
128+ h.res = res
129+ m.s.colorFeedbacks[h] = struct{}{}
130+ res.OnDestroy = func() { delete(m.s.colorFeedbacks, h) }
131+}
132+func (colorManager) CreateIccCreator(r *cm.WpColorManagerV1, _ uint32) {
133+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "ICC unsupported")
134+}
135+func (m colorManager) CreateParametricCreator(r *cm.WpColorManagerV1, id uint32) {
136+ _, _ = cm.NewWpImageDescriptionCreatorParamsV1(r.Client(), r.Version(), id, &colorParams{s: m.s})
137+}
138+func (colorManager) CreateWindowsScrgb(r *cm.WpColorManagerV1, _ uint32) {
139+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "scRGB unsupported")
140+}
141+func (colorManager) CreateWindowsBt2100(r *cm.WpColorManagerV1, _ uint32) {
142+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "BT2100 unsupported")
143+}
144+func (colorManager) GetImageDescription(r *cm.WpColorManagerV1, _ uint32, _ *cm.WpImageDescriptionReferenceV1) {
145+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "references unsupported")
146+}
147+
148+type colorOutput struct {
149+ s *Server
150+ o *output
151+ res *cm.WpColorManagementOutputV1
152+}
153+
154+func (*colorOutput) Destroy(*cm.WpColorManagementOutputV1) {}
155+func (h *colorOutput) GetImageDescription(r *cm.WpColorManagementOutputV1, id uint32) {
156+ if h.o == nil || h.s.outputByNameExact(h.o.name()) != h.o {
157+ h.s.newColorDescription(r.Client(), r.Version(), id, colorDescription{}, uint32(cm.WpImageDescriptionV1CauseNoOutput))
158+ return
159+ }
160+ h.s.newColorDescription(r.Client(), r.Version(), id, h.s.outputColor(h.o), 0)
161+}
162+
163+type colorSurface struct{ surf *surface }
164+
165+func (h *colorSurface) Destroy(*cm.WpColorManagementSurfaceV1) {
166+ if h.surf != nil && !h.surf.destroyed {
167+ h.surf.pendingColor = SurfaceColor{}
168+ }
169+}
170+func (h *colorSurface) SetImageDescription(r *cm.WpColorManagementSurfaceV1, desc *cm.WpImageDescriptionV1, intent uint32) {
171+ if h.surf == nil || h.surf.destroyed {
172+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorInert), "surface destroyed")
173+ return
174+ }
175+ if intent != 0 {
176+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorRenderIntent), "unsupported intent")
177+ return
178+ }
179+ if desc == nil {
180+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorImageDescription), "missing description")
181+ return
182+ }
183+ d, ok := h.surf.server.colorDescriptions[desc.Resource]
184+ if !ok {
185+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorImageDescription), "description not ready")
186+ return
187+ }
188+ h.surf.pendingColor = d.SurfaceColor
189+ h.surf.server.log.Debug().Str("component", "wayland").Uint32("tf", d.TF).Uint32("primaries", d.Primaries).Msg("surface color description set")
190+}
191+func (h *colorSurface) UnsetImageDescription(r *cm.WpColorManagementSurfaceV1) {
192+ if h.surf == nil || h.surf.destroyed {
193+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorInert), "surface destroyed")
194+ return
195+ }
196+ h.surf.pendingColor = SurfaceColor{}
197+ h.surf.server.log.Debug().Str("component", "wayland").Msg("surface color description unset")
198+}
199+
200+type colorFeedback struct {
201+ s *Server
202+ surf *surface
203+ res *cm.WpColorManagementSurfaceFeedbackV1
204+ last *output
205+}
206+
207+func (*colorFeedback) Destroy(*cm.WpColorManagementSurfaceFeedbackV1) {}
208+func (h *colorFeedback) preferred(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
209+ if h.surf == nil || h.surf.destroyed {
210+ r.PostError(uint32(cm.WpColorManagementSurfaceFeedbackV1ErrorInert), "surface destroyed")
211+ return
212+ }
213+ h.s.newColorDescription(r.Client(), r.Version(), id, h.s.outputColor(h.s.outputOfSurface(h.surf)), 0)
214+}
215+func (h *colorFeedback) GetPreferred(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
216+ h.preferred(r, id)
217+}
218+func (h *colorFeedback) GetPreferredParametric(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
219+ h.preferred(r, id)
220+}
221+func (s *Server) notifyColorFeedback() {
222+ for h := range s.colorFeedbacks {
223+ if h.surf == nil || h.surf.destroyed {
224+ continue
225+ }
226+ o := s.outputOfSurface(h.surf)
227+ if o != h.last {
228+ h.last = o
229+ h.changed(s.outputColor(o).identity)
230+ }
231+ }
232+}
233+func (h *colorFeedback) changed(id uint64) {
234+ if h.res.Version() >= 2 {
235+ h.res.SendPreferredChanged2(uint32(id>>32), uint32(id))
236+ } else {
237+ h.res.SendPreferredChanged(uint32(id))
238+ }
239+}
240+func (s *Server) colorOutputChanged(name string) {
241+ if s.outputByNameExact(name) == nil {
242+ return
243+ }
244+ if s.colorIdentity == nil {
245+ s.colorIdentity = map[string]uint64{}
246+ }
247+ s.colorIdentity[name] = s.nextColorID()
248+ for h := range s.colorOutputs {
249+ if h.o != nil && h.o.name() == name && s.outputByNameExact(name) == h.o {
250+ h.res.SendImageDescriptionChanged()
251+ for _, r := range h.o.resources {
252+ if r.Client() == h.res.Client() && r.Version() >= 2 {
253+ r.SendDone()
254+ }
255+ }
256+ }
257+ }
258+ for h := range s.colorFeedbacks {
259+ if h.surf != nil && !h.surf.destroyed {
260+ if o := s.outputOfSurface(h.surf); o != nil && o.name() == name {
261+ h.changed(s.colorIdentity[name])
262+ }
263+ }
264+ }
265+}
266+func (s *Server) newColorDescription(c server.Client, v int32, id uint32, d colorDescription, failure uint32) {
267+ s.createColorDescription(c, v, id, d, failure, true)
268+}
269+func (s *Server) createColorDescription(c server.Client, v int32, id uint32, d colorDescription, failure uint32, information bool) {
270+ h := &colorImage{s: s, d: d, ready: failure == 0, information: information}
271+ res, err := cm.NewWpImageDescriptionV1(c, v, id, h)
272+ if err != nil {
273+ return
274+ }
275+ if failure != 0 {
276+ msg := "output unavailable"
277+ if failure == uint32(cm.WpImageDescriptionV1CauseUnsupported) {
278+ msg = "unsupported transfer function and primaries combination"
279+ }
280+ res.SendFailed(failure, msg)
281+ return
282+ }
283+ if d.identity == 0 {
284+ d.identity = s.nextColorID()
285+ h.d.identity = d.identity
286+ }
287+ s.colorDescriptions[res.Resource] = d
288+ res.OnDestroy = func() { delete(s.colorDescriptions, res.Resource) }
289+ if v >= 2 {
290+ res.SendReady2(uint32(d.identity>>32), uint32(d.identity))
291+ } else {
292+ res.SendReady(uint32(d.identity))
293+ }
294+}
295+
296+type colorImage struct {
297+ s *Server
298+ d colorDescription
299+ ready bool
300+ information bool
301+}
302+
303+func (*colorImage) Destroy(*cm.WpImageDescriptionV1) {}
304+func (h *colorImage) GetInformation(r *cm.WpImageDescriptionV1, id uint32) {
305+ if !h.ready {
306+ r.PostError(uint32(cm.WpImageDescriptionV1ErrorNotReady), "description not ready")
307+ return
308+ }
309+ if !h.information {
310+ r.PostError(uint32(cm.WpImageDescriptionV1ErrorNoInformation), "information not allowed")
311+ return
312+ }
313+ info, err := cm.NewWpImageDescriptionInfoV1(r.Client(), r.Version(), id, colorInfo{})
314+ if err != nil {
315+ return
316+ }
317+ d := h.d
318+ coords := srgbCoords
319+ if d.Primaries == bt2020 {
320+ coords = bt2020Coords
321+ }
322+ info.SendPrimaries(coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], coords[6], coords[7])
323+ info.SendPrimariesNamed(d.Primaries)
324+ info.SendTfNamed(d.TF)
325+ if d.min != 0 || d.max != 0 {
326+ info.SendLuminances(d.min, d.max, d.white)
327+ }
328+ info.SendTargetPrimaries(coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], coords[6], coords[7])
329+ info.SendTargetLuminance(d.targetMin, d.targetMax)
330+ if d.cllSet {
331+ info.SendTargetMaxCll(d.MaxCLL)
332+ }
333+ if d.fallSet {
334+ info.SendTargetMaxFall(d.MaxFALL)
335+ }
336+ info.SendDone()
337+ info.Destroy()
338+}
339+
340+type colorInfo struct{}
341+
342+func (colorInfo) Destroy(*cm.WpImageDescriptionInfoV1) {}
343+
344+var srgbCoords = [8]int32{640000, 330000, 300000, 600000, 150000, 60000, 312700, 329000}
345+var bt2020Coords = [8]int32{708000, 292000, 170000, 797000, 131000, 46000, 312700, 329000}
346+
347+type colorParams struct {
348+ s *Server
349+ d colorDescription
350+ tfSet, primSet bool
351+}
352+
353+func (h *colorParams) fail(r *cm.WpImageDescriptionCreatorParamsV1, code cm.WpImageDescriptionCreatorParamsV1Error, msg string) {
354+ r.PostError(uint32(code), msg)
355+}
356+func (h *colorParams) Create(r *cm.WpImageDescriptionCreatorParamsV1, id uint32) {
357+ if !h.tfSet || !h.primSet {
358+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorIncompleteSet, "transfer and primaries required")
359+ return
360+ }
361+ if h.d.cllSet && h.d.fallSet && h.d.MaxFALL > h.d.MaxCLL {
362+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidLuminance, "MaxFALL exceeds MaxCLL")
363+ return
364+ }
365+ if r.Version() == 1 {
366+ for _, x := range []struct {
367+ set bool
368+ value uint32
369+ }{{h.d.cllSet, h.d.MaxCLL}, {h.d.fallSet, h.d.MaxFALL}} {
370+ if x.set && (uint64(x.value)*10000 <= uint64(h.d.targetMin) || x.value > h.d.targetMax) {
371+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidLuminance, "content luminance outside mastering range")
372+ return
373+ }
374+ }
375+ }
376+ // Advertised transfer functions and primaries do not imply that every
377+ // cross-product can be rendered. Reject unsupported combinations as an
378+ // image-description failure, not a protocol error.
379+ validSDR := h.d.Primaries == srgb && (h.d.TF == extLinear || h.d.TF == uint32(cm.WpColorManagerV1TransferFunctionSrgb))
380+ if !validSDR && !(h.d.Primaries == bt2020 && h.d.TF == pq) {
381+ h.s.createColorDescription(r.Client(), r.Version(), id, h.d, uint32(cm.WpImageDescriptionV1CauseUnsupported), false)
382+ return
383+ }
384+ h.s.createColorDescription(r.Client(), r.Version(), id, h.d, 0, false)
385+}
386+func (h *colorParams) SetTfNamed(r *cm.WpImageDescriptionCreatorParamsV1, tf uint32) {
387+ if h.tfSet {
388+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorAlreadySet, "TF already set")
389+ return
390+ }
391+ if tf == gamma22 {
392+ h.tfSet = true
393+ h.d.TF = tf
394+ h.d.Set = true
395+ return // Create reports failed(unsupported) for unsupported named TFs.
396+ }
397+ if tf != pq && tf != extLinear && tf != uint32(cm.WpColorManagerV1TransferFunctionSrgb) {
398+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidTf, "unsupported TF")
399+ return
400+ }
401+ h.tfSet = true
402+ h.d.TF = tf
403+ h.d.Set = true
404+ if tf == pq {
405+ h.d.min, h.d.max, h.d.white = 50, 10000, 203
406+ } else if tf == extLinear {
407+ // Protocol defaults: sRGB primary volume, 80 cd/m² reference white.
408+ h.d.min, h.d.max, h.d.white = 2000, 80, 80
409+ } else {
410+ h.d.min, h.d.max, h.d.white = 100, 100, 100
411+ }
412+ h.d.targetMin, h.d.targetMax = h.d.min, h.d.max
413+}
414+func (h *colorParams) SetTfPower(r *cm.WpImageDescriptionCreatorParamsV1, _ uint32) {
415+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "TF power unsupported")
416+}
417+func (h *colorParams) SetPrimariesNamed(r *cm.WpImageDescriptionCreatorParamsV1, p uint32) {
418+ if h.primSet {
419+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorAlreadySet, "primaries already set")
420+ return
421+ }
422+ if p != srgb && p != bt2020 {
423+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidPrimariesNamed, "unsupported primaries")
424+ return
425+ }
426+ h.primSet = true
427+ h.d.Primaries = p
428+}
429+func (h *colorParams) SetPrimaries(r *cm.WpImageDescriptionCreatorParamsV1, _, _, _, _, _, _, _, _ int32) {
430+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "custom primaries unsupported")
431+}
432+func (h *colorParams) SetLuminances(r *cm.WpImageDescriptionCreatorParamsV1, min, max, white uint32) {
433+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "custom luminances unsupported")
434+}
435+func (h *colorParams) SetMasteringDisplayPrimaries(r *cm.WpImageDescriptionCreatorParamsV1, _, _, _, _, _, _, _, _ int32) {
436+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "mastering primaries unsupported")
437+}
438+func (h *colorParams) SetMasteringLuminance(r *cm.WpImageDescriptionCreatorParamsV1, _, _ uint32) {
439+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "mastering luminance unsupported")
440+}
441+func (h *colorParams) SetMaxCll(_ *cm.WpImageDescriptionCreatorParamsV1, v uint32) {
442+ h.d.MaxCLL = v
443+ h.d.cllSet = true
444+}
445+func (h *colorParams) SetMaxFall(_ *cm.WpImageDescriptionCreatorParamsV1, v uint32) {
446+ h.d.MaxFALL = v
447+ h.d.fallSet = true
448+}
@@ -0,0 +1,448 @@
1+package wayland
2+
3+import (
4+ "math"
5+
6+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// SurfaceColor is the committed color encoding of a client surface. Set is false
12+// when no explicit description was committed; the default is compositor-defined.
13+type SurfaceColor struct {
14+ Set bool
15+ TF, Primaries, MaxCLL, MaxFALL uint32
16+}
17+
18+func (s *surface) ColorDescription() SurfaceColor { return s.color }
19+
20+type colorDescription struct {
21+ SurfaceColor
22+ min, max, white, targetMin, targetMax uint32
23+ cllSet, fallSet bool
24+ identity uint64
25+}
26+
27+const (
28+ srgb = uint32(cm.WpColorManagerV1PrimariesSrgb)
29+ bt2020 = uint32(cm.WpColorManagerV1PrimariesBt2020)
30+ gamma22 = uint32(cm.WpColorManagerV1TransferFunctionGamma22)
31+ pq = uint32(cm.WpColorManagerV1TransferFunctionSt2084Pq)
32+ extLinear = uint32(cm.WpColorManagerV1TransferFunctionExtLinear)
33+)
34+
35+func (s *Server) nextColorID() uint64 { s.colorID++; return s.colorID }
36+func (s *Server) outputColor(o *output) colorDescription {
37+ d := colorDescription{SurfaceColor: SurfaceColor{Set: true, TF: uint32(cm.WpColorManagerV1TransferFunctionSrgb), Primaries: srgb}, min: 2000, max: 80, white: 80, targetMin: 2000, targetMax: 80}
38+ if o != nil {
39+ if h, ok := s.hdrOutputs[o.name()]; ok {
40+ d.TF, d.Primaries, d.white = pq, bt2020, 203
41+ d.min = uint32(math.Max(0, math.Round(h.MinLuminance*10000)))
42+ d.max = d.min/10000 + 10000 // PQ primary volume is fixed by ST 2084.
43+ d.targetMin, d.targetMax = d.min, uint32(math.Max(1, math.Round(h.MaxLuminance)))
44+ d.MaxCLL = d.targetMax
45+ d.MaxFALL = uint32(math.Max(0, math.Round(h.MaxFrameAverage)))
46+ d.cllSet, d.fallSet = true, true
47+ }
48+ }
49+ // Identity changes only when the description changes, not on each query.
50+ if o != nil {
51+ if s.colorIdentity == nil {
52+ s.colorIdentity = map[string]uint64{}
53+ }
54+ d.identity = s.colorIdentity[o.name()]
55+ if d.identity == 0 {
56+ d.identity = s.nextColorID()
57+ s.colorIdentity[o.name()] = d.identity
58+ }
59+ }
60+ return d
61+}
62+func registerColorManagement(d *server.Display, s *Server) error {
63+ return cm.NewWpColorManagerV1Global(d, 2, func(c server.Client, v, id uint32) {
64+ r, err := cm.NewWpColorManagerV1(c, int32(v), id, colorManager{s})
65+ if err != nil {
66+ return
67+ }
68+ r.SendSupportedIntent(uint32(cm.WpColorManagerV1RenderIntentPerceptual))
69+ r.SendSupportedFeature(uint32(cm.WpColorManagerV1FeatureParametric))
70+ for _, tf := range []uint32{pq, extLinear, uint32(cm.WpColorManagerV1TransferFunctionSrgb)} {
71+ r.SendSupportedTfNamed(tf)
72+ }
73+
74+ for _, p := range []uint32{srgb, bt2020} {
75+ r.SendSupportedPrimariesNamed(p)
76+ }
77+ r.SendDone()
78+ })
79+}
80+
81+type colorManager struct{ s *Server }
82+
83+func (colorManager) Destroy(*cm.WpColorManagerV1) {}
84+func (m colorManager) GetOutput(r *cm.WpColorManagerV1, id uint32, out *wayland.Output) {
85+ h := &colorOutput{s: m.s, o: m.s.outputOf(out)}
86+ res, err := cm.NewWpColorManagementOutputV1(r.Client(), r.Version(), id, h)
87+ if err != nil {
88+ return
89+ }
90+ h.res = res
91+ m.s.colorOutputs[h] = struct{}{}
92+ res.OnDestroy = func() { delete(m.s.colorOutputs, h) }
93+}
94+func (m colorManager) GetSurface(r *cm.WpColorManagerV1, id uint32, wl *wayland.Surface) {
95+ var surf *surface
96+ if wl != nil {
97+ surf = m.s.surfaces[wl.Resource]
98+ }
99+ if surf != nil && surf.colorControl != nil {
100+ r.PostError(uint32(cm.WpColorManagerV1ErrorSurfaceExists), "surface already has color management")
101+ return
102+ }
103+ h := &colorSurface{surf: surf}
104+ res, err := cm.NewWpColorManagementSurfaceV1(r.Client(), r.Version(), id, h)
105+ if err != nil {
106+ return
107+ }
108+ if surf != nil {
109+ surf.colorControl = h
110+ res.OnDestroy = func() {
111+ if surf.colorControl == h {
112+ surf.colorControl = nil
113+ surf.pendingColor = SurfaceColor{}
114+ }
115+ }
116+ }
117+}
118+func (m colorManager) GetSurfaceFeedback(r *cm.WpColorManagerV1, id uint32, wl *wayland.Surface) {
119+ var surf *surface
120+ if wl != nil {
121+ surf = m.s.surfaces[wl.Resource]
122+ }
123+ h := &colorFeedback{s: m.s, surf: surf}
124+ res, err := cm.NewWpColorManagementSurfaceFeedbackV1(r.Client(), r.Version(), id, h)
125+ if err != nil {
126+ return
127+ }
128+ h.res = res
129+ m.s.colorFeedbacks[h] = struct{}{}
130+ res.OnDestroy = func() { delete(m.s.colorFeedbacks, h) }
131+}
132+func (colorManager) CreateIccCreator(r *cm.WpColorManagerV1, _ uint32) {
133+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "ICC unsupported")
134+}
135+func (m colorManager) CreateParametricCreator(r *cm.WpColorManagerV1, id uint32) {
136+ _, _ = cm.NewWpImageDescriptionCreatorParamsV1(r.Client(), r.Version(), id, &colorParams{s: m.s})
137+}
138+func (colorManager) CreateWindowsScrgb(r *cm.WpColorManagerV1, _ uint32) {
139+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "scRGB unsupported")
140+}
141+func (colorManager) CreateWindowsBt2100(r *cm.WpColorManagerV1, _ uint32) {
142+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "BT2100 unsupported")
143+}
144+func (colorManager) GetImageDescription(r *cm.WpColorManagerV1, _ uint32, _ *cm.WpImageDescriptionReferenceV1) {
145+ r.PostError(uint32(cm.WpColorManagerV1ErrorUnsupportedFeature), "references unsupported")
146+}
147+
148+type colorOutput struct {
149+ s *Server
150+ o *output
151+ res *cm.WpColorManagementOutputV1
152+}
153+
154+func (*colorOutput) Destroy(*cm.WpColorManagementOutputV1) {}
155+func (h *colorOutput) GetImageDescription(r *cm.WpColorManagementOutputV1, id uint32) {
156+ if h.o == nil || h.s.outputByNameExact(h.o.name()) != h.o {
157+ h.s.newColorDescription(r.Client(), r.Version(), id, colorDescription{}, uint32(cm.WpImageDescriptionV1CauseNoOutput))
158+ return
159+ }
160+ h.s.newColorDescription(r.Client(), r.Version(), id, h.s.outputColor(h.o), 0)
161+}
162+
163+type colorSurface struct{ surf *surface }
164+
165+func (h *colorSurface) Destroy(*cm.WpColorManagementSurfaceV1) {
166+ if h.surf != nil && !h.surf.destroyed {
167+ h.surf.pendingColor = SurfaceColor{}
168+ }
169+}
170+func (h *colorSurface) SetImageDescription(r *cm.WpColorManagementSurfaceV1, desc *cm.WpImageDescriptionV1, intent uint32) {
171+ if h.surf == nil || h.surf.destroyed {
172+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorInert), "surface destroyed")
173+ return
174+ }
175+ if intent != 0 {
176+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorRenderIntent), "unsupported intent")
177+ return
178+ }
179+ if desc == nil {
180+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorImageDescription), "missing description")
181+ return
182+ }
183+ d, ok := h.surf.server.colorDescriptions[desc.Resource]
184+ if !ok {
185+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorImageDescription), "description not ready")
186+ return
187+ }
188+ h.surf.pendingColor = d.SurfaceColor
189+ h.surf.server.log.Debug().Str("component", "wayland").Uint32("tf", d.TF).Uint32("primaries", d.Primaries).Msg("surface color description set")
190+}
191+func (h *colorSurface) UnsetImageDescription(r *cm.WpColorManagementSurfaceV1) {
192+ if h.surf == nil || h.surf.destroyed {
193+ r.PostError(uint32(cm.WpColorManagementSurfaceV1ErrorInert), "surface destroyed")
194+ return
195+ }
196+ h.surf.pendingColor = SurfaceColor{}
197+ h.surf.server.log.Debug().Str("component", "wayland").Msg("surface color description unset")
198+}
199+
200+type colorFeedback struct {
201+ s *Server
202+ surf *surface
203+ res *cm.WpColorManagementSurfaceFeedbackV1
204+ last *output
205+}
206+
207+func (*colorFeedback) Destroy(*cm.WpColorManagementSurfaceFeedbackV1) {}
208+func (h *colorFeedback) preferred(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
209+ if h.surf == nil || h.surf.destroyed {
210+ r.PostError(uint32(cm.WpColorManagementSurfaceFeedbackV1ErrorInert), "surface destroyed")
211+ return
212+ }
213+ h.s.newColorDescription(r.Client(), r.Version(), id, h.s.outputColor(h.s.outputOfSurface(h.surf)), 0)
214+}
215+func (h *colorFeedback) GetPreferred(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
216+ h.preferred(r, id)
217+}
218+func (h *colorFeedback) GetPreferredParametric(r *cm.WpColorManagementSurfaceFeedbackV1, id uint32) {
219+ h.preferred(r, id)
220+}
221+func (s *Server) notifyColorFeedback() {
222+ for h := range s.colorFeedbacks {
223+ if h.surf == nil || h.surf.destroyed {
224+ continue
225+ }
226+ o := s.outputOfSurface(h.surf)
227+ if o != h.last {
228+ h.last = o
229+ h.changed(s.outputColor(o).identity)
230+ }
231+ }
232+}
233+func (h *colorFeedback) changed(id uint64) {
234+ if h.res.Version() >= 2 {
235+ h.res.SendPreferredChanged2(uint32(id>>32), uint32(id))
236+ } else {
237+ h.res.SendPreferredChanged(uint32(id))
238+ }
239+}
240+func (s *Server) colorOutputChanged(name string) {
241+ if s.outputByNameExact(name) == nil {
242+ return
243+ }
244+ if s.colorIdentity == nil {
245+ s.colorIdentity = map[string]uint64{}
246+ }
247+ s.colorIdentity[name] = s.nextColorID()
248+ for h := range s.colorOutputs {
249+ if h.o != nil && h.o.name() == name && s.outputByNameExact(name) == h.o {
250+ h.res.SendImageDescriptionChanged()
251+ for _, r := range h.o.resources {
252+ if r.Client() == h.res.Client() && r.Version() >= 2 {
253+ r.SendDone()
254+ }
255+ }
256+ }
257+ }
258+ for h := range s.colorFeedbacks {
259+ if h.surf != nil && !h.surf.destroyed {
260+ if o := s.outputOfSurface(h.surf); o != nil && o.name() == name {
261+ h.changed(s.colorIdentity[name])
262+ }
263+ }
264+ }
265+}
266+func (s *Server) newColorDescription(c server.Client, v int32, id uint32, d colorDescription, failure uint32) {
267+ s.createColorDescription(c, v, id, d, failure, true)
268+}
269+func (s *Server) createColorDescription(c server.Client, v int32, id uint32, d colorDescription, failure uint32, information bool) {
270+ h := &colorImage{s: s, d: d, ready: failure == 0, information: information}
271+ res, err := cm.NewWpImageDescriptionV1(c, v, id, h)
272+ if err != nil {
273+ return
274+ }
275+ if failure != 0 {
276+ msg := "output unavailable"
277+ if failure == uint32(cm.WpImageDescriptionV1CauseUnsupported) {
278+ msg = "unsupported transfer function and primaries combination"
279+ }
280+ res.SendFailed(failure, msg)
281+ return
282+ }
283+ if d.identity == 0 {
284+ d.identity = s.nextColorID()
285+ h.d.identity = d.identity
286+ }
287+ s.colorDescriptions[res.Resource] = d
288+ res.OnDestroy = func() { delete(s.colorDescriptions, res.Resource) }
289+ if v >= 2 {
290+ res.SendReady2(uint32(d.identity>>32), uint32(d.identity))
291+ } else {
292+ res.SendReady(uint32(d.identity))
293+ }
294+}
295+
296+type colorImage struct {
297+ s *Server
298+ d colorDescription
299+ ready bool
300+ information bool
301+}
302+
303+func (*colorImage) Destroy(*cm.WpImageDescriptionV1) {}
304+func (h *colorImage) GetInformation(r *cm.WpImageDescriptionV1, id uint32) {
305+ if !h.ready {
306+ r.PostError(uint32(cm.WpImageDescriptionV1ErrorNotReady), "description not ready")
307+ return
308+ }
309+ if !h.information {
310+ r.PostError(uint32(cm.WpImageDescriptionV1ErrorNoInformation), "information not allowed")
311+ return
312+ }
313+ info, err := cm.NewWpImageDescriptionInfoV1(r.Client(), r.Version(), id, colorInfo{})
314+ if err != nil {
315+ return
316+ }
317+ d := h.d
318+ coords := srgbCoords
319+ if d.Primaries == bt2020 {
320+ coords = bt2020Coords
321+ }
322+ info.SendPrimaries(coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], coords[6], coords[7])
323+ info.SendPrimariesNamed(d.Primaries)
324+ info.SendTfNamed(d.TF)
325+ if d.min != 0 || d.max != 0 {
326+ info.SendLuminances(d.min, d.max, d.white)
327+ }
328+ info.SendTargetPrimaries(coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], coords[6], coords[7])
329+ info.SendTargetLuminance(d.targetMin, d.targetMax)
330+ if d.cllSet {
331+ info.SendTargetMaxCll(d.MaxCLL)
332+ }
333+ if d.fallSet {
334+ info.SendTargetMaxFall(d.MaxFALL)
335+ }
336+ info.SendDone()
337+ info.Destroy()
338+}
339+
340+type colorInfo struct{}
341+
342+func (colorInfo) Destroy(*cm.WpImageDescriptionInfoV1) {}
343+
344+var srgbCoords = [8]int32{640000, 330000, 300000, 600000, 150000, 60000, 312700, 329000}
345+var bt2020Coords = [8]int32{708000, 292000, 170000, 797000, 131000, 46000, 312700, 329000}
346+
347+type colorParams struct {
348+ s *Server
349+ d colorDescription
350+ tfSet, primSet bool
351+}
352+
353+func (h *colorParams) fail(r *cm.WpImageDescriptionCreatorParamsV1, code cm.WpImageDescriptionCreatorParamsV1Error, msg string) {
354+ r.PostError(uint32(code), msg)
355+}
356+func (h *colorParams) Create(r *cm.WpImageDescriptionCreatorParamsV1, id uint32) {
357+ if !h.tfSet || !h.primSet {
358+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorIncompleteSet, "transfer and primaries required")
359+ return
360+ }
361+ if h.d.cllSet && h.d.fallSet && h.d.MaxFALL > h.d.MaxCLL {
362+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidLuminance, "MaxFALL exceeds MaxCLL")
363+ return
364+ }
365+ if r.Version() == 1 {
366+ for _, x := range []struct {
367+ set bool
368+ value uint32
369+ }{{h.d.cllSet, h.d.MaxCLL}, {h.d.fallSet, h.d.MaxFALL}} {
370+ if x.set && (uint64(x.value)*10000 <= uint64(h.d.targetMin) || x.value > h.d.targetMax) {
371+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidLuminance, "content luminance outside mastering range")
372+ return
373+ }
374+ }
375+ }
376+ // Advertised transfer functions and primaries do not imply that every
377+ // cross-product can be rendered. Reject unsupported combinations as an
378+ // image-description failure, not a protocol error.
379+ validSDR := h.d.Primaries == srgb && (h.d.TF == extLinear || h.d.TF == uint32(cm.WpColorManagerV1TransferFunctionSrgb))
380+ if !validSDR && !(h.d.Primaries == bt2020 && h.d.TF == pq) {
381+ h.s.createColorDescription(r.Client(), r.Version(), id, h.d, uint32(cm.WpImageDescriptionV1CauseUnsupported), false)
382+ return
383+ }
384+ h.s.createColorDescription(r.Client(), r.Version(), id, h.d, 0, false)
385+}
386+func (h *colorParams) SetTfNamed(r *cm.WpImageDescriptionCreatorParamsV1, tf uint32) {
387+ if h.tfSet {
388+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorAlreadySet, "TF already set")
389+ return
390+ }
391+ if tf == gamma22 {
392+ h.tfSet = true
393+ h.d.TF = tf
394+ h.d.Set = true
395+ return // Create reports failed(unsupported) for unsupported named TFs.
396+ }
397+ if tf != pq && tf != extLinear && tf != uint32(cm.WpColorManagerV1TransferFunctionSrgb) {
398+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidTf, "unsupported TF")
399+ return
400+ }
401+ h.tfSet = true
402+ h.d.TF = tf
403+ h.d.Set = true
404+ if tf == pq {
405+ h.d.min, h.d.max, h.d.white = 50, 10000, 203
406+ } else if tf == extLinear {
407+ // Protocol defaults: sRGB primary volume, 80 cd/m² reference white.
408+ h.d.min, h.d.max, h.d.white = 2000, 80, 80
409+ } else {
410+ h.d.min, h.d.max, h.d.white = 100, 100, 100
411+ }
412+ h.d.targetMin, h.d.targetMax = h.d.min, h.d.max
413+}
414+func (h *colorParams) SetTfPower(r *cm.WpImageDescriptionCreatorParamsV1, _ uint32) {
415+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "TF power unsupported")
416+}
417+func (h *colorParams) SetPrimariesNamed(r *cm.WpImageDescriptionCreatorParamsV1, p uint32) {
418+ if h.primSet {
419+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorAlreadySet, "primaries already set")
420+ return
421+ }
422+ if p != srgb && p != bt2020 {
423+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorInvalidPrimariesNamed, "unsupported primaries")
424+ return
425+ }
426+ h.primSet = true
427+ h.d.Primaries = p
428+}
429+func (h *colorParams) SetPrimaries(r *cm.WpImageDescriptionCreatorParamsV1, _, _, _, _, _, _, _, _ int32) {
430+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "custom primaries unsupported")
431+}
432+func (h *colorParams) SetLuminances(r *cm.WpImageDescriptionCreatorParamsV1, min, max, white uint32) {
433+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "custom luminances unsupported")
434+}
435+func (h *colorParams) SetMasteringDisplayPrimaries(r *cm.WpImageDescriptionCreatorParamsV1, _, _, _, _, _, _, _, _ int32) {
436+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "mastering primaries unsupported")
437+}
438+func (h *colorParams) SetMasteringLuminance(r *cm.WpImageDescriptionCreatorParamsV1, _, _ uint32) {
439+ h.fail(r, cm.WpImageDescriptionCreatorParamsV1ErrorUnsupportedFeature, "mastering luminance unsupported")
440+}
441+func (h *colorParams) SetMaxCll(_ *cm.WpImageDescriptionCreatorParamsV1, v uint32) {
442+ h.d.MaxCLL = v
443+ h.d.cllSet = true
444+}
445+func (h *colorParams) SetMaxFall(_ *cm.WpImageDescriptionCreatorParamsV1, v uint32) {
446+ h.d.MaxFALL = v
447+ h.d.fallSet = true
448+}
A internal/adapters/wayland/colormanagement_errors_test.go +102 -0
@@ -0,0 +1,102 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+ "testing"
8+)
9+
10+func TestColorManagerErrors(t *testing.T) {
11+ for _, tc := range []struct {
12+ name string
13+ op uint32
14+ code uint32
15+ }{
16+ {"ICC", cm.WpColorManagerV1RequestCreateIccCreator, 0},
17+ {"scRGB", cm.WpColorManagerV1RequestCreateWindowsScrgb, 0},
18+ {"duplicate surface", cm.WpColorManagerV1RequestGetSurface, 1},
19+ } {
20+ t.Run(tc.name, func(t *testing.T) {
21+ _, c, _ := colorServer(t)
22+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
23+ registerProtocol(t, c, manager)
24+ var wl uint32
25+ if tc.name == "duplicate surface" {
26+ comp := bindProtocol(t, c, "wl_compositor")
27+ wl = c.AllocateID()
28+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
29+ registerProtocol(t, c, wl)
30+ control := c.AllocateID()
31+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, control, wl)
32+ registerProtocol(t, c, control)
33+ }
34+ id := c.AllocateID()
35+ if wl != 0 {
36+ requestProtocol(t, c, manager, tc.op, id, wl)
37+ } else {
38+ requestProtocol(t, c, manager, tc.op, id)
39+ }
40+ expectProtocolError(t, c, manager, tc.code)
41+ })
42+ }
43+}
44+func TestColorSurfaceErrors(t *testing.T) {
45+ for _, tc := range []struct {
46+ name string
47+ code uint32
48+ intent uint32
49+ inert bool
50+ }{
51+ {"intent", 0, 42, false}, {"description", 1, 0, false}, {"inert", 2, 0, true},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ s, c, _ := colorServer(t)
55+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
56+ registerProtocol(t, c, manager)
57+ comp := bindProtocol(t, c, "wl_compositor")
58+ wl := c.AllocateID()
59+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
60+ registerProtocol(t, c, wl)
61+ control := c.AllocateID()
62+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, control, wl)
63+ registerProtocol(t, c, control)
64+ if tc.inert {
65+ requestProtocol(t, c, wl, wayland.SurfaceRequestDestroy)
66+ requestProtocol(t, c, control, cm.WpColorManagementSurfaceV1RequestUnsetImageDescription)
67+ } else {
68+ var global uint32
69+ for name, g := range c.Registry().GetGlobals() {
70+ if g.Interface == "wl_output" {
71+ global = name
72+ }
73+ }
74+ out, err := c.Registry().BindID(global, "wl_output", 4)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ registerProtocol(t, c, out)
79+ output := c.AllocateID()
80+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetOutput, output, out)
81+ registerProtocol(t, c, output)
82+ if err := c.Roundtrip(); err != nil {
83+ t.Fatal(err)
84+ }
85+ if tc.name == "description" {
86+ s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) })
87+ if err := c.Roundtrip(); err != nil {
88+ t.Fatal(err)
89+ }
90+ }
91+ img := c.AllocateID()
92+ requestProtocol(t, c, output, cm.WpColorManagementOutputV1RequestGetImageDescription, img)
93+ registerProtocol(t, c, img)
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ requestProtocol(t, c, control, cm.WpColorManagementSurfaceV1RequestSetImageDescription, img, tc.intent)
98+ }
99+ expectProtocolError(t, c, control, tc.code)
100+ })
101+ }
102+}
@@ -0,0 +1,102 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
6+ "github.com/bnema/purego-libwayland/protocol/wayland"
7+ "testing"
8+)
9+
10+func TestColorManagerErrors(t *testing.T) {
11+ for _, tc := range []struct {
12+ name string
13+ op uint32
14+ code uint32
15+ }{
16+ {"ICC", cm.WpColorManagerV1RequestCreateIccCreator, 0},
17+ {"scRGB", cm.WpColorManagerV1RequestCreateWindowsScrgb, 0},
18+ {"duplicate surface", cm.WpColorManagerV1RequestGetSurface, 1},
19+ } {
20+ t.Run(tc.name, func(t *testing.T) {
21+ _, c, _ := colorServer(t)
22+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
23+ registerProtocol(t, c, manager)
24+ var wl uint32
25+ if tc.name == "duplicate surface" {
26+ comp := bindProtocol(t, c, "wl_compositor")
27+ wl = c.AllocateID()
28+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
29+ registerProtocol(t, c, wl)
30+ control := c.AllocateID()
31+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, control, wl)
32+ registerProtocol(t, c, control)
33+ }
34+ id := c.AllocateID()
35+ if wl != 0 {
36+ requestProtocol(t, c, manager, tc.op, id, wl)
37+ } else {
38+ requestProtocol(t, c, manager, tc.op, id)
39+ }
40+ expectProtocolError(t, c, manager, tc.code)
41+ })
42+ }
43+}
44+func TestColorSurfaceErrors(t *testing.T) {
45+ for _, tc := range []struct {
46+ name string
47+ code uint32
48+ intent uint32
49+ inert bool
50+ }{
51+ {"intent", 0, 42, false}, {"description", 1, 0, false}, {"inert", 2, 0, true},
52+ } {
53+ t.Run(tc.name, func(t *testing.T) {
54+ s, c, _ := colorServer(t)
55+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
56+ registerProtocol(t, c, manager)
57+ comp := bindProtocol(t, c, "wl_compositor")
58+ wl := c.AllocateID()
59+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
60+ registerProtocol(t, c, wl)
61+ control := c.AllocateID()
62+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, control, wl)
63+ registerProtocol(t, c, control)
64+ if tc.inert {
65+ requestProtocol(t, c, wl, wayland.SurfaceRequestDestroy)
66+ requestProtocol(t, c, control, cm.WpColorManagementSurfaceV1RequestUnsetImageDescription)
67+ } else {
68+ var global uint32
69+ for name, g := range c.Registry().GetGlobals() {
70+ if g.Interface == "wl_output" {
71+ global = name
72+ }
73+ }
74+ out, err := c.Registry().BindID(global, "wl_output", 4)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ registerProtocol(t, c, out)
79+ output := c.AllocateID()
80+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetOutput, output, out)
81+ registerProtocol(t, c, output)
82+ if err := c.Roundtrip(); err != nil {
83+ t.Fatal(err)
84+ }
85+ if tc.name == "description" {
86+ s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) })
87+ if err := c.Roundtrip(); err != nil {
88+ t.Fatal(err)
89+ }
90+ }
91+ img := c.AllocateID()
92+ requestProtocol(t, c, output, cm.WpColorManagementOutputV1RequestGetImageDescription, img)
93+ registerProtocol(t, c, img)
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ requestProtocol(t, c, control, cm.WpColorManagementSurfaceV1RequestSetImageDescription, img, tc.intent)
98+ }
99+ expectProtocolError(t, c, control, tc.code)
100+ })
101+ }
102+}
A internal/adapters/wayland/colormanagement_test.go +327 -0
@@ -0,0 +1,327 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/logging"
8+ "github.com/bnema/neferwl/internal/ports"
9+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type colorEvent struct {
15+ wlturbo.BaseProxy
16+ events []uint16
17+ values [][]uint32
18+}
19+
20+func (p *colorEvent) Dispatch(e *wlturbo.Event) {
21+ p.events = append(p.events, e.Opcode)
22+ var vals []uint32
23+ for i := 0; i < 12; i++ {
24+ if len(e.Data())-e.Offset() < 4 {
25+ break
26+ }
27+ vals = append(vals, e.Uint32())
28+ }
29+ p.values = append(p.values, vals)
30+}
31+func watchColor(c *wlturbo.Display, id uint32) *colorEvent {
32+ p := &colorEvent{}
33+ p.SetID(id)
34+ c.Context().Register(p)
35+ return p
36+}
37+func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) {
38+ t.Helper()
39+ dir := t.TempDir()
40+ formats := make(chan ports.OutputFormats, 8)
41+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 8, Height: 8}, Width: 8, Height: 8, Scale: 1}}}, Channels{OutputFormats: formats}, logging.For(context.Background(), "wayland"))
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ ctx, cancel := context.WithCancel(context.Background())
46+ done := make(chan error, 1)
47+ go func() { done <- s.Run(ctx) }()
48+ t.Cleanup(func() {
49+ cancel()
50+ if err := <-done; err != nil {
51+ t.Error(err)
52+ }
53+ })
54+ return s, protocolClient(t, s, dir), formats
55+}
56+func TestColorMesaFlow(t *testing.T) {
57+ s, c, _ := colorServer(t)
58+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
59+ caps := watchColor(c, manager)
60+ if err := c.Roundtrip(); err != nil {
61+ t.Fatal(err)
62+ }
63+ var features, tf, prim []uint32
64+ for i, op := range caps.events {
65+ switch uint32(op) {
66+ case cm.WpColorManagerV1EventSupportedFeature:
67+ features = append(features, caps.values[i][0])
68+ case cm.WpColorManagerV1EventSupportedTfNamed:
69+ tf = append(tf, caps.values[i][0])
70+ case cm.WpColorManagerV1EventSupportedPrimariesNamed:
71+ prim = append(prim, caps.values[i][0])
72+ }
73+ }
74+ if len(features) != 1 || features[0] != uint32(cm.WpColorManagerV1FeatureParametric) || len(tf) != 3 || tf[0] != pq || tf[1] != extLinear || tf[2] != uint32(cm.WpColorManagerV1TransferFunctionSrgb) || len(prim) != 2 || uint32(caps.events[len(caps.events)-1]) != cm.WpColorManagerV1EventDone {
75+ t.Fatalf("capabilities %v %v %v %v", features, tf, prim, caps.events)
76+ }
77+ comp := bindProtocol(t, c, "wl_compositor")
78+ wl := c.AllocateID()
79+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
80+ registerProtocol(t, c, wl)
81+ surfaceID := c.AllocateID()
82+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, surfaceID, wl)
83+ registerProtocol(t, c, surfaceID)
84+ creator := c.AllocateID()
85+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
86+ registerProtocol(t, c, creator)
87+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, bt2020)
88+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, pq)
89+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxCll, uint32(1000))
90+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxFall, uint32(400))
91+ image := c.AllocateID()
92+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestCreate, image)
93+ ready := watchColor(c, image)
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ if len(ready.events) != 1 || uint32(ready.events[0]) != cm.WpImageDescriptionV1EventReady2 {
98+ t.Fatalf("ready %v", ready.events)
99+ }
100+ requestProtocol(t, c, surfaceID, cm.WpColorManagementSurfaceV1RequestSetImageDescription, image, uint32(0))
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ var before SurfaceColor
105+ s.display.Do(func() { before = s.surfaceColor(wl) })
106+ if before.Set {
107+ t.Fatal("color applied before commit")
108+ }
109+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
110+ if err := c.Roundtrip(); err != nil {
111+ t.Fatal(err)
112+ }
113+ s.display.Do(func() { before = s.surfaceColor(wl) })
114+ if before != (SurfaceColor{true, pq, bt2020, 1000, 400}) {
115+ t.Fatalf("committed color %+v", before)
116+ }
117+ var routed ports.SurfaceColor
118+ s.display.Do(func() {
119+ for res, surf := range s.surfaces {
120+ if res.ID() == wl {
121+ routed = surf.tree(1).Color
122+ }
123+ }
124+ })
125+ if routed != (ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020, MaxCLL: 1000, MaxFALL: 400}) {
126+ t.Fatalf("routed color %+v", routed)
127+ }
128+ requestProtocol(t, c, surfaceID, cm.WpColorManagementSurfaceV1RequestUnsetImageDescription)
129+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
130+ if err := c.Roundtrip(); err != nil {
131+ t.Fatal(err)
132+ }
133+ s.display.Do(func() { before = s.surfaceColor(wl) })
134+ if before.Set {
135+ t.Fatal("unset not committed")
136+ }
137+}
138+func (s *Server) surfaceColor(id uint32) SurfaceColor {
139+ for r, v := range s.surfaces {
140+ if r.ID() == id {
141+ return v.ColorDescription()
142+ }
143+ }
144+ return SurfaceColor{}
145+}
146+func TestColorOutputAndFeedback(t *testing.T) {
147+ s, c, _ := colorServer(t)
148+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
149+ registerProtocol(t, c, manager)
150+ var name uint32
151+ for id, g := range c.Registry().GetGlobals() {
152+ if g.Interface == "wl_output" {
153+ name = id
154+ }
155+ }
156+ out, err := c.Registry().BindID(name, "wl_output", 4)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ registerProtocol(t, c, out)
161+ obj := c.AllocateID()
162+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetOutput, obj, out)
163+ changed := watchColor(c, obj)
164+ desc := c.AllocateID()
165+ requestProtocol(t, c, obj, cm.WpColorManagementOutputV1RequestGetImageDescription, desc)
166+ ready := watchColor(c, desc)
167+ if err := c.Roundtrip(); err != nil {
168+ t.Fatal(err)
169+ }
170+ if len(ready.events) != 1 || uint32(ready.events[0]) != cm.WpImageDescriptionV1EventReady2 {
171+ t.Fatalf("output ready %v", ready.events)
172+ }
173+ oldID := uint64(ready.values[0][0])<<32 | uint64(ready.values[0][1])
174+ if oldID == 0 {
175+ t.Fatal("zero output identity")
176+ }
177+ info := c.AllocateID()
178+ requestProtocol(t, c, desc, cm.WpImageDescriptionV1RequestGetInformation, info)
179+ ev := watchColor(c, info)
180+ if err := c.Roundtrip(); err != nil {
181+ t.Fatal(err)
182+ }
183+ if len(ev.events) < 5 || ev.values[1][0] != srgb || ev.values[2][0] != uint32(cm.WpColorManagerV1TransferFunctionSrgb) {
184+ t.Fatalf("SDR info %v %v", ev.events, ev.values)
185+ }
186+ comp := bindProtocol(t, c, "wl_compositor")
187+ wl := c.AllocateID()
188+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
189+ registerProtocol(t, c, wl)
190+ feedback := c.AllocateID()
191+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurfaceFeedback, feedback, wl)
192+ pref := watchColor(c, feedback)
193+ if err := c.Roundtrip(); err != nil {
194+ t.Fatal(err)
195+ }
196+ preferredID := c.AllocateID()
197+ requestProtocol(t, c, feedback, cm.WpColorManagementSurfaceFeedbackV1RequestGetPreferredParametric, preferredID)
198+ preferred := watchColor(c, preferredID)
199+ if err := c.Roundtrip(); err != nil {
200+ t.Fatal(err)
201+ }
202+ if len(preferred.events) != 1 || uint32(preferred.events[0]) != cm.WpImageDescriptionV1EventReady2 {
203+ t.Fatalf("preferred %v", preferred.events)
204+ }
205+ s.display.Do(func() {
206+ s.setOutputHDR(ports.OutputFormats{Output: "HEADLESS-1", HDR: &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}})
207+ })
208+ if err := c.Roundtrip(); err != nil {
209+ t.Fatal(err)
210+ }
211+ // The format report is asynchronous to client requests.
212+ if err := c.Roundtrip(); err != nil {
213+ t.Fatal(err)
214+ }
215+ if len(changed.events) == 0 || len(pref.events) == 0 {
216+ t.Fatalf("change events output=%v preferred=%v", changed.events, pref.events)
217+ }
218+ desc2 := c.AllocateID()
219+ requestProtocol(t, c, obj, cm.WpColorManagementOutputV1RequestGetImageDescription, desc2)
220+ ready2 := watchColor(c, desc2)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ newID := uint64(ready2.values[0][0])<<32 | uint64(ready2.values[0][1])
225+ if newID == 0 || newID == oldID {
226+ t.Fatalf("identities before=%d after=%d", oldID, newID)
227+ }
228+ if len(pref.values) == 0 || uint64(pref.values[len(pref.values)-1][0])<<32|uint64(pref.values[len(pref.values)-1][1]) != newID {
229+ t.Fatalf("preferred identity mismatch: %v, ready=%d", pref.values, newID)
230+ }
231+ info2 := c.AllocateID()
232+ requestProtocol(t, c, desc2, cm.WpImageDescriptionV1RequestGetInformation, info2)
233+ hdr := watchColor(c, info2)
234+ if err := c.Roundtrip(); err != nil {
235+ t.Fatal(err)
236+ }
237+ if len(hdr.values) < 8 || hdr.values[1][0] != bt2020 || hdr.values[3][0] != 50 || hdr.values[5][0] != 50 || hdr.values[5][1] != 1000 || hdr.values[6][0] != 1000 || hdr.values[7][0] != 400 {
238+ t.Fatalf("HDR info %v %v", hdr.events, hdr.values)
239+ }
240+}
241+func TestColorParametricCombinations(t *testing.T) {
242+ for _, tc := range []struct {
243+ name string
244+ prim, tf uint32
245+ ready bool
246+ }{
247+ {"sRGB gamma22", srgb, gamma22, false},
248+ {"sRGB sRGB", srgb, uint32(cm.WpColorManagerV1TransferFunctionSrgb), true},
249+ {"BT2020 PQ", bt2020, pq, true},
250+ {"sRGB extended linear", srgb, extLinear, true},
251+ {"BT2020 extended linear", bt2020, extLinear, false},
252+ {"sRGB PQ", srgb, pq, false},
253+ {"BT2020 gamma22", bt2020, gamma22, false},
254+ } {
255+ t.Run(tc.name, func(t *testing.T) {
256+ _, c, _ := colorServer(t)
257+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
258+ registerProtocol(t, c, manager)
259+ creator := c.AllocateID()
260+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
261+ registerProtocol(t, c, creator)
262+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, tc.prim)
263+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, tc.tf)
264+ image := c.AllocateID()
265+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestCreate, image)
266+ ev := watchColor(c, image)
267+ if err := c.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ if len(ev.events) != 1 {
271+ t.Fatalf("events %v", ev.events)
272+ }
273+ if tc.ready {
274+ if uint32(ev.events[0]) != cm.WpImageDescriptionV1EventReady2 {
275+ t.Fatalf("events %v", ev.events)
276+ }
277+ } else if uint32(ev.events[0]) != cm.WpImageDescriptionV1EventFailed || ev.values[0][0] != uint32(cm.WpImageDescriptionV1CauseUnsupported) {
278+ t.Fatalf("failure events %v values %v", ev.events, ev.values)
279+ }
280+ })
281+ }
282+}
283+
284+func TestColorProtocolErrors(t *testing.T) {
285+ for _, tc := range []struct {
286+ name string
287+ op uint32
288+ args []any
289+ code uint32
290+ complete bool
291+ }{
292+ {"incomplete", cm.WpImageDescriptionCreatorParamsV1RequestCreate, []any{uint32(90)}, 0, false},
293+ {"invalid tf", cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, []any{uint32(99)}, 3, false},
294+ {"invalid primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, []any{uint32(99)}, 4, false},
295+ {"unsupported power", cm.WpImageDescriptionCreatorParamsV1RequestSetTfPower, []any{uint32(22000)}, 2, false},
296+ {"unsupported primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimaries, []any{int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1)}, 2, false},
297+ {"unsupported luminances", cm.WpImageDescriptionCreatorParamsV1RequestSetLuminances, []any{uint32(1), uint32(100), uint32(100)}, 2, false},
298+ {"unsupported mastering primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetMasteringDisplayPrimaries, []any{int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1)}, 2, false},
299+ {"unsupported mastering luminance", cm.WpImageDescriptionCreatorParamsV1RequestSetMasteringLuminance, []any{uint32(1), uint32(100)}, 2, false},
300+ {"already tf", cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, []any{pq}, 1, true},
301+ {"already primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, []any{bt2020}, 1, true},
302+ {"max fall", cm.WpImageDescriptionCreatorParamsV1RequestCreate, []any{uint32(90)}, 5, true},
303+ } {
304+ t.Run(tc.name, func(t *testing.T) {
305+ _, c, _ := colorServer(t)
306+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
307+ registerProtocol(t, c, manager)
308+ creator := c.AllocateID()
309+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
310+ registerProtocol(t, c, creator)
311+ if tc.complete {
312+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, pq)
313+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, bt2020)
314+ if tc.name == "max fall" {
315+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxCll, uint32(100))
316+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxFall, uint32(200))
317+ }
318+ }
319+ args := tc.args
320+ if tc.op == cm.WpImageDescriptionCreatorParamsV1RequestCreate {
321+ args = []any{c.AllocateID()}
322+ }
323+ requestProtocol(t, c, creator, tc.op, args...)
324+ expectProtocolError(t, c, creator, tc.code)
325+ })
326+ }
327+}
@@ -0,0 +1,327 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/logging"
8+ "github.com/bnema/neferwl/internal/ports"
9+ cm "github.com/bnema/purego-libwayland/protocol/colormanagement"
10+ "github.com/bnema/purego-libwayland/protocol/wayland"
11+ "github.com/bnema/wlturbo"
12+)
13+
14+type colorEvent struct {
15+ wlturbo.BaseProxy
16+ events []uint16
17+ values [][]uint32
18+}
19+
20+func (p *colorEvent) Dispatch(e *wlturbo.Event) {
21+ p.events = append(p.events, e.Opcode)
22+ var vals []uint32
23+ for i := 0; i < 12; i++ {
24+ if len(e.Data())-e.Offset() < 4 {
25+ break
26+ }
27+ vals = append(vals, e.Uint32())
28+ }
29+ p.values = append(p.values, vals)
30+}
31+func watchColor(c *wlturbo.Display, id uint32) *colorEvent {
32+ p := &colorEvent{}
33+ p.SetID(id)
34+ c.Context().Register(p)
35+ return p
36+}
37+func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) {
38+ t.Helper()
39+ dir := t.TempDir()
40+ formats := make(chan ports.OutputFormats, 8)
41+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 8, Height: 8}, Width: 8, Height: 8, Scale: 1}}}, Channels{OutputFormats: formats}, logging.For(context.Background(), "wayland"))
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ ctx, cancel := context.WithCancel(context.Background())
46+ done := make(chan error, 1)
47+ go func() { done <- s.Run(ctx) }()
48+ t.Cleanup(func() {
49+ cancel()
50+ if err := <-done; err != nil {
51+ t.Error(err)
52+ }
53+ })
54+ return s, protocolClient(t, s, dir), formats
55+}
56+func TestColorMesaFlow(t *testing.T) {
57+ s, c, _ := colorServer(t)
58+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
59+ caps := watchColor(c, manager)
60+ if err := c.Roundtrip(); err != nil {
61+ t.Fatal(err)
62+ }
63+ var features, tf, prim []uint32
64+ for i, op := range caps.events {
65+ switch uint32(op) {
66+ case cm.WpColorManagerV1EventSupportedFeature:
67+ features = append(features, caps.values[i][0])
68+ case cm.WpColorManagerV1EventSupportedTfNamed:
69+ tf = append(tf, caps.values[i][0])
70+ case cm.WpColorManagerV1EventSupportedPrimariesNamed:
71+ prim = append(prim, caps.values[i][0])
72+ }
73+ }
74+ if len(features) != 1 || features[0] != uint32(cm.WpColorManagerV1FeatureParametric) || len(tf) != 3 || tf[0] != pq || tf[1] != extLinear || tf[2] != uint32(cm.WpColorManagerV1TransferFunctionSrgb) || len(prim) != 2 || uint32(caps.events[len(caps.events)-1]) != cm.WpColorManagerV1EventDone {
75+ t.Fatalf("capabilities %v %v %v %v", features, tf, prim, caps.events)
76+ }
77+ comp := bindProtocol(t, c, "wl_compositor")
78+ wl := c.AllocateID()
79+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
80+ registerProtocol(t, c, wl)
81+ surfaceID := c.AllocateID()
82+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurface, surfaceID, wl)
83+ registerProtocol(t, c, surfaceID)
84+ creator := c.AllocateID()
85+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
86+ registerProtocol(t, c, creator)
87+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, bt2020)
88+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, pq)
89+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxCll, uint32(1000))
90+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxFall, uint32(400))
91+ image := c.AllocateID()
92+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestCreate, image)
93+ ready := watchColor(c, image)
94+ if err := c.Roundtrip(); err != nil {
95+ t.Fatal(err)
96+ }
97+ if len(ready.events) != 1 || uint32(ready.events[0]) != cm.WpImageDescriptionV1EventReady2 {
98+ t.Fatalf("ready %v", ready.events)
99+ }
100+ requestProtocol(t, c, surfaceID, cm.WpColorManagementSurfaceV1RequestSetImageDescription, image, uint32(0))
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ var before SurfaceColor
105+ s.display.Do(func() { before = s.surfaceColor(wl) })
106+ if before.Set {
107+ t.Fatal("color applied before commit")
108+ }
109+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
110+ if err := c.Roundtrip(); err != nil {
111+ t.Fatal(err)
112+ }
113+ s.display.Do(func() { before = s.surfaceColor(wl) })
114+ if before != (SurfaceColor{true, pq, bt2020, 1000, 400}) {
115+ t.Fatalf("committed color %+v", before)
116+ }
117+ var routed ports.SurfaceColor
118+ s.display.Do(func() {
119+ for res, surf := range s.surfaces {
120+ if res.ID() == wl {
121+ routed = surf.tree(1).Color
122+ }
123+ }
124+ })
125+ if routed != (ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020, MaxCLL: 1000, MaxFALL: 400}) {
126+ t.Fatalf("routed color %+v", routed)
127+ }
128+ requestProtocol(t, c, surfaceID, cm.WpColorManagementSurfaceV1RequestUnsetImageDescription)
129+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
130+ if err := c.Roundtrip(); err != nil {
131+ t.Fatal(err)
132+ }
133+ s.display.Do(func() { before = s.surfaceColor(wl) })
134+ if before.Set {
135+ t.Fatal("unset not committed")
136+ }
137+}
138+func (s *Server) surfaceColor(id uint32) SurfaceColor {
139+ for r, v := range s.surfaces {
140+ if r.ID() == id {
141+ return v.ColorDescription()
142+ }
143+ }
144+ return SurfaceColor{}
145+}
146+func TestColorOutputAndFeedback(t *testing.T) {
147+ s, c, _ := colorServer(t)
148+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
149+ registerProtocol(t, c, manager)
150+ var name uint32
151+ for id, g := range c.Registry().GetGlobals() {
152+ if g.Interface == "wl_output" {
153+ name = id
154+ }
155+ }
156+ out, err := c.Registry().BindID(name, "wl_output", 4)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ registerProtocol(t, c, out)
161+ obj := c.AllocateID()
162+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetOutput, obj, out)
163+ changed := watchColor(c, obj)
164+ desc := c.AllocateID()
165+ requestProtocol(t, c, obj, cm.WpColorManagementOutputV1RequestGetImageDescription, desc)
166+ ready := watchColor(c, desc)
167+ if err := c.Roundtrip(); err != nil {
168+ t.Fatal(err)
169+ }
170+ if len(ready.events) != 1 || uint32(ready.events[0]) != cm.WpImageDescriptionV1EventReady2 {
171+ t.Fatalf("output ready %v", ready.events)
172+ }
173+ oldID := uint64(ready.values[0][0])<<32 | uint64(ready.values[0][1])
174+ if oldID == 0 {
175+ t.Fatal("zero output identity")
176+ }
177+ info := c.AllocateID()
178+ requestProtocol(t, c, desc, cm.WpImageDescriptionV1RequestGetInformation, info)
179+ ev := watchColor(c, info)
180+ if err := c.Roundtrip(); err != nil {
181+ t.Fatal(err)
182+ }
183+ if len(ev.events) < 5 || ev.values[1][0] != srgb || ev.values[2][0] != uint32(cm.WpColorManagerV1TransferFunctionSrgb) {
184+ t.Fatalf("SDR info %v %v", ev.events, ev.values)
185+ }
186+ comp := bindProtocol(t, c, "wl_compositor")
187+ wl := c.AllocateID()
188+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
189+ registerProtocol(t, c, wl)
190+ feedback := c.AllocateID()
191+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestGetSurfaceFeedback, feedback, wl)
192+ pref := watchColor(c, feedback)
193+ if err := c.Roundtrip(); err != nil {
194+ t.Fatal(err)
195+ }
196+ preferredID := c.AllocateID()
197+ requestProtocol(t, c, feedback, cm.WpColorManagementSurfaceFeedbackV1RequestGetPreferredParametric, preferredID)
198+ preferred := watchColor(c, preferredID)
199+ if err := c.Roundtrip(); err != nil {
200+ t.Fatal(err)
201+ }
202+ if len(preferred.events) != 1 || uint32(preferred.events[0]) != cm.WpImageDescriptionV1EventReady2 {
203+ t.Fatalf("preferred %v", preferred.events)
204+ }
205+ s.display.Do(func() {
206+ s.setOutputHDR(ports.OutputFormats{Output: "HEADLESS-1", HDR: &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}})
207+ })
208+ if err := c.Roundtrip(); err != nil {
209+ t.Fatal(err)
210+ }
211+ // The format report is asynchronous to client requests.
212+ if err := c.Roundtrip(); err != nil {
213+ t.Fatal(err)
214+ }
215+ if len(changed.events) == 0 || len(pref.events) == 0 {
216+ t.Fatalf("change events output=%v preferred=%v", changed.events, pref.events)
217+ }
218+ desc2 := c.AllocateID()
219+ requestProtocol(t, c, obj, cm.WpColorManagementOutputV1RequestGetImageDescription, desc2)
220+ ready2 := watchColor(c, desc2)
221+ if err := c.Roundtrip(); err != nil {
222+ t.Fatal(err)
223+ }
224+ newID := uint64(ready2.values[0][0])<<32 | uint64(ready2.values[0][1])
225+ if newID == 0 || newID == oldID {
226+ t.Fatalf("identities before=%d after=%d", oldID, newID)
227+ }
228+ if len(pref.values) == 0 || uint64(pref.values[len(pref.values)-1][0])<<32|uint64(pref.values[len(pref.values)-1][1]) != newID {
229+ t.Fatalf("preferred identity mismatch: %v, ready=%d", pref.values, newID)
230+ }
231+ info2 := c.AllocateID()
232+ requestProtocol(t, c, desc2, cm.WpImageDescriptionV1RequestGetInformation, info2)
233+ hdr := watchColor(c, info2)
234+ if err := c.Roundtrip(); err != nil {
235+ t.Fatal(err)
236+ }
237+ if len(hdr.values) < 8 || hdr.values[1][0] != bt2020 || hdr.values[3][0] != 50 || hdr.values[5][0] != 50 || hdr.values[5][1] != 1000 || hdr.values[6][0] != 1000 || hdr.values[7][0] != 400 {
238+ t.Fatalf("HDR info %v %v", hdr.events, hdr.values)
239+ }
240+}
241+func TestColorParametricCombinations(t *testing.T) {
242+ for _, tc := range []struct {
243+ name string
244+ prim, tf uint32
245+ ready bool
246+ }{
247+ {"sRGB gamma22", srgb, gamma22, false},
248+ {"sRGB sRGB", srgb, uint32(cm.WpColorManagerV1TransferFunctionSrgb), true},
249+ {"BT2020 PQ", bt2020, pq, true},
250+ {"sRGB extended linear", srgb, extLinear, true},
251+ {"BT2020 extended linear", bt2020, extLinear, false},
252+ {"sRGB PQ", srgb, pq, false},
253+ {"BT2020 gamma22", bt2020, gamma22, false},
254+ } {
255+ t.Run(tc.name, func(t *testing.T) {
256+ _, c, _ := colorServer(t)
257+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
258+ registerProtocol(t, c, manager)
259+ creator := c.AllocateID()
260+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
261+ registerProtocol(t, c, creator)
262+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, tc.prim)
263+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, tc.tf)
264+ image := c.AllocateID()
265+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestCreate, image)
266+ ev := watchColor(c, image)
267+ if err := c.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ if len(ev.events) != 1 {
271+ t.Fatalf("events %v", ev.events)
272+ }
273+ if tc.ready {
274+ if uint32(ev.events[0]) != cm.WpImageDescriptionV1EventReady2 {
275+ t.Fatalf("events %v", ev.events)
276+ }
277+ } else if uint32(ev.events[0]) != cm.WpImageDescriptionV1EventFailed || ev.values[0][0] != uint32(cm.WpImageDescriptionV1CauseUnsupported) {
278+ t.Fatalf("failure events %v values %v", ev.events, ev.values)
279+ }
280+ })
281+ }
282+}
283+
284+func TestColorProtocolErrors(t *testing.T) {
285+ for _, tc := range []struct {
286+ name string
287+ op uint32
288+ args []any
289+ code uint32
290+ complete bool
291+ }{
292+ {"incomplete", cm.WpImageDescriptionCreatorParamsV1RequestCreate, []any{uint32(90)}, 0, false},
293+ {"invalid tf", cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, []any{uint32(99)}, 3, false},
294+ {"invalid primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, []any{uint32(99)}, 4, false},
295+ {"unsupported power", cm.WpImageDescriptionCreatorParamsV1RequestSetTfPower, []any{uint32(22000)}, 2, false},
296+ {"unsupported primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimaries, []any{int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1)}, 2, false},
297+ {"unsupported luminances", cm.WpImageDescriptionCreatorParamsV1RequestSetLuminances, []any{uint32(1), uint32(100), uint32(100)}, 2, false},
298+ {"unsupported mastering primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetMasteringDisplayPrimaries, []any{int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1), int32(1)}, 2, false},
299+ {"unsupported mastering luminance", cm.WpImageDescriptionCreatorParamsV1RequestSetMasteringLuminance, []any{uint32(1), uint32(100)}, 2, false},
300+ {"already tf", cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, []any{pq}, 1, true},
301+ {"already primaries", cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, []any{bt2020}, 1, true},
302+ {"max fall", cm.WpImageDescriptionCreatorParamsV1RequestCreate, []any{uint32(90)}, 5, true},
303+ } {
304+ t.Run(tc.name, func(t *testing.T) {
305+ _, c, _ := colorServer(t)
306+ manager := bindVersion(t, c, "wp_color_manager_v1", 2)
307+ registerProtocol(t, c, manager)
308+ creator := c.AllocateID()
309+ requestProtocol(t, c, manager, cm.WpColorManagerV1RequestCreateParametricCreator, creator)
310+ registerProtocol(t, c, creator)
311+ if tc.complete {
312+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetTfNamed, pq)
313+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetPrimariesNamed, bt2020)
314+ if tc.name == "max fall" {
315+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxCll, uint32(100))
316+ requestProtocol(t, c, creator, cm.WpImageDescriptionCreatorParamsV1RequestSetMaxFall, uint32(200))
317+ }
318+ }
319+ args := tc.args
320+ if tc.op == cm.WpImageDescriptionCreatorParamsV1RequestCreate {
321+ args = []any{c.AllocateID()}
322+ }
323+ requestProtocol(t, c, creator, tc.op, args...)
324+ expectProtocolError(t, c, creator, tc.code)
325+ })
326+ }
327+}
A internal/adapters/wayland/colorrepresentation.go +143 -0
@@ -0,0 +1,143 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// The absent YUV representation defaults to BT.709 limited, type 0 chroma.
10+// RGB without a representation retains premultiplied electrical samples.
11+type surfaceRepresentation struct{ coefficients, rangeValue, chroma, alpha uint8 }
12+
13+const (
14+ coefficientIdentity = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsIdentity)
15+ coefficient709 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt709)
16+ coefficient601 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt601)
17+ coefficient2020 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt2020)
18+ rangeFull = uint8(colorrepresentation.WpColorRepresentationSurfaceV1RangeFull)
19+ rangeLimited = uint8(colorrepresentation.WpColorRepresentationSurfaceV1RangeLimited)
20+)
21+
22+func isYUV(format uint32) bool { return format == fourccNV12 || format == fourccP010 }
23+
24+func registerColorRepresentation(d *server.Display, s *Server) error {
25+ return colorrepresentation.NewWpColorRepresentationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
26+ r, err := colorrepresentation.NewWpColorRepresentationManagerV1(c, int32(v), id, representationManager{s})
27+ if err != nil {
28+ return
29+ }
30+ r.SendSupportedAlphaMode(uint32(colorrepresentation.WpColorRepresentationSurfaceV1AlphaModePremultipliedElectrical))
31+ r.SendSupportedCoefficientsAndRanges(uint32(coefficientIdentity), uint32(rangeFull))
32+ for _, coeff := range []uint8{coefficient601, coefficient709, coefficient2020} {
33+ for _, ran := range []uint8{rangeLimited, rangeFull} {
34+ r.SendSupportedCoefficientsAndRanges(uint32(coeff), uint32(ran))
35+ }
36+ }
37+ r.SendDone()
38+ })
39+}
40+
41+type representationManager struct{ server *Server }
42+
43+func (representationManager) Destroy(*colorrepresentation.WpColorRepresentationManagerV1) {}
44+func (m representationManager) GetSurface(r *colorrepresentation.WpColorRepresentationManagerV1, id uint32, wl *wayland.Surface) {
45+ surf := m.server.surfaceOf(wl)
46+ if surf != nil && surf.representationControl != nil {
47+ r.PostError(uint32(colorrepresentation.WpColorRepresentationManagerV1ErrorSurfaceExists), "surface already has color representation")
48+ return
49+ }
50+ h := &representationSurface{surf: surf}
51+ res, err := colorrepresentation.NewWpColorRepresentationSurfaceV1(r.Client(), r.Version(), id, h)
52+ if err != nil {
53+ return
54+ }
55+ h.res = res
56+ if surf != nil {
57+ surf.representationControl = h
58+ res.OnDestroy = func() {
59+ if surf.representationControl == h {
60+ surf.representationControl = nil
61+ surf.pendingRepresentation = surfaceRepresentation{}
62+ }
63+ }
64+ }
65+}
66+
67+type representationSurface struct {
68+ surf *surface
69+ res *colorrepresentation.WpColorRepresentationSurfaceV1
70+}
71+
72+func (h *representationSurface) Destroy(*colorrepresentation.WpColorRepresentationSurfaceV1) {
73+ if h.surf != nil && !h.surf.destroyed {
74+ h.surf.pendingRepresentation = surfaceRepresentation{}
75+ }
76+}
77+func (h *representationSurface) live(r *colorrepresentation.WpColorRepresentationSurfaceV1) bool {
78+ if h.surf == nil || h.surf.destroyed {
79+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorInert), "surface destroyed")
80+ return false
81+ }
82+ return true
83+}
84+func (h *representationSurface) SetAlphaMode(r *colorrepresentation.WpColorRepresentationSurfaceV1, mode uint32) {
85+ if !h.live(r) {
86+ return
87+ }
88+ if mode != uint32(colorrepresentation.WpColorRepresentationSurfaceV1AlphaModePremultipliedElectrical) {
89+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorAlphaMode), "unsupported alpha mode")
90+ return
91+ }
92+ h.surf.pendingRepresentation.alpha = uint8(mode)
93+}
94+func (h *representationSurface) SetCoefficientsAndRange(r *colorrepresentation.WpColorRepresentationSurfaceV1, coeff, ran uint32) {
95+ if !h.live(r) {
96+ return
97+ }
98+ valid := coeff == uint32(coefficientIdentity) && ran == uint32(rangeFull) ||
99+ (coeff == uint32(coefficient601) || coeff == uint32(coefficient709) || coeff == uint32(coefficient2020)) && (ran == uint32(rangeFull) || ran == uint32(rangeLimited))
100+ if !valid {
101+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorCoefficients), "unsupported coefficients or range")
102+ return
103+ }
104+ h.surf.pendingRepresentation.coefficients, h.surf.pendingRepresentation.rangeValue = uint8(coeff), uint8(ran)
105+}
106+func (h *representationSurface) SetChromaLocation(r *colorrepresentation.WpColorRepresentationSurfaceV1, chroma uint32) {
107+ if !h.live(r) {
108+ return
109+ }
110+ if chroma != uint32(colorrepresentation.WpColorRepresentationSurfaceV1ChromaLocationType0) {
111+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorChromaLocation), "unsupported chroma location")
112+ return
113+ }
114+ h.surf.pendingRepresentation.chroma = uint8(chroma)
115+}
116+
117+// The metadata is checked against the committed buffer, including when a
118+// client changes representation without attaching a new buffer.
119+func (s *surface) checkRepresentationCommit() bool {
120+ rep := s.pendingRepresentation
121+ if rep.coefficients == 0 && rep.chroma == 0 {
122+ return true
123+ }
124+ buffer := s.current
125+ if s.attached {
126+ buffer = s.pending
127+ }
128+ if buffer == nil {
129+ return true
130+ }
131+ b, ok := s.server.buffers[buffer.Resource]
132+ if !ok {
133+ return true
134+ }
135+ d, ok := b.(*dmabufBuffer)
136+ yuv := ok && isYUV(d.buf.Format)
137+ bad := rep.coefficients != 0 && (rep.coefficients == coefficientIdentity) == yuv || rep.chroma != 0 && !yuv
138+ if bad && s.representationControl != nil && s.representationControl.res != nil {
139+ s.representationControl.res.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorPixelFormat), "color representation incompatible with pixel format")
140+ return false
141+ }
142+ return true
143+}
@@ -0,0 +1,143 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// The absent YUV representation defaults to BT.709 limited, type 0 chroma.
10+// RGB without a representation retains premultiplied electrical samples.
11+type surfaceRepresentation struct{ coefficients, rangeValue, chroma, alpha uint8 }
12+
13+const (
14+ coefficientIdentity = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsIdentity)
15+ coefficient709 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt709)
16+ coefficient601 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt601)
17+ coefficient2020 = uint8(colorrepresentation.WpColorRepresentationSurfaceV1CoefficientsBt2020)
18+ rangeFull = uint8(colorrepresentation.WpColorRepresentationSurfaceV1RangeFull)
19+ rangeLimited = uint8(colorrepresentation.WpColorRepresentationSurfaceV1RangeLimited)
20+)
21+
22+func isYUV(format uint32) bool { return format == fourccNV12 || format == fourccP010 }
23+
24+func registerColorRepresentation(d *server.Display, s *Server) error {
25+ return colorrepresentation.NewWpColorRepresentationManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
26+ r, err := colorrepresentation.NewWpColorRepresentationManagerV1(c, int32(v), id, representationManager{s})
27+ if err != nil {
28+ return
29+ }
30+ r.SendSupportedAlphaMode(uint32(colorrepresentation.WpColorRepresentationSurfaceV1AlphaModePremultipliedElectrical))
31+ r.SendSupportedCoefficientsAndRanges(uint32(coefficientIdentity), uint32(rangeFull))
32+ for _, coeff := range []uint8{coefficient601, coefficient709, coefficient2020} {
33+ for _, ran := range []uint8{rangeLimited, rangeFull} {
34+ r.SendSupportedCoefficientsAndRanges(uint32(coeff), uint32(ran))
35+ }
36+ }
37+ r.SendDone()
38+ })
39+}
40+
41+type representationManager struct{ server *Server }
42+
43+func (representationManager) Destroy(*colorrepresentation.WpColorRepresentationManagerV1) {}
44+func (m representationManager) GetSurface(r *colorrepresentation.WpColorRepresentationManagerV1, id uint32, wl *wayland.Surface) {
45+ surf := m.server.surfaceOf(wl)
46+ if surf != nil && surf.representationControl != nil {
47+ r.PostError(uint32(colorrepresentation.WpColorRepresentationManagerV1ErrorSurfaceExists), "surface already has color representation")
48+ return
49+ }
50+ h := &representationSurface{surf: surf}
51+ res, err := colorrepresentation.NewWpColorRepresentationSurfaceV1(r.Client(), r.Version(), id, h)
52+ if err != nil {
53+ return
54+ }
55+ h.res = res
56+ if surf != nil {
57+ surf.representationControl = h
58+ res.OnDestroy = func() {
59+ if surf.representationControl == h {
60+ surf.representationControl = nil
61+ surf.pendingRepresentation = surfaceRepresentation{}
62+ }
63+ }
64+ }
65+}
66+
67+type representationSurface struct {
68+ surf *surface
69+ res *colorrepresentation.WpColorRepresentationSurfaceV1
70+}
71+
72+func (h *representationSurface) Destroy(*colorrepresentation.WpColorRepresentationSurfaceV1) {
73+ if h.surf != nil && !h.surf.destroyed {
74+ h.surf.pendingRepresentation = surfaceRepresentation{}
75+ }
76+}
77+func (h *representationSurface) live(r *colorrepresentation.WpColorRepresentationSurfaceV1) bool {
78+ if h.surf == nil || h.surf.destroyed {
79+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorInert), "surface destroyed")
80+ return false
81+ }
82+ return true
83+}
84+func (h *representationSurface) SetAlphaMode(r *colorrepresentation.WpColorRepresentationSurfaceV1, mode uint32) {
85+ if !h.live(r) {
86+ return
87+ }
88+ if mode != uint32(colorrepresentation.WpColorRepresentationSurfaceV1AlphaModePremultipliedElectrical) {
89+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorAlphaMode), "unsupported alpha mode")
90+ return
91+ }
92+ h.surf.pendingRepresentation.alpha = uint8(mode)
93+}
94+func (h *representationSurface) SetCoefficientsAndRange(r *colorrepresentation.WpColorRepresentationSurfaceV1, coeff, ran uint32) {
95+ if !h.live(r) {
96+ return
97+ }
98+ valid := coeff == uint32(coefficientIdentity) && ran == uint32(rangeFull) ||
99+ (coeff == uint32(coefficient601) || coeff == uint32(coefficient709) || coeff == uint32(coefficient2020)) && (ran == uint32(rangeFull) || ran == uint32(rangeLimited))
100+ if !valid {
101+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorCoefficients), "unsupported coefficients or range")
102+ return
103+ }
104+ h.surf.pendingRepresentation.coefficients, h.surf.pendingRepresentation.rangeValue = uint8(coeff), uint8(ran)
105+}
106+func (h *representationSurface) SetChromaLocation(r *colorrepresentation.WpColorRepresentationSurfaceV1, chroma uint32) {
107+ if !h.live(r) {
108+ return
109+ }
110+ if chroma != uint32(colorrepresentation.WpColorRepresentationSurfaceV1ChromaLocationType0) {
111+ r.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorChromaLocation), "unsupported chroma location")
112+ return
113+ }
114+ h.surf.pendingRepresentation.chroma = uint8(chroma)
115+}
116+
117+// The metadata is checked against the committed buffer, including when a
118+// client changes representation without attaching a new buffer.
119+func (s *surface) checkRepresentationCommit() bool {
120+ rep := s.pendingRepresentation
121+ if rep.coefficients == 0 && rep.chroma == 0 {
122+ return true
123+ }
124+ buffer := s.current
125+ if s.attached {
126+ buffer = s.pending
127+ }
128+ if buffer == nil {
129+ return true
130+ }
131+ b, ok := s.server.buffers[buffer.Resource]
132+ if !ok {
133+ return true
134+ }
135+ d, ok := b.(*dmabufBuffer)
136+ yuv := ok && isYUV(d.buf.Format)
137+ bad := rep.coefficients != 0 && (rep.coefficients == coefficientIdentity) == yuv || rep.chroma != 0 && !yuv
138+ if bad && s.representationControl != nil && s.representationControl.res != nil {
139+ s.representationControl.res.PostError(uint32(colorrepresentation.WpColorRepresentationSurfaceV1ErrorPixelFormat), "color representation incompatible with pixel format")
140+ return false
141+ }
142+ return true
143+}
A internal/adapters/wayland/colorrepresentation_test.go +169 -0
@@ -0,0 +1,169 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ cr "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+)
9+
10+func TestColorRepresentationProtocol(t *testing.T) {
11+ s, c, _ := colorServer(t)
12+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
13+ events := watchColor(c, manager)
14+ if err := c.Roundtrip(); err != nil {
15+ t.Fatal(err)
16+ }
17+ if len(events.events) != 9 || uint32(events.events[len(events.events)-1]) != cr.WpColorRepresentationManagerV1EventDone {
18+ t.Fatalf("capabilities %v", events.events)
19+ }
20+ comp := bindProtocol(t, c, "wl_compositor")
21+ wl := c.AllocateID()
22+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
23+ registerProtocol(t, c, wl)
24+ obj := c.AllocateID()
25+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
26+ registerProtocol(t, c, obj)
27+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient2020), uint32(rangeLimited))
28+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetChromaLocation, uint32(cr.WpColorRepresentationSurfaceV1ChromaLocationType0))
29+ if err := c.Roundtrip(); err != nil {
30+ t.Fatal(err)
31+ }
32+ var before, after surfaceRepresentation
33+ s.display.Do(func() {
34+ for res, surf := range s.surfaces {
35+ if res.ID() == wl {
36+ before = surf.representation
37+ }
38+ }
39+ })
40+ if before != (surfaceRepresentation{}) {
41+ t.Fatalf("applied before commit: %+v", before)
42+ }
43+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
44+ if err := c.Roundtrip(); err != nil {
45+ t.Fatal(err)
46+ }
47+ s.display.Do(func() {
48+ for res, surf := range s.surfaces {
49+ if res.ID() == wl {
50+ after = surf.representation
51+ }
52+ }
53+ })
54+ if after.coefficients != coefficient2020 || after.rangeValue != rangeLimited || after.chroma != 1 {
55+ t.Fatalf("commit %+v", after)
56+ }
57+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestDestroy)
58+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
59+ if err := c.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ s.display.Do(func() {
63+ for res, surf := range s.surfaces {
64+ if res.ID() == wl {
65+ after = surf.representation
66+ }
67+ }
68+ })
69+ if after != (surfaceRepresentation{}) {
70+ t.Fatalf("destroy %+v", after)
71+ }
72+}
73+
74+func TestColorRepresentationErrors(t *testing.T) {
75+ for _, tc := range []struct {
76+ name string
77+ op uint32
78+ args []any
79+ code uint32
80+ }{
81+ {"alpha", cr.WpColorRepresentationSurfaceV1RequestSetAlphaMode, []any{uint32(2)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorAlphaMode)},
82+ {"coefficients", cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, []any{uint32(8), uint32(1)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorCoefficients)},
83+ {"range", cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, []any{uint32(coefficient2020), uint32(3)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorCoefficients)},
84+ {"chroma", cr.WpColorRepresentationSurfaceV1RequestSetChromaLocation, []any{uint32(2)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorChromaLocation)},
85+ } {
86+ t.Run(tc.name, func(t *testing.T) {
87+ _, c, _ := colorServer(t)
88+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
89+ registerProtocol(t, c, manager)
90+ comp := bindProtocol(t, c, "wl_compositor")
91+ wl := c.AllocateID()
92+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
93+ registerProtocol(t, c, wl)
94+ obj := c.AllocateID()
95+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
96+ registerProtocol(t, c, obj)
97+ requestProtocol(t, c, obj, tc.op, tc.args...)
98+ expectProtocolError(t, c, obj, tc.code)
99+ })
100+ }
101+}
102+
103+// A queued commit snapshots representation; later requests must not rewrite it.
104+func TestColorRepresentationQueuedCommit(t *testing.T) {
105+ s, c, _ := colorServer(t)
106+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
107+ registerProtocol(t, c, manager)
108+ comp := bindProtocol(t, c, "wl_compositor")
109+ registerProtocol(t, c, comp)
110+ wl := c.AllocateID()
111+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
112+ registerProtocol(t, c, wl)
113+ obj := c.AllocateID()
114+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
115+ registerProtocol(t, c, obj)
116+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient709), uint32(rangeLimited))
117+ if err := c.Roundtrip(); err != nil {
118+ t.Fatal(err)
119+ }
120+ s.display.Do(func() {
121+ for res, surf := range s.surfaces {
122+ if res.ID() == wl {
123+ surf.queueUpdate()
124+ }
125+ }
126+ })
127+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient2020), uint32(rangeFull))
128+ if err := c.Roundtrip(); err != nil {
129+ t.Fatal(err)
130+ }
131+ s.display.Do(func() {
132+ for res, surf := range s.surfaces {
133+ if res.ID() != wl {
134+ continue
135+ }
136+ // The pacer may have already applied the queued update.
137+ if len(surf.queue) == 1 {
138+ if surf.queue[0].representation.coefficients != coefficient709 {
139+ t.Errorf("queued representation: %+v", surf.queue[0].representation)
140+ }
141+ u := surf.queue[0]
142+ surf.queue = nil
143+ surf.applyUpdate(u)
144+ }
145+ if surf.representation.coefficients != coefficient709 || surf.pendingRepresentation.coefficients != coefficient2020 {
146+ t.Errorf("applied=%+v pending=%+v", surf.representation, surf.pendingRepresentation)
147+ }
148+ }
149+ })
150+}
151+
152+func TestColorRepresentationRejectsRGBWithYUVCoefficients(t *testing.T) {
153+ _, c, _ := colorServer(t)
154+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
155+ registerProtocol(t, c, manager)
156+ comp := bindProtocol(t, c, "wl_compositor")
157+ registerProtocol(t, c, comp)
158+ wl := c.AllocateID()
159+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
160+ registerProtocol(t, c, wl)
161+ obj := c.AllocateID()
162+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
163+ registerProtocol(t, c, obj)
164+ buf := shmBuffer(t, c)
165+ requestProtocol(t, c, wl, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
166+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient709), uint32(rangeLimited))
167+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
168+ expectProtocolError(t, c, obj, uint32(cr.WpColorRepresentationSurfaceV1ErrorPixelFormat))
169+}
@@ -0,0 +1,169 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ cr "github.com/bnema/purego-libwayland/protocol/colorrepresentation"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+)
9+
10+func TestColorRepresentationProtocol(t *testing.T) {
11+ s, c, _ := colorServer(t)
12+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
13+ events := watchColor(c, manager)
14+ if err := c.Roundtrip(); err != nil {
15+ t.Fatal(err)
16+ }
17+ if len(events.events) != 9 || uint32(events.events[len(events.events)-1]) != cr.WpColorRepresentationManagerV1EventDone {
18+ t.Fatalf("capabilities %v", events.events)
19+ }
20+ comp := bindProtocol(t, c, "wl_compositor")
21+ wl := c.AllocateID()
22+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
23+ registerProtocol(t, c, wl)
24+ obj := c.AllocateID()
25+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
26+ registerProtocol(t, c, obj)
27+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient2020), uint32(rangeLimited))
28+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetChromaLocation, uint32(cr.WpColorRepresentationSurfaceV1ChromaLocationType0))
29+ if err := c.Roundtrip(); err != nil {
30+ t.Fatal(err)
31+ }
32+ var before, after surfaceRepresentation
33+ s.display.Do(func() {
34+ for res, surf := range s.surfaces {
35+ if res.ID() == wl {
36+ before = surf.representation
37+ }
38+ }
39+ })
40+ if before != (surfaceRepresentation{}) {
41+ t.Fatalf("applied before commit: %+v", before)
42+ }
43+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
44+ if err := c.Roundtrip(); err != nil {
45+ t.Fatal(err)
46+ }
47+ s.display.Do(func() {
48+ for res, surf := range s.surfaces {
49+ if res.ID() == wl {
50+ after = surf.representation
51+ }
52+ }
53+ })
54+ if after.coefficients != coefficient2020 || after.rangeValue != rangeLimited || after.chroma != 1 {
55+ t.Fatalf("commit %+v", after)
56+ }
57+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestDestroy)
58+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
59+ if err := c.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ s.display.Do(func() {
63+ for res, surf := range s.surfaces {
64+ if res.ID() == wl {
65+ after = surf.representation
66+ }
67+ }
68+ })
69+ if after != (surfaceRepresentation{}) {
70+ t.Fatalf("destroy %+v", after)
71+ }
72+}
73+
74+func TestColorRepresentationErrors(t *testing.T) {
75+ for _, tc := range []struct {
76+ name string
77+ op uint32
78+ args []any
79+ code uint32
80+ }{
81+ {"alpha", cr.WpColorRepresentationSurfaceV1RequestSetAlphaMode, []any{uint32(2)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorAlphaMode)},
82+ {"coefficients", cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, []any{uint32(8), uint32(1)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorCoefficients)},
83+ {"range", cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, []any{uint32(coefficient2020), uint32(3)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorCoefficients)},
84+ {"chroma", cr.WpColorRepresentationSurfaceV1RequestSetChromaLocation, []any{uint32(2)}, uint32(cr.WpColorRepresentationSurfaceV1ErrorChromaLocation)},
85+ } {
86+ t.Run(tc.name, func(t *testing.T) {
87+ _, c, _ := colorServer(t)
88+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
89+ registerProtocol(t, c, manager)
90+ comp := bindProtocol(t, c, "wl_compositor")
91+ wl := c.AllocateID()
92+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
93+ registerProtocol(t, c, wl)
94+ obj := c.AllocateID()
95+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
96+ registerProtocol(t, c, obj)
97+ requestProtocol(t, c, obj, tc.op, tc.args...)
98+ expectProtocolError(t, c, obj, tc.code)
99+ })
100+ }
101+}
102+
103+// A queued commit snapshots representation; later requests must not rewrite it.
104+func TestColorRepresentationQueuedCommit(t *testing.T) {
105+ s, c, _ := colorServer(t)
106+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
107+ registerProtocol(t, c, manager)
108+ comp := bindProtocol(t, c, "wl_compositor")
109+ registerProtocol(t, c, comp)
110+ wl := c.AllocateID()
111+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
112+ registerProtocol(t, c, wl)
113+ obj := c.AllocateID()
114+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
115+ registerProtocol(t, c, obj)
116+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient709), uint32(rangeLimited))
117+ if err := c.Roundtrip(); err != nil {
118+ t.Fatal(err)
119+ }
120+ s.display.Do(func() {
121+ for res, surf := range s.surfaces {
122+ if res.ID() == wl {
123+ surf.queueUpdate()
124+ }
125+ }
126+ })
127+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient2020), uint32(rangeFull))
128+ if err := c.Roundtrip(); err != nil {
129+ t.Fatal(err)
130+ }
131+ s.display.Do(func() {
132+ for res, surf := range s.surfaces {
133+ if res.ID() != wl {
134+ continue
135+ }
136+ // The pacer may have already applied the queued update.
137+ if len(surf.queue) == 1 {
138+ if surf.queue[0].representation.coefficients != coefficient709 {
139+ t.Errorf("queued representation: %+v", surf.queue[0].representation)
140+ }
141+ u := surf.queue[0]
142+ surf.queue = nil
143+ surf.applyUpdate(u)
144+ }
145+ if surf.representation.coefficients != coefficient709 || surf.pendingRepresentation.coefficients != coefficient2020 {
146+ t.Errorf("applied=%+v pending=%+v", surf.representation, surf.pendingRepresentation)
147+ }
148+ }
149+ })
150+}
151+
152+func TestColorRepresentationRejectsRGBWithYUVCoefficients(t *testing.T) {
153+ _, c, _ := colorServer(t)
154+ manager := bindProtocol(t, c, "wp_color_representation_manager_v1")
155+ registerProtocol(t, c, manager)
156+ comp := bindProtocol(t, c, "wl_compositor")
157+ registerProtocol(t, c, comp)
158+ wl := c.AllocateID()
159+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, wl)
160+ registerProtocol(t, c, wl)
161+ obj := c.AllocateID()
162+ requestProtocol(t, c, manager, cr.WpColorRepresentationManagerV1RequestGetSurface, obj, wl)
163+ registerProtocol(t, c, obj)
164+ buf := shmBuffer(t, c)
165+ requestProtocol(t, c, wl, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
166+ requestProtocol(t, c, obj, cr.WpColorRepresentationSurfaceV1RequestSetCoefficientsAndRange, uint32(coefficient709), uint32(rangeLimited))
167+ requestProtocol(t, c, wl, wayland.SurfaceRequestCommit)
168+ expectProtocolError(t, c, obj, uint32(cr.WpColorRepresentationSurfaceV1ErrorPixelFormat))
169+}
M internal/adapters/wayland/dmabuf.go +42 -10
@@ -270,6 +270,13 @@ func (p *params) anyPlane() bool {
270270 return slices.ContainsFunc(p.planes[:], func(pl *plane) bool { return pl != nil })
271271 }
272272
273+const (
274+ fourccNV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
275+ fourccP010 = 'P' | '0'<<8 | '1'<<16 | '0'<<24
276+ fourccAB4H = 'A' | 'B'<<8 | '4'<<16 | 'H'<<24
277+ fourccXB4H = 'X' | 'B'<<8 | '4'<<16 | 'H'<<24
278+)
279+
273280 // build validates the planes and turns them into a buffer; ok is false after
274281 // a protocol error, failed means the client gets `failed`.
275282 func (p *params) build(width, height int32, format, flags uint32) (buf *dmabufBuffer, failed, ok bool) {
@@ -297,19 +304,42 @@ func (p *params) build(width, height int32, format, flags uint32) (buf *dmabufBu
297304 if flags != 0 || !slices.Contains(p.global.support.Formats, want) {
298305 return nil, true, true
299306 }
300- // Supported formats are single-plane, 4 bytes per pixel.
301- if n != 1 {
302- r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete), "format takes one plane")
307+ // NV12/P010 are 4:2:0 two-plane images. The same modifier must
308+ // describe both planes; each plane may have its own fd and offset.
309+ yuv := isYUV(format)
310+ wantPlanes := 1
311+ if yuv {
312+ wantPlanes = 2
313+ }
314+ if n != wantPlanes {
315+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete), "wrong plane count")
303316 return nil, false, false
304317 }
305- pl := p.planes[0]
306- row := uint64(width) * 4
307- end := uint64(pl.offset) + uint64(pl.stride)*uint64(height-1) + row
308- size, err := pl.file.Seek(0, io.SeekEnd)
309- if uint64(pl.stride) < row || (err == nil && end > uint64(size)) {
310- r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds), "plane out of bounds")
318+ if yuv && (width%2 != 0 || height%2 != 0) {
319+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorInvalidDimensions), "4:2:0 requires even dimensions")
311320 return nil, false, false
312321 }
322+ for i, pl := range p.planes[:n] {
323+ row, rows := uint64(width)*4, uint64(height)
324+ if yuv {
325+ bytesPerSample := uint64(1)
326+ if format == fourccP010 {
327+ bytesPerSample = 2
328+ }
329+ row = uint64(width) * bytesPerSample
330+ if i == 1 {
331+ rows /= 2
332+ }
333+ } else if format == fourccAB4H || format == fourccXB4H {
334+ row = uint64(width) * 8
335+ }
336+ end := uint64(pl.offset) + uint64(pl.stride)*(rows-1) + row
337+ size, err := pl.file.Seek(0, io.SeekEnd)
338+ if uint64(pl.stride) < row || (err == nil && end > uint64(size)) {
339+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds), "plane out of bounds")
340+ return nil, false, false
341+ }
342+ }
313343 p.global.nextID++
314344 d := &ports.DMABuf{ID: p.global.nextID, Width: int(width), Height: int(height), Format: format, Modifier: p.modifier}
315345 for _, pl := range p.planes[:n] {
@@ -376,11 +406,13 @@ func (b *dmabufBuffer) size() (int, int) { return b.buf.Width, b.buf.Height }
376406
377407 // content hands the renderer the buffer itself: nothing is copied.
378408 func (b *dmabufBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
379- opaque := b.buf.Format == fourccXRGB || b.buf.Format == fourccXBGR
409+ opaque := b.buf.Format == fourccXRGB || b.buf.Format == fourccXBGR || b.buf.Format == fourccXR30 || b.buf.Format == fourccXB30
380410 return ports.SurfaceContent{ID: id, Width: b.buf.Width, Height: b.buf.Height, Opaque: opaque, DMABuf: b.buf}, true
381411 }
382412
383413 const (
384414 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
385415 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24
416+ fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
417+ fourccXB30 = 'X' | 'B'<<8 | '3'<<16 | '0'<<24
386418 )
@@ -270,6 +270,13 @@ func (p *params) anyPlane() bool {
270 return slices.ContainsFunc(p.planes[:], func(pl *plane) bool { return pl != nil })270 return slices.ContainsFunc(p.planes[:], func(pl *plane) bool { return pl != nil })
271 }271 }
272 272
273+const (
274+ fourccNV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24
275+ fourccP010 = 'P' | '0'<<8 | '1'<<16 | '0'<<24
276+ fourccAB4H = 'A' | 'B'<<8 | '4'<<16 | 'H'<<24
277+ fourccXB4H = 'X' | 'B'<<8 | '4'<<16 | 'H'<<24
278+)
279+
273 // build validates the planes and turns them into a buffer; ok is false after280 // build validates the planes and turns them into a buffer; ok is false after
274 // a protocol error, failed means the client gets `failed`.281 // a protocol error, failed means the client gets `failed`.
275 func (p *params) build(width, height int32, format, flags uint32) (buf *dmabufBuffer, failed, ok bool) {282 func (p *params) build(width, height int32, format, flags uint32) (buf *dmabufBuffer, failed, ok bool) {
@@ -297,19 +304,42 @@ func (p *params) build(width, height int32, format, flags uint32) (buf *dmabufBu
297 if flags != 0 || !slices.Contains(p.global.support.Formats, want) {304 if flags != 0 || !slices.Contains(p.global.support.Formats, want) {
298 return nil, true, true305 return nil, true, true
299 }306 }
300- // Supported formats are single-plane, 4 bytes per pixel.307+ // NV12/P010 are 4:2:0 two-plane images. The same modifier must
301- if n != 1 {308+ // describe both planes; each plane may have its own fd and offset.
302- r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete), "format takes one plane")309+ yuv := isYUV(format)
310+ wantPlanes := 1
311+ if yuv {
312+ wantPlanes = 2
313+ }
314+ if n != wantPlanes {
315+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete), "wrong plane count")
303 return nil, false, false316 return nil, false, false
304 }317 }
305- pl := p.planes[0]318+ if yuv && (width%2 != 0 || height%2 != 0) {
306- row := uint64(width) * 4319+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorInvalidDimensions), "4:2:0 requires even dimensions")
307- end := uint64(pl.offset) + uint64(pl.stride)*uint64(height-1) + row
308- size, err := pl.file.Seek(0, io.SeekEnd)
309- if uint64(pl.stride) < row || (err == nil && end > uint64(size)) {
310- r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds), "plane out of bounds")
311 return nil, false, false320 return nil, false, false
312 }321 }
322+ for i, pl := range p.planes[:n] {
323+ row, rows := uint64(width)*4, uint64(height)
324+ if yuv {
325+ bytesPerSample := uint64(1)
326+ if format == fourccP010 {
327+ bytesPerSample = 2
328+ }
329+ row = uint64(width) * bytesPerSample
330+ if i == 1 {
331+ rows /= 2
332+ }
333+ } else if format == fourccAB4H || format == fourccXB4H {
334+ row = uint64(width) * 8
335+ }
336+ end := uint64(pl.offset) + uint64(pl.stride)*(rows-1) + row
337+ size, err := pl.file.Seek(0, io.SeekEnd)
338+ if uint64(pl.stride) < row || (err == nil && end > uint64(size)) {
339+ r.PostError(uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds), "plane out of bounds")
340+ return nil, false, false
341+ }
342+ }
313 p.global.nextID++343 p.global.nextID++
314 d := &ports.DMABuf{ID: p.global.nextID, Width: int(width), Height: int(height), Format: format, Modifier: p.modifier}344 d := &ports.DMABuf{ID: p.global.nextID, Width: int(width), Height: int(height), Format: format, Modifier: p.modifier}
315 for _, pl := range p.planes[:n] {345 for _, pl := range p.planes[:n] {
@@ -376,11 +406,13 @@ func (b *dmabufBuffer) size() (int, int) { return b.buf.Width, b.buf.Height }
376 406
377 // content hands the renderer the buffer itself: nothing is copied.407 // content hands the renderer the buffer itself: nothing is copied.
378 func (b *dmabufBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {408 func (b *dmabufBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
379- opaque := b.buf.Format == fourccXRGB || b.buf.Format == fourccXBGR409+ opaque := b.buf.Format == fourccXRGB || b.buf.Format == fourccXBGR || b.buf.Format == fourccXR30 || b.buf.Format == fourccXB30
380 return ports.SurfaceContent{ID: id, Width: b.buf.Width, Height: b.buf.Height, Opaque: opaque, DMABuf: b.buf}, true410 return ports.SurfaceContent{ID: id, Width: b.buf.Width, Height: b.buf.Height, Opaque: opaque, DMABuf: b.buf}, true
381 }411 }
382 412
383 const (413 const (
384 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24414 fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
385 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24415 fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24
416+ fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
417+ fourccXB30 = 'X' | 'B'<<8 | '3'<<16 | '0'<<24
386 )418 )
A internal/adapters/wayland/dmabuf_yuv_test.go +86 -0
@@ -0,0 +1,86 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func TestYUVParams(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ format uint32
15+ two bool
16+ modMismatch bool
17+ short bool
18+ errCode uint32
19+ }{
20+ {name: "NV12", format: fourccNV12, two: true},
21+ {name: "P010", format: fourccP010, two: true},
22+ {name: "missing plane", format: fourccNV12, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete)},
23+ {name: "extra plane", format: fourccNV12, two: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete)},
24+ {name: "modifier mismatch", format: fourccNV12, two: true, modMismatch: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorInvalidFormat)},
25+ {name: "P010 short chroma", format: fourccP010, two: true, short: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds)},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{{Format: fourccNV12}, {Format: fourccP010}}})
29+ c := protocolClient(t, s, dir)
30+ dm := bindVersion(t, c, "zwp_linux_dmabuf_v1", 4)
31+ p := c.AllocateID()
32+ requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
33+ proxy := &paramsProxy{result: make(chan uint16, 2)}
34+ proxy.SetID(p)
35+ c.Context().Register(proxy)
36+ fd, err := unix.MemfdCreate("yuv-params", 0)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ defer unix.Close(fd)
41+ if err := unix.Ftruncate(fd, 8192); err != nil {
42+ t.Fatal(err)
43+ }
44+ add := func(idx, offset, stride, mod uint32) {
45+ t.Helper()
46+ if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil {
47+ t.Fatal(err)
48+ }
49+ }
50+ stride := uint32(64)
51+ if tc.format == fourccP010 {
52+ stride = 128
53+ }
54+ add(0, 0, stride, 0)
55+ if tc.two {
56+ mod := uint32(0)
57+ if tc.modMismatch {
58+ mod = 1
59+ }
60+ offset := uint32(1024)
61+ if tc.short {
62+ offset = 8180
63+ }
64+ add(1, offset, stride, mod)
65+ }
66+ if tc.name == "extra plane" {
67+ add(2, 4096, 64, 0)
68+ }
69+ if tc.modMismatch {
70+ expectProtocolError(t, c, p, tc.errCode)
71+ return
72+ }
73+ requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), tc.format, uint32(0))
74+ if tc.errCode != 0 {
75+ expectProtocolError(t, c, p, tc.errCode)
76+ return
77+ }
78+ if err := c.Roundtrip(); err != nil {
79+ t.Fatal(err)
80+ }
81+ if op := <-proxy.result; op != uint16(linuxdmabuf.ZwpLinuxBufferParamsV1EventCreated) {
82+ t.Fatalf("created event %d", op)
83+ }
84+ })
85+ }
86+}
@@ -0,0 +1,86 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/linuxdmabuf"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func TestYUVParams(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ format uint32
15+ two bool
16+ modMismatch bool
17+ short bool
18+ errCode uint32
19+ }{
20+ {name: "NV12", format: fourccNV12, two: true},
21+ {name: "P010", format: fourccP010, two: true},
22+ {name: "missing plane", format: fourccNV12, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete)},
23+ {name: "extra plane", format: fourccNV12, two: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorIncomplete)},
24+ {name: "modifier mismatch", format: fourccNV12, two: true, modMismatch: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorInvalidFormat)},
25+ {name: "P010 short chroma", format: fourccP010, two: true, short: true, errCode: uint32(linuxdmabuf.ZwpLinuxBufferParamsV1ErrorOutOfBounds)},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{{Format: fourccNV12}, {Format: fourccP010}}})
29+ c := protocolClient(t, s, dir)
30+ dm := bindVersion(t, c, "zwp_linux_dmabuf_v1", 4)
31+ p := c.AllocateID()
32+ requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p)
33+ proxy := &paramsProxy{result: make(chan uint16, 2)}
34+ proxy.SetID(p)
35+ c.Context().Register(proxy)
36+ fd, err := unix.MemfdCreate("yuv-params", 0)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ defer unix.Close(fd)
41+ if err := unix.Ftruncate(fd, 8192); err != nil {
42+ t.Fatal(err)
43+ }
44+ add := func(idx, offset, stride, mod uint32) {
45+ t.Helper()
46+ if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil {
47+ t.Fatal(err)
48+ }
49+ }
50+ stride := uint32(64)
51+ if tc.format == fourccP010 {
52+ stride = 128
53+ }
54+ add(0, 0, stride, 0)
55+ if tc.two {
56+ mod := uint32(0)
57+ if tc.modMismatch {
58+ mod = 1
59+ }
60+ offset := uint32(1024)
61+ if tc.short {
62+ offset = 8180
63+ }
64+ add(1, offset, stride, mod)
65+ }
66+ if tc.name == "extra plane" {
67+ add(2, 4096, 64, 0)
68+ }
69+ if tc.modMismatch {
70+ expectProtocolError(t, c, p, tc.errCode)
71+ return
72+ }
73+ requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), tc.format, uint32(0))
74+ if tc.errCode != 0 {
75+ expectProtocolError(t, c, p, tc.errCode)
76+ return
77+ }
78+ if err := c.Roundtrip(); err != nil {
79+ t.Fatal(err)
80+ }
81+ if op := <-proxy.result; op != uint16(linuxdmabuf.ZwpLinuxBufferParamsV1EventCreated) {
82+ t.Fatalf("created event %d", op)
83+ }
84+ })
85+ }
86+}
M internal/adapters/wayland/fifo.go +28 -24
@@ -198,29 +198,31 @@ func (s *Server) surfaceOf(w *wayland.Surface) *surface {
198198
199199 // update is a surface's pending state taken out by a commit that waits.
200200 type update struct {
201- attached bool
202- buffer *wayland.Buffer
203- scale int
204- async bool
205- kind uint32
206- callbacks []*wayland.Callback
207- vp *viewport
208- vpW, vpH int32
209- vpSet bool
210- moves []childMove
211- xdg *xdgSurface
212- geometry ports.Rect
213- cons *constraint
214- region *ports.Rect
215- layer *layerSurface
216- layerNext layerState
217- barrier bool
218- wait bool
219- at time.Time
220- feedback []*presentationtime.WpPresentationFeedback
221- sync *commitSync
222- damage []ports.Rect
223- bufDamage []ports.Rect
201+ attached bool
202+ buffer *wayland.Buffer
203+ scale int
204+ async bool
205+ kind uint32
206+ callbacks []*wayland.Callback
207+ vp *viewport
208+ vpW, vpH int32
209+ vpSet bool
210+ moves []childMove
211+ xdg *xdgSurface
212+ geometry ports.Rect
213+ cons *constraint
214+ region *ports.Rect
215+ layer *layerSurface
216+ layerNext layerState
217+ barrier bool
218+ wait bool
219+ at time.Time
220+ feedback []*presentationtime.WpPresentationFeedback
221+ sync *commitSync
222+ color SurfaceColor
223+ representation surfaceRepresentation
224+ damage []ports.Rect
225+ bufDamage []ports.Rect
224226 }
225227
226228 type childMove struct {
@@ -231,7 +233,7 @@ type childMove struct {
231233 // takePending moves the pending state into an update: one-shot state is
232234 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.
233235 func (s *surface) takePending() update {
234- u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback, sync: s.pendingSync}
236+ u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback, sync: s.pendingSync, color: s.pendingColor, representation: s.pendingRepresentation}
235237 s.attached, s.pending, s.callbacks = false, nil, nil
236238 s.pendingFeedback, s.pendingSync = nil, nil
237239 s.pendingDamage, s.pendingBufDamage = nil, nil
@@ -262,6 +264,8 @@ func (s *surface) takePending() update {
262264 func (s *surface) putPending(u update) {
263265 s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks
264266 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
267+ s.pendingColor = u.color
268+ s.pendingRepresentation = u.representation
265269 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
266270 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
267271 s.pendingFeedback, s.pendingSync = u.feedback, u.sync
@@ -198,29 +198,31 @@ func (s *Server) surfaceOf(w *wayland.Surface) *surface {
198 198
199 // update is a surface's pending state taken out by a commit that waits.199 // update is a surface's pending state taken out by a commit that waits.
200 type update struct {200 type update struct {
201- attached bool201+ attached bool
202- buffer *wayland.Buffer202+ buffer *wayland.Buffer
203- scale int203+ scale int
204- async bool204+ async bool
205- kind uint32205+ kind uint32
206- callbacks []*wayland.Callback206+ callbacks []*wayland.Callback
207- vp *viewport207+ vp *viewport
208- vpW, vpH int32208+ vpW, vpH int32
209- vpSet bool209+ vpSet bool
210- moves []childMove210+ moves []childMove
211- xdg *xdgSurface211+ xdg *xdgSurface
212- geometry ports.Rect212+ geometry ports.Rect
213- cons *constraint213+ cons *constraint
214- region *ports.Rect214+ region *ports.Rect
215- layer *layerSurface215+ layer *layerSurface
216- layerNext layerState216+ layerNext layerState
217- barrier bool217+ barrier bool
218- wait bool218+ wait bool
219- at time.Time219+ at time.Time
220- feedback []*presentationtime.WpPresentationFeedback220+ feedback []*presentationtime.WpPresentationFeedback
221- sync *commitSync221+ sync *commitSync
222- damage []ports.Rect222+ color SurfaceColor
223- bufDamage []ports.Rect223+ representation surfaceRepresentation
224+ damage []ports.Rect
225+ bufDamage []ports.Rect
224 }226 }
225 227
226 type childMove struct {228 type childMove struct {
@@ -231,7 +233,7 @@ type childMove struct {
231 // takePending moves the pending state into an update: one-shot state is233 // takePending moves the pending state into an update: one-shot state is
232 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.234 // cleared, sticky state (scale, hints, viewport, geometry, layer) kept.
233 func (s *surface) takePending() update {235 func (s *surface) takePending() update {
234- u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback, sync: s.pendingSync}236+ u := update{attached: s.attached, buffer: s.pending, scale: s.pendingScale, async: s.pendingAsync, kind: s.pendingKind, callbacks: s.callbacks, barrier: s.pendingBarrier, wait: s.pendingWait, at: s.pendingTime, damage: s.pendingDamage, bufDamage: s.pendingBufDamage, feedback: s.pendingFeedback, sync: s.pendingSync, color: s.pendingColor, representation: s.pendingRepresentation}
235 s.attached, s.pending, s.callbacks = false, nil, nil237 s.attached, s.pending, s.callbacks = false, nil, nil
236 s.pendingFeedback, s.pendingSync = nil, nil238 s.pendingFeedback, s.pendingSync = nil, nil
237 s.pendingDamage, s.pendingBufDamage = nil, nil239 s.pendingDamage, s.pendingBufDamage = nil, nil
@@ -262,6 +264,8 @@ func (s *surface) takePending() update {
262 func (s *surface) putPending(u update) {264 func (s *surface) putPending(u update) {
263 s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks265 s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks
264 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind266 s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind
267+ s.pendingColor = u.color
268+ s.pendingRepresentation = u.representation
265 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at269 s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at
266 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage270 s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage
267 s.pendingFeedback, s.pendingSync = u.feedback, u.sync271 s.pendingFeedback, s.pendingSync = u.feedback, u.sync
M internal/adapters/wayland/globals.go +2 -0
@@ -19,6 +19,8 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1919 func() error { return registerDMABuf(d, s, o.DMABuf) },
2020 func() error { return registerPointerConstraints(d, s) },
2121 func() error { return registerTearing(d, s) },
22+ func() error { return registerColorManagement(d, s) },
23+ func() error { return registerColorRepresentation(d, s) },
2224 func() error { return registerPresentationConstraints(d, s) },
2325 func() error { return registerPresentation(d, s) },
2426 func() error { return registerSyncobj(d, s) },
@@ -19,6 +19,8 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
19 func() error { return registerDMABuf(d, s, o.DMABuf) },19 func() error { return registerDMABuf(d, s, o.DMABuf) },
20 func() error { return registerPointerConstraints(d, s) },20 func() error { return registerPointerConstraints(d, s) },
21 func() error { return registerTearing(d, s) },21 func() error { return registerTearing(d, s) },
22+ func() error { return registerColorManagement(d, s) },
23+ func() error { return registerColorRepresentation(d, s) },
22 func() error { return registerPresentationConstraints(d, s) },24 func() error { return registerPresentationConstraints(d, s) },
23 func() error { return registerPresentation(d, s) },25 func() error { return registerPresentation(d, s) },
24 func() error { return registerSyncobj(d, s) },26 func() error { return registerSyncobj(d, s) },
M internal/adapters/wayland/outputs.go +20 -0
@@ -11,6 +11,23 @@ import (
1111 "github.com/bnema/purego-libwayland/server"
1212 )
1313
14+// setOutputHDR records confirmed DRM state independently of the core layout.
15+// Until the first modeset report for an output it is treated as SDR.
16+func (s *Server) setOutputHDR(f ports.OutputFormats) {
17+ if s.hdrOutputs == nil {
18+ s.hdrOutputs = make(map[string]ports.OutputHDR)
19+ }
20+ old, had := s.hdrOutputs[f.Output]
21+ if f.HDR == nil {
22+ delete(s.hdrOutputs, f.Output)
23+ } else {
24+ s.hdrOutputs[f.Output] = *f.HDR
25+ }
26+ if (f.HDR == nil && had) || (f.HDR != nil && (!had || old != *f.HDR)) {
27+ s.colorOutputChanged(f.Output)
28+ }
29+}
30+
1431 // Outputs. Core sends the global layout (ports.SetOutputs); each output is
1532 // one wl_output global, removed when the display is unplugged. Every size
1633 // sent to clients (configures, xdg-output, layer configures, pointer
@@ -155,6 +172,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
155172 continue
156173 }
157174 o.global.Remove()
175+ delete(s.hdrOutputs, o.name())
176+ delete(s.colorIdentity, o.name())
158177 for session := range s.captureSessions {
159178 if session.o == o && !session.stopped {
160179 session.stopped = true
@@ -208,6 +227,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
208227 for _, surf := range s.surfaces {
209228 surf.sendScale()
210229 }
230+ s.notifyColorFeedback()
211231 // Layer surfaces sized from the output follow its new logical size.
212232 adopted := false
213233 for _, l := range s.layers {
@@ -11,6 +11,23 @@ import (
11 "github.com/bnema/purego-libwayland/server"11 "github.com/bnema/purego-libwayland/server"
12 )12 )
13 13
14+// setOutputHDR records confirmed DRM state independently of the core layout.
15+// Until the first modeset report for an output it is treated as SDR.
16+func (s *Server) setOutputHDR(f ports.OutputFormats) {
17+ if s.hdrOutputs == nil {
18+ s.hdrOutputs = make(map[string]ports.OutputHDR)
19+ }
20+ old, had := s.hdrOutputs[f.Output]
21+ if f.HDR == nil {
22+ delete(s.hdrOutputs, f.Output)
23+ } else {
24+ s.hdrOutputs[f.Output] = *f.HDR
25+ }
26+ if (f.HDR == nil && had) || (f.HDR != nil && (!had || old != *f.HDR)) {
27+ s.colorOutputChanged(f.Output)
28+ }
29+}
30+
14 // Outputs. Core sends the global layout (ports.SetOutputs); each output is31 // Outputs. Core sends the global layout (ports.SetOutputs); each output is
15 // one wl_output global, removed when the display is unplugged. Every size32 // one wl_output global, removed when the display is unplugged. Every size
16 // sent to clients (configures, xdg-output, layer configures, pointer33 // sent to clients (configures, xdg-output, layer configures, pointer
@@ -155,6 +172,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
155 continue172 continue
156 }173 }
157 o.global.Remove()174 o.global.Remove()
175+ delete(s.hdrOutputs, o.name())
176+ delete(s.colorIdentity, o.name())
158 for session := range s.captureSessions {177 for session := range s.captureSessions {
159 if session.o == o && !session.stopped {178 if session.o == o && !session.stopped {
160 session.stopped = true179 session.stopped = true
@@ -208,6 +227,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
208 for _, surf := range s.surfaces {227 for _, surf := range s.surfaces {
209 surf.sendScale()228 surf.sendScale()
210 }229 }
230+ s.notifyColorFeedback()
211 // Layer surfaces sized from the output follow its new logical size.231 // Layer surfaces sized from the output follow its new logical size.
212 adopted := false232 adopted := false
213 for _, l := range s.layers {233 for _, l := range s.layers {
M internal/adapters/wayland/outputs_test.go +17 -0
@@ -12,6 +12,23 @@ import (
1212
1313 var second = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1280, Height: 1024}, X: 1920, Width: 1280, Height: 1024, Scale: 1}
1414
15+func TestOutputHDRConfirmedState(t *testing.T) {
16+ s := &Server{}
17+ const name = "DP-1"
18+ if _, ok := s.hdrOutputs[name]; ok {
19+ t.Fatal("output must start SDR")
20+ }
21+ want := ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
22+ s.setOutputHDR(ports.OutputFormats{Output: name, HDR: &want})
23+ if got := s.hdrOutputs[name]; got != want {
24+ t.Fatalf("HDR report: %+v", got)
25+ }
26+ s.setOutputHDR(ports.OutputFormats{Output: name})
27+ if _, ok := s.hdrOutputs[name]; ok {
28+ t.Fatal("SDR fallback must clear HDR")
29+ }
30+}
31+
1532 // outputCount counts wl_output globals the client sees.
1633 func outputCount(c *wlturbo.Display) int {
1734 n := 0
@@ -12,6 +12,23 @@ import (
12 12
13 var second = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1280, Height: 1024}, X: 1920, Width: 1280, Height: 1024, Scale: 1}13 var second = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1280, Height: 1024}, X: 1920, Width: 1280, Height: 1024, Scale: 1}
14 14
15+func TestOutputHDRConfirmedState(t *testing.T) {
16+ s := &Server{}
17+ const name = "DP-1"
18+ if _, ok := s.hdrOutputs[name]; ok {
19+ t.Fatal("output must start SDR")
20+ }
21+ want := ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
22+ s.setOutputHDR(ports.OutputFormats{Output: name, HDR: &want})
23+ if got := s.hdrOutputs[name]; got != want {
24+ t.Fatalf("HDR report: %+v", got)
25+ }
26+ s.setOutputHDR(ports.OutputFormats{Output: name})
27+ if _, ok := s.hdrOutputs[name]; ok {
28+ t.Fatal("SDR fallback must clear HDR")
29+ }
30+}
31+
15 // outputCount counts wl_output globals the client sees.32 // outputCount counts wl_output globals the client sees.
16 func outputCount(c *wlturbo.Display) int {33 func outputCount(c *wlturbo.Display) int {
17 n := 034 n := 0
M internal/adapters/wayland/scale.go +6 -0
@@ -53,6 +53,12 @@ func (surf *surface) sendScale() {
5353 surf.leave(surf.on)
5454 }
5555 surf.on = o
56+ for h := range s.colorFeedbacks {
57+ if h.surf == surf {
58+ h.last = o
59+ h.changed(s.outputColor(o).identity)
60+ }
61+ }
5662 if o != nil {
5763 for _, r := range o.resources {
5864 if r.Client() == surf.wl.Client() {
@@ -53,6 +53,12 @@ func (surf *surface) sendScale() {
53 surf.leave(surf.on)53 surf.leave(surf.on)
54 }54 }
55 surf.on = o55 surf.on = o
56+ for h := range s.colorFeedbacks {
57+ if h.surf == surf {
58+ h.last = o
59+ h.changed(s.outputColor(o).identity)
60+ }
61+ }
56 if o != nil {62 if o != nil {
57 for _, r := range o.resources {63 for _, r := range o.resources {
58 if r.Client() == surf.wl.Client() {64 if r.Client() == surf.wl.Client() {
M internal/adapters/wayland/server.go +16 -1
@@ -86,7 +86,14 @@ type Server struct {
8686 surfaces map[*server.Resource]*surface
8787 buffers map[*server.Resource]clientBuffer
8888 dmabuf *dmabufGlobal
89- serial uint32
89+ // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
90+ hdrOutputs map[string]ports.OutputHDR
91+ colorID uint64
92+ colorIdentity map[string]uint64
93+ colorDescriptions map[*server.Resource]colorDescription
94+ colorOutputs map[*colorOutput]struct{}
95+ colorFeedbacks map[*colorFeedback]struct{}
96+ serial uint32
9097 // press is the serial of the last button or key press, sent to
9198 // pressClient: popup grabs must come from it.
9299 press uint32
@@ -234,6 +241,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
234241 s.syncDev = node
235242 }
236243 }
244+ s.colorIdentity = map[string]uint64{}
245+ s.colorDescriptions = map[*server.Resource]colorDescription{}
246+ s.colorOutputs = map[*colorOutput]struct{}{}
247+ s.colorFeedbacks = map[*colorFeedback]struct{}{}
237248 s.tokens = map[string]activationToken{}
238249 if opts.Keymap != "" {
239250 fd, size, e := keymapFile(opts.Keymap)
@@ -390,6 +401,7 @@ func (s *Server) forwardOutputFormats(ctx context.Context) {
390401 return
391402 }
392403 if !s.display.Do(func() {
404+ s.setOutputHDR(f)
393405 if s.dmabuf != nil {
394406 s.dmabuf.setOutputFormats(f)
395407 }
@@ -662,6 +674,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
662674 if surf := w.xdg.surface; surf != nil && c.Output != "" {
663675 surf.sendTreeScale()
664676 }
677+ if scanoutChanged {
678+ s.notifyColorFeedback()
679+ }
665680 case ports.SetOutputs:
666681 s.setOutputs(c)
667682 off := map[string]bool{}
@@ -86,7 +86,14 @@ type Server struct {
86 surfaces map[*server.Resource]*surface86 surfaces map[*server.Resource]*surface
87 buffers map[*server.Resource]clientBuffer87 buffers map[*server.Resource]clientBuffer
88 dmabuf *dmabufGlobal88 dmabuf *dmabufGlobal
89- serial uint3289+ // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
90+ hdrOutputs map[string]ports.OutputHDR
91+ colorID uint64
92+ colorIdentity map[string]uint64
93+ colorDescriptions map[*server.Resource]colorDescription
94+ colorOutputs map[*colorOutput]struct{}
95+ colorFeedbacks map[*colorFeedback]struct{}
96+ serial uint32
90 // press is the serial of the last button or key press, sent to97 // press is the serial of the last button or key press, sent to
91 // pressClient: popup grabs must come from it.98 // pressClient: popup grabs must come from it.
92 press uint3299 press uint32
@@ -234,6 +241,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
234 s.syncDev = node241 s.syncDev = node
235 }242 }
236 }243 }
244+ s.colorIdentity = map[string]uint64{}
245+ s.colorDescriptions = map[*server.Resource]colorDescription{}
246+ s.colorOutputs = map[*colorOutput]struct{}{}
247+ s.colorFeedbacks = map[*colorFeedback]struct{}{}
237 s.tokens = map[string]activationToken{}248 s.tokens = map[string]activationToken{}
238 if opts.Keymap != "" {249 if opts.Keymap != "" {
239 fd, size, e := keymapFile(opts.Keymap)250 fd, size, e := keymapFile(opts.Keymap)
@@ -390,6 +401,7 @@ func (s *Server) forwardOutputFormats(ctx context.Context) {
390 return401 return
391 }402 }
392 if !s.display.Do(func() {403 if !s.display.Do(func() {
404+ s.setOutputHDR(f)
393 if s.dmabuf != nil {405 if s.dmabuf != nil {
394 s.dmabuf.setOutputFormats(f)406 s.dmabuf.setOutputFormats(f)
395 }407 }
@@ -662,6 +674,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
662 if surf := w.xdg.surface; surf != nil && c.Output != "" {674 if surf := w.xdg.surface; surf != nil && c.Output != "" {
663 surf.sendTreeScale()675 surf.sendTreeScale()
664 }676 }
677+ if scanoutChanged {
678+ s.notifyColorFeedback()
679+ }
665 case ports.SetOutputs:680 case ports.SetOutputs:
666 s.setOutputs(c)681 s.setOutputs(c)
667 off := map[string]bool{}682 off := map[string]bool{}
M internal/adapters/wayland/surface.go +25 -5
@@ -48,8 +48,12 @@ type surface struct {
4848 sub subState
4949 // tearing is the surface's wp_tearing_control_v1; async is the
5050 // committed hint, pendingAsync the requested one.
51- tearing *tearingHandler
52- async, pendingAsync bool
51+ tearing *tearingHandler
52+ async, pendingAsync bool
53+ colorControl *colorSurface
54+ color, pendingColor SurfaceColor
55+ representation, pendingRepresentation surfaceRepresentation
56+ representationControl *representationSurface
5357 // Presentation constraints (fifo.go): pending fifo requests and commit
5458 // timestamp, the barrier, and the commits waiting to apply.
5559 fifo *fifoHandler
@@ -124,10 +128,22 @@ func (s *surface) windowID() ports.WindowID {
124128 return 0
125129 }
126130
131+// outputColor converts the committed protocol description without allocating.
132+func (s *surface) outputColor() ports.SurfaceColor {
133+ return ports.SurfaceColor{TF: uint8(s.color.TF), Primaries: uint8(s.color.Primaries), MaxCLL: uint16(s.color.MaxCLL), MaxFALL: uint16(s.color.MaxFALL), Coefficients: s.representation.coefficients, Range: s.representation.rangeValue, Chroma: s.representation.chroma}
134+}
135+
136+func (s *surface) contentWithColor() ports.SurfaceContent {
137+ c := s.content
138+ c.Color = s.outputColor()
139+ return c
140+}
141+
127142 // tree is the root's content with its subsurfaces flattened.
128143 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
129144 c := s.content
130145 c.ID = id
146+ c.Color = s.outputColor()
131147 c.Children = nil
132148 for _, ch := range s.sub.children {
133149 ch.appendTree(&c.Children, ch.sub.x, ch.sub.y, ch.sub.below)
@@ -148,7 +164,7 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) {
148164 }
149165 }
150166 if s.has {
151- *out = append(*out, ports.Subsurface{X: x, Y: y, Below: below, SurfaceContent: s.content})
167+ *out = append(*out, ports.Subsurface{X: x, Y: y, Below: below, SurfaceContent: s.contentWithColor()})
152168 }
153169 for _, ch := range s.sub.children {
154170 if !ch.sub.below {
@@ -194,6 +210,8 @@ func (s *surface) Destroy(*wayland.Surface) {
194210 }
195211 s.pendingFeedback = nil
196212 s.tearing = nil // the control becomes inert
213+ s.colorControl = nil
214+ s.representationControl = nil
197215 s.dropQueue()
198216 if s.server.cursorSurface == s {
199217 // The pointer keeps no cursor until the client sets another.
@@ -243,7 +261,7 @@ func (s *surface) Commit(*wayland.Surface) {
243261 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")
244262 return
245263 }
246- if !s.checkSyncCommit() {
264+ if !s.checkSyncCommit() || !s.checkRepresentationCommit() {
247265 return
248266 }
249267 s.takeSyncPoints()
@@ -271,8 +289,10 @@ func (s *surface) applyCommit() {
271289 if s.pendingScale > 0 {
272290 s.bufferScale = s.pendingScale
273291 }
274- hinted := s.async != s.pendingAsync
292+ hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation
275293 s.async = s.pendingAsync
294+ s.color = s.pendingColor
295+ s.representation = s.pendingRepresentation
276296 if s.viewport != nil {
277297 s.viewport.commit()
278298 }
@@ -48,8 +48,12 @@ type surface struct {
48 sub subState48 sub subState
49 // tearing is the surface's wp_tearing_control_v1; async is the49 // tearing is the surface's wp_tearing_control_v1; async is the
50 // committed hint, pendingAsync the requested one.50 // committed hint, pendingAsync the requested one.
51- tearing *tearingHandler51+ tearing *tearingHandler
52- async, pendingAsync bool52+ async, pendingAsync bool
53+ colorControl *colorSurface
54+ color, pendingColor SurfaceColor
55+ representation, pendingRepresentation surfaceRepresentation
56+ representationControl *representationSurface
53 // Presentation constraints (fifo.go): pending fifo requests and commit57 // Presentation constraints (fifo.go): pending fifo requests and commit
54 // timestamp, the barrier, and the commits waiting to apply.58 // timestamp, the barrier, and the commits waiting to apply.
55 fifo *fifoHandler59 fifo *fifoHandler
@@ -124,10 +128,22 @@ func (s *surface) windowID() ports.WindowID {
124 return 0128 return 0
125 }129 }
126 130
131+// outputColor converts the committed protocol description without allocating.
132+func (s *surface) outputColor() ports.SurfaceColor {
133+ return ports.SurfaceColor{TF: uint8(s.color.TF), Primaries: uint8(s.color.Primaries), MaxCLL: uint16(s.color.MaxCLL), MaxFALL: uint16(s.color.MaxFALL), Coefficients: s.representation.coefficients, Range: s.representation.rangeValue, Chroma: s.representation.chroma}
134+}
135+
136+func (s *surface) contentWithColor() ports.SurfaceContent {
137+ c := s.content
138+ c.Color = s.outputColor()
139+ return c
140+}
141+
127 // tree is the root's content with its subsurfaces flattened.142 // tree is the root's content with its subsurfaces flattened.
128 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {143 func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
129 c := s.content144 c := s.content
130 c.ID = id145 c.ID = id
146+ c.Color = s.outputColor()
131 c.Children = nil147 c.Children = nil
132 for _, ch := range s.sub.children {148 for _, ch := range s.sub.children {
133 ch.appendTree(&c.Children, ch.sub.x, ch.sub.y, ch.sub.below)149 ch.appendTree(&c.Children, ch.sub.x, ch.sub.y, ch.sub.below)
@@ -148,7 +164,7 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) {
148 }164 }
149 }165 }
150 if s.has {166 if s.has {
151- *out = append(*out, ports.Subsurface{X: x, Y: y, Below: below, SurfaceContent: s.content})167+ *out = append(*out, ports.Subsurface{X: x, Y: y, Below: below, SurfaceContent: s.contentWithColor()})
152 }168 }
153 for _, ch := range s.sub.children {169 for _, ch := range s.sub.children {
154 if !ch.sub.below {170 if !ch.sub.below {
@@ -194,6 +210,8 @@ func (s *surface) Destroy(*wayland.Surface) {
194 }210 }
195 s.pendingFeedback = nil211 s.pendingFeedback = nil
196 s.tearing = nil // the control becomes inert212 s.tearing = nil // the control becomes inert
213+ s.colorControl = nil
214+ s.representationControl = nil
197 s.dropQueue()215 s.dropQueue()
198 if s.server.cursorSurface == s {216 if s.server.cursorSurface == s {
199 // The pointer keeps no cursor until the client sets another.217 // The pointer keeps no cursor until the client sets another.
@@ -243,7 +261,7 @@ func (s *surface) Commit(*wayland.Surface) {
243 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")261 s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack")
244 return262 return
245 }263 }
246- if !s.checkSyncCommit() {264+ if !s.checkSyncCommit() || !s.checkRepresentationCommit() {
247 return265 return
248 }266 }
249 s.takeSyncPoints()267 s.takeSyncPoints()
@@ -271,8 +289,10 @@ func (s *surface) applyCommit() {
271 if s.pendingScale > 0 {289 if s.pendingScale > 0 {
272 s.bufferScale = s.pendingScale290 s.bufferScale = s.pendingScale
273 }291 }
274- hinted := s.async != s.pendingAsync292+ hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation
275 s.async = s.pendingAsync293 s.async = s.pendingAsync
294+ s.color = s.pendingColor
295+ s.representation = s.pendingRepresentation
276 if s.viewport != nil {296 if s.viewport != nil {
277 s.viewport.commit()297 s.viewport.commit()
278 }298 }
M internal/app/app.go +3 -1
@@ -36,6 +36,8 @@ type Options struct {
3636 NoTerminal bool
3737 // NoXwayland skips the X11 display even when the config enables it.
3838 NoXwayland bool
39+ // HeadlessHDR enables a virtual HDR output for protocol testing only.
40+ HeadlessHDR bool
3941 // Session exports the displays to D-Bus and systemd user services
4042 // before any client starts, and withdraws them on exit.
4143 Session bool
@@ -240,7 +242,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
240242 })
241243 return
242244 }
243- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
245+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputFormats, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
244246 }()
245247
246248 if hw != nil {
@@ -36,6 +36,8 @@ type Options struct {
36 NoTerminal bool36 NoTerminal bool
37 // NoXwayland skips the X11 display even when the config enables it.37 // NoXwayland skips the X11 display even when the config enables it.
38 NoXwayland bool38 NoXwayland bool
39+ // HeadlessHDR enables a virtual HDR output for protocol testing only.
40+ HeadlessHDR bool
39 // Session exports the displays to D-Bus and systemd user services41 // Session exports the displays to D-Bus and systemd user services
40 // before any client starts, and withdraws them on exit.42 // before any client starts, and withdraws them on exit.
41 Session bool43 Session bool
@@ -240,7 +242,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
240 })242 })
241 return243 return
242 }244 }
243- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))245+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, outputFormats, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render"))
244 }()246 }()
245 247
246 if hw != nil {248 if hw != nil {
M internal/app/headless.go +2 -2
@@ -13,7 +13,7 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, formats chan<- ports.OutputFormats, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
1717 set := newOutputSet(ctx, captured)
1818 inventory := ports.OutputHeads{}
1919 for i, size := range sizes {
@@ -30,7 +30,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
3030 }
3131 cur := &headless.Cursor{}
3232 curs.set(name, cur)
33- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
33+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
3434 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
3535 return headless.Run(octx, opts, sc, cc, cu, cap)
3636 })
@@ -13,7 +13,7 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, formats chan<- ports.OutputFormats, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17 set := newOutputSet(ctx, captured)17 set := newOutputSet(ctx, captured)
18 inventory := ports.OutputHeads{}18 inventory := ports.OutputHeads{}
19 for i, size := range sizes {19 for i, size := range sizes {
@@ -30,7 +30,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
30 }30 }
31 cur := &headless.Cursor{}31 cur := &headless.Cursor{}
32 curs.set(name, cur)32 curs.set(name, cur)
33- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}33+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
34 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {34 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
35 return headless.Run(octx, opts, sc, cc, cu, cap)35 return headless.Run(octx, opts, sc, cc, cu, cap)
36 })36 })
M internal/ports/messages.go +39 -2
@@ -424,12 +424,21 @@ type OutputPresented struct {
424424 // OutputFormats carries output → wayland the dmabuf formats an output can
425425 // scan out directly (its primary plane's, that the renderer also samples,
426426 // so a refused buffer can still be composed). Device is the KMS device
427-// (dev_t) clients allocate scanout buffers for. Sent at start and after
428-// every modeset; empty Formats means no direct scanout.
427+// (dev_t) clients allocate scanout buffers for. Sent after every successful
428+// modeset, including startup and VT resume; empty Formats means no direct
429+// scanout. HDR is non-nil only when the output's HDR10 modeset succeeded;
430+// nil means SDR, regardless of the monitor's HDR capability.
429431 type OutputFormats struct {
430432 Output string
431433 Device uint64
432434 Formats []DMABufFormat
435+ HDR *OutputHDR
436+}
437+
438+// OutputHDR carries the active HDR10 output's EDID luminances in nits.
439+// These describe the display, not any client's content metadata.
440+type OutputHDR struct {
441+ MaxLuminance, MaxFrameAverage, MinLuminance float64
433442 }
434443
435444 // FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time
@@ -731,6 +740,33 @@ type SceneLayer struct {
731740 Rect Rect
732741 }
733742
743+// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
744+// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
745+// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
746+type SurfaceColor struct {
747+ TF, Primaries uint8
748+ MaxCLL, MaxFALL uint16
749+ // Coefficients/range follow wp_color_representation_v1 (zero =
750+ // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
751+ // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
752+ Coefficients, Range, Chroma uint8
753+}
754+
755+const (
756+ ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
757+ ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
758+ ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
759+ ColorPrimariesSRGB uint8 = 1
760+)
761+
762+func (c SurfaceColor) IsPQ2020() bool {
763+ return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
764+}
765+
766+func (c SurfaceColor) IsExtendedLinear() bool {
767+ return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
768+}
769+
734770 // SurfaceContent carries wayland → output the latest committed content of a
735771 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
736772 // Renderers read both in place and never modify them. Empty means the window
@@ -745,6 +781,7 @@ type SurfaceContent struct {
745781 Width, Height int
746782 LogicalW, LogicalH int
747783 Opaque bool // x formats: ignore the alpha byte
784+ Color SurfaceColor
748785 SHM *SHMBuffer
749786 DMABuf *DMABuf
750787 // Children are the subsurfaces, bottom to top, flattened.
@@ -424,12 +424,21 @@ type OutputPresented struct {
424 // OutputFormats carries output → wayland the dmabuf formats an output can424 // OutputFormats carries output → wayland the dmabuf formats an output can
425 // scan out directly (its primary plane's, that the renderer also samples,425 // scan out directly (its primary plane's, that the renderer also samples,
426 // so a refused buffer can still be composed). Device is the KMS device426 // so a refused buffer can still be composed). Device is the KMS device
427-// (dev_t) clients allocate scanout buffers for. Sent at start and after427+// (dev_t) clients allocate scanout buffers for. Sent after every successful
428-// every modeset; empty Formats means no direct scanout.428+// modeset, including startup and VT resume; empty Formats means no direct
429+// scanout. HDR is non-nil only when the output's HDR10 modeset succeeded;
430+// nil means SDR, regardless of the monitor's HDR capability.
429 type OutputFormats struct {431 type OutputFormats struct {
430 Output string432 Output string
431 Device uint64433 Device uint64
432 Formats []DMABufFormat434 Formats []DMABufFormat
435+ HDR *OutputHDR
436+}
437+
438+// OutputHDR carries the active HDR10 output's EDID luminances in nits.
439+// These describe the display, not any client's content metadata.
440+type OutputHDR struct {
441+ MaxLuminance, MaxFrameAverage, MinLuminance float64
433 }442 }
434 443
435 // FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time444 // FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time
@@ -731,6 +740,33 @@ type SceneLayer struct {
731 Rect Rect740 Rect Rect
732 }741 }
733 742
743+// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
744+// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
745+// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
746+type SurfaceColor struct {
747+ TF, Primaries uint8
748+ MaxCLL, MaxFALL uint16
749+ // Coefficients/range follow wp_color_representation_v1 (zero =
750+ // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
751+ // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
752+ Coefficients, Range, Chroma uint8
753+}
754+
755+const (
756+ ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
757+ ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
758+ ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
759+ ColorPrimariesSRGB uint8 = 1
760+)
761+
762+func (c SurfaceColor) IsPQ2020() bool {
763+ return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
764+}
765+
766+func (c SurfaceColor) IsExtendedLinear() bool {
767+ return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
768+}
769+
734 // SurfaceContent carries wayland → output the latest committed content of a770 // SurfaceContent carries wayland → output the latest committed content of a
735 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.771 // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
736 // Renderers read both in place and never modify them. Empty means the window772 // Renderers read both in place and never modify them. Empty means the window
@@ -745,6 +781,7 @@ type SurfaceContent struct {
745 Width, Height int781 Width, Height int
746 LogicalW, LogicalH int782 LogicalW, LogicalH int
747 Opaque bool // x formats: ignore the alpha byte783 Opaque bool // x formats: ignore the alpha byte
784+ Color SurfaceColor
748 SHM *SHMBuffer785 SHM *SHMBuffer
749 DMABuf *DMABuf786 DMABuf *DMABuf
750 // Children are the subsurfaces, bottom to top, flattened.787 // Children are the subsurfaces, bottom to top, flattened.

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 25

Tags 10

↑↓ moveenter openesc close