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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue