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

@ -13,7 +13,7 @@ namespace AcDream.Core.Lighting;
/// <para>
/// Retail <see cref="LightInfo"/> fields (r13 §1):
/// <list type="bullet">
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item>
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning PartArray root.</description></item>
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
@ -24,13 +24,10 @@ namespace AcDream.Core.Lighting;
public static class LightInfoLoader
{
/// <summary>
/// Extract all lights from a Setup, positioned in the entity's
/// world frame (via <paramref name="entityPosition"/> +
/// <paramref name="entityRotation"/>). The dat's per-light Frame is
/// treated as a local offset relative to the entity root; acdream
/// doesn't yet transform through the animated part chain (retail's
/// hand-held torches), so held lights render at the entity root
/// until the animation hook layer handles per-part placement.
/// Extract all lights from a Setup and retain each light's complete local
/// frame. Retail Setup lights belong to the PartArray root rather than an
/// indexed mesh part; moving held objects therefore follow their child
/// physics-object root after <c>CPhysicsObj::UpdateChild</c> composes it.
/// </summary>
public static IReadOnlyList<LightSource> Load(
Setup setup,
@ -38,7 +35,8 @@ public static class LightInfoLoader
Vector3 entityPosition,
Quaternion entityRotation,
bool isDynamic = false,
uint cellId = 0)
uint cellId = 0,
bool tracksOwnerPose = false)
{
var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@ -64,12 +62,15 @@ public static class LightInfoLoader
info.ViewSpaceLocation.Orientation.W);
}
// Transform local offset into world space via the entity's
// rotation + translation. No per-part chain yet — held
// torches track the entity's root for now.
Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation);
Quaternion worldRot = entityRotation * localRot;
Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot);
Matrix4x4 localPose = Matrix4x4.CreateFromQuaternion(localRot)
* Matrix4x4.CreateTranslation(localOffset);
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entityRotation)
* Matrix4x4.CreateTranslation(entityPosition);
Matrix4x4 lightWorld = localPose * rootWorld;
Vector3 worldPos = lightWorld.Translation;
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
if (forward.LengthSquared() > 1e-8f)
forward = Vector3.Normalize(forward);
var light = new LightSource
{
@ -93,6 +94,8 @@ public static class LightInfoLoader
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true,
IsDynamic = isDynamic,
TracksOwnerPose = tracksOwnerPose,
LocalPose = localPose,
};
results.Add(light);
}

View file

@ -55,6 +55,20 @@ public sealed class LightSource
public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3)
/// <summary>
/// True when this light belongs to a live physics object whose root can
/// move. DAT-static lights keep their hydration-time world frame and are
/// deliberately excluded from the per-frame pose refresh.
/// </summary>
public bool TracksOwnerPose;
/// <summary>
/// Setup-local light frame retained from <c>LIGHTINFO::view_space_location</c>.
/// Object-borne lights compose it with their owner's current root each
/// frame, matching <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calling
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>).
/// </summary>
public Matrix4x4 LocalPose = Matrix4x4.Identity;
// Cached each frame by LightManager.
public float DistSq;

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
namespace AcDream.Core.Lighting;
@ -24,14 +25,27 @@ namespace AcDream.Core.Lighting;
public sealed class LightingHookSink : IAnimationHookSink
{
private readonly LightManager _lights;
private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectCellSource? _cells;
// Index owner → the set of LightSource instances they registered.
// Maintained lazily — populated on first RegisterLight for that owner.
private readonly Dictionary<uint, List<LightSource>> _byOwner = new();
private readonly Dictionary<uint, List<LightSource>> _trackedByOwner = new();
private readonly Dictionary<uint, bool> _enabledByOwner = new();
public LightingHookSink(LightManager lights)
/// <summary>
/// Raised after retail's <c>set_lights</c> state changes. App-layer live
/// owners use this to create/destroy their Setup light presentation;
/// DAT-static owners are already registered and simply consume IsLit.
/// </summary>
public event Action<uint, bool>? OwnerLightingChanged;
public LightingHookSink(LightManager lights, IEntityEffectPoseSource poses)
{
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
_poses = poses ?? throw new System.ArgumentNullException(nameof(poses));
_cells = poses as IEntityEffectCellSource;
}
/// <summary>
@ -41,6 +55,8 @@ public sealed class LightingHookSink : IAnimationHookSink
public void RegisterOwnedLight(LightSource light)
{
System.ArgumentNullException.ThrowIfNull(light);
if (_enabledByOwner.TryGetValue(light.OwnerId, out bool enabled))
light.IsLit = enabled;
_lights.Register(light);
if (!_byOwner.TryGetValue(light.OwnerId, out var list))
{
@ -48,14 +64,54 @@ public sealed class LightingHookSink : IAnimationHookSink
_byOwner[light.OwnerId] = list;
}
list.Add(light);
if (light.TracksOwnerPose)
{
if (!_trackedByOwner.TryGetValue(light.OwnerId, out List<LightSource>? tracked))
{
tracked = new List<LightSource>();
_trackedByOwner[light.OwnerId] = tracked;
}
tracked.Add(light);
}
}
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
public void UnregisterOwner(uint ownerId)
public void UnregisterOwner(uint ownerId, bool forgetState = true)
{
if (!_byOwner.TryGetValue(ownerId, out var list)) return;
foreach (var l in list) _lights.Unregister(l);
_byOwner.Remove(ownerId);
if (_byOwner.TryGetValue(ownerId, out var list))
{
foreach (var l in list) _lights.Unregister(l);
_byOwner.Remove(ownerId);
}
_trackedByOwner.Remove(ownerId);
if (forgetState)
_enabledByOwner.Remove(ownerId);
}
public void InitializeOwnerLighting(uint ownerId, bool enabled) =>
_enabledByOwner.TryAdd(ownerId, enabled);
public bool IsOwnerLightingEnabled(uint ownerId) =>
!_enabledByOwner.TryGetValue(ownerId, out bool enabled) || enabled;
/// <summary>
/// Mirrors <c>CPhysicsObj::set_lights</c> (0x0050FCF0): update the owner's
/// Lighting state and its currently materialized lights as one operation.
/// </summary>
public void SetOwnerLighting(uint ownerId, bool enabled)
{
if (_enabledByOwner.TryGetValue(ownerId, out bool current)
&& current == enabled)
{
return;
}
_enabledByOwner[ownerId] = enabled;
if (_byOwner.TryGetValue(ownerId, out var list))
{
foreach (LightSource light in list)
light.IsLit = enabled;
}
OwnerLightingChanged?.Invoke(ownerId, enabled);
}
/// <summary>
@ -67,12 +123,36 @@ public sealed class LightingHookSink : IAnimationHookSink
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
}
/// <summary>
/// Recompose every object-borne light from the current physics-object root.
/// Setup <c>LIGHTINFO</c> has no part index; equipped-object animation is
/// represented by publishing that child's composed root.
/// </summary>
public void RefreshAttachedLights()
{
foreach ((uint ownerId, List<LightSource> lights) in _trackedByOwner)
{
if (!_poses.TryGetRootPose(ownerId, out Matrix4x4 rootWorld))
continue;
uint cellId = 0;
_cells?.TryGetCellId(ownerId, out cellId);
for (int i = 0; i < lights.Count; i++)
{
LightSource light = lights[i];
Matrix4x4 lightWorld = light.LocalPose * rootWorld;
light.WorldPosition = lightWorld.Translation;
Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld);
if (forward.LengthSquared() > 1e-8f)
light.WorldForward = Vector3.Normalize(forward);
light.CellId = cellId;
}
}
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
if (hook is not SetLightHook slh) return;
if (!_byOwner.TryGetValue(entityId, out var list)) return;
foreach (var light in list)
light.IsLit = slh.LightsOn;
SetOwnerLighting(entityId, slh.LightsOn);
}
}