feat(hdr): output HDR10 on capable displays (#6)
* build(deps): bump purego-libwayland to v0.4.0 * feat(config): add opt-in HDR output settings * feat(drm): detect HDR output capabilities from EDID and KMS * feat(hdr): prepare metadata transfer math and output reload * feat(vulkan): render SDR scenes into HDR10 scanout targets * feat(drm): activate HDR10 modesets with SDR fallback * fix(vulkan): preserve HDR image layouts and bound cursor conversion cache * fix(drm): retain HDR metadata blobs and validate static type 1 * feat(drm): log HDR10 activation and restoration Log HDR10 on (with SDR brightness and display luminance) and off at close; a failed restore after HDR is a warning, since the display may stay in HDR. Drop the duplicate component field on HDR logs: the DRM logger already carries it. * fix(drm): warn on a failed restore while HDR metadata may still be shown After a failed HDR setup and a failed SDR fallback, hdrOn is false but the HDR modeset can remain on screen. Close now treats a live metadata blob as HDR shown, so a failed restore is still a warning. * fix(drm): read connector Colorspace enums with a values buffer DRM_IOCTL_MODE_GETPROPERTY copies an enum's values when count_values is set, so reading the enum names with a nil values pointer failed with EFAULT. Colorspace then looked unsupported and every display was reported not HDR capable.
- commit
- 368d49677d9d13b08b1fbad0b9f0174f99d25fe6
- author
- bnema · · 2026-09-27 05:12 UTC
- committer
- GitHub · · 2026-09-27 05:12 UTC
- parent
- 471cea3
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
36 files · +1445 -76
M docs/config.md +2 -0
| @@ -34,6 +34,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 34 | 34 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 35 | 35 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| 36 | 36 | | `output.<name>.primary` | `off` | Gets focus and pointer at startup | | |
| 37 | + | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | |
| 38 | + | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | |
| 37 | 39 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | |
| 38 | 40 | | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout | | |
| 39 | 41 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| @@ -34,6 +34,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 34 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | 34 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | ||
| 35 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | 35 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | ||
| 36 | | `output.<name>.primary` | `off` | Gets focus and pointer at startup | | 36 | | `output.<name>.primary` | `off` | Gets focus and pointer at startup | | ||
| 37 | + | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | |||
| 38 | + | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | |||
| 37 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | 39 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | ||
| 38 | | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout | | 40 | | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout | | ||
| 39 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 41 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
M internal/adapters/config/config.go +24 -2
| @@ -209,7 +209,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 209 | 209 | return &c.Outputs[i] | |
| 210 | 210 | } | |
| 211 | 211 | outputs[name] = len(c.Outputs) | |
| 212 | - | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | |
| 212 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}) | |
| 213 | 213 | return &c.Outputs[len(c.Outputs)-1] | |
| 214 | 214 | } | |
| 215 | 215 | if base, ok := strings.CutSuffix(name, ".primary"); ok { | |
| @@ -223,6 +223,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 223 | 223 | entry().Primary = b | |
| 224 | 224 | continue | |
| 225 | 225 | } | |
| 226 | + | if base, ok := strings.CutSuffix(name, ".hdr"); ok { | |
| 227 | + | name = base | |
| 228 | + | b, err := onOff(value) | |
| 229 | + | if err != nil { | |
| 230 | + | warn("%s: %v", key, err) | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | override() | |
| 234 | + | entry().HDR = b | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | if base, ok := strings.CutSuffix(name, ".sdr-brightness"); ok { | |
| 238 | + | name = base | |
| 239 | + | nits, err := strconv.Atoi(value) | |
| 240 | + | if err != nil || nits < 80 || nits > 1000 { | |
| 241 | + | warn("%s: must be between 80 and 1000", key) | |
| 242 | + | continue | |
| 243 | + | } | |
| 244 | + | override() | |
| 245 | + | entry().SDRBrightness = nits | |
| 246 | + | continue | |
| 247 | + | } | |
| 226 | 248 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| 227 | 249 | name = base | |
| 228 | 250 | s, err := parseScale(value) | |
| @@ -245,7 +267,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 245 | 267 | } | |
| 246 | 268 | override() | |
| 247 | 269 | e := entry() | |
| 248 | - | o.Scale, o.Primary = e.Scale, e.Primary | |
| 270 | + | o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness | |
| 249 | 271 | *e = o | |
| 250 | 272 | continue | |
| 251 | 273 | } | |
| @@ -209,7 +209,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 209 | return &c.Outputs[i] | 209 | return &c.Outputs[i] | ||
| 210 | } | 210 | } | ||
| 211 | outputs[name] = len(c.Outputs) | 211 | outputs[name] = len(c.Outputs) | ||
| 212 | - | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | 212 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}) |
| 213 | return &c.Outputs[len(c.Outputs)-1] | 213 | return &c.Outputs[len(c.Outputs)-1] | ||
| 214 | } | 214 | } | ||
| 215 | if base, ok := strings.CutSuffix(name, ".primary"); ok { | 215 | if base, ok := strings.CutSuffix(name, ".primary"); ok { | ||
| @@ -223,6 +223,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 223 | entry().Primary = b | 223 | entry().Primary = b | ||
| 224 | continue | 224 | continue | ||
| 225 | } | 225 | } | ||
| 226 | + | if base, ok := strings.CutSuffix(name, ".hdr"); ok { | |||
| 227 | + | name = base | |||
| 228 | + | b, err := onOff(value) | |||
| 229 | + | if err != nil { | |||
| 230 | + | warn("%s: %v", key, err) | |||
| 231 | + | continue | |||
| 232 | + | } | |||
| 233 | + | override() | |||
| 234 | + | entry().HDR = b | |||
| 235 | + | continue | |||
| 236 | + | } | |||
| 237 | + | if base, ok := strings.CutSuffix(name, ".sdr-brightness"); ok { | |||
| 238 | + | name = base | |||
| 239 | + | nits, err := strconv.Atoi(value) | |||
| 240 | + | if err != nil || nits < 80 || nits > 1000 { | |||
| 241 | + | warn("%s: must be between 80 and 1000", key) | |||
| 242 | + | continue | |||
| 243 | + | } | |||
| 244 | + | override() | |||
| 245 | + | entry().SDRBrightness = nits | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 226 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | 248 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | ||
| 227 | name = base | 249 | name = base | ||
| 228 | s, err := parseScale(value) | 250 | s, err := parseScale(value) | ||
| @@ -245,7 +267,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 245 | } | 267 | } | ||
| 246 | override() | 268 | override() | ||
| 247 | e := entry() | 269 | e := entry() | ||
| 248 | - | o.Scale, o.Primary = e.Scale, e.Primary | 270 | + | o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness |
| 249 | *e = o | 271 | *e = o | ||
| 250 | continue | 272 | continue | ||
| 251 | } | 273 | } | ||
A internal/adapters/config/hdr_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestHDROutputConfig(t *testing.T) { | |
| 9 | + | c, w := parseString(t, "output.DP-1.hdr = on\noutput.DP-1.sdr-brightness = 80\noutput.DP-1 = preferred\noutput.DP-2 = preferred\noutput.DP-3.hdr = off\noutput.DP-4.sdr-brightness = 1000\n") | |
| 10 | + | if len(w) != 0 || len(c.Outputs) != 4 || !c.Outputs[0].HDR || c.Outputs[0].SDRBrightness != 80 || c.Outputs[0].ScaleOnly || c.Outputs[1].HDR || c.Outputs[1].SDRBrightness != 203 || !c.Outputs[2].ScaleOnly || c.Outputs[3].SDRBrightness != 1000 { | |
| 11 | + | t.Fatalf("%+v %v", c.Outputs, w) | |
| 12 | + | } | |
| 13 | + | for _, v := range []string{"79", "1001", "-1", "abc", "1.5"} { | |
| 14 | + | c, w = parseString(t, "output.DP-1.sdr-brightness = "+v+"\n") | |
| 15 | + | if len(w) != 1 || !strings.Contains(w[0].Msg, "between 80 and 1000") || len(c.Outputs) != 0 { | |
| 16 | + | t.Fatalf("%s: %+v %v", v, c.Outputs, w) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | c, w = parseString(t, "output.DP-1.sdr-brightness = 300\noutput.DP-1.sdr-brightness = 79\noutput.DP-1.hdr = on\noutput.DP-1.hdr = yes\n") | |
| 20 | + | if len(w) != 2 || len(c.Outputs) != 1 || c.Outputs[0].SDRBrightness != 300 || !c.Outputs[0].HDR { | |
| 21 | + | t.Fatalf("invalid override: %+v %v", c.Outputs, w) | |
| 22 | + | } | |
| 23 | + | c, w = parseString(t, "output.DP-1.hdr = yes\n") | |
| 24 | + | if len(w) != 1 || len(c.Outputs) != 0 { | |
| 25 | + | t.Fatalf("%+v %v", c.Outputs, w) | |
| 26 | + | } | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestHDROutputConfig(t *testing.T) { | |||
| 9 | + | c, w := parseString(t, "output.DP-1.hdr = on\noutput.DP-1.sdr-brightness = 80\noutput.DP-1 = preferred\noutput.DP-2 = preferred\noutput.DP-3.hdr = off\noutput.DP-4.sdr-brightness = 1000\n") | |||
| 10 | + | if len(w) != 0 || len(c.Outputs) != 4 || !c.Outputs[0].HDR || c.Outputs[0].SDRBrightness != 80 || c.Outputs[0].ScaleOnly || c.Outputs[1].HDR || c.Outputs[1].SDRBrightness != 203 || !c.Outputs[2].ScaleOnly || c.Outputs[3].SDRBrightness != 1000 { | |||
| 11 | + | t.Fatalf("%+v %v", c.Outputs, w) | |||
| 12 | + | } | |||
| 13 | + | for _, v := range []string{"79", "1001", "-1", "abc", "1.5"} { | |||
| 14 | + | c, w = parseString(t, "output.DP-1.sdr-brightness = "+v+"\n") | |||
| 15 | + | if len(w) != 1 || !strings.Contains(w[0].Msg, "between 80 and 1000") || len(c.Outputs) != 0 { | |||
| 16 | + | t.Fatalf("%s: %+v %v", v, c.Outputs, w) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | c, w = parseString(t, "output.DP-1.sdr-brightness = 300\noutput.DP-1.sdr-brightness = 79\noutput.DP-1.hdr = on\noutput.DP-1.hdr = yes\n") | |||
| 20 | + | if len(w) != 2 || len(c.Outputs) != 1 || c.Outputs[0].SDRBrightness != 300 || !c.Outputs[0].HDR { | |||
| 21 | + | t.Fatalf("invalid override: %+v %v", c.Outputs, w) | |||
| 22 | + | } | |||
| 23 | + | c, w = parseString(t, "output.DP-1.hdr = yes\n") | |||
| 24 | + | if len(w) != 1 || len(c.Outputs) != 0 { | |||
| 25 | + | t.Fatalf("%+v %v", c.Outputs, w) | |||
| 26 | + | } | |||
| 27 | + | } | |||
M internal/adapters/drm/card.go +17 -2
| @@ -74,7 +74,9 @@ func (c *Card) Device() uint64 { | |||
| 74 | 74 | func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch } | |
| 75 | 75 | ||
| 76 | 76 | // SetWant replaces the connector config; the next Scan applies it. | |
| 77 | - | func (c *Card) SetWant(w Want) { c.want = w } | |
| 77 | + | func (c *Card) SetWant(w Want) { | |
| 78 | + | c.want = w | |
| 79 | + | } | |
| 78 | 80 | ||
| 79 | 81 | // Scan compares connectors with the outputs driven now. It returns outputs to | |
| 80 | 82 | // start (newly connected, or with a new mode after Release), names of | |
| @@ -99,7 +101,7 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) { | |||
| 99 | 101 | seen[conn.name] = true | |
| 100 | 102 | mode := c.want.pickMode(conn) | |
| 101 | 103 | if o := c.outputs[conn.name]; o != nil { | |
| 102 | - | if o.mode == mode { | |
| 104 | + | if !outputNeedsRestart(o, mode, c.want.HDR[conn.name]) { | |
| 103 | 105 | continue | |
| 104 | 106 | } | |
| 105 | 107 | replaced = append(replaced, conn.name) | |
| @@ -203,3 +205,16 @@ func (c *Card) ReadEvents(ctx context.Context) error { | |||
| 203 | 205 | } | |
| 204 | 206 | return nil | |
| 205 | 207 | } | |
| 208 | + | ||
| 209 | + | func normalizedHDRSettings(settings HDRSettings) HDRSettings { | |
| 210 | + | if settings.SDRBrightness == 0 { | |
| 211 | + | settings.SDRBrightness = ports.DefaultSDRBrightness | |
| 212 | + | } | |
| 213 | + | return settings | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // outputNeedsRestart applies the same replacement path to mode and HDR | |
| 217 | + | // configuration changes; Release drops the old output before reopening it. | |
| 218 | + | func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool { | |
| 219 | + | return o.mode != mode || o.hdrSettings != normalizedHDRSettings(settings) | |
| 220 | + | } | |
| @@ -74,7 +74,9 @@ func (c *Card) Device() uint64 { | |||||
| 74 | func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch } | 74 | func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch } | ||
| 75 | 75 | ||||
| 76 | // SetWant replaces the connector config; the next Scan applies it. | 76 | // SetWant replaces the connector config; the next Scan applies it. | ||
| 77 | - | func (c *Card) SetWant(w Want) { c.want = w } | 77 | + | func (c *Card) SetWant(w Want) { |
| 78 | + | c.want = w | |||
| 79 | + | } | |||
| 78 | 80 | ||||
| 79 | // Scan compares connectors with the outputs driven now. It returns outputs to | 81 | // Scan compares connectors with the outputs driven now. It returns outputs to | ||
| 80 | // start (newly connected, or with a new mode after Release), names of | 82 | // start (newly connected, or with a new mode after Release), names of | ||
| @@ -99,7 +101,7 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) { | |||||
| 99 | seen[conn.name] = true | 101 | seen[conn.name] = true | ||
| 100 | mode := c.want.pickMode(conn) | 102 | mode := c.want.pickMode(conn) | ||
| 101 | if o := c.outputs[conn.name]; o != nil { | 103 | if o := c.outputs[conn.name]; o != nil { | ||
| 102 | - | if o.mode == mode { | 104 | + | if !outputNeedsRestart(o, mode, c.want.HDR[conn.name]) { |
| 103 | continue | 105 | continue | ||
| 104 | } | 106 | } | ||
| 105 | replaced = append(replaced, conn.name) | 107 | replaced = append(replaced, conn.name) | ||
| @@ -203,3 +205,16 @@ func (c *Card) ReadEvents(ctx context.Context) error { | |||||
| 203 | } | 205 | } | ||
| 204 | return nil | 206 | return nil | ||
| 205 | } | 207 | } | ||
| 208 | + | ||||
| 209 | + | func normalizedHDRSettings(settings HDRSettings) HDRSettings { | |||
| 210 | + | if settings.SDRBrightness == 0 { | |||
| 211 | + | settings.SDRBrightness = ports.DefaultSDRBrightness | |||
| 212 | + | } | |||
| 213 | + | return settings | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // outputNeedsRestart applies the same replacement path to mode and HDR | |||
| 217 | + | // configuration changes; Release drops the old output before reopening it. | |||
| 218 | + | func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool { | |||
| 219 | + | return o.mode != mode || o.hdrSettings != normalizedHDRSettings(settings) | |||
| 220 | + | } | |||
M internal/adapters/drm/commit_test.go +5 -0
| @@ -122,7 +122,9 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) { | |||
| 122 | 122 | w.Close() | |
| 123 | 123 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 124 | 124 | } | |
| 125 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 125 | 126 | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 127 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 126 | 128 | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 127 | 129 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 128 | 130 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| @@ -158,6 +160,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) { | |||
| 158 | 160 | w.Close() | |
| 159 | 161 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 160 | 162 | } | |
| 163 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 161 | 164 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 162 | 165 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 163 | 166 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| @@ -258,6 +261,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 258 | 261 | } | |
| 259 | 262 | r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 260 | 263 | r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil) | |
| 264 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 261 | 265 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 262 | 266 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 263 | 267 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| @@ -350,6 +354,7 @@ func TestRunClosesFrameFences(t *testing.T) { | |||
| 350 | 354 | w.Close() | |
| 351 | 355 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 352 | 356 | } | |
| 357 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 353 | 358 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 354 | 359 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 355 | 360 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| @@ -122,7 +122,9 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) { | |||||
| 122 | w.Close() | 122 | w.Close() | ||
| 123 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 123 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 124 | } | 124 | } | ||
| 125 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 125 | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 126 | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 127 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 126 | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 128 | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 127 | r.EXPECT().UseTarget(mock.Anything).Return() | 129 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 128 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | 130 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | ||
| @@ -158,6 +160,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) { | |||||
| 158 | w.Close() | 160 | w.Close() | ||
| 159 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 161 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 160 | } | 162 | } | ||
| 163 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 161 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 164 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 162 | r.EXPECT().UseTarget(mock.Anything).Return() | 165 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 163 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | 166 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | ||
| @@ -258,6 +261,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||||
| 258 | } | 261 | } | ||
| 259 | r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once() | 262 | r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 260 | r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil) | 263 | r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil) | ||
| 264 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 261 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 265 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 262 | r.EXPECT().UseTarget(mock.Anything).Return() | 266 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 263 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | 267 | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | ||
| @@ -350,6 +354,7 @@ func TestRunClosesFrameFences(t *testing.T) { | |||||
| 350 | w.Close() | 354 | w.Close() | ||
| 351 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 355 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 352 | } | 356 | } | ||
| 357 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 353 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 358 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 354 | r.EXPECT().UseTarget(mock.Anything).Return() | 359 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 355 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | 360 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | ||
M internal/adapters/drm/edid.go +68 -0
| @@ -2,6 +2,7 @@ package drm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "math" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "strings" | |
| @@ -10,6 +11,20 @@ import ( | |||
| 10 | 11 | // Monitor identifies the display attached to a connector. | |
| 11 | 12 | type Monitor struct { | |
| 12 | 13 | Make, Model, Serial string | |
| 14 | + | Chromaticity Chromaticity | |
| 15 | + | BitsPerColor int // 0 when unspecified | |
| 16 | + | HDR HDRMetadata | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Chromaticity is the EDID base block's 10-bit CIE xy coordinates. | |
| 20 | + | type Chromaticity struct { | |
| 21 | + | RedX, RedY, GreenX, GreenY, BlueX, BlueY, WhiteX, WhiteY float64 | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // HDRMetadata is the CTA-861 HDR static metadata and BT.2020 colorimetry. | |
| 25 | + | type HDRMetadata struct { | |
| 26 | + | SDR, PQ, HLG, StaticType1, BT2020RGB, BT2020YCC bool | |
| 27 | + | MaxLuminance, MaxFrameAverage, MinLuminance float64 // cd/m²; zero when unspecified | |
| 13 | 28 | } | |
| 14 | 29 | ||
| 15 | 30 | // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown". | |
| @@ -29,6 +44,19 @@ func parseEDID(d []byte) Monitor { | |||
| 29 | 44 | id := uint16(d[8])<<8 | uint16(d[9]) | |
| 30 | 45 | pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'}) | |
| 31 | 46 | m.Make = pnpName(pnp) | |
| 47 | + | if d[20]&0x80 != 0 { | |
| 48 | + | m.BitsPerColor = []int{0, 6, 8, 10, 12, 14, 16, 0}[d[20]>>4&7] | |
| 49 | + | } | |
| 50 | + | xy := func(hi, lo byte) float64 { return float64(uint16(hi)<<2|uint16(lo)) / 1024 } | |
| 51 | + | m.Chromaticity = Chromaticity{ | |
| 52 | + | RedX: xy(d[27], d[25]>>6), RedY: xy(d[28], d[25]>>4&3), | |
| 53 | + | GreenX: xy(d[29], d[25]>>2&3), GreenY: xy(d[30], d[25]&3), | |
| 54 | + | BlueX: xy(d[31], d[26]>>6), BlueY: xy(d[32], d[26]>>4&3), | |
| 55 | + | WhiteX: xy(d[33], d[26]>>2&3), WhiteY: xy(d[34], d[26]&3), | |
| 56 | + | } | |
| 57 | + | for i := 0; i < int(d[126]) && (i+2)*128 <= len(d); i++ { | |
| 58 | + | m.HDR.parseCTA(d[(i+1)*128 : (i+2)*128]) | |
| 59 | + | } | |
| 32 | 60 | // Display descriptors: 0xFC name, 0xFF serial. | |
| 33 | 61 | for off := 54; off+18 <= 126; off += 18 { | |
| 34 | 62 | b := d[off : off+18] | |
| @@ -46,6 +74,46 @@ func parseEDID(d []byte) Monitor { | |||
| 46 | 74 | return m | |
| 47 | 75 | } | |
| 48 | 76 | ||
| 77 | + | // parseCTA skips malformed data blocks rather than reading into the DTD area. | |
| 78 | + | func (h *HDRMetadata) parseCTA(b []byte) { | |
| 79 | + | if len(b) < 128 || b[0] != 0x02 || b[2] < 4 || b[2] > 127 { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | for off := 4; off < int(b[2]); { | |
| 83 | + | size := int(b[off] & 0x1f) | |
| 84 | + | end := off + 1 + size | |
| 85 | + | if end > int(b[2]) { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | if b[off]>>5 == 7 && size >= 2 { | |
| 89 | + | v := b[off+1 : end] | |
| 90 | + | switch v[0] { | |
| 91 | + | case 5: // colorimetry: BT2020RGB bit 7, BT2020YCC bit 6 | |
| 92 | + | h.BT2020RGB = h.BT2020RGB || v[1]&0x80 != 0 | |
| 93 | + | h.BT2020YCC = h.BT2020YCC || v[1]&0x40 != 0 | |
| 94 | + | case 6: // HDR static metadata: EOTF, descriptors, luminances | |
| 95 | + | if len(v) < 3 { | |
| 96 | + | break | |
| 97 | + | } | |
| 98 | + | h.SDR = h.SDR || v[1]&1 != 0 | |
| 99 | + | h.PQ = h.PQ || v[1]&4 != 0 | |
| 100 | + | h.HLG = h.HLG || v[1]&8 != 0 | |
| 101 | + | h.StaticType1 = h.StaticType1 || v[2]&1 != 0 | |
| 102 | + | if len(v) > 3 && v[3] != 0 { | |
| 103 | + | h.MaxLuminance = 50 * math.Exp2(float64(v[3])/32) | |
| 104 | + | } | |
| 105 | + | if len(v) > 4 && v[4] != 0 { | |
| 106 | + | h.MaxFrameAverage = 50 * math.Exp2(float64(v[4])/32) | |
| 107 | + | } | |
| 108 | + | if len(v) > 5 && h.MaxLuminance != 0 { | |
| 109 | + | h.MinLuminance = h.MaxLuminance * math.Pow(float64(v[5])/255, 2) / 100 | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | off = end | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 49 | 117 | // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id. | |
| 50 | 118 | func pnpName(id string) string { | |
| 51 | 119 | f, err := os.Open("/usr/share/hwdata/pnp.ids") | |
| @@ -2,6 +2,7 @@ package drm | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | + | "math" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "strings" | 8 | "strings" | ||
| @@ -10,6 +11,20 @@ import ( | |||||
| 10 | // Monitor identifies the display attached to a connector. | 11 | // Monitor identifies the display attached to a connector. | ||
| 11 | type Monitor struct { | 12 | type Monitor struct { | ||
| 12 | Make, Model, Serial string | 13 | Make, Model, Serial string | ||
| 14 | + | Chromaticity Chromaticity | |||
| 15 | + | BitsPerColor int // 0 when unspecified | |||
| 16 | + | HDR HDRMetadata | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Chromaticity is the EDID base block's 10-bit CIE xy coordinates. | |||
| 20 | + | type Chromaticity struct { | |||
| 21 | + | RedX, RedY, GreenX, GreenY, BlueX, BlueY, WhiteX, WhiteY float64 | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // HDRMetadata is the CTA-861 HDR static metadata and BT.2020 colorimetry. | |||
| 25 | + | type HDRMetadata struct { | |||
| 26 | + | SDR, PQ, HLG, StaticType1, BT2020RGB, BT2020YCC bool | |||
| 27 | + | MaxLuminance, MaxFrameAverage, MinLuminance float64 // cd/m²; zero when unspecified | |||
| 13 | } | 28 | } | ||
| 14 | 29 | ||||
| 15 | // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown". | 30 | // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown". | ||
| @@ -29,6 +44,19 @@ func parseEDID(d []byte) Monitor { | |||||
| 29 | id := uint16(d[8])<<8 | uint16(d[9]) | 44 | id := uint16(d[8])<<8 | uint16(d[9]) | ||
| 30 | pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'}) | 45 | pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'}) | ||
| 31 | m.Make = pnpName(pnp) | 46 | m.Make = pnpName(pnp) | ||
| 47 | + | if d[20]&0x80 != 0 { | |||
| 48 | + | m.BitsPerColor = []int{0, 6, 8, 10, 12, 14, 16, 0}[d[20]>>4&7] | |||
| 49 | + | } | |||
| 50 | + | xy := func(hi, lo byte) float64 { return float64(uint16(hi)<<2|uint16(lo)) / 1024 } | |||
| 51 | + | m.Chromaticity = Chromaticity{ | |||
| 52 | + | RedX: xy(d[27], d[25]>>6), RedY: xy(d[28], d[25]>>4&3), | |||
| 53 | + | GreenX: xy(d[29], d[25]>>2&3), GreenY: xy(d[30], d[25]&3), | |||
| 54 | + | BlueX: xy(d[31], d[26]>>6), BlueY: xy(d[32], d[26]>>4&3), | |||
| 55 | + | WhiteX: xy(d[33], d[26]>>2&3), WhiteY: xy(d[34], d[26]&3), | |||
| 56 | + | } | |||
| 57 | + | for i := 0; i < int(d[126]) && (i+2)*128 <= len(d); i++ { | |||
| 58 | + | m.HDR.parseCTA(d[(i+1)*128 : (i+2)*128]) | |||
| 59 | + | } | |||
| 32 | // Display descriptors: 0xFC name, 0xFF serial. | 60 | // Display descriptors: 0xFC name, 0xFF serial. | ||
| 33 | for off := 54; off+18 <= 126; off += 18 { | 61 | for off := 54; off+18 <= 126; off += 18 { | ||
| 34 | b := d[off : off+18] | 62 | b := d[off : off+18] | ||
| @@ -46,6 +74,46 @@ func parseEDID(d []byte) Monitor { | |||||
| 46 | return m | 74 | return m | ||
| 47 | } | 75 | } | ||
| 48 | 76 | ||||
| 77 | + | // parseCTA skips malformed data blocks rather than reading into the DTD area. | |||
| 78 | + | func (h *HDRMetadata) parseCTA(b []byte) { | |||
| 79 | + | if len(b) < 128 || b[0] != 0x02 || b[2] < 4 || b[2] > 127 { | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | for off := 4; off < int(b[2]); { | |||
| 83 | + | size := int(b[off] & 0x1f) | |||
| 84 | + | end := off + 1 + size | |||
| 85 | + | if end > int(b[2]) { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | if b[off]>>5 == 7 && size >= 2 { | |||
| 89 | + | v := b[off+1 : end] | |||
| 90 | + | switch v[0] { | |||
| 91 | + | case 5: // colorimetry: BT2020RGB bit 7, BT2020YCC bit 6 | |||
| 92 | + | h.BT2020RGB = h.BT2020RGB || v[1]&0x80 != 0 | |||
| 93 | + | h.BT2020YCC = h.BT2020YCC || v[1]&0x40 != 0 | |||
| 94 | + | case 6: // HDR static metadata: EOTF, descriptors, luminances | |||
| 95 | + | if len(v) < 3 { | |||
| 96 | + | break | |||
| 97 | + | } | |||
| 98 | + | h.SDR = h.SDR || v[1]&1 != 0 | |||
| 99 | + | h.PQ = h.PQ || v[1]&4 != 0 | |||
| 100 | + | h.HLG = h.HLG || v[1]&8 != 0 | |||
| 101 | + | h.StaticType1 = h.StaticType1 || v[2]&1 != 0 | |||
| 102 | + | if len(v) > 3 && v[3] != 0 { | |||
| 103 | + | h.MaxLuminance = 50 * math.Exp2(float64(v[3])/32) | |||
| 104 | + | } | |||
| 105 | + | if len(v) > 4 && v[4] != 0 { | |||
| 106 | + | h.MaxFrameAverage = 50 * math.Exp2(float64(v[4])/32) | |||
| 107 | + | } | |||
| 108 | + | if len(v) > 5 && h.MaxLuminance != 0 { | |||
| 109 | + | h.MinLuminance = h.MaxLuminance * math.Pow(float64(v[5])/255, 2) / 100 | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | off = end | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 49 | // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id. | 117 | // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id. | ||
| 50 | func pnpName(id string) string { | 118 | func pnpName(id string) string { | ||
| 51 | f, err := os.Open("/usr/share/hwdata/pnp.ids") | 119 | f, err := os.Open("/usr/share/hwdata/pnp.ids") | ||
A internal/adapters/drm/edid_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestEDIDHDR(t *testing.T) { | |
| 9 | + | d := make([]byte, 256) | |
| 10 | + | copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00") | |
| 11 | + | d[20] = 0xb0 // digital, 10 bits per color | |
| 12 | + | d[25], d[26] = 0b_10_01_11_00, 0b_01_11_10_00 | |
| 13 | + | d[27], d[28] = 128, 64 | |
| 14 | + | d[29], d[30] = 32, 16 | |
| 15 | + | d[31], d[32] = 8, 4 | |
| 16 | + | d[33], d[34] = 2, 1 | |
| 17 | + | d[126] = 1 | |
| 18 | + | d[128], d[130] = 2, 15 | |
| 19 | + | // CTA extended colorimetry: BT2020RGB + BT2020YCC. | |
| 20 | + | copy(d[132:], []byte{0xe3, 5, 0xc0, 0}) | |
| 21 | + | // EOTF SDR, PQ, HLG; static type 1; max 200, average 100, min 200/100. | |
| 22 | + | copy(d[136:], []byte{0xe6, 6, 0x0d, 1, 64, 32, 255}) | |
| 23 | + | m := parseEDID(d) | |
| 24 | + | if m.BitsPerColor != 10 || !m.HDR.SDR || !m.HDR.PQ || !m.HDR.HLG || !m.HDR.StaticType1 || !m.HDR.BT2020RGB || !m.HDR.BT2020YCC { | |
| 25 | + | t.Fatalf("%+v", m) | |
| 26 | + | } | |
| 27 | + | if m.Chromaticity.RedX != float64(128*4+2)/1024 || m.Chromaticity.GreenY != float64(16*4)/1024 || m.Chromaticity.WhiteY != float64(1*4)/1024 { | |
| 28 | + | t.Fatalf("chromaticity: %+v", m.Chromaticity) | |
| 29 | + | } | |
| 30 | + | if math.Abs(m.HDR.MaxLuminance-200) > 1e-9 || math.Abs(m.HDR.MaxFrameAverage-100) > 1e-9 || math.Abs(m.HDR.MinLuminance-2) > 1e-9 { | |
| 31 | + | t.Fatalf("luminances: %+v", m.HDR) | |
| 32 | + | } | |
| 33 | + | for _, n := range []int{0, 7, 127, 128, 129, 255} { | |
| 34 | + | got := parseEDID(d[:n]) | |
| 35 | + | if got.HDR.PQ { | |
| 36 | + | t.Fatalf("truncated %d: %+v", n, got) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | d[132] = 0xff // block exceeds DTD boundary | |
| 40 | + | if parseEDID(d).HDR.PQ { | |
| 41 | + | t.Fatal("parsed malformed block") | |
| 42 | + | } | |
| 43 | + | d[132] = 0xe3 | |
| 44 | + | d[130] = 3 // invalid CTA offset | |
| 45 | + | if parseEDID(d).HDR.PQ { | |
| 46 | + | t.Fatal("parsed invalid CTA offset") | |
| 47 | + | } | |
| 48 | + | d[130] = 15 | |
| 49 | + | d[136] = 0xe2 // HDR payload without mandatory descriptor byte | |
| 50 | + | if parseEDID(d).HDR.PQ { | |
| 51 | + | t.Fatal("parsed truncated HDR payload") | |
| 52 | + | } | |
| 53 | + | d[20] = 0 // analog input cannot specify digital bit depth | |
| 54 | + | if parseEDID(d).BitsPerColor != 0 { | |
| 55 | + | t.Fatal("analog input has bit depth") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestDetectHDR(t *testing.T) { | |
| 60 | + | m := Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true, StaticType1: true}} | |
| 61 | + | p := connectorHDRProps{Metadata: 1, Colorspace: 2, MaxBPC: 3, HasDefault: true} | |
| 62 | + | cases := []struct { | |
| 63 | + | name string | |
| 64 | + | m Monitor | |
| 65 | + | p connectorHDRProps | |
| 66 | + | reason string | |
| 67 | + | }{ | |
| 68 | + | {"ready", m, p, ""}, | |
| 69 | + | {"no PQ", Monitor{HDR: HDRMetadata{BT2020RGB: true, StaticType1: true}}, p, "edid: no PQ"}, | |
| 70 | + | {"no RGB", Monitor{HDR: HDRMetadata{PQ: true, StaticType1: true}}, p, "edid: no BT2020RGB"}, | |
| 71 | + | {"no type 1", Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true}}, p, "edid: no static metadata type 1"}, | |
| 72 | + | {"no metadata", m, connectorHDRProps{Colorspace: 2, MaxBPC: 3, HasDefault: true}, "connector: no HDR_OUTPUT_METADATA"}, | |
| 73 | + | {"no colorspace", m, connectorHDRProps{Metadata: 1, MaxBPC: 3}, "connector: no BT2020_RGB Colorspace"}, | |
| 74 | + | {"no bpc", m, connectorHDRProps{Metadata: 1, Colorspace: 2, HasDefault: true}, "connector: max bpc below 10"}, | |
| 75 | + | } | |
| 76 | + | for _, tt := range cases { | |
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |
| 78 | + | c := detectHDR(tt.m, tt.p) | |
| 79 | + | if c.Capable != (tt.reason == "") || c.Reason != tt.reason { | |
| 80 | + | t.Fatalf("%+v", c) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestEDIDHDR(t *testing.T) { | |||
| 9 | + | d := make([]byte, 256) | |||
| 10 | + | copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00") | |||
| 11 | + | d[20] = 0xb0 // digital, 10 bits per color | |||
| 12 | + | d[25], d[26] = 0b_10_01_11_00, 0b_01_11_10_00 | |||
| 13 | + | d[27], d[28] = 128, 64 | |||
| 14 | + | d[29], d[30] = 32, 16 | |||
| 15 | + | d[31], d[32] = 8, 4 | |||
| 16 | + | d[33], d[34] = 2, 1 | |||
| 17 | + | d[126] = 1 | |||
| 18 | + | d[128], d[130] = 2, 15 | |||
| 19 | + | // CTA extended colorimetry: BT2020RGB + BT2020YCC. | |||
| 20 | + | copy(d[132:], []byte{0xe3, 5, 0xc0, 0}) | |||
| 21 | + | // EOTF SDR, PQ, HLG; static type 1; max 200, average 100, min 200/100. | |||
| 22 | + | copy(d[136:], []byte{0xe6, 6, 0x0d, 1, 64, 32, 255}) | |||
| 23 | + | m := parseEDID(d) | |||
| 24 | + | if m.BitsPerColor != 10 || !m.HDR.SDR || !m.HDR.PQ || !m.HDR.HLG || !m.HDR.StaticType1 || !m.HDR.BT2020RGB || !m.HDR.BT2020YCC { | |||
| 25 | + | t.Fatalf("%+v", m) | |||
| 26 | + | } | |||
| 27 | + | if m.Chromaticity.RedX != float64(128*4+2)/1024 || m.Chromaticity.GreenY != float64(16*4)/1024 || m.Chromaticity.WhiteY != float64(1*4)/1024 { | |||
| 28 | + | t.Fatalf("chromaticity: %+v", m.Chromaticity) | |||
| 29 | + | } | |||
| 30 | + | if math.Abs(m.HDR.MaxLuminance-200) > 1e-9 || math.Abs(m.HDR.MaxFrameAverage-100) > 1e-9 || math.Abs(m.HDR.MinLuminance-2) > 1e-9 { | |||
| 31 | + | t.Fatalf("luminances: %+v", m.HDR) | |||
| 32 | + | } | |||
| 33 | + | for _, n := range []int{0, 7, 127, 128, 129, 255} { | |||
| 34 | + | got := parseEDID(d[:n]) | |||
| 35 | + | if got.HDR.PQ { | |||
| 36 | + | t.Fatalf("truncated %d: %+v", n, got) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | d[132] = 0xff // block exceeds DTD boundary | |||
| 40 | + | if parseEDID(d).HDR.PQ { | |||
| 41 | + | t.Fatal("parsed malformed block") | |||
| 42 | + | } | |||
| 43 | + | d[132] = 0xe3 | |||
| 44 | + | d[130] = 3 // invalid CTA offset | |||
| 45 | + | if parseEDID(d).HDR.PQ { | |||
| 46 | + | t.Fatal("parsed invalid CTA offset") | |||
| 47 | + | } | |||
| 48 | + | d[130] = 15 | |||
| 49 | + | d[136] = 0xe2 // HDR payload without mandatory descriptor byte | |||
| 50 | + | if parseEDID(d).HDR.PQ { | |||
| 51 | + | t.Fatal("parsed truncated HDR payload") | |||
| 52 | + | } | |||
| 53 | + | d[20] = 0 // analog input cannot specify digital bit depth | |||
| 54 | + | if parseEDID(d).BitsPerColor != 0 { | |||
| 55 | + | t.Fatal("analog input has bit depth") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestDetectHDR(t *testing.T) { | |||
| 60 | + | m := Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true, StaticType1: true}} | |||
| 61 | + | p := connectorHDRProps{Metadata: 1, Colorspace: 2, MaxBPC: 3, HasDefault: true} | |||
| 62 | + | cases := []struct { | |||
| 63 | + | name string | |||
| 64 | + | m Monitor | |||
| 65 | + | p connectorHDRProps | |||
| 66 | + | reason string | |||
| 67 | + | }{ | |||
| 68 | + | {"ready", m, p, ""}, | |||
| 69 | + | {"no PQ", Monitor{HDR: HDRMetadata{BT2020RGB: true, StaticType1: true}}, p, "edid: no PQ"}, | |||
| 70 | + | {"no RGB", Monitor{HDR: HDRMetadata{PQ: true, StaticType1: true}}, p, "edid: no BT2020RGB"}, | |||
| 71 | + | {"no type 1", Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true}}, p, "edid: no static metadata type 1"}, | |||
| 72 | + | {"no metadata", m, connectorHDRProps{Colorspace: 2, MaxBPC: 3, HasDefault: true}, "connector: no HDR_OUTPUT_METADATA"}, | |||
| 73 | + | {"no colorspace", m, connectorHDRProps{Metadata: 1, MaxBPC: 3}, "connector: no BT2020_RGB Colorspace"}, | |||
| 74 | + | {"no bpc", m, connectorHDRProps{Metadata: 1, Colorspace: 2, HasDefault: true}, "connector: max bpc below 10"}, | |||
| 75 | + | } | |||
| 76 | + | for _, tt := range cases { | |||
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 78 | + | c := detectHDR(tt.m, tt.p) | |||
| 79 | + | if c.Capable != (tt.reason == "") || c.Reason != tt.reason { | |||
| 80 | + | t.Fatalf("%+v", c) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
A internal/adapters/drm/hdr.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "unsafe" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // HDMI static metadata type 1, embedded in struct hdr_output_metadata. | |
| 9 | + | // All coordinates use 0.00002; minimum luminance uses 0.0001 nits. | |
| 10 | + | type hdmiMetadataType1 struct { | |
| 11 | + | EOTF, MetadataType uint8 | |
| 12 | + | DisplayPrimaries [3][2]uint16 | |
| 13 | + | WhitePoint [2]uint16 | |
| 14 | + | MaxDisplayMasteringLuminance uint16 | |
| 15 | + | MinDisplayMasteringLuminance uint16 | |
| 16 | + | MaxCLL, MaxFALL uint16 | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type hdrOutputMetadata struct { | |
| 20 | + | MetadataType uint32 | |
| 21 | + | HDMI hdmiMetadataType1 | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func hdrMetadata(m Monitor) hdrOutputMetadata { | |
| 25 | + | unit := func(v, scale float64) uint16 { | |
| 26 | + | return uint16(math.Round(max(0, min(v*scale, math.MaxUint16)))) | |
| 27 | + | } | |
| 28 | + | c, h := m.Chromaticity, m.HDR | |
| 29 | + | return hdrOutputMetadata{HDMI: hdmiMetadataType1{ | |
| 30 | + | EOTF: 2, | |
| 31 | + | DisplayPrimaries: [3][2]uint16{{unit(c.RedX, 50000), unit(c.RedY, 50000)}, {unit(c.GreenX, 50000), unit(c.GreenY, 50000)}, {unit(c.BlueX, 50000), unit(c.BlueY, 50000)}}, | |
| 32 | + | WhitePoint: [2]uint16{unit(c.WhiteX, 50000), unit(c.WhiteY, 50000)}, | |
| 33 | + | MaxDisplayMasteringLuminance: unit(h.MaxLuminance, 1), | |
| 34 | + | MinDisplayMasteringLuminance: unit(h.MinLuminance, 10000), | |
| 35 | + | MaxCLL: unit(h.MaxLuminance, 1), MaxFALL: unit(maxFrameAverage(h), 1), | |
| 36 | + | }} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func maxFrameAverage(h HDRMetadata) float64 { | |
| 40 | + | if h.MaxFrameAverage > 0 { | |
| 41 | + | return h.MaxFrameAverage | |
| 42 | + | } | |
| 43 | + | return h.MaxLuminance | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // hdrConnectorProps sets the connector's signal metadata on a modeset or restore. | |
| 47 | + | func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) { | |
| 48 | + | p := o.hdrProps | |
| 49 | + | if enabled { | |
| 50 | + | req.set(o.conn.id, p.MaxBPC, 10) | |
| 51 | + | req.set(o.conn.id, p.Colorspace, p.BT2020Value) | |
| 52 | + | req.set(o.conn.id, p.Metadata, uint64(o.hdrBlob)) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue) | |
| 56 | + | if p.HasDefault { | |
| 57 | + | req.set(o.conn.id, p.Colorspace, p.DefaultValue) | |
| 58 | + | } | |
| 59 | + | req.set(o.conn.id, p.Metadata, 0) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (m *hdrOutputMetadata) bytes() []byte { | |
| 63 | + | return unsafe.Slice((*byte)(unsafe.Pointer(m)), unsafe.Sizeof(*m)) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "unsafe" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // HDMI static metadata type 1, embedded in struct hdr_output_metadata. | |||
| 9 | + | // All coordinates use 0.00002; minimum luminance uses 0.0001 nits. | |||
| 10 | + | type hdmiMetadataType1 struct { | |||
| 11 | + | EOTF, MetadataType uint8 | |||
| 12 | + | DisplayPrimaries [3][2]uint16 | |||
| 13 | + | WhitePoint [2]uint16 | |||
| 14 | + | MaxDisplayMasteringLuminance uint16 | |||
| 15 | + | MinDisplayMasteringLuminance uint16 | |||
| 16 | + | MaxCLL, MaxFALL uint16 | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type hdrOutputMetadata struct { | |||
| 20 | + | MetadataType uint32 | |||
| 21 | + | HDMI hdmiMetadataType1 | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func hdrMetadata(m Monitor) hdrOutputMetadata { | |||
| 25 | + | unit := func(v, scale float64) uint16 { | |||
| 26 | + | return uint16(math.Round(max(0, min(v*scale, math.MaxUint16)))) | |||
| 27 | + | } | |||
| 28 | + | c, h := m.Chromaticity, m.HDR | |||
| 29 | + | return hdrOutputMetadata{HDMI: hdmiMetadataType1{ | |||
| 30 | + | EOTF: 2, | |||
| 31 | + | DisplayPrimaries: [3][2]uint16{{unit(c.RedX, 50000), unit(c.RedY, 50000)}, {unit(c.GreenX, 50000), unit(c.GreenY, 50000)}, {unit(c.BlueX, 50000), unit(c.BlueY, 50000)}}, | |||
| 32 | + | WhitePoint: [2]uint16{unit(c.WhiteX, 50000), unit(c.WhiteY, 50000)}, | |||
| 33 | + | MaxDisplayMasteringLuminance: unit(h.MaxLuminance, 1), | |||
| 34 | + | MinDisplayMasteringLuminance: unit(h.MinLuminance, 10000), | |||
| 35 | + | MaxCLL: unit(h.MaxLuminance, 1), MaxFALL: unit(maxFrameAverage(h), 1), | |||
| 36 | + | }} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func maxFrameAverage(h HDRMetadata) float64 { | |||
| 40 | + | if h.MaxFrameAverage > 0 { | |||
| 41 | + | return h.MaxFrameAverage | |||
| 42 | + | } | |||
| 43 | + | return h.MaxLuminance | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // hdrConnectorProps sets the connector's signal metadata on a modeset or restore. | |||
| 47 | + | func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) { | |||
| 48 | + | p := o.hdrProps | |||
| 49 | + | if enabled { | |||
| 50 | + | req.set(o.conn.id, p.MaxBPC, 10) | |||
| 51 | + | req.set(o.conn.id, p.Colorspace, p.BT2020Value) | |||
| 52 | + | req.set(o.conn.id, p.Metadata, uint64(o.hdrBlob)) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue) | |||
| 56 | + | if p.HasDefault { | |||
| 57 | + | req.set(o.conn.id, p.Colorspace, p.DefaultValue) | |||
| 58 | + | } | |||
| 59 | + | req.set(o.conn.id, p.Metadata, 0) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (m *hdrOutputMetadata) bytes() []byte { | |||
| 63 | + | return unsafe.Slice((*byte)(unsafe.Pointer(m)), unsafe.Sizeof(*m)) | |||
| 64 | + | } | |||
A internal/adapters/drm/hdr_test.go +288 -0
| @@ -0,0 +1,288 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | "unsafe" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestHDRMetadataLayoutAndContent(t *testing.T) { | |
| 16 | + | var layout hdrOutputMetadata | |
| 17 | + | if unsafe.Sizeof(layout) != 32 || unsafe.Offsetof(layout.HDMI) != 4 || unsafe.Offsetof(layout.HDMI.DisplayPrimaries) != 2 || unsafe.Offsetof(layout.HDMI.WhitePoint) != 14 || unsafe.Offsetof(layout.HDMI.MaxDisplayMasteringLuminance) != 18 || unsafe.Offsetof(layout.HDMI.MinDisplayMasteringLuminance) != 20 || unsafe.Offsetof(layout.HDMI.MaxCLL) != 22 || unsafe.Offsetof(layout.HDMI.MaxFALL) != 24 { | |
| 18 | + | t.Fatalf("unexpected HDR metadata layout: size=%d HDMI=%d", unsafe.Sizeof(layout), unsafe.Offsetof(layout.HDMI)) | |
| 19 | + | } | |
| 20 | + | m := Monitor{Chromaticity: Chromaticity{RedX: 0.64, RedY: 0.33, GreenX: 0.3, GreenY: 0.6, BlueX: 0.15, BlueY: 0.06, WhiteX: 0.3127, WhiteY: 0.329}, HDR: HDRMetadata{MaxLuminance: 1000, MinLuminance: 0.005}} | |
| 21 | + | data := hdrMetadata(m) | |
| 22 | + | if len(data.bytes()) != 32 || data.MetadataType != 0 || data.HDMI.EOTF != 2 || data.HDMI.MetadataType != 0 || data.HDMI.DisplayPrimaries != [3][2]uint16{{32000, 16500}, {15000, 30000}, {7500, 3000}} || data.HDMI.WhitePoint != [2]uint16{15635, 16450} || data.HDMI.MaxDisplayMasteringLuminance != 1000 || data.HDMI.MinDisplayMasteringLuminance != 50 || data.HDMI.MaxCLL != 1000 || data.HDMI.MaxFALL != 1000 { | |
| 23 | + | t.Fatalf("metadata: %+v", data) | |
| 24 | + | } | |
| 25 | + | m.HDR.MaxFrameAverage = 400 | |
| 26 | + | if withFALL := hdrMetadata(m); withFALL.HDMI.MaxFALL != 400 { | |
| 27 | + | t.Fatalf("frame-average luminance: %+v", withFALL) | |
| 28 | + | } | |
| 29 | + | if zero := hdrMetadata(Monitor{}); zero.HDMI.MaxCLL != 0 || zero.HDMI.MaxFALL != 0 { | |
| 30 | + | t.Fatalf("unknown luminance: %+v", zero) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) { | |
| 35 | + | o, _, _ := testOutput(t) | |
| 36 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8} | |
| 37 | + | o.hdrBlob, o.hdrOn = 321, true | |
| 38 | + | for _, tc := range []struct { | |
| 39 | + | on bool | |
| 40 | + | bpc, colorspace, metadata uint64 | |
| 41 | + | }{ | |
| 42 | + | {true, 10, 7, 321}, {false, 8, 0, 0}, | |
| 43 | + | } { | |
| 44 | + | o.hdrOn = tc.on | |
| 45 | + | req := o.modesetReq(11, true) | |
| 46 | + | for _, prop := range []struct { | |
| 47 | + | id uint32 | |
| 48 | + | want uint64 | |
| 49 | + | }{{102, tc.bpc}, {101, tc.colorspace}, {100, tc.metadata}} { | |
| 50 | + | if got, ok := req.value(o.conn.id, prop.id); !ok || got != prop.want { | |
| 51 | + | t.Fatalf("HDR %t prop %d: got %d (%t), want %d", tc.on, prop.id, got, ok, prop.want) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | o.hdrOn = true | |
| 56 | + | if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" { | |
| 57 | + | t.Fatalf("scanout: fb %d reason %q", fb, o.reason) | |
| 58 | + | } | |
| 59 | + | if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" { | |
| 60 | + | t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason) | |
| 61 | + | } | |
| 62 | + | // Colorspace property without a Default enum must not receive invented 0. | |
| 63 | + | o.hdrOn = false | |
| 64 | + | o.hdrProps.HasDefault = false | |
| 65 | + | if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok { | |
| 66 | + | t.Fatal("SDR set Colorspace with no Default enum") | |
| 67 | + | } | |
| 68 | + | restore := &atomicReq{} | |
| 69 | + | o.hdrConnectorProps(restore, false) | |
| 70 | + | if _, ok := restore.value(o.conn.id, o.hdrProps.Colorspace); ok { | |
| 71 | + | t.Fatal("restore set Colorspace with no Default enum") | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) { | |
| 76 | + | o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL) | |
| 77 | + | o.cursor = nil | |
| 78 | + | o.hdr = hdrCapability{Capable: true} | |
| 79 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203} | |
| 80 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |
| 81 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}} | |
| 82 | + | r := portsmocks.NewMockRenderer(t) | |
| 83 | + | buf := func() ports.DMABuf { | |
| 84 | + | f, w, _ := os.Pipe() | |
| 85 | + | w.Close() | |
| 86 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 87 | + | } | |
| 88 | + | var calls []string | |
| 89 | + | r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once() | |
| 90 | + | r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once() | |
| 91 | + | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Twice() | |
| 92 | + | r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once() | |
| 93 | + | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 94 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 95 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 96 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |
| 97 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(71, nil) | |
| 98 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |
| 99 | + | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once() | |
| 100 | + | k.EXPECT().createBlob(mock.Anything).Return(11, nil) | |
| 101 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| 102 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) { | |
| 106 | + | t.Fatalf("fallback order: %v", calls) | |
| 107 | + | } | |
| 108 | + | if o.hdrOn || !o.hdrFailed { | |
| 109 | + | t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed) | |
| 110 | + | } | |
| 111 | + | if len(*commits) != 4 { | |
| 112 | + | t.Fatalf("commits: %d", len(*commits)) | |
| 113 | + | } | |
| 114 | + | for i := range 2 { | |
| 115 | + | if (*commits)[i].flags != atomicTestOnly|atomicAllowModes { | |
| 116 | + | t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes { | |
| 120 | + | t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags) | |
| 121 | + | } | |
| 122 | + | if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 { | |
| 123 | + | t.Fatalf("HDR test metadata: %d", v) | |
| 124 | + | } | |
| 125 | + | if v, _ := (*commits)[len(*commits)-1].req.value(o.conn.id, 100); v != 0 { | |
| 126 | + | t.Fatalf("SDR metadata: %d", v) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestHDRSettingsRestartComparison(t *testing.T) { | |
| 131 | + | current := HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |
| 132 | + | for _, tc := range []struct { | |
| 133 | + | settings HDRSettings | |
| 134 | + | restart bool | |
| 135 | + | }{ | |
| 136 | + | {HDRSettings{Enabled: true}, false}, | |
| 137 | + | {HDRSettings{Enabled: true, SDRBrightness: 350}, true}, | |
| 138 | + | {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true}, | |
| 139 | + | } { | |
| 140 | + | if restart := outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{}, tc.settings); restart != tc.restart { | |
| 141 | + | t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) { | |
| 145 | + | t.Fatal("mode change did not restart") | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestHDRSuccessfulModeset(t *testing.T) { | |
| 150 | + | o, k, commits := testOutput(t) | |
| 151 | + | o.cursor = nil | |
| 152 | + | o.hdr = hdrCapability{Capable: true} | |
| 153 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |
| 154 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |
| 155 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |
| 156 | + | r := portsmocks.NewMockRenderer(t) | |
| 157 | + | buf := func() ports.DMABuf { | |
| 158 | + | f, w, _ := os.Pipe() | |
| 159 | + | w.Close() | |
| 160 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 161 | + | } | |
| 162 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once() | |
| 163 | + | r.EXPECT().ExportTargets(2, []uint64{19}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 164 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 165 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 166 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice() | |
| 167 | + | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once() | |
| 168 | + | k.EXPECT().createBlob(mock.Anything).Return(11, nil).Twice() | |
| 169 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| 170 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | if !o.hdrOn || o.hdrFailed || len(*commits) != 2 { | |
| 174 | + | t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits)) | |
| 175 | + | } | |
| 176 | + | for _, c := range *commits { | |
| 177 | + | for _, tc := range []struct { | |
| 178 | + | prop uint32 | |
| 179 | + | want uint64 | |
| 180 | + | }{{100, 321}, {101, 7}, {102, 10}} { | |
| 181 | + | if got, ok := c.req.value(o.conn.id, tc.prop); !ok || got != tc.want { | |
| 182 | + | t.Fatalf("commit prop %d: %d (%t), want %d", tc.prop, got, ok, tc.want) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // The metadata blob is retained across repeated image setup (e.g. VT resume) | |
| 189 | + | // and destroyed only once when Close restores SDR. | |
| 190 | + | func TestHDRBlobReusedAcrossImageSetup(t *testing.T) { | |
| 191 | + | o, k, commits := testOutput(t) | |
| 192 | + | o.cursor = nil | |
| 193 | + | o.hdr = hdrCapability{Capable: true} | |
| 194 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |
| 195 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |
| 196 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |
| 197 | + | r := portsmocks.NewMockRenderer(t) | |
| 198 | + | buf := func() ports.DMABuf { | |
| 199 | + | f, w, _ := os.Pipe() | |
| 200 | + | w.Close() | |
| 201 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 202 | + | } | |
| 203 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice() | |
| 204 | + | r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice() | |
| 205 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 206 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 207 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |
| 208 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 209 | + | created, destroyed := 0, 0 | |
| 210 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) { | |
| 211 | + | if len(data) == 32 { | |
| 212 | + | created++ | |
| 213 | + | return 321, nil | |
| 214 | + | } | |
| 215 | + | return 11, nil | |
| 216 | + | }) | |
| 217 | + | k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error { | |
| 218 | + | if id == 321 { | |
| 219 | + | destroyed++ | |
| 220 | + | } | |
| 221 | + | return nil | |
| 222 | + | }) | |
| 223 | + | for i := 0; i < 2; i++ { | |
| 224 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 225 | + | t.Fatal(err) | |
| 226 | + | } | |
| 227 | + | if o.hdrBlob != 321 || created != 1 || destroyed != 0 { | |
| 228 | + | t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | if len(*commits) != 4 { | |
| 232 | + | t.Fatalf("commits: %d", len(*commits)) | |
| 233 | + | } | |
| 234 | + | o.Close() | |
| 235 | + | if created != 1 || destroyed != 1 { | |
| 236 | + | t.Fatalf("created %d destroyed %d", created, destroyed) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestHDRBlobReplacementWaitsForCommit(t *testing.T) { | |
| 241 | + | o, k, _ := testOutput(t) | |
| 242 | + | o.cursor = nil | |
| 243 | + | o.hdr = hdrCapability{Capable: true} | |
| 244 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |
| 245 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |
| 246 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |
| 247 | + | r := portsmocks.NewMockRenderer(t) | |
| 248 | + | buf := func() ports.DMABuf { | |
| 249 | + | f, w, _ := os.Pipe() | |
| 250 | + | w.Close() | |
| 251 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 252 | + | } | |
| 253 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice() | |
| 254 | + | r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice() | |
| 255 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 256 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 257 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |
| 258 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 259 | + | metadata := uint32(320) | |
| 260 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) { | |
| 261 | + | if len(data) == 32 { | |
| 262 | + | metadata++ | |
| 263 | + | return metadata, nil | |
| 264 | + | } | |
| 265 | + | return 11, nil | |
| 266 | + | }) | |
| 267 | + | var destroyed []uint32 | |
| 268 | + | k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error { | |
| 269 | + | if id >= 321 { | |
| 270 | + | destroyed = append(destroyed, id) | |
| 271 | + | } | |
| 272 | + | return nil | |
| 273 | + | }) | |
| 274 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | o.monitor.HDR.MaxLuminance = 1000 | |
| 278 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |
| 279 | + | t.Fatal(err) | |
| 280 | + | } | |
| 281 | + | if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) { | |
| 282 | + | t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed) | |
| 283 | + | } | |
| 284 | + | o.Close() | |
| 285 | + | if !slices.Equal(destroyed, []uint32{321, 322}) { | |
| 286 | + | t.Fatalf("destroyed %v", destroyed) | |
| 287 | + | } | |
| 288 | + | } | |
| @@ -0,0 +1,288 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | "unsafe" | |||
| 8 | + | ||||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestHDRMetadataLayoutAndContent(t *testing.T) { | |||
| 16 | + | var layout hdrOutputMetadata | |||
| 17 | + | if unsafe.Sizeof(layout) != 32 || unsafe.Offsetof(layout.HDMI) != 4 || unsafe.Offsetof(layout.HDMI.DisplayPrimaries) != 2 || unsafe.Offsetof(layout.HDMI.WhitePoint) != 14 || unsafe.Offsetof(layout.HDMI.MaxDisplayMasteringLuminance) != 18 || unsafe.Offsetof(layout.HDMI.MinDisplayMasteringLuminance) != 20 || unsafe.Offsetof(layout.HDMI.MaxCLL) != 22 || unsafe.Offsetof(layout.HDMI.MaxFALL) != 24 { | |||
| 18 | + | t.Fatalf("unexpected HDR metadata layout: size=%d HDMI=%d", unsafe.Sizeof(layout), unsafe.Offsetof(layout.HDMI)) | |||
| 19 | + | } | |||
| 20 | + | m := Monitor{Chromaticity: Chromaticity{RedX: 0.64, RedY: 0.33, GreenX: 0.3, GreenY: 0.6, BlueX: 0.15, BlueY: 0.06, WhiteX: 0.3127, WhiteY: 0.329}, HDR: HDRMetadata{MaxLuminance: 1000, MinLuminance: 0.005}} | |||
| 21 | + | data := hdrMetadata(m) | |||
| 22 | + | if len(data.bytes()) != 32 || data.MetadataType != 0 || data.HDMI.EOTF != 2 || data.HDMI.MetadataType != 0 || data.HDMI.DisplayPrimaries != [3][2]uint16{{32000, 16500}, {15000, 30000}, {7500, 3000}} || data.HDMI.WhitePoint != [2]uint16{15635, 16450} || data.HDMI.MaxDisplayMasteringLuminance != 1000 || data.HDMI.MinDisplayMasteringLuminance != 50 || data.HDMI.MaxCLL != 1000 || data.HDMI.MaxFALL != 1000 { | |||
| 23 | + | t.Fatalf("metadata: %+v", data) | |||
| 24 | + | } | |||
| 25 | + | m.HDR.MaxFrameAverage = 400 | |||
| 26 | + | if withFALL := hdrMetadata(m); withFALL.HDMI.MaxFALL != 400 { | |||
| 27 | + | t.Fatalf("frame-average luminance: %+v", withFALL) | |||
| 28 | + | } | |||
| 29 | + | if zero := hdrMetadata(Monitor{}); zero.HDMI.MaxCLL != 0 || zero.HDMI.MaxFALL != 0 { | |||
| 30 | + | t.Fatalf("unknown luminance: %+v", zero) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) { | |||
| 35 | + | o, _, _ := testOutput(t) | |||
| 36 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8} | |||
| 37 | + | o.hdrBlob, o.hdrOn = 321, true | |||
| 38 | + | for _, tc := range []struct { | |||
| 39 | + | on bool | |||
| 40 | + | bpc, colorspace, metadata uint64 | |||
| 41 | + | }{ | |||
| 42 | + | {true, 10, 7, 321}, {false, 8, 0, 0}, | |||
| 43 | + | } { | |||
| 44 | + | o.hdrOn = tc.on | |||
| 45 | + | req := o.modesetReq(11, true) | |||
| 46 | + | for _, prop := range []struct { | |||
| 47 | + | id uint32 | |||
| 48 | + | want uint64 | |||
| 49 | + | }{{102, tc.bpc}, {101, tc.colorspace}, {100, tc.metadata}} { | |||
| 50 | + | if got, ok := req.value(o.conn.id, prop.id); !ok || got != prop.want { | |||
| 51 | + | t.Fatalf("HDR %t prop %d: got %d (%t), want %d", tc.on, prop.id, got, ok, prop.want) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | o.hdrOn = true | |||
| 56 | + | if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" { | |||
| 57 | + | t.Fatalf("scanout: fb %d reason %q", fb, o.reason) | |||
| 58 | + | } | |||
| 59 | + | if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" { | |||
| 60 | + | t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason) | |||
| 61 | + | } | |||
| 62 | + | // Colorspace property without a Default enum must not receive invented 0. | |||
| 63 | + | o.hdrOn = false | |||
| 64 | + | o.hdrProps.HasDefault = false | |||
| 65 | + | if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok { | |||
| 66 | + | t.Fatal("SDR set Colorspace with no Default enum") | |||
| 67 | + | } | |||
| 68 | + | restore := &atomicReq{} | |||
| 69 | + | o.hdrConnectorProps(restore, false) | |||
| 70 | + | if _, ok := restore.value(o.conn.id, o.hdrProps.Colorspace); ok { | |||
| 71 | + | t.Fatal("restore set Colorspace with no Default enum") | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) { | |||
| 76 | + | o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL) | |||
| 77 | + | o.cursor = nil | |||
| 78 | + | o.hdr = hdrCapability{Capable: true} | |||
| 79 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203} | |||
| 80 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |||
| 81 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}} | |||
| 82 | + | r := portsmocks.NewMockRenderer(t) | |||
| 83 | + | buf := func() ports.DMABuf { | |||
| 84 | + | f, w, _ := os.Pipe() | |||
| 85 | + | w.Close() | |||
| 86 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 87 | + | } | |||
| 88 | + | var calls []string | |||
| 89 | + | r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once() | |||
| 90 | + | r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once() | |||
| 91 | + | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Twice() | |||
| 92 | + | r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once() | |||
| 93 | + | r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 94 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 95 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 96 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |||
| 97 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(71, nil) | |||
| 98 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 99 | + | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once() | |||
| 100 | + | k.EXPECT().createBlob(mock.Anything).Return(11, nil) | |||
| 101 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |||
| 102 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) { | |||
| 106 | + | t.Fatalf("fallback order: %v", calls) | |||
| 107 | + | } | |||
| 108 | + | if o.hdrOn || !o.hdrFailed { | |||
| 109 | + | t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed) | |||
| 110 | + | } | |||
| 111 | + | if len(*commits) != 4 { | |||
| 112 | + | t.Fatalf("commits: %d", len(*commits)) | |||
| 113 | + | } | |||
| 114 | + | for i := range 2 { | |||
| 115 | + | if (*commits)[i].flags != atomicTestOnly|atomicAllowModes { | |||
| 116 | + | t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes { | |||
| 120 | + | t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags) | |||
| 121 | + | } | |||
| 122 | + | if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 { | |||
| 123 | + | t.Fatalf("HDR test metadata: %d", v) | |||
| 124 | + | } | |||
| 125 | + | if v, _ := (*commits)[len(*commits)-1].req.value(o.conn.id, 100); v != 0 { | |||
| 126 | + | t.Fatalf("SDR metadata: %d", v) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestHDRSettingsRestartComparison(t *testing.T) { | |||
| 131 | + | current := HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |||
| 132 | + | for _, tc := range []struct { | |||
| 133 | + | settings HDRSettings | |||
| 134 | + | restart bool | |||
| 135 | + | }{ | |||
| 136 | + | {HDRSettings{Enabled: true}, false}, | |||
| 137 | + | {HDRSettings{Enabled: true, SDRBrightness: 350}, true}, | |||
| 138 | + | {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true}, | |||
| 139 | + | } { | |||
| 140 | + | if restart := outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{}, tc.settings); restart != tc.restart { | |||
| 141 | + | t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) { | |||
| 145 | + | t.Fatal("mode change did not restart") | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestHDRSuccessfulModeset(t *testing.T) { | |||
| 150 | + | o, k, commits := testOutput(t) | |||
| 151 | + | o.cursor = nil | |||
| 152 | + | o.hdr = hdrCapability{Capable: true} | |||
| 153 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |||
| 154 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |||
| 155 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |||
| 156 | + | r := portsmocks.NewMockRenderer(t) | |||
| 157 | + | buf := func() ports.DMABuf { | |||
| 158 | + | f, w, _ := os.Pipe() | |||
| 159 | + | w.Close() | |||
| 160 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 161 | + | } | |||
| 162 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once() | |||
| 163 | + | r.EXPECT().ExportTargets(2, []uint64{19}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 164 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 165 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 166 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice() | |||
| 167 | + | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once() | |||
| 168 | + | k.EXPECT().createBlob(mock.Anything).Return(11, nil).Twice() | |||
| 169 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |||
| 170 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 171 | + | t.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | if !o.hdrOn || o.hdrFailed || len(*commits) != 2 { | |||
| 174 | + | t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits)) | |||
| 175 | + | } | |||
| 176 | + | for _, c := range *commits { | |||
| 177 | + | for _, tc := range []struct { | |||
| 178 | + | prop uint32 | |||
| 179 | + | want uint64 | |||
| 180 | + | }{{100, 321}, {101, 7}, {102, 10}} { | |||
| 181 | + | if got, ok := c.req.value(o.conn.id, tc.prop); !ok || got != tc.want { | |||
| 182 | + | t.Fatalf("commit prop %d: %d (%t), want %d", tc.prop, got, ok, tc.want) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // The metadata blob is retained across repeated image setup (e.g. VT resume) | |||
| 189 | + | // and destroyed only once when Close restores SDR. | |||
| 190 | + | func TestHDRBlobReusedAcrossImageSetup(t *testing.T) { | |||
| 191 | + | o, k, commits := testOutput(t) | |||
| 192 | + | o.cursor = nil | |||
| 193 | + | o.hdr = hdrCapability{Capable: true} | |||
| 194 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |||
| 195 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |||
| 196 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |||
| 197 | + | r := portsmocks.NewMockRenderer(t) | |||
| 198 | + | buf := func() ports.DMABuf { | |||
| 199 | + | f, w, _ := os.Pipe() | |||
| 200 | + | w.Close() | |||
| 201 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 202 | + | } | |||
| 203 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice() | |||
| 204 | + | r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice() | |||
| 205 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 206 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 207 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |||
| 208 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 209 | + | created, destroyed := 0, 0 | |||
| 210 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) { | |||
| 211 | + | if len(data) == 32 { | |||
| 212 | + | created++ | |||
| 213 | + | return 321, nil | |||
| 214 | + | } | |||
| 215 | + | return 11, nil | |||
| 216 | + | }) | |||
| 217 | + | k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error { | |||
| 218 | + | if id == 321 { | |||
| 219 | + | destroyed++ | |||
| 220 | + | } | |||
| 221 | + | return nil | |||
| 222 | + | }) | |||
| 223 | + | for i := 0; i < 2; i++ { | |||
| 224 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 225 | + | t.Fatal(err) | |||
| 226 | + | } | |||
| 227 | + | if o.hdrBlob != 321 || created != 1 || destroyed != 0 { | |||
| 228 | + | t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | if len(*commits) != 4 { | |||
| 232 | + | t.Fatalf("commits: %d", len(*commits)) | |||
| 233 | + | } | |||
| 234 | + | o.Close() | |||
| 235 | + | if created != 1 || destroyed != 1 { | |||
| 236 | + | t.Fatalf("created %d destroyed %d", created, destroyed) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestHDRBlobReplacementWaitsForCommit(t *testing.T) { | |||
| 241 | + | o, k, _ := testOutput(t) | |||
| 242 | + | o.cursor = nil | |||
| 243 | + | o.hdr = hdrCapability{Capable: true} | |||
| 244 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness} | |||
| 245 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |||
| 246 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}} | |||
| 247 | + | r := portsmocks.NewMockRenderer(t) | |||
| 248 | + | buf := func() ports.DMABuf { | |||
| 249 | + | f, w, _ := os.Pipe() | |||
| 250 | + | w.Close() | |||
| 251 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 252 | + | } | |||
| 253 | + | r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice() | |||
| 254 | + | r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice() | |||
| 255 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 256 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 257 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil) | |||
| 258 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 259 | + | metadata := uint32(320) | |||
| 260 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) { | |||
| 261 | + | if len(data) == 32 { | |||
| 262 | + | metadata++ | |||
| 263 | + | return metadata, nil | |||
| 264 | + | } | |||
| 265 | + | return 11, nil | |||
| 266 | + | }) | |||
| 267 | + | var destroyed []uint32 | |||
| 268 | + | k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error { | |||
| 269 | + | if id >= 321 { | |||
| 270 | + | destroyed = append(destroyed, id) | |||
| 271 | + | } | |||
| 272 | + | return nil | |||
| 273 | + | }) | |||
| 274 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | o.monitor.HDR.MaxLuminance = 1000 | |||
| 278 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 279 | + | t.Fatal(err) | |||
| 280 | + | } | |||
| 281 | + | if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) { | |||
| 282 | + | t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed) | |||
| 283 | + | } | |||
| 284 | + | o.Close() | |||
| 285 | + | if !slices.Equal(destroyed, []uint32{321, 322}) { | |||
| 286 | + | t.Fatalf("destroyed %v", destroyed) | |||
| 287 | + | } | |||
| 288 | + | } | |||
M internal/adapters/drm/kms.go +8 -0
| @@ -94,6 +94,8 @@ type Want struct { | |||
| 94 | 94 | Disabled map[string]bool | |
| 95 | 95 | // Modes maps a connector to W, H and Hz (0: highest refresh at W x H). | |
| 96 | 96 | Modes map[string][3]float64 | |
| 97 | + | // HDR settings are retained per connector; phase 1 never applies them to KMS. | |
| 98 | + | HDR map[string]HDRSettings | |
| 97 | 99 | // NoScanout disables direct scanout (render.direct-scanout = off). | |
| 98 | 100 | NoScanout bool | |
| 99 | 101 | // NoTearing ignores tearing requests (render.tearing = off). | |
| @@ -107,6 +109,12 @@ type Want struct { | |||
| 107 | 109 | Device uint64 | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 112 | + | // HDRSettings is the requested HDR state and SDR reference white (nits). | |
| 113 | + | type HDRSettings struct { | |
| 114 | + | Enabled bool | |
| 115 | + | SDRBrightness int | |
| 116 | + | } | |
| 117 | + | ||
| 110 | 118 | // usable reports whether a connector should be driven. | |
| 111 | 119 | func (w Want) usable(c connector) bool { | |
| 112 | 120 | return c.connected && len(c.modes) > 0 && !w.Disabled[c.name] | |
| @@ -94,6 +94,8 @@ type Want struct { | |||||
| 94 | Disabled map[string]bool | 94 | Disabled map[string]bool | ||
| 95 | // Modes maps a connector to W, H and Hz (0: highest refresh at W x H). | 95 | // Modes maps a connector to W, H and Hz (0: highest refresh at W x H). | ||
| 96 | Modes map[string][3]float64 | 96 | Modes map[string][3]float64 | ||
| 97 | + | // HDR settings are retained per connector; phase 1 never applies them to KMS. | |||
| 98 | + | HDR map[string]HDRSettings | |||
| 97 | // NoScanout disables direct scanout (render.direct-scanout = off). | 99 | // NoScanout disables direct scanout (render.direct-scanout = off). | ||
| 98 | NoScanout bool | 100 | NoScanout bool | ||
| 99 | // NoTearing ignores tearing requests (render.tearing = off). | 101 | // NoTearing ignores tearing requests (render.tearing = off). | ||
| @@ -107,6 +109,12 @@ type Want struct { | |||||
| 107 | Device uint64 | 109 | Device uint64 | ||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 112 | + | // HDRSettings is the requested HDR state and SDR reference white (nits). | |||
| 113 | + | type HDRSettings struct { | |||
| 114 | + | Enabled bool | |||
| 115 | + | SDRBrightness int | |||
| 116 | + | } | |||
| 117 | + | ||||
| 110 | // usable reports whether a connector should be driven. | 118 | // usable reports whether a connector should be driven. | ||
| 111 | func (w Want) usable(c connector) bool { | 119 | func (w Want) usable(c connector) bool { | ||
| 112 | return c.connected && len(c.modes) > 0 && !w.Disabled[c.name] | 120 | return c.connected && len(c.modes) > 0 && !w.Disabled[c.name] | ||
M internal/adapters/drm/output.go +126 -15
| @@ -26,14 +26,22 @@ import ( | |||
| 26 | 26 | // it with EBUSY (it retries soon), and an event that never comes is | |
| 27 | 27 | // bounded (stuckTimeout, then a modeset). | |
| 28 | 28 | type Output struct { | |
| 29 | - | k kms | |
| 30 | - | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | |
| 31 | - | crtc uint32 | |
| 32 | - | conn connector | |
| 33 | - | mode modeInfo | |
| 34 | - | saved modeCrtc | |
| 35 | - | monitor Monitor | |
| 36 | - | log zerowrap.Logger | |
| 29 | + | k kms | |
| 30 | + | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | |
| 31 | + | crtc uint32 | |
| 32 | + | conn connector | |
| 33 | + | mode modeInfo | |
| 34 | + | saved modeCrtc | |
| 35 | + | monitor Monitor | |
| 36 | + | hdr hdrCapability | |
| 37 | + | hdrProps connectorHDRProps | |
| 38 | + | hdrSettings HDRSettings | |
| 39 | + | // hdrFailed disables retries until this Output is replaced; hdrOn is | |
| 40 | + | // the currently selected signal encoding, including on VT resume. | |
| 41 | + | hdrOn, hdrFailed bool | |
| 42 | + | hdrBlob uint32 | |
| 43 | + | hdrBlobData hdrOutputMetadata | |
| 44 | + | log zerowrap.Logger | |
| 37 | 45 | // Properties: CRTC and connector property IDs by name. | |
| 38 | 46 | crtcProps map[string]uint32 | |
| 39 | 47 | connCrtc uint32 // the connector's CRTC_ID property | |
| @@ -283,9 +291,15 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er | |||
| 283 | 291 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | |
| 284 | 292 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | |
| 285 | 293 | } | |
| 286 | - | if err := o.readProps(pipe, card.taken, cursorSize(card.fd)); err != nil { | |
| 294 | + | if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil { | |
| 287 | 295 | return nil, err | |
| 288 | 296 | } | |
| 297 | + | o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name]) | |
| 298 | + | o.hdr = detectHDR(o.monitor, o.hdrProps) | |
| 299 | + | log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.Capable).Str("reason", o.hdr.Reason).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Float64("min_luminance", o.hdr.MinLuminance).Msg("HDR capability") | |
| 300 | + | if o.hdrSettings.Enabled && !o.hdr.Capable { | |
| 301 | + | log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable") | |
| 302 | + | } | |
| 289 | 303 | o.tearing = card.async && !card.want.NoTearing | |
| 290 | 304 | if !card.want.NoVRR { | |
| 291 | 305 | o.vrrProp = vrrProperty(o.k, c.id, crtc) | |
| @@ -296,7 +310,7 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er | |||
| 296 | 310 | ||
| 297 | 311 | // readProps finds the output's planes and property IDs. taken are planes | |
| 298 | 312 | // other outputs drive. | |
| 299 | - | func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) error { | |
| 313 | + | func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int) error { | |
| 300 | 314 | cp, err := o.k.objProps(o.crtc, objCrtc) | |
| 301 | 315 | if err != nil { | |
| 302 | 316 | return fmt.Errorf("crtc properties: %w", err) | |
| @@ -310,6 +324,8 @@ func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) erro | |||
| 310 | 324 | return fmt.Errorf("connector properties: %w", err) | |
| 311 | 325 | } | |
| 312 | 326 | o.connCrtc = uint32(np["CRTC_ID"][0]) | |
| 327 | + | // The connector's property metadata is needed for enum/range capability. | |
| 328 | + | o.hdrProps = readConnectorHDRProps(fd, np) | |
| 313 | 329 | if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 { | |
| 314 | 330 | return errors.New("crtc or connector lacks atomic properties") | |
| 315 | 331 | } | |
| @@ -467,7 +483,7 @@ func (o *Output) sendFormats() { | |||
| 467 | 483 | return | |
| 468 | 484 | } | |
| 469 | 485 | f := ports.OutputFormats{Output: o.conn.name, Device: o.device} | |
| 470 | - | if o.scanout { | |
| 486 | + | if o.scanout && !o.hdrOn { | |
| 471 | 487 | f.Formats = o.scanoutFormats(o.sampled) | |
| 472 | 488 | } | |
| 473 | 489 | select { | |
| @@ -528,6 +544,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||
| 528 | 544 | req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active)) | |
| 529 | 545 | req.set(o.crtc, o.vrrProp, 0) | |
| 530 | 546 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | |
| 547 | + | o.hdrConnectorProps(req, o.hdrOn) | |
| 531 | 548 | o.primaryProps(req, o.fbs[1-o.back]) | |
| 532 | 549 | if o.cursor != nil { | |
| 533 | 550 | o.cursor.props(req, o.crtc, cursorState{}) | |
| @@ -712,7 +729,9 @@ func boolValue(b bool) uint64 { | |||
| 712 | 729 | // composed. | |
| 713 | 730 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) { | |
| 714 | 731 | reason := "disabled" | |
| 715 | - | if o.scanout { | |
| 732 | + | if o.hdrOn { | |
| 733 | + | reason = "hdr" | |
| 734 | + | } else if o.scanout { | |
| 716 | 735 | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height()) | |
| 717 | 736 | } | |
| 718 | 737 | if reason == "" { | |
| @@ -784,6 +803,7 @@ func (o *Output) Close() { | |||
| 784 | 803 | o.cursor.props(req, o.crtc, cursorState{}) | |
| 785 | 804 | } | |
| 786 | 805 | o.overlayProps(req, overlayWin{}) | |
| 806 | + | o.hdrConnectorProps(req, false) | |
| 787 | 807 | req.set(o.crtc, o.vrrProp, 0) | |
| 788 | 808 | s := o.saved | |
| 789 | 809 | var blob uint32 | |
| @@ -815,9 +835,20 @@ func (o *Output) Close() { | |||
| 815 | 835 | req.set(p.id, p.prop("CRTC_ID"), 0) | |
| 816 | 836 | req.set(o.conn.id, o.connCrtc, 0) | |
| 817 | 837 | } | |
| 838 | + | // A live metadata blob means an HDR modeset may still be on screen, even | |
| 839 | + | // after a failed SDR fallback (hdrOn is then false): it is freed only once | |
| 840 | + | // an SDR modeset succeeds. | |
| 841 | + | hdrShown := o.hdrOn || o.hdrBlob != 0 | |
| 818 | 842 | // Best effort: at exit the card may already belong to another session. | |
| 819 | 843 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 820 | - | o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc") | |
| 844 | + | ev := o.log.Debug() | |
| 845 | + | if hdrShown { | |
| 846 | + | // The display may stay in HDR mode until the next modeset. | |
| 847 | + | ev = o.log.Warn() | |
| 848 | + | } | |
| 849 | + | ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc") | |
| 850 | + | } else if hdrShown { | |
| 851 | + | o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off") | |
| 821 | 852 | } | |
| 822 | 853 | if blob != 0 { | |
| 823 | 854 | _ = o.k.destroyBlob(blob) | |
| @@ -826,6 +857,10 @@ func (o *Output) Close() { | |||
| 826 | 857 | _ = o.k.destroyBlob(o.modeBlob) | |
| 827 | 858 | o.modeBlob = 0 | |
| 828 | 859 | } | |
| 860 | + | if o.hdrBlob != 0 { | |
| 861 | + | _ = o.k.destroyBlob(o.hdrBlob) | |
| 862 | + | o.hdrBlob = 0 | |
| 863 | + | } | |
| 829 | 864 | if o.cursor != nil { | |
| 830 | 865 | o.cursor.free(o.k) | |
| 831 | 866 | } | |
| @@ -1283,6 +1318,63 @@ const ( | |||
| 1283 | 1318 | // next kind is tried on refusal. cause is why the | |
| 1284 | 1319 | // previous kind failed, for the log. | |
| 1285 | 1320 | func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error { | |
| 1321 | + | if o.hdrSettings.Enabled && o.hdr.Capable && !o.hdrFailed { | |
| 1322 | + | o.hdrOn = true | |
| 1323 | + | meta := hdrMetadata(o.monitor) | |
| 1324 | + | // Reuse the live blob across VT resume. A changed EDID creates a | |
| 1325 | + | // replacement, but the old one remains alive until KMS accepts it. | |
| 1326 | + | oldBlob := o.hdrBlob | |
| 1327 | + | created := false | |
| 1328 | + | var err error | |
| 1329 | + | if oldBlob == 0 || meta != o.hdrBlobData { | |
| 1330 | + | var newBlob uint32 | |
| 1331 | + | newBlob, err = o.k.createBlob(meta.bytes()) | |
| 1332 | + | if err == nil { | |
| 1333 | + | o.hdrBlob = newBlob | |
| 1334 | + | created = true | |
| 1335 | + | } | |
| 1336 | + | } | |
| 1337 | + | if err == nil { | |
| 1338 | + | r.SetHDR(float64(o.hdrSettings.SDRBrightness)) | |
| 1339 | + | err = o.showImageKind(r, imagesDriver, nil) | |
| 1340 | + | } | |
| 1341 | + | if err == nil { | |
| 1342 | + | if created { | |
| 1343 | + | o.hdrBlobData = meta | |
| 1344 | + | if oldBlob != 0 { | |
| 1345 | + | _ = o.k.destroyBlob(oldBlob) | |
| 1346 | + | } | |
| 1347 | + | } | |
| 1348 | + | o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdrSettings.SDRBrightness).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Msg("HDR10 on") | |
| 1349 | + | return nil | |
| 1350 | + | } | |
| 1351 | + | if created && oldBlob != 0 { | |
| 1352 | + | // Failed replacement: the existing modeset may still use it. | |
| 1353 | + | _ = o.k.destroyBlob(o.hdrBlob) | |
| 1354 | + | o.hdrBlob = oldBlob | |
| 1355 | + | } | |
| 1356 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR") | |
| 1357 | + | o.hdrFailed = true | |
| 1358 | + | o.hdrOn = false | |
| 1359 | + | o.freeImages() | |
| 1360 | + | r.SetHDR(0) | |
| 1361 | + | _, _ = r.ExportTargets(0, nil) | |
| 1362 | + | // A failed test commit can leave the old HDR mode on screen; | |
| 1363 | + | // retain its blob until the SDR modeset succeeds or Close restores it. | |
| 1364 | + | } else { | |
| 1365 | + | r.SetHDR(0) | |
| 1366 | + | } | |
| 1367 | + | err := o.showImageKind(r, kind, cause) | |
| 1368 | + | if err == nil && o.hdrFailed && o.hdrBlob != 0 { | |
| 1369 | + | // The SDR commit has completed; the old HDR metadata is no longer in use. | |
| 1370 | + | _ = o.k.destroyBlob(o.hdrBlob) | |
| 1371 | + | o.hdrBlob = 0 | |
| 1372 | + | o.hdrBlobData = hdrOutputMetadata{} | |
| 1373 | + | } | |
| 1374 | + | return err | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error { | |
| 1286 | 1378 | for { | |
| 1287 | 1379 | got, err := o.setupImages(r, kind, cause) | |
| 1288 | 1380 | if err != nil { | |
| @@ -1319,7 +1411,22 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima | |||
| 1319 | 1411 | err := cause | |
| 1320 | 1412 | for ; kind <= imagesLinear; kind++ { | |
| 1321 | 1413 | mods := []uint64(nil) | |
| 1322 | - | if kind == imagesLinear { | |
| 1414 | + | if o.hdrOn { | |
| 1415 | + | for _, f := range o.primary.formats { | |
| 1416 | + | if f.Format == fourccXR30 { | |
| 1417 | + | mods = append(mods, f.Modifier) | |
| 1418 | + | } | |
| 1419 | + | } | |
| 1420 | + | if len(mods) == 0 { | |
| 1421 | + | return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers") | |
| 1422 | + | } | |
| 1423 | + | if kind == imagesLinear { | |
| 1424 | + | if !slices.Contains(mods, uint64(0)) { | |
| 1425 | + | continue | |
| 1426 | + | } | |
| 1427 | + | mods = []uint64{0} | |
| 1428 | + | } | |
| 1429 | + | } else if kind == imagesLinear { | |
| 1323 | 1430 | mods = []uint64{0} | |
| 1324 | 1431 | } | |
| 1325 | 1432 | if err = o.exportImages(r, mods); err == nil { | |
| @@ -1338,7 +1445,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error { | |||
| 1338 | 1445 | } | |
| 1339 | 1446 | for i := range bufs { | |
| 1340 | 1447 | if err == nil { | |
| 1341 | - | o.fbs[i], err = o.k.addFB(&bufs[i], fourccXRGB) | |
| 1448 | + | format := uint32(fourccXRGB) | |
| 1449 | + | if o.hdrOn { | |
| 1450 | + | format = fourccXR30 | |
| 1451 | + | } | |
| 1452 | + | o.fbs[i], err = o.k.addFB(&bufs[i], format) | |
| 1342 | 1453 | } | |
| 1343 | 1454 | bufs[i].Planes[0].File.Close() | |
| 1344 | 1455 | } | |
| @@ -26,14 +26,22 @@ import ( | |||||
| 26 | // it with EBUSY (it retries soon), and an event that never comes is | 26 | // it with EBUSY (it retries soon), and an event that never comes is | ||
| 27 | // bounded (stuckTimeout, then a modeset). | 27 | // bounded (stuckTimeout, then a modeset). | ||
| 28 | type Output struct { | 28 | type Output struct { | ||
| 29 | - | k kms | 29 | + | k kms |
| 30 | - | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | 30 | + | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents |
| 31 | - | crtc uint32 | 31 | + | crtc uint32 |
| 32 | - | conn connector | 32 | + | conn connector |
| 33 | - | mode modeInfo | 33 | + | mode modeInfo |
| 34 | - | saved modeCrtc | 34 | + | saved modeCrtc |
| 35 | - | monitor Monitor | 35 | + | monitor Monitor |
| 36 | - | log zerowrap.Logger | 36 | + | hdr hdrCapability |
| 37 | + | hdrProps connectorHDRProps | |||
| 38 | + | hdrSettings HDRSettings | |||
| 39 | + | // hdrFailed disables retries until this Output is replaced; hdrOn is | |||
| 40 | + | // the currently selected signal encoding, including on VT resume. | |||
| 41 | + | hdrOn, hdrFailed bool | |||
| 42 | + | hdrBlob uint32 | |||
| 43 | + | hdrBlobData hdrOutputMetadata | |||
| 44 | + | log zerowrap.Logger | |||
| 37 | // Properties: CRTC and connector property IDs by name. | 45 | // Properties: CRTC and connector property IDs by name. | ||
| 38 | crtcProps map[string]uint32 | 46 | crtcProps map[string]uint32 | ||
| 39 | connCrtc uint32 // the connector's CRTC_ID property | 47 | connCrtc uint32 // the connector's CRTC_ID property | ||
| @@ -283,9 +291,15 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er | |||||
| 283 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | 291 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | ||
| 284 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | 292 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | ||
| 285 | } | 293 | } | ||
| 286 | - | if err := o.readProps(pipe, card.taken, cursorSize(card.fd)); err != nil { | 294 | + | if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil { |
| 287 | return nil, err | 295 | return nil, err | ||
| 288 | } | 296 | } | ||
| 297 | + | o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name]) | |||
| 298 | + | o.hdr = detectHDR(o.monitor, o.hdrProps) | |||
| 299 | + | log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.Capable).Str("reason", o.hdr.Reason).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Float64("min_luminance", o.hdr.MinLuminance).Msg("HDR capability") | |||
| 300 | + | if o.hdrSettings.Enabled && !o.hdr.Capable { | |||
| 301 | + | log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable") | |||
| 302 | + | } | |||
| 289 | o.tearing = card.async && !card.want.NoTearing | 303 | o.tearing = card.async && !card.want.NoTearing | ||
| 290 | if !card.want.NoVRR { | 304 | if !card.want.NoVRR { | ||
| 291 | o.vrrProp = vrrProperty(o.k, c.id, crtc) | 305 | o.vrrProp = vrrProperty(o.k, c.id, crtc) | ||
| @@ -296,7 +310,7 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er | |||||
| 296 | 310 | ||||
| 297 | // readProps finds the output's planes and property IDs. taken are planes | 311 | // readProps finds the output's planes and property IDs. taken are planes | ||
| 298 | // other outputs drive. | 312 | // other outputs drive. | ||
| 299 | - | func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) error { | 313 | + | func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int) error { |
| 300 | cp, err := o.k.objProps(o.crtc, objCrtc) | 314 | cp, err := o.k.objProps(o.crtc, objCrtc) | ||
| 301 | if err != nil { | 315 | if err != nil { | ||
| 302 | return fmt.Errorf("crtc properties: %w", err) | 316 | return fmt.Errorf("crtc properties: %w", err) | ||
| @@ -310,6 +324,8 @@ func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) erro | |||||
| 310 | return fmt.Errorf("connector properties: %w", err) | 324 | return fmt.Errorf("connector properties: %w", err) | ||
| 311 | } | 325 | } | ||
| 312 | o.connCrtc = uint32(np["CRTC_ID"][0]) | 326 | o.connCrtc = uint32(np["CRTC_ID"][0]) | ||
| 327 | + | // The connector's property metadata is needed for enum/range capability. | |||
| 328 | + | o.hdrProps = readConnectorHDRProps(fd, np) | |||
| 313 | if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 { | 329 | if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 { | ||
| 314 | return errors.New("crtc or connector lacks atomic properties") | 330 | return errors.New("crtc or connector lacks atomic properties") | ||
| 315 | } | 331 | } | ||
| @@ -467,7 +483,7 @@ func (o *Output) sendFormats() { | |||||
| 467 | return | 483 | return | ||
| 468 | } | 484 | } | ||
| 469 | f := ports.OutputFormats{Output: o.conn.name, Device: o.device} | 485 | f := ports.OutputFormats{Output: o.conn.name, Device: o.device} | ||
| 470 | - | if o.scanout { | 486 | + | if o.scanout && !o.hdrOn { |
| 471 | f.Formats = o.scanoutFormats(o.sampled) | 487 | f.Formats = o.scanoutFormats(o.sampled) | ||
| 472 | } | 488 | } | ||
| 473 | select { | 489 | select { | ||
| @@ -528,6 +544,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||||
| 528 | req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active)) | 544 | req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active)) | ||
| 529 | req.set(o.crtc, o.vrrProp, 0) | 545 | req.set(o.crtc, o.vrrProp, 0) | ||
| 530 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | 546 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | ||
| 547 | + | o.hdrConnectorProps(req, o.hdrOn) | |||
| 531 | o.primaryProps(req, o.fbs[1-o.back]) | 548 | o.primaryProps(req, o.fbs[1-o.back]) | ||
| 532 | if o.cursor != nil { | 549 | if o.cursor != nil { | ||
| 533 | o.cursor.props(req, o.crtc, cursorState{}) | 550 | o.cursor.props(req, o.crtc, cursorState{}) | ||
| @@ -712,7 +729,9 @@ func boolValue(b bool) uint64 { | |||||
| 712 | // composed. | 729 | // composed. | ||
| 713 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) { | 730 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) { | ||
| 714 | reason := "disabled" | 731 | reason := "disabled" | ||
| 715 | - | if o.scanout { | 732 | + | if o.hdrOn { |
| 733 | + | reason = "hdr" | |||
| 734 | + | } else if o.scanout { | |||
| 716 | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height()) | 735 | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height()) | ||
| 717 | } | 736 | } | ||
| 718 | if reason == "" { | 737 | if reason == "" { | ||
| @@ -784,6 +803,7 @@ func (o *Output) Close() { | |||||
| 784 | o.cursor.props(req, o.crtc, cursorState{}) | 803 | o.cursor.props(req, o.crtc, cursorState{}) | ||
| 785 | } | 804 | } | ||
| 786 | o.overlayProps(req, overlayWin{}) | 805 | o.overlayProps(req, overlayWin{}) | ||
| 806 | + | o.hdrConnectorProps(req, false) | |||
| 787 | req.set(o.crtc, o.vrrProp, 0) | 807 | req.set(o.crtc, o.vrrProp, 0) | ||
| 788 | s := o.saved | 808 | s := o.saved | ||
| 789 | var blob uint32 | 809 | var blob uint32 | ||
| @@ -815,9 +835,20 @@ func (o *Output) Close() { | |||||
| 815 | req.set(p.id, p.prop("CRTC_ID"), 0) | 835 | req.set(p.id, p.prop("CRTC_ID"), 0) | ||
| 816 | req.set(o.conn.id, o.connCrtc, 0) | 836 | req.set(o.conn.id, o.connCrtc, 0) | ||
| 817 | } | 837 | } | ||
| 838 | + | // A live metadata blob means an HDR modeset may still be on screen, even | |||
| 839 | + | // after a failed SDR fallback (hdrOn is then false): it is freed only once | |||
| 840 | + | // an SDR modeset succeeds. | |||
| 841 | + | hdrShown := o.hdrOn || o.hdrBlob != 0 | |||
| 818 | // Best effort: at exit the card may already belong to another session. | 842 | // Best effort: at exit the card may already belong to another session. | ||
| 819 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | 843 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | ||
| 820 | - | o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc") | 844 | + | ev := o.log.Debug() |
| 845 | + | if hdrShown { | |||
| 846 | + | // The display may stay in HDR mode until the next modeset. | |||
| 847 | + | ev = o.log.Warn() | |||
| 848 | + | } | |||
| 849 | + | ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc") | |||
| 850 | + | } else if hdrShown { | |||
| 851 | + | o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off") | |||
| 821 | } | 852 | } | ||
| 822 | if blob != 0 { | 853 | if blob != 0 { | ||
| 823 | _ = o.k.destroyBlob(blob) | 854 | _ = o.k.destroyBlob(blob) | ||
| @@ -826,6 +857,10 @@ func (o *Output) Close() { | |||||
| 826 | _ = o.k.destroyBlob(o.modeBlob) | 857 | _ = o.k.destroyBlob(o.modeBlob) | ||
| 827 | o.modeBlob = 0 | 858 | o.modeBlob = 0 | ||
| 828 | } | 859 | } | ||
| 860 | + | if o.hdrBlob != 0 { | |||
| 861 | + | _ = o.k.destroyBlob(o.hdrBlob) | |||
| 862 | + | o.hdrBlob = 0 | |||
| 863 | + | } | |||
| 829 | if o.cursor != nil { | 864 | if o.cursor != nil { | ||
| 830 | o.cursor.free(o.k) | 865 | o.cursor.free(o.k) | ||
| 831 | } | 866 | } | ||
| @@ -1283,6 +1318,63 @@ const ( | |||||
| 1283 | // next kind is tried on refusal. cause is why the | 1318 | // next kind is tried on refusal. cause is why the | ||
| 1284 | // previous kind failed, for the log. | 1319 | // previous kind failed, for the log. | ||
| 1285 | func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error { | 1320 | func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error { | ||
| 1321 | + | if o.hdrSettings.Enabled && o.hdr.Capable && !o.hdrFailed { | |||
| 1322 | + | o.hdrOn = true | |||
| 1323 | + | meta := hdrMetadata(o.monitor) | |||
| 1324 | + | // Reuse the live blob across VT resume. A changed EDID creates a | |||
| 1325 | + | // replacement, but the old one remains alive until KMS accepts it. | |||
| 1326 | + | oldBlob := o.hdrBlob | |||
| 1327 | + | created := false | |||
| 1328 | + | var err error | |||
| 1329 | + | if oldBlob == 0 || meta != o.hdrBlobData { | |||
| 1330 | + | var newBlob uint32 | |||
| 1331 | + | newBlob, err = o.k.createBlob(meta.bytes()) | |||
| 1332 | + | if err == nil { | |||
| 1333 | + | o.hdrBlob = newBlob | |||
| 1334 | + | created = true | |||
| 1335 | + | } | |||
| 1336 | + | } | |||
| 1337 | + | if err == nil { | |||
| 1338 | + | r.SetHDR(float64(o.hdrSettings.SDRBrightness)) | |||
| 1339 | + | err = o.showImageKind(r, imagesDriver, nil) | |||
| 1340 | + | } | |||
| 1341 | + | if err == nil { | |||
| 1342 | + | if created { | |||
| 1343 | + | o.hdrBlobData = meta | |||
| 1344 | + | if oldBlob != 0 { | |||
| 1345 | + | _ = o.k.destroyBlob(oldBlob) | |||
| 1346 | + | } | |||
| 1347 | + | } | |||
| 1348 | + | o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdrSettings.SDRBrightness).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Msg("HDR10 on") | |||
| 1349 | + | return nil | |||
| 1350 | + | } | |||
| 1351 | + | if created && oldBlob != 0 { | |||
| 1352 | + | // Failed replacement: the existing modeset may still use it. | |||
| 1353 | + | _ = o.k.destroyBlob(o.hdrBlob) | |||
| 1354 | + | o.hdrBlob = oldBlob | |||
| 1355 | + | } | |||
| 1356 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR") | |||
| 1357 | + | o.hdrFailed = true | |||
| 1358 | + | o.hdrOn = false | |||
| 1359 | + | o.freeImages() | |||
| 1360 | + | r.SetHDR(0) | |||
| 1361 | + | _, _ = r.ExportTargets(0, nil) | |||
| 1362 | + | // A failed test commit can leave the old HDR mode on screen; | |||
| 1363 | + | // retain its blob until the SDR modeset succeeds or Close restores it. | |||
| 1364 | + | } else { | |||
| 1365 | + | r.SetHDR(0) | |||
| 1366 | + | } | |||
| 1367 | + | err := o.showImageKind(r, kind, cause) | |||
| 1368 | + | if err == nil && o.hdrFailed && o.hdrBlob != 0 { | |||
| 1369 | + | // The SDR commit has completed; the old HDR metadata is no longer in use. | |||
| 1370 | + | _ = o.k.destroyBlob(o.hdrBlob) | |||
| 1371 | + | o.hdrBlob = 0 | |||
| 1372 | + | o.hdrBlobData = hdrOutputMetadata{} | |||
| 1373 | + | } | |||
| 1374 | + | return err | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error { | |||
| 1286 | for { | 1378 | for { | ||
| 1287 | got, err := o.setupImages(r, kind, cause) | 1379 | got, err := o.setupImages(r, kind, cause) | ||
| 1288 | if err != nil { | 1380 | if err != nil { | ||
| @@ -1319,7 +1411,22 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima | |||||
| 1319 | err := cause | 1411 | err := cause | ||
| 1320 | for ; kind <= imagesLinear; kind++ { | 1412 | for ; kind <= imagesLinear; kind++ { | ||
| 1321 | mods := []uint64(nil) | 1413 | mods := []uint64(nil) | ||
| 1322 | - | if kind == imagesLinear { | 1414 | + | if o.hdrOn { |
| 1415 | + | for _, f := range o.primary.formats { | |||
| 1416 | + | if f.Format == fourccXR30 { | |||
| 1417 | + | mods = append(mods, f.Modifier) | |||
| 1418 | + | } | |||
| 1419 | + | } | |||
| 1420 | + | if len(mods) == 0 { | |||
| 1421 | + | return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers") | |||
| 1422 | + | } | |||
| 1423 | + | if kind == imagesLinear { | |||
| 1424 | + | if !slices.Contains(mods, uint64(0)) { | |||
| 1425 | + | continue | |||
| 1426 | + | } | |||
| 1427 | + | mods = []uint64{0} | |||
| 1428 | + | } | |||
| 1429 | + | } else if kind == imagesLinear { | |||
| 1323 | mods = []uint64{0} | 1430 | mods = []uint64{0} | ||
| 1324 | } | 1431 | } | ||
| 1325 | if err = o.exportImages(r, mods); err == nil { | 1432 | if err = o.exportImages(r, mods); err == nil { | ||
| @@ -1338,7 +1445,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error { | |||||
| 1338 | } | 1445 | } | ||
| 1339 | for i := range bufs { | 1446 | for i := range bufs { | ||
| 1340 | if err == nil { | 1447 | if err == nil { | ||
| 1341 | - | o.fbs[i], err = o.k.addFB(&bufs[i], fourccXRGB) | 1448 | + | format := uint32(fourccXRGB) |
| 1449 | + | if o.hdrOn { | |||
| 1450 | + | format = fourccXR30 | |||
| 1451 | + | } | |||
| 1452 | + | o.fbs[i], err = o.k.addFB(&bufs[i], format) | |||
| 1342 | } | 1453 | } | ||
| 1343 | bufs[i].Planes[0].File.Close() | 1454 | bufs[i].Planes[0].File.Close() | ||
| 1344 | } | 1455 | } | ||
M internal/adapters/drm/output_test.go +2 -0
| @@ -366,6 +366,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||
| 366 | 366 | w.Close() | |
| 367 | 367 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 368 | 368 | } | |
| 369 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 369 | 370 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | |
| 370 | 371 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 371 | 372 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| @@ -461,6 +462,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 461 | 462 | w.Close() | |
| 462 | 463 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 463 | 464 | } | |
| 465 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output | |
| 464 | 466 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once() | |
| 465 | 467 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 466 | 468 | rendered := make(chan ports.Scene, 3) | |
| @@ -366,6 +366,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||||
| 366 | w.Close() | 366 | w.Close() | ||
| 367 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 367 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 368 | } | 368 | } | ||
| 369 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 369 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | 370 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | ||
| 370 | r.EXPECT().UseTarget(mock.Anything).Return() | 371 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 371 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | 372 | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | ||
| @@ -461,6 +462,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||||
| 461 | w.Close() | 462 | w.Close() | ||
| 462 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 463 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 463 | } | 464 | } | ||
| 465 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output | |||
| 464 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once() | 466 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once() | ||
| 465 | r.EXPECT().UseTarget(mock.Anything).Return() | 467 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 466 | rendered := make(chan ports.Scene, 3) | 468 | rendered := make(chan ports.Scene, 3) | ||
M internal/adapters/drm/overlay.go +3 -1
| @@ -113,7 +113,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 113 | 113 | func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) { | |
| 114 | 114 | reason := "no_plane" | |
| 115 | 115 | var ov overlayWin | |
| 116 | - | if o.overlay != nil && o.scanout { | |
| 116 | + | if o.hdrOn { | |
| 117 | + | reason = "hdr" | |
| 118 | + | } else if o.overlay != nil && o.scanout { | |
| 117 | 119 | var w ports.SceneWindow | |
| 118 | 120 | var c ports.SurfaceContent | |
| 119 | 121 | w, c, reason = overlayCandidate(s, surfaces) | |
| @@ -113,7 +113,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 113 | func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) { | 113 | func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) { | ||
| 114 | reason := "no_plane" | 114 | reason := "no_plane" | ||
| 115 | var ov overlayWin | 115 | var ov overlayWin | ||
| 116 | - | if o.overlay != nil && o.scanout { | 116 | + | if o.hdrOn { |
| 117 | + | reason = "hdr" | |||
| 118 | + | } else if o.overlay != nil && o.scanout { | |||
| 117 | var w ports.SceneWindow | 119 | var w ports.SceneWindow | ||
| 118 | var c ports.SurfaceContent | 120 | var c ports.SurfaceContent | ||
| 119 | w, c, reason = overlayCandidate(s, surfaces) | 121 | w, c, reason = overlayCandidate(s, surfaces) | ||
M internal/adapters/drm/power_test.go +1 -0
| @@ -24,6 +24,7 @@ func TestRunOutputPower(t *testing.T) { | |||
| 24 | 24 | w.Close() | |
| 25 | 25 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 26 | 26 | } | |
| 27 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 27 | 28 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 28 | 29 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 29 | 30 | renders := make(chan struct{}, 8) | |
| @@ -24,6 +24,7 @@ func TestRunOutputPower(t *testing.T) { | |||||
| 24 | w.Close() | 24 | w.Close() | ||
| 25 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 25 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 26 | } | 26 | } | ||
| 27 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 27 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 28 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 28 | r.EXPECT().UseTarget(mock.Anything).Return() | 29 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 29 | renders := make(chan struct{}, 8) | 30 | renders := make(chan struct{}, 8) | ||
M internal/adapters/drm/props.go +98 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package drm | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "runtime" | |
| 4 | 5 | "unsafe" | |
| 5 | 6 | ||
| 6 | 7 | "golang.org/x/sys/unix" | |
| @@ -22,6 +23,11 @@ type objGetProps struct { | |||
| 22 | 23 | objType, _ uint32 | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 26 | + | type propEnum struct { | |
| 27 | + | value uint64 | |
| 28 | + | name [32]byte | |
| 29 | + | } | |
| 30 | + | ||
| 25 | 31 | type getProp struct { | |
| 26 | 32 | values, enumBlobs uint64 | |
| 27 | 33 | propID, flags uint32 | |
| @@ -55,6 +61,98 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) { | |||
| 55 | 61 | return out, nil | |
| 56 | 62 | } | |
| 57 | 63 | ||
| 64 | + | // connectorHDRProps retains connector property IDs, enum values and the | |
| 65 | + | // original max bpc for SDR modesets and restoration. | |
| 66 | + | type connectorHDRProps struct { | |
| 67 | + | Metadata, Colorspace, MaxBPC uint32 | |
| 68 | + | BT2020Value uint64 | |
| 69 | + | DefaultValue uint64 // connector Colorspace "Default" enum | |
| 70 | + | HasDefault bool | |
| 71 | + | MaxBPCValue uint64 // current value for eventual restoration | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // readConnectorHDRProps checks property metadata, not only current values. | |
| 75 | + | func readConnectorHDRProps(fd int, props map[string][2]uint64) connectorHDRProps { | |
| 76 | + | var out connectorHDRProps | |
| 77 | + | out.Metadata = uint32(props["HDR_OUTPUT_METADATA"][0]) | |
| 78 | + | if id := uint32(props["Colorspace"][0]); id != 0 { | |
| 79 | + | p := getProp{propID: id} | |
| 80 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countEn > 0 { | |
| 81 | + | enums := make([]propEnum, p.countEn) | |
| 82 | + | p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0]))) | |
| 83 | + | // The kernel also copies an enum's values when countValues is | |
| 84 | + | // set: a nil values pointer makes the whole call fail (EFAULT). | |
| 85 | + | values := make([]uint64, max(p.countValues, 1)) | |
| 86 | + | p.values = uint64(uintptr(unsafe.Pointer(&values[0]))) | |
| 87 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil { | |
| 88 | + | out.Colorspace, out.BT2020Value = bt2020Enum(id, enums[:min(int(p.countEn), len(enums))]) | |
| 89 | + | for _, e := range enums[:min(int(p.countEn), len(enums))] { | |
| 90 | + | if unix.ByteSliceToString(e.name[:]) == "Default" { | |
| 91 | + | out.DefaultValue, out.HasDefault = e.value, true | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | runtime.KeepAlive(enums) | |
| 96 | + | runtime.KeepAlive(values) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | if id := uint32(props["max bpc"][0]); id != 0 { | |
| 100 | + | p := getProp{propID: id} | |
| 101 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 { | |
| 102 | + | values := make([]uint64, p.countValues) | |
| 103 | + | p.values = uint64(uintptr(unsafe.Pointer(&values[0]))) | |
| 104 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 && bpcAllows10(values) { | |
| 105 | + | out.MaxBPC, out.MaxBPCValue = id, props["max bpc"][1] | |
| 106 | + | } | |
| 107 | + | runtime.KeepAlive(values) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | return out | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func bt2020Enum(id uint32, enums []propEnum) (uint32, uint64) { | |
| 114 | + | for _, e := range enums { | |
| 115 | + | if unix.ByteSliceToString(e.name[:]) == "BT2020_RGB" { | |
| 116 | + | return id, e.value | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return 0, 0 | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func bpcAllows10(values []uint64) bool { | |
| 123 | + | return len(values) >= 2 && values[0] <= 10 && values[1] >= 10 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // hdrCapability combines EDID and connector support, retaining the first reason. | |
| 127 | + | type hdrCapability struct { | |
| 128 | + | Capable bool | |
| 129 | + | Reason string | |
| 130 | + | HDRMetadata | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func detectHDR(m Monitor, p connectorHDRProps) hdrCapability { | |
| 134 | + | c := hdrCapability{HDRMetadata: m.HDR} | |
| 135 | + | switch { | |
| 136 | + | case !m.HDR.PQ: | |
| 137 | + | c.Reason = "edid: no PQ" | |
| 138 | + | case !m.HDR.StaticType1: | |
| 139 | + | c.Reason = "edid: no static metadata type 1" | |
| 140 | + | case !m.HDR.BT2020RGB: | |
| 141 | + | c.Reason = "edid: no BT2020RGB" | |
| 142 | + | case p.Metadata == 0: | |
| 143 | + | c.Reason = "connector: no HDR_OUTPUT_METADATA" | |
| 144 | + | case p.Colorspace == 0: | |
| 145 | + | c.Reason = "connector: no BT2020_RGB Colorspace" | |
| 146 | + | case p.MaxBPC == 0: | |
| 147 | + | c.Reason = "connector: max bpc below 10" | |
| 148 | + | case !p.HasDefault: | |
| 149 | + | c.Reason = "connector: no Default Colorspace" | |
| 150 | + | default: | |
| 151 | + | c.Capable = true | |
| 152 | + | } | |
| 153 | + | return c | |
| 154 | + | } | |
| 155 | + | ||
| 58 | 156 | // vrrProperty returns the CRTC's VRR_ENABLED property when the connector | |
| 59 | 157 | // is VRR capable, else 0. | |
| 60 | 158 | func vrrProperty(k kms, conn, crtc uint32) uint32 { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package drm | 1 | package drm | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "runtime" | |||
| 4 | "unsafe" | 5 | "unsafe" | ||
| 5 | 6 | ||||
| 6 | "golang.org/x/sys/unix" | 7 | "golang.org/x/sys/unix" | ||
| @@ -22,6 +23,11 @@ type objGetProps struct { | |||||
| 22 | objType, _ uint32 | 23 | objType, _ uint32 | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 26 | + | type propEnum struct { | |||
| 27 | + | value uint64 | |||
| 28 | + | name [32]byte | |||
| 29 | + | } | |||
| 30 | + | ||||
| 25 | type getProp struct { | 31 | type getProp struct { | ||
| 26 | values, enumBlobs uint64 | 32 | values, enumBlobs uint64 | ||
| 27 | propID, flags uint32 | 33 | propID, flags uint32 | ||
| @@ -55,6 +61,98 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) { | |||||
| 55 | return out, nil | 61 | return out, nil | ||
| 56 | } | 62 | } | ||
| 57 | 63 | ||||
| 64 | + | // connectorHDRProps retains connector property IDs, enum values and the | |||
| 65 | + | // original max bpc for SDR modesets and restoration. | |||
| 66 | + | type connectorHDRProps struct { | |||
| 67 | + | Metadata, Colorspace, MaxBPC uint32 | |||
| 68 | + | BT2020Value uint64 | |||
| 69 | + | DefaultValue uint64 // connector Colorspace "Default" enum | |||
| 70 | + | HasDefault bool | |||
| 71 | + | MaxBPCValue uint64 // current value for eventual restoration | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // readConnectorHDRProps checks property metadata, not only current values. | |||
| 75 | + | func readConnectorHDRProps(fd int, props map[string][2]uint64) connectorHDRProps { | |||
| 76 | + | var out connectorHDRProps | |||
| 77 | + | out.Metadata = uint32(props["HDR_OUTPUT_METADATA"][0]) | |||
| 78 | + | if id := uint32(props["Colorspace"][0]); id != 0 { | |||
| 79 | + | p := getProp{propID: id} | |||
| 80 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countEn > 0 { | |||
| 81 | + | enums := make([]propEnum, p.countEn) | |||
| 82 | + | p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0]))) | |||
| 83 | + | // The kernel also copies an enum's values when countValues is | |||
| 84 | + | // set: a nil values pointer makes the whole call fail (EFAULT). | |||
| 85 | + | values := make([]uint64, max(p.countValues, 1)) | |||
| 86 | + | p.values = uint64(uintptr(unsafe.Pointer(&values[0]))) | |||
| 87 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil { | |||
| 88 | + | out.Colorspace, out.BT2020Value = bt2020Enum(id, enums[:min(int(p.countEn), len(enums))]) | |||
| 89 | + | for _, e := range enums[:min(int(p.countEn), len(enums))] { | |||
| 90 | + | if unix.ByteSliceToString(e.name[:]) == "Default" { | |||
| 91 | + | out.DefaultValue, out.HasDefault = e.value, true | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | runtime.KeepAlive(enums) | |||
| 96 | + | runtime.KeepAlive(values) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | if id := uint32(props["max bpc"][0]); id != 0 { | |||
| 100 | + | p := getProp{propID: id} | |||
| 101 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 { | |||
| 102 | + | values := make([]uint64, p.countValues) | |||
| 103 | + | p.values = uint64(uintptr(unsafe.Pointer(&values[0]))) | |||
| 104 | + | if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 && bpcAllows10(values) { | |||
| 105 | + | out.MaxBPC, out.MaxBPCValue = id, props["max bpc"][1] | |||
| 106 | + | } | |||
| 107 | + | runtime.KeepAlive(values) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | return out | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func bt2020Enum(id uint32, enums []propEnum) (uint32, uint64) { | |||
| 114 | + | for _, e := range enums { | |||
| 115 | + | if unix.ByteSliceToString(e.name[:]) == "BT2020_RGB" { | |||
| 116 | + | return id, e.value | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | return 0, 0 | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func bpcAllows10(values []uint64) bool { | |||
| 123 | + | return len(values) >= 2 && values[0] <= 10 && values[1] >= 10 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // hdrCapability combines EDID and connector support, retaining the first reason. | |||
| 127 | + | type hdrCapability struct { | |||
| 128 | + | Capable bool | |||
| 129 | + | Reason string | |||
| 130 | + | HDRMetadata | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func detectHDR(m Monitor, p connectorHDRProps) hdrCapability { | |||
| 134 | + | c := hdrCapability{HDRMetadata: m.HDR} | |||
| 135 | + | switch { | |||
| 136 | + | case !m.HDR.PQ: | |||
| 137 | + | c.Reason = "edid: no PQ" | |||
| 138 | + | case !m.HDR.StaticType1: | |||
| 139 | + | c.Reason = "edid: no static metadata type 1" | |||
| 140 | + | case !m.HDR.BT2020RGB: | |||
| 141 | + | c.Reason = "edid: no BT2020RGB" | |||
| 142 | + | case p.Metadata == 0: | |||
| 143 | + | c.Reason = "connector: no HDR_OUTPUT_METADATA" | |||
| 144 | + | case p.Colorspace == 0: | |||
| 145 | + | c.Reason = "connector: no BT2020_RGB Colorspace" | |||
| 146 | + | case p.MaxBPC == 0: | |||
| 147 | + | c.Reason = "connector: max bpc below 10" | |||
| 148 | + | case !p.HasDefault: | |||
| 149 | + | c.Reason = "connector: no Default Colorspace" | |||
| 150 | + | default: | |||
| 151 | + | c.Capable = true | |||
| 152 | + | } | |||
| 153 | + | return c | |||
| 154 | + | } | |||
| 155 | + | ||||
| 58 | // vrrProperty returns the CRTC's VRR_ENABLED property when the connector | 156 | // vrrProperty returns the CRTC's VRR_ENABLED property when the connector | ||
| 59 | // is VRR capable, else 0. | 157 | // is VRR capable, else 0. | ||
| 60 | func vrrProperty(k kms, conn, crtc uint32) uint32 { | 158 | func vrrProperty(k kms, conn, crtc uint32) uint32 { | ||
A internal/adapters/drm/props_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestHDRPropertyMetadata(t *testing.T) { | |
| 6 | + | e := propEnum{value: 9} | |
| 7 | + | copy(e.name[:], "BT2020_RGB") | |
| 8 | + | if id, v := bt2020Enum(3, []propEnum{e}); id != 3 || v != 9 { | |
| 9 | + | t.Fatalf("enum %d %d", id, v) | |
| 10 | + | } | |
| 11 | + | if id, _ := bt2020Enum(3, nil); id != 0 { | |
| 12 | + | t.Fatal("missing enum accepted") | |
| 13 | + | } | |
| 14 | + | for _, tt := range []struct { | |
| 15 | + | values []uint64 | |
| 16 | + | want bool | |
| 17 | + | }{ | |
| 18 | + | {nil, false}, {[]uint64{8}, false}, {[]uint64{8, 9}, false}, {[]uint64{8, 10}, true}, {[]uint64{10, 12}, true}, {[]uint64{11, 12}, false}, | |
| 19 | + | } { | |
| 20 | + | if got := bpcAllows10(tt.values); got != tt.want { | |
| 21 | + | t.Fatalf("%v: %t", tt.values, got) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestHDRPropertyMetadata(t *testing.T) { | |||
| 6 | + | e := propEnum{value: 9} | |||
| 7 | + | copy(e.name[:], "BT2020_RGB") | |||
| 8 | + | if id, v := bt2020Enum(3, []propEnum{e}); id != 3 || v != 9 { | |||
| 9 | + | t.Fatalf("enum %d %d", id, v) | |||
| 10 | + | } | |||
| 11 | + | if id, _ := bt2020Enum(3, nil); id != 0 { | |||
| 12 | + | t.Fatal("missing enum accepted") | |||
| 13 | + | } | |||
| 14 | + | for _, tt := range []struct { | |||
| 15 | + | values []uint64 | |||
| 16 | + | want bool | |||
| 17 | + | }{ | |||
| 18 | + | {nil, false}, {[]uint64{8}, false}, {[]uint64{8, 9}, false}, {[]uint64{8, 10}, true}, {[]uint64{10, 12}, true}, {[]uint64{11, 12}, false}, | |||
| 19 | + | } { | |||
| 20 | + | if got := bpcAllows10(tt.values); got != tt.want { | |||
| 21 | + | t.Fatalf("%v: %t", tt.values, got) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | } | |||
M internal/adapters/drm/stuck_test.go +1 -0
| @@ -51,6 +51,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||
| 51 | 51 | w.Close() | |
| 52 | 52 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 53 | 53 | } | |
| 54 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 54 | 55 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 55 | 56 | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 56 | 57 | var started atomic.Bool | |
| @@ -51,6 +51,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun { | |||||
| 51 | w.Close() | 51 | w.Close() | ||
| 52 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | 52 | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | ||
| 53 | } | 53 | } | ||
| 54 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 54 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | 55 | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | ||
| 55 | r.EXPECT().UseTarget(mock.Anything).Return() | 56 | r.EXPECT().UseTarget(mock.Anything).Return() | ||
| 56 | var started atomic.Bool | 57 | var started atomic.Bool | ||
M internal/adapters/vulkan/compose.go +14 -6
| @@ -20,6 +20,8 @@ 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/hdr.vert -o shaders/hdr.vert.spv | |
| 24 | + | //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv | |
| 23 | 25 | ||
| 24 | 26 | var ( | |
| 25 | 27 | //go:embed shaders/compose.vert.spv | |
| @@ -98,13 +100,17 @@ func (r *Renderer) createComposer() error { | |||
| 98 | 100 | } | |
| 99 | 101 | ||
| 100 | 102 | func (r *Renderer) createPipeline() error { | |
| 101 | - | d, c := r.dd, &r.compose | |
| 102 | - | vert, err := r.shaderModule(composeVert) | |
| 103 | + | return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error { | |
| 107 | + | d := r.dd | |
| 108 | + | vert, err := r.shaderModule(vertex) | |
| 103 | 109 | if err != nil { | |
| 104 | 110 | return err | |
| 105 | 111 | } | |
| 106 | 112 | defer d.DestroyShaderModule(r.device, vert, nil) | |
| 107 | - | frag, err := r.shaderModule(composeFrag) | |
| 113 | + | frag, err := r.shaderModule(fragment) | |
| 108 | 114 | if err != nil { | |
| 109 | 115 | return err | |
| 110 | 116 | } | |
| @@ -128,16 +134,18 @@ func (r *Renderer) createPipeline() error { | |||
| 128 | 134 | SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd, | |
| 129 | 135 | ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit, | |
| 130 | 136 | } | |
| 137 | + | if !blend { | |
| 138 | + | attachment.BlendEnable = 0 | |
| 139 | + | } | |
| 131 | 140 | blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment} | |
| 132 | - | format := vk.Format(vk.FormatB8g8r8a8Unorm) | |
| 133 | 141 | rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format} | |
| 134 | 142 | gpi := vk.GraphicsPipelineCreateInfo{ | |
| 135 | 143 | SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering), | |
| 136 | 144 | StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly, | |
| 137 | 145 | ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample, | |
| 138 | - | ColorBlendState: &blendState, Layout: c.layout, BasePipelineIndex: -1, | |
| 146 | + | ColorBlendState: &blendState, Layout: layout, BasePipelineIndex: -1, | |
| 139 | 147 | } | |
| 140 | - | return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, &c.pipeline)) | |
| 148 | + | return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, pipeline)) | |
| 141 | 149 | } | |
| 142 | 150 | ||
| 143 | 151 | // createDummies makes the placeholder image (1×1, shader-read layout) and | |
| @@ -20,6 +20,8 @@ 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/hdr.vert -o shaders/hdr.vert.spv | |||
| 24 | + | //go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv | |||
| 23 | 25 | ||||
| 24 | var ( | 26 | var ( | ||
| 25 | //go:embed shaders/compose.vert.spv | 27 | //go:embed shaders/compose.vert.spv | ||
| @@ -98,13 +100,17 @@ func (r *Renderer) createComposer() error { | |||||
| 98 | } | 100 | } | ||
| 99 | 101 | ||||
| 100 | func (r *Renderer) createPipeline() error { | 102 | func (r *Renderer) createPipeline() error { | ||
| 101 | - | d, c := r.dd, &r.compose | 103 | + | return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline) |
| 102 | - | vert, err := r.shaderModule(composeVert) | 104 | + | } |
| 105 | + | ||||
| 106 | + | func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error { | |||
| 107 | + | d := r.dd | |||
| 108 | + | vert, err := r.shaderModule(vertex) | |||
| 103 | if err != nil { | 109 | if err != nil { | ||
| 104 | return err | 110 | return err | ||
| 105 | } | 111 | } | ||
| 106 | defer d.DestroyShaderModule(r.device, vert, nil) | 112 | defer d.DestroyShaderModule(r.device, vert, nil) | ||
| 107 | - | frag, err := r.shaderModule(composeFrag) | 113 | + | frag, err := r.shaderModule(fragment) |
| 108 | if err != nil { | 114 | if err != nil { | ||
| 109 | return err | 115 | return err | ||
| 110 | } | 116 | } | ||
| @@ -128,16 +134,18 @@ func (r *Renderer) createPipeline() error { | |||||
| 128 | SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd, | 134 | SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd, | ||
| 129 | ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit, | 135 | ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit, | ||
| 130 | } | 136 | } | ||
| 137 | + | if !blend { | |||
| 138 | + | attachment.BlendEnable = 0 | |||
| 139 | + | } | |||
| 131 | blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment} | 140 | blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment} | ||
| 132 | - | format := vk.Format(vk.FormatB8g8r8a8Unorm) | |||
| 133 | rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format} | 141 | rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format} | ||
| 134 | gpi := vk.GraphicsPipelineCreateInfo{ | 142 | gpi := vk.GraphicsPipelineCreateInfo{ | ||
| 135 | SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering), | 143 | SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering), | ||
| 136 | StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly, | 144 | StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly, | ||
| 137 | ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample, | 145 | ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample, | ||
| 138 | - | ColorBlendState: &blendState, Layout: c.layout, BasePipelineIndex: -1, | 146 | + | ColorBlendState: &blendState, Layout: layout, BasePipelineIndex: -1, |
| 139 | } | 147 | } | ||
| 140 | - | return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, &c.pipeline)) | 148 | + | return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, pipeline)) |
| 141 | } | 149 | } | ||
| 142 | 150 | ||||
| 143 | // createDummies makes the placeholder image (1×1, shader-read layout) and | 151 | // createDummies makes the placeholder image (1×1, shader-read layout) and | ||
M internal/adapters/vulkan/cursor.go +44 -2
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package vulkan | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/binary" | |
| 4 | 6 | "errors" | |
| 5 | 7 | "fmt" | |
| 8 | + | "math" | |
| 6 | 9 | "os" | |
| 7 | 10 | "unsafe" | |
| 8 | 11 | ||
| @@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) { | |||
| 105 | 108 | return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil | |
| 106 | 109 | } | |
| 107 | 110 | ||
| 111 | + | // cursorConversion keeps only the last converted image, bounded by the | |
| 112 | + | // cursor plane's maximum dimensions, regardless of cursor theme size. | |
| 113 | + | type cursorConversion struct { | |
| 114 | + | key [32]byte | |
| 115 | + | pixels []byte | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func cursorKey(pixels []byte, w, h int, nits float64) [32]byte { | |
| 119 | + | var header [24]byte | |
| 120 | + | binary.LittleEndian.PutUint64(header[:8], uint64(w)) | |
| 121 | + | binary.LittleEndian.PutUint64(header[8:16], uint64(h)) | |
| 122 | + | binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits)) | |
| 123 | + | digest := sha256.New() | |
| 124 | + | _, _ = digest.Write(header[:]) | |
| 125 | + | _, _ = digest.Write(pixels) | |
| 126 | + | var key [32]byte | |
| 127 | + | digest.Sum(key[:0]) | |
| 128 | + | return key | |
| 129 | + | } | |
| 130 | + | ||
| 108 | 131 | // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row) | |
| 109 | 132 | // into cursor image i at its row pitch, cropped and cleared around. | |
| 110 | 133 | func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error { | |
| @@ -118,8 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error { | |||
| 118 | 141 | mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size) | |
| 119 | 142 | clear(mem) | |
| 120 | 143 | cw := min(w, c.size) * 4 | |
| 121 | - | for y := range min(h, c.size) { | |
| 122 | - | copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw]) | |
| 144 | + | if r.hdrNits > 0 { | |
| 145 | + | // WriteCursor is called only on image changes, never on moves. | |
| 146 | + | key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits) | |
| 147 | + | converted := r.cursorCache.pixels | |
| 148 | + | if converted == nil || r.cursorCache.key != key { | |
| 149 | + | converted = make([]byte, cw*min(h, c.size)) | |
| 150 | + | for y := range min(h, c.size) { | |
| 151 | + | copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw]) | |
| 152 | + | for x := 0; x < cw; x += 4 { | |
| 153 | + | hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | r.cursorCache = cursorConversion{key: key, pixels: converted} | |
| 157 | + | } | |
| 158 | + | for y := range min(h, c.size) { | |
| 159 | + | copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw]) | |
| 160 | + | } | |
| 161 | + | } else { | |
| 162 | + | for y := range min(h, c.size) { | |
| 163 | + | copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw]) | |
| 164 | + | } | |
| 123 | 165 | } | |
| 124 | 166 | return nil | |
| 125 | 167 | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package vulkan | 1 | package vulkan | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "encoding/binary" | |||
| 4 | "errors" | 6 | "errors" | ||
| 5 | "fmt" | 7 | "fmt" | ||
| 8 | + | "math" | |||
| 6 | "os" | 9 | "os" | ||
| 7 | "unsafe" | 10 | "unsafe" | ||
| 8 | 11 | ||||
| @@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) { | |||||
| 105 | return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil | 108 | return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil | ||
| 106 | } | 109 | } | ||
| 107 | 110 | ||||
| 111 | + | // cursorConversion keeps only the last converted image, bounded by the | |||
| 112 | + | // cursor plane's maximum dimensions, regardless of cursor theme size. | |||
| 113 | + | type cursorConversion struct { | |||
| 114 | + | key [32]byte | |||
| 115 | + | pixels []byte | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func cursorKey(pixels []byte, w, h int, nits float64) [32]byte { | |||
| 119 | + | var header [24]byte | |||
| 120 | + | binary.LittleEndian.PutUint64(header[:8], uint64(w)) | |||
| 121 | + | binary.LittleEndian.PutUint64(header[8:16], uint64(h)) | |||
| 122 | + | binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits)) | |||
| 123 | + | digest := sha256.New() | |||
| 124 | + | _, _ = digest.Write(header[:]) | |||
| 125 | + | _, _ = digest.Write(pixels) | |||
| 126 | + | var key [32]byte | |||
| 127 | + | digest.Sum(key[:0]) | |||
| 128 | + | return key | |||
| 129 | + | } | |||
| 130 | + | ||||
| 108 | // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row) | 131 | // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row) | ||
| 109 | // into cursor image i at its row pitch, cropped and cleared around. | 132 | // into cursor image i at its row pitch, cropped and cleared around. | ||
| 110 | func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error { | 133 | func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error { | ||
| @@ -118,8 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error { | |||||
| 118 | mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size) | 141 | mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size) | ||
| 119 | clear(mem) | 142 | clear(mem) | ||
| 120 | cw := min(w, c.size) * 4 | 143 | cw := min(w, c.size) * 4 | ||
| 121 | - | for y := range min(h, c.size) { | 144 | + | if r.hdrNits > 0 { |
| 122 | - | copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw]) | 145 | + | // WriteCursor is called only on image changes, never on moves. |
| 146 | + | key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits) | |||
| 147 | + | converted := r.cursorCache.pixels | |||
| 148 | + | if converted == nil || r.cursorCache.key != key { | |||
| 149 | + | converted = make([]byte, cw*min(h, c.size)) | |||
| 150 | + | for y := range min(h, c.size) { | |||
| 151 | + | copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw]) | |||
| 152 | + | for x := 0; x < cw; x += 4 { | |||
| 153 | + | hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | r.cursorCache = cursorConversion{key: key, pixels: converted} | |||
| 157 | + | } | |||
| 158 | + | for y := range min(h, c.size) { | |||
| 159 | + | copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw]) | |||
| 160 | + | } | |||
| 161 | + | } else { | |||
| 162 | + | for y := range min(h, c.size) { | |||
| 163 | + | copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw]) | |||
| 164 | + | } | |||
| 123 | } | 165 | } | ||
| 124 | return nil | 166 | return nil | ||
| 125 | } | 167 | } | ||
M internal/adapters/vulkan/cursor_test.go +58 -0
| @@ -46,3 +46,61 @@ func TestCursorBuffersWriteCursor(t *testing.T) { | |||
| 46 | 46 | t.Fatal("short image accepted") | |
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | + | ||
| 50 | + | func TestHDRCursorCachesConversion(t *testing.T) { | |
| 51 | + | r, err := New(32, 16) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 54 | + | } | |
| 55 | + | defer r.Close() | |
| 56 | + | bufs, err := r.CursorBuffers(64) | |
| 57 | + | if err != nil { | |
| 58 | + | t.Skipf("no cursor images: %v", err) | |
| 59 | + | } | |
| 60 | + | for _, b := range bufs { | |
| 61 | + | b.Planes[0].File.Close() | |
| 62 | + | } | |
| 63 | + | r.SetHDR(203) | |
| 64 | + | px := []byte{255, 255, 255, 255} | |
| 65 | + | if err := r.WriteCursor(0, px, 1, 1); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if len(r.cursorCache.pixels) != 4 { | |
| 69 | + | t.Fatalf("cache size %d", len(r.cursorCache.pixels)) | |
| 70 | + | } | |
| 71 | + | if err := r.WriteCursor(1, px, 1, 1); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if len(r.cursorCache.pixels) != 4 { | |
| 75 | + | t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels)) | |
| 76 | + | } | |
| 77 | + | first := r.cursorCache.key | |
| 78 | + | for i := 0; i < 12; i++ { | |
| 79 | + | color := byte(i * 18) | |
| 80 | + | if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | if len(r.cursorCache.pixels) != 4 { | |
| 84 | + | t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels)) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if first == r.cursorCache.key { | |
| 88 | + | t.Fatal("cursor conversion not replaced") | |
| 89 | + | } | |
| 90 | + | if err := r.WriteCursor(1, px, 1, 1); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | for _, c := range r.cursors { | |
| 94 | + | got := unsafe.Slice((*byte)(c.mapped), 4) | |
| 95 | + | if got[0] != 148 || got[1] != 148 || got[2] != 148 || got[3] != 255 { | |
| 96 | + | t.Fatalf("cursor HDR pixel: %v", got) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | r.SetHDR(0) | |
| 100 | + | if err := r.WriteCursor(0, px, 1, 1); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 { | |
| 104 | + | t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels)) | |
| 105 | + | } | |
| 106 | + | } | |
| @@ -46,3 +46,61 @@ func TestCursorBuffersWriteCursor(t *testing.T) { | |||||
| 46 | t.Fatal("short image accepted") | 46 | t.Fatal("short image accepted") | ||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | + | ||||
| 50 | + | func TestHDRCursorCachesConversion(t *testing.T) { | |||
| 51 | + | r, err := New(32, 16) | |||
| 52 | + | if err != nil { | |||
| 53 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 54 | + | } | |||
| 55 | + | defer r.Close() | |||
| 56 | + | bufs, err := r.CursorBuffers(64) | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Skipf("no cursor images: %v", err) | |||
| 59 | + | } | |||
| 60 | + | for _, b := range bufs { | |||
| 61 | + | b.Planes[0].File.Close() | |||
| 62 | + | } | |||
| 63 | + | r.SetHDR(203) | |||
| 64 | + | px := []byte{255, 255, 255, 255} | |||
| 65 | + | if err := r.WriteCursor(0, px, 1, 1); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | if len(r.cursorCache.pixels) != 4 { | |||
| 69 | + | t.Fatalf("cache size %d", len(r.cursorCache.pixels)) | |||
| 70 | + | } | |||
| 71 | + | if err := r.WriteCursor(1, px, 1, 1); err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | if len(r.cursorCache.pixels) != 4 { | |||
| 75 | + | t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels)) | |||
| 76 | + | } | |||
| 77 | + | first := r.cursorCache.key | |||
| 78 | + | for i := 0; i < 12; i++ { | |||
| 79 | + | color := byte(i * 18) | |||
| 80 | + | if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | if len(r.cursorCache.pixels) != 4 { | |||
| 84 | + | t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels)) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | if first == r.cursorCache.key { | |||
| 88 | + | t.Fatal("cursor conversion not replaced") | |||
| 89 | + | } | |||
| 90 | + | if err := r.WriteCursor(1, px, 1, 1); err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | for _, c := range r.cursors { | |||
| 94 | + | got := unsafe.Slice((*byte)(c.mapped), 4) | |||
| 95 | + | if got[0] != 148 || got[1] != 148 || got[2] != 148 || got[3] != 255 { | |||
| 96 | + | t.Fatalf("cursor HDR pixel: %v", got) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | r.SetHDR(0) | |||
| 100 | + | if err := r.WriteCursor(0, px, 1, 1); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 { | |||
| 104 | + | t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels)) | |||
| 105 | + | } | |||
| 106 | + | } | |||
M internal/adapters/vulkan/frame.go +26 -11
| @@ -169,6 +169,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 169 | 169 | } | |
| 170 | 170 | } | |
| 171 | 171 | tg := r.target() | |
| 172 | + | hdr := r.hdrNits > 0 && len(r.targets) > 0 | |
| 173 | + | if hdr { | |
| 174 | + | tg = &r.own | |
| 175 | + | } | |
| 172 | 176 | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | |
| 173 | 177 | ds := r.draws(s, contents, dmg) | |
| 174 | 178 | dmg.finish() | |
| @@ -219,16 +223,21 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 219 | 223 | r.ownership(cmd, dmas, true) | |
| 220 | 224 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | |
| 221 | 225 | r.ownership(cmd, dmas, false) | |
| 222 | - | b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal | |
| 223 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored | |
| 224 | - | if tg.exported { | |
| 225 | - | // Release to the display: KMS scans it out after the fence. | |
| 226 | - | b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0 | |
| 227 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 228 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 229 | - | } else { | |
| 230 | - | b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit | |
| 231 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 226 | + | if hdr { | |
| 227 | + | r.recordHDR(cmd, r.targets[r.current]) | |
| 228 | + | } | |
| 229 | + | if !hdr { | |
| 230 | + | b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal | |
| 231 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored | |
| 232 | + | if tg.exported { | |
| 233 | + | // Release to the display: KMS scans it out after the fence. | |
| 234 | + | b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0 | |
| 235 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 236 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 237 | + | } else { | |
| 238 | + | b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit | |
| 239 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 240 | + | } | |
| 232 | 241 | } | |
| 233 | 242 | if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil { | |
| 234 | 243 | return nil, err | |
| @@ -268,7 +277,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 268 | 277 | runtime.KeepAlive(stages) | |
| 269 | 278 | runtime.KeepAlive(waits) | |
| 270 | 279 | slot.frame, slot.busy, r.submitted = frame, true, frame | |
| 271 | - | tg.layout = b.NewLayout | |
| 280 | + | if hdr { | |
| 281 | + | // recordHDR left the composed image in TRANSFER_SRC for Pixels. | |
| 282 | + | tg.layout = vk.ImageLayoutTransferSrcOptimal | |
| 283 | + | r.targets[r.current].layout = vk.ImageLayoutGeneral | |
| 284 | + | } else { | |
| 285 | + | tg.layout = b.NewLayout | |
| 286 | + | } | |
| 272 | 287 | tg.hold(s, dmg) | |
| 273 | 288 | r.last, r.readBack = tg, false | |
| 274 | 289 | r.dropUnused() | |
| @@ -169,6 +169,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 169 | } | 169 | } | ||
| 170 | } | 170 | } | ||
| 171 | tg := r.target() | 171 | tg := r.target() | ||
| 172 | + | hdr := r.hdrNits > 0 && len(r.targets) > 0 | |||
| 173 | + | if hdr { | |||
| 174 | + | tg = &r.own | |||
| 175 | + | } | |||
| 172 | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | 176 | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | ||
| 173 | ds := r.draws(s, contents, dmg) | 177 | ds := r.draws(s, contents, dmg) | ||
| 174 | dmg.finish() | 178 | dmg.finish() | ||
| @@ -219,16 +223,21 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 219 | r.ownership(cmd, dmas, true) | 223 | r.ownership(cmd, dmas, true) | ||
| 220 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | 224 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | ||
| 221 | r.ownership(cmd, dmas, false) | 225 | r.ownership(cmd, dmas, false) | ||
| 222 | - | b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal | 226 | + | if hdr { |
| 223 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored | 227 | + | r.recordHDR(cmd, r.targets[r.current]) |
| 224 | - | if tg.exported { | 228 | + | } |
| 225 | - | // Release to the display: KMS scans it out after the fence. | 229 | + | if !hdr { |
| 226 | - | b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0 | 230 | + | b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal |
| 227 | - | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | 231 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored |
| 228 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | 232 | + | if tg.exported { |
| 229 | - | } else { | 233 | + | // Release to the display: KMS scans it out after the fence. |
| 230 | - | b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit | 234 | + | b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0 |
| 231 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | 235 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT |
| 236 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 237 | + | } else { | |||
| 238 | + | b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit | |||
| 239 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 240 | + | } | |||
| 232 | } | 241 | } | ||
| 233 | if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil { | 242 | if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil { | ||
| 234 | return nil, err | 243 | return nil, err | ||
| @@ -268,7 +277,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 268 | runtime.KeepAlive(stages) | 277 | runtime.KeepAlive(stages) | ||
| 269 | runtime.KeepAlive(waits) | 278 | runtime.KeepAlive(waits) | ||
| 270 | slot.frame, slot.busy, r.submitted = frame, true, frame | 279 | slot.frame, slot.busy, r.submitted = frame, true, frame | ||
| 271 | - | tg.layout = b.NewLayout | 280 | + | if hdr { |
| 281 | + | // recordHDR left the composed image in TRANSFER_SRC for Pixels. | |||
| 282 | + | tg.layout = vk.ImageLayoutTransferSrcOptimal | |||
| 283 | + | r.targets[r.current].layout = vk.ImageLayoutGeneral | |||
| 284 | + | } else { | |||
| 285 | + | tg.layout = b.NewLayout | |||
| 286 | + | } | |||
| 272 | tg.hold(s, dmg) | 287 | tg.hold(s, dmg) | ||
| 273 | r.last, r.readBack = tg, false | 288 | r.last, r.readBack = tg, false | ||
| 274 | r.dropUnused() | 289 | r.dropUnused() | ||
A internal/adapters/vulkan/hdr.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | _ "embed" | |
| 5 | + | "unsafe" | |
| 6 | + | ||
| 7 | + | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | //go:embed shaders/hdr.vert.spv | |
| 12 | + | hdrVert []byte | |
| 13 | + | //go:embed shaders/hdr.frag.spv | |
| 14 | + | hdrFrag []byte | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type hdrPass struct { | |
| 18 | + | setLayout vk.DescriptorSetLayout | |
| 19 | + | layout vk.PipelineLayout | |
| 20 | + | pipeline vk.Pipeline | |
| 21 | + | sampler vk.Sampler | |
| 22 | + | pool vk.DescriptorPool | |
| 23 | + | set vk.DescriptorSet | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // SetHDR prepares the output transform before ExportTargets. The renderer | |
| 27 | + | // remains owner-goroutine confined; all GPU objects are reused each frame. | |
| 28 | + | func (r *Renderer) SetHDR(nits float64) { | |
| 29 | + | if nits != r.hdrNits { | |
| 30 | + | r.cursorCache = cursorConversion{} | |
| 31 | + | } | |
| 32 | + | r.hdrNits = nits | |
| 33 | + | if nits <= 0 { | |
| 34 | + | r.cursorCache = cursorConversion{} | |
| 35 | + | r.hdrError = nil | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | if r.hdr.pipeline != 0 { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | // The port has no error return: ExportTargets fails if preparation fails. | |
| 42 | + | r.hdrError = r.createHDR() | |
| 43 | + | if r.hdrError != nil { | |
| 44 | + | r.destroyHDR() | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (r *Renderer) createHDR() error { | |
| 49 | + | d, h := r.dd, &r.hdr | |
| 50 | + | binding := vk.DescriptorSetLayoutBinding{Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit} | |
| 51 | + | sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 1, Bindings: &binding} | |
| 52 | + | if err := checked("vkCreateDescriptorSetLayout(HDR)", d.CreateDescriptorSetLayout(r.device, &sli, nil, &h.setLayout)); err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | push := vk.PushConstantRange{StageFlags: vk.ShaderStageFragmentBit, Size: 4} | |
| 56 | + | pli := vk.PipelineLayoutCreateInfo{SType: vk.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: 1, SetLayouts: &h.setLayout, PushConstantRangeCount: 1, PushConstantRanges: &push} | |
| 57 | + | if err := checked("vkCreatePipelineLayout(HDR)", d.CreatePipelineLayout(r.device, &pli, nil, &h.layout)); err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | if err := r.createGraphicsPipeline(hdrVert, hdrFrag, vk.FormatA2r10g10b10UnormPack32, h.layout, false, &h.pipeline); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | si := vk.SamplerCreateInfo{SType: vk.StructureTypeSamplerCreateInfo, MagFilter: vk.FilterNearest, MinFilter: vk.FilterNearest, AddressModeU: vk.SamplerAddressModeClampToEdge, AddressModeV: vk.SamplerAddressModeClampToEdge, AddressModeW: vk.SamplerAddressModeClampToEdge} | |
| 64 | + | if err := checked("vkCreateSampler(HDR)", d.CreateSampler(r.device, &si, nil, &h.sampler)); err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | size := vk.DescriptorPoolSize{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1} | |
| 68 | + | dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 1, PoolSizes: &size} | |
| 69 | + | if err := checked("vkCreateDescriptorPool(HDR)", d.CreateDescriptorPool(r.device, &dpi, nil, &h.pool)); err != nil { | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | dai := vk.DescriptorSetAllocateInfo{SType: vk.StructureTypeDescriptorSetAllocateInfo, DescriptorPool: h.pool, DescriptorSetCount: 1, SetLayouts: &h.setLayout} | |
| 73 | + | if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal} | |
| 77 | + | 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) | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (r *Renderer) destroyHDR() { | |
| 83 | + | if r.dd == nil { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | d, h := r.dd, &r.hdr | |
| 87 | + | if h.pool != 0 { | |
| 88 | + | d.DestroyDescriptorPool(r.device, h.pool, nil) | |
| 89 | + | } | |
| 90 | + | if h.sampler != 0 { | |
| 91 | + | d.DestroySampler(r.device, h.sampler, nil) | |
| 92 | + | } | |
| 93 | + | if h.pipeline != 0 { | |
| 94 | + | d.DestroyPipeline(r.device, h.pipeline, nil) | |
| 95 | + | } | |
| 96 | + | if h.layout != 0 { | |
| 97 | + | d.DestroyPipelineLayout(r.device, h.layout, nil) | |
| 98 | + | } | |
| 99 | + | if h.setLayout != 0 { | |
| 100 | + | d.DestroyDescriptorSetLayout(r.device, h.setLayout, nil) | |
| 101 | + | } | |
| 102 | + | *h = hdrPass{} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // recordHDR converts the fully composed SDR image in the same command buffer. | |
| 106 | + | func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) { | |
| 107 | + | 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} | |
| 109 | + | 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} | |
| 111 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |
| 112 | + | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 113 | + | 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} | |
| 114 | + | d.CmdBeginRendering(cmd, &info) | |
| 115 | + | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline) | |
| 116 | + | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil) | |
| 117 | + | nits := float32(r.hdrNits) | |
| 118 | + | d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits)) | |
| 119 | + | d.CmdDraw(cmd, 3, 1, 0, 0) | |
| 120 | + | d.CmdEndRendering(cmd) | |
| 121 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0 | |
| 122 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral | |
| 123 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 124 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 125 | + | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | |
| 126 | + | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | |
| 127 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in) | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | _ "embed" | |||
| 5 | + | "unsafe" | |||
| 6 | + | ||||
| 7 | + | vk "github.com/bnema/purego-vulkan/vulkan" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | //go:embed shaders/hdr.vert.spv | |||
| 12 | + | hdrVert []byte | |||
| 13 | + | //go:embed shaders/hdr.frag.spv | |||
| 14 | + | hdrFrag []byte | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type hdrPass struct { | |||
| 18 | + | setLayout vk.DescriptorSetLayout | |||
| 19 | + | layout vk.PipelineLayout | |||
| 20 | + | pipeline vk.Pipeline | |||
| 21 | + | sampler vk.Sampler | |||
| 22 | + | pool vk.DescriptorPool | |||
| 23 | + | set vk.DescriptorSet | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // SetHDR prepares the output transform before ExportTargets. The renderer | |||
| 27 | + | // remains owner-goroutine confined; all GPU objects are reused each frame. | |||
| 28 | + | func (r *Renderer) SetHDR(nits float64) { | |||
| 29 | + | if nits != r.hdrNits { | |||
| 30 | + | r.cursorCache = cursorConversion{} | |||
| 31 | + | } | |||
| 32 | + | r.hdrNits = nits | |||
| 33 | + | if nits <= 0 { | |||
| 34 | + | r.cursorCache = cursorConversion{} | |||
| 35 | + | r.hdrError = nil | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | if r.hdr.pipeline != 0 { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | // The port has no error return: ExportTargets fails if preparation fails. | |||
| 42 | + | r.hdrError = r.createHDR() | |||
| 43 | + | if r.hdrError != nil { | |||
| 44 | + | r.destroyHDR() | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (r *Renderer) createHDR() error { | |||
| 49 | + | d, h := r.dd, &r.hdr | |||
| 50 | + | binding := vk.DescriptorSetLayoutBinding{Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit} | |||
| 51 | + | sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 1, Bindings: &binding} | |||
| 52 | + | if err := checked("vkCreateDescriptorSetLayout(HDR)", d.CreateDescriptorSetLayout(r.device, &sli, nil, &h.setLayout)); err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | push := vk.PushConstantRange{StageFlags: vk.ShaderStageFragmentBit, Size: 4} | |||
| 56 | + | pli := vk.PipelineLayoutCreateInfo{SType: vk.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: 1, SetLayouts: &h.setLayout, PushConstantRangeCount: 1, PushConstantRanges: &push} | |||
| 57 | + | if err := checked("vkCreatePipelineLayout(HDR)", d.CreatePipelineLayout(r.device, &pli, nil, &h.layout)); err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | if err := r.createGraphicsPipeline(hdrVert, hdrFrag, vk.FormatA2r10g10b10UnormPack32, h.layout, false, &h.pipeline); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | si := vk.SamplerCreateInfo{SType: vk.StructureTypeSamplerCreateInfo, MagFilter: vk.FilterNearest, MinFilter: vk.FilterNearest, AddressModeU: vk.SamplerAddressModeClampToEdge, AddressModeV: vk.SamplerAddressModeClampToEdge, AddressModeW: vk.SamplerAddressModeClampToEdge} | |||
| 64 | + | if err := checked("vkCreateSampler(HDR)", d.CreateSampler(r.device, &si, nil, &h.sampler)); err != nil { | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | size := vk.DescriptorPoolSize{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1} | |||
| 68 | + | dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 1, PoolSizes: &size} | |||
| 69 | + | if err := checked("vkCreateDescriptorPool(HDR)", d.CreateDescriptorPool(r.device, &dpi, nil, &h.pool)); err != nil { | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | dai := vk.DescriptorSetAllocateInfo{SType: vk.StructureTypeDescriptorSetAllocateInfo, DescriptorPool: h.pool, DescriptorSetCount: 1, SetLayouts: &h.setLayout} | |||
| 73 | + | if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal} | |||
| 77 | + | 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) | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (r *Renderer) destroyHDR() { | |||
| 83 | + | if r.dd == nil { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | d, h := r.dd, &r.hdr | |||
| 87 | + | if h.pool != 0 { | |||
| 88 | + | d.DestroyDescriptorPool(r.device, h.pool, nil) | |||
| 89 | + | } | |||
| 90 | + | if h.sampler != 0 { | |||
| 91 | + | d.DestroySampler(r.device, h.sampler, nil) | |||
| 92 | + | } | |||
| 93 | + | if h.pipeline != 0 { | |||
| 94 | + | d.DestroyPipeline(r.device, h.pipeline, nil) | |||
| 95 | + | } | |||
| 96 | + | if h.layout != 0 { | |||
| 97 | + | d.DestroyPipelineLayout(r.device, h.layout, nil) | |||
| 98 | + | } | |||
| 99 | + | if h.setLayout != 0 { | |||
| 100 | + | d.DestroyDescriptorSetLayout(r.device, h.setLayout, nil) | |||
| 101 | + | } | |||
| 102 | + | *h = hdrPass{} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // recordHDR converts the fully composed SDR image in the same command buffer. | |||
| 106 | + | func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) { | |||
| 107 | + | 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} | |||
| 109 | + | 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} | |||
| 111 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 112 | + | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |||
| 113 | + | 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} | |||
| 114 | + | d.CmdBeginRendering(cmd, &info) | |||
| 115 | + | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline) | |||
| 116 | + | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil) | |||
| 117 | + | nits := float32(r.hdrNits) | |||
| 118 | + | d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits)) | |||
| 119 | + | d.CmdDraw(cmd, 3, 1, 0, 0) | |||
| 120 | + | d.CmdEndRendering(cmd) | |||
| 121 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0 | |||
| 122 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral | |||
| 123 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |||
| 124 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 125 | + | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | |||
| 126 | + | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | |||
| 127 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in) | |||
| 128 | + | } | |||
A internal/adapters/vulkan/pq.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import "math" | |
| 4 | + | ||
| 5 | + | var srgbTable = func() (table [256]float64) { | |
| 6 | + | for i := range table { | |
| 7 | + | table[i] = srgbToLinear(float64(i) / 255) | |
| 8 | + | } | |
| 9 | + | return | |
| 10 | + | }() | |
| 11 | + | ||
| 12 | + | // srgbToLinear decodes a unit-range sRGB component. | |
| 13 | + | func srgbToLinear(v float64) float64 { | |
| 14 | + | if v <= 0.04045 { | |
| 15 | + | return v / 12.92 | |
| 16 | + | } | |
| 17 | + | return math.Pow((v+0.055)/1.055, 2.4) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // pqEncode encodes absolute luminance in nits using SMPTE ST 2084. | |
| 21 | + | func pqEncode(nits float64) float64 { | |
| 22 | + | if nits <= 0 { | |
| 23 | + | return 0 | |
| 24 | + | } | |
| 25 | + | x := math.Pow(min(nits, 10000)/10000, 2610.0/16384) | |
| 26 | + | return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ. | |
| 30 | + | func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) { | |
| 31 | + | r, g, b := srgbToLinear(red), srgbToLinear(green), srgbToLinear(blue) | |
| 32 | + | return pqEncode((0.627404*r + 0.329283*g + 0.043313*b) * nits), | |
| 33 | + | pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits), | |
| 34 | + | pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha. | |
| 38 | + | // The source is quantized to its 8-bit unassociated sRGB value for the LUT. | |
| 39 | + | func hdrCursorPixel(p []byte, nits float64) { | |
| 40 | + | a := float64(p[3]) / 255 | |
| 41 | + | if a == 0 { | |
| 42 | + | clear(p[:3]) | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) } | |
| 46 | + | r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])] | |
| 47 | + | r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits) | |
| 48 | + | p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255)) | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import "math" | |||
| 4 | + | ||||
| 5 | + | var srgbTable = func() (table [256]float64) { | |||
| 6 | + | for i := range table { | |||
| 7 | + | table[i] = srgbToLinear(float64(i) / 255) | |||
| 8 | + | } | |||
| 9 | + | return | |||
| 10 | + | }() | |||
| 11 | + | ||||
| 12 | + | // srgbToLinear decodes a unit-range sRGB component. | |||
| 13 | + | func srgbToLinear(v float64) float64 { | |||
| 14 | + | if v <= 0.04045 { | |||
| 15 | + | return v / 12.92 | |||
| 16 | + | } | |||
| 17 | + | return math.Pow((v+0.055)/1.055, 2.4) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // pqEncode encodes absolute luminance in nits using SMPTE ST 2084. | |||
| 21 | + | func pqEncode(nits float64) float64 { | |||
| 22 | + | if nits <= 0 { | |||
| 23 | + | return 0 | |||
| 24 | + | } | |||
| 25 | + | x := math.Pow(min(nits, 10000)/10000, 2610.0/16384) | |||
| 26 | + | return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ. | |||
| 30 | + | func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) { | |||
| 31 | + | r, g, b := srgbToLinear(red), srgbToLinear(green), srgbToLinear(blue) | |||
| 32 | + | return pqEncode((0.627404*r + 0.329283*g + 0.043313*b) * nits), | |||
| 33 | + | pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits), | |||
| 34 | + | pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha. | |||
| 38 | + | // The source is quantized to its 8-bit unassociated sRGB value for the LUT. | |||
| 39 | + | func hdrCursorPixel(p []byte, nits float64) { | |||
| 40 | + | a := float64(p[3]) / 255 | |||
| 41 | + | if a == 0 { | |||
| 42 | + | clear(p[:3]) | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) } | |||
| 46 | + | r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])] | |||
| 47 | + | r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits) | |||
| 48 | + | p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255)) | |||
| 49 | + | } | |||
A internal/adapters/vulkan/pq_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestPQMath(t *testing.T) { | |
| 9 | + | white, green, blue := hdrPixel(1, 1, 1, 203) | |
| 10 | + | if math.Abs(white-0.5806) > 0.002 || math.Abs(green-white) > 0.0001 || math.Abs(blue-white) > 0.0001 { | |
| 11 | + | t.Fatalf("SDR white at 203 nits: %v %v %v", white, green, blue) | |
| 12 | + | } | |
| 13 | + | if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 { | |
| 14 | + | t.Fatal("PQ endpoints") | |
| 15 | + | } | |
| 16 | + | p := []byte{255, 255, 255, 255} | |
| 17 | + | hdrCursorPixel(p, 203) | |
| 18 | + | if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] { | |
| 19 | + | t.Fatalf("cursor white: %v", p) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestPQMath(t *testing.T) { | |||
| 9 | + | white, green, blue := hdrPixel(1, 1, 1, 203) | |||
| 10 | + | if math.Abs(white-0.5806) > 0.002 || math.Abs(green-white) > 0.0001 || math.Abs(blue-white) > 0.0001 { | |||
| 11 | + | t.Fatalf("SDR white at 203 nits: %v %v %v", white, green, blue) | |||
| 12 | + | } | |||
| 13 | + | if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 { | |||
| 14 | + | t.Fatal("PQ endpoints") | |||
| 15 | + | } | |||
| 16 | + | p := []byte{255, 255, 255, 255} | |||
| 17 | + | hdrCursorPixel(p, 203) | |||
| 18 | + | if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] { | |||
| 19 | + | t.Fatalf("cursor white: %v", p) | |||
| 20 | + | } | |||
| 21 | + | } | |||
M internal/adapters/vulkan/renderer.go +14 -3
| @@ -22,12 +22,21 @@ type Renderer struct { | |||
| 22 | 22 | device vk.Device | |
| 23 | 23 | dd *vk.DeviceDispatch | |
| 24 | 24 | queue vk.Queue | |
| 25 | - | // own is the target until ExportTargets; targets are exported images | |
| 26 | - | // (targets.go), current the one the next frame draws into. | |
| 25 | + | // own is the composed image until ExportTargets; in HDR it remains | |
| 26 | + | // the SDR composition/readback image while targets carry PQ. | |
| 27 | 27 | own target | |
| 28 | 28 | targets []*target | |
| 29 | 29 | current int | |
| 30 | 30 | renderMods []uint64 | |
| 31 | + | hdrMods []uint64 | |
| 32 | + | hdrNits float64 | |
| 33 | + | hdr hdrPass | |
| 34 | + | hdrError error | |
| 35 | + | // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target. | |
| 36 | + | hdrReadback bool | |
| 37 | + | physical vk.PhysicalDevice | |
| 38 | + | // One converted cursor image is kept; changes replace it in bounded memory. | |
| 39 | + | cursorCache cursorConversion | |
| 31 | 40 | // last is the target of the last frame; readback copies it into | |
| 32 | 41 | // buffer only when Pixels asks (stale until then). | |
| 33 | 42 | last *target | |
| @@ -186,6 +195,7 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 186 | 195 | if sup := r.probeDMABuf(physical); sup.Device != 0 { | |
| 187 | 196 | r.dmabuf = sup | |
| 188 | 197 | r.probeRenderModifiers(physical) | |
| 198 | + | r.physical = physical | |
| 189 | 199 | } | |
| 190 | 200 | // VK_KHR_external_semaphore_fd (enabled above): frames export | |
| 191 | 201 | // their fence as a sync file. | |
| @@ -193,7 +203,7 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 193 | 203 | } | |
| 194 | 204 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | |
| 195 | 205 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | |
| 196 | - | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | |
| 206 | + | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | |
| 197 | 207 | if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil { | |
| 198 | 208 | return | |
| 199 | 209 | } | |
| @@ -356,6 +366,7 @@ func (r *Renderer) Close() { | |||
| 356 | 366 | r.idle() | |
| 357 | 367 | } | |
| 358 | 368 | r.dropTargets() | |
| 369 | + | r.destroyHDR() | |
| 359 | 370 | r.dropCursors() | |
| 360 | 371 | for id, im := range r.imports { | |
| 361 | 372 | r.release(im) | |
| @@ -22,12 +22,21 @@ 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 target until ExportTargets; targets are exported images | 25 | + | // own is the composed image until ExportTargets; in HDR it remains |
| 26 | - | // (targets.go), current the one the next frame draws into. | 26 | + | // the SDR composition/readback image while targets carry PQ. |
| 27 | own target | 27 | own target | ||
| 28 | targets []*target | 28 | targets []*target | ||
| 29 | current int | 29 | current int | ||
| 30 | renderMods []uint64 | 30 | renderMods []uint64 | ||
| 31 | + | hdrMods []uint64 | |||
| 32 | + | hdrNits float64 | |||
| 33 | + | hdr hdrPass | |||
| 34 | + | hdrError error | |||
| 35 | + | // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target. | |||
| 36 | + | hdrReadback bool | |||
| 37 | + | physical vk.PhysicalDevice | |||
| 38 | + | // One converted cursor image is kept; changes replace it in bounded memory. | |||
| 39 | + | cursorCache cursorConversion | |||
| 31 | // 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 | ||
| 32 | // buffer only when Pixels asks (stale until then). | 41 | // buffer only when Pixels asks (stale until then). | ||
| 33 | last *target | 42 | last *target | ||
| @@ -186,6 +195,7 @@ func New(width, height int) (r *Renderer, err error) { | |||||
| 186 | if sup := r.probeDMABuf(physical); sup.Device != 0 { | 195 | if sup := r.probeDMABuf(physical); sup.Device != 0 { | ||
| 187 | r.dmabuf = sup | 196 | r.dmabuf = sup | ||
| 188 | r.probeRenderModifiers(physical) | 197 | r.probeRenderModifiers(physical) | ||
| 198 | + | r.physical = physical | |||
| 189 | } | 199 | } | ||
| 190 | // VK_KHR_external_semaphore_fd (enabled above): frames export | 200 | // VK_KHR_external_semaphore_fd (enabled above): frames export | ||
| 191 | // their fence as a sync file. | 201 | // their fence as a sync file. | ||
| @@ -193,7 +203,7 @@ func New(width, height int) (r *Renderer, err error) { | |||||
| 193 | } | 203 | } | ||
| 194 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | 204 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | ||
| 195 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | 205 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | ||
| 196 | - | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | 206 | + | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} |
| 197 | if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil { | 207 | if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil { | ||
| 198 | return | 208 | return | ||
| 199 | } | 209 | } | ||
| @@ -356,6 +366,7 @@ func (r *Renderer) Close() { | |||||
| 356 | r.idle() | 366 | r.idle() | ||
| 357 | } | 367 | } | ||
| 358 | r.dropTargets() | 368 | r.dropTargets() | ||
| 369 | + | r.destroyHDR() | |||
| 359 | r.dropCursors() | 370 | r.dropCursors() | ||
| 360 | for id, im := range r.imports { | 371 | for id, im := range r.imports { | ||
| 361 | r.release(im) | 372 | r.release(im) | ||
A internal/adapters/vulkan/shaders/hdr.frag +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | #version 450 | |
| 2 | + | layout(set=0, binding=0) uniform sampler2D imageSDR; | |
| 3 | + | layout(push_constant) uniform Brightness { float nits; } pc; | |
| 4 | + | layout(location=0) out vec4 color; | |
| 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) { | |
| 10 | + | 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)); | |
| 12 | + | } | |
| 13 | + | void main() { | |
| 14 | + | vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0)); | |
| 15 | + | vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb); | |
| 16 | + | vec3 bt2020 = mat3( | |
| 17 | + | 0.627404, 0.069097, 0.016391, | |
| 18 | + | 0.329283, 0.919540, 0.088013, | |
| 19 | + | 0.043313, 0.011362, 0.895595) * rgb; | |
| 20 | + | color = vec4(encodePQ(bt2020 * pc.nits), 1.0); | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | #version 450 | |||
| 2 | + | layout(set=0, binding=0) uniform sampler2D imageSDR; | |||
| 3 | + | layout(push_constant) uniform Brightness { float nits; } pc; | |||
| 4 | + | layout(location=0) out vec4 color; | |||
| 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) { | |||
| 10 | + | 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)); | |||
| 12 | + | } | |||
| 13 | + | void main() { | |||
| 14 | + | vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0)); | |||
| 15 | + | vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb); | |||
| 16 | + | vec3 bt2020 = mat3( | |||
| 17 | + | 0.627404, 0.069097, 0.016391, | |||
| 18 | + | 0.329283, 0.919540, 0.088013, | |||
| 19 | + | 0.043313, 0.011362, 0.895595) * rgb; | |||
| 20 | + | color = vec4(encodePQ(bt2020 * pc.nits), 1.0); | |||
| 21 | + | } | |||
A internal/adapters/vulkan/shaders/hdr.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
A internal/adapters/vulkan/shaders/hdr.vert +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | #version 450 | |
| 2 | + | void main() { | |
| 3 | + | const vec2 corners[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3)); | |
| 4 | + | gl_Position = vec4(corners[gl_VertexIndex], 0, 1); | |
| 5 | + | } | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | #version 450 | |||
| 2 | + | void main() { | |||
| 3 | + | const vec2 corners[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3)); | |||
| 4 | + | gl_Position = vec4(corners[gl_VertexIndex], 0, 1); | |||
| 5 | + | } | |||
A internal/adapters/vulkan/shaders/hdr.vert.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/vulkan/targets.go +60 -22
| @@ -16,8 +16,8 @@ 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. The internal image is the target until then (headless, tests). | |
| 20 | - | // Readback into host memory happens only for Pixels (screenshots). | |
| 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. | |
| 21 | 21 | ||
| 22 | 22 | // target is an image frames are drawn into. | |
| 23 | 23 | type target struct { | |
| @@ -38,10 +38,16 @@ type target struct { | |||
| 38 | 38 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | |
| 39 | 39 | const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | |
| 40 | 40 | ||
| 41 | + | // fourccXR30 is DRM_FORMAT_XRGB2101010 (A2R10G10B10 packed). | |
| 42 | + | const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |
| 43 | + | ||
| 41 | 44 | // ExportTargets allocates n exported images of the output size, replacing | |
| 42 | 45 | // the previous ones; n = 0 only drops them (back to the internal image). | |
| 43 | 46 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | |
| 44 | 47 | r.dropTargets() | |
| 48 | + | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | |
| 49 | + | return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError) | |
| 50 | + | } | |
| 45 | 51 | if n == 0 { | |
| 46 | 52 | return nil, nil | |
| 47 | 53 | } | |
| @@ -50,7 +56,10 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||
| 50 | 56 | } | |
| 51 | 57 | mods := r.exportModifiers(modifiers) | |
| 52 | 58 | if len(mods) == 0 { | |
| 53 | - | return nil, errors.New("no XRGB8888 modifier both the device and the display accept") | |
| 59 | + | if r.hdrNits == 0 { | |
| 60 | + | return nil, errors.New("no XRGB8888 modifier both the device and the display accept") | |
| 61 | + | } | |
| 62 | + | return nil, errors.New("no XRGB2101010 modifier both the device and the display accept") | |
| 54 | 63 | } | |
| 55 | 64 | var out []ports.DMABuf | |
| 56 | 65 | for range n { | |
| @@ -86,43 +95,58 @@ func (r *Renderer) target() *target { | |||
| 86 | 95 | return &r.own | |
| 87 | 96 | } | |
| 88 | 97 | ||
| 89 | - | // exportModifiers are the XRGB8888 modifiers the device renders to and | |
| 90 | - | // exports with one plane, restricted to the display's when given. | |
| 98 | + | // exportModifiers intersects the selected signal format modifiers with | |
| 99 | + | // those supported by the display. HDR never assumes an unspecified list. | |
| 91 | 100 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | |
| 92 | 101 | var out []uint64 | |
| 93 | - | for _, m := range r.renderMods { | |
| 94 | - | if len(display) == 0 || slices.Contains(display, m) { | |
| 102 | + | available := r.renderMods | |
| 103 | + | if r.hdrNits > 0 { | |
| 104 | + | available = r.hdrMods | |
| 105 | + | } | |
| 106 | + | for _, m := range available { | |
| 107 | + | if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) { | |
| 95 | 108 | out = append(out, m) | |
| 96 | 109 | } | |
| 97 | 110 | } | |
| 98 | 111 | return out | |
| 99 | 112 | } | |
| 100 | 113 | ||
| 101 | - | // probeRenderModifiers lists the B8G8R8A8 modifiers usable as a transfer | |
| 102 | - | // destination with a single plane that the device can export. | |
| 114 | + | // probeRenderModifiers lists single-plane, exportable SDR and HDR targets. | |
| 103 | 115 | func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | |
| 116 | + | r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm) | |
| 117 | + | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | |
| 104 | 121 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | |
| 105 | 122 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | |
| 106 | - | r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp) | |
| 123 | + | r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp) | |
| 107 | 124 | if list.DrmFormatModifierCount == 0 { | |
| 108 | - | return | |
| 125 | + | return nil | |
| 109 | 126 | } | |
| 110 | 127 | mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount) | |
| 111 | 128 | list.DrmFormatModifierProperties = &mods[0] | |
| 112 | - | r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp) | |
| 113 | - | need := vk.FormatFeatureFlags(formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit) | |
| 114 | - | // Targets are drawn on (compose.go) and read back for screenshots. | |
| 129 | + | r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp) | |
| 130 | + | need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit) | |
| 131 | + | if format == vk.FormatB8g8r8a8Unorm { | |
| 132 | + | need |= formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBlendBit | |
| 133 | + | } else if r.hdrReadback { | |
| 134 | + | need |= formatFeatureTransferSrc | |
| 135 | + | } | |
| 136 | + | var result []uint64 | |
| 137 | + | // SDR targets are composed and read back; HDR targets are color attachments. | |
| 115 | 138 | for _, m := range mods[:list.DrmFormatModifierCount] { | |
| 116 | - | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, m.DrmFormatModifier) { | |
| 117 | - | r.renderMods = append(r.renderMods, m.DrmFormatModifier) | |
| 139 | + | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { | |
| 140 | + | result = append(result, m.DrmFormatModifier) | |
| 118 | 141 | } | |
| 119 | 142 | } | |
| 143 | + | return result | |
| 120 | 144 | } | |
| 121 | 145 | ||
| 122 | - | func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool { | |
| 146 | + | func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool { | |
| 123 | 147 | mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive} | |
| 124 | 148 | ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | |
| 125 | - | info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: vk.FormatB8g8r8a8Unorm, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage} | |
| 149 | + | info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)} | |
| 126 | 150 | extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties} | |
| 127 | 151 | out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)} | |
| 128 | 152 | if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success { | |
| @@ -135,13 +159,26 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool | |||
| 135 | 159 | // generated bindings. | |
| 136 | 160 | const formatFeatureTransferSrc = 1 << 14 | |
| 137 | 161 | ||
| 138 | - | // Targets are drawn on (compose.go) and copied from for screenshots. | |
| 162 | + | // Exported SDR targets compose directly; HDR targets receive the final pass. | |
| 139 | 163 | const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit | |
| 140 | 164 | ||
| 165 | + | // Production HDR targets need only a color attachment; test readback opts | |
| 166 | + | // into transfer source before export and skips if the driver refuses it. | |
| 167 | + | func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags { | |
| 168 | + | if format == vk.FormatA2r10g10b10UnormPack32 && !readback { | |
| 169 | + | return vk.ImageUsageColorAttachmentBit | |
| 170 | + | } | |
| 171 | + | return targetUsage | |
| 172 | + | } | |
| 173 | + | ||
| 141 | 174 | // exportTarget creates one exported image with a modifier from mods. | |
| 142 | 175 | func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |
| 143 | 176 | d := r.dd | |
| 144 | 177 | t := &target{exported: true, layout: vk.ImageLayoutUndefined} | |
| 178 | + | format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB) | |
| 179 | + | if r.hdrNits > 0 { | |
| 180 | + | format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30 | |
| 181 | + | } | |
| 145 | 182 | ok := false | |
| 146 | 183 | defer func() { | |
| 147 | 184 | if !ok { | |
| @@ -150,7 +187,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||
| 150 | 187 | }() | |
| 151 | 188 | list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]} | |
| 152 | 189 | external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | |
| 153 | - | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | |
| 190 | + | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback), SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | |
| 154 | 191 | if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil { | |
| 155 | 192 | return nil, ports.DMABuf{}, err | |
| 156 | 193 | } | |
| @@ -176,7 +213,8 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||
| 176 | 213 | var layout vk.SubresourceLayout | |
| 177 | 214 | sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT} | |
| 178 | 215 | d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout) | |
| 179 | - | if err := r.createView(t); err != nil { | |
| 216 | + | t.view, err = r.imageView(t.image, format) | |
| 217 | + | if err != nil { | |
| 180 | 218 | return nil, ports.DMABuf{}, err | |
| 181 | 219 | } | |
| 182 | 220 | var fd int32 | |
| @@ -188,7 +226,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||
| 188 | 226 | if f == nil { | |
| 189 | 227 | return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd) | |
| 190 | 228 | } | |
| 191 | - | buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourccXRGB, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}} | |
| 229 | + | buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}} | |
| 192 | 230 | ok = true | |
| 193 | 231 | return t, buf, nil | |
| 194 | 232 | } | |
| @@ -16,8 +16,8 @@ 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. The internal image is the target until then (headless, tests). | 19 | + | // CPU. In HDR the internal image retains the SDR scene, sampled by a |
| 20 | - | // Readback into host memory happens only for Pixels (screenshots). | 20 | + | // full-screen GPU pass into the 10-bit target. Pixels reads internal SDR. |
| 21 | 21 | ||||
| 22 | // target is an image frames are drawn into. | 22 | // target is an image frames are drawn into. | ||
| 23 | type target struct { | 23 | type target struct { | ||
| @@ -38,10 +38,16 @@ type target struct { | |||||
| 38 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | 38 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | ||
| 39 | const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | 39 | const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24 | ||
| 40 | 40 | ||||
| 41 | + | // fourccXR30 is DRM_FORMAT_XRGB2101010 (A2R10G10B10 packed). | |||
| 42 | + | const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24 | |||
| 43 | + | ||||
| 41 | // ExportTargets allocates n exported images of the output size, replacing | 44 | // ExportTargets allocates n exported images of the output size, replacing | ||
| 42 | // the previous ones; n = 0 only drops them (back to the internal image). | 45 | // the previous ones; n = 0 only drops them (back to the internal image). | ||
| 43 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | 46 | func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) { | ||
| 44 | r.dropTargets() | 47 | r.dropTargets() | ||
| 48 | + | if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 { | |||
| 49 | + | return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError) | |||
| 50 | + | } | |||
| 45 | if n == 0 { | 51 | if n == 0 { | ||
| 46 | return nil, nil | 52 | return nil, nil | ||
| 47 | } | 53 | } | ||
| @@ -50,7 +56,10 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||||
| 50 | } | 56 | } | ||
| 51 | mods := r.exportModifiers(modifiers) | 57 | mods := r.exportModifiers(modifiers) | ||
| 52 | if len(mods) == 0 { | 58 | if len(mods) == 0 { | ||
| 53 | - | return nil, errors.New("no XRGB8888 modifier both the device and the display accept") | 59 | + | if r.hdrNits == 0 { |
| 60 | + | return nil, errors.New("no XRGB8888 modifier both the device and the display accept") | |||
| 61 | + | } | |||
| 62 | + | return nil, errors.New("no XRGB2101010 modifier both the device and the display accept") | |||
| 54 | } | 63 | } | ||
| 55 | var out []ports.DMABuf | 64 | var out []ports.DMABuf | ||
| 56 | for range n { | 65 | for range n { | ||
| @@ -86,43 +95,58 @@ func (r *Renderer) target() *target { | |||||
| 86 | return &r.own | 95 | return &r.own | ||
| 87 | } | 96 | } | ||
| 88 | 97 | ||||
| 89 | - | // exportModifiers are the XRGB8888 modifiers the device renders to and | 98 | + | // exportModifiers intersects the selected signal format modifiers with |
| 90 | - | // exports with one plane, restricted to the display's when given. | 99 | + | // those supported by the display. HDR never assumes an unspecified list. |
| 91 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | 100 | func (r *Renderer) exportModifiers(display []uint64) []uint64 { | ||
| 92 | var out []uint64 | 101 | var out []uint64 | ||
| 93 | - | for _, m := range r.renderMods { | 102 | + | available := r.renderMods |
| 94 | - | if len(display) == 0 || slices.Contains(display, m) { | 103 | + | if r.hdrNits > 0 { |
| 104 | + | available = r.hdrMods | |||
| 105 | + | } | |||
| 106 | + | for _, m := range available { | |||
| 107 | + | if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) { | |||
| 95 | out = append(out, m) | 108 | out = append(out, m) | ||
| 96 | } | 109 | } | ||
| 97 | } | 110 | } | ||
| 98 | return out | 111 | return out | ||
| 99 | } | 112 | } | ||
| 100 | 113 | ||||
| 101 | - | // probeRenderModifiers lists the B8G8R8A8 modifiers usable as a transfer | 114 | + | // probeRenderModifiers lists single-plane, exportable SDR and HDR targets. |
| 102 | - | // destination with a single plane that the device can export. | |||
| 103 | func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | 115 | func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) { | ||
| 116 | + | r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm) | |||
| 117 | + | r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 { | |||
| 104 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | 121 | list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT} | ||
| 105 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | 122 | fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)} | ||
| 106 | - | r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp) | 123 | + | r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp) |
| 107 | if list.DrmFormatModifierCount == 0 { | 124 | if list.DrmFormatModifierCount == 0 { | ||
| 108 | - | return | 125 | + | return nil |
| 109 | } | 126 | } | ||
| 110 | mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount) | 127 | mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount) | ||
| 111 | list.DrmFormatModifierProperties = &mods[0] | 128 | list.DrmFormatModifierProperties = &mods[0] | ||
| 112 | - | r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp) | 129 | + | r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp) |
| 113 | - | need := vk.FormatFeatureFlags(formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit) | 130 | + | need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit) |
| 114 | - | // Targets are drawn on (compose.go) and read back for screenshots. | 131 | + | if format == vk.FormatB8g8r8a8Unorm { |
| 132 | + | need |= formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBlendBit | |||
| 133 | + | } else if r.hdrReadback { | |||
| 134 | + | need |= formatFeatureTransferSrc | |||
| 135 | + | } | |||
| 136 | + | var result []uint64 | |||
| 137 | + | // SDR targets are composed and read back; HDR targets are color attachments. | |||
| 115 | for _, m := range mods[:list.DrmFormatModifierCount] { | 138 | for _, m := range mods[:list.DrmFormatModifierCount] { | ||
| 116 | - | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, m.DrmFormatModifier) { | 139 | + | if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) { |
| 117 | - | r.renderMods = append(r.renderMods, m.DrmFormatModifier) | 140 | + | result = append(result, m.DrmFormatModifier) |
| 118 | } | 141 | } | ||
| 119 | } | 142 | } | ||
| 143 | + | return result | |||
| 120 | } | 144 | } | ||
| 121 | 145 | ||||
| 122 | - | func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool { | 146 | + | func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool { |
| 123 | mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive} | 147 | mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive} | ||
| 124 | ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | 148 | ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | ||
| 125 | - | info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: vk.FormatB8g8r8a8Unorm, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage} | 149 | + | info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)} |
| 126 | extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties} | 150 | extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties} | ||
| 127 | out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)} | 151 | out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)} | ||
| 128 | if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success { | 152 | if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success { | ||
| @@ -135,13 +159,26 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool | |||||
| 135 | // generated bindings. | 159 | // generated bindings. | ||
| 136 | const formatFeatureTransferSrc = 1 << 14 | 160 | const formatFeatureTransferSrc = 1 << 14 | ||
| 137 | 161 | ||||
| 138 | - | // Targets are drawn on (compose.go) and copied from for screenshots. | 162 | + | // Exported SDR targets compose directly; HDR targets receive the final pass. |
| 139 | const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit | 163 | const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit | ||
| 140 | 164 | ||||
| 165 | + | // Production HDR targets need only a color attachment; test readback opts | |||
| 166 | + | // into transfer source before export and skips if the driver refuses it. | |||
| 167 | + | func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags { | |||
| 168 | + | if format == vk.FormatA2r10g10b10UnormPack32 && !readback { | |||
| 169 | + | return vk.ImageUsageColorAttachmentBit | |||
| 170 | + | } | |||
| 171 | + | return targetUsage | |||
| 172 | + | } | |||
| 173 | + | ||||
| 141 | // exportTarget creates one exported image with a modifier from mods. | 174 | // exportTarget creates one exported image with a modifier from mods. | ||
| 142 | func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | 175 | func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | ||
| 143 | d := r.dd | 176 | d := r.dd | ||
| 144 | t := &target{exported: true, layout: vk.ImageLayoutUndefined} | 177 | t := &target{exported: true, layout: vk.ImageLayoutUndefined} | ||
| 178 | + | format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB) | |||
| 179 | + | if r.hdrNits > 0 { | |||
| 180 | + | format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30 | |||
| 181 | + | } | |||
| 145 | ok := false | 182 | ok := false | ||
| 146 | defer func() { | 183 | defer func() { | ||
| 147 | if !ok { | 184 | if !ok { | ||
| @@ -150,7 +187,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||||
| 150 | }() | 187 | }() | ||
| 151 | list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]} | 188 | list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]} | ||
| 152 | external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | 189 | external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT} | ||
| 153 | - | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} | 190 | + | ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback), SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined} |
| 154 | if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil { | 191 | if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil { | ||
| 155 | return nil, ports.DMABuf{}, err | 192 | return nil, ports.DMABuf{}, err | ||
| 156 | } | 193 | } | ||
| @@ -176,7 +213,8 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||||
| 176 | var layout vk.SubresourceLayout | 213 | var layout vk.SubresourceLayout | ||
| 177 | sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT} | 214 | sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT} | ||
| 178 | d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout) | 215 | d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout) | ||
| 179 | - | if err := r.createView(t); err != nil { | 216 | + | t.view, err = r.imageView(t.image, format) |
| 217 | + | if err != nil { | |||
| 180 | return nil, ports.DMABuf{}, err | 218 | return nil, ports.DMABuf{}, err | ||
| 181 | } | 219 | } | ||
| 182 | var fd int32 | 220 | var fd int32 | ||
| @@ -188,7 +226,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) { | |||||
| 188 | if f == nil { | 226 | if f == nil { | ||
| 189 | return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd) | 227 | return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd) | ||
| 190 | } | 228 | } | ||
| 191 | - | buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourccXRGB, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}} | 229 | + | buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}} |
| 192 | ok = true | 230 | ok = true | ||
| 193 | return t, buf, nil | 231 | return t, buf, nil | ||
| 194 | } | 232 | } | ||
M internal/adapters/vulkan/targets_test.go +96 -0
| @@ -2,9 +2,12 @@ package vulkan | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image/color" | |
| 5 | + | "math" | |
| 5 | 6 | "testing" | |
| 7 | + | "unsafe" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | // Frames drawn into exported targets reach their dmabufs: another renderer | |
| @@ -62,3 +65,96 @@ func TestExportTargets(t *testing.T) { | |||
| 62 | 65 | t.Fatalf("target 0 as seen by the display %v", got) | |
| 63 | 66 | } | |
| 64 | 67 | } | |
| 68 | + | ||
| 69 | + | // The exported HDR image carries PQ while Pixels remains the composed SDR | |
| 70 | + | // image. A test-only transfer-src readback checks the actual shader output. | |
| 71 | + | func TestHDRExportTarget(t *testing.T) { | |
| 72 | + | r, err := New(32, 16) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 75 | + | } | |
| 76 | + | defer r.Close() | |
| 77 | + | r.SetHDR(203) | |
| 78 | + | // Only this GPU test requests transfer-src on an HDR target. | |
| 79 | + | r.hdrReadback = true | |
| 80 | + | if r.physical == 0 { | |
| 81 | + | t.Skip("no exportable GPU") | |
| 82 | + | } | |
| 83 | + | r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32) | |
| 84 | + | if len(r.hdrMods) == 0 { | |
| 85 | + | t.Skip("no HDR modifier supporting transfer-src") | |
| 86 | + | } | |
| 87 | + | bufs, err := r.ExportTargets(1, r.hdrMods) | |
| 88 | + | if err != nil { | |
| 89 | + | t.Skipf("no HDR-exportable target: %v", err) | |
| 90 | + | } | |
| 91 | + | defer bufs[0].Planes[0].File.Close() | |
| 92 | + | if bufs[0].Format != fourccXR30 { | |
| 93 | + | t.Fatalf("format: %#x", bufs[0].Format) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // A real second frame with an unchanged scene must retain the internal | |
| 97 | + | // image and use a partial-damage load from its actual prior layout. | |
| 98 | + | scene := ports.Scene{Seq: 1, Background: "#ffffff"} | |
| 99 | + | for _, tc := range []struct { | |
| 100 | + | bg string | |
| 101 | + | rgb [3]float64 | |
| 102 | + | }{ | |
| 103 | + | {"#ffffff", [3]float64{1, 1, 1}}, | |
| 104 | + | {"#ff0000", [3]float64{1, 0, 0}}, | |
| 105 | + | {"#0000ff", [3]float64{0, 0, 1}}, | |
| 106 | + | {"#808080", [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255}}, | |
| 107 | + | } { | |
| 108 | + | scene.Seq++ | |
| 109 | + | scene.Background = tc.bg | |
| 110 | + | if err := render(r, scene, nil); err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | checkHDRPixel(t, r, tc.rgb) | |
| 114 | + | } | |
| 115 | + | before := r.redrawn | |
| 116 | + | if err := render(r, scene, nil); err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if r.redrawn-before >= r.width*r.height { | |
| 120 | + | t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before) | |
| 121 | + | } | |
| 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) | |
| 125 | + | } | |
| 126 | + | if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 { | |
| 127 | + | t.Fatalf("SDR readback: %v", got) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Test-only target readback: production HDR images do not have transfer-src. | |
| 132 | + | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | |
| 133 | + | t.Helper() | |
| 134 | + | if err := r.waitFrame(r.submitted); err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | target := r.targets[0] | |
| 138 | + | err := r.oneShot(func(cmd vk.CommandBuffer) { | |
| 139 | + | d := r.dd | |
| 140 | + | 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} | |
| 141 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 142 | + | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |
| 143 | + | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, ®ion) | |
| 144 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |
| 145 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |
| 146 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |
| 147 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 148 | + | }) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatal(err) | |
| 151 | + | } | |
| 152 | + | v := *(*uint32)(unsafe.Pointer(r.mapped)) | |
| 153 | + | red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203) | |
| 154 | + | for i, want := range []float64{blue, green, red} { | |
| 155 | + | got := float64(v>>uint(i*10)&1023) / 1023 | |
| 156 | + | if math.Abs(got-want) > 0.005 { | |
| 157 | + | t.Fatalf("rgb %v channel %d: PQ %.5f want %.5f", rgb, i, got, want) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| @@ -2,9 +2,12 @@ package vulkan | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image/color" | 4 | "image/color" | ||
| 5 | + | "math" | |||
| 5 | "testing" | 6 | "testing" | ||
| 7 | + | "unsafe" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 9 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | + | vk "github.com/bnema/purego-vulkan/vulkan" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | // Frames drawn into exported targets reach their dmabufs: another renderer | 13 | // Frames drawn into exported targets reach their dmabufs: another renderer | ||
| @@ -62,3 +65,96 @@ func TestExportTargets(t *testing.T) { | |||||
| 62 | t.Fatalf("target 0 as seen by the display %v", got) | 65 | t.Fatalf("target 0 as seen by the display %v", got) | ||
| 63 | } | 66 | } | ||
| 64 | } | 67 | } | ||
| 68 | + | ||||
| 69 | + | // The exported HDR image carries PQ while Pixels remains the composed SDR | |||
| 70 | + | // image. A test-only transfer-src readback checks the actual shader output. | |||
| 71 | + | func TestHDRExportTarget(t *testing.T) { | |||
| 72 | + | r, err := New(32, 16) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 75 | + | } | |||
| 76 | + | defer r.Close() | |||
| 77 | + | r.SetHDR(203) | |||
| 78 | + | // Only this GPU test requests transfer-src on an HDR target. | |||
| 79 | + | r.hdrReadback = true | |||
| 80 | + | if r.physical == 0 { | |||
| 81 | + | t.Skip("no exportable GPU") | |||
| 82 | + | } | |||
| 83 | + | r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32) | |||
| 84 | + | if len(r.hdrMods) == 0 { | |||
| 85 | + | t.Skip("no HDR modifier supporting transfer-src") | |||
| 86 | + | } | |||
| 87 | + | bufs, err := r.ExportTargets(1, r.hdrMods) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Skipf("no HDR-exportable target: %v", err) | |||
| 90 | + | } | |||
| 91 | + | defer bufs[0].Planes[0].File.Close() | |||
| 92 | + | if bufs[0].Format != fourccXR30 { | |||
| 93 | + | t.Fatalf("format: %#x", bufs[0].Format) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // A real second frame with an unchanged scene must retain the internal | |||
| 97 | + | // image and use a partial-damage load from its actual prior layout. | |||
| 98 | + | scene := ports.Scene{Seq: 1, Background: "#ffffff"} | |||
| 99 | + | for _, tc := range []struct { | |||
| 100 | + | bg string | |||
| 101 | + | rgb [3]float64 | |||
| 102 | + | }{ | |||
| 103 | + | {"#ffffff", [3]float64{1, 1, 1}}, | |||
| 104 | + | {"#ff0000", [3]float64{1, 0, 0}}, | |||
| 105 | + | {"#0000ff", [3]float64{0, 0, 1}}, | |||
| 106 | + | {"#808080", [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255}}, | |||
| 107 | + | } { | |||
| 108 | + | scene.Seq++ | |||
| 109 | + | scene.Background = tc.bg | |||
| 110 | + | if err := render(r, scene, nil); err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | checkHDRPixel(t, r, tc.rgb) | |||
| 114 | + | } | |||
| 115 | + | before := r.redrawn | |||
| 116 | + | if err := render(r, scene, nil); err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | if r.redrawn-before >= r.width*r.height { | |||
| 120 | + | t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before) | |||
| 121 | + | } | |||
| 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) | |||
| 125 | + | } | |||
| 126 | + | if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 { | |||
| 127 | + | t.Fatalf("SDR readback: %v", got) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // Test-only target readback: production HDR images do not have transfer-src. | |||
| 132 | + | func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) { | |||
| 133 | + | t.Helper() | |||
| 134 | + | if err := r.waitFrame(r.submitted); err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | target := r.targets[0] | |||
| 138 | + | err := r.oneShot(func(cmd vk.CommandBuffer) { | |||
| 139 | + | d := r.dd | |||
| 140 | + | 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} | |||
| 141 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 142 | + | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |||
| 143 | + | d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, ®ion) | |||
| 144 | + | b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0 | |||
| 145 | + | b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral | |||
| 146 | + | b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT | |||
| 147 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |||
| 148 | + | }) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatal(err) | |||
| 151 | + | } | |||
| 152 | + | v := *(*uint32)(unsafe.Pointer(r.mapped)) | |||
| 153 | + | red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203) | |||
| 154 | + | for i, want := range []float64{blue, green, red} { | |||
| 155 | + | got := float64(v>>uint(i*10)&1023) / 1023 | |||
| 156 | + | if math.Abs(got-want) > 0.005 { | |||
| 157 | + | t.Fatalf("rgb %v channel %d: PQ %.5f want %.5f", rgb, i, got, want) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
M internal/app/drm.go +6 -1
| @@ -81,8 +81,13 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) { | |||
| 81 | 81 | // wantFromConfig turns output.<name> and render.* entries into connector choices. | |
| 82 | 82 | func wantFromConfig(cfg ports.Config) drm.Want { | |
| 83 | 83 | r := cfg.Render | |
| 84 | - | want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR} | |
| 84 | + | want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR} | |
| 85 | 85 | for _, o := range cfg.Outputs { | |
| 86 | + | nits := o.SDRBrightness | |
| 87 | + | if nits == 0 { | |
| 88 | + | nits = ports.DefaultSDRBrightness | |
| 89 | + | } | |
| 90 | + | want.HDR[o.Name] = drm.HDRSettings{Enabled: o.HDR, SDRBrightness: nits} | |
| 86 | 91 | if o.Off { | |
| 87 | 92 | want.Disabled[o.Name] = true | |
| 88 | 93 | continue | |
| @@ -81,8 +81,13 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) { | |||||
| 81 | // wantFromConfig turns output.<name> and render.* entries into connector choices. | 81 | // wantFromConfig turns output.<name> and render.* entries into connector choices. | ||
| 82 | func wantFromConfig(cfg ports.Config) drm.Want { | 82 | func wantFromConfig(cfg ports.Config) drm.Want { | ||
| 83 | r := cfg.Render | 83 | r := cfg.Render | ||
| 84 | - | want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR} | 84 | + | want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR} |
| 85 | for _, o := range cfg.Outputs { | 85 | for _, o := range cfg.Outputs { | ||
| 86 | + | nits := o.SDRBrightness | |||
| 87 | + | if nits == 0 { | |||
| 88 | + | nits = ports.DefaultSDRBrightness | |||
| 89 | + | } | |||
| 90 | + | want.HDR[o.Name] = drm.HDRSettings{Enabled: o.HDR, SDRBrightness: nits} | |||
| 86 | if o.Off { | 91 | if o.Off { | ||
| 87 | want.Disabled[o.Name] = true | 92 | want.Disabled[o.Name] = true | ||
| 88 | continue | 93 | continue | ||
M internal/app/drm_test.go +3 -2
| @@ -3,14 +3,15 @@ package app | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/drm" | |
| 6 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestWantFromConfig(t *testing.T) { | |
| 10 | - | cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}} | |
| 11 | + | cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}} | |
| 11 | 12 | cfg.Render.DirectScanout, cfg.Render.VRR = true, true | |
| 12 | 13 | want := wantFromConfig(cfg) | |
| 13 | - | if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR { | |
| 14 | + | if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 { | |
| 14 | 15 | t.Fatalf("%+v", want) | |
| 15 | 16 | } | |
| 16 | 17 | } | |
| @@ -3,14 +3,15 @@ package app | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/drm" | |||
| 6 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestWantFromConfig(t *testing.T) { | 10 | func TestWantFromConfig(t *testing.T) { | ||
| 10 | - | cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}} | 11 | + | cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}} |
| 11 | cfg.Render.DirectScanout, cfg.Render.VRR = true, true | 12 | cfg.Render.DirectScanout, cfg.Render.VRR = true, true | ||
| 12 | want := wantFromConfig(cfg) | 13 | want := wantFromConfig(cfg) | ||
| 13 | - | if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR { | 14 | + | if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 { |
| 14 | t.Fatalf("%+v", want) | 15 | t.Fatalf("%+v", want) | ||
| 15 | } | 16 | } | ||
| 16 | } | 17 | } | ||
M internal/mocks/ports/devices_mock.go +40 -0
| @@ -388,6 +388,46 @@ func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, win | |||
| 388 | 388 | return _c | |
| 389 | 389 | } | |
| 390 | 390 | ||
| 391 | + | // SetHDR provides a mock function for the type MockRenderer | |
| 392 | + | func (_mock *MockRenderer) SetHDR(sdrNits float64) { | |
| 393 | + | _mock.Called(sdrNits) | |
| 394 | + | return | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | // MockRenderer_SetHDR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHDR' | |
| 398 | + | type MockRenderer_SetHDR_Call struct { | |
| 399 | + | *mock.Call | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // SetHDR is a helper method to define mock.On call | |
| 403 | + | // - sdrNits float64 | |
| 404 | + | func (_e *MockRenderer_Expecter) SetHDR(sdrNits any) *MockRenderer_SetHDR_Call { | |
| 405 | + | return &MockRenderer_SetHDR_Call{Call: _e.mock.On("SetHDR", sdrNits)} | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func (_c *MockRenderer_SetHDR_Call) Run(run func(sdrNits float64)) *MockRenderer_SetHDR_Call { | |
| 409 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 410 | + | var arg0 float64 | |
| 411 | + | if args[0] != nil { | |
| 412 | + | arg0 = args[0].(float64) | |
| 413 | + | } | |
| 414 | + | run( | |
| 415 | + | arg0, | |
| 416 | + | ) | |
| 417 | + | }) | |
| 418 | + | return _c | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | func (_c *MockRenderer_SetHDR_Call) Return() *MockRenderer_SetHDR_Call { | |
| 422 | + | _c.Call.Return() | |
| 423 | + | return _c | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *MockRenderer_SetHDR_Call { | |
| 427 | + | _c.Run(run) | |
| 428 | + | return _c | |
| 429 | + | } | |
| 430 | + | ||
| 391 | 431 | // UseTarget provides a mock function for the type MockRenderer | |
| 392 | 432 | func (_mock *MockRenderer) UseTarget(i int) { | |
| 393 | 433 | _mock.Called(i) | |
| @@ -388,6 +388,46 @@ func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, win | |||||
| 388 | return _c | 388 | return _c | ||
| 389 | } | 389 | } | ||
| 390 | 390 | ||||
| 391 | + | // SetHDR provides a mock function for the type MockRenderer | |||
| 392 | + | func (_mock *MockRenderer) SetHDR(sdrNits float64) { | |||
| 393 | + | _mock.Called(sdrNits) | |||
| 394 | + | return | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | // MockRenderer_SetHDR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHDR' | |||
| 398 | + | type MockRenderer_SetHDR_Call struct { | |||
| 399 | + | *mock.Call | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // SetHDR is a helper method to define mock.On call | |||
| 403 | + | // - sdrNits float64 | |||
| 404 | + | func (_e *MockRenderer_Expecter) SetHDR(sdrNits any) *MockRenderer_SetHDR_Call { | |||
| 405 | + | return &MockRenderer_SetHDR_Call{Call: _e.mock.On("SetHDR", sdrNits)} | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func (_c *MockRenderer_SetHDR_Call) Run(run func(sdrNits float64)) *MockRenderer_SetHDR_Call { | |||
| 409 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 410 | + | var arg0 float64 | |||
| 411 | + | if args[0] != nil { | |||
| 412 | + | arg0 = args[0].(float64) | |||
| 413 | + | } | |||
| 414 | + | run( | |||
| 415 | + | arg0, | |||
| 416 | + | ) | |||
| 417 | + | }) | |||
| 418 | + | return _c | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func (_c *MockRenderer_SetHDR_Call) Return() *MockRenderer_SetHDR_Call { | |||
| 422 | + | _c.Call.Return() | |||
| 423 | + | return _c | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *MockRenderer_SetHDR_Call { | |||
| 427 | + | _c.Run(run) | |||
| 428 | + | return _c | |||
| 429 | + | } | |||
| 430 | + | ||||
| 391 | // UseTarget provides a mock function for the type MockRenderer | 431 | // UseTarget provides a mock function for the type MockRenderer | ||
| 392 | func (_mock *MockRenderer) UseTarget(i int) { | 432 | func (_mock *MockRenderer) UseTarget(i int) { | ||
| 393 | _mock.Called(i) | 433 | _mock.Called(i) | ||
M internal/ports/config.go +12 -7
| @@ -1,5 +1,8 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | + | // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits. | |
| 4 | + | const DefaultSDRBrightness = 203 | |
| 5 | + | ||
| 3 | 6 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | |
| 4 | 7 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | |
| 5 | 8 | // monitor's preferred mode. Off disables the connector. | |
| @@ -8,15 +11,17 @@ package ports | |||
| 8 | 11 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 9 | 12 | // physical = logical × Scale. | |
| 10 | 13 | // Primary gets the focus and the pointer at startup, wherever it is placed. | |
| 11 | - | // ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it | |
| 14 | + | // ScaleOnly marks an entry set only by output.<name> subkeys: it | |
| 12 | 15 | // does not select the connector. | |
| 13 | 16 | type OutputConfig struct { | |
| 14 | - | Name string | |
| 15 | - | Mode string | |
| 16 | - | Off bool | |
| 17 | - | Scale float64 | |
| 18 | - | Primary bool | |
| 19 | - | ScaleOnly bool | |
| 17 | + | Name string | |
| 18 | + | Mode string | |
| 19 | + | Off bool | |
| 20 | + | Scale float64 | |
| 21 | + | Primary bool | |
| 22 | + | HDR bool // opt-in to HDR10 on capable outputs | |
| 23 | + | SDRBrightness int // nits; default DefaultSDRBrightness | |
| 24 | + | ScaleOnly bool | |
| 20 | 25 | } | |
| 21 | 26 | ||
| 22 | 27 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| @@ -1,5 +1,8 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | + | // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits. | |||
| 4 | + | const DefaultSDRBrightness = 203 | |||
| 5 | + | ||||
| 3 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | 6 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | ||
| 4 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | 7 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | ||
| 5 | // monitor's preferred mode. Off disables the connector. | 8 | // monitor's preferred mode. Off disables the connector. | ||
| @@ -8,15 +11,17 @@ package ports | |||||
| 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | 11 | // Scale is the output scale (0 means 1); layout works in logical pixels, | ||
| 9 | // physical = logical × Scale. | 12 | // physical = logical × Scale. | ||
| 10 | // Primary gets the focus and the pointer at startup, wherever it is placed. | 13 | // Primary gets the focus and the pointer at startup, wherever it is placed. | ||
| 11 | - | // ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it | 14 | + | // ScaleOnly marks an entry set only by output.<name> subkeys: it |
| 12 | // does not select the connector. | 15 | // does not select the connector. | ||
| 13 | type OutputConfig struct { | 16 | type OutputConfig struct { | ||
| 14 | - | Name string | 17 | + | Name string |
| 15 | - | Mode string | 18 | + | Mode string |
| 16 | - | Off bool | 19 | + | Off bool |
| 17 | - | Scale float64 | 20 | + | Scale float64 |
| 18 | - | Primary bool | 21 | + | Primary bool |
| 19 | - | ScaleOnly bool | 22 | + | HDR bool // opt-in to HDR10 on capable outputs |
| 23 | + | SDRBrightness int // nits; default DefaultSDRBrightness | |||
| 24 | + | ScaleOnly bool | |||
| 20 | } | 25 | } | ||
| 21 | 26 | ||||
| 22 | // Config is the parsed compositor configuration (see the config adapter for keys). | 27 | // Config is the parsed compositor configuration (see the config adapter for keys). | ||
M internal/ports/devices.go +6 -2
| @@ -9,12 +9,16 @@ import ( | |||
| 9 | 9 | // to its own image until ExportTargets gives it scanout images; then each | |
| 10 | 10 | // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass). | |
| 11 | 11 | type Renderer interface { | |
| 12 | + | // SetHDR selects the HDR10 output transform (0 disables it). | |
| 13 | + | // The DRM output calls this before ExportTargets. | |
| 14 | + | SetHDR(sdrNits float64) | |
| 12 | 15 | // Render draws the scene. done is a sync file signalled when the GPU | |
| 13 | 16 | // finished the frame (nil: already finished); the caller closes it. | |
| 14 | 17 | Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error) | |
| 15 | 18 | // ExportTargets allocates n images of the renderer's size that the | |
| 16 | - | // display can scan out, with one of the given XRGB8888 modifiers | |
| 17 | - | // (none: any the device exports), and returns them as dmabufs. | |
| 19 | + | // display can scan out, with one of the given XRGB8888 (SDR) or | |
| 20 | + | // XRGB2101010 (HDR) modifiers (none: any the device exports), | |
| 21 | + | // and returns them as dmabufs. | |
| 18 | 22 | // n = 0 drops the targets. | |
| 19 | 23 | ExportTargets(n int, modifiers []uint64) ([]DMABuf, error) | |
| 20 | 24 | // UseTarget selects the exported image the next Render draws into. | |
| @@ -9,12 +9,16 @@ import ( | |||||
| 9 | // to its own image until ExportTargets gives it scanout images; then each | 9 | // to its own image until ExportTargets gives it scanout images; then each | ||
| 10 | // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass). | 10 | // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass). | ||
| 11 | type Renderer interface { | 11 | type Renderer interface { | ||
| 12 | + | // SetHDR selects the HDR10 output transform (0 disables it). | |||
| 13 | + | // The DRM output calls this before ExportTargets. | |||
| 14 | + | SetHDR(sdrNits float64) | |||
| 12 | // Render draws the scene. done is a sync file signalled when the GPU | 15 | // Render draws the scene. done is a sync file signalled when the GPU | ||
| 13 | // finished the frame (nil: already finished); the caller closes it. | 16 | // finished the frame (nil: already finished); the caller closes it. | ||
| 14 | Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error) | 17 | Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error) | ||
| 15 | // ExportTargets allocates n images of the renderer's size that the | 18 | // ExportTargets allocates n images of the renderer's size that the | ||
| 16 | - | // display can scan out, with one of the given XRGB8888 modifiers | 19 | + | // display can scan out, with one of the given XRGB8888 (SDR) or |
| 17 | - | // (none: any the device exports), and returns them as dmabufs. | 20 | + | // XRGB2101010 (HDR) modifiers (none: any the device exports), |
| 21 | + | // and returns them as dmabufs. | |||
| 18 | // n = 0 drops the targets. | 22 | // n = 0 drops the targets. | ||
| 19 | ExportTargets(n int, modifiers []uint64) ([]DMABuf, error) | 23 | ExportTargets(n int, modifiers []uint64) ([]DMABuf, error) | ||
| 20 | // UseTarget selects the exported image the next Render draws into. | 24 | // UseTarget selects the exported image the next Render draws into. | ||