fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -23,5 +23,14 @@ public sealed class AcSurfaceMetadataTable
|
|||
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
|
||||
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
|
||||
|
||||
public void Remove(ulong gfxObjId)
|
||||
{
|
||||
foreach ((ulong Id, int SurfaceIndex) key in _table.Keys)
|
||||
{
|
||||
if (key.Id == gfxObjId)
|
||||
_table.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear() => _table.Clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ internal sealed class ContiguousRangeAllocator
|
|||
public int HighWaterMark { get; private set; }
|
||||
public int Free => Capacity - Used;
|
||||
public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
|
||||
public int TrailingFreeLength =>
|
||||
_free.Count != 0 && _free[^1].End == Capacity
|
||||
? _free[^1].Length
|
||||
: 0;
|
||||
|
||||
public bool TryAllocate(int length, out MeshBufferRange allocation)
|
||||
{
|
||||
|
|
@ -73,6 +77,30 @@ internal sealed class ContiguousRangeAllocator
|
|||
InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an unused tail after the matching physical GPU buffer has been
|
||||
/// replaced. Live offsets are unchanged, so no render-data rewrite is
|
||||
/// required.
|
||||
/// </summary>
|
||||
public void Shrink(int newCapacity)
|
||||
{
|
||||
if (newCapacity <= 0 || newCapacity >= Capacity)
|
||||
throw new ArgumentOutOfRangeException(nameof(newCapacity));
|
||||
if (newCapacity < HighWaterMark)
|
||||
throw new InvalidOperationException("Cannot trim a GPU arena through a live allocation.");
|
||||
|
||||
MeshBufferRange tail = _free.Count == 0 ? default : _free[^1];
|
||||
if (tail.End != Capacity || tail.Offset > newCapacity)
|
||||
throw new InvalidOperationException("The requested GPU arena tail is not wholly free.");
|
||||
|
||||
if (tail.Offset == newCapacity)
|
||||
_free.RemoveAt(_free.Count - 1);
|
||||
else
|
||||
_free[^1] = new MeshBufferRange(tail.Offset, newCapacity - tail.Offset);
|
||||
Capacity = newCapacity;
|
||||
RecalculateHighWaterMark();
|
||||
}
|
||||
|
||||
public void Release(MeshBufferRange allocation)
|
||||
{
|
||||
if (allocation.Length <= 0
|
||||
|
|
@ -84,6 +112,7 @@ internal sealed class ContiguousRangeAllocator
|
|||
|
||||
InsertAndCoalesce(allocation);
|
||||
Used = checked(Used - allocation.Length);
|
||||
RecalculateHighWaterMark();
|
||||
}
|
||||
|
||||
private void InsertAndCoalesce(MeshBufferRange released)
|
||||
|
|
@ -114,4 +143,11 @@ internal sealed class ContiguousRangeAllocator
|
|||
|
||||
_free.Insert(index, new MeshBufferRange(start, end - start));
|
||||
}
|
||||
|
||||
private void RecalculateHighWaterMark()
|
||||
{
|
||||
HighWaterMark = _free.Count != 0 && _free[^1].End == Capacity
|
||||
? _free[^1].Offset
|
||||
: Capacity;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,21 @@ using AcDream.Core.World;
|
|||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// The exact owner is still inside a reference transition, so its requested
|
||||
/// logical removal has been queued and must be retried by the live-entity
|
||||
/// teardown owner after the transition unwinds.
|
||||
/// </summary>
|
||||
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
|
||||
: InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
|
||||
|
||||
/// <summary>
|
||||
/// Routes server-spawned (<c>CreateObject</c>) entities through the
|
||||
/// per-instance rendering path. Server entities always carry per-instance
|
||||
/// customizations (palette overrides, texture changes, part swaps) that
|
||||
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
|
||||
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
|
||||
/// path which already hash-keys overrides for caching.
|
||||
/// customizations (palette overrides, texture changes, part swaps). Shared
|
||||
/// surfaces use the WB atlas while per-instance texture composites are owned
|
||||
/// by the live entity and released with it.
|
||||
///
|
||||
/// <para>
|
||||
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
|
||||
|
|
@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
|
||||
/// non-null, the adapter calls
|
||||
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
|
||||
/// once per surface id that is known at spawn time (those on
|
||||
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
|
||||
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
|
||||
/// dispatcher (Task 22) on first use — that matches the existing
|
||||
/// <c>StaticMeshRenderer</c> behavior.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Sequencer factory</b>: the adapter is constructed with a
|
||||
/// <c>Func<WorldEntity, AnimationSequencer></c> factory so tests can
|
||||
/// inject a stub without needing a live DatCollection or MotionTable.
|
||||
|
|
@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </summary>
|
||||
public sealed class EntitySpawnAdapter
|
||||
{
|
||||
private readonly ITextureCachePerInstance _textureCache;
|
||||
private readonly IEntityTextureLifetime _textureLifetime;
|
||||
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
|
||||
private readonly IWbMeshAdapter? _meshAdapter;
|
||||
|
||||
// Per-server-guid state. Written on OnCreate, released on OnRemove.
|
||||
// One logical owner per server GUID. Animated state survives projection
|
||||
// suspension, while the resident bit controls the shorter GPU-presentation
|
||||
// lifetime. The exact WorldEntity reference makes delayed visibility edges
|
||||
// from a displaced GUID generation harmless.
|
||||
// Single-threaded: called only from the render thread (same as GpuWorldState).
|
||||
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
|
||||
private readonly Dictionary<uint, Owner> _ownersByGuid = new();
|
||||
|
||||
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
|
||||
// so OnRemove can decrement each. Per-instance entities don't go through
|
||||
// LandblockSpawnAdapter, so without this their meshes would never load
|
||||
// (WB doesn't know they exist).
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
|
||||
private sealed class Owner(
|
||||
WorldEntity entity,
|
||||
AnimatedEntityState state,
|
||||
HashSet<ulong> meshIds)
|
||||
{
|
||||
public WorldEntity Entity { get; } = entity;
|
||||
public AnimatedEntityState State { get; } = state;
|
||||
public HashSet<ulong> MeshIds { get; set; } = meshIds;
|
||||
public HashSet<ulong> MeshReferencesHeld { get; } = new();
|
||||
public bool IsPresentationResident { get; set; }
|
||||
public bool TextureReleaseRequired { get; set; }
|
||||
public PresentationTransition Transition { get; set; }
|
||||
public bool RemovalPending { get; set; }
|
||||
|
||||
/// <param name="textureCache">
|
||||
/// Per-instance texture decode path. In production this is the
|
||||
/// <see cref="TextureCache"/> instance (which implements
|
||||
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
|
||||
public bool HasPresentationResources
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TextureReleaseRequired)
|
||||
return true;
|
||||
|
||||
return MeshReferencesHeld.Count != 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFullyResident
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsPresentationResident || !TextureReleaseRequired)
|
||||
return false;
|
||||
return MeshReferencesHeld.SetEquals(MeshIds);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFullySuspended =>
|
||||
!IsPresentationResident && !HasPresentationResources;
|
||||
}
|
||||
|
||||
private enum PresentationTransition
|
||||
{
|
||||
None,
|
||||
Resuming,
|
||||
Suspending,
|
||||
ChangingAppearance,
|
||||
}
|
||||
|
||||
/// <param name="textureLifetime">
|
||||
/// Per-entity texture lifetime owner. Production uses
|
||||
/// <see cref="TextureCache"/>; tests use a recording implementation.
|
||||
/// </param>
|
||||
/// <param name="sequencerFactory">
|
||||
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
|
||||
|
|
@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter
|
|||
/// returns a stub sequencer.
|
||||
/// </param>
|
||||
/// <param name="meshAdapter">
|
||||
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
|
||||
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
|
||||
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
|
||||
/// matching ref counts. When null, the adapter only tracks per-instance
|
||||
/// state without driving WB lifecycle (test mode + flag-off mode).
|
||||
/// Optional WB mesh adapter. When non-null, presentation residency
|
||||
/// registers each unique <c>MeshRef.GfxObjId</c> so WB background-loads
|
||||
/// the mesh data. Projection suspension or logical removal balances those
|
||||
/// references. When null, the adapter only tracks per-instance state.
|
||||
/// </param>
|
||||
public EntitySpawnAdapter(
|
||||
ITextureCachePerInstance textureCache,
|
||||
IEntityTextureLifetime textureLifetime,
|
||||
Func<WorldEntity, AnimationSequencer> sequencerFactory,
|
||||
IWbMeshAdapter? meshAdapter = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(textureCache);
|
||||
ArgumentNullException.ThrowIfNull(textureLifetime);
|
||||
ArgumentNullException.ThrowIfNull(sequencerFactory);
|
||||
_textureCache = textureCache;
|
||||
_textureLifetime = textureLifetime;
|
||||
_sequencerFactory = sequencerFactory;
|
||||
_meshAdapter = meshAdapter;
|
||||
}
|
||||
|
|
@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter
|
|||
// are handled by LandblockSpawnAdapter, not here.
|
||||
if (entity.ServerGuid == 0) return null;
|
||||
|
||||
// Pre-warm the per-instance texture cache for surfaces whose ids are
|
||||
// already known at spawn time (those appearing as keys in
|
||||
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
|
||||
// covered by SurfaceOverrides are decoded lazily by the draw
|
||||
// dispatcher on first use — consistent with StaticMeshRenderer.
|
||||
if (entity.PaletteOverride is { } paletteOverride)
|
||||
{
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (meshRef.SurfaceOverrides is null) continue;
|
||||
|
||||
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
|
||||
// meaning "no texture swap, just the palette override applies").
|
||||
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
|
||||
{
|
||||
_textureCache.GetOrUploadWithPaletteOverride(
|
||||
surfaceId,
|
||||
origTexOverride == 0 ? null : origTexOverride,
|
||||
paletteOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
|
||||
// rather than recomputing Position±5 per frame. Called here because
|
||||
// all entity-state initialization (position, rotation) is complete
|
||||
|
|
@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter
|
|||
foreach (var po in entity.PartOverrides)
|
||||
state.SetPartOverride(po.PartIndex, po.GfxObjId);
|
||||
|
||||
_stateByGuid[entity.ServerGuid] = state;
|
||||
HashSet<ulong> meshIds = _meshAdapter is null
|
||||
? []
|
||||
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
|
||||
|
||||
// Register each unique GfxObj id with WB so the meshes background-load.
|
||||
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
|
||||
// Includes both the entity's natural MeshRefs AND any server-sent
|
||||
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
|
||||
// Setup default and need their own mesh data uploaded.
|
||||
if (_meshAdapter is not null)
|
||||
// Construct the replacement completely before displacing a live owner.
|
||||
// Sequencer/appearance construction is allowed to fail; in that case
|
||||
// the prior GUID incarnation and its presentation references remain
|
||||
// valid. Retirement is also completed before replacement publication:
|
||||
// a failed texture or mesh release therefore leaves the prior owner in
|
||||
// the dictionary, with its per-resource progress available to retry.
|
||||
var replacementOwner = new Owner(entity, state, meshIds);
|
||||
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
|
||||
{
|
||||
var unique = new HashSet<ulong>();
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
foreach (var po in entity.PartOverrides)
|
||||
unique.Add((ulong)po.GfxObjId);
|
||||
if (displacedOwner.RemovalPending)
|
||||
{
|
||||
throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
|
||||
}
|
||||
|
||||
_meshIdsByGuid[entity.ServerGuid] = unique;
|
||||
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
|
||||
if (displacedOwner.Transition != PresentationTransition.None)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
|
||||
}
|
||||
|
||||
if (displacedOwner.HasPresentationResources)
|
||||
{
|
||||
if (!SuspendPresentation(displacedOwner))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
|
||||
}
|
||||
}
|
||||
|
||||
_ownersByGuid[entity.ServerGuid] = replacementOwner;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes only the shorter presentation/GPU lifetime of an already-created
|
||||
/// live entity. A visible projection acquires WB mesh references; suspension
|
||||
/// releases those references and every owner-scoped composite texture. The
|
||||
/// animated state remains registered and no create-time scripts are replayed.
|
||||
/// Duplicate edges and edges for an older incarnation of a reused server GUID
|
||||
/// are ignored. A failed release keeps the published resident state and the
|
||||
/// exact unfinished resources so the same edge can be retried safely.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when this call applied a residency edge.</returns>
|
||||
public bool SetPresentationResident(WorldEntity entity, bool resident)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|
||||
|| !ReferenceEquals(owner.Entity, entity)
|
||||
|| owner.RemovalPending
|
||||
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return resident
|
||||
? ResumePresentation(owner)
|
||||
: SuspendPresentation(owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the mesh-reference set for an in-place retail
|
||||
/// <c>SmartBox::UpdateVisualDesc</c> mutation. Added meshes are acquired
|
||||
/// before <paramref name="publishAppearance"/> makes the new appearance
|
||||
/// visible. Only then is the exact new mesh set published and superseded
|
||||
/// references released. A failed release remains represented by
|
||||
/// <see cref="Owner.MeshReferencesHeld"/> and is retried by the next
|
||||
/// residency edge or logical teardown.
|
||||
/// <paramref name="afterPublication"/> runs after that commit point but
|
||||
/// before retirement, so dependent pose/cache publication cannot leave the
|
||||
/// new entity appearance paired with rolled-back mesh ownership.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exact <see cref="WorldEntity"/> reference is the incarnation token.
|
||||
/// A delayed appearance callback from a GUID-reused owner is ignored. The
|
||||
/// method is render-thread only; a re-entrant request is rejected before
|
||||
/// its publication callback can mutate the active owner.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// <c>true</c> when the matching owner's appearance was published;
|
||||
/// otherwise <c>false</c> for a stale, removing, or re-entrant owner.
|
||||
/// </returns>
|
||||
public bool OnAppearanceChanged(
|
||||
WorldEntity entity,
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
IReadOnlyList<PartOverride> partOverrides,
|
||||
Action publishAppearance,
|
||||
Action? afterPublication = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(meshRefs);
|
||||
ArgumentNullException.ThrowIfNull(partOverrides);
|
||||
ArgumentNullException.ThrowIfNull(publishAppearance);
|
||||
|
||||
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|
||||
|| !ReferenceEquals(owner.Entity, entity)
|
||||
|| owner.RemovalPending
|
||||
|| owner.Transition != PresentationTransition.None)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<ulong> nextMeshIds = _meshAdapter is null
|
||||
? []
|
||||
: CollectMeshIds(meshRefs, partOverrides);
|
||||
owner.Transition = PresentationTransition.ChangingAppearance;
|
||||
var acquiredThisTransition = new List<ulong>();
|
||||
bool meshSetPublished = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (owner.IsPresentationResident && _meshAdapter is not null)
|
||||
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
|
||||
|
||||
publishAppearance();
|
||||
|
||||
// Publication is the commit point: every desired resident mesh is
|
||||
// owned before this assignment. Superseded references may remain
|
||||
// temporarily held if their release reports a retryable failure,
|
||||
// but they are no longer part of the published appearance set.
|
||||
owner.MeshIds = nextMeshIds;
|
||||
meshSetPublished = true;
|
||||
afterPublication?.Invoke();
|
||||
|
||||
List<Exception>? releaseFailures = owner.IsPresentationResident
|
||||
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
|
||||
: ReleaseAllMeshReferences(owner);
|
||||
if (releaseFailures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
|
||||
releaseFailures);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
|
||||
{
|
||||
// Acquisition failed before publication. Preserve the prior exact
|
||||
// mesh set and undo only references acquired by this transition.
|
||||
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
|
||||
owner,
|
||||
acquiredThisTransition);
|
||||
if (rollbackFailures is null)
|
||||
throw;
|
||||
|
||||
rollbackFailures.Insert(0, acquireOrPublicationFailure);
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
|
||||
rollbackFailures);
|
||||
}
|
||||
finally
|
||||
{
|
||||
owner.Transition = PresentationTransition.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
|
||||
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
|
||||
/// removed) are silently ignored.
|
||||
/// removed) are silently ignored. Cleanup failure leaves the owner registered
|
||||
/// and propagates the exception; a later call resumes its unfinished releases.
|
||||
/// </summary>
|
||||
public void OnRemove(uint serverGuid)
|
||||
{
|
||||
_stateByGuid.Remove(serverGuid);
|
||||
=> _ = TryRemove(serverGuid, expectedEntity: null);
|
||||
|
||||
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
|
||||
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
|
||||
{
|
||||
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|
||||
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
|
||||
{
|
||||
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
|
||||
_meshIdsByGuid.Remove(serverGuid);
|
||||
return false;
|
||||
}
|
||||
|
||||
owner.RemovalPending = true;
|
||||
if (owner.Transition != PresentationTransition.None)
|
||||
throw new EntityPresentationRemovalDeferredException(serverGuid);
|
||||
|
||||
// A resident owner still holds both mesh and potentially-lazy texture
|
||||
// resources. A suspended owner released them at the visibility edge,
|
||||
// so logical removal must not decrement or release a second time. Do
|
||||
// not remove the dictionary entry until cleanup succeeds: otherwise a
|
||||
// release exception would orphan its remaining references and make a
|
||||
// later OnRemove retry impossible.
|
||||
if (owner.HasPresentationResources && !SuspendPresentation(owner))
|
||||
return false;
|
||||
|
||||
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
|
||||
&& ReferenceEquals(current, owner))
|
||||
{
|
||||
_ownersByGuid.Remove(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases a logical owner only when <paramref name="entity"/> is the exact
|
||||
/// incarnation currently registered for its server GUID. This is the live
|
||||
/// runtime teardown entry point: a delayed callback from an older generation
|
||||
/// cannot remove a replacement that reused the same GUID.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when the matching owner was removed.</returns>
|
||||
public bool OnRemove(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
return TryRemove(entity.ServerGuid, entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter
|
|||
/// been removed.
|
||||
/// </summary>
|
||||
public AnimatedEntityState? GetState(uint serverGuid)
|
||||
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
|
||||
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|
||||
? owner.State
|
||||
: null;
|
||||
|
||||
private bool ResumePresentation(Owner owner)
|
||||
{
|
||||
if (owner.Transition != PresentationTransition.None)
|
||||
return false;
|
||||
|
||||
owner.Transition = PresentationTransition.Resuming;
|
||||
var acquiredThisTransition = new List<ulong>();
|
||||
bool desiredMeshSetAcquired = false;
|
||||
try
|
||||
{
|
||||
if (_meshAdapter is not null)
|
||||
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
|
||||
desiredMeshSetAcquired = true;
|
||||
|
||||
owner.TextureReleaseRequired = true;
|
||||
owner.IsPresentationResident = true;
|
||||
|
||||
List<Exception>? releaseFailures = ReleaseMeshReferencesOutside(
|
||||
owner,
|
||||
owner.MeshIds);
|
||||
if (releaseFailures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
|
||||
releaseFailures);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
|
||||
{
|
||||
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
|
||||
owner,
|
||||
acquiredThisTransition);
|
||||
if (rollbackFailures is null)
|
||||
throw;
|
||||
|
||||
rollbackFailures.Insert(0, acquireFailure);
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
|
||||
rollbackFailures);
|
||||
}
|
||||
finally
|
||||
{
|
||||
owner.Transition = PresentationTransition.None;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SuspendPresentation(Owner owner)
|
||||
{
|
||||
if (owner.Transition != PresentationTransition.None)
|
||||
return false;
|
||||
|
||||
// IsPresentationResident deliberately remains true until every release
|
||||
// succeeds. Transition prevents a re-entrant duplicate edge from
|
||||
// releasing the same resource twice, while the completion markers keep
|
||||
// partial progress retryable after an exception.
|
||||
owner.Transition = PresentationTransition.Suspending;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
if (owner.TextureReleaseRequired)
|
||||
{
|
||||
try
|
||||
{
|
||||
_textureLifetime.ReleaseOwner(owner.Entity.Id);
|
||||
owner.TextureReleaseRequired = false;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
List<Exception>? meshFailures = ReleaseAllMeshReferences(owner);
|
||||
if (meshFailures is not null)
|
||||
(failures ??= new List<Exception>()).AddRange(meshFailures);
|
||||
|
||||
if (failures is not null)
|
||||
{
|
||||
owner.Transition = PresentationTransition.None;
|
||||
throw new AggregateException(
|
||||
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
|
||||
failures);
|
||||
}
|
||||
|
||||
owner.IsPresentationResident = false;
|
||||
owner.Transition = PresentationTransition.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static HashSet<ulong> CollectMeshIds(
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
IReadOnlyList<PartOverride> partOverrides)
|
||||
{
|
||||
var unique = new HashSet<ulong>();
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
unique.Add(meshRefs[i].GfxObjId);
|
||||
for (int i = 0; i < partOverrides.Count; i++)
|
||||
unique.Add(partOverrides[i].GfxObjId);
|
||||
return unique;
|
||||
}
|
||||
|
||||
private void AcquireMissingMeshReferences(
|
||||
Owner owner,
|
||||
HashSet<ulong> desiredMeshIds,
|
||||
List<ulong> acquiredThisTransition)
|
||||
{
|
||||
if (_meshAdapter is null)
|
||||
return;
|
||||
|
||||
foreach (ulong meshId in desiredMeshIds)
|
||||
{
|
||||
if (owner.MeshReferencesHeld.Contains(meshId))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_meshAdapter.IncrementRefCount(meshId);
|
||||
owner.MeshReferencesHeld.Add(meshId);
|
||||
acquiredThisTransition.Add(meshId);
|
||||
}
|
||||
catch (MeshReferenceMutationException error)
|
||||
{
|
||||
if (error.MutationCommitted)
|
||||
{
|
||||
owner.MeshReferencesHeld.Add(meshId);
|
||||
acquiredThisTransition.Add(meshId);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Exception>? RollBackAcquiredMeshReferences(
|
||||
Owner owner,
|
||||
List<ulong> acquiredThisTransition)
|
||||
{
|
||||
if (_meshAdapter is null)
|
||||
return null;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ulong meshId = acquiredThisTransition[i];
|
||||
if (!owner.MeshReferencesHeld.Contains(meshId))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_meshAdapter.DecrementRefCount(meshId);
|
||||
owner.MeshReferencesHeld.Remove(meshId);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (error is MeshReferenceMutationException { MutationCommitted: true })
|
||||
owner.MeshReferencesHeld.Remove(meshId);
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private List<Exception>? ReleaseMeshReferencesOutside(
|
||||
Owner owner,
|
||||
HashSet<ulong> desiredMeshIds)
|
||||
{
|
||||
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
|
||||
return null;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
|
||||
foreach (ulong meshId in heldSnapshot)
|
||||
{
|
||||
if (desiredMeshIds.Contains(meshId))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_meshAdapter.DecrementRefCount(meshId);
|
||||
owner.MeshReferencesHeld.Remove(meshId);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (error is MeshReferenceMutationException { MutationCommitted: true })
|
||||
owner.MeshReferencesHeld.Remove(meshId);
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private List<Exception>? ReleaseAllMeshReferences(Owner owner)
|
||||
{
|
||||
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
|
||||
return null;
|
||||
|
||||
return ReleaseMeshReferencesOutside(owner, []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Starts CPU mesh extraction for a completed EnvCell build without publishing
|
||||
/// that build to the live renderer. ObjectMeshManager owns its thread-safe work
|
||||
/// queue; the render-thread commit remains a small atomic state replacement.
|
||||
/// Starts CPU mesh extraction after the completed EnvCell build has been
|
||||
/// published and its geometry ids have acquired render ownership. This order
|
||||
/// lets ObjectMeshManager cancel queued work as soon as a landblock leaves the
|
||||
/// current streaming generation; stale portal destinations never keep decoder
|
||||
/// jobs or surface lists alive.
|
||||
/// </summary>
|
||||
public static class EnvCellMeshPreparationScheduler
|
||||
{
|
||||
|
|
@ -11,8 +13,11 @@ public static class EnvCellMeshPreparationScheduler
|
|||
EnvCellLandblockBuild build,
|
||||
ObjectMeshManager meshManager)
|
||||
{
|
||||
var scheduled = new HashSet<ulong>();
|
||||
foreach (var shell in build.Shells)
|
||||
{
|
||||
if (!scheduled.Add(shell.GeometryId))
|
||||
continue;
|
||||
_ = meshManager.PrepareEnvCellGeomMeshDataAsync(
|
||||
shell.GeometryId,
|
||||
shell.EnvironmentId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,32 @@ namespace AcDream.App.Rendering.Wb {
|
|||
Device = device;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Always-on error boundary for resource transactions. Most render-path
|
||||
/// checks remain Debug-only because <c>glGetError</c> is a synchronous
|
||||
/// driver call; allocation/upload code must not publish CPU state after
|
||||
/// OpenGL reported OOM, context loss, or a rejected transfer in Release.
|
||||
/// Call exactly once before committing each transaction.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void ThrowOnResourceError(GL gl, string context) {
|
||||
GLEnum error = gl.GetError();
|
||||
if (error == GLEnum.NoError)
|
||||
return;
|
||||
|
||||
var errors = new System.Text.StringBuilder();
|
||||
do {
|
||||
if (errors.Length != 0)
|
||||
errors.Append(", ");
|
||||
errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')');
|
||||
error = gl.GetError();
|
||||
} while (error != GLEnum.NoError);
|
||||
|
||||
string message = $"OpenGL resource transaction failed: {errors}. Context: {context}";
|
||||
Logger?.LogError(message);
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static bool _loggedVersion = false;
|
||||
|
||||
|
|
@ -140,46 +166,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
return info.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates texture completeness for mipmapping
|
||||
/// </summary>
|
||||
public static bool ValidateTextureMipmapStatus(GL gl, GLEnum target, out string errorMessage) {
|
||||
try {
|
||||
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureWidth, out int width);
|
||||
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureHeight, out int height);
|
||||
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureInternalFormat, out int format);
|
||||
|
||||
if (width == 0 || height == 0) {
|
||||
errorMessage = "Texture has zero dimensions";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if format is valid for mipmap generation
|
||||
var internalFormat = (InternalFormat)format;
|
||||
if (IsCompressedFormat(internalFormat)) {
|
||||
errorMessage = $"Compressed format {internalFormat} does not support automatic mipmap generation";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = String.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
errorMessage = $"Exception during validation: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCompressedFormat(InternalFormat format) {
|
||||
return format == InternalFormat.CompressedRgbaS3TCDxt1Ext ||
|
||||
format == InternalFormat.CompressedRgbaS3TCDxt3Ext ||
|
||||
format == InternalFormat.CompressedRgbaS3TCDxt5Ext ||
|
||||
format == InternalFormat.CompressedRgbS3TCDxt1Ext ||
|
||||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt1Ext ||
|
||||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt3Ext ||
|
||||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt5Ext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs current OpenGL state for debugging
|
||||
/// </summary>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal file
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// A contiguous GPU-buffer suballocator whose released ranges become
|
||||
/// reusable only after the frame-retirement queue says that older draws have
|
||||
/// finished. This prevents <c>BufferSubData</c> from overwriting a range that
|
||||
/// an in-flight draw still reads.
|
||||
/// </summary>
|
||||
internal sealed class GpuRetiredRangeAllocator
|
||||
{
|
||||
private readonly ContiguousRangeAllocator _allocator;
|
||||
private readonly GpuRetirementLedger _retirementLedger;
|
||||
private readonly Dictionary<MeshBufferRange, RetryableGpuResourceRelease> _pendingReleases = [];
|
||||
private int _pendingReleaseCount;
|
||||
private int _pendingReleaseLength;
|
||||
|
||||
public GpuRetiredRangeAllocator(int capacity, IGpuResourceRetirementQueue retirement)
|
||||
{
|
||||
_allocator = new ContiguousRangeAllocator(capacity);
|
||||
_retirementLedger = new GpuRetirementLedger(
|
||||
retirement ?? throw new ArgumentNullException(nameof(retirement)));
|
||||
}
|
||||
|
||||
public int Capacity => _allocator.Capacity;
|
||||
public int Used => _allocator.Used;
|
||||
public int HighWaterMark => _allocator.HighWaterMark;
|
||||
public int LargestFreeRange => _allocator.LargestFreeRange;
|
||||
public int TrailingFreeLength => _allocator.TrailingFreeLength;
|
||||
public int PendingReleaseCount => _pendingReleaseCount;
|
||||
public int PendingReleaseLength => _pendingReleaseLength;
|
||||
|
||||
public bool TryAllocate(int length, out MeshBufferRange allocation) =>
|
||||
_allocator.TryAllocate(length, out allocation);
|
||||
|
||||
public void Grow(int newCapacity) => _allocator.Grow(newCapacity);
|
||||
|
||||
public void Shrink(int newCapacity) => _allocator.Shrink(newCapacity);
|
||||
|
||||
public void ReleaseAfterGpuUse(MeshBufferRange allocation)
|
||||
{
|
||||
if (_pendingReleases.TryGetValue(allocation, out RetryableGpuResourceRelease? pending))
|
||||
{
|
||||
// The caller retries this method when queue publication failed.
|
||||
// Re-publish the retained transaction instead of accounting for a
|
||||
// second logical release of the same physical range.
|
||||
try
|
||||
{
|
||||
_retirementLedger.RetryPendingPublication(pending);
|
||||
}
|
||||
catch when (pending.IsComplete)
|
||||
{
|
||||
// An immediate retirement queue may surface a wrapper failure
|
||||
// after the retained transaction itself fully committed.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int nextPendingCount = checked(_pendingReleaseCount + 1);
|
||||
int nextPendingLength = checked(_pendingReleaseLength + allocation.Length);
|
||||
var release = new RetryableGpuResourceRelease(
|
||||
() => _allocator.Release(allocation),
|
||||
() => _pendingReleaseCount = checked(_pendingReleaseCount - 1),
|
||||
() => _pendingReleaseLength = checked(
|
||||
_pendingReleaseLength - allocation.Length),
|
||||
() =>
|
||||
{
|
||||
if (!_pendingReleases.Remove(allocation))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"GPU buffer range retirement lost its ownership record.");
|
||||
}
|
||||
});
|
||||
_pendingReleases.Add(allocation, release);
|
||||
_pendingReleaseCount = nextPendingCount;
|
||||
_pendingReleaseLength = nextPendingLength;
|
||||
|
||||
try
|
||||
{
|
||||
_retirementLedger.Retire(release);
|
||||
}
|
||||
catch when (release.IsComplete)
|
||||
{
|
||||
// Completion is stronger than publication acknowledgement. The
|
||||
// physical range and its accounting already converged.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases a range that failed before it could be submitted in a draw.
|
||||
/// </summary>
|
||||
public void ReleaseUnsubmitted(MeshBufferRange allocation)
|
||||
{
|
||||
if (_pendingReleases.ContainsKey(allocation))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A GPU-submitted range cannot be released as unsubmitted.");
|
||||
}
|
||||
|
||||
_allocator.Release(allocation);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// without depending on dispatcher internals.
|
||||
/// </summary>
|
||||
internal readonly record struct GroupKey(
|
||||
uint Ibo,
|
||||
uint FirstIndex,
|
||||
int BaseVertex,
|
||||
int IndexCount,
|
||||
|
|
|
|||
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal file
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Logical-owner lifetime seam for per-entity texture composites. Retail
|
||||
/// <c>CSurface::Destroy</c> (0x005361F0) releases its current <c>ImgTex</c>;
|
||||
/// live-object teardown must do the same for modern bindless composites.
|
||||
/// </summary>
|
||||
public interface IEntityTextureLifetime
|
||||
{
|
||||
/// <summary>Release every composite acquired by one local entity id.</summary>
|
||||
void ReleaseOwner(uint localEntityId);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Seam interface over the per-instance palette-override decode path in
|
||||
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
|
||||
/// can be tested without a live GL context.
|
||||
/// </summary>
|
||||
public interface ITextureCachePerInstance
|
||||
{
|
||||
/// <summary>
|
||||
/// Decode (or return cached) the palette-overridden texture for
|
||||
/// <paramref name="surfaceId"/>. Delegates to
|
||||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
|
||||
/// production.
|
||||
/// </summary>
|
||||
uint GetOrUploadWithPaletteOverride(
|
||||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride);
|
||||
}
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the physical outcome when a mesh-reference callback cannot provide
|
||||
/// the normal strong exception guarantee. <see cref="MutationCommitted"/> lets
|
||||
/// a transactional owner reconcile its marker without guessing whether a
|
||||
/// throwing backend already changed the reference count.
|
||||
/// </summary>
|
||||
public sealed class MeshReferenceMutationException : Exception
|
||||
{
|
||||
public MeshReferenceMutationException(
|
||||
string message,
|
||||
bool mutationCommitted,
|
||||
Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
MutationCommitted = mutationCommitted;
|
||||
}
|
||||
|
||||
public bool MutationCommitted { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
|
||||
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
|
||||
|
|
@ -7,7 +27,17 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </summary>
|
||||
public interface IWbMeshAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Acquires one logical reference. A normal exception guarantees that no
|
||||
/// reference was acquired; <see cref="MeshReferenceMutationException"/>
|
||||
/// explicitly reports the exceptional backend case where it was committed.
|
||||
/// </summary>
|
||||
void IncrementRefCount(ulong id);
|
||||
|
||||
/// <summary>
|
||||
/// Releases one logical reference under the same committed-outcome contract
|
||||
/// as <see cref="IncrementRefCount"/>.
|
||||
/// </summary>
|
||||
void DecrementRefCount(ulong id);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -9,20 +9,24 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// entities (procedural / dat-hydrated, identified by
|
||||
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
|
||||
/// (per-instance tier) are skipped — those go through
|
||||
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
|
||||
/// <c>EntitySpawnAdapter</c> and the owner-scoped texture path
|
||||
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
|
||||
///
|
||||
/// <para>
|
||||
/// On load: walks the landblock's atlas-tier entities, collects unique
|
||||
/// GfxObj ids from their <c>MeshRefs</c>, calls
|
||||
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
|
||||
/// id without starting generic GfxObj decode. Snapshots both id-sets per
|
||||
/// landblock so unload can match the load 1:1.
|
||||
/// id without starting generic GfxObj decode. Each reference has an explicit
|
||||
/// desired/held marker so a throwing backend cannot make the logical snapshot
|
||||
/// disagree with the physical reference count.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
|
||||
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
|
||||
/// On unload: releases only references whose held marker is still set. A
|
||||
/// before-commit failure remains retryable; an after-commit
|
||||
/// <see cref="MeshReferenceMutationException"/> advances the marker before the
|
||||
/// exception is propagated. The registration is dropped only after every held
|
||||
/// reference has been released. Unknown / never-loaded landblocks no-op.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -42,15 +46,21 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </summary>
|
||||
public sealed class LandblockSpawnAdapter
|
||||
{
|
||||
private readonly IWbMeshAdapter _adapter;
|
||||
private sealed class ReferenceRegistration
|
||||
{
|
||||
public bool Desired;
|
||||
public bool Held;
|
||||
}
|
||||
|
||||
// Maps landblock id → unique GfxObj ids registered for that landblock.
|
||||
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
|
||||
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
|
||||
// than generic GfxObj loading, but still require explicit lifetime pins.
|
||||
// Keep their synthetic ids separate so registration uses the no-decode pin.
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
|
||||
private sealed class LandblockRegistration
|
||||
{
|
||||
public bool WantsLoaded;
|
||||
public Dictionary<ulong, ReferenceRegistration> Ordinary { get; } = new();
|
||||
public Dictionary<ulong, ReferenceRegistration> Prepared { get; } = new();
|
||||
}
|
||||
|
||||
private readonly IWbMeshAdapter _adapter;
|
||||
private readonly Dictionary<uint, LandblockRegistration> _registrations = new();
|
||||
|
||||
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
||||
{
|
||||
|
|
@ -81,33 +91,40 @@ public sealed class LandblockSpawnAdapter
|
|||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
}
|
||||
|
||||
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
|
||||
HashSet<ulong>? preparedIds = additionalReadinessIds is null
|
||||
? null
|
||||
: new HashSet<ulong>(additionalReadinessIds);
|
||||
|
||||
if (!_registrations.TryGetValue(landblock.LandblockId, out var registration))
|
||||
{
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
registration = new LandblockRegistration { WantsLoaded = true };
|
||||
_registrations.Add(landblock.LandblockId, registration);
|
||||
}
|
||||
else
|
||||
else if (!registration.WantsLoaded)
|
||||
{
|
||||
foreach (var id in unique)
|
||||
{
|
||||
if (registered.Add(id))
|
||||
_adapter.IncrementRefCount(id);
|
||||
}
|
||||
// This is a new load edge that arrived while a preceding unload
|
||||
// still had unfinished releases. The new snapshot replaces the old
|
||||
// desired set. Any still-held overlap remains acquired; obsolete
|
||||
// residual references are released by Reconcile below.
|
||||
MarkAllUndesired(registration.Ordinary);
|
||||
MarkAllUndesired(registration.Prepared);
|
||||
registration.WantsLoaded = true;
|
||||
}
|
||||
|
||||
if (!_additionalReadinessIdsByLandblock.TryGetValue(
|
||||
landblock.LandblockId,
|
||||
out var additional))
|
||||
{
|
||||
additional = new HashSet<ulong>();
|
||||
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
|
||||
}
|
||||
if (additionalReadinessIds is not null)
|
||||
{
|
||||
foreach (var id in additionalReadinessIds)
|
||||
if (additional.Add(id))
|
||||
_adapter.PinPreparedRenderData(id);
|
||||
}
|
||||
MarkDesired(registration.Ordinary, unique);
|
||||
if (preparedIds is not null)
|
||||
MarkDesired(registration.Prepared, preparedIds);
|
||||
|
||||
List<Exception>? failures = null;
|
||||
ReleaseUndesired(registration.Ordinary, ref failures);
|
||||
ReleaseUndesired(registration.Prepared, ref failures);
|
||||
PruneReleasedUndesired(registration.Ordinary);
|
||||
PruneReleasedUndesired(registration.Prepared);
|
||||
AcquireDesired(registration.Ordinary, prepared: false, ref failures);
|
||||
AcquireDesired(registration.Prepared, prepared: true, ref failures);
|
||||
ThrowFailures(
|
||||
failures,
|
||||
$"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -117,17 +134,19 @@ public sealed class LandblockSpawnAdapter
|
|||
/// </summary>
|
||||
public bool IsLandblockRenderReady(uint landblockId)
|
||||
{
|
||||
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|
||||
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
|
||||
{
|
||||
if (!_registrations.TryGetValue(landblockId, out var registration)
|
||||
|| !registration.WantsLoaded)
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var id in registered)
|
||||
if (!_adapter.IsRenderDataReady(id))
|
||||
foreach (var pair in registration.Ordinary)
|
||||
if (!pair.Value.Desired
|
||||
|| !pair.Value.Held
|
||||
|| !_adapter.IsRenderDataReady(pair.Key))
|
||||
return false;
|
||||
foreach (var id in additional)
|
||||
if (!_adapter.IsRenderDataReady(id))
|
||||
foreach (var pair in registration.Prepared)
|
||||
if (!pair.Value.Desired
|
||||
|| !pair.Value.Held
|
||||
|| !_adapter.IsRenderDataReady(pair.Key))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -139,11 +158,127 @@ public sealed class LandblockSpawnAdapter
|
|||
/// </summary>
|
||||
public void OnLandblockUnloaded(uint landblockId)
|
||||
{
|
||||
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
||||
foreach (var id in unique) _adapter.DecrementRefCount(id);
|
||||
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
|
||||
foreach (var id in additional) _adapter.DecrementRefCount(id);
|
||||
_idsByLandblock.Remove(landblockId);
|
||||
_additionalReadinessIdsByLandblock.Remove(landblockId);
|
||||
if (!_registrations.TryGetValue(landblockId, out var registration))
|
||||
return;
|
||||
|
||||
registration.WantsLoaded = false;
|
||||
MarkAllUndesired(registration.Ordinary);
|
||||
MarkAllUndesired(registration.Prepared);
|
||||
|
||||
List<Exception>? failures = null;
|
||||
ReleaseUndesired(registration.Ordinary, ref failures);
|
||||
ReleaseUndesired(registration.Prepared, ref failures);
|
||||
PruneReleasedUndesired(registration.Ordinary);
|
||||
PruneReleasedUndesired(registration.Prepared);
|
||||
|
||||
// Even an after-commit backend exception may report failure after the
|
||||
// final physical release. Drop the registration before propagating in
|
||||
// that case so a caller retry cannot decrement the same reference.
|
||||
if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0)
|
||||
_registrations.Remove(landblockId);
|
||||
|
||||
ThrowFailures(
|
||||
failures,
|
||||
$"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge.");
|
||||
}
|
||||
|
||||
private static void MarkDesired(
|
||||
Dictionary<ulong, ReferenceRegistration> registrations,
|
||||
IEnumerable<ulong> ids)
|
||||
{
|
||||
foreach (ulong id in ids)
|
||||
{
|
||||
if (!registrations.TryGetValue(id, out var reference))
|
||||
{
|
||||
reference = new ReferenceRegistration();
|
||||
registrations.Add(id, reference);
|
||||
}
|
||||
|
||||
reference.Desired = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MarkAllUndesired(
|
||||
Dictionary<ulong, ReferenceRegistration> registrations)
|
||||
{
|
||||
foreach (var reference in registrations.Values)
|
||||
reference.Desired = false;
|
||||
}
|
||||
|
||||
private void AcquireDesired(
|
||||
Dictionary<ulong, ReferenceRegistration> registrations,
|
||||
bool prepared,
|
||||
ref List<Exception>? failures)
|
||||
{
|
||||
foreach (var pair in registrations)
|
||||
{
|
||||
ReferenceRegistration reference = pair.Value;
|
||||
if (!reference.Desired || reference.Held)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (prepared)
|
||||
_adapter.PinPreparedRenderData(pair.Key);
|
||||
else
|
||||
_adapter.IncrementRefCount(pair.Key);
|
||||
reference.Held = true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (error is MeshReferenceMutationException { MutationCommitted: true })
|
||||
reference.Held = true;
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseUndesired(
|
||||
Dictionary<ulong, ReferenceRegistration> registrations,
|
||||
ref List<Exception>? failures)
|
||||
{
|
||||
foreach (var pair in registrations)
|
||||
{
|
||||
ReferenceRegistration reference = pair.Value;
|
||||
if (reference.Desired || !reference.Held)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_adapter.DecrementRefCount(pair.Key);
|
||||
reference.Held = false;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (error is MeshReferenceMutationException { MutationCommitted: true })
|
||||
reference.Held = false;
|
||||
(failures ??= new List<Exception>()).Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PruneReleasedUndesired(
|
||||
Dictionary<ulong, ReferenceRegistration> registrations)
|
||||
{
|
||||
List<ulong>? released = null;
|
||||
foreach (var pair in registrations)
|
||||
{
|
||||
if (!pair.Value.Desired && !pair.Value.Held)
|
||||
(released ??= new List<ulong>()).Add(pair.Key);
|
||||
}
|
||||
|
||||
if (released is null)
|
||||
return;
|
||||
foreach (ulong id in released)
|
||||
registrations.Remove(id);
|
||||
}
|
||||
|
||||
private static void ThrowFailures(List<Exception>? failures, string message)
|
||||
{
|
||||
if (failures is null)
|
||||
return;
|
||||
if (failures.Count == 1)
|
||||
throw failures[0];
|
||||
throw new AggregateException(message, failures);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public int Height { get; private set; }
|
||||
|
||||
public TextureFormat Format => TextureFormat.RGBA8;
|
||||
public ulong BindlessHandle { get; private set; }
|
||||
public ulong BindlessWrapHandle { get; private set; }
|
||||
public ulong BindlessClampHandle { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) {
|
||||
|
|
@ -54,12 +51,10 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering)
|
||||
{
|
||||
float maxAnisotropy = 0f;
|
||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
|
||||
|
||||
if (maxAnisotropy > 0)
|
||||
if (_device.MaxSupportedAnisotropy > 0)
|
||||
{
|
||||
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
|
||||
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy,
|
||||
_device.MaxSupportedAnisotropy);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,15 +67,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture);
|
||||
|
||||
if (_device.HasBindless && _device.BindlessExtension != null) {
|
||||
BindlessHandle = _device.BindlessExtension.GetTextureHandle(_texture);
|
||||
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.WrapSampler);
|
||||
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.ClampSampler);
|
||||
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private long CalculateSize() {
|
||||
|
|
@ -112,12 +98,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding);
|
||||
GL.BindTexture(GLEnum.Texture2D, _texture);
|
||||
|
||||
bool wasResident = false;
|
||||
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
|
||||
wasResident = true;
|
||||
}
|
||||
|
||||
fixed (byte* ptr = data) {
|
||||
GL.TexSubImage2D(
|
||||
GLEnum.Texture2D,
|
||||
|
|
@ -135,10 +115,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
// Generate mipmaps if needed
|
||||
GL.GenerateMipmap(GLEnum.Texture2D);
|
||||
|
||||
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
|
||||
}
|
||||
|
||||
GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding);
|
||||
GL.ActiveTexture((GLEnum)oldActiveTexture);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
|
|
@ -172,20 +148,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
protected void ReleaseTexture() {
|
||||
_device.QueueGLAction(GL => {
|
||||
if (_device.BindlessExtension != null) {
|
||||
if (BindlessHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
|
||||
BindlessHandle = 0;
|
||||
}
|
||||
if (BindlessWrapHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
|
||||
BindlessWrapHandle = 0;
|
||||
}
|
||||
if (BindlessClampHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
|
||||
BindlessClampHandle = 0;
|
||||
}
|
||||
}
|
||||
if (_texture != 0) {
|
||||
GL.DeleteTexture(_texture);
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers;
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Silk.NET.OpenGL;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb {
|
||||
public class ManagedGLTextureArray : ITextureArray {
|
||||
|
|
@ -18,14 +19,15 @@ namespace AcDream.App.Rendering.Wb {
|
|||
private readonly bool _isCompressed;
|
||||
private int _mipmapDirtyCount = 0;
|
||||
private readonly object _mipmapLock = new object();
|
||||
private uint _pboId;
|
||||
private int _pboSize;
|
||||
private readonly List<TextureLayerUpdate> _pendingUpdates = new();
|
||||
private int _disposeQueued;
|
||||
private int _disposePublicationQueued;
|
||||
private int _disposeRetirementAccepted;
|
||||
private RetryableGpuResourceRelease? _disposeRelease;
|
||||
|
||||
private struct TextureLayerUpdate {
|
||||
public int Layer;
|
||||
public int Offset;
|
||||
public int Size;
|
||||
public required byte[] Data;
|
||||
public PixelFormat? UploadPixelFormat;
|
||||
public PixelType? UploadPixelType;
|
||||
}
|
||||
|
|
@ -36,13 +38,12 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public int Size { get; private set; }
|
||||
public TextureFormat Format { get; private set; }
|
||||
public nint NativePtr { get; private set; }
|
||||
public ulong BindlessHandle { get; private set; }
|
||||
public ulong BindlessWrapHandle { get; private set; }
|
||||
public ulong BindlessClampHandle { get; private set; }
|
||||
public long TotalSizeInBytes => CalculateTotalSize();
|
||||
|
||||
/// <summary>
|
||||
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet
|
||||
/// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
|
||||
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
|
||||
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
|
||||
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
|
||||
|
|
@ -69,67 +70,121 @@ namespace AcDream.App.Rendering.Wb {
|
|||
_isCompressed = IsCompressedFormat(format);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
|
||||
NativePtr = (nint)GL.GenTexture();
|
||||
if (NativePtr == 0) {
|
||||
throw new InvalidOperationException("Failed to generate texture array.");
|
||||
}
|
||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
|
||||
uint textureName = 0;
|
||||
ulong wrapHandle = 0;
|
||||
ulong clampHandle = 0;
|
||||
bool textureTracked = false;
|
||||
bool textureBytesTracked = false;
|
||||
bool wrapResident = false;
|
||||
bool clampResident = false;
|
||||
long textureBytes = CalculateTotalSize();
|
||||
|
||||
GLHelpers.CheckErrors(GL);
|
||||
try {
|
||||
textureName = GL.GenTexture();
|
||||
if (textureName == 0)
|
||||
throw new InvalidOperationException("Failed to generate texture array.");
|
||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
|
||||
textureTracked = true;
|
||||
|
||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
GL.BindTexture(GLEnum.Texture2DArray, textureName);
|
||||
|
||||
int maxDimension = Math.Max(width, height);
|
||||
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
|
||||
int maxDimension = Math.Max(width, height);
|
||||
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
|
||||
|
||||
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
|
||||
(uint)size);
|
||||
GLHelpers.CheckErrorsWithContext(GL,
|
||||
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})");
|
||||
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
|
||||
(uint)size);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
|
||||
(int)p.MinFilter);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
|
||||
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
|
||||
(int)p.MinFilter);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1);
|
||||
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
|
||||
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
|
||||
|
||||
if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) {
|
||||
float maxAnisotropy = 0f;
|
||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
|
||||
|
||||
if (maxAnisotropy > 0) {
|
||||
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
|
||||
if (p.EnableAnisotropicFiltering
|
||||
&& graphicsDevice.RenderSettings.EnableAnisotropicFiltering
|
||||
&& graphicsDevice.MaxSupportedAnisotropy > 0) {
|
||||
GL.TexParameter(
|
||||
GLEnum.Texture2DArray,
|
||||
GLEnum.TextureMaxAnisotropy,
|
||||
graphicsDevice.MaxSupportedAnisotropy);
|
||||
}
|
||||
|
||||
if (format == TextureFormat.A8) {
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
|
||||
}
|
||||
|
||||
GLHelpers.ThrowOnResourceError(
|
||||
GL,
|
||||
$"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)");
|
||||
GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture);
|
||||
textureBytesTracked = true;
|
||||
|
||||
if (_device.HasBindless && _device.BindlessExtension != null) {
|
||||
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
|
||||
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
|
||||
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
|
||||
wrapResident = true;
|
||||
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
|
||||
clampResident = true;
|
||||
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
|
||||
}
|
||||
|
||||
NativePtr = (nint)textureName;
|
||||
BindlessWrapHandle = wrapHandle;
|
||||
BindlessClampHandle = clampHandle;
|
||||
}
|
||||
|
||||
// Set texture swizzle for single-channel formats
|
||||
if (format == TextureFormat.A8) {
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
|
||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
|
||||
catch (Exception constructionFailure) {
|
||||
// Constructor failure cannot use Dispose: the object was never
|
||||
// published and queued teardown would make retries accumulate
|
||||
// invalid resident handles. Attempt every independent cleanup.
|
||||
List<Exception>? cleanupFailures = null;
|
||||
void Attempt(Action cleanup) {
|
||||
try { cleanup(); }
|
||||
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
|
||||
}
|
||||
if (_device.BindlessExtension != null) {
|
||||
if (clampResident)
|
||||
Attempt(() => {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(clampHandle);
|
||||
GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle");
|
||||
clampResident = false;
|
||||
});
|
||||
if (wrapResident)
|
||||
Attempt(() => {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle);
|
||||
GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle");
|
||||
wrapResident = false;
|
||||
});
|
||||
}
|
||||
// Deleting a texture while either bindless sampler handle is
|
||||
// still resident is undefined. A pre-commit residency failure
|
||||
// therefore retains the texture instead of risking a driver
|
||||
// reset during constructor rollback.
|
||||
if (textureName != 0 && !clampResident && !wrapResident)
|
||||
Attempt(() => {
|
||||
GL.DeleteTexture(textureName);
|
||||
GLHelpers.ThrowOnResourceError(GL, "rolling back texture array");
|
||||
if (textureBytesTracked)
|
||||
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
|
||||
if (textureTracked)
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
||||
});
|
||||
if (cleanupFailures is not null) {
|
||||
cleanupFailures.Insert(0, constructionFailure);
|
||||
throw new AggregateException(
|
||||
"Texture-array construction and rollback both failed.",
|
||||
cleanupFailures);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
GLHelpers.CheckErrors(GL);
|
||||
|
||||
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture);
|
||||
|
||||
if (_device.HasBindless && _device.BindlessExtension != null) {
|
||||
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
|
||||
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
|
||||
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
|
||||
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
|
||||
finally {
|
||||
GL.ActiveTexture(TextureUnit.Texture0);
|
||||
GL.BindTexture(GLEnum.Texture2DArray, 0);
|
||||
RenderStateCache.CurrentAtlas = 0;
|
||||
}
|
||||
|
||||
_pboId = GL.GenBuffer();
|
||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
||||
}
|
||||
|
||||
public long CalculateTotalSize() {
|
||||
|
|
@ -151,7 +206,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
return totalSize;
|
||||
}
|
||||
|
||||
private bool IsCompressedFormat(TextureFormat format) {
|
||||
private static bool IsCompressedFormat(TextureFormat format) {
|
||||
return format == TextureFormat.DXT1 ||
|
||||
format == TextureFormat.DXT3 ||
|
||||
format == TextureFormat.DXT5;
|
||||
|
|
@ -220,127 +275,156 @@ namespace AcDream.App.Rendering.Wb {
|
|||
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
|
||||
}
|
||||
|
||||
int currentPboOffset = 0;
|
||||
ValidateUploadPayload(
|
||||
Format,
|
||||
Width,
|
||||
Height,
|
||||
data.Length,
|
||||
uploadPixelFormat,
|
||||
uploadPixelType);
|
||||
|
||||
lock (_mipmapLock) {
|
||||
if (_pendingUpdates.Count > 0) {
|
||||
var lastUpdate = _pendingUpdates[^1];
|
||||
currentPboOffset = lastUpdate.Offset + lastUpdate.Size;
|
||||
}
|
||||
|
||||
// Align to 4 bytes for safety
|
||||
currentPboOffset = (currentPboOffset + 3) & ~3;
|
||||
|
||||
if (currentPboOffset + data.Length > _pboSize) {
|
||||
// Flush existing updates first because BufferData will orphan/clear the PBO
|
||||
if (_pendingUpdates.Count > 0) {
|
||||
ProcessDirtyUpdatesInternal();
|
||||
}
|
||||
currentPboOffset = 0;
|
||||
|
||||
int newSize = Math.Max(_pboSize * 2, data.Length);
|
||||
newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers
|
||||
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
|
||||
GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw);
|
||||
|
||||
if (_pboSize > 0) {
|
||||
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
|
||||
}
|
||||
_pboSize = newSize;
|
||||
GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer);
|
||||
}
|
||||
else {
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
|
||||
}
|
||||
|
||||
fixed (byte* ptr = data) {
|
||||
GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr);
|
||||
}
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
|
||||
|
||||
_pendingUpdates.Add(new TextureLayerUpdate {
|
||||
// Retain the immutable decoded payload until the once-per-frame
|
||||
// atlas flush. The former per-atlas PBO permanently reserved
|
||||
// several MiB for every array and duplicated each upload
|
||||
// through BufferSubData before TexSubImage3D.
|
||||
var update = new TextureLayerUpdate {
|
||||
Layer = layer,
|
||||
Offset = currentPboOffset,
|
||||
Size = data.Length,
|
||||
Data = data,
|
||||
UploadPixelFormat = uploadPixelFormat,
|
||||
UploadPixelType = uploadPixelType
|
||||
});
|
||||
};
|
||||
int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer);
|
||||
if (existingIndex >= 0)
|
||||
_pendingUpdates[existingIndex] = update;
|
||||
else
|
||||
_pendingUpdates.Add(update);
|
||||
|
||||
_needsMipmapRegeneration = true;
|
||||
_mipmapDirtyCount++;
|
||||
if (existingIndex < 0)
|
||||
_mipmapDirtyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessDirtyUpdates() {
|
||||
public long ProcessDirtyUpdates() {
|
||||
lock (_mipmapLock) {
|
||||
ProcessDirtyUpdatesInternal();
|
||||
return ProcessDirtyUpdatesInternal(generateMipmaps: true);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void ProcessDirtyUpdatesInternal() {
|
||||
if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return;
|
||||
private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) {
|
||||
if (_pendingUpdates.Count == 0
|
||||
&& (!generateMipmaps || !_needsMipmapRegeneration)) return 0;
|
||||
|
||||
long generatedBytes = 0;
|
||||
|
||||
GLHelpers.CheckErrors(GL);
|
||||
|
||||
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
|
||||
// This runs in WbMeshAdapter.Tick before any draw pass. Establish
|
||||
// the upload phase's canonical texture state directly instead of
|
||||
// synchronously querying driver state for every dirty array.
|
||||
GL.ActiveTexture(TextureUnit.Texture0);
|
||||
RenderStateCache.CurrentAtlas = 0;
|
||||
|
||||
GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding);
|
||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
||||
bool mipmapWorkCompleted = false;
|
||||
try {
|
||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
||||
|
||||
bool wasResident = false;
|
||||
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
|
||||
wasResident = true;
|
||||
}
|
||||
if (_pendingUpdates.Count > 0) {
|
||||
// A non-zero pixel-unpack binding changes pointer arguments
|
||||
// into byte offsets. Direct client-memory uploads therefore
|
||||
// establish the canonical zero binding once for the batch.
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
|
||||
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
|
||||
GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
|
||||
GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
|
||||
GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
|
||||
|
||||
if (_pendingUpdates.Count > 0) {
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
|
||||
foreach (var update in _pendingUpdates) {
|
||||
fixed (byte* data = update.Data) {
|
||||
if (_isCompressed) {
|
||||
var internalFormat = Format.ToCompressedGL();
|
||||
GL.CompressedTexSubImage3D(
|
||||
GLEnum.Texture2DArray,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
update.Layer,
|
||||
(uint)Width,
|
||||
(uint)Height,
|
||||
1,
|
||||
internalFormat,
|
||||
(uint)update.Data.Length,
|
||||
data);
|
||||
}
|
||||
else {
|
||||
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
|
||||
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
|
||||
GL.TexSubImage3D(
|
||||
GLEnum.Texture2DArray,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
update.Layer,
|
||||
(uint)Width,
|
||||
(uint)Height,
|
||||
1,
|
||||
pixelFormat,
|
||||
pixelType,
|
||||
data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var update in _pendingUpdates) {
|
||||
if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
|
||||
if (_isCompressed) {
|
||||
var internalFormat = Format.ToCompressedGL();
|
||||
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
|
||||
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
|
||||
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
|
||||
}
|
||||
else {
|
||||
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
|
||||
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
|
||||
GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1,
|
||||
pixelFormat, pixelType, (void*)update.Offset);
|
||||
try {
|
||||
// Width, height and format were validated when the
|
||||
// immutable storage was allocated. Re-reading them
|
||||
// here forced three CPU/GPU synchronization points
|
||||
// for every dirty atlas without adding safety.
|
||||
GL.GenerateMipmap(GLEnum.Texture2DArray);
|
||||
generatedBytes = TotalSizeInBytes;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release builds must observe transfer/OOM/context errors
|
||||
// before the pending offsets and dirty mip state are cleared.
|
||||
// One check covers every layer in this array plus its single
|
||||
// mip generation, keeping the synchronization cost bounded by
|
||||
// dirty arrays rather than uploaded textures.
|
||||
GLHelpers.ThrowOnResourceError(
|
||||
GL,
|
||||
$"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})");
|
||||
mipmapWorkCompleted = generateMipmaps
|
||||
&& _needsMipmapRegeneration
|
||||
&& _mipmapDirtyCount > 0;
|
||||
}
|
||||
finally {
|
||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
|
||||
_pendingUpdates.Clear();
|
||||
GL.BindTexture(GLEnum.Texture2DArray, 0);
|
||||
GL.ActiveTexture(TextureUnit.Texture0);
|
||||
}
|
||||
|
||||
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) {
|
||||
if (_isCompressed) {
|
||||
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
|
||||
}
|
||||
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) {
|
||||
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage);
|
||||
}
|
||||
else {
|
||||
try {
|
||||
GL.GenerateMipmap(GLEnum.Texture2DArray);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
|
||||
}
|
||||
}
|
||||
// Commit CPU-side completion only after glGetError confirms the
|
||||
// uploads/mipmap work succeeded. If the driver rejects an
|
||||
// operation, the retained payloads and dirty flags remain intact and the
|
||||
// atlas stays in ObjectMeshManager's dirty set for a later retry.
|
||||
_pendingUpdates.Clear();
|
||||
if (mipmapWorkCompleted) {
|
||||
_mipmapDirtyCount = 0;
|
||||
_needsMipmapRegeneration = false;
|
||||
}
|
||||
|
||||
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
|
||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
|
||||
}
|
||||
|
||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding);
|
||||
GL.ActiveTexture((GLEnum)oldActiveTexture);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
return generatedBytes;
|
||||
}
|
||||
|
||||
private void ClearLayerForMipmap(int layer) {
|
||||
|
|
@ -351,17 +435,51 @@ namespace AcDream.App.Rendering.Wb {
|
|||
}
|
||||
|
||||
private int GetExpectedDataSize() {
|
||||
if (_isCompressed) {
|
||||
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format);
|
||||
return CalculateExpectedDataSize(Format, Width, Height);
|
||||
}
|
||||
|
||||
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
|
||||
if (IsCompressedFormat(format))
|
||||
return TextureHelpers.GetCompressedLayerSize(width, height, format);
|
||||
|
||||
return format switch {
|
||||
TextureFormat.RGBA8 => checked(width * height * 4),
|
||||
TextureFormat.RGB8 => checked(width * height * 3),
|
||||
TextureFormat.A8 => checked(width * height),
|
||||
TextureFormat.Rgba32f => checked(width * height * 16),
|
||||
_ => throw new NotSupportedException($"Unsupported format {format}")
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ValidateUploadPayload(
|
||||
TextureFormat format,
|
||||
int width,
|
||||
int height,
|
||||
int dataLength,
|
||||
PixelFormat? uploadPixelFormat,
|
||||
PixelType? uploadPixelType) {
|
||||
int expectedBytes = CalculateExpectedDataSize(format, width, height);
|
||||
if (dataLength != expectedBytes) {
|
||||
throw new ArgumentException(
|
||||
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
|
||||
+ $"for {format} {width}x{height}.",
|
||||
nameof(dataLength));
|
||||
}
|
||||
|
||||
return Format switch {
|
||||
TextureFormat.RGBA8 => Width * Height * 4,
|
||||
TextureFormat.RGB8 => Width * Height * 3,
|
||||
TextureFormat.A8 => Width * Height * 1,
|
||||
TextureFormat.Rgba32f => Width * Height * 16,
|
||||
_ => throw new NotSupportedException($"Unsupported format {Format}")
|
||||
};
|
||||
if (IsCompressedFormat(format)) {
|
||||
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
|
||||
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
|
||||
return;
|
||||
}
|
||||
|
||||
PixelFormat expectedFormat = format.ToPixelFormat();
|
||||
PixelType expectedType = format.ToPixelType();
|
||||
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|
||||
|| (uploadPixelType ?? expectedType) != expectedType) {
|
||||
throw new ArgumentException(
|
||||
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
|
||||
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveLayer(int layer) {
|
||||
|
|
@ -376,15 +494,8 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
_usedLayers[layer] = false;
|
||||
|
||||
// Make layer defined for mipmap completeness (uncompressed only)
|
||||
if (!_isCompressed) {
|
||||
ClearLayerForMipmap(layer);
|
||||
}
|
||||
|
||||
lock (_mipmapLock) {
|
||||
_mipmapDirtyCount++; // Mark dirty to regen
|
||||
_needsMipmapRegeneration = true;
|
||||
}
|
||||
// An unreferenced layer needs no clear or whole-array mip
|
||||
// regeneration before AddTexture overwrites it on reuse.
|
||||
}
|
||||
|
||||
public bool IsLayerUsed(int layer) {
|
||||
|
|
@ -396,6 +507,22 @@ namespace AcDream.App.Rendering.Wb {
|
|||
return _usedLayers.Count(x => x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True once disposal is durably owned by a queued GL publication,
|
||||
/// the frame-retirement queue, or a completed retained release. A
|
||||
/// caller may only commit its own logical disposal after this becomes
|
||||
/// true; otherwise a synchronous enqueue failure still needs retry.
|
||||
/// </summary>
|
||||
internal bool HasDurableDisposeOwnership {
|
||||
get {
|
||||
if (Volatile.Read(ref _disposeQueued) == 0)
|
||||
return false;
|
||||
return Volatile.Read(ref _disposePublicationQueued) != 0
|
||||
|| Volatile.Read(ref _disposeRetirementAccepted) != 0
|
||||
|| Volatile.Read(ref _disposeRelease) is null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Unbind() {
|
||||
GL.BindTexture(GLEnum.Texture2DArray, 0);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
|
|
@ -409,37 +536,95 @@ namespace AcDream.App.Rendering.Wb {
|
|||
}
|
||||
|
||||
public void Dispose() {
|
||||
_device.QueueGLAction(GL => {
|
||||
if (_device.BindlessExtension != null) {
|
||||
if (BindlessHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
|
||||
BindlessHandle = 0;
|
||||
if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) {
|
||||
ScheduleDisposeRelease();
|
||||
return;
|
||||
}
|
||||
|
||||
uint textureName = (uint)NativePtr;
|
||||
ulong bindlessWrapHandle = BindlessWrapHandle;
|
||||
ulong bindlessClampHandle = BindlessClampHandle;
|
||||
long textureBytes = CalculateTotalSize();
|
||||
|
||||
NativePtr = 0;
|
||||
BindlessWrapHandle = 0;
|
||||
BindlessClampHandle = 0;
|
||||
|
||||
_disposeRelease = new RetryableGpuResourceRelease(
|
||||
() => {
|
||||
if (_device.BindlessExtension != null && bindlessWrapHandle != 0)
|
||||
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)");
|
||||
},
|
||||
() => {
|
||||
if (_device.BindlessExtension != null && bindlessWrapHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle);
|
||||
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle");
|
||||
}
|
||||
if (BindlessWrapHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
|
||||
BindlessWrapHandle = 0;
|
||||
},
|
||||
() => {
|
||||
if (_device.BindlessExtension != null && bindlessClampHandle != 0)
|
||||
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
|
||||
},
|
||||
() => {
|
||||
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
|
||||
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
|
||||
}
|
||||
if (BindlessClampHandle != 0) {
|
||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
|
||||
BindlessClampHandle = 0;
|
||||
},
|
||||
() => {
|
||||
if (textureName != 0)
|
||||
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)");
|
||||
},
|
||||
() => {
|
||||
if (textureName != 0) {
|
||||
GL.DeleteTexture(textureName);
|
||||
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}");
|
||||
}
|
||||
}
|
||||
if (NativePtr != 0) {
|
||||
GL.DeleteTexture((uint)NativePtr);
|
||||
GLHelpers.CheckErrors(GL);
|
||||
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture);
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
||||
NativePtr = 0;
|
||||
}
|
||||
if (_pboId != 0) {
|
||||
GL.DeleteBuffer(_pboId);
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
|
||||
if (_pboSize > 0) {
|
||||
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
|
||||
},
|
||||
() => {
|
||||
if (textureName != 0)
|
||||
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
|
||||
},
|
||||
() => {
|
||||
if (textureName != 0)
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
||||
},
|
||||
() => _disposeRelease = null);
|
||||
|
||||
ScheduleDisposeRelease();
|
||||
}
|
||||
|
||||
private void ScheduleDisposeRelease(bool forNextPass = false) {
|
||||
RetryableGpuResourceRelease? release = _disposeRelease;
|
||||
if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0)
|
||||
return;
|
||||
if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0)
|
||||
return;
|
||||
|
||||
try {
|
||||
Action<GL> publish = GL => {
|
||||
Volatile.Write(ref _disposePublicationQueued, 0);
|
||||
try {
|
||||
_device.RetireGpuResource(release.Run);
|
||||
Volatile.Write(ref _disposeRetirementAccepted, 1);
|
||||
}
|
||||
_pboId = 0;
|
||||
}
|
||||
});
|
||||
catch {
|
||||
// Retire may fail before accepting the callback, or an
|
||||
// immediate queue may surface a partial release. The
|
||||
// release cursor makes this next-pass retry exact.
|
||||
ScheduleDisposeRelease(forNextPass: true);
|
||||
throw;
|
||||
}
|
||||
};
|
||||
if (forNextPass)
|
||||
_device.QueueGLActionForNextPass(publish);
|
||||
else
|
||||
_device.QueueGLAction(publish);
|
||||
}
|
||||
catch {
|
||||
Volatile.Write(ref _disposePublicationQueued, 0);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,23 @@ using AcDream.Content;
|
|||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
internal enum MeshStageResult
|
||||
{
|
||||
Staged,
|
||||
AlreadyStaged,
|
||||
HighWater,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable identity for one staging claim. Object ids can be released and
|
||||
/// reacquired while a render-thread upload is in flight; the generation keeps
|
||||
/// a stale completion or retry from consuming the replacement claim.
|
||||
/// </summary>
|
||||
internal readonly record struct MeshUploadQueueItem(
|
||||
ObjectMeshData Data,
|
||||
long SourceBytes,
|
||||
ulong Generation);
|
||||
|
||||
/// <summary>
|
||||
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
|
||||
/// in-flight at a time; a retry retains ownership until upload succeeds or
|
||||
|
|
@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </summary>
|
||||
internal sealed class MeshUploadStagingQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
|
||||
internal const int DefaultMaximumCount = 256;
|
||||
internal const long DefaultMaximumBytes = 128L * 1024 * 1024;
|
||||
internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024;
|
||||
|
||||
public bool Stage(ObjectMeshData data)
|
||||
private readonly object _gate = new();
|
||||
private readonly Queue<Entry> _queue = new();
|
||||
private readonly Dictionary<ulong, ulong> _generationById = new();
|
||||
private readonly int _maximumCount;
|
||||
private readonly long _maximumBytes;
|
||||
private readonly long _maximumSingleEntryBytes;
|
||||
private long _queuedBytes;
|
||||
private long _claimedBytes;
|
||||
private ulong _nextGeneration;
|
||||
|
||||
private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation)
|
||||
{
|
||||
if (!_ownedIds.TryAdd(data.ObjectId, 0))
|
||||
return false;
|
||||
|
||||
data.UploadAttempts = 0;
|
||||
_queue.Enqueue(data);
|
||||
return true;
|
||||
public MeshUploadQueueItem Item => new(Data, Bytes, Generation);
|
||||
}
|
||||
|
||||
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
|
||||
public MeshUploadStagingQueue(
|
||||
int maximumCount = DefaultMaximumCount,
|
||||
long maximumBytes = DefaultMaximumBytes,
|
||||
long maximumSingleEntryBytes = 0)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes);
|
||||
if (maximumSingleEntryBytes == 0)
|
||||
maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes);
|
||||
_maximumCount = maximumCount;
|
||||
_maximumBytes = maximumBytes;
|
||||
_maximumSingleEntryBytes = maximumSingleEntryBytes;
|
||||
}
|
||||
|
||||
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
|
||||
public bool Stage(ObjectMeshData data)
|
||||
=> TryStage(data) == MeshStageResult.Staged;
|
||||
|
||||
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
|
||||
public MeshStageResult TryStage(ObjectMeshData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_generationById.ContainsKey(data.ObjectId))
|
||||
return MeshStageResult.AlreadyStaged;
|
||||
|
||||
if (bytes > _maximumSingleEntryBytes)
|
||||
throw new NotSupportedException(
|
||||
$"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; "
|
||||
+ $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes.");
|
||||
|
||||
// Permit one explicitly bounded oversized head item so unusual
|
||||
// content cannot starve. Every other producer observes the strict
|
||||
// count/byte watermark; active decoders retain their result in the
|
||||
// bounded CPU cache and retry staging after the consumer drains.
|
||||
if (_generationById.Count != 0
|
||||
&& (_generationById.Count >= _maximumCount
|
||||
|| bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes)))
|
||||
{
|
||||
return MeshStageResult.HighWater;
|
||||
}
|
||||
|
||||
ulong generation = checked(++_nextGeneration);
|
||||
_generationById.Add(data.ObjectId, generation);
|
||||
data.UploadAttempts = 0;
|
||||
_queue.Enqueue(new Entry(data, bytes, generation));
|
||||
_queuedBytes = checked(_queuedBytes + bytes);
|
||||
_claimedBytes = checked(_claimedBytes + bytes);
|
||||
return MeshStageResult.Staged;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDequeue(out MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_queue.TryDequeue(out Entry entry))
|
||||
{
|
||||
item = default;
|
||||
return false;
|
||||
}
|
||||
_queuedBytes -= entry.Bytes;
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryPeek(out MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_queue.TryPeek(out Entry entry))
|
||||
{
|
||||
item = default;
|
||||
return false;
|
||||
}
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_gate) return _queue.Count; } }
|
||||
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
|
||||
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
|
||||
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
|
||||
public bool IsAtHighWater
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes;
|
||||
}
|
||||
}
|
||||
|
||||
public void Requeue(MeshUploadQueueItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item.Data);
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
if (item.SourceBytes > _maximumSingleEntryBytes)
|
||||
throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed.");
|
||||
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation));
|
||||
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete(MeshUploadQueueItem item)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
_generationById.Remove(item.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a dequeued, now-unowned generation and atomically hands the
|
||||
/// same CPU payload to an owner that raced in while it was dequeued. A
|
||||
/// concurrent cache hit either sees the old staging claim and this method
|
||||
/// requeues, or runs after the claim is removed and stages for itself; the
|
||||
/// mesh cannot fall through the gap between those operations.
|
||||
/// </summary>
|
||||
public bool CompleteOrRestageIfOwned(
|
||||
MeshUploadQueueItem item,
|
||||
MeshOwnershipCounter ownership)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item.Data);
|
||||
ArgumentNullException.ThrowIfNull(ownership);
|
||||
lock (_gate)
|
||||
{
|
||||
ValidateCurrentGeneration(item);
|
||||
_generationById.Remove(item.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
|
||||
if (!ownership.IsOwned(item.Data.ObjectId))
|
||||
return false;
|
||||
|
||||
ulong generation = checked(++_nextGeneration);
|
||||
_generationById.Add(item.Data.ObjectId, generation);
|
||||
item.Data.UploadAttempts = 0;
|
||||
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation));
|
||||
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
|
||||
_claimedBytes = checked(_claimedBytes + item.SourceBytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ownership);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maximum);
|
||||
int discarded = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
while (discarded < maximum
|
||||
&& _queue.TryPeek(out Entry entry)
|
||||
&& !ownership.IsOwned(entry.Data.ObjectId))
|
||||
{
|
||||
_queue.Dequeue();
|
||||
_queuedBytes -= entry.Bytes;
|
||||
if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation)
|
||||
&& generation == entry.Generation)
|
||||
{
|
||||
_generationById.Remove(entry.Data.ObjectId);
|
||||
_claimedBytes = checked(_claimedBytes - entry.Bytes);
|
||||
}
|
||||
discarded++;
|
||||
}
|
||||
}
|
||||
return discarded;
|
||||
}
|
||||
|
||||
private void ValidateCurrentGeneration(MeshUploadQueueItem item)
|
||||
{
|
||||
if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current)
|
||||
|| current != item.Generation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter
|
|||
internal sealed class CpuMeshUploadCache
|
||||
{
|
||||
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
|
||||
private readonly Dictionary<ulong, long> _bytesById = new();
|
||||
private readonly LinkedList<ulong> _lru = new();
|
||||
private readonly int _capacity;
|
||||
private readonly long _byteCapacity;
|
||||
private long _residentBytes;
|
||||
|
||||
public CpuMeshUploadCache(int capacity)
|
||||
public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1);
|
||||
_capacity = capacity;
|
||||
_byteCapacity = byteCapacity;
|
||||
}
|
||||
|
||||
internal int Count { get { lock (_data) return _data.Count; } }
|
||||
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
|
||||
|
||||
public bool TryGetAndStage(
|
||||
ulong id,
|
||||
MeshUploadStagingQueue staging,
|
||||
out ObjectMeshData? data)
|
||||
out ObjectMeshData? data,
|
||||
out MeshStageResult stageResult)
|
||||
{
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.TryGetValue(id, out data))
|
||||
if (!_data.TryGetValue(id, out data)) {
|
||||
stageResult = default;
|
||||
return false;
|
||||
}
|
||||
_lru.Remove(id);
|
||||
_lru.AddLast(id);
|
||||
staging.Stage(data);
|
||||
stageResult = staging.TryStage(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Store(ObjectMeshData data)
|
||||
public bool Store(ObjectMeshData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
|
||||
// Reject before touching the replacement/LRU state. The prior loop
|
||||
// evicted everything and then published one arbitrarily large entry,
|
||||
// allowing the cache-hit path to replay an unbounded payload forever.
|
||||
if (bytes > _byteCapacity)
|
||||
return false;
|
||||
lock (_data)
|
||||
{
|
||||
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
|
||||
if (_bytesById.Remove(data.ObjectId, out long replacedBytes))
|
||||
_residentBytes -= replacedBytes;
|
||||
|
||||
while (_lru.Count != 0
|
||||
&& (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity
|
||||
|| (_data.Count != 0 && bytes > _byteCapacity - _residentBytes)))
|
||||
{
|
||||
ulong oldest = _lru.First!.Value;
|
||||
_lru.RemoveFirst();
|
||||
_data.Remove(oldest);
|
||||
if (_bytesById.Remove(oldest, out long oldestBytes))
|
||||
_residentBytes -= oldestBytes;
|
||||
}
|
||||
|
||||
_data[data.ObjectId] = data;
|
||||
_bytesById[data.ObjectId] = bytes;
|
||||
_residentBytes = checked(_residentBytes + bytes);
|
||||
_lru.Remove(data.ObjectId);
|
||||
_lru.AddLast(data.ObjectId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache
|
|||
lock (_data)
|
||||
{
|
||||
_data.Clear();
|
||||
_bytesById.Clear();
|
||||
_lru.Clear();
|
||||
_residentBytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal file
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
internal readonly record struct MeshUploadCost(
|
||||
long SourceBytes,
|
||||
long ArrayAllocationBytes,
|
||||
long MipmapBytes,
|
||||
int NewArrayCount,
|
||||
long BufferUploadBytes = 0,
|
||||
long BufferAllocationBytes = 0,
|
||||
long BufferCopyBytes = 0,
|
||||
int NewBufferCount = 0,
|
||||
ulong AdmissionKey = 0);
|
||||
|
||||
internal readonly record struct MeshUploadBudgetLimits(
|
||||
int MaximumObjects,
|
||||
long MaximumSourceBytes,
|
||||
long MaximumArrayAllocationBytes,
|
||||
long MaximumMipmapBytes,
|
||||
int MaximumNewArrays,
|
||||
long MaximumBufferUploadBytes = long.MaxValue,
|
||||
long MaximumBufferAllocationBytes = long.MaxValue,
|
||||
long MaximumBufferCopyBytes = long.MaxValue,
|
||||
int MaximumNewBuffers = int.MaxValue,
|
||||
long MaximumSingleSourceBytes = long.MaxValue,
|
||||
long MaximumSingleArrayAllocationBytes = long.MaxValue,
|
||||
long MaximumSingleMipmapBytes = long.MaxValue,
|
||||
int MaximumSingleNewArrays = int.MaxValue,
|
||||
long MaximumSingleBufferUploadBytes = long.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Pure prefix-admission policy for render-thread mesh uploads. Ordinary
|
||||
/// uploads fit one frame in every independent CPU/GPU dimension. A physically
|
||||
/// indivisible FIFO head may exceed a soft frame budget only when it is below
|
||||
/// the explicit single-operation ceiling; it runs immediately rather than
|
||||
/// accumulating fictional credits across idle frames. Global-buffer growth
|
||||
/// and prefix copies are never admitted here: they are real, chunked
|
||||
/// maintenance operations driven by <see cref="GlobalMeshBuffer"/>.
|
||||
/// </summary>
|
||||
internal sealed class MeshUploadFrameBudget
|
||||
{
|
||||
private readonly MeshUploadBudgetLimits _limits;
|
||||
|
||||
public MeshUploadFrameBudget(MeshUploadBudgetLimits limits)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumObjects, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSourceBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumArrayAllocationBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumMipmapBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewArrays, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferUploadBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferAllocationBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferCopyBytes, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewBuffers, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleSourceBytes, limits.MaximumSourceBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleArrayAllocationBytes, limits.MaximumArrayAllocationBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleMipmapBytes, limits.MaximumMipmapBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleNewArrays, limits.MaximumNewArrays);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleBufferUploadBytes, limits.MaximumBufferUploadBytes);
|
||||
_limits = limits;
|
||||
}
|
||||
|
||||
public int ObjectCount { get; private set; }
|
||||
public long SourceBytes { get; private set; }
|
||||
public long ArrayAllocationBytes { get; private set; }
|
||||
public long MipmapBytes { get; private set; }
|
||||
public int NewArrayCount { get; private set; }
|
||||
public long BufferUploadBytes { get; private set; }
|
||||
public long BufferAllocationBytes { get; private set; }
|
||||
public long BufferCopyBytes { get; private set; }
|
||||
public int NewBufferCount { get; private set; }
|
||||
public void Reset()
|
||||
{
|
||||
ObjectCount = 0;
|
||||
SourceBytes = 0;
|
||||
ArrayAllocationBytes = 0;
|
||||
MipmapBytes = 0;
|
||||
NewArrayCount = 0;
|
||||
BufferUploadBytes = 0;
|
||||
BufferAllocationBytes = 0;
|
||||
BufferCopyBytes = 0;
|
||||
NewBufferCount = 0;
|
||||
}
|
||||
|
||||
public bool TryAdmit(MeshUploadCost cost)
|
||||
{
|
||||
Validate(cost);
|
||||
if (cost.BufferAllocationBytes != 0
|
||||
|| cost.BufferCopyBytes != 0
|
||||
|| cost.NewBufferCount != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Global-buffer allocation/copy work must be progressed as real maintenance before upload admission.");
|
||||
}
|
||||
|
||||
bool oversizedHead = ObjectCount == 0 && ExceedsSingleFrame(cost);
|
||||
if (oversizedHead)
|
||||
{
|
||||
if (cost.SourceBytes > _limits.MaximumSingleSourceBytes
|
||||
|| cost.ArrayAllocationBytes > _limits.MaximumSingleArrayAllocationBytes
|
||||
|| cost.MipmapBytes > _limits.MaximumSingleMipmapBytes
|
||||
|| cost.NewArrayCount > _limits.MaximumSingleNewArrays
|
||||
|| cost.BufferUploadBytes > _limits.MaximumSingleBufferUploadBytes)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Mesh upload generation {cost.AdmissionKey} exceeds the supported single-operation ceiling.");
|
||||
}
|
||||
|
||||
Admit(cost);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ObjectCount >= _limits.MaximumObjects
|
||||
|| WouldExceed(SourceBytes, cost.SourceBytes, _limits.MaximumSourceBytes)
|
||||
|| WouldExceed(ArrayAllocationBytes, cost.ArrayAllocationBytes, _limits.MaximumArrayAllocationBytes)
|
||||
|| WouldExceed(MipmapBytes, cost.MipmapBytes, _limits.MaximumMipmapBytes)
|
||||
|| cost.NewArrayCount > _limits.MaximumNewArrays - NewArrayCount
|
||||
|| WouldExceed(BufferUploadBytes, cost.BufferUploadBytes, _limits.MaximumBufferUploadBytes)
|
||||
|| WouldExceed(BufferAllocationBytes, cost.BufferAllocationBytes, _limits.MaximumBufferAllocationBytes)
|
||||
|| WouldExceed(BufferCopyBytes, cost.BufferCopyBytes, _limits.MaximumBufferCopyBytes)
|
||||
|| cost.NewBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Admit(cost);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Records work that actually executed this frame.</summary>
|
||||
public void RecordBufferMaintenance(long allocationBytes, long copyBytes, int newBufferCount)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(allocationBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(copyBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(newBufferCount);
|
||||
if (WouldExceed(BufferAllocationBytes, allocationBytes, _limits.MaximumBufferAllocationBytes)
|
||||
|| WouldExceed(BufferCopyBytes, copyBytes, _limits.MaximumBufferCopyBytes)
|
||||
|| newBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Executed global-buffer maintenance exceeded its real per-frame bound.");
|
||||
}
|
||||
BufferAllocationBytes = checked(BufferAllocationBytes + allocationBytes);
|
||||
BufferCopyBytes = checked(BufferCopyBytes + copyBytes);
|
||||
NewBufferCount = checked(NewBufferCount + newBufferCount);
|
||||
}
|
||||
|
||||
private bool ExceedsSingleFrame(MeshUploadCost cost) =>
|
||||
cost.SourceBytes > _limits.MaximumSourceBytes
|
||||
|| cost.ArrayAllocationBytes > _limits.MaximumArrayAllocationBytes
|
||||
|| cost.MipmapBytes > _limits.MaximumMipmapBytes
|
||||
|| cost.NewArrayCount > _limits.MaximumNewArrays
|
||||
|| cost.BufferUploadBytes > _limits.MaximumBufferUploadBytes
|
||||
|| cost.BufferAllocationBytes > _limits.MaximumBufferAllocationBytes
|
||||
|| cost.BufferCopyBytes > _limits.MaximumBufferCopyBytes
|
||||
|| cost.NewBufferCount > _limits.MaximumNewBuffers;
|
||||
|
||||
private void Admit(MeshUploadCost cost)
|
||||
{
|
||||
ObjectCount++;
|
||||
SourceBytes = checked(SourceBytes + cost.SourceBytes);
|
||||
ArrayAllocationBytes = checked(ArrayAllocationBytes + cost.ArrayAllocationBytes);
|
||||
MipmapBytes = checked(MipmapBytes + cost.MipmapBytes);
|
||||
NewArrayCount = checked(NewArrayCount + cost.NewArrayCount);
|
||||
BufferUploadBytes = checked(BufferUploadBytes + cost.BufferUploadBytes);
|
||||
BufferAllocationBytes = checked(BufferAllocationBytes + cost.BufferAllocationBytes);
|
||||
BufferCopyBytes = checked(BufferCopyBytes + cost.BufferCopyBytes);
|
||||
NewBufferCount = checked(NewBufferCount + cost.NewBufferCount);
|
||||
}
|
||||
|
||||
// Keep the comparison overflow-free even when a bounded indivisible head
|
||||
// has truthfully exceeded a soft per-frame budget.
|
||||
private static bool WouldExceed(long current, long added, long maximum) =>
|
||||
added > maximum - Math.Min(current, maximum);
|
||||
|
||||
private static void Validate(MeshUploadCost cost)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.SourceBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.ArrayAllocationBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.MipmapBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewArrayCount);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferUploadBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferAllocationBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferCopyBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewBufferCount);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,17 +22,43 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice {
|
||||
private readonly ILogger _log;
|
||||
private readonly DebugRenderSettings _renderSettings;
|
||||
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
|
||||
|
||||
public GL GL { get; }
|
||||
public DebugRenderSettings RenderSettings => _renderSettings;
|
||||
|
||||
private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new();
|
||||
private readonly ConcurrentQueue<Action<GL>> _nextGlThreadQueue = new();
|
||||
|
||||
internal bool HasPendingGLWork =>
|
||||
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
|
||||
|
||||
public void QueueGLAction(Action<GL> action) {
|
||||
_glThreadQueue.Enqueue(action);
|
||||
}
|
||||
|
||||
internal void QueueGLActionForNextPass(Action<GL> action) {
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
_nextGlThreadQueue.Enqueue(action);
|
||||
}
|
||||
|
||||
public void ProcessGLQueue() {
|
||||
// Retry the prior pass before ordinary work (notably sampler
|
||||
// deletion), but process only the captured generation so a
|
||||
// persistent driver failure cannot spin this frame forever.
|
||||
int retryCount = _nextGlThreadQueue.Count;
|
||||
for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action<GL>? retry); i++) {
|
||||
try {
|
||||
retry(GL);
|
||||
} catch (Exception ex) {
|
||||
_log.LogError(ex, "Error processing retryable GL queue action");
|
||||
}
|
||||
}
|
||||
// Normal actions retain drain-to-empty semantics because teardown
|
||||
// actions intentionally enqueue dependent atlas releases here.
|
||||
// A persistent retryable error must not starve unrelated uploads
|
||||
// and releases forever: the retry generation remains bounded to
|
||||
// one attempt per pass, while ordinary work still makes progress.
|
||||
while (_glThreadQueue.TryDequeue(out var action)) {
|
||||
try {
|
||||
action(GL);
|
||||
|
|
@ -61,6 +87,7 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public uint WrapSampler { get; private set; }
|
||||
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
|
||||
public uint ClampSampler { get; private set; }
|
||||
internal float MaxSupportedAnisotropy { get; private set; }
|
||||
|
||||
private ManagedGLUniformBuffer? _sceneDataBuffer;
|
||||
/// <summary>Shared SceneData UBO.</summary>
|
||||
|
|
@ -83,12 +110,23 @@ namespace AcDream.App.Rendering.Wb {
|
|||
protected OpenGLGraphicsDevice() : base() {
|
||||
_log = null!;
|
||||
_renderSettings = null!;
|
||||
_resourceRetirement = null!;
|
||||
GL = null!;
|
||||
}
|
||||
|
||||
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) : base() {
|
||||
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true)
|
||||
: this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) {
|
||||
}
|
||||
|
||||
internal OpenGLGraphicsDevice(
|
||||
GL gl,
|
||||
ILogger log,
|
||||
DebugRenderSettings renderSettings,
|
||||
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
|
||||
bool allowBindless = true) : base() {
|
||||
_log = log;
|
||||
_renderSettings = renderSettings;
|
||||
_resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement));
|
||||
|
||||
GL = gl;
|
||||
GLHelpers.Init(this, log);
|
||||
|
|
@ -114,26 +152,30 @@ namespace AcDream.App.Rendering.Wb {
|
|||
GL.GenBuffers(1, out uint instanceVbo);
|
||||
InstanceVBO = instanceVbo;
|
||||
|
||||
// Query this immutable device limit once. Atlas construction can
|
||||
// happen hundreds of times during portal streaming; repeating a
|
||||
// driver GetFloat for every texture serialized the upload burst.
|
||||
if (renderSettings.EnableAnisotropicFiltering) {
|
||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
|
||||
MaxSupportedAnisotropy = Math.Max(0f, maxAniso);
|
||||
}
|
||||
|
||||
// Create sampler objects for wrap vs clamp
|
||||
WrapSampler = GL.GenSampler();
|
||||
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
|
||||
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
|
||||
GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
|
||||
GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
|
||||
if (renderSettings.EnableAnisotropicFiltering) {
|
||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
|
||||
if (maxAniso > 0) GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
|
||||
}
|
||||
if (MaxSupportedAnisotropy > 0)
|
||||
GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
|
||||
|
||||
ClampSampler = GL.GenSampler();
|
||||
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
|
||||
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
|
||||
GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
|
||||
GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
|
||||
if (renderSettings.EnableAnisotropicFiltering) {
|
||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
|
||||
if (maxAniso > 0) GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
|
||||
}
|
||||
if (MaxSupportedAnisotropy > 0)
|
||||
GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
|
||||
|
||||
_sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf<SceneData>());
|
||||
|
||||
|
|
@ -145,6 +187,15 @@ namespace AcDream.App.Rendering.Wb {
|
|||
ParticleBatcher = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retires a GL resource only after every submitted draw that could
|
||||
/// reference it has completed on the GPU.
|
||||
/// </summary>
|
||||
internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release);
|
||||
|
||||
internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement =>
|
||||
_resourceRetirement;
|
||||
|
||||
private void InitializeSharedDebugResources() {
|
||||
// Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness)
|
||||
float[] quadVertices = {
|
||||
|
|
@ -608,14 +659,26 @@ namespace AcDream.App.Rendering.Wb {
|
|||
GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride);
|
||||
}
|
||||
}
|
||||
if (wrapSampler != 0) {
|
||||
gl.DeleteSampler(wrapSampler);
|
||||
}
|
||||
if (clampSampler != 0) {
|
||||
gl.DeleteSampler(clampSampler);
|
||||
}
|
||||
});
|
||||
|
||||
// Bindless texture-array retirements embed these samplers in their
|
||||
// resident handles. Ordinary GL work must keep flowing when one
|
||||
// retry is sick, but sampler deletion itself is dependency-ordered
|
||||
// behind the retry queue. Requeue into the next generation (never
|
||||
// the drain-to-empty ordinary queue) to remain one attempt/frame.
|
||||
Action<GL>? deleteSamplersWhenSafe = null;
|
||||
deleteSamplersWhenSafe = gl => {
|
||||
if (!_nextGlThreadQueue.IsEmpty) {
|
||||
QueueGLActionForNextPass(deleteSamplersWhenSafe!);
|
||||
return;
|
||||
}
|
||||
if (wrapSampler != 0)
|
||||
gl.DeleteSampler(wrapSampler);
|
||||
if (clampSampler != 0)
|
||||
gl.DeleteSampler(clampSampler);
|
||||
};
|
||||
QueueGLActionForNextPass(deleteSamplersWhenSafe);
|
||||
|
||||
InstanceVBO = 0;
|
||||
InstanceVBOPtr = null;
|
||||
WrapSampler = 0;
|
||||
|
|
|
|||
|
|
@ -58,9 +58,11 @@ namespace AcDream.App.Rendering.Wb {
|
|||
|
||||
if (emitter.HwGfxObjId.DataId != 0) {
|
||||
_meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId);
|
||||
_meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false);
|
||||
}
|
||||
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
|
||||
_meshManager.IncrementRefCount(emitter.GfxObjId.DataId);
|
||||
_meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +71,12 @@ namespace AcDream.App.Rendering.Wb {
|
|||
if (_gfxRenderData == null) {
|
||||
var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId;
|
||||
if (gfxId != 0) {
|
||||
_meshManager.PrepareMeshDataAsync(gfxId, isSetup: false);
|
||||
_gfxRenderData = _meshManager.TryGetRenderData(gfxId);
|
||||
}
|
||||
}
|
||||
if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) {
|
||||
_meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false);
|
||||
_textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId);
|
||||
}
|
||||
|
||||
|
|
|
|||
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal file
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Per-resource progress for a teardown which must attempt every independent
|
||||
/// release even when an earlier release fails. Successful operations are never
|
||||
/// replayed. A backend which throws after changing ownership reports that fact
|
||||
/// with <see cref="MeshReferenceMutationException.MutationCommitted"/> so the
|
||||
/// ledger can preserve the physical outcome while still surfacing the error.
|
||||
/// </summary>
|
||||
internal sealed class RetryableResourceReleaseLedger
|
||||
{
|
||||
private readonly Entry[] _entries;
|
||||
private int _remaining;
|
||||
private bool _advancing;
|
||||
|
||||
public RetryableResourceReleaseLedger(IEnumerable<(string Name, Action Release)> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
_entries = entries
|
||||
.Select(entry => new Entry(entry.Name, entry.Release))
|
||||
.ToArray();
|
||||
_remaining = _entries.Length;
|
||||
}
|
||||
|
||||
public bool IsComplete => _remaining == 0;
|
||||
public int RemainingCount => _remaining;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts every unfinished operation once. Ordinary exceptions retain
|
||||
/// the operation for a later attempt; committed-outcome exceptions mark it
|
||||
/// complete. Either kind remains in the returned diagnostics.
|
||||
/// </summary>
|
||||
public ResourceReleaseAttempt Advance()
|
||||
{
|
||||
// Release callbacks may synchronously drain their owner. The active
|
||||
// top-level pass already owns every unfinished entry; a nested pass
|
||||
// must not give a failed later entry a second attempt in this frame.
|
||||
if (_advancing)
|
||||
return new ResourceReleaseAttempt(0, 0, []);
|
||||
|
||||
_advancing = true;
|
||||
List<ResourceReleaseFailure>? failures = null;
|
||||
int attempted = 0;
|
||||
int completed = 0;
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < _entries.Length; i++)
|
||||
{
|
||||
Entry entry = _entries[i];
|
||||
if (entry.Completed || entry.Running)
|
||||
continue;
|
||||
|
||||
attempted++;
|
||||
entry.Running = true;
|
||||
try
|
||||
{
|
||||
entry.Release();
|
||||
entry.Completed = true;
|
||||
_remaining--;
|
||||
completed++;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
bool committed = error is MeshReferenceMutationException
|
||||
{
|
||||
MutationCommitted: true,
|
||||
};
|
||||
if (committed)
|
||||
{
|
||||
entry.Completed = true;
|
||||
_remaining--;
|
||||
completed++;
|
||||
}
|
||||
|
||||
(failures ??= []).Add(
|
||||
new ResourceReleaseFailure(entry.Name, error, committed));
|
||||
}
|
||||
finally
|
||||
{
|
||||
entry.Running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_advancing = false;
|
||||
}
|
||||
|
||||
return new ResourceReleaseAttempt(
|
||||
attempted,
|
||||
completed,
|
||||
failures ?? []);
|
||||
}
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public Entry(string name, Action release)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("A resource-release stage needs a name.", nameof(name));
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
Name = name;
|
||||
Release = release;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public Action Release { get; }
|
||||
public bool Completed { get; set; }
|
||||
public bool Running { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct ResourceReleaseFailure(
|
||||
string Stage,
|
||||
Exception Error,
|
||||
bool MutationCommitted);
|
||||
|
||||
internal readonly record struct ResourceReleaseAttempt(
|
||||
int AttemptedCount,
|
||||
int CompletedCount,
|
||||
IReadOnlyList<ResourceReleaseFailure> Failures)
|
||||
{
|
||||
public bool HasFailures => Failures.Count != 0;
|
||||
|
||||
public AggregateException ToException(string message) =>
|
||||
new(
|
||||
message,
|
||||
Failures.Select(failure =>
|
||||
new InvalidOperationException(
|
||||
$"Resource-release stage '{failure.Stage}' failed "
|
||||
+ (failure.MutationCommitted
|
||||
? "after committing its mutation."
|
||||
: "before committing its mutation."),
|
||||
failure.Error)));
|
||||
}
|
||||
|
|
@ -6,8 +6,38 @@ using Silk.NET.OpenGL;
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb {
|
||||
internal sealed class TextureAtlasDisposeTransaction {
|
||||
private bool _running;
|
||||
|
||||
public bool IsComplete { get; private set; }
|
||||
public bool IsRunning => _running;
|
||||
|
||||
public void Advance(
|
||||
Action retryLayerRetirements,
|
||||
Action disposeTextureArray,
|
||||
Action commitLogicalDisposal) {
|
||||
ArgumentNullException.ThrowIfNull(retryLayerRetirements);
|
||||
ArgumentNullException.ThrowIfNull(disposeTextureArray);
|
||||
ArgumentNullException.ThrowIfNull(commitLogicalDisposal);
|
||||
if (IsComplete || _running)
|
||||
return;
|
||||
|
||||
_running = true;
|
||||
try {
|
||||
retryLayerRetirements();
|
||||
disposeTextureArray();
|
||||
commitLogicalDisposal();
|
||||
IsComplete = true;
|
||||
}
|
||||
finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages texture arrays grouped by (Width, Height, Format).
|
||||
/// Deduplicates textures by a TextureKey and supports reference counting.
|
||||
|
|
@ -20,41 +50,99 @@ namespace AcDream.App.Rendering.Wb {
|
|||
private readonly TextureFormat _format;
|
||||
private readonly Dictionary<TextureKey, int> _textureIndices = new();
|
||||
private readonly Dictionary<int, int> _refCounts = new();
|
||||
private readonly Stack<int> _freeSlots = new();
|
||||
private int _nextIndex = 0;
|
||||
private const int InitialCapacity = 32;
|
||||
private readonly TextureAtlasSlotAllocator _slots;
|
||||
private readonly TextureAtlasLayerRetirement _layerRetirement;
|
||||
private readonly TextureAtlasDisposeTransaction _disposeTransaction = new();
|
||||
private readonly Action<TextureAtlasManager>? _onGpuSafeEmpty;
|
||||
private bool _disposed;
|
||||
internal const long TargetArrayBytes = 8L * 1024 * 1024;
|
||||
internal const int MaximumArrayLayers = 32;
|
||||
|
||||
public uint Slot { get; }
|
||||
public ManagedGLTextureArray TextureArray { get; private set; } = null!;
|
||||
public int UsedSlots => _textureIndices.Count;
|
||||
public int TotalSlots => TextureArray?.Size ?? InitialCapacity;
|
||||
public int FreeSlots => TotalSlots - UsedSlots;
|
||||
public int TotalSlots => TextureArray?.Size ?? 0;
|
||||
public int AvailableSlots => _slots.AvailableCount;
|
||||
internal bool IsGpuSafeEmpty => UsedSlots == 0 && AvailableSlots == TotalSlots;
|
||||
internal long AllocatedBytes {
|
||||
get {
|
||||
return TextureArray.TotalSizeInBytes;
|
||||
}
|
||||
}
|
||||
internal long LastUseSequence { get; set; }
|
||||
internal int Width => _textureWidth;
|
||||
internal int Height => _textureHeight;
|
||||
internal TextureFormat Format => _format;
|
||||
|
||||
public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) {
|
||||
public TextureAtlasManager(
|
||||
OpenGLGraphicsDevice graphicsDevice,
|
||||
int width,
|
||||
int height,
|
||||
TextureFormat format = TextureFormat.RGBA8,
|
||||
Action<TextureAtlasManager>? onGpuSafeEmpty = null) {
|
||||
Slot = _nextSlot++;
|
||||
_graphicsDevice = graphicsDevice;
|
||||
_textureWidth = width;
|
||||
_textureHeight = height;
|
||||
_format = format;
|
||||
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge);
|
||||
_onGpuSafeEmpty = onGpuSafeEmpty;
|
||||
_layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement);
|
||||
int capacity = CalculateInitialCapacity(width, height, format);
|
||||
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge);
|
||||
_slots = new TextureAtlasSlotAllocator(TextureArray.Size);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps each physical array near an eight-MiB target instead of
|
||||
/// reserving 32 layers for every size class. The old fixed capacity
|
||||
/// made a single 1024x1024 RGBA texture allocate roughly 171 MiB once
|
||||
/// its mip chain was included, and made glGenerateMipmap process all
|
||||
/// 32 layers during destination streaming.
|
||||
/// </summary>
|
||||
internal static int CalculateInitialCapacity(int width, int height, TextureFormat format) {
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
|
||||
|
||||
long bytesPerLayer = CalculateMipChainBytes(width, height, format);
|
||||
long targetLayers = Math.Max(1L, TargetArrayBytes / bytesPerLayer);
|
||||
return checked((int)Math.Min(targetLayers, MaximumArrayLayers));
|
||||
}
|
||||
|
||||
internal static long CalculateMipChainBytes(int width, int height, TextureFormat format) {
|
||||
long total = 0;
|
||||
int w = width;
|
||||
int h = height;
|
||||
while (true) {
|
||||
total = checked(total + CalculateLevelBytes(w, h, format));
|
||||
if (w == 1 && h == 1) return total;
|
||||
w = Math.Max(1, w >> 1);
|
||||
h = Math.Max(1, h >> 1);
|
||||
}
|
||||
}
|
||||
|
||||
internal static long CalculateArrayBytes(int width, int height, TextureFormat format) =>
|
||||
checked(CalculateMipChainBytes(width, height, format)
|
||||
* CalculateInitialCapacity(width, height, format));
|
||||
|
||||
internal static long CalculateLevelBytes(int width, int height, TextureFormat format) => format switch {
|
||||
TextureFormat.RGBA8 => checked((long)width * height * 4L),
|
||||
TextureFormat.RGB8 => checked((long)width * height * 3L),
|
||||
TextureFormat.A8 => checked((long)width * height),
|
||||
TextureFormat.Rgba32f => checked((long)width * height * 16L),
|
||||
TextureFormat.DXT1 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8L),
|
||||
TextureFormat.DXT3 or TextureFormat.DXT5 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 16L),
|
||||
_ => throw new NotSupportedException($"Unsupported texture-atlas format {format}.")
|
||||
};
|
||||
|
||||
public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) {
|
||||
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
|
||||
_layerRetirement.RetryPendingPublications();
|
||||
if (_textureIndices.TryGetValue(key, out var existingIndex)) {
|
||||
_refCounts[existingIndex]++;
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
int index;
|
||||
if (_freeSlots.Count > 0) {
|
||||
index = _freeSlots.Pop();
|
||||
}
|
||||
else {
|
||||
index = _nextIndex++;
|
||||
if (index >= TextureArray.Size) {
|
||||
throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used.");
|
||||
}
|
||||
}
|
||||
int index = _slots.Rent();
|
||||
|
||||
try {
|
||||
TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType);
|
||||
|
|
@ -63,14 +151,15 @@ namespace AcDream.App.Rendering.Wb {
|
|||
return index;
|
||||
}
|
||||
catch (Exception) {
|
||||
if (!_textureIndices.ContainsKey(key)) {
|
||||
_freeSlots.Push(index);
|
||||
}
|
||||
if (!_textureIndices.ContainsKey(key))
|
||||
_slots.Return(index);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReleaseTexture(TextureKey key) {
|
||||
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
|
||||
_layerRetirement.RetryPendingPublications();
|
||||
if (!_textureIndices.TryGetValue(key, out var index)) return;
|
||||
|
||||
if (!_refCounts.ContainsKey(index)) return;
|
||||
|
|
@ -79,21 +168,74 @@ namespace AcDream.App.Rendering.Wb {
|
|||
if (_refCounts[index] <= 0) {
|
||||
_textureIndices.Remove(key);
|
||||
_refCounts.Remove(index);
|
||||
_freeSlots.Push(index);
|
||||
TextureArray?.RemoveLayer(index);
|
||||
// The CPU no longer references this layer, but previously
|
||||
// submitted draws may still sample it. Recycle the slot only
|
||||
// after their GPU fence has signaled; no clear or whole-array
|
||||
// mip regeneration is needed for an unreferenced layer.
|
||||
_layerRetirement.Retire(
|
||||
() => {
|
||||
if (!_disposed)
|
||||
_slots.Return(index);
|
||||
},
|
||||
() => {
|
||||
if (!_disposed && IsGpuSafeEmpty)
|
||||
_onGpuSafeEmpty?.Invoke(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal void RetryPendingRetirements() =>
|
||||
_layerRetirement.RetryPendingPublications();
|
||||
|
||||
public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key);
|
||||
|
||||
public int GetTextureIndex(TextureKey key) =>
|
||||
_textureIndices.TryGetValue(key, out var index) ? index : -1;
|
||||
|
||||
public void Dispose() {
|
||||
TextureArray?.Dispose();
|
||||
_textureIndices.Clear();
|
||||
_refCounts.Clear();
|
||||
_freeSlots.Clear();
|
||||
if (_disposed) return;
|
||||
_disposeTransaction.Advance(
|
||||
_layerRetirement.RetryPendingPublications,
|
||||
() => {
|
||||
TextureArray?.Dispose();
|
||||
if (TextureArray is not null && !TextureArray.HasDurableDisposeOwnership)
|
||||
throw new InvalidOperationException(
|
||||
"Texture-array disposal returned without retaining or publishing its physical release.");
|
||||
},
|
||||
() => {
|
||||
_textureIndices.Clear();
|
||||
_refCounts.Clear();
|
||||
_disposed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the publication and callback cursor for returned atlas layers.
|
||||
/// Removing the logical texture key commits immediately; this owner keeps
|
||||
/// the physical layer reachable until the frame queue accepts it and keeps
|
||||
/// the empty-atlas observer separate from the slot return so it cannot make
|
||||
/// a callback retry return the same slot twice.
|
||||
/// </summary>
|
||||
internal sealed class TextureAtlasLayerRetirement
|
||||
{
|
||||
private readonly GpuRetirementLedger _ledger;
|
||||
|
||||
public TextureAtlasLayerRetirement(IGpuResourceRetirementQueue queue) =>
|
||||
_ledger = new GpuRetirementLedger(queue);
|
||||
|
||||
internal int AwaitingPublicationCount => _ledger.AwaitingPublicationCount;
|
||||
|
||||
public void Retire(Action returnLayer, Action notifyGpuSafeEmpty)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(returnLayer);
|
||||
ArgumentNullException.ThrowIfNull(notifyGpuSafeEmpty);
|
||||
_ledger.Retire(new RetryableGpuResourceRelease(
|
||||
returnLayer,
|
||||
notifyGpuSafeEmpty));
|
||||
}
|
||||
|
||||
public void RetryPendingPublications() =>
|
||||
_ledger.RetryPendingPublications();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal file
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the physical layer slots in one texture array. A released texture
|
||||
/// remains rented until the GPU-retirement callback returns its layer, so
|
||||
/// <see cref="AvailableCount"/> never advertises a CPU-unreferenced layer
|
||||
/// that an in-flight draw may still sample.
|
||||
/// </summary>
|
||||
internal sealed class TextureAtlasSlotAllocator
|
||||
{
|
||||
private readonly bool[] _rented;
|
||||
private readonly Stack<int> _returned = new();
|
||||
private int _next;
|
||||
|
||||
public TextureAtlasSlotAllocator(int capacity)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_rented = new bool[capacity];
|
||||
}
|
||||
|
||||
public int AvailableCount => _returned.Count + (_rented.Length - _next);
|
||||
|
||||
public int Rent()
|
||||
{
|
||||
int slot;
|
||||
if (_returned.Count != 0)
|
||||
{
|
||||
slot = _returned.Pop();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_next == _rented.Length)
|
||||
throw new InvalidOperationException(
|
||||
$"Texture atlas has no GPU-safe layer available ({_rented.Length} layers)."
|
||||
);
|
||||
slot = _next++;
|
||||
}
|
||||
|
||||
if (_rented[slot])
|
||||
throw new InvalidOperationException($"Texture atlas layer {slot} was rented twice.");
|
||||
_rented[slot] = true;
|
||||
return slot;
|
||||
}
|
||||
|
||||
public void Return(int slot)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(slot);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(slot, _next);
|
||||
if (!_rented[slot])
|
||||
throw new InvalidOperationException($"Texture atlas layer {slot} was returned twice.");
|
||||
|
||||
_rented[slot] = false;
|
||||
_returned.Push(slot);
|
||||
}
|
||||
}
|
||||
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal file
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
using Silk.NET.OpenGL;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Always-on transaction boundary and accounting for raw dynamic GL objects.
|
||||
/// Growth keeps the published CPU capacity unchanged until BufferData succeeds;
|
||||
/// OpenGL leaves the previous data store intact when allocation reports an
|
||||
/// error, so the caller can continue using the old capacity or unwind.
|
||||
/// </summary>
|
||||
internal static unsafe class TrackedGlResource
|
||||
{
|
||||
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
|
||||
GL gl,
|
||||
uint buffer,
|
||||
long capacityBytes,
|
||||
string context)
|
||||
{
|
||||
if (buffer == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(buffer));
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
|
||||
return new RetryableGpuResourceRelease(
|
||||
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
|
||||
() =>
|
||||
{
|
||||
gl.DeleteBuffer(buffer);
|
||||
// Per the GL error contract, a command which generates an
|
||||
// error does not change object state. Keep mutation and its
|
||||
// validation in one retryable stage so that failed deletion
|
||||
// is issued again, while later accounting remains untouched.
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (capacityBytes != 0)
|
||||
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
|
||||
},
|
||||
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
|
||||
}
|
||||
|
||||
public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion(
|
||||
GL gl,
|
||||
uint vertexArray,
|
||||
string context,
|
||||
Action? trackDeallocation = null)
|
||||
{
|
||||
if (vertexArray == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(vertexArray));
|
||||
return new RetryableGpuResourceRelease(
|
||||
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
|
||||
() =>
|
||||
{
|
||||
gl.DeleteVertexArray(vertexArray);
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
},
|
||||
trackDeallocation
|
||||
?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO)));
|
||||
}
|
||||
|
||||
public static void AllocateBufferStorage(
|
||||
GL gl,
|
||||
BufferTargetARB target,
|
||||
uint buffer,
|
||||
long previousBytes,
|
||||
long newBytes,
|
||||
BufferUsageARB usage,
|
||||
string context)
|
||||
=> AllocateBufferStorage(
|
||||
gl,
|
||||
(GLEnum)target,
|
||||
buffer,
|
||||
previousBytes,
|
||||
newBytes,
|
||||
(GLEnum)usage,
|
||||
context);
|
||||
|
||||
public static void AllocateBufferStorage(
|
||||
GL gl,
|
||||
BufferTargetARB target,
|
||||
uint buffer,
|
||||
long previousBytes,
|
||||
long newBytes,
|
||||
BufferUsageARB usage,
|
||||
void* data,
|
||||
string context)
|
||||
=> AllocateBufferStorage(
|
||||
gl,
|
||||
(GLEnum)target,
|
||||
buffer,
|
||||
previousBytes,
|
||||
newBytes,
|
||||
(GLEnum)usage,
|
||||
data,
|
||||
context);
|
||||
|
||||
public static uint CreateBuffer(GL gl, string context)
|
||||
{
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
uint name = gl.GenBuffer();
|
||||
try
|
||||
{
|
||||
if (name == 0)
|
||||
throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}");
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
||||
return name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (name != 0)
|
||||
gl.DeleteBuffer(name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static uint CreateVertexArray(GL gl, string context)
|
||||
{
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
uint name = gl.GenVertexArray();
|
||||
try
|
||||
{
|
||||
if (name == 0)
|
||||
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
|
||||
return name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (name != 0)
|
||||
gl.DeleteVertexArray(name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static void AllocateBufferStorage(
|
||||
GL gl,
|
||||
GLEnum target,
|
||||
uint buffer,
|
||||
long previousBytes,
|
||||
long newBytes,
|
||||
GLEnum usage,
|
||||
string context)
|
||||
=> AllocateBufferStorage(
|
||||
gl,
|
||||
target,
|
||||
buffer,
|
||||
previousBytes,
|
||||
newBytes,
|
||||
usage,
|
||||
null,
|
||||
context);
|
||||
|
||||
public static void AllocateBufferStorage(
|
||||
GL gl,
|
||||
GLEnum target,
|
||||
uint buffer,
|
||||
long previousBytes,
|
||||
long newBytes,
|
||||
GLEnum usage,
|
||||
void* data,
|
||||
string context)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(previousBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1);
|
||||
if (buffer == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(buffer));
|
||||
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
gl.BindBuffer(target, buffer);
|
||||
gl.BufferData(target, checked((nuint)newBytes), data, usage);
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
|
||||
long delta = checked(newBytes - previousBytes);
|
||||
if (delta > 0)
|
||||
GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer);
|
||||
else if (delta < 0)
|
||||
GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer);
|
||||
}
|
||||
|
||||
public static void UpdateBufferSubData(
|
||||
GL gl,
|
||||
BufferTargetARB target,
|
||||
uint buffer,
|
||||
nint byteOffset,
|
||||
long byteCount,
|
||||
void* data,
|
||||
string context)
|
||||
=> UpdateBufferSubData(
|
||||
gl,
|
||||
(GLEnum)target,
|
||||
buffer,
|
||||
byteOffset,
|
||||
byteCount,
|
||||
data,
|
||||
context);
|
||||
|
||||
public static void UpdateBufferSubData(
|
||||
GL gl,
|
||||
GLEnum target,
|
||||
uint buffer,
|
||||
nint byteOffset,
|
||||
long byteCount,
|
||||
void* data,
|
||||
string context)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(byteOffset);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1);
|
||||
if (buffer == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(buffer));
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
gl.BindBuffer(target, buffer);
|
||||
gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data);
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
}
|
||||
|
||||
public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context)
|
||||
{
|
||||
if (buffer == 0)
|
||||
return;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
gl.DeleteBuffer(buffer);
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
if (capacityBytes != 0)
|
||||
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
|
||||
}
|
||||
|
||||
public static void DeleteVertexArray(GL gl, uint vertexArray, string context)
|
||||
{
|
||||
if (vertexArray == 0)
|
||||
return;
|
||||
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
|
||||
gl.DeleteVertexArray(vertexArray);
|
||||
GLHelpers.ThrowOnResourceError(gl, context);
|
||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -17,18 +17,56 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// so the rest of the renderer doesn't need to know about WB's types directly.
|
||||
///
|
||||
/// <para>
|
||||
/// As of Phase O-T7, all DAT I/O routes through <see cref="DatCollectionAdapter"/>
|
||||
/// (backed by our shared <see cref="DatCollection"/>) — the separate
|
||||
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
|
||||
/// <see cref="IDatReaderWriter"/> facade — the separate
|
||||
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
||||
{
|
||||
internal const int MaximumUploadsPerFrame = 8;
|
||||
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
|
||||
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
|
||||
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
|
||||
internal const int MaximumNewArraysPerFrame = 1;
|
||||
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
|
||||
// A GL buffer store is indivisible. The active vertex arena's explicit
|
||||
// supported ceiling is therefore the truthful one-allocation bound; only
|
||||
// its prefix copy is staged across frames.
|
||||
internal const long MaximumBufferAllocationBytesPerFrame =
|
||||
GlobalMeshBuffer.MaximumVertexBufferBytes;
|
||||
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
|
||||
internal const int MaximumNewBuffersPerFrame = 1;
|
||||
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
|
||||
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
|
||||
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
|
||||
internal const int MaximumSingleNewArrays = 16;
|
||||
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
|
||||
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
|
||||
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
|
||||
internal const int MaximumStaleDiscardsPerFrame = 64;
|
||||
private readonly OpenGLGraphicsDevice? _graphicsDevice;
|
||||
private readonly ObjectMeshManager? _meshManager;
|
||||
private readonly DatCollection? _dats;
|
||||
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
|
||||
private readonly IDatReaderWriter? _dats;
|
||||
private readonly AcSurfaceMetadataTable _metadataTable = new();
|
||||
private readonly HashSet<ulong> _metadataPopulated = new();
|
||||
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
|
||||
MaximumUploadsPerFrame,
|
||||
MaximumUploadBytesPerFrame,
|
||||
MaximumArrayAllocationBytesPerFrame,
|
||||
MaximumMipmapBytesPerFrame,
|
||||
MaximumNewArraysPerFrame,
|
||||
MaximumBufferUploadBytesPerFrame,
|
||||
MaximumBufferAllocationBytesPerFrame,
|
||||
MaximumBufferCopyBytesPerFrame,
|
||||
MaximumNewBuffersPerFrame,
|
||||
MaximumSingleUploadBytes,
|
||||
MaximumSingleArrayAllocationBytes,
|
||||
MaximumSingleMipmapBytes,
|
||||
MaximumSingleNewArrays,
|
||||
MaximumSingleBufferUploadBytes));
|
||||
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when this instance was created via <see cref="CreateUninitialized"/>;
|
||||
|
|
@ -37,32 +75,64 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
private readonly bool _isUninitialized;
|
||||
|
||||
private bool _disposed;
|
||||
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
|
||||
internal int LastUploadCount { get; private set; }
|
||||
internal long LastUploadBytes { get; private set; }
|
||||
internal int LastStaleDiscardCount { get; private set; }
|
||||
internal long LastArrayAllocationBytes { get; private set; }
|
||||
internal long LastPlannedMipmapBytes { get; private set; }
|
||||
internal int LastNewArrayCount { get; private set; }
|
||||
internal long LastBufferUploadBytes { get; private set; }
|
||||
internal long LastBufferAllocationBytes { get; private set; }
|
||||
internal long LastBufferCopyBytes { get; private set; }
|
||||
internal int LastNewBufferCount { get; private set; }
|
||||
internal int LastMipmapArrayCount { get; private set; }
|
||||
internal long LastMipmapBytes { get; private set; }
|
||||
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
|
||||
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
|
||||
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
|
||||
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
|
||||
_meshManager?.CpuCacheDiagnostics ?? default;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter
|
||||
/// → ObjectMeshManager.
|
||||
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
|
||||
/// DAT facade → ObjectMeshManager.
|
||||
/// </summary>
|
||||
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
|
||||
/// thread (construction runs GL queries; call from OnLoad).</param>
|
||||
/// <param name="dats">acdream's DatCollection, used to populate the surface
|
||||
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
|
||||
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
|
||||
/// the rest of the client; read-only access from the render thread.</param>
|
||||
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
|
||||
/// NullLogger internally.</param>
|
||||
public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger)
|
||||
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
|
||||
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
internal WbMeshAdapter(
|
||||
GL gl,
|
||||
IDatReaderWriter dats,
|
||||
ILogger<WbMeshAdapter> logger,
|
||||
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_dats = dats;
|
||||
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
|
||||
_resourceRetirement = resourceRetirement;
|
||||
_graphicsDevice = new OpenGLGraphicsDevice(
|
||||
gl,
|
||||
logger,
|
||||
new DebugRenderSettings(),
|
||||
resourceRetirement);
|
||||
_graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice);
|
||||
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
|
||||
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
|
||||
_meshManager = new ObjectMeshManager(
|
||||
_graphicsDevice,
|
||||
new DatCollectionAdapter(dats),
|
||||
dats,
|
||||
new ConsoleErrorLogger<ObjectMeshManager>());
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +227,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
// An uninitialized adapter owns no render pipeline, so it must not
|
||||
// manufacture a readiness wait that can never complete. The modern
|
||||
// production path is always initialized at startup.
|
||||
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null;
|
||||
return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
|
@ -166,9 +236,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
if (_isUninitialized || _meshManager is null) return;
|
||||
_meshManager.IncrementRefCount(id);
|
||||
|
||||
bool firstEver = _metadataPopulated.Add(id);
|
||||
if (firstEver)
|
||||
PopulateMetadata(id);
|
||||
bool metadataClaimed = false;
|
||||
try
|
||||
{
|
||||
metadataClaimed = _metadataPopulated.Add(id);
|
||||
if (metadataClaimed)
|
||||
PopulateMetadata(id);
|
||||
|
||||
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
||||
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
|
||||
|
|
@ -197,8 +270,37 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
// isSetup: false — acdream's MeshRefs already carry expanded
|
||||
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
||||
// unused.
|
||||
if (firstEver || _meshManager.TryGetRenderData(id) is null)
|
||||
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
|
||||
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
}
|
||||
catch (Exception acquireFailure)
|
||||
{
|
||||
if (metadataClaimed)
|
||||
{
|
||||
_metadataPopulated.Remove(id);
|
||||
_metadataTable.Remove(id);
|
||||
}
|
||||
|
||||
// IncrementRefCount is a public ownership boundary. Metadata/DAT
|
||||
// preparation happens after ObjectMeshManager commits its count,
|
||||
// so compensate before reporting failure. ObjectMeshManager's
|
||||
// decrement has a strong exception guarantee; if compensation
|
||||
// itself fails, expose that the increment remains committed so a
|
||||
// higher-level transactional owner can retain its held marker.
|
||||
try
|
||||
{
|
||||
_meshManager.DecrementRefCount(id);
|
||||
}
|
||||
catch (Exception rollbackFailure)
|
||||
{
|
||||
throw new MeshReferenceMutationException(
|
||||
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
|
||||
mutationCommitted: true,
|
||||
new AggregateException(acquireFailure, rollbackFailure));
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
|
@ -263,6 +365,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
if (_isUninitialized) return;
|
||||
if (_disposed) return;
|
||||
|
||||
ObjectMeshManager meshManager = _meshManager!;
|
||||
_graphicsDevice!.ProcessGLQueue();
|
||||
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
|
||||
// null from its catch) is re-staged for a LATER frame, not dropped. The
|
||||
|
|
@ -270,43 +373,190 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
// inside the while would let a deterministic failure spin the queue in a
|
||||
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
||||
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
||||
List<ObjectMeshData>? requeue = null;
|
||||
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
|
||||
List<MeshUploadQueueItem>? requeue = null;
|
||||
_uploadBudget.Reset();
|
||||
_mipmapsBudgeted.Clear();
|
||||
int staleDiscardCount = 0;
|
||||
bool arenaBackpressured = false;
|
||||
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
|
||||
|
||||
// A backing-store migration is real GL work, not a cost estimate. One
|
||||
// bounded copy chunk runs per frame while the old VAO remains active.
|
||||
// Uploads stay queued until the atomic final swap has completed.
|
||||
if (globalBuffer?.IsMigrationInProgress == true)
|
||||
{
|
||||
if (meshData is null)
|
||||
continue;
|
||||
if (_meshManager.UploadOrRequeue(meshData))
|
||||
GlobalMeshMaintenanceStep maintenance =
|
||||
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
|
||||
_uploadBudget.RecordBufferMaintenance(
|
||||
maintenance.AllocationBytes,
|
||||
maintenance.CopyBytes,
|
||||
maintenance.NewBufferCount);
|
||||
arenaBackpressured = !maintenance.Completed;
|
||||
}
|
||||
|
||||
while (globalBuffer?.IsMigrationInProgress != true
|
||||
&& _uploadBudget.BufferCopyBytes == 0
|
||||
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
|
||||
{
|
||||
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
|
||||
MaximumStaleDiscardsPerFrame - staleDiscardCount);
|
||||
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
|
||||
break;
|
||||
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
|
||||
break;
|
||||
|
||||
GlobalMeshCapacityResult capacity;
|
||||
GlobalMeshMaintenanceStep maintenance;
|
||||
try
|
||||
{
|
||||
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
|
||||
}
|
||||
catch (NotSupportedException error)
|
||||
{
|
||||
RejectUnsupportedHead(meshManager, next, error);
|
||||
throw;
|
||||
}
|
||||
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
|
||||
{
|
||||
_uploadBudget.RecordBufferMaintenance(
|
||||
maintenance.AllocationBytes,
|
||||
maintenance.CopyBytes,
|
||||
maintenance.NewBufferCount);
|
||||
arenaBackpressured = true;
|
||||
break;
|
||||
}
|
||||
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
|
||||
{
|
||||
arenaBackpressured = true;
|
||||
break;
|
||||
}
|
||||
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
|
||||
{
|
||||
// Do not evict another batch while the frame-fence queue is
|
||||
// already returning ranges or retiring an old backing store.
|
||||
// Waiting for real allocator state prevents three frames of
|
||||
// duplicate eviction before the first release becomes reusable.
|
||||
if (globalBuffer?.HasPendingReclamation != true)
|
||||
{
|
||||
var reclaimed = meshManager.ReclaimUnusedResources(
|
||||
MaximumReclaimedMeshesPerFrame,
|
||||
MaximumReclaimedMeshBytesPerFrame,
|
||||
forceArenaReclamation: true);
|
||||
if (reclaimed.Count == 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"The live global-mesh working set cannot fit the supported "
|
||||
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
|
||||
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
|
||||
}
|
||||
}
|
||||
arenaBackpressured = true;
|
||||
break;
|
||||
}
|
||||
|
||||
MeshUploadCost cost;
|
||||
try
|
||||
{
|
||||
cost = meshManager.PlanUploadCost(
|
||||
next.Data,
|
||||
_mipmapsBudgeted,
|
||||
next.Generation);
|
||||
if (!_uploadBudget.TryAdmit(cost))
|
||||
break;
|
||||
}
|
||||
catch (NotSupportedException error)
|
||||
{
|
||||
RejectUnsupportedHead(meshManager, next, error);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
|
||||
break;
|
||||
if (meshData.Generation != next.Generation)
|
||||
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
|
||||
if (meshManager.UploadOrRequeue(meshData))
|
||||
(requeue ??= new()).Add(meshData);
|
||||
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
|
||||
}
|
||||
if (requeue is not null)
|
||||
foreach (var m in requeue)
|
||||
_meshManager.RequeueStagedMeshData(m);
|
||||
meshManager.RequeueStagedMeshData(m);
|
||||
meshManager.SetArenaBackpressure(arenaBackpressured);
|
||||
|
||||
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
||||
var pendingBefore = texProbe
|
||||
? _meshManager.GetPendingTextureUpdateStats()
|
||||
? meshManager.GetPendingTextureUpdateStats()
|
||||
: default;
|
||||
|
||||
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
|
||||
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the
|
||||
// immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
|
||||
// actual TexSubImage3D copies + mipmap regeneration happen in
|
||||
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
|
||||
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
|
||||
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
|
||||
// extraction replaced with GameWindow, so the driver was silently dropped:
|
||||
// staged updates only ever reached the GPU as a side effect of PBO growth,
|
||||
// and every layer staged after an array's LAST growth kept undefined
|
||||
// TexStorage3D content behind a valid resident bindless handle — the
|
||||
// staged updates never reached TexSubImage3D, leaving undefined
|
||||
// TexStorage3D content behind valid resident bindless handles — the
|
||||
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
|
||||
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
|
||||
// runs before all draw passes (GameWindow OnRender), so this is the exact
|
||||
// WB-equivalent position.
|
||||
_meshManager.GenerateMipmaps();
|
||||
(LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
|
||||
if (globalBuffer?.HasPendingReclamation != true)
|
||||
{
|
||||
meshManager.ReclaimUnusedResources(
|
||||
MaximumReclaimedMeshesPerFrame,
|
||||
MaximumReclaimedMeshBytesPerFrame);
|
||||
}
|
||||
meshManager.EvictOneEmptyAtlas();
|
||||
|
||||
// Arena maintenance is capacity-driven and uses genuinely idle upload
|
||||
// frames. It never competes with destination materialization, and the
|
||||
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
|
||||
// revisits do not alternate shrink/grow every frame.
|
||||
if (_uploadBudget.ObjectCount == 0
|
||||
&& LastMipmapArrayCount == 0
|
||||
&& meshManager.StagedMeshCount == 0
|
||||
&& globalBuffer?.IsMigrationInProgress == false
|
||||
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
|
||||
{
|
||||
_uploadBudget.RecordBufferMaintenance(
|
||||
trim.AllocationBytes,
|
||||
trim.CopyBytes,
|
||||
trim.NewBufferCount);
|
||||
meshManager.SetArenaBackpressure(true);
|
||||
}
|
||||
|
||||
LastUploadCount = _uploadBudget.ObjectCount;
|
||||
LastUploadBytes = _uploadBudget.SourceBytes;
|
||||
LastStaleDiscardCount = staleDiscardCount;
|
||||
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
|
||||
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
|
||||
LastNewArrayCount = _uploadBudget.NewArrayCount;
|
||||
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
|
||||
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
|
||||
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
|
||||
LastNewBufferCount = _uploadBudget.NewBufferCount;
|
||||
|
||||
if (texProbe)
|
||||
EmitTexFlushProbe(pendingBefore);
|
||||
}
|
||||
|
||||
private static void RejectUnsupportedHead(
|
||||
ObjectMeshManager meshManager,
|
||||
MeshUploadQueueItem expected,
|
||||
NotSupportedException error)
|
||||
{
|
||||
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|
||||
|| rejected.Generation != expected.Generation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
|
||||
error);
|
||||
}
|
||||
meshManager.RejectUnsupportedStagedUpload(rejected, error);
|
||||
}
|
||||
|
||||
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
|
||||
private int _lastTexFlushBefore = -1;
|
||||
private int _texFlushHeartbeat;
|
||||
|
|
@ -351,9 +601,46 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_meshManager?.Dispose();
|
||||
_graphicsDevice?.Dispose();
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// These are dependency edges, not merely a best-effort cleanup list.
|
||||
// In particular, the sampler objects owned by the device must remain
|
||||
// alive until every resident texture-array handle has been retired.
|
||||
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
|
||||
() =>
|
||||
{
|
||||
// The current global arena is still directly owned by the mesh
|
||||
// manager. Fence every submitted draw before manager teardown
|
||||
// can delete that arena's VAO/VBO/IBO.
|
||||
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
||||
frameFlights.WaitForSubmittedWork();
|
||||
},
|
||||
() => _meshManager?.Dispose(),
|
||||
() => DrainGraphicsQueue("publishing mesh resource retirements"),
|
||||
() =>
|
||||
{
|
||||
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
||||
frameFlights.WaitForSubmittedWork();
|
||||
},
|
||||
() => DrainGraphicsQueue("releasing retired mesh resources"),
|
||||
() => _graphicsDevice?.Dispose(),
|
||||
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
|
||||
|
||||
_teardown.Advance();
|
||||
_disposed = _teardown.IsComplete;
|
||||
}
|
||||
|
||||
private void DrainGraphicsQueue(string operation)
|
||||
{
|
||||
if (_graphicsDevice is null)
|
||||
return;
|
||||
|
||||
_graphicsDevice.ProcessGLQueue();
|
||||
if (_graphicsDevice.HasPendingGLWork)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal file
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
internal enum WbTextureResolutionKind : byte
|
||||
{
|
||||
SharedAtlas,
|
||||
OriginalTextureOverride,
|
||||
PaletteComposite,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the retail texture ownership path for one rendered surface.
|
||||
/// Retail creates a palette-combined <c>ImgTex</c> only for indexed source
|
||||
/// images; ordinary surfaces continue to reference their shared image.
|
||||
/// </summary>
|
||||
internal static class WbTextureResolutionPolicy
|
||||
{
|
||||
public static WbTextureResolutionKind Select(
|
||||
bool hasOriginalTextureOverride,
|
||||
bool hasPaletteOverride,
|
||||
bool sourceIsPaletteIndexed)
|
||||
{
|
||||
if (hasPaletteOverride && sourceIsPaletteIndexed)
|
||||
return WbTextureResolutionKind.PaletteComposite;
|
||||
|
||||
return hasOriginalTextureOverride
|
||||
? WbTextureResolutionKind.OriginalTextureOverride
|
||||
: WbTextureResolutionKind.SharedAtlas;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue