feat(#188): fading-wall + sliding-door translucency; hold open past animation settle

Lands the fading-secret-door feature and fixes the door "flip-back" that
surfaced while testing it.

#188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part
out via TransparentPartHook instead of swinging:
  - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager
    (per-(entity,part) linear translucency ramp; holds at End frame).
  - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches
    takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple
    0x005396f0) forcing AlphaBlend; fully-invisible parts skipped.
  - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier ->
    FragColor.a *= vOpacityMultiplier.
  - Register AP-89: the fade multiplies sampled texture alpha, not a separate
    D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces).

Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a
door/wall that finished opening holds a single unchanging frame, so the
uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the
Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 --
snapping it visually shut/opaque while physics stayed open. Reverted that
narrowing: every Sequencer entity stays on the per-frame path (live pose + live
fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost
that narrowing chased was a Debug-build artifact -- Release is GPU-bound
(~200 fps in Sawato, measured), so the unconditional add is free where it
matters. Left a code comment barring re-introduction.

Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED --
both fading-wall and sliding doors hold open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-09 09:17:50 +02:00
parent e2e285b855
commit 3284dd0aed
12 changed files with 625 additions and 14 deletions

View file

@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache;
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
// secret-passage walls). ClassifyBatches reads this per part to compute
// the instance's opacity multiplier; never mutated here.
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled =
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
private uint _instIndoorSsbo;
private uint[] _indoorData = new uint[256];
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
private uint _instAlphaSsbo;
private float[] _alphaData = new float[256];
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter,
BindlessSupport bindless,
EntityClassificationCache classificationCache)
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ArgumentNullException.ThrowIfNull(meshAdapter);
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
ArgumentNullException.ThrowIfNull(classificationCache);
ArgumentNullException.ThrowIfNull(translucencyFades);
_gl = gl;
_shader = shader;
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_meshAdapter = meshAdapter;
_entitySpawnAdapter = entitySpawnAdapter;
_cache = classificationCache;
_translucencyFades = translucencyFades;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
}
/// <summary>
@ -1290,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
// #188: setupPartIndex is the SAME index space
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
// consumer of this exact indexing: one Matrix4x4 per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null)
{
@ -1332,15 +1358,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
// sets draw_state|=1 and skips the whole part outright — not a
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
// t=1 commits the bitwise-exact value so this check is safe.
float opacityMultiplier = 1.0f;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
{
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
drewAny = true;
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
drewAny = true;
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
// retail's CPartArray for such an object is a single-entry array,
// so TransparentPartHook.PartIndex for it is always 0.
float opacityMultiplier = 1.0f;
bool fullyInvisible = false;
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
{
if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue;
}
if (!fullyInvisible)
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
drewAny = true;
}
}
// Track THIS entity for the next iteration's flush check. Only
@ -1427,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_indoorData.Length < totalInstances)
_indoorData = new uint[totalInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < totalInstances)
_alphaData = new float[totalInstances + 256];
_opaqueDraws.Clear();
_translucentDraws.Clear();
@ -1462,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
_indoorData[cursor] = grp.IndoorFlags[i];
// #188: Opacities[] is parallel to Matrices[]; write at the same
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
_alphaData[cursor] = grp.Opacities[i];
cursor++;
}
@ -1553,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* dp = _indoorData)
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
// #188: per-instance opacity buffer (binding=7), one float per instance,
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
// is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (float* ap = _alphaData)
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
// per-instance buffer holds 8 int indices into it per instance, laid out
@ -2056,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
// #188: cache-hit entities are always non-animated (the Tier-1 cache
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
// state for entities whose animation hooks fired — so a cached
// instance can never be mid-fade. Always unmodified opacity.
grp.Opacities.Add(1.0f);
}
/// <summary>
@ -2181,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ulong palHash,
AcSurfaceMetadataTable metaTable,
Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List<CachedBatch>? collector = null)
{
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
@ -2199,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
: TranslucencyKind.Opaque;
}
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha —
// the no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
if (texHandle == 0) continue;
@ -2228,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -2310,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2509,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// for outdoor objects (gets the sun). Written into _indoorData at the same
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List<uint> IndoorFlags = new();
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
// layout time the dispatcher writes Opacities[i] into _alphaData at
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
public readonly List<float> Opacities = new();
}
}