feat(vfx): bind effects to live animated poses

This commit is contained in:
Erik 2026-07-14 10:56:01 +02:00
parent 96ddfdf175
commit 542dcfc384
41 changed files with 3246 additions and 741 deletions

View file

@ -1,191 +1,177 @@
using System;
using System.Collections.Concurrent;
using System.Numerics;
using System.Threading;
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"/> — has the same payload
/// as CreateParticleHook, but suppresses creation while the same nonzero
/// logical emitter ID is live. It does not pause animation. Production DAT
/// loading substitutes <see cref="RetailCreateBlockingParticleHook"/> so
/// the inherited payload is retained despite the dependency's header-only
/// model. Suppression itself lands with live emitter bindings in Step 5.
/// </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>
/// Routes particle animation hooks through the owner's current root/part pose
/// and retains the retail logical-emitter identity rules.
/// </summary>
/// <remarks>
/// <para>
/// Retail oracle: <c>CreateParticleHook::Execute</c> (<c>0x00526EC0</c>),
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>),
/// <c>ParticleManager::CreateParticleEmitter</c> (<c>0x0051B6C0</c>), and
/// <c>CreateBlockingParticleEmitter</c> (<c>0x0051B8A0</c>).
/// </para>
/// <para>
/// The complete hook offset frame is retained on each binding for schema
/// fidelity. Retail <c>Particle::Init</c> (<c>0x0051C930</c>) transforms the
/// offset origin through the current owner frame, but does not apply the hook
/// offset quaternion to particle vectors; emitter orientation is the current
/// root/part orientation.
/// </para>
/// </remarks>
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 IEntityEffectPoseSource _poses;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
// entityId → set of live emitter handles. Dictionary-as-set so we can
// remove individual handles when their emitter dies (M4 fix —
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
// emitters used to leak).
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = new();
// C.1.5b #56: per-entity static part transforms (PlacementFrames[Resting]
// baked into a Matrix4x4 per Setup part). When set, SpawnFromHook applies
// partTransforms[hook.PartIndex] to the hook offset BEFORE rotating to
// world space. Without this, every emitter in a multi-part Setup
// collapses to the entity root (the bug). Cleared by StopAllForEntity.
// For ANIMATED entities this map would need a per-tick refresh similar
// to UpdateEntityAnchor — deferred to a future phase.
private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
private int _anonymousEmitterSerial;
public ParticleHookSink(ParticleSystem system)
private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_system.EmitterDied += OnEmitterDied;
}
public Action<string>? DiagnosticSink { get; set; }
private void OnEmitterDied(int handle)
{
if (!_trackingByHandle.TryRemove(handle, out var t))
if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
return;
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
bag.TryRemove(handle, out _);
if (binding.LogicalId != 0
&& _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current)
&& current == handle)
{
_handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _);
}
if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles))
{
handles.TryRemove(handle, out _);
if (handles.IsEmpty)
_handlesByEntity.TryRemove(binding.OwnerLocalId, out _);
}
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
switch (hook)
{
case RetailCreateBlockingParticleHook:
// The centralized content reader now preserves the complete
// retail payload. Step 5 owns live logical-ID suppression and
// attachment semantics; do not silently treat blocking as a
// normal replacement before that mechanism lands.
case RetailCreateBlockingParticleHook blocking:
SpawnFromHook(
entityId,
(uint)blocking.EmitterInfoId,
blocking.Offset,
unchecked((int)blocking.PartIndex),
blocking.EmitterId,
isBlocking: true);
break;
case CreateParticleHook cph:
SpawnFromHook(entityId, entityWorldPosition,
emitterInfoId: (uint)cph.EmitterInfoId,
offset: cph.Offset.Origin,
partIndex: (int)cph.PartIndex,
logicalId: cph.EmitterId);
case CreateParticleHook create:
SpawnFromHook(
entityId,
(uint)create.EmitterInfoId,
create.Offset,
unchecked((int)create.PartIndex),
create.EmitterId,
isBlocking: false);
break;
case CreateBlockingParticleHook:
// Defensive package-decoder shape. Production raw loaders
// replace this header-only model with the retail subclass.
// The upstream package exposes only the common header for this
// type. Production loaders replace it with the retail payload
// above; a header-only instance cannot create an emitter.
DiagnosticSink?.Invoke(
$"CreateBlockingParticle for owner 0x{entityId:X8} has no retail payload.");
break;
case DestroyParticleHook dph:
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
_system.StopEmitter(handleToDestroy, fadeOut: false);
case DestroyParticleHook destroy:
DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
break;
case StopParticleHook sph:
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
_system.StopEmitter(handleToStop, fadeOut: true);
case StopParticleHook stop:
DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
break;
// DefaultScript / CallPES are routed by EntityEffectController.
// ParticleHookSink intentionally owns particles only.
}
}
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
=> _renderPassByEntity[entityId] = renderPass;
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
_renderPassByEntity[entityId] = renderPass;
public void SetEntityRotation(uint entityId, Quaternion rotation)
=> _rotationByEntity[entityId] = rotation;
public void ClearEntityRenderPass(uint entityId) =>
_renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// Register per-part static transforms for an entity. The caller
/// (typically <c>EntityScriptActivator</c>) precomputes one
/// <see cref="Matrix4x4"/> per Setup part using
/// <c>SetupPartTransforms.Compute</c> and pushes them here at spawn
/// time. <see cref="SpawnFromHook"/> applies
/// <c>partTransforms[hook.PartIndex]</c> to the hook offset BEFORE
/// transforming to world space. Cleared on
/// <see cref="StopAllForEntity"/>.
/// Mirrors live cell membership without ending emitter lifetime. Retail
/// pauses the owner's particle manager while cell-less, then resumes the
/// same particles and logical IDs without catch-up emission on re-entry.
/// </summary>
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
=> _partTransformsByEntity[entityId] = partTransforms;
public void ClearEntityRenderPass(uint entityId)
=> _renderPassByEntity.TryRemove(entityId, out _);
/// <summary>
/// Refresh every live emitter on this entity to a new world anchor +
/// rotation. The owning subsystem (sky-PES driver, animation tick)
/// drives this each frame for AttachLocal emitters so they track their
/// moving parent — retail-faithful via
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
/// Safe to call for entities with no live emitters (no-op).
/// </summary>
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
public void SetEntityPresentationVisible(uint entityId, bool visible)
{
_rotationByEntity[entityId] = rotation;
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
return;
foreach (var handle in bag.Keys)
_system.UpdateEmitterAnchor(handle, anchor, rotation);
if (visible)
_hiddenPresentationOwners.TryRemove(entityId, out _);
else
_hiddenPresentationOwners[entityId] = 0;
// Withdrawal is immediate. Re-entry is enabled by the next pose
// refresh so an attached owner cannot flash for one frame at its old
// anchor before the composed child pose is published.
if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles))
{
foreach (int handle in handles.Keys)
{
_system.SetEmitterPresentationVisible(handle, false);
_system.SetEmitterSimulationEnabled(handle, false);
}
}
}
/// <summary>
/// Refreshes every emitter from the current root/part pose. World-released
/// particles keep their stored emission origin; AttachLocal particles read
/// the refreshed anchor when the particle system advances.
/// </summary>
public void RefreshAttachedEmitters()
{
foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
{
bool presentationVisible =
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation))
{
_system.UpdateEmitterAnchor(handle, anchor, rotation);
// Keep the anchor current while spatially paused; re-entry
// resumes at the authoritative pose without generating the
// absent interval's time- or distance-driven emissions.
_system.SetEmitterPresentationVisible(handle, presentationVisible);
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
}
else
_system.SetEmitterPresentationVisible(handle, false);
}
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
foreach (var handle in handles.Keys)
foreach (int handle in handles.Keys)
{
// A fading emitter needs its clock to retire naturally. A
// hard destroy is removed synchronously by ParticleSystem.
if (fadeOut)
_system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
_trackingByHandle.TryRemove(handle, out _);
_bindingsByHandle.TryRemove(handle, out _);
}
}
@ -194,61 +180,138 @@ public sealed class ParticleHookSink : IAnimationHookSink
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _);
_partTransformsByEntity.TryRemove(entityId, out _);
_hiddenPresentationOwners.TryRemove(entityId, out _);
}
private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut)
{
if (logicalId == 0
|| !_handlesByKey.TryGetValue((ownerLocalId, logicalId), out int handle))
{
return;
}
// Retail StopParticleEmitter (0x0051B7B0) only marks the emitter as
// stopped; it remains in particle_table until its final particle dies.
// A blocking create with the same logical ID must therefore continue
// to see it. DestroyParticleEmitter (0x0051B770) removes it at once.
if (!fadeOut)
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
if (fadeOut)
_system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
}
private void SpawnFromHook(
uint entityId,
Vector3 worldPos,
uint ownerLocalId,
uint emitterInfoId,
Vector3 offset,
Frame offset,
int partIndex,
uint logicalId)
uint logicalId,
bool isBlocking)
{
// Spawn position: entity pose + hook offset, with the hook
// offset first passed through the per-part transform when
// available (C.1.5b #56 fix). Without the per-part transform,
// every emitter in a multi-emitter PES script collapses to the
// entity root — visible symptom: ground-buried portal swirls.
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot
: Quaternion.Identity;
Vector3 partLocal = offset;
if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms)
&& partIndex >= 0
&& partIndex < partTransforms.Count)
if (logicalId != 0
&& _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
{
partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
if (isBlocking && _system.IsEmitterAlive(existing))
return;
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
_system.StopEmitter(existing, fadeOut: false);
}
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero;
Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity;
if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin,
out Vector3 anchor, out Quaternion rotation))
{
DiagnosticSink?.Invoke(
$"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " +
$"emitter 0x{emitterInfoId:X8} was not created.");
return;
}
ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass)
? pass
: ParticleRenderPass.Scene;
int handle = _system.SpawnEmitterById(
emitterId: emitterInfoId,
anchor: anchor,
rot: rotation,
attachedObjectId: entityId,
attachedPartIndex: partIndex,
renderPass: renderPass);
uint keyId = logicalId != 0
? logicalId
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
if (!_system.TrySpawnEmitterById(
emitterInfoId,
anchor,
rotation,
ownerLocalId,
partIndex,
renderPass,
out int handle))
{
_system.StopEmitter(oldHandle, fadeOut: false);
_trackingByHandle.TryRemove(oldHandle, out _);
DiagnosticSink?.Invoke(
$"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
$"0x{ownerLocalId:X8} was not found; no fallback was created.");
return;
}
if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
{
_system.SetEmitterPresentationVisible(handle, false);
_system.SetEmitterSimulationEnabled(handle, false);
}
_handlesByKey[(entityId, keyId)] = handle;
var binding = new EmitterBinding(
ownerLocalId,
partIndex,
offsetOrigin,
offsetOrientation,
logicalId,
renderPass);
_bindingsByHandle[handle] = binding;
_handlesByEntity
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
_trackingByHandle[handle] = (entityId, keyId);
if (logicalId != 0)
_handlesByKey[(ownerLocalId, logicalId)] = handle;
}
private bool TryResolveAnchor(
uint ownerLocalId,
int partIndex,
Vector3 offsetOrigin,
out Vector3 anchor,
out Quaternion rotation)
{
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
{
anchor = default;
rotation = default;
return false;
}
Matrix4x4 ownerFrame = rootWorld;
if (partIndex != -1)
{
if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
{
anchor = default;
rotation = default;
return false;
}
ownerFrame = partLocal * rootWorld;
}
anchor = Vector3.Transform(offsetOrigin, ownerFrame);
if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
{
anchor = default;
rotation = default;
return false;
}
rotation = Quaternion.Normalize(rotation);
return true;
}
private readonly record struct EmitterBinding(
uint OwnerLocalId,
int PartIndex,
Vector3 HookOffsetOrigin,
Quaternion HookOffsetOrientation,
uint LogicalId,
ParticleRenderPass RenderPass);
}