using System; using System.Linq; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; using AcDream.App.Tests.Rendering.Gpu; using Chorizite.Core.Render.Enums; namespace AcDream.App.Tests.Rendering.Wb; /// /// Campaign V slice V6i-2: the backend-neutral world texture array, driven /// against . /// /// These assert the two things the GL array and this one genuinely have to /// agree on — what a well-formed layer payload is, and that every atlas ends up /// addressable from a shader through both address modes — plus the one thing /// they deliberately do NOT share: how a mip chain is produced. Vulkan cannot /// blit into a compressed image, so a BC array's levels come from /// BlockCompressionMipChain while an RGBA8 array's come from the device's /// blit. Getting that backwards would compile, run, and render a texture with /// undefined mips. /// public sealed class RhiWorldTextureArrayTests { private const int Extent = 64; private static byte[] Rgba(int width, int height) { var pixels = new byte[width * height * 4]; Array.Fill(pixels, (byte)0xFF); return pixels; } private static byte[] Bc1(int width, int height) => new byte[Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8]; [Fact] public void AnUncompressedArrayLetsTheDeviceBlitItsMipChain() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 4); array.UpdateLayer(2, Rgba(Extent, Extent), null, null); Assert.Equal(1, array.PendingUpdateCount); long generated = array.ProcessDirtyUpdates(); RecordingGpuTexture texture = LastCreatedTexture(device); Assert.True(texture.MipChainGenerated); // Exactly one upload: level 0 of the layer that was staged. Every other // level is the device's blit. Assert.Equal([(0, 2, Extent * Extent * 4)], texture.Uploads); Assert.True(generated > 0); Assert.Equal(0, array.PendingUpdateCount); } [Fact] public void ABlockCompressedArrayUploadsACpuBuiltChainAndNeverBlits() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.DXT1, Extent, Extent, 2); array.UpdateLayer(0, Bc1(Extent, Extent), null, null); array.ProcessDirtyUpdates(); RecordingGpuTexture texture = LastCreatedTexture(device); Assert.False(texture.MipChainGenerated); // 64x64 is seven levels; level 0 was staged and 1..6 were encoded, all // for the one layer that was written. Assert.Equal(7, texture.Uploads.Count); Assert.All(texture.Uploads, upload => Assert.Equal(0, upload.Layer)); Assert.Equal([0, 1, 2, 3, 4, 5, 6], texture.Uploads.Select(u => u.MipLevel)); } [Fact] public void EveryArrayIsAddressableThroughBothAddressModes() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1); GpuTextureSlot wrap = array.ResolveSlot(wrapping: true); GpuTextureSlot clamp = array.ResolveSlot(wrapping: false); Assert.True(wrap.IsAssigned); Assert.True(clamp.IsAssigned); Assert.NotEqual(wrap, clamp); GpuSamplerDescription[] samplers = [ .. device.Calls.OfType() .Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal)) .Select(registration => registration.Sampler), ]; // Campaign V slice V7: both address modes, and BOTH anisotropic. The GL // array asks for GL_TEXTURE_MAX_ANISOTROPY = the driver's maximum on // every world atlas, unconditionally; asking for 1 here made Vulkan // sample a coarser mip than GL on every grazing-angle surface, which the // first GL-versus-Vulkan pair saw as blurred roof shingles and distant // scenery. `with { MaxAnisotropy = 1f }` recovers the campaign's shared // description so this stays an assertion about ONE dimension. Assert.Contains(GpuSamplerDescription.WorldClamp, samplers.Select(Isotropic)); Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers.Select(Isotropic)); Assert.All(samplers, sampler => Assert.True( sampler.MaxAnisotropy > 1f, $"world atlas sampler {sampler.AddressU} asked for anisotropy " + $"{sampler.MaxAnisotropy}; the GL arm always asks for the device maximum.")); static GpuSamplerDescription Isotropic(GpuSamplerDescription sampler) => sampler with { MaxAnisotropy = 1f }; } /// /// The slot pair must come back to the device, or a session that churns /// atlases exhausts the table's fixed capacity — the leak-with-an-end V4t /// introduced when it capped the table. /// [Fact] public void DisposalReturnsBothSlotsAndTheImage() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); int before = device.LiveTextureSlotCount; IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1); Assert.Equal(before + 2, device.LiveTextureSlotCount); array.Dispose(); Assert.Equal(before, device.LiveTextureSlotCount); Assert.True(LastCreatedTexture(device).IsDisposed); Assert.True(array.IsPhysicalRetirementComplete); Assert.True(array.HasDurableDisposeOwnership); // ReleaseTextureSlots after Dispose is idempotent: the retiring-atlas // path calls it once physical retirement completes, which is necessarily // after disposal. array.ReleaseTextureSlots(); Assert.Equal(before, device.LiveTextureSlotCount); } /// /// The GL array rejects a payload whose length contradicts the format. The /// RHI array reuses that validator rather than writing a second one, so a /// mis-sized layer fails the same way on both arms. /// [Fact] public void AMisSizedLayerIsRejectedByTheSharedValidator() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1); Assert.Throws(() => array.UpdateLayer(0, new byte[16], null, null)); Assert.Equal(0, array.PendingUpdateCount); } /// /// The formats with no member of GpuTextureFormat fail loudly at /// creation rather than silently substituting. A8 in particular needs the /// component swizzle the GL array applies, which lives in a Vulkan image view /// and is not part of the pinned texture description. /// [Theory] [InlineData(TextureFormat.A8)] [InlineData(TextureFormat.RGB8)] [InlineData(TextureFormat.Rgba32f)] public void AFormatWithNoRhiEquivalentIsRefusedAtCreation(TextureFormat format) { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); Assert.Throws( () => arrays.CreateClampedArray(format, Extent, Extent, 1)); } /// /// Both arms meter the same bytes, because the eviction budget that reads /// this number is shared policy above the seam. /// [Fact] public void AllocatedBytesMatchTheSharedMipChainAccounting() { using var device = new RecordingGpuDevice(); var arrays = new RhiWorldTextureArrayFactory(device); using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 3); Assert.Equal( TextureAtlasManager.CalculateMipChainBytes(Extent, Extent, TextureFormat.RGBA8) * 3, array.TotalSizeInBytes); } private static RecordingGpuTexture LastCreatedTexture(RecordingGpuDevice device) => device.CreatedTextures.Count > 0 ? device.CreatedTextures[^1] : throw new InvalidOperationException("The device created no texture."); }