using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Vfx; /// /// Runtime particle orchestrator. The data and update rules are a direct /// port of retail's ParticleEmitterInfo, ParticleEmitter, and /// Particle::Update paths from the named retail decompilation. /// public sealed class ParticleSystem : IParticleSystem { private readonly EmitterDescRegistry _registry; private readonly Random _rng; private readonly Dictionary _byHandle = new(); private readonly List _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 { 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 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; } } /// /// Refresh an active emitter's world anchor + orientation. Required for /// retail's is_parent_local=1 (acdream's /// ) semantics: retail /// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the /// LIVE parent frame each tick when is_parent_local != 0. The /// caller (typically a tick loop tracking a moving parent — the camera /// for sky-PES, an entity for animation hooks) drives this every frame. /// public void UpdateEmitterAnchor(int handle, Vector3 anchor, Quaternion? rot = null) { if (!_byHandle.TryGetValue(handle, out var em)) return; em.AnchorPos = anchor; if (rot.HasValue) em.AnchorRot = rot.Value; } /// True when the given handle still maps to a live emitter. public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle); /// /// 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. ) use this to prune /// per-entity handle tracking so the per-entity bag doesn't grow without /// bound during a long session. /// public event Action? 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; 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); } } } public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive() { foreach (var handle in _handleOrder) { if (!_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); } } } 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; } }