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 Unverified
B5690EEEBB952194The key is unknown
49 files · +3199 -142
M cmd/neferwl/main.go +5 -1
| @@ -118,6 +118,7 @@ func run() error { | |||
| 118 | 118 | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 119 | 119 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 120 | 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 | 122 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 122 | 123 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)") | |
| 123 | 124 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| @@ -133,6 +134,9 @@ func run() error { | |||
| 133 | 134 | fmt.Fprintln(os.Stderr, err) | |
| 134 | 135 | return err | |
| 135 | 136 | } | |
| 137 | + | if *headlessHDR && *backend != "headless" { | |
| 138 | + | return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")} | |
| 139 | + | } | |
| 136 | 140 | if *screenshot != "" && *backend != "headless" { | |
| 137 | 141 | err := usageError{fmt.Errorf("--screenshot requires --backend=headless")} | |
| 138 | 142 | fmt.Fprintln(os.Stderr, err) | |
| @@ -227,7 +231,7 @@ func run() error { | |||
| 227 | 231 | for _, w := range warnings { | |
| 228 | 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 | 235 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 232 | 236 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 233 | 237 | 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 err | 135 | 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 = nil | 237 | err = nil | ||
M docs/config.md +15 -0
| @@ -42,6 +42,21 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 42 | 42 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 43 | 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 | 60 | ## Binds | |
| 46 | 61 | ||
| 47 | 62 | `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 | ## Binds | 60 | ## 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) { | |||
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | 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 | 57 | t.Fatalf("scanout: fb %d reason %q", fb, o.reason) | |
| 58 | 58 | } | |
| 59 | 59 | 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 = true | 55 | 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 { | |||
| 480 | 480 | } | |
| 481 | 481 | o.off, o.vrrOn = true, false | |
| 482 | 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 | 485 | return nil | |
| 484 | 486 | } | |
| 485 | 487 | ||
| @@ -492,8 +494,15 @@ func (o *Output) sendFormats() { | |||
| 492 | 494 | return | |
| 493 | 495 | } | |
| 494 | 496 | 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 | + | } | |
| 497 | 506 | } | |
| 498 | 507 | select { | |
| 499 | 508 | case o.formats <- f: | |
| @@ -738,10 +747,22 @@ func boolValue(b bool) uint64 { | |||
| 738 | 747 | // composed. | |
| 739 | 748 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) { | |
| 740 | 749 | reason := "disabled" | |
| 741 | - | if o.hdrOn { | |
| 742 | - | reason = "hdr" | |
| 743 | - | } else if o.scanout { | |
| 750 | + | if o.scanout { | |
| 744 | 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 | 767 | if reason == "" { | |
| 747 | 768 | 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, false | 481 | 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 nil | 485 | return nil | ||
| 484 | } | 486 | } | ||
| 485 | 487 | ||||
| @@ -492,8 +494,15 @@ func (o *Output) sendFormats() { | |||||
| 492 | return | 494 | 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 | |||
| 74 | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above" | |
| 75 | 75 | } | |
| 76 | 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 | 78 | pick = w | |
| 79 | 79 | continue | |
| 80 | 80 | } | |
| @@ -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 = w | 78 | pick = w | ||
| 79 | continue | 79 | continue | ||
| 80 | } | 80 | } | ||
M internal/adapters/drm/overlay_test.go +5 -0
| @@ -45,6 +45,11 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 45 | 45 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | |
| 46 | 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 | 53 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}}) | |
| 49 | 54 | if _, _, reason := overlayCandidate(s, c); reason != "window_above" { | |
| 50 | 55 | 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 | |||
| 185 | 185 | 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 | 202 | const ( | |
| 203 | + | fourccNV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24 | |
| 204 | + | fourccP010 = 'P' | '0'<<8 | '1'<<16 | '0'<<24 | |
| 189 | 205 | fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | |
| 190 | 206 | fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24 | |
| 191 | 207 | fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24 | |
| @@ -185,7 +185,23 @@ func (o *Output) scanoutFormats(sampled []ports.DMABufFormat) []ports.DMABufForm | |||||
| 185 | return out | 185 | 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'<<24 | 205 | fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | ||
| 190 | fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24 | 206 | fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24 | ||
| 191 | fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24 | 207 | 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) { | |||
| 107 | 107 | o, k, _ := testOutput(t) | |
| 108 | 108 | o.cursor = nil | |
| 109 | 109 | 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}} | |
| 112 | 112 | ch := make(chan ports.OutputFormats, 1) | |
| 113 | 113 | o.formats = ch | |
| 114 | 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 | 204 | // A plane without IN_FORMATS (driver without modifiers) scans out its | |
| 129 | 205 | // GETPLANE formats, linear. | |
| 130 | 206 | 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 = nil | 108 | o.cursor = nil | ||
| 109 | o.scanout, o.device = on, 9 | 109 | 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 = ch | 113 | 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 its | 204 | // 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 { | |||
| 24 | 24 | LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | |
| 25 | 25 | Width, Height int | |
| 26 | 26 | ScreenshotDir string | |
| 27 | + | HDR bool // virtual HDR output for protocol testing only | |
| 28 | + | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | |
| 27 | 29 | Log zerowrap.Logger | |
| 28 | 30 | NewRenderer func(w, h int) (ports.Renderer, error) | |
| 29 | 31 | // 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 | 41 | return fmt.Errorf("create renderer: %w", err) | |
| 40 | 42 | } | |
| 41 | 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 | 68 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 43 | 69 | var scene ports.Scene | |
| 44 | 70 | 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 int | 25 | Width, Height int | ||
| 26 | ScreenshotDir string | 26 | 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.Logger | 29 | 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 each | 31 | // 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.Scene | 69 | var scene ports.Scene | ||
| 44 | haveScene, dirty := false, false | 70 | haveScene, dirty := false, false | ||
M internal/adapters/headless/output_test.go +50 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/neferwl/internal/logging" | |
| 15 | 16 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 18 | "github.com/stretchr/testify/mock" | |
| @@ -248,3 +249,52 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) { | |||
| 248 | 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 | + | } | |
| @@ -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 ( | |||
| 20 | 20 | ||
| 21 | 21 | //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv | |
| 22 | 22 | //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 | 24 | //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv | |
| 24 | 25 | //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv | |
| 25 | 26 | ||
| @@ -28,6 +29,8 @@ var ( | |||
| 28 | 29 | composeVert []byte | |
| 29 | 30 | //go:embed shaders/compose.frag.spv | |
| 30 | 31 | composeFrag []byte | |
| 32 | + | //go:embed shaders/compose_hdr.frag.spv | |
| 33 | + | composeHDRFrag []byte | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| 33 | 36 | // Draw modes and flags (shaders: modeSolid, modeImage, flag*). | |
| @@ -36,8 +39,12 @@ const ( | |||
| 36 | 39 | modeImage = 1 | |
| 37 | 40 | modeBuffer = 2 | |
| 38 | 41 | ||
| 39 | - | flagOpaque = 1 | |
| 40 | - | flagExact = 2 | |
| 42 | + | flagOpaque = 1 | |
| 43 | + | flagExact = 2 | |
| 44 | + | flagPQ = 4 | |
| 45 | + | flagExtendedLinear = 8 | |
| 46 | + | flagYUV = 16 | |
| 47 | + | flagP010 = 32 | |
| 41 | 48 | ) | |
| 42 | 49 | ||
| 43 | 50 | // pushConstants is struct Draw of the shaders (std430 push constant block). | |
| @@ -60,10 +67,11 @@ type draw struct { | |||
| 60 | 67 | ||
| 61 | 68 | // composer is the pipeline state and the objects every draw can bind. | |
| 62 | 69 | 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 | |
| 67 | 75 | // Placeholders for the binding a draw does not use: every binding | |
| 68 | 76 | // the shader declares must be valid. | |
| 69 | 77 | dummyImage vk.Image | |
| @@ -79,8 +87,9 @@ func (r *Renderer) createComposer() error { | |||
| 79 | 87 | bindings := []vk.DescriptorSetLayoutBinding{ | |
| 80 | 88 | {Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit}, | |
| 81 | 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 | 93 | if err := checked("vkCreateDescriptorSetLayout", d.CreateDescriptorSetLayout(r.device, &sli, nil, &c.setLayout)); err != nil { | |
| 85 | 94 | return err | |
| 86 | 95 | } | |
| @@ -100,7 +109,10 @@ func (r *Renderer) createComposer() error { | |||
| 100 | 109 | } | |
| 101 | 110 | ||
| 102 | 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 | 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 | 200 | // newSet allocates a descriptor set from its own pool (the bindings lack | |
| 189 | 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 | 203 | 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 | 205 | dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 2, PoolSizes: &sizes[0]} | |
| 194 | 206 | var pool vk.DescriptorPool | |
| 195 | 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 | 214 | return 0, 0, err | |
| 203 | 215 | } | |
| 204 | 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 | 221 | buf := vk.DescriptorBufferInfo{Buffer: buffer, Range: wholeSize} | |
| 206 | 222 | writes := []vk.WriteDescriptorSet{ | |
| 207 | 223 | {SType: vk.StructureTypeWriteDescriptorSet, DstSet: set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}, | |
| 208 | 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 | 228 | return pool, set, nil | |
| 212 | 229 | } | |
| 213 | 230 | ||
| @@ -232,6 +249,9 @@ func (r *Renderer) destroyComposer() { | |||
| 232 | 249 | if c.pipeline != 0 { | |
| 233 | 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 | 255 | if c.layout != 0 { | |
| 236 | 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 | 319 | func (r *Renderer) recordDraws(cmd vk.CommandBuffer, view vk.ImageView, bg [3]uint8, ds []draw, keep bool) { | |
| 300 | 320 | d, c := r.dd, &r.compose | |
| 301 | 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 | 327 | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpClear, StoreOp: vk.AttachmentStoreOpStore, ClearValue: clear} | |
| 303 | 328 | if keep { | |
| 304 | 329 | attachment.LoadOp = vk.AttachmentLoadOpLoad | |
| 305 | 330 | } | |
| 306 | 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 | 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 | 338 | var bound vk.DescriptorSet | |
| 310 | 339 | for i := range ds { | |
| 311 | 340 | 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.spv | 21 | //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.spv | 22 | //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.spv | 24 | //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.spv | 25 | //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 []byte | 29 | composeVert []byte | ||
| 29 | //go:embed shaders/compose.frag.spv | 30 | //go:embed shaders/compose.frag.spv | ||
| 30 | composeFrag []byte | 31 | 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 = 1 | 39 | modeImage = 1 | ||
| 37 | modeBuffer = 2 | 40 | modeBuffer = 2 | ||
| 38 | 41 | ||||
| 39 | - | flagOpaque = 1 | 42 | + | flagOpaque = 1 |
| 40 | - | flagExact = 2 | 43 | + | 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.DescriptorSetLayout | 70 | + | setLayout vk.DescriptorSetLayout |
| 64 | - | layout vk.PipelineLayout | 71 | + | layout vk.PipelineLayout |
| 65 | - | pipeline vk.Pipeline | 72 | + | pipeline vk.Pipeline |
| 66 | - | sampler vk.Sampler | 73 | + | hdrPipeline vk.Pipeline |
| 74 | + | sampler vk.Sampler | |||
| 67 | // Placeholders for the binding a draw does not use: every binding | 75 | // 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.Image | 77 | 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 err | 94 | 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 lack | 200 | // 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.dd | 203 | 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.DescriptorPool | 206 | 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, err | 214 | 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, nil | 228 | 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.compose | 320 | 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.AttachmentLoadOpLoad | 329 | 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.DescriptorSet | 338 | 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 { | |||
| 41 | 41 | {fourcc('X', 'R', '2', '4'), vk.FormatB8g8r8a8Unorm, true}, | |
| 42 | 42 | {fourcc('A', 'B', '2', '4'), vk.FormatR8g8b8a8Unorm, false}, | |
| 43 | 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 | 62 | func fourcc(a, b, c, d byte) uint32 { | |
| @@ -85,8 +101,9 @@ func cstring(b []byte) string { | |||
| 85 | 101 | return string(b) | |
| 86 | 102 | } | |
| 87 | 103 | ||
| 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. | |
| 90 | 107 | func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport { | |
| 91 | 108 | var sup ports.DMABufSupport | |
| 92 | 109 | drm := vk.PhysicalDeviceDrmPropertiesEXT{SType: vk.StructureTypePhysicalDeviceDRMPropertiesEXT} | |
| @@ -106,13 +123,15 @@ func (r *Renderer) probeDMABuf(physical vk.PhysicalDevice) ports.DMABufSupport { | |||
| 106 | 123 | list.DrmFormatModifierProperties = &mods[0] | |
| 107 | 124 | r.id.GetPhysicalDeviceFormatProperties2(physical, f.format, &fp) | |
| 108 | 125 | for _, m := range mods[:list.DrmFormatModifierCount] { | |
| 109 | - | // Multi-plane modifiers (compression metadata) are left to a | |
| 110 | - | // later step. | |
| 111 | 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 | 132 | 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 | 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 | 142 | // importable asks whether an image of that format and modifier can be | |
| 124 | 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 | 145 | mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive} | |
| 127 | 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 | 183 | extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties} | |
| 130 | 184 | out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)} | |
| 131 | 185 | if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success { | |
| @@ -140,12 +194,19 @@ func (r *Renderer) DMABuf() ports.DMABufSupport { return r.dmabuf } | |||
| 140 | 194 | // imported is a client dmabuf bound to a VkImage, with the view and set | |
| 141 | 195 | // the compose shader samples it through. | |
| 142 | 196 | 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 | |
| 149 | 210 | // last is the frame that drew it, for eviction. | |
| 150 | 211 | last uint64 | |
| 151 | 212 | } | |
| @@ -164,9 +225,18 @@ func (r *Renderer) importDMABuf(b *ports.DMABuf) (*imported, error) { | |||
| 164 | 225 | return nil, fmt.Errorf("dmabuf unsupported") | |
| 165 | 226 | } | |
| 166 | 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 | 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 | 240 | fd, err := dupFile(b.Planes[0].File) | |
| 171 | 241 | if err != nil { | |
| 172 | 242 | return nil, err | |
| @@ -246,15 +316,30 @@ func (r *Renderer) release(im *imported) { | |||
| 246 | 316 | if im.view != 0 { | |
| 247 | 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 | 322 | if im.image != 0 { | |
| 250 | 323 | r.dd.DestroyImage(r.device, im.image, nil) | |
| 251 | 324 | } | |
| 252 | 325 | if im.memory != 0 { | |
| 253 | 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 | 333 | if im.fd >= 0 { | |
| 256 | 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 | 345 | // dropUnused frees imports not drawn for importTTL frames (the client | |
| @@ -279,15 +364,27 @@ type dmaBufSync struct { | |||
| 279 | 364 | const ioctlExportSyncFile = 0xc0086202 | |
| 280 | 365 | const dmaBufSyncRead = 1 | |
| 281 | 366 | ||
| 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 | + | } | |
| 289 | 386 | } | |
| 290 | - | return r.importSyncFD(int(arg.fd)) | |
| 387 | + | return waits | |
| 291 | 388 | } | |
| 292 | 389 | ||
| 293 | 390 | // 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 as | 104 | + | // 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.DMABufSupport | 108 | 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 | continue | 132 | 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 be | 142 | // 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 set | 194 | // 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.Image | 197 | + | image vk.Image |
| 144 | - | memory vk.DeviceMemory | 198 | + | memory vk.DeviceMemory |
| 145 | - | view vk.ImageView | 199 | + | view vk.ImageView |
| 146 | - | pool vk.DescriptorPool | 200 | + | chromaView vk.ImageView |
| 147 | - | set vk.DescriptorSet | 201 | + | memories [2]vk.DeviceMemory |
| 148 | - | fd int // our duplicate of plane 0, for implicit sync | 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 | |||
| 149 | // last is the frame that drew it, for eviction. | 210 | // last is the frame that drew it, for eviction. | ||
| 150 | last uint64 | 211 | 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, err | 242 | 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 client | 345 | // dropUnused frees imports not drawn for importTTL frames (the client | ||
| @@ -279,15 +364,27 @@ type dmaBufSync struct { | |||||
| 279 | const ioctlExportSyncFile = 0xc0086202 | 364 | const ioctlExportSyncFile = 0xc0086202 | ||
| 280 | const dmaBufSyncRead = 1 | 365 | const dmaBufSyncRead = 1 | ||
| 281 | 366 | ||||
| 282 | - | // readFence returns a semaphore signalled when the client's pending writes | 367 | + | // appendReadFences waits on every distinct backing object's pending writes. |
| 283 | - | // to the buffer finish, or 0 when the kernel cannot export them (older than | 368 | + | // 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 0 | 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 | + | } | |||
| 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 the | 390 | // 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) { | |||
| 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 | 130 | // A subsurface dmabuf is read after its own acquire fence, not the root's. | |
| 112 | 131 | func TestRendererSubsurfaceAcquire(t *testing.T) { | |
| 113 | 132 | 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 | |||
| 170 | 170 | } | |
| 171 | 171 | tg := r.target() | |
| 172 | 172 | 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 | 176 | if hdr { | |
| 174 | - | tg = &r.own | |
| 177 | + | tg = &r.hdrOwn | |
| 175 | 178 | } | |
| 176 | 179 | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | |
| 177 | 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 | 249 | // acquire fence, else the buffer's implicit fences. | |
| 247 | 250 | var waits []vk.Semaphore | |
| 248 | 251 | for _, im := range dmas { | |
| 249 | - | sem := vk.Semaphore(0) | |
| 250 | 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 | 256 | } else { | |
| 253 | - | sem = r.readFence(im) | |
| 254 | - | } | |
| 255 | - | if sem != 0 { | |
| 256 | - | waits = append(waits, sem) | |
| 257 | + | waits = r.appendReadFences(waits, im) | |
| 257 | 258 | } | |
| 258 | 259 | } | |
| 259 | 260 | frame := r.frame | |
| @@ -278,7 +279,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 278 | 279 | runtime.KeepAlive(waits) | |
| 279 | 280 | slot.frame, slot.busy, r.submitted = frame, true, frame | |
| 280 | 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 | 283 | tg.layout = vk.ImageLayoutTransferSrcOptimal | |
| 283 | 284 | r.targets[r.current].layout = vk.ImageLayoutGeneral | |
| 284 | 285 | } else { | |
| @@ -320,6 +321,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 320 | 321 | func (r *Renderer) ownership(cmd vk.CommandBuffer, dmas []*imported, acquire bool) { | |
| 321 | 322 | for _, im := range dmas { | |
| 322 | 323 | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, Image: im.image, SubresourceRange: colorRange} | |
| 324 | + | if im.yuv { | |
| 325 | + | b.SubresourceRange.AspectMask = aspectPlane0 | aspectPlane1 | |
| 326 | + | } | |
| 323 | 327 | if acquire { | |
| 324 | 328 | b.OldLayout, b.NewLayout = vk.ImageLayoutGeneral, vk.ImageLayoutShaderReadOnlyOptimal | |
| 325 | 329 | 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) > 0 | 172 | 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.own | 177 | + | 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.Semaphore | 250 | 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.frame | 260 | 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, frame | 280 | 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.ImageLayoutTransferSrcOptimal | 283 | tg.layout = vk.ImageLayoutTransferSrcOptimal | ||
| 283 | r.targets[r.current].layout = vk.ImageLayoutGeneral | 284 | 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.ImageLayoutShaderReadOnlyOptimal | 328 | b.OldLayout, b.NewLayout = vk.ImageLayoutGeneral, vk.ImageLayoutShaderReadOnlyOptimal | ||
| 325 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family | 329 | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = vk.QueueFamilyForeignEXT, r.family | ||
M internal/adapters/vulkan/hdr.go +79 -3
| @@ -2,6 +2,7 @@ package vulkan | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | _ "embed" | |
| 5 | + | "fmt" | |
| 5 | 6 | "unsafe" | |
| 6 | 7 | ||
| 7 | 8 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| @@ -28,6 +29,21 @@ type hdrPass struct { | |||
| 28 | 29 | func (r *Renderer) SetHDR(nits float64) { | |
| 29 | 30 | if nits != r.hdrNits { | |
| 30 | 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 | 48 | r.hdrNits = nits | |
| 33 | 49 | if nits <= 0 { | |
| @@ -35,6 +51,29 @@ func (r *Renderer) SetHDR(nits float64) { | |||
| 35 | 51 | r.hdrError = nil | |
| 36 | 52 | 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 | 77 | if r.hdr.pipeline != 0 { | |
| 39 | 78 | return | |
| 40 | 79 | } | |
| @@ -42,7 +81,44 @@ func (r *Renderer) SetHDR(nits float64) { | |||
| 42 | 81 | r.hdrError = r.createHDR() | |
| 43 | 82 | if r.hdrError != nil { | |
| 44 | 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 | 124 | func (r *Renderer) createHDR() error { | |
| @@ -73,7 +149,7 @@ func (r *Renderer) createHDR() error { | |||
| 73 | 149 | if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil { | |
| 74 | 150 | 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 | 153 | write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img} | |
| 78 | 154 | d.UpdateDescriptorSets(r.device, 1, &write, 0, nil) | |
| 79 | 155 | return nil | |
| @@ -102,10 +178,10 @@ func (r *Renderer) destroyHDR() { | |||
| 102 | 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 | 182 | func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) { | |
| 107 | 183 | 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 | 185 | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | |
| 110 | 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 | 187 | 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 = nits | 48 | r.hdrNits = nits | ||
| 33 | if nits <= 0 { | 49 | if nits <= 0 { | ||
| @@ -35,6 +51,29 @@ func (r *Renderer) SetHDR(nits float64) { | |||||
| 35 | r.hdrError = nil | 51 | r.hdrError = nil | ||
| 36 | return | 52 | 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 | return | 78 | 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 err | 150 | 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 nil | 155 | 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.hdr | 183 | 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, ®ion) | |
| 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, ®ion) | |||
| 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 { | |||
| 22 | 22 | device vk.Device | |
| 23 | 23 | dd *vk.DeviceDispatch | |
| 24 | 24 | 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. | |
| 27 | 26 | own target | |
| 27 | + | hdrOwn target // linear BT.709 in units of SDR reference white | |
| 28 | 28 | targets []*target | |
| 29 | 29 | current int | |
| 30 | 30 | renderMods []uint64 | |
| @@ -39,14 +39,15 @@ type Renderer struct { | |||
| 39 | 39 | cursorCache cursorConversion | |
| 40 | 40 | // last is the target of the last frame; readback copies it into | |
| 41 | 41 | // 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 | |
| 50 | 51 | // Frames in flight (frame.go): frame is the last frame recorded, | |
| 51 | 52 | // submitted the last submitted, completed the last known finished. | |
| 52 | 53 | slots [frameSlots]frameSlot | |
| @@ -221,28 +222,7 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 221 | 222 | if err = checked("vkBindImageMemory", r.dd.BindImageMemory(r.device, r.own.image, r.own.memory, 0)); err != nil { | |
| 222 | 223 | return | |
| 223 | 224 | } | |
| 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 { | |
| 246 | 226 | return | |
| 247 | 227 | } | |
| 248 | 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 | 242 | 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 | 304 | func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (uint32, error) { | |
| 266 | 305 | for i := uint32(0); i < r.memory.MemoryTypeCount; i++ { | |
| 267 | 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 | 315 | if r.mapped == nil || r.readback() != nil { | |
| 277 | 316 | 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 | 322 | src := unsafe.Slice((*byte)(r.mapped), len(out.Pix)) | |
| 280 | 323 | for i := 0; i < len(src); i += 4 { | |
| 281 | 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 | 340 | if err := r.readback(); err != nil { | |
| 298 | 341 | 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 | 347 | src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4) | |
| 301 | 348 | for y := range h { | |
| 302 | 349 | start := ((region.Min.Y+y)*r.width + region.Min.X) * 4 | |
| @@ -393,6 +440,7 @@ func (r *Renderer) Close() { | |||
| 393 | 440 | r.bufferMemory = 0 | |
| 394 | 441 | } | |
| 395 | 442 | r.destroyComposer() | |
| 443 | + | r.freeTarget(&r.hdrOwn) | |
| 396 | 444 | r.freeTarget(&r.own) | |
| 397 | 445 | d.DestroyDevice(r.device, nil) | |
| 398 | 446 | r.device = 0 | |
| @@ -22,9 +22,9 @@ type Renderer struct { | |||||
| 22 | device vk.Device | 22 | device vk.Device | ||
| 23 | dd *vk.DeviceDispatch | 23 | dd *vk.DeviceDispatch | ||
| 24 | queue vk.Queue | 24 | queue vk.Queue | ||
| 25 | - | // own is the composed image until ExportTargets; in HDR it remains | 25 | + | // own is the SDR composition image; HDR uses hdrOwn and PQ targets. |
| 26 | - | // the SDR composition/readback image while targets carry PQ. | |||
| 27 | own target | 26 | own target | ||
| 27 | + | hdrOwn target // linear BT.709 in units of SDR reference white | |||
| 28 | targets []*target | 28 | targets []*target | ||
| 29 | current int | 29 | current int | ||
| 30 | renderMods []uint64 | 30 | renderMods []uint64 | ||
| @@ -39,14 +39,15 @@ type Renderer struct { | |||||
| 39 | cursorCache cursorConversion | 39 | cursorCache cursorConversion | ||
| 40 | // last is the target of the last frame; readback copies it into | 40 | // 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 *target | 42 | + | last *target |
| 43 | - | readBack bool | 43 | + | readBack bool |
| 44 | - | buffer vk.Buffer | 44 | + | buffer vk.Buffer |
| 45 | - | bufferMemory vk.DeviceMemory | 45 | + | bufferMemory vk.DeviceMemory |
| 46 | - | mapped unsafe.Pointer | 46 | + | readbackBytes int // bytes per pixel allocated for the mapped readback buffer |
| 47 | - | pool vk.CommandPool | 47 | + | mapped unsafe.Pointer |
| 48 | - | memory vk.PhysicalDeviceMemoryProperties | 48 | + | pool vk.CommandPool |
| 49 | - | family uint32 | 49 | + | 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]frameSlot | 53 | 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 | return | 223 | return | ||
| 223 | } | 224 | } | ||
| 224 | - | size := vk.DeviceSize(width) * vk.DeviceSize(height) * 4 | 225 | + | 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 | return | 226 | 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 | return | 242 | 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 out | 316 | 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 err | 341 | 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) * 4 | 349 | start := ((region.Min.Y+y)*r.width + region.Min.X) * 4 | ||
| @@ -393,6 +440,7 @@ func (r *Renderer) Close() { | |||||
| 393 | r.bufferMemory = 0 | 440 | 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 = 0 | 446 | 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 | |||
| 250 | 250 | return | |
| 251 | 251 | } | |
| 252 | 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 | 268 | dr.set, dr.im = im.set, im | |
| 254 | 269 | dr.acquire = content.Acquire | |
| 255 | 270 | w.draws = append(w.draws, dr) | |
| @@ -271,11 +286,27 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 271 | 286 | return | |
| 272 | 287 | } | |
| 273 | 288 | dr := r.contentDraw(rect, full, content.Width, content.Height, modeBuffer, content.Opaque) | |
| 289 | + | r.setContentColor(&dr, content.Color) | |
| 274 | 290 | dr.set = c.set | |
| 275 | 291 | dr.pc.buf = [4]uint32{0, uint32(content.Width), uint32(content.Height), 0} | |
| 276 | 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 | 310 | func parseColor(s string) [3]uint8 { | |
| 280 | 311 | var c [3]uint8 | |
| 281 | 312 | if len(s) != 7 || s[0] != '#' { | |
| @@ -250,6 +250,21 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||||
| 250 | return | 250 | 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, im | 268 | dr.set, dr.im = im.set, im | ||
| 254 | dr.acquire = content.Acquire | 269 | 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 | return | 286 | 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.set | 290 | 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]uint8 | 311 | 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 { | |||
| 15 | 15 | ||
| 16 | 16 | layout(set = 0, binding = 0) uniform sampler2D tex; | |
| 17 | 17 | layout(std430, set = 0, binding = 1) readonly buffer Pixels { uint pixels[]; }; | |
| 18 | + | layout(set = 0, binding = 2) uniform sampler2D chromaTex; | |
| 18 | 19 | ||
| 19 | 20 | layout(location = 0) out vec4 color; | |
| 20 | 21 | ||
| @@ -22,6 +23,53 @@ const uint modeSolid = 0u; | |||
| 22 | 23 | const uint modeImage = 1u; | |
| 23 | 24 | const uint flagOpaque = 1u; | |
| 24 | 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 | 74 | // texel reads buffer pixel p, clamped to the buffer (clamp to edge). | |
| 27 | 75 | vec4 texel(ivec2 p) { | |
| @@ -36,6 +84,8 @@ void main() { | |||
| 36 | 84 | vec4 c; | |
| 37 | 85 | if (d.misc.x == modeSolid) { | |
| 38 | 86 | c = d.color; | |
| 87 | + | } else if (d.misc.x == modeImage && (d.misc.y & flagYUV) != 0u) { | |
| 88 | + | c = sampleYUV(src); | |
| 39 | 89 | } else if (d.misc.x == modeImage) { | |
| 40 | 90 | ivec2 size = textureSize(tex, 0); | |
| 41 | 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 | 101 | if ((d.misc.y & flagOpaque) != 0u) { | |
| 52 | 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 | 112 | color = c; | |
| 55 | 113 | } | |
| @@ -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; | |||
| 3 | 3 | layout(push_constant) uniform Brightness { float nits; } pc; | |
| 4 | 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 | 6 | vec3 encodePQ(vec3 nits) { | |
| 10 | 7 | vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0)); | |
| 11 | 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 | 10 | void main() { | |
| 14 | 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 | 13 | vec3 bt2020 = mat3( | |
| 17 | 14 | 0.627404, 0.069097, 0.016391, | |
| 18 | 15 | 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 ( | |||
| 16 | 16 | // display accepts and exports their memory as dmabufs, which the output | |
| 17 | 17 | // turns into KMS framebuffers. Render then composes straight into the | |
| 18 | 18 | // 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. | |
| 21 | 22 | ||
| 22 | 23 | // target is an image frames are drawn into. | |
| 23 | 24 | type target struct { | |
| @@ -45,6 +46,10 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |||
| 45 | 46 | // the previous ones; n = 0 only drops them (back to the internal image). | |
| 46 | 47 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | |
| 47 | 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 | 53 | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | |
| 49 | 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 | 109 | available = r.hdrMods | |
| 105 | 110 | } | |
| 106 | 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 | 113 | out = append(out, m) | |
| 109 | 114 | } | |
| 110 | 115 | } | |
| @@ -16,8 +16,9 @@ import ( | |||||
| 16 | // display accepts and exports their memory as dmabufs, which the output | 16 | // display accepts and exports their memory as dmabufs, which the output | ||
| 17 | // turns into KMS framebuffers. Render then composes straight into the | 17 | // turns into KMS framebuffers. Render then composes straight into the | ||
| 18 | // selected target and the output flips to it; nothing is copied by the | 18 | // 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 | 19 | + | // 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.hdrMods | 109 | 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) { | |||
| 120 | 120 | t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before) | |
| 121 | 121 | } | |
| 122 | 122 | 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) | |
| 125 | 125 | } | |
| 126 | 126 | if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 { | |
| 127 | 127 | 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 { | |||
| 270 | 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 | 280 | // build validates the planes and turns them into a buffer; ok is false after | |
| 274 | 281 | // a protocol error, failed means the client gets `failed`. | |
| 275 | 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 | 304 | if flags != 0 || !slices.Contains(p.global.support.Formats, want) { | |
| 298 | 305 | return nil, true, true | |
| 299 | 306 | } | |
| 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") | |
| 303 | 316 | return nil, false, false | |
| 304 | 317 | } | |
| 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") | |
| 311 | 320 | 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 | 343 | p.global.nextID++ | |
| 314 | 344 | d := &ports.DMABuf{ID: p.global.nextID, Width: int(width), Height: int(height), Format: format, Modifier: p.modifier} | |
| 315 | 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 | 407 | // content hands the renderer the buffer itself: nothing is copied. | |
| 378 | 408 | 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 | |
| 380 | 410 | return ports.SurfaceContent{ID: id, Width: b.buf.Width, Height: b.buf.Height, Opaque: opaque, DMABuf: b.buf}, true | |
| 381 | 411 | } | |
| 382 | 412 | ||
| 383 | 413 | const ( | |
| 384 | 414 | fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | |
| 385 | 415 | 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 | ) | |
| @@ -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 after | 280 | // 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, true | 305 | 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, false | 316 | return nil, false, false | ||
| 304 | } | 317 | } | ||
| 305 | - | pl := p.planes[0] | 318 | + | if yuv && (width%2 != 0 || height%2 != 0) { |
| 306 | - | row := uint64(width) * 4 | 319 | + | 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, false | 320 | 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 == fourccXBGR | 409 | + | 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}, true | 410 | 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'<<24 | 414 | fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | ||
| 385 | fourccXBGR = 'X' | 'B'<<8 | '2'<<16 | '4'<<24 | 415 | 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 := ¶msProxy{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 := ¶msProxy{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 { | |||
| 198 | 198 | ||
| 199 | 199 | // update is a surface's pending state taken out by a commit that waits. | |
| 200 | 200 | 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 | |
| 224 | 226 | } | |
| 225 | 227 | ||
| 226 | 228 | type childMove struct { | |
| @@ -231,7 +233,7 @@ type childMove struct { | |||
| 231 | 233 | // takePending moves the pending state into an update: one-shot state is | |
| 232 | 234 | // cleared, sticky state (scale, hints, viewport, geometry, layer) kept. | |
| 233 | 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 | 237 | s.attached, s.pending, s.callbacks = false, nil, nil | |
| 236 | 238 | s.pendingFeedback, s.pendingSync = nil, nil | |
| 237 | 239 | s.pendingDamage, s.pendingBufDamage = nil, nil | |
| @@ -262,6 +264,8 @@ func (s *surface) takePending() update { | |||
| 262 | 264 | func (s *surface) putPending(u update) { | |
| 263 | 265 | s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks | |
| 264 | 266 | s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind | |
| 267 | + | s.pendingColor = u.color | |
| 268 | + | s.pendingRepresentation = u.representation | |
| 265 | 269 | s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at | |
| 266 | 270 | s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage | |
| 267 | 271 | 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 bool | 201 | + | attached bool |
| 202 | - | buffer *wayland.Buffer | 202 | + | buffer *wayland.Buffer |
| 203 | - | scale int | 203 | + | scale int |
| 204 | - | async bool | 204 | + | async bool |
| 205 | - | kind uint32 | 205 | + | kind uint32 |
| 206 | - | callbacks []*wayland.Callback | 206 | + | callbacks []*wayland.Callback |
| 207 | - | vp *viewport | 207 | + | vp *viewport |
| 208 | - | vpW, vpH int32 | 208 | + | vpW, vpH int32 |
| 209 | - | vpSet bool | 209 | + | vpSet bool |
| 210 | - | moves []childMove | 210 | + | moves []childMove |
| 211 | - | xdg *xdgSurface | 211 | + | xdg *xdgSurface |
| 212 | - | geometry ports.Rect | 212 | + | geometry ports.Rect |
| 213 | - | cons *constraint | 213 | + | cons *constraint |
| 214 | - | region *ports.Rect | 214 | + | region *ports.Rect |
| 215 | - | layer *layerSurface | 215 | + | layer *layerSurface |
| 216 | - | layerNext layerState | 216 | + | layerNext layerState |
| 217 | - | barrier bool | 217 | + | barrier bool |
| 218 | - | wait bool | 218 | + | wait bool |
| 219 | - | at time.Time | 219 | + | at time.Time |
| 220 | - | feedback []*presentationtime.WpPresentationFeedback | 220 | + | feedback []*presentationtime.WpPresentationFeedback |
| 221 | - | sync *commitSync | 221 | + | sync *commitSync |
| 222 | - | damage []ports.Rect | 222 | + | color SurfaceColor |
| 223 | - | bufDamage []ports.Rect | 223 | + | 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 is | 233 | // 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, nil | 237 | s.attached, s.pending, s.callbacks = false, nil, nil | ||
| 236 | s.pendingFeedback, s.pendingSync = nil, nil | 238 | s.pendingFeedback, s.pendingSync = nil, nil | ||
| 237 | s.pendingDamage, s.pendingBufDamage = nil, nil | 239 | 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.callbacks | 265 | s.attached, s.pending, s.callbacks = u.attached, u.buffer, u.callbacks | ||
| 264 | s.pendingScale, s.pendingAsync, s.pendingKind = u.scale, u.async, u.kind | 266 | 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.at | 269 | s.pendingBarrier, s.pendingWait, s.pendingTime = u.barrier, u.wait, u.at | ||
| 266 | s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage | 270 | s.pendingDamage, s.pendingBufDamage = u.damage, u.bufDamage | ||
| 267 | s.pendingFeedback, s.pendingSync = u.feedback, u.sync | 271 | 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 { | |||
| 19 | 19 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | |
| 20 | 20 | func() error { return registerPointerConstraints(d, s) }, | |
| 21 | 21 | func() error { return registerTearing(d, s) }, | |
| 22 | + | func() error { return registerColorManagement(d, s) }, | |
| 23 | + | func() error { return registerColorRepresentation(d, s) }, | |
| 22 | 24 | func() error { return registerPresentationConstraints(d, s) }, | |
| 23 | 25 | func() error { return registerPresentation(d, s) }, | |
| 24 | 26 | 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 ( | |||
| 11 | 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 | 31 | // Outputs. Core sends the global layout (ports.SetOutputs); each output is | |
| 15 | 32 | // one wl_output global, removed when the display is unplugged. Every size | |
| 16 | 33 | // sent to clients (configures, xdg-output, layer configures, pointer | |
| @@ -155,6 +172,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 155 | 172 | continue | |
| 156 | 173 | } | |
| 157 | 174 | o.global.Remove() | |
| 175 | + | delete(s.hdrOutputs, o.name()) | |
| 176 | + | delete(s.colorIdentity, o.name()) | |
| 158 | 177 | for session := range s.captureSessions { | |
| 159 | 178 | if session.o == o && !session.stopped { | |
| 160 | 179 | session.stopped = true | |
| @@ -208,6 +227,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 208 | 227 | for _, surf := range s.surfaces { | |
| 209 | 228 | surf.sendScale() | |
| 210 | 229 | } | |
| 230 | + | s.notifyColorFeedback() | |
| 211 | 231 | // Layer surfaces sized from the output follow its new logical size. | |
| 212 | 232 | adopted := false | |
| 213 | 233 | 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 is | 31 | // Outputs. Core sends the global layout (ports.SetOutputs); each output is | ||
| 15 | // one wl_output global, removed when the display is unplugged. Every size | 32 | // one wl_output global, removed when the display is unplugged. Every size | ||
| 16 | // sent to clients (configures, xdg-output, layer configures, pointer | 33 | // sent to clients (configures, xdg-output, layer configures, pointer | ||
| @@ -155,6 +172,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 155 | continue | 172 | 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 = true | 179 | 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 := false | 232 | 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 ( | |||
| 12 | 12 | ||
| 13 | 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 | 32 | // outputCount counts wl_output globals the client sees. | |
| 16 | 33 | func outputCount(c *wlturbo.Display) int { | |
| 17 | 34 | 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 := 0 | 34 | n := 0 | ||
M internal/adapters/wayland/scale.go +6 -0
| @@ -53,6 +53,12 @@ func (surf *surface) sendScale() { | |||
| 53 | 53 | surf.leave(surf.on) | |
| 54 | 54 | } | |
| 55 | 55 | 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 | 62 | if o != nil { | |
| 57 | 63 | for _, r := range o.resources { | |
| 58 | 64 | 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 = o | 55 | 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 { | |||
| 86 | 86 | surfaces map[*server.Resource]*surface | |
| 87 | 87 | buffers map[*server.Resource]clientBuffer | |
| 88 | 88 | 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 | |
| 90 | 97 | // press is the serial of the last button or key press, sent to | |
| 91 | 98 | // pressClient: popup grabs must come from it. | |
| 92 | 99 | press uint32 | |
| @@ -234,6 +241,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 234 | 241 | 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 | 248 | s.tokens = map[string]activationToken{} | |
| 238 | 249 | if opts.Keymap != "" { | |
| 239 | 250 | fd, size, e := keymapFile(opts.Keymap) | |
| @@ -390,6 +401,7 @@ func (s *Server) forwardOutputFormats(ctx context.Context) { | |||
| 390 | 401 | return | |
| 391 | 402 | } | |
| 392 | 403 | if !s.display.Do(func() { | |
| 404 | + | s.setOutputHDR(f) | |
| 393 | 405 | if s.dmabuf != nil { | |
| 394 | 406 | s.dmabuf.setOutputFormats(f) | |
| 395 | 407 | } | |
| @@ -662,6 +674,9 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 662 | 674 | if surf := w.xdg.surface; surf != nil && c.Output != "" { | |
| 663 | 675 | surf.sendTreeScale() | |
| 664 | 676 | } | |
| 677 | + | if scanoutChanged { | |
| 678 | + | s.notifyColorFeedback() | |
| 679 | + | } | |
| 665 | 680 | case ports.SetOutputs: | |
| 666 | 681 | s.setOutputs(c) | |
| 667 | 682 | off := map[string]bool{} | |
| @@ -86,7 +86,14 @@ type Server struct { | |||||
| 86 | surfaces map[*server.Resource]*surface | 86 | surfaces map[*server.Resource]*surface | ||
| 87 | buffers map[*server.Resource]clientBuffer | 87 | buffers map[*server.Resource]clientBuffer | ||
| 88 | dmabuf *dmabufGlobal | 88 | 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 | |||
| 90 | // press is the serial of the last button or key press, sent to | 97 | // 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 uint32 | 99 | press uint32 | ||
| @@ -234,6 +241,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 234 | s.syncDev = node | 241 | 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 | return | 401 | 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 { | |||
| 48 | 48 | sub subState | |
| 49 | 49 | // tearing is the surface's wp_tearing_control_v1; async is the | |
| 50 | 50 | // 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 | |
| 53 | 57 | // Presentation constraints (fifo.go): pending fifo requests and commit | |
| 54 | 58 | // timestamp, the barrier, and the commits waiting to apply. | |
| 55 | 59 | fifo *fifoHandler | |
| @@ -124,10 +128,22 @@ func (s *surface) windowID() ports.WindowID { | |||
| 124 | 128 | 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 | 142 | // tree is the root's content with its subsurfaces flattened. | |
| 128 | 143 | func (s *surface) tree(id ports.WindowID) ports.SurfaceContent { | |
| 129 | 144 | c := s.content | |
| 130 | 145 | c.ID = id | |
| 146 | + | c.Color = s.outputColor() | |
| 131 | 147 | c.Children = nil | |
| 132 | 148 | for _, ch := range s.sub.children { | |
| 133 | 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 | 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 | 169 | for _, ch := range s.sub.children { | |
| 154 | 170 | if !ch.sub.below { | |
| @@ -194,6 +210,8 @@ func (s *surface) Destroy(*wayland.Surface) { | |||
| 194 | 210 | } | |
| 195 | 211 | s.pendingFeedback = nil | |
| 196 | 212 | s.tearing = nil // the control becomes inert | |
| 213 | + | s.colorControl = nil | |
| 214 | + | s.representationControl = nil | |
| 197 | 215 | s.dropQueue() | |
| 198 | 216 | if s.server.cursorSurface == s { | |
| 199 | 217 | // The pointer keeps no cursor until the client sets another. | |
| @@ -243,7 +261,7 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 243 | 261 | s.xdg.resource.PostError(uint32(xdgshell.SurfaceErrorUnconfiguredBuffer), "buffer before initial configure ack") | |
| 244 | 262 | return | |
| 245 | 263 | } | |
| 246 | - | if !s.checkSyncCommit() { | |
| 264 | + | if !s.checkSyncCommit() || !s.checkRepresentationCommit() { | |
| 247 | 265 | return | |
| 248 | 266 | } | |
| 249 | 267 | s.takeSyncPoints() | |
| @@ -271,8 +289,10 @@ func (s *surface) applyCommit() { | |||
| 271 | 289 | if s.pendingScale > 0 { | |
| 272 | 290 | s.bufferScale = s.pendingScale | |
| 273 | 291 | } | |
| 274 | - | hinted := s.async != s.pendingAsync | |
| 292 | + | hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation | |
| 275 | 293 | s.async = s.pendingAsync | |
| 294 | + | s.color = s.pendingColor | |
| 295 | + | s.representation = s.pendingRepresentation | |
| 276 | 296 | if s.viewport != nil { | |
| 277 | 297 | s.viewport.commit() | |
| 278 | 298 | } | |
| @@ -48,8 +48,12 @@ type surface struct { | |||||
| 48 | sub subState | 48 | sub subState | ||
| 49 | // tearing is the surface's wp_tearing_control_v1; async is the | 49 | // 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 *tearingHandler | 51 | + | tearing *tearingHandler |
| 52 | - | async, pendingAsync bool | 52 | + | 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 commit | 57 | // 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 *fifoHandler | 59 | fifo *fifoHandler | ||
| @@ -124,10 +128,22 @@ func (s *surface) windowID() ports.WindowID { | |||||
| 124 | return 0 | 128 | 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.content | 144 | c := s.content | ||
| 130 | c.ID = id | 145 | c.ID = id | ||
| 146 | + | c.Color = s.outputColor() | |||
| 131 | c.Children = nil | 147 | 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 = nil | 211 | s.pendingFeedback = nil | ||
| 196 | s.tearing = nil // the control becomes inert | 212 | 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 | return | 262 | return | ||
| 245 | } | 263 | } | ||
| 246 | - | if !s.checkSyncCommit() { | 264 | + | if !s.checkSyncCommit() || !s.checkRepresentationCommit() { |
| 247 | return | 265 | 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.pendingScale | 290 | s.bufferScale = s.pendingScale | ||
| 273 | } | 291 | } | ||
| 274 | - | hinted := s.async != s.pendingAsync | 292 | + | hinted := s.async != s.pendingAsync || s.color != s.pendingColor || s.representation != s.pendingRepresentation |
| 275 | s.async = s.pendingAsync | 293 | 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 { | |||
| 36 | 36 | NoTerminal bool | |
| 37 | 37 | // NoXwayland skips the X11 display even when the config enables it. | |
| 38 | 38 | NoXwayland bool | |
| 39 | + | // HeadlessHDR enables a virtual HDR output for protocol testing only. | |
| 40 | + | HeadlessHDR bool | |
| 39 | 41 | // Session exports the displays to D-Bus and systemd user services | |
| 40 | 42 | // before any client starts, and withdraws them on exit. | |
| 41 | 43 | Session bool | |
| @@ -240,7 +242,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 240 | 242 | }) | |
| 241 | 243 | 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 | 248 | if hw != nil { | |
| @@ -36,6 +36,8 @@ type Options struct { | |||||
| 36 | NoTerminal bool | 36 | 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 bool | 38 | 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 services | 41 | // 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 bool | 43 | Session bool | ||
| @@ -240,7 +242,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 240 | }) | 242 | }) | ||
| 241 | return | 243 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 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 | 17 | set := newOutputSet(ctx, captured) | |
| 18 | 18 | inventory := ports.OutputHeads{} | |
| 19 | 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 | 31 | cur := &headless.Cursor{} | |
| 32 | 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 | 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 | 35 | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| 36 | 36 | }) | |
| @@ -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 { | |||
| 424 | 424 | // OutputFormats carries output → wayland the dmabuf formats an output can | |
| 425 | 425 | // scan out directly (its primary plane's, that the renderer also samples, | |
| 426 | 426 | // 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. | |
| 429 | 431 | type OutputFormats struct { | |
| 430 | 432 | Output string | |
| 431 | 433 | Device uint64 | |
| 432 | 434 | 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 | 444 | // FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time | |
| @@ -731,6 +740,33 @@ type SceneLayer struct { | |||
| 731 | 740 | 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 | 770 | // SurfaceContent carries wayland → output the latest committed content of a | |
| 735 | 771 | // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer. | |
| 736 | 772 | // Renderers read both in place and never modify them. Empty means the window | |
| @@ -745,6 +781,7 @@ type SurfaceContent struct { | |||
| 745 | 781 | Width, Height int | |
| 746 | 782 | LogicalW, LogicalH int | |
| 747 | 783 | Opaque bool // x formats: ignore the alpha byte | |
| 784 | + | Color SurfaceColor | |
| 748 | 785 | SHM *SHMBuffer | |
| 749 | 786 | DMABuf *DMABuf | |
| 750 | 787 | // 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 can | 424 | // 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 device | 426 | // 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 | 427 | + | // (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 string | 432 | Output string | ||
| 431 | Device uint64 | 433 | Device uint64 | ||
| 432 | Formats []DMABufFormat | 434 | 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 time | 444 | // FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time | ||
| @@ -731,6 +740,33 @@ type SceneLayer struct { | |||||
| 731 | Rect Rect | 740 | 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 a | 770 | // 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 window | 772 | // Renderers read both in place and never modify them. Empty means the window | ||
| @@ -745,6 +781,7 @@ type SurfaceContent struct { | |||||
| 745 | Width, Height int | 781 | Width, Height int | ||
| 746 | LogicalW, LogicalH int | 782 | LogicalW, LogicalH int | ||
| 747 | Opaque bool // x formats: ignore the alpha byte | 783 | Opaque bool // x formats: ignore the alpha byte | ||
| 784 | + | Color SurfaceColor | |||
| 748 | SHM *SHMBuffer | 785 | SHM *SHMBuffer | ||
| 749 | DMABuf *DMABuf | 786 | DMABuf *DMABuf | ||
| 750 | // Children are the subsurfaces, bottom to top, flattened. | 787 | // Children are the subsurfaces, bottom to top, flattened. | ||