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

@ -43,6 +43,7 @@ public sealed class EntityScriptActivatorTests
private record Pipeline(
ParticleSystem System,
ParticleHookSink Sink,
EntityEffectPoseRegistry Poses,
PhysicsScriptRunner Runner,
RecordingSink Recording);
@ -50,14 +51,15 @@ public sealed class EntityScriptActivatorTests
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
var poses = new EntityEffectPoseRegistry();
var hookSink = new ParticleHookSink(system, poses); // for activator's StopAllForEntity
var recording = new RecordingSink(); // for runner's hook dispatch
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
recording);
return new Pipeline(system, hookSink, runner, recording);
return new Pipeline(system, hookSink, poses, runner, recording);
}
/// <summary>
@ -74,7 +76,7 @@ public sealed class EntityScriptActivatorTests
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity);
@ -90,7 +92,7 @@ public sealed class EntityScriptActivatorTests
public void OnCreate_WithoutDefaultScript_DoesNothing()
{
var p = BuildPipeline(); // no scripts registered
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
@ -125,11 +127,11 @@ public sealed class EntityScriptActivatorTests
};
[Fact]
public void OnCreate_SetsEntityRotationForHookOffsetTransform()
public void OnCreate_PublishesEntityRotationForHookOffsetTransform()
{
// The CreateParticleHook's Offset is in entity-local frame; the sink
// needs the entity's rotation to transform it to world space. If the
// activator forgets SetEntityRotation, the offset goes off in world
// reads the published entity root to transform it to world space. If
// the activator omits that pose, the offset goes off in world
// axes — visual symptom: portal swirls misaligned to the portal stone.
// This test verifies the seed happens by checking the spawned particle's
// world position matches the rotated offset, not the unrotated offset.
@ -138,7 +140,8 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var poses = new EntityEffectPoseRegistry();
var hookSink = new ParticleHookSink(system, poses);
// Hook offset = (1, 0, 0) in entity-local frame.
var hookOffset = new Frame
@ -147,13 +150,18 @@ public sealed class EntityScriptActivatorTests
Orientation = Quaternion.Identity,
};
var script = BuildScript(
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset }));
(0.0, new CreateParticleHook
{
EmitterInfoId = 100u,
Offset = hookOffset,
PartIndex = uint.MaxValue,
}));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
// Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y.
var entityRotation = Quaternion.CreateFromAxisAngle(
@ -193,15 +201,21 @@ public sealed class EntityScriptActivatorTests
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var poses = new EntityEffectPoseRegistry();
var hookSink = new ParticleHookSink(system, poses);
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
var script = BuildScript((0.0, new CreateParticleHook
{
EmitterInfoId = 100u,
Offset = new Frame(),
PartIndex = uint.MaxValue,
}));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink); // runner dispatches into real sink, not RecordingSink
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
@ -226,7 +240,7 @@ public sealed class EntityScriptActivatorTests
// OnCreate must use entity.Id as the key (not skip).
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
var entity = new WorldEntity
{
Id = 0x40A9B401u, // dat-hydrated interior id
@ -251,7 +265,7 @@ public sealed class EntityScriptActivatorTests
{
var p = BuildPipeline(
(0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, StaticResolver(0xAAu));
WorldEntity first = MakeDatStatic(0x80A9B400u, new Vector3(1, 0, 0));
WorldEntity second = MakeDatStatic(0x80A9B400u, new Vector3(2, 0, 0));
@ -268,7 +282,7 @@ public sealed class EntityScriptActivatorTests
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => null);
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
var entity = new WorldEntity
{
Id = 1_000_123u,
@ -285,6 +299,34 @@ public sealed class EntityScriptActivatorTests
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
}
[Fact]
public void RemovingLiveOwner_DoesNotClearIndependentDatStaticPose()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
StaticResolver(0));
WorldEntity datStatic = MakeDatStatic(0x40A9B410u, Vector3.One);
var live = new WorldEntity
{
Id = 1_000_410u,
ServerGuid = 0x70000410u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(2, 2, 2),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
activator.OnCreate(datStatic);
activator.OnCreate(live);
activator.OnRemove(live);
Assert.True(p.Poses.TryGetRootPose(datStatic.Id, out _));
Assert.False(p.Poses.TryGetRootPose(live.Id, out _));
}
[Fact]
public void OnCreate_PassesPartTransformsToSink()
{
@ -295,7 +337,8 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var poses = new EntityEffectPoseRegistry();
var hookSink = new ParticleHookSink(system, poses);
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
var script = BuildScript(
@ -311,7 +354,7 @@ public sealed class EntityScriptActivatorTests
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
var activator = new EntityScriptActivator(runner, hookSink,
var activator = new EntityScriptActivator(runner, hookSink, poses,
_ => new ScriptActivationInfo(0xAAu, partTransforms));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
@ -335,15 +378,21 @@ public sealed class EntityScriptActivatorTests
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var poses = new EntityEffectPoseRegistry();
var hookSink = new ParticleHookSink(system, poses);
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
var script = BuildScript((0.0, new CreateParticleHook
{
EmitterInfoId = 100u,
Offset = new Frame(),
PartIndex = uint.MaxValue,
}));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
var activator = new EntityScriptActivator(runner, hookSink, StaticResolver(0xAAu));
var activator = new EntityScriptActivator(runner, hookSink, poses, StaticResolver(0xAAu));
var entity = new WorldEntity
{
Id = 0x40A9B402u,