From a4548d7708e2647b03496bb594e53988bdfd012a Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 15:18:10 +0300 Subject: [PATCH 1/5] fix(dx12): support aspect-aware texture view SRVs --- hal/dx12/convert.go | 23 ++++++++++++++++++ hal/dx12/convert_test.go | 25 ++++++++++++++++++++ hal/dx12/d3d12/view_helpers.go | 5 ++-- hal/dx12/d3d12/view_helpers_test.go | 20 ++++++++++++++++ hal/dx12/device.go | 30 ++++++++++++++++-------- hal/dx12/resource.go | 1 + hal/dx12/resource_test.go | 36 +++++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 hal/dx12/d3d12/view_helpers_test.go diff --git a/hal/dx12/convert.go b/hal/dx12/convert.go index bac205f2..1dad0aad 100644 --- a/hal/dx12/convert.go +++ b/hal/dx12/convert.go @@ -237,6 +237,8 @@ func depthFormatToTypeless(format gputypes.TextureFormat) d3d12.DXGI_FORMAT { return d3d12.DXGI_FORMAT_R32_TYPELESS case gputypes.TextureFormatDepth32FloatStencil8: return d3d12.DXGI_FORMAT_R32G8X24_TYPELESS + case gputypes.TextureFormatStencil8: + return d3d12.DXGI_FORMAT_R24G8_TYPELESS default: return d3d12.DXGI_FORMAT_UNKNOWN } @@ -258,6 +260,27 @@ func depthFormatToSRV(format gputypes.TextureFormat) d3d12.DXGI_FORMAT { } } +// textureFormatToSRV selects the typed view format and physical D3D12 plane +// for a WebGPU texture aspect. Packed depth/stencil formats expose their +// stencil plane through an X*TYPELESS_G8*UINT SRV. Standalone Stencil8 uses a +// D24S8 backing resource on DX12 and therefore follows the same representation. +func textureFormatToSRV(format gputypes.TextureFormat, aspect gputypes.TextureAspect) (d3d12.DXGI_FORMAT, uint32) { + if aspect == gputypes.TextureAspectStencilOnly || format == gputypes.TextureFormatStencil8 { + switch format { + case gputypes.TextureFormatDepth24PlusStencil8, gputypes.TextureFormatStencil8: + return d3d12.DXGI_FORMAT_X24_TYPELESS_G8_UINT, 1 + case gputypes.TextureFormatDepth32FloatStencil8: + return d3d12.DXGI_FORMAT_X32_TYPELESS_G8X24_UINT, 1 + default: + return d3d12.DXGI_FORMAT_UNKNOWN, 1 + } + } + if isDepthFormat(format) { + return depthFormatToSRV(format), 0 + } + return textureFormatToD3D12(format), 0 +} + // addressModeToD3D12 converts a WebGPU address mode to D3D12. func addressModeToD3D12(mode gputypes.AddressMode) d3d12.D3D12_TEXTURE_ADDRESS_MODE { switch mode { diff --git a/hal/dx12/convert_test.go b/hal/dx12/convert_test.go index d1148166..eb918098 100644 --- a/hal/dx12/convert_test.go +++ b/hal/dx12/convert_test.go @@ -236,6 +236,7 @@ func TestDepthFormatToTypeless(t *testing.T) { {"Depth24PlusStencil8", gputypes.TextureFormatDepth24PlusStencil8, d3d12.DXGI_FORMAT_R24G8_TYPELESS}, {"Depth32Float", gputypes.TextureFormatDepth32Float, d3d12.DXGI_FORMAT_R32_TYPELESS}, {"Depth32FloatStencil8", gputypes.TextureFormatDepth32FloatStencil8, d3d12.DXGI_FORMAT_R32G8X24_TYPELESS}, + {"Stencil8", gputypes.TextureFormatStencil8, d3d12.DXGI_FORMAT_R24G8_TYPELESS}, {"Non-depth returns UNKNOWN", gputypes.TextureFormatRGBA8Unorm, d3d12.DXGI_FORMAT_UNKNOWN}, } @@ -249,6 +250,30 @@ func TestDepthFormatToTypeless(t *testing.T) { } } +func TestTextureFormatToSRVSelectsAspectPlane(t *testing.T) { + tests := []struct { + name string + format gputypes.TextureFormat + aspect gputypes.TextureAspect + wantFormat d3d12.DXGI_FORMAT + wantPlane uint32 + }{ + {"D24S8 depth", gputypes.TextureFormatDepth24PlusStencil8, gputypes.TextureAspectDepthOnly, d3d12.DXGI_FORMAT_R24_UNORM_X8_TYPELESS, 0}, + {"D24S8 stencil", gputypes.TextureFormatDepth24PlusStencil8, gputypes.TextureAspectStencilOnly, d3d12.DXGI_FORMAT_X24_TYPELESS_G8_UINT, 1}, + {"D32S8 stencil", gputypes.TextureFormatDepth32FloatStencil8, gputypes.TextureAspectStencilOnly, d3d12.DXGI_FORMAT_X32_TYPELESS_G8X24_UINT, 1}, + {"standalone stencil defaults to stencil plane", gputypes.TextureFormatStencil8, gputypes.TextureAspectAll, d3d12.DXGI_FORMAT_X24_TYPELESS_G8_UINT, 1}, + {"color", gputypes.TextureFormatRGBA8Unorm, gputypes.TextureAspectAll, d3d12.DXGI_FORMAT_R8G8B8A8_UNORM, 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + format, plane := textureFormatToSRV(test.format, test.aspect) + if format != test.wantFormat || plane != test.wantPlane { + t.Fatalf("SRV selection = (%d, %d), want (%d, %d)", format, plane, test.wantFormat, test.wantPlane) + } + }) + } +} + func TestDepthFormatToSRV(t *testing.T) { tests := []struct { name string diff --git a/hal/dx12/d3d12/view_helpers.go b/hal/dx12/d3d12/view_helpers.go index 43aa20e2..bbb3b3bd 100644 --- a/hal/dx12/d3d12/view_helpers.go +++ b/hal/dx12/d3d12/view_helpers.go @@ -43,9 +43,8 @@ func (d *D3D12_SHADER_RESOURCE_VIEW_DESC) SetTexture2DArray(mostDetailedMip, mip binary.LittleEndian.PutUint32(d.Union[4:8], mipLevels) binary.LittleEndian.PutUint32(d.Union[8:12], firstArraySlice) binary.LittleEndian.PutUint32(d.Union[12:16], arraySize) - // Note: planeSlice and resourceMinLODClamp are in extended union space (handled by D3D12) - _ = planeSlice - _ = resourceMinLODClamp + binary.LittleEndian.PutUint32(d.Union[16:20], planeSlice) + binary.LittleEndian.PutUint32(d.Union[20:24], floatBits(resourceMinLODClamp)) } // SetTexture3D sets up a 3D texture SRV. diff --git a/hal/dx12/d3d12/view_helpers_test.go b/hal/dx12/d3d12/view_helpers_test.go new file mode 100644 index 00000000..f67f8fb5 --- /dev/null +++ b/hal/dx12/d3d12/view_helpers_test.go @@ -0,0 +1,20 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package d3d12 + +import ( + "encoding/binary" + "testing" +) + +func TestSetTexture2DArrayEncodesPlaneSlice(t *testing.T) { + var desc D3D12_SHADER_RESOURCE_VIEW_DESC + desc.SetTexture2DArray(2, 3, 4, 5, 1, 0) + + if got := binary.LittleEndian.Uint32(desc.Union[16:20]); got != 1 { + t.Fatalf("plane slice = %d, want 1", got) + } +} diff --git a/hal/dx12/device.go b/hal/dx12/device.go index 0db60333..e117cacf 100644 --- a/hal/dx12/device.go +++ b/hal/dx12/device.go @@ -1385,6 +1385,7 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes }, format: st.format, dimension: gputypes.TextureViewDimension2D, + aspect: gputypes.TextureAspectAll, baseMip: 0, mipCount: 1, baseLayer: 0, @@ -1450,12 +1451,16 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes texture: tex, format: viewFormat, dimension: viewDim, + aspect: gputypes.TextureAspectAll, baseMip: baseMip, mipCount: mipCount, baseLayer: baseLayer, layerCount: layerCount, device: d, } + if desc != nil && desc.Aspect != gputypes.TextureAspectUndefined { + view.aspect = desc.Aspect + } dxgiFormat := textureFormatToD3D12(viewFormat) @@ -1466,7 +1471,7 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes // Allocate RTV descriptor rtvHandle, rtvIndex, err := d.allocateRTVDescriptor() if err != nil { - return nil, fmt.Errorf("dx12: failed to allocate RTV descriptor: %w", err) + return failTextureViewCreation(view, fmt.Errorf("dx12: failed to allocate RTV descriptor: %w", err)) } var rtvDesc d3d12.D3D12_RENDER_TARGET_VIEW_DESC @@ -1507,7 +1512,7 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes // Allocate DSV descriptor dsvHandle, dsvIndex, err := d.allocateDSVDescriptor() if err != nil { - return nil, fmt.Errorf("dx12: failed to allocate DSV descriptor: %w", err) + return failTextureViewCreation(view, fmt.Errorf("dx12: failed to allocate DSV descriptor: %w", err)) } // For depth views, use the actual depth format, not typeless @@ -1549,13 +1554,13 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes // Allocate SRV descriptor srvHandle, srvIndex, err := d.allocateSRVDescriptor() if err != nil { - return nil, fmt.Errorf("dx12: failed to allocate SRV descriptor: %w", err) + return failTextureViewCreation(view, fmt.Errorf("dx12: failed to allocate SRV descriptor: %w", err)) } - // For depth textures, use SRV-compatible format - srvFormat := dxgiFormat - if isDepthFormat(viewFormat) { - srvFormat = depthFormatToSRV(viewFormat) + srvFormat, srvPlane := textureFormatToSRV(viewFormat, view.aspect) + if srvFormat == d3d12.DXGI_FORMAT_UNKNOWN { + d.stagingViewHeap.Free(srvIndex, 1) + return failTextureViewCreation(view, fmt.Errorf("dx12: texture format %d aspect %d cannot create an SRV", viewFormat, view.aspect)) } // Create SRV desc @@ -1570,9 +1575,9 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes case gputypes.TextureViewDimension1D: srvDesc.SetTexture1D(baseMip, mipCount, 0) case gputypes.TextureViewDimension2D: - srvDesc.SetTexture2D(baseMip, mipCount, 0, 0) + srvDesc.SetTexture2D(baseMip, mipCount, srvPlane, 0) case gputypes.TextureViewDimension2DArray: - srvDesc.SetTexture2DArray(baseMip, mipCount, baseLayer, layerCount, 0, 0) + srvDesc.SetTexture2DArray(baseMip, mipCount, baseLayer, layerCount, srvPlane, 0) case gputypes.TextureViewDimensionCube: srvDesc.SetTextureCube(baseMip, mipCount, 0) case gputypes.TextureViewDimensionCubeArray: @@ -1590,6 +1595,13 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes return view, nil } +func failTextureViewCreation(view *TextureView, err error) (hal.TextureView, error) { + if view != nil { + view.Destroy() + } + return nil, err +} + // DestroyTextureView destroys a texture view. func (d *Device) DestroyTextureView(view hal.TextureView) { if v, ok := view.(*TextureView); ok && v != nil { diff --git a/hal/dx12/resource.go b/hal/dx12/resource.go index 3123bd1b..4b6c2038 100644 --- a/hal/dx12/resource.go +++ b/hal/dx12/resource.go @@ -248,6 +248,7 @@ type TextureView struct { texture *Texture format gputypes.TextureFormat dimension gputypes.TextureViewDimension + aspect gputypes.TextureAspect baseMip uint32 mipCount uint32 baseLayer uint32 diff --git a/hal/dx12/resource_test.go b/hal/dx12/resource_test.go index 858bc507..3834a12f 100644 --- a/hal/dx12/resource_test.go +++ b/hal/dx12/resource_test.go @@ -6,6 +6,7 @@ package dx12 import ( + "errors" "testing" ) @@ -111,3 +112,38 @@ func TestTexture_DecPendingRef_NoDeferWithoutDestroy(t *testing.T) { t.Error("pendingDeath should remain false") } } + +func TestFailTextureViewCreationRecyclesAllocatedDescriptors(t *testing.T) { + device := &Device{ + rtvHeap: &DescriptorHeap{}, + dsvHeap: &DescriptorHeap{}, + stagingViewHeap: &DescriptorHeap{}, + } + view := &TextureView{ + texture: &Texture{}, + device: device, + hasRTV: true, + rtvHeapIndex: 2, + hasDSV: true, + dsvHeapIndex: 3, + hasSRV: true, + srvHeapIndex: 4, + } + + result, err := failTextureViewCreation(view, errors.New("view creation failed")) + if result != nil { + t.Fatal("failed texture view should not be returned") + } + if err == nil || err.Error() != "view creation failed" { + t.Fatalf("error = %v, want original view creation error", err) + } + if len(device.rtvHeap.freeList) != 1 || device.rtvHeap.freeList[0] != 2 { + t.Fatalf("RTV free list = %v, want [2]", device.rtvHeap.freeList) + } + if len(device.dsvHeap.freeList) != 1 || device.dsvHeap.freeList[0] != 3 { + t.Fatalf("DSV free list = %v, want [3]", device.dsvHeap.freeList) + } + if len(device.stagingViewHeap.freeList) != 1 || device.stagingViewHeap.freeList[0] != 4 { + t.Fatalf("SRV free list = %v, want [4]", device.stagingViewHeap.freeList) + } +} From 48546184043a5b7799fda607864bc66178ab234d Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 15:26:49 +0300 Subject: [PATCH 2/5] fix(dx12): plan aligned texture copies --- hal/dx12/command.go | 117 +--------- hal/dx12/copy_commands.go | 89 ++++++++ hal/dx12/copy_plan.go | 447 +++++++++++++++++++++++++++++++++++++ hal/dx12/copy_plan_test.go | 156 +++++++++++++ hal/dx12/queue.go | 99 +++----- 5 files changed, 723 insertions(+), 185 deletions(-) create mode 100644 hal/dx12/copy_commands.go create mode 100644 hal/dx12/copy_plan.go create mode 100644 hal/dx12/copy_plan_test.go diff --git a/hal/dx12/command.go b/hal/dx12/command.go index 543affec..bc629bf9 100644 --- a/hal/dx12/command.go +++ b/hal/dx12/command.go @@ -286,42 +286,7 @@ func (e *CommandEncoder) CopyBufferToTexture(src hal.Buffer, dst hal.Texture, re return } - // Transition source buffer to COPY_SOURCE if needed. - e.transitionBufferIfNeeded(srcBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE) - - for _, r := range regions { - // Source location (buffer) - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: r.BufferLayout.Offset, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(dstTex.format), - Width: r.Size.Width, - Height: r.Size.Height, - Depth: r.Size.DepthOrArrayLayers, - RowPitch: r.BufferLayout.BytesPerRow, - }, - }) - - // Destination location (texture) - subresource := r.TextureBase.MipLevel + r.TextureBase.Origin.Z*dstTex.mipLevels - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(subresource) - - // Copy region (nil means entire subresource) - e.cmdList.CopyTextureRegion( - &dstLoc, - r.TextureBase.Origin.X, r.TextureBase.Origin.Y, r.TextureBase.Origin.Z, - &srcLoc, - nil, // Copy entire source - ) - } + e.copyBufferToTexture(srcBuf, dstTex, regions) } // CopyTextureToBuffer copies data from a texture to a buffer. @@ -337,50 +302,7 @@ func (e *CommandEncoder) CopyTextureToBuffer(src hal.Texture, dst hal.Buffer, re return } - // Transition destination buffer to COPY_DEST if needed. - e.transitionBufferIfNeeded(dstBuf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST) - - for _, r := range regions { - // Source location (texture) - subresource := r.TextureBase.MipLevel + r.TextureBase.Origin.Z*srcTex.mipLevels - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - srcLoc.SetSubresourceIndex(subresource) - - // D3D12 requires RowPitch aligned to 256 bytes. - // The caller should pass aligned BytesPerRow, but align defensively. - rowPitch := (r.BufferLayout.BytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) - - // Destination location (buffer) - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - dstLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: r.BufferLayout.Offset, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(srcTex.format), - Width: r.Size.Width, - Height: r.Size.Height, - Depth: r.Size.DepthOrArrayLayers, - RowPitch: rowPitch, - }, - }) - - // Source box - srcBox := d3d12.D3D12_BOX{ - Left: r.TextureBase.Origin.X, - Top: r.TextureBase.Origin.Y, - Front: 0, - Right: r.TextureBase.Origin.X + r.Size.Width, - Bottom: r.TextureBase.Origin.Y + r.Size.Height, - Back: r.Size.DepthOrArrayLayers, - } - - e.cmdList.CopyTextureRegion(&dstLoc, 0, 0, 0, &srcLoc, &srcBox) - } + e.copyTextureToBuffer(srcTex, dstBuf, regions) } // CopyTextureToTexture copies data between textures. @@ -395,40 +317,7 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst hal.Texture, regions []ha return } - for _, r := range regions { - // Source location - srcSubresource := r.SrcBase.MipLevel + r.SrcBase.Origin.Z*srcTex.mipLevels - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - srcLoc.SetSubresourceIndex(srcSubresource) - - // Destination location - dstSubresource := r.DstBase.MipLevel + r.DstBase.Origin.Z*dstTex.mipLevels - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(dstSubresource) - - // Source box - srcBox := d3d12.D3D12_BOX{ - Left: r.SrcBase.Origin.X, - Top: r.SrcBase.Origin.Y, - Front: 0, - Right: r.SrcBase.Origin.X + r.Size.Width, - Bottom: r.SrcBase.Origin.Y + r.Size.Height, - Back: r.Size.DepthOrArrayLayers, - } - - e.cmdList.CopyTextureRegion( - &dstLoc, - r.DstBase.Origin.X, r.DstBase.Origin.Y, r.DstBase.Origin.Z, - &srcLoc, - &srcBox, - ) - } + e.copyTextureToTexture(srcTex, dstTex, regions) } // ResolveQuerySet copies query results from a query set into a destination buffer. diff --git a/hal/dx12/copy_commands.go b/hal/dx12/copy_commands.go new file mode 100644 index 00000000..43911218 --- /dev/null +++ b/hal/dx12/copy_commands.go @@ -0,0 +1,89 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/dx12/d3d12" +) + +func (e *CommandEncoder) copyBufferToTexture(src *Buffer, texture *Texture, regions []hal.BufferTextureCopy) { + e.transitionBufferIfNeeded(src, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE) + for _, region := range regions { + for _, plan := range planBufferTextureCopies(texture, region.TextureBase, region.BufferLayout, region.Size) { + srcLoc := placedFootprintLocation(src, texture.format, plan) + dstLoc := subresourceLocation(texture, plan.subresource) + box := d3d12.D3D12_BOX{ + Left: plan.bufferOriginX, + Top: plan.bufferOriginY, + Front: 0, + Right: plan.bufferOriginX + plan.copyWidth, + Bottom: plan.bufferOriginY + plan.copyHeight, + Back: plan.footprintDepth, + } + e.cmdList.CopyTextureRegion(&dstLoc, plan.textureOriginX, plan.textureOriginY, plan.textureOriginZ, &srcLoc, &box) + } + } +} + +func (e *CommandEncoder) copyTextureToBuffer(src *Texture, dst *Buffer, regions []hal.BufferTextureCopy) { + e.transitionBufferIfNeeded(dst, d3d12.D3D12_RESOURCE_STATE_COPY_DEST) + for _, region := range regions { + for _, plan := range planBufferTextureCopies(src, region.TextureBase, region.BufferLayout, region.Size) { + srcLoc := subresourceLocation(src, plan.subresource) + dstLoc := placedFootprintLocation(dst, src.format, plan) + box := d3d12.D3D12_BOX{ + Left: plan.textureOriginX, + Top: plan.textureOriginY, + Front: plan.textureOriginZ, + Right: plan.textureOriginX + plan.copyWidth, + Bottom: plan.textureOriginY + plan.copyHeight, + Back: plan.textureOriginZ + plan.footprintDepth, + } + e.cmdList.CopyTextureRegion(&dstLoc, plan.bufferOriginX, plan.bufferOriginY, 0, &srcLoc, &box) + } + } +} + +func (e *CommandEncoder) copyTextureToTexture(src, dst *Texture, regions []hal.TextureCopy) { + for _, region := range regions { + for _, plan := range planTextureTextureCopies(src, dst, region) { + srcLoc := subresourceLocation(src, plan.srcSubresource) + dstLoc := subresourceLocation(dst, plan.dstSubresource) + box := d3d12.D3D12_BOX{ + Left: region.SrcBase.Origin.X, + Top: region.SrcBase.Origin.Y, + Front: plan.srcFront, + Right: region.SrcBase.Origin.X + region.Size.Width, + Bottom: region.SrcBase.Origin.Y + region.Size.Height, + Back: plan.srcBack, + } + e.cmdList.CopyTextureRegion(&dstLoc, region.DstBase.Origin.X, region.DstBase.Origin.Y, plan.dstZ, &srcLoc, &box) + } + } +} + +func placedFootprintLocation(buffer *Buffer, format gputypes.TextureFormat, plan bufferTextureCopyPlan) d3d12.D3D12_TEXTURE_COPY_LOCATION { + location := d3d12.D3D12_TEXTURE_COPY_LOCATION{Resource: buffer.raw, Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT} + location.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ + Offset: plan.bufferOffset, + Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ + Format: textureFormatToD3D12(format), + Width: plan.footprintWidth, + Height: plan.footprintHeight, + Depth: plan.footprintDepth, + RowPitch: plan.rowPitch, + }, + }) + return location +} + +func subresourceLocation(texture *Texture, subresource uint32) d3d12.D3D12_TEXTURE_COPY_LOCATION { + location := d3d12.D3D12_TEXTURE_COPY_LOCATION{Resource: texture.raw, Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX} + location.SetSubresourceIndex(subresource) + return location +} diff --git a/hal/dx12/copy_plan.go b/hal/dx12/copy_plan.go new file mode 100644 index 00000000..b07410f7 --- /dev/null +++ b/hal/dx12/copy_plan.go @@ -0,0 +1,447 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "math" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +const d3d12TexturePlacementAlignment = 512 + +// bufferTextureCopyPlan describes one native D3D12 copy. WebGPU treats a +// 2D-array layer as a separate subresource, while D3D12 treats a 3D slice as +// a depth-one copy into one subresource. The buffer origin fields select the +// logical byte range inside an aligned, possibly enlarged footprint. +type bufferTextureCopyPlan struct { + subresource uint32 + bufferOffset uint64 + bufferOriginX uint32 + bufferOriginY uint32 + footprintWidth uint32 + footprintHeight uint32 + footprintDepth uint32 + textureOriginX uint32 + textureOriginY uint32 + textureOriginZ uint32 + rowPitch uint32 + copyWidth uint32 + copyHeight uint32 +} + +type textureTextureCopyPlan struct { + srcSubresource uint32 + dstSubresource uint32 + srcFront uint32 + srcBack uint32 + dstZ uint32 +} + +type textureBlockInfo struct { + width uint32 + height uint32 + bytes uint32 +} + +func textureBlockInfoForFormat(format gputypes.TextureFormat) (textureBlockInfo, bool) { + bytes := format.BlockCopySize() + if bytes == 0 { + return textureBlockInfo{}, false + } + if textureFormatIsBC(format) { + return textureBlockInfo{width: 4, height: 4, bytes: bytes}, true + } + return textureBlockInfo{width: 1, height: 1, bytes: bytes}, true +} + +func textureFormatIsBC(format gputypes.TextureFormat) bool { + switch format { + case gputypes.TextureFormatBC1RGBAUnorm, + gputypes.TextureFormatBC1RGBAUnormSrgb, + gputypes.TextureFormatBC2RGBAUnorm, + gputypes.TextureFormatBC2RGBAUnormSrgb, + gputypes.TextureFormatBC3RGBAUnorm, + gputypes.TextureFormatBC3RGBAUnormSrgb, + gputypes.TextureFormatBC4RUnorm, + gputypes.TextureFormatBC4RSnorm, + gputypes.TextureFormatBC5RGUnorm, + gputypes.TextureFormatBC5RGSnorm, + gputypes.TextureFormatBC6HRGBUfloat, + gputypes.TextureFormatBC6HRGBFloat, + gputypes.TextureFormatBC7RGBAUnorm, + gputypes.TextureFormatBC7RGBAUnormSrgb: + return true + default: + return false + } +} + +func textureFormatBlockHeight(format gputypes.TextureFormat) uint32 { + if info, ok := textureBlockInfoForFormat(format); ok { + return info.height + } + return 1 +} + +func alignUint32(value, alignment uint32) (uint32, bool) { + if alignment == 0 { + return value, true + } + if value > math.MaxUint32-(alignment-1) { + return 0, false + } + return (value + alignment - 1) / alignment * alignment, true +} + +func copyExtentBlocks(info textureBlockInfo, origin hal.Origin3D, extent hal.Extent3D) (width, height uint32, ok bool) { + if extent.Width == 0 || extent.Height == 0 { + return 0, 0, false + } + if origin.X > math.MaxUint32-extent.Width || origin.Y > math.MaxUint32-extent.Height { + return 0, 0, false + } + endX := origin.X + extent.Width + endY := origin.Y + extent.Height + if endX > math.MaxUint32-(info.width-1) || endY > math.MaxUint32-(info.height-1) { + return 0, 0, false + } + startBlockX := origin.X / info.width + startBlockY := origin.Y / info.height + endBlockX := (endX + info.width - 1) / info.width + endBlockY := (endY + info.height - 1) / info.height + if endBlockX < startBlockX || endBlockY < startBlockY { + return 0, 0, false + } + return endBlockX - startBlockX, endBlockY - startBlockY, true +} + +func textureAspectPlanes(texture *Texture, aspect gputypes.TextureAspect) []uint32 { + if texture == nil || texture.planeCount() < 2 { + return []uint32{0} + } + if texture.format == gputypes.TextureFormatStencil8 { + if aspect == gputypes.TextureAspectDepthOnly { + return nil + } + return []uint32{1} + } + if texture.format == gputypes.TextureFormatDepth24Plus { + if aspect == gputypes.TextureAspectStencilOnly { + return nil + } + return []uint32{0} + } + switch aspect { + case gputypes.TextureAspectDepthOnly: + return []uint32{0} + case gputypes.TextureAspectStencilOnly: + return []uint32{1} + default: + return []uint32{0, 1} + } +} + +func (t *Texture) planeCount() uint32 { + switch t.format { + case gputypes.TextureFormatDepth24Plus, + gputypes.TextureFormatDepth24PlusStencil8, + gputypes.TextureFormatDepth32FloatStencil8, + gputypes.TextureFormatStencil8: + return 2 + default: + return 1 + } +} + +func (t *Texture) subresourceCount() uint32 { + mips := t.mipLevels + if mips == 0 { + mips = 1 + } + layers := uint32(1) + if t.dimension != gputypes.TextureDimension3D { + layers = t.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + } + return mips * layers * t.planeCount() +} + +func (t *Texture) subresourceIndexForPlane(mipLevel, arrayLayer, plane uint32) uint32 { + mips := t.mipLevels + if mips == 0 { + mips = 1 + } + layers := uint32(1) + if t.dimension != gputypes.TextureDimension3D { + layers = t.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + } + return mipLevel + arrayLayer*mips + plane*mips*layers +} + +func (t *Texture) subresourceIndex(mipLevel, arrayLayer uint32) uint32 { + return t.subresourceIndexForPlane(mipLevel, arrayLayer, 0) +} + +// planPlacedBufferSlice aligns a logical image slice down to D3D12's 512-byte +// placement boundary. The byte delta is represented by block-aware source +// origins, so the selected source box still starts at the original address. +func planPlacedBufferSlice(logicalOffset uint64, rowPitch, blockWidth, blockHeight, blockBytes, copyWidth, copyHeight uint32) (bufferOffset uint64, bufferOriginX, bufferOriginY, footprintWidth, footprintHeight uint32, ok bool) { + if rowPitch == 0 || blockWidth == 0 || blockHeight == 0 || blockBytes == 0 { + return 0, 0, 0, 0, 0, false + } + bufferOffset = logicalOffset &^ (d3d12TexturePlacementAlignment - 1) + delta := logicalOffset - bufferOffset + rowBytes := uint64(rowPitch) + yRows := delta / rowBytes + xBytes := delta % rowBytes + if xBytes%uint64(blockBytes) != 0 { + return 0, 0, 0, 0, 0, false + } + xBlocks := xBytes / uint64(blockBytes) + if xBlocks > uint64(math.MaxUint32/blockWidth) || yRows > uint64(math.MaxUint32/blockHeight) { + return 0, 0, 0, 0, 0, false + } + bufferOriginX = uint32(xBlocks) * blockWidth + bufferOriginY = uint32(yRows) * blockHeight + if bufferOriginX > math.MaxUint32-copyWidth || bufferOriginY > math.MaxUint32-copyHeight { + return 0, 0, 0, 0, 0, false + } + footprintWidth, ok = alignUint32(bufferOriginX+copyWidth, blockWidth) + if !ok { + return 0, 0, 0, 0, 0, false + } + footprintHeight, ok = alignUint32(bufferOriginY+copyHeight, blockHeight) + if !ok { + return 0, 0, 0, 0, 0, false + } + if uint64(footprintWidth/blockWidth)*uint64(blockBytes) > rowBytes { + return 0, 0, 0, 0, 0, false + } + return bufferOffset, bufferOriginX, bufferOriginY, footprintWidth, footprintHeight, true +} + +func normalizedCopyLayout(texture *Texture, layout hal.ImageDataLayout, size hal.Extent3D) (textureBlockInfo, uint32, uint32, bool) { + if texture == nil { + return textureBlockInfo{}, 0, 0, false + } + info, ok := textureBlockInfoForFormat(texture.format) + if !ok { + return textureBlockInfo{}, 0, 0, false + } + minWidth := (uint64(size.Width) + uint64(info.width) - 1) / uint64(info.width) + minRowBytes64 := minWidth * uint64(info.bytes) + if minRowBytes64 == 0 || minRowBytes64 > math.MaxUint32 { + return textureBlockInfo{}, 0, 0, false + } + rowPitch := layout.BytesPerRow + if rowPitch == 0 { + if size.Height > info.height { + return textureBlockInfo{}, 0, 0, false + } + aligned, alignedOK := alignUint32(uint32(minRowBytes64), d3d12TexturePitchAlignment) + if !alignedOK { + return textureBlockInfo{}, 0, 0, false + } + rowPitch = aligned + } else if uint64(rowPitch) < minRowBytes64 || rowPitch%d3d12TexturePitchAlignment != 0 { + return textureBlockInfo{}, 0, 0, false + } + rowsPerImage := layout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = size.Height + } + if rowsPerImage < size.Height { + return textureBlockInfo{}, 0, 0, false + } + blockRows := (rowsPerImage + info.height - 1) / info.height + return info, rowPitch, blockRows, true +} + +func textureWriteSourceOffset(base uint64, sourceBytesPerRow, blockRows, slice, row uint32) uint64 { + return base + uint64(slice)*uint64(sourceBytesPerRow)*uint64(blockRows) + uint64(row)*uint64(sourceBytesPerRow) +} + +func writeTextureNativeLayout(texture *Texture, layout hal.ImageDataLayout, size hal.Extent3D) (textureBlockInfo, hal.ImageDataLayout, uint32, uint32, bool) { + if texture == nil || size.Width == 0 { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + info, ok := textureBlockInfoForFormat(texture.format) + if !ok { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + logicalRowBytes64 := ((uint64(size.Width) + uint64(info.width) - 1) / uint64(info.width)) * uint64(info.bytes) + if logicalRowBytes64 == 0 || logicalRowBytes64 > math.MaxUint32 { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + sourceBytesPerRow := layout.BytesPerRow + if sourceBytesPerRow == 0 { + sourceBytesPerRow = uint32(logicalRowBytes64) + } + if uint64(sourceBytesPerRow) < logicalRowBytes64 { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + nativeRowPitch, ok := alignUint32(uint32(logicalRowBytes64), d3d12TexturePitchAlignment) + if !ok { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + nativeLayout := layout + nativeLayout.BytesPerRow = nativeRowPitch + _, _, blockRows, ok := normalizedCopyLayout(texture, nativeLayout, size) + if !ok { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + return info, nativeLayout, sourceBytesPerRow, blockRows, true +} + +// planBufferTextureCopies converts a WebGPU buffer/texture copy into one plan +// per D3D12 subresource or 3D depth slice. It returns nil when the layout +// cannot be represented without changing logical byte addresses. +func planBufferTextureCopies(texture *Texture, copy hal.ImageCopyTexture, layout hal.ImageDataLayout, size hal.Extent3D) []bufferTextureCopyPlan { + info, rowPitch, blockRows, ok := normalizedCopyLayout(texture, layout, size) + if !ok || texture == nil { + return nil + } + widthBlocks, heightBlocks, ok := copyExtentBlocks(info, copy.Origin, size) + if !ok || widthBlocks == 0 || heightBlocks == 0 { + return nil + } + if copy.MipLevel >= maxTextureMips(texture) { + return nil + } + depth := size.DepthOrArrayLayers + if depth == 0 { + depth = 1 + } + if !validTextureCopyDepth(texture, copy.Origin.Z, depth) { + return nil + } + planes := textureAspectPlanes(texture, copy.Aspect) + if len(planes) == 0 { + return nil + } + stride := uint64(rowPitch) * uint64(blockRows) + if stride > math.MaxUint64/uint64(depth) { + return nil + } + plans := make([]bufferTextureCopyPlan, 0, len(planes)*int(depth)) + for _, plane := range planes { + for slice := uint32(0); slice < depth; slice++ { + logicalOffset := layout.Offset + uint64(slice)*stride + if logicalOffset < layout.Offset { + return nil + } + bufferOffset, originX, originY, footprintWidth, footprintHeight, representable := planPlacedBufferSlice( + logicalOffset, rowPitch, info.width, info.height, info.bytes, + widthBlocks*info.width, heightBlocks*info.height) + if !representable { + return nil + } + subresource := texture.subresourceIndexForPlane(copy.MipLevel, 0, plane) + textureOriginZ := copy.Origin.Z + slice + if texture.dimension != gputypes.TextureDimension3D { + subresource = texture.subresourceIndexForPlane(copy.MipLevel, copy.Origin.Z+slice, plane) + textureOriginZ = 0 + } + plans = append(plans, bufferTextureCopyPlan{ + subresource: subresource, + bufferOffset: bufferOffset, + bufferOriginX: originX, + bufferOriginY: originY, + footprintWidth: footprintWidth, + footprintHeight: footprintHeight, + footprintDepth: 1, + textureOriginX: copy.Origin.X, + textureOriginY: copy.Origin.Y, + textureOriginZ: textureOriginZ, + rowPitch: rowPitch, + copyWidth: widthBlocks * info.width, + copyHeight: heightBlocks * info.height, + }) + } + } + return plans +} + +func maxTextureMips(texture *Texture) uint32 { + if texture.mipLevels == 0 { + return 1 + } + return texture.mipLevels +} + +func validTextureCopyDepth(texture *Texture, origin, depth uint32) bool { + if origin > math.MaxUint32-depth { + return false + } + if texture.dimension == gputypes.TextureDimension3D { + layers := texture.size.DepthOrArrayLayers + return layers == 0 || origin+depth <= layers + } + layers := texture.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + return origin+depth <= layers +} + +// planTextureTextureCopies applies D3D12's distinct 3D-volume and 2D-array +// copy models while pairing selected source and destination planes. +func planTextureTextureCopies(src, dst *Texture, copy hal.TextureCopy) []textureTextureCopyPlan { + if src == nil || dst == nil || src.dimension != dst.dimension { + return nil + } + depth := copy.Size.DepthOrArrayLayers + if depth == 0 { + depth = 1 + } + if copy.SrcBase.MipLevel >= maxTextureMips(src) || copy.DstBase.MipLevel >= maxTextureMips(dst) { + return nil + } + if !validTextureCopyDepth(src, copy.SrcBase.Origin.Z, depth) || !validTextureCopyDepth(dst, copy.DstBase.Origin.Z, depth) { + return nil + } + srcPlanes := textureAspectPlanes(src, copy.SrcBase.Aspect) + dstPlanes := textureAspectPlanes(dst, copy.DstBase.Aspect) + if len(srcPlanes) == 0 || len(srcPlanes) != len(dstPlanes) { + return nil + } + if copy.Size.Width == 0 || copy.Size.Height == 0 { + return nil + } + if src.dimension == gputypes.TextureDimension3D { + plans := make([]textureTextureCopyPlan, 0, len(srcPlanes)) + for plane := range srcPlanes { + plans = append(plans, textureTextureCopyPlan{ + srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, 0, srcPlanes[plane]), + dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, 0, dstPlanes[plane]), + srcFront: copy.SrcBase.Origin.Z, + srcBack: copy.SrcBase.Origin.Z + depth, + dstZ: copy.DstBase.Origin.Z, + }) + } + return plans + } + plans := make([]textureTextureCopyPlan, 0, len(srcPlanes)*int(depth)) + for plane := range srcPlanes { + for layer := uint32(0); layer < depth; layer++ { + plans = append(plans, textureTextureCopyPlan{ + srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, copy.SrcBase.Origin.Z+layer, srcPlanes[plane]), + dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, copy.DstBase.Origin.Z+layer, dstPlanes[plane]), + srcFront: 0, + srcBack: 1, + dstZ: 0, + }) + } + } + return plans +} diff --git a/hal/dx12/copy_plan_test.go b/hal/dx12/copy_plan_test.go new file mode 100644 index 00000000..022e5215 --- /dev/null +++ b/hal/dx12/copy_plan_test.go @@ -0,0 +1,156 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +func testTexture(format gputypes.TextureFormat, dimension gputypes.TextureDimension, width, height, depth, mips uint32) *Texture { + return &Texture{format: format, dimension: dimension, size: hal.Extent3D{Width: width, Height: height, DepthOrArrayLayers: depth}, mipLevels: mips} +} + +func TestPlanBufferTextureCopiesPlacementAndArraySlices(t *testing.T) { + texture := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, 128, 16, 3, 2) + copy := hal.ImageCopyTexture{MipLevel: 1, Origin: hal.Origin3D{X: 4, Y: 2, Z: 1}, Aspect: gputypes.TextureAspectAll} + size := hal.Extent3D{Width: 8, Height: 2, DepthOrArrayLayers: 2} + plans := planBufferTextureCopies(texture, copy, hal.ImageDataLayout{Offset: 0, BytesPerRow: 256, RowsPerImage: 4}, size) + if len(plans) != 2 { + t.Fatalf("got %d plans, want 2", len(plans)) + } + for i, plan := range plans { + if plan.bufferOffset%512 != 0 { + t.Errorf("plan %d offset %d is not 512-aligned", i, plan.bufferOffset) + } + if plan.subresource != texture.subresourceIndex(1, 1+uint32(i)) { + t.Errorf("plan %d subresource %d", i, plan.subresource) + } + if plan.textureOriginX != copy.Origin.X || plan.textureOriginY != copy.Origin.Y || plan.textureOriginZ != 0 { + t.Errorf("plan %d texture origin = (%d,%d,%d)", i, plan.textureOriginX, plan.textureOriginY, plan.textureOriginZ) + } + } +} + +func TestPlanBufferTextureCopiesReconstructsPlacedDelta(t *testing.T) { + texture := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, 128, 64, 1, 1) + copy := hal.ImageCopyTexture{Origin: hal.Origin3D{}, Aspect: gputypes.TextureAspectAll} + for _, tc := range []struct { + name string + offset uint64 + pitch uint32 + }{ + {name: "zero", offset: 0, pitch: 256}, + {name: "one-block", offset: 4, pitch: 256}, + {name: "256", offset: 256, pitch: 768}, + {name: "512", offset: 512, pitch: 256}, + } { + t.Run(tc.name, func(t *testing.T) { + plans := planBufferTextureCopies(texture, copy, hal.ImageDataLayout{Offset: tc.offset, BytesPerRow: tc.pitch, RowsPerImage: 2}, hal.Extent3D{Width: 8, Height: 2, DepthOrArrayLayers: 1}) + if len(plans) != 1 { + t.Fatalf("got %d plans", len(plans)) + } + plan := plans[0] + logical := plan.bufferOffset + uint64(plan.bufferOriginY)*uint64(plan.rowPitch)/uint64(1) + uint64(plan.bufferOriginX)*4 + if logical != tc.offset { + t.Fatalf("reconstructed offset %d, want %d", logical, tc.offset) + } + }) + } +} + +func TestPlanBufferTextureCopiesBCAndRejectsUnrepresentableDelta(t *testing.T) { + texture := testTexture(gputypes.TextureFormatBC1RGBAUnorm, gputypes.TextureDimension2D, 64, 64, 1, 1) + copy := hal.ImageCopyTexture{Aspect: gputypes.TextureAspectAll} + plans := planBufferTextureCopies(texture, copy, hal.ImageDataLayout{Offset: 256, BytesPerRow: 512, RowsPerImage: 4}, hal.Extent3D{Width: 4, Height: 4, DepthOrArrayLayers: 1}) + if len(plans) != 1 { + t.Fatalf("got %d BC plans", len(plans)) + } + if plans[0].bufferOriginX != 128 || plans[0].footprintWidth < 132 { + t.Fatalf("BC plan origin/footprint = (%d,%d)", plans[0].bufferOriginX, plans[0].footprintWidth) + } + plan := plans[0] + reconstructed := plan.bufferOffset + uint64(plan.bufferOriginY/4)*uint64(plan.rowPitch) + uint64(plan.bufferOriginX/4)*8 + if reconstructed != 256 { + t.Fatalf("BC reconstructed offset %d, want 256", reconstructed) + } + if got := planBufferTextureCopies(texture, copy, hal.ImageDataLayout{Offset: 4, BytesPerRow: 256, RowsPerImage: 4}, hal.Extent3D{Width: 4, Height: 4, DepthOrArrayLayers: 1}); got != nil { + t.Fatal("expected byte delta not divisible by BC block size to be rejected") + } +} + +func TestPlanBufferTextureCopiesZeroPitchUsesNativeAlignment(t *testing.T) { + rgba := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, 8, 8, 1, 1) + rgbaPlans := planBufferTextureCopies(rgba, hal.ImageCopyTexture{Aspect: gputypes.TextureAspectAll}, hal.ImageDataLayout{}, hal.Extent3D{Width: 8, Height: 1, DepthOrArrayLayers: 1}) + if len(rgbaPlans) != 1 || rgbaPlans[0].rowPitch != 256 { + t.Fatalf("RGBA zero-pitch plan = %#v, want one 256-byte row", rgbaPlans) + } + bc := testTexture(gputypes.TextureFormatBC1RGBAUnorm, gputypes.TextureDimension2D, 8, 8, 1, 1) + bcPlans := planBufferTextureCopies(bc, hal.ImageCopyTexture{Aspect: gputypes.TextureAspectAll}, hal.ImageDataLayout{}, hal.Extent3D{Width: 8, Height: 4, DepthOrArrayLayers: 1}) + if len(bcPlans) != 1 || bcPlans[0].rowPitch != 256 { + t.Fatalf("BC zero-pitch plan = %#v, want one 256-byte block row", bcPlans) + } + if planBufferTextureCopies(rgba, hal.ImageCopyTexture{Aspect: gputypes.TextureAspectAll}, hal.ImageDataLayout{}, hal.Extent3D{Width: 8, Height: 2, DepthOrArrayLayers: 1}) != nil { + t.Fatal("zero BytesPerRow must remain invalid for multi-row RGBA copies") + } +} + +func TestTextureWriteSourceOffsetUsesBlockRows(t *testing.T) { + // BC1's four texel rows occupy one source block row. A two-layer upload + // with RowsPerImage=4 must therefore advance by one row pitch per layer. + if got := textureWriteSourceOffset(16, 256, 1, 1, 0); got != 272 { + t.Fatalf("BC layer offset = %d, want 272", got) + } + if got := textureWriteSourceOffset(16, 256, 2, 1, 0); got != 528 { + t.Fatalf("two block-row layer offset = %d, want 528", got) + } +} + +func TestWriteTextureNativeLayoutRepacksUnalignedSourcePitch(t *testing.T) { + texture := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, 64, 8, 1, 1) + _, native, sourceBPR, _, ok := writeTextureNativeLayout(texture, hal.ImageDataLayout{BytesPerRow: 300, RowsPerImage: 2}, hal.Extent3D{Width: 64, Height: 2, DepthOrArrayLayers: 1}) + if !ok || native.BytesPerRow != 256 || sourceBPR != 300 { + t.Fatalf("native layout = %#v, source BPR = %d, ok=%v", native, sourceBPR, ok) + } +} + +func TestPlanBufferTextureCopiesPadded3DUsesAbsoluteDepth(t *testing.T) { + texture := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension3D, 32, 32, 8, 1) + copy := hal.ImageCopyTexture{Origin: hal.Origin3D{X: 2, Y: 3, Z: 2}, Aspect: gputypes.TextureAspectAll} + plans := planBufferTextureCopies(texture, copy, hal.ImageDataLayout{BytesPerRow: 256, RowsPerImage: 8}, hal.Extent3D{Width: 4, Height: 2, DepthOrArrayLayers: 3}) + if len(plans) != 3 { + t.Fatalf("got %d plans", len(plans)) + } + for i, plan := range plans { + if plan.subresource != 0 || plan.textureOriginZ != 2+uint32(i) { + t.Errorf("plan %d subresource/depth = %d/%d", i, plan.subresource, plan.textureOriginZ) + } + } +} + +func TestPlanTextureTextureCopiesArraysAnd3D(t *testing.T) { + array := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension2D, 32, 32, 4, 2) + arrayCopy := hal.TextureCopy{ + SrcBase: hal.ImageCopyTexture{MipLevel: 1, Origin: hal.Origin3D{Z: 1}, Aspect: gputypes.TextureAspectAll}, + DstBase: hal.ImageCopyTexture{MipLevel: 0, Origin: hal.Origin3D{Z: 2}, Aspect: gputypes.TextureAspectAll}, + Size: hal.Extent3D{Width: 4, Height: 4, DepthOrArrayLayers: 2}, + } + if plans := planTextureTextureCopies(array, array, arrayCopy); len(plans) != 2 || plans[0].srcFront != 0 || plans[0].dstZ != 0 { + t.Fatalf("array plans = %#v", plans) + } + volume := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension3D, 32, 32, 8, 1) + volumeCopy := hal.TextureCopy{SrcBase: hal.ImageCopyTexture{Origin: hal.Origin3D{Z: 2}, Aspect: gputypes.TextureAspectAll}, DstBase: hal.ImageCopyTexture{Origin: hal.Origin3D{Z: 3}, Aspect: gputypes.TextureAspectAll}, Size: hal.Extent3D{Width: 4, Height: 4, DepthOrArrayLayers: 3}} + plans := planTextureTextureCopies(volume, volume, volumeCopy) + if len(plans) != 1 || plans[0].srcFront != 2 || plans[0].srcBack != 5 || plans[0].dstZ != 3 { + t.Fatalf("3D plans = %#v", plans) + } + volumeCopy.Size.DepthOrArrayLayers = 6 + if planTextureTextureCopies(volume, volume, volumeCopy) != nil { + t.Fatal("expected absolute 3D bounds to reject an oversized copy") + } +} diff --git a/hal/dx12/queue.go b/hal/dx12/queue.go index 44655eea..a65363fb 100644 --- a/hal/dx12/queue.go +++ b/hal/dx12/queue.go @@ -201,7 +201,7 @@ const d3d12TexturePitchAlignment = 256 // Creates an upload heap staging buffer, copies data with proper row pitch // alignment, and uses CopyTextureRegion to transfer to the GPU texture. func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal.ImageDataLayout, size *hal.Extent3D) error { - if dst == nil || dst.Texture == nil || len(data) == 0 || size == nil { + if dst == nil || dst.Texture == nil || layout == nil || len(data) == 0 || size == nil { return fmt.Errorf("dx12: WriteTexture: invalid arguments") } @@ -210,27 +210,16 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal return fmt.Errorf("dx12: WriteTexture: invalid texture type") } - // Calculate layout parameters - bytesPerRow := layout.BytesPerRow - if bytesPerRow == 0 { - bytesPerRow = size.Width * 4 // Assume RGBA8 (4 bytes per pixel) + info, copyLayout, sourceBytesPerRow, blockRows, valid := writeTextureNativeLayout(dstTex, *layout, *size) + if !valid { + return fmt.Errorf("dx12: WriteTexture: layout cannot be represented") } - - rowsPerImage := layout.RowsPerImage - if rowsPerImage == 0 { - rowsPerImage = size.Height - } - + rowPitch := copyLayout.BytesPerRow depthOrLayers := size.DepthOrArrayLayers if depthOrLayers == 0 { depthOrLayers = 1 } - - // D3D12 requires RowPitch to be aligned to 256 bytes - alignedRowPitch := (bytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) - - // Calculate staging buffer size with aligned pitch - stagingSize := uint64(alignedRowPitch) * uint64(rowsPerImage) * uint64(depthOrLayers) + stagingSize := uint64(rowPitch) * uint64(blockRows) * uint64(depthOrLayers) // Create upload heap staging buffer staging, err := q.device.CreateBuffer(&hal.BufferDescriptor{ @@ -246,31 +235,23 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal stagingBuf := staging.(*Buffer) - // Copy data to staging buffer with proper row pitch alignment - srcOffset := layout.Offset - if bytesPerRow == alignedRowPitch { - // No alignment padding needed — single copy - srcData := data[srcOffset:] - if uint64(len(srcData)) > stagingSize { - srcData = srcData[:stagingSize] - } - d := unsafe.Slice((*byte)(stagingBuf.mappedPointer), len(srcData)) - copy(d, srcData) - } else { - // Row-by-row copy to handle alignment padding - for z := uint32(0); z < depthOrLayers; z++ { - for row := uint32(0); row < rowsPerImage; row++ { - srcStart := srcOffset + uint64(z)*uint64(bytesPerRow)*uint64(rowsPerImage) + uint64(row)*uint64(bytesPerRow) - dstStart := uint64(z)*uint64(alignedRowPitch)*uint64(rowsPerImage) + uint64(row)*uint64(alignedRowPitch) - - if srcStart+uint64(bytesPerRow) > uint64(len(data)) { - break - } - - src := data[srcStart : srcStart+uint64(bytesPerRow)] - d := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), bytesPerRow) - copy(d, src) + // Repack source rows into a 256-byte aligned, block-row staging layout. + logicalRowBytes := ((uint64(size.Width) + uint64(info.width) - 1) / uint64(info.width)) * uint64(info.bytes) + sourceSliceRows := blockRows + rowsPerImage := copyLayout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = size.Height + } + for z := uint32(0); z < depthOrLayers; z++ { + for row := uint32(0); row < blockRows; row++ { + srcStart := textureWriteSourceOffset(copyLayout.Offset, sourceBytesPerRow, sourceSliceRows, z, row) + if srcStart+logicalRowBytes > uint64(len(data)) { + return fmt.Errorf("dx12: WriteTexture: data is smaller than layout") } + dstStart := uint64(z)*uint64(rowPitch)*uint64(blockRows) + uint64(row)*uint64(rowPitch) + src := data[srcStart : srcStart+logicalRowBytes] + d := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), len(src)) + copy(d, src) } } @@ -306,36 +287,12 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal encoder.cmdList.ResourceBarrier(1, &barrierToCopy) } - // Source location (staging buffer with placed footprint) - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: stagingBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: 0, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(dstTex.format), - Width: size.Width, - Height: size.Height, - Depth: depthOrLayers, - RowPitch: alignedRowPitch, - }, - }) - - // Destination location (texture subresource) - subresource := dst.MipLevel + dst.Origin.Z*dstTex.mipLevels - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(subresource) - - encoder.cmdList.CopyTextureRegion( - &dstLoc, - dst.Origin.X, dst.Origin.Y, dst.Origin.Z, - &srcLoc, - nil, // Copy entire source - ) + regions := []hal.BufferTextureCopy{{ + BufferLayout: hal.ImageDataLayout{Offset: 0, BytesPerRow: rowPitch, RowsPerImage: rowsPerImage}, + TextureBase: *dst, + Size: *size, + }} + encoder.copyBufferToTexture(stagingBuf, dstTex, regions) // Transition texture to shader resource state (ready for rendering) afterState := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE From d905d210991e3c71ce9e9ccc41ee193986a039d7 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 15:33:26 +0300 Subject: [PATCH 3/5] fix(dx12): reconcile command resource states --- hal/dx12/command.go | 551 +++++++++++++++++++------- hal/dx12/d3d12/device.go | 11 + hal/dx12/d3d12/resource_test.go | 33 ++ hal/dx12/device.go | 146 +++++-- hal/dx12/instance.go | 15 +- hal/dx12/pipeline.go | 6 +- hal/dx12/queue.go | 585 +++++++++++++++++++++++---- hal/dx12/resource.go | 185 +++++++-- hal/dx12/state_tracker.go | 585 +++++++++++++++++++++++++++ hal/dx12/state_tracker_test.go | 672 ++++++++++++++++++++++++++++++++ hal/dx12/surface.go | 34 +- 11 files changed, 2527 insertions(+), 296 deletions(-) create mode 100644 hal/dx12/d3d12/resource_test.go create mode 100644 hal/dx12/state_tracker.go create mode 100644 hal/dx12/state_tracker_test.go diff --git a/hal/dx12/command.go b/hal/dx12/command.go index bc629bf9..f15ee0f3 100644 --- a/hal/dx12/command.go +++ b/hal/dx12/command.go @@ -21,7 +21,9 @@ type CommandAllocator struct { // CommandBuffer holds a recorded D3D12 command list. // Allocators are managed by Device frame tracking, not by CommandBuffer. type CommandBuffer struct { - cmdList *d3d12.ID3D12GraphicsCommandList + cmdList *d3d12.ID3D12GraphicsCommandList + stateSummaries []commandStateSummary + preambleBarriers []stateBarrierPlan // populated by Queue.Submit for diagnostics/replay } // Destroy releases the command buffer's command list. @@ -52,6 +54,8 @@ type CommandEncoder struct { // are needed: one for CBV/SRV/UAV views and one for samplers. descriptorHeaps [2]*d3d12.ID3D12DescriptorHeap descriptorHeapCount int + + stateTracker commandStateTracker } // BeginEncoding begins command recording. @@ -67,6 +71,7 @@ func (e *CommandEncoder) BeginEncoding(label string) error { if err := list.Reset(e.allocator, nil); err == nil { e.cmdList = list e.isRecording = true + e.stateTracker.reset() return nil } // Reset failed — discard this list, try next or create new. @@ -80,6 +85,7 @@ func (e *CommandEncoder) BeginEncoding(label string) error { } e.cmdList = cmdList e.isRecording = true + e.stateTracker.reset() return nil } @@ -97,7 +103,7 @@ func (e *CommandEncoder) EndEncoding() (hal.CommandBuffer, error) { } e.isRecording = false - cb := &CommandBuffer{cmdList: e.cmdList} + cb := &CommandBuffer{cmdList: e.cmdList, stateSummaries: e.stateTracker.summary()} e.cmdList = nil // Detach — owned by CommandBuffer until ResetAll returns it. return cb, nil } @@ -154,8 +160,7 @@ func (e *CommandEncoder) TransitionBuffers(barriers []hal.BufferBarrier) { return } - // Convert to D3D12 resource barriers - d3dBarriers := make([]d3d12.D3D12_RESOURCE_BARRIER, 0, len(barriers)) + plans := make([]stateBarrierPlan, 0, len(barriers)) for _, b := range barriers { buf, ok := b.Buffer.(*Buffer) if !ok || buf.raw == nil { @@ -163,19 +168,13 @@ func (e *CommandEncoder) TransitionBuffers(barriers []hal.BufferBarrier) { continue } - beforeState := bufferUsageToD3D12State(b.Usage.OldUsage) afterState := bufferUsageToD3D12State(b.Usage.NewUsage) - - if beforeState == afterState { - continue + beforeState, needsBarrier := e.stateTracker.transitionBuffer(buf, afterState) + if needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: beforeState, after: afterState}) } - - d3dBarriers = append(d3dBarriers, d3d12.NewTransitionBarrier(buf.raw, beforeState, afterState, d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES)) - } - - if len(d3dBarriers) > 0 { - e.cmdList.ResourceBarrier(uint32(len(d3dBarriers)), &d3dBarriers[0]) } + e.emitStateBarrierPlans(plans) } // TransitionTextures transitions texture states for synchronization. @@ -184,8 +183,7 @@ func (e *CommandEncoder) TransitionTextures(barriers []hal.TextureBarrier) { return } - // Convert to D3D12 resource barriers - d3dBarriers := make([]d3d12.D3D12_RESOURCE_BARRIER, 0, len(barriers)) + plans := make([]stateBarrierPlan, 0, len(barriers)) for _, b := range barriers { tex, ok := b.Texture.(*Texture) if !ok || tex.raw == nil { @@ -193,24 +191,32 @@ func (e *CommandEncoder) TransitionTextures(barriers []hal.TextureBarrier) { continue } - beforeState := textureUsageToD3D12State(b.Usage.OldUsage) afterState := textureUsageToD3D12State(b.Usage.NewUsage) - - // Skip if no transition needed - if beforeState == afterState { - continue + for _, plan := range e.stateTracker.transitionTextureRange(tex, textureRangeSubresources(tex, b.Range), afterState) { + plans = append(plans, plan) } + } + e.emitStateBarrierPlans(plans) +} - // Calculate subresource or use all - subresource := d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES - if b.Range.MipLevelCount == 1 && b.Range.ArrayLayerCount == 1 { - // Single subresource - subresource = b.Range.BaseMipLevel + b.Range.BaseArrayLayer*tex.mipLevels +func (e *CommandEncoder) emitStateBarrierPlans(plans []stateBarrierPlan) { + if !e.isRecording || len(plans) == 0 { + return + } + d3dBarriers := make([]d3d12.D3D12_RESOURCE_BARRIER, 0, len(plans)) + for _, plan := range plans { + var raw *d3d12.ID3D12Resource + switch resource := plan.resource.(type) { + case *Buffer: + raw = resource.raw + case *Texture: + raw = resource.raw } - - d3dBarriers = append(d3dBarriers, d3d12.NewTransitionBarrier(tex.raw, beforeState, afterState, subresource)) + if raw == nil || plan.before == plan.after { + continue + } + d3dBarriers = append(d3dBarriers, d3d12.NewTransitionBarrier(raw, plan.before, plan.after, plan.subresource)) } - if len(d3dBarriers) > 0 { hal.Logger().Debug("dx12: resource barrier", "label", e.label, "count", len(d3dBarriers)) e.cmdList.ResourceBarrier(uint32(len(d3dBarriers)), &d3dBarriers[0]) @@ -260,12 +266,14 @@ func (e *CommandEncoder) CopyBufferToBuffer(src, dst hal.Buffer, regions []hal.B return } - // Insert transition barriers for buffers not in the required copy state. - // COMMON state allows implicit promotion to COPY_DEST (D3D12 spec) but NOT - // to COPY_SOURCE — an explicit barrier is required for COPY_SOURCE. - // Batch multiple barriers into a single ResourceBarrier call (Rust pattern). - e.transitionBuffersForCopy(srcBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE, - dstBuf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST) + plans := make([]stateBarrierPlan, 0, 2) + if before, needsBarrier := e.stateTracker.transitionBuffer(srcBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: srcBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + if before, needsBarrier := e.stateTracker.transitionBuffer(dstBuf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + e.emitStateBarrierPlans(plans) for _, r := range regions { hal.Logger().Debug("dx12: copy buffer region", "label", e.label, "offset", r.DstOffset, "size", r.Size) @@ -286,7 +294,60 @@ func (e *CommandEncoder) CopyBufferToTexture(src hal.Buffer, dst hal.Texture, re return } - e.copyBufferToTexture(srcBuf, dstTex, regions) + plans := make([]stateBarrierPlan, 0, 1+len(regions)) + if before, needsBarrier := e.stateTracker.transitionBuffer(srcBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: srcBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + for _, r := range regions { + for _, copyPlan := range planBufferTextureCopies(dstTex, r.TextureBase, r.BufferLayout, r.Size) { + if before, needsBarrier := e.stateTracker.transitionTexture(dstTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + } + } + e.emitStateBarrierPlans(plans) + + for _, r := range regions { + for _, copyPlan := range planBufferTextureCopies(dstTex, r.TextureBase, r.BufferLayout, r.Size) { + // A 2D array layer is a distinct subresource and therefore needs a + // distinct placed footprint with depth=1. + srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: srcBuf.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, + } + srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ + Offset: copyPlan.bufferOffset, + Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ + Format: textureFormatToD3D12(dstTex.format), + Width: r.Size.Width, + Height: copyPlan.footprintHeight, + Depth: copyPlan.footprintDepth, + RowPitch: r.BufferLayout.BytesPerRow, + }, + }) + + dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: dstTex.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, + } + dstLoc.SetSubresourceIndex(copyPlan.subresource) + srcBox := d3d12.D3D12_BOX{ + Left: 0, + Top: copyPlan.bufferOriginY, + Front: 0, + Right: r.Size.Width, + Bottom: copyPlan.bufferOriginY + r.Size.Height, + Back: copyPlan.footprintDepth, + } + + e.cmdList.CopyTextureRegion( + &dstLoc, + r.TextureBase.Origin.X, r.TextureBase.Origin.Y, copyPlan.textureOriginZ, + &srcLoc, + &srcBox, + ) + } + } } // CopyTextureToBuffer copies data from a texture to a buffer. @@ -302,7 +363,57 @@ func (e *CommandEncoder) CopyTextureToBuffer(src hal.Texture, dst hal.Buffer, re return } - e.copyTextureToBuffer(srcTex, dstBuf, regions) + plans := make([]stateBarrierPlan, 0, 1+len(regions)) + if before, needsBarrier := e.stateTracker.transitionBuffer(dstBuf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + for _, r := range regions { + for _, copyPlan := range planBufferTextureCopies(srcTex, r.TextureBase, r.BufferLayout, r.Size) { + if before, needsBarrier := e.stateTracker.transitionTexture(srcTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: srcTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + } + } + e.emitStateBarrierPlans(plans) + + for _, r := range regions { + // D3D12 requires RowPitch aligned to 256 bytes. + // The caller should pass aligned BytesPerRow, but align defensively. + rowPitch := (r.BufferLayout.BytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) + for _, copyPlan := range planBufferTextureCopies(srcTex, r.TextureBase, r.BufferLayout, r.Size) { + srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: srcTex.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, + } + srcLoc.SetSubresourceIndex(copyPlan.subresource) + + dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: dstBuf.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, + } + dstLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ + Offset: copyPlan.bufferOffset, + Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ + Format: textureFormatToD3D12(srcTex.format), + Width: r.Size.Width, + Height: copyPlan.footprintHeight, + Depth: copyPlan.footprintDepth, + RowPitch: rowPitch, + }, + }) + + srcBox := d3d12.D3D12_BOX{ + Left: r.TextureBase.Origin.X, + Top: r.TextureBase.Origin.Y, + Front: copyPlan.textureOriginZ, + Right: r.TextureBase.Origin.X + r.Size.Width, + Bottom: r.TextureBase.Origin.Y + r.Size.Height, + Back: copyPlan.textureOriginZ + copyPlan.footprintDepth, + } + + e.cmdList.CopyTextureRegion(&dstLoc, 0, copyPlan.bufferOriginY, 0, &srcLoc, &srcBox) + } + } } // CopyTextureToTexture copies data between textures. @@ -317,7 +428,50 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst hal.Texture, regions []ha return } - e.copyTextureToTexture(srcTex, dstTex, regions) + plans := make([]stateBarrierPlan, 0, len(regions)*2) + for _, r := range regions { + for _, copyPlan := range planTextureTextureCopies(srcTex, dstTex, r) { + if before, needsBarrier := e.stateTracker.transitionTexture(srcTex, copyPlan.srcSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: srcTex, subresource: copyPlan.srcSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + if before, needsBarrier := e.stateTracker.transitionTexture(dstTex, copyPlan.dstSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.dstSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + } + } + e.emitStateBarrierPlans(plans) + + for _, r := range regions { + for _, copyPlan := range planTextureTextureCopies(srcTex, dstTex, r) { + srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: srcTex.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, + } + srcLoc.SetSubresourceIndex(copyPlan.srcSubresource) + + dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: dstTex.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, + } + dstLoc.SetSubresourceIndex(copyPlan.dstSubresource) + + srcBox := d3d12.D3D12_BOX{ + Left: r.SrcBase.Origin.X, + Top: r.SrcBase.Origin.Y, + Front: copyPlan.srcFront, + Right: r.SrcBase.Origin.X + r.Size.Width, + Bottom: r.SrcBase.Origin.Y + r.Size.Height, + Back: copyPlan.srcBack, + } + + e.cmdList.CopyTextureRegion( + &dstLoc, + r.DstBase.Origin.X, r.DstBase.Origin.Y, copyPlan.dstZ, + &srcLoc, + &srcBox, + ) + } + } } // ResolveQuerySet copies query results from a query set into a destination buffer. @@ -335,6 +489,9 @@ func (e *CommandEncoder) ResolveQuerySet(querySet hal.QuerySet, firstQuery, quer if !ok || buf == nil || buf.raw == nil { return } + if before, needsBarrier := e.stateTracker.transitionBuffer(buf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + e.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}}) + } e.cmdList.ResolveQueryData(qs.raw, qs.rawTy, firstQuery, queryCount, buf.raw, destinationOffset) } @@ -396,23 +553,22 @@ func (e *CommandEncoder) BeginRenderPass(desc *hal.RenderPassDescriptor) hal.Ren return rpe } - // Transition surface textures from PRESENT to RENDER_TARGET state. - // DX12 requires explicit barriers (unlike Vulkan which uses render pass layout transitions). + // Render attachments are first-use requirements in the command-local + // tracker. Surface views share the same owner as their swapchain back + // buffer, so PRESENT is captured from that owner rather than guessed here. + attachmentPlans := make([]stateBarrierPlan, 0) for _, ca := range desc.ColorAttachments { view, ok := ca.View.(*TextureView) if !ok || view.texture == nil || view.texture.raw == nil { continue } - if view.texture.isExternal { - barrier := d3d12.NewTransitionBarrier( - view.texture.raw, - d3d12.D3D12_RESOURCE_STATE_PRESENT, - d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - e.cmdList.ResourceBarrier(1, &barrier) + for _, subresource := range textureViewSubresources(view) { + if before, needsBarrier := e.stateTracker.transitionTexture(view.texture, subresource, d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET); needsBarrier { + attachmentPlans = append(attachmentPlans, stateBarrierPlan{resource: view.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET}) + } } } + e.emitStateBarrierPlans(attachmentPlans) // Set render targets rtvHandles := make([]d3d12.D3D12_CPU_DESCRIPTOR_HANDLE, 0, len(desc.ColorAttachments)) @@ -552,57 +708,52 @@ func (e *RenderPassEncoder) End() { } // MSAA resolve: render target → resolve source, resolve target → resolve dest. - b1 := d3d12.NewTransitionBarrier( - msaaView.texture.raw, - d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET, - d3d12.D3D12_RESOURCE_STATE_RESOLVE_SOURCE, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - b2 := d3d12.NewTransitionBarrier( - resolveView.texture.raw, - resolveRestState, - d3d12.D3D12_RESOURCE_STATE_RESOLVE_DEST, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - barriers := [2]d3d12.D3D12_RESOURCE_BARRIER{b1, b2} - e.encoder.cmdList.ResourceBarrier(2, &barriers[0]) + msaaSubresources := textureViewSubresources(msaaView) + resolveSubresources := textureViewSubresources(resolveView) + plans := make([]stateBarrierPlan, 0, len(msaaSubresources)+len(resolveSubresources)) + for _, subresource := range msaaSubresources { + if before, needsBarrier := e.encoder.stateTracker.transitionTexture(msaaView.texture, subresource, d3d12.D3D12_RESOURCE_STATE_RESOLVE_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: msaaView.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_RESOLVE_SOURCE}) + } + } + for _, subresource := range resolveSubresources { + if before, needsBarrier := e.encoder.stateTracker.transitionTexture(resolveView.texture, subresource, d3d12.D3D12_RESOURCE_STATE_RESOLVE_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: resolveView.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_RESOLVE_DEST}) + } + } + e.encoder.emitStateBarrierPlans(plans) // Resolve MSAA → single-sample. format := textureFormatToD3D12(msaaView.texture.format) e.encoder.cmdList.ResolveSubresource( - resolveView.texture.raw, 0, - msaaView.texture.raw, 0, + resolveView.texture.raw, resolveSubresourceIndex(resolveView), + msaaView.texture.raw, resolveSubresourceIndex(msaaView), format, ) - // Transition back: MSAA → render target (for next frame), - // resolve target → resting state. - b3 := d3d12.NewTransitionBarrier( - msaaView.texture.raw, - d3d12.D3D12_RESOURCE_STATE_RESOLVE_SOURCE, - d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - b4 := d3d12.NewTransitionBarrier( - resolveView.texture.raw, - d3d12.D3D12_RESOURCE_STATE_RESOLVE_DEST, - resolveRestState, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - barriers2 := [2]d3d12.D3D12_RESOURCE_BARRIER{b3, b4} - e.encoder.cmdList.ResourceBarrier(2, &barriers2[0]) + // Return the resources to their command-local resting states. + plans = plans[:0] + for _, subresource := range msaaSubresources { + if before, needsBarrier := e.encoder.stateTracker.transitionTexture(msaaView.texture, subresource, d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: msaaView.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET}) + } + } + for _, subresource := range resolveSubresources { + if before, needsBarrier := e.encoder.stateTracker.transitionTexture(resolveView.texture, subresource, resolveRestState); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: resolveView.texture, subresource: subresource, before: before, after: resolveRestState}) + } + } + e.encoder.emitStateBarrierPlans(plans) continue } // No resolve — just transition external surface back to PRESENT. if msaaView.texture.isExternal { - barrier := d3d12.NewTransitionBarrier( - msaaView.texture.raw, - d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET, - d3d12.D3D12_RESOURCE_STATE_PRESENT, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - e.encoder.cmdList.ResourceBarrier(1, &barrier) + for _, subresource := range textureViewSubresources(msaaView) { + if before, needsBarrier := e.encoder.stateTracker.transitionTexture(msaaView.texture, subresource, d3d12.D3D12_RESOURCE_STATE_PRESENT); needsBarrier { + e.encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: msaaView.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_PRESENT}}) + } + } } } } @@ -657,6 +808,7 @@ func (e *RenderPassEncoder) SetBindGroup(index uint32, group hal.BindGroup, offs // Bind the group using graphics root descriptor tables. e.encoder.bindGroupToRootTables(index, bg, false, mappings) + e.encoder.trackBindGroupState(bg) _ = offsets // Dynamic offsets handled via root constants (simplified for now) } @@ -666,6 +818,9 @@ func (e *RenderPassEncoder) SetVertexBuffer(slot uint32, buffer hal.Buffer, offs if !ok || !e.encoder.isRecording { return } + if before, target, needsBarrier := e.encoder.stateTracker.transitionBufferRead(buf, d3d12.D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER); needsBarrier { + e.encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}}) + } // Get stride from pipeline if available var stride uint32 @@ -688,6 +843,9 @@ func (e *RenderPassEncoder) SetIndexBuffer(buffer hal.Buffer, format gputypes.In if !ok || !e.encoder.isRecording { return } + if before, target, needsBarrier := e.encoder.stateTracker.transitionBufferRead(buf, d3d12.D3D12_RESOURCE_STATE_INDEX_BUFFER); needsBarrier { + e.encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}}) + } e.indexFormat = format dxgiFormat := d3d12.DXGI_FORMAT_R16_UINT @@ -789,6 +947,9 @@ func (e *RenderPassEncoder) DrawIndirect(buffer hal.Buffer, offset uint64) { if !ok || !e.encoder.isRecording { return } + if before, target, needsBarrier := e.encoder.stateTracker.transitionBufferRead(buf, d3d12.D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT); needsBarrier { + e.encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}}) + } e.encoder.cmdList.ExecuteIndirect( e.encoder.device.cmdSignatures.draw, @@ -804,6 +965,9 @@ func (e *RenderPassEncoder) DrawIndexedIndirect(buffer hal.Buffer, offset uint64 if !ok || !e.encoder.isRecording { return } + if before, target, needsBarrier := e.encoder.stateTracker.transitionBufferRead(buf, d3d12.D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT); needsBarrier { + e.encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}}) + } e.encoder.cmdList.ExecuteIndirect( e.encoder.device.cmdSignatures.drawIndexed, @@ -824,10 +988,8 @@ type ComputePassEncoder struct { descriptorHeapsSet bool // Tracks whether descriptor heaps have been bound // boundStorageBuffers tracks storage buffers from SetBindGroup calls. - // After Dispatch(), these buffers' currentState is updated to UNORDERED_ACCESS - // so that subsequent CopyBufferToBuffer can insert the correct transition - // barrier (UNORDERED_ACCESS -> COPY_SOURCE). Without this tracking, the - // copy command would not know the buffer was used as UAV (BUG-DX12-012). + // Dispatch records them as UNORDERED_ACCESS in the command-local tracker so + // a later copy in the same command list can transition from the UAV state. // // Matches Rust wgpu-core pattern: compute pass tracks buffer usage per // dispatch via BufferUsageScope, then drains barriers on state transitions @@ -901,6 +1063,7 @@ func (e *ComputePassEncoder) SetBindGroup(index uint32, group hal.BindGroup, off // Bind the group using compute root descriptor tables. e.encoder.bindGroupToRootTables(index, bg, true, mappings) + e.encoder.trackBindGroupState(bg) // Track storage buffers from this bind group for state tracking. // After Dispatch(), these buffers will be marked as UNORDERED_ACCESS @@ -918,6 +1081,13 @@ func (e *ComputePassEncoder) Dispatch(x, y, z uint32) { return } + plans := make([]stateBarrierPlan, 0, len(e.boundStorageBuffers)) + for _, buf := range e.boundStorageBuffers { + if before, needsBarrier := e.encoder.stateTracker.transitionBuffer(buf, d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS}) + } + } + e.encoder.emitStateBarrierPlans(plans) e.encoder.cmdList.Dispatch(x, y, z) e.insertUAVBarrier() @@ -927,9 +1097,6 @@ func (e *ComputePassEncoder) Dispatch(x, y, z uint32) { // need these buffers in a different state (e.g., COPY_SOURCE). // Matches Rust wgpu-core pattern: flush_bindings sets BufferUses::STORAGE_READ_WRITE // on bound storage buffers, then drain_barriers emits transitions. - for _, buf := range e.boundStorageBuffers { - buf.currentState = d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS - } // Clear for next dispatch (same pattern as Rust per-dispatch usage scope). e.boundStorageBuffers = e.boundStorageBuffers[:0] } @@ -942,6 +1109,16 @@ func (e *ComputePassEncoder) DispatchIndirect(buffer hal.Buffer, offset uint64) if !ok || !e.encoder.isRecording { return } + plans := make([]stateBarrierPlan, 0, 1+len(e.boundStorageBuffers)) + if before, target, needsBarrier := e.encoder.stateTracker.transitionBufferRead(buf, d3d12.D3D12_RESOURCE_STATE_INDIRECT_ARGUMENT); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}) + } + for _, storage := range e.boundStorageBuffers { + if before, needsBarrier := e.encoder.stateTracker.transitionBuffer(storage, d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: storage, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS}) + } + } + e.encoder.emitStateBarrierPlans(plans) e.encoder.cmdList.ExecuteIndirect( e.encoder.device.cmdSignatures.dispatch, @@ -952,9 +1129,6 @@ func (e *ComputePassEncoder) DispatchIndirect(buffer hal.Buffer, offset uint64) // Mark bound storage buffers as UNORDERED_ACCESS, matching Dispatch() pattern. // After an indirect dispatch, storage buffers are left in UAV state for correct // transition barriers on subsequent commands. - for _, b := range e.boundStorageBuffers { - b.currentState = d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS - } e.boundStorageBuffers = e.boundStorageBuffers[:0] } @@ -997,25 +1171,11 @@ func needsExplicitBarrier(current, target d3d12.D3D12_RESOURCE_STATES) bool { } // transitionBufferIfNeeded inserts a transition barrier for a single buffer if -// its current state requires an explicit barrier to reach the target state. -// Updates the buffer's currentState to the target state after the barrier. +// its command-local state requires an explicit barrier to reach the target. func (e *CommandEncoder) transitionBufferIfNeeded(buf *Buffer, targetState d3d12.D3D12_RESOURCE_STATES) { - if !needsExplicitBarrier(buf.currentState, targetState) { - // Even with implicit promotion, update the tracked state. - if buf.currentState != targetState { - buf.currentState = targetState - } - return + if before, needsBarrier := e.stateTracker.transitionBuffer(buf, targetState); needsBarrier { + e.emitStateBarrierPlans([]stateBarrierPlan{{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: targetState}}) } - - barrier := d3d12.NewTransitionBarrier(buf.raw, buf.currentState, targetState, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES) - e.cmdList.ResourceBarrier(1, &barrier) - hal.Logger().Debug("dx12: buffer state transition", - "label", e.label, - "from", buf.currentState, - "to", targetState) - buf.currentState = targetState } // transitionBuffersForCopy inserts batched transition barriers for a source and @@ -1026,40 +1186,24 @@ func (e *CommandEncoder) transitionBuffersForCopy( srcBuf *Buffer, srcTarget d3d12.D3D12_RESOURCE_STATES, dstBuf *Buffer, dstTarget d3d12.D3D12_RESOURCE_STATES, ) { - var barriers [2]d3d12.D3D12_RESOURCE_BARRIER - count := 0 - - srcNeedsBarrier := needsExplicitBarrier(srcBuf.currentState, srcTarget) - dstNeedsBarrier := needsExplicitBarrier(dstBuf.currentState, dstTarget) - - if srcNeedsBarrier { - barriers[count] = d3d12.NewTransitionBarrier(srcBuf.raw, srcBuf.currentState, srcTarget, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES) - count++ - hal.Logger().Debug("dx12: buffer state transition (copy src)", - "label", e.label, - "from", srcBuf.currentState, - "to", srcTarget) + plans := make([]stateBarrierPlan, 0, 2) + if before, needsBarrier := e.stateTracker.transitionBuffer(srcBuf, srcTarget); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: srcBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: srcTarget}) } - - if dstNeedsBarrier { - barriers[count] = d3d12.NewTransitionBarrier(dstBuf.raw, dstBuf.currentState, dstTarget, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES) - count++ - hal.Logger().Debug("dx12: buffer state transition (copy dst)", - "label", e.label, - "from", dstBuf.currentState, - "to", dstTarget) + if before, needsBarrier := e.stateTracker.transitionBuffer(dstBuf, dstTarget); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: dstTarget}) } + e.emitStateBarrierPlans(plans) +} - if count > 0 { - e.cmdList.ResourceBarrier(uint32(count), &barriers[0]) +func resolveSubresourceIndex(view *TextureView) uint32 { + if view == nil || view.texture == nil { + return 0 } - - // Update tracked state. Do this even for implicit promotions so the - // tracker stays consistent with actual GPU state. - srcBuf.currentState = srcTarget - dstBuf.currentState = dstTarget + if subresources := textureViewSubresources(view); len(subresources) > 0 { + return subresources[0] + } + return view.texture.subresourceIndex(view.baseMip, view.baseLayer) } // --- Helper functions --- @@ -1106,6 +1250,85 @@ func (e *CommandEncoder) bindGroupToRootTables(bindGroupIndex uint32, bg *BindGr } } +func (e *CommandEncoder) trackBindGroupState(bg *BindGroup) { + if bg == nil { + return + } + plans := make([]stateBarrierPlan, 0) + for _, buffer := range bg.uniformBuffers { + if before, target, needsBarrier := e.stateTracker.transitionBufferRead(buffer, d3d12.D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buffer, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}) + } + } + for _, buffer := range bg.readOnlyStorageBuffers { + if before, target, needsBarrier := e.stateTracker.transitionBufferRead(buffer, d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE|d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buffer, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: target}) + } + } + for _, buffer := range bg.storageBuffers { + if before, needsBarrier := e.stateTracker.transitionBuffer(buffer, d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buffer, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS}) + } + } + for _, view := range bg.sampledTextures { + for _, subresource := range textureViewSubresources(view) { + if before, target, needsBarrier := e.stateTracker.transitionTextureRead(view.texture, subresource, d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE|d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: view.texture, subresource: subresource, before: before, after: target}) + } + } + } + for _, view := range bg.storageTextures { + for _, subresource := range textureViewSubresources(view) { + if before, needsBarrier := e.stateTracker.transitionTexture(view.texture, subresource, d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: view.texture, subresource: subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS}) + } + } + } + e.emitStateBarrierPlans(plans) +} + +func textureViewSubresources(view *TextureView) []uint32 { + if view == nil || view.texture == nil { + return nil + } + return textureRangeSubresources(view.texture, hal.TextureRange{ + Aspect: view.aspect, + BaseMipLevel: view.baseMip, + MipLevelCount: view.mipCount, + BaseArrayLayer: view.baseLayer, + ArrayLayerCount: view.layerCount, + }) +} + +func textureViewSubresourcePlanes(view *TextureView) []textureSubresource { + if view == nil || view.texture == nil { + return nil + } + return textureRangeSubresourcePlanes(view.texture, hal.TextureRange{ + Aspect: view.aspect, + BaseMipLevel: view.baseMip, + MipLevelCount: view.mipCount, + BaseArrayLayer: view.baseLayer, + ArrayLayerCount: view.layerCount, + }) +} + +func textureViewPhysicalSubresourcePlanes(view *TextureView) []textureSubresource { + if view == nil || view.texture == nil { + return nil + } + planes := []uint32{0} + if view.texture.planeCount() > 1 { + planes = []uint32{0, 1} + } + return textureRangeSubresourcePlanesForPlanes(view.texture, hal.TextureRange{ + BaseMipLevel: view.baseMip, + MipLevelCount: view.mipCount, + BaseArrayLayer: view.baseLayer, + ArrayLayerCount: view.layerCount, + }, planes) +} + // setupDepthStencilAttachment configures depth/stencil attachment for a render pass. // Returns the DSV handle if valid, nil otherwise. func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthStencilAttachment) *d3d12.D3D12_CPU_DESCRIPTOR_HANDLE { @@ -1118,6 +1341,25 @@ func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthSte return nil } + depthReadOnly, stencilReadOnly := depthStencilReadOnlyFlags(view.format, view.aspect, dsa.DepthReadOnly, dsa.StencilReadOnly) + dsvFlags := d3d12.D3D12_DSV_FLAG_NONE + if depthReadOnly { + dsvFlags |= d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH + } + if stencilReadOnly { + dsvFlags |= d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL + } + exposedPlanes := textureAspectPlanes(view.texture, view.aspect) + plans := make([]stateBarrierPlan, 0) + for _, subresource := range textureViewPhysicalSubresourcePlanes(view) { + shaderReadable := view.hasSRV && containsPlane(exposedPlanes, subresource.plane) + state := depthStencilPlaneState(subresource.plane, depthReadOnly, stencilReadOnly, shaderReadable) + if before, needsBarrier := e.stateTracker.transitionTexture(view.texture, subresource.subresource, state); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: view.texture, subresource: subresource.subresource, before: before, after: state}) + } + } + e.emitStateBarrierPlans(plans) + // Determine clear flags var clearFlags d3d12.D3D12_CLEAR_FLAGS if dsa.DepthLoadOp == gputypes.LoadOpClear { @@ -1127,10 +1369,13 @@ func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthSte clearFlags |= d3d12.D3D12_CLEAR_FLAG_STENCIL } - // Clear if needed + dsvHandle := view.dsvHandleForFlags(dsvFlags) + + // Clear if needed. Use the same DSV variant as the pass so an unexposed or + // explicitly read-only companion plane stays protected during the clear. if clearFlags != 0 { e.cmdList.ClearDepthStencilView( - view.dsvHandle, + *dsvHandle, clearFlags, dsa.DepthClearValue, uint8(dsa.StencilClearValue), @@ -1138,7 +1383,16 @@ func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthSte ) } - return &view.dsvHandle + return dsvHandle +} + +func containsPlane(planes []uint32, target uint32) bool { + for _, plane := range planes { + if plane == target { + return true + } + } + return false } // bufferUsageToD3D12State converts buffer usage to D3D12 resource state. @@ -1195,6 +1449,9 @@ func d3d12StateToTextureUsage(state d3d12.D3D12_RESOURCE_STATES) gputypes.Textur if state&d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET != 0 { usage |= gputypes.TextureUsageRenderAttachment } + if state&(d3d12.D3D12_RESOURCE_STATE_DEPTH_READ|d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE) != 0 { + usage |= gputypes.TextureUsageRenderAttachment + } // COMMON (0) maps to 0 (no specific usage) return usage diff --git a/hal/dx12/d3d12/device.go b/hal/dx12/d3d12/device.go index 067da904..4f65428b 100644 --- a/hal/dx12/d3d12/device.go +++ b/hal/dx12/d3d12/device.go @@ -1178,6 +1178,17 @@ func (f *ID3D12Fence) Signal(value uint64) error { // ID3D12Resource methods // ----------------------------------------------------------------------------- +// AddRef increments the reference count. +func (r *ID3D12Resource) AddRef() uint32 { + ret, _, _ := syscall.Syscall( + r.vtbl.AddRef, + 1, + uintptr(unsafe.Pointer(r)), + 0, 0, + ) + return uint32(ret) +} + // Release decrements the reference count. func (r *ID3D12Resource) Release() uint32 { ret, _, _ := syscall.Syscall( diff --git a/hal/dx12/d3d12/resource_test.go b/hal/dx12/d3d12/resource_test.go new file mode 100644 index 00000000..69af5872 --- /dev/null +++ b/hal/dx12/d3d12/resource_test.go @@ -0,0 +1,33 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package d3d12 + +import ( + "syscall" + "testing" +) + +func TestResourceAddRefAndReleaseUseIUnknownVTable(t *testing.T) { + addRefs := 0 + releases := 0 + resource := &ID3D12Resource{vtbl: &id3d12ResourceVtbl{ + AddRef: syscall.NewCallback(func(uintptr) uintptr { + addRefs++ + return 2 + }), + Release: syscall.NewCallback(func(uintptr) uintptr { + releases++ + return 1 + }), + }} + + if got := resource.AddRef(); got != 2 || addRefs != 1 { + t.Fatalf("AddRef = (%d, calls=%d), want (2, 1)", got, addRefs) + } + if got := resource.Release(); got != 1 || releases != 1 { + t.Fatalf("Release = (%d, calls=%d), want (1, 1)", got, releases) + } +} diff --git a/hal/dx12/device.go b/hal/dx12/device.go index e117cacf..11abf985 100644 --- a/hal/dx12/device.go +++ b/hal/dx12/device.go @@ -52,6 +52,7 @@ type Device struct { // Command queue for graphics/compute operations. directQueue *d3d12.ID3D12CommandQueue + queueState *queueState // shared noncyclic lifetime/preamble owner // Descriptor heaps (shared for all resources). viewHeap *DescriptorHeap // CBV/SRV/UAV (shader-visible, for bind groups) @@ -1113,6 +1114,10 @@ func (d *Device) CreateBuffer(desc *hal.BufferDescriptor) (hal.Buffer, error) { gpuVA: resource.GetGPUVirtualAddress(), device: d, currentState: initialState, + stateOwner: resourceStateOwner{ + bufferState: initialState, + bufferStateSet: true, + }, } // Map at creation if requested @@ -1339,6 +1344,11 @@ func (d *Device) CreateTexture(desc *hal.TextureDescriptor) (hal.Texture, error) device: d, currentState: initialState, } + textureStates := make([]d3d12.D3D12_RESOURCE_STATES, tex.subresourceCount()) + for i := range textureStates { + textureStates[i] = initialState + } + tex.stateOwner.setTextureStates(textureStates) // Post-creation health check: detect if CreateCommittedResource silently poisoned the device. if reason := d.raw.GetDeviceRemovedReason(); reason != nil { @@ -1374,15 +1384,25 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes // hasRTV=true so BeginRenderPass uses this RTV, but isExternal=true tells // Destroy() to skip freeing the RTV heap slot (the Surface owns it). if st, ok := texture.(*SurfaceTexture); ok { + owner := st.stateOwner + if owner == nil { + owner = &Texture{ + raw: st.resource, + format: st.format, + dimension: gputypes.TextureDimension2D, + size: hal.Extent3D{Width: st.width, Height: st.height, DepthOrArrayLayers: 1}, + mipLevels: 1, + samples: 1, + usage: gputypes.TextureUsageRenderAttachment, + device: d, + isExternal: true, + currentState: d3d12.D3D12_RESOURCE_STATE_PRESENT, + } + owner.stateOwner.setTextureStates([]d3d12.D3D12_RESOURCE_STATES{d3d12.D3D12_RESOURCE_STATE_PRESENT}) + st.stateOwner = owner + } return &TextureView{ - texture: &Texture{ - raw: st.resource, - format: st.format, - dimension: gputypes.TextureDimension2D, - size: hal.Extent3D{Width: st.width, Height: st.height, DepthOrArrayLayers: 1}, - mipLevels: 1, - isExternal: true, - }, + texture: owner, format: st.format, dimension: gputypes.TextureViewDimension2D, aspect: gputypes.TextureAspectAll, @@ -1545,8 +1565,26 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes d.raw.CreateDepthStencilView(tex.raw, &dsvDesc, dsvHandle) view.dsvHandle = dsvHandle - view.dsvHeapIndex = dsvIndex + view.dsvHandles[0] = dsvHandle + view.dsvHeapIndex[0] = dsvIndex + view.hasDSVVariants[0] = true view.hasDSV = true + + // D3D12 encodes read-only depth/stencil planes in the DSV itself. + // Keep all four flag combinations so a render pass can select the + // descriptor matching its DepthReadOnly/StencilReadOnly attachment. + for mask := uint32(1); mask < 4; mask++ { + variantHandle, variantIndex, variantErr := d.allocateDSVDescriptor() + if variantErr != nil { + return failTextureViewCreation(view, fmt.Errorf("dx12: failed to allocate read-only DSV descriptor: %w", variantErr)) + } + variantDesc := dsvDesc + variantDesc.Flags = d3d12.D3D12_DSV_FLAGS(mask) + d.raw.CreateDepthStencilView(tex.raw, &variantDesc, variantHandle) + view.dsvHandles[mask] = variantHandle + view.dsvHeapIndex[mask] = variantIndex + view.hasDSVVariants[mask] = true + } } // Create SRV if texture supports texture binding @@ -1763,23 +1801,36 @@ func (d *Device) CreateBindGroup(desc *hal.BindGroupDescriptor) (hal.BindGroup, } } - // Collect storage buffer references for DX12 resource state tracking. - // Match layout entries (which know the binding type) with bind group entries - // (which carry the buffer handle) to identify storage buffers. - // These references are used by ComputePassEncoder to mark buffers as - // UNORDERED_ACCESS after Dispatch(), enabling correct transition barriers - // before subsequent copy commands (BUG-DX12-012). + // Collect resource references for DX12 command-local state tracking. The + // descriptor table itself does not carry enough information to emit state + // transitions, so retain the typed resources alongside it. for _, layoutEntry := range layout.entries { - if layoutEntry.Type != BindingTypeStorageBuffer { - continue - } for _, bgEntry := range desc.Entries { if bgEntry.Binding != layoutEntry.Binding { continue } - if bufBinding, ok := bgEntry.Resource.(gputypes.BufferBinding); ok && bufBinding.Buffer != 0 { - buf := (*Buffer)(unsafe.Pointer(bufBinding.Buffer)) //nolint:govet // intentional: HAL handle -> concrete type - bg.storageBuffers = append(bg.storageBuffers, buf) + switch layoutEntry.Type { + case BindingTypeUniformBuffer, BindingTypeStorageBuffer, BindingTypeReadOnlyStorageBuffer: + if bufBinding, ok := bgEntry.Resource.(gputypes.BufferBinding); ok && bufBinding.Buffer != 0 { + buf := (*Buffer)(unsafe.Pointer(bufBinding.Buffer)) //nolint:govet // intentional: HAL handle -> concrete type + switch layoutEntry.Type { + case BindingTypeUniformBuffer: + bg.uniformBuffers = append(bg.uniformBuffers, buf) + case BindingTypeStorageBuffer: + bg.storageBuffers = append(bg.storageBuffers, buf) + case BindingTypeReadOnlyStorageBuffer: + bg.readOnlyStorageBuffers = append(bg.readOnlyStorageBuffers, buf) + } + } + case BindingTypeSampledTexture, BindingTypeStorageTexture: + if textureBinding, ok := bgEntry.Resource.(gputypes.TextureViewBinding); ok && textureBinding.TextureView != 0 { + view := (*TextureView)(unsafe.Pointer(textureBinding.TextureView)) //nolint:govet // intentional: HAL handle -> concrete type + if layoutEntry.Type == BindingTypeSampledTexture { + bg.sampledTextures = append(bg.sampledTextures, view) + } else { + bg.storageTextures = append(bg.storageTextures, view) + } + } } break } @@ -2693,7 +2744,24 @@ func (d *Device) DestroyRenderBundle(bundle hal.RenderBundle) {} // WaitIdle waits for all GPU work to complete. func (d *Device) WaitIdle() error { - return d.waitForGPU() + if d == nil { + return fmt.Errorf("dx12: device is nil") + } + state := d.queueState + if state != nil { + state.submitMu.Lock() + defer state.submitMu.Unlock() + if state.closed { + return fmt.Errorf("dx12: device queue is closed") + } + } + if err := d.waitForGPU(); err != nil { + return err + } + if state != nil { + state.releaseAllOwnedLocked() + } + return nil } // Destroy releases the device. @@ -2701,16 +2769,46 @@ func (d *Device) Destroy() { if d == nil { return } + state := d.queueState + if state != nil { + state.submitMu.Lock() + defer state.submitMu.Unlock() + if state.closed { + return + } + // Closing while holding submission exclusion prevents new work from + // entering after the terminal idle fence has been enqueued. + state.closed = true + } + if d.raw == nil { + return + } // Clear finalizer to prevent double-free runtime.SetFinalizer(d, nil) - // Wait for GPU to finish before cleanup - _ = d.waitForGPU() + waitErr := d.waitForGPU() + deviceRemoved := false + if waitErr != nil { + deviceRemoved = d.raw.GetDeviceRemovedReason() != nil + } + if state != nil && shouldReleaseTerminalOwnedObjects(waitErr, deviceRemoved) { + state.releaseAllOwnedLocked() + } else if state != nil && (len(state.preambleInFlight) > 0 || len(state.oneShotsInFlight) > 0) { + // A failed event registration/wait does not prove GPU completion. + // Retain the COM objects rather than releasing allocators, command lists, + // or staging resources that may still be in flight. Device removal is the + // only safe exception. + hal.Logger().Warn("dx12: retaining queue-owned GPU objects after ambiguous idle failure", "err", waitErr) + } d.cleanup() } +func shouldReleaseTerminalOwnedObjects(waitErr error, deviceRemoved bool) bool { + return waitErr == nil || deviceRemoved +} + // ----------------------------------------------------------------------------- // Fence implementation // ----------------------------------------------------------------------------- diff --git a/hal/dx12/instance.go b/hal/dx12/instance.go index 80964064..c81e9ff6 100644 --- a/hal/dx12/instance.go +++ b/hal/dx12/instance.go @@ -448,13 +448,14 @@ func (s *Surface) AcquireTexture(_ hal.Fence) (*hal.AcquiredSurfaceTexture, erro // Create surface texture wrapper surfaceTexture := &SurfaceTexture{ - surface: s, - index: index, - resource: bb.resource, - rtvHandle: bb.rtvHandle, - format: s.halFormat, - width: s.width, - height: s.height, + surface: s, + index: index, + resource: bb.resource, + stateOwner: bb.texture, + rtvHandle: bb.rtvHandle, + format: s.halFormat, + width: s.width, + height: s.height, // Note: Suboptimal detection requires DXGI_STATUS_OCCLUDED/DXGI_ERROR_DEVICE_REMOVED checks. suboptimal: false, } diff --git a/hal/dx12/pipeline.go b/hal/dx12/pipeline.go index 64af9c22..08245591 100644 --- a/hal/dx12/pipeline.go +++ b/hal/dx12/pipeline.go @@ -157,7 +157,11 @@ type BindGroup struct { // BindingTypeStorageBuffer against the corresponding buffer bindings. // Used by ComputePassEncoder to track which buffers transition to // UNORDERED_ACCESS after Dispatch() (BUG-DX12-012 fix). - storageBuffers []*Buffer + storageBuffers []*Buffer + readOnlyStorageBuffers []*Buffer + uniformBuffers []*Buffer + sampledTextures []*TextureView + storageTextures []*TextureView } // Destroy releases the bind group resources and recycles descriptor heap slots. diff --git a/hal/dx12/queue.go b/hal/dx12/queue.go index a65363fb..42118b7f 100644 --- a/hal/dx12/queue.go +++ b/hal/dx12/queue.go @@ -8,6 +8,7 @@ package dx12 import ( "fmt" "image" + "sync" "time" "unsafe" @@ -22,42 +23,173 @@ import ( type Queue struct { device *Device raw *d3d12.ID3D12CommandQueue + state *queueState +} + +type queueState struct { + // submitMu serializes state reconciliation with ExecuteCommandLists. The + // public queue already serializes at the wgpu layer, but HAL callers may + // submit directly. It also excludes terminal Device teardown from every + // queue operation that can touch native device state. + submitMu sync.Mutex + closed bool + + preambleInFlight []preambleInFlight + oneShotsInFlight []oneShotInFlight +} + +type preambleInFlight struct { + submission uint64 + allocator *d3d12.ID3D12CommandAllocator + cmdList *d3d12.ID3D12GraphicsCommandList +} + +// oneShotInFlight owns only native objects. Keeping Device/Buffer/encoder +// wrappers here would create a finalizer cycle through Device.queueState. +type oneShotInFlight struct { + submission uint64 + staging *d3d12.ID3D12Resource + destination *d3d12.ID3D12Resource + allocator *d3d12.ID3D12CommandAllocator + cmdList *d3d12.ID3D12GraphicsCommandList +} + +// oneShotWriteOwner is the single local owner while a staged write is being +// prepared. Before submission failures it releases the wrappers normally. +// After EndEncoding, detach transfers the native objects to queueState without +// retaining wrapper back-references to Device. +type oneShotWriteOwner struct { + staging *Buffer + destination *d3d12.ID3D12Resource // explicit extra COM reference + encoder *CommandEncoder + commandBuffer *CommandBuffer +} + +func newOneShotWriteOwner(staging *Buffer, destination *d3d12.ID3D12Resource) *oneShotWriteOwner { + // The caller still owns its Buffer/Texture wrapper and may destroy it as + // soon as Write returns an error. Hold a distinct native reference until + // completion so post-Execute ambiguity cannot free the GPU destination. + destination.AddRef() + return &oneShotWriteOwner{staging: staging, destination: destination} +} + +func (o *oneShotWriteOwner) release() { + if o == nil { + return + } + if o.commandBuffer != nil { + o.commandBuffer.Destroy() + } + if o.encoder != nil { + o.encoder.Destroy() + } + if o.staging != nil { + o.staging.Destroy() + } + if o.destination != nil { + o.destination.Release() + o.destination = nil + } +} + +func (o *oneShotWriteOwner) detach(submission uint64) oneShotInFlight { + retained := oneShotInFlight{submission: submission} + if o.staging != nil { + retained.staging = o.staging.raw + o.staging.raw = nil + o.staging.mappedPointer = nil + } + retained.destination = o.destination + o.destination = nil + if o.commandBuffer != nil { + retained.cmdList = o.commandBuffer.cmdList + o.commandBuffer.cmdList = nil + } + if o.encoder != nil { + retained.allocator = o.encoder.allocator + o.encoder.allocator = nil + } + return retained } // newQueue creates a new Queue wrapping the device's command queue. func newQueue(device *Device) *Queue { - return &Queue{ + state := device.queueState + if state == nil { + state = &queueState{} + device.queueState = state + } + queue := &Queue{ device: device, raw: device.directQueue, + state: state, } + return queue } // Submit submits command buffers to the GPU. // Returns a monotonically increasing submission index for tracking completion. func (q *Queue) Submit(commandBuffers []hal.CommandBuffer) (uint64, error) { + if err := q.lockOpen(); err != nil { + return 0, err + } + defer q.state.submitMu.Unlock() + return q.submitLocked(commandBuffers) +} + +func (q *Queue) submitLocked(commandBuffers []hal.CommandBuffer) (uint64, error) { + completed := q.device.completedFrameFenceValue() + q.releaseCompletedPreambles(completed) + q.releaseCompletedOneShots(completed) + if len(commandBuffers) == 0 { return 0, nil } - // Convert command buffers to D3D12 command lists - cmdLists := make([]*d3d12.ID3D12GraphicsCommandList, len(commandBuffers)) - for i, cb := range commandBuffers { + // Convert command buffers and collect their command-local summaries. + summaries := make([]commandStateSummary, 0) + for _, cb := range commandBuffers { dx12CB, ok := cb.(*CommandBuffer) - if !ok { + if !ok || dx12CB == nil || dx12CB.cmdList == nil { return 0, fmt.Errorf("dx12: command buffer is not a DX12 command buffer") } - cmdLists[i] = dx12CB.cmdList + summaries = append(summaries, dx12CB.stateSummaries...) + } + + scheduled := q.snapshotScheduledStates(summaries) + commands := commandSummariesInOrder(commandBuffers) + preambles, finalStates := planSubmissionState(scheduled, commands) + nativePreambles := make([]preambleInFlight, 0) + executionLists := make([]*d3d12.ID3D12GraphicsCommandList, 0, len(commandBuffers)+len(preambles)) + for i, cb := range commandBuffers { + dx12CB := cb.(*CommandBuffer) + if i < len(preambles) && len(preambles[i]) > 0 { + preamble, err := q.buildPreamble(preambles[i]) + if err != nil { + for _, built := range nativePreambles { + built.cmdList.Release() + built.allocator.Release() + } + return 0, err + } + nativePreambles = append(nativePreambles, preamble) + executionLists = append(executionLists, preamble.cmdList) + dx12CB.preambleBarriers = append(dx12CB.preambleBarriers[:0], preambles[i]...) + } + executionLists = append(executionLists, dx12CB.cmdList) } // Execute command lists submitStart := time.Now() - q.raw.ExecuteCommandLists(uint32(len(cmdLists)), &cmdLists[0]) + q.raw.ExecuteCommandLists(uint32(len(executionLists)), &executionLists[0]) // Check for immediate device removal after execution. if reason := q.device.raw.GetDeviceRemovedReason(); reason != nil { q.device.logDREDBreadcrumbs() + q.retainPreamblesWithoutFence(nativePreambles) return 0, fmt.Errorf("dx12: device removed after ExecuteCommandLists: %w", reason) } + q.commitScheduledStates(finalStates) // Drain debug messages after submission. q.device.DrainDebugMessages() @@ -65,27 +197,235 @@ func (q *Queue) Submit(commandBuffers []hal.CommandBuffer) (uint64, error) { // Signal the frame fence for per-frame allocator tracking and return its value // as the submission index. if err := q.device.signalFrameFence(); err != nil { + // ExecuteCommandLists has already handed these lists to the GPU. A + // failed Signal leaves no completion value with which to prove that + // releasing their allocator/list is safe, so retain them permanently + // rather than risking reuse while GPU work is in flight. + q.retainPreamblesWithoutFence(nativePreambles) return 0, err } + submission := q.device.currentFrameFenceValue() + for i := range nativePreambles { + nativePreambles[i].submission = submission + } + q.state.preambleInFlight = append(q.state.preambleInFlight, nativePreambles...) hal.Logger().Debug("dx12: command list submitted", - "cmdLists", len(cmdLists), + "cmdLists", len(executionLists), "elapsed", time.Since(submitStart), ) - return q.device.currentFrameFenceValue(), nil + return submission, nil +} + +func (q *Queue) lockOpen() error { + if q == nil || q.state == nil { + return fmt.Errorf("dx12: queue is unavailable") + } + q.state.submitMu.Lock() + if q.state.closed { + q.state.submitMu.Unlock() + return fmt.Errorf("dx12: queue is closed") + } + return nil +} + +func commandSummariesInOrder(commandBuffers []hal.CommandBuffer) []commandStateSummary { + var summaries []commandStateSummary + for commandIndex, commandBuffer := range commandBuffers { + cb, ok := commandBuffer.(*CommandBuffer) + if !ok || cb == nil { + continue + } + for _, summary := range cb.stateSummaries { + summary.commandIndex = commandIndex + summaries = append(summaries, summary) + } + } + return summaries +} + +func (q *Queue) snapshotScheduledStates(summaries []commandStateSummary) map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES { + scheduled := make(map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES) + for _, summary := range summaries { + states := scheduled[summary.resource] + if states == nil { + states = make(map[uint32]d3d12.D3D12_RESOURCE_STATES) + scheduled[summary.resource] = states + } + switch resource := summary.resource.(type) { + case *Buffer: + states[0] = resource.scheduledStateSnapshot() + case *Texture: + for _, state := range summary.states { + states[state.subresource] = resource.scheduledStateSnapshot(state.subresource) + } + } + } + return scheduled +} + +func (q *Queue) commitScheduledStates(final map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES) { + for resource, states := range final { + switch typed := resource.(type) { + case *Buffer: + if state, ok := states[0]; ok { + typed.commitScheduledState(state) + } + case *Texture: + allStates := typed.scheduledStateSnapshotAll() + count := int(typed.subresourceCount()) + if len(allStates) < count { + fallback := d3d12.D3D12_RESOURCE_STATE_COMMON + if len(allStates) > 0 { + fallback = allStates[0] + } + oldLen := len(allStates) + allStates = append(allStates, make([]d3d12.D3D12_RESOURCE_STATES, count-oldLen)...) + for i := oldLen; i < len(allStates); i++ { + allStates[i] = fallback + } + } + for subresource, state := range states { + if int(subresource) < len(allStates) { + allStates[subresource] = state + } + } + typed.commitScheduledStates(allStates) + } + } +} + +func (q *Queue) buildPreamble(plans []stateBarrierPlan) (preambleInFlight, error) { + allocator, err := q.device.raw.CreateCommandAllocator(d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT) + if err != nil { + return preambleInFlight{}, fmt.Errorf("dx12: create state preamble allocator: %w", err) + } + list, err := q.device.raw.CreateCommandList(0, d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT, allocator, nil) + if err != nil { + allocator.Release() + return preambleInFlight{}, fmt.Errorf("dx12: create state preamble command list: %w", err) + } + barriers := make([]d3d12.D3D12_RESOURCE_BARRIER, 0, len(plans)) + for _, plan := range plans { + var raw *d3d12.ID3D12Resource + switch resource := plan.resource.(type) { + case *Buffer: + raw = resource.raw + case *Texture: + raw = resource.raw + } + if raw == nil || plan.before == plan.after { + continue + } + barriers = append(barriers, d3d12.NewTransitionBarrier(raw, plan.before, plan.after, plan.subresource)) + } + if len(barriers) > 0 { + list.ResourceBarrier(uint32(len(barriers)), &barriers[0]) + } + if err := list.Close(); err != nil { + list.Release() + allocator.Release() + return preambleInFlight{}, fmt.Errorf("dx12: close state preamble command list: %w", err) + } + return preambleInFlight{allocator: allocator, cmdList: list}, nil +} + +func (q *Queue) releaseCompletedPreambles(completed uint64) { + keep := q.state.preambleInFlight[:0] + for _, preamble := range q.state.preambleInFlight { + if preamble.submission != 0 && preamble.submission <= completed { + releasePreamble(preamble) + continue + } + keep = append(keep, preamble) + } + q.state.preambleInFlight = keep +} + +func releasePreamble(preamble preambleInFlight) { + if preamble.cmdList != nil { + preamble.cmdList.Release() + } + if preamble.allocator != nil { + preamble.allocator.Release() + } +} + +func (q *Queue) releaseCompletedOneShots(completed uint64) { + keep := q.state.oneShotsInFlight[:0] + for _, oneShot := range q.state.oneShotsInFlight { + if oneShot.submission != 0 && oneShot.submission <= completed { + releaseOneShot(oneShot) + continue + } + keep = append(keep, oneShot) + } + q.state.oneShotsInFlight = keep +} + +func releaseOneShot(oneShot oneShotInFlight) { + // The command list must be released before its allocator. + if oneShot.cmdList != nil { + oneShot.cmdList.Release() + } + if oneShot.allocator != nil { + oneShot.allocator.Release() + } + if oneShot.staging != nil { + oneShot.staging.Release() + } + if oneShot.destination != nil { + oneShot.destination.Release() + } +} + +func (s *queueState) releaseAllOwnedLocked() { + for _, preamble := range s.preambleInFlight { + releasePreamble(preamble) + } + s.preambleInFlight = nil + for _, oneShot := range s.oneShotsInFlight { + releaseOneShot(oneShot) + } + s.oneShotsInFlight = nil +} + +func (q *Queue) retainPreamblesWithoutFence(preambles []preambleInFlight) { + for i := range preambles { + // submission=0 is intentionally never released by + // releaseCompletedPreambles because no fence value is trustworthy. + preambles[i].submission = 0 + } + q.state.preambleInFlight = append(q.state.preambleInFlight, preambles...) +} + +func (q *Queue) retainOneShot(owner *oneShotWriteOwner, submission uint64) { + q.state.oneShotsInFlight = append(q.state.oneShotsInFlight, owner.detach(submission)) } // PollCompleted returns the highest submission index known to be completed by the GPU. // Non-blocking. func (q *Queue) PollCompleted() uint64 { - return q.device.completedFrameFenceValue() + if err := q.lockOpen(); err != nil { + return 0 + } + defer q.state.submitMu.Unlock() + completed := q.device.completedFrameFenceValue() + q.releaseCompletedPreambles(completed) + q.releaseCompletedOneShots(completed) + return completed } // WriteBuffer writes data to a buffer immediately. // For upload heap buffers, data is copied directly via CPU mapping. // For default heap buffers, a staging buffer + GPU copy command is used. func (q *Queue) WriteBuffer(buffer hal.Buffer, offset uint64, data []byte) error { + if err := q.lockOpen(); err != nil { + return err + } + defer q.state.submitMu.Unlock() + if buffer == nil { return fmt.Errorf("dx12: WriteBuffer: nil buffer") } @@ -136,6 +476,13 @@ func (q *Queue) writeBufferDirect(buf *Buffer, offset uint64, data []byte) error // writeBufferStaged copies data to a GPU-only (default heap) buffer // via an upload heap staging buffer and CopyBufferRegion command. func (q *Queue) writeBufferStaged(buf *Buffer, offset uint64, data []byte) error { + owner := newOneShotWriteOwner(nil, buf.raw) + defer func() { + if owner != nil { + owner.release() + } + }() + // Create upload heap staging buffer (mapped at creation for immediate write) staging, err := q.device.CreateBuffer(&hal.BufferDescriptor{ Label: "write-buffer-staging", @@ -146,9 +493,8 @@ func (q *Queue) writeBufferStaged(buf *Buffer, offset uint64, data []byte) error if err != nil { return fmt.Errorf("dx12: WriteBuffer: staging buffer creation failed: %w", err) } - defer q.device.DestroyBuffer(staging) - stagingBuf := staging.(*Buffer) + owner.staging = stagingBuf // Copy data to mapped staging buffer dst := unsafe.Slice((*byte)(stagingBuf.mappedPointer), len(data)) @@ -168,40 +514,66 @@ func (q *Queue) writeBufferStaged(buf *Buffer, offset uint64, data []byte) error } encoder := cmdEncoder.(*CommandEncoder) + owner.encoder = encoder if err := encoder.BeginEncoding("write-buffer-copy"); err != nil { return fmt.Errorf("dx12: WriteBuffer: BeginEncoding failed: %w", err) } - // D3D12 auto-promotes buffers from COMMON to COPY_DEST. - // After command list execution, buffers auto-decay back to COMMON. + // Route the one-shot copy through the same command-local tracker as user + // command buffers. The destination may already be in a shader/UAV state. + plans := make([]stateBarrierPlan, 0, 2) + if before, needsBarrier := encoder.stateTracker.transitionBuffer(stagingBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: stagingBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + if before, needsBarrier := encoder.stateTracker.transitionBuffer(buf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: buf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + encoder.emitStateBarrierPlans(plans) encoder.cmdList.CopyBufferRegion(buf.raw, offset, stagingBuf.raw, 0, uint64(len(data))) cmdBuffer, err := encoder.EndEncoding() if err != nil { return fmt.Errorf("dx12: WriteBuffer: EndEncoding failed: %w", err) } + owner.commandBuffer = cmdBuffer.(*CommandBuffer) // Submit and wait for GPU completion. - _, err = q.Submit([]hal.CommandBuffer{cmdBuffer}) + submission, err := q.submitLocked([]hal.CommandBuffer{cmdBuffer}) if err != nil { + q.retainOneShot(owner, 0) + owner = nil return fmt.Errorf("dx12: WriteBuffer: Submit failed: %w", err) } // Block until GPU finishes the copy — staging buffer must remain valid. - if err := q.device.WaitIdle(); err != nil { - hal.Logger().Error("dx12: WaitIdle failed after staged write", "err", err) - } - q.device.FreeCommandBuffer(cmdBuffer) + if err := q.device.waitForGPU(); err != nil { + q.retainOneShot(owner, submission) + owner = nil + return fmt.Errorf("dx12: WriteBuffer: WaitIdle failed: %w", err) + } + // A successful all-queue wait proves completion even for older retained + // objects that had no trustworthy submission fence. + q.state.releaseAllOwnedLocked() return nil } -// d3d12TexturePitchAlignment is the required row pitch alignment for texture data. -const d3d12TexturePitchAlignment = 256 +const ( + // D3D12 placed footprints require a 256-byte row pitch and a 512-byte + // starting offset. Array layers use separate footprints, so their staging + // strides must satisfy both alignments. + d3d12TexturePitchAlignment = 256 + d3d12TexturePlacementAlignment = 512 +) // WriteTexture writes data to a texture immediately. // Creates an upload heap staging buffer, copies data with proper row pitch // alignment, and uses CopyTextureRegion to transfer to the GPU texture. func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal.ImageDataLayout, size *hal.Extent3D) error { - if dst == nil || dst.Texture == nil || layout == nil || len(data) == 0 || size == nil { + if err := q.lockOpen(); err != nil { + return err + } + defer q.state.submitMu.Unlock() + + if dst == nil || dst.Texture == nil || len(data) == 0 || layout == nil || size == nil { return fmt.Errorf("dx12: WriteTexture: invalid arguments") } @@ -210,16 +582,42 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal return fmt.Errorf("dx12: WriteTexture: invalid texture type") } - info, copyLayout, sourceBytesPerRow, blockRows, valid := writeTextureNativeLayout(dstTex, *layout, *size) - if !valid { - return fmt.Errorf("dx12: WriteTexture: layout cannot be represented") + // Calculate layout parameters + bytesPerRow := layout.BytesPerRow + if bytesPerRow == 0 { + bytesPerRow = size.Width * 4 // Assume RGBA8 (4 bytes per pixel) } - rowPitch := copyLayout.BytesPerRow + + rowsPerImage := layout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = size.Height + } + blockHeight := textureFormatBlockHeight(dstTex.format) + sourceBlockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight + copyBlockRows := (size.Height + blockHeight - 1) / blockHeight + depthOrLayers := size.DepthOrArrayLayers if depthOrLayers == 0 { depthOrLayers = 1 } - stagingSize := uint64(rowPitch) * uint64(blockRows) * uint64(depthOrLayers) + + // D3D12 requires RowPitch to be aligned to 256 bytes + alignedRowPitch := (bytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) + + stagingBlockRowsPerImage := copyBlockRows + stagingLayerStride := uint64(alignedRowPitch) * uint64(stagingBlockRowsPerImage) + if dstTex.dimension != gputypes.TextureDimension3D { + stagingLayerStride = (stagingLayerStride + d3d12TexturePlacementAlignment - 1) &^ uint64(d3d12TexturePlacementAlignment-1) + stagingBlockRowsPerImage = uint32(stagingLayerStride / uint64(alignedRowPitch)) + } + stagingRowsPerImage := stagingBlockRowsPerImage * blockHeight + stagingSize := stagingLayerStride * uint64(depthOrLayers) + owner := newOneShotWriteOwner(nil, dstTex.raw) + defer func() { + if owner != nil { + owner.release() + } + }() // Create upload heap staging buffer staging, err := q.device.CreateBuffer(&hal.BufferDescriptor{ @@ -231,26 +629,21 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal if err != nil { return fmt.Errorf("dx12: WriteTexture: CreateBuffer failed: %w", err) } - defer q.device.DestroyBuffer(staging) - stagingBuf := staging.(*Buffer) + owner.staging = stagingBuf - // Repack source rows into a 256-byte aligned, block-row staging layout. - logicalRowBytes := ((uint64(size.Width) + uint64(info.width) - 1) / uint64(info.width)) * uint64(info.bytes) - sourceSliceRows := blockRows - rowsPerImage := copyLayout.RowsPerImage - if rowsPerImage == 0 { - rowsPerImage = size.Height - } + // Repack only the image rows. Input RowsPerImage controls the source stride; + // staging padding is chosen independently to satisfy D3D12 placement rules. + srcOffset := layout.Offset for z := uint32(0); z < depthOrLayers; z++ { - for row := uint32(0); row < blockRows; row++ { - srcStart := textureWriteSourceOffset(copyLayout.Offset, sourceBytesPerRow, sourceSliceRows, z, row) - if srcStart+logicalRowBytes > uint64(len(data)) { - return fmt.Errorf("dx12: WriteTexture: data is smaller than layout") + for row := uint32(0); row < copyBlockRows; row++ { + srcStart := srcOffset + uint64(z)*uint64(bytesPerRow)*uint64(sourceBlockRowsPerImage) + uint64(row)*uint64(bytesPerRow) + if srcStart > uint64(len(data)) || uint64(bytesPerRow) > uint64(len(data))-srcStart { + return fmt.Errorf("dx12: WriteTexture: source data is too short for layer %d block row %d", z, row) } - dstStart := uint64(z)*uint64(rowPitch)*uint64(blockRows) + uint64(row)*uint64(rowPitch) - src := data[srcStart : srcStart+logicalRowBytes] - d := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), len(src)) + dstStart := uint64(z)*stagingLayerStride + uint64(row)*uint64(alignedRowPitch) + src := data[srcStart : srcStart+uint64(bytesPerRow)] + d := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), bytesPerRow) copy(d, src) } } @@ -269,60 +662,92 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal } encoder := cmdEncoder.(*CommandEncoder) + owner.encoder = encoder if err := encoder.BeginEncoding("write-texture-copy"); err != nil { return fmt.Errorf("dx12: WriteTexture: BeginEncoding failed: %w", err) } - // Transition texture to COPY_DEST using tracked current state. - // After first WriteTexture, the texture is in PIXEL_SHADER_RESOURCE state, - // not COMMON — using wrong "before" state causes undefined behavior on DX12. - beforeState := dstTex.currentState - if beforeState != d3d12.D3D12_RESOURCE_STATE_COPY_DEST { - barrierToCopy := d3d12.NewTransitionBarrier( - dstTex.raw, - beforeState, - d3d12.D3D12_RESOURCE_STATE_COPY_DEST, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - encoder.cmdList.ResourceBarrier(1, &barrierToCopy) + plans := make([]stateBarrierPlan, 0, 3) + if before, needsBarrier := encoder.stateTracker.transitionBuffer(stagingBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: stagingBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + stagingLayout := hal.ImageDataLayout{BytesPerRow: alignedRowPitch, RowsPerImage: stagingRowsPerImage} + copyPlans := planBufferTextureCopies(dstTex, *dst, stagingLayout, *size) + for _, copyPlan := range copyPlans { + if before, needsBarrier := encoder.stateTracker.transitionTexture(dstTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { + plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } } + encoder.emitStateBarrierPlans(plans) - regions := []hal.BufferTextureCopy{{ - BufferLayout: hal.ImageDataLayout{Offset: 0, BytesPerRow: rowPitch, RowsPerImage: rowsPerImage}, - TextureBase: *dst, - Size: *size, - }} - encoder.copyBufferToTexture(stagingBuf, dstTex, regions) + for _, copyPlan := range copyPlans { + srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: stagingBuf.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, + } + srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ + Offset: copyPlan.bufferOffset, + Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ + Format: textureFormatToD3D12(dstTex.format), + Width: size.Width, + Height: copyPlan.footprintHeight, + Depth: copyPlan.footprintDepth, + RowPitch: alignedRowPitch, + }, + }) + + dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ + Resource: dstTex.raw, + Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, + } + dstLoc.SetSubresourceIndex(copyPlan.subresource) + srcBox := d3d12.D3D12_BOX{ + Left: 0, + Top: copyPlan.bufferOriginY, + Front: 0, + Right: size.Width, + Bottom: copyPlan.bufferOriginY + size.Height, + Back: copyPlan.footprintDepth, + } + + encoder.cmdList.CopyTextureRegion( + &dstLoc, + dst.Origin.X, dst.Origin.Y, copyPlan.textureOriginZ, + &srcLoc, + &srcBox, + ) + } - // Transition texture to shader resource state (ready for rendering) + // Transition the written subresources to shader resource state (ready for rendering). afterState := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE - barrierToShader := d3d12.NewTransitionBarrier( - dstTex.raw, - d3d12.D3D12_RESOURCE_STATE_COPY_DEST, - afterState, - d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, - ) - encoder.cmdList.ResourceBarrier(1, &barrierToShader) + for _, copyPlan := range copyPlans { + if before, needsBarrier := encoder.stateTracker.transitionTexture(dstTex, copyPlan.subresource, afterState); needsBarrier { + encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: dstTex, subresource: copyPlan.subresource, before: before, after: afterState}}) + } + } // End encoding cmdBuffer, err := encoder.EndEncoding() if err != nil { return fmt.Errorf("dx12: WriteTexture: EndEncoding failed: %w", err) } + owner.commandBuffer = cmdBuffer.(*CommandBuffer) // Submit and wait for GPU completion. - _, err = q.Submit([]hal.CommandBuffer{cmdBuffer}) + submission, err := q.submitLocked([]hal.CommandBuffer{cmdBuffer}) if err != nil { + q.retainOneShot(owner, 0) + owner = nil return fmt.Errorf("dx12: WriteTexture: Submit failed: %w", err) } // Block until GPU finishes the copy — staging buffer must remain valid. - if err := q.device.WaitIdle(); err != nil { - hal.Logger().Error("dx12: WaitIdle failed after texture write", "err", err) + if err := q.device.waitForGPU(); err != nil { + q.retainOneShot(owner, submission) + owner = nil + return fmt.Errorf("dx12: WriteTexture: WaitIdle failed: %w", err) } - q.device.FreeCommandBuffer(cmdBuffer) + q.state.releaseAllOwnedLocked() - // Update tracked state AFTER successful execution. - dstTex.currentState = afterState return nil } @@ -335,6 +760,11 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal // IDXGISwapChain1::Present1 is called with DXGI_PRESENT_PARAMETERS containing // the dirty rects. Otherwise, the standard Present() path is used. func (q *Queue) Present(surface hal.Surface, _ hal.SurfaceTexture, damageRects []image.Rectangle) error { + if err := q.lockOpen(); err != nil { + return err + } + defer q.state.submitMu.Unlock() + dx12Surface, ok := surface.(*Surface) if !ok { return fmt.Errorf("dx12: surface is not a DX12 surface") @@ -423,6 +853,11 @@ func (q *Queue) Present(surface hal.Surface, _ hal.SurfaceTexture, damageRects [ // GetTimestampPeriod returns the timestamp period in nanoseconds. // Used to convert timestamp query results to real time. func (q *Queue) GetTimestampPeriod() float32 { + if err := q.lockOpen(); err != nil { + return 1.0 + } + defer q.state.submitMu.Unlock() + freq, err := q.raw.GetTimestampFrequency() if err != nil || freq == 0 { // Default to 1.0 if we can't get the frequency diff --git a/hal/dx12/resource.go b/hal/dx12/resource.go index 4b6c2038..d6d2f1fa 100644 --- a/hal/dx12/resource.go +++ b/hal/dx12/resource.go @@ -29,21 +29,16 @@ type Buffer struct { device *Device mappedPointer unsafe.Pointer // Non-nil if buffer is currently mapped - // currentState tracks the D3D12 resource state for barrier correctness. - // DX12 requires explicit D3D12_RESOURCE_BARRIER transitions between states - // (e.g., UNORDERED_ACCESS -> COPY_SOURCE). Without tracking, missing barriers - // cause DEVICE_REMOVED on compute -> copy sequences (BUG-DX12-012). - // - // Set to the initial state in CreateBuffer, updated by: - // - ComputePassEncoder.Dispatch() -> UNORDERED_ACCESS for bound storage buffers - // - CopyBufferToBuffer -> COPY_SOURCE/COPY_DEST for src/dst - // - CopyBufferToTexture -> COPY_SOURCE for src - // - CopyTextureToBuffer -> COPY_DEST for dst - // - // Thread safety: buffers are used single-threaded within a command encoder - // recording scope, matching the WebGPU spec (command encoders are not - // thread-safe). No atomic needed. + // currentState mirrors the queue-scheduled state for legacy diagnostics. + // Access it only while holding stateOwner.mu; command recording keeps its + // own state and Queue.Submit reconciles that state with stateOwner. currentState d3d12.D3D12_RESOURCE_STATES + + // stateOwner is the queue-scheduled state. currentState is retained for + // compatibility with older tests and diagnostics, but command recording + // must never mutate it: concurrent command buffers reconcile against this + // owner only at Queue.Submit. + stateOwner resourceStateOwner } // isMappable returns true if the buffer can be mapped for CPU access. @@ -168,15 +163,116 @@ type Texture struct { usage gputypes.TextureUsage device *Device isExternal bool // True for swapchain images (not owned) - currentState d3d12.D3D12_RESOURCE_STATES // Tracked resource state for barrier correctness - pendingRefs int32 // >0 = PendingWrites in-flight, defer Destroy (BUG-DX12-006) - pendingDeath bool // true = Destroy was called while pendingRefs > 0 + currentState d3d12.D3D12_RESOURCE_STATES // Legacy diagnostic state; use stateOwner for tracking. + stateOwner resourceStateOwner + pendingRefs int32 // >0 = PendingWrites in-flight, defer Destroy (BUG-DX12-006) + pendingDeath bool // true = Destroy was called while pendingRefs > 0 } // CurrentUsage returns the texture's tracked D3D12 resource state mapped to gputypes.TextureUsage. // Used by PendingWrites to determine the correct "before" state for copy barriers. func (t *Texture) CurrentUsage() gputypes.TextureUsage { - return d3d12StateToTextureUsage(t.currentState) + return d3d12StateToTextureUsage(t.scheduledStateSnapshot(0)) +} + +func (b *Buffer) scheduledStateSnapshot() d3d12.D3D12_RESOURCE_STATES { + b.stateOwner.mu.Lock() + defer b.stateOwner.mu.Unlock() + if b.stateOwner.bufferStateSet { + return b.stateOwner.bufferState + } + return b.currentState +} + +func (b *Buffer) commitScheduledState(state d3d12.D3D12_RESOURCE_STATES) { + b.stateOwner.mu.Lock() + b.stateOwner.bufferState = state + b.stateOwner.bufferStateSet = true + // Keep the legacy field useful to diagnostics and existing callers, but + // keep it under the same owner lock as the authoritative state. + b.currentState = state + b.stateOwner.mu.Unlock() +} + +func (t *Texture) scheduledStateSnapshot(subresource uint32) d3d12.D3D12_RESOURCE_STATES { + t.stateOwner.mu.Lock() + defer t.stateOwner.mu.Unlock() + if subresource < uint32(len(t.stateOwner.textureStates)) { + return t.stateOwner.textureStates[subresource] + } + return t.currentState +} + +func (t *Texture) scheduledStateSnapshotAll() []d3d12.D3D12_RESOURCE_STATES { + t.stateOwner.mu.Lock() + defer t.stateOwner.mu.Unlock() + if len(t.stateOwner.textureStates) == 0 { + return []d3d12.D3D12_RESOURCE_STATES{t.currentState} + } + return append([]d3d12.D3D12_RESOURCE_STATES(nil), t.stateOwner.textureStates...) +} + +func (t *Texture) commitScheduledStates(states []d3d12.D3D12_RESOURCE_STATES) { + t.stateOwner.mu.Lock() + t.stateOwner.textureStates = append(t.stateOwner.textureStates[:0], states...) + if len(states) > 0 { + // The legacy field is only meaningful for all-subresource views. Keep + // it synchronized with subresource zero under the owner lock. + t.currentState = states[0] + } + t.stateOwner.mu.Unlock() +} + +func (t *Texture) subresourceCount() uint32 { + mipLevels := t.mipLevels + if mipLevels == 0 { + mipLevels = 1 + } + layers := uint32(1) + if t.dimension != gputypes.TextureDimension3D { + layers = t.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + } + return mipLevels * layers * t.planeCount() +} + +// planeCount returns the number of D3D12 subresource planes represented by +// this texture. D3D12 uses a separate subresource range for depth and stencil +// planes of packed depth/stencil formats; mip levels remain the fastest +// varying component within each array layer and plane. +func (t *Texture) planeCount() uint32 { + switch t.format { + case gputypes.TextureFormatDepth24Plus, + gputypes.TextureFormatDepth24PlusStencil8, + gputypes.TextureFormatDepth32FloatStencil8, + gputypes.TextureFormatStencil8: + // DX12 represents Depth24Plus and Stencil8 with D24S8, so retain + // both physical planes even when WebGPU exposes only one aspect. + return 2 + default: + return 1 + } +} + +func (t *Texture) subresourceIndexForPlane(mipLevel, arrayLayer, plane uint32) uint32 { + mipLevels := t.mipLevels + if mipLevels == 0 { + mipLevels = 1 + } + layers := uint32(1) + if t.dimension != gputypes.TextureDimension3D { + layers = t.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + } + return mipLevel + arrayLayer*mipLevels + plane*mipLevels*layers +} + +func (t *Texture) subresourceIndex(mipLevel, arrayLayer uint32) uint32 { + return t.subresourceIndexForPlane(mipLevel, arrayLayer, 0) } // AddPendingRef increments the pending reference count. @@ -245,24 +341,26 @@ func (t *Texture) Dimension() gputypes.TextureDimension { // TextureView implements hal.TextureView for DirectX 12. type TextureView struct { - texture *Texture - format gputypes.TextureFormat - dimension gputypes.TextureViewDimension - aspect gputypes.TextureAspect - baseMip uint32 - mipCount uint32 - baseLayer uint32 - layerCount uint32 - device *Device - srvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Shader resource view (for sampling) - rtvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Render target view - dsvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Depth stencil view - hasSRV bool - hasRTV bool - hasDSV bool - srvHeapIndex uint32 - rtvHeapIndex uint32 - dsvHeapIndex uint32 + texture *Texture + format gputypes.TextureFormat + dimension gputypes.TextureViewDimension + aspect gputypes.TextureAspect + baseMip uint32 + mipCount uint32 + baseLayer uint32 + layerCount uint32 + device *Device + srvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Shader resource view (for sampling) + rtvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Render target view + dsvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // Depth stencil view + dsvHandles [4]d3d12.D3D12_CPU_DESCRIPTOR_HANDLE // DSV variants keyed by D3D12_DSV_FLAGS + hasSRV bool + hasRTV bool + hasDSV bool + hasDSVVariants [4]bool + srvHeapIndex uint32 + rtvHeapIndex uint32 + dsvHeapIndex [4]uint32 } // Destroy releases the texture view resources and recycles descriptor heap slots. @@ -277,12 +375,17 @@ func (v *TextureView) Destroy() { v.device.rtvHeap.Free(v.rtvHeapIndex, 1) } if v.hasDSV && v.device.dsvHeap != nil { - v.device.dsvHeap.Free(v.dsvHeapIndex, 1) + for i := range v.dsvHeapIndex { + if v.hasDSVVariants[i] { + v.device.dsvHeap.Free(v.dsvHeapIndex[i], 1) + } + } } } v.hasSRV = false v.hasRTV = false v.hasDSV = false + v.hasDSVVariants = [4]bool{} } // Texture returns the parent texture. @@ -306,6 +409,14 @@ func (v *TextureView) DSVHandle() d3d12.D3D12_CPU_DESCRIPTOR_HANDLE { return v.dsvHandle } +func (v *TextureView) dsvHandleForFlags(flags d3d12.D3D12_DSV_FLAGS) *d3d12.D3D12_CPU_DESCRIPTOR_HANDLE { + index := uint32(flags) & 3 + if index < uint32(len(v.dsvHandles)) && v.hasDSVVariants[index] { + return &v.dsvHandles[index] + } + return &v.dsvHandle +} + // SRVHandle returns the shader resource view descriptor handle. func (v *TextureView) SRVHandle() d3d12.D3D12_CPU_DESCRIPTOR_HANDLE { return v.srvHandle diff --git a/hal/dx12/state_tracker.go b/hal/dx12/state_tracker.go new file mode 100644 index 00000000..fe7442b8 --- /dev/null +++ b/hal/dx12/state_tracker.go @@ -0,0 +1,585 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "sort" + "sync" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/dx12/d3d12" +) + +// subresourceState is the command-local state of one resource subresource. +// The initial state is captured on first use; current is changed only while +// recording the command list. Neither field represents state currently owned +// by the GPU until Queue.Submit reconciles and commits the summary. +type subresourceState struct { + subresource uint32 + initial d3d12.D3D12_RESOURCE_STATES + current d3d12.D3D12_RESOURCE_STATES +} + +// commandStateSummary is the state hand-off carried by a CommandBuffer. +// Resource is deliberately opaque here so the pure tracker can be tested +// without creating D3D12 resources; the queue type-switches it back to +// *Buffer/*Texture when emitting native barriers and committing state. +type commandStateSummary struct { + commandIndex int + resource any + states []subresourceState +} + +// stateBarrierPlan describes a queue preamble transition without any native +// D3D12 handle. Queue.Submit resolves Resource to its raw resource when it +// builds a temporary preamble command list. +type stateBarrierPlan struct { + resource any + subresource uint32 + before d3d12.D3D12_RESOURCE_STATES + after d3d12.D3D12_RESOURCE_STATES +} + +// commandStateTracker owns state summaries for one command encoder. The map +// key is a resource pointer in production and a comparable test value in the +// pure tracker tests. +type commandStateTracker struct { + resources map[any]map[uint32]*subresourceState +} + +func newCommandStateTracker() commandStateTracker { + return commandStateTracker{resources: make(map[any]map[uint32]*subresourceState)} +} + +func (t *commandStateTracker) reset() { + t.resources = make(map[any]map[uint32]*subresourceState) +} + +// transition records a local transition and reports whether an inline barrier +// is required. It always uses explicit barriers; buffer callers may opt into +// DX12's COMMON implicit-promotion rules through transitionBuffer below. +func (t *commandStateTracker) transition(resource any, subresource uint32, initial, target d3d12.D3D12_RESOURCE_STATES) (before d3d12.D3D12_RESOURCE_STATES, barrier bool) { + return t.transitionWithPolicy(resource, subresource, initial, target, true) +} + +func (t *commandStateTracker) transitionWithPolicy(resource any, subresource uint32, initial, target d3d12.D3D12_RESOURCE_STATES, explicit bool) (before d3d12.D3D12_RESOURCE_STATES, barrier bool) { + if t.resources == nil { + t.resources = make(map[any]map[uint32]*subresourceState) + } + bySubresource := t.resources[resource] + if bySubresource == nil { + bySubresource = make(map[uint32]*subresourceState) + t.resources[resource] = bySubresource + } + state := bySubresource[subresource] + if state == nil { + state = &subresourceState{subresource: subresource, initial: initial, current: initial} + bySubresource[subresource] = state + } + before = state.current + if before == target { + return before, false + } + state.current = target + return before, explicit +} + +func (t *commandStateTracker) summary() []commandStateSummary { + if len(t.resources) == 0 { + return nil + } + keys := make([]any, 0, len(t.resources)) + for key := range t.resources { + keys = append(keys, key) + } + // Pointer keys are not ordered, but deterministic ordering is useful for + // tests and for stable debug output. Keep the sort limited to comparable + // stringified keys; queue semantics never depend on this order. + sort.SliceStable(keys, func(i, j int) bool { + return stateResourceSortKey(keys[i]) < stateResourceSortKey(keys[j]) + }) + + result := make([]commandStateSummary, 0, len(keys)) + for _, key := range keys { + bySubresource := t.resources[key] + subresources := make([]uint32, 0, len(bySubresource)) + for subresource := range bySubresource { + subresources = append(subresources, subresource) + } + sort.Slice(subresources, func(i, j int) bool { return subresources[i] < subresources[j] }) + states := make([]subresourceState, 0, len(subresources)) + for _, subresource := range subresources { + states = append(states, *bySubresource[subresource]) + } + result = append(result, commandStateSummary{resource: key, states: states}) + } + return result +} + +func stateResourceSortKey(resource any) string { + if value, ok := resource.(string); ok { + return value + } + return "resource" +} + +// planSubmissionState computes queue preambles and the state that would be +// scheduled after all command buffers execute in order. The input map is +// never mutated, so a failed ExecuteCommandLists can leave ownership intact. +func planSubmissionState(scheduled map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES, commands []commandStateSummary) ([][]stateBarrierPlan, map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES) { + current := cloneScheduledState(scheduled) + commandCount := 0 + for _, command := range commands { + if command.commandIndex >= commandCount { + commandCount = command.commandIndex + 1 + } + } + preambles := make([][]stateBarrierPlan, commandCount) + + for _, command := range commands { + resourceStates := current[command.resource] + if resourceStates == nil { + resourceStates = make(map[uint32]d3d12.D3D12_RESOURCE_STATES) + current[command.resource] = resourceStates + } + for _, state := range command.states { + actual, exists := resourceStates[state.subresource] + if !exists { + actual = state.initial + } + if actual != state.initial { + preambles[command.commandIndex] = append(preambles[command.commandIndex], stateBarrierPlan{ + resource: command.resource, + subresource: state.subresource, + before: actual, + after: state.initial, + }) + } + resourceStates[state.subresource] = state.current + } + } + + return preambles, current +} + +func cloneScheduledState(scheduled map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES) map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES { + clone := make(map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES, len(scheduled)) + for resource, states := range scheduled { + statesClone := make(map[uint32]d3d12.D3D12_RESOURCE_STATES, len(states)) + for subresource, state := range states { + statesClone[subresource] = state + } + clone[resource] = statesClone + } + return clone +} + +func (t *commandStateTracker) transitionBuffer(buffer *Buffer, target d3d12.D3D12_RESOURCE_STATES) (d3d12.D3D12_RESOURCE_STATES, bool) { + initial := buffer.scheduledStateSnapshot() + current := t.currentState(buffer, 0, initial) + return t.transitionWithPolicy(buffer, 0, initial, target, needsExplicitBarrier(current, target)) +} + +func (t *commandStateTracker) transitionTexture(texture *Texture, subresource uint32, target d3d12.D3D12_RESOURCE_STATES) (d3d12.D3D12_RESOURCE_STATES, bool) { + initial := texture.scheduledStateSnapshot(subresource) + return t.transition(texture, subresource, initial, target) +} + +func (t *commandStateTracker) transitionBufferRead(buffer *Buffer, requested d3d12.D3D12_RESOURCE_STATES) (before, target d3d12.D3D12_RESOURCE_STATES, barrier bool) { + initial := buffer.scheduledStateSnapshot() + current := t.currentState(buffer, 0, initial) + target = mergeCompatibleReadStates(current, requested) + before, barrier = t.transitionWithPolicy(buffer, 0, initial, target, needsExplicitBarrier(current, target)) + return before, target, barrier +} + +func (t *commandStateTracker) transitionTextureRead(texture *Texture, subresource uint32, requested d3d12.D3D12_RESOURCE_STATES) (before, target d3d12.D3D12_RESOURCE_STATES, barrier bool) { + initial := texture.scheduledStateSnapshot(subresource) + current := t.currentState(texture, subresource, initial) + target = mergeCompatibleReadStates(current, requested) + before, barrier = t.transition(texture, subresource, initial, target) + return before, target, barrier +} + +func (t *commandStateTracker) currentState(resource any, subresource uint32, fallback d3d12.D3D12_RESOURCE_STATES) d3d12.D3D12_RESOURCE_STATES { + if states := t.resources[resource]; states != nil { + if state := states[subresource]; state != nil { + return state.current + } + } + return fallback +} + +func mergeCompatibleReadStates(current, requested d3d12.D3D12_RESOURCE_STATES) d3d12.D3D12_RESOURCE_STATES { + if current == d3d12.D3D12_RESOURCE_STATE_COMMON || requested == d3d12.D3D12_RESOURCE_STATE_COMMON { + return requested + } + if current&d3d12WriteStates != 0 || requested&d3d12WriteStates != 0 { + return requested + } + return current | requested +} + +const d3d12WriteStates = d3d12.D3D12_RESOURCE_STATE_RENDER_TARGET | + d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS | + d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE | + d3d12.D3D12_RESOURCE_STATE_STREAM_OUT | + d3d12.D3D12_RESOURCE_STATE_COPY_DEST | + d3d12.D3D12_RESOURCE_STATE_RESOLVE_DEST | + d3d12.D3D12_RESOURCE_STATE_VIDEO_DECODE_WRITE | + d3d12.D3D12_RESOURCE_STATE_VIDEO_PROCESS_WRITE | + d3d12.D3D12_RESOURCE_STATE_VIDEO_ENCODE_WRITE + +func (t *commandStateTracker) transitionTextureRange(texture *Texture, subresources []uint32, target d3d12.D3D12_RESOURCE_STATES) []stateBarrierPlan { + plans := make([]stateBarrierPlan, 0, len(subresources)) + for _, subresource := range subresources { + before, barrier := t.transitionTexture(texture, subresource, target) + if barrier { + plans = append(plans, stateBarrierPlan{resource: texture, subresource: subresource, before: before, after: target}) + } + } + return plans +} + +// textureSubresource identifies both the native subresource index and its +// logical plane. Keeping the plane alongside the index lets depth/stencil +// attachment policy choose independent states for depth and stencil while +// callers that only need native barriers can continue using uint32 indexes. +type textureSubresource struct { + subresource uint32 + plane uint32 +} + +func textureAspectPlanes(texture *Texture, aspect gputypes.TextureAspect) []uint32 { + if texture == nil { + return nil + } + if texture.planeCount() < 2 { + return []uint32{0} + } + if texture.format == gputypes.TextureFormatStencil8 { + if aspect == gputypes.TextureAspectDepthOnly { + return nil + } + // Stencil8 is backed by D24S8 on DX12, but only physical plane 1 + // belongs to its WebGPU interface. + return []uint32{1} + } + if texture.format == gputypes.TextureFormatDepth24Plus { + if aspect == gputypes.TextureAspectStencilOnly { + return nil + } + // Depth24Plus is also backed by D24S8 but exposes only depth. + return []uint32{0} + } + switch aspect { + case gputypes.TextureAspectDepthOnly: + return []uint32{0} + case gputypes.TextureAspectStencilOnly: + return []uint32{1} + default: + // Undefined follows WebGPU's default/all-aspects behavior. For a + // packed D24S8/D32S8 resource this selects both physical planes. + return []uint32{0, 1} + } +} + +func textureRangeSubresourcePlanes(texture *Texture, r hal.TextureRange) []textureSubresource { + return textureRangeSubresourcePlanesForPlanes(texture, r, textureAspectPlanes(texture, r.Aspect)) +} + +func textureRangeSubresourcePlanesForPlanes(texture *Texture, r hal.TextureRange, planes []uint32) []textureSubresource { + if texture == nil { + return nil + } + mipLevels := texture.mipLevels + if mipLevels == 0 { + mipLevels = 1 + } + baseMip := r.BaseMipLevel + mipCount := r.MipLevelCount + if mipCount == 0 { + if baseMip >= mipLevels { + return nil + } + mipCount = mipLevels - baseMip + } + if baseMip >= mipLevels || mipCount > mipLevels-baseMip { + return nil + } + + layers := uint32(1) + if texture.dimension != gputypes.TextureDimension3D { + layers = texture.size.DepthOrArrayLayers + if layers == 0 { + layers = 1 + } + } + baseLayer := r.BaseArrayLayer + layerCount := r.ArrayLayerCount + if texture.dimension == gputypes.TextureDimension3D { + // Origin.Z is a texel coordinate for 3D copies; it never selects an + // array layer or alters the subresource identity. + baseLayer = 0 + layerCount = 1 + } + if layerCount == 0 { + if baseLayer >= layers { + return nil + } + layerCount = layers - baseLayer + } + if baseLayer >= layers || layerCount > layers-baseLayer { + return nil + } + + result := make([]textureSubresource, 0, mipCount*layerCount*uint32(len(planes))) + for _, plane := range planes { + for layer := uint32(0); layer < layerCount; layer++ { + for mip := uint32(0); mip < mipCount; mip++ { + arrayLayer := baseLayer + layer + result = append(result, textureSubresource{ + subresource: texture.subresourceIndexForPlane(baseMip+mip, arrayLayer, plane), + plane: plane, + }) + } + } + } + return result +} + +func textureRangeSubresources(texture *Texture, r hal.TextureRange) []uint32 { + planes := textureRangeSubresourcePlanes(texture, r) + result := make([]uint32, 0, len(planes)) + for _, plane := range planes { + result = append(result, plane.subresource) + } + return result +} + +type bufferTextureCopyPlan struct { + subresource uint32 + bufferOffset uint64 + bufferOriginY uint32 + footprintHeight uint32 + footprintDepth uint32 + textureOriginZ uint32 +} + +// planBufferTextureCopies converts WebGPU's depth-or-array-layer copy into +// the native operations D3D12 requires. A 3D texture keeps one subresource +// but emits a depth-1 footprint per Z slice; a 2D array emits one footprint +// and one texture subresource per layer. +func planBufferTextureCopies(texture *Texture, copy hal.ImageCopyTexture, layout hal.ImageDataLayout, size hal.Extent3D) []bufferTextureCopyPlan { + if texture == nil { + return nil + } + depthOrLayers := size.DepthOrArrayLayers + if depthOrLayers == 0 { + depthOrLayers = 1 + } + planes := textureAspectPlanes(texture, copy.Aspect) + blockHeight := textureFormatBlockHeight(texture.format) + if texture.dimension == gputypes.TextureDimension3D { + rowsPerImage := layout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = size.Height + } + blockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight + sliceStride := uint64(layout.BytesPerRow) * uint64(blockRowsPerImage) + plans := make([]bufferTextureCopyPlan, 0, int(depthOrLayers)*len(planes)) + for _, plane := range planes { + for z := uint32(0); z < depthOrLayers; z++ { + logicalOffset := layout.Offset + uint64(z)*sliceStride + placedOffset, bufferOriginY, footprintHeight := planPlacedBufferSlice(logicalOffset, layout.BytesPerRow, blockHeight, size.Height) + plans = append(plans, bufferTextureCopyPlan{ + subresource: texture.subresourceIndexForPlane(copy.MipLevel, 0, plane), + bufferOffset: placedOffset, + bufferOriginY: bufferOriginY, + footprintHeight: footprintHeight, + footprintDepth: 1, + textureOriginZ: copy.Origin.Z + z, + }) + } + } + return plans + } + + rowsPerImage := layout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = size.Height + } + blockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight + layerStride := uint64(layout.BytesPerRow) * uint64(blockRowsPerImage) + plans := make([]bufferTextureCopyPlan, 0, int(depthOrLayers)*len(planes)) + for _, plane := range planes { + for layer := uint32(0); layer < depthOrLayers; layer++ { + logicalOffset := layout.Offset + uint64(layer)*layerStride + placedOffset, bufferOriginY, footprintHeight := planPlacedBufferSlice(logicalOffset, layout.BytesPerRow, blockHeight, size.Height) + plans = append(plans, bufferTextureCopyPlan{ + subresource: texture.subresourceIndexForPlane(copy.MipLevel, copy.Origin.Z+layer, plane), + bufferOffset: placedOffset, + bufferOriginY: bufferOriginY, + footprintHeight: footprintHeight, + footprintDepth: 1, + textureOriginZ: 0, + }) + } + } + return plans +} + +// planPlacedBufferSlice maps one logical image/depth slice to a legacy D3D12 +// placed footprint. Rewinding by whole block rows preserves the exact data +// address while meeting the 512-byte placement requirement. +func planPlacedBufferSlice(logicalOffset uint64, bytesPerRow, blockHeight, copyHeight uint32) (placedOffset uint64, bufferOriginY, footprintHeight uint32) { + placedOffset = logicalOffset + for bytesPerRow != 0 && placedOffset%d3d12TexturePlacementAlignment != 0 && placedOffset >= uint64(bytesPerRow) { + placedOffset -= uint64(bytesPerRow) + bufferOriginY += blockHeight + } + return placedOffset, bufferOriginY, copyHeight + bufferOriginY +} + +func textureFormatBlockHeight(format gputypes.TextureFormat) uint32 { + switch format { + case gputypes.TextureFormatBC1RGBAUnorm, + gputypes.TextureFormatBC1RGBAUnormSrgb, + gputypes.TextureFormatBC2RGBAUnorm, + gputypes.TextureFormatBC2RGBAUnormSrgb, + gputypes.TextureFormatBC3RGBAUnorm, + gputypes.TextureFormatBC3RGBAUnormSrgb, + gputypes.TextureFormatBC4RUnorm, + gputypes.TextureFormatBC4RSnorm, + gputypes.TextureFormatBC5RGUnorm, + gputypes.TextureFormatBC5RGSnorm, + gputypes.TextureFormatBC6HRGBUfloat, + gputypes.TextureFormatBC6HRGBFloat, + gputypes.TextureFormatBC7RGBAUnorm, + gputypes.TextureFormatBC7RGBAUnormSrgb: + return 4 + default: + return 1 + } +} + +type textureTextureCopyPlan struct { + srcSubresource uint32 + dstSubresource uint32 + srcFront uint32 + srcBack uint32 + dstZ uint32 +} + +// planTextureTextureCopies applies D3D12's distinct 3D-volume and 2D-array +// copy models while pairing the selected source and destination planes. +func planTextureTextureCopies(src, dst *Texture, copy hal.TextureCopy) []textureTextureCopyPlan { + if src == nil || dst == nil { + return nil + } + srcPlanes := textureAspectPlanes(src, copy.SrcBase.Aspect) + dstPlanes := textureAspectPlanes(dst, copy.DstBase.Aspect) + planeCount := len(srcPlanes) + if len(dstPlanes) < planeCount { + planeCount = len(dstPlanes) + } + if planeCount == 0 { + return nil + } + depthOrLayers := copy.Size.DepthOrArrayLayers + if depthOrLayers == 0 { + depthOrLayers = 1 + } + + if src.dimension == gputypes.TextureDimension3D || dst.dimension == gputypes.TextureDimension3D { + // Core validation requires matching texture dimensions. Refuse to + // invent layer/depth semantics for direct HAL callers that bypass it. + if src.dimension != dst.dimension { + return nil + } + plans := make([]textureTextureCopyPlan, 0, planeCount) + for plane := 0; plane < planeCount; plane++ { + plans = append(plans, textureTextureCopyPlan{ + srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, 0, srcPlanes[plane]), + dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, 0, dstPlanes[plane]), + srcFront: copy.SrcBase.Origin.Z, + srcBack: copy.SrcBase.Origin.Z + depthOrLayers, + dstZ: copy.DstBase.Origin.Z, + }) + } + return plans + } + + plans := make([]textureTextureCopyPlan, 0, planeCount*int(depthOrLayers)) + for plane := 0; plane < planeCount; plane++ { + for layer := uint32(0); layer < depthOrLayers; layer++ { + plans = append(plans, textureTextureCopyPlan{ + srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, copy.SrcBase.Origin.Z+layer, srcPlanes[plane]), + dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, copy.DstBase.Origin.Z+layer, dstPlanes[plane]), + srcFront: 0, + srcBack: 1, + dstZ: 0, + }) + } + } + return plans +} + +// depthStencilPlaneState returns the D3D12 state for one depth/stencil plane +// under a render-pass attachment's independent read-only flags. +func depthStencilPlaneState(plane uint32, depthReadOnly, stencilReadOnly, shaderReadable bool) d3d12.D3D12_RESOURCE_STATES { + if plane == 0 { + if !depthReadOnly { + return d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE + } + state := d3d12.D3D12_RESOURCE_STATE_DEPTH_READ + if shaderReadable { + state |= d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE + } + return state + } + if stencilReadOnly { + return d3d12.D3D12_RESOURCE_STATE_DEPTH_READ + } + return d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE +} + +// depthStencilReadOnlyFlags protects physical planes that a WebGPU view does +// not expose. DX12 still sees those planes in the packed DSV and otherwise +// requires them to be in a writable depth/stencil state. +func depthStencilReadOnlyFlags(format gputypes.TextureFormat, aspect gputypes.TextureAspect, depthReadOnly, stencilReadOnly bool) (bool, bool) { + switch format { + case gputypes.TextureFormatStencil8: + depthReadOnly = true + case gputypes.TextureFormatDepth24Plus: + stencilReadOnly = true + case gputypes.TextureFormatDepth24PlusStencil8, gputypes.TextureFormatDepth32FloatStencil8: + if aspect == gputypes.TextureAspectStencilOnly { + depthReadOnly = true + } + if aspect == gputypes.TextureAspectDepthOnly { + stencilReadOnly = true + } + } + return depthReadOnly, stencilReadOnly +} + +// resourceStateOwner is embedded by Buffer and Texture. It is intentionally +// tiny: queue state is the only mutable state owned by the resource, while +// command-local state lives in commandStateTracker. +type resourceStateOwner struct { + mu sync.Mutex + bufferState d3d12.D3D12_RESOURCE_STATES + bufferStateSet bool + textureStates []d3d12.D3D12_RESOURCE_STATES +} + +func (o *resourceStateOwner) setTextureStates(states []d3d12.D3D12_RESOURCE_STATES) { + o.mu.Lock() + o.textureStates = append(o.textureStates[:0], states...) + o.mu.Unlock() +} diff --git a/hal/dx12/state_tracker_test.go b/hal/dx12/state_tracker_test.go new file mode 100644 index 00000000..4c6be7d9 --- /dev/null +++ b/hal/dx12/state_tracker_test.go @@ -0,0 +1,672 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "errors" + "sync" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/dx12/d3d12" +) + +func TestCommandStateTrackerRetainsFirstUseAndEmitsInlineTransitions(t *testing.T) { + const resource = "texture" + tracker := newCommandStateTracker() + + if before, barrier := tracker.transition(resource, 3, d3d12.D3D12_RESOURCE_STATE_COMMON, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); before != d3d12.D3D12_RESOURCE_STATE_COMMON || !barrier { + t.Fatalf("first use = (%d, %v), want (COMMON, true)", before, barrier) + } + if before, barrier := tracker.transition(resource, 3, d3d12.D3D12_RESOURCE_STATE_COMMON, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); before != d3d12.D3D12_RESOURCE_STATE_COPY_DEST || barrier { + t.Fatalf("same local state = (%d, %v), want (COPY_DEST, false)", before, barrier) + } + if before, barrier := tracker.transition(resource, 3, d3d12.D3D12_RESOURCE_STATE_COMMON, d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); before != d3d12.D3D12_RESOURCE_STATE_COPY_DEST || !barrier { + t.Fatalf("inline transition = (%d, %v), want (COPY_DEST, true)", before, barrier) + } + + summary := tracker.summary() + if len(summary) != 1 || len(summary[0].states) != 1 { + t.Fatalf("summary = %#v, want one resource/subresource", summary) + } + state := summary[0].states[0] + if state.subresource != 3 || state.initial != d3d12.D3D12_RESOURCE_STATE_COMMON || state.current != d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE { + t.Fatalf("state summary = %#v, want initial COMMON/final PIXEL_SHADER_RESOURCE", state) + } +} + +func TestMergeCompatibleReadStates(t *testing.T) { + shaderRead := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE + tests := []struct { + name string + current d3d12.D3D12_RESOURCE_STATES + requested d3d12.D3D12_RESOURCE_STATES + want d3d12.D3D12_RESOURCE_STATES + }{ + { + name: "uniform and storage reads coexist", + current: d3d12.D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER, + requested: shaderRead, + want: d3d12.D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER | shaderRead, + }, + { + name: "read-only depth remains attached while sampled", + current: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ, + requested: shaderRead, + want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ | shaderRead, + }, + { + name: "write state is replaced by read state", + current: d3d12.D3D12_RESOURCE_STATE_UNORDERED_ACCESS, + requested: shaderRead, + want: shaderRead, + }, + { + name: "read state is replaced by write state", + current: shaderRead, + requested: d3d12.D3D12_RESOURCE_STATE_COPY_DEST, + want: d3d12.D3D12_RESOURCE_STATE_COPY_DEST, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := mergeCompatibleReadStates(test.current, test.requested); got != test.want { + t.Fatalf("merged state = %d, want %d", got, test.want) + } + }) + } +} + +func TestTextureSubresourceCountTreats3DDepthAsOneSubresource(t *testing.T) { + array := &Texture{dimension: gputypes.TextureDimension2D, mipLevels: 4, size: hal.Extent3D{DepthOrArrayLayers: 3}} + _3d := &Texture{dimension: gputypes.TextureDimension3D, mipLevels: 4, size: hal.Extent3D{DepthOrArrayLayers: 16}} + + if got := array.subresourceCount(); got != 12 { + t.Fatalf("array subresource count = %d, want 12", got) + } + if got := _3d.subresourceCount(); got != 4 { + t.Fatalf("3D subresource count = %d, want 4", got) + } +} + +func TestDepthStencilSubresourcePlanes(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatDepth24PlusStencil8, + dimension: gputypes.TextureDimension2D, + mipLevels: 2, + size: hal.Extent3D{DepthOrArrayLayers: 2}, + } + if got := texture.subresourceCount(); got != 8 { + t.Fatalf("depth/stencil subresource count = %d, want 8", got) + } + + depth := textureRangeSubresources(texture, hal.TextureRange{ + Aspect: gputypes.TextureAspectDepthOnly, + MipLevelCount: 2, + ArrayLayerCount: 2, + }) + stencil := textureRangeSubresources(texture, hal.TextureRange{ + Aspect: gputypes.TextureAspectStencilOnly, + MipLevelCount: 2, + ArrayLayerCount: 2, + }) + all := textureRangeSubresources(texture, hal.TextureRange{ + Aspect: gputypes.TextureAspectAll, + MipLevelCount: 2, + ArrayLayerCount: 2, + }) + if want := []uint32{0, 1, 2, 3}; !equalUint32s(depth, want) { + t.Fatalf("depth plane indexes = %v, want %v", depth, want) + } + if want := []uint32{4, 5, 6, 7}; !equalUint32s(stencil, want) { + t.Fatalf("stencil plane indexes = %v, want %v", stencil, want) + } + if len(all) != 8 { + t.Fatalf("all-aspect indexes = %v, want 8 entries", all) + } + + stencilOnlyTexture := &Texture{ + format: gputypes.TextureFormatStencil8, + dimension: gputypes.TextureDimension2D, + mipLevels: 1, + size: hal.Extent3D{DepthOrArrayLayers: 2}, + } + standaloneStencil := textureRangeSubresources(stencilOnlyTexture, hal.TextureRange{ + Aspect: gputypes.TextureAspectAll, + MipLevelCount: 1, + ArrayLayerCount: 2, + }) + if want := []uint32{2, 3}; !equalUint32s(standaloneStencil, want) { + t.Fatalf("standalone stencil indexes = %v, want physical plane-1 indexes %v", standaloneStencil, want) + } +} + +func TestDepthStencilAttachmentIncludesPackedCompanionPlane(t *testing.T) { + view := &TextureView{ + texture: &Texture{ + format: gputypes.TextureFormatStencil8, + dimension: gputypes.TextureDimension2D, + mipLevels: 1, + size: hal.Extent3D{DepthOrArrayLayers: 2}, + }, + aspect: gputypes.TextureAspectAll, + mipCount: 1, + layerCount: 2, + } + selective := textureViewSubresourcePlanes(view) + physical := textureViewPhysicalSubresourcePlanes(view) + if want := []uint32{2, 3}; !equalSubresourceIndexes(selective, want) { + t.Fatalf("selective stencil subresources = %#v, want %v", selective, want) + } + if want := []uint32{0, 1, 2, 3}; !equalSubresourceIndexes(physical, want) { + t.Fatalf("physical DSV subresources = %#v, want %v", physical, want) + } +} + +func TestDepthStencilPlaneStates(t *testing.T) { + shaderRead := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE + tests := []struct { + name string + plane uint32 + depthReadOnly bool + stencilReadOnly bool + shaderReadable bool + want d3d12.D3D12_RESOURCE_STATES + }{ + {name: "depth read stencil write", plane: 0, depthReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ | shaderRead}, + {name: "depth write stencil read", plane: 1, stencilReadOnly: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ}, + {name: "depth write stencil write", plane: 0, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE}, + {name: "stencil write", plane: 1, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE}, + {name: "both read", plane: 0, depthReadOnly: true, stencilReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ | shaderRead}, + {name: "both read stencil plane", plane: 1, depthReadOnly: true, stencilReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := depthStencilPlaneState(test.plane, test.depthReadOnly, test.stencilReadOnly, test.shaderReadable); got != test.want { + t.Fatalf("plane state = %d, want %d", got, test.want) + } + }) + } +} + +func TestDepthStencilReadOnlyFlagsProtectUnexposedPlanes(t *testing.T) { + tests := []struct { + name string + format gputypes.TextureFormat + aspect gputypes.TextureAspect + depth bool + stencil bool + wantDepth bool + wantStencil bool + }{ + {name: "standalone stencil protects backing depth", format: gputypes.TextureFormatStencil8, aspect: gputypes.TextureAspectAll, wantDepth: true}, + {name: "depth24plus protects backing stencil", format: gputypes.TextureFormatDepth24Plus, aspect: gputypes.TextureAspectAll, wantStencil: true}, + {name: "stencil aspect protects depth", format: gputypes.TextureFormatDepth24PlusStencil8, aspect: gputypes.TextureAspectStencilOnly, wantDepth: true}, + {name: "depth aspect protects stencil", format: gputypes.TextureFormatDepth32FloatStencil8, aspect: gputypes.TextureAspectDepthOnly, wantStencil: true}, + {name: "single-plane depth has no companion stencil", format: gputypes.TextureFormatDepth32Float, aspect: gputypes.TextureAspectDepthOnly}, + {name: "explicit flags remain set", format: gputypes.TextureFormatDepth24PlusStencil8, aspect: gputypes.TextureAspectAll, depth: true, stencil: true, wantDepth: true, wantStencil: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + depth, stencil := depthStencilReadOnlyFlags(test.format, test.aspect, test.depth, test.stencil) + if depth != test.wantDepth || stencil != test.wantStencil { + t.Fatalf("read-only flags = (%v, %v), want (%v, %v)", depth, stencil, test.wantDepth, test.wantStencil) + } + }) + } +} + +func TestPlanBufferTextureCopiesSplitsArrayLayers(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatRGBA8Unorm, + dimension: gputypes.TextureDimension2D, + mipLevels: 2, + size: hal.Extent3D{DepthOrArrayLayers: 6}, + } + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{ + MipLevel: 1, + Origin: hal.Origin3D{Z: 2}, + Aspect: gputypes.TextureAspectAll, + }, hal.ImageDataLayout{ + Offset: 1024, + BytesPerRow: 256, + RowsPerImage: 4, + }, hal.Extent3D{Width: 8, Height: 4, DepthOrArrayLayers: 2}) + + want := []bufferTextureCopyPlan{ + {subresource: 5, bufferOffset: 1024, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 0}, + {subresource: 7, bufferOffset: 2048, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 0}, + } + if !equalBufferTextureCopyPlans(plans, want) { + t.Fatalf("array copy plans = %#v, want %#v", plans, want) + } +} + +func TestPlanBufferTextureCopiesAlignsPlacedFootprintsByWholeRows(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatRGBA8Unorm, + dimension: gputypes.TextureDimension2D, + mipLevels: 1, + size: hal.Extent3D{DepthOrArrayLayers: 2}, + } + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{ + Aspect: gputypes.TextureAspectAll, + }, hal.ImageDataLayout{ + Offset: 512, + BytesPerRow: 768, + RowsPerImage: 3, + }, hal.Extent3D{Width: 8, Height: 2, DepthOrArrayLayers: 2}) + + want := []bufferTextureCopyPlan{ + {subresource: 0, bufferOffset: 512, footprintHeight: 2, footprintDepth: 1}, + {subresource: 1, bufferOffset: 2048, bufferOriginY: 1, footprintHeight: 3, footprintDepth: 1}, + } + if !equalBufferTextureCopyPlans(plans, want) { + t.Fatalf("array copy plans = %#v, want %#v", plans, want) + } + if got := plans[1].bufferOffset + uint64(plans[1].bufferOriginY)*768; got != 2816 { + t.Fatalf("second logical layer offset = %d, want 2816", got) + } +} + +func TestPlanBufferTextureCopiesUsesBlockRowsForCompressedLayers(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatBC1RGBAUnorm, + dimension: gputypes.TextureDimension2D, + mipLevels: 1, + size: hal.Extent3D{DepthOrArrayLayers: 2}, + } + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{ + Aspect: gputypes.TextureAspectAll, + }, hal.ImageDataLayout{ + Offset: 512, + BytesPerRow: 256, + RowsPerImage: 4, + }, hal.Extent3D{Width: 4, Height: 4, DepthOrArrayLayers: 2}) + + want := []bufferTextureCopyPlan{ + {subresource: 0, bufferOffset: 512, footprintHeight: 4, footprintDepth: 1}, + {subresource: 1, bufferOffset: 512, bufferOriginY: 4, footprintHeight: 8, footprintDepth: 1}, + } + if !equalBufferTextureCopyPlans(plans, want) { + t.Fatalf("compressed array copy plans = %#v, want %#v", plans, want) + } +} + +func TestPlanBufferTextureCopiesKeeps3DDepthInOneSubresource(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatRGBA8Unorm, + dimension: gputypes.TextureDimension3D, + mipLevels: 3, + size: hal.Extent3D{DepthOrArrayLayers: 16}, + } + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{ + MipLevel: 2, + Origin: hal.Origin3D{Z: 4}, + Aspect: gputypes.TextureAspectAll, + }, hal.ImageDataLayout{Offset: 1024, BytesPerRow: 256, RowsPerImage: 4}, hal.Extent3D{ + Width: 8, Height: 4, DepthOrArrayLayers: 3, + }) + + want := []bufferTextureCopyPlan{ + {subresource: 2, bufferOffset: 1024, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 4}, + {subresource: 2, bufferOffset: 2048, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 5}, + {subresource: 2, bufferOffset: 3072, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 6}, + } + if !equalBufferTextureCopyPlans(plans, want) { + t.Fatalf("3D copy plans = %#v, want %#v", plans, want) + } +} + +func TestPlanBufferTextureCopiesPreserves3DRowsPerImageAsSliceStride(t *testing.T) { + texture := &Texture{ + format: gputypes.TextureFormatRGBA8Unorm, + dimension: gputypes.TextureDimension3D, + mipLevels: 1, + size: hal.Extent3D{DepthOrArrayLayers: 8}, + } + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{ + Aspect: gputypes.TextureAspectAll, + }, hal.ImageDataLayout{Offset: 512, BytesPerRow: 256, RowsPerImage: 8}, hal.Extent3D{ + Width: 8, Height: 4, DepthOrArrayLayers: 3, + }) + want := []bufferTextureCopyPlan{ + {subresource: 0, bufferOffset: 512, footprintHeight: 4, footprintDepth: 1}, + {subresource: 0, bufferOffset: 2560, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 1}, + {subresource: 0, bufferOffset: 4608, footprintHeight: 4, footprintDepth: 1, textureOriginZ: 2}, + } + if !equalBufferTextureCopyPlans(plans, want) { + t.Fatalf("3D padded-row copy plans = %#v, want %#v", plans, want) + } +} + +func TestPlanBufferTextureCopiesKeepsMinimum3DBufferBounds(t *testing.T) { + tests := []struct { + name string + format gputypes.TextureFormat + width uint32 + height uint32 + rowsPerImage uint32 + rowBytes uint64 + wantEnd uint64 + }{ + {name: "rgba8", format: gputypes.TextureFormatRGBA8Unorm, width: 1, height: 1, rowsPerImage: 2, rowBytes: 4, wantEnd: 1028}, + {name: "bc1", format: gputypes.TextureFormatBC1RGBAUnorm, width: 4, height: 4, rowsPerImage: 8, rowBytes: 8, wantEnd: 1032}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + texture := &Texture{format: test.format, dimension: gputypes.TextureDimension3D, mipLevels: 1} + plans := planBufferTextureCopies(texture, hal.ImageCopyTexture{Aspect: gputypes.TextureAspectAll}, hal.ImageDataLayout{ + Offset: 512, BytesPerRow: 256, RowsPerImage: test.rowsPerImage, + }, hal.Extent3D{Width: test.width, Height: test.height, DepthOrArrayLayers: 2}) + if len(plans) != 2 { + t.Fatalf("3D slice plans = %#v, want 2", plans) + } + last := plans[1] + if last.footprintDepth != 1 { + t.Fatalf("last footprint depth = %d, want 1", last.footprintDepth) + } + logicalStart := last.bufferOffset + uint64(last.bufferOriginY/textureFormatBlockHeight(test.format))*256 + if got := logicalStart + test.rowBytes; got != test.wantEnd { + t.Fatalf("last copied byte end = %d, want minimum buffer end %d", got, test.wantEnd) + } + }) + } +} + +func TestPlanTextureCopiesSplitsArraysAndUsesAbsolute3DBack(t *testing.T) { + arraySrc := &Texture{format: gputypes.TextureFormatRGBA8Unorm, dimension: gputypes.TextureDimension2D, mipLevels: 2, size: hal.Extent3D{DepthOrArrayLayers: 6}} + arrayDst := &Texture{format: gputypes.TextureFormatRGBA8Unorm, dimension: gputypes.TextureDimension2D, mipLevels: 2, size: hal.Extent3D{DepthOrArrayLayers: 8}} + arrayPlans := planTextureTextureCopies(arraySrc, arrayDst, hal.TextureCopy{ + SrcBase: hal.ImageCopyTexture{Origin: hal.Origin3D{Z: 1}, Aspect: gputypes.TextureAspectAll}, + DstBase: hal.ImageCopyTexture{Origin: hal.Origin3D{Z: 3}, Aspect: gputypes.TextureAspectAll}, + Size: hal.Extent3D{Width: 8, Height: 4, DepthOrArrayLayers: 2}, + }) + arrayWant := []textureTextureCopyPlan{ + {srcSubresource: 2, dstSubresource: 6, srcFront: 0, srcBack: 1, dstZ: 0}, + {srcSubresource: 4, dstSubresource: 8, srcFront: 0, srcBack: 1, dstZ: 0}, + } + if !equalTextureTextureCopyPlans(arrayPlans, arrayWant) { + t.Fatalf("array texture plans = %#v, want %#v", arrayPlans, arrayWant) + } + + volumeSrc := &Texture{format: gputypes.TextureFormatRGBA8Unorm, dimension: gputypes.TextureDimension3D, mipLevels: 3} + volumeDst := &Texture{format: gputypes.TextureFormatRGBA8Unorm, dimension: gputypes.TextureDimension3D, mipLevels: 3} + volumePlans := planTextureTextureCopies(volumeSrc, volumeDst, hal.TextureCopy{ + SrcBase: hal.ImageCopyTexture{MipLevel: 1, Origin: hal.Origin3D{Z: 4}, Aspect: gputypes.TextureAspectAll}, + DstBase: hal.ImageCopyTexture{MipLevel: 2, Origin: hal.Origin3D{Z: 7}, Aspect: gputypes.TextureAspectAll}, + Size: hal.Extent3D{Width: 8, Height: 4, DepthOrArrayLayers: 3}, + }) + volumeWant := []textureTextureCopyPlan{{srcSubresource: 1, dstSubresource: 2, srcFront: 4, srcBack: 7, dstZ: 7}} + if !equalTextureTextureCopyPlans(volumePlans, volumeWant) { + t.Fatalf("3D texture plans = %#v, want %#v", volumePlans, volumeWant) + } +} + +func equalUint32s(got, want []uint32) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + +func equalSubresourceIndexes(got []textureSubresource, want []uint32) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i].subresource != want[i] { + return false + } + } + return true +} + +func equalBufferTextureCopyPlans(got, want []bufferTextureCopyPlan) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + +func equalTextureTextureCopyPlans(got, want []textureTextureCopyPlan) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestSubmissionPlannerReconcilesActualOrderWithoutMutatingInput(t *testing.T) { + const resource = "texture" + common := d3d12.D3D12_RESOURCE_STATE_COMMON + read := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE + write := d3d12.D3D12_RESOURCE_STATE_COPY_DEST + + first := commandStateSummary{commandIndex: 0, resource: resource, states: []subresourceState{{subresource: 0, initial: common, current: read}}} + second := commandStateSummary{commandIndex: 1, resource: resource, states: []subresourceState{{subresource: 0, initial: common, current: write}}} + scheduled := map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES{resource: {0: common}} + + preambles, final := planSubmissionState(scheduled, []commandStateSummary{first, second}) + if len(preambles) != 2 || len(preambles[0]) != 0 || len(preambles[1]) != 1 { + t.Fatalf("preambles = %#v, want [none, one transition]", preambles) + } + transition := preambles[1][0] + if transition.before != read || transition.after != common { + t.Fatalf("second preamble = %#v, want READ -> COMMON", transition) + } + if got := final[resource][0]; got != write { + t.Fatalf("final scheduled state = %d, want COPY_DEST", got) + } + if got := scheduled[resource][0]; got != common { + t.Fatalf("input scheduled state mutated to %d, want COMMON", got) + } +} + +func TestResourceStateSnapshotsAndCommitsAreConcurrentSafe(t *testing.T) { + buffer := &Buffer{currentState: d3d12.D3D12_RESOURCE_STATE_COMMON} + texture := &Texture{currentState: d3d12.D3D12_RESOURCE_STATE_COMMON} + states := []d3d12.D3D12_RESOURCE_STATES{ + d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE, + d3d12.D3D12_RESOURCE_STATE_COPY_DEST, + d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, + } + + var wg sync.WaitGroup + for worker := 0; worker < 8; worker++ { + wg.Add(1) + go func(offset int) { + defer wg.Done() + for i := 0; i < 1000; i++ { + state := states[(i+offset)%len(states)] + buffer.commitScheduledState(state) + _ = buffer.scheduledStateSnapshot() + texture.commitScheduledStates([]d3d12.D3D12_RESOURCE_STATES{state}) + _ = texture.scheduledStateSnapshot(0) + _ = texture.scheduledStateSnapshotAll() + } + }(worker) + } + wg.Wait() +} + +func TestQueueStateReleasesTrackedAndUnfencedOwnedObjects(t *testing.T) { + device := &Device{} + queue := newQueue(device) + if device.queueState == nil || device.queueState != queue.state { + t.Fatal("device and queue do not share their noncyclic lifetime state") + } + queue.state.preambleInFlight = []preambleInFlight{ + {submission: 7}, + {submission: 0}, + } + queue.state.oneShotsInFlight = []oneShotInFlight{ + {submission: 7}, + {submission: 0}, + } + + queue.state.releaseAllOwnedLocked() + if len(queue.state.preambleInFlight) != 0 { + t.Fatalf("preambles after drain = %d, want 0", len(queue.state.preambleInFlight)) + } + if len(queue.state.oneShotsInFlight) != 0 { + t.Fatalf("one-shots after drain = %d, want 0", len(queue.state.oneShotsInFlight)) + } +} + +func TestCompletedOneShotsReleaseOnlyFenceTrackedOwnership(t *testing.T) { + queue := &Queue{state: &queueState{oneShotsInFlight: []oneShotInFlight{ + {submission: 4}, + {submission: 0}, + {submission: 6}, + }}} + + queue.releaseCompletedOneShots(4) + if got := queue.state.oneShotsInFlight; len(got) != 2 || got[0].submission != 0 || got[1].submission != 6 { + t.Fatalf("retained one-shots = %+v, want submissions [0 6]", got) + } +} + +func TestOneShotDetachTransfersNativeOwnershipWithoutDeviceBackReferences(t *testing.T) { + stagingRaw := &d3d12.ID3D12Resource{} + destinationRaw := &d3d12.ID3D12Resource{} + allocatorRaw := &d3d12.ID3D12CommandAllocator{} + cmdListRaw := &d3d12.ID3D12GraphicsCommandList{} + destination := &Buffer{raw: destinationRaw} + owner := &oneShotWriteOwner{ + staging: &Buffer{raw: stagingRaw}, + destination: destinationRaw, + encoder: &CommandEncoder{allocator: allocatorRaw}, + commandBuffer: &CommandBuffer{cmdList: cmdListRaw}, + } + + retained := owner.detach(11) + if retained.submission != 11 || retained.staging != stagingRaw || retained.destination != destinationRaw || retained.allocator != allocatorRaw || retained.cmdList != cmdListRaw { + t.Fatalf("detached ownership = %+v, want all native objects at submission 11", retained) + } + if owner.staging.raw != nil || owner.destination != nil || owner.encoder.allocator != nil || owner.commandBuffer.cmdList != nil { + t.Fatal("wrapper ownership remained after detach") + } + // Deferred local cleanup runs after detach in the error paths. It must be a + // no-op for every transferred reference rather than double-releasing it. + owner.release() + if destination.raw != destinationRaw { + t.Fatal("detach stole the caller's destination wrapper reference") + } +} + +func TestClosedQueueOperationsRejectBeforeTouchingDevice(t *testing.T) { + queue := &Queue{state: &queueState{closed: true}} + if _, err := queue.Submit(nil); err == nil { + t.Fatal("Submit on closed queue succeeded") + } + if err := queue.WriteBuffer(nil, 0, nil); err == nil { + t.Fatal("WriteBuffer on closed queue succeeded") + } + if err := queue.WriteTexture(nil, nil, nil, nil); err == nil { + t.Fatal("WriteTexture on closed queue succeeded") + } + if err := queue.Present(nil, nil, nil); err == nil { + t.Fatal("Present on closed queue succeeded") + } + if got := queue.PollCompleted(); got != 0 { + t.Fatalf("PollCompleted on closed queue = %d, want 0", got) + } + if got := queue.GetTimestampPeriod(); got != 1.0 { + t.Fatalf("GetTimestampPeriod on closed queue = %f, want fallback 1.0", got) + } +} + +func TestClosedDeviceWaitIdleRejectsBeforeTouchingNativeDevice(t *testing.T) { + device := &Device{queueState: &queueState{closed: true}} + if err := device.WaitIdle(); err == nil { + t.Fatal("WaitIdle on closed device succeeded") + } +} + +func TestQueueCloseExcludesConcurrentOperation(t *testing.T) { + state := &queueState{} + queue := &Queue{state: state} + started := make(chan struct{}) + result := make(chan error, 1) + + state.submitMu.Lock() + go func() { + close(started) + result <- queue.lockOpen() + }() + <-started + state.closed = true + state.submitMu.Unlock() + + if err := <-result; err == nil { + // lockOpen retains the mutex on success; avoid poisoning the test + // process if the exclusion contract regresses. + state.submitMu.Unlock() + t.Fatal("operation entered after queue close") + } +} + +func TestTerminalOwnedObjectReleaseRequiresProofOfCompletion(t *testing.T) { + waitErr := errors.New("event wait failed") + if !shouldReleaseTerminalOwnedObjects(nil, false) { + t.Fatal("successful idle wait did not release owned objects") + } + if !shouldReleaseTerminalOwnedObjects(waitErr, true) { + t.Fatal("confirmed device removal did not release owned objects") + } + if shouldReleaseTerminalOwnedObjects(waitErr, false) { + t.Fatal("ambiguous idle failure released owned objects") + } +} + +func TestFailTextureViewCreationRecyclesAllocatedDescriptors(t *testing.T) { + device := &Device{ + stagingViewHeap: &DescriptorHeap{}, + rtvHeap: &DescriptorHeap{}, + dsvHeap: &DescriptorHeap{}, + } + view := &TextureView{ + texture: &Texture{}, + device: device, + hasSRV: true, + hasRTV: true, + hasDSV: true, + hasDSVVariants: [4]bool{true, true, true, true}, + srvHeapIndex: 11, + rtvHeapIndex: 12, + dsvHeapIndex: [4]uint32{20, 21, 22, 23}, + } + wantErr := errors.New("descriptor allocation failed") + gotView, gotErr := failTextureViewCreation(view, wantErr) + if gotView != nil || !errors.Is(gotErr, wantErr) { + t.Fatalf("failure result = (%v, %v), want (nil, %v)", gotView, gotErr, wantErr) + } + if want := []uint32{11}; !equalUint32s(device.stagingViewHeap.freeList, want) { + t.Fatalf("freed SRVs = %v, want %v", device.stagingViewHeap.freeList, want) + } + if want := []uint32{12}; !equalUint32s(device.rtvHeap.freeList, want) { + t.Fatalf("freed RTVs = %v, want %v", device.rtvHeap.freeList, want) + } + if want := []uint32{20, 21, 22, 23}; !equalUint32s(device.dsvHeap.freeList, want) { + t.Fatalf("freed DSVs = %v, want %v", device.dsvHeap.freeList, want) + } +} diff --git a/hal/dx12/surface.go b/hal/dx12/surface.go index 542017c9..77f99dad 100644 --- a/hal/dx12/surface.go +++ b/hal/dx12/surface.go @@ -24,6 +24,7 @@ const maxFrameLatency = 2 // backBuffer represents a back buffer resource and its RTV. type backBuffer struct { resource *d3d12.ID3D12Resource + texture *Texture // shared queue-scheduled state owner rtvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE rtvIndex uint32 // Heap index for recycling on release } @@ -183,10 +184,23 @@ func (s *Surface) createBackBufferRTVs() error { s.device.raw.CreateRenderTargetView(resource, nil, rtvHandle) s.backBuffers[i] = backBuffer{ - resource: resource, + resource: resource, + texture: &Texture{ + raw: resource, + format: s.halFormat, + dimension: gputypes.TextureDimension2D, + size: hal.Extent3D{Width: s.width, Height: s.height, DepthOrArrayLayers: 1}, + mipLevels: 1, + samples: 1, + usage: gputypes.TextureUsageRenderAttachment, + device: s.device, + isExternal: true, + currentState: d3d12.D3D12_RESOURCE_STATE_PRESENT, + }, rtvHandle: rtvHandle, rtvIndex: rtvIndex, } + s.backBuffers[i].texture.stateOwner.setTextureStates([]d3d12.D3D12_RESOURCE_STATES{d3d12.D3D12_RESOURCE_STATE_PRESENT}) } return nil @@ -196,6 +210,10 @@ func (s *Surface) createBackBufferRTVs() error { func (s *Surface) releaseBackBuffers() { for i := range s.backBuffers { if s.backBuffers[i].resource != nil { + if s.backBuffers[i].texture != nil { + s.backBuffers[i].texture.raw = nil + s.backBuffers[i].texture = nil + } s.backBuffers[i].resource.Release() s.backBuffers[i].resource = nil } @@ -414,6 +432,7 @@ type SurfaceTexture struct { surface *Surface index uint32 resource *d3d12.ID3D12Resource + stateOwner *Texture rtvHandle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE format gputypes.TextureFormat width uint32 @@ -421,10 +440,15 @@ type SurfaceTexture struct { suboptimal bool } -// CurrentUsage returns 0 — DX12 surface textures are managed by swapchain, state tracked externally. -func (t *SurfaceTexture) CurrentUsage() gputypes.TextureUsage { return 0 } -func (t *SurfaceTexture) AddPendingRef() {} -func (t *SurfaceTexture) DecPendingRef() {} +// CurrentUsage returns the queue-scheduled usage of the shared back-buffer owner. +func (t *SurfaceTexture) CurrentUsage() gputypes.TextureUsage { + if t == nil || t.stateOwner == nil { + return 0 + } + return t.stateOwner.CurrentUsage() +} +func (t *SurfaceTexture) AddPendingRef() {} +func (t *SurfaceTexture) DecPendingRef() {} // Destroy implements hal.SurfaceTexture. // Surface textures are owned by the swapchain and should not be destroyed individually. From 07106522ab2319bd51660e2b43e3dee53e3bd3e4 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 15:22:49 +0300 Subject: [PATCH 4/5] fix(dx12): pool state preamble command pairs --- hal/dx12/command.go | 13 +- hal/dx12/device.go | 41 +++- hal/dx12/queue.go | 131 +++++++++-- hal/dx12/state_tracker.go | 40 ++-- hal/dx12/state_tracker_test.go | 394 ++++++++++++++++++++++++++++++++- 5 files changed, 560 insertions(+), 59 deletions(-) diff --git a/hal/dx12/command.go b/hal/dx12/command.go index f15ee0f3..aae1d138 100644 --- a/hal/dx12/command.go +++ b/hal/dx12/command.go @@ -1349,10 +1349,10 @@ func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthSte if stencilReadOnly { dsvFlags |= d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL } - exposedPlanes := textureAspectPlanes(view.texture, view.aspect) + _, srvPlane := textureFormatToSRV(view.format, view.aspect) plans := make([]stateBarrierPlan, 0) for _, subresource := range textureViewPhysicalSubresourcePlanes(view) { - shaderReadable := view.hasSRV && containsPlane(exposedPlanes, subresource.plane) + shaderReadable := view.hasSRV && subresource.plane == srvPlane state := depthStencilPlaneState(subresource.plane, depthReadOnly, stencilReadOnly, shaderReadable) if before, needsBarrier := e.stateTracker.transitionTexture(view.texture, subresource.subresource, state); needsBarrier { plans = append(plans, stateBarrierPlan{resource: view.texture, subresource: subresource.subresource, before: before, after: state}) @@ -1386,15 +1386,6 @@ func (e *CommandEncoder) setupDepthStencilAttachment(dsa *hal.RenderPassDepthSte return dsvHandle } -func containsPlane(planes []uint32, target uint32) bool { - for _, plane := range planes { - if plane == target { - return true - } - } - return false -} - // bufferUsageToD3D12State converts buffer usage to D3D12 resource state. func bufferUsageToD3D12State(usage gputypes.BufferUsage) d3d12.D3D12_RESOURCE_STATES { var state d3d12.D3D12_RESOURCE_STATES diff --git a/hal/dx12/device.go b/hal/dx12/device.go index 11abf985..14a6e03e 100644 --- a/hal/dx12/device.go +++ b/hal/dx12/device.go @@ -1570,10 +1570,11 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes view.hasDSVVariants[0] = true view.hasDSV = true - // D3D12 encodes read-only depth/stencil planes in the DSV itself. - // Keep all four flag combinations so a render pass can select the - // descriptor matching its DepthReadOnly/StencilReadOnly attachment. - for mask := uint32(1); mask < 4; mask++ { + // D3D12 encodes read-only depth/stencil planes in the DSV itself. A + // stencil read-only flag is invalid for single-plane D16/D32 formats, + // while WebGPU's Depth24Plus and Stencil8 use packed D24S8 resources and + // need both physical-plane flags to protect their hidden companion. + for _, mask := range dsvReadOnlyVariantMasks(viewFormat) { variantHandle, variantIndex, variantErr := d.allocateDSVDescriptor() if variantErr != nil { return failTextureViewCreation(view, fmt.Errorf("dx12: failed to allocate read-only DSV descriptor: %w", variantErr)) @@ -1633,6 +1634,22 @@ func (d *Device) CreateTextureView(texture hal.Texture, desc *hal.TextureViewDes return view, nil } +func dsvReadOnlyVariantMasks(format gputypes.TextureFormat) []uint32 { + switch format { + case gputypes.TextureFormatDepth24Plus, + gputypes.TextureFormatDepth24PlusStencil8, + gputypes.TextureFormatDepth32FloatStencil8, + gputypes.TextureFormatStencil8: + return []uint32{ + uint32(d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH), + uint32(d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL), + uint32(d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH | d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL), + } + default: + return []uint32{uint32(d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH)} + } +} + func failTextureViewCreation(view *TextureView, err error) (hal.TextureView, error) { if view != nil { view.Destroy() @@ -2792,14 +2809,14 @@ func (d *Device) Destroy() { if waitErr != nil { deviceRemoved = d.raw.GetDeviceRemovedReason() != nil } - if state != nil && shouldReleaseTerminalOwnedObjects(waitErr, deviceRemoved) { - state.releaseAllOwnedLocked() - } else if state != nil && (len(state.preambleInFlight) > 0 || len(state.oneShotsInFlight) > 0) { - // A failed event registration/wait does not prove GPU completion. - // Retain the COM objects rather than releasing allocators, command lists, - // or staging resources that may still be in flight. Device removal is the - // only safe exception. - hal.Logger().Warn("dx12: retaining queue-owned GPU objects after ambiguous idle failure", "err", waitErr) + if state != nil { + state.releaseTerminalOwnedLocked(waitErr, deviceRemoved) + if !shouldReleaseTerminalOwnedObjects(waitErr, deviceRemoved) && + (len(state.preambleInFlight) > 0 || len(state.oneShotsInFlight) > 0) { + // A failed event registration/wait does not prove GPU completion. + // Device removal is the only safe exception for in-flight work. + hal.Logger().Warn("dx12: retaining queue-owned GPU objects after ambiguous idle failure", "err", waitErr) + } } d.cleanup() diff --git a/hal/dx12/queue.go b/hal/dx12/queue.go index 42118b7f..dfcd3bf1 100644 --- a/hal/dx12/queue.go +++ b/hal/dx12/queue.go @@ -35,6 +35,8 @@ type queueState struct { closed bool preambleInFlight []preambleInFlight + preambleIdle []preambleInFlight + preambleOps preambleNativeOps oneShotsInFlight []oneShotInFlight } @@ -42,8 +44,22 @@ type preambleInFlight struct { submission uint64 allocator *d3d12.ID3D12CommandAllocator cmdList *d3d12.ID3D12GraphicsCommandList + testID uint64 } +// preambleNativeOps keeps allocator/list lifecycle effects behind a narrow +// seam. The pool owns allocator/list pairs as one unit; tests can exercise +// every failure path without constructing COM objects. +type preambleNativeOps interface { + create(*Device) (preambleInFlight, error) + resetAllocator(preambleInFlight) error + resetCommandList(preambleInFlight) error + recordAndClose(preambleInFlight, []stateBarrierPlan) error + release(preambleInFlight) +} + +type d3d12PreambleNativeOps struct{} + // oneShotInFlight owns only native objects. Keeping Device/Buffer/encoder // wrappers here would create a finalizer cycle through Device.queueState. type oneShotInFlight struct { @@ -166,10 +182,7 @@ func (q *Queue) submitLocked(commandBuffers []hal.CommandBuffer) (uint64, error) if i < len(preambles) && len(preambles[i]) > 0 { preamble, err := q.buildPreamble(preambles[i]) if err != nil { - for _, built := range nativePreambles { - built.cmdList.Release() - built.allocator.Release() - } + q.releaseBuiltPreambles(nativePreambles) return 0, err } nativePreambles = append(nativePreambles, preamble) @@ -218,6 +231,12 @@ func (q *Queue) submitLocked(commandBuffers []hal.CommandBuffer) (uint64, error) return submission, nil } +func (q *Queue) releaseBuiltPreambles(preambles []preambleInFlight) { + for _, preamble := range preambles { + q.state.releasePreamble(preamble) + } +} + func (q *Queue) lockOpen() error { if q == nil || q.state == nil { return fmt.Errorf("dx12: queue is unavailable") @@ -297,15 +316,58 @@ func (q *Queue) commitScheduledStates(final map[any]map[uint32]d3d12.D3D12_RESOU } func (q *Queue) buildPreamble(plans []stateBarrierPlan) (preambleInFlight, error) { - allocator, err := q.device.raw.CreateCommandAllocator(d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT) + ops := q.state.nativePreambleOps() + if count := len(q.state.preambleIdle); count > 0 { + preamble := q.state.preambleIdle[count-1] + q.state.preambleIdle[count-1] = preambleInFlight{} + q.state.preambleIdle = q.state.preambleIdle[:count-1] + preamble.submission = 0 + + if err := ops.resetAllocator(preamble); err == nil { + if err = ops.resetCommandList(preamble); err == nil { + if err = ops.recordAndClose(preamble, plans); err == nil { + return preamble, nil + } + } + } + // A pair with any failed reset or close is no longer reusable. Release + // both objects before falling back to a fresh pair. + ops.release(preamble) + } + + preamble, err := ops.create(q.device) + if err != nil { + return preambleInFlight{}, err + } + if err := ops.recordAndClose(preamble, plans); err != nil { + ops.release(preamble) + return preambleInFlight{}, fmt.Errorf("dx12: close state preamble command list: %w", err) + } + return preamble, nil +} + +func (d3d12PreambleNativeOps) create(device *Device) (preambleInFlight, error) { + allocator, err := device.raw.CreateCommandAllocator(d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT) if err != nil { return preambleInFlight{}, fmt.Errorf("dx12: create state preamble allocator: %w", err) } - list, err := q.device.raw.CreateCommandList(0, d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT, allocator, nil) + list, err := device.raw.CreateCommandList(0, d3d12.D3D12_COMMAND_LIST_TYPE_DIRECT, allocator, nil) if err != nil { allocator.Release() return preambleInFlight{}, fmt.Errorf("dx12: create state preamble command list: %w", err) } + return preambleInFlight{allocator: allocator, cmdList: list}, nil +} + +func (d3d12PreambleNativeOps) resetAllocator(preamble preambleInFlight) error { + return preamble.allocator.Reset() +} + +func (d3d12PreambleNativeOps) resetCommandList(preamble preambleInFlight) error { + return preamble.cmdList.Reset(preamble.allocator, nil) +} + +func (d3d12PreambleNativeOps) recordAndClose(preamble preambleInFlight, plans []stateBarrierPlan) error { barriers := make([]d3d12.D3D12_RESOURCE_BARRIER, 0, len(plans)) for _, plan := range plans { var raw *d3d12.ID3D12Resource @@ -321,21 +383,30 @@ func (q *Queue) buildPreamble(plans []stateBarrierPlan) (preambleInFlight, error barriers = append(barriers, d3d12.NewTransitionBarrier(raw, plan.before, plan.after, plan.subresource)) } if len(barriers) > 0 { - list.ResourceBarrier(uint32(len(barriers)), &barriers[0]) + preamble.cmdList.ResourceBarrier(uint32(len(barriers)), &barriers[0]) } - if err := list.Close(); err != nil { - list.Release() - allocator.Release() - return preambleInFlight{}, fmt.Errorf("dx12: close state preamble command list: %w", err) + return preamble.cmdList.Close() +} + +func (d3d12PreambleNativeOps) release(preamble preambleInFlight) { + if preamble.cmdList != nil { + preamble.cmdList.Release() + } + if preamble.allocator != nil { + preamble.allocator.Release() } - return preambleInFlight{allocator: allocator, cmdList: list}, nil } func (q *Queue) releaseCompletedPreambles(completed uint64) { keep := q.state.preambleInFlight[:0] for _, preamble := range q.state.preambleInFlight { if preamble.submission != 0 && preamble.submission <= completed { - releasePreamble(preamble) + preamble.submission = 0 + if len(q.state.preambleIdle) < maxFramesInFlight { + q.state.preambleIdle = append(q.state.preambleIdle, preamble) + } else { + q.state.releasePreamble(preamble) + } continue } keep = append(keep, preamble) @@ -343,13 +414,15 @@ func (q *Queue) releaseCompletedPreambles(completed uint64) { q.state.preambleInFlight = keep } -func releasePreamble(preamble preambleInFlight) { - if preamble.cmdList != nil { - preamble.cmdList.Release() - } - if preamble.allocator != nil { - preamble.allocator.Release() +func (s *queueState) nativePreambleOps() preambleNativeOps { + if s.preambleOps != nil { + return s.preambleOps } + return d3d12PreambleNativeOps{} +} + +func (s *queueState) releasePreamble(preamble preambleInFlight) { + s.nativePreambleOps().release(preamble) } func (q *Queue) releaseCompletedOneShots(completed uint64) { @@ -382,15 +455,33 @@ func releaseOneShot(oneShot oneShotInFlight) { func (s *queueState) releaseAllOwnedLocked() { for _, preamble := range s.preambleInFlight { - releasePreamble(preamble) + s.releasePreamble(preamble) } s.preambleInFlight = nil + s.releaseIdlePreamblesLocked() for _, oneShot := range s.oneShotsInFlight { releaseOneShot(oneShot) } s.oneShotsInFlight = nil } +func (s *queueState) releaseIdlePreamblesLocked() { + for _, preamble := range s.preambleIdle { + s.releasePreamble(preamble) + } + s.preambleIdle = nil +} + +func (s *queueState) releaseTerminalOwnedLocked(waitErr error, deviceRemoved bool) { + if shouldReleaseTerminalOwnedObjects(waitErr, deviceRemoved) { + s.releaseAllOwnedLocked() + return + } + // Idle pairs have already crossed a trustworthy fence. Keep only native + // objects whose in-flight completion remains ambiguous. + s.releaseIdlePreamblesLocked() +} + func (q *Queue) retainPreamblesWithoutFence(preambles []preambleInFlight) { for i := range preambles { // submission=0 is intentionally never released by diff --git a/hal/dx12/state_tracker.go b/hal/dx12/state_tracker.go index fe7442b8..5d71bb38 100644 --- a/hal/dx12/state_tracker.go +++ b/hal/dx12/state_tracker.go @@ -128,8 +128,10 @@ func stateResourceSortKey(resource any) string { } // planSubmissionState computes queue preambles and the state that would be -// scheduled after all command buffers execute in order. The input map is -// never mutated, so a failed ExecuteCommandLists can leave ownership intact. +// scheduled after all command buffers execute in order. Buffer target states +// remain visible while planning command lists in the same ExecuteCommandLists +// call, then decay to COMMON at that call boundary. The input map is never +// mutated, so a failed ExecuteCommandLists can leave ownership intact. func planSubmissionState(scheduled map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES, commands []commandStateSummary) ([][]stateBarrierPlan, map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES) { current := cloneScheduledState(scheduled) commandCount := 0 @@ -163,6 +165,15 @@ func planSubmissionState(scheduled map[any]map[uint32]d3d12.D3D12_RESOURCE_STATE } } + // D3D12 buffers decay to COMMON only after the ExecuteCommandLists call. + // Do this after all command-list preambles have been planned so later lists + // in this same call still observe the preceding list's target state. + for resource, states := range current { + if _, ok := resource.(*Buffer); ok { + states[0] = d3d12.D3D12_RESOURCE_STATE_COMMON + } + } + return preambles, current } @@ -532,20 +543,18 @@ func planTextureTextureCopies(src, dst *Texture, copy hal.TextureCopy) []texture // depthStencilPlaneState returns the D3D12 state for one depth/stencil plane // under a render-pass attachment's independent read-only flags. func depthStencilPlaneState(plane uint32, depthReadOnly, stencilReadOnly, shaderReadable bool) d3d12.D3D12_RESOURCE_STATES { - if plane == 0 { - if !depthReadOnly { - return d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE - } - state := d3d12.D3D12_RESOURCE_STATE_DEPTH_READ - if shaderReadable { - state |= d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE - } - return state + readOnly := depthReadOnly + if plane != 0 { + readOnly = stencilReadOnly + } + if !readOnly { + return d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE } - if stencilReadOnly { - return d3d12.D3D12_RESOURCE_STATE_DEPTH_READ + state := d3d12.D3D12_RESOURCE_STATE_DEPTH_READ + if shaderReadable { + state |= d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE } - return d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE + return state } // depthStencilReadOnlyFlags protects physical planes that a WebGPU view does @@ -553,6 +562,9 @@ func depthStencilPlaneState(plane uint32, depthReadOnly, stencilReadOnly, shader // requires them to be in a writable depth/stencil state. func depthStencilReadOnlyFlags(format gputypes.TextureFormat, aspect gputypes.TextureAspect, depthReadOnly, stencilReadOnly bool) (bool, bool) { switch format { + case gputypes.TextureFormatDepth16Unorm, gputypes.TextureFormatDepth32Float: + // Single-plane DSV formats cannot encode READ_ONLY_STENCIL. + stencilReadOnly = false case gputypes.TextureFormatStencil8: depthReadOnly = true case gputypes.TextureFormatDepth24Plus: diff --git a/hal/dx12/state_tracker_test.go b/hal/dx12/state_tracker_test.go index 4c6be7d9..fd3413cb 100644 --- a/hal/dx12/state_tracker_test.go +++ b/hal/dx12/state_tracker_test.go @@ -182,7 +182,7 @@ func TestDepthStencilPlaneStates(t *testing.T) { {name: "depth write stencil write", plane: 0, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE}, {name: "stencil write", plane: 1, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_WRITE}, {name: "both read", plane: 0, depthReadOnly: true, stencilReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ | shaderRead}, - {name: "both read stencil plane", plane: 1, depthReadOnly: true, stencilReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ}, + {name: "sampled read-only stencil plane", plane: 1, depthReadOnly: true, stencilReadOnly: true, shaderReadable: true, want: d3d12.D3D12_RESOURCE_STATE_DEPTH_READ | shaderRead}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -207,7 +207,7 @@ func TestDepthStencilReadOnlyFlagsProtectUnexposedPlanes(t *testing.T) { {name: "depth24plus protects backing stencil", format: gputypes.TextureFormatDepth24Plus, aspect: gputypes.TextureAspectAll, wantStencil: true}, {name: "stencil aspect protects depth", format: gputypes.TextureFormatDepth24PlusStencil8, aspect: gputypes.TextureAspectStencilOnly, wantDepth: true}, {name: "depth aspect protects stencil", format: gputypes.TextureFormatDepth32FloatStencil8, aspect: gputypes.TextureAspectDepthOnly, wantStencil: true}, - {name: "single-plane depth has no companion stencil", format: gputypes.TextureFormatDepth32Float, aspect: gputypes.TextureAspectDepthOnly}, + {name: "single-plane depth drops invalid stencil flag", format: gputypes.TextureFormatDepth32Float, aspect: gputypes.TextureAspectDepthOnly, stencil: true}, {name: "explicit flags remain set", format: gputypes.TextureFormatDepth24PlusStencil8, aspect: gputypes.TextureAspectAll, depth: true, stencil: true, wantDepth: true, wantStencil: true}, } for _, test := range tests { @@ -220,6 +220,71 @@ func TestDepthStencilReadOnlyFlagsProtectUnexposedPlanes(t *testing.T) { } } +func TestDSVReadOnlyVariantsMatchPhysicalFormat(t *testing.T) { + tests := []struct { + name string + format gputypes.TextureFormat + want []uint32 + }{ + {name: "depth16", format: gputypes.TextureFormatDepth16Unorm, want: []uint32{1}}, + {name: "depth32", format: gputypes.TextureFormatDepth32Float, want: []uint32{1}}, + {name: "depth24 backing stencil", format: gputypes.TextureFormatDepth24Plus, want: []uint32{1, 2, 3}}, + {name: "stencil8 backing depth", format: gputypes.TextureFormatStencil8, want: []uint32{1, 2, 3}}, + {name: "depth24 stencil8", format: gputypes.TextureFormatDepth24PlusStencil8, want: []uint32{1, 2, 3}}, + {name: "depth32 stencil8", format: gputypes.TextureFormatDepth32FloatStencil8, want: []uint32{1, 2, 3}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := dsvReadOnlyVariantMasks(test.format); !equalUint32s(got, test.want) { + t.Fatalf("DSV read-only masks = %v, want %v", got, test.want) + } + }) + } +} + +func TestDSVHandleSelectionCoversLegalAndHiddenCompanionFlags(t *testing.T) { + packed := &TextureView{ + dsvHandle: d3d12.D3D12_CPU_DESCRIPTOR_HANDLE{Ptr: 100}, + dsvHandles: [4]d3d12.D3D12_CPU_DESCRIPTOR_HANDLE{{Ptr: 100}, {Ptr: 101}, {Ptr: 102}, {Ptr: 103}}, + hasDSVVariants: [4]bool{true, true, true, true}, + } + depth, stencil := depthStencilReadOnlyFlags(gputypes.TextureFormatStencil8, gputypes.TextureAspectAll, false, false) + if got := packed.dsvHandleForFlags(dsvFlags(depth, stencil)); got.Ptr != 101 { + t.Fatalf("Stencil8 hidden-depth descriptor = %d, want 101", got.Ptr) + } + depth, stencil = depthStencilReadOnlyFlags(gputypes.TextureFormatDepth24Plus, gputypes.TextureAspectAll, false, false) + if got := packed.dsvHandleForFlags(dsvFlags(depth, stencil)); got.Ptr != 102 { + t.Fatalf("Depth24Plus hidden-stencil descriptor = %d, want 102", got.Ptr) + } + if got := packed.dsvHandleForFlags(d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH | d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL); got.Ptr != 103 { + t.Fatalf("fully read-only descriptor = %d, want 103", got.Ptr) + } + + singlePlane := &TextureView{ + dsvHandle: d3d12.D3D12_CPU_DESCRIPTOR_HANDLE{Ptr: 200}, + dsvHandles: [4]d3d12.D3D12_CPU_DESCRIPTOR_HANDLE{{Ptr: 200}, {Ptr: 201}}, + hasDSVVariants: [4]bool{true, true}, + } + depth, stencil = depthStencilReadOnlyFlags(gputypes.TextureFormatDepth32Float, gputypes.TextureAspectDepthOnly, true, true) + if stencil { + t.Fatal("single-plane format retained an invalid stencil flag") + } + if got := singlePlane.dsvHandleForFlags(dsvFlags(depth, stencil)); got.Ptr != 201 { + t.Fatalf("single-plane read-only descriptor = %d, want 201", got.Ptr) + } +} + +func dsvFlags(depthReadOnly, stencilReadOnly bool) d3d12.D3D12_DSV_FLAGS { + flags := d3d12.D3D12_DSV_FLAG_NONE + if depthReadOnly { + flags |= d3d12.D3D12_DSV_FLAG_READ_ONLY_DEPTH + } + if stencilReadOnly { + flags |= d3d12.D3D12_DSV_FLAG_READ_ONLY_STENCIL + } + return flags +} + func TestPlanBufferTextureCopiesSplitsArrayLayers(t *testing.T) { texture := &Texture{ format: gputypes.TextureFormatRGBA8Unorm, @@ -481,6 +546,40 @@ func TestSubmissionPlannerReconcilesActualOrderWithoutMutatingInput(t *testing.T } } +func TestSubmissionPlannerDecaysBuffersOnlyAfterExecuteBoundary(t *testing.T) { + buffer := &Buffer{} + common := d3d12.D3D12_RESOURCE_STATE_COMMON + write := d3d12.D3D12_RESOURCE_STATE_COPY_DEST + read := d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE + commands := []commandStateSummary{ + {commandIndex: 0, resource: buffer, states: []subresourceState{{subresource: 0, initial: common, current: write}}}, + {commandIndex: 1, resource: buffer, states: []subresourceState{{subresource: 0, initial: common, current: read}}}, + } + + preambles, final := planSubmissionState( + map[any]map[uint32]d3d12.D3D12_RESOURCE_STATES{buffer: {0: common}}, + commands, + ) + if len(preambles) != 2 || len(preambles[0]) != 0 || len(preambles[1]) != 1 { + t.Fatalf("preambles = %#v, want second-list reconciliation only", preambles) + } + if got := preambles[1][0]; got.before != write || got.after != common { + t.Fatalf("second-list preamble = %#v, want COPY_DEST -> COMMON", got) + } + if got := final[buffer][0]; got != common { + t.Fatalf("post-execute buffer state = %d, want COMMON", got) + } + + next, _ := planSubmissionState(final, []commandStateSummary{{ + commandIndex: 0, + resource: buffer, + states: []subresourceState{{subresource: 0, initial: common, current: read}}, + }}) + if len(next) != 1 || len(next[0]) != 0 { + t.Fatalf("next execute preambles = %#v, want no transition from decayed COMMON", next) + } +} + func TestResourceStateSnapshotsAndCommitsAreConcurrentSafe(t *testing.T) { buffer := &Buffer{currentState: d3d12.D3D12_RESOURCE_STATE_COMMON} texture := &Texture{currentState: d3d12.D3D12_RESOURCE_STATE_COMMON} @@ -508,6 +607,281 @@ func TestResourceStateSnapshotsAndCommitsAreConcurrentSafe(t *testing.T) { wg.Wait() } +type fakePreambleEvent struct { + op string + id uint64 +} + +type fakePreambleNativeOps struct { + nextID uint64 + createFailures int + resetAllocatorFailures int + resetListFailures int + closeFailures int + events []fakePreambleEvent + releases map[uint64]int +} + +func (f *fakePreambleNativeOps) create(*Device) (preambleInFlight, error) { + f.nextID++ + f.events = append(f.events, fakePreambleEvent{op: "create", id: f.nextID}) + if f.createFailures > 0 { + f.createFailures-- + return preambleInFlight{}, errors.New("create pair") + } + return preambleInFlight{testID: f.nextID}, nil +} + +func (f *fakePreambleNativeOps) resetAllocator(preamble preambleInFlight) error { + f.events = append(f.events, fakePreambleEvent{op: "reset allocator", id: preamble.testID}) + if f.resetAllocatorFailures > 0 { + f.resetAllocatorFailures-- + return errors.New("reset allocator") + } + return nil +} + +func (f *fakePreambleNativeOps) resetCommandList(preamble preambleInFlight) error { + f.events = append(f.events, fakePreambleEvent{op: "reset list", id: preamble.testID}) + if f.resetListFailures > 0 { + f.resetListFailures-- + return errors.New("reset list") + } + return nil +} + +func (f *fakePreambleNativeOps) recordAndClose(preamble preambleInFlight, _ []stateBarrierPlan) error { + f.events = append(f.events, fakePreambleEvent{op: "close", id: preamble.testID}) + if f.closeFailures > 0 { + f.closeFailures-- + return errors.New("close list") + } + return nil +} + +func (f *fakePreambleNativeOps) release(preamble preambleInFlight) { + f.events = append(f.events, fakePreambleEvent{op: "release", id: preamble.testID}) + if f.releases == nil { + f.releases = make(map[uint64]int) + } + f.releases[preamble.testID]++ +} + +func TestPreamblePoolWaitsForFenceThenReusesPair(t *testing.T) { + ops := &fakePreambleNativeOps{} + queue := &Queue{state: &queueState{preambleOps: ops}} + + first, err := queue.buildPreamble(nil) + if err != nil || first.testID != 1 { + t.Fatalf("first preamble = (%+v, %v), want fresh pair 1", first, err) + } + first.submission = 4 + queue.state.preambleInFlight = append(queue.state.preambleInFlight, first) + queue.releaseCompletedPreambles(3) + if len(queue.state.preambleInFlight) != 1 || len(queue.state.preambleIdle) != 0 { + t.Fatal("preamble became reusable before its fence completed") + } + queue.releaseCompletedPreambles(4) + queue.releaseCompletedPreambles(4) // polling is idempotent + if len(queue.state.preambleInFlight) != 0 || len(queue.state.preambleIdle) != 1 || len(ops.releases) != 0 { + t.Fatalf("completed pool state = in-flight %d idle %d releases %v", len(queue.state.preambleInFlight), len(queue.state.preambleIdle), ops.releases) + } + + second, err := queue.buildPreamble(nil) + if err != nil || second.testID != first.testID { + t.Fatalf("reused preamble = (%+v, %v), want pair %d", second, err, first.testID) + } + want := []fakePreambleEvent{ + {op: "create", id: 1}, + {op: "close", id: 1}, + {op: "reset allocator", id: 1}, + {op: "reset list", id: 1}, + {op: "close", id: 1}, + } + if !equalPreambleEvents(ops.events, want) { + t.Fatalf("native events = %#v, want %#v", ops.events, want) + } +} + +func TestPreamblePoolDiscardsFailedReusablePairAndFallsBackFresh(t *testing.T) { + tests := []struct { + name string + resetAllocator int + resetList int + close int + wantOps []string + }{ + {name: "allocator reset", resetAllocator: 1, wantOps: []string{"reset allocator", "release", "create", "close"}}, + {name: "list reset", resetList: 1, wantOps: []string{"reset allocator", "reset list", "release", "create", "close"}}, + {name: "close", close: 1, wantOps: []string{"reset allocator", "reset list", "close", "release", "create", "close"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ops := &fakePreambleNativeOps{ + nextID: 10, + resetAllocatorFailures: test.resetAllocator, + resetListFailures: test.resetList, + closeFailures: test.close, + } + queue := &Queue{state: &queueState{ + preambleOps: ops, + preambleIdle: []preambleInFlight{{testID: 10}}, + }} + got, err := queue.buildPreamble(nil) + if err != nil || got.testID != 11 { + t.Fatalf("fallback preamble = (%+v, %v), want fresh pair 11", got, err) + } + if ops.releases[10] != 1 { + t.Fatalf("failed pair releases = %v, want pair 10 exactly once", ops.releases) + } + if gotOps := preambleEventOps(ops.events); !equalStrings(gotOps, test.wantOps) { + t.Fatalf("native operations = %v, want %v", gotOps, test.wantOps) + } + }) + } +} + +func TestPreambleBuildFailuresReleaseEveryCreatedPairOnce(t *testing.T) { + t.Run("fresh create", func(t *testing.T) { + ops := &fakePreambleNativeOps{createFailures: 1} + queue := &Queue{state: &queueState{preambleOps: ops}} + if _, err := queue.buildPreamble(nil); err == nil { + t.Fatal("fresh create failure succeeded") + } + if len(ops.releases) != 0 { + t.Fatalf("create failure releases = %v, want none", ops.releases) + } + }) + + t.Run("fresh close", func(t *testing.T) { + ops := &fakePreambleNativeOps{closeFailures: 1} + queue := &Queue{state: &queueState{preambleOps: ops}} + if _, err := queue.buildPreamble(nil); err == nil { + t.Fatal("fresh close failure succeeded") + } + if ops.releases[1] != 1 { + t.Fatalf("fresh close releases = %v, want pair 1 once", ops.releases) + } + }) + + t.Run("later preamble", func(t *testing.T) { + ops := &fakePreambleNativeOps{} + queue := &Queue{state: &queueState{preambleOps: ops}} + first, err := queue.buildPreamble(nil) + if err != nil { + t.Fatalf("first preamble: %v", err) + } + ops.createFailures = 1 + if _, err := queue.buildPreamble(nil); err == nil { + t.Fatal("second preamble create failure succeeded") + } + queue.releaseBuiltPreambles([]preambleInFlight{first}) + if ops.releases[first.testID] != 1 { + t.Fatalf("partial unwind releases = %v, want first pair once", ops.releases) + } + }) +} + +func TestPreamblePoolCapsIdlePairsAndRetainsUnfencedPairs(t *testing.T) { + ops := &fakePreambleNativeOps{} + queue := &Queue{state: &queueState{preambleOps: ops}} + for id := uint64(1); id <= maxFramesInFlight+2; id++ { + queue.state.preambleInFlight = append(queue.state.preambleInFlight, preambleInFlight{submission: 7, testID: id}) + } + queue.retainPreamblesWithoutFence([]preambleInFlight{{testID: 99}}) + queue.releaseCompletedPreambles(7) + queue.releaseCompletedPreambles(7) + + if len(queue.state.preambleIdle) != maxFramesInFlight { + t.Fatalf("idle pool size = %d, want cap %d", len(queue.state.preambleIdle), maxFramesInFlight) + } + if got := queue.state.preambleInFlight; len(got) != 1 || got[0].submission != 0 || got[0].testID != 99 { + t.Fatalf("retained unfenced pairs = %+v, want pair 99 at submission 0", got) + } + if len(ops.releases) != 2 { + t.Fatalf("excess releases = %v, want two pairs", ops.releases) + } + + queue.state.releaseAllOwnedLocked() + if len(queue.state.preambleIdle) != 0 || len(queue.state.preambleInFlight) != 0 { + t.Fatal("pool destruction retained owned pairs") + } + for id, count := range ops.releases { + if count != 1 { + t.Fatalf("pair %d release count = %d, want 1", id, count) + } + } +} + +func TestTerminalPreambleReleaseKeepsOnlyAmbiguousInFlightPairs(t *testing.T) { + tests := []struct { + name string + waitErr error + deviceRemoved bool + wantInFlight int + wantIdle int + }{ + {name: "successful wait"}, + {name: "confirmed removal", waitErr: errors.New("device removed"), deviceRemoved: true}, + {name: "ambiguous wait", waitErr: errors.New("event wait failed"), wantInFlight: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ops := &fakePreambleNativeOps{} + state := &queueState{ + preambleOps: ops, + preambleIdle: []preambleInFlight{{testID: 1}}, + preambleInFlight: []preambleInFlight{{submission: 2, testID: 2}}, + } + state.releaseTerminalOwnedLocked(test.waitErr, test.deviceRemoved) + if len(state.preambleInFlight) != test.wantInFlight || len(state.preambleIdle) != test.wantIdle { + t.Fatalf("terminal state = in-flight %d idle %d, want %d/%d", len(state.preambleInFlight), len(state.preambleIdle), test.wantInFlight, test.wantIdle) + } + if ops.releases[1] != 1 { + t.Fatalf("idle pair release count = %d, want 1", ops.releases[1]) + } + if test.wantInFlight == 0 && ops.releases[2] != 1 { + t.Fatalf("safe in-flight pair release count = %d, want 1", ops.releases[2]) + } + if test.wantInFlight == 1 && ops.releases[2] != 0 { + t.Fatal("ambiguous in-flight pair was released") + } + }) + } +} + +func equalPreambleEvents(got, want []fakePreambleEvent) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + +func preambleEventOps(events []fakePreambleEvent) []string { + result := make([]string, len(events)) + for i, event := range events { + result[i] = event.op + } + return result +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} + func TestQueueStateReleasesTrackedAndUnfencedOwnedObjects(t *testing.T) { device := &Device{} queue := newQueue(device) @@ -670,3 +1044,19 @@ func TestFailTextureViewCreationRecyclesAllocatedDescriptors(t *testing.T) { t.Fatalf("freed DSVs = %v, want %v", device.dsvHeap.freeList, want) } } + +func TestTextureViewDestroyRecyclesOnlyAllocatedDSVVariantsOnce(t *testing.T) { + device := &Device{dsvHeap: &DescriptorHeap{}} + view := &TextureView{ + texture: &Texture{}, + device: device, + hasDSV: true, + hasDSVVariants: [4]bool{true, true}, + dsvHeapIndex: [4]uint32{20, 21}, + } + view.Destroy() + view.Destroy() + if want := []uint32{20, 21}; !equalUint32s(device.dsvHeap.freeList, want) { + t.Fatalf("freed partial DSV variants = %v, want %v", device.dsvHeap.freeList, want) + } +} From 97c722c03d75ec25a56a0a442c6759dd47796cb5 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 15 Jul 2026 15:38:26 +0300 Subject: [PATCH 5/5] fix(dx12): integrate aligned copies with queue tracking --- hal/dx12/command.go | 176 +++------------------------------ hal/dx12/copy_commands.go | 38 ++++++- hal/dx12/copy_plan.go | 88 +++-------------- hal/dx12/copy_plan_test.go | 8 ++ hal/dx12/queue.go | 167 +++++++------------------------ hal/dx12/resource_test.go | 17 ++-- hal/dx12/state_tracker.go | 170 ------------------------------- hal/dx12/state_tracker_test.go | 33 ------- 8 files changed, 112 insertions(+), 585 deletions(-) diff --git a/hal/dx12/command.go b/hal/dx12/command.go index aae1d138..d1be73ea 100644 --- a/hal/dx12/command.go +++ b/hal/dx12/command.go @@ -287,67 +287,12 @@ func (e *CommandEncoder) CopyBufferToTexture(src hal.Buffer, dst hal.Texture, re if !e.isRecording { return } - - srcBuf, srcOk := src.(*Buffer) - dstTex, dstOk := dst.(*Texture) - if !srcOk || !dstOk { + srcBuf, srcOK := src.(*Buffer) + dstTex, dstOK := dst.(*Texture) + if !srcOK || !dstOK { return } - - plans := make([]stateBarrierPlan, 0, 1+len(regions)) - if before, needsBarrier := e.stateTracker.transitionBuffer(srcBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: srcBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) - } - for _, r := range regions { - for _, copyPlan := range planBufferTextureCopies(dstTex, r.TextureBase, r.BufferLayout, r.Size) { - if before, needsBarrier := e.stateTracker.transitionTexture(dstTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) - } - } - } - e.emitStateBarrierPlans(plans) - - for _, r := range regions { - for _, copyPlan := range planBufferTextureCopies(dstTex, r.TextureBase, r.BufferLayout, r.Size) { - // A 2D array layer is a distinct subresource and therefore needs a - // distinct placed footprint with depth=1. - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: copyPlan.bufferOffset, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(dstTex.format), - Width: r.Size.Width, - Height: copyPlan.footprintHeight, - Depth: copyPlan.footprintDepth, - RowPitch: r.BufferLayout.BytesPerRow, - }, - }) - - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(copyPlan.subresource) - srcBox := d3d12.D3D12_BOX{ - Left: 0, - Top: copyPlan.bufferOriginY, - Front: 0, - Right: r.Size.Width, - Bottom: copyPlan.bufferOriginY + r.Size.Height, - Back: copyPlan.footprintDepth, - } - - e.cmdList.CopyTextureRegion( - &dstLoc, - r.TextureBase.Origin.X, r.TextureBase.Origin.Y, copyPlan.textureOriginZ, - &srcLoc, - &srcBox, - ) - } - } + e.copyBufferToTexture(srcBuf, dstTex, regions) } // CopyTextureToBuffer copies data from a texture to a buffer. @@ -356,64 +301,12 @@ func (e *CommandEncoder) CopyTextureToBuffer(src hal.Texture, dst hal.Buffer, re if !e.isRecording { return } - - srcTex, srcOk := src.(*Texture) - dstBuf, dstOk := dst.(*Buffer) - if !srcOk || !dstOk { + srcTex, srcOK := src.(*Texture) + dstBuf, dstOK := dst.(*Buffer) + if !srcOK || !dstOK { return } - - plans := make([]stateBarrierPlan, 0, 1+len(regions)) - if before, needsBarrier := e.stateTracker.transitionBuffer(dstBuf, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: dstBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) - } - for _, r := range regions { - for _, copyPlan := range planBufferTextureCopies(srcTex, r.TextureBase, r.BufferLayout, r.Size) { - if before, needsBarrier := e.stateTracker.transitionTexture(srcTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: srcTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) - } - } - } - e.emitStateBarrierPlans(plans) - - for _, r := range regions { - // D3D12 requires RowPitch aligned to 256 bytes. - // The caller should pass aligned BytesPerRow, but align defensively. - rowPitch := (r.BufferLayout.BytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) - for _, copyPlan := range planBufferTextureCopies(srcTex, r.TextureBase, r.BufferLayout, r.Size) { - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - srcLoc.SetSubresourceIndex(copyPlan.subresource) - - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - dstLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: copyPlan.bufferOffset, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(srcTex.format), - Width: r.Size.Width, - Height: copyPlan.footprintHeight, - Depth: copyPlan.footprintDepth, - RowPitch: rowPitch, - }, - }) - - srcBox := d3d12.D3D12_BOX{ - Left: r.TextureBase.Origin.X, - Top: r.TextureBase.Origin.Y, - Front: copyPlan.textureOriginZ, - Right: r.TextureBase.Origin.X + r.Size.Width, - Bottom: r.TextureBase.Origin.Y + r.Size.Height, - Back: copyPlan.textureOriginZ + copyPlan.footprintDepth, - } - - e.cmdList.CopyTextureRegion(&dstLoc, 0, copyPlan.bufferOriginY, 0, &srcLoc, &srcBox) - } - } + e.copyTextureToBuffer(srcTex, dstBuf, regions) } // CopyTextureToTexture copies data between textures. @@ -421,57 +314,12 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst hal.Texture, regions []ha if !e.isRecording { return } - - srcTex, srcOk := src.(*Texture) - dstTex, dstOk := dst.(*Texture) - if !srcOk || !dstOk { + srcTex, srcOK := src.(*Texture) + dstTex, dstOK := dst.(*Texture) + if !srcOK || !dstOK { return } - - plans := make([]stateBarrierPlan, 0, len(regions)*2) - for _, r := range regions { - for _, copyPlan := range planTextureTextureCopies(srcTex, dstTex, r) { - if before, needsBarrier := e.stateTracker.transitionTexture(srcTex, copyPlan.srcSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: srcTex, subresource: copyPlan.srcSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) - } - if before, needsBarrier := e.stateTracker.transitionTexture(dstTex, copyPlan.dstSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.dstSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) - } - } - } - e.emitStateBarrierPlans(plans) - - for _, r := range regions { - for _, copyPlan := range planTextureTextureCopies(srcTex, dstTex, r) { - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: srcTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - srcLoc.SetSubresourceIndex(copyPlan.srcSubresource) - - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(copyPlan.dstSubresource) - - srcBox := d3d12.D3D12_BOX{ - Left: r.SrcBase.Origin.X, - Top: r.SrcBase.Origin.Y, - Front: copyPlan.srcFront, - Right: r.SrcBase.Origin.X + r.Size.Width, - Bottom: r.SrcBase.Origin.Y + r.Size.Height, - Back: copyPlan.srcBack, - } - - e.cmdList.CopyTextureRegion( - &dstLoc, - r.DstBase.Origin.X, r.DstBase.Origin.Y, copyPlan.dstZ, - &srcLoc, - &srcBox, - ) - } - } + e.copyTextureToTexture(srcTex, dstTex, regions) } // ResolveQuerySet copies query results from a query set into a destination buffer. diff --git a/hal/dx12/copy_commands.go b/hal/dx12/copy_commands.go index 43911218..9240daad 100644 --- a/hal/dx12/copy_commands.go +++ b/hal/dx12/copy_commands.go @@ -12,7 +12,18 @@ import ( ) func (e *CommandEncoder) copyBufferToTexture(src *Buffer, texture *Texture, regions []hal.BufferTextureCopy) { - e.transitionBufferIfNeeded(src, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE) + plans := make([]stateBarrierPlan, 0, 1+len(regions)) + if before, barrier := e.stateTracker.transitionBuffer(src, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); barrier { + plans = append(plans, stateBarrierPlan{resource: src, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + for _, region := range regions { + for _, copyPlan := range planBufferTextureCopies(texture, region.TextureBase, region.BufferLayout, region.Size) { + if before, barrier := e.stateTracker.transitionTexture(texture, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); barrier { + plans = append(plans, stateBarrierPlan{resource: texture, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + } + } + e.emitStateBarrierPlans(plans) for _, region := range regions { for _, plan := range planBufferTextureCopies(texture, region.TextureBase, region.BufferLayout, region.Size) { srcLoc := placedFootprintLocation(src, texture.format, plan) @@ -31,7 +42,18 @@ func (e *CommandEncoder) copyBufferToTexture(src *Buffer, texture *Texture, regi } func (e *CommandEncoder) copyTextureToBuffer(src *Texture, dst *Buffer, regions []hal.BufferTextureCopy) { - e.transitionBufferIfNeeded(dst, d3d12.D3D12_RESOURCE_STATE_COPY_DEST) + plans := make([]stateBarrierPlan, 0, 1+len(regions)) + if before, barrier := e.stateTracker.transitionBuffer(dst, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); barrier { + plans = append(plans, stateBarrierPlan{resource: dst, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + for _, region := range regions { + for _, copyPlan := range planBufferTextureCopies(src, region.TextureBase, region.BufferLayout, region.Size) { + if before, barrier := e.stateTracker.transitionTexture(src, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); barrier { + plans = append(plans, stateBarrierPlan{resource: src, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + } + } + e.emitStateBarrierPlans(plans) for _, region := range regions { for _, plan := range planBufferTextureCopies(src, region.TextureBase, region.BufferLayout, region.Size) { srcLoc := subresourceLocation(src, plan.subresource) @@ -50,6 +72,18 @@ func (e *CommandEncoder) copyTextureToBuffer(src *Texture, dst *Buffer, regions } func (e *CommandEncoder) copyTextureToTexture(src, dst *Texture, regions []hal.TextureCopy) { + plans := make([]stateBarrierPlan, 0, len(regions)*2) + for _, region := range regions { + for _, copyPlan := range planTextureTextureCopies(src, dst, region) { + if before, barrier := e.stateTracker.transitionTexture(src, copyPlan.srcSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); barrier { + plans = append(plans, stateBarrierPlan{resource: src, subresource: copyPlan.srcSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) + } + if before, barrier := e.stateTracker.transitionTexture(dst, copyPlan.dstSubresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); barrier { + plans = append(plans, stateBarrierPlan{resource: dst, subresource: copyPlan.dstSubresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) + } + } + } + e.emitStateBarrierPlans(plans) for _, region := range regions { for _, plan := range planTextureTextureCopies(src, dst, region) { srcLoc := subresourceLocation(src, plan.srcSubresource) diff --git a/hal/dx12/copy_plan.go b/hal/dx12/copy_plan.go index b07410f7..47700231 100644 --- a/hal/dx12/copy_plan.go +++ b/hal/dx12/copy_plan.go @@ -120,81 +120,6 @@ func copyExtentBlocks(info textureBlockInfo, origin hal.Origin3D, extent hal.Ext return endBlockX - startBlockX, endBlockY - startBlockY, true } -func textureAspectPlanes(texture *Texture, aspect gputypes.TextureAspect) []uint32 { - if texture == nil || texture.planeCount() < 2 { - return []uint32{0} - } - if texture.format == gputypes.TextureFormatStencil8 { - if aspect == gputypes.TextureAspectDepthOnly { - return nil - } - return []uint32{1} - } - if texture.format == gputypes.TextureFormatDepth24Plus { - if aspect == gputypes.TextureAspectStencilOnly { - return nil - } - return []uint32{0} - } - switch aspect { - case gputypes.TextureAspectDepthOnly: - return []uint32{0} - case gputypes.TextureAspectStencilOnly: - return []uint32{1} - default: - return []uint32{0, 1} - } -} - -func (t *Texture) planeCount() uint32 { - switch t.format { - case gputypes.TextureFormatDepth24Plus, - gputypes.TextureFormatDepth24PlusStencil8, - gputypes.TextureFormatDepth32FloatStencil8, - gputypes.TextureFormatStencil8: - return 2 - default: - return 1 - } -} - -func (t *Texture) subresourceCount() uint32 { - mips := t.mipLevels - if mips == 0 { - mips = 1 - } - layers := uint32(1) - if t.dimension != gputypes.TextureDimension3D { - layers = t.size.DepthOrArrayLayers - if layers == 0 { - layers = 1 - } - } - return mips * layers * t.planeCount() -} - -func (t *Texture) subresourceIndexForPlane(mipLevel, arrayLayer, plane uint32) uint32 { - mips := t.mipLevels - if mips == 0 { - mips = 1 - } - layers := uint32(1) - if t.dimension != gputypes.TextureDimension3D { - layers = t.size.DepthOrArrayLayers - if layers == 0 { - layers = 1 - } - } - return mipLevel + arrayLayer*mips + plane*mips*layers -} - -func (t *Texture) subresourceIndex(mipLevel, arrayLayer uint32) uint32 { - return t.subresourceIndexForPlane(mipLevel, arrayLayer, 0) -} - -// planPlacedBufferSlice aligns a logical image slice down to D3D12's 512-byte -// placement boundary. The byte delta is represented by block-aware source -// origins, so the selected source box still starts at the original address. func planPlacedBufferSlice(logicalOffset uint64, rowPitch, blockWidth, blockHeight, blockBytes, copyWidth, copyHeight uint32) (bufferOffset uint64, bufferOriginX, bufferOriginY, footprintWidth, footprintHeight uint32, ok bool) { if rowPitch == 0 || blockWidth == 0 || blockHeight == 0 || blockBytes == 0 { return 0, 0, 0, 0, 0, false @@ -296,11 +221,20 @@ func writeTextureNativeLayout(texture *Texture, layout hal.ImageDataLayout, size } nativeLayout := layout nativeLayout.BytesPerRow = nativeRowPitch - _, _, blockRows, ok := normalizedCopyLayout(texture, nativeLayout, size) + sourceRowsPerImage := layout.RowsPerImage + if sourceRowsPerImage == 0 { + sourceRowsPerImage = size.Height + } + if sourceRowsPerImage < size.Height { + return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false + } + sourceBlockRows := (sourceRowsPerImage + info.height - 1) / info.height + nativeLayout.RowsPerImage = size.Height + _, _, _, ok = normalizedCopyLayout(texture, nativeLayout, size) if !ok { return textureBlockInfo{}, hal.ImageDataLayout{}, 0, 0, false } - return info, nativeLayout, sourceBytesPerRow, blockRows, true + return info, nativeLayout, sourceBytesPerRow, sourceBlockRows, true } // planBufferTextureCopies converts a WebGPU buffer/texture copy into one plan diff --git a/hal/dx12/copy_plan_test.go b/hal/dx12/copy_plan_test.go index 022e5215..0434116f 100644 --- a/hal/dx12/copy_plan_test.go +++ b/hal/dx12/copy_plan_test.go @@ -119,6 +119,14 @@ func TestWriteTextureNativeLayoutRepacksUnalignedSourcePitch(t *testing.T) { } } +func TestWriteTextureNativeLayoutKeepsSourcePaddingOutOfStaging(t *testing.T) { + texture := testTexture(gputypes.TextureFormatBC1RGBAUnorm, gputypes.TextureDimension2D, 8, 8, 2, 1) + _, native, _, sourceBlockRows, ok := writeTextureNativeLayout(texture, hal.ImageDataLayout{BytesPerRow: 300, RowsPerImage: 8}, hal.Extent3D{Width: 8, Height: 4, DepthOrArrayLayers: 2}) + if !ok || sourceBlockRows != 2 || native.RowsPerImage != 4 { + t.Fatalf("native layout = %#v, source block rows = %d, ok=%v", native, sourceBlockRows, ok) + } +} + func TestPlanBufferTextureCopiesPadded3DUsesAbsoluteDepth(t *testing.T) { texture := testTexture(gputypes.TextureFormatRGBA8Unorm, gputypes.TextureDimension3D, 32, 32, 8, 1) copy := hal.ImageCopyTexture{Origin: hal.Origin3D{X: 2, Y: 3, Z: 2}, Aspect: gputypes.TextureAspectAll} diff --git a/hal/dx12/queue.go b/hal/dx12/queue.go index dfcd3bf1..48f9f45b 100644 --- a/hal/dx12/queue.go +++ b/hal/dx12/queue.go @@ -647,13 +647,8 @@ func (q *Queue) writeBufferStaged(buf *Buffer, offset uint64, data []byte) error return nil } -const ( - // D3D12 placed footprints require a 256-byte row pitch and a 512-byte - // starting offset. Array layers use separate footprints, so their staging - // strides must satisfy both alignments. - d3d12TexturePitchAlignment = 256 - d3d12TexturePlacementAlignment = 512 -) +// D3D12 placed footprints require a 256-byte row pitch. +const d3d12TexturePitchAlignment = 256 // WriteTexture writes data to a texture immediately. // Creates an upload heap staging buffer, copies data with proper row pitch @@ -663,182 +658,92 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal return err } defer q.state.submitMu.Unlock() - - if dst == nil || dst.Texture == nil || len(data) == 0 || layout == nil || size == nil { + if dst == nil || dst.Texture == nil || layout == nil || len(data) == 0 || size == nil { return fmt.Errorf("dx12: WriteTexture: invalid arguments") } - dstTex, ok := dst.Texture.(*Texture) if !ok || dstTex.raw == nil { return fmt.Errorf("dx12: WriteTexture: invalid texture type") } - - // Calculate layout parameters - bytesPerRow := layout.BytesPerRow - if bytesPerRow == 0 { - bytesPerRow = size.Width * 4 // Assume RGBA8 (4 bytes per pixel) + info, nativeLayout, sourceBPR, _, valid := writeTextureNativeLayout(dstTex, *layout, *size) + if !valid { + return fmt.Errorf("dx12: WriteTexture: layout cannot be represented") } - - rowsPerImage := layout.RowsPerImage - if rowsPerImage == 0 { - rowsPerImage = size.Height + copyBlockRows := (size.Height + info.height - 1) / info.height + sourceRows := layout.RowsPerImage + if sourceRows == 0 { + sourceRows = size.Height } - blockHeight := textureFormatBlockHeight(dstTex.format) - sourceBlockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight - copyBlockRows := (size.Height + blockHeight - 1) / blockHeight - - depthOrLayers := size.DepthOrArrayLayers - if depthOrLayers == 0 { - depthOrLayers = 1 + sourceBlockRows := (sourceRows + info.height - 1) / info.height + nativeLayout.Offset = 0 + nativeLayout.RowsPerImage = copyBlockRows * info.height + rowPitch := nativeLayout.BytesPerRow + depth := size.DepthOrArrayLayers + if depth == 0 { + depth = 1 } - - // D3D12 requires RowPitch to be aligned to 256 bytes - alignedRowPitch := (bytesPerRow + d3d12TexturePitchAlignment - 1) &^ (d3d12TexturePitchAlignment - 1) - - stagingBlockRowsPerImage := copyBlockRows - stagingLayerStride := uint64(alignedRowPitch) * uint64(stagingBlockRowsPerImage) - if dstTex.dimension != gputypes.TextureDimension3D { - stagingLayerStride = (stagingLayerStride + d3d12TexturePlacementAlignment - 1) &^ uint64(d3d12TexturePlacementAlignment-1) - stagingBlockRowsPerImage = uint32(stagingLayerStride / uint64(alignedRowPitch)) - } - stagingRowsPerImage := stagingBlockRowsPerImage * blockHeight - stagingSize := stagingLayerStride * uint64(depthOrLayers) + stagingSize := uint64(rowPitch) * uint64(copyBlockRows) * uint64(depth) owner := newOneShotWriteOwner(nil, dstTex.raw) defer func() { if owner != nil { owner.release() } }() - - // Create upload heap staging buffer - staging, err := q.device.CreateBuffer(&hal.BufferDescriptor{ - Label: "write-texture-staging", - Size: stagingSize, - Usage: gputypes.BufferUsageCopySrc | gputypes.BufferUsageMapWrite, - MappedAtCreation: true, - }) + staging, err := q.device.CreateBuffer(&hal.BufferDescriptor{Label: "write-texture-staging", Size: stagingSize, Usage: gputypes.BufferUsageCopySrc | gputypes.BufferUsageMapWrite, MappedAtCreation: true}) if err != nil { return fmt.Errorf("dx12: WriteTexture: CreateBuffer failed: %w", err) } stagingBuf := staging.(*Buffer) owner.staging = stagingBuf - - // Repack only the image rows. Input RowsPerImage controls the source stride; - // staging padding is chosen independently to satisfy D3D12 placement rules. - srcOffset := layout.Offset - for z := uint32(0); z < depthOrLayers; z++ { + logicalRowBytes := ((uint64(size.Width) + uint64(info.width) - 1) / uint64(info.width)) * uint64(info.bytes) + for z := uint32(0); z < depth; z++ { for row := uint32(0); row < copyBlockRows; row++ { - srcStart := srcOffset + uint64(z)*uint64(bytesPerRow)*uint64(sourceBlockRowsPerImage) + uint64(row)*uint64(bytesPerRow) - if srcStart > uint64(len(data)) || uint64(bytesPerRow) > uint64(len(data))-srcStart { - return fmt.Errorf("dx12: WriteTexture: source data is too short for layer %d block row %d", z, row) + srcStart := textureWriteSourceOffset(layout.Offset, sourceBPR, sourceBlockRows, z, row) + if srcStart > uint64(len(data)) || logicalRowBytes > uint64(len(data))-srcStart { + return fmt.Errorf("dx12: WriteTexture: data is smaller than layout") } - dstStart := uint64(z)*stagingLayerStride + uint64(row)*uint64(alignedRowPitch) - src := data[srcStart : srcStart+uint64(bytesPerRow)] - d := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), bytesPerRow) - copy(d, src) + dstStart := uint64(z)*uint64(rowPitch)*uint64(copyBlockRows) + uint64(row)*uint64(rowPitch) + src := data[srcStart : srcStart+logicalRowBytes] + target := unsafe.Slice((*byte)(unsafe.Add(stagingBuf.mappedPointer, int(dstStart))), len(src)) + copy(target, src) } } - - // Unmap staging buffer - writtenRange := &d3d12.D3D12_RANGE{Begin: 0, End: uintptr(stagingSize)} - stagingBuf.raw.Unmap(0, writtenRange) + stagingBuf.raw.Unmap(0, &d3d12.D3D12_RANGE{Begin: 0, End: uintptr(stagingSize)}) stagingBuf.mappedPointer = nil - - // Create one-shot command encoder - cmdEncoder, err := q.device.CreateCommandEncoder(&hal.CommandEncoderDescriptor{ - Label: "write-texture-copy", - }) + cmdEncoder, err := q.device.CreateCommandEncoder(&hal.CommandEncoderDescriptor{Label: "write-texture-copy"}) if err != nil { return fmt.Errorf("dx12: WriteTexture: CreateCommandEncoder failed: %w", err) } - encoder := cmdEncoder.(*CommandEncoder) owner.encoder = encoder if err := encoder.BeginEncoding("write-texture-copy"); err != nil { return fmt.Errorf("dx12: WriteTexture: BeginEncoding failed: %w", err) } - - plans := make([]stateBarrierPlan, 0, 3) - if before, needsBarrier := encoder.stateTracker.transitionBuffer(stagingBuf, d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: stagingBuf, subresource: d3d12.D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_SOURCE}) - } - stagingLayout := hal.ImageDataLayout{BytesPerRow: alignedRowPitch, RowsPerImage: stagingRowsPerImage} - copyPlans := planBufferTextureCopies(dstTex, *dst, stagingLayout, *size) - for _, copyPlan := range copyPlans { - if before, needsBarrier := encoder.stateTracker.transitionTexture(dstTex, copyPlan.subresource, d3d12.D3D12_RESOURCE_STATE_COPY_DEST); needsBarrier { - plans = append(plans, stateBarrierPlan{resource: dstTex, subresource: copyPlan.subresource, before: before, after: d3d12.D3D12_RESOURCE_STATE_COPY_DEST}) - } - } - encoder.emitStateBarrierPlans(plans) - - for _, copyPlan := range copyPlans { - srcLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: stagingBuf.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT, - } - srcLoc.SetPlacedFootprint(d3d12.D3D12_PLACED_SUBRESOURCE_FOOTPRINT{ - Offset: copyPlan.bufferOffset, - Footprint: d3d12.D3D12_SUBRESOURCE_FOOTPRINT{ - Format: textureFormatToD3D12(dstTex.format), - Width: size.Width, - Height: copyPlan.footprintHeight, - Depth: copyPlan.footprintDepth, - RowPitch: alignedRowPitch, - }, - }) - - dstLoc := d3d12.D3D12_TEXTURE_COPY_LOCATION{ - Resource: dstTex.raw, - Type: d3d12.D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX, - } - dstLoc.SetSubresourceIndex(copyPlan.subresource) - srcBox := d3d12.D3D12_BOX{ - Left: 0, - Top: copyPlan.bufferOriginY, - Front: 0, - Right: size.Width, - Bottom: copyPlan.bufferOriginY + size.Height, - Back: copyPlan.footprintDepth, - } - - encoder.cmdList.CopyTextureRegion( - &dstLoc, - dst.Origin.X, dst.Origin.Y, copyPlan.textureOriginZ, - &srcLoc, - &srcBox, - ) - } - - // Transition the written subresources to shader resource state (ready for rendering). - afterState := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE - for _, copyPlan := range copyPlans { - if before, needsBarrier := encoder.stateTracker.transitionTexture(dstTex, copyPlan.subresource, afterState); needsBarrier { - encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: dstTex, subresource: copyPlan.subresource, before: before, after: afterState}}) + regions := []hal.BufferTextureCopy{{BufferLayout: nativeLayout, TextureBase: *dst, Size: *size}} + encoder.copyBufferToTexture(stagingBuf, dstTex, regions) + after := d3d12.D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE | d3d12.D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE + for _, plan := range planBufferTextureCopies(dstTex, *dst, nativeLayout, *size) { + if before, barrier := encoder.stateTracker.transitionTexture(dstTex, plan.subresource, after); barrier { + encoder.emitStateBarrierPlans([]stateBarrierPlan{{resource: dstTex, subresource: plan.subresource, before: before, after: after}}) } } - - // End encoding cmdBuffer, err := encoder.EndEncoding() if err != nil { return fmt.Errorf("dx12: WriteTexture: EndEncoding failed: %w", err) } owner.commandBuffer = cmdBuffer.(*CommandBuffer) - - // Submit and wait for GPU completion. submission, err := q.submitLocked([]hal.CommandBuffer{cmdBuffer}) if err != nil { q.retainOneShot(owner, 0) owner = nil return fmt.Errorf("dx12: WriteTexture: Submit failed: %w", err) } - // Block until GPU finishes the copy — staging buffer must remain valid. if err := q.device.waitForGPU(); err != nil { q.retainOneShot(owner, submission) owner = nil return fmt.Errorf("dx12: WriteTexture: WaitIdle failed: %w", err) } q.state.releaseAllOwnedLocked() - return nil } diff --git a/hal/dx12/resource_test.go b/hal/dx12/resource_test.go index 3834a12f..1a9ca885 100644 --- a/hal/dx12/resource_test.go +++ b/hal/dx12/resource_test.go @@ -120,14 +120,15 @@ func TestFailTextureViewCreationRecyclesAllocatedDescriptors(t *testing.T) { stagingViewHeap: &DescriptorHeap{}, } view := &TextureView{ - texture: &Texture{}, - device: device, - hasRTV: true, - rtvHeapIndex: 2, - hasDSV: true, - dsvHeapIndex: 3, - hasSRV: true, - srvHeapIndex: 4, + texture: &Texture{}, + device: device, + hasRTV: true, + rtvHeapIndex: 2, + hasDSV: true, + hasDSVVariants: [4]bool{true}, + dsvHeapIndex: [4]uint32{3}, + hasSRV: true, + srvHeapIndex: 4, } result, err := failTextureViewCreation(view, errors.New("view creation failed")) diff --git a/hal/dx12/state_tracker.go b/hal/dx12/state_tracker.go index 5d71bb38..a96f1591 100644 --- a/hal/dx12/state_tracker.go +++ b/hal/dx12/state_tracker.go @@ -372,176 +372,6 @@ func textureRangeSubresources(texture *Texture, r hal.TextureRange) []uint32 { return result } -type bufferTextureCopyPlan struct { - subresource uint32 - bufferOffset uint64 - bufferOriginY uint32 - footprintHeight uint32 - footprintDepth uint32 - textureOriginZ uint32 -} - -// planBufferTextureCopies converts WebGPU's depth-or-array-layer copy into -// the native operations D3D12 requires. A 3D texture keeps one subresource -// but emits a depth-1 footprint per Z slice; a 2D array emits one footprint -// and one texture subresource per layer. -func planBufferTextureCopies(texture *Texture, copy hal.ImageCopyTexture, layout hal.ImageDataLayout, size hal.Extent3D) []bufferTextureCopyPlan { - if texture == nil { - return nil - } - depthOrLayers := size.DepthOrArrayLayers - if depthOrLayers == 0 { - depthOrLayers = 1 - } - planes := textureAspectPlanes(texture, copy.Aspect) - blockHeight := textureFormatBlockHeight(texture.format) - if texture.dimension == gputypes.TextureDimension3D { - rowsPerImage := layout.RowsPerImage - if rowsPerImage == 0 { - rowsPerImage = size.Height - } - blockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight - sliceStride := uint64(layout.BytesPerRow) * uint64(blockRowsPerImage) - plans := make([]bufferTextureCopyPlan, 0, int(depthOrLayers)*len(planes)) - for _, plane := range planes { - for z := uint32(0); z < depthOrLayers; z++ { - logicalOffset := layout.Offset + uint64(z)*sliceStride - placedOffset, bufferOriginY, footprintHeight := planPlacedBufferSlice(logicalOffset, layout.BytesPerRow, blockHeight, size.Height) - plans = append(plans, bufferTextureCopyPlan{ - subresource: texture.subresourceIndexForPlane(copy.MipLevel, 0, plane), - bufferOffset: placedOffset, - bufferOriginY: bufferOriginY, - footprintHeight: footprintHeight, - footprintDepth: 1, - textureOriginZ: copy.Origin.Z + z, - }) - } - } - return plans - } - - rowsPerImage := layout.RowsPerImage - if rowsPerImage == 0 { - rowsPerImage = size.Height - } - blockRowsPerImage := (rowsPerImage + blockHeight - 1) / blockHeight - layerStride := uint64(layout.BytesPerRow) * uint64(blockRowsPerImage) - plans := make([]bufferTextureCopyPlan, 0, int(depthOrLayers)*len(planes)) - for _, plane := range planes { - for layer := uint32(0); layer < depthOrLayers; layer++ { - logicalOffset := layout.Offset + uint64(layer)*layerStride - placedOffset, bufferOriginY, footprintHeight := planPlacedBufferSlice(logicalOffset, layout.BytesPerRow, blockHeight, size.Height) - plans = append(plans, bufferTextureCopyPlan{ - subresource: texture.subresourceIndexForPlane(copy.MipLevel, copy.Origin.Z+layer, plane), - bufferOffset: placedOffset, - bufferOriginY: bufferOriginY, - footprintHeight: footprintHeight, - footprintDepth: 1, - textureOriginZ: 0, - }) - } - } - return plans -} - -// planPlacedBufferSlice maps one logical image/depth slice to a legacy D3D12 -// placed footprint. Rewinding by whole block rows preserves the exact data -// address while meeting the 512-byte placement requirement. -func planPlacedBufferSlice(logicalOffset uint64, bytesPerRow, blockHeight, copyHeight uint32) (placedOffset uint64, bufferOriginY, footprintHeight uint32) { - placedOffset = logicalOffset - for bytesPerRow != 0 && placedOffset%d3d12TexturePlacementAlignment != 0 && placedOffset >= uint64(bytesPerRow) { - placedOffset -= uint64(bytesPerRow) - bufferOriginY += blockHeight - } - return placedOffset, bufferOriginY, copyHeight + bufferOriginY -} - -func textureFormatBlockHeight(format gputypes.TextureFormat) uint32 { - switch format { - case gputypes.TextureFormatBC1RGBAUnorm, - gputypes.TextureFormatBC1RGBAUnormSrgb, - gputypes.TextureFormatBC2RGBAUnorm, - gputypes.TextureFormatBC2RGBAUnormSrgb, - gputypes.TextureFormatBC3RGBAUnorm, - gputypes.TextureFormatBC3RGBAUnormSrgb, - gputypes.TextureFormatBC4RUnorm, - gputypes.TextureFormatBC4RSnorm, - gputypes.TextureFormatBC5RGUnorm, - gputypes.TextureFormatBC5RGSnorm, - gputypes.TextureFormatBC6HRGBUfloat, - gputypes.TextureFormatBC6HRGBFloat, - gputypes.TextureFormatBC7RGBAUnorm, - gputypes.TextureFormatBC7RGBAUnormSrgb: - return 4 - default: - return 1 - } -} - -type textureTextureCopyPlan struct { - srcSubresource uint32 - dstSubresource uint32 - srcFront uint32 - srcBack uint32 - dstZ uint32 -} - -// planTextureTextureCopies applies D3D12's distinct 3D-volume and 2D-array -// copy models while pairing the selected source and destination planes. -func planTextureTextureCopies(src, dst *Texture, copy hal.TextureCopy) []textureTextureCopyPlan { - if src == nil || dst == nil { - return nil - } - srcPlanes := textureAspectPlanes(src, copy.SrcBase.Aspect) - dstPlanes := textureAspectPlanes(dst, copy.DstBase.Aspect) - planeCount := len(srcPlanes) - if len(dstPlanes) < planeCount { - planeCount = len(dstPlanes) - } - if planeCount == 0 { - return nil - } - depthOrLayers := copy.Size.DepthOrArrayLayers - if depthOrLayers == 0 { - depthOrLayers = 1 - } - - if src.dimension == gputypes.TextureDimension3D || dst.dimension == gputypes.TextureDimension3D { - // Core validation requires matching texture dimensions. Refuse to - // invent layer/depth semantics for direct HAL callers that bypass it. - if src.dimension != dst.dimension { - return nil - } - plans := make([]textureTextureCopyPlan, 0, planeCount) - for plane := 0; plane < planeCount; plane++ { - plans = append(plans, textureTextureCopyPlan{ - srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, 0, srcPlanes[plane]), - dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, 0, dstPlanes[plane]), - srcFront: copy.SrcBase.Origin.Z, - srcBack: copy.SrcBase.Origin.Z + depthOrLayers, - dstZ: copy.DstBase.Origin.Z, - }) - } - return plans - } - - plans := make([]textureTextureCopyPlan, 0, planeCount*int(depthOrLayers)) - for plane := 0; plane < planeCount; plane++ { - for layer := uint32(0); layer < depthOrLayers; layer++ { - plans = append(plans, textureTextureCopyPlan{ - srcSubresource: src.subresourceIndexForPlane(copy.SrcBase.MipLevel, copy.SrcBase.Origin.Z+layer, srcPlanes[plane]), - dstSubresource: dst.subresourceIndexForPlane(copy.DstBase.MipLevel, copy.DstBase.Origin.Z+layer, dstPlanes[plane]), - srcFront: 0, - srcBack: 1, - dstZ: 0, - }) - } - } - return plans -} - -// depthStencilPlaneState returns the D3D12 state for one depth/stencil plane -// under a render-pass attachment's independent read-only flags. func depthStencilPlaneState(plane uint32, depthReadOnly, stencilReadOnly, shaderReadable bool) d3d12.D3D12_RESOURCE_STATES { readOnly := depthReadOnly if plane != 0 { diff --git a/hal/dx12/state_tracker_test.go b/hal/dx12/state_tracker_test.go index fd3413cb..223922f8 100644 --- a/hal/dx12/state_tracker_test.go +++ b/hal/dx12/state_tracker_test.go @@ -1012,39 +1012,6 @@ func TestTerminalOwnedObjectReleaseRequiresProofOfCompletion(t *testing.T) { } } -func TestFailTextureViewCreationRecyclesAllocatedDescriptors(t *testing.T) { - device := &Device{ - stagingViewHeap: &DescriptorHeap{}, - rtvHeap: &DescriptorHeap{}, - dsvHeap: &DescriptorHeap{}, - } - view := &TextureView{ - texture: &Texture{}, - device: device, - hasSRV: true, - hasRTV: true, - hasDSV: true, - hasDSVVariants: [4]bool{true, true, true, true}, - srvHeapIndex: 11, - rtvHeapIndex: 12, - dsvHeapIndex: [4]uint32{20, 21, 22, 23}, - } - wantErr := errors.New("descriptor allocation failed") - gotView, gotErr := failTextureViewCreation(view, wantErr) - if gotView != nil || !errors.Is(gotErr, wantErr) { - t.Fatalf("failure result = (%v, %v), want (nil, %v)", gotView, gotErr, wantErr) - } - if want := []uint32{11}; !equalUint32s(device.stagingViewHeap.freeList, want) { - t.Fatalf("freed SRVs = %v, want %v", device.stagingViewHeap.freeList, want) - } - if want := []uint32{12}; !equalUint32s(device.rtvHeap.freeList, want) { - t.Fatalf("freed RTVs = %v, want %v", device.rtvHeap.freeList, want) - } - if want := []uint32{20, 21, 22, 23}; !equalUint32s(device.dsvHeap.freeList, want) { - t.Fatalf("freed DSVs = %v, want %v", device.dsvHeap.freeList, want) - } -} - func TestTextureViewDestroyRecyclesOnlyAllocatedDSVVariantsOnce(t *testing.T) { device := &Device{dsvHeap: &DescriptorHeap{}} view := &TextureView{