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.");
+ }
}