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

@ -0,0 +1,152 @@
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
public sealed class TranslucencyFadeManagerTests
{
[Fact]
public void StartPartFade_InstantEpsilon_CommitsEndImmediately()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f,
time: TranslucencyFadeManager.TranslucencyInstantEpsilon);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(1f, value);
// No ramp survives — advancing further changes nothing.
mgr.AdvanceAll(10f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float after));
Assert.Equal(1f, after);
}
[Fact]
public void StartPartFade_ZeroTime_IsTreatedAsInstant()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 5, partIndex: 2, start: 0f, end: 0.5f, time: 0f);
Assert.True(mgr.TryGetCurrentValue(5, 2, out float value));
Assert.Equal(0.5f, value);
}
[Fact]
public void StartPartFade_RealDuration_ImmediatelyReadableAsStart()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 0.8f, time: 1f);
// Before any AdvanceAll — the ramp's t=0 value must already be readable
// (FPHook's first Execute call reports value=start on the same frame).
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(0.2f, value);
}
[Fact]
public void AdvanceAll_LinearInterpolation_MidpointIsExact()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
mgr.AdvanceAll(0.5f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(0.5f, value, 5);
}
[Fact]
public void AdvanceAll_AsymmetricRange_LinearAtQuarterDuration()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 1.0f, time: 4f);
mgr.AdvanceAll(1f); // t = 0.25
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(0.2f + (1.0f - 0.2f) * 0.25f, value, 5);
}
[Fact]
public void AdvanceAll_Overshoot_CommitsExactEndValue()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.267f);
mgr.AdvanceAll(10f); // massive overshoot past duration
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(1f, value); // bitwise-exact, not approximately-equal
}
[Fact]
public void AdvanceAll_PastCompletion_IsIdempotent()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.1f);
mgr.AdvanceAll(1f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float first));
mgr.AdvanceAll(1f); // ramp already finished — a second advance must not change anything
Assert.True(mgr.TryGetCurrentValue(1, 0, out float second));
Assert.Equal(first, second);
Assert.Equal(1f, second);
}
[Fact]
public void ClearEntity_RemovesActiveAndCommittedState()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out _));
mgr.ClearEntity(1);
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
mgr.AdvanceAll(1f); // must not throw even though the entity is gone
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
}
[Fact]
public void StartPartFade_SecondCallReplacesFirst()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 10f);
mgr.AdvanceAll(1f); // 10% into the first (abandoned) ramp
// Restart with a fresh, shorter ramp — the old target must be abandoned.
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.9f, end: 0f, time: 1f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float atRestart));
Assert.Equal(0.9f, atRestart);
mgr.AdvanceAll(1f); // finishes the SECOND ramp, not the first
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
Assert.Equal(0f, value);
}
[Fact]
public void TryGetCurrentValue_UnknownPart_ReturnsFalse()
{
var mgr = new TranslucencyFadeManager();
Assert.False(mgr.TryGetCurrentValue(entityId: 999, partIndex: 0, out float value));
Assert.Equal(0f, value);
}
[Fact]
public void MultiplePartsOnSameEntity_AdvanceIndependently()
{
var mgr = new TranslucencyFadeManager();
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
mgr.StartPartFade(entityId: 1, partIndex: 1, start: 0f, end: 1f, time: 2f);
mgr.AdvanceAll(1f);
Assert.True(mgr.TryGetCurrentValue(1, 0, out float part0));
Assert.True(mgr.TryGetCurrentValue(1, 1, out float part1));
Assert.Equal(1f, part0); // part 0's 1s ramp is done
Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway
}
}