acdream/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
Erik 420e5eea70 refactor(app): key live projections by runtime identity
Move materialized live-object sidecars and presentation worksets to exact RuntimeEntityKey ownership. Runtime remains the only GUID/incarnation/local-ID authority while hydration, animation, effects, lights, equipped children, renderer resources, visibility, liveness, and teardown resolve exact projection identities. Preserve synchronous callbacks, local-ID allocation order, and current rendering behavior.
2026-07-25 21:50:58 +02:00

353 lines
12 KiB
C#

using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Rendering.Scene;
internal interface ILiveRenderProjectionSink : IRenderProjectionSyncPhase
{
bool OnEntityReady(LiveEntityReadyCandidate candidate);
void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible);
void OnProjectionPoseReady(uint serverGuid);
void OnProjectionRemoved(LiveEntityRecord record);
void OnResourceUnregister(WorldEntity entity);
}
/// <summary>
/// Change-driven render projection for canonical live records. It retains
/// exact record references and local presentation IDs only; server GUID lookup
/// remains exclusively in <see cref="LiveEntityRuntime"/>.
/// </summary>
internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
{
private const byte LiveProjectionDomain = 3;
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly Dictionary<RuntimeEntityKey, TrackedProjection> _byKey = [];
private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _activeScratch = [];
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
RenderProjectionJournal journal,
IRenderTraversalOrderSource traversalOrder)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
_traversalOrder = traversalOrder
?? throw new ArgumentNullException(nameof(traversalOrder));
}
public int ProjectionCount => _byKey.Count;
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
if (!candidate.IsCurrent(_runtime)
|| candidate.WorldEntity is not { } entity
|| !candidate.Record.ResourcesRegistered)
{
return false;
}
Upsert(candidate.Record, entity, candidate.Record.IsSpatiallyVisible);
return candidate.IsCurrent(_runtime);
}
public void OnProjectionVisibilityChanged(
LiveEntityRecord record,
bool visible)
{
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record)
|| record.WorldEntity is not { } entity
|| record.ProjectionKey is not { } key
|| !_byKey.ContainsKey(key))
{
return;
}
Upsert(record, entity, visible);
}
public void OnProjectionPoseReady(uint serverGuid)
{
if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached)
{
return;
}
// This callback is the attached-projection owner's authoritative
// presence edge as well as its pose-update edge. A child can leave
// presentation during a portal transition while its accepted parent
// relation remains alive; the first composed destination pose must
// therefore restore a projection removed by that transition.
Upsert(record, entity, record.IsSpatiallyVisible);
}
public void OnProjectionRemoved(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (record.ProjectionKey is { } key
&& _byKey.TryGetValue(
key,
out TrackedProjection? tracked)
&& tracked.Projection.ProjectionClass is
RenderProjectionClass.EquippedChild)
{
if (_runtime.IsCurrentRecord(tracked.Record)
&& ReferenceEquals(
tracked.Record.WorldEntity,
tracked.Entity))
{
// Leaving cell presentation is not logical destruction. Keep
// the projection identity inactive so a later residency or
// destination-pose edge can reactivate the same attachment
// without replaying EntityReady.
Upsert(tracked.Record, tracked.Entity, spatiallyVisible: false);
}
else
{
Remove(tracked);
}
}
}
public void OnResourceUnregister(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
TrackedProjection? tracked = null;
foreach (TrackedProjection candidate in _byKey.Values)
{
if (ReferenceEquals(candidate.Entity, entity))
{
tracked = candidate;
break;
}
}
if (tracked is not null)
Remove(tracked);
}
/// <summary>
/// Mirrors only registered live/attached sources after simulation,
/// animation, and attachment pose composition have committed. Resident
/// statics are never scanned.
/// </summary>
public void SynchronizeActiveSources()
{
// The canonical live-runtime workset is the source of truth for world
// roots. Walking only this journal's already-tracked values would make
// a missed/reordered derived callback permanent and prevent shadow
// comparison from converging after a same-location revisit.
_runtime.CopySpatialRootObjectRecordsTo(_activeRootScratch);
for (int i = 0; i < _activeRootScratch.Count; i++)
{
LiveEntityRecord record = _activeRootScratch[i];
if (!record.ResourcesRegistered
|| record.WorldEntity is not { } entity
|| !_runtime.IsCurrentSpatialRootObject(record))
{
continue;
}
Upsert(record, entity, record.IsSpatiallyVisible);
}
// Attached children are not members of the ordinary root workset.
// Their exact EntityReady/pose/removal callbacks remain authoritative;
// synchronization only refreshes children whose attachment projection
// is still retained by this derived scene.
_activeScratch.Clear();
foreach (TrackedProjection tracked in _byKey.Values)
{
if (tracked.Record.IsSpatiallyProjected
&& tracked.Record.ProjectionKind is
LiveEntityProjectionKind.Attached)
_activeScratch.Add(tracked);
}
for (int i = 0; i < _activeScratch.Count; i++)
{
TrackedProjection tracked = _activeScratch[i];
if (!_runtime.IsCurrentRecord(tracked.Record)
|| !ReferenceEquals(tracked.Record.WorldEntity, tracked.Entity)
|| tracked.Record.ProjectionKey is not { } key
|| !_byKey.TryGetValue(
key,
out TrackedProjection? current)
|| !ReferenceEquals(current, tracked))
{
continue;
}
Upsert(
tracked.Record,
tracked.Entity,
tracked.Record.IsSpatiallyVisible);
}
}
public void ResetTracking()
{
_byKey.Clear();
_activeRootScratch.Clear();
_activeScratch.Clear();
}
internal bool TryGet(
uint localEntityId,
out RenderProjectionRecord record)
{
if (_runtime.TryGetRecordByLocalEntityId(
localEntityId,
out LiveEntityRecord owner)
&& owner.ProjectionKey is { } key
&& _byKey.TryGetValue(key, out TrackedProjection? tracked))
{
record = tracked.Projection;
return true;
}
record = default;
return false;
}
internal static RenderProjectionId ProjectionId(uint localEntityId) =>
RenderProjectionId.FromRaw(
((ulong)LiveProjectionDomain << 56) | localEntityId);
private void Upsert(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
RenderProjectionRecord projected = Project(
record,
entity,
spatiallyVisible);
RuntimeEntityKey key = RequireProjectionKey(record);
if (!_byKey.TryGetValue(key, out TrackedProjection? tracked))
{
_journal.Register(in projected);
_byKey.Add(
key,
new TrackedProjection(record, entity, projected));
return;
}
if (!ReferenceEquals(tracked.Record, record)
|| !ReferenceEquals(tracked.Entity, entity)
|| tracked.Projection.OwnerIncarnation
!= projected.OwnerIncarnation
|| tracked.Projection.ProjectionClass
!= projected.ProjectionClass)
{
_journal.Register(in projected);
_byKey[key] =
new TrackedProjection(record, entity, projected);
return;
}
projected = projected with
{
PreviousTransform = new PreviousRenderTransform(
tracked.Projection.Transform.LocalToWorld),
};
_journal.AppendDifference(tracked.Projection, projected);
tracked.Projection = projected;
}
private RenderProjectionRecord Project(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
uint fullCellId = record.FullCellId != 0
? record.FullCellId
: entity.ParentCellId ?? 0;
uint ownerLandblockId = record.CanonicalLandblockId != 0
? record.CanonicalLandblockId
: fullCellId == 0
? 0
: Canonicalize(fullCellId);
RenderProjectionRecord projected =
RenderProjectionRecordFactory.ProjectEntity(
ProjectionId(entity.Id),
record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderProjectionClass.EquippedChild
: RenderProjectionClass.LiveDynamicRoot,
RenderOwnerIncarnation.FromRaw(
((ulong)record.Generation << 32) | entity.Id),
ownerLandblockId,
fullCellId,
entity,
spatiallyVisible);
if (_traversalOrder.TryGetTraversalSortKey(
entity,
out RenderSortKey sortKey))
{
return projected with { SortKey = sortKey };
}
// Spatial withdrawal is not logical destruction. Retain the last
// resident order while the projection is inactive so a portal/rebucket
// edge does not manufacture an unrelated ordering mutation.
RuntimeEntityKey key = RequireProjectionKey(record);
return _byKey.TryGetValue(
key,
out TrackedProjection? retained)
? projected with { SortKey = retained.Projection.SortKey }
: projected;
}
private void Remove(TrackedProjection tracked)
{
RuntimeEntityKey key = RequireProjectionKey(tracked.Record);
if (!_byKey.Remove(key))
return;
_journal.Unregister(
tracked.Projection.Id,
tracked.Projection.OwnerIncarnation);
}
private static uint Canonicalize(uint cellOrLandblockId) =>
(cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
private sealed class TrackedProjection(
LiveEntityRecord record,
WorldEntity entity,
RenderProjectionRecord projection)
{
public LiveEntityRecord Record { get; } = record;
public WorldEntity Entity { get; } = entity;
public RenderProjectionRecord Projection { get; set; } = projection;
}
}
internal sealed class LiveRenderProjectionResourceLifecycle(
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
{
private readonly ILiveRenderProjectionSink _sink =
sink ?? throw new ArgumentNullException(nameof(sink));
public void Register(WorldEntity entity)
{
// The exact EntityReady edge follows all resource registration and is
// the first point at which the projection may enter the journal.
}
public void Unregister(WorldEntity entity) =>
_sink.OnResourceUnregister(entity);
}