acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs
Erik 9eae496301 feat(render): Campaign V slice V6b - Vulkan textures, mips, samplers and the descriptor table
The second of V6's three commits: everything the fragment stage samples. Plan
sections 4.3 (textures and mip generation) and 4.4 (descriptors).

The descriptor table is the piece that retires GL_ARB_bindless_texture. One
update-after-bind, partially-bound, variable-count combined-image-sampler array
of 16384; registration appends exactly one vkUpdateDescriptorSets and nothing is
written at draw time, so steady state is zero descriptor writes per frame. A
slot is a (view, sampler) pair, exactly like a bindless handle, which is why the
CPU data model needs no change at all - GpuTextureSlot already carries the index
and V2 already moved every batch onto it.

Eviction is retirement-gated and the slot is scrubbed on the way out. Returning
a slot the moment a texture is deleted would let the LRU alias a live draw onto
a new texture, so the release is filed through the ledger; and when it runs the
slot is first overwritten with the default 1x1 white. A stale view descriptor
sitting in a partially-bound array is legal right up until something reads it,
at which point it is a use-after-free with no error attached. Writing the dummy
makes that impossible rather than unlikely.

The CPU block-compression codec is the slice's other substantial piece, and it
exists because Vulkan cannot blit into a compressed image. DAT surfaces arrive
as DXT1/3/5 with no mips, so the chain has to be decoded, box filtered and
re-encoded here. That is not merely a substitute for the missing blit: the GL
path calls glGenerateMipmap on compressed array textures, whose result is
explicitly implementation-defined, so this is the first time that part of the
pipeline has had a defined answer.

Two properties matter more than quality, and both are tested. It is
deterministic - integer arithmetic end to end, endpoints from the block's
bounding box, nearest-palette selection, no dithering and no iterative fit -
because the offline pixel gate compares captures from separate processes and a
chain that varied run to run would make every textured surface look like a
regression. And it preserves BC1's one-bit cut-out: a block containing any texel
below the alpha threshold is encoded in three-colour mode, because retail's
foliage and grates ARE that mode and quantising those texels to an opaque colour
would fill in every leaf. Plan 4.3's escape hatch stands if quality ever trips a
gate: store the affected textures as RGBA8 and blit their mips.

Uncompressed images do take the blit chain, added to the upload queue. Each
source level moves to TRANSFER_SRC for its blit and back to TRANSFER_DST
afterwards; leaving the chain in mixed layouts would be one barrier cheaper and
would then force the batch's final shader-read transition to name a different
old layout per level, so ending every level the same way is what keeps that
transition one barrier per image.

The upload queue now records the layout each image is in on ENTRY to a batch
rather than always naming UNDEFINED. UNDEFINED lets the driver discard existing
contents, which is right for a fresh image and wrong for the incremental
array-layer fills that mirror ManagedGLTextureArray - discarding there would
erase every layer uploaded earlier.

Render targets are single-sampled per the contract and carry SAMPLED usage
alongside COLOR_ATTACHMENT, so a paperdoll or appraisal view can be registered
into the table and drawn by the retained UI the moment its pass ends.
VulkanBackbufferAttachments owns the two attachments the swapchain does not: the
multisampled colour scratch that resolves into the swapchain image, and the
transient depth/stencil. Both are TRANSIENT_ATTACHMENT because nothing reads
either after the frame. Stencil is not optional - issue #117's portal punch
needs the aspect, which is why the V5 gate prefers D32_SFLOAT_S8_UINT over a
depth-only format.

Every format stays UNORM, and that is the V3 audit's finding rather than a
default. The plan previously specified an sRGB swapchain "matching the GL
FramebufferSrgb contract"; that contract does not exist, the renderer is plain
UNORM end to end, and shipping _SRGB would have brightened every frame and
passed silently until V7.

VulkanPipelineLayouts is extracted from V5's capability probe rather than
written beside it, and the probe now calls it. The probe's whole value is
proving the layouts the live backend builds can be built on this device; two
similar-looking definitions would have quietly ended that the first time one of
them changed.

Gates: Release build clean, App suite 4037 passed / 3 skipped (4014 at V6a plus
23 new), offline pixel gate PASS against the parent baseline at a differing
fraction of 4.26e-05 - 24 pixels of 563,200, one above the campaign's recorded
15-23 same-commit noise band and about 23x under the 0.001 threshold, on a
commit that changes no GL code path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:49:40 +02:00

578 lines
22 KiB
C#

