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