acdream/src/AcDream.Core/Vfx/ParticleHookSink.cs
Erik 11521f4418 fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform
Adds per-entity part-transform side-table mirroring _rotationByEntity.
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
before rotating to world space. Backwards-compatible: entities without
registered part transforms fall through to identity (pre-C.1.5b behavior),
so the existing C.1.5a rotation-seed test stays green.

Adds SetEntityPartTransforms public method. Cleared on StopAllForEntity
alongside the rotation entry.

2 new xUnit tests:
- SpawnFromHook_AppliesPartTransform_WhenRegistered — part 1 lifted +Z=1,
  hook offset (1,0,0), PartIndex=1 → world (1,0,1).
- SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds — PartIndex=99
  on a 2-part array → offset applied without crash, pre-C.1.5b behavior.

Closes the renderer side of #56. EntityScriptActivator wiring (Task 3)
lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:57:20 +02:00

249 lines
10 KiB
C#

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"/> — 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();
// 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<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)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_system.EmitterDied += OnEmitterDied;
}
private void OnEmitterDied(int handle)
{
if (!_trackingByHandle.TryRemove(handle, out var t))
return;
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
bag.TryRemove(handle, out _);
}
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.
}
}
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
=> _renderPassByEntity[entityId] = renderPass;
public void SetEntityRotation(uint entityId, Quaternion rotation)
=> _rotationByEntity[entityId] = rotation;
/// <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"/>.
/// </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)
{
_rotationByEntity[entityId] = rotation;
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
return;
foreach (var handle in bag.Keys)
_system.UpdateEmitterAnchor(handle, anchor, rotation);
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
{
foreach (var handle in handles.Keys)
{
_system.StopEmitter(handle, fadeOut);
_trackingByHandle.TryRemove(handle, out _);
}
}
foreach (var key in _handlesByKey.Keys)
{
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
}
ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _);
_partTransformsByEntity.TryRemove(entityId, out _);
}
private void SpawnFromHook(
uint entityId,
Vector3 worldPos,
uint emitterInfoId,
Vector3 offset,
int partIndex,
uint logicalId)
{
// 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)
{
partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
}
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
var renderPass = _renderPassByEntity.TryGetValue(entityId, 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))
{
_system.StopEmitter(oldHandle, fadeOut: false);
_trackingByHandle.TryRemove(oldHandle, out _);
}
_handlesByKey[(entityId, keyId)] = handle;
_handlesByEntity
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
_trackingByHandle[handle] = (entityId, keyId);
}
}