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>
This commit is contained in:
Erik 2026-05-11 23:57:20 +02:00
parent f3bc15ed9d
commit 11521f4418
2 changed files with 115 additions and 4 deletions

View file

@ -72,6 +72,14 @@ public sealed class ParticleHookSink : IAnimationHookSink
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)
@ -131,6 +139,19 @@ public sealed class ParticleHookSink : IAnimationHookSink
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 _);
@ -171,6 +192,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
ClearEntityRenderPass(entityId);
_rotationByEntity.TryRemove(entityId, out _);
_partTransformsByEntity.TryRemove(entityId, out _);
}
private void SpawnFromHook(
@ -181,13 +203,22 @@ public sealed class ParticleHookSink : IAnimationHookSink
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.
// 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;
var anchor = worldPos + Vector3.Transform(offset, rotation);
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;

View file

@ -92,4 +92,84 @@ public sealed class ParticleHookSinkTests
sys.Tick(0.01f);
Assert.Equal(0, sys.ActiveEmitterCount);
}
[Fact]
public void SpawnFromHook_AppliesPartTransform_WhenRegistered()
{
// C.1.5b #56: when SetEntityPartTransforms has been called for
// entityId, SpawnFromHook must transform the hook offset through
// the part-local matrix before applying entity rotation.
// Part 1 is lifted +Z=1; hook offset = (1, 0, 0), PartIndex=1.
// Expected world position: (1, 0, 1) with identity rotation.
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(0x32000030u, attachLocal: false));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var partTransforms = new Matrix4x4[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
{
EmitterInfoId = 0x32000030u,
EmitterId = 0,
PartIndex = 1,
Offset = new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = Quaternion.Identity,
},
});
sys.Tick(0.001f);
var live = System.Linq.Enumerable.Single(sys.EnumerateLive());
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.99f, 1.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, 0.99f, 1.01f);
}
[Fact]
public void SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds()
{
// Out-of-bounds PartIndex must NOT crash and must NOT apply a
// wrong matrix; falls back to no part transform (Identity), so
// the offset is applied in entity-local space as pre-C.1.5b.
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(0x32000031u, attachLocal: false));
var sys = new ParticleSystem(registry, new System.Random(42));
var sink = new ParticleHookSink(sys);
var partTransforms = new Matrix4x4[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
{
EmitterInfoId = 0x32000031u,
EmitterId = 0,
PartIndex = 99, // way past the 2-part array
Offset = new Frame
{
Origin = new Vector3(2f, 0f, 0f),
Orientation = Quaternion.Identity,
},
});
sys.Tick(0.001f);
var live = System.Linq.Enumerable.Single(sys.EnumerateLive());
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 1.99f, 2.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, -0.01f, 0.01f);
}
}