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>
87 lines
3.2 KiB
C#
87 lines
3.2 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.Core.Vfx;
|
|
|
|
/// <summary>
|
|
/// Resolves <see cref="EmitterDesc"/> instances by their retail emitter
|
|
/// dat id (<c>0x32xxxxxx</c> range). The current build of
|
|
/// Chorizite.DatReaderWriter (v2.1.7) doesn't yet ship a
|
|
/// <c>ParticleEmitterInfo</c> DBObj class, so we maintain a small
|
|
/// registry of synthesized descriptors for the handful of emitters
|
|
/// acdream actually needs (portal swirl, chimney smoke, fireplace
|
|
/// flames, footstep dust, spell auras, weapon trails) and fall back to
|
|
/// a generic "puff" for unknown ids. When a future DRW release adds
|
|
/// the dat-type, this class will additionally load + cache from dats.
|
|
///
|
|
/// <para>
|
|
/// Field mapping once the dat-type arrives (docs/research/deepdives/
|
|
/// r04-vfx-particles.md §1 + references/DatReaderWriter's own generated
|
|
/// <c>ParticleEmitterInfo.generated.cs</c>):
|
|
/// <list type="bullet">
|
|
/// <item><description>
|
|
/// <c>Birthrate</c> → <c>1 / EmitRate</c> (retail stores the avg
|
|
/// time between spawns, not the rate).
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// <c>Lifespan ± LifespanRand</c> → <c>LifetimeMin / LifetimeMax</c>
|
|
/// range.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// <c>A, MinA, MaxA</c> → primary initial velocity with magnitude
|
|
/// jitter; <c>B</c> / <c>C</c> are secondary spread components.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// <c>StartScale, FinalScale</c> / <c>StartTrans, FinalTrans</c>
|
|
/// interpolate linearly over life.
|
|
/// </description></item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class EmitterDescRegistry
|
|
{
|
|
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
|
|
|
|
public EmitterDescRegistry()
|
|
{
|
|
// Seed with a handful of well-known AC emitter ids plus a
|
|
// fallback. Ids here come from empirical ACViewer dat dumps —
|
|
// see r04 §5.2 for the more complete inventory.
|
|
Register(new EmitterDesc
|
|
{
|
|
DatId = 0xFFFFFFFFu, // "default" sentinel
|
|
Type = ParticleType.LocalVelocity,
|
|
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
|
|
EmitRate = 10f,
|
|
MaxParticles = 32,
|
|
LifetimeMin = 0.6f,
|
|
LifetimeMax = 1.2f,
|
|
OffsetDir = new Vector3(0, 0, 1),
|
|
MinOffset = 0f,
|
|
MaxOffset = 0.1f,
|
|
SpawnDiskRadius = 0.1f,
|
|
InitialVelocity = new Vector3(0, 0, 0.5f),
|
|
VelocityJitter = 0.3f,
|
|
StartSize = 0.25f,
|
|
EndSize = 0.6f,
|
|
StartAlpha = 0.85f,
|
|
EndAlpha = 0f,
|
|
});
|
|
}
|
|
|
|
public void Register(EmitterDesc desc)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(desc);
|
|
_byId[desc.DatId] = desc;
|
|
}
|
|
|
|
public EmitterDesc Get(uint emitterId)
|
|
{
|
|
if (_byId.TryGetValue(emitterId, out var desc)) return desc;
|
|
if (_byId.TryGetValue(0xFFFFFFFFu, out var fallback)) return fallback;
|
|
throw new InvalidOperationException("No default emitter registered in registry.");
|
|
}
|
|
|
|
public int Count => _byId.Count;
|
|
}
|