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.App.World;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Items;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
@ -27,12 +28,20 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities;
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private readonly EntityEffectPoseRegistry _poses;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary>
public event Action<uint>? EntityReady;
/// <summary>Raised after an attached projection has its composed pose.</summary>
public event Action<uint>? ProjectionPoseReady;
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
public event Action<uint>? ProjectionRemoved;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new();
private readonly Func<AttachedChild, uint?> _parentOfAttached;
private readonly Func<uint, bool> _tickAttached;
public IEnumerable<uint> AttachedEntityIds
{
@ -48,13 +57,17 @@ public sealed class EquippedChildRenderController : IDisposable
object datLock,
ClientObjectTable objects,
LiveEntityRuntime liveEntities,
EntityEffectPoseRegistry poses,
Func<ParentEvent.Parsed, bool> acceptParent)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_parentOfAttached = static child => child.ParentGuid;
_tickAttached = TickChild;
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack;
@ -83,17 +96,11 @@ public sealed class EquippedChildRenderController : IDisposable
spawn.PositionSequence));
}
ResolveRelations(spawn.Guid);
TryRealize(spawn.Guid);
ResolveAndTryRealize(spawn.Guid);
// ParentEvent can precede the parent's CreateObject. Revisit every
// child waiting specifically on the object that just arrived.
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(spawn.Guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
// ParentEvent can precede the parent's CreateObject. Revisit the
// complete descendant chain rooted at the object that arrived.
RetryWaitingDescendants(spawn.Guid);
}
}
@ -107,15 +114,20 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
RetryWaitingDescendants(guid);
}
}
/// <summary>
/// Retries pose-withdrawn descendants only after their parent's new root
/// and indexed parts have been published.
/// </summary>
public void OnPosePublished(uint guid)
{
lock (_datLock)
RetryWaitingDescendants(guid);
}
/// <summary>
/// Apply/queue a live ParentEvent after the live-entity owner has exact-
/// gated the parent's INSTANCE_TS and advanced the child's shared
@ -126,8 +138,8 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock)
{
Relations.Enqueue(update);
ResolveRelations(update.ChildGuid);
TryRealize(update.ChildGuid);
if (ResolveAndTryRealize(update.ChildGuid))
RetryWaitingDescendants(update.ChildGuid);
}
}
@ -171,68 +183,103 @@ public sealed class EquippedChildRenderController : IDisposable
/// <summary>Recompose every child after the parent's animation tick.</summary>
public void Tick()
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
continue;
if (!EquippedChildAttachment.TryCompose(
child.ParentSetup,
parent.MeshRefs,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
out IReadOnlyList<MeshRef> parts))
continue;
child.Entity.MeshRefs = parts;
child.Entity.SetPosition(parent.Position);
child.Entity.Rotation = parent.Rotation;
child.Entity.ParentCellId = parent.ParentCellId;
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
}
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
_attachedByChild,
_parentOfAttached,
_tickAttached);
for (int i = 0; i < failed.Count; i++)
WithdrawForPoseLoss(failed[i]);
}
private void TryRealize(uint childGuid)
private bool TickChild(uint childGuid)
{
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
return false;
if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
&& _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld)
&& _poses.TryGetPartPoseSnapshot(
parent.Id,
out var parentPartPoses,
out var parentPartAvailability)
&& EquippedChildAttachment.TryComposePoseInto(
child.ParentSetup,
parentPartPoses,
parentPartAvailability,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
child.PartPoseBuffer,
child.AttachedPartBuffer,
out EquippedChildPose pose))
{
child.Entity.MeshRefs = pose.AttachedParts;
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
if (!ApplyParentWorldPose(child.Entity, parentWorld))
return false;
child.Entity.ParentCellId = parent.ParentCellId;
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
ProjectionPoseReady?.Invoke(child.ChildGuid);
return true;
}
return false;
}
private bool TryRealize(uint childGuid)
{
if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
return;
return false;
if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
return;
return false;
if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId)
return;
return false;
Setup? parentSetup = _dats.Get<Setup>(parentSetupId);
Setup? childSetup = _dats.Get<Setup>(childSetupId);
if (parentSetup is null || childSetup is null)
return;
return false;
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn);
bool[] childPartAvailability = BuildPartAvailability(template);
float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale
: 1.0f;
if (!_poses.TryGetPartPoseSnapshot(
parentEntity.Id,
out var parentPartPoses,
out var parentPartAvailability))
return false;
if (!_poses.TryGetRootPose(parentEntity.Id, out Matrix4x4 parentWorld)
|| !TryDecomposeWorldPose(parentWorld, out Vector3 parentPosition, out Quaternion parentRotation))
{
return false;
}
if (!EquippedChildAttachment.TryCompose(
if (!EquippedChildAttachment.TryComposePoseInto(
parentSetup,
parentEntity.MeshRefs,
parentPartPoses,
parentPartAvailability,
childSetup,
parentLocation,
placement,
template,
scale,
out IReadOnlyList<MeshRef> parts))
return;
partPoseBuffer: null,
attachedPartBuffer: null,
out EquippedChildPose pose))
return false;
// A parented object may materialize here before the ordinary world
// hydration path sees it. Install its logical effect profile before
@ -250,29 +297,34 @@ public sealed class EquippedChildRenderController : IDisposable
WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
parentCellId,
localId => new WorldEntity
localId =>
{
Id = localId,
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position,
Rotation = parentEntity.Rotation,
MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
var created = new WorldEntity
{
Id = localId,
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentPosition,
Rotation = parentRotation,
MeshRefs = pose.AttachedParts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
};
created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
return created;
},
LiveEntityProjectionKind.Attached);
if (entity is null)
return;
entity.SetPosition(parentEntity.Position);
entity.Rotation = parentEntity.Rotation;
return false;
ApplyParentWorldPose(entity, parentWorld);
entity.ParentCellId = parentCellId;
entity.ApplyAppearance(
parts,
pose.AttachedParts,
BuildPaletteOverride(childSpawn),
childSpawn.AnimPartChanges is { Count: > 0 } changes
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty<PartOverride>());
entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
_attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid,
childGuid,
@ -281,13 +333,60 @@ public sealed class EquippedChildRenderController : IDisposable
parentSetup,
childSetup,
template,
childPartAvailability,
pose.PartLocal,
pose.AttachedParts,
scale,
entity);
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid);
ProjectionPoseReady?.Invoke(childGuid);
EntityReady?.Invoke(childGuid);
return true;
}
private void PublishChildPose(
WorldEntity child,
Matrix4x4 parentWorld,
uint? parentCellId,
EquippedChildPose pose)
{
_poses.Publish(
child.Id,
pose.RootLocal * parentWorld,
pose.PartLocal,
parentCellId ?? 0u,
child.IndexedPartAvailable);
}
/// <summary>
/// Installs the immediate parent's composed world root on the child
/// projection. The child's MeshRefs already contain its holding and
/// placement transforms relative to that direct parent root.
/// </summary>
internal static bool ApplyParentWorldPose(WorldEntity child, Matrix4x4 parentWorld)
{
if (!TryDecomposeWorldPose(parentWorld, out Vector3 position, out Quaternion rotation))
return false;
child.SetPosition(position);
child.Rotation = rotation;
return true;
}
private static bool TryDecomposeWorldPose(
Matrix4x4 world,
out Vector3 position,
out Quaternion rotation)
{
position = world.Translation;
if (!Matrix4x4.Decompose(world, out Vector3 scale, out rotation, out _)
|| Vector3.DistanceSquared(scale, Vector3.One) > 1e-6f)
return false;
rotation = Quaternion.Normalize(rotation);
return true;
}
/// <summary>
@ -337,6 +436,20 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent);
}
private bool ResolveAndTryRealize(uint childGuid)
{
ResolveRelations(childGuid);
return TryRealize(childGuid);
}
private void RetryWaitingDescendants(uint parentGuid)
{
_updateOrder.RealizeDescendants(
parentGuid,
Relations.ChildrenWaitingForParent,
ResolveAndTryRealize);
}
private IReadOnlyList<MeshRef> BuildPartTemplate(
Setup setup,
WorldSession.EntitySpawn spawn)
@ -396,6 +509,14 @@ public sealed class EquippedChildRenderController : IDisposable
return result;
}
private bool[] BuildPartAvailability(IReadOnlyList<MeshRef> template)
{
var available = new bool[template.Count];
for (int i = 0; i < template.Count; i++)
available[i] = _dats.Get<GfxObj>(template[i].GfxObjId) is not null;
return available;
}
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@ -430,27 +551,52 @@ public sealed class EquippedChildRenderController : IDisposable
if (Relations.RestoreLastAccepted(item.ObjectId))
{
lock (_datLock)
TryRealize(item.ObjectId);
{
if (ResolveAndTryRealize(item.ObjectId))
RetryWaitingDescendants(item.ObjectId);
}
}
}
private void Remove(uint childGuid)
{
if (!_attachedByChild.Remove(childGuid))
if (!_attachedByChild.Remove(childGuid, out AttachedChild? child))
return;
_liveEntities.WithdrawLiveEntityProjection(childGuid);
_poses.Remove(child.Entity.Id);
ProjectionRemoved?.Invoke(child.Entity.Id);
}
private void WithdrawForPoseLoss(uint childGuid)
{
if (!_attachedByChild.ContainsKey(childGuid))
return;
Remove(childGuid);
// MarkProjected removed the active candidate but retained the last
// accepted relationship. Requeue that exact accepted relation so a
// later parent pose/appearance registration can realize it again.
Relations.RestoreLastAccepted(childGuid);
}
private void TearDownObjectProjections(uint guid)
{
Remove(guid);
uint[] children = _attachedByChild.Values
.Where(child => child.ParentGuid == guid)
.Select(child => child.ChildGuid)
.ToArray();
for (int i = 0; i < children.Length; i++)
Remove(children[i]);
IReadOnlyList<uint> subtree = _updateOrder.CollectSubtreePostOrder(
_attachedByChild,
guid,
_parentOfAttached);
for (int i = 0; i < subtree.Count; i++)
{
uint childGuid = subtree[i];
Remove(childGuid);
if (childGuid != guid)
{
// Retail unparents direct children. Descendant relationships
// can become renderable again if their immediate parent later
// acquires a top-level Position, so retain their accepted
// relationship as a pending projection candidate.
Relations.RestoreLastAccepted(childGuid);
}
}
}
public void Clear()
@ -477,6 +623,9 @@ public sealed class EquippedChildRenderController : IDisposable
Setup ParentSetup,
Setup ChildSetup,
IReadOnlyList<MeshRef> PartTemplate,
IReadOnlyList<bool> PartAvailability,
Matrix4x4[] PartPoseBuffer,
MeshRef[] AttachedPartBuffer,
float Scale,
WorldEntity Entity);
}