From acf6191054c6563c4924c12647b67a5a10d5e7c2 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 22:58:46 +0300 Subject: [PATCH 01/13] feat(core): complete all 12 stub resource types (CORE-001) Replace empty struct{} stubs in core/resource.go with full implementations following the existing Buffer pattern: - Texture: format, dimension, usage, size, mip levels, sample count - Sampler, ShaderModule, QuerySet: HAL handle + properties - BindGroupLayout, PipelineLayout, BindGroup: binding infrastructure - RenderPipeline, ComputePipeline: pipeline types - CommandEncoder, CommandBuffer: command recording types - Surface: HAL surface (not Snatchable, owned by Instance) Each type has: Snatchable HAL handle (where applicable), device reference, WebGPU properties, constructor function, and Raw() accessor. Foundation for CORE-002 (Surface lifecycle), CORE-003 (CommandEncoder state machine), and CORE-004 (pipeline validation). --- CHANGELOG.md | 9 + core/hub_test.go | 3 +- core/resource.go | 657 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 644 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f6b39..2259c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **core: complete all 12 stub resource types** — Texture, Sampler, BindGroupLayout, + PipelineLayout, BindGroup, ShaderModule, RenderPipeline, ComputePipeline, + CommandEncoder, CommandBuffer, QuerySet, and Surface now have full struct definitions + with HAL handle wrapping (Snatchable pattern), device references, WebGPU properties, + and constructor functions. Previously these were empty `struct{}` stubs. This completes + the foundation for wgpu/core resource lifecycle management (CORE-001). + ## [0.20.2] - 2026-03-12 ### Fixed diff --git a/core/hub_test.go b/core/hub_test.go index c6e7397..9bc6683 100644 --- a/core/hub_test.go +++ b/core/hub_test.go @@ -195,7 +195,8 @@ func TestHubBindGroupLayout(t *testing.T) { if err != nil { t.Fatalf("GetBindGroupLayout failed: %v", err) } - if got != layout { + // Can't compare structs with slice fields, compare label instead + if got.label != layout.label { t.Error("GetBindGroupLayout returned different layout") } diff --git a/core/resource.go b/core/resource.go index 64e2bc3..54a1b76 100644 --- a/core/resource.go +++ b/core/resource.go @@ -697,8 +697,92 @@ func (t *BufferInitTracker) IsInitialized(offset, size uint64) bool { return true } -// Texture represents a GPU texture. -type Texture struct{} +// Texture represents a GPU texture with HAL integration. +// +// Texture wraps a HAL texture handle and stores WebGPU texture properties. +// The HAL texture is wrapped in a Snatchable to enable safe deferred destruction. +type Texture struct { + // raw is the HAL texture handle wrapped for safe destruction. + raw *Snatchable[hal.Texture] + + // device is a pointer to the parent Device. + device *Device + + // format is the texture pixel format. + format gputypes.TextureFormat + + // dimension is the texture dimension (1D, 2D, 3D). + dimension gputypes.TextureDimension + + // usage is the texture usage flags. + usage gputypes.TextureUsage + + // size is the texture dimensions. + size gputypes.Extent3D + + // mipLevelCount is the number of mip levels. + mipLevelCount uint32 + + // sampleCount is the number of samples per pixel. + sampleCount uint32 + + // label is a debug label for the texture. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewTexture creates a core Texture wrapping a HAL texture. +// +// Parameters: +// - halTexture: The HAL texture to wrap (ownership transferred) +// - device: The parent device +// - format: Texture pixel format +// - dimension: Texture dimension (1D, 2D, 3D) +// - usage: Texture usage flags +// - size: Texture dimensions +// - mipLevelCount: Number of mip levels +// - sampleCount: Number of samples per pixel +// - label: Debug label for the texture +func NewTexture( + halTexture hal.Texture, + device *Device, + format gputypes.TextureFormat, + dimension gputypes.TextureDimension, + usage gputypes.TextureUsage, + size gputypes.Extent3D, + mipLevelCount uint32, + sampleCount uint32, + label string, +) *Texture { + t := &Texture{ + raw: NewSnatchable(halTexture), + device: device, + format: format, + dimension: dimension, + usage: usage, + size: size, + mipLevelCount: mipLevelCount, + sampleCount: sampleCount, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(t)), "Texture") //nolint:gosec // debug tracking uses pointer as unique ID + return t +} + +// Raw returns the underlying HAL texture if it hasn't been snatched. +func (t *Texture) Raw(guard *SnatchGuard) hal.Texture { + if t.raw == nil { + return nil + } + p := t.raw.Get(guard) + if p == nil { + return nil + } + return *p +} // TextureView represents a view into a texture. type TextureView struct { @@ -707,35 +791,560 @@ type TextureView struct { HAL hal.TextureView } -// Sampler represents a texture sampler. -type Sampler struct{} +// Sampler represents a texture sampler with HAL integration. +// +// Sampler wraps a HAL sampler handle. Samplers are immutable after creation +// and have no mutable state beyond their HAL handle. +type Sampler struct { + // raw is the HAL sampler handle wrapped for safe destruction. + raw *Snatchable[hal.Sampler] + + // device is a pointer to the parent Device. + device *Device + + // label is a debug label for the sampler. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewSampler creates a core Sampler wrapping a HAL sampler. +// +// Parameters: +// - halSampler: The HAL sampler to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the sampler +func NewSampler( + halSampler hal.Sampler, + device *Device, + label string, +) *Sampler { + s := &Sampler{ + raw: NewSnatchable(halSampler), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(s)), "Sampler") //nolint:gosec // debug tracking uses pointer as unique ID + return s +} + +// Raw returns the underlying HAL sampler if it hasn't been snatched. +func (s *Sampler) Raw(guard *SnatchGuard) hal.Sampler { + if s.raw == nil { + return nil + } + p := s.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// BindGroupLayout represents the layout of a bind group with HAL integration. +// +// BindGroupLayout wraps a HAL bind group layout handle and stores the layout entries. +type BindGroupLayout struct { + // raw is the HAL bind group layout handle wrapped for safe destruction. + raw *Snatchable[hal.BindGroupLayout] + + // device is a pointer to the parent Device. + device *Device + + // entries are the binding entries in this layout. + entries []gputypes.BindGroupLayoutEntry + + // label is a debug label for the bind group layout. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewBindGroupLayout creates a core BindGroupLayout wrapping a HAL bind group layout. +// +// Parameters: +// - halLayout: The HAL bind group layout to wrap (ownership transferred) +// - device: The parent device +// - entries: The binding entries in this layout +// - label: Debug label for the bind group layout +func NewBindGroupLayout( + halLayout hal.BindGroupLayout, + device *Device, + entries []gputypes.BindGroupLayoutEntry, + label string, +) *BindGroupLayout { + bgl := &BindGroupLayout{ + raw: NewSnatchable(halLayout), + device: device, + entries: entries, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(bgl)), "BindGroupLayout") //nolint:gosec // debug tracking uses pointer as unique ID + return bgl +} + +// Raw returns the underlying HAL bind group layout if it hasn't been snatched. +func (bgl *BindGroupLayout) Raw(guard *SnatchGuard) hal.BindGroupLayout { + if bgl.raw == nil { + return nil + } + p := bgl.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// PipelineLayout represents the layout of a pipeline with HAL integration. +// +// PipelineLayout wraps a HAL pipeline layout handle and stores the bind group +// layout count. It does not store pointers to BindGroupLayout to avoid +// circular references. +type PipelineLayout struct { + // raw is the HAL pipeline layout handle wrapped for safe destruction. + raw *Snatchable[hal.PipelineLayout] + + // device is a pointer to the parent Device. + device *Device + + // bindGroupLayoutCount is the number of bind group layouts in this pipeline layout. + bindGroupLayoutCount int + + // label is a debug label for the pipeline layout. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewPipelineLayout creates a core PipelineLayout wrapping a HAL pipeline layout. +// +// Parameters: +// - halLayout: The HAL pipeline layout to wrap (ownership transferred) +// - device: The parent device +// - bindGroupLayoutCount: Number of bind group layouts +// - label: Debug label for the pipeline layout +func NewPipelineLayout( + halLayout hal.PipelineLayout, + device *Device, + bindGroupLayoutCount int, + label string, +) *PipelineLayout { + pl := &PipelineLayout{ + raw: NewSnatchable(halLayout), + device: device, + bindGroupLayoutCount: bindGroupLayoutCount, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(pl)), "PipelineLayout") //nolint:gosec // debug tracking uses pointer as unique ID + return pl +} + +// Raw returns the underlying HAL pipeline layout if it hasn't been snatched. +func (pl *PipelineLayout) Raw(guard *SnatchGuard) hal.PipelineLayout { + if pl.raw == nil { + return nil + } + p := pl.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// BindGroup represents a collection of resources bound together with HAL integration. +// +// BindGroup wraps a HAL bind group handle. Resource references are not stored +// yet to keep the implementation simple — that is future work. +type BindGroup struct { + // raw is the HAL bind group handle wrapped for safe destruction. + raw *Snatchable[hal.BindGroup] + + // device is a pointer to the parent Device. + device *Device + + // label is a debug label for the bind group. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewBindGroup creates a core BindGroup wrapping a HAL bind group. +// +// Parameters: +// - halGroup: The HAL bind group to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the bind group +func NewBindGroup( + halGroup hal.BindGroup, + device *Device, + label string, +) *BindGroup { + bg := &BindGroup{ + raw: NewSnatchable(halGroup), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(bg)), "BindGroup") //nolint:gosec // debug tracking uses pointer as unique ID + return bg +} + +// Raw returns the underlying HAL bind group if it hasn't been snatched. +func (bg *BindGroup) Raw(guard *SnatchGuard) hal.BindGroup { + if bg.raw == nil { + return nil + } + p := bg.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// ShaderModule represents a compiled shader module with HAL integration. +// +// ShaderModule wraps a HAL shader module handle. +type ShaderModule struct { + // raw is the HAL shader module handle wrapped for safe destruction. + raw *Snatchable[hal.ShaderModule] + + // device is a pointer to the parent Device. + device *Device + + // label is a debug label for the shader module. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewShaderModule creates a core ShaderModule wrapping a HAL shader module. +// +// Parameters: +// - halModule: The HAL shader module to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the shader module +func NewShaderModule( + halModule hal.ShaderModule, + device *Device, + label string, +) *ShaderModule { + sm := &ShaderModule{ + raw: NewSnatchable(halModule), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(sm)), "ShaderModule") //nolint:gosec // debug tracking uses pointer as unique ID + return sm +} -// BindGroupLayout represents the layout of a bind group. -type BindGroupLayout struct{} +// Raw returns the underlying HAL shader module if it hasn't been snatched. +func (sm *ShaderModule) Raw(guard *SnatchGuard) hal.ShaderModule { + if sm.raw == nil { + return nil + } + p := sm.raw.Get(guard) + if p == nil { + return nil + } + return *p +} -// PipelineLayout represents the layout of a pipeline. -type PipelineLayout struct{} +// RenderPipeline represents a render pipeline with HAL integration. +// +// RenderPipeline wraps a HAL render pipeline handle. +type RenderPipeline struct { + // raw is the HAL render pipeline handle wrapped for safe destruction. + raw *Snatchable[hal.RenderPipeline] -// BindGroup represents a collection of resources bound together. -type BindGroup struct{} + // device is a pointer to the parent Device. + device *Device -// ShaderModule represents a compiled shader module. -type ShaderModule struct{} + // label is a debug label for the render pipeline. + label string -// RenderPipeline represents a render pipeline. -type RenderPipeline struct{} + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} -// ComputePipeline represents a compute pipeline. -type ComputePipeline struct{} +// NewRenderPipeline creates a core RenderPipeline wrapping a HAL render pipeline. +// +// Parameters: +// - halPipeline: The HAL render pipeline to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the render pipeline +func NewRenderPipeline( + halPipeline hal.RenderPipeline, + device *Device, + label string, +) *RenderPipeline { + rp := &RenderPipeline{ + raw: NewSnatchable(halPipeline), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(rp)), "RenderPipeline") //nolint:gosec // debug tracking uses pointer as unique ID + return rp +} -// CommandEncoder represents a command encoder. -type CommandEncoder struct{} +// Raw returns the underlying HAL render pipeline if it hasn't been snatched. +func (rp *RenderPipeline) Raw(guard *SnatchGuard) hal.RenderPipeline { + if rp.raw == nil { + return nil + } + p := rp.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// ComputePipeline represents a compute pipeline with HAL integration. +// +// ComputePipeline wraps a HAL compute pipeline handle. +type ComputePipeline struct { + // raw is the HAL compute pipeline handle wrapped for safe destruction. + raw *Snatchable[hal.ComputePipeline] + + // device is a pointer to the parent Device. + device *Device -// CommandBuffer represents a recorded command buffer. -type CommandBuffer struct{} + // label is a debug label for the compute pipeline. + label string -// QuerySet represents a set of queries. -type QuerySet struct{} + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} -// Surface represents a rendering surface. -type Surface struct{} +// NewComputePipeline creates a core ComputePipeline wrapping a HAL compute pipeline. +// +// Parameters: +// - halPipeline: The HAL compute pipeline to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the compute pipeline +func NewComputePipeline( + halPipeline hal.ComputePipeline, + device *Device, + label string, +) *ComputePipeline { + cp := &ComputePipeline{ + raw: NewSnatchable(halPipeline), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(cp)), "ComputePipeline") //nolint:gosec // debug tracking uses pointer as unique ID + return cp +} + +// Raw returns the underlying HAL compute pipeline if it hasn't been snatched. +func (cp *ComputePipeline) Raw(guard *SnatchGuard) hal.ComputePipeline { + if cp.raw == nil { + return nil + } + p := cp.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// CommandEncoder represents a command encoder with HAL integration. +// +// CommandEncoder wraps a HAL command encoder handle. The full state machine +// for encoding commands is implemented in CORE-003; this struct provides +// the basic storage structure. +type CommandEncoder struct { + // raw is the HAL command encoder handle. + // Not wrapped in Snatchable — state machine lifecycle is managed by CORE-003. + raw hal.CommandEncoder + + // device is a pointer to the parent Device. + device *Device + + // label is a debug label for the command encoder. + label string +} + +// NewCommandEncoder creates a core CommandEncoder wrapping a HAL command encoder. +// +// Parameters: +// - halEncoder: The HAL command encoder to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the command encoder +func NewCommandEncoder( + halEncoder hal.CommandEncoder, + device *Device, + label string, +) *CommandEncoder { + ce := &CommandEncoder{ + raw: halEncoder, + device: device, + label: label, + } + trackResource(uintptr(unsafe.Pointer(ce)), "CommandEncoder") //nolint:gosec // debug tracking uses pointer as unique ID + return ce +} + +// RawEncoder returns the underlying HAL command encoder. +func (ce *CommandEncoder) RawEncoder() hal.CommandEncoder { + return ce.raw +} + +// CommandBuffer represents a recorded command buffer with HAL integration. +// +// CommandBuffer wraps a HAL command buffer handle. Command buffers are +// immutable after encoding and can be submitted to a queue. +type CommandBuffer struct { + // raw is the HAL command buffer handle wrapped for safe destruction. + raw *Snatchable[hal.CommandBuffer] + + // device is a pointer to the parent Device. + device *Device + + // label is a debug label for the command buffer. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewCommandBuffer creates a core CommandBuffer wrapping a HAL command buffer. +// +// Parameters: +// - halBuffer: The HAL command buffer to wrap (ownership transferred) +// - device: The parent device +// - label: Debug label for the command buffer +func NewCommandBuffer( + halBuffer hal.CommandBuffer, + device *Device, + label string, +) *CommandBuffer { + cb := &CommandBuffer{ + raw: NewSnatchable(halBuffer), + device: device, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(cb)), "CommandBuffer") //nolint:gosec // debug tracking uses pointer as unique ID + return cb +} + +// Raw returns the underlying HAL command buffer if it hasn't been snatched. +func (cb *CommandBuffer) Raw(guard *SnatchGuard) hal.CommandBuffer { + if cb.raw == nil { + return nil + } + p := cb.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// QuerySet represents a set of queries with HAL integration. +// +// QuerySet wraps a HAL query set handle and stores query set properties. +type QuerySet struct { + // raw is the HAL query set handle wrapped for safe destruction. + raw *Snatchable[hal.QuerySet] + + // device is a pointer to the parent Device. + device *Device + + // queryType is the type of queries in this set. + queryType hal.QueryType + + // count is the number of queries in the set. + count uint32 + + // label is a debug label for the query set. + label string + + // trackingData holds per-resource tracking information. + trackingData *TrackingData +} + +// NewQuerySet creates a core QuerySet wrapping a HAL query set. +// +// Parameters: +// - halQuerySet: The HAL query set to wrap (ownership transferred) +// - device: The parent device +// - queryType: The type of queries in this set +// - count: Number of queries in the set +// - label: Debug label for the query set +func NewQuerySet( + halQuerySet hal.QuerySet, + device *Device, + queryType hal.QueryType, + count uint32, + label string, +) *QuerySet { + qs := &QuerySet{ + raw: NewSnatchable(halQuerySet), + device: device, + queryType: queryType, + count: count, + label: label, + trackingData: NewTrackingData(device.TrackerIndices()), + } + trackResource(uintptr(unsafe.Pointer(qs)), "QuerySet") //nolint:gosec // debug tracking uses pointer as unique ID + return qs +} + +// Raw returns the underlying HAL query set if it hasn't been snatched. +func (qs *QuerySet) Raw(guard *SnatchGuard) hal.QuerySet { + if qs.raw == nil { + return nil + } + p := qs.raw.Get(guard) + if p == nil { + return nil + } + return *p +} + +// Surface represents a rendering surface with HAL integration. +// +// Surface wraps a HAL surface handle. Unlike other resources, surfaces are +// owned by the Instance (not Device) and outlive devices, so the HAL handle +// is stored directly rather than in a Snatchable. Full lifecycle management +// is implemented in CORE-002. +type Surface struct { + // raw is the HAL surface handle. + // Not wrapped in Snatchable — surfaces are owned by Instance, not Device. + raw hal.Surface + + // label is a debug label for the surface. + label string +} + +// NewSurface creates a core Surface wrapping a HAL surface. +// +// Parameters: +// - halSurface: The HAL surface to wrap (ownership transferred) +// - label: Debug label for the surface +func NewSurface( + halSurface hal.Surface, + label string, +) *Surface { + s := &Surface{ + raw: halSurface, + label: label, + } + trackResource(uintptr(unsafe.Pointer(s)), "Surface") //nolint:gosec // debug tracking uses pointer as unique ID + return s +} + +// RawSurface returns the underlying HAL surface. +func (s *Surface) RawSurface() hal.Surface { + return s.raw +} From 92816d4780b1e116eb5d068ed66280d74e99a5d0 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 23:15:40 +0300 Subject: [PATCH 02/13] feat(core): Surface lifecycle state machine with PrepareFrame hook (CORE-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full surface lifecycle management in core.Surface: - State machine: Unconfigured → Configured → Acquired → Configured - Mutex-protected thread-safe transitions - Configure/Unconfigure with device validation - AcquireTexture with state check + PrepareFrame hook - Present with acquired texture validation - DiscardTexture for error recovery - PrepareFrameFunc callback for platform DPI/scale integration (Metal contentsScale, Windows WM_DPICHANGED, Wayland wl_output.scale) - Auto-reconfigure on dimension change from PrepareFrame Global registry changed from Registry[Surface] to Registry[*Surface] to support sync.Mutex (non-copyable). 16 new tests covering all state transitions and error conditions. --- CHANGELOG.md | 7 + core/global.go | 12 +- core/global_test.go | 14 +- core/resource.go | 47 +++++- core/surface.go | 236 ++++++++++++++++++++++++++++ core/surface_test.go | 366 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 667 insertions(+), 15 deletions(-) create mode 100644 core/surface.go create mode 100644 core/surface_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2259c9f..066fc92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and constructor functions. Previously these were empty `struct{}` stubs. This completes the foundation for wgpu/core resource lifecycle management (CORE-001). +- **core: Surface lifecycle state machine** — `core.Surface` now manages the full + Unconfigured → Configured → Acquired → Configured state machine with mutex-protected + transitions. Methods: Configure, Unconfigure, AcquireTexture, Present, DiscardTexture. + Validates state transitions (e.g., can't acquire twice, can't present without acquire). + Includes `PrepareFrameFunc` hook for platform DPI/scale integration — called before + each AcquireTexture, auto-reconfigures surface on dimension changes (CORE-002). + ## [0.20.2] - 2026-03-12 ### Fixed diff --git a/core/global.go b/core/global.go index 418591b..9578a56 100644 --- a/core/global.go +++ b/core/global.go @@ -13,7 +13,7 @@ import ( // Thread-safe for concurrent use via singleton pattern. type Global struct { mu sync.RWMutex - surfaces *Registry[Surface, surfaceMarker] + surfaces *Registry[*Surface, surfaceMarker] hub *Hub } @@ -27,7 +27,7 @@ var ( func GetGlobal() *Global { globalOnce.Do(func() { globalInstance = &Global{ - surfaces: NewRegistry[Surface, surfaceMarker](), + surfaces: NewRegistry[*Surface, surfaceMarker](), hub: NewHub(), } }) @@ -47,21 +47,21 @@ func (g *Global) Hub() *Hub { // RegisterSurface allocates a new ID and stores the surface. // Surfaces are managed separately from other GPU resources because // they're tied to windowing systems and created before adapters. -func (g *Global) RegisterSurface(surface Surface) SurfaceID { +func (g *Global) RegisterSurface(surface *Surface) SurfaceID { g.mu.Lock() defer g.mu.Unlock() return g.surfaces.Register(surface) } // GetSurface retrieves a surface by ID. -func (g *Global) GetSurface(id SurfaceID) (Surface, error) { +func (g *Global) GetSurface(id SurfaceID) (*Surface, error) { g.mu.RLock() defer g.mu.RUnlock() return g.surfaces.Get(id) } // UnregisterSurface removes a surface by ID. -func (g *Global) UnregisterSurface(id SurfaceID) (Surface, error) { +func (g *Global) UnregisterSurface(id SurfaceID) (*Surface, error) { g.mu.Lock() defer g.mu.Unlock() return g.surfaces.Unregister(id) @@ -100,7 +100,7 @@ func (g *Global) Clear() { // Should only be used in tests. func ResetGlobal() { globalInstance = &Global{ - surfaces: NewRegistry[Surface, surfaceMarker](), + surfaces: NewRegistry[*Surface, surfaceMarker](), hub: NewHub(), } } diff --git a/core/global_test.go b/core/global_test.go index b9ecc02..3e5c3a7 100644 --- a/core/global_test.go +++ b/core/global_test.go @@ -37,7 +37,7 @@ func TestGlobalHub(t *testing.T) { func TestGlobalSurface(t *testing.T) { global := GetGlobal() - surface := Surface{} + surface := &Surface{label: "test"} // Register id := global.RegisterSurface(surface) @@ -89,7 +89,7 @@ func TestGlobalStats(t *testing.T) { initialStats := global.Stats() // Register resources - surfaceID := global.RegisterSurface(Surface{}) + surfaceID := global.RegisterSurface(&Surface{}) adapterID := global.Hub().RegisterAdapter(&Adapter{}) deviceID := global.Hub().RegisterDevice(Device{}) bufferID := global.Hub().RegisterBuffer(Buffer{}) @@ -137,7 +137,7 @@ func TestGlobalClear(t *testing.T) { global.Clear() // Start clean // Register resources - surfaceID := global.RegisterSurface(Surface{}) + surfaceID := global.RegisterSurface(&Surface{}) adapterID := global.Hub().RegisterAdapter(&Adapter{}) deviceID := global.Hub().RegisterDevice(Device{}) bufferID := global.Hub().RegisterBuffer(Buffer{}) @@ -187,7 +187,7 @@ func TestGlobalConcurrentSurfaceAccess(t *testing.T) { defer wg.Done() for j := 0; j < opsPerGoroutine; j++ { // Register - id := global.RegisterSurface(Surface{}) + id := global.RegisterSurface(&Surface{}) // Get _, err := global.GetSurface(id) @@ -265,9 +265,9 @@ func TestGlobalMixedOperations(t *testing.T) { initialStats := global.Stats() // Register surfaces and hub resources in mixed order - surfaceID1 := global.RegisterSurface(Surface{}) + surfaceID1 := global.RegisterSurface(&Surface{}) adapterID := global.Hub().RegisterAdapter(&Adapter{}) - surfaceID2 := global.RegisterSurface(Surface{}) + surfaceID2 := global.RegisterSurface(&Surface{}) deviceID := global.Hub().RegisterDevice(Device{}) bufferID := global.Hub().RegisterBuffer(Buffer{}) @@ -333,7 +333,7 @@ func TestGlobalSurfaceEpochMismatch(t *testing.T) { global.Clear() // Start clean // Register and unregister to increment epoch - id1 := global.RegisterSurface(Surface{}) + id1 := global.RegisterSurface(&Surface{}) _, err := global.UnregisterSurface(id1) if err != nil { t.Fatalf("UnregisterSurface failed: %v", err) diff --git a/core/resource.go b/core/resource.go index 54a1b76..9fe4d19 100644 --- a/core/resource.go +++ b/core/resource.go @@ -1312,12 +1312,33 @@ func (qs *QuerySet) Raw(guard *SnatchGuard) hal.QuerySet { return *p } +// SurfaceState represents the lifecycle state of a surface. +type SurfaceState int + +const ( + // SurfaceStateUnconfigured indicates the surface has not been configured. + SurfaceStateUnconfigured SurfaceState = iota + + // SurfaceStateConfigured indicates the surface is configured and ready to acquire textures. + SurfaceStateConfigured + + // SurfaceStateAcquired indicates a texture has been acquired and not yet presented or discarded. + SurfaceStateAcquired +) + +// PrepareFrameFunc is a platform hook called before acquiring a surface texture. +// It returns the current surface dimensions and whether they changed since the last call. +// If changed is true, the surface will be reconfigured with the new dimensions before acquiring. +type PrepareFrameFunc func() (width, height uint32, changed bool) + // Surface represents a rendering surface with HAL integration. // // Surface wraps a HAL surface handle. Unlike other resources, surfaces are // owned by the Instance (not Device) and outlive devices, so the HAL handle -// is stored directly rather than in a Snatchable. Full lifecycle management -// is implemented in CORE-002. +// is stored directly rather than in a Snatchable. +// +// Surface manages a state machine: Unconfigured -> Configured -> Acquired -> Configured. +// All state transitions are protected by a mutex. type Surface struct { // raw is the HAL surface handle. // Not wrapped in Snatchable — surfaces are owned by Instance, not Device. @@ -1325,10 +1346,31 @@ type Surface struct { // label is a debug label for the surface. label string + + // device is the configured device (nil when unconfigured). + device *Device + + // config is the current surface configuration (nil when unconfigured). + config *hal.SurfaceConfiguration + + // state is the current lifecycle state. + state SurfaceState + + // acquiredTex is the currently acquired surface texture (nil when not acquired). + acquiredTex hal.SurfaceTexture + + // prepareFrame is an optional platform hook called before acquiring a texture. + prepareFrame PrepareFrameFunc + + // mu protects state transitions. + mu sync.Mutex } // NewSurface creates a core Surface wrapping a HAL surface. // +// The surface starts in the Unconfigured state. Call Configure() before +// acquiring textures. +// // Parameters: // - halSurface: The HAL surface to wrap (ownership transferred) // - label: Debug label for the surface @@ -1339,6 +1381,7 @@ func NewSurface( s := &Surface{ raw: halSurface, label: label, + state: SurfaceStateUnconfigured, } trackResource(uintptr(unsafe.Pointer(s)), "Surface") //nolint:gosec // debug tracking uses pointer as unique ID return s diff --git a/core/surface.go b/core/surface.go new file mode 100644 index 0000000..9c16ae2 --- /dev/null +++ b/core/surface.go @@ -0,0 +1,236 @@ +package core + +import ( + "errors" + + "github.com/gogpu/wgpu/hal" +) + +// Surface lifecycle errors. +var ( + // ErrSurfaceNotConfigured is returned when attempting to acquire or present + // on a surface that has not been configured. + ErrSurfaceNotConfigured = errors.New("core: surface is not configured") + + // ErrSurfaceAlreadyAcquired is returned when attempting to acquire a texture + // while one is already acquired. + ErrSurfaceAlreadyAcquired = errors.New("core: surface texture already acquired") + + // ErrSurfaceNoTextureAcquired is returned when attempting to present or discard + // without an acquired texture. + ErrSurfaceNoTextureAcquired = errors.New("core: no surface texture acquired") + + // ErrSurfaceConfigureWhileAcquired is returned when attempting to configure + // a surface while a texture is still acquired. + ErrSurfaceConfigureWhileAcquired = errors.New("core: cannot configure surface while texture is acquired") + + // ErrSurfaceNilDevice is returned when a nil device is passed to Configure. + ErrSurfaceNilDevice = errors.New("core: device must not be nil") + + // ErrSurfaceNilConfig is returned when a nil config is passed to Configure. + ErrSurfaceNilConfig = errors.New("core: surface configuration must not be nil") +) + +// SetPrepareFrame registers a platform hook that is called before acquiring a texture. +// +// The hook returns the current surface dimensions and whether they changed. +// If changed is true, the surface is automatically reconfigured before acquiring. +// +// Pass nil to remove the hook. +func (s *Surface) SetPrepareFrame(fn PrepareFrameFunc) { + s.mu.Lock() + defer s.mu.Unlock() + s.prepareFrame = fn +} + +// Configure configures the surface with the given device and settings. +// +// The surface must not have an acquired texture. If the surface is already +// configured, it will be reconfigured with the new settings. +// +// After Configure, the surface enters the Configured state and is ready +// to acquire textures. +func (s *Surface) Configure(device *Device, config *hal.SurfaceConfiguration) error { + s.mu.Lock() + defer s.mu.Unlock() + + if device == nil { + return ErrSurfaceNilDevice + } + if config == nil { + return ErrSurfaceNilConfig + } + if s.state == SurfaceStateAcquired { + return ErrSurfaceConfigureWhileAcquired + } + + halDevice := s.getHALDevice(device) + if halDevice == nil { + return ErrDeviceDestroyed + } + + if err := s.raw.Configure(halDevice, config); err != nil { + return err + } + + s.device = device + s.config = config + s.state = SurfaceStateConfigured + return nil +} + +// Unconfigure removes the surface configuration and returns to the Unconfigured state. +// +// If a texture is currently acquired, it is discarded first. +// If the surface is already unconfigured, this is a no-op. +func (s *Surface) Unconfigure() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.state == SurfaceStateUnconfigured { + return + } + + // Discard acquired texture if any + if s.state == SurfaceStateAcquired && s.acquiredTex != nil { + s.raw.DiscardTexture(s.acquiredTex) + s.acquiredTex = nil + } + + halDevice := s.getHALDevice(s.device) + if halDevice != nil { + s.raw.Unconfigure(halDevice) + } + + s.device = nil + s.config = nil + s.state = SurfaceStateUnconfigured +} + +// AcquireTexture acquires the next surface texture for rendering. +// +// The surface must be in the Configured state. If a PrepareFrame hook is +// registered and reports that dimensions changed, the surface is automatically +// reconfigured before acquiring. +// +// After a successful acquire, the surface enters the Acquired state. +// The caller must either Present or DiscardTexture before acquiring again. +func (s *Surface) AcquireTexture(fence hal.Fence) (*hal.AcquiredSurfaceTexture, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.state == SurfaceStateAcquired { + return nil, ErrSurfaceAlreadyAcquired + } + if s.state != SurfaceStateConfigured { + return nil, ErrSurfaceNotConfigured + } + + // Call PrepareFrame hook if registered + if err := s.applyPrepareFrame(); err != nil { + return nil, err + } + + result, err := s.raw.AcquireTexture(fence) + if err != nil { + return nil, err + } + + s.acquiredTex = result.Texture + s.state = SurfaceStateAcquired + return result, nil +} + +// Present presents the acquired surface texture to the screen. +// +// The surface must be in the Acquired state. After presenting, the surface +// returns to the Configured state and is ready to acquire again. +func (s *Surface) Present(queue hal.Queue) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.state != SurfaceStateAcquired { + return ErrSurfaceNoTextureAcquired + } + + err := queue.Present(s.raw, s.acquiredTex) + s.acquiredTex = nil + s.state = SurfaceStateConfigured + return err +} + +// DiscardTexture discards the acquired surface texture without presenting it. +// +// Use this if rendering failed or was canceled. If no texture is acquired, +// this is a no-op. +func (s *Surface) DiscardTexture() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.state != SurfaceStateAcquired { + return + } + + if s.acquiredTex != nil { + s.raw.DiscardTexture(s.acquiredTex) + } + + s.acquiredTex = nil + s.state = SurfaceStateConfigured +} + +// State returns the current lifecycle state of the surface. +func (s *Surface) State() SurfaceState { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +// Config returns the current surface configuration. +// Returns nil if the surface is unconfigured. +func (s *Surface) Config() *hal.SurfaceConfiguration { + s.mu.Lock() + defer s.mu.Unlock() + return s.config +} + +// applyPrepareFrame calls the PrepareFrame hook and reconfigures if dimensions changed. +// Must be called with s.mu held. +func (s *Surface) applyPrepareFrame() error { + if s.prepareFrame == nil { + return nil + } + + width, height, changed := s.prepareFrame() + if !changed || s.config == nil { + return nil + } + + newConfig := *s.config + newConfig.Width = width + newConfig.Height = height + + halDevice := s.getHALDevice(s.device) + if halDevice == nil { + return ErrDeviceDestroyed + } + + if err := s.raw.Configure(halDevice, &newConfig); err != nil { + return err + } + s.config = &newConfig + return nil +} + +// getHALDevice extracts the hal.Device from a core.Device using the snatch lock. +// Returns nil if the device has been destroyed or has no HAL integration. +// Must NOT be called with s.mu held if the device's snatch lock could deadlock; +// in practice the snatch lock is independent so this is safe. +func (s *Surface) getHALDevice(device *Device) hal.Device { + if device == nil || device.SnatchLock() == nil { + return nil + } + guard := device.SnatchLock().Read() + defer guard.Release() + return device.Raw(guard) +} diff --git a/core/surface_test.go b/core/surface_test.go new file mode 100644 index 0000000..0865a0f --- /dev/null +++ b/core/surface_test.go @@ -0,0 +1,366 @@ +package core + +import ( + "errors" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/noop" +) + +// newTestSurface creates a test Surface with a noop HAL backend. +// Returns the core Surface, a core Device (with HAL), and a noop Queue. +func newTestSurface(t *testing.T) (*Surface, *Device, hal.Queue) { + t.Helper() + + api := noop.API{} + inst, err := api.CreateInstance(nil) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + + halSurface, err := inst.CreateSurface(0, 0) + if err != nil { + t.Fatalf("CreateSurface: %v", err) + } + + adapters := inst.EnumerateAdapters(nil) + if len(adapters) == 0 { + t.Fatal("no adapters returned by noop backend") + } + + openDev, err := adapters[0].Adapter.Open(0, gputypes.DefaultLimits()) + if err != nil { + t.Fatalf("Adapter.Open: %v", err) + } + + device := NewDevice( + openDev.Device, + nil, // adapter not needed for surface tests + 0, + gputypes.DefaultLimits(), + "test-device", + ) + + coreSurface := NewSurface(halSurface, "test-surface") + return coreSurface, device, openDev.Queue +} + +// testSurfaceConfig returns a default SurfaceConfiguration for testing. +func testSurfaceConfig() *hal.SurfaceConfiguration { + return &hal.SurfaceConfiguration{ + Width: 800, + Height: 600, + Format: gputypes.TextureFormatBGRA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + } +} + +func TestSurfaceNewUnconfigured(t *testing.T) { + surface, _, _ := newTestSurface(t) + + if surface.State() != SurfaceStateUnconfigured { + t.Errorf("new surface state = %d, want SurfaceStateUnconfigured (%d)", + surface.State(), SurfaceStateUnconfigured) + } + if surface.Config() != nil { + t.Error("new surface config should be nil") + } +} + +func TestSurfaceConfigure(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + err := surface.Configure(device, config) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + if surface.State() != SurfaceStateConfigured { + t.Errorf("state after Configure = %d, want SurfaceStateConfigured (%d)", + surface.State(), SurfaceStateConfigured) + } + if surface.Config() == nil { + t.Error("config should not be nil after Configure") + } + if surface.Config().Width != 800 || surface.Config().Height != 600 { + t.Errorf("config dimensions = %dx%d, want 800x600", + surface.Config().Width, surface.Config().Height) + } +} + +func TestSurfaceConfigureNilDevice(t *testing.T) { + surface, _, _ := newTestSurface(t) + config := testSurfaceConfig() + + err := surface.Configure(nil, config) + if !errors.Is(err, ErrSurfaceNilDevice) { + t.Errorf("Configure(nil device) = %v, want ErrSurfaceNilDevice", err) + } +} + +func TestSurfaceConfigureNilConfig(t *testing.T) { + surface, device, _ := newTestSurface(t) + + err := surface.Configure(device, nil) + if !errors.Is(err, ErrSurfaceNilConfig) { + t.Errorf("Configure(nil config) = %v, want ErrSurfaceNilConfig", err) + } +} + +func TestSurfaceAcquirePresent(t *testing.T) { + surface, device, queue := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + // Acquire + result, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + if result == nil || result.Texture == nil { + t.Fatal("AcquireTexture returned nil result or texture") + } + if surface.State() != SurfaceStateAcquired { + t.Errorf("state after Acquire = %d, want SurfaceStateAcquired (%d)", + surface.State(), SurfaceStateAcquired) + } + + // Present + if err := surface.Present(queue); err != nil { + t.Fatalf("Present: %v", err) + } + if surface.State() != SurfaceStateConfigured { + t.Errorf("state after Present = %d, want SurfaceStateConfigured (%d)", + surface.State(), SurfaceStateConfigured) + } +} + +func TestSurfaceDoubleAcquire(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + // First acquire succeeds + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("first AcquireTexture: %v", err) + } + + // Second acquire fails + _, err = surface.AcquireTexture(nil) + if !errors.Is(err, ErrSurfaceAlreadyAcquired) { + t.Errorf("second AcquireTexture = %v, want ErrSurfaceAlreadyAcquired", err) + } +} + +func TestSurfacePresentWithoutAcquire(t *testing.T) { + surface, device, queue := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + err := surface.Present(queue) + if !errors.Is(err, ErrSurfaceNoTextureAcquired) { + t.Errorf("Present without acquire = %v, want ErrSurfaceNoTextureAcquired", err) + } +} + +func TestSurfaceAcquireWithoutConfigure(t *testing.T) { + surface, _, _ := newTestSurface(t) + + _, err := surface.AcquireTexture(nil) + if !errors.Is(err, ErrSurfaceNotConfigured) { + t.Errorf("AcquireTexture unconfigured = %v, want ErrSurfaceNotConfigured", err) + } +} + +func TestSurfaceUnconfigureWhileAcquired(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + + // Unconfigure while acquired — should discard and return to unconfigured + surface.Unconfigure() + + if surface.State() != SurfaceStateUnconfigured { + t.Errorf("state after Unconfigure = %d, want SurfaceStateUnconfigured (%d)", + surface.State(), SurfaceStateUnconfigured) + } + if surface.Config() != nil { + t.Error("config should be nil after Unconfigure") + } +} + +func TestSurfaceReconfigure(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + // First configure + if err := surface.Configure(device, config); err != nil { + t.Fatalf("first Configure: %v", err) + } + + // Reconfigure with different dimensions + config2 := &hal.SurfaceConfiguration{ + Width: 1024, + Height: 768, + Format: gputypes.TextureFormatBGRA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + } + if err := surface.Configure(device, config2); err != nil { + t.Fatalf("second Configure: %v", err) + } + + if surface.State() != SurfaceStateConfigured { + t.Errorf("state after reconfigure = %d, want SurfaceStateConfigured", surface.State()) + } + if surface.Config().Width != 1024 || surface.Config().Height != 768 { + t.Errorf("config dimensions = %dx%d, want 1024x768", + surface.Config().Width, surface.Config().Height) + } +} + +func TestSurfaceConfigureWhileAcquired(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + + // Configure while acquired should fail + err = surface.Configure(device, config) + if !errors.Is(err, ErrSurfaceConfigureWhileAcquired) { + t.Errorf("Configure while acquired = %v, want ErrSurfaceConfigureWhileAcquired", err) + } +} + +func TestSurfacePrepareFrame(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + called := false + surface.SetPrepareFrame(func() (uint32, uint32, bool) { + called = true + return 800, 600, false // no change + }) + + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + + if !called { + t.Error("PrepareFrame hook was not called") + } +} + +func TestSurfacePrepareFrameReconfigure(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + // PrepareFrame reports new dimensions + surface.SetPrepareFrame(func() (uint32, uint32, bool) { + return 1920, 1080, true // changed + }) + + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + + // Config should have been updated + if surface.Config().Width != 1920 || surface.Config().Height != 1080 { + t.Errorf("config after PrepareFrame = %dx%d, want 1920x1080", + surface.Config().Width, surface.Config().Height) + } +} + +func TestSurfaceDiscardTexture(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + _, err := surface.AcquireTexture(nil) + if err != nil { + t.Fatalf("AcquireTexture: %v", err) + } + + surface.DiscardTexture() + + if surface.State() != SurfaceStateConfigured { + t.Errorf("state after DiscardTexture = %d, want SurfaceStateConfigured", surface.State()) + } + + // Should be able to acquire again after discard + _, err = surface.AcquireTexture(nil) + if err != nil { + t.Errorf("AcquireTexture after discard: %v", err) + } +} + +func TestSurfaceDiscardWithoutAcquire(t *testing.T) { + surface, device, _ := newTestSurface(t) + config := testSurfaceConfig() + + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure: %v", err) + } + + // DiscardTexture when not acquired should be a no-op + surface.DiscardTexture() + + if surface.State() != SurfaceStateConfigured { + t.Errorf("state after no-op DiscardTexture = %d, want SurfaceStateConfigured", surface.State()) + } +} + +func TestSurfaceUnconfigureWhenUnconfigured(t *testing.T) { + surface, _, _ := newTestSurface(t) + + // Unconfigure when already unconfigured should be a no-op + surface.Unconfigure() + + if surface.State() != SurfaceStateUnconfigured { + t.Errorf("state after no-op Unconfigure = %d, want SurfaceStateUnconfigured", surface.State()) + } +} From b509d080c1e405c9c3c39de55e75c69d3597cfc1 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 23:25:56 +0300 Subject: [PATCH 03/13] feat: migrate public API Surface to core.Surface delegation (CORE-005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wgpu.Surface now delegates to core.Surface instead of using hal.Surface directly. All lifecycle methods go through core validation and state tracking: - Configure/Unconfigure → core state machine validation - GetCurrentTexture → PrepareFrame hook + state check + HAL acquire - Present → acquired texture validation + HAL present New public methods: - SetPrepareFrame(fn) — platform hook for HiDPI/DPI changes (Metal contentsScale, Windows WM_DPICHANGED, Wayland wl_output.scale) - HAL() — escape hatch for backward-compatible direct HAL access This enables gogpu PLAT-001 (PrepareFrame architecture) to register platform callbacks on the wgpu Surface. --- CHANGELOG.md | 6 ++++++ surface.go | 52 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 066fc92..9c6bae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Includes `PrepareFrameFunc` hook for platform DPI/scale integration — called before each AcquireTexture, auto-reconfigures surface on dimension changes (CORE-002). +- **public API: Surface delegates to core.Surface** — `wgpu.Surface` now uses + `core.Surface` internally instead of `hal.Surface` directly. All lifecycle methods + (Configure, Unconfigure, GetCurrentTexture, Present) go through core validation and + state tracking. New public methods: `SetPrepareFrame()` for platform HiDPI hooks, + `HAL()` escape hatch for backward compatibility (CORE-005). + ## [0.20.2] - 2026-03-12 ### Fixed diff --git a/surface.go b/surface.go index eea5f43..4ccadd6 100644 --- a/surface.go +++ b/surface.go @@ -3,12 +3,16 @@ package wgpu import ( "fmt" + "github.com/gogpu/wgpu/core" "github.com/gogpu/wgpu/hal" ) // Surface represents a platform rendering surface (e.g., a window). +// +// Surface delegates lifecycle management to core.Surface, which enforces +// the state machine: Unconfigured -> Configured -> Acquired -> Configured. type Surface struct { - hal hal.Surface + core *core.Surface instance *Instance device *Device released bool @@ -35,8 +39,9 @@ func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, return nil, fmt.Errorf("wgpu: failed to create surface: %w", err) } + coreSurface := core.NewSurface(halSurface, "") return &Surface{ - hal: halSurface, + core: coreSurface, instance: i, }, nil } @@ -54,11 +59,6 @@ func (s *Surface) Configure(device *Device, config *SurfaceConfiguration) error return fmt.Errorf("wgpu: device is nil") } - halDevice := device.halDevice() - if halDevice == nil { - return ErrReleased - } - halConfig := &hal.SurfaceConfiguration{ Width: config.Width, Height: config.Height, @@ -69,23 +69,22 @@ func (s *Surface) Configure(device *Device, config *SurfaceConfiguration) error } s.device = device - return s.hal.Configure(halDevice, halConfig) + return s.core.Configure(device.core, halConfig) } // Unconfigure removes the surface configuration. func (s *Surface) Unconfigure() { - if s.released || s.device == nil { - return - } - halDevice := s.device.halDevice() - if halDevice == nil { + if s.released { return } - s.hal.Unconfigure(halDevice) + s.core.Unconfigure() } // GetCurrentTexture acquires the next texture for rendering. // Returns the surface texture and whether the surface is suboptimal. +// +// If a PrepareFrame hook is registered and reports changed dimensions, +// the surface is automatically reconfigured before acquiring. func (s *Surface) GetCurrentTexture() (*SurfaceTexture, bool, error) { if s.released { return nil, false, ErrReleased @@ -105,7 +104,7 @@ func (s *Surface) GetCurrentTexture() (*SurfaceTexture, bool, error) { } defer halDevice.DestroyFence(fence) - acquired, err := s.hal.AcquireTexture(fence) + acquired, err := s.core.AcquireTexture(fence) if err != nil { return nil, false, err } @@ -133,7 +132,25 @@ func (s *Surface) Present(texture *SurfaceTexture) error { return fmt.Errorf("wgpu: surface texture is nil") } - return s.device.queue.hal.Present(s.hal, texture.hal) + return s.core.Present(s.device.queue.hal) +} + +// SetPrepareFrame registers a platform hook called before each GetCurrentTexture. +// If the hook returns changed=true with new dimensions, the surface is automatically +// reconfigured. This is the integration point for HiDPI/DPI change handling: +// - macOS Metal: read CAMetalLayer.contentsScale +// - Windows: handle WM_DPICHANGED +// - Wayland: read wl_output.scale +// +// Pass nil to remove the hook. +func (s *Surface) SetPrepareFrame(fn core.PrepareFrameFunc) { + s.core.SetPrepareFrame(fn) +} + +// HAL returns the underlying HAL surface for backward compatibility. +// Prefer using Surface methods instead of direct HAL access. +func (s *Surface) HAL() hal.Surface { + return s.core.RawSurface() } // Release releases the surface. @@ -142,7 +159,8 @@ func (s *Surface) Release() { return } s.released = true - s.hal.Destroy() + s.core.RawSurface().Destroy() + s.core = nil } // SurfaceTexture is a texture acquired from a surface for rendering. From 4923462bb78ec81a4c40034381a5c8645e1709da Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 23:40:28 +0300 Subject: [PATCH 04/13] feat(core): CommandEncoder/CommandBuffer state machine (CORE-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandEncoder tracks pass state with validated transitions: - Recording → BeginRenderPass → InRenderPass → EndRenderPass → Recording - Recording → BeginComputePass → InComputePass → EndComputePass → Recording - Recording → Finish → Finished (validates no open passes) - Any state → RecordError → Error (captures first error message) CommandBuffer tracks submission state: - Available → MarkSubmitted → Submitted (prevents double-submit) 13 new tests covering all state transitions and error conditions. --- CHANGELOG.md | 7 ++ core/command_encoder.go | 123 +++++++++++++++++++++ core/command_encoder_test.go | 204 +++++++++++++++++++++++++++++++++++ core/resource.go | 91 ++++++++++++++-- 4 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 core/command_encoder.go create mode 100644 core/command_encoder_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6bae0..71dea2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 state tracking. New public methods: `SetPrepareFrame()` for platform HiDPI hooks, `HAL()` escape hatch for backward compatibility (CORE-005). +- **core: CommandEncoder/CommandBuffer state machine** — `core.CommandEncoder` now tracks + pass state (Recording, InRenderPass, InComputePass, Finished, Error) with validated + transitions. BeginRenderPass/EndRenderPass, BeginComputePass/EndComputePass enforce + proper nesting. Finish validates no open passes. RecordError captures first error. + `core.CommandBuffer` tracks submission state (Available, Submitted) to prevent + double-submission (CORE-003). + ## [0.20.2] - 2026-03-12 ### Fixed diff --git a/core/command_encoder.go b/core/command_encoder.go new file mode 100644 index 0000000..acdb469 --- /dev/null +++ b/core/command_encoder.go @@ -0,0 +1,123 @@ +package core + +import "fmt" + +// ============================================================================= +// CommandEncoder State Machine (CORE-003) +// ============================================================================= + +// PassState returns the current pass lifecycle state. +func (e *CommandEncoder) PassState() CommandEncoderPassState { + return e.passState +} + +// Label returns the encoder's debug label. +func (e *CommandEncoder) EncoderLabel() string { + return e.label +} + +// BeginRenderPass validates the encoder state and transitions to InRenderPass. +// +// The encoder must be in the Recording state. After this call, the encoder +// is locked in the InRenderPass state until EndRenderPass is called. +func (e *CommandEncoder) BeginRenderPass() error { + if e.passState != CommandEncoderPassStateRecording { + return fmt.Errorf("core: command encoder: cannot begin render pass in %s state", e.passState) + } + e.passState = CommandEncoderPassStateInRenderPass + e.passDepth++ + return nil +} + +// EndRenderPass validates the encoder state and transitions back to Recording. +// +// The encoder must be in the InRenderPass state. +func (e *CommandEncoder) EndRenderPass() error { + if e.passState != CommandEncoderPassStateInRenderPass { + return fmt.Errorf("core: command encoder: cannot end render pass in %s state", e.passState) + } + e.passState = CommandEncoderPassStateRecording + e.passDepth-- + return nil +} + +// BeginComputePass validates the encoder state and transitions to InComputePass. +// +// The encoder must be in the Recording state. After this call, the encoder +// is locked in the InComputePass state until EndComputePass is called. +func (e *CommandEncoder) BeginComputePass() error { + if e.passState != CommandEncoderPassStateRecording { + return fmt.Errorf("core: command encoder: cannot begin compute pass in %s state", e.passState) + } + e.passState = CommandEncoderPassStateInComputePass + e.passDepth++ + return nil +} + +// EndComputePass validates the encoder state and transitions back to Recording. +// +// The encoder must be in the InComputePass state. +func (e *CommandEncoder) EndComputePass() error { + if e.passState != CommandEncoderPassStateInComputePass { + return fmt.Errorf("core: command encoder: cannot end compute pass in %s state", e.passState) + } + e.passState = CommandEncoderPassStateRecording + e.passDepth-- + return nil +} + +// Finish validates the encoder state and transitions to Finished. +// +// The encoder must be in the Recording state with no open passes. +// Returns an error if the encoder is in the Error state, not in Recording +// state, or has open passes. +func (e *CommandEncoder) Finish() error { + if e.passState == CommandEncoderPassStateError { + return fmt.Errorf("core: command encoder: encoder in error state: %s", e.errorMessage) + } + if e.passState != CommandEncoderPassStateRecording { + return fmt.Errorf("core: command encoder: cannot finish in %s state", e.passState) + } + if e.passDepth != 0 { + return fmt.Errorf("core: command encoder: cannot finish with %d open passes", e.passDepth) + } + e.passState = CommandEncoderPassStateFinished + return nil +} + +// RecordError records the first error encountered by this encoder. +// +// The encoder transitions to the Error state. Subsequent calls to RecordError +// are ignored, preserving the first error message. +func (e *CommandEncoder) RecordError(msg string) { + if e.passState == CommandEncoderPassStateError { + return // Keep first error + } + e.errorMessage = msg + e.passState = CommandEncoderPassStateError +} + +// ErrorMessage returns the recorded error message, or empty string if none. +func (e *CommandEncoder) ErrorMessage() string { + return e.errorMessage +} + +// ============================================================================= +// CommandBuffer State Methods (CORE-003) +// ============================================================================= + +// MarkSubmitted transitions the command buffer to the submitted state. +// +// Returns an error if the buffer has already been submitted. +func (cb *CommandBuffer) MarkSubmitted() error { + if cb.submitState != CommandBufferSubmitStateAvailable { + return fmt.Errorf("core: command buffer: already submitted") + } + cb.submitState = CommandBufferSubmitStateSubmitted + return nil +} + +// IsSubmitted returns whether the command buffer has been submitted. +func (cb *CommandBuffer) IsSubmitted() bool { + return cb.submitState == CommandBufferSubmitStateSubmitted +} diff --git a/core/command_encoder_test.go b/core/command_encoder_test.go new file mode 100644 index 0000000..e660a88 --- /dev/null +++ b/core/command_encoder_test.go @@ -0,0 +1,204 @@ +package core + +import "testing" + +// ============================================================================= +// CommandEncoder State Machine Tests +// ============================================================================= + +func TestCommandEncoderPassState_NewIsRecording(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + if enc.PassState() != CommandEncoderPassStateRecording { + t.Errorf("new encoder state = %v, want Recording", enc.PassState()) + } +} + +func TestCommandEncoderPassState_RenderPassLifecycle(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + if err := enc.BeginRenderPass(); err != nil { + t.Fatalf("BeginRenderPass() error = %v", err) + } + if enc.PassState() != CommandEncoderPassStateInRenderPass { + t.Errorf("state after BeginRenderPass = %v, want InRenderPass", enc.PassState()) + } + + if err := enc.EndRenderPass(); err != nil { + t.Fatalf("EndRenderPass() error = %v", err) + } + if enc.PassState() != CommandEncoderPassStateRecording { + t.Errorf("state after EndRenderPass = %v, want Recording", enc.PassState()) + } +} + +func TestCommandEncoderPassState_ComputePassLifecycle(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + if err := enc.BeginComputePass(); err != nil { + t.Fatalf("BeginComputePass() error = %v", err) + } + if enc.PassState() != CommandEncoderPassStateInComputePass { + t.Errorf("state after BeginComputePass = %v, want InComputePass", enc.PassState()) + } + + if err := enc.EndComputePass(); err != nil { + t.Fatalf("EndComputePass() error = %v", err) + } + if enc.PassState() != CommandEncoderPassStateRecording { + t.Errorf("state after EndComputePass = %v, want Recording", enc.PassState()) + } +} + +func TestCommandEncoderPassState_Finish(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + if err := enc.Finish(); err != nil { + t.Fatalf("Finish() error = %v", err) + } + if enc.PassState() != CommandEncoderPassStateFinished { + t.Errorf("state after Finish = %v, want Finished", enc.PassState()) + } +} + +func TestCommandEncoderPassState_DoubleBeginRenderPass(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + if err := enc.BeginRenderPass(); err != nil { + t.Fatalf("first BeginRenderPass() error = %v", err) + } + + err := enc.BeginRenderPass() + if err == nil { + t.Fatal("second BeginRenderPass() should return error") + } +} + +func TestCommandEncoderPassState_EndWithoutBegin(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + err := enc.EndRenderPass() + if err == nil { + t.Fatal("EndRenderPass() without Begin should return error") + } + + err = enc.EndComputePass() + if err == nil { + t.Fatal("EndComputePass() without Begin should return error") + } +} + +func TestCommandEncoderPassState_FinishWhileInPass(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + _ = enc.BeginRenderPass() + + err := enc.Finish() + if err == nil { + t.Fatal("Finish() while in pass should return error") + } +} + +func TestCommandEncoderPassState_FinishAfterError(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + enc.RecordError("something went wrong") + + err := enc.Finish() + if err == nil { + t.Fatal("Finish() after error should return error") + } +} + +func TestCommandEncoderRecordError(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + + enc.RecordError("first error") + if enc.PassState() != CommandEncoderPassStateError { + t.Errorf("state after RecordError = %v, want Error", enc.PassState()) + } + if enc.ErrorMessage() != "first error" { + t.Errorf("error message = %q, want %q", enc.ErrorMessage(), "first error") + } + + // Second error should be ignored + enc.RecordError("second error") + if enc.ErrorMessage() != "first error" { + t.Errorf("error message after second RecordError = %q, want %q", enc.ErrorMessage(), "first error") + } +} + +func TestCommandEncoderOperationAfterFinish(t *testing.T) { + enc := NewCommandEncoder(nil, nil, "test") + _ = enc.Finish() + + err := enc.BeginRenderPass() + if err == nil { + t.Fatal("BeginRenderPass() after Finish should return error") + } + + err = enc.BeginComputePass() + if err == nil { + t.Fatal("BeginComputePass() after Finish should return error") + } +} + +// ============================================================================= +// CommandBuffer State Tests +// ============================================================================= + +func TestCommandBufferMarkSubmitted(t *testing.T) { + // Create CommandBuffer directly to avoid nil device panic in NewCommandBuffer. + cb := &CommandBuffer{ + label: "test", + submitState: CommandBufferSubmitStateAvailable, + } + if cb.IsSubmitted() { + t.Fatal("new command buffer should not be submitted") + } + + err := cb.MarkSubmitted() + if err != nil { + t.Fatalf("MarkSubmitted() error = %v", err) + } + if !cb.IsSubmitted() { + t.Fatal("command buffer should be submitted after MarkSubmitted()") + } +} + +func TestCommandBufferDoubleSubmit(t *testing.T) { + cb := &CommandBuffer{ + label: "test", + submitState: CommandBufferSubmitStateAvailable, + } + _ = cb.MarkSubmitted() + + err := cb.MarkSubmitted() + if err == nil { + t.Fatal("second MarkSubmitted() should return error") + } +} + +// ============================================================================= +// CommandEncoderPassState String Tests +// ============================================================================= + +func TestCommandEncoderPassState_String(t *testing.T) { + tests := []struct { + state CommandEncoderPassState + expected string + }{ + {CommandEncoderPassStateRecording, "Recording"}, + {CommandEncoderPassStateInRenderPass, "InRenderPass"}, + {CommandEncoderPassStateInComputePass, "InComputePass"}, + {CommandEncoderPassStateFinished, "Finished"}, + {CommandEncoderPassStateError, "Error"}, + {CommandEncoderPassState(99), "Unknown(99)"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + got := tt.state.String() + if got != tt.expected { + t.Errorf("String() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/core/resource.go b/core/resource.go index 9fe4d19..b026e35 100644 --- a/core/resource.go +++ b/core/resource.go @@ -1,6 +1,7 @@ package core import ( + "fmt" "sync" "sync/atomic" "unsafe" @@ -1157,11 +1158,61 @@ func (cp *ComputePipeline) Raw(guard *SnatchGuard) hal.ComputePipeline { return *p } +// CommandEncoderPassState represents the current state of a command encoder +// with respect to pass lifecycle. +type CommandEncoderPassState int + +const ( + // CommandEncoderPassStateRecording means the encoder is recording commands + // outside of any pass. + CommandEncoderPassStateRecording CommandEncoderPassState = iota + + // CommandEncoderPassStateInRenderPass means the encoder is inside a render pass. + CommandEncoderPassStateInRenderPass + + // CommandEncoderPassStateInComputePass means the encoder is inside a compute pass. + CommandEncoderPassStateInComputePass + + // CommandEncoderPassStateFinished means encoding is complete. + CommandEncoderPassStateFinished + + // CommandEncoderPassStateError means the encoder encountered an error. + CommandEncoderPassStateError +) + +// String returns a human-readable representation of the pass state. +func (s CommandEncoderPassState) String() string { + switch s { + case CommandEncoderPassStateRecording: + return "Recording" + case CommandEncoderPassStateInRenderPass: + return "InRenderPass" + case CommandEncoderPassStateInComputePass: + return "InComputePass" + case CommandEncoderPassStateFinished: + return "Finished" + case CommandEncoderPassStateError: + return "Error" + default: + return fmt.Sprintf("Unknown(%d)", s) + } +} + // CommandEncoder represents a command encoder with HAL integration. // -// CommandEncoder wraps a HAL command encoder handle. The full state machine -// for encoding commands is implemented in CORE-003; this struct provides -// the basic storage structure. +// CommandEncoder wraps a HAL command encoder handle and tracks the encoder's +// lifecycle state. The state machine ensures commands are recorded in the +// correct order: passes must be opened and closed properly, and encoding +// must be finished before the resulting command buffer can be submitted. +// +// State transitions: +// +// Recording -> BeginRenderPass() -> InRenderPass +// Recording -> BeginComputePass() -> InComputePass +// InRenderPass -> EndRenderPass() -> Recording +// InComputePass -> EndComputePass() -> Recording +// Recording -> Finish() -> Finished +// Any -> RecordError() -> Error type CommandEncoder struct { // raw is the HAL command encoder handle. // Not wrapped in Snatchable — state machine lifecycle is managed by CORE-003. @@ -1170,12 +1221,23 @@ type CommandEncoder struct { // device is a pointer to the parent Device. device *Device + // passState is the current pass lifecycle state. + passState CommandEncoderPassState + // label is a debug label for the command encoder. label string + + // passDepth tracks nesting depth (should never exceed 1). + passDepth int + + // errorMessage holds the first error encountered. + errorMessage string } // NewCommandEncoder creates a core CommandEncoder wrapping a HAL command encoder. // +// The encoder starts in the Recording state, ready to record commands. +// // Parameters: // - halEncoder: The HAL command encoder to wrap (ownership transferred) // - device: The parent device @@ -1186,9 +1248,10 @@ func NewCommandEncoder( label string, ) *CommandEncoder { ce := &CommandEncoder{ - raw: halEncoder, - device: device, - label: label, + raw: halEncoder, + device: device, + passState: CommandEncoderPassStateRecording, + label: label, } trackResource(uintptr(unsafe.Pointer(ce)), "CommandEncoder") //nolint:gosec // debug tracking uses pointer as unique ID return ce @@ -1199,10 +1262,21 @@ func (ce *CommandEncoder) RawEncoder() hal.CommandEncoder { return ce.raw } +// CommandBufferSubmitState represents the submission state of a command buffer. +type CommandBufferSubmitState int + +const ( + // CommandBufferSubmitStateAvailable means the buffer is ready for submission. + CommandBufferSubmitStateAvailable CommandBufferSubmitState = iota + + // CommandBufferSubmitStateSubmitted means the buffer has been submitted to a queue. + CommandBufferSubmitStateSubmitted +) + // CommandBuffer represents a recorded command buffer with HAL integration. // // CommandBuffer wraps a HAL command buffer handle. Command buffers are -// immutable after encoding and can be submitted to a queue. +// immutable after encoding and can be submitted to a queue exactly once. type CommandBuffer struct { // raw is the HAL command buffer handle wrapped for safe destruction. raw *Snatchable[hal.CommandBuffer] @@ -1213,6 +1287,9 @@ type CommandBuffer struct { // label is a debug label for the command buffer. label string + // submitState tracks whether the buffer has been submitted. + submitState CommandBufferSubmitState + // trackingData holds per-resource tracking information. trackingData *TrackingData } From acd5044db05ba1c086b2eeede862c1ac219a7279 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 14 Mar 2026 23:45:24 +0300 Subject: [PATCH 05/13] feat(core): resource accessor methods and Destroy for all types (CORE-004) Add read-only accessors and idempotent Destroy for 9 resource types: - Texture: Format, Dimension, Usage, Size, MipLevelCount, SampleCount - BindGroupLayout: Entries, EntryCount - QuerySet: QueryType, Count - All types: Label, Destroy, IsDestroyed Destroy follows Buffer snatch-and-release pattern: read-lock device, release lock, write-lock to snatch HAL handle, destroy via device. Safe to call multiple times (idempotent). Methods in separate resource_accessors.go with full test coverage. --- CHANGELOG.md | 7 + core/resource_accessors.go | 489 ++++++++++++++++++++++++++++++++ core/resource_accessors_test.go | 465 ++++++++++++++++++++++++++++++ 3 files changed, 961 insertions(+) create mode 100644 core/resource_accessors.go create mode 100644 core/resource_accessors_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 71dea2d..7895b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **core: resource accessor methods and Destroy** — All pipeline and binding resource + types (Texture, Sampler, BindGroupLayout, PipelineLayout, BindGroup, ShaderModule, + RenderPipeline, ComputePipeline, QuerySet) now have read-only accessor methods for + their properties (Label, Format, Size, Entries, Count, etc.), an idempotent Destroy() + method following the Buffer snatch-and-release pattern, and an IsDestroyed() check. + Methods are in a separate `resource_accessors.go` file with full test coverage (CORE-004). + - **core: complete all 12 stub resource types** — Texture, Sampler, BindGroupLayout, PipelineLayout, BindGroup, ShaderModule, RenderPipeline, ComputePipeline, CommandEncoder, CommandBuffer, QuerySet, and Surface now have full struct definitions diff --git a/core/resource_accessors.go b/core/resource_accessors.go new file mode 100644 index 0000000..1402a98 --- /dev/null +++ b/core/resource_accessors.go @@ -0,0 +1,489 @@ +package core + +import ( + "unsafe" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +// ============================================================================= +// Texture accessors and Destroy +// ============================================================================= + +// Format returns the texture's pixel format. +func (t *Texture) Format() gputypes.TextureFormat { + return t.format +} + +// Dimension returns the texture's dimension (1D, 2D, 3D). +func (t *Texture) Dimension() gputypes.TextureDimension { + return t.dimension +} + +// Usage returns the texture's usage flags. +func (t *Texture) Usage() gputypes.TextureUsage { + return t.usage +} + +// Size returns the texture's dimensions. +func (t *Texture) Size() gputypes.Extent3D { + return t.size +} + +// MipLevelCount returns the number of mip levels. +func (t *Texture) MipLevelCount() uint32 { + return t.mipLevelCount +} + +// SampleCount returns the number of samples per pixel. +func (t *Texture) SampleCount() uint32 { + return t.sampleCount +} + +// Label returns the texture's debug label. +func (t *Texture) Label() string { + return t.label +} + +// Destroy releases the HAL texture. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (t *Texture) Destroy() { + untrackResource(uintptr(unsafe.Pointer(t))) //nolint:gosec // debug tracking uses pointer as unique ID + + if t.device == nil || t.device.SnatchLock() == nil || t.raw == nil { + return + } + + readGuard := t.device.SnatchLock().Read() + halDevice := t.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := t.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halTexture := t.raw.Snatch(exclusiveGuard) + if halTexture == nil { + return + } + + halDevice.DestroyTexture(*halTexture) +} + +// IsDestroyed returns true if the texture has been destroyed. +func (t *Texture) IsDestroyed() bool { + if t.raw == nil { + return true + } + return t.raw.IsSnatched() +} + +// ============================================================================= +// Sampler accessors and Destroy +// ============================================================================= + +// Label returns the sampler's debug label. +func (s *Sampler) Label() string { + return s.label +} + +// Destroy releases the HAL sampler. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (s *Sampler) Destroy() { + untrackResource(uintptr(unsafe.Pointer(s))) //nolint:gosec // debug tracking uses pointer as unique ID + + if s.device == nil || s.device.SnatchLock() == nil || s.raw == nil { + return + } + + readGuard := s.device.SnatchLock().Read() + halDevice := s.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := s.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halSampler := s.raw.Snatch(exclusiveGuard) + if halSampler == nil { + return + } + + halDevice.DestroySampler(*halSampler) +} + +// IsDestroyed returns true if the sampler has been destroyed. +func (s *Sampler) IsDestroyed() bool { + if s.raw == nil { + return true + } + return s.raw.IsSnatched() +} + +// ============================================================================= +// BindGroupLayout accessors and Destroy +// ============================================================================= + +// Entries returns the binding entries in this layout. +// +// The returned slice is a direct reference to the internal entries. +// Callers must not modify the returned slice. +func (bgl *BindGroupLayout) Entries() []gputypes.BindGroupLayoutEntry { + return bgl.entries +} + +// EntryCount returns the number of binding entries in this layout. +func (bgl *BindGroupLayout) EntryCount() int { + return len(bgl.entries) +} + +// Label returns the bind group layout's debug label. +func (bgl *BindGroupLayout) Label() string { + return bgl.label +} + +// Destroy releases the HAL bind group layout. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (bgl *BindGroupLayout) Destroy() { + untrackResource(uintptr(unsafe.Pointer(bgl))) //nolint:gosec // debug tracking uses pointer as unique ID + + if bgl.device == nil || bgl.device.SnatchLock() == nil || bgl.raw == nil { + return + } + + readGuard := bgl.device.SnatchLock().Read() + halDevice := bgl.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := bgl.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halLayout := bgl.raw.Snatch(exclusiveGuard) + if halLayout == nil { + return + } + + halDevice.DestroyBindGroupLayout(*halLayout) +} + +// IsDestroyed returns true if the bind group layout has been destroyed. +func (bgl *BindGroupLayout) IsDestroyed() bool { + if bgl.raw == nil { + return true + } + return bgl.raw.IsSnatched() +} + +// ============================================================================= +// PipelineLayout accessors and Destroy +// ============================================================================= + +// BindGroupLayoutCount returns the number of bind group layouts in this pipeline layout. +func (pl *PipelineLayout) BindGroupLayoutCount() int { + return pl.bindGroupLayoutCount +} + +// Label returns the pipeline layout's debug label. +func (pl *PipelineLayout) Label() string { + return pl.label +} + +// Destroy releases the HAL pipeline layout. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (pl *PipelineLayout) Destroy() { + untrackResource(uintptr(unsafe.Pointer(pl))) //nolint:gosec // debug tracking uses pointer as unique ID + + if pl.device == nil || pl.device.SnatchLock() == nil || pl.raw == nil { + return + } + + readGuard := pl.device.SnatchLock().Read() + halDevice := pl.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := pl.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halLayout := pl.raw.Snatch(exclusiveGuard) + if halLayout == nil { + return + } + + halDevice.DestroyPipelineLayout(*halLayout) +} + +// IsDestroyed returns true if the pipeline layout has been destroyed. +func (pl *PipelineLayout) IsDestroyed() bool { + if pl.raw == nil { + return true + } + return pl.raw.IsSnatched() +} + +// ============================================================================= +// BindGroup accessors and Destroy +// ============================================================================= + +// Label returns the bind group's debug label. +func (bg *BindGroup) Label() string { + return bg.label +} + +// Destroy releases the HAL bind group. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (bg *BindGroup) Destroy() { + untrackResource(uintptr(unsafe.Pointer(bg))) //nolint:gosec // debug tracking uses pointer as unique ID + + if bg.device == nil || bg.device.SnatchLock() == nil || bg.raw == nil { + return + } + + readGuard := bg.device.SnatchLock().Read() + halDevice := bg.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := bg.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halGroup := bg.raw.Snatch(exclusiveGuard) + if halGroup == nil { + return + } + + halDevice.DestroyBindGroup(*halGroup) +} + +// IsDestroyed returns true if the bind group has been destroyed. +func (bg *BindGroup) IsDestroyed() bool { + if bg.raw == nil { + return true + } + return bg.raw.IsSnatched() +} + +// ============================================================================= +// ShaderModule accessors and Destroy +// ============================================================================= + +// Label returns the shader module's debug label. +func (sm *ShaderModule) Label() string { + return sm.label +} + +// Destroy releases the HAL shader module. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (sm *ShaderModule) Destroy() { + untrackResource(uintptr(unsafe.Pointer(sm))) //nolint:gosec // debug tracking uses pointer as unique ID + + if sm.device == nil || sm.device.SnatchLock() == nil || sm.raw == nil { + return + } + + readGuard := sm.device.SnatchLock().Read() + halDevice := sm.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := sm.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halModule := sm.raw.Snatch(exclusiveGuard) + if halModule == nil { + return + } + + halDevice.DestroyShaderModule(*halModule) +} + +// IsDestroyed returns true if the shader module has been destroyed. +func (sm *ShaderModule) IsDestroyed() bool { + if sm.raw == nil { + return true + } + return sm.raw.IsSnatched() +} + +// ============================================================================= +// RenderPipeline accessors and Destroy +// ============================================================================= + +// Label returns the render pipeline's debug label. +func (rp *RenderPipeline) Label() string { + return rp.label +} + +// Destroy releases the HAL render pipeline. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (rp *RenderPipeline) Destroy() { + untrackResource(uintptr(unsafe.Pointer(rp))) //nolint:gosec // debug tracking uses pointer as unique ID + + if rp.device == nil || rp.device.SnatchLock() == nil || rp.raw == nil { + return + } + + readGuard := rp.device.SnatchLock().Read() + halDevice := rp.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := rp.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halPipeline := rp.raw.Snatch(exclusiveGuard) + if halPipeline == nil { + return + } + + halDevice.DestroyRenderPipeline(*halPipeline) +} + +// IsDestroyed returns true if the render pipeline has been destroyed. +func (rp *RenderPipeline) IsDestroyed() bool { + if rp.raw == nil { + return true + } + return rp.raw.IsSnatched() +} + +// ============================================================================= +// ComputePipeline accessors and Destroy +// ============================================================================= + +// Label returns the compute pipeline's debug label. +func (cp *ComputePipeline) Label() string { + return cp.label +} + +// Destroy releases the HAL compute pipeline. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (cp *ComputePipeline) Destroy() { + untrackResource(uintptr(unsafe.Pointer(cp))) //nolint:gosec // debug tracking uses pointer as unique ID + + if cp.device == nil || cp.device.SnatchLock() == nil || cp.raw == nil { + return + } + + readGuard := cp.device.SnatchLock().Read() + halDevice := cp.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := cp.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halPipeline := cp.raw.Snatch(exclusiveGuard) + if halPipeline == nil { + return + } + + halDevice.DestroyComputePipeline(*halPipeline) +} + +// IsDestroyed returns true if the compute pipeline has been destroyed. +func (cp *ComputePipeline) IsDestroyed() bool { + if cp.raw == nil { + return true + } + return cp.raw.IsSnatched() +} + +// ============================================================================= +// QuerySet accessors and Destroy +// ============================================================================= + +// QueryType returns the type of queries in this set. +func (qs *QuerySet) QueryType() hal.QueryType { + return qs.queryType +} + +// Count returns the number of queries in the set. +func (qs *QuerySet) Count() uint32 { + return qs.count +} + +// Label returns the query set's debug label. +func (qs *QuerySet) Label() string { + return qs.label +} + +// Destroy releases the HAL query set. +// +// This method is idempotent - calling it multiple times is safe. +// After calling Destroy(), Raw() returns nil. +func (qs *QuerySet) Destroy() { + untrackResource(uintptr(unsafe.Pointer(qs))) //nolint:gosec // debug tracking uses pointer as unique ID + + if qs.device == nil || qs.device.SnatchLock() == nil || qs.raw == nil { + return + } + + readGuard := qs.device.SnatchLock().Read() + halDevice := qs.device.Raw(readGuard) + readGuard.Release() + + if halDevice == nil { + return + } + + exclusiveGuard := qs.device.SnatchLock().Write() + defer exclusiveGuard.Release() + + halQuerySet := qs.raw.Snatch(exclusiveGuard) + if halQuerySet == nil { + return + } + + halDevice.DestroyQuerySet(*halQuerySet) +} + +// IsDestroyed returns true if the query set has been destroyed. +func (qs *QuerySet) IsDestroyed() bool { + if qs.raw == nil { + return true + } + return qs.raw.IsSnatched() +} diff --git a/core/resource_accessors_test.go b/core/resource_accessors_test.go new file mode 100644 index 0000000..ee819fd --- /dev/null +++ b/core/resource_accessors_test.go @@ -0,0 +1,465 @@ +package core + +import ( + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +// mockQuerySet implements hal.QuerySet for testing. +type mockQuerySet struct{} + +func (mockQuerySet) Destroy() {} + +// ============================================================================= +// Texture accessor tests +// ============================================================================= + +func TestTexture_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + size := gputypes.Extent3D{Width: 256, Height: 128, DepthOrArrayLayers: 1} + tex := NewTexture( + mockTexture{}, device, + gputypes.TextureFormatRGBA8Unorm, + gputypes.TextureDimension2D, + gputypes.TextureUsageRenderAttachment|gputypes.TextureUsageTextureBinding, + size, 4, 1, "TestTexture", + ) + + if tex.Format() != gputypes.TextureFormatRGBA8Unorm { + t.Errorf("Format() = %v, want RGBA8Unorm", tex.Format()) + } + if tex.Dimension() != gputypes.TextureDimension2D { + t.Errorf("Dimension() = %v, want 2D", tex.Dimension()) + } + if tex.Usage() != gputypes.TextureUsageRenderAttachment|gputypes.TextureUsageTextureBinding { + t.Errorf("Usage() = %v, want RenderAttachment|TextureBinding", tex.Usage()) + } + if tex.Size() != size { + t.Errorf("Size() = %v, want %v", tex.Size(), size) + } + if tex.MipLevelCount() != 4 { + t.Errorf("MipLevelCount() = %d, want 4", tex.MipLevelCount()) + } + if tex.SampleCount() != 1 { + t.Errorf("SampleCount() = %d, want 1", tex.SampleCount()) + } + if tex.Label() != "TestTexture" { + t.Errorf("Label() = %q, want %q", tex.Label(), "TestTexture") + } +} + +func TestTexture_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + tex := NewTexture( + mockTexture{}, device, + gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, + gputypes.TextureUsageRenderAttachment, + gputypes.Extent3D{Width: 64, Height: 64, DepthOrArrayLayers: 1}, + 1, 1, "DestroyTest", + ) + + if tex.IsDestroyed() { + t.Error("Texture should not be destroyed initially") + } + + tex.Destroy() + + if !tex.IsDestroyed() { + t.Error("Texture should be destroyed after Destroy()") + } + + // Raw should return nil after destroy + guard := device.SnatchLock().Read() + raw := tex.Raw(guard) + guard.Release() + if raw != nil { + t.Error("Raw() should return nil after Destroy()") + } +} + +func TestTexture_DestroyIdempotent(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + tex := NewTexture( + mockTexture{}, device, + gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, + gputypes.TextureUsageRenderAttachment, + gputypes.Extent3D{Width: 64, Height: 64, DepthOrArrayLayers: 1}, + 1, 1, "IdempotentTest", + ) + + // Multiple destroy calls should be safe + tex.Destroy() + tex.Destroy() + tex.Destroy() + + if !tex.IsDestroyed() { + t.Error("Texture should be destroyed") + } +} + +// ============================================================================= +// Sampler accessor tests +// ============================================================================= + +func TestSampler_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + sampler := NewSampler(mockSampler{}, device, "TestSampler") + + if sampler.Label() != "TestSampler" { + t.Errorf("Label() = %q, want %q", sampler.Label(), "TestSampler") + } +} + +func TestSampler_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + sampler := NewSampler(mockSampler{}, device, "DestroyTest") + + if sampler.IsDestroyed() { + t.Error("Sampler should not be destroyed initially") + } + + sampler.Destroy() + + if !sampler.IsDestroyed() { + t.Error("Sampler should be destroyed after Destroy()") + } + + // Idempotent + sampler.Destroy() +} + +// ============================================================================= +// BindGroupLayout accessor tests +// ============================================================================= + +func TestBindGroupLayout_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + entries := []gputypes.BindGroupLayoutEntry{ + {Binding: 0}, + {Binding: 1}, + {Binding: 2}, + } + bgl := NewBindGroupLayout(mockBindGroupLayout{}, device, entries, "TestBGL") + + if bgl.Label() != "TestBGL" { + t.Errorf("Label() = %q, want %q", bgl.Label(), "TestBGL") + } + if bgl.EntryCount() != 3 { + t.Errorf("EntryCount() = %d, want 3", bgl.EntryCount()) + } + gotEntries := bgl.Entries() + if len(gotEntries) != 3 { + t.Fatalf("Entries() len = %d, want 3", len(gotEntries)) + } + for i, e := range gotEntries { + if e.Binding != uint32(i) { + t.Errorf("Entries()[%d].Binding = %d, want %d", i, e.Binding, i) + } + } +} + +func TestBindGroupLayout_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + bgl := NewBindGroupLayout(mockBindGroupLayout{}, device, nil, "DestroyTest") + + if bgl.IsDestroyed() { + t.Error("BindGroupLayout should not be destroyed initially") + } + + bgl.Destroy() + + if !bgl.IsDestroyed() { + t.Error("BindGroupLayout should be destroyed after Destroy()") + } + + // Idempotent + bgl.Destroy() +} + +// ============================================================================= +// PipelineLayout accessor tests +// ============================================================================= + +func TestPipelineLayout_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + pl := NewPipelineLayout(mockPipelineLayout{}, device, 3, "TestPL") + + if pl.Label() != "TestPL" { + t.Errorf("Label() = %q, want %q", pl.Label(), "TestPL") + } + if pl.BindGroupLayoutCount() != 3 { + t.Errorf("BindGroupLayoutCount() = %d, want 3", pl.BindGroupLayoutCount()) + } +} + +func TestPipelineLayout_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + pl := NewPipelineLayout(mockPipelineLayout{}, device, 2, "DestroyTest") + + if pl.IsDestroyed() { + t.Error("PipelineLayout should not be destroyed initially") + } + + pl.Destroy() + + if !pl.IsDestroyed() { + t.Error("PipelineLayout should be destroyed after Destroy()") + } + + // Idempotent + pl.Destroy() +} + +// ============================================================================= +// BindGroup accessor tests +// ============================================================================= + +func TestBindGroup_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + bg := NewBindGroup(mockBindGroup{}, device, "TestBG") + + if bg.Label() != "TestBG" { + t.Errorf("Label() = %q, want %q", bg.Label(), "TestBG") + } +} + +func TestBindGroup_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + bg := NewBindGroup(mockBindGroup{}, device, "DestroyTest") + + if bg.IsDestroyed() { + t.Error("BindGroup should not be destroyed initially") + } + + bg.Destroy() + + if !bg.IsDestroyed() { + t.Error("BindGroup should be destroyed after Destroy()") + } + + // Idempotent + bg.Destroy() +} + +// ============================================================================= +// ShaderModule accessor tests +// ============================================================================= + +func TestShaderModule_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + sm := NewShaderModule(mockShaderModule{}, device, "TestSM") + + if sm.Label() != "TestSM" { + t.Errorf("Label() = %q, want %q", sm.Label(), "TestSM") + } +} + +func TestShaderModule_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + sm := NewShaderModule(mockShaderModule{}, device, "DestroyTest") + + if sm.IsDestroyed() { + t.Error("ShaderModule should not be destroyed initially") + } + + sm.Destroy() + + if !sm.IsDestroyed() { + t.Error("ShaderModule should be destroyed after Destroy()") + } + + // Idempotent + sm.Destroy() +} + +// ============================================================================= +// RenderPipeline accessor tests +// ============================================================================= + +func TestRenderPipeline_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + rp := NewRenderPipeline(mockRenderPipeline{}, device, "TestRP") + + if rp.Label() != "TestRP" { + t.Errorf("Label() = %q, want %q", rp.Label(), "TestRP") + } +} + +func TestRenderPipeline_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + rp := NewRenderPipeline(mockRenderPipeline{}, device, "DestroyTest") + + if rp.IsDestroyed() { + t.Error("RenderPipeline should not be destroyed initially") + } + + rp.Destroy() + + if !rp.IsDestroyed() { + t.Error("RenderPipeline should be destroyed after Destroy()") + } + + // Idempotent + rp.Destroy() +} + +// ============================================================================= +// ComputePipeline accessor tests +// ============================================================================= + +func TestComputePipeline_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + cp := NewComputePipeline(mockComputePipeline{}, device, "TestCP") + + if cp.Label() != "TestCP" { + t.Errorf("Label() = %q, want %q", cp.Label(), "TestCP") + } +} + +func TestComputePipeline_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + cp := NewComputePipeline(mockComputePipeline{}, device, "DestroyTest") + + if cp.IsDestroyed() { + t.Error("ComputePipeline should not be destroyed initially") + } + + cp.Destroy() + + if !cp.IsDestroyed() { + t.Error("ComputePipeline should be destroyed after Destroy()") + } + + // Idempotent + cp.Destroy() +} + +// ============================================================================= +// QuerySet accessor tests +// ============================================================================= + +func TestQuerySet_Accessors(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + qs := NewQuerySet(mockQuerySet{}, device, hal.QueryTypeOcclusion, 16, "TestQS") + + if qs.QueryType() != hal.QueryTypeOcclusion { + t.Errorf("QueryType() = %v, want Occlusion", qs.QueryType()) + } + if qs.Count() != 16 { + t.Errorf("Count() = %d, want 16", qs.Count()) + } + if qs.Label() != "TestQS" { + t.Errorf("Label() = %q, want %q", qs.Label(), "TestQS") + } +} + +func TestQuerySet_Destroy(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + qs := NewQuerySet(mockQuerySet{}, device, hal.QueryTypeOcclusion, 8, "DestroyTest") + + if qs.IsDestroyed() { + t.Error("QuerySet should not be destroyed initially") + } + + qs.Destroy() + + if !qs.IsDestroyed() { + t.Error("QuerySet should be destroyed after Destroy()") + } + + // Idempotent + qs.Destroy() +} + +// ============================================================================= +// Cross-cutting destroy tests +// ============================================================================= + +func TestDestroy_AfterDeviceDestroyed(t *testing.T) { + halDevice := &mockHALDevice{} + device := NewDevice(halDevice, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + + tex := NewTexture( + mockTexture{}, device, + gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, + gputypes.TextureUsageRenderAttachment, + gputypes.Extent3D{Width: 64, Height: 64, DepthOrArrayLayers: 1}, + 1, 1, "OrphanTexture", + ) + + // Destroy device first + device.Destroy() + + // Destroying texture after device should not panic + tex.Destroy() +} + +func TestDestroy_NilDevice(t *testing.T) { + // Resources with nil device should not panic on Destroy + tex := &Texture{} + tex.Destroy() + + sampler := &Sampler{} + sampler.Destroy() + + bgl := &BindGroupLayout{} + bgl.Destroy() + + pl := &PipelineLayout{} + pl.Destroy() + + bg := &BindGroup{} + bg.Destroy() + + sm := &ShaderModule{} + sm.Destroy() + + rp := &RenderPipeline{} + rp.Destroy() + + cp := &ComputePipeline{} + cp.Destroy() + + qs := &QuerySet{} + qs.Destroy() +} From e3ea513a6b0359f64acdaf6f1ae65b15000fe367 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 00:43:11 +0300 Subject: [PATCH 06/13] feat: complete public API for gogpu renderer migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing public API methods needed for full gogpu migration from HAL direct to wgpu public API: - Fence type wrapper with Release() (fence.go) - Device: CreateFence, ResetFence, GetFenceStatus, WaitForFence, FreeCommandBuffer (for FencePool pattern) - Queue: SubmitWithFence for non-blocking async submission - Surface: DiscardTexture for error recovery All existing resource types already had Release() methods. CommandEncoder/RenderPassEncoder/CommandBuffer wrappers already existed. This completes the wgpu public API surface needed for gogpu to stop using HAL directly and go through the proper wgpu → core → HAL chain. --- CHANGELOG.md | 15 +++++++ device.go | 96 ++++++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 6 ++- fence.go | 27 +++++++++++++ queue.go | 34 ++++++++++++++++ surface.go | 10 +++++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 fence.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7895b7a..6cccae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **public API: Fence type and Device fence methods** — New `Fence` wrapper type with + `Release()` method. Device now exposes `CreateFence()`, `DestroyFence()`, `ResetFence()`, + `GetFenceStatus()`, `WaitForFence()`, and `FreeCommandBuffer()` for non-blocking GPU + submission tracking. This enables the FencePool pattern used by gogpu renderer for + async submit with per-frame fence tracking. + +- **public API: Queue.SubmitWithFence** — Non-blocking submit variant that signals a + fence on GPU completion instead of blocking. Unlike `Submit()` which waits synchronously, + `SubmitWithFence()` returns immediately and the caller polls/waits on the fence. + Command buffers must be freed manually after the fence signals. + +- **public API: Surface.DiscardTexture** — Discards an acquired surface texture without + presenting it. Delegates to `core.Surface.DiscardTexture()`. Use when rendering fails + or is canceled after acquiring a texture via `GetCurrentTexture()`. + - **core: resource accessor methods and Destroy** — All pipeline and binding resource types (Texture, Sampler, BindGroupLayout, PipelineLayout, BindGroup, ShaderModule, RenderPipeline, ComputePipeline, QuerySet) now have read-only accessor methods for diff --git a/device.go b/device.go index 742b563..9287a9f 100644 --- a/device.go +++ b/device.go @@ -2,6 +2,7 @@ package wgpu import ( "fmt" + "time" "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/core" @@ -382,6 +383,101 @@ func (d *Device) CreateCommandEncoder(desc *CommandEncoderDescriptor) (*CommandE return &CommandEncoder{core: coreEncoder, device: d}, nil } +// CreateFence creates a GPU synchronization fence. +// The returned fence can be used with Queue.SubmitWithFence to track +// GPU work completion without blocking. +func (d *Device) CreateFence() (*Fence, error) { + if d.released { + return nil, ErrReleased + } + halDevice := d.halDevice() + if halDevice == nil { + return nil, ErrReleased + } + + halFence, err := halDevice.CreateFence() + if err != nil { + return nil, fmt.Errorf("wgpu: failed to create fence: %w", err) + } + + return &Fence{hal: halFence, device: d}, nil +} + +// DestroyFence destroys a fence. +// The fence must not be in use by the GPU when destroyed. +// +// Deprecated: Use Fence.Release() instead. +func (d *Device) DestroyFence(f *Fence) { + if f != nil { + f.Release() + } +} + +// ResetFence resets a fence to the unsignaled state. +// The fence must not be in use by the GPU. +func (d *Device) ResetFence(f *Fence) error { + if d.released { + return ErrReleased + } + if f == nil || f.released { + return ErrReleased + } + halDevice := d.halDevice() + if halDevice == nil { + return ErrReleased + } + return halDevice.ResetFence(f.hal) +} + +// GetFenceStatus returns true if the fence is signaled (non-blocking). +// This is used for polling completion without blocking. +func (d *Device) GetFenceStatus(f *Fence) (bool, error) { + if d.released { + return false, ErrReleased + } + if f == nil || f.released { + return false, ErrReleased + } + halDevice := d.halDevice() + if halDevice == nil { + return false, ErrReleased + } + return halDevice.GetFenceStatus(f.hal) +} + +// WaitForFence waits for a fence to reach the specified value. +// Returns true if the fence reached the value, false if timeout expired. +func (d *Device) WaitForFence(f *Fence, value uint64, timeout time.Duration) (bool, error) { + if d.released { + return false, ErrReleased + } + if f == nil || f.released { + return false, ErrReleased + } + halDevice := d.halDevice() + if halDevice == nil { + return false, ErrReleased + } + return halDevice.Wait(f.hal, value, timeout) +} + +// FreeCommandBuffer returns a command buffer to the command pool. +// This must be called after the GPU has finished using the command buffer. +// The command buffer handle becomes invalid after this call. +func (d *Device) FreeCommandBuffer(cb *CommandBuffer) { + if d.released || cb == nil { + return + } + halDevice := d.halDevice() + if halDevice == nil { + return + } + raw := cb.halBuffer() + if raw != nil { + halDevice.FreeCommandBuffer(raw) + } +} + // PushErrorScope pushes a new error scope onto the device's error scope stack. func (d *Device) PushErrorScope(filter ErrorFilter) { d.core.PushErrorScope(filter) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 485731d..7882722 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,11 @@ Validation layer between the public API and HAL. Core validates exhaustively — - **Resource tracking** — Leak detection in debug builds - **Structured logging** — `log/slog` integration, silent by default -Key types: `Instance`, `Adapter`, `Device`, `Queue`, `Buffer`, `Texture`, `RenderPipeline`, `ComputePipeline`, `CommandEncoder`. +Key types: `Instance`, `Adapter`, `Device`, `Queue`, `Buffer`, `Texture`, `RenderPipeline`, `ComputePipeline`, `CommandEncoder`, `CommandBuffer`, `Surface`. + +- **Surface lifecycle** — `core.Surface` manages the Unconfigured → Configured → Acquired state machine with mutex-protected transitions. Validates state (can't acquire twice, can't present without acquire). Includes `PrepareFrameFunc` hook for platform HiDPI/DPI integration (Metal contentsScale, Windows WM_DPICHANGED, Wayland wl_output.scale). +- **CommandEncoder lifecycle** — `core.CommandEncoder` tracks pass state (Recording → InRenderPass/InComputePass → Finished) with validated transitions. +- **Resource types** — All 17 resource types have full struct definitions with HAL handles wrapped in `Snatchable` for safe destruction, device references, and WebGPU properties. ### `hal/` — Hardware Abstraction Layer diff --git a/fence.go b/fence.go new file mode 100644 index 0000000..74b8e6a --- /dev/null +++ b/fence.go @@ -0,0 +1,27 @@ +package wgpu + +import "github.com/gogpu/wgpu/hal" + +// Fence is a GPU synchronization primitive. +// Fences allow CPU-GPU synchronization by signaling when submitted work completes. +// +// Fences are created via Device.CreateFence and should be released via Release() +// when no longer needed. +type Fence struct { + hal hal.Fence + device *Device + released bool +} + +// Release destroys the fence. +// After this call, the fence must not be used. +func (f *Fence) Release() { + if f.released { + return + } + f.released = true + halDevice := f.device.halDevice() + if halDevice != nil { + halDevice.DestroyFence(f.hal) + } +} diff --git a/queue.go b/queue.go index 4bf29ca..ff57a2c 100644 --- a/queue.go +++ b/queue.go @@ -57,6 +57,40 @@ func (q *Queue) Submit(commandBuffers ...*CommandBuffer) error { return nil } +// SubmitWithFence submits command buffers for execution with fence-based tracking. +// Unlike Submit, this method does NOT block until GPU completion. Instead, it +// signals the provided fence with submissionIndex when the work completes. +// The caller is responsible for polling or waiting on the fence. +// +// If fence is nil, the submission proceeds without fence signaling. +// Command buffers must be freed by the caller after the fence signals +// (use Device.FreeCommandBuffer). +func (q *Queue) SubmitWithFence(commandBuffers []*CommandBuffer, fence *Fence, submissionIndex uint64) error { + if q.hal == nil { + return fmt.Errorf("wgpu: queue not available") + } + + halBuffers := make([]hal.CommandBuffer, len(commandBuffers)) + for i, cb := range commandBuffers { + if cb == nil { + return fmt.Errorf("wgpu: command buffer at index %d is nil", i) + } + halBuffers[i] = cb.halBuffer() + } + + var halFence hal.Fence + if fence != nil { + halFence = fence.hal + } + + err := q.hal.Submit(halBuffers, halFence, submissionIndex) + if err != nil { + return fmt.Errorf("wgpu: submit failed: %w", err) + } + + return nil +} + // WriteBuffer writes data to a buffer. func (q *Queue) WriteBuffer(buffer *Buffer, offset uint64, data []byte) error { if q.hal == nil || buffer == nil { diff --git a/surface.go b/surface.go index 4ccadd6..919a6bb 100644 --- a/surface.go +++ b/surface.go @@ -147,6 +147,16 @@ func (s *Surface) SetPrepareFrame(fn core.PrepareFrameFunc) { s.core.SetPrepareFrame(fn) } +// DiscardTexture discards the acquired surface texture without presenting it. +// Use this if rendering failed or was canceled. If no texture is currently +// acquired, this is a no-op. +func (s *Surface) DiscardTexture() { + if s.released { + return + } + s.core.DiscardTexture() +} + // HAL returns the underlying HAL surface for backward compatibility. // Prefer using Surface methods instead of direct HAL access. func (s *Surface) HAL() hal.Surface { From b83920cc3d63f100c51aef60d404f88b1ab7e2f0 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 09:07:15 +0300 Subject: [PATCH 07/13] feat: HAL accessor methods + wgpu API triangle examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - texture.go: HalTextureView() on *TextureView for gg integration - wrap.go: HalDevice(), HalQueue() on *Device for legacy HAL access - surface.go: fix nil TextureViewDescriptor (pass nil to HAL, not empty), remove unnecessary fence in GetCurrentTexture, remove debug logging - cmd/wgpu-triangle/: single-threaded wgpu public API triangle example - cmd/wgpu-triangle-mt/: multi-threaded wgpu public API triangle example (same architecture as gogpu renderer — validates wgpu API thread safety) --- cmd/wgpu-triangle-mt/main.go | 268 ++++++++++++++++++ cmd/wgpu-triangle-mt/window_windows.go | 364 +++++++++++++++++++++++++ cmd/wgpu-triangle/main.go | 175 ++++++++++++ cmd/wgpu-triangle/window_windows.go | 364 +++++++++++++++++++++++++ surface.go | 33 +-- texture.go | 12 + wrap.go | 110 ++++++++ 7 files changed, 1305 insertions(+), 21 deletions(-) create mode 100644 cmd/wgpu-triangle-mt/main.go create mode 100644 cmd/wgpu-triangle-mt/window_windows.go create mode 100644 cmd/wgpu-triangle/main.go create mode 100644 cmd/wgpu-triangle/window_windows.go create mode 100644 wrap.go diff --git a/cmd/wgpu-triangle-mt/main.go b/cmd/wgpu-triangle-mt/main.go new file mode 100644 index 0000000..7ccdde8 --- /dev/null +++ b/cmd/wgpu-triangle-mt/main.go @@ -0,0 +1,268 @@ +//go:build windows + +// Command wgpu-triangle tests the wgpu public API rendering pipeline. +// Multi-threaded: main thread = window events, render thread = GPU ops. +// Same architecture as gogpu renderer. +package main + +import ( + "fmt" + "log" + "os" + "runtime" + "time" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu" + _ "github.com/gogpu/wgpu/hal/vulkan" + "github.com/gogpu/wgpu/internal/thread" +) + +const ( + windowWidth = 800 + windowHeight = 600 + windowTitle = "wgpu API Triangle Test (Multi-Thread)" +) + +func init() { + runtime.LockOSThread() +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) + os.Exit(1) + } +} + +func run() error { + log.Println("=== wgpu Multi-Thread Triangle Test ===") + + // 1. Window (main thread) + window, err := NewWindow(windowTitle, windowWidth, windowHeight) + if err != nil { + return fmt.Errorf("window: %w", err) + } + defer window.Destroy() + log.Println("1. Window created") + + // 2. Render thread + renderLoop := thread.NewRenderLoop() + defer renderLoop.Stop() + log.Println("2. Render thread created") + + // 3-9. Init GPU on render thread + var instance *wgpu.Instance + var surface *wgpu.Surface + var device *wgpu.Device + var pipeline *wgpu.RenderPipeline + var pipelineLayout *wgpu.PipelineLayout + var shader *wgpu.ShaderModule + var initErr error + + renderLoop.RunOnRenderThreadVoid(func() { + instance, err = wgpu.CreateInstance(&wgpu.InstanceDescriptor{ + Backends: gputypes.BackendsVulkan, + }) + if err != nil { + initErr = fmt.Errorf("instance: %w", err) + return + } + + surface, err = instance.CreateSurface(0, uintptr(window.Handle())) + if err != nil { + initErr = fmt.Errorf("surface: %w", err) + return + } + + adapter, err := instance.RequestAdapter(nil) + if err != nil { + initErr = fmt.Errorf("adapter: %w", err) + return + } + log.Printf(" Adapter: %s", adapter.Info().Name) + + device, err = adapter.RequestDevice(nil) + if err != nil { + initErr = fmt.Errorf("device: %w", err) + return + } + + w, h := window.Size() + err = surface.Configure(device, &wgpu.SurfaceConfiguration{ + Format: gputypes.TextureFormatBGRA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + Width: safeUint32(w), + Height: safeUint32(h), + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }) + if err != nil { + initErr = fmt.Errorf("configure: %w", err) + return + } + + shader, err = device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{ + Label: "Triangle", + WGSL: triangleShaderWGSL, + }) + if err != nil { + initErr = fmt.Errorf("shader: %w", err) + return + } + + pipelineLayout, err = device.CreatePipelineLayout(&wgpu.PipelineLayoutDescriptor{ + Label: "Triangle Layout", + }) + if err != nil { + initErr = fmt.Errorf("layout: %w", err) + return + } + + pipeline, err = device.CreateRenderPipeline(&wgpu.RenderPipelineDescriptor{ + Label: "Triangle Pipeline", + Layout: pipelineLayout, + Vertex: wgpu.VertexState{ + Module: shader, + EntryPoint: "vs_main", + }, + Fragment: &wgpu.FragmentState{ + Module: shader, + EntryPoint: "fs_main", + Targets: []gputypes.ColorTargetState{{ + Format: gputypes.TextureFormatBGRA8Unorm, + WriteMask: gputypes.ColorWriteMaskAll, + }}, + }, + }) + if err != nil { + initErr = fmt.Errorf("pipeline: %w", err) + return + } + + log.Println("3-9. GPU initialized on render thread") + }) + + if initErr != nil { + return initErr + } + + // 10. Render loop + log.Println("=== Render loop started ===") + frameCount := 0 + startTime := time.Now() + + for window.PollEvents() { + var frameErr error + + renderLoop.RunOnRenderThreadVoid(func() { + // Acquire + surfaceTex, _, err := surface.GetCurrentTexture() + if err != nil { + frameErr = fmt.Errorf("GetCurrentTexture: %w", err) + return + } + + view, err := surfaceTex.CreateView(nil) + if err != nil { + frameErr = fmt.Errorf("CreateView: %w", err) + surface.DiscardTexture() + return + } + + // Encode + encoder, err := device.CreateCommandEncoder(&wgpu.CommandEncoderDescriptor{Label: "Frame"}) + if err != nil { + frameErr = fmt.Errorf("CreateCommandEncoder: %w", err) + view.Release() + return + } + + renderPass, err := encoder.BeginRenderPass(&wgpu.RenderPassDescriptor{ + ColorAttachments: []wgpu.RenderPassColorAttachment{{ + View: view, + LoadOp: gputypes.LoadOpClear, + StoreOp: gputypes.StoreOpStore, + ClearValue: gputypes.Color{R: 0, G: 0, B: 0.5, A: 1}, + }}, + }) + if err != nil { + frameErr = fmt.Errorf("BeginRenderPass: %w", err) + view.Release() + return + } + + renderPass.SetPipeline(pipeline) + renderPass.Draw(3, 1, 0, 0) + if err := renderPass.End(); err != nil { + frameErr = fmt.Errorf("End: %w", err) + view.Release() + return + } + + commands, err := encoder.Finish() + if err != nil { + frameErr = fmt.Errorf("Finish: %w", err) + view.Release() + return + } + + if err := device.Queue().Submit(commands); err != nil { + frameErr = fmt.Errorf("Submit: %w", err) + } + + if err := surface.Present(surfaceTex); err != nil { + frameErr = fmt.Errorf("Present: %w", err) + } + + view.Release() + }) + + if frameErr != nil { + log.Printf("Frame error: %v", frameErr) + continue + } + + frameCount++ + if frameCount%60 == 0 { + fps := float64(frameCount) / time.Since(startTime).Seconds() + log.Printf("Frame %d (%.1f FPS)", frameCount, fps) + } + } + + // Cleanup on render thread + renderLoop.RunOnRenderThreadVoid(func() { + pipeline.Release() + pipelineLayout.Release() + shader.Release() + surface.Unconfigure() + surface.Release() + }) + + log.Printf("Done. %d frames", frameCount) + return nil +} + +const triangleShaderWGSL = ` +@vertex +fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(0.0, 0.5), + vec2(-0.5, -0.5), + vec2(0.5, -0.5) + ); + return vec4(positions[idx], 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.0, 0.0, 1.0); +} +` + +func safeUint32(v int32) uint32 { + if v < 0 { + return 0 + } + return uint32(v) +} diff --git a/cmd/wgpu-triangle-mt/window_windows.go b/cmd/wgpu-triangle-mt/window_windows.go new file mode 100644 index 0000000..ec940b5 --- /dev/null +++ b/cmd/wgpu-triangle-mt/window_windows.go @@ -0,0 +1,364 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows + +package main + +import ( + "fmt" + "sync/atomic" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procDestroyWindow = user32.NewProc("DestroyWindow") + procShowWindow = user32.NewProc("ShowWindow") + procUpdateWindow = user32.NewProc("UpdateWindow") + procGetMessageW = user32.NewProc("GetMessageW") + procPeekMessageW = user32.NewProc("PeekMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procGetClientRect = user32.NewProc("GetClientRect") + procAdjustWindowRectEx = user32.NewProc("AdjustWindowRectEx") + procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW") + procLoadCursorW = user32.NewProc("LoadCursorW") + procSetCursor = user32.NewProc("SetCursor") +) + +const ( + csOwnDC = 0x0020 + + // Window styles + wsOverlappedWindow = 0x00CF0000 // Standard overlapped window with all buttons + wsVisible = 0x10000000 + + swShow = 5 + + // Window messages + wmDestroy = 0x0002 + wmSize = 0x0005 + wmClose = 0x0010 + wmQuit = 0x0012 + wmSetCursor = 0x0020 + wmEnterSizeMove = 0x0231 + wmExitSizeMove = 0x0232 + + pmRemove = 0x0001 + + // Cursor constants + idcArrow = 32512 + + // WM_SETCURSOR hit test codes + htClient = 1 +) + +type wndClassExW struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type msg struct { + Hwnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point +} + +type point struct { + X int32 + Y int32 +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +// Window represents a platform window with professional event handling. +// Uses hybrid GetMessage/PeekMessage pattern from Gio for responsiveness. +type Window struct { + hwnd uintptr + hInstance uintptr + cursor uintptr // Default arrow cursor + width int32 + height int32 + running bool + + // Resize handling (professional pattern from Gio/wgpu) + inSizeMove atomic.Bool // True during modal resize/move loop + needsResize atomic.Bool // Resize pending after size move ends + pendingW atomic.Int32 + pendingH atomic.Int32 + + // Animation state for hybrid event loop + animating atomic.Bool +} + +// Global window pointer for wndProc callback +var globalWindow *Window + +// NewWindow creates a new window with the given title and size. +func NewWindow(title string, width, height int32) (*Window, error) { + hInstance, _, _ := procGetModuleHandleW.Call(0) + + className, err := windows.UTF16PtrFromString("VulkanTriangleWindow") + if err != nil { + return nil, fmt.Errorf("failed to create class name: %w", err) + } + windowTitle, err := windows.UTF16PtrFromString(title) + if err != nil { + return nil, fmt.Errorf("failed to create window title: %w", err) + } + + // Load default arrow cursor + cursor, _, _ := procLoadCursorW.Call(0, uintptr(idcArrow)) + + wc := wndClassExW{ + Size: uint32(unsafe.Sizeof(wndClassExW{})), + Style: csOwnDC, + WndProc: windows.NewCallback(wndProc), + Instance: hInstance, + Cursor: cursor, + ClassName: className, + } + + ret, _, callErr := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) //nolint:gosec // G103: Win32 API + if ret == 0 { + return nil, fmt.Errorf("RegisterClassExW failed: %w", callErr) + } + + style := uint32(wsOverlappedWindow) + + // Adjust window size to account for borders + var rc rect + rc.Right = width + rc.Bottom = height + procAdjustWindowRectEx.Call( //nolint:errcheck,gosec // G103: Win32 API + uintptr(unsafe.Pointer(&rc)), //nolint:gosec // G103: Win32 API + uintptr(style), + 0, // no menu + 0, // no extended style + ) + + hwnd, _, callErr := procCreateWindowExW.Call( + 0, + uintptr(unsafe.Pointer(className)), //nolint:gosec // G103: Win32 API + uintptr(unsafe.Pointer(windowTitle)), //nolint:gosec // G103: Win32 API + uintptr(style), + 100, 100, // x, y + uintptr(rc.Right-rc.Left), //nolint:gosec // G115: window dimensions always positive + uintptr(rc.Bottom-rc.Top), //nolint:gosec // G115: window dimensions always positive + 0, 0, hInstance, 0, + ) + if hwnd == 0 { + return nil, fmt.Errorf("CreateWindowExW failed: %w", callErr) + } + + w := &Window{ + hwnd: hwnd, + hInstance: hInstance, + cursor: cursor, + width: width, + height: height, + running: true, + } + w.animating.Store(true) // Start in animating mode for games + + // Store window pointer for wndProc + globalWindow = w + // Store window pointer for wndProc (gwlpUserData = -21) + procSetWindowLongPtrW.Call(hwnd, ^uintptr(20), uintptr(unsafe.Pointer(w))) //nolint:errcheck,gosec + + // Show window + procShowWindow.Call(hwnd, uintptr(swShow)) //nolint:errcheck,gosec // Win32 API + procUpdateWindow.Call(hwnd) //nolint:errcheck,gosec // Win32 API + + return w, nil +} + +// Destroy destroys the window. +func (w *Window) Destroy() { + if w.hwnd != 0 { + _, _, _ = procDestroyWindow.Call(w.hwnd) + w.hwnd = 0 + } + if globalWindow == w { + globalWindow = nil + } +} + +// Handle returns the native window handle (HWND). +func (w *Window) Handle() uintptr { + return w.hwnd +} + +// Size returns the client area size of the window. +func (w *Window) Size() (width, height int32) { + var rc rect + procGetClientRect.Call(w.hwnd, uintptr(unsafe.Pointer(&rc))) //nolint:errcheck,gosec // Win32 API + return rc.Right - rc.Left, rc.Bottom - rc.Top +} + +// SetAnimating sets whether the window should use continuous rendering mode. +// When true, uses PeekMessage (non-blocking) for maximum FPS. +// When false, uses GetMessage (blocking) for lower CPU usage. +func (w *Window) SetAnimating(animating bool) { + w.animating.Store(animating) +} + +// NeedsResize returns true if a resize event occurred and clears the flag. +func (w *Window) NeedsResize() bool { + return w.needsResize.Swap(false) +} + +// InSizeMove returns true if the window is currently being resized/moved. +// During this time, rendering should continue but swapchain recreation should be deferred. +func (w *Window) InSizeMove() bool { + return w.inSizeMove.Load() +} + +// PollEvents processes pending window events using hybrid GetMessage/PeekMessage. +// This is the professional pattern from Gio that prevents "Not Responding". +// Returns false when the window should close. +// +//nolint:nestif // Hybrid event loop requires different paths for animating/idle modes +func (w *Window) PollEvents() bool { + var m msg + + // Hybrid event loop pattern from Gio: + // - When animating: use PeekMessage (non-blocking) for max FPS + // - When idle: would use GetMessage (blocking) for CPU efficiency + // For games/realtime apps, we always use PeekMessage + + if w.animating.Load() { + // Non-blocking: process all pending messages, then return for rendering + for { + ret, _, _ := procPeekMessageW.Call( + uintptr(unsafe.Pointer(&m)), //nolint:gosec // G103: Win32 API + 0, + 0, + 0, + uintptr(pmRemove), + ) + if ret == 0 { + break // No more messages + } + + if m.Message == wmQuit { + w.running = false + return false + } + + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + } + } else { + // Blocking: wait for message (for GUI apps that don't need continuous render) + ret, _, _ := procGetMessageW.Call( + uintptr(unsafe.Pointer(&m)), //nolint:gosec // G103: Win32 API + 0, + 0, + 0, + ) + if ret == 0 || m.Message == wmQuit { + w.running = false + return false + } + + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + } + + return w.running +} + +// wndProc is the window procedure callback. +// Handles resize events professionally like Gio/wgpu. +func wndProc(hwnd, message, wParam, lParam uintptr) uintptr { + // Get window pointer + w := globalWindow + if w == nil || w.hwnd != hwnd { + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } + + switch message { + case wmDestroy, wmClose: + _, _, _ = procPostQuitMessage.Call(0) + return 0 + + case wmEnterSizeMove: + // User started resizing/moving - enter modal loop + // Windows blocks the message pump during resize, so we track this + w.inSizeMove.Store(true) + return 0 + + case wmExitSizeMove: + // User finished resizing/moving - safe to recreate swapchain now + w.inSizeMove.Store(false) + // Signal that resize handling is needed + if w.pendingW.Load() > 0 && w.pendingH.Load() > 0 { + w.needsResize.Store(true) + } + return 0 + + case wmSize: + // Window size changed + width := int32(lParam & 0xFFFF) + height := int32((lParam >> 16) & 0xFFFF) + + if width > 0 && height > 0 { + w.width = width + w.height = height + w.pendingW.Store(width) + w.pendingH.Store(height) + + // If not in modal resize loop, signal resize immediately + if !w.inSizeMove.Load() { + w.needsResize.Store(true) + } + } + return 0 + + case wmSetCursor: + // Restore cursor to arrow when in client area + // This fixes the resize cursor staying after resize ends + hitTest := lParam & 0xFFFF + if hitTest == htClient { + _, _, _ = procSetCursor.Call(w.cursor) + return 1 // Cursor was set + } + // Let Windows handle non-client area cursors (resize handles, etc.) + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + + default: + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } +} diff --git a/cmd/wgpu-triangle/main.go b/cmd/wgpu-triangle/main.go new file mode 100644 index 0000000..23ccea7 --- /dev/null +++ b/cmd/wgpu-triangle/main.go @@ -0,0 +1,175 @@ +//go:build windows + +// Command wgpu-triangle tests the wgpu public API rendering pipeline. +// Single-threaded — validates wgpu API works correctly. +package main + +import ( + "fmt" + "log" + "os" + "runtime" + "time" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu" + _ "github.com/gogpu/wgpu/hal/vulkan" +) + +func init() { + runtime.LockOSThread() +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "FATAL: %v\n", err) + os.Exit(1) + } +} + +func run() error { + log.Println("=== wgpu Public API Triangle Test ===") + + window, err := NewWindow("wgpu API Triangle Test", 800, 600) + if err != nil { + return fmt.Errorf("window: %w", err) + } + defer window.Destroy() + + instance, err := wgpu.CreateInstance(&wgpu.InstanceDescriptor{Backends: gputypes.BackendsVulkan}) + if err != nil { + return fmt.Errorf("instance: %w", err) + } + + surface, err := instance.CreateSurface(0, uintptr(window.Handle())) + if err != nil { + return fmt.Errorf("surface: %w", err) + } + + adapter, err := instance.RequestAdapter(nil) + if err != nil { + return fmt.Errorf("adapter: %w", err) + } + log.Printf("Adapter: %s", adapter.Info().Name) + + device, err := adapter.RequestDevice(nil) + if err != nil { + return fmt.Errorf("device: %w", err) + } + + w, h := window.Size() + err = surface.Configure(device, &wgpu.SurfaceConfiguration{ + Format: gputypes.TextureFormatBGRA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + Width: safeUint32(w), + Height: safeUint32(h), + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeOpaque, + }) + if err != nil { + return fmt.Errorf("configure: %w", err) + } + + shader, err := device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{ + Label: "Triangle", + WGSL: triangleShaderWGSL, + }) + if err != nil { + return fmt.Errorf("shader: %w", err) + } + + pipelineLayout, err := device.CreatePipelineLayout(&wgpu.PipelineLayoutDescriptor{Label: "Layout"}) + if err != nil { + return fmt.Errorf("layout: %w", err) + } + + pipeline, err := device.CreateRenderPipeline(&wgpu.RenderPipelineDescriptor{ + Label: "Triangle", + Layout: pipelineLayout, + Vertex: wgpu.VertexState{Module: shader, EntryPoint: "vs_main"}, + Fragment: &wgpu.FragmentState{ + Module: shader, EntryPoint: "fs_main", + Targets: []gputypes.ColorTargetState{{ + Format: gputypes.TextureFormatBGRA8Unorm, WriteMask: gputypes.ColorWriteMaskAll, + }}, + }, + }) + if err != nil { + return fmt.Errorf("pipeline: %w", err) + } + + log.Println("Render loop started") + frameCount := 0 + startTime := time.Now() + + for window.PollEvents() { + surfaceTex, _, err := surface.GetCurrentTexture() + if err != nil { + continue + } + view, err := surfaceTex.CreateView(nil) + if err != nil { + surface.DiscardTexture() + continue + } + encoder, err := device.CreateCommandEncoder(&wgpu.CommandEncoderDescriptor{Label: "Frame"}) + if err != nil { + view.Release() + continue + } + renderPass, err := encoder.BeginRenderPass(&wgpu.RenderPassDescriptor{ + ColorAttachments: []wgpu.RenderPassColorAttachment{{ + View: view, LoadOp: gputypes.LoadOpClear, StoreOp: gputypes.StoreOpStore, + ClearValue: gputypes.Color{R: 0, G: 0, B: 0.5, A: 1}, + }}, + }) + if err != nil { + view.Release() + continue + } + renderPass.SetPipeline(pipeline) + renderPass.Draw(3, 1, 0, 0) + _ = renderPass.End() + commands, _ := encoder.Finish() + _ = device.Queue().Submit(commands) + _ = surface.Present(surfaceTex) + view.Release() + + frameCount++ + if frameCount%60 == 0 { + fps := float64(frameCount) / time.Since(startTime).Seconds() + log.Printf("Frame %d (%.1f FPS)", frameCount, fps) + } + } + + pipeline.Release() + pipelineLayout.Release() + shader.Release() + + log.Printf("Done. %d frames", frameCount) + return nil +} + +const triangleShaderWGSL = ` +@vertex +fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { + var positions = array, 3>( + vec2(0.0, 0.5), + vec2(-0.5, -0.5), + vec2(0.5, -0.5) + ); + return vec4(positions[idx], 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(1.0, 0.0, 0.0, 1.0); +} +` + +func safeUint32(v int32) uint32 { + if v < 0 { + return 0 + } + return uint32(v) +} diff --git a/cmd/wgpu-triangle/window_windows.go b/cmd/wgpu-triangle/window_windows.go new file mode 100644 index 0000000..ec940b5 --- /dev/null +++ b/cmd/wgpu-triangle/window_windows.go @@ -0,0 +1,364 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows + +package main + +import ( + "fmt" + "sync/atomic" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procDestroyWindow = user32.NewProc("DestroyWindow") + procShowWindow = user32.NewProc("ShowWindow") + procUpdateWindow = user32.NewProc("UpdateWindow") + procGetMessageW = user32.NewProc("GetMessageW") + procPeekMessageW = user32.NewProc("PeekMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procGetClientRect = user32.NewProc("GetClientRect") + procAdjustWindowRectEx = user32.NewProc("AdjustWindowRectEx") + procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW") + procLoadCursorW = user32.NewProc("LoadCursorW") + procSetCursor = user32.NewProc("SetCursor") +) + +const ( + csOwnDC = 0x0020 + + // Window styles + wsOverlappedWindow = 0x00CF0000 // Standard overlapped window with all buttons + wsVisible = 0x10000000 + + swShow = 5 + + // Window messages + wmDestroy = 0x0002 + wmSize = 0x0005 + wmClose = 0x0010 + wmQuit = 0x0012 + wmSetCursor = 0x0020 + wmEnterSizeMove = 0x0231 + wmExitSizeMove = 0x0232 + + pmRemove = 0x0001 + + // Cursor constants + idcArrow = 32512 + + // WM_SETCURSOR hit test codes + htClient = 1 +) + +type wndClassExW struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type msg struct { + Hwnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point +} + +type point struct { + X int32 + Y int32 +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +// Window represents a platform window with professional event handling. +// Uses hybrid GetMessage/PeekMessage pattern from Gio for responsiveness. +type Window struct { + hwnd uintptr + hInstance uintptr + cursor uintptr // Default arrow cursor + width int32 + height int32 + running bool + + // Resize handling (professional pattern from Gio/wgpu) + inSizeMove atomic.Bool // True during modal resize/move loop + needsResize atomic.Bool // Resize pending after size move ends + pendingW atomic.Int32 + pendingH atomic.Int32 + + // Animation state for hybrid event loop + animating atomic.Bool +} + +// Global window pointer for wndProc callback +var globalWindow *Window + +// NewWindow creates a new window with the given title and size. +func NewWindow(title string, width, height int32) (*Window, error) { + hInstance, _, _ := procGetModuleHandleW.Call(0) + + className, err := windows.UTF16PtrFromString("VulkanTriangleWindow") + if err != nil { + return nil, fmt.Errorf("failed to create class name: %w", err) + } + windowTitle, err := windows.UTF16PtrFromString(title) + if err != nil { + return nil, fmt.Errorf("failed to create window title: %w", err) + } + + // Load default arrow cursor + cursor, _, _ := procLoadCursorW.Call(0, uintptr(idcArrow)) + + wc := wndClassExW{ + Size: uint32(unsafe.Sizeof(wndClassExW{})), + Style: csOwnDC, + WndProc: windows.NewCallback(wndProc), + Instance: hInstance, + Cursor: cursor, + ClassName: className, + } + + ret, _, callErr := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) //nolint:gosec // G103: Win32 API + if ret == 0 { + return nil, fmt.Errorf("RegisterClassExW failed: %w", callErr) + } + + style := uint32(wsOverlappedWindow) + + // Adjust window size to account for borders + var rc rect + rc.Right = width + rc.Bottom = height + procAdjustWindowRectEx.Call( //nolint:errcheck,gosec // G103: Win32 API + uintptr(unsafe.Pointer(&rc)), //nolint:gosec // G103: Win32 API + uintptr(style), + 0, // no menu + 0, // no extended style + ) + + hwnd, _, callErr := procCreateWindowExW.Call( + 0, + uintptr(unsafe.Pointer(className)), //nolint:gosec // G103: Win32 API + uintptr(unsafe.Pointer(windowTitle)), //nolint:gosec // G103: Win32 API + uintptr(style), + 100, 100, // x, y + uintptr(rc.Right-rc.Left), //nolint:gosec // G115: window dimensions always positive + uintptr(rc.Bottom-rc.Top), //nolint:gosec // G115: window dimensions always positive + 0, 0, hInstance, 0, + ) + if hwnd == 0 { + return nil, fmt.Errorf("CreateWindowExW failed: %w", callErr) + } + + w := &Window{ + hwnd: hwnd, + hInstance: hInstance, + cursor: cursor, + width: width, + height: height, + running: true, + } + w.animating.Store(true) // Start in animating mode for games + + // Store window pointer for wndProc + globalWindow = w + // Store window pointer for wndProc (gwlpUserData = -21) + procSetWindowLongPtrW.Call(hwnd, ^uintptr(20), uintptr(unsafe.Pointer(w))) //nolint:errcheck,gosec + + // Show window + procShowWindow.Call(hwnd, uintptr(swShow)) //nolint:errcheck,gosec // Win32 API + procUpdateWindow.Call(hwnd) //nolint:errcheck,gosec // Win32 API + + return w, nil +} + +// Destroy destroys the window. +func (w *Window) Destroy() { + if w.hwnd != 0 { + _, _, _ = procDestroyWindow.Call(w.hwnd) + w.hwnd = 0 + } + if globalWindow == w { + globalWindow = nil + } +} + +// Handle returns the native window handle (HWND). +func (w *Window) Handle() uintptr { + return w.hwnd +} + +// Size returns the client area size of the window. +func (w *Window) Size() (width, height int32) { + var rc rect + procGetClientRect.Call(w.hwnd, uintptr(unsafe.Pointer(&rc))) //nolint:errcheck,gosec // Win32 API + return rc.Right - rc.Left, rc.Bottom - rc.Top +} + +// SetAnimating sets whether the window should use continuous rendering mode. +// When true, uses PeekMessage (non-blocking) for maximum FPS. +// When false, uses GetMessage (blocking) for lower CPU usage. +func (w *Window) SetAnimating(animating bool) { + w.animating.Store(animating) +} + +// NeedsResize returns true if a resize event occurred and clears the flag. +func (w *Window) NeedsResize() bool { + return w.needsResize.Swap(false) +} + +// InSizeMove returns true if the window is currently being resized/moved. +// During this time, rendering should continue but swapchain recreation should be deferred. +func (w *Window) InSizeMove() bool { + return w.inSizeMove.Load() +} + +// PollEvents processes pending window events using hybrid GetMessage/PeekMessage. +// This is the professional pattern from Gio that prevents "Not Responding". +// Returns false when the window should close. +// +//nolint:nestif // Hybrid event loop requires different paths for animating/idle modes +func (w *Window) PollEvents() bool { + var m msg + + // Hybrid event loop pattern from Gio: + // - When animating: use PeekMessage (non-blocking) for max FPS + // - When idle: would use GetMessage (blocking) for CPU efficiency + // For games/realtime apps, we always use PeekMessage + + if w.animating.Load() { + // Non-blocking: process all pending messages, then return for rendering + for { + ret, _, _ := procPeekMessageW.Call( + uintptr(unsafe.Pointer(&m)), //nolint:gosec // G103: Win32 API + 0, + 0, + 0, + uintptr(pmRemove), + ) + if ret == 0 { + break // No more messages + } + + if m.Message == wmQuit { + w.running = false + return false + } + + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + } + } else { + // Blocking: wait for message (for GUI apps that don't need continuous render) + ret, _, _ := procGetMessageW.Call( + uintptr(unsafe.Pointer(&m)), //nolint:gosec // G103: Win32 API + 0, + 0, + 0, + ) + if ret == 0 || m.Message == wmQuit { + w.running = false + return false + } + + _, _, _ = procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + _, _, _ = procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) //nolint:gosec // G103: Win32 API + } + + return w.running +} + +// wndProc is the window procedure callback. +// Handles resize events professionally like Gio/wgpu. +func wndProc(hwnd, message, wParam, lParam uintptr) uintptr { + // Get window pointer + w := globalWindow + if w == nil || w.hwnd != hwnd { + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } + + switch message { + case wmDestroy, wmClose: + _, _, _ = procPostQuitMessage.Call(0) + return 0 + + case wmEnterSizeMove: + // User started resizing/moving - enter modal loop + // Windows blocks the message pump during resize, so we track this + w.inSizeMove.Store(true) + return 0 + + case wmExitSizeMove: + // User finished resizing/moving - safe to recreate swapchain now + w.inSizeMove.Store(false) + // Signal that resize handling is needed + if w.pendingW.Load() > 0 && w.pendingH.Load() > 0 { + w.needsResize.Store(true) + } + return 0 + + case wmSize: + // Window size changed + width := int32(lParam & 0xFFFF) + height := int32((lParam >> 16) & 0xFFFF) + + if width > 0 && height > 0 { + w.width = width + w.height = height + w.pendingW.Store(width) + w.pendingH.Store(height) + + // If not in modal resize loop, signal resize immediately + if !w.inSizeMove.Load() { + w.needsResize.Store(true) + } + } + return 0 + + case wmSetCursor: + // Restore cursor to arrow when in client area + // This fixes the resize cursor staying after resize ends + hitTest := lParam & 0xFFFF + if hitTest == htClient { + _, _, _ = procSetCursor.Call(w.cursor) + return 1 // Cursor was set + } + // Let Windows handle non-client area cursors (resize handles, etc.) + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + + default: + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } +} diff --git a/surface.go b/surface.go index 919a6bb..633cca0 100644 --- a/surface.go +++ b/surface.go @@ -93,18 +93,7 @@ func (s *Surface) GetCurrentTexture() (*SurfaceTexture, bool, error) { return nil, false, fmt.Errorf("wgpu: surface not configured") } - halDevice := s.device.halDevice() - if halDevice == nil { - return nil, false, ErrReleased - } - - fence, err := halDevice.CreateFence() - if err != nil { - return nil, false, fmt.Errorf("wgpu: failed to create acquire fence: %w", err) - } - defer halDevice.DestroyFence(fence) - - acquired, err := s.core.AcquireTexture(fence) + acquired, err := s.core.AcquireTexture(nil) if err != nil { return nil, false, err } @@ -187,16 +176,18 @@ func (st *SurfaceTexture) CreateView(desc *TextureViewDescriptor) (*TextureView, return nil, ErrReleased } - halDesc := &hal.TextureViewDescriptor{} + var halDesc *hal.TextureViewDescriptor if desc != nil { - halDesc.Label = desc.Label - halDesc.Format = desc.Format - halDesc.Dimension = desc.Dimension - halDesc.Aspect = desc.Aspect - halDesc.BaseMipLevel = desc.BaseMipLevel - halDesc.MipLevelCount = desc.MipLevelCount - halDesc.BaseArrayLayer = desc.BaseArrayLayer - halDesc.ArrayLayerCount = desc.ArrayLayerCount + halDesc = &hal.TextureViewDescriptor{ + Label: desc.Label, + Format: desc.Format, + Dimension: desc.Dimension, + Aspect: desc.Aspect, + BaseMipLevel: desc.BaseMipLevel, + MipLevelCount: desc.MipLevelCount, + BaseArrayLayer: desc.BaseArrayLayer, + ArrayLayerCount: desc.ArrayLayerCount, + } } halView, err := halDevice.CreateTextureView(st.hal, halDesc) diff --git a/texture.go b/texture.go index 0a5c67e..4992e64 100644 --- a/texture.go +++ b/texture.go @@ -35,6 +35,18 @@ type TextureView struct { released bool } +// HalTextureView returns the underlying HAL texture view for advanced use cases. +// This enables interop with code that needs direct HAL access (e.g., gg +// GPU accelerator surface rendering). +// +// Returns nil if the view has been released. +func (v *TextureView) HalTextureView() hal.TextureView { + if v.released { + return nil + } + return v.hal +} + // Release destroys the texture view. func (v *TextureView) Release() { if v.released { diff --git a/wrap.go b/wrap.go new file mode 100644 index 0000000..2c700bf --- /dev/null +++ b/wrap.go @@ -0,0 +1,110 @@ +package wgpu + +import ( + "fmt" + "sync/atomic" + + "github.com/gogpu/wgpu/core" + "github.com/gogpu/wgpu/hal" +) + +// NewDeviceFromHAL creates a Device wrapping existing HAL device and queue objects. +// This constructor is used by backends that manage their own HAL lifecycle +// (e.g., the Rust FFI backend via wgpu-native) and need to expose their +// HAL objects through the wgpu public API. +// +// Ownership of halDevice and halQueue is transferred to the returned Device. +// The caller must not destroy them directly after this call. +func NewDeviceFromHAL( + halDevice hal.Device, + halQueue hal.Queue, + features Features, + limits Limits, + label string, +) (*Device, error) { + if halDevice == nil { + return nil, fmt.Errorf("wgpu: halDevice is nil") + } + if halQueue == nil { + return nil, fmt.Errorf("wgpu: halQueue is nil") + } + + // Create a core.Adapter stub for the core.Device constructor. + // The Rust backend doesn't go through the adapter registry, so we + // create a minimal adapter that satisfies the core.Device requirements. + coreAdapter := &core.Adapter{ + Features: features, + Limits: limits, + } + + coreDevice := core.NewDevice(halDevice, coreAdapter, features, limits, label) + + fence, err := halDevice.CreateFence() + if err != nil { + coreDevice.Destroy() + return nil, fmt.Errorf("wgpu: failed to create queue fence: %w", err) + } + + queue := &Queue{ + hal: halQueue, + halDevice: halDevice, + fence: fence, + } + queue.fenceValue = atomic.Uint64{} + + coreDevice.SetAssociatedQueue(&core.Queue{Label: label + " Queue"}) + + device := &Device{ + core: coreDevice, + queue: queue, + } + queue.device = device + + return device, nil +} + +// NewSurfaceFromHAL creates a Surface wrapping an existing HAL surface. +// This constructor is used by backends that create surfaces externally +// (e.g., the Rust FFI backend). +// +// Ownership of halSurface is transferred to the returned Surface. +func NewSurfaceFromHAL(halSurface hal.Surface, label string) *Surface { + coreSurface := core.NewSurface(halSurface, label) + return &Surface{ + core: coreSurface, + } +} + +// NewTextureFromHAL creates a Texture wrapping an existing HAL texture. +// Used for backward compatibility and testing. +func NewTextureFromHAL(halTexture hal.Texture, device *Device, format TextureFormat) *Texture { + return &Texture{hal: halTexture, device: device, format: format} +} + +// NewTextureViewFromHAL creates a TextureView wrapping an existing HAL texture view. +func NewTextureViewFromHAL(halView hal.TextureView, device *Device) *TextureView { + return &TextureView{hal: halView, device: device} +} + +// NewSamplerFromHAL creates a Sampler wrapping an existing HAL sampler. +func NewSamplerFromHAL(halSampler hal.Sampler, device *Device) *Sampler { + return &Sampler{hal: halSampler, device: device} +} + +// HalDevice returns the underlying HAL device for advanced use cases. +// This enables interop with code that needs direct HAL access (e.g., gg +// GPU accelerator, DeviceProvider interfaces). +// +// Returns nil if the device has been released or has no HAL backend. +func (d *Device) HalDevice() hal.Device { + return d.halDevice() +} + +// HalQueue returns the underlying HAL queue. +// Returns nil if the device has been released or has no HAL backend. +func (d *Device) HalQueue() hal.Queue { + if d.queue == nil { + return nil + } + return d.queue.hal +} From 9e81f36b43329b2a11a193ce01b569ec31c79828 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 10:31:41 +0300 Subject: [PATCH 08/13] feat: add CopyTextureToBuffer, TransitionTextures, HalTexture for gg migration CommandEncoder: CopyTextureToBuffer, TransitionTextures, DiscardEncoding. Texture: HalTexture() accessor for texture barrier interop. Required by gg GPU-API-001 migration (readback + Vulkan layout transitions). --- encoder.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ texture.go | 12 ++++++++++++ 2 files changed, 65 insertions(+) diff --git a/encoder.go b/encoder.go index 2a7c145..2f0fa98 100644 --- a/encoder.go +++ b/encoder.go @@ -94,6 +94,59 @@ func (e *CommandEncoder) CopyBufferToBuffer(src *Buffer, srcOffset uint64, dst * }) } +// CopyTextureToBuffer copies data from a texture to a buffer. +// This is used for GPU-to-CPU readback of rendered content. +func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions []hal.BufferTextureCopy) { + if e.released { + return + } + if src == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToBuffer: source texture is nil")) + return + } + if dst == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToBuffer: destination buffer is nil")) + return + } + raw := e.core.RawEncoder() + if raw == nil { + return + } + halDst := dst.halBuffer() + if src.hal == nil || halDst == nil { + return + } + raw.CopyTextureToBuffer(src.hal, halDst, regions) +} + +// TransitionTextures transitions texture states for synchronization. +// This is needed on Vulkan for layout transitions between render pass +// and copy operations (e.g., after MSAA resolve before CopyTextureToBuffer). +// On Metal, GLES, and software backends this is a no-op. +func (e *CommandEncoder) TransitionTextures(barriers []hal.TextureBarrier) { + if e.released { + return + } + raw := e.core.RawEncoder() + if raw == nil { + return + } + raw.TransitionTextures(barriers) +} + +// DiscardEncoding discards the encoder without producing a command buffer. +// Use this to abandon an in-progress encoding when an error occurs. +func (e *CommandEncoder) DiscardEncoding() { + if e.released { + return + } + e.released = true + raw := e.core.RawEncoder() + if raw != nil { + raw.DiscardEncoding() + } +} + // Finish completes command recording and returns a CommandBuffer. // After calling Finish(), the encoder cannot be used again. func (e *CommandEncoder) Finish() (*CommandBuffer, error) { diff --git a/texture.go b/texture.go index 4992e64..e90de9f 100644 --- a/texture.go +++ b/texture.go @@ -15,6 +15,18 @@ type Texture struct { // Format returns the texture format. func (t *Texture) Format() TextureFormat { return t.format } +// HalTexture returns the underlying HAL texture for advanced use cases. +// This enables interop with code that needs direct HAL access (e.g., gg +// GPU accelerator texture barriers and copy operations). +// +// Returns nil if the texture has been released. +func (t *Texture) HalTexture() hal.Texture { + if t.released { + return nil + } + return t.hal +} + // Release destroys the texture. func (t *Texture) Release() { if t.released { From 9d421149ca22a58de5efbade2f3f8fb97ea425e1 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 12:46:16 +0300 Subject: [PATCH 09/13] feat: promote HAL types to wgpu public API (WGPU-API-002) Replace type aliases to hal with proper struct definitions + toHAL() converters: - Extent3D, Origin3D, ImageDataLayout, DepthStencilState, StencilFaceState - New: TextureBarrier, TextureRange, TextureUsageTransition, BufferTextureCopy - StencilOperation kept as alias (uint8 enum, same as CompareFunction) - CommandEncoder signatures use wgpu types (no hal in public API) - New wgpu.SetLogger()/Logger() for stack-wide logging propagation - Device.CreateTexture uses desc.toHAL() (removed duplication) --- CHANGELOG.md | 19 ++++++ descriptor.go | 160 +++++++++++++++++++++++++++++++++++++++++++++++--- device.go | 11 +--- encoder.go | 16 +++-- logger.go | 24 ++++++++ queue.go | 4 +- 6 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 logger.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cccae6..d3d70f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **public API: SetLogger / Logger** — New `wgpu.SetLogger()` and `wgpu.Logger()` + functions that propagate the logger to the entire wgpu stack (public API, core + validation, HAL backends). Consumers no longer need to import `wgpu/hal` for logging. + +- **public API: proper type definitions for GPU descriptors** — Replaced type aliases + to `hal` package with proper struct definitions and unexported `toHAL()` converters. + This removes `hal` leakage from godoc and follows the Google Go Style Guide (aliases + are for migration only, not permanent public API). Promoted types: + `Extent3D`, `Origin3D`, `ImageDataLayout`, `DepthStencilState`, `StencilFaceState`, + `StencilOperation` (enum alias kept), plus new barrier/copy types: + `TextureBarrier`, `TextureRange`, `TextureUsageTransition`, `BufferTextureCopy`. + +### Changed + +- **public API: CommandEncoder signatures use wgpu types** — `CopyTextureToBuffer` + now takes `[]BufferTextureCopy` (was `[]hal.BufferTextureCopy`), `TransitionTextures` + now takes `[]TextureBarrier` (was `[]hal.TextureBarrier`). No HAL types in public + method signatures. + - **public API: Fence type and Device fence methods** — New `Fence` wrapper type with `Release()` method. Device now exposes `CreateFence()`, `DestroyFence()`, `ResetFence()`, `GetFenceStatus()`, `WaitForFence()`, and `FreeCommandBuffer()` for non-blocking GPU diff --git a/descriptor.go b/descriptor.go index ad47f29..9a15844 100644 --- a/descriptor.go +++ b/descriptor.go @@ -6,13 +6,37 @@ import ( ) // Extent3D is a 3D size. -type Extent3D = hal.Extent3D +type Extent3D struct { + Width uint32 + Height uint32 + DepthOrArrayLayers uint32 +} + +func (e Extent3D) toHAL() hal.Extent3D { + return hal.Extent3D{Width: e.Width, Height: e.Height, DepthOrArrayLayers: e.DepthOrArrayLayers} +} // Origin3D is a 3D origin point. -type Origin3D = hal.Origin3D +type Origin3D struct { + X uint32 + Y uint32 + Z uint32 +} + +func (o Origin3D) toHAL() hal.Origin3D { + return hal.Origin3D{X: o.X, Y: o.Y, Z: o.Z} +} // ImageDataLayout describes the layout of image data in a buffer. -type ImageDataLayout = hal.ImageDataLayout +type ImageDataLayout struct { + Offset uint64 + BytesPerRow uint32 + RowsPerImage uint32 +} + +func (l ImageDataLayout) toHAL() hal.ImageDataLayout { + return hal.ImageDataLayout{Offset: l.Offset, BytesPerRow: l.BytesPerRow, RowsPerImage: l.RowsPerImage} +} // BufferDescriptor describes buffer creation parameters. type BufferDescriptor struct { @@ -48,7 +72,7 @@ type TextureDescriptor struct { func (d *TextureDescriptor) toHAL() *hal.TextureDescriptor { return &hal.TextureDescriptor{ Label: d.Label, - Size: d.Size, + Size: d.Size.toHAL(), MipLevelCount: d.MipLevelCount, SampleCount: d.SampleCount, Dimension: d.Dimension, @@ -213,8 +237,64 @@ type PipelineLayoutDescriptor struct { BindGroupLayouts []*BindGroupLayout } -// DepthStencilState describes depth/stencil testing configuration. -type DepthStencilState = hal.DepthStencilState +// StencilOperation describes a stencil operation. +type StencilOperation = hal.StencilOperation + +// Stencil operation constants. +const ( + StencilOperationKeep = hal.StencilOperationKeep + StencilOperationZero = hal.StencilOperationZero + StencilOperationReplace = hal.StencilOperationReplace + StencilOperationInvert = hal.StencilOperationInvert + StencilOperationIncrementClamp = hal.StencilOperationIncrementClamp + StencilOperationDecrementClamp = hal.StencilOperationDecrementClamp + StencilOperationIncrementWrap = hal.StencilOperationIncrementWrap + StencilOperationDecrementWrap = hal.StencilOperationDecrementWrap +) + +// StencilFaceState describes stencil operations for a face. +type StencilFaceState struct { + Compare CompareFunction + FailOp StencilOperation + DepthFailOp StencilOperation + PassOp StencilOperation +} + +func (s StencilFaceState) toHAL() hal.StencilFaceState { + return hal.StencilFaceState{Compare: s.Compare, FailOp: s.FailOp, DepthFailOp: s.DepthFailOp, PassOp: s.PassOp} +} + +// DepthStencilState describes depth and stencil testing configuration. +type DepthStencilState struct { + Format TextureFormat + DepthWriteEnabled bool + DepthCompare CompareFunction + StencilFront StencilFaceState + StencilBack StencilFaceState + StencilReadMask uint32 + StencilWriteMask uint32 + DepthBias int32 + DepthBiasSlopeScale float32 + DepthBiasClamp float32 +} + +func (d *DepthStencilState) toHAL() *hal.DepthStencilState { + if d == nil { + return nil + } + return &hal.DepthStencilState{ + Format: d.Format, + DepthWriteEnabled: d.DepthWriteEnabled, + DepthCompare: d.DepthCompare, + StencilFront: d.StencilFront.toHAL(), + StencilBack: d.StencilBack.toHAL(), + StencilReadMask: d.StencilReadMask, + StencilWriteMask: d.StencilWriteMask, + DepthBias: d.DepthBias, + DepthBiasSlopeScale: d.DepthBiasSlopeScale, + DepthBiasClamp: d.DepthBiasClamp, + } +} // RenderPipelineDescriptor describes a render pipeline. type RenderPipelineDescriptor struct { @@ -247,7 +327,7 @@ func (d *RenderPipelineDescriptor) toHAL() *hal.RenderPipelineDescriptor { Label: d.Label, Primitive: d.Primitive, Multisample: d.Multisample, - DepthStencil: d.DepthStencil, + DepthStencil: d.DepthStencil.toHAL(), } if d.Layout != nil { @@ -422,7 +502,71 @@ func (i *ImageCopyTexture) toHAL() *hal.ImageCopyTexture { return &hal.ImageCopyTexture{ Texture: i.Texture.hal, MipLevel: i.MipLevel, - Origin: i.Origin, + Origin: i.Origin.toHAL(), Aspect: i.Aspect, } } + +// TextureUsageTransition defines a texture usage state transition. +type TextureUsageTransition struct { + OldUsage TextureUsage + NewUsage TextureUsage +} + +// TextureRange specifies a range of texture subresources. +type TextureRange struct { + Aspect TextureAspect + BaseMipLevel uint32 + MipLevelCount uint32 + BaseArrayLayer uint32 + ArrayLayerCount uint32 +} + +// TextureBarrier defines a texture state transition for synchronization. +// Required on Vulkan for layout transitions between render pass and copy +// operations. On Metal, GLES, and software backends this is a no-op. +type TextureBarrier struct { + Texture *Texture + Range TextureRange + Usage TextureUsageTransition +} + +func (b TextureBarrier) toHAL() hal.TextureBarrier { + var t hal.Texture + if b.Texture != nil { + t = b.Texture.hal + } + return hal.TextureBarrier{ + Texture: t, + Range: hal.TextureRange{ + Aspect: b.Range.Aspect, + BaseMipLevel: b.Range.BaseMipLevel, + MipLevelCount: b.Range.MipLevelCount, + BaseArrayLayer: b.Range.BaseArrayLayer, + ArrayLayerCount: b.Range.ArrayLayerCount, + }, + Usage: hal.TextureUsageTransition{ + OldUsage: b.Usage.OldUsage, + NewUsage: b.Usage.NewUsage, + }, + } +} + +// BufferTextureCopy defines a buffer-texture copy region. +type BufferTextureCopy struct { + BufferLayout ImageDataLayout + TextureBase ImageCopyTexture + Size Extent3D +} + +func (c BufferTextureCopy) toHAL() hal.BufferTextureCopy { + btc := hal.BufferTextureCopy{ + BufferLayout: c.BufferLayout.toHAL(), + Size: c.Size.toHAL(), + } + halTex := c.TextureBase.toHAL() + if halTex != nil { + btc.TextureBase = *halTex + } + return btc +} diff --git a/device.go b/device.go index 9287a9f..b04d10b 100644 --- a/device.go +++ b/device.go @@ -73,16 +73,7 @@ func (d *Device) CreateTexture(desc *TextureDescriptor) (*Texture, error) { return nil, ErrReleased } - halDesc := &hal.TextureDescriptor{ - Label: desc.Label, - Size: hal.Extent3D{Width: desc.Size.Width, Height: desc.Size.Height, DepthOrArrayLayers: desc.Size.DepthOrArrayLayers}, - MipLevelCount: desc.MipLevelCount, - SampleCount: desc.SampleCount, - Dimension: desc.Dimension, - Format: desc.Format, - Usage: desc.Usage, - ViewFormats: desc.ViewFormats, - } + halDesc := desc.toHAL() if err := core.ValidateTextureDescriptor(halDesc, d.core.Limits); err != nil { return nil, err diff --git a/encoder.go b/encoder.go index 2f0fa98..1aec58f 100644 --- a/encoder.go +++ b/encoder.go @@ -96,7 +96,7 @@ func (e *CommandEncoder) CopyBufferToBuffer(src *Buffer, srcOffset uint64, dst * // CopyTextureToBuffer copies data from a texture to a buffer. // This is used for GPU-to-CPU readback of rendered content. -func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions []hal.BufferTextureCopy) { +func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions []BufferTextureCopy) { if e.released { return } @@ -116,14 +116,18 @@ func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions if src.hal == nil || halDst == nil { return } - raw.CopyTextureToBuffer(src.hal, halDst, regions) + halRegions := make([]hal.BufferTextureCopy, len(regions)) + for i, r := range regions { + halRegions[i] = r.toHAL() + } + raw.CopyTextureToBuffer(src.hal, halDst, halRegions) } // TransitionTextures transitions texture states for synchronization. // This is needed on Vulkan for layout transitions between render pass // and copy operations (e.g., after MSAA resolve before CopyTextureToBuffer). // On Metal, GLES, and software backends this is a no-op. -func (e *CommandEncoder) TransitionTextures(barriers []hal.TextureBarrier) { +func (e *CommandEncoder) TransitionTextures(barriers []TextureBarrier) { if e.released { return } @@ -131,7 +135,11 @@ func (e *CommandEncoder) TransitionTextures(barriers []hal.TextureBarrier) { if raw == nil { return } - raw.TransitionTextures(barriers) + halBarriers := make([]hal.TextureBarrier, len(barriers)) + for i, b := range barriers { + halBarriers[i] = b.toHAL() + } + raw.TransitionTextures(halBarriers) } // DiscardEncoding discards the encoder without producing a command buffer. diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..36cb356 --- /dev/null +++ b/logger.go @@ -0,0 +1,24 @@ +package wgpu + +import ( + "log/slog" + + "github.com/gogpu/wgpu/hal" +) + +// SetLogger configures the logger for the entire wgpu stack (public API, +// core validation layer, and HAL backends: Vulkan, Metal, DX12, GLES). +// +// By default, wgpu produces no log output. Call SetLogger to enable logging +// for deep debugging across the full GPU pipeline. +// +// SetLogger is safe for concurrent use. +// Pass nil to disable logging (restore default silent behavior). +func SetLogger(l *slog.Logger) { + hal.SetLogger(l) +} + +// Logger returns the current logger used by the wgpu stack. +func Logger() *slog.Logger { + return hal.Logger() +} diff --git a/queue.go b/queue.go index ff57a2c..311993c 100644 --- a/queue.go +++ b/queue.go @@ -138,7 +138,9 @@ func (q *Queue) WriteTexture(dst *ImageCopyTexture, data []byte, layout *ImageDa } halDst := dst.toHAL() - return q.hal.WriteTexture(halDst, data, layout, size) + halLayout := layout.toHAL() + halSize := size.toHAL() + return q.hal.WriteTexture(halDst, data, &halLayout, &halSize) } // release cleans up queue resources. From 50421ce269622682c3a697ae50e9621aa249d3bf Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 13:01:50 +0300 Subject: [PATCH 10/13] docs: add HAL escape hatch note to timestamp queries guide --- docs/COMPUTE-SHADERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/COMPUTE-SHADERS.md b/docs/COMPUTE-SHADERS.md index b284271..afcf2b4 100644 --- a/docs/COMPUTE-SHADERS.md +++ b/docs/COMPUTE-SHADERS.md @@ -298,6 +298,7 @@ results := unsafe.Slice((*float32)(unsafe.Pointer(&resultBytes[0])), numElements > **Note:** Timestamp queries use the `hal/` package directly — they are not yet exposed > in the high-level `wgpu` root package. Import `"github.com/gogpu/wgpu/hal"` for this functionality. +> Access HAL device/queue from wgpu types via `device.HalDevice()` and `device.HalQueue()`. You can measure GPU execution time of compute passes using timestamp queries. From d6dd4fb2e9f9daaf1a8c95d125333d499b283c72 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 13:30:01 +0300 Subject: [PATCH 11/13] =?UTF-8?q?chore:=20update=20naga=20v0.14.6=20?= =?UTF-8?q?=E2=86=92=20v0.14.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes MSL sequential per-type binding indices across bind groups. --- CHANGELOG.md | 4 ++++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d70f9..dcd5b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Updated naga v0.14.6 → v0.14.7** — Fixes MSL sequential per-type binding indices + across bind groups (`buffer(N)`, `texture(N)`, `sampler(N)` counters now reset per type + not per group). + - **public API: CommandEncoder signatures use wgpu types** — `CopyTextureToBuffer` now takes `[]BufferTextureCopy` (was `[]hal.BufferTextureCopy`), `TransitionTextures` now takes `[]TextureBarrier` (was `[]hal.TextureBarrier`). No HAL types in public diff --git a/go.mod b/go.mod index 3e37c79..641d8c4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,6 @@ go 1.25 require ( github.com/go-webgpu/goffi v0.4.2 github.com/gogpu/gputypes v0.3.0 - github.com/gogpu/naga v0.14.6 + github.com/gogpu/naga v0.14.7 golang.org/x/sys v0.41.0 ) diff --git a/go.sum b/go.sum index 4666df1..7a12624 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,7 @@ github.com/go-webgpu/goffi v0.4.2 h1:cwSiwro2ndP7jfXYlsz3kbOk8mNaFsHGZ0Q0cszC9cU github.com/go-webgpu/goffi v0.4.2/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/gogpu/gputypes v0.3.0 h1:gcwxsBrcoCX19GqqqiV55wLv2iFwaybiOluKCb0hVrs= github.com/gogpu/gputypes v0.3.0/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= -github.com/gogpu/naga v0.14.6 h1:zXtYxeEt1P70UDVuoa1EgMzKvted9ZsHkVcFV13qkSs= -github.com/gogpu/naga v0.14.6/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg= +github.com/gogpu/naga v0.14.7 h1:7X8Gb1HVA4zwYljAi8acfmzQch5PpBWc7cms2Iwhw9U= +github.com/gogpu/naga v0.14.7/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= From 7f8f45eb172fd4ad793269993776acbc1d78c0cb Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 13:35:58 +0300 Subject: [PATCH 12/13] docs: update CHANGELOG and ROADMAP for v0.21.0 release --- CHANGELOG.md | 114 +++++++++++++++++++++------------------------------ ROADMAP.md | 30 ++++++++++---- 2 files changed, 68 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd5b15..d0839ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,79 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.0] - 2026-03-15 + ### Added -- **public API: SetLogger / Logger** — New `wgpu.SetLogger()` and `wgpu.Logger()` - functions that propagate the logger to the entire wgpu stack (public API, core - validation, HAL backends). Consumers no longer need to import `wgpu/hal` for logging. +- **public API: complete three-layer WebGPU stack** — The root `wgpu` package now + provides a full typed API for GPU programming. All operations go through + wgpu (public) → wgpu/core (validation) → wgpu/hal (backend). Consumers never + need to import `wgpu/hal` for standard use. + +- **public API: SetLogger / Logger** — `wgpu.SetLogger()` and `wgpu.Logger()` + propagate the logger to the entire stack (API, core, HAL backends). + +- **public API: Fence and async submission** — `Fence` type, `Device.CreateFence()`, + `WaitForFence()`, `ResetFence()`, `GetFenceStatus()`, `FreeCommandBuffer()`. + `Queue.SubmitWithFence()` for non-blocking GPU submission with fence signaling. + +- **public API: Surface lifecycle** — `Surface.SetPrepareFrame()` for platform + HiDPI/DPI hooks. `Surface.DiscardTexture()` for canceled frames. `Surface.HAL()` + escape hatch. Delegates to `core.Surface` state machine. + +- **public API: CommandEncoder extensions** — `CopyTextureToBuffer()`, + `TransitionTextures()`, `DiscardEncoding()`. All use wgpu types (no hal in signatures). + +- **public API: HAL accessors** — `Device.HalDevice()`, `Device.HalQueue()`, + `Texture.HalTexture()`, `TextureView.HalTextureView()` for advanced interop. + +- **public API: proper type definitions** — Replaced hal type aliases with proper + structs: `Extent3D`, `Origin3D`, `ImageDataLayout`, `DepthStencilState`, + `StencilFaceState`, `TextureBarrier`, `TextureRange`, `TextureUsageTransition`, + `BufferTextureCopy`. Unexported `toHAL()` converters. No hal leakage in godoc. + +- **core: complete resource types (CORE-001)** — All 12 stub resource types + (Texture, Sampler, BindGroupLayout, PipelineLayout, BindGroup, ShaderModule, + RenderPipeline, ComputePipeline, CommandEncoder, CommandBuffer, QuerySet, Surface) + now have full struct definitions with HAL handle wrapping. + +- **core: Surface state machine (CORE-002)** — Unconfigured → Configured → Acquired + lifecycle with PrepareFrameFunc hook and auto-reconfigure on dimension changes. + +- **core: CommandEncoder state machine (CORE-003)** — Recording/InRenderPass/ + InComputePass/Finished/Error states with validated transitions. + +- **core: resource accessors (CORE-004)** — Read-only accessors and idempotent + Destroy() for all resource types. + +- **cmd/wgpu-triangle** — Single-threaded wgpu API triangle example. -- **public API: proper type definitions for GPU descriptors** — Replaced type aliases - to `hal` package with proper struct definitions and unexported `toHAL()` converters. - This removes `hal` leakage from godoc and follows the Google Go Style Guide (aliases - are for migration only, not permanent public API). Promoted types: - `Extent3D`, `Origin3D`, `ImageDataLayout`, `DepthStencilState`, `StencilFaceState`, - `StencilOperation` (enum alias kept), plus new barrier/copy types: - `TextureBarrier`, `TextureRange`, `TextureUsageTransition`, `BufferTextureCopy`. +- **cmd/wgpu-triangle-mt** — Multi-threaded wgpu API triangle example. ### Changed -- **Updated naga v0.14.6 → v0.14.7** — Fixes MSL sequential per-type binding indices - across bind groups (`buffer(N)`, `texture(N)`, `sampler(N)` counters now reset per type - not per group). - -- **public API: CommandEncoder signatures use wgpu types** — `CopyTextureToBuffer` - now takes `[]BufferTextureCopy` (was `[]hal.BufferTextureCopy`), `TransitionTextures` - now takes `[]TextureBarrier` (was `[]hal.TextureBarrier`). No HAL types in public - method signatures. - -- **public API: Fence type and Device fence methods** — New `Fence` wrapper type with - `Release()` method. Device now exposes `CreateFence()`, `DestroyFence()`, `ResetFence()`, - `GetFenceStatus()`, `WaitForFence()`, and `FreeCommandBuffer()` for non-blocking GPU - submission tracking. This enables the FencePool pattern used by gogpu renderer for - async submit with per-frame fence tracking. - -- **public API: Queue.SubmitWithFence** — Non-blocking submit variant that signals a - fence on GPU completion instead of blocking. Unlike `Submit()` which waits synchronously, - `SubmitWithFence()` returns immediately and the caller polls/waits on the fence. - Command buffers must be freed manually after the fence signals. - -- **public API: Surface.DiscardTexture** — Discards an acquired surface texture without - presenting it. Delegates to `core.Surface.DiscardTexture()`. Use when rendering fails - or is canceled after acquiring a texture via `GetCurrentTexture()`. - -- **core: resource accessor methods and Destroy** — All pipeline and binding resource - types (Texture, Sampler, BindGroupLayout, PipelineLayout, BindGroup, ShaderModule, - RenderPipeline, ComputePipeline, QuerySet) now have read-only accessor methods for - their properties (Label, Format, Size, Entries, Count, etc.), an idempotent Destroy() - method following the Buffer snatch-and-release pattern, and an IsDestroyed() check. - Methods are in a separate `resource_accessors.go` file with full test coverage (CORE-004). - -- **core: complete all 12 stub resource types** — Texture, Sampler, BindGroupLayout, - PipelineLayout, BindGroup, ShaderModule, RenderPipeline, ComputePipeline, - CommandEncoder, CommandBuffer, QuerySet, and Surface now have full struct definitions - with HAL handle wrapping (Snatchable pattern), device references, WebGPU properties, - and constructor functions. Previously these were empty `struct{}` stubs. This completes - the foundation for wgpu/core resource lifecycle management (CORE-001). - -- **core: Surface lifecycle state machine** — `core.Surface` now manages the full - Unconfigured → Configured → Acquired → Configured state machine with mutex-protected - transitions. Methods: Configure, Unconfigure, AcquireTexture, Present, DiscardTexture. - Validates state transitions (e.g., can't acquire twice, can't present without acquire). - Includes `PrepareFrameFunc` hook for platform DPI/scale integration — called before - each AcquireTexture, auto-reconfigures surface on dimension changes (CORE-002). - -- **public API: Surface delegates to core.Surface** — `wgpu.Surface` now uses - `core.Surface` internally instead of `hal.Surface` directly. All lifecycle methods - (Configure, Unconfigure, GetCurrentTexture, Present) go through core validation and - state tracking. New public methods: `SetPrepareFrame()` for platform HiDPI hooks, - `HAL()` escape hatch for backward compatibility (CORE-005). - -- **core: CommandEncoder/CommandBuffer state machine** — `core.CommandEncoder` now tracks - pass state (Recording, InRenderPass, InComputePass, Finished, Error) with validated - transitions. BeginRenderPass/EndRenderPass, BeginComputePass/EndComputePass enforce - proper nesting. Finish validates no open passes. RecordError captures first error. - `core.CommandBuffer` tracks submission state (Available, Submitted) to prevent - double-submission (CORE-003). +- **Updated naga v0.14.6 → v0.14.7** — Fixes MSL sequential per-type binding + indices across bind groups. ## [0.20.2] - 2026-03-12 diff --git a/ROADMAP.md b/ROADMAP.md index 1deb35f..173dee6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,19 +19,31 @@ --- -## Current State: v0.18.1 +## Current State: v0.21.0 ✅ **All 5 HAL backends complete** (~80K LOC, ~100K total) -✅ **Public API root package** — `import "github.com/gogpu/wgpu"` - -**New in v0.18.1:** -- Vulkan: fix buffer-to-image copy row stride corruption — use format's block copy size instead of inferring from padded `BytesPerRow / Width` (gogpu#96) - -**New in v0.18.0:** -- Public API root package with 20 user-facing types wrapping core/ and hal/ +✅ **Three-layer WebGPU stack** — wgpu API → wgpu/core → wgpu/hal +✅ **Complete public API** — consumers never import `wgpu/hal` + +**New in v0.21.0:** +- Complete three-layer architecture: public API → core validation → HAL backends +- core: Surface lifecycle state machine, CommandEncoder state machine, 12 resource types +- Proper type definitions (no hal aliases in godoc): Extent3D, DepthStencilState, TextureBarrier, etc. +- Fence + async submission (SubmitWithFence), Surface PrepareFrame hook +- SetLogger/Logger for stack-wide logging propagation +- naga v0.14.7 (MSL binding index fix) + +**New in v0.20.2:** +- Vulkan: validate WSI query functions in LoadInstance (prevents nil pointer SIGSEGV) + +**New in v0.20.1:** +- Metal: missing stencil attachment in render pass (macOS rendering fix) +- Metal: missing setClearDepth: call + +**New in v0.20.0:** +- Public API root package with typed wrappers for core/ and hal/ - WebGPU-spec-aligned flow: `CreateInstance()` → `RequestAdapter()` → `RequestDevice()` - Synchronous `Queue.Submit()` with internal fence management -- Type aliases from `gputypes` — no extra imports needed - Deterministic `Release()` cleanup on all resource types **New in v0.16.17:** From 5891ab3d2782dc719b4c1df38c75f582142260be Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 15 Mar 2026 13:41:08 +0300 Subject: [PATCH 13/13] fix: lint issues in triangle examples --- cmd/wgpu-triangle-mt/main.go | 11 ++++++----- cmd/wgpu-triangle/main.go | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/wgpu-triangle-mt/main.go b/cmd/wgpu-triangle-mt/main.go index 7ccdde8..075e0b2 100644 --- a/cmd/wgpu-triangle-mt/main.go +++ b/cmd/wgpu-triangle-mt/main.go @@ -35,6 +35,7 @@ func main() { } } +//nolint:gocognit,gocyclo,cyclop,funlen // example code — intentionally sequential func run() error { log.Println("=== wgpu Multi-Thread Triangle Test ===") @@ -69,7 +70,7 @@ func run() error { return } - surface, err = instance.CreateSurface(0, uintptr(window.Handle())) + surface, err = instance.CreateSurface(0, window.Handle()) if err != nil { initErr = fmt.Errorf("surface: %w", err) return @@ -195,24 +196,24 @@ func run() error { renderPass.SetPipeline(pipeline) renderPass.Draw(3, 1, 0, 0) if err := renderPass.End(); err != nil { - frameErr = fmt.Errorf("End: %w", err) + frameErr = fmt.Errorf("end: %w", err) view.Release() return } commands, err := encoder.Finish() if err != nil { - frameErr = fmt.Errorf("Finish: %w", err) + frameErr = fmt.Errorf("finish: %w", err) view.Release() return } if err := device.Queue().Submit(commands); err != nil { - frameErr = fmt.Errorf("Submit: %w", err) + frameErr = fmt.Errorf("submit: %w", err) } if err := surface.Present(surfaceTex); err != nil { - frameErr = fmt.Errorf("Present: %w", err) + frameErr = fmt.Errorf("present: %w", err) } view.Release() diff --git a/cmd/wgpu-triangle/main.go b/cmd/wgpu-triangle/main.go index 23ccea7..6fe3e24 100644 --- a/cmd/wgpu-triangle/main.go +++ b/cmd/wgpu-triangle/main.go @@ -27,6 +27,7 @@ func main() { } } +//nolint:funlen // example code — intentionally sequential func run() error { log.Println("=== wgpu Public API Triangle Test ===") @@ -41,7 +42,7 @@ func run() error { return fmt.Errorf("instance: %w", err) } - surface, err := instance.CreateSurface(0, uintptr(window.Handle())) + surface, err := instance.CreateSurface(0, window.Handle()) if err != nil { return fmt.Errorf("surface: %w", err) }