From 11521f4418032e57c547953b8930b4387ad6cd2e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 11 May 2026 23:57:20 +0200 Subject: [PATCH] fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Vfx/ParticleHookSink.cs | 39 ++++++++- .../Vfx/ParticleHookSinkTests.cs | 80 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index bfb47e1..737b9dc 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -72,6 +72,14 @@ public sealed class ParticleHookSink : IAnimationHookSink private readonly ConcurrentDictionary _trackingByHandle = new(); private readonly ConcurrentDictionary _renderPassByEntity = new(); private readonly ConcurrentDictionary _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> _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; + /// + /// Register per-part static transforms for an entity. The caller + /// (typically EntityScriptActivator) precomputes one + /// per Setup part using + /// SetupPartTransforms.Compute and pushes them here at spawn + /// time. applies + /// partTransforms[hook.PartIndex] to the hook offset BEFORE + /// transforming to world space. Cleared on + /// . + /// + public void SetEntityPartTransforms(uint entityId, IReadOnlyList 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; diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs index 1fc53e6..2fbf839 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -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); + } }