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, ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World) { ArgumentNullException.ThrowIfNull(desc); int handle = _nextHandle++; var emitter = new ParticleEmitter { Handle = handle, Desc = desc, AnchorPos = anchor, OwnerPosition = anchor, AnchorRot = rot ?? Quaternion.Identity, AttachedObjectId = attachedObjectId, AttachedPartIndex = attachedPartIndex, RenderPass = renderPass, VisibilityPolicy = visibilityPolicy, 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, ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World) { var desc = _registry.Get(emitterId); return SpawnEmitter( desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass, visibilityPolicy); } public bool TrySpawnEmitterById( uint emitterId, Vector3 anchor, Quaternion? rot, uint attachedObjectId, int attachedPartIndex, ParticleRenderPass renderPass, ParticleVisibilityPolicy visibilityPolicy, out int handle) { if (!_registry.TryGet(emitterId, out EmitterDesc? desc)) { handle = 0; return false; } handle = SpawnEmitter( desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass, visibilityPolicy); 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; em.ActiveCount = 0; // 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); } } /// /// 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 (!em.SimulationEnabled) em.LastEmitOffset = anchor; if (rot.HasValue) em.AnchorRot = rot.Value; } /// /// Refreshes the owning physics object's root used by retail's particle /// degradation-distance check. This remains distinct from an animated /// part's emitter anchor. /// public void UpdateEmitterOwnerPosition(int handle, Vector3 ownerPosition) { if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) emitter.OwnerPosition = ownerPosition; } public void UpdateEmitterOwnerCell(int handle, uint ownerCellId) { if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) emitter.OwnerCellId = ownerCellId; } public void SetEmitterVisibilityPolicy( int handle, ParticleVisibilityPolicy visibilityPolicy) { if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) emitter.VisibilityPolicy = visibilityPolicy; } /// /// Applies CPhysicsObj::ShouldDrawParticles (0x0050FE60) to every /// live emitter. The App layer supplies the previous completed retail /// PView's cell set, equivalent to CObjCell::IsInView when the next /// physics update runs. /// public void ApplyRetailView( Vector3 viewerPosition, IReadOnlySet visibleCellIds, bool hasCompletedView, float rangeMultiplier = 1f) { ArgumentNullException.ThrowIfNull(visibleCellIds); if (!float.IsFinite(rangeMultiplier) || rangeMultiplier <= 0f) rangeMultiplier = 1f; foreach (int handle in _handleOrder) { if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) continue; if (emitter.VisibilityPolicy != ParticleVisibilityPolicy.World) { emitter.ViewEligible = true; continue; } if (!hasCompletedView) { // With no completed world PView (portal space, login, or a // reset frame), a world cell cannot report IsInView. emitter.ViewEligible = false; continue; } float maxDistance = emitter.Desc.MaxDegradeDistance * rangeMultiplier; float distance = RetailDistance(emitter.OwnerPosition, viewerPosition); emitter.ViewEligible = emitter.OwnerCellId != 0 && visibleCellIds.Contains(emitter.OwnerCellId) // The x87 comparison in ShouldDrawParticles admits unordered // comparisons (NaN) and reject a negative authored range. && (float.IsNaN(distance) || float.IsNaN(maxDistance) || distance <= maxDistance); } } /// /// Changes only render presentation. Logical lifetime is unaffected. /// public void SetEmitterPresentationVisible(int handle, bool visible) { if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) emitter.PresentationVisible = visible; } /// /// 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. /// 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; } /// 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; if (!em.SimulationEnabled) continue; bool wasFinishedBeforeUpdate = em.Finished; if (em.ViewEligible) { em.DegradedOut = false; AdvanceEmitter(em); } else { em.DegradedOut = true; AdvanceDegradedEmitter(em, dt); } int live = em.ActiveCount; _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; // UpdateParticles returns true on the exact tick StopEmitter // first changes state. Only the next update's already-stopped // branch may return num_particles == 0 and retire the emitter. if (em.Finished && live == 0 && wasFinishedBeforeUpdate) { _byHandle.Remove(handle); _handleOrder.RemoveAt(i); i--; EmitterDied?.Invoke(handle); } } } /// /// Enumerate every live particle across every active emitter as /// (emitter, particle-index) pairs, in emitter-spawn order. /// /// /// 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. /// 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 struct /// instead: `foreach` over it uses the struct enumerator directly (no /// allocation), while LINQ / test callers that need /// (`.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. /// /// public LiveParticleEnumerable EnumerateLive() => new(this); /// /// Enumerates live emitters in spawn order. The renderer consumes this /// before particle slots so pass/visibility rejection is O(emitters), not /// O(all particles in every pass). /// public LiveEmitterEnumerable EnumerateEmitters() => new(this); public readonly struct LiveEmitterEnumerable : IEnumerable { private readonly ParticleSystem _owner; internal LiveEmitterEnumerable(ParticleSystem owner) => _owner = owner; public Enumerator GetEnumerator() => new(_owner); IEnumerator IEnumerable.GetEnumerator() => EnumerateBoxed(_owner).GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => EnumerateBoxed(_owner).GetEnumerator(); private static IEnumerable EnumerateBoxed(ParticleSystem owner) { foreach (int handle in owner._handleOrder) { if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter)) yield return emitter; } } public struct Enumerator { private readonly ParticleSystem _owner; private int _index; internal Enumerator(ParticleSystem owner) { _owner = owner; _index = -1; Current = null!; } public ParticleEmitter Current { get; private set; } public bool MoveNext() { while (++_index < _owner._handleOrder.Count) { if (_owner._byHandle.TryGetValue( _owner._handleOrder[_index], out ParticleEmitter? emitter)) { Current = emitter; return true; } } return false; } } } /// /// Struct enumerable returned by . Wraps the /// owning so foreach gets a /// zero-allocation struct enumerator; falls back to a boxed iterator /// only when consumed through the surface /// (LINQ, test helpers). /// 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); } } } /// Zero-allocation struct enumerator for the `foreach` fast path. 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 = EffectiveParticleAge(em, p); if (p.Lifetime <= 0f || p.Age >= p.Lifetime) { p.Alive = false; em.ActiveCount--; 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; } } } /// /// Retail ParticleEmitter::UpdateParticles (0x0051D180) /// degraded branch. Infinite emitters freeze particle age without normal /// updates or emissions. Finite emitters age/retire existing particles, /// record at most one due emission without creating a drawable particle, /// then run retail's conditional stop test. /// private void AdvanceDegradedEmitter(ParticleEmitter em, float dt) { bool infinite = em.Desc.TotalParticles == 0 && em.Desc.TotalDuration == 0f; if (infinite) { em.FrozenTime += dt; // Callback-created descriptors sometimes use the legacy EmitRate // representation. Rebase that accumulator so it cannot synthesize // a catch-up burst after degradation; retail performs no backlog. if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f) { em.LastEmitTime = _time; em.EmittedAccumulator = 0f; } return; } for (int i = 0; i < em.Particles.Length; i++) { ref Particle particle = ref em.Particles[i]; if (!particle.Alive) continue; particle.Age = _time - particle.SpawnedAt; if (particle.Lifetime <= 0f || particle.Age >= particle.Lifetime) { particle.Alive = false; em.ActiveCount--; } } if (!em.Finished && ShouldEmitParticle(em)) { // ParticleEmitter::RecordParticleEmission (0x0051C870) advances // both num_particles and total_emitted while degraded, but no // PhysicsPart is made visible. em.ActiveCount++; em.TotalEmitted++; em.LastEmitTime = _time; em.LastEmitOffset = em.AnchorPos; } } private bool ShouldEmitParticle(ParticleEmitter em) { var desc = em.Desc; if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles) return false; if (em.ActiveCount >= 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]; bool replacesLiveParticle = particle.Alive; particle = default; particle.Alive = true; particle.SpawnedAt = _time; particle.FrozenTimeAtSpawn = em.FrozenTime; 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++; if (!replacesLiveParticle) em.ActiveCount++; em.LastEmitTime = _time; em.LastEmitOffset = em.AnchorPos; return true; } private float EffectiveParticleAge(ParticleEmitter emitter, in Particle particle) => _time - particle.SpawnedAt - (emitter.FrozenTime - particle.FrozenTimeAtSpawn); 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 float RetailDistance(Vector3 a, Vector3 b) { // Overflow-safe Euclidean length. Vector3.DistanceSquared overflows // for finite coordinates near FLT_MAX, whereas retail compares its // already-computed direct distance against max_dist. float dx = a.X - b.X; float dy = a.Y - b.Y; float dz = a.Z - b.Z; float scale = MathF.Max(MathF.Abs(dx), MathF.Max(MathF.Abs(dy), MathF.Abs(dz))); if (float.IsNaN(scale) || float.IsInfinity(scale) || scale == 0f) return scale; dx /= scale; dy /= scale; dz /= scale; return scale * MathF.Sqrt(dx * dx + dy * dy + dz * dz); } 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; } }