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
|
|
@ -8,7 +8,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" />
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
|
||||
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
87
src/AcDream.Core/Vfx/EmitterDescLoader.cs
Normal file
87
src/AcDream.Core/Vfx/EmitterDescLoader.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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;
|
||||
}
|
||||
129
src/AcDream.Core/Vfx/ParticleHookSink.cs
Normal file
129
src/AcDream.Core/Vfx/ParticleHookSink.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAnimationHookSink"/> that translates particle-bearing
|
||||
/// animation hooks into <see cref="ParticleSystem"/> spawn / stop calls.
|
||||
///
|
||||
/// <para>
|
||||
/// Hook types handled (r04 §6):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <see cref="CreateParticleHook"/> — spawn an emitter from the
|
||||
/// hook's <c>EmitterInfoId</c> at the entity's world pose plus the
|
||||
/// hook offset. Retail attaches to a specific mesh part; we
|
||||
/// attach to the entity's root and will refine per-part when the
|
||||
/// renderer exposes per-part world transforms.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CreateBlockingParticleHook"/> — same as
|
||||
/// CreateParticleHook but the retail sequencer blocks animation
|
||||
/// progression until the particle is done. We spawn the emitter
|
||||
/// identically; the "blocking" semantics belong to the sequencer.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
|
||||
/// matching the hook's <c>EmitterId</c>. Deferred to a future pass
|
||||
/// when we retain per-entity emitter-id → handle maps.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="StopParticleHook"/> — pause all emitters on the
|
||||
/// entity (fade out).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DefaultScriptHook"/> / <see cref="DefaultScriptPartHook"/>
|
||||
/// — trigger the entity's <c>DefaultScriptId</c> PhysicsScript.
|
||||
/// Requires PhysicsScript table; deferred.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CallPESHook"/> — fire a PhysicsScript by id.
|
||||
/// Deferred until DRW exposes PhysicsScript dat.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
|
||||
/// StopParticle can target the right emitter when a server-sent
|
||||
/// <c>PlayEffect</c> fires.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ParticleHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly ParticleSystem _system;
|
||||
|
||||
// entityId → most-recently-spawned emitter handle per emitterId.
|
||||
// DestroyParticleHook.EmitterId is effectively an application-layer
|
||||
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
|
||||
// (entity, emitterId).
|
||||
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
|
||||
|
||||
public ParticleHookSink(ParticleSystem system)
|
||||
{
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
switch (hook)
|
||||
{
|
||||
case CreateParticleHook cph:
|
||||
SpawnFromHook(entityId, entityWorldPosition,
|
||||
emitterInfoId: (uint)cph.EmitterInfoId,
|
||||
offset: cph.Offset.Origin,
|
||||
partIndex: (int)cph.PartIndex,
|
||||
logicalId: cph.EmitterId);
|
||||
break;
|
||||
|
||||
case CreateBlockingParticleHook:
|
||||
// Retail's CreateBlockingParticleHook is a sentinel (no
|
||||
// payload) — the sequencer pauses advancement until the
|
||||
// current particle emitter finishes. We surface this as
|
||||
// "no spawn needed, just keep the current particle alive";
|
||||
// blocking semantics live in the sequencer, not here.
|
||||
break;
|
||||
|
||||
case DestroyParticleHook dph:
|
||||
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
|
||||
_system.StopEmitter(handleToDestroy, fadeOut: false);
|
||||
break;
|
||||
|
||||
case StopParticleHook sph:
|
||||
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
|
||||
_system.StopEmitter(handleToStop, fadeOut: true);
|
||||
break;
|
||||
|
||||
// DefaultScript / CallPES are wired when PhysicsScript loading
|
||||
// is available. They arrive at the sink but we can't act until
|
||||
// the script table returns a real emitter list.
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnFromHook(
|
||||
uint entityId,
|
||||
Vector3 worldPos,
|
||||
uint emitterInfoId,
|
||||
Vector3 offset,
|
||||
int partIndex,
|
||||
uint logicalId)
|
||||
{
|
||||
// Spawn position: entity pose + hook offset. PartIndex will be
|
||||
// used when the renderer passes per-part transforms through; for
|
||||
// now, fold it into the root pos.
|
||||
var anchor = worldPos + offset;
|
||||
|
||||
int handle = _system.SpawnEmitterById(
|
||||
emitterId: emitterInfoId,
|
||||
anchor: anchor,
|
||||
rot: Quaternion.Identity,
|
||||
attachedObjectId: entityId,
|
||||
attachedPartIndex: partIndex);
|
||||
|
||||
_handlesByKey[(entityId, logicalId)] = handle;
|
||||
}
|
||||
}
|
||||
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