feat(wayland): implement wp_single_pixel_buffer_v1 (#121)
* refactor(ports): centralize surface content buffer checks Add SurfaceContent.HasBuffer and build Empty on it so new buffer kinds only need one place to be counted. The vulkan scene walk uses it and only inspects SHM stride and offset when the content is SHM. Server.addBuffer now takes any clientBuffer and closes it on destroy when it implements close(). * feat(wayland): implement wp_single_pixel_buffer_v1 Advertise wp_single_pixel_buffer_manager_v1 at version 1. A single-pixel buffer reaches the renderer as SurfaceContent.Solid (1x1, premultiplied sRGB-encoded SolidColor), opaque only when alpha is full. Vulkan draws it as a solid quad with the alpha modifier applied and HDR pre-encoding so the shader's decode yields the right linear value; DRM scanout treats it as a non-dmabuf root. Rename pendingIsSHM to pendingNotDMABuf since shm and single-pixel buffers both lack syncobj support.
- commit
- a59ec800dc63b2e208ea459691780fa4eb46d893
- author
- bnema · · 2026-10-03 20:13 UTC
- committer
- GitHub · · 2026-10-03 20:13 UTC
- parent
- 1dd76b1
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
13 files · +327 -12
M README.md +1 -0
| @@ -54,6 +54,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term | |||
| 54 | 54 | ## The performance path | |
| 55 | 55 | ||
| 56 | 56 | - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait. | |
| 57 | + | - `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers. | |
| 57 | 58 | - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane. | |
| 58 | 59 | - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output. | |
| 59 | 60 | - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it. | |
| @@ -54,6 +54,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term | |||||
| 54 | ## The performance path | 54 | ## The performance path | ||
| 55 | 55 | ||||
| 56 | - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait. | 56 | - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait. | ||
| 57 | + | - `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers. | |||
| 57 | - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane. | 58 | - Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane. | ||
| 58 | - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output. | 59 | - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output. | ||
| 59 | - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it. | 60 | - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it. | ||
M internal/adapters/drm/scanout_test.go +13 -0
| @@ -106,6 +106,19 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||
| 106 | 106 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | |
| 107 | 107 | t.Fatalf("candidate %+v, reason %q", c, reason) | |
| 108 | 108 | } | |
| 109 | + | ||
| 110 | + | // A single-pixel root is hidden by the opaque dmabuf over it, like an shm | |
| 111 | + | // one; alone it is not a dmabuf. | |
| 112 | + | solidRoot := root | |
| 113 | + | solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1} | |
| 114 | + | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100) | |
| 115 | + | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | |
| 116 | + | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | |
| 117 | + | } | |
| 118 | + | solidRoot.Children = nil | |
| 119 | + | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" { | |
| 120 | + | t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason) | |
| 121 | + | } | |
| 109 | 122 | } | |
| 110 | 123 | ||
| 111 | 124 | // VRR follows a visible fullscreen window covering the output, even | |
| @@ -106,6 +106,19 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||||
| 106 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | 106 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | ||
| 107 | t.Fatalf("candidate %+v, reason %q", c, reason) | 107 | t.Fatalf("candidate %+v, reason %q", c, reason) | ||
| 108 | } | 108 | } | ||
| 109 | + | ||||
| 110 | + | // A single-pixel root is hidden by the opaque dmabuf over it, like an shm | |||
| 111 | + | // one; alone it is not a dmabuf. | |||
| 112 | + | solidRoot := root | |||
| 113 | + | solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1} | |||
| 114 | + | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100) | |||
| 115 | + | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | |||
| 116 | + | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | |||
| 117 | + | } | |||
| 118 | + | solidRoot.Children = nil | |||
| 119 | + | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" { | |||
| 120 | + | t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason) | |||
| 121 | + | } | |||
| 109 | } | 122 | } | ||
| 110 | 123 | ||||
| 111 | // VRR follows a visible fullscreen window covering the output, even | 124 | // VRR follows a visible fullscreen window covering the output, even | ||
M internal/adapters/vulkan/compose.go +24 -0
| @@ -302,6 +302,30 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||
| 302 | 302 | return dr | |
| 303 | 303 | } | |
| 304 | 304 | ||
| 305 | + | // solidDraw is the quad of a single-pixel buffer. Opaque forces alpha to 1 | |
| 306 | + | // keeping rgb, like the shader's flagOpaque does for client pixels; Fade | |
| 307 | + | // scales the four premultiplied channels. The shader decodes the rgb of a | |
| 308 | + | // solid on HDR outputs, so there it is pre-encoded such that the decode | |
| 309 | + | // yields the linear premultiplied color. | |
| 310 | + | func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent) draw { | |
| 311 | + | c := *content.Solid | |
| 312 | + | if content.Opaque { | |
| 313 | + | c.A = 1 | |
| 314 | + | } | |
| 315 | + | k := 1 - max(0, min(1, content.Fade)) | |
| 316 | + | c.R, c.G, c.B, c.A = c.R*k, c.G*k, c.B*k, c.A*k | |
| 317 | + | if a := float64(c.A); r.hdrNits > 0 && a > 0 { | |
| 318 | + | enc := func(ch float32) float32 { | |
| 319 | + | v := max(0, min(1, float64(ch)/a)) | |
| 320 | + | return float32(linearToSRGB(srgbToLinear(v) * a)) | |
| 321 | + | } | |
| 322 | + | c.R, c.G, c.B = enc(c.R), enc(c.G), enc(c.B) | |
| 323 | + | } | |
| 324 | + | dr := r.fillDraw(rect, [3]uint8{}) | |
| 325 | + | dr.pc.color = [4]float32{c.R, c.G, c.B, c.A} | |
| 326 | + | return dr | |
| 327 | + | } | |
| 328 | + | ||
| 305 | 329 | // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA | |
| 306 | 330 | // leaves the destination at (1-alpha) of its previous value. | |
| 307 | 331 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | |
| @@ -302,6 +302,30 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||||
| 302 | return dr | 302 | return dr | ||
| 303 | } | 303 | } | ||
| 304 | 304 | ||||
| 305 | + | // solidDraw is the quad of a single-pixel buffer. Opaque forces alpha to 1 | |||
| 306 | + | // keeping rgb, like the shader's flagOpaque does for client pixels; Fade | |||
| 307 | + | // scales the four premultiplied channels. The shader decodes the rgb of a | |||
| 308 | + | // solid on HDR outputs, so there it is pre-encoded such that the decode | |||
| 309 | + | // yields the linear premultiplied color. | |||
| 310 | + | func (r *Renderer) solidDraw(rect image.Rectangle, content *ports.SurfaceContent) draw { | |||
| 311 | + | c := *content.Solid | |||
| 312 | + | if content.Opaque { | |||
| 313 | + | c.A = 1 | |||
| 314 | + | } | |||
| 315 | + | k := 1 - max(0, min(1, content.Fade)) | |||
| 316 | + | c.R, c.G, c.B, c.A = c.R*k, c.G*k, c.B*k, c.A*k | |||
| 317 | + | if a := float64(c.A); r.hdrNits > 0 && a > 0 { | |||
| 318 | + | enc := func(ch float32) float32 { | |||
| 319 | + | v := max(0, min(1, float64(ch)/a)) | |||
| 320 | + | return float32(linearToSRGB(srgbToLinear(v) * a)) | |||
| 321 | + | } | |||
| 322 | + | c.R, c.G, c.B = enc(c.R), enc(c.G), enc(c.B) | |||
| 323 | + | } | |||
| 324 | + | dr := r.fillDraw(rect, [3]uint8{}) | |||
| 325 | + | dr.pc.color = [4]float32{c.R, c.G, c.B, c.A} | |||
| 326 | + | return dr | |||
| 327 | + | } | |||
| 328 | + | ||||
| 305 | // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA | 329 | // dimDraw is premultiplied black: blending ONE, ONE_MINUS_SRC_ALPHA | ||
| 306 | // leaves the destination at (1-alpha) of its previous value. | 330 | // leaves the destination at (1-alpha) of its previous value. | ||
| 307 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | 331 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | ||
M internal/adapters/vulkan/pq.go +9 -0
| @@ -17,6 +17,15 @@ func srgbToLinear(v float64) float64 { | |||
| 17 | 17 | return math.Pow((v+0.055)/1.055, 2.4) | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | // linearToSRGB encodes a unit-range linear component; the inverse of | |
| 21 | + | // srgbToLinear. | |
| 22 | + | func linearToSRGB(v float64) float64 { | |
| 23 | + | if v <= 0.04045/12.92 { | |
| 24 | + | return v * 12.92 | |
| 25 | + | } | |
| 26 | + | return 1.055*math.Pow(v, 1/2.4) - 0.055 | |
| 27 | + | } | |
| 28 | + | ||
| 20 | 29 | // pqEncode encodes absolute luminance in nits using SMPTE ST 2084. | |
| 21 | 30 | func pqEncode(nits float64) float64 { | |
| 22 | 31 | if nits <= 0 { | |
| @@ -17,6 +17,15 @@ func srgbToLinear(v float64) float64 { | |||||
| 17 | return math.Pow((v+0.055)/1.055, 2.4) | 17 | return math.Pow((v+0.055)/1.055, 2.4) | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | + | // linearToSRGB encodes a unit-range linear component; the inverse of | |||
| 21 | + | // srgbToLinear. | |||
| 22 | + | func linearToSRGB(v float64) float64 { | |||
| 23 | + | if v <= 0.04045/12.92 { | |||
| 24 | + | return v * 12.92 | |||
| 25 | + | } | |||
| 26 | + | return 1.055*math.Pow(v, 1/2.4) - 0.055 | |||
| 27 | + | } | |||
| 28 | + | ||||
| 20 | // pqEncode encodes absolute luminance in nits using SMPTE ST 2084. | 29 | // pqEncode encodes absolute luminance in nits using SMPTE ST 2084. | ||
| 21 | func pqEncode(nits float64) float64 { | 30 | func pqEncode(nits float64) float64 { | ||
| 22 | if nits <= 0 { | 31 | if nits <= 0 { | ||
M internal/adapters/vulkan/scene.go +11 -2
| @@ -292,6 +292,9 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64 | |||
| 292 | 292 | // drawable reports whether the child's buffer will draw. GPU buffer | |
| 293 | 293 | // imports are cached, so pre-importing here costs nothing. | |
| 294 | 294 | func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool { | |
| 295 | + | if c.Solid != nil { | |
| 296 | + | return true | |
| 297 | + | } | |
| 295 | 298 | if c.DMABuf != nil { | |
| 296 | 299 | _, err := w.r.importDMABuf(c.DMABuf) | |
| 297 | 300 | return err == nil | |
| @@ -312,10 +315,10 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl | |||
| 312 | 315 | if lw <= 0 || lh <= 0 { | |
| 313 | 316 | lw, lh = content.Width, content.Height | |
| 314 | 317 | } | |
| 315 | - | if content.SHM == nil && content.DMABuf == nil || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 { | |
| 318 | + | if !content.HasBuffer() || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 { | |
| 316 | 319 | return full, dst, false | |
| 317 | 320 | } | |
| 318 | - | if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | |
| 321 | + | if content.SHM != nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | |
| 319 | 322 | return full, dst, false | |
| 320 | 323 | } | |
| 321 | 324 | full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom) | |
| @@ -361,6 +364,12 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||
| 361 | 364 | if rect.Empty() { | |
| 362 | 365 | return | |
| 363 | 366 | } | |
| 367 | + | if content.Solid != nil { | |
| 368 | + | if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 { | |
| 369 | + | w.draws = append(w.draws, dr) | |
| 370 | + | } | |
| 371 | + | return | |
| 372 | + | } | |
| 364 | 373 | if content.DMABuf != nil { | |
| 365 | 374 | im, err := r.importDMABuf(content.DMABuf) | |
| 366 | 375 | if err != nil { | |
| @@ -292,6 +292,9 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64 | |||||
| 292 | // drawable reports whether the child's buffer will draw. GPU buffer | 292 | // drawable reports whether the child's buffer will draw. GPU buffer | ||
| 293 | // imports are cached, so pre-importing here costs nothing. | 293 | // imports are cached, so pre-importing here costs nothing. | ||
| 294 | func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool { | 294 | func (w *sceneWalk) drawable(c *ports.SurfaceContent) bool { | ||
| 295 | + | if c.Solid != nil { | |||
| 296 | + | return true | |||
| 297 | + | } | |||
| 295 | if c.DMABuf != nil { | 298 | if c.DMABuf != nil { | ||
| 296 | _, err := w.r.importDMABuf(c.DMABuf) | 299 | _, err := w.r.importDMABuf(c.DMABuf) | ||
| 297 | return err == nil | 300 | return err == nil | ||
| @@ -312,10 +315,10 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl | |||||
| 312 | if lw <= 0 || lh <= 0 { | 315 | if lw <= 0 || lh <= 0 { | ||
| 313 | lw, lh = content.Width, content.Height | 316 | lw, lh = content.Width, content.Height | ||
| 314 | } | 317 | } | ||
| 315 | - | if content.SHM == nil && content.DMABuf == nil || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 { | 318 | + | if !content.HasBuffer() || lw <= 0 || lh <= 0 || content.Width <= 0 || content.Height <= 0 { |
| 316 | return full, dst, false | 319 | return full, dst, false | ||
| 317 | } | 320 | } | ||
| 318 | - | if content.DMABuf == nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { | 321 | + | if content.SHM != nil && (content.SHM.Stride < content.Width*4 || content.SHM.Offset < 0) { |
| 319 | return full, dst, false | 322 | return full, dst, false | ||
| 320 | } | 323 | } | ||
| 321 | full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom) | 324 | full = w.physRectF(x, y, float64(lw)*w.zoom, float64(lh)*w.zoom) | ||
| @@ -361,6 +364,12 @@ func (w *sceneWalk) content(dst, full image.Rectangle, content *ports.SurfaceCon | |||||
| 361 | if rect.Empty() { | 364 | if rect.Empty() { | ||
| 362 | return | 365 | return | ||
| 363 | } | 366 | } | ||
| 367 | + | if content.Solid != nil { | |||
| 368 | + | if dr := r.solidDraw(rect, content); dr.pc.color[3] > 0 { | |||
| 369 | + | w.draws = append(w.draws, dr) | |||
| 370 | + | } | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 364 | if content.DMABuf != nil { | 373 | if content.DMABuf != nil { | ||
| 365 | im, err := r.importDMABuf(content.DMABuf) | 374 | im, err := r.importDMABuf(content.DMABuf) | ||
| 366 | if err != nil { | 375 | if err != nil { | ||
A internal/adapters/vulkan/scene_solid_test.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // These walk tests need no Vulkan device: a single-pixel buffer is a solid | |
| 12 | + | // quad, which imports nothing. | |
| 13 | + | func TestSceneSolid(t *testing.T) { | |
| 14 | + | walk := func(r *Renderer, c ports.SurfaceContent) []draw { | |
| 15 | + | c.ID = 1 | |
| 16 | + | c.Width, c.Height, c.LogicalW, c.LogicalH = 1, 1, 20, 20 | |
| 17 | + | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}} | |
| 18 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |
| 19 | + | return r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |
| 20 | + | } | |
| 21 | + | // The window's own background fill comes first; the content follows. | |
| 22 | + | solids := func(ds []draw) []draw { | |
| 23 | + | if len(ds) == 0 || ds[0].pc.misc[0] != modeSolid || ds[0].pc.rect != [4]int32{0, 0, 20, 20} { | |
| 24 | + | t.Fatalf("no window background first: %+v", ds) | |
| 25 | + | } | |
| 26 | + | return ds[1:] | |
| 27 | + | } | |
| 28 | + | near := func(a, b [4]float32) bool { | |
| 29 | + | for i := range a { | |
| 30 | + | if math.Abs(float64(a[i]-b[i])) > 1e-6 { | |
| 31 | + | return false | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | return true | |
| 35 | + | } | |
| 36 | + | half := &ports.SolidColor{R: 0.25, G: 0.125, B: 0, A: 0.5} // premultiplied | |
| 37 | + | t.Run("premultiplied", func(t *testing.T) { | |
| 38 | + | r := &Renderer{width: 80, height: 60} | |
| 39 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half})) | |
| 40 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 0.5} { | |
| 41 | + | t.Fatalf("draws = %+v", got) | |
| 42 | + | } | |
| 43 | + | }) | |
| 44 | + | t.Run("fade", func(t *testing.T) { | |
| 45 | + | r := &Renderer{width: 80, height: 60} | |
| 46 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half, Fade: 0.5})) | |
| 47 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.125, 0.0625, 0, 0.25} { | |
| 48 | + | t.Fatalf("draws = %+v", got) | |
| 49 | + | } | |
| 50 | + | }) | |
| 51 | + | t.Run("opaque", func(t *testing.T) { | |
| 52 | + | r := &Renderer{width: 80, height: 60} | |
| 53 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half, Opaque: true})) | |
| 54 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 1} { | |
| 55 | + | t.Fatalf("draws = %+v", got) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | t.Run("hdr", func(t *testing.T) { | |
| 59 | + | r := &Renderer{width: 80, height: 60, hdrNits: 203} | |
| 60 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half})) | |
| 61 | + | if len(got) != 1 { | |
| 62 | + | t.Fatalf("draws = %+v", got) | |
| 63 | + | } | |
| 64 | + | // The HDR shader decodes rgb (decodeSRGB) and blends linear light: | |
| 65 | + | // decoded values must be the linear straight color times alpha. | |
| 66 | + | c := got[0].pc.color | |
| 67 | + | decoded := [4]float32{float32(srgbToLinear(float64(c[0]))), float32(srgbToLinear(float64(c[1]))), float32(srgbToLinear(float64(c[2]))), c[3]} | |
| 68 | + | want := [4]float32{float32(srgbToLinear(0.5) * 0.5), float32(srgbToLinear(0.25) * 0.5), 0, 0.5} | |
| 69 | + | if !near(decoded, want) { | |
| 70 | + | t.Fatalf("decoded = %v, want %v", decoded, want) | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | t.Run("transparent", func(t *testing.T) { | |
| 74 | + | r := &Renderer{width: 80, height: 60} | |
| 75 | + | if got := solids(walk(r, ports.SurfaceContent{Solid: &ports.SolidColor{}})); len(got) != 0 { | |
| 76 | + | t.Fatalf("draws = %+v", got) | |
| 77 | + | } | |
| 78 | + | // Fully faded away too. | |
| 79 | + | if got := walk(r, ports.SurfaceContent{Solid: half, Fade: 1}); len(solids(got)) != 0 { | |
| 80 | + | t.Fatalf("faded draws = %+v", got) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestLinearToSRGB(t *testing.T) { | |
| 86 | + | for _, v := range []float64{0, 0.002, 0.04045, 0.5, 1} { | |
| 87 | + | if got := srgbToLinear(linearToSRGB(v)); math.Abs(got-v) > 1e-6 { | |
| 88 | + | t.Errorf("round trip of %v = %v", v, got) | |
| 89 | + | } | |
| 90 | + | if got := linearToSRGB(srgbToLinear(v)); math.Abs(got-v) > 1e-6 { | |
| 91 | + | t.Errorf("inverse round trip of %v = %v", v, got) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // These walk tests need no Vulkan device: a single-pixel buffer is a solid | |||
| 12 | + | // quad, which imports nothing. | |||
| 13 | + | func TestSceneSolid(t *testing.T) { | |||
| 14 | + | walk := func(r *Renderer, c ports.SurfaceContent) []draw { | |||
| 15 | + | c.ID = 1 | |||
| 16 | + | c.Width, c.Height, c.LogicalW, c.LogicalH = 1, 1, 20, 20 | |||
| 17 | + | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}} | |||
| 18 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |||
| 19 | + | return r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |||
| 20 | + | } | |||
| 21 | + | // The window's own background fill comes first; the content follows. | |||
| 22 | + | solids := func(ds []draw) []draw { | |||
| 23 | + | if len(ds) == 0 || ds[0].pc.misc[0] != modeSolid || ds[0].pc.rect != [4]int32{0, 0, 20, 20} { | |||
| 24 | + | t.Fatalf("no window background first: %+v", ds) | |||
| 25 | + | } | |||
| 26 | + | return ds[1:] | |||
| 27 | + | } | |||
| 28 | + | near := func(a, b [4]float32) bool { | |||
| 29 | + | for i := range a { | |||
| 30 | + | if math.Abs(float64(a[i]-b[i])) > 1e-6 { | |||
| 31 | + | return false | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | return true | |||
| 35 | + | } | |||
| 36 | + | half := &ports.SolidColor{R: 0.25, G: 0.125, B: 0, A: 0.5} // premultiplied | |||
| 37 | + | t.Run("premultiplied", func(t *testing.T) { | |||
| 38 | + | r := &Renderer{width: 80, height: 60} | |||
| 39 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half})) | |||
| 40 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 0.5} { | |||
| 41 | + | t.Fatalf("draws = %+v", got) | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | t.Run("fade", func(t *testing.T) { | |||
| 45 | + | r := &Renderer{width: 80, height: 60} | |||
| 46 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half, Fade: 0.5})) | |||
| 47 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.125, 0.0625, 0, 0.25} { | |||
| 48 | + | t.Fatalf("draws = %+v", got) | |||
| 49 | + | } | |||
| 50 | + | }) | |||
| 51 | + | t.Run("opaque", func(t *testing.T) { | |||
| 52 | + | r := &Renderer{width: 80, height: 60} | |||
| 53 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half, Opaque: true})) | |||
| 54 | + | if len(got) != 1 || got[0].pc.color != [4]float32{0.25, 0.125, 0, 1} { | |||
| 55 | + | t.Fatalf("draws = %+v", got) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | t.Run("hdr", func(t *testing.T) { | |||
| 59 | + | r := &Renderer{width: 80, height: 60, hdrNits: 203} | |||
| 60 | + | got := solids(walk(r, ports.SurfaceContent{Solid: half})) | |||
| 61 | + | if len(got) != 1 { | |||
| 62 | + | t.Fatalf("draws = %+v", got) | |||
| 63 | + | } | |||
| 64 | + | // The HDR shader decodes rgb (decodeSRGB) and blends linear light: | |||
| 65 | + | // decoded values must be the linear straight color times alpha. | |||
| 66 | + | c := got[0].pc.color | |||
| 67 | + | decoded := [4]float32{float32(srgbToLinear(float64(c[0]))), float32(srgbToLinear(float64(c[1]))), float32(srgbToLinear(float64(c[2]))), c[3]} | |||
| 68 | + | want := [4]float32{float32(srgbToLinear(0.5) * 0.5), float32(srgbToLinear(0.25) * 0.5), 0, 0.5} | |||
| 69 | + | if !near(decoded, want) { | |||
| 70 | + | t.Fatalf("decoded = %v, want %v", decoded, want) | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | t.Run("transparent", func(t *testing.T) { | |||
| 74 | + | r := &Renderer{width: 80, height: 60} | |||
| 75 | + | if got := solids(walk(r, ports.SurfaceContent{Solid: &ports.SolidColor{}})); len(got) != 0 { | |||
| 76 | + | t.Fatalf("draws = %+v", got) | |||
| 77 | + | } | |||
| 78 | + | // Fully faded away too. | |||
| 79 | + | if got := walk(r, ports.SurfaceContent{Solid: half, Fade: 1}); len(solids(got)) != 0 { | |||
| 80 | + | t.Fatalf("faded draws = %+v", got) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestLinearToSRGB(t *testing.T) { | |||
| 86 | + | for _, v := range []float64{0, 0.002, 0.04045, 0.5, 1} { | |||
| 87 | + | if got := srgbToLinear(linearToSRGB(v)); math.Abs(got-v) > 1e-6 { | |||
| 88 | + | t.Errorf("round trip of %v = %v", v, got) | |||
| 89 | + | } | |||
| 90 | + | if got := linearToSRGB(srgbToLinear(v)); math.Abs(got-v) > 1e-6 { | |||
| 91 | + | t.Errorf("inverse round trip of %v = %v", v, got) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
M internal/adapters/wayland/globals.go +8 -3
| @@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 30 | 30 | func() error { return registerScale(d, s) }, | |
| 31 | 31 | func() error { return registerCursorShape(d, s) }, | |
| 32 | 32 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | |
| 33 | + | func() error { return registerSinglePixelBuffer(d, s) }, | |
| 33 | 34 | func() error { return registerPointerConstraints(d, s) }, | |
| 34 | 35 | func() error { return registerTearing(d, s) }, | |
| 35 | 36 | func() error { return registerColorManagement(d, s) }, | |
| @@ -390,7 +391,8 @@ func (*buffer) Destroy(*wayland.Buffer) {} | |||
| 390 | 391 | ||
| 391 | 392 | func (b *buffer) size() (int, int) { return b.width, b.height } | |
| 392 | 393 | ||
| 393 | - | // clientBuffer is a wl_buffer's content source: wl_shm or linux-dmabuf. | |
| 394 | + | // clientBuffer is a wl_buffer's content source: wl_shm, linux-dmabuf or | |
| 395 | + | // single-pixel buffers. | |
| 394 | 396 | type clientBuffer interface { | |
| 395 | 397 | // content returns what the renderer draws; false means the client | |
| 396 | 398 | // broke its buffer (a truncated shm file). | |
| @@ -399,11 +401,14 @@ type clientBuffer interface { | |||
| 399 | 401 | } | |
| 400 | 402 | ||
| 401 | 403 | // addBuffer tracks a buffer until the client destroys it. | |
| 402 | - | func (s *Server) addBuffer(r *wayland.Buffer, b *dmabufBuffer) { | |
| 404 | + | // A buffer that owns resources implements close() to release them. | |
| 405 | + | func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) { | |
| 403 | 406 | s.buffers[r.Resource] = b | |
| 404 | 407 | r.OnDestroy = func() { | |
| 405 | 408 | delete(s.buffers, r.Resource) | |
| 406 | - | b.close() | |
| 409 | + | if c, ok := b.(interface{ close() }); ok { | |
| 410 | + | c.close() | |
| 411 | + | } | |
| 407 | 412 | } | |
| 408 | 413 | } | |
| 409 | 414 | ||
| @@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 30 | func() error { return registerScale(d, s) }, | 30 | func() error { return registerScale(d, s) }, | ||
| 31 | func() error { return registerCursorShape(d, s) }, | 31 | func() error { return registerCursorShape(d, s) }, | ||
| 32 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | 32 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | ||
| 33 | + | func() error { return registerSinglePixelBuffer(d, s) }, | |||
| 33 | func() error { return registerPointerConstraints(d, s) }, | 34 | func() error { return registerPointerConstraints(d, s) }, | ||
| 34 | func() error { return registerTearing(d, s) }, | 35 | func() error { return registerTearing(d, s) }, | ||
| 35 | func() error { return registerColorManagement(d, s) }, | 36 | func() error { return registerColorManagement(d, s) }, | ||
| @@ -390,7 +391,8 @@ func (*buffer) Destroy(*wayland.Buffer) {} | |||||
| 390 | 391 | ||||
| 391 | func (b *buffer) size() (int, int) { return b.width, b.height } | 392 | func (b *buffer) size() (int, int) { return b.width, b.height } | ||
| 392 | 393 | ||||
| 393 | - | // clientBuffer is a wl_buffer's content source: wl_shm or linux-dmabuf. | 394 | + | // clientBuffer is a wl_buffer's content source: wl_shm, linux-dmabuf or |
| 395 | + | // single-pixel buffers. | |||
| 394 | type clientBuffer interface { | 396 | type clientBuffer interface { | ||
| 395 | // content returns what the renderer draws; false means the client | 397 | // content returns what the renderer draws; false means the client | ||
| 396 | // broke its buffer (a truncated shm file). | 398 | // broke its buffer (a truncated shm file). | ||
| @@ -399,11 +401,14 @@ type clientBuffer interface { | |||||
| 399 | } | 401 | } | ||
| 400 | 402 | ||||
| 401 | // addBuffer tracks a buffer until the client destroys it. | 403 | // addBuffer tracks a buffer until the client destroys it. | ||
| 402 | - | func (s *Server) addBuffer(r *wayland.Buffer, b *dmabufBuffer) { | 404 | + | // A buffer that owns resources implements close() to release them. |
| 405 | + | func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) { | |||
| 403 | s.buffers[r.Resource] = b | 406 | s.buffers[r.Resource] = b | ||
| 404 | r.OnDestroy = func() { | 407 | r.OnDestroy = func() { | ||
| 405 | delete(s.buffers, r.Resource) | 408 | delete(s.buffers, r.Resource) | ||
| 406 | - | b.close() | 409 | + | if c, ok := b.(interface{ close() }); ok { |
| 410 | + | c.close() | |||
| 411 | + | } | |||
| 407 | } | 412 | } | ||
| 408 | } | 413 | } | ||
| 409 | 414 | ||||
A internal/adapters/wayland/singlepixel.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |
| 7 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // wp_single_pixel_buffer_v1: a 1x1 wl_buffer of one color. Clients use it | |
| 13 | + | // for solid backgrounds and letterbox bars; the renderer draws it as a fill | |
| 14 | + | // (ports.SurfaceContent.Solid) and no pixels are ever read or imported. | |
| 15 | + | ||
| 16 | + | func registerSinglePixelBuffer(d *server.Display, s *Server) error { | |
| 17 | + | return singlepixelbuffer.NewWpSinglePixelBufferManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 18 | + | _, _ = singlepixelbuffer.NewWpSinglePixelBufferManagerV1(c, int32(v), id, singlePixelManager{s}) | |
| 19 | + | }) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type singlePixelManager struct{ server *Server } | |
| 23 | + | ||
| 24 | + | func (singlePixelManager) Destroy(*singlepixelbuffer.WpSinglePixelBufferManagerV1) {} | |
| 25 | + | ||
| 26 | + | // CreateU32RgbaBuffer takes 32-bit components already premultiplied by | |
| 27 | + | // alpha, like client pixels; they become unit floats. | |
| 28 | + | func (m singlePixelManager) CreateU32RgbaBuffer(r *singlepixelbuffer.WpSinglePixelBufferManagerV1, id, red, green, blue, alpha uint32) { | |
| 29 | + | unit := func(v uint32) float32 { return float32(float64(v) / math.MaxUint32) } | |
| 30 | + | buf := &singlePixelBuffer{ | |
| 31 | + | color: ports.SolidColor{R: unit(red), G: unit(green), B: unit(blue), A: unit(alpha)}, | |
| 32 | + | opaque: alpha == math.MaxUint32, | |
| 33 | + | } | |
| 34 | + | res, err := wayland.NewBuffer(r.Client(), 1, id, buf) | |
| 35 | + | if err != nil { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | m.server.addBuffer(res, buf) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // singlePixelBuffer is a wl_buffer of one color. The color is never mutated, | |
| 42 | + | // so content shares it between commits. | |
| 43 | + | type singlePixelBuffer struct { | |
| 44 | + | color ports.SolidColor | |
| 45 | + | opaque bool | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (*singlePixelBuffer) Destroy(*wayland.Buffer) {} | |
| 49 | + | ||
| 50 | + | func (*singlePixelBuffer) size() (int, int) { return 1, 1 } | |
| 51 | + | ||
| 52 | + | func (b *singlePixelBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) { | |
| 53 | + | return ports.SurfaceContent{ID: id, Width: 1, Height: 1, Opaque: b.opaque, Solid: &b.color}, true | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |||
| 7 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | "github.com/bnema/purego-libwayland/server" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // wp_single_pixel_buffer_v1: a 1x1 wl_buffer of one color. Clients use it | |||
| 13 | + | // for solid backgrounds and letterbox bars; the renderer draws it as a fill | |||
| 14 | + | // (ports.SurfaceContent.Solid) and no pixels are ever read or imported. | |||
| 15 | + | ||||
| 16 | + | func registerSinglePixelBuffer(d *server.Display, s *Server) error { | |||
| 17 | + | return singlepixelbuffer.NewWpSinglePixelBufferManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 18 | + | _, _ = singlepixelbuffer.NewWpSinglePixelBufferManagerV1(c, int32(v), id, singlePixelManager{s}) | |||
| 19 | + | }) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type singlePixelManager struct{ server *Server } | |||
| 23 | + | ||||
| 24 | + | func (singlePixelManager) Destroy(*singlepixelbuffer.WpSinglePixelBufferManagerV1) {} | |||
| 25 | + | ||||
| 26 | + | // CreateU32RgbaBuffer takes 32-bit components already premultiplied by | |||
| 27 | + | // alpha, like client pixels; they become unit floats. | |||
| 28 | + | func (m singlePixelManager) CreateU32RgbaBuffer(r *singlepixelbuffer.WpSinglePixelBufferManagerV1, id, red, green, blue, alpha uint32) { | |||
| 29 | + | unit := func(v uint32) float32 { return float32(float64(v) / math.MaxUint32) } | |||
| 30 | + | buf := &singlePixelBuffer{ | |||
| 31 | + | color: ports.SolidColor{R: unit(red), G: unit(green), B: unit(blue), A: unit(alpha)}, | |||
| 32 | + | opaque: alpha == math.MaxUint32, | |||
| 33 | + | } | |||
| 34 | + | res, err := wayland.NewBuffer(r.Client(), 1, id, buf) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | m.server.addBuffer(res, buf) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // singlePixelBuffer is a wl_buffer of one color. The color is never mutated, | |||
| 42 | + | // so content shares it between commits. | |||
| 43 | + | type singlePixelBuffer struct { | |||
| 44 | + | color ports.SolidColor | |||
| 45 | + | opaque bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (*singlePixelBuffer) Destroy(*wayland.Buffer) {} | |||
| 49 | + | ||||
| 50 | + | func (*singlePixelBuffer) size() (int, int) { return 1, 1 } | |||
| 51 | + | ||||
| 52 | + | func (b *singlePixelBuffer) content(id ports.WindowID) (ports.SurfaceContent, bool) { | |||
| 53 | + | return ports.SurfaceContent{ID: id, Width: 1, Height: 1, Opaque: b.opaque, Solid: &b.color}, true | |||
| 54 | + | } | |||
A internal/adapters/wayland/singlepixel_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // A single-pixel buffer reaches the renderer as a 1x1 solid color, opaque | |
| 14 | + | // only when its alpha is full. | |
| 15 | + | func TestSinglePixelBuffer(t *testing.T) { | |
| 16 | + | s, events, contents, dir := dmabufServer(t, ports.DMABufSupport{}) | |
| 17 | + | c := protocolClient(t, s, dir) | |
| 18 | + | g, ok := c.Registry().FindGlobal("wp_single_pixel_buffer_manager_v1") | |
| 19 | + | if !ok || g.Version != 1 { | |
| 20 | + | t.Fatalf("global %+v", g) | |
| 21 | + | } | |
| 22 | + | mgr, err := bindWireID(c, g.Name, g.Interface, 1) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | w, surf, _ := surfaceMapper(t, c, events)() | |
| 27 | + | solid := func(alpha uint32, red uint32) ports.SurfaceContent { | |
| 28 | + | t.Helper() | |
| 29 | + | buf := c.AllocateID() | |
| 30 | + | registerProtocol(t, c, buf) | |
| 31 | + | requestProtocol(t, c, mgr, singlepixelbuffer.WpSinglePixelBufferManagerV1RequestCreateU32RgbaBuffer, buf, red, uint32(0), uint32(0), alpha) | |
| 32 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 33 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 34 | + | if err := c.Roundtrip(); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | timeout := time.After(2 * time.Second) | |
| 38 | + | for { | |
| 39 | + | select { | |
| 40 | + | case got := <-contents: | |
| 41 | + | if got.ID == w.ID && got.Solid != nil { | |
| 42 | + | return got | |
| 43 | + | } | |
| 44 | + | case <-timeout: | |
| 45 | + | t.Fatal("no solid content") | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | got := solid(math.MaxUint32, 0) | |
| 50 | + | if got.Width != 1 || got.Height != 1 || !got.Opaque || *got.Solid != (ports.SolidColor{A: 1}) { | |
| 51 | + | t.Fatalf("opaque black = %+v solid %+v", got, got.Solid) | |
| 52 | + | } | |
| 53 | + | got = solid(math.MaxUint32/2, math.MaxUint32/2) | |
| 54 | + | if got.Width != 1 || got.Height != 1 || got.Opaque { | |
| 55 | + | t.Fatalf("translucent = %+v", got) | |
| 56 | + | } | |
| 57 | + | if want := float32(0.5); got.Solid.R != want || got.Solid.A != want || got.Solid.G != 0 || got.Solid.B != 0 { | |
| 58 | + | t.Fatalf("translucent color = %+v", got.Solid) | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // A single-pixel buffer reaches the renderer as a 1x1 solid color, opaque | |||
| 14 | + | // only when its alpha is full. | |||
| 15 | + | func TestSinglePixelBuffer(t *testing.T) { | |||
| 16 | + | s, events, contents, dir := dmabufServer(t, ports.DMABufSupport{}) | |||
| 17 | + | c := protocolClient(t, s, dir) | |||
| 18 | + | g, ok := c.Registry().FindGlobal("wp_single_pixel_buffer_manager_v1") | |||
| 19 | + | if !ok || g.Version != 1 { | |||
| 20 | + | t.Fatalf("global %+v", g) | |||
| 21 | + | } | |||
| 22 | + | mgr, err := bindWireID(c, g.Name, g.Interface, 1) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | w, surf, _ := surfaceMapper(t, c, events)() | |||
| 27 | + | solid := func(alpha uint32, red uint32) ports.SurfaceContent { | |||
| 28 | + | t.Helper() | |||
| 29 | + | buf := c.AllocateID() | |||
| 30 | + | registerProtocol(t, c, buf) | |||
| 31 | + | requestProtocol(t, c, mgr, singlepixelbuffer.WpSinglePixelBufferManagerV1RequestCreateU32RgbaBuffer, buf, red, uint32(0), uint32(0), alpha) | |||
| 32 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |||
| 33 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 34 | + | if err := c.Roundtrip(); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | timeout := time.After(2 * time.Second) | |||
| 38 | + | for { | |||
| 39 | + | select { | |||
| 40 | + | case got := <-contents: | |||
| 41 | + | if got.ID == w.ID && got.Solid != nil { | |||
| 42 | + | return got | |||
| 43 | + | } | |||
| 44 | + | case <-timeout: | |||
| 45 | + | t.Fatal("no solid content") | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | got := solid(math.MaxUint32, 0) | |||
| 50 | + | if got.Width != 1 || got.Height != 1 || !got.Opaque || *got.Solid != (ports.SolidColor{A: 1}) { | |||
| 51 | + | t.Fatalf("opaque black = %+v solid %+v", got, got.Solid) | |||
| 52 | + | } | |||
| 53 | + | got = solid(math.MaxUint32/2, math.MaxUint32/2) | |||
| 54 | + | if got.Width != 1 || got.Height != 1 || got.Opaque { | |||
| 55 | + | t.Fatalf("translucent = %+v", got) | |||
| 56 | + | } | |||
| 57 | + | if want := float32(0.5); got.Solid.R != want || got.Solid.A != want || got.Solid.G != 0 || got.Solid.B != 0 { | |||
| 58 | + | t.Fatalf("translucent color = %+v", got.Solid) | |||
| 59 | + | } | |||
| 60 | + | } | |||
M internal/adapters/wayland/syncobj.go +5 -4
| @@ -168,16 +168,17 @@ func (s *surface) checkSyncCommit() bool { | |||
| 168 | 168 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point") | |
| 169 | 169 | case a.tl == r.tl && a.point >= r.point: | |
| 170 | 170 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point") | |
| 171 | - | case s.pendingIsSHM(): | |
| 172 | - | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a wl_shm buffer") | |
| 171 | + | case s.pendingNotDMABuf(): | |
| 172 | + | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a non-dmabuf buffer") | |
| 173 | 173 | default: | |
| 174 | 174 | return true | |
| 175 | 175 | } | |
| 176 | 176 | return false | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | - | // pendingIsSHM reports whether the buffer of the next commit is wl_shm. | |
| 180 | - | func (s *surface) pendingIsSHM() bool { | |
| 179 | + | // pendingNotDMABuf reports whether the buffer of the next commit is not a | |
| 180 | + | // dmabuf: wl_shm and single-pixel buffers have no syncobj support. | |
| 181 | + | func (s *surface) pendingNotDMABuf() bool { | |
| 181 | 182 | b := s.next.buffer | |
| 182 | 183 | if !s.next.attached { | |
| 183 | 184 | b = s.current | |
| @@ -168,16 +168,17 @@ func (s *surface) checkSyncCommit() bool { | |||||
| 168 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point") | 168 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorNoReleasePoint), "no release point") | ||
| 169 | case a.tl == r.tl && a.point >= r.point: | 169 | case a.tl == r.tl && a.point >= r.point: | ||
| 170 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point") | 170 | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorConflictingPoints), "release point not after acquire point") | ||
| 171 | - | case s.pendingIsSHM(): | 171 | + | case s.pendingNotDMABuf(): |
| 172 | - | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a wl_shm buffer") | 172 | + | res.PostError(uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1ErrorUnsupportedBuffer), "explicit sync on a non-dmabuf buffer") |
| 173 | default: | 173 | default: | ||
| 174 | return true | 174 | return true | ||
| 175 | } | 175 | } | ||
| 176 | return false | 176 | return false | ||
| 177 | } | 177 | } | ||
| 178 | 178 | ||||
| 179 | - | // pendingIsSHM reports whether the buffer of the next commit is wl_shm. | 179 | + | // pendingNotDMABuf reports whether the buffer of the next commit is not a |
| 180 | - | func (s *surface) pendingIsSHM() bool { | 180 | + | // dmabuf: wl_shm and single-pixel buffers have no syncobj support. |
| 181 | + | func (s *surface) pendingNotDMABuf() bool { | |||
| 181 | b := s.next.buffer | 182 | b := s.next.buffer | ||
| 182 | if !s.next.attached { | 183 | if !s.next.attached { | ||
| 183 | b = s.current | 184 | b = s.current | ||
M internal/adapters/wayland/wire_client_test.go +2 -0
| @@ -37,6 +37,7 @@ import ( | |||
| 37 | 37 | wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer" | |
| 38 | 38 | wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext" | |
| 39 | 39 | wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration" | |
| 40 | + | wire_singlepixelbuffer "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |
| 40 | 41 | wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol" | |
| 41 | 42 | wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput" | |
| 42 | 43 | wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter" | |
| @@ -188,6 +189,7 @@ var wireSchemas = map[string]wireSchema{ | |||
| 188 | 189 | "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}}, | |
| 189 | 190 | "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | |
| 190 | 191 | "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil}, | |
| 192 | + | "wp_single_pixel_buffer_manager_v1": {wire_singlepixelbuffer.WpSinglePixelBufferManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | |
| 191 | 193 | "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil}, | |
| 192 | 194 | "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil}, | |
| 193 | 195 | "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil}, | |
| @@ -37,6 +37,7 @@ import ( | |||||
| 37 | wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer" | 37 | wire_relativepointer "github.com/bnema/go-wayland-bindings/server/relativepointer" | ||
| 38 | wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext" | 38 | wire_securitycontext "github.com/bnema/go-wayland-bindings/server/securitycontext" | ||
| 39 | wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration" | 39 | wire_kdedecoration "github.com/bnema/go-wayland-bindings/server/serverdecoration" | ||
| 40 | + | wire_singlepixelbuffer "github.com/bnema/go-wayland-bindings/server/singlepixelbuffer" | |||
| 40 | wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol" | 41 | wire_tearingcontrol "github.com/bnema/go-wayland-bindings/server/tearingcontrol" | ||
| 41 | wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput" | 42 | wire_textinput "github.com/bnema/go-wayland-bindings/server/textinput" | ||
| 42 | wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter" | 43 | wire_viewporter "github.com/bnema/go-wayland-bindings/server/viewporter" | ||
| @@ -188,6 +189,7 @@ var wireSchemas = map[string]wireSchema{ | |||||
| 188 | "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}}, | 189 | "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}}, | ||
| 189 | "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | 190 | "wp_security_context_manager_v1": {wire_securitycontext.WpSecurityContextManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | ||
| 190 | "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil}, | 191 | "wp_security_context_v1": {wire_securitycontext.WpSecurityContextV1Interface, nil, map[uint16]bool{0: true}, nil}, | ||
| 192 | + | "wp_single_pixel_buffer_manager_v1": {wire_singlepixelbuffer.WpSinglePixelBufferManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | |||
| 191 | "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil}, | 193 | "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil}, | ||
| 192 | "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil}, | 194 | "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil}, | ||
| 193 | "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil}, | 195 | "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil}, | ||
M internal/ports/scene.go +16 -3
| @@ -148,8 +148,9 @@ const ( | |||
| 148 | 148 | ) | |
| 149 | 149 | ||
| 150 | 150 | // SurfaceContent carries wayland → output the latest committed content of a | |
| 151 | - | // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer. | |
| 152 | - | // Renderers read both in place and never modify them. Empty means the window | |
| 151 | + | // window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or | |
| 152 | + | // Solid, a single-pixel buffer. Renderers read them in place and never modify | |
| 153 | + | // them. Empty means the window | |
| 153 | 154 | // has no content. LogicalW and LogicalH are the surface size in logical | |
| 154 | 155 | // pixels (buffer scale and viewport applied). Subsurfaces come in Children; | |
| 155 | 156 | // Geometry is the part of the surface that is the window (xdg window | |
| @@ -179,6 +180,9 @@ type SurfaceContent struct { | |||
| 179 | 180 | Color SurfaceColor | |
| 180 | 181 | SHM *SHMBuffer | |
| 181 | 182 | DMABuf *DMABuf | |
| 183 | + | // Solid is a single-pixel buffer (Width and Height are 1): the surface is | |
| 184 | + | // one flat color. | |
| 185 | + | Solid *SolidColor | |
| 182 | 186 | // Children are the subsurfaces, bottom to top, flattened. | |
| 183 | 187 | Children []Subsurface | |
| 184 | 188 | // Geometry is in logical pixels from the surface origin; empty means | |
| @@ -293,9 +297,18 @@ type Subsurface struct { | |||
| 293 | 297 | SurfaceContent | |
| 294 | 298 | } | |
| 295 | 299 | ||
| 300 | + | // HasBuffer reports whether the surface itself carries a client buffer. | |
| 301 | + | func (c SurfaceContent) HasBuffer() bool { | |
| 302 | + | return c.SHM != nil || c.DMABuf != nil || c.Solid != nil | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // SolidColor is the color of a single-pixel buffer: premultiplied by alpha, | |
| 306 | + | // 0 to 1, sRGB-encoded like the pixels of client buffers. | |
| 307 | + | type SolidColor struct{ R, G, B, A float32 } | |
| 308 | + | ||
| 296 | 309 | // Empty reports whether the content has nothing to draw. | |
| 297 | 310 | func (c SurfaceContent) Empty() bool { | |
| 298 | - | return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0 | |
| 311 | + | return !c.HasBuffer() && len(c.Children) == 0 | |
| 299 | 312 | } | |
| 300 | 313 | ||
| 301 | 314 | // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888 | |
| @@ -148,8 +148,9 @@ const ( | |||||
| 148 | ) | 148 | ) | ||
| 149 | 149 | ||||
| 150 | // SurfaceContent carries wayland → output the latest committed content of a | 150 | // SurfaceContent carries wayland → output the latest committed content of a | ||
| 151 | - | // window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer. | 151 | + | // window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or |
| 152 | - | // Renderers read both in place and never modify them. Empty means the window | 152 | + | // Solid, a single-pixel buffer. Renderers read them in place and never modify |
| 153 | + | // them. Empty means the window | |||
| 153 | // has no content. LogicalW and LogicalH are the surface size in logical | 154 | // has no content. LogicalW and LogicalH are the surface size in logical | ||
| 154 | // pixels (buffer scale and viewport applied). Subsurfaces come in Children; | 155 | // pixels (buffer scale and viewport applied). Subsurfaces come in Children; | ||
| 155 | // Geometry is the part of the surface that is the window (xdg window | 156 | // Geometry is the part of the surface that is the window (xdg window | ||
| @@ -179,6 +180,9 @@ type SurfaceContent struct { | |||||
| 179 | Color SurfaceColor | 180 | Color SurfaceColor | ||
| 180 | SHM *SHMBuffer | 181 | SHM *SHMBuffer | ||
| 181 | DMABuf *DMABuf | 182 | DMABuf *DMABuf | ||
| 183 | + | // Solid is a single-pixel buffer (Width and Height are 1): the surface is | |||
| 184 | + | // one flat color. | |||
| 185 | + | Solid *SolidColor | |||
| 182 | // Children are the subsurfaces, bottom to top, flattened. | 186 | // Children are the subsurfaces, bottom to top, flattened. | ||
| 183 | Children []Subsurface | 187 | Children []Subsurface | ||
| 184 | // Geometry is in logical pixels from the surface origin; empty means | 188 | // Geometry is in logical pixels from the surface origin; empty means | ||
| @@ -293,9 +297,18 @@ type Subsurface struct { | |||||
| 293 | SurfaceContent | 297 | SurfaceContent | ||
| 294 | } | 298 | } | ||
| 295 | 299 | ||||
| 300 | + | // HasBuffer reports whether the surface itself carries a client buffer. | |||
| 301 | + | func (c SurfaceContent) HasBuffer() bool { | |||
| 302 | + | return c.SHM != nil || c.DMABuf != nil || c.Solid != nil | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // SolidColor is the color of a single-pixel buffer: premultiplied by alpha, | |||
| 306 | + | // 0 to 1, sRGB-encoded like the pixels of client buffers. | |||
| 307 | + | type SolidColor struct{ R, G, B, A float32 } | |||
| 308 | + | ||||
| 296 | // Empty reports whether the content has nothing to draw. | 309 | // Empty reports whether the content has nothing to draw. | ||
| 297 | func (c SurfaceContent) Empty() bool { | 310 | func (c SurfaceContent) Empty() bool { | ||
| 298 | - | return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0 | 311 | + | return !c.HasBuffer() && len(c.Children) == 0 |
| 299 | } | 312 | } | ||
| 300 | 313 | ||||
| 301 | // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888 | 314 | // SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888 | ||
A internal/ports/scene_content_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // HasBuffer is true for content carrying its own buffer; Empty also looks at | |
| 6 | + | // subsurfaces. | |
| 7 | + | func TestSurfaceContentHasBufferEmpty(t *testing.T) { | |
| 8 | + | tests := []struct { | |
| 9 | + | name string | |
| 10 | + | content SurfaceContent | |
| 11 | + | hasBuffer bool | |
| 12 | + | empty bool | |
| 13 | + | }{ | |
| 14 | + | {"zero", SurfaceContent{}, false, true}, | |
| 15 | + | {"shm", SurfaceContent{SHM: &SHMBuffer{}}, true, false}, | |
| 16 | + | {"dmabuf", SurfaceContent{DMABuf: &DMABuf{}}, true, false}, | |
| 17 | + | {"solid", SurfaceContent{Solid: &SolidColor{A: 1}}, true, false}, | |
| 18 | + | {"children only", SurfaceContent{Children: []Subsurface{{}}}, false, false}, | |
| 19 | + | } | |
| 20 | + | for _, tt := range tests { | |
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |
| 22 | + | if got := tt.content.HasBuffer(); got != tt.hasBuffer { | |
| 23 | + | t.Errorf("HasBuffer() = %v, want %v", got, tt.hasBuffer) | |
| 24 | + | } | |
| 25 | + | if got := tt.content.Empty(); got != tt.empty { | |
| 26 | + | t.Errorf("Empty() = %v, want %v", got, tt.empty) | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // HasBuffer is true for content carrying its own buffer; Empty also looks at | |||
| 6 | + | // subsurfaces. | |||
| 7 | + | func TestSurfaceContentHasBufferEmpty(t *testing.T) { | |||
| 8 | + | tests := []struct { | |||
| 9 | + | name string | |||
| 10 | + | content SurfaceContent | |||
| 11 | + | hasBuffer bool | |||
| 12 | + | empty bool | |||
| 13 | + | }{ | |||
| 14 | + | {"zero", SurfaceContent{}, false, true}, | |||
| 15 | + | {"shm", SurfaceContent{SHM: &SHMBuffer{}}, true, false}, | |||
| 16 | + | {"dmabuf", SurfaceContent{DMABuf: &DMABuf{}}, true, false}, | |||
| 17 | + | {"solid", SurfaceContent{Solid: &SolidColor{A: 1}}, true, false}, | |||
| 18 | + | {"children only", SurfaceContent{Children: []Subsurface{{}}}, false, false}, | |||
| 19 | + | } | |||
| 20 | + | for _, tt := range tests { | |||
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 22 | + | if got := tt.content.HasBuffer(); got != tt.hasBuffer { | |||
| 23 | + | t.Errorf("HasBuffer() = %v, want %v", got, tt.hasBuffer) | |||
| 24 | + | } | |||
| 25 | + | if got := tt.content.Empty(); got != tt.empty { | |||
| 26 | + | t.Errorf("Empty() = %v, want %v", got, tt.empty) | |||
| 27 | + | } | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||