From 776c6d6d42f602d806a8fc6f56dcce397116fa77 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 22:15:55 +0300 Subject: [PATCH 01/11] feat(vulkan): add Android arm64 WSI parity --- hal/allbackends/register.go | 2 +- hal/allbackends/register_android.go | 12 ++ hal/api.go | 1 + hal/resource.go | 11 +- hal/vulkan/adapter.go | 2 +- hal/vulkan/android_surface_policy.go | 60 ++++++++++ hal/vulkan/android_surface_policy_test.go | 127 ++++++++++++++++++++++ hal/vulkan/api.go | 13 ++- hal/vulkan/api_android.go | 49 +++++++++ hal/vulkan/api_linux.go | 2 +- hal/vulkan/init_android.go | 12 ++ hal/vulkan/platform_android.go | 63 +++++++++++ hal/vulkan/platform_state_default.go | 16 +++ hal/vulkan/swapchain.go | 115 +++++++++++++------- hal/vulkan/swapchain_platform.go | 101 +++++++++++++++++ hal/vulkan/swapchain_platform_test.go | 108 ++++++++++++++++++ hal/vulkan/swapchain_rust_parity_test.go | 119 ++++++++++++++++++++ hal/vulkan/vk/android_support_test.go | 45 ++++++++ hal/vulkan/vk/commands.go | 16 +++ hal/vulkan/vk/loader.go | 8 +- 20 files changed, 829 insertions(+), 53 deletions(-) create mode 100644 hal/allbackends/register_android.go create mode 100644 hal/vulkan/android_surface_policy.go create mode 100644 hal/vulkan/android_surface_policy_test.go create mode 100644 hal/vulkan/api_android.go create mode 100644 hal/vulkan/init_android.go create mode 100644 hal/vulkan/platform_android.go create mode 100644 hal/vulkan/platform_state_default.go create mode 100644 hal/vulkan/swapchain_platform.go create mode 100644 hal/vulkan/swapchain_platform_test.go create mode 100644 hal/vulkan/swapchain_rust_parity_test.go create mode 100644 hal/vulkan/vk/android_support_test.go diff --git a/hal/allbackends/register.go b/hal/allbackends/register.go index 51880a99..3c0253ba 100644 --- a/hal/allbackends/register.go +++ b/hal/allbackends/register.go @@ -1,4 +1,4 @@ -//go:build !(js && wasm) +//go:build !android && !(js && wasm) // Copyright 2025 The GoGPU Authors // SPDX-License-Identifier: MIT diff --git a/hal/allbackends/register_android.go b/hal/allbackends/register_android.go new file mode 100644 index 00000000..1961bfb2 --- /dev/null +++ b/hal/allbackends/register_android.go @@ -0,0 +1,12 @@ +//go:build android && arm64 + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package allbackends + +import ( + // Android intentionally registers Vulkan only. GLES and software fallback + // are outside the Android/arm64 preview contract. + _ "github.com/gogpu/wgpu/hal/vulkan" +) diff --git a/hal/api.go b/hal/api.go index 2f496633..c24cd153 100644 --- a/hal/api.go +++ b/hal/api.go @@ -51,6 +51,7 @@ type Instance interface { // CreateSurface creates a rendering surface from platform handles. // displayHandle is platform-specific (HDC on Windows, NSWindow* on macOS, etc.). // windowHandle is the window handle (HWND on Windows, NSView* on macOS, etc.). + // On Android, displayHandle is ignored and windowHandle is ANativeWindow*. CreateSurface(displayHandle, windowHandle uintptr) (Surface, error) // EnumerateAdapters enumerates available physical GPUs. diff --git a/hal/resource.go b/hal/resource.go index fecb2e7c..bb9256a8 100644 --- a/hal/resource.go +++ b/hal/resource.go @@ -140,13 +140,12 @@ type Surface interface { // Use this if rendering failed or was canceled. DiscardTexture(texture SurfaceTexture) - // ActualExtent returns the actual swapchain dimensions after driver clamping. + // ActualExtent returns the dimensions selected for the swapchain. // - // On Vulkan, the driver may clamp the requested extent to its supported - // range (e.g., on X11 HiDPI where the compositor reports a different - // physical size). The returned values reflect what the swapchain was - // actually created with, which may differ from the requested - // SurfaceConfiguration.Width/Height. + // On Vulkan, a defined CurrentExtent is compositor-owned and used verbatim + // (common on Android). When CurrentExtent is undefined, the requested extent + // is clamped to the driver's supported range. The result may therefore differ + // from SurfaceConfiguration.Width/Height. // // On non-Vulkan backends (DX12, Metal, GLES, Software), the returned // values match the configured dimensions since those backends do not diff --git a/hal/vulkan/adapter.go b/hal/vulkan/adapter.go index 61d70b51..acf90a1d 100644 --- a/hal/vulkan/adapter.go +++ b/hal/vulkan/adapter.go @@ -492,7 +492,7 @@ func makeSurfaceSnapshot(capabilities vk.SurfaceCapabilitiesKHR, formats []vk.Su AlphaModes: alphaModes, } for _, format := range formats { - if textureFormat := vkFormatToTextureFormat(format.Format); textureFormat != gputypes.TextureFormatUndefined { + if textureFormat := textureFormatForSurfacePair(format); textureFormat != gputypes.TextureFormatUndefined { public.Formats = append(public.Formats, textureFormat) } } diff --git a/hal/vulkan/android_surface_policy.go b/hal/vulkan/android_surface_policy.go new file mode 100644 index 00000000..9641bfc4 --- /dev/null +++ b/hal/vulkan/android_surface_policy.go @@ -0,0 +1,60 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "fmt" + "unsafe" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +func validateAndroidSurfaceSupport(hasWSIQueries, hasCreateCommand bool) error { + if !hasWSIQueries || !hasCreateCommand { + return fmt.Errorf("vulkan: Android surface WSI is unavailable") + } + return nil +} + +func validateAndroidSurfaceRequest(window uintptr) error { + if window == 0 { + return fmt.Errorf("vulkan: Android ANativeWindow must be non-null") + } + return nil +} + +func validateAndroidSDKVersion(sdk uint32) error { + if sdk < 29 { + return fmt.Errorf("vulkan: Android API 29 or newer is required, device reports %d", sdk) + } + return nil +} + +func validateAndroidInstanceFlags(flags gputypes.InstanceFlags) error { + if flags&gputypes.InstanceFlagsDebug != 0 { + return fmt.Errorf("vulkan: debug callbacks are unsupported on Android") + } + return nil +} + +func setAndroidSurfaceNativeWindow(createInfo *vk.AndroidSurfaceCreateInfoKHR, window uintptr) { + // Window is generated as *vk.ANativeWindow but contains a raw C pointer. + // Store its integer bits in-place without manufacturing a Go pointer. + *(*uintptr)(unsafe.Pointer(&createInfo.Window)) = window +} + +func mapAndroidSurfaceCreateError(result vk.Result) error { + switch result { + case vk.ErrorSurfaceLostKhr, vk.ErrorInitializationFailed: + return fmt.Errorf("vulkan: vkCreateAndroidSurfaceKHR failed: %w", hal.ErrSurfaceLost) + case vk.ErrorNativeWindowInUseKhr: + return fmt.Errorf("vulkan: vkCreateAndroidSurfaceKHR failed: native window is already in use") + default: + return mapVulkanResult("vkCreateAndroidSurfaceKHR", result) + } +} diff --git a/hal/vulkan/android_surface_policy_test.go b/hal/vulkan/android_surface_policy_test.go new file mode 100644 index 00000000..05b09809 --- /dev/null +++ b/hal/vulkan/android_surface_policy_test.go @@ -0,0 +1,127 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "errors" + "testing" + "unsafe" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +func TestAndroidSurfaceRequestRejectsNullNativeWindow(t *testing.T) { + if err := validateAndroidSurfaceRequest(0); err == nil { + t.Fatal("null ANativeWindow was accepted") + } +} + +func TestAndroidSurfaceRequestIsWindowOnlyAndStateless(t *testing.T) { + // The helper deliberately has no display/generation parameter or retained + // state: two live windows are independently valid requests. + for _, window := range []uintptr{0x12345678, 0x23456789} { + if err := validateAndroidSurfaceRequest(window); err != nil { + t.Fatalf("ANativeWindow %#x rejected: %v", window, err) + } + } +} + +func TestAndroidSurfaceCreateInfoPreservesIndependentRawWindows(t *testing.T) { + firstWindow := uintptr(0x87654321) | uintptr(0x10)<<32 + secondWindow := uintptr(0x23456789) + + first := vk.AndroidSurfaceCreateInfoKHR{SType: vk.StructureTypeAndroidSurfaceCreateInfoKhr} + second := vk.AndroidSurfaceCreateInfoKHR{SType: vk.StructureTypeAndroidSurfaceCreateInfoKhr} + setAndroidSurfaceNativeWindow(&first, firstWindow) + setAndroidSurfaceNativeWindow(&second, secondWindow) + + if got := *(*uintptr)(unsafe.Pointer(&first.Window)); got != firstWindow { + t.Fatalf("first raw ANativeWindow = %#x, want %#x", got, firstWindow) + } + if got := *(*uintptr)(unsafe.Pointer(&second.Window)); got != secondWindow { + t.Fatalf("second raw ANativeWindow = %#x, want %#x", got, secondWindow) + } +} + +func TestAndroidSurfaceCreateInfoMatchesNDKArm64ABI(t *testing.T) { + if unsafe.Sizeof(uintptr(0)) != 8 { + t.Skip("Android preview supports arm64 only") + } + + var info vk.AndroidSurfaceCreateInfoKHR + if got := unsafe.Sizeof(info); got != 32 { + t.Fatalf("VkAndroidSurfaceCreateInfoKHR size = %d, want 32", got) + } + if got := unsafe.Offsetof(info.SType); got != 0 { + t.Fatalf("sType offset = %d, want 0", got) + } + if got := unsafe.Offsetof(info.PNext); got != 8 { + t.Fatalf("pNext offset = %d, want 8", got) + } + if got := unsafe.Offsetof(info.Flags); got != 16 { + t.Fatalf("flags offset = %d, want 16", got) + } + if got := unsafe.Offsetof(info.Window); got != 24 { + t.Fatalf("window offset = %d, want 24", got) + } +} + +func TestAndroidSurfaceSupportRequiresExtensionAndCommands(t *testing.T) { + if err := validateAndroidSurfaceSupport(true, true); err != nil { + t.Fatalf("complete Android WSI support rejected: %v", err) + } + for _, support := range [][2]bool{ + {false, true}, + {true, false}, + } { + if err := validateAndroidSurfaceSupport(support[0], support[1]); err == nil { + t.Fatalf("incomplete Android WSI support accepted: %v", support) + } + } +} + +func TestAndroidSurfaceCreateErrorsRemainRecoverable(t *testing.T) { + for _, test := range []struct { + result vk.Result + want error + }{ + {result: vk.ErrorSurfaceLostKhr, want: hal.ErrSurfaceLost}, + {result: vk.ErrorInitializationFailed, want: hal.ErrSurfaceLost}, + {result: vk.ErrorDeviceLost, want: hal.ErrDeviceLost}, + {result: vk.ErrorOutOfHostMemory, want: hal.ErrDeviceOutOfMemory}, + {result: vk.ErrorOutOfDeviceMemory, want: hal.ErrDeviceOutOfMemory}, + } { + if err := mapAndroidSurfaceCreateError(test.result); !errors.Is(err, test.want) { + t.Fatalf("mapAndroidSurfaceCreateError(%d) = %v, want %v", test.result, err, test.want) + } + } + err := mapAndroidSurfaceCreateError(vk.ErrorNativeWindowInUseKhr) + if err == nil || errors.Is(err, hal.ErrSurfaceLost) { + t.Fatalf("native-window-in-use error = %v, want untyped ownership conflict", err) + } +} + +func TestAndroidSDKPolicyStartsAtAPI29(t *testing.T) { + if err := validateAndroidSDKVersion(28); err == nil { + t.Fatal("Android API 28 was accepted") + } + for _, sdk := range []uint32{29, 30} { + if err := validateAndroidSDKVersion(sdk); err != nil { + t.Fatalf("Android API %d rejected: %v", sdk, err) + } + } +} + +func TestAndroidInstancePolicyRejectsDebugCallbacks(t *testing.T) { + if err := validateAndroidInstanceFlags(gputypes.InstanceFlagsDebug); err == nil { + t.Fatal("Android debug callback request was accepted") + } + if err := validateAndroidInstanceFlags(gputypes.InstanceFlagsNone); err != nil { + t.Fatalf("ordinary Android instance flags rejected: %v", err) + } +} diff --git a/hal/vulkan/api.go b/hal/vulkan/api.go index 8d122ff9..2d1d686b 100644 --- a/hal/vulkan/api.go +++ b/hal/vulkan/api.go @@ -25,6 +25,11 @@ func (Backend) Variant() gputypes.Backend { // CreateInstance creates a new Vulkan instance. func (Backend) CreateInstance(desc *hal.InstanceDescriptor) (hal.Instance, error) { + platform, err := newPlatformInstanceState(desc) + if err != nil { + return nil, err + } + // Initialize Vulkan library if err := vk.Init(); err != nil { return nil, fmt.Errorf("vulkan: failed to initialize: %w", err) @@ -123,6 +128,7 @@ func (Backend) CreateInstance(desc *hal.InstanceDescriptor) (hal.Instance, error handle: instance, cmds: *cmds, debugEnabled: validationEnabled, + platform: platform, } // Create debug messenger when validation layers are active. @@ -145,6 +151,7 @@ type Instance struct { cmds vk.Commands debugMessenger vk.DebugUtilsMessengerEXT debugEnabled bool + platform platformInstanceState } // EnumerateAdapters returns available Vulkan adapters (physical devices). @@ -296,9 +303,9 @@ func (s *Surface) Configure(device hal.Device, config *hal.SurfaceConfiguration) return s.createSwapchain(vkDevice, config) } -// ActualExtent returns the actual swapchain dimensions after driver clamping. -// On Vulkan, the driver may clamp the requested extent to its supported range -// (e.g., on X11 HiDPI). Returns (0, 0) if no swapchain is configured. +// ActualExtent returns the dimensions selected for the swapchain. A defined +// Vulkan CurrentExtent is compositor-owned; otherwise the requested extent is +// clamped to the supported range. Returns (0, 0) if no swapchain is configured. func (s *Surface) ActualExtent() (width, height uint32) { if s.swapchain == nil { return 0, 0 diff --git a/hal/vulkan/api_android.go b/hal/vulkan/api_android.go new file mode 100644 index 00000000..9358705e --- /dev/null +++ b/hal/vulkan/api_android.go @@ -0,0 +1,49 @@ +//go:build android && arm64 + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "fmt" + + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +func platformSurfaceExtension() string { + return "VK_KHR_android_surface\x00" +} + +// CreateSurface creates a Vulkan surface from an ANativeWindow. +// +// The display handle is ignored, matching Rust wgpu v29's Android path. The +// window handle is the raw ANativeWindow pointer and must be non-zero. The host +// retains its application reference; Vulkan owns its surface reference until +// DestroySurfaceKHR. WGPU does not acquire or release ANativeWindow itself. +func (i *Instance) CreateSurface(_, windowHandle uintptr) (hal.Surface, error) { + if err := validateAndroidSurfaceRequest(windowHandle); err != nil { + return nil, err + } + if i == nil || i.handle == 0 { + return nil, fmt.Errorf("vulkan: cannot create Android surface from a destroyed instance") + } + if err := validateAndroidSurfaceSupport(i.cmds.HasWSIQueries(), i.cmds.HasCreateAndroidSurfaceKHR()); err != nil { + return nil, err + } + + createInfo := vk.AndroidSurfaceCreateInfoKHR{SType: vk.StructureTypeAndroidSurfaceCreateInfoKhr} + setAndroidSurfaceNativeWindow(&createInfo, windowHandle) + + var handle vk.SurfaceKHR + result := i.cmds.CreateAndroidSurfaceKHR(i.handle, &createInfo, nil, &handle) + if result != vk.Success { + return nil, mapAndroidSurfaceCreateError(result) + } + if handle == 0 { + return nil, fmt.Errorf("vulkan: vkCreateAndroidSurfaceKHR returned success with a null surface") + } + + return &Surface{handle: handle, instance: i}, nil +} diff --git a/hal/vulkan/api_linux.go b/hal/vulkan/api_linux.go index 1ea5c610..668af6b4 100644 --- a/hal/vulkan/api_linux.go +++ b/hal/vulkan/api_linux.go @@ -1,4 +1,4 @@ -//go:build linux && !(js && wasm) +//go:build linux && !android && !(js && wasm) // Copyright 2025 The GoGPU Authors // SPDX-License-Identifier: MIT diff --git a/hal/vulkan/init_android.go b/hal/vulkan/init_android.go new file mode 100644 index 00000000..c207daba --- /dev/null +++ b/hal/vulkan/init_android.go @@ -0,0 +1,12 @@ +//go:build android && arm64 + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import "github.com/gogpu/wgpu/hal" + +func init() { + hal.RegisterBackend(Backend{}) +} diff --git a/hal/vulkan/platform_android.go b/hal/vulkan/platform_android.go new file mode 100644 index 00000000..baa040b2 --- /dev/null +++ b/hal/vulkan/platform_android.go @@ -0,0 +1,63 @@ +//go:build android && arm64 + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "fmt" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" + "github.com/gogpu/wgpu/hal" +) + +type platformInstanceState struct { + swapchain swapchainPlatformPolicy +} + +func newPlatformInstanceState(desc *hal.InstanceDescriptor) (platformInstanceState, error) { + if desc != nil { + if err := validateAndroidInstanceFlags(desc.Flags); err != nil { + return platformInstanceState{}, err + } + } + + sdk, err := loadAndroidSDKVersion() + if err != nil { + return platformInstanceState{}, fmt.Errorf("vulkan: query Android SDK version: %w", err) + } + if err := validateAndroidSDKVersion(sdk); err != nil { + return platformInstanceState{}, err + } + + return platformInstanceState{swapchain: androidSwapchainPlatformPolicy(sdk)}, nil +} + +func loadAndroidSDKVersion() (uint32, error) { + handle, err := ffi.LoadLibrary("libc.so") + if err != nil { + return 0, err + } + defer func() { _ = ffi.FreeLibrary(handle) }() + + fn, err := ffi.GetSymbol(handle, "android_get_device_api_level") + if err != nil { + return 0, err + } + var call types.CallInterface + if err := ffi.PrepareCallInterface(&call, types.DefaultCall, types.SInt32TypeDescriptor, nil); err != nil { + return 0, err + } + + var sdk int32 + if _, err := ffi.CallFunction(&call, fn, unsafe.Pointer(&sdk), nil); err != nil { + return 0, err + } + if sdk < 0 { + return 0, fmt.Errorf("invalid negative SDK version %d", sdk) + } + return uint32(sdk), nil +} diff --git a/hal/vulkan/platform_state_default.go b/hal/vulkan/platform_state_default.go new file mode 100644 index 00000000..b51a76f8 --- /dev/null +++ b/hal/vulkan/platform_state_default.go @@ -0,0 +1,16 @@ +//go:build !android && !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import "github.com/gogpu/wgpu/hal" + +type platformInstanceState struct { + swapchain swapchainPlatformPolicy +} + +func newPlatformInstanceState(_ *hal.InstanceDescriptor) (platformInstanceState, error) { + return platformInstanceState{swapchain: defaultSwapchainPlatformPolicy()}, nil +} diff --git a/hal/vulkan/swapchain.go b/hal/vulkan/swapchain.go index 3e93c47b..dcae9578 100644 --- a/hal/vulkan/swapchain.go +++ b/hal/vulkan/swapchain.go @@ -124,19 +124,36 @@ type swapchainSurfaceSnapshot struct { presentModes []vk.PresentModeKHR } +// textureFormatForSurfacePair projects the format/color-space pairs that this +// backend can configure. Rust wgpu v29 exposes RGBA16Float only for linear +// scRGB; accepting the same VkFormat with another color space would advertise +// a capability that createSwapchain cannot honor. +func textureFormatForSurfacePair(surfaceFormat vk.SurfaceFormatKHR) gputypes.TextureFormat { + format := vkFormatToTextureFormat(surfaceFormat.Format) + if format == gputypes.TextureFormatRGBA16Float && surfaceFormat.ColorSpace != vk.ColorSpaceExtendedSrgbLinearExt { + return gputypes.TextureFormatUndefined + } + return format +} + func (snapshot swapchainSurfaceSnapshot) formatFor(requested gputypes.TextureFormat) (vk.SurfaceFormatKHR, error) { requestedVk := textureFormatToVk(requested) if requestedVk == vk.FormatUndefined { return vk.SurfaceFormatKHR{}, fmt.Errorf("vulkan: unsupported surface format %v", requested) } + preferredColorSpace := vk.ColorSpaceSrgbNonlinearKhr + if requested == gputypes.TextureFormatRGBA16Float { + // Match Rust wgpu v29's scRGB swapchain pairing. + preferredColorSpace = vk.ColorSpaceExtendedSrgbLinearExt + } var fallback *vk.SurfaceFormatKHR for i := range snapshot.formats { format := snapshot.formats[i] - if format.Format != requestedVk { + if format.Format != requestedVk || textureFormatForSurfacePair(format) != requested { continue } - if format.ColorSpace == vk.ColorSpaceSrgbNonlinearKhr { + if format.ColorSpace == preferredColorSpace { return format, nil } if fallback == nil { @@ -293,14 +310,42 @@ func querySwapchainSurfaceSnapshot(instance *Instance, device vk.PhysicalDevice, } func surfaceQueryError(operation string, result vk.Result) error { - switch result { - case vk.ErrorSurfaceLostKhr: - return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrSurfaceLost) - case vk.ErrorOutOfDateKhr: - return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrSurfaceOutdated) - default: - return fmt.Errorf("vulkan: %s failed: %d", operation, result) + return mapVulkanResult(operation, result) +} + +const undefinedSurfaceExtent = ^uint32(0) + +// selectSwapchainExtent follows Vulkan's two surface extent modes. A defined +// current extent is compositor-owned; UINT32_MAX lets the application choose +// an extent within the advertised range. +func selectSwapchainExtent(capabilities vk.SurfaceCapabilitiesKHR, requestedWidth, requestedHeight uint32) (vk.Extent2D, error) { + if capabilities.MinImageExtent.Width > capabilities.MaxImageExtent.Width || + capabilities.MinImageExtent.Height > capabilities.MaxImageExtent.Height { + return vk.Extent2D{}, fmt.Errorf("vulkan: surface returned invalid image extent range") + } + widthDefined := capabilities.CurrentExtent.Width != undefinedSurfaceExtent + heightDefined := capabilities.CurrentExtent.Height != undefinedSurfaceExtent + if widthDefined != heightDefined { + return vk.Extent2D{}, fmt.Errorf("vulkan: surface returned partially defined current extent") + } + + var extent vk.Extent2D + if widthDefined { + extent = capabilities.CurrentExtent + if extent.Width < capabilities.MinImageExtent.Width || extent.Width > capabilities.MaxImageExtent.Width || + extent.Height < capabilities.MinImageExtent.Height || extent.Height > capabilities.MaxImageExtent.Height { + return vk.Extent2D{}, fmt.Errorf("vulkan: surface current extent is outside its advertised range") + } + } else { + extent = vk.Extent2D{ + Width: clampUint32(requestedWidth, capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width), + Height: clampUint32(requestedHeight, capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height), + } } + if extent.Width == 0 || extent.Height == 0 { + return vk.Extent2D{}, hal.ErrZeroArea + } + return extent, nil } func querySwapchainFormats(instance *Instance, device vk.PhysicalDevice, surface vk.SurfaceKHR) ([]vk.SurfaceFormatKHR, error) { @@ -398,6 +443,11 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati return err } capabilities := snapshot.capabilities + policy := swapchainPolicyForSurface(s) + preTransform, err := policy.preTransform(capabilities) + if err != nil { + return err + } // Determine image count if capabilities.MinImageCount == 0 { @@ -406,10 +456,6 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati if capabilities.MaxImageArrayLayers == 0 { return fmt.Errorf("vulkan: surface does not support one image array layer") } - if capabilities.CurrentExtent.Width != 0xFFFFFFFF && - (capabilities.MinImageExtent.Width > capabilities.MaxImageExtent.Width || capabilities.MinImageExtent.Height > capabilities.MaxImageExtent.Height) { - return fmt.Errorf("vulkan: surface returned invalid image extent range") - } imageCount := capabilities.MinImageCount if imageCount < math.MaxUint32 { imageCount++ @@ -421,12 +467,9 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati return fmt.Errorf("vulkan: surface returned invalid image count range") } - // Use config dimensions as primary source (matching Rust wgpu-hal behavior). - // CurrentExtent from the driver is used only for clamping to the valid range. - // Ref: wgpu-hal/src/vulkan/swapchain/native.rs:189-197 - extent := vk.Extent2D{ - Width: config.Width, - Height: config.Height, + extent, err := selectSwapchainExtent(capabilities, config.Width, config.Height) + if err != nil { + return err } // Log surface capabilities for HiDPI diagnostics (BUG-VK-HIDPI-001). @@ -438,13 +481,6 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati "maxExtent", [2]uint32{capabilities.MaxImageExtent.Width, capabilities.MaxImageExtent.Height}, ) - // Clamp to driver-reported range when CurrentExtent is defined. - // CurrentExtent of 0xFFFFFFFF means the surface size is determined by the swapchain. - if capabilities.CurrentExtent.Width != 0xFFFFFFFF { - extent.Width = clampUint32(extent.Width, capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width) - extent.Height = clampUint32(extent.Height, capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height) - } - // Warn if the driver clamped the extent to different dimensions than // requested. This commonly happens on X11 HiDPI where the compositor // reports physical pixels that differ from the application's logical @@ -459,11 +495,6 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati ) } - // Zero extent means the window is minimized -- skip swapchain creation. - if extent.Width == 0 || extent.Height == 0 { - return hal.ErrZeroArea - } - vkFormat := selectedFormat.Format // Wait for an existing swapchain before passing it as OldSwapchain. This @@ -493,7 +524,7 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati ImageArrayLayers: 1, ImageUsage: imageUsage, ImageSharingMode: vk.SharingModeExclusive, - PreTransform: capabilities.CurrentTransform, + PreTransform: preTransform, CompositeAlpha: compositeAlpha, PresentMode: presentMode, Clipped: vk.True, @@ -503,7 +534,7 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati var swapchainHandle vk.SwapchainKHR result := vkCreateSwapchainKHR(device, &createInfo, nil, &swapchainHandle) if result != vk.Success { - return swapchainCreateError(result) + return mapSwapchainCreateResult(result) } // Get swapchain images @@ -944,7 +975,9 @@ func (sc *Swapchain) acquireNextImage() (*SwapchainTexture, bool, error) { // Timeout for acquire - match wgpu-core's FRAME_TIMEOUT_MS = 1000 // This is the proven timeout that works across drivers. // On timeout, caller should retry once (wgpu pattern). - const timeout = uint64(1_000_000_000) // 1000ms = 1 second + const requestedTimeout = uint64(1_000_000_000) // 1000ms = 1 second + policy := swapchainPolicyForSurface(sc.surface) + timeout := policy.acquireTimeout(requestedTimeout) // Get the acquire semaphore from the rotating pool. acquireIdx := sc.nextAcquireIdx @@ -1004,7 +1037,7 @@ func (sc *Swapchain) acquireNextImage() (*SwapchainTexture, bool, error) { sc.markBroken(hal.ErrDeviceLost) return nil, false, hal.ErrDeviceLost default: - err := fmt.Errorf("vulkan: vkAcquireNextImageKHR failed: %d", result) + err := mapVulkanResult("vkAcquireNextImageKHR", result) sc.markBroken(err) return nil, false, err } @@ -1017,12 +1050,12 @@ func (sc *Swapchain) acquireNextImage() (*SwapchainTexture, bool, error) { if fence != 0 { waitResult := sc.device.cmds.WaitForFences(sc.device.handle, 1, &fence, vk.True, timeout) if waitResult != vk.Success { - sc.markBroken(fmt.Errorf("vulkan: vkWaitForFences after acquire failed: %d", waitResult)) + sc.markBroken(mapVulkanResult("vkWaitForFences after acquire", waitResult)) return nil, false, sc.failureErr } resetResult := sc.device.cmds.ResetFences(sc.device.handle, 1, &fence) if resetResult != vk.Success { - sc.markBroken(fmt.Errorf("vulkan: vkResetFences after acquire failed: %d", resetResult)) + sc.markBroken(mapVulkanResult("vkResetFences after acquire", resetResult)) return nil, false, sc.failureErr } } @@ -1047,7 +1080,7 @@ func (sc *Swapchain) acquireNextImage() (*SwapchainTexture, bool, error) { // can insert a barrier if no render pass transitions to PRESENT_SRC_KHR. sc.imageLayouts[imageIndex] = vk.ImageLayoutUndefined - return sc.surfaceTextures[imageIndex], result == vk.SuboptimalKhr, nil + return sc.surfaceTextures[imageIndex], policy.reportSuboptimal(result == vk.SuboptimalKhr), nil } // present presents the current image to the screen. @@ -1143,7 +1176,9 @@ func (sc *Swapchain) present(queue *Queue, damageRects []image.Rectangle) error case vk.Success: return nil case vk.SuboptimalKhr: - // Suboptimal but presented successfully + if swapchainPolicyForSurface(sc.surface).reportSuboptimal(true) { + hal.Logger().Debug("vulkan: suboptimal swapchain present", "imageIndex", sc.currentImage) + } return nil case vk.ErrorOutOfDateKhr: sc.markBroken(hal.ErrSurfaceOutdated) @@ -1155,7 +1190,7 @@ func (sc *Swapchain) present(queue *Queue, damageRects []image.Rectangle) error sc.markBroken(hal.ErrDeviceLost) return hal.ErrDeviceLost default: - err := fmt.Errorf("vulkan: vkQueuePresentKHR failed: %d", result) + err := mapVulkanResult("vkQueuePresentKHR", result) sc.markBroken(err) return err } diff --git a/hal/vulkan/swapchain_platform.go b/hal/vulkan/swapchain_platform.go new file mode 100644 index 00000000..c967eba0 --- /dev/null +++ b/hal/vulkan/swapchain_platform.go @@ -0,0 +1,101 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "fmt" + + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +// swapchainPlatformPolicy isolates the few WSI choices that differ on +// Android while keeping the Vulkan swapchain mechanism shared. +type swapchainPlatformPolicy struct { + android bool + androidSDKVersion uint32 +} + +func defaultSwapchainPlatformPolicy() swapchainPlatformPolicy { + return swapchainPlatformPolicy{} +} + +func androidSwapchainPlatformPolicy(sdk uint32) swapchainPlatformPolicy { + return swapchainPlatformPolicy{android: true, androidSDKVersion: sdk} +} + +func (p swapchainPlatformPolicy) acquireTimeout(requested uint64) uint64 { + // Android 10's presentation implementation does not support finite image + // acquire timeouts. Android 11 (API 30) added support. + if p.android && p.androidSDKVersion < 30 { + return ^uint64(0) + } + return requested +} + +func (p swapchainPlatformPolicy) preTransform(capabilities vk.SurfaceCapabilitiesKHR) (vk.SurfaceTransformFlagBitsKHR, error) { + transform := capabilities.CurrentTransform + if p.android { + // Rust wgpu v29 leaves pre-rotation to the compositor on Android. + transform = vk.SurfaceTransformIdentityBitKhr + } + if transform == 0 { + return 0, fmt.Errorf("vulkan: surface returned no usable transform") + } + if vk.Flags(capabilities.SupportedTransforms)&vk.Flags(transform) == 0 { + if p.android { + return 0, fmt.Errorf("vulkan: Android surface does not support the required identity transform") + } + return 0, fmt.Errorf("vulkan: surface current transform is not supported") + } + return transform, nil +} + +func (p swapchainPlatformPolicy) reportSuboptimal(suboptimal bool) bool { + // Android reports SUBOPTIMAL when identity pre-transform differs from the + // current orientation. Rust wgpu v29 intentionally treats that as success. + return suboptimal && !p.android +} + +func swapchainPolicyForSurface(surface *Surface) swapchainPlatformPolicy { + if surface == nil || surface.instance == nil { + return defaultSwapchainPlatformPolicy() + } + return surface.instance.platform.swapchain +} + +func mapSwapchainCreateResult(result vk.Result) error { + switch result { + case vk.ErrorSurfaceLostKhr, vk.ErrorInitializationFailed: + return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: %w", hal.ErrSurfaceLost) + case vk.ErrorNativeWindowInUseKhr: + return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: native window is already in use") + default: + return mapVulkanResult("vkCreateSwapchainKHR", result) + } +} + +// mapVulkanResult preserves the typed HAL errors callers use for recovery. +func mapVulkanResult(operation string, result vk.Result) error { + switch result { + case vk.Success: + return nil + case vk.Timeout: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrTimeout) + case vk.NotReady: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrNotReady) + case vk.ErrorOutOfHostMemory, vk.ErrorOutOfDeviceMemory: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrDeviceOutOfMemory) + case vk.ErrorDeviceLost: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrDeviceLost) + case vk.ErrorSurfaceLostKhr: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrSurfaceLost) + case vk.ErrorOutOfDateKhr: + return fmt.Errorf("vulkan: %s failed: %w", operation, hal.ErrSurfaceOutdated) + default: + return fmt.Errorf("vulkan: %s failed: %d", operation, result) + } +} diff --git a/hal/vulkan/swapchain_platform_test.go b/hal/vulkan/swapchain_platform_test.go new file mode 100644 index 00000000..43e3993b --- /dev/null +++ b/hal/vulkan/swapchain_platform_test.go @@ -0,0 +1,108 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "errors" + "math" + "testing" + + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +func TestSwapchainPlatformPolicyAcquireTimeout(t *testing.T) { + const requested = uint64(1_000_000_000) + tests := []struct { + name string + policy swapchainPlatformPolicy + want uint64 + }{ + {name: "desktop", policy: defaultSwapchainPlatformPolicy(), want: requested}, + {name: "Android API 29", policy: androidSwapchainPlatformPolicy(29), want: math.MaxUint64}, + {name: "Android API 30", policy: androidSwapchainPlatformPolicy(30), want: requested}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.policy.acquireTimeout(requested); got != test.want { + t.Fatalf("acquireTimeout(%d) = %d, want %d", requested, got, test.want) + } + }) + } +} + +func TestSwapchainPlatformPolicyTransform(t *testing.T) { + capabilities := vk.SurfaceCapabilitiesKHR{ + CurrentTransform: vk.SurfaceTransformRotate90BitKhr, + SupportedTransforms: vk.SurfaceTransformFlagsKHR( + vk.SurfaceTransformIdentityBitKhr | vk.SurfaceTransformRotate90BitKhr, + ), + } + desktop, err := defaultSwapchainPlatformPolicy().preTransform(capabilities) + if err != nil { + t.Fatalf("desktop preTransform() error: %v", err) + } + if desktop != vk.SurfaceTransformRotate90BitKhr { + t.Fatalf("desktop transform = %v, want current transform", desktop) + } + + android, err := androidSwapchainPlatformPolicy(29).preTransform(capabilities) + if err != nil { + t.Fatalf("Android preTransform() error: %v", err) + } + if android != vk.SurfaceTransformIdentityBitKhr { + t.Fatalf("Android transform = %v, want identity", android) + } + + capabilities.SupportedTransforms = vk.SurfaceTransformFlagsKHR(vk.SurfaceTransformRotate90BitKhr) + if _, err := androidSwapchainPlatformPolicy(29).preTransform(capabilities); err == nil { + t.Fatal("Android identity transform was accepted when unsupported") + } +} + +func TestSwapchainPlatformPolicySuboptimal(t *testing.T) { + if !defaultSwapchainPlatformPolicy().reportSuboptimal(true) { + t.Fatal("desktop suboptimal result was suppressed") + } + if androidSwapchainPlatformPolicy(29).reportSuboptimal(true) { + t.Fatal("Android suboptimal result was reported") + } +} + +func TestMapVulkanResultPreservesRecoverableErrors(t *testing.T) { + tests := []struct { + result vk.Result + want error + }{ + {result: vk.ErrorOutOfHostMemory, want: hal.ErrDeviceOutOfMemory}, + {result: vk.ErrorOutOfDeviceMemory, want: hal.ErrDeviceOutOfMemory}, + {result: vk.ErrorDeviceLost, want: hal.ErrDeviceLost}, + {result: vk.ErrorSurfaceLostKhr, want: hal.ErrSurfaceLost}, + {result: vk.ErrorOutOfDateKhr, want: hal.ErrSurfaceOutdated}, + {result: vk.Timeout, want: hal.ErrTimeout}, + {result: vk.NotReady, want: hal.ErrNotReady}, + } + for _, test := range tests { + if err := mapVulkanResult("operation", test.result); !errors.Is(err, test.want) { + t.Fatalf("mapVulkanResult(%d) = %v, want %v", test.result, err, test.want) + } + } + if err := mapVulkanResult("operation", vk.Success); err != nil { + t.Fatalf("mapVulkanResult(Success) = %v, want nil", err) + } +} + +func TestMapSwapchainCreateResultPreservesSurfaceErrors(t *testing.T) { + for _, result := range []vk.Result{vk.ErrorSurfaceLostKhr, vk.ErrorInitializationFailed} { + if err := mapSwapchainCreateResult(result); !errors.Is(err, hal.ErrSurfaceLost) { + t.Fatalf("mapSwapchainCreateResult(%d) = %v, want ErrSurfaceLost", result, err) + } + } + err := mapSwapchainCreateResult(vk.ErrorNativeWindowInUseKhr) + if err == nil || errors.Is(err, hal.ErrSurfaceLost) { + t.Fatalf("native-window-in-use error = %v, want untyped ownership conflict", err) + } +} diff --git a/hal/vulkan/swapchain_rust_parity_test.go b/hal/vulkan/swapchain_rust_parity_test.go new file mode 100644 index 00000000..f4696316 --- /dev/null +++ b/hal/vulkan/swapchain_rust_parity_test.go @@ -0,0 +1,119 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "errors" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" +) + +func TestFloatSurfaceFormatPrefersExtendedLinearSRGB(t *testing.T) { + snapshot := swapchainSurfaceSnapshot{formats: []vk.SurfaceFormatKHR{ + {Format: vk.FormatR16g16b16a16Sfloat, ColorSpace: vk.ColorSpaceSrgbNonlinearKhr}, + {Format: vk.FormatR16g16b16a16Sfloat, ColorSpace: vk.ColorSpaceExtendedSrgbLinearExt}, + }} + + got, err := snapshot.formatFor(gputypes.TextureFormatRGBA16Float) + if err != nil { + t.Fatalf("formatFor(RGBA16Float) error: %v", err) + } + if got.ColorSpace != vk.ColorSpaceExtendedSrgbLinearExt { + t.Fatalf("color space = %v, want extended linear sRGB", got.ColorSpace) + } + + snapshot.formats = snapshot.formats[:1] + if _, err := snapshot.formatFor(gputypes.TextureFormatRGBA16Float); err == nil { + t.Fatal("RGBA16Float without extended linear sRGB was accepted") + } +} + +func TestSurfaceCapabilitiesFilterInvalidFloatColorSpacePair(t *testing.T) { + snapshot, err := makeSurfaceSnapshot( + vk.SurfaceCapabilitiesKHR{SupportedCompositeAlpha: vk.CompositeAlphaFlagsKHR(vk.CompositeAlphaOpaqueBitKhr)}, + []vk.SurfaceFormatKHR{ + {Format: vk.FormatR16g16b16a16Sfloat, ColorSpace: vk.ColorSpaceSrgbNonlinearKhr}, + {Format: vk.FormatR8g8b8a8Unorm, ColorSpace: vk.ColorSpaceSrgbNonlinearKhr}, + }, + []vk.PresentModeKHR{vk.PresentModeFifoKhr}, + ) + if err != nil { + t.Fatalf("makeSurfaceSnapshot() error: %v", err) + } + if len(snapshot.public.Formats) != 1 || snapshot.public.Formats[0] != gputypes.TextureFormatRGBA8Unorm { + t.Fatalf("public formats = %v, want only RGBA8Unorm", snapshot.public.Formats) + } +} + +func TestSelectSwapchainExtentUsesCompositorExtentVerbatim(t *testing.T) { + capabilities := vk.SurfaceCapabilitiesKHR{ + CurrentExtent: vk.Extent2D{Width: 1080, Height: 1920}, + MinImageExtent: vk.Extent2D{Width: 1, Height: 1}, + MaxImageExtent: vk.Extent2D{Width: 4096, Height: 4096}, + } + + got, err := selectSwapchainExtent(capabilities, 640, 480) + if err != nil { + t.Fatalf("selectSwapchainExtent() error: %v", err) + } + if got != capabilities.CurrentExtent { + t.Fatalf("extent = %+v, want fixed compositor extent %+v", got, capabilities.CurrentExtent) + } +} + +func TestSelectSwapchainExtentClampsApplicationExtent(t *testing.T) { + capabilities := vk.SurfaceCapabilitiesKHR{ + CurrentExtent: vk.Extent2D{Width: undefinedSurfaceExtent, Height: undefinedSurfaceExtent}, + MinImageExtent: vk.Extent2D{Width: 320, Height: 240}, + MaxImageExtent: vk.Extent2D{Width: 1920, Height: 1080}, + } + + got, err := selectSwapchainExtent(capabilities, 2048, 100) + if err != nil { + t.Fatalf("selectSwapchainExtent() error: %v", err) + } + want := vk.Extent2D{Width: 1920, Height: 240} + if got != want { + t.Fatalf("extent = %+v, want %+v", got, want) + } +} + +func TestSelectSwapchainExtentRejectsInvalidDriverState(t *testing.T) { + tests := []vk.SurfaceCapabilitiesKHR{ + { + CurrentExtent: vk.Extent2D{Width: 640, Height: undefinedSurfaceExtent}, + MinImageExtent: vk.Extent2D{Width: 1, Height: 1}, + MaxImageExtent: vk.Extent2D{Width: 1920, Height: 1080}, + }, + { + CurrentExtent: vk.Extent2D{Width: 2048, Height: 1080}, + MinImageExtent: vk.Extent2D{Width: 1, Height: 1}, + MaxImageExtent: vk.Extent2D{Width: 1920, Height: 1080}, + }, + { + CurrentExtent: vk.Extent2D{Width: undefinedSurfaceExtent, Height: undefinedSurfaceExtent}, + MinImageExtent: vk.Extent2D{Width: 800, Height: 600}, + MaxImageExtent: vk.Extent2D{Width: 640, Height: 480}, + }, + } + for index, capabilities := range tests { + if _, err := selectSwapchainExtent(capabilities, 640, 480); err == nil { + t.Fatalf("case %d: invalid driver extent state was accepted", index) + } + } + + zero := vk.SurfaceCapabilitiesKHR{ + CurrentExtent: vk.Extent2D{}, + MinImageExtent: vk.Extent2D{}, + MaxImageExtent: vk.Extent2D{Width: 1, Height: 1}, + } + if _, err := selectSwapchainExtent(zero, 1, 1); !errors.Is(err, hal.ErrZeroArea) { + t.Fatalf("zero fixed extent error = %v, want ErrZeroArea", err) + } +} diff --git a/hal/vulkan/vk/android_support_test.go b/hal/vulkan/vk/android_support_test.go new file mode 100644 index 00000000..71288fbc --- /dev/null +++ b/hal/vulkan/vk/android_support_test.go @@ -0,0 +1,45 @@ +//go:build !(js && wasm) + +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vk + +import ( + "testing" + "unsafe" +) + +func TestVulkanLibraryNameForAndroidDoesNotUseDesktopSoname(t *testing.T) { + if got := vulkanLibraryNameFor("android"); got != "libvulkan.so" { + t.Fatalf("Android Vulkan library = %q, want libvulkan.so", got) + } + if got := vulkanLibraryNameFor("linux"); got != "libvulkan.so.1" { + t.Fatalf("Linux Vulkan library = %q, want libvulkan.so.1", got) + } +} + +func TestAndroidSurfaceCommandSupportIsExplicit(t *testing.T) { + command := unsafe.Pointer(new(byte)) + commands := Commands{ + createAndroidSurfaceKHR: command, + destroySurfaceKHR: command, + getPhysicalDeviceSurfaceSupportKHR: command, + getPhysicalDeviceSurfaceCapabilitiesKHR: command, + getPhysicalDeviceSurfaceFormatsKHR: command, + getPhysicalDeviceSurfacePresentModesKHR: command, + } + if !commands.HasCreateAndroidSurfaceKHR() || !commands.HasWSIQueries() { + t.Fatal("complete Android WSI command set was rejected") + } + + commands.createAndroidSurfaceKHR = nil + if commands.HasCreateAndroidSurfaceKHR() { + t.Fatal("missing vkCreateAndroidSurfaceKHR was accepted") + } + commands.createAndroidSurfaceKHR = command + commands.getPhysicalDeviceSurfaceSupportKHR = nil + if commands.HasWSIQueries() { + t.Fatal("incomplete VK_KHR_surface command set was accepted") + } +} diff --git a/hal/vulkan/vk/commands.go b/hal/vulkan/vk/commands.go index 01b68977..62c23126 100644 --- a/hal/vulkan/vk/commands.go +++ b/hal/vulkan/vk/commands.go @@ -104,6 +104,7 @@ func (c *Commands) LoadInstance(instance Instance) error { c.createXlibSurfaceKHR = GetInstanceProcAddr(instance, "vkCreateXlibSurfaceKHR") c.createXcbSurfaceKHR = GetInstanceProcAddr(instance, "vkCreateXcbSurfaceKHR") c.createWaylandSurfaceKHR = GetInstanceProcAddr(instance, "vkCreateWaylandSurfaceKHR") + c.createAndroidSurfaceKHR = GetInstanceProcAddr(instance, "vkCreateAndroidSurfaceKHR") c.createMetalSurfaceEXT = GetInstanceProcAddr(instance, "vkCreateMetalSurfaceEXT") // Vulkan 1.1+ instance functions @@ -317,6 +318,21 @@ func (c *Commands) HasCreateWaylandSurfaceKHR() bool { return c.createWaylandSurfaceKHR != nil } +// HasCreateAndroidSurfaceKHR returns true if vkCreateAndroidSurfaceKHR is available. +func (c *Commands) HasCreateAndroidSurfaceKHR() bool { + return c.createAndroidSurfaceKHR != nil +} + +// HasWSIQueries reports whether the VK_KHR_surface commands used by the +// backend are available. Platform surface creation is checked separately. +func (c *Commands) HasWSIQueries() bool { + return c.destroySurfaceKHR != nil && + c.getPhysicalDeviceSurfaceSupportKHR != nil && + c.getPhysicalDeviceSurfaceCapabilitiesKHR != nil && + c.getPhysicalDeviceSurfaceFormatsKHR != nil && + c.getPhysicalDeviceSurfacePresentModesKHR != nil +} + // HasCreateMetalSurfaceEXT returns true if vkCreateMetalSurfaceEXT is available. func (c *Commands) HasCreateMetalSurfaceEXT() bool { return c.createMetalSurfaceEXT != nil diff --git a/hal/vulkan/vk/loader.go b/hal/vulkan/vk/loader.go index 20d5ff4b..6fb27ae9 100644 --- a/hal/vulkan/vk/loader.go +++ b/hal/vulkan/vk/loader.go @@ -60,11 +60,17 @@ var ( // vulkanLibraryName returns platform-specific Vulkan library name. func vulkanLibraryName() string { - switch runtime.GOOS { + return vulkanLibraryNameFor(runtime.GOOS) +} + +func vulkanLibraryNameFor(goos string) string { + switch goos { case "windows": return "vulkan-1.dll" case "darwin": return "libvulkan.dylib" // MoltenVK + case "android": + return "libvulkan.so" default: // linux, freebsd, etc. return "libvulkan.so.1" } From 0b529afc2e79cea57c3f631b0bf6eeebe92bd73b Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 22:15:58 +0300 Subject: [PATCH 02/11] ci(android): prove arm64 preview boundaries --- .github/workflows/ci.yml | 45 +++++++ scripts/check-android-arm64-preview.sh | 135 ++++++++++++++++++++ scripts/testdata/android_arm64_vulkan_abi.c | 21 +++ 3 files changed, 201 insertions(+) create mode 100755 scripts/check-android-arm64-preview.sh create mode 100644 scripts/testdata/android_arm64_vulkan_abi.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a3e4390..d7b4aa40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,51 @@ env: CGO_ENABLED: 0 jobs: + # Draft-only integration against the exact canonical goffi Android candidate. + # The script creates an ephemeral go.work; module metadata stays releasable. + android-arm64-preview: + name: Android arm64 preview - Go ${{ matrix.go-version }} + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.25.12', '1.26.5'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Checkout exact goffi Android candidate + uses: actions/checkout@v4 + with: + repository: go-webgpu/goffi + ref: 8ccaae72d877a7af0af4b628bf86e92536e27d88 + path: .ci/goffi + persist-credentials: false + + - name: Verify exact goffi candidate + working-directory: .ci/goffi + run: test "$(git rev-parse HEAD)" = 8ccaae72d877a7af0af4b628bf86e92536e27d88 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android NDK r29 + run: sdkmanager 'ndk;29.0.14206865' + + - name: Check Android arm64 source, tests, and ELF dependencies + env: + GOFFI_DIR: ${{ github.workspace }}/.ci/goffi + GOFFI_EXPECTED_HEAD: 8ccaae72d877a7af0af4b628bf86e92536e27d88 + run: | + export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/29.0.14206865" + ./scripts/check-android-arm64-preview.sh + # Build verification - Cross-platform build: name: Build - ${{ matrix.os }} diff --git a/scripts/check-android-arm64-preview.sh b/scripts/check-android-arm64-preview.sh new file mode 100755 index 00000000..1b63de64 --- /dev/null +++ b/scripts/check-android-arm64-preview.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +: "${GOFFI_DIR:?set GOFFI_DIR to the checked-out canonical goffi Android candidate}" +: "${GOFFI_EXPECTED_HEAD:?set GOFFI_EXPECTED_HEAD to its immutable commit}" +goffi_dir=$(cd "$GOFFI_DIR" && pwd -P) +actual_head=$(git -C "$goffi_dir" rev-parse HEAD) +if [[ "$actual_head" != "$GOFFI_EXPECTED_HEAD" ]]; then + echo "goffi HEAD is $actual_head, want $GOFFI_EXPECTED_HEAD" >&2 + exit 1 +fi + +: "${ANDROID_NDK_HOME:?set ANDROID_NDK_HOME to Android NDK r29}" +if ! grep -q '^Pkg.Revision = 29\.' "$ANDROID_NDK_HOME/source.properties"; then + echo "Android NDK r29 is required" >&2 + exit 1 +fi + +readelf_path=$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path '*/bin/llvm-readelf' \( -type f -o -type l \) -print -quit) +clang_path=$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path '*/bin/aarch64-linux-android29-clang' \( -type f -o -type l \) -print -quit) +if [[ -z "$readelf_path" || -z "$clang_path" ]]; then + echo "NDK r29 llvm-readelf or API 29 arm64 clang was not found" >&2 + exit 1 +fi +"$clang_path" -std=c11 -fsyntax-only "$root/scripts/testdata/android_arm64_vulkan_abi.c" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +( + cd "$tmpdir" + GOWORK=off go work init "$root" "$goffi_dir" +) +workspace="$tmpdir/go.work" + +selected_goffi=$(GOWORK="$workspace" go list -m -f '{{.Dir}}' github.com/go-webgpu/goffi) +if [[ "$(cd "$selected_goffi" && pwd -P)" != "$goffi_dir" ]]; then + echo "workspace selected goffi from $selected_goffi, want $goffi_dir" >&2 + exit 1 +fi + +audit_source_selection() { + local cgo=$1 + local vulkan_files + local backend_files + local dependencies="$tmpdir/android-arm64-cgo$cgo.deps" + local -a env_args=( + "GOWORK=$workspace" + "GOOS=android" + "GOARCH=arm64" + "CGO_ENABLED=$cgo" + ) + if [[ "$cgo" == 1 ]]; then + env_args+=("CC=$clang_path") + fi + + vulkan_files=$(cd "$root" && env "${env_args[@]}" go list -f '{{.GoFiles}}' ./hal/vulkan) + backend_files=$(cd "$root" && env "${env_args[@]}" go list -f '{{.GoFiles}}' ./hal/allbackends) + for file in api_android.go init_android.go platform_android.go; do + grep -q "$file" <<<"$vulkan_files" + done + if grep -Eq 'api_linux\.go|platform_state_default\.go|(^|[^[:alnum:]_])init\.go([^[:alnum:]_]|$)' <<<"$vulkan_files"; then + echo "Android selected a desktop Vulkan source: $vulkan_files" >&2 + exit 1 + fi + grep -q 'register_android.go' <<<"$backend_files" + if grep -Eq 'register(_linux)?\.go' <<<"$backend_files"; then + echo "Android selected a desktop/fallback backend source: $backend_files" >&2 + exit 1 + fi + + (cd "$root" && env "${env_args[@]}" go list -deps ./examples/triangle-headless) >"$dependencies" + if grep -Eq 'github.com/gogpu/wgpu/hal/(gles|software)$' "$dependencies"; then + echo "Android dependency graph contains a GLES or software fallback" >&2 + exit 1 + fi +} + +audit_elf() { + local binary=$1 + local label=$2 + local dynamic="$tmpdir/$label.dynamic" + local strings_file="$tmpdir/$label.strings" + + "$readelf_path" -d "$binary" >"$dynamic" + strings -a "$binary" >"$strings_file" + + grep -Eq 'Shared library: \[libc\.so\]' "$dynamic" + grep -Eq 'Shared library: \[libdl\.so\]' "$dynamic" + grep -q 'libvulkan.so' "$strings_file" + + if grep -Eq 'Shared library: \[[^]]*\.so\.[0-9]|Shared library: \[libpthread' "$dynamic"; then + echo "$label contains a glibc-style or standalone pthread dependency" >&2 + cat "$dynamic" >&2 + exit 1 + fi + if grep -Eq 'GLIBC_|__errno_location|libX11|libwayland-client|libEGL|libGLESv2' "$strings_file"; then + echo "$label contains a forbidden non-Bionic/desktop symbol or library" >&2 + exit 1 + fi +} + +run_mode() { + local cgo=$1 + local label="android-arm64-cgo$cgo" + local binary="$tmpdir/$label" + local -a env_args=( + "GOWORK=$workspace" + "GOOS=android" + "GOARCH=arm64" + "CGO_ENABLED=$cgo" + ) + if [[ "$cgo" == 1 ]]; then + env_args+=("CC=$clang_path") + fi + + audit_source_selection "$cgo" + ( + cd "$root" + env "${env_args[@]}" go test -exec=true ./... + env "${env_args[@]}" go build -o "$binary" ./examples/triangle-headless + ) + audit_elf "$binary" "$label" +} + +run_mode 0 +run_mode 1 + +if [[ -e "$root/go.work" || -e "$root/go.work.sum" ]]; then + echo "preview proof must not leave a committed/local workspace in the WGPU tree" >&2 + exit 1 +fi +git -C "$root" diff --exit-code -- go.mod go.sum +echo "Android arm64 preview checks passed with goffi $actual_head" diff --git a/scripts/testdata/android_arm64_vulkan_abi.c b/scripts/testdata/android_arm64_vulkan_abi.c new file mode 100644 index 00000000..770e9659 --- /dev/null +++ b/scripts/testdata/android_arm64_vulkan_abi.c @@ -0,0 +1,21 @@ +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +#define VK_USE_PLATFORM_ANDROID_KHR 1 + +#include +#include + +_Static_assert(sizeof(void *) == 8, "Android preview requires LP64"); +_Static_assert(sizeof(VkInstance) == 8, "unexpected VkInstance size"); +_Static_assert(sizeof(VkSurfaceKHR) == 8, "unexpected VkSurfaceKHR size"); +_Static_assert(sizeof(VkAndroidSurfaceCreateInfoKHR) == 32, + "unexpected VkAndroidSurfaceCreateInfoKHR size"); +_Static_assert(offsetof(VkAndroidSurfaceCreateInfoKHR, sType) == 0, + "unexpected sType offset"); +_Static_assert(offsetof(VkAndroidSurfaceCreateInfoKHR, pNext) == 8, + "unexpected pNext offset"); +_Static_assert(offsetof(VkAndroidSurfaceCreateInfoKHR, flags) == 16, + "unexpected flags offset"); +_Static_assert(offsetof(VkAndroidSurfaceCreateInfoKHR, window) == 24, + "unexpected window offset"); From 60b2c8d39daae5ebcf0cfa8133e6bda7dad92313 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 22:16:02 +0300 Subject: [PATCH 03/11] docs(android): define the Vulkan preview contract --- README.md | 29 +++++++------ docs/ANDROID.md | 98 ++++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 17 +++++--- hal/allbackends/doc.go | 6 +-- hal/vulkan/doc.go | 3 +- surface_native.go | 1 + 6 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 docs/ANDROID.md diff --git a/README.md b/README.md index d7d14af7..c382b3a8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ | Category | Capabilities | |----------|--------------| | **Backends** | Vulkan, Metal, DirectX 12, OpenGL ES, Software, **Browser WebGPU**, **Rust FFI** | -| **Platforms** | Windows, Linux, macOS, iOS, **Browser (WASM)** | +| **Platforms** | Windows, Linux, macOS, iOS, **Browser (WASM)**, **Android/arm64 (preview)** | | **API** | WebGPU-compliant (W3C specification) | | **Shaders** | WGSL via gogpu/naga compiler (SPIR-V, HLSL, MSL, GLSL, DXIL) | | **Compute** | Full compute shader support, GPU→CPU readback | @@ -56,6 +56,10 @@ CGO_ENABLED=0 go build > **Note:** wgpu uses Pure Go FFI via [goffi](https://github.com/go-webgpu/goffi). Both `CGO_ENABLED=0` (default, zero C compiler dependency) and `CGO_ENABLED=1` (for race detector or coexistence with CGO libraries) are supported. +The unreleased Android/arm64 Vulkan implementation is documented separately in +[Android Vulkan preview](docs/ANDROID.md). It requires the exact canonical +goffi candidate named there and is not yet a released support claim. + **Rust FFI backend** (optional, battle-tested wgpu-native drivers): ```bash go build -tags rust @@ -217,7 +221,7 @@ wgpu/ │ ├── noop/ # No-op backend (testing) │ ├── software/ # CPU software rasterizer (~14K LOC) │ ├── gles/ # OpenGL ES 3.0+ (~12K LOC) -│ ├── vulkan/ # Vulkan 1.3 (~42K LOC) +│ ├── vulkan/ # Pure Go Vulkan backend (~42K LOC) │ ├── metal/ # Metal (~7K LOC) │ └── dx12/ # DirectX 12 (~17K LOC) ├── examples/ @@ -252,7 +256,8 @@ import _ "github.com/gogpu/wgpu/hal/allbackends" // Platform-specific backends auto-registered: // - Windows: Vulkan, DX12, GLES, Software // - Linux: Vulkan, GLES, Software -// - macOS: Metal, Software +// - macOS: Metal, Vulkan, Software +// - Android/arm64 preview: Vulkan only ``` --- @@ -261,19 +266,19 @@ import _ "github.com/gogpu/wgpu/hal/allbackends" ### Platform Support -| Backend | Windows | Linux | macOS | iOS | Notes | -|---------|:-------:|:-----:|:-----:|:---:|-------| -| **Vulkan** | Yes | Yes | Yes | - | MoltenVK on macOS | -| **Metal** | - | - | Yes | Yes | Native Apple GPU | -| **DX12** | Yes | - | - | - | Windows 10+ | -| **GLES** | Yes | Yes | - | - | OpenGL ES 3.0+ | -| **Software** | Yes | Yes | Yes | Yes | CPU fallback | +| Backend | Windows | Linux | macOS | iOS | Android/arm64 | Notes | +|---------|:-------:|:-----:|:-----:|:---:|:-------------:|-------| +| **Vulkan** | Yes | Yes | Yes | - | Preview | MoltenVK on macOS; [Android contract](docs/ANDROID.md) | +| **Metal** | - | - | Yes | Yes | - | Native Apple GPU | +| **DX12** | Yes | - | - | - | - | Windows 10+ | +| **GLES** | Yes | Yes | - | - | - | OpenGL ES 3.0+ | +| **Software** | Yes | Yes | Yes | Yes | - | CPU fallback | **Architectures:** amd64, arm64 (including Windows ARM64 / Snapdragon X) ### Vulkan Backend -Full Vulkan 1.3 implementation with: +Pure Go Vulkan backend with: - Auto-generated bindings from official `vk.xml` - Buddy allocator for GPU memory (O(log n), minimal fragmentation) @@ -282,7 +287,7 @@ Full Vulkan 1.3 implementation with: - wgpu-style swapchain synchronization - MSAA render pass with automatic resolve - Complete resource management (Buffer, Texture, Pipeline, BindGroup) -- Surface creation: Win32, X11, Wayland, Metal (MoltenVK) +- Surface creation: Win32, X11, Wayland, Metal (MoltenVK), and Android `ANativeWindow` (preview) - Debug messenger for validation layer error capture (`VK_EXT_debug_utils`) - Structured diagnostic logging via `log/slog` diff --git a/docs/ANDROID.md b/docs/ANDROID.md new file mode 100644 index 00000000..20f3699d --- /dev/null +++ b/docs/ANDROID.md @@ -0,0 +1,98 @@ +# Android Vulkan preview + +Android/arm64 support is an unreleased implementation preview in +[gogpu/wgpu#268](https://github.com/gogpu/wgpu/pull/268), not a released support +claim. Its current scope is Vulkan, arm64, Android API 29 or newer, and both +`CGO_ENABLED=0` and `CGO_ENABLED=1`. GLES, software fallback, 32-bit Android, +debug callbacks, and API 28 or older are out of scope. + +The preview depends on the unreleased canonical goffi work in +[go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62), exactly at +`8ccaae72d877a7af0af4b628bf86e92536e27d88`. Neither module uses a forked +module path, a `replace` directive, or a committed `go.work`. Draft CI checks +out that exact canonical commit and creates an ephemeral workspace only for +integration proof. + +## Host contract + +`Instance.CreateSurface` keeps the existing two-`uintptr` API on Android: + +- `displayHandle` is ignored, matching Rust wgpu v29. +- `windowHandle` is the raw, non-null `ANativeWindow*` value. + +The host owns the Activity/native-window lifecycle and retains its application +reference. A successfully created `VkSurfaceKHR` owns Vulkan's separate window +reference until `vkDestroySurfaceKHR`; WGPU therefore does not call +`ANativeWindow_acquire` or `ANativeWindow_release`. + +Surfaces are independent. Creating a second surface does not invalidate the +first. When Android replaces a native window, create a surface for the new raw +window and release the old surface after its configured device has drained. +There is deliberately no display-generation protocol: a counter cannot prove +pointer lifetime and would reject legitimate simultaneous surfaces. + +## Rust wgpu v29 mapping + +The semantic oracle is gfx-rs/wgpu v29.0.3 commit +`4cbe6232b2d7c289b6e1a38416a6ae1461a22e81`. + +| Behavior | Rust v29 location | Go implementation and proof | +|----------|-------------------|-----------------------------| +| Ignore Android display; use only raw `a_native_window` | `wgpu-hal/src/vulkan/instance.rs` | `api_android.go`, `android_surface_policy_test.go` | +| Load `libvulkan.so`; require Android WSI extension/commands | Vulkan instance loader | `vk/loader.go`, `vk/commands.go`, source-selection audit | +| API 29 uses infinite acquire timeout; API 30+ preserves one second | `swapchain/native.rs::acquire` | `swapchainPlatformPolicy.acquireTimeout` | +| Fence wait uses `waitAll=true` | `swapchain/native.rs::acquire` | checked acquire path in `swapchain.go` | +| Identity pre-transform; ignore orientation-only `SUBOPTIMAL` | `swapchain/native.rs::{create_swapchain,acquire,present}` | platform policy and host tests | +| Fixed `currentExtent` is authoritative; variable extent is clamped | `swapchain/native.rs::surface_capabilities` | `selectSwapchainExtent` tests | +| `Rgba16Float` pairs with extended-linear sRGB | `swapchain/native.rs::create_swapchain`, `conv.rs` | exact pair-selection test | +| Device drains configured swapchains before native teardown | native swapchain ownership | [wgpu#269](https://github.com/gogpu/wgpu/pull/269) lifecycle tests | + +This change adds no Android-driven global Vulkan version check or adapter +filter. The canonical backend's existing application-version request remains +unchanged; actual extensions, commands, features, and device evidence decide +whether a Vulkan implementation works. + +## Exact draft stack + +The draft is stacked only until its prerequisites merge. Their original exact +heads remain separately reviewable: + +| Prerequisite | Exact head | +|--------------|------------| +| [wgpu#264](https://github.com/gogpu/wgpu/pull/264), drain before device teardown | `0ed17064f8c977f35d9b49b5cde0d0c69e867ecf` | +| [wgpu#265](https://github.com/gogpu/wgpu/pull/265), fail-closed swapchain negotiation | `a8ff52e340f0a06c8e1b6599a03856d7fc74d1a2` | +| [wgpu#266](https://github.com/gogpu/wgpu/pull/266), explicit mock construction | `e97e4901ee76d9e5f587569c073b38114221c4e6` | +| [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `a3e839f94a12edce98e2496d96e5bd8d3cdd2fc3` | +| [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `85eeeb02a278bf4caebf66310b6baad8c328dd87` | +| [goffi#62](https://github.com/go-webgpu/goffi/pull/62), Android/Bionic runtime | `8ccaae72d877a7af0af4b628bf86e92536e27d88` | + +## Reproducing deterministic proof + +Use Android NDK r29 and clean checkouts of this branch and the exact goffi +candidate: + +```bash +GOFFI_DIR=/path/to/goffi \ +GOFFI_EXPECTED_HEAD=8ccaae72d877a7af0af4b628bf86e92536e27d88 \ +ANDROID_NDK_HOME=/path/to/android-ndk-r29 \ +GOTOOLCHAIN=go1.26.5 \ +./scripts/check-android-arm64-preview.sh +``` + +The script verifies Android-only source selection, compiles every package and +test in cgo0 and cgo1 modes, builds the headless example, checks the generated +Go and NDK C ABI layouts, and audits ELF dependencies. It requires Bionic +`libc.so`/`libdl.so`, confirms `libvulkan.so`, and rejects glibc sonames, +standalone `libpthread`, desktop WSI, GLES, and software fallback. CI repeats +the proof with Go 1.25.12 and Go 1.26.5. + +These are deterministic cross-build and binary-shape checks. They do not prove +process startup, adapter enumeration, surface creation, rendering, +presentation, rotation, or lifecycle recovery on a physical device. + +Before the preview can become merge-ready, goffi#62 and the WGPU prerequisites +must merge and ship through canonical refs, #268 must rebase to Android-only +commits, and API 29 plus API 30-or-newer arm64 devices must pass cgo0/cgo1 +startup, known-color presentation, rotation, Activity recreation, and repeated +native-window replacement without crashes, hangs, stale frames, or validation +errors. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b7fadfe9..6820ca99 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -86,12 +86,13 @@ Key interfaces (defined in `hal/api.go`): ### `hal/vulkan/` — Vulkan Backend -Pure Go Vulkan 1.0+ implementation using `cgo_import_dynamic` for function loading. +Pure Go Vulkan implementation using goffi for dynamic function loading. - `vk/` — Low-level Vulkan bindings (generated types, function signatures, loader) - `memory/` — GPU memory allocator (buddy allocation, `maxMemoryAllocationSize` enforcement) - Command encoder: free list of pre-allocated VkCommandBuffers (batch 16), `vkResetCommandPool` for batch reset (Rust wgpu-hal parity) -- Platform surface: VkWin32, VkXlib, VkMetal +- Platform surface: VkWin32, VkXlib/VkWayland, VkMetal, and Android + `ANativeWindow` (arm64/API 29+ preview; see [ANDROID.md](ANDROID.md)) ### `hal/metal/` — Metal Backend @@ -203,9 +204,13 @@ Platform selection (`hal/allbackends/`): | Platform | Backends | |----------|----------| -| Windows | Vulkan, DX12, GLES, Software, Noop | -| macOS | Metal, Software, Noop | -| Linux | Vulkan, GLES, Software, Noop | +| Windows | Vulkan, DX12, GLES, Software | +| macOS | Metal, Vulkan, Software | +| Linux | Vulkan, GLES, Software | +| Android/arm64 | Vulkan only (preview) | + +The no-op backend is imported explicitly for tests; `hal/allbackends` does not +register it automatically. Backend priority for auto-selection: Vulkan > Metal > DX12 > GLES > Software > Noop. @@ -301,5 +306,5 @@ gogpu (app framework) / gg (2D graphics) External dependencies: - `github.com/gogpu/naga` — shader compiler (WGSL → SPIR-V / MSL / GLSL / HLSL / DXIL), Pure Go - `github.com/gogpu/gputypes` v0.5.0 — shared WebGPU type definitions -- `github.com/go-webgpu/goffi` v0.5.1 — Pure Go FFI for Vulkan/Metal symbol loading +- `github.com/go-webgpu/goffi` v0.6.0 — Pure Go FFI for Vulkan/Metal symbol loading - `golang.org/x/sys` v0.44.0 — platform syscall definitions diff --git a/hal/allbackends/doc.go b/hal/allbackends/doc.go index 9d03b707..c7e631d8 100644 --- a/hal/allbackends/doc.go +++ b/hal/allbackends/doc.go @@ -12,7 +12,7 @@ // ) // // This will register: -// - Vulkan backend (Windows, Linux, macOS) +// - Vulkan backend (Windows, Linux, macOS; Android/arm64 preview) // - Metal backend (macOS, iOS) // - DX12 backend (Windows) // - OpenGL ES backend (Windows, Linux) @@ -21,8 +21,8 @@ // After importing, use hal.GetBackend or hal.SelectBestBackend to access backends. // // Build tags control which backends are available: -// - Default: All backends for the current platform -// - "!android": Excludes Android-specific Vulkan loader +// - Desktop: platform GPU backends plus software fallback +// - Android/arm64: Vulkan only // // The no-op provider is not registered by this package. Import // github.com/gogpu/wgpu/hal/noop explicitly when it is required for tests. diff --git a/hal/vulkan/doc.go b/hal/vulkan/doc.go index c7f1c4db..c6ab92ed 100644 --- a/hal/vulkan/doc.go +++ b/hal/vulkan/doc.go @@ -7,7 +7,7 @@ // // This backend uses goffi for cross-platform Vulkan API calls, requiring no CGO. // Function pointers are loaded dynamically from vulkan-1.dll (Windows), -// libvulkan.so.1 (Linux), or MoltenVK (macOS). +// libvulkan.so.1 (Linux), MoltenVK (macOS), or libvulkan.so (Android). // // # Architecture // @@ -28,4 +28,5 @@ // - Windows: vulkan-1.dll + VK_KHR_win32_surface // - Linux: libvulkan.so.1 + VK_KHR_xlib_surface/VK_KHR_xcb_surface (planned) // - macOS: MoltenVK + VK_EXT_metal_surface (planned) +// - Android/arm64 preview: libvulkan.so + VK_KHR_android_surface (API 29+) package vulkan diff --git a/surface_native.go b/surface_native.go index eae60444..c2436801 100644 --- a/surface_native.go +++ b/surface_native.go @@ -37,6 +37,7 @@ type Surface struct { // - macOS: displayHandle=0, windowHandle=NSView* // - Linux/X11: displayHandle=Display*, windowHandle=Window // - Linux/Wayland: displayHandle=wl_display*, windowHandle=wl_surface* +// - Android: displayHandle ignored, windowHandle=ANativeWindow* func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, error) { if i.isReleased() { return nil, ErrReleased From 68e232788dbec120bb4cbef371a4d51155f65572 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 10:08:34 +0300 Subject: [PATCH 04/11] test(android): cover API 36 platform policy --- hal/vulkan/android_surface_policy_test.go | 2 +- hal/vulkan/swapchain_platform_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/hal/vulkan/android_surface_policy_test.go b/hal/vulkan/android_surface_policy_test.go index 05b09809..fdee5376 100644 --- a/hal/vulkan/android_surface_policy_test.go +++ b/hal/vulkan/android_surface_policy_test.go @@ -110,7 +110,7 @@ func TestAndroidSDKPolicyStartsAtAPI29(t *testing.T) { if err := validateAndroidSDKVersion(28); err == nil { t.Fatal("Android API 28 was accepted") } - for _, sdk := range []uint32{29, 30} { + for _, sdk := range []uint32{29, 30, 36} { if err := validateAndroidSDKVersion(sdk); err != nil { t.Fatalf("Android API %d rejected: %v", sdk, err) } diff --git a/hal/vulkan/swapchain_platform_test.go b/hal/vulkan/swapchain_platform_test.go index 43e3993b..b811f9bf 100644 --- a/hal/vulkan/swapchain_platform_test.go +++ b/hal/vulkan/swapchain_platform_test.go @@ -24,6 +24,7 @@ func TestSwapchainPlatformPolicyAcquireTimeout(t *testing.T) { {name: "desktop", policy: defaultSwapchainPlatformPolicy(), want: requested}, {name: "Android API 29", policy: androidSwapchainPlatformPolicy(29), want: math.MaxUint64}, {name: "Android API 30", policy: androidSwapchainPlatformPolicy(30), want: requested}, + {name: "Android API 36", policy: androidSwapchainPlatformPolicy(36), want: requested}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { From 27ce9155616d3d39ca503d84875c6dd2b28dc6d3 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 10:07:03 +0300 Subject: [PATCH 05/11] feat(surface): add Rust-v29 typed surface targets --- adapter_native.go | 2 +- cmd/gles-test/main.go | 6 +- cmd/vk-test/main.go | 6 +- cmd/vulkan-triangle/main.go | 5 +- core/backend_test.go | 4 +- core/instance.go | 55 +++++- core/instance_gles_test.go | 25 ++- core/instance_surface_test.go | 53 ++++- core/instance_test.go | 2 +- core/surface_test.go | 2 +- hal/api.go | 8 +- hal/backends_test.go | 4 +- hal/dx12/instance.go | 9 +- hal/error_test.go | 4 +- hal/gles/api.go | 6 +- hal/gles/api_linux.go | 16 +- hal/gles/egl/context.go | 21 +- hal/gles/egl/context_policy_test.go | 37 ++++ hal/gles/egl/display.go | 18 +- hal/metal/api.go | 11 +- hal/metal/surface_test.go | 5 +- hal/noop/api.go | 4 +- hal/noop/noop_test.go | 16 +- hal/registry_test.go | 2 +- hal/software/api.go | 40 +++- hal/software/blit_linux.go | 6 +- hal/software/blit_wayland.go | 12 +- hal/software/damage_test.go | 12 +- hal/software/draw_test.go | 2 +- hal/software/resource.go | 1 + hal/software/software_test.go | 75 ++++++- hal/surface_target.go | 67 +++++++ hal/surface_target_test.go | 35 ++++ hal/vulkan/api.go | 55 +++++- hal/vulkan/api_android.go | 10 +- hal/vulkan/api_darwin.go | 19 +- hal/vulkan/api_linux.go | 53 +++-- hal/vulkan/api_linux_test.go | 25 +++ hal/vulkan/api_windows.go | 14 +- hal/vulkan/instance_extensions_test.go | 28 +++ instance_browser.go | 2 +- instance_lifecycle_native_test.go | 54 ++++++ instance_native.go | 6 +- instance_rust.go | 2 +- surface_backend_native_test.go | 162 ++++++++++++++++ surface_browser.go | 51 ++++- surface_native.go | 259 +++++++++++++++++++++---- surface_rust.go | 44 ++++- surface_rust_android.go | 20 ++ surface_rust_darwin.go | 20 +- surface_rust_linux.go | 27 ++- surface_rust_windows.go | 19 +- surface_target.go | 170 ++++++++++++++++ surface_target_contract_test.go | 6 + surface_target_hal_test.go | 116 +++++++++++ surface_target_linux_test.go | 17 ++ surface_target_native_test.go | 119 ++++++++++++ 57 files changed, 1681 insertions(+), 188 deletions(-) create mode 100644 hal/gles/egl/context_policy_test.go create mode 100644 hal/surface_target.go create mode 100644 hal/surface_target_test.go create mode 100644 hal/vulkan/api_linux_test.go create mode 100644 hal/vulkan/instance_extensions_test.go create mode 100644 surface_backend_native_test.go create mode 100644 surface_rust_android.go create mode 100644 surface_target.go create mode 100644 surface_target_contract_test.go create mode 100644 surface_target_hal_test.go create mode 100644 surface_target_linux_test.go create mode 100644 surface_target_native_test.go diff --git a/adapter_native.go b/adapter_native.go index 7b405067..6dd136f2 100644 --- a/adapter_native.go +++ b/adapter_native.go @@ -167,7 +167,7 @@ func (a *Adapter) GetSurfaceCapabilities(surface *Surface) *SurfaceCapabilities } } - halSurface := surface.HAL() + halSurface := surface.halSurfaceForBackend(a.info.Backend) if halSurface == nil { return nil } diff --git a/cmd/gles-test/main.go b/cmd/gles-test/main.go index d542b1f6..bb1d3055 100644 --- a/cmd/gles-test/main.go +++ b/cmd/gles-test/main.go @@ -13,6 +13,7 @@ import ( "time" "unsafe" + "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/gles" "github.com/gogpu/wgpu/hal/gles/gl" "github.com/gogpu/wgpu/hal/gles/wgl" @@ -302,7 +303,10 @@ func testGLESBackend() error { // Test 3: Create surface fmt.Print(" Creating surface... ") - surface, err := instance.CreateSurface(0, hwnd) + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetWindowsHWND, + WindowHandle: hwnd, + }) if err != nil { return fmt.Errorf("CreateSurface: %w", err) } diff --git a/cmd/vk-test/main.go b/cmd/vk-test/main.go index 871521b4..0b0dc8be 100644 --- a/cmd/vk-test/main.go +++ b/cmd/vk-test/main.go @@ -12,6 +12,7 @@ import ( "syscall" "unsafe" + "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/vulkan" "github.com/gogpu/wgpu/hal/vulkan/vk" ) @@ -162,7 +163,10 @@ func testVulkanBackend() error { // Test 3: Create surface fmt.Print(" Creating surface... ") - surface, err := instance.CreateSurface(0, hwnd) + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetWindowsHWND, + WindowHandle: hwnd, + }) if err != nil { return fmt.Errorf("CreateSurface: %w", err) } diff --git a/cmd/vulkan-triangle/main.go b/cmd/vulkan-triangle/main.go index 0af21827..979ea26d 100644 --- a/cmd/vulkan-triangle/main.go +++ b/cmd/vulkan-triangle/main.go @@ -224,7 +224,10 @@ func initGPU(window *Window) (*gpuResources, error) { // Create surface fmt.Print("5. Creating surface... ") - surface, err := instance.CreateSurface(0, window.Handle()) + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetWindowsHWND, + WindowHandle: window.Handle(), + }) if err != nil { return nil, fmt.Errorf("creating surface: %w", err) } diff --git a/core/backend_test.go b/core/backend_test.go index 4b5a7d35..86d83355 100644 --- a/core/backend_test.go +++ b/core/backend_test.go @@ -375,7 +375,9 @@ func (b *testHALBackend) CreateInstance(_ *hal.InstanceDescriptor) (hal.Instance type testHALInstance struct{} -func (i *testHALInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { return nil, nil } //nolint:nilnil +func (i *testHALInstance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { + return nil, hal.ErrUnsupportedSurfaceTarget +} func (i *testHALInstance) EnumerateAdapters(_ hal.Surface) []hal.ExposedAdapter { return nil } diff --git a/core/instance.go b/core/instance.go index 46bb7e53..203978d4 100644 --- a/core/instance.go +++ b/core/instance.go @@ -40,6 +40,10 @@ type Instance struct { // halInstances tracks HAL instances created for each backend. // These are destroyed when the Instance is destroyed. halInstances []hal.Instance + // halInstanceEntries preserves backend priority alongside each HAL instance. + // Surface creation uses this ordered view to mirror Rust wgpu's attempt to + // create a raw surface for every enabled backend. + halInstanceEntries []HALInstanceEntry // halInstanceMap maps backend type to its HAL instance for backend-specific // surface creation. When a device uses a different backend than the initially @@ -61,6 +65,13 @@ type Instance struct { useMock bool } +// HALInstanceEntry associates an enabled backend with its HAL instance. +// Entries are ordered by the same backend priority used during discovery. +type HALInstanceEntry struct { + Backend gputypes.Backend + Instance hal.Instance +} + // surfaceAdapterQualifier is an optional backend capability. It intentionally // stays private to core so the stable HAL Adapter interface does not grow a // surface-specific method. Vulkan implements the method on its concrete @@ -169,6 +180,10 @@ func (i *Instance) enumerateRealAdapters(desc *gputypes.InstanceDescriptor) { // Lock() call creates the GL context on the render thread via sync.Once. if provider.Variant() == gputypes.BackendGL { i.halInstances = append(i.halInstances, halInstance) + i.halInstanceEntries = append(i.halInstanceEntries, HALInstanceEntry{ + Backend: provider.Variant(), + Instance: halInstance, + }) i.halInstanceMap[provider.Variant()] = halInstance i.deferredGLES = append(i.deferredGLES, halInstance) continue @@ -176,6 +191,10 @@ func (i *Instance) enumerateRealAdapters(desc *gputypes.InstanceDescriptor) { // Track HAL instance for cleanup i.halInstances = append(i.halInstances, halInstance) + i.halInstanceEntries = append(i.halInstanceEntries, HALInstanceEntry{ + Backend: provider.Variant(), + Instance: halInstance, + }) i.halInstanceMap[provider.Variant()] = halInstance // Enumerate adapters from this backend @@ -372,6 +391,24 @@ func (i *Instance) RequestAdapterWithSurface(options *gputypes.RequestAdapterOpt if surfaceHint == nil { return i.RequestAdapter(options) } + return i.requestAdapterWithSurfaceResolver(options, func(gputypes.Backend) hal.Surface { + return surfaceHint + }) +} + +// RequestAdapterWithSurfaces requests an adapter using the surface created for +// each adapter's own backend. This mirrors Rust wgpu's per-backend surface map +// while preserving RequestAdapterWithSurface for callers that own one HAL. +func (i *Instance) RequestAdapterWithSurfaces(options *gputypes.RequestAdapterOptions, surfaces map[gputypes.Backend]hal.Surface) (AdapterID, error) { + if len(surfaces) == 0 { + return i.RequestAdapter(options) + } + return i.requestAdapterWithSurfaceResolver(options, func(backend gputypes.Backend) hal.Surface { + return surfaces[backend] + }) +} + +func (i *Instance) requestAdapterWithSurfaceResolver(options *gputypes.RequestAdapterOptions, surfaceForBackend func(gputypes.Backend) hal.Surface) (AdapterID, error) { i.surfaceRequestMu.Lock() defer i.surfaceRequestMu.Unlock() @@ -388,7 +425,9 @@ func (i *Instance) RequestAdapterWithSurface(options *gputypes.RequestAdapterOpt // Run deferred GLES enumeration with the real surface hint before collecting // candidates. Once glesEnumerated is true this is a no-op on later calls. - i.enumerateDeferredGLES(surfaceHint) + if glesSurface := surfaceForBackend(gputypes.BackendGL); glesSurface != nil { + i.enumerateDeferredGLES(glesSurface) + } i.mu.RLock() allAdapterIDs := append([]AdapterID(nil), i.adapters...) @@ -405,6 +444,10 @@ func (i *Instance) RequestAdapterWithSurface(options *gputypes.RequestAdapterOpt if err != nil { continue } + surfaceHint := surfaceForBackend(adapter.Backend) + if surfaceHint == nil { + continue + } // A newly enumerated adapter was produced with the surface hint. Keep // that backend-owned adapter as-is; this preserves existing GLES @@ -598,6 +641,15 @@ func (i *Instance) HALInstance() hal.Instance { return nil } +// HALInstanceEntries returns enabled HAL instances in backend-priority order. +// The returned slice is a snapshot and may be inspected without holding the +// Instance lock. +func (i *Instance) HALInstanceEntries() []HALInstanceEntry { + i.mu.RLock() + defer i.mu.RUnlock() + return append([]HALInstanceEntry(nil), i.halInstanceEntries...) +} + // HALInstanceForBackend returns the HAL instance for a specific backend type. // Returns nil if no instance exists for the given backend. // Used to re-create surfaces when the device's backend differs from the @@ -666,5 +718,6 @@ func (i *Instance) Destroy() { halInstance.Destroy() } i.halInstances = nil + i.halInstanceEntries = nil i.deferredGLES = nil } diff --git a/core/instance_gles_test.go b/core/instance_gles_test.go index 40482ad6..50540b98 100644 --- a/core/instance_gles_test.go +++ b/core/instance_gles_test.go @@ -42,7 +42,7 @@ type trackingGLESInstance struct { halAdapter hal.Adapter // non-nil ⇒ returns a real ExposedAdapter } -func (i *trackingGLESInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { +func (i *trackingGLESInstance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { return nil, nil //nolint:nilnil } func (i *trackingGLESInstance) EnumerateAdapters(hint hal.Surface) []hal.ExposedAdapter { @@ -68,7 +68,7 @@ func (i *trackingGLESInstance) Destroy() {} type countingGLESInstance struct{ callCount int } -func (i *countingGLESInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { +func (i *countingGLESInstance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { return nil, nil //nolint:nilnil } func (i *countingGLESInstance) EnumerateAdapters(_ hal.Surface) []hal.ExposedAdapter { @@ -97,6 +97,27 @@ func TestRequestAdapterWithSurface_PassesHintToEnumerateAdapters(t *testing.T) { } } +func TestRequestAdapterWithSurfacesPassesMatchingBackendHint(t *testing.T) { + GetGlobal().Clear() + + tracker := &trackingGLESInstance{halAdapter: &stubHALAdapter{}} + instance := &Instance{ + backends: gputypes.BackendsAll, + deferredGLES: []hal.Instance{tracker}, + } + vulkanHint := hal.Surface(&stubHALSurface{id: 1}) + glHint := hal.Surface(&stubHALSurface{id: 2}) + + _, _ = instance.RequestAdapterWithSurfaces(nil, map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: vulkanHint, + gputypes.BackendGL: glHint, + }) + + if tracker.receivedHint != glHint { + t.Errorf("GLES EnumerateAdapters received hint %v, want matching GL hint %v", tracker.receivedHint, glHint) + } +} + // TestRequestAdapterWithSurface_SelectsGLESAdapter verifies that the GLES // adapter exposed by EnumerateAdapters(hint) is the one RequestAdapter returns. func TestRequestAdapterWithSurface_SelectsGLESAdapter(t *testing.T) { diff --git a/core/instance_surface_test.go b/core/instance_surface_test.go index 2d2419cb..b9bbea42 100644 --- a/core/instance_surface_test.go +++ b/core/instance_surface_test.go @@ -12,9 +12,10 @@ import ( ) type surfaceQualificationAdapter struct { - compatible bool - qualifies *int - destroys *int + compatible bool + qualifies *int + destroys *int + lastSurface hal.Surface } func (a *surfaceQualificationAdapter) Open(_ gputypes.Features, _ gputypes.Limits) (hal.OpenDevice, error) { @@ -35,7 +36,8 @@ func (a *surfaceQualificationAdapter) Destroy() { } } -func (a *surfaceQualificationAdapter) QualifySurface(_ hal.Surface) (hal.Adapter, error) { +func (a *surfaceQualificationAdapter) QualifySurface(surface hal.Surface) (hal.Adapter, error) { + a.lastSurface = surface if a.qualifies != nil { (*a.qualifies)++ } @@ -45,6 +47,49 @@ func (a *surfaceQualificationAdapter) QualifySurface(_ hal.Surface) (hal.Adapter return &surfaceQualificationAdapter{compatible: true, destroys: a.destroys}, nil } +func TestRequestAdapterWithSurfacesUsesEachBackendsOwnSurface(t *testing.T) { + GetGlobal().Clear() + hub := GetGlobal().Hub() + + vulkanHAL := &surfaceQualificationAdapter{compatible: true} + glHAL := &surfaceQualificationAdapter{compatible: true} + vulkanID := hub.RegisterAdapter(&Adapter{ + Info: gputypes.AdapterInfo{DeviceType: gputypes.DeviceTypeDiscreteGPU, Backend: gputypes.BackendVulkan}, + Limits: gputypes.DefaultLimits(), + Backend: gputypes.BackendVulkan, + halAdapter: vulkanHAL, + }) + glID := hub.RegisterAdapter(&Adapter{ + Info: gputypes.AdapterInfo{DeviceType: gputypes.DeviceTypeIntegratedGPU, Backend: gputypes.BackendGL}, + Limits: gputypes.DefaultLimits(), + Backend: gputypes.BackendGL, + halAdapter: glHAL, + }) + instance := &Instance{ + backends: gputypes.BackendsVulkan | gputypes.BackendsGL, + adapters: []AdapterID{vulkanID, glID}, + } + defer instance.Destroy() + + vulkanSurface := hal.Surface(&stubHALSurface{id: 21}) + glSurface := hal.Surface(&stubHALSurface{id: 22}) + selected, err := instance.RequestAdapterWithSurfaces(nil, map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: vulkanSurface, + gputypes.BackendGL: glSurface, + }) + if err != nil { + t.Fatalf("RequestAdapterWithSurfaces: %v", err) + } + defer instance.ReleaseSurfaceAdapter(selected) + + if vulkanHAL.lastSurface != vulkanSurface { + t.Fatalf("Vulkan qualification surface = %v, want %v", vulkanHAL.lastSurface, vulkanSurface) + } + if glHAL.lastSurface != glSurface { + t.Fatalf("GL qualification surface = %v, want %v", glHAL.lastSurface, glSurface) + } +} + func TestRequestAdapterWithSurfaceReleasesUnselectedQualifiedAdapters(t *testing.T) { GetGlobal().Clear() hub := GetGlobal().Hub() diff --git a/core/instance_test.go b/core/instance_test.go index 8852f44a..43f39e4e 100644 --- a/core/instance_test.go +++ b/core/instance_test.go @@ -12,7 +12,7 @@ import ( type providerBackedTestInstance struct{} -func (*providerBackedTestInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { +func (*providerBackedTestInstance) CreateSurface(hal.SurfaceTarget) (hal.Surface, error) { return nil, errors.New("test instance does not create surfaces") } diff --git a/core/surface_test.go b/core/surface_test.go index aa81890c..d20e49fe 100644 --- a/core/surface_test.go +++ b/core/surface_test.go @@ -24,7 +24,7 @@ func newTestSurface(t *testing.T) (*Surface, *Device, hal.Queue) { t.Fatalf("CreateInstance: %v", err) } - halSurface, err := inst.CreateSurface(0, 0) + halSurface, err := inst.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface: %v", err) } diff --git a/hal/api.go b/hal/api.go index c24cd153..b39fc6a2 100644 --- a/hal/api.go +++ b/hal/api.go @@ -48,11 +48,9 @@ type Backend interface { // Instance is the entry point for GPU operations. // An instance manages adapter enumeration and surface creation. type Instance interface { - // CreateSurface creates a rendering surface from platform handles. - // displayHandle is platform-specific (HDC on Windows, NSWindow* on macOS, etc.). - // windowHandle is the window handle (HWND on Windows, NSView* on macOS, etc.). - // On Android, displayHandle is ignored and windowHandle is ANativeWindow*. - CreateSurface(displayHandle, windowHandle uintptr) (Surface, error) + // CreateSurface creates a rendering surface from a typed raw platform target. + // The target's native objects remain caller-owned and must outlive the Surface. + CreateSurface(target SurfaceTarget) (Surface, error) // EnumerateAdapters enumerates available physical GPUs. // surfaceHint is optional - if provided, only adapters compatible with diff --git a/hal/backends_test.go b/hal/backends_test.go index 3d84419d..424d8fb4 100644 --- a/hal/backends_test.go +++ b/hal/backends_test.go @@ -32,7 +32,9 @@ func (b *factoryTestBackend) CreateInstance(_ *hal.InstanceDescriptor) (hal.Inst // factoryTestInstance implements hal.Instance for factory tests. type factoryTestInstance struct{} -func (i *factoryTestInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { return nil, nil } //nolint:nilnil +func (i *factoryTestInstance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { + return nil, hal.ErrUnsupportedSurfaceTarget +} func (i *factoryTestInstance) EnumerateAdapters(_ hal.Surface) []hal.ExposedAdapter { return nil } diff --git a/hal/dx12/instance.go b/hal/dx12/instance.go index 80964064..f348dc24 100644 --- a/hal/dx12/instance.go +++ b/hal/dx12/instance.go @@ -171,14 +171,17 @@ func (i *Instance) checkTearingSupport() { // CreateSurface creates a rendering surface from platform handles. // displayHandle is not used on Windows (can be 0). // windowHandle must be a valid HWND. -func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (hal.Surface, error) { - if windowHandle == 0 { +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetWindowsHWND); err != nil { + return nil, fmt.Errorf("dx12: %w", err) + } + if target.WindowHandle == 0 { return nil, fmt.Errorf("dx12: windowHandle (HWND) is required") } return &Surface{ instance: i, - hwnd: windowHandle, + hwnd: target.WindowHandle, }, nil } diff --git a/hal/error_test.go b/hal/error_test.go index 58e98e19..c81f4e76 100644 --- a/hal/error_test.go +++ b/hal/error_test.go @@ -56,7 +56,7 @@ func TestSurfaceConfigureZeroDimensions_Vulkan(t *testing.T) { } defer instance.Destroy() - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Skipf("Surface creation failed: %v", err) } @@ -121,7 +121,7 @@ func TestSurfaceConfigureValidDimensions(t *testing.T) { } defer instance.Destroy() - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } diff --git a/hal/gles/api.go b/hal/gles/api.go index a0c28668..c50ae0dd 100644 --- a/hal/gles/api.go +++ b/hal/gles/api.go @@ -84,7 +84,11 @@ type Instance struct { // user DCs during Present. // // Follows Rust wgpu-hal/src/gles/wgl.rs Instance::create_surface (lines 624-670). -func (i *Instance) CreateSurface(_, windowHandle uintptr) (hal.Surface, error) { +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetWindowsHWND); err != nil { + return nil, fmt.Errorf("gles: %w", err) + } + windowHandle := target.WindowHandle hwnd := wgl.HWND(windowHandle) // Set pixel format on user window DC. Required for wglMakeCurrent to diff --git a/hal/gles/api_linux.go b/hal/gles/api_linux.go index 2f811e7e..3ece124f 100644 --- a/hal/gles/api_linux.go +++ b/hal/gles/api_linux.go @@ -97,11 +97,22 @@ type Instance struct { // // When Instance has no context (Wayland — no wl_display* at init), CreateSurface // creates a new EGL context with the caller's displayHandle. -func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (hal.Surface, error) { +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + var targetWindowKind egl.WindowKind + switch target.Kind { + case hal.SurfaceTargetXlibWindow: + targetWindowKind = egl.WindowKindX11 + case hal.SurfaceTargetWaylandSurface: + targetWindowKind = egl.WindowKindWayland + default: + return nil, fmt.Errorf("gles: %w: got %s, backend requires Xlib window or Wayland surface", hal.ErrUnsupportedSurfaceTarget, target.Kind) + } + displayHandle, windowHandle := target.DisplayHandle, target.WindowHandle + // Path A: share Instance context (X11 — context matches window system). // Do NOT share if Instance context is surfaceless (headless/Wayland fallback) // and Surface needs a window — the EGL display won't support eglCreateWindowSurface. - if i.eglCtx != nil && i.glCtx != nil && i.eglCtx.WindowKind() != egl.WindowKindSurfaceless { + if i.eglCtx != nil && i.glCtx != nil && i.eglCtx.WindowKind() == targetWindowKind { hal.Logger().Info("gles: surface sharing Instance EGL context") return &Surface{ displayHandle: displayHandle, @@ -120,6 +131,7 @@ func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (hal.Surfa // expose EGL_OPENGL_ES3_BIT configs, not EGL_OPENGL_BIT. Found by @lkmavi (PR #215). config := egl.DefaultContextConfig() config.NativeDisplay = displayHandle + config.WindowKind = &targetWindowKind ctx, err := egl.NewContext(config) if err != nil { config.GLES = true diff --git a/hal/gles/egl/context.go b/hal/gles/egl/context.go index 8aeeab27..1e0cc44d 100644 --- a/hal/gles/egl/context.go +++ b/hal/gles/egl/context.go @@ -40,6 +40,10 @@ type ContextConfig struct { // a second connection, which makes wl_surface proxies mismatched on configure. // On X11: the X11 Display*. Zero uses the default display. NativeDisplay uintptr + // WindowKind selects the native window system explicitly. Nil preserves + // automatic environment-based detection and keeps ContextConfig's zero value + // independent of WindowKind's numeric constants. + WindowKind *WindowKind } // DefaultContextConfig returns a sensible default context configuration. @@ -55,13 +59,13 @@ func DefaultContextConfig() ContextConfig { } } -// NewContext creates a new EGL context with automatic platform detection. -// It detects the window system (X11, Wayland, or Surfaceless) and creates -// an appropriate EGL context. +// NewContext creates a new EGL context. An explicit WindowKind selects that +// platform directly; otherwise it detects X11, Wayland, or Surfaceless. func NewContext(config ContextConfig) (*Context, error) { - // Get EGL display for the detected platform. + // Get an EGL display for the explicit or detected platform. // displayOwner (non-nil for X11) keeps the native display connection alive. - display, windowKind, displayOwner, err := GetEGLDisplay(config.NativeDisplay) + windowKind := selectWindowKind(config.WindowKind, DetectWindowKind) + display, windowKind, displayOwner, err := getEGLDisplayForKind(config.NativeDisplay, windowKind) if err != nil { return nil, fmt.Errorf("failed to get EGL display: %w", err) } @@ -140,6 +144,13 @@ func NewContext(config ContextConfig) (*Context, error) { }, nil } +func selectWindowKind(requested *WindowKind, detect func() WindowKind) WindowKind { + if requested != nil { + return *requested + } + return detect() +} + // chooseEGLConfig selects an appropriate EGL frame buffer configuration. func chooseEGLConfig(display EGLDisplay, config ContextConfig) (EGLConfig, error) { // Determine renderable type diff --git a/hal/gles/egl/context_policy_test.go b/hal/gles/egl/context_policy_test.go new file mode 100644 index 00000000..1153c9a3 --- /dev/null +++ b/hal/gles/egl/context_policy_test.go @@ -0,0 +1,37 @@ +//go:build linux && !(js && wasm) + +package egl + +import "testing" + +func TestSelectWindowKindHonorsExplicitTarget(t *testing.T) { + x11 := WindowKindX11 + wayland := WindowKindWayland + tests := []struct { + name string + requested *WindowKind + detected WindowKind + want WindowKind + wantCalls int + }{ + {name: "explicit X11 under Wayland", requested: &x11, detected: WindowKindWayland, want: WindowKindX11, wantCalls: 0}, + {name: "explicit Wayland under X11", requested: &wayland, detected: WindowKindX11, want: WindowKindWayland, wantCalls: 0}, + {name: "nil uses automatic selection", detected: WindowKindSurfaceless, want: WindowKindSurfaceless, wantCalls: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + got := selectWindowKind(test.requested, func() WindowKind { + calls++ + return test.detected + }) + if got != test.want { + t.Fatalf("selectWindowKind() = %v, want %v", got, test.want) + } + if calls != test.wantCalls { + t.Fatalf("detector calls = %d, want %d", calls, test.wantCalls) + } + }) + } +} diff --git a/hal/gles/egl/display.go b/hal/gles/egl/display.go index d07accd8..8587c653 100644 --- a/hal/gles/egl/display.go +++ b/hal/gles/egl/display.go @@ -220,10 +220,26 @@ func DetectWindowKind() WindowKind { // - X11: the X11 Display* (0 = use DISPLAY env var via OpenX11Display). // - Surfaceless: ignored. func GetEGLDisplay(nativeDisplay uintptr) (EGLDisplay, WindowKind, *DisplayOwner, error) { - windowKind := DetectWindowKind() + return getEGLDisplayForKind(nativeDisplay, DetectWindowKind()) +} +// getEGLDisplayForKind returns an EGL display for an explicit native window +// system. It is used by typed surface targets so process-global environment +// variables cannot redirect Xlib handles into Wayland or vice versa. +func getEGLDisplayForKind(nativeDisplay uintptr, windowKind WindowKind) (EGLDisplay, WindowKind, *DisplayOwner, error) { switch windowKind { case WindowKindX11: + if nativeDisplay != 0 { + display := GetPlatformDisplay(PlatformX11KHR, nativeDisplay, nil) + if display != NoDisplay { + return display, WindowKindX11, nil, nil + } + display = GetDisplay(EGLNativeDisplayType(nativeDisplay)) + if display == NoDisplay { + return NoDisplay, WindowKindUnknown, nil, fmt.Errorf("eglGetDisplay failed for supplied X11 display") + } + return display, WindowKindX11, nil, nil + } owner := OpenX11Display() if owner == nil { return NoDisplay, WindowKindUnknown, nil, fmt.Errorf("failed to open X11 display") diff --git a/hal/metal/api.go b/hal/metal/api.go index 74600a59..9ad4e450 100644 --- a/hal/metal/api.go +++ b/hal/metal/api.go @@ -32,11 +32,12 @@ func (Backend) CreateInstance(desc *hal.InstanceDescriptor) (hal.Instance, error // Instance implements hal.Instance for Metal. type Instance struct{} -// CreateSurface creates a rendering surface from platform handles. -func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (hal.Surface, error) { - // On macOS, windowHandle is typically NSView* or CAMetalLayer* - // We need to get or create a CAMetalLayer from the view - layer := ID(windowHandle) +// CreateSurface creates a rendering surface from a CAMetalLayer target. +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetMetalLayer); err != nil { + return nil, fmt.Errorf("metal: %w", err) + } + layer := ID(target.WindowHandle) if layer == 0 { return nil, fmt.Errorf("metal: window handle is nil") } diff --git a/hal/metal/surface_test.go b/hal/metal/surface_test.go index e434f123..e5a47e70 100644 --- a/hal/metal/surface_test.go +++ b/hal/metal/surface_test.go @@ -35,7 +35,10 @@ func TestSurfaceTextureCreateView(t *testing.T) { } instance := inst.(*Instance) - surface, err := instance.CreateSurface(0, uintptr(layer)) + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetMetalLayer, + WindowHandle: uintptr(layer), + }) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } diff --git a/hal/noop/api.go b/hal/noop/api.go index fd1fe6c3..58bab3a8 100644 --- a/hal/noop/api.go +++ b/hal/noop/api.go @@ -25,8 +25,8 @@ func (API) CreateInstance(_ *hal.InstanceDescriptor) (hal.Instance, error) { type Instance struct{} // CreateSurface creates a noop surface. -// Always succeeds regardless of display/window handles. -func (i *Instance) CreateSurface(_, _ uintptr) (hal.Surface, error) { +// Always succeeds regardless of the target. +func (i *Instance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { return &Surface{}, nil } diff --git a/hal/noop/noop_test.go b/hal/noop/noop_test.go index e00ebaa7..575a370e 100644 --- a/hal/noop/noop_test.go +++ b/hal/noop/noop_test.go @@ -95,7 +95,7 @@ func TestNoopEnumerateAdapters_WithSurfaceHint(t *testing.T) { defer instance.Destroy() // Create a surface - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } @@ -128,7 +128,11 @@ func TestNoopCreateSurface(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - surface, err := instance.CreateSurface(tt.displayHandle, tt.windowHandle) + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetHeadless, + DisplayHandle: tt.displayHandle, + WindowHandle: tt.windowHandle, + }) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } @@ -198,7 +202,7 @@ func TestNoopAdapterCapabilities(t *testing.T) { } // Test surface capabilities - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() surfaceCaps := adapter.SurfaceCapabilities(surface) @@ -955,7 +959,7 @@ func TestNoopSurfaceConfigure(t *testing.T) { instance, _ := api.CreateInstance(nil) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() adapters := instance.EnumerateAdapters(nil) @@ -986,7 +990,7 @@ func TestNoopSurfaceAcquireTexture(t *testing.T) { instance, _ := api.CreateInstance(nil) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() adapters := instance.EnumerateAdapters(nil) @@ -1103,7 +1107,7 @@ func TestNoopFullLifecycle(t *testing.T) { defer instance.Destroy() // Create surface - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } diff --git a/hal/registry_test.go b/hal/registry_test.go index cc0a269a..8bddcb38 100644 --- a/hal/registry_test.go +++ b/hal/registry_test.go @@ -26,7 +26,7 @@ func (m *mockBackend) CreateInstance(_ *hal.InstanceDescriptor) (hal.Instance, e // mockInstance is a minimal instance implementation for testing. type mockInstance struct{} -func (m *mockInstance) CreateSurface(_, _ uintptr) (hal.Surface, error) { +func (m *mockInstance) CreateSurface(_ hal.SurfaceTarget) (hal.Surface, error) { return &mockSurface{}, nil } func (m *mockInstance) EnumerateAdapters(_ hal.Surface) []hal.ExposedAdapter { diff --git a/hal/software/api.go b/hal/software/api.go index 53e55e6e..07f4b1ea 100644 --- a/hal/software/api.go +++ b/hal/software/api.go @@ -3,10 +3,19 @@ package software import ( + "fmt" + "runtime" + "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/hal" ) +const ( + goosWindows = "windows" + goosLinux = "linux" + goosDarwin = "darwin" +) + // API implements hal.Backend for the software backend. type API struct{} @@ -30,10 +39,33 @@ type Instance struct{} // XPutImage on Linux X11). // If window is 0 (headless mode), Present() is a no-op. // -// displayHandle is platform-specific: X11 Display* on Linux, 0 elsewhere. -// windowHandle is the native window: HWND on Windows, X11 Window on Linux. -func (i *Instance) CreateSurface(displayHandle, window uintptr) (hal.Surface, error) { - return &Surface{displayHandle: displayHandle, hwnd: window}, nil +// The target kind remains attached to the Surface so deferred platform setup +// never has to infer the window system from process-global state. +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if !supportsSurfaceTarget(runtime.GOOS, target.Kind) { + return nil, fmt.Errorf("software: %w: got %s on %s", hal.ErrUnsupportedSurfaceTarget, target.Kind, runtime.GOOS) + } + return &Surface{ + targetKind: target.Kind, + displayHandle: target.DisplayHandle, + hwnd: target.WindowHandle, + }, nil +} + +func supportsSurfaceTarget(goos string, kind hal.SurfaceTargetKind) bool { + if kind == hal.SurfaceTargetHeadless { + return true + } + switch goos { + case goosWindows: + return kind == hal.SurfaceTargetWindowsHWND + case goosLinux: + return kind == hal.SurfaceTargetXlibWindow || kind == hal.SurfaceTargetWaylandSurface + case goosDarwin: + return kind == hal.SurfaceTargetMetalLayer + default: + return false + } } // EnumerateAdapters returns a single default software adapter. diff --git a/hal/software/blit_linux.go b/hal/software/blit_linux.go index 02eb9bbb..ec6adcdb 100644 --- a/hal/software/blit_linux.go +++ b/hal/software/blit_linux.go @@ -21,6 +21,7 @@ import ( "github.com/go-webgpu/goffi/ffi" "github.com/go-webgpu/goffi/types" + "github.com/gogpu/wgpu/hal" ) // X11 constants (from X.h / Xlib.h). @@ -208,7 +209,8 @@ type platformBlit struct { wlState waylandBlitState // Wayland SHM state, initialized eagerly in Configure } -// configurePlatformBlit detects Wayland vs X11 and eagerly obtains wl_shm. +// configurePlatformBlit uses the surface's typed target and eagerly obtains +// wl_shm for Wayland. // Called from Configure() on the MAIN thread, before the event loop or render // thread calls Present. This eliminates the race between wl_display_roundtrip // (in obtainWlShm) and concurrent wl_display_dispatch (in gogpu event loop). @@ -227,7 +229,7 @@ func (s *Surface) configurePlatformBlit() { } if !s.wlState.detected { s.wlState.detected = true - s.wlState.isWayland = isWaylandDisplay() + s.wlState.isWayland = s.targetKind == hal.SurfaceTargetWaylandSurface if s.wlState.isWayland { // Create shmQueue FIRST — obtainWlShm needs it for the // display wrapper so all proxies inherit this queue. diff --git a/hal/software/blit_wayland.go b/hal/software/blit_wayland.go index 7a7b8337..1d50baf8 100644 --- a/hal/software/blit_wayland.go +++ b/hal/software/blit_wayland.go @@ -19,7 +19,6 @@ package software import ( "image" "log/slog" - "os" "sync" "syscall" "unsafe" @@ -120,7 +119,7 @@ var ( // (qwaylandshmbackingstore.cpp); 3 is the minimum for skip-free // presentation on slow compositors (e.g. pixman renderer). type waylandBlitState struct { - isWayland bool // true if displayHandle is wl_display* (detected once) + isWayland bool // true when SurfaceTargetKind is Wayland detected bool // true if detection has been performed wlShm uintptr // bound wl_shm global (on shmQueue, 0 if not yet obtained) @@ -190,15 +189,6 @@ var ( bufferBusyMap = map[uintptr]*waylandShmBuffer{} ) -// isWaylandDisplay returns true if the session is running under Wayland. -// Uses WAYLAND_DISPLAY env var — same pattern as hal/vulkan/api_linux.go. -// The previous fd-probe approach (wl_display_get_fd on the display handle) -// was unsafe: an X11 Display* passed to wl_display_get_fd reads garbage -// from an unrelated struct and can return a false-positive fd >= 0. -func isWaylandDisplay() bool { - return os.Getenv("WAYLAND_DISPLAY") != "" -} - // initWayland loads libwayland-client.so and prepares CIFs for SHM presentation. func initWayland() { var err error diff --git a/hal/software/damage_test.go b/hal/software/damage_test.go index f87cc793..023df2cf 100644 --- a/hal/software/damage_test.go +++ b/hal/software/damage_test.go @@ -42,7 +42,7 @@ func createDamageTestSurface(t *testing.T, width, height uint32) (*Surface, *Dev } t.Cleanup(instance.Destroy) - surface, err := instance.CreateSurface(0, 0) // headless: hwnd=0 + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface: %v", err) } @@ -77,7 +77,7 @@ func createDamageTestSurfaceBGRA(t *testing.T, width, height uint32) (*Surface, } t.Cleanup(instance.Destroy) - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface: %v", err) } @@ -868,7 +868,7 @@ func BenchmarkPresent_FullSurface(b *testing.B) { openDev, _ := adapters[0].Adapter.Open(0, gputypes.DefaultLimits()) defer openDev.Device.Destroy() - surface, _ := instance.CreateSurface(0, 0) // headless + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() _ = surface.Configure(openDev.Device, &hal.SurfaceConfiguration{ @@ -896,7 +896,7 @@ func BenchmarkPresent_SmallDamageRect(b *testing.B) { openDev, _ := adapters[0].Adapter.Open(0, gputypes.DefaultLimits()) defer openDev.Device.Destroy() - surface, _ := instance.CreateSurface(0, 0) // headless + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() _ = surface.Configure(openDev.Device, &hal.SurfaceConfiguration{ @@ -925,7 +925,7 @@ func BenchmarkPresent_MultipleSmallRects(b *testing.B) { openDev, _ := adapters[0].Adapter.Open(0, gputypes.DefaultLimits()) defer openDev.Device.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() _ = surface.Configure(openDev.Device, &hal.SurfaceConfiguration{ @@ -1007,7 +1007,7 @@ func TestDamage_ReconfigureThenPresent(t *testing.T) { } defer instance.Destroy() - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("CreateSurface: %v", err) } diff --git a/hal/software/draw_test.go b/hal/software/draw_test.go index acc53691..0db5deb4 100644 --- a/hal/software/draw_test.go +++ b/hal/software/draw_test.go @@ -540,7 +540,7 @@ func TestDrawWithSurfaceTexture(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() _ = surface.Configure(dev, &hal.SurfaceConfiguration{ diff --git a/hal/software/resource.go b/hal/software/resource.go index 3c91ed2d..b8503aa7 100644 --- a/hal/software/resource.go +++ b/hal/software/resource.go @@ -170,6 +170,7 @@ type Surface struct { mu sync.RWMutex // Protects framebuffer access presentMode hal.PresentMode alphaMode hal.CompositeAlphaMode + targetKind hal.SurfaceTargetKind displayHandle uintptr // X11: Display*, macOS/Windows: 0 hwnd uintptr // window handle for platform blit (0 = headless) platformBlit // platform-specific blit resources (Windows: DIB section, Linux: X11 GC) diff --git a/hal/software/software_test.go b/hal/software/software_test.go index df546831..0d275a20 100644 --- a/hal/software/software_test.go +++ b/hal/software/software_test.go @@ -4,6 +4,7 @@ package software import ( "errors" + "runtime" "testing" "github.com/gogpu/gputypes" @@ -29,6 +30,51 @@ func TestInstanceCreation(t *testing.T) { instance.Destroy() } +func TestSurfaceTargetSupportMatchesHostPlatform(t *testing.T) { + tests := []struct { + name string + goos string + kind hal.SurfaceTargetKind + want bool + }{ + {name: "headless is portable", goos: "android", kind: hal.SurfaceTargetHeadless, want: true}, + {name: "Windows accepts HWND", goos: goosWindows, kind: hal.SurfaceTargetWindowsHWND, want: true}, + {name: "Windows rejects Xlib", goos: goosWindows, kind: hal.SurfaceTargetXlibWindow, want: false}, + {name: "Linux accepts Xlib", goos: goosLinux, kind: hal.SurfaceTargetXlibWindow, want: true}, + {name: "Linux accepts Wayland", goos: goosLinux, kind: hal.SurfaceTargetWaylandSurface, want: true}, + {name: "Linux rejects Android", goos: goosLinux, kind: hal.SurfaceTargetAndroidNativeWindow, want: false}, + {name: "Android rejects Linux", goos: "android", kind: hal.SurfaceTargetXlibWindow, want: false}, + {name: "Android rejects native window", goos: "android", kind: hal.SurfaceTargetAndroidNativeWindow, want: false}, + {name: "Darwin accepts Metal", goos: goosDarwin, kind: hal.SurfaceTargetMetalLayer, want: true}, + {name: "Darwin rejects HWND", goos: goosDarwin, kind: hal.SurfaceTargetWindowsHWND, want: false}, + {name: "unknown OS is headless only", goos: "plan9", kind: hal.SurfaceTargetMetalLayer, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := supportsSurfaceTarget(test.goos, test.kind); got != test.want { + t.Fatalf("supportsSurfaceTarget(%q, %v) = %v, want %v", test.goos, test.kind, got, test.want) + } + }) + } +} + +func TestCreateSurfaceRejectsForeignTargetBeforeStoringHandles(t *testing.T) { + instance := &Instance{} + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: hal.SurfaceTargetAndroidNativeWindow, + DisplayHandle: 0x1111, + WindowHandle: 0x2222, + }) + if surface != nil { + surface.Destroy() + t.Fatal("CreateSurface returned a surface for a foreign target") + } + if !errors.Is(err, hal.ErrUnsupportedSurfaceTarget) { + t.Fatalf("CreateSurface error = %v, want ErrUnsupportedSurfaceTarget", err) + } +} + func TestAdapterEnumeration(t *testing.T) { backend := API{} instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) @@ -224,7 +270,7 @@ func TestSurfaceConfiguration(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, err := instance.CreateSurface(0, 0) + surface, err := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) if err != nil { t.Fatalf("Failed to create surface: %v", err) } @@ -265,7 +311,7 @@ func TestSurfaceFramebufferReadback(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() adapters := instance.EnumerateAdapters(nil) @@ -1006,7 +1052,7 @@ func TestSurfaceZeroArea(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() adapters := instance.EnumerateAdapters(nil) @@ -1037,7 +1083,7 @@ func TestSurfaceAcquireTexture(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, _ := instance.CreateSurface(0, 0) + surface, _ := instance.CreateSurface(hal.SurfaceTarget{Kind: hal.SurfaceTargetHeadless}) defer surface.Destroy() adapters := instance.EnumerateAdapters(nil) @@ -1076,7 +1122,23 @@ func TestSurfaceStoresDisplayHandle(t *testing.T) { instance, _ := backend.CreateInstance(&hal.InstanceDescriptor{}) defer instance.Destroy() - surface, err := instance.CreateSurface(0xDEAD, 0xBEEF) + var kind hal.SurfaceTargetKind + switch runtime.GOOS { + case goosWindows: + kind = hal.SurfaceTargetWindowsHWND + case goosLinux: + kind = hal.SurfaceTargetXlibWindow + case goosDarwin: + kind = hal.SurfaceTargetMetalLayer + default: + t.Skipf("software backend has no window target on %s", runtime.GOOS) + } + + surface, err := instance.CreateSurface(hal.SurfaceTarget{ + Kind: kind, + DisplayHandle: 0xDEAD, + WindowHandle: 0xBEEF, + }) if err != nil { t.Fatalf("CreateSurface failed: %v", err) } @@ -1089,6 +1151,9 @@ func TestSurfaceStoresDisplayHandle(t *testing.T) { if s.hwnd != 0xBEEF { t.Errorf("hwnd = %#x, want 0xBEEF", s.hwnd) } + if s.targetKind != kind { + t.Errorf("targetKind = %v, want %v", s.targetKind, kind) + } } func TestSurfaceGetFramebufferNil(t *testing.T) { diff --git a/hal/surface_target.go b/hal/surface_target.go new file mode 100644 index 00000000..e4329671 --- /dev/null +++ b/hal/surface_target.go @@ -0,0 +1,67 @@ +//go:build !(js && wasm) + +package hal + +import ( + "errors" + "fmt" +) + +// ErrUnsupportedSurfaceTarget indicates that a HAL backend cannot create a +// surface for the supplied platform target kind. +var ErrUnsupportedSurfaceTarget = errors.New("hal: unsupported surface target") + +// SurfaceTargetKind identifies the native window-system representation carried +// by a SurfaceTarget. +type SurfaceTargetKind uint8 + +const ( + SurfaceTargetInvalid SurfaceTargetKind = iota + SurfaceTargetHeadless + SurfaceTargetWindowsHWND + SurfaceTargetXlibWindow + SurfaceTargetWaylandSurface + SurfaceTargetAndroidNativeWindow + SurfaceTargetMetalLayer +) + +// SurfaceTarget is the typed raw-window contract passed from core to HAL. +// DisplayHandle is unused for Android and Metal. WindowHandle is HWND, Xlib +// Window, wl_surface*, ANativeWindow*, or CAMetalLayer* according to Kind. +// HAL never owns these raw handles; they must outlive the created Surface. +// Headless is a Go software/noop extension and carries no handles. +type SurfaceTarget struct { + Kind SurfaceTargetKind + DisplayHandle uintptr + WindowHandle uintptr +} + +// RequireKind rejects a target intended for a different window system. +func (t SurfaceTarget) RequireKind(expected SurfaceTargetKind) error { + if t.Kind != expected { + return fmt.Errorf("%w: got %s, backend requires %s", ErrUnsupportedSurfaceTarget, t.Kind, expected) + } + return nil +} + +// String returns a stable diagnostic name for the target kind. +func (k SurfaceTargetKind) String() string { + switch k { + case SurfaceTargetHeadless: + return "headless" + case SurfaceTargetWindowsHWND: + return "Win32 HWND" + case SurfaceTargetXlibWindow: + return "Xlib window" + case SurfaceTargetWaylandSurface: + return "Wayland surface" + case SurfaceTargetAndroidNativeWindow: + return "Android native window" + case SurfaceTargetMetalLayer: + return "Metal layer" + case SurfaceTargetInvalid: + return "invalid" + default: + return fmt.Sprintf("SurfaceTargetKind(%d)", uint8(k)) + } +} diff --git a/hal/surface_target_test.go b/hal/surface_target_test.go new file mode 100644 index 00000000..d48def24 --- /dev/null +++ b/hal/surface_target_test.go @@ -0,0 +1,35 @@ +//go:build !(js && wasm) + +package hal_test + +import ( + "errors" + "testing" + + "github.com/gogpu/wgpu/hal" +) + +func TestSurfaceTargetCarriesExplicitPlatformKind(t *testing.T) { + target := hal.SurfaceTarget{ + Kind: hal.SurfaceTargetAndroidNativeWindow, + DisplayHandle: 0x1234, + WindowHandle: 0x5678, + } + + if target.Kind != hal.SurfaceTargetAndroidNativeWindow { + t.Fatalf("Kind = %v, want Android native window", target.Kind) + } + if target.DisplayHandle != 0x1234 || target.WindowHandle != 0x5678 { + t.Fatalf("handles = (%#x, %#x), want (%#x, %#x)", target.DisplayHandle, target.WindowHandle, uintptr(0x1234), uintptr(0x5678)) + } +} + +func TestSurfaceTargetRequireKind(t *testing.T) { + target := hal.SurfaceTarget{Kind: hal.SurfaceTargetAndroidNativeWindow} + if err := target.RequireKind(hal.SurfaceTargetAndroidNativeWindow); err != nil { + t.Fatalf("RequireKind rejected matching target: %v", err) + } + if err := target.RequireKind(hal.SurfaceTargetXlibWindow); !errors.Is(err, hal.ErrUnsupportedSurfaceTarget) { + t.Fatalf("RequireKind mismatch error = %v, want ErrUnsupportedSurfaceTarget", err) + } +} diff --git a/hal/vulkan/api.go b/hal/vulkan/api.go index 2d1d686b..41692096 100644 --- a/hal/vulkan/api.go +++ b/hal/vulkan/api.go @@ -8,6 +8,7 @@ package vulkan import ( "fmt" "runtime" + "strings" "unsafe" "github.com/gogpu/gputypes" @@ -15,6 +16,11 @@ import ( "github.com/gogpu/wgpu/hal/vulkan/vk" ) +const ( + extensionWaylandSurface = "VK_KHR_wayland_surface\x00" + extensionXlibSurface = "VK_KHR_xlib_surface\x00" +) + // Backend implements hal.Backend for Vulkan. type Backend struct{} @@ -59,8 +65,14 @@ func (Backend) CreateInstance(desc *hal.InstanceDescriptor) (hal.Instance, error "VK_KHR_surface\x00", } - // Platform-specific surface extension - extensions = append(extensions, platformSurfaceExtension()) + // Enable every platform WSI extension that this loader exposes. Linux can + // legitimately use Xlib and Wayland in the same process (for example + // XWayland), so ambient session variables must not select the instance ABI. + availableExtensions, err := enumerateInstanceExtensions(cmds) + if err != nil { + return nil, fmt.Errorf("vulkan: enumerate instance extensions: %w", err) + } + extensions = append(extensions, selectAvailableExtensions(platformSurfaceExtensions(), availableExtensions)...) // Optional: validation layers for debug (only if available) var layers []string @@ -609,3 +621,42 @@ func isLayerAvailable(cmds *vk.Commands, layerName string) bool { } return false } + +func enumerateInstanceExtensions(cmds *vk.Commands) (map[string]struct{}, error) { + for range 3 { + var count uint32 + result := cmds.EnumerateInstanceExtensionProperties(0, &count, nil) + if result != vk.Success && result != vk.Incomplete { + return nil, fmt.Errorf("vkEnumerateInstanceExtensionProperties(count) failed: %d", result) + } + if count == 0 { + return map[string]struct{}{}, nil + } + + properties := make([]vk.ExtensionProperties, count) + result = cmds.EnumerateInstanceExtensionProperties(0, &count, &properties[0]) + if result == vk.Incomplete { + continue + } + if result != vk.Success { + return nil, fmt.Errorf("vkEnumerateInstanceExtensionProperties(list) failed: %d", result) + } + available := make(map[string]struct{}, count) + for index := range count { + available[cStringToGo(properties[index].ExtensionName[:])] = struct{}{} + } + return available, nil + } + return nil, fmt.Errorf("vkEnumerateInstanceExtensionProperties remained incomplete") +} + +func selectAvailableExtensions(candidates []string, available map[string]struct{}) []string { + selected := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + name := strings.TrimSuffix(candidate, "\x00") + if _, ok := available[name]; ok { + selected = append(selected, candidate) + } + } + return selected +} diff --git a/hal/vulkan/api_android.go b/hal/vulkan/api_android.go index 9358705e..63b148bc 100644 --- a/hal/vulkan/api_android.go +++ b/hal/vulkan/api_android.go @@ -12,8 +12,8 @@ import ( "github.com/gogpu/wgpu/hal/vulkan/vk" ) -func platformSurfaceExtension() string { - return "VK_KHR_android_surface\x00" +func platformSurfaceExtensions() []string { + return []string{"VK_KHR_android_surface\x00"} } // CreateSurface creates a Vulkan surface from an ANativeWindow. @@ -22,7 +22,11 @@ func platformSurfaceExtension() string { // window handle is the raw ANativeWindow pointer and must be non-zero. The host // retains its application reference; Vulkan owns its surface reference until // DestroySurfaceKHR. WGPU does not acquire or release ANativeWindow itself. -func (i *Instance) CreateSurface(_, windowHandle uintptr) (hal.Surface, error) { +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetAndroidNativeWindow); err != nil { + return nil, fmt.Errorf("vulkan: %w", err) + } + windowHandle := target.WindowHandle if err := validateAndroidSurfaceRequest(windowHandle); err != nil { return nil, err } diff --git a/hal/vulkan/api_darwin.go b/hal/vulkan/api_darwin.go index c3416870..e176a6fe 100644 --- a/hal/vulkan/api_darwin.go +++ b/hal/vulkan/api_darwin.go @@ -13,16 +13,17 @@ import ( "github.com/gogpu/wgpu/hal/vulkan/vk" ) -// platformSurfaceExtension returns the macOS surface extension. -func platformSurfaceExtension() string { - return "VK_EXT_metal_surface\x00" +// platformSurfaceExtensions returns the macOS surface extension. +func platformSurfaceExtensions() []string { + return []string{"VK_EXT_metal_surface\x00"} } -// CreateSurface creates a Metal surface from a CAMetalLayer. -// Parameters: -// - _: unused first parameter for API consistency with other platforms -// - metalLayer: Pointer to CAMetalLayer -func (i *Instance) CreateSurface(_, metalLayer uintptr) (hal.Surface, error) { +// CreateSurface creates a Metal surface from a CAMetalLayer target. +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetMetalLayer); err != nil { + return nil, fmt.Errorf("vulkan: %w", err) + } + metalLayer := target.WindowHandle createInfo := vk.MetalSurfaceCreateInfoEXT{ SType: vk.StructureTypeMetalSurfaceCreateInfoExt, } @@ -34,7 +35,7 @@ func (i *Instance) CreateSurface(_, metalLayer uintptr) (hal.Surface, error) { *(*uintptr)(unsafe.Pointer(&createInfo.PLayer)) = metalLayer if !i.cmds.HasCreateMetalSurfaceEXT() { - return nil, fmt.Errorf("vulkan: vkCreateMetalSurfaceEXT not available (VK_EXT_metal_surface extension not loaded)") + return nil, fmt.Errorf("vulkan: %w: vkCreateMetalSurfaceEXT not available (VK_EXT_metal_surface extension not loaded)", hal.ErrUnsupportedSurfaceTarget) } var surface vk.SurfaceKHR diff --git a/hal/vulkan/api_linux.go b/hal/vulkan/api_linux.go index 668af6b4..6914317e 100644 --- a/hal/vulkan/api_linux.go +++ b/hal/vulkan/api_linux.go @@ -7,45 +7,40 @@ package vulkan import ( "fmt" - "os" "unsafe" "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/vulkan/vk" ) -// platformSurfaceExtensions returns all Linux surface extensions to request. -// Both X11 and Wayland extensions are requested; the driver enables what it supports. -func platformSurfaceExtension() string { - // Request both — Vulkan instance creation accepts unsupported extensions gracefully. - // The actual surface creation checks HasCreate*SurfaceKHR at runtime. - if isWayland() { - return "VK_KHR_wayland_surface\x00" +// platformSurfaceExtensions returns every Linux WSI extension the backend can +// use. CreateInstance filters this list against the loader's advertised +// extensions, independent of DISPLAY or WAYLAND_DISPLAY. +func platformSurfaceExtensions() []string { + return []string{ + extensionWaylandSurface, + extensionXlibSurface, } - return "VK_KHR_xlib_surface\x00" } -// isWayland returns true if the session is running under Wayland. -func isWayland() bool { - return os.Getenv("WAYLAND_DISPLAY") != "" -} - -// CreateSurface creates a Vulkan surface from platform-specific handles. -// On Linux, it auto-detects X11 vs Wayland based on available extensions: -// - Wayland: display = wl_display*, window = wl_surface* (from libwayland-client) -// - X11: display = Display* (from libX11), window = X11 Window ID -func (i *Instance) CreateSurface(display, window uintptr) (hal.Surface, error) { - // Try Wayland first if the extension is available - if i.cmds.HasCreateWaylandSurfaceKHR() && isWayland() { - return i.createWaylandSurface(display, window) +// CreateSurface creates a Vulkan surface from an explicit Xlib or Wayland +// target. Both target kinds can coexist in one process; the corresponding +// command is present when the loader advertised that WSI extension. +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + switch target.Kind { + case hal.SurfaceTargetXlibWindow: + if !i.cmds.HasCreateXlibSurfaceKHR() { + return nil, fmt.Errorf("vulkan: %w: vkCreateXlibSurfaceKHR not available", hal.ErrUnsupportedSurfaceTarget) + } + return i.createXlibSurface(target.DisplayHandle, target.WindowHandle) + case hal.SurfaceTargetWaylandSurface: + if !i.cmds.HasCreateWaylandSurfaceKHR() { + return nil, fmt.Errorf("vulkan: %w: vkCreateWaylandSurfaceKHR not available", hal.ErrUnsupportedSurfaceTarget) + } + return i.createWaylandSurface(target.DisplayHandle, target.WindowHandle) + default: + return nil, fmt.Errorf("vulkan: %w: got %s, backend requires Xlib window or Wayland surface", hal.ErrUnsupportedSurfaceTarget, target.Kind) } - - // Fall back to X11 - if i.cmds.HasCreateXlibSurfaceKHR() { - return i.createXlibSurface(display, window) - } - - return nil, fmt.Errorf("vulkan: no surface creation extension available (need VK_KHR_xlib_surface or VK_KHR_wayland_surface)") } // createXlibSurface creates an X11 surface. diff --git a/hal/vulkan/api_linux_test.go b/hal/vulkan/api_linux_test.go new file mode 100644 index 00000000..423eccd5 --- /dev/null +++ b/hal/vulkan/api_linux_test.go @@ -0,0 +1,25 @@ +//go:build linux && !android && !(js && wasm) + +package vulkan + +import ( + "slices" + "testing" +) + +func TestPlatformSurfaceExtensionsIgnoreSessionEnvironment(t *testing.T) { + want := []string{ + extensionWaylandSurface, + extensionXlibSurface, + } + + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + if got := platformSurfaceExtensions(); !slices.Equal(got, want) { + t.Fatalf("Wayland session extensions = %q, want %q", got, want) + } + + t.Setenv("WAYLAND_DISPLAY", "") + if got := platformSurfaceExtensions(); !slices.Equal(got, want) { + t.Fatalf("X11 session extensions = %q, want %q", got, want) + } +} diff --git a/hal/vulkan/api_windows.go b/hal/vulkan/api_windows.go index 12f2a1df..08e82e5b 100644 --- a/hal/vulkan/api_windows.go +++ b/hal/vulkan/api_windows.go @@ -18,13 +18,17 @@ var ( getModuleHandleW = kernel32.NewProc("GetModuleHandleW") ) -// platformSurfaceExtension returns the Windows surface extension. -func platformSurfaceExtension() string { - return "VK_KHR_win32_surface\x00" +// platformSurfaceExtensions returns the Windows surface extension. +func platformSurfaceExtensions() []string { + return []string{"VK_KHR_win32_surface\x00"} } // CreateSurface creates a Windows surface from HINSTANCE and HWND. -func (i *Instance) CreateSurface(hinstance, hwnd uintptr) (hal.Surface, error) { +func (i *Instance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + if err := target.RequireKind(hal.SurfaceTargetWindowsHWND); err != nil { + return nil, fmt.Errorf("vulkan: %w", err) + } + hinstance, hwnd := target.DisplayHandle, target.WindowHandle // If hinstance is 0, get the current module handle if hinstance == 0 { hinstance, _, _ = getModuleHandleW.Call(0) @@ -37,7 +41,7 @@ func (i *Instance) CreateSurface(hinstance, hwnd uintptr) (hal.Surface, error) { } if !i.cmds.HasCreateWin32SurfaceKHR() { - return nil, fmt.Errorf("vulkan: vkCreateWin32SurfaceKHR not available (VK_KHR_win32_surface extension not loaded)") + return nil, fmt.Errorf("vulkan: %w: vkCreateWin32SurfaceKHR not available (VK_KHR_win32_surface extension not loaded)", hal.ErrUnsupportedSurfaceTarget) } var surface vk.SurfaceKHR diff --git a/hal/vulkan/instance_extensions_test.go b/hal/vulkan/instance_extensions_test.go new file mode 100644 index 00000000..761e8487 --- /dev/null +++ b/hal/vulkan/instance_extensions_test.go @@ -0,0 +1,28 @@ +//go:build !(js && wasm) + +package vulkan + +import ( + "slices" + "testing" +) + +func TestSelectAvailableExtensionsPreservesCandidateOrder(t *testing.T) { + candidates := []string{ + extensionWaylandSurface, + extensionXlibSurface, + "VK_EXT_metal_surface\x00", + } + available := map[string]struct{}{ + "VK_KHR_xlib_surface": {}, + "VK_KHR_wayland_surface": {}, + } + want := []string{ + extensionWaylandSurface, + extensionXlibSurface, + } + + if got := selectAvailableExtensions(candidates, available); !slices.Equal(got, want) { + t.Fatalf("selectAvailableExtensions() = %q, want %q", got, want) + } +} diff --git a/instance_browser.go b/instance_browser.go index 2da2952b..79cb9e10 100644 --- a/instance_browser.go +++ b/instance_browser.go @@ -75,7 +75,7 @@ func (i *Instance) RequestAdapter(opts *RequestAdapterOptions) (*Adapter, error) // CreateSurface and CreateSurfaceFromCanvas are defined in surface_browser.go. -// Release releases the instance and all associated resources. +// Release releases the instance. Surfaces must be released explicitly. func (i *Instance) Release() { if i.released { return diff --git a/instance_lifecycle_native_test.go b/instance_lifecycle_native_test.go index de17586e..17405b62 100644 --- a/instance_lifecycle_native_test.go +++ b/instance_lifecycle_native_test.go @@ -48,6 +48,60 @@ func (s *instanceLifecycleConfiguredSurface) Destroy() { *s.events = append(*s.events, "surface-destroy") } +type instanceLifecycleTargetSource struct{} + +func (*instanceLifecycleTargetSource) SurfaceTarget() (SurfaceTargetUnsafe, error) { + return SurfaceTargetFromAndroidNativeWindow(1), nil +} + +type instanceLifecycleTargetSourceSurface struct { + noop.Surface + surface *Surface + events *[]string +} + +func (s *instanceLifecycleTargetSourceSurface) Destroy() { + if s.surface.targetSource == nil { + *s.events = append(*s.events, "source-cleared-before-surface-destroy") + return + } + *s.events = append(*s.events, "surface-destroy-with-source") +} + +func TestSurfaceReleaseRetainsTargetSourceThroughHALDestroy(t *testing.T) { + events := []string{} + targetSource := &instanceLifecycleTargetSource{} + firstRawSurface := &instanceLifecycleTargetSourceSurface{events: &events} + secondRawSurface := &instanceLifecycleTargetSourceSurface{events: &events} + surface := &Surface{ + core: core.NewSurface(firstRawSurface, "target-source-lifecycle-test"), + targetSource: targetSource, + currentBackend: gputypes.BackendVulkan, + surfaceCreated: true, + halSurfaces: map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: firstRawSurface, + gputypes.BackendGL: secondRawSurface, + }, + } + firstRawSurface.surface = surface + secondRawSurface.surface = surface + + surface.Release() + + want := []string{"surface-destroy-with-source", "surface-destroy-with-source"} + if !slices.Equal(events, want) { + t.Fatalf("release events = %v, want %v", events, want) + } + if surface.targetSource != nil { + t.Fatal("surface retained target source after HAL destruction") + } + + surface.Release() + if !slices.Equal(events, want) { + t.Fatalf("idempotent release changed events to %v", events) + } +} + func TestInstanceReleaseDestroysDevicesBeforeSurfaces(t *testing.T) { events := []string{} instance := &Instance{core: core.NewInstanceWithMock(nil)} diff --git a/instance_native.go b/instance_native.go index d4f89802..a517b3fc 100644 --- a/instance_native.go +++ b/instance_native.go @@ -73,8 +73,10 @@ func (i *Instance) RequestAdapter(opts *RequestAdapterOptions) (*Adapter, error) var adapterID core.AdapterID var err error if opts != nil && opts.CompatibleSurface != nil { - halSurface := opts.CompatibleSurface.HAL() - adapterID, err = i.core.RequestAdapterWithSurface(coreOpts, halSurface) + adapterID, err = i.core.RequestAdapterWithSurfaces( + coreOpts, + opts.CompatibleSurface.halSurfacesForAdapterRequest(), + ) } else { adapterID, err = i.core.RequestAdapter(coreOpts) } diff --git a/instance_rust.go b/instance_rust.go index 876e19ba..83588bba 100644 --- a/instance_rust.go +++ b/instance_rust.go @@ -96,7 +96,7 @@ func (i *Instance) RequestAdapter(opts *RequestAdapterOptions) (*Adapter, error) }, nil } -// Release releases the instance and all associated resources. +// Release releases the instance. Surfaces must be released explicitly. func (i *Instance) Release() { if i.released { return diff --git a/surface_backend_native_test.go b/surface_backend_native_test.go new file mode 100644 index 00000000..0771077d --- /dev/null +++ b/surface_backend_native_test.go @@ -0,0 +1,162 @@ +//go:build !rust && !(js && wasm) + +package wgpu + +import ( + "errors" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/core" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/noop" +) + +type surfaceCreateTestInstance struct { + surface hal.Surface + err error + calls []hal.SurfaceTarget +} + +func (i *surfaceCreateTestInstance) CreateSurface(target hal.SurfaceTarget) (hal.Surface, error) { + i.calls = append(i.calls, target) + return i.surface, i.err +} + +func (*surfaceCreateTestInstance) EnumerateAdapters(hal.Surface) []hal.ExposedAdapter { return nil } +func (*surfaceCreateTestInstance) Destroy() {} + +func TestCreateHALSurfacesTriesEveryBackendAndSucceedsIfAny(t *testing.T) { + target := hal.SurfaceTarget{ + Kind: hal.SurfaceTargetXlibWindow, + DisplayHandle: 1, + WindowHandle: 2, + } + unsupported := &surfaceCreateTestInstance{err: hal.ErrUnsupportedSurfaceTarget} + vulkanSurface := &noop.Surface{} + vulkan := &surfaceCreateTestInstance{surface: vulkanSurface} + softwareSurface := &noop.Surface{} + software := &surfaceCreateTestInstance{surface: softwareSurface} + + surfaces, firstBackend, err := createHALSurfaces([]core.HALInstanceEntry{ + {Backend: gputypes.BackendGL, Instance: unsupported}, + {Backend: gputypes.BackendVulkan, Instance: vulkan}, + {Backend: gputypes.BackendEmpty, Instance: software}, + }, target) + if err != nil { + t.Fatalf("createHALSurfaces: %v", err) + } + if firstBackend != gputypes.BackendVulkan { + t.Fatalf("first successful backend = %v, want Vulkan", firstBackend) + } + if len(surfaces) != 2 { + t.Fatalf("surface count = %d, want 2", len(surfaces)) + } + if surfaces[gputypes.BackendVulkan] != vulkanSurface { + t.Fatal("Vulkan surface was not retained under the Vulkan backend") + } + if surfaces[gputypes.BackendEmpty] != softwareSurface { + t.Fatal("software surface was not retained under the software backend") + } + for name, instance := range map[string]*surfaceCreateTestInstance{ + "unsupported": unsupported, + "Vulkan": vulkan, + "software": software, + } { + if len(instance.calls) != 1 || instance.calls[0] != target { + t.Fatalf("%s calls = %+v, want target once", name, instance.calls) + } + } +} + +func TestCreateHALSurfacesReportsFailureOnlyWhenAllBackendsFail(t *testing.T) { + regularErr := errors.New("driver rejected surface") + _, _, err := createHALSurfaces([]core.HALInstanceEntry{ + {Backend: gputypes.BackendGL, Instance: &surfaceCreateTestInstance{err: hal.ErrUnsupportedSurfaceTarget}}, + {Backend: gputypes.BackendVulkan, Instance: &surfaceCreateTestInstance{err: regularErr}}, + }, hal.SurfaceTarget{Kind: hal.SurfaceTargetXlibWindow, DisplayHandle: 1, WindowHandle: 2}) + if !errors.Is(err, regularErr) { + t.Fatalf("createHALSurfaces error = %v, want joined driver error", err) + } + if errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("mixed backend failure = %v, must not collapse to ErrUnsupportedSurfaceTarget", err) + } +} + +func TestCreateHALSurfacesMapsAllUnsupportedFailures(t *testing.T) { + _, _, err := createHALSurfaces([]core.HALInstanceEntry{ + {Backend: gputypes.BackendGL, Instance: &surfaceCreateTestInstance{err: hal.ErrUnsupportedSurfaceTarget}}, + {Backend: gputypes.BackendVulkan, Instance: &surfaceCreateTestInstance{err: hal.ErrUnsupportedSurfaceTarget}}, + }, hal.SurfaceTarget{Kind: hal.SurfaceTargetAndroidNativeWindow, WindowHandle: 1}) + if !errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("createHALSurfaces error = %v, want ErrUnsupportedSurfaceTarget", err) + } +} + +func TestEnsureHALSurfaceSwitchesToRetainedBackendSurface(t *testing.T) { + first := &noop.Surface{} + second := &noop.Surface{} + surface := &Surface{ + core: core.NewSurface(first, "multi-backend-switch"), + currentBackend: gputypes.BackendVulkan, + surfaceCreated: true, + halSurfaces: map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: first, + gputypes.BackendGL: second, + }, + } + + if err := surface.ensureHALSurface(gputypes.BackendGL); err != nil { + t.Fatalf("ensureHALSurface: %v", err) + } + if got := surface.HAL(); got != second { + t.Fatalf("active HAL surface = %v, want retained GL surface %v", got, second) + } + if surface.currentBackend != gputypes.BackendGL { + t.Fatalf("current backend = %v, want GL", surface.currentBackend) + } + + surface.Release() +} + +func TestHALSurfaceForBackendDoesNotFollowActiveBackend(t *testing.T) { + vulkanSurface := &noop.Surface{} + glSurface := &noop.Surface{} + surface := &Surface{ + core: core.NewSurface(vulkanSurface, "adapter-capabilities"), + currentBackend: gputypes.BackendVulkan, + surfaceCreated: true, + halSurfaces: map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: vulkanSurface, + gputypes.BackendGL: glSurface, + }, + } + defer surface.Release() + + if got := surface.halSurfaceForBackend(gputypes.BackendVulkan); got != vulkanSurface { + t.Fatalf("Vulkan adapter surface = %v, want retained Vulkan surface %v", got, vulkanSurface) + } + if got := surface.halSurfaceForBackend(gputypes.BackendGL); got != glSurface { + t.Fatalf("GL adapter surface = %v, want retained GL surface %v", got, glSurface) + } + + if err := surface.ensureHALSurface(gputypes.BackendGL); err != nil { + t.Fatalf("ensureHALSurface(GL): %v", err) + } + if got := surface.halSurfaceForBackend(gputypes.BackendVulkan); got != vulkanSurface { + t.Fatalf("Vulkan adapter surface after GL activation = %v, want %v", got, vulkanSurface) + } + if got := surface.halSurfaceForBackend(gputypes.BackendGL); got != glSurface { + t.Fatalf("GL adapter surface after GL activation = %v, want %v", got, glSurface) + } + if got := surface.halSurfaceForBackend(gputypes.BackendDX12); got != nil { + t.Fatalf("missing DX12 adapter surface = %v, want nil", got) + } + + legacyRaw := &noop.Surface{} + legacy := &Surface{core: core.NewSurface(legacyRaw, "legacy-single-hal")} + defer legacy.Release() + if got := legacy.halSurfaceForBackend(gputypes.BackendGL); got != legacyRaw { + t.Fatalf("legacy single-HAL adapter surface = %v, want active surface %v", got, legacyRaw) + } +} diff --git a/surface_browser.go b/surface_browser.go index cd3a2262..16ba86f0 100644 --- a/surface_browser.go +++ b/surface_browser.go @@ -20,11 +20,16 @@ type Surface struct { device *Device released bool + // targetSource is retained only for CreateSurfaceFromTarget. + targetSource SurfaceTarget + // Cached configuration for GetCurrentTexture texture creation. configFormat TextureFormat } -// CreateSurface creates a rendering surface from an HTML canvas element. +// CreateSurface creates a rendering surface from a legacy numeric canvas +// handle. New code should prefer CreateSurfaceFromTarget, +// CreateSurfaceUnsafe, or CreateSurfaceFromCanvas. // // On browser, displayHandle is ignored and windowHandle is treated as a // numeric canvas element ID (data-raw-handle attribute lookup). If windowHandle @@ -35,6 +40,42 @@ type Surface struct { // Matches Rust wgpu InstanceInterface::create_surface for WebSurface which // uses RawWindowHandle::Web to query the DOM by data-raw-handle attribute. func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, error) { + return i.createSurfaceFromCanvasID(windowHandle, nil) +} + +// CreateSurfaceFromTarget samples a provider once and retains it until the +// surface is released. +func (i *Instance) CreateSurfaceFromTarget(target SurfaceTarget) (*Surface, error) { + if i == nil || i.released { + return nil, ErrReleased + } + rawTarget, err := resolveSurfaceTarget(target) + if err != nil { + return nil, err + } + return i.createSurfaceFromTarget(rawTarget, target) +} + +// CreateSurfaceUnsafe creates a browser surface from a raw numeric canvas ID +// without retaining an ownership source. +func (i *Instance) CreateSurfaceUnsafe(target SurfaceTargetUnsafe) (*Surface, error) { + if i == nil || i.released { + return nil, ErrReleased + } + if err := target.validate(); err != nil { + return nil, err + } + return i.createSurfaceFromTarget(target, nil) +} + +func (i *Instance) createSurfaceFromTarget(target SurfaceTargetUnsafe, targetSource SurfaceTarget) (*Surface, error) { + if target.kind != surfaceTargetWebCanvasID { + return nil, fmt.Errorf("%w: browser backend requires a Web canvas ID", ErrUnsupportedSurfaceTarget) + } + return i.createSurfaceFromCanvasID(target.windowHandle, targetSource) +} + +func (i *Instance) createSurfaceFromCanvasID(windowHandle uintptr, targetSource SurfaceTarget) (*Surface, error) { if i.released { return nil, ErrReleased } @@ -56,7 +97,12 @@ func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, return nil, fmt.Errorf("wgpu: no canvas element found for handle %d", windowHandle) } - return i.createSurfaceFromCanvas(canvas) + surface, err := i.createSurfaceFromCanvas(canvas) + if err != nil { + return nil, err + } + surface.targetSource = targetSource + return surface, nil } // CreateSurfaceFromCanvas creates a rendering surface from a js.Value canvas. @@ -208,6 +254,7 @@ func (s *Surface) Release() { if s.browser != nil { s.browser.Destroy() } + s.targetSource = nil } // SurfaceTexture is a texture acquired from a surface for rendering. diff --git a/surface_native.go b/surface_native.go index c2436801..3d0ea0d4 100644 --- a/surface_native.go +++ b/surface_native.go @@ -3,8 +3,11 @@ package wgpu import ( + "errors" "fmt" "image" + "os" + "runtime" "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/core" @@ -21,62 +24,182 @@ type Surface struct { device *Device released bool - // displayHandle and windowHandle are stored for deferred HAL surface - // re-creation when the device's backend differs from the initially - // selected one (e.g., software adapter via ForceFallbackAdapter when - // the initial surface was created on Vulkan/DX12). - displayHandle uintptr - windowHandle uintptr + // targetSource is retained only for CreateSurfaceFromTarget. Raw handles + // passed through CreateSurfaceUnsafe or the compatibility CreateSurface + // method remain entirely caller-owned. + targetSource SurfaceTarget + + // target is stored for a backend whose initial surface creation failed but + // whose device is later supplied explicitly. + target SurfaceTargetUnsafe currentBackend gputypes.Backend // backend type of the current HAL surface - surfaceCreated bool // true after first ensureHALSurface + surfaceCreated bool // true while core points at a HAL surface + // halSurfaces owns one successfully created surface per enabled backend, + // matching Rust wgpu's surface_per_backend representation. core points at + // exactly one of these at a time to keep its lifecycle state machine small. + halSurfaces map[gputypes.Backend]hal.Surface } -// CreateSurface creates a rendering surface from platform-specific handles. +// CreateSurface creates a rendering surface from legacy platform-specific +// handles. New code should prefer CreateSurfaceFromTarget or +// CreateSurfaceUnsafe so the target kind and ownership contract are explicit. // displayHandle and windowHandle are platform-specific: // - Windows: displayHandle=0, windowHandle=HWND -// - macOS: displayHandle=0, windowHandle=NSView* +// - macOS: displayHandle=0, windowHandle=CAMetalLayer* // - Linux/X11: displayHandle=Display*, windowHandle=Window // - Linux/Wayland: displayHandle=wl_display*, windowHandle=wl_surface* // - Android: displayHandle ignored, windowHandle=ANativeWindow* func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, error) { + return i.createSurface(surfaceTargetFromLegacyHandles(displayHandle, windowHandle), nil) +} + +// CreateSurfaceFromTarget samples a provider once and retains it until the +// surface is released. The provider's native objects must remain valid for the +// same lifetime. +func (i *Instance) CreateSurfaceFromTarget(target SurfaceTarget) (*Surface, error) { + if i.isReleased() { + return nil, ErrReleased + } + rawTarget, err := resolveSurfaceTarget(target) + if err != nil { + return nil, err + } + return i.createSurface(rawTarget, target) +} + +// CreateSurfaceUnsafe creates a surface from raw platform handles without +// retaining an ownership source. Every referenced native object must remain +// valid until the returned Surface is released. +func (i *Instance) CreateSurfaceUnsafe(target SurfaceTargetUnsafe) (*Surface, error) { + if i.isReleased() { + return nil, ErrReleased + } + if err := target.validate(); err != nil { + return nil, err + } + return i.createSurface(target, nil) +} + +func (i *Instance) createSurface(target SurfaceTargetUnsafe, targetSource SurfaceTarget) (*Surface, error) { if i.isReleased() { return nil, ErrReleased } - halInstance := i.core.HALInstance() - if halInstance == nil { + entries := i.core.HALInstanceEntries() + if len(entries) == 0 { return nil, fmt.Errorf("wgpu: no HAL instance available for surface creation") } - halSurface, err := halInstance.CreateSurface(displayHandle, windowHandle) + halTarget, err := target.halTarget() if err != nil { - return nil, fmt.Errorf("wgpu: failed to create surface: %w", err) + return nil, err } - - // Determine the backend of the initial HAL instance. - var initialBackend gputypes.Backend - for b, inst := range i.core.HALInstanceMap() { - if inst == halInstance { - initialBackend = b - break - } + halSurfaces, initialBackend, err := createHALSurfaces(entries, halTarget) + if err != nil { + return nil, err } + halSurface := halSurfaces[initialBackend] coreSurface := core.NewSurface(halSurface, "") surface := &Surface{ core: coreSurface, - displayHandle: displayHandle, - windowHandle: windowHandle, + target: target, + targetSource: targetSource, currentBackend: initialBackend, surfaceCreated: true, + halSurfaces: halSurfaces, } if err := i.adoptSurface(surface); err != nil { - halSurface.Destroy() + destroyHALSurfaces(coreSurface, halSurfaces, initialBackend, true) return nil, err } return surface, nil } +func createHALSurfaces(entries []core.HALInstanceEntry, target hal.SurfaceTarget) (map[gputypes.Backend]hal.Surface, gputypes.Backend, error) { + surfaces := make(map[gputypes.Backend]hal.Surface, len(entries)) + errs := make([]error, 0, len(entries)) + allUnsupported := true + var firstBackend gputypes.Backend + firstSet := false + + for _, entry := range entries { + raw, err := entry.Instance.CreateSurface(target) + if err == nil && raw == nil { + err = fmt.Errorf("backend %v returned a nil surface", entry.Backend) + } + if err != nil { + if !errors.Is(err, hal.ErrUnsupportedSurfaceTarget) { + allUnsupported = false + } + errs = append(errs, fmt.Errorf("backend %v: %w", entry.Backend, err)) + hal.Logger().Debug("wgpu: backend surface creation failed", "backend", entry.Backend, "error", err) + continue + } + surfaces[entry.Backend] = raw + if !firstSet { + firstBackend = entry.Backend + firstSet = true + } + } + + if firstSet { + return surfaces, firstBackend, nil + } + joined := errors.Join(errs...) + if allUnsupported { + return nil, 0, fmt.Errorf("%w: no enabled backend accepted the target: %w", ErrUnsupportedSurfaceTarget, joined) + } + return nil, 0, fmt.Errorf("wgpu: failed to create surface for every enabled backend: %w", joined) +} + +func surfaceTargetFromLegacyHandles(displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { + switch runtime.GOOS { + case "windows": + return SurfaceTargetFromWindowsHWND(displayHandle, windowHandle) + case "darwin": + return SurfaceTargetFromMetalLayer(windowHandle) + case "linux": + if os.Getenv("WAYLAND_DISPLAY") != "" { + return SurfaceTargetFromWaylandSurface(displayHandle, windowHandle) + } + return SurfaceTargetFromXlibWindow(displayHandle, windowHandle) + case "android": + return SurfaceTargetFromAndroidNativeWindow(windowHandle) + default: + return SurfaceTargetUnsafe{ + kind: surfaceTargetInvalid, + displayHandle: displayHandle, + windowHandle: windowHandle, + } + } +} + +func (t SurfaceTargetUnsafe) halTarget() (hal.SurfaceTarget, error) { + var kind hal.SurfaceTargetKind + switch t.kind { + case surfaceTargetWindowsHWND: + kind = hal.SurfaceTargetWindowsHWND + case surfaceTargetXlibWindow: + kind = hal.SurfaceTargetXlibWindow + case surfaceTargetWaylandSurface: + kind = hal.SurfaceTargetWaylandSurface + case surfaceTargetAndroidNativeWindow: + kind = hal.SurfaceTargetAndroidNativeWindow + case surfaceTargetMetalLayer: + kind = hal.SurfaceTargetMetalLayer + case surfaceTargetWebCanvasID: + return hal.SurfaceTarget{}, fmt.Errorf("%w: Web canvas target on native backend", ErrUnsupportedSurfaceTarget) + default: + return hal.SurfaceTarget{}, invalidSurfaceTarget("target kind is unknown") + } + return hal.SurfaceTarget{ + Kind: kind, + DisplayHandle: t.displayHandle, + WindowHandle: t.windowHandle, + }, nil +} + // Configure configures the surface for presentation. // Must be called before GetCurrentTexture(). func (s *Surface) Configure(device *Device, config *SurfaceConfiguration) error { @@ -323,21 +446,44 @@ func (s *Surface) retireDevice(device *Device) { s.device = nil } +func (s *Surface) createHALSurface(backend gputypes.Backend) (hal.Surface, error) { + targetInstance := s.instance.core.HALInstanceForBackend(backend) + if targetInstance == nil { + return nil, fmt.Errorf("wgpu: no HAL instance for backend %v", backend) + } + halTarget, err := s.target.halTarget() + if err != nil { + return nil, err + } + halSurface, err := targetInstance.CreateSurface(halTarget) + if errors.Is(err, hal.ErrUnsupportedSurfaceTarget) { + return nil, fmt.Errorf("%w: backend %v: %w", ErrUnsupportedSurfaceTarget, backend, err) + } + if err != nil { + return nil, fmt.Errorf("wgpu: failed to create surface for backend %v: %w", backend, err) + } + return halSurface, nil +} + // ensureHALSurface creates or re-creates the HAL surface for the given backend. func (s *Surface) ensureHALSurface(backend gputypes.Backend) error { if s.surfaceCreated && s.currentBackend == backend { return nil } - targetInstance := s.instance.core.HALInstanceForBackend(backend) - if targetInstance == nil { - return fmt.Errorf("wgpu: no HAL instance for backend %v", backend) - } - if s.core.RawSurface() != nil { - s.core.RawSurface().Destroy() + halSurface := s.halSurfaces[backend] + if halSurface == nil { + var err error + halSurface, err = s.createHALSurface(backend) + if err != nil { + return err + } + if s.halSurfaces == nil { + s.halSurfaces = make(map[gputypes.Backend]hal.Surface) + } + s.halSurfaces[backend] = halSurface } - halSurface, err := targetInstance.CreateSurface(s.displayHandle, s.windowHandle) - if err != nil { - return fmt.Errorf("wgpu: failed to create surface for backend %v: %w", backend, err) + if s.core.State() != core.SurfaceStateUnconfigured { + s.core.Unconfigure() } s.core.SetRawSurface(halSurface) s.currentBackend = backend @@ -354,6 +500,35 @@ func (s *Surface) HAL() hal.Surface { return s.core.RawSurface() } +// halSurfaceForBackend returns the retained surface that belongs to an +// adapter's backend. Legacy surfaces wrapped from one HAL handle have no +// backend map, so they retain the historical active-surface fallback. +func (s *Surface) halSurfaceForBackend(backend gputypes.Backend) hal.Surface { + if s == nil || s.released || s.core == nil { + return nil + } + if len(s.halSurfaces) != 0 { + return s.halSurfaces[backend] + } + return s.core.RawSurface() +} + +func (s *Surface) halSurfacesForAdapterRequest() map[gputypes.Backend]hal.Surface { + if s == nil || s.released || s.core == nil { + return nil + } + result := make(map[gputypes.Backend]hal.Surface, len(s.halSurfaces)) + for backend, surface := range s.halSurfaces { + result[backend] = surface + } + if len(result) == 0 { + if raw := s.core.RawSurface(); raw != nil { + result[s.currentBackend] = raw + } + } + return result +} + // Release releases the surface. func (s *Surface) Release() { if s.released { @@ -361,13 +536,29 @@ func (s *Surface) Release() { } s.released = true if s.core != nil { - s.core.Destroy() + destroyHALSurfaces(s.core, s.halSurfaces, s.currentBackend, s.surfaceCreated) } s.core = nil + s.halSurfaces = nil if s.instance != nil { s.instance.unregisterSurface(s) s.instance = nil } + s.targetSource = nil +} + +func destroyHALSurfaces(coreSurface *core.Surface, surfaces map[gputypes.Backend]hal.Surface, currentBackend gputypes.Backend, currentSet bool) { + if coreSurface != nil { + coreSurface.Destroy() + } + for backend, surface := range surfaces { + if currentSet && backend == currentBackend { + continue + } + if surface != nil { + surface.Destroy() + } + } } // SurfaceTexture is a texture acquired from a surface for rendering. diff --git a/surface_rust.go b/surface_rust.go index d7822b0c..38ffaece 100644 --- a/surface_rust.go +++ b/surface_rust.go @@ -16,30 +16,65 @@ type Surface struct { device *Device released bool + // targetSource is retained only for CreateSurfaceFromTarget. + targetSource SurfaceTarget + // Cached configuration for texture creation. configFormat TextureFormat configWidth uint32 configHeight uint32 } -// CreateSurface creates a rendering surface from platform-specific handles. -// On Rust backend, dispatches to the platform-appropriate creation method. +// CreateSurface creates a rendering surface from legacy platform-specific +// handles. New code should prefer CreateSurfaceFromTarget or +// CreateSurfaceUnsafe so the target kind and ownership contract are explicit. +// On Rust backend, it dispatches to the platform-appropriate creation method. // displayHandle and windowHandle are platform-specific: // - Windows: displayHandle=HINSTANCE (can be 0), windowHandle=HWND // - macOS: displayHandle=0, windowHandle=CAMetalLayer* // - Linux/X11: displayHandle=Display*, windowHandle=Window // - Linux/Wayland: displayHandle=wl_display*, windowHandle=wl_surface* +// - Android: displayHandle ignored, windowHandle=ANativeWindow* func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, error) { + return i.createSurface(surfaceTargetFromLegacyHandles(displayHandle, windowHandle), nil) +} + +// CreateSurfaceFromTarget samples a provider once and retains it until the +// surface is released. +func (i *Instance) CreateSurfaceFromTarget(target SurfaceTarget) (*Surface, error) { + if i == nil || i.released { + return nil, ErrReleased + } + rawTarget, err := resolveSurfaceTarget(target) + if err != nil { + return nil, err + } + return i.createSurface(rawTarget, target) +} + +// CreateSurfaceUnsafe creates a surface from raw platform handles without +// retaining an ownership source. +func (i *Instance) CreateSurfaceUnsafe(target SurfaceTargetUnsafe) (*Surface, error) { + if i == nil || i.released { + return nil, ErrReleased + } + if err := target.validate(); err != nil { + return nil, err + } + return i.createSurface(target, nil) +} + +func (i *Instance) createSurface(target SurfaceTargetUnsafe, targetSource SurfaceTarget) (*Surface, error) { if i.released { return nil, ErrReleased } - rs, err := createPlatformSurface(i.r, displayHandle, windowHandle) + rs, err := createPlatformSurfaceTarget(i.r, target) if err != nil { return nil, fmt.Errorf("wgpu: failed to create surface: %w", err) } - return &Surface{r: rs}, nil + return &Surface{r: rs, targetSource: targetSource}, nil } // Configure configures the surface for presentation. @@ -155,6 +190,7 @@ func (s *Surface) Release() { if s.r != nil { s.r.Release() } + s.targetSource = nil } // SurfaceTexture is a texture acquired from a surface for rendering. diff --git a/surface_rust_android.go b/surface_rust_android.go new file mode 100644 index 00000000..7c68ebe8 --- /dev/null +++ b/surface_rust_android.go @@ -0,0 +1,20 @@ +//go:build rust && android + +package wgpu + +import ( + "fmt" + + rwgpu "github.com/go-webgpu/webgpu/wgpu" +) + +func surfaceTargetFromLegacyHandles(_, windowHandle uintptr) SurfaceTargetUnsafe { + return SurfaceTargetFromAndroidNativeWindow(windowHandle) +} + +func createPlatformSurfaceTarget(instance *rwgpu.Instance, target SurfaceTargetUnsafe) (*rwgpu.Surface, error) { + if target.kind != surfaceTargetAndroidNativeWindow { + return nil, fmt.Errorf("%w: Android backend requires an ANativeWindow", ErrUnsupportedSurfaceTarget) + } + return instance.CreateSurfaceFromAndroidNativeWindow(target.windowHandle) +} diff --git a/surface_rust_darwin.go b/surface_rust_darwin.go index 8ae0297a..3ff9fb1a 100644 --- a/surface_rust_darwin.go +++ b/surface_rust_darwin.go @@ -2,10 +2,20 @@ package wgpu -import rwgpu "github.com/go-webgpu/webgpu/wgpu" +import ( + "fmt" -// createPlatformSurface creates a rendering surface on macOS via CAMetalLayer. -// On macOS, displayHandle is unused (0) and windowHandle is a CAMetalLayer pointer. -func createPlatformSurface(instance *rwgpu.Instance, _, windowHandle uintptr) (*rwgpu.Surface, error) { - return instance.CreateSurfaceFromMetalLayer(windowHandle) + rwgpu "github.com/go-webgpu/webgpu/wgpu" +) + +func surfaceTargetFromLegacyHandles(_, windowHandle uintptr) SurfaceTargetUnsafe { + return SurfaceTargetFromMetalLayer(windowHandle) +} + +// createPlatformSurfaceTarget creates a rendering surface on macOS via CAMetalLayer. +func createPlatformSurfaceTarget(instance *rwgpu.Instance, target SurfaceTargetUnsafe) (*rwgpu.Surface, error) { + if target.kind != surfaceTargetMetalLayer { + return nil, fmt.Errorf("%w: macOS backend requires a Metal layer", ErrUnsupportedSurfaceTarget) + } + return instance.CreateSurfaceFromMetalLayer(target.windowHandle) } diff --git a/surface_rust_linux.go b/surface_rust_linux.go index c5c09256..d73ed3d8 100644 --- a/surface_rust_linux.go +++ b/surface_rust_linux.go @@ -1,21 +1,30 @@ -//go:build rust && linux +//go:build rust && linux && !android package wgpu import ( + "fmt" "os" rwgpu "github.com/go-webgpu/webgpu/wgpu" ) -// createPlatformSurface creates a rendering surface on Linux. -// Detects Wayland vs X11 based on WAYLAND_DISPLAY environment variable, -// matching the platform detection in gogpu's internal/platform/platform_linux.go -// and the rust backend in gogpu/gpu/backend/rust/rust_linux.go. -func createPlatformSurface(instance *rwgpu.Instance, displayHandle, windowHandle uintptr) (*rwgpu.Surface, error) { +func surfaceTargetFromLegacyHandles(displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { if os.Getenv("WAYLAND_DISPLAY") != "" { - return instance.CreateSurfaceFromWaylandSurface(displayHandle, windowHandle) + return SurfaceTargetFromWaylandSurface(displayHandle, windowHandle) + } + return SurfaceTargetFromXlibWindow(displayHandle, windowHandle) +} + +// createPlatformSurfaceTarget creates a rendering surface from an explicit +// Xlib or Wayland target. Environment detection is limited to the legacy API. +func createPlatformSurfaceTarget(instance *rwgpu.Instance, target SurfaceTargetUnsafe) (*rwgpu.Surface, error) { + switch target.kind { + case surfaceTargetXlibWindow: + return instance.CreateSurfaceFromXlibWindow(target.displayHandle, uint64(target.windowHandle)) + case surfaceTargetWaylandSurface: + return instance.CreateSurfaceFromWaylandSurface(target.displayHandle, target.windowHandle) + default: + return nil, fmt.Errorf("%w: Linux backend requires an Xlib or Wayland target", ErrUnsupportedSurfaceTarget) } - // X11 fallback: window handle must be uint64 for Xlib Window (XID). - return instance.CreateSurfaceFromXlibWindow(displayHandle, uint64(windowHandle)) } diff --git a/surface_rust_windows.go b/surface_rust_windows.go index 359c7350..e1fb5032 100644 --- a/surface_rust_windows.go +++ b/surface_rust_windows.go @@ -2,9 +2,20 @@ package wgpu -import rwgpu "github.com/go-webgpu/webgpu/wgpu" +import ( + "fmt" -// createPlatformSurface creates a rendering surface on Windows via HWND. -func createPlatformSurface(instance *rwgpu.Instance, displayHandle, windowHandle uintptr) (*rwgpu.Surface, error) { - return instance.CreateSurfaceFromWindowsHWND(displayHandle, windowHandle) + rwgpu "github.com/go-webgpu/webgpu/wgpu" +) + +func surfaceTargetFromLegacyHandles(displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { + return SurfaceTargetFromWindowsHWND(displayHandle, windowHandle) +} + +// createPlatformSurfaceTarget creates a rendering surface on Windows via HWND. +func createPlatformSurfaceTarget(instance *rwgpu.Instance, target SurfaceTargetUnsafe) (*rwgpu.Surface, error) { + if target.kind != surfaceTargetWindowsHWND { + return nil, fmt.Errorf("%w: Windows backend requires a Win32 HWND", ErrUnsupportedSurfaceTarget) + } + return instance.CreateSurfaceFromWindowsHWND(target.displayHandle, target.windowHandle) } diff --git a/surface_target.go b/surface_target.go new file mode 100644 index 00000000..e5407ae3 --- /dev/null +++ b/surface_target.go @@ -0,0 +1,170 @@ +package wgpu + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrInvalidSurfaceTarget is returned when a surface target is empty or has +// missing platform handles. +var ErrInvalidSurfaceTarget = errors.New("wgpu: invalid surface target") + +// ErrUnsupportedSurfaceTarget is returned when the selected implementation +// cannot create a surface for the supplied platform target kind. +var ErrUnsupportedSurfaceTarget = errors.New("wgpu: unsupported surface target") + +// SurfaceTarget provides the raw platform target used to create a Surface. +// +// CreateSurfaceFromTarget samples SurfaceTarget once and retains the provider +// until the returned Surface is released. Retaining the provider keeps its Go +// ownership graph alive; the provider remains responsible for ensuring that +// the underlying native display and window stay valid for the same lifetime. +// +// This is the Go equivalent of Rust wgpu's safe SurfaceTarget path. +type SurfaceTarget interface { + SurfaceTarget() (SurfaceTargetUnsafe, error) +} + +type surfaceTargetKind uint8 + +const ( + surfaceTargetInvalid surfaceTargetKind = iota + surfaceTargetWindowsHWND + surfaceTargetXlibWindow + surfaceTargetWaylandSurface + surfaceTargetAndroidNativeWindow + surfaceTargetMetalLayer + surfaceTargetWebCanvasID +) + +// SurfaceTargetUnsafe identifies raw platform handles for surface creation. +// Construct values with one of the SurfaceTargetFrom* functions. +// +// No ownership source is retained for an unsafe target. The caller must keep +// every referenced native object valid until the returned Surface is released. +// This is the Go equivalent of Rust wgpu's SurfaceTargetUnsafe::RawHandle. +type SurfaceTargetUnsafe struct { + kind surfaceTargetKind + displayHandle uintptr + windowHandle uintptr +} + +// 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 { + return SurfaceTargetUnsafe{ + kind: surfaceTargetWindowsHWND, + displayHandle: hinstance, + windowHandle: hwnd, + } +} + +// SurfaceTargetFromXlibWindow returns a raw Xlib Display*/Window target. +func SurfaceTargetFromXlibWindow(display, window uintptr) SurfaceTargetUnsafe { + return SurfaceTargetUnsafe{ + kind: surfaceTargetXlibWindow, + displayHandle: display, + windowHandle: window, + } +} + +// SurfaceTargetFromWaylandSurface returns a raw wl_display*/wl_surface* target. +func SurfaceTargetFromWaylandSurface(display, surface uintptr) SurfaceTargetUnsafe { + return SurfaceTargetUnsafe{ + kind: surfaceTargetWaylandSurface, + displayHandle: display, + windowHandle: surface, + } +} + +// SurfaceTargetFromAndroidNativeWindow returns a raw ANativeWindow* target. +// Android has no meaningful display handle for this operation. +func SurfaceTargetFromAndroidNativeWindow(window uintptr) SurfaceTargetUnsafe { + return SurfaceTargetUnsafe{ + kind: surfaceTargetAndroidNativeWindow, + windowHandle: window, + } +} + +// SurfaceTargetFromMetalLayer returns a raw CAMetalLayer* target. +func SurfaceTargetFromMetalLayer(layer uintptr) SurfaceTargetUnsafe { + return SurfaceTargetUnsafe{ + kind: surfaceTargetMetalLayer, + windowHandle: layer, + } +} + +// SurfaceTargetFromWebCanvasID returns a browser canvas target identified by +// its data-raw-handle attribute. ID zero retains the legacy behavior of using +// the first canvas element in the document. +func SurfaceTargetFromWebCanvasID(id uintptr) SurfaceTargetUnsafe { + return SurfaceTargetUnsafe{ + kind: surfaceTargetWebCanvasID, + windowHandle: id, + } +} + +func (t SurfaceTargetUnsafe) validate() error { + switch t.kind { + case surfaceTargetWindowsHWND: + if t.windowHandle == 0 { + return invalidSurfaceTarget("Win32 HWND is zero") + } + case surfaceTargetXlibWindow: + if t.displayHandle == 0 || t.windowHandle == 0 { + return invalidSurfaceTarget("Xlib Display or Window is zero") + } + case surfaceTargetWaylandSurface: + if t.displayHandle == 0 || t.windowHandle == 0 { + return invalidSurfaceTarget("Wayland display or surface is zero") + } + case surfaceTargetAndroidNativeWindow: + if t.windowHandle == 0 { + return invalidSurfaceTarget("Android ANativeWindow is zero") + } + case surfaceTargetMetalLayer: + if t.windowHandle == 0 { + return invalidSurfaceTarget("Metal layer is zero") + } + case surfaceTargetWebCanvasID: + // Zero intentionally selects the first canvas for compatibility. + case surfaceTargetInvalid: + return invalidSurfaceTarget("target is empty") + default: + return invalidSurfaceTarget("target kind is unknown") + } + return nil +} + +func invalidSurfaceTarget(message string) error { + return fmt.Errorf("%w: %s", ErrInvalidSurfaceTarget, message) +} + +func resolveSurfaceTarget(target SurfaceTarget) (SurfaceTargetUnsafe, error) { + if isNilSurfaceTarget(target) { + return SurfaceTargetUnsafe{}, invalidSurfaceTarget("provider is nil") + } + + rawTarget, err := target.SurfaceTarget() + if err != nil { + return SurfaceTargetUnsafe{}, fmt.Errorf("wgpu: get surface target: %w", err) + } + if err := rawTarget.validate(); err != nil { + return SurfaceTargetUnsafe{}, err + } + return rawTarget, nil +} + +func isNilSurfaceTarget(target SurfaceTarget) bool { + if target == nil { + return true + } + value := reflect.ValueOf(target) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/surface_target_contract_test.go b/surface_target_contract_test.go new file mode 100644 index 00000000..ef3cf600 --- /dev/null +++ b/surface_target_contract_test.go @@ -0,0 +1,6 @@ +package wgpu + +var ( + _ func(*Instance, SurfaceTarget) (*Surface, error) = (*Instance).CreateSurfaceFromTarget + _ func(*Instance, SurfaceTargetUnsafe) (*Surface, error) = (*Instance).CreateSurfaceUnsafe +) diff --git a/surface_target_hal_test.go b/surface_target_hal_test.go new file mode 100644 index 00000000..9e17293d --- /dev/null +++ b/surface_target_hal_test.go @@ -0,0 +1,116 @@ +//go:build !rust && !(js && wasm) + +package wgpu + +import ( + "errors" + "testing" + + "github.com/gogpu/wgpu/hal" +) + +type fixedSurfaceTargetProvider struct { + target SurfaceTargetUnsafe +} + +func (p fixedSurfaceTargetProvider) SurfaceTarget() (SurfaceTargetUnsafe, error) { + return p.target, nil +} + +func TestSurfaceTargetUnsafeMapsToTypedHALTarget(t *testing.T) { + tests := []struct { + name string + target SurfaceTargetUnsafe + want hal.SurfaceTarget + }{ + { + name: "Win32", + target: SurfaceTargetFromWindowsHWND(1, 2), + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetWindowsHWND, DisplayHandle: 1, WindowHandle: 2}, + }, + { + name: "Xlib", + target: SurfaceTargetFromXlibWindow(3, 4), + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetXlibWindow, DisplayHandle: 3, WindowHandle: 4}, + }, + { + name: "Wayland", + target: SurfaceTargetFromWaylandSurface(5, 6), + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetWaylandSurface, DisplayHandle: 5, WindowHandle: 6}, + }, + { + name: "Android", + target: SurfaceTargetFromAndroidNativeWindow(7), + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetAndroidNativeWindow, WindowHandle: 7}, + }, + { + name: "Metal", + target: SurfaceTargetFromMetalLayer(8), + want: hal.SurfaceTarget{Kind: hal.SurfaceTargetMetalLayer, WindowHandle: 8}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := test.target.halTarget() + if err != nil { + t.Fatalf("halTarget: %v", err) + } + if got != test.want { + t.Fatalf("halTarget = %+v, want %+v", got, test.want) + } + }) + } +} + +func TestWebSurfaceTargetIsUnsupportedByNativeHAL(t *testing.T) { + _, err := SurfaceTargetFromWebCanvasID(1).halTarget() + if !errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("halTarget error = %v, want ErrUnsupportedSurfaceTarget", err) + } +} + +func TestSurfaceTargetUnsafeValidationAcceptsValidTargets(t *testing.T) { + targets := []SurfaceTargetUnsafe{ + SurfaceTargetFromWindowsHWND(0, 1), + SurfaceTargetFromXlibWindow(1, 2), + SurfaceTargetFromWaylandSurface(3, 4), + SurfaceTargetFromAndroidNativeWindow(5), + SurfaceTargetFromMetalLayer(6), + SurfaceTargetFromWebCanvasID(0), + } + + for _, target := range targets { + if err := target.validate(); err != nil { + t.Fatalf("validate(%+v): %v", target, err) + } + } +} + +func TestSurfaceTargetUnsafeValidationRejectsUnknownKind(t *testing.T) { + target := SurfaceTargetUnsafe{kind: surfaceTargetKind(255), windowHandle: 1} + if err := target.validate(); !errors.Is(err, ErrInvalidSurfaceTarget) { + t.Fatalf("validate unknown target = %v, want ErrInvalidSurfaceTarget", err) + } +} + +func TestResolveSurfaceTargetValidatesProviderResult(t *testing.T) { + want := SurfaceTargetFromAndroidNativeWindow(7) + got, err := resolveSurfaceTarget(fixedSurfaceTargetProvider{target: want}) + if err != nil { + t.Fatalf("resolveSurfaceTarget(valid): %v", err) + } + if got != want { + t.Fatalf("resolved target = %+v, want %+v", got, want) + } + + _, err = resolveSurfaceTarget(fixedSurfaceTargetProvider{}) + if !errors.Is(err, ErrInvalidSurfaceTarget) { + t.Fatalf("resolveSurfaceTarget(invalid) = %v, want ErrInvalidSurfaceTarget", err) + } + + _, err = resolveSurfaceTarget(nil) + if !errors.Is(err, ErrInvalidSurfaceTarget) { + t.Fatalf("resolveSurfaceTarget(nil) = %v, want ErrInvalidSurfaceTarget", err) + } +} diff --git a/surface_target_linux_test.go b/surface_target_linux_test.go new file mode 100644 index 00000000..cbb62112 --- /dev/null +++ b/surface_target_linux_test.go @@ -0,0 +1,17 @@ +//go:build linux && !rust + +package wgpu + +import "testing" + +func TestLegacySurfaceTargetMappingLinux(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "") + if got, want := surfaceTargetFromLegacyHandles(1, 2), SurfaceTargetFromXlibWindow(1, 2); got != want { + t.Fatalf("Xlib legacy target = %+v, want %+v", got, want) + } + + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + if got, want := surfaceTargetFromLegacyHandles(3, 4), SurfaceTargetFromWaylandSurface(3, 4); got != want { + t.Fatalf("Wayland legacy target = %+v, want %+v", got, want) + } +} diff --git a/surface_target_native_test.go b/surface_target_native_test.go new file mode 100644 index 00000000..18fd9652 --- /dev/null +++ b/surface_target_native_test.go @@ -0,0 +1,119 @@ +//go:build !rust && !(js && wasm) + +package wgpu_test + +import ( + "errors" + "testing" + + "github.com/gogpu/wgpu" +) + +func TestCreateSurfaceUnsafeRejectsInvalidTargets(t *testing.T) { + instance := newInstance(t) + defer instance.Release() + + tests := []struct { + name string + target wgpu.SurfaceTargetUnsafe + }{ + {name: "empty", target: wgpu.SurfaceTargetUnsafe{}}, + {name: "Win32 HWND", target: wgpu.SurfaceTargetFromWindowsHWND(0, 0)}, + {name: "Xlib display", target: wgpu.SurfaceTargetFromXlibWindow(0, 1)}, + {name: "Xlib window", target: wgpu.SurfaceTargetFromXlibWindow(1, 0)}, + {name: "Wayland display", target: wgpu.SurfaceTargetFromWaylandSurface(0, 1)}, + {name: "Wayland surface", target: wgpu.SurfaceTargetFromWaylandSurface(1, 0)}, + {name: "Android native window", target: wgpu.SurfaceTargetFromAndroidNativeWindow(0)}, + {name: "Metal layer", target: wgpu.SurfaceTargetFromMetalLayer(0)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + surface, err := instance.CreateSurfaceUnsafe(test.target) + if surface != nil { + surface.Release() + t.Fatal("CreateSurfaceUnsafe returned a surface for an invalid target") + } + if !errors.Is(err, wgpu.ErrInvalidSurfaceTarget) { + t.Fatalf("CreateSurfaceUnsafe error = %v, want ErrInvalidSurfaceTarget", err) + } + }) + } +} + +type testSurfaceTargetProvider struct { + target wgpu.SurfaceTargetUnsafe + err error + calls int +} + +func (p *testSurfaceTargetProvider) SurfaceTarget() (wgpu.SurfaceTargetUnsafe, error) { + p.calls++ + return p.target, p.err +} + +func TestCreateSurfaceFromTargetRejectsNilProvider(t *testing.T) { + instance := newInstance(t) + defer instance.Release() + + var provider *testSurfaceTargetProvider + surface, err := instance.CreateSurfaceFromTarget(provider) + if surface != nil { + surface.Release() + t.Fatal("CreateSurfaceFromTarget returned a surface for a nil provider") + } + if !errors.Is(err, wgpu.ErrInvalidSurfaceTarget) { + t.Fatalf("CreateSurfaceFromTarget error = %v, want ErrInvalidSurfaceTarget", err) + } +} + +func TestCreateSurfaceFromTargetPropagatesProviderError(t *testing.T) { + instance := newInstance(t) + defer instance.Release() + + wantErr := errors.New("window unavailable") + provider := &testSurfaceTargetProvider{err: wantErr} + surface, err := instance.CreateSurfaceFromTarget(provider) + if surface != nil { + surface.Release() + t.Fatal("CreateSurfaceFromTarget returned a surface after provider failure") + } + if !errors.Is(err, wantErr) { + t.Fatalf("CreateSurfaceFromTarget error = %v, want provider error", err) + } + if provider.calls != 1 { + t.Fatalf("SurfaceTarget calls = %d, want 1", provider.calls) + } +} + +func TestCreateSurfaceFromTargetDoesNotCallProviderAfterInstanceRelease(t *testing.T) { + instance := newInstance(t) + instance.Release() + + provider := &testSurfaceTargetProvider{} + surface, err := instance.CreateSurfaceFromTarget(provider) + if surface != nil { + surface.Release() + t.Fatal("CreateSurfaceFromTarget returned a surface after instance release") + } + if !errors.Is(err, wgpu.ErrReleased) { + t.Fatalf("CreateSurfaceFromTarget error = %v, want ErrReleased", err) + } + if provider.calls != 0 { + t.Fatalf("SurfaceTarget calls = %d, want 0 after instance release", provider.calls) + } +} + +func TestCreateSurfaceUnsafeChecksInstanceBeforeTarget(t *testing.T) { + instance := newInstance(t) + instance.Release() + + surface, err := instance.CreateSurfaceUnsafe(wgpu.SurfaceTargetUnsafe{}) + if surface != nil { + surface.Release() + t.Fatal("CreateSurfaceUnsafe returned a surface after instance release") + } + if !errors.Is(err, wgpu.ErrReleased) { + t.Fatalf("CreateSurfaceUnsafe error = %v, want ErrReleased", err) + } +} From 09c259f8c7c51d3bc77f588a434362f8eda5519a Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 10:07:09 +0300 Subject: [PATCH 06/11] ci(surface): verify Android Rust wrapper parity --- .github/workflows/ci.yml | 1 + scripts/check-android-arm64-preview.sh | 100 +++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7b4aa40..7d5f7912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,7 @@ jobs: env: GOFFI_DIR: ${{ github.workspace }}/.ci/goffi GOFFI_EXPECTED_HEAD: 8ccaae72d877a7af0af4b628bf86e92536e27d88 + GOFFI_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run: | export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/29.0.14206865" ./scripts/check-android-arm64-preview.sh diff --git a/scripts/check-android-arm64-preview.sh b/scripts/check-android-arm64-preview.sh index 1b63de64..2260caf5 100755 --- a/scripts/check-android-arm64-preview.sh +++ b/scripts/check-android-arm64-preview.sh @@ -3,12 +3,70 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) + +sha256_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum + else + shasum -a 256 + fi +} + +worktree_fingerprint() { + local repo=$1 + { + git -C "$repo" diff --binary --no-ext-diff HEAD -- . + git -C "$repo" ls-files --others --exclude-standard | LC_ALL=C sort | + while IFS= read -r file; do + [[ -n "$file" ]] || continue + printf 'UNTRACKED %s\n' "$file" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$repo/$file" | awk '{print $1}' + else + shasum -a 256 "$repo/$file" | awk '{print $1}' + fi + done + } | sha256_stream | awk '{print $1}' +} + +require_checkout() { + local label=$1 + local repo=$2 + local expected_head=$3 + local expected_patch=$4 + local actual_head + local actual_patch + + actual_head=$(git -C "$repo" rev-parse HEAD) + if [[ "$actual_head" != "$expected_head" ]]; then + echo "$label HEAD is $actual_head, want $expected_head" >&2 + exit 1 + fi + git -C "$repo" diff --check + actual_patch=$(worktree_fingerprint "$repo") + if [[ "$actual_patch" != "$expected_patch" ]]; then + echo "$label working-tree fingerprint is $actual_patch, want $expected_patch" >&2 + exit 1 + fi +} + : "${GOFFI_DIR:?set GOFFI_DIR to the checked-out canonical goffi Android candidate}" : "${GOFFI_EXPECTED_HEAD:?set GOFFI_EXPECTED_HEAD to its immutable commit}" +: "${GOFFI_EXPECTED_PATCH:?set GOFFI_EXPECTED_PATCH to its working-tree fingerprint}" goffi_dir=$(cd "$GOFFI_DIR" && pwd -P) actual_head=$(git -C "$goffi_dir" rev-parse HEAD) -if [[ "$actual_head" != "$GOFFI_EXPECTED_HEAD" ]]; then - echo "goffi HEAD is $actual_head, want $GOFFI_EXPECTED_HEAD" >&2 +require_checkout goffi "$goffi_dir" "$GOFFI_EXPECTED_HEAD" "$GOFFI_EXPECTED_PATCH" + +webgpu_dir= +actual_webgpu_head= +if [[ -n "${WEBGPU_DIR:-}" ]]; then + : "${WEBGPU_EXPECTED_HEAD:?set WEBGPU_EXPECTED_HEAD to the helper candidate immutable commit}" + : "${WEBGPU_EXPECTED_PATCH:?set WEBGPU_EXPECTED_PATCH to its working-tree fingerprint}" + webgpu_dir=$(cd "$WEBGPU_DIR" && pwd -P) + actual_webgpu_head=$(git -C "$webgpu_dir" rev-parse HEAD) + require_checkout go-webgpu/webgpu "$webgpu_dir" "$WEBGPU_EXPECTED_HEAD" "$WEBGPU_EXPECTED_PATCH" +elif [[ -n "${WEBGPU_EXPECTED_HEAD:-}" || -n "${WEBGPU_EXPECTED_PATCH:-}" ]]; then + echo "WEBGPU_DIR is required when a go-webgpu/webgpu identity is supplied" >&2 exit 1 fi @@ -30,7 +88,11 @@ tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT ( cd "$tmpdir" - GOWORK=off go work init "$root" "$goffi_dir" + workspace_modules=("$root" "$goffi_dir") + if [[ -n "$webgpu_dir" ]]; then + workspace_modules+=("$webgpu_dir") + fi + GOWORK=off go work init "${workspace_modules[@]}" ) workspace="$tmpdir/go.work" @@ -39,6 +101,13 @@ if [[ "$(cd "$selected_goffi" && pwd -P)" != "$goffi_dir" ]]; then echo "workspace selected goffi from $selected_goffi, want $goffi_dir" >&2 exit 1 fi +if [[ -n "$webgpu_dir" ]]; then + selected_webgpu=$(GOWORK="$workspace" go list -m -f '{{.Dir}}' github.com/go-webgpu/webgpu) + if [[ "$(cd "$selected_webgpu" && pwd -P)" != "$webgpu_dir" ]]; then + echo "workspace selected go-webgpu/webgpu from $selected_webgpu, want $webgpu_dir" >&2 + exit 1 + fi +fi audit_source_selection() { local cgo=$1 @@ -80,6 +149,7 @@ audit_source_selection() { audit_elf() { local binary=$1 local label=$2 + local expected_loader=$3 local dynamic="$tmpdir/$label.dynamic" local strings_file="$tmpdir/$label.strings" @@ -88,7 +158,7 @@ audit_elf() { grep -Eq 'Shared library: \[libc\.so\]' "$dynamic" grep -Eq 'Shared library: \[libdl\.so\]' "$dynamic" - grep -q 'libvulkan.so' "$strings_file" + grep -q "$expected_loader" "$strings_file" if grep -Eq 'Shared library: \[[^]]*\.so\.[0-9]|Shared library: \[libpthread' "$dynamic"; then echo "$label contains a glibc-style or standalone pthread dependency" >&2 @@ -104,7 +174,8 @@ audit_elf() { run_mode() { local cgo=$1 local label="android-arm64-cgo$cgo" - local binary="$tmpdir/$label" + local native_binary="$tmpdir/$label-native" + local rust_binary="$tmpdir/$label-rust" local -a env_args=( "GOWORK=$workspace" "GOOS=android" @@ -119,9 +190,17 @@ run_mode() { ( cd "$root" env "${env_args[@]}" go test -exec=true ./... - env "${env_args[@]}" go build -o "$binary" ./examples/triangle-headless + env "${env_args[@]}" go build -o "$native_binary" ./examples/triangle-headless ) - audit_elf "$binary" "$label" + audit_elf "$native_binary" "$label-native" 'libvulkan.so' + if [[ -n "$webgpu_dir" ]]; then + ( + cd "$root" + env "${env_args[@]}" go test -tags rust -exec=true ./... + env "${env_args[@]}" go build -tags rust -o "$rust_binary" ./examples/triangle-headless + ) + audit_elf "$rust_binary" "$label-rust" 'libwgpu_native.so' + fi } run_mode 0 @@ -132,4 +211,9 @@ if [[ -e "$root/go.work" || -e "$root/go.work.sum" ]]; then exit 1 fi git -C "$root" diff --exit-code -- go.mod go.sum -echo "Android arm64 preview checks passed with goffi $actual_head" +if [[ -n "$webgpu_dir" ]]; then + git -C "$webgpu_dir" diff --exit-code -- go.mod go.sum + echo "Android arm64 native and Rust checks passed with goffi $actual_head and go-webgpu/webgpu $actual_webgpu_head" +else + echo "Android arm64 native checks passed with goffi $actual_head (Rust checks skipped: WEBGPU_DIR not set)" +fi From 4a7253f2f760035100c8812b7381ac2e30f32a3c Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 10:07:20 +0300 Subject: [PATCH 07/11] docs(surface): define typed-target ownership and mapping --- CHANGELOG.md | 11 +++ README.md | 4 + docs/ANDROID.md | 104 +++++++++++++++++------- docs/SURFACE-TARGETS.md | 173 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 docs/SURFACE-TARGETS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aca9e902..4b9fa13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 immutable. Iterates all queue families — ahead of Rust wgpu which hardcodes `queue_family_index = 0`. Contributor: @besmpl (#267). +- **Typed surface targets** — add retained-provider + `CreateSurfaceFromTarget` and explicit raw-handle `CreateSurfaceUnsafe` + paths modeled on Rust `wgpu` v29. The original two-`uintptr` method remains a + compatibility adapter across native, Rust, and browser implementations. The + native path creates one surface per successful enabled backend and qualifies + adapters against their matching backend surface, as Rust `wgpu` does. + +- **Android raw surface target** — route `ANativeWindow*` explicitly through + public, HAL, Vulkan, and Rust-tag surface creation without cgo or Activity/JNI + policy in WGPU. + ### Changed - **Counted indirect draws** — added `RenderPassEncoder.MultiDrawIndirect` and diff --git a/README.md b/README.md index c382b3a8..b0a0376a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ The unreleased Android/arm64 Vulkan implementation is documented separately in [Android Vulkan preview](docs/ANDROID.md). It requires the exact canonical goffi candidate named there and is not yet a released support claim. +New surface integrations should use the explicit safe or unsafe target API +described in [Surface targets](docs/SURFACE-TARGETS.md). The original +two-`uintptr` method remains available as a compatibility adapter. + **Rust FFI backend** (optional, battle-tested wgpu-native drivers): ```bash go build -tags rust diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 20f3699d..dfec9a02 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -6,24 +6,41 @@ claim. Its current scope is Vulkan, arm64, Android API 29 or newer, and both `CGO_ENABLED=0` and `CGO_ENABLED=1`. GLES, software fallback, 32-bit Android, debug callbacks, and API 28 or older are out of scope. -The preview depends on the unreleased canonical goffi work in +The default backend depends on the unreleased canonical goffi work in [go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62), exactly at -`8ccaae72d877a7af0af4b628bf86e92536e27d88`. Neither module uses a forked -module path, a `replace` directive, or a committed `go.work`. Draft CI checks -out that exact canonical commit and creates an ephemeral workspace only for -integration proof. +`8ccaae72d877a7af0af4b628bf86e92536e27d88`. The `rust` build-tag path also +needs the Android surface-source helper proposed in canonical +[`go-webgpu/webgpu`](https://github.com/go-webgpu/webgpu). Neither integration +uses a forked module path, a `replace` directive, or a committed `go.work`. +The proof script creates an ephemeral workspace and verifies the exact heads +and working-tree fingerprints supplied by the caller. ## Host contract -`Instance.CreateSurface` keeps the existing two-`uintptr` API on Android: +New Android hosts should use the explicit raw-target path: -- `displayHandle` is ignored, matching Rust wgpu v29. -- `windowHandle` is the raw, non-null `ANativeWindow*` value. +```go +surface, err := instance.CreateSurfaceUnsafe( + wgpu.SurfaceTargetFromAndroidNativeWindow(nativeWindow), +) +``` + +`nativeWindow` is the raw, non-null `ANativeWindow*` value. Android has no +display handle for this operation. The existing +`Instance.CreateSurface(displayHandle, windowHandle)` method remains a +source-compatible adapter and ignores `displayHandle`, matching Rust `wgpu` +v29. + +Hosts that represent native-window ownership with a Go object can instead +implement `SurfaceTarget` and call `CreateSurfaceFromTarget`. WGPU samples the +provider once and retains it until backend surface destruction completes. +See [Surface targets](SURFACE-TARGETS.md) for the complete safe/unsafe mapping. -The host owns the Activity/native-window lifecycle and retains its application -reference. A successfully created `VkSurfaceKHR` owns Vulkan's separate window -reference until `vkDestroySurfaceKHR`; WGPU therefore does not call -`ANativeWindow_acquire` or `ANativeWindow_release`. +The host owns the Activity/native-window lifecycle. For the unsafe path it must +keep its application reference valid until `Surface.Release` returns. A +successfully created `VkSurfaceKHR` owns Vulkan's separate window reference +until `vkDestroySurfaceKHR`; WGPU therefore does not call +`ANativeWindow_acquire` or `ANativeWindow_release` itself. Surfaces are independent. Creating a second surface does not invalidate the first. When Android replaces a native window, create a surface for the new raw @@ -38,6 +55,10 @@ The semantic oracle is gfx-rs/wgpu v29.0.3 commit | Behavior | Rust v29 location | Go implementation and proof | |----------|-------------------|-----------------------------| +| Safe provider is retained for the surface lifetime | `wgpu/src/api/{instance,surface}.rs` | `SurfaceTarget`, `CreateSurfaceFromTarget`, release-order test | +| Unsafe raw target retains no ownership source | `SurfaceTargetUnsafe::RawHandle`, `create_surface_unsafe` | opaque constructors and `CreateSurfaceUnsafe` validation tests | +| Target kind remains explicit below the public API | `raw-window-handle` enum matching | typed `hal.SurfaceTarget`; backend kind-routing tests | +| Create for every enabled backend; succeed if any succeeds | `wgpu-core/src/instance.rs::create_surface` | per-backend HAL surface map and matching adapter-qualification tests | | Ignore Android display; use only raw `a_native_window` | `wgpu-hal/src/vulkan/instance.rs` | `api_android.go`, `android_surface_policy_test.go` | | Load `libvulkan.so`; require Android WSI extension/commands | Vulkan instance loader | `vk/loader.go`, `vk/commands.go`, source-selection audit | | API 29 uses infinite acquire timeout; API 30+ preserves one second | `swapchain/native.rs::acquire` | `swapchainPlatformPolicy.acquireTimeout` | @@ -52,10 +73,15 @@ filter. The canonical backend's existing application-version request remains unchanged; actual extensions, commands, features, and device evidence decide whether a Vulkan implementation works. -## Exact draft stack +## Proposed review order -The draft is stacked only until its prerequisites merge. Their original exact -heads remain separately reviewable: +The Android Vulkan work remains in +[wgpu#268](https://github.com/gogpu/wgpu/pull/268). This typed-surface change is +best reviewed as a follow-up on that exact head, not folded into unrelated +prerequisites. After predecessors merge, #268 can be rebased to its Android-only +commits and this follow-up can be replayed without changing its public design. + +The independent prerequisites and their original exact heads are: | Prerequisite | Exact head | |--------------|------------| @@ -65,34 +91,54 @@ heads remain separately reviewable: | [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `a3e839f94a12edce98e2496d96e5bd8d3cdd2fc3` | | [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `85eeeb02a278bf4caebf66310b6baad8c328dd87` | | [goffi#62](https://github.com/go-webgpu/goffi/pull/62), Android/Bionic runtime | `8ccaae72d877a7af0af4b628bf86e92536e27d88` | +| `go-webgpu/webgpu` Android surface-source helper | base `351770c3f88ab91014abf9f8a512e58684018917`; separate helper PR required | + +[wgpu#253](https://github.com/gogpu/wgpu/pull/253) is the design precedent for +matching Rust's typed public/HAL seam; it is not a code dependency of Android. +The default Vulkan path needs #268 and goffi#62. The `rust` build-tag path needs +the canonical `go-webgpu/webgpu` helper as well. No WGPU-local copy of that +helper should be merged. ## Reproducing deterministic proof -Use Android NDK r29 and clean checkouts of this branch and the exact goffi -candidate: +Use Android NDK r29 and checkouts of this branch, the exact clean goffi +candidate, and the canonical `go-webgpu/webgpu` helper candidate: ```bash GOFFI_DIR=/path/to/goffi \ GOFFI_EXPECTED_HEAD=8ccaae72d877a7af0af4b628bf86e92536e27d88 \ +GOFFI_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ +WEBGPU_DIR=/path/to/go-webgpu-webgpu \ +WEBGPU_EXPECTED_HEAD=351770c3f88ab91014abf9f8a512e58684018917 \ +WEBGPU_EXPECTED_PATCH=2f4d4b42953d17e843684e99c7b7590214a1fcaa02bf19a80c8503d8b0e26cb2 \ ANDROID_NDK_HOME=/path/to/android-ndk-r29 \ GOTOOLCHAIN=go1.26.5 \ ./scripts/check-android-arm64-preview.sh ``` -The script verifies Android-only source selection, compiles every package and -test in cgo0 and cgo1 modes, builds the headless example, checks the generated -Go and NDK C ABI layouts, and audits ELF dependencies. It requires Bionic -`libc.so`/`libdl.so`, confirms `libvulkan.so`, and rejects glibc sonames, -standalone `libpthread`, desktop WSI, GLES, and software fallback. CI repeats -the proof with Go 1.25.12 and Go 1.26.5. +The helper fingerprint above identifies the four-file helper plus its changelog +entry on that canonical base. Once its helper PR exists, replace the +base/fingerprint pair with the immutable PR head and the clean-tree fingerprint +(`e3b0c442...`). + +The script verifies Android-only source selection; compiles every package and +test for both the default and `rust` implementations in cgo0 and cgo1 modes; +builds each headless example; checks the generated Go and NDK C ABI layouts; +and audits both ELF dependency sets. It requires Bionic `libc.so`/`libdl.so`, +confirms `libvulkan.so` for the default backend and `libwgpu_native.so` for the +Rust backend, and rejects glibc sonames, standalone `libpthread`, desktop WSI, +GLES, and software fallback. Current CI repeats the default lane with Go +1.25.12 and Go 1.26.5. Supplying `WEBGPU_DIR` enables the Rust lane locally; +that lane can become mandatory in CI as soon as the helper has a canonical +immutable ref. These are deterministic cross-build and binary-shape checks. They do not prove process startup, adapter enumeration, surface creation, rendering, presentation, rotation, or lifecycle recovery on a physical device. -Before the preview can become merge-ready, goffi#62 and the WGPU prerequisites -must merge and ship through canonical refs, #268 must rebase to Android-only -commits, and API 29 plus API 30-or-newer arm64 devices must pass cgo0/cgo1 -startup, known-color presentation, rotation, Activity recreation, and repeated -native-window replacement without crashes, hangs, stale frames, or validation -errors. +Before the preview can become a released support claim, goffi#62, the canonical +`go-webgpu/webgpu` helper, and the WGPU prerequisites must merge and ship +through canonical refs; #268 must rebase to Android-only commits; and API 29 +plus API 30-or-newer arm64 devices must pass cgo0/cgo1 startup, known-color +presentation, rotation, Activity recreation, and repeated native-window +replacement without crashes, hangs, stale frames, or validation errors. diff --git a/docs/SURFACE-TARGETS.md b/docs/SURFACE-TARGETS.md new file mode 100644 index 00000000..4e171afc --- /dev/null +++ b/docs/SURFACE-TARGETS.md @@ -0,0 +1,173 @@ +# Surface targets + +Surface creation follows the safe/unsafe ownership split in Rust `wgpu` +v29.0.3, pinned at +[`4cbe6232b2d7c289b6e1a38416a6ae1461a22e81`](https://github.com/gfx-rs/wgpu/tree/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81). +The Go API is additive: the existing two-`uintptr` `CreateSurface` method remains +available as a compatibility adapter, while new code can name its platform +target and ownership mode explicitly. + +## Exhaustive API mapping + +This map covers every exported production symbol added or behaviorally changed +by this follow-up relative to PR #268. Test-only declarations are excluded. +Build-tagged implementations of the same package selector are one logical Go +symbol. The Rust references are the pinned v29.0.3 sources for +[`Instance::create_surface`](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81/wgpu/src/api/instance.rs#L175-L280), +[`SurfaceTarget` and `SurfaceTargetUnsafe`](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81/wgpu/src/api/surface.rs#L254-L458), +and [core surface creation](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81/wgpu-core/src/instance.rs#L218-L290). + +### Root `wgpu` package + +| Go symbol | Rust analogue or explicit Go adaptation | Ownership and error contract | +|-----------|------------------------------------------|------------------------------| +| `ErrInvalidSurfaceTarget` | `CreateSurfaceErrorKind::RawHandle`, represented as an `errors.Is` sentinel because Go has no raw-window-handle error type | Returned for an empty target, typed-nil provider, unknown kind, or missing required handle before a backend call | +| `ErrUnsupportedSurfaceTarget` | `CreateSurfaceError::FailedToCreateSurfaceForAnyBackend`, split from malformed-input errors so callers can distinguish platform support | Returned when an implementation or every enabled native backend rejects the target kind; backend detail remains wrapped | +| `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 | +| `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 | +| `SurfaceTargetFromAndroidNativeWindow` | `RawWindowHandle::AndroidNdk`; Rust Vulkan also ignores the display variant | `ANativeWindow*` is required; caller keeps its native reference alive through release | +| `SurfaceTargetFromMetalLayer` | Rust core's Metal-layer creation path, adapted to the repository's existing `CAMetalLayer*` convention | Layer is required; the Metal HAL takes its own Objective-C retain while the unsafe Go call retains no provider | +| `SurfaceTargetFromWebCanvasID` | `RawWindowHandle::Web` numeric DOM identifier | Zero intentionally preserves the first-canvas convention; non-browser implementations return the unsupported sentinel | +| `(*Instance).CreateSurfaceFromTarget` | Safe `Instance::create_surface` | Checks instance lifetime first, samples and validates once, retains the provider on success, and clears it only after surface teardown | +| `(*Instance).CreateSurfaceUnsafe` | `unsafe Instance::create_surface_unsafe` | Go cannot mark a method unsafe, so the name, opaque constructors, validation, and documentation carry the contract; no provider is retained | +| `(*Instance).CreateSurface` | Go-only source-compatibility adapter to the typed unsafe path | Retains no provider; Linux alone keeps legacy environment-based Xlib/Wayland selection; new code should use an explicit target | +| `(*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).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 | + +### Exported integration seams + +These packages are advanced implementation surfaces, but they are importable Go +packages and therefore still require an explicit parity rationale. Rust's +corresponding layers are the ordered `instance_per_backend` / `surface_per_backend` +maps in core and the [`wgpu-hal::Instance::create_surface`](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81/wgpu-hal/src/lib.rs#L648-L662) +backend trait. + +| Go symbol | Rust analogue or explicit Go adaptation | Ownership and error contract | +|-----------|------------------------------------------|------------------------------| +| `hal.ErrUnsupportedSurfaceTarget` | `wgpu_hal::InstanceError` for an incompatible raw-window-handle variant, made classifiable with `errors.Is` | Must be returned before a foreign target's integers are interpreted as platform pointers | +| `hal.SurfaceTargetKind` | The discriminator of `RawWindowHandle` | Closed enum used only below the public opaque target; no ownership | +| `hal.SurfaceTargetInvalid` | Go validation sentinel; no Rust payload analogue | Never reaches a platform API | +| `hal.SurfaceTargetHeadless` | Go software/noop extension | Carries no handles; accepted only by backends with an explicit headless contract | +| `hal.SurfaceTargetWindowsHWND` | `RawWindowHandle::Win32` | Selects Win32 consumers only | +| `hal.SurfaceTargetXlibWindow` | `RawWindowHandle::Xlib` | Selects Xlib consumers independently of environment variables | +| `hal.SurfaceTargetWaylandSurface` | `RawWindowHandle::Wayland` | Selects Wayland consumers independently of environment variables | +| `hal.SurfaceTargetAndroidNativeWindow` | `RawWindowHandle::AndroidNdk` | Selects `ANativeWindow*`; display is ignored | +| `hal.SurfaceTargetMetalLayer` | Rust Metal-layer surface path | Selects `CAMetalLayer*` | +| `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.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 | +| `hal/metal.(*Instance).CreateSurface` | Rust Metal layer creation | Accepts only `MetalLayer`, validates nonzero, then retains/releases the Objective-C layer internally | +| `hal/noop.(*Instance).CreateSurface` | Go test-backend adaptation | Performs no native access and accepts the target without ownership; not a platform support claim | +| `hal/software.(*Instance).CreateSurface` | Go CPU-presenter adaptation | Accepts only headless or host-appropriate kinds, stores the kind with the handles, and rejects foreign kinds before deferred blit setup | +| `hal/vulkan.Backend.CreateInstance` | Rust Vulkan instance creation enables the loader-supported WSI set rather than selecting one ABI from session state | Enumerates extensions before instance creation, preserves deterministic candidate order, and returns loader/enumeration failures without acquiring window ownership | +| `hal/vulkan.(*Instance).CreateSurface` | Rust Vulkan's [raw-handle match](https://github.com/gfx-rs/wgpu/blob/4cbe6232b2d7c289b6e1a38416a6ae1461a22e81/wgpu-hal/src/vulkan/instance.rs#L879-L922) | Accepts only kinds supported by the build target; Android passes `ANativeWindow*` directly and ignores display; Vulkan errors remain wrapped | +| `core.HALInstanceEntry` and fields `Backend`, `Instance` | One ordered entry in Rust core's `instance_per_backend` | Core retains ownership; snapshots handed upward borrow instances until core destruction | +| `core.(*Instance).HALInstanceEntries` | Ordered iteration over `instance_per_backend` | Returns a copied slice in backend-priority order; copying transfers no HAL ownership | +| `core.(*Instance).RequestAdapterWithSurfaces` | Adapter selection against Rust core's backend-keyed `surface_per_backend` | Borrows the map for the call, gives each adapter only its same-backend surface, and falls back to ordinary selection only for an empty map | +| `core.(*Instance).RequestAdapterWithSurface` | Go-only compatibility wrapper for existing single-HAL callers | Borrows one surface and preserves historical single-backend behavior; root multi-backend code uses the map method | +| `egl.ContextConfig.WindowKind` | Rust EGL's display-handle-selected window-system interface | Optional pointer: `nil` means legacy detection, non-nil forces X11/Wayland/surfaceless; zero-value `ContextConfig` therefore cannot accidentally force X11 | +| `egl.DefaultContextConfig` | Default Rust EGL instance policy | Leaves `WindowKind` nil, so no native object or environment decision is captured early | +| `egl.NewContext` | Rust EGL initialization from an explicit display-handle variant | Honors an explicit kind without consulting opposing session variables; otherwise preserves detection; owns only any display connection it opens itself | +| `egl.GetEGLDisplay` | Existing Go auto-detection entry point | Remains source-compatible and delegates to the private explicit-kind mechanism; errors close any internally opened display owner | + +### Canonical Rust-wrapper dependency + +| Go symbol | Rust / WebGPU-native analogue | Ownership and error contract | +|-----------|--------------------------------|------------------------------| +| `go-webgpu/webgpu/wgpu.(*Instance).CreateSurfaceFromAndroidNativeWindow` | `WGPUSurfaceSourceAndroidNativeWindow` passed to `wgpuInstanceCreateSurface` | Android-only; rejects zero before FFI, keeps the wire descriptor live for the call, retains no `ANativeWindow` reference, and returns `WGPUError` for released instance or null result | + +That wrapper method belongs in canonical `go-webgpu/webgpu`, not this +repository. Its PR and an immutable released revision must land before the +Rust-tag Android lane can be merged here; the WGPU follow-up does not vendor the +ABI struct or add a local `replace`. + +Like Rust `wgpu`, the native implementation attempts surface creation for +every enabled backend and succeeds when at least one backend succeeds. It keeps +one raw surface per successful backend so `CompatibleSurface` qualification and +later configuration always use the surface created by that same backend. + +Rust can express the target as an enum of `raw-window-handle` values and mark a +method `unsafe`. Go has neither tagged unions nor unsafe methods, so the Go +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. + +## Safe provider path + +A provider converts an application-owned window object into a raw target: + +```go +type AndroidWindow struct { + nativeWindow uintptr + // Fields that own or retain the native window belong here. +} + +func (w *AndroidWindow) SurfaceTarget() (wgpu.SurfaceTargetUnsafe, error) { + return wgpu.SurfaceTargetFromAndroidNativeWindow(w.nativeWindow), nil +} + +surface, err := instance.CreateSurfaceFromTarget(window) +``` + +`CreateSurfaceFromTarget` calls `SurfaceTarget` exactly once. On success it +retains the provider until backend surface destruction has completed during +`Surface.Release`. The default native implementation also releases its tracked +surfaces from `Instance.Release`; Rust-tag and browser callers must release each +surface explicitly. This keeps the provider's Go ownership graph reachable. +The provider still must make the underlying native objects valid for that whole +lifetime; finalizers are not a substitute for an explicit platform lifetime +contract. + +## Unsafe raw-target path + +Use a raw target when the host already controls the native object's lifetime: + +```go +surface, err := instance.CreateSurfaceUnsafe( + wgpu.SurfaceTargetFromAndroidNativeWindow(nativeWindow), +) +``` + +No ownership source is retained. Every referenced display, window, surface, or +layer must remain valid until `Surface.Release` returns. A zero required handle +is rejected before any backend call. + +Available constructors are: + +| Constructor | Raw objects | +|-------------|-------------| +| `SurfaceTargetFromWindowsHWND` | optional `HINSTANCE`, required `HWND` | +| `SurfaceTargetFromXlibWindow` | required `Display*` and Xlib `Window` | +| `SurfaceTargetFromWaylandSurface` | required `wl_display*` and `wl_surface*` | +| `SurfaceTargetFromAndroidNativeWindow` | required `ANativeWindow*`; no display | +| `SurfaceTargetFromMetalLayer` | required `CAMetalLayer*` | +| `SurfaceTargetFromWebCanvasID` | numeric `data-raw-handle`; zero selects the first canvas | + +Each implementation accepts only targets it can realize. A mismatched target +returns `ErrUnsupportedSurfaceTarget`; a malformed or empty target returns +`ErrInvalidSurfaceTarget`. Provider errors preserve `errors.Is` identity. +`ErrReleased` takes precedence when the instance has already been released, so +creation never evaluates a provider or target after instance teardown. + +## Compatibility method + +`Instance.CreateSurface(displayHandle, windowHandle)` adapts legacy handles to +the same typed implementation. It remains source-compatible, but new code +should prefer one of the explicit methods above. On Linux only, the legacy +method must still use `WAYLAND_DISPLAY` to distinguish Wayland from Xlib; +typed targets remove that ambiguity. On Android, `displayHandle` is ignored, +matching Rust's Vulkan mapping of `RawWindowHandle::AndroidNdk` directly to +`a_native_window`. + +The browser-specific `CreateSurfaceFromCanvas(js.Value)` remains the preferred +path when the caller already has an `HTMLCanvasElement` or `OffscreenCanvas`. From 99dcdfaad68688d3967a6e6622b14e4bb35cdecd Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 10:24:13 +0300 Subject: [PATCH 08/11] ci(android): pin canonical Rust surface helper --- .github/workflows/ci.yml | 19 ++++++++++++-- docs/ANDROID.md | 54 ++++++++++++++++++++++------------------ docs/SURFACE-TARGETS.md | 10 +++++--- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d5f7912..a480958d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ env: CGO_ENABLED: 0 jobs: - # Draft-only integration against the exact canonical goffi Android candidate. - # The script creates an ephemeral go.work; module metadata stays releasable. + # Integration against the exact pending canonical Android candidates. The + # script creates an ephemeral go.work; module metadata stays releasable. android-arm64-preview: name: Android arm64 preview - Go ${{ matrix.go-version }} runs-on: ubuntu-latest @@ -53,6 +53,18 @@ jobs: working-directory: .ci/goffi run: test "$(git rev-parse HEAD)" = 8ccaae72d877a7af0af4b628bf86e92536e27d88 + - name: Checkout exact WebGPU Android surface candidate + uses: actions/checkout@v4 + with: + repository: go-webgpu/webgpu + ref: refs/pull/24/head + path: .ci/webgpu + persist-credentials: false + + - name: Verify exact WebGPU candidate + working-directory: .ci/webgpu + run: test "$(git rev-parse HEAD)" = 08592c9f5916b64dfc70aba9e67a74a764bb3ef5 + - name: Set up Go uses: actions/setup-go@v5 with: @@ -70,6 +82,9 @@ jobs: GOFFI_DIR: ${{ github.workspace }}/.ci/goffi GOFFI_EXPECTED_HEAD: 8ccaae72d877a7af0af4b628bf86e92536e27d88 GOFFI_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + WEBGPU_DIR: ${{ github.workspace }}/.ci/webgpu + WEBGPU_EXPECTED_HEAD: 08592c9f5916b64dfc70aba9e67a74a764bb3ef5 + WEBGPU_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run: | export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/29.0.14206865" ./scripts/check-android-arm64-preview.sh diff --git a/docs/ANDROID.md b/docs/ANDROID.md index dfec9a02..eb8906ff 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -9,11 +9,18 @@ debug callbacks, and API 28 or older are out of scope. The default backend depends on the unreleased canonical goffi work in [go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62), exactly at `8ccaae72d877a7af0af4b628bf86e92536e27d88`. The `rust` build-tag path also -needs the Android surface-source helper proposed in canonical -[`go-webgpu/webgpu`](https://github.com/go-webgpu/webgpu). Neither integration -uses a forked module path, a `replace` directive, or a committed `go.work`. -The proof script creates an ephemeral workspace and verifies the exact heads -and working-tree fingerprints supplied by the caller. +depends on canonical [go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), +exactly at `08592c9f5916b64dfc70aba9e67a74a764bb3ef5`. Neither integration uses a +forked module path, a `replace` directive, or a committed `go.work`. The proof +script creates an ephemeral workspace and verifies the exact clean heads +supplied by the caller. + +API 29 is the native symbol and application install floor. API 36 is the +downstream Java compile/Play target and the newest runtime-observer endpoint; +it is not a distinct WGPU ABI. NDK r29 publishes native platform wrappers +through API 35, so the native cross-build deliberately targets the supported +API 29 floor instead of inventing an API 36 clang target. Policy tests cover +the API 29 branch and the shared API 30-through-36 branch. ## Host contract @@ -81,17 +88,22 @@ best reviewed as a follow-up on that exact head, not folded into unrelated prerequisites. After predecessors merge, #268 can be rebased to its Android-only commits and this follow-up can be replayed without changing its public design. -The independent prerequisites and their original exact heads are: +The independently reviewable prerequisites and their current exact heads are: | Prerequisite | Exact head | |--------------|------------| -| [wgpu#264](https://github.com/gogpu/wgpu/pull/264), drain before device teardown | `0ed17064f8c977f35d9b49b5cde0d0c69e867ecf` | -| [wgpu#265](https://github.com/gogpu/wgpu/pull/265), fail-closed swapchain negotiation | `a8ff52e340f0a06c8e1b6599a03856d7fc74d1a2` | -| [wgpu#266](https://github.com/gogpu/wgpu/pull/266), explicit mock construction | `e97e4901ee76d9e5f587569c073b38114221c4e6` | -| [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `a3e839f94a12edce98e2496d96e5bd8d3cdd2fc3` | -| [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `85eeeb02a278bf4caebf66310b6baad8c328dd87` | +| [wgpu#264](https://github.com/gogpu/wgpu/pull/264), drain before device teardown | `6acfb1ea1cca1dc14d7ab0902931c678dc13e0a6` | +| [wgpu#265](https://github.com/gogpu/wgpu/pull/265), fail-closed swapchain negotiation | `eb48db0e7eec4c12eaa87cc998f6ea9e8f348fdd` | +| [wgpu#266](https://github.com/gogpu/wgpu/pull/266), explicit mock construction | `e65bec3c74c56cd07265c055ce6a7ec7fbedc680` | +| [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `63efce782d5cf41dacab761d202da0e3d68ed2da` | +| [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `2a069cb1fde942339813efd7199ed305a3a2ca84` | | [goffi#62](https://github.com/go-webgpu/goffi/pull/62), Android/Bionic runtime | `8ccaae72d877a7af0af4b628bf86e92536e27d88` | -| `go-webgpu/webgpu` Android surface-source helper | base `351770c3f88ab91014abf9f8a512e58684018917`; separate helper PR required | +| [webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), Android Rust-wrapper surface source | `08592c9f5916b64dfc70aba9e67a74a764bb3ef5` | + +The current stacked [wgpu#268](https://github.com/gogpu/wgpu/pull/268) head is +`041e19e65620a0a554718a712ead544794a863b5`. Once the five WGPU prerequisites +merge, #268 drops their replayed commits and retains its four Android-owned +commits. This typed-target follow-up is then replayed on that reduced head. [wgpu#253](https://github.com/gogpu/wgpu/pull/253) is the design precedent for matching Rust's typed public/HAL seam; it is not a code dependency of Android. @@ -109,28 +121,22 @@ GOFFI_DIR=/path/to/goffi \ GOFFI_EXPECTED_HEAD=8ccaae72d877a7af0af4b628bf86e92536e27d88 \ GOFFI_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ WEBGPU_DIR=/path/to/go-webgpu-webgpu \ -WEBGPU_EXPECTED_HEAD=351770c3f88ab91014abf9f8a512e58684018917 \ -WEBGPU_EXPECTED_PATCH=2f4d4b42953d17e843684e99c7b7590214a1fcaa02bf19a80c8503d8b0e26cb2 \ +WEBGPU_EXPECTED_HEAD=08592c9f5916b64dfc70aba9e67a74a764bb3ef5 \ +WEBGPU_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ ANDROID_NDK_HOME=/path/to/android-ndk-r29 \ GOTOOLCHAIN=go1.26.5 \ ./scripts/check-android-arm64-preview.sh ``` -The helper fingerprint above identifies the four-file helper plus its changelog -entry on that canonical base. Once its helper PR exists, replace the -base/fingerprint pair with the immutable PR head and the clean-tree fingerprint -(`e3b0c442...`). - The script verifies Android-only source selection; compiles every package and test for both the default and `rust` implementations in cgo0 and cgo1 modes; builds each headless example; checks the generated Go and NDK C ABI layouts; and audits both ELF dependency sets. It requires Bionic `libc.so`/`libdl.so`, confirms `libvulkan.so` for the default backend and `libwgpu_native.so` for the Rust backend, and rejects glibc sonames, standalone `libpthread`, desktop WSI, -GLES, and software fallback. Current CI repeats the default lane with Go -1.25.12 and Go 1.26.5. Supplying `WEBGPU_DIR` enables the Rust lane locally; -that lane can become mandatory in CI as soon as the helper has a canonical -immutable ref. +GLES, and software fallback. Current CI repeats both the default and Rust lanes +with Go 1.25.12 and Go 1.26.5 against the exact clean goffi#62 and webgpu#24 +heads. These are deterministic cross-build and binary-shape checks. They do not prove process startup, adapter enumeration, surface creation, rendering, @@ -139,6 +145,6 @@ presentation, rotation, or lifecycle recovery on a physical device. Before the preview can become a released support claim, goffi#62, the canonical `go-webgpu/webgpu` helper, and the WGPU prerequisites must merge and ship through canonical refs; #268 must rebase to Android-only commits; and API 29 -plus API 30-or-newer arm64 devices must pass cgo0/cgo1 startup, known-color +plus API 36 arm64 devices must pass cgo0/cgo1 startup, known-color presentation, rotation, Activity recreation, and repeated native-window replacement without crashes, hangs, stale frames, or validation errors. diff --git a/docs/SURFACE-TARGETS.md b/docs/SURFACE-TARGETS.md index 4e171afc..dce7fc9a 100644 --- a/docs/SURFACE-TARGETS.md +++ b/docs/SURFACE-TARGETS.md @@ -86,10 +86,12 @@ backend trait. |-----------|--------------------------------|------------------------------| | `go-webgpu/webgpu/wgpu.(*Instance).CreateSurfaceFromAndroidNativeWindow` | `WGPUSurfaceSourceAndroidNativeWindow` passed to `wgpuInstanceCreateSurface` | Android-only; rejects zero before FFI, keeps the wire descriptor live for the call, retains no `ANativeWindow` reference, and returns `WGPUError` for released instance or null result | -That wrapper method belongs in canonical `go-webgpu/webgpu`, not this -repository. Its PR and an immutable released revision must land before the -Rust-tag Android lane can be merged here; the WGPU follow-up does not vendor the -ABI struct or add a local `replace`. +That wrapper method belongs in canonical +[go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), not this +repository. This branch tests its exact clean head +`08592c9f5916b64dfc70aba9e67a74a764bb3ef5`; #24 and an immutable released +revision must land before the Rust-tag Android lane can merge here. The WGPU +follow-up does not vendor the ABI struct or add a local `replace`. Like Rust `wgpu`, the native implementation attempts surface creation for every enabled backend and succeeds when at least one backend succeeds. It keeps From a1b077c2109b3c3c4b5197ead93bdbc1f02f47df Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 21 Jul 2026 15:42:04 +0300 Subject: [PATCH 09/11] build(android): consume canonical goffi v0.6.1 --- .github/workflows/ci.yml | 19 +-------- AGENTS.md | 2 +- docs/ANDROID.md | 59 ++++++++++++-------------- go.mod | 2 +- go.sum | 4 +- scripts/check-android-arm64-preview.sh | 51 +++++++++++++--------- 6 files changed, 64 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a480958d..c8db1c59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ env: CGO_ENABLED: 0 jobs: - # Integration against the exact pending canonical Android candidates. The - # script creates an ephemeral go.work; module metadata stays releasable. + # Integration against canonical goffi and the exact pending WebGPU Android + # candidate. The ephemeral go.work injects only the unpublished candidate. android-arm64-preview: name: Android arm64 preview - Go ${{ matrix.go-version }} runs-on: ubuntu-latest @@ -41,18 +41,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Checkout exact goffi Android candidate - uses: actions/checkout@v4 - with: - repository: go-webgpu/goffi - ref: 8ccaae72d877a7af0af4b628bf86e92536e27d88 - path: .ci/goffi - persist-credentials: false - - - name: Verify exact goffi candidate - working-directory: .ci/goffi - run: test "$(git rev-parse HEAD)" = 8ccaae72d877a7af0af4b628bf86e92536e27d88 - - name: Checkout exact WebGPU Android surface candidate uses: actions/checkout@v4 with: @@ -79,9 +67,6 @@ jobs: - name: Check Android arm64 source, tests, and ELF dependencies env: - GOFFI_DIR: ${{ github.workspace }}/.ci/goffi - GOFFI_EXPECTED_HEAD: 8ccaae72d877a7af0af4b628bf86e92536e27d88 - GOFFI_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 WEBGPU_DIR: ${{ github.workspace }}/.ci/webgpu WEBGPU_EXPECTED_HEAD: 08592c9f5916b64dfc70aba9e67a74a764bb3ef5 WEBGPU_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/AGENTS.md b/AGENTS.md index a3c4a653..8ee35535 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ wgpu (public API — Device, Queue, Buffer, Texture, Pipeline...) ## Current Version -v0.30.22 | Go 1.25+ | Dependencies: naga v0.17.15, gpucontext v0.21.1, gputypes v0.5.1, goffi v0.6.0, webgpu v0.5.3 +v0.30.22 | Go 1.25+ | Dependencies: naga v0.17.15, gpucontext v0.21.1, gputypes v0.5.1, goffi v0.6.1, webgpu v0.5.3 ## Build & Test diff --git a/docs/ANDROID.md b/docs/ANDROID.md index eb8906ff..803b623f 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -6,14 +6,15 @@ claim. Its current scope is Vulkan, arm64, Android API 29 or newer, and both `CGO_ENABLED=0` and `CGO_ENABLED=1`. GLES, software fallback, 32-bit Android, debug callbacks, and API 28 or older are out of scope. -The default backend depends on the unreleased canonical goffi work in -[go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62), exactly at -`8ccaae72d877a7af0af4b628bf86e92536e27d88`. The `rust` build-tag path also -depends on canonical [go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), -exactly at `08592c9f5916b64dfc70aba9e67a74a764bb3ef5`. Neither integration uses a -forked module path, a `replace` directive, or a committed `go.work`. The proof -script creates an ephemeral workspace and verifies the exact clean heads -supplied by the caller. +The default backend consumes canonical +[goffi v0.6.1](https://github.com/go-webgpu/goffi/releases/tag/v0.6.1), released +from [go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62). The +`rust` build-tag path also depends on canonical +[go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), exactly at +`08592c9f5916b64dfc70aba9e67a74a764bb3ef5`. goffi is declared directly in +`go.mod` and verified by `go.sum`; only the unreleased WebGPU candidate is +injected through an ephemeral workspace. There is no forked module path, +`replace` directive, or committed `go.work`. API 29 is the native symbol and application install floor. API 36 is the downstream Java compile/Play target and the newest runtime-observer endpoint; @@ -97,29 +98,25 @@ The independently reviewable prerequisites and their current exact heads are: | [wgpu#266](https://github.com/gogpu/wgpu/pull/266), explicit mock construction | `e65bec3c74c56cd07265c055ce6a7ec7fbedc680` | | [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `63efce782d5cf41dacab761d202da0e3d68ed2da` | | [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `2a069cb1fde942339813efd7199ed305a3a2ca84` | -| [goffi#62](https://github.com/go-webgpu/goffi/pull/62), Android/Bionic runtime | `8ccaae72d877a7af0af4b628bf86e92536e27d88` | | [webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), Android Rust-wrapper surface source | `08592c9f5916b64dfc70aba9e67a74a764bb3ef5` | The current stacked [wgpu#268](https://github.com/gogpu/wgpu/pull/268) head is -`041e19e65620a0a554718a712ead544794a863b5`. Once the five WGPU prerequisites +`13b59db954dafd2653e6cb5ce3ea14c022782ad2`. Once the five WGPU prerequisites merge, #268 drops their replayed commits and retains its four Android-owned commits. This typed-target follow-up is then replayed on that reduced head. [wgpu#253](https://github.com/gogpu/wgpu/pull/253) is the design precedent for matching Rust's typed public/HAL seam; it is not a code dependency of Android. -The default Vulkan path needs #268 and goffi#62. The `rust` build-tag path needs -the canonical `go-webgpu/webgpu` helper as well. No WGPU-local copy of that -helper should be merged. +The default Vulkan path needs #268 and goffi v0.6.1. The `rust` build-tag path +needs the canonical `go-webgpu/webgpu` helper as well. No WGPU-local copy of +that helper should be merged. ## Reproducing deterministic proof -Use Android NDK r29 and checkouts of this branch, the exact clean goffi -candidate, and the canonical `go-webgpu/webgpu` helper candidate: +Use Android NDK r29 and clean checkouts of this branch and the canonical +`go-webgpu/webgpu` helper candidate: ```bash -GOFFI_DIR=/path/to/goffi \ -GOFFI_EXPECTED_HEAD=8ccaae72d877a7af0af4b628bf86e92536e27d88 \ -GOFFI_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ WEBGPU_DIR=/path/to/go-webgpu-webgpu \ WEBGPU_EXPECTED_HEAD=08592c9f5916b64dfc70aba9e67a74a764bb3ef5 \ WEBGPU_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ @@ -128,23 +125,23 @@ GOTOOLCHAIN=go1.26.5 \ ./scripts/check-android-arm64-preview.sh ``` -The script verifies Android-only source selection; compiles every package and -test for both the default and `rust` implementations in cgo0 and cgo1 modes; -builds each headless example; checks the generated Go and NDK C ABI layouts; -and audits both ELF dependency sets. It requires Bionic `libc.so`/`libdl.so`, +The script first asserts that the module graph selects canonical goffi v0.6.1. +It then verifies Android-only source selection; compiles every package and test +for both the default and `rust` implementations in cgo0 and cgo1 modes; builds +each headless example; checks the generated Go and NDK C ABI layouts; and +audits both ELF dependency sets. It requires Bionic `libc.so`/`libdl.so`, confirms `libvulkan.so` for the default backend and `libwgpu_native.so` for the Rust backend, and rejects glibc sonames, standalone `libpthread`, desktop WSI, -GLES, and software fallback. Current CI repeats both the default and Rust lanes -with Go 1.25.12 and Go 1.26.5 against the exact clean goffi#62 and webgpu#24 -heads. +GLES, and software fallback. Current CI repeats both lanes with Go 1.25.12 and +Go 1.26.5 against canonical goffi v0.6.1 and the exact clean webgpu#24 head. These are deterministic cross-build and binary-shape checks. They do not prove process startup, adapter enumeration, surface creation, rendering, presentation, rotation, or lifecycle recovery on a physical device. -Before the preview can become a released support claim, goffi#62, the canonical -`go-webgpu/webgpu` helper, and the WGPU prerequisites must merge and ship -through canonical refs; #268 must rebase to Android-only commits; and API 29 -plus API 36 arm64 devices must pass cgo0/cgo1 startup, known-color -presentation, rotation, Activity recreation, and repeated native-window -replacement without crashes, hangs, stale frames, or validation errors. +Before the preview can become a released support claim, the canonical +`go-webgpu/webgpu` helper and the WGPU prerequisites must merge and ship through +canonical refs; #268 must rebase to Android-only commits; and API 29 plus API +36 arm64 devices must pass startup, known-color presentation, rotation, +Activity recreation, and repeated native-window replacement without crashes, +hangs, stale frames, or validation errors. diff --git a/go.mod b/go.mod index f9193562..8fe08e8c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/gogpu/wgpu go 1.25.0 require ( - github.com/go-webgpu/goffi v0.6.0 + github.com/go-webgpu/goffi v0.6.1 github.com/go-webgpu/webgpu v0.5.3 github.com/gogpu/gpucontext v0.21.1 github.com/gogpu/gputypes v0.5.1 diff --git a/go.sum b/go.sum index c2d46091..a1998f9a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/go-webgpu/goffi v0.6.0 h1:dTBwfzj8CZUW0w0fgeMaYGBrIktK7nzfjMsnSpkSt4Y= -github.com/go-webgpu/goffi v0.6.0/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= +github.com/go-webgpu/goffi v0.6.1 h1:XEJD4f4qb6RKrSKh4fB2jxO7t/V0r/BdoXJhRMHopCA= +github.com/go-webgpu/goffi v0.6.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/go-webgpu/webgpu v0.5.3 h1:EFinkgY9eSNBsougS8Z+m5v4Ue8k2B1w9G77Dvh1wTQ= github.com/go-webgpu/webgpu v0.5.3/go.mod h1:/kJpg7pKvyQqjg6ewvQBc9HmS2FuzVESRqopbdaaYW8= github.com/gogpu/gpucontext v0.21.1 h1:/X96uCLG8QtQVfGPYPz8ycnfsAg0iTyzqzEadWBUupU= diff --git a/scripts/check-android-arm64-preview.sh b/scripts/check-android-arm64-preview.sh index 2260caf5..2ddd6aee 100755 --- a/scripts/check-android-arm64-preview.sh +++ b/scripts/check-android-arm64-preview.sh @@ -3,6 +3,7 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +module_state_before=$(git -C "$root" hash-object go.mod go.sum) sha256_stream() { if command -v sha256sum >/dev/null 2>&1; then @@ -50,12 +51,12 @@ require_checkout() { fi } -: "${GOFFI_DIR:?set GOFFI_DIR to the checked-out canonical goffi Android candidate}" -: "${GOFFI_EXPECTED_HEAD:?set GOFFI_EXPECTED_HEAD to its immutable commit}" -: "${GOFFI_EXPECTED_PATCH:?set GOFFI_EXPECTED_PATCH to its working-tree fingerprint}" -goffi_dir=$(cd "$GOFFI_DIR" && pwd -P) -actual_head=$(git -C "$goffi_dir" rev-parse HEAD) -require_checkout goffi "$goffi_dir" "$GOFFI_EXPECTED_HEAD" "$GOFFI_EXPECTED_PATCH" +goffi_module=github.com/go-webgpu/goffi +goffi_version=v0.6.1 +( + cd "$root" + GOWORK=off go mod download "$goffi_module@$goffi_version" +) webgpu_dir= actual_webgpu_head= @@ -86,19 +87,23 @@ fi tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT -( - cd "$tmpdir" - workspace_modules=("$root" "$goffi_dir") - if [[ -n "$webgpu_dir" ]]; then - workspace_modules+=("$webgpu_dir") - fi - GOWORK=off go work init "${workspace_modules[@]}" -) -workspace="$tmpdir/go.work" +workspace=off +if [[ -n "$webgpu_dir" ]]; then + ( + cd "$tmpdir" + GOWORK=off go work init "$root" "$webgpu_dir" + ) + workspace="$tmpdir/go.work" +fi -selected_goffi=$(GOWORK="$workspace" go list -m -f '{{.Dir}}' github.com/go-webgpu/goffi) -if [[ "$(cd "$selected_goffi" && pwd -P)" != "$goffi_dir" ]]; then - echo "workspace selected goffi from $selected_goffi, want $goffi_dir" >&2 +actual_goffi_version=$(GOWORK="$workspace" go list -m -f '{{.Version}}' "$goffi_module") +if [[ "$actual_goffi_version" != "$goffi_version" ]]; then + echo "selected goffi version is $actual_goffi_version, want $goffi_version" >&2 + exit 1 +fi +goffi_replacement=$(GOWORK="$workspace" go list -m -f '{{with .Replace}}{{.Path}} {{.Version}} {{.Dir}}{{end}}' "$goffi_module") +if [[ -n "$goffi_replacement" ]]; then + echo "goffi must come from its canonical module release, not replacement $goffi_replacement" >&2 exit 1 fi if [[ -n "$webgpu_dir" ]]; then @@ -210,10 +215,14 @@ if [[ -e "$root/go.work" || -e "$root/go.work.sum" ]]; then echo "preview proof must not leave a committed/local workspace in the WGPU tree" >&2 exit 1 fi -git -C "$root" diff --exit-code -- go.mod go.sum +module_state_after=$(git -C "$root" hash-object go.mod go.sum) +if [[ "$module_state_after" != "$module_state_before" ]]; then + echo "preview proof modified go.mod or go.sum" >&2 + exit 1 +fi if [[ -n "$webgpu_dir" ]]; then git -C "$webgpu_dir" diff --exit-code -- go.mod go.sum - echo "Android arm64 native and Rust checks passed with goffi $actual_head and go-webgpu/webgpu $actual_webgpu_head" + echo "Android arm64 native and Rust checks passed with goffi $actual_goffi_version and go-webgpu/webgpu $actual_webgpu_head" else - echo "Android arm64 native checks passed with goffi $actual_head (Rust checks skipped: WEBGPU_DIR not set)" + echo "Android arm64 native checks passed with goffi $actual_goffi_version (Rust checks skipped: WEBGPU_DIR not set)" fi From 6c51256c49b741bfb3ac3cd4583f8333bb748963 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 23 Jul 2026 22:07:41 +0300 Subject: [PATCH 10/11] test(surface): cover typed target lifecycle paths --- core/instance_surface_test.go | 48 +++ core/instance_test.go | 36 +++ hal/vulkan/swapchain.go | 6 +- hal/vulkan/swapchain_platform.go | 11 - hal/vulkan/swapchain_platform_test.go | 8 +- surface_backend_native_test.go | 407 ++++++++++++++++++++++++++ surface_native.go | 32 +- surface_target_hal_test.go | 5 + 8 files changed, 532 insertions(+), 21 deletions(-) diff --git a/core/instance_surface_test.go b/core/instance_surface_test.go index b9bbea42..ba853bc9 100644 --- a/core/instance_surface_test.go +++ b/core/instance_surface_test.go @@ -90,6 +90,54 @@ func TestRequestAdapterWithSurfacesUsesEachBackendsOwnSurface(t *testing.T) { } } +func TestRequestAdapterWithSurfacesEmptyMapUsesOrdinaryPath(t *testing.T) { + GetGlobal().Clear() + instance := NewInstanceWithMock(nil) + defer instance.Destroy() + + ordinary, err := instance.RequestAdapter(nil) + if err != nil { + t.Fatalf("RequestAdapter: %v", err) + } + selected, err := instance.RequestAdapterWithSurfaces(nil, map[gputypes.Backend]hal.Surface{}) + if err != nil { + t.Fatalf("RequestAdapterWithSurfaces(empty): %v", err) + } + if selected != ordinary { + t.Fatalf("empty-map selection = %v, want ordinary adapter %v", selected, ordinary) + } +} + +func TestRequestAdapterWithSurfacesSkipsBackendWithoutSurface(t *testing.T) { + GetGlobal().Clear() + hub := GetGlobal().Hub() + qualifications := 0 + id := hub.RegisterAdapter(&Adapter{ + Info: gputypes.AdapterInfo{ + DeviceType: gputypes.DeviceTypeDiscreteGPU, + Backend: gputypes.BackendVulkan, + }, + Limits: gputypes.DefaultLimits(), + Backend: gputypes.BackendVulkan, + halAdapter: &surfaceQualificationAdapter{compatible: true, qualifies: &qualifications}, + }) + instance := &Instance{ + backends: gputypes.BackendsVulkan, + adapters: []AdapterID{id}, + } + defer instance.Destroy() + + _, err := instance.RequestAdapterWithSurfaces(nil, map[gputypes.Backend]hal.Surface{ + gputypes.BackendGL: &stubHALSurface{id: 23}, + }) + if err == nil { + t.Fatal("RequestAdapterWithSurfaces selected an adapter without a matching surface") + } + if qualifications != 0 { + t.Fatalf("adapter qualifications = %d, want 0", qualifications) + } +} + func TestRequestAdapterWithSurfaceReleasesUnselectedQualifiedAdapters(t *testing.T) { GetGlobal().Clear() hub := GetGlobal().Hub() diff --git a/core/instance_test.go b/core/instance_test.go index 43f39e4e..fba2a884 100644 --- a/core/instance_test.go +++ b/core/instance_test.go @@ -181,6 +181,42 @@ func TestNewInstanceUsesRegisteredProviderWithoutEnablingMock(t *testing.T) { } } +func TestNewInstanceRecordsDeferredGLESHALInstanceEntry(t *testing.T) { + tracker := &trackingGLESInstance{} + providersMu.Lock() + savedProviders := providers + providers = map[gputypes.Backend]BackendProvider{ + gputypes.BackendGL: &testProvider{ + variant: gputypes.BackendGL, + available: true, + instance: tracker, + }, + } + providersMu.Unlock() + t.Cleanup(func() { + providersMu.Lock() + providers = savedProviders + providersMu.Unlock() + }) + GetGlobal().Clear() + + instance := NewInstance(&gputypes.InstanceDescriptor{Backends: gputypes.BackendsGL}) + t.Cleanup(instance.Destroy) + entries := instance.HALInstanceEntries() + if len(entries) != 1 { + t.Fatalf("HAL instance entries = %d, want 1", len(entries)) + } + if entries[0].Backend != gputypes.BackendGL || entries[0].Instance != tracker { + t.Fatalf("HAL instance entry = %+v, want GL tracker", entries[0]) + } + + entries[0] = HALInstanceEntry{} + snapshot := instance.HALInstanceEntries() + if len(snapshot) != 1 || snapshot[0].Instance != tracker { + t.Fatal("HALInstanceEntries returned an aliased slice") + } +} + func TestNewInstanceWithMockIsExplicit(t *testing.T) { GetGlobal().Clear() diff --git a/hal/vulkan/swapchain.go b/hal/vulkan/swapchain.go index dcae9578..94cc8760 100644 --- a/hal/vulkan/swapchain.go +++ b/hal/vulkan/swapchain.go @@ -534,7 +534,7 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati var swapchainHandle vk.SwapchainKHR result := vkCreateSwapchainKHR(device, &createInfo, nil, &swapchainHandle) if result != vk.Success { - return mapSwapchainCreateResult(result) + return swapchainCreateError(result) } // Get swapchain images @@ -894,8 +894,10 @@ func swapchainCreateError(result vk.Result) error { // common WSI implementations it means the native surface can no longer // produce a swapchain and retrying the same handle cannot recover. return hal.ErrSurfaceLost + case vk.ErrorNativeWindowInUseKhr: + return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: native window is already in use") default: - return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: %d", result) + return mapVulkanResult("vkCreateSwapchainKHR", result) } } diff --git a/hal/vulkan/swapchain_platform.go b/hal/vulkan/swapchain_platform.go index c967eba0..2980f8d1 100644 --- a/hal/vulkan/swapchain_platform.go +++ b/hal/vulkan/swapchain_platform.go @@ -67,17 +67,6 @@ func swapchainPolicyForSurface(surface *Surface) swapchainPlatformPolicy { return surface.instance.platform.swapchain } -func mapSwapchainCreateResult(result vk.Result) error { - switch result { - case vk.ErrorSurfaceLostKhr, vk.ErrorInitializationFailed: - return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: %w", hal.ErrSurfaceLost) - case vk.ErrorNativeWindowInUseKhr: - return fmt.Errorf("vulkan: vkCreateSwapchainKHR failed: native window is already in use") - default: - return mapVulkanResult("vkCreateSwapchainKHR", result) - } -} - // mapVulkanResult preserves the typed HAL errors callers use for recovery. func mapVulkanResult(operation string, result vk.Result) error { switch result { diff --git a/hal/vulkan/swapchain_platform_test.go b/hal/vulkan/swapchain_platform_test.go index b811f9bf..7c0f12fd 100644 --- a/hal/vulkan/swapchain_platform_test.go +++ b/hal/vulkan/swapchain_platform_test.go @@ -96,13 +96,13 @@ func TestMapVulkanResultPreservesRecoverableErrors(t *testing.T) { } } -func TestMapSwapchainCreateResultPreservesSurfaceErrors(t *testing.T) { +func TestSwapchainCreateErrorPreservesSurfaceErrors(t *testing.T) { for _, result := range []vk.Result{vk.ErrorSurfaceLostKhr, vk.ErrorInitializationFailed} { - if err := mapSwapchainCreateResult(result); !errors.Is(err, hal.ErrSurfaceLost) { - t.Fatalf("mapSwapchainCreateResult(%d) = %v, want ErrSurfaceLost", result, err) + if err := swapchainCreateError(result); !errors.Is(err, hal.ErrSurfaceLost) { + t.Fatalf("swapchainCreateError(%d) = %v, want ErrSurfaceLost", result, err) } } - err := mapSwapchainCreateResult(vk.ErrorNativeWindowInUseKhr) + err := swapchainCreateError(vk.ErrorNativeWindowInUseKhr) if err == nil || errors.Is(err, hal.ErrSurfaceLost) { t.Fatalf("native-window-in-use error = %v, want untyped ownership conflict", err) } diff --git a/surface_backend_native_test.go b/surface_backend_native_test.go index 0771077d..6b421383 100644 --- a/surface_backend_native_test.go +++ b/surface_backend_native_test.go @@ -4,12 +4,14 @@ package wgpu import ( "errors" + "runtime" "testing" "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/core" "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/noop" + "github.com/gogpu/wgpu/hal/software" ) type surfaceCreateTestInstance struct { @@ -26,6 +28,54 @@ func (i *surfaceCreateTestInstance) CreateSurface(target hal.SurfaceTarget) (hal func (*surfaceCreateTestInstance) EnumerateAdapters(hal.Surface) []hal.ExposedAdapter { return nil } func (*surfaceCreateTestInstance) Destroy() {} +type surfaceTargetTestProvider struct { + target SurfaceTargetUnsafe + calls int +} + +func (p *surfaceTargetTestProvider) SurfaceTarget() (SurfaceTargetUnsafe, error) { + p.calls++ + return p.target, nil +} + +type surfaceDestroyCounter struct { + noop.Surface + destroys int +} + +func (s *surfaceDestroyCounter) Destroy() { + s.destroys++ +} + +func newSoftwareSurfaceTestInstance(t *testing.T) *Instance { + t.Helper() + hal.RegisterBackend(software.API{}) + instance, err := CreateInstance(&InstanceDescriptor{Backends: BackendsAll}) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(instance.Release) + if instance.core.HALInstanceForBackend(gputypes.BackendEmpty) == nil { + t.Fatal("software HAL instance was not registered") + } + return instance +} + +func currentPlatformSurfaceTarget(t *testing.T) SurfaceTargetUnsafe { + t.Helper() + switch runtime.GOOS { + case "windows": + return SurfaceTargetFromWindowsHWND(1, 2) + case "darwin": + return SurfaceTargetFromMetalLayer(2) + case "linux": + return SurfaceTargetFromXlibWindow(1, 2) + default: + t.Skipf("no software window target for %s", runtime.GOOS) + return SurfaceTargetUnsafe{} + } +} + func TestCreateHALSurfacesTriesEveryBackendAndSucceedsIfAny(t *testing.T) { target := hal.SurfaceTarget{ Kind: hal.SurfaceTargetXlibWindow, @@ -81,6 +131,13 @@ func TestCreateHALSurfacesReportsFailureOnlyWhenAllBackendsFail(t *testing.T) { if errors.Is(err, ErrUnsupportedSurfaceTarget) { t.Fatalf("mixed backend failure = %v, must not collapse to ErrUnsupportedSurfaceTarget", err) } + + _, _, err = createHALSurfaces([]core.HALInstanceEntry{ + {Backend: gputypes.BackendVulkan, Instance: &surfaceCreateTestInstance{}}, + }, hal.SurfaceTarget{Kind: hal.SurfaceTargetXlibWindow, DisplayHandle: 1, WindowHandle: 2}) + if err == nil || errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("nil backend surface error = %v, want ordinary backend failure", err) + } } func TestCreateHALSurfacesMapsAllUnsupportedFailures(t *testing.T) { @@ -160,3 +217,353 @@ func TestHALSurfaceForBackendDoesNotFollowActiveBackend(t *testing.T) { t.Fatalf("legacy single-HAL adapter surface = %v, want active surface %v", got, legacyRaw) } } + +func TestSurfaceTargetFromLegacyHandlesForPlatform(t *testing.T) { + tests := []struct { + name string + goos string + waylandDisplay string + display, window uintptr + want SurfaceTargetUnsafe + }{ + { + name: "Windows", + goos: "windows", display: 1, window: 2, + want: SurfaceTargetFromWindowsHWND(1, 2), + }, + { + name: "Darwin", + goos: "darwin", display: 1, window: 2, + want: SurfaceTargetFromMetalLayer(2), + }, + { + name: "Linux Xlib", + goos: "linux", display: 1, window: 2, + want: SurfaceTargetFromXlibWindow(1, 2), + }, + { + name: "Linux Wayland", goos: "linux", waylandDisplay: "wayland-0", display: 1, window: 2, + want: SurfaceTargetFromWaylandSurface(1, 2), + }, + { + name: "Android", + goos: "android", display: 1, window: 2, + want: SurfaceTargetFromAndroidNativeWindow(2), + }, + { + name: "unsupported", + goos: "plan9", display: 1, window: 2, + want: SurfaceTargetUnsafe{kind: surfaceTargetInvalid, displayHandle: 1, windowHandle: 2}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := surfaceTargetFromLegacyHandlesForPlatform( + test.goos, + test.waylandDisplay, + test.display, + test.window, + ) + if got != test.want { + t.Fatalf("legacy target = %+v, want %+v", got, test.want) + } + }) + } +} + +func TestTypedSurfaceCreationPathsUseBackendMap(t *testing.T) { + instance := newSoftwareSurfaceTestInstance(t) + target := currentPlatformSurfaceTarget(t) + + legacy, err := instance.CreateSurface(target.displayHandle, target.windowHandle) + if err != nil { + t.Fatalf("CreateSurface: %v", err) + } + if legacy.HAL() == nil { + t.Fatal("legacy surface has no HAL surface") + } + legacy.Release() + + unsafeSurface, err := instance.CreateSurfaceUnsafe(target) + if err != nil { + t.Fatalf("CreateSurfaceUnsafe: %v", err) + } + if unsafeSurface.targetSource != nil { + t.Fatal("unsafe surface retained an ownership source") + } + unsafeSurface.Release() + + provider := &surfaceTargetTestProvider{target: target} + safeSurface, err := instance.CreateSurfaceFromTarget(provider) + if err != nil { + t.Fatalf("CreateSurfaceFromTarget: %v", err) + } + if provider.calls != 1 { + t.Fatalf("provider calls = %d, want 1", provider.calls) + } + if safeSurface.targetSource != provider { + t.Fatal("safe surface did not retain its provider") + } + if safeSurface.halSurfaces[gputypes.BackendEmpty] == nil { + t.Fatal("safe surface did not retain the software backend surface") + } + + adapter, err := instance.RequestAdapter(&RequestAdapterOptions{CompatibleSurface: safeSurface}) + if err != nil { + t.Fatalf("RequestAdapter(compatible surface): %v", err) + } + if capabilities := adapter.GetSurfaceCapabilities(safeSurface); capabilities == nil { + t.Fatal("compatible software adapter returned no surface capabilities") + } + adapter.Release() + + safeSurface.Release() + if safeSurface.targetSource != nil { + t.Fatal("released safe surface retained its provider") + } +} + +func TestCreateSurfaceReportsBoundaryFailures(t *testing.T) { + target := currentPlatformSurfaceTarget(t) + + empty := &Instance{core: core.NewInstanceWithMock(nil)} + t.Cleanup(empty.core.Destroy) + if surface, err := empty.CreateSurfaceUnsafe(target); surface != nil || err == nil { + t.Fatalf("CreateSurfaceUnsafe without HAL = (%v, %v), want nil error result", surface, err) + } + + instance := newSoftwareSurfaceTestInstance(t) + if surface, err := instance.createSurface(SurfaceTargetFromWebCanvasID(1), nil); surface != nil || + !errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("native web target = (%v, %v), want ErrUnsupportedSurfaceTarget", surface, err) + } + + if runtime.GOOS != "android" { + surface, err := instance.CreateSurfaceUnsafe(SurfaceTargetFromAndroidNativeWindow(1)) + if surface != nil { + surface.Release() + t.Fatal("desktop software backend accepted Android target") + } + if !errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("Android target error = %v, want ErrUnsupportedSurfaceTarget", err) + } + } + + released := newSoftwareSurfaceTestInstance(t) + released.Release() + if surface, err := released.CreateSurface(target.displayHandle, target.windowHandle); surface != nil || + !errors.Is(err, ErrReleased) { + t.Fatalf("CreateSurface after release = (%v, %v), want ErrReleased", surface, err) + } +} + +func TestAdoptCreatedSurfaceDestroysEverySurfaceOnReleaseRace(t *testing.T) { + instance := &Instance{core: core.NewInstanceWithMock(nil), released: true} + t.Cleanup(instance.core.Destroy) + first := &surfaceDestroyCounter{} + second := &surfaceDestroyCounter{} + + surface, err := instance.adoptCreatedSurface( + SurfaceTargetFromAndroidNativeWindow(1), + nil, + map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: first, + gputypes.BackendGL: second, + }, + gputypes.BackendVulkan, + ) + if surface != nil || !errors.Is(err, ErrReleased) { + t.Fatalf("adoptCreatedSurface = (%v, %v), want ErrReleased", surface, err) + } + if first.destroys != 1 || second.destroys != 1 { + t.Fatalf("destroy counts = (%d, %d), want (1, 1)", first.destroys, second.destroys) + } +} + +func TestCreateHALSurfaceForTargetMapsBackendFailures(t *testing.T) { + target := hal.SurfaceTarget{ + Kind: hal.SurfaceTargetXlibWindow, + DisplayHandle: 1, + WindowHandle: 2, + } + wantSurface := &noop.Surface{} + regularErr := errors.New("driver rejected surface") + tests := []struct { + name string + instance *surfaceCreateTestInstance + want hal.Surface + wantErr error + }{ + { + name: "success", + instance: &surfaceCreateTestInstance{surface: wantSurface}, + want: wantSurface, + }, + { + name: "unsupported", + instance: &surfaceCreateTestInstance{err: hal.ErrUnsupportedSurfaceTarget}, + wantErr: ErrUnsupportedSurfaceTarget, + }, + { + name: "driver error", + instance: &surfaceCreateTestInstance{err: regularErr}, + wantErr: regularErr, + }, + { + name: "nil surface", + instance: &surfaceCreateTestInstance{}, + wantErr: errors.New("nil surface"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := createHALSurfaceForTarget(test.instance, gputypes.BackendVulkan, target) + if got != test.want { + t.Fatalf("surface = %v, want %v", got, test.want) + } + if test.name == "nil surface" { + if err == nil { + t.Fatal("nil HAL surface returned no error") + } + return + } + if !errors.Is(err, test.wantErr) { + t.Fatalf("error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestCreateAndEnsureHALSurfaceCoverDeferredPaths(t *testing.T) { + instance := newSoftwareSurfaceTestInstance(t) + target := currentPlatformSurfaceTarget(t) + + surface := &Surface{ + core: core.NewSurface(&noop.Surface{}, "deferred-create"), + instance: instance, + target: target, + } + if _, err := surface.createHALSurface(gputypes.Backend(255)); err == nil { + t.Fatal("createHALSurface succeeded without a matching HAL instance") + } + surface.target = SurfaceTargetFromWebCanvasID(1) + if _, err := surface.createHALSurface(gputypes.BackendEmpty); !errors.Is(err, ErrUnsupportedSurfaceTarget) { + t.Fatalf("createHALSurface(web target) = %v, want ErrUnsupportedSurfaceTarget", err) + } + surface.target = target + raw, err := surface.createHALSurface(gputypes.BackendEmpty) + if err != nil { + t.Fatalf("createHALSurface(software): %v", err) + } + raw.Destroy() + + if err := surface.ensureHALSurface(gputypes.BackendEmpty); err != nil { + t.Fatalf("ensureHALSurface(software): %v", err) + } + if surface.halSurfaces[gputypes.BackendEmpty] == nil { + t.Fatal("ensureHALSurface did not retain the lazily created surface") + } + if surface.currentBackend != gputypes.BackendEmpty || !surface.surfaceCreated { + t.Fatalf("active surface = (%v, %v), want software/created", surface.currentBackend, surface.surfaceCreated) + } + surface.Release() + + failing := &Surface{ + core: core.NewSurface(&noop.Surface{}, "deferred-failure"), + instance: instance, + target: target, + } + if err := failing.ensureHALSurface(gputypes.Backend(255)); err == nil { + t.Fatal("ensureHALSurface succeeded without a matching HAL instance") + } + failing.Release() +} + +func TestEnsureHALSurfaceUnconfiguresBeforeSwitch(t *testing.T) { + first := &noop.Surface{} + second := &noop.Surface{} + coreSurface := core.NewSurface(first, "configured-switch") + coreDevice := core.NewDevice(&noop.Device{}, nil, 0, gputypes.DefaultLimits(), "configured-switch") + t.Cleanup(coreDevice.Destroy) + if err := coreSurface.Configure(coreDevice, &hal.SurfaceConfiguration{ + Width: 1, + Height: 1, + Format: gputypes.TextureFormatBGRA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + surface := &Surface{ + core: coreSurface, + currentBackend: gputypes.BackendVulkan, + surfaceCreated: true, + halSurfaces: map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: first, + gputypes.BackendGL: second, + }, + } + + if err := surface.ensureHALSurface(gputypes.BackendGL); err != nil { + t.Fatalf("ensureHALSurface: %v", err) + } + if coreSurface.State() != core.SurfaceStateUnconfigured { + t.Fatalf("surface state = %v, want unconfigured", coreSurface.State()) + } + surface.Release() +} + +func TestSurfaceBackendViewsHandleNilReleasedAndLegacySurfaces(t *testing.T) { + var nilSurface *Surface + if got := nilSurface.halSurfaceForBackend(gputypes.BackendVulkan); got != nil { + t.Fatalf("nil surface HAL = %v, want nil", got) + } + if got := nilSurface.halSurfacesForAdapterRequest(); got != nil { + t.Fatalf("nil surface adapter map = %v, want nil", got) + } + + raw := &noop.Surface{} + vulkan := &noop.Surface{} + surface := &Surface{ + core: core.NewSurface(raw, "backend-views"), + currentBackend: gputypes.BackendGL, + halSurfaces: map[gputypes.Backend]hal.Surface{ + gputypes.BackendVulkan: vulkan, + }, + } + copied := surface.halSurfacesForAdapterRequest() + if copied[gputypes.BackendVulkan] != vulkan { + t.Fatalf("copied Vulkan surface = %v, want %v", copied[gputypes.BackendVulkan], vulkan) + } + delete(copied, gputypes.BackendVulkan) + if surface.halSurfaces[gputypes.BackendVulkan] != vulkan { + t.Fatal("adapter surface map aliases the owned map") + } + surface.Release() + if got := surface.halSurfaceForBackend(gputypes.BackendVulkan); got != nil { + t.Fatalf("released surface HAL = %v, want nil", got) + } + if got := surface.halSurfacesForAdapterRequest(); got != nil { + t.Fatalf("released surface adapter map = %v, want nil", got) + } + + legacyRaw := &noop.Surface{} + legacy := &Surface{ + core: core.NewSurface(legacyRaw, "legacy-adapter-map"), + currentBackend: gputypes.BackendGL, + } + legacyMap := legacy.halSurfacesForAdapterRequest() + if len(legacyMap) != 1 || legacyMap[gputypes.BackendGL] != legacyRaw { + t.Fatalf("legacy adapter map = %v, want GL raw surface", legacyMap) + } + legacy.Release() + + empty := &Surface{core: core.NewSurface(nil, "empty-adapter-map")} + if got := empty.halSurfacesForAdapterRequest(); len(got) != 0 { + t.Fatalf("empty adapter map = %v, want empty", got) + } + empty.Release() +} diff --git a/surface_native.go b/surface_native.go index 3d0ea0d4..dba6340d 100644 --- a/surface_native.go +++ b/surface_native.go @@ -98,8 +98,16 @@ func (i *Instance) createSurface(target SurfaceTargetUnsafe, targetSource Surfac if err != nil { return nil, err } - halSurface := halSurfaces[initialBackend] + return i.adoptCreatedSurface(target, targetSource, halSurfaces, initialBackend) +} +func (i *Instance) adoptCreatedSurface( + target SurfaceTargetUnsafe, + targetSource SurfaceTarget, + halSurfaces map[gputypes.Backend]hal.Surface, + initialBackend gputypes.Backend, +) (*Surface, error) { + halSurface := halSurfaces[initialBackend] coreSurface := core.NewSurface(halSurface, "") surface := &Surface{ core: coreSurface, @@ -154,13 +162,22 @@ func createHALSurfaces(entries []core.HALInstanceEntry, target hal.SurfaceTarget } func surfaceTargetFromLegacyHandles(displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { - switch runtime.GOOS { + return surfaceTargetFromLegacyHandlesForPlatform( + runtime.GOOS, + os.Getenv("WAYLAND_DISPLAY"), + displayHandle, + windowHandle, + ) +} + +func surfaceTargetFromLegacyHandlesForPlatform(goos, waylandDisplay string, displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { + switch goos { case "windows": return SurfaceTargetFromWindowsHWND(displayHandle, windowHandle) case "darwin": return SurfaceTargetFromMetalLayer(windowHandle) case "linux": - if os.Getenv("WAYLAND_DISPLAY") != "" { + if waylandDisplay != "" { return SurfaceTargetFromWaylandSurface(displayHandle, windowHandle) } return SurfaceTargetFromXlibWindow(displayHandle, windowHandle) @@ -455,13 +472,20 @@ func (s *Surface) createHALSurface(backend gputypes.Backend) (hal.Surface, error if err != nil { return nil, err } - halSurface, err := targetInstance.CreateSurface(halTarget) + return createHALSurfaceForTarget(targetInstance, backend, halTarget) +} + +func createHALSurfaceForTarget(targetInstance hal.Instance, backend gputypes.Backend, target hal.SurfaceTarget) (hal.Surface, error) { + halSurface, err := targetInstance.CreateSurface(target) if errors.Is(err, hal.ErrUnsupportedSurfaceTarget) { return nil, fmt.Errorf("%w: backend %v: %w", ErrUnsupportedSurfaceTarget, backend, err) } if err != nil { return nil, fmt.Errorf("wgpu: failed to create surface for backend %v: %w", backend, err) } + if halSurface == nil { + return nil, fmt.Errorf("wgpu: backend %v returned a nil surface", backend) + } return halSurface, nil } diff --git a/surface_target_hal_test.go b/surface_target_hal_test.go index 9e17293d..7b04a81b 100644 --- a/surface_target_hal_test.go +++ b/surface_target_hal_test.go @@ -68,6 +68,11 @@ func TestWebSurfaceTargetIsUnsupportedByNativeHAL(t *testing.T) { if !errors.Is(err, ErrUnsupportedSurfaceTarget) { t.Fatalf("halTarget error = %v, want ErrUnsupportedSurfaceTarget", err) } + + _, err = (SurfaceTargetUnsafe{kind: surfaceTargetKind(255)}).halTarget() + if !errors.Is(err, ErrInvalidSurfaceTarget) { + t.Fatalf("unknown halTarget error = %v, want ErrInvalidSurfaceTarget", err) + } } func TestSurfaceTargetUnsafeValidationAcceptsValidTargets(t *testing.T) { From 5b0802a6823eba6372cd11361dd4c37b87f2cac2 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 23 Jul 2026 22:16:34 +0300 Subject: [PATCH 11/11] ci(android): pin merged WebGPU surface source --- .github/workflows/ci.yml | 14 ++++----- docs/ANDROID.md | 39 +++++++++----------------- docs/SURFACE-TARGETS.md | 7 +++-- scripts/check-android-arm64-preview.sh | 2 +- surface_backend_native_test.go | 24 ++++++++-------- surface_native.go | 15 +++++++--- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8db1c59..407390df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ env: CGO_ENABLED: 0 jobs: - # Integration against canonical goffi and the exact pending WebGPU Android - # candidate. The ephemeral go.work injects only the unpublished candidate. + # Integration against canonical goffi and the exact merged WebGPU Android + # source. The ephemeral go.work injects the change until it is released. android-arm64-preview: name: Android arm64 preview - Go ${{ matrix.go-version }} runs-on: ubuntu-latest @@ -41,17 +41,17 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Checkout exact WebGPU Android surface candidate + - name: Checkout exact merged WebGPU Android surface source uses: actions/checkout@v4 with: repository: go-webgpu/webgpu - ref: refs/pull/24/head + ref: a801aed7399042e5564ef76fc9f075da5cb70081 path: .ci/webgpu persist-credentials: false - - name: Verify exact WebGPU candidate + - name: Verify exact WebGPU merge working-directory: .ci/webgpu - run: test "$(git rev-parse HEAD)" = 08592c9f5916b64dfc70aba9e67a74a764bb3ef5 + run: test "$(git rev-parse HEAD)" = a801aed7399042e5564ef76fc9f075da5cb70081 - name: Set up Go uses: actions/setup-go@v5 @@ -68,7 +68,7 @@ jobs: - name: Check Android arm64 source, tests, and ELF dependencies env: WEBGPU_DIR: ${{ github.workspace }}/.ci/webgpu - WEBGPU_EXPECTED_HEAD: 08592c9f5916b64dfc70aba9e67a74a764bb3ef5 + WEBGPU_EXPECTED_HEAD: a801aed7399042e5564ef76fc9f075da5cb70081 WEBGPU_EXPECTED_PATCH: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run: | export ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/29.0.14206865" diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 803b623f..d52238d1 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -10,9 +10,9 @@ The default backend consumes canonical [goffi v0.6.1](https://github.com/go-webgpu/goffi/releases/tag/v0.6.1), released from [go-webgpu/goffi#62](https://github.com/go-webgpu/goffi/pull/62). The `rust` build-tag path also depends on canonical -[go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), exactly at -`08592c9f5916b64dfc70aba9e67a74a764bb3ef5`. goffi is declared directly in -`go.mod` and verified by `go.sum`; only the unreleased WebGPU candidate is +[go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), merged at +`a801aed7399042e5564ef76fc9f075da5cb70081`. goffi is declared directly in +`go.mod` and verified by `go.sum`; the merged but unreleased WebGPU source is injected through an ephemeral workspace. There is no forked module path, `replace` directive, or committed `go.work`. @@ -81,29 +81,16 @@ filter. The canonical backend's existing application-version request remains unchanged; actual extensions, commands, features, and device evidence decide whether a Vulkan implementation works. -## Proposed review order +## Merge order The Android Vulkan work remains in [wgpu#268](https://github.com/gogpu/wgpu/pull/268). This typed-surface change is -best reviewed as a follow-up on that exact head, not folded into unrelated -prerequisites. After predecessors merge, #268 can be rebased to its Android-only -commits and this follow-up can be replayed without changing its public design. - -The independently reviewable prerequisites and their current exact heads are: - -| Prerequisite | Exact head | -|--------------|------------| -| [wgpu#264](https://github.com/gogpu/wgpu/pull/264), drain before device teardown | `6acfb1ea1cca1dc14d7ab0902931c678dc13e0a6` | -| [wgpu#265](https://github.com/gogpu/wgpu/pull/265), fail-closed swapchain negotiation | `eb48db0e7eec4c12eaa87cc998f6ea9e8f348fdd` | -| [wgpu#266](https://github.com/gogpu/wgpu/pull/266), explicit mock construction | `e65bec3c74c56cd07265c055ce6a7ec7fbedc680` | -| [wgpu#267](https://github.com/gogpu/wgpu/pull/267), surface-qualified present queue | `63efce782d5cf41dacab761d202da0e3d68ed2da` | -| [wgpu#269](https://github.com/gogpu/wgpu/pull/269), surface lifetime ownership | `2a069cb1fde942339813efd7199ed305a3a2ca84` | -| [webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), Android Rust-wrapper surface source | `08592c9f5916b64dfc70aba9e67a74a764bb3ef5` | - -The current stacked [wgpu#268](https://github.com/gogpu/wgpu/pull/268) head is -`13b59db954dafd2653e6cb5ce3ea14c022782ad2`. Once the five WGPU prerequisites -merge, #268 drops their replayed commits and retains its four Android-owned -commits. This typed-target follow-up is then replayed on that reduced head. +best reviewed and merged after #268. Its five WGPU prerequisites (#264, #265, +#266, #267, and #269), goffi#62/v0.6.1, and webgpu#24 are merged. This branch is +rebased onto the resulting WGPU `main`, so none of those five WGPU prerequisite +commits are replayed here. Until #268 lands, its Android-owned commits remain +below this PR's typed-target commits; they can be dropped in one final rebase +without changing the public design. [wgpu#253](https://github.com/gogpu/wgpu/pull/253) is the design precedent for matching Rust's typed public/HAL seam; it is not a code dependency of Android. @@ -113,12 +100,12 @@ that helper should be merged. ## Reproducing deterministic proof -Use Android NDK r29 and clean checkouts of this branch and the canonical -`go-webgpu/webgpu` helper candidate: +Use Android NDK r29 and clean checkouts of this branch and the canonical merged +`go-webgpu/webgpu` helper: ```bash WEBGPU_DIR=/path/to/go-webgpu-webgpu \ -WEBGPU_EXPECTED_HEAD=08592c9f5916b64dfc70aba9e67a74a764bb3ef5 \ +WEBGPU_EXPECTED_HEAD=a801aed7399042e5564ef76fc9f075da5cb70081 \ WEBGPU_EXPECTED_PATCH=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ ANDROID_NDK_HOME=/path/to/android-ndk-r29 \ GOTOOLCHAIN=go1.26.5 \ diff --git a/docs/SURFACE-TARGETS.md b/docs/SURFACE-TARGETS.md index dce7fc9a..992a9908 100644 --- a/docs/SURFACE-TARGETS.md +++ b/docs/SURFACE-TARGETS.md @@ -89,9 +89,10 @@ backend trait. That wrapper method belongs in canonical [go-webgpu/webgpu#24](https://github.com/go-webgpu/webgpu/pull/24), not this repository. This branch tests its exact clean head -`08592c9f5916b64dfc70aba9e67a74a764bb3ef5`; #24 and an immutable released -revision must land before the Rust-tag Android lane can merge here. The WGPU -follow-up does not vendor the ABI struct or add a local `replace`. +`a801aed7399042e5564ef76fc9f075da5cb70081`, the canonical #24 merge. Until a +release contains that commit, the Rust-tag Android lane injects the exact clean +source through an ephemeral workspace. The WGPU follow-up does not vendor the +ABI struct or add a local `replace`. Like Rust `wgpu`, the native implementation attempts surface creation for every enabled backend and succeeds when at least one backend succeeds. It keeps diff --git a/scripts/check-android-arm64-preview.sh b/scripts/check-android-arm64-preview.sh index 2ddd6aee..e97a2321 100755 --- a/scripts/check-android-arm64-preview.sh +++ b/scripts/check-android-arm64-preview.sh @@ -61,7 +61,7 @@ goffi_version=v0.6.1 webgpu_dir= actual_webgpu_head= if [[ -n "${WEBGPU_DIR:-}" ]]; then - : "${WEBGPU_EXPECTED_HEAD:?set WEBGPU_EXPECTED_HEAD to the helper candidate immutable commit}" + : "${WEBGPU_EXPECTED_HEAD:?set WEBGPU_EXPECTED_HEAD to the helper source immutable commit}" : "${WEBGPU_EXPECTED_PATCH:?set WEBGPU_EXPECTED_PATCH to its working-tree fingerprint}" webgpu_dir=$(cd "$WEBGPU_DIR" && pwd -P) actual_webgpu_head=$(git -C "$webgpu_dir" rev-parse HEAD) diff --git a/surface_backend_native_test.go b/surface_backend_native_test.go index 6b421383..3ed58065 100644 --- a/surface_backend_native_test.go +++ b/surface_backend_native_test.go @@ -64,11 +64,11 @@ func newSoftwareSurfaceTestInstance(t *testing.T) *Instance { func currentPlatformSurfaceTarget(t *testing.T) SurfaceTargetUnsafe { t.Helper() switch runtime.GOOS { - case "windows": + case platformWindows: return SurfaceTargetFromWindowsHWND(1, 2) - case "darwin": + case platformDarwin: return SurfaceTargetFromMetalLayer(2) - case "linux": + case platformLinux: return SurfaceTargetFromXlibWindow(1, 2) default: t.Skipf("no software window target for %s", runtime.GOOS) @@ -86,12 +86,12 @@ func TestCreateHALSurfacesTriesEveryBackendAndSucceedsIfAny(t *testing.T) { vulkanSurface := &noop.Surface{} vulkan := &surfaceCreateTestInstance{surface: vulkanSurface} softwareSurface := &noop.Surface{} - software := &surfaceCreateTestInstance{surface: softwareSurface} + softwareInstance := &surfaceCreateTestInstance{surface: softwareSurface} surfaces, firstBackend, err := createHALSurfaces([]core.HALInstanceEntry{ {Backend: gputypes.BackendGL, Instance: unsupported}, {Backend: gputypes.BackendVulkan, Instance: vulkan}, - {Backend: gputypes.BackendEmpty, Instance: software}, + {Backend: gputypes.BackendEmpty, Instance: softwareInstance}, }, target) if err != nil { t.Fatalf("createHALSurfaces: %v", err) @@ -111,7 +111,7 @@ func TestCreateHALSurfacesTriesEveryBackendAndSucceedsIfAny(t *testing.T) { for name, instance := range map[string]*surfaceCreateTestInstance{ "unsupported": unsupported, "Vulkan": vulkan, - "software": software, + "software": softwareInstance, } { if len(instance.calls) != 1 || instance.calls[0] != target { t.Fatalf("%s calls = %+v, want target once", name, instance.calls) @@ -228,26 +228,26 @@ func TestSurfaceTargetFromLegacyHandlesForPlatform(t *testing.T) { }{ { name: "Windows", - goos: "windows", display: 1, window: 2, + goos: platformWindows, display: 1, window: 2, want: SurfaceTargetFromWindowsHWND(1, 2), }, { name: "Darwin", - goos: "darwin", display: 1, window: 2, + goos: platformDarwin, display: 1, window: 2, want: SurfaceTargetFromMetalLayer(2), }, { name: "Linux Xlib", - goos: "linux", display: 1, window: 2, + goos: platformLinux, display: 1, window: 2, want: SurfaceTargetFromXlibWindow(1, 2), }, { - name: "Linux Wayland", goos: "linux", waylandDisplay: "wayland-0", display: 1, window: 2, + name: "Linux Wayland", goos: platformLinux, waylandDisplay: "wayland-0", display: 1, window: 2, want: SurfaceTargetFromWaylandSurface(1, 2), }, { name: "Android", - goos: "android", display: 1, window: 2, + goos: platformAndroid, display: 1, window: 2, want: SurfaceTargetFromAndroidNativeWindow(2), }, { @@ -339,7 +339,7 @@ func TestCreateSurfaceReportsBoundaryFailures(t *testing.T) { t.Fatalf("native web target = (%v, %v), want ErrUnsupportedSurfaceTarget", surface, err) } - if runtime.GOOS != "android" { + if runtime.GOOS != platformAndroid { surface, err := instance.CreateSurfaceUnsafe(SurfaceTargetFromAndroidNativeWindow(1)) if surface != nil { surface.Release() diff --git a/surface_native.go b/surface_native.go index dba6340d..8d3a8659 100644 --- a/surface_native.go +++ b/surface_native.go @@ -14,6 +14,13 @@ import ( "github.com/gogpu/wgpu/hal" ) +const ( + platformWindows = "windows" + platformDarwin = "darwin" + platformLinux = "linux" + platformAndroid = "android" +) + // Surface represents a platform rendering surface (e.g., a window). // // Surface delegates lifecycle management to core.Surface, which enforces @@ -172,16 +179,16 @@ func surfaceTargetFromLegacyHandles(displayHandle, windowHandle uintptr) Surface func surfaceTargetFromLegacyHandlesForPlatform(goos, waylandDisplay string, displayHandle, windowHandle uintptr) SurfaceTargetUnsafe { switch goos { - case "windows": + case platformWindows: return SurfaceTargetFromWindowsHWND(displayHandle, windowHandle) - case "darwin": + case platformDarwin: return SurfaceTargetFromMetalLayer(windowHandle) - case "linux": + case platformLinux: if waylandDisplay != "" { return SurfaceTargetFromWaylandSurface(displayHandle, windowHandle) } return SurfaceTargetFromXlibWindow(displayHandle, windowHandle) - case "android": + case platformAndroid: return SurfaceTargetFromAndroidNativeWindow(windowHandle) default: return SurfaceTargetUnsafe{