From ff5d86175f4548d4d65da8f307937854554bd3a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 22:12:26 +0200 Subject: [PATCH] feat(rendering): index incremental scene queries Maintain packed render worksets as lifecycle deltas arrive and synchronize active animated statics without scanning the resident world. This keeps Slice F non-drawing while preparing exact spatial and feature queries for shadow comparison. Release: 8,206 passed, 5 skipped. --- ...-modern-runtime-slices-f-g-render-scene.md | 4 +- .../RetailStaticAnimatingObjectScheduler.cs | 15 + .../Rendering/Scene/Arch/ArchRenderScene.cs | 285 +++++++++++++++++- .../Rendering/Scene/RenderSceneContracts.cs | 95 ++++++ .../Scene/StaticRenderProjectionJournal.cs | 114 ++++++- .../Update/LiveObjectFrameController.cs | 13 +- .../Rendering/ArchRenderSceneTests.cs | 179 ++++++++++- .../StaticRenderProjectionJournalTests.cs | 56 ++++ 8 files changed, 745 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index 553b20ac..88ddcbb2 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -21,8 +21,8 @@ retail-faithful gameplay | F1 — scene types and contained adapter | complete | `Arch 2.1.0` is pinned in App only behind acdream contracts. Five narrow archetypes, generation/incarnation/sequence gates, borrowed-query invalidation, memory accounting, deterministic digesting, and update-thread enforcement pass 11 focused tests. Release gate: 3,701 App tests / 3 skips and 8,185 complete-solution tests / 5 skips. | | F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. | | F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. | -| F4 — dynamic indices | active | Packed spatial/class/visibility query indices have not landed. | -| F5 | pending | Continuous shadow comparison has not started. | +| F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. | +| F5 | active | Continuous lifecycle-automation shadow comparison is the next unit; no production consumer has switched. | | G0–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index ab813a51..9874090c 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -334,6 +334,21 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram } } + internal void CopyActiveDatStaticEntitiesTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach (Owner owner in _owners.Values) + { + if (owner.Entity.ServerGuid == 0 + && owner.Sequencer is not null + && _isResident(owner.Entity)) + { + destination.Add(owner.Entity); + } + } + } + public void Tick(float elapsedSeconds) { if (!float.IsFinite(elapsedSeconds) diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index af50712e..d6e9f529 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using AcDream.App.Rendering; using Arch.Core; using ArchWorld = Arch.Core.World; @@ -18,6 +19,19 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource private readonly int _ownerThreadId; private readonly Dictionary _entries = []; + private readonly HashSet _outdoorStatics = []; + private readonly HashSet _indoorCellStatics = []; + private readonly HashSet _dynamics = []; + private readonly HashSet _outdoorDynamics = []; + private readonly HashSet _portalStraddlingDynamics = []; + private readonly HashSet _translucent = []; + private readonly HashSet _selectable = []; + private readonly HashSet _lightCandidates = []; + private readonly HashSet _dirty = []; + private readonly Dictionary> + _cellStatics = []; + private readonly Dictionary> + _cellDynamics = []; private ArchWorld _world; private RenderProjectionCounts _counts; private ulong _lastAppliedJournalSequence; @@ -59,6 +73,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource int lookupCapacity = _entries.EnsureCapacity(0); long lookupBytes = (long)lookupCapacity * Unsafe.SizeOf(); + long indexBytes = EstimateIndexBytes(); return new RenderSceneMemoryAccounting( EntityCount: _world.Size, @@ -68,7 +83,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource EstimatedChunkPayloadBytes: estimatedChunkPayloadBytes, ProjectionLookupCapacity: lookupCapacity, EstimatedProjectionLookupBytes: lookupBytes, - EstimatedIndexBytes: 0, + EstimatedIndexBytes: indexBytes, EstimatedJournalBufferBytes: 0, EstimatedSynchronizationSourceBytes: 0); } @@ -158,6 +173,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ref RenderDirtyMask dirty = ref _world.Get(entry.Entity); dirty |= RenderDirtyMask.Transform | RenderDirtyMask.WorldBounds; + _dirty.Add(update.Id); } } @@ -205,6 +221,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource ArchWorld.Destroy(_world); _world = CreateWorld(); _entries.Clear(); + ClearIndices(); _counts = default; _lastAppliedJournalSequence = 0; Generation = replacementGeneration; @@ -218,10 +235,22 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource EnsureMutationThread(); ArchWorld.Destroy(_world); _entries.Clear(); + ClearIndices(); _counts = default; _disposed = true; } + public void ClearDirty() + { + EnsureMutationThread(); + foreach (RenderProjectionId id in _dirty) + { + if (_entries.TryGetValue(id, out SceneEntry entry)) + _world.Set(entry.Entity, RenderDirtyMask.None); + } + _dirty.Clear(); + } + RenderProjectionCounts IRenderSceneQuerySource.GetCounts( RenderSceneGeneration generation) { @@ -229,6 +258,22 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return _counts; } + RenderSceneIndexCounts IRenderSceneQuerySource.GetIndexCounts( + RenderSceneGeneration generation) + { + EnsureQueryGeneration(generation); + return new RenderSceneIndexCounts( + _outdoorStatics.Count, + _indoorCellStatics.Count, + _dynamics.Count, + _outdoorDynamics.Count, + _portalStraddlingDynamics.Count, + _translucent.Count, + _selectable.Count, + _lightCandidates.Count, + _dirty.Count); + } + bool IRenderSceneQuerySource.TryGet( RenderSceneGeneration generation, RenderProjectionId id, @@ -276,6 +321,41 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource return count; } + int IRenderSceneQuerySource.CopyIndexTo( + RenderSceneGeneration generation, + RenderSceneIndex index, + Span destination) + { + EnsureQueryGeneration(generation); + HashSet source = Index(index); + return CopyIdsTo(source, destination); + } + + int IRenderSceneQuerySource.GetCellCount( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic) + { + EnsureQueryGeneration(generation); + Dictionary> index = + dynamic ? _cellDynamics : _cellStatics; + return index.TryGetValue(fullCellId, out var ids) ? ids.Count : 0; + } + + int IRenderSceneQuerySource.CopyCellTo( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic, + Span destination) + { + EnsureQueryGeneration(generation); + Dictionary> index = + dynamic ? _cellDynamics : _cellStatics; + return index.TryGetValue(fullCellId, out var ids) + ? CopyIdsTo(ids, destination) + : 0; + } + private static ArchWorld CreateWorld() => ArchWorld.Create( archetypeCapacity: InitialArchetypeCapacity, @@ -298,7 +378,9 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource if (incarnationOrder == 0 && record.ProjectionClass == existing.ProjectionClass) { + RenderProjectionRecord prior = ReadRecord(in existing); WriteRecord(existing.Entity, in record); + UpdateIndices(in prior, in record); result.Applied++; result.Updated++; return; @@ -314,6 +396,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource record.OwnerIncarnation, record.ProjectionClass); IncrementCount(record.ProjectionClass); + AddToIndices(in record); result.Applied++; result.Registered++; } @@ -326,6 +409,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource if (!TryGetCurrent(in record, ref result, out SceneEntry entry)) return; + RenderProjectionRecord prior = ReadRecord(in entry); switch (kind) { case RenderProjectionDeltaKind.UpdateTransform: @@ -336,6 +420,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entry.Entity, record.Source); OrDirty( entry.Entity, + record.Id, RenderDirtyMask.Transform | RenderDirtyMask.WorldBounds | RenderDirtyMask.SortKey); @@ -345,22 +430,30 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entry.Entity, record.Material); _world.Set(entry.Entity, record.DegradeState); _world.Set(entry.Entity, record.Source); - OrDirty(entry.Entity, RenderDirtyMask.Appearance); + OrDirty( + entry.Entity, + record.Id, + RenderDirtyMask.Appearance); break; case RenderProjectionDeltaKind.UpdateFlags: _world.Set(entry.Entity, record.Flags); _world.Set(entry.Entity, record.Source); - OrDirty(entry.Entity, RenderDirtyMask.Flags); + OrDirty(entry.Entity, record.Id, RenderDirtyMask.Flags); break; case RenderProjectionDeltaKind.Rebucket: _world.Set(entry.Entity, record.Residency); _world.Set(entry.Entity, record.Source); - OrDirty(entry.Entity, RenderDirtyMask.SpatialResidency); + OrDirty( + entry.Entity, + record.Id, + RenderDirtyMask.SpatialResidency); break; default: throw new ArgumentOutOfRangeException(nameof(kind), kind, null); } + RenderProjectionRecord current = ReadRecord(in entry); + UpdateIndices(in prior, in current); result.Applied++; result.Updated++; } @@ -474,14 +567,196 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource private void Destroy(in SceneEntry entry) { + RenderProjectionRecord record = ReadRecord(in entry); + RemoveFromIndices(in record); _world.Destroy(entry.Entity); DecrementCount(entry.ProjectionClass); } - private void OrDirty(Entity entity, RenderDirtyMask value) + private void OrDirty( + Entity entity, + RenderProjectionId id, + RenderDirtyMask value) { ref RenderDirtyMask dirty = ref _world.Get(entity); dirty |= value; + _dirty.Add(id); + } + + private void UpdateIndices( + in RenderProjectionRecord prior, + in RenderProjectionRecord current) + { + RemoveFromIndices(in prior); + AddToIndices(in current); + } + + private void AddToIndices(in RenderProjectionRecord record) + { + bool dynamic = IsDynamic(record.ProjectionClass); + bool staticProjection = !dynamic; + bool indoor = InteriorEntityPartition.IsIndoorCellId( + record.Source.ParentCellId == 0 + ? null + : record.Source.ParentCellId); + + if (staticProjection) + { + if (indoor) + { + _indoorCellStatics.Add(record.Id); + AddCell(_cellStatics, record.Residency.FullCellId, record.Id); + } + else + { + _outdoorStatics.Add(record.Id); + } + } + else + { + _dynamics.Add(record.Id); + if (indoor) + AddCell(_cellDynamics, record.Residency.FullCellId, record.Id); + else + _outdoorDynamics.Add(record.Id); + if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0) + _portalStraddlingDynamics.Add(record.Id); + } + + if ((record.Flags & RenderProjectionFlags.Translucent) != 0) + _translucent.Add(record.Id); + if ((record.Flags & RenderProjectionFlags.Selectable) != 0) + _selectable.Add(record.Id); + if ((record.Flags & RenderProjectionFlags.LightCandidate) != 0) + _lightCandidates.Add(record.Id); + if (record.DirtyMask != RenderDirtyMask.None) + _dirty.Add(record.Id); + } + + private void RemoveFromIndices(in RenderProjectionRecord record) + { + _outdoorStatics.Remove(record.Id); + _indoorCellStatics.Remove(record.Id); + _dynamics.Remove(record.Id); + _outdoorDynamics.Remove(record.Id); + _portalStraddlingDynamics.Remove(record.Id); + _translucent.Remove(record.Id); + _selectable.Remove(record.Id); + _lightCandidates.Remove(record.Id); + _dirty.Remove(record.Id); + RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id); + RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id); + } + + private static bool IsDynamic(RenderProjectionClass projectionClass) => + projectionClass is RenderProjectionClass.LiveDynamicRoot + or RenderProjectionClass.EquippedChild; + + private static void AddCell( + Dictionary> index, + uint fullCellId, + RenderProjectionId id) + { + if (fullCellId == 0) + return; + if (!index.TryGetValue(fullCellId, out HashSet? ids)) + { + ids = []; + index.Add(fullCellId, ids); + } + ids.Add(id); + } + + private static void RemoveCell( + Dictionary> index, + uint fullCellId, + RenderProjectionId id) + { + if (!index.TryGetValue(fullCellId, out HashSet? ids)) + return; + ids.Remove(id); + if (ids.Count == 0) + index.Remove(fullCellId); + } + + private HashSet Index(RenderSceneIndex index) => + index switch + { + RenderSceneIndex.OutdoorStatic => _outdoorStatics, + RenderSceneIndex.IndoorCellStatic => _indoorCellStatics, + RenderSceneIndex.Dynamic => _dynamics, + RenderSceneIndex.OutdoorDynamic => _outdoorDynamics, + RenderSceneIndex.PortalStraddlingDynamic => + _portalStraddlingDynamics, + RenderSceneIndex.Translucent => _translucent, + RenderSceneIndex.Selectable => _selectable, + RenderSceneIndex.LightCandidate => _lightCandidates, + RenderSceneIndex.Dirty => _dirty, + _ => throw new ArgumentOutOfRangeException( + nameof(index), + index, + null), + }; + + private int CopyIdsTo( + HashSet source, + Span destination) + { + if (destination.Length < source.Count) + { + throw new ArgumentException( + $"Destination holds {destination.Length} records; {source.Count} required.", + nameof(destination)); + } + + int count = 0; + foreach (RenderProjectionId id in source) + { + if (!_entries.TryGetValue(id, out SceneEntry entry)) + { + throw new InvalidOperationException( + $"Render index retained missing {id}."); + } + destination[count++] = ReadRecord(in entry); + } + return count; + } + + private long EstimateIndexBytes() + { + long slots = + _outdoorStatics.EnsureCapacity(0) + + _indoorCellStatics.EnsureCapacity(0) + + _dynamics.EnsureCapacity(0) + + _outdoorDynamics.EnsureCapacity(0) + + _portalStraddlingDynamics.EnsureCapacity(0) + + _translucent.EnsureCapacity(0) + + _selectable.EnsureCapacity(0) + + _lightCandidates.EnsureCapacity(0) + + _dirty.EnsureCapacity(0); + foreach (HashSet ids in _cellStatics.Values) + slots += ids.EnsureCapacity(0); + foreach (HashSet ids in _cellDynamics.Values) + slots += ids.EnsureCapacity(0); + long cellLookupSlots = + _cellStatics.EnsureCapacity(0) + + _cellDynamics.EnsureCapacity(0); + return checked(slots * 24L + cellLookupSlots * 40L); + } + + private void ClearIndices() + { + _outdoorStatics.Clear(); + _indoorCellStatics.Clear(); + _dynamics.Clear(); + _outdoorDynamics.Clear(); + _portalStraddlingDynamics.Clear(); + _translucent.Clear(); + _selectable.Clear(); + _lightCandidates.Clear(); + _dirty.Clear(); + _cellStatics.Clear(); + _cellDynamics.Clear(); } private void IncrementCount(RenderProjectionClass projectionClass) diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index 4b67be2c..2f2aa863 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -304,6 +304,50 @@ internal readonly record struct RenderProjectionCounts( }; } +internal enum RenderSceneIndex : byte +{ + OutdoorStatic, + IndoorCellStatic, + Dynamic, + OutdoorDynamic, + PortalStraddlingDynamic, + Translucent, + Selectable, + LightCandidate, + Dirty, +} + +internal readonly record struct RenderSceneIndexCounts( + int OutdoorStatic, + int IndoorCellStatic, + int Dynamic, + int OutdoorDynamic, + int PortalStraddlingDynamic, + int Translucent, + int Selectable, + int LightCandidate, + int Dirty) +{ + public int For(RenderSceneIndex index) => + index switch + { + RenderSceneIndex.OutdoorStatic => OutdoorStatic, + RenderSceneIndex.IndoorCellStatic => IndoorCellStatic, + RenderSceneIndex.Dynamic => Dynamic, + RenderSceneIndex.OutdoorDynamic => OutdoorDynamic, + RenderSceneIndex.PortalStraddlingDynamic => + PortalStraddlingDynamic, + RenderSceneIndex.Translucent => Translucent, + RenderSceneIndex.Selectable => Selectable, + RenderSceneIndex.LightCandidate => LightCandidate, + RenderSceneIndex.Dirty => Dirty, + _ => throw new ArgumentOutOfRangeException( + nameof(index), + index, + null), + }; +} + internal readonly record struct RenderDeltaApplyResult( int Applied, int Registered, @@ -357,6 +401,7 @@ internal sealed class RenderSceneDigestBuffer internal interface IRenderSceneQuerySource { RenderProjectionCounts GetCounts(RenderSceneGeneration generation); + RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation); bool TryGet( RenderSceneGeneration generation, @@ -367,6 +412,22 @@ internal interface IRenderSceneQuerySource RenderSceneGeneration generation, RenderProjectionClass? projectionClass, Span destination); + + int CopyIndexTo( + RenderSceneGeneration generation, + RenderSceneIndex index, + Span destination); + + int GetCellCount( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic); + + int CopyCellTo( + RenderSceneGeneration generation, + uint fullCellId, + bool dynamic, + Span destination); } internal readonly struct RenderSceneQuery @@ -386,6 +447,9 @@ internal readonly struct RenderSceneQuery public RenderProjectionCounts Counts => Source.GetCounts(Generation); + public RenderSceneIndexCounts IndexCounts => + Source.GetIndexCounts(Generation); + public bool TryGet( RenderProjectionId id, out RenderProjectionRecord record) => @@ -399,6 +463,35 @@ internal readonly struct RenderSceneQuery Span destination) => Source.CopyTo(Generation, projectionClass, destination); + public int CopyIndexTo( + RenderSceneIndex index, + Span destination) => + Source.CopyIndexTo(Generation, index, destination); + + public int GetCellStaticCount(uint fullCellId) => + Source.GetCellCount(Generation, fullCellId, dynamic: false); + + public int CopyCellStaticsTo( + uint fullCellId, + Span destination) => + Source.CopyCellTo( + Generation, + fullCellId, + dynamic: false, + destination); + + public int GetCellDynamicCount(uint fullCellId) => + Source.GetCellCount(Generation, fullCellId, dynamic: true); + + public int CopyCellDynamicsTo( + uint fullCellId, + Span destination) => + Source.CopyCellTo( + Generation, + fullCellId, + dynamic: true, + destination); + private IRenderSceneQuerySource Source => _source ?? throw new InvalidOperationException("The render-scene query is uninitialized."); @@ -420,5 +513,7 @@ internal interface IRenderScene : IDisposable RenderSceneQuery OpenQuery(); + void ClearDirty(); + void Clear(RenderSceneGeneration replacementGeneration); } diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs index fc491ea8..96f51d37 100644 --- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -28,6 +28,12 @@ internal sealed class StaticRenderProjectionJournal : private readonly RenderProjectionJournal _journal; private readonly Dictionary> _byLandblock = []; + private readonly Dictionary + _trackedById = []; + private readonly Dictionary _idByEntity = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> _entitiesByLandblock = + []; private readonly List _candidates = []; private readonly List _removed = []; private ulong _nextIncarnation = 1; @@ -87,7 +93,10 @@ internal sealed class StaticRenderProjectionJournal : for (int i = 0; i < _candidates.Count; i++) { RenderProjectionRecord candidate = _candidates[i]; - if (current.TryGetValue(candidate.Id, out TrackedProjection retained)) + if (current.TryGetValue( + candidate.Id, + out TrackedProjection? retained) + && retained is not null) { _removed.Remove(candidate.Id); RenderProjectionRecord accepted = candidate with @@ -107,14 +116,14 @@ internal sealed class StaticRenderProjectionJournal : OwnerIncarnation = NextIncarnation(), }; _journal.Register(in accepted); - current[candidate.Id] = new TrackedProjection(accepted); + retained.Record = accepted; continue; } if (accepted != retained.Record) { _journal.AppendDifference(retained.Record, accepted); - current[candidate.Id] = new TrackedProjection(accepted); + retained.Record = accepted; } continue; } @@ -124,7 +133,9 @@ internal sealed class StaticRenderProjectionJournal : OwnerIncarnation = NextIncarnation(), }; _journal.Register(in registered); - current.Add(registered.Id, new TrackedProjection(registered)); + var tracked = new TrackedProjection(registered); + current.Add(registered.Id, tracked); + _trackedById.Add(registered.Id, tracked); ProjectionCount++; } @@ -135,11 +146,13 @@ internal sealed class StaticRenderProjectionJournal : TrackedProjection omitted = current[id]; _journal.Unregister(id, omitted.Record.OwnerIncarnation); current.Remove(id); + _trackedById.Remove(id); ProjectionCount--; } if (current.Count == 0) _byLandblock.Remove(landblockId); + ReplaceStaticEntityMap(landblockId, publication.Landblock.Entities); } public void Retire(GpuLandblockRetirement retirement) @@ -166,17 +179,66 @@ internal sealed class StaticRenderProjectionJournal : } ProjectionCount -= current.Count; + foreach (RenderProjectionId id in current.Keys) + _trackedById.Remove(id); + RemoveStaticEntityMap(landblockId); } public void Clear(RenderSceneGeneration replacementGeneration) { _byLandblock.Clear(); + _trackedById.Clear(); + _idByEntity.Clear(); + _entitiesByLandblock.Clear(); _candidates.Clear(); _removed.Clear(); ProjectionCount = 0; _journal.Clear(replacementGeneration); } + public void SynchronizeActiveAnimatedSources( + IReadOnlyList activeEntities) + { + ArgumentNullException.ThrowIfNull(activeEntities); + for (int i = 0; i < activeEntities.Count; i++) + { + WorldEntity entity = activeEntities[i]; + if (!_idByEntity.TryGetValue( + entity, + out RenderProjectionId id) + || !_trackedById.TryGetValue( + id, + out TrackedProjection? tracked)) + { + continue; + } + + RenderProjectionRecord current = + RenderProjectionRecordFactory.ProjectEntity( + id, + RenderProjectionClass.ActiveAnimatedStatic, + tracked.Record.OwnerIncarnation, + tracked.Record.Residency.OwnerLandblockId, + tracked.Record.Residency.FullCellId, + entity, + spatiallyVisible: true) with + { + PreviousTransform = new PreviousRenderTransform( + tracked.Record.Transform.LocalToWorld), + }; + if (tracked.Record.ProjectionClass + != RenderProjectionClass.ActiveAnimatedStatic) + { + _journal.Register(in current); + } + else + { + _journal.AppendDifference(tracked.Record, current); + } + tracked.Record = current; + } + } + internal bool TryGet( RenderProjectionId id, out RenderProjectionRecord record) @@ -184,7 +246,10 @@ internal sealed class StaticRenderProjectionJournal : foreach (Dictionary projections in _byLandblock.Values) { - if (projections.TryGetValue(id, out TrackedProjection tracked)) + if (projections.TryGetValue( + id, + out TrackedProjection? tracked) + && tracked is not null) { record = tracked.Record; return true; @@ -310,8 +375,43 @@ internal sealed class StaticRenderProjectionJournal : private static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu; - private readonly record struct TrackedProjection( - RenderProjectionRecord Record); + private void ReplaceStaticEntityMap( + uint landblockId, + IReadOnlyList entities) + { + RemoveStaticEntityMap(landblockId); + var retained = new List(); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + if (entity.ServerGuid != 0) + continue; + RenderProjectionId id = StaticEntityId(landblockId, entity.Id); + if (!_trackedById.ContainsKey(id)) + continue; + _idByEntity.Add(entity, id); + retained.Add(entity); + } + if (retained.Count > 0) + _entitiesByLandblock.Add(landblockId, retained); + } + + private void RemoveStaticEntityMap(uint landblockId) + { + if (!_entitiesByLandblock.Remove( + landblockId, + out List? entities)) + { + return; + } + for (int i = 0; i < entities.Count; i++) + _idByEntity.Remove(entities[i]); + } + + private sealed class TrackedProjection(RenderProjectionRecord record) + { + public RenderProjectionRecord Record { get; set; } = record; + } private sealed class ProjectionRecordComparer : IComparer diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index a6ba5de5..8a3f6a7c 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -138,6 +138,8 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase private readonly EquippedChildRenderController _equippedChildren; private readonly LiveEffectFrameController _effects; private readonly ILiveRenderProjectionSink? _renderProjections; + private readonly StaticRenderProjectionJournal? _staticRenderProjections; + private readonly List _activeStaticProjectionScratch = []; public LiveObjectFrameController( RetailInboundEventDispatcher inboundEvents, @@ -152,7 +154,8 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase LiveEntityAnimationRuntimeView animatedEntities, EquippedChildRenderController equippedChildren, LiveEffectFrameController effects, - ILiveRenderProjectionSink? renderProjections = null) + ILiveRenderProjectionSink? renderProjections = null, + StaticRenderProjectionJournal? staticRenderProjections = null) { _inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents)); _localPlayerFrame = localPlayerFrame @@ -172,6 +175,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase ?? throw new ArgumentNullException(nameof(equippedChildren)); _effects = effects ?? throw new ArgumentNullException(nameof(effects)); _renderProjections = renderProjections; + _staticRenderProjections = staticRenderProjections; } public void Tick(float deltaSeconds) => @@ -207,6 +211,13 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase _animationPresenter.Present(schedules); _equippedChildren.Tick(); _renderProjections?.SynchronizeActiveSources(); + if (_staticRenderProjections is not null) + { + _staticAnimations.CopyActiveDatStaticEntitiesTo( + _activeStaticProjectionScratch); + _staticRenderProjections.SynchronizeActiveAnimatedSources( + _activeStaticProjectionScratch); + } // Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs // Script -> Particle -> process_hooks. acdream intentionally retains // the TS-51 static-order difference here: ProcessHooks precedes the diff --git a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs index 058784ed..9e4f98c0 100644 --- a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs @@ -253,7 +253,8 @@ public sealed class ArchRenderSceneTests Assert.True(memory.EstimatedProjectionLookupBytes > 0); Assert.Equal( memory.EstimatedChunkPayloadBytes - + memory.EstimatedProjectionLookupBytes, + + memory.EstimatedProjectionLookupBytes + + memory.EstimatedIndexBytes, memory.TotalEstimatedBytes); } @@ -359,6 +360,182 @@ public sealed class ArchRenderSceneTests Assert.Equal(0, scene.Counts.Total); } + [Fact] + public void IncrementalIndices_PreserveOutdoorCellDynamicAndFeatureMembership() + { + const uint indoorCell = 0x12340100u; + RenderSceneGeneration generation = Generation(16); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord outdoorStatic = Record( + 1, + 1, + RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord indoorStatic = Record( + 2, + 1, + RenderProjectionClass.IndoorCellStatic) with + { + Residency = new RenderSpatialResidency( + Bucket(indoorCell), + 0x1234FFFFu, + indoorCell), + Source = new RenderSourceMetadata() with + { + ParentCellId = indoorCell, + }, + }; + RenderProjectionRecord animatedStatic = Record( + 3, + 1, + RenderProjectionClass.ActiveAnimatedStatic); + RenderProjectionRecord outdoorDynamic = Record( + 4, + 1, + RenderProjectionClass.LiveDynamicRoot); + RenderProjectionRecord cellDynamic = Record( + 5, + 1, + RenderProjectionClass.LiveDynamicRoot) with + { + Residency = new RenderSpatialResidency( + Bucket(indoorCell), + 0x1234FFFFu, + indoorCell), + Source = new RenderSourceMetadata() with + { + ParentCellId = indoorCell, + }, + }; + RenderProjectionRecord equipped = Record( + 6, + 1, + RenderProjectionClass.EquippedChild) with + { + Flags = RenderProjectionFlags.Draw + | RenderProjectionFlags.Selectable + | RenderProjectionFlags.Translucent + | RenderProjectionFlags.LightCandidate + | RenderProjectionFlags.PortalStraddling, + }; + RenderProjectionRecord[] records = + [ + outdoorStatic, + indoorStatic, + animatedStatic, + outdoorDynamic, + cellDynamic, + equipped, + ]; + var deltas = new RenderProjectionDelta[records.Length]; + for (int i = 0; i < records.Length; i++) + { + deltas[i] = RenderProjectionDelta.Register( + generation, + (ulong)i + 1, + records[i]); + } + + scene.Apply(deltas); + RenderSceneQuery query = scene.OpenQuery(); + + Assert.Equal( + new RenderSceneIndexCounts( + OutdoorStatic: 2, + IndoorCellStatic: 1, + Dynamic: 3, + OutdoorDynamic: 2, + PortalStraddlingDynamic: 1, + Translucent: 1, + Selectable: 6, + LightCandidate: 1, + Dirty: 6), + query.IndexCounts); + Assert.Equal(1, query.GetCellStaticCount(indoorCell)); + Assert.Equal(1, query.GetCellDynamicCount(indoorCell)); + var cellRecords = new RenderProjectionRecord[1]; + Assert.Equal(1, query.CopyCellStaticsTo(indoorCell, cellRecords)); + Assert.Equal(indoorStatic.Id, cellRecords[0].Id); + Assert.Equal(1, query.CopyCellDynamicsTo(indoorCell, cellRecords)); + Assert.Equal(cellDynamic.Id, cellRecords[0].Id); + Assert.True(scene.Memory.EstimatedIndexBytes > 0); + } + + [Fact] + public void IncrementalIndices_UpdateRebucketReplaceRemoveAndDirtyAcknowledge() + { + const uint indoorCell = 0x22220100u; + RenderSceneGeneration generation = Generation(17); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord original = Record( + 17, + 1, + RenderProjectionClass.LiveDynamicRoot); + scene.Apply( + [RenderProjectionDelta.Register(generation, 1, original)]); + scene.ClearDirty(); + Assert.Equal(0, scene.OpenQuery().IndexCounts.Dirty); + + RenderProjectionRecord flagged = original with + { + Flags = original.Flags + | RenderProjectionFlags.Translucent + | RenderProjectionFlags.LightCandidate + | RenderProjectionFlags.PortalStraddling, + Source = original.Source with { ParentCellId = indoorCell }, + }; + RenderProjectionRecord rebucketed = flagged with + { + Residency = new RenderSpatialResidency( + Bucket(indoorCell), + 0x2222FFFFu, + indoorCell), + }; + scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 2, + flagged), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.Rebucket, + generation, + 3, + rebucketed), + ]); + + RenderSceneQuery updated = scene.OpenQuery(); + Assert.Equal(0, updated.IndexCounts.OutdoorDynamic); + Assert.Equal(1, updated.IndexCounts.PortalStraddlingDynamic); + Assert.Equal(1, updated.IndexCounts.Translucent); + Assert.Equal(1, updated.IndexCounts.LightCandidate); + Assert.Equal(1, updated.IndexCounts.Dirty); + Assert.Equal(1, updated.GetCellDynamicCount(indoorCell)); + + RenderProjectionRecord replacement = rebucketed with + { + ProjectionClass = RenderProjectionClass.IndoorCellStatic, + OwnerIncarnation = Incarnation(2), + }; + scene.Apply( + [RenderProjectionDelta.Register(generation, 4, replacement)]); + RenderSceneQuery replaced = scene.OpenQuery(); + Assert.Equal(0, replaced.IndexCounts.Dynamic); + Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic); + Assert.Equal(1, replaced.GetCellStaticCount(indoorCell)); + + scene.Apply( + [ + RenderProjectionDelta.Unregister( + generation, + 5, + replacement.Id, + replacement.OwnerIncarnation), + ]); + Assert.Equal(default, scene.OpenQuery().IndexCounts); + Assert.True(scene.Memory.EstimatedIndexBytes > 0); + } + private static RenderProjectionRecord Record( ulong id, ulong incarnation, diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs index 179dd3eb..704462d2 100644 --- a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs @@ -207,6 +207,62 @@ public sealed class StaticRenderProjectionJournalTests () => statics.Clear(Generation(7))); } + [Fact] + public void ActiveAnimatedSynchronization_ReclassifiesAndUpdatesOnlyActiveSource() + { + RenderSceneGeneration generation = Generation(8); + var journal = new RenderProjectionJournal(generation); + var statics = new StaticRenderProjectionJournal(journal); + WorldEntity animated = Entity(1); + WorldEntity ordinary = Entity(2); + LandblockBuild build = Build( + LandblockId, + [animated, ordinary], + includeShell: false); + statics.Reconcile(build, Publication(build)); + using var scene = new ArchRenderScene(generation); + journal.DrainTo(scene); + RenderProjectionId animatedId = + StaticRenderProjectionJournal.StaticEntityId( + LandblockId, + animated.Id); + Assert.True(scene.OpenQuery().TryGet(animatedId, out var before)); + + statics.SynchronizeActiveAnimatedSources([animated]); + Assert.Single(journal.Pending.ToArray()); + Assert.Equal( + RenderProjectionDeltaKind.Register, + journal.Pending[0].Kind); + Assert.Equal( + RenderProjectionClass.ActiveAnimatedStatic, + journal.Pending[0].Record.ProjectionClass); + Assert.Equal( + before.OwnerIncarnation, + journal.Pending[0].Record.OwnerIncarnation); + journal.DrainTo(scene); + + statics.SynchronizeActiveAnimatedSources([animated]); + Assert.Equal(0, journal.Count); + + animated.Rotation = + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); + statics.SynchronizeActiveAnimatedSources([animated]); + Assert.Single(journal.Pending.ToArray()); + Assert.Equal( + RenderProjectionDeltaKind.UpdateTransform, + journal.Pending[0].Kind); + Assert.True(statics.TryGet(animatedId, out var updated)); + Assert.Equal(animated.Rotation, updated.Transform.Rotation); + RenderProjectionId ordinaryId = + StaticRenderProjectionJournal.StaticEntityId( + LandblockId, + ordinary.Id); + Assert.True(statics.TryGet(ordinaryId, out var unchanged)); + Assert.Equal( + RenderProjectionClass.OutdoorStatic, + unchanged.ProjectionClass); + } + private static GpuLandblockSpatialPublication Publication( LandblockBuild build, bool requiresActivation = true) =>