acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.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

297 lines
12 KiB
C#

using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6b: <see cref="IGpuTexture"/> on Vulkan.
///
/// <para>Images are device-local and filled through the staging path. 2D arrays
/// are allocated at full size and filled layer by layer, mirroring
/// <c>ManagedGLTextureArray</c> — which is why the upload queue tracks the
/// layout an image is in on entry rather than always discarding its contents.
/// </para>
///
/// <para><see cref="GenerateMipChain"/> is explicit rather than automatic
/// because the two backends genuinely cannot do it the same way, and the
/// contract says so. Uncompressed images get a <c>vkCmdBlitImage</c> chain.
/// Block-compressed images cannot: a compressed image is not a legal blit
/// destination, so this method throws and the caller supplies a CPU-built chain
/// through <see cref="Upload"/> (<see cref="BlockCompressionMipChain"/> builds
/// it). That is a deliberate improvement rather than a limitation — the GL path
/// calls <c>glGenerateMipmap</c> on compressed arrays, whose result is
/// implementation-defined.</para>
/// </summary>
internal sealed unsafe class VulkanGpuTexture : IGpuTexture
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly VulkanDeviceMemoryAllocator _allocator;
private readonly VulkanUploadQueue _uploads;
private readonly IGpuResourceRetirementQueue _retirement;
private readonly VulkanAllocation _allocation;
private bool _disposed;
internal VulkanGpuTexture(
Silk.NET.Vulkan.Vk vk,
Device device,
VulkanDeviceMemoryAllocator allocator,
VulkanUploadQueue uploads,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
in GpuTextureDescription description,
int sampleCount = 1,
bool renderTarget = false)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_allocator = allocator ?? throw new ArgumentNullException(nameof(allocator));
_uploads = uploads ?? throw new ArgumentNullException(nameof(uploads));
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.LayerCount);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.MipLevelCount);
Name = description.Name;
Kind = description.Kind;
Format = description.Format;
Width = description.Width;
Height = description.Height;
LayerCount = description.LayerCount;
MipLevelCount = description.MipLevelCount;
SampleCount = sampleCount;
VkFormat = VulkanTextureFormatMapping.FormatOf(description.Format);
Aspect = VulkanTextureFormatMapping.AspectOf(description.Format);
bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format);
ImageUsageFlags usage = depthStencil
? ImageUsageFlags.DepthStencilAttachmentBit
: ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
if (renderTarget && !depthStencil)
usage |= ImageUsageFlags.ColorAttachmentBit;
if (sampleCount > 1)
{
// A multisampled image is never sampled or copied directly; it is
// resolved. Declaring TRANSIENT lets a tiler keep it in on-chip
// memory and never write it out at all.
usage = depthStencil
? ImageUsageFlags.DepthStencilAttachmentBit | ImageUsageFlags.TransientAttachmentBit
: ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransientAttachmentBit;
}
var create = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = VkFormat,
Extent = new Extent3D((uint)description.Width, (uint)description.Height, 1),
MipLevels = (uint)description.MipLevelCount,
ArrayLayers = (uint)description.LayerCount,
Samples = VulkanTextureFormatMapping.SampleCountOf(sampleCount),
Tiling = ImageTiling.Optimal,
Usage = usage,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
VulkanInterop.Check(
_vk.CreateImage(_device, &create, null, out Image image),
$"vkCreateImage ('{description.Name}')");
Image = image;
try
{
_vk.GetImageMemoryRequirements(_device, image, out MemoryRequirements requirements);
_allocation = _allocator.Allocate(requirements, GpuMemoryResidency.DeviceLocal, description.Name);
VulkanInterop.Check(
_vk.BindImageMemory(_device, image, _allocation.Memory, _allocation.OffsetBytes),
$"vkBindImageMemory ('{description.Name}')");
var viewCreate = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = VulkanTextureFormatMapping.ViewTypeOf(description.Kind),
Format = VkFormat,
SubresourceRange = new ImageSubresourceRange
{
AspectMask = Aspect,
BaseMipLevel = 0,
LevelCount = (uint)description.MipLevelCount,
BaseArrayLayer = 0,
LayerCount = (uint)description.LayerCount,
},
};
VulkanInterop.Check(
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
$"vkCreateImageView ('{description.Name}')");
View = view;
}
catch
{
_vk.DestroyImage(_device, image, null);
throw;
}
debugNames.NameImage(image, description.Name);
debugNames.NameImageView(View, $"{description.Name}-view");
}
public string Name { get; }
public GpuTextureKind Kind { get; }
public GpuTextureFormat Format { get; }
public int Width { get; }
public int Height { get; }
public int LayerCount { get; }
public int MipLevelCount { get; }
internal int SampleCount { get; }
internal Image Image { get; }
internal ImageView View { get; }
internal Format VkFormat { get; }
internal ImageAspectFlags Aspect { get; }
/// <summary>
/// Layout the image is currently in, as far as the CPU-side record knows.
/// Starts UNDEFINED so the first upload may discard, and becomes
/// SHADER_READ_ONLY once anything has been written — which is what stops an
/// incremental array-layer fill from erasing the layers already there.
/// </summary>
internal ImageLayout CurrentLayout { get; private set; } = ImageLayout.Undefined;
internal void MarkLayout(ImageLayout layout) => CurrentLayout = layout;
public void Upload(int mipLevel, int layer, ReadOnlySpan<byte> data)
{
ThrowIfDisposed();
ArgumentOutOfRangeException.ThrowIfNegative(mipLevel);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(mipLevel, MipLevelCount);
ArgumentOutOfRangeException.ThrowIfNegative(layer);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, LayerCount);
if (data.IsEmpty)
return;
(int width, int height) = VulkanTextureFormatMapping.LevelExtent(Width, Height, mipLevel);
int expected = VulkanTextureFormatMapping.LevelSizeBytes(Format, width, height);
if (data.Length < expected)
{
throw new ArgumentException(
$"Mip {mipLevel} of '{Name}' is {width}x{height} and needs {expected} bytes; " +
$"{data.Length} were supplied.",
nameof(data));
}
_uploads.StageImageWrite(Image, mipLevel, layer, width, height, CurrentLayout, data, Name);
CurrentLayout = ImageLayout.ShaderReadOnlyOptimal;
}
public void GenerateMipChain()
{
ThrowIfDisposed();
if (MipLevelCount <= 1)
return;
if (BlockCompressionCodec.IsBlockCompressed(Format))
{
throw new NotSupportedException(
$"'{Name}' is {Format}, and Vulkan cannot blit into a block-compressed image. " +
"Build the chain on the CPU with BlockCompressionMipChain and upload each level " +
"through Upload(mipLevel, layer, data). The GL path's reliance on driver-defined " +
"glGenerateMipmap for compressed arrays is deliberately not carried forward.");
}
_uploads.EnqueueMipBlit(Image, Width, Height, MipLevelCount, LayerCount, CurrentLayout);
CurrentLayout = ImageLayout.ShaderReadOnlyOptimal;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Image image = Image;
ImageView view = View;
VulkanAllocation allocation = _allocation;
_retirement.Retire(() =>
{
_vk.DestroyImageView(_device, view, null);
_vk.DestroyImage(_device, image, null);
_allocator.Free(allocation);
});
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}
/// <summary>
/// Campaign V slice V6b: <see cref="IGpuSampler"/> on Vulkan.
///
/// Immutable and de-duplicated by value at the device, because the set of
/// distinct samplers acdream uses is tiny — wrap or clamp, crossed with nearest
/// or linear. That is exactly what makes a combined image-sampler descriptor
/// table practical: a texture registered with two samplers occupies two slots,
/// the same way it holds two bindless handles today.
/// </summary>
internal sealed unsafe class VulkanGpuSampler : IGpuSampler
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly IGpuResourceRetirementQueue _retirement;
private bool _disposed;
internal VulkanGpuSampler(
Silk.NET.Vulkan.Vk vk,
Device device,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
in GpuSamplerDescription description,
float maxSupportedAnisotropy)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description;
float anisotropy = Math.Clamp(description.MaxAnisotropy, 1f, Math.Max(1f, maxSupportedAnisotropy));
var create = new SamplerCreateInfo
{
SType = StructureType.SamplerCreateInfo,
MinFilter = VulkanTextureFormatMapping.FilterOf(description.MinFilter),
MagFilter = VulkanTextureFormatMapping.FilterOf(description.MagFilter),
MipmapMode = VulkanTextureFormatMapping.MipmapModeOf(description.MipFilter),
AddressModeU = VulkanTextureFormatMapping.AddressModeOf(description.AddressU),
AddressModeV = VulkanTextureFormatMapping.AddressModeOf(description.AddressV),
AddressModeW = VulkanTextureFormatMapping.AddressModeOf(description.AddressV),
AnisotropyEnable = anisotropy > 1f,
MaxAnisotropy = anisotropy,
MinLod = 0f,
// GpuMipFilter.None means "level 0 only", which Vulkan expresses as a
// zero-width LOD range rather than as a filter mode.
MaxLod = description.MipFilter == GpuMipFilter.None ? 0f : Silk.NET.Vulkan.Vk.LodClampNone,
BorderColor = BorderColor.FloatTransparentBlack,
CompareEnable = false,
UnnormalizedCoordinates = false,
};
VulkanInterop.Check(
_vk.CreateSampler(_device, &create, null, out Sampler sampler),
"vkCreateSampler");
Handle = sampler;
debugNames.NameSampler(
sampler,
$"sampler-{description.MinFilter}-{description.MipFilter}-{description.AddressU}");
}
public GpuSamplerDescription Description { get; }
internal Sampler Handle { get; }
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Sampler handle = Handle;
_retirement.Retire(() => _vk.DestroySampler(_device, handle, null));
}
}