feat(rendering): journal static scene projections

Append exact static and EnvCell-shell projection deltas only at committed spatial activation and detach receipts. Same-landblock rehydrate now reconciles retained, new, and omitted presentation identities without constructing or drawing the shadow scene in production.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 21:57:33 +02:00
parent dbd8318417
commit 5d19c56d15
10 changed files with 997 additions and 10 deletions

View file

@ -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. |
| F3F5 | 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. |
| F4F5 | pending | Dynamic indices and continuous shadow comparison have not started. |
| G0G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is

View file

@ -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<RenderProjectionFlags>(entry.Entity),
_world.Get<RenderDegradeState>(entry.Entity),
_world.Get<RenderSortKey>(entry.Entity),
_world.Get<RenderDirtyMask>(entry.Entity));
_world.Get<RenderDirtyMask>(entry.Entity),
_world.Get<RenderSourceMetadata>(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);

View file

@ -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,

View file

@ -0,0 +1,88 @@
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// 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.
/// </summary>
internal sealed class RenderProjectionJournal
{
private readonly List<RenderProjectionDelta> _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<RenderProjectionDelta> Pending =>
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_deltas);
private ulong NextSequence()
{
if (_nextSequence == ulong.MaxValue)
{
throw new InvalidOperationException(
"Render projection journal sequence exhausted.");
}
return _nextSequence++;
}
}

View file

@ -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);
/// <summary>
/// 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.
/// </summary>
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
{

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
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<uint, Dictionary<RenderProjectionId, TrackedProjection>>
_byLandblock = [];
private readonly List<RenderProjectionRecord> _candidates = [];
private readonly List<RenderProjectionId> _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<RenderProjectionId, TrackedProjection>? 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<RenderProjectionId, TrackedProjection>? 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<RenderProjectionId, TrackedProjection> 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<RenderProjectionRecord>
{
public static ProjectionRecordComparer Instance { get; } = new();
public int Compare(RenderProjectionRecord x, RenderProjectionRecord y) =>
x.Id.CompareTo(y.Id);
}
private sealed class ProjectionIdComparer : IComparer<RenderProjectionId>
{
public static ProjectionIdComparer Instance { get; } = new();
public int Compare(RenderProjectionId x, RenderProjectionId y) =>
x.CompareTo(y);
}
}

View file

@ -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<uint>? _onLandblockLoaded;
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly IRenderStaticProjectionJournalSink? _staticProjectionSink;
private readonly LandblockRetirementCoordinator _retirements;
private readonly Dictionary<LandblockStreamResult, PublicationTransaction> _publications =
new(ReferenceEqualityComparer.Instance);
@ -84,7 +86,8 @@ public sealed class LandblockPresentationPipeline
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null)
Action<uint>? 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<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? 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<uint>? onLandblockLoaded,
Action<EnvCellLandblockBuild>? 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);
}
/// <summary>
@ -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;
}))
{

View file

@ -273,6 +273,7 @@ public sealed class LandblockRetirementCoordinator
LandblockRetirementOperationResult>? _advancePresentationStep;
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
_requiredPresentationStages;
private readonly Action<GpuLandblockRetirement>? _onDetached;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new();
private readonly List<uint> _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<LandblockRetirementTicket> advancePresentation,
Func<LandblockRetirementKind, LandblockRetirementStage>
requiredPresentationStages)
requiredPresentationStages,
Action<GpuLandblockRetirement>? 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<LandblockRetirementTicket, LandblockRetirementOperationResult>
advancePresentationStep,
Action<LandblockRetirementTicket> advancePresentation)
Action<LandblockRetirementTicket> advancePresentation,
Action<GpuLandblockRetirement>? 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);
}
/// <summary>
@ -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);

View file

@ -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<ArgumentOutOfRangeException>(
() => 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<WorldEntity> entities,
bool includeShell)
{
EnvCellLandblockBuild? envCells = null;
if (includeShell)
{
uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u;
envCells = new EnvCellLandblockBuild(
landblockId,
Array.Empty<LoadedCell>(),
[
new EnvCellShellPlacement(
cellId,
0x2_0000_0001UL,
0x0D000001u,
1,
ImmutableArray<ushort>.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);
}

View file

@ -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<InvalidOperationException>(
() => 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()
{