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. |
|
||||
| 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. |
|
||||
| F2–F5 | pending | Delta publication and continuous shadow comparison have not started. |
|
||||
| F1 — scene types and contained adapter | complete | `Arch 2.1.0` is pinned in App only behind acdream contracts. Five narrow archetypes, generation/incarnation/sequence gates, borrowed-query invalidation, memory accounting, deterministic digesting, and update-thread enforcement pass 11 focused tests. Release gate: 3,701 App tests / 3 skips and 8,185 complete-solution tests / 5 skips. |
|
||||
| F2 — static projection journal | active | Static publication/retirement owners do not emit render deltas yet. |
|
||||
| F3–F5 | pending | Live/equipped publication, dynamic indices, and continuous shadow comparison have not started. |
|
||||
| G0–G5 | pending | No production consumer has switched. |
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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). -->
|
||||
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
|
||||
<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.Extensions.ARB" 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