diff --git a/hal/metal/device.go b/hal/metal/device.go index 66cabfa..9688a4e 100644 --- a/hal/metal/device.go +++ b/hal/metal/device.go @@ -190,11 +190,11 @@ func (d *Device) CreateTexture(desc *hal.TextureDescriptor) (hal.Texture, error) _ = MsgSend(texDesc, Sel("setWidth:"), uintptr(desc.Size.Width)) _ = MsgSend(texDesc, Sel("setHeight:"), uintptr(desc.Size.Height)) - depth := desc.Size.DepthOrArrayLayers - if depth == 0 { - depth = 1 + shape := metalTextureDescriptorShape(desc.Dimension, desc.Size.DepthOrArrayLayers) + _ = MsgSend(texDesc, Sel("setDepth:"), uintptr(shape.depth)) + if texType == MTLTextureType1DArray || texType == MTLTextureType2DArray { + _ = MsgSend(texDesc, Sel("setArrayLength:"), uintptr(shape.arrayLength)) } - _ = MsgSend(texDesc, Sel("setDepth:"), uintptr(depth)) mipLevels := desc.MipLevelCount if mipLevels == 0 { @@ -241,7 +241,7 @@ func (d *Device) CreateTexture(desc *hal.TextureDescriptor) (hal.Texture, error) format: desc.Format, width: desc.Size.Width, height: desc.Size.Height, - depth: depth, + depth: max(desc.Size.DepthOrArrayLayers, 1), mipLevels: mipLevels, samples: sampleCount, dimension: desc.Dimension, diff --git a/hal/metal/encoder.go b/hal/metal/encoder.go index 73f1391..f76769e 100644 --- a/hal/metal/encoder.go +++ b/hal/metal/encoder.go @@ -150,6 +150,11 @@ func (e *CommandEncoder) CopyBufferToTexture(src hal.Buffer, dst hal.Texture, re if !ok || dstTex == nil { return } + for _, region := range regions { + if _, _, ok := validateMetalBufferTextureCopyPlan(dstTex.format, dstTex.dimension, region.BufferLayout, region.TextureBase.Origin, region.Size); !ok { + return + } + } pool := NewAutoreleasePool() defer pool.Drain() blitEncoder := MsgSend(e.cmdBuffer, Sel("blitCommandEncoder")) @@ -157,21 +162,23 @@ func (e *CommandEncoder) CopyBufferToTexture(src hal.Buffer, dst hal.Texture, re return } for _, region := range regions { - sourceSize := MTLSize{Width: NSUInteger(region.Size.Width), Height: NSUInteger(region.Size.Height), Depth: NSUInteger(region.Size.DepthOrArrayLayers)} - destOrigin := MTLOrigin{X: NSUInteger(region.TextureBase.Origin.X), Y: NSUInteger(region.TextureBase.Origin.Y), Z: NSUInteger(region.TextureBase.Origin.Z)} - bytesPerRow := region.BufferLayout.BytesPerRow - bytesPerImage := region.BufferLayout.RowsPerImage * bytesPerRow - msgSendVoid(blitEncoder, Sel("copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), - argPointer(uintptr(srcBuf.raw)), - argUint64(region.BufferLayout.Offset), - argUint64(uint64(bytesPerRow)), - argUint64(uint64(bytesPerImage)), - argStruct(sourceSize, mtlSizeType), - argPointer(uintptr(dstTex.raw)), - argUint64(uint64(region.TextureBase.Origin.Z)), - argUint64(uint64(region.TextureBase.MipLevel)), - argStruct(destOrigin, mtlOriginType), - ) + plan, bytesPerImage, _ := validateMetalBufferTextureCopyPlan(dstTex.format, dstTex.dimension, region.BufferLayout, region.TextureBase.Origin, region.Size) + strides := metalBlitStrides(dstTex.dimension, uint64(region.BufferLayout.BytesPerRow), bytesPerImage) + for operation := uint32(0); operation < plan.operationCount; operation++ { + destination, _ := plan.textureRegion(dstTex.dimension, region.TextureBase.Origin, region.Size, operation) + sourceOffset, _ := plan.bufferOffset(region.BufferLayout.Offset, bytesPerImage, operation) + msgSendVoid(blitEncoder, Sel("copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), + argPointer(uintptr(srcBuf.raw)), + argUint64(sourceOffset), + argUint64(strides.bytesPerRow), + argUint64(strides.bytesPerImage), + argStruct(destination.size, mtlSizeType), + argPointer(uintptr(dstTex.raw)), + argUint64(uint64(destination.slice)), + argUint64(uint64(region.TextureBase.MipLevel)), + argStruct(destination.origin, mtlOriginType), + ) + } } _ = MsgSend(blitEncoder, Sel("endEncoding")) } @@ -189,6 +196,11 @@ func (e *CommandEncoder) CopyTextureToBuffer(src hal.Texture, dst hal.Buffer, re if !ok || dstBuf == nil { return } + for _, region := range regions { + if _, _, ok := validateMetalBufferTextureCopyPlan(srcTex.format, srcTex.dimension, region.BufferLayout, region.TextureBase.Origin, region.Size); !ok { + return + } + } pool := NewAutoreleasePool() defer pool.Drain() blitEncoder := MsgSend(e.cmdBuffer, Sel("blitCommandEncoder")) @@ -196,21 +208,23 @@ func (e *CommandEncoder) CopyTextureToBuffer(src hal.Texture, dst hal.Buffer, re return } for _, region := range regions { - sourceSize := MTLSize{Width: NSUInteger(region.Size.Width), Height: NSUInteger(region.Size.Height), Depth: NSUInteger(region.Size.DepthOrArrayLayers)} - sourceOrigin := MTLOrigin{X: NSUInteger(region.TextureBase.Origin.X), Y: NSUInteger(region.TextureBase.Origin.Y), Z: NSUInteger(region.TextureBase.Origin.Z)} - bytesPerRow := region.BufferLayout.BytesPerRow - bytesPerImage := region.BufferLayout.RowsPerImage * bytesPerRow - msgSendVoid(blitEncoder, Sel("copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:toBuffer:destinationOffset:destinationBytesPerRow:destinationBytesPerImage:"), - argPointer(uintptr(srcTex.raw)), - argUint64(uint64(region.TextureBase.Origin.Z)), - argUint64(uint64(region.TextureBase.MipLevel)), - argStruct(sourceOrigin, mtlOriginType), - argStruct(sourceSize, mtlSizeType), - argPointer(uintptr(dstBuf.raw)), - argUint64(region.BufferLayout.Offset), - argUint64(uint64(bytesPerRow)), - argUint64(uint64(bytesPerImage)), - ) + plan, bytesPerImage, _ := validateMetalBufferTextureCopyPlan(srcTex.format, srcTex.dimension, region.BufferLayout, region.TextureBase.Origin, region.Size) + strides := metalBlitStrides(srcTex.dimension, uint64(region.BufferLayout.BytesPerRow), bytesPerImage) + for operation := uint32(0); operation < plan.operationCount; operation++ { + source, _ := plan.textureRegion(srcTex.dimension, region.TextureBase.Origin, region.Size, operation) + destinationOffset, _ := plan.bufferOffset(region.BufferLayout.Offset, bytesPerImage, operation) + msgSendVoid(blitEncoder, Sel("copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:toBuffer:destinationOffset:destinationBytesPerRow:destinationBytesPerImage:"), + argPointer(uintptr(srcTex.raw)), + argUint64(uint64(source.slice)), + argUint64(uint64(region.TextureBase.MipLevel)), + argStruct(source.origin, mtlOriginType), + argStruct(source.size, mtlSizeType), + argPointer(uintptr(dstBuf.raw)), + argUint64(destinationOffset), + argUint64(strides.bytesPerRow), + argUint64(strides.bytesPerImage), + ) + } } _ = MsgSend(blitEncoder, Sel("endEncoding")) } @@ -228,6 +242,11 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst hal.Texture, regions []ha if !ok || dstTex == nil { return } + for _, region := range regions { + if _, ok := validateMetalTextureCopyPlan(srcTex.dimension, dstTex.dimension, region.SrcBase.Origin, region.DstBase.Origin, region.Size); !ok { + return + } + } pool := NewAutoreleasePool() defer pool.Drain() blitEncoder := MsgSend(e.cmdBuffer, Sel("blitCommandEncoder")) @@ -235,20 +254,22 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst hal.Texture, regions []ha return } for _, region := range regions { - sourceSize := MTLSize{Width: NSUInteger(region.Size.Width), Height: NSUInteger(region.Size.Height), Depth: NSUInteger(region.Size.DepthOrArrayLayers)} - sourceOrigin := MTLOrigin{X: NSUInteger(region.SrcBase.Origin.X), Y: NSUInteger(region.SrcBase.Origin.Y), Z: NSUInteger(region.SrcBase.Origin.Z)} - destOrigin := MTLOrigin{X: NSUInteger(region.DstBase.Origin.X), Y: NSUInteger(region.DstBase.Origin.Y), Z: NSUInteger(region.DstBase.Origin.Z)} - msgSendVoid(blitEncoder, Sel("copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), - argPointer(uintptr(srcTex.raw)), - argUint64(uint64(region.SrcBase.Origin.Z)), - argUint64(uint64(region.SrcBase.MipLevel)), - argStruct(sourceOrigin, mtlOriginType), - argStruct(sourceSize, mtlSizeType), - argPointer(uintptr(dstTex.raw)), - argUint64(uint64(region.DstBase.Origin.Z)), - argUint64(uint64(region.DstBase.MipLevel)), - argStruct(destOrigin, mtlOriginType), - ) + plan, _ := validateMetalTextureCopyPlan(srcTex.dimension, dstTex.dimension, region.SrcBase.Origin, region.DstBase.Origin, region.Size) + for operation := uint32(0); operation < plan.operationCount; operation++ { + source, _ := plan.textureRegion(srcTex.dimension, region.SrcBase.Origin, region.Size, operation) + destination, _ := plan.textureRegion(dstTex.dimension, region.DstBase.Origin, region.Size, operation) + msgSendVoid(blitEncoder, Sel("copyFromTexture:sourceSlice:sourceLevel:sourceOrigin:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), + argPointer(uintptr(srcTex.raw)), + argUint64(uint64(source.slice)), + argUint64(uint64(region.SrcBase.MipLevel)), + argStruct(source.origin, mtlOriginType), + argStruct(source.size, mtlSizeType), + argPointer(uintptr(dstTex.raw)), + argUint64(uint64(destination.slice)), + argUint64(uint64(region.DstBase.MipLevel)), + argStruct(destination.origin, mtlOriginType), + ) + } } _ = MsgSend(blitEncoder, Sel("endEncoding")) } diff --git a/hal/metal/metal.go b/hal/metal/metal.go index 3d7981d..1e75ea8 100644 --- a/hal/metal/metal.go +++ b/hal/metal/metal.go @@ -160,9 +160,10 @@ func preRegisterSelectors() { // MTLTexture "width", "height", "depth", "pixelFormat", "textureType", + "replaceRegion:mipmapLevel:slice:withBytes:bytesPerRow:bytesPerImage:", "newTextureViewWithPixelFormat:", // Descriptors - "setWidth:", "setHeight:", "setDepth:", + "setWidth:", "setHeight:", "setDepth:", "setArrayLength:", "setPixelFormat:", "setTextureType:", "setUsage:", "setStorageMode:", "setMipmapLevelCount:", "setSampleCount:", // MTLRenderPipelineDescriptor diff --git a/hal/metal/queue.go b/hal/metal/queue.go index 9516966..a02192a 100644 --- a/hal/metal/queue.go +++ b/hal/metal/queue.go @@ -254,16 +254,29 @@ func (q *Queue) writeBufferStaged(buf *Buffer, offset uint64, data []byte) error // the bytes into the staging buffer before this method returns, so the caller // may reuse or free the data slice immediately. func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal.ImageDataLayout, size *hal.Extent3D) error { + if dst == nil || layout == nil { + return fmt.Errorf("metal: WriteTexture: invalid arguments") + } tex, ok := dst.Texture.(*Texture) if !ok || tex == nil || len(data) == 0 || size == nil { return fmt.Errorf("metal: WriteTexture: invalid arguments") } + copyLayout, err := validateMetalTextureDataCopyLayout(uint64(len(data)), tex.format, *layout, *size) + if err != nil { + return fmt.Errorf("metal: WriteTexture: %w", err) + } + normalizedLayout := *layout + normalizedLayout.BytesPerRow = copyLayout.bytesPerRow + plan, bytesPerImage, ok := validateMetalBufferTextureCopyPlan(tex.format, tex.dimension, normalizedLayout, dst.Origin, *size) + if !ok || bytesPerImage != copyLayout.bytesPerImage { + return fmt.Errorf("metal: WriteTexture: copy address arithmetic overflows") + } // Apple Silicon UMA fast path: Shared textures accept synchronous CPU writes // via replaceRegion:. This avoids per-upload staging buffers and one-shot // blit command buffers — the main source of resize-induced memory growth. if tex.isShared { - return q.writeTextureShared(tex, dst, data, layout, size) + return q.writeTextureShared(tex, dst, data, layout, copyLayout, size, plan) } pool := NewAutoreleasePool() @@ -295,44 +308,23 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal return fmt.Errorf("metal: WriteTexture: blit encoder creation failed") } - // Calculate layout parameters. - bytesPerRow := layout.BytesPerRow - if bytesPerRow == 0 { - // Estimate bytes per row from width and format (assume 4 bytes/pixel for RGBA8). - bytesPerRow = size.Width * 4 - } - layers := size.DepthOrArrayLayers - if layers == 0 { - layers = 1 - } - bytesPerImage := layout.RowsPerImage * bytesPerRow - if bytesPerImage == 0 { - bytesPerImage = size.Height * bytesPerRow - } - - sourceOrigin := MTLOrigin{ - X: NSUInteger(dst.Origin.X), - Y: NSUInteger(dst.Origin.Y), - Z: NSUInteger(dst.Origin.Z), - } - sourceSize := MTLSize{ - Width: NSUInteger(size.Width), - Height: NSUInteger(size.Height), - Depth: NSUInteger(layers), + strides := metalBlitStrides(tex.dimension, uint64(copyLayout.bytesPerRow), copyLayout.bytesPerImage) + for operation := uint32(0); operation < plan.operationCount; operation++ { + destination, _ := plan.textureRegion(tex.dimension, dst.Origin, *size, operation) + sourceOffset, _ := plan.bufferOffset(layout.Offset, copyLayout.bytesPerImage, operation) + msgSendVoid(blitEncoder, Sel("copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), + argPointer(uintptr(stagingBuffer)), + argUint64(sourceOffset), + argUint64(strides.bytesPerRow), + argUint64(strides.bytesPerImage), + argStruct(destination.size, mtlSizeType), + argPointer(uintptr(tex.raw)), + argUint64(uint64(destination.slice)), + argUint64(uint64(dst.MipLevel)), + argStruct(destination.origin, mtlOriginType), + ) } - msgSendVoid(blitEncoder, Sel("copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:toTexture:destinationSlice:destinationLevel:destinationOrigin:"), - argPointer(uintptr(stagingBuffer)), - argUint64(layout.Offset), - argUint64(uint64(bytesPerRow)), - argUint64(uint64(bytesPerImage)), - argStruct(sourceSize, mtlSizeType), - argPointer(uintptr(tex.raw)), - argUint64(uint64(dst.Origin.Z)), - argUint64(uint64(dst.MipLevel)), - argStruct(sourceOrigin, mtlOriginType), - ) - _ = MsgSend(blitEncoder, Sel("endEncoding")) // Try async path: register a completion handler to release the staging @@ -378,50 +370,34 @@ func (q *Queue) WriteTexture(dst *hal.ImageCopyTexture, data []byte, layout *hal return nil } -// writeTextureShared writes pixel data directly into a Shared-storage Metal texture -// using replaceRegion:mipmapLevel:withBytes:bytesPerRow:. This is a synchronous CPU -// write — no GPU command buffer or staging buffer is required. Valid only when -// tex.isShared is true (Apple Silicon UMA). -func (q *Queue) writeTextureShared(tex *Texture, dst *hal.ImageCopyTexture, data []byte, layout *hal.ImageDataLayout, size *hal.Extent3D) error { - bytesPerRow := layout.BytesPerRow - if bytesPerRow == 0 { - bytesPerRow = size.Width * 4 - } - depth := size.DepthOrArrayLayers - if depth == 0 { - depth = 1 - } - - region := MTLRegion{ - Origin: MTLOrigin{ - X: NSUInteger(dst.Origin.X), - Y: NSUInteger(dst.Origin.Y), - Z: NSUInteger(dst.Origin.Z), - }, - Size: MTLSize{ - Width: NSUInteger(size.Width), - Height: NSUInteger(size.Height), - Depth: NSUInteger(depth), - }, - } - - src := data[layout.Offset:] - if len(src) == 0 { - return nil +// writeTextureShared writes pixel data directly into a Shared-storage Metal texture. +// It uses the slice-aware replaceRegion form so 2D arrays and true 3D textures +// preserve their distinct Metal layouts. The write is synchronous; no GPU command +// buffer or staging buffer is required. Valid only when tex.isShared is true. +func (q *Queue) writeTextureShared(tex *Texture, dst *hal.ImageCopyTexture, data []byte, layout *hal.ImageDataLayout, copyLayout metalTextureDataCopyLayout, size *hal.Extent3D, plan metalCopyPlan) error { + strides := metalReplaceRegionStrides(tex.dimension, uint64(copyLayout.bytesPerRow), copyLayout.bytesPerImage) + for operation := uint32(0); operation < plan.operationCount; operation++ { + offset, _ := plan.bufferOffset(layout.Offset, copyLayout.bytesPerImage, operation) + src := data[offset:] + destination, _ := plan.textureRegion(tex.dimension, dst.Origin, *size, operation) + region := MTLRegion{Origin: destination.origin, Size: destination.size} + + // The slice-aware form carries an image stride for true 3D writes. Array + // writes target one physical slice and therefore pass a zero image stride. + msgSendVoid(tex.raw, Sel("replaceRegion:mipmapLevel:slice:withBytes:bytesPerRow:bytesPerImage:"), + argStruct(region, mtlRegionType), + argUint64(uint64(dst.MipLevel)), + argUint64(uint64(destination.slice)), + argPointer(uintptr(unsafe.Pointer(&src[0]))), + argUint64(strides.bytesPerRow), + argUint64(strides.bytesPerImage), + ) } - // replaceRegion:mipmapLevel:withBytes:bytesPerRow: — synchronous CPU→Shared write. - msgSendVoid(tex.raw, Sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), - argStruct(region, mtlRegionType), - argUint64(uint64(dst.MipLevel)), - argPointer(uintptr(unsafe.Pointer(&src[0]))), - argUint64(uint64(bytesPerRow)), - ) - hal.Logger().Debug("metal: WriteTexture (shared direct)", "width", size.Width, "height", size.Height, - "bytesPerRow", bytesPerRow, + "bytesPerRow", copyLayout.bytesPerRow, ) return nil } diff --git a/hal/metal/texture_copy.go b/hal/metal/texture_copy.go new file mode 100644 index 0000000..c4e39fb --- /dev/null +++ b/hal/metal/texture_copy.go @@ -0,0 +1,357 @@ +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build darwin && !(js && wasm) + +package metal + +import ( + "fmt" + "math" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +type metalTextureShape struct { + depth uint32 + arrayLength uint32 +} + +// metalTextureDescriptorShape separates WebGPU's combined depth-or-layers +// value into Metal's physical depth and array length properties. +func metalTextureDescriptorShape(dimension gputypes.TextureDimension, depthOrArrayLayers uint32) metalTextureShape { + if depthOrArrayLayers == 0 { + depthOrArrayLayers = 1 + } + if dimension == gputypes.TextureDimension3D { + return metalTextureShape{depth: depthOrArrayLayers, arrayLength: 1} + } + return metalTextureShape{depth: 1, arrayLength: depthOrArrayLayers} +} + +type metalTextureCopyRegion struct { + slice NSUInteger + origin MTLOrigin + size MTLSize +} + +type metalCopyPlan struct { + operationCount uint32 + splitDepth bool +} + +func planMetalBufferTextureCopy(dimension gputypes.TextureDimension, depthOrArrayLayers uint32) metalCopyPlan { + return newMetalCopyPlan(dimension != gputypes.TextureDimension3D, depthOrArrayLayers) +} + +func planMetalTextureCopy(sourceDimension, destinationDimension gputypes.TextureDimension, depthOrArrayLayers uint32) metalCopyPlan { + return newMetalCopyPlan( + sourceDimension != gputypes.TextureDimension3D || destinationDimension != gputypes.TextureDimension3D, + depthOrArrayLayers, + ) +} + +func newMetalCopyPlan(splitDepth bool, depthOrArrayLayers uint32) metalCopyPlan { + if depthOrArrayLayers == 0 { + return metalCopyPlan{splitDepth: splitDepth} + } + operationCount := uint32(1) + if splitDepth { + operationCount = depthOrArrayLayers + } + return metalCopyPlan{operationCount: operationCount, splitDepth: splitDepth} +} + +func (p metalCopyPlan) textureRegion(dimension gputypes.TextureDimension, origin hal.Origin3D, size hal.Extent3D, operation uint32) (metalTextureCopyRegion, bool) { + if operation >= p.operationCount { + return metalTextureCopyRegion{}, false + } + region := metalTextureCopyRegion{ + origin: MTLOrigin{X: NSUInteger(origin.X), Y: NSUInteger(origin.Y), Z: NSUInteger(origin.Z)}, + size: MTLSize{ + Width: NSUInteger(size.Width), + Height: NSUInteger(size.Height), + Depth: NSUInteger(size.DepthOrArrayLayers), + }, + } + if dimension != gputypes.TextureDimension3D { + layer, ok := checkedMetalTextureDataAdd32(origin.Z, operation) + if !ok { + return metalTextureCopyRegion{}, false + } + region.slice = NSUInteger(layer) + region.origin.Z = 0 + region.size.Depth = 1 + } else if p.splitDepth { + depth, ok := checkedMetalTextureDataAdd32(origin.Z, operation) + if !ok { + return metalTextureCopyRegion{}, false + } + region.origin.Z = NSUInteger(depth) + region.size.Depth = 1 + } + return region, true +} + +func (p metalCopyPlan) bufferOffset(base, bytesPerImage uint64, operation uint32) (uint64, bool) { + if operation >= p.operationCount { + return 0, false + } + if !p.splitDepth { + return base, true + } + imageOffset, ok := checkedMetalTextureDataMul(uint64(operation), bytesPerImage) + if !ok { + return 0, false + } + return checkedMetalTextureDataAdd(base, imageOffset) +} + +func metalCopyBytesPerImage(format gputypes.TextureFormat, bytesPerRow, rowsPerImage, imageHeight uint32) (uint64, bool) { + if rowsPerImage == 0 { + _, blockHeight, _, ok := metalTextureFormatBlockInfo(format) + if !ok { + return 0, false + } + rowsPerImage = uint32((uint64(imageHeight) + uint64(blockHeight) - 1) / uint64(blockHeight)) + } + return checkedMetalTextureDataMul(uint64(rowsPerImage), uint64(bytesPerRow)) +} + +type metalTextureDataCopyLayout struct { + bytesPerRow uint32 + bytesPerImage uint64 +} + +type metalCopyStrides struct { + bytesPerRow uint64 + bytesPerImage uint64 +} + +// metalReplaceRegionStrides applies MTLTexture.replaceRegion's dimension rules. +// Metal infers the sole row of a 1D texture and only accepts an image stride for 3D. +func metalReplaceRegionStrides(dimension gputypes.TextureDimension, bytesPerRow, bytesPerImage uint64) metalCopyStrides { + switch dimension { + case gputypes.TextureDimension1D: + return metalCopyStrides{} + case gputypes.TextureDimension3D: + return metalCopyStrides{bytesPerRow: bytesPerRow, bytesPerImage: bytesPerImage} + default: + return metalCopyStrides{bytesPerRow: bytesPerRow} + } +} + +// metalBlitStrides applies MTLBlitCommandEncoder's buffer/texture copy rules. +// Its row stride remains explicit; its image stride is only valid for true 3D. +func metalBlitStrides(dimension gputypes.TextureDimension, bytesPerRow, bytesPerImage uint64) metalCopyStrides { + strides := metalCopyStrides{bytesPerRow: bytesPerRow} + if dimension == gputypes.TextureDimension3D { + strides.bytesPerImage = bytesPerImage + } + return strides +} + +func validateMetalBufferTextureCopyPlan( + format gputypes.TextureFormat, + dimension gputypes.TextureDimension, + layout hal.ImageDataLayout, + origin hal.Origin3D, + size hal.Extent3D, +) (metalCopyPlan, uint64, bool) { + bytesPerImage, ok := metalCopyBytesPerImage(format, layout.BytesPerRow, layout.RowsPerImage, size.Height) + if !ok { + return metalCopyPlan{}, 0, false + } + plan := planMetalBufferTextureCopy(dimension, size.DepthOrArrayLayers) + if plan.operationCount == 0 { + return metalCopyPlan{}, 0, false + } + lastOperation := plan.operationCount - 1 + if _, ok := plan.textureRegion(dimension, origin, size, lastOperation); !ok { + return metalCopyPlan{}, 0, false + } + if _, ok := plan.bufferOffset(layout.Offset, bytesPerImage, lastOperation); !ok { + return metalCopyPlan{}, 0, false + } + return plan, bytesPerImage, true +} + +func validateMetalTextureCopyPlan( + sourceDimension, destinationDimension gputypes.TextureDimension, + sourceOrigin, destinationOrigin hal.Origin3D, + size hal.Extent3D, +) (metalCopyPlan, bool) { + plan := planMetalTextureCopy(sourceDimension, destinationDimension, size.DepthOrArrayLayers) + if plan.operationCount == 0 { + return metalCopyPlan{}, false + } + lastOperation := plan.operationCount - 1 + if _, ok := plan.textureRegion(sourceDimension, sourceOrigin, size, lastOperation); !ok { + return metalCopyPlan{}, false + } + if _, ok := plan.textureRegion(destinationDimension, destinationOrigin, size, lastOperation); !ok { + return metalCopyPlan{}, false + } + return plan, true +} + +// validateMetalTextureDataCopyLayout proves that every byte Metal may read is +// present before either the Shared direct-write or Private staging path starts. +func validateMetalTextureDataCopyLayout(dataLength uint64, format gputypes.TextureFormat, layout hal.ImageDataLayout, size hal.Extent3D) (metalTextureDataCopyLayout, error) { + if size.Width == 0 || size.Height == 0 || size.DepthOrArrayLayers == 0 { + return metalTextureDataCopyLayout{}, fmt.Errorf("copy extent must be non-zero") + } + + rowBytes, blockRows, ok := metalTextureCopyGeometry(format, size.Width, size.Height) + if !ok { + return metalTextureDataCopyLayout{}, fmt.Errorf("texture format %s has no defined copy block size", format) + } + if rowBytes > math.MaxUint32 { + return metalTextureDataCopyLayout{}, fmt.Errorf("texture row size overflows BytesPerRow") + } + + bytesPerRow := layout.BytesPerRow + if bytesPerRow == 0 { + bytesPerRow = uint32(rowBytes) + } + if uint64(bytesPerRow) < rowBytes { + return metalTextureDataCopyLayout{}, fmt.Errorf("BytesPerRow %d is smaller than the copied row size %d", bytesPerRow, rowBytes) + } + + rowsPerImage := layout.RowsPerImage + if rowsPerImage == 0 { + rowsPerImage = uint32(blockRows) + } + if uint64(rowsPerImage) < blockRows { + return metalTextureDataCopyLayout{}, fmt.Errorf("RowsPerImage %d is smaller than the copied block-row count %d", rowsPerImage, blockRows) + } + + bytesPerImage, ok := checkedMetalTextureDataMul(uint64(rowsPerImage), uint64(bytesPerRow)) + if !ok { + return metalTextureDataCopyLayout{}, fmt.Errorf("BytesPerImage overflows uint64") + } + lastImageOffset, ok := checkedMetalTextureDataMul(uint64(size.DepthOrArrayLayers-1), bytesPerImage) + if !ok { + return metalTextureDataCopyLayout{}, fmt.Errorf("last image offset overflows uint64") + } + lastRowOffset, ok := checkedMetalTextureDataMul(blockRows-1, uint64(bytesPerRow)) + if !ok { + return metalTextureDataCopyLayout{}, fmt.Errorf("last row offset overflows uint64") + } + requiredBytes, ok := checkedMetalTextureDataAdd(layout.Offset, lastImageOffset) + if ok { + requiredBytes, ok = checkedMetalTextureDataAdd(requiredBytes, lastRowOffset) + } + if ok { + requiredBytes, ok = checkedMetalTextureDataAdd(requiredBytes, rowBytes) + } + if !ok { + return metalTextureDataCopyLayout{}, fmt.Errorf("source data range overflows uint64") + } + if requiredBytes > dataLength { + return metalTextureDataCopyLayout{}, fmt.Errorf("source data is too short: need %d bytes, have %d", requiredBytes, dataLength) + } + + return metalTextureDataCopyLayout{bytesPerRow: bytesPerRow, bytesPerImage: bytesPerImage}, nil +} + +func metalTextureCopyGeometry(format gputypes.TextureFormat, width, height uint32) (rowBytes, blockRows uint64, ok bool) { + blockWidth, blockHeight, blockSize, ok := metalTextureFormatBlockInfo(format) + if !ok { + return 0, 0, false + } + blockColumns := (uint64(width) + uint64(blockWidth) - 1) / uint64(blockWidth) + blockRows = (uint64(height) + uint64(blockHeight) - 1) / uint64(blockHeight) + rowBytes, ok = checkedMetalTextureDataMul(blockColumns, uint64(blockSize)) + return rowBytes, blockRows, ok +} + +func metalTextureFormatBlockInfo(format gputypes.TextureFormat) (width, height, size uint32, ok bool) { + blockSize := format.BlockCopySize() + if blockSize == 0 { + return 0, 0, 0, false + } + + 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, + gputypes.TextureFormatETC2RGB8Unorm, + gputypes.TextureFormatETC2RGB8UnormSrgb, + gputypes.TextureFormatETC2RGB8A1Unorm, + gputypes.TextureFormatETC2RGB8A1UnormSrgb, + gputypes.TextureFormatETC2RGBA8Unorm, + gputypes.TextureFormatETC2RGBA8UnormSrgb, + gputypes.TextureFormatEACR11Unorm, + gputypes.TextureFormatEACR11Snorm, + gputypes.TextureFormatEACRG11Unorm, + gputypes.TextureFormatEACRG11Snorm: + return 4, 4, blockSize, true + } + + switch format { + case gputypes.TextureFormatASTC4x4Unorm, gputypes.TextureFormatASTC4x4UnormSrgb: + return 4, 4, blockSize, true + case gputypes.TextureFormatASTC5x4Unorm, gputypes.TextureFormatASTC5x4UnormSrgb: + return 5, 4, blockSize, true + case gputypes.TextureFormatASTC5x5Unorm, gputypes.TextureFormatASTC5x5UnormSrgb: + return 5, 5, blockSize, true + case gputypes.TextureFormatASTC6x5Unorm, gputypes.TextureFormatASTC6x5UnormSrgb: + return 6, 5, blockSize, true + case gputypes.TextureFormatASTC6x6Unorm, gputypes.TextureFormatASTC6x6UnormSrgb: + return 6, 6, blockSize, true + case gputypes.TextureFormatASTC8x5Unorm, gputypes.TextureFormatASTC8x5UnormSrgb: + return 8, 5, blockSize, true + case gputypes.TextureFormatASTC8x6Unorm, gputypes.TextureFormatASTC8x6UnormSrgb: + return 8, 6, blockSize, true + case gputypes.TextureFormatASTC8x8Unorm, gputypes.TextureFormatASTC8x8UnormSrgb: + return 8, 8, blockSize, true + case gputypes.TextureFormatASTC10x5Unorm, gputypes.TextureFormatASTC10x5UnormSrgb: + return 10, 5, blockSize, true + case gputypes.TextureFormatASTC10x6Unorm, gputypes.TextureFormatASTC10x6UnormSrgb: + return 10, 6, blockSize, true + case gputypes.TextureFormatASTC10x8Unorm, gputypes.TextureFormatASTC10x8UnormSrgb: + return 10, 8, blockSize, true + case gputypes.TextureFormatASTC10x10Unorm, gputypes.TextureFormatASTC10x10UnormSrgb: + return 10, 10, blockSize, true + case gputypes.TextureFormatASTC12x10Unorm, gputypes.TextureFormatASTC12x10UnormSrgb: + return 12, 10, blockSize, true + case gputypes.TextureFormatASTC12x12Unorm, gputypes.TextureFormatASTC12x12UnormSrgb: + return 12, 12, blockSize, true + default: + return 1, 1, blockSize, true + } +} + +func checkedMetalTextureDataMul(a, b uint64) (uint64, bool) { + if a != 0 && b > math.MaxUint64/a { + return 0, false + } + return a * b, true +} + +func checkedMetalTextureDataAdd(a, b uint64) (uint64, bool) { + if b > math.MaxUint64-a { + return 0, false + } + return a + b, true +} + +func checkedMetalTextureDataAdd32(a, b uint32) (uint32, bool) { + if b > math.MaxUint32-a { + return 0, false + } + return a + b, true +} diff --git a/hal/metal/texture_copy_test.go b/hal/metal/texture_copy_test.go new file mode 100644 index 0000000..ff170e7 --- /dev/null +++ b/hal/metal/texture_copy_test.go @@ -0,0 +1,648 @@ +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build darwin && !(js && wasm) + +package metal + +import ( + "math" + "testing" + "unsafe" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" +) + +func TestMetalTextureDataCopyLayoutValidatesCompleteSourceRange(t *testing.T) { + const ( + width = uint32(4) + height = uint32(2) + depth = uint32(3) + bytesPerRow = uint32(32) + rowsPerImage = uint32(4) + requiredBytes = uint64(321) + ) + layout := hal.ImageDataLayout{Offset: 17, BytesPerRow: bytesPerRow, RowsPerImage: rowsPerImage} + size := hal.Extent3D{Width: width, Height: height, DepthOrArrayLayers: depth} + + got, err := validateMetalTextureDataCopyLayout(requiredBytes, gputypes.TextureFormatRGBA8Unorm, layout, size) + if err != nil { + t.Fatalf("exact source range rejected: %v", err) + } + if got.bytesPerRow != bytesPerRow || got.bytesPerImage != uint64(bytesPerRow)*uint64(rowsPerImage) { + t.Fatalf("normalized layout = %+v; want bytesPerRow %d, bytesPerImage %d", got, bytesPerRow, bytesPerRow*rowsPerImage) + } + + tests := []struct { + name string + dataLength uint64 + layout hal.ImageDataLayout + size hal.Extent3D + }{ + { + name: "truncated later layer", + dataLength: requiredBytes - 1, + layout: layout, + size: size, + }, + { + name: "offset plus final row overflows", + dataLength: math.MaxUint64, + layout: hal.ImageDataLayout{Offset: math.MaxUint64 - 3, BytesPerRow: 8, RowsPerImage: 1}, + size: hal.Extent3D{Width: 2, Height: 1, DepthOrArrayLayers: 1}, + }, + { + name: "rows per image times later layers overflows", + dataLength: math.MaxUint64, + layout: hal.ImageDataLayout{BytesPerRow: math.MaxUint32, RowsPerImage: math.MaxUint32}, + size: hal.Extent3D{Width: 1, Height: 1, DepthOrArrayLayers: math.MaxUint32}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := validateMetalTextureDataCopyLayout(test.dataLength, gputypes.TextureFormatRGBA8Unorm, test.layout, test.size); err == nil { + t.Fatal("invalid source range accepted") + } + }) + } +} + +func TestMetalTextureFormatBlockInfoCoversCompressedFormats(t *testing.T) { + for format := gputypes.TextureFormatBC1RGBAUnorm; format <= gputypes.TextureFormatASTC12x12UnormSrgb; format++ { + width, height, size, ok := metalTextureFormatBlockInfo(format) + if !ok || width == 0 || height == 0 || size == 0 { + t.Fatalf("compressed format %s (%d) has incomplete block info: %dx%d, %d bytes, ok=%t", format, format, width, height, size, ok) + } + } + + tests := []struct { + format gputypes.TextureFormat + width, height uint32 + }{ + {gputypes.TextureFormatBC7RGBAUnorm, 4, 4}, + {gputypes.TextureFormatETC2RGBA8Unorm, 4, 4}, + {gputypes.TextureFormatASTC4x4Unorm, 4, 4}, + {gputypes.TextureFormatASTC5x4Unorm, 5, 4}, + {gputypes.TextureFormatASTC5x5Unorm, 5, 5}, + {gputypes.TextureFormatASTC6x5Unorm, 6, 5}, + {gputypes.TextureFormatASTC6x6Unorm, 6, 6}, + {gputypes.TextureFormatASTC8x5Unorm, 8, 5}, + {gputypes.TextureFormatASTC8x6Unorm, 8, 6}, + {gputypes.TextureFormatASTC8x8Unorm, 8, 8}, + {gputypes.TextureFormatASTC10x5Unorm, 10, 5}, + {gputypes.TextureFormatASTC10x6Unorm, 10, 6}, + {gputypes.TextureFormatASTC10x8Unorm, 10, 8}, + {gputypes.TextureFormatASTC10x10Unorm, 10, 10}, + {gputypes.TextureFormatASTC12x10Unorm, 12, 10}, + {gputypes.TextureFormatASTC12x12Unorm, 12, 12}, + } + for _, test := range tests { + width, height, _, ok := metalTextureFormatBlockInfo(test.format) + if !ok || width != test.width || height != test.height { + t.Errorf("%s block dimensions = %dx%d, ok=%t; want %dx%d", test.format, width, height, ok, test.width, test.height) + } + } +} + +func TestMetalTextureDataCopyLayoutUsesCompressedBlockRows(t *testing.T) { + layout, err := validateMetalTextureDataCopyLayout( + 64, + gputypes.TextureFormatASTC5x4Unorm, + hal.ImageDataLayout{}, + hal.Extent3D{Width: 6, Height: 5, DepthOrArrayLayers: 1}, + ) + if err != nil { + t.Fatalf("compressed layout rejected: %v", err) + } + if layout.bytesPerRow != 32 || layout.bytesPerImage != 64 { + t.Fatalf("compressed layout = %+v; want 32 bytes per row and 64 bytes per image", layout) + } +} + +func TestMetalCopyBytesPerImageUsesCompressedBlockRows(t *testing.T) { + got, ok := metalCopyBytesPerImage(gputypes.TextureFormatASTC5x4Unorm, 32, 0, 5) + if !ok || got != 64 { + t.Fatalf("compressed bytes per image = %d, ok=%t; want 64, true", got, ok) + } + + got, ok = metalCopyBytesPerImage(gputypes.TextureFormatRGBA8Unorm, 32, 0, 5) + if !ok || got != 160 { + t.Fatalf("plain bytes per image = %d, ok=%t; want 160, true", got, ok) + } +} + +func TestMetalReplaceRegionStridesMatchTextureDimension(t *testing.T) { + tests := []struct { + name string + dimension gputypes.TextureDimension + wantBytesPerRow, wantPerImage uint64 + }{ + {name: "1D array", dimension: gputypes.TextureDimension1D, wantBytesPerRow: 0, wantPerImage: 0}, + {name: "2D array", dimension: gputypes.TextureDimension2D, wantBytesPerRow: 256, wantPerImage: 0}, + {name: "3D", dimension: gputypes.TextureDimension3D, wantBytesPerRow: 256, wantPerImage: 1024}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := metalReplaceRegionStrides(test.dimension, 256, 1024) + if got.bytesPerRow != test.wantBytesPerRow || got.bytesPerImage != test.wantPerImage { + t.Fatalf("strides = %+v; want row %d image %d", got, test.wantBytesPerRow, test.wantPerImage) + } + }) + } +} + +func TestMetalBlitStridesMatchTextureDimension(t *testing.T) { + for _, dimension := range []gputypes.TextureDimension{gputypes.TextureDimension1D, gputypes.TextureDimension2D} { + got := metalBlitStrides(dimension, 256, 1024) + if got.bytesPerRow != 256 || got.bytesPerImage != 0 { + t.Errorf("%s blit strides = %+v; want row 256 image 0", dimension, got) + } + } + got := metalBlitStrides(gputypes.TextureDimension3D, 256, 1024) + if got.bytesPerRow != 256 || got.bytesPerImage != 1024 { + t.Errorf("3D blit strides = %+v; want row 256 image 1024", got) + } +} + +func TestMetalTextureDescriptorShape2DArray(t *testing.T) { + shape := metalTextureDescriptorShape(gputypes.TextureDimension2D, 4) + + if shape.depth != 1 || shape.arrayLength != 4 { + t.Fatalf("2D array shape = depth %d, array length %d; want 1, 4", shape.depth, shape.arrayLength) + } +} + +func TestMetalBufferTextureCopyPlan2DArray(t *testing.T) { + plan := planMetalBufferTextureCopy(gputypes.TextureDimension2D, 4) + if plan.operationCount != 4 { + t.Fatalf("operation count = %d; want 4", plan.operationCount) + } + + region, ok := plan.textureRegion( + gputypes.TextureDimension2D, + hal.Origin3D{X: 3, Y: 4, Z: 5}, + hal.Extent3D{Width: 16, Height: 8, DepthOrArrayLayers: 4}, + 2, + ) + if !ok { + t.Fatal("valid texture region overflowed") + } + if region.slice != 7 { + t.Errorf("slice = %d; want 7", region.slice) + } + if region.origin != (MTLOrigin{X: 3, Y: 4, Z: 0}) { + t.Errorf("origin = %+v; want {3 4 0}", region.origin) + } + if region.size != (MTLSize{Width: 16, Height: 8, Depth: 1}) { + t.Errorf("size = %+v; want {16 8 1}", region.size) + } + if got, ok := plan.bufferOffset(64, 1024, 2); !ok || got != 2112 { + t.Errorf("buffer offset = %d; want 2112", got) + } +} + +func TestMetalCopyPlanRejectsWrappedOriginAndBufferOffset(t *testing.T) { + plan := planMetalBufferTextureCopy(gputypes.TextureDimension2D, 2) + if _, ok := plan.textureRegion( + gputypes.TextureDimension2D, + hal.Origin3D{Z: math.MaxUint32}, + hal.Extent3D{Width: 1, Height: 1, DepthOrArrayLayers: 2}, + 1, + ); ok { + t.Fatal("wrapped array-layer origin accepted") + } + if _, ok := plan.bufferOffset(math.MaxUint64-7, 8, 1); ok { + t.Fatal("wrapped buffer offset accepted") + } + if _, ok := plan.bufferOffset(0, math.MaxUint64, 2); ok { + t.Fatal("wrapped image-stride multiplication accepted") + } +} + +func TestCommandEncoderRejectsWrappedCopyBeforeMetalCall(t *testing.T) { + // Sentinel native handles are deliberately invalid. These calls only remain + // safe if arithmetic preflight returns before asking Objective-C for a blit + // encoder or issuing a copy selector. + encoder := &CommandEncoder{cmdBuffer: 1} + buffer := &Buffer{raw: 1} + array := &Texture{raw: 1, dimension: gputypes.TextureDimension2D, format: gputypes.TextureFormatRGBA8Unorm} + volume := &Texture{raw: 1, dimension: gputypes.TextureDimension3D, format: gputypes.TextureFormatRGBA8Unorm} + size := hal.Extent3D{Width: 1, Height: 1, DepthOrArrayLayers: 2} + + encoder.CopyBufferToTexture(buffer, array, []hal.BufferTextureCopy{{ + BufferLayout: hal.ImageDataLayout{Offset: math.MaxUint64 - 1, BytesPerRow: 4, RowsPerImage: 1}, + TextureBase: hal.ImageCopyTexture{Texture: array}, + Size: size, + }}) + encoder.CopyTextureToBuffer(array, buffer, []hal.BufferTextureCopy{{ + BufferLayout: hal.ImageDataLayout{BytesPerRow: 4, RowsPerImage: 1}, + TextureBase: hal.ImageCopyTexture{Texture: array, Origin: hal.Origin3D{Z: math.MaxUint32}}, + Size: size, + }}) + encoder.CopyTextureToTexture(volume, array, []hal.TextureCopy{{ + SrcBase: hal.ImageCopyTexture{Texture: volume, Origin: hal.Origin3D{Z: math.MaxUint32}}, + DstBase: hal.ImageCopyTexture{Texture: array}, + Size: size, + }}) +} + +func TestMetalBufferTextureCopyPlan3D(t *testing.T) { + plan := planMetalBufferTextureCopy(gputypes.TextureDimension3D, 4) + if plan.operationCount != 1 { + t.Fatalf("operation count = %d; want 1", plan.operationCount) + } + + region, ok := plan.textureRegion( + gputypes.TextureDimension3D, + hal.Origin3D{X: 3, Y: 4, Z: 5}, + hal.Extent3D{Width: 16, Height: 8, DepthOrArrayLayers: 4}, + 0, + ) + if !ok { + t.Fatal("valid texture region overflowed") + } + if region.slice != 0 { + t.Errorf("slice = %d; want 0", region.slice) + } + if region.origin != (MTLOrigin{X: 3, Y: 4, Z: 5}) { + t.Errorf("origin = %+v; want {3 4 5}", region.origin) + } + if region.size != (MTLSize{Width: 16, Height: 8, Depth: 4}) { + t.Errorf("size = %+v; want {16 8 4}", region.size) + } + if got, ok := plan.bufferOffset(64, 1024, 0); !ok || got != 64 { + t.Errorf("buffer offset = %d; want 64", got) + } +} + +func TestMetalTextureCopyPlanSplits3DTo2DArray(t *testing.T) { + plan := planMetalTextureCopy(gputypes.TextureDimension3D, gputypes.TextureDimension2D, 4) + if plan.operationCount != 4 { + t.Fatalf("operation count = %d; want 4", plan.operationCount) + } + + source, ok := plan.textureRegion( + gputypes.TextureDimension3D, + hal.Origin3D{X: 1, Y: 2, Z: 3}, + hal.Extent3D{Width: 16, Height: 8, DepthOrArrayLayers: 4}, + 2, + ) + if !ok { + t.Fatal("valid source region overflowed") + } + destination, ok := plan.textureRegion( + gputypes.TextureDimension2D, + hal.Origin3D{X: 4, Y: 5, Z: 6}, + hal.Extent3D{Width: 16, Height: 8, DepthOrArrayLayers: 4}, + 2, + ) + if !ok { + t.Fatal("valid destination region overflowed") + } + if source.slice != 0 || source.origin.Z != 5 || source.size.Depth != 1 { + t.Errorf("3D source = slice %d, origin %+v, size %+v; want slice 0, z 5, depth 1", source.slice, source.origin, source.size) + } + if destination.slice != 8 || destination.origin.Z != 0 || destination.size.Depth != 1 { + t.Errorf("2D-array destination = slice %d, origin %+v, size %+v; want slice 8, z 0, depth 1", destination.slice, destination.origin, destination.size) + } +} + +func TestMetalTextureDescriptorShape3D(t *testing.T) { + shape := metalTextureDescriptorShape(gputypes.TextureDimension3D, 4) + + if shape.depth != 4 || shape.arrayLength != 1 { + t.Fatalf("3D shape = depth %d, array length %d; want 4, 1", shape.depth, shape.arrayLength) + } +} + +func TestDeviceCreateTexture2DArrayUsesMetalArrayShape(t *testing.T) { + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + rawDevice := CreateSystemDefaultDevice() + if rawDevice == 0 { + t.Skip("no Metal device available") + } + defer Release(rawDevice) + + device, err := newDevice(&Adapter{raw: rawDevice}) + if err != nil { + t.Fatalf("newDevice failed: %v", err) + } + defer device.Destroy() + + rawTexture, err := device.CreateTexture(&hal.TextureDescriptor{ + Size: hal.Extent3D{Width: 8, Height: 8, DepthOrArrayLayers: 4}, + MipLevelCount: 1, + SampleCount: 1, + Dimension: gputypes.TextureDimension2D, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageCopySrc | gputypes.TextureUsageCopyDst, + }) + if err != nil { + t.Fatalf("CreateTexture failed: %v", err) + } + texture := rawTexture.(*Texture) + defer texture.Destroy() + + if texture.depth != 4 { + t.Errorf("retained logical depth = %d; want 4 array layers", texture.depth) + } + if got := MsgSendUint(texture.raw, Sel("depth")); got != 1 { + t.Errorf("Metal physical depth = %d; want 1", got) + } + if got := MsgSendUint(texture.raw, Sel("arrayLength")); got != 4 { + t.Errorf("Metal array length = %d; want 4", got) + } + if got := MTLTextureType(MsgSendUint(texture.raw, Sel("textureType"))); got != MTLTextureType2DArray { + t.Errorf("Metal texture type = %d; want %d", got, MTLTextureType2DArray) + } +} + +func TestCommandEncoderCopiesLayeredShapes(t *testing.T) { + device, queue := newMetalTextureCopyTestDevice(t) + for _, shape := range []struct { + name string + dimension gputypes.TextureDimension + height uint32 + yOrigin uint32 + }{ + {name: "1D array", dimension: gputypes.TextureDimension1D, height: 1}, + {name: "2D array", dimension: gputypes.TextureDimension2D, height: 2, yOrigin: 1}, + {name: "true 3D", dimension: gputypes.TextureDimension3D, height: 2, yOrigin: 1}, + } { + t.Run(shape.name, func(t *testing.T) { + const width, depth, bytesPerRow, offset = uint32(4), uint32(3), uint32(256), uint64(16) + rowsPerImage := shape.height + 1 + bytesPerImage := bytesPerRow * rowsPerImage + data := metalTextureArrayTestData(width, shape.height, depth, bytesPerRow, bytesPerImage) + upload := createMetalTextureCopyTestUpload(t, device, offset, data) + defer upload.Destroy() + textureHeight := uint32(1) + if shape.dimension != gputypes.TextureDimension1D { + textureHeight = shape.height + 2 + } + texture := createMetalTextureCopyTestTexture(t, device, shape.dimension, width+2, textureHeight, depth+2) + defer texture.Destroy() + readback := createMetalTextureCopyTestReadback(t, device, offset+uint64(len(data))) + defer readback.Destroy() + + layout := hal.ImageDataLayout{Offset: offset, BytesPerRow: bytesPerRow, RowsPerImage: rowsPerImage} + origin := hal.Origin3D{X: 1, Y: shape.yOrigin, Z: 1} + size := hal.Extent3D{Width: width, Height: shape.height, DepthOrArrayLayers: depth} + encoder := &CommandEncoder{device: device} + if err := encoder.BeginEncoding(shape.name + " buffer round trip"); err != nil { + t.Fatalf("BeginEncoding failed: %v", err) + } + encoder.CopyBufferToTexture(upload, texture, []hal.BufferTextureCopy{{BufferLayout: layout, TextureBase: hal.ImageCopyTexture{Texture: texture, Origin: origin}, Size: size}}) + encoder.CopyTextureToBuffer(texture, readback, []hal.BufferTextureCopy{{BufferLayout: layout, TextureBase: hal.ImageCopyTexture{Texture: texture, Origin: origin}, Size: size}}) + submitMetalTextureCopyTestEncoder(t, device, queue, encoder) + + got := unsafe.Slice((*byte)(readback.Contents()), int(offset)+len(data))[offset:] + assertMetalTextureArrayTestData(t, got, data, width, shape.height, depth, bytesPerRow, bytesPerImage) + }) + } +} + +func TestCommandEncoderCopiesBetweenLayeredTextures(t *testing.T) { + device, queue := newMetalTextureCopyTestDevice(t) + for _, test := range []struct { + name string + source, destination gputypes.TextureDimension + }{ + {name: "3D to 3D", source: gputypes.TextureDimension3D, destination: gputypes.TextureDimension3D}, + {name: "3D to 2D array", source: gputypes.TextureDimension3D, destination: gputypes.TextureDimension2D}, + {name: "2D array to 3D", source: gputypes.TextureDimension2D, destination: gputypes.TextureDimension3D}, + } { + t.Run(test.name, func(t *testing.T) { + const width, height, depth, bytesPerRow, offset = uint32(4), uint32(2), uint32(3), uint32(256), uint64(16) + const rowsPerImage = uint32(3) + const bytesPerImage = bytesPerRow * rowsPerImage + data := metalTextureArrayTestData(width, height, depth, bytesPerRow, bytesPerImage) + upload := createMetalTextureCopyTestUpload(t, device, offset, data) + defer upload.Destroy() + source := createMetalTextureCopyTestTexture(t, device, test.source, width+3, height+2, depth+2) + defer source.Destroy() + destination := createMetalTextureCopyTestTexture(t, device, test.destination, width+3, height+2, depth+2) + defer destination.Destroy() + readback := createMetalTextureCopyTestReadback(t, device, offset+uint64(len(data))) + defer readback.Destroy() + + layout := hal.ImageDataLayout{Offset: offset, BytesPerRow: bytesPerRow, RowsPerImage: rowsPerImage} + size := hal.Extent3D{Width: width, Height: height, DepthOrArrayLayers: depth} + sourceOrigin := hal.Origin3D{X: 1, Y: 1, Z: 1} + destinationOrigin := hal.Origin3D{X: 2, Y: 1, Z: 1} + encoder := &CommandEncoder{device: device} + if err := encoder.BeginEncoding(test.name); err != nil { + t.Fatalf("BeginEncoding failed: %v", err) + } + encoder.CopyBufferToTexture(upload, source, []hal.BufferTextureCopy{{BufferLayout: layout, TextureBase: hal.ImageCopyTexture{Texture: source, Origin: sourceOrigin}, Size: size}}) + encoder.CopyTextureToTexture(source, destination, []hal.TextureCopy{{SrcBase: hal.ImageCopyTexture{Texture: source, Origin: sourceOrigin}, DstBase: hal.ImageCopyTexture{Texture: destination, Origin: destinationOrigin}, Size: size}}) + encoder.CopyTextureToBuffer(destination, readback, []hal.BufferTextureCopy{{BufferLayout: layout, TextureBase: hal.ImageCopyTexture{Texture: destination, Origin: destinationOrigin}, Size: size}}) + submitMetalTextureCopyTestEncoder(t, device, queue, encoder) + + got := unsafe.Slice((*byte)(readback.Contents()), int(offset)+len(data))[offset:] + assertMetalTextureArrayTestData(t, got, data, width, height, depth, bytesPerRow, bytesPerImage) + }) + } +} + +func TestQueueWriteTextureLayeredShapes(t *testing.T) { + device, queue := newMetalTextureCopyTestDevice(t) + hasUnifiedMemory := device.hasUnifiedMemory + + shapes := []struct { + name string + dimension gputypes.TextureDimension + height uint32 + }{ + {name: "1D array", dimension: gputypes.TextureDimension1D, height: 1}, + {name: "2D array", dimension: gputypes.TextureDimension2D, height: 2}, + {name: "true 3D", dimension: gputypes.TextureDimension3D, height: 2}, + } + storageModes := []struct { + name string + shared bool + }{ + {name: "private staging", shared: false}, + {name: "shared direct", shared: true}, + } + for _, shape := range shapes { + for _, storage := range storageModes { + t.Run(shape.name+"/"+storage.name, func(t *testing.T) { + if storage.shared && !hasUnifiedMemory { + t.Skip("direct Shared-texture writes require unified memory") + } + device.hasUnifiedMemory = storage.shared + + const ( + width = uint32(4) + layers = uint32(3) + bytesPerRow = uint32(256) + offset = uint64(16) + ) + rowsPerImage := shape.height + 1 + bytesPerImage := bytesPerRow * rowsPerImage + expected := metalTextureArrayTestData(width, shape.height, layers, bytesPerRow, bytesPerImage) + data := make([]byte, offset+uint64(len(expected))) + copy(data[offset:], expected) + + texture := createMetalTextureCopyTestTexture(t, device, shape.dimension, width, shape.height, layers) + defer texture.Destroy() + if texture.isShared != storage.shared { + t.Fatalf("texture shared mode = %t; want %t", texture.isShared, storage.shared) + } + readback := createMetalTextureCopyTestReadback(t, device, uint64(len(expected))) + defer readback.Destroy() + + layout := &hal.ImageDataLayout{Offset: offset, BytesPerRow: bytesPerRow, RowsPerImage: rowsPerImage} + size := &hal.Extent3D{Width: width, Height: shape.height, DepthOrArrayLayers: layers} + requiredBytes := offset + uint64(layers-1)*uint64(bytesPerImage) + uint64(shape.height-1)*uint64(bytesPerRow) + uint64(width*4) + if err := queue.WriteTexture(&hal.ImageCopyTexture{Texture: texture}, data[:requiredBytes-1], layout, size); err == nil { + t.Fatal("WriteTexture accepted a source truncated in the final image") + } + if err := queue.WriteTexture(&hal.ImageCopyTexture{Texture: texture}, data, layout, size); err != nil { + t.Fatalf("WriteTexture failed: %v", err) + } + + encoder := &CommandEncoder{device: device} + if err := encoder.BeginEncoding(shape.name + " WriteTexture readback"); err != nil { + t.Fatalf("BeginEncoding failed: %v", err) + } + encoder.CopyTextureToBuffer(texture, readback, []hal.BufferTextureCopy{{ + BufferLayout: hal.ImageDataLayout{BytesPerRow: bytesPerRow, RowsPerImage: rowsPerImage}, + TextureBase: hal.ImageCopyTexture{Texture: texture}, + Size: *size, + }}) + commandBuffer, err := encoder.EndEncoding() + if err != nil { + t.Fatalf("EndEncoding failed: %v", err) + } + defer commandBuffer.Destroy() + if _, err := queue.Submit([]hal.CommandBuffer{commandBuffer}); err != nil { + t.Fatalf("Submit failed: %v", err) + } + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle failed: %v", err) + } + + got := unsafe.Slice((*byte)(readback.Contents()), len(expected)) + assertMetalTextureArrayTestData(t, got, expected, width, shape.height, layers, bytesPerRow, bytesPerImage) + }) + } + } +} + +func newMetalTextureCopyTestDevice(t *testing.T) (*Device, *Queue) { + t.Helper() + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + rawDevice := CreateSystemDefaultDevice() + if rawDevice == 0 { + t.Skip("no Metal device available") + } + t.Cleanup(func() { Release(rawDevice) }) + + device, err := newDevice(&Adapter{raw: rawDevice}) + if err != nil { + t.Fatalf("newDevice failed: %v", err) + } + t.Cleanup(device.Destroy) + queue := &Queue{device: device, commandQueue: device.commandQueue} + device.queue = queue + return device, queue +} + +func createMetalTextureCopyTestArray(t *testing.T, device *Device, width, height, layers uint32) *Texture { + return createMetalTextureCopyTestTexture(t, device, gputypes.TextureDimension2D, width, height, layers) +} + +func createMetalTextureCopyTestTexture(t *testing.T, device *Device, dimension gputypes.TextureDimension, width, height, depthOrLayers uint32) *Texture { + t.Helper() + raw, err := device.CreateTexture(&hal.TextureDescriptor{ + Size: hal.Extent3D{Width: width, Height: height, DepthOrArrayLayers: depthOrLayers}, + MipLevelCount: 1, + SampleCount: 1, + Dimension: dimension, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageCopySrc | gputypes.TextureUsageCopyDst, + }) + if err != nil { + t.Fatalf("CreateTexture failed: %v", err) + } + return raw.(*Texture) +} + +func createMetalTextureCopyTestReadback(t *testing.T, device *Device, size uint64) *Buffer { + t.Helper() + raw, err := device.CreateBuffer(&hal.BufferDescriptor{ + Size: size, + Usage: gputypes.BufferUsageMapRead | gputypes.BufferUsageCopyDst, + }) + if err != nil { + t.Fatalf("CreateBuffer(readback) failed: %v", err) + } + return raw.(*Buffer) +} + +func createMetalTextureCopyTestUpload(t *testing.T, device *Device, offset uint64, data []byte) *Buffer { + t.Helper() + raw, err := device.CreateBuffer(&hal.BufferDescriptor{ + Size: offset + uint64(len(data)), + Usage: gputypes.BufferUsageMapWrite | gputypes.BufferUsageCopySrc, + }) + if err != nil { + t.Fatalf("CreateBuffer(upload) failed: %v", err) + } + upload := raw.(*Buffer) + copy(unsafe.Slice((*byte)(upload.Contents()), int(offset)+len(data))[offset:], data) + return upload +} + +func submitMetalTextureCopyTestEncoder(t *testing.T, device *Device, queue *Queue, encoder *CommandEncoder) { + t.Helper() + commandBuffer, err := encoder.EndEncoding() + if err != nil { + t.Fatalf("EndEncoding failed: %v", err) + } + defer commandBuffer.Destroy() + if _, err := queue.Submit([]hal.CommandBuffer{commandBuffer}); err != nil { + t.Fatalf("Submit failed: %v", err) + } + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle failed: %v", err) + } +} + +func metalTextureArrayTestData(width, height, layers, bytesPerRow, bytesPerImage uint32) []byte { + data := make([]byte, bytesPerImage*layers) + for layer := uint32(0); layer < layers; layer++ { + for row := uint32(0); row < height; row++ { + for column := uint32(0); column < width; column++ { + offset := layer*bytesPerImage + row*bytesPerRow + column*4 + data[offset+0] = byte(10 + layer) + data[offset+1] = byte(20 + row) + data[offset+2] = byte(30 + column) + data[offset+3] = 255 + } + } + } + return data +} + +func assertMetalTextureArrayTestData(t *testing.T, got, want []byte, width, height, layers, bytesPerRow, bytesPerImage uint32) { + t.Helper() + for layer := uint32(0); layer < layers; layer++ { + for row := uint32(0); row < height; row++ { + rowStart := layer*bytesPerImage + row*bytesPerRow + rowEnd := rowStart + width*4 + for index := rowStart; index < rowEnd; index++ { + if got[index] != want[index] { + t.Fatalf("layer %d row %d byte %d = %d; want %d", layer, row, index-rowStart, got[index], want[index]) + } + } + } + } +}