acdream/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs
Erik ef1d263337 perf(rendering): draw retained frame product
Make the incremental render scene the production entity source at the existing retail PView stages while retaining the accepted dispatcher upload and draw executor. Keep diagnostics consumer-gated, retain ordered indices across unchanged frames, refresh only dirty records, and preserve exact mesh-load, selection, alpha, lighting, and route lifecycle semantics.
2026-07-25 04:12:23 +02:00

677 lines
24 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
namespace AcDream.App.Tests.Rendering;
public sealed class ArchRenderSceneTests
{
[Fact]
public void Apply_RegisterChannelUpdatesAndUnregister_PreservesTypedRecord()
{
RenderSceneGeneration generation = Generation(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = Record(
id: 10,
incarnation: 3,
RenderProjectionClass.LiveDynamicRoot,
x: 1,
bucket: 0xAABB);
RenderDeltaApplyResult register = scene.Apply(
[RenderProjectionDelta.Register(generation, 1, original)]);
Assert.Equal(1, register.Applied);
Assert.Equal(1, register.Registered);
Assert.Equal(
new RenderProjectionCounts(1, 0, 0, 1, 0, 0),
scene.Counts);
RenderProjectionRecord transformed = original with
{
PreviousTransform = new PreviousRenderTransform(
original.Transform.LocalToWorld),
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(4, 5, 6)),
Bounds = new RenderWorldBounds(
new Vector3(3, 4, 5),
new Vector3(5, 6, 7)),
SortKey = new RenderSortKey(99),
};
RenderProjectionRecord appearance = transformed with
{
MeshSet = new RenderMeshSet(Asset(80), 4, 7),
Material = new RenderMaterialVariant(12, 13, 0.5f),
DegradeState = new RenderDegradeState(2, 8),
};
RenderProjectionRecord flags = appearance with
{
Flags = RenderProjectionFlags.Draw
| RenderProjectionFlags.Translucent,
};
RenderProjectionRecord rebucketed = flags with
{
Residency = new RenderSpatialResidency(
Bucket(0xCCDD),
0xCCDD0000,
0xCCDD0102),
};
RenderDeltaApplyResult updates = scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
transformed),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateAppearance,
generation,
3,
appearance),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
4,
flags),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.Rebucket,
generation,
5,
rebucketed),
]);
Assert.Equal(4, updates.Applied);
Assert.Equal(4, updates.Updated);
Assert.True(scene.OpenQuery().TryGet(original.Id, out var stored));
Assert.Equal(rebucketed.Transform, stored.Transform);
Assert.Equal(rebucketed.Bounds, stored.Bounds);
Assert.Equal(rebucketed.MeshSet, stored.MeshSet);
Assert.Equal(rebucketed.Material, stored.Material);
Assert.Equal(rebucketed.Flags, stored.Flags);
Assert.Equal(rebucketed.Residency, stored.Residency);
Assert.Equal(
RenderDirtyMask.All,
stored.DirtyMask);
RenderDeltaApplyResult unregister = scene.Apply(
[
RenderProjectionDelta.Unregister(
generation,
6,
original.Id,
original.OwnerIncarnation),
]);
Assert.Equal(1, unregister.Applied);
Assert.Equal(1, unregister.Unregistered);
Assert.Equal(default, scene.Counts);
Assert.False(scene.OpenQuery().TryGet(original.Id, out _));
}
[Fact]
public void Register_NewerIncarnationReplacesAndOldCallbacksCannotRemoveIt()
{
RenderSceneGeneration generation = Generation(4);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord first = Record(
44,
10,
RenderProjectionClass.OutdoorStatic);
RenderProjectionRecord replacement = Record(
44,
11,
RenderProjectionClass.ActiveAnimatedStatic,
x: 9);
scene.Apply([RenderProjectionDelta.Register(generation, 1, first)]);
RenderDeltaApplyResult replaced = scene.Apply(
[RenderProjectionDelta.Register(generation, 2, replacement)]);
RenderDeltaApplyResult staleRegister = scene.Apply(
[RenderProjectionDelta.Register(generation, 3, first)]);
RenderDeltaApplyResult staleDelete = scene.Apply(
[
RenderProjectionDelta.Unregister(
generation,
4,
first.Id,
first.OwnerIncarnation),
]);
Assert.Equal(1, replaced.Replaced);
Assert.Equal(1, replaced.Registered);
Assert.Equal(1, staleRegister.RejectedStaleIncarnation);
Assert.Equal(1, staleDelete.RejectedStaleIncarnation);
Assert.Equal(
new RenderProjectionCounts(1, 0, 0, 0, 1, 0),
scene.Counts);
Assert.True(scene.OpenQuery().TryGet(first.Id, out var current));
Assert.Equal(replacement.OwnerIncarnation, current.OwnerIncarnation);
Assert.Equal(replacement.Transform, current.Transform);
}
[Fact]
public void Apply_RejectsWrongGenerationOutOfOrderAndMissingDeltas()
{
RenderSceneGeneration generation = Generation(7);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord record = Record(
70,
1,
RenderProjectionClass.IndoorCellStatic);
RenderDeltaApplyResult result = scene.Apply(
[
RenderProjectionDelta.Register(Generation(6), 1, record),
RenderProjectionDelta.Register(generation, 2, record),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
2,
record),
RenderProjectionDelta.Unregister(
generation,
3,
Projection(999),
Incarnation(1)),
]);
Assert.Equal(1, result.Applied);
Assert.Equal(1, result.RejectedGeneration);
Assert.Equal(1, result.RejectedOutOfOrderSequence);
Assert.Equal(1, result.RejectedMissing);
Assert.Equal(1, scene.Counts.Total);
}
[Fact]
public void Clear_AdvancesGenerationReclaimsRecordsAndInvalidatesBorrowedQuery()
{
RenderSceneGeneration firstGeneration = Generation(1);
using var scene = new ArchRenderScene(firstGeneration);
RenderProjectionRecord record = Record(
1,
1,
RenderProjectionClass.EquippedChild);
scene.Apply(
[RenderProjectionDelta.Register(firstGeneration, 1, record)]);
RenderSceneQuery borrowed = scene.OpenQuery();
scene.Clear(Generation(2));
Assert.Equal(Generation(2), scene.Generation);
Assert.Equal(default, scene.Counts);
Assert.Equal(0, scene.Memory.EntityCount);
Assert.Throws<InvalidOperationException>(() => _ = borrowed.Counts);
Assert.Throws<ArgumentOutOfRangeException>(
() => scene.Clear(Generation(2)));
RenderProjectionRecord next = record with
{
OwnerIncarnation = Incarnation(2),
};
RenderDeltaApplyResult applied = scene.Apply(
[RenderProjectionDelta.Register(Generation(2), 1, next)]);
Assert.Equal(1, applied.Registered);
}
[Fact]
public void FiveProjectionClasses_UseNarrowArchetypesAndReportRetainedMemory()
{
RenderSceneGeneration generation = Generation(9);
using var scene = new ArchRenderScene(generation);
RenderProjectionClass[] classes =
[
RenderProjectionClass.OutdoorStatic,
RenderProjectionClass.IndoorCellStatic,
RenderProjectionClass.LiveDynamicRoot,
RenderProjectionClass.ActiveAnimatedStatic,
RenderProjectionClass.EquippedChild,
];
var deltas = new RenderProjectionDelta[classes.Length];
for (int i = 0; i < classes.Length; i++)
{
RenderProjectionRecord record = Record(
(ulong)i + 1,
1,
classes[i]);
deltas[i] = RenderProjectionDelta.Register(
generation,
(ulong)i + 1,
record);
}
scene.Apply(deltas);
RenderSceneMemoryAccounting memory = scene.Memory;
Assert.Equal(5, scene.Counts.Total);
Assert.Equal(5, memory.EntityCount);
Assert.Equal(5, memory.ArchetypeCount);
Assert.Equal(5, memory.AllocatedChunkCount);
Assert.True(memory.ArchEntityCapacity >= memory.EntityCount);
Assert.True(memory.EstimatedChunkPayloadBytes > 0);
Assert.True(memory.ProjectionLookupCapacity >= memory.EntityCount);
Assert.True(memory.EstimatedProjectionLookupBytes > 0);
Assert.Equal(
memory.EstimatedChunkPayloadBytes
+ memory.EstimatedProjectionLookupBytes
+ memory.EstimatedIndexBytes,
memory.TotalEstimatedBytes);
}
[Fact]
public void DynamicSynchronization_UpdatesOnlyMatchingLiveIncarnation()
{
RenderSceneGeneration generation = Generation(10);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord record = Record(
100,
5,
RenderProjectionClass.LiveDynamicRoot);
scene.Apply([RenderProjectionDelta.Register(generation, 1, record)]);
DynamicProjectionUpdate stale = new(
record.Id,
Incarnation(4),
new RenderTransform(Matrix4x4.CreateTranslation(100, 0, 0)),
record.Bounds);
DynamicProjectionUpdate current = new(
record.Id,
record.OwnerIncarnation,
new RenderTransform(Matrix4x4.CreateTranslation(8, 9, 10)),
new RenderWorldBounds(
new Vector3(7, 8, 9),
new Vector3(9, 10, 11)));
scene.SynchronizeDynamicSources(
new DynamicProjectionSyncInput(generation, [stale, current]));
Assert.True(scene.OpenQuery().TryGet(record.Id, out var stored));
Assert.Equal(record.Transform.LocalToWorld, stored.PreviousTransform.LocalToWorld);
Assert.Equal(current.Transform, stored.Transform);
Assert.Equal(current.Bounds, stored.Bounds);
}
[Fact]
public void TransformUpdateBatch_ReusesRetainedStorage()
{
const int count = 1_000;
RenderSceneGeneration generation = Generation(11);
using var scene = new ArchRenderScene(generation);
var registrations = new RenderProjectionDelta[count];
var updates = new RenderProjectionDelta[count];
for (int index = 0; index < count; index++)
{
RenderProjectionRecord record = Record(
(ulong)index + 1,
1,
RenderProjectionClass.ActiveAnimatedStatic,
x: index);
registrations[index] = RenderProjectionDelta.Register(
generation,
(ulong)index + 1,
record);
updates[index] = RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
(ulong)count + (ulong)index + 1,
record with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(index + 1, 0, 0)),
});
}
scene.Apply(registrations);
long before = GC.GetAllocatedBytesForCurrentThread();
scene.Apply(updates);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(allocated == 0, $"Allocated {allocated:N0} bytes.");
}
[Fact]
public void Digest_IsStableAcrossRegistrationOrderAndBufferReuse()
{
RenderSceneGeneration generation = Generation(12);
using var first = new ArchRenderScene(generation);
using var second = new ArchRenderScene(generation);
RenderProjectionRecord a = Record(
1,
1,
RenderProjectionClass.OutdoorStatic);
RenderProjectionRecord b = Record(
2,
1,
RenderProjectionClass.LiveDynamicRoot);
first.Apply(
[
RenderProjectionDelta.Register(generation, 1, a),
RenderProjectionDelta.Register(generation, 2, b),
]);
second.Apply(
[
RenderProjectionDelta.Register(generation, 1, b),
RenderProjectionDelta.Register(generation, 2, a),
]);
var buffer = new RenderSceneDigestBuffer();
RenderSceneDigest firstDigest = first.BuildDigest(buffer);
int retainedCapacity = buffer.Capacity;
RenderSceneDigest repeatedDigest = first.BuildDigest(buffer);
RenderSceneDigest secondDigest =
second.BuildDigest(new RenderSceneDigestBuffer());
Assert.Equal(firstDigest, repeatedDigest);
Assert.Equal(firstDigest, secondDigest);
Assert.Equal(retainedCapacity, buffer.Capacity);
}
[Fact]
public void Mutation_FromAnotherThread_IsRejected()
{
RenderSceneGeneration generation = Generation(15);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord record = Record(
15,
1,
RenderProjectionClass.LiveDynamicRoot);
Exception? captured = null;
var mutationThread = new Thread(() =>
{
try
{
scene.Apply(
[RenderProjectionDelta.Register(generation, 1, record)]);
}
catch (Exception error)
{
captured = error;
}
});
mutationThread.Start();
mutationThread.Join();
InvalidOperationException error =
Assert.IsType<InvalidOperationException>(captured);
Assert.Contains("owning update thread", error.Message);
Assert.Equal(0, scene.Counts.Total);
}
[Fact]
public void IncrementalIndices_PreserveOutdoorCellDynamicAndFeatureMembership()
{
const uint indoorCell = 0x12340100u;
RenderSceneGeneration generation = Generation(16);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord outdoorStatic = Record(
1,
1,
RenderProjectionClass.OutdoorStatic);
RenderProjectionRecord indoorStatic = Record(
2,
1,
RenderProjectionClass.IndoorCellStatic) with
{
Residency = new RenderSpatialResidency(
Bucket(indoorCell),
0x1234FFFFu,
indoorCell),
Source = new RenderSourceMetadata() with
{
ParentCellId = indoorCell,
},
};
RenderProjectionRecord animatedStatic = Record(
3,
1,
RenderProjectionClass.ActiveAnimatedStatic);
RenderProjectionRecord outdoorDynamic = Record(
4,
1,
RenderProjectionClass.LiveDynamicRoot);
RenderProjectionRecord cellDynamic = Record(
5,
1,
RenderProjectionClass.LiveDynamicRoot) with
{
Residency = new RenderSpatialResidency(
Bucket(indoorCell),
0x1234FFFFu,
indoorCell),
Source = new RenderSourceMetadata() with
{
ParentCellId = indoorCell,
},
};
RenderProjectionRecord equipped = Record(
6,
1,
RenderProjectionClass.EquippedChild) with
{
Flags = RenderProjectionFlags.Draw
| RenderProjectionFlags.Selectable
| RenderProjectionFlags.Translucent
| RenderProjectionFlags.LightCandidate
| RenderProjectionFlags.PortalStraddling,
};
RenderProjectionRecord[] records =
[
outdoorStatic,
indoorStatic,
animatedStatic,
outdoorDynamic,
cellDynamic,
equipped,
];
var deltas = new RenderProjectionDelta[records.Length];
for (int i = 0; i < records.Length; i++)
{
deltas[i] = RenderProjectionDelta.Register(
generation,
(ulong)i + 1,
records[i]);
}
scene.Apply(deltas);
RenderSceneQuery query = scene.OpenQuery();
Assert.Equal(
new RenderSceneIndexCounts(
OutdoorStatic: 2,
IndoorCellStatic: 1,
Dynamic: 3,
OutdoorDynamic: 2,
PortalStraddlingDynamic: 1,
Translucent: 1,
Selectable: 6,
LightCandidate: 1,
Dirty: 6),
query.IndexCounts);
Assert.Equal(1, query.GetCellStaticCount(indoorCell));
Assert.Equal(1, query.GetCellDynamicCount(indoorCell));
var cellRecords = new RenderProjectionRecord[1];
Assert.Equal(1, query.CopyCellStaticsTo(indoorCell, cellRecords));
Assert.Equal(indoorStatic.Id, cellRecords[0].Id);
Assert.Equal(1, query.CopyCellDynamicsTo(indoorCell, cellRecords));
Assert.Equal(cellDynamic.Id, cellRecords[0].Id);
Assert.True(scene.Memory.EstimatedIndexBytes > 0);
}
[Fact]
public void IncrementalIndices_UpdateRebucketReplaceRemoveAndDirtyAcknowledge()
{
const uint indoorCell = 0x22220100u;
RenderSceneGeneration generation = Generation(17);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = Record(
17,
1,
RenderProjectionClass.LiveDynamicRoot);
scene.Apply(
[RenderProjectionDelta.Register(generation, 1, original)]);
scene.ClearDirty();
Assert.Equal(0, scene.OpenQuery().IndexCounts.Dirty);
RenderProjectionRecord flagged = original with
{
Flags = original.Flags
| RenderProjectionFlags.Translucent
| RenderProjectionFlags.LightCandidate
| RenderProjectionFlags.PortalStraddling,
Source = original.Source with { ParentCellId = indoorCell },
};
RenderProjectionRecord rebucketed = flagged with
{
Residency = new RenderSpatialResidency(
Bucket(indoorCell),
0x2222FFFFu,
indoorCell),
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
2,
flagged),
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.Rebucket,
generation,
3,
rebucketed),
]);
RenderSceneQuery updated = scene.OpenQuery();
Assert.Equal(0, updated.IndexCounts.OutdoorDynamic);
Assert.Equal(1, updated.IndexCounts.PortalStraddlingDynamic);
Assert.Equal(1, updated.IndexCounts.Translucent);
Assert.Equal(1, updated.IndexCounts.LightCandidate);
Assert.Equal(1, updated.IndexCounts.Dirty);
Assert.Equal(1, updated.GetCellDynamicCount(indoorCell));
RenderProjectionRecord replacement = rebucketed with
{
ProjectionClass = RenderProjectionClass.IndoorCellStatic,
OwnerIncarnation = Incarnation(2),
};
scene.Apply(
[RenderProjectionDelta.Register(generation, 4, replacement)]);
RenderSceneQuery replaced = scene.OpenQuery();
Assert.Equal(0, replaced.IndexCounts.Dynamic);
Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic);
Assert.Equal(1, replaced.GetCellStaticCount(indoorCell));
scene.Apply(
[
RenderProjectionDelta.Unregister(
generation,
5,
replacement.Id,
replacement.OwnerIncarnation),
]);
Assert.Equal(default, scene.OpenQuery().IndexCounts);
Assert.True(scene.Memory.EstimatedIndexBytes > 0);
}
[Fact]
public void IndexRevision_AdvancesOnlyWhenOrderedMembershipCanChange()
{
RenderSceneGeneration generation = Generation(18);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = Record(
18,
1,
RenderProjectionClass.OutdoorStatic);
scene.Apply(
[RenderProjectionDelta.Register(generation, 1, original)]);
scene.ClearDirty();
ulong registeredRevision = scene.OpenQuery().IndexRevision;
Matrix4x4 movedTransform =
Matrix4x4.CreateTranslation(4, 5, 6);
RenderProjectionRecord moved = original with
{
Transform = new RenderTransform(movedTransform),
PreviousTransform =
new PreviousRenderTransform(
original.Transform.LocalToWorld),
Bounds = new RenderWorldBounds(
new Vector3(3, 4, 5),
new Vector3(5, 6, 7)),
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
2,
moved),
]);
RenderSceneQuery movedQuery = scene.OpenQuery();
Assert.Equal(registeredRevision, movedQuery.IndexRevision);
Assert.Equal(1, movedQuery.IndexCounts.Dirty);
RenderProjectionRecord reordered = moved with
{
SortKey = new RenderSortKey(moved.SortKey.Value + 1),
};
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
3,
reordered),
]);
Assert.True(
scene.OpenQuery().IndexRevision > registeredRevision);
}
private static RenderProjectionRecord Record(
ulong id,
ulong incarnation,
RenderProjectionClass projectionClass,
float x = 0,
ulong bucket = 1)
{
Matrix4x4 transform = Matrix4x4.CreateTranslation(x, 2, 3);
return new RenderProjectionRecord(
Projection(id),
projectionClass,
Incarnation(incarnation),
new RenderTransform(transform),
new PreviousRenderTransform(transform),
new RenderMeshSet(Asset(id + 100), 2, 1),
new RenderMaterialVariant(id + 200, id + 300, 1),
new RenderSpatialResidency(
Bucket(bucket),
(uint)(bucket << 16),
(uint)((bucket << 16) | 0x101)),
new RenderWorldBounds(
new Vector3(x - 1, 1, 2),
new Vector3(x + 1, 3, 4)),
RenderProjectionFlags.Draw | RenderProjectionFlags.Selectable,
new RenderDegradeState(0, 1),
new RenderSortKey(id + 400),
RenderDirtyMask.All);
}
private static RenderProjectionId Projection(ulong value) =>
RenderProjectionId.FromRaw(value);
private static RenderOwnerIncarnation Incarnation(ulong value) =>
RenderOwnerIncarnation.FromRaw(value);
private static RenderSceneGeneration Generation(ulong value) =>
RenderSceneGeneration.FromRaw(value);
private static RenderSpatialBucket Bucket(ulong value) =>
RenderSpatialBucket.FromRaw(value);
private static RenderAssetHandle Asset(ulong value) =>
RenderAssetHandle.FromRaw(value);
}