feat(rendering): contain incremental render scene

Pin Arch behind acdream-owned identities, deltas, queries, generation gates, and memory diagnostics. The new five-archetype store is unbound and non-drawing so static publication can be shadowed without changing production behavior.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 21:42:42 +02:00
parent f9b68f8f2a
commit dbd8318417
7 changed files with 1698 additions and 2 deletions

View file

@ -0,0 +1,634 @@
using System.Runtime.CompilerServices;
using Arch.Core;
using ArchWorld = Arch.Core.World;
namespace AcDream.App.Rendering.Scene.Arch;
/// <summary>
/// Contained Arch-backed implementation of acdream's derived render
/// projection. Arch identities never leave this namespace; callers only see
/// generation-checked acdream value types.
///
/// Slice F1 deliberately leaves this owner unbound and non-drawing.
/// </summary>
internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
{
private const int InitialEntityCapacity = 256;
private const int InitialArchetypeCapacity = 8;
private readonly int _ownerThreadId;
private readonly Dictionary<RenderProjectionId, SceneEntry> _entries = [];
private ArchWorld _world;
private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence;
private bool _disposed;
public ArchRenderScene(RenderSceneGeneration initialGeneration)
{
Generation = initialGeneration;
_ownerThreadId = Environment.CurrentManagedThreadId;
_world = CreateWorld();
}
public RenderSceneGeneration Generation { get; private set; }
public RenderProjectionCounts Counts
{
get
{
EnsureAvailable();
return _counts;
}
}
public RenderSceneMemoryAccounting Memory
{
get
{
EnsureAvailable();
int allocatedChunkCount = 0;
long estimatedChunkPayloadBytes = 0;
foreach (Archetype archetype in _world.Archetypes.AsSpan())
{
allocatedChunkCount += archetype.ChunkCount;
estimatedChunkPayloadBytes +=
(long)archetype.ChunkSize * archetype.ChunkCount;
}
int lookupCapacity = _entries.EnsureCapacity(0);
long lookupBytes =
(long)lookupCapacity * Unsafe.SizeOf<ProjectionLookupSlotEstimate>();
return new RenderSceneMemoryAccounting(
EntityCount: _world.Size,
ArchEntityCapacity: _world.Capacity,
ArchetypeCount: _world.Archetypes.Count,
AllocatedChunkCount: allocatedChunkCount,
EstimatedChunkPayloadBytes: estimatedChunkPayloadBytes,
ProjectionLookupCapacity: lookupCapacity,
EstimatedProjectionLookupBytes: lookupBytes,
EstimatedIndexBytes: 0,
EstimatedJournalBufferBytes: 0,
EstimatedSynchronizationSourceBytes: 0);
}
}
public RenderDeltaApplyResult Apply(
ReadOnlySpan<RenderProjectionDelta> deltas)
{
EnsureMutationThread();
var result = new ApplyResultBuilder();
foreach (ref readonly RenderProjectionDelta delta in deltas)
{
if (delta.Generation != Generation)
{
result.RejectedGeneration++;
continue;
}
if (delta.JournalSequence == 0
|| delta.JournalSequence <= _lastAppliedJournalSequence)
{
result.RejectedOutOfOrderSequence++;
continue;
}
_lastAppliedJournalSequence = delta.JournalSequence;
RenderProjectionRecord record = delta.Record;
switch (delta.Kind)
{
case RenderProjectionDeltaKind.Register:
ApplyRegister(in record, ref result);
break;
case RenderProjectionDeltaKind.UpdateTransform:
case RenderProjectionDeltaKind.UpdateAppearance:
case RenderProjectionDeltaKind.UpdateFlags:
case RenderProjectionDeltaKind.Rebucket:
ApplyUpdate(delta.Kind, in record, ref result);
break;
case RenderProjectionDeltaKind.Unregister:
ApplyUnregister(in record, ref result);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(delta.Kind),
delta.Kind,
null);
}
}
return result.Build();
}
public void SynchronizeDynamicSources(
in DynamicProjectionSyncInput input)
{
EnsureMutationThread();
if (input.Generation != Generation)
{
throw new InvalidOperationException(
$"Dynamic synchronization belongs to {input.Generation}, "
+ $"but the scene is {Generation}.");
}
foreach (ref readonly DynamicProjectionUpdate update in input.Updates)
{
if (!_entries.TryGetValue(update.Id, out SceneEntry entry)
|| entry.OwnerIncarnation != update.OwnerIncarnation)
{
continue;
}
ref RenderTransform current =
ref _world.Get<RenderTransform>(entry.Entity);
ref RenderWorldBounds bounds =
ref _world.Get<RenderWorldBounds>(entry.Entity);
if (current == update.Transform && bounds == update.Bounds)
continue;
_world.Set(
entry.Entity,
new PreviousRenderTransform(current.LocalToWorld));
_world.Set(entry.Entity, update.Transform);
_world.Set(entry.Entity, update.Bounds);
ref RenderDirtyMask dirty =
ref _world.Get<RenderDirtyMask>(entry.Entity);
dirty |= RenderDirtyMask.Transform | RenderDirtyMask.WorldBounds;
}
}
public RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse)
{
ArgumentNullException.ThrowIfNull(reuse);
EnsureAvailable();
List<RenderProjectionRecord> records = reuse.Records;
records.Clear();
if (records.Capacity < _entries.Count)
records.Capacity = _entries.Count;
foreach (SceneEntry entry in _entries.Values)
records.Add(ReadRecord(in entry));
records.Sort(RenderProjectionRecordComparer.Instance);
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(Generation.RawValue);
hash.Add(records.Count);
foreach (RenderProjectionRecord record in records)
AddRecord(ref hash, in record);
return new RenderSceneDigest(Generation, _counts, hash.Finish());
}
public RenderSceneQuery OpenQuery()
{
EnsureAvailable();
return new RenderSceneQuery(this, Generation);
}
public void Clear(RenderSceneGeneration replacementGeneration)
{
EnsureMutationThread();
if (replacementGeneration.CompareTo(Generation) <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(replacementGeneration),
replacementGeneration,
"A replacement render-scene generation must advance.");
}
ArchWorld.Destroy(_world);
_world = CreateWorld();
_entries.Clear();
_counts = default;
_lastAppliedJournalSequence = 0;
Generation = replacementGeneration;
}
public void Dispose()
{
if (_disposed)
return;
EnsureMutationThread();
ArchWorld.Destroy(_world);
_entries.Clear();
_counts = default;
_disposed = true;
}
RenderProjectionCounts IRenderSceneQuerySource.GetCounts(
RenderSceneGeneration generation)
{
EnsureQueryGeneration(generation);
return _counts;
}
bool IRenderSceneQuerySource.TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
out RenderProjectionRecord record)
{
EnsureQueryGeneration(generation);
if (_entries.TryGetValue(id, out SceneEntry entry))
{
record = ReadRecord(in entry);
return true;
}
record = default;
return false;
}
int IRenderSceneQuerySource.CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
Span<RenderProjectionRecord> destination)
{
EnsureQueryGeneration(generation);
int required = projectionClass.HasValue
? _counts.For(projectionClass.Value)
: _counts.Total;
if (destination.Length < required)
{
throw new ArgumentException(
$"Destination holds {destination.Length} records; {required} required.",
nameof(destination));
}
int count = 0;
foreach (SceneEntry entry in _entries.Values)
{
if (projectionClass.HasValue
&& entry.ProjectionClass != projectionClass.Value)
{
continue;
}
destination[count++] = ReadRecord(in entry);
}
return count;
}
private static ArchWorld CreateWorld() =>
ArchWorld.Create(
archetypeCapacity: InitialArchetypeCapacity,
entityCapacity: InitialEntityCapacity);
private void ApplyRegister(
in RenderProjectionRecord record,
ref ApplyResultBuilder result)
{
if (_entries.TryGetValue(record.Id, out SceneEntry existing))
{
int incarnationOrder =
record.OwnerIncarnation.CompareTo(existing.OwnerIncarnation);
if (incarnationOrder < 0)
{
result.RejectedStaleIncarnation++;
return;
}
if (incarnationOrder == 0
&& record.ProjectionClass == existing.ProjectionClass)
{
WriteRecord(existing.Entity, in record);
result.Applied++;
result.Updated++;
return;
}
Destroy(in existing);
result.Replaced++;
}
Entity entity = CreateEntity(in record);
_entries[record.Id] = new SceneEntry(
entity,
record.OwnerIncarnation,
record.ProjectionClass);
IncrementCount(record.ProjectionClass);
result.Applied++;
result.Registered++;
}
private void ApplyUpdate(
RenderProjectionDeltaKind kind,
in RenderProjectionRecord record,
ref ApplyResultBuilder result)
{
if (!TryGetCurrent(in record, ref result, out SceneEntry entry))
return;
switch (kind)
{
case RenderProjectionDeltaKind.UpdateTransform:
_world.Set(entry.Entity, record.PreviousTransform);
_world.Set(entry.Entity, record.Transform);
_world.Set(entry.Entity, record.Bounds);
_world.Set(entry.Entity, record.SortKey);
OrDirty(
entry.Entity,
RenderDirtyMask.Transform
| RenderDirtyMask.WorldBounds
| RenderDirtyMask.SortKey);
break;
case RenderProjectionDeltaKind.UpdateAppearance:
_world.Set(entry.Entity, record.MeshSet);
_world.Set(entry.Entity, record.Material);
_world.Set(entry.Entity, record.DegradeState);
OrDirty(entry.Entity, RenderDirtyMask.Appearance);
break;
case RenderProjectionDeltaKind.UpdateFlags:
_world.Set(entry.Entity, record.Flags);
OrDirty(entry.Entity, RenderDirtyMask.Flags);
break;
case RenderProjectionDeltaKind.Rebucket:
_world.Set(entry.Entity, record.Residency);
OrDirty(entry.Entity, RenderDirtyMask.SpatialResidency);
break;
default:
throw new ArgumentOutOfRangeException(nameof(kind), kind, null);
}
result.Applied++;
result.Updated++;
}
private void ApplyUnregister(
in RenderProjectionRecord record,
ref ApplyResultBuilder result)
{
if (!TryGetCurrent(in record, ref result, out SceneEntry entry))
return;
Destroy(in entry);
_entries.Remove(record.Id);
result.Applied++;
result.Unregistered++;
}
private bool TryGetCurrent(
in RenderProjectionRecord record,
ref ApplyResultBuilder result,
out SceneEntry entry)
{
if (!_entries.TryGetValue(record.Id, out entry))
{
result.RejectedMissing++;
return false;
}
if (record.OwnerIncarnation != entry.OwnerIncarnation)
{
result.RejectedStaleIncarnation++;
return false;
}
return true;
}
private Entity CreateEntity(in RenderProjectionRecord record) =>
record.ProjectionClass switch
{
RenderProjectionClass.OutdoorStatic =>
CreateEntity(in record, new OutdoorStaticTag()),
RenderProjectionClass.IndoorCellStatic =>
CreateEntity(in record, new IndoorCellStaticTag()),
RenderProjectionClass.LiveDynamicRoot =>
CreateEntity(in record, new LiveDynamicRootTag()),
RenderProjectionClass.ActiveAnimatedStatic =>
CreateEntity(in record, new ActiveAnimatedStaticTag()),
RenderProjectionClass.EquippedChild =>
CreateEntity(in record, new EquippedChildTag()),
_ => throw new ArgumentOutOfRangeException(
nameof(record.ProjectionClass),
record.ProjectionClass,
null),
};
private Entity CreateEntity<TTag>(
in RenderProjectionRecord record,
TTag tag)
where TTag : struct =>
_world.Create(
new ProjectionIdentity(record.Id),
record.Transform,
record.PreviousTransform,
record.MeshSet,
record.Material,
record.Residency,
record.Bounds,
record.Flags,
record.DegradeState,
record.SortKey,
record.OwnerIncarnation,
record.DirtyMask,
tag);
private void WriteRecord(
Entity entity,
in RenderProjectionRecord record)
{
_world.Set(entity, record.Transform);
_world.Set(entity, record.PreviousTransform);
_world.Set(entity, record.MeshSet);
_world.Set(entity, record.Material);
_world.Set(entity, record.Residency);
_world.Set(entity, record.Bounds);
_world.Set(entity, record.Flags);
_world.Set(entity, record.DegradeState);
_world.Set(entity, record.SortKey);
_world.Set(entity, record.OwnerIncarnation);
_world.Set(entity, record.DirtyMask);
}
private RenderProjectionRecord ReadRecord(in SceneEntry entry) =>
new(
_world.Get<ProjectionIdentity>(entry.Entity).Id,
entry.ProjectionClass,
_world.Get<RenderOwnerIncarnation>(entry.Entity),
_world.Get<RenderTransform>(entry.Entity),
_world.Get<PreviousRenderTransform>(entry.Entity),
_world.Get<RenderMeshSet>(entry.Entity),
_world.Get<RenderMaterialVariant>(entry.Entity),
_world.Get<RenderSpatialResidency>(entry.Entity),
_world.Get<RenderWorldBounds>(entry.Entity),
_world.Get<RenderProjectionFlags>(entry.Entity),
_world.Get<RenderDegradeState>(entry.Entity),
_world.Get<RenderSortKey>(entry.Entity),
_world.Get<RenderDirtyMask>(entry.Entity));
private void Destroy(in SceneEntry entry)
{
_world.Destroy(entry.Entity);
DecrementCount(entry.ProjectionClass);
}
private void OrDirty(Entity entity, RenderDirtyMask value)
{
ref RenderDirtyMask dirty = ref _world.Get<RenderDirtyMask>(entity);
dirty |= value;
}
private void IncrementCount(RenderProjectionClass projectionClass)
{
_counts = WithClassCount(
_counts,
projectionClass,
_counts.For(projectionClass) + 1,
_counts.Total + 1);
}
private void DecrementCount(RenderProjectionClass projectionClass)
{
_counts = WithClassCount(
_counts,
projectionClass,
_counts.For(projectionClass) - 1,
_counts.Total - 1);
}
private static RenderProjectionCounts WithClassCount(
RenderProjectionCounts counts,
RenderProjectionClass projectionClass,
int classCount,
int total) =>
projectionClass switch
{
RenderProjectionClass.OutdoorStatic =>
counts with { Total = total, OutdoorStatic = classCount },
RenderProjectionClass.IndoorCellStatic =>
counts with { Total = total, IndoorCellStatic = classCount },
RenderProjectionClass.LiveDynamicRoot =>
counts with { Total = total, LiveDynamicRoot = classCount },
RenderProjectionClass.ActiveAnimatedStatic =>
counts with { Total = total, ActiveAnimatedStatic = classCount },
RenderProjectionClass.EquippedChild =>
counts with { Total = total, EquippedChild = classCount },
_ => throw new ArgumentOutOfRangeException(
nameof(projectionClass),
projectionClass,
null),
};
private void EnsureMutationThread()
{
EnsureAvailable();
if (Environment.CurrentManagedThreadId != _ownerThreadId)
{
throw new InvalidOperationException(
"Render-scene mutation must remain on its owning update thread.");
}
}
private void EnsureQueryGeneration(RenderSceneGeneration generation)
{
EnsureAvailable();
if (generation != Generation)
{
throw new InvalidOperationException(
$"Borrowed render-scene query belongs to {generation}; "
+ $"the current scene is {Generation}.");
}
}
private void EnsureAvailable() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private static void AddRecord(
ref StableRenderHash128 hash,
in RenderProjectionRecord record)
{
hash.Add(record.Id.RawValue);
hash.Add((byte)record.ProjectionClass);
hash.Add(record.OwnerIncarnation.RawValue);
hash.Add(record.Transform.LocalToWorld);
hash.Add(record.PreviousTransform.LocalToWorld);
hash.Add(record.MeshSet.Handle.RawValue);
hash.Add(record.MeshSet.MeshCount);
hash.Add(record.MeshSet.Revision);
hash.Add(record.Material.PaletteKey);
hash.Add(record.Material.TextureReplacementKey);
hash.Add(record.Material.Opacity);
hash.Add(record.Residency.Bucket.RawValue);
hash.Add(record.Residency.OwnerLandblockId);
hash.Add(record.Residency.FullCellId);
hash.Add(record.Bounds.Minimum);
hash.Add(record.Bounds.Maximum);
hash.Add((uint)record.Flags);
hash.Add(record.DegradeState.Level);
hash.Add(record.DegradeState.Revision);
hash.Add(record.SortKey.Value);
}
private readonly record struct ProjectionIdentity(RenderProjectionId Id);
private readonly record struct OutdoorStaticTag;
private readonly record struct IndoorCellStaticTag;
private readonly record struct LiveDynamicRootTag;
private readonly record struct ActiveAnimatedStaticTag;
private readonly record struct EquippedChildTag;
private readonly record struct SceneEntry(
Entity Entity,
RenderOwnerIncarnation OwnerIncarnation,
RenderProjectionClass ProjectionClass);
private readonly record struct ProjectionLookupSlotEstimate(
int HashCode,
int Next,
RenderProjectionId Key,
SceneEntry Entry);
private struct ApplyResultBuilder
{
public int Applied;
public int Registered;
public int Updated;
public int Replaced;
public int Unregistered;
public int RejectedGeneration;
public int RejectedOutOfOrderSequence;
public int RejectedStaleIncarnation;
public int RejectedMissing;
public readonly RenderDeltaApplyResult Build() =>
new(
Applied,
Registered,
Updated,
Replaced,
Unregistered,
RejectedGeneration,
RejectedOutOfOrderSequence,
RejectedStaleIncarnation,
RejectedMissing);
}
private sealed class RenderProjectionRecordComparer :
IComparer<RenderProjectionRecord>
{
public static RenderProjectionRecordComparer Instance { get; } = new();
public int Compare(
RenderProjectionRecord x,
RenderProjectionRecord y)
{
int value = x.Id.CompareTo(y.Id);
return value != 0
? value
: x.ProjectionClass.CompareTo(y.ProjectionClass);
}
}
}