feat(render): Campaign V slice V2c - particle texture-index migration

Completes Campaign V slice V2 by moving both particle render paths -
billboard (particle.vert/.frag) and mesh-emitter (particle_mesh.vert/.frag) -
from raw 64-bit ARB_bindless_texture handles to binding=9 handle-table
indices, matching V2a's mesh path and V2b's terrain path.

Particles differ from both prior sub-slices in HOW the handle reaches the
shader:

- Billboard particles carry it as a per-INSTANCE vertex attribute (not an
  SSBO batch or a per-draw uniform), because each particle can use a
  different texture within one instanced draw. aTextureHandle (location 6,
  uvec2) became aTextureIndex (uint); particle.vert looks it up via
  ACDREAM_TEXTURE_HANDLE and reconstructs the SAME uvec2 into vTextureHandle
  exactly as before, so particle.frag - including its zero-handle check for
  the procedural circle fallback - needed no change at all. The per-instance
  ABI struct BillboardGpuInstance shrank by 4 bytes (one uint slot instead of
  two uint handle halves); ParticleBindlessInstanceTests updated for the new
  68-byte layout and the vertex attribute declaration text.

- Mesh-emitter particles carry it as a per-draw uniform (uTextureHandle,
  uvec2) exactly like terrain's pattern from V2b: one texture per draw call,
  set right before it. Became uTextureIndex (uint) + the same
  ACDREAM_TEXTURE_HANDLE lookup.

ParticleRenderer owns its own GlBindlessHandleTable and binding=9 SSBO,
independent of the other three renderers' tables, created eagerly in the
constructor alongside the other GL resources it already creates there. Unlike
the other three renderers, flushing/binding the table happens immediately
before EVERY individual draw call (four call sites: immediate billboard,
immediate mesh, and both halves of the deferred/prepared RetailAlphaQueue
path) rather than once per pipeline-state switch - a run of consecutive
mesh-particle sub-batches can register a new handle partway through (each
sub-batch has its own texture), and the table must be current for each one,
not just the first.

TextureCache's particle-texture cache (AcquireParticleTexture,
StandaloneBindlessTextureCache) needed no change: it only ever hands back a
raw ulong handle, and both ParticleGfxInfo.TextureHandle and
ParticleInstance.TextureHandle keep carrying that raw value - the table
lookup is added exactly where each path already converts its handle into
GPU-visible state (WriteBillboardGpuInstance and the two ProgramUniform
call sites).

Coverage caveat (flagged per the campaign doc's slice table): the offline
pixel gate's fixed outdoor view has no particles in frame, so it does not
exercise this slice - it only confirms nothing else regressed. This change
is correspondingly kept strictly mechanical (indirection only, no logic
change), but it still needs a user visual check with live particle emitters
before being trusted as pixel-identical.

Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped on a clean run - one unrelated
pre-existing flaky allocation test, UiDatFontTests, failed once and passed
on immediate re-run in isolation and in the full suite, confirmed unrelated
to this change), and tools/run-offline-pixel-gate.ps1 passed against the
V2b commit's build with a 4.62e-05 differing-pixel fraction (a tripwire
only, per the coverage caveat above). No divergence-register row: this
introduces no retail behavior deviation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 16:02:59 +02:00
parent 1f1f6c088b
commit a85743f70d
4 changed files with 130 additions and 32 deletions

View file

@ -9,7 +9,10 @@ layout(location = 2) in vec4 aCenter;
layout(location = 3) in vec4 aAxisX;
layout(location = 4) in vec4 aAxisY;
layout(location = 5) in vec4 aColor;
layout(location = 6) in uvec2 aTextureHandle;
// Campaign V slice V2c (2026-07-27): was uvec2 aTextureHandle (a raw
// ARB_bindless_texture handle); now a slot into the binding=9 handle table
// (ACDREAM_TEXTURE_HANDLE, common.glsl).
layout(location = 6) in uint aTextureIndex;
uniform mat4 uViewProjection;
@ -24,6 +27,8 @@ void main() {
vTex = aTex;
vColor = aColor;
vTextureHandle = aTextureHandle;
// Reconstruct the SAME uvec2 handle particle.frag used to receive
// directly — one binding=9 lookup, identical value downstream.
vTextureHandle = ACDREAM_TEXTURE_HANDLE(aTextureIndex);
gl_Position = uViewProjection * vec4(world, 1.0);
}

View file

@ -5,11 +5,13 @@ in vec2 vTexCoord;
in vec4 vColor;
out vec4 fragColor;
uniform uvec2 uTextureHandle;
// 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;
void main() {
sampler2DArray tex = sampler2DArray(uTextureHandle);
sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex));
vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor;
if (color.a < 0.02)
discard;