From d0c425ee46ae574d66fc1ab5bda77e6a1f7ea9b4 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 24 Jul 2026 13:02:25 +0300 Subject: [PATCH] feat(software): expose headless surface readback --- CHANGELOG.md | 7 + README.md | 7 +- docs/SURFACE-TARGETS.md | 146 +++++++++++++ hal/resource.go | 12 ++ hal/software/readpixels_test.go | 97 +++++++++ hal/software/resource.go | 17 +- headless_surface_native_test.go | 365 ++++++++++++++++++++++++++++++++ surface_browser.go | 12 ++ surface_native.go | 31 +++ surface_rust.go | 12 ++ surface_target.go | 16 ++ surface_target_contract_test.go | 20 ++ surface_target_hal_test.go | 6 + 13 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 hal/software/readpixels_test.go create mode 100644 headless_surface_native_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9fa13a..eb528427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 public, HAL, Vulkan, and Rust-tag surface creation without cgo or Activity/JNI policy in WGPU. +- **Headless software surface readback (non-standard)** — add the zero-sized + `HeadlessSurfaceTarget` and root `Surface.ReadPixels()` lifecycle. The Pure-Go + software backend now returns owned, tightly packed RGBA8 snapshots after + present/discard for both RGBA8 and BGRA8 configurations. Other backends fail + explicitly through the optional `hal.PixelReader` capability rather than + widening the mandatory HAL surface interface. (#256) + ### Changed - **Counted indirect draws** — added `RenderPassEncoder.MultiDrawIndirect` and diff --git a/README.md b/README.md index b0a0376a..4f2f7300 100644 --- a/README.md +++ b/README.md @@ -361,9 +361,14 @@ import _ "github.com/gogpu/wgpu/hal/software" **Debug & Testing:** - Render pass instrumentation: `hal.Logger().Debug()` events + `RenderPassStats` for CI e2e assertions -- `GetFramebuffer()` pixel readback for headless test verification +- Public `wgpu.HeadlessSurfaceTarget` + `Surface.ReadPixels()` lifecycle for deterministic headless render verification; snapshots are owned, tightly packed RGBA8 +- HAL `GetFramebuffer()` remains as a compatibility alias for existing software-backend callers; new root API code should use `Surface.ReadPixels()` - Damage-aware partial blit with pixel-level test coverage +See [Surface targets](docs/SURFACE-TARGETS.md#headless-software-surface-and-readback) +for the complete configure → acquire → render → submit → present → readback +recipe and the explicit non-WebGPU support contract. + **Windowed Presentation:** - **Windows:** DWM-safe `CreateDIBSection` + `BitBlt` (SDL3/Qt6 pattern), zero-copy into GDI bitmap - **Linux X11:** `XPutImage` via goffi (Skia pattern), BGRA = X11 ZPixmap native format diff --git a/docs/SURFACE-TARGETS.md b/docs/SURFACE-TARGETS.md index 992a9908..2a12af4a 100644 --- a/docs/SURFACE-TARGETS.md +++ b/docs/SURFACE-TARGETS.md @@ -26,6 +26,7 @@ and [core surface creation](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289 | `SurfaceTarget` | Safe `SurfaceTarget<'window>` | A provider is sampled exactly once; the provider, not merely its raw result, is retained through backend destruction | | `SurfaceTarget.SurfaceTarget` | `Into` followed by raw-handle extraction | May return an application error; its identity is preserved with wrapping; it is never called after instance release | | `SurfaceTargetUnsafe` | `SurfaceTargetUnsafe::RawHandle` | Opaque closed value prevents callers from inventing a kind/handle mismatch; retains no ownership source | +| `HeadlessSurfaceTarget` | Explicit Go software extension; Rust `wgpu` has no windowless `SurfaceTarget` variant | Zero-sized safe target with no handles or external lifetime; accepted by the Pure-Go software backend and rejected by Rust/browser implementations | | `SurfaceTargetFromWindowsHWND` | `RawWindowHandle::Win32` plus the optional Windows display/module handle | `HWND` is required at creation; caller owns both handles through release | | `SurfaceTargetFromXlibWindow` | `RawDisplayHandle::Xlib` plus `RawWindowHandle::Xlib` | Both `Display*` and `Window` are required and caller-owned | | `SurfaceTargetFromWaylandSurface` | `RawDisplayHandle::Wayland` plus `RawWindowHandle::Wayland` | Both `wl_display*` and `wl_surface*` are required and caller-owned | @@ -38,6 +39,7 @@ and [core surface creation](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289 | `(*Instance).RequestAdapter` with `CompatibleSurface` | Rust `RequestAdapterOptions::compatible_surface` | Native dispatch supplies the surface created by each candidate adapter's own backend; a missing backend surface makes that backend incompatible | | `(*Adapter).GetSurfaceCapabilities` | `Surface::get_capabilities`, which resolves `surface.raw(adapter.backend())` | Never substitutes the currently active surface for another backend; missing same-backend state reports no capabilities | | `(*Surface).Configure` backend selection | Rust core's `surface_per_backend` selection by device backend | Reuses the retained surface for that backend and leaves other backend surfaces owned but inactive | +| `(*Surface).ReadPixels` | Explicit Go software extension; ordinary WebGPU readback uses texture-to-buffer copies | After present/discard, returns an owned, tightly packed top-left RGBA8 snapshot; unsupported implementations return an error rather than inventing pixels | | `(*Surface).Release` | Rust `Surface` drop and `_handle_source` field order | Destroys the active and inactive backend surfaces before clearing the safe provider; idempotent | | `(*Instance).Release` documentation | Rust surfaces have independent lifetimes | Native instances retire tracked surfaces; Rust-tag and browser surfaces still require explicit release, now stated without a false cascading promise | @@ -63,6 +65,7 @@ backend trait. | `hal.SurfaceTarget` and fields `Kind`, `DisplayHandle`, `WindowHandle` | Go representation of Rust's typed display/window-handle pair | Borrowed data only; HAL does not receive a Go ownership source and must reject a mismatched kind before pointer use | | `hal.SurfaceTarget.RequireKind` | A Rust `match` arm on `RawWindowHandle` | Wraps `hal.ErrUnsupportedSurfaceTarget`; performs no I/O or pointer access | | `hal.SurfaceTargetKind.String` | `Debug` formatting of raw-window-handle variants | Stable diagnostics only; unknown numeric values remain printable and unsupported | +| `hal.PixelReader` | Go optional-capability adaptation; no `wgpu-hal` surface method analogue | Implementations return a caller-owned, tightly packed top-left RGBA8 snapshot; adding the capability does not widen the mandatory `hal.Surface` interface | | `hal.Instance.CreateSurface` | `wgpu_hal::Instance::create_surface` | Signature intentionally changes from two unlabelled integers to one typed borrowed target; platform failures remain backend errors | | `hal/dx12.(*Instance).CreateSurface` | Rust DX12 Win32 surface creation | Accepts only `WindowsHWND`; stores a borrowed HWND and rejects other kinds first | | `hal/gles.(*Instance).CreateSurface` | Rust GLES WGL/EGL surface creation | Windows accepts `WindowsHWND`; Linux accepts Xlib or Wayland and explicitly selects the matching EGL display; backend errors remain wrapped | @@ -105,6 +108,149 @@ adaptation uses an opaque value with named constructors and validates it at the API boundary. The target kind remains explicit all the way into HAL; backends never infer Xlib versus Wayland from two unlabelled integers. +## Headless software surface and readback + +`HeadlessSurfaceTarget` is a deliberate Pure-Go software extension, not a Rust +`wgpu` or WebGPU surface variant. It lets tests and server-side renderers use the +normal surface lifecycle without fabricating a platform window: + +1. create a headless surface; +2. request a compatible fallback adapter; +3. configure, acquire, render, submit, and present normally; then +4. call `Surface.ReadPixels` after the acquired texture has been presented or + discarded. + +`ReadPixels` returns a caller-owned `width * height * 4` byte slice in tightly +packed, top-left, row-major RGBA8 order. The output contract is the same for +RGBA8 and BGRA8 surface configurations. Mutating the returned slice does not +change the surface. Calling it before configuration, while a texture is +acquired, after unconfiguration/release, or on a backend without the optional +readback capability returns an error. + +The following complete clear-and-capture path uses only the public root API: + +```go +package main + +import ( + "fmt" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu" + _ "github.com/gogpu/wgpu/hal/allbackends" +) + +func capture() ([]byte, error) { + instance, err := wgpu.CreateInstance(nil) + if err != nil { + return nil, err + } + defer instance.Release() + + surface, err := instance.CreateSurfaceFromTarget(wgpu.HeadlessSurfaceTarget{}) + if err != nil { + return nil, err + } + defer surface.Release() + + adapter, err := instance.RequestAdapter(&wgpu.RequestAdapterOptions{ + CompatibleSurface: surface, + ForceFallbackAdapter: true, + }) + if err != nil { + return nil, err + } + defer adapter.Release() + + device, err := adapter.RequestDevice(nil) + if err != nil { + return nil, err + } + defer device.Release() + + if err := surface.Configure(device, &wgpu.SurfaceConfiguration{ + Width: 4, + Height: 4, + Format: wgpu.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }); err != nil { + return nil, err + } + if width, height := surface.ActualExtent(); width != 4 || height != 4 { + return nil, fmt.Errorf("configured extent = %dx%d, want 4x4", width, height) + } + + texture, _, err := surface.GetCurrentTexture() + if err != nil { + return nil, err + } + presented := false + defer func() { + if !presented { + surface.DiscardTexture() + } + }() + + view, err := texture.CreateView(nil) + if err != nil { + return nil, err + } + defer view.Release() + + encoder, err := device.CreateCommandEncoder(nil) + if err != nil { + return nil, err + } + pass, err := encoder.BeginRenderPass(&wgpu.RenderPassDescriptor{ + ColorAttachments: []wgpu.RenderPassColorAttachment{{ + View: view, + LoadOp: gputypes.LoadOpClear, + StoreOp: gputypes.StoreOpStore, + ClearValue: wgpu.Color{R: 1, A: 1}, + }}, + }) + if err != nil { + encoder.DiscardEncoding() + return nil, err + } + if err := pass.End(); err != nil { + encoder.DiscardEncoding() + return nil, err + } + + commands, err := encoder.Finish() + if err != nil { + return nil, err + } + if _, err := device.Queue().Submit(commands); err != nil { + commands.Release() + return nil, err + } + if err := surface.Present(texture); err != nil { + return nil, err + } + presented = true + + return surface.ReadPixels() +} + +func main() { + pixels, err := capture() + if err != nil { + panic(err) + } + fmt.Println(len(pixels)) // 64 +} +``` + +`ForceFallbackAdapter` makes the all-backends example select the software +adapter compatible with the headless surface. The Rust and browser builds +expose the same Go method set, but reject this target with +`ErrUnsupportedSurfaceTarget`; ordinary GPU backends also reject `ReadPixels` +because surface readback is not part of WebGPU. + ## Safe provider path A provider converts an application-owned window object into a raw target: diff --git a/hal/resource.go b/hal/resource.go index bb9256a8..3f9181cf 100644 --- a/hal/resource.go +++ b/hal/resource.go @@ -173,6 +173,18 @@ type PixelWriter interface { WritePixels(data []byte, width, height uint32) error } +// PixelReader is an optional Surface capability for capturing the current +// framebuffer without exposing backend-owned memory. +// +// ReadPixels returns a caller-owned, tightly packed RGBA8 snapshot in top-left, +// row-major order. The returned slice remains valid after later rendering or +// surface destruction. +// +// Extension: not part of WebGPU specification. +type PixelReader interface { + ReadPixels() []byte +} + // SurfaceTexture is a texture acquired from a surface. // Surface textures have special lifetime constraints - they must be presented // or discarded before the next frame. diff --git a/hal/software/readpixels_test.go b/hal/software/readpixels_test.go new file mode 100644 index 00000000..61e0ad90 --- /dev/null +++ b/hal/software/readpixels_test.go @@ -0,0 +1,97 @@ +//go:build !(js && wasm) + +package software + +import ( + "bytes" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +func configuredReadPixelsSurface(t *testing.T, format gputypes.TextureFormat) *Surface { + t.Helper() + + surface := &Surface{targetKind: hal.SurfaceTargetHeadless} + if err := surface.Configure(nil, &hal.SurfaceConfiguration{ + Width: 2, + Height: 1, + Format: format, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + t.Cleanup(func() { surface.Unconfigure(nil) }) + return surface +} + +func TestSurfaceReadPixelsUnconfigured(t *testing.T) { + surface := &Surface{targetKind: hal.SurfaceTargetHeadless} + if pixels := surface.ReadPixels(); pixels != nil { + t.Fatalf("ReadPixels = %v, want nil before Configure", pixels) + } +} + +func TestSurfaceReadPixelsFormatsAndOwnership(t *testing.T) { + want := []byte{ + 0x11, 0x22, 0x33, 0x44, + 0xaa, 0xbb, 0xcc, 0xdd, + } + tests := []struct { + name string + format gputypes.TextureFormat + bgra bool + }{ + {name: "RGBA8Unorm", format: gputypes.TextureFormatRGBA8Unorm}, + {name: "RGBA8UnormSrgb", format: gputypes.TextureFormatRGBA8UnormSrgb}, + {name: "BGRA8Unorm", format: gputypes.TextureFormatBGRA8Unorm, bgra: true}, + {name: "BGRA8UnormSrgb", format: gputypes.TextureFormatBGRA8UnormSrgb, bgra: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + surface := configuredReadPixelsSurface(t, test.format) + if err := surface.WritePixels(want, 2, 1); err != nil { + t.Fatalf("WritePixels: %v", err) + } + + if test.bgra { + wantStored := []byte{ + 0x33, 0x22, 0x11, 0x44, + 0xcc, 0xbb, 0xaa, 0xdd, + } + if !bytes.Equal(surface.framebuffer, wantStored) { + t.Fatalf("stored framebuffer = %v, want BGRA %v", surface.framebuffer, wantStored) + } + } + + first := surface.ReadPixels() + if !bytes.Equal(first, want) { + t.Fatalf("ReadPixels = %v, want RGBA %v", first, want) + } + if len(first) != 2*1*4 { + t.Fatalf("ReadPixels length = %d, want 8", len(first)) + } + + first[0] ^= 0xff + second := surface.ReadPixels() + if !bytes.Equal(second, want) { + t.Fatalf("second ReadPixels = %v after caller mutation, want %v", second, want) + } + }) + } +} + +func TestSurfaceGetFramebufferCompatibility(t *testing.T) { + surface := configuredReadPixelsSurface(t, gputypes.TextureFormatBGRA8Unorm) + want := []byte{0xf1, 0x82, 0x13, 0xff, 0x27, 0x38, 0x49, 0x5a} + if err := surface.WritePixels(want, 2, 1); err != nil { + t.Fatalf("WritePixels: %v", err) + } + if got := surface.GetFramebuffer(); !bytes.Equal(got, want) { + t.Fatalf("GetFramebuffer = %v, want %v", got, want) + } +} diff --git a/hal/software/resource.go b/hal/software/resource.go index b8503aa7..119454e6 100644 --- a/hal/software/resource.go +++ b/hal/software/resource.go @@ -176,6 +176,8 @@ type Surface struct { platformBlit // platform-specific blit resources (Windows: DIB section, Linux: X11 GC) } +var _ hal.PixelReader = (*Surface)(nil) + // Configure configures the surface with the given settings. // // Returns hal.ErrZeroArea if width or height is zero. @@ -347,11 +349,11 @@ func (s *Surface) ActualExtent() (width, height uint32) { return s.width, s.height } -// GetFramebuffer returns a copy of the current framebuffer data in RGBA byte +// ReadPixels returns a copy of the current framebuffer data in RGBA byte // order (thread-safe). If the surface format is BGRA, R and B channels are -// swapped so callers always receive consistent RGBA data. This allows -// platform blit code to do a single RGBA→BGRA conversion for GDI/X11. -func (s *Surface) GetFramebuffer() []byte { +// swapped so callers always receive consistent RGBA data. The returned slice +// is caller-owned and remains valid after later rendering or surface release. +func (s *Surface) ReadPixels() []byte { s.mu.RLock() defer s.mu.RUnlock() @@ -374,6 +376,13 @@ func (s *Surface) GetFramebuffer() []byte { return result } +// GetFramebuffer returns an owned RGBA snapshot for compatibility with +// existing software HAL callers. New root-wgpu callers should use +// Surface.ReadPixels. +func (s *Surface) GetFramebuffer() []byte { + return s.ReadPixels() +} + // SurfaceTexture implements hal.SurfaceTexture. // It shares the framebuffer with the surface. type SurfaceTexture struct { diff --git a/headless_surface_native_test.go b/headless_surface_native_test.go new file mode 100644 index 00000000..297e6e7f --- /dev/null +++ b/headless_surface_native_test.go @@ -0,0 +1,365 @@ +//go:build !rust && !(js && wasm) && !android + +package wgpu + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/noop" + _ "github.com/gogpu/wgpu/hal/software" +) + +const headlessTriangleWGSL = ` +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + if (index == 0u) { return vec4( 0.0, 0.8, 0.0, 1.0); } + if (index == 1u) { return vec4(-0.8, -0.8, 0.0, 1.0); } + return vec4(0.8, -0.8, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(0.0, 1.0, 0.0, 1.0); +} +` + +type headlessSoftwareFixture struct { + instance *Instance + adapter *Adapter + device *Device + surface *Surface + width uint32 + height uint32 + format TextureFormat +} + +type emptyPixelReaderSurface struct { + noop.Surface +} + +func (*emptyPixelReaderSurface) ReadPixels() []byte { return nil } + +func newHeadlessSoftwareFixture(t *testing.T, width, height uint32, format TextureFormat, configure bool) *headlessSoftwareFixture { + t.Helper() + + instance, err := CreateInstance(nil) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + surface, err := instance.CreateSurfaceFromTarget(HeadlessSurfaceTarget{}) + if err != nil { + instance.Release() + t.Fatalf("CreateSurfaceFromTarget: %v", err) + } + + adapter, err := instance.RequestAdapter(&RequestAdapterOptions{ + CompatibleSurface: surface, + ForceFallbackAdapter: true, + }) + if err != nil { + surface.Release() + instance.Release() + t.Fatalf("RequestAdapter: %v", err) + } + if got := adapter.Info().DeviceType; got != gputypes.DeviceTypeCPU { + adapter.Release() + surface.Release() + instance.Release() + t.Fatalf("adapter device type = %v, want CPU software adapter", got) + } + + device, err := adapter.RequestDevice(nil) + if err != nil { + adapter.Release() + surface.Release() + instance.Release() + t.Fatalf("RequestDevice: %v", err) + } + + fixture := &headlessSoftwareFixture{ + instance: instance, + adapter: adapter, + device: device, + surface: surface, + width: width, + height: height, + format: format, + } + t.Cleanup(func() { + device.Release() + surface.Release() + adapter.Release() + instance.Release() + }) + + if configure { + fixture.configure(t) + } + return fixture +} + +func (f *headlessSoftwareFixture) configure(t *testing.T) { + t.Helper() + if err := f.surface.Configure(f.device, &SurfaceConfiguration{ + Width: f.width, + Height: f.height, + Format: f.format, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }); err != nil { + t.Fatalf("Configure: %v", err) + } +} + +func (f *headlessSoftwareFixture) beginFrame(t *testing.T, clearColor Color) (*SurfaceTexture, *TextureView, *CommandEncoder, *RenderPassEncoder) { + t.Helper() + + texture, suboptimal, err := f.surface.GetCurrentTexture() + if err != nil { + t.Fatalf("GetCurrentTexture: %v", err) + } + if suboptimal { + t.Fatal("headless software surface unexpectedly reported suboptimal") + } + view, err := texture.CreateView(nil) + if err != nil { + f.surface.DiscardTexture() + t.Fatalf("CreateView: %v", err) + } + encoder, err := f.device.CreateCommandEncoder(&CommandEncoderDescriptor{Label: "headless-readback"}) + if err != nil { + view.Release() + f.surface.DiscardTexture() + t.Fatalf("CreateCommandEncoder: %v", err) + } + pass, err := encoder.BeginRenderPass(&RenderPassDescriptor{ + Label: "headless-readback", + ColorAttachments: []RenderPassColorAttachment{{ + View: view, + LoadOp: gputypes.LoadOpClear, + StoreOp: gputypes.StoreOpStore, + ClearValue: clearColor, + }}, + }) + if err != nil { + encoder.DiscardEncoding() + view.Release() + f.surface.DiscardTexture() + t.Fatalf("BeginRenderPass: %v", err) + } + return texture, view, encoder, pass +} + +func (f *headlessSoftwareFixture) submitAndPresent(t *testing.T, texture *SurfaceTexture, view *TextureView, encoder *CommandEncoder, pass *RenderPassEncoder) { + t.Helper() + + if err := pass.End(); err != nil { + encoder.DiscardEncoding() + view.Release() + f.surface.DiscardTexture() + t.Fatalf("RenderPass.End: %v", err) + } + commandBuffer, err := encoder.Finish() + if err != nil { + view.Release() + f.surface.DiscardTexture() + t.Fatalf("CommandEncoder.Finish: %v", err) + } + if _, err := f.device.Queue().Submit(commandBuffer); err != nil { + commandBuffer.Release() + view.Release() + f.surface.DiscardTexture() + t.Fatalf("Queue.Submit: %v", err) + } + view.Release() + if err := f.surface.Present(texture); err != nil { + t.Fatalf("Present: %v", err) + } +} + +func newConfiguredWrappedSurface(t *testing.T, raw hal.Surface) *Surface { + t.Helper() + + device, err := NewDeviceFromHAL(&noop.Device{}, &noop.Queue{}, 0, DefaultLimits(), "readback-wrapper") + if err != nil { + t.Fatalf("NewDeviceFromHAL: %v", err) + } + surface := NewSurfaceFromHAL(raw, "readback-wrapper") + // The wrapper owns an already-created raw surface. Set the same internal + // creation fact that Instance-created surfaces carry before Configure. + surface.surfaceCreated = true + t.Cleanup(func() { + surface.Unconfigure() + surface.Release() + device.Release() + }) + if err := surface.Configure(device, &SurfaceConfiguration{ + Width: 2, + Height: 2, + Format: TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + return surface +} + +func TestHeadlessSurfaceClearReadback(t *testing.T) { + const width, height = uint32(7), uint32(5) + wantPixel := []byte{0xff, 0x00, 0x7f, 0xff} + + for _, format := range []TextureFormat{TextureFormatRGBA8Unorm, TextureFormatBGRA8Unorm} { + t.Run(format.String(), func(t *testing.T) { + fixture := newHeadlessSoftwareFixture(t, width, height, format, true) + texture, view, encoder, pass := fixture.beginFrame(t, Color{R: 1, G: 0, B: 0.5, A: 1}) + fixture.submitAndPresent(t, texture, view, encoder, pass) + + pixels, err := fixture.surface.ReadPixels() + if err != nil { + t.Fatalf("ReadPixels: %v", err) + } + if want := int(width * height * 4); len(pixels) != want { + t.Fatalf("ReadPixels length = %d, want %d", len(pixels), want) + } + for offset := 0; offset < len(pixels); offset += 4 { + if !bytes.Equal(pixels[offset:offset+4], wantPixel) { + t.Fatalf("pixel %d = %v, want RGBA %v", offset/4, pixels[offset:offset+4], wantPixel) + } + } + + pixels[0] = 0 + second, err := fixture.surface.ReadPixels() + if err != nil { + t.Fatalf("second ReadPixels: %v", err) + } + if !bytes.Equal(second[:4], wantPixel) { + t.Fatalf("second snapshot begins %v after caller mutation, want %v", second[:4], wantPixel) + } + }) + } +} + +func TestHeadlessSurfaceTriangleReadback(t *testing.T) { + const width, height = uint32(32), uint32(32) + fixture := newHeadlessSoftwareFixture(t, width, height, TextureFormatRGBA8Unorm, true) + + shader, err := fixture.device.CreateShaderModule(&ShaderModuleDescriptor{ + Label: "headless-triangle", + WGSL: headlessTriangleWGSL, + }) + if err != nil { + t.Fatalf("CreateShaderModule: %v", err) + } + defer shader.Release() + + pipeline, err := fixture.device.CreateRenderPipeline(&RenderPipelineDescriptor{ + Label: "headless-triangle", + Vertex: VertexState{Module: shader, EntryPoint: "vs_main"}, + Fragment: &FragmentState{ + Module: shader, + EntryPoint: "fs_main", + Targets: []ColorTargetState{{ + Format: TextureFormatRGBA8Unorm, + WriteMask: gputypes.ColorWriteMaskAll, + }}, + }, + Primitive: gputypes.PrimitiveState{Topology: gputypes.PrimitiveTopologyTriangleList, CullMode: gputypes.CullModeNone}, + Multisample: gputypes.MultisampleState{Count: 1, Mask: 0xffffffff}, + }) + if err != nil { + t.Fatalf("CreateRenderPipeline: %v", err) + } + defer pipeline.Release() + + texture, view, encoder, pass := fixture.beginFrame(t, Color{R: 0, G: 0, B: 1, A: 1}) + pass.SetPipeline(pipeline) + pass.Draw(3, 1, 0, 0) + fixture.submitAndPresent(t, texture, view, encoder, pass) + + pixels, err := fixture.surface.ReadPixels() + if err != nil { + t.Fatalf("ReadPixels: %v", err) + } + if want := int(width * height * 4); len(pixels) != want { + t.Fatalf("ReadPixels length = %d, want %d", len(pixels), want) + } + + assertPixel := func(x, y uint32, want []byte) { + t.Helper() + offset := int((y*width + x) * 4) + if got := pixels[offset : offset+4]; !bytes.Equal(got, want) { + t.Fatalf("pixel (%d,%d) = %v, want %v", x, y, got, want) + } + } + assertPixel(width/2, height/2, []byte{0, 0xff, 0, 0xff}) + assertPixel(0, 0, []byte{0, 0, 0xff, 0xff}) + assertPixel(width-1, 0, []byte{0, 0, 0xff, 0xff}) + assertPixel(0, height-1, []byte{0, 0, 0xff, 0xff}) + assertPixel(width-1, height-1, []byte{0, 0, 0xff, 0xff}) +} + +func TestHeadlessSurfaceReadPixelsStateErrors(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + var surface *Surface + if _, err := surface.ReadPixels(); !errors.Is(err, ErrReleased) { + t.Fatalf("ReadPixels error = %v, want ErrReleased", err) + } + }) + + t.Run("Unconfigured", func(t *testing.T) { + fixture := newHeadlessSoftwareFixture(t, 2, 2, TextureFormatRGBA8Unorm, false) + if _, err := fixture.surface.ReadPixels(); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("ReadPixels error = %v, want not configured", err) + } + }) + + t.Run("Acquired", func(t *testing.T) { + fixture := newHeadlessSoftwareFixture(t, 2, 2, TextureFormatRGBA8Unorm, true) + if _, _, err := fixture.surface.GetCurrentTexture(); err != nil { + t.Fatalf("GetCurrentTexture: %v", err) + } + if _, err := fixture.surface.ReadPixels(); err == nil || !strings.Contains(err.Error(), "still acquired") { + t.Fatalf("ReadPixels error = %v, want acquired-state error", err) + } + fixture.surface.DiscardTexture() + }) + + t.Run("AfterUnconfigure", func(t *testing.T) { + fixture := newHeadlessSoftwareFixture(t, 2, 2, TextureFormatRGBA8Unorm, true) + fixture.surface.Unconfigure() + if _, err := fixture.surface.ReadPixels(); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("ReadPixels error = %v, want not configured", err) + } + }) + + t.Run("AfterRelease", func(t *testing.T) { + fixture := newHeadlessSoftwareFixture(t, 2, 2, TextureFormatRGBA8Unorm, true) + fixture.surface.Release() + if _, err := fixture.surface.ReadPixels(); !errors.Is(err, ErrReleased) { + t.Fatalf("ReadPixels error = %v, want ErrReleased", err) + } + }) + + t.Run("UnsupportedBackend", func(t *testing.T) { + surface := newConfiguredWrappedSurface(t, &noop.Surface{}) + if _, err := surface.ReadPixels(); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("ReadPixels error = %v, want unsupported backend", err) + } + }) + + t.Run("EmptySnapshot", func(t *testing.T) { + surface := newConfiguredWrappedSurface(t, &emptyPixelReaderSurface{}) + if _, err := surface.ReadPixels(); err == nil || !strings.Contains(err.Error(), "no pixel data") { + t.Fatalf("ReadPixels error = %v, want empty-snapshot failure", err) + } + }) +} diff --git a/surface_browser.go b/surface_browser.go index 16ba86f0..320d560b 100644 --- a/surface_browser.go +++ b/surface_browser.go @@ -226,6 +226,18 @@ func (s *Surface) PresentWithDamage(st *SurfaceTexture, _ []image.Rectangle) err return s.Present(st) } +// ReadPixels is not supported by browser WebGPU surfaces. +// Headless surface readback is a Pure-Go software-backend extension. +func (s *Surface) ReadPixels() ([]byte, error) { + if s == nil || s.released { + return nil, ErrReleased + } + if s.device == nil { + return nil, fmt.Errorf("wgpu: surface not configured") + } + return nil, fmt.Errorf("wgpu: ReadPixels not supported on this backend") +} + // ActualExtent returns the configured surface dimensions. // On browser, the canvas dimensions are always used as-is (no driver clamping). // Returns (0, 0) if the surface is not configured. diff --git a/surface_native.go b/surface_native.go index 8d3a8659..8c877a45 100644 --- a/surface_native.go +++ b/surface_native.go @@ -202,6 +202,8 @@ func surfaceTargetFromLegacyHandlesForPlatform(goos, waylandDisplay string, disp func (t SurfaceTargetUnsafe) halTarget() (hal.SurfaceTarget, error) { var kind hal.SurfaceTargetKind switch t.kind { + case surfaceTargetHeadless: + kind = hal.SurfaceTargetHeadless case surfaceTargetWindowsHWND: kind = hal.SurfaceTargetWindowsHWND case surfaceTargetXlibWindow: @@ -420,6 +422,35 @@ func (s *Surface) WritePixels(data []byte, width, height uint32) error { return fmt.Errorf("wgpu: WritePixels not supported on this backend") } +// ReadPixels captures the current surface framebuffer as an owned, tightly +// packed RGBA8 snapshot in top-left, row-major order. +// +// This is a non-WebGPU extension implemented by the Pure-Go software backend. +// The surface must be configured, and any acquired texture must be presented +// or discarded before capture. +func (s *Surface) ReadPixels() ([]byte, error) { + if s == nil || s.released || s.core == nil { + return nil, ErrReleased + } + + switch s.core.State() { + case core.SurfaceStateUnconfigured: + return nil, fmt.Errorf("wgpu: surface not configured") + case core.SurfaceStateAcquired: + return nil, fmt.Errorf("wgpu: surface texture is still acquired; present or discard it before ReadPixels") + } + + reader, ok := s.core.RawSurface().(hal.PixelReader) + if !ok { + return nil, fmt.Errorf("wgpu: ReadPixels not supported on this backend") + } + pixels := reader.ReadPixels() + if pixels == nil { + return nil, fmt.Errorf("wgpu: ReadPixels returned no pixel data") + } + return pixels, nil +} + // ActualExtent returns the actual swapchain dimensions after driver clamping. // // On Vulkan, the driver may clamp the requested extent to its supported range diff --git a/surface_rust.go b/surface_rust.go index 38ffaece..c468ac21 100644 --- a/surface_rust.go +++ b/surface_rust.go @@ -162,6 +162,18 @@ func (s *Surface) PresentWithDamage(st *SurfaceTexture, _ []image.Rectangle) err return s.Present(st) } +// ReadPixels is not supported by the Rust FFI backend. +// Headless surface readback is a Pure-Go software-backend extension. +func (s *Surface) ReadPixels() ([]byte, error) { + if s == nil || s.released { + return nil, ErrReleased + } + if s.device == nil { + return nil, fmt.Errorf("wgpu: surface not configured") + } + return nil, fmt.Errorf("wgpu: ReadPixels not supported on this backend") +} + // ActualExtent returns the configured surface dimensions. func (s *Surface) ActualExtent() (width, height uint32) { if s.released { diff --git a/surface_target.go b/surface_target.go index e5407ae3..daa19f9b 100644 --- a/surface_target.go +++ b/surface_target.go @@ -30,6 +30,7 @@ type surfaceTargetKind uint8 const ( surfaceTargetInvalid surfaceTargetKind = iota + surfaceTargetHeadless surfaceTargetWindowsHWND surfaceTargetXlibWindow surfaceTargetWaylandSurface @@ -50,6 +51,19 @@ type SurfaceTargetUnsafe struct { windowHandle uintptr } +// HeadlessSurfaceTarget requests a surface without a native window. +// +// The Pure-Go software backend implements this Go-specific extension. It owns +// no native handles, so the zero value is ready for use with +// Instance.CreateSurfaceFromTarget. Rust and browser implementations reject +// this target with ErrUnsupportedSurfaceTarget. +type HeadlessSurfaceTarget struct{} + +// SurfaceTarget returns the raw target used by the Pure-Go software backend. +func (HeadlessSurfaceTarget) SurfaceTarget() (SurfaceTargetUnsafe, error) { + return SurfaceTargetUnsafe{kind: surfaceTargetHeadless}, nil +} + // SurfaceTargetFromWindowsHWND returns a raw Win32 surface target. // hinstance may be zero when the backend can resolve the current module. func SurfaceTargetFromWindowsHWND(hinstance, hwnd uintptr) SurfaceTargetUnsafe { @@ -107,6 +121,8 @@ func SurfaceTargetFromWebCanvasID(id uintptr) SurfaceTargetUnsafe { func (t SurfaceTargetUnsafe) validate() error { switch t.kind { + case surfaceTargetHeadless: + // A headless target carries no platform handles. case surfaceTargetWindowsHWND: if t.windowHandle == 0 { return invalidSurfaceTarget("Win32 HWND is zero") diff --git a/surface_target_contract_test.go b/surface_target_contract_test.go index ef3cf600..de9d2e1b 100644 --- a/surface_target_contract_test.go +++ b/surface_target_contract_test.go @@ -1,6 +1,26 @@ package wgpu +import "testing" + var ( _ func(*Instance, SurfaceTarget) (*Surface, error) = (*Instance).CreateSurfaceFromTarget _ func(*Instance, SurfaceTargetUnsafe) (*Surface, error) = (*Instance).CreateSurfaceUnsafe + _ SurfaceTarget = HeadlessSurfaceTarget{} + _ func(*Surface) ([]byte, error) = (*Surface).ReadPixels ) + +func TestHeadlessSurfaceTargetContract(t *testing.T) { + target, err := (HeadlessSurfaceTarget{}).SurfaceTarget() + if err != nil { + t.Fatalf("SurfaceTarget: %v", err) + } + if err := target.validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if target.kind != surfaceTargetHeadless { + t.Fatalf("target kind = %v, want headless", target.kind) + } + if target.displayHandle != 0 || target.windowHandle != 0 { + t.Fatalf("headless target carries handles: %+v", target) + } +} diff --git a/surface_target_hal_test.go b/surface_target_hal_test.go index 7b04a81b..0adeebaf 100644 --- a/surface_target_hal_test.go +++ b/surface_target_hal_test.go @@ -23,6 +23,11 @@ func TestSurfaceTargetUnsafeMapsToTypedHALTarget(t *testing.T) { target SurfaceTargetUnsafe want hal.SurfaceTarget }{ + { + name: "Headless", + target: SurfaceTargetUnsafe{kind: surfaceTargetHeadless}, + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}, + }, { name: "Win32", target: SurfaceTargetFromWindowsHWND(1, 2), @@ -77,6 +82,7 @@ func TestWebSurfaceTargetIsUnsupportedByNativeHAL(t *testing.T) { func TestSurfaceTargetUnsafeValidationAcceptsValidTargets(t *testing.T) { targets := []SurfaceTargetUnsafe{ + {kind: surfaceTargetHeadless}, SurfaceTargetFromWindowsHWND(0, 1), SurfaceTargetFromXlibWindow(1, 2), SurfaceTargetFromWaylandSurface(3, 4),