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:
parent
f9b68f8f2a
commit
dbd8318417
7 changed files with 1698 additions and 2 deletions
|
|
@ -18,8 +18,9 @@ retail-faithful gameplay
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| F0a — partition-input referee | complete | Diagnostic-only `CurrentRenderSceneOracle`; no production draw decision changed. |
|
| 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. |
|
| 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 | active | No render-scene storage or shadow projection exists yet. |
|
| 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–F5 | pending | Delta publication and continuous shadow comparison have not started. |
|
| 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. |
|
||||||
| G0–G5 | pending | No production consumer has switched. |
|
| G0–G5 | pending | No production consumer has switched. |
|
||||||
|
|
||||||
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
|
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
|
||||||
|
|
@ -314,6 +315,17 @@ performance regression with comparison disabled.
|
||||||
|
|
||||||
**Commit gate:** the scene is unbound and non-drawing; full suite green.
|
**Commit gate:** the scene is unbound and non-drawing; full suite green.
|
||||||
|
|
||||||
|
**Landed evidence:** the package/API/license decision is recorded in
|
||||||
|
`docs/research/2026-07-24-arch-render-scene-dependency.md`. Production
|
||||||
|
composition does not construct `ArchRenderScene`; F1 therefore changes no
|
||||||
|
runtime path. Source/reflection architecture tests confine all Arch types and
|
||||||
|
primitive-ID extraction to `AcDream.App/Rendering/Scene/Arch`. Pure App tests
|
||||||
|
cover every structural/channel operation, generation replacement, stale
|
||||||
|
incarnation and sequence rejection, borrowed-query invalidation, deterministic
|
||||||
|
digest order, retained-memory accounting, and cross-thread mutation rejection.
|
||||||
|
The Release gate is 3,701 App tests / 3 skips and 8,185 complete-solution tests
|
||||||
|
/ 5 skips.
|
||||||
|
|
||||||
### F2 — Static projection journal
|
### F2 — Static projection journal
|
||||||
|
|
||||||
1. Append Register deltas from the existing final landblock spatial publication
|
1. Append Register deltas from the existing final landblock spatial publication
|
||||||
|
|
|
||||||
77
docs/research/2026-07-24-arch-render-scene-dependency.md
Normal file
77
docs/research/2026-07-24-arch-render-scene-dependency.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Arch render-scene dependency decision
|
||||||
|
|
||||||
|
**Date:** 2026-07-24
|
||||||
|
|
||||||
|
**Scope:** Modern Runtime Slice F1 only
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Pin `Arch` version `2.1.0` directly in `AcDream.App`. Do not reference it from
|
||||||
|
Core, UI abstractions, plugins, or any other production project. Do not use
|
||||||
|
Arch systems, command buffers, parallel queries, jobs, events, source
|
||||||
|
generators, persistence, relationships, or extension packages.
|
||||||
|
|
||||||
|
The only allowed Arch namespace is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/AcDream.App/Rendering/Scene/Arch/
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything outside that folder uses acdream-owned `IRenderScene`, wrapper
|
||||||
|
identities, packed component values, deltas, borrowed queries, digests, and
|
||||||
|
memory-accounting values. Arch entities and primitive wrapper values cannot
|
||||||
|
cross that boundary.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Official package: [NuGet `Arch 2.1.0`](https://www.nuget.org/packages/Arch/2.1.0)
|
||||||
|
- Official source/tag:
|
||||||
|
[genaray/Arch `v2.1.0`](https://github.com/genaray/Arch/tree/v2.1.0)
|
||||||
|
- License:
|
||||||
|
[Apache License 2.0](https://github.com/genaray/Arch/blob/v2.1.0/LICENSE.MD)
|
||||||
|
- Selected source tag commit:
|
||||||
|
`04d52e7268eb6f376ca4841a0c204334120c5e9d`
|
||||||
|
|
||||||
|
The selected package supplies .NET 6 and .NET Standard 2.1 assets, both
|
||||||
|
compatible with acdream's .NET 10 App target. The v2.1.0 source was inspected
|
||||||
|
for the exact API used by the adapter:
|
||||||
|
|
||||||
|
- `World.Create`
|
||||||
|
- generic `World.Create<T...>`
|
||||||
|
- `World.Set<T>` / `World.Get<T>`
|
||||||
|
- `World.Destroy(Entity)` / `World.Destroy(World)`
|
||||||
|
- `World.Size`, `World.Capacity`, and `World.Archetypes`
|
||||||
|
- archetype chunk count and chunk-size accounting
|
||||||
|
|
||||||
|
The resolved dependency graph was captured with
|
||||||
|
`dotnet list package --include-transitive`. It resolves:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Arch 2.1.0
|
||||||
|
Arch.LowLevel 1.1.5
|
||||||
|
Collections.Pooled 2.0.0-preview.27
|
||||||
|
CommunityToolkit.HighPerformance 8.4.0
|
||||||
|
Microsoft.Extensions.ObjectPool 7.0.0
|
||||||
|
ZeroAllocJobScheduler 1.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
No package in the resolved App graph was reported vulnerable by NuGet's
|
||||||
|
`--vulnerable --include-transitive` audit on 2026-07-24. The transitive job
|
||||||
|
scheduler is not initialized or called by acdream; Slice F uses only
|
||||||
|
single-threaded structural operations and direct component access on the
|
||||||
|
existing update thread.
|
||||||
|
|
||||||
|
## Containment proof
|
||||||
|
|
||||||
|
`RenderSceneArchitectureTests` enforces all of the following:
|
||||||
|
|
||||||
|
1. `Arch` is a direct package reference in `AcDream.App` only.
|
||||||
|
2. Arch namespaces occur only in the contained adapter folder.
|
||||||
|
3. Reflection finds no Arch field, property, parameter, or return type outside
|
||||||
|
that namespace.
|
||||||
|
4. Raw wrapper identity extraction occurs only in the adapter (apart from the
|
||||||
|
private contract declarations themselves).
|
||||||
|
|
||||||
|
This is storage selection, not simulation architecture. `LiveEntityRuntime`,
|
||||||
|
`GpuWorldState`, gameplay identities, retail PView, GL ownership, and all
|
||||||
|
behavioral owners remain authoritative and unchanged.
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
ObjectMeshManager.cs (extracted in T2). -->
|
ObjectMeshManager.cs (extracted in T2). -->
|
||||||
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
|
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
<!-- Slice F: contained render-projection storage. Arch types are confined to
|
||||||
|
Rendering/Scene/Arch and never cross an acdream-owned interface. -->
|
||||||
|
<PackageReference Include="Arch" Version="2.1.0" />
|
||||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
|
||||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
|
|
|
||||||
634
src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs
Normal file
634
src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
Normal file
381
src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Scene;
|
||||||
|
|
||||||
|
internal readonly record struct RenderProjectionId
|
||||||
|
{
|
||||||
|
private readonly ulong _value;
|
||||||
|
|
||||||
|
private RenderProjectionId(ulong value) => _value = value;
|
||||||
|
|
||||||
|
internal static RenderProjectionId FromRaw(ulong value) => new(value);
|
||||||
|
|
||||||
|
internal ulong RawValue => _value;
|
||||||
|
|
||||||
|
public int CompareTo(RenderProjectionId other) =>
|
||||||
|
_value.CompareTo(other._value);
|
||||||
|
|
||||||
|
public override string ToString() => $"projection:{_value:X16}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderOwnerIncarnation
|
||||||
|
{
|
||||||
|
private readonly ulong _value;
|
||||||
|
|
||||||
|
private RenderOwnerIncarnation(ulong value) => _value = value;
|
||||||
|
|
||||||
|
internal static RenderOwnerIncarnation FromRaw(ulong value) => new(value);
|
||||||
|
|
||||||
|
internal ulong RawValue => _value;
|
||||||
|
|
||||||
|
public int CompareTo(RenderOwnerIncarnation other) =>
|
||||||
|
_value.CompareTo(other._value);
|
||||||
|
|
||||||
|
public override string ToString() => $"incarnation:{_value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderSceneGeneration
|
||||||
|
{
|
||||||
|
private readonly ulong _value;
|
||||||
|
|
||||||
|
private RenderSceneGeneration(ulong value) => _value = value;
|
||||||
|
|
||||||
|
internal static RenderSceneGeneration FromRaw(ulong value) => new(value);
|
||||||
|
|
||||||
|
internal ulong RawValue => _value;
|
||||||
|
|
||||||
|
public int CompareTo(RenderSceneGeneration other) =>
|
||||||
|
_value.CompareTo(other._value);
|
||||||
|
|
||||||
|
public override string ToString() => $"generation:{_value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderSpatialBucket
|
||||||
|
{
|
||||||
|
private readonly ulong _value;
|
||||||
|
|
||||||
|
private RenderSpatialBucket(ulong value) => _value = value;
|
||||||
|
|
||||||
|
internal static RenderSpatialBucket FromRaw(ulong value) => new(value);
|
||||||
|
|
||||||
|
internal ulong RawValue => _value;
|
||||||
|
|
||||||
|
public int CompareTo(RenderSpatialBucket other) =>
|
||||||
|
_value.CompareTo(other._value);
|
||||||
|
|
||||||
|
public override string ToString() => $"bucket:{_value:X16}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderAssetHandle
|
||||||
|
{
|
||||||
|
private readonly ulong _value;
|
||||||
|
|
||||||
|
private RenderAssetHandle(ulong value) => _value = value;
|
||||||
|
|
||||||
|
internal static RenderAssetHandle FromRaw(ulong value) => new(value);
|
||||||
|
|
||||||
|
internal ulong RawValue => _value;
|
||||||
|
|
||||||
|
public int CompareTo(RenderAssetHandle other) =>
|
||||||
|
_value.CompareTo(other._value);
|
||||||
|
|
||||||
|
public override string ToString() => $"asset:{_value:X16}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum RenderProjectionClass : byte
|
||||||
|
{
|
||||||
|
OutdoorStatic,
|
||||||
|
IndoorCellStatic,
|
||||||
|
LiveDynamicRoot,
|
||||||
|
ActiveAnimatedStatic,
|
||||||
|
EquippedChild,
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
internal enum RenderProjectionFlags : uint
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Draw = 1 << 0,
|
||||||
|
Hidden = 1 << 1,
|
||||||
|
AncestorHidden = 1 << 2,
|
||||||
|
Translucent = 1 << 3,
|
||||||
|
Selectable = 1 << 4,
|
||||||
|
LightCandidate = 1 << 5,
|
||||||
|
PortalStraddling = 1 << 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
internal enum RenderDirtyMask : ushort
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Transform = 1 << 0,
|
||||||
|
Appearance = 1 << 1,
|
||||||
|
Flags = 1 << 2,
|
||||||
|
SpatialResidency = 1 << 3,
|
||||||
|
WorldBounds = 1 << 4,
|
||||||
|
SortKey = 1 << 5,
|
||||||
|
All = ushort.MaxValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderTransform(Matrix4x4 LocalToWorld);
|
||||||
|
|
||||||
|
internal readonly record struct PreviousRenderTransform(Matrix4x4 LocalToWorld);
|
||||||
|
|
||||||
|
internal readonly record struct RenderMeshSet(
|
||||||
|
RenderAssetHandle Handle,
|
||||||
|
int MeshCount,
|
||||||
|
ulong Revision);
|
||||||
|
|
||||||
|
internal readonly record struct RenderMaterialVariant(
|
||||||
|
ulong PaletteKey,
|
||||||
|
ulong TextureReplacementKey,
|
||||||
|
float Opacity);
|
||||||
|
|
||||||
|
internal readonly record struct RenderSpatialResidency(
|
||||||
|
RenderSpatialBucket Bucket,
|
||||||
|
uint OwnerLandblockId,
|
||||||
|
uint FullCellId);
|
||||||
|
|
||||||
|
internal readonly record struct RenderWorldBounds(Vector3 Minimum, Vector3 Maximum);
|
||||||
|
|
||||||
|
internal readonly record struct RenderDegradeState(byte Level, uint Revision);
|
||||||
|
|
||||||
|
internal readonly record struct RenderSortKey(ulong Value);
|
||||||
|
|
||||||
|
internal readonly record struct RenderProjectionRecord(
|
||||||
|
RenderProjectionId Id,
|
||||||
|
RenderProjectionClass ProjectionClass,
|
||||||
|
RenderOwnerIncarnation OwnerIncarnation,
|
||||||
|
RenderTransform Transform,
|
||||||
|
PreviousRenderTransform PreviousTransform,
|
||||||
|
RenderMeshSet MeshSet,
|
||||||
|
RenderMaterialVariant Material,
|
||||||
|
RenderSpatialResidency Residency,
|
||||||
|
RenderWorldBounds Bounds,
|
||||||
|
RenderProjectionFlags Flags,
|
||||||
|
RenderDegradeState DegradeState,
|
||||||
|
RenderSortKey SortKey,
|
||||||
|
RenderDirtyMask DirtyMask);
|
||||||
|
|
||||||
|
internal enum RenderProjectionDeltaKind : byte
|
||||||
|
{
|
||||||
|
Register,
|
||||||
|
UpdateTransform,
|
||||||
|
UpdateAppearance,
|
||||||
|
UpdateFlags,
|
||||||
|
Rebucket,
|
||||||
|
Unregister,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderProjectionDelta(
|
||||||
|
RenderProjectionDeltaKind Kind,
|
||||||
|
RenderSceneGeneration Generation,
|
||||||
|
ulong JournalSequence,
|
||||||
|
RenderProjectionRecord Record)
|
||||||
|
{
|
||||||
|
public static RenderProjectionDelta Register(
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
ulong journalSequence,
|
||||||
|
in RenderProjectionRecord record) =>
|
||||||
|
new(
|
||||||
|
RenderProjectionDeltaKind.Register,
|
||||||
|
generation,
|
||||||
|
journalSequence,
|
||||||
|
record);
|
||||||
|
|
||||||
|
public static RenderProjectionDelta Update(
|
||||||
|
RenderProjectionDeltaKind kind,
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
ulong journalSequence,
|
||||||
|
in RenderProjectionRecord record)
|
||||||
|
{
|
||||||
|
if (kind is RenderProjectionDeltaKind.Register
|
||||||
|
or RenderProjectionDeltaKind.Unregister)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(kind),
|
||||||
|
kind,
|
||||||
|
"Use Register or Unregister for structural deltas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RenderProjectionDelta(kind, generation, journalSequence, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RenderProjectionDelta Unregister(
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
ulong journalSequence,
|
||||||
|
RenderProjectionId id,
|
||||||
|
RenderOwnerIncarnation ownerIncarnation) =>
|
||||||
|
new(
|
||||||
|
RenderProjectionDeltaKind.Unregister,
|
||||||
|
generation,
|
||||||
|
journalSequence,
|
||||||
|
new RenderProjectionRecord() with
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
OwnerIncarnation = ownerIncarnation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct DynamicProjectionUpdate(
|
||||||
|
RenderProjectionId Id,
|
||||||
|
RenderOwnerIncarnation OwnerIncarnation,
|
||||||
|
RenderTransform Transform,
|
||||||
|
RenderWorldBounds Bounds);
|
||||||
|
|
||||||
|
internal readonly ref struct DynamicProjectionSyncInput
|
||||||
|
{
|
||||||
|
public DynamicProjectionSyncInput(
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
ReadOnlySpan<DynamicProjectionUpdate> updates)
|
||||||
|
{
|
||||||
|
Generation = generation;
|
||||||
|
Updates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RenderSceneGeneration Generation { get; }
|
||||||
|
|
||||||
|
public ReadOnlySpan<DynamicProjectionUpdate> Updates { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderProjectionCounts(
|
||||||
|
int Total,
|
||||||
|
int OutdoorStatic,
|
||||||
|
int IndoorCellStatic,
|
||||||
|
int LiveDynamicRoot,
|
||||||
|
int ActiveAnimatedStatic,
|
||||||
|
int EquippedChild)
|
||||||
|
{
|
||||||
|
public int For(RenderProjectionClass projectionClass) =>
|
||||||
|
projectionClass switch
|
||||||
|
{
|
||||||
|
RenderProjectionClass.OutdoorStatic => OutdoorStatic,
|
||||||
|
RenderProjectionClass.IndoorCellStatic => IndoorCellStatic,
|
||||||
|
RenderProjectionClass.LiveDynamicRoot => LiveDynamicRoot,
|
||||||
|
RenderProjectionClass.ActiveAnimatedStatic => ActiveAnimatedStatic,
|
||||||
|
RenderProjectionClass.EquippedChild => EquippedChild,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(projectionClass),
|
||||||
|
projectionClass,
|
||||||
|
null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderDeltaApplyResult(
|
||||||
|
int Applied,
|
||||||
|
int Registered,
|
||||||
|
int Updated,
|
||||||
|
int Replaced,
|
||||||
|
int Unregistered,
|
||||||
|
int RejectedGeneration,
|
||||||
|
int RejectedOutOfOrderSequence,
|
||||||
|
int RejectedStaleIncarnation,
|
||||||
|
int RejectedMissing)
|
||||||
|
{
|
||||||
|
public int Rejected =>
|
||||||
|
RejectedGeneration
|
||||||
|
+ RejectedOutOfOrderSequence
|
||||||
|
+ RejectedStaleIncarnation
|
||||||
|
+ RejectedMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderSceneMemoryAccounting(
|
||||||
|
int EntityCount,
|
||||||
|
int ArchEntityCapacity,
|
||||||
|
int ArchetypeCount,
|
||||||
|
int AllocatedChunkCount,
|
||||||
|
long EstimatedChunkPayloadBytes,
|
||||||
|
int ProjectionLookupCapacity,
|
||||||
|
long EstimatedProjectionLookupBytes,
|
||||||
|
long EstimatedIndexBytes,
|
||||||
|
long EstimatedJournalBufferBytes,
|
||||||
|
long EstimatedSynchronizationSourceBytes)
|
||||||
|
{
|
||||||
|
public long TotalEstimatedBytes =>
|
||||||
|
EstimatedChunkPayloadBytes
|
||||||
|
+ EstimatedProjectionLookupBytes
|
||||||
|
+ EstimatedIndexBytes
|
||||||
|
+ EstimatedJournalBufferBytes
|
||||||
|
+ EstimatedSynchronizationSourceBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RenderSceneDigest(
|
||||||
|
RenderSceneGeneration Generation,
|
||||||
|
RenderProjectionCounts Counts,
|
||||||
|
RenderSceneHash128 Hash);
|
||||||
|
|
||||||
|
internal sealed class RenderSceneDigestBuffer
|
||||||
|
{
|
||||||
|
internal List<RenderProjectionRecord> Records { get; } = [];
|
||||||
|
|
||||||
|
internal int Capacity => Records.Capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRenderSceneQuerySource
|
||||||
|
{
|
||||||
|
RenderProjectionCounts GetCounts(RenderSceneGeneration generation);
|
||||||
|
|
||||||
|
bool TryGet(
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
RenderProjectionId id,
|
||||||
|
out RenderProjectionRecord record);
|
||||||
|
|
||||||
|
int CopyTo(
|
||||||
|
RenderSceneGeneration generation,
|
||||||
|
RenderProjectionClass? projectionClass,
|
||||||
|
Span<RenderProjectionRecord> destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly struct RenderSceneQuery
|
||||||
|
{
|
||||||
|
private readonly IRenderSceneQuerySource? _source;
|
||||||
|
|
||||||
|
internal RenderSceneQuery(
|
||||||
|
IRenderSceneQuerySource source,
|
||||||
|
RenderSceneGeneration generation)
|
||||||
|
{
|
||||||
|
_source = source;
|
||||||
|
Generation = generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RenderSceneGeneration Generation { get; }
|
||||||
|
|
||||||
|
public RenderProjectionCounts Counts =>
|
||||||
|
Source.GetCounts(Generation);
|
||||||
|
|
||||||
|
public bool TryGet(
|
||||||
|
RenderProjectionId id,
|
||||||
|
out RenderProjectionRecord record) =>
|
||||||
|
Source.TryGet(Generation, id, out record);
|
||||||
|
|
||||||
|
public int CopyTo(Span<RenderProjectionRecord> destination) =>
|
||||||
|
Source.CopyTo(Generation, null, destination);
|
||||||
|
|
||||||
|
public int CopyTo(
|
||||||
|
RenderProjectionClass projectionClass,
|
||||||
|
Span<RenderProjectionRecord> destination) =>
|
||||||
|
Source.CopyTo(Generation, projectionClass, destination);
|
||||||
|
|
||||||
|
private IRenderSceneQuerySource Source =>
|
||||||
|
_source
|
||||||
|
?? throw new InvalidOperationException("The render-scene query is uninitialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRenderScene : IDisposable
|
||||||
|
{
|
||||||
|
RenderSceneGeneration Generation { get; }
|
||||||
|
|
||||||
|
RenderProjectionCounts Counts { get; }
|
||||||
|
|
||||||
|
RenderSceneMemoryAccounting Memory { get; }
|
||||||
|
|
||||||
|
RenderDeltaApplyResult Apply(ReadOnlySpan<RenderProjectionDelta> deltas);
|
||||||
|
|
||||||
|
void SynchronizeDynamicSources(in DynamicProjectionSyncInput input);
|
||||||
|
|
||||||
|
RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse);
|
||||||
|
|
||||||
|
RenderSceneQuery OpenQuery();
|
||||||
|
|
||||||
|
void Clear(RenderSceneGeneration replacementGeneration);
|
||||||
|
}
|
||||||
405
tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs
Normal file
405
tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
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.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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using AcDream.App.Rendering.Scene;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class RenderSceneArchitectureTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ArchDependency_IsPinnedOnlyInAppAndNamespacesStayContained()
|
||||||
|
{
|
||||||
|
string root = FindRepoRoot();
|
||||||
|
string appRoot = Path.Combine(root, "src", "AcDream.App");
|
||||||
|
string allowedRoot = Path.GetFullPath(
|
||||||
|
Path.Combine(appRoot, "Rendering", "Scene", "Arch"));
|
||||||
|
string[] appSources =
|
||||||
|
Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
string[] namespaceViolations = appSources
|
||||||
|
.Where(path => Regex.IsMatch(
|
||||||
|
File.ReadAllText(path),
|
||||||
|
@"(?:\busing\s+Arch(?:\.|;)|\bArch\.Core\b)"))
|
||||||
|
.Where(path => !IsUnder(path, allowedRoot))
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
string[] packageOwners = Directory
|
||||||
|
.GetFiles(Path.Combine(root, "src"), "*.csproj", SearchOption.AllDirectories)
|
||||||
|
.Where(HasDirectArchReference)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(namespaceViolations);
|
||||||
|
Assert.Equal(
|
||||||
|
[Path.GetFullPath(Path.Combine(appRoot, "AcDream.App.csproj"))],
|
||||||
|
packageOwners);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ArchTypes_DoNotCrossAcdreamOwnedSceneBoundary()
|
||||||
|
{
|
||||||
|
Type[] appTypes = typeof(IRenderScene).Assembly.GetTypes();
|
||||||
|
var violations = new List<string>();
|
||||||
|
|
||||||
|
foreach (Type type in appTypes)
|
||||||
|
{
|
||||||
|
if (type.Namespace?.StartsWith(
|
||||||
|
"AcDream.App.Rendering.Scene.Arch",
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
== true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (FieldInfo field in type.GetFields(
|
||||||
|
BindingFlags.Instance
|
||||||
|
| BindingFlags.Static
|
||||||
|
| BindingFlags.Public
|
||||||
|
| BindingFlags.NonPublic
|
||||||
|
| BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (ContainsArchType(field.FieldType))
|
||||||
|
violations.Add($"{type.FullName} field {field.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PropertyInfo property in type.GetProperties(
|
||||||
|
BindingFlags.Instance
|
||||||
|
| BindingFlags.Static
|
||||||
|
| BindingFlags.Public
|
||||||
|
| BindingFlags.NonPublic
|
||||||
|
| BindingFlags.DeclaredOnly))
|
||||||
|
{
|
||||||
|
if (ContainsArchType(property.PropertyType))
|
||||||
|
violations.Add($"{type.FullName} property {property.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (MethodBase method in type
|
||||||
|
.GetMethods(
|
||||||
|
BindingFlags.Instance
|
||||||
|
| BindingFlags.Static
|
||||||
|
| BindingFlags.Public
|
||||||
|
| BindingFlags.NonPublic
|
||||||
|
| BindingFlags.DeclaredOnly)
|
||||||
|
.Cast<MethodBase>()
|
||||||
|
.Concat(type.GetConstructors(
|
||||||
|
BindingFlags.Instance
|
||||||
|
| BindingFlags.Public
|
||||||
|
| BindingFlags.NonPublic)))
|
||||||
|
{
|
||||||
|
if (method.GetParameters()
|
||||||
|
.Any(parameter => ContainsArchType(parameter.ParameterType)))
|
||||||
|
{
|
||||||
|
violations.Add($"{type.FullName} method {method.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method is MethodInfo info
|
||||||
|
&& ContainsArchType(info.ReturnType))
|
||||||
|
{
|
||||||
|
violations.Add($"{type.FullName} return {method.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Empty(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrimitiveIdentityExtraction_StaysInsideArchAdapter()
|
||||||
|
{
|
||||||
|
string root = FindRepoRoot();
|
||||||
|
string appRoot = Path.Combine(root, "src", "AcDream.App");
|
||||||
|
string allowedRoot = Path.GetFullPath(
|
||||||
|
Path.Combine(appRoot, "Rendering", "Scene", "Arch"));
|
||||||
|
string contracts = Path.GetFullPath(
|
||||||
|
Path.Combine(
|
||||||
|
appRoot,
|
||||||
|
"Rendering",
|
||||||
|
"Scene",
|
||||||
|
"RenderSceneContracts.cs"));
|
||||||
|
|
||||||
|
string[] violations = Directory
|
||||||
|
.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories)
|
||||||
|
.Where(path => Regex.IsMatch(
|
||||||
|
File.ReadAllText(path),
|
||||||
|
@"\.\s*RawValue\b"))
|
||||||
|
.Where(path =>
|
||||||
|
!IsUnder(path, allowedRoot)
|
||||||
|
&& !Path.GetFullPath(path).Equals(
|
||||||
|
contracts,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Empty(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasDirectArchReference(string projectPath)
|
||||||
|
{
|
||||||
|
XDocument project = XDocument.Load(projectPath);
|
||||||
|
return project
|
||||||
|
.Descendants("PackageReference")
|
||||||
|
.Any(element => string.Equals(
|
||||||
|
(string?)element.Attribute("Include"),
|
||||||
|
"Arch",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsArchType(Type type)
|
||||||
|
{
|
||||||
|
if (type.IsByRef || type.IsArray || type.IsPointer)
|
||||||
|
return ContainsArchType(type.GetElementType()!);
|
||||||
|
|
||||||
|
if (type.Namespace?.StartsWith("Arch", StringComparison.Ordinal) == true)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return type.IsGenericType
|
||||||
|
&& type.GetGenericArguments().Any(ContainsArchType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnder(string path, string root)
|
||||||
|
{
|
||||||
|
string relative = Path.GetRelativePath(root, Path.GetFullPath(path));
|
||||||
|
return relative != ".."
|
||||||
|
&& !relative.StartsWith(
|
||||||
|
$"..{Path.DirectorySeparatorChar}",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
string? dir = AppContext.BaseDirectory;
|
||||||
|
while (dir is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
||||||
|
return dir;
|
||||||
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue