diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs index e7ff3827..5e50d0fe 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs @@ -58,7 +58,11 @@ internal struct GpuPushConstants /// GLSL uTextureIndexB. Secondary per-pass slot — currently the terrain alpha-mask array. public uint TextureIndexB; - /// GLSL uParamA. Spare scalar; unclaimed at V0. + /// + /// GLSL uParamA. Spare scalar, claimed at slice V6e by + /// particle_mesh as the array layer its per-pass texture is sampled + /// from — a value the shader converted to float anyway. + /// public float ParamA; /// GLSL uParamB. Spare scalar; unclaimed at V0. diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 81657d9a..b00d3d91 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -116,6 +116,11 @@ public sealed unsafe class ParticleRenderer : IDisposable private bool _disposing; private bool _disposed; private readonly HashSet _meshLoadRequestedThisFrame = new(); + // Campaign V slice V6e: particle_mesh.frag's two loose uniforms were renamed + // onto members of the shared push-constant block (uTextureIndex → + // uTextureIndexA, uTextureLayer → uParamA), because Vulkan GLSL has no + // default uniform block to declare them in. Under GL they are still plain + // program uniforms set exactly as before; only the names moved. private readonly int _meshTextureIndexLoc = -1; private readonly int _meshTextureLayerLoc = -1; @@ -266,8 +271,8 @@ public sealed unsafe class ParticleRenderer : IDisposable constructionResources.Add( "particle mesh shader", _meshShader.Dispose); - _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex"); - _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); + _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndexA"); + _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uParamA"); } // Campaign V slice V2c: binding=9 texture-table SSBO (GL-only @@ -564,7 +569,10 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshShader.Program, _meshTextureIndexLoc, _textureTable.GetOrAdd(batch.BindlessTextureHandle)); - _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); + // Slice V6e: uParamA is a float, so the layer is widened here rather + // than in the shader's float(uTextureLayer). Layers are small + // integers; the sampled value is bit-identical. + _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex); UploadMeshInstances(_meshRunScratch); PrepareMeshPipeline(viewProjection, global); @@ -756,7 +764,10 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshShader.Program, _meshTextureIndexLoc, _textureTable.GetOrAdd(batch.BindlessTextureHandle)); - _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); + // Slice V6e: uParamA is a float, so the layer is widened here rather + // than in the shader's float(uTextureLayer). Layers are small + // integers; the sampled value is bit-identical. + _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex); _gl.BindVertexArray(_meshVao); FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) @@ -1057,6 +1068,22 @@ public sealed unsafe class ParticleRenderer : IDisposable PersistActiveDynamicBufferCapacities(); } + /// + /// Campaign V slice V6e: the shader-side spelling of "this particle has no + /// texture, draw the procedural blob". It must agree with + /// ACDREAM_TEXTURE_NONE in Shaders/common.glsl and in the + /// Vulkan preamble. + /// + /// V2c encoded the same fact as "a table slot whose handle is zero", + /// which particle.frag could test because GL's emulated table stores the + /// handles themselves. Vulkan's table is an opaque descriptor array with + /// nothing to compare — reading an unwritten element of a partially-bound + /// array is undefined, not zero — so the fact moves into the index, where + /// both dialects test it the same way. GL renders identically: the same + /// particles take the same branch. + /// + private const uint NoTextureSlot = 0xFFFFFFFFu; + // Campaign V slice V2c: instance method (not static) because it converts // the particle's raw bindless handle to a _textureTable slot. private void WriteBillboardGpuInstance( @@ -1073,7 +1100,9 @@ public sealed unsafe class ParticleRenderer : IDisposable ((particle.ColorArgb >> 8) & 0xFF) / 255f, (particle.ColorArgb & 0xFF) / 255f, ((particle.ColorArgb >> 24) & 0xFF) / 255f), - TextureIndex = _textureTable.GetOrAdd(particle.TextureHandle), + TextureIndex = particle.TextureHandle == 0UL + ? NoTextureSlot + : _textureTable.GetOrAdd(particle.TextureHandle), }; } diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag index 526183d4..a4a571e3 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.frag +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -3,14 +3,17 @@ in vec2 vTex; in vec4 vColor; -flat in uvec2 vTextureHandle; +// Campaign V slice V6e: the texture-table SLOT, not the bindless handle — see +// particle.vert. ACDREAM_TEXTURE_NONE is the untextured particle, which used to +// be spelled "the slot whose handle is zero"; a Vulkan descriptor array cannot +// be asked that question, so the answer lives in the index (common.glsl). +flat in uint vTextureIndex; out vec4 fragColor; void main() { vec4 texel; - if (any(notEqual(vTextureHandle, uvec2(0)))) { - sampler2DArray particleTexture = sampler2DArray(vTextureHandle); - texel = texture(particleTexture, vec3(vTex, 0.0)); + if (vTextureIndex != ACDREAM_TEXTURE_NONE) { + texel = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTex, 0.0)); } else { vec2 d = vTex - vec2(0.5, 0.5); float r = length(d) * 2.0; diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 11162a38..e9259d61 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -18,7 +18,10 @@ uniform mat4 uViewProjection; out vec2 vTex; out vec4 vColor; -flat out uvec2 vTextureHandle; +// Campaign V slice V6e: was `flat uvec2 vTextureHandle`. A varying cannot carry +// a Vulkan descriptor, so the table SLOT crosses the stage boundary and +// particle.frag does the lookup at the sample site. +flat out uint vTextureIndex; void main() { vec3 world = aCenter.xyz @@ -27,8 +30,8 @@ void main() { vTex = aTex; vColor = aColor; - // Reconstruct the SAME uvec2 handle particle.frag used to receive - // directly — one binding=9 lookup, identical value downstream. - vTextureHandle = ACDREAM_TEXTURE_HANDLE(aTextureIndex); + // Slice V6e: forward the slot untouched; the lookup moved to the fragment + // stage, which is the only form Vulkan can express. + vTextureIndex = aTextureIndex; gl_Position = uViewProjection * vec4(world, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag index d880c95d..dd992060 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag @@ -7,12 +7,19 @@ out vec4 fragColor; // Campaign V slice V2c (2026-07-27): was uvec2 uTextureHandle (a raw // ARB_bindless_texture handle); now a slot into the binding=9 handle table. -uniform uint uTextureIndex; -uniform int uTextureLayer; +// +// Slice V6e renamed both onto members of the shared 96-byte push-constant block +// (GpuPushConstants), which is the only home Vulkan has for a loose uniform: +// there is no default uniform block, so `uniform uint uTextureIndex;` is not +// merely unsupported, it is unspellable. Both values are per-pass — one texture, +// one layer, for a whole mesh-particle sub-batch — which is exactly what the +// block is for. uParamA carries the layer because it is consumed as a float +// anyway; the CPU writes the same integral value it always did. +uniform uint uTextureIndexA; +uniform float uParamA; void main() { - sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex)); - vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor; + vec4 color = ACDREAM_SAMPLE_ARRAY(uTextureIndexA, vec3(vTexCoord, uParamA)) * vColor; if (color.a < 0.02) discard; fragColor = color; diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv new file mode 100644 index 00000000..c4e9788b Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv new file mode 100644 index 00000000..26fcf5de Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv new file mode 100644 index 00000000..c196344d Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv new file mode 100644 index 00000000..3b32357f Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 3c6fb2c1..d7d345b4 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -53,25 +53,23 @@ }, { "name": "particle", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", - "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", - "compiled": false, - "message": "particle.vert:85: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027" + "sourceSha256": "9629271f8997853a3c78a3cb1ec7af02a13518d68bdaa5ae1378997da7e5ab62", + "compiled": true }, { "stage": "frag", - "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", - "compiled": false, - "message": "particle.frag:62: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" + "sourceSha256": "bc08e4fb6f57da94d6c41d52aa81d73ab287c968214303a446c40a04d0495b40", + "compiled": true } ] }, { "name": "particle_mesh", - "vulkanReady": false, + "vulkanReady": true, "stages": [ { "stage": "vert", @@ -80,9 +78,8 @@ }, { "stage": "frag", - "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", - "compiled": false, - "message": "particle_mesh.frag:64: error: \u0027uTextureIndex\u0027 : undeclared identifier" + "sourceSha256": "73c8db72aeb44e88054dd79fd722c7f683b27a8bc03368b5201f05adffd92728", + "compiled": true } ] }, diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs index 0f18be54..aba04240 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.InteropServices; using AcDream.App.Rendering; @@ -30,15 +31,61 @@ public sealed class ParticleBindlessInstanceTests 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 now carries a - // binding=9 table slot, not the raw handle; particle.vert - // reconstructs the SAME uvec2 handle via ACDREAM_TEXTURE_HANDLE - // before handing it to particle.frag, so the fragment shader's half - // (extension, reconstruction, varying type/name) is untouched. + // 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 uvec2 vTextureHandle;", vertex); + Assert.Contains("flat out uint vTextureIndex;", vertex); + Assert.Contains("vTextureIndex = aTextureIndex;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); - Assert.Contains("sampler2DArray(vTextureHandle)", 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 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. + /// + [Fact] + public void TheReservedNoTextureSlotAgreesAcrossCpuAndBothDialects() + { + const string literal = "0xFFFFFFFF"; + + object? cpuValue = typeof(ParticleRenderer) + .GetField("NoTextureSlot", BindingFlags.NonPublic | BindingFlags.Static) + ?.GetRawConstantValue(); + Assert.Equal(0xFFFFFFFFu, Assert.IsType(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."); + } }