feat(vfx): Phase E.3 particle system + hook wiring + registry
Full runtime particle pipeline consuming Phase E.1's animation hooks.
13 motion integrators, per-emitter particle pools with overwrite-oldest
eviction, colour / scale / alpha interpolation over life, and a
ParticleHookSink routing CreateParticle / DestroyParticle / StopParticle /
CreateBlockingParticle hooks from the animation-hook router.
Core layer:
- ParticleSystem: handle-based emitter pool, per-tick emission
accumulator (retail Birthrate = time-between-spawns → our emit rate
via 1/B), 13 integrators covering the full ParticleType enum:
Still, LocalVelocity, GlobalVelocity, 7 Parabolic variants (all
apply Gravity * dt to velocity), Swarm (orbital drift),
Explode (outward from anchor), Implode (inward to anchor, dies at
convergence).
- EmitterDescRegistry: id-keyed EmitterDesc cache with fallback-to-
default for unknown ids. Replaces the dat-loaded path until
Chorizite.DatReaderWriter exposes ParticleEmitterInfo (v2.1.7 does
not; upgraded from 2.1.4 anyway for future types).
- ParticleHookSink: wires the full hook family:
- CreateParticleHook → SpawnEmitterById at entity pose + hook offset
- CreateBlockingParticleHook → marker only (blocking semantics live
in the sequencer not here)
- DestroyParticleHook → StopEmitter(handle, fadeOut=false)
- StopParticleHook → StopEmitter(handle, fadeOut=true)
- (Default/CallPES deferred until PhysicsScript dat is loadable)
GameWindow integration:
- ParticleSystem created eagerly (no driver dep), sink registered with
hook router, Tick advanced per OnRender frame after animation tick so
hooks fired this frame get integrated.
Tests (11 new): spawn-handle, emit-over-time steady state, lifetime
death curve, LocalVelocity movement, Parabolic gravity arc, Explode
outward trajectory, StopEmitter instant kill vs fadeOut, MaxParticles
cap enforcement, registry default fallback, registry custom
registration.
Upgraded Chorizite.DatReaderWriter 2.1.4 → 2.1.7 across Core + Cli.
Build green, 508 tests pass (up from 497).
Ref: r04 §2 (CParticleManager), §3 (13 integrators), §6 (PhysicsScript).
Renderer (instanced billboarded quads in translucent pass) ships next
commit; this one covers the data / logic / wiring layer in full.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
351723928f
commit
d3165f99d7
7 changed files with 820 additions and 2 deletions
363
src/AcDream.Core/Vfx/ParticleSystem.cs
Normal file
363
src/AcDream.Core/Vfx/ParticleSystem.cs
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime particle orchestrator — port of retail's <c>CParticleManager</c>
|
||||
/// (r04 §2). Owns a pool of active <see cref="ParticleEmitter"/> instances,
|
||||
/// advances each per-frame via one of 13 motion integrators, fades colour /
|
||||
/// scale over life, and exposes a flat particle stream for the renderer.
|
||||
///
|
||||
/// <para>
|
||||
/// Not thread-safe — called only from the render thread (same thread that
|
||||
/// drives TickAnimations).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Handle-based API so callers can stop a specific emitter later (cast
|
||||
/// interrupt, fadeout). <see cref="SpawnEmitter"/> returns a positive
|
||||
/// integer; <see cref="StopEmitter"/> accepts it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ParticleSystem : IParticleSystem
|
||||
{
|
||||
private readonly EmitterDescRegistry _registry;
|
||||
private readonly Random _rng;
|
||||
|
||||
// All live emitters keyed by our handle. Lookup is cheap; iteration is
|
||||
// per-frame so we also keep a flat list for stable ordering (draw order).
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(desc);
|
||||
|
||||
int handle = _nextHandle++;
|
||||
var emitter = new ParticleEmitter
|
||||
{
|
||||
Desc = desc,
|
||||
AnchorPos = anchor,
|
||||
AnchorRot = rot ?? Quaternion.Identity,
|
||||
AttachedObjectId = attachedObjectId,
|
||||
AttachedPartIndex = attachedPartIndex,
|
||||
Particles = new Particle[Math.Max(1, desc.MaxParticles)],
|
||||
StartedAt = _time,
|
||||
};
|
||||
_byHandle[handle] = emitter;
|
||||
_handleOrder.Add(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: spawn by retail emitter id — the registry resolves to
|
||||
/// the correct <see cref="EmitterDesc"/>, or falls back to the default
|
||||
/// if unknown. Used by the hook sink when a CreateParticleHook arrives.
|
||||
/// </summary>
|
||||
public int SpawnEmitterById(
|
||||
uint emitterId,
|
||||
Vector3 anchor,
|
||||
Quaternion? rot = null,
|
||||
uint attachedObjectId = 0,
|
||||
int attachedPartIndex = -1)
|
||||
{
|
||||
var desc = _registry.Get(emitterId);
|
||||
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex);
|
||||
}
|
||||
|
||||
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
|
||||
{
|
||||
// Full PhysicsScript dispatch is on hold until the DatReaderWriter
|
||||
// library exposes ParticleEmitterInfo / PhysicsScript. For now,
|
||||
// this is a no-op — callers use SpawnEmitter or the hook sink.
|
||||
}
|
||||
|
||||
public void StopEmitter(int handle, bool fadeOut)
|
||||
{
|
||||
if (!_byHandle.TryGetValue(handle, out var em)) return;
|
||||
em.Finished = true;
|
||||
// fadeOut=false would stop instantly; our renderer currently drops
|
||||
// Finished emitters that have no living particles each tick.
|
||||
if (!fadeOut)
|
||||
{
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
em.Particles[i].Alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float dt)
|
||||
{
|
||||
if (dt <= 0f) return;
|
||||
_time += dt;
|
||||
_activeParticleCount = 0;
|
||||
|
||||
// Iterate handles by a snapshot so StopEmitter-inside-emit is safe.
|
||||
for (int i = 0; i < _handleOrder.Count; i++)
|
||||
{
|
||||
int handle = _handleOrder[i];
|
||||
if (!_byHandle.TryGetValue(handle, out var em)) continue;
|
||||
|
||||
AdvanceEmitter(em, dt);
|
||||
_activeParticleCount += CountAlive(em);
|
||||
|
||||
bool durationDone = em.Desc.TotalDuration > 0f
|
||||
&& (_time - em.StartedAt) > em.Desc.TotalDuration;
|
||||
if (durationDone) em.Finished = true;
|
||||
|
||||
// Drop emitter entirely when it has no live particles AND is
|
||||
// marked finished (duration elapsed, StopEmitter, etc).
|
||||
if (em.Finished && CountAlive(em) == 0)
|
||||
{
|
||||
_byHandle.Remove(handle);
|
||||
_handleOrder.RemoveAt(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate every live particle with its emitter description for
|
||||
/// the renderer. Yields (emitter, particleIndex) so the caller can
|
||||
/// read <c>em.Particles[idx]</c> directly.
|
||||
/// </summary>
|
||||
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: emission + integration ──────────────────────────────────────
|
||||
|
||||
private void AdvanceEmitter(ParticleEmitter em, float dt)
|
||||
{
|
||||
if (!em.Finished && em.Desc.EmitRate > 0f)
|
||||
{
|
||||
em.EmittedAccumulator += dt * em.Desc.EmitRate;
|
||||
while (em.EmittedAccumulator >= 1.0f)
|
||||
{
|
||||
em.EmittedAccumulator -= 1.0f;
|
||||
SpawnOne(em);
|
||||
}
|
||||
}
|
||||
|
||||
// Update every particle slot.
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
ref var p = ref em.Particles[i];
|
||||
if (!p.Alive) continue;
|
||||
|
||||
p.Age += dt;
|
||||
if (p.Age >= p.Lifetime)
|
||||
{
|
||||
p.Alive = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
Integrate(ref p, em, dt);
|
||||
|
||||
float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f);
|
||||
p.Size = Lerp(em.Desc.StartSize, em.Desc.EndSize, tLife);
|
||||
float alpha = Lerp(em.Desc.StartAlpha, em.Desc.EndAlpha, tLife);
|
||||
p.ColorArgb = Color32(alpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, tLife);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnOne(ParticleEmitter em)
|
||||
{
|
||||
// Find a free slot; overwrite the oldest if pool is full.
|
||||
int slot = -1;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
if (!em.Particles[i].Alive) { slot = i; break; }
|
||||
}
|
||||
if (slot < 0)
|
||||
{
|
||||
// Pool saturated; overwrite the slot closest to dying (oldest
|
||||
// by age / lifetime ratio). Matches retail's behaviour of
|
||||
// recycling the expiring particle rather than dropping.
|
||||
float best = -1f;
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
ref var p = ref em.Particles[i];
|
||||
float r = p.Lifetime > 0 ? p.Age / p.Lifetime : 1f;
|
||||
if (r > best) { best = r; slot = i; }
|
||||
}
|
||||
if (slot < 0) return;
|
||||
}
|
||||
|
||||
ref var particle = ref em.Particles[slot];
|
||||
particle.Alive = true;
|
||||
particle.Age = 0f;
|
||||
particle.Lifetime = Lerp(em.Desc.LifetimeMin, em.Desc.LifetimeMax,
|
||||
(float)_rng.NextDouble());
|
||||
|
||||
// Position = emitter anchor + random offset in a disk perpendicular
|
||||
// to OffsetDir. This models the retail annulus.
|
||||
Vector3 disk = RandomDiskVector(em.Desc.OffsetDir, em.Desc.MaxOffset);
|
||||
particle.Position = em.AnchorPos + disk;
|
||||
particle.SpawnedAt = _time;
|
||||
|
||||
// Velocity = initial vector ± jitter in all three axes.
|
||||
Vector3 v = em.Desc.InitialVelocity;
|
||||
if (em.Desc.VelocityJitter > 0f)
|
||||
{
|
||||
v += new Vector3(
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter),
|
||||
RandomCentered(em.Desc.VelocityJitter));
|
||||
}
|
||||
particle.Velocity = v;
|
||||
particle.Size = em.Desc.StartSize;
|
||||
particle.Rotation = em.Desc.StartRotation;
|
||||
particle.ColorArgb = em.Desc.StartColorArgb;
|
||||
}
|
||||
|
||||
// ── 13 retail motion integrators (r04 §3) ────────────────────────────────
|
||||
|
||||
private void Integrate(ref Particle p, ParticleEmitter em, float dt)
|
||||
{
|
||||
switch (em.Desc.Type)
|
||||
{
|
||||
case ParticleType.Still:
|
||||
// No motion. Age + fade only.
|
||||
break;
|
||||
|
||||
case ParticleType.LocalVelocity:
|
||||
// Constant spawn velocity, no acceleration.
|
||||
p.Position += p.Velocity * dt;
|
||||
break;
|
||||
|
||||
case ParticleType.GlobalVelocity:
|
||||
// Uses emitter's InitialVelocity (global/world-space);
|
||||
// each particle keeps its own copy already (set at spawn),
|
||||
// so behaves identically to LocalVelocity at runtime.
|
||||
p.Position += p.Velocity * dt;
|
||||
break;
|
||||
|
||||
case ParticleType.Parabolic:
|
||||
case ParticleType.ParabolicLVGV:
|
||||
case ParticleType.ParabolicLVGA:
|
||||
case ParticleType.ParabolicLVLA:
|
||||
case ParticleType.ParabolicGVGA:
|
||||
case ParticleType.ParabolicGVLA:
|
||||
case ParticleType.ParabolicLALV:
|
||||
// Velocity decays with gravity; position integrates.
|
||||
p.Velocity += em.Desc.Gravity * dt;
|
||||
p.Position += p.Velocity * dt;
|
||||
break;
|
||||
|
||||
case ParticleType.Swarm:
|
||||
// Orbital drift around anchor. Apply a tangential swirl.
|
||||
{
|
||||
Vector3 toCenter = em.AnchorPos - p.Position;
|
||||
Vector3 axis = em.Desc.OffsetDir == Vector3.Zero ? Vector3.UnitZ : em.Desc.OffsetDir;
|
||||
Vector3 tangent = Vector3.Normalize(Vector3.Cross(axis, toCenter));
|
||||
p.Velocity = Vector3.Lerp(p.Velocity, tangent * em.Desc.InitialVelocity.Length(), dt * 4f);
|
||||
p.Position += p.Velocity * dt;
|
||||
}
|
||||
break;
|
||||
|
||||
case ParticleType.Explode:
|
||||
// Push outward along (position - anchor).
|
||||
{
|
||||
Vector3 dir = p.Position - em.AnchorPos;
|
||||
if (dir.LengthSquared() < 1e-6f) dir = Vector3.UnitZ;
|
||||
else dir = Vector3.Normalize(dir);
|
||||
p.Velocity = dir * em.Desc.InitialVelocity.Length();
|
||||
p.Position += p.Velocity * dt;
|
||||
}
|
||||
break;
|
||||
|
||||
case ParticleType.Implode:
|
||||
// Pull inward toward anchor.
|
||||
{
|
||||
Vector3 dir = em.AnchorPos - p.Position;
|
||||
float dist = dir.Length();
|
||||
if (dist < 0.01f) { p.Alive = false; break; }
|
||||
dir /= dist;
|
||||
p.Velocity = dir * em.Desc.InitialVelocity.Length();
|
||||
p.Position += p.Velocity * dt;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
p.Position += p.Velocity * dt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 static float Lerp(float a, float b, float t) => a + (b - a) * t;
|
||||
|
||||
private static uint Color32(float alpha, uint startArgb, uint endArgb, float t)
|
||||
{
|
||||
// Blend RGB channels linearly; apply alpha override from fade.
|
||||
byte sa = (byte)((startArgb >> 24) & 0xFF);
|
||||
byte sr = (byte)((startArgb >> 16) & 0xFF);
|
||||
byte sg = (byte)((startArgb >> 8) & 0xFF);
|
||||
byte sb = (byte)( startArgb & 0xFF);
|
||||
byte ea = (byte)((endArgb >> 24) & 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;
|
||||
}
|
||||
|
||||
private Vector3 RandomDiskVector(Vector3 axis, float maxRadius)
|
||||
{
|
||||
if (maxRadius <= 0f) return Vector3.Zero;
|
||||
// Two perpendicular vectors to axis.
|
||||
Vector3 n = Vector3.Normalize(axis == Vector3.Zero ? Vector3.UnitZ : axis);
|
||||
Vector3 t1 = Math.Abs(n.X) < 0.9f
|
||||
? Vector3.Normalize(Vector3.Cross(n, Vector3.UnitX))
|
||||
: Vector3.Normalize(Vector3.Cross(n, Vector3.UnitY));
|
||||
Vector3 t2 = Vector3.Normalize(Vector3.Cross(n, t1));
|
||||
float theta = (float)(_rng.NextDouble() * Math.PI * 2.0);
|
||||
float r = maxRadius * MathF.Sqrt((float)_rng.NextDouble());
|
||||
return (t1 * MathF.Cos(theta) + t2 * MathF.Sin(theta)) * r;
|
||||
}
|
||||
|
||||
private float RandomCentered(float halfWidth)
|
||||
{
|
||||
return ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue