feat(vfx): bind effects to live animated poses
This commit is contained in:
parent
96ddfdf175
commit
542dcfc384
41 changed files with 3246 additions and 741 deletions
132
src/AcDream.App/Rendering/AttachmentUpdateOrder.cs
Normal file
132
src/AcDream.App/Rendering/AttachmentUpdateOrder.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable parent-before-child traversal for retail's
|
||||
/// <c>CPhysicsObj::UpdateChildrenInternal</c> attachment graph. Failed parent
|
||||
/// projections suppress their descendants for the same frame; callers remove
|
||||
/// those projections only after traversal, so dictionary enumeration remains
|
||||
/// stable.
|
||||
/// </summary>
|
||||
internal sealed class AttachmentUpdateOrder<TChild>
|
||||
{
|
||||
private readonly HashSet<uint> _visiting = new();
|
||||
private readonly HashSet<uint> _completed = new();
|
||||
private readonly HashSet<uint> _failedSet = new();
|
||||
private readonly List<uint> _failed = new();
|
||||
private readonly List<uint> _subtree = new();
|
||||
private readonly Queue<uint> _recoveryQueue = new();
|
||||
private readonly HashSet<uint> _recoveryVisited = new();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the current attachment map without per-frame delegate or
|
||||
/// enumerator boxing. The returned list is borrowed until the next call.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> ForEachParentFirst(
|
||||
Dictionary<uint, TChild> children,
|
||||
Func<TChild, uint?> parentOf,
|
||||
Func<uint, bool> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(children);
|
||||
ArgumentNullException.ThrowIfNull(parentOf);
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
_visiting.Clear();
|
||||
_completed.Clear();
|
||||
_failedSet.Clear();
|
||||
_failed.Clear();
|
||||
foreach (uint childId in children.Keys)
|
||||
Visit(childId, children, parentOf, update);
|
||||
return _failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns descendants before ancestors so an attachment subtree can be
|
||||
/// withdrawn without leaving a child rooted at a stale intermediate pose.
|
||||
/// The returned list is borrowed until the next call.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> CollectSubtreePostOrder(
|
||||
Dictionary<uint, TChild> children,
|
||||
uint rootId,
|
||||
Func<TChild, uint?> parentOf)
|
||||
{
|
||||
_subtree.Clear();
|
||||
_visiting.Clear();
|
||||
Collect(rootId, children, parentOf);
|
||||
return _subtree;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retries every waiting child after a parent pose becomes available.
|
||||
/// Each successfully realized child becomes a parent candidate in turn,
|
||||
/// so A→B→C recovers in one parent-first drain.
|
||||
/// </summary>
|
||||
public void RealizeDescendants(
|
||||
uint parentId,
|
||||
Func<uint, IReadOnlyList<uint>> childrenWaitingForParent,
|
||||
Func<uint, bool> realize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(childrenWaitingForParent);
|
||||
ArgumentNullException.ThrowIfNull(realize);
|
||||
|
||||
_recoveryQueue.Clear();
|
||||
_recoveryVisited.Clear();
|
||||
_recoveryQueue.Enqueue(parentId);
|
||||
_recoveryVisited.Add(parentId);
|
||||
while (_recoveryQueue.Count > 0)
|
||||
{
|
||||
uint currentParent = _recoveryQueue.Dequeue();
|
||||
IReadOnlyList<uint> waiting = childrenWaitingForParent(currentParent);
|
||||
for (int i = 0; i < waiting.Count; i++)
|
||||
{
|
||||
uint childId = waiting[i];
|
||||
if (realize(childId) && _recoveryVisited.Add(childId))
|
||||
_recoveryQueue.Enqueue(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Collect(
|
||||
uint rootId,
|
||||
Dictionary<uint, TChild> children,
|
||||
Func<TChild, uint?> parentOf)
|
||||
{
|
||||
if (!_visiting.Add(rootId))
|
||||
return;
|
||||
foreach ((uint candidateId, TChild child) in children)
|
||||
{
|
||||
if (parentOf(child) == rootId)
|
||||
Collect(candidateId, children, parentOf);
|
||||
}
|
||||
_subtree.Add(rootId);
|
||||
}
|
||||
|
||||
private void Visit(
|
||||
uint childId,
|
||||
Dictionary<uint, TChild> children,
|
||||
Func<TChild, uint?> parentOf,
|
||||
Func<uint, bool> update)
|
||||
{
|
||||
if (_completed.Contains(childId)
|
||||
|| !children.TryGetValue(childId, out TChild? child))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!_visiting.Add(childId))
|
||||
return;
|
||||
|
||||
uint? parentId = parentOf(child);
|
||||
if (parentId is { } attachedParentId && children.ContainsKey(attachedParentId))
|
||||
Visit(attachedParentId, children, parentOf, update);
|
||||
|
||||
_visiting.Remove(childId);
|
||||
if (!_completed.Add(childId))
|
||||
return;
|
||||
|
||||
bool succeeded = parentId is not { } parent
|
||||
|| !_failedSet.Contains(parent);
|
||||
if (succeeded)
|
||||
succeeded = update(childId);
|
||||
if (!succeeded && _failedSet.Add(childId))
|
||||
_failed.Add(childId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// from the current animation frame; only these two fields are
|
||||
/// reused unchanged.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
|
||||
public required IReadOnlyList<AnimatedPartTemplate> PartTemplate;
|
||||
public required IReadOnlyList<bool> PartAvailability;
|
||||
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
||||
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
|
||||
|
||||
|
|
@ -307,8 +308,15 @@ public sealed class GameWindow : IDisposable
|
|||
/// in-place mutation of this list.
|
||||
/// </summary>
|
||||
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
|
||||
/// <summary>Indexed final part poses, including debug-hidden parts.</summary>
|
||||
public readonly List<System.Numerics.Matrix4x4> EffectPartPosesScratch = new();
|
||||
}
|
||||
|
||||
internal readonly record struct AnimatedPartTemplate(
|
||||
uint GfxObjId,
|
||||
IReadOnlyDictionary<uint, uint>? SurfaceOverrides,
|
||||
bool IsDrawable);
|
||||
|
||||
private sealed record AppearanceUpdateState(
|
||||
AcDream.Core.World.WorldEntity Entity,
|
||||
AnimatedEntity? Animation);
|
||||
|
|
@ -332,6 +340,8 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
|
||||
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
||||
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
||||
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
|
||||
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
|
||||
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
|
||||
// and typed PlayScriptType (0xF755) events through one effect owner, then
|
||||
// fans every dat-defined hook to particles, audio, lights, translucency,
|
||||
|
|
@ -815,6 +825,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Wired into the hook router in OnLoad so SetLightHook fires
|
||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
|
||||
|
||||
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||
|
|
@ -1444,7 +1455,12 @@ public sealed class GameWindow : IDisposable
|
|||
// StopParticle hooks fired from motion tables produce visible
|
||||
// spawns. The Tick call is driven from OnRender.
|
||||
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
|
||||
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
|
||||
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
|
||||
_particleSink.DiagnosticSink = message =>
|
||||
Console.Error.WriteLine($"vfx: {message}");
|
||||
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
|
||||
_hookRouter,
|
||||
_effectPoses);
|
||||
_hookRouter.Register(_particleSink);
|
||||
|
||||
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
|
||||
|
|
@ -1460,7 +1476,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
|
||||
// owner-tagged lights so ignite-torch animations light up,
|
||||
// extinguish-torch animations go dark.
|
||||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
|
||||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
|
||||
_hookRouter.Register(_lightingSink);
|
||||
|
||||
// #188 — TransparentPartHook (per-part translucency fade, e.g.
|
||||
|
|
@ -2294,8 +2310,10 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
|
||||
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
|
||||
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
|
||||
// transforms via SetupPartTransforms.Compute so multi-emitter scripts
|
||||
// distribute across mesh parts (closes #56). _scriptRunner and
|
||||
// transforms from the entity's exact current MeshRefs so
|
||||
// multi-emitter scripts distribute across mesh parts (closes #56).
|
||||
// Animated frames replace this snapshot through EntityEffectPoseRegistry.
|
||||
// _scriptRunner and
|
||||
// _particleSink are initialised earlier in OnLoad (line ~1083); both
|
||||
// are non-null here. The resolver lambda captures _dats and swallows
|
||||
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
|
||||
|
|
@ -2309,12 +2327,21 @@ public sealed class GameWindow : IDisposable
|
|||
e.SourceGfxObjOrSetupId);
|
||||
if (setup is null) return null;
|
||||
uint scriptId = setup.DefaultScript.DataId;
|
||||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||||
if (e.IndexedPartTransforms.Count == 0)
|
||||
{
|
||||
var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
|
||||
setup,
|
||||
e);
|
||||
e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
|
||||
}
|
||||
IReadOnlyList<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
|
||||
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
|
||||
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
|
||||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
|
||||
scriptId,
|
||||
parts,
|
||||
profile);
|
||||
profile,
|
||||
availability);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -2324,6 +2351,7 @@ public sealed class GameWindow : IDisposable
|
|||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||||
_scriptRunner!,
|
||||
_particleSink!,
|
||||
_effectPoses,
|
||||
ResolveActivation,
|
||||
(ownerId, entity, profile) =>
|
||||
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
|
||||
|
|
@ -2364,12 +2392,24 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}),
|
||||
TearDownLiveEntityRuntimeComponents);
|
||||
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (record.WorldEntity is { } entity)
|
||||
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
|
||||
};
|
||||
|
||||
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
||||
_liveEntities,
|
||||
_effectPoses,
|
||||
_lightingSink!,
|
||||
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
|
||||
|
||||
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
|
||||
_dats!,
|
||||
_datLock,
|
||||
Objects,
|
||||
_liveEntities,
|
||||
_effectPoses,
|
||||
TryAcceptParentForRender);
|
||||
|
||||
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
|
||||
|
|
@ -2378,6 +2418,7 @@ public sealed class GameWindow : IDisposable
|
|||
_liveEntities,
|
||||
_scriptRunner!,
|
||||
tableResolver,
|
||||
_effectPoses,
|
||||
(parentLocalId, partIndex) =>
|
||||
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
|
||||
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
|
||||
|
|
@ -2389,8 +2430,14 @@ public sealed class GameWindow : IDisposable
|
|||
_entitySoundTables?.Set(ownerId, did);
|
||||
});
|
||||
_entityEffects = entityEffects;
|
||||
entityEffects.DiagnosticSink = message =>
|
||||
Console.Error.WriteLine($"vfx: {message}");
|
||||
_equippedChildRenderer.EntityReady += guid =>
|
||||
{
|
||||
entityEffects.OnLiveEntityReady(guid);
|
||||
};
|
||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||
_hookRouter.Register(entityEffects);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
|
|
@ -2619,6 +2666,7 @@ public sealed class GameWindow : IDisposable
|
|||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3626,14 +3674,39 @@ public sealed class GameWindow : IDisposable
|
|||
// creatures/characters whose size is intrinsic to the mesh).
|
||||
float scale = spawn.ObjScale ?? 1.0f;
|
||||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
|
||||
IReadOnlyList<System.Numerics.Matrix4x4> rigidPartTransforms =
|
||||
AcDream.Core.Meshing.SetupPartTransforms.Compute(
|
||||
setup,
|
||||
idleFrame,
|
||||
scale);
|
||||
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count];
|
||||
var indexedPartAvailable = new bool[parts.Count];
|
||||
var animatedPartTemplate = new AnimatedPartTemplate[parts.Count];
|
||||
var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
int dumpClothingTotalTris = 0;
|
||||
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||
{
|
||||
var mr = parts[partIdx];
|
||||
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
|
||||
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
|
||||
surfaceOverrides = partOverrides;
|
||||
|
||||
// Keep the Setup index even when the visual GfxObj is absent.
|
||||
// MeshRefs is a drawable-only list, while hooks and holding
|
||||
// locations address the stable CPartArray slot number.
|
||||
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
|
||||
indexedPartTransforms[partIdx] = partIdx < rigidPartTransforms.Count
|
||||
? rigidPartTransforms[partIdx]
|
||||
: System.Numerics.Matrix4x4.Identity;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
bool isDrawable = gfx is not null;
|
||||
indexedPartAvailable[partIdx] = isDrawable;
|
||||
animatedPartTemplate[partIdx] = new AnimatedPartTemplate(
|
||||
mr.GfxObjId,
|
||||
surfaceOverrides,
|
||||
isDrawable);
|
||||
if (gfx is null)
|
||||
{
|
||||
if (dumpClothing)
|
||||
|
|
@ -3650,10 +3723,6 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
|
||||
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
|
||||
surfaceOverrides = partOverrides;
|
||||
|
||||
// Multiplication order matches offline scenery hydration:
|
||||
// `PartTransform * scaleMat`. In row-vector semantics this means
|
||||
// "apply PartTransform first (which includes the part-attachment
|
||||
|
|
@ -3663,8 +3732,6 @@ public sealed class GameWindow : IDisposable
|
|||
// for multi-part entities like the Nullified Statue that causes
|
||||
// the parts to drift relative to each other ("distorted") and the
|
||||
// base anchor to end up below the ground ("sinks into foundry").
|
||||
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
|
||||
|
||||
// #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb).
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) liveBounds.Add(transform, pb.Value);
|
||||
|
|
@ -3719,10 +3786,17 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
|
||||
existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
|
||||
existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
|
||||
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
|
||||
existing.SetLocalBounds(appearanceMin, appearanceMax);
|
||||
if (visualUpdate.Animation is { } animation)
|
||||
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
|
||||
RebindAnimatedEntityForAppearance(
|
||||
animation,
|
||||
existing,
|
||||
setup,
|
||||
scale,
|
||||
animatedPartTemplate,
|
||||
indexedPartAvailable);
|
||||
_classificationCache.InvalidateEntity(existing.Id);
|
||||
if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
|
||||
&& record.ProjectionKind is LiveEntityProjectionKind.Attached)
|
||||
|
|
@ -3732,6 +3806,14 @@ public sealed class GameWindow : IDisposable
|
|||
// after replacing the child's visual description.
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// SmartBox::UpdateVisualDesc mutates the existing PartArray.
|
||||
// Publish those exact replacement part frames before another
|
||||
// packet in this update can execute a part-attached hook.
|
||||
_effectPoses.PublishMeshRefs(existing);
|
||||
_equippedChildRenderer?.OnPosePublished(spawn.Guid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3762,6 +3844,7 @@ public sealed class GameWindow : IDisposable
|
|||
PartOverrides = entityPartOverrides,
|
||||
ParentCellId = spawn.Position.Value.LandblockId,
|
||||
};
|
||||
created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
|
||||
if (liveBounds.TryGet(out var createdMin, out var createdMax))
|
||||
created.SetLocalBounds(createdMin, createdMax);
|
||||
return created;
|
||||
|
|
@ -3777,8 +3860,10 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Rotation = rot;
|
||||
entity.ParentCellId = spawn.Position.Value.LandblockId;
|
||||
entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
|
||||
entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
|
||||
if (liveBounds.TryGet(out var retainedMin, out var retainedMax))
|
||||
entity.SetLocalBounds(retainedMin, retainedMax);
|
||||
_effectPoses.PublishMeshRefs(entity);
|
||||
}
|
||||
|
||||
// Retail CPhysicsObj::leave_world removes cell/shadow membership but
|
||||
|
|
@ -3788,47 +3873,6 @@ public sealed class GameWindow : IDisposable
|
|||
bool retainedAnimationRuntime = !createdProjection
|
||||
&& _animatedEntities.TryGetValue(entity.Id, out _);
|
||||
|
||||
// A7 indoor lighting: server-spawned weenie fixtures (lanterns,
|
||||
// braziers, glowing items) carry their light in Setup.Lights exactly
|
||||
// like dat-baked statics, but the static registration in
|
||||
// ApplyLoadedTerrainLocked never sees CreateObject entities — so an
|
||||
// interior's lanterns cast no light and the room reads dark. Register
|
||||
// them here, mirroring that path (GameWindow.cs ~6742). Owned by
|
||||
// entity.Id, so leave-world and logical teardown both remove their
|
||||
// cell-scoped presentation. Retail registers object-borne lights
|
||||
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
|
||||
// The light is placed at the spawn frame and does NOT follow a moving
|
||||
// light-bearer yet (register row AP-44) — fine for stationary fixtures,
|
||||
// which is the common case. _dats is non-null here (checked above).
|
||||
if (_lightingSink is not null
|
||||
&& (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var liteSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
|
||||
if (liteSetup is not null && liteSetup.Lights.Count > 0)
|
||||
{
|
||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||
liteSetup,
|
||||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation,
|
||||
// A7 fix #2 (#176/#181): isDynamic is decided by whether the light
|
||||
// MOVES, not by weenie-vs-dat origin. Server-spawned FIXTURES
|
||||
// (lanterns, braziers) are stationary — they take retail's STATIC
|
||||
// curve (calc_point_light 0x0059c8b0: 1/d³ beyond 1 m, range×1.3,
|
||||
// per-channel colour clamp), not the D3D dynamic path (1/d,
|
||||
// range×1.5) the former #143 flag forced — which burned every
|
||||
// fixture ~10× hotter than retail at 3 m: the magenta wash that
|
||||
// zebra-striped the Facility Hub walls. Site-A lights are all
|
||||
// stationary today (AP-44: they don't follow bearers); genuinely
|
||||
// moving lights (projectiles, portal swirls) re-earn isDynamic
|
||||
// when they exist.
|
||||
isDynamic: false,
|
||||
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||
foreach (var ls in loaded)
|
||||
_lightingSink.RegisterOwnedLight(ls);
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
Id: entity.Id,
|
||||
SourceId: entity.SourceGfxObjOrSetupId,
|
||||
|
|
@ -3895,9 +3939,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Snapshot per-part identity from the hydrated meshRefs so the
|
||||
// tick can rebuild MeshRefs without redoing AnimPartChanges or
|
||||
// texture-override resolution every frame.
|
||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
|
||||
AnimatedPartTemplate[] template = animatedPartTemplate;
|
||||
|
||||
// Create an AnimationSequencer if we can load the MotionTable.
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
|
||||
|
|
@ -3925,6 +3967,7 @@ public sealed class GameWindow : IDisposable
|
|||
Framerate = idleCycle.Framerate,
|
||||
Scale = scale,
|
||||
PartTemplate = template,
|
||||
PartAvailability = indexedPartAvailable,
|
||||
CurrFrame = idleCycle.LowFrame,
|
||||
Sequencer = sequencer,
|
||||
};
|
||||
|
|
@ -3969,9 +4012,7 @@ public sealed class GameWindow : IDisposable
|
|||
var sequencer = SpawnMotionInitializer.Create(
|
||||
setup, mtable, _animLoader, spawn.MotionState);
|
||||
|
||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
|
||||
AnimatedPartTemplate[] template = animatedPartTemplate;
|
||||
|
||||
_animatedEntities[entity.Id] = new AnimatedEntity
|
||||
{
|
||||
|
|
@ -3983,6 +4024,7 @@ public sealed class GameWindow : IDisposable
|
|||
Framerate = 0f,
|
||||
Scale = scale,
|
||||
PartTemplate = template,
|
||||
PartAvailability = indexedPartAvailable,
|
||||
CurrFrame = 0,
|
||||
Sequencer = sequencer,
|
||||
};
|
||||
|
|
@ -4124,15 +4166,14 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.World.WorldEntity entity,
|
||||
DatReaderWriter.DBObjs.Setup setup,
|
||||
float scale,
|
||||
IReadOnlyList<AcDream.Core.World.MeshRef> meshRefs)
|
||||
IReadOnlyList<AnimatedPartTemplate> partTemplate,
|
||||
IReadOnlyList<bool> partAvailability)
|
||||
{
|
||||
animation.Entity = entity;
|
||||
animation.Setup = setup;
|
||||
animation.Scale = scale;
|
||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
|
||||
animation.PartTemplate = template;
|
||||
animation.PartTemplate = partTemplate;
|
||||
animation.PartAvailability = partAvailability;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4878,6 +4919,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||
LeaveWorldLiveEntityRuntimeComponents(record);
|
||||
_liveEntityLights?.Forget(existingEntity.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4897,10 +4939,10 @@ public sealed class GameWindow : IDisposable
|
|||
if (record.ServerGuid == _playerServerGuid)
|
||||
_lastLocalPlayerShadow = null;
|
||||
|
||||
// Object-borne lights are cell-scoped presentation. The owning Setup
|
||||
// and logical light capability remain on the live record; re-entry
|
||||
// registers the light in the new cell without duplicating it.
|
||||
_lightingSink?.UnregisterOwner(entity.Id);
|
||||
// Pose-attached effects keep logical ownership while cell-less. A
|
||||
// missing pose suppresses stale anchors; re-entry republishes the same
|
||||
// WorldEntity frames without replaying create-time resources.
|
||||
_effectPoses.Remove(entity.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -5625,6 +5667,8 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
|
||||
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
||||
// L.2g slice 1c (2026-05-13): the server addresses entities by
|
||||
|
|
@ -6628,29 +6672,25 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
|
||||
seen.Add(key);
|
||||
|
||||
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
|
||||
continue;
|
||||
|
||||
uint skyEntityId = SkyPesEntityId(key);
|
||||
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
|
||||
var renderPass = obj.IsPostScene
|
||||
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
|
||||
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
|
||||
_particleSink.SetEntityRenderPass(skyEntityId, renderPass);
|
||||
var anchor = SkyPesAnchor(obj, cameraWorldPos);
|
||||
var rotation = SkyPesRotation(obj, dayFraction);
|
||||
// Refresh anchor + rotation every frame so AttachLocal
|
||||
// (is_parent_local=1) particles track the camera. Retail
|
||||
// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the
|
||||
// live parent frame each tick; for sky-PES the parent IS
|
||||
// the camera. UpdateEntityAnchor is a no-op when no
|
||||
// emitters yet exist (script just spawned this frame).
|
||||
_particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation);
|
||||
_effectPoses.Publish(
|
||||
skyEntityId,
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(anchor),
|
||||
System.Array.Empty<System.Numerics.Matrix4x4>(),
|
||||
cellId: 0u);
|
||||
|
||||
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
|
||||
continue;
|
||||
|
||||
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
|
||||
|
||||
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
|
||||
{
|
||||
_activeSkyPes.Add(key);
|
||||
|
|
@ -6660,6 +6700,7 @@ public sealed class GameWindow : IDisposable
|
|||
_missingSkyPes.Add(key);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.ClearEntityRenderPass(skyEntityId);
|
||||
_effectPoses.Remove(skyEntityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6673,6 +6714,7 @@ public sealed class GameWindow : IDisposable
|
|||
_scriptRunner.StopAllForEntity(skyEntityId);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
|
||||
_effectPoses.Remove(skyEntityId);
|
||||
_activeSkyPes.Remove(key);
|
||||
}
|
||||
|
||||
|
|
@ -9110,6 +9152,7 @@ public sealed class GameWindow : IDisposable
|
|||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
_equippedChildRenderer?.Tick();
|
||||
_animationHookFrames?.Drain();
|
||||
|
||||
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
|
||||
// _animatedEntities.Count): a one-shot open-cycle animation can
|
||||
|
|
@ -9276,8 +9319,10 @@ public sealed class GameWindow : IDisposable
|
|||
// debug-only and disabled for normal retail rendering.
|
||||
if (_options.EnableSkyPesDebug)
|
||||
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
|
||||
_entityEffects?.RefreshLiveOwnerAnchors();
|
||||
_entityEffects?.RefreshLiveOwnerPoses();
|
||||
_scriptRunner?.Tick(_physicsScriptGameTime);
|
||||
_particleSink?.RefreshAttachedEmitters();
|
||||
_liveEntityLights?.Refresh();
|
||||
_particleSystem?.Tick((float)deltaSeconds);
|
||||
|
||||
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||
|
|
@ -10369,33 +10414,13 @@ public sealed class GameWindow : IDisposable
|
|||
};
|
||||
}
|
||||
|
||||
// Phase E.1: drain animation hooks (footstep sounds, attack
|
||||
// damage frames, particle spawns, part swaps, etc.) and fan
|
||||
// them out to registered subsystems via the hook router.
|
||||
// Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
|
||||
//
|
||||
// R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam;
|
||||
// per-tick PLACEMENT provisional until R6 installs retail's
|
||||
// UpdateObjectInternal order): each drained AnimDone hook
|
||||
// advances the entity's MotionTableManager countdown
|
||||
// (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone
|
||||
// 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime
|
||||
// runs once per tick to sweep zero-tick queue entries
|
||||
// (retail call sites 0x00517d57/0x00517d67).
|
||||
var hooks = ae.Sequencer.ConsumePendingHooks();
|
||||
if (hooks.Count > 0)
|
||||
{
|
||||
System.Numerics.Vector3 worldPos = ae.Entity.Position;
|
||||
for (int hi = 0; hi < hooks.Count; hi++)
|
||||
{
|
||||
var hook = hooks[hi];
|
||||
if (hook is null) continue;
|
||||
_hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
|
||||
if (hook is DatReaderWriter.Types.AnimationDoneHook)
|
||||
ae.Sequencer.Manager.AnimationDone(success: true);
|
||||
}
|
||||
}
|
||||
ae.Sequencer.Manager.UseTime();
|
||||
// Capture hooks now, but deliver them only after every final
|
||||
// part transform and equipped-child root has been published.
|
||||
// This matches CPhysicsObj::UpdateObjectInternal followed by
|
||||
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
|
||||
// reads this frame's pose, never the previous frame's pose.
|
||||
// AnimationDone/UseTime remain paired with the deferred drain.
|
||||
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -10475,6 +10500,8 @@ public sealed class GameWindow : IDisposable
|
|||
// consumer caches a stale reference or diffs list identity).
|
||||
var newMeshRefs = ae.MeshRefsScratch;
|
||||
newMeshRefs.Clear();
|
||||
var effectPartPoses = ae.EffectPartPosesScratch;
|
||||
effectPartPoses.Clear();
|
||||
var scaleMat = ae.Scale == 1.0f
|
||||
? System.Numerics.Matrix4x4.Identity
|
||||
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
|
||||
|
|
@ -10532,7 +10559,7 @@ public sealed class GameWindow : IDisposable
|
|||
? ae.Setup.DefaultScale[i]
|
||||
: System.Numerics.Vector3.One;
|
||||
|
||||
var partTransform =
|
||||
var visualPartTransform =
|
||||
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(origin);
|
||||
|
|
@ -10540,14 +10567,23 @@ public sealed class GameWindow : IDisposable
|
|||
// Bake the entity's ObjScale on top, matching the hydration
|
||||
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
|
||||
if (ae.Scale != 1.0f)
|
||||
partTransform = partTransform * scaleMat;
|
||||
visualPartTransform *= scaleMat;
|
||||
|
||||
// Retail CPartArray::UpdateParts (0x005190F0) scales only the
|
||||
// frame origin. Setup DefaultScale is visual gfxobj_scale,
|
||||
// not part-frame state (SetScaleInternal 0x00518A00).
|
||||
var rigidPartTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale);
|
||||
effectPartPoses.Add(rigidPartTransform);
|
||||
|
||||
var template = ae.PartTemplate[i];
|
||||
if (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)
|
||||
if (!template.IsDrawable
|
||||
|| (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
|
||||
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform)
|
||||
{
|
||||
SurfaceOverrides = template.SurfaceOverrides,
|
||||
});
|
||||
|
|
@ -10557,6 +10593,8 @@ public sealed class GameWindow : IDisposable
|
|||
// property store) and keeps this line's shape identical to the
|
||||
// pre-MP-Alloc code for anyone grepping the history.
|
||||
ae.Entity.MeshRefs = newMeshRefs;
|
||||
ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability);
|
||||
_effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -14019,7 +14057,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
finally
|
||||
{
|
||||
_liveEntityLights?.Dispose();
|
||||
_liveEntityLights = null;
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
_effectPoses.Clear();
|
||||
}
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
draws.Clear();
|
||||
foreach (var (em, idx) in particles.EnumerateLive())
|
||||
{
|
||||
if (!em.PresentationVisible)
|
||||
continue;
|
||||
if (em.RenderPass != renderPass)
|
||||
continue;
|
||||
if (emitterFilter is not null && !emitterFilter(em))
|
||||
|
|
|
|||
89
src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs
Normal file
89
src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Defers animation-hook delivery until every entity and equipped child has
|
||||
/// published its final pose for the frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail stages hooks during sequence advance, then the same object update
|
||||
/// commits the final part frame and runs <c>CPhysicsObj::UpdateChild</c>
|
||||
/// (<c>0x00512D50</c>) before particles advance or anything draws. Capturing
|
||||
/// here preserves that observable order and avoids firing a hand or weapon
|
||||
/// effect against the previous animation frame.
|
||||
/// </remarks>
|
||||
public sealed class AnimationHookFrameQueue
|
||||
{
|
||||
private readonly AnimationHookRouter _router;
|
||||
private readonly IEntityEffectPoseSource _poses;
|
||||
private readonly List<Entry> _entries = new();
|
||||
|
||||
public AnimationHookFrameQueue(
|
||||
AnimationHookRouter router,
|
||||
IEntityEffectPoseSource poses)
|
||||
{
|
||||
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
}
|
||||
|
||||
public int Count => _entries.Count;
|
||||
|
||||
public void Capture(uint ownerLocalId, AnimationSequencer sequencer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
Capture(ownerLocalId, sequencer, sequencer.ConsumePendingHooks());
|
||||
}
|
||||
|
||||
internal void Capture(
|
||||
uint ownerLocalId,
|
||||
AnimationSequencer sequencer,
|
||||
IReadOnlyList<AnimationHook> hooks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
ArgumentNullException.ThrowIfNull(hooks);
|
||||
_entries.Add(new Entry(
|
||||
ownerLocalId,
|
||||
sequencer,
|
||||
hooks));
|
||||
}
|
||||
|
||||
public void Drain()
|
||||
{
|
||||
for (int i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
Entry entry = _entries[i];
|
||||
bool hasRootPose = _poses.TryGetRootPose(
|
||||
entry.OwnerLocalId,
|
||||
out Matrix4x4 rootWorld);
|
||||
Vector3 worldPosition = hasRootPose
|
||||
? rootWorld.Translation
|
||||
: Vector3.Zero;
|
||||
for (int hi = 0; hi < entry.Hooks.Count; hi++)
|
||||
{
|
||||
AnimationHook? hook = entry.Hooks[hi];
|
||||
if (hook is null)
|
||||
continue;
|
||||
if (hasRootPose)
|
||||
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
|
||||
if (hook is AnimationDoneHook)
|
||||
entry.Sequencer.Manager.AnimationDone(success: true);
|
||||
}
|
||||
|
||||
// Retail sweeps zero-tick motion entries even on frames without a
|
||||
// hook. It remains paired with every sequencer advanced this frame.
|
||||
entry.Sequencer.Manager.UseTime();
|
||||
}
|
||||
_entries.Clear();
|
||||
}
|
||||
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
private readonly record struct Entry(
|
||||
uint OwnerLocalId,
|
||||
AnimationSequencer Sequencer,
|
||||
IReadOnlyList<AnimationHook> Hooks);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly PhysicsScriptRunner _runner;
|
||||
private readonly PhysicsScriptTableResolver _tables;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly Func<uint, uint, uint?> _childAtPart;
|
||||
private readonly Func<uint, uint?> _parentOfAttachedChild;
|
||||
private readonly Action<uint> _ownerUnregistered;
|
||||
|
|
@ -42,6 +43,7 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
LiveEntityRuntime liveEntities,
|
||||
PhysicsScriptRunner runner,
|
||||
PhysicsScriptTableResolver tables,
|
||||
EntityEffectPoseRegistry poses,
|
||||
Func<uint, uint, uint?>? childAtPart = null,
|
||||
Func<uint, uint?>? parentOfAttachedChild = null,
|
||||
Action<uint>? ownerUnregistered = null,
|
||||
|
|
@ -50,6 +52,7 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_childAtPart = childAtPart ?? ((_, _) => null);
|
||||
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
|
||||
_ownerUnregistered = ownerUnregistered ?? (_ => { });
|
||||
|
|
@ -201,8 +204,8 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
_pendingByServerGuid.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
|
||||
public void RefreshLiveOwnerAnchors()
|
||||
/// <summary>Refreshes every live root after animation/movement.</summary>
|
||||
public void RefreshLiveOwnerPoses()
|
||||
{
|
||||
foreach (uint serverGuid in _readyServerGuids.ToArray())
|
||||
{
|
||||
|
|
@ -353,7 +356,18 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
&& record.WorldEntity is { } entity
|
||||
&& entity.Id == localId)
|
||||
{
|
||||
_runner.SetOwnerAnchor(localId, entity.Position);
|
||||
// Top-level roots follow the WorldEntity directly. Attached roots
|
||||
// were already composed through the parent's current part by
|
||||
// EquippedChildRenderController; overwriting one with the child's
|
||||
// bookkeeping Position would collapse a weapon effect to the
|
||||
// parent's origin.
|
||||
if (record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_poses.UpdateRoot(entity);
|
||||
|
||||
Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
|
||||
? rootWorld.Translation
|
||||
: entity.Position;
|
||||
_runner.SetOwnerAnchor(localId, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
216
src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs
Normal file
216
src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread registry of final root and indexed part poses for particles,
|
||||
/// lights, and other DAT-driven entity effects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail composes particle anchors from the current physics-object/part frame
|
||||
/// in <c>Particle::Init</c> (<c>0x0051C930</c>) and refreshes parent-local
|
||||
/// particles from those frames in <c>ParticleEmitter::UpdateParticles</c>
|
||||
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
|
||||
/// those same final frames without coupling Core effects to the renderer.
|
||||
/// </remarks>
|
||||
public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource
|
||||
{
|
||||
private sealed class PoseRecord
|
||||
{
|
||||
public Matrix4x4 RootWorld;
|
||||
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
|
||||
public bool[] PartAvailable = Array.Empty<bool>();
|
||||
public uint CellId;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, PoseRecord> _poses = new();
|
||||
|
||||
public int Count => _poses.Count;
|
||||
|
||||
public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal)
|
||||
{
|
||||
Publish(entity, partLocal, availability: null);
|
||||
}
|
||||
|
||||
public void Publish(
|
||||
WorldEntity entity,
|
||||
IReadOnlyList<Matrix4x4> partLocal,
|
||||
IReadOnlyList<bool>? availability)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
Publish(
|
||||
entity.Id,
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
||||
* Matrix4x4.CreateTranslation(entity.Position),
|
||||
partLocal,
|
||||
entity.ParentCellId ?? 0u,
|
||||
availability);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish the exact indexed part transforms already installed on an
|
||||
/// entity. Appearance replacement uses this overload so a PhysicsScript
|
||||
/// delivered later in the same update observes the new parts immediately.
|
||||
/// </summary>
|
||||
public void PublishMeshRefs(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
||||
* Matrix4x4.CreateTranslation(entity.Position);
|
||||
|
||||
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
_poses.Add(entity.Id, record);
|
||||
}
|
||||
|
||||
record.RootWorld = rootWorld;
|
||||
record.CellId = entity.ParentCellId ?? 0u;
|
||||
if (entity.IndexedPartTransforms.Count > 0)
|
||||
{
|
||||
CopyParts(
|
||||
record,
|
||||
entity.IndexedPartTransforms,
|
||||
entity.IndexedPartAvailable);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (record.PartLocal.Length != entity.MeshRefs.Count)
|
||||
record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
|
||||
if (record.PartAvailable.Length != entity.MeshRefs.Count)
|
||||
record.PartAvailable = new bool[entity.MeshRefs.Count];
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
{
|
||||
record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
|
||||
record.PartAvailable[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish(
|
||||
uint localEntityId,
|
||||
Matrix4x4 rootWorld,
|
||||
IReadOnlyList<Matrix4x4> partLocal,
|
||||
uint cellId,
|
||||
IReadOnlyList<bool>? availability = null)
|
||||
{
|
||||
if (localEntityId == 0)
|
||||
return;
|
||||
ArgumentNullException.ThrowIfNull(partLocal);
|
||||
|
||||
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
_poses.Add(localEntityId, record);
|
||||
}
|
||||
|
||||
record.RootWorld = rootWorld;
|
||||
record.CellId = cellId;
|
||||
CopyParts(record, partLocal, availability);
|
||||
}
|
||||
|
||||
/// <summary>Refresh only the moving root while retaining current part poses.</summary>
|
||||
public bool UpdateRoot(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
||||
return false;
|
||||
record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
||||
* Matrix4x4.CreateTranslation(entity.Position);
|
||||
record.CellId = entity.ParentCellId ?? 0u;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Remove(uint localEntityId) => _poses.Remove(localEntityId);
|
||||
|
||||
public void Clear() => _poses.Clear();
|
||||
|
||||
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
rootWorld = record.RootWorld;
|
||||
return true;
|
||||
}
|
||||
rootWorld = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
|
||||
{
|
||||
if (partIndex >= 0
|
||||
&& _poses.TryGetValue(localEntityId, out PoseRecord? record)
|
||||
&& partIndex < record.PartLocal.Length
|
||||
&& record.PartAvailable[partIndex])
|
||||
{
|
||||
partLocal = record.PartLocal[partIndex];
|
||||
return true;
|
||||
}
|
||||
partLocal = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrow the current indexed part-pose snapshot for synchronous
|
||||
/// update-thread composition. Callers must not retain or mutate it.
|
||||
/// </summary>
|
||||
public bool TryGetPartPoses(
|
||||
uint localEntityId,
|
||||
out IReadOnlyList<Matrix4x4> partLocal)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
partLocal = record.PartLocal;
|
||||
return true;
|
||||
}
|
||||
partLocal = Array.Empty<Matrix4x4>();
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPartPoseSnapshot(
|
||||
uint localEntityId,
|
||||
out IReadOnlyList<Matrix4x4> partLocal,
|
||||
out IReadOnlyList<bool> availability)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
partLocal = record.PartLocal;
|
||||
availability = record.PartAvailable;
|
||||
return true;
|
||||
}
|
||||
partLocal = Array.Empty<Matrix4x4>();
|
||||
availability = Array.Empty<bool>();
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetCellId(uint localEntityId, out uint cellId)
|
||||
{
|
||||
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
cellId = record.CellId;
|
||||
return true;
|
||||
}
|
||||
cellId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void CopyParts(
|
||||
PoseRecord record,
|
||||
IReadOnlyList<Matrix4x4> partLocal,
|
||||
IReadOnlyList<bool>? availability)
|
||||
{
|
||||
if (availability is not null && availability.Count != partLocal.Count)
|
||||
throw new ArgumentException("Part pose and availability counts must match.");
|
||||
if (record.PartLocal.Length != partLocal.Count)
|
||||
record.PartLocal = new Matrix4x4[partLocal.Count];
|
||||
if (record.PartAvailable.Length != partLocal.Count)
|
||||
record.PartAvailable = new bool[partLocal.Count];
|
||||
for (int i = 0; i < partLocal.Count; i++)
|
||||
{
|
||||
record.PartLocal[i] = partLocal[i];
|
||||
record.PartAvailable[i] = availability is null || availability[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
|
@ -7,97 +5,57 @@ using AcDream.Core.World;
|
|||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// What the activator's resolver returns when an entity's Setup carries
|
||||
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
|
||||
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
|
||||
/// lookup yields both pieces of state. The activator pushes the part
|
||||
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
|
||||
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
|
||||
/// the part-anchor pipeline introduced for issue #56.
|
||||
/// Setup script/profile data resolved once when a logical effect owner is
|
||||
/// registered. Part transforms are indexed and root-local.
|
||||
/// </summary>
|
||||
public sealed record ScriptActivationInfo(
|
||||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms,
|
||||
EntityEffectProfile? EffectProfile = null);
|
||||
EntityEffectProfile? EffectProfile = null,
|
||||
IReadOnlyList<bool>? PartAvailability = null);
|
||||
|
||||
/// <summary>
|
||||
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
|
||||
/// when a <see cref="WorldEntity"/> enters the world, so static objects
|
||||
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
|
||||
/// emit their retail-faithful persistent particle effects automatically.
|
||||
/// Stops the scripts and live emitters when the entity despawns.
|
||||
///
|
||||
/// <para>
|
||||
/// Handles both server-spawned and dat-hydrated entities. Live owners use the
|
||||
/// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
|
||||
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
|
||||
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
|
||||
/// (which have no server guid because they come from the dat file, not
|
||||
/// the network) also fire their DefaultScript.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// For live objects this is invoked by <c>LiveEntityRuntime</c> logical
|
||||
/// registration/teardown. <c>GpuWorldState</c> invokes it only for dat-static
|
||||
/// landblock load, promotion, demotion, and unload paths.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
|
||||
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
|
||||
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
|
||||
/// runner; this class adds the missing fire-on-spawn call site.
|
||||
/// </para>
|
||||
/// Owns create-time Setup script activation and its symmetric teardown for
|
||||
/// live and DAT-static entities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setup <c>DefaultScript</c> is a direct PES played during Setup
|
||||
/// initialization. Live registration invokes this class once per logical
|
||||
/// generation; spatial rebucketing never replays it.
|
||||
/// </remarks>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
|
||||
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
|
||||
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
|
||||
|
||||
/// <param name="scriptRunner">Per-owner retail FIFO that schedules every
|
||||
/// hook at its absolute script <c>StartTime</c>.</param>
|
||||
/// <param name="particleSink">Already-shipped hook sink from C.1. The
|
||||
/// activator pushes per-entity rotation + part transforms here, and
|
||||
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
|
||||
/// per-entity emitter handles on despawn.</param>
|
||||
/// <param name="resolver">Returns
|
||||
/// <see cref="ScriptActivationInfo"/> with the entity's
|
||||
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
|
||||
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
|
||||
/// throw / missing DefaultScript. Production lambda hits
|
||||
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
|
||||
public EntityScriptActivator(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
ParticleHookSink particleSink,
|
||||
EntityEffectPoseRegistry poses,
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver,
|
||||
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
|
||||
Action<uint>? unregisterDatStaticEffectOwner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scriptRunner);
|
||||
ArgumentNullException.ThrowIfNull(particleSink);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
_scriptRunner = scriptRunner;
|
||||
_particleSink = particleSink;
|
||||
_resolver = resolver;
|
||||
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
|
||||
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
|
||||
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
||||
/// the script runner under its canonical runtime identity.
|
||||
/// No-op if the entity has no DefaultScript (resolver returns null
|
||||
/// or zero-script).
|
||||
/// </summary>
|
||||
public void OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
uint key = entity.Id;
|
||||
if (key == 0) return; // malformed entity
|
||||
if (key == 0)
|
||||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
|
||||
{
|
||||
|
|
@ -107,56 +65,43 @@ public sealed class EntityScriptActivator
|
|||
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
|
||||
}
|
||||
|
||||
var info = _resolver(entity);
|
||||
if (info is null) return;
|
||||
ScriptActivationInfo? info = _resolver(entity);
|
||||
if (info is null)
|
||||
return;
|
||||
|
||||
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
|
||||
// (in entity-local frame) transforms correctly to world space when the
|
||||
// hook fires. C.1.5a fix: without this, the sink falls through to
|
||||
// Quaternion.Identity and the offset gets applied in world axes —
|
||||
// visual symptom for portals: swirl oriented along world XYZ instead
|
||||
// of the portal's facing, partially buried.
|
||||
_particleSink.SetEntityRotation(key, entity.Rotation);
|
||||
|
||||
// C.1.5b #56: seed the sink's per-entity part transforms so
|
||||
// CreateParticleHook.PartIndex routes the hook offset through the
|
||||
// right mesh part's resting transform. Without this, every emitter
|
||||
// in a multi-part Setup collapses to the entity root.
|
||||
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
|
||||
// Particle::Init (0x0051C930) samples the current root/part frames.
|
||||
// Publish before the Setup default script can execute; effects never
|
||||
// fall back to the camera or a cached spawn anchor.
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
|
||||
if (entity.ServerGuid == 0)
|
||||
_activeStaticOwners.Add(key, entity);
|
||||
|
||||
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
|
||||
_registerDatStaticEffectOwner?.Invoke(
|
||||
key,
|
||||
entity,
|
||||
profile);
|
||||
_registerDatStaticEffectOwner?.Invoke(key, entity, profile);
|
||||
|
||||
if (info.ScriptId != 0)
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop every script instance the runner is tracking for this key, and
|
||||
/// kill every live emitter the sink has attributed to the runtime effect
|
||||
/// owner. Idempotent for unknown entities.
|
||||
/// </summary>
|
||||
public void OnRemove(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
uint key = entity.Id;
|
||||
if (key == 0) return;
|
||||
if (key == 0)
|
||||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|
||||
|| !ReferenceEquals(existing, entity)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
_poses.Remove(key);
|
||||
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
66
src/AcDream.App/Rendering/Vfx/IndexedSetupPartPoseBuilder.cs
Normal file
66
src/AcDream.App/Rendering/Vfx/IndexedSetupPartPoseBuilder.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs retail's stable CPartArray indices from a drawable-only
|
||||
/// MeshRef projection. Rigid part frames come from Setup data while missing
|
||||
/// DAT parts keep an unavailable placeholder at their stable Setup index.
|
||||
/// Hydration omits by GfxObj DID and otherwise preserves Setup order, so
|
||||
/// duplicate DIDs are all present or all absent; first-unconsumed matching is
|
||||
/// lossless under that invariant.
|
||||
/// </summary>
|
||||
internal static class IndexedSetupPartPoseBuilder
|
||||
{
|
||||
public static (Matrix4x4[] Poses, bool[] Available) Build(
|
||||
Setup setup,
|
||||
WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
int partCount = setup.Parts.Count;
|
||||
var poses = new Matrix4x4[partCount];
|
||||
IReadOnlyList<Matrix4x4> defaults = SetupPartTransforms.Compute(
|
||||
setup,
|
||||
objectScale: entity.Scale);
|
||||
for (int i = 0; i < partCount; i++)
|
||||
poses[i] = i < defaults.Count ? defaults[i] : Matrix4x4.Identity;
|
||||
|
||||
var expectedGfx = new uint[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
expectedGfx[i] = (uint)setup.Parts[i];
|
||||
for (int i = 0; i < entity.PartOverrides.Count; i++)
|
||||
{
|
||||
PartOverride replacement = entity.PartOverrides[i];
|
||||
if (replacement.PartIndex < expectedGfx.Length)
|
||||
expectedGfx[replacement.PartIndex] = replacement.GfxObjId;
|
||||
}
|
||||
|
||||
var available = new bool[partCount];
|
||||
var consumed = new bool[entity.MeshRefs.Count];
|
||||
for (int partIndex = 0; partIndex < partCount; partIndex++)
|
||||
{
|
||||
for (int drawableIndex = 0; drawableIndex < entity.MeshRefs.Count; drawableIndex++)
|
||||
{
|
||||
if (consumed[drawableIndex]
|
||||
|| entity.MeshRefs[drawableIndex].GfxObjId != expectedGfx[partIndex])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
consumed[drawableIndex] = true;
|
||||
available[partIndex] = true;
|
||||
// The drawable transform carries DefaultScale. Retail's
|
||||
// attachment/effect frame is the rigid Setup frame computed
|
||||
// above; matching only establishes which stable slot exists.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (poses, available);
|
||||
}
|
||||
}
|
||||
176
src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs
Normal file
176
src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Projects Setup lights for live top-level and attached entities, while the
|
||||
/// shared pose registry supplies their current root on each update frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calls
|
||||
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>) whenever the owning physics
|
||||
/// object's frame changes. Logical ownership remains in
|
||||
/// <see cref="LiveEntityRuntime"/>; leaving the world removes only this
|
||||
/// cell-scoped presentation and re-entry registers it again.
|
||||
/// </remarks>
|
||||
public sealed class LiveEntityLightController : IDisposable
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly LightingHookSink _lighting;
|
||||
private readonly Func<uint, Setup?> _loadSetup;
|
||||
private readonly Dictionary<uint, uint> _serverGuidByOwner = new();
|
||||
private readonly HashSet<uint> _presentOwners = new();
|
||||
|
||||
public LiveEntityLightController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
EntityEffectPoseRegistry poses,
|
||||
LightingHookSink lighting,
|
||||
Func<uint, Setup?> loadSetup)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
|
||||
_loadSetup = loadSetup ?? throw new ArgumentNullException(nameof(loadSetup));
|
||||
_lighting.OwnerLightingChanged += OnOwnerLightingChanged;
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
public bool Register(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_serverGuidByOwner[entity.Id] = serverGuid;
|
||||
_presentOwners.Add(entity.Id);
|
||||
_lighting.UnregisterOwner(entity.Id, forgetState: false);
|
||||
_lighting.InitializeOwnerLighting(
|
||||
entity.Id,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
|
||||
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) != 0x02000000u)
|
||||
return false;
|
||||
|
||||
if (record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
{
|
||||
if (!_poses.UpdateRoot(entity))
|
||||
_poses.PublishMeshRefs(entity);
|
||||
}
|
||||
|
||||
Setup? setup = _loadSetup(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null
|
||||
|| setup.Lights.Count == 0
|
||||
|| !_lighting.IsOwnerLightingEnabled(entity.Id))
|
||||
return false;
|
||||
|
||||
bool isDynamic = (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0;
|
||||
IReadOnlyList<LightSource> loaded = LightInfoLoader.Load(
|
||||
setup,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
isDynamic,
|
||||
entity.ParentCellId ?? 0u,
|
||||
tracksOwnerPose: true);
|
||||
for (int i = 0; i < loaded.Count; i++)
|
||||
_lighting.RegisterOwnedLight(loaded[i]);
|
||||
return loaded.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Withdraw cell-scoped presentation but retain logical light state.</summary>
|
||||
public void Unregister(uint ownerLocalId)
|
||||
{
|
||||
_presentOwners.Remove(ownerLocalId);
|
||||
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
|
||||
}
|
||||
|
||||
/// <summary>Apply a fresh authoritative PhysicsState Lighting transition.</summary>
|
||||
public void OnStateChanged(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_lighting.SetOwnerLighting(
|
||||
entity.Id,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
|
||||
}
|
||||
|
||||
/// <summary>End logical ownership after leave-world presentation teardown.</summary>
|
||||
public void Forget(uint ownerLocalId)
|
||||
{
|
||||
_presentOwners.Remove(ownerLocalId);
|
||||
_serverGuidByOwner.Remove(ownerLocalId);
|
||||
_lighting.UnregisterOwner(ownerLocalId);
|
||||
}
|
||||
|
||||
public void Refresh() => _lighting.RefreshAttachedLights();
|
||||
|
||||
/// <summary>
|
||||
/// Attached projection visibility can become true before its holding-part
|
||||
/// composition is published. The attachment owner calls this barrier only
|
||||
/// after the composed child root is current.
|
||||
/// </summary>
|
||||
public void OnAttachedPoseReady(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| _presentOwners.Contains(entity.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Register(serverGuid);
|
||||
}
|
||||
|
||||
private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled)
|
||||
{
|
||||
if (!_presentOwners.Contains(ownerLocalId)
|
||||
|| !_serverGuidByOwner.TryGetValue(ownerLocalId, out uint serverGuid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
|
||||
return;
|
||||
}
|
||||
|
||||
Register(serverGuid);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lighting.OwnerLightingChanged -= OnOwnerLightingChanged;
|
||||
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||
foreach (uint ownerId in _serverGuidByOwner.Keys.ToArray())
|
||||
Forget(ownerId);
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
if (record.ProjectionKind is LiveEntityProjectionKind.Attached)
|
||||
{
|
||||
// The true edge precedes attachment pose publication. False is
|
||||
// safe and must immediately withdraw the cell-scoped light.
|
||||
if (!visible)
|
||||
Unregister(entity.Id);
|
||||
return;
|
||||
}
|
||||
if (visible)
|
||||
Register(record.ServerGuid);
|
||||
else
|
||||
Unregister(entity.Id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1311,7 +1311,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// TransparentPartHook.PartIndex addresses — retail's CPartArray
|
||||
// numbers parts by their ordinal position in the Setup's own
|
||||
// part list (SetupPartTransforms.Compute is the other verified
|
||||
// consumer of this exact indexing: one Matrix4x4 per
|
||||
// consumer of this exact indexing: one rigid pose per
|
||||
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
|
||||
// MeshRef is acdream's own decomposition of top-level
|
||||
// attachments (weapon/shield/etc), a different concept.
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ public sealed class LiveEntityRuntime
|
|||
private readonly Dictionary<uint, uint> _guidByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
|
||||
private uint _rebucketingGuid;
|
||||
private uint _nextLocalEntityId;
|
||||
|
||||
public LiveEntityRuntime(
|
||||
|
|
@ -191,6 +192,12 @@ public sealed class LiveEntityRuntime
|
|||
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
|
||||
public ParentAttachmentState ParentAttachments { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a materialized projection enters or leaves the currently
|
||||
/// loaded spatial view. Logical resources remain owned by the record.
|
||||
/// </summary>
|
||||
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
|
||||
|
||||
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
InboundCreateResult result = _inbound.AcceptCreate(incoming);
|
||||
|
|
@ -311,7 +318,21 @@ public sealed class LiveEntityRuntime
|
|||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
||||
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
|
||||
bool wasProjected = record.IsSpatiallyProjected;
|
||||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
// GpuWorldState reports an intermediate false/true pair while moving
|
||||
// between two loaded buckets. Suppress those implementation details
|
||||
// and publish only the final logical visibility edge.
|
||||
record.IsSpatiallyProjected = true;
|
||||
_rebucketingGuid = serverGuid;
|
||||
try
|
||||
{
|
||||
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rebucketingGuid = 0;
|
||||
}
|
||||
bool visible = _spatial.IsLiveEntityVisible(serverGuid);
|
||||
record.IsSpatiallyVisible = visible;
|
||||
if (record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
|
|
@ -327,7 +348,8 @@ public sealed class LiveEntityRuntime
|
|||
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
||||
? 0u
|
||||
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
record.IsSpatiallyProjected = true;
|
||||
if (!wasProjected || wasVisible != visible)
|
||||
ProjectionVisibilityChanged?.Invoke(record, visible);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -708,6 +730,8 @@ public sealed class LiveEntityRuntime
|
|||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
if (_rebucketingGuid != serverGuid)
|
||||
ProjectionVisibilityChanged?.Invoke(record, visible);
|
||||
}
|
||||
|
||||
private void TearDownRecord(LiveEntityRecord record)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ using DatReaderWriter.Types;
|
|||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>Final child root and indexed part poses in the parent-root frame.</summary>
|
||||
public readonly record struct EquippedChildPose(
|
||||
Matrix4x4 RootLocal,
|
||||
Matrix4x4[] PartLocal,
|
||||
MeshRef[] AttachedParts);
|
||||
|
||||
/// <summary>
|
||||
/// Retail held-object transform composition. A weapon is a separate child
|
||||
/// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects
|
||||
|
|
@ -28,6 +34,90 @@ public static class EquippedChildAttachment
|
|||
IReadOnlyList<MeshRef> childPartTemplate,
|
||||
float childScale,
|
||||
out IReadOnlyList<MeshRef> attachedParts)
|
||||
{
|
||||
bool composed = TryComposePose(
|
||||
parentSetup,
|
||||
currentParentPose,
|
||||
childSetup,
|
||||
parentLocation,
|
||||
placement,
|
||||
childPartTemplate,
|
||||
childScale,
|
||||
out EquippedChildPose pose);
|
||||
attachedParts = composed ? pose.AttachedParts : Array.Empty<MeshRef>();
|
||||
return composed;
|
||||
}
|
||||
|
||||
public static bool TryComposePose(
|
||||
Setup parentSetup,
|
||||
IReadOnlyList<MeshRef> currentParentPose,
|
||||
Setup childSetup,
|
||||
ParentLocation parentLocation,
|
||||
Placement placement,
|
||||
IReadOnlyList<MeshRef> childPartTemplate,
|
||||
float childScale,
|
||||
out EquippedChildPose pose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(currentParentPose);
|
||||
var parentParts = new Matrix4x4[currentParentPose.Count];
|
||||
for (int i = 0; i < parentParts.Length; i++)
|
||||
parentParts[i] = currentParentPose[i].PartTransform;
|
||||
return TryComposePose(
|
||||
parentSetup,
|
||||
parentParts,
|
||||
childSetup,
|
||||
parentLocation,
|
||||
placement,
|
||||
childPartTemplate,
|
||||
childScale,
|
||||
out pose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose from the parent's indexed final animation poses. The renderer
|
||||
/// exposes these independently of drawable MeshRefs so a hidden or missing
|
||||
/// visual part cannot shift the retail holding-location index.
|
||||
/// </summary>
|
||||
public static bool TryComposePose(
|
||||
Setup parentSetup,
|
||||
IReadOnlyList<Matrix4x4> currentParentPose,
|
||||
Setup childSetup,
|
||||
ParentLocation parentLocation,
|
||||
Placement placement,
|
||||
IReadOnlyList<MeshRef> childPartTemplate,
|
||||
float childScale,
|
||||
out EquippedChildPose pose)
|
||||
{
|
||||
return TryComposePoseInto(
|
||||
parentSetup,
|
||||
currentParentPose,
|
||||
parentPartAvailability: null,
|
||||
childSetup,
|
||||
parentLocation,
|
||||
placement,
|
||||
childPartTemplate,
|
||||
childScale,
|
||||
partPoseBuffer: null,
|
||||
attachedPartBuffer: null,
|
||||
out pose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free update path for an already realized child. Buffers are
|
||||
/// reused by the App-layer attachment owner after their first composition.
|
||||
/// </summary>
|
||||
public static bool TryComposePoseInto(
|
||||
Setup parentSetup,
|
||||
IReadOnlyList<Matrix4x4> currentParentPose,
|
||||
IReadOnlyList<bool>? parentPartAvailability,
|
||||
Setup childSetup,
|
||||
ParentLocation parentLocation,
|
||||
Placement placement,
|
||||
IReadOnlyList<MeshRef> childPartTemplate,
|
||||
float childScale,
|
||||
Matrix4x4[]? partPoseBuffer,
|
||||
MeshRef[]? attachedPartBuffer,
|
||||
out EquippedChildPose pose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parentSetup);
|
||||
ArgumentNullException.ThrowIfNull(currentParentPose);
|
||||
|
|
@ -36,14 +126,32 @@ public static class EquippedChildAttachment
|
|||
|
||||
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
|
||||
{
|
||||
attachedParts = Array.Empty<MeshRef>();
|
||||
pose = new EquippedChildPose(
|
||||
Matrix4x4.Identity,
|
||||
Array.Empty<Matrix4x4>(),
|
||||
Array.Empty<MeshRef>());
|
||||
return false;
|
||||
}
|
||||
|
||||
Matrix4x4 parentPart = holding.PartId >= 0
|
||||
&& holding.PartId < currentParentPose.Count
|
||||
? currentParentPose[holding.PartId].PartTransform
|
||||
: Matrix4x4.Identity;
|
||||
Matrix4x4 parentPart;
|
||||
if (holding.PartId >= 0 && holding.PartId < currentParentPose.Count)
|
||||
{
|
||||
if (parentPartAvailability is not null
|
||||
&& (holding.PartId >= parentPartAvailability.Count
|
||||
|| !parentPartAvailability[(int)holding.PartId]))
|
||||
{
|
||||
pose = default;
|
||||
return false;
|
||||
}
|
||||
parentPart = currentParentPose[(int)holding.PartId];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retail CPhysicsObj::UpdateChild (0x00512D50) uses the parent
|
||||
// root for every part number >= num_parts. This includes the
|
||||
// canonical 0xFFFFFFFF root sentinel and malformed positives.
|
||||
parentPart = Matrix4x4.Identity;
|
||||
}
|
||||
Matrix4x4 holdingFrame = ToMatrix(holding.Frame);
|
||||
Matrix4x4 childRoot = holdingFrame * parentPart;
|
||||
|
||||
|
|
@ -54,28 +162,40 @@ public static class EquippedChildAttachment
|
|||
childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame);
|
||||
|
||||
int partCount = Math.Min(childSetup.Parts.Count, childPartTemplate.Count);
|
||||
var result = new MeshRef[partCount];
|
||||
MeshRef[] result = attachedPartBuffer is { Length: var attachedLength }
|
||||
&& attachedLength == partCount
|
||||
? attachedPartBuffer
|
||||
: new MeshRef[partCount];
|
||||
Matrix4x4[] childPartPoses = partPoseBuffer is { Length: var poseLength }
|
||||
&& poseLength == partCount
|
||||
? partPoseBuffer
|
||||
: new Matrix4x4[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
||||
? placementFrame.Frames[i]
|
||||
: new Frame { Orientation = Quaternion.Identity };
|
||||
Vector3 scale = i < childSetup.DefaultScale.Count
|
||||
Vector3 defaultScale = i < childSetup.DefaultScale.Count
|
||||
? childSetup.DefaultScale[i]
|
||||
: Vector3.One;
|
||||
Matrix4x4 childPart = Matrix4x4.CreateScale(scale)
|
||||
Matrix4x4 visualChildPart = Matrix4x4.CreateScale(defaultScale)
|
||||
* ToMatrix(partFrame);
|
||||
if (childScale != 1.0f)
|
||||
childPart *= Matrix4x4.CreateScale(childScale);
|
||||
visualChildPart *= Matrix4x4.CreateScale(childScale);
|
||||
|
||||
Matrix4x4 rigidChildPart = Matrix4x4.CreateFromQuaternion(partFrame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(partFrame.Origin * childScale);
|
||||
|
||||
childPartPoses[i] = rigidChildPart;
|
||||
|
||||
MeshRef template = childPartTemplate[i];
|
||||
result[i] = new MeshRef(template.GfxObjId, childPart * childRoot)
|
||||
result[i] = new MeshRef(template.GfxObjId, visualChildPart * childRoot)
|
||||
{
|
||||
SurfaceOverrides = template.SurfaceOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
attachedParts = result;
|
||||
pose = new EquippedChildPose(childRoot, childPartPoses, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,48 +7,35 @@ using DatReaderWriter.Types;
|
|||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-part static transforms for a Setup using its
|
||||
/// PlacementFrames. For each part <c>i</c>, the returned matrix takes a
|
||||
/// point in part-local space and yields a point in setup-local space at
|
||||
/// the Setup's resting pose.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors <see cref="SetupMesh.Flatten"/>'s pose-source priority —
|
||||
/// <c>PlacementFrames[Resting]</c> → <c>[Default]</c> → first available
|
||||
/// — so that a particle anchor at part <c>i</c> matches the part's
|
||||
/// visible rest position. If renderer and resolver ever drift on this
|
||||
/// priority, particles will visibly drift relative to their parent
|
||||
/// mesh; keep them in lockstep.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns an empty list when the Setup has no PlacementFrames. The
|
||||
/// caller (e.g. <c>ParticleHookSink.SpawnFromHook</c>) should then fall
|
||||
/// back to <see cref="Matrix4x4.Identity"/> per part, which is the
|
||||
/// pre-C.1.5b behavior.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// For animated entities, per-part transforms vary per animation frame
|
||||
/// and live in <c>AnimatedEntityState</c>; a future "animated
|
||||
/// DefaultScript" path would publish those each tick via the same
|
||||
/// <c>SetEntityPartTransforms</c> seam. Out of scope here.
|
||||
/// </para>
|
||||
/// Computes retail's rigid per-part frames for attachments and effects.
|
||||
/// Visual GfxObj scale is deliberately excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pose selection matches <see cref="SetupMesh.Flatten"/>: an explicit motion
|
||||
/// frame, then Resting, Default, then the first placement frame. Retail
|
||||
/// <c>CPartArray::UpdateParts</c> (<c>0x005190F0</c>) scales only the frame
|
||||
/// origin by the object's scale. <c>CPartArray::SetScaleInternal</c>
|
||||
/// (<c>0x00518A00</c>) stores Setup DefaultScale separately on the visual
|
||||
/// <c>CPhysicsPart::gfxobj_scale</c>, so it never enters this rigid frame.
|
||||
/// </remarks>
|
||||
public static class SetupPartTransforms
|
||||
{
|
||||
public static IReadOnlyList<Matrix4x4> Compute(Setup setup)
|
||||
public static IReadOnlyList<Matrix4x4> Compute(
|
||||
Setup setup,
|
||||
AnimationFrame? motionFrameOverride = null,
|
||||
float objectScale = 1.0f)
|
||||
{
|
||||
AnimationFrame? source = null;
|
||||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
AnimationFrame? source = motionFrameOverride;
|
||||
if (source is null && setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
||||
{
|
||||
source = resting;
|
||||
}
|
||||
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
||||
else if (source is null && setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
||||
{
|
||||
source = def;
|
||||
}
|
||||
else
|
||||
else if (source is null)
|
||||
{
|
||||
foreach (var kvp in setup.PlacementFrames)
|
||||
{
|
||||
|
|
@ -57,7 +44,8 @@ public static class SetupPartTransforms
|
|||
}
|
||||
}
|
||||
|
||||
if (source is null) return System.Array.Empty<Matrix4x4>();
|
||||
if (source is null)
|
||||
return Array.Empty<Matrix4x4>();
|
||||
|
||||
int partCount = setup.Parts.Count;
|
||||
var result = new Matrix4x4[partCount];
|
||||
|
|
@ -66,10 +54,8 @@ public static class SetupPartTransforms
|
|||
Frame frame = i < source.Frames.Count
|
||||
? source.Frames[i]
|
||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One;
|
||||
result[i] = Matrix4x4.CreateScale(scale)
|
||||
* Matrix4x4.CreateFromQuaternion(frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(frame.Origin);
|
||||
result[i] = Matrix4x4.CreateFromQuaternion(frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(frame.Origin * objectScale);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ namespace AcDream.Core.Vfx;
|
|||
/// </summary>
|
||||
public sealed class EmitterDescRegistry
|
||||
{
|
||||
private const uint FallbackEmitterId = 0xFFFFFFFFu;
|
||||
|
||||
private readonly Func<uint, DatParticleEmitter?>? _resolver;
|
||||
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
|
||||
|
||||
|
|
@ -32,7 +30,6 @@ public sealed class EmitterDescRegistry
|
|||
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
Register(BuildFallback());
|
||||
}
|
||||
|
||||
public void Register(EmitterDesc desc)
|
||||
|
|
@ -43,9 +40,16 @@ public sealed class EmitterDescRegistry
|
|||
|
||||
public EmitterDesc Get(uint emitterId)
|
||||
{
|
||||
if (_byId.TryGetValue(emitterId, out var desc))
|
||||
if (TryGet(emitterId, out EmitterDesc? desc))
|
||||
return desc;
|
||||
throw new KeyNotFoundException(
|
||||
$"ParticleEmitterInfo 0x{emitterId:X8} was not found.");
|
||||
}
|
||||
|
||||
public bool TryGet(uint emitterId, out EmitterDesc desc)
|
||||
{
|
||||
if (_byId.TryGetValue(emitterId, out desc!))
|
||||
return true;
|
||||
if (_resolver is not null)
|
||||
{
|
||||
var dat = _resolver(emitterId);
|
||||
|
|
@ -53,14 +57,11 @@ public sealed class EmitterDescRegistry
|
|||
{
|
||||
desc = FromDat(emitterId, dat);
|
||||
_byId[emitterId] = desc;
|
||||
return desc;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (_byId.TryGetValue(FallbackEmitterId, out var fallback))
|
||||
return fallback;
|
||||
|
||||
throw new InvalidOperationException("No default emitter registered in registry.");
|
||||
desc = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public int Count => _byId.Count;
|
||||
|
|
@ -145,36 +146,6 @@ public sealed class EmitterDescRegistry
|
|||
}
|
||||
}
|
||||
|
||||
private static EmitterDesc BuildFallback() => new()
|
||||
{
|
||||
DatId = FallbackEmitterId,
|
||||
Type = ParticleType.LocalVelocity,
|
||||
EmitterKind = ParticleEmitterKind.BirthratePerSec,
|
||||
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
|
||||
Birthrate = 0.1f,
|
||||
EmitRate = 10f,
|
||||
MaxParticles = 32,
|
||||
LifetimeMin = 0.6f,
|
||||
LifetimeMax = 1.2f,
|
||||
Lifespan = 0.9f,
|
||||
LifespanRand = 0.3f,
|
||||
OffsetDir = new Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 0.1f,
|
||||
SpawnDiskRadius = 0.1f,
|
||||
InitialVelocity = new Vector3(0, 0, 0.5f),
|
||||
VelocityJitter = 0.3f,
|
||||
A = new Vector3(0, 0, 0.5f),
|
||||
MinA = 1f,
|
||||
MaxA = 1f,
|
||||
B = Vector3.Zero,
|
||||
C = Vector3.Zero,
|
||||
StartSize = 0.25f,
|
||||
EndSize = 0.6f,
|
||||
StartAlpha = 0.85f,
|
||||
EndAlpha = 0f,
|
||||
};
|
||||
|
||||
private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
|
||||
{
|
||||
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,
|
||||
|
|
|
|||
24
src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs
Normal file
24
src/AcDream.Core/Vfx/IEntityEffectPoseSource.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view of the final pose used by entity-attached effects.
|
||||
/// Implementations publish poses on the update/render thread after root motion,
|
||||
/// animation, and equipped-child composition have completed.
|
||||
/// </summary>
|
||||
public interface IEntityEffectPoseSource
|
||||
{
|
||||
bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld);
|
||||
|
||||
bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional spatial companion for consumers, such as object lights, whose
|
||||
/// render registration is scoped to the owner's current AC cell.
|
||||
/// </summary>
|
||||
public interface IEntityEffectCellSource
|
||||
{
|
||||
bool TryGetCellId(uint localEntityId, out uint cellId);
|
||||
}
|
||||
|
|
@ -1,191 +1,177 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Vfx;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAnimationHookSink"/> that translates particle-bearing
|
||||
/// animation hooks into <see cref="ParticleSystem"/> spawn / stop calls.
|
||||
///
|
||||
/// <para>
|
||||
/// Hook types handled (r04 §6):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <see cref="CreateParticleHook"/> — spawn an emitter from the
|
||||
/// hook's <c>EmitterInfoId</c> at the entity's world pose plus the
|
||||
/// hook offset. Retail attaches to a specific mesh part; we
|
||||
/// attach to the entity's root and will refine per-part when the
|
||||
/// renderer exposes per-part world transforms.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CreateBlockingParticleHook"/> — has the same payload
|
||||
/// as CreateParticleHook, but suppresses creation while the same nonzero
|
||||
/// logical emitter ID is live. It does not pause animation. Production DAT
|
||||
/// loading substitutes <see cref="RetailCreateBlockingParticleHook"/> so
|
||||
/// the inherited payload is retained despite the dependency's header-only
|
||||
/// model. Suppression itself lands with live emitter bindings in Step 5.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DestroyParticleHook"/> — stop the most-recent emitter
|
||||
/// matching the hook's <c>EmitterId</c>. Deferred to a future pass
|
||||
/// when we retain per-entity emitter-id → handle maps.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="StopParticleHook"/> — pause all emitters on the
|
||||
/// entity (fade out).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="DefaultScriptHook"/> / <see cref="DefaultScriptPartHook"/>
|
||||
/// — trigger the entity's <c>DefaultScriptId</c> PhysicsScript.
|
||||
/// Requires PhysicsScript table; deferred.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="CallPESHook"/> — fire a PhysicsScript by id.
|
||||
/// Deferred until DRW exposes PhysicsScript dat.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Per-entity emitter handle tracking is kept here so DestroyParticle /
|
||||
/// StopParticle can target the right emitter when a server-sent
|
||||
/// <c>PlayEffect</c> fires.
|
||||
/// </para>
|
||||
/// Routes particle animation hooks through the owner's current root/part pose
|
||||
/// and retains the retail logical-emitter identity rules.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail oracle: <c>CreateParticleHook::Execute</c> (<c>0x00526EC0</c>),
|
||||
/// <c>CreateBlockingParticleHook::Execute</c> (<c>0x00526EF0</c>),
|
||||
/// <c>ParticleManager::CreateParticleEmitter</c> (<c>0x0051B6C0</c>), and
|
||||
/// <c>CreateBlockingParticleEmitter</c> (<c>0x0051B8A0</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The complete hook offset frame is retained on each binding for schema
|
||||
/// fidelity. Retail <c>Particle::Init</c> (<c>0x0051C930</c>) transforms the
|
||||
/// offset origin through the current owner frame, but does not apply the hook
|
||||
/// offset quaternion to particle vectors; emitter orientation is the current
|
||||
/// root/part orientation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ParticleHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly ParticleSystem _system;
|
||||
|
||||
// entityId → most-recently-spawned emitter handle per emitterId.
|
||||
// DestroyParticleHook.EmitterId is effectively an application-layer
|
||||
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
|
||||
// (entity, emitterId).
|
||||
private readonly IEntityEffectPoseSource _poses;
|
||||
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
|
||||
// entityId → set of live emitter handles. Dictionary-as-set so we can
|
||||
// remove individual handles when their emitter dies (M4 fix —
|
||||
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
|
||||
// emitters used to leak).
|
||||
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
|
||||
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
|
||||
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
||||
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
|
||||
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = 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;
|
||||
|
||||
public ParticleHookSink(ParticleSystem system)
|
||||
private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
|
||||
public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
|
||||
{
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_system.EmitterDied += OnEmitterDied;
|
||||
}
|
||||
|
||||
public Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
private void OnEmitterDied(int handle)
|
||||
{
|
||||
if (!_trackingByHandle.TryRemove(handle, out var t))
|
||||
if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
|
||||
return;
|
||||
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
|
||||
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
|
||||
bag.TryRemove(handle, out _);
|
||||
|
||||
if (binding.LogicalId != 0
|
||||
&& _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current)
|
||||
&& current == handle)
|
||||
{
|
||||
_handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _);
|
||||
}
|
||||
if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles))
|
||||
{
|
||||
handles.TryRemove(handle, out _);
|
||||
if (handles.IsEmpty)
|
||||
_handlesByEntity.TryRemove(binding.OwnerLocalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
switch (hook)
|
||||
{
|
||||
case RetailCreateBlockingParticleHook:
|
||||
// The centralized content reader now preserves the complete
|
||||
// retail payload. Step 5 owns live logical-ID suppression and
|
||||
// attachment semantics; do not silently treat blocking as a
|
||||
// normal replacement before that mechanism lands.
|
||||
case RetailCreateBlockingParticleHook blocking:
|
||||
SpawnFromHook(
|
||||
entityId,
|
||||
(uint)blocking.EmitterInfoId,
|
||||
blocking.Offset,
|
||||
unchecked((int)blocking.PartIndex),
|
||||
blocking.EmitterId,
|
||||
isBlocking: true);
|
||||
break;
|
||||
|
||||
case CreateParticleHook cph:
|
||||
SpawnFromHook(entityId, entityWorldPosition,
|
||||
emitterInfoId: (uint)cph.EmitterInfoId,
|
||||
offset: cph.Offset.Origin,
|
||||
partIndex: (int)cph.PartIndex,
|
||||
logicalId: cph.EmitterId);
|
||||
case CreateParticleHook create:
|
||||
SpawnFromHook(
|
||||
entityId,
|
||||
(uint)create.EmitterInfoId,
|
||||
create.Offset,
|
||||
unchecked((int)create.PartIndex),
|
||||
create.EmitterId,
|
||||
isBlocking: false);
|
||||
break;
|
||||
|
||||
case CreateBlockingParticleHook:
|
||||
// Defensive package-decoder shape. Production raw loaders
|
||||
// replace this header-only model with the retail subclass.
|
||||
// The upstream package exposes only the common header for this
|
||||
// type. Production loaders replace it with the retail payload
|
||||
// above; a header-only instance cannot create an emitter.
|
||||
DiagnosticSink?.Invoke(
|
||||
$"CreateBlockingParticle for owner 0x{entityId:X8} has no retail payload.");
|
||||
break;
|
||||
|
||||
case DestroyParticleHook dph:
|
||||
if (_handlesByKey.TryRemove((entityId, dph.EmitterId), out var handleToDestroy))
|
||||
_system.StopEmitter(handleToDestroy, fadeOut: false);
|
||||
case DestroyParticleHook destroy:
|
||||
DestroyLogical(entityId, destroy.EmitterId, fadeOut: false);
|
||||
break;
|
||||
|
||||
case StopParticleHook sph:
|
||||
if (_handlesByKey.TryGetValue((entityId, sph.EmitterId), out var handleToStop))
|
||||
_system.StopEmitter(handleToStop, fadeOut: true);
|
||||
case StopParticleHook stop:
|
||||
DestroyLogical(entityId, stop.EmitterId, fadeOut: true);
|
||||
break;
|
||||
|
||||
// DefaultScript / CallPES are routed by EntityEffectController.
|
||||
// ParticleHookSink intentionally owns particles only.
|
||||
}
|
||||
}
|
||||
|
||||
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
|
||||
=> _renderPassByEntity[entityId] = renderPass;
|
||||
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) =>
|
||||
_renderPassByEntity[entityId] = renderPass;
|
||||
|
||||
public void SetEntityRotation(uint entityId, Quaternion rotation)
|
||||
=> _rotationByEntity[entityId] = rotation;
|
||||
public void ClearEntityRenderPass(uint entityId) =>
|
||||
_renderPassByEntity.TryRemove(entityId, out _);
|
||||
|
||||
/// <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"/>.
|
||||
/// Mirrors live cell membership without ending emitter lifetime. Retail
|
||||
/// pauses the owner's particle manager while cell-less, then resumes the
|
||||
/// same particles and logical IDs without catch-up emission on re-entry.
|
||||
/// </summary>
|
||||
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
|
||||
=> _partTransformsByEntity[entityId] = partTransforms;
|
||||
|
||||
public void ClearEntityRenderPass(uint entityId)
|
||||
=> _renderPassByEntity.TryRemove(entityId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Refresh every live emitter on this entity to a new world anchor +
|
||||
/// rotation. The owning subsystem (sky-PES driver, animation tick)
|
||||
/// drives this each frame for AttachLocal emitters so they track their
|
||||
/// moving parent — retail-faithful via
|
||||
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
|
||||
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
|
||||
/// Safe to call for entities with no live emitters (no-op).
|
||||
/// </summary>
|
||||
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
|
||||
public void SetEntityPresentationVisible(uint entityId, bool visible)
|
||||
{
|
||||
_rotationByEntity[entityId] = rotation;
|
||||
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
|
||||
return;
|
||||
foreach (var handle in bag.Keys)
|
||||
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
||||
if (visible)
|
||||
_hiddenPresentationOwners.TryRemove(entityId, out _);
|
||||
else
|
||||
_hiddenPresentationOwners[entityId] = 0;
|
||||
|
||||
// Withdrawal is immediate. Re-entry is enabled by the next pose
|
||||
// refresh so an attached owner cannot flash for one frame at its old
|
||||
// anchor before the composed child pose is published.
|
||||
if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles))
|
||||
{
|
||||
foreach (int handle in handles.Keys)
|
||||
{
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
_system.SetEmitterSimulationEnabled(handle, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes every emitter from the current root/part pose. World-released
|
||||
/// particles keep their stored emission origin; AttachLocal particles read
|
||||
/// the refreshed anchor when the particle system advances.
|
||||
/// </summary>
|
||||
public void RefreshAttachedEmitters()
|
||||
{
|
||||
foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
|
||||
{
|
||||
bool presentationVisible =
|
||||
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
|
||||
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
|
||||
binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation))
|
||||
{
|
||||
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
||||
// Keep the anchor current while spatially paused; re-entry
|
||||
// resumes at the authoritative pose without generating the
|
||||
// absent interval's time- or distance-driven emissions.
|
||||
_system.SetEmitterPresentationVisible(handle, presentationVisible);
|
||||
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
|
||||
}
|
||||
else
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopAllForEntity(uint entityId, bool fadeOut)
|
||||
{
|
||||
if (_handlesByEntity.TryRemove(entityId, out var handles))
|
||||
{
|
||||
foreach (var handle in handles.Keys)
|
||||
foreach (int handle in handles.Keys)
|
||||
{
|
||||
// A fading emitter needs its clock to retire naturally. A
|
||||
// hard destroy is removed synchronously by ParticleSystem.
|
||||
if (fadeOut)
|
||||
_system.SetEmitterSimulationEnabled(handle, true);
|
||||
_system.StopEmitter(handle, fadeOut);
|
||||
_trackingByHandle.TryRemove(handle, out _);
|
||||
_bindingsByHandle.TryRemove(handle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,61 +180,138 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
if (key.EntityId == entityId)
|
||||
_handlesByKey.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
ClearEntityRenderPass(entityId);
|
||||
_rotationByEntity.TryRemove(entityId, out _);
|
||||
_partTransformsByEntity.TryRemove(entityId, out _);
|
||||
_hiddenPresentationOwners.TryRemove(entityId, out _);
|
||||
}
|
||||
|
||||
private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut)
|
||||
{
|
||||
if (logicalId == 0
|
||||
|| !_handlesByKey.TryGetValue((ownerLocalId, logicalId), out int handle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Retail StopParticleEmitter (0x0051B7B0) only marks the emitter as
|
||||
// stopped; it remains in particle_table until its final particle dies.
|
||||
// A blocking create with the same logical ID must therefore continue
|
||||
// to see it. DestroyParticleEmitter (0x0051B770) removes it at once.
|
||||
if (!fadeOut)
|
||||
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
|
||||
if (fadeOut)
|
||||
_system.SetEmitterSimulationEnabled(handle, true);
|
||||
_system.StopEmitter(handle, fadeOut);
|
||||
}
|
||||
|
||||
private void SpawnFromHook(
|
||||
uint entityId,
|
||||
Vector3 worldPos,
|
||||
uint ownerLocalId,
|
||||
uint emitterInfoId,
|
||||
Vector3 offset,
|
||||
Frame offset,
|
||||
int partIndex,
|
||||
uint logicalId)
|
||||
uint logicalId,
|
||||
bool isBlocking)
|
||||
{
|
||||
// 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;
|
||||
Vector3 partLocal = offset;
|
||||
if (_partTransformsByEntity.TryGetValue(entityId, out var partTransforms)
|
||||
&& partIndex >= 0
|
||||
&& partIndex < partTransforms.Count)
|
||||
if (logicalId != 0
|
||||
&& _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
|
||||
{
|
||||
partLocal = Vector3.Transform(offset, partTransforms[partIndex]);
|
||||
if (isBlocking && _system.IsEmitterAlive(existing))
|
||||
return;
|
||||
|
||||
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
|
||||
_system.StopEmitter(existing, fadeOut: false);
|
||||
}
|
||||
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
|
||||
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
|
||||
|
||||
Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero;
|
||||
Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity;
|
||||
if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin,
|
||||
out Vector3 anchor, out Quaternion rotation))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " +
|
||||
$"emitter 0x{emitterInfoId:X8} was not created.");
|
||||
return;
|
||||
}
|
||||
|
||||
ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass)
|
||||
? pass
|
||||
: ParticleRenderPass.Scene;
|
||||
|
||||
int handle = _system.SpawnEmitterById(
|
||||
emitterId: emitterInfoId,
|
||||
anchor: anchor,
|
||||
rot: rotation,
|
||||
attachedObjectId: entityId,
|
||||
attachedPartIndex: partIndex,
|
||||
renderPass: renderPass);
|
||||
|
||||
uint keyId = logicalId != 0
|
||||
? logicalId
|
||||
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
|
||||
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
|
||||
if (!_system.TrySpawnEmitterById(
|
||||
emitterInfoId,
|
||||
anchor,
|
||||
rotation,
|
||||
ownerLocalId,
|
||||
partIndex,
|
||||
renderPass,
|
||||
out int handle))
|
||||
{
|
||||
_system.StopEmitter(oldHandle, fadeOut: false);
|
||||
_trackingByHandle.TryRemove(oldHandle, out _);
|
||||
DiagnosticSink?.Invoke(
|
||||
$"ParticleEmitterInfo 0x{emitterInfoId:X8} for owner " +
|
||||
$"0x{ownerLocalId:X8} was not found; no fallback was created.");
|
||||
return;
|
||||
}
|
||||
if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
|
||||
{
|
||||
_system.SetEmitterPresentationVisible(handle, false);
|
||||
_system.SetEmitterSimulationEnabled(handle, false);
|
||||
}
|
||||
|
||||
_handlesByKey[(entityId, keyId)] = handle;
|
||||
var binding = new EmitterBinding(
|
||||
ownerLocalId,
|
||||
partIndex,
|
||||
offsetOrigin,
|
||||
offsetOrientation,
|
||||
logicalId,
|
||||
renderPass);
|
||||
_bindingsByHandle[handle] = binding;
|
||||
_handlesByEntity
|
||||
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
|
||||
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
|
||||
.TryAdd(handle, 0);
|
||||
_trackingByHandle[handle] = (entityId, keyId);
|
||||
if (logicalId != 0)
|
||||
_handlesByKey[(ownerLocalId, logicalId)] = handle;
|
||||
}
|
||||
|
||||
private bool TryResolveAnchor(
|
||||
uint ownerLocalId,
|
||||
int partIndex,
|
||||
Vector3 offsetOrigin,
|
||||
out Vector3 anchor,
|
||||
out Quaternion rotation)
|
||||
{
|
||||
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
Matrix4x4 ownerFrame = rootWorld;
|
||||
if (partIndex != -1)
|
||||
{
|
||||
if (!_poses.TryGetPartPose(ownerLocalId, partIndex, out Matrix4x4 partLocal))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
ownerFrame = partLocal * rootWorld;
|
||||
}
|
||||
|
||||
anchor = Vector3.Transform(offsetOrigin, ownerFrame);
|
||||
if (!Matrix4x4.Decompose(ownerFrame, out _, out rotation, out _))
|
||||
{
|
||||
anchor = default;
|
||||
rotation = default;
|
||||
return false;
|
||||
}
|
||||
rotation = Quaternion.Normalize(rotation);
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly record struct EmitterBinding(
|
||||
uint OwnerLocalId,
|
||||
int PartIndex,
|
||||
Vector3 HookOffsetOrigin,
|
||||
Quaternion HookOffsetOrientation,
|
||||
uint LogicalId,
|
||||
ParticleRenderPass RenderPass);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,31 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass);
|
||||
}
|
||||
|
||||
public bool TrySpawnEmitterById(
|
||||
uint emitterId,
|
||||
Vector3 anchor,
|
||||
Quaternion? rot,
|
||||
uint attachedObjectId,
|
||||
int attachedPartIndex,
|
||||
ParticleRenderPass renderPass,
|
||||
out int handle)
|
||||
{
|
||||
if (!_registry.TryGet(emitterId, out EmitterDesc? desc))
|
||||
{
|
||||
handle = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
handle = SpawnEmitter(
|
||||
desc,
|
||||
anchor,
|
||||
rot,
|
||||
attachedObjectId,
|
||||
attachedPartIndex,
|
||||
renderPass);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f)
|
||||
{
|
||||
// Full PhysicsScript scheduling lives in PhysicsScriptRunner.
|
||||
|
|
@ -90,6 +115,12 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
{
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
em.Particles[i].Alive = false;
|
||||
// Retail DestroyParticleEmitter removes the table entry now; it
|
||||
// does not wait for the next update. This is also required for a
|
||||
// hard-stopped emitter whose cell-less simulation is paused.
|
||||
_byHandle.Remove(handle);
|
||||
_handleOrder.Remove(handle);
|
||||
EmitterDied?.Invoke(handle);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,10 +138,50 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
if (!_byHandle.TryGetValue(handle, out var em))
|
||||
return;
|
||||
em.AnchorPos = anchor;
|
||||
if (!em.SimulationEnabled)
|
||||
em.LastEmitOffset = anchor;
|
||||
if (rot.HasValue)
|
||||
em.AnchorRot = rot.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes only render presentation. Logical lifetime is unaffected.
|
||||
/// </summary>
|
||||
public void SetEmitterPresentationVisible(int handle, bool visible)
|
||||
{
|
||||
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
|
||||
emitter.PresentationVisible = visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies retail's in-cell update gate without ending emitter ownership.
|
||||
/// Retail retains absolute creation timestamps while cell-less; the next
|
||||
/// update observes the elapsed wall-clock interval and expires old state.
|
||||
/// Only acdream's legacy rate accumulator is rebased to prevent a synthetic
|
||||
/// multi-particle catch-up burst that retail's one-shot emission path lacks.
|
||||
/// </summary>
|
||||
public void SetEmitterSimulationEnabled(int handle, bool enabled)
|
||||
{
|
||||
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|
||||
|| emitter.SimulationEnabled == enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
emitter.SimulationEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitter.Desc.Birthrate <= 0f && emitter.Desc.EmitRate > 0f)
|
||||
{
|
||||
emitter.LastEmitTime = _time;
|
||||
emitter.EmittedAccumulator = 0f;
|
||||
}
|
||||
emitter.SimulationEnabled = true;
|
||||
}
|
||||
|
||||
/// <summary>True when the given handle still maps to a live emitter.</summary>
|
||||
public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle);
|
||||
|
||||
|
|
@ -136,6 +207,8 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
int handle = _handleOrder[i];
|
||||
if (!_byHandle.TryGetValue(handle, out var em))
|
||||
continue;
|
||||
if (!em.SimulationEnabled)
|
||||
continue;
|
||||
|
||||
AdvanceEmitter(em);
|
||||
int live = CountAlive(em);
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ public sealed class ParticleEmitter
|
|||
public Vector3 AnchorPos { get; set; }
|
||||
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
|
||||
public uint AttachedObjectId { get; set; }
|
||||
/// <summary>
|
||||
/// Spatial presentation latch. A cell-less owner submits no particles.
|
||||
/// </summary>
|
||||
public bool PresentationVisible { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Retail cell-membership update gate. Paused emitters retain their exact
|
||||
/// particles, logical identity, and elapsed-time position until re-entry.
|
||||
/// </summary>
|
||||
public bool SimulationEnabled { get; set; } = true;
|
||||
public int AttachedPartIndex { get; set; } = -1;
|
||||
public Particle[] Particles { get; init; } = null!;
|
||||
public ParticleRenderPass RenderPass { get; init; }
|
||||
|
|
|
|||
|
|
@ -30,6 +30,30 @@ public sealed class WorldEntity
|
|||
/// </summary>
|
||||
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
|
||||
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT
|
||||
/// part whose GfxObj could not be loaded. <see cref="IndexedPartAvailable"/>
|
||||
/// distinguishes a real indexed part from a retained placeholder pose.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Matrix4x4> IndexedPartTransforms { get; private set; } =
|
||||
Array.Empty<Matrix4x4>();
|
||||
|
||||
public IReadOnlyList<bool> IndexedPartAvailable { get; private set; } =
|
||||
Array.Empty<bool>();
|
||||
|
||||
public void SetIndexedPartPoses(
|
||||
IReadOnlyList<Matrix4x4> transforms,
|
||||
IReadOnlyList<bool> available)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transforms);
|
||||
ArgumentNullException.ThrowIfNull(available);
|
||||
if (transforms.Count != available.Count)
|
||||
throw new ArgumentException("Indexed part pose and availability counts must match.");
|
||||
IndexedPartTransforms = transforms;
|
||||
IndexedPartAvailable = available;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-entity palette override (server-specified base +
|
||||
/// subpalette overlays). When non-null, applies to every palette-
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue