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:
parent
e2e285b855
commit
3284dd0aed
12 changed files with 625 additions and 14 deletions
|
|
@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
|
|||
/// (animation-hook frame = damage frame for melee / thrown).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="TransparentPartHook"/> → #188
|
||||
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
|
||||
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ReplaceObjectHook"/>,
|
||||
/// <see cref="TransparentHook"/>,
|
||||
/// <see cref="TransparentHook"/> (whole-object variant — NOT yet
|
||||
/// ported, #188 scope was the per-part case only),
|
||||
/// <see cref="LuminousHook"/>,
|
||||
/// <see cref="DiffuseHook"/>,
|
||||
/// <see cref="ScaleHook"/>,
|
||||
|
|
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
|
|||
/// <see cref="SetOmegaHook"/>,
|
||||
/// <see cref="TextureVelocityHook"/>,
|
||||
/// <see cref="SetLightHook"/> →
|
||||
/// GfxObjMesh / renderer state mutations on the target entity.
|
||||
/// GfxObjMesh / renderer state mutations on the target entity
|
||||
/// (<see cref="SetLightHook"/> is the one exception already wired,
|
||||
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
||||
|
|
|
|||
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
|
||||
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
|
||||
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
|
||||
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
|
||||
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
|
||||
/// <c>Time</c> seconds, ticked every frame rather than retail's per-object
|
||||
/// physics tick (behaviorally identical — both re-evaluate the same
|
||||
/// elapsed/duration ratio every simulated step).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
|
||||
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
|
||||
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
|
||||
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
|
||||
/// multiplier at the point of use.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
|
||||
/// ~0.2 ms, the target value is applied immediately with no ramp
|
||||
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
|
||||
/// is that exact retail constant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyFadeManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
|
||||
/// 0x00511730): a hook <c>Time</c> at or below this is applied
|
||||
/// instantly, no ramp.
|
||||
/// </summary>
|
||||
public const float TranslucencyInstantEpsilon = 0.000199999995f;
|
||||
|
||||
private sealed class Fade
|
||||
{
|
||||
public float Elapsed;
|
||||
public float Duration;
|
||||
public float Start;
|
||||
public float End;
|
||||
}
|
||||
|
||||
// entityId -> partIndex -> in-flight ramp.
|
||||
private readonly Dictionary<uint, Dictionary<uint, Fade>> _activeFades = new();
|
||||
|
||||
// entityId -> partIndex -> current committed translucency value
|
||||
// ([0,1], retail scale). Persists after a fade completes so later
|
||||
// frames keep reading the settled value.
|
||||
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
|
||||
|
||||
/// <summary>
|
||||
/// Start (or replace) a translucency ramp for one Setup part of one
|
||||
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
|
||||
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
|
||||
/// immediately; otherwise the value ramps from
|
||||
/// <paramref name="start"/> to <paramref name="end"/> across
|
||||
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
|
||||
/// </summary>
|
||||
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
|
||||
{
|
||||
if (time <= TranslucencyInstantEpsilon)
|
||||
{
|
||||
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
|
||||
activeForEntity.Remove(partIndex);
|
||||
Commit(entityId, partIndex, end);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_activeFades.TryGetValue(entityId, out var fades))
|
||||
{
|
||||
fades = new Dictionary<uint, Fade>();
|
||||
_activeFades[entityId] = fades;
|
||||
}
|
||||
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
|
||||
|
||||
// FPHook's first Execute call (at elapsed=0) already reports
|
||||
// value=start — commit it now so a reader on the same frame the
|
||||
// hook fired sees the ramp's starting value, not the stale prior one.
|
||||
Commit(entityId, partIndex, start);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every in-flight fade by <paramref name="dt"/> seconds
|
||||
/// (plain linear interpolation, no easing — matches
|
||||
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
|
||||
/// the bitwise-exact <c>End</c> value and stops advancing (retail
|
||||
/// deletes the FPHook at that point); the committed value stays
|
||||
/// readable via <see cref="TryGetCurrentValue"/>.
|
||||
/// </summary>
|
||||
public void AdvanceAll(float dt)
|
||||
{
|
||||
if (_activeFades.Count == 0) return;
|
||||
|
||||
List<uint>? emptyEntities = null;
|
||||
foreach (var (entityId, fades) in _activeFades)
|
||||
{
|
||||
List<uint>? finished = null;
|
||||
foreach (var (partIndex, fade) in fades)
|
||||
{
|
||||
fade.Elapsed += dt;
|
||||
float t = fade.Duration <= TranslucencyInstantEpsilon
|
||||
? 1f
|
||||
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
|
||||
|
||||
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
|
||||
Commit(entityId, partIndex, value);
|
||||
|
||||
if (t >= 1f)
|
||||
{
|
||||
finished ??= new List<uint>();
|
||||
finished.Add(partIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (finished is not null)
|
||||
{
|
||||
foreach (var partIndex in finished)
|
||||
fades.Remove(partIndex);
|
||||
if (fades.Count == 0)
|
||||
{
|
||||
emptyEntities ??= new List<uint>();
|
||||
emptyEntities.Add(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyEntities is not null)
|
||||
foreach (var entityId in emptyEntities)
|
||||
_activeFades.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current committed translucency value ([0,1], retail
|
||||
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
|
||||
/// if this part has never had a fade start (caller should fall back
|
||||
/// to the dat-authored default — a no-op).
|
||||
/// </summary>
|
||||
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
|
||||
{
|
||||
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
|
||||
return true;
|
||||
value = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
|
||||
public void ClearEntity(uint entityId)
|
||||
{
|
||||
_activeFades.Remove(entityId);
|
||||
_committed.Remove(entityId);
|
||||
}
|
||||
|
||||
private void Commit(uint entityId, uint partIndex, float value)
|
||||
{
|
||||
if (!_committed.TryGetValue(entityId, out var parts))
|
||||
{
|
||||
parts = new Dictionary<uint, float>();
|
||||
_committed[entityId] = parts;
|
||||
}
|
||||
parts[partIndex] = value;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
|
||||
/// <see cref="TranslucencyFadeManager"/>. Retail's
|
||||
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
|
||||
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
|
||||
/// this sink is that same one-line forward.
|
||||
///
|
||||
/// <para>
|
||||
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
|
||||
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
|
||||
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
|
||||
/// state, zero rendering coupling — and acdream's collision-passthrough
|
||||
/// already applies server-authoritative via the <c>SetState</c> wire
|
||||
/// message, independent of this hook firing client-side.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly TranslucencyFadeManager _fades;
|
||||
|
||||
public TranslucencyHookSink(TranslucencyFadeManager fades)
|
||||
{
|
||||
_fades = fades ?? throw new ArgumentNullException(nameof(fades));
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
if (hook is not TransparentPartHook tph) return;
|
||||
_fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue