From 9eae496301a9c85bd951f4504fc05f8458e21857 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 07:49:40 +0200 Subject: [PATCH] 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 --- .../Rendering/Gpu/Vk/BlockCompressionCodec.cs | 521 ++++++++++++++++++ .../Gpu/Vk/BlockCompressionMipChain.cs | 128 +++++ .../Gpu/Vk/VulkanActiveDeviceProbe.cs | 157 +----- .../Gpu/Vk/VulkanGpuDevice.Resources.cs | 187 ++++++- .../Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs | 292 ++++++++++ .../Rendering/Gpu/Vk/VulkanGpuTexture.cs | 297 ++++++++++ .../Rendering/Gpu/Vk/VulkanPipelineLayouts.cs | 220 ++++++++ .../Gpu/Vk/VulkanTextureFormatMapping.cs | 123 +++++ .../Rendering/Gpu/Vk/VulkanTextureTable.cs | 242 ++++++++ .../Rendering/Gpu/Vk/VulkanUploadQueue.cs | 284 ++++++++-- .../Rendering/Gpu/Vk/BlockCompressionTests.cs | 383 +++++++++++++ 11 files changed, 2614 insertions(+), 220 deletions(-) create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionCodec.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionMipChain.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Gpu/Vk/BlockCompressionTests.cs diff --git a/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionCodec.cs b/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionCodec.cs new file mode 100644 index 00000000..7d3d3712 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionCodec.cs @@ -0,0 +1,521 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b, plan §4.3: decode and encode BC1/BC2/BC3 blocks on the +/// CPU. +/// +/// Why acdream needs an encoder at all. DAT surfaces ship as DXT1/3/5 +/// and no mip levels, and they upload as BC1/2/3 with no transcode — that is the +/// whole reason textureCompressionBC is a required feature. But Vulkan +/// cannot vkCmdBlitImage into a compressed image, so the blit chain that +/// serves RGBA8 is unavailable for exactly the formats most of the world uses. +/// The chain therefore has to be built here: decode level 0 to RGBA, box filter +/// it down, re-encode each level. +/// +/// That also replaces something worse. The GL path calls +/// glGenerateMipmap on compressed array textures, whose result is +/// explicitly implementation-defined — two drivers may legitimately produce +/// different pixels. A deterministic managed encoder is not merely a +/// substitute for the missing blit; it is the first time this part of the +/// pipeline has had a defined answer. +/// +/// Deliberately simple, deliberately deterministic. Endpoints come +/// from the block's bounding box and each texel takes its nearest palette entry. +/// No principal-component fit, no iterative refinement, no dithering — every one +/// of those trades reproducibility or complexity for quality that mip levels 1 +/// and below do not need. Identical input bytes must always produce identical +/// output bytes, because the pixel gate compares captures across processes and a +/// mip chain that varied would make every texture look like a regression. Plan +/// §4.3 names the escape hatch if quality ever does trip a gate: store the +/// affected textures as RGBA8 and blit their mips. +/// +internal static class BlockCompressionCodec +{ + /// Edge of a BC block in texels. + internal const int BlockExtent = 4; + + /// Compressed bytes per 4x4 block for a format. + internal static int BlockSizeBytes(GpuTextureFormat format) => format switch + { + GpuTextureFormat.Bc1Unorm => 8, + GpuTextureFormat.Bc2Unorm or GpuTextureFormat.Bc3Unorm => 16, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Not a block-compressed format."), + }; + + internal static bool IsBlockCompressed(GpuTextureFormat format) => + format is GpuTextureFormat.Bc1Unorm or GpuTextureFormat.Bc2Unorm or GpuTextureFormat.Bc3Unorm; + + /// Blocks needed to cover texels along one axis. + internal static int BlockCount(int extent) => Math.Max(1, (extent + BlockExtent - 1) / BlockExtent); + + /// Bytes one mip level of a compressed image occupies. + internal static int LevelSizeBytes(GpuTextureFormat format, int width, int height) => + BlockCount(width) * BlockCount(height) * BlockSizeBytes(format); + + // ── decode ─────────────────────────────────────────────────────────────── + + /// + /// Decodes a whole compressed level into tightly packed RGBA8. The output is + /// x ; texels that fall + /// outside a partially covered edge block are simply not written. + /// + internal static byte[] DecodeLevel( + GpuTextureFormat format, + ReadOnlySpan blocks, + int width, + int height) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + int blockSize = BlockSizeBytes(format); + int blocksX = BlockCount(width); + int blocksY = BlockCount(height); + if (blocks.Length < blocksX * blocksY * blockSize) + { + throw new ArgumentException( + $"A {width}x{height} {format} level needs {blocksX * blocksY * blockSize} bytes; " + + $"{blocks.Length} were supplied.", + nameof(blocks)); + } + + var rgba = new byte[width * height * 4]; + Span texels = stackalloc byte[BlockExtent * BlockExtent * 4]; + for (int by = 0; by < blocksY; by++) + { + for (int bx = 0; bx < blocksX; bx++) + { + int offset = ((by * blocksX) + bx) * blockSize; + DecodeBlock(format, blocks.Slice(offset, blockSize), texels); + + for (int y = 0; y < BlockExtent; y++) + { + int targetY = (by * BlockExtent) + y; + if (targetY >= height) + break; + for (int x = 0; x < BlockExtent; x++) + { + int targetX = (bx * BlockExtent) + x; + if (targetX >= width) + break; + int source = ((y * BlockExtent) + x) * 4; + int destination = ((targetY * width) + targetX) * 4; + texels.Slice(source, 4).CopyTo(rgba.AsSpan(destination, 4)); + } + } + } + } + + return rgba; + } + + /// Decodes one block into 16 RGBA8 texels in row-major order. + internal static void DecodeBlock(GpuTextureFormat format, ReadOnlySpan block, Span rgba) + { + if (rgba.Length < BlockExtent * BlockExtent * 4) + throw new ArgumentException("A decoded block needs 64 bytes.", nameof(rgba)); + + int colourOffset = format == GpuTextureFormat.Bc1Unorm ? 0 : 8; + DecodeColourBlock( + block.Slice(colourOffset, 8), + allowTransparentMode: format == GpuTextureFormat.Bc1Unorm, + rgba); + + switch (format) + { + case GpuTextureFormat.Bc2Unorm: + DecodeExplicitAlpha(block[..8], rgba); + break; + case GpuTextureFormat.Bc3Unorm: + DecodeInterpolatedAlpha(block[..8], rgba); + break; + } + } + + private static void DecodeColourBlock(ReadOnlySpan block, bool allowTransparentMode, Span rgba) + { + ushort c0 = (ushort)(block[0] | (block[1] << 8)); + ushort c1 = (ushort)(block[2] | (block[3] << 8)); + uint indices = (uint)(block[4] | (block[5] << 8) | (block[6] << 16) | (block[7] << 24)); + + Span palette = stackalloc byte[4 * 4]; + Unpack565(c0, palette[..4]); + Unpack565(c1, palette.Slice(4, 4)); + + bool transparent = allowTransparentMode && c0 <= c1; + if (transparent) + { + for (int channel = 0; channel < 3; channel++) + palette[8 + channel] = (byte)((palette[channel] + palette[4 + channel]) / 2); + palette[11] = 255; + palette[12] = 0; + palette[13] = 0; + palette[14] = 0; + palette[15] = 0; + } + else + { + for (int channel = 0; channel < 3; channel++) + { + palette[8 + channel] = (byte)(((2 * palette[channel]) + palette[4 + channel]) / 3); + palette[12 + channel] = (byte)((palette[channel] + (2 * palette[4 + channel])) / 3); + } + + palette[11] = 255; + palette[15] = 255; + } + + for (int texel = 0; texel < 16; texel++) + { + int index = (int)((indices >> (texel * 2)) & 0x3); + palette.Slice(index * 4, 4).CopyTo(rgba.Slice(texel * 4, 4)); + } + } + + private static void DecodeExplicitAlpha(ReadOnlySpan alphaBlock, Span rgba) + { + for (int texel = 0; texel < 16; texel++) + { + int nibble = (alphaBlock[texel / 2] >> ((texel % 2) * 4)) & 0xF; + // 4-bit alpha replicated into 8 bits, the standard BC2 expansion. + rgba[(texel * 4) + 3] = (byte)((nibble * 255) / 15); + } + } + + private static void DecodeInterpolatedAlpha(ReadOnlySpan alphaBlock, Span rgba) + { + byte a0 = alphaBlock[0]; + byte a1 = alphaBlock[1]; + Span palette = stackalloc byte[8]; + palette[0] = a0; + palette[1] = a1; + if (a0 > a1) + { + for (int i = 1; i <= 6; i++) + palette[i + 1] = (byte)((((7 - i) * a0) + (i * a1)) / 7); + } + else + { + for (int i = 1; i <= 4; i++) + palette[i + 1] = (byte)((((5 - i) * a0) + (i * a1)) / 5); + palette[6] = 0; + palette[7] = 255; + } + + ulong bits = 0; + for (int i = 0; i < 6; i++) + bits |= (ulong)alphaBlock[2 + i] << (i * 8); + + for (int texel = 0; texel < 16; texel++) + { + int index = (int)((bits >> (texel * 3)) & 0x7); + rgba[(texel * 4) + 3] = palette[index]; + } + } + + /// + /// Quantises an 8-bit RGB triple into RGB565 by rounding to the nearest + /// representable level. Rounding rather than truncating matters: a + /// truncating pack biases every endpoint darker, and the bias accumulates + /// down a mip chain that re-encodes its own output. + /// + private static ushort Pack565(int r, int g, int b) + { + int r5 = ((Math.Clamp(r, 0, 255) * 31) + 127) / 255; + int g6 = ((Math.Clamp(g, 0, 255) * 63) + 127) / 255; + int b5 = ((Math.Clamp(b, 0, 255) * 31) + 127) / 255; + return (ushort)((r5 << 11) | (g6 << 5) | b5); + } + + private static void Unpack565(ushort packed, Span rgba) + { + int r = (packed >> 11) & 0x1F; + int g = (packed >> 5) & 0x3F; + int b = packed & 0x1F; + rgba[0] = (byte)((r << 3) | (r >> 2)); + rgba[1] = (byte)((g << 2) | (g >> 4)); + rgba[2] = (byte)((b << 3) | (b >> 2)); + rgba[3] = 255; + } + + // ── encode ─────────────────────────────────────────────────────────────── + + /// Encodes a tightly packed RGBA8 level into compressed blocks. + internal static byte[] EncodeLevel( + GpuTextureFormat format, + ReadOnlySpan rgba, + int width, + int height) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + if (rgba.Length < width * height * 4) + throw new ArgumentException($"A {width}x{height} RGBA8 level needs {width * height * 4} bytes.", nameof(rgba)); + + int blockSize = BlockSizeBytes(format); + int blocksX = BlockCount(width); + int blocksY = BlockCount(height); + var output = new byte[blocksX * blocksY * blockSize]; + + Span texels = stackalloc byte[BlockExtent * BlockExtent * 4]; + for (int by = 0; by < blocksY; by++) + { + for (int bx = 0; bx < blocksX; bx++) + { + GatherBlock(rgba, width, height, bx, by, texels); + EncodeBlock(format, texels, output.AsSpan(((by * blocksX) + bx) * blockSize, blockSize)); + } + } + + return output; + } + + /// + /// Copies one 4x4 block out of the image. Texels past the edge repeat the + /// last real column/row rather than reading zero, so a partially covered + /// block does not drag its endpoints toward black. + /// + private static void GatherBlock( + ReadOnlySpan rgba, + int width, + int height, + int blockX, + int blockY, + Span texels) + { + for (int y = 0; y < BlockExtent; y++) + { + int sourceY = Math.Min((blockY * BlockExtent) + y, height - 1); + for (int x = 0; x < BlockExtent; x++) + { + int sourceX = Math.Min((blockX * BlockExtent) + x, width - 1); + int source = ((sourceY * width) + sourceX) * 4; + rgba.Slice(source, 4).CopyTo(texels.Slice(((y * BlockExtent) + x) * 4, 4)); + } + } + } + + internal static void EncodeBlock(GpuTextureFormat format, ReadOnlySpan rgba, Span block) + { + block.Clear(); + switch (format) + { + case GpuTextureFormat.Bc1Unorm: + EncodeColourBlock(rgba, allowTransparentMode: true, block[..8]); + break; + case GpuTextureFormat.Bc2Unorm: + EncodeExplicitAlpha(rgba, block[..8]); + EncodeColourBlock(rgba, allowTransparentMode: false, block.Slice(8, 8)); + break; + case GpuTextureFormat.Bc3Unorm: + EncodeInterpolatedAlpha(rgba, block[..8]); + EncodeColourBlock(rgba, allowTransparentMode: false, block.Slice(8, 8)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(format), format, "Not a block-compressed format."); + } + } + + private static void EncodeColourBlock(ReadOnlySpan rgba, bool allowTransparentMode, Span block) + { + // BC1's three-colour mode is the only way to express "transparent" in + // DXT1, so a block containing any cut-out texel must use it — quantising + // those texels to an opaque colour would fill in every leaf and grate in + // the world. + bool needsTransparency = false; + if (allowTransparentMode) + { + for (int texel = 0; texel < 16 && !needsTransparency; texel++) + needsTransparency = rgba[(texel * 4) + 3] < 128; + } + + FindColourExtremes(rgba, needsTransparency, out int minR, out int minG, out int minB, out int maxR, out int maxG, out int maxB); + ushort c0 = Pack565(maxR, maxG, maxB); + ushort c1 = Pack565(minR, minG, minB); + + if (needsTransparency) + { + // Three-colour mode requires c0 <= c1. + if (c0 > c1) + (c0, c1) = (c1, c0); + } + else if (c0 <= c1) + { + // Four-colour mode requires c0 > c1. When the block is a single + // colour the endpoints collide; index 0 then reproduces it exactly, + // so nudging c1 down by one preserves the ordering harmlessly. + if (c0 == c1) + { + if (c1 == 0) + c0 = 1; + else + c1 = (ushort)(c1 - 1); + } + else + { + (c0, c1) = (c1, c0); + } + } + + Span palette = stackalloc byte[4 * 4]; + Unpack565(c0, palette[..4]); + Unpack565(c1, palette.Slice(4, 4)); + if (needsTransparency) + { + for (int channel = 0; channel < 3; channel++) + palette[8 + channel] = (byte)((palette[channel] + palette[4 + channel]) / 2); + } + else + { + for (int channel = 0; channel < 3; channel++) + { + palette[8 + channel] = (byte)(((2 * palette[channel]) + palette[4 + channel]) / 3); + palette[12 + channel] = (byte)((palette[channel] + (2 * palette[4 + channel])) / 3); + } + } + + uint indices = 0; + int paletteSize = needsTransparency ? 3 : 4; + for (int texel = 0; texel < 16; texel++) + { + int index; + if (needsTransparency && rgba[(texel * 4) + 3] < 128) + { + index = 3; + } + else + { + index = NearestPaletteEntry(rgba.Slice(texel * 4, 3), palette, paletteSize); + } + + indices |= (uint)index << (texel * 2); + } + + block[0] = (byte)(c0 & 0xFF); + block[1] = (byte)(c0 >> 8); + block[2] = (byte)(c1 & 0xFF); + block[3] = (byte)(c1 >> 8); + block[4] = (byte)(indices & 0xFF); + block[5] = (byte)((indices >> 8) & 0xFF); + block[6] = (byte)((indices >> 16) & 0xFF); + block[7] = (byte)((indices >> 24) & 0xFF); + } + + private static void FindColourExtremes( + ReadOnlySpan rgba, + bool ignoreTransparentTexels, + out int minR, out int minG, out int minB, + out int maxR, out int maxG, out int maxB) + { + minR = minG = minB = 255; + maxR = maxG = maxB = 0; + bool any = false; + for (int texel = 0; texel < 16; texel++) + { + if (ignoreTransparentTexels && rgba[(texel * 4) + 3] < 128) + continue; + any = true; + int r = rgba[texel * 4]; + int g = rgba[(texel * 4) + 1]; + int b = rgba[(texel * 4) + 2]; + minR = Math.Min(minR, r); + minG = Math.Min(minG, g); + minB = Math.Min(minB, b); + maxR = Math.Max(maxR, r); + maxG = Math.Max(maxG, g); + maxB = Math.Max(maxB, b); + } + + if (any) + return; + + // Every texel is cut out; the colour endpoints are unused but must be + // well defined so the encoding is reproducible. + minR = minG = minB = 0; + maxR = maxG = maxB = 0; + } + + private static int NearestPaletteEntry(ReadOnlySpan colour, ReadOnlySpan palette, int paletteSize) + { + int best = 0; + int bestDistance = int.MaxValue; + for (int entry = 0; entry < paletteSize; entry++) + { + int dr = colour[0] - palette[entry * 4]; + int dg = colour[1] - palette[(entry * 4) + 1]; + int db = colour[2] - palette[(entry * 4) + 2]; + int distance = (dr * dr) + (dg * dg) + (db * db); + if (distance >= bestDistance) + continue; + bestDistance = distance; + best = entry; + } + + return best; + } + + private static void EncodeExplicitAlpha(ReadOnlySpan rgba, Span alphaBlock) + { + for (int texel = 0; texel < 16; texel++) + { + int nibble = (rgba[(texel * 4) + 3] * 15 + 127) / 255; + int index = texel / 2; + if (texel % 2 == 0) + alphaBlock[index] = (byte)((alphaBlock[index] & 0xF0) | nibble); + else + alphaBlock[index] = (byte)((alphaBlock[index] & 0x0F) | (nibble << 4)); + } + } + + private static void EncodeInterpolatedAlpha(ReadOnlySpan rgba, Span alphaBlock) + { + byte min = 255; + byte max = 0; + for (int texel = 0; texel < 16; texel++) + { + byte a = rgba[(texel * 4) + 3]; + min = Math.Min(min, a); + max = Math.Max(max, a); + } + + byte a0 = max; + byte a1 = min; + if (a0 == a1) + { + // Eight-alpha mode needs a0 > a1; index 0 reproduces the constant + // exactly either way, so drop the low endpoint when there is room. + if (a1 > 0) + a1 = (byte)(a1 - 1); + else + a0 = 1; + } + + Span palette = stackalloc byte[8]; + palette[0] = a0; + palette[1] = a1; + for (int i = 1; i <= 6; i++) + palette[i + 1] = (byte)((((7 - i) * a0) + (i * a1)) / 7); + + ulong bits = 0; + for (int texel = 0; texel < 16; texel++) + { + byte a = rgba[(texel * 4) + 3]; + int best = 0; + int bestDistance = int.MaxValue; + for (int entry = 0; entry < 8; entry++) + { + int distance = Math.Abs(a - palette[entry]); + if (distance >= bestDistance) + continue; + bestDistance = distance; + best = entry; + } + + bits |= (ulong)best << (texel * 3); + } + + alphaBlock[0] = a0; + alphaBlock[1] = a1; + for (int i = 0; i < 6; i++) + alphaBlock[2 + i] = (byte)((bits >> (i * 8)) & 0xFF); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionMipChain.cs b/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionMipChain.cs new file mode 100644 index 00000000..9c5ac5df --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/BlockCompressionMipChain.cs @@ -0,0 +1,128 @@ +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b, plan §4.3: build a full mip chain on the CPU. +/// +/// DAT surfaces arrive with level 0 only. Uncompressed formats can have +/// their chain generated on the GPU with vkCmdBlitImage; block-compressed +/// ones cannot, because a compressed image is not a legal blit destination. So +/// BC chains are built here, and the result is uploaded level by level exactly +/// as level 0 was. +/// +/// The filter is a plain 2x2 box average, computed in 8-bit space with +/// round-half-up. It is not a Gaussian or a Kaiser, and the reason is the same +/// reason the encoder does not fit a principal axis: this has to produce the +/// same bytes every run, on every machine, so the pixel gate compares renders +/// rather than the output of a floating-point reduction that reassociated +/// differently. Integer arithmetic throughout means it does. +/// +/// Alpha is averaged, not premultiplied. Retail's cut-out foliage +/// uses BC1's one-bit alpha, and the encoder's three-colour mode preserves the +/// cut at every level; premultiplying would darken the fringe of every leaf as +/// the chain descended. +/// +internal static class BlockCompressionMipChain +{ + /// One level of a generated chain: its extent and its encoded bytes. + internal readonly record struct Level(int MipLevel, int Width, int Height, byte[] Data); + + /// + /// Builds levels 1..-1 from a compressed + /// level 0. Level 0 itself is not returned — it was uploaded verbatim and + /// re-encoding it would be a lossy round trip of data that is already right. + /// + internal static IReadOnlyList BuildCompressed( + GpuTextureFormat format, + ReadOnlySpan level0, + int width, + int height, + int mipLevelCount) + { + if (!BlockCompressionCodec.IsBlockCompressed(format)) + throw new ArgumentOutOfRangeException(nameof(format), format, "This chain builder is for BC formats."); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mipLevelCount); + if (mipLevelCount == 1) + return []; + + byte[] rgba = BlockCompressionCodec.DecodeLevel(format, level0, width, height); + return BuildFromRgba(format, rgba, width, height, mipLevelCount); + } + + /// + /// Builds levels 1..N-1 from a tightly packed RGBA8 level 0, encoding each + /// into . Exposed separately because a caller that + /// already has uncompressed source (a composited UI surface, a test) should + /// not pay a decode to get here. + /// + internal static IReadOnlyList BuildFromRgba( + GpuTextureFormat format, + ReadOnlySpan rgba, + int width, + int height, + int mipLevelCount) + { + var levels = new List(Math.Max(0, mipLevelCount - 1)); + byte[] current = rgba.ToArray(); + int currentWidth = width; + int currentHeight = height; + + for (int level = 1; level < mipLevelCount; level++) + { + (byte[] next, int nextWidth, int nextHeight) = Downsample(current, currentWidth, currentHeight); + byte[] encoded = BlockCompressionCodec.IsBlockCompressed(format) + ? BlockCompressionCodec.EncodeLevel(format, next, nextWidth, nextHeight) + : next; + levels.Add(new Level(level, nextWidth, nextHeight, encoded)); + current = next; + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return levels; + } + + /// + /// Halves an RGBA8 image with a 2x2 box average. Odd extents clamp the + /// second sample to the last row/column instead of wrapping or reading out + /// of bounds — a non-power-of-two DAT surface is unusual but not impossible, + /// and the alternative is a buffer overrun rather than a slightly soft mip. + /// + internal static (byte[] Rgba, int Width, int Height) Downsample( + ReadOnlySpan rgba, + int width, + int height) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + int nextWidth = Math.Max(1, width / 2); + int nextHeight = Math.Max(1, height / 2); + var output = new byte[nextWidth * nextHeight * 4]; + + for (int y = 0; y < nextHeight; y++) + { + int y0 = Math.Min((y * 2) + 0, height - 1); + int y1 = Math.Min((y * 2) + 1, height - 1); + for (int x = 0; x < nextWidth; x++) + { + int x0 = Math.Min((x * 2) + 0, width - 1); + int x1 = Math.Min((x * 2) + 1, width - 1); + + int a = ((y0 * width) + x0) * 4; + int b = ((y0 * width) + x1) * 4; + int c = ((y1 * width) + x0) * 4; + int d = ((y1 * width) + x1) * 4; + int destination = ((y * nextWidth) + x) * 4; + + for (int channel = 0; channel < 4; channel++) + { + int sum = rgba[a + channel] + rgba[b + channel] + rgba[c + channel] + rgba[d + channel]; + // Round half up in integers so the result never depends on + // a floating-point rounding mode. + output[destination + channel] = (byte)((sum + 2) / 4); + } + } + } + + return (output, nextWidth, nextHeight); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs index 8770cacf..a0a3d10a 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs @@ -69,9 +69,14 @@ internal static unsafe class VulkanActiveDeviceProbe "descriptor-indexing set layouts", () => { - storageLayout = CreateStorageSetLayout(vk, device); - uniformLayout = CreateUniformSetLayout(vk, device); - tableLayout = CreateTextureTableSetLayout(vk, device); + // Campaign V slice V6b: the production factory, not a copy + // of it. The probe's whole value is proving that the layouts + // the live backend builds can be built on this device, and + // two similar-looking definitions would quietly end that the + // first time one of them changed. + storageLayout = VulkanPipelineLayouts.CreateStorageSetLayout(vk, device); + uniformLayout = VulkanPipelineLayouts.CreateUniformSetLayout(vk, device); + tableLayout = VulkanPipelineLayouts.CreateTextureTableSetLayout(vk, device); }, failures); @@ -79,7 +84,7 @@ internal static unsafe class VulkanActiveDeviceProbe { pushConstantLayout = Attempt( "three-set pipeline layout with the 96-byte push-constant block", - () => pipelineLayout = CreatePipelineLayout( + () => pipelineLayout = VulkanPipelineLayouts.CreatePipelineLayout( vk, device, storageLayout, @@ -145,150 +150,6 @@ internal static unsafe class VulkanActiveDeviceProbe } } - /// Set 0 — the ten storage bindings GpuBindingModel pins. - private static DescriptorSetLayout CreateStorageSetLayout( - Silk.NET.Vulkan.Vk vk, - Device device) - { - int count = (int)GpuBindingModel.StorageBindingCount; - DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count]; - for (int i = 0; i < count; i++) - { - bindings[i] = new DescriptorSetLayoutBinding - { - Binding = (uint)i, - DescriptorType = DescriptorType.StorageBuffer, - DescriptorCount = 1, - StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, - }; - } - - var create = new DescriptorSetLayoutCreateInfo - { - SType = StructureType.DescriptorSetLayoutCreateInfo, - BindingCount = (uint)count, - PBindings = bindings, - }; - VulkanInterop.Check( - vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), - "vkCreateDescriptorSetLayout (set 0, storage)"); - return layout; - } - - /// Set 1 — the SceneLighting and terrain-tiling uniform blocks. - private static DescriptorSetLayout CreateUniformSetLayout( - Silk.NET.Vulkan.Vk vk, - Device device) - { - DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2]; - bindings[0] = new DescriptorSetLayoutBinding - { - Binding = GpuBindingModel.UniformSceneLighting, - DescriptorType = DescriptorType.UniformBuffer, - DescriptorCount = 1, - StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, - }; - bindings[1] = new DescriptorSetLayoutBinding - { - Binding = GpuBindingModel.UniformTerrainTiling, - DescriptorType = DescriptorType.UniformBuffer, - DescriptorCount = 1, - StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, - }; - - var create = new DescriptorSetLayoutCreateInfo - { - SType = StructureType.DescriptorSetLayoutCreateInfo, - BindingCount = 2, - PBindings = bindings, - }; - VulkanInterop.Check( - vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), - "vkCreateDescriptorSetLayout (set 1, uniform)"); - return layout; - } - - /// - /// Set 2 — the production texture table exactly as §4.4 specifies it: one - /// combined-image-sampler binding of , - /// partially bound, update-after-bind, update-unused-while-pending, variable - /// count. This is the layout the whole bindless replacement rests on, so the - /// probe builds the real thing rather than a token one. - /// - private static DescriptorSetLayout CreateTextureTableSetLayout( - Silk.NET.Vulkan.Vk vk, - Device device) - { - var binding = new DescriptorSetLayoutBinding - { - Binding = GpuBindingModel.TextureTableBinding, - DescriptorType = DescriptorType.CombinedImageSampler, - DescriptorCount = GpuBindingModel.TextureTableCapacity, - StageFlags = ShaderStageFlags.FragmentBit, - }; - DescriptorBindingFlags flags = - DescriptorBindingFlags.PartiallyBoundBit - | DescriptorBindingFlags.UpdateAfterBindBit - | DescriptorBindingFlags.UpdateUnusedWhilePendingBit - | DescriptorBindingFlags.VariableDescriptorCountBit; - - var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo - { - SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo, - BindingCount = 1, - PBindingFlags = &flags, - }; - var create = new DescriptorSetLayoutCreateInfo - { - SType = StructureType.DescriptorSetLayoutCreateInfo, - PNext = &bindingFlags, - Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit, - BindingCount = 1, - PBindings = &binding, - }; - VulkanInterop.Check( - vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), - "vkCreateDescriptorSetLayout (set 2, texture table)"); - return layout; - } - - /// - /// One shared pipeline layout: three sets plus the single 96-byte push-constant - /// block. Creating it proves maxBoundDescriptorSets and - /// maxPushConstantsSize for real rather than by reading a limit. - /// - private static PipelineLayout CreatePipelineLayout( - Silk.NET.Vulkan.Vk vk, - Device device, - DescriptorSetLayout storage, - DescriptorSetLayout uniform, - DescriptorSetLayout table) - { - DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3]; - sets[0] = storage; - sets[1] = uniform; - sets[2] = table; - - var pushConstants = new PushConstantRange - { - StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, - Offset = 0, - Size = GpuBindingModel.PushConstantBytes, - }; - var create = new PipelineLayoutCreateInfo - { - SType = StructureType.PipelineLayoutCreateInfo, - SetLayoutCount = 3, - PSetLayouts = sets, - PushConstantRangeCount = 1, - PPushConstantRanges = &pushConstants, - }; - VulkanInterop.Check( - vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout), - "vkCreatePipelineLayout"); - return layout; - } - /// /// Reset a timestamp pool from the host. This is the whole point of /// hostQueryReset: without it the reset costs a command-buffer call diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index ea6842fb..d14ccf6e 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -7,19 +7,69 @@ namespace AcDream.App.Rendering.Gpu.Vk; /// pipelines and passes. /// /// Split into its own file because the three V6 commits divide along -/// exactly this line: V6a lands memory, buffers, rings and the frame timeline — -/// everything in VulkanGpuDevice.cs — while textures, the descriptor -/// table and render targets arrive at V6b and pipelines, passes and readback at -/// V6c. Until each lands, the corresponding contract member throws with the -/// slice named, rather than returning something that would fail later and -/// further away. +/// exactly this line: V6a landed memory, buffers, rings and the frame timeline — +/// everything in VulkanGpuDevice.cs — V6b lands textures, samplers, the +/// descriptor table and render targets here, and pipelines, passes and readback +/// arrive at V6c. Until each lands, the corresponding contract member throws +/// with the slice named, rather than returning something that would fail later +/// and further away. /// internal sealed unsafe partial class VulkanGpuDevice { + private VulkanPipelineLayouts.Created? _layouts; + private VulkanTextureTable? _textureTable; + private VulkanBackbufferAttachments? _backbufferAttachments; + private VulkanGpuTexture? _defaultTexture; + + private readonly Dictionary _samplers = []; + private float _maxSamplerAnisotropy = 1f; + private void InitialiseResources(string? shaderSpirvDirectory, string? pipelineCacheDirectory) { _ = shaderSpirvDirectory; _ = pipelineCacheDirectory; + + _vk.GetPhysicalDeviceProperties(_physicalDevice, out PhysicalDeviceProperties properties); + _maxSamplerAnisotropy = properties.Limits.MaxSamplerAnisotropy; + + _layouts = VulkanPipelineLayouts.Create(_vk, _device); + _textureTable = new VulkanTextureTable( + _vk, + _device, + _layouts.TextureTable, + Math.Min(GpuBindingModel.TextureTableCapacity, Capabilities.MaxTextureTableSlots)); + _backbufferAttachments = new VulkanBackbufferAttachments( + _vk, + _device, + _allocator, + _debugNames, + DepthStencilFormat); + + // The default slot is registered first so it is slot 0 and so the table + // has something defined to scrub evicted slots with. GpuTextureSlot + // documents Unassigned as a loud sentinel precisely so nothing silently + // resolves to slot 0 — this texture exists for the renderers that + // legitimately need a fallback and ask for it by name. + _defaultTexture = new VulkanGpuTexture( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + new GpuTextureDescription( + "vk-default-white", + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + Width: 1, + Height: 1, + LayerCount: 1, + MipLevelCount: 1)); + _defaultTexture.Upload(0, 0, [255, 255, 255, 255]); + + var defaultSampler = (VulkanGpuSampler)CreateSampler(GpuSamplerDescription.UiNearest); + _textureTable.SetScrubTarget(_defaultTexture.View, defaultSampler.Handle); + DefaultTextureSlot = _textureTable.Register(_defaultTexture.View, defaultSampler.Handle); } private void BeginFrameResources(int slotIndex) => _ = slotIndex; @@ -32,27 +82,118 @@ internal sealed unsafe partial class VulkanGpuDevice private void DisposeResources() { + foreach (VulkanGpuSampler sampler in _samplers.Values) + sampler.Dispose(); + _samplers.Clear(); + + _defaultTexture?.Dispose(); + _defaultTexture = null; + + _flights.DrainAll(); + + _backbufferAttachments?.Dispose(); + _backbufferAttachments = null; + _textureTable?.Dispose(); + _textureTable = null; + _layouts?.Destroy(_vk, _device); + _layouts = null; + } + + /// The three shared descriptor set layouts and the one pipeline layout. + internal VulkanPipelineLayouts.Created Layouts => + _layouts ?? throw new InvalidOperationException("The device's pipeline layouts have not been created."); + + /// The global sampled-texture table (plan §4.4). + internal VulkanTextureTable TextureTable => + _textureTable ?? throw new InvalidOperationException("The device's texture table has not been created."); + + /// MSAA colour scratch and transient depth for the backbuffer pass. + internal VulkanBackbufferAttachments BackbufferAttachments => + _backbufferAttachments ?? throw new InvalidOperationException("The backbuffer attachments have not been created."); + + public GpuTextureSlot DefaultTextureSlot { get; private set; } = GpuTextureSlot.Unassigned; + + /// + /// Matches the backbuffer pass's attachments to the swapchain's current + /// extent and the requested sample count. Called by the host after a + /// swapchain create or recreate, behind a device-idle wait. + /// + internal void ConfigureBackbufferAttachments(uint width, uint height, Format colorFormat, int sampleCount) => + BackbufferAttachments.Configure(width, height, colorFormat, sampleCount); + + public IGpuTexture CreateTexture(in GpuTextureDescription description) + { + ThrowIfDisposed(); + return new VulkanGpuTexture( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + description, + sampleCount: 1, + renderTarget: VulkanTextureFormatMapping.IsRenderTarget(description.Format)); + } + + public IGpuSampler CreateSampler(in GpuSamplerDescription description) + { + ThrowIfDisposed(); + if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing)) + return existing; + + var created = new VulkanGpuSampler( + _vk, + _device, + _flights, + _debugNames, + description, + _maxSamplerAnisotropy); + _samplers.Add(description, created); + return created; + } + + public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) + { + ThrowIfDisposed(); + return new VulkanGpuRenderTarget( + _vk, + _device, + _allocator, + _uploads, + _flights, + _debugNames, + description); + } + + public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(texture); + ArgumentNullException.ThrowIfNull(sampler); + if (texture is not VulkanGpuTexture vulkanTexture) + throw new ArgumentException("The Vulkan backend can only register a Vulkan texture.", nameof(texture)); + if (sampler is not VulkanGpuSampler vulkanSampler) + throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler)); + + return TextureTable.Register(vulkanTexture.View, vulkanSampler.Handle); + } + + public void ReleaseTextureSlot(GpuTextureSlot slot) + { + ThrowIfDisposed(); + if (!slot.IsAssigned) + throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot)); + + // Deferred, and scrubbed to the default texture on the way out: a + // submitted-but-unretired frame may still sample this slot, so reusing + // it now would alias a live draw onto whatever texture claims it next. + VulkanTextureTable table = TextureTable; + _flights.Retire(() => table.ReleaseNow(slot)); } public IGpuTimerPool Timers => throw NotYet("GPU timer scopes", "V6c"); - public GpuTextureSlot DefaultTextureSlot => throw NotYet("the default 1x1 white table slot", "V6b"); - - public IGpuTexture CreateTexture(in GpuTextureDescription description) => - throw NotYet($"texture creation ('{description.Name}')", "V6b"); - - public IGpuSampler CreateSampler(in GpuSamplerDescription description) => - throw NotYet("sampler creation", "V6b"); - - public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) => - throw NotYet($"render targets ('{description.Name}')", "V6b"); - - public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) => - throw NotYet("the global texture table", "V6b"); - - public void ReleaseTextureSlot(GpuTextureSlot slot) => - throw NotYet("the global texture table", "V6b"); - public IGpuPipeline CreatePipeline(GpuPipelineDescription description) => throw NotYet($"pipelines ('{description?.Name}')", "V6c"); diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs new file mode 100644 index 00000000..68842377 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs @@ -0,0 +1,292 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b: on Vulkan — the +/// offscreen colour(+depth) bundle behind the paperdoll, the creature-appraisal +/// viewport and the portal mask. +/// +/// Offscreen targets stay single-sampled, matching the contract. Their +/// colour image carries SAMPLED as well as COLOR_ATTACHMENT usage +/// so it can be registered into the texture table and drawn by the retained UI +/// the moment its pass ends — which is the whole reason these exist rather than +/// rendering those views onto the backbuffer. +/// +internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget +{ + private readonly VulkanGpuTexture _color; + private readonly VulkanGpuTexture? _depth; + private bool _disposed; + + internal VulkanGpuRenderTarget( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanDeviceMemoryAllocator allocator, + VulkanUploadQueue uploads, + IGpuResourceRetirementQueue retirement, + VulkanDebugNames debugNames, + in GpuRenderTargetDescription description) + { + ArgumentException.ThrowIfNullOrWhiteSpace(description.Name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height); + Description = description; + + _color = new VulkanGpuTexture( + vk, + device, + allocator, + uploads, + retirement, + debugNames, + new GpuTextureDescription( + $"{description.Name}-color", + GpuTextureKind.Texture2D, + description.ColorFormat, + description.Width, + description.Height, + LayerCount: 1, + MipLevelCount: 1), + Math.Max(1, description.SampleCount), + renderTarget: true); + + if (description.DepthFormat is { } depthFormat) + { + _depth = new VulkanGpuTexture( + vk, + device, + allocator, + uploads, + retirement, + debugNames, + new GpuTextureDescription( + $"{description.Name}-depth", + GpuTextureKind.Texture2D, + depthFormat, + description.Width, + description.Height, + LayerCount: 1, + MipLevelCount: 1), + Math.Max(1, description.SampleCount), + renderTarget: true); + } + } + + public GpuRenderTargetDescription Description { get; } + + public IGpuTexture ColorTexture => _color; + + internal VulkanGpuTexture Color => _color; + + internal VulkanGpuTexture? Depth => _depth; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _depth?.Dispose(); + _color.Dispose(); + } +} + +/// +/// Campaign V slice V6b: the attachments the backbuffer pass needs but the +/// swapchain does not own — the multisampled colour scratch and the transient +/// depth/stencil buffer. +/// +/// Plan §4.8's main pass declares MSAA colour as CLEAR/DONT_CARE +/// resolving into the swapchain image, and depth as CLEAR/DONT_CARE. +/// Nothing reads either after the frame, so both are created with +/// TRANSIENT_ATTACHMENT usage and neither is ever written back to memory +/// on a tiler. +/// +/// Stencil is not optional: issue #117's portal punch needs the stencil +/// aspect, which is why the V5 gate prefers D32_SFLOAT_S8_UINT and falls +/// back to D24_UNORM_S8_UINT rather than taking a depth-only format. +/// +/// +/// They are recreated with the swapchain, since both must match its extent +/// exactly, and the recreation runs behind a vkDeviceWaitIdle in the host +/// — a resize is not a hot path and the alternative is tracking two more +/// generations of retirement for no measurable gain. +/// +internal sealed unsafe class VulkanBackbufferAttachments : IDisposable +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly VulkanDeviceMemoryAllocator _allocator; + private readonly VulkanDebugNames _debugNames; + + private Image _colorImage; + private ImageView _colorView; + private VulkanAllocation _colorAllocation; + private Image _depthImage; + private ImageView _depthView; + private VulkanAllocation _depthAllocation; + private bool _disposed; + + internal VulkanBackbufferAttachments( + Silk.NET.Vulkan.Vk vk, + Device device, + VulkanDeviceMemoryAllocator allocator, + VulkanDebugNames debugNames, + Format depthStencilFormat) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _allocator = allocator ?? throw new ArgumentNullException(nameof(allocator)); + _debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames)); + DepthStencilFormat = depthStencilFormat; + } + + internal Format DepthStencilFormat { get; } + + internal uint Width { get; private set; } + + internal uint Height { get; private set; } + + internal int SampleCount { get; private set; } = 1; + + internal Format ColorFormat { get; private set; } + + /// Multisampled colour view, or a zero handle when the pass renders straight to the swapchain. + internal ImageView ColorView => _colorView; + + internal ImageView DepthView => _depthView; + + internal Image ColorImage => _colorImage; + + internal Image DepthImage => _depthImage; + + internal bool HasMultisampledColor => _colorView.Handle != 0; + + internal bool HasDepth => _depthView.Handle != 0; + + /// + /// Rebuilds both attachments for a new extent, format or sample count. + /// A no-op when nothing changed, so the host may call it every frame. + /// + internal void Configure(uint width, uint height, Format colorFormat, int sampleCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (width == 0 || height == 0) + return; + if (width == Width && height == Height && colorFormat == ColorFormat && sampleCount == SampleCount) + return; + + DestroyImages(); + Width = width; + Height = height; + ColorFormat = colorFormat; + SampleCount = Math.Max(1, sampleCount); + + if (SampleCount > 1) + { + (_colorImage, _colorAllocation, _colorView) = CreateAttachment( + colorFormat, + ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransientAttachmentBit, + ImageAspectFlags.ColorBit, + "vk-backbuffer-msaa-color"); + } + + if (DepthStencilFormat != Format.Undefined) + { + (_depthImage, _depthAllocation, _depthView) = CreateAttachment( + DepthStencilFormat, + ImageUsageFlags.DepthStencilAttachmentBit | ImageUsageFlags.TransientAttachmentBit, + ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit, + "vk-backbuffer-depth"); + } + } + + private (Image, VulkanAllocation, ImageView) CreateAttachment( + Format format, + ImageUsageFlags usage, + ImageAspectFlags aspect, + string name) + { + var create = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Format = format, + Extent = new Extent3D(Width, Height, 1), + MipLevels = 1, + ArrayLayers = 1, + 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 ({name})"); + + _vk.GetImageMemoryRequirements(_device, image, out MemoryRequirements requirements); + VulkanAllocation allocation = _allocator.Allocate( + requirements, + GpuMemoryResidency.DeviceLocal, + name); + VulkanInterop.Check( + _vk.BindImageMemory(_device, image, allocation.Memory, allocation.OffsetBytes), + $"vkBindImageMemory ({name})"); + + var viewCreate = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange + { + AspectMask = aspect, + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }, + }; + VulkanInterop.Check( + _vk.CreateImageView(_device, &viewCreate, null, out ImageView view), + $"vkCreateImageView ({name})"); + + _debugNames.NameImage(image, name); + _debugNames.NameImageView(view, $"{name}-view"); + return (image, allocation, view); + } + + private void DestroyImages() + { + if (_colorView.Handle != 0) + _vk.DestroyImageView(_device, _colorView, null); + if (_colorImage.Handle != 0) + { + _vk.DestroyImage(_device, _colorImage, null); + _allocator.Free(_colorAllocation); + } + + if (_depthView.Handle != 0) + _vk.DestroyImageView(_device, _depthView, null); + if (_depthImage.Handle != 0) + { + _vk.DestroyImage(_device, _depthImage, null); + _allocator.Free(_depthAllocation); + } + + _colorView = default; + _colorImage = default; + _colorAllocation = default; + _depthView = default; + _depthImage = default; + _depthAllocation = default; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + DestroyImages(); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs new file mode 100644 index 00000000..8b88c48d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs @@ -0,0 +1,297 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b: on Vulkan. +/// +/// Images are device-local and filled through the staging path. 2D arrays +/// are allocated at full size and filled layer by layer, mirroring +/// ManagedGLTextureArray — which is why the upload queue tracks the +/// layout an image is in on entry rather than always discarding its contents. +/// +/// +/// 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 vkCmdBlitImage 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 ( builds +/// it). That is a deliberate improvement rather than a limitation — the GL path +/// calls glGenerateMipmap on compressed arrays, whose result is +/// implementation-defined. +/// +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; } + + /// + /// 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. + /// + internal ImageLayout CurrentLayout { get; private set; } = ImageLayout.Undefined; + + internal void MarkLayout(ImageLayout layout) => CurrentLayout = layout; + + public void Upload(int mipLevel, int layer, ReadOnlySpan 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); +} + +/// +/// Campaign V slice V6b: 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. +/// +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)); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs new file mode 100644 index 00000000..a29eb0cd --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs @@ -0,0 +1,220 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V, plan §3.4 and §4.4: the three descriptor set layouts and the ONE +/// pipeline layout every acdream pipeline shares. +/// +/// Extracted from slice V5's active capability probe at V6b so the probe +/// and the live backend build the same objects from the same code. The probe's +/// whole value is that it proves the production layouts can be created on this +/// device; a second, similar-looking definition would quietly destroy that +/// property the first time one of them changed. +/// +/// One pipeline layout is a decision, not an economy. Because every +/// pipeline shares it, switching pipelines mid-pass does not invalidate bound +/// descriptor sets or push constants — which is what lets the world dispatcher +/// bind the texture table once per frame and then change pipeline per bucket. +/// The single 96-byte push-constant block exists for the same reason. +/// +internal static unsafe class VulkanPipelineLayouts +{ + /// The three sets plus the shared layout, owned together and destroyed together. + internal sealed class Created( + DescriptorSetLayout storage, + DescriptorSetLayout uniform, + DescriptorSetLayout textureTable, + PipelineLayout pipelineLayout) : IDisposable + { + private bool _disposed; + + internal DescriptorSetLayout Storage { get; } = storage; + internal DescriptorSetLayout Uniform { get; } = uniform; + internal DescriptorSetLayout TextureTable { get; } = textureTable; + internal PipelineLayout PipelineLayout { get; } = pipelineLayout; + + internal void Destroy(Silk.NET.Vulkan.Vk vk, Device device) + { + if (_disposed) + return; + _disposed = true; + if (PipelineLayout.Handle != 0) + vk.DestroyPipelineLayout(device, PipelineLayout, null); + if (TextureTable.Handle != 0) + vk.DestroyDescriptorSetLayout(device, TextureTable, null); + if (Uniform.Handle != 0) + vk.DestroyDescriptorSetLayout(device, Uniform, null); + if (Storage.Handle != 0) + vk.DestroyDescriptorSetLayout(device, Storage, null); + } + + /// Destruction needs the device, so is the real disposer. + public void Dispose() => _disposed = true; + } + + /// Creates all four objects, cleaning up whatever succeeded if a later one fails. + internal static Created Create(Silk.NET.Vulkan.Vk vk, Device device) + { + ArgumentNullException.ThrowIfNull(vk); + + DescriptorSetLayout storage = default; + DescriptorSetLayout uniform = default; + DescriptorSetLayout table = default; + try + { + storage = CreateStorageSetLayout(vk, device); + uniform = CreateUniformSetLayout(vk, device); + table = CreateTextureTableSetLayout(vk, device); + PipelineLayout layout = CreatePipelineLayout(vk, device, storage, uniform, table); + return new Created(storage, uniform, table, layout); + } + catch + { + if (table.Handle != 0) + vk.DestroyDescriptorSetLayout(device, table, null); + if (uniform.Handle != 0) + vk.DestroyDescriptorSetLayout(device, uniform, null); + if (storage.Handle != 0) + vk.DestroyDescriptorSetLayout(device, storage, null); + throw; + } + } + + /// Set 0 — the ten storage bindings pins. + internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device) + { + int count = (int)GpuBindingModel.StorageBindingCount; + DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count]; + for (int i = 0; i < count; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = (uint)i, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + } + + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = (uint)count, + PBindings = bindings, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 0, storage)"); + return layout; + } + + /// Set 1 — the SceneLighting and terrain-tiling uniform blocks. + internal static DescriptorSetLayout CreateUniformSetLayout(Silk.NET.Vulkan.Vk vk, Device device) + { + DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2]; + bindings[0] = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.UniformSceneLighting, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + bindings[1] = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.UniformTerrainTiling, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 2, + PBindings = bindings, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 1, uniform)"); + return layout; + } + + /// + /// Set 2 — the production texture table exactly as §4.4 specifies it: one + /// combined-image-sampler binding of + /// , partially bound, + /// update-after-bind, update-unused-while-pending, variable count. + /// + internal static DescriptorSetLayout CreateTextureTableSetLayout(Silk.NET.Vulkan.Vk vk, Device device) + { + var binding = new DescriptorSetLayoutBinding + { + Binding = GpuBindingModel.TextureTableBinding, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = GpuBindingModel.TextureTableCapacity, + StageFlags = ShaderStageFlags.FragmentBit, + }; + DescriptorBindingFlags flags = + DescriptorBindingFlags.PartiallyBoundBit + | DescriptorBindingFlags.UpdateAfterBindBit + | DescriptorBindingFlags.UpdateUnusedWhilePendingBit + | DescriptorBindingFlags.VariableDescriptorCountBit; + + var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo + { + SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo, + BindingCount = 1, + PBindingFlags = &flags, + }; + var create = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + PNext = &bindingFlags, + Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit, + BindingCount = 1, + PBindings = &binding, + }; + VulkanInterop.Check( + vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout), + "vkCreateDescriptorSetLayout (set 2, texture table)"); + return layout; + } + + /// + /// One shared pipeline layout: three sets plus the single 96-byte + /// push-constant block. Creating it proves maxBoundDescriptorSets and + /// maxPushConstantsSize for real rather than by reading a limit. + /// + internal static PipelineLayout CreatePipelineLayout( + Silk.NET.Vulkan.Vk vk, + Device device, + DescriptorSetLayout storage, + DescriptorSetLayout uniform, + DescriptorSetLayout table) + { + DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3]; + sets[0] = storage; + sets[1] = uniform; + sets[2] = table; + + var pushConstants = new PushConstantRange + { + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + Offset = 0, + Size = GpuBindingModel.PushConstantBytes, + }; + var create = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 3, + PSetLayouts = sets, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstants, + }; + VulkanInterop.Check( + vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout), + "vkCreatePipelineLayout"); + return layout; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs new file mode 100644 index 00000000..901829ee --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs @@ -0,0 +1,123 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b, plan §4.3: to +/// , and the byte arithmetic each format implies. +/// +/// Every format here is UNORM, and that is a finding rather than a +/// default. The V3 audit (plan §4.10) established that acdream has no sRGB +/// anywhere: not on upload, not in a shader, not at the framebuffer. The plan +/// previously specified an sRGB swapchain "matching the GL FramebufferSrgb +/// contract" — a contract that does not exist. Shipping an sRGB format would +/// have applied an unwanted encode to already-display-space values, brightening +/// every frame, and it would have passed silently until the V7 differential. +/// +/// +internal static class VulkanTextureFormatMapping +{ + /// The Vulkan format acdream uploads this surface as. BC formats transcode nothing. + internal static Format FormatOf(GpuTextureFormat format) => format switch + { + GpuTextureFormat.Rgba8Unorm => Format.R8G8B8A8Unorm, + GpuTextureFormat.R8Unorm => Format.R8Unorm, + GpuTextureFormat.Bc1Unorm => Format.BC1RgbaUnormBlock, + GpuTextureFormat.Bc2Unorm => Format.BC2UnormBlock, + GpuTextureFormat.Bc3Unorm => Format.BC3UnormBlock, + GpuTextureFormat.Rgba8UnormRenderTarget => Format.R8G8B8A8Unorm, + GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."), + }; + + internal static bool IsDepthStencil(GpuTextureFormat format) => + format == GpuTextureFormat.Depth24Stencil8; + + internal static bool IsRenderTarget(GpuTextureFormat format) => + format is GpuTextureFormat.Rgba8UnormRenderTarget or GpuTextureFormat.Depth24Stencil8; + + /// Bytes one texel occupies. Only meaningful for uncompressed formats. + internal static int BytesPerTexel(GpuTextureFormat format) => format switch + { + GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget => 4, + GpuTextureFormat.R8Unorm => 1, + GpuTextureFormat.Depth24Stencil8 => 4, + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "A block-compressed format has no texel size."), + }; + + /// Bytes one mip level of one array layer occupies. + internal static int LevelSizeBytes(GpuTextureFormat format, int width, int height) => + BlockCompressionCodec.IsBlockCompressed(format) + ? BlockCompressionCodec.LevelSizeBytes(format, width, height) + : width * height * BytesPerTexel(format); + + /// Dimensions of mip level , floored at 1 texel. + internal static (int Width, int Height) LevelExtent(int width, int height, int level) + { + for (int i = 0; i < level; i++) + { + width = Math.Max(1, width / 2); + height = Math.Max(1, height / 2); + } + + return (width, height); + } + + /// Full mip chain length for an image of this size — the count that reaches 1x1. + internal static int FullMipLevelCount(int width, int height) + { + int levels = 1; + while (width > 1 || height > 1) + { + width = Math.Max(1, width / 2); + height = Math.Max(1, height / 2); + levels++; + } + + return levels; + } + + internal static ImageAspectFlags AspectOf(GpuTextureFormat format) => + IsDepthStencil(format) + ? ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit + : ImageAspectFlags.ColorBit; + + /// Sample-count flag for a plain sample count. Only 1/2/4/8 are used. + internal static SampleCountFlags SampleCountOf(int sampleCount) => sampleCount switch + { + <= 1 => SampleCountFlags.Count1Bit, + 2 => SampleCountFlags.Count2Bit, + <= 4 => SampleCountFlags.Count4Bit, + _ => SampleCountFlags.Count8Bit, + }; + + internal static ImageViewType ViewTypeOf(GpuTextureKind kind) => kind switch + { + GpuTextureKind.Texture2D => ImageViewType.Type2D, + GpuTextureKind.Texture2DArray => ImageViewType.Type2DArray, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown texture kind."), + }; + + internal static Filter FilterOf(GpuFilter filter) => filter switch + { + GpuFilter.Nearest => Filter.Nearest, + GpuFilter.Linear => Filter.Linear, + _ => throw new ArgumentOutOfRangeException(nameof(filter), filter, "Unknown filter."), + }; + + internal static SamplerMipmapMode MipmapModeOf(GpuMipFilter filter) => filter switch + { + // No mip filtering still needs a mode; NEAREST with a zero LOD range is + // the standard way to express "level 0 only". + GpuMipFilter.None or GpuMipFilter.Nearest => SamplerMipmapMode.Nearest, + GpuMipFilter.Linear => SamplerMipmapMode.Linear, + _ => throw new ArgumentOutOfRangeException(nameof(filter), filter, "Unknown mip filter."), + }; + + internal static SamplerAddressMode AddressModeOf(GpuAddressMode mode) => mode switch + { + GpuAddressMode.Repeat => SamplerAddressMode.Repeat, + GpuAddressMode.ClampToEdge => SamplerAddressMode.ClampToEdge, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown address mode."), + }; +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs new file mode 100644 index 00000000..c41d12e8 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs @@ -0,0 +1,242 @@ +using Silk.NET.Vulkan; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b: the slot allocator behind the global texture table. +/// +/// Pure, because the property that matters is not "does +/// vkUpdateDescriptorSets work" but "can a slot ever be reused while a +/// submitted frame still reads it". Releases are queued and only returned to the +/// free list when the caller says the frames that could reference them have +/// retired, and this class is where that is expressed and tested. +/// +/// Slots are handed out lowest-first so a session's live set stays dense +/// near zero. That is not cosmetic: it keeps a capture's descriptor dump +/// readable and it means an off-by-one that reads slot N+1 lands on a real +/// texture rather than on unwritten memory, which turns a silent +/// wrong-texture bug into a visible one. +/// +internal sealed class VulkanTextureSlotAllocator +{ + private readonly uint _capacity; + private readonly PriorityQueue _free = new(); + private readonly HashSet _live = []; + private uint _highWater; + + internal VulkanTextureSlotAllocator(uint capacity) + { + ArgumentOutOfRangeException.ThrowIfZero(capacity); + _capacity = capacity; + } + + internal uint Capacity => _capacity; + + internal int LiveCount => _live.Count; + + internal int FreeCount => _free.Count; + + /// Highest slot index ever handed out, plus one. The table only ever writes below this. + internal uint HighWater => _highWater; + + internal uint Allocate() + { + uint slot; + if (_free.Count > 0) + { + slot = _free.Dequeue(); + } + else + { + if (_highWater >= _capacity) + { + throw new InvalidOperationException( + $"The Vulkan texture table is full at {_capacity} slots. Raise " + + "GpuBindingModel.TextureTableCapacity and the capability gate's limit together."); + } + + slot = _highWater++; + } + + _live.Add(slot); + return slot; + } + + /// Returns a slot to the free list. Only call once the owning frames have retired. + internal void Release(uint slot) + { + if (!_live.Remove(slot)) + { + throw new InvalidOperationException( + $"Texture table slot {slot} is not live; releasing it twice would let two textures " + + "share one index."); + } + + _free.Enqueue(slot, slot); + } + + internal bool IsLive(uint slot) => _live.Contains(slot); +} + +/// +/// Campaign V slice V6b, plan §4.4: the global texture table — one +/// update-after-bind descriptor array that replaces +/// GL_ARB_bindless_texture entirely. +/// +/// Zero descriptor writes per frame. Registration appends exactly +/// one vkUpdateDescriptorSets; nothing is written at draw time, and the +/// set is bound once. That is what removes the whole +/// MakeTextureHandleResident churn the GL path pays, and it is why the +/// capability gate asserts maxDescriptorSetUpdateAfterBindSampledImages +/// rather than discovering it at draw time. +/// +/// A slot is a (view, sampler) pair, exactly like a bindless +/// handle, so a texture registered with two samplers takes two slots and the CPU +/// data model needs no change. +/// +/// Eviction is retirement-gated, and the slot is scrubbed before +/// reuse. Returning a slot to the free list 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 retirement queue, and when it runs the slot is first +/// overwritten with the default texture. Leaving a stale view descriptor 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 failure mode impossible rather than unlikely. +/// +internal sealed unsafe class VulkanTextureTable : IDisposable +{ + private readonly Silk.NET.Vulkan.Vk _vk; + private readonly Device _device; + private readonly VulkanTextureSlotAllocator _slots; + private readonly DescriptorPool _pool; + private readonly DescriptorSet _set; + + private ImageView _defaultView; + private Sampler _defaultSampler; + private bool _disposed; + + internal VulkanTextureTable( + Silk.NET.Vulkan.Vk vk, + Device device, + DescriptorSetLayout layout, + uint capacity) + { + _vk = vk ?? throw new ArgumentNullException(nameof(vk)); + _device = device; + _slots = new VulkanTextureSlotAllocator(capacity); + Capacity = capacity; + + var poolSize = new DescriptorPoolSize + { + Type = DescriptorType.CombinedImageSampler, + DescriptorCount = capacity, + }; + var poolCreate = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + // The pool must be update-after-bind too, not only the layout; + // omitting this is a validation error that only fires on the first + // registration. + Flags = DescriptorPoolCreateFlags.UpdateAfterBindBit, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + VulkanInterop.Check( + _vk.CreateDescriptorPool(_device, &poolCreate, null, out _pool), + "vkCreateDescriptorPool (texture table)"); + + uint variableCount = capacity; + DescriptorSetLayout setLayout = layout; + var variable = new DescriptorSetVariableDescriptorCountAllocateInfo + { + SType = StructureType.DescriptorSetVariableDescriptorCountAllocateInfo, + DescriptorSetCount = 1, + PDescriptorCounts = &variableCount, + }; + var allocate = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + PNext = &variable, + DescriptorPool = _pool, + DescriptorSetCount = 1, + PSetLayouts = &setLayout, + }; + VulkanInterop.Check( + _vk.AllocateDescriptorSets(_device, &allocate, out _set), + "vkAllocateDescriptorSets (texture table)"); + } + + internal uint Capacity { get; } + + internal DescriptorSet Set => _set; + + internal int LiveSlotCount => _slots.LiveCount; + + internal uint HighWater => _slots.HighWater; + + /// + /// Records the (view, sampler) pair written into a slot when it is scrubbed. + /// Supplied after the default texture exists, which is necessarily after the + /// table itself. + /// + internal void SetScrubTarget(ImageView view, Sampler sampler) + { + _defaultView = view; + _defaultSampler = sampler; + } + + internal GpuTextureSlot Register(ImageView view, Sampler sampler) + { + ObjectDisposedException.ThrowIf(_disposed, this); + uint slot = _slots.Allocate(); + Write(slot, view, sampler); + return new GpuTextureSlot(slot); + } + + /// + /// Scrubs the slot to the default texture and returns it to the free list. + /// The caller is responsible for having deferred this until the frames that + /// could reference it have retired. + /// + internal void ReleaseNow(GpuTextureSlot slot) + { + if (_disposed || !slot.IsAssigned) + return; + if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0) + Write(slot.Index, _defaultView, _defaultSampler); + _slots.Release(slot.Index); + } + + internal bool IsLive(GpuTextureSlot slot) => slot.IsAssigned && _slots.IsLive(slot.Index); + + private void Write(uint slot, ImageView view, Sampler sampler) + { + var info = new DescriptorImageInfo + { + ImageView = view, + Sampler = sampler, + ImageLayout = ImageLayout.ShaderReadOnlyOptimal, + }; + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = _set, + DstBinding = GpuBindingModel.TextureTableBinding, + DstArrayElement = slot, + DescriptorCount = 1, + DescriptorType = DescriptorType.CombinedImageSampler, + PImageInfo = &info, + }; + _vk.UpdateDescriptorSets(_device, 1, &write, 0, null); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_pool.Handle != 0) + _vk.DestroyDescriptorPool(_device, _pool, null); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs index 6c384391..93dbe6c9 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanUploadQueue.cs @@ -59,9 +59,7 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable uint MipLevel, uint Layer, uint Width, - uint Height, - uint MipLevelCount, - uint LayerCount); + uint Height); private readonly record struct TemporaryStaging(Buffer Buffer, VulkanAllocation Allocation); @@ -86,8 +84,28 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable "vk-staging-ring"); } - /// Images whose layout must be moved to TRANSFER_DST before the drain and to SHADER_READ after it. - private readonly HashSet _imagesNeedingBarrier = []; + /// + /// Images touched by this batch, and the layout each is in on entry. + /// + /// The entry layout is not always UNDEFINED, and that + /// distinction is load-bearing. UNDEFINED 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 ManagedGLTextureArray — 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. + /// + private readonly Dictionary _imageEntryLayouts = []; + + /// Mip chains to generate with vkCmdBlitImage after this batch's copies land. + private readonly List _mipBlits = []; + + private readonly record struct MipBlitRequest( + Image Image, + int Width, + int Height, + int MipLevelCount, + int LayerCount); internal int PendingBufferCopyCount => _bufferCopies.Count; @@ -122,8 +140,7 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable int layer, int width, int height, - int mipLevelCount, - int layerCount, + ImageLayout entryLayout, ReadOnlySpan data, string ownerName) { @@ -142,10 +159,36 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable (uint)mipLevel, (uint)layer, (uint)width, - (uint)height, - (uint)mipLevelCount, - (uint)layerCount)); - _imagesNeedingBarrier.Add(destination); + (uint)height)); + RecordEntryLayout(destination, entryLayout); + } + + /// + /// Queues a vkCmdBlitImage 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). + /// + 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); } /// Queues a device-side buffer copy — the mesh arena's grow-and-copy migration. @@ -174,11 +217,11 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable /// internal bool Record(CommandBuffer commands) { - if (_bufferCopies.Count == 0 && _imageCopies.Count == 0) + if (_bufferCopies.Count == 0 && _imageCopies.Count == 0 && _mipBlits.Count == 0) return false; - if (_imagesNeedingBarrier.Count > 0) - TransitionImages(commands, ImageLayout.Undefined, ImageLayout.TransferDstOptimal, toTransfer: true); + if (_imageEntryLayouts.Count > 0) + TransitionImagesToTransfer(commands); foreach (BufferCopy2 copy in _bufferCopies) { @@ -217,8 +260,11 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable ®ion); } - if (_imagesNeedingBarrier.Count > 0) - TransitionImages(commands, ImageLayout.TransferDstOptimal, ImageLayout.ShaderReadOnlyOptimal, toTransfer: false); + 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. @@ -250,64 +296,204 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable _bufferCopies.Clear(); _imageCopies.Clear(); - _imagesNeedingBarrier.Clear(); + _mipBlits.Clear(); + _imageEntryLayouts.Clear(); return true; } - private void TransitionImages( - CommandBuffer commands, - ImageLayout oldLayout, - ImageLayout newLayout, - bool toTransfer) + private static ImageSubresourceRange WholeColorImage => new() { - int count = _imagesNeedingBarrier.Count; - var barriers = new ImageMemoryBarrier2[count]; + 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 in _imagesNeedingBarrier) + foreach ((Image image, ImageLayout entryLayout) in _imageEntryLayouts) { barriers[index++] = new ImageMemoryBarrier2 { SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = toTransfer - ? PipelineStageFlags2.AllCommandsBit - : PipelineStageFlags2.AllTransferBit, - SrcAccessMask = toTransfer ? AccessFlags2.None : AccessFlags2.TransferWriteBit, - DstStageMask = toTransfer - ? PipelineStageFlags2.AllTransferBit - : PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.VertexShaderBit, - DstAccessMask = toTransfer ? AccessFlags2.TransferWriteBit : AccessFlags2.ShaderReadBit, - // Undefined discards existing contents, which is right for the - // first upload into a fresh image and wrong for an incremental - // one; incremental array-layer fills therefore name the layout - // they are already in. - OldLayout = oldLayout, - NewLayout = newLayout, + 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 = new ImageSubresourceRange - { - AspectMask = ImageAspectFlags.ColorBit, - BaseMipLevel = 0, - LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels, - BaseArrayLayer = 0, - LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers, - }, + 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)count, + ImageMemoryBarrierCount = (uint)barriers.Length, PImageMemoryBarriers = first, }; _vk.CmdPipelineBarrier2(commands, &dependency); } } + /// + /// Halves level N into level N+1 with a linear blit, all layers at once. + /// + /// 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. + /// + 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); + } + /// Reclaims staging bytes and temporary buffers belonging to completed frames. internal void ReleaseCompleted(long completedSerial) => _ringState.Release(completedSerial); @@ -387,6 +573,6 @@ internal sealed unsafe class VulkanUploadQueue : IDisposable _ringState.Reset(); _bufferCopies.Clear(); _imageCopies.Clear(); - _imagesNeedingBarrier.Clear(); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/BlockCompressionTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/BlockCompressionTests.cs new file mode 100644 index 00000000..04f5545c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/BlockCompressionTests.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6b — the CPU block-compression codec and mip chain +/// (plan §4.3). +/// +/// This code exists because Vulkan cannot vkCmdBlitImage into a +/// compressed image, so the mip chain for every DXT surface in the game has to +/// be built on the CPU. Two properties matter more than image quality: +/// +/// 1. It is DETERMINISTIC. The offline pixel gate compares captures from +/// separate processes, so a chain that varied run to run would make every +/// textured surface look like a regression. Integer arithmetic throughout is +/// what guarantees it, and these tests are what prove it. +/// 2. It preserves BC1's one-bit cut-out. Retail's foliage and grates are DXT1 +/// with the three-colour transparent mode; an encoder that quantised those +/// texels to an opaque colour would fill in every leaf. +/// +public sealed class BlockCompressionTests +{ + private static byte[] SolidRgba(int width, int height, byte r, byte g, byte b, byte a = 255) + { + var pixels = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + pixels[(i * 4) + 0] = r; + pixels[(i * 4) + 1] = g; + pixels[(i * 4) + 2] = b; + pixels[(i * 4) + 3] = a; + } + + return pixels; + } + + // ── sizes ──────────────────────────────────────────────────────────────── + + // The RHI contract's enums are internal, so these stay [Fact]s over an + // inline table rather than [Theory]s — xUnit needs public test methods and a + // public method cannot take an internal parameter. + [Fact] + public void BlockSizesMatchTheDxtFormats() + { + Assert.Equal(8, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc1Unorm)); + Assert.Equal(16, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc2Unorm)); + Assert.Equal(16, BlockCompressionCodec.BlockSizeBytes(GpuTextureFormat.Bc3Unorm)); + } + + [Fact] + public void LevelSizeRoundsUpToWholeBlocks() + { + // A 5x5 BC1 level still needs 2x2 blocks: the edge block is partly + // outside the image and is still stored in full. + Assert.Equal(4 * 8, BlockCompressionCodec.LevelSizeBytes(GpuTextureFormat.Bc1Unorm, 5, 5)); + // And a level smaller than one block is still one block. + Assert.Equal(8, BlockCompressionCodec.LevelSizeBytes(GpuTextureFormat.Bc1Unorm, 1, 1)); + } + + [Fact] + public void FullMipChainReachesOneByOne() + { + Assert.Equal(9, VulkanTextureFormatMapping.FullMipLevelCount(256, 256)); + Assert.Equal(1, VulkanTextureFormatMapping.FullMipLevelCount(1, 1)); + // Non-square: the chain runs until BOTH axes are 1. + Assert.Equal(9, VulkanTextureFormatMapping.FullMipLevelCount(256, 4)); + } + + [Fact] + public void LevelExtentFloorsAtOneTexel() + { + Assert.Equal((64, 16), VulkanTextureFormatMapping.LevelExtent(256, 64, 2)); + Assert.Equal((1, 1), VulkanTextureFormatMapping.LevelExtent(4, 4, 9)); + } + + // ── round trips ────────────────────────────────────────────────────────── + + [Fact] + public void SolidColourSurvivesTheRoundTripExactlyInEveryFormat() + { + GpuTextureFormat[] formats = + [ + GpuTextureFormat.Bc1Unorm, + GpuTextureFormat.Bc2Unorm, + GpuTextureFormat.Bc3Unorm, + ]; + + // 8-bit values that are exactly representable in RGB565 (multiples of + // the quantisation step), so "exact" is a fair thing to demand. + byte[] source = SolidRgba(8, 8, r: 0x00, g: 0x00, b: 0xFF); + + foreach (GpuTextureFormat format in formats) + { + byte[] encoded = BlockCompressionCodec.EncodeLevel(format, source, 8, 8); + byte[] decoded = BlockCompressionCodec.DecodeLevel(format, encoded, 8, 8); + Assert.Equal(source, decoded); + } + } + + [Fact] + public void Bc3PreservesASolidAlphaExactly() + { + byte[] source = SolidRgba(4, 4, 0xFF, 0xFF, 0xFF, a: 0x42); + + byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 4, 4); + byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc3Unorm, encoded, 4, 4); + + for (int texel = 0; texel < 16; texel++) + Assert.Equal(0x42, decoded[(texel * 4) + 3]); + } + + [Fact] + public void Bc1KeepsCutOutTexelsFullyTransparent() + { + // A checkerboard of opaque white and cut-out texels — the shape of every + // leaf and grate in the world. + byte[] source = SolidRgba(4, 4, 0xFF, 0xFF, 0xFF); + for (int texel = 0; texel < 16; texel += 2) + source[(texel * 4) + 3] = 0; + + byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 4, 4); + byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc1Unorm, encoded, 4, 4); + + for (int texel = 0; texel < 16; texel++) + { + byte alpha = decoded[(texel * 4) + 3]; + if (texel % 2 == 0) + Assert.Equal(0, alpha); + else + Assert.Equal(255, alpha); + } + } + + [Fact] + public void Bc1UsesThreeColourModeOnlyWhenTheBlockHasCutOutTexels() + { + byte[] opaque = SolidRgba(4, 4, 0x20, 0x40, 0x60); + byte[] withHoles = SolidRgba(4, 4, 0x20, 0x40, 0x60); + withHoles[3] = 0; + + byte[] opaqueBlock = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, opaque, 4, 4); + byte[] holedBlock = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, withHoles, 4, 4); + + ushort opaqueC0 = (ushort)(opaqueBlock[0] | (opaqueBlock[1] << 8)); + ushort opaqueC1 = (ushort)(opaqueBlock[2] | (opaqueBlock[3] << 8)); + ushort holedC0 = (ushort)(holedBlock[0] | (holedBlock[1] << 8)); + ushort holedC1 = (ushort)(holedBlock[2] | (holedBlock[3] << 8)); + + // Four-colour mode is c0 > c1; three-colour (with transparency) is + // c0 <= c1. The bit pattern IS the mode — there is no other flag. + Assert.True(opaqueC0 > opaqueC1, "an opaque block must use BC1's four-colour mode"); + Assert.True(holedC0 <= holedC1, "a block with a cut-out texel must use BC1's three-colour mode"); + } + + [Fact] + public void GradientRoundTripStaysCloseRatherThanCollapsing() + { + // A horizontal red ramp: exactly the case where a broken endpoint search + // silently flattens a block to one colour. + var source = new byte[4 * 4 * 4]; + for (int texel = 0; texel < 16; texel++) + { + source[(texel * 4) + 0] = (byte)((texel % 4) * 80); + source[(texel * 4) + 3] = 255; + } + + byte[] encoded = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 4, 4); + byte[] decoded = BlockCompressionCodec.DecodeLevel(GpuTextureFormat.Bc1Unorm, encoded, 4, 4); + + var distinct = new HashSet(); + for (int texel = 0; texel < 16; texel++) + { + distinct.Add(decoded[texel * 4]); + Assert.InRange(Math.Abs(decoded[texel * 4] - source[texel * 4]), 0, 12); + } + + Assert.Equal(4, distinct.Count); + } + + // ── determinism ────────────────────────────────────────────────────────── + + [Fact] + public void EncodingTheSameBytesTwiceProducesTheSameBytes() + { + var random = new Random(20260728); + var source = new byte[16 * 16 * 4]; + random.NextBytes(source); + + byte[] first = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 16, 16); + byte[] second = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc3Unorm, source, 16, 16); + + Assert.Equal(first, second); + } + + [Fact] + public void TheWholeMipChainIsReproducible() + { + var random = new Random(1189998819991197253L.GetHashCode()); + var source = new byte[32 * 32 * 4]; + random.NextBytes(source); + + IReadOnlyList first = + BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 32, 32, 6); + IReadOnlyList second = + BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 32, 32, 6); + + Assert.Equal(first.Count, second.Count); + for (int i = 0; i < first.Count; i++) + Assert.Equal(first[i].Data, second[i].Data); + } + + // ── the chain ──────────────────────────────────────────────────────────── + + [Fact] + public void ChainHalvesEachLevelAndStopsAtOneByOne() + { + byte[] source = SolidRgba(16, 16, 0x00, 0xFF, 0x00); + + IReadOnlyList levels = + BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 16, 16, 5); + + Assert.Equal([1, 2, 3, 4], levels.Select(level => level.MipLevel)); + Assert.Equal([8, 4, 2, 1], levels.Select(level => level.Width)); + Assert.Equal([8, 4, 2, 1], levels.Select(level => level.Height)); + // Even a 1x1 level occupies one whole block. + Assert.Equal(8, levels[^1].Data.Length); + } + + [Fact] + public void ChainOfAUniformImageIsThatColourAtEveryLevel() + { + byte[] source = SolidRgba(16, 16, 0x00, 0x00, 0xFF); + + IReadOnlyList levels = + BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 16, 16, 5); + + foreach (BlockCompressionMipChain.Level level in levels) + { + byte[] decoded = BlockCompressionCodec.DecodeLevel( + GpuTextureFormat.Bc1Unorm, + level.Data, + level.Width, + level.Height); + for (int texel = 0; texel < level.Width * level.Height; texel++) + { + Assert.Equal(0x00, decoded[texel * 4]); + Assert.Equal(0x00, decoded[(texel * 4) + 1]); + Assert.Equal(0xFF, decoded[(texel * 4) + 2]); + } + } + } + + [Fact] + public void ChainStartsFromACompressedLevelZeroWithoutReEncodingIt() + { + byte[] source = SolidRgba(8, 8, 0xFF, 0x00, 0x00); + byte[] level0 = BlockCompressionCodec.EncodeLevel(GpuTextureFormat.Bc1Unorm, source, 8, 8); + + IReadOnlyList levels = + BlockCompressionMipChain.BuildCompressed(GpuTextureFormat.Bc1Unorm, level0, 8, 8, 4); + + // Level 0 is not returned: it was uploaded verbatim, and re-encoding it + // would be a lossy round trip of data that is already right. + Assert.Equal([1, 2, 3], levels.Select(level => level.MipLevel)); + } + + [Fact] + public void ChainOfASingleLevelImageIsEmpty() + { + byte[] source = SolidRgba(4, 4, 1, 2, 3); + Assert.Empty(BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 4, 4, 1)); + } + + // ── the filter ─────────────────────────────────────────────────────────── + + [Fact] + public void DownsampleAveragesTwoByTwoBlocksWithRoundHalfUp() + { + // Two texels of 0 and two of 1 average to 0.5, which rounds to 1. + byte[] source = + [ + 0, 0, 0, 0, + 1, 1, 1, 1, + 1, 1, 1, 1, + 0, 0, 0, 0, + ]; + + (byte[] output, int width, int height) = BlockCompressionMipChain.Downsample(source, 2, 2); + + Assert.Equal(1, width); + Assert.Equal(1, height); + Assert.Equal([1, 1, 1, 1], output); + } + + [Fact] + public void DownsampleClampsAtOddExtentsRatherThanReadingOutOfBounds() + { + byte[] source = SolidRgba(3, 3, 10, 20, 30); + + (byte[] output, int width, int height) = BlockCompressionMipChain.Downsample(source, 3, 3); + + Assert.Equal(1, width); + Assert.Equal(1, height); + Assert.Equal([10, 20, 30, 255], output); + } + + [Fact] + public void DownsampleAveragesAlphaWithoutPremultiplying() + { + // Opaque white beside a fully transparent texel. Premultiplying would + // darken the surviving colour; averaging keeps it white at half alpha, + // which is what stops every leaf fringe going grey down the chain. + byte[] source = + [ + 255, 255, 255, 255, + 255, 255, 255, 0, + 255, 255, 255, 255, + 255, 255, 255, 0, + ]; + + (byte[] output, _, _) = BlockCompressionMipChain.Downsample(source, 2, 2); + + Assert.Equal(255, output[0]); + Assert.Equal(255, output[1]); + Assert.Equal(255, output[2]); + Assert.Equal(128, output[3]); + } + + // ── the table's slot policy ────────────────────────────────────────────── + + [Fact] + public void TextureSlotsAreHandedOutLowestFirst() + { + var slots = new VulkanTextureSlotAllocator(16); + + Assert.Equal(0u, slots.Allocate()); + Assert.Equal(1u, slots.Allocate()); + Assert.Equal(2u, slots.Allocate()); + Assert.Equal(3u, slots.HighWater); + } + + [Fact] + public void AReleasedSlotIsReusedAndTheHighWaterDoesNotGrow() + { + var slots = new VulkanTextureSlotAllocator(16); + slots.Allocate(); + uint second = slots.Allocate(); + slots.Allocate(); + + slots.Release(second); + + Assert.Equal(second, slots.Allocate()); + Assert.Equal(3u, slots.HighWater); + } + + [Fact] + public void ReleasingTheSameSlotTwiceIsRejected() + { + var slots = new VulkanTextureSlotAllocator(16); + uint slot = slots.Allocate(); + slots.Release(slot); + + // Two live textures sharing one table index is a silent + // wrong-texture bug, so this has to be loud. + Assert.Throws(() => slots.Release(slot)); + } + + [Fact] + public void RunningOutOfSlotsNamesTheCapacityToRaise() + { + var slots = new VulkanTextureSlotAllocator(2); + slots.Allocate(); + slots.Allocate(); + + InvalidOperationException error = Assert.Throws(() => slots.Allocate()); + + Assert.Contains("TextureTableCapacity", error.Message, StringComparison.Ordinal); + } +}