feat(render): V6e — both particle shaders cross the dialect
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>
This commit is contained in:
parent
935f4dc3d9
commit
602bc9dddb
11 changed files with 126 additions and 36 deletions
|
|
@ -58,7 +58,11 @@ internal struct GpuPushConstants
|
|||
/// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot — currently the terrain alpha-mask array.</summary>
|
||||
public uint TextureIndexB;
|
||||
|
||||
/// <summary>GLSL <c>uParamA</c>. Spare scalar; unclaimed at V0.</summary>
|
||||
/// <summary>
|
||||
/// GLSL <c>uParamA</c>. Spare scalar, claimed at slice V6e by
|
||||
/// <c>particle_mesh</c> as the array layer its per-pass texture is sampled
|
||||
/// from — a value the shader converted to float anyway.
|
||||
/// </summary>
|
||||
public float ParamA;
|
||||
|
||||
/// <summary>GLSL <c>uParamB</c>. Spare scalar; unclaimed at V0.</summary>
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
private bool _disposing;
|
||||
private bool _disposed;
|
||||
private readonly HashSet<uint> _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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6e: the shader-side spelling of "this particle has no
|
||||
/// texture, draw the procedural blob". It must agree with
|
||||
/// <c>ACDREAM_TEXTURE_NONE</c> in <c>Shaders/common.glsl</c> and in the
|
||||
/// Vulkan preamble.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv
Normal file
BIN
src/AcDream.App/Rendering/Shaders/spv/particle.frag.spv
Normal file
Binary file not shown.
BIN
src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv
Normal file
BIN
src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv
Normal file
Binary file not shown.
BIN
src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv
Normal file
BIN
src/AcDream.App/Rendering/Shaders/spv/particle_mesh.frag.spv
Normal file
Binary file not shown.
BIN
src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv
Normal file
BIN
src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv
Normal file
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue