Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions docs/SURFACE-TARGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SurfaceTarget>` 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 |
Expand All @@ -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 |

Expand All @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions hal/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 97 additions & 0 deletions hal/software/readpixels_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 13 additions & 4 deletions hal/software/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand All @@ -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 {
Expand Down
Loading
Loading