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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions core/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -799,30 +799,40 @@ 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 {
guard := p.device.snatchLock.Read()
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 {
guard := p.device.snatchLock.Read()
defer guard.Release()
halBuffer := buffer.Raw(guard)
if halBuffer != nil {
p.raw.DrawIndexedIndirect(halBuffer, offset)
p.raw.DrawIndexedIndirect(halBuffer, offset, drawCount)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions core/device_hal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions core/indirect_count_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions hal/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions hal/dx12/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
39 changes: 33 additions & 6 deletions hal/dx12/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion hal/dx12/d3d12/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions hal/dx12/d3d12/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 8 additions & 8 deletions hal/gles/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion hal/gles/gl/context_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ func initCommonCallInterfaces() error {
if err != nil {
return err
}

// int32 fn(uint32, void*)
err = ffi.PrepareCallInterface(&cifInt322, types.DefaultCall,
types.SInt32TypeDescriptor,
Expand Down
26 changes: 26 additions & 0 deletions hal/gles/indirect_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading