feat(render): establish typed residency policy ledger
Define Slice D's transition and concurrency contract, add generation-safe asset handles and owner leases, and parse the existing cache ceilings through typed runtime budgets. Stale generations cannot release or revive replacements, and worker observations are bounded and coalesced. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
a564c4b782
commit
1866ea0c6d
8 changed files with 1696 additions and 2 deletions
|
|
@ -0,0 +1,245 @@
|
|||
# Modern Runtime Slice D — Typed Asset Handles and Unified Residency
|
||||
|
||||
**Status:** active
|
||||
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
|
||||
**Baseline:** Slice C closeout commit `a564c4b7`
|
||||
**Behavior contract:** no visual-quality or retail-behavior change
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
Slice D makes runtime memory finite, attributable, and generation-safe without
|
||||
replacing the specialized caches that already work.
|
||||
|
||||
One `ResidencyManager` owns policy, logical asset identity, generations,
|
||||
leases, budgets, and the aggregate diagnostic snapshot. Existing renderer and
|
||||
content owners continue to own their physical storage:
|
||||
|
||||
- `ObjectMeshManager` owns object render data, mesh ranges, texture atlases,
|
||||
prepared CPU mesh entries, and staged uploads.
|
||||
- `GlobalMeshBuffer` owns vertex/index arenas and fence-delayed range reuse.
|
||||
- `CompositeTextureArrayCache` owns pooled composite-array layers and arrays.
|
||||
- `StandaloneBindlessTextureCache` owns standalone particle arrays.
|
||||
- `GpuFrameFlightController` and `GpuRetirementLedger` own fence-delayed
|
||||
physical release.
|
||||
- `RetailAnimationLoader` owns parsed animation residence.
|
||||
- Deferred-alpha producers own their reusable CPU scratch arrays.
|
||||
|
||||
The manager never stores or deletes an OpenGL name. It observes immutable
|
||||
facts, makes bounded policy decisions during the update/resource-maintenance
|
||||
phase, and issues logical trim requests. The physical owner executes any GL
|
||||
release during its existing render-thread maintenance phase.
|
||||
|
||||
## 2. Current-owner inventory
|
||||
|
||||
| Domain | Existing policy | Physical owner | Slice D action |
|
||||
|---|---|---|---|
|
||||
| Object render data | 1 GiB estimated GPU / 50 unowned objects, per-frame bounded reclamation | `ObjectMeshManager` | Preserve; source limits from typed budgets; report logical/non-arena/arena/retiring bytes separately |
|
||||
| Prepared mesh CPU cache | 100 entries / 128 MiB LRU | `CpuMeshUploadCache` | Preserve; source limits from typed budgets; report hits, misses, evictions, count, bytes |
|
||||
| Mesh staging | 256 claims / 128 MiB, generation checked | `MeshUploadStagingQueue` | Preserve; source limits from typed budgets; report queued and claimed bytes separately |
|
||||
| Global mesh arenas | 384 MiB vertex, 128 MiB index, 896 MiB maximum physical overlap; shrink hysteresis | `GlobalMeshBuffer` | Preserve; expose live, free, largest-free, staging, retired, and fragmentation facts |
|
||||
| Composite texture arrays | 64 MiB unowned / 128 MiB physical, throttled logical eviction and fence release | `CompositeTextureArrayCache` | Preserve; source limits from typed budgets; report resident/unowned/retiring/fragmentation |
|
||||
| Standalone particle arrays | 32 MiB / 256 unowned, one eviction per frame | `StandaloneBindlessTextureCache` | Preserve; source limits from typed budgets; report owned/unowned/retiring bytes |
|
||||
| Prepared package | one immutable memory map; payload arrays are charged to CPU cache/staging | `PakPreparedAssetSource` | Report mapped virtual bytes separately; do not mislabel the 27.5 GiB address map as committed residence |
|
||||
| Parsed animations | unbounded dictionary | `RetailAnimationLoader` | Add byte-and-count bounded concurrent LRU; a live sequencer keeps its own reference after cache eviction |
|
||||
| Decoded audio waves | 32 MiB LRU | `DatSoundCache` | Preserve and report; sound-table metadata is finite DAT identity metadata |
|
||||
| Deferred-alpha scratch | grow-only lists/arrays at three submission seams | producer owners | Add capacity-budgeted retention with immediate correctness-preserving growth and policy-driven shrink |
|
||||
| Mesh bounds memo | not present in current source | none | Close stale issue #243; do not add a replacement |
|
||||
|
||||
The process-wide `GpuMemoryTracker` remains an independent physical-allocation
|
||||
cross-check. It is not a policy owner because it intentionally covers shaders,
|
||||
framebuffers, UI textures, and other allocations outside Slice D's cache set.
|
||||
|
||||
## 3. Typed identity
|
||||
|
||||
```csharp
|
||||
readonly record struct AssetHandle<TAsset>(uint Index, ushort Generation);
|
||||
readonly record struct OwnerToken(uint Index, ushort Generation);
|
||||
readonly record struct AssetLease<TAsset>(
|
||||
AssetHandle<TAsset> Handle,
|
||||
OwnerToken Owner);
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Index zero and generation zero are invalid.
|
||||
2. A slot generation increments before an index is reused.
|
||||
3. A stale handle cannot touch, transition, lease, release, or retire its
|
||||
replacement generation.
|
||||
4. Owner tokens are allocated and retired independently from assets. Retiring
|
||||
an owner releases exactly that owner's leases.
|
||||
5. Repeated acquisition of the same `(asset, owner)` is idempotent.
|
||||
6. Callers never derive a handle, owner, or generation through an integer cast.
|
||||
7. Physical cache keys remain domain-specific (`ulong` mesh id,
|
||||
`CompositeTextureKey`, surface DID). An adapter maps those keys to typed
|
||||
handles; the manager does not replace their key or storage types.
|
||||
|
||||
## 4. State machine
|
||||
|
||||
The logical state machine is single-writer. Terminal failure states preserve
|
||||
diagnostic identity until a new generation is requested.
|
||||
|
||||
| Current | Event | Next | Required accounting/action |
|
||||
|---|---|---|---|
|
||||
| `Absent` | request | `Requested` | allocate/current generation; record priority and request frame |
|
||||
| `Requested` | prepare succeeds | `Prepared` | publish actual CPU prepared/decoded bytes |
|
||||
| `Requested` | cancel | `Cancelled` | clear transient bytes; never publish a completion |
|
||||
| `Requested` | missing | `Missing` | retain negative result only |
|
||||
| `Requested` | corrupt | `Corrupt` | retain diagnostic result; do not retry silently |
|
||||
| `Requested` | failure | `Failed` | retain typed failure; do not swallow |
|
||||
| `Prepared` | queue upload | `UploadPending` | move or add staging/GPU-requested charges |
|
||||
| `Prepared` | release with no owners | `Retiring` | enqueue logical eviction |
|
||||
| `UploadPending` | upload succeeds | `Resident` | clear requested/staging charge; publish exact resident charge |
|
||||
| `UploadPending` | retryable rollback completes | `Prepared` | clear GPU-requested charge; retain CPU data |
|
||||
| `UploadPending` | cancel/no owners | `Retiring` | invalidate generation; stale completion is ignored |
|
||||
| `UploadPending` | corrupt/failure | `Corrupt`/`Failed` | clear transient charges; retain diagnostic state |
|
||||
| `Resident` | acquire/touch | `Resident` | update owner set, priority, frame, and rebuild cost |
|
||||
| `Resident` | last owner releases | `Resident` | becomes eviction-eligible; physical cache may retain it |
|
||||
| `Resident` | evict | `Retiring` | invalidate logical availability before physical release |
|
||||
| `Retiring` | fence release completes | `Absent` | clear retiring bytes and recycle slot with next generation |
|
||||
| any non-retiring state | session/world generation invalidates | `Retiring` or terminal cancel | reject later stale work |
|
||||
| terminal failure state | explicit new request | `Requested` on a new generation | prior completion cannot revive the new request |
|
||||
|
||||
Illegal transitions throw in tests and emit a release diagnostic in
|
||||
production composition; they are never silently coerced.
|
||||
|
||||
## 5. Threading and phase ownership
|
||||
|
||||
The graphical client currently advances update and render on one native window
|
||||
thread. Slice D nevertheless fixes the contract at the future host boundary:
|
||||
|
||||
### Shared/worker side
|
||||
|
||||
- Decode and preparation workers may only enqueue immutable
|
||||
`ResidencyObservation` records.
|
||||
- Workers never mutate the manager ledger, budgets, owners, GL resources, or
|
||||
render cache collections.
|
||||
- The observation queue is bounded by active asset generations. Repeated
|
||||
observations for one `(handle, kind)` coalesce last-writer before drain.
|
||||
- Cancellation and corrupt/failure completion carry the exact asset generation.
|
||||
|
||||
### Update/resource-maintenance phase
|
||||
|
||||
- `ResidencyManager` is single-writer.
|
||||
- At most one manager drain and policy pass runs per host tick.
|
||||
- It applies observations, updates the lease/state ledger, captures domain
|
||||
facts, and emits bounded `ResidencyTrimRequest` commands.
|
||||
- Eviction order is: zero owners first, lowest priority first, oldest use
|
||||
generation/frame first, lowest rebuild cost first, then stable handle index.
|
||||
- Manager code never waits on decode, file I/O, a GPU fence, or a lock acquired
|
||||
by the render path.
|
||||
|
||||
### Render-resource phase
|
||||
|
||||
- Specialized owners consume trim requests without calling back into the
|
||||
manager while holding their internal mutation locks.
|
||||
- Logical removal occurs before publication of a physical-release request.
|
||||
- OpenGL non-residency/deletion and arena range return remain render-thread
|
||||
only and fence delayed through the existing retirement owners.
|
||||
- Physical completion is observed on the following manager drain. A stale
|
||||
completion with the wrong handle generation is rejected.
|
||||
|
||||
No render-thread code blocks waiting for the manager, and the manager never
|
||||
sees a raw GL name or bindless handle.
|
||||
|
||||
## 6. Accounting model
|
||||
|
||||
Every domain reports the following independent values:
|
||||
|
||||
- `LogicalBytes`: immutable metadata and logical entry overhead where measured.
|
||||
- `CpuPreparedBytes`: parsed/prepared payload retained for reuse.
|
||||
- `DecodedBytes`: canonical decoded pixels/audio/animation graphs.
|
||||
- `PinnedBytes`: CPU data that cannot currently be evicted due to a live lease.
|
||||
- `StagingBytes`: queued or claimed upload payload.
|
||||
- `GpuRequestedBytes`: physical allocation requested but not yet published.
|
||||
- `GpuResidentBytes`: currently drawable physical allocation.
|
||||
- `RetiringBytes`: logically dead physical allocation awaiting a fence/retry.
|
||||
- `MappedVirtualBytes`: immutable package address space, not committed RAM.
|
||||
- `BudgetBytes`: the policy ceiling for the relevant charge.
|
||||
- `CapacityBytes`, `UsedBytes`, `LargestFreeBytes`: allocator facts used to
|
||||
calculate fragmentation without pretending free capacity is a leak.
|
||||
|
||||
Aggregate totals never add `MappedVirtualBytes` to committed CPU residence and
|
||||
never add an arena's logical allocation bytes to its physical capacity twice.
|
||||
|
||||
## 7. Budgets and defaults
|
||||
|
||||
`RuntimeOptions` owns environment parsing once. `ResidencyBudgetOptions`
|
||||
contains byte/count ceilings with the current production values as defaults:
|
||||
|
||||
- object mesh physical: 1 GiB;
|
||||
- object mesh unowned entries: 50;
|
||||
- prepared mesh CPU: 128 MiB / 100;
|
||||
- staging: 128 MiB / 256;
|
||||
- composite arrays: 128 MiB physical / 64 MiB unowned;
|
||||
- standalone particle arrays: 32 MiB / 256;
|
||||
- decoded animations: 64 MiB / 512;
|
||||
- deferred-alpha retained CPU scratch: 16 MiB aggregate with per-owner floors.
|
||||
|
||||
Environment overrides are diagnostic/startup inputs and reject zero, negative,
|
||||
overflowing, or malformed values by falling back to the documented default.
|
||||
The settings quality preset may select a complete budget profile later; Slice D
|
||||
first exposes the typed runtime target so a settings change is atomic rather
|
||||
than mutating individual caches independently. Defaults preserve today's
|
||||
visual radius and cache behavior.
|
||||
|
||||
Multi-session retuning is evidence driven. This slice records actual working
|
||||
sets under normal, dense, and forced-pressure routes before changing production
|
||||
defaults below their current values.
|
||||
|
||||
## 8. Delivery checkpoints
|
||||
|
||||
### D1 — Contract and logical ledger
|
||||
|
||||
- Add typed handles, owner tokens, leases, priorities, states, observations,
|
||||
trim requests, budgets, and aggregate snapshots.
|
||||
- Implement the transition table and generation-safe owner release.
|
||||
- Test stale completions, duplicate acquire, owner reuse, asset-slot reuse,
|
||||
cancellation, corrupt/failure states, and illegal transitions.
|
||||
|
||||
### D2 — Existing-owner adapters
|
||||
|
||||
- Parameterize current mesh, staging, composite, and standalone budgets.
|
||||
- Add exact snapshot facts to each owner.
|
||||
- Register them with one manager without changing their physical storage or
|
||||
fence protocol.
|
||||
- Cross-check manager GPU totals with `GpuMemoryTracker`, allowing explicitly
|
||||
enumerated non-cache resources.
|
||||
|
||||
### D3 — Missing policies
|
||||
|
||||
- Bound `RetailAnimationLoader` by count and estimated retained bytes.
|
||||
- Bring deferred-alpha retained scratch behind a bounded capacity owner.
|
||||
- Close stale #243 after proving no current bounds cache exists.
|
||||
|
||||
### D4 — Diagnostics and forced pressure
|
||||
|
||||
- Publish aggregate and per-domain budgets/occupancy/fragmentation through the
|
||||
lifecycle diagnostic artifact.
|
||||
- Add deterministic forced-pressure tests that exceed every existing ceiling,
|
||||
drain fence release, and prove convergence.
|
||||
- Assert manager and physical-owner accounting return to zero at teardown.
|
||||
|
||||
### D5 — Connected gate
|
||||
|
||||
- Run capped, uncapped, and dense connected routes against Slice C.
|
||||
- Run repeated same-location portal loops and compare first/second/third visit.
|
||||
- Require no missing texture/mesh, no stale-generation release, no monotonic
|
||||
owner/resource growth, and graceful shutdown.
|
||||
- Preserve fixed-camera screenshots and obtain the user's visual gate if the
|
||||
connected route changes any visible frame.
|
||||
|
||||
## 9. Acceptance
|
||||
|
||||
Slice D closes only when:
|
||||
|
||||
1. All state/ownership/generation tests pass.
|
||||
2. Forced-pressure tests prove the configured ceilings, not merely a route
|
||||
that happens to stay below them.
|
||||
3. The third same-location visit plateaus within the documented tolerance.
|
||||
4. Repeated portal loops do not grow logical owners, staged work, resident GPU
|
||||
resources, retiring resources, or parsed animation residence.
|
||||
5. The complete Release build/test suite is green.
|
||||
6. The architecture, roadmap, issues, inventory, and durable memory agree with
|
||||
the implementation.
|
||||
7. Connected screenshots retain Slice C's visual output.
|
||||
|
||||
199
src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs
Normal file
199
src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
namespace AcDream.App.Rendering.Residency;
|
||||
|
||||
internal enum ResidencyDomain : byte
|
||||
{
|
||||
ObjectMeshes,
|
||||
PreparedMeshCpu,
|
||||
MeshStaging,
|
||||
GlobalMeshArena,
|
||||
CompositeTextures,
|
||||
StandaloneTextures,
|
||||
PreparedPackage,
|
||||
Animations,
|
||||
Audio,
|
||||
AlphaScratch,
|
||||
}
|
||||
|
||||
internal enum ResidencyPriority : byte
|
||||
{
|
||||
Speculative,
|
||||
Far,
|
||||
Near,
|
||||
Visible,
|
||||
DestinationCritical,
|
||||
}
|
||||
|
||||
internal enum AssetResidencyState : byte
|
||||
{
|
||||
Absent,
|
||||
Requested,
|
||||
Prepared,
|
||||
UploadPending,
|
||||
Resident,
|
||||
Retiring,
|
||||
Cancelled,
|
||||
Missing,
|
||||
Corrupt,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal enum ResidencyOwnerKind : byte
|
||||
{
|
||||
Session,
|
||||
Landblock,
|
||||
Entity,
|
||||
ParticleEmitter,
|
||||
UserInterface,
|
||||
Tooling,
|
||||
}
|
||||
|
||||
internal readonly record struct ResidencyAssetKey(
|
||||
ResidencyDomain Domain,
|
||||
ulong ContentKey,
|
||||
ulong Variant = 0);
|
||||
|
||||
internal readonly record struct AssetHandle<TAsset>(uint Index, ushort Generation)
|
||||
{
|
||||
public bool IsValid => Index != 0 && Generation != 0;
|
||||
|
||||
internal AssetReference Untyped =>
|
||||
new(Index, Generation, typeof(TAsset).TypeHandle);
|
||||
}
|
||||
|
||||
internal readonly record struct OwnerToken(uint Index, ushort Generation)
|
||||
{
|
||||
public bool IsValid => Index != 0 && Generation != 0;
|
||||
}
|
||||
|
||||
internal readonly record struct AssetLease<TAsset>(
|
||||
AssetHandle<TAsset> Handle,
|
||||
OwnerToken Owner);
|
||||
|
||||
internal readonly record struct AssetReference(
|
||||
uint Index,
|
||||
ushort Generation,
|
||||
RuntimeTypeHandle AssetType)
|
||||
{
|
||||
public bool IsValid =>
|
||||
Index != 0
|
||||
&& Generation != 0
|
||||
&& !AssetType.Equals(default(RuntimeTypeHandle));
|
||||
}
|
||||
|
||||
internal readonly record struct ResidencyCharges(
|
||||
long LogicalBytes = 0,
|
||||
long CpuPreparedBytes = 0,
|
||||
long DecodedBytes = 0,
|
||||
long PinnedBytes = 0,
|
||||
long StagingBytes = 0,
|
||||
long GpuRequestedBytes = 0,
|
||||
long GpuResidentBytes = 0,
|
||||
long RetiringBytes = 0,
|
||||
long MappedVirtualBytes = 0)
|
||||
{
|
||||
public static ResidencyCharges Zero => default;
|
||||
|
||||
public long CommittedCpuBytes => checked(
|
||||
LogicalBytes
|
||||
+ CpuPreparedBytes
|
||||
+ DecodedBytes
|
||||
+ StagingBytes);
|
||||
|
||||
public long PhysicalGpuBytes => checked(
|
||||
GpuRequestedBytes
|
||||
+ GpuResidentBytes
|
||||
+ RetiringBytes);
|
||||
|
||||
public bool IsZero => this == default;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(GpuResidentBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(RetiringBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(MappedVirtualBytes);
|
||||
}
|
||||
|
||||
public static ResidencyCharges operator +(
|
||||
ResidencyCharges left,
|
||||
ResidencyCharges right) =>
|
||||
new(
|
||||
checked(left.LogicalBytes + right.LogicalBytes),
|
||||
checked(left.CpuPreparedBytes + right.CpuPreparedBytes),
|
||||
checked(left.DecodedBytes + right.DecodedBytes),
|
||||
checked(left.PinnedBytes + right.PinnedBytes),
|
||||
checked(left.StagingBytes + right.StagingBytes),
|
||||
checked(left.GpuRequestedBytes + right.GpuRequestedBytes),
|
||||
checked(left.GpuResidentBytes + right.GpuResidentBytes),
|
||||
checked(left.RetiringBytes + right.RetiringBytes),
|
||||
checked(left.MappedVirtualBytes + right.MappedVirtualBytes));
|
||||
}
|
||||
|
||||
internal readonly record struct ResidencyEntrySnapshot(
|
||||
AssetReference Asset,
|
||||
ResidencyAssetKey Key,
|
||||
AssetResidencyState State,
|
||||
ResidencyPriority Priority,
|
||||
ulong WorldGeneration,
|
||||
long LastUsedFrame,
|
||||
long RebuildCost,
|
||||
int OwnerCount,
|
||||
ResidencyCharges Charges,
|
||||
string? Failure);
|
||||
|
||||
internal readonly record struct ResidencyTrimRequest(
|
||||
AssetReference Asset,
|
||||
ResidencyAssetKey Key,
|
||||
ResidencyCharges Charges);
|
||||
|
||||
internal readonly record struct ResidencyDomainSnapshot(
|
||||
ResidencyDomain Domain,
|
||||
int EntryCount,
|
||||
int OwnerCount,
|
||||
ResidencyCharges Charges,
|
||||
long BudgetBytes = 0,
|
||||
long CapacityBytes = 0,
|
||||
long UsedBytes = 0,
|
||||
long LargestFreeBytes = 0,
|
||||
long Evictions = 0)
|
||||
{
|
||||
public long FreeBytes => Math.Max(0, CapacityBytes - UsedBytes);
|
||||
|
||||
public long FragmentedFreeBytes =>
|
||||
Math.Max(0, FreeBytes - LargestFreeBytes);
|
||||
}
|
||||
|
||||
internal readonly record struct ResidencySnapshot(
|
||||
IReadOnlyList<ResidencyDomainSnapshot> Domains,
|
||||
ResidencyCharges TotalCharges)
|
||||
{
|
||||
public ResidencyDomainSnapshot Get(ResidencyDomain domain) =>
|
||||
Domains.FirstOrDefault(snapshot => snapshot.Domain == domain);
|
||||
}
|
||||
|
||||
internal interface IResidencyDomainSource
|
||||
{
|
||||
ResidencyDomain Domain { get; }
|
||||
|
||||
ResidencyDomainSnapshot CaptureResidency();
|
||||
}
|
||||
|
||||
internal enum ResidencyObservationKind : byte
|
||||
{
|
||||
Transition,
|
||||
Touch,
|
||||
}
|
||||
|
||||
internal readonly record struct ResidencyObservation(
|
||||
AssetReference Asset,
|
||||
ResidencyObservationKind Kind,
|
||||
AssetResidencyState State,
|
||||
ResidencyCharges Charges,
|
||||
ResidencyPriority Priority,
|
||||
long Frame,
|
||||
string? Failure = null);
|
||||
113
src/AcDream.App/Rendering/Residency/ResidencyBudgetOptions.cs
Normal file
113
src/AcDream.App/Rendering/Residency/ResidencyBudgetOptions.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace AcDream.App.Rendering.Residency;
|
||||
|
||||
/// <summary>
|
||||
/// Startup-time residency ceilings. Values preserve the pre-Slice-D cache
|
||||
/// behavior by default and are changed atomically as one profile.
|
||||
/// </summary>
|
||||
public sealed record ResidencyBudgetOptions(
|
||||
long ObjectMeshGpuBytes,
|
||||
int ObjectMeshUnownedEntries,
|
||||
long PreparedMeshCpuBytes,
|
||||
int PreparedMeshCpuEntries,
|
||||
long MeshStagingBytes,
|
||||
int MeshStagingEntries,
|
||||
long CompositePhysicalBytes,
|
||||
long CompositeUnownedBytes,
|
||||
long StandaloneUnownedBytes,
|
||||
int StandaloneUnownedEntries,
|
||||
long AnimationBytes,
|
||||
int AnimationEntries,
|
||||
long AlphaScratchBytes)
|
||||
{
|
||||
public const long MiB = 1024L * 1024L;
|
||||
|
||||
public static ResidencyBudgetOptions Default { get; } = new(
|
||||
ObjectMeshGpuBytes: 1024 * MiB,
|
||||
ObjectMeshUnownedEntries: 50,
|
||||
PreparedMeshCpuBytes: 128 * MiB,
|
||||
PreparedMeshCpuEntries: 100,
|
||||
MeshStagingBytes: 128 * MiB,
|
||||
MeshStagingEntries: 256,
|
||||
CompositePhysicalBytes: 128 * MiB,
|
||||
CompositeUnownedBytes: 64 * MiB,
|
||||
StandaloneUnownedBytes: 32 * MiB,
|
||||
StandaloneUnownedEntries: 256,
|
||||
AnimationBytes: 64 * MiB,
|
||||
AnimationEntries: 512,
|
||||
AlphaScratchBytes: 16 * MiB);
|
||||
|
||||
internal static ResidencyBudgetOptions Parse(
|
||||
Func<string, string?> env)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(env);
|
||||
ResidencyBudgetOptions defaults = Default;
|
||||
return new ResidencyBudgetOptions(
|
||||
ObjectMeshGpuBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_MESH_GPU_MIB"),
|
||||
defaults.ObjectMeshGpuBytes),
|
||||
ObjectMeshUnownedEntries: ParseCount(
|
||||
env("ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"),
|
||||
defaults.ObjectMeshUnownedEntries),
|
||||
PreparedMeshCpuBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_PREPARED_MESH_MIB"),
|
||||
defaults.PreparedMeshCpuBytes),
|
||||
PreparedMeshCpuEntries: ParseCount(
|
||||
env("ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES"),
|
||||
defaults.PreparedMeshCpuEntries),
|
||||
MeshStagingBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_MESH_STAGING_MIB"),
|
||||
defaults.MeshStagingBytes),
|
||||
MeshStagingEntries: ParseCount(
|
||||
env("ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES"),
|
||||
defaults.MeshStagingEntries),
|
||||
CompositePhysicalBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB"),
|
||||
defaults.CompositePhysicalBytes),
|
||||
CompositeUnownedBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB"),
|
||||
defaults.CompositeUnownedBytes),
|
||||
StandaloneUnownedBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB"),
|
||||
defaults.StandaloneUnownedBytes),
|
||||
StandaloneUnownedEntries: ParseCount(
|
||||
env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES"),
|
||||
defaults.StandaloneUnownedEntries),
|
||||
AnimationBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_ANIMATION_MIB"),
|
||||
defaults.AnimationBytes),
|
||||
AnimationEntries: ParseCount(
|
||||
env("ACDREAM_RESIDENCY_ANIMATION_ENTRIES"),
|
||||
defaults.AnimationEntries),
|
||||
AlphaScratchBytes: ParseMiB(
|
||||
env("ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB"),
|
||||
defaults.AlphaScratchBytes));
|
||||
}
|
||||
|
||||
private static long ParseMiB(string? value, long fallback)
|
||||
{
|
||||
if (!long.TryParse(
|
||||
value,
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out long mebibytes)
|
||||
|| mebibytes <= 0
|
||||
|| mebibytes > long.MaxValue / MiB)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
return checked(mebibytes * MiB);
|
||||
}
|
||||
|
||||
private static int ParseCount(string? value, int fallback) =>
|
||||
int.TryParse(
|
||||
value,
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out int count)
|
||||
&& count > 0
|
||||
? count
|
||||
: fallback;
|
||||
}
|
||||
|
||||
614
src/AcDream.App/Rendering/Residency/ResidencyManager.cs
Normal file
614
src/AcDream.App/Rendering/Residency/ResidencyManager.cs
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
namespace AcDream.App.Rendering.Residency;
|
||||
|
||||
/// <summary>
|
||||
/// Single-writer logical residency owner. It has no dependency on OpenGL and
|
||||
/// never stores physical resource names.
|
||||
/// </summary>
|
||||
internal sealed class ResidencyManager
|
||||
{
|
||||
private sealed class AssetSlot
|
||||
{
|
||||
public required RuntimeTypeHandle AssetType { get; init; }
|
||||
public required ushort Generation { get; init; }
|
||||
public required ResidencyAssetKey Key { get; init; }
|
||||
public required ulong WorldGeneration { get; init; }
|
||||
public AssetResidencyState State { get; set; }
|
||||
public ResidencyPriority Priority { get; set; }
|
||||
public long LastUsedFrame { get; set; }
|
||||
public long RebuildCost { get; set; }
|
||||
public ResidencyCharges Charges { get; set; }
|
||||
public string? Failure { get; set; }
|
||||
public HashSet<OwnerToken> Owners { get; } = [];
|
||||
}
|
||||
|
||||
private sealed class OwnerSlot
|
||||
{
|
||||
public required ushort Generation { get; init; }
|
||||
public required ResidencyOwnerKind Kind { get; init; }
|
||||
public required ulong LogicalId { get; init; }
|
||||
public required ulong WorldGeneration { get; init; }
|
||||
public HashSet<AssetReference> Assets { get; } = [];
|
||||
}
|
||||
|
||||
private readonly record struct TypedAssetKey(
|
||||
RuntimeTypeHandle AssetType,
|
||||
ResidencyAssetKey Key);
|
||||
|
||||
private readonly List<AssetSlot?> _assets = [null];
|
||||
private readonly List<ushort> _assetGenerations = [0];
|
||||
private readonly Stack<uint> _freeAssets = [];
|
||||
private readonly Dictionary<TypedAssetKey, uint> _assetByKey = [];
|
||||
|
||||
private readonly List<OwnerSlot?> _owners = [null];
|
||||
private readonly List<ushort> _ownerGenerations = [0];
|
||||
private readonly Stack<uint> _freeOwners = [];
|
||||
|
||||
private readonly List<IResidencyDomainSource> _domainSources = [];
|
||||
private readonly List<ResidencyObservation> _observationScratch = [];
|
||||
private readonly List<ResidencyDomainSnapshot> _snapshotScratch = [];
|
||||
|
||||
public int ActiveAssetCount => _assetByKey.Count;
|
||||
|
||||
public int ActiveOwnerCount => _owners.Count(owner => owner is not null);
|
||||
|
||||
public void RegisterDomainSource(IResidencyDomainSource source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
if (_domainSources.Any(candidate => candidate.Domain == source.Domain))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Residency domain {source.Domain} is already registered.");
|
||||
}
|
||||
_domainSources.Add(source);
|
||||
}
|
||||
|
||||
public OwnerToken CreateOwner(
|
||||
ResidencyOwnerKind kind,
|
||||
ulong logicalId,
|
||||
ulong worldGeneration)
|
||||
{
|
||||
uint index = AllocateIndex(_owners, _ownerGenerations, _freeOwners);
|
||||
ushort generation = NextGeneration(_ownerGenerations, index);
|
||||
_owners[checked((int)index)] = new OwnerSlot
|
||||
{
|
||||
Generation = generation,
|
||||
Kind = kind,
|
||||
LogicalId = logicalId,
|
||||
WorldGeneration = worldGeneration,
|
||||
};
|
||||
return new OwnerToken(index, generation);
|
||||
}
|
||||
|
||||
public bool RetireOwner(OwnerToken owner)
|
||||
{
|
||||
if (!TryGetOwner(owner, out OwnerSlot slot))
|
||||
return false;
|
||||
|
||||
AssetReference[] assets = slot.Assets.ToArray();
|
||||
for (int i = 0; i < assets.Length; i++)
|
||||
{
|
||||
if (TryGetAsset(assets[i], out AssetSlot asset))
|
||||
asset.Owners.Remove(owner);
|
||||
}
|
||||
|
||||
_owners[checked((int)owner.Index)] = null;
|
||||
_freeOwners.Push(owner.Index);
|
||||
return true;
|
||||
}
|
||||
|
||||
public AssetHandle<TAsset> Request<TAsset>(
|
||||
ResidencyAssetKey key,
|
||||
ResidencyPriority priority,
|
||||
ulong worldGeneration,
|
||||
long frame,
|
||||
long rebuildCost = 0)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(frame);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(rebuildCost);
|
||||
RuntimeTypeHandle type = typeof(TAsset).TypeHandle;
|
||||
var typedKey = new TypedAssetKey(type, key);
|
||||
if (_assetByKey.TryGetValue(typedKey, out uint existingIndex))
|
||||
{
|
||||
AssetSlot existing = _assets[checked((int)existingIndex)]
|
||||
?? throw new InvalidOperationException(
|
||||
"Residency key index did not resolve to a live slot.");
|
||||
if (IsTerminal(existing.State))
|
||||
{
|
||||
RecycleAsset(existingIndex, existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Priority = Max(existing.Priority, priority);
|
||||
existing.LastUsedFrame = Math.Max(existing.LastUsedFrame, frame);
|
||||
existing.RebuildCost = Math.Max(existing.RebuildCost, rebuildCost);
|
||||
return new AssetHandle<TAsset>(
|
||||
existingIndex,
|
||||
existing.Generation);
|
||||
}
|
||||
}
|
||||
|
||||
uint index = AllocateIndex(_assets, _assetGenerations, _freeAssets);
|
||||
ushort generation = NextGeneration(_assetGenerations, index);
|
||||
var slot = new AssetSlot
|
||||
{
|
||||
AssetType = type,
|
||||
Generation = generation,
|
||||
Key = key,
|
||||
WorldGeneration = worldGeneration,
|
||||
State = AssetResidencyState.Requested,
|
||||
Priority = priority,
|
||||
LastUsedFrame = frame,
|
||||
RebuildCost = rebuildCost,
|
||||
};
|
||||
_assets[checked((int)index)] = slot;
|
||||
_assetByKey.Add(typedKey, index);
|
||||
return new AssetHandle<TAsset>(index, generation);
|
||||
}
|
||||
|
||||
public AssetLease<TAsset> Acquire<TAsset>(
|
||||
AssetHandle<TAsset> handle,
|
||||
OwnerToken owner,
|
||||
ResidencyPriority priority,
|
||||
long frame)
|
||||
{
|
||||
AssetSlot asset = GetAsset(handle);
|
||||
OwnerSlot ownerSlot = GetOwner(owner);
|
||||
if (asset.State is AssetResidencyState.Retiring
|
||||
or AssetResidencyState.Cancelled
|
||||
or AssetResidencyState.Missing
|
||||
or AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot acquire an asset in {asset.State} state.");
|
||||
}
|
||||
if (ownerSlot.WorldGeneration != asset.WorldGeneration
|
||||
&& ownerSlot.Kind is not ResidencyOwnerKind.Session
|
||||
and not ResidencyOwnerKind.UserInterface
|
||||
and not ResidencyOwnerKind.Tooling)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A world-scoped owner cannot acquire an asset from another generation.");
|
||||
}
|
||||
|
||||
var lease = new AssetLease<TAsset>(handle, owner);
|
||||
if (asset.Owners.Add(owner))
|
||||
ownerSlot.Assets.Add(handle.Untyped);
|
||||
asset.Priority = Max(asset.Priority, priority);
|
||||
asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame);
|
||||
return lease;
|
||||
}
|
||||
|
||||
public bool Release<TAsset>(AssetLease<TAsset> lease)
|
||||
{
|
||||
if (!TryGetAsset(lease.Handle.Untyped, out AssetSlot asset)
|
||||
|| !TryGetOwner(lease.Owner, out OwnerSlot owner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool removed = asset.Owners.Remove(lease.Owner);
|
||||
if (removed)
|
||||
{
|
||||
owner.Assets.Remove(lease.Handle.Untyped);
|
||||
if (asset.Owners.Count == 0)
|
||||
asset.Priority = ResidencyPriority.Speculative;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public bool Touch<TAsset>(
|
||||
AssetHandle<TAsset> handle,
|
||||
ResidencyPriority priority,
|
||||
long frame)
|
||||
{
|
||||
if (!TryGetAsset(handle.Untyped, out AssetSlot asset))
|
||||
return false;
|
||||
asset.Priority = Max(asset.Priority, priority);
|
||||
asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Transition<TAsset>(
|
||||
AssetHandle<TAsset> handle,
|
||||
AssetResidencyState next,
|
||||
ResidencyCharges charges,
|
||||
long frame,
|
||||
string? failure = null) =>
|
||||
Transition(handle.Untyped, next, charges, frame, failure);
|
||||
|
||||
public bool CompleteRetirement<TAsset>(AssetHandle<TAsset> handle) =>
|
||||
CompleteRetirement(handle.Untyped);
|
||||
|
||||
public bool CompleteRetirement(AssetReference asset)
|
||||
{
|
||||
if (!TryGetAsset(asset, out AssetSlot slot))
|
||||
return false;
|
||||
if (slot.State != AssetResidencyState.Retiring)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only a retiring asset can become absent; current={slot.State}.");
|
||||
}
|
||||
if (slot.Owners.Count != 0)
|
||||
throw new InvalidOperationException(
|
||||
"A retiring asset retained logical owners.");
|
||||
|
||||
RecycleAsset(asset.Index, slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetSnapshot<TAsset>(
|
||||
AssetHandle<TAsset> handle,
|
||||
out ResidencyEntrySnapshot snapshot) =>
|
||||
TryGetSnapshot(handle.Untyped, out snapshot);
|
||||
|
||||
public int Drain(ResidencyObservationJournal journal)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(journal);
|
||||
_observationScratch.Clear();
|
||||
journal.DrainTo(_observationScratch);
|
||||
_observationScratch.Sort(static (left, right) =>
|
||||
{
|
||||
int byIndex = left.Asset.Index.CompareTo(right.Asset.Index);
|
||||
if (byIndex != 0)
|
||||
return byIndex;
|
||||
return left.Kind.CompareTo(right.Kind);
|
||||
});
|
||||
|
||||
int applied = 0;
|
||||
for (int i = 0; i < _observationScratch.Count; i++)
|
||||
{
|
||||
ResidencyObservation observation = _observationScratch[i];
|
||||
bool accepted = observation.Kind switch
|
||||
{
|
||||
ResidencyObservationKind.Touch =>
|
||||
Touch(
|
||||
observation.Asset,
|
||||
observation.Priority,
|
||||
observation.Frame),
|
||||
ResidencyObservationKind.Transition =>
|
||||
Transition(
|
||||
observation.Asset,
|
||||
observation.State,
|
||||
observation.Charges,
|
||||
observation.Frame,
|
||||
observation.Failure),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observation),
|
||||
observation.Kind,
|
||||
"unknown residency observation kind"),
|
||||
};
|
||||
if (accepted)
|
||||
applied++;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ResidencyTrimRequest> SelectEvictions(
|
||||
ResidencyDomain domain,
|
||||
long bytesToRelease,
|
||||
int maximumCount)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(bytesToRelease);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maximumCount);
|
||||
if (bytesToRelease == 0 || maximumCount == 0)
|
||||
return Array.Empty<ResidencyTrimRequest>();
|
||||
|
||||
List<(uint Index, AssetSlot Slot)> candidates = [];
|
||||
for (uint index = 1; index < _assets.Count; index++)
|
||||
{
|
||||
AssetSlot? slot = _assets[checked((int)index)];
|
||||
if (slot is null
|
||||
|| slot.Key.Domain != domain
|
||||
|| slot.Owners.Count != 0
|
||||
|| slot.State is not (AssetResidencyState.Resident
|
||||
or AssetResidencyState.Prepared))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
candidates.Add((index, slot));
|
||||
}
|
||||
|
||||
candidates.Sort(static (left, right) =>
|
||||
{
|
||||
int order = left.Slot.Priority.CompareTo(right.Slot.Priority);
|
||||
if (order != 0)
|
||||
return order;
|
||||
order = left.Slot.LastUsedFrame.CompareTo(right.Slot.LastUsedFrame);
|
||||
if (order != 0)
|
||||
return order;
|
||||
order = left.Slot.RebuildCost.CompareTo(right.Slot.RebuildCost);
|
||||
return order != 0
|
||||
? order
|
||||
: left.Index.CompareTo(right.Index);
|
||||
});
|
||||
|
||||
List<ResidencyTrimRequest> requests = [];
|
||||
long selectedBytes = 0;
|
||||
for (int i = 0;
|
||||
i < candidates.Count
|
||||
&& requests.Count < maximumCount
|
||||
&& selectedBytes < bytesToRelease;
|
||||
i++)
|
||||
{
|
||||
(uint index, AssetSlot slot) = candidates[i];
|
||||
ResidencyCharges retiring = ToRetiringCharges(slot.Charges);
|
||||
var asset = new AssetReference(
|
||||
index,
|
||||
slot.Generation,
|
||||
slot.AssetType);
|
||||
Transition(
|
||||
asset,
|
||||
AssetResidencyState.Retiring,
|
||||
retiring,
|
||||
slot.LastUsedFrame);
|
||||
requests.Add(new ResidencyTrimRequest(asset, slot.Key, retiring));
|
||||
selectedBytes = checked(
|
||||
selectedBytes + Math.Max(
|
||||
1,
|
||||
retiring.RetiringBytes
|
||||
+ retiring.CommittedCpuBytes));
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
public ResidencySnapshot CaptureSnapshot()
|
||||
{
|
||||
_snapshotScratch.Clear();
|
||||
for (int i = 0; i < _domainSources.Count; i++)
|
||||
_snapshotScratch.Add(_domainSources[i].CaptureResidency());
|
||||
|
||||
ResidencyCharges totals = default;
|
||||
for (uint index = 1; index < _assets.Count; index++)
|
||||
{
|
||||
AssetSlot? slot = _assets[checked((int)index)];
|
||||
if (slot is not null)
|
||||
totals += slot.Charges;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _snapshotScratch.Count; i++)
|
||||
totals += _snapshotScratch[i].Charges;
|
||||
|
||||
return new ResidencySnapshot(_snapshotScratch.ToArray(), totals);
|
||||
}
|
||||
|
||||
private bool Transition(
|
||||
AssetReference asset,
|
||||
AssetResidencyState next,
|
||||
ResidencyCharges charges,
|
||||
long frame,
|
||||
string? failure = null)
|
||||
{
|
||||
if (!TryGetAsset(asset, out AssetSlot slot))
|
||||
return false;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(frame);
|
||||
charges.Validate();
|
||||
ValidateTransition(slot.State, next);
|
||||
ValidateCharges(next, charges);
|
||||
if (next == AssetResidencyState.Retiring && slot.Owners.Count != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An owned asset cannot enter retiring state.");
|
||||
}
|
||||
|
||||
slot.State = next;
|
||||
slot.Charges = charges;
|
||||
slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame);
|
||||
slot.Failure = next is AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed
|
||||
? failure
|
||||
: null;
|
||||
if (IsTerminal(next))
|
||||
DetachAllOwners(asset, slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Touch(
|
||||
AssetReference asset,
|
||||
ResidencyPriority priority,
|
||||
long frame)
|
||||
{
|
||||
if (!TryGetAsset(asset, out AssetSlot slot))
|
||||
return false;
|
||||
slot.Priority = Max(slot.Priority, priority);
|
||||
slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetSnapshot(
|
||||
AssetReference asset,
|
||||
out ResidencyEntrySnapshot snapshot)
|
||||
{
|
||||
if (!TryGetAsset(asset, out AssetSlot slot))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new ResidencyEntrySnapshot(
|
||||
asset,
|
||||
slot.Key,
|
||||
slot.State,
|
||||
slot.Priority,
|
||||
slot.WorldGeneration,
|
||||
slot.LastUsedFrame,
|
||||
slot.RebuildCost,
|
||||
slot.Owners.Count,
|
||||
slot.Charges,
|
||||
slot.Failure);
|
||||
return true;
|
||||
}
|
||||
|
||||
private AssetSlot GetAsset<TAsset>(AssetHandle<TAsset> handle) =>
|
||||
TryGetAsset(handle.Untyped, out AssetSlot slot)
|
||||
? slot
|
||||
: throw new InvalidOperationException("Asset handle is stale or invalid.");
|
||||
|
||||
private OwnerSlot GetOwner(OwnerToken owner) =>
|
||||
TryGetOwner(owner, out OwnerSlot slot)
|
||||
? slot
|
||||
: throw new InvalidOperationException("Owner token is stale or invalid.");
|
||||
|
||||
private bool TryGetAsset(
|
||||
AssetReference asset,
|
||||
out AssetSlot slot)
|
||||
{
|
||||
slot = null!;
|
||||
if (!asset.IsValid || asset.Index >= _assets.Count)
|
||||
return false;
|
||||
AssetSlot? candidate = _assets[checked((int)asset.Index)];
|
||||
if (candidate is null
|
||||
|| candidate.Generation != asset.Generation
|
||||
|| !candidate.AssetType.Equals(asset.AssetType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
slot = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetOwner(OwnerToken owner, out OwnerSlot slot)
|
||||
{
|
||||
slot = null!;
|
||||
if (!owner.IsValid || owner.Index >= _owners.Count)
|
||||
return false;
|
||||
OwnerSlot? candidate = _owners[checked((int)owner.Index)];
|
||||
if (candidate is null || candidate.Generation != owner.Generation)
|
||||
return false;
|
||||
slot = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RecycleAsset(uint index, AssetSlot slot)
|
||||
{
|
||||
if (slot.Owners.Count != 0)
|
||||
throw new InvalidOperationException(
|
||||
"An asset cannot be recycled while owners remain.");
|
||||
_assetByKey.Remove(new TypedAssetKey(slot.AssetType, slot.Key));
|
||||
_assets[checked((int)index)] = null;
|
||||
_freeAssets.Push(index);
|
||||
}
|
||||
|
||||
private void DetachAllOwners(AssetReference asset, AssetSlot slot)
|
||||
{
|
||||
if (slot.Owners.Count == 0)
|
||||
return;
|
||||
OwnerToken[] owners = slot.Owners.ToArray();
|
||||
for (int i = 0; i < owners.Length; i++)
|
||||
{
|
||||
if (TryGetOwner(owners[i], out OwnerSlot owner))
|
||||
owner.Assets.Remove(asset);
|
||||
}
|
||||
slot.Owners.Clear();
|
||||
}
|
||||
|
||||
private static uint AllocateIndex<T>(
|
||||
List<T?> slots,
|
||||
List<ushort> generations,
|
||||
Stack<uint> free)
|
||||
where T : class
|
||||
{
|
||||
if (free.TryPop(out uint reused))
|
||||
return reused;
|
||||
uint index = checked((uint)slots.Count);
|
||||
slots.Add(null);
|
||||
generations.Add(0);
|
||||
return index;
|
||||
}
|
||||
|
||||
private static ushort NextGeneration(
|
||||
List<ushort> generations,
|
||||
uint index)
|
||||
{
|
||||
ushort generation = unchecked(
|
||||
(ushort)(generations[checked((int)index)] + 1));
|
||||
if (generation == 0)
|
||||
generation = 1;
|
||||
generations[checked((int)index)] = generation;
|
||||
return generation;
|
||||
}
|
||||
|
||||
private static ResidencyPriority Max(
|
||||
ResidencyPriority left,
|
||||
ResidencyPriority right) =>
|
||||
left >= right ? left : right;
|
||||
|
||||
private static bool IsTerminal(AssetResidencyState state) =>
|
||||
state is AssetResidencyState.Cancelled
|
||||
or AssetResidencyState.Missing
|
||||
or AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed;
|
||||
|
||||
private static void ValidateTransition(
|
||||
AssetResidencyState current,
|
||||
AssetResidencyState next)
|
||||
{
|
||||
bool legal = current switch
|
||||
{
|
||||
AssetResidencyState.Requested =>
|
||||
next is AssetResidencyState.Prepared
|
||||
or AssetResidencyState.Cancelled
|
||||
or AssetResidencyState.Missing
|
||||
or AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed
|
||||
or AssetResidencyState.Retiring,
|
||||
AssetResidencyState.Prepared =>
|
||||
next is AssetResidencyState.UploadPending
|
||||
or AssetResidencyState.Retiring,
|
||||
AssetResidencyState.UploadPending =>
|
||||
next is AssetResidencyState.Resident
|
||||
or AssetResidencyState.Prepared
|
||||
or AssetResidencyState.Retiring
|
||||
or AssetResidencyState.Cancelled
|
||||
or AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed,
|
||||
AssetResidencyState.Resident =>
|
||||
next is AssetResidencyState.Retiring,
|
||||
_ => false,
|
||||
};
|
||||
if (!legal)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Illegal residency transition {current} -> {next}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCharges(
|
||||
AssetResidencyState state,
|
||||
ResidencyCharges charges)
|
||||
{
|
||||
if (state is AssetResidencyState.Cancelled
|
||||
or AssetResidencyState.Missing
|
||||
or AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed)
|
||||
{
|
||||
if (!charges.IsZero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Terminal state {state} cannot retain residency charges.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == AssetResidencyState.Retiring
|
||||
&& (charges.GpuRequestedBytes != 0
|
||||
|| charges.GpuResidentBytes != 0
|
||||
|| charges.StagingBytes != 0))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Retiring charges must move transient and resident GPU bytes into RetiringBytes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static ResidencyCharges ToRetiringCharges(
|
||||
ResidencyCharges current) =>
|
||||
new(
|
||||
LogicalBytes: current.LogicalBytes,
|
||||
CpuPreparedBytes: current.CpuPreparedBytes,
|
||||
DecodedBytes: current.DecodedBytes,
|
||||
PinnedBytes: current.PinnedBytes,
|
||||
RetiringBytes: checked(
|
||||
current.GpuRequestedBytes
|
||||
+ current.GpuResidentBytes
|
||||
+ current.RetiringBytes));
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AcDream.App.Rendering.Residency;
|
||||
|
||||
/// <summary>
|
||||
/// Worker-safe, coalescing observation seam. The single update-thread owner
|
||||
/// drains immutable observations into <see cref="ResidencyManager"/>.
|
||||
/// </summary>
|
||||
internal sealed class ResidencyObservationJournal
|
||||
{
|
||||
private readonly ConcurrentDictionary<
|
||||
(AssetReference Asset, ResidencyObservationKind Kind),
|
||||
ResidencyObservation> _pending = new();
|
||||
private readonly int _maximumEntries;
|
||||
|
||||
public ResidencyObservationJournal(int maximumEntries)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1);
|
||||
_maximumEntries = maximumEntries;
|
||||
}
|
||||
|
||||
public int Count => _pending.Count;
|
||||
|
||||
public bool TryPublish(in ResidencyObservation observation)
|
||||
{
|
||||
if (!observation.Asset.IsValid)
|
||||
throw new ArgumentException(
|
||||
"Residency observations require a valid asset reference.",
|
||||
nameof(observation));
|
||||
observation.Charges.Validate();
|
||||
|
||||
var key = (observation.Asset, observation.Kind);
|
||||
while (true)
|
||||
{
|
||||
if (_pending.TryGetValue(key, out ResidencyObservation current))
|
||||
{
|
||||
// Network/decode callbacks may arrive concurrently. Preserve
|
||||
// the newest immutable fact instead of letting a late older
|
||||
// callback overwrite it.
|
||||
if (observation.Frame < current.Frame)
|
||||
return true;
|
||||
if (_pending.TryUpdate(key, observation, current))
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_pending.Count >= _maximumEntries)
|
||||
return false;
|
||||
if (_pending.TryAdd(key, observation))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int DrainTo(List<ResidencyObservation> destination)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
int start = destination.Count;
|
||||
foreach (KeyValuePair<
|
||||
(AssetReference Asset, ResidencyObservationKind Kind),
|
||||
ResidencyObservation> pair in _pending)
|
||||
{
|
||||
// Remove only the exact value enumerated. If a worker replaced it
|
||||
// after enumeration, the newer value remains for the next drain.
|
||||
if (((ICollection<KeyValuePair<
|
||||
(AssetReference Asset, ResidencyObservationKind Kind),
|
||||
ResidencyObservation>>)_pending).Remove(pair))
|
||||
{
|
||||
destination.Add(pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return destination.Count - start;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
|
||||
namespace AcDream.App;
|
||||
|
||||
|
|
@ -50,7 +51,8 @@ public sealed record RuntimeOptions(
|
|||
string? UiProbeScript,
|
||||
string? AutomationArtifactDirectory,
|
||||
float FogStartMultiplier,
|
||||
float FogEndMultiplier)
|
||||
float FogEndMultiplier,
|
||||
ResidencyBudgetOptions ResidencyBudgets)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -107,7 +109,8 @@ public sealed record RuntimeOptions(
|
|||
AutomationArtifactDirectory:
|
||||
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
|
||||
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
|
||||
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f);
|
||||
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
|
||||
ResidencyBudgets: ResidencyBudgetOptions.Parse(env));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
using AcDream.App.Rendering.Residency;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Residency;
|
||||
|
||||
public sealed class ResidencyManagerTests
|
||||
{
|
||||
private sealed class MeshAsset;
|
||||
private sealed class TextureAsset;
|
||||
|
||||
[Fact]
|
||||
public void HandlesAndOwnersReserveZeroAsInvalid()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 42,
|
||||
worldGeneration: 7);
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 0x01020304),
|
||||
ResidencyPriority.Visible,
|
||||
worldGeneration: 7,
|
||||
frame: 10);
|
||||
|
||||
Assert.True(owner.IsValid);
|
||||
Assert.True(asset.IsValid);
|
||||
Assert.NotEqual(0u, owner.Index);
|
||||
Assert.NotEqual(0u, asset.Index);
|
||||
Assert.NotEqual((ushort)0, owner.Generation);
|
||||
Assert.NotEqual((ushort)0, asset.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateAcquireIsIdempotentAndSingleReleaseOwnsLease()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 42,
|
||||
worldGeneration: 7);
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1),
|
||||
ResidencyPriority.Near,
|
||||
worldGeneration: 7,
|
||||
frame: 1);
|
||||
|
||||
AssetLease<MeshAsset> first = manager.Acquire(
|
||||
asset,
|
||||
owner,
|
||||
ResidencyPriority.Visible,
|
||||
frame: 2);
|
||||
AssetLease<MeshAsset> duplicate = manager.Acquire(
|
||||
asset,
|
||||
owner,
|
||||
ResidencyPriority.Visible,
|
||||
frame: 3);
|
||||
|
||||
Assert.Equal(first, duplicate);
|
||||
Assert.True(manager.TryGetSnapshot(asset, out var snapshot));
|
||||
Assert.Equal(1, snapshot.OwnerCount);
|
||||
Assert.Equal(3, snapshot.LastUsedFrame);
|
||||
Assert.True(manager.Release(first));
|
||||
Assert.False(manager.Release(duplicate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiringAssetCannotBeAcquiredAndRecyclesWithNewGeneration()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
ResidencyAssetKey key =
|
||||
new(ResidencyDomain.ObjectMeshes, 0x02000001);
|
||||
AssetHandle<MeshAsset> first = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.Far,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
manager.Transition(
|
||||
first,
|
||||
AssetResidencyState.Prepared,
|
||||
new ResidencyCharges(CpuPreparedBytes: 64),
|
||||
frame: 1);
|
||||
manager.Transition(
|
||||
first,
|
||||
AssetResidencyState.Retiring,
|
||||
new ResidencyCharges(),
|
||||
frame: 2);
|
||||
|
||||
Assert.True(manager.CompleteRetirement(first));
|
||||
AssetHandle<MeshAsset> replacement = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.Visible,
|
||||
worldGeneration: 2,
|
||||
frame: 3);
|
||||
|
||||
Assert.Equal(first.Index, replacement.Index);
|
||||
Assert.NotEqual(first.Generation, replacement.Generation);
|
||||
Assert.False(manager.Touch(first, ResidencyPriority.Visible, frame: 4));
|
||||
Assert.False(manager.CompleteRetirement(first));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)AssetResidencyState.Cancelled)]
|
||||
[InlineData((int)AssetResidencyState.Missing)]
|
||||
[InlineData((int)AssetResidencyState.Corrupt)]
|
||||
[InlineData((int)AssetResidencyState.Failed)]
|
||||
public void TerminalCompletionDetachesOwnersAndRestartsAsNewGeneration(
|
||||
int terminalValue)
|
||||
{
|
||||
var terminal = (AssetResidencyState)terminalValue;
|
||||
var manager = new ResidencyManager();
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Landblock,
|
||||
logicalId: 0x1234,
|
||||
worldGeneration: 5);
|
||||
ResidencyAssetKey key =
|
||||
new(ResidencyDomain.PreparedMeshCpu, 7);
|
||||
AssetHandle<MeshAsset> first = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.DestinationCritical,
|
||||
worldGeneration: 5,
|
||||
frame: 0);
|
||||
manager.Acquire(
|
||||
first,
|
||||
owner,
|
||||
ResidencyPriority.DestinationCritical,
|
||||
frame: 0);
|
||||
|
||||
manager.Transition(
|
||||
first,
|
||||
terminal,
|
||||
ResidencyCharges.Zero,
|
||||
frame: 1,
|
||||
failure: terminal is AssetResidencyState.Corrupt
|
||||
or AssetResidencyState.Failed
|
||||
? "fixture"
|
||||
: null);
|
||||
Assert.True(manager.TryGetSnapshot(first, out var terminalSnapshot));
|
||||
Assert.Equal(0, terminalSnapshot.OwnerCount);
|
||||
|
||||
AssetHandle<MeshAsset> replacement = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.Near,
|
||||
worldGeneration: 5,
|
||||
frame: 2);
|
||||
Assert.Equal(first.Index, replacement.Index);
|
||||
Assert.NotEqual(first.Generation, replacement.Generation);
|
||||
Assert.False(manager.Release(new AssetLease<MeshAsset>(first, owner)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiringRejectsLiveOwners()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 1,
|
||||
worldGeneration: 1);
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1),
|
||||
ResidencyPriority.Visible,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
manager.Acquire(asset, owner, ResidencyPriority.Visible, frame: 0);
|
||||
manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Prepared,
|
||||
new ResidencyCharges(CpuPreparedBytes: 10),
|
||||
frame: 1);
|
||||
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
|
||||
() => manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Retiring,
|
||||
ResidencyCharges.Zero,
|
||||
frame: 2));
|
||||
|
||||
Assert.Contains("owned asset", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IllegalTransitionAndTerminalChargesFailLoudly()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1),
|
||||
ResidencyPriority.Far,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Resident,
|
||||
new ResidencyCharges(GpuResidentBytes: 1),
|
||||
frame: 1));
|
||||
Assert.Throws<InvalidOperationException>(() => manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Failed,
|
||||
new ResidencyCharges(CpuPreparedBytes: 1),
|
||||
frame: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnerGenerationPreventsStaleReleaseOfReplacementOwner()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
OwnerToken first = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 1,
|
||||
worldGeneration: 1);
|
||||
Assert.True(manager.RetireOwner(first));
|
||||
OwnerToken replacement = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 2,
|
||||
worldGeneration: 1);
|
||||
|
||||
Assert.Equal(first.Index, replacement.Index);
|
||||
Assert.NotEqual(first.Generation, replacement.Generation);
|
||||
Assert.False(manager.RetireOwner(first));
|
||||
Assert.Equal(1, manager.ActiveOwnerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldScopedOwnerCannotCrossGeneration()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 1,
|
||||
worldGeneration: 2);
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1),
|
||||
ResidencyPriority.Near,
|
||||
worldGeneration: 3,
|
||||
frame: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => manager.Acquire(
|
||||
asset,
|
||||
owner,
|
||||
ResidencyPriority.Near,
|
||||
frame: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvictionIsOwnerSafePriorityThenAgeThenCostThenIndex()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
AssetHandle<MeshAsset> farOldExpensive = Resident(
|
||||
manager, 1, ResidencyPriority.Far, frame: 1, bytes: 10, rebuild: 9);
|
||||
AssetHandle<MeshAsset> speculativeNew = Resident(
|
||||
manager, 2, ResidencyPriority.Speculative, frame: 9, bytes: 20, rebuild: 9);
|
||||
AssetHandle<MeshAsset> farOldCheap = Resident(
|
||||
manager, 3, ResidencyPriority.Far, frame: 1, bytes: 30, rebuild: 1);
|
||||
OwnerToken owner = manager.CreateOwner(
|
||||
ResidencyOwnerKind.Entity,
|
||||
logicalId: 9,
|
||||
worldGeneration: 1);
|
||||
manager.Acquire(
|
||||
speculativeNew,
|
||||
owner,
|
||||
ResidencyPriority.Visible,
|
||||
frame: 10);
|
||||
|
||||
IReadOnlyList<ResidencyTrimRequest> requests =
|
||||
manager.SelectEvictions(
|
||||
ResidencyDomain.ObjectMeshes,
|
||||
bytesToRelease: 35,
|
||||
maximumCount: 3);
|
||||
|
||||
Assert.Collection(
|
||||
requests,
|
||||
first => Assert.Equal(farOldCheap.Untyped, first.Asset),
|
||||
second => Assert.Equal(farOldExpensive.Untyped, second.Asset));
|
||||
Assert.DoesNotContain(
|
||||
requests,
|
||||
request => request.Asset == speculativeNew.Untyped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JournalCoalescesAndStaleObservationCannotReviveReplacement()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
var journal = new ResidencyObservationJournal(maximumEntries: 4);
|
||||
ResidencyAssetKey key =
|
||||
new(ResidencyDomain.PreparedMeshCpu, 4);
|
||||
AssetHandle<MeshAsset> first = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.Far,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
Assert.True(journal.TryPublish(new ResidencyObservation(
|
||||
first.Untyped,
|
||||
ResidencyObservationKind.Transition,
|
||||
AssetResidencyState.Prepared,
|
||||
new ResidencyCharges(CpuPreparedBytes: 10),
|
||||
ResidencyPriority.Far,
|
||||
Frame: 1)));
|
||||
Assert.True(journal.TryPublish(new ResidencyObservation(
|
||||
first.Untyped,
|
||||
ResidencyObservationKind.Transition,
|
||||
AssetResidencyState.Cancelled,
|
||||
ResidencyCharges.Zero,
|
||||
ResidencyPriority.Far,
|
||||
Frame: 2)));
|
||||
Assert.Equal(1, journal.Count);
|
||||
Assert.Equal(1, manager.Drain(journal));
|
||||
|
||||
AssetHandle<MeshAsset> replacement = manager.Request<MeshAsset>(
|
||||
key,
|
||||
ResidencyPriority.Visible,
|
||||
worldGeneration: 2,
|
||||
frame: 3);
|
||||
Assert.True(journal.TryPublish(new ResidencyObservation(
|
||||
first.Untyped,
|
||||
ResidencyObservationKind.Transition,
|
||||
AssetResidencyState.Prepared,
|
||||
new ResidencyCharges(CpuPreparedBytes: 10),
|
||||
ResidencyPriority.Far,
|
||||
Frame: 4)));
|
||||
|
||||
Assert.Equal(0, manager.Drain(journal));
|
||||
Assert.True(manager.TryGetSnapshot(replacement, out var snapshot));
|
||||
Assert.Equal(AssetResidencyState.Requested, snapshot.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JournalHasExplicitBoundForDistinctObservations()
|
||||
{
|
||||
var manager = new ResidencyManager();
|
||||
var journal = new ResidencyObservationJournal(maximumEntries: 1);
|
||||
AssetHandle<MeshAsset> first = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.PreparedMeshCpu, 1),
|
||||
ResidencyPriority.Far,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
AssetHandle<TextureAsset> second = manager.Request<TextureAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.StandaloneTextures, 2),
|
||||
ResidencyPriority.Far,
|
||||
worldGeneration: 1,
|
||||
frame: 0);
|
||||
|
||||
Assert.True(journal.TryPublish(Touch(first, frame: 1)));
|
||||
Assert.False(journal.TryPublish(Touch(second, frame: 1)));
|
||||
Assert.Equal(1, journal.Count);
|
||||
}
|
||||
|
||||
private static AssetHandle<MeshAsset> Resident(
|
||||
ResidencyManager manager,
|
||||
ulong id,
|
||||
ResidencyPriority priority,
|
||||
long frame,
|
||||
long bytes,
|
||||
long rebuild)
|
||||
{
|
||||
AssetHandle<MeshAsset> asset = manager.Request<MeshAsset>(
|
||||
new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, id),
|
||||
priority,
|
||||
worldGeneration: 1,
|
||||
frame,
|
||||
rebuild);
|
||||
manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Prepared,
|
||||
new ResidencyCharges(CpuPreparedBytes: bytes),
|
||||
frame);
|
||||
manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.UploadPending,
|
||||
new ResidencyCharges(
|
||||
StagingBytes: bytes,
|
||||
GpuRequestedBytes: bytes),
|
||||
frame);
|
||||
manager.Transition(
|
||||
asset,
|
||||
AssetResidencyState.Resident,
|
||||
new ResidencyCharges(GpuResidentBytes: bytes),
|
||||
frame);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private static ResidencyObservation Touch<T>(
|
||||
AssetHandle<T> asset,
|
||||
long frame) =>
|
||||
new(
|
||||
asset.Untyped,
|
||||
ResidencyObservationKind.Touch,
|
||||
AssetResidencyState.Absent,
|
||||
ResidencyCharges.Zero,
|
||||
ResidencyPriority.Visible,
|
||||
frame);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
|
||||
namespace AcDream.App.Tests;
|
||||
|
||||
|
|
@ -11,6 +12,61 @@ namespace AcDream.App.Tests;
|
|||
/// </summary>
|
||||
public sealed class RuntimeOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResidencyBudgetsPreserveCurrentProductionDefaults()
|
||||
{
|
||||
RuntimeOptions options = RuntimeOptions.Parse(
|
||||
"D:\\dat",
|
||||
_ => null);
|
||||
|
||||
Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResidencyBudgetOverridesAreTypedMebibytesAndCounts()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["ACDREAM_RESIDENCY_MESH_GPU_MIB"] = "768",
|
||||
["ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"] = "72",
|
||||
["ACDREAM_RESIDENCY_ANIMATION_MIB"] = "48",
|
||||
["ACDREAM_RESIDENCY_ANIMATION_ENTRIES"] = "300",
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.Parse(
|
||||
"D:\\dat",
|
||||
name => values.GetValueOrDefault(name));
|
||||
|
||||
Assert.Equal(
|
||||
768 * ResidencyBudgetOptions.MiB,
|
||||
options.ResidencyBudgets.ObjectMeshGpuBytes);
|
||||
Assert.Equal(
|
||||
72,
|
||||
options.ResidencyBudgets.ObjectMeshUnownedEntries);
|
||||
Assert.Equal(
|
||||
48 * ResidencyBudgetOptions.MiB,
|
||||
options.ResidencyBudgets.AnimationBytes);
|
||||
Assert.Equal(300, options.ResidencyBudgets.AnimationEntries);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("bad")]
|
||||
[InlineData("8796093022208")]
|
||||
public void InvalidResidencyBudgetFallsBackToCurrentDefault(string value)
|
||||
{
|
||||
RuntimeOptions options = RuntimeOptions.Parse(
|
||||
"D:\\dat",
|
||||
name => name == "ACDREAM_RESIDENCY_MESH_GPU_MIB"
|
||||
? value
|
||||
: null);
|
||||
|
||||
Assert.Equal(
|
||||
ResidencyBudgetOptions.Default.ObjectMeshGpuBytes,
|
||||
options.ResidencyBudgets.ObjectMeshGpuBytes);
|
||||
}
|
||||
|
||||
private const string AnyDatDir = "C:/Users/test/dats";
|
||||
|
||||
private static Func<string, string?> Env(Dictionary<string, string?> values)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue