198 lines
7.1 KiB
C#
198 lines
7.1 KiB
C#
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();
|
|
|
|
private ulong _revision = 1;
|
|
|
|
/// <summary>
|
|
/// Advances whenever the committed per-part opacity topology changes.
|
|
/// Render products may use this to retain classification while no fade
|
|
/// state changes, and to rebuild exact caster membership when it does.
|
|
/// </summary>
|
|
public ulong Revision => _revision;
|
|
|
|
/// <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)
|
|
{
|
|
bool changed = _activeFades.Remove(entityId);
|
|
changed |= _committed.Remove(entityId);
|
|
if (changed)
|
|
AdvanceRevision();
|
|
}
|
|
|
|
private void Commit(uint entityId, uint partIndex, float value)
|
|
{
|
|
if (!_committed.TryGetValue(entityId, out var parts))
|
|
{
|
|
parts = new Dictionary<uint, float>();
|
|
_committed[entityId] = parts;
|
|
}
|
|
|
|
if (parts.TryGetValue(partIndex, out float prior)
|
|
&& BitConverter.SingleToInt32Bits(prior)
|
|
== BitConverter.SingleToInt32Bits(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
parts[partIndex] = value;
|
|
AdvanceRevision();
|
|
}
|
|
|
|
private void AdvanceRevision()
|
|
{
|
|
if (_revision == ulong.MaxValue)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Translucency fade revision space was exhausted.");
|
|
}
|
|
|
|
_revision++;
|
|
}
|
|
}
|