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

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Lighting;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using Xunit;
@ -11,7 +12,7 @@ public sealed class LightingHookSinkTests
public void SetLightHook_FlipsOwnedLights()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true };
@ -32,7 +33,7 @@ public sealed class LightingHookSinkTests
public void UnregisterOwner_RemovesAllOwnedLights()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
var sink = new LightingHookSink(mgr, new MutablePoseSource());
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
sink.RegisterOwnedLight(new LightSource { OwnerId = 7 });
@ -46,7 +47,7 @@ public sealed class LightingHookSinkTests
public void UnrelatedHook_Ignored()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr);
var sink = new LightingHookSink(mgr, new MutablePoseSource());
var light = new LightSource { OwnerId = 1, IsLit = true };
sink.RegisterOwnedLight(light);
@ -56,6 +57,69 @@ public sealed class LightingHookSinkTests
Assert.True(light.IsLit);
}
[Fact]
public void RefreshAttachedLights_ComposesLocalFrameWithCurrentRootAndCell()
{
var mgr = new LightManager();
var poses = new MutablePoseSource();
poses.Publish(42u,
Matrix4x4.CreateRotationZ(MathF.PI / 2f)
* Matrix4x4.CreateTranslation(10, 20, 30),
cellId: 0x01010002u);
var sink = new LightingHookSink(mgr, poses);
var light = new LightSource
{
OwnerId = 42u,
LocalPose = Matrix4x4.CreateTranslation(1, 0, 2),
TracksOwnerPose = true,
};
sink.RegisterOwnedLight(light);
sink.RefreshAttachedLights();
Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f);
Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f);
Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f);
Assert.Equal(0x01010002u, light.CellId);
}
[Fact]
public void RefreshAttachedLights_DoesNotRecomposeDatStaticLight()
{
var mgr = new LightManager();
var poses = new MutablePoseSource();
poses.Publish(42u, Matrix4x4.CreateTranslation(100, 0, 0));
var sink = new LightingHookSink(mgr, poses);
var light = new LightSource
{
OwnerId = 42u,
WorldPosition = new Vector3(7, 8, 9),
LocalPose = Matrix4x4.CreateTranslation(1, 0, 0),
TracksOwnerPose = false,
};
sink.RegisterOwnedLight(light);
sink.RefreshAttachedLights();
Assert.Equal(new Vector3(7, 8, 9), light.WorldPosition);
Assert.Equal(0, poses.RootPoseQueryCount);
}
[Fact]
public void UnregisterPresentation_PreservesSetLightStateForReregistration()
{
var mgr = new LightManager();
var sink = new LightingHookSink(mgr, new MutablePoseSource());
sink.InitializeOwnerLighting(7u, enabled: true);
sink.SetOwnerLighting(7u, enabled: false);
sink.UnregisterOwner(7u, forgetState: false);
var replacement = new LightSource { OwnerId = 7u };
sink.RegisterOwnedLight(replacement);
Assert.False(replacement.IsLit);
}
}
public sealed class LightInfoLoaderTests
@ -96,9 +160,33 @@ public sealed class LightInfoLoaderTests
Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24)
Assert.Equal(0.8f, light.Intensity);
Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition);
Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation);
Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f);
}
[Fact]
public void Load_DynamicLight_UsesRetailHardwareRangeFactor()
{
var setup = new DatReaderWriter.DBObjs.Setup();
setup.Lights[0] = new LightInfo
{
ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity },
Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 },
Intensity = 1f,
Falloff = 8f,
};
var light = Assert.Single(LightInfoLoader.Load(
setup,
ownerId: 77u,
entityPosition: Vector3.Zero,
entityRotation: Quaternion.Identity,
isDynamic: true));
Assert.True(light.IsDynamic);
Assert.Equal(12f, light.Range, 3);
}
[Fact]
public void Load_NonZeroConeAngle_ProducesSpot()
{
@ -117,3 +205,42 @@ public sealed class LightInfoLoaderTests
Assert.Equal(0.5f, result[0].ConeAngle);
}
}
file sealed class MutablePoseSource : IEntityEffectPoseSource, IEntityEffectCellSource
{
private readonly Dictionary<uint, (Matrix4x4 Root, uint Cell)> _poses = new();
public void Publish(uint id, Matrix4x4 root, uint cellId = 0) =>
_poses[id] = (root, cellId);
public int RootPoseQueryCount { get; private set; }
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
RootPoseQueryCount++;
if (_poses.TryGetValue(localEntityId, out var pose))
{
rootWorld = pose.Root;
return true;
}
rootWorld = default;
return false;
}
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
{
partLocal = default;
return false;
}
public bool TryGetCellId(uint localEntityId, out uint cellId)
{
if (_poses.TryGetValue(localEntityId, out var pose))
{
cellId = pose.Cell;
return true;
}
cellId = 0;
return false;
}
}