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); + } +}