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:
parent
f3bc15ed9d
commit
11521f4418
2 changed files with 115 additions and 4 deletions
|
|
@ -72,6 +72,14 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
||||||
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
|
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
|
||||||
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = 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;
|
private int _anonymousEmitterSerial;
|
||||||
|
|
||||||
public ParticleHookSink(ParticleSystem system)
|
public ParticleHookSink(ParticleSystem system)
|
||||||
|
|
@ -131,6 +139,19 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
public void SetEntityRotation(uint entityId, Quaternion rotation)
|
public void SetEntityRotation(uint entityId, Quaternion rotation)
|
||||||
=> _rotationByEntity[entityId] = 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)
|
public void ClearEntityRenderPass(uint entityId)
|
||||||
=> _renderPassByEntity.TryRemove(entityId, out _);
|
=> _renderPassByEntity.TryRemove(entityId, out _);
|
||||||
|
|
||||||
|
|
@ -171,6 +192,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
|
|
||||||
ClearEntityRenderPass(entityId);
|
ClearEntityRenderPass(entityId);
|
||||||
_rotationByEntity.TryRemove(entityId, out _);
|
_rotationByEntity.TryRemove(entityId, out _);
|
||||||
|
_partTransformsByEntity.TryRemove(entityId, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SpawnFromHook(
|
private void SpawnFromHook(
|
||||||
|
|
@ -181,13 +203,22 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
||||||
int partIndex,
|
int partIndex,
|
||||||
uint logicalId)
|
uint logicalId)
|
||||||
{
|
{
|
||||||
// Spawn position: entity pose + hook offset. PartIndex will be
|
// Spawn position: entity pose + hook offset, with the hook
|
||||||
// used when the renderer passes per-part transforms through; for
|
// offset first passed through the per-part transform when
|
||||||
// now, fold it into the root pos.
|
// 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)
|
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
|
||||||
? rot
|
? rot
|
||||||
: Quaternion.Identity;
|
: 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)
|
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
|
||||||
? pass
|
? pass
|
||||||
: ParticleRenderPass.Scene;
|
: ParticleRenderPass.Scene;
|
||||||
|
|
|
||||||
|
|
@ -92,4 +92,84 @@ public sealed class ParticleHookSinkTests
|
||||||
sys.Tick(0.01f);
|
sys.Tick(0.01f);
|
||||||
Assert.Equal(0, sys.ActiveEmitterCount);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue