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>
249 lines
10 KiB
C#
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);
|
|
}
|
|
}
|