acdream/src/AcDream.Core/Vfx/ParticleSystem.cs
Erik b26f84cc69 fix(ui): restore radar, retail wield switching, and protection meshes
Late-bind radar snapshots to the canonical LiveEntityRuntime maps so the retained radar survives bootstrap and session replacement instead of capturing empty sentinels.

Route paperdoll drops through the retail AutoWield blocker transaction. Move conflicting held weapons, shields, explicit jewelry destinations, and mismatched ammo to the backpack one authoritative confirmation at a time; preserve compatible arrows when switching to melee.

Render mode-1 and no-degrade particle GfxObjs as their authored modern-pipeline meshes, retain Always2D billboards, interleave both paths back-to-front, balance emitter mesh ownership, and fail safely on corrupt DAT material metadata. This restores the closed apex on Armor Self/protection effects.

Retain Studio fixture controller lifetimes, add installed-DAT and adversarial regression coverage, synchronize retail research/divergence bookkeeping, and pass all three review tracks plus the full Release suite.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 20:52:45 +02:00

707 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// Runtime particle orchestrator. The data and update rules are a direct
/// port of retail's <c>ParticleEmitterInfo</c>, <c>ParticleEmitter</c>, and
/// <c>Particle::Update</c> paths from the named retail decompilation.
/// </summary>
public sealed class ParticleSystem : IParticleSystem
{
private readonly EmitterDescRegistry _registry;
private readonly Random _rng;
private readonly Dictionary<int, ParticleEmitter> _byHandle = new();
private readonly List<int> _handleOrder = new();
private int _nextHandle = 1;
private float _time;
private int _activeParticleCount;
public ParticleSystem(EmitterDescRegistry registry, Random? rng = null)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_rng = rng ?? Random.Shared;
}
public int ActiveEmitterCount => _byHandle.Count;
public int ActiveParticleCount => _activeParticleCount;
public int SpawnEmitter(
EmitterDesc desc,
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
{
ArgumentNullException.ThrowIfNull(desc);
int handle = _nextHandle++;
var emitter = new ParticleEmitter
{
Handle = handle,
Desc = desc,
AnchorPos = anchor,
AnchorRot = rot ?? Quaternion.Identity,
AttachedObjectId = attachedObjectId,
AttachedPartIndex = attachedPartIndex,
RenderPass = renderPass,
Particles = new Particle[Math.Max(1, desc.MaxParticles)],
StartedAt = _time,
LastEmitTime = _time,
LastEmitOffset = anchor,
};
_byHandle[handle] = emitter;
_handleOrder.Add(handle);
for (int i = 0; i < desc.InitialParticles; i++)
SpawnOne(emitter, allowWhenFull: false);
return handle;
}
public int SpawnEmitterById(
uint emitterId,
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
{
var desc = _registry.Get(emitterId);
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
}
public bool TrySpawnEmitterById(
uint emitterId,
Vector3 anchor,
Quaternion? rot,
uint attachedObjectId,
int attachedPartIndex,
ParticleRenderPass renderPass,
out int handle)
{
if (!_registry.TryGet(emitterId, out EmitterDesc? desc))
{
handle = 0;
return false;
}
handle = SpawnEmitter(
desc,
anchor,
rot,
attachedObjectId,
attachedPartIndex,
renderPass);
return true;
}
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
{
// Full PhysicsScript scheduling lives in PhysicsScriptRunner.
}
public void StopEmitter(int handle, bool fadeOut)
{
if (!_byHandle.TryGetValue(handle, out var em))
return;
em.Finished = true;
if (!fadeOut)
{
for (int i = 0; i < em.Particles.Length; i++)
em.Particles[i].Alive = false;
// Retail DestroyParticleEmitter removes the table entry now; it
// does not wait for the next update. This is also required for a
// hard-stopped emitter whose cell-less simulation is paused.
_byHandle.Remove(handle);
_handleOrder.Remove(handle);
EmitterDied?.Invoke(handle);
}
}
/// <summary>
/// Refresh an active emitter's world anchor + orientation. Required for
/// retail's <c>is_parent_local=1</c> (acdream's
/// <see cref="EmitterFlags.AttachLocal"/>) semantics: retail
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c> reads the
/// LIVE parent frame each tick when <c>is_parent_local != 0</c>. The
/// caller (typically a tick loop tracking a moving parent — the camera
/// for sky-PES, an entity for animation hooks) drives this every frame.
/// </summary>
public void UpdateEmitterAnchor(int handle, Vector3 anchor, Quaternion? rot = null)
{
if (!_byHandle.TryGetValue(handle, out var em))
return;
em.AnchorPos = anchor;
if (!em.SimulationEnabled)
em.LastEmitOffset = anchor;
if (rot.HasValue)
em.AnchorRot = rot.Value;
}
/// <summary>
/// Changes only render presentation. Logical lifetime is unaffected.
/// </summary>
public void SetEmitterPresentationVisible(int handle, bool visible)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.PresentationVisible = visible;
}
/// <summary>
/// Applies retail's in-cell update gate without ending emitter ownership.
/// Retail retains absolute creation timestamps while cell-less; the next
/// update observes the elapsed wall-clock interval and expires old state.
/// Only acdream's legacy rate accumulator is rebased to prevent a synthetic
/// multi-particle catch-up burst that retail's one-shot emission path lacks.
/// </summary>
public void SetEmitterSimulationEnabled(int handle, bool enabled)
{
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|| emitter.SimulationEnabled == enabled)
{
return;
}
if (!enabled)
{
emitter.SimulationEnabled = false;
return;
}
if (emitter.Desc.Birthrate <= 0f && emitter.Desc.EmitRate > 0f)
{
emitter.LastEmitTime = _time;
emitter.EmittedAccumulator = 0f;
}
emitter.SimulationEnabled = true;
}
/// <summary>True when the given handle still maps to a live emitter.</summary>
public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle);
/// <summary>
/// Fired exactly once per emitter when it is removed from the live set
/// (either because it finished naturally or was stopped without fade).
/// Subscribers (e.g. <see cref="ParticleHookSink"/>) use this to prune
/// per-entity handle tracking so the per-entity bag doesn't grow without
/// bound during a long session.
/// </summary>
public event Action<int>? EmitterDied;
public void Tick(float dt)
{
if (dt <= 0f)
return;
_time += dt;
_activeParticleCount = 0;
for (int i = 0; i < _handleOrder.Count; i++)
{
int handle = _handleOrder[i];
if (!_byHandle.TryGetValue(handle, out var em))
continue;
if (!em.SimulationEnabled)
continue;
AdvanceEmitter(em);
int live = CountAlive(em);
em.ActiveCount = live;
_activeParticleCount += live;
if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration)
em.Finished = true;
if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles)
em.Finished = true;
if (em.Finished && live == 0)
{
_byHandle.Remove(handle);
_handleOrder.RemoveAt(i);
i--;
EmitterDied?.Invoke(handle);
}
}
}
/// <summary>
/// Enumerate every live particle across every active emitter as
/// (emitter, particle-index) pairs, in emitter-spawn order.
///
/// <para>
/// MP-Alloc (2026-07-05): this used to be a C# iterator block (a
/// compiler-generated heap-allocated state machine, `yield return`),
/// allocated fresh on every call. <see cref="ParticleRenderer.Draw"/>
/// calls this once per pass and there are up to ~11 passes per frame
/// (sky pre/post, scene, per-visible-cell, dynamics, unattached), so
/// this was 11 iterator allocations per frame even with zero particles
/// on screen. Returns a <see cref="LiveParticleEnumerable"/> struct
/// instead: `foreach` over it uses the struct enumerator directly (no
/// allocation), while LINQ / test callers that need
/// <see cref="IEnumerable{T}"/> (`.ToList()`, `.Single()`, etc.) still
/// work via the explicit interface implementation — those call sites
/// are test-only, not the per-frame render path this task targets.
/// </para>
/// </summary>
public LiveParticleEnumerable EnumerateLive() => new(this);
/// <summary>
/// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the
/// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a
/// zero-allocation struct enumerator; falls back to a boxed iterator
/// only when consumed through the <see cref="IEnumerable{T}"/> surface
/// (LINQ, test helpers).
/// </summary>
public readonly struct LiveParticleEnumerable : IEnumerable<(ParticleEmitter Emitter, int Index)>
{
private readonly ParticleSystem _owner;
internal LiveParticleEnumerable(ParticleSystem owner) => _owner = owner;
public Enumerator GetEnumerator() => new(_owner);
IEnumerator<(ParticleEmitter Emitter, int Index)> IEnumerable<(ParticleEmitter Emitter, int Index)>.GetEnumerator()
=> EnumerateLiveBoxed(_owner).GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> EnumerateLiveBoxed(_owner).GetEnumerator();
private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner)
{
foreach (var handle in owner._handleOrder)
{
if (!owner._byHandle.TryGetValue(handle, out var em))
continue;
for (int i = 0; i < em.Particles.Length; i++)
{
if (em.Particles[i].Alive)
yield return (em, i);
}
}
}
/// <summary>Zero-allocation struct enumerator for the `foreach` fast path.</summary>
public struct Enumerator
{
private readonly ParticleSystem _owner;
private int _handleIdx;
private ParticleEmitter? _currentEmitter;
private int _particleIdx;
internal Enumerator(ParticleSystem owner)
{
_owner = owner;
_handleIdx = -1;
_currentEmitter = null;
_particleIdx = -1;
}
public (ParticleEmitter Emitter, int Index) Current => (_currentEmitter!, _particleIdx);
public bool MoveNext()
{
while (true)
{
if (_currentEmitter is not null)
{
for (_particleIdx++; _particleIdx < _currentEmitter.Particles.Length; _particleIdx++)
{
if (_currentEmitter.Particles[_particleIdx].Alive)
return true;
}
_currentEmitter = null;
}
_handleIdx++;
if (_handleIdx >= _owner._handleOrder.Count)
return false;
if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em))
continue;
_currentEmitter = em;
_particleIdx = -1;
}
}
}
}
private void AdvanceEmitter(ParticleEmitter em)
{
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
if (!p.Alive)
continue;
p.Age = _time - p.SpawnedAt;
if (p.Lifetime <= 0f || p.Age >= p.Lifetime)
{
p.Alive = false;
continue;
}
p.Position = ComputePosition(em, p);
float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f);
p.Size = Lerp(p.StartSize, p.EndSize, tLife);
p.Rotation = Lerp(em.Desc.StartRotation, em.Desc.EndRotation, tLife);
float alpha = Lerp(p.StartAlpha, p.EndAlpha, tLife);
p.ColorArgb = Color32(alpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, tLife);
}
if (em.Finished || _time < em.StartedAt + em.Desc.StartDelay)
return;
while (ShouldEmitParticle(em))
{
if (!SpawnOne(em, allowWhenFull: false))
break;
}
if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f)
{
float dt = _time - em.LastEmitTime;
em.EmittedAccumulator += dt * em.Desc.EmitRate;
em.LastEmitTime = _time;
while (em.EmittedAccumulator >= 1f)
{
em.EmittedAccumulator -= 1f;
if (!SpawnOne(em, allowWhenFull: false))
break;
}
}
}
private bool ShouldEmitParticle(ParticleEmitter em)
{
var desc = em.Desc;
if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles)
return false;
if (CountAlive(em) >= desc.MaxParticles)
return false;
if (desc.Birthrate <= 0f)
return false;
return desc.EmitterKind switch
{
ParticleEmitterKind.BirthratePerSec => (_time - em.LastEmitTime) > desc.Birthrate,
ParticleEmitterKind.BirthratePerMeter =>
Vector3.DistanceSquared(em.AnchorPos, em.LastEmitOffset) > desc.Birthrate * desc.Birthrate,
_ => false,
};
}
private bool SpawnOne(ParticleEmitter em, bool allowWhenFull)
{
int slot = FindFreeSlot(em);
if (slot < 0 && allowWhenFull)
slot = FindOldestSlot(em);
if (slot < 0)
return false;
ref var particle = ref em.Particles[slot];
particle = default;
particle.Alive = true;
particle.SpawnedAt = _time;
particle.Lifetime = RandomLifespan(em.Desc);
particle.EmissionOrigin = em.AnchorPos;
particle.SpawnRotation = em.AnchorRot;
Vector3 localOffset = RandomOffset(em.Desc);
Vector3 localA = RandomVector(em.Desc.A, em.Desc.MinA, em.Desc.MaxA);
Vector3 localB = RandomVector(em.Desc.B, em.Desc.MinB, em.Desc.MaxB);
Vector3 localC = RandomVector(em.Desc.C, em.Desc.MinC, em.Desc.MaxC);
if (localA == Vector3.Zero && em.Desc.InitialVelocity != Vector3.Zero)
{
localA = em.Desc.InitialVelocity;
if (em.Desc.VelocityJitter > 0f)
{
localA += new Vector3(
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter),
RandomCentered(em.Desc.VelocityJitter));
}
}
if (localB == Vector3.Zero && em.Desc.Gravity != Vector3.Zero)
localB = em.Desc.Gravity;
InitParticleVectors(em, ref particle, localOffset, localA, localB, localC);
particle.Velocity = particle.A;
particle.StartSize = RandomScale(em.Desc.StartSize, em.Desc.ScaleRand);
particle.EndSize = RandomScale(em.Desc.EndSize, em.Desc.ScaleRand);
particle.StartAlpha = RandomTrans(em.Desc.StartAlpha, em.Desc.TransRand);
particle.EndAlpha = RandomTrans(em.Desc.EndAlpha, em.Desc.TransRand);
particle.Size = particle.StartSize;
particle.ColorArgb = Color32(particle.StartAlpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, 0f);
particle.Position = ComputePosition(em, particle);
em.TotalEmitted++;
em.LastEmitTime = _time;
em.LastEmitOffset = em.AnchorPos;
return true;
}
private Vector3 ComputePosition(ParticleEmitter em, Particle p)
{
float t = p.Age;
Vector3 origin = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
? em.AnchorPos
: p.EmissionOrigin;
Vector3 offset = p.Offset;
Vector3 a = p.A;
Vector3 b = p.B;
Vector3 c = p.C;
return em.Desc.Type switch
{
ParticleType.Still => origin + offset,
ParticleType.LocalVelocity or ParticleType.GlobalVelocity =>
origin + offset + t * a,
ParticleType.ParabolicLVGA or ParticleType.ParabolicLVLA or ParticleType.ParabolicGVGA =>
origin + offset + t * a + 0.5f * t * t * b,
ParticleType.ParabolicLVGAGR or ParticleType.ParabolicLVLALR or ParticleType.ParabolicGVGAGR =>
origin + offset + t * a + 0.5f * t * t * b,
ParticleType.Swarm =>
origin + offset + t * a + new Vector3(
MathF.Cos(t * b.X) * c.X,
MathF.Sin(t * b.Y) * c.Y,
MathF.Cos(t * b.Z) * c.Z),
ParticleType.Explode =>
origin + offset + new Vector3(
(t * b.X + c.X * a.X) * t,
(t * b.Y + c.Y * a.X) * t,
(t * b.Z + c.Z * a.X + a.Z) * t),
ParticleType.Implode =>
origin + offset + MathF.Cos(a.X * t) * c + t * t * b,
_ => origin + offset + t * a,
};
}
private void InitParticleVectors(
ParticleEmitter em,
ref Particle particle,
Vector3 localOffset,
Vector3 localA,
Vector3 localB,
Vector3 localC)
{
// Retail Particle::Init 0x0051c930 resolves local/global vector
// spaces once at spawn; Particle::Update 0x0051c290 then integrates
// those stored world-space coefficients each frame.
particle.Offset = ToSpawnWorld(em, localOffset);
particle.A = localA;
particle.B = localB;
particle.C = localC;
switch (em.Desc.Type)
{
case ParticleType.LocalVelocity:
case ParticleType.ParabolicLVGA:
particle.A = ToSpawnWorld(em, localA);
break;
case ParticleType.ParabolicLVLA:
particle.A = ToSpawnWorld(em, localA);
particle.B = ToSpawnWorld(em, localB);
break;
case ParticleType.ParabolicLVGAGR:
particle.A = ToSpawnWorld(em, localA);
particle.C = localC;
break;
case ParticleType.Swarm:
particle.A = ToSpawnWorld(em, localA);
break;
case ParticleType.Explode:
particle.A = localA;
particle.B = localB;
particle.C = RandomExplodeDirection(localC);
break;
case ParticleType.Implode:
particle.A = localA;
particle.B = localB;
particle.Offset = new Vector3(
particle.Offset.X * localC.X,
particle.Offset.Y * localC.Y,
particle.Offset.Z * localC.Z);
particle.C = particle.Offset;
break;
case ParticleType.ParabolicLVLALR:
particle.A = ToSpawnWorld(em, localA);
particle.B = ToSpawnWorld(em, localB);
particle.C = ToSpawnWorld(em, localC);
break;
case ParticleType.ParabolicGVGAGR:
particle.C = localC;
break;
}
}
private static Vector3 ToSpawnWorld(ParticleEmitter em, Vector3 value)
=> em.AnchorRot == Quaternion.Identity ? value : Vector3.Transform(value, em.AnchorRot);
private Vector3 RandomExplodeDirection(Vector3 localC)
{
float yaw = RandomRange(-MathF.PI, MathF.PI);
float pitch = RandomRange(-MathF.PI, MathF.PI);
float cosPitch = MathF.Cos(pitch);
Vector3 c = new(
MathF.Cos(yaw) * localC.X * cosPitch,
MathF.Sin(yaw) * localC.Y * cosPitch,
MathF.Sin(pitch) * localC.Z);
return NormalizeCheckSmall(ref c) ? Vector3.Zero : c;
}
private int FindFreeSlot(ParticleEmitter em)
{
for (int i = 0; i < em.Particles.Length; i++)
{
if (!em.Particles[i].Alive)
return i;
}
return -1;
}
private static int FindOldestSlot(ParticleEmitter em)
{
int slot = -1;
float best = -1f;
for (int i = 0; i < em.Particles.Length; i++)
{
ref var p = ref em.Particles[i];
float r = p.Lifetime > 0f ? p.Age / p.Lifetime : 1f;
if (r > best)
{
best = r;
slot = i;
}
}
return slot;
}
private static int CountAlive(ParticleEmitter em)
{
int n = 0;
for (int i = 0; i < em.Particles.Length; i++)
{
if (em.Particles[i].Alive)
n++;
}
return n;
}
private float RandomLifespan(EmitterDesc desc)
{
float lifespan = desc.Lifespan > 0f ? desc.Lifespan : (desc.LifetimeMin + desc.LifetimeMax) * 0.5f;
float rand = desc.LifespanRand > 0f ? desc.LifespanRand : MathF.Abs(desc.LifetimeMax - desc.LifetimeMin) * 0.5f;
float value = lifespan + RandomCentered(rand);
if (value <= 0f && desc.LifetimeMax > 0f)
value = Lerp(desc.LifetimeMin, desc.LifetimeMax, (float)_rng.NextDouble());
return MathF.Max(0f, value);
}
private Vector3 RandomOffset(EmitterDesc desc)
{
float min = MathF.Min(desc.MinOffset, desc.MaxOffset);
float max = MathF.Max(desc.MinOffset, desc.MaxOffset);
if (max <= 0f)
return Vector3.Zero;
Vector3 axis = NormalizeOrZero(desc.OffsetDir);
Vector3 v = new(
RandomCentered(1f),
RandomCentered(1f),
RandomCentered(1f));
if (axis != Vector3.Zero)
v -= axis * Vector3.Dot(v, axis);
if (v.LengthSquared() < 1e-8f)
v = axis != Vector3.Zero ? Perpendicular(axis) : Vector3.UnitX;
else
v = Vector3.Normalize(v);
return v * Lerp(min, max, (float)_rng.NextDouble());
}
private Vector3 RandomVector(Vector3 direction, float min, float max)
{
if (direction == Vector3.Zero)
return Vector3.Zero;
if (max < min)
(min, max) = (max, min);
return direction * Lerp(min, max, (float)_rng.NextDouble());
}
private float RandomScale(float baseValue, float rand)
=> Math.Clamp(baseValue + RandomCentered(rand), 0.1f, 10f);
private float RandomTrans(float baseValue, float rand)
=> Math.Clamp(baseValue + RandomCentered(rand), 0f, 1f);
private float RandomCentered(float halfWidth)
=> ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth;
private float RandomRange(float min, float max)
=> Lerp(min, max, (float)_rng.NextDouble());
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
private static Vector3 NormalizeOrZero(Vector3 v)
=> v.LengthSquared() > 1e-8f ? Vector3.Normalize(v) : Vector3.Zero;
private static bool NormalizeCheckSmall(ref Vector3 v)
{
float length = v.Length();
if (length < 1e-8f)
return true;
v /= length;
return false;
}
private static Vector3 Perpendicular(Vector3 v)
{
Vector3 basis = MathF.Abs(v.X) < 0.9f ? Vector3.UnitX : Vector3.UnitY;
return Vector3.Normalize(Vector3.Cross(v, basis));
}
private static uint Color32(float alpha, uint startArgb, uint endArgb, float t)
{
byte sr = (byte)((startArgb >> 16) & 0xFF);
byte sg = (byte)((startArgb >> 8) & 0xFF);
byte sb = (byte)(startArgb & 0xFF);
byte er = (byte)((endArgb >> 16) & 0xFF);
byte eg = (byte)((endArgb >> 8) & 0xFF);
byte eb = (byte)(endArgb & 0xFF);
byte r = (byte)Math.Clamp(sr + (er - sr) * t, 0f, 255f);
byte g = (byte)Math.Clamp(sg + (eg - sg) * t, 0f, 255f);
byte b = (byte)Math.Clamp(sb + (eb - sb) * t, 0f, 255f);
byte a = (byte)Math.Clamp(alpha * 255f, 0f, 255f);
return ((uint)a << 24) | ((uint)r << 16) | ((uint)g << 8) | b;
}
}