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 0593b2a8..a89ec894 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 @@ -19,8 +19,9 @@ retail-faithful gameplay | F0a — partition-input referee | complete | Diagnostic-only `CurrentRenderSceneOracle`; no production draw decision changed. | | F0b — survivor/dispatcher/selection referee | complete | Exact PView routes, dispatcher candidates and final group payloads, material/alpha/clip/light/selection fields, and accepted picking parts. Release gate: 3,690 App tests / 3 skips and 8,174 complete-solution tests / 5 skips. | | 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 | active | Static publication/retirement owners do not emit render deltas yet. | -| F3–F5 | pending | Live/equipped publication, dynamic indices, and continuous shadow comparison have not started. | +| 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 | active | Live/equipped accepted-state publication and final-frame synchronization have not landed. | +| F4–F5 | pending | Dynamic indices and continuous shadow comparison have not started. | | 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/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index c835a582..af50712e 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -333,6 +333,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entry.Entity, record.Transform); _world.Set(entry.Entity, record.Bounds); _world.Set(entry.Entity, record.SortKey); + _world.Set(entry.Entity, record.Source); OrDirty( entry.Entity, RenderDirtyMask.Transform @@ -343,14 +344,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entry.Entity, record.MeshSet); _world.Set(entry.Entity, record.Material); _world.Set(entry.Entity, record.DegradeState); + _world.Set(entry.Entity, record.Source); OrDirty(entry.Entity, RenderDirtyMask.Appearance); break; case RenderProjectionDeltaKind.UpdateFlags: _world.Set(entry.Entity, record.Flags); + _world.Set(entry.Entity, record.Source); OrDirty(entry.Entity, RenderDirtyMask.Flags); break; case RenderProjectionDeltaKind.Rebucket: _world.Set(entry.Entity, record.Residency); + _world.Set(entry.Entity, record.Source); OrDirty(entry.Entity, RenderDirtyMask.SpatialResidency); break; default: @@ -430,6 +434,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource record.SortKey, record.OwnerIncarnation, record.DirtyMask, + record.Source, tag); private void WriteRecord( @@ -447,6 +452,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entity, record.SortKey); _world.Set(entity, record.OwnerIncarnation); _world.Set(entity, record.DirtyMask); + _world.Set(entity, record.Source); } private RenderProjectionRecord ReadRecord(in SceneEntry entry) => @@ -463,7 +469,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Get(entry.Entity), _world.Get(entry.Entity), _world.Get(entry.Entity), - _world.Get(entry.Entity)); + _world.Get(entry.Entity), + _world.Get(entry.Entity)); private void Destroy(in SceneEntry entry) { @@ -549,6 +556,9 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource hash.Add(record.Id.RawValue); hash.Add((byte)record.ProjectionClass); hash.Add(record.OwnerIncarnation.RawValue); + hash.Add(record.Transform.Position); + hash.Add(record.Transform.Rotation); + hash.Add(record.Transform.UniformScale); hash.Add(record.Transform.LocalToWorld); hash.Add(record.PreviousTransform.LocalToWorld); hash.Add(record.MeshSet.Handle.RawValue); @@ -566,6 +576,18 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource hash.Add(record.DegradeState.Level); hash.Add(record.DegradeState.Revision); hash.Add(record.SortKey.Value); + hash.Add(record.Source.LocalEntityId); + hash.Add(record.Source.ServerGuid); + hash.Add(record.Source.SourceId); + hash.Add(record.Source.ParentCellId); + hash.Add(record.Source.EffectCellId); + hash.Add(record.Source.BuildingShellAnchorCellId); + hash.Add(record.Source.TransformFingerprint.Low); + hash.Add(record.Source.TransformFingerprint.High); + hash.Add(record.Source.GeometryFingerprint.Low); + hash.Add(record.Source.GeometryFingerprint.High); + hash.Add(record.Source.AppearanceFingerprint.Low); + hash.Add(record.Source.AppearanceFingerprint.High); } private readonly record struct ProjectionIdentity(RenderProjectionId Id); diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index a028328f..0c2601b2 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -701,6 +701,15 @@ internal sealed class CurrentRenderSceneOracle : }; } + internal static CurrentRenderProjectionFingerprint + CreateProjectionFingerprint( + uint landblockId, + WorldEntity entity) => + CreateFingerprint( + landblockId, + entity, + ProjectionClassOf(entity)); + private static CurrentRenderProjectionFingerprint CreateFingerprint( uint landblockId, WorldEntity entity, diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs new file mode 100644 index 00000000..4d99b99a --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs @@ -0,0 +1,88 @@ +namespace AcDream.App.Rendering.Scene; + +/// +/// Update-thread-owned structural journal between accepted world mutation and +/// the disposable render projection. Structural edges are never coalesced. +/// A successful frame publication drains the complete retained buffer. +/// +internal sealed class RenderProjectionJournal +{ + private readonly List _deltas = []; + private ulong _nextSequence = 1; + + public RenderProjectionJournal(RenderSceneGeneration generation) + { + Generation = generation; + } + + public RenderSceneGeneration Generation { get; private set; } + public int Count => _deltas.Count; + public int Capacity => _deltas.Capacity; + + public void Register(in RenderProjectionRecord record) => + _deltas.Add(RenderProjectionDelta.Register( + Generation, + NextSequence(), + in record)); + + public void Update( + RenderProjectionDeltaKind kind, + in RenderProjectionRecord record) => + _deltas.Add(RenderProjectionDelta.Update( + kind, + Generation, + NextSequence(), + in record)); + + public void Unregister( + RenderProjectionId id, + RenderOwnerIncarnation incarnation) => + _deltas.Add(RenderProjectionDelta.Unregister( + Generation, + NextSequence(), + id, + incarnation)); + + public RenderDeltaApplyResult DrainTo(IRenderScene scene) + { + ArgumentNullException.ThrowIfNull(scene); + if (scene.Generation != Generation) + { + throw new InvalidOperationException( + $"Journal {Generation} cannot drain into scene {scene.Generation}."); + } + + RenderDeltaApplyResult result = + scene.Apply(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_deltas)); + _deltas.Clear(); + return result; + } + + public void Clear(RenderSceneGeneration replacementGeneration) + { + if (replacementGeneration.CompareTo(Generation) <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(replacementGeneration), + replacementGeneration, + "A replacement journal generation must advance."); + } + + _deltas.Clear(); + Generation = replacementGeneration; + } + + internal ReadOnlySpan Pending => + System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_deltas); + + private ulong NextSequence() + { + if (_nextSequence == ulong.MaxValue) + { + throw new InvalidOperationException( + "Render projection journal sequence exhausted."); + } + + return _nextSequence++; + } +} diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index 8bb31d5c..4b67be2c 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -11,6 +11,7 @@ internal readonly record struct RenderProjectionId internal static RenderProjectionId FromRaw(ulong value) => new(value); internal ulong RawValue => _value; + internal RenderSortKey ToSortKey() => new(_value); public int CompareTo(RenderProjectionId other) => _value.CompareTo(other._value); @@ -117,7 +118,32 @@ internal enum RenderDirtyMask : ushort All = ushort.MaxValue, } -internal readonly record struct RenderTransform(Matrix4x4 LocalToWorld); +internal readonly record struct RenderTransform( + Vector3 Position, + Quaternion Rotation, + float UniformScale, + Matrix4x4 LocalToWorld) +{ + public RenderTransform(Matrix4x4 localToWorld) + : this( + localToWorld.Translation, + Quaternion.Identity, + 1.0f, + localToWorld) + { + } + + public static RenderTransform FromRoot( + Vector3 position, + Quaternion rotation, + float uniformScale) => + new( + position, + rotation, + uniformScale, + Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(position)); +} internal readonly record struct PreviousRenderTransform(Matrix4x4 LocalToWorld); @@ -142,6 +168,22 @@ internal readonly record struct RenderDegradeState(byte Level, uint Revision); internal readonly record struct RenderSortKey(ulong Value); +/// +/// Immutable source facts required to compare the derived presentation scene +/// with the accepted current path. These are diagnostics/render inputs only; +/// they never become a second gameplay or server-GUID authority. +/// +internal readonly record struct RenderSourceMetadata( + uint LocalEntityId, + uint ServerGuid, + uint SourceId, + uint ParentCellId, + uint EffectCellId, + uint BuildingShellAnchorCellId, + RenderSceneHash128 TransformFingerprint, + RenderSceneHash128 GeometryFingerprint, + RenderSceneHash128 AppearanceFingerprint); + internal readonly record struct RenderProjectionRecord( RenderProjectionId Id, RenderProjectionClass ProjectionClass, @@ -155,7 +197,8 @@ internal readonly record struct RenderProjectionRecord( RenderProjectionFlags Flags, RenderDegradeState DegradeState, RenderSortKey SortKey, - RenderDirtyMask DirtyMask); + RenderDirtyMask DirtyMask, + RenderSourceMetadata Source = default); internal enum RenderProjectionDeltaKind : byte { diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs new file mode 100644 index 00000000..16240b56 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -0,0 +1,466 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Scene; + +internal interface IRenderStaticProjectionJournalSink +{ + void Reconcile( + LandblockBuild build, + GpuLandblockSpatialPublication publication); + + void Retire(GpuLandblockRetirement retirement); +} + +/// +/// Derived, non-authoritative mirror of committed static presentation facts. +/// It owns no mesh, gameplay, GUID, cell, or streaming lifetime. Its only +/// purpose in Slice F2 is to append exact render-scene deltas at the accepted +/// publication and retirement receipts. +/// +internal sealed class StaticRenderProjectionJournal : + IRenderStaticProjectionJournalSink +{ + private const byte StaticEntityDomain = 1; + private const byte EnvCellShellDomain = 2; + private const float DefaultAabbRadius = 5.0f; + + private readonly RenderProjectionJournal _journal; + private readonly Dictionary> + _byLandblock = []; + private readonly List _candidates = []; + private readonly List _removed = []; + private ulong _nextIncarnation = 1; + + public StaticRenderProjectionJournal(RenderProjectionJournal journal) + { + _journal = journal ?? throw new ArgumentNullException(nameof(journal)); + } + + public int ProjectionCount { get; private set; } + public int LandblockCount => _byLandblock.Count; + + public void Reconcile( + LandblockBuild build, + GpuLandblockSpatialPublication publication) + { + ArgumentNullException.ThrowIfNull(build); + ArgumentNullException.ThrowIfNull(publication); + uint landblockId = Canonicalize(publication.LandblockId); + if (Canonicalize(build.LandblockId) != landblockId) + { + throw new ArgumentException( + "Static render projection publication belongs to another landblock.", + nameof(build)); + } + + if (!publication.RequiresActivation) + return; + + _candidates.Clear(); + for (int i = 0; i < publication.Landblock.Entities.Count; i++) + { + WorldEntity entity = publication.Landblock.Entities[i]; + if (entity.ServerGuid == 0) + _candidates.Add(ProjectStaticEntity(landblockId, entity)); + } + + if (build.EnvCells is { } envCells) + { + for (int i = 0; i < envCells.Shells.Length; i++) + _candidates.Add(ProjectEnvCellShell(landblockId, envCells.Shells[i])); + } + + _candidates.Sort(ProjectionRecordComparer.Instance); + if (!_byLandblock.TryGetValue( + landblockId, + out Dictionary? current)) + { + current = []; + _byLandblock.Add(landblockId, current); + } + + _removed.Clear(); + foreach (RenderProjectionId id in current.Keys) + _removed.Add(id); + + for (int i = 0; i < _candidates.Count; i++) + { + RenderProjectionRecord candidate = _candidates[i]; + if (current.TryGetValue(candidate.Id, out TrackedProjection retained)) + { + _removed.Remove(candidate.Id); + RenderProjectionRecord accepted = candidate with + { + OwnerIncarnation = retained.Record.OwnerIncarnation, + PreviousTransform = new PreviousRenderTransform( + retained.Record.Transform.LocalToWorld), + }; + if (accepted.ProjectionClass + != retained.Record.ProjectionClass) + { + _journal.Unregister( + retained.Record.Id, + retained.Record.OwnerIncarnation); + accepted = accepted with + { + OwnerIncarnation = NextIncarnation(), + }; + _journal.Register(in accepted); + current[candidate.Id] = new TrackedProjection(accepted); + continue; + } + + if (accepted != retained.Record) + { + AppendUpdates(retained.Record, accepted); + current[candidate.Id] = new TrackedProjection(accepted); + } + continue; + } + + RenderProjectionRecord registered = candidate with + { + OwnerIncarnation = NextIncarnation(), + }; + _journal.Register(in registered); + current.Add(registered.Id, new TrackedProjection(registered)); + ProjectionCount++; + } + + _removed.Sort(ProjectionIdComparer.Instance); + for (int i = 0; i < _removed.Count; i++) + { + RenderProjectionId id = _removed[i]; + TrackedProjection omitted = current[id]; + _journal.Unregister(id, omitted.Record.OwnerIncarnation); + current.Remove(id); + ProjectionCount--; + } + + if (current.Count == 0) + _byLandblock.Remove(landblockId); + } + + public void Retire(GpuLandblockRetirement retirement) + { + ArgumentNullException.ThrowIfNull(retirement); + uint landblockId = Canonicalize(retirement.LandblockId); + if (!_byLandblock.Remove( + landblockId, + out Dictionary? current)) + { + return; + } + + _removed.Clear(); + foreach (RenderProjectionId id in current.Keys) + _removed.Add(id); + _removed.Sort(ProjectionIdComparer.Instance); + for (int i = 0; i < _removed.Count; i++) + { + TrackedProjection tracked = current[_removed[i]]; + _journal.Unregister( + tracked.Record.Id, + tracked.Record.OwnerIncarnation); + } + + ProjectionCount -= current.Count; + } + + public void Clear(RenderSceneGeneration replacementGeneration) + { + _byLandblock.Clear(); + _candidates.Clear(); + _removed.Clear(); + ProjectionCount = 0; + _journal.Clear(replacementGeneration); + } + + internal bool TryGet( + RenderProjectionId id, + out RenderProjectionRecord record) + { + foreach (Dictionary projections + in _byLandblock.Values) + { + if (projections.TryGetValue(id, out TrackedProjection tracked)) + { + record = tracked.Record; + return true; + } + } + + record = default; + return false; + } + + internal static RenderProjectionId StaticEntityId( + uint landblockId, + uint localEntityId) => + CreateProjectionId( + StaticEntityDomain, + Canonicalize(landblockId), + localEntityId); + + internal static RenderProjectionId EnvCellShellId( + uint landblockId, + uint cellId) => + CreateProjectionId( + EnvCellShellDomain, + Canonicalize(landblockId), + cellId); + + private RenderProjectionRecord ProjectStaticEntity( + uint landblockId, + WorldEntity entity) + { + CurrentRenderProjectionFingerprint fingerprint = + CurrentRenderSceneOracle.CreateProjectionFingerprint( + landblockId, + entity); + RenderProjectionFlags flags = RenderProjectionFlags.Selectable; + if (entity.IsDrawVisible && entity.IsAncestorDrawVisible) + flags |= RenderProjectionFlags.Draw; + if (!entity.IsDrawVisible) + flags |= RenderProjectionFlags.Hidden; + if (!entity.IsAncestorDrawVisible) + flags |= RenderProjectionFlags.AncestorHidden; + + RenderTransform transform = RenderTransform.FromRoot( + entity.Position, + entity.Rotation, + entity.Scale); + (Vector3 minimum, Vector3 maximum) = CalculateBounds(entity); + RenderProjectionId id = StaticEntityId(landblockId, entity.Id); + uint fullCellId = entity.ParentCellId ?? landblockId; + return new RenderProjectionRecord( + id, + InteriorEntityPartition.IsIndoorCellId(entity.ParentCellId) + ? RenderProjectionClass.IndoorCellStatic + : RenderProjectionClass.OutdoorStatic, + default, + transform, + new PreviousRenderTransform(transform.LocalToWorld), + new RenderMeshSet( + RenderAssetHandle.FromRaw( + fingerprint.Geometry.Low ^ fingerprint.Geometry.High), + entity.MeshRefs.Count, + 0), + new RenderMaterialVariant( + fingerprint.Appearance.Low, + fingerprint.Appearance.High, + 1.0f), + new RenderSpatialResidency( + RenderSpatialBucket.FromRaw(fullCellId), + landblockId, + fullCellId), + new RenderWorldBounds(minimum, maximum), + flags, + default, + id.ToSortKey(), + RenderDirtyMask.All, + new RenderSourceMetadata( + entity.Id, + entity.ServerGuid, + entity.SourceGfxObjOrSetupId, + entity.ParentCellId ?? 0, + entity.EffectCellId ?? 0, + entity.BuildingShellAnchorCellId ?? 0, + fingerprint.Transform, + fingerprint.Geometry, + fingerprint.Appearance)); + } + + private static RenderProjectionRecord ProjectEnvCellShell( + uint landblockId, + EnvCellShellPlacement shell) + { + StableRenderHash128 transformHash = StableRenderHash128.Create(); + transformHash.Add(shell.WorldPosition); + transformHash.Add(shell.Rotation); + transformHash.Add(1.0f); + RenderSceneHash128 transformFingerprint = transformHash.Finish(); + + StableRenderHash128 geometryHash = StableRenderHash128.Create(); + geometryHash.Add(shell.GeometryId); + geometryHash.Add(shell.EnvironmentId); + geometryHash.Add(shell.CellStructure); + geometryHash.Add(shell.Surfaces.Length); + for (int i = 0; i < shell.Surfaces.Length; i++) + geometryHash.Add(shell.Surfaces[i]); + RenderSceneHash128 geometryFingerprint = geometryHash.Finish(); + + RenderProjectionId id = EnvCellShellId(landblockId, shell.CellId); + return new RenderProjectionRecord( + id, + RenderProjectionClass.IndoorCellStatic, + default, + new RenderTransform( + shell.WorldPosition, + shell.Rotation, + 1.0f, + shell.Transform), + new PreviousRenderTransform(shell.Transform), + new RenderMeshSet( + RenderAssetHandle.FromRaw(shell.GeometryId), + 1, + 0), + new RenderMaterialVariant(0, 0, 1.0f), + new RenderSpatialResidency( + RenderSpatialBucket.FromRaw(shell.CellId), + landblockId, + shell.CellId), + new RenderWorldBounds( + shell.WorldBounds.Min, + shell.WorldBounds.Max), + RenderProjectionFlags.Draw, + default, + id.ToSortKey(), + RenderDirtyMask.All, + new RenderSourceMetadata( + shell.CellId, + 0, + unchecked((uint)shell.GeometryId), + shell.CellId, + shell.CellId, + 0, + transformFingerprint, + geometryFingerprint, + RenderSceneHash128.Empty)); + } + + private void AppendUpdates( + in RenderProjectionRecord prior, + in RenderProjectionRecord current) + { + if (prior.Transform != current.Transform + || prior.Bounds != current.Bounds + || prior.SortKey != current.SortKey + || prior.Source.TransformFingerprint + != current.Source.TransformFingerprint) + { + _journal.Update( + RenderProjectionDeltaKind.UpdateTransform, + in current); + } + + if (prior.MeshSet != current.MeshSet + || prior.Material != current.Material + || prior.DegradeState != current.DegradeState + || prior.Source.GeometryFingerprint != current.Source.GeometryFingerprint + || prior.Source.AppearanceFingerprint != current.Source.AppearanceFingerprint) + { + _journal.Update( + RenderProjectionDeltaKind.UpdateAppearance, + in current); + } + + if (prior.Residency != current.Residency) + { + _journal.Update( + RenderProjectionDeltaKind.Rebucket, + in current); + } + + if (prior.Flags != current.Flags) + { + _journal.Update( + RenderProjectionDeltaKind.UpdateFlags, + in current); + } + } + + private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds( + WorldEntity entity) + { + Vector3 position = entity.Position; + if (entity.HasLocalBounds) + { + Vector3 localMin = entity.LocalBoundMin; + Vector3 localMax = entity.LocalBoundMax; + Vector3 minimum = default; + Vector3 maximum = default; + for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++) + { + Vector3 corner = new( + (cornerIndex & 1) == 0 ? localMin.X : localMax.X, + (cornerIndex & 2) == 0 ? localMin.Y : localMax.Y, + (cornerIndex & 4) == 0 ? localMin.Z : localMax.Z); + Vector3 transformed = Vector3.Transform(corner, entity.Rotation); + if (cornerIndex == 0) + { + minimum = transformed; + maximum = transformed; + } + else + { + minimum = Vector3.Min(minimum, transformed); + maximum = Vector3.Max(maximum, transformed); + } + } + + Vector3 margin = new(DefaultAabbRadius); + return ( + position + minimum - margin, + position + maximum + margin); + } + + float radius = DefaultAabbRadius; + for (int i = 0; i < entity.MeshRefs.Count; i++) + { + radius = MathF.Max( + radius, + DefaultAabbRadius + + entity.MeshRefs[i].PartTransform.Translation.Length()); + } + + Vector3 extent = new(radius); + return (position - extent, position + extent); + } + + private RenderOwnerIncarnation NextIncarnation() + { + if (_nextIncarnation == ulong.MaxValue) + { + throw new InvalidOperationException( + "Static render-projection incarnation exhausted."); + } + + return RenderOwnerIncarnation.FromRaw(_nextIncarnation++); + } + + private static RenderProjectionId CreateProjectionId( + byte domain, + uint canonicalLandblockId, + uint localId) => + RenderProjectionId.FromRaw( + ((ulong)domain << 56) + | ((ulong)(canonicalLandblockId >> 16) << 32) + | localId); + + private static uint Canonicalize(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; + + private readonly record struct TrackedProjection( + RenderProjectionRecord Record); + + private sealed class ProjectionRecordComparer : + IComparer + { + public static ProjectionRecordComparer Instance { get; } = new(); + + public int Compare(RenderProjectionRecord x, RenderProjectionRecord y) => + x.Id.CompareTo(y.Id); + } + + private sealed class ProjectionIdComparer : IComparer + { + public static ProjectionIdComparer Instance { get; } = new(); + + public int Compare(RenderProjectionId x, RenderProjectionId y) => + x.CompareTo(y); + } +} diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 9c569091..de1e8d89 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -1,4 +1,5 @@ using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Scene; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -73,6 +74,7 @@ public sealed class LandblockPresentationPipeline private readonly GpuWorldState _state; private readonly Action? _onLandblockLoaded; private readonly Action? _ensureEnvCellMeshes; + private readonly IRenderStaticProjectionJournalSink? _staticProjectionSink; private readonly LandblockRetirementCoordinator _retirements; private readonly Dictionary _publications = new(ReferenceEqualityComparer.Instance); @@ -84,7 +86,8 @@ public sealed class LandblockPresentationPipeline Action? ensureEnvCellMeshes = null, LandblockRetirementCoordinator? retirementCoordinator = null, Action? removeTerrain = null, - Action? demoteNearLayer = null) + Action? demoteNearLayer = null, + IRenderStaticProjectionJournalSink? staticProjectionSink = null) { ArgumentNullException.ThrowIfNull(publishBeforeSpatialCommit); ArgumentNullException.ThrowIfNull(state); @@ -93,6 +96,7 @@ public sealed class LandblockPresentationPipeline _state = state; _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; + _staticProjectionSink = staticProjectionSink; if (retirementCoordinator is not null && !retirementCoordinator.MatchesState(_state)) { @@ -120,6 +124,27 @@ public sealed class LandblockPresentationPipeline LandblockPresentationRetirementOwner retirementOwner, Action? onLandblockLoaded = null, Action? ensureEnvCellMeshes = null) + : this( + renderPublisher, + physicsPublisher, + staticPublisher, + state, + retirementOwner, + onLandblockLoaded, + ensureEnvCellMeshes, + staticProjectionSink: null) + { + } + + internal LandblockPresentationPipeline( + LandblockRenderPublisher renderPublisher, + LandblockPhysicsPublisher physicsPublisher, + LandblockStaticPresentationPublisher staticPublisher, + GpuWorldState state, + LandblockPresentationRetirementOwner retirementOwner, + Action? onLandblockLoaded, + Action? ensureEnvCellMeshes, + IRenderStaticProjectionJournalSink? staticProjectionSink) { _renderPublisher = renderPublisher ?? throw new ArgumentNullException(nameof(renderPublisher)); @@ -146,10 +171,14 @@ public sealed class LandblockPresentationPipeline } _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; + _staticProjectionSink = staticProjectionSink; _retirements = LandblockRetirementCoordinator.CreateBudgeted( state, retirementOwner.AdvanceOne, - retirementOwner.Advance); + retirementOwner.Advance, + staticProjectionSink is null + ? null + : staticProjectionSink.Retire); } /// @@ -785,6 +814,12 @@ public sealed class LandblockPresentationPipeline transaction.SpatialPublication ?? throw new InvalidOperationException( "A committed spatial publication has no activation receipt.")); + if (transaction.SpatialPublication.RequiresActivation) + { + _staticProjectionSink?.Reconcile( + transaction.Build, + transaction.SpatialPublication); + } transaction.SpatialPresentationCommitted = true; })) { diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index 36409ea5..21c61ba2 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -273,6 +273,7 @@ public sealed class LandblockRetirementCoordinator LandblockRetirementOperationResult>? _advancePresentationStep; private readonly Func _requiredPresentationStages; + private readonly Action? _onDetached; private readonly Dictionary> _pending = new(); private readonly Queue _pendingOrder = new(); private readonly List _completedIds = new(); @@ -290,6 +291,7 @@ public sealed class LandblockRetirementCoordinator _state = state; _advancePresentation = advancePresentation; _advancePresentationStep = null; + _onDetached = null; _requiredPresentationStages = requiredPresentationStages ?? (kind => kind == LandblockRetirementKind.Full ? ProductionPresentationStages @@ -302,7 +304,8 @@ public sealed class LandblockRetirementCoordinator advancePresentationStep, Action advancePresentation, Func - requiredPresentationStages) + requiredPresentationStages, + Action? onDetached) { _state = state ?? throw new ArgumentNullException(nameof(state)); _advancePresentationStep = advancePresentationStep @@ -311,6 +314,7 @@ public sealed class LandblockRetirementCoordinator ?? throw new ArgumentNullException(nameof(advancePresentation)); _requiredPresentationStages = requiredPresentationStages ?? throw new ArgumentNullException(nameof(requiredPresentationStages)); + _onDetached = onDetached; } public int PendingCount { get; private set; } @@ -335,7 +339,8 @@ public sealed class LandblockRetirementCoordinator GpuWorldState state, Func advancePresentationStep, - Action advancePresentation) + Action advancePresentation, + Action? onDetached = null) { return new LandblockRetirementCoordinator( state, @@ -343,7 +348,8 @@ public sealed class LandblockRetirementCoordinator advancePresentation, kind => kind == LandblockRetirementKind.Full ? ProductionPresentationStages - : ProductionPresentationStages & ~LandblockRetirementStage.Terrain); + : ProductionPresentationStages & ~LandblockRetirementStage.Terrain, + onDetached); } /// @@ -531,6 +537,8 @@ public sealed class LandblockRetirementCoordinator if (_advancePresentationStep is not null) _pendingOrder.Enqueue(ticket); + _onDetached?.Invoke(stateRetirement); + if (_advancePresentationStep is null) { AdvanceTicket(ticket); diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs new file mode 100644 index 00000000..179dd3eb --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs @@ -0,0 +1,280 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Scene.Arch; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Rendering; + +public sealed class StaticRenderProjectionJournalTests +{ + private const uint LandblockId = 0x1234FFFFu; + + [Fact] + public void Reconcile_RegistersOnlyStaticEntitiesAndEnvCellShells() + { + RenderSceneGeneration generation = Generation(1); + var journal = new RenderProjectionJournal(generation); + var statics = new StaticRenderProjectionJournal(journal); + WorldEntity staticEntity = Entity(7, position: new Vector3(1, 2, 3)); + WorldEntity liveEntity = Entity(8, serverGuid: 0x80000008u); + LandblockBuild build = Build( + LandblockId, + [staticEntity, liveEntity], + includeShell: true); + var publication = Publication(build); + + statics.Reconcile(build, publication); + + Assert.Equal(2, statics.ProjectionCount); + Assert.Equal(2, journal.Count); + Assert.All( + journal.Pending.ToArray(), + delta => Assert.Equal( + RenderProjectionDeltaKind.Register, + delta.Kind)); + + using var scene = new ArchRenderScene(generation); + RenderDeltaApplyResult applied = journal.DrainTo(scene); + Assert.Equal(2, applied.Registered); + Assert.Equal(0, journal.Count); + Assert.Equal(2, scene.Counts.Total); + Assert.Equal(1, scene.Counts.OutdoorStatic); + Assert.Equal(1, scene.Counts.IndoorCellStatic); + + RenderProjectionId staticId = + StaticRenderProjectionJournal.StaticEntityId( + LandblockId, + staticEntity.Id); + Assert.True(scene.OpenQuery().TryGet(staticId, out var projected)); + CurrentRenderProjectionFingerprint expected = + CurrentRenderSceneOracle.CreateProjectionFingerprint( + LandblockId, + staticEntity); + Assert.Equal(expected.Transform, projected.Source.TransformFingerprint); + Assert.Equal(expected.Geometry, projected.Source.GeometryFingerprint); + Assert.Equal(expected.Appearance, projected.Source.AppearanceFingerprint); + Assert.Equal(LandblockId, projected.Residency.OwnerLandblockId); + } + + [Fact] + public void Reconcile_SameLandblockRetainsUpdatesOmitsAndAddsDeterministically() + { + RenderSceneGeneration generation = Generation(2); + var journal = new RenderProjectionJournal(generation); + var statics = new StaticRenderProjectionJournal(journal); + WorldEntity retained = Entity(1); + WorldEntity omitted = Entity(2); + LandblockBuild first = Build( + LandblockId, + [retained, omitted], + includeShell: true); + statics.Reconcile(first, Publication(first)); + using var scene = new ArchRenderScene(generation); + journal.DrainTo(scene); + RenderProjectionId retainedId = + StaticRenderProjectionJournal.StaticEntityId(LandblockId, 1); + Assert.True(scene.OpenQuery().TryGet(retainedId, out var before)); + + WorldEntity moved = Entity(1, position: new Vector3(10, 0, 0)); + WorldEntity added = Entity(3); + LandblockBuild replacement = Build( + LandblockId, + [moved, added], + includeShell: false); + statics.Reconcile(replacement, Publication(replacement)); + + Assert.Equal(4, journal.Count); + Assert.Equal( + [ + RenderProjectionDeltaKind.UpdateTransform, + RenderProjectionDeltaKind.Register, + RenderProjectionDeltaKind.Unregister, + RenderProjectionDeltaKind.Unregister, + ], + journal.Pending.ToArray().Select(delta => delta.Kind).ToArray()); + + journal.DrainTo(scene); + Assert.Equal(2, scene.Counts.Total); + Assert.True(scene.OpenQuery().TryGet(retainedId, out var after)); + Assert.Equal(before.OwnerIncarnation, after.OwnerIncarnation); + Assert.Equal(moved.Position, after.Transform.Position); + Assert.False(scene.OpenQuery().TryGet( + StaticRenderProjectionJournal.StaticEntityId(LandblockId, 2), + out _)); + Assert.False(scene.OpenQuery().TryGet( + StaticRenderProjectionJournal.EnvCellShellId( + LandblockId, + (LandblockId & 0xFFFF0000u) | 0x0100u), + out _)); + } + + [Fact] + public void ProjectionIdentity_DistinguishesDuplicateLocalIdsAcrossLandblocks() + { + const uint otherLandblock = 0x5678FFFFu; + var journal = new RenderProjectionJournal(Generation(3)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild first = Build( + LandblockId, + [Entity(42)], + includeShell: false); + LandblockBuild second = Build( + otherLandblock, + [Entity(42)], + includeShell: false); + + statics.Reconcile(first, Publication(first)); + statics.Reconcile(second, Publication(second)); + + Assert.Equal(2, statics.ProjectionCount); + Assert.NotEqual( + StaticRenderProjectionJournal.StaticEntityId(LandblockId, 42), + StaticRenderProjectionJournal.StaticEntityId(otherLandblock, 42)); + } + + [Fact] + public void StaleFarPublication_DoesNotMutateProjectionJournal() + { + var journal = new RenderProjectionJournal(Generation(4)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild build = Build( + LandblockId, + [Entity(1)], + includeShell: true); + var stale = Publication(build) with { RequiresActivation = false }; + + statics.Reconcile(build, stale); + + Assert.Equal(0, statics.ProjectionCount); + Assert.Equal(0, journal.Count); + } + + [Fact] + public void Retire_IsExactAndIdempotentForFullAndNearLayerReceipts() + { + var journal = new RenderProjectionJournal(Generation(5)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild build = Build( + LandblockId, + [Entity(1), Entity(2)], + includeShell: true); + statics.Reconcile(build, Publication(build)); + using var scene = new ArchRenderScene(Generation(5)); + journal.DrainTo(scene); + + var retirement = new GpuLandblockRetirement( + LandblockId, + LandblockRetirementKind.NearLayer, + build.Landblock.Entities); + statics.Retire(retirement); + statics.Retire(retirement); + + Assert.Equal(3, journal.Count); + Assert.Equal(0, statics.ProjectionCount); + Assert.All( + journal.Pending.ToArray(), + delta => Assert.Equal( + RenderProjectionDeltaKind.Unregister, + delta.Kind)); + RenderDeltaApplyResult result = journal.DrainTo(scene); + Assert.Equal(3, result.Unregistered); + Assert.Equal(0, scene.Counts.Total); + } + + [Fact] + public void Clear_AdvancesExactGenerationAndDropsRetainedState() + { + var journal = new RenderProjectionJournal(Generation(6)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild build = Build( + LandblockId, + [Entity(1)], + includeShell: true); + statics.Reconcile(build, Publication(build)); + + statics.Clear(Generation(7)); + + Assert.Equal(Generation(7), journal.Generation); + Assert.Equal(0, journal.Count); + Assert.Equal(0, statics.ProjectionCount); + Assert.Equal(0, statics.LandblockCount); + Assert.Throws( + () => statics.Clear(Generation(7))); + } + + private static GpuLandblockSpatialPublication Publication( + LandblockBuild build, + bool requiresActivation = true) => + new( + build.LandblockId, + build.Landblock, + build.EnvCells?.Shells.Select(shell => shell.GeometryId).ToArray() + ?? [], + build.Landblock.Entities.Where(entity => entity.ServerGuid == 0).ToArray(), + requiresActivation); + + private static LandblockBuild Build( + uint landblockId, + IReadOnlyList entities, + bool includeShell) + { + EnvCellLandblockBuild? envCells = null; + if (includeShell) + { + uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u; + envCells = new EnvCellLandblockBuild( + landblockId, + Array.Empty(), + [ + new EnvCellShellPlacement( + cellId, + 0x2_0000_0001UL, + 0x0D000001u, + 1, + ImmutableArray.Empty, + new Vector3(4, 5, 6), + Quaternion.Identity, + Matrix4x4.CreateTranslation(4, 5, 6), + new WbBoundingBox(Vector3.Zero, Vector3.One), + new WbBoundingBox( + new Vector3(4, 5, 6), + new Vector3(5, 6, 7))), + ]); + } + + return new LandblockBuild( + new LoadedLandblock( + landblockId, + new LandBlock(), + entities, + PhysicsDatBundle.Empty), + envCells); + } + + private static WorldEntity Entity( + uint id, + uint serverGuid = 0, + Vector3 position = default) => + new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x01000000u + id, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = + [ + new MeshRef( + 0x01010000u + id, + Matrix4x4.CreateTranslation(0, 0, id)), + ], + }; + + private static RenderSceneGeneration Generation(ulong value) => + RenderSceneGeneration.FromRaw(value); +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index f5671012..8835ed7e 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; using AcDream.App.World; @@ -989,6 +990,40 @@ public sealed class LandblockPresentationPipelineTests Assert.Equal(2, meshes.PinAttempts); } + [Fact] + public void StaticRenderProjection_ReconcilesOnlyAfterSuccessfulSpatialActivation() + { + const uint landblockId = 0x6363FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var journal = new RenderProjectionJournal( + RenderSceneGeneration.FromRaw(1)); + var statics = new StaticRenderProjectionJournal(journal); + LandblockBuild build = BuildWithShell(landblockId, Entity(32)); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + var pipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + state, + staticProjectionSink: statics); + + Assert.Throws( + () => pipeline.PublishLoaded(result)); + Assert.Equal(0, journal.Count); + Assert.Equal(0, statics.ProjectionCount); + + pipeline.PublishLoaded(result); + + Assert.Equal(2, journal.Count); + Assert.Equal(2, statics.ProjectionCount); + Assert.Equal(2, meshes.PinAttempts); + pipeline.PublishLoaded(result); + Assert.Equal(2, journal.Count); + } + [Fact] public void PriorityFailure_PreservesFailingResultAndUntouchedDrainedSuffix() {