using System.Reflection; using System.Runtime.InteropServices; using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering; public sealed class ParticleBindlessInstanceTests { [Fact] public void BillboardGpuInstance_MatchesVertexAttributeAbi() { // Campaign V slice V2c (2026-07-27): TextureHandleLow/High (the split // halves of a raw 64-bit ARB_bindless_texture handle, 8 bytes) became // one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the // struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's // former offset (64 — right after the four vec4 fields). Assert.Equal(68, Marshal.SizeOf()); Assert.Equal( new IntPtr(64), Marshal.OffsetOf( nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex))); } [Fact] public void BillboardShaders_ConsumeOneBindlessTextureHandlePerInstance() { string shadersDirectory = Path.Combine( AppContext.BaseDirectory, "Rendering", "Shaders"); string vertex = File.ReadAllText(Path.Combine(shadersDirectory, "particle.vert")); string fragment = File.ReadAllText(Path.Combine(shadersDirectory, "particle.frag")); // Campaign V slice V2c: the per-instance attribute carries a binding=9 // table slot, not the raw handle. // // Campaign V slice V6e: the SLOT is what crosses the stage boundary now, // and the fragment stage does the lookup — a varying cannot carry a // Vulkan descriptor. The untextured particle is spelled by the reserved // index rather than by a null handle, because Vulkan's descriptor array // cannot be asked whether an element was ever written. Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex); Assert.Contains("flat out uint vTextureIndex;", vertex); Assert.Contains("vTextureIndex = aTextureIndex;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); Assert.Contains("flat in uint vTextureIndex;", fragment); Assert.Contains("ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTex, 0.0))", fragment); Assert.Contains("vTextureIndex != ACDREAM_TEXTURE_NONE", fragment); Assert.DoesNotContain("uniform sampler2D uParticleTexture", fragment); } /// /// Campaign V slice V6e: "this particle has no texture" is a reserved index /// rather than a null handle, and that value is written in two places — the /// CPU that produces it and the Vulkan preamble that tests it in the shader. /// Before Campaign V slice V11 deleted the GL arm there was a third copy in /// common.glsl, which this test also cross-checked; two copies of a magic /// number is still a drift waiting to happen, and its failure mode is /// silent: a particle would sample slot 0xFFFFFFFF instead of drawing the /// procedural blob. /// [Fact] public void TheReservedNoTextureSlotAgreesBetweenCpuAndTheVulkanPreamble() { const string literal = "0xFFFFFFFF"; object? cpuValue = typeof(ParticleRenderer) .GetField("NoTextureSlot", BindingFlags.NonPublic | BindingFlags.Static) ?.GetRawConstantValue(); Assert.Equal(0xFFFFFFFFu, Assert.IsType(cpuValue)); // The Vulkan half is injected by the offline compiler at compile time, // not carried in a shared GLSL source, so it is a separate declaration // that has to say the same thing. string preamble = File.ReadAllText(Path.Combine( RepositoryRoot(), "tools", "ShaderCompiler", "VulkanGlslPreamble.cs")); Assert.Contains($"#define ACDREAM_TEXTURE_NONE {literal}u", preamble); } private static string RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) directory = directory.Parent; return directory?.FullName ?? throw new InvalidOperationException( "Could not locate the repository root from the test binary."); } }