Campaign V slice V6e, second of three. Billboard particles and mesh particles
are the last two pairs blocked on the texture-table shape; sky follows.
particle takes the same treatment mesh_modern took: the `flat uvec2` handle
varying becomes a `flat uint` slot and the fragment stage samples through
ACDREAM_SAMPLE_ARRAY. What is different here is the untextured particle. The
shader used to ask "is the handle I was given zero", which GL can answer because
its emulated table stores handles; Vulkan cannot, because set 2 is an opaque
descriptor array and reading an element nobody wrote is undefined rather than
zero. So the question moves to the index: the CPU writes ACDREAM_TEXTURE_NONE
for a particle with no texture instead of interning the null handle as a table
slot, and both dialects test the same value. GL renders identically — the same
particles take the same branch to the same procedural blob — and the handle
table simply stops carrying an entry that never named a texture. A test pins the
sentinel across all three declarations of it, because a silent disagreement here
would sample slot 0xFFFFFFFF instead of drawing the blob.
particle_mesh needed no restructuring, only names. Vulkan GLSL has no default
uniform block, so `uniform uint uTextureIndex;` is not unsupported but
unspellable, and the two values are per-pass — one texture and one layer for a
whole sub-batch — which is exactly what the shared push-constant block is for.
uTextureIndex becomes uTextureIndexA; uTextureLayer becomes uParamA, which was
the spare scalar and is a natural fit because the shader converted the layer to
float anyway. The widening moved from the shader to the CPU; layers are small
integers, so the sampled value is bit-identical.
Gates: Release build clean; App tests 4,058 passed / 3 skipped (baseline 4,057
plus the sentinel drift guard). Offline pixel gate against 95f8c25f: two
captures, 29 px and 21 px of 563,200 compared (3.73e-05 and 5.15e-05), with a
same-commit control between them of 13 px and this commit measuring 14 px
against its own parent. The scene draws no particles, so this gate is a tripwire
that the world path is undisturbed, not evidence about particles.
Particles remain user-gate debt — the same debt V2c and V4e already carry, to be
paid by casting a spell in a connected session.
Manifest: 6/9 pairs compile. Remaining: mesh (legacy, no consumer), sky (next
commit), terrain_modern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
4.3 KiB
C#
91 lines
4.3 KiB
C#
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<ParticleRenderer.BillboardGpuInstance>());
|
|
Assert.Equal(
|
|
new IntPtr(64),
|
|
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6e: "this particle has no texture" is now a reserved
|
|
/// index rather than a null handle, and that value is written in three
|
|
/// places — the CPU that produces it, the GL preamble that tests it, and the
|
|
/// Vulkan preamble that will. Three copies of a magic number is a drift
|
|
/// waiting to happen, and its failure mode is silent: a particle would
|
|
/// sample slot 0xFFFFFFFF instead of drawing the procedural blob.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheReservedNoTextureSlotAgreesAcrossCpuAndBothDialects()
|
|
{
|
|
const string literal = "0xFFFFFFFF";
|
|
|
|
object? cpuValue = typeof(ParticleRenderer)
|
|
.GetField("NoTextureSlot", BindingFlags.NonPublic | BindingFlags.Static)
|
|
?.GetRawConstantValue();
|
|
Assert.Equal(0xFFFFFFFFu, Assert.IsType<uint>(cpuValue));
|
|
|
|
string common = File.ReadAllText(Path.Combine(
|
|
AppContext.BaseDirectory, "Rendering", "Shaders", "common.glsl"));
|
|
Assert.Contains($"#define ACDREAM_TEXTURE_NONE {literal}u", common);
|
|
|
|
// The Vulkan half is injected by the offline compiler, not by
|
|
// common.glsl, 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.");
|
|
}
|
|
}
|