using Silk.NET.Vulkan;
using Buffer = Silk.NET.Vulkan.Buffer;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6a, plan §4.3 and §4.8: every transfer that has been asked
/// for but not yet recorded, plus the staging memory those transfers read from.
///
/// <para><b>Why a queue rather than an immediate copy.</b> Uploads arrive from
/// the streaming and texture-cache code at arbitrary moments, including with no
/// frame open at all. Vulkan copies must be recorded into a command buffer, and
/// they must be recorded OUTSIDE a dynamic-rendering block. So requests
/// accumulate here and are drained into the frame's command buffer at the one
/// moment both conditions hold: immediately before a pass begins. That is the
/// direct analogue of the GL backend's "flush immediately before every draw"
/// discipline, moved to the coarser granularity Vulkan actually needs.</para>
///
/// <para><b>One batched barrier, not one per copy.</b> The drain emits a single
/// buffer memory barrier covering every copy it recorded, moving the whole batch
/// from transfer writes to the vertex/index/indirect/shader reads that follow.
/// Plan §4.8 budgets four to six barriers per frame; this is one of them.</para>
///
/// <para><b>Staging exhaustion is not an error.</b> When the ring cannot serve a
/// request — the payload is larger than the whole ring, or unretired frames hold
/// the space — the upload takes a temporary dedicated staging buffer that is
/// retired through the ledger. The transfer is equally correct and equally
/// ordered; it just costs one allocation. See
/// <see cref="VulkanStagingRingState"/> for why that is a policy rather than a
/// workaround.</para>
/// </summary>
internal sealed unsafe class VulkanUploadQueue : IDisposable
{
/// <summary>Plan §4.3: a 48 MiB persistently mapped staging ring.</summary>
internal const ulong DefaultStagingCapacityBytes = 48UL * 1024 * 1024;
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly VulkanDeviceMemoryAllocator _allocator;
private readonly VulkanFrameFlightController _flights;
private readonly VulkanStagingRingState _ringState;
private readonly VulkanDebugNames _debugNames;
private readonly Buffer _stagingBuffer;
private readonly VulkanAllocation _stagingAllocation;
private readonly List<BufferCopy2> _bufferCopies = [];
private readonly List<ImageCopy2> _imageCopies = [];
private readonly List<TemporaryStaging> _temporaries = [];
private bool _disposed;
private readonly record struct BufferCopy2(Buffer Source, Buffer Destination, ulong SourceOffset, ulong DestinationOffset, ulong SizeBytes);
private readonly record struct ImageCopy2(
Buffer Source,
ulong SourceOffset,
Image Destination,
uint MipLevel,
uint Layer,
uint Width,
uint Height);
private readonly record struct TemporaryStaging(Buffer Buffer, VulkanAllocation Allocation);
internal VulkanUploadQueue(
Silk.NET.Vulkan.Vk vk,
Device device,
VulkanDeviceMemoryAllocator allocator,
VulkanFrameFlightController flights,
VulkanDebugNames debugNames,
ulong stagingCapacityBytes = DefaultStagingCapacityBytes)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_allocator = allocator ?? throw new ArgumentNullException(nameof(allocator));
_flights = flights ?? throw new ArgumentNullException(nameof(flights));
_debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames));
_ringState = new VulkanStagingRingState(stagingCapacityBytes);
(_stagingBuffer, _stagingAllocation) = CreateHostBuffer(
stagingCapacityBytes,
BufferUsageFlags.TransferSrcBit,
"vk-staging-ring");
}
/// <summary>
/// Images touched by this batch, and the layout each is in on entry.
///
/// <para>The entry layout is not always <c>UNDEFINED</c>, and that
/// distinction is load-bearing. <c>UNDEFINED</c> lets the driver discard the
/// existing contents, which is exactly right for the first upload into a
/// fresh image and exactly wrong for the incremental array-layer fills that
/// mirror <c>ManagedGLTextureArray</c> — discarding there would erase every
/// layer uploaded earlier. The first writer of a batch records the layout it
/// found the image in, and that is what the barrier names.</para>
/// </summary>
private readonly Dictionary<Image, ImageLayout> _imageEntryLayouts = [];
/// <summary>Mip chains to generate with <c>vkCmdBlitImage</c> after this batch's copies land.</summary>
private readonly List<MipBlitRequest> _mipBlits = [];
private readonly record struct MipBlitRequest(
Image Image,
int Width,
int Height,
int MipLevelCount,
int LayerCount);
internal int PendingBufferCopyCount => _bufferCopies.Count;
internal int PendingImageCopyCount => _imageCopies.Count;
internal ulong StagingLiveBytes => _ringState.LiveBytes;
/// <summary>Stages <paramref name="data"/> and queues a copy into <paramref name="destination"/>.</summary>
internal void StageBufferWrite(
Buffer destination,
ulong destinationOffsetBytes,
ReadOnlySpan<byte> data,
string ownerName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (data.IsEmpty)
return;
(Buffer source, ulong sourceOffset) = Stage(data, ownerName);
_bufferCopies.Add(new BufferCopy2(
source,
destination,
sourceOffset,
destinationOffsetBytes,
(ulong)data.Length));
}
/// <summary>Stages <paramref name="data"/> and queues a copy into one mip level of one array layer.</summary>
internal void StageImageWrite(
Image destination,
int mipLevel,
int layer,
int width,
int height,
ImageLayout entryLayout,
ReadOnlySpan<byte> data,
string ownerName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (data.IsEmpty)
return;
// Vulkan requires a buffer-to-image copy's source offset to be a
// multiple of 4 and of the texel/block size; 16 covers every format
// acdream uses (BC3's 16-byte block is the largest).
(Buffer source, ulong sourceOffset) = Stage(data, ownerName, alignmentBytes: 16);
_imageCopies.Add(new ImageCopy2(
source,
sourceOffset,
destination,
(uint)mipLevel,
(uint)layer,
(uint)width,
(uint)height));
RecordEntryLayout(destination, entryLayout);
}
/// <summary>
/// Queues a <c>vkCmdBlitImage</c> mip chain for an uncompressed image. BC
/// images cannot use this — a compressed image is not a legal blit
/// destination — and take a CPU-built chain instead (plan §4.3).
/// </summary>
internal void EnqueueMipBlit(
Image image,
int width,
int height,
int mipLevelCount,
int layerCount,
ImageLayout entryLayout)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (mipLevelCount <= 1)
return;
_mipBlits.Add(new MipBlitRequest(image, width, height, mipLevelCount, layerCount));
RecordEntryLayout(image, entryLayout);
}
private void RecordEntryLayout(Image image, ImageLayout entryLayout)
{
// First writer of the batch wins: a later writer that found the image
// already in TRANSFER_DST is describing this batch's own effect, not the
// layout the batch started from.
_imageEntryLayouts.TryAdd(image, entryLayout);
}
/// <summary>Queues a device-side buffer copy — the mesh arena's grow-and-copy migration.</summary>
internal void EnqueueBufferCopy(
Buffer source,
Buffer destination,
ulong sourceOffsetBytes,
ulong destinationOffsetBytes,
ulong byteCount)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (byteCount == 0)
return;
_bufferCopies.Add(new BufferCopy2(
source,
destination,
sourceOffsetBytes,
destinationOffsetBytes,
byteCount));
}
/// <summary>
/// Records every pending transfer into <paramref name="commands"/> and
/// clears the queue. Must be called outside a dynamic-rendering block.
/// Returns false when there was nothing to do.
/// </summary>
internal bool Record(CommandBuffer commands)
{
if (_bufferCopies.Count == 0 && _imageCopies.Count == 0 && _mipBlits.Count == 0)
return false;
if (_imageEntryLayouts.Count > 0)
TransitionImagesToTransfer(commands);
foreach (BufferCopy2 copy in _bufferCopies)
{
var region = new BufferCopy
{
SrcOffset = copy.SourceOffset,
DstOffset = copy.DestinationOffset,
Size = copy.SizeBytes,
};
_vk.CmdCopyBuffer(commands, copy.Source, copy.Destination, 1, &region);
}
foreach (ImageCopy2 copy in _imageCopies)
{
var region = new BufferImageCopy
{
BufferOffset = copy.SourceOffset,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
MipLevel = copy.MipLevel,
BaseArrayLayer = copy.Layer,
LayerCount = 1,
},
ImageOffset = new Offset3D(0, 0, 0),
ImageExtent = new Extent3D(copy.Width, copy.Height, 1),
};
_vk.CmdCopyBufferToImage(
commands,
copy.Source,
copy.Destination,
ImageLayout.TransferDstOptimal,
1,
&region);
}
foreach (MipBlitRequest blit in _mipBlits)
RecordMipBlit(commands, blit);
if (_imageEntryLayouts.Count > 0)
TransitionImagesToShaderRead(commands);
// One buffer barrier for the whole batch: transfer writes become
// readable by every consumer stage a copied buffer can feed.
if (_bufferCopies.Count > 0)
{
var barrier = new MemoryBarrier2
{
SType = StructureType.MemoryBarrier2,
SrcStageMask = PipelineStageFlags2.AllTransferBit,
SrcAccessMask = AccessFlags2.TransferWriteBit,
DstStageMask = PipelineStageFlags2.VertexInputBit
| PipelineStageFlags2.VertexShaderBit
| PipelineStageFlags2.FragmentShaderBit
| PipelineStageFlags2.DrawIndirectBit,
DstAccessMask = AccessFlags2.VertexAttributeReadBit
| AccessFlags2.IndexReadBit
| AccessFlags2.ShaderReadBit
| AccessFlags2.UniformReadBit
| AccessFlags2.IndirectCommandReadBit,
};
var dependency = new DependencyInfo
{
SType = StructureType.DependencyInfo,
MemoryBarrierCount = 1,
PMemoryBarriers = &barrier,
};
_vk.CmdPipelineBarrier2(commands, &dependency);
}
_bufferCopies.Clear();
_imageCopies.Clear();
_mipBlits.Clear();
_imageEntryLayouts.Clear();
return true;
}
private static ImageSubresourceRange WholeColorImage => new()
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = 0,
LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels,
BaseArrayLayer = 0,
LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers,
};
private void TransitionImagesToTransfer(CommandBuffer commands)
{
var barriers = new ImageMemoryBarrier2[_imageEntryLayouts.Count];
int index = 0;
foreach ((Image image, ImageLayout entryLayout) in _imageEntryLayouts)
{
barriers[index++] = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
SrcStageMask = PipelineStageFlags2.AllCommandsBit,
SrcAccessMask = AccessFlags2.None,
DstStageMask = PipelineStageFlags2.AllTransferBit,
DstAccessMask = AccessFlags2.TransferWriteBit | AccessFlags2.TransferReadBit,
OldLayout = entryLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = WholeColorImage,
};
}
SubmitBarriers(commands, barriers);
}
private void TransitionImagesToShaderRead(CommandBuffer commands)
{
var barriers = new ImageMemoryBarrier2[_imageEntryLayouts.Count];
int index = 0;
foreach (Image image in _imageEntryLayouts.Keys)
{
barriers[index++] = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
SrcStageMask = PipelineStageFlags2.AllTransferBit,
SrcAccessMask = AccessFlags2.TransferWriteBit,
DstStageMask = PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.VertexShaderBit,
DstAccessMask = AccessFlags2.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = WholeColorImage,
};
}
SubmitBarriers(commands, barriers);
}
private void SubmitBarriers(CommandBuffer commands, ImageMemoryBarrier2[] barriers)
{
if (barriers.Length == 0)
return;
fixed (ImageMemoryBarrier2* first = barriers)
{
var dependency = new DependencyInfo
{
SType = StructureType.DependencyInfo,
ImageMemoryBarrierCount = (uint)barriers.Length,
PImageMemoryBarriers = first,
};
_vk.CmdPipelineBarrier2(commands, &dependency);
}
}
/// <summary>
/// Halves level N into level N+1 with a linear blit, all layers at once.
///
/// <para>Each source level is moved to TRANSFER_SRC for its blit and then
/// moved BACK to TRANSFER_DST. Leaving the chain in mixed layouts would be
/// one barrier cheaper and would then need the batch's final
/// shader-read transition to name a different old layout per level; ending
/// every level in the same layout is what lets that final transition stay
/// one barrier per image.</para>
/// </summary>
private void RecordMipBlit(CommandBuffer commands, in MipBlitRequest request)
{
int width = request.Width;
int height = request.Height;
for (uint level = 1; level < request.MipLevelCount; level++)
{
int nextWidth = Math.Max(1, width / 2);
int nextHeight = Math.Max(1, height / 2);
TransitionMipLevel(
commands,
request.Image,
level - 1,
ImageLayout.TransferDstOptimal,
ImageLayout.TransferSrcOptimal,
AccessFlags2.TransferWriteBit,
AccessFlags2.TransferReadBit);
var blit = new ImageBlit2
{
SType = StructureType.ImageBlit2,
SrcSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
MipLevel = level - 1,
BaseArrayLayer = 0,
LayerCount = (uint)request.LayerCount,
},
DstSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
MipLevel = level,
BaseArrayLayer = 0,
LayerCount = (uint)request.LayerCount,
},
};
blit.SrcOffsets.Element0 = new Offset3D(0, 0, 0);
blit.SrcOffsets.Element1 = new Offset3D(width, height, 1);
blit.DstOffsets.Element0 = new Offset3D(0, 0, 0);
blit.DstOffsets.Element1 = new Offset3D(nextWidth, nextHeight, 1);
var info = new BlitImageInfo2
{
SType = StructureType.BlitImageInfo2,
SrcImage = request.Image,
SrcImageLayout = ImageLayout.TransferSrcOptimal,
DstImage = request.Image,
DstImageLayout = ImageLayout.TransferDstOptimal,
RegionCount = 1,
PRegions = &blit,
Filter = Filter.Linear,
};
_vk.CmdBlitImage2(commands, &info);
TransitionMipLevel(
commands,
request.Image,
level - 1,
ImageLayout.TransferSrcOptimal,
ImageLayout.TransferDstOptimal,
AccessFlags2.TransferReadBit,
AccessFlags2.TransferWriteBit);
width = nextWidth;
height = nextHeight;
}
}
private void TransitionMipLevel(
CommandBuffer commands,
Image image,
uint level,
ImageLayout oldLayout,
ImageLayout newLayout,
AccessFlags2 sourceAccess,
AccessFlags2 destinationAccess)
{
var barrier = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
SrcStageMask = PipelineStageFlags2.AllTransferBit,
SrcAccessMask = sourceAccess,
DstStageMask = PipelineStageFlags2.AllTransferBit,
DstAccessMask = destinationAccess,
OldLayout = oldLayout,
NewLayout = newLayout,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = new ImageSubresourceRange
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = level,
LevelCount = 1,
BaseArrayLayer = 0,
LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers,
},
};
var dependency = new DependencyInfo
{
SType = StructureType.DependencyInfo,
ImageMemoryBarrierCount = 1,
PImageMemoryBarriers = &barrier,
};
_vk.CmdPipelineBarrier2(commands, &dependency);
}
/// <summary>Reclaims staging bytes and temporary buffers belonging to completed frames.</summary>
internal void ReleaseCompleted(long completedSerial) => _ringState.Release(completedSerial);
private (Buffer Buffer, ulong Offset) Stage(
ReadOnlySpan<byte> data,
string ownerName,
ulong alignmentBytes = 4)
{
long serial = _flights.OpenSerial != 0 ? _flights.OpenSerial : _flights.SubmittedSerial + 1;
if (_ringState.TryAllocate(data.Length, alignmentBytes, serial, out ulong offset))
{
data.CopyTo(_stagingAllocation.AsSpan().Slice((int)offset, data.Length));
return (_stagingBuffer, offset);
}
(Buffer temporary, VulkanAllocation allocation) = CreateHostBuffer(
(ulong)data.Length,
BufferUsageFlags.TransferSrcBit,
$"vk-staging-temp-{ownerName}");
data.CopyTo(allocation.AsSpan());
_temporaries.Add(new TemporaryStaging(temporary, allocation));
Buffer captured = temporary;
VulkanAllocation capturedAllocation = allocation;
_flights.Retire(() =>
{
_vk.DestroyBuffer(_device, captured, null);
_allocator.Free(capturedAllocation);
_temporaries.RemoveAll(entry => entry.Buffer.Handle == captured.Handle);
});
return (temporary, 0);
}
private (Buffer Buffer, VulkanAllocation Allocation) CreateHostBuffer(
ulong sizeBytes,
BufferUsageFlags usage,
string name)
{
var create = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = sizeBytes,
Usage = usage,
SharingMode = SharingMode.Exclusive,
};
VulkanInterop.Check(
_vk.CreateBuffer(_device, &create, null, out Buffer buffer),
$"vkCreateBuffer ({name})");
_vk.GetBufferMemoryRequirements(_device, buffer, out MemoryRequirements requirements);
VulkanAllocation allocation = _allocator.Allocate(
requirements,
GpuMemoryResidency.HostWritable,
name);
VulkanInterop.Check(
_vk.BindBufferMemory(_device, buffer, allocation.Memory, allocation.OffsetBytes),
$"vkBindBufferMemory ({name})");
_debugNames.NameBuffer(buffer, name);
return (buffer, allocation);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (TemporaryStaging temporary in _temporaries)
{
_vk.DestroyBuffer(_device, temporary.Buffer, null);
_allocator.Free(temporary.Allocation);
}
_temporaries.Clear();
_vk.DestroyBuffer(_device, _stagingBuffer, null);
_allocator.Free(_stagingAllocation);
_ringState.Reset();
_bufferCopies.Clear();
_imageCopies.Clear();
}
}