From ecb2480b822cefd70e2177c72e8cc98c39553efb Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 14 Jul 2026 15:23:14 +0300 Subject: [PATCH 1/2] fix(dx12): match indirect descriptor ABI size --- hal/dx12/d3d12/types.go | 6 +++++- hal/dx12/d3d12/types_test.go | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 hal/dx12/d3d12/types_test.go diff --git a/hal/dx12/d3d12/types.go b/hal/dx12/d3d12/types.go index 62055fb..504b56b 100644 --- a/hal/dx12/d3d12/types.go +++ b/hal/dx12/d3d12/types.go @@ -597,7 +597,11 @@ type D3D12_COMMAND_SIGNATURE_DESC struct { type D3D12_INDIRECT_ARGUMENT_DESC struct { Type D3D12_INDIRECT_ARGUMENT_TYPE // Union for different argument types - Union [8]byte + // The largest member (D3D12_INDIRECT_ARGUMENT_DESC_CONSTANT) is three + // uint32 values, so the C union occupies 12 bytes and the full descriptor + // is 16 bytes including Type. Keeping the exact size matters because + // CreateCommandSignature reads the native descriptor layout directly. + Union [12]byte } // D3D12_DISCARD_REGION describes a discard region. diff --git a/hal/dx12/d3d12/types_test.go b/hal/dx12/d3d12/types_test.go new file mode 100644 index 0000000..16d5052 --- /dev/null +++ b/hal/dx12/d3d12/types_test.go @@ -0,0 +1,17 @@ +//go:build windows && !(js && wasm) + +package d3d12 + +import ( + "testing" + "unsafe" +) + +func TestIndirectArgumentDescABI(t *testing.T) { + if got := unsafe.Sizeof(D3D12_INDIRECT_ARGUMENT_DESC{}); got != 16 { + t.Fatalf("D3D12_INDIRECT_ARGUMENT_DESC size = %d, want 16", got) + } + if got := unsafe.Offsetof(D3D12_INDIRECT_ARGUMENT_DESC{}.Union); got != 4 { + t.Fatalf("D3D12_INDIRECT_ARGUMENT_DESC.Union offset = %d, want 4", got) + } +} From 19534523036ff4a84247fb19c95dfa7d6807eb13 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 14 Jul 2026 15:54:14 +0300 Subject: [PATCH 2/2] feat: add fixed-count indirect draws --- CHANGELOG.md | 14 ++ core/command.go | 20 +- core/device_hal_test.go | 4 +- core/indirect_count_test.go | 122 +++++++++++ hal/command.go | 8 +- hal/dx12/adapter.go | 4 + hal/dx12/command.go | 39 +++- hal/gles/command.go | 16 +- hal/gles/gl/context_linux.go | 1 - hal/gles/indirect_test.go | 26 +++ hal/metal/encoder.go | 58 ++++- hal/metal/indirect_count_test.go | 14 ++ hal/noop/command.go | 4 +- hal/noop/noop_test.go | 4 +- hal/software/command.go | 4 +- hal/software/software_test.go | 4 +- hal/vulkan/adapter.go | 2 + hal/vulkan/command.go | 105 ++++++++- hal/vulkan/device.go | 20 +- hal/vulkan/indirect_count_test.go | 107 +++++++++ indirect.go | 53 +++++ indirect_count_integration_test.go | 218 +++++++++++++++++++ internal/browser/renderpass.go | 2 +- internal/browser/renderpass_indirect_test.go | 56 +++++ renderpass_browser.go | 35 ++- renderpass_indirect_browser_test.go | 54 +++++ renderpass_indirect_rust_test.go | 46 ++++ renderpass_indirect_test.go | 77 +++++++ renderpass_indirect_wrappers_test.go | 45 ++++ renderpass_native.go | 38 +++- renderpass_rust.go | 47 +++- wgpu_test.go | 141 ++++++++++++ 32 files changed, 1315 insertions(+), 73 deletions(-) create mode 100644 core/indirect_count_test.go create mode 100644 hal/gles/indirect_test.go create mode 100644 hal/metal/indirect_count_test.go create mode 100644 hal/vulkan/indirect_count_test.go create mode 100644 indirect.go create mode 100644 indirect_count_integration_test.go create mode 100644 internal/browser/renderpass_indirect_test.go create mode 100644 renderpass_indirect_browser_test.go create mode 100644 renderpass_indirect_rust_test.go create mode 100644 renderpass_indirect_test.go create mode 100644 renderpass_indirect_wrappers_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb3465..09ea7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **Counted indirect draws** — added `RenderPassEncoder.MultiDrawIndirect` and + `MultiDrawIndexedIndirect` for consecutive 16-byte and 20-byte argument + records. Existing two-argument `DrawIndirect` and `DrawIndexedIndirect` + remain single-draw APIs. The Vulkan `FeatureMultiDrawIndirect` capability is + used only as a performance hint; Vulkan multi-draw calls fall back to exact + loops when unavailable or over the device limit. GLES indirect drawing + remains unsupported as before. `FeatureMultiDrawIndirectCount` remains + reserved for future GPU-driven count buffers. External HAL adapters must add + the `drawCount` parameter to both indirect draw methods. + ## [0.30.20] - 2026-07-14 ### Fixed diff --git a/core/command.go b/core/command.go index adf704b..a6cedac 100644 --- a/core/command.go +++ b/core/command.go @@ -799,7 +799,11 @@ func (p *CoreRenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstInde // DrawIndirect draws primitives with GPU-generated parameters. func (p *CoreRenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { - if p.ended { + p.MultiDrawIndirect(buffer, offset, 1) +} + +func (p *CoreRenderPassEncoder) MultiDrawIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if p.ended || drawCount == 0 { return } if p.raw != nil && buffer != nil { @@ -807,14 +811,20 @@ func (p *CoreRenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { defer guard.Release() halBuffer := buffer.Raw(guard) if halBuffer != nil { - p.raw.DrawIndirect(halBuffer, offset) + p.raw.DrawIndirect(halBuffer, offset, drawCount) } } } -// DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. +// DrawIndexedIndirect draws one indexed primitive with GPU-generated parameters. func (p *CoreRenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint64) { - if p.ended { + p.MultiDrawIndexedIndirect(buffer, offset, 1) +} + +// MultiDrawIndexedIndirect draws consecutive indexed primitives with +// GPU-generated parameters. +func (p *CoreRenderPassEncoder) MultiDrawIndexedIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if p.ended || drawCount == 0 { return } if p.raw != nil && buffer != nil { @@ -822,7 +832,7 @@ func (p *CoreRenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint6 defer guard.Release() halBuffer := buffer.Raw(guard) if halBuffer != nil { - p.raw.DrawIndexedIndirect(halBuffer, offset) + p.raw.DrawIndexedIndirect(halBuffer, offset, drawCount) } } } diff --git a/core/device_hal_test.go b/core/device_hal_test.go index a091384..0abae96 100644 --- a/core/device_hal_test.go +++ b/core/device_hal_test.go @@ -93,8 +93,8 @@ func (mockRenderPassEncoder) SetBlendConstant(_ *gputypes.Color) func (mockRenderPassEncoder) SetStencilReference(_ uint32) {} func (mockRenderPassEncoder) Draw(_, _, _, _ uint32) {} func (mockRenderPassEncoder) DrawIndexed(_, _, _ uint32, _ int32, _ uint32) {} -func (mockRenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64) {} -func (mockRenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64) {} +func (mockRenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64, _ uint32) {} +func (mockRenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64, _ uint32) {} func (mockRenderPassEncoder) ExecuteBundle(_ hal.RenderBundle) {} // mockComputePassEncoder implements hal.ComputePassEncoder (minimal) diff --git a/core/indirect_count_test.go b/core/indirect_count_test.go new file mode 100644 index 0000000..c3f0391 --- /dev/null +++ b/core/indirect_count_test.go @@ -0,0 +1,122 @@ +//go:build !(js && wasm) + +package core + +import ( + "fmt" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +type countedRenderPassEncoder struct { + mockRenderPassEncoder + drawCalls int + drawOffset uint64 + drawCount uint32 + calls int + offset uint64 + count uint32 +} + +func (p *countedRenderPassEncoder) DrawIndirect(_ hal.Buffer, offset uint64, count uint32) { + p.drawCalls++ + p.drawOffset = offset + p.drawCount = count +} + +func TestCoreRenderPassEncoderDrawIndirectForwardsCountOnce(t *testing.T) { + device := NewDevice(&mockHALDevice{}, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + buffer := NewBuffer(mockBuffer{}, device, gputypes.BufferUsageIndirect, 64, "indirect") + recorder := &countedRenderPassEncoder{} + pass := &CoreRenderPassEncoder{raw: recorder, device: device} + + pass.DrawIndirect(buffer, 4) + if recorder.drawCalls != 1 || recorder.drawOffset != 4 || recorder.drawCount != 1 { + t.Fatalf("single forwarding = calls %d offset %d count %d; want 1, 4, 1", recorder.drawCalls, recorder.drawOffset, recorder.drawCount) + } + pass.MultiDrawIndirect(buffer, 4, 3) + if recorder.drawCalls != 2 || recorder.drawCount != 3 { + t.Fatalf("multi forwarding = calls %d count %d; want 2, 3", recorder.drawCalls, recorder.drawCount) + } +} + +func (p *countedRenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, offset uint64, count uint32) { + p.calls++ + p.offset = offset + p.count = count +} + +func TestCoreRenderPassEncoderDrawIndexedIndirectForwardsCountOnce(t *testing.T) { + for _, drawCount := range []uint32{1, 3} { + t.Run(fmt.Sprintf("count-%d", drawCount), func(t *testing.T) { + device := NewDevice(&mockHALDevice{}, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + buffer := NewBuffer(mockBuffer{}, device, gputypes.BufferUsageIndirect, 64, "indirect") + recorder := &countedRenderPassEncoder{} + pass := &CoreRenderPassEncoder{raw: recorder, device: device} + + pass.MultiDrawIndexedIndirect(buffer, 4, drawCount) + if recorder.calls != 1 { + t.Fatalf("HAL DrawIndexedIndirect calls = %d, want 1", recorder.calls) + } + if recorder.offset != 4 || recorder.count != drawCount { + t.Fatalf("HAL arguments = offset %d, count %d; want 4, %d", recorder.offset, recorder.count, drawCount) + } + }) + } +} + +func TestCoreRenderPassEncoderDrawIndexedIndirectZeroCountIsNoOp(t *testing.T) { + device := NewDevice(&mockHALDevice{}, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + recorder := &countedRenderPassEncoder{} + pass := &CoreRenderPassEncoder{raw: recorder, device: device} + + pass.MultiDrawIndexedIndirect(nil, ^uint64(0), 0) + if recorder.calls != 0 { + t.Fatalf("HAL DrawIndexedIndirect calls = %d, want 0", recorder.calls) + } +} + +func TestCoreRenderPassEncoderDrawIndexedIndirectAllocations(t *testing.T) { + device := NewDevice(&mockHALDevice{}, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + buffer := NewBuffer(mockBuffer{}, device, gputypes.BufferUsageIndirect, 64, "indirect") + recorder := &countedRenderPassEncoder{} + pass := &CoreRenderPassEncoder{raw: recorder, device: device} + + for _, drawCount := range []uint32{1, 3} { + t.Run(fmt.Sprintf("count-%d", drawCount), func(t *testing.T) { + if allocs := testing.AllocsPerRun(1000, func() { + pass.MultiDrawIndexedIndirect(buffer, 4, drawCount) + }); allocs != 0 { + t.Fatalf("CoreRenderPassEncoder.DrawIndexedIndirect allocations = %v, want 0", allocs) + } + }) + } +} + +func BenchmarkCoreRenderPassEncoderDrawIndexedIndirectCount1(b *testing.B) { + benchmarkCoreRenderPassEncoderDrawIndexedIndirect(b, 1) +} + +func BenchmarkCoreRenderPassEncoderDrawIndexedIndirectCountN(b *testing.B) { + benchmarkCoreRenderPassEncoderDrawIndexedIndirect(b, 1024) +} + +func benchmarkCoreRenderPassEncoderDrawIndexedIndirect(b *testing.B, drawCount uint32) { + device := NewDevice(&mockHALDevice{}, &Adapter{}, gputypes.Features(0), gputypes.DefaultLimits(), "TestDevice") + bufferSize := uint64(drawCount)*20 + 4 + buffer := NewBuffer(mockBuffer{}, device, gputypes.BufferUsageIndirect, bufferSize, "indirect") + recorder := &countedRenderPassEncoder{} + pass := &CoreRenderPassEncoder{raw: recorder, device: device} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + pass.MultiDrawIndexedIndirect(buffer, 4, drawCount) + } + b.StopTimer() + if recorder.calls != b.N { + b.Fatalf("HAL DrawIndexedIndirect calls = %d, want %d", recorder.calls, b.N) + } +} diff --git a/hal/command.go b/hal/command.go index 347366a..2598909 100644 --- a/hal/command.go +++ b/hal/command.go @@ -124,12 +124,12 @@ type RenderPassEncoder interface { DrawIndexed(indexCount, instanceCount, firstIndex uint32, baseVertex int32, firstInstance uint32) // DrawIndirect draws primitives with GPU-generated parameters. - // buffer contains DrawIndirectArgs at the given offset. - DrawIndirect(buffer Buffer, offset uint64) + // buffer contains drawCount consecutive 16-byte DrawIndirectArgs records. + DrawIndirect(buffer Buffer, offset uint64, drawCount uint32) // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. - // buffer contains DrawIndexedIndirectArgs at the given offset. - DrawIndexedIndirect(buffer Buffer, offset uint64) + // buffer contains drawCount consecutive 20-byte DrawIndexedIndirectArgs records. + DrawIndexedIndirect(buffer Buffer, offset uint64, drawCount uint32) // ExecuteBundle executes a pre-recorded render bundle. // Bundles are an optimization for repeated draw calls. diff --git a/hal/dx12/adapter.go b/hal/dx12/adapter.go index de4b6fe..fb9a679 100644 --- a/hal/dx12/adapter.go +++ b/hal/dx12/adapter.go @@ -239,6 +239,9 @@ func (a *Adapter) Info() gputypes.AdapterInfo { // Features returns supported WebGPU features. func (a *Adapter) Features() gputypes.Features { var features gputypes.Features + // ExecuteIndirect supports counted draws natively. This feature is a + // performance hint; the public MultiDraw APIs do not gate on it. + features |= gputypes.Features(gputypes.FeatureMultiDrawIndirect) // Map D3D12 capabilities to WebGPU features // Feature level 11.0+ guarantees basic compute and texture compression @@ -588,6 +591,7 @@ func (a *AdapterLegacy) toExposedAdapter() hal.ExposedAdapter { // Features returns supported WebGPU features for legacy adapter. func (a *AdapterLegacy) Features() gputypes.Features { var features gputypes.Features + features |= gputypes.Features(gputypes.FeatureMultiDrawIndirect) if a.capabilities.FeatureLevel >= d3d12.D3D_FEATURE_LEVEL_11_0 { features |= gputypes.Features(gputypes.FeatureTextureCompressionBC) } diff --git a/hal/dx12/command.go b/hal/dx12/command.go index 543affe..88e45f8 100644 --- a/hal/dx12/command.go +++ b/hal/dx12/command.go @@ -895,33 +895,60 @@ func (e *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui // DrawIndirect draws primitives with GPU-generated parameters. // The buffer must contain a D3D12_DRAW_ARGUMENTS struct at the given offset // (vertexCountPerInstance, instanceCount, startVertexLocation, startInstanceLocation — 16 bytes). -func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64) { +func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || !e.encoder.isRecording { + if !ok || !e.encoder.isRecording || drawCount == 0 { + return + } + if !indirectRangeFits(buf.size, offset, 16, drawCount) { + return + } + if _, ok := indirectRecordOffset(offset, 16, drawCount-1); !ok { return } e.encoder.cmdList.ExecuteIndirect( e.encoder.device.cmdSignatures.draw, - 1, buf.raw, offset, nil, 0, + drawCount, buf.raw, offset, nil, 0, ) } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. // The buffer must contain a D3D12_DRAW_INDEXED_ARGUMENTS struct at the given offset // (indexCountPerInstance, instanceCount, startIndexLocation, baseVertexLocation, startInstanceLocation — 20 bytes). -func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64) { +func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || !e.encoder.isRecording { + if !ok || !e.encoder.isRecording || drawCount == 0 { + return + } + if !indirectRangeFits(buf.size, offset, 20, drawCount) { + return + } + if _, ok := indirectRecordOffset(offset, 20, drawCount-1); !ok { return } e.encoder.cmdList.ExecuteIndirect( e.encoder.device.cmdSignatures.drawIndexed, - 1, buf.raw, offset, nil, 0, + drawCount, buf.raw, offset, nil, 0, ) } +func indirectRecordOffset(offset, stride uint64, index uint32) (uint64, bool) { + delta := uint64(index) * stride + if offset > ^uint64(0)-delta { + return 0, false + } + return offset + delta, true +} + +func indirectRangeFits(bufferSize, offset, stride uint64, drawCount uint32) bool { + if offset > bufferSize { + return false + } + return uint64(drawCount) <= (bufferSize-offset)/stride +} + // ExecuteBundle executes a pre-recorded render bundle. func (e *RenderPassEncoder) ExecuteBundle(bundle hal.RenderBundle) { // Note: DX12 bundles use ID3D12GraphicsCommandList created with D3D12_COMMAND_LIST_TYPE_BUNDLE. diff --git a/hal/gles/command.go b/hal/gles/command.go index 9c80a94..76fb568 100644 --- a/hal/gles/command.go +++ b/hal/gles/command.go @@ -650,20 +650,20 @@ func (e *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui }) } -// DrawIndirect draws primitives with GPU-generated parameters. -// Note: Requires GL_ARB_draw_indirect (GL 4.0+ / GLES 3.1+). -// Currently not implemented - use direct Draw calls instead. -func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64) { +// DrawIndirect is not implemented by the GLES backend. +func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { _ = buffer _ = offset + _ = drawCount } -// DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. -// Note: Requires GL_ARB_draw_indirect (GL 4.0+ / GLES 3.1+). -// Currently not implemented - use direct DrawIndexed calls instead. -func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64) { +// DrawIndexedIndirect is not implemented by the GLES backend. +// OpenGL cannot apply a WebGPU index-buffer base offset to an indirect record +// without translating that record or the bound index buffer. +func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { _ = buffer _ = offset + _ = drawCount } // ExecuteBundle executes a pre-recorded render bundle. diff --git a/hal/gles/gl/context_linux.go b/hal/gles/gl/context_linux.go index b6c3414..2e9e20f 100644 --- a/hal/gles/gl/context_linux.go +++ b/hal/gles/gl/context_linux.go @@ -172,7 +172,6 @@ func initCommonCallInterfaces() error { if err != nil { return err } - // int32 fn(uint32, void*) err = ffi.PrepareCallInterface(&cifInt322, types.DefaultCall, types.SInt32TypeDescriptor, diff --git a/hal/gles/indirect_test.go b/hal/gles/indirect_test.go new file mode 100644 index 0000000..29c49ea --- /dev/null +++ b/hal/gles/indirect_test.go @@ -0,0 +1,26 @@ +//go:build (windows || linux) && !(js && wasm) + +package gles + +import ( + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +func TestRenderPassEncoderCountedIndirectRemainsUnsupported(t *testing.T) { + enc := &CommandEncoder{} + if err := enc.BeginEncoding("indirect"); err != nil { + t.Fatal(err) + } + pass := enc.BeginRenderPass(&hal.RenderPassDescriptor{ColorAttachments: []hal.RenderPassColorAttachment{}}) + + pass.DrawIndirect(&Buffer{id: 7, size: 64}, 0, 2) + pass.SetIndexBuffer(&Buffer{id: 9, size: 64}, gputypes.IndexFormatUint32, 16) + pass.DrawIndexedIndirect(&Buffer{id: 8, size: 64}, 0, 2) + + if len(enc.commands) != 1 { + t.Fatalf("commands = %d, want only SetIndexBufferCommand", len(enc.commands)) + } +} diff --git a/hal/metal/encoder.go b/hal/metal/encoder.go index 73f1391..5398d21 100644 --- a/hal/metal/encoder.go +++ b/hal/metal/encoder.go @@ -592,24 +592,66 @@ func (e *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui } // DrawIndirect draws primitives with GPU-generated parameters. -func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64) { +func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || buf == nil { + if !ok || buf == nil || drawCount == 0 { + return + } + if !indirectRangeFits(buf.size, offset, 16, drawCount) { + return + } + if _, ok := indirectRecordOffset(offset, 16, drawCount-1); !ok { return } - _ = MsgSend(e.raw, Sel("drawPrimitives:indirectBuffer:indirectBufferOffset:"), - uintptr(MTLPrimitiveTypeTriangle), uintptr(buf.raw), uintptr(offset)) + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := indirectRecordOffset(offset, 16, i) + _ = MsgSend(e.raw, Sel("drawPrimitives:indirectBuffer:indirectBufferOffset:"), + uintptr(MTLPrimitiveTypeTriangle), uintptr(buf.raw), uintptr(recordOffset)) + } } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. -func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64) { +// Metal exposes only the single-record indirect operation, so count is lowered +// to consecutive 20-byte calls. +func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || buf == nil || e.indexBuffer == nil { + if !ok || buf == nil || e.indexBuffer == nil || drawCount == 0 { + return + } + if !indirectRangeFits(buf.size, offset, 20, drawCount) { + return + } + if _, ok := indirectRecordOffset(offset, 20, drawCount-1); !ok { return } indexType := indexFormatToMTL(e.indexFormat) - _ = MsgSend(e.raw, Sel("drawIndexedPrimitives:indexType:indexBuffer:indexBufferOffset:indirectBuffer:indirectBufferOffset:"), - uintptr(MTLPrimitiveTypeTriangle), uintptr(indexType), uintptr(e.indexBuffer.raw), uintptr(e.indexOffset), uintptr(buf.raw), uintptr(offset)) + for i := uint32(0); i < drawCount; i++ { + recordOffset, ok := indirectRecordOffset(offset, 20, i) + if !ok { + return + } + _ = MsgSend(e.raw, Sel("drawIndexedPrimitives:indexType:indexBuffer:indexBufferOffset:indirectBuffer:indirectBufferOffset:"), + uintptr(MTLPrimitiveTypeTriangle), uintptr(indexType), uintptr(e.indexBuffer.raw), uintptr(e.indexOffset), uintptr(buf.raw), uintptr(recordOffset)) + } +} + +func indirectRecordOffset(offset, stride uint64, index uint32) (uint64, bool) { + delta := uint64(index) * stride + if offset > ^uint64(0)-delta { + return 0, false + } + return offset + delta, true +} + +func indirectRangeFits(bufferSize, offset, stride uint64, drawCount uint32) bool { + if offset > bufferSize { + return false + } + return uint64(drawCount) <= (bufferSize-offset)/stride +} + +func indexedIndirectRecordOffset(offset uint64, index uint32) (uint64, bool) { + return indirectRecordOffset(offset, 20, index) } // ExecuteBundle executes a pre-recorded render bundle. diff --git a/hal/metal/indirect_count_test.go b/hal/metal/indirect_count_test.go new file mode 100644 index 0000000..88c7731 --- /dev/null +++ b/hal/metal/indirect_count_test.go @@ -0,0 +1,14 @@ +//go:build darwin && !(js && wasm) + +package metal + +import "testing" + +func TestIndexedIndirectRecordOffset(t *testing.T) { + if got, ok := indexedIndirectRecordOffset(4, 3); !ok || got != 64 { + t.Fatalf("indexedIndirectRecordOffset(4, 3) = %d, %t; want 64, true", got, ok) + } + if _, ok := indexedIndirectRecordOffset(^uint64(0)-3, 1); ok { + t.Fatal("indexedIndirectRecordOffset should reject uint64 overflow") + } +} diff --git a/hal/noop/command.go b/hal/noop/command.go index 5d1cf77..5dee2cc 100644 --- a/hal/noop/command.go +++ b/hal/noop/command.go @@ -102,10 +102,10 @@ func (r *RenderPassEncoder) Draw(_, _, _, _ uint32) {} func (r *RenderPassEncoder) DrawIndexed(_, _, _ uint32, _ int32, _ uint32) {} // DrawIndirect is a no-op. -func (r *RenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64) {} +func (r *RenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64, _ uint32) {} // DrawIndexedIndirect is a no-op. -func (r *RenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64) {} +func (r *RenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64, _ uint32) {} // ExecuteBundle is a no-op. func (r *RenderPassEncoder) ExecuteBundle(_ hal.RenderBundle) {} diff --git a/hal/noop/noop_test.go b/hal/noop/noop_test.go index c02bb37..e00ebaa 100644 --- a/hal/noop/noop_test.go +++ b/hal/noop/noop_test.go @@ -1562,8 +1562,8 @@ func TestNoopRenderPassEncoder(t *testing.T) { pass.SetStencilReference(0xFF) pass.Draw(6, 1, 0, 0) pass.DrawIndexed(6, 1, 0, 0, 0) - pass.DrawIndirect(buf, 0) - pass.DrawIndexedIndirect(buf, 0) + pass.DrawIndirect(buf, 0, 1) + pass.DrawIndexedIndirect(buf, 0, 1) pass.ExecuteBundle(nil) pass.End() } diff --git a/hal/software/command.go b/hal/software/command.go index 3213cff..71c9093 100644 --- a/hal/software/command.go +++ b/hal/software/command.go @@ -481,10 +481,10 @@ func (r *RenderPassEncoder) drawVertexIndex(firstVertex, pos uint32) uint32 { } // DrawIndirect is a no-op. -func (r *RenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64) {} +func (r *RenderPassEncoder) DrawIndirect(_ hal.Buffer, _ uint64, _ uint32) {} // DrawIndexedIndirect is a no-op. -func (r *RenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64) {} +func (r *RenderPassEncoder) DrawIndexedIndirect(_ hal.Buffer, _ uint64, _ uint32) {} // ExecuteBundle is a no-op. func (r *RenderPassEncoder) ExecuteBundle(_ hal.RenderBundle) {} diff --git a/hal/software/software_test.go b/hal/software/software_test.go index 59cf880..df54683 100644 --- a/hal/software/software_test.go +++ b/hal/software/software_test.go @@ -972,8 +972,8 @@ func TestRenderPassEncoderAllNoOps(t *testing.T) { pass.SetStencilReference(0xFF) pass.Draw(3, 1, 0, 0) pass.DrawIndexed(6, 1, 0, 0, 0) - pass.DrawIndirect(buf, 0) - pass.DrawIndexedIndirect(buf, 0) + pass.DrawIndirect(buf, 0, 1) + pass.DrawIndexedIndirect(buf, 0, 1) pass.ExecuteBundle(nil) pass.End() } diff --git a/hal/vulkan/adapter.go b/hal/vulkan/adapter.go index 0a972e3..24b5798 100644 --- a/hal/vulkan/adapter.go +++ b/hal/vulkan/adapter.go @@ -148,6 +148,8 @@ func (a *Adapter) Open(features gputypes.Features, limits gputypes.Limits) (hal. instance: a.instance, graphicsFamily: uint32(graphicsFamily), cmds: &deviceCmds, + supportsMultiDrawIndirect: a.features.MultiDrawIndirect != 0, + maxDrawIndirectCount: a.properties.Limits.MaxDrawIndirectCount, supportsIncrementalPresent: hasIncrementalPresent, } diff --git a/hal/vulkan/command.go b/hal/vulkan/command.go index 15864bf..a64bc05 100644 --- a/hal/vulkan/command.go +++ b/hal/vulkan/command.go @@ -866,6 +866,12 @@ type RenderPassEncoder struct { framebuffer vk.Framebuffer } +const ( + drawIndirectStride = uint32(16) + drawIndexedIndirectStride = uint32(20) + indexedIndirectStride = drawIndexedIndirectStride +) + // End finishes the render pass. // Returns the encoder to the pool for reuse (VK-PERF-006). func (e *RenderPassEncoder) End() { @@ -1030,23 +1036,108 @@ func (e *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui } // DrawIndirect draws primitives with GPU-generated parameters. -func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64) { +func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || e.encoder.active == 0 { + if !ok || e.encoder.active == 0 || drawCount == 0 { return } - - vkCmdDrawIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(offset), 1, 0) + if !indirectRangeFits(buf.size, offset, uint64(drawIndirectStride), drawCount) { + return + } + call, batched, ok := indirectCallPlan(e.encoder.device.supportsMultiDrawIndirect, + e.encoder.device.maxDrawIndirectCount, offset, drawCount, uint32(drawIndirectStride)) + if !ok { + return + } + if batched { + vkCmdDrawIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(call.offset), call.count, call.stride) + return + } + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := indirectRecordOffset(offset, uint64(drawIndirectStride), i) + vkCmdDrawIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(recordOffset), 1, drawIndirectStride) + } } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. -func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64) { +// Vulkan can encode a span in one command only when the optional +// multiDrawIndirect feature is enabled and the count fits the device limit; +// otherwise emit the exact single-record loop. +func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64, drawCount uint32) { buf, ok := buffer.(*Buffer) - if !ok || e.encoder.active == 0 { + if !ok || e.encoder.active == 0 || drawCount == 0 { + return + } + if !indirectRangeFits(buf.size, offset, uint64(drawIndexedIndirectStride), drawCount) { + return + } + call, batched, ok := indirectCallPlan( + e.encoder.device.supportsMultiDrawIndirect, + e.encoder.device.maxDrawIndirectCount, + offset, + drawCount, + uint32(drawIndexedIndirectStride), + ) + if !ok { + return + } + + if batched { + vkCmdDrawIndexedIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(call.offset), call.count, call.stride) return } + for i := uint32(0); i < drawCount; i++ { + recordOffset, ok := indirectRecordOffset(offset, uint64(drawIndexedIndirectStride), i) + if !ok { + return + } + vkCmdDrawIndexedIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(recordOffset), call.count, call.stride) + } +} + +type indexedIndirectCall struct { + offset uint64 + count uint32 + stride uint32 +} + +// indexedIndirectCallPlan returns the first native call shape for an indexed +// indirect draw. The plan is pure so count/stride policy can be tested without +// constructing a Vulkan command buffer or invoking FFI. +func indirectCallPlan(supportsMultiDraw bool, maxDrawCount uint32, offset uint64, drawCount, stride uint32) (indexedIndirectCall, bool, bool) { + if drawCount == 0 { + return indexedIndirectCall{}, false, false + } + if _, ok := indirectRecordOffset(offset, uint64(stride), drawCount-1); !ok { + return indexedIndirectCall{}, false, false + } + if supportsMultiDraw && drawCount <= maxDrawCount { + return indexedIndirectCall{offset: offset, count: drawCount, stride: stride}, true, true + } + return indexedIndirectCall{offset: offset, count: 1, stride: stride}, false, true +} + +func indexedIndirectCallPlan(supportsMultiDraw bool, maxDrawCount uint32, offset uint64, drawCount uint32) (indexedIndirectCall, bool, bool) { + return indirectCallPlan(supportsMultiDraw, maxDrawCount, offset, drawCount, uint32(drawIndexedIndirectStride)) +} + +func indirectRecordOffset(offset, stride uint64, index uint32) (uint64, bool) { + delta := uint64(index) * stride + if offset > ^uint64(0)-delta { + return 0, false + } + return offset + delta, true +} + +func indirectRangeFits(bufferSize, offset, stride uint64, drawCount uint32) bool { + if offset > bufferSize { + return false + } + return uint64(drawCount) <= (bufferSize-offset)/stride +} - vkCmdDrawIndexedIndirect(e.encoder.device.cmds, e.encoder.active, buf.handle, vk.DeviceSize(offset), 1, 0) +func indexedIndirectRecordOffset(offset uint64, index uint32) (uint64, bool) { + return indirectRecordOffset(offset, uint64(drawIndexedIndirectStride), index) } // ExecuteBundle executes a pre-recorded render bundle. diff --git a/hal/vulkan/device.go b/hal/vulkan/device.go index 67a4ca5..a0f3f56 100644 --- a/hal/vulkan/device.go +++ b/hal/vulkan/device.go @@ -56,15 +56,17 @@ var renderPassPool = sync.Pool{ // Device implements hal.Device for Vulkan. type Device struct { - handle vk.Device - physicalDevice vk.PhysicalDevice - instance *Instance - graphicsFamily uint32 - allocator *memory.GpuAllocator - cmds *vk.Commands - descriptorAllocator *DescriptorAllocator // Descriptor pool management for bind groups - queue *Queue // Primary queue (for swapchain synchronization) - renderPassCache *RenderPassCache // Cache for VkRenderPass and VkFramebuffer objects + handle vk.Device + physicalDevice vk.PhysicalDevice + instance *Instance + graphicsFamily uint32 + allocator *memory.GpuAllocator + cmds *vk.Commands + supportsMultiDrawIndirect bool + maxDrawIndirectCount uint32 + descriptorAllocator *DescriptorAllocator // Descriptor pool management for bind groups + queue *Queue // Primary queue (for swapchain synchronization) + renderPassCache *RenderPassCache // Cache for VkRenderPass and VkFramebuffer objects // supportsIncrementalPresent is true when VK_KHR_incremental_present // is enabled on this device. When true, Present can chain diff --git a/hal/vulkan/indirect_count_test.go b/hal/vulkan/indirect_count_test.go new file mode 100644 index 0000000..10b2ad8 --- /dev/null +++ b/hal/vulkan/indirect_count_test.go @@ -0,0 +1,107 @@ +//go:build !(js && wasm) + +package vulkan + +import "testing" + +func TestIndexedIndirectCallPlanCapturesCountAndStride(t *testing.T) { + tests := []struct { + name string + supports bool + max uint32 + drawCount uint32 + wantOffset uint64 + wantCount uint32 + wantBatch bool + }{ + {name: "native multi draw", supports: true, max: 8, drawCount: 3, wantOffset: 4, wantCount: 3, wantBatch: true}, + {name: "single draw fallback", supports: false, max: 8, drawCount: 3, wantOffset: 4, wantCount: 1, wantBatch: false}, + {name: "count one", supports: false, max: 0, drawCount: 1, wantOffset: 4, wantCount: 1, wantBatch: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + call, batched, ok := indexedIndirectCallPlan(test.supports, test.max, test.wantOffset, test.drawCount) + if !ok { + t.Fatal("indexedIndirectCallPlan rejected a valid range") + } + if batched != test.wantBatch { + t.Fatalf("batched = %t, want %t", batched, test.wantBatch) + } + if call.offset != test.wantOffset || call.count != test.wantCount || call.stride != indexedIndirectStride { + t.Fatalf("captured call = offset %d, count %d, stride %d; want offset %d, count %d, stride %d", call.offset, call.count, call.stride, test.wantOffset, test.wantCount, indexedIndirectStride) + } + }) + } +} + +func TestIndirectCallPlanUsesFixedRecordStrideForBothDrawKinds(t *testing.T) { + for _, test := range []struct { + name string + stride uint32 + }{ + {name: "draw", stride: drawIndirectStride}, + {name: "indexed", stride: drawIndexedIndirectStride}, + } { + t.Run(test.name, func(t *testing.T) { + call, batched, ok := indirectCallPlan(true, 8, 4, 3, test.stride) + if !ok || !batched { + t.Fatalf("indirectCallPlan = %#v, batched=%t, ok=%t", call, batched, ok) + } + if call.offset != 4 || call.count != 3 || call.stride != test.stride { + t.Fatalf("call = %#v, want offset=4 count=3 stride=%d", call, test.stride) + } + }) + } +} + +func TestIndexedIndirectRecordOffset(t *testing.T) { + if got, ok := indexedIndirectRecordOffset(4, 3); !ok || got != 64 { + t.Fatalf("indexedIndirectRecordOffset(4, 3) = %d, %t; want 64, true", got, ok) + } + if _, ok := indexedIndirectRecordOffset(^uint64(0)-3, 1); ok { + t.Fatal("indexedIndirectRecordOffset should reject uint64 overflow") + } +} + +func TestIndexedIndirectHelpersAllocateZero(t *testing.T) { + var ( + offset uint64 + ok bool + ) + if allocs := testing.AllocsPerRun(1000, func() { + offset, ok = indexedIndirectRecordOffset(4, 3) + }); allocs != 0 { + t.Fatalf("indexedIndirectRecordOffset allocations = %v, want 0", allocs) + } + if offset != 64 || !ok { + t.Fatalf("indexedIndirectRecordOffset result = %d, %t; want 64, true", offset, ok) + } + if allocs := testing.AllocsPerRun(1000, func() { + _, _, ok = indexedIndirectCallPlan(true, 8, 4, 3) + }); allocs != 0 { + t.Fatalf("indexedIndirectCallPlan allocations = %v, want 0", allocs) + } +} + +func BenchmarkIndexedIndirectCallPlanCount1(b *testing.B) { + benchmarkIndexedIndirectCallPlan(b, 1) +} + +func BenchmarkIndexedIndirectCallPlanCountN(b *testing.B) { + benchmarkIndexedIndirectCallPlan(b, 1024) +} + +func benchmarkIndexedIndirectCallPlan(b *testing.B, drawCount uint32) { + b.ReportAllocs() + var call indexedIndirectCall + var ok bool + b.ResetTimer() + for i := 0; i < b.N; i++ { + call, _, ok = indexedIndirectCallPlan(true, ^uint32(0), 4, drawCount) + } + b.StopTimer() + if !ok || call.count != drawCount || call.stride != indexedIndirectStride { + b.Fatalf("indexedIndirectCallPlan result = %#v, %t", call, ok) + } +} diff --git a/indirect.go b/indirect.go new file mode 100644 index 0000000..7987a3b --- /dev/null +++ b/indirect.go @@ -0,0 +1,53 @@ +package wgpu + +const ( + drawIndirectRecordSize = uint64(16) + drawIndexedIndirectRecordSize = uint64(20) + indexedIndirectRecordSize = drawIndexedIndirectRecordSize +) + +func indirectRangeFits(bufferSize, offset, recordSize uint64, drawCount uint32) bool { + if offset > bufferSize { + return false + } + return uint64(drawCount) <= (bufferSize-offset)/recordSize +} + +func indirectRecordOffset(offset, recordSize uint64, index uint32) (uint64, bool) { + delta := uint64(index) * recordSize + if offset > ^uint64(0)-delta { + return 0, false + } + return offset + delta, true +} + +func drawIndirectRangeFits(bufferSize, offset uint64, drawCount uint32) bool { + return indirectRangeFits(bufferSize, offset, drawIndirectRecordSize, drawCount) +} + +func drawIndirectRecordOffset(offset uint64, index uint32) (uint64, bool) { + return indirectRecordOffset(offset, drawIndirectRecordSize, index) +} + +func indirectDelegatedValidationOffset(bufferSize, offset, recordSize uint64, drawCount uint32) uint64 { + lastOffset, ok := indirectRecordOffset(offset, recordSize, drawCount-1) + if !ok { + return bufferSize + } + return lastOffset +} + +// indexedIndirectRangeFits reports whether drawCount consecutive indexed +// indirect argument records fit in a buffer without overflowing uint64 math. +func indexedIndirectRangeFits(bufferSize, offset uint64, drawCount uint32) bool { + if offset > bufferSize { + return false + } + return indirectRangeFits(bufferSize, offset, drawIndexedIndirectRecordSize, drawCount) +} + +// indexedIndirectRecordOffset returns the byte offset of one record in a +// counted indexed-indirect span without allowing uint64 wraparound. +func indexedIndirectRecordOffset(offset uint64, index uint32) (uint64, bool) { + return indirectRecordOffset(offset, drawIndexedIndirectRecordSize, index) +} diff --git a/indirect_count_integration_test.go b/indirect_count_integration_test.go new file mode 100644 index 0000000..d1a5a83 --- /dev/null +++ b/indirect_count_integration_test.go @@ -0,0 +1,218 @@ +//go:build integration && darwin && !rust && !(js && wasm) + +package wgpu_test + +import ( + "context" + "encoding/binary" + "math" + "testing" + "time" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu" + _ "github.com/gogpu/wgpu/hal/metal" +) + +const countedIndexedShader = ` +@vertex +fn vs_main(@location(0) pos: vec2) -> @builtin(position) vec4 { + return vec4(pos, 0.0, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return vec4(0.0, 1.0, 0.0, 1.0); +} +` + +func TestMultiDrawIndexedIndirectRendersDistinctRecords(t *testing.T) { + instance, err := wgpu.CreateInstance(&wgpu.InstanceDescriptor{Backends: wgpu.BackendsPrimary}) + if err != nil { + t.Skipf("CreateInstance: %v", err) + } + defer instance.Release() + + adapter, err := instance.RequestAdapter(nil) + if err != nil { + t.Skipf("RequestAdapter: %v", err) + } + defer adapter.Release() + if info := adapter.Info(); info.Backend != gputypes.BackendMetal { + t.Skipf("native Metal adapter unavailable: %+v", info) + } + + device, err := adapter.RequestDevice(nil) + if err != nil { + t.Skipf("RequestDevice: %v", err) + } + defer device.Release() + queue := device.Queue() + + vertices := []float32{-1, -1, 1, -1, 1, 1, -1, 1} + vertexData := make([]byte, len(vertices)*4) + for i, value := range vertices { + binary.LittleEndian.PutUint32(vertexData[i*4:], math.Float32bits(value)) + } + indexData := []byte{0, 0, 1, 0, 2, 0, 2, 0, 3, 0, 0, 0} + + // Four padding bytes make the starting offset observable. The two records + // select different triangles; both are required to fill the target. + indirectData := make([]byte, 4+2*20) + putCountedIndexedIndirectRecord(indirectData[4:], 3, 1, 0, 0, 0) + putCountedIndexedIndirectRecord(indirectData[24:], 3, 1, 3, 0, 0) + + vertexBuffer := createCountedIndirectTestBuffer(t, device, queue, vertexData, wgpu.BufferUsageVertex|wgpu.BufferUsageCopyDst) + defer vertexBuffer.Release() + indexBuffer := createCountedIndirectTestBuffer(t, device, queue, indexData, wgpu.BufferUsageIndex|wgpu.BufferUsageCopyDst) + defer indexBuffer.Release() + indirectBuffer := createCountedIndirectTestBuffer(t, device, queue, indirectData, wgpu.BufferUsageIndirect|wgpu.BufferUsageCopyDst) + defer indirectBuffer.Release() + + shader, err := device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{WGSL: countedIndexedShader}) + if err != nil { + t.Fatalf("CreateShaderModule: %v", err) + } + defer shader.Release() + layout, err := device.CreatePipelineLayout(&wgpu.PipelineLayoutDescriptor{}) + if err != nil { + t.Fatalf("CreatePipelineLayout: %v", err) + } + defer layout.Release() + pipeline, err := device.CreateRenderPipeline(&wgpu.RenderPipelineDescriptor{ + Layout: layout, + Vertex: wgpu.VertexState{ + Module: shader, EntryPoint: "vs_main", + Buffers: []gputypes.VertexBufferLayout{{ + ArrayStride: 8, + StepMode: gputypes.VertexStepModeVertex, + Attributes: []gputypes.VertexAttribute{{ + Format: gputypes.VertexFormatFloat32x2, ShaderLocation: 0, + }}, + }}, + }, + Fragment: &wgpu.FragmentState{ + Module: shader, EntryPoint: "fs_main", + Targets: []gputypes.ColorTargetState{{ + Format: gputypes.TextureFormatRGBA8Unorm, WriteMask: gputypes.ColorWriteMaskAll, + }}, + }, + Primitive: gputypes.PrimitiveState{Topology: gputypes.PrimitiveTopologyTriangleList, CullMode: gputypes.CullModeNone}, + Multisample: gputypes.MultisampleState{Count: 1, Mask: 0xFFFFFFFF}, + }) + if err != nil { + t.Fatalf("CreateRenderPipeline: %v", err) + } + defer pipeline.Release() + + const width, height = uint32(64), uint32(16) + target, err := device.CreateTexture(&wgpu.TextureDescriptor{ + Size: wgpu.Extent3D{Width: width, Height: height, DepthOrArrayLayers: 1}, + MipLevelCount: 1, SampleCount: 1, Dimension: gputypes.TextureDimension2D, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment | gputypes.TextureUsageCopySrc, + }) + if err != nil { + t.Fatalf("CreateTexture: %v", err) + } + defer target.Release() + targetView, err := device.CreateTextureView(target, nil) + if err != nil { + t.Fatalf("CreateTextureView: %v", err) + } + defer targetView.Release() + + encoder, err := device.CreateCommandEncoder(&wgpu.CommandEncoderDescriptor{}) + if err != nil { + t.Fatalf("CreateCommandEncoder: %v", err) + } + pass, err := encoder.BeginRenderPass(&wgpu.RenderPassDescriptor{ + ColorAttachments: []wgpu.RenderPassColorAttachment{{ + View: targetView, LoadOp: gputypes.LoadOpClear, StoreOp: gputypes.StoreOpStore, + ClearValue: gputypes.Color{A: 1}, + }}, + }) + if err != nil { + t.Fatalf("BeginRenderPass: %v", err) + } + pass.SetPipeline(pipeline) + pass.SetVertexBuffer(0, vertexBuffer, 0) + pass.SetIndexBuffer(indexBuffer, gputypes.IndexFormatUint16, 0) + pass.MultiDrawIndexedIndirect(indirectBuffer, 4, 2) + if err := pass.End(); err != nil { + t.Fatalf("RenderPass.End: %v", err) + } + encoder.TransitionTextures([]wgpu.TextureBarrier{{ + Texture: target, + Usage: wgpu.TextureUsageTransition{ + OldUsage: gputypes.TextureUsageRenderAttachment, + NewUsage: gputypes.TextureUsageCopySrc, + }, + }}) + + readbackSize := uint64(width * height * 4) + readback, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Size: readbackSize, Usage: gputypes.BufferUsageMapRead | gputypes.BufferUsageCopyDst, + }) + if err != nil { + t.Fatalf("CreateBuffer(readback): %v", err) + } + defer readback.Release() + encoder.CopyTextureToBuffer(target, readback, []wgpu.BufferTextureCopy{{ + BufferLayout: wgpu.ImageDataLayout{BytesPerRow: width * 4, RowsPerImage: height}, + TextureBase: wgpu.ImageCopyTexture{Texture: target}, + Size: wgpu.Extent3D{Width: width, Height: height, DepthOrArrayLayers: 1}, + }}) + + commands, err := encoder.Finish() + if err != nil { + t.Fatalf("Finish: %v", err) + } + if _, err := queue.Submit(commands); err != nil { + t.Fatalf("Submit: %v", err) + } + mapContext, cancelMap := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelMap() + if err := readback.Map(mapContext, wgpu.MapModeRead, 0, readbackSize); err != nil { + t.Fatalf("Map: %v", err) + } + mapped, err := readback.MappedRange(0, readbackSize) + if err != nil { + _ = readback.Unmap() + t.Fatalf("MappedRange: %v", err) + } + pixels := mapped.Bytes() + filled := 0 + for pixel := 0; pixel < int(width*height); pixel++ { + if pixels[pixel*4+1] > 128 { + filled++ + } + } + if err := readback.Unmap(); err != nil { + t.Fatalf("Unmap: %v", err) + } + if minimum := int(width*height) * 3 / 4; filled < minimum { + t.Fatalf("counted indexed indirect filled %d/%d pixels, want at least %d; both records did not render", filled, width*height, minimum) + } +} + +func createCountedIndirectTestBuffer(t *testing.T, device *wgpu.Device, queue *wgpu.Queue, data []byte, usage wgpu.BufferUsage) *wgpu.Buffer { + t.Helper() + buffer, err := device.CreateBuffer(&wgpu.BufferDescriptor{Size: uint64(len(data)), Usage: usage}) + if err != nil { + t.Fatalf("CreateBuffer: %v", err) + } + if err := queue.WriteBuffer(buffer, 0, data); err != nil { + buffer.Release() + t.Fatalf("WriteBuffer: %v", err) + } + return buffer +} + +func putCountedIndexedIndirectRecord(dst []byte, indexCount, instanceCount, firstIndex uint32, baseVertex int32, firstInstance uint32) { + binary.LittleEndian.PutUint32(dst[0:], indexCount) + binary.LittleEndian.PutUint32(dst[4:], instanceCount) + binary.LittleEndian.PutUint32(dst[8:], firstIndex) + binary.LittleEndian.PutUint32(dst[12:], uint32(baseVertex)) + binary.LittleEndian.PutUint32(dst[16:], firstInstance) +} diff --git a/internal/browser/renderpass.go b/internal/browser/renderpass.go index dddc4a5..18c2be0 100644 --- a/internal/browser/renderpass.go +++ b/internal/browser/renderpass.go @@ -118,7 +118,7 @@ func (p *RenderPassEncoder) DrawIndirect(buffer js.Value, offset uint64) { p.fnDrawIndirect.Invoke(buffer, float64(offset)) } -// DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. +// DrawIndexedIndirect draws one indexed primitive with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndexedIndirect(buffer js.Value, offset uint64) { p.fnDrawIndexedIndirect.Invoke(buffer, float64(offset)) } diff --git a/internal/browser/renderpass_indirect_test.go b/internal/browser/renderpass_indirect_test.go new file mode 100644 index 0000000..2190f8c --- /dev/null +++ b/internal/browser/renderpass_indirect_test.go @@ -0,0 +1,56 @@ +//go:build js && wasm + +package browser + +import ( + "syscall/js" + "testing" +) + +func TestDrawIndexedIndirectOverflowDelegatesOneValidationCall(t *testing.T) { + var offsets []float64 + draw := js.FuncOf(func(_ js.Value, args []js.Value) any { + offsets = append(offsets, args[1].Float()) + return nil + }) + defer draw.Release() + + pass := &RenderPassEncoder{fnDrawIndexedIndirect: draw.Value} + pass.DrawIndexedIndirect(js.Undefined(), ^uint64(0)-3) + + if len(offsets) != 1 { + t.Fatalf("drawIndexedIndirect calls = %d, want one delegated validation call", len(offsets)) + } +} + +func TestDrawIndexedIndirectCountOnePreservesOffset(t *testing.T) { + var offsets []float64 + draw := js.FuncOf(func(_ js.Value, args []js.Value) any { + offsets = append(offsets, args[1].Float()) + return nil + }) + defer draw.Release() + + pass := &RenderPassEncoder{fnDrawIndexedIndirect: draw.Value} + pass.DrawIndexedIndirect(js.Undefined(), 8) + + if len(offsets) != 1 || offsets[0] != 8 { + t.Fatalf("drawIndexedIndirect offsets = %v, want [8]", offsets) + } +} + +func TestDrawIndexedIndirectSingleRecordCallsOnce(t *testing.T) { + var calls int + draw := js.FuncOf(func(_ js.Value, _ []js.Value) any { + calls++ + return nil + }) + defer draw.Release() + + pass := &RenderPassEncoder{fnDrawIndexedIndirect: draw.Value} + pass.DrawIndexedIndirect(js.Undefined(), 8) + + if calls != 1 { + t.Fatalf("drawIndexedIndirect calls = %d, want one", calls) + } +} diff --git a/renderpass_browser.go b/renderpass_browser.go index 8c7f4c3..c73c2e1 100644 --- a/renderpass_browser.go +++ b/renderpass_browser.go @@ -84,18 +84,49 @@ func (p *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui // DrawIndirect draws primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndirect(buffer, offset, 1) +} + +// MultiDrawIndirect draws consecutive primitives with GPU-generated parameters. +func (p *RenderPassEncoder) MultiDrawIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if buffer == nil || buffer.browser == nil { return } - p.browser.DrawIndirect(buffer.browser.Ref(), offset) + if !drawIndirectRangeFits(buffer.Size(), offset, drawCount) { + p.browser.DrawIndirect(buffer.browser.Ref(), indirectDelegatedValidationOffset(buffer.Size(), offset, drawIndirectRecordSize, drawCount)) + return + } + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := drawIndirectRecordOffset(offset, i) + p.browser.DrawIndirect(buffer.browser.Ref(), recordOffset) + } } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndexedIndirect(buffer, offset, 1) +} + +// MultiDrawIndexedIndirect draws consecutive indexed primitives with +// GPU-generated parameters. +func (p *RenderPassEncoder) MultiDrawIndexedIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if buffer == nil || buffer.browser == nil { return } - p.browser.DrawIndexedIndirect(buffer.browser.Ref(), offset) + if !indexedIndirectRangeFits(buffer.Size(), offset, drawCount) { + p.browser.DrawIndexedIndirect(buffer.browser.Ref(), indirectDelegatedValidationOffset(buffer.Size(), offset, drawIndexedIndirectRecordSize, drawCount)) + return + } + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := indexedIndirectRecordOffset(offset, i) + p.browser.DrawIndexedIndirect(buffer.browser.Ref(), recordOffset) + } } // End ends the render pass. diff --git a/renderpass_indirect_browser_test.go b/renderpass_indirect_browser_test.go new file mode 100644 index 0000000..f144cd2 --- /dev/null +++ b/renderpass_indirect_browser_test.go @@ -0,0 +1,54 @@ +//go:build js && wasm + +package wgpu + +import ( + "slices" + "syscall/js" + "testing" + + "github.com/gogpu/wgpu/internal/browser" +) + +func TestBrowserDrawIndexedIndirectInvalidSpanDelegatesOneFailingCall(t *testing.T) { + tests := []struct { + name string + bufferSize uint64 + offset uint64 + drawCount uint32 + want []float64 + }{ + {name: "later record fails", bufferSize: 59, offset: 0, drawCount: 3, want: []float64{40}}, + {name: "count one preserves offset", bufferSize: 20, offset: 24, drawCount: 1, want: []float64{24}}, + {name: "arithmetic overflow", bufferSize: 64, offset: ^uint64(0) - 3, drawCount: 2, want: []float64{64}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var offsets []float64 + draw := js.FuncOf(func(_ js.Value, args []js.Value) any { + offsets = append(offsets, args[1].Float()) + return nil + }) + defer draw.Release() + + rawPass := js.Global().Get("Object").New() + rawPass.Set("drawIndexedIndirect", draw) + pass := &RenderPassEncoder{browser: browser.NewRenderPassEncoder(rawPass)} + + rawBuffer := js.Global().Get("Object").New() + rawBuffer.Set("size", float64(test.bufferSize)) + rawBuffer.Set("usage", 0) + buffer := &Buffer{ + browser: browser.NewBuffer(rawBuffer), + size: test.bufferSize, + } + + pass.MultiDrawIndexedIndirect(buffer, test.offset, test.drawCount) + + if !slices.Equal(offsets, test.want) { + t.Fatalf("underlying offsets = %v, want %v", offsets, test.want) + } + }) + } +} diff --git a/renderpass_indirect_rust_test.go b/renderpass_indirect_rust_test.go new file mode 100644 index 0000000..53a3686 --- /dev/null +++ b/renderpass_indirect_rust_test.go @@ -0,0 +1,46 @@ +//go:build rust + +package wgpu + +import ( + "slices" + "testing" +) + +func TestLowerRustIndexedIndirectInvalidSpanDelegatesOneFailingCall(t *testing.T) { + tests := []struct { + name string + bufferSize uint64 + offset uint64 + drawCount uint32 + want []uint64 + }{ + {name: "later record fails", bufferSize: 59, offset: 0, drawCount: 3, want: []uint64{40}}, + {name: "count one preserves offset", bufferSize: 20, offset: 24, drawCount: 1, want: []uint64{24}}, + {name: "arithmetic overflow", bufferSize: 64, offset: ^uint64(0) - 3, drawCount: 2, want: []uint64{64}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var offsets []uint64 + lowerRustIndexedIndirect(test.bufferSize, test.offset, test.drawCount, func(offset uint64) { + offsets = append(offsets, offset) + }) + + if !slices.Equal(offsets, test.want) { + t.Fatalf("underlying offsets = %v, want %v", offsets, test.want) + } + }) + } +} + +func TestLowerRustIndexedIndirectValidSpanEmitsEveryRecordInOrder(t *testing.T) { + var offsets []uint64 + lowerRustIndexedIndirect(64, 4, 3, func(offset uint64) { + offsets = append(offsets, offset) + }) + + if want := []uint64{4, 24, 44}; !slices.Equal(offsets, want) { + t.Fatalf("underlying offsets = %v, want %v", offsets, want) + } +} diff --git a/renderpass_indirect_test.go b/renderpass_indirect_test.go new file mode 100644 index 0000000..a87b542 --- /dev/null +++ b/renderpass_indirect_test.go @@ -0,0 +1,77 @@ +//go:build !rust && !(js && wasm) + +package wgpu + +import "testing" + +func TestIndexedIndirectRangeFits(t *testing.T) { + tests := []struct { + name string + bufferSize uint64 + offset uint64 + drawCount uint32 + want bool + }{ + {name: "one record", bufferSize: 20, offset: 0, drawCount: 1, want: true}, + {name: "three records", bufferSize: 60, offset: 0, drawCount: 3, want: true}, + {name: "tail records", bufferSize: 80, offset: 20, drawCount: 3, want: true}, + {name: "one byte short", bufferSize: 59, offset: 0, drawCount: 3, want: false}, + {name: "offset past end", bufferSize: 20, offset: 21, drawCount: 1, want: false}, + {name: "offset overflow", bufferSize: ^uint64(0), offset: ^uint64(0) - 3, drawCount: 1, want: false}, + {name: "count overflow", bufferSize: ^uint64(0), offset: 0, drawCount: ^uint32(0), want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := indexedIndirectRangeFits(test.bufferSize, test.offset, test.drawCount); got != test.want { + t.Fatalf("indexedIndirectRangeFits(%d, %d, %d) = %t, want %t", test.bufferSize, test.offset, test.drawCount, got, test.want) + } + }) + } +} + +func TestIndexedIndirectHelpersAllocateZero(t *testing.T) { + var ( + fits bool + offset uint64 + ok bool + ) + if allocs := testing.AllocsPerRun(1000, func() { + fits = indexedIndirectRangeFits(64, 4, 3) + }); allocs != 0 { + t.Fatalf("indexedIndirectRangeFits allocations = %v, want 0", allocs) + } + if !fits { + t.Fatal("indexedIndirectRangeFits rejected a valid range") + } + if allocs := testing.AllocsPerRun(1000, func() { + offset, ok = indexedIndirectRecordOffset(4, 2) + }); allocs != 0 { + t.Fatalf("indexedIndirectRecordOffset allocations = %v, want 0", allocs) + } + if offset != 44 || !ok { + t.Fatalf("indexedIndirectRecordOffset result = %d, %t; want 44, true", offset, ok) + } +} + +func BenchmarkIndexedIndirectRangeFitsCount1(b *testing.B) { + benchmarkIndexedIndirectRangeFits(b, 1) +} + +func BenchmarkIndexedIndirectRangeFitsCountN(b *testing.B) { + benchmarkIndexedIndirectRangeFits(b, 1024) +} + +func benchmarkIndexedIndirectRangeFits(b *testing.B, drawCount uint32) { + b.ReportAllocs() + var fits bool + bufferSize := uint64(drawCount)*indexedIndirectRecordSize + 4 + b.ResetTimer() + for i := 0; i < b.N; i++ { + fits = indexedIndirectRangeFits(bufferSize, 4, drawCount) + } + b.StopTimer() + if !fits { + b.Fatal("indexedIndirectRangeFits rejected a valid range") + } +} diff --git a/renderpass_indirect_wrappers_test.go b/renderpass_indirect_wrappers_test.go new file mode 100644 index 0000000..eef1ef5 --- /dev/null +++ b/renderpass_indirect_wrappers_test.go @@ -0,0 +1,45 @@ +//go:build rust || (js && wasm) + +package wgpu + +import "testing" + +func TestIndexedIndirectDelegatedValidationOffset(t *testing.T) { + tests := []struct { + name string + bufferSize uint64 + offset uint64 + drawCount uint32 + want uint64 + }{ + { + name: "count one preserves caller offset", + bufferSize: 20, + offset: 24, + drawCount: 1, + want: 24, + }, + { + name: "later record delegates only failing offset", + bufferSize: 59, + offset: 0, + drawCount: 3, + want: 40, + }, + { + name: "arithmetic overflow uses end of buffer", + bufferSize: 64, + offset: ^uint64(0) - 3, + drawCount: 2, + want: 64, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := indirectDelegatedValidationOffset(test.bufferSize, test.offset, drawIndexedIndirectRecordSize, test.drawCount); got != test.want { + t.Fatalf("indexedIndirectDelegatedValidationOffset(%d, %d, %d) = %d, want %d", test.bufferSize, test.offset, test.drawCount, got, test.want) + } + }) + } +} diff --git a/renderpass_native.go b/renderpass_native.go index f3e50a4..48cf720 100644 --- a/renderpass_native.go +++ b/renderpass_native.go @@ -246,6 +246,15 @@ func (p *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui // DrawIndirect draws primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndirect(buffer, offset, 1) +} + +// MultiDrawIndirect draws consecutive primitives with GPU-generated parameters. +// Each argument record is 16 bytes. +func (p *RenderPassEncoder) MultiDrawIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if !p.validateDrawState("DrawIndirect") { return } @@ -271,19 +280,28 @@ func (p *RenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { } // VAL-B3: Validate indirect args fit within buffer. // DrawIndirect args: 4 × uint32 = 16 bytes. Matches Rust render.rs:2772-2779. - if offset+16 > buffer.Size() { + if !drawIndirectRangeFits(buffer.Size(), offset, drawCount) { p.encoder.setError(fmt.Errorf( - "wgpu: RenderPass.DrawIndirect: offset %d + 16 bytes exceeds buffer size %d: %w", - offset, buffer.Size(), ErrDrawIndirectBufferOverrun)) + "wgpu: RenderPass.MultiDrawIndirect: offset %d + %d draw(s) exceeds buffer size %d: %w", + offset, drawCount, buffer.Size(), ErrDrawIndirectBufferOverrun)) return } p.trackRef(buffer.core.Ref) p.encoder.trackBuffer(buffer) - p.core.DrawIndirect(buffer.coreBuffer(), offset) + p.core.MultiDrawIndirect(buffer.coreBuffer(), offset, drawCount) } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndexedIndirect(buffer, offset, 1) +} + +// MultiDrawIndexedIndirect draws consecutive indexed primitives with +// GPU-generated parameters. Each argument record is 20 bytes. +func (p *RenderPassEncoder) MultiDrawIndexedIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if !p.validateDrawState("DrawIndexedIndirect") { return } @@ -320,17 +338,17 @@ func (p *RenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint64) { offset, ErrDrawIndirectOffsetAlignment)) return } - // VAL-B3: Validate indirect args fit within buffer. - // DrawIndexedIndirect args: 5 × uint32 = 20 bytes. Matches Rust render.rs:2772-2779. - if offset+20 > buffer.Size() { + // VAL-B3: Validate all indirect args fit within the buffer without allowing + // offset+drawCount*20 to wrap around uint64. + if !indexedIndirectRangeFits(buffer.Size(), offset, drawCount) { p.encoder.setError(fmt.Errorf( - "wgpu: RenderPass.DrawIndexedIndirect: offset %d + 20 bytes exceeds buffer size %d: %w", - offset, buffer.Size(), ErrDrawIndirectBufferOverrun)) + "wgpu: RenderPass.MultiDrawIndexedIndirect: offset %d + %d draw(s) exceeds buffer size %d: %w", + offset, drawCount, buffer.Size(), ErrDrawIndirectBufferOverrun)) return } p.trackRef(buffer.core.Ref) p.encoder.trackBuffer(buffer) - p.core.DrawIndexedIndirect(buffer.coreBuffer(), offset) + p.core.MultiDrawIndexedIndirect(buffer.coreBuffer(), offset, drawCount) } // End ends the render pass. diff --git a/renderpass_rust.go b/renderpass_rust.go index f973847..80f0f3e 100644 --- a/renderpass_rust.go +++ b/renderpass_rust.go @@ -90,18 +90,61 @@ func (p *RenderPassEncoder) DrawIndexed(indexCount, instanceCount, firstIndex ui // DrawIndirect draws primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndirect(buffer, offset, 1) +} + +// MultiDrawIndirect draws consecutive primitives with GPU-generated parameters. +func (p *RenderPassEncoder) MultiDrawIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if buffer == nil || buffer.r == nil { return } - p.r.DrawIndirect(buffer.r, offset) + if !drawIndirectRangeFits(buffer.Size(), offset, drawCount) { + p.r.DrawIndirect(buffer.r, indirectDelegatedValidationOffset(buffer.Size(), offset, drawIndirectRecordSize, drawCount)) + return + } + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := drawIndirectRecordOffset(offset, i) + p.r.DrawIndirect(buffer.r, recordOffset) + } } // DrawIndexedIndirect draws indexed primitives with GPU-generated parameters. func (p *RenderPassEncoder) DrawIndexedIndirect(buffer *Buffer, offset uint64) { + p.MultiDrawIndexedIndirect(buffer, offset, 1) +} + +// MultiDrawIndexedIndirect draws consecutive indexed primitives with +// GPU-generated parameters. +func (p *RenderPassEncoder) MultiDrawIndexedIndirect(buffer *Buffer, offset uint64, drawCount uint32) { + if drawCount == 0 { + return + } if buffer == nil || buffer.r == nil { return } - p.r.DrawIndexedIndirect(buffer.r, offset) + lowerRustIndexedIndirect(buffer.Size(), offset, drawCount, func(recordOffset uint64) { + p.r.DrawIndexedIndirect(buffer.r, recordOffset) + }) +} + +// lowerRustIndexedIndirect lowers one counted span through the Rust adapter's +// single-record interface. Invalid positive spans delegate exactly one failing +// record before any valid record can be emitted. +func lowerRustIndexedIndirect(bufferSize, offset uint64, drawCount uint32, draw func(uint64)) { + if drawCount == 0 { + return + } + if !indexedIndirectRangeFits(bufferSize, offset, drawCount) { + draw(indirectDelegatedValidationOffset(bufferSize, offset, drawIndexedIndirectRecordSize, drawCount)) + return + } + for i := uint32(0); i < drawCount; i++ { + recordOffset, _ := indexedIndirectRecordOffset(offset, i) + draw(recordOffset) + } } // End ends the render pass. diff --git a/wgpu_test.go b/wgpu_test.go index 8ca789f..552236c 100644 --- a/wgpu_test.go +++ b/wgpu_test.go @@ -1663,6 +1663,147 @@ func TestRenderPassDrawIndexedIndirectNilDeferredError(t *testing.T) { } } +func TestRenderPassDrawIndexedIndirectZeroCountIsNoOp(t *testing.T) { + device, encoder, pass := newEncoderWithRenderPass(t) + defer device.Release() + + // Zero count is intentionally checked before state and buffer validation. + pass.MultiDrawIndexedIndirect(nil, ^uint64(0)-3, 0) + if err := pass.End(); err != nil { + t.Fatalf("End: %v", err) + } + if _, err := encoder.Finish(); err != nil { + t.Fatalf("zero-count DrawIndexedIndirect recorded an error: %v", err) + } +} + +func TestRenderPassMultiDrawIndirectZeroCountIsNoOp(t *testing.T) { + device, encoder, pass := newEncoderWithRenderPass(t) + defer device.Release() + + pass.MultiDrawIndirect(nil, ^uint64(0)-3, 0) + if err := pass.End(); err != nil { + t.Fatalf("End: %v", err) + } + if _, err := encoder.Finish(); err != nil { + t.Fatalf("zero-count MultiDrawIndirect recorded an error: %v", err) + } +} + +func TestRenderPassMultiDrawIndirectRangeValidation(t *testing.T) { + device, encoder, pass := newEncoderWithRenderPass(t) + defer device.Release() + + pipeline := &wgpu.RenderPipeline{} + pipeline.SetTestRequiredVertexBuffers(0) + pass.SetPipeline(pipeline) + buf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "count-indirect-buf", + Size: 31, + Usage: wgpu.BufferUsageIndirect, + }) + if err != nil { + t.Fatalf("CreateBuffer: %v", err) + } + defer buf.Release() + + pass.MultiDrawIndirect(buf, 0, 2) + _ = pass.End() + if _, err := encoder.Finish(); !errors.Is(err, wgpu.ErrDrawIndirectBufferOverrun) { + t.Fatalf("Finish error = %v, want ErrDrawIndirectBufferOverrun", err) + } +} + +func TestRenderPassDrawIndexedIndirectCountRangeValidation(t *testing.T) { + tests := []struct { + name string + bufferSize uint64 + offset uint64 + drawCount uint32 + }{ + {name: "count exceeds buffer", bufferSize: 59, offset: 0, drawCount: 3}, + {name: "offset arithmetic overflow", bufferSize: 64, offset: ^uint64(0) - 3, drawCount: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + device, encoder, pass := newEncoderWithRenderPass(t) + defer device.Release() + + pipeline := &wgpu.RenderPipeline{} + pipeline.SetTestRequiredVertexBuffers(0) + pass.SetPipeline(pipeline) + + idxBuf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "count-index-buffer", + Size: 64, + Usage: wgpu.BufferUsageIndex, + }) + if err != nil { + t.Fatalf("CreateBuffer(index): %v", err) + } + defer idxBuf.Release() + pass.SetIndexBuffer(idxBuf, gputypes.IndexFormatUint16, 0) + + indirectBuf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "count-indirect-buffer", + Size: test.bufferSize, + Usage: wgpu.BufferUsageIndirect, + }) + if err != nil { + t.Fatalf("CreateBuffer(indirect): %v", err) + } + defer indirectBuf.Release() + + pass.MultiDrawIndexedIndirect(indirectBuf, test.offset, test.drawCount) + _ = pass.End() + _, finishErr := encoder.Finish() + if finishErr == nil { + t.Fatal("Finish() should report an indirect buffer overrun") + } + if !errors.Is(finishErr, wgpu.ErrDrawIndirectBufferOverrun) { + t.Fatalf("error = %v, want ErrDrawIndirectBufferOverrun", finishErr) + } + }) + } +} + +func TestRenderPassDrawIndexedIndirectCountMany(t *testing.T) { + device, encoder, pass := newEncoderWithRenderPass(t) + defer device.Release() + + pipeline := &wgpu.RenderPipeline{} + pipeline.SetTestRequiredVertexBuffers(0) + pass.SetPipeline(pipeline) + + idxBuf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "count-index-buffer", + Size: 64, + Usage: wgpu.BufferUsageIndex, + }) + if err != nil { + t.Fatalf("CreateBuffer(index): %v", err) + } + defer idxBuf.Release() + pass.SetIndexBuffer(idxBuf, gputypes.IndexFormatUint16, 0) + + indirectBuf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "count-indirect-buffer", + Size: 60, + Usage: wgpu.BufferUsageIndirect, + }) + if err != nil { + t.Fatalf("CreateBuffer(indirect): %v", err) + } + defer indirectBuf.Release() + + pass.MultiDrawIndexedIndirect(indirectBuf, 0, 3) + _ = pass.End() + if _, err := encoder.Finish(); err != nil { + t.Fatalf("counted DrawIndexedIndirect failed: %v", err) + } +} + func TestComputePassSetPipelineNilDeferredError(t *testing.T) { device, encoder, pass := newEncoderWithComputePass(t) defer device.Release()