The second of V6's three commits: everything the fragment stage samples. Plan sections 4.3 (textures and mip generation) and 4.4 (descriptors). The descriptor table is the piece that retires GL_ARB_bindless_texture. One update-after-bind, partially-bound, variable-count combined-image-sampler array of 16384; registration appends exactly one vkUpdateDescriptorSets and nothing is written at draw time, so steady state is zero descriptor writes per frame. A slot is a (view, sampler) pair, exactly like a bindless handle, which is why the CPU data model needs no change at all - GpuTextureSlot already carries the index and V2 already moved every batch onto it. Eviction is retirement-gated and the slot is scrubbed on the way out. Returning a slot the moment a texture is deleted would let the LRU alias a live draw onto a new texture, so the release is filed through the ledger; and when it runs the slot is first overwritten with the default 1x1 white. A stale view descriptor sitting in a partially-bound array is legal right up until something reads it, at which point it is a use-after-free with no error attached. Writing the dummy makes that impossible rather than unlikely. The CPU block-compression codec is the slice's other substantial piece, and it exists because Vulkan cannot blit into a compressed image. DAT surfaces arrive as DXT1/3/5 with no mips, so the chain has to be decoded, box filtered and re-encoded here. That is not merely a substitute for the missing blit: the GL path calls glGenerateMipmap on compressed array textures, whose result is explicitly implementation-defined, so this is the first time that part of the pipeline has had a defined answer. Two properties matter more than quality, and both are tested. It is deterministic - integer arithmetic end to end, endpoints from the block's bounding box, nearest-palette selection, no dithering and no iterative fit - because the offline pixel gate compares captures from separate processes and a chain that varied run to run would make every textured surface look like a regression. And it preserves BC1's one-bit cut-out: a block containing any texel below the alpha threshold is encoded in three-colour mode, because retail's foliage and grates ARE that mode and quantising those texels to an opaque colour would fill in every leaf. Plan 4.3's escape hatch stands if quality ever trips a gate: store the affected textures as RGBA8 and blit their mips. Uncompressed images do take the blit chain, added to the upload queue. Each source level moves to TRANSFER_SRC for its blit and back to TRANSFER_DST afterwards; leaving the chain in mixed layouts would be one barrier cheaper and would then force the batch's final shader-read transition to name a different old layout per level, so ending every level the same way is what keeps that transition one barrier per image. The upload queue now records the layout each image is in on ENTRY to a batch rather than always naming UNDEFINED. UNDEFINED lets the driver discard existing contents, which is right for a fresh image and wrong for the incremental array-layer fills that mirror ManagedGLTextureArray - discarding there would erase every layer uploaded earlier. Render targets are single-sampled per the contract and carry SAMPLED usage alongside COLOR_ATTACHMENT, so a paperdoll or appraisal view can be registered into the table and drawn by the retained UI the moment its pass ends. VulkanBackbufferAttachments owns the two attachments the swapchain does not: the multisampled colour scratch that resolves into the swapchain image, and the transient depth/stencil. Both are TRANSIENT_ATTACHMENT because nothing reads either after the frame. Stencil is not optional - issue #117's portal punch needs the aspect, which is why the V5 gate prefers D32_SFLOAT_S8_UINT over a depth-only format. Every format stays UNORM, and that is the V3 audit's finding rather than a default. The plan previously specified an sRGB swapchain "matching the GL FramebufferSrgb contract"; that contract does not exist, the renderer is plain UNORM end to end, and shipping _SRGB would have brightened every frame and passed silently until V7. VulkanPipelineLayouts is extracted from V5's capability probe rather than written beside it, and the probe now calls it. The probe's whole value is proving the layouts the live backend builds can be built on this device; two similar-looking definitions would have quietly ended that the first time one of them changed. Gates: Release build clean, App suite 4037 passed / 3 skipped (4014 at V6a plus 23 new), offline pixel gate PASS against the parent baseline at a differing fraction of 4.26e-05 - 24 pixels of 563,200, one above the campaign's recorded 15-23 same-commit noise band and about 23x under the 0.001 threshold, on a commit that changes no GL code path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
383 lines
15 KiB
C#
383 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6b — the CPU block-compression codec and mip chain
|
|
/// (plan §4.3).
|
|
///
|
|
/// This code exists because Vulkan cannot <c>vkCmdBlitImage</c> 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.
|
|
/// </summary>
|
|
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<byte>();
|
|
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<BlockCompressionMipChain.Level> first =
|
|
BlockCompressionMipChain.BuildFromRgba(GpuTextureFormat.Bc1Unorm, source, 32, 32, 6);
|
|
IReadOnlyList<BlockCompressionMipChain.Level> 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<BlockCompressionMipChain.Level> 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<BlockCompressionMipChain.Level> 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<BlockCompressionMipChain.Level> 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<byte[]>([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<byte[]>([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<InvalidOperationException>(() => slots.Release(slot));
|
|
}
|
|
|
|
[Fact]
|
|
public void RunningOutOfSlotsNamesTheCapacityToRaise()
|
|
{
|
|
var slots = new VulkanTextureSlotAllocator(2);
|
|
slots.Allocate();
|
|
slots.Allocate();
|
|
|
|
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => slots.Allocate());
|
|
|
|
Assert.Contains("TextureTableCapacity", error.Message, StringComparison.Ordinal);
|
|
}
|
|
}
|